diff --git a/.changeset/grumpy-mirrors-exist.md b/.changeset/grumpy-mirrors-exist.md new file mode 100644 index 0000000000..af374618b2 --- /dev/null +++ b/.changeset/grumpy-mirrors-exist.md @@ -0,0 +1,5 @@ +--- +'@shopify/app': patch +--- + +Enable non-interactive `app init` via a new `--organization-id` flag and not prompting to link to an existing app if `--name` is provided. diff --git a/.github/workflows/cli-main.yml b/.github/workflows/cli-main.yml index de9fcd09b3..c961ff983b 100644 --- a/.github/workflows/cli-main.yml +++ b/.github/workflows/cli-main.yml @@ -20,7 +20,6 @@ env: SHOPIFY_CONFIG: debug PNPM_VERSION: '10.11.1' BUNDLE_WITHOUT: 'test:development' - SHOPIFY_FLAG_CLIENT_ID: ${{ secrets.SHOPIFY_FLAG_CLIENT_ID }} GH_TOKEN: ${{ secrets.SHOPIFY_GH_READ_CONTENT_TOKEN }} GH_TOKEN_SHOP: ${{ secrets.SHOP_GH_READ_CONTENT_TOKEN }} DEFAULT_NODE_VERSION: '24.1.0' @@ -64,6 +63,7 @@ jobs: if: ${{ matrix.node == '24.1.0' }} env: SHOPIFY_CLI_PARTNERS_TOKEN: ${{ secrets.SHOPIFY_CLI_PARTNERS_TOKEN }} + SHOPIFY_FLAG_CLIENT_ID: ${{ secrets.SHOPIFY_FLAG_CLIENT_ID }} run: pnpm nx run features:test - name: Send Slack notification on failure uses: slackapi/slack-github-action@007b2c3c751a190b6f0f040e47ed024deaa72844 # pin@v1.23.0 diff --git a/.github/workflows/shopify-cli.yml b/.github/workflows/shopify-cli.yml index cbcc127e12..f19b0edc6e 100644 --- a/.github/workflows/shopify-cli.yml +++ b/.github/workflows/shopify-cli.yml @@ -14,7 +14,6 @@ env: SHOPIFY_CONFIG: debug PNPM_VERSION: '10.11.1' BUNDLE_WITHOUT: 'test:development' - SHOPIFY_FLAG_CLIENT_ID: ${{ secrets.SHOPIFY_FLAG_CLIENT_ID }} GH_TOKEN: ${{ secrets.SHOPIFY_GH_READ_CONTENT_TOKEN }} GH_TOKEN_SHOP: ${{ secrets.SHOP_GH_READ_CONTENT_TOKEN }} DEFAULT_NODE_VERSION: '24.1.0' @@ -192,6 +191,7 @@ jobs: - name: Acceptance tests env: SHOPIFY_CLI_PARTNERS_TOKEN: ${{ secrets.SHOPIFY_CLI_PARTNERS_TOKEN }} + SHOPIFY_FLAG_CLIENT_ID: ${{ secrets.SHOPIFY_FLAG_CLIENT_ID }} run: pnpm test:features --output-style=stream test-coverage: diff --git a/docs-shopify.dev/commands/interfaces/app-init.interface.ts b/docs-shopify.dev/commands/interfaces/app-init.interface.ts index 7e2265bb45..0d5e08ec98 100644 --- a/docs-shopify.dev/commands/interfaces/app-init.interface.ts +++ b/docs-shopify.dev/commands/interfaces/app-init.interface.ts @@ -24,6 +24,12 @@ export interface appinit { */ '--no-color'?: '' + /** + * The organization ID. Your organization ID can be found in your Dev Dashboard URL: https://dev.shopify.com/dashboard/ + * @environment SHOPIFY_FLAG_ORGANIZATION_ID + */ + '--organization-id '?: string + /** * * @environment SHOPIFY_FLAG_PACKAGE_MANAGER diff --git a/docs-shopify.dev/generated/generated_docs_data.json b/docs-shopify.dev/generated/generated_docs_data.json index 971f231082..5a1332f6f3 100644 --- a/docs-shopify.dev/generated/generated_docs_data.json +++ b/docs-shopify.dev/generated/generated_docs_data.json @@ -1916,6 +1916,15 @@ "isOptional": true, "environmentValue": "SHOPIFY_FLAG_NO_COLOR" }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-init.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--organization-id ", + "value": "string", + "description": "The organization ID. Your organization ID can be found in your Dev Dashboard URL: https://dev.shopify.com/dashboard/", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_ORGANIZATION_ID" + }, { "filePath": "docs-shopify.dev/commands/interfaces/app-init.interface.ts", "syntaxKind": "PropertySignature", @@ -1962,7 +1971,7 @@ "environmentValue": "SHOPIFY_FLAG_PATH" } ], - "value": "export interface appinit {\n /**\n * 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.\n * @environment SHOPIFY_FLAG_CLIENT_ID\n */\n '--client-id '?: string\n\n /**\n * Which flavor of the given template to use.\n * @environment SHOPIFY_FLAG_TEMPLATE_FLAVOR\n */\n '--flavor '?: string\n\n /**\n * \n * @environment SHOPIFY_FLAG_NAME\n */\n '-n, --name '?: string\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * \n * @environment SHOPIFY_FLAG_PACKAGE_MANAGER\n */\n '-d, --package-manager '?: string\n\n /**\n * \n * @environment SHOPIFY_FLAG_PATH\n */\n '-p, --path '?: string\n\n /**\n * The app template. Accepts one of the following:\n - \n - Any GitHub repo with optional branch and subpath, e.g., https://github.com/Shopify//[subpath]#[branch]\n * @environment SHOPIFY_FLAG_TEMPLATE\n */\n '--template '?: string\n\n /**\n * Increase the verbosity of the output.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" + "value": "export interface appinit {\n /**\n * 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.\n * @environment SHOPIFY_FLAG_CLIENT_ID\n */\n '--client-id '?: string\n\n /**\n * Which flavor of the given template to use.\n * @environment SHOPIFY_FLAG_TEMPLATE_FLAVOR\n */\n '--flavor '?: string\n\n /**\n * \n * @environment SHOPIFY_FLAG_NAME\n */\n '-n, --name '?: string\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * The organization ID. Your organization ID can be found in your Dev Dashboard URL: https://dev.shopify.com/dashboard/\n * @environment SHOPIFY_FLAG_ORGANIZATION_ID\n */\n '--organization-id '?: string\n\n /**\n * \n * @environment SHOPIFY_FLAG_PACKAGE_MANAGER\n */\n '-d, --package-manager '?: string\n\n /**\n * \n * @environment SHOPIFY_FLAG_PATH\n */\n '-p, --path '?: string\n\n /**\n * The app template. Accepts one of the following:\n - \n - Any GitHub repo with optional branch and subpath, e.g., https://github.com/Shopify//[subpath]#[branch]\n * @environment SHOPIFY_FLAG_TEMPLATE\n */\n '--template '?: string\n\n /**\n * Increase the verbosity of the output.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" } } } diff --git a/packages/app/src/cli/commands/app/init.test.ts b/packages/app/src/cli/commands/app/init.test.ts new file mode 100644 index 0000000000..d2f6f49724 --- /dev/null +++ b/packages/app/src/cli/commands/app/init.test.ts @@ -0,0 +1,156 @@ +import Init from './init.js' +import initPrompt from '../../prompts/init/init.js' +import initService from '../../services/init/init.js' +import {selectDeveloperPlatformClient} from '../../utilities/developer-platform-client.js' +import {selectOrg} from '../../services/context.js' +import {appNamePrompt, createAsNewAppPrompt} from '../../prompts/dev.js' +import {validateFlavorValue, validateTemplateValue} from '../../services/init/validate.js' +import {testAppLinked, testDeveloperPlatformClient, testOrganization} from '../../models/app/app.test-data.js' +import {describe, expect, test, vi} from 'vitest' +import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' +import {generateRandomNameForSubdirectory} from '@shopify/cli-kit/node/fs' +import {inferPackageManager} from '@shopify/cli-kit/node/node-package-manager' + +vi.mock('../../prompts/init/init.js') +vi.mock('../../services/init/init.js') +vi.mock('../../utilities/developer-platform-client.js') +vi.mock('../../services/context.js') +vi.mock('../../prompts/dev.js') +vi.mock('../../services/init/validate.js') +vi.mock('@shopify/cli-kit/node/fs') +vi.mock('@shopify/cli-kit/node/node-package-manager') + +describe('Init command', () => { + test('runs init command with default flags', async () => { + // Given + const mockOrganization = testOrganization() + const mockDeveloperPlatformClient = testDeveloperPlatformClient() + const mockApp = testAppLinked() + + mockAndCaptureOutput() + vi.mocked(validateTemplateValue).mockReturnValue(undefined) + vi.mocked(validateFlavorValue).mockReturnValue(undefined) + vi.mocked(inferPackageManager).mockReturnValue('npm') + vi.mocked(generateRandomNameForSubdirectory).mockResolvedValue('test-app') + vi.mocked(selectDeveloperPlatformClient).mockReturnValue(mockDeveloperPlatformClient) + vi.mocked(selectOrg).mockResolvedValue(mockOrganization) + + // Mock the orgAndApps method on the developer platform client + vi.mocked(mockDeveloperPlatformClient.orgAndApps).mockResolvedValue({ + organization: mockOrganization, + apps: [], + hasMorePages: false, + }) + + vi.mocked(initPrompt).mockResolvedValue({ + template: 'https://github.com/Shopify/shopify-app-template-remix', + templateType: 'remix', + globalCLIResult: {install: false, alreadyInstalled: false}, + }) + vi.mocked(createAsNewAppPrompt).mockResolvedValue(true) + vi.mocked(appNamePrompt).mockResolvedValue('test-app') + vi.mocked(initService).mockResolvedValue({app: mockApp}) + + // When + await Init.run([]) + + // Then + expect(initService).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'test-app', + packageManager: 'npm', + }), + ) + }) + + test('runs init command without prompts when organization-id, name, and template flags are provided', async () => { + // Given + const mockOrganization = testOrganization() + const mockDeveloperPlatformClient = testDeveloperPlatformClient() + const mockApp = testAppLinked() + + mockAndCaptureOutput() + vi.mocked(validateTemplateValue).mockReturnValue(undefined) + vi.mocked(validateFlavorValue).mockReturnValue(undefined) + vi.mocked(inferPackageManager).mockReturnValue('npm') + vi.mocked(selectDeveloperPlatformClient).mockReturnValue(mockDeveloperPlatformClient) + + // Mock orgFromId to return the organization + vi.mocked(mockDeveloperPlatformClient.orgFromId).mockResolvedValue(mockOrganization) + + // Mock the orgAndApps method on the developer platform client + vi.mocked(mockDeveloperPlatformClient.orgAndApps).mockResolvedValue({ + organization: mockOrganization, + apps: [], + hasMorePages: false, + }) + + vi.mocked(initPrompt).mockResolvedValue({ + template: 'https://github.com/Shopify/shopify-app-template-remix', + templateType: 'remix', + globalCLIResult: {install: false, alreadyInstalled: false}, + }) + vi.mocked(initService).mockResolvedValue({app: mockApp}) + + // When + await Init.run(['--organization-id', mockOrganization.id, '--name', 'my-app', '--template', 'remix']) + + // Then + // Verify that prompt functions were NOT called + // Any other interactive prompts would also cause the test to fail with an AbortError + expect(selectOrg).not.toHaveBeenCalled() + expect(createAsNewAppPrompt).not.toHaveBeenCalled() + expect(appNamePrompt).not.toHaveBeenCalled() + + // Verify the command completed successfully + expect(initService).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'my-app', + packageManager: 'npm', + template: 'https://github.com/Shopify/shopify-app-template-remix', + }), + ) + }) + + test('fails with clear error message when invalid organization-id is provided', async () => { + // Given + const validOrg = testOrganization() + const mockDeveloperPlatformClient = testDeveloperPlatformClient() + + // Suppress stderr output for this error test + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + try { + const outputMock = mockAndCaptureOutput() + vi.mocked(validateTemplateValue).mockReturnValue(undefined) + vi.mocked(validateFlavorValue).mockReturnValue(undefined) + vi.mocked(inferPackageManager).mockReturnValue('npm') + vi.mocked(selectDeveloperPlatformClient).mockReturnValue(mockDeveloperPlatformClient) + + // Mock orgFromId to return undefined for invalid organization + vi.mocked(mockDeveloperPlatformClient.orgFromId).mockResolvedValue(undefined) + + vi.mocked(initPrompt).mockResolvedValue({ + template: 'https://github.com/Shopify/shopify-app-template-remix', + templateType: 'remix', + globalCLIResult: {install: false, alreadyInstalled: false}, + }) + + // When/Then + // The command throws an AbortError which is caught by oclif's error handler + // This causes process.exit(1) which vitest intercepts + await expect( + Init.run(['--organization-id', 'invalid-org-id', '--name', 'my-app', '--template', 'remix']), + ).rejects.toThrow('process.exit unexpectedly called with "1"') + + // Verify the error message was displayed + expect(outputMock.error()).toContain('Organization with ID invalid-org-id not found') + + // Verify initService was never called since validation failed + expect(initService).not.toHaveBeenCalled() + } finally { + // Always restore console.error, even if the test fails + consoleErrorSpy.mockRestore() + } + }) +}) diff --git a/packages/app/src/cli/commands/app/init.ts b/packages/app/src/cli/commands/app/init.ts index c293518696..e8398f78f4 100644 --- a/packages/app/src/cli/commands/app/init.ts +++ b/packages/app/src/cli/commands/app/init.ts @@ -64,6 +64,12 @@ export default class Init extends AppLinkedCommand { env: 'SHOPIFY_FLAG_CLIENT_ID', exclusive: ['config'], }), + 'organization-id': Flags.string({ + hidden: false, + description: + 'The organization ID. Your organization ID can be found in your Dev Dashboard URL: https://dev.shopify.com/dashboard/', + env: 'SHOPIFY_FLAG_ORGANIZATION_ID', + }), } async run(): Promise { @@ -93,10 +99,31 @@ export default class Init extends AppLinkedCommand { developerPlatformClient = selectedApp.developerPlatformClient ?? developerPlatformClient selectAppResult = {result: 'existing', app: selectedApp} } else { - const org = await selectOrg() + let org: Organization + if (flags['organization-id']) { + // If an organization-id is provided, fetch the organization directly + const matchingOrg = await developerPlatformClient.orgFromId(flags['organization-id']) + if (!matchingOrg) { + throw new AbortError( + `Organization with ID ${flags['organization-id']} not found`, + "Run `shopify auth login` to confirm you've selected the right account, and verify your organization ID. " + + 'You can find your organization ID in your Dev Dashboard URL: https://dev.shopify.com/dashboard/', + ) + } + org = matchingOrg + } else { + org = await selectOrg() + } developerPlatformClient = selectDeveloperPlatformClient({organization: org}) const {organization, apps, hasMorePages} = await developerPlatformClient.orgAndApps(org.id) - selectAppResult = await selectAppOrNewAppName(name, apps, hasMorePages, organization, developerPlatformClient) + selectAppResult = await selectAppOrNewAppName( + flags.name !== undefined, + name, + apps, + hasMorePages, + organization, + developerPlatformClient, + ) appName = selectAppResult.result === 'new' ? selectAppResult.name : selectAppResult.app.title } @@ -152,18 +179,19 @@ export type SelectAppOrNewAppNameResult = * But doesn't create the app yet, the app creation is deferred and is responsibility of the caller. */ async function selectAppOrNewAppName( + nameProvidedAsFlag: boolean, localAppName: string, apps: MinimalOrganizationApp[], hasMorePages: boolean, org: Organization, developerPlatformClient: DeveloperPlatformClient, ): Promise { - let createNewApp = apps.length === 0 + let createNewApp = apps.length === 0 || nameProvidedAsFlag if (!createNewApp) { createNewApp = await createAsNewAppPrompt() } if (createNewApp) { - const name = await appNamePrompt(localAppName) + const name = nameProvidedAsFlag ? localAppName : await appNamePrompt(localAppName) return {result: 'new', name, org} } else { const app = await selectAppPrompt(searchForAppsByNameFactory(developerPlatformClient, org.id), apps, hasMorePages) diff --git a/packages/cli/README.md b/packages/cli/README.md index 41a104121b..749c5ffbb3 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -581,8 +581,8 @@ Create a new app project ``` USAGE - $ shopify app init [--client-id | ] [--flavor ] [-n ] [--no-color] [-d - npm|yarn|pnpm|bun] [-p ] [--template ] [--verbose] + $ shopify app init [--client-id | ] [--flavor ] [-n ] [--no-color] [--organization-id + ] [-d npm|yarn|pnpm|bun] [-p ] [--template ] [--verbose] FLAGS -d, --package-manager=