diff --git a/packages/cli/json-output-command-exceptions.cjs b/packages/cli/json-output-command-exceptions.cjs index 963a995641..834ed51025 100644 --- a/packages/cli/json-output-command-exceptions.cjs +++ b/packages/cli/json-output-command-exceptions.cjs @@ -2,17 +2,11 @@ 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/init.ts', - 'packages/cli/src/commands/hydrogen/link.ts', - 'packages/cli/src/commands/hydrogen/list.ts', - 'packages/cli/src/commands/hydrogen/login.ts', - 'packages/cli/src/commands/hydrogen/logout.ts', 'packages/cli/src/commands/hydrogen/setup.ts', 'packages/cli/src/commands/hydrogen/setup/css.ts', 'packages/cli/src/commands/hydrogen/setup/markets.ts', 'packages/cli/src/commands/hydrogen/setup/vite.ts', 'packages/cli/src/commands/hydrogen/shortcut.ts', - 'packages/cli/src/commands/hydrogen/unlink.ts', 'packages/cli/src/commands/hydrogen/upgrade.ts', // Streaming commands without a single finite result. diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 2d8bbe156e..646f5f900c 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -1203,7 +1203,7 @@ "hydrogen:init": { "aliases": [], "args": {}, - "description": "Creates a new Hydrogen storefront.", + "description": "Creates a new Hydrogen storefront.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `HydrogenInitResult` schema.\n\n```json\n{\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"location\": {\n \"type\": \"string\"\n },\n \"name\": {\n \"type\": \"string\"\n },\n \"directory\": {\n \"type\": \"string\"\n },\n \"storefrontTitle\": {\n \"type\": \"string\"\n },\n \"language\": {\n \"type\": \"string\",\n \"enum\": [\n \"js\",\n \"ts\"\n ]\n },\n \"packageManager\": {\n \"type\": \"string\"\n },\n \"depsInstalled\": {\n \"type\": \"boolean\"\n },\n \"cssStrategy\": {\n \"type\": \"string\"\n },\n \"i18n\": {\n \"type\": \"string\"\n },\n \"routes\": {\n \"type\": \"object\",\n \"additionalProperties\": {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n ]\n }\n },\n \"failures\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\",\n \"enum\": [\n \"dependencies\",\n \"markets\",\n \"routes\"\n ]\n }\n }\n },\n \"required\": [\n \"location\",\n \"name\",\n \"directory\",\n \"packageManager\",\n \"depsInstalled\",\n \"failures\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"null\"\n }\n ],\n \"title\": \"HydrogenInitResult\",\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", "flags": { "json-schema": { "description": "Print the command's JSON schemas.", @@ -1212,6 +1212,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" + }, "force": { "char": "f", "description": "Overwrites the destination directory and files if they already exist.", @@ -1330,7 +1339,7 @@ "hydrogen:link": { "aliases": [], "args": {}, - "description": "Link a local project to one of your shop's Hydrogen storefronts.", + "description": "Links your local development environment to a remote Hydrogen storefront. You can link an unlimited number of development environments to a single Hydrogen storefront.\n\n Linking to a Hydrogen storefront enables you to run \"dev\" (https://shopify.dev/docs/api/shopify-cli/hydrogen/hydrogen-dev) and automatically inject your linked Hydrogen storefront's environment variables directly into the server runtime.\n\n After you run the `link` command, you can access the \"env list\" (https://shopify.dev/docs/api/shopify-cli/hydrogen/hydrogen-env-list), \"env pull\" (https://shopify.dev/docs/api/shopify-cli/hydrogen/hydrogen-env-pull), and \"unlink\" (https://shopify.dev/docs/api/shopify-cli/hydrogen/hydrogen-unlink) commands.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `HydrogenLinkResult` schema.\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"shop\": {\n \"type\": \"string\"\n },\n \"storefront\": {\n \"anyOf\": [\n {\n \"$ref\": \"#/definitions/Storefront\"\n },\n {\n \"type\": \"null\"\n }\n ]\n }\n },\n \"required\": [\n \"shop\",\n \"storefront\"\n ],\n \"additionalProperties\": false,\n \"title\": \"HydrogenLinkResult\",\n \"definitions\": {\n \"Storefront\": {\n \"type\": \"object\",\n \"properties\": {\n \"id\": {\n \"type\": \"string\"\n },\n \"title\": {\n \"type\": \"string\"\n },\n \"productionUrl\": {\n \"type\": [\n \"string\",\n \"null\"\n ]\n },\n \"parsedId\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"id\",\n \"title\"\n ],\n \"additionalProperties\": false\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", "flags": { "json-schema": { "description": "Print the command's JSON schemas.", @@ -1339,6 +1348,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" + }, "force": { "char": "f", "description": "Overwrites the destination directory and files if they already exist.", @@ -1418,7 +1436,7 @@ "hydrogen:list": { "aliases": [], "args": {}, - "description": "Returns a list of Hydrogen storefronts available on a given shop.", + "description": "Lists all remote Hydrogen storefronts available to link to your local development environment.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `HydrogenListResult` schema.\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"shop\": {\n \"type\": \"string\"\n },\n \"storefronts\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/Storefront\"\n }\n }\n },\n \"required\": [\n \"shop\",\n \"storefronts\"\n ],\n \"additionalProperties\": false,\n \"title\": \"HydrogenListResult\",\n \"definitions\": {\n \"Storefront\": {\n \"type\": \"object\",\n \"properties\": {\n \"id\": {\n \"type\": \"string\"\n },\n \"title\": {\n \"type\": \"string\"\n },\n \"productionUrl\": {\n \"type\": [\n \"string\",\n \"null\"\n ]\n },\n \"parsedId\": {\n \"type\": \"string\"\n },\n \"currentProductionDeployment\": {\n \"anyOf\": [\n {\n \"$ref\": \"#/definitions/Deployment\"\n },\n {\n \"type\": \"null\"\n }\n ]\n }\n },\n \"required\": [\n \"id\",\n \"title\",\n \"currentProductionDeployment\"\n ],\n \"additionalProperties\": false\n },\n \"Deployment\": {\n \"type\": \"object\",\n \"properties\": {\n \"id\": {\n \"type\": \"string\"\n },\n \"createdAt\": {\n \"type\": \"string\"\n },\n \"commitMessage\": {\n \"type\": [\n \"string\",\n \"null\"\n ]\n }\n },\n \"required\": [\n \"id\",\n \"createdAt\",\n \"commitMessage\"\n ],\n \"additionalProperties\": false\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", "flags": { "json-schema": { "description": "Print the command's JSON schemas.", @@ -1427,6 +1445,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", @@ -1456,7 +1483,7 @@ "hydrogen:login": { "aliases": [], "args": {}, - "description": "Login to your Shopify account.", + "description": "Logs in to the specified shop and saves the shop domain to the project.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `HydrogenLoginResult` schema.\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"shop\": {\n \"type\": \"string\"\n },\n \"shopName\": {\n \"type\": \"string\"\n },\n \"email\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"shop\",\n \"shopName\",\n \"email\"\n ],\n \"additionalProperties\": false,\n \"title\": \"HydrogenLoginResult\",\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", "flags": { "json-schema": { "description": "Print the command's JSON schemas.", @@ -1465,6 +1492,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", @@ -1503,7 +1539,7 @@ "hydrogen:logout": { "aliases": [], "args": {}, - "description": "Logout of your local session.", + "description": "Log out from the current shop.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `HydrogenLogoutResult` schema.\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"loggedOut\": {\n \"type\": \"boolean\",\n \"const\": true\n }\n },\n \"required\": [\n \"loggedOut\"\n ],\n \"additionalProperties\": false,\n \"title\": \"HydrogenLogoutResult\",\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", "flags": { "json-schema": { "description": "Print the command's JSON schemas.", @@ -1512,6 +1548,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", @@ -1937,7 +1982,7 @@ "hydrogen:unlink": { "aliases": [], "args": {}, - "description": "Unlink a local project from a Hydrogen storefront.", + "description": "Unlinks your local development environment from a remote Hydrogen storefront.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `HydrogenUnlinkResult` schema.\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"unlinked\": {\n \"type\": \"boolean\"\n },\n \"storefront\": {\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"id\": {\n \"type\": \"string\"\n },\n \"title\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"id\",\n \"title\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"null\"\n }\n ]\n }\n },\n \"required\": [\n \"unlinked\",\n \"storefront\"\n ],\n \"additionalProperties\": false,\n \"title\": \"HydrogenUnlinkResult\",\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", "flags": { "json-schema": { "description": "Print the command's JSON schemas.", @@ -1946,6 +1991,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", diff --git a/packages/cli/src/commands/hydrogen/init.ts b/packages/cli/src/commands/hydrogen/init.ts index 2672fc336f..8880acaa63 100644 --- a/packages/cli/src/commands/hydrogen/init.ts +++ b/packages/cli/src/commands/hydrogen/init.ts @@ -1,3 +1,5 @@ +import {jsonFlag} from '@shopify/cli-kit/node/cli'; +import {initJsonOutputSchema} from '../../lib/onboarding/types.js'; import Command from '@shopify/cli-kit/node/base-command'; import {Flags} from '@oclif/core'; import {AbortError} from '@shopify/cli-kit/node/error'; @@ -19,9 +21,14 @@ import {LANGUAGES} from '../../lib/onboarding/common.js'; const FLAG_MAP = {f: 'force'} as Record; export default class Init extends Command { + static get jsonOutputSchema(): typeof initJsonOutputSchema { + return initJsonOutputSchema; + } + static descriptionWithMarkdown = 'Creates a new Hydrogen storefront.'; - static description = 'Creates a new Hydrogen storefront.'; + static description = this.descriptionForHelp(); static flags = { + ...jsonFlag, ...commonFlags.force, path: Flags.string({ description: 'The path to the directory of the new Hydrogen storefront.', diff --git a/packages/cli/src/commands/hydrogen/link.ts b/packages/cli/src/commands/hydrogen/link.ts index 1edf7dcf29..dfa2b088fd 100644 --- a/packages/cli/src/commands/hydrogen/link.ts +++ b/packages/cli/src/commands/hydrogen/link.ts @@ -1,3 +1,6 @@ +import {writeJsonResult} from '../../lib/json-output.js'; +import {jsonFlag} from '@shopify/cli-kit/node/cli'; +import {linkJsonOutputSchema} from '../../lib/storefronts/types.js'; import {Flags} from '@oclif/core'; import Command from '@shopify/cli-kit/node/base-command'; import {basename} from '@shopify/cli-kit/node/path'; @@ -28,16 +31,20 @@ import { } from '../../lib/onboarding/common.js'; export default class Link extends Command { + static get jsonOutputSchema(): typeof linkJsonOutputSchema { + return linkJsonOutputSchema; + } + static descriptionWithMarkdown = `Links your local development environment to a remote Hydrogen storefront. You can link an unlimited number of development environments to a single Hydrogen storefront. Linking to a Hydrogen storefront enables you to run [dev](https://shopify.dev/docs/api/shopify-cli/hydrogen/hydrogen-dev) and automatically inject your linked Hydrogen storefront's environment variables directly into the server runtime. After you run the \`link\` command, you can access the [env list](https://shopify.dev/docs/api/shopify-cli/hydrogen/hydrogen-env-list), [env pull](https://shopify.dev/docs/api/shopify-cli/hydrogen/hydrogen-env-pull), and [unlink](https://shopify.dev/docs/api/shopify-cli/hydrogen/hydrogen-unlink) commands.`; - static description = - "Link a local project to one of your shop's Hydrogen storefronts."; + static description = this.descriptionForHelp(); static flags = { + ...jsonFlag, ...commonFlags.force, ...commonFlags.path, ...commonFlags.shop, @@ -60,7 +67,7 @@ export default class Link extends Command { async run(): Promise { const {flags} = await this.parse(Link); - await runLink(flagsToCamelObject(flags)); + await runLink(flagsToCamelObject(flags), flags.json); } } @@ -73,14 +80,17 @@ export interface LinkStorefrontArguments { name?: string; } -export async function runLink({ - createStorefront: flagCreateStorefront, - force, - path: root = process.cwd(), - shop, - storefront: flagStorefront, - name, -}: LinkStorefrontArguments) { +export async function runLink( + { + createStorefront: flagCreateStorefront, + force, + path: root = process.cwd(), + shop, + storefront: flagStorefront, + name, + }: LinkStorefrontArguments, + json?: boolean, +) { const [{session, config}, cliCommand] = await Promise.all([ login(root, shop), getCliCommand(), @@ -94,7 +104,9 @@ export async function runLink({ cliCommand, }); - if (!linkedStore) return; + const result = {shop: config.shop!, storefront: linkedStore ?? null}; + if (writeJsonResult(linkJsonOutputSchema, result, json) || !linkedStore) + return result; renderSuccess({ body: [{userInput: linkedStore.title}, 'is now linked'], @@ -106,6 +118,7 @@ export async function runLink({ ], ], }); + return result; } export async function linkStorefront( diff --git a/packages/cli/src/commands/hydrogen/list.ts b/packages/cli/src/commands/hydrogen/list.ts index 656757d1b5..819e515baf 100644 --- a/packages/cli/src/commands/hydrogen/list.ts +++ b/packages/cli/src/commands/hydrogen/list.ts @@ -1,3 +1,6 @@ +import {writeJsonResult} from '../../lib/json-output.js'; +import {jsonFlag} from '@shopify/cli-kit/node/cli'; +import {listJsonOutputSchema} from '../../lib/storefronts/types.js'; import Command from '@shopify/cli-kit/node/base-command'; import {pluralize} from '@shopify/cli-kit/common/string'; import colors from '@shopify/cli-kit/node/colors'; @@ -11,7 +14,6 @@ import {commonFlags} from '../../lib/flags.js'; import {parseGid} from '../../lib/gid.js'; import { type Deployment, - type HydrogenStorefront, getStorefrontsWithDeployment, } from '../../lib/graphql/admin/list-storefronts.js'; import {newHydrogenStorefrontUrl} from '../../lib/admin-urls.js'; @@ -19,19 +21,23 @@ import {login} from '../../lib/auth.js'; import {getCliCommand} from '../../lib/shell.js'; export default class List extends Command { + static get jsonOutputSchema(): typeof listJsonOutputSchema { + return listJsonOutputSchema; + } + static descriptionWithMarkdown = 'Lists all remote Hydrogen storefronts available to link to your local development environment.'; - static description = - 'Returns a list of Hydrogen storefronts available on a given shop.'; + static description = this.descriptionForHelp(); static flags = { + ...jsonFlag, ...commonFlags.path, }; async run(): Promise { const {flags} = await this.parse(List); - await runList(flags); + await runList(flags, flags.json); } } @@ -39,18 +45,34 @@ interface Flags { path?: string; } -export async function runList({path: root = process.cwd()}: Flags) { +export async function listStorefronts({ + path: root = process.cwd(), +}: Flags): Promise { const {session} = await login(root); const storefronts = await getStorefrontsWithDeployment(session); + return {shop: session.storeFqdn, storefronts}; +} + +export async function runList(options: Flags, json?: boolean) { + const result = await listStorefronts(options); + if (!writeJsonResult(listJsonOutputSchema, result, json)) + await renderStorefronts(result, options.path); + return result; +} + +async function renderStorefronts( + {shop, storefronts}: import('../../lib/storefronts/types.js').ListResult, + root?: string, +) { if (storefronts.length > 0) { outputNewline(); outputInfo( pluralizedStorefronts({ storefronts, - shop: session.storeFqdn, + shop, }).toString(), ); @@ -84,10 +106,10 @@ export async function runList({path: root = process.cwd()}: Flags) { headline: 'Hydrogen storefronts', body: 'There are no Hydrogen storefronts on your Shop.', nextSteps: [ - `Ensure you are logged in to the correct shop (currently: ${session.storeFqdn})`, + `Ensure you are logged in to the correct shop (currently: ${shop})`, `Create a new Hydrogen storefront: Run \`${await getCliCommand( root, - )} link\` or visit ${newHydrogenStorefrontUrl(session)}`, + )} link\` or visit ${newHydrogenStorefrontUrl({storeFqdn: shop})}`, ], }); } @@ -120,7 +142,7 @@ const pluralizedStorefronts = ({ storefronts, shop, }: { - storefronts: HydrogenStorefront[]; + storefronts: import('../../lib/storefronts/types.js').ListResult['storefronts']; shop: string; }) => { return pluralize( diff --git a/packages/cli/src/commands/hydrogen/login.ts b/packages/cli/src/commands/hydrogen/login.ts index 9c6f4315b5..4284063135 100644 --- a/packages/cli/src/commands/hydrogen/login.ts +++ b/packages/cli/src/commands/hydrogen/login.ts @@ -1,3 +1,6 @@ +import {writeJsonResult} from '../../lib/json-output.js'; +import {jsonFlag} from '@shopify/cli-kit/node/cli'; +import {loginJsonOutputSchema} from '../../lib/authentication/types.js'; import Command from '@shopify/cli-kit/node/base-command'; import {outputNewline} from '@shopify/cli-kit/node/output'; import {commonFlags} from '../../lib/flags.js'; @@ -5,19 +8,24 @@ import {login, renderLoginSuccess} from '../../lib/auth.js'; import {enhanceAuthLogs} from '../../lib/log.js'; export default class Login extends Command { + static get jsonOutputSchema(): typeof loginJsonOutputSchema { + return loginJsonOutputSchema; + } + static descriptionWithMarkdown = 'Logs in to the specified shop and saves the shop domain to the project.'; - static description = 'Login to your Shopify account.'; + static description = this.descriptionForHelp(); static flags = { + ...jsonFlag, ...commonFlags.path, ...commonFlags.shop, }; async run(): Promise { const {flags} = await this.parse(Login); - await runLogin(flags); + await runLogin(flags, flags.json); } } @@ -26,12 +34,16 @@ interface LoginArguments { shop?: string; } -async function runLogin({ - path: root = process.cwd(), - shop: shopFlag, -}: LoginArguments) { +export async function runLogin( + {path: root = process.cwd(), shop: shopFlag}: LoginArguments, + json?: boolean, +) { outputNewline(); enhanceAuthLogs(true); const {config} = await login(root, shopFlag ?? true); - renderLoginSuccess(config); + const {shop, shopName, email} = config; + const result = {shop, shopName, email}; + if (!writeJsonResult(loginJsonOutputSchema, result, json)) + renderLoginSuccess(config); + return result; } diff --git a/packages/cli/src/commands/hydrogen/logout.ts b/packages/cli/src/commands/hydrogen/logout.ts index 8ef00ff41f..6a80093ece 100644 --- a/packages/cli/src/commands/hydrogen/logout.ts +++ b/packages/cli/src/commands/hydrogen/logout.ts @@ -1,3 +1,6 @@ +import {writeJsonResult} from '../../lib/json-output.js'; +import {jsonFlag} from '@shopify/cli-kit/node/cli'; +import {logoutJsonOutputSchema} from '../../lib/authentication/types.js'; import Command from '@shopify/cli-kit/node/base-command'; import {renderSuccess} from '../../lib/ui.js'; import {outputNewline} from '@shopify/cli-kit/node/output'; @@ -6,17 +9,22 @@ import {commonFlags} from '../../lib/flags.js'; import {logout} from '../../lib/auth.js'; export default class Logout extends Command { + static get jsonOutputSchema(): typeof logoutJsonOutputSchema { + return logoutJsonOutputSchema; + } + static descriptionWithMarkdown = 'Log out from the current shop.'; - static description = 'Logout of your local session.'; + static description = this.descriptionForHelp(); static flags = { + ...jsonFlag, ...commonFlags.path, }; async run(): Promise { const {flags} = await this.parse(Logout); - await runLogout(flags); + await runLogout(flags, flags.json); } } @@ -24,8 +32,14 @@ interface LogoutArguments { path?: string; } -async function runLogout({path: root = process.cwd()}: LogoutArguments) { +export async function runLogout( + {path: root = process.cwd()}: LogoutArguments, + json?: boolean, +) { outputNewline(); await logout(root); - renderSuccess({body: 'You are logged out from Shopify.'}); + const result = {loggedOut: true as const}; + if (!writeJsonResult(logoutJsonOutputSchema, result, json)) + renderSuccess({body: 'You are logged out from Shopify.'}); + return result; } diff --git a/packages/cli/src/commands/hydrogen/project-json.test.ts b/packages/cli/src/commands/hydrogen/project-json.test.ts new file mode 100644 index 0000000000..f528314ab8 --- /dev/null +++ b/packages/cli/src/commands/hydrogen/project-json.test.ts @@ -0,0 +1,146 @@ +import {beforeEach, expect, it, vi} from 'vitest'; +import {captureJsonOutput} from '../../../tests/output.js'; +import {login, logout} from '../../lib/auth.js'; +import {getConfig, unsetStorefront} from '../../lib/shopify-config.js'; +import {getStorefrontsWithDeployment} from '../../lib/graphql/admin/list-storefronts.js'; +import {getStorefronts} from '../../lib/graphql/admin/link-storefront.js'; +import List, {runList} from './list.js'; +import Link, {runLink} from './link.js'; +import Login, {runLogin} from './login.js'; +import Logout, {runLogout} from './logout.js'; +import Unlink, {unlinkStorefront} from './unlink.js'; +import Init from './init.js'; +import {presentTemplateResult} from '../../lib/onboarding/result.js'; + +vi.mock('../../lib/auth.js'); +vi.mock('../../lib/log.js'); +vi.mock('../../lib/shopify-config.js'); +vi.mock('../../lib/graphql/admin/list-storefronts.js'); +vi.mock('../../lib/graphql/admin/link-storefront.js'); +vi.mock('../../lib/shell.js', () => ({ + getCliCommand: () => 'h2', + ALIAS_NAME: 'h2', +})); + +const config = { + shop: 'example.myshopify.com', + shopName: 'Example', + email: 'developer@example.com', +}; +const storefront = { + id: 'gid://shopify/HydrogenStorefront/1', + parsedId: '1', + title: 'Example', + productionUrl: 'https://example.com', +}; +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(login).mockResolvedValue({ + session: {token: 'secret', storeFqdn: config.shop}, + config, + }); + vi.mocked(getStorefrontsWithDeployment).mockResolvedValue([ + {...storefront, currentProductionDeployment: null}, + ]); + vi.mocked(getStorefronts).mockResolvedValue([storefront]); + vi.mocked(getConfig).mockResolvedValue({storefront}); +}); + +it('lists full storefront and deployment fields without authentication tokens', async () => { + const {stdout, stderr} = await captureJsonOutput(() => runList({})); + expect(JSON.parse(stdout)).toEqual({ + shop: config.shop, + storefronts: [{...storefront, currentProductionDeployment: null}], + }); + expect(stdout).not.toContain('secret'); + expect(stderr).toBe(''); +}); + +it('encodes empty storefront collections', async () => { + vi.mocked(getStorefrontsWithDeployment).mockResolvedValue([]); + const {stdout} = await captureJsonOutput(() => runList({})); + expect(JSON.parse(stdout)).toEqual({shop: config.shop, storefronts: []}); +}); + +it('encodes the selected storefront after linking', async () => { + const {stdout} = await captureJsonOutput(() => + runLink({storefront: 'Example', force: true}), + ); + expect(JSON.parse(stdout)).toEqual({shop: config.shop, storefront}); +}); + +it('reports authentication and logout results without exposing session data', async () => { + const loggedIn = await captureJsonOutput(() => runLogin({shop: config.shop})); + expect(JSON.parse(loggedIn.stdout)).toEqual(config); + const loggedOut = await captureJsonOutput(() => + runLogout({path: '/project'}), + ); + expect(JSON.parse(loggedOut.stdout)).toEqual({loggedOut: true}); + expect(logout).toHaveBeenCalledWith('/project'); +}); + +it('reports both unlinking and an already unlinked project', async () => { + const unlinked = await captureJsonOutput(() => + unlinkStorefront({path: '/project'}), + ); + expect(JSON.parse(unlinked.stdout)).toEqual({ + unlinked: true, + storefront: {id: storefront.id, title: storefront.title}, + }); + expect(unsetStorefront).toHaveBeenCalledWith('/project'); + vi.mocked(getConfig).mockResolvedValue({}); + const alreadyUnlinked = await captureJsonOutput(() => unlinkStorefront({})); + expect(JSON.parse(alreadyUnlinked.stdout)).toEqual({ + unlinked: false, + storefront: null, + }); +}); + +it('encodes initialization outcomes and emits partial failures as diagnostics', async () => { + const project = { + location: 'example', + name: 'example', + directory: '/example', + language: 'ts' as const, + packageManager: 'npm' as const, + depsInstalled: false, + cliCommand: 'h2' as const, + depsError: new Error('Install failed'), + }; + const {stdout, stderr} = await captureJsonOutput(() => + presentTemplateResult(project), + ); + expect(JSON.parse(stdout)).toEqual({ + location: 'example', + name: 'example', + directory: '/example', + language: 'ts', + packageManager: 'npm', + depsInstalled: false, + failures: ['dependencies'], + }); + expect(JSON.parse(stderr)).toMatchObject({ + type: 'diagnostic', + message: 'Install failed', + }); + const cancelled = await captureJsonOutput(() => + presentTemplateResult(undefined), + ); + expect(JSON.parse(cancelled.stdout)).toBeNull(); +}); + +it('does not produce success output on authentication failure', async () => { + vi.mocked(login).mockRejectedValue(new Error('Authentication failed')); + const {stdout} = await captureJsonOutput(async () => { + await expect(runLogin({})).rejects.toThrow('Authentication failed'); + }); + expect(stdout).toBe(''); +}); + +it.each([List, Link, Login, Logout, Unlink, Init])( + 'exposes flags, schema and help: %s', + (command) => { + expect(command.flags.json).toBeDefined(); + expect(command.description).toContain(command.jsonOutputSchema.name); + }, +); diff --git a/packages/cli/src/commands/hydrogen/unlink.ts b/packages/cli/src/commands/hydrogen/unlink.ts index 87c10cd938..fcf5ab010f 100644 --- a/packages/cli/src/commands/hydrogen/unlink.ts +++ b/packages/cli/src/commands/hydrogen/unlink.ts @@ -1,3 +1,6 @@ +import {writeJsonResult} from '../../lib/json-output.js'; +import {jsonFlag} from '@shopify/cli-kit/node/cli'; +import {unlinkJsonOutputSchema} from '../../lib/storefronts/types.js'; import Command from '@shopify/cli-kit/node/base-command'; import {renderSuccess} from '../../lib/ui.js'; import {outputWarn} from '@shopify/cli-kit/node/output'; @@ -6,18 +9,23 @@ import {commonFlags} from '../../lib/flags.js'; import {getConfig, unsetStorefront} from '../../lib/shopify-config.js'; export default class Unlink extends Command { + static get jsonOutputSchema(): typeof unlinkJsonOutputSchema { + return unlinkJsonOutputSchema; + } + static descriptionWithMarkdown = 'Unlinks your local development environment from a remote Hydrogen storefront.'; - static description = 'Unlink a local project from a Hydrogen storefront.'; + static description = this.descriptionForHelp(); static flags = { + ...jsonFlag, ...commonFlags.path, }; async run(): Promise { const {flags} = await this.parse(Unlink); - await unlinkStorefront(flags); + await unlinkStorefront(flags, flags.json); } } @@ -25,20 +33,28 @@ export interface LinkFlags { path?: string; } -export async function unlinkStorefront({path}: LinkFlags) { - const actualPath = path ?? process.cwd(); - const {storefront: configStorefront} = await getConfig(actualPath); - - if (!configStorefront) { - outputWarn("This project isn't linked to a Hydrogen storefront."); - return; +export async function unlinkStorefront(options: LinkFlags, json?: boolean) { + const result = await removeStorefrontLink(options); + if (!writeJsonResult(unlinkJsonOutputSchema, result, json)) { + if (result.storefront) + renderSuccess({ + body: ['You are no longer linked to', {bold: result.storefront.title}], + }); + else outputWarn("This project isn't linked to a Hydrogen storefront."); } + return result; +} - const storefrontTitle = configStorefront.title; +export async function removeStorefrontLink({ + path, +}: LinkFlags): Promise { + const actualPath = path ?? process.cwd(); + const {storefront: configStorefront} = await getConfig(actualPath); + if (!configStorefront) return {unlinked: false, storefront: null}; await unsetStorefront(actualPath); - - renderSuccess({ - body: ['You are no longer linked to', {bold: storefrontTitle}], - }); + return { + unlinked: true, + storefront: {id: configStorefront.id, title: configStorefront.title}, + }; } diff --git a/packages/cli/src/lib/admin-urls.ts b/packages/cli/src/lib/admin-urls.ts index 8d9a29b154..e549c6276e 100644 --- a/packages/cli/src/lib/admin-urls.ts +++ b/packages/cli/src/lib/admin-urls.ts @@ -1,6 +1,8 @@ import type {AdminSession} from '@shopify/cli-kit/node/session'; -export function newHydrogenStorefrontUrl(session: AdminSession) { +export function newHydrogenStorefrontUrl( + session: Pick, +) { const {storeFqdn} = session; return `https://${storeFqdn}/admin/custom_storefronts/new`; } diff --git a/packages/cli/src/lib/authentication/types.ts b/packages/cli/src/lib/authentication/types.ts new file mode 100644 index 0000000000..565b4d58a8 --- /dev/null +++ b/packages/cli/src/lib/authentication/types.ts @@ -0,0 +1,20 @@ +import { + defineJsonOutputSchema, + type InferJsonOutputSchema, +} from '@shopify/cli-kit/node/json-output-schema'; +import {zod} from '@shopify/cli-kit/node/schema'; + +export const loginJsonOutputSchema = defineJsonOutputSchema({ + name: 'HydrogenLoginResult', + schema: zod.object({ + shop: zod.string(), + shopName: zod.string(), + email: zod.string(), + }), +}); +export type LoginResult = InferJsonOutputSchema; +export const logoutJsonOutputSchema = defineJsonOutputSchema({ + name: 'HydrogenLogoutResult', + schema: zod.object({loggedOut: zod.literal(true)}), +}); +export type LogoutResult = InferJsonOutputSchema; diff --git a/packages/cli/src/lib/onboarding/common.ts b/packages/cli/src/lib/onboarding/common.ts index 4542fed43a..30a1828620 100644 --- a/packages/cli/src/lib/onboarding/common.ts +++ b/packages/cli/src/lib/onboarding/common.ts @@ -1,3 +1,10 @@ +import {isJsonOutput} from '../json-output.js'; +import {errorHandler} from '@shopify/cli-kit/node/error-handler'; +import { + flushStdout, + outputDebug, + formatPackageManagerCommand, +} from '@shopify/cli-kit/node/output'; import {readdir, symlink} from 'node:fs/promises'; import { installNodeModules, @@ -29,10 +36,6 @@ import { writeFile, copyFile, } from '@shopify/cli-kit/node/fs'; -import { - outputDebug, - formatPackageManagerCommand, -} from '@shopify/cli-kit/node/output'; import {currentProcessIsGlobal} from '@shopify/cli-kit/node/is-global'; import colors from '@shopify/cli-kit/node/colors'; import {type AdminSession, login, renderLoginSuccess} from '../auth.js'; @@ -742,6 +745,17 @@ export function createAbortHandler( await rmdir(project!.directory, {force: true}).catch(() => {}); } + if (isJsonOutput()) { + await errorHandler( + new AbortError( + 'Failed to initialize project: ' + (error?.message ?? ''), + error?.tryMessage ?? error?.stack, + ), + ); + await flushStdout(); + process.exit(1); + } + renderFatalError( new AbortError( 'Failed to initialize project: ' + (error?.message ?? ''), diff --git a/packages/cli/src/lib/onboarding/index.ts b/packages/cli/src/lib/onboarding/index.ts index 52dd12c92a..96fdf8995a 100644 --- a/packages/cli/src/lib/onboarding/index.ts +++ b/packages/cli/src/lib/onboarding/index.ts @@ -1,3 +1,4 @@ +import {presentTemplateResult} from './result.js'; import {AbortController} from '@shopify/cli-kit/node/abort'; import {setupLocalStarterTemplate} from './local.js'; import {setupRemoteTemplate} from './remote.js'; @@ -11,9 +12,12 @@ export async function setupTemplate(options: InitOptions) { try { const template = options.template; - return template + const result = template ? await setupRemoteTemplate({...options, template}, controller) : await setupLocalStarterTemplate(options, controller); + + await presentTemplateResult(result, template); + return result; } catch (error) { controller.abort(); throw error; diff --git a/packages/cli/src/lib/onboarding/local.ts b/packages/cli/src/lib/onboarding/local.ts index cde59d0d43..ec942fb05c 100644 --- a/packages/cli/src/lib/onboarding/local.ts +++ b/packages/cli/src/lib/onboarding/local.ts @@ -1,3 +1,4 @@ +import {isJsonOutput} from '../json-output.js'; import {cp as copyWithFilter} from 'node:fs/promises'; import {AbortError} from '@shopify/cli-kit/node/error'; import {AbortController} from '@shopify/cli-kit/node/abort'; @@ -24,7 +25,6 @@ import { handleI18n, handleRouteGeneration, createInitialCommit, - renderProjectReady, commitAll, generateProjectEntries, } from './common.js'; @@ -302,7 +302,7 @@ export async function setupLocalStarterTemplate( // If running in --quickstart mode, skip this success banner if (options.quickstart) { - console.log('\n'); + if (!isJsonOutput()) console.log('\n'); } else { renderSuccess({ headline: [ @@ -384,8 +384,6 @@ export async function setupLocalStarterTemplate( await commitAll(project.directory, 'Lockfile'); } - await renderProjectReady(project, setupSummary); - return { ...project, ...setupSummary, diff --git a/packages/cli/src/lib/onboarding/remote.ts b/packages/cli/src/lib/onboarding/remote.ts index f3b257894d..a33427f1c8 100644 --- a/packages/cli/src/lib/onboarding/remote.ts +++ b/packages/cli/src/lib/onboarding/remote.ts @@ -4,7 +4,7 @@ import {AbortController, AbortSignal} from '@shopify/cli-kit/node/abort'; import {copyFile, fileExists} from '@shopify/cli-kit/node/fs'; import {readAndParsePackageJson} from '@shopify/cli-kit/node/node-package-manager'; import {joinPath} from '@shopify/cli-kit/node/path'; -import {renderInfo, renderTasks} from '../ui.js'; +import {renderTasks} from '../ui.js'; import { downloadExternalRepo, downloadMonorepoTemplates, @@ -17,7 +17,6 @@ import { handleDependencies, handleLanguage, handleProjectLocation, - renderProjectReady, SetupSummary, type InitOptions, } from './common.js'; @@ -135,17 +134,6 @@ export async function setupRemoteTemplate( await commitAll(project.directory, 'Lockfile'); } - await renderProjectReady(project, setupSummary); - - renderInfo({ - headline: `Your project will display inventory from ${ - options.template.endsWith(DEMO_STORE_REPO) - ? 'the Hydrogen Demo Store' - : 'Mock.shop' - }.`, - body: `To connect this project to your Shopify store’s inventory, update \`${project.name}/.env\` with your store ID and Storefront API key.`, - }); - return { ...project, ...setupSummary, diff --git a/packages/cli/src/lib/onboarding/result.ts b/packages/cli/src/lib/onboarding/result.ts new file mode 100644 index 0000000000..e7b9716c32 --- /dev/null +++ b/packages/cli/src/lib/onboarding/result.ts @@ -0,0 +1,67 @@ +import {outputWarn} from '@shopify/cli-kit/node/output'; +import {writeJsonResult, isJsonOutput} from '../json-output.js'; +import {renderInfo} from '../ui.js'; +import {renderProjectReady} from './common.js'; +import {initJsonOutputSchema, type InitResult} from './types.js'; +import type {setupLocalStarterTemplate} from './local.js'; + +type TemplateResult = Awaited>; + +export function toInitResult(result: TemplateResult): InitResult { + if (!result) return null; + const { + location, + name, + directory, + storefrontTitle, + language, + packageManager, + depsInstalled, + cssStrategy, + i18n, + routes, + } = result; + const failures: NonNullable['failures'] = []; + if (result.depsError) failures.push('dependencies'); + if (result.i18nError) failures.push('markets'); + if (result.routesError) failures.push('routes'); + return { + location, + name, + directory, + storefrontTitle, + language, + packageManager, + depsInstalled, + cssStrategy, + i18n, + routes, + failures, + }; +} + +export async function presentTemplateResult( + result: TemplateResult, + template?: string, +) { + if (isJsonOutput()) { + for (const error of [ + result?.depsError, + result?.i18nError, + result?.routesError, + ]) { + if (error) outputWarn(error.message); + } + } + if (writeJsonResult(initJsonOutputSchema, toInitResult(result)) || !result) + return; + await renderProjectReady(result, result); + if (template) + renderInfo({ + headline: `Your project will display inventory from ${template.endsWith('shopify/hydrogen-demo-store') ? 'the Hydrogen Demo Store' : 'Mock.shop'}.`, + body: + 'To connect this project to your Shopify store’s inventory, update `' + + result.name + + '/.env` with your store ID and Storefront API key.', + }); +} diff --git a/packages/cli/src/lib/onboarding/types.ts b/packages/cli/src/lib/onboarding/types.ts new file mode 100644 index 0000000000..f3ec18f1dc --- /dev/null +++ b/packages/cli/src/lib/onboarding/types.ts @@ -0,0 +1,27 @@ +import { + defineJsonOutputSchema, + type InferJsonOutputSchema, +} from '@shopify/cli-kit/node/json-output-schema'; +import {zod} from '@shopify/cli-kit/node/schema'; + +export const initJsonOutputSchema = defineJsonOutputSchema({ + name: 'HydrogenInitResult', + schema: zod + .object({ + location: zod.string(), + name: zod.string(), + directory: zod.string(), + storefrontTitle: zod.string().optional(), + language: zod.enum(['js', 'ts']).optional(), + packageManager: zod.string(), + depsInstalled: zod.boolean(), + cssStrategy: zod.string().optional(), + i18n: zod.string().optional(), + routes: zod + .record(zod.union([zod.string(), zod.array(zod.string())])) + .optional(), + failures: zod.array(zod.enum(['dependencies', 'markets', 'routes'])), + }) + .nullable(), +}); +export type InitResult = InferJsonOutputSchema; diff --git a/packages/cli/src/lib/storefronts/types.ts b/packages/cli/src/lib/storefronts/types.ts new file mode 100644 index 0000000000..a002fe6b51 --- /dev/null +++ b/packages/cli/src/lib/storefronts/types.ts @@ -0,0 +1,43 @@ +import { + defineJsonOutputSchema, + type InferJsonOutputSchema, +} from '@shopify/cli-kit/node/json-output-schema'; +import {zod} from '@shopify/cli-kit/node/schema'; + +const Deployment = zod.object({ + id: zod.string(), + createdAt: zod.string(), + commitMessage: zod.string().nullable(), +}); +const Storefront = zod.object({ + id: zod.string(), + title: zod.string(), + productionUrl: zod.string().nullish(), + parsedId: zod.string().optional(), +}); +const StorefrontWithDeployment = Storefront.extend({ + currentProductionDeployment: Deployment.nullable(), +}); +export const listJsonOutputSchema = defineJsonOutputSchema({ + name: 'HydrogenListResult', + schema: zod.object({ + shop: zod.string(), + storefronts: zod.array(StorefrontWithDeployment), + }), + definitions: {Storefront: StorefrontWithDeployment, Deployment}, +}); +export type ListResult = InferJsonOutputSchema; +export const linkJsonOutputSchema = defineJsonOutputSchema({ + name: 'HydrogenLinkResult', + schema: zod.object({shop: zod.string(), storefront: Storefront.nullable()}), + definitions: {Storefront}, +}); +export type LinkResult = InferJsonOutputSchema; +export const unlinkJsonOutputSchema = defineJsonOutputSchema({ + name: 'HydrogenUnlinkResult', + schema: zod.object({ + unlinked: zod.boolean(), + storefront: zod.object({id: zod.string(), title: zod.string()}).nullable(), + }), +}); +export type UnlinkResult = InferJsonOutputSchema;