Skip to content
Draft
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
9 changes: 9 additions & 0 deletions .changeset/store-list-command.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@shopify/store': minor
'@shopify/cli-kit': patch
'@shopify/cli': minor
---

Add `shopify store list`, which prints every locally stored store-auth session — both standard PKCE-authenticated sessions (`shopify store auth`) and preview-store sessions (`shopify store create preview`) — as a table with `Store`, `Kind`, and `User` columns. Supports `--kind standard|preview` for filtering and `--json` for machine-readable output (intended for AI agent consumption alongside the M1 [Preview Store for AI Agent Surfaces](https://vault.shopify.io/gsd/proposals/60T12R) work).

Internally adds a `LocalStorage#entries()` enumerator to `@shopify/cli-kit` and a `listStoredStoreAppSessions()` helper to `@shopify/store` so the new command can resolve sessions across every stored shop without knowing the keys in advance.
24 changes: 24 additions & 0 deletions packages/cli-kit/src/public/node/local-storage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,30 @@ describe('storage', () => {
expect(got2).toEqual(undefined)
})
})

test('entries returns every stored key/value pair', async () => {
await inTemporaryDirectory((cwd) => {
const storage = new LocalStorage<TestSchema>({cwd})

storage.set('testValue', 'first')
storage.set('anotherKey' as keyof TestSchema, 'second' as TestSchema[keyof TestSchema])

const entries = storage.entries()

expect(entries).toHaveLength(2)
expect(Object.fromEntries(entries)).toEqual({
testValue: 'first',
anotherKey: 'second',
})
})
})

test('entries returns an empty array when nothing has been stored', async () => {
await inTemporaryDirectory((cwd) => {
const storage = new LocalStorage<TestSchema>({cwd})
expect(storage.entries()).toEqual([])
})
})
})

describe('error handling', () => {
Expand Down
21 changes: 21 additions & 0 deletions packages/cli-kit/src/public/node/local-storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,27 @@ export class LocalStorage<T extends Record<string, any>> {
}
}

/**
* Get every `[key, value]` pair currently held in the local storage.
*
* Useful for callers that need to enumerate all stored values without knowing the
* full set of keys in advance (for example, a `list` command iterating over every
* stored session). The `conf` package stores its entire state as a single JSON
* object, so this is just a typed wrapper around that object.
*
* @returns An array of `[key, value]` tuples.
* @throws AbortError if a permission error occurs.
* @throws BugError if an unexpected error occurs.
*/
entries(): [keyof T, T[keyof T]][] {
try {
return Object.entries(this.config.store) as [keyof T, T[keyof T]][]
// eslint-disable-next-line no-catch-all/no-catch-all
} catch (error) {
this.handleError(error, 'entries')
}
}

/**
* Clear the local storage (delete all values).
*
Expand Down
32 changes: 32 additions & 0 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@
* [`shopify store auth`](#shopify-store-auth)
* [`shopify store create preview`](#shopify-store-create-preview)
* [`shopify store execute`](#shopify-store-execute)
* [`shopify store list`](#shopify-store-list)
* [`shopify theme check`](#shopify-theme-check)
* [`shopify theme console`](#shopify-theme-console)
* [`shopify theme delete`](#shopify-theme-delete)
Expand Down Expand Up @@ -2224,6 +2225,37 @@ EXAMPLES
$ shopify store execute --store shop.myshopify.com --query "query { shop { name } }" --json
```

## `shopify store list`

List stored store-auth sessions.

```
USAGE
$ shopify store list [-j] [--kind standard|preview] [--no-color] [--verbose]

FLAGS
-j, --json [env: SHOPIFY_FLAG_JSON] Output the result as JSON. Automatically disables color output.
--kind=<option> [env: SHOPIFY_FLAG_STORE_LIST_KIND] Filter results to a single session kind.
<options: standard|preview>
--no-color [env: SHOPIFY_FLAG_NO_COLOR] Disable color output.
--verbose [env: SHOPIFY_FLAG_VERBOSE] Increase the verbosity of the output.

DESCRIPTION
List stored store-auth sessions.

Lists every store that has a locally stored auth session, including both standard PKCE-authenticated stores (via
`shopify store auth`) and preview stores (via `shopify store create preview`).

Use `--kind` to filter by session type, or `--json` to emit a machine-readable list for agent consumption.

EXAMPLES
$ shopify store list

$ shopify store list --kind preview

$ shopify store list --json
```

## `shopify theme check`

Validate the theme.
Expand Down
63 changes: 63 additions & 0 deletions packages/cli/oclif.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -5967,6 +5967,69 @@
"strict": true,
"summary": "Execute GraphQL queries and mutations on a store."
},
"store:list": {
"aliases": [
],
"args": {
},
"customPluginName": "@shopify/store",
"description": "Lists every store that has a locally stored auth session, including both standard PKCE-authenticated stores (via `shopify store auth`) and preview stores (via `shopify store create preview`).\n\nUse `--kind` to filter by session type, or `--json` to emit a machine-readable list for agent consumption.",
"descriptionWithMarkdown": "Lists every store that has a locally stored auth session, including both standard PKCE-authenticated stores (via `shopify store auth`) and preview stores (via `shopify store create preview`).\n\nUse `--kind` to filter by session type, or `--json` to emit a machine-readable list for agent consumption.",
"examples": [
"<%= config.bin %> <%= command.id %>",
"<%= config.bin %> <%= command.id %> --kind preview",
"<%= 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"
},
"kind": {
"description": "Filter results to a single session kind.",
"env": "SHOPIFY_FLAG_STORE_LIST_KIND",
"hasDynamicHelp": false,
"multiple": false,
"name": "kind",
"options": [
"standard",
"preview"
],
"required": false,
"type": "option"
},
"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,
"hiddenAliases": [
],
"id": "store:list",
"pluginAlias": "@shopify/cli",
"pluginName": "@shopify/cli",
"pluginType": "core",
"strict": true,
"summary": "List stored store-auth sessions."
},
"theme:check": {
"aliases": [
],
Expand Down
44 changes: 44 additions & 0 deletions packages/store/src/cli/commands/store/list.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import {listStoredStores, type StoreListEntryKind} from '../../services/store/list/index.js'
import {writeStoreListResult} from '../../services/store/list/result.js'
import StoreCommand from '../../utilities/store-command.js'
import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli'
import {Flags} from '@oclif/core'

const STORE_LIST_KINDS: StoreListEntryKind[] = ['standard', 'preview']

export default class StoreList extends StoreCommand {
static summary = 'List stored store-auth sessions.'

static descriptionWithMarkdown = `Lists every store that has a locally stored auth session, including both standard PKCE-authenticated stores (via \`shopify store auth\`) and preview stores (via \`shopify store create preview\`).

Use \`--kind\` to filter by session type, or \`--json\` to emit a machine-readable list for agent consumption.`

static description = this.descriptionWithoutMarkdown()

static examples = [
'<%= config.bin %> <%= command.id %>',
'<%= config.bin %> <%= command.id %> --kind preview',
'<%= config.bin %> <%= command.id %> --json',
]

static flags = {
...globalFlags,
...jsonFlag,
kind: Flags.string({
description: 'Filter results to a single session kind.',
env: 'SHOPIFY_FLAG_STORE_LIST_KIND',
options: STORE_LIST_KINDS,
required: false,
}),
}

public async run(): Promise<void> {
const {flags} = await this.parse(StoreList)

const entries = listStoredStores({
kind: flags.kind as StoreListEntryKind | undefined,
})

writeStoreListResult(entries, flags.json ? 'json' : 'text')
}
}
125 changes: 125 additions & 0 deletions packages/store/src/cli/services/store/auth/session-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
clearStoredStoreAppSession,
getCurrentStoredStoreAppSession,
isPreviewStoreSession,
listStoredStoreAppSessions,
sessionKind,
setStoredStoreAppSession,
type StoredStoreAppSession,
Expand All @@ -24,6 +25,9 @@ function inMemoryStorage() {
delete(key: string) {
values.delete(key)
},
entries() {
return Array.from(values.entries()) as [string, unknown][]
},
} as LocalStorage<Record<string, unknown>>
}

Expand Down Expand Up @@ -340,3 +344,124 @@ describe('preview-store discriminator', () => {
expect(getCurrentStoredStoreAppSession(standard.store, storage as any)).toEqual(preview)
})
})

describe('listStoredStoreAppSessions', () => {
test('returns an empty array when no sessions are stored', () => {
expect(listStoredStoreAppSessions(inMemoryStorage() as any)).toEqual([])
})

test('returns the current session for every shop with a stored bucket', () => {
const storage = inMemoryStorage()
const first = buildSession({store: 'a.myshopify.com'})
const second = buildSession({store: 'b.myshopify.com', userId: '84'})
const preview = buildPreviewSession({store: 'c.myshopify.io'})

setStoredStoreAppSession(first, storage as any)
setStoredStoreAppSession(second, storage as any)
setStoredStoreAppSession(preview, storage as any)

const listed = listStoredStoreAppSessions(storage as any)
expect(listed).toHaveLength(3)
expect(listed.map((session) => session.store).sort()).toEqual([
'a.myshopify.com',
'b.myshopify.com',
'c.myshopify.io',
])
})

test('returns only the bucket\u2019s current-user session, not every stored user', () => {
const storage = inMemoryStorage()
const firstUser = buildSession({userId: '42', accessToken: 'token-old'})
const secondUser = buildSession({userId: '84', accessToken: 'token-new'})

setStoredStoreAppSession(firstUser, storage as any)
setStoredStoreAppSession(secondUser, storage as any)

const listed = listStoredStoreAppSessions(storage as any)
expect(listed).toHaveLength(1)
expect(listed[0]!.userId).toBe('84')
})

test('skips buckets that belong to a different client id', () => {
const storage = inMemoryStorage()
setStoredStoreAppSession(buildSession(), storage as any)
storage.set('some-other-client::shop.myshopify.com', {
currentUserId: '42',
sessionsByUserId: {'42': buildSession({clientId: 'some-other-client'})},
})

const listed = listStoredStoreAppSessions(storage as any)
expect(listed).toHaveLength(1)
expect(listed[0]!.clientId).toBe(STORE_AUTH_APP_CLIENT_ID)
})

test('silently skips malformed buckets and sessions that fail sanitization', () => {
const storage = inMemoryStorage()
setStoredStoreAppSession(buildSession(), storage as any)

storage.set(storeAuthSessionKey('malformed-bucket.myshopify.com'), {
currentUserId: 42,
sessionsByUserId: null,
})
storage.set(storeAuthSessionKey('missing-current.myshopify.com'), {
currentUserId: '999',
sessionsByUserId: {'42': buildSession()},
})
storage.set(storeAuthSessionKey('malformed-session.myshopify.com'), {
currentUserId: '42',
sessionsByUserId: {'42': {userId: '42'}},
})

const listed = listStoredStoreAppSessions(storage as any)
expect(listed).toHaveLength(1)
expect(listed[0]!.store).toBe('shop.myshopify.com')
})

// The underlying `conf` library treats `.` in keys as a path separator, so a shop
// domain like `preview-1.my.shop.dev` is persisted as a nested object tree rather
// than a single top-level key. `entries()` therefore returns only the outermost
// segment, and listStoredStoreAppSessions has to walk down into the tree to find
// the bucket. The in-memory test storage doesn't reproduce that nesting, so we
// simulate it directly here.
test('finds buckets stored under a dotted shop domain (conf dot-notation expansion)', () => {
const storage = inMemoryStorage()
const dottedDomainSession = buildSession({store: 'preview-1.my.shop.dev', userId: '7'})
storage.set(`${STORE_AUTH_APP_CLIENT_ID}::preview-1`, {
my: {
shop: {
dev: {
currentUserId: '7',
sessionsByUserId: {'7': dottedDomainSession},
},
},
},
})

const listed = listStoredStoreAppSessions(storage as any)
expect(listed).toHaveLength(1)
expect(listed[0]!.store).toBe('preview-1.my.shop.dev')
})

test('finds buckets at multiple depths under the same top-level key', () => {
const storage = inMemoryStorage()
const shallow = buildSession({store: 'shop-a.myshopify.com', userId: '1'})
const deep = buildSession({store: 'shop-a.my.shop.dev', userId: '2'})

storage.set(`${STORE_AUTH_APP_CLIENT_ID}::shop-a`, {
myshopify: {
com: {currentUserId: '1', sessionsByUserId: {'1': shallow}},
},
my: {
shop: {
dev: {currentUserId: '2', sessionsByUserId: {'2': deep}},
},
},
})

const listed = listStoredStoreAppSessions(storage as any)
expect(listed.map((session) => session.store).sort()).toEqual([
'shop-a.my.shop.dev',
'shop-a.myshopify.com',
])
})
})
Loading
Loading