From 7ac4962bfd93172739b49ffb26618c39da4f3caf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Tue, 6 Oct 2026 16:51:26 +0200 Subject: [PATCH 1/3] Add typed JSON output to auth login --- .changeset/auth-login-json.md | 5 + .../generated/generated_docs_data_v2.json | 38 +++- .../ui/components/Prompts/PromptLayout.tsx | 4 +- .../public/node/session-prompt-output.test.ts | 89 +++++++++ .../cli-kit/src/public/node/session-prompt.ts | 8 +- packages/cli/README.md | 44 ++++- packages/cli/oclif.manifest.json | 35 +++- .../src/cli/commands/auth/login-json.test.ts | 187 ++++++++++++++++++ packages/cli/src/cli/commands/auth/login.ts | 27 ++- .../rules/json-output-command-exceptions.js | 1 - 10 files changed, 427 insertions(+), 11 deletions(-) create mode 100644 .changeset/auth-login-json.md create mode 100644 packages/cli-kit/src/public/node/session-prompt-output.test.ts create mode 100644 packages/cli/src/cli/commands/auth/login-json.test.ts 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-kit/src/private/node/ui/components/Prompts/PromptLayout.tsx b/packages/cli-kit/src/private/node/ui/components/Prompts/PromptLayout.tsx index fe035922d80..d8159a15c5f 100644 --- a/packages/cli-kit/src/private/node/ui/components/Prompts/PromptLayout.tsx +++ b/packages/cli-kit/src/private/node/ui/components/Prompts/PromptLayout.tsx @@ -38,7 +38,7 @@ const PromptLayout = ({ const [wrapperHeight, setWrapperHeight] = useState(0) const [promptAreaHeight, setPromptAreaHeight] = useState(0) const [inputFixedAreaHeight, setInputFixedAreaHeight] = useState(0) - const currentAvailableLines = stdout.rows - promptAreaHeight - inputFixedAreaHeight + const currentAvailableLines = (stdout.rows ?? 24) - promptAreaHeight - inputFixedAreaHeight const [availableLines, setAvailableLines] = useState(currentAvailableLines) const wrapperRef = useCallback( @@ -72,7 +72,7 @@ const PromptLayout = ({ useLayoutEffect(() => { function onResize() { - const newAvailableLines = stdout.rows - promptAreaHeight - inputFixedAreaHeight + const newAvailableLines = (stdout.rows ?? 24) - promptAreaHeight - inputFixedAreaHeight if (newAvailableLines !== availableLines) { setAvailableLines(newAvailableLines) } diff --git a/packages/cli-kit/src/public/node/session-prompt-output.test.ts b/packages/cli-kit/src/public/node/session-prompt-output.test.ts new file mode 100644 index 00000000000..80d317c006e --- /dev/null +++ b/packages/cli-kit/src/public/node/session-prompt-output.test.ts @@ -0,0 +1,89 @@ +import {promptSessionSelect} from './session-prompt.js' +import * as session from './session.js' +import * as system from './system.js' +import * as fqdn from './context/fqdn.js' +import {runWithCommandEvents, renderCommandEventAsJson} from './command-events.js' +import {outputResult} from './output.js' +import {withCapturedStandardStreams} from './testing/output.js' +import {LocalStorage} from './local-storage.js' +import {inTemporaryDirectory} from './fs.js' +import * as confStore from '../../private/node/conf-store.js' +import * as sessionStore from '../../private/node/session/store.js' +import * as ui from '../../private/node/ui.js' +import {Stdin, waitForInputsToBeReady} from '../../private/node/testing/ui.js' +import {afterEach, expect, test, vi} from 'vitest' + +const {getSessions, setSessions, setCurrentSessionId} = confStore +const {render} = ui + +afterEach(() => { + vi.unstubAllEnvs() +}) + +test.each(['cached', 'new'])('keeps the real %s account prompt on stderr before the JSON result', async (flow) => { + await inTemporaryDirectory(async (directory) => { + vi.stubEnv('CI', '1') + const storage = new LocalStorage({cwd: directory}) + vi.spyOn(confStore, 'getSessions').mockImplementation(() => getSessions(storage)) + vi.spyOn(confStore, 'setSessions').mockImplementation((value) => setSessions(value, storage)) + vi.spyOn(confStore, 'setCurrentSessionId').mockImplementation((value) => setCurrentSessionId(value, storage)) + vi.spyOn(fqdn, 'identityFqdn').mockResolvedValue('accounts.example.com') + vi.spyOn(system, 'terminalSupportsPrompting').mockReturnValue(true) + const sessions = { + 'accounts.example.com': { + 'user-123': { + identity: { + userId: 'user-123', + alias: flow === 'cached' ? 'Work account' : undefined, + accessToken: 'access-token', + refreshToken: 'refresh-token', + expiresAt: new Date('2030-01-01T00:00:00Z'), + scopes: [], + }, + applications: {}, + }, + }, + } + if (flow === 'cached') await sessionStore.store(sessions) + vi.spyOn(session, 'ensureAuthenticatedUser').mockImplementation(async () => { + await sessionStore.store(sessions) + return {userId: 'user-123'} + }) + const stdin = new Stdin() + vi.spyOn(ui, 'render').mockImplementation((element, options) => + render(element, { + ...options, + stdin: stdin as unknown as NodeJS.ReadStream, + debug: true, + patchConsole: false, + }), + ) + + await withCapturedStandardStreams(async ({stdout, stderr}) => { + const selection = runWithCommandEvents({outputMode: 'json', sink: renderCommandEventAsJson}, () => + promptSessionSelect(), + ) + await vi.waitFor(() => expect(stdin.listenerCount('readable')).toBeGreaterThan(0)) + await waitForInputsToBeReady() + if (flow === 'new') { + stdin.write('Work account') + await vi.waitFor(() => expect(stderr()).toContain('Work account')) + await waitForInputsToBeReady() + } + stdin.write('\r') + const alias = await selection + expect(stdout()).toBe('') + expect(stderr()).toContain( + flow === 'cached' ? 'Which account would you like to use?' : 'Enter an alias for this account', + ) + + outputResult(JSON.stringify({status: 'success', alias})) + + expect(JSON.parse(stdout())).toEqual({ + status: 'success', + alias: 'Work account', + }) + expect(stdout()).not.toContain('access-token') + }) + }) +}) diff --git a/packages/cli-kit/src/public/node/session-prompt.ts b/packages/cli-kit/src/public/node/session-prompt.ts index 278be669125..977e3361c4a 100644 --- a/packages/cli-kit/src/public/node/session-prompt.ts +++ b/packages/cli-kit/src/public/node/session-prompt.ts @@ -1,6 +1,7 @@ import {renderSelectPrompt, renderTextPrompt} from './ui.js' import {ensureAuthenticatedUser} from './session.js' import {identityFqdn} from './context/fqdn.js' +import {commandEventOutputMode} from './command-events.js' import * as sessionStore from '../../private/node/session/store.js' import {setCurrentSessionId} from '../../private/node/conf-store.js' import type {Sessions} from '../../private/node/session/schema.js' @@ -48,6 +49,7 @@ async function handleNewLogin(): Promise { if (!alias) { const userAlias = await renderTextPrompt({ message: 'Enter an alias for this account (e.g. your email or a nickname)', + ...(commandEventOutputMode() === 'json' ? {renderOptions: {stdout: process.stderr}} : {}), }) await sessionStore.setSessionAlias(result.userId, userAlias) return userAlias @@ -103,7 +105,11 @@ export async function promptSessionSelect(alias?: string): Promise { if (choices.length > 0) { const message = 'Which account would you like to use?' - selectedValue = await renderSelectPrompt({message, choices}) + selectedValue = await renderSelectPrompt({ + message, + choices, + ...(commandEventOutputMode() === 'json' ? {renderOptions: {stdout: process.stderr}} : {}), + }) } if (selectedValue === NEW_LOGIN_VALUE) { 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..ee0cdf60ee5 --- /dev/null +++ b/packages/cli/src/cli/commands/auth/login-json.test.ts @@ -0,0 +1,187 @@ +import Login from './login.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.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..a42ea7b3407 100644 --- a/packages/cli/src/cli/commands/auth/login.ts +++ b/packages/cli/src/cli/commands/auth/login.ts @@ -1,13 +1,24 @@ 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' +import {defineJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema' +import {zod} from '@shopify/cli-kit/node/schema' + +const authLoginJsonOutputSchema = defineJsonOutputSchema({ + name: 'AuthLoginResult', + schema: zod.object({status: zod.literal('success'), alias: zod.string()}).strict(), +}) 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 +27,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/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', From d43a1d2ee790fe99dff9ba926e57965e0a3c82ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Wed, 7 Oct 2026 11:05:32 +0200 Subject: [PATCH 2/3] Move auth login JSON schema into its types module --- packages/cli/src/cli/commands/auth/login-json.test.ts | 2 ++ packages/cli/src/cli/commands/auth/login.ts | 8 +------- .../cli/src/cli/services/commands/auth/login/types.ts | 7 +++++++ 3 files changed, 10 insertions(+), 7 deletions(-) create mode 100644 packages/cli/src/cli/services/commands/auth/login/types.ts diff --git a/packages/cli/src/cli/commands/auth/login-json.test.ts b/packages/cli/src/cli/commands/auth/login-json.test.ts index ee0cdf60ee5..134944e1fbf 100644 --- a/packages/cli/src/cli/commands/auth/login-json.test.ts +++ b/packages/cli/src/cli/commands/auth/login-json.test.ts @@ -1,4 +1,5 @@ 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' @@ -115,6 +116,7 @@ test.each([false, true])('requires an alias before authentication when input is }) 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}) }) diff --git a/packages/cli/src/cli/commands/auth/login.ts b/packages/cli/src/cli/commands/auth/login.ts index a42ea7b3407..7e6bca17d51 100644 --- a/packages/cli/src/cli/commands/auth/login.ts +++ b/packages/cli/src/cli/commands/auth/login.ts @@ -1,15 +1,9 @@ +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 {globalFlags, jsonFlag, requiredIfNonInteractive} from '@shopify/cli-kit/node/cli' import {Flags} from '@oclif/core' import {outputCompleted, outputResult} from '@shopify/cli-kit/node/output' -import {defineJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema' -import {zod} from '@shopify/cli-kit/node/schema' - -const authLoginJsonOutputSchema = defineJsonOutputSchema({ - name: 'AuthLoginResult', - schema: zod.object({status: zod.literal('success'), alias: zod.string()}).strict(), -}) export default class Login extends Command { static descriptionWithMarkdown = 'Logs you in to your Shopify account.' 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(), +}) From d6953f5858b2ef161a97e739c3cdea698dcc550d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Wed, 7 Oct 2026 11:39:06 +0200 Subject: [PATCH 3/3] Keep prompt stream handling out of auth JSON changes --- .../ui/components/Prompts/PromptLayout.tsx | 4 +- .../public/node/session-prompt-output.test.ts | 89 ------------------- .../cli-kit/src/public/node/session-prompt.ts | 8 +- 3 files changed, 3 insertions(+), 98 deletions(-) delete mode 100644 packages/cli-kit/src/public/node/session-prompt-output.test.ts diff --git a/packages/cli-kit/src/private/node/ui/components/Prompts/PromptLayout.tsx b/packages/cli-kit/src/private/node/ui/components/Prompts/PromptLayout.tsx index d8159a15c5f..fe035922d80 100644 --- a/packages/cli-kit/src/private/node/ui/components/Prompts/PromptLayout.tsx +++ b/packages/cli-kit/src/private/node/ui/components/Prompts/PromptLayout.tsx @@ -38,7 +38,7 @@ const PromptLayout = ({ const [wrapperHeight, setWrapperHeight] = useState(0) const [promptAreaHeight, setPromptAreaHeight] = useState(0) const [inputFixedAreaHeight, setInputFixedAreaHeight] = useState(0) - const currentAvailableLines = (stdout.rows ?? 24) - promptAreaHeight - inputFixedAreaHeight + const currentAvailableLines = stdout.rows - promptAreaHeight - inputFixedAreaHeight const [availableLines, setAvailableLines] = useState(currentAvailableLines) const wrapperRef = useCallback( @@ -72,7 +72,7 @@ const PromptLayout = ({ useLayoutEffect(() => { function onResize() { - const newAvailableLines = (stdout.rows ?? 24) - promptAreaHeight - inputFixedAreaHeight + const newAvailableLines = stdout.rows - promptAreaHeight - inputFixedAreaHeight if (newAvailableLines !== availableLines) { setAvailableLines(newAvailableLines) } diff --git a/packages/cli-kit/src/public/node/session-prompt-output.test.ts b/packages/cli-kit/src/public/node/session-prompt-output.test.ts deleted file mode 100644 index 80d317c006e..00000000000 --- a/packages/cli-kit/src/public/node/session-prompt-output.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import {promptSessionSelect} from './session-prompt.js' -import * as session from './session.js' -import * as system from './system.js' -import * as fqdn from './context/fqdn.js' -import {runWithCommandEvents, renderCommandEventAsJson} from './command-events.js' -import {outputResult} from './output.js' -import {withCapturedStandardStreams} from './testing/output.js' -import {LocalStorage} from './local-storage.js' -import {inTemporaryDirectory} from './fs.js' -import * as confStore from '../../private/node/conf-store.js' -import * as sessionStore from '../../private/node/session/store.js' -import * as ui from '../../private/node/ui.js' -import {Stdin, waitForInputsToBeReady} from '../../private/node/testing/ui.js' -import {afterEach, expect, test, vi} from 'vitest' - -const {getSessions, setSessions, setCurrentSessionId} = confStore -const {render} = ui - -afterEach(() => { - vi.unstubAllEnvs() -}) - -test.each(['cached', 'new'])('keeps the real %s account prompt on stderr before the JSON result', async (flow) => { - await inTemporaryDirectory(async (directory) => { - vi.stubEnv('CI', '1') - const storage = new LocalStorage({cwd: directory}) - vi.spyOn(confStore, 'getSessions').mockImplementation(() => getSessions(storage)) - vi.spyOn(confStore, 'setSessions').mockImplementation((value) => setSessions(value, storage)) - vi.spyOn(confStore, 'setCurrentSessionId').mockImplementation((value) => setCurrentSessionId(value, storage)) - vi.spyOn(fqdn, 'identityFqdn').mockResolvedValue('accounts.example.com') - vi.spyOn(system, 'terminalSupportsPrompting').mockReturnValue(true) - const sessions = { - 'accounts.example.com': { - 'user-123': { - identity: { - userId: 'user-123', - alias: flow === 'cached' ? 'Work account' : undefined, - accessToken: 'access-token', - refreshToken: 'refresh-token', - expiresAt: new Date('2030-01-01T00:00:00Z'), - scopes: [], - }, - applications: {}, - }, - }, - } - if (flow === 'cached') await sessionStore.store(sessions) - vi.spyOn(session, 'ensureAuthenticatedUser').mockImplementation(async () => { - await sessionStore.store(sessions) - return {userId: 'user-123'} - }) - const stdin = new Stdin() - vi.spyOn(ui, 'render').mockImplementation((element, options) => - render(element, { - ...options, - stdin: stdin as unknown as NodeJS.ReadStream, - debug: true, - patchConsole: false, - }), - ) - - await withCapturedStandardStreams(async ({stdout, stderr}) => { - const selection = runWithCommandEvents({outputMode: 'json', sink: renderCommandEventAsJson}, () => - promptSessionSelect(), - ) - await vi.waitFor(() => expect(stdin.listenerCount('readable')).toBeGreaterThan(0)) - await waitForInputsToBeReady() - if (flow === 'new') { - stdin.write('Work account') - await vi.waitFor(() => expect(stderr()).toContain('Work account')) - await waitForInputsToBeReady() - } - stdin.write('\r') - const alias = await selection - expect(stdout()).toBe('') - expect(stderr()).toContain( - flow === 'cached' ? 'Which account would you like to use?' : 'Enter an alias for this account', - ) - - outputResult(JSON.stringify({status: 'success', alias})) - - expect(JSON.parse(stdout())).toEqual({ - status: 'success', - alias: 'Work account', - }) - expect(stdout()).not.toContain('access-token') - }) - }) -}) diff --git a/packages/cli-kit/src/public/node/session-prompt.ts b/packages/cli-kit/src/public/node/session-prompt.ts index 977e3361c4a..278be669125 100644 --- a/packages/cli-kit/src/public/node/session-prompt.ts +++ b/packages/cli-kit/src/public/node/session-prompt.ts @@ -1,7 +1,6 @@ import {renderSelectPrompt, renderTextPrompt} from './ui.js' import {ensureAuthenticatedUser} from './session.js' import {identityFqdn} from './context/fqdn.js' -import {commandEventOutputMode} from './command-events.js' import * as sessionStore from '../../private/node/session/store.js' import {setCurrentSessionId} from '../../private/node/conf-store.js' import type {Sessions} from '../../private/node/session/schema.js' @@ -49,7 +48,6 @@ async function handleNewLogin(): Promise { if (!alias) { const userAlias = await renderTextPrompt({ message: 'Enter an alias for this account (e.g. your email or a nickname)', - ...(commandEventOutputMode() === 'json' ? {renderOptions: {stdout: process.stderr}} : {}), }) await sessionStore.setSessionAlias(result.userId, userAlias) return userAlias @@ -105,11 +103,7 @@ export async function promptSessionSelect(alias?: string): Promise { if (choices.length > 0) { const message = 'Which account would you like to use?' - selectedValue = await renderSelectPrompt({ - message, - choices, - ...(commandEventOutputMode() === 'json' ? {renderOptions: {stdout: process.stderr}} : {}), - }) + selectedValue = await renderSelectPrompt({message, choices}) } if (selectedValue === NEW_LOGIN_VALUE) {