Skip to content

ai dev server status #5908

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 120 additions & 0 deletions packages/app/src/cli/commands/app/execute.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import {appFlags} from '../../flags.js'
import {showApiKeyDeprecationWarning} from '../../prompts/deprecation-warnings.js'
import {checkFolderIsValidApp} from '../../models/app/loader.js'
import AppCommand, {AppCommandOutput} from '../../utilities/app-command.js'
import {linkedAppContext} from '../../services/app-context.js'
import {storeContext} from '../../services/store-context.js'
import {Flags} from '@oclif/core'
import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn'
import {globalFlags} from '@shopify/cli-kit/node/cli'
import {ensureAuthenticatedAdminAsApp} from '@shopify/cli-kit/node/session'
import {adminAsAppRequest} from '@shopify/cli-kit/node/api/admin-as-app'
import {outputContent, outputResult} from '@shopify/cli-kit/node/output'

export default class Execute extends AppCommand {
static summary = 'Execute a Shopify GraphQL Admin API query'

static flags = {
...globalFlags,
...appFlags,
'api-key': Flags.string({
hidden: true,
description: 'The API key of your app.',
env: 'SHOPIFY_FLAG_APP_API_KEY',
exclusive: ['config'],
}),
store: Flags.string({
char: 's',
description: 'Store URL. Must be an existing development or Shopify Plus sandbox store.',
env: 'SHOPIFY_FLAG_STORE',
parse: async (input) => normalizeStoreFqdn(input),
}),
query: Flags.string({
char: 'q',
description: 'The GraphQL query to execute.',
env: 'SHOPIFY_FLAG_QUERY',
required: true,
allowStdin: true,
}),
variables: Flags.string({
description:
'The GraphQL variables to pass to the query. Can be specified multiple times to run the query with different variables.',
env: 'SHOPIFY_FLAG_VARIABLES',
multiple: true,
}),
'api-version': Flags.string({
description: 'The API version to use.',
env: 'SHOPIFY_FLAG_API_VERSION',
default: '2025-01',
}),
'allow-mutation': Flags.boolean({
description:
'Allow the query to be a mutation. If you are an automated tool using this option, you MUST confirm with the user before taking action',
env: 'SHOPIFY_FLAG_ALLOW_MUTATION',
default: false,
}),
}

public static analyticsStopCommand(): string | undefined {
return 'app dev stop'
}

public async run(): Promise<AppCommandOutput> {
const {flags} = await this.parse(Execute)

if (!flags['api-key'] && process.env.SHOPIFY_API_KEY) {
flags['api-key'] = process.env.SHOPIFY_API_KEY
}
if (flags['api-key']) {
await showApiKeyDeprecationWarning()
}

await checkFolderIsValidApp(flags.path)

const appContextResult = await linkedAppContext({
directory: flags.path,
clientId: flags['client-id'] ?? flags['api-key'],
forceRelink: flags.reset,
userProvidedConfigName: flags.config,
})
const store = await storeContext({
appContextResult,
storeFqdn: flags.store,
forceReselectStore: flags.reset,
})

const session = await ensureAuthenticatedAdminAsApp(
store.shopDomain,
appContextResult.remoteApp.apiKey,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
appContextResult.remoteApp.apiSecretKeys[0]!.secret,
)

const variablesAsObjects = flags.variables ? flags.variables.map((variable) => JSON.parse(variable)) : [undefined]

let i = 0
for (const variable of variablesAsObjects) {
// eslint-disable-next-line no-await-in-loop
const result = await adminAsAppRequest(
flags.query,
flags['allow-mutation'],
session,
flags['api-version'],
variable,
)

if (result.status === 'blocked') {
outputResult(outputContent`Use the --allow-mutation flag to execute mutations.`)
return {app: appContextResult.app}
} else {
if (flags.variables !== undefined && flags.variables.length > 0) {
outputResult(`${i}:`)
}
outputResult(outputContent`${JSON.stringify(result.data, null, 2)}`)
}
i++
}

return {app: appContextResult.app}
}
}
31 changes: 26 additions & 5 deletions packages/app/src/cli/commands/app/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,16 @@ export default class Init extends AppCommand {
static flags = {
...globalFlags,
name: Flags.string({
description: 'The name of the app.',
char: 'n',
env: 'SHOPIFY_FLAG_NAME',
hidden: false,
}),
organization: Flags.string({
description: 'The ID of the organization to create the app in. You must have access to this organization.',
env: 'SHOPIFY_FLAG_ORGANIZATION',
hidden: false,
}),
path: Flags.string({
char: 'p',
env: 'SHOPIFY_FLAG_PATH',
Expand Down Expand Up @@ -62,13 +68,17 @@ export default class Init extends AppCommand {
description:
'The Client ID of your app. Use this to automatically link your new project to an existing app. Using this flag avoids the app selection prompt.',
env: 'SHOPIFY_FLAG_CLIENT_ID',
exclusive: ['config'],
exclusive: ['config', 'organization'],
}),
}

async run(): Promise<AppCommandOutput> {
const {flags} = await this.parse(Init)

// Allow template and flavor to fail when prompted
const requiredNonTTYFlags = ['name']
this.failMissingNonTTYFlags(flags, requiredNonTTYFlags)

validateTemplateValue(flags.template)
validateFlavorValue(flags.template, flags.flavor)

Expand All @@ -81,7 +91,9 @@ export default class Init extends AppCommand {

const promptAnswers = await initPrompt({
template: flags.template,
templateFlagName: Init.flags.template.name,
flavor: flags.flavor,
flavorFlagName: Init.flags.flavor.name,
})

let selectAppResult: SelectAppOrNewAppNameResult
Expand All @@ -93,10 +105,19 @@ export default class Init extends AppCommand {
developerPlatformClient = selectedApp.developerPlatformClient ?? developerPlatformClient
selectAppResult = {result: 'existing', app: selectedApp}
} else {
const org = await selectOrg()
developerPlatformClient = selectDeveloperPlatformClient({organization: org})
const {organization, apps, hasMorePages} = await developerPlatformClient.orgAndApps(org.id)
selectAppResult = await selectAppOrNewAppName(name, apps, hasMorePages, organization, developerPlatformClient)
const org = await selectOrg({
organization: flags.organization,
flagName: Init.flags.organization.name,
})
if (flags.organization && flags.name) {
// No client id provided, and organization and name flags are provided
// Assume the user wants to create a new app in the given organization
selectAppResult = {result: 'new', name, org}
} else {
developerPlatformClient = selectDeveloperPlatformClient({organization: org})
const {organization, apps, hasMorePages} = await developerPlatformClient.orgAndApps(org.id)
selectAppResult = await selectAppOrNewAppName(name, apps, hasMorePages, organization, developerPlatformClient)
}
appName = selectAppResult.result === 'new' ? selectAppResult.name : selectAppResult.app.title
}

Expand Down
1 change: 1 addition & 0 deletions packages/app/src/cli/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export const blocks = {
export const ports = {
graphiql: 3457,
localhost: 3458,
devStatusServer: 3459,
} as const

export const EsbuildEnvVarRegex = /^([a-zA-Z_$])([a-zA-Z0-9_$])*$/
2 changes: 2 additions & 0 deletions packages/app/src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import gatherPublicMetadata from './hooks/public_metadata.js'
import gatherSensitiveMetadata from './hooks/sensitive_metadata.js'
import AppCommand from './utilities/app-command.js'
import DevClean from './commands/app/dev/clean.js'
import Execute from './commands/app/execute.js'

/**
* All app commands should extend AppCommand.
Expand Down Expand Up @@ -57,6 +58,7 @@ export const commands: {[key: string]: typeof AppCommand} = {
'app:webhook:trigger': WebhookTrigger,
'webhook:trigger': WebhookTriggerDeprecated,
'demo:watcher': DemoWatcher,
'app:execute': Execute,
}

export const AppSensitiveMetadataHook = gatherSensitiveMetadata
Expand Down
14 changes: 13 additions & 1 deletion packages/app/src/cli/prompts/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,17 @@ import {
} from '@shopify/cli-kit/node/ui'
import {outputCompleted} from '@shopify/cli-kit/node/output'

export async function selectOrganizationPrompt(organizations: Organization[]): Promise<Organization> {
interface SelectOrganizationPromptOptions {
organizations: Organization[]
flagName?: string
flagValues?: string[]
}

export async function selectOrganizationPrompt({
organizations,
flagName,
flagValues,
}: SelectOrganizationPromptOptions): Promise<Organization> {
if (organizations.length === 1) {
return organizations[0]!
}
Expand All @@ -43,6 +53,8 @@ export async function selectOrganizationPrompt(organizations: Organization[]): P
const id = await renderAutocompletePrompt({
message: `Which organization is this work for?`,
choices: orgList,
flagName,
flagValues,
})
return organizations.find((org) => org.id === id)!
}
Expand Down
9 changes: 8 additions & 1 deletion packages/app/src/cli/prompts/generate/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,17 +81,21 @@
throw new AbortError('You have reached the limit for the number of extensions you can create.')
}

const choices = buildChoices(extensionTemplates, options.unavailableExtensions)

// eslint-disable-next-line require-atomic-updates
templateType = await renderAutocompletePrompt({
message: 'Type of extension?',
choices: buildChoices(extensionTemplates, options.unavailableExtensions),
choices,
flagName: 'template',
flagValues: choices.map((choice) => choice.value),
})
}

// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const extensionTemplate = extensionTemplates.find((template) => template.identifier === templateType)!

const name = options.name || (await promptName(options.directory, extensionTemplate.defaultName))

Check warning on line 98 in packages/app/src/cli/prompts/generate/extension.ts

View workflow job for this annotation

GitHub Actions / ESLint Report Analysis

packages/app/src/cli/prompts/generate/extension.ts#L98

[@typescript-eslint/prefer-nullish-coalescing] Prefer using nullish coalescing operator (`??`) instead of a logical or (`||`), as it is a safer operator.
const flavor = options.extensionFlavor ?? (await promptFlavor(extensionTemplate))
const extensionContent = {name, flavor}

Expand All @@ -109,6 +113,7 @@
return renderTextPrompt({
message: 'Name your extension:',
defaultValue: name,
flagName: 'name',
})
}

Expand All @@ -130,6 +135,8 @@
}
}),
defaultValue: 'react',
flagName: 'flavor',
flagValues: extensionTemplate.supportedFlavors.map((choice) => choice.value),
})
}

Expand Down
6 changes: 6 additions & 0 deletions packages/app/src/cli/prompts/init/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@

export interface InitOptions {
template?: string
templateFlagName?: string
flavor?: string
flavorFlagName?: string
}

interface InitOutput {
Expand Down Expand Up @@ -86,12 +88,14 @@
template = await renderSelectPrompt({
choices: templateOptionsInOrder.map((key) => {
return {
label: templates[key].label || key,

Check warning on line 91 in packages/app/src/cli/prompts/init/init.ts

View workflow job for this annotation

GitHub Actions / ESLint Report Analysis

packages/app/src/cli/prompts/init/init.ts#L91

[@typescript-eslint/prefer-nullish-coalescing] Prefer using nullish coalescing operator (`??`) instead of a logical or (`||`), as it is a safer operator.
value: key,
}
}),
message: 'Get started building your app:',
defaultValue: allTemplates.find((key) => templates[key].url === defaults.template),
flagName: options.templateFlagName,
flagValues: templateOptionsInOrder.map((option) => option),
})
}

Expand All @@ -118,6 +122,8 @@
value: branch.branch,
label: branch.label,
})),
flagName: options.flavorFlagName,
flagValues: Object.keys(selectedTemplate.branches.options),
})
}
}
Expand All @@ -127,7 +133,7 @@
selectedUrl = `${selectedUrl}#${branch}`
}

answers.template = selectedUrl || answers.template || defaults.template

Check warning on line 136 in packages/app/src/cli/prompts/init/init.ts

View workflow job for this annotation

GitHub Actions / ESLint Report Analysis

packages/app/src/cli/prompts/init/init.ts#L136

[@typescript-eslint/prefer-nullish-coalescing] Prefer using nullish coalescing operator (`??`) instead of a logical or (`||`), as it is a safer operator.

answers.globalCLIResult = await installGlobalCLIPrompt()

Expand Down
23 changes: 21 additions & 2 deletions packages/app/src/cli/services/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,14 +282,33 @@ export async function fetchOrCreateOrganizationApp(options: CreateAppOptions): P
return remoteApp
}

interface SelectOrgOptions {
organization?: string
flagName?: string
}

/**
* Fetch all orgs the user belongs to and show a prompt to select one of them
* @param developerPlatformClient - The client to access the platform API
* @returns The selected organization ID
*/
export async function selectOrg(): Promise<Organization> {
export async function selectOrg({organization, flagName}: SelectOrgOptions = {}): Promise<Organization> {
const orgs = await fetchOrganizations()
const org = await selectOrganizationPrompt(orgs)
if (organization) {
const matchingOrgs = orgs.filter((org) => org.id === organization)
if (matchingOrgs.length === 0) {
throw new AbortError(`Organization not found: ${organization}`)
}
if (matchingOrgs.length > 1) {
throw new AbortError(`Multiple organizations found with ID: ${organization}`)
}
return matchingOrgs[0] as Organization
}
const org = await selectOrganizationPrompt({
organizations: orgs,
flagName,
flagValues: orgs.map((org) => `${org.id} (${org.businessName})`),
})
return org
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {handleWatcherEvents} from './app-event-watcher-handler.js'
import {AppLinkedInterface} from '../../../models/app/app.js'
import {ExtensionInstance} from '../../../models/extensions/extension-instance.js'
import {ExtensionBuildOptions} from '../../build/extension.js'
import {DevSessionStatusManager} from '../processes/dev-session/dev-session-status-manager.js'
import {outputDebug} from '@shopify/cli-kit/node/output'
import {AbortSignal} from '@shopify/cli-kit/node/abort'
import {joinPath} from '@shopify/cli-kit/node/path'
Expand Down Expand Up @@ -98,9 +99,11 @@ export class AppEventWatcher extends EventEmitter {
private ready = false
private initialEvents: ExtensionEvent[] = []
private fileWatcher?: FileWatcher
private readonly devSessionStatusManager: DevSessionStatusManager

constructor(
app: AppLinkedInterface,
devSessionStatusManager?: DevSessionStatusManager,
appURL?: string,
buildOutputPath?: string,
esbuildManager?: ESBuildContextManager,
Expand All @@ -109,6 +112,7 @@ export class AppEventWatcher extends EventEmitter {
super()
this.app = app
this.appURL = appURL
this.devSessionStatusManager = devSessionStatusManager ?? new DevSessionStatusManager()
this.buildOutputPath = buildOutputPath ?? joinPath(app.directory, '.shopify', 'dev-bundle')
// Default options, to be overwritten by the start method
this.options = {stdout: process.stdout, stderr: process.stderr, signal: new AbortSignal()}
Expand Down Expand Up @@ -157,9 +161,11 @@ export class AppEventWatcher extends EventEmitter {
// Find affected created/updated extensions and build them
const buildableEvents = appEvent.extensionEvents.filter((extEvent) => extEvent.type !== EventType.Deleted)

this.devSessionStatusManager.setBuildingState(true)
// Build the created/updated extensions and update the extension events with the build result
await this.buildExtensions(buildableEvents)

this.devSessionStatusManager.setBuildingState(false)
// Find deleted extensions and delete their previous build output
await this.deleteExtensionsBuildOutput(appEvent)
this.emit('all', appEvent)
Expand Down Expand Up @@ -204,6 +210,16 @@ export class AppEventWatcher extends EventEmitter {
return this
}

/**
* Get the current app instance.
* This will be the latest version of the app after any reloads due to configuration changes.
*
* @returns The current app instance
*/
get currentApp(): AppLinkedInterface {
return this.app
}

onError(listener: (error: Error) => Promise<void> | void) {
// eslint-disable-next-line @typescript-eslint/no-misused-promises
this.addListener('error', listener)
Expand Down
Loading
Loading