Skip to content

Commit f983174

Browse files
isaacroldanshauns
authored andcommitted
ai dev server status
1 parent 8fa8036 commit f983174

29 files changed

+719
-40
lines changed
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import {appFlags} from '../../flags.js'
2+
import {showApiKeyDeprecationWarning} from '../../prompts/deprecation-warnings.js'
3+
import {checkFolderIsValidApp} from '../../models/app/loader.js'
4+
import AppCommand, {AppCommandOutput} from '../../utilities/app-command.js'
5+
import {linkedAppContext} from '../../services/app-context.js'
6+
import {storeContext} from '../../services/store-context.js'
7+
import {Flags} from '@oclif/core'
8+
import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn'
9+
import {globalFlags} from '@shopify/cli-kit/node/cli'
10+
import {ensureAuthenticatedAdminAsApp} from '@shopify/cli-kit/node/session'
11+
import {adminAsAppRequest} from '@shopify/cli-kit/node/api/admin-as-app'
12+
import {outputContent, outputResult} from '@shopify/cli-kit/node/output'
13+
14+
export default class Execute extends AppCommand {
15+
static summary = 'Execute a Shopify GraphQL Admin API query'
16+
17+
static flags = {
18+
...globalFlags,
19+
...appFlags,
20+
'api-key': Flags.string({
21+
hidden: true,
22+
description: 'The API key of your app.',
23+
env: 'SHOPIFY_FLAG_APP_API_KEY',
24+
exclusive: ['config'],
25+
}),
26+
store: Flags.string({
27+
char: 's',
28+
description: 'Store URL. Must be an existing development or Shopify Plus sandbox store.',
29+
env: 'SHOPIFY_FLAG_STORE',
30+
parse: async (input) => normalizeStoreFqdn(input),
31+
}),
32+
query: Flags.string({
33+
char: 'q',
34+
description: 'The GraphQL query to execute.',
35+
env: 'SHOPIFY_FLAG_QUERY',
36+
required: true,
37+
allowStdin: true,
38+
}),
39+
variables: Flags.string({
40+
description:
41+
'The GraphQL variables to pass to the query. Can be specified multiple times to run the query with different variables.',
42+
env: 'SHOPIFY_FLAG_VARIABLES',
43+
multiple: true,
44+
}),
45+
'api-version': Flags.string({
46+
description: 'The API version to use.',
47+
env: 'SHOPIFY_FLAG_API_VERSION',
48+
default: '2025-01',
49+
}),
50+
'allow-mutation': Flags.boolean({
51+
description:
52+
'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',
53+
env: 'SHOPIFY_FLAG_ALLOW_MUTATION',
54+
default: false,
55+
}),
56+
}
57+
58+
public static analyticsStopCommand(): string | undefined {
59+
return 'app dev stop'
60+
}
61+
62+
public async run(): Promise<AppCommandOutput> {
63+
const {flags} = await this.parse(Execute)
64+
65+
if (!flags['api-key'] && process.env.SHOPIFY_API_KEY) {
66+
flags['api-key'] = process.env.SHOPIFY_API_KEY
67+
}
68+
if (flags['api-key']) {
69+
await showApiKeyDeprecationWarning()
70+
}
71+
72+
await checkFolderIsValidApp(flags.path)
73+
74+
const appContextResult = await linkedAppContext({
75+
directory: flags.path,
76+
clientId: flags['client-id'] ?? flags['api-key'],
77+
forceRelink: flags.reset,
78+
userProvidedConfigName: flags.config,
79+
})
80+
const store = await storeContext({
81+
appContextResult,
82+
storeFqdn: flags.store,
83+
forceReselectStore: flags.reset,
84+
})
85+
86+
const session = await ensureAuthenticatedAdminAsApp(
87+
store.shopDomain,
88+
appContextResult.remoteApp.apiKey,
89+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
90+
appContextResult.remoteApp.apiSecretKeys[0]!.secret,
91+
)
92+
93+
const variablesAsObjects = flags.variables ? flags.variables.map((variable) => JSON.parse(variable)) : [undefined]
94+
95+
let i = 0
96+
for (const variable of variablesAsObjects) {
97+
// eslint-disable-next-line no-await-in-loop
98+
const result = await adminAsAppRequest(
99+
flags.query,
100+
flags['allow-mutation'],
101+
session,
102+
flags['api-version'],
103+
variable,
104+
)
105+
106+
if (result.status === 'blocked') {
107+
outputResult(outputContent`Use the --allow-mutation flag to execute mutations.`)
108+
return {app: appContextResult.app}
109+
} else {
110+
if (flags.variables !== undefined && flags.variables.length > 0) {
111+
outputResult(`${i}:`)
112+
}
113+
outputResult(outputContent`${JSON.stringify(result.data, null, 2)}`)
114+
}
115+
i++
116+
}
117+
118+
return {app: appContextResult.app}
119+
}
120+
}

packages/app/src/cli/commands/app/init.ts

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,16 @@ export default class Init extends AppCommand {
2424
static flags = {
2525
...globalFlags,
2626
name: Flags.string({
27+
description: 'The name of the app.',
2728
char: 'n',
2829
env: 'SHOPIFY_FLAG_NAME',
2930
hidden: false,
3031
}),
32+
organization: Flags.string({
33+
description: 'The ID of the organization to create the app in. You must have access to this organization.',
34+
env: 'SHOPIFY_FLAG_ORGANIZATION',
35+
hidden: false,
36+
}),
3137
path: Flags.string({
3238
char: 'p',
3339
env: 'SHOPIFY_FLAG_PATH',
@@ -62,13 +68,17 @@ export default class Init extends AppCommand {
6268
description:
6369
'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.',
6470
env: 'SHOPIFY_FLAG_CLIENT_ID',
65-
exclusive: ['config'],
71+
exclusive: ['config', 'organization'],
6672
}),
6773
}
6874

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

78+
// Allow template and flavor to fail when prompted
79+
const requiredNonTTYFlags = ['name']
80+
this.failMissingNonTTYFlags(flags, requiredNonTTYFlags)
81+
7282
validateTemplateValue(flags.template)
7383
validateFlavorValue(flags.template, flags.flavor)
7484

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

8292
const promptAnswers = await initPrompt({
8393
template: flags.template,
94+
templateFlagName: Init.flags.template.name,
8495
flavor: flags.flavor,
96+
flavorFlagName: Init.flags.flavor.name,
8597
})
8698

8799
let selectAppResult: SelectAppOrNewAppNameResult
@@ -93,10 +105,19 @@ export default class Init extends AppCommand {
93105
developerPlatformClient = selectedApp.developerPlatformClient ?? developerPlatformClient
94106
selectAppResult = {result: 'existing', app: selectedApp}
95107
} else {
96-
const org = await selectOrg()
97-
developerPlatformClient = selectDeveloperPlatformClient({organization: org})
98-
const {organization, apps, hasMorePages} = await developerPlatformClient.orgAndApps(org.id)
99-
selectAppResult = await selectAppOrNewAppName(name, apps, hasMorePages, organization, developerPlatformClient)
108+
const org = await selectOrg({
109+
organization: flags.organization,
110+
flagName: Init.flags.organization.name,
111+
})
112+
if (flags.organization && flags.name) {
113+
// No client id provided, and organization and name flags are provided
114+
// Assume the user wants to create a new app in the given organization
115+
selectAppResult = {result: 'new', name, org}
116+
} else {
117+
developerPlatformClient = selectDeveloperPlatformClient({organization: org})
118+
const {organization, apps, hasMorePages} = await developerPlatformClient.orgAndApps(org.id)
119+
selectAppResult = await selectAppOrNewAppName(name, apps, hasMorePages, organization, developerPlatformClient)
120+
}
100121
appName = selectAppResult.result === 'new' ? selectAppResult.name : selectAppResult.app.title
101122
}
102123

packages/app/src/cli/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ export const blocks = {
4242
export const ports = {
4343
graphiql: 3457,
4444
localhost: 3458,
45+
devStatusServer: 3459,
4546
} as const
4647

4748
export const EsbuildEnvVarRegex = /^([a-zA-Z_$])([a-zA-Z0-9_$])*$/

packages/app/src/cli/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import gatherPublicMetadata from './hooks/public_metadata.js'
2727
import gatherSensitiveMetadata from './hooks/sensitive_metadata.js'
2828
import AppCommand from './utilities/app-command.js'
2929
import DevClean from './commands/app/dev/clean.js'
30+
import Execute from './commands/app/execute.js'
3031

3132
/**
3233
* All app commands should extend AppCommand.
@@ -57,6 +58,7 @@ export const commands: {[key: string]: typeof AppCommand} = {
5758
'app:webhook:trigger': WebhookTrigger,
5859
'webhook:trigger': WebhookTriggerDeprecated,
5960
'demo:watcher': DemoWatcher,
61+
'app:execute': Execute,
6062
}
6163

6264
export const AppSensitiveMetadataHook = gatherSensitiveMetadata

packages/app/src/cli/prompts/dev.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,17 @@ import {
2020
} from '@shopify/cli-kit/node/ui'
2121
import {outputCompleted} from '@shopify/cli-kit/node/output'
2222

23-
export async function selectOrganizationPrompt(organizations: Organization[]): Promise<Organization> {
23+
interface SelectOrganizationPromptOptions {
24+
organizations: Organization[]
25+
flagName?: string
26+
flagValues?: string[]
27+
}
28+
29+
export async function selectOrganizationPrompt({
30+
organizations,
31+
flagName,
32+
flagValues,
33+
}: SelectOrganizationPromptOptions): Promise<Organization> {
2434
if (organizations.length === 1) {
2535
return organizations[0]!
2636
}
@@ -43,6 +53,8 @@ export async function selectOrganizationPrompt(organizations: Organization[]): P
4353
const id = await renderAutocompletePrompt({
4454
message: `Which organization is this work for?`,
4555
choices: orgList,
56+
flagName,
57+
flagValues,
4658
})
4759
return organizations.find((org) => org.id === id)!
4860
}

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,10 +81,14 @@ const generateExtensionPrompts = async (
8181
throw new AbortError('You have reached the limit for the number of extensions you can create.')
8282
}
8383

84+
const choices = buildChoices(extensionTemplates, options.unavailableExtensions)
85+
8486
// eslint-disable-next-line require-atomic-updates
8587
templateType = await renderAutocompletePrompt({
8688
message: 'Type of extension?',
87-
choices: buildChoices(extensionTemplates, options.unavailableExtensions),
89+
choices,
90+
flagName: 'template',
91+
flagValues: choices.map((choice) => choice.value),
8892
})
8993
}
9094

@@ -109,6 +113,7 @@ async function promptName(directory: string, defaultName: string, number = 1): P
109113
return renderTextPrompt({
110114
message: 'Name your extension:',
111115
defaultValue: name,
116+
flagName: 'name',
112117
})
113118
}
114119

@@ -130,6 +135,8 @@ async function promptFlavor(extensionTemplate: ExtensionTemplate): Promise<Exten
130135
}
131136
}),
132137
defaultValue: 'react',
138+
flagName: 'flavor',
139+
flagValues: extensionTemplate.supportedFlavors.map((choice) => choice.value),
133140
})
134141
}
135142

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ import {renderSelectPrompt} from '@shopify/cli-kit/node/ui'
33

44
export interface InitOptions {
55
template?: string
6+
templateFlagName?: string
67
flavor?: string
8+
flavorFlagName?: string
79
}
810

911
interface InitOutput {
@@ -92,6 +94,8 @@ const init = async (options: InitOptions): Promise<InitOutput> => {
9294
}),
9395
message: 'Get started building your app:',
9496
defaultValue: allTemplates.find((key) => templates[key].url === defaults.template),
97+
flagName: options.templateFlagName,
98+
flagValues: templateOptionsInOrder.map((option) => option),
9599
})
96100
}
97101

@@ -118,6 +122,8 @@ const init = async (options: InitOptions): Promise<InitOutput> => {
118122
value: branch.branch,
119123
label: branch.label,
120124
})),
125+
flagName: options.flavorFlagName,
126+
flagValues: Object.keys(selectedTemplate.branches.options),
121127
})
122128
}
123129
}

packages/app/src/cli/services/context.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -282,14 +282,33 @@ export async function fetchOrCreateOrganizationApp(options: CreateAppOptions): P
282282
return remoteApp
283283
}
284284

285+
interface SelectOrgOptions {
286+
organization?: string
287+
flagName?: string
288+
}
289+
285290
/**
286291
* Fetch all orgs the user belongs to and show a prompt to select one of them
287292
* @param developerPlatformClient - The client to access the platform API
288293
* @returns The selected organization ID
289294
*/
290-
export async function selectOrg(): Promise<Organization> {
295+
export async function selectOrg({organization, flagName}: SelectOrgOptions = {}): Promise<Organization> {
291296
const orgs = await fetchOrganizations()
292-
const org = await selectOrganizationPrompt(orgs)
297+
if (organization) {
298+
const matchingOrgs = orgs.filter((org) => org.id === organization)
299+
if (matchingOrgs.length === 0) {
300+
throw new AbortError(`Organization not found: ${organization}`)
301+
}
302+
if (matchingOrgs.length > 1) {
303+
throw new AbortError(`Multiple organizations found with ID: ${organization}`)
304+
}
305+
return matchingOrgs[0] as Organization
306+
}
307+
const org = await selectOrganizationPrompt({
308+
organizations: orgs,
309+
flagName,
310+
flagValues: orgs.map((org) => `${org.id} (${org.businessName})`),
311+
})
293312
return org
294313
}
295314

packages/app/src/cli/services/dev/app-events/app-event-watcher.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,16 @@ export class AppEventWatcher extends EventEmitter {
204204
return this
205205
}
206206

207+
/**
208+
* Get the current app instance.
209+
* This will be the latest version of the app after any reloads due to configuration changes.
210+
*
211+
* @returns The current app instance
212+
*/
213+
get currentApp(): AppLinkedInterface {
214+
return this.app
215+
}
216+
207217
onError(listener: (error: Error) => Promise<void> | void) {
208218
// eslint-disable-next-line @typescript-eslint/no-misused-promises
209219
this.addListener('error', listener)

packages/app/src/cli/services/dev/processes/dev-session/dev-session-process.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ import {DevSession} from './dev-session.js'
33
import {BaseProcess, DevProcessFunction} from '../types.js'
44
import {DeveloperPlatformClient} from '../../../../utilities/developer-platform-client.js'
55
import {AppLinkedInterface} from '../../../../models/app/app.js'
6-
import {AppEventWatcher} from '../../app-events/app-event-watcher.js'
6+
import {AppEvent, AppEventWatcher} from '../../app-events/app-event-watcher.js'
7+
import {SerialBatchProcessor} from '@shopify/cli-kit/node/serial-batch-processor'
78

89
export interface DevSessionProcessOptions {
910
developerPlatformClient: DeveloperPlatformClient
@@ -17,6 +18,7 @@ export interface DevSessionProcessOptions {
1718
appPreviewURL: string
1819
appLocalProxyURL: string
1920
devSessionStatusManager: DevSessionStatusManager
21+
appEventsProcessor: SerialBatchProcessor<AppEvent>
2022
}
2123

2224
export interface DevSessionProcess extends BaseProcess<DevSessionProcessOptions> {

0 commit comments

Comments
 (0)