diff --git a/docs-shopify.dev/generated/generated_docs_data_v2.json b/docs-shopify.dev/generated/generated_docs_data_v2.json index ff8e3489503..70df816bed9 100644 --- a/docs-shopify.dev/generated/generated_docs_data_v2.json +++ b/docs-shopify.dev/generated/generated_docs_data_v2.json @@ -2364,6 +2364,116 @@ "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": "--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", + "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. For piped output, use an already-linked app: first-time linking prompts may interleave with the output.", + "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" + }, + { + "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 * 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": { "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/import/channel-config.ts b/packages/app/src/cli/commands/app/import/channel-config.ts new file mode 100644 index 00000000000..0e3c1705b60 --- /dev/null +++ b/packages/app/src/cli/commands/app/import/channel-config.ts @@ -0,0 +1,61 @@ +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, 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.' + + 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.` + + 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.', + env: 'SHOPIFY_FLAG_OVERWRITE', + default: false, + }), + } + + public async run(): Promise { + const {flags} = await this.parse(ImportChannelConfig) + + const {app, remoteApp, developerPlatformClient} = await linkedAppContext({ + directory: flags.path, + clientId: flags['client-id'], + forceRelink: flags.reset, + userProvidedConfigName: flags.config, + }) + + await importChannelConfig({ + app, + remoteApp, + developerPlatformClient, + stdout: flags.stdout, + overwrite: flags.overwrite, + json: flags.json, + }) + + return {app} + } +} diff --git a/packages/app/src/cli/index.ts b/packages/app/src/cli/index.ts index 347d1cf18cf..d5090029547 100644 --- a/packages/app/src/cli/index.ts +++ b/packages/app/src/cli/index.ts @@ -42,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' @@ -63,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/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 new file mode 100644 index 00000000000..69505e4d372 --- /dev/null +++ b/packages/app/src/cli/services/import-channel-config/fetch.ts @@ -0,0 +1,125 @@ +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' + +interface ChannelSpecExportWarning { + code: string + message: string +} + +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() + // 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, { + method: 'GET', + headers: appManagementHeaders(token), + }) + + if (response.status === 404) { + // 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 decoded: unknown + try { + decoded = await response.json() + } catch { + throw new AbortError(`Failed to fetch the channel spec export: unexpected response (status ${response.status}).`) + } + + 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.') + } + + return { + success: true, + handle, + filename, + toml, + 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 [] + }) +} 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 new file mode 100644 index 00000000000..17490cac9c4 --- /dev/null +++ b/packages/app/src/cli/services/import-channel-config/import.test.ts @@ -0,0 +1,236 @@ +import {fetchChannelSpecExport} from './fetch.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' +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, json = false} = {}) { + return { + app, + remoteApp: testOrganizationApp(), + developerPlatformClient: testDeveloperPlatformClient(), + stdout, + overwrite, + json, + } +} + +describe('importChannelConfig', () => { + 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 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('Imported the 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(importChannelConfig(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 importChannelConfig(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 importChannelConfig(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 importChannelConfig(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(importChannelConfig(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(importChannelConfig(testOptions(app))).rejects.toThrow(/mystery_reason/) + }) + }) + + 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 + 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 new file mode 100644 index 00000000000..2a5878dab37 --- /dev/null +++ b/packages/app/src/cli/services/import-channel-config/import.ts @@ -0,0 +1,131 @@ +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' +import {AbortError} from '@shopify/cli-kit/node/error' +import {fileExists, mkdir, writeFile} from '@shopify/cli-kit/node/fs' +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_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: + '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.', +} + +interface ImportChannelConfigOptions { + app: AppLinkedInterface + remoteApp: OrganizationApp + developerPlatformClient: DeveloperPlatformClient + stdout: boolean + overwrite: boolean + json: boolean +} + +/** + * 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 importChannelConfig(options: ImportChannelConfigOptions): Promise { + const {app, remoteApp, developerPlatformClient, stdout, overwrite, json} = 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 + } + + // 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)}.`, + 'Re-run with `--overwrite` to replace it.', + ) + } + + await mkdir(dirname(outputPath)) + 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({ + headline: ['Imported the channel spec for', {userInput: remoteApp.title}, {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.'], + ], + }) +} + +/** + * 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/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 38c5dc2c8ff..2a5cfe70385 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -22,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) @@ -1512,6 +1513,129 @@ 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 | -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] + + --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. + + 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` Import metafield and metaobject definitions. diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 1b88620390e..cf055ab1558 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -3146,6 +3146,125 @@ "pluginName": "@shopify/cli", "pluginType": "core" }, + "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.\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": { + "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" + }, + "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.", + "env": "SHOPIFY_FLAG_JSON_SCHEMA", + "name": "json-schema", + "type": "boolean" + }, + "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. 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" + }, + "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:import:custom-data-definitions": { "aliases": [ ], 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