Skip to content
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
5 changes: 5 additions & 0 deletions .changeset/grumpy-mirrors-exist.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion .github/workflows/cli-main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/shopify-cli.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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:
Expand Down
6 changes: 6 additions & 0 deletions docs-shopify.dev/commands/interfaces/app-init.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/<organization-id>
* @environment SHOPIFY_FLAG_ORGANIZATION_ID
*/
'--organization-id <value>'?: string

/**
*
* @environment SHOPIFY_FLAG_PACKAGE_MANAGER
Expand Down
11 changes: 10 additions & 1 deletion docs-shopify.dev/generated/generated_docs_data.json
Original file line number Diff line number Diff line change
Expand Up @@ -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>",
"value": "string",
"description": "The organization ID. Your organization ID can be found in your Dev Dashboard URL: https://dev.shopify.com/dashboard/<organization-id>",
"isOptional": true,
"environmentValue": "SHOPIFY_FLAG_ORGANIZATION_ID"
},
{
"filePath": "docs-shopify.dev/commands/interfaces/app-init.interface.ts",
"syntaxKind": "PropertySignature",
Expand Down Expand Up @@ -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 <value>'?: string\n\n /**\n * Which flavor of the given template to use.\n * @environment SHOPIFY_FLAG_TEMPLATE_FLAVOR\n */\n '--flavor <value>'?: string\n\n /**\n * \n * @environment SHOPIFY_FLAG_NAME\n */\n '-n, --name <value>'?: 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 <value>'?: string\n\n /**\n * \n * @environment SHOPIFY_FLAG_PATH\n */\n '-p, --path <value>'?: string\n\n /**\n * The app template. Accepts one of the following:\n - <reactRouter|remix|none>\n - Any GitHub repo with optional branch and subpath, e.g., https://github.com/Shopify/<repository>/[subpath]#[branch]\n * @environment SHOPIFY_FLAG_TEMPLATE\n */\n '--template <value>'?: 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 <value>'?: string\n\n /**\n * Which flavor of the given template to use.\n * @environment SHOPIFY_FLAG_TEMPLATE_FLAVOR\n */\n '--flavor <value>'?: string\n\n /**\n * \n * @environment SHOPIFY_FLAG_NAME\n */\n '-n, --name <value>'?: 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/<organization-id>\n * @environment SHOPIFY_FLAG_ORGANIZATION_ID\n */\n '--organization-id <value>'?: string\n\n /**\n * \n * @environment SHOPIFY_FLAG_PACKAGE_MANAGER\n */\n '-d, --package-manager <value>'?: string\n\n /**\n * \n * @environment SHOPIFY_FLAG_PATH\n */\n '-p, --path <value>'?: string\n\n /**\n * The app template. Accepts one of the following:\n - <reactRouter|remix|none>\n - Any GitHub repo with optional branch and subpath, e.g., https://github.com/Shopify/<repository>/[subpath]#[branch]\n * @environment SHOPIFY_FLAG_TEMPLATE\n */\n '--template <value>'?: string\n\n /**\n * Increase the verbosity of the output.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}"
}
}
}
Expand Down
156 changes: 156 additions & 0 deletions packages/app/src/cli/commands/app/init.test.ts
Original file line number Diff line number Diff line change
@@ -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()
}
})
})
36 changes: 32 additions & 4 deletions packages/app/src/cli/commands/app/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/<organization-id>',
env: 'SHOPIFY_FLAG_ORGANIZATION_ID',
}),
}

async run(): Promise<AppLinkedCommandOutput> {
Expand Down Expand Up @@ -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/<organization-id>',
)
}
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
}

Expand Down Expand Up @@ -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<SelectAppOrNewAppNameResult> {
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)
Expand Down
6 changes: 4 additions & 2 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -581,8 +581,8 @@ Create a new app project

```
USAGE
$ shopify app init [--client-id <value> | ] [--flavor <value>] [-n <value>] [--no-color] [-d
npm|yarn|pnpm|bun] [-p <value>] [--template <value>] [--verbose]
$ shopify app init [--client-id <value> | ] [--flavor <value>] [-n <value>] [--no-color] [--organization-id
<value>] [-d npm|yarn|pnpm|bun] [-p <value>] [--template <value>] [--verbose]

FLAGS
-d, --package-manager=<option> <options: npm|yarn|pnpm|bun>
Expand All @@ -592,6 +592,8 @@ FLAGS
existing app. Using this flag avoids the app selection prompt.
--flavor=<value> Which flavor of the given template to use.
--no-color Disable color output.
--organization-id=<value> The organization ID. Your organization ID can be found in your Dev Dashboard URL:
https://dev.shopify.com/dashboard/<organization-id>
--template=<value> The app template. Accepts one of the following:
- <reactRouter|remix|none>
- Any GitHub repo with optional branch and subpath, e.g.,
Expand Down
9 changes: 9 additions & 0 deletions packages/cli/oclif.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -1905,6 +1905,15 @@
"name": "no-color",
"type": "boolean"
},
"organization-id": {
"description": "The organization ID. Your organization ID can be found in your Dev Dashboard URL: https://dev.shopify.com/dashboard/<organization-id>",
"env": "SHOPIFY_FLAG_ORGANIZATION_ID",
"hasDynamicHelp": false,
"hidden": false,
"multiple": false,
"name": "organization-id",
"type": "option"
},
"package-manager": {
"char": "d",
"env": "SHOPIFY_FLAG_PACKAGE_MANAGER",
Expand Down
9 changes: 9 additions & 0 deletions packages/create-app/oclif.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,15 @@
"name": "no-color",
"type": "boolean"
},
"organization-id": {
"description": "The organization ID. Your organization ID can be found in your Dev Dashboard URL: https://dev.shopify.com/dashboard/<organization-id>",
"env": "SHOPIFY_FLAG_ORGANIZATION_ID",
"hasDynamicHelp": false,
"hidden": false,
"multiple": false,
"name": "organization-id",
"type": "option"
},
"package-manager": {
"char": "d",
"env": "SHOPIFY_FLAG_PACKAGE_MANAGER",
Expand Down