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

Filter by extension

Filter by extension

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

Add typed JSON output to `auth login`.
38 changes: 37 additions & 1 deletion docs-shopify.dev/generated/generated_docs_data_v2.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 <value>'?: 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 <value>'?: string\n\n /**\n * Output the result as JSON. Automatically disables color output.\n * @environment SHOPIFY_FLAG_JSON\n */\n '-j, --json'?: ''\n\n /**\n * Print the command's JSON schemas.\n * @environment SHOPIFY_FLAG_JSON_SCHEMA\n */\n '--json-schema'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Disable interactive prompts and browser authentication.\n * @environment SHOPIFY_FLAG_NO_INPUT\n */\n '--no-input'?: ''\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}"
}
},
"authlogout": {
Expand Down
44 changes: 43 additions & 1 deletion packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4060,9 +4060,13 @@ Logs you in to your Shopify account.

```
USAGE
$ shopify auth login [--alias <value>] [--json-schema]
$ shopify auth login [--alias <value>] [-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=<value>
Alias of an existing session you want to use. Required if non interactive.
[env: SHOPIFY_FLAG_AUTH_ALIAS]
Expand All @@ -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`
Expand Down
35 changes: 34 additions & 1 deletion packages/cli/oclif.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -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,
Expand Down
189 changes: 189 additions & 0 deletions packages/cli/src/cli/commands/auth/login-json.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
Loading
Loading