Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/app-dev-clean-json.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/cli': minor
---

Add typed JSON output to `app dev clean`.
7 changes: 6 additions & 1 deletion bin/prettify-manifests.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
11 changes: 10 additions & 1 deletion docs-shopify.dev/generated/generated_docs_data_v2.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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 <value>'?: 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 <value>'?: string\n\n /**\n * The name of the app configuration.\n * @environment SHOPIFY_FLAG_APP_CONFIG\n */\n '-c, --config <value>'?: 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 <value>'?: 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 <value>'?: 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 <value>'?: 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 <value>'?: string\n\n /**\n * The name of the app configuration.\n * @environment SHOPIFY_FLAG_APP_CONFIG\n */\n '-c, --config <value>'?: 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 <value>'?: 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 <value>'?: string\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}"
}
},
"appdev": {
Expand Down
5 changes: 5 additions & 0 deletions packages/app/src/cli/commands/app/dev.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}
}
Expand Down
69 changes: 69 additions & 0 deletions packages/app/src/cli/commands/app/dev/clean.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import DevClean from './clean.js'
import {linkedAppContext} from '../../../services/app-context.js'
import {storeContext} from '../../../services/store-context.js'
import {
testAppLinked,
testDeveloperPlatformClient,
testOrganization,
testOrganizationApp,
testOrganizationStore,
testProject,
} from '../../../models/app/app.test-data.js'
import {Config} from '@oclif/core'
import {afterEach, expect, test, vi} from 'vitest'
import {runWithCommandEventsForCommand} from '@shopify/cli-kit/node/command-events'
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')

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(response)}),
organization: testOrganization(),
specifications: [],
project: testProject(),
activeConfig: {} as never,
}
vi.mocked(linkedAppContext).mockImplementation(async () => {
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 runCommand(undefined, true)
expect(JSON.parse(stdout())).toEqual({
status: 'success',
app: {name: 'Test App', clientId: 'public-client-id'},
storeDomain: 'test-store.myshopify.com',
})
expect(JSON.parse(stderr())).toMatchObject({type: 'diagnostic', message: 'Using the selected app.'})
})
})

test('writes one fatal JSON document when deletion returns user errors', async () => {
vi.stubEnv('SHOPIFY_FLAG_JSON', '1')
const userErrors = [{message: 'Preview cannot be stopped', code: 'UPSTREAM_ERROR'}]
await withCapturedStandardStreams(async ({stdout, stderr}) => {
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('')
})
})
10 changes: 8 additions & 2 deletions packages/app/src/cli/commands/app/dev/clean.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -15,11 +17,14 @@ export default class DevClean extends AppLinkedCommand {
It restores the app's active version to the selected development store.
`

static jsonOutputSchema = appDevCleanJsonOutputSchema

static description = this.descriptionForHelp()

static flags = {
...globalFlags,
...appFlags,
...jsonFlag,
'client-id': Flags.string({
hidden: false,
description:
Expand Down Expand Up @@ -51,7 +56,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}
}
Expand Down
81 changes: 33 additions & 48 deletions packages/app/src/cli/services/dev-clean.test.ts
Original file line number Diff line number Diff line change
@@ -1,57 +1,42 @@
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 {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'},
storeHostname: 'test-store.myshopify.com',
})
})

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: {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},
})
},
)
32 changes: 19 additions & 13 deletions packages/app/src/cli/services/dev-clean.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,36 @@
import {LoadedAppContextOutput} from './app-context.js'
import {DevCleanResult} 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 {
appContextResult: LoadedAppContextOutput
store: OrganizationStore
}

export async function devClean(options: DevCleanOptions) {
export async function devClean(options: DevCleanOptions): Promise<DevCleanResult> {
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},
storeHostname: options.store.shopDomain,
}
}
44 changes: 44 additions & 0 deletions packages/app/src/cli/services/dev-clean/result.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import {renderDevCleanResult} from './result.js'
import {DevCleanResult} from './types.js'
import {renderSuccess} from '@shopify/cli-kit/node/ui'
import {withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output'
import {expect, test, vi} from 'vitest'

vi.mock('@shopify/cli-kit/node/ui')

test('keeps the dev preview success message', () => {
const result: DevCleanResult = {
status: 'success',
app: {name: 'Test App', clientId: 'public-client-id'},
storeHostname: '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'},
],
})
})

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.",
]),
}),
)
})
25 changes: 25 additions & 0 deletions packages/app/src/cli/services/dev-clean/result.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
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: DevCleanResult, format: 'json' | 'text'): void {
if (format === 'json') {
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.storeHostname} and the app's active version has been restored.`,
'You can start it again with',
{command: 'shopify app dev'},
],
})
}
20 changes: 20 additions & 0 deletions packages/app/src/cli/services/dev-clean/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
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().nullable().describe('The canonical *.myshopify.com hostname, or null when unknown.'),
})
.strict(),
definitions: {AppDevCleanApp: appSchema},
})

export type AppDevCleanResult = InferJsonOutputSchema<typeof appDevCleanJsonOutputSchema>

export type DevCleanResult = Omit<AppDevCleanResult, 'storeDomain'> & {storeHostname: string}
Loading
Loading