From 70a11eba159a484fab6639e95dcdf8563d60a998 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Wed, 7 Oct 2026 13:56:30 +0200 Subject: [PATCH 1/6] Add typed JSON output to app import dashboard-extensions --- .../app-import-dashboard-extensions-json.md | 5 + .../generated/generated_docs_data_v2.json | 11 +- .../app/import/dashboard-extensions.test.ts | 503 ++++++++++++++++++ .../app/import/dashboard-extensions.ts | 154 +++++- .../cli/services/import-extensions.test.ts | 61 ++- .../app/src/cli/services/import-extensions.ts | 113 ++-- .../cli/services/import-extensions/result.ts | 15 + .../services/import-extensions/types.test.ts | 93 ++++ .../cli/services/import-extensions/types.ts | 68 +++ packages/cli/README.md | 303 ++++++++++- packages/cli/oclif.manifest.json | 24 +- .../rules/json-output-command-exceptions.js | 1 - 12 files changed, 1297 insertions(+), 54 deletions(-) create mode 100644 .changeset/app-import-dashboard-extensions-json.md create mode 100644 packages/app/src/cli/commands/app/import/dashboard-extensions.test.ts create mode 100644 packages/app/src/cli/services/import-extensions/result.ts create mode 100644 packages/app/src/cli/services/import-extensions/types.test.ts create mode 100644 packages/app/src/cli/services/import-extensions/types.ts diff --git a/.changeset/app-import-dashboard-extensions-json.md b/.changeset/app-import-dashboard-extensions-json.md new file mode 100644 index 00000000000..b6d60405e2f --- /dev/null +++ b/.changeset/app-import-dashboard-extensions-json.md @@ -0,0 +1,5 @@ +--- +"@shopify/cli": minor +--- + +Add typed JSON output to `app import dashboard-extensions`. diff --git a/docs-shopify.dev/generated/generated_docs_data_v2.json b/docs-shopify.dev/generated/generated_docs_data_v2.json index 79b22e26edf..af8299837e9 100644 --- a/docs-shopify.dev/generated/generated_docs_data_v2.json +++ b/docs-shopify.dev/generated/generated_docs_data_v2.json @@ -2795,9 +2795,18 @@ "description": "The name of the app configuration.", "isOptional": true, "environmentValue": "SHOPIFY_FLAG_APP_CONFIG" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-import-dashboard-extensions.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 appimportdashboardextensions {\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 * Disable interactive prompts and browser authentication.\n * @environment SHOPIFY_FLAG_NO_INPUT\n */\n '--no-input'?: ''\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 * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" + "value": "export interface appimportdashboardextensions {\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 * Disable interactive prompts and browser authentication.\n * @environment SHOPIFY_FLAG_NO_INPUT\n */\n '--no-input'?: ''\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 * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" } }, "appinfo": { diff --git a/packages/app/src/cli/commands/app/import/dashboard-extensions.test.ts b/packages/app/src/cli/commands/app/import/dashboard-extensions.test.ts new file mode 100644 index 00000000000..cc101efd6fa --- /dev/null +++ b/packages/app/src/cli/commands/app/import/dashboard-extensions.test.ts @@ -0,0 +1,503 @@ +import ImportDashboardExtensions from './dashboard-extensions.js' +import ImportExtensionsDeprecated from '../import-extensions.js' +import {linkedAppContext} from '../../../services/app-context.js' +import {ExtensionImportCancelledError, ExtensionImportFailedError} from '../../../services/import-extensions.js' +import * as extensionImportService from '../../../services/import-extensions.js' +import {importDashboardExtensionsJsonOutputSchema} from '../../../services/import-extensions/types.js' +import {testAppLinked, testOrganizationApp, testDeveloperPlatformClient} from '../../../models/app/app.test-data.js' +import {ExtensionRegistration} from '../../../api/graphql/all_app_extension_registrations.js' +import {Config, Errors} from '@oclif/core' +import {afterEach, expect, test, vi} from 'vitest' +import {fileExists, inTemporaryDirectory, mkdir, readFile, writeFile} from '@shopify/cli-kit/node/fs' +import {joinPath} from '@shopify/cli-kit/node/path' +import {withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output' +import {runWithCommandEventsForCommand} from '@shopify/cli-kit/node/command-events' +import {outputInfo, unstyled} from '@shopify/cli-kit/node/output' +import {renderSelectPrompt} from '@shopify/cli-kit/node/ui' +import {reportAnalyticsEvent} from '@shopify/cli-kit/node/analytics' +import {sendErrorToBugsnag} from '@shopify/cli-kit/node/error-handler' +import {AbortError, AbortSilentError, handler} from '@shopify/cli-kit/node/error' + +// eslint-disable-next-line n/prefer-global/console +import {Console} from 'node:console' + +vi.mock('../../../services/app-context.js') +vi.mock('@shopify/cli-kit/node/analytics') +vi.mock('@shopify/cli-kit/node/error-handler') +vi.mock('@shopify/cli-kit/node/ui', async (importOriginal) => ({ + ...(await importOriginal()), + renderSelectPrompt: vi.fn(), +})) + +const extension: ExtensionRegistration = { + id: 'gid://shopify/AppExtensionRegistration/1', + uuid: 'e7282f2d-3017-4608-a9a4-54dd5a7a70aa', + title: 'Example action', + type: 'flow_action_definition', + activeVersion: {config: '{"title":"Example action","description":"A description","url":"https://example.com/run"}'}, +} +const expectedToml = + '[[extensions]]\ntype = "flow_action"\nname = "Example action"\nhandle = "example-action"\ndescription = "A description"\nruntime_url = "https://example.com/run"\n' + +afterEach(() => { + vi.unstubAllEnvs() + vi.unstubAllGlobals() +}) + +async function withApp(run: (app: ReturnType) => Promise, extensions = [extension]) { + vi.stubGlobal('console', {...globalThis.console, Console}) + await inTemporaryDirectory(async (directory) => { + const configPath = joinPath(directory, 'shopify.app.toml') + await writeFile(configPath, 'name = "Test app"\nclient_id = "test-client-id"\n') + const app = testAppLinked({directory, configPath}) + const developerPlatformClient = testDeveloperPlatformClient() + vi.spyOn(developerPlatformClient, 'appExtensionRegistrations').mockImplementation(async () => { + outputInfo('Loaded dashboard extensions') + return { + app: { + extensionRegistrations: [], + dashboardManagedExtensionRegistrations: extensions, + configurationRegistrations: [], + }, + } + }) + vi.mocked(linkedAppContext).mockResolvedValue({ + app, + remoteApp: testOrganizationApp({apiKey: 'test-client-id'}), + developerPlatformClient, + } as unknown as Awaited>) + vi.mocked(renderSelectPrompt).mockResolvedValue(extension.uuid) + await run(app) + }) +} + +async function runCommand(directory: string, argv: string[], deprecated = false) { + const Command = deprecated ? ImportExtensionsDeprecated : ImportDashboardExtensions + const args = ['--path', directory, ...argv] + const command = new Command(args, await Config.load()) + return runWithCommandEventsForCommand(args, () => command.run()) +} + +async function handleSilentExit(outcome: unknown) { + expect(outcome).toBeInstanceOf(AbortSilentError) + vi.stubEnv('SHOPIFY_FLAG_JSON', '1') + await handler(outcome) + const exit = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as typeof process.exit) + try { + await Errors.handle(outcome as AbortSilentError) + expect(exit).toHaveBeenCalledWith(1) + } finally { + exit.mockRestore() + } +} + +function publicExtension(directory: string, changed = true, ext = extension) { + const extensionDirectory = joinPath( + directory, + 'extensions', + ext.title === extension.title ? 'example-action' : 'other-action', + ) + return { + id: ext.uuid, + name: ext.title, + type: ext.type, + directory: extensionDirectory, + configurationPath: joinPath(extensionDirectory, 'shopify.extension.toml'), + changed, + } +} + +test('writes one JSON result, diagnostics on stderr, native TOML, and extension identifiers', async () => { + await withApp(async (app) => { + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await expect(runCommand(app.directory, ['--json'])).resolves.toEqual({app}) + expect(JSON.parse(stdout())).toEqual({ + status: 'success', + reason: null, + extensions: [publicExtension(app.directory)], + errors: [], + identifiersUpdated: true, + }) + expect( + stderr() + .trim() + .split('\n') + .map((line) => JSON.parse(line)), + ).toEqual([expect.objectContaining({type: 'diagnostic', level: 'info', message: 'Loaded dashboard extensions'})]) + expect(stderr()).not.toContain('Imported the following') + }) + await expect(readFile(publicExtension(app.directory).configurationPath)).resolves.toBe(expectedToml) + await expect(readFile(joinPath(app.directory, '.env'))).resolves.toContain( + `SHOPIFY_EXAMPLE_ACTION_ID=${extension.uuid}`, + ) + expect(app.dotenv?.variables.SHOPIFY_EXAMPLE_ACTION_ID).toBe(extension.uuid) + await expect(fileExists(joinPath(app.directory, 'extensions', 'example-action', '.shopify.lock'))).resolves.toBe( + false, + ) + }) +}) + +test('keeps native local TOML unchanged and still persists identifiers when the picker selects Keep', async () => { + await withApp(async (app) => { + const {directory, configurationPath} = publicExtension(app.directory) + await mkdir(directory) + const localToml = '# Local café\r\n[[extensions]]\r\nname = "Local action"\r\n' + await writeFile(configurationPath, localToml) + vi.mocked(renderSelectPrompt).mockResolvedValueOnce(extension.uuid).mockResolvedValueOnce('skip') + await withCapturedStandardStreams(async ({stdout}) => { + await runCommand(app.directory, ['--json']) + expect(JSON.parse(stdout())).toEqual({ + status: 'success', + reason: null, + extensions: [publicExtension(app.directory, false)], + errors: [], + identifiersUpdated: true, + }) + }) + await expect(readFile(configurationPath)).resolves.toBe(localToml) + await expect(readFile(joinPath(app.directory, '.env'))).resolves.toContain( + `SHOPIFY_EXAMPLE_ACTION_ID=${extension.uuid}`, + ) + }) +}) + +test('does not report a TOML artifact for an empty kept directory', async () => { + await withApp(async (app) => { + await mkdir(publicExtension(app.directory).directory) + vi.mocked(renderSelectPrompt).mockResolvedValueOnce(extension.uuid).mockResolvedValueOnce('skip') + await withCapturedStandardStreams(async ({stdout}) => { + await runCommand(app.directory, ['--json']) + expect(JSON.parse(stdout())).toMatchObject({extensions: [{changed: false, configurationPath: null}]}) + }) + }) +}) + +test('returns a skipped result without writing identifiers when no remote extensions exist', async () => { + await withApp(async (app) => { + await withCapturedStandardStreams(async ({stdout}) => { + await runCommand(app.directory, ['--json']) + expect(JSON.parse(stdout())).toEqual({ + status: 'skipped', + reason: 'no-extensions', + extensions: [], + errors: [], + identifiersUpdated: false, + }) + }) + expect(renderSelectPrompt).not.toHaveBeenCalled() + await expect(fileExists(joinPath(app.directory, '.env'))).resolves.toBe(false) + }, []) +}) + +test('keeps the success banner and relative extension path on stderr in text mode', async () => { + await withApp(async (app) => { + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await runCommand(app.directory, []) + expect(stdout()).toBe('') + expect(unstyled(stderr())).toContain('Imported the following extensions from the dashboard:') + expect(unstyled(stderr())).toContain('"Example action" at: extensions/example-action') + }) + await expect(readFile(publicExtension(app.directory).configurationPath)).resolves.toBe(expectedToml) + }) +}) + +test('returns one cancelled document and exit 1 for the existing-directory Cancel selection', async () => { + await withApp(async (app) => { + await mkdir(publicExtension(app.directory).directory) + vi.mocked(renderSelectPrompt).mockResolvedValueOnce(extension.uuid).mockResolvedValueOnce('cancel') + await withCapturedStandardStreams(async ({stdout}) => { + const error = await runCommand(app.directory, ['--json']).catch((failure: unknown) => failure) + await handleSilentExit(error) + expect(JSON.parse(stdout())).toEqual({ + status: 'cancelled', + reason: 'directory-selection-cancelled', + extensions: [], + errors: [], + identifiersUpdated: false, + }) + }) + await expect(fileExists(joinPath(app.directory, '.env'))).resolves.toBe(false) + }) +}) + +test('preserves AbortSilentError and the standard exit 1 in text mode', async () => { + await withApp(async (app) => { + await mkdir(publicExtension(app.directory).directory) + vi.mocked(renderSelectPrompt).mockResolvedValueOnce(extension.uuid).mockResolvedValueOnce('cancel') + await withCapturedStandardStreams(async ({stdout, stderr}) => { + const error = await runCommand(app.directory, []).catch((failure: AbortSilentError) => failure) + expect(error).toBeInstanceOf(AbortSilentError) + const exit = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as typeof process.exit) + await Errors.handle(error as AbortSilentError) + expect(exit).toHaveBeenCalledWith(1) + exit.mockRestore() + expect(stdout()).toBe('') + expect(stderr()).not.toContain('Imported the following') + }) + }) +}) + +test.each(['json', 'text'])('preserves pending sibling writes when another picker cancels (%s)', async (format) => { + const sibling = {...extension, uuid: '594a39be-1c11-4a47-a17c-4a7d043dbbb7', title: 'Other action'} + await withApp( + async (app) => { + await mkdir(publicExtension(app.directory).directory) + await mkdir(publicExtension(app.directory, true, sibling).directory) + let releaseWrite!: () => void + const writeReady = new Promise((resolve) => { + releaseWrite = () => resolve('write') + }) + let selectCancelled!: () => void + const cancelled = new Promise((resolve) => { + selectCancelled = resolve + }) + vi.mocked(renderSelectPrompt).mockImplementation(async ({message}) => { + if (message === 'Extensions to migrate') return 'All' + if (typeof message === 'string' && message.includes('example-action')) return writeReady + selectCancelled() + return 'cancel' + }) + await withCapturedStandardStreams(async ({stdout}) => { + const command = runCommand(app.directory, format === 'json' ? ['--json'] : []) + const outcome = command.catch((error: ExtensionImportCancelledError) => error) + await cancelled + expect(stdout()).toBe('') + if (format === 'text') { + const error = await outcome + expect(error).toBeInstanceOf(ExtensionImportCancelledError) + await expect(fileExists(publicExtension(app.directory).configurationPath)).resolves.toBe(false) + releaseWrite() + const completed = await (error as ExtensionImportCancelledError).completedImports() + expect(completed.extensions).toHaveLength(1) + } else { + releaseWrite() + await handleSilentExit(await outcome) + expect(JSON.parse(stdout())).toEqual({ + status: 'cancelled', + reason: 'directory-selection-cancelled', + extensions: [publicExtension(app.directory)], + errors: [], + identifiersUpdated: false, + }) + } + }) + await expect(readFile(publicExtension(app.directory).configurationPath)).resolves.toBe(expectedToml) + await expect(fileExists(joinPath(app.directory, '.env'))).resolves.toBe(false) + }, + [extension, sibling], + ) +}) + +test.each(['json', 'text'])('preserves identifier-write failure ordering (%s)', async (format) => { + await withApp(async (app) => { + const dotenvPath = joinPath(app.directory, 'environment-directory') + app.dotenv = {path: dotenvPath, variables: {}} + await mkdir(dotenvPath) + await withCapturedStandardStreams(async ({stdout, stderr}) => { + if (format === 'text') { + await expect(runCommand(app.directory, [])).rejects.toThrow() + expect(stdout()).toBe('') + expect(unstyled(stderr())).toContain('Imported the following extensions from the dashboard:') + } else { + await handleSilentExit(await runCommand(app.directory, ['--json']).catch((failure: unknown) => failure)) + expect(JSON.parse(stdout())).toMatchObject({ + status: 'partial', + extensions: [publicExtension(app.directory)], + errors: [{extensionId: null, error: {type: 'bug', code: 'EISDIR'}}], + identifiersUpdated: false, + }) + expect(stderr()).not.toContain('Imported the following') + } + }) + await expect(readFile(publicExtension(app.directory).configurationPath)).resolves.toBe(expectedToml) + }) +}) + +test.each(['json', 'text'])( + 'waits for a delayed successful sibling after a write failure only in JSON (%s)', + async (format) => { + const sibling = {...extension, uuid: '594a39be-1c11-4a47-a17c-4a7d043dbbb7', title: 'Other action'} + await withApp( + async (app) => { + await mkdir(publicExtension(app.directory).directory) + await mkdir(publicExtension(app.directory, true, sibling).directory) + await mkdir(publicExtension(app.directory, true, sibling).configurationPath) + let releaseWrite!: () => void + const writeReady = new Promise((resolve) => { + releaseWrite = () => resolve('write') + }) + let reportFailure!: (error: ExtensionImportFailedError) => void + const failedWrite = new Promise((resolve) => { + reportFailure = resolve + }) + const realImport = extensionImportService.importExtensions + const importSpy = vi.spyOn(extensionImportService, 'importExtensions').mockImplementation(async (options) => { + try { + return await realImport(options) + } catch (error) { + if (error instanceof ExtensionImportFailedError) reportFailure(error) + throw error + } + }) + vi.mocked(renderSelectPrompt).mockImplementation(async ({message}) => { + if (message === 'Extensions to migrate') return 'All' + if (typeof message === 'string' && message.includes('example-action')) return writeReady + return 'write' + }) + await withCapturedStandardStreams(async ({stdout}) => { + const outcome = runCommand(app.directory, format === 'json' ? ['--json'] : []).catch( + (error: unknown) => error, + ) + const failure = await failedWrite + expect(stdout()).toBe('') + if (format === 'text') { + await expect(outcome).resolves.toBe(failure.originalError) + await expect(fileExists(publicExtension(app.directory).configurationPath)).resolves.toBe(false) + releaseWrite() + await failure.completedImports() + } else { + releaseWrite() + await handleSilentExit(await outcome) + expect(JSON.parse(stdout())).toMatchObject({ + status: 'partial', + reason: null, + extensions: [publicExtension(app.directory)], + errors: [{extensionId: sibling.uuid, error: {type: 'bug', code: 'EISDIR'}}], + identifiersUpdated: false, + }) + } + }) + importSpy.mockRestore() + await expect(readFile(publicExtension(app.directory).configurationPath)).resolves.toBe(expectedToml) + await expect(fileExists(joinPath(app.directory, '.env'))).resolves.toBe(false) + }, + [extension, sibling], + ) + }, +) + +test('all failed imports retain the first original error and emit one shared fatal document', async () => { + const sibling = {...extension, uuid: '594a39be-1c11-4a47-a17c-4a7d043dbbb7', title: 'Other action'} + await withApp( + async (app) => { + await Promise.all( + [extension, sibling].map(async (ext) => { + const record = publicExtension(app.directory, true, ext) + await mkdir(record.directory) + await mkdir(record.configurationPath) + }), + ) + vi.mocked(renderSelectPrompt).mockResolvedValueOnce('All').mockResolvedValue('write') + await withCapturedStandardStreams(async ({stdout}) => { + const error = await runCommand(app.directory, ['--json']).catch((failure: unknown) => failure) + expect(error).toMatchObject({code: 'EISDIR'}) + expect(stdout()).toBe('') + vi.stubEnv('SHOPIFY_FLAG_JSON', '1') + await runWithCommandEventsForCommand(['--json'], () => handler(error as Error)) + expect(JSON.parse(stdout())).toMatchObject({error: {type: 'bug', message: expect.stringContaining('EISDIR')}}) + }) + await expect(fileExists(joinPath(app.directory, '.env'))).resolves.toBe(false) + }, + [extension, sibling], + ) +}) + +test('keeps transport errors on the fatal path without printing a result first', async () => { + await withApp(async (app) => { + vi.mocked(linkedAppContext).mockRejectedValue(new AbortError('Authentication failed')) + await withCapturedStandardStreams(async ({stdout}) => { + const error = await runCommand(app.directory, ['--json']).catch((failure: AbortError) => failure) + expect(stdout()).toBe('') + vi.stubEnv('SHOPIFY_FLAG_JSON', '1') + await runWithCommandEventsForCommand(['--json'], () => handler(error as AbortError)) + expect(JSON.parse(stdout())).toMatchObject({error: {type: 'abort', message: 'Authentication failed'}}) + }) + }) +}) + +test.each([ + {argv: [], disabled: false}, + {argv: ['--json'], disabled: false}, + {argv: ['--no-input'], disabled: true}, + {argv: ['--json', '--no-input'], disabled: true}, +])('keeps input policy independent of output formatting: $argv', async ({argv, disabled}) => { + await withApp(async (app) => { + vi.stubEnv('SHOPIFY_FLAG_NO_INPUT', disabled ? '1' : '0') + const realUi = await vi.importActual('@shopify/cli-kit/node/ui') + if (disabled) vi.mocked(renderSelectPrompt).mockImplementation(realUi.renderSelectPrompt) + await withCapturedStandardStreams(async ({stdout}) => { + if (disabled) { + await expect(runCommand(app.directory, argv)).rejects.toThrow('Failed to prompt:') + expect(stdout()).toBe('') + await expect(fileExists(publicExtension(app.directory).configurationPath)).resolves.toBe(false) + } else { + await runCommand(app.directory, argv) + expect(renderSelectPrompt).toHaveBeenCalledWith(expect.objectContaining({message: 'Extensions to migrate'})) + if (argv.includes('--json')) expect(JSON.parse(stdout())).toHaveProperty('status', 'success') + } + }) + }) +}) + +test('preserves the deprecated alias and exposes schema and flags from both paths', async () => { + expect(ImportDashboardExtensions.jsonOutputSchema).toBe(importDashboardExtensionsJsonOutputSchema) + expect(ImportExtensionsDeprecated.jsonOutputSchema).toBe(importDashboardExtensionsJsonOutputSchema) + expect(ImportExtensionsDeprecated.flags.json).toBeDefined() + expect(ImportDashboardExtensions.descriptionForHelp()).toContain('`ImportDashboardExtensionsResult` schema') + await withApp(async (app) => { + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await runCommand(app.directory, ['--json'], true) + expect(JSON.parse(stdout())).toHaveProperty('status', 'success') + expect(unstyled(stderr()).replaceAll('`', '')).toContain('shopify app import-extensions has moved.') + }) + }) +}) + +test.each(['cancelled', 'partial'])( + 'skips the success postrun hook for a JSON %s result and preserves reporting', + async (status) => { + await withApp(async (app) => { + if (status === 'cancelled') { + await mkdir(publicExtension(app.directory).directory) + vi.mocked(renderSelectPrompt).mockResolvedValueOnce(extension.uuid).mockResolvedValueOnce('cancel') + } else { + const dotenvPath = joinPath(app.directory, 'environment-directory') + app.dotenv = {path: dotenvPath, variables: {}} + await mkdir(dotenvPath) + } + class LifecycleImportCommand extends ImportDashboardExtensions { + async catch(error: Error & {skipOclifErrorHandling: boolean}): Promise { + throw error + } + + protected async init() { + return undefined + } + } + const config = await Config.load() + const metadata = config.findCommand('app:import:dashboard-extensions')! + vi.spyOn(config, 'findCommand').mockReturnValue({...metadata, load: async () => LifecycleImportCommand}) + const hooks = vi.spyOn(config, 'runHook').mockResolvedValue({successes: [], failures: []}) + await withCapturedStandardStreams(async ({stdout}) => { + await expect( + config.runCommand('app:import:dashboard-extensions', ['--path', app.directory, '--json']), + ).rejects.toThrow(AbortSilentError) + expect(JSON.parse(stdout())).toHaveProperty('status', status) + expect(hooks.mock.calls.map(([event]) => event)).not.toContain('postrun') + }) + if (status === 'cancelled') { + expect(reportAnalyticsEvent).not.toHaveBeenCalled() + expect(sendErrorToBugsnag).not.toHaveBeenCalled() + } else { + expect(reportAnalyticsEvent).toHaveBeenCalledOnce() + expect(reportAnalyticsEvent).toHaveBeenCalledWith({ + config, + errorMessage: expect.stringContaining('EISDIR'), + exitMode: 'unexpected_error', + }) + expect(sendErrorToBugsnag).toHaveBeenCalledOnce() + expect(sendErrorToBugsnag).toHaveBeenCalledWith(expect.objectContaining({code: 'EISDIR'}), 'unexpected_error') + } + }) + }, +) diff --git a/packages/app/src/cli/commands/app/import/dashboard-extensions.ts b/packages/app/src/cli/commands/app/import/dashboard-extensions.ts index 83622ad561b..43e48b33d1f 100644 --- a/packages/app/src/cli/commands/app/import/dashboard-extensions.ts +++ b/packages/app/src/cli/commands/app/import/dashboard-extensions.ts @@ -1,19 +1,49 @@ import {appFlags} from '../../../flags.js' -import {allExtensionTypes, importExtensions} from '../../../services/import-extensions.js' +import { + allExtensionTypes, + ExtensionImportCancelledError, + ExtensionImportFailedError, + importExtensions, +} from '../../../services/import-extensions.js' import AppLinkedCommand, {AppLinkedCommandOutput} from '../../../utilities/app-linked-command.js' import {linkedAppContext} from '../../../services/app-context.js' import {getMigrationChoices, selectMigrationChoice} from '../../../prompts/import-extensions.js' import {getExtensions} from '../../../services/fetch-extensions.js' +import {updateAppIdentifiers} from '../../../models/app/identifiers.js' +import {renderImportExtensionsResult} from '../../../services/import-extensions/result.js' +import { + importDashboardExtensionsJsonOutputSchema, + ImportDashboardExtensionsResult, + ImportedDashboardExtension, + ImportExtensionsResult, +} from '../../../services/import-extensions/types.js' +import { + AbortError, + AbortSilentError, + errorMapper, + FatalErrorType, + shouldReportErrorAsUnexpected, +} from '@shopify/cli-kit/node/error' +import {reportAnalyticsEvent} from '@shopify/cli-kit/node/analytics' +import {sendErrorToBugsnag} from '@shopify/cli-kit/node/error-handler' +import {outputResult, unstyled} from '@shopify/cli-kit/node/output' import {Flags} from '@oclif/core' -import {globalFlags} from '@shopify/cli-kit/node/cli' +import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli' import {renderSuccess} from '@shopify/cli-kit/node/ui' export default class ImportDashboardExtensions extends AppLinkedCommand { - static description = 'Import dashboard-managed extensions into your app.' + static descriptionWithMarkdown = 'Import dashboard-managed extensions into your app.' + + static description = this.descriptionForHelp() + + static get jsonOutputSchema() { + return importDashboardExtensionsJsonOutputSchema + } static flags = { ...globalFlags, ...appFlags, + ...jsonFlag, 'client-id': Flags.string({ hidden: false, description: 'The Client ID of your app.', @@ -41,17 +71,121 @@ export default class ImportDashboardExtensions extends AppLinkedCommand { const migrationChoices = getMigrationChoices(extensions) if (migrationChoices.length === 0) { - renderSuccess({headline: ['No extensions to migrate.']}) + if (flags.json) { + outputResult( + importDashboardExtensionsJsonOutputSchema.encode({ + status: 'skipped', + reason: 'no-extensions', + extensions: [], + errors: [], + identifiersUpdated: false, + }), + ) + } else { + renderSuccess({headline: ['No extensions to migrate.']}) + } } else { const migrationChoice = await selectMigrationChoice(migrationChoices) - await importExtensions({ - ...appContext, - extensions, - extensionTypes: migrationChoice.extensionTypes, - buildExtensionConfig: migrationChoice.buildExtensionConfig, - }) + let result: ImportExtensionsResult | undefined + let identifiersUpdated = false + try { + result = await importExtensions({ + ...appContext, + extensions, + extensionTypes: migrationChoice.extensionTypes, + buildExtensionConfig: migrationChoice.buildExtensionConfig, + }) + if (!flags.json) renderImportExtensionsResult(result.extensions) + await updateAppIdentifiers({ + app: appContext.app, + appApiKey: appContext.remoteApp.apiKey, + extensionUuids: result.extensionUuids, + command: 'import-extensions', + }) + identifiersUpdated = true + if (flags.json) { + outputResult( + importDashboardExtensionsJsonOutputSchema.encode({ + status: 'success', + reason: null, + extensions: projectExtensions(result.extensions), + errors: [], + identifiersUpdated: true, + }), + ) + } + } catch (error) { + if (!flags.json) throw error instanceof ExtensionImportFailedError ? error.originalError : error + if (error instanceof ExtensionImportCancelledError || error instanceof ExtensionImportFailedError) { + const completion = await error.completedImports() + if (error instanceof ExtensionImportFailedError && completion.extensions.length === 0) { + throw error.originalError + } + outputResult( + importDashboardExtensionsJsonOutputSchema.encode({ + status: error instanceof ExtensionImportCancelledError ? 'cancelled' : 'partial', + reason: error instanceof ExtensionImportCancelledError ? 'directory-selection-cancelled' : null, + extensions: projectExtensions(completion.extensions), + errors: completion.failures.map(({extension, error: failure}) => ({ + extensionId: extension.uuid, + error: projectImportError(failure), + })), + identifiersUpdated: false, + }), + ) + } else if (result && !identifiersUpdated) { + outputResult( + importDashboardExtensionsJsonOutputSchema.encode({ + status: 'partial', + reason: null, + extensions: projectExtensions(result.extensions), + errors: [{extensionId: null, error: projectImportError(error)}], + identifiersUpdated: false, + }), + ) + } else { + throw error + } + if (error instanceof ExtensionImportCancelledError) throw error + const originalError = error instanceof ExtensionImportFailedError ? error.originalError : error + const mappedError = await errorMapper(originalError) + const exitMode = shouldReportErrorAsUnexpected(mappedError) ? 'unexpected_error' : 'expected_error' + await reportAnalyticsEvent({ + config: this.config, + errorMessage: mappedError instanceof Error ? mappedError.message : undefined, + exitMode, + }) + await sendErrorToBugsnag(mappedError, exitMode) + throw new AbortSilentError() + } } return {app: appContext.app} } } + +function projectExtensions(extensions: ImportedDashboardExtension[]): ImportDashboardExtensionsResult['extensions'] { + return extensions.map(({extension, directory, configurationPath, changed}) => ({ + id: extension.uuid, + name: extension.title, + type: extension.type, + directory, + configurationPath, + changed, + })) +} + +function projectImportError(error: unknown): ImportDashboardExtensionsResult['errors'][number]['error'] { + const type = + error instanceof AbortError || (error instanceof Error && 'type' in error && error.type === FatalErrorType.Abort) + ? 'abort' + : 'bug' + return { + type, + message: error instanceof Error ? unstyled(error.message) : String(error), + ...(error instanceof Error && 'code' in error && typeof error.code === 'string' && error.code.length > 0 + ? {code: error.code} + : {}), + ...(error instanceof AbortError && error.details !== undefined ? {details: error.details} : {}), + } +} diff --git a/packages/app/src/cli/services/import-extensions.test.ts b/packages/app/src/cli/services/import-extensions.test.ts index bf048cb71be..ced4044cb98 100644 --- a/packages/app/src/cli/services/import-extensions.test.ts +++ b/packages/app/src/cli/services/import-extensions.test.ts @@ -1,10 +1,11 @@ -import {importExtensions, filterOutImportedExtensions} from './import-extensions.js' +import {importExtensions, importAllExtensions, filterOutImportedExtensions} from './import-extensions.js' +import {renderImportExtensionsResult} from './import-extensions/result.js' import {buildExtensionConfig} from './flow/extension-config-builder.js' import {testAppLinked, testDeveloperPlatformClient, testUIExtension} from '../models/app/app.test-data.js' import {OrganizationApp} from '../models/organization.js' import {ExtensionRegistration} from '../api/graphql/all_app_extension_registrations.js' import {describe, expect, test, vi, beforeEach} from 'vitest' -import {fileExistsSync, inTemporaryDirectory, mkdir} from '@shopify/cli-kit/node/fs' +import {fileExistsSync, inTemporaryDirectory, mkdir, readFile} from '@shopify/cli-kit/node/fs' import {renderSelectPrompt, renderSuccess} from '@shopify/cli-kit/node/ui' import {joinPath} from '@shopify/cli-kit/node/path' import {AbortSilentError} from '@shopify/cli-kit/node/error' @@ -97,7 +98,7 @@ describe('import-extensions', () => { await inTemporaryDirectory(async (tmpDir) => { const app = testAppLinked({directory: tmpDir}) - await importExtensions({ + const result = await importExtensions({ app, remoteApp: organizationApp, developerPlatformClient: testDeveloperPlatformClient(), @@ -112,6 +113,7 @@ describe('import-extensions', () => { buildExtensionConfig, }) + renderImportExtensionsResult(result.extensions) expect(renderSuccess).toHaveBeenCalledWith({ headline: ['Imported the following extensions from the dashboard:'], body: '• "titleA" at: extensions/title-a', @@ -160,7 +162,7 @@ describe('import-extensions', () => { // Skip existing directory .mockResolvedValueOnce('skip') - await importExtensions({ + const result = await importExtensions({ app, remoteApp: organizationApp, developerPlatformClient: testDeveloperPlatformClient(), @@ -169,7 +171,10 @@ describe('import-extensions', () => { buildExtensionConfig, }) + expect(result.extensionUuids).toEqual({titlea: 'uuidA'}) + // Then - expect the success message to be shown (even for skipped extensions) + renderImportExtensionsResult(result.extensions) expect(renderSuccess).toHaveBeenCalledWith({ headline: ['Imported the following extensions from the dashboard:'], body: '• "titleA" at: extensions/title-a', @@ -206,7 +211,7 @@ describe('import-extensions', () => { // Write/overwrite existing directory .mockResolvedValueOnce('write') - await importExtensions({ + const result = await importExtensions({ app, remoteApp: organizationApp, developerPlatformClient: testDeveloperPlatformClient(), @@ -215,7 +220,10 @@ describe('import-extensions', () => { buildExtensionConfig, }) + expect(result.extensionUuids).toEqual({titlea: 'uuidA'}) + // Then - expect the success message to be shown + renderImportExtensionsResult(result.extensions) expect(renderSuccess).toHaveBeenCalledWith({ headline: ['Imported the following extensions from the dashboard:'], body: '• "titleA" at: extensions/title-a', @@ -287,7 +295,7 @@ describe('import-extensions', () => { await inTemporaryDirectory(async (tmpDir) => { const app = testAppLinked({directory: tmpDir}) - await importExtensions({ + const result = await importExtensions({ app, remoteApp: organizationApp, developerPlatformClient: testDeveloperPlatformClient(), @@ -302,6 +310,7 @@ describe('import-extensions', () => { buildExtensionConfig, }) + renderImportExtensionsResult(result.extensions) expect(renderSuccess).toHaveBeenCalledWith({ headline: ['Imported the following extensions from the dashboard:'], body: '• "titleA" at: extensions/title-a\n• "titleB" at: extensions/title-b\n• "titleC" at: extensions/title-c\n• "titleD" at: extensions/title-d\n• "titleE" at: extensions/title-e', @@ -400,3 +409,43 @@ describe('filterOutImportedExtensions', () => { expect(result).toEqual([marketingActivityExtension]) }) }) + +test('the import-all caller retains text presentation and persists identifiers', async () => { + await inTemporaryDirectory(async (directory) => { + const app = testAppLinked({directory, configPath: joinPath(directory, 'shopify.app.toml')}) + await importAllExtensions({ + app, + remoteApp: organizationApp, + developerPlatformClient: testDeveloperPlatformClient(), + extensions: [flowExtensionA, flowExtensionB], + }) + expect(renderSelectPrompt).not.toHaveBeenCalled() + expect(renderSuccess).toHaveBeenCalledWith({ + headline: ['Imported the following extensions from the dashboard:'], + body: '• "titleA" at: extensions/title-a\n• "titleB" at: extensions/title-b', + }) + await expect(readFile(joinPath(directory, '.env'))).resolves.toContain('SHOPIFY_TITLEA_ID=uuidA') + await expect(readFile(joinPath(directory, '.env'))).resolves.toContain('SHOPIFY_TITLEB_ID=uuidB') + }) +}) + +test('the import-all caller still presents completed files before an identifier write fails', async () => { + await inTemporaryDirectory(async (directory) => { + const dotenvPath = joinPath(directory, 'environment-directory') + await mkdir(dotenvPath) + const app = testAppLinked({directory, dotenv: {path: dotenvPath, variables: {}}}) + await expect( + importAllExtensions({ + app, + remoteApp: organizationApp, + developerPlatformClient: testDeveloperPlatformClient(), + extensions: [flowExtensionA], + }), + ).rejects.toThrow() + expect(renderSuccess).toHaveBeenCalledWith({ + headline: ['Imported the following extensions from the dashboard:'], + body: '• "titleA" at: extensions/title-a', + }) + expect(fileExistsSync(joinPath(directory, 'extensions', 'title-a', 'shopify.extension.toml'))).toBe(true) + }) +}) diff --git a/packages/app/src/cli/services/import-extensions.ts b/packages/app/src/cli/services/import-extensions.ts index 67ca900befa..57e39aa0444 100644 --- a/packages/app/src/cli/services/import-extensions.ts +++ b/packages/app/src/cli/services/import-extensions.ts @@ -1,3 +1,9 @@ +import { + ExtensionImportCompletion, + ImportedDashboardExtension, + ImportExtensionsResult, +} from './import-extensions/types.js' +import {renderImportExtensionsResult} from './import-extensions/result.js' import {AppLinkedInterface, CurrentAppConfiguration} from '../models/app/app.js' import {updateAppIdentifiers, ExtensionUuidsByLocalIdentifier} from '../models/app/identifiers.js' import {ExtensionRegistration} from '../api/graphql/all_app_extension_registrations.js' @@ -6,12 +12,11 @@ import {MAX_EXTENSION_HANDLE_LENGTH} from '../models/extensions/schemas.js' import {OrganizationApp} from '../models/organization.js' import {allMigrationChoices, getMigrationChoices} from '../prompts/import-extensions.js' import {configurationFileNames, blocks} from '../constants.js' -import {renderSelectPrompt, renderSuccess} from '@shopify/cli-kit/node/ui' -import {basename, joinPath} from '@shopify/cli-kit/node/path' +import {renderSelectPrompt} from '@shopify/cli-kit/node/ui' +import {joinPath} from '@shopify/cli-kit/node/path' import {removeFile, fileExists, mkdir, touchFile} from '@shopify/cli-kit/node/fs' import {TomlFile} from '@shopify/cli-kit/node/toml/toml-file' import {JsonMapType} from '@shopify/cli-kit/node/toml' -import {outputContent} from '@shopify/cli-kit/node/output' import {slugify, hyphenate} from '@shopify/cli-kit/common/string' import {AbortError, AbortSilentError} from '@shopify/cli-kit/node/error' @@ -65,10 +70,6 @@ async function handleExtensionDirectory({ choices, }) - if (action === DirectoryAction.Cancel) { - throw new AbortSilentError() - } - return {directory: extensionDirectory, action} } @@ -78,8 +79,50 @@ async function handleExtensionDirectory({ return {directory: extensionDirectory, action: DirectoryAction.Write} } -export async function importExtensions(options: ImportOptions) { - const {app, remoteApp, extensionTypes, extensions, buildExtensionConfig, all} = options +export class ExtensionImportCancelledError extends AbortSilentError { + constructor( + private readonly pendingImports: Promise[], + private readonly selectedExtensions: ExtensionRegistration[], + ) { + super() + } + + completedImports(): Promise { + return completeStartedImports(this.pendingImports, this.selectedExtensions) + } +} + +export class ExtensionImportFailedError extends Error { + constructor( + readonly originalError: unknown, + private readonly pendingImports: Promise[], + private readonly selectedExtensions: ExtensionRegistration[], + ) { + super(originalError instanceof Error ? originalError.message : 'Dashboard extension import failed') + } + + completedImports(): Promise { + return completeStartedImports(this.pendingImports, this.selectedExtensions) + } +} + +async function completeStartedImports( + pendingImports: Promise[], + selectedExtensions: ExtensionRegistration[], +): Promise { + const results = await Promise.allSettled(pendingImports) + return { + extensions: results.flatMap((result) => (result.status === 'fulfilled' ? [result.value] : [])), + failures: results.flatMap((result, index) => + result.status === 'rejected' && !(result.reason instanceof ExtensionImportCancelledError) + ? [{extension: selectedExtensions[index]!, error: result.reason as unknown}] + : [], + ), + } +} + +export async function importExtensions(options: ImportOptions): Promise { + const {app, extensionTypes, extensions, buildExtensionConfig, all} = options let extensionsToMigrate = extensions.filter((ext) => extensionTypes.includes(ext.type.toLowerCase())) extensionsToMigrate = filterOutImportedExtensions(app, extensionsToMigrate) @@ -104,32 +147,40 @@ export async function importExtensions(options: ImportOptions) { } const extensionUuids: ExtensionUuidsByLocalIdentifier = {} - const importPromises = extensionsToMigrate.map(async (ext) => { + const importPromises: Promise[] = extensionsToMigrate.map(async (ext) => { const {directory, action} = await handleExtensionDirectory({app, name: ext.title}) + if (action === DirectoryAction.Cancel) { + throw new ExtensionImportCancelledError(importPromises, extensionsToMigrate) + } + const handle = slugify(ext.title.substring(0, MAX_EXTENSION_HANDLE_LENGTH)) extensionUuids[handle] = ext.uuid + const tomlPath = joinPath(directory, 'shopify.extension.toml') if (action === DirectoryAction.Write) { const tomlContent = buildExtensionConfig(ext, extensions, app.configuration) - const tomlPath = joinPath(directory, 'shopify.extension.toml') const file = new TomlFile(tomlPath, tomlContent as JsonMapType) await file.replace(tomlContent as JsonMapType) const lockFilePath = joinPath(directory, configurationFileNames.lockFile) await removeFile(lockFilePath) } - return {extension: ext, directory: joinPath('extensions', basename(directory))} + return { + extension: ext, + directory, + configurationPath: action === DirectoryAction.Write || (await fileExists(tomlPath)) ? tomlPath : null, + changed: action === DirectoryAction.Write, + } }) - const generatedExtensions = await Promise.all(importPromises) - renderSuccessMessages(generatedExtensions) - await updateAppIdentifiers({ - app, - appApiKey: remoteApp.apiKey, - extensionUuids, - command: 'import-extensions', - }) + try { + const generatedExtensions = await Promise.all(importPromises) + return {extensions: generatedExtensions, extensionUuids} + } catch (error) { + if (error instanceof ExtensionImportCancelledError) throw error + throw new ExtensionImportFailedError(error, importPromises, extensionsToMigrate) + } } // import-extensions updates the .env file with the new UUIDs. we can use that to know if an extension was already imported. @@ -144,23 +195,21 @@ export async function importAllExtensions(options: ImportAllOptions) { const migrationChoices = getMigrationChoices(options.extensions) await Promise.all( migrationChoices.map(async (choice) => { - return importExtensions({ + const result = await importExtensions({ ...options, extensionTypes: choice.extensionTypes, buildExtensionConfig: choice.buildExtensionConfig, all: true, + }).catch((error: unknown) => { + throw error instanceof ExtensionImportFailedError ? error.originalError : error + }) + renderImportExtensionsResult(result.extensions) + await updateAppIdentifiers({ + app: options.app, + appApiKey: options.remoteApp.apiKey, + extensionUuids: result.extensionUuids, + command: 'import-extensions', }) }), ) } - -function renderSuccessMessages(generatedExtensions: {extension: ExtensionRegistration; directory: string}[]) { - renderSuccess({ - headline: ['Imported the following extensions from the dashboard:'], - body: generatedExtensions - .map((gen) => { - return outputContent`• "${gen.extension.title}" at: ${gen.directory}`.value - }) - .join('\n'), - }) -} diff --git a/packages/app/src/cli/services/import-extensions/result.ts b/packages/app/src/cli/services/import-extensions/result.ts new file mode 100644 index 00000000000..78e85d884f9 --- /dev/null +++ b/packages/app/src/cli/services/import-extensions/result.ts @@ -0,0 +1,15 @@ +import {ImportedDashboardExtension} from './types.js' +import {renderSuccess} from '@shopify/cli-kit/node/ui' +import {outputContent} from '@shopify/cli-kit/node/output' +import {basename, joinPath} from '@shopify/cli-kit/node/path' + +export function renderImportExtensionsResult(extensions: ImportedDashboardExtension[]) { + renderSuccess({ + headline: ['Imported the following extensions from the dashboard:'], + body: extensions + .map(({extension, directory}) => { + return outputContent`• "${extension.title}" at: ${joinPath('extensions', basename(directory))}`.value + }) + .join('\n'), + }) +} diff --git a/packages/app/src/cli/services/import-extensions/types.test.ts b/packages/app/src/cli/services/import-extensions/types.test.ts new file mode 100644 index 00000000000..f3a0eeb4ee8 --- /dev/null +++ b/packages/app/src/cli/services/import-extensions/types.test.ts @@ -0,0 +1,93 @@ +import {importDashboardExtensionsJsonOutputSchema, ImportDashboardExtensionsResult} from './types.js' +import {expect, test} from 'vitest' +import {joinPath} from '@shopify/cli-kit/node/path' +import {tmpdir} from 'node:os' + +const extension = { + id: 'e7282f2d-3017-4608-a9a4-54dd5a7a70aa', + name: 'Example action', + type: 'flow_action_definition', + directory: joinPath(tmpdir(), 'app', 'extensions', 'example-action'), + configurationPath: joinPath(tmpdir(), 'app', 'extensions', 'example-action', 'shopify.extension.toml'), + changed: true, +} +const result: ImportDashboardExtensionsResult = { + status: 'success', + reason: null, + extensions: [extension], + errors: [], + identifiersUpdated: true, +} + +test('encodes completed imports, known empty results, and kept directories without a TOML file', () => { + expect(JSON.parse(importDashboardExtensionsJsonOutputSchema.encode(result))).toEqual(result) + const skipped: ImportDashboardExtensionsResult = { + status: 'skipped', + reason: 'no-extensions', + extensions: [], + errors: [], + identifiersUpdated: false, + } + expect(JSON.parse(importDashboardExtensionsJsonOutputSchema.encode(skipped))).toEqual(skipped) + const cancelled: ImportDashboardExtensionsResult = { + status: 'cancelled', + reason: 'directory-selection-cancelled', + extensions: [{...extension, changed: false, configurationPath: null}], + errors: [], + identifiersUpdated: false, + } + expect(JSON.parse(importDashboardExtensionsJsonOutputSchema.encode(cancelled))).toEqual(cancelled) +}) + +test.each([ + {field: 'id', value: 'gid://shopify/AppExtension/1'}, + {field: 'directory', value: 'extensions/example-action'}, + {field: 'configurationPath', value: 'shopify.extension.toml'}, + {field: 'type', value: ''}, + {field: 'changed', value: null}, +])('rejects invalid extension $field', ({field, value}) => { + expect(() => + importDashboardExtensionsJsonOutputSchema.validate({...result, extensions: [{...extension, [field]: value}]}), + ).toThrow() +}) + +test('rejects unknown status and accidental internal fields', () => { + expect(() => importDashboardExtensionsJsonOutputSchema.validate({...result, status: 'failed'})).toThrow() + expect(() => + importDashboardExtensionsJsonOutputSchema.validate({...result, extensionUuids: {handle: extension.id}}), + ).toThrow() + expect(() => + importDashboardExtensionsJsonOutputSchema.validate({ + ...result, + extensions: [{...extension, activeVersion: {config: 'secret'}}], + }), + ).toThrow() +}) + +test('encodes partial results with structured errors and rejects invalid failure records', () => { + const partial: ImportDashboardExtensionsResult = { + ...result, + status: 'partial', + identifiersUpdated: false, + errors: [{extensionId: null, error: {type: 'abort', message: 'Identifiers could not be saved', code: 'EACCES'}}], + } + expect(JSON.parse(importDashboardExtensionsJsonOutputSchema.encode(partial))).toEqual(partial) + expect(() => + importDashboardExtensionsJsonOutputSchema.validate({ + ...partial, + errors: [{extensionId: 'bad-id', error: {type: 'abort', message: 'Import failed'}}], + }), + ).toThrow() + expect(() => + importDashboardExtensionsJsonOutputSchema.validate({ + ...partial, + errors: [{extensionId: extension.id, error: {type: 'unknown', message: 'Import failed'}}], + }), + ).toThrow() + expect(() => + importDashboardExtensionsJsonOutputSchema.validate({ + ...partial, + errors: [{extensionId: extension.id, error: {type: 'abort', message: 'Import failed', code: ''}}], + }), + ).toThrow() +}) diff --git a/packages/app/src/cli/services/import-extensions/types.ts b/packages/app/src/cli/services/import-extensions/types.ts new file mode 100644 index 00000000000..9953bfe9f4c --- /dev/null +++ b/packages/app/src/cli/services/import-extensions/types.ts @@ -0,0 +1,68 @@ +import {ExtensionRegistration} from '../../api/graphql/all_app_extension_registrations.js' +import {ExtensionUuidsByLocalIdentifier} from '../../models/app/identifiers.js' +import {defineJsonOutputSchema, type InferJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema' +import {isAbsolutePath} from '@shopify/cli-kit/node/path' +import {JsonErrorSchema, jsonErrorOutputSchema} from '@shopify/cli-kit/node/error/schema' +import {zod} from '@shopify/cli-kit/node/schema' + +const absolutePathSchema = zod.string().refine(isAbsolutePath, 'Expected an absolute native filesystem path') +const importedExtensionSchema = zod + .object({ + id: zod.string().uuid().describe('The dashboard extension registration UUID, not a Shopify GID.'), + name: zod.string(), + type: zod.string().min(1).describe('The upstream dashboard extension type.'), + directory: absolutePathSchema.describe('The absolute local extension directory.'), + configurationPath: absolutePathSchema + .nullable() + .describe('The absolute local TOML path, or null when no TOML file exists in a kept directory.'), + changed: zod.boolean().describe('Whether this import wrote the local extension TOML.'), + }) + .strict() + +const importFailureSchema = zod + .object({ + extensionId: zod.string().uuid().nullable().describe('The registration UUID, or null for identifier persistence.'), + error: JsonErrorSchema, + }) + .strict() + +export const importDashboardExtensionsJsonOutputSchema = defineJsonOutputSchema({ + name: 'ImportDashboardExtensionsResult', + schema: zod + .object({ + status: zod.enum(['success', 'partial', 'skipped', 'cancelled']), + reason: zod.enum(['no-extensions', 'directory-selection-cancelled']).nullable(), + extensions: zod + .array(importedExtensionSchema) + .describe('Completed extension imports and kept local directories, in selection order.'), + errors: zod.array(importFailureSchema).describe('Failed selected imports or identifier persistence.'), + identifiersUpdated: zod + .boolean() + .describe('Whether extension identifiers were persisted to the app environment file.'), + }) + .strict(), + definitions: { + ImportedDashboardExtension: importedExtensionSchema, + ExtensionImportFailure: importFailureSchema, + ...jsonErrorOutputSchema.definitions, + }, +}) + +export type ImportDashboardExtensionsResult = InferJsonOutputSchema + +export interface ImportedDashboardExtension { + extension: ExtensionRegistration + directory: string + configurationPath: string | null + changed: boolean +} + +export interface ImportExtensionsResult { + extensions: ImportedDashboardExtension[] + extensionUuids: ExtensionUuidsByLocalIdentifier +} + +export interface ExtensionImportCompletion { + extensions: ImportedDashboardExtension[] + failures: {extension: ExtensionRegistration; error: unknown}[] +} diff --git a/packages/cli/README.md b/packages/cli/README.md index 95b19a8e8b7..60f9386b123 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -2216,14 +2216,18 @@ Import dashboard-managed extensions into your app. ``` USAGE - $ shopify app import dashboard-extensions [--auth-alias ] [--client-id | -c ] [--json-schema] [--no-color] - [--no-input] [--path ] [--reset | ] [--verbose] + $ shopify app import dashboard-extensions [--auth-alias ] [--client-id | -c ] [-j] [--json-schema] + [--no-color] [--no-input] [--path ] [--reset | ] [--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] @@ -2258,6 +2262,301 @@ FLAGS DESCRIPTION Import dashboard-managed extensions into your app. + + Use `--json-schema` to print the result, error, and event schemas. + + Output from `--json` conforms to the `ImportDashboardExtensionsResult` schema. + + ```json + { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "success", + "partial", + "skipped", + "cancelled" + ] + }, + "reason": { + "anyOf": [ + { + "type": "string", + "enum": [ + "no-extensions", + "directory-selection-cancelled" + ] + }, + { + "type": "null" + } + ] + }, + "extensions": { + "type": "array", + "items": { + "$ref": "#/definitions/ImportedDashboardExtension" + }, + "description": "Completed extension imports and kept local directories, in selection order." + }, + "errors": { + "type": "array", + "items": { + "$ref": "#/definitions/ExtensionImportFailure" + }, + "description": "Failed selected imports or identifier persistence." + }, + "identifiersUpdated": { + "type": "boolean", + "description": "Whether extension identifiers were persisted to the app environment file." + } + }, + "required": [ + "status", + "reason", + "extensions", + "errors", + "identifiersUpdated" + ], + "additionalProperties": false, + "title": "ImportDashboardExtensionsResult", + "definitions": { + "ImportedDashboardExtension": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "description": "The dashboard extension registration UUID, not a Shopify GID." + }, + "name": { + "type": "string" + }, + "type": { + "type": "string", + "minLength": 1, + "description": "The upstream dashboard extension type." + }, + "directory": { + "type": "string", + "description": "The absolute local extension directory." + }, + "configurationPath": { + "anyOf": [ + { + "$ref": "#/definitions/ImportedDashboardExtension/properties/directory" + }, + { + "type": "null" + } + ], + "description": "The absolute local TOML path, or null when no TOML file exists in a kept directory." + }, + "changed": { + "type": "boolean", + "description": "Whether this import wrote the local extension TOML." + } + }, + "required": [ + "id", + "name", + "type", + "directory", + "configurationPath", + "changed" + ], + "additionalProperties": false + }, + "ExtensionImportFailure": { + "type": "object", + "properties": { + "extensionId": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ], + "description": "The registration UUID, or null for identifier persistence." + }, + "error": { + "$ref": "#/definitions/JsonError" + } + }, + "required": [ + "extensionId", + "error" + ], + "additionalProperties": false + }, + "JsonError": { + "anyOf": [ + { + "$ref": "#/definitions/JsonAbortError" + }, + { + "$ref": "#/definitions/JsonBugError" + }, + { + "$ref": "#/definitions/JsonExternalError" + } + ] + }, + "JsonErrorCustomSection": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "body": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "string" + } + } + } + ] + } + }, + "required": [ + "body" + ], + "additionalProperties": false + }, + "JsonAbortError": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "abort" + }, + "message": { + "type": "string" + }, + "code": { + "type": "string", + "minLength": 1, + "description": "A stable error code, included only when known." + }, + "tryMessage": { + "type": "string" + }, + "nextSteps": { + "type": "array", + "items": { + "type": "string" + } + }, + "customSections": { + "type": "array", + "items": { + "$ref": "#/definitions/JsonErrorCustomSection" + } + }, + "details": { + "description": "Selected domain details, preserving native API payloads such as GraphQL errors, extensions, and data." + } + }, + "required": [ + "type", + "message" + ], + "additionalProperties": false + }, + "JsonBugError": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "bug" + }, + "message": { + "$ref": "#/definitions/JsonAbortError/properties/message" + }, + "code": { + "$ref": "#/definitions/JsonAbortError/properties/code" + }, + "tryMessage": { + "$ref": "#/definitions/JsonAbortError/properties/tryMessage" + }, + "nextSteps": { + "$ref": "#/definitions/JsonAbortError/properties/nextSteps" + }, + "customSections": { + "$ref": "#/definitions/JsonAbortError/properties/customSections" + }, + "details": { + "$ref": "#/definitions/JsonAbortError/properties/details" + }, + "stack": { + "type": "string" + } + }, + "required": [ + "type", + "message" + ], + "additionalProperties": false + }, + "JsonExternalError": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "external" + }, + "message": { + "$ref": "#/definitions/JsonAbortError/properties/message" + }, + "code": { + "$ref": "#/definitions/JsonAbortError/properties/code" + }, + "tryMessage": { + "$ref": "#/definitions/JsonAbortError/properties/tryMessage" + }, + "nextSteps": { + "$ref": "#/definitions/JsonAbortError/properties/nextSteps" + }, + "customSections": { + "$ref": "#/definitions/JsonAbortError/properties/customSections" + }, + "details": { + "$ref": "#/definitions/JsonAbortError/properties/details" + }, + "command": { + "type": "string" + }, + "args": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "type", + "message", + "command", + "args" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" + } + ``` ``` ## `shopify app info` diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 31e82b9ad49..5574310f5a4 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -2947,7 +2947,8 @@ "args": { }, "customPluginName": "@shopify/app", - "description": "Import dashboard-managed extensions into your app.", + "description": "Import dashboard-managed extensions into your app.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `ImportDashboardExtensionsResult` schema.\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"enum\": [\n \"success\",\n \"partial\",\n \"skipped\",\n \"cancelled\"\n ]\n },\n \"reason\": {\n \"anyOf\": [\n {\n \"type\": \"string\",\n \"enum\": [\n \"no-extensions\",\n \"directory-selection-cancelled\"\n ]\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"extensions\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/ImportedDashboardExtension\"\n },\n \"description\": \"Completed extension imports and kept local directories, in selection order.\"\n },\n \"errors\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/ExtensionImportFailure\"\n },\n \"description\": \"Failed selected imports or identifier persistence.\"\n },\n \"identifiersUpdated\": {\n \"type\": \"boolean\",\n \"description\": \"Whether extension identifiers were persisted to the app environment file.\"\n }\n },\n \"required\": [\n \"status\",\n \"reason\",\n \"extensions\",\n \"errors\",\n \"identifiersUpdated\"\n ],\n \"additionalProperties\": false,\n \"title\": \"ImportDashboardExtensionsResult\",\n \"definitions\": {\n \"ImportedDashboardExtension\": {\n \"type\": \"object\",\n \"properties\": {\n \"id\": {\n \"type\": \"string\",\n \"format\": \"uuid\",\n \"description\": \"The dashboard extension registration UUID, not a Shopify GID.\"\n },\n \"name\": {\n \"type\": \"string\"\n },\n \"type\": {\n \"type\": \"string\",\n \"minLength\": 1,\n \"description\": \"The upstream dashboard extension type.\"\n },\n \"directory\": {\n \"type\": \"string\",\n \"description\": \"The absolute local extension directory.\"\n },\n \"configurationPath\": {\n \"anyOf\": [\n {\n \"$ref\": \"#/definitions/ImportedDashboardExtension/properties/directory\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"description\": \"The absolute local TOML path, or null when no TOML file exists in a kept directory.\"\n },\n \"changed\": {\n \"type\": \"boolean\",\n \"description\": \"Whether this import wrote the local extension TOML.\"\n }\n },\n \"required\": [\n \"id\",\n \"name\",\n \"type\",\n \"directory\",\n \"configurationPath\",\n \"changed\"\n ],\n \"additionalProperties\": false\n },\n \"ExtensionImportFailure\": {\n \"type\": \"object\",\n \"properties\": {\n \"extensionId\": {\n \"anyOf\": [\n {\n \"type\": \"string\",\n \"format\": \"uuid\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"description\": \"The registration UUID, or null for identifier persistence.\"\n },\n \"error\": {\n \"$ref\": \"#/definitions/JsonError\"\n }\n },\n \"required\": [\n \"extensionId\",\n \"error\"\n ],\n \"additionalProperties\": false\n },\n \"JsonError\": {\n \"anyOf\": [\n {\n \"$ref\": \"#/definitions/JsonAbortError\"\n },\n {\n \"$ref\": \"#/definitions/JsonBugError\"\n },\n {\n \"$ref\": \"#/definitions/JsonExternalError\"\n }\n ]\n },\n \"JsonErrorCustomSection\": {\n \"type\": \"object\",\n \"properties\": {\n \"title\": {\n \"type\": \"string\"\n },\n \"body\": {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n }\n ]\n }\n },\n \"required\": [\n \"body\"\n ],\n \"additionalProperties\": false\n },\n \"JsonAbortError\": {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"abort\"\n },\n \"message\": {\n \"type\": \"string\"\n },\n \"code\": {\n \"type\": \"string\",\n \"minLength\": 1,\n \"description\": \"A stable error code, included only when known.\"\n },\n \"tryMessage\": {\n \"type\": \"string\"\n },\n \"nextSteps\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n },\n \"customSections\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/JsonErrorCustomSection\"\n }\n },\n \"details\": {\n \"description\": \"Selected domain details, preserving native API payloads such as GraphQL errors, extensions, and data.\"\n }\n },\n \"required\": [\n \"type\",\n \"message\"\n ],\n \"additionalProperties\": false\n },\n \"JsonBugError\": {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"bug\"\n },\n \"message\": {\n \"$ref\": \"#/definitions/JsonAbortError/properties/message\"\n },\n \"code\": {\n \"$ref\": \"#/definitions/JsonAbortError/properties/code\"\n },\n \"tryMessage\": {\n \"$ref\": \"#/definitions/JsonAbortError/properties/tryMessage\"\n },\n \"nextSteps\": {\n \"$ref\": \"#/definitions/JsonAbortError/properties/nextSteps\"\n },\n \"customSections\": {\n \"$ref\": \"#/definitions/JsonAbortError/properties/customSections\"\n },\n \"details\": {\n \"$ref\": \"#/definitions/JsonAbortError/properties/details\"\n },\n \"stack\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"type\",\n \"message\"\n ],\n \"additionalProperties\": false\n },\n \"JsonExternalError\": {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"external\"\n },\n \"message\": {\n \"$ref\": \"#/definitions/JsonAbortError/properties/message\"\n },\n \"code\": {\n \"$ref\": \"#/definitions/JsonAbortError/properties/code\"\n },\n \"tryMessage\": {\n \"$ref\": \"#/definitions/JsonAbortError/properties/tryMessage\"\n },\n \"nextSteps\": {\n \"$ref\": \"#/definitions/JsonAbortError/properties/nextSteps\"\n },\n \"customSections\": {\n \"$ref\": \"#/definitions/JsonAbortError/properties/customSections\"\n },\n \"details\": {\n \"$ref\": \"#/definitions/JsonAbortError/properties/details\"\n },\n \"command\": {\n \"type\": \"string\"\n },\n \"args\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n },\n \"required\": [\n \"type\",\n \"message\",\n \"command\",\n \"args\"\n ],\n \"additionalProperties\": false\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", + "descriptionWithMarkdown": "Import dashboard-managed extensions into your app.", "flags": { "auth-alias": { "description": "Alias of the Shopify account to use for authentication.", @@ -2979,6 +2980,15 @@ "name": "config", "type": "option" }, + "json": { + "allowNo": false, + "char": "j", + "description": "Output the result as JSON. Automatically disables color output.", + "env": "SHOPIFY_FLAG_JSON", + "hidden": false, + "name": "json", + "type": "boolean" + }, "json-schema": { "allowNo": false, "description": "Print the command's JSON schemas.", @@ -3278,7 +3288,8 @@ "args": { }, "customPluginName": "@shopify/app", - "description": "Import dashboard-managed extensions into your app.", + "description": "Import dashboard-managed extensions into your app.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `ImportDashboardExtensionsResult` schema.\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"enum\": [\n \"success\",\n \"partial\",\n \"skipped\",\n \"cancelled\"\n ]\n },\n \"reason\": {\n \"anyOf\": [\n {\n \"type\": \"string\",\n \"enum\": [\n \"no-extensions\",\n \"directory-selection-cancelled\"\n ]\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"extensions\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/ImportedDashboardExtension\"\n },\n \"description\": \"Completed extension imports and kept local directories, in selection order.\"\n },\n \"errors\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/ExtensionImportFailure\"\n },\n \"description\": \"Failed selected imports or identifier persistence.\"\n },\n \"identifiersUpdated\": {\n \"type\": \"boolean\",\n \"description\": \"Whether extension identifiers were persisted to the app environment file.\"\n }\n },\n \"required\": [\n \"status\",\n \"reason\",\n \"extensions\",\n \"errors\",\n \"identifiersUpdated\"\n ],\n \"additionalProperties\": false,\n \"title\": \"ImportDashboardExtensionsResult\",\n \"definitions\": {\n \"ImportedDashboardExtension\": {\n \"type\": \"object\",\n \"properties\": {\n \"id\": {\n \"type\": \"string\",\n \"format\": \"uuid\",\n \"description\": \"The dashboard extension registration UUID, not a Shopify GID.\"\n },\n \"name\": {\n \"type\": \"string\"\n },\n \"type\": {\n \"type\": \"string\",\n \"minLength\": 1,\n \"description\": \"The upstream dashboard extension type.\"\n },\n \"directory\": {\n \"type\": \"string\",\n \"description\": \"The absolute local extension directory.\"\n },\n \"configurationPath\": {\n \"anyOf\": [\n {\n \"$ref\": \"#/definitions/ImportedDashboardExtension/properties/directory\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"description\": \"The absolute local TOML path, or null when no TOML file exists in a kept directory.\"\n },\n \"changed\": {\n \"type\": \"boolean\",\n \"description\": \"Whether this import wrote the local extension TOML.\"\n }\n },\n \"required\": [\n \"id\",\n \"name\",\n \"type\",\n \"directory\",\n \"configurationPath\",\n \"changed\"\n ],\n \"additionalProperties\": false\n },\n \"ExtensionImportFailure\": {\n \"type\": \"object\",\n \"properties\": {\n \"extensionId\": {\n \"anyOf\": [\n {\n \"type\": \"string\",\n \"format\": \"uuid\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"description\": \"The registration UUID, or null for identifier persistence.\"\n },\n \"error\": {\n \"$ref\": \"#/definitions/JsonError\"\n }\n },\n \"required\": [\n \"extensionId\",\n \"error\"\n ],\n \"additionalProperties\": false\n },\n \"JsonError\": {\n \"anyOf\": [\n {\n \"$ref\": \"#/definitions/JsonAbortError\"\n },\n {\n \"$ref\": \"#/definitions/JsonBugError\"\n },\n {\n \"$ref\": \"#/definitions/JsonExternalError\"\n }\n ]\n },\n \"JsonErrorCustomSection\": {\n \"type\": \"object\",\n \"properties\": {\n \"title\": {\n \"type\": \"string\"\n },\n \"body\": {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n }\n ]\n }\n },\n \"required\": [\n \"body\"\n ],\n \"additionalProperties\": false\n },\n \"JsonAbortError\": {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"abort\"\n },\n \"message\": {\n \"type\": \"string\"\n },\n \"code\": {\n \"type\": \"string\",\n \"minLength\": 1,\n \"description\": \"A stable error code, included only when known.\"\n },\n \"tryMessage\": {\n \"type\": \"string\"\n },\n \"nextSteps\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n },\n \"customSections\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/JsonErrorCustomSection\"\n }\n },\n \"details\": {\n \"description\": \"Selected domain details, preserving native API payloads such as GraphQL errors, extensions, and data.\"\n }\n },\n \"required\": [\n \"type\",\n \"message\"\n ],\n \"additionalProperties\": false\n },\n \"JsonBugError\": {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"bug\"\n },\n \"message\": {\n \"$ref\": \"#/definitions/JsonAbortError/properties/message\"\n },\n \"code\": {\n \"$ref\": \"#/definitions/JsonAbortError/properties/code\"\n },\n \"tryMessage\": {\n \"$ref\": \"#/definitions/JsonAbortError/properties/tryMessage\"\n },\n \"nextSteps\": {\n \"$ref\": \"#/definitions/JsonAbortError/properties/nextSteps\"\n },\n \"customSections\": {\n \"$ref\": \"#/definitions/JsonAbortError/properties/customSections\"\n },\n \"details\": {\n \"$ref\": \"#/definitions/JsonAbortError/properties/details\"\n },\n \"stack\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"type\",\n \"message\"\n ],\n \"additionalProperties\": false\n },\n \"JsonExternalError\": {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"external\"\n },\n \"message\": {\n \"$ref\": \"#/definitions/JsonAbortError/properties/message\"\n },\n \"code\": {\n \"$ref\": \"#/definitions/JsonAbortError/properties/code\"\n },\n \"tryMessage\": {\n \"$ref\": \"#/definitions/JsonAbortError/properties/tryMessage\"\n },\n \"nextSteps\": {\n \"$ref\": \"#/definitions/JsonAbortError/properties/nextSteps\"\n },\n \"customSections\": {\n \"$ref\": \"#/definitions/JsonAbortError/properties/customSections\"\n },\n \"details\": {\n \"$ref\": \"#/definitions/JsonAbortError/properties/details\"\n },\n \"command\": {\n \"type\": \"string\"\n },\n \"args\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n },\n \"required\": [\n \"type\",\n \"message\",\n \"command\",\n \"args\"\n ],\n \"additionalProperties\": false\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", + "descriptionWithMarkdown": "Import dashboard-managed extensions into your app.", "flags": { "auth-alias": { "description": "Alias of the Shopify account to use for authentication.", @@ -3310,6 +3321,15 @@ "name": "config", "type": "option" }, + "json": { + "allowNo": false, + "char": "j", + "description": "Output the result as JSON. Automatically disables color output.", + "env": "SHOPIFY_FLAG_JSON", + "hidden": false, + "name": "json", + "type": "boolean" + }, "json-schema": { "allowNo": false, "description": "Print the command's JSON schemas.", diff --git a/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js b/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js index 0fcd20c5ff1..ad9a2fa3dfb 100644 --- a/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js +++ b/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js @@ -20,7 +20,6 @@ const commandExceptions = [ 'packages/app/src/cli/commands/app/function/typegen.ts', 'packages/app/src/cli/commands/app/generate/extension.ts', 'packages/app/src/cli/commands/app/import/custom-data-definitions.ts', - 'packages/app/src/cli/commands/app/import/dashboard-extensions.ts', 'packages/app/src/cli/commands/app/init.ts', 'packages/app/src/cli/commands/app/release.ts', 'packages/app/src/cli/commands/app/subscription-migrations/cancel.ts', From d72c70b1499ee84e729f098ccd50bd242294229c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Wed, 7 Oct 2026 14:56:02 +0200 Subject: [PATCH 2/6] Focus dashboard import tests on changed JSON behavior --- .../app/import/dashboard-extensions.test.ts | 480 ++++-------------- .../cli/services/import-extensions.test.ts | 21 - .../services/import-extensions/types.test.ts | 93 ---- 3 files changed, 89 insertions(+), 505 deletions(-) delete mode 100644 packages/app/src/cli/services/import-extensions/types.test.ts diff --git a/packages/app/src/cli/commands/app/import/dashboard-extensions.test.ts b/packages/app/src/cli/commands/app/import/dashboard-extensions.test.ts index cc101efd6fa..21f8fd9917f 100644 --- a/packages/app/src/cli/commands/app/import/dashboard-extensions.test.ts +++ b/packages/app/src/cli/commands/app/import/dashboard-extensions.test.ts @@ -1,29 +1,27 @@ import ImportDashboardExtensions from './dashboard-extensions.js' -import ImportExtensionsDeprecated from '../import-extensions.js' import {linkedAppContext} from '../../../services/app-context.js' -import {ExtensionImportCancelledError, ExtensionImportFailedError} from '../../../services/import-extensions.js' import * as extensionImportService from '../../../services/import-extensions.js' import {importDashboardExtensionsJsonOutputSchema} from '../../../services/import-extensions/types.js' import {testAppLinked, testOrganizationApp, testDeveloperPlatformClient} from '../../../models/app/app.test-data.js' import {ExtensionRegistration} from '../../../api/graphql/all_app_extension_registrations.js' import {Config, Errors} from '@oclif/core' -import {afterEach, expect, test, vi} from 'vitest' -import {fileExists, inTemporaryDirectory, mkdir, readFile, writeFile} from '@shopify/cli-kit/node/fs' +import {expect, test, vi} from 'vitest' +import {inTemporaryDirectory, mkdir, readFile, writeFile} from '@shopify/cli-kit/node/fs' import {joinPath} from '@shopify/cli-kit/node/path' import {withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output' import {runWithCommandEventsForCommand} from '@shopify/cli-kit/node/command-events' -import {outputInfo, unstyled} from '@shopify/cli-kit/node/output' +import {outputInfo} from '@shopify/cli-kit/node/output' import {renderSelectPrompt} from '@shopify/cli-kit/node/ui' import {reportAnalyticsEvent} from '@shopify/cli-kit/node/analytics' -import {sendErrorToBugsnag} from '@shopify/cli-kit/node/error-handler' -import {AbortError, AbortSilentError, handler} from '@shopify/cli-kit/node/error' - -// eslint-disable-next-line n/prefer-global/console -import {Console} from 'node:console' +import {errorHandler, sendErrorToBugsnag} from '@shopify/cli-kit/node/error-handler' +import {AbortSilentError} from '@shopify/cli-kit/node/error' vi.mock('../../../services/app-context.js') vi.mock('@shopify/cli-kit/node/analytics') -vi.mock('@shopify/cli-kit/node/error-handler') +vi.mock('@shopify/cli-kit/node/error-handler', async (importOriginal) => ({ + ...(await importOriginal()), + sendErrorToBugsnag: vi.fn(), +})) vi.mock('@shopify/cli-kit/node/ui', async (importOriginal) => ({ ...(await importOriginal()), renderSelectPrompt: vi.fn(), @@ -36,16 +34,8 @@ const extension: ExtensionRegistration = { type: 'flow_action_definition', activeVersion: {config: '{"title":"Example action","description":"A description","url":"https://example.com/run"}'}, } -const expectedToml = - '[[extensions]]\ntype = "flow_action"\nname = "Example action"\nhandle = "example-action"\ndescription = "A description"\nruntime_url = "https://example.com/run"\n' - -afterEach(() => { - vi.unstubAllEnvs() - vi.unstubAllGlobals() -}) async function withApp(run: (app: ReturnType) => Promise, extensions = [extension]) { - vi.stubGlobal('console', {...globalThis.console, Console}) await inTemporaryDirectory(async (directory) => { const configPath = joinPath(directory, 'shopify.app.toml') await writeFile(configPath, 'name = "Test app"\nclient_id = "test-client-id"\n') @@ -66,438 +56,146 @@ async function withApp(run: (app: ReturnType) => Promise>) - vi.mocked(renderSelectPrompt).mockResolvedValue(extension.uuid) + vi.mocked(renderSelectPrompt).mockReset().mockResolvedValue(extension.uuid) await run(app) }) } -async function runCommand(directory: string, argv: string[], deprecated = false) { - const Command = deprecated ? ImportExtensionsDeprecated : ImportDashboardExtensions - const args = ['--path', directory, ...argv] - const command = new Command(args, await Config.load()) - return runWithCommandEventsForCommand(args, () => command.run()) -} - -async function handleSilentExit(outcome: unknown) { - expect(outcome).toBeInstanceOf(AbortSilentError) - vi.stubEnv('SHOPIFY_FLAG_JSON', '1') - await handler(outcome) - const exit = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as typeof process.exit) - try { - await Errors.handle(outcome as AbortSilentError) - expect(exit).toHaveBeenCalledWith(1) - } finally { - exit.mockRestore() - } -} - -function publicExtension(directory: string, changed = true, ext = extension) { - const extensionDirectory = joinPath( - directory, - 'extensions', - ext.title === extension.title ? 'example-action' : 'other-action', - ) +function publicExtension(directory: string) { + const extensionDirectory = joinPath(directory, 'extensions', 'example-action') return { - id: ext.uuid, - name: ext.title, - type: ext.type, + id: extension.uuid, + name: extension.title, + type: extension.type, directory: extensionDirectory, configurationPath: joinPath(extensionDirectory, 'shopify.extension.toml'), - changed, + changed: true, } } -test('writes one JSON result, diagnostics on stderr, native TOML, and extension identifiers', async () => { +async function runCommand(directory: string) { + const args = ['--path', directory, '--json'] + const command = new ImportDashboardExtensions(args, await Config.load()) + return runWithCommandEventsForCommand(args, () => command.run()) +} + +test('writes JSON after saving identifiers and sends diagnostics to stderr', async () => { await withApp(async (app) => { await withCapturedStandardStreams(async ({stdout, stderr}) => { - await expect(runCommand(app.directory, ['--json'])).resolves.toEqual({app}) - expect(JSON.parse(stdout())).toEqual({ + await runCommand(app.directory) + const result = JSON.parse(stdout()) + expect(result).toEqual({ status: 'success', reason: null, extensions: [publicExtension(app.directory)], errors: [], identifiersUpdated: true, }) - expect( - stderr() - .trim() - .split('\n') - .map((line) => JSON.parse(line)), - ).toEqual([expect.objectContaining({type: 'diagnostic', level: 'info', message: 'Loaded dashboard extensions'})]) - expect(stderr()).not.toContain('Imported the following') + expect(JSON.parse(stderr())).toMatchObject({type: 'diagnostic', message: 'Loaded dashboard extensions'}) + expect(() => importDashboardExtensionsJsonOutputSchema.encode({...result, internal: true})).toThrow() }) - await expect(readFile(publicExtension(app.directory).configurationPath)).resolves.toBe(expectedToml) await expect(readFile(joinPath(app.directory, '.env'))).resolves.toContain( `SHOPIFY_EXAMPLE_ACTION_ID=${extension.uuid}`, ) - expect(app.dotenv?.variables.SHOPIFY_EXAMPLE_ACTION_ID).toBe(extension.uuid) - await expect(fileExists(joinPath(app.directory, 'extensions', 'example-action', '.shopify.lock'))).resolves.toBe( - false, - ) - }) -}) - -test('keeps native local TOML unchanged and still persists identifiers when the picker selects Keep', async () => { - await withApp(async (app) => { - const {directory, configurationPath} = publicExtension(app.directory) - await mkdir(directory) - const localToml = '# Local café\r\n[[extensions]]\r\nname = "Local action"\r\n' - await writeFile(configurationPath, localToml) - vi.mocked(renderSelectPrompt).mockResolvedValueOnce(extension.uuid).mockResolvedValueOnce('skip') - await withCapturedStandardStreams(async ({stdout}) => { - await runCommand(app.directory, ['--json']) - expect(JSON.parse(stdout())).toEqual({ - status: 'success', - reason: null, - extensions: [publicExtension(app.directory, false)], - errors: [], - identifiersUpdated: true, - }) - }) - await expect(readFile(configurationPath)).resolves.toBe(localToml) - await expect(readFile(joinPath(app.directory, '.env'))).resolves.toContain( - `SHOPIFY_EXAMPLE_ACTION_ID=${extension.uuid}`, - ) - }) -}) - -test('does not report a TOML artifact for an empty kept directory', async () => { - await withApp(async (app) => { - await mkdir(publicExtension(app.directory).directory) - vi.mocked(renderSelectPrompt).mockResolvedValueOnce(extension.uuid).mockResolvedValueOnce('skip') - await withCapturedStandardStreams(async ({stdout}) => { - await runCommand(app.directory, ['--json']) - expect(JSON.parse(stdout())).toMatchObject({extensions: [{changed: false, configurationPath: null}]}) - }) }) }) -test('returns a skipped result without writing identifiers when no remote extensions exist', async () => { +test('writes a skipped JSON result when there are no extensions', async () => { await withApp(async (app) => { await withCapturedStandardStreams(async ({stdout}) => { - await runCommand(app.directory, ['--json']) - expect(JSON.parse(stdout())).toEqual({ - status: 'skipped', - reason: 'no-extensions', - extensions: [], - errors: [], - identifiersUpdated: false, - }) + await runCommand(app.directory) + expect(JSON.parse(stdout())).toMatchObject({status: 'skipped', extensions: [], identifiersUpdated: false}) }) - expect(renderSelectPrompt).not.toHaveBeenCalled() - await expect(fileExists(joinPath(app.directory, '.env'))).resolves.toBe(false) }, []) }) -test('keeps the success banner and relative extension path on stderr in text mode', async () => { +test('leaves a failed import on the fatal path without printing a result', async () => { await withApp(async (app) => { - await withCapturedStandardStreams(async ({stdout, stderr}) => { - await runCommand(app.directory, []) - expect(stdout()).toBe('') - expect(unstyled(stderr())).toContain('Imported the following extensions from the dashboard:') - expect(unstyled(stderr())).toContain('"Example action" at: extensions/example-action') - }) - await expect(readFile(publicExtension(app.directory).configurationPath)).resolves.toBe(expectedToml) - }) -}) - -test('returns one cancelled document and exit 1 for the existing-directory Cancel selection', async () => { - await withApp(async (app) => { - await mkdir(publicExtension(app.directory).directory) - vi.mocked(renderSelectPrompt).mockResolvedValueOnce(extension.uuid).mockResolvedValueOnce('cancel') + const record = publicExtension(app.directory) + await mkdir(record.directory) + await mkdir(record.configurationPath) + vi.mocked(renderSelectPrompt).mockResolvedValueOnce(extension.uuid).mockResolvedValueOnce('write') await withCapturedStandardStreams(async ({stdout}) => { - const error = await runCommand(app.directory, ['--json']).catch((failure: unknown) => failure) - await handleSilentExit(error) - expect(JSON.parse(stdout())).toEqual({ - status: 'cancelled', - reason: 'directory-selection-cancelled', - extensions: [], - errors: [], - identifiersUpdated: false, - }) - }) - await expect(fileExists(joinPath(app.directory, '.env'))).resolves.toBe(false) - }) -}) - -test('preserves AbortSilentError and the standard exit 1 in text mode', async () => { - await withApp(async (app) => { - await mkdir(publicExtension(app.directory).directory) - vi.mocked(renderSelectPrompt).mockResolvedValueOnce(extension.uuid).mockResolvedValueOnce('cancel') - await withCapturedStandardStreams(async ({stdout, stderr}) => { - const error = await runCommand(app.directory, []).catch((failure: AbortSilentError) => failure) - expect(error).toBeInstanceOf(AbortSilentError) - const exit = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as typeof process.exit) - await Errors.handle(error as AbortSilentError) - expect(exit).toHaveBeenCalledWith(1) - exit.mockRestore() + await expect(runCommand(app.directory)).rejects.toThrow() expect(stdout()).toBe('') - expect(stderr()).not.toContain('Imported the following') - }) - }) -}) - -test.each(['json', 'text'])('preserves pending sibling writes when another picker cancels (%s)', async (format) => { - const sibling = {...extension, uuid: '594a39be-1c11-4a47-a17c-4a7d043dbbb7', title: 'Other action'} - await withApp( - async (app) => { - await mkdir(publicExtension(app.directory).directory) - await mkdir(publicExtension(app.directory, true, sibling).directory) - let releaseWrite!: () => void - const writeReady = new Promise((resolve) => { - releaseWrite = () => resolve('write') - }) - let selectCancelled!: () => void - const cancelled = new Promise((resolve) => { - selectCancelled = resolve - }) - vi.mocked(renderSelectPrompt).mockImplementation(async ({message}) => { - if (message === 'Extensions to migrate') return 'All' - if (typeof message === 'string' && message.includes('example-action')) return writeReady - selectCancelled() - return 'cancel' - }) - await withCapturedStandardStreams(async ({stdout}) => { - const command = runCommand(app.directory, format === 'json' ? ['--json'] : []) - const outcome = command.catch((error: ExtensionImportCancelledError) => error) - await cancelled - expect(stdout()).toBe('') - if (format === 'text') { - const error = await outcome - expect(error).toBeInstanceOf(ExtensionImportCancelledError) - await expect(fileExists(publicExtension(app.directory).configurationPath)).resolves.toBe(false) - releaseWrite() - const completed = await (error as ExtensionImportCancelledError).completedImports() - expect(completed.extensions).toHaveLength(1) - } else { - releaseWrite() - await handleSilentExit(await outcome) - expect(JSON.parse(stdout())).toEqual({ - status: 'cancelled', - reason: 'directory-selection-cancelled', - extensions: [publicExtension(app.directory)], - errors: [], - identifiersUpdated: false, - }) - } - }) - await expect(readFile(publicExtension(app.directory).configurationPath)).resolves.toBe(expectedToml) - await expect(fileExists(joinPath(app.directory, '.env'))).resolves.toBe(false) - }, - [extension, sibling], - ) -}) - -test.each(['json', 'text'])('preserves identifier-write failure ordering (%s)', async (format) => { - await withApp(async (app) => { - const dotenvPath = joinPath(app.directory, 'environment-directory') - app.dotenv = {path: dotenvPath, variables: {}} - await mkdir(dotenvPath) - await withCapturedStandardStreams(async ({stdout, stderr}) => { - if (format === 'text') { - await expect(runCommand(app.directory, [])).rejects.toThrow() - expect(stdout()).toBe('') - expect(unstyled(stderr())).toContain('Imported the following extensions from the dashboard:') - } else { - await handleSilentExit(await runCommand(app.directory, ['--json']).catch((failure: unknown) => failure)) - expect(JSON.parse(stdout())).toMatchObject({ - status: 'partial', - extensions: [publicExtension(app.directory)], - errors: [{extensionId: null, error: {type: 'bug', code: 'EISDIR'}}], - identifiersUpdated: false, - }) - expect(stderr()).not.toContain('Imported the following') - } }) - await expect(readFile(publicExtension(app.directory).configurationPath)).resolves.toBe(expectedToml) }) }) -test.each(['json', 'text'])( - 'waits for a delayed successful sibling after a write failure only in JSON (%s)', - async (format) => { +test.each(['cancelled', 'partial'])( + 'waits for a pending import before writing %s JSON and exits once without postrun', + async (status) => { const sibling = {...extension, uuid: '594a39be-1c11-4a47-a17c-4a7d043dbbb7', title: 'Other action'} await withApp( async (app) => { await mkdir(publicExtension(app.directory).directory) - await mkdir(publicExtension(app.directory, true, sibling).directory) - await mkdir(publicExtension(app.directory, true, sibling).configurationPath) - let releaseWrite!: () => void - const writeReady = new Promise((resolve) => { - releaseWrite = () => resolve('write') + const siblingDirectory = joinPath(app.directory, 'extensions', 'other-action') + await mkdir(siblingDirectory) + if (status === 'partial') await mkdir(joinPath(siblingDirectory, 'shopify.extension.toml')) + let releaseWrite!: (action: string) => void + const pendingWrite = new Promise((resolve) => { + releaseWrite = resolve }) - let reportFailure!: (error: ExtensionImportFailedError) => void - const failedWrite = new Promise((resolve) => { + let reportFailure!: () => void + const failureReady = new Promise((resolve) => { reportFailure = resolve }) - const realImport = extensionImportService.importExtensions + const originalImport = extensionImportService.importExtensions const importSpy = vi.spyOn(extensionImportService, 'importExtensions').mockImplementation(async (options) => { try { - return await realImport(options) + return await originalImport(options) } catch (error) { - if (error instanceof ExtensionImportFailedError) reportFailure(error) + reportFailure() throw error } }) vi.mocked(renderSelectPrompt).mockImplementation(async ({message}) => { if (message === 'Extensions to migrate') return 'All' - if (typeof message === 'string' && message.includes('example-action')) return writeReady - return 'write' + if (typeof message === 'string' && message.includes('example-action')) return pendingWrite + return status === 'cancelled' ? 'cancel' : 'write' }) - await withCapturedStandardStreams(async ({stdout}) => { - const outcome = runCommand(app.directory, format === 'json' ? ['--json'] : []).catch( - (error: unknown) => error, - ) - const failure = await failedWrite - expect(stdout()).toBe('') - if (format === 'text') { - await expect(outcome).resolves.toBe(failure.originalError) - await expect(fileExists(publicExtension(app.directory).configurationPath)).resolves.toBe(false) - releaseWrite() - await failure.completedImports() - } else { - releaseWrite() - await handleSilentExit(await outcome) + class LifecycleImportCommand extends ImportDashboardExtensions { + async catch(error: Error): Promise { + await errorHandler(error) + await Errors.handle(error) + throw error + } + + protected async init() { + return undefined + } + } + const config = await Config.load() + const metadata = config.findCommand('app:import:dashboard-extensions')! + vi.spyOn(config, 'findCommand').mockReturnValue({...metadata, load: async () => LifecycleImportCommand}) + const hooks = vi.spyOn(config, 'runHook').mockResolvedValue({successes: [], failures: []}) + const exit = vi.spyOn(process, 'exit').mockReturnValue(undefined as never) + try { + await withCapturedStandardStreams(async ({stdout}) => { + const outcome = config.runCommand('app:import:dashboard-extensions', ['--path', app.directory, '--json']) + const rejection = expect(outcome).rejects.toBeInstanceOf(AbortSilentError) + await failureReady + await new Promise((resolve) => setImmediate(resolve)) + expect(stdout()).toBe('') + releaseWrite('write') + await rejection expect(JSON.parse(stdout())).toMatchObject({ - status: 'partial', - reason: null, + status, extensions: [publicExtension(app.directory)], - errors: [{extensionId: sibling.uuid, error: {type: 'bug', code: 'EISDIR'}}], identifiersUpdated: false, + errors: status === 'cancelled' ? [] : [expect.objectContaining({extensionId: sibling.uuid})], }) - } - }) - importSpy.mockRestore() - await expect(readFile(publicExtension(app.directory).configurationPath)).resolves.toBe(expectedToml) - await expect(fileExists(joinPath(app.directory, '.env'))).resolves.toBe(false) + }) + expect(exit).toHaveBeenCalledExactlyOnceWith(1) + expect(hooks.mock.calls.map(([event]) => event)).not.toContain('postrun') + expect(reportAnalyticsEvent).toHaveBeenCalledTimes(status === 'partial' ? 1 : 0) + expect(sendErrorToBugsnag).toHaveBeenCalledTimes(status === 'partial' ? 1 : 0) + } finally { + exit.mockRestore() + importSpy.mockRestore() + } }, [extension, sibling], ) }, ) - -test('all failed imports retain the first original error and emit one shared fatal document', async () => { - const sibling = {...extension, uuid: '594a39be-1c11-4a47-a17c-4a7d043dbbb7', title: 'Other action'} - await withApp( - async (app) => { - await Promise.all( - [extension, sibling].map(async (ext) => { - const record = publicExtension(app.directory, true, ext) - await mkdir(record.directory) - await mkdir(record.configurationPath) - }), - ) - vi.mocked(renderSelectPrompt).mockResolvedValueOnce('All').mockResolvedValue('write') - await withCapturedStandardStreams(async ({stdout}) => { - const error = await runCommand(app.directory, ['--json']).catch((failure: unknown) => failure) - expect(error).toMatchObject({code: 'EISDIR'}) - expect(stdout()).toBe('') - vi.stubEnv('SHOPIFY_FLAG_JSON', '1') - await runWithCommandEventsForCommand(['--json'], () => handler(error as Error)) - expect(JSON.parse(stdout())).toMatchObject({error: {type: 'bug', message: expect.stringContaining('EISDIR')}}) - }) - await expect(fileExists(joinPath(app.directory, '.env'))).resolves.toBe(false) - }, - [extension, sibling], - ) -}) - -test('keeps transport errors on the fatal path without printing a result first', async () => { - await withApp(async (app) => { - vi.mocked(linkedAppContext).mockRejectedValue(new AbortError('Authentication failed')) - await withCapturedStandardStreams(async ({stdout}) => { - const error = await runCommand(app.directory, ['--json']).catch((failure: AbortError) => failure) - expect(stdout()).toBe('') - vi.stubEnv('SHOPIFY_FLAG_JSON', '1') - await runWithCommandEventsForCommand(['--json'], () => handler(error as AbortError)) - expect(JSON.parse(stdout())).toMatchObject({error: {type: 'abort', message: 'Authentication failed'}}) - }) - }) -}) - -test.each([ - {argv: [], disabled: false}, - {argv: ['--json'], disabled: false}, - {argv: ['--no-input'], disabled: true}, - {argv: ['--json', '--no-input'], disabled: true}, -])('keeps input policy independent of output formatting: $argv', async ({argv, disabled}) => { - await withApp(async (app) => { - vi.stubEnv('SHOPIFY_FLAG_NO_INPUT', disabled ? '1' : '0') - const realUi = await vi.importActual('@shopify/cli-kit/node/ui') - if (disabled) vi.mocked(renderSelectPrompt).mockImplementation(realUi.renderSelectPrompt) - await withCapturedStandardStreams(async ({stdout}) => { - if (disabled) { - await expect(runCommand(app.directory, argv)).rejects.toThrow('Failed to prompt:') - expect(stdout()).toBe('') - await expect(fileExists(publicExtension(app.directory).configurationPath)).resolves.toBe(false) - } else { - await runCommand(app.directory, argv) - expect(renderSelectPrompt).toHaveBeenCalledWith(expect.objectContaining({message: 'Extensions to migrate'})) - if (argv.includes('--json')) expect(JSON.parse(stdout())).toHaveProperty('status', 'success') - } - }) - }) -}) - -test('preserves the deprecated alias and exposes schema and flags from both paths', async () => { - expect(ImportDashboardExtensions.jsonOutputSchema).toBe(importDashboardExtensionsJsonOutputSchema) - expect(ImportExtensionsDeprecated.jsonOutputSchema).toBe(importDashboardExtensionsJsonOutputSchema) - expect(ImportExtensionsDeprecated.flags.json).toBeDefined() - expect(ImportDashboardExtensions.descriptionForHelp()).toContain('`ImportDashboardExtensionsResult` schema') - await withApp(async (app) => { - await withCapturedStandardStreams(async ({stdout, stderr}) => { - await runCommand(app.directory, ['--json'], true) - expect(JSON.parse(stdout())).toHaveProperty('status', 'success') - expect(unstyled(stderr()).replaceAll('`', '')).toContain('shopify app import-extensions has moved.') - }) - }) -}) - -test.each(['cancelled', 'partial'])( - 'skips the success postrun hook for a JSON %s result and preserves reporting', - async (status) => { - await withApp(async (app) => { - if (status === 'cancelled') { - await mkdir(publicExtension(app.directory).directory) - vi.mocked(renderSelectPrompt).mockResolvedValueOnce(extension.uuid).mockResolvedValueOnce('cancel') - } else { - const dotenvPath = joinPath(app.directory, 'environment-directory') - app.dotenv = {path: dotenvPath, variables: {}} - await mkdir(dotenvPath) - } - class LifecycleImportCommand extends ImportDashboardExtensions { - async catch(error: Error & {skipOclifErrorHandling: boolean}): Promise { - throw error - } - - protected async init() { - return undefined - } - } - const config = await Config.load() - const metadata = config.findCommand('app:import:dashboard-extensions')! - vi.spyOn(config, 'findCommand').mockReturnValue({...metadata, load: async () => LifecycleImportCommand}) - const hooks = vi.spyOn(config, 'runHook').mockResolvedValue({successes: [], failures: []}) - await withCapturedStandardStreams(async ({stdout}) => { - await expect( - config.runCommand('app:import:dashboard-extensions', ['--path', app.directory, '--json']), - ).rejects.toThrow(AbortSilentError) - expect(JSON.parse(stdout())).toHaveProperty('status', status) - expect(hooks.mock.calls.map(([event]) => event)).not.toContain('postrun') - }) - if (status === 'cancelled') { - expect(reportAnalyticsEvent).not.toHaveBeenCalled() - expect(sendErrorToBugsnag).not.toHaveBeenCalled() - } else { - expect(reportAnalyticsEvent).toHaveBeenCalledOnce() - expect(reportAnalyticsEvent).toHaveBeenCalledWith({ - config, - errorMessage: expect.stringContaining('EISDIR'), - exitMode: 'unexpected_error', - }) - expect(sendErrorToBugsnag).toHaveBeenCalledOnce() - expect(sendErrorToBugsnag).toHaveBeenCalledWith(expect.objectContaining({code: 'EISDIR'}), 'unexpected_error') - } - }) - }, -) diff --git a/packages/app/src/cli/services/import-extensions.test.ts b/packages/app/src/cli/services/import-extensions.test.ts index ced4044cb98..35ab1d1cfce 100644 --- a/packages/app/src/cli/services/import-extensions.test.ts +++ b/packages/app/src/cli/services/import-extensions.test.ts @@ -428,24 +428,3 @@ test('the import-all caller retains text presentation and persists identifiers', await expect(readFile(joinPath(directory, '.env'))).resolves.toContain('SHOPIFY_TITLEB_ID=uuidB') }) }) - -test('the import-all caller still presents completed files before an identifier write fails', async () => { - await inTemporaryDirectory(async (directory) => { - const dotenvPath = joinPath(directory, 'environment-directory') - await mkdir(dotenvPath) - const app = testAppLinked({directory, dotenv: {path: dotenvPath, variables: {}}}) - await expect( - importAllExtensions({ - app, - remoteApp: organizationApp, - developerPlatformClient: testDeveloperPlatformClient(), - extensions: [flowExtensionA], - }), - ).rejects.toThrow() - expect(renderSuccess).toHaveBeenCalledWith({ - headline: ['Imported the following extensions from the dashboard:'], - body: '• "titleA" at: extensions/title-a', - }) - expect(fileExistsSync(joinPath(directory, 'extensions', 'title-a', 'shopify.extension.toml'))).toBe(true) - }) -}) diff --git a/packages/app/src/cli/services/import-extensions/types.test.ts b/packages/app/src/cli/services/import-extensions/types.test.ts deleted file mode 100644 index f3a0eeb4ee8..00000000000 --- a/packages/app/src/cli/services/import-extensions/types.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import {importDashboardExtensionsJsonOutputSchema, ImportDashboardExtensionsResult} from './types.js' -import {expect, test} from 'vitest' -import {joinPath} from '@shopify/cli-kit/node/path' -import {tmpdir} from 'node:os' - -const extension = { - id: 'e7282f2d-3017-4608-a9a4-54dd5a7a70aa', - name: 'Example action', - type: 'flow_action_definition', - directory: joinPath(tmpdir(), 'app', 'extensions', 'example-action'), - configurationPath: joinPath(tmpdir(), 'app', 'extensions', 'example-action', 'shopify.extension.toml'), - changed: true, -} -const result: ImportDashboardExtensionsResult = { - status: 'success', - reason: null, - extensions: [extension], - errors: [], - identifiersUpdated: true, -} - -test('encodes completed imports, known empty results, and kept directories without a TOML file', () => { - expect(JSON.parse(importDashboardExtensionsJsonOutputSchema.encode(result))).toEqual(result) - const skipped: ImportDashboardExtensionsResult = { - status: 'skipped', - reason: 'no-extensions', - extensions: [], - errors: [], - identifiersUpdated: false, - } - expect(JSON.parse(importDashboardExtensionsJsonOutputSchema.encode(skipped))).toEqual(skipped) - const cancelled: ImportDashboardExtensionsResult = { - status: 'cancelled', - reason: 'directory-selection-cancelled', - extensions: [{...extension, changed: false, configurationPath: null}], - errors: [], - identifiersUpdated: false, - } - expect(JSON.parse(importDashboardExtensionsJsonOutputSchema.encode(cancelled))).toEqual(cancelled) -}) - -test.each([ - {field: 'id', value: 'gid://shopify/AppExtension/1'}, - {field: 'directory', value: 'extensions/example-action'}, - {field: 'configurationPath', value: 'shopify.extension.toml'}, - {field: 'type', value: ''}, - {field: 'changed', value: null}, -])('rejects invalid extension $field', ({field, value}) => { - expect(() => - importDashboardExtensionsJsonOutputSchema.validate({...result, extensions: [{...extension, [field]: value}]}), - ).toThrow() -}) - -test('rejects unknown status and accidental internal fields', () => { - expect(() => importDashboardExtensionsJsonOutputSchema.validate({...result, status: 'failed'})).toThrow() - expect(() => - importDashboardExtensionsJsonOutputSchema.validate({...result, extensionUuids: {handle: extension.id}}), - ).toThrow() - expect(() => - importDashboardExtensionsJsonOutputSchema.validate({ - ...result, - extensions: [{...extension, activeVersion: {config: 'secret'}}], - }), - ).toThrow() -}) - -test('encodes partial results with structured errors and rejects invalid failure records', () => { - const partial: ImportDashboardExtensionsResult = { - ...result, - status: 'partial', - identifiersUpdated: false, - errors: [{extensionId: null, error: {type: 'abort', message: 'Identifiers could not be saved', code: 'EACCES'}}], - } - expect(JSON.parse(importDashboardExtensionsJsonOutputSchema.encode(partial))).toEqual(partial) - expect(() => - importDashboardExtensionsJsonOutputSchema.validate({ - ...partial, - errors: [{extensionId: 'bad-id', error: {type: 'abort', message: 'Import failed'}}], - }), - ).toThrow() - expect(() => - importDashboardExtensionsJsonOutputSchema.validate({ - ...partial, - errors: [{extensionId: extension.id, error: {type: 'unknown', message: 'Import failed'}}], - }), - ).toThrow() - expect(() => - importDashboardExtensionsJsonOutputSchema.validate({ - ...partial, - errors: [{extensionId: extension.id, error: {type: 'abort', message: 'Import failed', code: ''}}], - }), - ).toThrow() -}) From a2c403c4213659518f621fe4a556109f6d05cb25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Wed, 7 Oct 2026 15:00:02 +0200 Subject: [PATCH 3/6] Retain concise identifier failure coverage --- .../app/import/dashboard-extensions.test.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/packages/app/src/cli/commands/app/import/dashboard-extensions.test.ts b/packages/app/src/cli/commands/app/import/dashboard-extensions.test.ts index 21f8fd9917f..5b586c135d2 100644 --- a/packages/app/src/cli/commands/app/import/dashboard-extensions.test.ts +++ b/packages/app/src/cli/commands/app/import/dashboard-extensions.test.ts @@ -122,6 +122,23 @@ test('leaves a failed import on the fatal path without printing a result', async }) }) +test('reports completed imports as partial when saving identifiers fails', async () => { + await withApp(async (app) => { + const path = joinPath(app.directory, 'environment-directory') + app.dotenv = {path, variables: {}} + await mkdir(path) + await withCapturedStandardStreams(async ({stdout}) => { + await expect(runCommand(app.directory)).rejects.toBeInstanceOf(AbortSilentError) + expect(JSON.parse(stdout())).toMatchObject({ + status: 'partial', + extensions: [publicExtension(app.directory)], + errors: [{extensionId: null, error: {type: 'bug'}}], + identifiersUpdated: false, + }) + }) + }) +}) + test.each(['cancelled', 'partial'])( 'waits for a pending import before writing %s JSON and exits once without postrun', async (status) => { From c630a3b016d52d7c228d32cb89a5ab13d7035c5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Wed, 7 Oct 2026 15:52:30 +0200 Subject: [PATCH 4/6] Simplify dashboard schema and test declarations --- .../app/import/dashboard-extensions.test.ts | 27 +++++++++---------- .../app/import/dashboard-extensions.ts | 6 ++--- .../rules/command-json-output.js | 4 ++- .../rules/command-json-output.test.js | 4 +-- 4 files changed, 18 insertions(+), 23 deletions(-) diff --git a/packages/app/src/cli/commands/app/import/dashboard-extensions.test.ts b/packages/app/src/cli/commands/app/import/dashboard-extensions.test.ts index 5b586c135d2..7b1d9655943 100644 --- a/packages/app/src/cli/commands/app/import/dashboard-extensions.test.ts +++ b/packages/app/src/cli/commands/app/import/dashboard-extensions.test.ts @@ -11,21 +11,13 @@ import {joinPath} from '@shopify/cli-kit/node/path' import {withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output' import {runWithCommandEventsForCommand} from '@shopify/cli-kit/node/command-events' import {outputInfo} from '@shopify/cli-kit/node/output' -import {renderSelectPrompt} from '@shopify/cli-kit/node/ui' +import * as ui from '@shopify/cli-kit/node/ui' import {reportAnalyticsEvent} from '@shopify/cli-kit/node/analytics' -import {errorHandler, sendErrorToBugsnag} from '@shopify/cli-kit/node/error-handler' +import * as errorHandlers from '@shopify/cli-kit/node/error-handler' import {AbortSilentError} from '@shopify/cli-kit/node/error' vi.mock('../../../services/app-context.js') vi.mock('@shopify/cli-kit/node/analytics') -vi.mock('@shopify/cli-kit/node/error-handler', async (importOriginal) => ({ - ...(await importOriginal()), - sendErrorToBugsnag: vi.fn(), -})) -vi.mock('@shopify/cli-kit/node/ui', async (importOriginal) => ({ - ...(await importOriginal()), - renderSelectPrompt: vi.fn(), -})) const extension: ExtensionRegistration = { id: 'gid://shopify/AppExtensionRegistration/1', @@ -56,7 +48,12 @@ async function withApp(run: (app: ReturnType) => Promise>) - vi.mocked(renderSelectPrompt).mockReset().mockResolvedValue(extension.uuid) + vi.spyOn(ui, 'renderSelectPrompt').mockResolvedValue(extension.uuid) + vi.spyOn(errorHandlers, 'sendErrorToBugsnag').mockResolvedValue({ + reported: false, + error: undefined, + unhandled: undefined, + }) await run(app) }) } @@ -114,7 +111,7 @@ test('leaves a failed import on the fatal path without printing a result', async const record = publicExtension(app.directory) await mkdir(record.directory) await mkdir(record.configurationPath) - vi.mocked(renderSelectPrompt).mockResolvedValueOnce(extension.uuid).mockResolvedValueOnce('write') + vi.mocked(ui.renderSelectPrompt).mockResolvedValueOnce(extension.uuid).mockResolvedValueOnce('write') await withCapturedStandardStreams(async ({stdout}) => { await expect(runCommand(app.directory)).rejects.toThrow() expect(stdout()).toBe('') @@ -166,14 +163,14 @@ test.each(['cancelled', 'partial'])( throw error } }) - vi.mocked(renderSelectPrompt).mockImplementation(async ({message}) => { + vi.mocked(ui.renderSelectPrompt).mockImplementation(async ({message}) => { if (message === 'Extensions to migrate') return 'All' if (typeof message === 'string' && message.includes('example-action')) return pendingWrite return status === 'cancelled' ? 'cancel' : 'write' }) class LifecycleImportCommand extends ImportDashboardExtensions { async catch(error: Error): Promise { - await errorHandler(error) + await errorHandlers.errorHandler(error) await Errors.handle(error) throw error } @@ -206,7 +203,7 @@ test.each(['cancelled', 'partial'])( expect(exit).toHaveBeenCalledExactlyOnceWith(1) expect(hooks.mock.calls.map(([event]) => event)).not.toContain('postrun') expect(reportAnalyticsEvent).toHaveBeenCalledTimes(status === 'partial' ? 1 : 0) - expect(sendErrorToBugsnag).toHaveBeenCalledTimes(status === 'partial' ? 1 : 0) + expect(errorHandlers.sendErrorToBugsnag).toHaveBeenCalledTimes(status === 'partial' ? 1 : 0) } finally { exit.mockRestore() importSpy.mockRestore() diff --git a/packages/app/src/cli/commands/app/import/dashboard-extensions.ts b/packages/app/src/cli/commands/app/import/dashboard-extensions.ts index 43e48b33d1f..17d61183f57 100644 --- a/packages/app/src/cli/commands/app/import/dashboard-extensions.ts +++ b/packages/app/src/cli/commands/app/import/dashboard-extensions.ts @@ -34,11 +34,9 @@ import {renderSuccess} from '@shopify/cli-kit/node/ui' export default class ImportDashboardExtensions extends AppLinkedCommand { static descriptionWithMarkdown = 'Import dashboard-managed extensions into your app.' - static description = this.descriptionForHelp() + static jsonOutputSchema = importDashboardExtensionsJsonOutputSchema - static get jsonOutputSchema() { - return importDashboardExtensionsJsonOutputSchema - } + static description = this.descriptionForHelp() static flags = { ...globalFlags, 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 eaf638118014afc80705daefd0f337ebf2283cd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Wed, 7 Oct 2026 15:58:25 +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 ccf8f51fffc5bdb86c5115f0da616215f6729a23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Thu, 8 Oct 2026 18:00:39 +0200 Subject: [PATCH 6/6] Preserve dashboard import failures after picker cancellation --- bin/prettify-manifests.js | 7 +- .../app/import/dashboard-extensions.test.ts | 199 ++++++++++-------- .../app/import/dashboard-extensions.ts | 40 ++-- .../rules/command-json-output.js | 4 +- .../rules/command-json-output.test.js | 4 +- 5 files changed, 133 insertions(+), 121 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/dashboard-extensions.test.ts b/packages/app/src/cli/commands/app/import/dashboard-extensions.test.ts index 7b1d9655943..ee81b1feb26 100644 --- a/packages/app/src/cli/commands/app/import/dashboard-extensions.test.ts +++ b/packages/app/src/cli/commands/app/import/dashboard-extensions.test.ts @@ -76,26 +76,37 @@ async function runCommand(directory: string) { return runWithCommandEventsForCommand(args, () => command.run()) } -test('writes JSON after saving identifiers and sends diagnostics to stderr', async () => { - await withApp(async (app) => { - await withCapturedStandardStreams(async ({stdout, stderr}) => { - await runCommand(app.directory) - const result = JSON.parse(stdout()) - expect(result).toEqual({ - status: 'success', - reason: null, - extensions: [publicExtension(app.directory)], - errors: [], - identifiersUpdated: true, +test.each(['write', 'skip'])( + 'writes %s JSON after saving identifiers and sends diagnostics to stderr', + async (action) => { + await withApp(async (app) => { + const record = publicExtension(app.directory) + const localToml = 'name = "Local action"\n# Preserve this comment.\n' + if (action === 'skip') { + await mkdir(record.directory) + await writeFile(record.configurationPath, localToml) + vi.mocked(ui.renderSelectPrompt).mockResolvedValueOnce(extension.uuid).mockResolvedValueOnce('skip') + } + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await runCommand(app.directory) + const result = JSON.parse(stdout()) + expect(result).toEqual({ + status: 'success', + reason: null, + extensions: [{...record, changed: action === 'write'}], + errors: [], + identifiersUpdated: true, + }) + expect(JSON.parse(stderr())).toMatchObject({type: 'diagnostic', message: 'Loaded dashboard extensions'}) + expect(() => importDashboardExtensionsJsonOutputSchema.encode({...result, internal: true})).toThrow() }) - expect(JSON.parse(stderr())).toMatchObject({type: 'diagnostic', message: 'Loaded dashboard extensions'}) - expect(() => importDashboardExtensionsJsonOutputSchema.encode({...result, internal: true})).toThrow() + if (action === 'skip') await expect(readFile(record.configurationPath)).resolves.toBe(localToml) + await expect(readFile(joinPath(app.directory, '.env'))).resolves.toContain( + `SHOPIFY_EXAMPLE_ACTION_ID=${extension.uuid}`, + ) }) - await expect(readFile(joinPath(app.directory, '.env'))).resolves.toContain( - `SHOPIFY_EXAMPLE_ACTION_ID=${extension.uuid}`, - ) - }) -}) + }, +) test('writes a skipped JSON result when there are no extensions', async () => { await withApp(async (app) => { @@ -136,80 +147,84 @@ test('reports completed imports as partial when saving identifiers fails', async }) }) -test.each(['cancelled', 'partial'])( - 'waits for a pending import before writing %s JSON and exits once without postrun', - async (status) => { - const sibling = {...extension, uuid: '594a39be-1c11-4a47-a17c-4a7d043dbbb7', title: 'Other action'} - await withApp( - async (app) => { - await mkdir(publicExtension(app.directory).directory) - const siblingDirectory = joinPath(app.directory, 'extensions', 'other-action') - await mkdir(siblingDirectory) - if (status === 'partial') await mkdir(joinPath(siblingDirectory, 'shopify.extension.toml')) - let releaseWrite!: (action: string) => void - const pendingWrite = new Promise((resolve) => { - releaseWrite = resolve - }) - let reportFailure!: () => void - const failureReady = new Promise((resolve) => { - reportFailure = resolve - }) - const originalImport = extensionImportService.importExtensions - const importSpy = vi.spyOn(extensionImportService, 'importExtensions').mockImplementation(async (options) => { - try { - return await originalImport(options) - } catch (error) { - reportFailure() - throw error - } - }) - vi.mocked(ui.renderSelectPrompt).mockImplementation(async ({message}) => { - if (message === 'Extensions to migrate') return 'All' - if (typeof message === 'string' && message.includes('example-action')) return pendingWrite - return status === 'cancelled' ? 'cancel' : 'write' - }) - class LifecycleImportCommand extends ImportDashboardExtensions { - async catch(error: Error): Promise { - await errorHandlers.errorHandler(error) - await Errors.handle(error) - throw error - } +test.each([ + {name: 'cancellation', status: 'cancelled', cancel: true, fail: false}, + {name: 'failure', status: 'partial', cancel: false, fail: true}, + {name: 'cancellation followed by failure', status: 'partial', cancel: true, fail: true}, +])('$name waits for pending imports before writing one $status JSON result', async (row) => { + const cancelledExtension = {...extension, uuid: '594a39be-1c11-4a47-a17c-4a7d043dbbb7', title: 'Cancelled action'} + const failedExtension = {...extension, uuid: 'd8fb83d0-011b-47a4-b61e-4d304d352ca9', title: 'Failed action'} + await withApp( + async (app) => { + await mkdir(publicExtension(app.directory).directory) + if (row.cancel) await mkdir(joinPath(app.directory, 'extensions', 'cancelled-action')) + if (row.fail) await mkdir(joinPath(app.directory, 'extensions', 'failed-action', 'shopify.extension.toml')) + let releaseWrite!: (action: string) => void + const pendingWrite = new Promise((resolve) => { + releaseWrite = resolve + }) + let reportFailure!: () => void + const failureReady = new Promise((resolve) => { + reportFailure = resolve + }) + const originalImport = extensionImportService.importExtensions + const importSpy = vi.spyOn(extensionImportService, 'importExtensions').mockImplementation(async (options) => { + try { + return await originalImport(options) + } catch (error) { + reportFailure() + throw error + } + }) + vi.mocked(ui.renderSelectPrompt).mockImplementation(async ({message}) => { + if (message === 'Extensions to migrate') return 'All' + if (typeof message === 'string' && message.includes('example-action')) return pendingWrite + if (typeof message === 'string' && message.includes('cancelled-action')) return 'cancel' + if (row.cancel) await failureReady + return 'write' + }) + class LifecycleImportCommand extends ImportDashboardExtensions { + async catch(error: Error): Promise { + await errorHandlers.errorHandler(error) + await Errors.handle(error) + throw error + } - protected async init() { - return undefined - } + protected async init() { + return undefined } - const config = await Config.load() - const metadata = config.findCommand('app:import:dashboard-extensions')! - vi.spyOn(config, 'findCommand').mockReturnValue({...metadata, load: async () => LifecycleImportCommand}) - const hooks = vi.spyOn(config, 'runHook').mockResolvedValue({successes: [], failures: []}) - const exit = vi.spyOn(process, 'exit').mockReturnValue(undefined as never) - try { - await withCapturedStandardStreams(async ({stdout}) => { - const outcome = config.runCommand('app:import:dashboard-extensions', ['--path', app.directory, '--json']) - const rejection = expect(outcome).rejects.toBeInstanceOf(AbortSilentError) - await failureReady - await new Promise((resolve) => setImmediate(resolve)) - expect(stdout()).toBe('') - releaseWrite('write') - await rejection - expect(JSON.parse(stdout())).toMatchObject({ - status, - extensions: [publicExtension(app.directory)], - identifiersUpdated: false, - errors: status === 'cancelled' ? [] : [expect.objectContaining({extensionId: sibling.uuid})], - }) + } + const config = await Config.load() + const metadata = config.findCommand('app:import:dashboard-extensions')! + vi.spyOn(config, 'findCommand').mockReturnValue({...metadata, load: async () => LifecycleImportCommand}) + const hooks = vi.spyOn(config, 'runHook').mockResolvedValue({successes: [], failures: []}) + const exit = vi.spyOn(process, 'exit').mockReturnValue(undefined as never) + try { + await withCapturedStandardStreams(async ({stdout}) => { + const outcome = config.runCommand('app:import:dashboard-extensions', ['--path', app.directory, '--json']) + const rejection = expect(outcome).rejects.toBeInstanceOf(AbortSilentError) + await failureReady + await new Promise((resolve) => setImmediate(resolve)) + expect(stdout()).toBe('') + releaseWrite('write') + await rejection + expect(JSON.parse(stdout())).toMatchObject({ + status: row.status, + reason: row.status === 'cancelled' ? 'directory-selection-cancelled' : null, + extensions: [publicExtension(app.directory)], + identifiersUpdated: false, + errors: row.fail ? [expect.objectContaining({extensionId: failedExtension.uuid})] : [], }) - expect(exit).toHaveBeenCalledExactlyOnceWith(1) - expect(hooks.mock.calls.map(([event]) => event)).not.toContain('postrun') - expect(reportAnalyticsEvent).toHaveBeenCalledTimes(status === 'partial' ? 1 : 0) - expect(errorHandlers.sendErrorToBugsnag).toHaveBeenCalledTimes(status === 'partial' ? 1 : 0) - } finally { - exit.mockRestore() - importSpy.mockRestore() - } - }, - [extension, sibling], - ) - }, -) + }) + expect(exit).toHaveBeenCalledExactlyOnceWith(1) + expect(hooks.mock.calls.map(([event]) => event)).not.toContain('postrun') + expect(reportAnalyticsEvent).toHaveBeenCalledTimes(row.fail ? 1 : 0) + expect(errorHandlers.sendErrorToBugsnag).toHaveBeenCalledTimes(row.fail ? 1 : 0) + } finally { + exit.mockRestore() + importSpy.mockRestore() + } + }, + [extension, ...(row.cancel ? [cancelledExtension] : []), ...(row.fail ? [failedExtension] : [])], + ) +}) diff --git a/packages/app/src/cli/commands/app/import/dashboard-extensions.ts b/packages/app/src/cli/commands/app/import/dashboard-extensions.ts index 17d61183f57..38d56069b3f 100644 --- a/packages/app/src/cli/commands/app/import/dashboard-extensions.ts +++ b/packages/app/src/cli/commands/app/import/dashboard-extensions.ts @@ -34,7 +34,9 @@ import {renderSuccess} from '@shopify/cli-kit/node/ui' export default class ImportDashboardExtensions extends AppLinkedCommand { static descriptionWithMarkdown = 'Import dashboard-managed extensions into your app.' - static jsonOutputSchema = importDashboardExtensionsJsonOutputSchema + static get jsonOutputSchema() { + return importDashboardExtensionsJsonOutputSchema + } static description = this.descriptionForHelp() @@ -85,7 +87,6 @@ export default class ImportDashboardExtensions extends AppLinkedCommand { } else { const migrationChoice = await selectMigrationChoice(migrationChoices) let result: ImportExtensionsResult | undefined - let identifiersUpdated = false try { result = await importExtensions({ ...appContext, @@ -100,29 +101,19 @@ export default class ImportDashboardExtensions extends AppLinkedCommand { extensionUuids: result.extensionUuids, command: 'import-extensions', }) - identifiersUpdated = true - if (flags.json) { - outputResult( - importDashboardExtensionsJsonOutputSchema.encode({ - status: 'success', - reason: null, - extensions: projectExtensions(result.extensions), - errors: [], - identifiersUpdated: true, - }), - ) - } } catch (error) { if (!flags.json) throw error instanceof ExtensionImportFailedError ? error.originalError : error + let originalError = error instanceof ExtensionImportFailedError ? error.originalError : error if (error instanceof ExtensionImportCancelledError || error instanceof ExtensionImportFailedError) { const completion = await error.completedImports() if (error instanceof ExtensionImportFailedError && completion.extensions.length === 0) { throw error.originalError } + const cancelled = error instanceof ExtensionImportCancelledError && completion.failures.length === 0 outputResult( importDashboardExtensionsJsonOutputSchema.encode({ - status: error instanceof ExtensionImportCancelledError ? 'cancelled' : 'partial', - reason: error instanceof ExtensionImportCancelledError ? 'directory-selection-cancelled' : null, + status: cancelled ? 'cancelled' : 'partial', + reason: cancelled ? 'directory-selection-cancelled' : null, extensions: projectExtensions(completion.extensions), errors: completion.failures.map(({extension, error: failure}) => ({ extensionId: extension.uuid, @@ -131,7 +122,9 @@ export default class ImportDashboardExtensions extends AppLinkedCommand { identifiersUpdated: false, }), ) - } else if (result && !identifiersUpdated) { + if (cancelled) throw error + if (error instanceof ExtensionImportCancelledError) originalError = completion.failures[0]!.error + } else if (result) { outputResult( importDashboardExtensionsJsonOutputSchema.encode({ status: 'partial', @@ -144,8 +137,6 @@ export default class ImportDashboardExtensions extends AppLinkedCommand { } else { throw error } - if (error instanceof ExtensionImportCancelledError) throw error - const originalError = error instanceof ExtensionImportFailedError ? error.originalError : error const mappedError = await errorMapper(originalError) const exitMode = shouldReportErrorAsUnexpected(mappedError) ? 'unexpected_error' : 'expected_error' await reportAnalyticsEvent({ @@ -156,6 +147,17 @@ export default class ImportDashboardExtensions extends AppLinkedCommand { await sendErrorToBugsnag(mappedError, exitMode) throw new AbortSilentError() } + if (flags.json) { + outputResult( + importDashboardExtensionsJsonOutputSchema.encode({ + status: 'success', + reason: null, + extensions: projectExtensions(result.extensions), + errors: [], + identifiersUpdated: true, + }), + ) + } } return {app: appContext.app} 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 + } } `, },