diff --git a/packages/cli/json-output-command-exceptions.cjs b/packages/cli/json-output-command-exceptions.cjs index a97d9d7eb1..821fbc9220 100644 --- a/packages/cli/json-output-command-exceptions.cjs +++ b/packages/cli/json-output-command-exceptions.cjs @@ -2,7 +2,6 @@ const commandExceptions = [ // Existing finite commands awaiting migration. Remove entries as they adopt typed JSON output. // Do not add new finite commands to this section. - 'packages/cli/src/commands/hydrogen/customer-account-push.ts', 'packages/cli/src/commands/hydrogen/deploy.ts', 'packages/cli/src/commands/hydrogen/env/list.ts', 'packages/cli/src/commands/hydrogen/env/pull.ts', diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 9049bd0dd1..b89cc16bad 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -243,7 +243,7 @@ "hydrogen:customer-account-push": { "aliases": [], "args": {}, - "description": "Push project configuration to admin", + "description": "Push project configuration to admin\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `HydrogenCustomerAccountPushResult` schema.\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"storefrontId\": {\n \"type\": \"string\"\n },\n \"redirectUri\": {\n \"type\": \"string\"\n },\n \"javascriptOrigin\": {\n \"type\": \"string\"\n },\n \"logoutUri\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"storefrontId\",\n \"redirectUri\",\n \"javascriptOrigin\",\n \"logoutUri\"\n ],\n \"additionalProperties\": false,\n \"title\": \"HydrogenCustomerAccountPushResult\",\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", "flags": { "json-schema": { "description": "Print the command's JSON schemas.", @@ -252,6 +252,15 @@ "allowNo": false, "type": "boolean" }, + "json": { + "char": "j", + "description": "Output the result as JSON. Automatically disables color output.", + "env": "SHOPIFY_FLAG_JSON", + "hidden": false, + "name": "json", + "allowNo": false, + "type": "boolean" + }, "path": { "description": "The path to the directory of the Hydrogen storefront. Defaults to the current directory where the command is run.", "env": "SHOPIFY_HYDROGEN_FLAG_PATH", @@ -305,6 +314,7 @@ "pluginType": "core", "strict": true, "enableJsonFlag": false, + "descriptionWithMarkdown": "Push project configuration to admin", "isESM": true, "relativePath": [ "dist", diff --git a/packages/cli/src/commands/hydrogen/customer-account-push.test.ts b/packages/cli/src/commands/hydrogen/customer-account-push.test.ts index 01721922b7..83dbeb74a7 100644 --- a/packages/cli/src/commands/hydrogen/customer-account-push.test.ts +++ b/packages/cli/src/commands/hydrogen/customer-account-push.test.ts @@ -2,7 +2,11 @@ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import {type AdminSession, login} from '../../lib/auth.js'; import {replaceCustomerApplicationUrls} from '../../lib/graphql/admin/customer-application-update.js'; import {setCustomerAccountConfig} from '../../lib/shopify-config.js'; -import {runCustomerAccountPush} from './customer-account-push.js'; +import CustomerAccountPush, { + runCustomerAccountPush, + pushCustomerAccountConfig, +} from './customer-account-push.js'; +import {captureJsonOutput} from '../../../tests/output.js'; vi.mock('../../lib/auth.js'); vi.mock('../../lib/graphql/admin/customer-application-update.js'); @@ -47,6 +51,71 @@ describe('runCustomerAccountPush', () => { vi.clearAllMocks(); }); + it('writes configured URLs as one JSON document through the command', async () => { + const command = new CustomerAccountPush([], {} as any); + const parse = vi.spyOn(command as any, 'parse').mockResolvedValue({ + flags: { + json: true, + 'dev-origin': DEV_ORIGIN, + 'javascript-origin': JAVASCRIPT_ORIGIN, + 'relative-redirect-uri': '/custom/callback', + 'relative-logout-uri': '/logout', + }, + }); + try { + const {stdout, stderr} = await captureJsonOutput(() => command.run()); + expect(JSON.parse(stdout)).toEqual({ + storefrontId: STOREFRONT_ID, + redirectUri: `${DEV_ORIGIN}/custom/callback`, + javascriptOrigin: JAVASCRIPT_ORIGIN, + logoutUri: `${DEV_ORIGIN}/logout`, + }); + expect(stderr).toBe(''); + } finally { + parse.mockRestore(); + } + }); + + it('retains the dev server cleanup callback', async () => { + const cleanup = await runCustomerAccountPush({devOrigin: DEV_ORIGIN}); + vi.mocked(replaceCustomerApplicationUrls).mockClear(); + await cleanup?.(); + expect(replaceCustomerApplicationUrls).toHaveBeenCalledWith( + ADMIN_SESSION, + STOREFRONT_ID, + { + redirectUri: {removeRegex: `${DEV_ORIGIN}/account/authorize`}, + javascriptOrigin: {removeRegex: DEV_ORIGIN}, + logoutUris: {removeRegex: DEV_ORIGIN}, + }, + ); + }); + + it('propagates mutation errors without producing a success result', async () => { + vi.mocked(replaceCustomerApplicationUrls).mockResolvedValue({ + success: false, + userErrors: [], + }); + await expect( + pushCustomerAccountConfig({devOrigin: DEV_ORIGIN}), + ).rejects.toThrow('setup update fail'); + }); + + it('validates the public schema and advertises it in help', () => { + expect(CustomerAccountPush.flags.json).toBeDefined(); + expect(CustomerAccountPush.description).toContain( + CustomerAccountPush.jsonOutputSchema.name, + ); + expect(() => + CustomerAccountPush.jsonOutputSchema.encode({ + storefrontId: 1, + redirectUri: DEV_ORIGIN, + javascriptOrigin: DEV_ORIGIN, + logoutUri: DEV_ORIGIN, + } as any), + ).toThrow(); + }); + it('defaults the JavaScript origin to the development origin', async () => { await runCustomerAccountPush({devOrigin: DEV_ORIGIN}); diff --git a/packages/cli/src/commands/hydrogen/customer-account-push.ts b/packages/cli/src/commands/hydrogen/customer-account-push.ts index e395ff9ecd..bd305dc8a6 100644 --- a/packages/cli/src/commands/hydrogen/customer-account-push.ts +++ b/packages/cli/src/commands/hydrogen/customer-account-push.ts @@ -1,3 +1,6 @@ +import {writeJsonResult} from '../../lib/json-output.js'; +import {jsonFlag} from '@shopify/cli-kit/node/cli'; +import {customerAccountPushJsonOutputSchema} from '../../lib/customer-account/types.js'; import Command from '@shopify/cli-kit/node/base-command'; import {Flags} from '@oclif/core'; import {AbortError} from '@shopify/cli-kit/node/error'; @@ -14,9 +17,16 @@ import { import {replaceCustomerApplicationUrls} from '../../lib/graphql/admin/customer-application-update.js'; export default class CustomerAccountPush extends Command { - static description = 'Push project configuration to admin'; + static get jsonOutputSchema(): typeof customerAccountPushJsonOutputSchema { + return customerAccountPushJsonOutputSchema; + } + + static descriptionWithMarkdown = 'Push project configuration to admin'; + + static description = this.descriptionForHelp(); static flags = { + ...jsonFlag, ...commonFlags.path, 'storefront-id': Flags.string({ description: @@ -42,11 +52,27 @@ export default class CustomerAccountPush extends Command { async run(): Promise { const {flags} = await this.parse(CustomerAccountPush); - await runCustomerAccountPush({...flagsToCamelObject(flags)}); + const outcome = await pushCustomerAccountConfig({ + ...flagsToCamelObject(flags), + redirectUriRelativeUrl: flags['relative-redirect-uri'], + logoutUriRelativeUrl: flags['relative-logout-uri'], + }); + writeJsonResult( + customerAccountPushJsonOutputSchema, + outcome.result, + flags.json, + ); } } -export async function runCustomerAccountPush({ +/** Compatibility entry point used by the dev server to remove temporary URLs. */ +export async function runCustomerAccountPush( + options: Parameters[0], +) { + return (await pushCustomerAccountConfig(options)).cleanup; +} + +export async function pushCustomerAccountConfig({ path: root = process.cwd(), storefrontId: storefrontIdFromFlag, devOrigin, @@ -79,10 +105,6 @@ export async function runCustomerAccountPush({ ? new URL(logoutUriRelativeUrl, devOrigin).toString() : devOrigin; - if (!redirectUri && !javascriptOrigin && !logoutUri) { - return; - } - const {session, config} = await login(root); const customerAccountConfig = config?.storefront?.customerAccountConfig; const {success, userErrors} = await replaceCustomerApplicationUrls( @@ -118,12 +140,20 @@ export async function runCustomerAccountPush({ logoutUri, }); - return () => - cleanupCustomerApplicationUrls(session, storefrontId, { + return { + result: { + storefrontId, redirectUri, javascriptOrigin, logoutUri, - }); + } satisfies import('../../lib/customer-account/types.js').CustomerAccountPushResult, + cleanup: () => + cleanupCustomerApplicationUrls(session, storefrontId, { + redirectUri, + javascriptOrigin, + logoutUri, + }), + }; } catch (error: any) { let confidentialAccessFound = false; diff --git a/packages/cli/src/lib/customer-account/types.ts b/packages/cli/src/lib/customer-account/types.ts new file mode 100644 index 0000000000..110adce1cb --- /dev/null +++ b/packages/cli/src/lib/customer-account/types.ts @@ -0,0 +1,18 @@ +import { + defineJsonOutputSchema, + type InferJsonOutputSchema, +} from '@shopify/cli-kit/node/json-output-schema'; +import {zod} from '@shopify/cli-kit/node/schema'; + +export const customerAccountPushJsonOutputSchema = defineJsonOutputSchema({ + name: 'HydrogenCustomerAccountPushResult', + schema: zod.object({ + storefrontId: zod.string(), + redirectUri: zod.string(), + javascriptOrigin: zod.string(), + logoutUri: zod.string(), + }), +}); +export type CustomerAccountPushResult = InferJsonOutputSchema< + typeof customerAccountPushJsonOutputSchema +>;