From b46926e5776ba83612b0990c0d1a66d239163fd6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Wed, 7 Oct 2026 13:56:32 +0200 Subject: [PATCH 1/5] Add typed JSON output to app dev clean --- .changeset/app-dev-clean-json.md | 5 + .../generated/generated_docs_data_v2.json | 11 +- .../src/cli/commands/app/dev/clean.test.ts | 186 ++++++++++++++++++ .../app/src/cli/commands/app/dev/clean.ts | 12 +- .../app/src/cli/services/dev-clean.test.ts | 97 ++++----- packages/app/src/cli/services/dev-clean.ts | 32 +-- .../src/cli/services/dev-clean/result.test.ts | 23 +++ .../app/src/cli/services/dev-clean/result.ts | 19 ++ .../src/cli/services/dev-clean/types.test.ts | 32 +++ .../app/src/cli/services/dev-clean/types.ts | 21 ++ packages/cli/README.md | 60 +++++- packages/cli/oclif.manifest.json | 11 +- .../rules/json-output-command-exceptions.js | 1 - 13 files changed, 443 insertions(+), 67 deletions(-) create mode 100644 .changeset/app-dev-clean-json.md create mode 100644 packages/app/src/cli/commands/app/dev/clean.test.ts create mode 100644 packages/app/src/cli/services/dev-clean/result.test.ts create mode 100644 packages/app/src/cli/services/dev-clean/result.ts create mode 100644 packages/app/src/cli/services/dev-clean/types.test.ts create mode 100644 packages/app/src/cli/services/dev-clean/types.ts diff --git a/.changeset/app-dev-clean-json.md b/.changeset/app-dev-clean-json.md new file mode 100644 index 00000000000..d07284f25f5 --- /dev/null +++ b/.changeset/app-dev-clean-json.md @@ -0,0 +1,5 @@ +--- +'@shopify/cli': minor +--- + +Add typed JSON output to `app dev clean`. diff --git a/docs-shopify.dev/generated/generated_docs_data_v2.json b/docs-shopify.dev/generated/generated_docs_data_v2.json index af6f199146d..c7249ce0e1a 100644 --- a/docs-shopify.dev/generated/generated_docs_data_v2.json +++ b/docs-shopify.dev/generated/generated_docs_data_v2.json @@ -1112,6 +1112,15 @@ "isOptional": true, "environmentValue": "SHOPIFY_FLAG_APP_CONFIG" }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-dev-clean.interface.ts", + "syntaxKind": "PropertySignature", + "name": "-j, --json", + "value": "''", + "description": "Output the result as JSON. Automatically disables color output.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_JSON" + }, { "filePath": "docs-shopify.dev/commands/interfaces/app-dev-clean.interface.ts", "syntaxKind": "PropertySignature", @@ -1122,7 +1131,7 @@ "environmentValue": "SHOPIFY_FLAG_STORE" } ], - "value": "export interface appdevclean {\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. Use with --config to clean up the dev preview for a different app than the one it is linked to.\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 * Store URL. Must be an existing development store.\n * @environment SHOPIFY_FLAG_STORE\n */\n '-s, --store '?: string\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" + "value": "export interface appdevclean {\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. Use with --config to clean up the dev preview for a different app than the one it is linked to.\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 * Store URL. Must be an existing development store.\n * @environment SHOPIFY_FLAG_STORE\n */\n '-s, --store '?: string\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" } }, "appdev": { diff --git a/packages/app/src/cli/commands/app/dev/clean.test.ts b/packages/app/src/cli/commands/app/dev/clean.test.ts new file mode 100644 index 00000000000..d2906b87ab1 --- /dev/null +++ b/packages/app/src/cli/commands/app/dev/clean.test.ts @@ -0,0 +1,186 @@ +import DevClean from './clean.js' +import {linkedAppContext} from '../../../services/app-context.js' +import {storeContext} from '../../../services/store-context.js' +import {appDevCleanJsonOutputSchema} from '../../../services/dev-clean/types.js' +import { + testAppLinked, + testDeveloperPlatformClient, + testOrganization, + testOrganizationApp, + testOrganizationStore, + testProject, +} from '../../../models/app/app.test-data.js' +import {Config} from '@oclif/core' +import {beforeEach, afterEach, expect, test, vi} from 'vitest' +import {runWithCommandEventsForCommand} from '@shopify/cli-kit/node/command-events' +import {AbortError, handler} from '@shopify/cli-kit/node/error' +import {outputInfo, unstyled} from '@shopify/cli-kit/node/output' +import {mockAndCaptureOutput, withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output' + +vi.mock('../../../services/app-context.js') +vi.mock('../../../services/store-context.js') + +function context() { + return { + app: testAppLinked(), + remoteApp: testOrganizationApp({title: 'Test App', apiKey: 'public-client-id'}), + developerPlatformClient: testDeveloperPlatformClient({ + devSessionDelete: vi.fn().mockResolvedValue({devSessionDelete: {userErrors: []}}), + }), + organization: testOrganization(), + specifications: [], + project: testProject(), + activeConfig: {} as never, + } +} + +beforeEach(() => { + vi.mocked(linkedAppContext).mockResolvedValue(context()) + vi.mocked(storeContext).mockResolvedValue(testOrganizationStore({shopDomain: 'test-store.myshopify.com'})) +}) + +afterEach(() => { + mockAndCaptureOutput().clear() + vi.unstubAllEnvs() +}) + +async function runCommand(argv: string[]) { + const command = new DevClean(argv, await Config.load()) + return runWithCommandEventsForCommand(argv, () => command.run()) +} + +test('writes one encoded public result and keeps diagnostics on stderr', async () => { + const appContext = context() + vi.mocked(linkedAppContext).mockImplementation(async () => { + outputInfo('Using the selected app.') + return appContext + }) + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await expect( + runCommand(['--json', '--no-input', '--store', 'HTTPS://TEST-STORE.MYSHOPIFY.COM/admin']), + ).resolves.toEqual({ + app: appContext.app, + }) + expect(JSON.parse(stdout())).toEqual({ + status: 'success', + app: {name: 'Test App', clientId: 'public-client-id'}, + storeDomain: 'test-store.myshopify.com', + }) + expect(stdout()).not.toContain('Dev preview stopped') + expect(stdout()).not.toContain('apiSecretKeys') + expect(JSON.parse(stderr())).toMatchObject({type: 'diagnostic', level: 'info', message: 'Using the selected app.'}) + }) + expect(storeContext).toHaveBeenCalledExactlyOnceWith({ + appContextResult: appContext, + storeFqdn: 'test-store.myshopify.com', + forceReselectStore: false, + }) + expect(appContext.developerPlatformClient.devSessionDelete).toHaveBeenCalledExactlyOnceWith({ + shopFqdn: 'test-store.myshopify.com', + appId: appContext.remoteApp.id, + }) +}) + +test.each([{argv: []}, {argv: ['--no-input']}])('keeps text output without selecting JSON: %j', async ({argv}) => { + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await runCommand([...argv, '--store', 'test-store']) + expect(stdout()).toBe('') + const text = unstyled(stderr()).replace(/│/g, '').replace(/\s+/g, ' ') + expect(text).toContain('Dev preview stopped.') + expect(text).toContain("The dev preview has been stopped on test-store.myshopify.com and the app's active version") + expect(text).toContain('shopify app dev') + }) +}) + +test('JSON output keeps the existing store selection path', async () => { + await withCapturedStandardStreams(async ({stdout}) => { + await runCommand(['--json']) + expect(JSON.parse(stdout()).status).toBe('success') + }) + expect(storeContext).toHaveBeenCalledWith(expect.objectContaining({storeFqdn: undefined, forceReselectStore: false})) +}) + +test.each([ + {response: {devSessionDelete: {userErrors: [{message: 'Preview cannot be stopped', code: 'UPSTREAM_ERROR'}]}}}, + {response: {devSessionDelete: null}}, + {response: {}}, + {response: {devSessionDelete: {userErrors: null}}}, +])('prints one fatal envelope and no result for an invalid deletion: %j', async ({response}) => { + vi.stubEnv('SHOPIFY_FLAG_JSON', '1') + const appContext = context() + vi.mocked(appContext.developerPlatformClient.devSessionDelete).mockResolvedValue(response as never) + vi.mocked(linkedAppContext).mockResolvedValue(appContext) + await withCapturedStandardStreams(async ({stdout, stderr}) => { + try { + await runCommand(['--json']) + throw new Error('Expected the deletion to fail') + } catch (error) { + if (!(error instanceof AbortError)) throw error + await handler(error) + } + const envelope = JSON.parse(stdout()) + expect(envelope.error.type).toBe('abort') + expect(envelope.error.message).toContain('Failed to stop the dev preview:') + const userErrors = response.devSessionDelete?.userErrors + expect(envelope.error.details).toEqual(userErrors ? {userErrors} : {data: response}) + expect(envelope).not.toHaveProperty('status') + expect(stderr()).toBe('') + }) +}) + +test('does not print a result after an API transport failure', async () => { + const appContext = context() + vi.mocked(appContext.developerPlatformClient.devSessionDelete).mockRejectedValue(new Error('Network unavailable')) + vi.mocked(linkedAppContext).mockResolvedValue(appContext) + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await expect(runCommand(['--json'])).rejects.toThrow('Network unavailable') + expect(stdout()).toBe('') + expect(stderr()).toBe('') + }) +}) + +test('preserves selection errors before the deletion starts', async () => { + const appContext = context() + const error = new AbortError('No store was selected.') + vi.mocked(linkedAppContext).mockResolvedValue(appContext) + vi.mocked(storeContext).mockRejectedValue(error) + await withCapturedStandardStreams(async ({stdout}) => { + await expect(runCommand(['--json', '--no-input'])).rejects.toBe(error) + expect(stdout()).toBe('') + }) + expect(appContext.developerPlatformClient.devSessionDelete).not.toHaveBeenCalled() +}) + +test.each([{argv: ['--no-input']}, {argv: ['--json', '--no-input']}])( + 'does not prompt when a store selection is required and input is disabled: %j', + async ({argv}) => { + vi.stubEnv('SHOPIFY_FLAG_NO_INPUT', '1') + const appContext = context() + vi.mocked(linkedAppContext).mockResolvedValue(appContext) + vi.mocked(appContext.developerPlatformClient.devStoresForOrg).mockResolvedValue({ + stores: [ + testOrganizationStore({shopId: '1', shopDomain: 'first-store.myshopify.com'}), + testOrganizationStore({shopId: '2', shopDomain: 'second-store.myshopify.com'}), + ], + hasMorePages: false, + }) + const actual = await vi.importActual( + '../../../services/store-context.js', + ) + vi.mocked(storeContext).mockImplementation(actual.storeContext) + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await expect(runCommand(argv)).rejects.toBeInstanceOf(AbortError) + expect(stdout()).toBe('') + expect(stderr()).toBe('') + }) + expect(appContext.developerPlatformClient.devSessionDelete).not.toHaveBeenCalled() + }, +) + +test('exposes the JSON contract and inherited input flags', () => { + expect(DevClean.jsonOutputSchema).toBe(appDevCleanJsonOutputSchema) + expect(DevClean.flags).toHaveProperty('json') + expect(DevClean.flags).toHaveProperty('no-input') + expect(DevClean.descriptionForHelp()).toContain('`AppDevCleanResult` schema') + expect(DevClean.descriptionForHelp()).toContain('AppDevCleanApp') +}) diff --git a/packages/app/src/cli/commands/app/dev/clean.ts b/packages/app/src/cli/commands/app/dev/clean.ts index c4c78c56772..5bdababf3d3 100644 --- a/packages/app/src/cli/commands/app/dev/clean.ts +++ b/packages/app/src/cli/commands/app/dev/clean.ts @@ -3,7 +3,9 @@ import AppLinkedCommand, {AppLinkedCommandOutput} from '../../../utilities/app-l import {appFlags} from '../../../flags.js' import {storeContext} from '../../../services/store-context.js' import {devClean} from '../../../services/dev-clean.js' -import {globalFlags} from '@shopify/cli-kit/node/cli' +import {appDevCleanJsonOutputSchema} from '../../../services/dev-clean/types.js' +import {renderDevCleanResult} from '../../../services/dev-clean/result.js' +import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli' import {Flags} from '@oclif/core' import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' @@ -15,11 +17,16 @@ export default class DevClean extends AppLinkedCommand { It restores the app's active version to the selected development store. ` + static get jsonOutputSchema() { + return appDevCleanJsonOutputSchema + } + static description = this.descriptionForHelp() static flags = { ...globalFlags, ...appFlags, + ...jsonFlag, 'client-id': Flags.string({ hidden: false, description: @@ -51,7 +58,8 @@ export default class DevClean extends AppLinkedCommand { forceReselectStore: flags.reset, }) - await devClean({appContextResult, store}) + const result = await devClean({appContextResult, store}) + renderDevCleanResult(result, flags.json ? 'json' : 'text') return {app: appContextResult.app} } diff --git a/packages/app/src/cli/services/dev-clean.test.ts b/packages/app/src/cli/services/dev-clean.test.ts index 3d8fe124c37..6959f03e290 100644 --- a/packages/app/src/cli/services/dev-clean.test.ts +++ b/packages/app/src/cli/services/dev-clean.test.ts @@ -1,57 +1,60 @@ import {devClean} from './dev-clean.js' import {LoadedAppContextOutput} from './app-context.js' -import {testDeveloperPlatformClient, testOrganizationStore} from '../models/app/app.test-data.js' -import {renderSuccess} from '@shopify/cli-kit/node/ui' -import {describe, expect, test, vi} from 'vitest' - -vi.mock('@shopify/cli-kit/node/ui') - -const shopDomain = 'test-store.myshopify.com' -const mockStore = testOrganizationStore({shopDomain}) - -const mockOptions = { - appContextResult: { - developerPlatformClient: testDeveloperPlatformClient(), - remoteApp: {id: 'app-id-1', title: 'Test App', apiKey: 'api-key-1'}, - } as unknown as LoadedAppContextOutput, - store: mockStore, +import {testDeveloperPlatformClient, testOrganizationApp, testOrganizationStore} from '../models/app/app.test-data.js' +import {AbortError} from '@shopify/cli-kit/node/error' +import {expect, test, vi} from 'vitest' + +function options(response: unknown = {devSessionDelete: {userErrors: []}}) { + const devSessionDelete = vi.fn().mockResolvedValue(response) + return { + appContextResult: { + developerPlatformClient: testDeveloperPlatformClient({devSessionDelete}), + remoteApp: testOrganizationApp({id: 'app-id-1', title: 'Test App', apiKey: 'public-client-id'}), + } as unknown as LoadedAppContextOutput, + store: testOrganizationStore({shopDomain: 'test-store.myshopify.com'}), + } } -describe('devClean', () => { - test('successfully stops dev preview and renders success message', async () => { - // Given - mockOptions.appContextResult.developerPlatformClient = customDevPlatformClient() - - // When - await devClean(mockOptions) - - // Then - expect(renderSuccess).toHaveBeenCalledWith({ - headline: 'Dev preview stopped.', - body: [ - `The dev preview has been stopped on ${mockStore.shopDomain} and the app's active version has been restored.`, - 'You can start it again with', - {command: 'shopify app dev'}, - ], - }) +test('returns public app and store data after stopping the dev preview', async () => { + const input = options() + await expect(devClean(input)).resolves.toEqual({ + status: 'success', + app: {name: 'Test App', clientId: 'public-client-id'}, + storeDomain: 'test-store.myshopify.com', }) + expect(input.appContextResult.developerPlatformClient.devSessionDelete).toHaveBeenCalledExactlyOnceWith({ + shopFqdn: 'test-store.myshopify.com', + appId: 'app-id-1', + }) +}) - test('throws AbortError when devSessionDelete returns user errors', async () => { - // Given - const errorMessage = 'Failed to stop dev preview' - mockOptions.appContextResult.developerPlatformClient = customDevPlatformClient(errorMessage) - - // When/Then - await expect(devClean(mockOptions)).rejects.toThrow(`Failed to stop the dev preview: ${errorMessage}`) +test('retains user error text and native details', async () => { + const userErrors = [{message: 'First error'}, {message: 'Second error', code: 'UPSTREAM_ERROR'}] + await expect(devClean(options({devSessionDelete: {userErrors}}))).rejects.toMatchObject({ + message: 'Failed to stop the dev preview: First error\nSecond error', + details: {userErrors}, }) }) -function customDevPlatformClient(devSessionDeleteError?: string) { - return testDeveloperPlatformClient({ - devSessionDelete: vi.fn().mockResolvedValue({ - devSessionDelete: { - userErrors: devSessionDeleteError ? [{message: devSessionDeleteError}] : [], - }, - }), +test.each([ + null, + {}, + {devSessionDelete: null}, + {devSessionDelete: {}}, + {devSessionDelete: {userErrors: null}}, + {devSessionDelete: {userErrors: 'not an array'}}, + {devSessionDelete: {userErrors: [null]}}, + {devSessionDelete: {userErrors: [{message: null}]}}, +])('rejects a missing or malformed deletion response: %j', async (response) => { + await expect(devClean(options(response))).rejects.toMatchObject({ + message: 'Failed to stop the dev preview: the server returned an invalid response.', + details: {data: response}, }) -} +}) + +test('propagates the original API failure', async () => { + const error = new AbortError('API unavailable') + const input = options() + vi.mocked(input.appContextResult.developerPlatformClient.devSessionDelete).mockRejectedValue(error) + await expect(devClean(input)).rejects.toBe(error) +}) diff --git a/packages/app/src/cli/services/dev-clean.ts b/packages/app/src/cli/services/dev-clean.ts index ee3118b365e..475a8d81061 100644 --- a/packages/app/src/cli/services/dev-clean.ts +++ b/packages/app/src/cli/services/dev-clean.ts @@ -1,6 +1,6 @@ import {LoadedAppContextOutput} from './app-context.js' +import {AppDevCleanResult} from './dev-clean/types.js' import {OrganizationStore} from '../models/organization.js' -import {renderSuccess} from '@shopify/cli-kit/node/ui' import {AbortError} from '@shopify/cli-kit/node/error' interface DevCleanOptions { @@ -8,23 +8,29 @@ interface DevCleanOptions { store: OrganizationStore } -export async function devClean(options: DevCleanOptions) { +export async function devClean(options: DevCleanOptions): Promise { const client = options.appContextResult.developerPlatformClient const remoteApp = options.appContextResult.remoteApp const result = await client.devSessionDelete({shopFqdn: options.store.shopDomain, appId: remoteApp.id}) - if (result.devSessionDelete?.userErrors.length) { - const errors = result.devSessionDelete.userErrors.map((error) => error.message).join('\n') - throw new AbortError(`Failed to stop the dev preview: ${errors}`) + const userErrors = result?.devSessionDelete?.userErrors + if (!Array.isArray(userErrors) || userErrors.some((error) => typeof error?.message !== 'string')) { + const error = new AbortError('Failed to stop the dev preview: the server returned an invalid response.') + error.details = {data: result} + throw error } - renderSuccess({ - headline: 'Dev preview stopped.', - body: [ - `The dev preview has been stopped on ${options.store.shopDomain} and the app's active version has been restored.`, - 'You can start it again with', - {command: 'shopify app dev'}, - ], - }) + if (userErrors.length) { + const errors = userErrors.map((error) => error.message).join('\n') + const error = new AbortError(`Failed to stop the dev preview: ${errors}`) + error.details = {userErrors} + throw error + } + + return { + status: 'success', + app: {name: remoteApp.title, clientId: remoteApp.apiKey}, + storeDomain: options.store.shopDomain, + } } diff --git a/packages/app/src/cli/services/dev-clean/result.test.ts b/packages/app/src/cli/services/dev-clean/result.test.ts new file mode 100644 index 00000000000..8ab0e745e98 --- /dev/null +++ b/packages/app/src/cli/services/dev-clean/result.test.ts @@ -0,0 +1,23 @@ +import {renderDevCleanResult} from './result.js' +import {AppDevCleanResult} from './types.js' +import {renderSuccess} from '@shopify/cli-kit/node/ui' +import {expect, test, vi} from 'vitest' + +vi.mock('@shopify/cli-kit/node/ui') + +test('keeps the dev preview success message', () => { + const result: AppDevCleanResult = { + status: 'success', + app: {name: 'Test App', clientId: 'public-client-id'}, + storeDomain: 'test-store.myshopify.com', + } + renderDevCleanResult(result, 'text') + expect(renderSuccess).toHaveBeenCalledExactlyOnceWith({ + headline: 'Dev preview stopped.', + body: [ + "The dev preview has been stopped on test-store.myshopify.com and the app's active version has been restored.", + 'You can start it again with', + {command: 'shopify app dev'}, + ], + }) +}) diff --git a/packages/app/src/cli/services/dev-clean/result.ts b/packages/app/src/cli/services/dev-clean/result.ts new file mode 100644 index 00000000000..f479cf9eb00 --- /dev/null +++ b/packages/app/src/cli/services/dev-clean/result.ts @@ -0,0 +1,19 @@ +import {appDevCleanJsonOutputSchema, AppDevCleanResult} from './types.js' +import {outputResult} from '@shopify/cli-kit/node/output' +import {renderSuccess} from '@shopify/cli-kit/node/ui' + +export function renderDevCleanResult(result: AppDevCleanResult, format: 'json' | 'text'): void { + if (format === 'json') { + outputResult(appDevCleanJsonOutputSchema.encode(result)) + return + } + + renderSuccess({ + headline: 'Dev preview stopped.', + body: [ + `The dev preview has been stopped on ${result.storeDomain} and the app's active version has been restored.`, + 'You can start it again with', + {command: 'shopify app dev'}, + ], + }) +} diff --git a/packages/app/src/cli/services/dev-clean/types.test.ts b/packages/app/src/cli/services/dev-clean/types.test.ts new file mode 100644 index 00000000000..92ebf3d572c --- /dev/null +++ b/packages/app/src/cli/services/dev-clean/types.test.ts @@ -0,0 +1,32 @@ +import {appDevCleanJsonOutputSchema} from './types.js' +import {expect, test} from 'vitest' + +const result = { + status: 'success', + app: {name: 'Test App', clientId: 'public-client-id'}, + storeDomain: 'test-store.myshopify.com', +} as const + +test('encodes the public success result', () => { + expect(JSON.parse(appDevCleanJsonOutputSchema.encode(result))).toEqual(result) +}) + +test('accepts the canonical local development hostname', () => { + const localResult = {...result, storeDomain: 'test-store.my.shop.dev'} + expect(JSON.parse(appDevCleanJsonOutputSchema.encode(localResult))).toEqual(localResult) +}) + +test.each([ + {...result, status: 'failed'}, + {...result, changed: true}, + {...result, app: {...result.app, clientId: ''}}, + {...result, app: {...result.app, name: null}}, + {...result, app: {...result.app, apiSecretKeys: ['private']}}, + {...result, storeDomain: 'https://test-store.myshopify.com'}, + {...result, storeDomain: 'test-store.myshopify.com/admin'}, + {...result, storeDomain: 'test-store.myshopify.com:443'}, + {...result, storeDomain: 'test-store..myshopify.com'}, + {...result, storeDomain: 'Test-Store.myshopify.com'}, +])('rejects an invalid public result: %j', (input) => { + expect(() => appDevCleanJsonOutputSchema.validate(input)).toThrow() +}) diff --git a/packages/app/src/cli/services/dev-clean/types.ts b/packages/app/src/cli/services/dev-clean/types.ts new file mode 100644 index 00000000000..79f7ca7c46b --- /dev/null +++ b/packages/app/src/cli/services/dev-clean/types.ts @@ -0,0 +1,21 @@ +import {defineJsonOutputSchema, type InferJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema' +import {zod} from '@shopify/cli-kit/node/schema' + +const appSchema = zod.object({name: zod.string(), clientId: zod.string().min(1)}).strict() + +export const appDevCleanJsonOutputSchema = defineJsonOutputSchema({ + name: 'AppDevCleanResult', + schema: zod + .object({ + status: zod.literal('success'), + app: appSchema, + storeDomain: zod + .string() + .regex(/^(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/) + .describe('The canonical store hostname, without a scheme or path.'), + }) + .strict(), + definitions: {AppDevCleanApp: appSchema}, +}) + +export type AppDevCleanResult = InferJsonOutputSchema diff --git a/packages/cli/README.md b/packages/cli/README.md index 997b0a3b74e..50a8bbec554 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -854,14 +854,18 @@ Cleans up the dev preview from the selected store. ``` USAGE - $ shopify app dev clean [--auth-alias ] [--client-id ] [--json-schema] [--no-color] [--no-input] - [--path ] [--reset | -c ] [-s ] [--verbose] + $ shopify app dev clean [--auth-alias ] [--client-id ] [-j] [--json-schema] [--no-color] + [--no-input] [--path ] [--reset | -c ] [-s ] [--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] + -s, --store= Store URL. Must be an existing development store. [env: SHOPIFY_FLAG_STORE] @@ -905,6 +909,58 @@ DESCRIPTION Stop the dev preview that was started with `shopify app dev`. It restores the app's active version to the selected development store. + + + Use `--json-schema` to print the result, error, and event schemas. + + Output from `--json` conforms to the `AppDevCleanResult` schema. + + ```json + { + "type": "object", + "properties": { + "status": { + "type": "string", + "const": "success" + }, + "app": { + "$ref": "#/definitions/AppDevCleanApp" + }, + "storeDomain": { + "type": "string", + "pattern": "^(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$", + "description": "The canonical store hostname, without a scheme or path." + } + }, + "required": [ + "status", + "app", + "storeDomain" + ], + "additionalProperties": false, + "title": "AppDevCleanResult", + "definitions": { + "AppDevCleanApp": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "clientId": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "name", + "clientId" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" + } + ``` ``` ## `shopify app env pull` diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index ea9b7cfb824..c6c7842b83d 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -1351,7 +1351,7 @@ "args": { }, "customPluginName": "@shopify/app", - "description": "Stop the dev preview that was started with `shopify app dev`.\n\n It restores the app's active version to the selected development store.\n ", + "description": "Stop the dev preview that was started with `shopify app dev`.\n\n It restores the app's active version to the selected development store.\n \n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `AppDevCleanResult` schema.\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"success\"\n },\n \"app\": {\n \"$ref\": \"#/definitions/AppDevCleanApp\"\n },\n \"storeDomain\": {\n \"type\": \"string\",\n \"pattern\": \"^(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\\\\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$\",\n \"description\": \"The canonical store hostname, without a scheme or path.\"\n }\n },\n \"required\": [\n \"status\",\n \"app\",\n \"storeDomain\"\n ],\n \"additionalProperties\": false,\n \"title\": \"AppDevCleanResult\",\n \"definitions\": {\n \"AppDevCleanApp\": {\n \"type\": \"object\",\n \"properties\": {\n \"name\": {\n \"type\": \"string\"\n },\n \"clientId\": {\n \"type\": \"string\",\n \"minLength\": 1\n }\n },\n \"required\": [\n \"name\",\n \"clientId\"\n ],\n \"additionalProperties\": false\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", "descriptionWithMarkdown": "Stop the dev preview that was started with `shopify app dev`.\n\n It restores the app's active version to the selected development store.\n ", "flags": { "auth-alias": { @@ -1381,6 +1381,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 adf82f9988f..137908bf8ac 100644 --- a/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js +++ b/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js @@ -11,7 +11,6 @@ const commandExceptions = [ 'packages/app/src/cli/commands/app/config/use.ts', 'packages/app/src/cli/commands/app/config/validate.ts', 'packages/app/src/cli/commands/app/deploy.ts', - 'packages/app/src/cli/commands/app/dev/clean.ts', 'packages/app/src/cli/commands/app/env/pull.ts', 'packages/app/src/cli/commands/app/env/show.ts', 'packages/app/src/cli/commands/app/execute.ts', From 75760cae06992c56dcb3276c934191664830047f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Wed, 7 Oct 2026 14:40:04 +0200 Subject: [PATCH 2/5] Focus dev clean JSON tests on changed output --- .../src/cli/commands/app/dev/clean.test.ts | 173 +++--------------- .../app/src/cli/services/dev-clean.test.ts | 36 +--- .../src/cli/services/dev-clean/types.test.ts | 32 ---- 3 files changed, 37 insertions(+), 204 deletions(-) delete mode 100644 packages/app/src/cli/services/dev-clean/types.test.ts diff --git a/packages/app/src/cli/commands/app/dev/clean.test.ts b/packages/app/src/cli/commands/app/dev/clean.test.ts index d2906b87ab1..ccab2fa1723 100644 --- a/packages/app/src/cli/commands/app/dev/clean.test.ts +++ b/packages/app/src/cli/commands/app/dev/clean.test.ts @@ -1,7 +1,6 @@ import DevClean from './clean.js' import {linkedAppContext} from '../../../services/app-context.js' import {storeContext} from '../../../services/store-context.js' -import {appDevCleanJsonOutputSchema} from '../../../services/dev-clean/types.js' import { testAppLinked, testDeveloperPlatformClient, @@ -11,176 +10,60 @@ import { testProject, } from '../../../models/app/app.test-data.js' import {Config} from '@oclif/core' -import {beforeEach, afterEach, expect, test, vi} from 'vitest' +import {afterEach, expect, test, vi} from 'vitest' import {runWithCommandEventsForCommand} from '@shopify/cli-kit/node/command-events' -import {AbortError, handler} from '@shopify/cli-kit/node/error' -import {outputInfo, unstyled} from '@shopify/cli-kit/node/output' -import {mockAndCaptureOutput, withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output' +import {handler} from '@shopify/cli-kit/node/error' +import {outputInfo} from '@shopify/cli-kit/node/output' +import {withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output' vi.mock('../../../services/app-context.js') vi.mock('../../../services/store-context.js') -function context() { - return { +afterEach(() => vi.unstubAllEnvs()) + +async function runCommand(response: unknown = {devSessionDelete: {userErrors: []}}, diagnostic = false) { + const appContext = { app: testAppLinked(), remoteApp: testOrganizationApp({title: 'Test App', apiKey: 'public-client-id'}), - developerPlatformClient: testDeveloperPlatformClient({ - devSessionDelete: vi.fn().mockResolvedValue({devSessionDelete: {userErrors: []}}), - }), + developerPlatformClient: testDeveloperPlatformClient({devSessionDelete: vi.fn().mockResolvedValue(response)}), organization: testOrganization(), specifications: [], project: testProject(), activeConfig: {} as never, } -} - -beforeEach(() => { - vi.mocked(linkedAppContext).mockResolvedValue(context()) - vi.mocked(storeContext).mockResolvedValue(testOrganizationStore({shopDomain: 'test-store.myshopify.com'})) -}) - -afterEach(() => { - mockAndCaptureOutput().clear() - vi.unstubAllEnvs() -}) - -async function runCommand(argv: string[]) { - const command = new DevClean(argv, await Config.load()) - return runWithCommandEventsForCommand(argv, () => command.run()) -} - -test('writes one encoded public result and keeps diagnostics on stderr', async () => { - const appContext = context() vi.mocked(linkedAppContext).mockImplementation(async () => { - outputInfo('Using the selected app.') + if (diagnostic) outputInfo('Using the selected app.') return appContext }) + vi.mocked(storeContext).mockResolvedValue(testOrganizationStore({shopDomain: 'test-store.myshopify.com'})) + const command = new DevClean(['--json'], await Config.load()) + return runWithCommandEventsForCommand(['--json'], () => command.run()) +} + +test('writes one public JSON result with diagnostics on stderr', async () => { await withCapturedStandardStreams(async ({stdout, stderr}) => { - await expect( - runCommand(['--json', '--no-input', '--store', 'HTTPS://TEST-STORE.MYSHOPIFY.COM/admin']), - ).resolves.toEqual({ - app: appContext.app, - }) + await runCommand(undefined, true) expect(JSON.parse(stdout())).toEqual({ status: 'success', app: {name: 'Test App', clientId: 'public-client-id'}, storeDomain: 'test-store.myshopify.com', }) - expect(stdout()).not.toContain('Dev preview stopped') - expect(stdout()).not.toContain('apiSecretKeys') - expect(JSON.parse(stderr())).toMatchObject({type: 'diagnostic', level: 'info', message: 'Using the selected app.'}) - }) - expect(storeContext).toHaveBeenCalledExactlyOnceWith({ - appContextResult: appContext, - storeFqdn: 'test-store.myshopify.com', - forceReselectStore: false, - }) - expect(appContext.developerPlatformClient.devSessionDelete).toHaveBeenCalledExactlyOnceWith({ - shopFqdn: 'test-store.myshopify.com', - appId: appContext.remoteApp.id, - }) -}) - -test.each([{argv: []}, {argv: ['--no-input']}])('keeps text output without selecting JSON: %j', async ({argv}) => { - await withCapturedStandardStreams(async ({stdout, stderr}) => { - await runCommand([...argv, '--store', 'test-store']) - expect(stdout()).toBe('') - const text = unstyled(stderr()).replace(/│/g, '').replace(/\s+/g, ' ') - expect(text).toContain('Dev preview stopped.') - expect(text).toContain("The dev preview has been stopped on test-store.myshopify.com and the app's active version") - expect(text).toContain('shopify app dev') + expect(JSON.parse(stderr())).toMatchObject({type: 'diagnostic', message: 'Using the selected app.'}) }) }) -test('JSON output keeps the existing store selection path', async () => { - await withCapturedStandardStreams(async ({stdout}) => { - await runCommand(['--json']) - expect(JSON.parse(stdout()).status).toBe('success') - }) - expect(storeContext).toHaveBeenCalledWith(expect.objectContaining({storeFqdn: undefined, forceReselectStore: false})) -}) - -test.each([ - {response: {devSessionDelete: {userErrors: [{message: 'Preview cannot be stopped', code: 'UPSTREAM_ERROR'}]}}}, - {response: {devSessionDelete: null}}, - {response: {}}, - {response: {devSessionDelete: {userErrors: null}}}, -])('prints one fatal envelope and no result for an invalid deletion: %j', async ({response}) => { +test('writes one fatal JSON document when deletion returns user errors', async () => { vi.stubEnv('SHOPIFY_FLAG_JSON', '1') - const appContext = context() - vi.mocked(appContext.developerPlatformClient.devSessionDelete).mockResolvedValue(response as never) - vi.mocked(linkedAppContext).mockResolvedValue(appContext) - await withCapturedStandardStreams(async ({stdout, stderr}) => { - try { - await runCommand(['--json']) - throw new Error('Expected the deletion to fail') - } catch (error) { - if (!(error instanceof AbortError)) throw error - await handler(error) - } - const envelope = JSON.parse(stdout()) - expect(envelope.error.type).toBe('abort') - expect(envelope.error.message).toContain('Failed to stop the dev preview:') - const userErrors = response.devSessionDelete?.userErrors - expect(envelope.error.details).toEqual(userErrors ? {userErrors} : {data: response}) - expect(envelope).not.toHaveProperty('status') - expect(stderr()).toBe('') - }) -}) - -test('does not print a result after an API transport failure', async () => { - const appContext = context() - vi.mocked(appContext.developerPlatformClient.devSessionDelete).mockRejectedValue(new Error('Network unavailable')) - vi.mocked(linkedAppContext).mockResolvedValue(appContext) + const userErrors = [{message: 'Preview cannot be stopped', code: 'UPSTREAM_ERROR'}] await withCapturedStandardStreams(async ({stdout, stderr}) => { - await expect(runCommand(['--json'])).rejects.toThrow('Network unavailable') - expect(stdout()).toBe('') + await runCommand({devSessionDelete: {userErrors}}).catch(handler) + expect(JSON.parse(stdout())).toEqual({ + error: { + type: 'abort', + message: 'Failed to stop the dev preview: Preview cannot be stopped', + details: {userErrors}, + }, + }) expect(stderr()).toBe('') }) }) - -test('preserves selection errors before the deletion starts', async () => { - const appContext = context() - const error = new AbortError('No store was selected.') - vi.mocked(linkedAppContext).mockResolvedValue(appContext) - vi.mocked(storeContext).mockRejectedValue(error) - await withCapturedStandardStreams(async ({stdout}) => { - await expect(runCommand(['--json', '--no-input'])).rejects.toBe(error) - expect(stdout()).toBe('') - }) - expect(appContext.developerPlatformClient.devSessionDelete).not.toHaveBeenCalled() -}) - -test.each([{argv: ['--no-input']}, {argv: ['--json', '--no-input']}])( - 'does not prompt when a store selection is required and input is disabled: %j', - async ({argv}) => { - vi.stubEnv('SHOPIFY_FLAG_NO_INPUT', '1') - const appContext = context() - vi.mocked(linkedAppContext).mockResolvedValue(appContext) - vi.mocked(appContext.developerPlatformClient.devStoresForOrg).mockResolvedValue({ - stores: [ - testOrganizationStore({shopId: '1', shopDomain: 'first-store.myshopify.com'}), - testOrganizationStore({shopId: '2', shopDomain: 'second-store.myshopify.com'}), - ], - hasMorePages: false, - }) - const actual = await vi.importActual( - '../../../services/store-context.js', - ) - vi.mocked(storeContext).mockImplementation(actual.storeContext) - await withCapturedStandardStreams(async ({stdout, stderr}) => { - await expect(runCommand(argv)).rejects.toBeInstanceOf(AbortError) - expect(stdout()).toBe('') - expect(stderr()).toBe('') - }) - expect(appContext.developerPlatformClient.devSessionDelete).not.toHaveBeenCalled() - }, -) - -test('exposes the JSON contract and inherited input flags', () => { - expect(DevClean.jsonOutputSchema).toBe(appDevCleanJsonOutputSchema) - expect(DevClean.flags).toHaveProperty('json') - expect(DevClean.flags).toHaveProperty('no-input') - expect(DevClean.descriptionForHelp()).toContain('`AppDevCleanResult` schema') - expect(DevClean.descriptionForHelp()).toContain('AppDevCleanApp') -}) diff --git a/packages/app/src/cli/services/dev-clean.test.ts b/packages/app/src/cli/services/dev-clean.test.ts index 6959f03e290..ea026c5f9b5 100644 --- a/packages/app/src/cli/services/dev-clean.test.ts +++ b/packages/app/src/cli/services/dev-clean.test.ts @@ -1,7 +1,6 @@ import {devClean} from './dev-clean.js' import {LoadedAppContextOutput} from './app-context.js' import {testDeveloperPlatformClient, testOrganizationApp, testOrganizationStore} from '../models/app/app.test-data.js' -import {AbortError} from '@shopify/cli-kit/node/error' import {expect, test, vi} from 'vitest' function options(response: unknown = {devSessionDelete: {userErrors: []}}) { @@ -22,10 +21,6 @@ test('returns public app and store data after stopping the dev preview', async ( app: {name: 'Test App', clientId: 'public-client-id'}, storeDomain: 'test-store.myshopify.com', }) - expect(input.appContextResult.developerPlatformClient.devSessionDelete).toHaveBeenCalledExactlyOnceWith({ - shopFqdn: 'test-store.myshopify.com', - appId: 'app-id-1', - }) }) test('retains user error text and native details', async () => { @@ -36,25 +31,12 @@ test('retains user error text and native details', async () => { }) }) -test.each([ - null, - {}, - {devSessionDelete: null}, - {devSessionDelete: {}}, - {devSessionDelete: {userErrors: null}}, - {devSessionDelete: {userErrors: 'not an array'}}, - {devSessionDelete: {userErrors: [null]}}, - {devSessionDelete: {userErrors: [{message: null}]}}, -])('rejects a missing or malformed deletion response: %j', async (response) => { - await expect(devClean(options(response))).rejects.toMatchObject({ - message: 'Failed to stop the dev preview: the server returned an invalid response.', - details: {data: response}, - }) -}) - -test('propagates the original API failure', async () => { - const error = new AbortError('API unavailable') - const input = options() - vi.mocked(input.appContextResult.developerPlatformClient.devSessionDelete).mockRejectedValue(error) - await expect(devClean(input)).rejects.toBe(error) -}) +test.each([null, {devSessionDelete: {userErrors: [{message: null}]}}])( + 'rejects a missing or malformed deletion response: %j', + async (response) => { + await expect(devClean(options(response))).rejects.toMatchObject({ + message: 'Failed to stop the dev preview: the server returned an invalid response.', + details: {data: response}, + }) + }, +) diff --git a/packages/app/src/cli/services/dev-clean/types.test.ts b/packages/app/src/cli/services/dev-clean/types.test.ts deleted file mode 100644 index 92ebf3d572c..00000000000 --- a/packages/app/src/cli/services/dev-clean/types.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import {appDevCleanJsonOutputSchema} from './types.js' -import {expect, test} from 'vitest' - -const result = { - status: 'success', - app: {name: 'Test App', clientId: 'public-client-id'}, - storeDomain: 'test-store.myshopify.com', -} as const - -test('encodes the public success result', () => { - expect(JSON.parse(appDevCleanJsonOutputSchema.encode(result))).toEqual(result) -}) - -test('accepts the canonical local development hostname', () => { - const localResult = {...result, storeDomain: 'test-store.my.shop.dev'} - expect(JSON.parse(appDevCleanJsonOutputSchema.encode(localResult))).toEqual(localResult) -}) - -test.each([ - {...result, status: 'failed'}, - {...result, changed: true}, - {...result, app: {...result.app, clientId: ''}}, - {...result, app: {...result.app, name: null}}, - {...result, app: {...result.app, apiSecretKeys: ['private']}}, - {...result, storeDomain: 'https://test-store.myshopify.com'}, - {...result, storeDomain: 'test-store.myshopify.com/admin'}, - {...result, storeDomain: 'test-store.myshopify.com:443'}, - {...result, storeDomain: 'test-store..myshopify.com'}, - {...result, storeDomain: 'Test-Store.myshopify.com'}, -])('rejects an invalid public result: %j', (input) => { - expect(() => appDevCleanJsonOutputSchema.validate(input)).toThrow() -}) From c02e6c71d57c0142d2df390b65aa16c0be4a7c01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Wed, 7 Oct 2026 15:27:34 +0200 Subject: [PATCH 3/5] Keep dev clean JSON store domains canonical --- packages/app/src/cli/commands/app/dev.test.ts | 5 ++++ .../app/src/cli/services/dev-clean.test.ts | 2 +- packages/app/src/cli/services/dev-clean.ts | 6 ++--- .../src/cli/services/dev-clean/result.test.ts | 27 ++++++++++++++++--- .../app/src/cli/services/dev-clean/result.ts | 14 +++++++--- .../app/src/cli/services/dev-clean/types.ts | 7 +++-- packages/cli/README.md | 13 ++++++--- packages/cli/oclif.manifest.json | 2 +- 8 files changed, 59 insertions(+), 17 deletions(-) diff --git a/packages/app/src/cli/commands/app/dev.test.ts b/packages/app/src/cli/commands/app/dev.test.ts index f14870d69fa..098e329f753 100644 --- a/packages/app/src/cli/commands/app/dev.test.ts +++ b/packages/app/src/cli/commands/app/dev.test.ts @@ -50,6 +50,11 @@ describe('app dev command', () => { vi.mocked(linkedAppContext).mockResolvedValue(appContextResult) vi.mocked(storeContext).mockResolvedValue(store) + vi.mocked(devClean).mockResolvedValue({ + status: 'success', + app: {name: appContextResult.remoteApp.title, clientId: appContextResult.remoteApp.apiKey}, + storeHostname: store.shopDomain, + }) return {store} } diff --git a/packages/app/src/cli/services/dev-clean.test.ts b/packages/app/src/cli/services/dev-clean.test.ts index ea026c5f9b5..dcb319540e9 100644 --- a/packages/app/src/cli/services/dev-clean.test.ts +++ b/packages/app/src/cli/services/dev-clean.test.ts @@ -19,7 +19,7 @@ test('returns public app and store data after stopping the dev preview', async ( await expect(devClean(input)).resolves.toEqual({ status: 'success', app: {name: 'Test App', clientId: 'public-client-id'}, - storeDomain: 'test-store.myshopify.com', + storeHostname: 'test-store.myshopify.com', }) }) diff --git a/packages/app/src/cli/services/dev-clean.ts b/packages/app/src/cli/services/dev-clean.ts index 475a8d81061..018980ffc6a 100644 --- a/packages/app/src/cli/services/dev-clean.ts +++ b/packages/app/src/cli/services/dev-clean.ts @@ -1,5 +1,5 @@ import {LoadedAppContextOutput} from './app-context.js' -import {AppDevCleanResult} from './dev-clean/types.js' +import {DevCleanResult} from './dev-clean/types.js' import {OrganizationStore} from '../models/organization.js' import {AbortError} from '@shopify/cli-kit/node/error' @@ -8,7 +8,7 @@ interface DevCleanOptions { store: OrganizationStore } -export async function devClean(options: DevCleanOptions): Promise { +export async function devClean(options: DevCleanOptions): Promise { const client = options.appContextResult.developerPlatformClient const remoteApp = options.appContextResult.remoteApp @@ -31,6 +31,6 @@ export async function devClean(options: DevCleanOptions): Promise { - const result: AppDevCleanResult = { + const result: DevCleanResult = { status: 'success', app: {name: 'Test App', clientId: 'public-client-id'}, - storeDomain: 'test-store.myshopify.com', + storeHostname: 'test-store.myshopify.com', } renderDevCleanResult(result, 'text') expect(renderSuccess).toHaveBeenCalledExactlyOnceWith({ @@ -21,3 +22,23 @@ test('keeps the dev preview success message', () => { ], }) }) + +test('an unknown canonical store domain is null in JSON and retains its hostname in text', async () => { + const result: DevCleanResult = { + status: 'success', + app: {name: 'Test App', clientId: 'public-client-id'}, + storeHostname: 'test-store.my.shop.dev', + } + await withCapturedStandardStreams(async ({stdout}) => { + renderDevCleanResult(result, 'json') + expect(JSON.parse(stdout())).toMatchObject({storeDomain: null}) + }) + renderDevCleanResult(result, 'text') + expect(renderSuccess).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.arrayContaining([ + "The dev preview has been stopped on test-store.my.shop.dev and the app's active version has been restored.", + ]), + }), + ) +}) diff --git a/packages/app/src/cli/services/dev-clean/result.ts b/packages/app/src/cli/services/dev-clean/result.ts index f479cf9eb00..bdbe8ca08df 100644 --- a/packages/app/src/cli/services/dev-clean/result.ts +++ b/packages/app/src/cli/services/dev-clean/result.ts @@ -1,17 +1,23 @@ -import {appDevCleanJsonOutputSchema, AppDevCleanResult} from './types.js' +import {appDevCleanJsonOutputSchema, AppDevCleanResult, DevCleanResult} from './types.js' import {outputResult} from '@shopify/cli-kit/node/output' import {renderSuccess} from '@shopify/cli-kit/node/ui' +import {extractMyshopifyHandle} from '@shopify/cli-kit/common/url' -export function renderDevCleanResult(result: AppDevCleanResult, format: 'json' | 'text'): void { +export function renderDevCleanResult(result: DevCleanResult, format: 'json' | 'text'): void { if (format === 'json') { - outputResult(appDevCleanJsonOutputSchema.encode(result)) + const publicResult: AppDevCleanResult = { + status: result.status, + app: result.app, + storeDomain: extractMyshopifyHandle(result.storeHostname) ? result.storeHostname : null, + } + outputResult(appDevCleanJsonOutputSchema.encode(publicResult)) return } renderSuccess({ headline: 'Dev preview stopped.', body: [ - `The dev preview has been stopped on ${result.storeDomain} and the app's active version has been restored.`, + `The dev preview has been stopped on ${result.storeHostname} and the app's active version has been restored.`, 'You can start it again with', {command: 'shopify app dev'}, ], diff --git a/packages/app/src/cli/services/dev-clean/types.ts b/packages/app/src/cli/services/dev-clean/types.ts index 79f7ca7c46b..d3f9be17d1a 100644 --- a/packages/app/src/cli/services/dev-clean/types.ts +++ b/packages/app/src/cli/services/dev-clean/types.ts @@ -11,11 +11,14 @@ export const appDevCleanJsonOutputSchema = defineJsonOutputSchema({ app: appSchema, storeDomain: zod .string() - .regex(/^(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/) - .describe('The canonical store hostname, without a scheme or path.'), + .regex(/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.myshopify\.com$/) + .nullable() + .describe('The canonical *.myshopify.com hostname, or null when unknown.'), }) .strict(), definitions: {AppDevCleanApp: appSchema}, }) export type AppDevCleanResult = InferJsonOutputSchema + +export type DevCleanResult = Omit & {storeHostname: string} diff --git a/packages/cli/README.md b/packages/cli/README.md index 50a8bbec554..c9112cbfba7 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -927,9 +927,16 @@ DESCRIPTION "$ref": "#/definitions/AppDevCleanApp" }, "storeDomain": { - "type": "string", - "pattern": "^(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$", - "description": "The canonical store hostname, without a scheme or path." + "anyOf": [ + { + "type": "string", + "pattern": "^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\\.myshopify\\.com$" + }, + { + "type": "null" + } + ], + "description": "The canonical *.myshopify.com hostname, or null when unknown." } }, "required": [ diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index c6c7842b83d..89b3e3418da 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -1351,7 +1351,7 @@ "args": { }, "customPluginName": "@shopify/app", - "description": "Stop the dev preview that was started with `shopify app dev`.\n\n It restores the app's active version to the selected development store.\n \n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `AppDevCleanResult` schema.\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"success\"\n },\n \"app\": {\n \"$ref\": \"#/definitions/AppDevCleanApp\"\n },\n \"storeDomain\": {\n \"type\": \"string\",\n \"pattern\": \"^(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\\\\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$\",\n \"description\": \"The canonical store hostname, without a scheme or path.\"\n }\n },\n \"required\": [\n \"status\",\n \"app\",\n \"storeDomain\"\n ],\n \"additionalProperties\": false,\n \"title\": \"AppDevCleanResult\",\n \"definitions\": {\n \"AppDevCleanApp\": {\n \"type\": \"object\",\n \"properties\": {\n \"name\": {\n \"type\": \"string\"\n },\n \"clientId\": {\n \"type\": \"string\",\n \"minLength\": 1\n }\n },\n \"required\": [\n \"name\",\n \"clientId\"\n ],\n \"additionalProperties\": false\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", + "description": "Stop the dev preview that was started with `shopify app dev`.\n\n It restores the app's active version to the selected development store.\n \n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `AppDevCleanResult` schema.\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"success\"\n },\n \"app\": {\n \"$ref\": \"#/definitions/AppDevCleanApp\"\n },\n \"storeDomain\": {\n \"anyOf\": [\n {\n \"type\": \"string\",\n \"pattern\": \"^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\\\\.myshopify\\\\.com$\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"description\": \"The canonical *.myshopify.com hostname, or null when unknown.\"\n }\n },\n \"required\": [\n \"status\",\n \"app\",\n \"storeDomain\"\n ],\n \"additionalProperties\": false,\n \"title\": \"AppDevCleanResult\",\n \"definitions\": {\n \"AppDevCleanApp\": {\n \"type\": \"object\",\n \"properties\": {\n \"name\": {\n \"type\": \"string\"\n },\n \"clientId\": {\n \"type\": \"string\",\n \"minLength\": 1\n }\n },\n \"required\": [\n \"name\",\n \"clientId\"\n ],\n \"additionalProperties\": false\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", "descriptionWithMarkdown": "Stop the dev preview that was started with `shopify app dev`.\n\n It restores the app's active version to the selected development store.\n ", "flags": { "auth-alias": { From 7afd19d33e16075a62d7e646f2e4a99a2825d6c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Wed, 7 Oct 2026 16:00:41 +0200 Subject: [PATCH 4/5] Declare dev clean JSON schema as a static field --- bin/prettify-manifests.js | 7 ++++++- packages/app/src/cli/commands/app/dev/clean.ts | 4 +--- packages/eslint-plugin-cli/rules/command-json-output.js | 4 +++- .../eslint-plugin-cli/rules/command-json-output.test.js | 4 +--- 4 files changed, 11 insertions(+), 8 deletions(-) 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) } diff --git a/packages/app/src/cli/commands/app/dev/clean.ts b/packages/app/src/cli/commands/app/dev/clean.ts index 5bdababf3d3..e5054492159 100644 --- a/packages/app/src/cli/commands/app/dev/clean.ts +++ b/packages/app/src/cli/commands/app/dev/clean.ts @@ -17,9 +17,7 @@ export default class DevClean extends AppLinkedCommand { It restores the app's active version to the selected development store. ` - static get jsonOutputSchema() { - return appDevCleanJsonOutputSchema - } + static jsonOutputSchema = appDevCleanJsonOutputSchema static description = this.descriptionForHelp() diff --git a/packages/eslint-plugin-cli/rules/command-json-output.js b/packages/eslint-plugin-cli/rules/command-json-output.js index a6493344283..4d41adab57a 100644 --- a/packages/eslint-plugin-cli/rules/command-json-output.js +++ b/packages/eslint-plugin-cli/rules/command-json-output.js @@ -62,7 +62,9 @@ function repositoryPath(filename) { function hasJsonOutputSchema(classMembers) { return classMembers.some( (member) => - member.type === 'MethodDefinition' && member.kind === 'get' && isStaticMemberNamed(member, 'jsonOutputSchema'), + isStaticMemberNamed(member, 'jsonOutputSchema') && + ((member.type === 'MethodDefinition' && member.kind === 'get') || + (member.type === 'PropertyDefinition' && Boolean(member.value))), ) } diff --git a/packages/eslint-plugin-cli/rules/command-json-output.test.js b/packages/eslint-plugin-cli/rules/command-json-output.test.js index 5eaa3d46144..b095246b579 100644 --- a/packages/eslint-plugin-cli/rules/command-json-output.test.js +++ b/packages/eslint-plugin-cli/rules/command-json-output.test.js @@ -37,9 +37,7 @@ ruleTester.run('command-json-output', rule, { code: ` export default class WidgetDelete extends Command { static flags = {...globalFlags, ...jsonFlag} - static get jsonOutputSchema() { - return widgetDeleteJsonOutputSchema - } + static jsonOutputSchema = widgetDeleteJsonOutputSchema } `, }, From feb5579997ca832c1689abfacd24e43b1b46becb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Thu, 8 Oct 2026 10:39:54 +0200 Subject: [PATCH 5/5] Simplify dev clean store domain schema --- packages/app/src/cli/services/dev-clean/types.ts | 6 +----- packages/cli/README.md | 11 +++-------- packages/cli/oclif.manifest.json | 2 +- 3 files changed, 5 insertions(+), 14 deletions(-) diff --git a/packages/app/src/cli/services/dev-clean/types.ts b/packages/app/src/cli/services/dev-clean/types.ts index d3f9be17d1a..12c81ed0945 100644 --- a/packages/app/src/cli/services/dev-clean/types.ts +++ b/packages/app/src/cli/services/dev-clean/types.ts @@ -9,11 +9,7 @@ export const appDevCleanJsonOutputSchema = defineJsonOutputSchema({ .object({ status: zod.literal('success'), app: appSchema, - storeDomain: zod - .string() - .regex(/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.myshopify\.com$/) - .nullable() - .describe('The canonical *.myshopify.com hostname, or null when unknown.'), + storeDomain: zod.string().nullable().describe('The canonical *.myshopify.com hostname, or null when unknown.'), }) .strict(), definitions: {AppDevCleanApp: appSchema}, diff --git a/packages/cli/README.md b/packages/cli/README.md index c9112cbfba7..b35895e3e57 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -927,14 +927,9 @@ DESCRIPTION "$ref": "#/definitions/AppDevCleanApp" }, "storeDomain": { - "anyOf": [ - { - "type": "string", - "pattern": "^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\\.myshopify\\.com$" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ], "description": "The canonical *.myshopify.com hostname, or null when unknown." } diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 89b3e3418da..549730004f0 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -1351,7 +1351,7 @@ "args": { }, "customPluginName": "@shopify/app", - "description": "Stop the dev preview that was started with `shopify app dev`.\n\n It restores the app's active version to the selected development store.\n \n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `AppDevCleanResult` schema.\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"success\"\n },\n \"app\": {\n \"$ref\": \"#/definitions/AppDevCleanApp\"\n },\n \"storeDomain\": {\n \"anyOf\": [\n {\n \"type\": \"string\",\n \"pattern\": \"^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\\\\.myshopify\\\\.com$\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"description\": \"The canonical *.myshopify.com hostname, or null when unknown.\"\n }\n },\n \"required\": [\n \"status\",\n \"app\",\n \"storeDomain\"\n ],\n \"additionalProperties\": false,\n \"title\": \"AppDevCleanResult\",\n \"definitions\": {\n \"AppDevCleanApp\": {\n \"type\": \"object\",\n \"properties\": {\n \"name\": {\n \"type\": \"string\"\n },\n \"clientId\": {\n \"type\": \"string\",\n \"minLength\": 1\n }\n },\n \"required\": [\n \"name\",\n \"clientId\"\n ],\n \"additionalProperties\": false\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", + "description": "Stop the dev preview that was started with `shopify app dev`.\n\n It restores the app's active version to the selected development store.\n \n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `AppDevCleanResult` schema.\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"success\"\n },\n \"app\": {\n \"$ref\": \"#/definitions/AppDevCleanApp\"\n },\n \"storeDomain\": {\n \"type\": [\n \"string\",\n \"null\"\n ],\n \"description\": \"The canonical *.myshopify.com hostname, or null when unknown.\"\n }\n },\n \"required\": [\n \"status\",\n \"app\",\n \"storeDomain\"\n ],\n \"additionalProperties\": false,\n \"title\": \"AppDevCleanResult\",\n \"definitions\": {\n \"AppDevCleanApp\": {\n \"type\": \"object\",\n \"properties\": {\n \"name\": {\n \"type\": \"string\"\n },\n \"clientId\": {\n \"type\": \"string\",\n \"minLength\": 1\n }\n },\n \"required\": [\n \"name\",\n \"clientId\"\n ],\n \"additionalProperties\": false\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", "descriptionWithMarkdown": "Stop the dev preview that was started with `shopify app dev`.\n\n It restores the app's active version to the selected development store.\n ", "flags": { "auth-alias": {