diff --git a/.changeset/auth-list-store-sessions.md b/.changeset/auth-list-store-sessions.md new file mode 100644 index 00000000000..4ebb8630e60 --- /dev/null +++ b/.changeset/auth-list-store-sessions.md @@ -0,0 +1,6 @@ +--- +'@shopify/cli': minor +'@shopify/store': minor +--- + +Add `shopify auth list` to list stores authenticated directly with `shopify store auth`. diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 4bff57d73b1..c8654ed3cf6 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -5748,6 +5748,57 @@ "strict": true, "summary": "Authenticate an app against a store for store commands." }, + "store:auth:list": { + "aliases": [ + ], + "args": { + }, + "customPluginName": "@shopify/store", + "description": "Lists stores authenticated directly on this machine with `shopify store auth`.\n\nUse this command to find stores that can be used with store-authenticated commands such as `shopify store execute`.\nTo list stores in a Shopify organization, run `shopify store list`.", + "descriptionWithMarkdown": "Lists stores authenticated directly on this machine with `shopify store auth`.\n\nUse this command to find stores that can be used with store-authenticated commands such as `shopify store execute`.\nTo list stores in a Shopify organization, run `shopify store list`.", + "enableJsonFlag": false, + "examples": [ + "<%= config.bin %> <%= command.id %>", + "<%= config.bin %> <%= command.id %> --json" + ], + "flags": { + "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" + }, + "no-color": { + "allowNo": false, + "description": "Disable color output.", + "env": "SHOPIFY_FLAG_NO_COLOR", + "hidden": false, + "name": "no-color", + "type": "boolean" + }, + "verbose": { + "allowNo": false, + "description": "Increase the verbosity of the output.", + "env": "SHOPIFY_FLAG_VERBOSE", + "hidden": false, + "name": "verbose", + "type": "boolean" + } + }, + "hasDynamicHelp": false, + "hidden": true, + "hiddenAliases": [ + ], + "id": "store:auth:list", + "pluginAlias": "@shopify/cli", + "pluginName": "@shopify/cli", + "pluginType": "core", + "strict": true, + "summary": "List stores authenticated directly with store auth." + }, "store:create:dev": { "aliases": [ ], diff --git a/packages/store/src/cli/commands/store/auth/list.test.ts b/packages/store/src/cli/commands/store/auth/list.test.ts new file mode 100644 index 00000000000..9a6885238e9 --- /dev/null +++ b/packages/store/src/cli/commands/store/auth/list.test.ts @@ -0,0 +1,33 @@ +import StoreAuthList from './list.js' +import {listStoreAuthSessions} from '../../../services/store/auth/list.js' +import {writeStoreAuthListResult} from '../../../services/store/auth/list-result.js' +import {describe, expect, test, vi} from 'vitest' + +vi.mock('../../../services/store/auth/list.js') +vi.mock('../../../services/store/auth/list-result.js') + +describe('store auth list command', () => { + test('lists direct store-auth sessions and writes text output by default', async () => { + vi.mocked(listStoreAuthSessions).mockReturnValue({sessions: []}) + + await StoreAuthList.run([]) + + expect(listStoreAuthSessions).toHaveBeenCalledWith() + expect(writeStoreAuthListResult).toHaveBeenCalledWith({sessions: []}, 'text') + }) + + test('writes json output when requested', async () => { + vi.mocked(listStoreAuthSessions).mockReturnValue({sessions: []}) + + await StoreAuthList.run(['--json']) + + expect(writeStoreAuthListResult).toHaveBeenCalledWith({sessions: []}, 'json') + }) + + test('does not expose organization or source-selection flags', () => { + expect(StoreAuthList.hidden).toBe(true) + expect(StoreAuthList.flags.json).toBeDefined() + expect(StoreAuthList.flags).not.toHaveProperty('organization-id') + expect(StoreAuthList.flags).not.toHaveProperty('from') + }) +}) diff --git a/packages/store/src/cli/commands/store/auth/list.ts b/packages/store/src/cli/commands/store/auth/list.ts new file mode 100644 index 00000000000..271fe414046 --- /dev/null +++ b/packages/store/src/cli/commands/store/auth/list.ts @@ -0,0 +1,31 @@ +import {listStoreAuthSessions} from '../../../services/store/auth/list.js' +import {writeStoreAuthListResult} from '../../../services/store/auth/list-result.js' +import Command from '@shopify/cli-kit/node/base-command' +import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli' + +export default class StoreAuthList extends Command { + static hidden = true + + static summary = 'List stores authenticated directly with store auth.' + + static descriptionWithMarkdown = `Lists stores authenticated directly on this machine with \`shopify store auth\`. + +Use this command to find stores that can be used with store-authenticated commands such as \`shopify store execute\`. +To list stores in a Shopify organization, run \`shopify store list\`.` + + static description = this.descriptionWithoutMarkdown() + + static examples = ['<%= config.bin %> <%= command.id %>', '<%= config.bin %> <%= command.id %> --json'] + + static flags = { + ...globalFlags, + ...jsonFlag, + } + + async run(): Promise { + const {flags} = await this.parse(StoreAuthList) + const result = listStoreAuthSessions() + + writeStoreAuthListResult(result, flags.json ? 'json' : 'text') + } +} diff --git a/packages/store/src/cli/services/store/auth/list-result.test.ts b/packages/store/src/cli/services/store/auth/list-result.test.ts new file mode 100644 index 00000000000..1634551a9b3 --- /dev/null +++ b/packages/store/src/cli/services/store/auth/list-result.test.ts @@ -0,0 +1,93 @@ +import {writeStoreAuthListResult} from './list-result.js' +import {beforeEach, describe, expect, test} from 'vitest' +import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' + +describe('writeStoreAuthListResult', () => { + beforeEach(() => { + mockAndCaptureOutput().clear() + }) + + test('renders direct store-auth sessions with subdomain and connected date', () => { + const output = mockAndCaptureOutput() + + writeStoreAuthListResult( + { + sessions: [ + { + kind: 'store', + store: 'my-shop.myshopify.com', + userId: '42', + scopes: ['read_products', 'write_products'], + connectedAt: '2026-05-22T00:00:00Z', + associatedUser: {id: 42, email: 'merchant@example.com'}, + }, + ], + }, + 'text', + ) + + expect(output.info()).toContain('Subdomain') + expect(output.info()).toContain('Connected') + expect(output.info()).toContain('my-shop') + expect(output.info()).not.toContain('my-shop.myshopify.com') + expect(output.info()).toContain('May 22, 2026') + expect(output.info()).not.toContain('merchant@example.com') + expect(output.info()).not.toContain('read_products, write_products') + expect(output.info()).not.toContain('shopify store list') + }) + + test('renders an empty state with auth and organization-list guidance', () => { + const output = mockAndCaptureOutput() + + writeStoreAuthListResult({sessions: []}, 'text') + + expect(output.info()).toContain('No stores are authenticated directly with `shopify store auth`.') + expect(output.info()).toContain('shopify store auth --store --scopes ') + expect(output.info()).toContain('shopify store list') + }) + + test('writes a deterministic JSON document with only subdomain and connected date', () => { + const output = mockAndCaptureOutput() + + writeStoreAuthListResult( + { + sessions: [ + { + kind: 'store', + store: 'shop.myshopify.com', + userId: '42', + scopes: ['read_products'], + connectedAt: '2026-05-22T00:00:00Z', + associatedUser: {id: 42, email: 'merchant@example.com'}, + }, + ], + }, + 'json', + ) + + expect(JSON.parse(output.output())).toEqual({ + sessions: [ + { + subdomain: 'shop', + connected: 'May 22, 2026', + }, + ], + }) + }) + + test('includes empty-state guidance in JSON output when there are no sessions', () => { + const output = mockAndCaptureOutput() + + writeStoreAuthListResult({sessions: []}, 'json') + + expect(JSON.parse(output.output())).toEqual({ + sessions: [], + message: [ + 'No stores are authenticated directly with `shopify store auth`.', + '', + 'Run `shopify store auth --store --scopes ` to authenticate a store.', + 'Run `shopify store list` to list stores in a Shopify organization.', + ].join('\n'), + }) + }) +}) diff --git a/packages/store/src/cli/services/store/auth/list-result.ts b/packages/store/src/cli/services/store/auth/list-result.ts new file mode 100644 index 00000000000..e8cba213c5d --- /dev/null +++ b/packages/store/src/cli/services/store/auth/list-result.ts @@ -0,0 +1,54 @@ +import {type StoreAuthListResult} from './list.js' +import {extractSubdomain, formatShortDate} from '../display.js' +import {outputInfo, outputResult} from '@shopify/cli-kit/node/output' +import {renderTable} from '@shopify/cli-kit/node/ui' + +export function writeStoreAuthListResult(result: StoreAuthListResult, format: 'text' | 'json'): void { + if (format === 'json') { + outputResult(JSON.stringify(toJsonResult(result), null, 2)) + return + } + + renderTextResult(result) +} + +function toJsonResult(result: StoreAuthListResult): { + sessions: ReturnType[] + message?: string +} { + return { + sessions: result.sessions.map(toDisplaySession), + ...(result.sessions.length === 0 ? {message: emptyStateMessage()} : {}), + } +} + +function renderTextResult(result: StoreAuthListResult): void { + if (result.sessions.length === 0) { + outputInfo(emptyStateMessage()) + return + } + + renderTable({ + rows: result.sessions.map(toDisplaySession), + columns: { + subdomain: {header: 'Subdomain'}, + connected: {header: 'Connected'}, + }, + }) +} + +function toDisplaySession(session: StoreAuthListResult['sessions'][number]): {subdomain: string; connected: string} { + return { + subdomain: extractSubdomain(session.store) ?? session.store, + connected: formatShortDate(session.connectedAt), + } +} + +function emptyStateMessage(): string { + return [ + 'No stores are authenticated directly with `shopify store auth`.', + '', + 'Run `shopify store auth --store --scopes ` to authenticate a store.', + 'Run `shopify store list` to list stores in a Shopify organization.', + ].join('\n') +} diff --git a/packages/store/src/cli/services/store/auth/list.test.ts b/packages/store/src/cli/services/store/auth/list.test.ts new file mode 100644 index 00000000000..7f61ebcc5e7 --- /dev/null +++ b/packages/store/src/cli/services/store/auth/list.test.ts @@ -0,0 +1,36 @@ +import {listStoreAuthSessions} from './list.js' +import {listStoredStoreAuthSummaries} from './stored-auth.js' +import {describe, expect, test, vi} from 'vitest' + +vi.mock('./stored-auth.js') + +describe('listStoreAuthSessions', () => { + test('projects stored store auth summaries into typed auth sessions', () => { + vi.mocked(listStoredStoreAuthSummaries).mockReturnValue([ + { + store: 'shop.myshopify.com', + userId: '42', + scopes: ['read_products'], + acquiredAt: '2026-03-27T00:00:00.000Z', + expiresAt: '2026-03-28T00:00:00.000Z', + refreshTokenExpiresAt: '2026-04-28T00:00:00.000Z', + associatedUser: {id: 42, email: 'merchant@example.com'}, + }, + ]) + + expect(listStoreAuthSessions()).toEqual({ + sessions: [ + { + kind: 'store', + store: 'shop.myshopify.com', + userId: '42', + scopes: ['read_products'], + connectedAt: '2026-03-27T00:00:00.000Z', + expiresAt: '2026-03-28T00:00:00.000Z', + refreshTokenExpiresAt: '2026-04-28T00:00:00.000Z', + associatedUser: {id: 42, email: 'merchant@example.com'}, + }, + ], + }) + }) +}) diff --git a/packages/store/src/cli/services/store/auth/list.ts b/packages/store/src/cli/services/store/auth/list.ts new file mode 100644 index 00000000000..1a28e07d981 --- /dev/null +++ b/packages/store/src/cli/services/store/auth/list.ts @@ -0,0 +1,31 @@ +import {listStoredStoreAuthSummaries, type StoredStoreAuthSummary} from './stored-auth.js' + +export interface StoreAuthListEntry { + kind: 'store' + store: string + userId: string + scopes: string[] + connectedAt: string + expiresAt?: string + refreshTokenExpiresAt?: string + associatedUser?: StoredStoreAuthSummary['associatedUser'] +} + +export interface StoreAuthListResult { + sessions: StoreAuthListEntry[] +} + +export function listStoreAuthSessions(): StoreAuthListResult { + return { + sessions: listStoredStoreAuthSummaries().map((summary) => ({ + kind: 'store', + store: summary.store, + userId: summary.userId, + scopes: summary.scopes, + connectedAt: summary.acquiredAt, + ...(summary.expiresAt ? {expiresAt: summary.expiresAt} : {}), + ...(summary.refreshTokenExpiresAt ? {refreshTokenExpiresAt: summary.refreshTokenExpiresAt} : {}), + ...(summary.associatedUser ? {associatedUser: summary.associatedUser} : {}), + })), + } +} diff --git a/packages/store/src/cli/services/store/list/result.test.ts b/packages/store/src/cli/services/store/list/result.test.ts index a1c654b97ce..cd84e69811b 100644 --- a/packages/store/src/cli/services/store/list/result.test.ts +++ b/packages/store/src/cli/services/store/list/result.test.ts @@ -38,6 +38,7 @@ describe('writeStoreListResult', () => { expect(output.info()).toContain('My Shop') expect(output.info()).toContain('Dev') expect(output.info()).toContain('May 22, 2026') + expect(output.info()).toContain('shopify store auth list') }) test('renders the subdomain handle for non-myshopify hosts (local dev)', () => { @@ -78,6 +79,7 @@ describe('writeStoreListResult', () => { expect(output.warn()).toContain("Couldn't resolve a Shopify account for the current CLI session.") expect(output.info()).toContain('No stores were returned for the current CLI session.') + expect(output.info()).toContain('shopify store auth list') }) test('renders the selected organization empty state', () => { @@ -94,6 +96,7 @@ describe('writeStoreListResult', () => { writeStoreListResult({source: 'organization', stores: []}, 'text') expect(output.info()).toContain('No stores found in your Shopify organization.') + expect(output.info()).toContain('shopify store auth list') }) test('emits a {stores, organization} JSON document on stdout', () => { diff --git a/packages/store/src/cli/services/store/list/result.ts b/packages/store/src/cli/services/store/list/result.ts index 413590fa20d..925dbba7fee 100644 --- a/packages/store/src/cli/services/store/list/result.ts +++ b/packages/store/src/cli/services/store/list/result.ts @@ -46,6 +46,7 @@ function renderTextResult(result: ListStoresResult): void { } renderOrganizationTable(result.stores) + outputInfo('To list stores authenticated directly with `shopify store auth`, run `shopify store auth list`.') } function renderOrganizationTable(stores: StoreListEntry[]): void { @@ -67,14 +68,22 @@ function renderOrganizationTable(stores: StoreListEntry[]): void { function emptyStateMessage(result: ListStoresResult): string { if (result.notice) { - return 'No stores were returned for the current CLI session.' + return [ + 'No stores were returned for the current CLI session.', + '', + 'Run `shopify store auth list` to list stores authenticated directly with `shopify store auth`.', + ].join('\n') } if (result.organization) { return `No stores found in ${result.organization.name}.` } - return 'No stores found in your Shopify organization.' + return [ + 'No stores found in your Shopify organization.', + '', + 'Run `shopify store auth list` to list stores authenticated directly with `shopify store auth`.', + ].join('\n') } function subdomainFor(store: string): string { diff --git a/packages/store/src/index.ts b/packages/store/src/index.ts index eede458c992..89caa4a6946 100644 --- a/packages/store/src/index.ts +++ b/packages/store/src/index.ts @@ -1,3 +1,4 @@ +import StoreAuthList from './cli/commands/store/auth/list.js' import StoreAuth from './cli/commands/store/auth.js' import StoreCreateDev from './cli/commands/store/create/dev.js' import StoreCreatePreview from './cli/commands/store/create/preview.js' @@ -6,6 +7,7 @@ import StoreInfo from './cli/commands/store/info.js' import StoreList from './cli/commands/store/list.js' const COMMANDS = { + 'store:auth:list': StoreAuthList, 'store:auth': StoreAuth, 'store:create:dev': StoreCreateDev, 'store:create:preview': StoreCreatePreview,