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
6 changes: 6 additions & 0 deletions .changeset/auth-list-store-sessions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@shopify/cli': minor
'@shopify/store': minor
---

Add `shopify auth list` to list stores authenticated directly with `shopify store auth`.
51 changes: 51 additions & 0 deletions packages/cli/oclif.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
],
Expand Down
33 changes: 33 additions & 0 deletions packages/store/src/cli/commands/store/auth/list.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
31 changes: 31 additions & 0 deletions packages/store/src/cli/commands/store/auth/list.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
const {flags} = await this.parse(StoreAuthList)
const result = listStoreAuthSessions()

writeStoreAuthListResult(result, flags.json ? 'json' : 'text')
}
}
93 changes: 93 additions & 0 deletions packages/store/src/cli/services/store/auth/list-result.test.ts
Original file line number Diff line number Diff line change
@@ -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 <domain> --scopes <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 <domain> --scopes <scopes>` to authenticate a store.',
'Run `shopify store list` to list stores in a Shopify organization.',
].join('\n'),
})
})
})
54 changes: 54 additions & 0 deletions packages/store/src/cli/services/store/auth/list-result.ts
Original file line number Diff line number Diff line change
@@ -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<typeof toDisplaySession>[]
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 <domain> --scopes <scopes>` to authenticate a store.',
'Run `shopify store list` to list stores in a Shopify organization.',
].join('\n')
Comment thread
alfonso-noriega marked this conversation as resolved.
}
36 changes: 36 additions & 0 deletions packages/store/src/cli/services/store/auth/list.test.ts
Original file line number Diff line number Diff line change
@@ -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'},
},
],
})
})
})
31 changes: 31 additions & 0 deletions packages/store/src/cli/services/store/auth/list.ts
Original file line number Diff line number Diff line change
@@ -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} : {}),
})),
}
}
3 changes: 3 additions & 0 deletions packages/store/src/cli/services/store/list/result.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)', () => {
Expand Down Expand Up @@ -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', () => {
Expand All @@ -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', () => {
Expand Down
Loading
Loading