From 9349b1741214894418109647a4d92a640982f936 Mon Sep 17 00:00:00 2001 From: Suzie Kim Date: Thu, 20 Aug 2026 15:35:46 -0400 Subject: [PATCH 1/7] Add app channel-spec generate command Generates a deployable channel_config spec TOML from the Shopify-authored default channel specification. The TOML is produced and validated server-side by the Channels-owned exporter; the CLI handles app context, transport, and local file UX only. - shopify app channel-spec generate writes extensions/channel-config/specifications/.toml - --stdout prints only the TOML (warnings go to stderr) - --overwrite required to replace an existing file - Backend warnings ({code, message}) are rendered out-of-band, never into the TOML - Failure reason codes map to partner-facing guidance --- .../generated/generated_docs_data_v2.json | 92 +++++++++++ .../cli/commands/app/channel-spec/generate.ts | 52 ++++++ packages/app/src/cli/index.ts | 2 + .../src/cli/services/channel-spec/fetch.ts | 80 +++++++++ .../services/channel-spec/generate.test.ts | 155 ++++++++++++++++++ .../src/cli/services/channel-spec/generate.ts | 85 ++++++++++ packages/cli/README.md | 57 +++++++ packages/cli/oclif.manifest.json | 100 +++++++++++ 8 files changed, 623 insertions(+) create mode 100644 packages/app/src/cli/commands/app/channel-spec/generate.ts create mode 100644 packages/app/src/cli/services/channel-spec/fetch.ts create mode 100644 packages/app/src/cli/services/channel-spec/generate.test.ts create mode 100644 packages/app/src/cli/services/channel-spec/generate.ts diff --git a/docs-shopify.dev/generated/generated_docs_data_v2.json b/docs-shopify.dev/generated/generated_docs_data_v2.json index ff8e3489503..a8a1541638e 100644 --- a/docs-shopify.dev/generated/generated_docs_data_v2.json +++ b/docs-shopify.dev/generated/generated_docs_data_v2.json @@ -447,6 +447,98 @@ "value": "export interface appbulkstatus {\n /**\n * Alias of the Shopify account to use for authentication.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--auth-alias '?: string\n\n /**\n * The Client ID of your app.\n * @environment SHOPIFY_FLAG_CLIENT_ID\n */\n '--client-id '?: string\n\n /**\n * The name of the app configuration.\n * @environment SHOPIFY_FLAG_APP_CONFIG\n */\n '-c, --config '?: string\n\n /**\n * The bulk operation ID (numeric ID or full GID). If not provided, lists all bulk operations belonging to this app on this store in the last 7 days.\n * @environment SHOPIFY_FLAG_ID\n */\n '--id '?: string\n\n /**\n * Print the command's JSON schemas.\n * @environment SHOPIFY_FLAG_JSON_SCHEMA\n */\n '--json-schema'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * The path to your app directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path '?: string\n\n /**\n * Reset all your settings.\n * @environment SHOPIFY_FLAG_RESET\n */\n '--reset'?: ''\n\n /**\n * The store domain. Must be an existing dev store.\n * @environment SHOPIFY_FLAG_STORE\n */\n '-s, --store '?: string\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" } }, + "appchannelspecgenerate": { + "docs-shopify.dev/commands/interfaces/app-channel-spec-generate.interface.ts": { + "filePath": "docs-shopify.dev/commands/interfaces/app-channel-spec-generate.interface.ts", + "name": "appchannelspecgenerate", + "description": "The following flags are available for the `app channel-spec generate` command:", + "isPublicDocs": true, + "members": [ + { + "filePath": "docs-shopify.dev/commands/interfaces/app-channel-spec-generate.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--auth-alias ", + "value": "string", + "description": "Alias of the Shopify account to use for authentication.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_AUTH_ALIAS" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-channel-spec-generate.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--client-id ", + "value": "string", + "description": "The Client ID of your app.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_CLIENT_ID" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-channel-spec-generate.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--no-color", + "value": "''", + "description": "Disable color output.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_NO_COLOR" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-channel-spec-generate.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--overwrite", + "value": "''", + "description": "Overwrite the existing channel spec file if one already exists.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_OVERWRITE" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-channel-spec-generate.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--path ", + "value": "string", + "description": "The path to your app directory.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_PATH" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-channel-spec-generate.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--reset", + "value": "''", + "description": "Reset all your settings.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_RESET" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-channel-spec-generate.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--stdout", + "value": "''", + "description": "Print the generated TOML to stdout instead of writing it to a file.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_STDOUT" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-channel-spec-generate.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--verbose", + "value": "''", + "description": "Increase the verbosity of the output. May include sensitive data.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_VERBOSE" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-channel-spec-generate.interface.ts", + "syntaxKind": "PropertySignature", + "name": "-c, --config ", + "value": "string", + "description": "The name of the app configuration.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_APP_CONFIG" + } + ], + "value": "export interface appchannelspecgenerate {\n /**\n * Alias of the Shopify account to use for authentication.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--auth-alias '?: string\n\n /**\n * The Client ID of your app.\n * @environment SHOPIFY_FLAG_CLIENT_ID\n */\n '--client-id '?: string\n\n /**\n * The name of the app configuration.\n * @environment SHOPIFY_FLAG_APP_CONFIG\n */\n '-c, --config '?: string\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Overwrite the existing channel spec file if one already exists.\n * @environment SHOPIFY_FLAG_OVERWRITE\n */\n '--overwrite'?: ''\n\n /**\n * The path to your app directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path '?: string\n\n /**\n * Reset all your settings.\n * @environment SHOPIFY_FLAG_RESET\n */\n '--reset'?: ''\n\n /**\n * Print the generated TOML to stdout instead of writing it to a file.\n * @environment SHOPIFY_FLAG_STDOUT\n */\n '--stdout'?: ''\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" + } + }, "appconfiglink": { "docs-shopify.dev/commands/interfaces/app-config-link.interface.ts": { "filePath": "docs-shopify.dev/commands/interfaces/app-config-link.interface.ts", diff --git a/packages/app/src/cli/commands/app/channel-spec/generate.ts b/packages/app/src/cli/commands/app/channel-spec/generate.ts new file mode 100644 index 00000000000..01c4e56b842 --- /dev/null +++ b/packages/app/src/cli/commands/app/channel-spec/generate.ts @@ -0,0 +1,52 @@ +import {appFlags} from '../../../flags.js' +import {linkedAppContext} from '../../../services/app-context.js' +import {generateChannelSpec} from '../../../services/channel-spec/generate.js' +import AppLinkedCommand, {AppLinkedCommandOutput} from '../../../utilities/app-linked-command.js' +import {Flags} from '@oclif/core' +import {globalFlags} from '@shopify/cli-kit/node/cli' + +export default class ChannelSpecGenerate extends AppLinkedCommand { + static summary = 'Generate a channel spec TOML file from the Shopify-authored default.' + + static descriptionWithMarkdown = `Generates a deployable \`channel_config\` extension spec from the Shopify-authored default channel specification for your app. + + The generated TOML file contains only public \`channel_config\` fields. Review it, commit it to your app, then deploy it with \`shopify app deploy\`. This command never deploys the spec itself.` + + static description = this.descriptionWithoutMarkdown() + + static flags = { + ...globalFlags, + ...appFlags, + stdout: Flags.boolean({ + description: 'Print the generated TOML to stdout instead of writing it to a file.', + env: 'SHOPIFY_FLAG_STDOUT', + default: false, + }), + overwrite: Flags.boolean({ + description: 'Overwrite the existing channel spec file if one already exists.', + env: 'SHOPIFY_FLAG_OVERWRITE', + default: false, + }), + } + + public async run(): Promise { + const {flags} = await this.parse(ChannelSpecGenerate) + + const {app, remoteApp, developerPlatformClient} = await linkedAppContext({ + directory: flags.path, + clientId: flags['client-id'], + forceRelink: flags.reset, + userProvidedConfigName: flags.config, + }) + + await generateChannelSpec({ + app, + remoteApp, + developerPlatformClient, + stdout: flags.stdout, + overwrite: flags.overwrite, + }) + + return {app} + } +} diff --git a/packages/app/src/cli/index.ts b/packages/app/src/cli/index.ts index 347d1cf18cf..ca2ec2873d8 100644 --- a/packages/app/src/cli/index.ts +++ b/packages/app/src/cli/index.ts @@ -1,6 +1,7 @@ import Build from './commands/app/build.js' import BulkCancel from './commands/app/bulk/cancel.js' import BulkStatus from './commands/app/bulk/status.js' +import ChannelSpecGenerate from './commands/app/channel-spec/generate.js' import ConfigLink from './commands/app/config/link.js' import ConfigUse from './commands/app/config/use.js' import ConfigPull from './commands/app/config/pull.js' @@ -55,6 +56,7 @@ export const commands: {[key: string]: typeof AppLinkedCommand | typeof AppUnlin 'app:build': Build, 'app:bulk:cancel': BulkCancel, 'app:bulk:status': BulkStatus, + 'app:channel-spec:generate': ChannelSpecGenerate, 'app:deploy': Deploy, 'app:dev': Dev, 'app:dev:clean': DevClean, diff --git a/packages/app/src/cli/services/channel-spec/fetch.ts b/packages/app/src/cli/services/channel-spec/fetch.ts new file mode 100644 index 00000000000..b5ee33c276b --- /dev/null +++ b/packages/app/src/cli/services/channel-spec/fetch.ts @@ -0,0 +1,80 @@ +import {OrganizationApp} from '../../models/organization.js' +import {DeveloperPlatformClient} from '../../utilities/developer-platform-client.js' +import {appManagementHeaders} from '@shopify/cli-kit/node/api/app-management' +import {appManagementFqdn} from '@shopify/cli-kit/node/context/fqdn' +import {shopifyFetch} from '@shopify/cli-kit/node/http' +import {AbortError} from '@shopify/cli-kit/node/error' + +export interface ChannelSpecExportWarning { + code: string + message: string +} + +export type ChannelSpecExportResult = + | { + success: true + handle: string + filename: string + toml: string + warnings: ChannelSpecExportWarning[] + } + | { + success: false + reason: string + } + +interface FetchChannelSpecExportOptions { + remoteApp: OrganizationApp + developerPlatformClient: DeveloperPlatformClient +} + +/** + * Fetches the partner-safe channel spec export for an app. + * + * The export itself is produced server-side by the Channels-owned exporter, which projects the + * Shopify-authored default channel specification into the public channel_config schema and + * validates it before returning it. The CLI intentionally does not transform or validate the + * TOML locally: the backend response is the deployable artifact. + */ +export async function fetchChannelSpecExport({ + remoteApp, + developerPlatformClient, +}: FetchChannelSpecExportOptions): Promise { + const fqdn = await appManagementFqdn() + const url = `https://${fqdn}/app_management/unstable/organizations/${remoteApp.organizationId}/apps/${remoteApp.id}/channel_spec_export.json` + const token = (await developerPlatformClient.session()).token + + const response = await shopifyFetch(url, { + method: 'GET', + headers: appManagementHeaders(token), + }) + + if (response.status === 404) { + return {success: false, reason: 'no_exportable_frozen_record'} + } + + let payload: {[key: string]: unknown} + try { + payload = (await response.json()) as {[key: string]: unknown} + } catch { + throw new AbortError(`Failed to fetch the channel spec export: unexpected response (status ${response.status}).`) + } + + if (!response.ok) { + const reason = typeof payload.reason === 'string' ? payload.reason : `http_${response.status}` + return {success: false, reason} + } + + const {handle, filename, toml, warnings} = payload + if (typeof handle !== 'string' || typeof filename !== 'string' || typeof toml !== 'string') { + throw new AbortError('Failed to fetch the channel spec export: the response was missing required fields.') + } + + return { + success: true, + handle, + filename, + toml, + warnings: Array.isArray(warnings) ? (warnings as ChannelSpecExportWarning[]) : [], + } +} diff --git a/packages/app/src/cli/services/channel-spec/generate.test.ts b/packages/app/src/cli/services/channel-spec/generate.test.ts new file mode 100644 index 00000000000..81ab05f963f --- /dev/null +++ b/packages/app/src/cli/services/channel-spec/generate.test.ts @@ -0,0 +1,155 @@ +import {fetchChannelSpecExport} from './fetch.js' +import {generateChannelSpec, CHANNEL_SPEC_DIRECTORY} from './generate.js' +import {AppLinkedInterface} from '../../models/app/app.js' +import {testAppLinked, testDeveloperPlatformClient, testOrganizationApp} from '../../models/app/app.test-data.js' +import {describe, expect, test, vi} from 'vitest' +import {fileExists, inTemporaryDirectory, mkdir, readFile, writeFile} from '@shopify/cli-kit/node/fs' +import {dirname, joinPath} from '@shopify/cli-kit/node/path' +import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' + +vi.mock('./fetch.js') + +const TOML = 'handle = "example"\nlabel = "Example Channel"\n' + +function successResult(warnings: {code: string; message: string}[] = []) { + return { + success: true as const, + handle: 'example', + filename: 'example.toml', + toml: TOML, + warnings, + } +} + +function testOptions(app: AppLinkedInterface, {stdout = false, overwrite = false} = {}) { + return { + app, + remoteApp: testOrganizationApp(), + developerPlatformClient: testDeveloperPlatformClient(), + stdout, + overwrite, + } +} + +describe('generateChannelSpec', () => { + test('writes the TOML to the channel-config specifications directory', async () => { + await inTemporaryDirectory(async (tmpDir) => { + // Given + vi.mocked(fetchChannelSpecExport).mockResolvedValue(successResult()) + const app = testAppLinked({directory: tmpDir}) + const outputMock = mockAndCaptureOutput() + + // When + await generateChannelSpec(testOptions(app)) + + // Then + const outputPath = joinPath(tmpDir, CHANNEL_SPEC_DIRECTORY, 'example.toml') + await expect(fileExists(outputPath)).resolves.toBe(true) + await expect(readFile(outputPath)).resolves.toEqual(TOML) + expect(outputMock.info()).toContain('Generated a channel spec') + expect(outputMock.info()).toContain('shopify app deploy') + }) + }) + + test('refuses to overwrite an existing spec without --overwrite', async () => { + await inTemporaryDirectory(async (tmpDir) => { + // Given + vi.mocked(fetchChannelSpecExport).mockResolvedValue(successResult()) + const app = testAppLinked({directory: tmpDir}) + const outputPath = joinPath(tmpDir, CHANNEL_SPEC_DIRECTORY, 'example.toml') + await mkdir(dirname(outputPath)) + await writeFile(outputPath, 'existing = true\n') + + // When/Then + await expect(generateChannelSpec(testOptions(app))).rejects.toThrow(/already exists/) + await expect(readFile(outputPath)).resolves.toEqual('existing = true\n') + }) + }) + + test('overwrites an existing spec with --overwrite', async () => { + await inTemporaryDirectory(async (tmpDir) => { + // Given + vi.mocked(fetchChannelSpecExport).mockResolvedValue(successResult()) + const app = testAppLinked({directory: tmpDir}) + const outputPath = joinPath(tmpDir, CHANNEL_SPEC_DIRECTORY, 'example.toml') + await mkdir(dirname(outputPath)) + await writeFile(outputPath, 'existing = true\n') + + // When + await generateChannelSpec(testOptions(app, {overwrite: true})) + + // Then + await expect(readFile(outputPath)).resolves.toEqual(TOML) + }) + }) + + test('prints only the TOML to stdout with --stdout, keeping warnings out-of-band', async () => { + await inTemporaryDirectory(async (tmpDir) => { + // Given + const warning = { + code: 'automatic_product_feed_management', + message: + 'This generated spec enables automatic product feed management. Review the generated configuration before deploying.', + } + vi.mocked(fetchChannelSpecExport).mockResolvedValue(successResult([warning])) + const app = testAppLinked({directory: tmpDir}) + const outputMock = mockAndCaptureOutput() + + // When + await generateChannelSpec(testOptions(app, {stdout: true})) + + // Then + expect(outputMock.output()).toContain(TOML) + expect(outputMock.output()).not.toContain(warning.code) + expect(outputMock.warn()).toContain(warning.message) + await expect(fileExists(joinPath(tmpDir, CHANNEL_SPEC_DIRECTORY, 'example.toml'))).resolves.toBe(false) + }) + }) + + test('renders backend warnings when writing the file', async () => { + await inTemporaryDirectory(async (tmpDir) => { + // Given + const warning = { + code: 'automatic_product_feed_management', + message: + 'This generated spec enables automatic product feed management. Review the generated configuration before deploying.', + } + vi.mocked(fetchChannelSpecExport).mockResolvedValue(successResult([warning])) + const app = testAppLinked({directory: tmpDir}) + const outputMock = mockAndCaptureOutput() + + // When + await generateChannelSpec(testOptions(app)) + + // Then + expect(outputMock.warn()).toContain(warning.message) + await expect(readFile(joinPath(tmpDir, CHANNEL_SPEC_DIRECTORY, 'example.toml'))).resolves.not.toContain( + warning.message, + ) + }) + }) + + test('aborts with partner-facing guidance when no export is available', async () => { + await inTemporaryDirectory(async (tmpDir) => { + // Given + vi.mocked(fetchChannelSpecExport).mockResolvedValue({success: false, reason: 'no_exportable_frozen_record'}) + const app = testAppLinked({directory: tmpDir}) + + // When/Then + await expect(generateChannelSpec(testOptions(app))).rejects.toThrow( + /No deployable channel spec is available for this app yet/, + ) + }) + }) + + test('aborts with the reason code when the backend returns an unknown reason', async () => { + await inTemporaryDirectory(async (tmpDir) => { + // Given + vi.mocked(fetchChannelSpecExport).mockResolvedValue({success: false, reason: 'mystery_reason'}) + const app = testAppLinked({directory: tmpDir}) + + // When/Then + await expect(generateChannelSpec(testOptions(app))).rejects.toThrow(/mystery_reason/) + }) + }) +}) diff --git a/packages/app/src/cli/services/channel-spec/generate.ts b/packages/app/src/cli/services/channel-spec/generate.ts new file mode 100644 index 00000000000..8c251116163 --- /dev/null +++ b/packages/app/src/cli/services/channel-spec/generate.ts @@ -0,0 +1,85 @@ +import {fetchChannelSpecExport, ChannelSpecExportWarning} from './fetch.js' +import {AppLinkedInterface} from '../../models/app/app.js' +import {OrganizationApp} from '../../models/organization.js' +import {DeveloperPlatformClient} from '../../utilities/developer-platform-client.js' +import {AbortError} from '@shopify/cli-kit/node/error' +import {fileExists, mkdir, writeFile} from '@shopify/cli-kit/node/fs' +import {dirname, joinPath, relativePath} from '@shopify/cli-kit/node/path' +import {outputResult, outputWarn} from '@shopify/cli-kit/node/output' +import {renderSuccess, renderWarning} from '@shopify/cli-kit/node/ui' + +export const CHANNEL_SPEC_DIRECTORY = joinPath('extensions', 'channel-config', 'specifications') + +const FAILURE_MESSAGES: {[reason: string]: string} = { + no_exportable_frozen_record: + 'No deployable channel spec is available for this app yet.\n\n' + + "The Shopify-authored default can't currently be exported to the public channel_config schema.", + multiple_exportable_records: + "This app has more than one Shopify-authored channel spec, so a single spec can't be exported automatically.", + not_allowlisted: "This app isn't part of the channel spec export prototype yet.", + contains_no_public_fields: + 'The Shopify-authored default for this app contains no fields that are part of the public channel_config schema.', + invalid_public_schema: + 'The Shopify-authored default for this app could not be projected into a valid public channel_config spec.', +} + +export interface GenerateChannelSpecOptions { + app: AppLinkedInterface + remoteApp: OrganizationApp + developerPlatformClient: DeveloperPlatformClient + stdout: boolean + overwrite: boolean +} + +/** + * Generates a deployable channel_config spec TOML file for the app. + * + * On success the TOML is either printed to stdout (`--stdout`) or written to + * `extensions/channel-config/specifications/.toml` inside the app directory. Warnings + * returned by the backend are rendered out-of-band and are never written into the TOML file. + * This command never deploys; the partner reviews the generated file and runs `shopify app deploy`. + */ +export async function generateChannelSpec(options: GenerateChannelSpecOptions): Promise { + const {app, remoteApp, developerPlatformClient, stdout, overwrite} = options + + const result = await fetchChannelSpecExport({remoteApp, developerPlatformClient}) + + if (!result.success) { + const message = FAILURE_MESSAGES[result.reason] + if (message) throw new AbortError(message) + throw new AbortError(`The channel spec for this app could not be exported (reason: ${result.reason}).`) + } + + if (stdout) { + // Warnings go to stderr so stdout carries only the TOML and stays pipeable. + result.warnings.forEach((warning) => outputWarn(warning.message)) + outputResult(result.toml) + return + } + + const outputPath = joinPath(app.directory, CHANNEL_SPEC_DIRECTORY, result.filename) + if (!overwrite && (await fileExists(outputPath))) { + throw new AbortError( + `A channel spec already exists at ${relativePath(app.directory, outputPath)}.`, + 'Re-run with `--overwrite` to replace it.', + ) + } + + await mkdir(dirname(outputPath)) + await writeFile(outputPath, result.toml) + + result.warnings.forEach((warning) => renderExportWarning(warning)) + + renderSuccess({ + headline: ['Generated a channel spec for', {userInput: remoteApp.title}, {char: '.'}], + body: ['The spec was written to', {filePath: relativePath(app.directory, outputPath)}, {char: '.'}], + nextSteps: [ + 'Review the generated spec before deploying it.', + ['Run', {command: 'shopify app deploy'}, 'to deploy the spec as part of your app.'], + ], + }) +} + +function renderExportWarning(warning: ChannelSpecExportWarning): void { + renderWarning({body: warning.message}) +} diff --git a/packages/cli/README.md b/packages/cli/README.md index 38c5dc2c8ff..df7e3d52161 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -4,6 +4,7 @@ * [`shopify app bulk cancel`](#shopify-app-bulk-cancel) * [`shopify app bulk execute`](#shopify-app-bulk-execute) * [`shopify app bulk status`](#shopify-app-bulk-status) +* [`shopify app channel-spec generate`](#shopify-app-channel-spec-generate) * [`shopify app config link`](#shopify-app-config-link) * [`shopify app config pull`](#shopify-app-config-pull) * [`shopify app config use [config] [flags]`](#shopify-app-config-use-config-flags) @@ -383,6 +384,62 @@ DESCRIPTION Use "`bulk execute`" (https://shopify.dev/docs/api/shopify-cli/app/app-bulk-execute) to start a new bulk operation. ``` +## `shopify app channel-spec generate` + +Generate a channel spec TOML file from the Shopify-authored default. + +``` +USAGE + $ shopify app channel-spec generate [--auth-alias ] [--client-id | -c ] [--no-color] [--overwrite] + [--path ] [--reset | ] [--stdout] [--verbose] + +FLAGS + -c, --config= + The name of the app configuration. + [env: SHOPIFY_FLAG_APP_CONFIG] + + --auth-alias= + Alias of the Shopify account to use for authentication. + [env: SHOPIFY_FLAG_AUTH_ALIAS] + + --client-id= + The Client ID of your app. + [env: SHOPIFY_FLAG_CLIENT_ID] + + --no-color + Disable color output. + [env: SHOPIFY_FLAG_NO_COLOR] + + --overwrite + Overwrite the existing channel spec file if one already exists. + [env: SHOPIFY_FLAG_OVERWRITE] + + --path= + The path to your app directory. + [env: SHOPIFY_FLAG_PATH] + + --reset + Reset all your settings. + [env: SHOPIFY_FLAG_RESET] + + --stdout + Print the generated TOML to stdout instead of writing it to a file. + [env: SHOPIFY_FLAG_STDOUT] + + --verbose + Increase the verbosity of the output. May include sensitive data. + [env: SHOPIFY_FLAG_VERBOSE] + +DESCRIPTION + Generate a channel spec TOML file from the Shopify-authored default. + + Generates a deployable `channel_config` extension spec from the Shopify-authored default channel specification for + your app. + + The generated TOML file contains only public `channel_config` fields. Review it, commit it to your app, then deploy it + with `shopify app deploy`. This command never deploys the spec itself. +``` + ## `shopify app config link` Fetch your app configuration from the Developer Dashboard. diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 1b88620390e..3603cfc8c29 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -490,6 +490,106 @@ "strict": true, "summary": "Check the status of bulk operations." }, + "app:channel-spec:generate": { + "aliases": [ + ], + "args": { + }, + "customPluginName": "@shopify/app", + "description": "Generates a deployable `channel_config` extension spec from the Shopify-authored default channel specification for your app.\n\n The generated TOML file contains only public `channel_config` fields. Review it, commit it to your app, then deploy it with `shopify app deploy`. This command never deploys the spec itself.", + "descriptionWithMarkdown": "Generates a deployable `channel_config` extension spec from the Shopify-authored default channel specification for your app.\n\n The generated TOML file contains only public `channel_config` fields. Review it, commit it to your app, then deploy it with `shopify app deploy`. This command never deploys the spec itself.", + "flags": { + "auth-alias": { + "description": "Alias of the Shopify account to use for authentication.", + "env": "SHOPIFY_FLAG_AUTH_ALIAS", + "hasDynamicHelp": false, + "multiple": false, + "name": "auth-alias", + "type": "option" + }, + "client-id": { + "description": "The Client ID of your app.", + "env": "SHOPIFY_FLAG_CLIENT_ID", + "exclusive": [ + "config" + ], + "hasDynamicHelp": false, + "hidden": false, + "multiple": false, + "name": "client-id", + "type": "option" + }, + "config": { + "char": "c", + "description": "The name of the app configuration.", + "env": "SHOPIFY_FLAG_APP_CONFIG", + "hasDynamicHelp": false, + "hidden": false, + "multiple": false, + "name": "config", + "type": "option" + }, + "no-color": { + "allowNo": false, + "description": "Disable color output.", + "env": "SHOPIFY_FLAG_NO_COLOR", + "hidden": false, + "name": "no-color", + "type": "boolean" + }, + "overwrite": { + "allowNo": false, + "description": "Overwrite the existing channel spec file if one already exists.", + "env": "SHOPIFY_FLAG_OVERWRITE", + "name": "overwrite", + "type": "boolean" + }, + "path": { + "description": "The path to your app directory.", + "env": "SHOPIFY_FLAG_PATH", + "hasDynamicHelp": false, + "multiple": false, + "name": "path", + "noCacheDefault": true, + "type": "option" + }, + "reset": { + "allowNo": false, + "description": "Reset all your settings.", + "env": "SHOPIFY_FLAG_RESET", + "exclusive": [ + "config" + ], + "hidden": false, + "name": "reset", + "type": "boolean" + }, + "stdout": { + "allowNo": false, + "description": "Print the generated TOML to stdout instead of writing it to a file.", + "env": "SHOPIFY_FLAG_STDOUT", + "name": "stdout", + "type": "boolean" + }, + "verbose": { + "allowNo": false, + "description": "Increase the verbosity of the output. May include sensitive data.", + "env": "SHOPIFY_FLAG_VERBOSE", + "hidden": false, + "name": "verbose", + "type": "boolean" + } + }, + "hasDynamicHelp": false, + "hiddenAliases": [ + ], + "id": "app:channel-spec:generate", + "pluginAlias": "@shopify/cli", + "pluginName": "@shopify/cli", + "pluginType": "core", + "strict": true, + "summary": "Generate a channel spec TOML file from the Shopify-authored default." + }, "app:config:link": { "aliases": [ ], From e602d26e1c24f33309689648c1a31baeda66036b Mon Sep 17 00:00:00 2001 From: Suzie Kim Date: Fri, 21 Aug 2026 16:45:00 -0400 Subject: [PATCH 2/7] Treat 404 as endpoint-unavailable and validate warning shape in channel spec fetch - A 404 is not part of the export contract (failures are 422 with a reason code), so stop mapping it to no_exportable_frozen_record and abort with an endpoint-unavailable message instead. - Validate that backend warnings are {code, message} objects before rendering them, dropping malformed entries. --- .../src/cli/services/channel-spec/fetch.ts | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/packages/app/src/cli/services/channel-spec/fetch.ts b/packages/app/src/cli/services/channel-spec/fetch.ts index b5ee33c276b..0c847b82f23 100644 --- a/packages/app/src/cli/services/channel-spec/fetch.ts +++ b/packages/app/src/cli/services/channel-spec/fetch.ts @@ -50,7 +50,12 @@ export async function fetchChannelSpecExport({ }) if (response.status === 404) { - return {success: false, reason: 'no_exportable_frozen_record'} + // A 404 is not part of the export contract (failures are 422 with a reason code). It means the + // export endpoint isn't available (not deployed yet), or the app/organization couldn't be found. + throw new AbortError( + 'The channel spec export endpoint is not available for this app.', + 'Confirm the app and organization are correct, and that the channel spec export backend is available.', + ) } let payload: {[key: string]: unknown} @@ -75,6 +80,21 @@ export async function fetchChannelSpecExport({ handle, filename, toml, - warnings: Array.isArray(warnings) ? (warnings as ChannelSpecExportWarning[]) : [], + warnings: parseWarnings(warnings), } } + +function parseWarnings(warnings: unknown): ChannelSpecExportWarning[] { + if (!Array.isArray(warnings)) return [] + return warnings.flatMap((warning) => { + if ( + warning && + typeof warning === 'object' && + typeof (warning as {code?: unknown}).code === 'string' && + typeof (warning as {message?: unknown}).message === 'string' + ) { + return [warning as ChannelSpecExportWarning] + } + return [] + }) +} From 499a942bcf78ef7ae143b1d80214081102c5af53 Mon Sep 17 00:00:00 2001 From: Suzie Kim Date: Mon, 14 Sep 2026 15:33:58 -0400 Subject: [PATCH 3/7] Rename command to `shopify app import channel-config` Per CLI team feedback, the command previously named `shopify app channel-spec generate` is now `shopify app import channel-config`: - Move commands/app/channel-spec/generate.ts to commands/app/import/channel-config.ts and rename the class to ImportChannelConfig (path mirrors the new oclif ID app:import:channel-config for lazy loading). - Rename services/channel-spec/ to services/import-channel-config/ and generateChannelSpec to importChannelConfig. - Register the new command ID in packages/app/src/cli/index.ts and add the "app:import" oclif topic in packages/cli/package.json. - Regenerate oclif.manifest.json, README.md, and dev docs JSON. Assisted-By: devx/3abb0030-fdc9-44fb-8c5f-e2f47d8caf39 --- .../generated/generated_docs_data_v2.json | 184 ++++++++-------- .../generate.ts => import/channel-config.ts} | 12 +- packages/app/src/cli/index.ts | 4 +- .../fetch.ts | 0 .../import.test.ts} | 20 +- .../import.ts} | 8 +- packages/cli/oclif.manifest.json | 200 +++++++++--------- 7 files changed, 214 insertions(+), 214 deletions(-) rename packages/app/src/cli/commands/app/{channel-spec/generate.ts => import/channel-config.ts} (73%) rename packages/app/src/cli/services/{channel-spec => import-channel-config}/fetch.ts (100%) rename packages/app/src/cli/services/{channel-spec/generate.test.ts => import-channel-config/import.test.ts} (89%) rename packages/app/src/cli/services/{channel-spec/generate.ts => import-channel-config/import.ts} (91%) diff --git a/docs-shopify.dev/generated/generated_docs_data_v2.json b/docs-shopify.dev/generated/generated_docs_data_v2.json index a8a1541638e..e7aa98aae4d 100644 --- a/docs-shopify.dev/generated/generated_docs_data_v2.json +++ b/docs-shopify.dev/generated/generated_docs_data_v2.json @@ -447,98 +447,6 @@ "value": "export interface appbulkstatus {\n /**\n * Alias of the Shopify account to use for authentication.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--auth-alias '?: string\n\n /**\n * The Client ID of your app.\n * @environment SHOPIFY_FLAG_CLIENT_ID\n */\n '--client-id '?: string\n\n /**\n * The name of the app configuration.\n * @environment SHOPIFY_FLAG_APP_CONFIG\n */\n '-c, --config '?: string\n\n /**\n * The bulk operation ID (numeric ID or full GID). If not provided, lists all bulk operations belonging to this app on this store in the last 7 days.\n * @environment SHOPIFY_FLAG_ID\n */\n '--id '?: string\n\n /**\n * Print the command's JSON schemas.\n * @environment SHOPIFY_FLAG_JSON_SCHEMA\n */\n '--json-schema'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * The path to your app directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path '?: string\n\n /**\n * Reset all your settings.\n * @environment SHOPIFY_FLAG_RESET\n */\n '--reset'?: ''\n\n /**\n * The store domain. Must be an existing dev store.\n * @environment SHOPIFY_FLAG_STORE\n */\n '-s, --store '?: string\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" } }, - "appchannelspecgenerate": { - "docs-shopify.dev/commands/interfaces/app-channel-spec-generate.interface.ts": { - "filePath": "docs-shopify.dev/commands/interfaces/app-channel-spec-generate.interface.ts", - "name": "appchannelspecgenerate", - "description": "The following flags are available for the `app channel-spec generate` command:", - "isPublicDocs": true, - "members": [ - { - "filePath": "docs-shopify.dev/commands/interfaces/app-channel-spec-generate.interface.ts", - "syntaxKind": "PropertySignature", - "name": "--auth-alias ", - "value": "string", - "description": "Alias of the Shopify account to use for authentication.", - "isOptional": true, - "environmentValue": "SHOPIFY_FLAG_AUTH_ALIAS" - }, - { - "filePath": "docs-shopify.dev/commands/interfaces/app-channel-spec-generate.interface.ts", - "syntaxKind": "PropertySignature", - "name": "--client-id ", - "value": "string", - "description": "The Client ID of your app.", - "isOptional": true, - "environmentValue": "SHOPIFY_FLAG_CLIENT_ID" - }, - { - "filePath": "docs-shopify.dev/commands/interfaces/app-channel-spec-generate.interface.ts", - "syntaxKind": "PropertySignature", - "name": "--no-color", - "value": "''", - "description": "Disable color output.", - "isOptional": true, - "environmentValue": "SHOPIFY_FLAG_NO_COLOR" - }, - { - "filePath": "docs-shopify.dev/commands/interfaces/app-channel-spec-generate.interface.ts", - "syntaxKind": "PropertySignature", - "name": "--overwrite", - "value": "''", - "description": "Overwrite the existing channel spec file if one already exists.", - "isOptional": true, - "environmentValue": "SHOPIFY_FLAG_OVERWRITE" - }, - { - "filePath": "docs-shopify.dev/commands/interfaces/app-channel-spec-generate.interface.ts", - "syntaxKind": "PropertySignature", - "name": "--path ", - "value": "string", - "description": "The path to your app directory.", - "isOptional": true, - "environmentValue": "SHOPIFY_FLAG_PATH" - }, - { - "filePath": "docs-shopify.dev/commands/interfaces/app-channel-spec-generate.interface.ts", - "syntaxKind": "PropertySignature", - "name": "--reset", - "value": "''", - "description": "Reset all your settings.", - "isOptional": true, - "environmentValue": "SHOPIFY_FLAG_RESET" - }, - { - "filePath": "docs-shopify.dev/commands/interfaces/app-channel-spec-generate.interface.ts", - "syntaxKind": "PropertySignature", - "name": "--stdout", - "value": "''", - "description": "Print the generated TOML to stdout instead of writing it to a file.", - "isOptional": true, - "environmentValue": "SHOPIFY_FLAG_STDOUT" - }, - { - "filePath": "docs-shopify.dev/commands/interfaces/app-channel-spec-generate.interface.ts", - "syntaxKind": "PropertySignature", - "name": "--verbose", - "value": "''", - "description": "Increase the verbosity of the output. May include sensitive data.", - "isOptional": true, - "environmentValue": "SHOPIFY_FLAG_VERBOSE" - }, - { - "filePath": "docs-shopify.dev/commands/interfaces/app-channel-spec-generate.interface.ts", - "syntaxKind": "PropertySignature", - "name": "-c, --config ", - "value": "string", - "description": "The name of the app configuration.", - "isOptional": true, - "environmentValue": "SHOPIFY_FLAG_APP_CONFIG" - } - ], - "value": "export interface appchannelspecgenerate {\n /**\n * Alias of the Shopify account to use for authentication.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--auth-alias '?: string\n\n /**\n * The Client ID of your app.\n * @environment SHOPIFY_FLAG_CLIENT_ID\n */\n '--client-id '?: string\n\n /**\n * The name of the app configuration.\n * @environment SHOPIFY_FLAG_APP_CONFIG\n */\n '-c, --config '?: string\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Overwrite the existing channel spec file if one already exists.\n * @environment SHOPIFY_FLAG_OVERWRITE\n */\n '--overwrite'?: ''\n\n /**\n * The path to your app directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path '?: string\n\n /**\n * Reset all your settings.\n * @environment SHOPIFY_FLAG_RESET\n */\n '--reset'?: ''\n\n /**\n * Print the generated TOML to stdout instead of writing it to a file.\n * @environment SHOPIFY_FLAG_STDOUT\n */\n '--stdout'?: ''\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" - } - }, "appconfiglink": { "docs-shopify.dev/commands/interfaces/app-config-link.interface.ts": { "filePath": "docs-shopify.dev/commands/interfaces/app-config-link.interface.ts", @@ -2456,6 +2364,98 @@ "value": "export interface appgraphiql {\n /**\n * Alias of the Shopify account to use for authentication.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--auth-alias '?: string\n\n /**\n * The Client ID of your app.\n * @environment SHOPIFY_FLAG_CLIENT_ID\n */\n '--client-id '?: string\n\n /**\n * The name of the app configuration.\n * @environment SHOPIFY_FLAG_APP_CONFIG\n */\n '-c, --config '?: string\n\n /**\n * Print the command's JSON schemas.\n * @environment SHOPIFY_FLAG_JSON_SCHEMA\n */\n '--json-schema'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * The path to your app directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path '?: string\n\n /**\n * Local port for the GraphiQL server. Must be between 1 and 65535.\n * @environment SHOPIFY_FLAG_PORT\n */\n '--port '?: string\n\n /**\n * Reset all your settings.\n * @environment SHOPIFY_FLAG_RESET\n */\n '--reset'?: ''\n\n /**\n * The myshopify.com domain of the store to open GraphiQL against. The app must be installed on the store. If not specified, you will be prompted to select a store.\n * @environment SHOPIFY_FLAG_STORE\n */\n '-s, --store '?: string\n\n /**\n * The values for any GraphQL variables in your query or mutation, in JSON format.\n * @environment SHOPIFY_FLAG_VARIABLES\n */\n '-v, --variables '?: string\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n\n /**\n * The API version to use in GraphiQL. Defaults to the latest stable version.\n * @environment SHOPIFY_FLAG_VERSION\n */\n '--version '?: string\n}" } }, + "appimportchannelconfig": { + "docs-shopify.dev/commands/interfaces/app-import-channel-config.interface.ts": { + "filePath": "docs-shopify.dev/commands/interfaces/app-import-channel-config.interface.ts", + "name": "appimportchannelconfig", + "description": "The following flags are available for the `app import channel-config` command:", + "isPublicDocs": true, + "members": [ + { + "filePath": "docs-shopify.dev/commands/interfaces/app-import-channel-config.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--auth-alias ", + "value": "string", + "description": "Alias of the Shopify account to use for authentication.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_AUTH_ALIAS" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-import-channel-config.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--client-id ", + "value": "string", + "description": "The Client ID of your app.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_CLIENT_ID" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-import-channel-config.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--no-color", + "value": "''", + "description": "Disable color output.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_NO_COLOR" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-import-channel-config.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--overwrite", + "value": "''", + "description": "Overwrite the existing channel spec file if one already exists.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_OVERWRITE" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-import-channel-config.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--path ", + "value": "string", + "description": "The path to your app directory.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_PATH" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-import-channel-config.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--reset", + "value": "''", + "description": "Reset all your settings.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_RESET" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-import-channel-config.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--stdout", + "value": "''", + "description": "Print the generated TOML to stdout instead of writing it to a file.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_STDOUT" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-import-channel-config.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--verbose", + "value": "''", + "description": "Increase the verbosity of the output. May include sensitive data.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_VERBOSE" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-import-channel-config.interface.ts", + "syntaxKind": "PropertySignature", + "name": "-c, --config ", + "value": "string", + "description": "The name of the app configuration.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_APP_CONFIG" + } + ], + "value": "export interface appimportchannelconfig {\n /**\n * Alias of the Shopify account to use for authentication.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--auth-alias '?: string\n\n /**\n * The Client ID of your app.\n * @environment SHOPIFY_FLAG_CLIENT_ID\n */\n '--client-id '?: string\n\n /**\n * The name of the app configuration.\n * @environment SHOPIFY_FLAG_APP_CONFIG\n */\n '-c, --config '?: string\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Overwrite the existing channel spec file if one already exists.\n * @environment SHOPIFY_FLAG_OVERWRITE\n */\n '--overwrite'?: ''\n\n /**\n * The path to your app directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path '?: string\n\n /**\n * Reset all your settings.\n * @environment SHOPIFY_FLAG_RESET\n */\n '--reset'?: ''\n\n /**\n * Print the generated TOML to stdout instead of writing it to a file.\n * @environment SHOPIFY_FLAG_STDOUT\n */\n '--stdout'?: ''\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" + } + }, "appimportcustomdatadefinitions": { "docs-shopify.dev/commands/interfaces/app-import-custom-data-definitions.interface.ts": { "filePath": "docs-shopify.dev/commands/interfaces/app-import-custom-data-definitions.interface.ts", diff --git a/packages/app/src/cli/commands/app/channel-spec/generate.ts b/packages/app/src/cli/commands/app/import/channel-config.ts similarity index 73% rename from packages/app/src/cli/commands/app/channel-spec/generate.ts rename to packages/app/src/cli/commands/app/import/channel-config.ts index 01c4e56b842..70954551f18 100644 --- a/packages/app/src/cli/commands/app/channel-spec/generate.ts +++ b/packages/app/src/cli/commands/app/import/channel-config.ts @@ -1,14 +1,14 @@ import {appFlags} from '../../../flags.js' import {linkedAppContext} from '../../../services/app-context.js' -import {generateChannelSpec} from '../../../services/channel-spec/generate.js' +import {importChannelConfig} from '../../../services/import-channel-config/import.js' import AppLinkedCommand, {AppLinkedCommandOutput} from '../../../utilities/app-linked-command.js' import {Flags} from '@oclif/core' import {globalFlags} from '@shopify/cli-kit/node/cli' -export default class ChannelSpecGenerate extends AppLinkedCommand { - static summary = 'Generate a channel spec TOML file from the Shopify-authored default.' +export default class ImportChannelConfig extends AppLinkedCommand { + static summary = 'Import the Shopify-authored default channel spec as a channel_config TOML file.' - static descriptionWithMarkdown = `Generates a deployable \`channel_config\` extension spec from the Shopify-authored default channel specification for your app. + static descriptionWithMarkdown = `Imports the Shopify-authored default channel specification for your app as a deployable \`channel_config\` extension spec. The generated TOML file contains only public \`channel_config\` fields. Review it, commit it to your app, then deploy it with \`shopify app deploy\`. This command never deploys the spec itself.` @@ -30,7 +30,7 @@ export default class ChannelSpecGenerate extends AppLinkedCommand { } public async run(): Promise { - const {flags} = await this.parse(ChannelSpecGenerate) + const {flags} = await this.parse(ImportChannelConfig) const {app, remoteApp, developerPlatformClient} = await linkedAppContext({ directory: flags.path, @@ -39,7 +39,7 @@ export default class ChannelSpecGenerate extends AppLinkedCommand { userProvidedConfigName: flags.config, }) - await generateChannelSpec({ + await importChannelConfig({ app, remoteApp, developerPlatformClient, diff --git a/packages/app/src/cli/index.ts b/packages/app/src/cli/index.ts index ca2ec2873d8..d5090029547 100644 --- a/packages/app/src/cli/index.ts +++ b/packages/app/src/cli/index.ts @@ -1,7 +1,6 @@ import Build from './commands/app/build.js' import BulkCancel from './commands/app/bulk/cancel.js' import BulkStatus from './commands/app/bulk/status.js' -import ChannelSpecGenerate from './commands/app/channel-spec/generate.js' import ConfigLink from './commands/app/config/link.js' import ConfigUse from './commands/app/config/use.js' import ConfigPull from './commands/app/config/pull.js' @@ -43,6 +42,7 @@ import AppLinkedCommand from './utilities/app-linked-command.js' import DevClean from './commands/app/dev/clean.js' import AppUnlinkedCommand from './utilities/app-unlinked-command.js' import FunctionInfo from './commands/app/function/info.js' +import ImportChannelConfig from './commands/app/import/channel-config.js' import ImportCustomDataDefinitions from './commands/app/import/custom-data-definitions.js' import ImportCustomDataDefinitionsDeprecated from './commands/app/import-custom-data-definitions.js' import OrganizationList from './commands/organization/list.js' @@ -56,7 +56,6 @@ export const commands: {[key: string]: typeof AppLinkedCommand | typeof AppUnlin 'app:build': Build, 'app:bulk:cancel': BulkCancel, 'app:bulk:status': BulkStatus, - 'app:channel-spec:generate': ChannelSpecGenerate, 'app:deploy': Deploy, 'app:dev': Dev, 'app:dev:clean': DevClean, @@ -65,6 +64,7 @@ export const commands: {[key: string]: typeof AppLinkedCommand | typeof AppUnlin 'app:doctor': Doctor, 'app:logs': Logs, 'app:logs:sources': Sources, + 'app:import:channel-config': ImportChannelConfig, 'app:import:custom-data-definitions': ImportCustomDataDefinitions, 'app:import:dashboard-extensions': ImportDashboardExtensions, // Deprecated paths, kept registered (and hidden) so existing scripts keep working. diff --git a/packages/app/src/cli/services/channel-spec/fetch.ts b/packages/app/src/cli/services/import-channel-config/fetch.ts similarity index 100% rename from packages/app/src/cli/services/channel-spec/fetch.ts rename to packages/app/src/cli/services/import-channel-config/fetch.ts diff --git a/packages/app/src/cli/services/channel-spec/generate.test.ts b/packages/app/src/cli/services/import-channel-config/import.test.ts similarity index 89% rename from packages/app/src/cli/services/channel-spec/generate.test.ts rename to packages/app/src/cli/services/import-channel-config/import.test.ts index 81ab05f963f..1cc7f18d0a2 100644 --- a/packages/app/src/cli/services/channel-spec/generate.test.ts +++ b/packages/app/src/cli/services/import-channel-config/import.test.ts @@ -1,5 +1,5 @@ import {fetchChannelSpecExport} from './fetch.js' -import {generateChannelSpec, CHANNEL_SPEC_DIRECTORY} from './generate.js' +import {importChannelConfig, CHANNEL_SPEC_DIRECTORY} from './import.js' import {AppLinkedInterface} from '../../models/app/app.js' import {testAppLinked, testDeveloperPlatformClient, testOrganizationApp} from '../../models/app/app.test-data.js' import {describe, expect, test, vi} from 'vitest' @@ -31,7 +31,7 @@ function testOptions(app: AppLinkedInterface, {stdout = false, overwrite = false } } -describe('generateChannelSpec', () => { +describe('importChannelConfig', () => { test('writes the TOML to the channel-config specifications directory', async () => { await inTemporaryDirectory(async (tmpDir) => { // Given @@ -40,13 +40,13 @@ describe('generateChannelSpec', () => { const outputMock = mockAndCaptureOutput() // When - await generateChannelSpec(testOptions(app)) + await importChannelConfig(testOptions(app)) // Then const outputPath = joinPath(tmpDir, CHANNEL_SPEC_DIRECTORY, 'example.toml') await expect(fileExists(outputPath)).resolves.toBe(true) await expect(readFile(outputPath)).resolves.toEqual(TOML) - expect(outputMock.info()).toContain('Generated a channel spec') + expect(outputMock.info()).toContain('Imported the channel spec') expect(outputMock.info()).toContain('shopify app deploy') }) }) @@ -61,7 +61,7 @@ describe('generateChannelSpec', () => { await writeFile(outputPath, 'existing = true\n') // When/Then - await expect(generateChannelSpec(testOptions(app))).rejects.toThrow(/already exists/) + await expect(importChannelConfig(testOptions(app))).rejects.toThrow(/already exists/) await expect(readFile(outputPath)).resolves.toEqual('existing = true\n') }) }) @@ -76,7 +76,7 @@ describe('generateChannelSpec', () => { await writeFile(outputPath, 'existing = true\n') // When - await generateChannelSpec(testOptions(app, {overwrite: true})) + await importChannelConfig(testOptions(app, {overwrite: true})) // Then await expect(readFile(outputPath)).resolves.toEqual(TOML) @@ -96,7 +96,7 @@ describe('generateChannelSpec', () => { const outputMock = mockAndCaptureOutput() // When - await generateChannelSpec(testOptions(app, {stdout: true})) + await importChannelConfig(testOptions(app, {stdout: true})) // Then expect(outputMock.output()).toContain(TOML) @@ -119,7 +119,7 @@ describe('generateChannelSpec', () => { const outputMock = mockAndCaptureOutput() // When - await generateChannelSpec(testOptions(app)) + await importChannelConfig(testOptions(app)) // Then expect(outputMock.warn()).toContain(warning.message) @@ -136,7 +136,7 @@ describe('generateChannelSpec', () => { const app = testAppLinked({directory: tmpDir}) // When/Then - await expect(generateChannelSpec(testOptions(app))).rejects.toThrow( + await expect(importChannelConfig(testOptions(app))).rejects.toThrow( /No deployable channel spec is available for this app yet/, ) }) @@ -149,7 +149,7 @@ describe('generateChannelSpec', () => { const app = testAppLinked({directory: tmpDir}) // When/Then - await expect(generateChannelSpec(testOptions(app))).rejects.toThrow(/mystery_reason/) + await expect(importChannelConfig(testOptions(app))).rejects.toThrow(/mystery_reason/) }) }) }) diff --git a/packages/app/src/cli/services/channel-spec/generate.ts b/packages/app/src/cli/services/import-channel-config/import.ts similarity index 91% rename from packages/app/src/cli/services/channel-spec/generate.ts rename to packages/app/src/cli/services/import-channel-config/import.ts index 8c251116163..0c379e95a8c 100644 --- a/packages/app/src/cli/services/channel-spec/generate.ts +++ b/packages/app/src/cli/services/import-channel-config/import.ts @@ -23,7 +23,7 @@ const FAILURE_MESSAGES: {[reason: string]: string} = { 'The Shopify-authored default for this app could not be projected into a valid public channel_config spec.', } -export interface GenerateChannelSpecOptions { +export interface ImportChannelConfigOptions { app: AppLinkedInterface remoteApp: OrganizationApp developerPlatformClient: DeveloperPlatformClient @@ -32,14 +32,14 @@ export interface GenerateChannelSpecOptions { } /** - * Generates a deployable channel_config spec TOML file for the app. + * Imports the Shopify-authored default channel spec as a deployable channel_config TOML file. * * On success the TOML is either printed to stdout (`--stdout`) or written to * `extensions/channel-config/specifications/.toml` inside the app directory. Warnings * returned by the backend are rendered out-of-band and are never written into the TOML file. * This command never deploys; the partner reviews the generated file and runs `shopify app deploy`. */ -export async function generateChannelSpec(options: GenerateChannelSpecOptions): Promise { +export async function importChannelConfig(options: ImportChannelConfigOptions): Promise { const {app, remoteApp, developerPlatformClient, stdout, overwrite} = options const result = await fetchChannelSpecExport({remoteApp, developerPlatformClient}) @@ -71,7 +71,7 @@ export async function generateChannelSpec(options: GenerateChannelSpecOptions): result.warnings.forEach((warning) => renderExportWarning(warning)) renderSuccess({ - headline: ['Generated a channel spec for', {userInput: remoteApp.title}, {char: '.'}], + headline: ['Imported the channel spec for', {userInput: remoteApp.title}, {char: '.'}], body: ['The spec was written to', {filePath: relativePath(app.directory, outputPath)}, {char: '.'}], nextSteps: [ 'Review the generated spec before deploying it.', diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 3603cfc8c29..8ee5e7bacf5 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -490,106 +490,6 @@ "strict": true, "summary": "Check the status of bulk operations." }, - "app:channel-spec:generate": { - "aliases": [ - ], - "args": { - }, - "customPluginName": "@shopify/app", - "description": "Generates a deployable `channel_config` extension spec from the Shopify-authored default channel specification for your app.\n\n The generated TOML file contains only public `channel_config` fields. Review it, commit it to your app, then deploy it with `shopify app deploy`. This command never deploys the spec itself.", - "descriptionWithMarkdown": "Generates a deployable `channel_config` extension spec from the Shopify-authored default channel specification for your app.\n\n The generated TOML file contains only public `channel_config` fields. Review it, commit it to your app, then deploy it with `shopify app deploy`. This command never deploys the spec itself.", - "flags": { - "auth-alias": { - "description": "Alias of the Shopify account to use for authentication.", - "env": "SHOPIFY_FLAG_AUTH_ALIAS", - "hasDynamicHelp": false, - "multiple": false, - "name": "auth-alias", - "type": "option" - }, - "client-id": { - "description": "The Client ID of your app.", - "env": "SHOPIFY_FLAG_CLIENT_ID", - "exclusive": [ - "config" - ], - "hasDynamicHelp": false, - "hidden": false, - "multiple": false, - "name": "client-id", - "type": "option" - }, - "config": { - "char": "c", - "description": "The name of the app configuration.", - "env": "SHOPIFY_FLAG_APP_CONFIG", - "hasDynamicHelp": false, - "hidden": false, - "multiple": false, - "name": "config", - "type": "option" - }, - "no-color": { - "allowNo": false, - "description": "Disable color output.", - "env": "SHOPIFY_FLAG_NO_COLOR", - "hidden": false, - "name": "no-color", - "type": "boolean" - }, - "overwrite": { - "allowNo": false, - "description": "Overwrite the existing channel spec file if one already exists.", - "env": "SHOPIFY_FLAG_OVERWRITE", - "name": "overwrite", - "type": "boolean" - }, - "path": { - "description": "The path to your app directory.", - "env": "SHOPIFY_FLAG_PATH", - "hasDynamicHelp": false, - "multiple": false, - "name": "path", - "noCacheDefault": true, - "type": "option" - }, - "reset": { - "allowNo": false, - "description": "Reset all your settings.", - "env": "SHOPIFY_FLAG_RESET", - "exclusive": [ - "config" - ], - "hidden": false, - "name": "reset", - "type": "boolean" - }, - "stdout": { - "allowNo": false, - "description": "Print the generated TOML to stdout instead of writing it to a file.", - "env": "SHOPIFY_FLAG_STDOUT", - "name": "stdout", - "type": "boolean" - }, - "verbose": { - "allowNo": false, - "description": "Increase the verbosity of the output. May include sensitive data.", - "env": "SHOPIFY_FLAG_VERBOSE", - "hidden": false, - "name": "verbose", - "type": "boolean" - } - }, - "hasDynamicHelp": false, - "hiddenAliases": [ - ], - "id": "app:channel-spec:generate", - "pluginAlias": "@shopify/cli", - "pluginName": "@shopify/cli", - "pluginType": "core", - "strict": true, - "summary": "Generate a channel spec TOML file from the Shopify-authored default." - }, "app:config:link": { "aliases": [ ], @@ -3446,6 +3346,106 @@ "pluginType": "core", "strict": true }, + "app:import:channel-config": { + "aliases": [ + ], + "args": { + }, + "customPluginName": "@shopify/app", + "description": "Imports the Shopify-authored default channel specification for your app as a deployable `channel_config` extension spec.\n\n The generated TOML file contains only public `channel_config` fields. Review it, commit it to your app, then deploy it with `shopify app deploy`. This command never deploys the spec itself.", + "descriptionWithMarkdown": "Imports the Shopify-authored default channel specification for your app as a deployable `channel_config` extension spec.\n\n The generated TOML file contains only public `channel_config` fields. Review it, commit it to your app, then deploy it with `shopify app deploy`. This command never deploys the spec itself.", + "flags": { + "auth-alias": { + "description": "Alias of the Shopify account to use for authentication.", + "env": "SHOPIFY_FLAG_AUTH_ALIAS", + "hasDynamicHelp": false, + "multiple": false, + "name": "auth-alias", + "type": "option" + }, + "client-id": { + "description": "The Client ID of your app.", + "env": "SHOPIFY_FLAG_CLIENT_ID", + "exclusive": [ + "config" + ], + "hasDynamicHelp": false, + "hidden": false, + "multiple": false, + "name": "client-id", + "type": "option" + }, + "config": { + "char": "c", + "description": "The name of the app configuration.", + "env": "SHOPIFY_FLAG_APP_CONFIG", + "hasDynamicHelp": false, + "hidden": false, + "multiple": false, + "name": "config", + "type": "option" + }, + "no-color": { + "allowNo": false, + "description": "Disable color output.", + "env": "SHOPIFY_FLAG_NO_COLOR", + "hidden": false, + "name": "no-color", + "type": "boolean" + }, + "overwrite": { + "allowNo": false, + "description": "Overwrite the existing channel spec file if one already exists.", + "env": "SHOPIFY_FLAG_OVERWRITE", + "name": "overwrite", + "type": "boolean" + }, + "path": { + "description": "The path to your app directory.", + "env": "SHOPIFY_FLAG_PATH", + "hasDynamicHelp": false, + "multiple": false, + "name": "path", + "noCacheDefault": true, + "type": "option" + }, + "reset": { + "allowNo": false, + "description": "Reset all your settings.", + "env": "SHOPIFY_FLAG_RESET", + "exclusive": [ + "config" + ], + "hidden": false, + "name": "reset", + "type": "boolean" + }, + "stdout": { + "allowNo": false, + "description": "Print the generated TOML to stdout instead of writing it to a file.", + "env": "SHOPIFY_FLAG_STDOUT", + "name": "stdout", + "type": "boolean" + }, + "verbose": { + "allowNo": false, + "description": "Increase the verbosity of the output. May include sensitive data.", + "env": "SHOPIFY_FLAG_VERBOSE", + "hidden": false, + "name": "verbose", + "type": "boolean" + } + }, + "hasDynamicHelp": false, + "hiddenAliases": [ + ], + "id": "app:import:channel-config", + "pluginAlias": "@shopify/cli", + "pluginName": "@shopify/cli", + "pluginType": "core", + "strict": true, + "summary": "Import the Shopify-authored default channel spec as a channel_config TOML file." + }, "app:info": { "aliases": [ ], From 2936079ce2df6580f53fa6643206d21422b7756f Mon Sep 17 00:00:00 2001 From: Suzie Kim Date: Mon, 14 Sep 2026 16:15:01 -0400 Subject: [PATCH 4/7] Unexport internal types flagged by knip --- packages/app/src/cli/services/import-channel-config/fetch.ts | 2 +- packages/app/src/cli/services/import-channel-config/import.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/app/src/cli/services/import-channel-config/fetch.ts b/packages/app/src/cli/services/import-channel-config/fetch.ts index 0c847b82f23..956c90c7194 100644 --- a/packages/app/src/cli/services/import-channel-config/fetch.ts +++ b/packages/app/src/cli/services/import-channel-config/fetch.ts @@ -10,7 +10,7 @@ export interface ChannelSpecExportWarning { message: string } -export type ChannelSpecExportResult = +type ChannelSpecExportResult = | { success: true handle: string diff --git a/packages/app/src/cli/services/import-channel-config/import.ts b/packages/app/src/cli/services/import-channel-config/import.ts index 0c379e95a8c..4b87442d79e 100644 --- a/packages/app/src/cli/services/import-channel-config/import.ts +++ b/packages/app/src/cli/services/import-channel-config/import.ts @@ -23,7 +23,7 @@ const FAILURE_MESSAGES: {[reason: string]: string} = { 'The Shopify-authored default for this app could not be projected into a valid public channel_config spec.', } -export interface ImportChannelConfigOptions { +interface ImportChannelConfigOptions { app: AppLinkedInterface remoteApp: OrganizationApp developerPlatformClient: DeveloperPlatformClient From e5d83d5839bad2b497a7ac6d391e1ff7c7ccc865 Mon Sep 17 00:00:00 2001 From: Suzie Kim Date: Mon, 14 Sep 2026 16:18:51 -0400 Subject: [PATCH 5/7] Regenerate commands snapshot for app import channel-config --- packages/e2e/data/snapshots/commands.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/e2e/data/snapshots/commands.txt b/packages/e2e/data/snapshots/commands.txt index 40c4fc41625..1b419954e01 100644 --- a/packages/e2e/data/snapshots/commands.txt +++ b/packages/e2e/data/snapshots/commands.txt @@ -27,6 +27,7 @@ │ │ └─ extension │ ├─ graphiql │ ├─ import +│ │ ├─ channel-config │ │ ├─ custom-data-definitions │ │ └─ dashboard-extensions │ ├─ info From 6082b1e7a43cdbe6107593915f477b5c58b6a7d7 Mon Sep 17 00:00:00 2001 From: Suzie Kim Date: Wed, 16 Sep 2026 10:32:41 -0400 Subject: [PATCH 6/7] Address council review: GID app id, response validation, status handling, path containment, extension scaffold --- .../generated/generated_docs_data_v2.json | 13 +- .../cli/commands/app/import/channel-config.ts | 3 +- .../import-channel-config/fetch.test.ts | 131 ++++++++++++++++++ .../services/import-channel-config/fetch.ts | 35 ++++- .../import-channel-config/import.test.ts | 56 +++++++- .../services/import-channel-config/import.ts | 46 ++++-- packages/cli/README.md | 119 ++++++++-------- packages/cli/oclif.manifest.json | 95 +++++++------ 8 files changed, 380 insertions(+), 118 deletions(-) create mode 100644 packages/app/src/cli/services/import-channel-config/fetch.test.ts diff --git a/docs-shopify.dev/generated/generated_docs_data_v2.json b/docs-shopify.dev/generated/generated_docs_data_v2.json index e7aa98aae4d..27814f21aea 100644 --- a/docs-shopify.dev/generated/generated_docs_data_v2.json +++ b/docs-shopify.dev/generated/generated_docs_data_v2.json @@ -2389,6 +2389,15 @@ "isOptional": true, "environmentValue": "SHOPIFY_FLAG_CLIENT_ID" }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-import-channel-config.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--json-schema", + "value": "''", + "description": "Print the command's JSON schemas.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_JSON_SCHEMA" + }, { "filePath": "docs-shopify.dev/commands/interfaces/app-import-channel-config.interface.ts", "syntaxKind": "PropertySignature", @@ -2430,7 +2439,7 @@ "syntaxKind": "PropertySignature", "name": "--stdout", "value": "''", - "description": "Print the generated TOML to stdout instead of writing it to a file.", + "description": "Print the generated TOML to stdout instead of writing it to a file. For piped output, use an already-linked app: first-time linking prompts may interleave with the output.", "isOptional": true, "environmentValue": "SHOPIFY_FLAG_STDOUT" }, @@ -2453,7 +2462,7 @@ "environmentValue": "SHOPIFY_FLAG_APP_CONFIG" } ], - "value": "export interface appimportchannelconfig {\n /**\n * Alias of the Shopify account to use for authentication.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--auth-alias '?: string\n\n /**\n * The Client ID of your app.\n * @environment SHOPIFY_FLAG_CLIENT_ID\n */\n '--client-id '?: string\n\n /**\n * The name of the app configuration.\n * @environment SHOPIFY_FLAG_APP_CONFIG\n */\n '-c, --config '?: string\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Overwrite the existing channel spec file if one already exists.\n * @environment SHOPIFY_FLAG_OVERWRITE\n */\n '--overwrite'?: ''\n\n /**\n * The path to your app directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path '?: string\n\n /**\n * Reset all your settings.\n * @environment SHOPIFY_FLAG_RESET\n */\n '--reset'?: ''\n\n /**\n * Print the generated TOML to stdout instead of writing it to a file.\n * @environment SHOPIFY_FLAG_STDOUT\n */\n '--stdout'?: ''\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" + "value": "export interface appimportchannelconfig {\n /**\n * Alias of the Shopify account to use for authentication.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--auth-alias '?: string\n\n /**\n * The Client ID of your app.\n * @environment SHOPIFY_FLAG_CLIENT_ID\n */\n '--client-id '?: string\n\n /**\n * The name of the app configuration.\n * @environment SHOPIFY_FLAG_APP_CONFIG\n */\n '-c, --config '?: string\n\n /**\n * Print the command's JSON schemas.\n * @environment SHOPIFY_FLAG_JSON_SCHEMA\n */\n '--json-schema'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Overwrite the existing channel spec file if one already exists.\n * @environment SHOPIFY_FLAG_OVERWRITE\n */\n '--overwrite'?: ''\n\n /**\n * The path to your app directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path '?: string\n\n /**\n * Reset all your settings.\n * @environment SHOPIFY_FLAG_RESET\n */\n '--reset'?: ''\n\n /**\n * Print the generated TOML to stdout instead of writing it to a file. For piped output, use an already-linked app: first-time linking prompts may interleave with the output.\n * @environment SHOPIFY_FLAG_STDOUT\n */\n '--stdout'?: ''\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" } }, "appimportcustomdatadefinitions": { diff --git a/packages/app/src/cli/commands/app/import/channel-config.ts b/packages/app/src/cli/commands/app/import/channel-config.ts index 70954551f18..fc4c339cda6 100644 --- a/packages/app/src/cli/commands/app/import/channel-config.ts +++ b/packages/app/src/cli/commands/app/import/channel-config.ts @@ -18,7 +18,8 @@ export default class ImportChannelConfig extends AppLinkedCommand { ...globalFlags, ...appFlags, stdout: Flags.boolean({ - description: 'Print the generated TOML to stdout instead of writing it to a file.', + description: + 'Print the generated TOML to stdout instead of writing it to a file. For piped output, use an already-linked app: first-time linking prompts may interleave with the output.', env: 'SHOPIFY_FLAG_STDOUT', default: false, }), diff --git a/packages/app/src/cli/services/import-channel-config/fetch.test.ts b/packages/app/src/cli/services/import-channel-config/fetch.test.ts new file mode 100644 index 00000000000..3950ada1bb3 --- /dev/null +++ b/packages/app/src/cli/services/import-channel-config/fetch.test.ts @@ -0,0 +1,131 @@ +import {fetchChannelSpecExport} from './fetch.js' +import {testDeveloperPlatformClient, testOrganizationApp} from '../../models/app/app.test-data.js' +import {describe, expect, test, vi} from 'vitest' +import {shopifyFetch} from '@shopify/cli-kit/node/http' +import {appManagementFqdn} from '@shopify/cli-kit/node/context/fqdn' + +vi.mock('@shopify/cli-kit/node/http') +vi.mock('@shopify/cli-kit/node/context/fqdn') + +const SUCCESS_PAYLOAD = { + success: true, + handle: 'example', + filename: 'example.toml', + toml: 'handle = "example"\n', + warnings: [], +} + +function mockResponse({status = 200, json}: {status?: number; json?: unknown} = {}) { + return { + status, + ok: status >= 200 && status < 300, + json: json === undefined ? () => Promise.reject(new Error('invalid json')) : () => Promise.resolve(json), + } as unknown as Awaited> +} + +function testOptions() { + return { + remoteApp: testOrganizationApp({id: 'gid://shopify/App/123', organizationId: '42'}), + developerPlatformClient: testDeveloperPlatformClient(), + } +} + +describe('fetchChannelSpecExport', () => { + test('extracts the numeric app id from a GID when building the endpoint URL', async () => { + // Given + vi.mocked(appManagementFqdn).mockResolvedValue('app.shopify.com') + vi.mocked(shopifyFetch).mockResolvedValue(mockResponse({json: SUCCESS_PAYLOAD})) + + // When + await fetchChannelSpecExport(testOptions()) + + // Then + expect(shopifyFetch).toHaveBeenCalledWith( + 'https://app.shopify.com/app_management/unstable/organizations/42/apps/123/channel_spec_export.json', + expect.anything(), + ) + }) + + test('returns the parsed export on success', async () => { + // Given + vi.mocked(appManagementFqdn).mockResolvedValue('app.shopify.com') + vi.mocked(shopifyFetch).mockResolvedValue(mockResponse({json: SUCCESS_PAYLOAD})) + + // When + const result = await fetchChannelSpecExport(testOptions()) + + // Then + expect(result).toEqual({ + success: true, + handle: 'example', + filename: 'example.toml', + toml: 'handle = "example"\n', + warnings: [], + }) + }) + + test('treats a 422 as a well-formed export failure with a reason', async () => { + // Given + vi.mocked(appManagementFqdn).mockResolvedValue('app.shopify.com') + vi.mocked(shopifyFetch).mockResolvedValue( + mockResponse({status: 422, json: {success: false, error: 'not_exportable_yet', reason: 'not_allowlisted'}}), + ) + + // When + const result = await fetchChannelSpecExport(testOptions()) + + // Then + expect(result).toEqual({success: false, reason: 'not_allowlisted'}) + }) + + test('aborts with endpoint-unavailable guidance on 404', async () => { + // Given + vi.mocked(appManagementFqdn).mockResolvedValue('app.shopify.com') + vi.mocked(shopifyFetch).mockResolvedValue(mockResponse({status: 404, json: {}})) + + // When/Then + await expect(fetchChannelSpecExport(testOptions())).rejects.toThrow( + 'The channel spec export endpoint is not available for this app.', + ) + }) + + test.each([401, 403])('aborts with re-auth guidance on %i instead of reporting an export failure', async (status) => { + // Given + vi.mocked(appManagementFqdn).mockResolvedValue('app.shopify.com') + vi.mocked(shopifyFetch).mockResolvedValue(mockResponse({status, json: {}})) + + // When/Then + await expect(fetchChannelSpecExport(testOptions())).rejects.toThrow('authentication failed') + }) + + test('aborts with retry guidance on 5xx JSON responses instead of reporting an export failure', async () => { + // Given + vi.mocked(appManagementFqdn).mockResolvedValue('app.shopify.com') + vi.mocked(shopifyFetch).mockResolvedValue(mockResponse({status: 500, json: {message: 'oops'}})) + + // When/Then + await expect(fetchChannelSpecExport(testOptions())).rejects.toThrow('responded with status 500') + }) + + test.each([ + ['null', null], + ['an array', ['not', 'an', 'object']], + ['a primitive', 'nope'], + ])('aborts with a controlled error when the body is %s', async (_label, json) => { + // Given + vi.mocked(appManagementFqdn).mockResolvedValue('app.shopify.com') + vi.mocked(shopifyFetch).mockResolvedValue(mockResponse({json})) + + // When/Then + await expect(fetchChannelSpecExport(testOptions())).rejects.toThrow('unexpected response') + }) + + test('aborts when required fields are missing from the response', async () => { + // Given + vi.mocked(appManagementFqdn).mockResolvedValue('app.shopify.com') + vi.mocked(shopifyFetch).mockResolvedValue(mockResponse({json: {handle: 'example'}})) + + // When/Then + await expect(fetchChannelSpecExport(testOptions())).rejects.toThrow('missing required fields') + }) +}) diff --git a/packages/app/src/cli/services/import-channel-config/fetch.ts b/packages/app/src/cli/services/import-channel-config/fetch.ts index 956c90c7194..69505e4d372 100644 --- a/packages/app/src/cli/services/import-channel-config/fetch.ts +++ b/packages/app/src/cli/services/import-channel-config/fetch.ts @@ -1,11 +1,12 @@ import {OrganizationApp} from '../../models/organization.js' import {DeveloperPlatformClient} from '../../utilities/developer-platform-client.js' +import {numericIdFromGid} from '@shopify/cli-kit/common/gid' import {appManagementHeaders} from '@shopify/cli-kit/node/api/app-management' import {appManagementFqdn} from '@shopify/cli-kit/node/context/fqdn' import {shopifyFetch} from '@shopify/cli-kit/node/http' import {AbortError} from '@shopify/cli-kit/node/error' -export interface ChannelSpecExportWarning { +interface ChannelSpecExportWarning { code: string message: string } @@ -41,7 +42,11 @@ export async function fetchChannelSpecExport({ developerPlatformClient, }: FetchChannelSpecExportOptions): Promise { const fqdn = await appManagementFqdn() - const url = `https://${fqdn}/app_management/unstable/organizations/${remoteApp.organizationId}/apps/${remoteApp.id}/channel_spec_export.json` + // App Management returns app ids as GIDs (gid://shopify/App/); the REST path needs the numeric id. + const appId = numericIdFromGid(remoteApp.id) ?? remoteApp.id + const url = `https://${fqdn}/app_management/unstable/organizations/${encodeURIComponent( + remoteApp.organizationId, + )}/apps/${encodeURIComponent(appId)}/channel_spec_export.json` const token = (await developerPlatformClient.session()).token const response = await shopifyFetch(url, { @@ -58,18 +63,38 @@ export async function fetchChannelSpecExport({ ) } - let payload: {[key: string]: unknown} + let decoded: unknown try { - payload = (await response.json()) as {[key: string]: unknown} + decoded = await response.json() } catch { throw new AbortError(`Failed to fetch the channel spec export: unexpected response (status ${response.status}).`) } - if (!response.ok) { + if (typeof decoded !== 'object' || decoded === null || Array.isArray(decoded)) { + throw new AbortError(`Failed to fetch the channel spec export: unexpected response (status ${response.status}).`) + } + const payload = decoded as {[key: string]: unknown} + + // Only 422 carries a well-formed export failure ({error, reason}); any other non-ok status is a + // transport/auth/server problem and should not be presented as "this app can't be exported". + if (response.status === 422) { const reason = typeof payload.reason === 'string' ? payload.reason : `http_${response.status}` return {success: false, reason} } + if (!response.ok) { + if (response.status === 401 || response.status === 403) { + throw new AbortError( + `Failed to fetch the channel spec export: authentication failed (status ${response.status}).`, + 'Log out with `shopify auth logout` and re-run the command to refresh your session.', + ) + } + throw new AbortError( + `Failed to fetch the channel spec export: the server responded with status ${response.status}.`, + 'This is likely temporary. Wait a moment and try again.', + ) + } + const {handle, filename, toml, warnings} = payload if (typeof handle !== 'string' || typeof filename !== 'string' || typeof toml !== 'string') { throw new AbortError('Failed to fetch the channel spec export: the response was missing required fields.') diff --git a/packages/app/src/cli/services/import-channel-config/import.test.ts b/packages/app/src/cli/services/import-channel-config/import.test.ts index 1cc7f18d0a2..673f6cd2917 100644 --- a/packages/app/src/cli/services/import-channel-config/import.test.ts +++ b/packages/app/src/cli/services/import-channel-config/import.test.ts @@ -1,5 +1,5 @@ import {fetchChannelSpecExport} from './fetch.js' -import {importChannelConfig, CHANNEL_SPEC_DIRECTORY} from './import.js' +import {importChannelConfig, CHANNEL_SPEC_DIRECTORY, CHANNEL_SPEC_EXTENSION_DIRECTORY} from './import.js' import {AppLinkedInterface} from '../../models/app/app.js' import {testAppLinked, testDeveloperPlatformClient, testOrganizationApp} from '../../models/app/app.test-data.js' import {describe, expect, test, vi} from 'vitest' @@ -152,4 +152,58 @@ describe('importChannelConfig', () => { await expect(importChannelConfig(testOptions(app))).rejects.toThrow(/mystery_reason/) }) }) + + test('confines the write to the specifications directory when the filename contains path segments', async () => { + await inTemporaryDirectory(async (tmpDir) => { + // Given + vi.mocked(fetchChannelSpecExport).mockResolvedValue({...successResult(), filename: '../../evil.toml'}) + const app = testAppLinked({directory: tmpDir}) + mockAndCaptureOutput() + + // When + await importChannelConfig(testOptions(app)) + + // Then + await expect(fileExists(joinPath(tmpDir, 'evil.toml'))).resolves.toBe(false) + await expect(fileExists(joinPath(tmpDir, CHANNEL_SPEC_DIRECTORY, 'evil.toml'))).resolves.toBe(true) + }) + }) + + test('creates a minimal shopify.extension.toml so the imported spec deploys', async () => { + await inTemporaryDirectory(async (tmpDir) => { + // Given + vi.mocked(fetchChannelSpecExport).mockResolvedValue(successResult()) + const app = testAppLinked({directory: tmpDir}) + const outputMock = mockAndCaptureOutput() + + // When + await importChannelConfig(testOptions(app)) + + // Then + const extensionConfigPath = joinPath(tmpDir, CHANNEL_SPEC_EXTENSION_DIRECTORY, 'shopify.extension.toml') + await expect(readFile(extensionConfigPath)).resolves.toContain('type = "channel_config"') + expect(outputMock.info()).toContain('shopify.extension.toml') + }) + }) + + test('does not replace an existing shopify.extension.toml', async () => { + await inTemporaryDirectory(async (tmpDir) => { + // Given + vi.mocked(fetchChannelSpecExport).mockResolvedValue(successResult()) + const app = testAppLinked({directory: tmpDir}) + const extensionConfigPath = joinPath(tmpDir, CHANNEL_SPEC_EXTENSION_DIRECTORY, 'shopify.extension.toml') + await mkdir(dirname(extensionConfigPath)) + const existingContent = 'name = "My channel"\ntype = "channel_config"\nhandle = "my-channel"\n' + await writeFile(extensionConfigPath, existingContent) + const outputMock = mockAndCaptureOutput() + outputMock.clear() + + // When + await importChannelConfig(testOptions(app)) + + // Then + await expect(readFile(extensionConfigPath)).resolves.toEqual(existingContent) + expect(outputMock.info()).not.toContain('Also created') + }) + }) }) diff --git a/packages/app/src/cli/services/import-channel-config/import.ts b/packages/app/src/cli/services/import-channel-config/import.ts index 4b87442d79e..572ead70783 100644 --- a/packages/app/src/cli/services/import-channel-config/import.ts +++ b/packages/app/src/cli/services/import-channel-config/import.ts @@ -1,14 +1,21 @@ -import {fetchChannelSpecExport, ChannelSpecExportWarning} from './fetch.js' +import {fetchChannelSpecExport} from './fetch.js' import {AppLinkedInterface} from '../../models/app/app.js' import {OrganizationApp} from '../../models/organization.js' import {DeveloperPlatformClient} from '../../utilities/developer-platform-client.js' import {AbortError} from '@shopify/cli-kit/node/error' import {fileExists, mkdir, writeFile} from '@shopify/cli-kit/node/fs' -import {dirname, joinPath, relativePath} from '@shopify/cli-kit/node/path' +import {basename, dirname, joinPath, relativePath} from '@shopify/cli-kit/node/path' import {outputResult, outputWarn} from '@shopify/cli-kit/node/output' import {renderSuccess, renderWarning} from '@shopify/cli-kit/node/ui' -export const CHANNEL_SPEC_DIRECTORY = joinPath('extensions', 'channel-config', 'specifications') +export const CHANNEL_SPEC_EXTENSION_DIRECTORY = joinPath('extensions', 'channel-config') +export const CHANNEL_SPEC_DIRECTORY = joinPath(CHANNEL_SPEC_EXTENSION_DIRECTORY, 'specifications') + +const EXTENSION_CONFIG_FILENAME = 'shopify.extension.toml' +// Minimal extension scaffold: the app loader only discovers extensions through *.extension.toml +// files, and the channel_config deploy step copies `specifications/` relative to the extension +// directory. Without this file, `shopify app deploy` would silently exclude the imported spec. +const EXTENSION_CONFIG_CONTENT = 'name = "Channel config"\ntype = "channel_config"\nhandle = "channel-config"\n' const FAILURE_MESSAGES: {[reason: string]: string} = { no_exportable_frozen_record: @@ -57,7 +64,9 @@ export async function importChannelConfig(options: ImportChannelConfigOptions): return } - const outputPath = joinPath(app.directory, CHANNEL_SPEC_DIRECTORY, result.filename) + // basename() confines the write to the specifications directory even if the backend ever + // returned a filename containing path separators. + const outputPath = joinPath(app.directory, CHANNEL_SPEC_DIRECTORY, basename(result.filename)) if (!overwrite && (await fileExists(outputPath))) { throw new AbortError( `A channel spec already exists at ${relativePath(app.directory, outputPath)}.`, @@ -67,12 +76,24 @@ export async function importChannelConfig(options: ImportChannelConfigOptions): await mkdir(dirname(outputPath)) await writeFile(outputPath, result.toml) + const createdExtensionConfig = await ensureExtensionConfig(app.directory) - result.warnings.forEach((warning) => renderExportWarning(warning)) + result.warnings.forEach((warning) => renderWarning({body: warning.message})) renderSuccess({ headline: ['Imported the channel spec for', {userInput: remoteApp.title}, {char: '.'}], - body: ['The spec was written to', {filePath: relativePath(app.directory, outputPath)}, {char: '.'}], + body: [ + 'The spec was written to', + {filePath: relativePath(app.directory, outputPath)}, + {char: '.'}, + ...(createdExtensionConfig + ? [ + 'Also created', + {filePath: joinPath(CHANNEL_SPEC_EXTENSION_DIRECTORY, EXTENSION_CONFIG_FILENAME)}, + 'so the spec is included when your app is deployed.', + ] + : []), + ], nextSteps: [ 'Review the generated spec before deploying it.', ['Run', {command: 'shopify app deploy'}, 'to deploy the spec as part of your app.'], @@ -80,6 +101,15 @@ export async function importChannelConfig(options: ImportChannelConfigOptions): }) } -function renderExportWarning(warning: ChannelSpecExportWarning): void { - renderWarning({body: warning.message}) +/** + * Ensures the channel-config extension has a `shopify.extension.toml`, without which the app + * loader would not discover the extension and the imported spec would never reach a deploy bundle. + * + * @returns true when the file was created, false when one already existed. + */ +async function ensureExtensionConfig(appDirectory: string): Promise { + const extensionConfigPath = joinPath(appDirectory, CHANNEL_SPEC_EXTENSION_DIRECTORY, EXTENSION_CONFIG_FILENAME) + if (await fileExists(extensionConfigPath)) return false + await writeFile(extensionConfigPath, EXTENSION_CONFIG_CONTENT) + return true } diff --git a/packages/cli/README.md b/packages/cli/README.md index df7e3d52161..f284f5ea506 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -4,7 +4,6 @@ * [`shopify app bulk cancel`](#shopify-app-bulk-cancel) * [`shopify app bulk execute`](#shopify-app-bulk-execute) * [`shopify app bulk status`](#shopify-app-bulk-status) -* [`shopify app channel-spec generate`](#shopify-app-channel-spec-generate) * [`shopify app config link`](#shopify-app-config-link) * [`shopify app config pull`](#shopify-app-config-pull) * [`shopify app config use [config] [flags]`](#shopify-app-config-use-config-flags) @@ -23,6 +22,7 @@ * [`shopify app function typegen`](#shopify-app-function-typegen) * [`shopify app generate extension`](#shopify-app-generate-extension) * [`shopify app graphiql`](#shopify-app-graphiql) +* [`shopify app import channel-config`](#shopify-app-import-channel-config) * [`shopify app import custom-data-definitions`](#shopify-app-import-custom-data-definitions) * [`shopify app import dashboard-extensions`](#shopify-app-import-dashboard-extensions) * [`shopify app info`](#shopify-app-info) @@ -384,62 +384,6 @@ DESCRIPTION Use "`bulk execute`" (https://shopify.dev/docs/api/shopify-cli/app/app-bulk-execute) to start a new bulk operation. ``` -## `shopify app channel-spec generate` - -Generate a channel spec TOML file from the Shopify-authored default. - -``` -USAGE - $ shopify app channel-spec generate [--auth-alias ] [--client-id | -c ] [--no-color] [--overwrite] - [--path ] [--reset | ] [--stdout] [--verbose] - -FLAGS - -c, --config= - The name of the app configuration. - [env: SHOPIFY_FLAG_APP_CONFIG] - - --auth-alias= - Alias of the Shopify account to use for authentication. - [env: SHOPIFY_FLAG_AUTH_ALIAS] - - --client-id= - The Client ID of your app. - [env: SHOPIFY_FLAG_CLIENT_ID] - - --no-color - Disable color output. - [env: SHOPIFY_FLAG_NO_COLOR] - - --overwrite - Overwrite the existing channel spec file if one already exists. - [env: SHOPIFY_FLAG_OVERWRITE] - - --path= - The path to your app directory. - [env: SHOPIFY_FLAG_PATH] - - --reset - Reset all your settings. - [env: SHOPIFY_FLAG_RESET] - - --stdout - Print the generated TOML to stdout instead of writing it to a file. - [env: SHOPIFY_FLAG_STDOUT] - - --verbose - Increase the verbosity of the output. May include sensitive data. - [env: SHOPIFY_FLAG_VERBOSE] - -DESCRIPTION - Generate a channel spec TOML file from the Shopify-authored default. - - Generates a deployable `channel_config` extension spec from the Shopify-authored default channel specification for - your app. - - The generated TOML file contains only public `channel_config` fields. Review it, commit it to your app, then deploy it - with `shopify app deploy`. This command never deploys the spec itself. -``` - ## `shopify app config link` Fetch your app configuration from the Developer Dashboard. @@ -1569,6 +1513,67 @@ EXAMPLES $ shopify app graphiql --store shop.myshopify.com --port 9123 ``` +## `shopify app import channel-config` + +Import the Shopify-authored default channel spec as a channel_config TOML file. + +``` +USAGE + $ shopify app import channel-config [--auth-alias ] [--client-id | -c ] [--json-schema] [--no-color] + [--overwrite] [--path ] [--reset | ] [--stdout] [--verbose] + +FLAGS + -c, --config= + The name of the app configuration. + [env: SHOPIFY_FLAG_APP_CONFIG] + + --auth-alias= + Alias of the Shopify account to use for authentication. + [env: SHOPIFY_FLAG_AUTH_ALIAS] + + --client-id= + The Client ID of your app. + [env: SHOPIFY_FLAG_CLIENT_ID] + + --json-schema + Print the command's JSON schemas. + [env: SHOPIFY_FLAG_JSON_SCHEMA] + + --no-color + Disable color output. + [env: SHOPIFY_FLAG_NO_COLOR] + + --overwrite + Overwrite the existing channel spec file if one already exists. + [env: SHOPIFY_FLAG_OVERWRITE] + + --path= + The path to your app directory. + [env: SHOPIFY_FLAG_PATH] + + --reset + Reset all your settings. + [env: SHOPIFY_FLAG_RESET] + + --stdout + Print the generated TOML to stdout instead of writing it to a file. For piped output, use an already-linked app: + first-time linking prompts may interleave with the output. + [env: SHOPIFY_FLAG_STDOUT] + + --verbose + Increase the verbosity of the output. May include sensitive data. + [env: SHOPIFY_FLAG_VERBOSE] + +DESCRIPTION + Import the Shopify-authored default channel spec as a channel_config TOML file. + + Imports the Shopify-authored default channel specification for your app as a deployable `channel_config` extension + spec. + + The generated TOML file contains only public `channel_config` fields. Review it, commit it to your app, then deploy it + with `shopify app deploy`. This command never deploys the spec itself. +``` + ## `shopify app import custom-data-definitions` Import metafield and metaobject definitions. diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 8ee5e7bacf5..771ae3e9898 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -3146,14 +3146,14 @@ "pluginName": "@shopify/cli", "pluginType": "core" }, - "app:import:custom-data-definitions": { + "app:import:channel-config": { "aliases": [ ], "args": { }, "customPluginName": "@shopify/app", - "description": "Import metafield and metaobject definitions from your development store. \"Read more about declarative custom data definitions\" (https://shopify.dev/docs/apps/build/custom-data/declarative-custom-data-definitions).", - "descriptionWithMarkdown": "Import metafield and metaobject definitions from your development store. [Read more about declarative custom data definitions](https://shopify.dev/docs/apps/build/custom-data/declarative-custom-data-definitions).", + "description": "Imports the Shopify-authored default channel specification for your app as a deployable `channel_config` extension spec.\n\n The generated TOML file contains only public `channel_config` fields. Review it, commit it to your app, then deploy it with `shopify app deploy`. This command never deploys the spec itself.", + "descriptionWithMarkdown": "Imports the Shopify-authored default channel specification for your app as a deployable `channel_config` extension spec.\n\n The generated TOML file contains only public `channel_config` fields. Review it, commit it to your app, then deploy it with `shopify app deploy`. This command never deploys the spec itself.", "flags": { "auth-alias": { "description": "Alias of the Shopify account to use for authentication.", @@ -3185,13 +3185,6 @@ "name": "config", "type": "option" }, - "include-existing": { - "allowNo": false, - "description": "Include existing declared definitions in the output.", - "env": "SHOPIFY_FLAG_INCLUDE_EXISTING", - "name": "include-existing", - "type": "boolean" - }, "json-schema": { "allowNo": false, "description": "Print the command's JSON schemas.", @@ -3207,6 +3200,13 @@ "name": "no-color", "type": "boolean" }, + "overwrite": { + "allowNo": false, + "description": "Overwrite the existing channel spec file if one already exists.", + "env": "SHOPIFY_FLAG_OVERWRITE", + "name": "overwrite", + "type": "boolean" + }, "path": { "description": "The path to your app directory.", "env": "SHOPIFY_FLAG_PATH", @@ -3227,14 +3227,12 @@ "name": "reset", "type": "boolean" }, - "store": { - "char": "s", - "description": "Store URL. Must be an existing development or Shopify Plus sandbox store.", - "env": "SHOPIFY_FLAG_STORE", - "hasDynamicHelp": false, - "multiple": false, - "name": "store", - "type": "option" + "stdout": { + "allowNo": false, + "description": "Print the generated TOML to stdout instead of writing it to a file. For piped output, use an already-linked app: first-time linking prompts may interleave with the output.", + "env": "SHOPIFY_FLAG_STDOUT", + "name": "stdout", + "type": "boolean" }, "verbose": { "allowNo": false, @@ -3248,20 +3246,21 @@ "hasDynamicHelp": false, "hiddenAliases": [ ], - "id": "app:import:custom-data-definitions", + "id": "app:import:channel-config", "pluginAlias": "@shopify/cli", "pluginName": "@shopify/cli", "pluginType": "core", "strict": true, - "summary": "Import metafield and metaobject definitions." + "summary": "Import the Shopify-authored default channel spec as a channel_config TOML file." }, - "app:import:dashboard-extensions": { + "app:import:custom-data-definitions": { "aliases": [ ], "args": { }, "customPluginName": "@shopify/app", - "description": "Import dashboard-managed extensions into your app.", + "description": "Import metafield and metaobject definitions from your development store. \"Read more about declarative custom data definitions\" (https://shopify.dev/docs/apps/build/custom-data/declarative-custom-data-definitions).", + "descriptionWithMarkdown": "Import metafield and metaobject definitions from your development store. [Read more about declarative custom data definitions](https://shopify.dev/docs/apps/build/custom-data/declarative-custom-data-definitions).", "flags": { "auth-alias": { "description": "Alias of the Shopify account to use for authentication.", @@ -3293,6 +3292,13 @@ "name": "config", "type": "option" }, + "include-existing": { + "allowNo": false, + "description": "Include existing declared definitions in the output.", + "env": "SHOPIFY_FLAG_INCLUDE_EXISTING", + "name": "include-existing", + "type": "boolean" + }, "json-schema": { "allowNo": false, "description": "Print the command's JSON schemas.", @@ -3328,6 +3334,15 @@ "name": "reset", "type": "boolean" }, + "store": { + "char": "s", + "description": "Store URL. Must be an existing development or Shopify Plus sandbox store.", + "env": "SHOPIFY_FLAG_STORE", + "hasDynamicHelp": false, + "multiple": false, + "name": "store", + "type": "option" + }, "verbose": { "allowNo": false, "description": "Increase the verbosity of the output. May include sensitive data.", @@ -3340,20 +3355,20 @@ "hasDynamicHelp": false, "hiddenAliases": [ ], - "id": "app:import:dashboard-extensions", + "id": "app:import:custom-data-definitions", "pluginAlias": "@shopify/cli", "pluginName": "@shopify/cli", "pluginType": "core", - "strict": true + "strict": true, + "summary": "Import metafield and metaobject definitions." }, - "app:import:channel-config": { + "app:import:dashboard-extensions": { "aliases": [ ], "args": { }, "customPluginName": "@shopify/app", - "description": "Imports the Shopify-authored default channel specification for your app as a deployable `channel_config` extension spec.\n\n The generated TOML file contains only public `channel_config` fields. Review it, commit it to your app, then deploy it with `shopify app deploy`. This command never deploys the spec itself.", - "descriptionWithMarkdown": "Imports the Shopify-authored default channel specification for your app as a deployable `channel_config` extension spec.\n\n The generated TOML file contains only public `channel_config` fields. Review it, commit it to your app, then deploy it with `shopify app deploy`. This command never deploys the spec itself.", + "description": "Import dashboard-managed extensions into your app.", "flags": { "auth-alias": { "description": "Alias of the Shopify account to use for authentication.", @@ -3385,6 +3400,13 @@ "name": "config", "type": "option" }, + "json-schema": { + "allowNo": false, + "description": "Print the command's JSON schemas.", + "env": "SHOPIFY_FLAG_JSON_SCHEMA", + "name": "json-schema", + "type": "boolean" + }, "no-color": { "allowNo": false, "description": "Disable color output.", @@ -3393,13 +3415,6 @@ "name": "no-color", "type": "boolean" }, - "overwrite": { - "allowNo": false, - "description": "Overwrite the existing channel spec file if one already exists.", - "env": "SHOPIFY_FLAG_OVERWRITE", - "name": "overwrite", - "type": "boolean" - }, "path": { "description": "The path to your app directory.", "env": "SHOPIFY_FLAG_PATH", @@ -3420,13 +3435,6 @@ "name": "reset", "type": "boolean" }, - "stdout": { - "allowNo": false, - "description": "Print the generated TOML to stdout instead of writing it to a file.", - "env": "SHOPIFY_FLAG_STDOUT", - "name": "stdout", - "type": "boolean" - }, "verbose": { "allowNo": false, "description": "Increase the verbosity of the output. May include sensitive data.", @@ -3439,12 +3447,11 @@ "hasDynamicHelp": false, "hiddenAliases": [ ], - "id": "app:import:channel-config", + "id": "app:import:dashboard-extensions", "pluginAlias": "@shopify/cli", "pluginName": "@shopify/cli", "pluginType": "core", - "strict": true, - "summary": "Import the Shopify-authored default channel spec as a channel_config TOML file." + "strict": true }, "app:info": { "aliases": [ From 7753e06a419ecd4d5222afcb86671b707aa53156 Mon Sep 17 00:00:00 2001 From: Suzie Kim Date: Thu, 17 Sep 2026 15:47:47 -0400 Subject: [PATCH 7/7] Add JSON output contract to app import channel-config --- .../generated/generated_docs_data_v2.json | 11 +++- .../cli/commands/app/import/channel-config.ts | 10 ++- .../import-channel-config/import.test.ts | 29 ++++++++- .../services/import-channel-config/import.ts | 18 +++++- .../services/import-channel-config/types.ts | 19 ++++++ packages/cli/README.md | 64 ++++++++++++++++++- packages/cli/oclif.manifest.json | 14 +++- 7 files changed, 159 insertions(+), 6 deletions(-) create mode 100644 packages/app/src/cli/services/import-channel-config/types.ts diff --git a/docs-shopify.dev/generated/generated_docs_data_v2.json b/docs-shopify.dev/generated/generated_docs_data_v2.json index 27814f21aea..70df816bed9 100644 --- a/docs-shopify.dev/generated/generated_docs_data_v2.json +++ b/docs-shopify.dev/generated/generated_docs_data_v2.json @@ -2460,9 +2460,18 @@ "description": "The name of the app configuration.", "isOptional": true, "environmentValue": "SHOPIFY_FLAG_APP_CONFIG" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-import-channel-config.interface.ts", + "syntaxKind": "PropertySignature", + "name": "-j, --json", + "value": "''", + "description": "Output the result as JSON. Automatically disables color output.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_JSON" } ], - "value": "export interface appimportchannelconfig {\n /**\n * Alias of the Shopify account to use for authentication.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--auth-alias '?: string\n\n /**\n * The Client ID of your app.\n * @environment SHOPIFY_FLAG_CLIENT_ID\n */\n '--client-id '?: string\n\n /**\n * The name of the app configuration.\n * @environment SHOPIFY_FLAG_APP_CONFIG\n */\n '-c, --config '?: string\n\n /**\n * Print the command's JSON schemas.\n * @environment SHOPIFY_FLAG_JSON_SCHEMA\n */\n '--json-schema'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Overwrite the existing channel spec file if one already exists.\n * @environment SHOPIFY_FLAG_OVERWRITE\n */\n '--overwrite'?: ''\n\n /**\n * The path to your app directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path '?: string\n\n /**\n * Reset all your settings.\n * @environment SHOPIFY_FLAG_RESET\n */\n '--reset'?: ''\n\n /**\n * Print the generated TOML to stdout instead of writing it to a file. For piped output, use an already-linked app: first-time linking prompts may interleave with the output.\n * @environment SHOPIFY_FLAG_STDOUT\n */\n '--stdout'?: ''\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" + "value": "export interface appimportchannelconfig {\n /**\n * Alias of the Shopify account to use for authentication.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--auth-alias '?: string\n\n /**\n * The Client ID of your app.\n * @environment SHOPIFY_FLAG_CLIENT_ID\n */\n '--client-id '?: string\n\n /**\n * The name of the app configuration.\n * @environment SHOPIFY_FLAG_APP_CONFIG\n */\n '-c, --config '?: string\n\n /**\n * Output the result as JSON. Automatically disables color output.\n * @environment SHOPIFY_FLAG_JSON\n */\n '-j, --json'?: ''\n\n /**\n * Print the command's JSON schemas.\n * @environment SHOPIFY_FLAG_JSON_SCHEMA\n */\n '--json-schema'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Overwrite the existing channel spec file if one already exists.\n * @environment SHOPIFY_FLAG_OVERWRITE\n */\n '--overwrite'?: ''\n\n /**\n * The path to your app directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path '?: string\n\n /**\n * Reset all your settings.\n * @environment SHOPIFY_FLAG_RESET\n */\n '--reset'?: ''\n\n /**\n * Print the generated TOML to stdout instead of writing it to a file. For piped output, use an already-linked app: first-time linking prompts may interleave with the output.\n * @environment SHOPIFY_FLAG_STDOUT\n */\n '--stdout'?: ''\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" } }, "appimportcustomdatadefinitions": { diff --git a/packages/app/src/cli/commands/app/import/channel-config.ts b/packages/app/src/cli/commands/app/import/channel-config.ts index fc4c339cda6..0e3c1705b60 100644 --- a/packages/app/src/cli/commands/app/import/channel-config.ts +++ b/packages/app/src/cli/commands/app/import/channel-config.ts @@ -2,8 +2,9 @@ import {appFlags} from '../../../flags.js' import {linkedAppContext} from '../../../services/app-context.js' import {importChannelConfig} from '../../../services/import-channel-config/import.js' import AppLinkedCommand, {AppLinkedCommandOutput} from '../../../utilities/app-linked-command.js' +import {importChannelConfigJsonOutputSchema} from '../../../services/import-channel-config/types.js' import {Flags} from '@oclif/core' -import {globalFlags} from '@shopify/cli-kit/node/cli' +import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli' export default class ImportChannelConfig extends AppLinkedCommand { static summary = 'Import the Shopify-authored default channel spec as a channel_config TOML file.' @@ -14,14 +15,20 @@ export default class ImportChannelConfig extends AppLinkedCommand { static description = this.descriptionWithoutMarkdown() + static get jsonOutputSchema() { + return importChannelConfigJsonOutputSchema + } + static flags = { ...globalFlags, ...appFlags, + ...jsonFlag, stdout: Flags.boolean({ description: 'Print the generated TOML to stdout instead of writing it to a file. For piped output, use an already-linked app: first-time linking prompts may interleave with the output.', env: 'SHOPIFY_FLAG_STDOUT', default: false, + exclusive: ['json'], }), overwrite: Flags.boolean({ description: 'Overwrite the existing channel spec file if one already exists.', @@ -46,6 +53,7 @@ export default class ImportChannelConfig extends AppLinkedCommand { developerPlatformClient, stdout: flags.stdout, overwrite: flags.overwrite, + json: flags.json, }) return {app} diff --git a/packages/app/src/cli/services/import-channel-config/import.test.ts b/packages/app/src/cli/services/import-channel-config/import.test.ts index 673f6cd2917..17490cac9c4 100644 --- a/packages/app/src/cli/services/import-channel-config/import.test.ts +++ b/packages/app/src/cli/services/import-channel-config/import.test.ts @@ -21,13 +21,14 @@ function successResult(warnings: {code: string; message: string}[] = []) { } } -function testOptions(app: AppLinkedInterface, {stdout = false, overwrite = false} = {}) { +function testOptions(app: AppLinkedInterface, {stdout = false, overwrite = false, json = false} = {}) { return { app, remoteApp: testOrganizationApp(), developerPlatformClient: testDeveloperPlatformClient(), stdout, overwrite, + json, } } @@ -153,6 +154,32 @@ describe('importChannelConfig', () => { }) }) + test('emits the encoded JSON result and still writes the file in --json mode', async () => { + await inTemporaryDirectory(async (tmpDir) => { + // Given + const warning = {code: 'missing_countries', message: 'Add a countries section.'} + vi.mocked(fetchChannelSpecExport).mockResolvedValue(successResult([warning])) + const app = testAppLinked({directory: tmpDir}) + const outputMock = mockAndCaptureOutput() + outputMock.clear() + + // When + await importChannelConfig(testOptions(app, {json: true})) + + // Then + const outputPath = joinPath(tmpDir, CHANNEL_SPEC_DIRECTORY, 'example.toml') + await expect(fileExists(outputPath)).resolves.toBe(true) + const parsed = JSON.parse(outputMock.info()) + expect(parsed).toEqual({ + handle: 'example', + filename: 'example.toml', + path: joinPath(CHANNEL_SPEC_DIRECTORY, 'example.toml'), + toml: TOML, + warnings: [warning], + }) + }) + }) + test('confines the write to the specifications directory when the filename contains path segments', async () => { await inTemporaryDirectory(async (tmpDir) => { // Given diff --git a/packages/app/src/cli/services/import-channel-config/import.ts b/packages/app/src/cli/services/import-channel-config/import.ts index 572ead70783..2a5878dab37 100644 --- a/packages/app/src/cli/services/import-channel-config/import.ts +++ b/packages/app/src/cli/services/import-channel-config/import.ts @@ -1,4 +1,5 @@ import {fetchChannelSpecExport} from './fetch.js' +import {importChannelConfigJsonOutputSchema} from './types.js' import {AppLinkedInterface} from '../../models/app/app.js' import {OrganizationApp} from '../../models/organization.js' import {DeveloperPlatformClient} from '../../utilities/developer-platform-client.js' @@ -36,6 +37,7 @@ interface ImportChannelConfigOptions { developerPlatformClient: DeveloperPlatformClient stdout: boolean overwrite: boolean + json: boolean } /** @@ -47,7 +49,7 @@ interface ImportChannelConfigOptions { * This command never deploys; the partner reviews the generated file and runs `shopify app deploy`. */ export async function importChannelConfig(options: ImportChannelConfigOptions): Promise { - const {app, remoteApp, developerPlatformClient, stdout, overwrite} = options + const {app, remoteApp, developerPlatformClient, stdout, overwrite, json} = options const result = await fetchChannelSpecExport({remoteApp, developerPlatformClient}) @@ -78,6 +80,20 @@ export async function importChannelConfig(options: ImportChannelConfigOptions): await writeFile(outputPath, result.toml) const createdExtensionConfig = await ensureExtensionConfig(app.directory) + if (json) { + // Warnings are part of the JSON result rather than out-of-band stderr text. + outputResult( + importChannelConfigJsonOutputSchema.encode({ + handle: result.handle, + filename: basename(result.filename), + path: relativePath(app.directory, outputPath), + toml: result.toml, + warnings: result.warnings, + }), + ) + return + } + result.warnings.forEach((warning) => renderWarning({body: warning.message})) renderSuccess({ diff --git a/packages/app/src/cli/services/import-channel-config/types.ts b/packages/app/src/cli/services/import-channel-config/types.ts new file mode 100644 index 00000000000..c03efda7bfb --- /dev/null +++ b/packages/app/src/cli/services/import-channel-config/types.ts @@ -0,0 +1,19 @@ +import {defineJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema' +import {zod} from '@shopify/cli-kit/node/schema' + +const channelSpecExportWarningSchema = zod.object({ + code: zod.string(), + message: zod.string(), +}) + +export const importChannelConfigJsonOutputSchema = defineJsonOutputSchema({ + name: 'ImportChannelConfigResult', + schema: zod.object({ + handle: zod.string(), + filename: zod.string(), + path: zod.string(), + toml: zod.string(), + warnings: zod.array(channelSpecExportWarningSchema), + }), + definitions: {ChannelSpecExportWarning: channelSpecExportWarningSchema}, +}) diff --git a/packages/cli/README.md b/packages/cli/README.md index f284f5ea506..2a5cfe70385 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -1520,13 +1520,17 @@ Import the Shopify-authored default channel spec as a channel_config TOML file. ``` USAGE $ shopify app import channel-config [--auth-alias ] [--client-id | -c ] [--json-schema] [--no-color] - [--overwrite] [--path ] [--reset | ] [--stdout] [--verbose] + [--overwrite] [--path ] [--reset | ] [--stdout | -j] [--verbose] FLAGS -c, --config= The name of the app configuration. [env: SHOPIFY_FLAG_APP_CONFIG] + -j, --json + Output the result as JSON. Automatically disables color output. + [env: SHOPIFY_FLAG_JSON] + --auth-alias= Alias of the Shopify account to use for authentication. [env: SHOPIFY_FLAG_AUTH_ALIAS] @@ -1572,6 +1576,64 @@ DESCRIPTION The generated TOML file contains only public `channel_config` fields. Review it, commit it to your app, then deploy it with `shopify app deploy`. This command never deploys the spec itself. + + Output from `--json` conforms to the `ImportChannelConfigResult` schema. + + Use `--json-schema` to print the result, error, and event schemas. + + ```json + { + "type": "object", + "properties": { + "handle": { + "type": "string" + }, + "filename": { + "type": "string" + }, + "path": { + "type": "string" + }, + "toml": { + "type": "string" + }, + "warnings": { + "type": "array", + "items": { + "$ref": "#/definitions/ChannelSpecExportWarning" + } + } + }, + "required": [ + "handle", + "filename", + "path", + "toml", + "warnings" + ], + "additionalProperties": false, + "title": "ImportChannelConfigResult", + "definitions": { + "ChannelSpecExportWarning": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" + } + ``` ``` ## `shopify app import custom-data-definitions` diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 771ae3e9898..cf055ab1558 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -3152,7 +3152,7 @@ "args": { }, "customPluginName": "@shopify/app", - "description": "Imports the Shopify-authored default channel specification for your app as a deployable `channel_config` extension spec.\n\n The generated TOML file contains only public `channel_config` fields. Review it, commit it to your app, then deploy it with `shopify app deploy`. This command never deploys the spec itself.", + "description": "Imports the Shopify-authored default channel specification for your app as a deployable `channel_config` extension spec.\n\n The generated TOML file contains only public `channel_config` fields. Review it, commit it to your app, then deploy it with `shopify app deploy`. This command never deploys the spec itself.\n\nOutput from `--json` conforms to the `ImportChannelConfigResult` schema.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"handle\": {\n \"type\": \"string\"\n },\n \"filename\": {\n \"type\": \"string\"\n },\n \"path\": {\n \"type\": \"string\"\n },\n \"toml\": {\n \"type\": \"string\"\n },\n \"warnings\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/ChannelSpecExportWarning\"\n }\n }\n },\n \"required\": [\n \"handle\",\n \"filename\",\n \"path\",\n \"toml\",\n \"warnings\"\n ],\n \"additionalProperties\": false,\n \"title\": \"ImportChannelConfigResult\",\n \"definitions\": {\n \"ChannelSpecExportWarning\": {\n \"type\": \"object\",\n \"properties\": {\n \"code\": {\n \"type\": \"string\"\n },\n \"message\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"code\",\n \"message\"\n ],\n \"additionalProperties\": false\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", "descriptionWithMarkdown": "Imports the Shopify-authored default channel specification for your app as a deployable `channel_config` extension spec.\n\n The generated TOML file contains only public `channel_config` fields. Review it, commit it to your app, then deploy it with `shopify app deploy`. This command never deploys the spec itself.", "flags": { "auth-alias": { @@ -3185,6 +3185,15 @@ "name": "config", "type": "option" }, + "json": { + "allowNo": false, + "char": "j", + "description": "Output the result as JSON. Automatically disables color output.", + "env": "SHOPIFY_FLAG_JSON", + "hidden": false, + "name": "json", + "type": "boolean" + }, "json-schema": { "allowNo": false, "description": "Print the command's JSON schemas.", @@ -3231,6 +3240,9 @@ "allowNo": false, "description": "Print the generated TOML to stdout instead of writing it to a file. For piped output, use an already-linked app: first-time linking prompts may interleave with the output.", "env": "SHOPIFY_FLAG_STDOUT", + "exclusive": [ + "json" + ], "name": "stdout", "type": "boolean" },