diff --git a/.changeset/grumpy-mirrors-exist.md b/.changeset/grumpy-mirrors-exist.md new file mode 100644 index 00000000000..af374618b2d --- /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/tests-main.yml b/.github/workflows/tests-main.yml index c8398a1af1e..9effb7157ae 100644 --- a/.github/workflows/tests-main.yml +++ b/.github/workflows/tests-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 # v1.23.0 diff --git a/.github/workflows/tests-pr.yml b/.github/workflows/tests-pr.yml index 7ac2b783bb7..4d9bac553c0 100644 --- a/.github/workflows/tests-pr.yml +++ b/.github/workflows/tests-pr.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 7e2265bb453..5c861ed2a42 100644 --- a/docs-shopify.dev/commands/interfaces/app-init.interface.ts +++ b/docs-shopify.dev/commands/interfaces/app-init.interface.ts @@ -13,7 +13,7 @@ export interface appinit { '--flavor '?: string /** - * + * The name for the new app. When provided, skips the app selection prompt and creates a new app with this name. * @environment SHOPIFY_FLAG_NAME */ '-n, --name '?: string @@ -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 d25a20bd95c..fedbd4203a1 100644 --- a/docs-shopify.dev/generated/generated_docs_data.json +++ b/docs-shopify.dev/generated/generated_docs_data.json @@ -2674,6 +2674,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", @@ -2706,7 +2715,7 @@ "syntaxKind": "PropertySignature", "name": "-n, --name ", "value": "string", - "description": "", + "description": "The name for the new app. When provided, skips the app selection prompt and creates a new app with this name.", "isOptional": true, "environmentValue": "SHOPIFY_FLAG_NAME" }, @@ -2720,7 +2729,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 * The name for the new app. When provided, skips the app selection prompt and creates a new app with this name.\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 00000000000..2be8f0daeec --- /dev/null +++ b/packages/app/src/cli/commands/app/init.test.ts @@ -0,0 +1,270 @@ +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 {fetchOrgFromId, NoOrgError} from '../../services/dev/fetch.js' +import {appNamePrompt, createAsNewAppPrompt, selectAppPrompt} from '../../prompts/dev.js' +import {validateFlavorValue, validateTemplateValue} from '../../services/init/validate.js' +import { + testAppLinked, + testDeveloperPlatformClient, + testOrganization, + testOrganizationApp, +} 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('../../services/dev/fetch.js', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + fetchOrgFromId: vi.fn(), + } +}) +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 fetchOrgFromId to return the organization + vi.mocked(fetchOrgFromId).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 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 fetchOrgFromId to throw NoOrgError for invalid organization + vi.mocked(fetchOrgFromId).mockRejectedValue( + new NoOrgError({type: 'UserAccount', email: 'test@example.com'}, 'invalid-org-id'), + ) + + 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('No Organization 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() + } + }) + + test('skips app selection prompts when organization has existing apps but --name flag is provided', async () => { + // Given + const mockOrganization = testOrganization() + const mockDeveloperPlatformClient = testDeveloperPlatformClient() + const mockApp = testAppLinked() + const existingApp = testOrganizationApp() + + mockAndCaptureOutput() + vi.mocked(validateTemplateValue).mockReturnValue(undefined) + vi.mocked(validateFlavorValue).mockReturnValue(undefined) + vi.mocked(inferPackageManager).mockReturnValue('npm') + vi.mocked(selectDeveloperPlatformClient).mockReturnValue(mockDeveloperPlatformClient) + + // Mock fetchOrgFromId to return the organization + vi.mocked(fetchOrgFromId).mockResolvedValue(mockOrganization) + + // Mock the orgAndApps method to return existing apps + vi.mocked(mockDeveloperPlatformClient.orgAndApps).mockResolvedValue({ + organization: mockOrganization, + apps: [existingApp], + 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-new-app', '--template', 'remix']) + + // Then + // Verify that app selection prompts were NOT called even though org has existing apps + expect(selectOrg).not.toHaveBeenCalled() + expect(createAsNewAppPrompt).not.toHaveBeenCalled() + expect(selectAppPrompt).not.toHaveBeenCalled() + expect(appNamePrompt).not.toHaveBeenCalled() + + // Verify the command completed successfully with the provided name + expect(initService).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'my-new-app', + packageManager: 'npm', + template: 'https://github.com/Shopify/shopify-app-template-remix', + }), + ) + }) + + test('fails with clear error message when --name flag is empty', async () => { + // Given + // 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) + + // When/Then + await expect(Init.run(['--name', '', '--template', 'remix'])).rejects.toThrow( + 'process.exit unexpectedly called with "1"', + ) + + // Verify the error message was displayed + expect(outputMock.error()).toContain("The --name flag can't be empty") + + // Verify initService was never called since validation failed + expect(initService).not.toHaveBeenCalled() + } finally { + consoleErrorSpy.mockRestore() + } + }) + + test('fails with clear error message when --name flag is whitespace only', async () => { + // Given + // 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) + + // When/Then + await expect(Init.run(['--name', ' ', '--template', 'remix'])).rejects.toThrow( + 'process.exit unexpectedly called with "1"', + ) + + // Verify the error message was displayed + expect(outputMock.error()).toContain("The --name flag can't be empty") + + // Verify initService was never called since validation failed + expect(initService).not.toHaveBeenCalled() + } finally { + consoleErrorSpy.mockRestore() + } + }) +}) diff --git a/packages/app/src/cli/commands/app/init.ts b/packages/app/src/cli/commands/app/init.ts index 9b4e7080041..09548496475 100644 --- a/packages/app/src/cli/commands/app/init.ts +++ b/packages/app/src/cli/commands/app/init.ts @@ -2,6 +2,7 @@ import initPrompt, {visibleTemplates} from '../../prompts/init/init.js' import initService from '../../services/init/init.js' import {DeveloperPlatformClient, selectDeveloperPlatformClient} from '../../utilities/developer-platform-client.js' import {appFromIdentifiers, selectOrg} from '../../services/context.js' +import {fetchOrgFromId} from '../../services/dev/fetch.js' import AppLinkedCommand, {AppLinkedCommandOutput} from '../../utilities/app-linked-command.js' import {validateFlavorValue, validateTemplateValue} from '../../services/init/validate.js' import {MinimalOrganizationApp, Organization, OrganizationApp} from '../../models/organization.js' @@ -27,6 +28,8 @@ export default class Init extends AppLinkedCommand { char: 'n', env: 'SHOPIFY_FLAG_NAME', hidden: false, + description: + 'The name for the new app. When provided, skips the app selection prompt and creates a new app with this name.', }), path: Flags.string({ char: 'p', @@ -64,6 +67,13 @@ 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', + exclusive: ['client-id'], + }), } async run(): Promise { @@ -72,6 +82,11 @@ export default class Init extends AppLinkedCommand { validateTemplateValue(flags.template) validateFlavorValue(flags.template, flags.flavor) + // Validate that --name is not empty/whitespace if provided + if (flags.name !== undefined && flags.name.trim() === '') { + throw new AbortError("The --name flag can't be empty", 'Provide a valid app name, for example: --name my-app') + } + const inferredPackageManager = inferPackageManager(flags['package-manager']) const name = flags.name ?? (await getAppName(flags.path)) @@ -93,10 +108,23 @@ 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 + org = await fetchOrgFromId(flags['organization-id'], developerPlatformClient) + } 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 +180,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 63fa0299535..e73865a2f92 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -806,19 +806,22 @@ 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 [--flavor ] [-n ] [--no-color] [--organization-id | [--client-id + | ]] [-d npm|yarn|pnpm|bun] [-p ] [--template ] [--verbose] FLAGS -d, --package-manager=