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..ee81b1feb26 --- /dev/null +++ b/packages/app/src/cli/commands/app/import/dashboard-extensions.test.ts @@ -0,0 +1,230 @@ +import ImportDashboardExtensions from './dashboard-extensions.js' +import {linkedAppContext} from '../../../services/app-context.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 {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} from '@shopify/cli-kit/node/output' +import * as ui from '@shopify/cli-kit/node/ui' +import {reportAnalyticsEvent} from '@shopify/cli-kit/node/analytics' +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') + +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"}'}, +} + +async function withApp(run: (app: ReturnType) => Promise, extensions = [extension]) { + 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.spyOn(ui, 'renderSelectPrompt').mockResolvedValue(extension.uuid) + vi.spyOn(errorHandlers, 'sendErrorToBugsnag').mockResolvedValue({ + reported: false, + error: undefined, + unhandled: undefined, + }) + await run(app) + }) +} + +function publicExtension(directory: string) { + const extensionDirectory = joinPath(directory, 'extensions', 'example-action') + return { + id: extension.uuid, + name: extension.title, + type: extension.type, + directory: extensionDirectory, + configurationPath: joinPath(extensionDirectory, 'shopify.extension.toml'), + changed: true, + } +} + +async function runCommand(directory: string) { + const args = ['--path', directory, '--json'] + const command = new ImportDashboardExtensions(args, await Config.load()) + return runWithCommandEventsForCommand(args, () => command.run()) +} + +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() + }) + 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}`, + ) + }) + }, +) + +test('writes a skipped JSON result when there are no extensions', async () => { + await withApp(async (app) => { + await withCapturedStandardStreams(async ({stdout}) => { + await runCommand(app.directory) + expect(JSON.parse(stdout())).toMatchObject({status: 'skipped', extensions: [], identifiersUpdated: false}) + }) + }, []) +}) + +test('leaves a failed import on the fatal path without printing a result', async () => { + await withApp(async (app) => { + const record = publicExtension(app.directory) + await mkdir(record.directory) + await mkdir(record.configurationPath) + vi.mocked(ui.renderSelectPrompt).mockResolvedValueOnce(extension.uuid).mockResolvedValueOnce('write') + await withCapturedStandardStreams(async ({stdout}) => { + await expect(runCommand(app.directory)).rejects.toThrow() + expect(stdout()).toBe('') + }) + }) +}) + +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([ + {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 + } + } + 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(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 83622ad561b..38d56069b3f 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 get jsonOutputSchema() { + return importDashboardExtensionsJsonOutputSchema + } + + static description = this.descriptionForHelp() 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 + 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', + }) + } 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: cancelled ? 'cancelled' : 'partial', + reason: cancelled ? 'directory-selection-cancelled' : null, + extensions: projectExtensions(completion.extensions), + errors: completion.failures.map(({extension, error: failure}) => ({ + extensionId: extension.uuid, + error: projectImportError(failure), + })), + identifiersUpdated: false, + }), + ) + if (cancelled) throw error + if (error instanceof ExtensionImportCancelledError) originalError = completion.failures[0]!.error + } else if (result) { + outputResult( + importDashboardExtensionsJsonOutputSchema.encode({ + status: 'partial', + reason: null, + extensions: projectExtensions(result.extensions), + errors: [{extensionId: null, error: projectImportError(error)}], + identifiersUpdated: false, + }), + ) + } else { + throw 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() + } + if (flags.json) { + outputResult( + importDashboardExtensionsJsonOutputSchema.encode({ + status: 'success', + reason: null, + extensions: projectExtensions(result.extensions), + errors: [], + identifiersUpdated: true, + }), + ) + } } 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..35ab1d1cfce 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,22 @@ 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') + }) +}) 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.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',