From 731f591ed744f4aac95f97ea130c7a220d121e35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Wed, 7 Oct 2026 14:02:41 +0200 Subject: [PATCH 1/6] Add typed JSON output to app import channel-config --- .changeset/json-app-channel-config.md | 5 + .../app/import/channel-config.test.ts | 167 ++++++++++++++++++ .../cli/commands/app/import/channel-config.ts | 14 +- .../import-channel-config/import.test.ts | 61 +++---- .../services/import-channel-config/import.ts | 55 ++---- .../import-channel-config/result.test.ts | 81 +++++++++ .../services/import-channel-config/result.ts | 50 ++++++ .../import-channel-config/types.test.ts | 39 ++++ .../services/import-channel-config/types.ts | 36 ++-- packages/cli/oclif.manifest.json | 2 +- 10 files changed, 416 insertions(+), 94 deletions(-) create mode 100644 .changeset/json-app-channel-config.md create mode 100644 packages/app/src/cli/commands/app/import/channel-config.test.ts create mode 100644 packages/app/src/cli/services/import-channel-config/result.test.ts create mode 100644 packages/app/src/cli/services/import-channel-config/result.ts create mode 100644 packages/app/src/cli/services/import-channel-config/types.test.ts diff --git a/.changeset/json-app-channel-config.md b/.changeset/json-app-channel-config.md new file mode 100644 index 00000000000..b6a9d8f0789 --- /dev/null +++ b/.changeset/json-app-channel-config.md @@ -0,0 +1,5 @@ +--- +'@shopify/cli': major +--- + +Return a success status and absolute file path in `app import channel-config --json`. diff --git a/packages/app/src/cli/commands/app/import/channel-config.test.ts b/packages/app/src/cli/commands/app/import/channel-config.test.ts new file mode 100644 index 00000000000..e0cbf55eb43 --- /dev/null +++ b/packages/app/src/cli/commands/app/import/channel-config.test.ts @@ -0,0 +1,167 @@ +import ImportChannelConfig from './channel-config.js' +import {linkedAppContext} from '../../../services/app-context.js' +import {importChannelConfigJsonOutputSchema} from '../../../services/import-channel-config/types.js' +import { + CHANNEL_SPEC_DIRECTORY, + CHANNEL_SPEC_EXTENSION_DIRECTORY, +} from '../../../services/import-channel-config/import.js' +import {testAppLinked, testDeveloperPlatformClient, testOrganizationApp} from '../../../models/app/app.test-data.js' +import {Config} from '@oclif/core' +import {fileExists, inTemporaryDirectory, mkdir, readFile, writeFile} from '@shopify/cli-kit/node/fs' +import {dirname, joinPath} from '@shopify/cli-kit/node/path' +import {withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output' +import {unstyled} from '@shopify/cli-kit/node/output' +import {afterEach, expect, test, vi} from 'vitest' +import {fileURLToPath} from 'node:url' +import type {ChannelSpecExportResponse} from '../../../utilities/developer-platform-client.js' + +vi.mock('../../../services/app-context.js') + +const TOML = 'handle = "example"\nlabel = "Café channel"\n' +const warning = {code: 'missing_countries', message: 'Add a countries section.'} + +afterEach(() => { + vi.unstubAllEnvs() +}) + +test('exposes its strict result schema and JSON flag in help', () => { + expect(ImportChannelConfig.jsonOutputSchema).toBe(importChannelConfigJsonOutputSchema) + expect(ImportChannelConfig.description).toContain('ImportChannelConfigResult') + expect(ImportChannelConfig.flags.json.char).toBe('j') + expect(importChannelConfigJsonOutputSchema.jsonSchema).toMatchObject({additionalProperties: false}) +}) + +test('imports real files and writes one JSON document with warning events on stderr', async () => { + await inTemporaryDirectory(async (tmp) => { + const {app, developerPlatformClient} = setContext(tmp) + await withCapturedStandardStreams(async ({stdout, stderr}) => { + const commandResult = await runCommand(['--path', tmp, '--json', '--no-input']) + const path = joinPath(tmp, CHANNEL_SPEC_DIRECTORY, 'example.toml') + expect(JSON.parse(stdout())).toEqual({ + status: 'success', + handle: 'example', + filename: 'example.toml', + path, + toml: TOML, + warnings: [warning], + }) + expect(JSON.parse(stderr())).toMatchObject({type: 'diagnostic', level: 'warning', ...warning}) + expect(commandResult).toEqual({app}) + await expect(readFile(path)).resolves.toBe(TOML) + await expect(readFile(joinPath(tmp, CHANNEL_SPEC_EXTENSION_DIRECTORY, 'shopify.extension.toml'))).resolves.toBe( + 'name = "Channel config"\ntype = "channel_config"\nhandle = "channel-config"\n', + ) + expect(developerPlatformClient.channelSpecExport).toHaveBeenCalledOnce() + }) + }) +}) + +test('JSON without no-input uses the same context and does not imply force', async () => { + await inTemporaryDirectory(async (tmp) => { + setContext(tmp) + await withCapturedStandardStreams(async ({stdout}) => { + await runCommand(['--path', tmp, '--json']) + expect(JSON.parse(stdout()).status).toBe('success') + expect(linkedAppContext).toHaveBeenCalledWith({ + directory: tmp, + clientId: undefined, + forceRelink: false, + userProvidedConfigName: undefined, + }) + }) + }) +}) + +test('no-input alone keeps the existing warning and success text', async () => { + await inTemporaryDirectory(async (tmp) => { + setContext(tmp) + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await runCommand(['--path', tmp, '--no-input']) + expect(stdout()).toBe('') + expect(unstyled(stderr())).toContain('Imported the channel spec for app1.') + expect(unstyled(stderr())).toContain(warning.message) + expect(unstyled(stderr())).toContain('Also created') + }) + }) +}) + +test('overwrite refusal emits one fatal document, preserves the file, and does not create extension metadata', async () => { + vi.stubEnv('SHOPIFY_FLAG_JSON', '1') + const exit = vi.spyOn(process, 'exit') + await inTemporaryDirectory(async (tmp) => { + setContext(tmp) + const path = joinPath(tmp, CHANNEL_SPEC_DIRECTORY, 'example.toml') + await mkdir(dirname(path)) + await writeFile(path, 'existing = true\n') + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await expect(runCommand(['--path', tmp, '--json', '--no-input'])).rejects.toThrow() + expect(JSON.parse(stdout())).toMatchObject({ + error: {type: 'abort', message: expect.stringContaining('already exists')}, + }) + expect(stderr()).toBe('') + expect(exit).toHaveBeenCalledWith(1) + await expect(readFile(path)).resolves.toBe('existing = true\n') + await expect(fileExists(joinPath(tmp, CHANNEL_SPEC_EXTENSION_DIRECTORY, 'shopify.extension.toml'))).resolves.toBe( + false, + ) + }) + }) +}) + +test('force replaces the existing file and returns its absolute path', async () => { + await inTemporaryDirectory(async (tmp) => { + setContext(tmp) + const path = joinPath(tmp, CHANNEL_SPEC_DIRECTORY, 'example.toml') + await mkdir(dirname(path)) + await writeFile(path, 'existing = true\n') + await withCapturedStandardStreams(async ({stdout}) => { + await runCommand(['--path', tmp, '--json', '--no-input', '--force']) + expect(JSON.parse(stdout()).path).toBe(path) + await expect(readFile(path)).resolves.toBe(TOML) + }) + }) +}) + +test.each([ + {ok: false as const, status: 422, body: {reason: 'not_allowlisted'}}, + {ok: false as const, status: 401, body: {}}, + {ok: true as const, status: 200, body: {handle: 'example'}}, +])('export failure emits one fatal document without writing a file: %j', async (response) => { + vi.stubEnv('SHOPIFY_FLAG_JSON', '1') + const exit = vi.spyOn(process, 'exit') + await inTemporaryDirectory(async (tmp) => { + setContext(tmp, response) + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await expect(runCommand(['--path', tmp, '--json', '--no-input'])).rejects.toThrow() + expect(JSON.parse(stdout())).toMatchObject({error: {type: 'abort'}}) + expect(stderr()).toBe('') + expect(exit).toHaveBeenCalledWith(1) + await expect(fileExists(joinPath(tmp, CHANNEL_SPEC_EXTENSION_DIRECTORY))).resolves.toBe(false) + }) + }) +}) + +function setContext( + directory: string, + response: ChannelSpecExportResponse = { + ok: true, + status: 200, + body: {handle: 'example', filename: 'example.toml', toml: TOML, warnings: [warning]}, + }, +) { + const app = testAppLinked({directory}) + const developerPlatformClient = testDeveloperPlatformClient({channelSpecExport: async () => response}) + vi.mocked(linkedAppContext).mockResolvedValue({ + app, + remoteApp: testOrganizationApp(), + developerPlatformClient, + } as unknown as Awaited>) + return {app, developerPlatformClient} +} + +async function runCommand(argv: string[]) { + const config = await Config.load({root: joinPath(dirname(fileURLToPath(import.meta.url)), '../../../../..')}) + // The installed CLI bundles this source package instead of loading it as a custom plugin. + config.plugins.delete('@shopify/app') + return ImportChannelConfig.run(argv, config) +} 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 02780a5a284..f0602b2f0ba 100644 --- a/packages/app/src/cli/commands/app/import/channel-config.ts +++ b/packages/app/src/cli/commands/app/import/channel-config.ts @@ -3,6 +3,7 @@ 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 {renderImportChannelConfigResult} from '../../../services/import-channel-config/result.js' import {Flags} from '@oclif/core' import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli' @@ -16,12 +17,12 @@ export default class ImportChannelConfig extends AppLinkedCommand { The generated TOML only includes public \`channel_config\` fields. Review it, then deploy it with \`shopify app deploy\`.` - static description = this.descriptionWithoutMarkdown() - static get jsonOutputSchema() { return importChannelConfigJsonOutputSchema } + static description = this.descriptionForHelp() + static flags = { ...globalFlags, ...appFlags, @@ -43,14 +44,19 @@ export default class ImportChannelConfig extends AppLinkedCommand { userProvidedConfigName: flags.config, }) - await importChannelConfig({ + const result = await importChannelConfig({ app, remoteApp, developerPlatformClient, force: flags.force, - json: flags.json, }) + renderImportChannelConfigResult( + result, + {directory: app.directory, name: remoteApp.title}, + flags.json ? 'json' : 'text', + ) + 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 532428acfe4..24be03b2112 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 @@ -31,13 +31,12 @@ function successResult(warnings: {code: string; message: string}[] = []) { } } -function testOptions(app: AppLinkedInterface, {force = false, json = false} = {}) { +function testOptions(app: AppLinkedInterface, {force = false} = {}) { return { app, remoteApp: testOrganizationApp(), developerPlatformClient: testDeveloperPlatformClient(), force, - json, } } @@ -47,18 +46,24 @@ describe('importChannelConfig', () => { // Given vi.mocked(fetchChannelSpecExport).mockResolvedValue(successResult()) const app = testAppLinked({directory: tmpDir}) - const outputMock = mockAndCaptureOutput() // When - await importChannelConfig(testOptions(app)) + const result = 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 dev') - expect(outputMock.info()).toContain('shopify app deploy') + expect(result).toEqual({ + status: 'success', + handle: 'example', + filename: 'example.toml', + path: outputPath, + toml: TOML, + warnings: [], + extensionConfigurationPath: joinPath(tmpDir, CHANNEL_SPEC_EXTENSION_DIRECTORY, 'shopify.extension.toml'), + }) + expect(mockAndCaptureOutput().info()).toBe('') }) }) @@ -94,7 +99,7 @@ describe('importChannelConfig', () => { }) }) - test('renders backend warnings when writing the file', async () => { + test('returns backend warnings when writing the file', async () => { await inTemporaryDirectory(async (tmpDir) => { // Given const warning = { @@ -104,13 +109,13 @@ describe('importChannelConfig', () => { } vi.mocked(fetchChannelSpecExport).mockResolvedValue(successResult([warning])) const app = testAppLinked({directory: tmpDir}) - const outputMock = mockAndCaptureOutput() // When - await importChannelConfig(testOptions(app)) + const result = await importChannelConfig(testOptions(app)) // Then - expect(outputMock.warn()).toContain('This generated spec enables automatic product feed management.') + expect(result.warnings).toEqual([warning]) + expect(mockAndCaptureOutput().warn()).toBe('') await expect(readFile(joinPath(tmpDir, CHANNEL_SPEC_DIRECTORY, 'example.toml'))).resolves.not.toContain( 'product feed management', ) @@ -141,28 +146,17 @@ describe('importChannelConfig', () => { }) }) - test('emits the encoded JSON result and still writes the file in --json mode', async () => { + test('a file write failure preserves existing content and returns no success data', async () => { await inTemporaryDirectory(async (tmpDir) => { - // Given - const warning = {code: 'missing_countries', message: 'Add a countries section.'} - vi.mocked(fetchChannelSpecExport).mockResolvedValue(successResult([warning])) + vi.mocked(fetchChannelSpecExport).mockResolvedValue(successResult()) const app = testAppLinked({directory: tmpDir}) - const outputMock = mockAndCaptureOutput() - - // When - await importChannelConfig(testOptions(app, {json: true})) + const extensionDirectory = joinPath(tmpDir, CHANNEL_SPEC_EXTENSION_DIRECTORY) + await mkdir(dirname(extensionDirectory)) + await writeFile(extensionDirectory, 'existing content\n') - // 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], - }) + await expect(importChannelConfig(testOptions(app))).rejects.toThrow() + await expect(readFile(extensionDirectory)).resolves.toBe('existing content\n') + expect(mockAndCaptureOutput().info()).toBe('') }) }) @@ -187,7 +181,6 @@ describe('importChannelConfig', () => { // Given vi.mocked(fetchChannelSpecExport).mockResolvedValue(successResult()) const app = testAppLinked({directory: tmpDir}) - const outputMock = mockAndCaptureOutput() // When await importChannelConfig(testOptions(app)) @@ -195,7 +188,6 @@ describe('importChannelConfig', () => { // 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') }) }) @@ -212,16 +204,15 @@ describe('importChannelConfig', () => { directory: tmpDir, allExtensions: [await testChannelConfigExtension(extensionDirectory, 'my-channel')], }) - const outputMock = mockAndCaptureOutput() // When - await importChannelConfig(testOptions(app)) + const result = await importChannelConfig(testOptions(app)) // Then await expect(readFile(joinPath(extensionDirectory, 'specifications', 'example.toml'))).resolves.toEqual(TOML) await expect(readFile(extensionConfigPath)).resolves.toEqual(existingContent) await expect(fileExists(joinPath(tmpDir, CHANNEL_SPEC_EXTENSION_DIRECTORY))).resolves.toBe(false) - expect(outputMock.info()).not.toContain('Also created') + expect(result.extensionConfigurationPath).toBeNull() }) }) 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 1723917f559..c16478532bd 100644 --- a/packages/app/src/cli/services/import-channel-config/import.ts +++ b/packages/app/src/cli/services/import-channel-config/import.ts @@ -1,5 +1,5 @@ import {fetchChannelSpecExport} from './fetch.js' -import {importChannelConfigJsonOutputSchema} from './types.js' +import {ImportedChannelConfig} from './types.js' import {AppLinkedInterface} from '../../models/app/app.js' import { CHANNEL_CONFIG_IDENTIFIER, @@ -9,9 +9,7 @@ import {OrganizationApp} from '../../models/organization.js' import {DeveloperPlatformClient} from '../../utilities/developer-platform-client.js' import {AbortError} from '@shopify/cli-kit/node/error' import {fileExists, matchGlob, mkdir, writeFile} from '@shopify/cli-kit/node/fs' -import {basename, dirname, joinPath, relativePath} from '@shopify/cli-kit/node/path' -import {outputResult} from '@shopify/cli-kit/node/output' -import {renderSuccess, renderWarning} from '@shopify/cli-kit/node/ui' +import {basename, dirname, joinPath, relativePath, resolvePath} from '@shopify/cli-kit/node/path' export const CHANNEL_SPEC_EXTENSION_DIRECTORY = joinPath('extensions', 'channel-config') export const CHANNEL_SPEC_DIRECTORY = joinPath( @@ -36,15 +34,14 @@ interface ImportChannelConfigOptions { remoteApp: OrganizationApp developerPlatformClient: DeveloperPlatformClient force: boolean - json: boolean } /** * Imports the Shopify-authored channel spec for the linked app into the app's channel_config * extension, creating the extension if the app doesn't have one yet. */ -export async function importChannelConfig(options: ImportChannelConfigOptions): Promise { - const {app, remoteApp, developerPlatformClient, force, json} = options +export async function importChannelConfig(options: ImportChannelConfigOptions): Promise { + const {app, remoteApp, developerPlatformClient, force} = options const result = await fetchChannelSpecExport({remoteApp, developerPlatformClient}) @@ -57,7 +54,7 @@ export async function importChannelConfig(options: ImportChannelConfigOptions): const {extensionDirectory, createExtension} = resolveExtensionDirectory(app) // basename() so a filename with path separators can't escape the specifications directory - const outputPath = joinPath(extensionDirectory, CHANNEL_CONFIG_SPECIFICATIONS_DIRECTORY, basename(result.filename)) + const outputPath = resolvePath(extensionDirectory, CHANNEL_CONFIG_SPECIFICATIONS_DIRECTORY, basename(result.filename)) if (!force && (await fileExists(outputPath))) { throw new AbortError( `A channel spec already exists at ${relativePath(app.directory, outputPath)}.`, @@ -71,41 +68,15 @@ export async function importChannelConfig(options: ImportChannelConfigOptions): } await writeFile(outputPath, result.toml) - if (json) { - outputResult( - importChannelConfigJsonOutputSchema.encode({ - handle: result.handle, - filename: basename(result.filename), - path: relativePath(app.directory, outputPath), - toml: result.toml, - warnings: result.warnings, - }), - ) - return + return { + status: 'success', + handle: result.handle, + filename: basename(result.filename), + path: outputPath, + toml: result.toml, + warnings: result.warnings.map(({code, message}) => ({code, message})), + extensionConfigurationPath: createExtension ? resolvePath(extensionDirectory, EXTENSION_CONFIG_FILENAME) : null, } - - 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: '.'}, - ...(createExtension - ? [ - 'Also created', - {filePath: relativePath(app.directory, joinPath(extensionDirectory, EXTENSION_CONFIG_FILENAME))}, - 'so the spec is included when your app is deployed.', - ] - : []), - ], - nextSteps: [ - 'Review the generated spec and make any changes your channel needs.', - ['Run', {command: 'shopify app dev'}, 'to try the spec on a development store before releasing it.'], - ['Run', {command: 'shopify app deploy'}, 'to deploy the spec as part of your app.'], - ], - }) } /** diff --git a/packages/app/src/cli/services/import-channel-config/result.test.ts b/packages/app/src/cli/services/import-channel-config/result.test.ts new file mode 100644 index 00000000000..9b7d0e0a5a0 --- /dev/null +++ b/packages/app/src/cli/services/import-channel-config/result.test.ts @@ -0,0 +1,81 @@ +import {renderImportChannelConfigResult} from './result.js' +import {withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output' +import {runWithCommandEventsForCommand} from '@shopify/cli-kit/node/command-events' +import {unstyled} from '@shopify/cli-kit/node/output' +import {resolvePath, joinPath} from '@shopify/cli-kit/node/path' +import {expect, test} from 'vitest' + +const directory = resolvePath('example-app') +const result = { + status: 'success' as const, + handle: 'example', + filename: 'example.toml', + path: joinPath(directory, 'extensions/channel-config/specifications/example.toml'), + toml: 'handle = "example"\nlabel = "Café channel"\n', + warnings: [{code: 'missing_countries', message: 'Add a countries section.'}], + extensionConfigurationPath: joinPath(directory, 'extensions/channel-config/shopify.extension.toml'), +} +const app = {directory, name: 'Example app'} + +test('writes one public document and warning events through the real encoder and writers', async () => { + const warnings = [{...result.warnings[0]!, internal: 'private'}] + await withCapturedStandardStreams(async ({stdout, stderr}) => { + runWithCommandEventsForCommand(['--json'], () => + renderImportChannelConfigResult({...result, warnings}, app, 'json'), + ) + expect(JSON.parse(stdout())).toEqual({ + status: 'success', + handle: result.handle, + filename: result.filename, + path: result.path, + toml: result.toml, + warnings: result.warnings, + }) + expect(JSON.parse(stderr())).toMatchObject({ + type: 'diagnostic', + level: 'warning', + ...result.warnings[0], + }) + expect(stdout()).not.toContain('extensionConfigurationPath') + expect(stdout()).not.toContain('internal') + }) +}) + +test('preserves empty warnings and does not emit an empty diagnostic code', async () => { + await withCapturedStandardStreams(async ({stdout, stderr}) => { + runWithCommandEventsForCommand(['--json'], () => + renderImportChannelConfigResult({...result, warnings: []}, app, 'json'), + ) + expect(JSON.parse(stdout()).warnings).toEqual([]) + expect(stderr()).toBe('') + }) + await withCapturedStandardStreams(async ({stderr}) => { + runWithCommandEventsForCommand(['--json'], () => + renderImportChannelConfigResult({...result, warnings: [{code: '', message: 'Review the spec.'}]}, app, 'json'), + ) + expect(JSON.parse(stderr())).not.toHaveProperty('code') + }) +}) + +test('keeps the warning and success text on stderr with relative file paths', async () => { + await withCapturedStandardStreams(async ({stdout, stderr}) => { + renderImportChannelConfigResult(result, app, 'text') + expect(stdout()).toBe('') + const text = unstyled(stderr()) + expect(text).toContain('Add a countries section.') + expect(text).toContain('Imported the channel spec for Example app.') + expect(text).toContain('extensions/channel-config/specifications/example.toml') + expect(text).toContain('Also created') + expect(text).toContain('extensions/channel-config/shopify.extension.toml') + expect(text).toContain('shopify app dev') + expect(text).toContain('shopify app deploy') + expect(text).not.toContain(directory) + }) +}) + +test('omits the created-extension text when the extension already exists', async () => { + await withCapturedStandardStreams(async ({stderr}) => { + renderImportChannelConfigResult({...result, extensionConfigurationPath: null}, app, 'text') + expect(unstyled(stderr())).not.toContain('Also created') + }) +}) diff --git a/packages/app/src/cli/services/import-channel-config/result.ts b/packages/app/src/cli/services/import-channel-config/result.ts new file mode 100644 index 00000000000..3c0a7111e73 --- /dev/null +++ b/packages/app/src/cli/services/import-channel-config/result.ts @@ -0,0 +1,50 @@ +import {importChannelConfigJsonOutputSchema, type ImportedChannelConfig} from './types.js' +import {relativePath} from '@shopify/cli-kit/node/path' +import {outputResult} from '@shopify/cli-kit/node/output' +import {emitCommandEvent} from '@shopify/cli-kit/node/command-events' +import {renderSuccess, renderWarning} from '@shopify/cli-kit/node/ui' + +export function renderImportChannelConfigResult( + result: ImportedChannelConfig, + app: {directory: string; name: string}, + format: 'json' | 'text', +): void { + if (format === 'json') { + result.warnings.forEach(({code, message}) => + emitCommandEvent({type: 'diagnostic', level: 'warning', message, ...(code ? {code} : {})}), + ) + outputResult( + importChannelConfigJsonOutputSchema.encode({ + status: result.status, + handle: result.handle, + filename: result.filename, + path: result.path, + toml: result.toml, + warnings: result.warnings.map(({code, message}) => ({code, message})), + }), + ) + return + } + + result.warnings.forEach((warning) => renderWarning({body: warning.message})) + renderSuccess({ + headline: ['Imported the channel spec for', {userInput: app.name}, {char: '.'}], + body: [ + 'The spec was written to', + {filePath: relativePath(app.directory, result.path)}, + {char: '.'}, + ...(result.extensionConfigurationPath + ? [ + 'Also created', + {filePath: relativePath(app.directory, result.extensionConfigurationPath)}, + 'so the spec is included when your app is deployed.', + ] + : []), + ], + nextSteps: [ + 'Review the generated spec and make any changes your channel needs.', + ['Run', {command: 'shopify app dev'}, 'to try the spec on a development store before releasing it.'], + ['Run', {command: 'shopify app deploy'}, 'to deploy the spec as part of your app.'], + ], + }) +} diff --git a/packages/app/src/cli/services/import-channel-config/types.test.ts b/packages/app/src/cli/services/import-channel-config/types.test.ts new file mode 100644 index 00000000000..20b086c4765 --- /dev/null +++ b/packages/app/src/cli/services/import-channel-config/types.test.ts @@ -0,0 +1,39 @@ +import {importChannelConfigJsonOutputSchema} from './types.js' +import {resolvePath} from '@shopify/cli-kit/node/path' +import {expect, test} from 'vitest' + +const result = { + status: 'success' as const, + handle: 'example', + filename: 'example.toml', + path: resolvePath('extensions/channel-config/specifications/example.toml'), + toml: 'handle = "example"\n', + warnings: [{code: 'missing_countries', message: 'Add a countries section.'}], +} + +test('encodes native TOML and warning data without changing it', () => { + expect(JSON.parse(importChannelConfigJsonOutputSchema.encode(result))).toEqual(result) + expect(JSON.parse(importChannelConfigJsonOutputSchema.encode({...result, warnings: []}))).toEqual({ + ...result, + warnings: [], + }) +}) + +test.each([ + {...result, status: 'failed'}, + {...result, internal: true}, + {...result, path: 'extensions/channel-config/specifications/example.toml'}, + {...result, path: 'C:example.toml'}, + {...result, warnings: [{...result.warnings[0], internal: true}]}, + {...result, warnings: [{code: 42, message: 'Add a countries section.'}]}, + {...result, toml: null}, +])('rejects invalid CLI-owned fields: %j', (value) => { + expect(() => importChannelConfigJsonOutputSchema.validate(value)).toThrow() +}) + +test.each(['/tmp/example.toml', 'C:\\app\\example.toml', '\\\\server\\app\\example.toml'])( + 'accepts absolute native paths: %s', + (path) => { + expect(JSON.parse(importChannelConfigJsonOutputSchema.encode({...result, path})).path).toBe(path) + }, +) diff --git a/packages/app/src/cli/services/import-channel-config/types.ts b/packages/app/src/cli/services/import-channel-config/types.ts index c03efda7bfb..9597bd6c2c3 100644 --- a/packages/app/src/cli/services/import-channel-config/types.ts +++ b/packages/app/src/cli/services/import-channel-config/types.ts @@ -1,19 +1,31 @@ -import {defineJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema' +import {defineJsonOutputSchema, type InferJsonOutputSchema} 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(), -}) +const channelSpecExportWarningSchema = zod + .object({ + code: zod.string(), + message: zod.string(), + }) + .strict() 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), - }), + schema: zod + .object({ + status: zod.literal('success'), + handle: zod.string(), + filename: zod.string(), + path: zod + .string() + .regex(/^(?:\/|[a-zA-Z]:[\\/]|\\\\)/, 'The path must be absolute.') + .describe('Absolute path of the written TOML file.'), + toml: zod.string().describe('Native channel_config TOML content, unchanged from the export.'), + warnings: zod.array(channelSpecExportWarningSchema).describe('Warnings returned with the channel spec export.'), + }) + .strict(), definitions: {ChannelSpecExportWarning: channelSpecExportWarningSchema}, }) + +export type ImportChannelConfigResult = InferJsonOutputSchema + +export type ImportedChannelConfig = ImportChannelConfigResult & {extensionConfigurationPath: string | null} diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 31e82b9ad49..28e84e45e90 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -3045,7 +3045,7 @@ "args": { }, "customPluginName": "@shopify/app", - "description": "Imports the Shopify-authored channel spec for your app as a `channel_config` extension.\n\n The generated TOML only includes public `channel_config` fields. Review it, then deploy it with `shopify app deploy`.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `ImportChannelConfigResult` schema.\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```", + "description": "Imports the Shopify-authored channel spec for your app as a `channel_config` extension.\n\n The generated TOML only includes public `channel_config` fields. Review it, then deploy it with `shopify app deploy`.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `ImportChannelConfigResult` schema.\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"success\"\n },\n \"handle\": {\n \"type\": \"string\"\n },\n \"filename\": {\n \"type\": \"string\"\n },\n \"path\": {\n \"type\": \"string\",\n \"pattern\": \"^(?:\\\\/|[a-zA-Z]:[\\\\\\\\/]|\\\\\\\\\\\\\\\\)\",\n \"description\": \"Absolute path of the written TOML file.\"\n },\n \"toml\": {\n \"type\": \"string\",\n \"description\": \"Native channel_config TOML content, unchanged from the export.\"\n },\n \"warnings\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/ChannelSpecExportWarning\"\n },\n \"description\": \"Warnings returned with the channel spec export.\"\n }\n },\n \"required\": [\n \"status\",\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 channel spec for your app as a `channel_config` extension.\n\n The generated TOML only includes public `channel_config` fields. Review it, then deploy it with `shopify app deploy`.", "flags": { "auth-alias": { From 9a671e1c02207baca83dfaec7ae7d2aa850953e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Wed, 7 Oct 2026 14:42:00 +0200 Subject: [PATCH 2/6] Keep channel-config JSON tests focused on changed output --- .../app/import/channel-config.test.ts | 139 ++---------------- .../import-channel-config/import.test.ts | 27 +--- .../import-channel-config/result.test.ts | 43 ------ .../import-channel-config/types.test.ts | 39 ----- 4 files changed, 17 insertions(+), 231 deletions(-) delete mode 100644 packages/app/src/cli/services/import-channel-config/types.test.ts diff --git a/packages/app/src/cli/commands/app/import/channel-config.test.ts b/packages/app/src/cli/commands/app/import/channel-config.test.ts index e0cbf55eb43..fd2743e098d 100644 --- a/packages/app/src/cli/commands/app/import/channel-config.test.ts +++ b/packages/app/src/cli/commands/app/import/channel-config.test.ts @@ -1,41 +1,39 @@ import ImportChannelConfig from './channel-config.js' import {linkedAppContext} from '../../../services/app-context.js' -import {importChannelConfigJsonOutputSchema} from '../../../services/import-channel-config/types.js' import { CHANNEL_SPEC_DIRECTORY, CHANNEL_SPEC_EXTENSION_DIRECTORY, } from '../../../services/import-channel-config/import.js' import {testAppLinked, testDeveloperPlatformClient, testOrganizationApp} from '../../../models/app/app.test-data.js' import {Config} from '@oclif/core' -import {fileExists, inTemporaryDirectory, mkdir, readFile, writeFile} from '@shopify/cli-kit/node/fs' +import {inTemporaryDirectory, readFile} from '@shopify/cli-kit/node/fs' import {dirname, joinPath} from '@shopify/cli-kit/node/path' import {withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output' -import {unstyled} from '@shopify/cli-kit/node/output' -import {afterEach, expect, test, vi} from 'vitest' +import {expect, test, vi} from 'vitest' import {fileURLToPath} from 'node:url' -import type {ChannelSpecExportResponse} from '../../../utilities/developer-platform-client.js' vi.mock('../../../services/app-context.js') const TOML = 'handle = "example"\nlabel = "Café channel"\n' const warning = {code: 'missing_countries', message: 'Add a countries section.'} -afterEach(() => { - vi.unstubAllEnvs() -}) - -test('exposes its strict result schema and JSON flag in help', () => { - expect(ImportChannelConfig.jsonOutputSchema).toBe(importChannelConfigJsonOutputSchema) - expect(ImportChannelConfig.description).toContain('ImportChannelConfigResult') - expect(ImportChannelConfig.flags.json.char).toBe('j') - expect(importChannelConfigJsonOutputSchema.jsonSchema).toMatchObject({additionalProperties: false}) -}) - test('imports real files and writes one JSON document with warning events on stderr', async () => { await inTemporaryDirectory(async (tmp) => { - const {app, developerPlatformClient} = setContext(tmp) + const app = testAppLinked({directory: tmp}) + const developerPlatformClient = testDeveloperPlatformClient({ + channelSpecExport: async () => ({ + ok: true, + status: 200, + body: {handle: 'example', filename: 'example.toml', toml: TOML, warnings: [{...warning, internal: 'private'}]}, + }), + }) + vi.mocked(linkedAppContext).mockResolvedValue({ + app, + remoteApp: testOrganizationApp(), + developerPlatformClient, + } as unknown as Awaited>) await withCapturedStandardStreams(async ({stdout, stderr}) => { - const commandResult = await runCommand(['--path', tmp, '--json', '--no-input']) + await runCommand(['--path', tmp, '--json']) const path = joinPath(tmp, CHANNEL_SPEC_DIRECTORY, 'example.toml') expect(JSON.parse(stdout())).toEqual({ status: 'success', @@ -46,119 +44,14 @@ test('imports real files and writes one JSON document with warning events on std warnings: [warning], }) expect(JSON.parse(stderr())).toMatchObject({type: 'diagnostic', level: 'warning', ...warning}) - expect(commandResult).toEqual({app}) await expect(readFile(path)).resolves.toBe(TOML) await expect(readFile(joinPath(tmp, CHANNEL_SPEC_EXTENSION_DIRECTORY, 'shopify.extension.toml'))).resolves.toBe( 'name = "Channel config"\ntype = "channel_config"\nhandle = "channel-config"\n', ) - expect(developerPlatformClient.channelSpecExport).toHaveBeenCalledOnce() }) }) }) -test('JSON without no-input uses the same context and does not imply force', async () => { - await inTemporaryDirectory(async (tmp) => { - setContext(tmp) - await withCapturedStandardStreams(async ({stdout}) => { - await runCommand(['--path', tmp, '--json']) - expect(JSON.parse(stdout()).status).toBe('success') - expect(linkedAppContext).toHaveBeenCalledWith({ - directory: tmp, - clientId: undefined, - forceRelink: false, - userProvidedConfigName: undefined, - }) - }) - }) -}) - -test('no-input alone keeps the existing warning and success text', async () => { - await inTemporaryDirectory(async (tmp) => { - setContext(tmp) - await withCapturedStandardStreams(async ({stdout, stderr}) => { - await runCommand(['--path', tmp, '--no-input']) - expect(stdout()).toBe('') - expect(unstyled(stderr())).toContain('Imported the channel spec for app1.') - expect(unstyled(stderr())).toContain(warning.message) - expect(unstyled(stderr())).toContain('Also created') - }) - }) -}) - -test('overwrite refusal emits one fatal document, preserves the file, and does not create extension metadata', async () => { - vi.stubEnv('SHOPIFY_FLAG_JSON', '1') - const exit = vi.spyOn(process, 'exit') - await inTemporaryDirectory(async (tmp) => { - setContext(tmp) - const path = joinPath(tmp, CHANNEL_SPEC_DIRECTORY, 'example.toml') - await mkdir(dirname(path)) - await writeFile(path, 'existing = true\n') - await withCapturedStandardStreams(async ({stdout, stderr}) => { - await expect(runCommand(['--path', tmp, '--json', '--no-input'])).rejects.toThrow() - expect(JSON.parse(stdout())).toMatchObject({ - error: {type: 'abort', message: expect.stringContaining('already exists')}, - }) - expect(stderr()).toBe('') - expect(exit).toHaveBeenCalledWith(1) - await expect(readFile(path)).resolves.toBe('existing = true\n') - await expect(fileExists(joinPath(tmp, CHANNEL_SPEC_EXTENSION_DIRECTORY, 'shopify.extension.toml'))).resolves.toBe( - false, - ) - }) - }) -}) - -test('force replaces the existing file and returns its absolute path', async () => { - await inTemporaryDirectory(async (tmp) => { - setContext(tmp) - const path = joinPath(tmp, CHANNEL_SPEC_DIRECTORY, 'example.toml') - await mkdir(dirname(path)) - await writeFile(path, 'existing = true\n') - await withCapturedStandardStreams(async ({stdout}) => { - await runCommand(['--path', tmp, '--json', '--no-input', '--force']) - expect(JSON.parse(stdout()).path).toBe(path) - await expect(readFile(path)).resolves.toBe(TOML) - }) - }) -}) - -test.each([ - {ok: false as const, status: 422, body: {reason: 'not_allowlisted'}}, - {ok: false as const, status: 401, body: {}}, - {ok: true as const, status: 200, body: {handle: 'example'}}, -])('export failure emits one fatal document without writing a file: %j', async (response) => { - vi.stubEnv('SHOPIFY_FLAG_JSON', '1') - const exit = vi.spyOn(process, 'exit') - await inTemporaryDirectory(async (tmp) => { - setContext(tmp, response) - await withCapturedStandardStreams(async ({stdout, stderr}) => { - await expect(runCommand(['--path', tmp, '--json', '--no-input'])).rejects.toThrow() - expect(JSON.parse(stdout())).toMatchObject({error: {type: 'abort'}}) - expect(stderr()).toBe('') - expect(exit).toHaveBeenCalledWith(1) - await expect(fileExists(joinPath(tmp, CHANNEL_SPEC_EXTENSION_DIRECTORY))).resolves.toBe(false) - }) - }) -}) - -function setContext( - directory: string, - response: ChannelSpecExportResponse = { - ok: true, - status: 200, - body: {handle: 'example', filename: 'example.toml', toml: TOML, warnings: [warning]}, - }, -) { - const app = testAppLinked({directory}) - const developerPlatformClient = testDeveloperPlatformClient({channelSpecExport: async () => response}) - vi.mocked(linkedAppContext).mockResolvedValue({ - app, - remoteApp: testOrganizationApp(), - developerPlatformClient, - } as unknown as Awaited>) - return {app, developerPlatformClient} -} - async function runCommand(argv: string[]) { const config = await Config.load({root: joinPath(dirname(fileURLToPath(import.meta.url)), '../../../../..')}) // The installed CLI bundles this source package instead of loading it as a custom plugin. 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 24be03b2112..3628a56a1d7 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 @@ -48,22 +48,12 @@ describe('importChannelConfig', () => { const app = testAppLinked({directory: tmpDir}) // When - const result = await importChannelConfig(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(result).toEqual({ - status: 'success', - handle: 'example', - filename: 'example.toml', - path: outputPath, - toml: TOML, - warnings: [], - extensionConfigurationPath: joinPath(tmpDir, CHANNEL_SPEC_EXTENSION_DIRECTORY, 'shopify.extension.toml'), - }) - expect(mockAndCaptureOutput().info()).toBe('') }) }) @@ -115,7 +105,6 @@ describe('importChannelConfig', () => { // Then expect(result.warnings).toEqual([warning]) - expect(mockAndCaptureOutput().warn()).toBe('') await expect(readFile(joinPath(tmpDir, CHANNEL_SPEC_DIRECTORY, 'example.toml'))).resolves.not.toContain( 'product feed management', ) @@ -146,20 +135,6 @@ describe('importChannelConfig', () => { }) }) - test('a file write failure preserves existing content and returns no success data', async () => { - await inTemporaryDirectory(async (tmpDir) => { - vi.mocked(fetchChannelSpecExport).mockResolvedValue(successResult()) - const app = testAppLinked({directory: tmpDir}) - const extensionDirectory = joinPath(tmpDir, CHANNEL_SPEC_EXTENSION_DIRECTORY) - await mkdir(dirname(extensionDirectory)) - await writeFile(extensionDirectory, 'existing content\n') - - await expect(importChannelConfig(testOptions(app))).rejects.toThrow() - await expect(readFile(extensionDirectory)).resolves.toBe('existing content\n') - expect(mockAndCaptureOutput().info()).toBe('') - }) - }) - 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/result.test.ts b/packages/app/src/cli/services/import-channel-config/result.test.ts index 9b7d0e0a5a0..0ca46fff9c1 100644 --- a/packages/app/src/cli/services/import-channel-config/result.test.ts +++ b/packages/app/src/cli/services/import-channel-config/result.test.ts @@ -1,6 +1,5 @@ import {renderImportChannelConfigResult} from './result.js' import {withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output' -import {runWithCommandEventsForCommand} from '@shopify/cli-kit/node/command-events' import {unstyled} from '@shopify/cli-kit/node/output' import {resolvePath, joinPath} from '@shopify/cli-kit/node/path' import {expect, test} from 'vitest' @@ -17,46 +16,6 @@ const result = { } const app = {directory, name: 'Example app'} -test('writes one public document and warning events through the real encoder and writers', async () => { - const warnings = [{...result.warnings[0]!, internal: 'private'}] - await withCapturedStandardStreams(async ({stdout, stderr}) => { - runWithCommandEventsForCommand(['--json'], () => - renderImportChannelConfigResult({...result, warnings}, app, 'json'), - ) - expect(JSON.parse(stdout())).toEqual({ - status: 'success', - handle: result.handle, - filename: result.filename, - path: result.path, - toml: result.toml, - warnings: result.warnings, - }) - expect(JSON.parse(stderr())).toMatchObject({ - type: 'diagnostic', - level: 'warning', - ...result.warnings[0], - }) - expect(stdout()).not.toContain('extensionConfigurationPath') - expect(stdout()).not.toContain('internal') - }) -}) - -test('preserves empty warnings and does not emit an empty diagnostic code', async () => { - await withCapturedStandardStreams(async ({stdout, stderr}) => { - runWithCommandEventsForCommand(['--json'], () => - renderImportChannelConfigResult({...result, warnings: []}, app, 'json'), - ) - expect(JSON.parse(stdout()).warnings).toEqual([]) - expect(stderr()).toBe('') - }) - await withCapturedStandardStreams(async ({stderr}) => { - runWithCommandEventsForCommand(['--json'], () => - renderImportChannelConfigResult({...result, warnings: [{code: '', message: 'Review the spec.'}]}, app, 'json'), - ) - expect(JSON.parse(stderr())).not.toHaveProperty('code') - }) -}) - test('keeps the warning and success text on stderr with relative file paths', async () => { await withCapturedStandardStreams(async ({stdout, stderr}) => { renderImportChannelConfigResult(result, app, 'text') @@ -64,12 +23,10 @@ test('keeps the warning and success text on stderr with relative file paths', as const text = unstyled(stderr()) expect(text).toContain('Add a countries section.') expect(text).toContain('Imported the channel spec for Example app.') - expect(text).toContain('extensions/channel-config/specifications/example.toml') expect(text).toContain('Also created') expect(text).toContain('extensions/channel-config/shopify.extension.toml') expect(text).toContain('shopify app dev') expect(text).toContain('shopify app deploy') - expect(text).not.toContain(directory) }) }) diff --git a/packages/app/src/cli/services/import-channel-config/types.test.ts b/packages/app/src/cli/services/import-channel-config/types.test.ts deleted file mode 100644 index 20b086c4765..00000000000 --- a/packages/app/src/cli/services/import-channel-config/types.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import {importChannelConfigJsonOutputSchema} from './types.js' -import {resolvePath} from '@shopify/cli-kit/node/path' -import {expect, test} from 'vitest' - -const result = { - status: 'success' as const, - handle: 'example', - filename: 'example.toml', - path: resolvePath('extensions/channel-config/specifications/example.toml'), - toml: 'handle = "example"\n', - warnings: [{code: 'missing_countries', message: 'Add a countries section.'}], -} - -test('encodes native TOML and warning data without changing it', () => { - expect(JSON.parse(importChannelConfigJsonOutputSchema.encode(result))).toEqual(result) - expect(JSON.parse(importChannelConfigJsonOutputSchema.encode({...result, warnings: []}))).toEqual({ - ...result, - warnings: [], - }) -}) - -test.each([ - {...result, status: 'failed'}, - {...result, internal: true}, - {...result, path: 'extensions/channel-config/specifications/example.toml'}, - {...result, path: 'C:example.toml'}, - {...result, warnings: [{...result.warnings[0], internal: true}]}, - {...result, warnings: [{code: 42, message: 'Add a countries section.'}]}, - {...result, toml: null}, -])('rejects invalid CLI-owned fields: %j', (value) => { - expect(() => importChannelConfigJsonOutputSchema.validate(value)).toThrow() -}) - -test.each(['/tmp/example.toml', 'C:\\app\\example.toml', '\\\\server\\app\\example.toml'])( - 'accepts absolute native paths: %s', - (path) => { - expect(JSON.parse(importChannelConfigJsonOutputSchema.encode({...result, path})).path).toBe(path) - }, -) From 1206ea1a5584856ec5b8529357fc7cf6a94fd85b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Wed, 7 Oct 2026 15:18:55 +0200 Subject: [PATCH 3/6] Use shared warning diagnostics for channel imports --- .../src/cli/commands/app/import/channel-config.test.ts | 9 +++++++-- .../app/src/cli/services/import-channel-config/import.ts | 2 +- .../app/src/cli/services/import-channel-config/result.ts | 6 +----- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/app/src/cli/commands/app/import/channel-config.test.ts b/packages/app/src/cli/commands/app/import/channel-config.test.ts index fd2743e098d..91d0f909617 100644 --- a/packages/app/src/cli/commands/app/import/channel-config.test.ts +++ b/packages/app/src/cli/commands/app/import/channel-config.test.ts @@ -1,5 +1,6 @@ import ImportChannelConfig from './channel-config.js' import {linkedAppContext} from '../../../services/app-context.js' +import {importChannelConfigJsonOutputSchema} from '../../../services/import-channel-config/types.js' import { CHANNEL_SPEC_DIRECTORY, CHANNEL_SPEC_EXTENSION_DIRECTORY, @@ -35,7 +36,8 @@ test('imports real files and writes one JSON document with warning events on std await withCapturedStandardStreams(async ({stdout, stderr}) => { await runCommand(['--path', tmp, '--json']) const path = joinPath(tmp, CHANNEL_SPEC_DIRECTORY, 'example.toml') - expect(JSON.parse(stdout())).toEqual({ + const result = JSON.parse(stdout()) + expect(result).toEqual({ status: 'success', handle: 'example', filename: 'example.toml', @@ -43,7 +45,10 @@ test('imports real files and writes one JSON document with warning events on std toml: TOML, warnings: [warning], }) - expect(JSON.parse(stderr())).toMatchObject({type: 'diagnostic', level: 'warning', ...warning}) + expect(() => + importChannelConfigJsonOutputSchema.encode({...result, path: joinPath(CHANNEL_SPEC_DIRECTORY, 'example.toml')}), + ).toThrow() + expect(JSON.parse(stderr())).toMatchObject({type: 'diagnostic', level: 'warning', message: warning.message}) await expect(readFile(path)).resolves.toBe(TOML) await expect(readFile(joinPath(tmp, CHANNEL_SPEC_EXTENSION_DIRECTORY, 'shopify.extension.toml'))).resolves.toBe( 'name = "Channel config"\ntype = "channel_config"\nhandle = "channel-config"\n', 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 c16478532bd..33aeb671f37 100644 --- a/packages/app/src/cli/services/import-channel-config/import.ts +++ b/packages/app/src/cli/services/import-channel-config/import.ts @@ -74,7 +74,7 @@ export async function importChannelConfig(options: ImportChannelConfigOptions): filename: basename(result.filename), path: outputPath, toml: result.toml, - warnings: result.warnings.map(({code, message}) => ({code, message})), + warnings: result.warnings, extensionConfigurationPath: createExtension ? resolvePath(extensionDirectory, EXTENSION_CONFIG_FILENAME) : null, } } diff --git a/packages/app/src/cli/services/import-channel-config/result.ts b/packages/app/src/cli/services/import-channel-config/result.ts index 3c0a7111e73..ba2acfa2a05 100644 --- a/packages/app/src/cli/services/import-channel-config/result.ts +++ b/packages/app/src/cli/services/import-channel-config/result.ts @@ -1,7 +1,6 @@ import {importChannelConfigJsonOutputSchema, type ImportedChannelConfig} from './types.js' import {relativePath} from '@shopify/cli-kit/node/path' import {outputResult} from '@shopify/cli-kit/node/output' -import {emitCommandEvent} from '@shopify/cli-kit/node/command-events' import {renderSuccess, renderWarning} from '@shopify/cli-kit/node/ui' export function renderImportChannelConfigResult( @@ -9,10 +8,8 @@ export function renderImportChannelConfigResult( app: {directory: string; name: string}, format: 'json' | 'text', ): void { + result.warnings.forEach((warning) => renderWarning({body: warning.message})) if (format === 'json') { - result.warnings.forEach(({code, message}) => - emitCommandEvent({type: 'diagnostic', level: 'warning', message, ...(code ? {code} : {})}), - ) outputResult( importChannelConfigJsonOutputSchema.encode({ status: result.status, @@ -26,7 +23,6 @@ export function renderImportChannelConfigResult( return } - result.warnings.forEach((warning) => renderWarning({body: warning.message})) renderSuccess({ headline: ['Imported the channel spec for', {userInput: app.name}, {char: '.'}], body: [ From f511de3b22cc9a176d9cfafa420f84a74855b30f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Wed, 7 Oct 2026 15:52:26 +0200 Subject: [PATCH 4/6] Use a static schema field for channel imports --- packages/app/src/cli/commands/app/import/channel-config.ts | 4 +--- packages/eslint-plugin-cli/rules/command-json-output.js | 4 +++- packages/eslint-plugin-cli/rules/command-json-output.test.js | 4 +--- 3 files changed, 5 insertions(+), 7 deletions(-) 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 f0602b2f0ba..2845096f21e 100644 --- a/packages/app/src/cli/commands/app/import/channel-config.ts +++ b/packages/app/src/cli/commands/app/import/channel-config.ts @@ -17,9 +17,7 @@ export default class ImportChannelConfig extends AppLinkedCommand { The generated TOML only includes public \`channel_config\` fields. Review it, then deploy it with \`shopify app deploy\`.` - static get jsonOutputSchema() { - return importChannelConfigJsonOutputSchema - } + static jsonOutputSchema = importChannelConfigJsonOutputSchema static description = this.descriptionForHelp() diff --git a/packages/eslint-plugin-cli/rules/command-json-output.js b/packages/eslint-plugin-cli/rules/command-json-output.js index a6493344283..4d41adab57a 100644 --- a/packages/eslint-plugin-cli/rules/command-json-output.js +++ b/packages/eslint-plugin-cli/rules/command-json-output.js @@ -62,7 +62,9 @@ function repositoryPath(filename) { function hasJsonOutputSchema(classMembers) { return classMembers.some( (member) => - member.type === 'MethodDefinition' && member.kind === 'get' && isStaticMemberNamed(member, 'jsonOutputSchema'), + isStaticMemberNamed(member, 'jsonOutputSchema') && + ((member.type === 'MethodDefinition' && member.kind === 'get') || + (member.type === 'PropertyDefinition' && Boolean(member.value))), ) } diff --git a/packages/eslint-plugin-cli/rules/command-json-output.test.js b/packages/eslint-plugin-cli/rules/command-json-output.test.js index 5eaa3d46144..b095246b579 100644 --- a/packages/eslint-plugin-cli/rules/command-json-output.test.js +++ b/packages/eslint-plugin-cli/rules/command-json-output.test.js @@ -37,9 +37,7 @@ ruleTester.run('command-json-output', rule, { code: ` export default class WidgetDelete extends Command { static flags = {...globalFlags, ...jsonFlag} - static get jsonOutputSchema() { - return widgetDeleteJsonOutputSchema - } + static jsonOutputSchema = widgetDeleteJsonOutputSchema } `, }, From d7233963b79019d1f6f15d6e2754e844faa50a7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Wed, 7 Oct 2026 15:58:20 +0200 Subject: [PATCH 5/6] Keep runtime JSON schemas out of cached manifests --- bin/prettify-manifests.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/bin/prettify-manifests.js b/bin/prettify-manifests.js index 2363b227819..5ce134edb21 100755 --- a/bin/prettify-manifests.js +++ b/bin/prettify-manifests.js @@ -13,6 +13,11 @@ const manifestFiles = glob.sync(`packages/*/oclif.manifest.json`) for (const file of manifestFiles) { console.log(`Prettifying ${file}...`) const content = fs.readFileSync(file) - const prettyContent = stringify(JSON.parse(content), {space: ' '}).replaceAll(root, '.') + const manifest = JSON.parse(content) + for (const command of Object.values(manifest.commands)) { + // Runtime schemas belong to command classes, not cached metadata. + delete command.jsonOutputSchema + } + const prettyContent = stringify(manifest, {space: ' '}).replaceAll(root, '.') fs.writeFileSync(file, prettyContent) } From 2718614cbe9395ed9e4cf6e2dd2475765186afd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Thu, 8 Oct 2026 18:02:17 +0200 Subject: [PATCH 6/6] Simplify channel-config JSON output and validate before writing --- bin/prettify-manifests.js | 7 +----- .../app/import/channel-config.test.ts | 4 +++ .../cli/commands/app/import/channel-config.ts | 4 ++- .../import-channel-config/import.test.ts | 25 +++++++++++++------ .../services/import-channel-config/import.ts | 20 +++++++++------ .../services/import-channel-config/types.ts | 4 +-- packages/cli/oclif.manifest.json | 2 +- .../rules/command-json-output.js | 4 +-- .../rules/command-json-output.test.js | 4 ++- 9 files changed, 45 insertions(+), 29 deletions(-) diff --git a/bin/prettify-manifests.js b/bin/prettify-manifests.js index 5ce134edb21..2363b227819 100755 --- a/bin/prettify-manifests.js +++ b/bin/prettify-manifests.js @@ -13,11 +13,6 @@ const manifestFiles = glob.sync(`packages/*/oclif.manifest.json`) for (const file of manifestFiles) { console.log(`Prettifying ${file}...`) const content = fs.readFileSync(file) - const manifest = JSON.parse(content) - for (const command of Object.values(manifest.commands)) { - // Runtime schemas belong to command classes, not cached metadata. - delete command.jsonOutputSchema - } - const prettyContent = stringify(manifest, {space: ' '}).replaceAll(root, '.') + const prettyContent = stringify(JSON.parse(content), {space: ' '}).replaceAll(root, '.') fs.writeFileSync(file, prettyContent) } diff --git a/packages/app/src/cli/commands/app/import/channel-config.test.ts b/packages/app/src/cli/commands/app/import/channel-config.test.ts index 91d0f909617..3b5e724413f 100644 --- a/packages/app/src/cli/commands/app/import/channel-config.test.ts +++ b/packages/app/src/cli/commands/app/import/channel-config.test.ts @@ -48,6 +48,10 @@ test('imports real files and writes one JSON document with warning events on std expect(() => importChannelConfigJsonOutputSchema.encode({...result, path: joinPath(CHANNEL_SPEC_DIRECTORY, 'example.toml')}), ).toThrow() + expect(() => importChannelConfigJsonOutputSchema.encode({...result, internal: 'private'})).toThrow() + expect(() => + importChannelConfigJsonOutputSchema.encode({...result, warnings: [{...warning, internal: 'private'}]}), + ).toThrow() expect(JSON.parse(stderr())).toMatchObject({type: 'diagnostic', level: 'warning', message: warning.message}) await expect(readFile(path)).resolves.toBe(TOML) await expect(readFile(joinPath(tmp, CHANNEL_SPEC_EXTENSION_DIRECTORY, 'shopify.extension.toml'))).resolves.toBe( 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 2845096f21e..f0602b2f0ba 100644 --- a/packages/app/src/cli/commands/app/import/channel-config.ts +++ b/packages/app/src/cli/commands/app/import/channel-config.ts @@ -17,7 +17,9 @@ export default class ImportChannelConfig extends AppLinkedCommand { The generated TOML only includes public \`channel_config\` fields. Review it, then deploy it with \`shopify app deploy\`.` - static jsonOutputSchema = importChannelConfigJsonOutputSchema + static get jsonOutputSchema() { + return importChannelConfigJsonOutputSchema + } static description = this.descriptionForHelp() 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 3628a56a1d7..50b0e48e181 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 @@ -8,17 +8,12 @@ import { testOrganizationApp, testUIExtension, } from '../../models/app/app.test-data.js' -import {afterEach, describe, expect, test, vi} from 'vitest' +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') -afterEach(() => { - mockAndCaptureOutput().clear() -}) - const TOML = 'handle = "example"\nlabel = "Example Channel"\n' function successResult(warnings: {code: string; message: string}[] = []) { @@ -89,6 +84,23 @@ describe('importChannelConfig', () => { }) }) + test('validates the export before replacing an existing spec with --force', async () => { + await inTemporaryDirectory(async (tmpDir) => { + vi.mocked(fetchChannelSpecExport).mockResolvedValue({...successResult(), handle: ''}) + const app = testAppLinked({directory: tmpDir}) + const outputPath = joinPath(tmpDir, CHANNEL_SPEC_DIRECTORY, 'example.toml') + await mkdir(dirname(outputPath)) + await writeFile(outputPath, 'existing = true\n') + + await expect(importChannelConfig(testOptions(app, {force: true}))).rejects.toThrow() + + await expect(readFile(outputPath)).resolves.toEqual('existing = true\n') + await expect( + fileExists(joinPath(tmpDir, CHANNEL_SPEC_EXTENSION_DIRECTORY, 'shopify.extension.toml')), + ).resolves.toBe(false) + }) + }) + test('returns backend warnings when writing the file', async () => { await inTemporaryDirectory(async (tmpDir) => { // Given @@ -140,7 +152,6 @@ describe('importChannelConfig', () => { // Given vi.mocked(fetchChannelSpecExport).mockResolvedValue({...successResult(), filename: '../../evil.toml'}) const app = testAppLinked({directory: tmpDir}) - mockAndCaptureOutput() // When await importChannelConfig(testOptions(app)) 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 33aeb671f37..090afc6a527 100644 --- a/packages/app/src/cli/services/import-channel-config/import.ts +++ b/packages/app/src/cli/services/import-channel-config/import.ts @@ -1,5 +1,5 @@ import {fetchChannelSpecExport} from './fetch.js' -import {ImportedChannelConfig} from './types.js' +import {importChannelConfigJsonOutputSchema, type ImportedChannelConfig} from './types.js' import {AppLinkedInterface} from '../../models/app/app.js' import { CHANNEL_CONFIG_IDENTIFIER, @@ -62,19 +62,23 @@ export async function importChannelConfig(options: ImportChannelConfigOptions): ) } + const imported = importChannelConfigJsonOutputSchema.validate({ + status: 'success', + handle: result.handle, + filename: basename(result.filename), + path: outputPath, + toml: result.toml, + warnings: result.warnings.map(({code, message}) => ({code, message})), + }) + await mkdir(dirname(outputPath)) if (createExtension) { await writeFile(joinPath(extensionDirectory, EXTENSION_CONFIG_FILENAME), EXTENSION_CONFIG_CONTENT) } - await writeFile(outputPath, result.toml) + await writeFile(outputPath, imported.toml) return { - status: 'success', - handle: result.handle, - filename: basename(result.filename), - path: outputPath, - toml: result.toml, - warnings: result.warnings, + ...imported, extensionConfigurationPath: createExtension ? resolvePath(extensionDirectory, EXTENSION_CONFIG_FILENAME) : null, } } diff --git a/packages/app/src/cli/services/import-channel-config/types.ts b/packages/app/src/cli/services/import-channel-config/types.ts index 9597bd6c2c3..ab99cbdf651 100644 --- a/packages/app/src/cli/services/import-channel-config/types.ts +++ b/packages/app/src/cli/services/import-channel-config/types.ts @@ -13,8 +13,8 @@ export const importChannelConfigJsonOutputSchema = defineJsonOutputSchema({ schema: zod .object({ status: zod.literal('success'), - handle: zod.string(), - filename: zod.string(), + handle: zod.string().min(1), + filename: zod.string().min(1), path: zod .string() .regex(/^(?:\/|[a-zA-Z]:[\\/]|\\\\)/, 'The path must be absolute.') diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 28e84e45e90..dedabb291b0 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -3045,7 +3045,7 @@ "args": { }, "customPluginName": "@shopify/app", - "description": "Imports the Shopify-authored channel spec for your app as a `channel_config` extension.\n\n The generated TOML only includes public `channel_config` fields. Review it, then deploy it with `shopify app deploy`.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `ImportChannelConfigResult` schema.\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"success\"\n },\n \"handle\": {\n \"type\": \"string\"\n },\n \"filename\": {\n \"type\": \"string\"\n },\n \"path\": {\n \"type\": \"string\",\n \"pattern\": \"^(?:\\\\/|[a-zA-Z]:[\\\\\\\\/]|\\\\\\\\\\\\\\\\)\",\n \"description\": \"Absolute path of the written TOML file.\"\n },\n \"toml\": {\n \"type\": \"string\",\n \"description\": \"Native channel_config TOML content, unchanged from the export.\"\n },\n \"warnings\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/ChannelSpecExportWarning\"\n },\n \"description\": \"Warnings returned with the channel spec export.\"\n }\n },\n \"required\": [\n \"status\",\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```", + "description": "Imports the Shopify-authored channel spec for your app as a `channel_config` extension.\n\n The generated TOML only includes public `channel_config` fields. Review it, then deploy it with `shopify app deploy`.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `ImportChannelConfigResult` schema.\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"success\"\n },\n \"handle\": {\n \"type\": \"string\",\n \"minLength\": 1\n },\n \"filename\": {\n \"type\": \"string\",\n \"minLength\": 1\n },\n \"path\": {\n \"type\": \"string\",\n \"pattern\": \"^(?:\\\\/|[a-zA-Z]:[\\\\\\\\/]|\\\\\\\\\\\\\\\\)\",\n \"description\": \"Absolute path of the written TOML file.\"\n },\n \"toml\": {\n \"type\": \"string\",\n \"description\": \"Native channel_config TOML content, unchanged from the export.\"\n },\n \"warnings\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/ChannelSpecExportWarning\"\n },\n \"description\": \"Warnings returned with the channel spec export.\"\n }\n },\n \"required\": [\n \"status\",\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 channel spec for your app as a `channel_config` extension.\n\n The generated TOML only includes public `channel_config` fields. Review it, then deploy it with `shopify app deploy`.", "flags": { "auth-alias": { diff --git a/packages/eslint-plugin-cli/rules/command-json-output.js b/packages/eslint-plugin-cli/rules/command-json-output.js index 4d41adab57a..a6493344283 100644 --- a/packages/eslint-plugin-cli/rules/command-json-output.js +++ b/packages/eslint-plugin-cli/rules/command-json-output.js @@ -62,9 +62,7 @@ function repositoryPath(filename) { function hasJsonOutputSchema(classMembers) { return classMembers.some( (member) => - isStaticMemberNamed(member, 'jsonOutputSchema') && - ((member.type === 'MethodDefinition' && member.kind === 'get') || - (member.type === 'PropertyDefinition' && Boolean(member.value))), + member.type === 'MethodDefinition' && member.kind === 'get' && isStaticMemberNamed(member, 'jsonOutputSchema'), ) } diff --git a/packages/eslint-plugin-cli/rules/command-json-output.test.js b/packages/eslint-plugin-cli/rules/command-json-output.test.js index b095246b579..5eaa3d46144 100644 --- a/packages/eslint-plugin-cli/rules/command-json-output.test.js +++ b/packages/eslint-plugin-cli/rules/command-json-output.test.js @@ -37,7 +37,9 @@ ruleTester.run('command-json-output', rule, { code: ` export default class WidgetDelete extends Command { static flags = {...globalFlags, ...jsonFlag} - static jsonOutputSchema = widgetDeleteJsonOutputSchema + static get jsonOutputSchema() { + return widgetDeleteJsonOutputSchema + } } `, },