diff --git a/.changeset/auth-login-json.md b/.changeset/auth-login-json.md new file mode 100644 index 00000000000..8620b27d69c --- /dev/null +++ b/.changeset/auth-login-json.md @@ -0,0 +1,5 @@ +--- +'@shopify/cli': minor +--- + +Add typed JSON output to `auth login`. diff --git a/docs-shopify.dev/generated/generated_docs_data_v2.json b/docs-shopify.dev/generated/generated_docs_data_v2.json index e9b3d73715b..8585bf8b9e2 100644 --- a/docs-shopify.dev/generated/generated_docs_data_v2.json +++ b/docs-shopify.dev/generated/generated_docs_data_v2.json @@ -4706,9 +4706,45 @@ "description": "Print the command's JSON schemas.", "isOptional": true, "environmentValue": "SHOPIFY_FLAG_JSON_SCHEMA" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/auth-login.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--no-color", + "value": "''", + "description": "Disable color output.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_NO_COLOR" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/auth-login.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--no-input", + "value": "''", + "description": "Disable interactive prompts and browser authentication.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_NO_INPUT" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/auth-login.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--verbose", + "value": "''", + "description": "Increase the verbosity of the output. May include sensitive data.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_VERBOSE" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/auth-login.interface.ts", + "syntaxKind": "PropertySignature", + "name": "-j, --json", + "value": "''", + "description": "Output the result as JSON. Automatically disables color output.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_JSON" } ], - "value": "export interface authlogin {\n /**\n * Alias of an existing session you want to use. Required if non interactive.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--alias '?: string\n\n /**\n * Print the command's JSON schemas.\n * @environment SHOPIFY_FLAG_JSON_SCHEMA\n */\n '--json-schema'?: ''\n}" + "value": "export interface authlogin {\n /**\n * Alias of an existing session you want to use. Required if non interactive.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--alias '?: 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 * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" } }, "authlogout": { diff --git a/packages/cli/README.md b/packages/cli/README.md index d9cca4e59a2..0fcda30b8f6 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -4060,9 +4060,13 @@ Logs you in to your Shopify account. ``` USAGE - $ shopify auth login [--alias ] [--json-schema] + $ shopify auth login [--alias ] [-j] [--json-schema] [--no-color] [--no-input] [--verbose] FLAGS + -j, --json + Output the result as JSON. Automatically disables color output. + [env: SHOPIFY_FLAG_JSON] + --alias= Alias of an existing session you want to use. Required if non interactive. [env: SHOPIFY_FLAG_AUTH_ALIAS] @@ -4071,8 +4075,46 @@ FLAGS Print the command's JSON schemas. [env: SHOPIFY_FLAG_JSON_SCHEMA] + --no-color + Disable color output. + [env: SHOPIFY_FLAG_NO_COLOR] + + --no-input + Disable interactive prompts and browser authentication. + [env: SHOPIFY_FLAG_NO_INPUT] + + --verbose + Increase the verbosity of the output. May include sensitive data. + [env: SHOPIFY_FLAG_VERBOSE] + DESCRIPTION Logs you in to your Shopify account. + + Use `--json-schema` to print the result, error, and event schemas. + + Output from `--json` conforms to the `AuthLoginResult` schema. + + ```json + { + "type": "object", + "properties": { + "status": { + "type": "string", + "const": "success" + }, + "alias": { + "type": "string" + } + }, + "required": [ + "status", + "alias" + ], + "additionalProperties": false, + "title": "AuthLoginResult", + "$schema": "http://json-schema.org/draft-07/schema#" + } + ``` ``` ## `shopify auth logout` diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index ccc8f8856f7..9a714accbc2 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -5497,7 +5497,8 @@ ], "args": { }, - "description": "Logs you in to your Shopify account.", + "description": "Logs you in to your Shopify account.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `AuthLoginResult` schema.\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"success\"\n },\n \"alias\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"status\",\n \"alias\"\n ],\n \"additionalProperties\": false,\n \"title\": \"AuthLoginResult\",\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", + "descriptionWithMarkdown": "Logs you in to your Shopify account.", "enableJsonFlag": false, "flags": { "alias": { @@ -5508,12 +5509,44 @@ "name": "alias", "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.", "env": "SHOPIFY_FLAG_JSON_SCHEMA", "name": "json-schema", "type": "boolean" + }, + "no-color": { + "allowNo": false, + "description": "Disable color output.", + "env": "SHOPIFY_FLAG_NO_COLOR", + "hidden": false, + "name": "no-color", + "type": "boolean" + }, + "no-input": { + "allowNo": false, + "description": "Disable interactive prompts and browser authentication.", + "env": "SHOPIFY_FLAG_NO_INPUT", + "name": "no-input", + "type": "boolean" + }, + "verbose": { + "allowNo": false, + "description": "Increase the verbosity of the output. May include sensitive data.", + "env": "SHOPIFY_FLAG_VERBOSE", + "hidden": false, + "name": "verbose", + "type": "boolean" } }, "hasDynamicHelp": false, diff --git a/packages/cli/src/cli/commands/auth/login-json.test.ts b/packages/cli/src/cli/commands/auth/login-json.test.ts new file mode 100644 index 00000000000..134944e1fbf --- /dev/null +++ b/packages/cli/src/cli/commands/auth/login-json.test.ts @@ -0,0 +1,189 @@ +import Login from './login.js' +import {authLoginJsonOutputSchema} from '../../services/commands/auth/login/types.js' +import {promptSessionSelect} from '@shopify/cli-kit/node/session-prompt' +import * as system from '@shopify/cli-kit/node/system' +import {launchCLI} from '@shopify/cli-kit/node/cli-launcher' +import {ShopifyConfig} from '@shopify/cli-kit/node/custom-oclif-loader' +import {AbortError} from '@shopify/cli-kit/node/error' +import {outputInfo, outputCompleted, unstyled} from '@shopify/cli-kit/node/output' +import {mockAndCaptureOutput, withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output' +import {beforeEach, afterEach, expect, test, vi} from 'vitest' + +vi.mock('@shopify/cli-kit/node/session-prompt') + +beforeEach(() => { + vi.stubEnv('CI', '1') + vi.stubEnv('SHOPIFY_CLI_NO_ANALYTICS', '1') + vi.spyOn(ShopifyConfig.prototype, 'runHook').mockResolvedValue({successes: [], failures: []}) + vi.mocked(promptSessionSelect).mockResolvedValue('Work account') +}) + +afterEach(() => { + vi.unstubAllEnvs() + mockAndCaptureOutput().clear() +}) + +test.each(['--json', '-j'])('writes the selected alias through the real launcher with %s', async (flag) => { + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await launchCLI({ + moduleURL: import.meta.url, + argv: ['auth', 'login', '--alias', 'Work account', flag], + lazyCommandLoader: async () => Login, + }) + + expect(promptSessionSelect).toHaveBeenCalledExactlyOnceWith('Work account') + expect(stdout()).toBe(`${JSON.stringify({status: 'success', alias: 'Work account'}, null, 2)}\n`) + expect(stderr()).toBe('') + }) +}) + +test('uses the selected alias rather than the requested one', async () => { + vi.mocked(promptSessionSelect).mockResolvedValue('Other account') + + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await Login.run(['--alias', 'Missing account', '--json'], import.meta.url) + + expect(JSON.parse(stdout())).toEqual({status: 'success', alias: 'Other account'}) + expect(stderr()).toBe('') + }) +}) + +test('supports JSON and alias environment flags', async () => { + vi.stubEnv('SHOPIFY_FLAG_JSON', '1') + vi.stubEnv('SHOPIFY_FLAG_AUTH_ALIAS', 'Work account') + + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await Login.run([], import.meta.url) + + expect(promptSessionSelect).toHaveBeenCalledExactlyOnceWith('Work account') + expect(JSON.parse(stdout())).toEqual({status: 'success', alias: 'Work account'}) + expect(stderr()).toBe('') + }) +}) + +test.each([false, true])('allows interactive session selection independently of JSON: %s', async (json) => { + vi.spyOn(system, 'terminalSupportsPrompting').mockReturnValue(true) + + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await Login.run(json ? ['--json'] : [], import.meta.url) + + expect(promptSessionSelect).toHaveBeenCalledExactlyOnceWith(undefined) + if (json) { + expect(JSON.parse(stdout())).toEqual({status: 'success', alias: 'Work account'}) + expect(stderr()).toBe('') + } else { + expect(stdout()).toBe('') + expect(unstyled(stderr())).toBe('✔ Current account: Work account.\n') + } + }) +}) + +test.each([false, true])('supports no-input with an explicit alias independently of JSON: %s', async (json) => { + vi.stubEnv('SHOPIFY_FLAG_NO_INPUT', '1') + + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await Login.run(['--alias', 'Work account', '--no-input', ...(json ? ['--json'] : [])], import.meta.url) + + expect(promptSessionSelect).toHaveBeenCalledExactlyOnceWith('Work account') + if (json) { + expect(JSON.parse(stdout())).toEqual({status: 'success', alias: 'Work account'}) + expect(stderr()).toBe('') + } else { + expect(stdout()).toBe('') + expect(unstyled(stderr())).toBe('✔ Current account: Work account.\n') + } + }) +}) + +test.each([false, true])('requires an alias before authentication when input is disabled, JSON: %s', async (json) => { + vi.stubEnv('SHOPIFY_FLAG_NO_INPUT', '1') + if (json) vi.stubEnv('SHOPIFY_FLAG_JSON', '1') + const exit = vi.spyOn(process, 'exit').mockReturnValue(undefined as never) + + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await Login.run(['--no-input', ...(json ? ['--json'] : [])], import.meta.url) + + expect(promptSessionSelect).not.toHaveBeenCalled() + expect(exit).toHaveBeenCalledExactlyOnceWith(1) + if (json) { + expect(JSON.parse(stdout()).error).toMatchObject({type: 'abort', message: expect.stringContaining('--alias')}) + expect(stderr()).toBe('') + } else { + expect(stdout()).toBe('') + expect(unstyled(stderr())).toContain('--alias') + } + }) +}) + +test('exposes the result schema and keeps the alias requirement in help', () => { + expect(Login.jsonOutputSchema).toBe(authLoginJsonOutputSchema) + expect(Login.description).toContain('Output from `--json` conforms to the `AuthLoginResult` schema.') + expect(Login.flags.alias).toMatchObject({requiredIfNonInteractive: true}) +}) + +test('discovers the schema without requiring an alias or starting authentication', async () => { + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await launchCLI({ + moduleURL: import.meta.url, + argv: ['auth', 'login', '--json-schema'], + lazyCommandLoader: async () => Login, + }) + + expect(JSON.parse(stdout()).definitions.Result).toMatchObject({ + type: 'object', + properties: { + status: {type: 'string', const: 'success'}, + alias: {type: 'string'}, + }, + required: ['status', 'alias'], + additionalProperties: false, + }) + expect(promptSessionSelect).not.toHaveBeenCalled() + expect(stderr()).toBe('') + }) +}) + +test('keeps authentication guidance and completion events on stderr', async () => { + vi.mocked(promptSessionSelect).mockImplementation(async () => { + outputInfo('To run this command, log in to Shopify.') + outputCompleted('Logged in.') + return 'Work account' + }) + + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await Login.run(['--alias', 'Work account', '--json'], import.meta.url) + + expect(JSON.parse(stdout())).toEqual({status: 'success', alias: 'Work account'}) + const events = stderr() + .trim() + .split('\n') + .map((line) => JSON.parse(line)) + expect(events).toEqual([ + expect.objectContaining({type: 'diagnostic', level: 'info', message: 'To run this command, log in to Shopify.'}), + expect.objectContaining({type: 'diagnostic', level: 'info', message: 'Logged in.'}), + ]) + }) +}) + +test('writes only the fatal error after authentication fails', async () => { + vi.stubEnv('SHOPIFY_FLAG_JSON', '1') + const exit = vi.spyOn(process, 'exit').mockReturnValue(undefined as never) + vi.mocked(promptSessionSelect).mockRejectedValue(new AbortError('Authentication failed.')) + + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await Login.run(['--alias', 'Work account', '--json'], import.meta.url) + + expect(JSON.parse(stdout())).toEqual({error: {type: 'abort', message: 'Authentication failed.'}}) + expect(stderr()).toBe('') + expect(exit).toHaveBeenCalledExactlyOnceWith(1) + }) +}) + +test.each([ + {status: 'failed', alias: 'Work account'}, + {status: 'success', alias: 1}, + {status: 'success'}, + {status: 'success', alias: 'Work account', accessToken: 'secret'}, +])('rejects an invalid result %j', (value) => { + expect(() => Login.jsonOutputSchema.validate(value)).toThrow() +}) diff --git a/packages/cli/src/cli/commands/auth/login.ts b/packages/cli/src/cli/commands/auth/login.ts index 1652f0d28ae..7e6bca17d51 100644 --- a/packages/cli/src/cli/commands/auth/login.ts +++ b/packages/cli/src/cli/commands/auth/login.ts @@ -1,13 +1,18 @@ +import {authLoginJsonOutputSchema} from '../../services/commands/auth/login/types.js' import Command from '@shopify/cli-kit/node/base-command' import {promptSessionSelect} from '@shopify/cli-kit/node/session-prompt' -import {requiredIfNonInteractive} from '@shopify/cli-kit/node/cli' +import {globalFlags, jsonFlag, requiredIfNonInteractive} from '@shopify/cli-kit/node/cli' import {Flags} from '@oclif/core' -import {outputCompleted} from '@shopify/cli-kit/node/output' +import {outputCompleted, outputResult} from '@shopify/cli-kit/node/output' export default class Login extends Command { - static description = 'Logs you in to your Shopify account.' + static descriptionWithMarkdown = 'Logs you in to your Shopify account.' + + static description = this.descriptionForHelp() static flags = { + ...globalFlags, + ...jsonFlag, alias: requiredIfNonInteractive( Flags.string({ description: 'Alias of an existing session you want to use.', @@ -16,9 +21,17 @@ export default class Login extends Command { ), } + static get jsonOutputSchema() { + return authLoginJsonOutputSchema + } + async run(): Promise { const {flags} = await this.parse(Login) const result = await promptSessionSelect(flags.alias) - outputCompleted(`Current account: ${result}.`) + if (flags.json) { + outputResult(authLoginJsonOutputSchema.encode({status: 'success', alias: result})) + } else { + outputCompleted(`Current account: ${result}.`) + } } } diff --git a/packages/cli/src/cli/services/commands/auth/login/types.ts b/packages/cli/src/cli/services/commands/auth/login/types.ts new file mode 100644 index 00000000000..8d7253c36c0 --- /dev/null +++ b/packages/cli/src/cli/services/commands/auth/login/types.ts @@ -0,0 +1,7 @@ +import {defineJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema' +import {zod} from '@shopify/cli-kit/node/schema' + +export const authLoginJsonOutputSchema = defineJsonOutputSchema({ + name: 'AuthLoginResult', + schema: zod.object({status: zod.literal('success'), alias: zod.string()}).strict(), +}) 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 ff9b0471ad9..f961d13c8e5 100644 --- a/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js +++ b/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js @@ -32,7 +32,6 @@ const commandExceptions = [ 'packages/app/src/cli/commands/app/subscription-migrations/status.ts', 'packages/app/src/cli/commands/app/subscription-migrations/unschedule.ts', 'packages/app/src/cli/commands/app/webhook/trigger.ts', - 'packages/cli/src/cli/commands/auth/login.ts', 'packages/cli/src/cli/commands/doc/fetch.ts', 'packages/cli/src/cli/commands/doc/search.ts', 'packages/cli/src/cli/commands/upgrade.ts',