From 99875ada5a06c850cbc7ea04110d38992beafb26 Mon Sep 17 00:00:00 2001 From: Melissa Luu Date: Mon, 30 Mar 2026 11:36:09 -0400 Subject: [PATCH 01/31] Add app static_root to extensibility websocket watch list --- .../app/src/cli/services/dev/extension.ts | 71 ++++++++++++++++++- .../services/dev/extension/payload/models.ts | 6 ++ .../services/dev/extension/payload/store.ts | 24 ++++++- .../src/cli/services/dev/extension/server.ts | 5 ++ .../dev/extension/server/middlewares.ts | 17 +++++ .../dev/processes/previewable-extension.ts | 15 ++++ .../ui-extensions-server-kit/src/types.ts | 6 ++ 7 files changed, 142 insertions(+), 2 deletions(-) diff --git a/packages/app/src/cli/services/dev/extension.ts b/packages/app/src/cli/services/dev/extension.ts index 301f1313c4d..e9ab9f346f9 100644 --- a/packages/app/src/cli/services/dev/extension.ts +++ b/packages/app/src/cli/services/dev/extension.ts @@ -11,8 +11,15 @@ import {buildCartURLIfNeeded} from './extension/utilities.js' import {ExtensionInstance} from '../../models/extensions/extension-instance.js' import {AbortSignal} from '@shopify/cli-kit/node/abort' import {outputDebug} from '@shopify/cli-kit/node/output' +import {joinPath} from '@shopify/cli-kit/node/path' +import {fileExists} from '@shopify/cli-kit/node/fs' import {DotEnvFile} from '@shopify/cli-kit/node/dot-env' import {Writable} from 'stream' +import type {FSWatcher} from 'chokidar' + +export interface AppAssets { + [key: string]: string +} export interface ExtensionDevOptions { /** @@ -112,6 +119,23 @@ export interface ExtensionDevOptions { * The app watcher that emits events when the app is updated */ appWatcher: AppEventWatcher + + /** + * Map of asset key to absolute directory path for app-level assets (e.g., admin static_root) + */ + appAssets?: AppAssets +} + +export function resolveAppAssets(allExtensions: ExtensionInstance[]): Record { + const appAssets: Record = {} + const adminExtension = allExtensions.find((ext) => ext.specification.identifier === 'admin') + if (adminExtension) { + const staticRoot = (adminExtension.configuration as {admin?: {static_root?: string}}).admin?.static_root + if (staticRoot) { + appAssets.staticRoot = joinPath(adminExtension.directory, staticRoot) + } + } + return appAssets } export async function devUIExtensions(options: ExtensionDevOptions): Promise { @@ -133,15 +157,57 @@ export async function devUIExtensions(options: ExtensionDevOptions): Promise) { + // Close existing watchers + await Promise.all(assetWatchers.map((watcher) => watcher.close())) + // eslint-disable-next-line require-atomic-updates + assetWatchers = [] + + const {default: chokidar} = await import('chokidar') + for (const [assetKey, directoryPath] of Object.entries(assets)) { + const exists = await fileExists(directoryPath) + if (!exists) continue + + let debounceTimer: ReturnType | undefined + const watcher = chokidar.watch(directoryPath, {ignoreInitial: true}) + watcher.on('all', () => { + if (debounceTimer) clearTimeout(debounceTimer) + debounceTimer = setTimeout(() => { + payloadStore.updateAppAssetTimestamp(assetKey) + }, 200) + }) + assetWatchers.push(watcher) + } + } + + if (currentAppAssets && Object.keys(currentAppAssets).length > 0) { + await startAssetWatchers(currentAppAssets) + } + const eventHandler = async ({appWasReloaded, app, extensionEvents}: AppEvent) => { if (appWasReloaded) { extensions = app.allExtensions.filter((ext) => ext.isPreviewable) + + // Re-resolve app assets in case admin extension was added/removed/changed + const newAppAssets = resolveAppAssets(app.allExtensions) + const hasAssets = Object.keys(newAppAssets).length > 0 + payloadOptions.appAssets = hasAssets ? newAppAssets : undefined + await startAssetWatchers(hasAssets ? newAppAssets : {}) } for (const event of extensionEvents) { @@ -178,6 +244,9 @@ export async function devUIExtensions(options: ExtensionDevOptions): Promise { outputDebug('Closing the UI extensions dev server...') + for (const watcher of assetWatchers) { + watcher.close().catch(() => {}) + } websocketConnection.close() httpServer.close() }) diff --git a/packages/app/src/cli/services/dev/extension/payload/models.ts b/packages/app/src/cli/services/dev/extension/payload/models.ts index 796f93108bd..9495c31aab6 100644 --- a/packages/app/src/cli/services/dev/extension/payload/models.ts +++ b/packages/app/src/cli/services/dev/extension/payload/models.ts @@ -8,6 +8,12 @@ interface ExtensionsPayloadInterface { url: string mobileUrl: string title: string + assets?: { + [key: string]: { + url: string + lastUpdated: number + } + } } appId?: string store: string diff --git a/packages/app/src/cli/services/dev/extension/payload/store.ts b/packages/app/src/cli/services/dev/extension/payload/store.ts index ae4919485c1..db1ef4dd8b5 100644 --- a/packages/app/src/cli/services/dev/extension/payload/store.ts +++ b/packages/app/src/cli/services/dev/extension/payload/store.ts @@ -9,6 +9,7 @@ import {EventEmitter} from 'events' export interface ExtensionsPayloadStoreOptions extends ExtensionDevOptions { websocketURL: string + appAssets?: Record } export enum ExtensionsPayloadStoreEvent { @@ -19,7 +20,7 @@ export async function getExtensionsPayloadStoreRawPayload( options: Omit, bundlePath: string, ): Promise { - return { + const payload: ExtensionsEndpointPayload = { app: { title: options.appName, apiKey: options.apiKey, @@ -40,6 +41,19 @@ export async function getExtensionsPayloadStoreRawPayload( store: options.storeFqdn, extensions: await Promise.all(options.extensions.map((ext) => getUIExtensionPayload(ext, bundlePath, options))), } + + if (options.appAssets) { + const assets: Record = {} + for (const [assetKey] of Object.entries(options.appAssets)) { + assets[assetKey] = { + url: new URL('/extensions/admin/assets/', options.url).toString(), + lastUpdated: Date.now(), + } + } + payload.app.assets = assets + } + + return payload } export class ExtensionsPayloadStore extends EventEmitter { @@ -170,6 +184,14 @@ export class ExtensionsPayloadStore extends EventEmitter { this.emitUpdate([extension.devUUID]) } + updateAppAssetTimestamp(assetKey: string) { + const asset = this.rawPayload.app.assets?.[assetKey] + if (asset) { + asset.lastUpdated = Date.now() + this.emitUpdate([]) + } + } + private emitUpdate(extensionIds: string[]) { this.emit(ExtensionsPayloadStoreEvent.Update, extensionIds) } diff --git a/packages/app/src/cli/services/dev/extension/server.ts b/packages/app/src/cli/services/dev/extension/server.ts index 456c8364c61..90bf727415b 100644 --- a/packages/app/src/cli/services/dev/extension/server.ts +++ b/packages/app/src/cli/services/dev/extension/server.ts @@ -2,6 +2,7 @@ import { corsMiddleware, devConsoleAssetsMiddleware, devConsoleIndexMiddleware, + getAppAssetsMiddleware, getExtensionAssetMiddleware, getExtensionPayloadMiddleware, getExtensionPointMiddleware, @@ -19,6 +20,7 @@ interface SetupHTTPServerOptions { devOptions: ExtensionsPayloadStoreOptions payloadStore: ExtensionsPayloadStore getExtensions: () => ExtensionInstance[] + appAssets?: Record } export function setupHTTPServer(options: SetupHTTPServerOptions) { @@ -28,6 +30,9 @@ export function setupHTTPServer(options: SetupHTTPServerOptions) { httpApp.use(getLogMiddleware(options)) httpApp.use(corsMiddleware) httpApp.use(noCacheMiddleware) + if (options.appAssets) { + httpRouter.use('/extensions/admin/assets/**:filePath', getAppAssetsMiddleware(options.appAssets)) + } httpRouter.use('/extensions/dev-console', devConsoleIndexMiddleware) httpRouter.use('/extensions/dev-console/assets/**:assetPath', devConsoleAssetsMiddleware) httpRouter.use('/extensions/:extensionId', getExtensionPayloadMiddleware(options)) diff --git a/packages/app/src/cli/services/dev/extension/server/middlewares.ts b/packages/app/src/cli/services/dev/extension/server/middlewares.ts index ed17a0f474d..29e6f2a1506 100644 --- a/packages/app/src/cli/services/dev/extension/server/middlewares.ts +++ b/packages/app/src/cli/services/dev/extension/server/middlewares.ts @@ -134,6 +134,23 @@ export const devConsoleAssetsMiddleware = defineEventHandler(async (event) => { }) }) +export function getAppAssetsMiddleware(appAssets: Record) { + return defineEventHandler(async (event) => { + const {filePath = ''} = getRouterParams(event) + + // Find the asset directory for 'staticRoot' (the only key currently) + const directory = Object.values(appAssets)[0] + + if (!directory) { + return sendError(event, {statusCode: 404, statusMessage: 'No app assets configured'}) + } + + return fileServerMiddleware(event, { + filePath: joinPath(directory, filePath), + }) + }) +} + export function getLogMiddleware({devOptions}: GetExtensionsMiddlewareOptions) { return defineEventHandler((event) => { outputDebug(`UI extensions server received a ${event.method} request to URL ${event.path}`, devOptions.stdout) diff --git a/packages/app/src/cli/services/dev/processes/previewable-extension.ts b/packages/app/src/cli/services/dev/processes/previewable-extension.ts index 387d97ebeed..4675294d819 100644 --- a/packages/app/src/cli/services/dev/processes/previewable-extension.ts +++ b/packages/app/src/cli/services/dev/processes/previewable-extension.ts @@ -5,6 +5,7 @@ import {buildCartURLIfNeeded} from '../extension/utilities.js' import {AppEventWatcher} from '../app-events/app-event-watcher.js' import {DotEnvFile} from '@shopify/cli-kit/node/dot-env' import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' +import {joinPath} from '@shopify/cli-kit/node/path' const MANIFEST_VERSION = '3' @@ -24,6 +25,7 @@ interface PreviewableExtensionOptions { grantedScopes: string[] previewableExtensions: ExtensionInstance[] appWatcher: AppEventWatcher + appAssets?: Record } export interface PreviewableExtensionProcess extends BaseProcess { @@ -47,6 +49,7 @@ export const launchPreviewableExtensionProcess: DevProcessFunction { await devUIExtensions({ @@ -68,6 +71,7 @@ export const launchPreviewableExtensionProcess: DevProcessFunction = {} + const adminExtension = allExtensions.find((ext) => ext.specification.identifier === 'admin') + if (adminExtension) { + const staticRoot = (adminExtension.configuration as {admin?: {static_root?: string}}).admin?.static_root + if (staticRoot) { + appAssets.staticRoot = joinPath(adminExtension.directory, staticRoot) + } + } + return { prefix: 'extensions', type: 'previewable-extension', @@ -94,6 +108,7 @@ export async function setupPreviewableExtensionsProcess({ storeFqdn, previewableExtensions, cartUrl, + appAssets: Object.keys(appAssets).length > 0 ? appAssets : undefined, ...options, }, } diff --git a/packages/ui-extensions-server-kit/src/types.ts b/packages/ui-extensions-server-kit/src/types.ts index e2ef8e8eb61..43e86fcb2ad 100644 --- a/packages/ui-extensions-server-kit/src/types.ts +++ b/packages/ui-extensions-server-kit/src/types.ts @@ -182,4 +182,10 @@ export interface App { } supportEmail?: string supportLocales?: string[] + assets?: { + [key: string]: { + url: string + lastUpdated: number + } + } } From 34bbcc0494a516b764cbc312e6d17e613d480482 Mon Sep 17 00:00:00 2001 From: Melissa Luu Date: Wed, 1 Apr 2026 19:01:16 -0400 Subject: [PATCH 02/31] Added tests and fix a few callouts from LLM review --- .../src/cli/services/dev/extension.test.ts | 102 +++++++++++-- .../app/src/cli/services/dev/extension.ts | 39 +++-- .../dev/extension/payload/store.test.ts | 139 ++++++++++++++++++ .../services/dev/extension/payload/store.ts | 20 ++- .../src/cli/services/dev/extension/server.ts | 2 +- .../dev/extension/server/middlewares.test.ts | 39 +++++ .../dev/extension/server/middlewares.ts | 7 +- .../dev/processes/previewable-extension.ts | 13 +- 8 files changed, 317 insertions(+), 44 deletions(-) diff --git a/packages/app/src/cli/services/dev/extension.test.ts b/packages/app/src/cli/services/dev/extension.test.ts index a6b696159bf..c29b83dd4df 100644 --- a/packages/app/src/cli/services/dev/extension.test.ts +++ b/packages/app/src/cli/services/dev/extension.test.ts @@ -1,7 +1,7 @@ import * as store from './extension/payload/store.js' import * as server from './extension/server.js' import * as websocket from './extension/websocket.js' -import {devUIExtensions, ExtensionDevOptions} from './extension.js' +import {devUIExtensions, ExtensionDevOptions, resolveAppAssets} from './extension.js' import {ExtensionsEndpointPayload} from './extension/payload/models.js' import {WebsocketConnection} from './extension/websocket/models.js' import {AppEventWatcher} from './app-events/app-event-watcher.js' @@ -33,7 +33,10 @@ describe('devUIExtensions()', () => { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - vi.spyOn(store, 'ExtensionsPayloadStore').mockImplementation(() => ({mock: 'payload-store'})) + vi.spyOn(store, 'ExtensionsPayloadStore').mockImplementation(() => ({ + mock: 'payload-store', + updateAppAssets: vi.fn(), + })) vi.spyOn(server, 'setupHTTPServer').mockReturnValue({ mock: 'http-server', close: serverCloseSpy, @@ -65,11 +68,13 @@ describe('devUIExtensions()', () => { await devUIExtensions(options) // THEN - expect(server.setupHTTPServer).toHaveBeenCalledWith({ - devOptions: {...options, websocketURL: 'wss://mock.url/extensions'}, - payloadStore: {mock: 'payload-store'}, - getExtensions: expect.any(Function), - }) + expect(server.setupHTTPServer).toHaveBeenCalledWith( + expect.objectContaining({ + devOptions: expect.objectContaining({websocketURL: 'wss://mock.url/extensions'}), + payloadStore: expect.objectContaining({mock: 'payload-store'}), + getExtensions: expect.any(Function), + }), + ) }) test('initializes the HTTP server with a getExtensions function that returns the extensions from the provided options', async () => { @@ -91,12 +96,13 @@ describe('devUIExtensions()', () => { await devUIExtensions(options) // THEN - expect(websocket.setupWebsocketConnection).toHaveBeenCalledWith({ - ...options, - httpServer: expect.objectContaining({mock: 'http-server'}), - payloadStore: {mock: 'payload-store'}, - websocketURL: 'wss://mock.url/extensions', - }) + expect(websocket.setupWebsocketConnection).toHaveBeenCalledWith( + expect.objectContaining({ + httpServer: expect.objectContaining({mock: 'http-server'}), + payloadStore: expect.objectContaining({mock: 'payload-store'}), + websocketURL: 'wss://mock.url/extensions', + }), + ) }) test('closes the http server, websocket and bundler when the process aborts', async () => { @@ -128,14 +134,80 @@ describe('devUIExtensions()', () => { const {getExtensions} = vi.mocked(server.setupHTTPServer).mock.calls[0]![0] expect(getExtensions()).toStrictEqual(options.extensions) - const newUIExtension = {type: 'ui_extension', devUUID: 'BAR', isPreviewable: true} + const newUIExtension = { + type: 'ui_extension', + devUUID: 'BAR', + isPreviewable: true, + specification: {identifier: 'ui_extension'}, + } const newApp = { ...app, - allExtensions: [newUIExtension, {type: 'function_extension', devUUID: 'FUNCTION', isPreviewable: false}], + allExtensions: [ + newUIExtension, + { + type: 'function_extension', + devUUID: 'FUNCTION', + isPreviewable: false, + specification: {identifier: 'function'}, + }, + ], } options.appWatcher.emit('all', {app: newApp, appWasReloaded: true, extensionEvents: []}) // THEN expect(getExtensions()).toStrictEqual([newUIExtension]) }) + + test('passes appAssets to the HTTP server when provided', async () => { + // GIVEN + spyOnEverything() + const optionsWithAssets = { + ...options, + appAssets: {staticRoot: '/absolute/path/to/public'}, + } as unknown as ExtensionDevOptions + + // WHEN + await devUIExtensions(optionsWithAssets) + + // THEN + expect(server.setupHTTPServer).toHaveBeenCalledWith( + expect.objectContaining({ + appAssets: {staticRoot: '/absolute/path/to/public'}, + }), + ) + }) +}) + +describe('resolveAppAssets()', () => { + test('returns empty object when no admin extension exists', () => { + const extensions = [ + {specification: {identifier: 'ui_extension'}, configuration: {}, directory: '/app'}, + ] as unknown as Parameters[0] + + expect(resolveAppAssets(extensions)).toStrictEqual({}) + }) + + test('returns empty object when admin extension has no static_root', () => { + const extensions = [ + {specification: {identifier: 'admin'}, configuration: {admin: {}}, directory: '/app/extensions/admin'}, + ] as unknown as Parameters[0] + + expect(resolveAppAssets(extensions)).toStrictEqual({}) + }) + + test('returns staticRoot mapped to resolved absolute path when static_root is set', () => { + const extensions = [ + { + specification: {identifier: 'admin'}, + configuration: {admin: {static_root: 'public'}}, + directory: '/app/extensions/admin', + }, + ] as unknown as Parameters[0] + + const result = resolveAppAssets(extensions) + + expect(result).toStrictEqual({ + staticRoot: '/app/extensions/admin/public', + }) + }) }) diff --git a/packages/app/src/cli/services/dev/extension.ts b/packages/app/src/cli/services/dev/extension.ts index e9ab9f346f9..3ac6e7db255 100644 --- a/packages/app/src/cli/services/dev/extension.ts +++ b/packages/app/src/cli/services/dev/extension.ts @@ -12,6 +12,7 @@ import {ExtensionInstance} from '../../models/extensions/extension-instance.js' import {AbortSignal} from '@shopify/cli-kit/node/abort' import {outputDebug} from '@shopify/cli-kit/node/output' import {joinPath} from '@shopify/cli-kit/node/path' +import {getPathValue} from '@shopify/cli-kit/common/object' import {fileExists} from '@shopify/cli-kit/node/fs' import {DotEnvFile} from '@shopify/cli-kit/node/dot-env' import {Writable} from 'stream' @@ -130,9 +131,9 @@ export function resolveAppAssets(allExtensions: ExtensionInstance[]): Record = {} const adminExtension = allExtensions.find((ext) => ext.specification.identifier === 'admin') if (adminExtension) { - const staticRoot = (adminExtension.configuration as {admin?: {static_root?: string}}).admin?.static_root - if (staticRoot) { - appAssets.staticRoot = joinPath(adminExtension.directory, staticRoot) + const staticRootPath = getPathValue(adminExtension.configuration, 'admin.static_root') + if (staticRootPath) { + appAssets.staticRoot = joinPath(adminExtension.directory, staticRootPath) } } return appAssets @@ -171,9 +172,29 @@ export async function devUIExtensions(options: ExtensionDevOptions): Promise[] = [] + + function createDebouncedAssetUpdater(assetKey: string) { + let debounceTimer: ReturnType | undefined + return () => { + if (debounceTimer) clearTimeout(debounceTimer) + debounceTimer = setTimeout(() => { + payloadStore.updateAppAssetTimestamp(assetKey) + }, 200) + debounceTimers.push(debounceTimer) + } + } + + function clearDebounceTimers() { + for (const timer of debounceTimers) { + clearTimeout(timer) + } + debounceTimers = [] + } async function startAssetWatchers(assets: Record) { - // Close existing watchers + // Clear pending debounce timers and close existing watchers + clearDebounceTimers() await Promise.all(assetWatchers.map((watcher) => watcher.close())) // eslint-disable-next-line require-atomic-updates assetWatchers = [] @@ -183,14 +204,8 @@ export async function devUIExtensions(options: ExtensionDevOptions): Promise | undefined const watcher = chokidar.watch(directoryPath, {ignoreInitial: true}) - watcher.on('all', () => { - if (debounceTimer) clearTimeout(debounceTimer) - debounceTimer = setTimeout(() => { - payloadStore.updateAppAssetTimestamp(assetKey) - }, 200) - }) + watcher.on('all', createDebouncedAssetUpdater(assetKey)) assetWatchers.push(watcher) } } @@ -207,6 +222,7 @@ export async function devUIExtensions(options: ExtensionDevOptions): Promise 0 payloadOptions.appAssets = hasAssets ? newAppAssets : undefined + payloadStore.updateAppAssets(payloadOptions.appAssets, payloadOptions.url) await startAssetWatchers(hasAssets ? newAppAssets : {}) } @@ -244,6 +260,7 @@ export async function devUIExtensions(options: ExtensionDevOptions): Promise { outputDebug('Closing the UI extensions dev server...') + clearDebounceTimers() for (const watcher of assetWatchers) { watcher.close().catch(() => {}) } diff --git a/packages/app/src/cli/services/dev/extension/payload/store.test.ts b/packages/app/src/cli/services/dev/extension/payload/store.test.ts index cd9a5229da8..4be0b5a8adf 100644 --- a/packages/app/src/cli/services/dev/extension/payload/store.test.ts +++ b/packages/app/src/cli/services/dev/extension/payload/store.test.ts @@ -365,4 +365,143 @@ describe('ExtensionsPayloadStore()', () => { expect(onUpdateSpy).not.toHaveBeenCalled() }) }) + + describe('updateAppAssetTimestamp()', () => { + test('updates lastUpdated for the given asset key and emits update', () => { + // Given + const mockPayload = { + app: { + assets: { + staticRoot: {url: 'https://mock.url/extensions/assets/staticRoot/', lastUpdated: 1000}, + }, + }, + extensions: [], + } as unknown as ExtensionsEndpointPayload + + const extensionsPayloadStore = new ExtensionsPayloadStore(mockPayload, mockOptions) + const onUpdateSpy = vi.fn() + extensionsPayloadStore.on(ExtensionsPayloadStoreEvent.Update, onUpdateSpy) + + // When + extensionsPayloadStore.updateAppAssetTimestamp('staticRoot') + + // Then + const asset = extensionsPayloadStore.getRawPayload().app.assets?.staticRoot + expect(asset?.url).toBe('https://mock.url/extensions/assets/staticRoot/') + expect(asset?.lastUpdated).toBeGreaterThan(1000) + expect(onUpdateSpy).toHaveBeenCalledWith([]) + }) + + test('does nothing if the asset key does not exist', () => { + // Given + const mockPayload = { + app: {assets: {}}, + extensions: [], + } as unknown as ExtensionsEndpointPayload + + const extensionsPayloadStore = new ExtensionsPayloadStore(mockPayload, mockOptions) + const onUpdateSpy = vi.fn() + extensionsPayloadStore.on(ExtensionsPayloadStoreEvent.Update, onUpdateSpy) + + // When + extensionsPayloadStore.updateAppAssetTimestamp('nonExistent') + + // Then + expect(onUpdateSpy).not.toHaveBeenCalled() + }) + }) + + describe('updateAppAssets()', () => { + test('sets app.assets from the provided appAssets map', () => { + // Given + const mockPayload = { + app: {}, + extensions: [], + } as unknown as ExtensionsEndpointPayload + + const extensionsPayloadStore = new ExtensionsPayloadStore(mockPayload, mockOptions) + const onUpdateSpy = vi.fn() + extensionsPayloadStore.on(ExtensionsPayloadStoreEvent.Update, onUpdateSpy) + + // When + extensionsPayloadStore.updateAppAssets({staticRoot: '/path/to/public'}, 'https://mock.url') + + // Then + const assets = extensionsPayloadStore.getRawPayload().app.assets + expect(assets?.staticRoot?.url).toBe('https://mock.url/extensions/assets/staticRoot/') + expect(assets?.staticRoot?.lastUpdated).toBeGreaterThan(0) + expect(onUpdateSpy).toHaveBeenCalledWith([]) + }) + + test('removes app.assets when appAssets is undefined', () => { + // Given + const mockPayload = { + app: { + assets: { + staticRoot: {url: 'https://mock.url/extensions/assets/staticRoot/', lastUpdated: 1000}, + }, + }, + extensions: [], + } as unknown as ExtensionsEndpointPayload + + const extensionsPayloadStore = new ExtensionsPayloadStore(mockPayload, mockOptions) + const onUpdateSpy = vi.fn() + extensionsPayloadStore.on(ExtensionsPayloadStoreEvent.Update, onUpdateSpy) + + // When + extensionsPayloadStore.updateAppAssets(undefined, 'https://mock.url') + + // Then + expect(extensionsPayloadStore.getRawPayload().app.assets).toBeUndefined() + expect(onUpdateSpy).toHaveBeenCalledWith([]) + }) + }) +}) + +describe('getExtensionsPayloadStoreRawPayload() with appAssets', () => { + test('populates app.assets when appAssets option is provided', async () => { + vi.spyOn(payload, 'getUIExtensionPayload').mockResolvedValue({ + mock: 'extension-payload', + } as unknown as UIExtensionPayload) + + const options = { + apiKey: 'mock-api-key', + appName: 'mock-app-name', + url: 'https://mock-url.com', + websocketURL: 'wss://mock-websocket-url.com', + extensions: [], + storeFqdn: 'mock-store-fqdn.myshopify.com', + manifestVersion: '3', + appAssets: {staticRoot: '/path/to/public'}, + } as unknown as ExtensionsPayloadStoreOptions + + const rawPayload = await getExtensionsPayloadStoreRawPayload(options, 'mock-bundle-path') + + expect(rawPayload.app.assets).toStrictEqual({ + staticRoot: { + url: 'https://mock-url.com/extensions/assets/staticRoot/', + lastUpdated: expect.any(Number), + }, + }) + }) + + test('does not set app.assets when appAssets option is not provided', async () => { + vi.spyOn(payload, 'getUIExtensionPayload').mockResolvedValue({ + mock: 'extension-payload', + } as unknown as UIExtensionPayload) + + const options = { + apiKey: 'mock-api-key', + appName: 'mock-app-name', + url: 'https://mock-url.com', + websocketURL: 'wss://mock-websocket-url.com', + extensions: [], + storeFqdn: 'mock-store-fqdn.myshopify.com', + manifestVersion: '3', + } as unknown as ExtensionsPayloadStoreOptions + + const rawPayload = await getExtensionsPayloadStoreRawPayload(options, 'mock-bundle-path') + + expect(rawPayload.app.assets).toBeUndefined() + }) }) diff --git a/packages/app/src/cli/services/dev/extension/payload/store.ts b/packages/app/src/cli/services/dev/extension/payload/store.ts index db1ef4dd8b5..5b28e11787f 100644 --- a/packages/app/src/cli/services/dev/extension/payload/store.ts +++ b/packages/app/src/cli/services/dev/extension/payload/store.ts @@ -44,9 +44,9 @@ export async function getExtensionsPayloadStoreRawPayload( if (options.appAssets) { const assets: Record = {} - for (const [assetKey] of Object.entries(options.appAssets)) { + for (const assetKey of Object.keys(options.appAssets)) { assets[assetKey] = { - url: new URL('/extensions/admin/assets/', options.url).toString(), + url: new URL(`/extensions/assets/${assetKey}/`, options.url).toString(), lastUpdated: Date.now(), } } @@ -184,6 +184,22 @@ export class ExtensionsPayloadStore extends EventEmitter { this.emitUpdate([extension.devUUID]) } + updateAppAssets(appAssets: Record | undefined, url: string) { + if (!appAssets || Object.keys(appAssets).length === 0) { + delete this.rawPayload.app.assets + } else { + const assets: Record = {} + for (const assetKey of Object.keys(appAssets)) { + assets[assetKey] = { + url: new URL(`/extensions/assets/${assetKey}/`, url).toString(), + lastUpdated: Date.now(), + } + } + this.rawPayload.app.assets = assets + } + this.emitUpdate([]) + } + updateAppAssetTimestamp(assetKey: string) { const asset = this.rawPayload.app.assets?.[assetKey] if (asset) { diff --git a/packages/app/src/cli/services/dev/extension/server.ts b/packages/app/src/cli/services/dev/extension/server.ts index 90bf727415b..4c8e030d922 100644 --- a/packages/app/src/cli/services/dev/extension/server.ts +++ b/packages/app/src/cli/services/dev/extension/server.ts @@ -31,7 +31,7 @@ export function setupHTTPServer(options: SetupHTTPServerOptions) { httpApp.use(corsMiddleware) httpApp.use(noCacheMiddleware) if (options.appAssets) { - httpRouter.use('/extensions/admin/assets/**:filePath', getAppAssetsMiddleware(options.appAssets)) + httpRouter.use('/extensions/assets/:assetKey/**:filePath', getAppAssetsMiddleware(options.appAssets)) } httpRouter.use('/extensions/dev-console', devConsoleIndexMiddleware) httpRouter.use('/extensions/dev-console/assets/**:assetPath', devConsoleAssetsMiddleware) diff --git a/packages/app/src/cli/services/dev/extension/server/middlewares.test.ts b/packages/app/src/cli/services/dev/extension/server/middlewares.test.ts index 69924d80cc9..56cb48016a7 100644 --- a/packages/app/src/cli/services/dev/extension/server/middlewares.test.ts +++ b/packages/app/src/cli/services/dev/extension/server/middlewares.test.ts @@ -1,5 +1,6 @@ import { corsMiddleware, + getAppAssetsMiddleware, getExtensionAssetMiddleware, getExtensionPayloadMiddleware, fileServerMiddleware, @@ -573,3 +574,41 @@ describe('getExtensionPointMiddleware()', () => { expect(h3.sendRedirect).toHaveBeenCalledWith(event, 'http://www.mock.com/redirect/url', 307) }) }) + +describe('getAppAssetsMiddleware()', () => { + test('serves a file from the matching asset directory', async () => { + await inTemporaryDirectory(async (tmpDir: string) => { + const assetDir = joinPath(tmpDir, 'public') + await mkdir(assetDir) + await writeFile(joinPath(assetDir, 'icon.png'), 'png-content') + + const middleware = getAppAssetsMiddleware({staticRoot: assetDir}) + + const event = getMockEvent({ + params: {assetKey: 'staticRoot', filePath: 'icon.png'}, + }) + + const result = await middleware(event) + + expect(event.node.res.setHeader).toHaveBeenCalledWith('Content-Type', 'image/png') + expect(result).toBe('png-content') + }) + }) + + test('returns 404 for an unknown asset key', async () => { + vi.spyOn(utilities, 'sendError').mockImplementation(() => {}) + + const middleware = getAppAssetsMiddleware({staticRoot: '/some/path'}) + + const event = getMockEvent({ + params: {assetKey: 'unknown', filePath: 'icon.png'}, + }) + + await middleware(event) + + expect(utilities.sendError).toHaveBeenCalledWith(event, { + statusCode: 404, + statusMessage: 'No app assets configured for key: unknown', + }) + }) +}) diff --git a/packages/app/src/cli/services/dev/extension/server/middlewares.ts b/packages/app/src/cli/services/dev/extension/server/middlewares.ts index 29e6f2a1506..fe5b1538d03 100644 --- a/packages/app/src/cli/services/dev/extension/server/middlewares.ts +++ b/packages/app/src/cli/services/dev/extension/server/middlewares.ts @@ -136,13 +136,12 @@ export const devConsoleAssetsMiddleware = defineEventHandler(async (event) => { export function getAppAssetsMiddleware(appAssets: Record) { return defineEventHandler(async (event) => { - const {filePath = ''} = getRouterParams(event) + const {assetKey = '', filePath = ''} = getRouterParams(event) - // Find the asset directory for 'staticRoot' (the only key currently) - const directory = Object.values(appAssets)[0] + const directory = appAssets[assetKey] if (!directory) { - return sendError(event, {statusCode: 404, statusMessage: 'No app assets configured'}) + return sendError(event, {statusCode: 404, statusMessage: `No app assets configured for key: ${assetKey}`}) } return fileServerMiddleware(event, { diff --git a/packages/app/src/cli/services/dev/processes/previewable-extension.ts b/packages/app/src/cli/services/dev/processes/previewable-extension.ts index 4675294d819..c5fc8317c45 100644 --- a/packages/app/src/cli/services/dev/processes/previewable-extension.ts +++ b/packages/app/src/cli/services/dev/processes/previewable-extension.ts @@ -1,11 +1,10 @@ import {BaseProcess, DevProcessFunction} from './types.js' -import {devUIExtensions} from '../extension.js' +import {devUIExtensions, resolveAppAssets} from '../extension.js' import {ExtensionInstance} from '../../../models/extensions/extension-instance.js' import {buildCartURLIfNeeded} from '../extension/utilities.js' import {AppEventWatcher} from '../app-events/app-event-watcher.js' import {DotEnvFile} from '@shopify/cli-kit/node/dot-env' import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' -import {joinPath} from '@shopify/cli-kit/node/path' const MANIFEST_VERSION = '3' @@ -88,15 +87,7 @@ export async function setupPreviewableExtensionsProcess({ const cartUrl = await buildCartURLIfNeeded(previewableExtensions, storeFqdn, checkoutCartUrl) - // Resolve app-level asset directories from admin extensions - const appAssets: Record = {} - const adminExtension = allExtensions.find((ext) => ext.specification.identifier === 'admin') - if (adminExtension) { - const staticRoot = (adminExtension.configuration as {admin?: {static_root?: string}}).admin?.static_root - if (staticRoot) { - appAssets.staticRoot = joinPath(adminExtension.directory, staticRoot) - } - } + const appAssets = resolveAppAssets(allExtensions) return { prefix: 'extensions', From 1b6ad754d072434f8dbc361639709680a4a05c27 Mon Sep 17 00:00:00 2001 From: Melissa Luu Date: Wed, 1 Apr 2026 19:05:27 -0400 Subject: [PATCH 03/31] Remove unnecessary export from AppAssets interface Co-Authored-By: Claude Opus 4.6 --- packages/app/src/cli/services/dev/extension.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/cli/services/dev/extension.ts b/packages/app/src/cli/services/dev/extension.ts index 3ac6e7db255..6997e5713c9 100644 --- a/packages/app/src/cli/services/dev/extension.ts +++ b/packages/app/src/cli/services/dev/extension.ts @@ -18,7 +18,7 @@ import {DotEnvFile} from '@shopify/cli-kit/node/dot-env' import {Writable} from 'stream' import type {FSWatcher} from 'chokidar' -export interface AppAssets { +interface AppAssets { [key: string]: string } From a349d95f5f8df6c392b39da4aa4a91b2e38e764a Mon Sep 17 00:00:00 2001 From: Melissa Luu Date: Wed, 1 Apr 2026 19:54:44 -0400 Subject: [PATCH 04/31] Address PR feedback: use getAppAssets callback and fix debounce timer leak - Changed appAssets from a captured reference to a getAppAssets() callback in setupHTTPServer and getAppAssetsMiddleware, so the middleware always reads fresh asset config after app reloads - Replaced unbounded debounceTimers array with a Map keyed by assetKey, so each key has at most one timer entry Co-Authored-By: Claude Opus 4.6 --- .../src/cli/services/dev/extension.test.ts | 7 +++-- .../app/src/cli/services/dev/extension.ts | 30 +++++++++++-------- .../src/cli/services/dev/extension/server.ts | 6 ++-- .../dev/extension/server/middlewares.test.ts | 4 +-- .../dev/extension/server/middlewares.ts | 5 ++-- 5 files changed, 30 insertions(+), 22 deletions(-) diff --git a/packages/app/src/cli/services/dev/extension.test.ts b/packages/app/src/cli/services/dev/extension.test.ts index c29b83dd4df..d69838e49cb 100644 --- a/packages/app/src/cli/services/dev/extension.test.ts +++ b/packages/app/src/cli/services/dev/extension.test.ts @@ -158,7 +158,7 @@ describe('devUIExtensions()', () => { expect(getExtensions()).toStrictEqual([newUIExtension]) }) - test('passes appAssets to the HTTP server when provided', async () => { + test('passes getAppAssets callback to the HTTP server when appAssets provided', async () => { // GIVEN spyOnEverything() const optionsWithAssets = { @@ -172,9 +172,12 @@ describe('devUIExtensions()', () => { // THEN expect(server.setupHTTPServer).toHaveBeenCalledWith( expect.objectContaining({ - appAssets: {staticRoot: '/absolute/path/to/public'}, + getAppAssets: expect.any(Function), }), ) + + const {getAppAssets} = vi.mocked(server.setupHTTPServer).mock.calls[0]![0] + expect(getAppAssets!()).toStrictEqual({staticRoot: '/absolute/path/to/public'}) }) }) diff --git a/packages/app/src/cli/services/dev/extension.ts b/packages/app/src/cli/services/dev/extension.ts index 6997e5713c9..7ac040b416e 100644 --- a/packages/app/src/cli/services/dev/extension.ts +++ b/packages/app/src/cli/services/dev/extension.ts @@ -158,12 +158,12 @@ export async function devUIExtensions(options: ExtensionDevOptions): Promise payloadOptions.appAssets const httpServer = setupHTTPServer({ devOptions: payloadOptions, payloadStore, getExtensions, - appAssets: currentAppAssets, + getAppAssets, }) outputDebug(`Setting up the UI extensions Websocket server...`, payloadOptions.stdout) @@ -172,24 +172,27 @@ export async function devUIExtensions(options: ExtensionDevOptions): Promise[] = [] + const debounceTimers = new Map>() function createDebouncedAssetUpdater(assetKey: string) { - let debounceTimer: ReturnType | undefined return () => { - if (debounceTimer) clearTimeout(debounceTimer) - debounceTimer = setTimeout(() => { - payloadStore.updateAppAssetTimestamp(assetKey) - }, 200) - debounceTimers.push(debounceTimer) + const existing = debounceTimers.get(assetKey) + if (existing) clearTimeout(existing) + debounceTimers.set( + assetKey, + setTimeout(() => { + debounceTimers.delete(assetKey) + payloadStore.updateAppAssetTimestamp(assetKey) + }, 200), + ) } } function clearDebounceTimers() { - for (const timer of debounceTimers) { + for (const timer of debounceTimers.values()) { clearTimeout(timer) } - debounceTimers = [] + debounceTimers.clear() } async function startAssetWatchers(assets: Record) { @@ -210,8 +213,9 @@ export async function devUIExtensions(options: ExtensionDevOptions): Promise 0) { - await startAssetWatchers(currentAppAssets) + const initialAppAssets = payloadOptions.appAssets + if (initialAppAssets && Object.keys(initialAppAssets).length > 0) { + await startAssetWatchers(initialAppAssets) } const eventHandler = async ({appWasReloaded, app, extensionEvents}: AppEvent) => { diff --git a/packages/app/src/cli/services/dev/extension/server.ts b/packages/app/src/cli/services/dev/extension/server.ts index 4c8e030d922..b7a5bd7adec 100644 --- a/packages/app/src/cli/services/dev/extension/server.ts +++ b/packages/app/src/cli/services/dev/extension/server.ts @@ -20,7 +20,7 @@ interface SetupHTTPServerOptions { devOptions: ExtensionsPayloadStoreOptions payloadStore: ExtensionsPayloadStore getExtensions: () => ExtensionInstance[] - appAssets?: Record + getAppAssets?: () => Record | undefined } export function setupHTTPServer(options: SetupHTTPServerOptions) { @@ -30,8 +30,8 @@ export function setupHTTPServer(options: SetupHTTPServerOptions) { httpApp.use(getLogMiddleware(options)) httpApp.use(corsMiddleware) httpApp.use(noCacheMiddleware) - if (options.appAssets) { - httpRouter.use('/extensions/assets/:assetKey/**:filePath', getAppAssetsMiddleware(options.appAssets)) + if (options.getAppAssets) { + httpRouter.use('/extensions/assets/:assetKey/**:filePath', getAppAssetsMiddleware(options.getAppAssets)) } httpRouter.use('/extensions/dev-console', devConsoleIndexMiddleware) httpRouter.use('/extensions/dev-console/assets/**:assetPath', devConsoleAssetsMiddleware) diff --git a/packages/app/src/cli/services/dev/extension/server/middlewares.test.ts b/packages/app/src/cli/services/dev/extension/server/middlewares.test.ts index 56cb48016a7..651c4fc86c5 100644 --- a/packages/app/src/cli/services/dev/extension/server/middlewares.test.ts +++ b/packages/app/src/cli/services/dev/extension/server/middlewares.test.ts @@ -582,7 +582,7 @@ describe('getAppAssetsMiddleware()', () => { await mkdir(assetDir) await writeFile(joinPath(assetDir, 'icon.png'), 'png-content') - const middleware = getAppAssetsMiddleware({staticRoot: assetDir}) + const middleware = getAppAssetsMiddleware(() => ({staticRoot: assetDir})) const event = getMockEvent({ params: {assetKey: 'staticRoot', filePath: 'icon.png'}, @@ -598,7 +598,7 @@ describe('getAppAssetsMiddleware()', () => { test('returns 404 for an unknown asset key', async () => { vi.spyOn(utilities, 'sendError').mockImplementation(() => {}) - const middleware = getAppAssetsMiddleware({staticRoot: '/some/path'}) + const middleware = getAppAssetsMiddleware(() => ({staticRoot: '/some/path'})) const event = getMockEvent({ params: {assetKey: 'unknown', filePath: 'icon.png'}, diff --git a/packages/app/src/cli/services/dev/extension/server/middlewares.ts b/packages/app/src/cli/services/dev/extension/server/middlewares.ts index fe5b1538d03..7dd0993cac1 100644 --- a/packages/app/src/cli/services/dev/extension/server/middlewares.ts +++ b/packages/app/src/cli/services/dev/extension/server/middlewares.ts @@ -134,11 +134,12 @@ export const devConsoleAssetsMiddleware = defineEventHandler(async (event) => { }) }) -export function getAppAssetsMiddleware(appAssets: Record) { +export function getAppAssetsMiddleware(getAppAssets: () => Record | undefined) { return defineEventHandler(async (event) => { const {assetKey = '', filePath = ''} = getRouterParams(event) - const directory = appAssets[assetKey] + const appAssets = getAppAssets() + const directory = appAssets?.[assetKey] if (!directory) { return sendError(event, {statusCode: 404, statusMessage: `No app assets configured for key: ${assetKey}`}) From 5ce5d81106ca755eb6e6f2778322840b94b3245d Mon Sep 17 00:00:00 2001 From: Melissa Luu Date: Thu, 2 Apr 2026 14:54:49 -0400 Subject: [PATCH 05/31] Hook app asset file watcher into the existing app event watcher list and fix upload assets on change Co-authored-by: Trish Ta --- .../models/extensions/specifications/admin.ts | 4 +- .../app-events/app-event-watcher-handler.ts | 13 ++++ .../dev/app-events/app-event-watcher.ts | 1 + .../dev/app-events/file-watcher.test.ts | 6 +- .../services/dev/app-events/file-watcher.ts | 65 +++++++++++++++++- .../app/src/cli/services/dev/extension.ts | 68 ++----------------- 6 files changed, 90 insertions(+), 67 deletions(-) diff --git a/packages/app/src/cli/models/extensions/specifications/admin.ts b/packages/app/src/cli/models/extensions/specifications/admin.ts index 0aea2ee661a..14a1b5cd3c8 100644 --- a/packages/app/src/cli/models/extensions/specifications/admin.ts +++ b/packages/app/src/cli/models/extensions/specifications/admin.ts @@ -3,6 +3,7 @@ import {createContractBasedModuleSpecification} from '../specification.js' const adminSpecificationSpec = createContractBasedModuleSpecification({ identifier: 'admin', uidStrategy: 'single', + experience: 'configuration', transformRemoteToLocal: (remoteContent) => { return { admin: { @@ -24,7 +25,8 @@ const adminSpecificationSpec = createContractBasedModuleSpecification({ name: 'Hosted App Copy Files', type: 'include_assets', config: { - generatesAssetsManifest: true, + // Remove this until we fix the bug related to recreating the manifest during dev + generatesAssetsManifest: false, inclusions: [ { type: 'configKey', diff --git a/packages/app/src/cli/services/dev/app-events/app-event-watcher-handler.ts b/packages/app/src/cli/services/dev/app-events/app-event-watcher-handler.ts index e54a27ba937..fafd5d3ed21 100644 --- a/packages/app/src/cli/services/dev/app-events/app-event-watcher-handler.ts +++ b/packages/app/src/cli/services/dev/app-events/app-event-watcher-handler.ts @@ -32,6 +32,7 @@ export async function handleWatcherEvents( const affectedExtensions = app.realExtensions.filter((ext) => ext.directory === event.extensionPath) const newEvent = handlers[event.type]({event, app: appEvent.app, extensions: affectedExtensions, options}) appEvent.extensionEvents.push(...newEvent.extensionEvents) + if (newEvent.appAssetsUpdated) appEvent.appAssetsUpdated = true } return appEvent @@ -61,6 +62,7 @@ const handlers: {[key in WatcherEvent['type']]: Handler} = { file_deleted: FileChangeHandler, file_updated: FileChangeHandler, app_config_deleted: AppConfigDeletedHandler, + app_asset_updated: AppAssetUpdatedHandler, // These two are processed manually to avoid multiple reloads extension_folder_created: EmptyHandler, extensions_config_updated: EmptyHandler, @@ -91,6 +93,17 @@ function FileChangeHandler({event, app, extensions}: HandlerInput): AppEvent { return {app, extensionEvents: events, startTime: event.startTime, path: event.path} } +/** + * When a file inside an app asset directory (e.g. static_root) is updated: + * Find the owning extension via extensionPath and return an Updated event so it gets rebuilt. + * The rebuild runs the include_assets step which copies the changed files into the bundle. + */ +function AppAssetUpdatedHandler({event, app}: HandlerInput): AppEvent { + const adminExtension = app.realExtensions.find((ext) => ext.specification.identifier === 'admin') + const events: ExtensionEvent[] = adminExtension ? [{type: EventType.Updated, extension: adminExtension}] : [] + return {app, extensionEvents: events, startTime: event.startTime, path: event.path, appAssetsUpdated: true} +} + /** * When an event doesn't require any action, return the same app and an empty event. */ diff --git a/packages/app/src/cli/services/dev/app-events/app-event-watcher.ts b/packages/app/src/cli/services/dev/app-events/app-event-watcher.ts index 5a760de6332..2436bfc9448 100644 --- a/packages/app/src/cli/services/dev/app-events/app-event-watcher.ts +++ b/packages/app/src/cli/services/dev/app-events/app-event-watcher.ts @@ -82,6 +82,7 @@ export interface AppEvent { path: string startTime: [number, number] appWasReloaded?: boolean + appAssetsUpdated?: boolean } type ExtensionBuildResult = {status: 'ok'; uid: string} | {status: 'error'; error: string; file?: string; uid: string} diff --git a/packages/app/src/cli/services/dev/app-events/file-watcher.test.ts b/packages/app/src/cli/services/dev/app-events/file-watcher.test.ts index ca479009e0a..aed4668a773 100644 --- a/packages/app/src/cli/services/dev/app-events/file-watcher.test.ts +++ b/packages/app/src/cli/services/dev/app-events/file-watcher.test.ts @@ -263,15 +263,15 @@ describe('file-watcher events', () => { // Then expect(watchSpy).toHaveBeenCalledWith([joinPath(dir, '/shopify.app.toml'), joinPath(dir, '/extensions')], { - ignored: [ + ignored: expect.arrayContaining([ '**/node_modules/**', '**/.git/**', '**/*.test.*', - '**/dist/**', '**/*.swp', '**/generated/**', '**/.gitignore', - ], + expect.any(Function), + ]), ignoreInitial: true, persistent: true, }) diff --git a/packages/app/src/cli/services/dev/app-events/file-watcher.ts b/packages/app/src/cli/services/dev/app-events/file-watcher.ts index fae4b59f190..8d273bf3b6e 100644 --- a/packages/app/src/cli/services/dev/app-events/file-watcher.ts +++ b/packages/app/src/cli/services/dev/app-events/file-watcher.ts @@ -1,6 +1,7 @@ /* eslint-disable no-case-declarations */ import {AppLinkedInterface} from '../../../models/app/app.js' import {configurationFileNames} from '../../../constants.js' +import {ExtensionInstance} from '../../../models/extensions/extension-instance.js' import {dirname, joinPath, normalizePath, relativePath} from '@shopify/cli-kit/node/path' import {FSWatcher} from 'chokidar' import {outputDebug} from '@shopify/cli-kit/node/output' @@ -9,6 +10,7 @@ import {startHRTime, StartTime} from '@shopify/cli-kit/node/hrtime' import {fileExistsSync, matchGlob, mkdir, readFileSync} from '@shopify/cli-kit/node/fs' import {debounce} from '@shopify/cli-kit/common/function' import ignore from 'ignore' +import {getPathValue} from '@shopify/cli-kit/common/object' import {Writable} from 'stream' const DEFAULT_DEBOUNCE_TIME_IN_MS = 200 @@ -36,6 +38,7 @@ export interface WatcherEvent { | 'file_deleted' | 'extensions_config_updated' | 'app_config_deleted' + | 'app_asset_updated' path: string extensionPath: string startTime: StartTime @@ -58,6 +61,8 @@ export class FileWatcher { private readonly ignored: {[key: string]: ignore.Ignore | undefined} = {} // Map of file paths to the extensions that watch them private readonly extensionWatchedFiles = new Map>() + // Map of asset directory path to the extension directory that owns it + private appAssetToExtensionDir = new Map() constructor( app: AppLinkedInterface, @@ -104,7 +109,9 @@ export class FileWatcher { }), ) + this.appAssetToExtensionDir = this.resolveAppAssetWatchPaths(this.app.realExtensions) const watchPaths = [this.app.configPath, ...fullExtensionDirectories] + Array.from(this.appAssetToExtensionDir.keys()).forEach((key) => watchPaths.push(key)) // Get all watched files from extensions const allWatchedFiles = this.getAllWatchedFiles() @@ -114,15 +121,24 @@ export class FileWatcher { // Create new watcher const {default: chokidar} = await import('chokidar') + const appAssetDirs = [...this.appAssetToExtensionDir.keys()] this.watcher = chokidar.watch(watchPaths, { ignored: [ '**/node_modules/**', '**/.git/**', '**/*.test.*', - '**/dist/**', '**/*.swp', '**/generated/**', '**/.gitignore', + // Ignore files inside dist/ directories, unless the path falls under a watched + // app asset directory (e.g. static_root may point to a dist/ folder). + // Non-dist paths are never ignored here (return false). For dist paths, we only + // allow them through if they are inside one of the app asset directories. + (filePath: string) => { + const normalized = normalizePath(filePath) + if (!normalized.includes('/dist/') && !normalized.endsWith('/dist')) return false + return !appAssetDirs.some((assetDir) => normalized.startsWith(assetDir)) + }, ], persistent: true, ignoreInitial: true, @@ -177,6 +193,36 @@ export class FileWatcher { return Array.from(allFiles) } + /** + * Resolves app asset directories that should be watched. + * Returns a map of absolute asset directory path → owning extension directory. + */ + private resolveAppAssetWatchPaths(allExtensions: ExtensionInstance[]): Map { + const result = new Map() + const adminExtension = allExtensions.find((ext) => ext.specification.identifier === 'admin') + if (adminExtension) { + const staticRootPath = getPathValue(adminExtension.configuration, 'admin.static_root') + if (staticRootPath) { + const absolutePath = joinPath(adminExtension.directory, staticRootPath) + result.set(normalizePath(absolutePath), normalizePath(adminExtension.directory)) + } + } + return result + } + + /** + * Checks if a file path is inside any app asset directory. + * Returns the owning extension directory if found, undefined otherwise. + */ + private findAppAssetExtensionDir(filePath: string): string | undefined { + for (const [assetDir, extensionDir] of this.appAssetToExtensionDir) { + if (filePath.startsWith(assetDir)) { + return extensionDir + } + } + return undefined + } + /** * Emits the accumulated events and resets the current events list. * It also logs the number of events emitted and their paths for debugging purposes. @@ -227,7 +273,12 @@ export class FileWatcher { * Explicit watch paths have priority over custom gitignore files */ private shouldIgnoreEvent(event: WatcherEvent) { - if (event.type === 'extension_folder_deleted' || event.type === 'extension_folder_created') return false + if ( + event.type === 'extension_folder_deleted' || + event.type === 'extension_folder_created' || + event.type === 'app_asset_updated' + ) + return false const extension = this.app.realExtensions.find((ext) => ext.directory === event.extensionPath) const watchPaths = extension?.watchedFiles() @@ -258,6 +309,16 @@ export class FileWatcher { const affectedExtensions = this.extensionWatchedFiles.get(normalizedPath) const isUnknownExtension = affectedExtensions === undefined || affectedExtensions.size === 0 + // Check if the file is inside an app asset directory (e.g. static_root) + const appAssetExtensionDir = this.findAppAssetExtensionDir(normalizedPath) + if (appAssetExtensionDir) { + if (event === 'change' || event === 'add' || event === 'unlink') { + this.pushEvent({type: 'app_asset_updated', path, extensionPath: appAssetExtensionDir, startTime}) + } + this.debouncedEmit() + return + } + if (isUnknownExtension && !isExtensionToml && !isConfigAppPath) { // Ignore an event if it's not part of an existing extension // Except if it is a toml file (either app config or extension config) diff --git a/packages/app/src/cli/services/dev/extension.ts b/packages/app/src/cli/services/dev/extension.ts index 7ac040b416e..a88922d3a15 100644 --- a/packages/app/src/cli/services/dev/extension.ts +++ b/packages/app/src/cli/services/dev/extension.ts @@ -13,10 +13,8 @@ import {AbortSignal} from '@shopify/cli-kit/node/abort' import {outputDebug} from '@shopify/cli-kit/node/output' import {joinPath} from '@shopify/cli-kit/node/path' import {getPathValue} from '@shopify/cli-kit/common/object' -import {fileExists} from '@shopify/cli-kit/node/fs' import {DotEnvFile} from '@shopify/cli-kit/node/dot-env' import {Writable} from 'stream' -import type {FSWatcher} from 'chokidar' interface AppAssets { [key: string]: string @@ -170,64 +168,16 @@ export async function devUIExtensions(options: ExtensionDevOptions): Promise>() - - function createDebouncedAssetUpdater(assetKey: string) { - return () => { - const existing = debounceTimers.get(assetKey) - if (existing) clearTimeout(existing) - debounceTimers.set( - assetKey, - setTimeout(() => { - debounceTimers.delete(assetKey) - payloadStore.updateAppAssetTimestamp(assetKey) - }, 200), - ) - } - } - - function clearDebounceTimers() { - for (const timer of debounceTimers.values()) { - clearTimeout(timer) - } - debounceTimers.clear() - } - - async function startAssetWatchers(assets: Record) { - // Clear pending debounce timers and close existing watchers - clearDebounceTimers() - await Promise.all(assetWatchers.map((watcher) => watcher.close())) - // eslint-disable-next-line require-atomic-updates - assetWatchers = [] - - const {default: chokidar} = await import('chokidar') - for (const [assetKey, directoryPath] of Object.entries(assets)) { - const exists = await fileExists(directoryPath) - if (!exists) continue - - const watcher = chokidar.watch(directoryPath, {ignoreInitial: true}) - watcher.on('all', createDebouncedAssetUpdater(assetKey)) - assetWatchers.push(watcher) - } - } - - const initialAppAssets = payloadOptions.appAssets - if (initialAppAssets && Object.keys(initialAppAssets).length > 0) { - await startAssetWatchers(initialAppAssets) - } - - const eventHandler = async ({appWasReloaded, app, extensionEvents}: AppEvent) => { + const eventHandler = async ({appWasReloaded, app, extensionEvents, appAssetsUpdated}: AppEvent) => { if (appWasReloaded) { extensions = app.allExtensions.filter((ext) => ext.isPreviewable) + } - // Re-resolve app assets in case admin extension was added/removed/changed - const newAppAssets = resolveAppAssets(app.allExtensions) - const hasAssets = Object.keys(newAppAssets).length > 0 - payloadOptions.appAssets = hasAssets ? newAppAssets : undefined - payloadStore.updateAppAssets(payloadOptions.appAssets, payloadOptions.url) - await startAssetWatchers(hasAssets ? newAppAssets : {}) + if (appAssetsUpdated && payloadOptions.appAssets) { + for (const assetKey of Object.keys(payloadOptions.appAssets)) { + payloadStore.updateAppAssetTimestamp(assetKey) + } + return } for (const event of extensionEvents) { @@ -264,10 +214,6 @@ export async function devUIExtensions(options: ExtensionDevOptions): Promise { outputDebug('Closing the UI extensions dev server...') - clearDebounceTimers() - for (const watcher of assetWatchers) { - watcher.close().catch(() => {}) - } websocketConnection.close() httpServer.close() }) From 6fb614b06ad270695e6f3e638244abd916644ff2 Mon Sep 17 00:00:00 2001 From: Melissa Luu Date: Thu, 2 Apr 2026 15:14:27 -0400 Subject: [PATCH 06/31] Fix tests and remove unnecessary early return --- .../app/src/cli/models/extensions/specification.ts | 3 ++- .../src/cli/models/extensions/specifications/admin.ts | 11 +++++++++++ packages/app/src/cli/services/dev/extension.ts | 1 - 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/app/src/cli/models/extensions/specification.ts b/packages/app/src/cli/models/extensions/specification.ts index 2f7b1bea33c..3df81f77f7f 100644 --- a/packages/app/src/cli/models/extensions/specification.ts +++ b/packages/app/src/cli/models/extensions/specification.ts @@ -294,11 +294,12 @@ export function createContractBasedModuleSpecification, ) { return createExtensionSpecification({ identifier: spec.identifier, - schema: zod.any({}) as unknown as ZodSchemaType, + schema: spec.schema ?? (zod.any({}) as unknown as ZodSchemaType), appModuleFeatures: spec.appModuleFeatures, experience: spec.experience, buildConfig: spec.buildConfig ?? {mode: 'none'}, diff --git a/packages/app/src/cli/models/extensions/specifications/admin.ts b/packages/app/src/cli/models/extensions/specifications/admin.ts index 14a1b5cd3c8..e4c859fbbbe 100644 --- a/packages/app/src/cli/models/extensions/specifications/admin.ts +++ b/packages/app/src/cli/models/extensions/specifications/admin.ts @@ -1,9 +1,20 @@ import {createContractBasedModuleSpecification} from '../specification.js' +import {ZodSchemaType, BaseConfigType} from '../schemas.js' +import {zod} from '@shopify/cli-kit/node/schema' + +const AdminSchema = zod.object({ + admin: zod + .object({ + static_root: zod.string().optional(), + }) + .optional(), +}) const adminSpecificationSpec = createContractBasedModuleSpecification({ identifier: 'admin', uidStrategy: 'single', experience: 'configuration', + schema: AdminSchema as unknown as ZodSchemaType, transformRemoteToLocal: (remoteContent) => { return { admin: { diff --git a/packages/app/src/cli/services/dev/extension.ts b/packages/app/src/cli/services/dev/extension.ts index a88922d3a15..98cb1bc8918 100644 --- a/packages/app/src/cli/services/dev/extension.ts +++ b/packages/app/src/cli/services/dev/extension.ts @@ -177,7 +177,6 @@ export async function devUIExtensions(options: ExtensionDevOptions): Promise Date: Thu, 2 Apr 2026 16:21:50 -0500 Subject: [PATCH 07/31] Add release notes for 3.93 Partner-facing summary of changes from Version Packages PR #6978 Co-Authored-By: Claude Opus 4.6 (1M context) --- RELEASE_NOTES/3.93.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 RELEASE_NOTES/3.93.md diff --git a/RELEASE_NOTES/3.93.md b/RELEASE_NOTES/3.93.md new file mode 100644 index 00000000000..3f082fdf81f --- /dev/null +++ b/RELEASE_NOTES/3.93.md @@ -0,0 +1,19 @@ +## App + +- Add `shopify organization list` command to list Shopify organizations you have access to, supports `--json` flag for structured output [#6956](https://github.com/Shopify/cli/pull/6956) +- Add `shopify store auth` and `shopify store execute` commands. `store auth` authenticates an app against a store using PKCE. `store execute` runs Admin API GraphQL against that stored auth [#7122](https://github.com/Shopify/cli/pull/7122) +- Add support for SHOPIFY_APP_AUTOMATION_TOKEN env var as a new name for SHOPIFY_CLI_PARTNERS_TOKEN [#7057](https://github.com/Shopify/cli/pull/7057) +- Enable non-interactive `app init` via a new `--organization-id` flag and not prompting to link to an existing app if `--name` is provided [#6640](https://github.com/Shopify/cli/pull/6640) +- Deprecation warning for `--force` flag on `app deploy` and `app release`. Use `--allow-updates` for CI/CD environments, or `--allow-updates --allow-deletes` if you also want to allow removals [#7059](https://github.com/Shopify/cli/pull/7059) +- Fix crash when organization is not found in app-management-client [#7012](https://github.com/Shopify/cli/pull/7012) + +## Theme + +- Add `--development-context` flag to `theme push` to specify a unique identifier for a development theme context (e.g., PR number, branch name), useful for CI environments [#6657](https://github.com/Shopify/cli/pull/6657) +- Add support for theme previews using a JSON via `theme preview`. Pass a JSON via --override to quickly preview overrides on a live theme. Also adds a --preview-id flag to handle in-place updates for previews created from an override JSON [#6890](https://github.com/Shopify/cli/pull/6890) +- Add --json flag to `theme preview` to configure a json output [#7043](https://github.com/Shopify/cli/pull/7043) +- Change wording for current development theme in `theme list` from `[yours]` to `[current]` to reflect support for multiple development themes [#6657](https://github.com/Shopify/cli/pull/6657) +- Fix theme editor shortcut tracking fetch requests instead of page navigation [#6924](https://github.com/Shopify/cli/pull/6924) +- Add a 250ms debounce on filewatcher for themes to stop potential file deletes [#6791](https://github.com/Shopify/cli/pull/6791) +- Fix Cart rate limiting issue [#6975](https://github.com/Shopify/cli/pull/6975) +- Fix missing json output for `theme info` when no theme or dev flag is present [#6905](https://github.com/Shopify/cli/pull/6905) From 937c354a228e9d3165e7e9eb6aaeebbc47adf957 Mon Sep 17 00:00:00 2001 From: Liz Kenyon Date: Thu, 2 Apr 2026 16:32:00 -0500 Subject: [PATCH 08/31] Update RELEASE_NOTES/3.93.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- RELEASE_NOTES/3.93.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/RELEASE_NOTES/3.93.md b/RELEASE_NOTES/3.93.md index 3f082fdf81f..d083980e7b9 100644 --- a/RELEASE_NOTES/3.93.md +++ b/RELEASE_NOTES/3.93.md @@ -10,8 +10,8 @@ ## Theme - Add `--development-context` flag to `theme push` to specify a unique identifier for a development theme context (e.g., PR number, branch name), useful for CI environments [#6657](https://github.com/Shopify/cli/pull/6657) -- Add support for theme previews using a JSON via `theme preview`. Pass a JSON via --override to quickly preview overrides on a live theme. Also adds a --preview-id flag to handle in-place updates for previews created from an override JSON [#6890](https://github.com/Shopify/cli/pull/6890) -- Add --json flag to `theme preview` to configure a json output [#7043](https://github.com/Shopify/cli/pull/7043) +- Add support for theme previews using a JSON via `theme preview`. Pass a JSON via `--override` to quickly preview overrides on a live theme. Also adds a `--preview-id` flag to handle in-place updates for previews created from an override JSON [#6890](https://github.com/Shopify/cli/pull/6890) +- Add `--json` flag to `theme preview` to configure a json output [#7043](https://github.com/Shopify/cli/pull/7043) - Change wording for current development theme in `theme list` from `[yours]` to `[current]` to reflect support for multiple development themes [#6657](https://github.com/Shopify/cli/pull/6657) - Fix theme editor shortcut tracking fetch requests instead of page navigation [#6924](https://github.com/Shopify/cli/pull/6924) - Add a 250ms debounce on filewatcher for themes to stop potential file deletes [#6791](https://github.com/Shopify/cli/pull/6791) From d8a434f1b8277d119c15cbb8ca9fc1e338e52916 Mon Sep 17 00:00:00 2001 From: Liz Kenyon Date: Thu, 2 Apr 2026 16:32:10 -0500 Subject: [PATCH 09/31] Update RELEASE_NOTES/3.93.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- RELEASE_NOTES/3.93.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/RELEASE_NOTES/3.93.md b/RELEASE_NOTES/3.93.md index d083980e7b9..bd5d7fe87a3 100644 --- a/RELEASE_NOTES/3.93.md +++ b/RELEASE_NOTES/3.93.md @@ -2,7 +2,7 @@ - Add `shopify organization list` command to list Shopify organizations you have access to, supports `--json` flag for structured output [#6956](https://github.com/Shopify/cli/pull/6956) - Add `shopify store auth` and `shopify store execute` commands. `store auth` authenticates an app against a store using PKCE. `store execute` runs Admin API GraphQL against that stored auth [#7122](https://github.com/Shopify/cli/pull/7122) -- Add support for SHOPIFY_APP_AUTOMATION_TOKEN env var as a new name for SHOPIFY_CLI_PARTNERS_TOKEN [#7057](https://github.com/Shopify/cli/pull/7057) +- Add support for `SHOPIFY_APP_AUTOMATION_TOKEN` env var as a new name for `SHOPIFY_CLI_PARTNERS_TOKEN` [#7057](https://github.com/Shopify/cli/pull/7057) - Enable non-interactive `app init` via a new `--organization-id` flag and not prompting to link to an existing app if `--name` is provided [#6640](https://github.com/Shopify/cli/pull/6640) - Deprecation warning for `--force` flag on `app deploy` and `app release`. Use `--allow-updates` for CI/CD environments, or `--allow-updates --allow-deletes` if you also want to allow removals [#7059](https://github.com/Shopify/cli/pull/7059) - Fix crash when organization is not found in app-management-client [#7012](https://github.com/Shopify/cli/pull/7012) From 12cd80ff74c9ed85f9650e20a257e4de75d0b84d Mon Sep 17 00:00:00 2001 From: Liz Kenyon Date: Thu, 2 Apr 2026 16:32:21 -0500 Subject: [PATCH 10/31] Update RELEASE_NOTES/3.93.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- RELEASE_NOTES/3.93.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/RELEASE_NOTES/3.93.md b/RELEASE_NOTES/3.93.md index bd5d7fe87a3..2dd9aa4940a 100644 --- a/RELEASE_NOTES/3.93.md +++ b/RELEASE_NOTES/3.93.md @@ -1,7 +1,7 @@ ## App - Add `shopify organization list` command to list Shopify organizations you have access to, supports `--json` flag for structured output [#6956](https://github.com/Shopify/cli/pull/6956) -- Add `shopify store auth` and `shopify store execute` commands. `store auth` authenticates an app against a store using PKCE. `store execute` runs Admin API GraphQL against that stored auth [#7122](https://github.com/Shopify/cli/pull/7122) +- Add `shopify store auth` and `shopify store execute` commands. `store auth` stores per-user online auth for a store using PKCE. `store execute` runs Admin API GraphQL against that stored auth; mutations are disabled by default and write operations require `--allow-mutations` [#7122](https://github.com/Shopify/cli/pull/7122) - Add support for `SHOPIFY_APP_AUTOMATION_TOKEN` env var as a new name for `SHOPIFY_CLI_PARTNERS_TOKEN` [#7057](https://github.com/Shopify/cli/pull/7057) - Enable non-interactive `app init` via a new `--organization-id` flag and not prompting to link to an existing app if `--name` is provided [#6640](https://github.com/Shopify/cli/pull/6640) - Deprecation warning for `--force` flag on `app deploy` and `app release`. Use `--allow-updates` for CI/CD environments, or `--allow-updates --allow-deletes` if you also want to allow removals [#7059](https://github.com/Shopify/cli/pull/7059) From b26fa2325d26bf97282b438167e83c193eaf4b43 Mon Sep 17 00:00:00 2001 From: Ryan Bahan Date: Thu, 2 Apr 2026 17:02:46 -0600 Subject: [PATCH 11/31] Remove unused @babel/core and eslint-plugin-jsx-a11y deps from eslint-plugin-cli Neither package is directly used: @babel/core had no consumer (the only babel reference is a rule set to 'off'), and eslint-plugin-jsx-a11y is already pulled in transitively via @shopify/eslint-plugin. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/eslint-plugin-cli/package.json | 2 -- pnpm-lock.yaml | 8 -------- 2 files changed, 10 deletions(-) diff --git a/packages/eslint-plugin-cli/package.json b/packages/eslint-plugin-cli/package.json index 366a043d01b..549177bf99e 100644 --- a/packages/eslint-plugin-cli/package.json +++ b/packages/eslint-plugin-cli/package.json @@ -16,13 +16,11 @@ "license": "MIT", "author": "Shopify Inc.", "dependencies": { - "@babel/core": "7.27.4", "@shopify/eslint-plugin": "50.0.0", "@typescript-eslint/eslint-plugin": "8.56.1", "@typescript-eslint/parser": "8.56.1", "eslint-config-prettier": "10.1.5", "eslint-plugin-jsdoc": "50.7.1", - "eslint-plugin-jsx-a11y": "6.10.2", "eslint-plugin-no-catch-all": "1.1.0", "eslint-plugin-prettier": "5.5.1", "eslint-plugin-react": "7.37.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 779bdad0570..e37f8dfb5a4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -569,9 +569,6 @@ importers: packages/eslint-plugin-cli: dependencies: - '@babel/core': - specifier: 7.27.4 - version: 7.27.4 '@shopify/eslint-plugin': specifier: 50.0.0 version: 50.0.0(@typescript-eslint/eslint-plugin@8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/utils@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1))(prettier@3.8.1)(typescript@5.9.3) @@ -596,9 +593,6 @@ importers: eslint-plugin-jsdoc: specifier: 50.7.1 version: 50.7.1(eslint@9.39.3(jiti@2.6.1)) - eslint-plugin-jsx-a11y: - specifier: 6.10.2 - version: 6.10.2(eslint@9.39.3(jiti@2.6.1)) eslint-plugin-no-catch-all: specifier: 1.1.0 version: 1.1.0(eslint@9.39.3(jiti@2.6.1)) @@ -12640,7 +12634,6 @@ snapshots: '@shopify/eslint-plugin-cli@file:packages/eslint-plugin-cli(@typescript-eslint/utils@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1))(prettier@3.8.1)(typescript@5.9.3)(vitest@3.2.4(@types/node@18.19.70)(jiti@2.6.1)(jsdom@28.1.0)(msw@2.12.10(@types/node@18.19.70)(typescript@5.9.3))(sass@1.97.3)(yaml@2.8.3))': dependencies: - '@babel/core': 7.27.4 '@shopify/eslint-plugin': 50.0.0(@typescript-eslint/eslint-plugin@8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/utils@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1))(prettier@3.8.1)(typescript@5.9.3) '@typescript-eslint/eslint-plugin': 8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/parser': 8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) @@ -12649,7 +12642,6 @@ snapshots: eslint: 9.39.3(jiti@2.6.1) eslint-config-prettier: 10.1.5(eslint@9.39.3(jiti@2.6.1)) eslint-plugin-jsdoc: 50.7.1(eslint@9.39.3(jiti@2.6.1)) - eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.3(jiti@2.6.1)) eslint-plugin-no-catch-all: 1.1.0(eslint@9.39.3(jiti@2.6.1)) eslint-plugin-prettier: 5.5.1(eslint-config-prettier@10.1.5(eslint@9.39.3(jiti@2.6.1)))(eslint@9.39.3(jiti@2.6.1))(prettier@3.8.1) eslint-plugin-react: 7.37.5(eslint@9.39.3(jiti@2.6.1)) From 69df45077304732972a23fdf213fb951848a43dd Mon Sep 17 00:00:00 2001 From: Ryan Bahan Date: Thu, 2 Apr 2026 17:08:23 -0600 Subject: [PATCH 12/31] Remove @opentelemetry/semantic-conventions dependency The only usage was SemanticResourceAttributes.SERVICE_NAME (the string literal 'service.name') in DefaultMeterProvider.ts. Inline the value and drop the package dependency. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli-kit/package.json | 1 - .../otel-js/service/DefaultOtelService/DefaultMeterProvider.ts | 3 +-- pnpm-lock.yaml | 3 --- 3 files changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/cli-kit/package.json b/packages/cli-kit/package.json index 8813b2b8fd0..1df7f81a0e5 100644 --- a/packages/cli-kit/package.json +++ b/packages/cli-kit/package.json @@ -113,7 +113,6 @@ "@opentelemetry/exporter-metrics-otlp-http": "0.57.0", "@opentelemetry/resources": "1.30.0", "@opentelemetry/sdk-metrics": "1.30.0", - "@opentelemetry/semantic-conventions": "1.28.0", "@types/archiver": "5.3.2", "ajv": "8.18.0", "ansi-escapes": "6.2.1", diff --git a/packages/cli-kit/src/public/node/vendor/otel-js/service/DefaultOtelService/DefaultMeterProvider.ts b/packages/cli-kit/src/public/node/vendor/otel-js/service/DefaultOtelService/DefaultMeterProvider.ts index 1cdad01c0ea..76bd3883092 100644 --- a/packages/cli-kit/src/public/node/vendor/otel-js/service/DefaultOtelService/DefaultMeterProvider.ts +++ b/packages/cli-kit/src/public/node/vendor/otel-js/service/DefaultOtelService/DefaultMeterProvider.ts @@ -2,7 +2,6 @@ import {InstantaneousMetricReader} from '../../export/InstantaneousMetricReader. import {OTLPMetricExporter, OTLPMetricExporterOptions} from '@opentelemetry/exporter-metrics-otlp-http' import {Resource} from '@opentelemetry/resources' import {AggregationTemporality, ConsoleMetricExporter, MeterProvider} from '@opentelemetry/sdk-metrics' -import {SemanticResourceAttributes} from '@opentelemetry/semantic-conventions' export type Environment = 'production' | 'staging' | 'local' @@ -19,7 +18,7 @@ export class DefaultMeterProvider extends MeterProvider { constructor({serviceName, env, throttleLimit, useXhr, otelEndpoint}: DefaultMeterProviderOptions) { super({ resource: new Resource({ - [SemanticResourceAttributes.SERVICE_NAME]: serviceName, + ['service.name']: serviceName, }), }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e37f8dfb5a4..342c198900b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -330,9 +330,6 @@ importers: '@opentelemetry/sdk-metrics': specifier: 1.30.0 version: 1.30.0(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': - specifier: 1.28.0 - version: 1.28.0 '@shopify/toml-patch': specifier: 0.3.0 version: 0.3.0 From c173151212fea91f3433dc03837d1783a262e90b Mon Sep 17 00:00:00 2001 From: Ryan Bahan Date: Thu, 2 Apr 2026 17:13:51 -0600 Subject: [PATCH 13/31] Remove commondir dependency, inline as commonParentDirectory() The commondir package is a tiny, unmaintained (~2014) utility. Inline its logic as an exported commonParentDirectory() function in path.ts, removing both the runtime dep and @types/commondir. Added parity tests covering the original package's test cases. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli-kit/package.json | 2 -- packages/cli-kit/src/public/node/path.test.ts | 36 ++++++++++++++++++- packages/cli-kit/src/public/node/path.ts | 20 +++++++++-- pnpm-lock.yaml | 16 --------- 4 files changed, 53 insertions(+), 21 deletions(-) diff --git a/packages/cli-kit/package.json b/packages/cli-kit/package.json index 1df7f81a0e5..e102e827e5d 100644 --- a/packages/cli-kit/package.json +++ b/packages/cli-kit/package.json @@ -121,7 +121,6 @@ "chalk": "5.4.1", "change-case": "4.1.2", "color-json": "3.0.5", - "commondir": "1.0.1", "conf": "11.0.2", "deepmerge": "4.3.1", "dotenv": "16.4.7", @@ -165,7 +164,6 @@ "zod": "3.24.4" }, "devDependencies": { - "@types/commondir": "^1.0.0", "@types/diff": "^5.2.3", "@types/fs-extra": "9.0.13", "@types/gradient-string": "^1.1.2", diff --git a/packages/cli-kit/src/public/node/path.test.ts b/packages/cli-kit/src/public/node/path.test.ts index e2dc5d69c8d..4972b3ac276 100644 --- a/packages/cli-kit/src/public/node/path.test.ts +++ b/packages/cli-kit/src/public/node/path.test.ts @@ -1,4 +1,4 @@ -import {relativizePath, normalizePath, cwd, sniffForPath} from './path.js' +import {relativizePath, normalizePath, cwd, sniffForPath, commonParentDirectory} from './path.js' import {describe, test, expect} from 'vitest' describe('relativize', () => { @@ -25,6 +25,40 @@ describe('cwd', () => { }) }) +describe('commonParentDirectory', () => { + // Parity tests with the original 'commondir' npm package (v1.0.1) + test('finds common parent for paths sharing a prefix', () => { + expect(commonParentDirectory('/foo', '/foo/bar')).toBe('/foo') + expect(commonParentDirectory('/foo/bar', '/foo//bar/baz')).toBe('/foo/bar') + }) + + test('finds deepest common ancestor', () => { + expect(commonParentDirectory('/a/b/c', '/a/b')).toBe('/a/b') + expect(commonParentDirectory('/a/b', '/a/b/c/d/e')).toBe('/a/b') + }) + + test('returns root when paths diverge at top level', () => { + expect(commonParentDirectory('/x/y/z/w', '/xy/z')).toBe('/') + }) + + test('handles Windows-style paths', () => { + expect(commonParentDirectory('X:\\foo', 'X:\\\\foo\\bar')).toBe('X:/foo') + expect(commonParentDirectory('X:\\a\\b\\c', 'X:\\a\\b')).toBe('X:/a/b') + }) + + test('returns root for completely divergent Windows paths', () => { + expect(commonParentDirectory('X:\\x\\y\\z\\w', '\\\\xy\\z')).toBe('/') + }) + + test('returns root for single-component paths', () => { + expect(commonParentDirectory('/', '/')).toBe('/') + }) + + test('handles identical paths', () => { + expect(commonParentDirectory('/a/b/c', '/a/b/c')).toBe('/a/b/c') + }) +}) + describe('sniffForPath', () => { test('returns the path if provided', () => { // Given diff --git a/packages/cli-kit/src/public/node/path.ts b/packages/cli-kit/src/public/node/path.ts index 20ce55840cf..697dad3ed92 100644 --- a/packages/cli-kit/src/public/node/path.ts +++ b/packages/cli-kit/src/public/node/path.ts @@ -1,4 +1,3 @@ -import commondir from 'commondir' import { relative, dirname as patheDirname, @@ -106,6 +105,23 @@ export function parsePath(path: string): {root: string; dir: string; base: strin return parse(path) } +/** + * Returns the longest common parent directory of two absolute paths. + * + * @param first - First absolute path. + * @param second - Second absolute path. + * @returns The common parent directory, or '/' if they share only the root. + */ +export function commonParentDirectory(first: string, second: string): string { + const firstParts = first.split(/\/+|\\+/) + const secondParts = second.split(/\/+|\\+/) + let i = 0 + while (i < firstParts.length && i < secondParts.length && firstParts[i] === secondParts[i]) { + i++ + } + return i > 1 ? firstParts.slice(0, i).join('/') : '/' +} + /** * Given an absolute filesystem path, it makes it relative to * the current working directory. This is useful when logging paths @@ -117,7 +133,7 @@ export function parsePath(path: string): {root: string; dir: string; base: strin * @returns Relativized path. */ export function relativizePath(path: string, dir: string = cwd()): string { - const result = commondir([path, dir]) + const result = commonParentDirectory(path, dir) const relativePath = relative(dir, path) // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 342c198900b..c672a983964 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -357,9 +357,6 @@ importers: color-json: specifier: 3.0.5 version: 3.0.5 - commondir: - specifier: 1.0.1 - version: 1.0.1 conf: specifier: 11.0.2 version: 11.0.2 @@ -484,9 +481,6 @@ importers: specifier: 3.24.4 version: 3.24.4 devDependencies: - '@types/commondir': - specifier: ^1.0.0 - version: 1.0.2 '@types/diff': specifier: ^5.2.3 version: 5.2.3 @@ -3906,9 +3900,6 @@ packages: '@types/cli-progress@3.11.6': resolution: {integrity: sha512-cE3+jb9WRlu+uOSAugewNpITJDt1VF8dHOopPO4IABFc3SXYL5WE/+PTz/FCdZRRfIujiWW3n3aMbv1eIGVRWA==} - '@types/commondir@1.0.2': - resolution: {integrity: sha512-ugIRUhO6vLS6Pi5Pz/0yuMYX7Q1rsEpXNrU7ef6rVdH+cb3hTDz8HL55C0QINDqMB4ZWsrE8lLWyYUbZKPhduQ==} - '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} @@ -4862,9 +4853,6 @@ packages: resolution: {integrity: sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==} engines: {node: '>=4.0.0'} - commondir@1.0.1: - resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==} - compress-commons@4.1.2: resolution: {integrity: sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==} engines: {node: '>= 10'} @@ -13280,8 +13268,6 @@ snapshots: dependencies: '@types/node': 18.19.70 - '@types/commondir@1.0.2': {} - '@types/deep-eql@4.0.2': {} '@types/diff@5.2.3': {} @@ -14345,8 +14331,6 @@ snapshots: common-tags@1.8.2: {} - commondir@1.0.1: {} - compress-commons@4.1.2: dependencies: buffer-crc32: 0.2.13 From 0ec88b9596f2ee408ae2b1c2dbbb964a2c6373d1 Mon Sep 17 00:00:00 2001 From: Ryan Bahan Date: Thu, 2 Apr 2026 17:23:59 -0600 Subject: [PATCH 14/31] Remove get-port-please, use Node net module directly Replace get-port-please with Node's built-in net.createServer for both random port allocation (bind to port 0) and port availability checks. This removes an unmaintained dependency and fixes the HOST env var bug that required a hardcoded workaround. Servers call unref() to prevent dangling handles from keeping the process alive, matching get-port-please's behavior. Tests cover integration behavior (real sockets, bindability verification) and retry/error paths (mocked net). Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli-kit/package.json | 1 - .../cli-kit/src/public/node/tcp-retry.test.ts | 63 ++++++++ packages/cli-kit/src/public/node/tcp.test.ts | 151 +++++++++--------- packages/cli-kit/src/public/node/tcp.ts | 39 ++++- pnpm-lock.yaml | 8 - 5 files changed, 166 insertions(+), 96 deletions(-) create mode 100644 packages/cli-kit/src/public/node/tcp-retry.test.ts diff --git a/packages/cli-kit/package.json b/packages/cli-kit/package.json index e102e827e5d..cedbf26541b 100644 --- a/packages/cli-kit/package.json +++ b/packages/cli-kit/package.json @@ -131,7 +131,6 @@ "find-up": "6.3.0", "form-data": "4.0.4", "fs-extra": "11.1.0", - "get-port-please": "3.1.2", "gradient-string": "2.0.2", "graphql": "16.10.0", "graphql-request": "6.1.0", diff --git a/packages/cli-kit/src/public/node/tcp-retry.test.ts b/packages/cli-kit/src/public/node/tcp-retry.test.ts new file mode 100644 index 00000000000..955ab8737d6 --- /dev/null +++ b/packages/cli-kit/src/public/node/tcp-retry.test.ts @@ -0,0 +1,63 @@ +import {AbortError} from './error.js' +import {describe, expect, test, vi, beforeEach} from 'vitest' +import {EventEmitter} from 'events' + +vi.mock('./system.js', async (importOriginal) => { + const actual: any = await importOriginal() + return {...actual, sleep: vi.fn()} +}) + +let callCount = 0 +let failCount = 0 + +vi.mock('net', () => { + return { + createServer: () => { + callCount++ + const server = new EventEmitter() as any + server.unref = () => server + server.listen = (_port: number, _host: string, cb?: () => void) => { + if (callCount <= failCount) { + process.nextTick(() => server.emit('error', new Error('mock port allocation failure'))) + } else { + server.address = () => ({port: 9999}) + process.nextTick(() => cb?.()) + } + return server + } + server.close = (cb?: () => void) => { + cb?.() + return server + } + return server + }, + } +}) + +beforeEach(() => { + callCount = 0 + failCount = 0 +}) + +describe('getAvailableTCPPort retry behavior', () => { + test('retries and returns port after transient error', async () => { + const {getAvailableTCPPort} = await import('./tcp.js') + const {sleep} = await import('./system.js') + + failCount = 1 + + const got = await getAvailableTCPPort(undefined, {waitTimeInSeconds: 0}) + expect(got).toBe(9999) + expect(sleep).toHaveBeenCalledOnce() + }) + + test('throws AbortError when all retries are exhausted', async () => { + const {getAvailableTCPPort} = await import('./tcp.js') + + failCount = 100 + + await expect(() => getAvailableTCPPort(undefined, {maxTries: 3, waitTimeInSeconds: 0})).rejects.toThrowError( + AbortError, + ) + }) +}) diff --git a/packages/cli-kit/src/public/node/tcp.test.ts b/packages/cli-kit/src/public/node/tcp.test.ts index 530b595fa9f..3a42698e4fc 100644 --- a/packages/cli-kit/src/public/node/tcp.test.ts +++ b/packages/cli-kit/src/public/node/tcp.test.ts @@ -1,100 +1,93 @@ import {getAvailableTCPPort, checkPortAvailability} from './tcp.js' -import * as system from './system.js' -import {AbortError} from './error.js' -import * as port from 'get-port-please' -import {describe, expect, test, vi} from 'vitest' - -vi.mock('get-port-please') - -const errorMessage = 'Unable to generate random port' +import {describe, expect, test} from 'vitest' +import {createServer} from 'net' describe('getAvailableTCPPort', () => { - test('returns random port if the number retries is not exceeded', async () => { - // Given - vi.mocked(port.getRandomPort).mockRejectedValueOnce(new Error(errorMessage)) - vi.mocked(port.getRandomPort).mockResolvedValue(5) - const debugError = vi.spyOn(system, 'sleep') - - // When - const got = await getAvailableTCPPort(undefined, {waitTimeInSeconds: 0}) - - // Then - expect(got).toBe(5) - expect(debugError).toHaveBeenCalledOnce() + test('returns a valid port number', async () => { + const port = await getAvailableTCPPort() + expect(port).toBeGreaterThan(0) + expect(port).toBeLessThanOrEqual(65535) }) - test('throws an abort exception with same error message received from third party getRandomPort if the number retries is exceeded', async () => { - // Given - const maxTries = 5 - for (let i = 0; i < maxTries; i++) { - vi.mocked(port.getRandomPort).mockRejectedValueOnce(new Error(errorMessage)) - } - - // When/Then - await expect(() => getAvailableTCPPort(undefined, {waitTimeInSeconds: 0})).rejects.toThrowError( - new AbortError(errorMessage), - ) + test('returns the preferred port when it is available', async () => { + const freePort = await getAvailableTCPPort() + const got = await getAvailableTCPPort(freePort) + expect(got).toBe(freePort) }) - test('returns the provided port when it is available', async () => { - // Given - vi.mocked(port.checkPort).mockResolvedValue(666) - - // When - const got = await getAvailableTCPPort(666) - - // Then - expect(got).toBe(666) + test('returns a different port when the preferred one is in use', async () => { + const server = createServer() + const occupiedPort = await new Promise((resolve) => { + server.listen(0, 'localhost', () => { + const address = server.address() + resolve((address as {port: number}).port) + }) + }) + + try { + const got = await getAvailableTCPPort(occupiedPort) + expect(got).not.toBe(occupiedPort) + expect(got).toBeGreaterThan(0) + } finally { + server.close() + } }) - test('returns a random port when the provided one is not available', async () => { - // Given - vi.mocked(port.checkPort).mockResolvedValue(false) - vi.mocked(port.getRandomPort).mockResolvedValue(5) - - // When - const got = await getAvailableTCPPort(666) - - // Then - expect(got).toBe(5) + test('returns unique ports across multiple calls', async () => { + const ports = new Set() + for (let i = 0; i < 5; i++) { + // eslint-disable-next-line no-await-in-loop + const port = await getAvailableTCPPort() + ports.add(port) + } + expect(ports.size).toBe(5) }) - test('reserves random ports and does not reuse them', async () => { - vi.mocked(port.checkPort).mockResolvedValue(false) - vi.mocked(port.getRandomPort).mockResolvedValueOnce(55).mockResolvedValueOnce(55).mockResolvedValueOnce(66) - - let got = await getAvailableTCPPort(123) - expect(got).toBe(55) - - got = await getAvailableTCPPort(123) - expect(got).toBe(66) + test('returns unique ports and all are bindable', async () => { + const ports: number[] = [] + for (let i = 0; i < 3; i++) { + // eslint-disable-next-line no-await-in-loop + ports.push(await getAvailableTCPPort()) + } + expect(new Set(ports).size).toBe(3) + + // Verify all ports are actually bindable + const servers = await Promise.all( + ports.map( + (port) => + new Promise>((resolve, reject) => { + const server = createServer() + server.once('error', reject) + server.listen(port, 'localhost', () => resolve(server)) + }), + ), + ) + // All three bound successfully — clean up + await Promise.all(servers.map((server) => new Promise((resolve) => server.close(() => resolve())))) }) }) describe('checkPortAvailability', () => { test('returns true when port is available', async () => { - // Given - const portNumber = 3000 - vi.mocked(port.checkPort).mockResolvedValue(portNumber) - - // When - const result = await checkPortAvailability(portNumber) - - // Then + const freePort = await getAvailableTCPPort() + const result = await checkPortAvailability(freePort) expect(result).toBe(true) - expect(port.checkPort).toHaveBeenCalledWith(portNumber, 'localhost') }) - test('returns false when port is not available', async () => { - // Given - const portNumber = 3000 - vi.mocked(port.checkPort).mockResolvedValue(false) - - // When - const result = await checkPortAvailability(portNumber) - - // Then - expect(result).toBe(false) - expect(port.checkPort).toHaveBeenCalledWith(portNumber, 'localhost') + test('returns false when port is in use', async () => { + const server = createServer() + const occupiedPort = await new Promise((resolve) => { + server.listen(0, 'localhost', () => { + const address = server.address() + resolve((address as {port: number}).port) + }) + }) + + try { + const result = await checkPortAvailability(occupiedPort) + expect(result).toBe(false) + } finally { + server.close() + } }) }) diff --git a/packages/cli-kit/src/public/node/tcp.ts b/packages/cli-kit/src/public/node/tcp.ts index 86d05ec552e..ed194b450b7 100644 --- a/packages/cli-kit/src/public/node/tcp.ts +++ b/packages/cli-kit/src/public/node/tcp.ts @@ -1,7 +1,7 @@ import {sleep} from './system.js' import {AbortError} from './error.js' import {outputDebug, outputContent, outputToken} from './output.js' -import * as port from 'get-port-please' +import {createServer} from 'net' interface GetTCPPortOptions { waitTimeInSeconds?: number @@ -23,14 +23,14 @@ export async function getAvailableTCPPort(preferredPort?: number, options?: GetT return preferredPort } outputDebug(outputContent`Getting a random port...`) - let randomPort = await retryOnError(() => port.getRandomPort(host()), options?.maxTries, options?.waitTimeInSeconds) + let randomPort = await retryOnError(() => getRandomPort(), options?.maxTries, options?.waitTimeInSeconds) for (let i = 0; i < (options?.maxTries ?? 5); i++) { if (!obtainedRandomPorts.has(randomPort)) { break } // eslint-disable-next-line no-await-in-loop - randomPort = await retryOnError(() => port.getRandomPort(host()), options?.maxTries, options?.waitTimeInSeconds) + randomPort = await retryOnError(() => getRandomPort(), options?.maxTries, options?.waitTimeInSeconds) } outputDebug(outputContent`Random port obtained: ${outputToken.raw(`${randomPort}`)}`) @@ -45,13 +45,36 @@ export async function getAvailableTCPPort(preferredPort?: number, options?: GetT * @returns A promise that resolves with a boolean indicating if the port is available. */ export async function checkPortAvailability(portNumber: number): Promise { - return (await port.checkPort(portNumber, host())) === portNumber + return new Promise((resolve) => { + const server = createServer() + server.unref() + server.once('error', () => resolve(false)) + server.listen(portNumber, 'localhost', () => { + server.close(() => resolve(true)) + }) + }) } -function host(): string | undefined { - // The get-port-please library does not work as expected when HOST env var is defined, - // so explicitly set the host to localhost to avoid conflicts - return 'localhost' +/** + * Gets a random available port by binding to port 0 on localhost. + * + * @returns A promise that resolves with an available port number. + */ +function getRandomPort(): Promise { + return new Promise((resolve, reject) => { + const server = createServer() + server.unref() + server.once('error', reject) + server.listen(0, 'localhost', () => { + const address = server.address() + if (address && typeof address === 'object') { + const assignedPort = address.port + server.close(() => resolve(assignedPort)) + } else { + server.close(() => reject(new Error('Unable to determine assigned port'))) + } + }) + }) } /** diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c672a983964..adceb575232 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -387,9 +387,6 @@ importers: fs-extra: specifier: 11.1.0 version: 11.1.0 - get-port-please: - specifier: 3.1.2 - version: 3.1.2 gradient-string: specifier: 2.0.2 version: 2.0.2 @@ -5838,9 +5835,6 @@ packages: resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} engines: {node: '>=8.0.0'} - get-port-please@3.1.2: - resolution: {integrity: sha512-Gxc29eLs1fbn6LQ4jSU4vXjlwyZhF5HsGuMAa7gqBP4Rw4yxxltyDUuF5MBclFzDTXO+ACchGQoeela4DSfzdQ==} - get-port@7.1.0: resolution: {integrity: sha512-QB9NKEeDg3xxVwCCwJQ9+xycaz6pBB6iQ76wiWMl1927n0Kir6alPiP+yuiICLLU4jpMe08dXfpebuQppFA2zw==} engines: {node: '>=16'} @@ -15479,8 +15473,6 @@ snapshots: get-package-type@0.1.0: {} - get-port-please@3.1.2: {} - get-port@7.1.0: {} get-proto@1.0.1: From 01f72a731fc66df2859aea756163969af33b6ba8 Mon Sep 17 00:00:00 2001 From: Ryan Bahan Date: Thu, 2 Apr 2026 17:43:19 -0600 Subject: [PATCH 15/31] Remove react-router-dom, render Extensions directly The Routes component used a catch-all route (path="*") that always rendered . No actual routing was happening. Render the component directly in App.tsx and remove the Routes wrapper, its directory, and the react-router-dom dependency. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ui-extensions-dev-console/package.json | 1 - .../ui-extensions-dev-console/src/App.tsx | 4 +-- .../src/foundation/Routes/Routes.tsx | 13 ------- .../src/foundation/Routes/index.ts | 1 - pnpm-lock.yaml | 34 ------------------- 5 files changed, 2 insertions(+), 51 deletions(-) delete mode 100644 packages/ui-extensions-dev-console/src/foundation/Routes/Routes.tsx delete mode 100644 packages/ui-extensions-dev-console/src/foundation/Routes/index.ts diff --git a/packages/ui-extensions-dev-console/package.json b/packages/ui-extensions-dev-console/package.json index 46ed8f0b4f2..6c67cd419ba 100644 --- a/packages/ui-extensions-dev-console/package.json +++ b/packages/ui-extensions-dev-console/package.json @@ -18,7 +18,6 @@ "qrcode.react": "^4.2.0", "react": "^18.2.0", "react-dom": "^18.2.0", - "react-router-dom": "^6.14.2", "react-toastify": "^9.1.3", "react-transition-group": "^4.4.5" }, diff --git a/packages/ui-extensions-dev-console/src/App.tsx b/packages/ui-extensions-dev-console/src/App.tsx index d94d2667338..a2b346a1c16 100644 --- a/packages/ui-extensions-dev-console/src/App.tsx +++ b/packages/ui-extensions-dev-console/src/App.tsx @@ -1,5 +1,5 @@ import {Layout} from '@/foundation/Layout' -import {Routes} from '@/foundation/Routes' +import {Extensions} from '@/sections/Extensions' import {Toast} from '@/foundation/Toast' import {Theme} from '@/foundation/Theme' import {ModalContainer} from '@/foundation/ModalContainer' @@ -29,7 +29,7 @@ function App() { - + diff --git a/packages/ui-extensions-dev-console/src/foundation/Routes/Routes.tsx b/packages/ui-extensions-dev-console/src/foundation/Routes/Routes.tsx deleted file mode 100644 index 6dd59db7199..00000000000 --- a/packages/ui-extensions-dev-console/src/foundation/Routes/Routes.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import {Extensions} from '@/sections/Extensions' -import React from 'react' -import {BrowserRouter, Routes as ReactRouterRoutes, Route} from 'react-router-dom' - -export function Routes() { - return ( - - - } /> - - - ) -} diff --git a/packages/ui-extensions-dev-console/src/foundation/Routes/index.ts b/packages/ui-extensions-dev-console/src/foundation/Routes/index.ts deleted file mode 100644 index 254d69e7060..00000000000 --- a/packages/ui-extensions-dev-console/src/foundation/Routes/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './Routes' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index adceb575232..6b0c7b1cf40 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -693,9 +693,6 @@ importers: react-dom: specifier: ^18.2.0 version: 18.3.1(react@18.3.1) - react-router-dom: - specifier: ^6.14.2 - version: 6.30.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react-toastify: specifier: ^9.1.3 version: 9.1.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -3346,10 +3343,6 @@ packages: '@protobufjs/utf8@1.1.0': resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==} - '@remix-run/router@1.23.2': - resolution: {integrity: sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==} - engines: {node: '>=14.0.0'} - '@repeaterjs/repeater@3.0.6': resolution: {integrity: sha512-Javneu5lsuhwNCryN+pXH93VPQ8g0dBX7wItHFgYiwQmzE1sVdg5tWHiOgHywzL2W21XQopa7IwIEnNbmeUJYA==} @@ -7645,19 +7638,6 @@ packages: resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} engines: {node: '>=0.10.0'} - react-router-dom@6.30.3: - resolution: {integrity: sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==} - engines: {node: '>=14.0.0'} - peerDependencies: - react: '>=16.8' - react-dom: '>=16.8' - - react-router@6.30.3: - resolution: {integrity: sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==} - engines: {node: '>=14.0.0'} - peerDependencies: - react: '>=16.8' - react-toastify@9.1.3: resolution: {integrity: sha512-fPfb8ghtn/XMxw3LkxQBk3IyagNpF/LIKjOBflbexr2AWxAH1MJgvnESwEwBn9liLFXgTKWgBSdZpw9m4OTHTg==} peerDependencies: @@ -12478,8 +12458,6 @@ snapshots: '@protobufjs/utf8@1.1.0': {} - '@remix-run/router@1.23.2': {} - '@repeaterjs/repeater@3.0.6': {} '@rolldown/pluginutils@1.0.0-rc.3': {} @@ -17455,18 +17433,6 @@ snapshots: react-refresh@0.18.0: {} - react-router-dom@6.30.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1): - dependencies: - '@remix-run/router': 1.23.2 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - react-router: 6.30.3(react@18.3.1) - - react-router@6.30.3(react@18.3.1): - dependencies: - '@remix-run/router': 1.23.2 - react: 18.3.1 - react-toastify@9.1.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: clsx: 1.2.1 From e56597003a9c820d9816d237811d00bb03a2860b Mon Sep 17 00:00:00 2001 From: Ryan Bahan Date: Thu, 2 Apr 2026 18:02:45 -0600 Subject: [PATCH 16/31] Remove debug dependency from eslint-plugin-cli The only usage was a single diagnostic log line in no-inline-graphql. Replace with a conditional console.error gated on process.env.DEBUG. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/eslint-plugin-cli/package.json | 3 +-- packages/eslint-plugin-cli/rules/no-inline-graphql.js | 4 ++-- pnpm-lock.yaml | 4 ---- 3 files changed, 3 insertions(+), 8 deletions(-) diff --git a/packages/eslint-plugin-cli/package.json b/packages/eslint-plugin-cli/package.json index 549177bf99e..3183b3105ef 100644 --- a/packages/eslint-plugin-cli/package.json +++ b/packages/eslint-plugin-cli/package.json @@ -29,8 +29,7 @@ "eslint-plugin-unused-imports": "4.1.4", "@vitest/eslint-plugin": "1.1.44", "globals": "16.2.0", - "execa": "7.2.0", - "debug": "4.4.0" + "execa": "7.2.0" }, "devDependencies": { "prettier": "3.8.1" diff --git a/packages/eslint-plugin-cli/rules/no-inline-graphql.js b/packages/eslint-plugin-cli/rules/no-inline-graphql.js index 7acf5006eaa..8959ccda80b 100644 --- a/packages/eslint-plugin-cli/rules/no-inline-graphql.js +++ b/packages/eslint-plugin-cli/rules/no-inline-graphql.js @@ -2,7 +2,7 @@ const path = require('path') const fs = require('fs') const crypto = require('crypto') -const debug = require('debug')('eslint-plugin-cli:no-inline-graphql') +const debugEnabled = process.env.DEBUG && process.env.DEBUG.includes('eslint-plugin-cli') /** * Check if using a gql`` template literal @@ -38,7 +38,7 @@ function checkKnownFailuresIfShouldFail(context) { const shouldFail = !knownFailures[relativePath] || knownFailures[relativePath] !== fileHash if (shouldFail) { - debug(`Reporting inline GraphQL tag fail for - '${relativePath}': '${fileHash}',`) + if (debugEnabled) console.error(`eslint-plugin-cli:no-inline-graphql Reporting inline GraphQL tag fail for - '${relativePath}': '${fileHash}',`) } return shouldFail diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6b0c7b1cf40..b76d2f5954e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -569,9 +569,6 @@ importers: '@vitest/eslint-plugin': specifier: 1.1.44 version: 1.1.44(@typescript-eslint/utils@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)(vitest@3.2.4(@types/node@22.19.15)(jiti@2.6.1)(jsdom@28.1.0)(msw@2.12.10(@types/node@22.19.15)(typescript@5.9.3))(sass@1.97.3)(yaml@2.8.3)) - debug: - specifier: 4.4.0 - version: 4.4.0(supports-color@8.1.1) eslint: specifier: ^9.0.0 version: 9.39.3(jiti@2.6.1) @@ -12595,7 +12592,6 @@ snapshots: '@typescript-eslint/eslint-plugin': 8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/parser': 8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) '@vitest/eslint-plugin': 1.1.44(@typescript-eslint/utils@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)(vitest@3.2.4(@types/node@18.19.70)(jiti@2.6.1)(jsdom@28.1.0)(msw@2.12.10(@types/node@18.19.70)(typescript@5.9.3))(sass@1.97.3)(yaml@2.8.3)) - debug: 4.4.0(supports-color@8.1.1) eslint: 9.39.3(jiti@2.6.1) eslint-config-prettier: 10.1.5(eslint@9.39.3(jiti@2.6.1)) eslint-plugin-jsdoc: 50.7.1(eslint@9.39.3(jiti@2.6.1)) From 3cfbe296449e5be6475836c33912bc91434988a6 Mon Sep 17 00:00:00 2001 From: Ryan Bahan Date: Thu, 2 Apr 2026 18:27:06 -0600 Subject: [PATCH 17/31] Remove tempy dependency, use Node builtins for temp directories Replace tempy with Node's built-in fs.mkdtemp/mkdtempSync for temp directory creation and cleanup. Add a small temp-dir.ts module that captures the real temp directory path at module load time using async realpath (via top-level await), which resolves both macOS symlinks (/var -> /private/var) and Windows 8.3 short names (RUNNER~1 -> runneradmin). Cleanup uses maxRetries: 2 for Windows robustness. All matching tempy's behavior. Fix three test files that used bare vi.mock('os') to use partial mocks instead, preserving real os.tmpdir() while still allowing specific function mocking (platform, hostname, EOL). Also removes unused tempy dep from e2e and workspace packages. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../cli/services/init/template/npm.test.ts | 5 +- packages/cli-kit/package.json | 1 - packages/cli-kit/src/private/node/temp-dir.ts | 8 ++ .../node/themes/generate-theme-name.test.ts | 5 +- packages/cli-kit/src/public/node/fs.test.ts | 5 +- packages/cli-kit/src/public/node/fs.ts | 12 +- packages/e2e/package.json | 3 +- pnpm-lock.yaml | 117 ------------------ workspace/package.json | 3 +- workspace/src/type-diff.js | 10 +- 10 files changed, 38 insertions(+), 131 deletions(-) create mode 100644 packages/cli-kit/src/private/node/temp-dir.ts diff --git a/packages/app/src/cli/services/init/template/npm.test.ts b/packages/app/src/cli/services/init/template/npm.test.ts index b8eeb21770a..4b5f3923b5a 100644 --- a/packages/app/src/cli/services/init/template/npm.test.ts +++ b/packages/app/src/cli/services/init/template/npm.test.ts @@ -5,7 +5,10 @@ import {inTemporaryDirectory, mkdir, readFile, writeFile} from '@shopify/cli-kit import {joinPath, moduleDirectory, normalizePath} from '@shopify/cli-kit/node/path' import {platform} from 'os' -vi.mock('os') +vi.mock('os', async (importOriginal) => { + const actual = await importOriginal() + return {...actual, platform: vi.fn()} +}) vi.mock('@shopify/cli-kit/node/node-package-manager') vi.mock('@shopify/cli-kit/common/version', () => ({CLI_KIT_VERSION: '1.2.3'})) diff --git a/packages/cli-kit/package.json b/packages/cli-kit/package.json index cedbf26541b..c0f8ada24dd 100644 --- a/packages/cli-kit/package.json +++ b/packages/cli-kit/package.json @@ -156,7 +156,6 @@ "stacktracey": "2.1.8", "strip-ansi": "7.1.0", "supports-hyperlinks": "3.1.0", - "tempy": "3.1.0", "terminal-link": "3.0.0", "ts-error": "1.0.6", "which": "4.0.0", diff --git a/packages/cli-kit/src/private/node/temp-dir.ts b/packages/cli-kit/src/private/node/temp-dir.ts new file mode 100644 index 00000000000..fbbdfeb3b66 --- /dev/null +++ b/packages/cli-kit/src/private/node/temp-dir.ts @@ -0,0 +1,8 @@ +import {realpath} from 'fs/promises' +import {tmpdir} from 'os' + +// Captured at module load time, before test mocks can interfere. +// Async realpath resolves symlinks (e.g. /var -> /private/var on macOS) +// and 8.3 short names on Windows (e.g. RUNNER~1 -> runneradmin), +// matching tempy's temp-dir behavior. +export const systemTempDir = await realpath(tmpdir()) diff --git a/packages/cli-kit/src/private/node/themes/generate-theme-name.test.ts b/packages/cli-kit/src/private/node/themes/generate-theme-name.test.ts index 7c9dfa5cebc..49acc905139 100644 --- a/packages/cli-kit/src/private/node/themes/generate-theme-name.test.ts +++ b/packages/cli-kit/src/private/node/themes/generate-theme-name.test.ts @@ -3,7 +3,10 @@ import {beforeEach, describe, expect, test, vi} from 'vitest' import {hostname} from 'os' import {randomBytes} from 'crypto' -vi.mock('os') +vi.mock('os', async (importOriginal) => { + const actual = await importOriginal() + return {...actual, hostname: vi.fn()} +}) vi.mock('crypto') describe('generateThemeName', () => { diff --git a/packages/cli-kit/src/public/node/fs.test.ts b/packages/cli-kit/src/public/node/fs.test.ts index 60a3a973541..c65237fc338 100644 --- a/packages/cli-kit/src/public/node/fs.test.ts +++ b/packages/cli-kit/src/public/node/fs.test.ts @@ -32,7 +32,10 @@ import * as os from 'os' vi.mock('../common/array.js') vi.mock('fast-glob') -vi.mock('os') +vi.mock('os', async (importOriginal) => { + const actual = await importOriginal() + return {...actual, EOL: actual.EOL} +}) describe('inTemporaryDirectory', () => { test('ties the lifecycle of the temporary directory to the lifecycle of the callback', async () => { diff --git a/packages/cli-kit/src/public/node/fs.ts b/packages/cli-kit/src/public/node/fs.ts index a20f0df445b..4163b73b2d9 100644 --- a/packages/cli-kit/src/public/node/fs.ts +++ b/packages/cli-kit/src/public/node/fs.ts @@ -2,6 +2,7 @@ import {outputContent, outputToken, outputDebug} from './output.js' import {joinPath, normalizePath} from './path.js' import {OverloadParameters} from '../../private/common/ts/overloaded-parameters.js' import {getRandomName, RandomNameFamily} from '../common/string.js' +import {systemTempDir} from '../../private/node/temp-dir.js' import { copy as fsCopy, ensureFile as fsEnsureFile, @@ -13,7 +14,6 @@ import { // @ts-ignore } from 'fs-extra/esm' -import {temporaryDirectory, temporaryDirectoryTask} from 'tempy' import {sep, join} from 'pathe' import {findUp as internalFindUp, findUpSync as internalFindUpSync} from 'find-up' import {minimatch} from 'minimatch' @@ -29,6 +29,7 @@ import { constants as fsConstants, existsSync as fsFileExistsSync, unlinkSync as fsUnlinkSync, + mkdtempSync as fsMkdtempSync, accessSync, ReadStream, WriteStream, @@ -75,7 +76,12 @@ export function stripUpPath(path: string, strip: number): string { * @param callback - The callback that receives the temporary directory. */ export async function inTemporaryDirectory(callback: (tmpDir: string) => T | Promise): Promise { - return temporaryDirectoryTask(callback) + const tmpDir = await fsMkdtemp(join(systemTempDir, 'tmp-')) + try { + return await callback(tmpDir) + } finally { + await fsRm(tmpDir, {recursive: true, force: true, maxRetries: 2}) + } } /** @@ -83,7 +89,7 @@ export async function inTemporaryDirectory(callback: (tmpDir: string) => T | * @returns - The path to the temporary directory. */ export function tempDirectory(): string { - return temporaryDirectory() + return fsMkdtempSync(join(systemTempDir, 'tmp-')) } /** diff --git a/packages/e2e/package.json b/packages/e2e/package.json index 8b9d5de63e8..fa0b9c0d312 100644 --- a/packages/e2e/package.json +++ b/packages/e2e/package.json @@ -33,8 +33,7 @@ "@types/node": "18.19.70", "execa": "^7.2.0", "node-pty": "^1.0.0", - "strip-ansi": "^7.1.0", - "tempy": "^1.0.1" + "strip-ansi": "^7.1.0" }, "engines": { "node": ">=20.10.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b76d2f5954e..dca13818234 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -462,9 +462,6 @@ importers: supports-hyperlinks: specifier: 3.1.0 version: 3.1.0 - tempy: - specifier: 3.1.0 - version: 3.1.0 terminal-link: specifier: 3.0.0 version: 3.0.0 @@ -551,9 +548,6 @@ importers: strip-ansi: specifier: ^7.1.0 version: 7.1.0 - tempy: - specifier: ^1.0.1 - version: 1.0.1 packages/eslint-plugin-cli: dependencies: @@ -778,9 +772,6 @@ importers: simple-git: specifier: 3.32.3 version: 3.32.3 - tempy: - specifier: 3.0.0 - version: 3.0.0 packages: @@ -4254,10 +4245,6 @@ packages: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} - aggregate-error@3.1.0: - resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==} - engines: {node: '>=8'} - ajv-formats@2.1.1: resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} peerDependencies: @@ -4703,10 +4690,6 @@ packages: resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} engines: {node: '>=8'} - clean-stack@2.2.0: - resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==} - engines: {node: '>=6'} - clean-stack@3.0.1: resolution: {integrity: sha512-lR9wNiMRcVQjSB3a7xXGLuz4cr4wJuuXlaAEbRutGowQTmlp7R72/DOgN21e8jdwblMWl9UOJMJXarX94pzKdg==} engines: {node: '>=10'} @@ -4949,10 +4932,6 @@ packages: crossws@0.3.5: resolution: {integrity: sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==} - crypto-random-string@2.0.0: - resolution: {integrity: sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==} - engines: {node: '>=8'} - crypto-random-string@4.0.0: resolution: {integrity: sha512-x8dy3RnvYdlUcPOjkEHqozhiwzKNSq7GcPuXFbnyMOCHxX8V3OgIg/pYuabl2sbUPfIJaeAQB7PMOK8DFIdoRA==} engines: {node: '>=12'} @@ -5094,10 +5073,6 @@ packages: defu@6.1.4: resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} - del@6.1.1: - resolution: {integrity: sha512-ua8BhapfP0JUJKC/zV9yHHDW/rDoDxP4Zhn3AkA6/xT6gY7jYXJiaeyBZznYVujhZZET+UgcbZiQ7sN3WqcImg==} - engines: {node: '>=10'} - delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} @@ -6332,14 +6307,6 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} - is-path-cwd@2.2.0: - resolution: {integrity: sha512-w942bTcih8fdJPJmQHFzkS76NEP8Kzzvmw92cXsazb8intwLqPibPPdXf4ANdKV3rYMuuQYGIWtvz9JilB3NFQ==} - engines: {node: '>=6'} - - is-path-inside@3.0.3: - resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} - engines: {node: '>=8'} - is-plain-obj@1.1.0: resolution: {integrity: sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==} engines: {node: '>=0.10.0'} @@ -7302,10 +7269,6 @@ packages: resolution: {integrity: sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==} engines: {node: '>=6'} - p-map@4.0.0: - resolution: {integrity: sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==} - engines: {node: '>=10'} - p-try@2.2.0: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} @@ -7819,11 +7782,6 @@ packages: rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} - rimraf@3.0.2: - resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} - deprecated: Rimraf versions prior to v4 are no longer supported - hasBin: true - rimraf@6.1.3: resolution: {integrity: sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==} engines: {node: 20 || >=22} @@ -8262,22 +8220,10 @@ packages: resolution: {integrity: sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==} engines: {node: '>=8'} - temp-dir@3.0.0: - resolution: {integrity: sha512-nHc6S/bwIilKHNRgK/3jlhDoIHcp45YgyiwcAk46Tr0LfEqGBVpmiAyuiuxeVE44m3mXnEeVhaipLOEWmH+Njw==} - engines: {node: '>=14.16'} - - tempy@1.0.1: - resolution: {integrity: sha512-biM9brNqxSc04Ee71hzFbryD11nX7VPhQQY32AdDmjFvodsRFz/3ufeoTZ6uYkRFfGo188tENcASNs3vTdsM0w==} - engines: {node: '>=10'} - tempy@3.0.0: resolution: {integrity: sha512-B2I9X7+o2wOaW4r/CWMkpOO9mdiTRCxXNgob6iGvPmfPWgH/KyUD6Uy5crtWBxIBe3YrNZKR2lSzv1JJKWD4vA==} engines: {node: '>=14.16'} - tempy@3.1.0: - resolution: {integrity: sha512-7jDLIdD2Zp0bDe5r3D2qtkd1QOCacylBuL7oa4udvN6v2pqr4+LcCr67C8DR1zkpaZ8XosF5m1yQSabKAW6f2g==} - engines: {node: '>=14.16'} - term-size@2.2.1: resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==} engines: {node: '>=8'} @@ -8453,10 +8399,6 @@ packages: resolution: {integrity: sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==} engines: {node: '>=10'} - type-fest@0.16.0: - resolution: {integrity: sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg==} - engines: {node: '>=10'} - type-fest@0.21.3: resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} engines: {node: '>=10'} @@ -8582,10 +8524,6 @@ packages: resolution: {integrity: sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==} engines: {node: '>=4'} - unique-string@2.0.0: - resolution: {integrity: sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==} - engines: {node: '>=8'} - unique-string@3.0.0: resolution: {integrity: sha512-VGXBUVwxKMBUznyffQweQABPRRW1vHZAbadFZud4pLFAqRGvv/96vafgjWFqzourzr8YonlQiPgH0YCJfawoGQ==} engines: {node: '>=12'} @@ -13641,11 +13579,6 @@ snapshots: agent-base@7.1.4: {} - aggregate-error@3.1.0: - dependencies: - clean-stack: 2.2.0 - indent-string: 4.0.0 - ajv-formats@2.1.1(ajv@8.18.0): optionalDependencies: ajv: 8.18.0 @@ -14180,8 +14113,6 @@ snapshots: ci-info@3.9.0: {} - clean-stack@2.2.0: {} - clean-stack@3.0.1: dependencies: escape-string-regexp: 4.0.0 @@ -14429,8 +14360,6 @@ snapshots: dependencies: uncrypto: 0.1.3 - crypto-random-string@2.0.0: {} - crypto-random-string@4.0.0: dependencies: type-fest: 1.4.0 @@ -14560,17 +14489,6 @@ snapshots: defu@6.1.4: {} - del@6.1.1: - dependencies: - globby: 11.1.0 - graceful-fs: 4.2.11 - is-glob: 4.0.3 - is-path-cwd: 2.2.0 - is-path-inside: 3.0.3 - p-map: 4.0.0 - rimraf: 3.0.2 - slash: 3.0.0 - delayed-stream@1.0.0: {} dependency-graph@0.11.0: {} @@ -16041,10 +15959,6 @@ snapshots: is-number@7.0.0: {} - is-path-cwd@2.2.0: {} - - is-path-inside@3.0.3: {} - is-plain-obj@1.1.0: {} is-plain-obj@4.1.0: {} @@ -17095,10 +17009,6 @@ snapshots: p-map@2.1.0: {} - p-map@4.0.0: - dependencies: - aggregate-error: 3.1.0 - p-try@2.2.0: {} package-json-from-dist@1.0.1: {} @@ -17643,10 +17553,6 @@ snapshots: rfdc@1.4.1: {} - rimraf@3.0.2: - dependencies: - glob: 7.2.3 - rimraf@6.1.3: dependencies: glob: 13.0.6 @@ -18172,16 +18078,6 @@ snapshots: temp-dir@2.0.0: {} - temp-dir@3.0.0: {} - - tempy@1.0.1: - dependencies: - del: 6.1.1 - is-stream: 2.0.1 - temp-dir: 2.0.0 - type-fest: 0.16.0 - unique-string: 2.0.0 - tempy@3.0.0: dependencies: is-stream: 3.0.0 @@ -18189,13 +18085,6 @@ snapshots: type-fest: 2.19.0 unique-string: 3.0.0 - tempy@3.1.0: - dependencies: - is-stream: 3.0.0 - temp-dir: 3.0.0 - type-fest: 2.19.0 - unique-string: 3.0.0 - term-size@2.2.1: {} terminal-link@3.0.0: @@ -18358,8 +18247,6 @@ snapshots: type-fest@0.13.1: {} - type-fest@0.16.0: {} - type-fest@0.21.3: {} type-fest@0.6.0: {} @@ -18476,10 +18363,6 @@ snapshots: unicode-property-aliases-ecmascript@2.2.0: {} - unique-string@2.0.0: - dependencies: - crypto-random-string: 2.0.0 - unique-string@3.0.0: dependencies: crypto-random-string: 4.0.0 diff --git a/workspace/package.json b/workspace/package.json index ad64174f312..f37f031c5f6 100644 --- a/workspace/package.json +++ b/workspace/package.json @@ -17,7 +17,6 @@ "devDependencies": { "@actions/core": "^1.10.0", "git-diff": "^2.0.6", - "simple-git": "3.32.3", - "tempy": "3.0.0" + "simple-git": "3.32.3" } } diff --git a/workspace/src/type-diff.js b/workspace/src/type-diff.js index c9c057f7d28..e33c879f572 100644 --- a/workspace/src/type-diff.js +++ b/workspace/src/type-diff.js @@ -4,7 +4,8 @@ import * as path from 'pathe' import * as url from 'url' import {execa} from 'execa' import fg from 'fast-glob' -import {temporaryDirectoryTask} from 'tempy' +import {mkdtemp, rm} from 'fs/promises' +import os from 'os' import git from 'simple-git' import {setOutput} from '@actions/core' import {promises as fs, existsSync} from 'fs' @@ -95,7 +96,8 @@ ${ ) } -await temporaryDirectoryTask(async (tmpDir) => { +const tmpDir = await mkdtemp(path.join(os.tmpdir(), 'type-diff-')) +try { const baselineDirectory = await cloneCLIRepository(tmpDir) const currentDirectory = path.join(url.fileURLToPath(new URL('.', import.meta.url)), '../..') const baselineFiles = await build(baselineDirectory, {name: 'baseline'}) @@ -106,4 +108,6 @@ await temporaryDirectoryTask(async (tmpDir) => { baselineFiles, currentFiles, }) -}) +} finally { + await rm(tmpDir, {recursive: true, force: true, maxRetries: 2}) +} From ea31be17f869c589b277b8ff7fbda036a3dd7fab Mon Sep 17 00:00:00 2001 From: Ryan Bahan Date: Thu, 2 Apr 2026 19:05:49 -0600 Subject: [PATCH 18/31] Remove eslint-plugin-jsdoc from eslint-plugin-cli Move the jsdoc plugin dependency and registration from eslint-plugin-cli to the root eslint.config.js where the jsdoc rules are actually configured. The single jsdoc/require-returns-description rule that was in eslint-plugin-cli's config.js is redundant with the comprehensive jsdoc rule set in the root config, so it's removed. Co-Authored-By: Claude Opus 4.6 (1M context) --- eslint.config.js | 4 ++++ package.json | 1 + packages/eslint-plugin-cli/config.js | 3 --- packages/eslint-plugin-cli/package.json | 1 - pnpm-lock.yaml | 7 +++---- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index dd96a5b50e0..2ef521d66a8 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,5 +1,6 @@ import nxPlugin from '@nx/eslint-plugin' import cliPlugin from '@shopify/eslint-plugin-cli' +import jsdocPlugin from 'eslint-plugin-jsdoc' // Spread the CLI plugin's base config which includes all necessary plugins const config = [ @@ -44,6 +45,9 @@ const config = [ '**/public/node/result.ts', '**/public/node/themes/**/*', ], + plugins: { + jsdoc: jsdocPlugin, + }, settings: { jsdoc: { publicFunctionsOnly: true, diff --git a/package.json b/package.json index cd81b6826d4..fc6802e3a90 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "bugsnag-build-reporter": "^2.0.0", "commander": "^9.4.0", "esbuild": "0.27.4", + "eslint-plugin-jsdoc": "50.7.1", "eslint": "^9.26.0", "execa": "^7.2.0", "fast-glob": "3.3.3", diff --git a/packages/eslint-plugin-cli/config.js b/packages/eslint-plugin-cli/config.js index 8a67005dffe..b01187d5f85 100644 --- a/packages/eslint-plugin-cli/config.js +++ b/packages/eslint-plugin-cli/config.js @@ -2,7 +2,6 @@ const shopifyPlugin = require('@shopify/eslint-plugin') const vitestPlugin = require('@vitest/eslint-plugin') const unusedImportsPlugin = require('eslint-plugin-unused-imports') const tsdocPlugin = require('eslint-plugin-tsdoc') -const jsdocPlugin = require('eslint-plugin-jsdoc') const noCatchAllPlugin = require('eslint-plugin-no-catch-all') const eslintConfigPrettier = require('eslint-config-prettier') const globals = require('globals') @@ -176,7 +175,6 @@ const baseRules = { }, ], 'tsdoc/syntax': 'error', - 'jsdoc/require-returns-description': 'error', 'promise/catch-or-return': ['error', {allowFinally: true}], 'no-unused-vars': 'off', '@typescript-eslint/no-unused-vars': [ @@ -280,7 +278,6 @@ const config = [ vitest: vitestPlugin, 'unused-imports': unusedImportsPlugin, tsdoc: tsdocPlugin, - jsdoc: jsdocPlugin, 'no-catch-all': noCatchAllPlugin, '@shopify/cli': cliPlugin, }, diff --git a/packages/eslint-plugin-cli/package.json b/packages/eslint-plugin-cli/package.json index 3183b3105ef..30988fc9234 100644 --- a/packages/eslint-plugin-cli/package.json +++ b/packages/eslint-plugin-cli/package.json @@ -20,7 +20,6 @@ "@typescript-eslint/eslint-plugin": "8.56.1", "@typescript-eslint/parser": "8.56.1", "eslint-config-prettier": "10.1.5", - "eslint-plugin-jsdoc": "50.7.1", "eslint-plugin-no-catch-all": "1.1.0", "eslint-plugin-prettier": "5.5.1", "eslint-plugin-react": "7.37.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dca13818234..de8fba7428c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -82,6 +82,9 @@ importers: eslint: specifier: ^9.26.0 version: 9.39.3(jiti@2.6.1) + eslint-plugin-jsdoc: + specifier: 50.7.1 + version: 50.7.1(eslint@9.39.3(jiti@2.6.1)) execa: specifier: ^7.2.0 version: 7.2.0 @@ -569,9 +572,6 @@ importers: eslint-config-prettier: specifier: 10.1.5 version: 10.1.5(eslint@9.39.3(jiti@2.6.1)) - eslint-plugin-jsdoc: - specifier: 50.7.1 - version: 50.7.1(eslint@9.39.3(jiti@2.6.1)) eslint-plugin-no-catch-all: specifier: 1.1.0 version: 1.1.0(eslint@9.39.3(jiti@2.6.1)) @@ -12532,7 +12532,6 @@ snapshots: '@vitest/eslint-plugin': 1.1.44(@typescript-eslint/utils@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)(vitest@3.2.4(@types/node@18.19.70)(jiti@2.6.1)(jsdom@28.1.0)(msw@2.12.10(@types/node@18.19.70)(typescript@5.9.3))(sass@1.97.3)(yaml@2.8.3)) eslint: 9.39.3(jiti@2.6.1) eslint-config-prettier: 10.1.5(eslint@9.39.3(jiti@2.6.1)) - eslint-plugin-jsdoc: 50.7.1(eslint@9.39.3(jiti@2.6.1)) eslint-plugin-no-catch-all: 1.1.0(eslint@9.39.3(jiti@2.6.1)) eslint-plugin-prettier: 5.5.1(eslint-config-prettier@10.1.5(eslint@9.39.3(jiti@2.6.1)))(eslint@9.39.3(jiti@2.6.1))(prettier@3.8.1) eslint-plugin-react: 7.37.5(eslint@9.39.3(jiti@2.6.1)) From 4b45d1c3881a0cfa4f4648f86c48151ba9835f71 Mon Sep 17 00:00:00 2001 From: Ryan Bahan Date: Thu, 2 Apr 2026 19:10:02 -0600 Subject: [PATCH 19/31] Remove terminal-link, use existing ansi-escapes and supports-hyperlinks The single call site in content-tokens.ts is replaced with a direct check of supportsHyperlinks.stdout and ansiEscapes.link(), both of which are already dependencies used by the Link.tsx component. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli-kit/package.json | 1 - .../src/private/node/content-tokens.ts | 8 ++++++-- pnpm-lock.yaml | 20 ------------------- 3 files changed, 6 insertions(+), 23 deletions(-) diff --git a/packages/cli-kit/package.json b/packages/cli-kit/package.json index c0f8ada24dd..abcb38f0c36 100644 --- a/packages/cli-kit/package.json +++ b/packages/cli-kit/package.json @@ -156,7 +156,6 @@ "stacktracey": "2.1.8", "strip-ansi": "7.1.0", "supports-hyperlinks": "3.1.0", - "terminal-link": "3.0.0", "ts-error": "1.0.6", "which": "4.0.0", "zod": "3.24.4" diff --git a/packages/cli-kit/src/private/node/content-tokens.ts b/packages/cli-kit/src/private/node/content-tokens.ts index 932c53389c0..5f2cbb5eee3 100644 --- a/packages/cli-kit/src/private/node/content-tokens.ts +++ b/packages/cli-kit/src/private/node/content-tokens.ts @@ -1,7 +1,8 @@ import colors from '../../public/node/colors.js' import {OutputMessage, stringifyMessage} from '../../public/node/output.js' import {relativizePath} from '../../public/node/path.js' -import terminalLink from 'terminal-link' +import ansiEscapes from 'ansi-escapes' +import supportsHyperlinks from 'supports-hyperlinks' import cjs from 'color-json' import type {Change} from 'diff' @@ -35,7 +36,10 @@ export class LinkContentToken extends ContentToken { const text = colors.green(stringifyMessage(this.value)) const url = this.link ?? '' const defaultFallback = this.value === this.link ? text : `${text} ( ${url} )` - return terminalLink(text, url, {fallback: () => this.fallback ?? defaultFallback}) + if (supportsHyperlinks.stdout) { + return ansiEscapes.link(text, url) + } + return this.fallback ?? defaultFallback } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index de8fba7428c..6d6ac541a03 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -465,9 +465,6 @@ importers: supports-hyperlinks: specifier: 3.1.0 version: 3.1.0 - terminal-link: - specifier: 3.0.0 - version: 3.0.0 ts-error: specifier: 1.0.6 version: 1.0.6 @@ -4270,10 +4267,6 @@ packages: resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} engines: {node: '>=8'} - ansi-escapes@5.0.0: - resolution: {integrity: sha512-5GFMVX8HqE/TB+FuBJGuO5XG0WrsA6ptUqoODaT/n9mmUaZFkqnBueB4leqGBCmrUHnCnC4PCZTCd0E7QQ83bA==} - engines: {node: '>=12'} - ansi-escapes@6.2.1: resolution: {integrity: sha512-4nJ3yixlEthEJ9Rk4vPcdBRkZvQZlYyu8j4/Mqz5sgIkddmEnH2Yj2ZrnP9S3tQOvSNRUIgVNF/1yPpRAGNRig==} engines: {node: '>=14.16'} @@ -8228,10 +8221,6 @@ packages: resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==} engines: {node: '>=8'} - terminal-link@3.0.0: - resolution: {integrity: sha512-flFL3m4wuixmf6IfhFJd1YPiLiMuxEc8uHRM1buzIeZPm22Au2pDqBJQgdo7n1WfPU1ONFGv7YDwpFBmHGF6lg==} - engines: {node: '>=12'} - terminal-size@4.0.1: resolution: {integrity: sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ==} engines: {node: '>=18'} @@ -13609,10 +13598,6 @@ snapshots: dependencies: type-fest: 0.21.3 - ansi-escapes@5.0.0: - dependencies: - type-fest: 1.4.0 - ansi-escapes@6.2.1: {} ansi-escapes@7.3.0: @@ -18086,11 +18071,6 @@ snapshots: term-size@2.2.1: {} - terminal-link@3.0.0: - dependencies: - ansi-escapes: 5.0.0 - supports-hyperlinks: 3.1.0 - terminal-size@4.0.1: {} test-exclude@7.0.2: From 5dcc4115cb19f8ded3920d2bc5aed1ff8e09eb26 Mon Sep 17 00:00:00 2001 From: Craig Martin Date: Wed, 1 Apr 2026 16:50:08 +0000 Subject: [PATCH 20/31] test: add regression tests for clientSteps passthrough in specification factories Ensures clientSteps is properly forwarded in createContractBasedModuleSpecification, createExtensionSpecification, and createConfigExtensionSpecification. Prevents regression of the bug fixed in #7141 where clientSteps was accidentally dropped from createContractBasedModuleSpecification during a conflict resolution, breaking channel_config extension builds. --- .../specification.integration.test.ts | 78 ++++++++++++++++++- 1 file changed, 77 insertions(+), 1 deletion(-) diff --git a/packages/app/src/cli/models/extensions/specification.integration.test.ts b/packages/app/src/cli/models/extensions/specification.integration.test.ts index 9bbcdd6167c..11f7912698d 100644 --- a/packages/app/src/cli/models/extensions/specification.integration.test.ts +++ b/packages/app/src/cli/models/extensions/specification.integration.test.ts @@ -1,5 +1,12 @@ import {loadLocalExtensionsSpecifications} from './load-specifications.js' -import {configWithoutFirstClassFields, createContractBasedModuleSpecification} from './specification.js' +import { + configWithoutFirstClassFields, + createContractBasedModuleSpecification, + createConfigExtensionSpecification, + createExtensionSpecification, +} from './specification.js' +import {BaseSchema} from './schemas.js' +import {ClientSteps} from '../../services/build/client-steps.js' import {AppSchema} from '../app/app.js' import {describe, test, expect, beforeAll} from 'vitest' @@ -28,6 +35,19 @@ describe('allLocalSpecs', () => { }) }) +const testClientSteps: ClientSteps = [ + { + lifecycle: 'deploy', + steps: [ + { + id: 'copy_static', + name: 'Copy static assets', + type: 'copy_static_assets', + }, + ], + }, +] + describe('createContractBasedModuleSpecification', () => { test('creates a specification with the given identifier', () => { // When @@ -48,6 +68,62 @@ describe('createContractBasedModuleSpecification', () => { ) expect(got.appModuleFeatures()).toEqual(['localization']) }) + + test('passes clientSteps through to the created specification', () => { + // When + const got = createContractBasedModuleSpecification({ + identifier: 'channel_config', + uidStrategy: 'uuid', + experience: 'extension', + appModuleFeatures: () => [], + clientSteps: testClientSteps, + }) + + // Then + expect(got.clientSteps).toEqual(testClientSteps) + }) + + test('clientSteps is undefined when not provided', () => { + // When + const got = createContractBasedModuleSpecification({ + identifier: 'test', + uidStrategy: 'uuid', + experience: 'extension', + appModuleFeatures: () => [], + }) + + // Then + expect(got.clientSteps).toBeUndefined() + }) +}) + +describe('createExtensionSpecification', () => { + test('passes clientSteps through to the created specification', () => { + // When + const got = createExtensionSpecification({ + identifier: 'test_extension', + appModuleFeatures: () => [], + clientSteps: testClientSteps, + }) + + // Then + expect(got.clientSteps).toEqual(testClientSteps) + }) +}) + +describe('createConfigExtensionSpecification', () => { + test('passes clientSteps through to the created specification', () => { + // When + const got = createConfigExtensionSpecification({ + identifier: 'test_config', + schema: BaseSchema, + transformConfig: {}, + clientSteps: testClientSteps, + }) + + // Then + expect(got.clientSteps).toEqual(testClientSteps) + }) }) describe('configWithoutFirstClassFields', () => { From 58f48d06447e05bceb8dd83ae364363e47f0d905 Mon Sep 17 00:00:00 2001 From: Donald Merand Date: Thu, 2 Apr 2026 18:00:17 -0400 Subject: [PATCH 21/31] Make store auth scopes additive --- .../services/store/admin-graphql-context.ts | 106 +----- .../cli/src/cli/services/store/auth.test.ts | 330 +++++++++++++++++- packages/cli/src/cli/services/store/auth.ts | 139 +++++++- .../src/cli/services/store/stored-session.ts | 104 ++++++ 4 files changed, 564 insertions(+), 115 deletions(-) create mode 100644 packages/cli/src/cli/services/store/stored-session.ts diff --git a/packages/cli/src/cli/services/store/admin-graphql-context.ts b/packages/cli/src/cli/services/store/admin-graphql-context.ts index f014e5b9e9e..a0c5f6a6fe6 100644 --- a/packages/cli/src/cli/services/store/admin-graphql-context.ts +++ b/packages/cli/src/cli/services/store/admin-graphql-context.ts @@ -1,17 +1,11 @@ import {fetchApiVersions} from '@shopify/cli-kit/node/api/admin' import {AbortError} from '@shopify/cli-kit/node/error' -import {fetch} from '@shopify/cli-kit/node/http' import {outputContent, outputDebug, outputToken} from '@shopify/cli-kit/node/output' import {AdminSession} from '@shopify/cli-kit/node/session' -import {maskToken, STORE_AUTH_APP_CLIENT_ID} from './auth-config.js' -import {createStoredStoreAuthError, reauthenticateStoreAuthError} from './auth-recovery.js' -import { - clearStoredStoreAppSession, - getStoredStoreAppSession, - isSessionExpired, - setStoredStoreAppSession, - StoredStoreAppSession, -} from './session.js' +import {reauthenticateStoreAuthError} from './auth-recovery.js' +import {clearStoredStoreAppSession} from './session.js' +import type {StoredStoreAppSession} from './session.js' +import {loadStoredStoreSession} from './stored-session.js' export interface AdminStoreGraphQLContext { adminSession: AdminSession @@ -19,98 +13,6 @@ export interface AdminStoreGraphQLContext { sessionUserId: string } -async function refreshStoreToken(session: StoredStoreAppSession): Promise { - if (!session.refreshToken) { - throw reauthenticateStoreAuthError(`No refresh token stored for ${session.store}.`, session.store, session.scopes.join(',')) - } - - const endpoint = `https://${session.store}/admin/oauth/access_token` - - outputDebug( - outputContent`Refreshing expired token for ${outputToken.raw(session.store)} (expired at ${outputToken.raw(session.expiresAt ?? 'unknown')}, refresh_token=${outputToken.raw(maskToken(session.refreshToken))})`, - ) - - const response = await fetch(endpoint, { - method: 'POST', - headers: {'Content-Type': 'application/json'}, - body: JSON.stringify({ - client_id: STORE_AUTH_APP_CLIENT_ID, - grant_type: 'refresh_token', - refresh_token: session.refreshToken, - }), - }) - - const body = await response.text() - - if (!response.ok) { - outputDebug( - outputContent`Token refresh failed with HTTP ${outputToken.raw(String(response.status))}: ${outputToken.raw(body.slice(0, 300))}`, - ) - clearStoredStoreAppSession(session.store, session.userId) - throw reauthenticateStoreAuthError( - `Token refresh failed for ${session.store} (HTTP ${response.status}).`, - session.store, - session.scopes.join(','), - ) - } - - let data: {access_token?: string; refresh_token?: string; expires_in?: number; refresh_token_expires_in?: number} - try { - data = JSON.parse(body) - } catch { - clearStoredStoreAppSession(session.store, session.userId) - throw new AbortError('Received an invalid refresh response from Shopify.') - } - - if (!data.access_token) { - clearStoredStoreAppSession(session.store, session.userId) - throw reauthenticateStoreAuthError( - `Token refresh returned an invalid response for ${session.store}.`, - session.store, - session.scopes.join(','), - ) - } - - const now = Date.now() - const expiresAt = data.expires_in ? new Date(now + data.expires_in * 1000).toISOString() : session.expiresAt - - const refreshedSession: StoredStoreAppSession = { - ...session, - accessToken: data.access_token, - refreshToken: data.refresh_token ?? session.refreshToken, - expiresAt, - refreshTokenExpiresAt: data.refresh_token_expires_in - ? new Date(now + data.refresh_token_expires_in * 1000).toISOString() - : session.refreshTokenExpiresAt, - acquiredAt: new Date(now).toISOString(), - } - - outputDebug( - outputContent`Token refresh succeeded for ${outputToken.raw(session.store)}: ${outputToken.raw(maskToken(session.accessToken))} → ${outputToken.raw(maskToken(refreshedSession.accessToken))}, new expiry ${outputToken.raw(expiresAt ?? 'unknown')}`, - ) - - setStoredStoreAppSession(refreshedSession) - return refreshedSession -} - -async function loadStoredStoreSession(store: string): Promise { - let session = getStoredStoreAppSession(store) - - if (!session) { - throw createStoredStoreAuthError(store) - } - - outputDebug( - outputContent`Loaded stored session for ${outputToken.raw(store)}: token=${outputToken.raw(maskToken(session.accessToken))}, expires=${outputToken.raw(session.expiresAt ?? 'unknown')}`, - ) - - if (isSessionExpired(session)) { - session = await refreshStoreToken(session) - } - - return session -} - async function resolveApiVersion(options: { session: StoredStoreAppSession adminSession: AdminSession diff --git a/packages/cli/src/cli/services/store/auth.test.ts b/packages/cli/src/cli/services/store/auth.test.ts index 0ae4fe7c94e..7dba9f6d9ca 100644 --- a/packages/cli/src/cli/services/store/auth.test.ts +++ b/packages/cli/src/cli/services/store/auth.test.ts @@ -7,14 +7,28 @@ import { generateCodeVerifier, computeCodeChallenge, exchangeStoreAuthCodeForToken, + resolveExistingStoreAuthScopes, waitForStoreAuthCode, } from './auth.js' -import {setStoredStoreAppSession} from './session.js' +import {loadStoredStoreSession} from './stored-session.js' +import {getStoredStoreAppSession, setStoredStoreAppSession} from './session.js' import {STORE_AUTH_APP_CLIENT_ID} from './auth-config.js' +import {adminUrl} from '@shopify/cli-kit/node/api/admin' +import {graphqlRequest} from '@shopify/cli-kit/node/api/graphql' import {fetch} from '@shopify/cli-kit/node/http' +import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' vi.mock('./session.js') +vi.mock('./stored-session.js', () => ({loadStoredStoreSession: vi.fn()})) vi.mock('@shopify/cli-kit/node/http') +vi.mock('@shopify/cli-kit/node/api/graphql') +vi.mock('@shopify/cli-kit/node/api/admin', async () => { + const actual = await vi.importActual('@shopify/cli-kit/node/api/admin') + return { + ...actual, + adminUrl: vi.fn(), + } +}) vi.mock('@shopify/cli-kit/node/system', () => ({openURL: vi.fn().mockResolvedValue(true)})) vi.mock('@shopify/cli-kit/node/crypto', () => ({randomUUID: vi.fn().mockReturnValue('state-123')})) @@ -62,10 +76,12 @@ function callbackParams(options?: { describe('store auth service', () => { beforeEach(() => { vi.clearAllMocks() + vi.mocked(adminUrl).mockReturnValue('https://shop.myshopify.com/admin/api/unstable/graphql.json') }) afterEach(() => { vi.restoreAllMocks() + mockAndCaptureOutput().clear() }) test('generateCodeVerifier produces a base64url string of 43 chars', () => { @@ -115,6 +131,139 @@ describe('store auth service', () => { expect(url.searchParams.get('grant_options[]')).toBeNull() }) + test('resolveExistingStoreAuthScopes returns no scopes when no stored auth exists', async () => { + vi.mocked(getStoredStoreAppSession).mockReturnValue(undefined) + + await expect(resolveExistingStoreAuthScopes('shop.myshopify.com')).resolves.toEqual({scopes: [], authoritative: true}) + expect(loadStoredStoreSession).not.toHaveBeenCalled() + expect(graphqlRequest).not.toHaveBeenCalled() + }) + + test('resolveExistingStoreAuthScopes prefers current remote scopes over stale local scopes', async () => { + vi.mocked(getStoredStoreAppSession).mockReturnValue({ + store: 'shop.myshopify.com', + clientId: STORE_AUTH_APP_CLIENT_ID, + userId: '42', + accessToken: 'existing-token', + refreshToken: 'existing-refresh-token', + scopes: ['read_orders'], + acquiredAt: '2026-04-02T00:00:00.000Z', + }) + vi.mocked(loadStoredStoreSession).mockResolvedValue({ + store: 'shop.myshopify.com', + clientId: STORE_AUTH_APP_CLIENT_ID, + userId: '42', + accessToken: 'fresh-token', + refreshToken: 'fresh-refresh-token', + scopes: ['read_orders'], + acquiredAt: '2026-04-02T00:00:00.000Z', + } as any) + vi.mocked(graphqlRequest).mockResolvedValue({ + currentAppInstallation: {accessScopes: [{handle: 'read_products'}, {handle: 'read_customers'}]}, + } as any) + + await expect(resolveExistingStoreAuthScopes('shop.myshopify.com')).resolves.toEqual({ + scopes: ['read_products', 'read_customers'], + authoritative: true, + }) + expect(adminUrl).toHaveBeenCalledWith('shop.myshopify.com', 'unstable') + expect(graphqlRequest).toHaveBeenCalledWith({ + query: expect.stringContaining('currentAppInstallation'), + api: 'Admin', + url: 'https://shop.myshopify.com/admin/api/unstable/graphql.json', + token: 'fresh-token', + responseOptions: {handleErrors: false}, + }) + }) + + test('resolveExistingStoreAuthScopes falls back to locally stored scopes when remote lookup fails', async () => { + vi.mocked(getStoredStoreAppSession).mockReturnValue({ + store: 'shop.myshopify.com', + clientId: STORE_AUTH_APP_CLIENT_ID, + userId: '42', + accessToken: 'existing-token', + refreshToken: 'existing-refresh-token', + scopes: ['read_orders'], + acquiredAt: '2026-04-02T00:00:00.000Z', + }) + vi.mocked(loadStoredStoreSession).mockRejectedValue(new Error('boom')) + + await expect(resolveExistingStoreAuthScopes('shop.myshopify.com')).resolves.toEqual({ + scopes: ['read_orders'], + authoritative: false, + }) + }) + + test('resolveExistingStoreAuthScopes falls back to locally stored scopes when access scopes request fails', async () => { + const output = mockAndCaptureOutput() + + vi.mocked(getStoredStoreAppSession).mockReturnValue({ + store: 'shop.myshopify.com', + clientId: STORE_AUTH_APP_CLIENT_ID, + userId: '42', + accessToken: 'existing-token', + refreshToken: 'existing-refresh-token', + scopes: ['read_orders'], + acquiredAt: '2026-04-02T00:00:00.000Z', + }) + vi.mocked(loadStoredStoreSession).mockResolvedValue({ + store: 'shop.myshopify.com', + clientId: STORE_AUTH_APP_CLIENT_ID, + userId: '42', + accessToken: 'fresh-token', + refreshToken: 'fresh-refresh-token', + scopes: ['read_orders'], + acquiredAt: '2026-04-02T00:00:00.000Z', + } as any) + const scopeLookupError = new Error('GraphQL Error (Code: 401)') + Object.assign(scopeLookupError, { + response: { + status: 401, + errors: '[API] Invalid API key or access token (unrecognized login or wrong password)', + }, + request: { + query: '#graphql query CurrentAppInstallationAccessScopes { currentAppInstallation { accessScopes { handle } } }', + }, + }) + vi.mocked(graphqlRequest).mockRejectedValue(scopeLookupError) + + await expect(resolveExistingStoreAuthScopes('shop.myshopify.com')).resolves.toEqual({ + scopes: ['read_orders'], + authoritative: false, + }) + expect(output.debug()).toContain('after remote scope lookup failed: HTTP 401: [API] Invalid API key or access token') + expect(output.debug()).not.toContain('CurrentAppInstallationAccessScopes') + }) + + test('resolveExistingStoreAuthScopes falls back to locally stored scopes when access scopes response is invalid', async () => { + vi.mocked(getStoredStoreAppSession).mockReturnValue({ + store: 'shop.myshopify.com', + clientId: STORE_AUTH_APP_CLIENT_ID, + userId: '42', + accessToken: 'existing-token', + refreshToken: 'existing-refresh-token', + scopes: ['read_orders'], + acquiredAt: '2026-04-02T00:00:00.000Z', + }) + vi.mocked(loadStoredStoreSession).mockResolvedValue({ + store: 'shop.myshopify.com', + clientId: STORE_AUTH_APP_CLIENT_ID, + userId: '42', + accessToken: 'fresh-token', + refreshToken: 'fresh-refresh-token', + scopes: ['read_orders'], + acquiredAt: '2026-04-02T00:00:00.000Z', + } as any) + vi.mocked(graphqlRequest).mockResolvedValue({ + currentAppInstallation: undefined, + } as any) + + await expect(resolveExistingStoreAuthScopes('shop.myshopify.com')).resolves.toEqual({ + scopes: ['read_orders'], + authoritative: false, + }) + }) + test('waitForStoreAuthCode resolves after a valid callback', async () => { const port = await getAvailablePort() const params = callbackParams() @@ -354,6 +503,185 @@ describe('store auth service', () => { }) }) + test('authenticateStoreWithApp uses remote scopes by default when available', async () => { + vi.mocked(getStoredStoreAppSession).mockReturnValue({ + store: 'shop.myshopify.com', + clientId: STORE_AUTH_APP_CLIENT_ID, + userId: '42', + accessToken: 'existing-token', + refreshToken: 'existing-refresh-token', + scopes: ['read_orders'], + acquiredAt: '2026-04-02T00:00:00.000Z', + }) + vi.mocked(loadStoredStoreSession).mockResolvedValue({ + store: 'shop.myshopify.com', + clientId: STORE_AUTH_APP_CLIENT_ID, + userId: '42', + accessToken: 'fresh-token', + refreshToken: 'fresh-refresh-token', + scopes: ['read_orders'], + acquiredAt: '2026-04-02T00:00:00.000Z', + } as any) + vi.mocked(graphqlRequest).mockResolvedValue({ + currentAppInstallation: {accessScopes: [{handle: 'read_customers'}]}, + } as any) + + const openURL = vi.fn().mockResolvedValue(true) + const presenter = { + openingBrowser: vi.fn(), + manualAuthUrl: vi.fn(), + success: vi.fn(), + } + const waitForStoreAuthCodeMock = vi.fn().mockImplementation(async (options) => { + await options.onListening?.() + return 'abc123' + }) + + await authenticateStoreWithApp( + { + store: 'shop.myshopify.com', + scopes: 'read_products', + }, + { + openURL, + waitForStoreAuthCode: waitForStoreAuthCodeMock, + exchangeStoreAuthCodeForToken: vi.fn().mockResolvedValue({ + access_token: 'token', + scope: 'read_customers,read_products', + expires_in: 86400, + associated_user: {id: 42, email: 'test@example.com'}, + }), + presenter, + }, + ) + + const authorizationUrl = new URL(openURL.mock.calls[0]![0]) + expect(authorizationUrl.searchParams.get('scope')).toBe('read_customers,read_products') + }) + + test('authenticateStoreWithApp reuses resolved existing scopes when requesting additional access', async () => { + const openURL = vi.fn().mockResolvedValue(true) + const presenter = { + openingBrowser: vi.fn(), + manualAuthUrl: vi.fn(), + success: vi.fn(), + } + const waitForStoreAuthCodeMock = vi.fn().mockImplementation(async (options) => { + await options.onListening?.() + return 'abc123' + }) + + await authenticateStoreWithApp( + { + store: 'shop.myshopify.com', + scopes: 'read_products', + }, + { + openURL, + waitForStoreAuthCode: waitForStoreAuthCodeMock, + exchangeStoreAuthCodeForToken: vi.fn().mockResolvedValue({ + access_token: 'token', + scope: 'read_orders,read_products', + expires_in: 86400, + associated_user: {id: 42, email: 'test@example.com'}, + }), + resolveExistingScopes: vi.fn().mockResolvedValue({scopes: ['read_orders'], authoritative: true}), + presenter, + }, + ) + + const authorizationUrl = new URL(openURL.mock.calls[0]![0]) + expect(authorizationUrl.searchParams.get('scope')).toBe('read_orders,read_products') + expect(setStoredStoreAppSession).toHaveBeenCalledWith( + expect.objectContaining({ + store: 'shop.myshopify.com', + scopes: ['read_orders', 'read_products'], + }), + ) + }) + + test('authenticateStoreWithApp does not require non-authoritative cached scopes to still be granted', async () => { + const openURL = vi.fn().mockResolvedValue(true) + const presenter = { + openingBrowser: vi.fn(), + manualAuthUrl: vi.fn(), + success: vi.fn(), + } + const waitForStoreAuthCodeMock = vi.fn().mockImplementation(async (options) => { + await options.onListening?.() + return 'abc123' + }) + + await authenticateStoreWithApp( + { + store: 'shop.myshopify.com', + scopes: 'read_products', + }, + { + openURL, + waitForStoreAuthCode: waitForStoreAuthCodeMock, + exchangeStoreAuthCodeForToken: vi.fn().mockResolvedValue({ + access_token: 'token', + scope: 'read_products', + expires_in: 86400, + associated_user: {id: 42, email: 'test@example.com'}, + }), + resolveExistingScopes: vi.fn().mockResolvedValue({scopes: ['read_orders'], authoritative: false}), + presenter, + }, + ) + + const authorizationUrl = new URL(openURL.mock.calls[0]![0]) + expect(authorizationUrl.searchParams.get('scope')).toBe('read_orders,read_products') + expect(setStoredStoreAppSession).toHaveBeenCalledWith( + expect.objectContaining({ + store: 'shop.myshopify.com', + scopes: ['read_products'], + }), + ) + }) + + test('authenticateStoreWithApp avoids requesting redundant read scopes already implied by existing write scopes', async () => { + const openURL = vi.fn().mockResolvedValue(true) + const presenter = { + openingBrowser: vi.fn(), + manualAuthUrl: vi.fn(), + success: vi.fn(), + } + const waitForStoreAuthCodeMock = vi.fn().mockImplementation(async (options) => { + await options.onListening?.() + return 'abc123' + }) + + await authenticateStoreWithApp( + { + store: 'shop.myshopify.com', + scopes: 'read_products', + }, + { + openURL, + waitForStoreAuthCode: waitForStoreAuthCodeMock, + exchangeStoreAuthCodeForToken: vi.fn().mockResolvedValue({ + access_token: 'token', + scope: 'write_products', + expires_in: 86400, + associated_user: {id: 42, email: 'test@example.com'}, + }), + resolveExistingScopes: vi.fn().mockResolvedValue({scopes: ['write_products'], authoritative: true}), + presenter, + }, + ) + + const authorizationUrl = new URL(openURL.mock.calls[0]![0]) + expect(authorizationUrl.searchParams.get('scope')).toBe('write_products') + expect(setStoredStoreAppSession).toHaveBeenCalledWith( + expect.objectContaining({ + store: 'shop.myshopify.com', + scopes: ['write_products'], + }), + ) + }) + test('authenticateStoreWithApp shows a manual auth URL when the browser does not open automatically', async () => { const openURL = vi.fn().mockResolvedValue(false) const presenter = { diff --git a/packages/cli/src/cli/services/store/auth.ts b/packages/cli/src/cli/services/store/auth.ts index 0b38fc842d3..27a6eeda04f 100644 --- a/packages/cli/src/cli/services/store/auth.ts +++ b/packages/cli/src/cli/services/store/auth.ts @@ -1,6 +1,10 @@ import {DEFAULT_STORE_AUTH_PORT, STORE_AUTH_APP_CLIENT_ID, STORE_AUTH_CALLBACK_PATH, maskToken, storeAuthRedirectUri} from './auth-config.js' import {retryStoreAuthWithPermanentDomainError} from './auth-recovery.js' -import {setStoredStoreAppSession} from './session.js' +import {getStoredStoreAppSession, setStoredStoreAppSession} from './session.js' +import type {StoredStoreAppSession} from './session.js' +import {loadStoredStoreSession} from './stored-session.js' +import {adminUrl} from '@shopify/cli-kit/node/api/admin' +import {graphqlRequest} from '@shopify/cli-kit/node/api/graphql' import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' import {randomUUID} from '@shopify/cli-kit/node/crypto' import {AbortError} from '@shopify/cli-kit/node/error' @@ -93,6 +97,106 @@ function expandImpliedStoreScopes(scopes: string[]): Set { return expandedScopes } +function mergeRequestedAndStoredScopes(requestedScopes: string[], storedScopes: string[]): string[] { + const mergedScopes = [...storedScopes] + const expandedScopes = expandImpliedStoreScopes(storedScopes) + + for (const scope of requestedScopes) { + if (expandedScopes.has(scope)) continue + + mergedScopes.push(scope) + for (const expandedScope of expandImpliedStoreScopes([scope])) { + expandedScopes.add(expandedScope) + } + } + + return mergedScopes +} + +interface StoreAccessScopesResponse { + currentAppInstallation?: { + accessScopes?: {handle?: string}[] + } +} + +interface ResolvedStoreAuthScopes { + scopes: string[] + authoritative: boolean +} + +function truncateDebugMessage(message: string, length = 300): string { + return message.slice(0, length) +} + +function formatStoreScopeLookupError(error: unknown): string { + if (error && typeof error === 'object' && 'response' in error) { + const response = (error as {response?: {status?: number; errors?: unknown}}).response + const status = response?.status + const details = response?.errors + + if (typeof status === 'number') { + const summary = typeof details === 'string' ? details : JSON.stringify(details) + return truncateDebugMessage(summary ? `HTTP ${status}: ${summary}` : `HTTP ${status}`) + } + } + + return truncateDebugMessage(error instanceof Error ? error.message : String(error)) +} + +const CurrentAppInstallationAccessScopesQuery = `#graphql + query CurrentAppInstallationAccessScopes { + currentAppInstallation { + accessScopes { + handle + } + } + } +` + +async function fetchCurrentStoreAuthScopes(session: StoredStoreAppSession): Promise { + outputDebug( + outputContent`Fetching current app installation scopes for ${outputToken.raw(session.store)} using token ${outputToken.raw(maskToken(session.accessToken))}`, + ) + + const data = await graphqlRequest({ + query: CurrentAppInstallationAccessScopesQuery, + api: 'Admin', + url: adminUrl(session.store, 'unstable'), + token: session.accessToken, + responseOptions: {handleErrors: false}, + }) + + if (!Array.isArray(data.currentAppInstallation?.accessScopes)) { + throw new Error('Shopify did not return currentAppInstallation.accessScopes.') + } + + return data.currentAppInstallation.accessScopes.flatMap((scope) => + typeof scope.handle === 'string' ? [scope.handle] : [], + ) +} + +export async function resolveExistingStoreAuthScopes(store: string): Promise { + const normalizedStore = normalizeStoreFqdn(store) + const storedSession = getStoredStoreAppSession(normalizedStore) + if (!storedSession) return {scopes: [], authoritative: true} + + try { + const usableSession = await loadStoredStoreSession(normalizedStore) + const remoteScopes = await fetchCurrentStoreAuthScopes(usableSession) + + outputDebug( + outputContent`Resolved current remote scopes for ${outputToken.raw(normalizedStore)}: ${outputToken.raw(remoteScopes.join(',') || 'none')}`, + ) + + return {scopes: remoteScopes, authoritative: true} + } catch (error) { + outputDebug( + outputContent`Falling back to locally stored scopes for ${outputToken.raw(normalizedStore)} after remote scope lookup failed: ${outputToken.raw(formatStoreScopeLookupError(error))}`, + ) + return {scopes: storedSession.scopes, authoritative: false} + } +} + function resolveGrantedScopes(tokenResponse: StoreTokenResponse, requestedScopes: string[]): string[] { if (!tokenResponse.scope) { outputDebug(outputContent`Token response did not include scope; falling back to requested scopes`) @@ -363,6 +467,7 @@ interface StoreAuthDependencies { openURL: typeof openURL waitForStoreAuthCode: typeof waitForStoreAuthCode exchangeStoreAuthCodeForToken: typeof exchangeStoreAuthCodeForToken + resolveExistingScopes?: (store: string) => Promise presenter: StoreAuthPresenter } @@ -394,12 +499,12 @@ const defaultStoreAuthDependencies: StoreAuthDependencies = { presenter: defaultStoreAuthPresenter, } -function createPkceBootstrap( - input: StoreAuthInput, - exchangeCodeForToken: typeof exchangeStoreAuthCodeForToken, -): StoreAuthBootstrap { - const store = normalizeStoreFqdn(input.store) - const scopes = parseStoreAuthScopes(input.scopes) +function createPkceBootstrap(options: { + store: string + scopes: string[] + exchangeCodeForToken: typeof exchangeStoreAuthCodeForToken +}): StoreAuthBootstrap { + const {store, scopes, exchangeCodeForToken} = options const port = DEFAULT_STORE_AUTH_PORT const state = randomUUID() const redirectUri = storeAuthRedirectUri(port) @@ -434,10 +539,20 @@ export async function authenticateStoreWithApp( input: StoreAuthInput, dependencies: StoreAuthDependencies = defaultStoreAuthDependencies, ): Promise { - const bootstrap = createPkceBootstrap(input, dependencies.exchangeStoreAuthCodeForToken) - const { - authorization: {store, scopes, redirectUri, authorizationUrl}, - } = bootstrap + const store = normalizeStoreFqdn(input.store) + const requestedScopes = parseStoreAuthScopes(input.scopes) + const existingScopeResolution = await (dependencies.resolveExistingScopes ?? resolveExistingStoreAuthScopes)(store) + const scopes = mergeRequestedAndStoredScopes(requestedScopes, existingScopeResolution.scopes) + const validationScopes = existingScopeResolution.authoritative ? scopes : requestedScopes + + if (existingScopeResolution.scopes.length > 0) { + outputDebug( + outputContent`Merged requested scopes ${outputToken.raw(requestedScopes.join(','))} with existing scopes ${outputToken.raw(existingScopeResolution.scopes.join(','))} for ${outputToken.raw(store)}`, + ) + } + + const bootstrap = createPkceBootstrap({store, scopes, exchangeCodeForToken: dependencies.exchangeStoreAuthCodeForToken}) + const {authorization: {authorizationUrl}} = bootstrap dependencies.presenter.openingBrowser() @@ -467,7 +582,7 @@ export async function authenticateStoreWithApp( // Store the raw scopes returned by Shopify. Validation may treat implied // write_* -> read_* permissions as satisfied, so callers should not assume // session.scopes is an expanded/effective permission set. - scopes: resolveGrantedScopes(tokenResponse, scopes), + scopes: resolveGrantedScopes(tokenResponse, validationScopes), acquiredAt: new Date(now).toISOString(), expiresAt, refreshTokenExpiresAt: tokenResponse.refresh_token_expires_in diff --git a/packages/cli/src/cli/services/store/stored-session.ts b/packages/cli/src/cli/services/store/stored-session.ts new file mode 100644 index 00000000000..f41329b6b0b --- /dev/null +++ b/packages/cli/src/cli/services/store/stored-session.ts @@ -0,0 +1,104 @@ +import {AbortError} from '@shopify/cli-kit/node/error' +import {fetch} from '@shopify/cli-kit/node/http' +import {outputContent, outputDebug, outputToken} from '@shopify/cli-kit/node/output' +import {maskToken, STORE_AUTH_APP_CLIENT_ID} from './auth-config.js' +import {createStoredStoreAuthError, reauthenticateStoreAuthError} from './auth-recovery.js' +import { + clearStoredStoreAppSession, + getStoredStoreAppSession, + isSessionExpired, + setStoredStoreAppSession, +} from './session.js' +import type {StoredStoreAppSession} from './session.js' + +async function refreshStoreToken(session: StoredStoreAppSession): Promise { + if (!session.refreshToken) { + throw reauthenticateStoreAuthError(`No refresh token stored for ${session.store}.`, session.store, session.scopes.join(',')) + } + + const endpoint = `https://${session.store}/admin/oauth/access_token` + + outputDebug( + outputContent`Refreshing expired token for ${outputToken.raw(session.store)} (expired at ${outputToken.raw(session.expiresAt ?? 'unknown')}, refresh_token=${outputToken.raw(maskToken(session.refreshToken))})`, + ) + + const response = await fetch(endpoint, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + client_id: STORE_AUTH_APP_CLIENT_ID, + grant_type: 'refresh_token', + refresh_token: session.refreshToken, + }), + }) + + const body = await response.text() + + if (!response.ok) { + outputDebug( + outputContent`Token refresh failed with HTTP ${outputToken.raw(String(response.status))}: ${outputToken.raw(body.slice(0, 300))}`, + ) + clearStoredStoreAppSession(session.store, session.userId) + throw reauthenticateStoreAuthError( + `Token refresh failed for ${session.store} (HTTP ${response.status}).`, + session.store, + session.scopes.join(','), + ) + } + + let data: {access_token?: string; refresh_token?: string; expires_in?: number; refresh_token_expires_in?: number} + try { + data = JSON.parse(body) + } catch { + clearStoredStoreAppSession(session.store, session.userId) + throw new AbortError('Received an invalid refresh response from Shopify.') + } + + if (!data.access_token) { + clearStoredStoreAppSession(session.store, session.userId) + throw reauthenticateStoreAuthError( + `Token refresh returned an invalid response for ${session.store}.`, + session.store, + session.scopes.join(','), + ) + } + + const now = Date.now() + const expiresAt = data.expires_in ? new Date(now + data.expires_in * 1000).toISOString() : session.expiresAt + + const refreshedSession: StoredStoreAppSession = { + ...session, + accessToken: data.access_token, + refreshToken: data.refresh_token ?? session.refreshToken, + expiresAt, + refreshTokenExpiresAt: data.refresh_token_expires_in + ? new Date(now + data.refresh_token_expires_in * 1000).toISOString() + : session.refreshTokenExpiresAt, + acquiredAt: new Date(now).toISOString(), + } + + outputDebug( + outputContent`Token refresh succeeded for ${outputToken.raw(session.store)}: ${outputToken.raw(maskToken(session.accessToken))} → ${outputToken.raw(maskToken(refreshedSession.accessToken))}, new expiry ${outputToken.raw(expiresAt ?? 'unknown')}`, + ) + + setStoredStoreAppSession(refreshedSession) + return refreshedSession +} + +export async function loadStoredStoreSession(store: string): Promise { + let session = getStoredStoreAppSession(store) + + if (!session) { + throw createStoredStoreAuthError(store) + } + + outputDebug( + outputContent`Loaded stored session for ${outputToken.raw(store)}: token=${outputToken.raw(maskToken(session.accessToken))}, expires=${outputToken.raw(session.expiresAt ?? 'unknown')}`, + ) + + if (isSessionExpired(session)) { + session = await refreshStoreToken(session) + } + + return session +} From b94371e67acb545826f257031ebf7d79499282be Mon Sep 17 00:00:00 2001 From: Ryan Bahan Date: Fri, 3 Apr 2026 14:44:35 -0600 Subject: [PATCH 22/31] Stop Playwright tracing during E2E login to prevent credential exposure Playwright traces capture page.fill() values verbatim, and traces are uploaded as publicly downloadable CI artifacts. This stops tracing before entering credentials and restarts it after login completes, navigating to about:blank before restart so the first snapshot doesn't capture residual form state. Also removes page HTML dump from login error messages since filled input values could appear in the DOM. Ref: shopify/bugbounty#3638393 Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/e2e/helpers/browser-login.ts | 31 +++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/packages/e2e/helpers/browser-login.ts b/packages/e2e/helpers/browser-login.ts index ba637b6d382..8ad7a8defd4 100644 --- a/packages/e2e/helpers/browser-login.ts +++ b/packages/e2e/helpers/browser-login.ts @@ -1,12 +1,27 @@ +/* eslint-disable no-console */ import type {Page} from '@playwright/test' /** * Completes the Shopify OAuth login flow on a Playwright page. */ export async function completeLogin(page: Page, loginUrl: string, email: string, password: string): Promise { - await page.goto(loginUrl) + const tracing = page.context().tracing + // Traces are uploaded as public CI artifacts, so we must stop + // tracing before entering credentials and restart it after login completes. + let tracingWasActive = false try { + await tracing.stop() + tracingWasActive = true + console.log('[e2e] Tracing paused for credential entry') + // eslint-disable-next-line no-catch-all/no-catch-all + } catch { + // tracing.stop() throws if tracing was never started — that's fine + } + + try { + await page.goto(loginUrl) + // Fill in email await page.waitForSelector('input[name="account[email]"], input[type="email"]', {timeout: 60_000}) await page.locator('input[name="account[email]"], input[type="email"]').first().fill(email) @@ -27,12 +42,20 @@ export async function completeLogin(page: Page, loginUrl: string, email: string, // No confirmation page — expected } } catch (error) { - const pageContent = await page.content().catch(() => '(failed to get content)') + // Intentionally omit page HTML from the error — it may contain filled + // credential values in input elements, which would leak into test reports. const pageUrl = page.url() throw new Error( `Login failed at ${pageUrl}\n` + - `Original error: ${error}\n` + - `Page HTML (first 2000 chars): ${pageContent.slice(0, 2000)}`, + `Original error: ${error}`, ) + } finally { + if (tracingWasActive) { + // Navigate to blank page before restarting tracing so the first snapshot + // does not capture any residual credentials on the login form. + await page.goto('about:blank').catch(() => {}) + await tracing.start({screenshots: true, snapshots: true}) + console.log('[e2e] Tracing resumed after credential entry') + } } } From 6cb9a06d746240e239dab4ecc809701272b56bfe Mon Sep 17 00:00:00 2001 From: Ryan Bahan Date: Fri, 3 Apr 2026 14:53:47 -0600 Subject: [PATCH 23/31] Replace fill() with evaluate() to prevent credential capture in traces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous approach (context.tracing.stop()) did not work — Playwright's test runner instruments API calls at a level above context.tracing, so fill() values are logged in traces regardless. Using evaluate() to set input values via the DOM bypasses the Playwright action log entirely, so credentials never appear in trace files or HTML reports. Ref: shopify/bugbounty#3638393 Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/e2e/helpers/browser-login.ts | 49 +++++++++++++-------------- 1 file changed, 23 insertions(+), 26 deletions(-) diff --git a/packages/e2e/helpers/browser-login.ts b/packages/e2e/helpers/browser-login.ts index 8ad7a8defd4..913322bf002 100644 --- a/packages/e2e/helpers/browser-login.ts +++ b/packages/e2e/helpers/browser-login.ts @@ -1,35 +1,40 @@ -/* eslint-disable no-console */ import type {Page} from '@playwright/test' +/** + * Sets an input field's value via the DOM, bypassing Playwright's fill() API. + * + * Security (shopify/bugbounty#3638393): Playwright's test runner logs every + * fill() call — including the literal value — into trace files, which are + * uploaded as publicly downloadable CI artifacts. Using evaluate() to set + * the value directly avoids the Playwright action log entirely. The runner's + * tracing instruments at a level above context.tracing, so context.tracing.stop() + * does NOT prevent the leak. + */ +async function fillSensitive(page: Page, selector: string, value: string): Promise { + const locator = page.locator(selector).first() + await locator.evaluate((el, val) => { + const input = el as HTMLInputElement + input.value = val + input.dispatchEvent(new Event('input', {bubbles: true})) + input.dispatchEvent(new Event('change', {bubbles: true})) + }, value) +} + /** * Completes the Shopify OAuth login flow on a Playwright page. */ export async function completeLogin(page: Page, loginUrl: string, email: string, password: string): Promise { - const tracing = page.context().tracing + await page.goto(loginUrl) - // Traces are uploaded as public CI artifacts, so we must stop - // tracing before entering credentials and restart it after login completes. - let tracingWasActive = false try { - await tracing.stop() - tracingWasActive = true - console.log('[e2e] Tracing paused for credential entry') - // eslint-disable-next-line no-catch-all/no-catch-all - } catch { - // tracing.stop() throws if tracing was never started — that's fine - } - - try { - await page.goto(loginUrl) - // Fill in email await page.waitForSelector('input[name="account[email]"], input[type="email"]', {timeout: 60_000}) - await page.locator('input[name="account[email]"], input[type="email"]').first().fill(email) + await fillSensitive(page, 'input[name="account[email]"], input[type="email"]', email) await page.locator('button[type="submit"]').first().click() // Fill in password await page.waitForSelector('input[name="account[password]"], input[type="password"]', {timeout: 60_000}) - await page.locator('input[name="account[password]"], input[type="password"]').first().fill(password) + await fillSensitive(page, 'input[name="account[password]"], input[type="password"]', password) await page.locator('button[type="submit"]').first().click() // Handle any confirmation/approval page @@ -49,13 +54,5 @@ export async function completeLogin(page: Page, loginUrl: string, email: string, `Login failed at ${pageUrl}\n` + `Original error: ${error}`, ) - } finally { - if (tracingWasActive) { - // Navigate to blank page before restarting tracing so the first snapshot - // does not capture any residual credentials on the login form. - await page.goto('about:blank').catch(() => {}) - await tracing.start({screenshots: true, snapshots: true}) - console.log('[e2e] Tracing resumed after credential entry') - } } } From c3c911cb6b76c6ccf605532c6c1b4ae00dd5f3a7 Mon Sep 17 00:00:00 2001 From: Ryan Bahan Date: Fri, 3 Apr 2026 15:04:30 -0600 Subject: [PATCH 24/31] Fix lint and type errors in browser-login - Fix prettier formatting for error string concatenation - Avoid HTMLInputElement (not in tsconfig types: ["node"]) by casting through unknown Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/e2e/helpers/browser-login.ts | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/packages/e2e/helpers/browser-login.ts b/packages/e2e/helpers/browser-login.ts index 913322bf002..76e9835fb92 100644 --- a/packages/e2e/helpers/browser-login.ts +++ b/packages/e2e/helpers/browser-login.ts @@ -12,11 +12,10 @@ import type {Page} from '@playwright/test' */ async function fillSensitive(page: Page, selector: string, value: string): Promise { const locator = page.locator(selector).first() - await locator.evaluate((el, val) => { - const input = el as HTMLInputElement - input.value = val - input.dispatchEvent(new Event('input', {bubbles: true})) - input.dispatchEvent(new Event('change', {bubbles: true})) + await locator.evaluate((el: Element, val: string) => { + ;(el as unknown as {value: string}).value = val + el.dispatchEvent(new Event('input', {bubbles: true})) + el.dispatchEvent(new Event('change', {bubbles: true})) }, value) } @@ -50,9 +49,6 @@ export async function completeLogin(page: Page, loginUrl: string, email: string, // Intentionally omit page HTML from the error — it may contain filled // credential values in input elements, which would leak into test reports. const pageUrl = page.url() - throw new Error( - `Login failed at ${pageUrl}\n` + - `Original error: ${error}`, - ) + throw new Error(`Login failed at ${pageUrl}\n` + `Original error: ${error}`) } } From dbf8127fde5b4118733a638baac3e30ce2c7d676 Mon Sep 17 00:00:00 2001 From: Ryan Bahan Date: Fri, 3 Apr 2026 15:08:15 -0600 Subject: [PATCH 25/31] Fix remaining type and lint errors - Drop explicit Element type annotation (not in node-only tsconfig) - Inline string concatenation to fix no-useless-concat Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/e2e/helpers/browser-login.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/e2e/helpers/browser-login.ts b/packages/e2e/helpers/browser-login.ts index 76e9835fb92..8d34417c8b4 100644 --- a/packages/e2e/helpers/browser-login.ts +++ b/packages/e2e/helpers/browser-login.ts @@ -12,7 +12,7 @@ import type {Page} from '@playwright/test' */ async function fillSensitive(page: Page, selector: string, value: string): Promise { const locator = page.locator(selector).first() - await locator.evaluate((el: Element, val: string) => { + await locator.evaluate((el, val) => { ;(el as unknown as {value: string}).value = val el.dispatchEvent(new Event('input', {bubbles: true})) el.dispatchEvent(new Event('change', {bubbles: true})) @@ -49,6 +49,6 @@ export async function completeLogin(page: Page, loginUrl: string, email: string, // Intentionally omit page HTML from the error — it may contain filled // credential values in input elements, which would leak into test reports. const pageUrl = page.url() - throw new Error(`Login failed at ${pageUrl}\n` + `Original error: ${error}`) + throw new Error(`Login failed at ${pageUrl}\nOriginal error: ${error}`) } } From bdf5e4493edaf332fb51e220cc42c78982d24eb3 Mon Sep 17 00:00:00 2001 From: Ryan Bahan Date: Fri, 3 Apr 2026 15:38:23 -0600 Subject: [PATCH 26/31] Clear login page on auth failure to prevent credential capture Navigate to about:blank in the catch block before rethrowing so that failure artifacts (screenshots, trace snapshots) do not capture the login form with credentials still populated. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/e2e/helpers/browser-login.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/e2e/helpers/browser-login.ts b/packages/e2e/helpers/browser-login.ts index 8d34417c8b4..da5a4766c50 100644 --- a/packages/e2e/helpers/browser-login.ts +++ b/packages/e2e/helpers/browser-login.ts @@ -46,9 +46,10 @@ export async function completeLogin(page: Page, loginUrl: string, email: string, // No confirmation page — expected } } catch (error) { - // Intentionally omit page HTML from the error — it may contain filled - // credential values in input elements, which would leak into test reports. const pageUrl = page.url() + // Clear the page so failure artifacts (screenshots, trace snapshots) do + // not capture the login form with credentials still populated. + await page.goto('about:blank').catch(() => {}) throw new Error(`Login failed at ${pageUrl}\nOriginal error: ${error}`) } } From 20534ecf448126a59605e5ea549ff188594e05ae Mon Sep 17 00:00:00 2001 From: Ryan Bahan Date: Mon, 6 Apr 2026 09:44:02 -0600 Subject: [PATCH 27/31] add monorail recording --- .../cli/commands/app/config/validate.test.ts | 95 +++++++++++++++++++ .../src/cli/commands/app/config/validate.ts | 18 ++++ .../app/src/cli/services/validate.test.ts | 37 ++++++++ packages/app/src/cli/services/validate.ts | 14 +++ .../cli-kit/src/private/node/analytics.ts | 6 ++ packages/cli-kit/src/public/node/monorail.ts | 4 + 6 files changed, 174 insertions(+) diff --git a/packages/app/src/cli/commands/app/config/validate.test.ts b/packages/app/src/cli/commands/app/config/validate.test.ts index 784c89a1031..98fdd12ce00 100644 --- a/packages/app/src/cli/commands/app/config/validate.test.ts +++ b/packages/app/src/cli/commands/app/config/validate.test.ts @@ -5,7 +5,9 @@ import {testAppLinked} from '../../../models/app/app.test-data.js' import {Project} from '../../../models/project/project.js' import {selectActiveConfig} from '../../../models/project/active-config.js' import {errorsForConfig} from '../../../models/project/config-selection.js' +import metadata from '../../../metadata.js' import {outputResult} from '@shopify/cli-kit/node/output' +import {AbortError} from '@shopify/cli-kit/node/error' import {TomlFile} from '@shopify/cli-kit/node/toml/toml-file' import {describe, expect, test, vi} from 'vitest' @@ -14,12 +16,19 @@ vi.mock('../../../services/validate.js') vi.mock('../../../models/project/project.js') vi.mock('../../../models/project/active-config.js') vi.mock('../../../models/project/config-selection.js') +vi.mock('../../../metadata.js', () => ({default: {addPublicMetadata: vi.fn()}})) vi.mock('@shopify/cli-kit/node/output', async (importOriginal) => { const actual = await importOriginal() return {...actual, outputResult: vi.fn()} }) vi.mock('@shopify/cli-kit/node/ui') +async function expectValidationMetadataCalls(...expectedMetadata: Record[]) { + const metadataCalls = vi.mocked(metadata.addPublicMetadata).mock.calls.map(([getMetadata]) => getMetadata) + expect(metadataCalls).toHaveLength(expectedMetadata.length) + await expect(Promise.all(metadataCalls.map((getMetadata) => getMetadata()))).resolves.toEqual(expectedMetadata) +} + function mockHealthyProject() { vi.mocked(Project.load).mockResolvedValue({errors: []} as unknown as Project) vi.mocked(selectActiveConfig).mockResolvedValue({file: new TomlFile('shopify.app.toml', {})} as any) @@ -36,6 +45,7 @@ describe('app config validate command', () => { await Validate.run([], import.meta.url) expect(validateApp).toHaveBeenCalledWith(app, {json: false}) + await expectValidationMetadataCalls({cmd_app_validate_json: false}) }) test('calls validateApp with json: true when --json flag is passed', async () => { @@ -47,6 +57,7 @@ describe('app config validate command', () => { await Validate.run(['--json'], import.meta.url) expect(validateApp).toHaveBeenCalledWith(app, {json: true}) + await expectValidationMetadataCalls({cmd_app_validate_json: true}) }) test('calls validateApp with json: true when -j flag is passed', async () => { @@ -58,6 +69,7 @@ describe('app config validate command', () => { await Validate.run(['-j'], import.meta.url) expect(validateApp).toHaveBeenCalledWith(app, {json: true}) + await expectValidationMetadataCalls({cmd_app_validate_json: true}) }) test('outputs JSON issues when active config has TOML parse errors', async () => { @@ -71,5 +83,88 @@ describe('app config validate command', () => { expect(outputResult).toHaveBeenCalledWith(expect.stringContaining('"valid": false')) expect(linkedAppContext).not.toHaveBeenCalled() + await expectValidationMetadataCalls( + {cmd_app_validate_json: true}, + { + cmd_app_validate_valid: false, + cmd_app_validate_issue_count: 1, + cmd_app_validate_file_count: 1, + }, + ) + }) + + test('records failure metadata for config errors in non-json mode', async () => { + vi.mocked(Project.load).mockResolvedValue({errors: []} as unknown as Project) + vi.mocked(selectActiveConfig).mockResolvedValue({file: new TomlFile('shopify.app.toml', {})} as any) + vi.mocked(errorsForConfig).mockReturnValue([ + {path: '/app/shopify.app.toml', message: 'Missing required field'} as any, + {path: '/app/shopify.app.toml', message: 'Invalid value'} as any, + ]) + + await expect(Validate.run([], import.meta.url)).rejects.toThrow() + + expect(linkedAppContext).not.toHaveBeenCalled() + await expectValidationMetadataCalls( + {cmd_app_validate_json: false}, + { + cmd_app_validate_valid: false, + cmd_app_validate_issue_count: 2, + cmd_app_validate_file_count: 1, + }, + ) + }) + + test('records failure metadata when Project.load fails with --json', async () => { + vi.mocked(Project.load).mockRejectedValue(new AbortError('Could not find app configuration')) + + await expect(Validate.run(['--json'], import.meta.url)).rejects.toThrow() + + expect(outputResult).toHaveBeenCalledWith(expect.stringContaining('"valid": false')) + expect(selectActiveConfig).not.toHaveBeenCalled() + await expectValidationMetadataCalls( + {cmd_app_validate_json: true}, + { + cmd_app_validate_valid: false, + cmd_app_validate_issue_count: 1, + cmd_app_validate_file_count: 1, + }, + ) + }) + + test('records failure metadata when selectActiveConfig fails with --json', async () => { + vi.mocked(Project.load).mockResolvedValue({errors: []} as unknown as Project) + vi.mocked(selectActiveConfig).mockRejectedValue(new AbortError('No config found')) + + await expect(Validate.run(['--json'], import.meta.url)).rejects.toThrow() + + expect(outputResult).toHaveBeenCalledWith(expect.stringContaining('"valid": false')) + expect(linkedAppContext).not.toHaveBeenCalled() + await expectValidationMetadataCalls( + {cmd_app_validate_json: true}, + { + cmd_app_validate_valid: false, + cmd_app_validate_issue_count: 1, + cmd_app_validate_file_count: 1, + }, + ) + }) + + test('records failure metadata when linkedAppContext throws a validation error with --json', async () => { + vi.mocked(Project.load).mockResolvedValue({errors: []} as unknown as Project) + vi.mocked(selectActiveConfig).mockResolvedValue({file: new TomlFile('shopify.app.toml', {})} as any) + vi.mocked(errorsForConfig).mockReturnValue([]) + vi.mocked(linkedAppContext).mockRejectedValue(new AbortError('Validation errors in /app/shopify.app.toml')) + + await expect(Validate.run(['--json'], import.meta.url)).rejects.toThrow() + + expect(outputResult).toHaveBeenCalledWith(expect.stringContaining('"valid": false')) + await expectValidationMetadataCalls( + {cmd_app_validate_json: true}, + { + cmd_app_validate_valid: false, + cmd_app_validate_issue_count: 1, + cmd_app_validate_file_count: 1, + }, + ) }) }) diff --git a/packages/app/src/cli/commands/app/config/validate.ts b/packages/app/src/cli/commands/app/config/validate.ts index dd9efc4958a..4da9ddc48ba 100644 --- a/packages/app/src/cli/commands/app/config/validate.ts +++ b/packages/app/src/cli/commands/app/config/validate.ts @@ -5,11 +5,20 @@ import {linkedAppContext} from '../../../services/app-context.js' import {selectActiveConfig} from '../../../models/project/active-config.js' import {errorsForConfig} from '../../../models/project/config-selection.js' import {Project} from '../../../models/project/project.js' +import metadata from '../../../metadata.js' import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli' import {AbortError, AbortSilentError} from '@shopify/cli-kit/node/error' import {outputResult, stringifyMessage, unstyled} from '@shopify/cli-kit/node/output' import {renderError} from '@shopify/cli-kit/node/ui' +async function recordValidationFailure(issueCount: number, fileCount: number) { + await metadata.addPublicMetadata(() => ({ + cmd_app_validate_valid: false, + cmd_app_validate_issue_count: issueCount, + cmd_app_validate_file_count: fileCount, + })) +} + export default class Validate extends AppLinkedCommand { static summary = 'Validate your app configuration and extensions.' @@ -26,12 +35,17 @@ export default class Validate extends AppLinkedCommand { public async run(): Promise { const {flags} = await this.parse(Validate) + await metadata.addPublicMetadata(() => ({ + cmd_app_validate_json: flags.json, + })) + // Stage 1: Load project let project: Project try { project = await Project.load(flags.path) } catch (err) { if (err instanceof AbortError && flags.json) { + await recordValidationFailure(1, 1) const message = unstyled(stringifyMessage(err.message)).trim() outputResult(JSON.stringify({valid: false, issues: [{message}]}, null, 2)) throw new AbortSilentError() @@ -45,6 +59,7 @@ export default class Validate extends AppLinkedCommand { activeConfig = await selectActiveConfig(project, flags.config) } catch (err) { if (err instanceof AbortError && flags.json) { + await recordValidationFailure(1, 1) const message = unstyled(stringifyMessage(err.message)).trim() outputResult(JSON.stringify({valid: false, issues: [{message}]}, null, 2)) throw new AbortSilentError() @@ -55,6 +70,8 @@ export default class Validate extends AppLinkedCommand { const configErrors = errorsForConfig(project, activeConfig.file) if (configErrors.length > 0) { const issues = configErrors.map((err) => ({file: err.path, message: err.message})) + const fileCount = new Set(configErrors.map((err) => err.path)).size + await recordValidationFailure(issues.length, fileCount) if (flags.json) { outputResult(JSON.stringify({valid: false, issues}, null, 2)) throw new AbortSilentError() @@ -83,6 +100,7 @@ export default class Validate extends AppLinkedCommand { const message = err instanceof AbortError ? unstyled(stringifyMessage(err.message)).trim() : '' const isValidationError = message.startsWith('Validation errors in ') if (isValidationError && flags.json) { + await recordValidationFailure(1, 1) outputResult(JSON.stringify({valid: false, issues: [{message}]}, null, 2)) throw new AbortSilentError() } diff --git a/packages/app/src/cli/services/validate.test.ts b/packages/app/src/cli/services/validate.test.ts index f6c16446640..f22f543286f 100644 --- a/packages/app/src/cli/services/validate.test.ts +++ b/packages/app/src/cli/services/validate.test.ts @@ -1,11 +1,13 @@ import {validateApp} from './validate.js' import {testAppLinked} from '../models/app/app.test-data.js' import {AppErrors, formatConfigurationError} from '../models/app/loader.js' +import metadata from '../metadata.js' import {describe, expect, test, vi} from 'vitest' import {outputResult} from '@shopify/cli-kit/node/output' import {renderError, renderSuccess} from '@shopify/cli-kit/node/ui' import {AbortSilentError} from '@shopify/cli-kit/node/error' +vi.mock('../metadata.js', () => ({default: {addPublicMetadata: vi.fn()}})) vi.mock('@shopify/cli-kit/node/output', async (importOriginal) => { const actual = await importOriginal() return { @@ -15,6 +17,16 @@ vi.mock('@shopify/cli-kit/node/output', async (importOriginal) => { }) vi.mock('@shopify/cli-kit/node/ui') +async function expectLastValidationMetadata(expected: { + cmd_app_validate_valid: boolean + cmd_app_validate_issue_count: number + cmd_app_validate_file_count: number +}) { + const getMetadata = vi.mocked(metadata.addPublicMetadata).mock.calls.at(-1)?.[0] + expect(getMetadata).toBeDefined() + await expect(Promise.resolve(getMetadata!())).resolves.toEqual(expected) +} + describe('formatConfigurationError', () => { test('returns plain message when no path', () => { expect(formatConfigurationError({file: 'foo.toml', message: 'something broke'})).toBe('something broke') @@ -34,6 +46,11 @@ describe('validateApp', () => { expect(renderSuccess).toHaveBeenCalledWith({headline: 'App configuration is valid.'}) expect(renderError).not.toHaveBeenCalled() expect(outputResult).not.toHaveBeenCalled() + await expectLastValidationMetadata({ + cmd_app_validate_valid: true, + cmd_app_validate_issue_count: 0, + cmd_app_validate_file_count: 0, + }) }) test('outputs json success when --json is enabled and there are no errors', async () => { @@ -42,6 +59,11 @@ describe('validateApp', () => { expect(outputResult).toHaveBeenCalledWith(JSON.stringify({valid: true, issues: []}, null, 2)) expect(renderSuccess).not.toHaveBeenCalled() expect(renderError).not.toHaveBeenCalled() + await expectLastValidationMetadata({ + cmd_app_validate_valid: true, + cmd_app_validate_issue_count: 0, + cmd_app_validate_file_count: 0, + }) }) test('renders errors and throws when there are validation errors', async () => { @@ -58,6 +80,11 @@ describe('validateApp', () => { }) expect(renderSuccess).not.toHaveBeenCalled() expect(outputResult).not.toHaveBeenCalled() + await expectLastValidationMetadata({ + cmd_app_validate_valid: false, + cmd_app_validate_issue_count: 2, + cmd_app_validate_file_count: 2, + }) }) test('outputs structured json issues when --json is enabled and there are validation errors', async () => { @@ -83,6 +110,11 @@ describe('validateApp', () => { ) expect(renderError).not.toHaveBeenCalled() expect(renderSuccess).not.toHaveBeenCalled() + await expectLastValidationMetadata({ + cmd_app_validate_valid: false, + cmd_app_validate_issue_count: 2, + cmd_app_validate_file_count: 2, + }) }) test('renders success when errors object exists but is empty', async () => { @@ -111,5 +143,10 @@ describe('validateApp', () => { 2, ), ) + await expectLastValidationMetadata({ + cmd_app_validate_valid: false, + cmd_app_validate_issue_count: 1, + cmd_app_validate_file_count: 1, + }) }) }) diff --git a/packages/app/src/cli/services/validate.ts b/packages/app/src/cli/services/validate.ts index 00b634c90af..7655c369a5b 100644 --- a/packages/app/src/cli/services/validate.ts +++ b/packages/app/src/cli/services/validate.ts @@ -1,5 +1,6 @@ import {AppLinkedInterface} from '../models/app/app.js' import {formatConfigurationError} from '../models/app/loader.js' +import metadata from '../metadata.js' import {outputResult} from '@shopify/cli-kit/node/output' import {renderError, renderSuccess} from '@shopify/cli-kit/node/ui' import {AbortSilentError} from '@shopify/cli-kit/node/error' @@ -8,10 +9,22 @@ interface ValidateAppOptions { json: boolean } +async function recordValidationMetadata(valid: boolean, errors: {file: string}[]) { + const fileCount = new Set(errors.map((error) => error.file)).size + + await metadata.addPublicMetadata(() => ({ + cmd_app_validate_valid: valid, + cmd_app_validate_issue_count: errors.length, + cmd_app_validate_file_count: fileCount, + })) +} + export async function validateApp(app: AppLinkedInterface, options: ValidateAppOptions = {json: false}): Promise { const appErrors = app.errors if (!appErrors || appErrors.isEmpty()) { + await recordValidationMetadata(true, []) + if (options.json) { outputResult(JSON.stringify({valid: true, issues: []}, null, 2)) return @@ -22,6 +35,7 @@ export async function validateApp(app: AppLinkedInterface, options: ValidateAppO } const errors = appErrors.getErrors() + await recordValidationMetadata(false, errors) if (options.json) { const issues = errors.map(({file, message, path, code}) => ({file, message, path, code})) diff --git a/packages/cli-kit/src/private/node/analytics.ts b/packages/cli-kit/src/private/node/analytics.ts index 3034d8c1eff..0306936343c 100644 --- a/packages/cli-kit/src/private/node/analytics.ts +++ b/packages/cli-kit/src/private/node/analytics.ts @@ -103,6 +103,12 @@ export async function getSensitiveEnvironmentData(config: Interfaces.Config) { } function getShopifyEnvironmentVariables() { + // Agent callers can identify themselves today via SHOPIFY_* environment + // variables. The current contract is intentionally lightweight and is kept in + // the sensitive payload until we prove which dimensions deserve first-class + // Monorail fields, e.g. SHOPIFY_CLI_AGENT, SHOPIFY_CLI_AGENT_VERSION, + // SHOPIFY_CLI_AGENT_RUN_ID, SHOPIFY_CLI_AGENT_SESSION_ID, and + // SHOPIFY_CLI_AGENT_PROVIDER. return Object.fromEntries(Object.entries(process.env).filter(([key]) => key.startsWith('SHOPIFY_'))) } diff --git a/packages/cli-kit/src/public/node/monorail.ts b/packages/cli-kit/src/public/node/monorail.ts index b687ec9fee4..dc6130bc372 100644 --- a/packages/cli-kit/src/public/node/monorail.ts +++ b/packages/cli-kit/src/public/node/monorail.ts @@ -88,6 +88,10 @@ export interface Schemas { cmd_app_linked_config_uses_cli_managed_urls?: Optional cmd_app_warning_api_key_deprecation_displayed?: Optional cmd_app_deployment_mode?: Optional + cmd_app_validate_json?: Optional + cmd_app_validate_valid?: Optional + cmd_app_validate_issue_count?: Optional + cmd_app_validate_file_count?: Optional // Dev related commands cmd_dev_tunnel_type?: Optional From 318f36ea9935b940cf0e28835c395e4be2d8244d Mon Sep 17 00:00:00 2001 From: Donald Merand Date: Fri, 3 Apr 2026 11:27:32 -0400 Subject: [PATCH 28/31] Store auth restructure --- .../cli/src/cli/commands/store/auth.test.ts | 4 +- packages/cli/src/cli/commands/store/auth.ts | 2 +- .../store/admin-graphql-context.test.ts | 30 +- .../services/store/admin-graphql-context.ts | 8 +- .../store/admin-graphql-transport.test.ts | 4 +- .../services/store/admin-graphql-transport.ts | 4 +- .../cli/src/cli/services/store/auth.test.ts | 902 ------------------ packages/cli/src/cli/services/store/auth.ts | 607 ------------ .../cli/services/store/auth/callback.test.ts | 189 ++++ .../src/cli/services/store/auth/callback.ts | 185 ++++ .../store/{auth-config.ts => auth/config.ts} | 0 .../store/auth/existing-scopes.test.ts | 164 ++++ .../services/store/auth/existing-scopes.ts | 54 ++ .../src/cli/services/store/auth/index.test.ts | 447 +++++++++ .../cli/src/cli/services/store/auth/index.ts | 134 +++ .../src/cli/services/store/auth/pkce.test.ts | 45 + .../cli/src/cli/services/store/auth/pkce.ts | 90 ++ .../{auth-recovery.ts => auth/recovery.ts} | 0 .../cli/services/store/auth/scopes.test.ts | 54 ++ .../cli/src/cli/services/store/auth/scopes.ts | 70 ++ .../store/auth/session-lifecycle.test.ts | 179 ++++ .../services/store/auth/session-lifecycle.ts | 105 ++ .../session-store.test.ts} | 95 +- .../cli/services/store/auth/session-store.ts | 192 ++++ .../services/store/auth/token-client.test.ts | 167 ++++ .../cli/services/store/auth/token-client.ts | 171 ++++ .../src/cli/services/store/execute.test.ts | 14 +- .../cli/src/cli/services/store/session.ts | 125 --- .../src/cli/services/store/stored-session.ts | 104 -- 29 files changed, 2344 insertions(+), 1801 deletions(-) delete mode 100644 packages/cli/src/cli/services/store/auth.test.ts delete mode 100644 packages/cli/src/cli/services/store/auth.ts create mode 100644 packages/cli/src/cli/services/store/auth/callback.test.ts create mode 100644 packages/cli/src/cli/services/store/auth/callback.ts rename packages/cli/src/cli/services/store/{auth-config.ts => auth/config.ts} (100%) create mode 100644 packages/cli/src/cli/services/store/auth/existing-scopes.test.ts create mode 100644 packages/cli/src/cli/services/store/auth/existing-scopes.ts create mode 100644 packages/cli/src/cli/services/store/auth/index.test.ts create mode 100644 packages/cli/src/cli/services/store/auth/index.ts create mode 100644 packages/cli/src/cli/services/store/auth/pkce.test.ts create mode 100644 packages/cli/src/cli/services/store/auth/pkce.ts rename packages/cli/src/cli/services/store/{auth-recovery.ts => auth/recovery.ts} (100%) create mode 100644 packages/cli/src/cli/services/store/auth/scopes.test.ts create mode 100644 packages/cli/src/cli/services/store/auth/scopes.ts create mode 100644 packages/cli/src/cli/services/store/auth/session-lifecycle.test.ts create mode 100644 packages/cli/src/cli/services/store/auth/session-lifecycle.ts rename packages/cli/src/cli/services/store/{session.test.ts => auth/session-store.test.ts} (50%) create mode 100644 packages/cli/src/cli/services/store/auth/session-store.ts create mode 100644 packages/cli/src/cli/services/store/auth/token-client.test.ts create mode 100644 packages/cli/src/cli/services/store/auth/token-client.ts delete mode 100644 packages/cli/src/cli/services/store/session.ts delete mode 100644 packages/cli/src/cli/services/store/stored-session.ts diff --git a/packages/cli/src/cli/commands/store/auth.test.ts b/packages/cli/src/cli/commands/store/auth.test.ts index d2bc2c9a33d..21701dff48b 100644 --- a/packages/cli/src/cli/commands/store/auth.test.ts +++ b/packages/cli/src/cli/commands/store/auth.test.ts @@ -1,8 +1,8 @@ import {describe, test, expect, vi, beforeEach} from 'vitest' import StoreAuth from './auth.js' -import {authenticateStoreWithApp} from '../../services/store/auth.js' +import {authenticateStoreWithApp} from '../../services/store/auth/index.js' -vi.mock('../../services/store/auth.js') +vi.mock('../../services/store/auth/index.js') describe('store auth command', () => { beforeEach(() => { diff --git a/packages/cli/src/cli/commands/store/auth.ts b/packages/cli/src/cli/commands/store/auth.ts index 1fe48e3f3dc..6b46ece1257 100644 --- a/packages/cli/src/cli/commands/store/auth.ts +++ b/packages/cli/src/cli/commands/store/auth.ts @@ -2,7 +2,7 @@ import Command from '@shopify/cli-kit/node/base-command' import {globalFlags} from '@shopify/cli-kit/node/cli' import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' import {Flags} from '@oclif/core' -import {authenticateStoreWithApp} from '../../services/store/auth.js' +import {authenticateStoreWithApp} from '../../services/store/auth/index.js' export default class StoreAuth extends Command { static summary = 'Authenticate an app against a store for store commands.' diff --git a/packages/cli/src/cli/services/store/admin-graphql-context.test.ts b/packages/cli/src/cli/services/store/admin-graphql-context.test.ts index 137c3f2761f..83371130436 100644 --- a/packages/cli/src/cli/services/store/admin-graphql-context.test.ts +++ b/packages/cli/src/cli/services/store/admin-graphql-context.test.ts @@ -4,14 +4,13 @@ import {AbortError} from '@shopify/cli-kit/node/error' import {fetch} from '@shopify/cli-kit/node/http' import { clearStoredStoreAppSession, - getStoredStoreAppSession, - isSessionExpired, + getCurrentStoredStoreAppSession, setStoredStoreAppSession, -} from './session.js' -import {STORE_AUTH_APP_CLIENT_ID} from './auth-config.js' +} from './auth/session-store.js' +import {STORE_AUTH_APP_CLIENT_ID} from './auth/config.js' import {prepareAdminStoreGraphQLContext} from './admin-graphql-context.js' -vi.mock('./session.js') +vi.mock('./auth/session-store.js') vi.mock('@shopify/cli-kit/node/http') vi.mock('@shopify/cli-kit/node/api/admin', async () => { const actual = await vi.importActual('@shopify/cli-kit/node/api/admin') @@ -23,6 +22,9 @@ vi.mock('@shopify/cli-kit/node/api/admin', async () => { describe('prepareAdminStoreGraphQLContext', () => { const store = 'shop.myshopify.com' + const futureExpiry = new Date(Date.now() + 60 * 60 * 1000).toISOString() + const expiredAt = new Date(Date.now() - 60 * 1000).toISOString() + const storedSession = { store, clientId: STORE_AUTH_APP_CLIENT_ID, @@ -31,13 +33,12 @@ describe('prepareAdminStoreGraphQLContext', () => { refreshToken: 'refresh-token', scopes: ['read_products'], acquiredAt: '2026-03-27T00:00:00.000Z', - expiresAt: '2026-03-27T01:00:00.000Z', + expiresAt: futureExpiry, } beforeEach(() => { vi.clearAllMocks() - vi.mocked(getStoredStoreAppSession).mockReturnValue(storedSession) - vi.mocked(isSessionExpired).mockReturnValue(false) + vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue(storedSession) vi.mocked(fetchApiVersions).mockResolvedValue([ {handle: '2025-10', supported: true}, {handle: '2025-07', supported: true}, @@ -59,7 +60,7 @@ describe('prepareAdminStoreGraphQLContext', () => { }) test('refreshes expired sessions before resolving the API version', async () => { - vi.mocked(isSessionExpired).mockReturnValue(true) + vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue({...storedSession, expiresAt: expiredAt}) vi.mocked(fetch).mockResolvedValue({ ok: true, text: vi.fn().mockResolvedValue( @@ -98,7 +99,7 @@ describe('prepareAdminStoreGraphQLContext', () => { }) test('throws when no stored auth exists', async () => { - vi.mocked(getStoredStoreAppSession).mockReturnValue(undefined) + vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue(undefined) await expect(prepareAdminStoreGraphQLContext({store})).rejects.toMatchObject({ message: `No stored app authentication found for ${store}.`, @@ -108,7 +109,7 @@ describe('prepareAdminStoreGraphQLContext', () => { }) test('clears stored auth when token refresh fails', async () => { - vi.mocked(isSessionExpired).mockReturnValue(true) + vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue({...storedSession, expiresAt: expiredAt}) vi.mocked(fetch).mockResolvedValue({ ok: false, status: 401, @@ -124,8 +125,7 @@ describe('prepareAdminStoreGraphQLContext', () => { }) test('throws when an expired session cannot be refreshed because no refresh token is stored', async () => { - vi.mocked(isSessionExpired).mockReturnValue(true) - vi.mocked(getStoredStoreAppSession).mockReturnValue({...storedSession, refreshToken: undefined}) + vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue({...storedSession, refreshToken: undefined, expiresAt: expiredAt}) await expect(prepareAdminStoreGraphQLContext({store})).rejects.toMatchObject({ message: `No refresh token stored for ${store}.`, @@ -136,7 +136,7 @@ describe('prepareAdminStoreGraphQLContext', () => { }) test('clears only the current stored auth when token refresh returns an invalid response body', async () => { - vi.mocked(isSessionExpired).mockReturnValue(true) + vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue({...storedSession, expiresAt: expiredAt}) vi.mocked(fetch).mockResolvedValue({ ok: true, text: vi.fn().mockResolvedValue(JSON.stringify({refresh_token: 'fresh-refresh-token'})), @@ -151,7 +151,7 @@ describe('prepareAdminStoreGraphQLContext', () => { }) test('clears only the current stored auth when token refresh returns malformed JSON', async () => { - vi.mocked(isSessionExpired).mockReturnValue(true) + vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue({...storedSession, expiresAt: expiredAt}) vi.mocked(fetch).mockResolvedValue({ ok: true, text: vi.fn().mockResolvedValue('not-json'), diff --git a/packages/cli/src/cli/services/store/admin-graphql-context.ts b/packages/cli/src/cli/services/store/admin-graphql-context.ts index a0c5f6a6fe6..e0a493fbc09 100644 --- a/packages/cli/src/cli/services/store/admin-graphql-context.ts +++ b/packages/cli/src/cli/services/store/admin-graphql-context.ts @@ -2,10 +2,10 @@ import {fetchApiVersions} from '@shopify/cli-kit/node/api/admin' import {AbortError} from '@shopify/cli-kit/node/error' import {outputContent, outputDebug, outputToken} from '@shopify/cli-kit/node/output' import {AdminSession} from '@shopify/cli-kit/node/session' -import {reauthenticateStoreAuthError} from './auth-recovery.js' -import {clearStoredStoreAppSession} from './session.js' -import type {StoredStoreAppSession} from './session.js' -import {loadStoredStoreSession} from './stored-session.js' +import {reauthenticateStoreAuthError} from './auth/recovery.js' +import {clearStoredStoreAppSession} from './auth/session-store.js' +import type {StoredStoreAppSession} from './auth/session-store.js' +import {loadStoredStoreSession} from './auth/session-lifecycle.js' export interface AdminStoreGraphQLContext { adminSession: AdminSession diff --git a/packages/cli/src/cli/services/store/admin-graphql-transport.test.ts b/packages/cli/src/cli/services/store/admin-graphql-transport.test.ts index a0d03fc1051..3a994321fce 100644 --- a/packages/cli/src/cli/services/store/admin-graphql-transport.test.ts +++ b/packages/cli/src/cli/services/store/admin-graphql-transport.test.ts @@ -2,11 +2,11 @@ import {beforeEach, describe, expect, test, vi} from 'vitest' import {adminUrl} from '@shopify/cli-kit/node/api/admin' import {graphqlRequest} from '@shopify/cli-kit/node/api/graphql' import {renderSingleTask} from '@shopify/cli-kit/node/ui' -import {clearStoredStoreAppSession} from './session.js' +import {clearStoredStoreAppSession} from './auth/session-store.js' import {prepareStoreExecuteRequest} from './execute-request.js' import {runAdminStoreGraphQLOperation} from './admin-graphql-transport.js' -vi.mock('./session.js') +vi.mock('./auth/session-store.js') vi.mock('@shopify/cli-kit/node/api/graphql') vi.mock('@shopify/cli-kit/node/ui') vi.mock('@shopify/cli-kit/node/api/admin', async () => { diff --git a/packages/cli/src/cli/services/store/admin-graphql-transport.ts b/packages/cli/src/cli/services/store/admin-graphql-transport.ts index 1a104ee160e..6c1a3f8a5db 100644 --- a/packages/cli/src/cli/services/store/admin-graphql-transport.ts +++ b/packages/cli/src/cli/services/store/admin-graphql-transport.ts @@ -4,9 +4,9 @@ import {AbortError} from '@shopify/cli-kit/node/error' import {outputContent} from '@shopify/cli-kit/node/output' import {AdminSession} from '@shopify/cli-kit/node/session' import {renderSingleTask} from '@shopify/cli-kit/node/ui' -import {reauthenticateStoreAuthError} from './auth-recovery.js' +import {reauthenticateStoreAuthError} from './auth/recovery.js' import {PreparedStoreExecuteRequest} from './execute-request.js' -import {clearStoredStoreAppSession} from './session.js' +import {clearStoredStoreAppSession} from './auth/session-store.js' function isGraphQLClientError(error: unknown): error is {response: {errors?: unknown; status?: number}} { if (!error || typeof error !== 'object' || !('response' in error)) return false diff --git a/packages/cli/src/cli/services/store/auth.test.ts b/packages/cli/src/cli/services/store/auth.test.ts deleted file mode 100644 index 7dba9f6d9ca..00000000000 --- a/packages/cli/src/cli/services/store/auth.test.ts +++ /dev/null @@ -1,902 +0,0 @@ -import {createServer} from 'http' -import {describe, test, expect, vi, beforeEach, afterEach} from 'vitest' -import { - authenticateStoreWithApp, - buildStoreAuthUrl, - parseStoreAuthScopes, - generateCodeVerifier, - computeCodeChallenge, - exchangeStoreAuthCodeForToken, - resolveExistingStoreAuthScopes, - waitForStoreAuthCode, -} from './auth.js' -import {loadStoredStoreSession} from './stored-session.js' -import {getStoredStoreAppSession, setStoredStoreAppSession} from './session.js' -import {STORE_AUTH_APP_CLIENT_ID} from './auth-config.js' -import {adminUrl} from '@shopify/cli-kit/node/api/admin' -import {graphqlRequest} from '@shopify/cli-kit/node/api/graphql' -import {fetch} from '@shopify/cli-kit/node/http' -import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' - -vi.mock('./session.js') -vi.mock('./stored-session.js', () => ({loadStoredStoreSession: vi.fn()})) -vi.mock('@shopify/cli-kit/node/http') -vi.mock('@shopify/cli-kit/node/api/graphql') -vi.mock('@shopify/cli-kit/node/api/admin', async () => { - const actual = await vi.importActual('@shopify/cli-kit/node/api/admin') - return { - ...actual, - adminUrl: vi.fn(), - } -}) -vi.mock('@shopify/cli-kit/node/system', () => ({openURL: vi.fn().mockResolvedValue(true)})) -vi.mock('@shopify/cli-kit/node/crypto', () => ({randomUUID: vi.fn().mockReturnValue('state-123')})) - -async function getAvailablePort(): Promise { - return await new Promise((resolve, reject) => { - const server = createServer() - - server.on('error', reject) - server.listen(0, '127.0.0.1', () => { - const address = server.address() - if (!address || typeof address === 'string') { - reject(new Error('Expected an ephemeral port.')) - return - } - - server.close((error) => { - if (error) { - reject(error) - return - } - - resolve(address.port) - }) - }) - }) -} - -function callbackParams(options?: { - code?: string - shop?: string - state?: string - error?: string -}): URLSearchParams { - const params = new URLSearchParams() - params.set('shop', options?.shop ?? 'shop.myshopify.com') - params.set('state', options?.state ?? 'state-123') - - if (options?.code) params.set('code', options.code) - if (options?.error) params.set('error', options.error) - if (!options?.code && !options?.error) params.set('code', 'abc123') - - return params -} - -describe('store auth service', () => { - beforeEach(() => { - vi.clearAllMocks() - vi.mocked(adminUrl).mockReturnValue('https://shop.myshopify.com/admin/api/unstable/graphql.json') - }) - - afterEach(() => { - vi.restoreAllMocks() - mockAndCaptureOutput().clear() - }) - - test('generateCodeVerifier produces a base64url string of 43 chars', () => { - const verifier = generateCodeVerifier() - expect(verifier).toMatch(/^[A-Za-z0-9_-]{43}$/) - }) - - test('generateCodeVerifier produces unique values', () => { - const a = generateCodeVerifier() - const b = generateCodeVerifier() - expect(a).not.toBe(b) - }) - - test('computeCodeChallenge produces a deterministic S256 hash', () => { - const verifier = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk' - const expected = 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM' - expect(computeCodeChallenge(verifier)).toBe(expected) - }) - - test('parseStoreAuthScopes splits and deduplicates scopes', () => { - expect(parseStoreAuthScopes('read_products, write_products,read_products')).toEqual([ - 'read_products', - 'write_products', - ]) - }) - - test('buildStoreAuthUrl includes PKCE params and response_type=code', () => { - const url = new URL( - buildStoreAuthUrl({ - store: 'shop.myshopify.com', - scopes: ['read_products', 'write_products'], - state: 'state-123', - redirectUri: 'http://127.0.0.1:13387/auth/callback', - codeChallenge: 'test-challenge-value', - }), - ) - - expect(url.hostname).toBe('shop.myshopify.com') - expect(url.pathname).toBe('/admin/oauth/authorize') - expect(url.searchParams.get('client_id')).toBe(STORE_AUTH_APP_CLIENT_ID) - expect(url.searchParams.get('scope')).toBe('read_products,write_products') - expect(url.searchParams.get('state')).toBe('state-123') - expect(url.searchParams.get('redirect_uri')).toBe('http://127.0.0.1:13387/auth/callback') - expect(url.searchParams.get('response_type')).toBe('code') - expect(url.searchParams.get('code_challenge')).toBe('test-challenge-value') - expect(url.searchParams.get('code_challenge_method')).toBe('S256') - expect(url.searchParams.get('grant_options[]')).toBeNull() - }) - - test('resolveExistingStoreAuthScopes returns no scopes when no stored auth exists', async () => { - vi.mocked(getStoredStoreAppSession).mockReturnValue(undefined) - - await expect(resolveExistingStoreAuthScopes('shop.myshopify.com')).resolves.toEqual({scopes: [], authoritative: true}) - expect(loadStoredStoreSession).not.toHaveBeenCalled() - expect(graphqlRequest).not.toHaveBeenCalled() - }) - - test('resolveExistingStoreAuthScopes prefers current remote scopes over stale local scopes', async () => { - vi.mocked(getStoredStoreAppSession).mockReturnValue({ - store: 'shop.myshopify.com', - clientId: STORE_AUTH_APP_CLIENT_ID, - userId: '42', - accessToken: 'existing-token', - refreshToken: 'existing-refresh-token', - scopes: ['read_orders'], - acquiredAt: '2026-04-02T00:00:00.000Z', - }) - vi.mocked(loadStoredStoreSession).mockResolvedValue({ - store: 'shop.myshopify.com', - clientId: STORE_AUTH_APP_CLIENT_ID, - userId: '42', - accessToken: 'fresh-token', - refreshToken: 'fresh-refresh-token', - scopes: ['read_orders'], - acquiredAt: '2026-04-02T00:00:00.000Z', - } as any) - vi.mocked(graphqlRequest).mockResolvedValue({ - currentAppInstallation: {accessScopes: [{handle: 'read_products'}, {handle: 'read_customers'}]}, - } as any) - - await expect(resolveExistingStoreAuthScopes('shop.myshopify.com')).resolves.toEqual({ - scopes: ['read_products', 'read_customers'], - authoritative: true, - }) - expect(adminUrl).toHaveBeenCalledWith('shop.myshopify.com', 'unstable') - expect(graphqlRequest).toHaveBeenCalledWith({ - query: expect.stringContaining('currentAppInstallation'), - api: 'Admin', - url: 'https://shop.myshopify.com/admin/api/unstable/graphql.json', - token: 'fresh-token', - responseOptions: {handleErrors: false}, - }) - }) - - test('resolveExistingStoreAuthScopes falls back to locally stored scopes when remote lookup fails', async () => { - vi.mocked(getStoredStoreAppSession).mockReturnValue({ - store: 'shop.myshopify.com', - clientId: STORE_AUTH_APP_CLIENT_ID, - userId: '42', - accessToken: 'existing-token', - refreshToken: 'existing-refresh-token', - scopes: ['read_orders'], - acquiredAt: '2026-04-02T00:00:00.000Z', - }) - vi.mocked(loadStoredStoreSession).mockRejectedValue(new Error('boom')) - - await expect(resolveExistingStoreAuthScopes('shop.myshopify.com')).resolves.toEqual({ - scopes: ['read_orders'], - authoritative: false, - }) - }) - - test('resolveExistingStoreAuthScopes falls back to locally stored scopes when access scopes request fails', async () => { - const output = mockAndCaptureOutput() - - vi.mocked(getStoredStoreAppSession).mockReturnValue({ - store: 'shop.myshopify.com', - clientId: STORE_AUTH_APP_CLIENT_ID, - userId: '42', - accessToken: 'existing-token', - refreshToken: 'existing-refresh-token', - scopes: ['read_orders'], - acquiredAt: '2026-04-02T00:00:00.000Z', - }) - vi.mocked(loadStoredStoreSession).mockResolvedValue({ - store: 'shop.myshopify.com', - clientId: STORE_AUTH_APP_CLIENT_ID, - userId: '42', - accessToken: 'fresh-token', - refreshToken: 'fresh-refresh-token', - scopes: ['read_orders'], - acquiredAt: '2026-04-02T00:00:00.000Z', - } as any) - const scopeLookupError = new Error('GraphQL Error (Code: 401)') - Object.assign(scopeLookupError, { - response: { - status: 401, - errors: '[API] Invalid API key or access token (unrecognized login or wrong password)', - }, - request: { - query: '#graphql query CurrentAppInstallationAccessScopes { currentAppInstallation { accessScopes { handle } } }', - }, - }) - vi.mocked(graphqlRequest).mockRejectedValue(scopeLookupError) - - await expect(resolveExistingStoreAuthScopes('shop.myshopify.com')).resolves.toEqual({ - scopes: ['read_orders'], - authoritative: false, - }) - expect(output.debug()).toContain('after remote scope lookup failed: HTTP 401: [API] Invalid API key or access token') - expect(output.debug()).not.toContain('CurrentAppInstallationAccessScopes') - }) - - test('resolveExistingStoreAuthScopes falls back to locally stored scopes when access scopes response is invalid', async () => { - vi.mocked(getStoredStoreAppSession).mockReturnValue({ - store: 'shop.myshopify.com', - clientId: STORE_AUTH_APP_CLIENT_ID, - userId: '42', - accessToken: 'existing-token', - refreshToken: 'existing-refresh-token', - scopes: ['read_orders'], - acquiredAt: '2026-04-02T00:00:00.000Z', - }) - vi.mocked(loadStoredStoreSession).mockResolvedValue({ - store: 'shop.myshopify.com', - clientId: STORE_AUTH_APP_CLIENT_ID, - userId: '42', - accessToken: 'fresh-token', - refreshToken: 'fresh-refresh-token', - scopes: ['read_orders'], - acquiredAt: '2026-04-02T00:00:00.000Z', - } as any) - vi.mocked(graphqlRequest).mockResolvedValue({ - currentAppInstallation: undefined, - } as any) - - await expect(resolveExistingStoreAuthScopes('shop.myshopify.com')).resolves.toEqual({ - scopes: ['read_orders'], - authoritative: false, - }) - }) - - test('waitForStoreAuthCode resolves after a valid callback', async () => { - const port = await getAvailablePort() - const params = callbackParams() - const onListening = vi.fn(async () => { - const response = await globalThis.fetch(`http://127.0.0.1:${port}/auth/callback?${params.toString()}`) - expect(response.status).toBe(200) - await response.text() - }) - - await expect( - waitForStoreAuthCode({ - store: 'shop.myshopify.com', - state: 'state-123', - port, - timeoutMs: 1000, - onListening, - }), - ).resolves.toBe('abc123') - - expect(onListening).toHaveBeenCalledOnce() - }) - - test('waitForStoreAuthCode rejects when callback state does not match', async () => { - const port = await getAvailablePort() - const params = callbackParams({state: 'wrong-state'}) - - await expect( - waitForStoreAuthCode({ - store: 'shop.myshopify.com', - state: 'state-123', - port, - timeoutMs: 1000, - onListening: async () => { - const response = await globalThis.fetch(`http://127.0.0.1:${port}/auth/callback?${params.toString()}`) - expect(response.status).toBe(400) - await response.text() - }, - }), - ).rejects.toThrow('OAuth callback state does not match the original request.') - }) - - test('waitForStoreAuthCode rejects when callback store does not match and suggests the returned permanent domain', async () => { - const port = await getAvailablePort() - const params = callbackParams({shop: 'other-shop.myshopify.com'}) - - await expect( - waitForStoreAuthCode({ - store: 'shop.myshopify.com', - state: 'state-123', - port, - timeoutMs: 1000, - onListening: async () => { - const response = await globalThis.fetch(`http://127.0.0.1:${port}/auth/callback?${params.toString()}`) - expect(response.status).toBe(400) - await response.text() - }, - }), - ).rejects.toMatchObject({ - message: 'OAuth callback store does not match the requested store.', - tryMessage: 'Shopify returned other-shop.myshopify.com during authentication. Re-run using the permanent store domain:', - nextSteps: [[{command: 'shopify store auth --store other-shop.myshopify.com --scopes '}]], - }) - }) - - test('waitForStoreAuthCode rejects when Shopify returns an OAuth error', async () => { - const port = await getAvailablePort() - const params = callbackParams({error: 'access_denied'}) - - await expect( - waitForStoreAuthCode({ - store: 'shop.myshopify.com', - state: 'state-123', - port, - timeoutMs: 1000, - onListening: async () => { - const response = await globalThis.fetch(`http://127.0.0.1:${port}/auth/callback?${params.toString()}`) - expect(response.status).toBe(400) - await response.text() - }, - }), - ).rejects.toThrow('Shopify returned an OAuth error: access_denied') - }) - - test('waitForStoreAuthCode rejects when callback does not include an authorization code', async () => { - const port = await getAvailablePort() - const params = callbackParams() - params.delete('code') - - await expect( - waitForStoreAuthCode({ - store: 'shop.myshopify.com', - state: 'state-123', - port, - timeoutMs: 1000, - onListening: async () => { - const response = await globalThis.fetch(`http://127.0.0.1:${port}/auth/callback?${params.toString()}`) - expect(response.status).toBe(400) - await response.text() - }, - }), - ).rejects.toThrow('OAuth callback did not include an authorization code.') - }) - - test('waitForStoreAuthCode rejects when the port is already in use', async () => { - const port = await getAvailablePort() - const server = createServer() - await new Promise((resolve, reject) => { - server.on('error', reject) - server.listen(port, '127.0.0.1', () => resolve()) - }) - - await expect( - waitForStoreAuthCode({ - store: 'shop.myshopify.com', - state: 'state-123', - port, - timeoutMs: 1000, - }), - ).rejects.toThrow(`Port ${port} is already in use.`) - - await new Promise((resolve, reject) => { - server.close((error) => { - if (error) { - reject(error) - return - } - - resolve() - }) - }) - }) - - test('waitForStoreAuthCode rejects on timeout', async () => { - const port = await getAvailablePort() - - await expect( - waitForStoreAuthCode({ - store: 'shop.myshopify.com', - state: 'state-123', - port, - timeoutMs: 25, - }), - ).rejects.toThrow('Timed out waiting for OAuth callback.') - }) - - test('exchangeStoreAuthCodeForToken sends PKCE params and returns token response', async () => { - vi.mocked(fetch).mockResolvedValue({ - ok: true, - text: vi.fn().mockResolvedValue( - JSON.stringify({ - access_token: 'token', - scope: 'read_products', - expires_in: 86400, - refresh_token: 'refresh-token', - associated_user: {id: 42, email: 'test@example.com'}, - }), - ), - } as any) - - const response = await exchangeStoreAuthCodeForToken({ - store: 'shop.myshopify.com', - code: 'abc123', - codeVerifier: 'test-verifier', - redirectUri: 'http://127.0.0.1:13387/auth/callback', - }) - - expect(response.access_token).toBe('token') - expect(response.refresh_token).toBe('refresh-token') - expect(response.expires_in).toBe(86400) - - expect(fetch).toHaveBeenCalledWith( - 'https://shop.myshopify.com/admin/oauth/access_token', - expect.objectContaining({ - method: 'POST', - body: expect.stringContaining('"code_verifier":"test-verifier"'), - }), - ) - - const sentBody = JSON.parse((fetch as any).mock.calls[0][1].body) - expect(sentBody.client_id).toBe(STORE_AUTH_APP_CLIENT_ID) - expect(sentBody.code).toBe('abc123') - expect(sentBody.code_verifier).toBe('test-verifier') - expect(sentBody.redirect_uri).toBe('http://127.0.0.1:13387/auth/callback') - expect(sentBody.client_secret).toBeUndefined() - }) - - test('authenticateStoreWithApp opens the browser and stores the session with refresh token', async () => { - const openURL = vi.fn().mockResolvedValue(true) - const presenter = { - openingBrowser: vi.fn(), - manualAuthUrl: vi.fn(), - success: vi.fn(), - } - const waitForStoreAuthCodeMock = vi.fn().mockImplementation(async (options) => { - await options.onListening?.() - return 'abc123' - }) - - await authenticateStoreWithApp( - { - store: 'shop.myshopify.com', - scopes: 'read_products', - }, - { - openURL, - waitForStoreAuthCode: waitForStoreAuthCodeMock, - exchangeStoreAuthCodeForToken: vi.fn().mockResolvedValue({ - access_token: 'token', - scope: 'read_products', - expires_in: 86400, - refresh_token: 'refresh-token', - associated_user: {id: 42, email: 'test@example.com'}, - }), - presenter, - }, - ) - - expect(presenter.openingBrowser).toHaveBeenCalledOnce() - expect(openURL).toHaveBeenCalledWith(expect.stringContaining('/admin/oauth/authorize?')) - expect(presenter.manualAuthUrl).not.toHaveBeenCalled() - expect(presenter.success).toHaveBeenCalledWith('shop.myshopify.com', 'test@example.com') - - const storedSession = vi.mocked(setStoredStoreAppSession).mock.calls[0]![0] - expect(storedSession.store).toBe('shop.myshopify.com') - expect(storedSession.clientId).toBe(STORE_AUTH_APP_CLIENT_ID) - expect(storedSession.userId).toBe('42') - expect(storedSession.accessToken).toBe('token') - expect(storedSession.refreshToken).toBe('refresh-token') - expect(storedSession.scopes).toEqual(['read_products']) - expect(storedSession.expiresAt).toBeDefined() - expect(storedSession.associatedUser).toEqual({ - id: 42, - email: 'test@example.com', - firstName: undefined, - lastName: undefined, - accountOwner: undefined, - }) - }) - - test('authenticateStoreWithApp uses remote scopes by default when available', async () => { - vi.mocked(getStoredStoreAppSession).mockReturnValue({ - store: 'shop.myshopify.com', - clientId: STORE_AUTH_APP_CLIENT_ID, - userId: '42', - accessToken: 'existing-token', - refreshToken: 'existing-refresh-token', - scopes: ['read_orders'], - acquiredAt: '2026-04-02T00:00:00.000Z', - }) - vi.mocked(loadStoredStoreSession).mockResolvedValue({ - store: 'shop.myshopify.com', - clientId: STORE_AUTH_APP_CLIENT_ID, - userId: '42', - accessToken: 'fresh-token', - refreshToken: 'fresh-refresh-token', - scopes: ['read_orders'], - acquiredAt: '2026-04-02T00:00:00.000Z', - } as any) - vi.mocked(graphqlRequest).mockResolvedValue({ - currentAppInstallation: {accessScopes: [{handle: 'read_customers'}]}, - } as any) - - const openURL = vi.fn().mockResolvedValue(true) - const presenter = { - openingBrowser: vi.fn(), - manualAuthUrl: vi.fn(), - success: vi.fn(), - } - const waitForStoreAuthCodeMock = vi.fn().mockImplementation(async (options) => { - await options.onListening?.() - return 'abc123' - }) - - await authenticateStoreWithApp( - { - store: 'shop.myshopify.com', - scopes: 'read_products', - }, - { - openURL, - waitForStoreAuthCode: waitForStoreAuthCodeMock, - exchangeStoreAuthCodeForToken: vi.fn().mockResolvedValue({ - access_token: 'token', - scope: 'read_customers,read_products', - expires_in: 86400, - associated_user: {id: 42, email: 'test@example.com'}, - }), - presenter, - }, - ) - - const authorizationUrl = new URL(openURL.mock.calls[0]![0]) - expect(authorizationUrl.searchParams.get('scope')).toBe('read_customers,read_products') - }) - - test('authenticateStoreWithApp reuses resolved existing scopes when requesting additional access', async () => { - const openURL = vi.fn().mockResolvedValue(true) - const presenter = { - openingBrowser: vi.fn(), - manualAuthUrl: vi.fn(), - success: vi.fn(), - } - const waitForStoreAuthCodeMock = vi.fn().mockImplementation(async (options) => { - await options.onListening?.() - return 'abc123' - }) - - await authenticateStoreWithApp( - { - store: 'shop.myshopify.com', - scopes: 'read_products', - }, - { - openURL, - waitForStoreAuthCode: waitForStoreAuthCodeMock, - exchangeStoreAuthCodeForToken: vi.fn().mockResolvedValue({ - access_token: 'token', - scope: 'read_orders,read_products', - expires_in: 86400, - associated_user: {id: 42, email: 'test@example.com'}, - }), - resolveExistingScopes: vi.fn().mockResolvedValue({scopes: ['read_orders'], authoritative: true}), - presenter, - }, - ) - - const authorizationUrl = new URL(openURL.mock.calls[0]![0]) - expect(authorizationUrl.searchParams.get('scope')).toBe('read_orders,read_products') - expect(setStoredStoreAppSession).toHaveBeenCalledWith( - expect.objectContaining({ - store: 'shop.myshopify.com', - scopes: ['read_orders', 'read_products'], - }), - ) - }) - - test('authenticateStoreWithApp does not require non-authoritative cached scopes to still be granted', async () => { - const openURL = vi.fn().mockResolvedValue(true) - const presenter = { - openingBrowser: vi.fn(), - manualAuthUrl: vi.fn(), - success: vi.fn(), - } - const waitForStoreAuthCodeMock = vi.fn().mockImplementation(async (options) => { - await options.onListening?.() - return 'abc123' - }) - - await authenticateStoreWithApp( - { - store: 'shop.myshopify.com', - scopes: 'read_products', - }, - { - openURL, - waitForStoreAuthCode: waitForStoreAuthCodeMock, - exchangeStoreAuthCodeForToken: vi.fn().mockResolvedValue({ - access_token: 'token', - scope: 'read_products', - expires_in: 86400, - associated_user: {id: 42, email: 'test@example.com'}, - }), - resolveExistingScopes: vi.fn().mockResolvedValue({scopes: ['read_orders'], authoritative: false}), - presenter, - }, - ) - - const authorizationUrl = new URL(openURL.mock.calls[0]![0]) - expect(authorizationUrl.searchParams.get('scope')).toBe('read_orders,read_products') - expect(setStoredStoreAppSession).toHaveBeenCalledWith( - expect.objectContaining({ - store: 'shop.myshopify.com', - scopes: ['read_products'], - }), - ) - }) - - test('authenticateStoreWithApp avoids requesting redundant read scopes already implied by existing write scopes', async () => { - const openURL = vi.fn().mockResolvedValue(true) - const presenter = { - openingBrowser: vi.fn(), - manualAuthUrl: vi.fn(), - success: vi.fn(), - } - const waitForStoreAuthCodeMock = vi.fn().mockImplementation(async (options) => { - await options.onListening?.() - return 'abc123' - }) - - await authenticateStoreWithApp( - { - store: 'shop.myshopify.com', - scopes: 'read_products', - }, - { - openURL, - waitForStoreAuthCode: waitForStoreAuthCodeMock, - exchangeStoreAuthCodeForToken: vi.fn().mockResolvedValue({ - access_token: 'token', - scope: 'write_products', - expires_in: 86400, - associated_user: {id: 42, email: 'test@example.com'}, - }), - resolveExistingScopes: vi.fn().mockResolvedValue({scopes: ['write_products'], authoritative: true}), - presenter, - }, - ) - - const authorizationUrl = new URL(openURL.mock.calls[0]![0]) - expect(authorizationUrl.searchParams.get('scope')).toBe('write_products') - expect(setStoredStoreAppSession).toHaveBeenCalledWith( - expect.objectContaining({ - store: 'shop.myshopify.com', - scopes: ['write_products'], - }), - ) - }) - - test('authenticateStoreWithApp shows a manual auth URL when the browser does not open automatically', async () => { - const openURL = vi.fn().mockResolvedValue(false) - const presenter = { - openingBrowser: vi.fn(), - manualAuthUrl: vi.fn(), - success: vi.fn(), - } - const waitForStoreAuthCodeMock = vi.fn().mockImplementation(async (options) => { - await options.onListening?.() - return 'abc123' - }) - - await authenticateStoreWithApp( - { - store: 'shop.myshopify.com', - scopes: 'read_products', - }, - { - openURL, - waitForStoreAuthCode: waitForStoreAuthCodeMock, - exchangeStoreAuthCodeForToken: vi.fn().mockResolvedValue({ - access_token: 'token', - scope: 'read_products', - expires_in: 86400, - associated_user: {id: 42, email: 'test@example.com'}, - }), - presenter, - }, - ) - - expect(presenter.openingBrowser).toHaveBeenCalledOnce() - expect(presenter.manualAuthUrl).toHaveBeenCalledWith( - expect.stringContaining('https://shop.myshopify.com/admin/oauth/authorize?'), - ) - expect(presenter.success).toHaveBeenCalledWith('shop.myshopify.com', 'test@example.com') - }) - - test('authenticateStoreWithApp rejects when Shopify grants fewer scopes than requested', async () => { - const waitForStoreAuthCodeMock = vi.fn().mockImplementation(async (options) => { - await options.onListening?.() - return 'abc123' - }) - - await expect( - authenticateStoreWithApp( - { - store: 'shop.myshopify.com', - scopes: 'read_products,write_products', - }, - { - openURL: vi.fn().mockResolvedValue(true), - waitForStoreAuthCode: waitForStoreAuthCodeMock, - exchangeStoreAuthCodeForToken: vi.fn().mockResolvedValue({ - access_token: 'token', - scope: 'read_products', - expires_in: 86400, - associated_user: {id: 42, email: 'test@example.com'}, - }), - presenter: { - openingBrowser: vi.fn(), - manualAuthUrl: vi.fn(), - success: vi.fn(), - }, - }, - ), - ).rejects.toMatchObject({ - message: 'Shopify granted fewer scopes than were requested.', - tryMessage: 'Missing scopes: write_products.', - nextSteps: [ - 'Update the app or store installation scopes.', - 'See https://shopify.dev/app/scopes', - 'Re-run shopify store auth.', - ], - }) - - expect(setStoredStoreAppSession).not.toHaveBeenCalled() - }) - - test('authenticateStoreWithApp accepts compressed write scopes that imply requested read scopes', async () => { - const waitForStoreAuthCodeMock = vi.fn().mockImplementation(async (options) => { - await options.onListening?.() - return 'abc123' - }) - - await authenticateStoreWithApp( - { - store: 'shop.myshopify.com', - scopes: 'read_products,write_products', - }, - { - openURL: vi.fn().mockResolvedValue(true), - waitForStoreAuthCode: waitForStoreAuthCodeMock, - exchangeStoreAuthCodeForToken: vi.fn().mockResolvedValue({ - access_token: 'token', - scope: 'write_products', - expires_in: 86400, - associated_user: {id: 42, email: 'test@example.com'}, - }), - presenter: { - openingBrowser: vi.fn(), - manualAuthUrl: vi.fn(), - success: vi.fn(), - }, - }, - ) - - expect(setStoredStoreAppSession).toHaveBeenCalledWith( - expect.objectContaining({ - store: 'shop.myshopify.com', - scopes: ['write_products'], - }), - ) - }) - - test('authenticateStoreWithApp still rejects when other requested scopes are missing', async () => { - const waitForStoreAuthCodeMock = vi.fn().mockImplementation(async (options) => { - await options.onListening?.() - return 'abc123' - }) - - await expect( - authenticateStoreWithApp( - { - store: 'shop.myshopify.com', - scopes: 'read_products,write_products,read_orders', - }, - { - openURL: vi.fn().mockResolvedValue(true), - waitForStoreAuthCode: waitForStoreAuthCodeMock, - exchangeStoreAuthCodeForToken: vi.fn().mockResolvedValue({ - access_token: 'token', - scope: 'write_products', - expires_in: 86400, - associated_user: {id: 42, email: 'test@example.com'}, - }), - presenter: { - openingBrowser: vi.fn(), - manualAuthUrl: vi.fn(), - success: vi.fn(), - }, - }, - ), - ).rejects.toThrow('Shopify granted fewer scopes than were requested.') - - expect(setStoredStoreAppSession).not.toHaveBeenCalled() - }) - - test('authenticateStoreWithApp falls back to requested scopes when Shopify omits granted scopes', async () => { - const waitForStoreAuthCodeMock = vi.fn().mockImplementation(async (options) => { - await options.onListening?.() - return 'abc123' - }) - - await authenticateStoreWithApp( - { - store: 'shop.myshopify.com', - scopes: 'read_products', - }, - { - openURL: vi.fn().mockResolvedValue(true), - waitForStoreAuthCode: waitForStoreAuthCodeMock, - exchangeStoreAuthCodeForToken: vi.fn().mockResolvedValue({ - access_token: 'token', - expires_in: 86400, - associated_user: {id: 42, email: 'test@example.com'}, - }), - presenter: { - openingBrowser: vi.fn(), - manualAuthUrl: vi.fn(), - success: vi.fn(), - }, - }, - ) - - expect(setStoredStoreAppSession).toHaveBeenCalledWith( - expect.objectContaining({ - store: 'shop.myshopify.com', - scopes: ['read_products'], - }), - ) - }) - - test('authenticateStoreWithApp accepts compressed unauthenticated write scopes that imply requested unauthenticated read scopes', async () => { - const waitForStoreAuthCodeMock = vi.fn().mockImplementation(async (options) => { - await options.onListening?.() - return 'abc123' - }) - - await authenticateStoreWithApp( - { - store: 'shop.myshopify.com', - scopes: 'unauthenticated_read_product_listings,unauthenticated_write_product_listings', - }, - { - openURL: vi.fn().mockResolvedValue(true), - waitForStoreAuthCode: waitForStoreAuthCodeMock, - exchangeStoreAuthCodeForToken: vi.fn().mockResolvedValue({ - access_token: 'token', - scope: 'unauthenticated_write_product_listings', - expires_in: 86400, - associated_user: {id: 42, email: 'test@example.com'}, - }), - presenter: { - openingBrowser: vi.fn(), - manualAuthUrl: vi.fn(), - success: vi.fn(), - }, - }, - ) - - expect(setStoredStoreAppSession).toHaveBeenCalledWith( - expect.objectContaining({ - store: 'shop.myshopify.com', - scopes: ['unauthenticated_write_product_listings'], - }), - ) - }) -}) diff --git a/packages/cli/src/cli/services/store/auth.ts b/packages/cli/src/cli/services/store/auth.ts deleted file mode 100644 index 27a6eeda04f..00000000000 --- a/packages/cli/src/cli/services/store/auth.ts +++ /dev/null @@ -1,607 +0,0 @@ -import {DEFAULT_STORE_AUTH_PORT, STORE_AUTH_APP_CLIENT_ID, STORE_AUTH_CALLBACK_PATH, maskToken, storeAuthRedirectUri} from './auth-config.js' -import {retryStoreAuthWithPermanentDomainError} from './auth-recovery.js' -import {getStoredStoreAppSession, setStoredStoreAppSession} from './session.js' -import type {StoredStoreAppSession} from './session.js' -import {loadStoredStoreSession} from './stored-session.js' -import {adminUrl} from '@shopify/cli-kit/node/api/admin' -import {graphqlRequest} from '@shopify/cli-kit/node/api/graphql' -import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' -import {randomUUID} from '@shopify/cli-kit/node/crypto' -import {AbortError} from '@shopify/cli-kit/node/error' -import {fetch} from '@shopify/cli-kit/node/http' -import {outputCompleted, outputContent, outputDebug, outputInfo, outputToken} from '@shopify/cli-kit/node/output' -import {openURL} from '@shopify/cli-kit/node/system' -import {createHash, randomBytes, timingSafeEqual} from 'crypto' -import {createServer} from 'http' - -interface StoreAuthInput { - store: string - scopes: string -} - -interface StoreTokenResponse { - access_token: string - token_type?: string - scope?: string - expires_in?: number - refresh_token?: string - refresh_token_expires_in?: number - associated_user_scope?: string - associated_user?: { - id: number - first_name?: string - last_name?: string - email?: string - account_owner?: boolean - locale?: string - collaborator?: boolean - email_verified?: boolean - } -} - -interface StoreAuthorizationContext { - store: string - scopes: string[] - state: string - port: number - redirectUri: string - authorizationUrl: string - codeVerifier: string -} - -interface StoreAuthBootstrap { - authorization: StoreAuthorizationContext - waitForAuthCodeOptions: WaitForAuthCodeOptions - exchangeCodeForToken: (code: string) => Promise -} - -interface WaitForAuthCodeOptions { - store: string - state: string - port: number - timeoutMs?: number - onListening?: () => void | Promise -} - -export function generateCodeVerifier(): string { - return randomBytes(32).toString('base64url') -} - -export function computeCodeChallenge(verifier: string): string { - return createHash('sha256').update(verifier).digest('base64url') -} - -export function parseStoreAuthScopes(input: string): string[] { - const scopes = input - .split(',') - .map((scope) => scope.trim()) - .filter(Boolean) - - if (scopes.length === 0) { - throw new AbortError('At least one scope is required.', 'Pass --scopes as a comma-separated list.') - } - - return [...new Set(scopes)] -} - -function expandImpliedStoreScopes(scopes: string[]): Set { - const expandedScopes = new Set(scopes) - - for (const scope of scopes) { - const matches = scope.match(/^(unauthenticated_)?write_(.*)$/) - if (matches) { - expandedScopes.add(`${matches[1] ?? ''}read_${matches[2]}`) - } - } - - return expandedScopes -} - -function mergeRequestedAndStoredScopes(requestedScopes: string[], storedScopes: string[]): string[] { - const mergedScopes = [...storedScopes] - const expandedScopes = expandImpliedStoreScopes(storedScopes) - - for (const scope of requestedScopes) { - if (expandedScopes.has(scope)) continue - - mergedScopes.push(scope) - for (const expandedScope of expandImpliedStoreScopes([scope])) { - expandedScopes.add(expandedScope) - } - } - - return mergedScopes -} - -interface StoreAccessScopesResponse { - currentAppInstallation?: { - accessScopes?: {handle?: string}[] - } -} - -interface ResolvedStoreAuthScopes { - scopes: string[] - authoritative: boolean -} - -function truncateDebugMessage(message: string, length = 300): string { - return message.slice(0, length) -} - -function formatStoreScopeLookupError(error: unknown): string { - if (error && typeof error === 'object' && 'response' in error) { - const response = (error as {response?: {status?: number; errors?: unknown}}).response - const status = response?.status - const details = response?.errors - - if (typeof status === 'number') { - const summary = typeof details === 'string' ? details : JSON.stringify(details) - return truncateDebugMessage(summary ? `HTTP ${status}: ${summary}` : `HTTP ${status}`) - } - } - - return truncateDebugMessage(error instanceof Error ? error.message : String(error)) -} - -const CurrentAppInstallationAccessScopesQuery = `#graphql - query CurrentAppInstallationAccessScopes { - currentAppInstallation { - accessScopes { - handle - } - } - } -` - -async function fetchCurrentStoreAuthScopes(session: StoredStoreAppSession): Promise { - outputDebug( - outputContent`Fetching current app installation scopes for ${outputToken.raw(session.store)} using token ${outputToken.raw(maskToken(session.accessToken))}`, - ) - - const data = await graphqlRequest({ - query: CurrentAppInstallationAccessScopesQuery, - api: 'Admin', - url: adminUrl(session.store, 'unstable'), - token: session.accessToken, - responseOptions: {handleErrors: false}, - }) - - if (!Array.isArray(data.currentAppInstallation?.accessScopes)) { - throw new Error('Shopify did not return currentAppInstallation.accessScopes.') - } - - return data.currentAppInstallation.accessScopes.flatMap((scope) => - typeof scope.handle === 'string' ? [scope.handle] : [], - ) -} - -export async function resolveExistingStoreAuthScopes(store: string): Promise { - const normalizedStore = normalizeStoreFqdn(store) - const storedSession = getStoredStoreAppSession(normalizedStore) - if (!storedSession) return {scopes: [], authoritative: true} - - try { - const usableSession = await loadStoredStoreSession(normalizedStore) - const remoteScopes = await fetchCurrentStoreAuthScopes(usableSession) - - outputDebug( - outputContent`Resolved current remote scopes for ${outputToken.raw(normalizedStore)}: ${outputToken.raw(remoteScopes.join(',') || 'none')}`, - ) - - return {scopes: remoteScopes, authoritative: true} - } catch (error) { - outputDebug( - outputContent`Falling back to locally stored scopes for ${outputToken.raw(normalizedStore)} after remote scope lookup failed: ${outputToken.raw(formatStoreScopeLookupError(error))}`, - ) - return {scopes: storedSession.scopes, authoritative: false} - } -} - -function resolveGrantedScopes(tokenResponse: StoreTokenResponse, requestedScopes: string[]): string[] { - if (!tokenResponse.scope) { - outputDebug(outputContent`Token response did not include scope; falling back to requested scopes`) - return requestedScopes - } - - const grantedScopes = parseStoreAuthScopes(tokenResponse.scope) - const expandedGrantedScopes = expandImpliedStoreScopes(grantedScopes) - const missingScopes = requestedScopes.filter((scope) => !expandedGrantedScopes.has(scope)) - - if (missingScopes.length > 0) { - throw new AbortError( - 'Shopify granted fewer scopes than were requested.', - `Missing scopes: ${missingScopes.join(', ')}.`, - [ - 'Update the app or store installation scopes.', - 'See https://shopify.dev/app/scopes', - 'Re-run shopify store auth.', - ], - ) - } - - return grantedScopes -} - -export function buildStoreAuthUrl(options: { - store: string - scopes: string[] - state: string - redirectUri: string - codeChallenge: string -}): string { - const params = new URLSearchParams() - params.set('client_id', STORE_AUTH_APP_CLIENT_ID) - params.set('scope', options.scopes.join(',')) - params.set('redirect_uri', options.redirectUri) - params.set('state', options.state) - params.set('response_type', 'code') - params.set('code_challenge', options.codeChallenge) - params.set('code_challenge_method', 'S256') - - return `https://${options.store}/admin/oauth/authorize?${params.toString()}` -} - -function renderAuthCallbackPage(title: string, message: string): string { - const safeTitle = title - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - const safeMessage = message - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - - return ` - - - - - ${safeTitle} - - -
-
-

${safeTitle}

-

${safeMessage}

-
-
- -` -} - -export async function waitForStoreAuthCode({ - store, - state, - port, - timeoutMs = 5 * 60 * 1000, - onListening, -}: WaitForAuthCodeOptions): Promise { - const normalizedStore = normalizeStoreFqdn(store) - - return new Promise((resolve, reject) => { - let settled = false - let isListening = false - - const timeout = setTimeout(() => { - settleWithError(new AbortError('Timed out waiting for OAuth callback.')) - }, timeoutMs) - - const server = createServer((req, res) => { - const requestUrl = new URL(req.url ?? '/', `http://127.0.0.1:${port}`) - - if (requestUrl.pathname !== STORE_AUTH_CALLBACK_PATH) { - res.statusCode = 404 - res.end('Not found') - return - } - - const {searchParams} = requestUrl - - const fail = (error: AbortError | string, tryMessage?: string) => { - const abortError = typeof error === 'string' ? new AbortError(error, tryMessage) : error - - res.statusCode = 400 - res.setHeader('Content-Type', 'text/html') - res.setHeader('Connection', 'close') - res.once('finish', () => settleWithError(abortError)) - res.end(renderAuthCallbackPage('Authentication failed', abortError.message)) - } - - const returnedStore = searchParams.get('shop') - outputDebug(outputContent`Received OAuth callback for shop ${outputToken.raw(returnedStore ?? 'unknown')}`) - - if (!returnedStore) { - fail('OAuth callback store does not match the requested store.') - return - } - - const normalizedReturnedStore = normalizeStoreFqdn(returnedStore) - if (normalizedReturnedStore !== normalizedStore) { - fail(retryStoreAuthWithPermanentDomainError(normalizedReturnedStore)) - return - } - - const returnedState = searchParams.get('state') - if (!returnedState || !constantTimeEqual(returnedState, state)) { - fail('OAuth callback state does not match the original request.') - return - } - - const error = searchParams.get('error') - if (error) { - fail(`Shopify returned an OAuth error: ${error}`) - return - } - - const code = searchParams.get('code') - if (!code) { - fail('OAuth callback did not include an authorization code.') - return - } - - outputDebug(outputContent`Received authorization code ${outputToken.raw(maskToken(code))}`) - - res.statusCode = 200 - res.setHeader('Content-Type', 'text/html') - res.setHeader('Connection', 'close') - res.once('finish', () => settle(() => resolve(code))) - res.end(renderAuthCallbackPage('Authentication succeeded', 'You can close this window and return to the terminal.')) - }) - - const settle = (callback: () => void) => { - if (settled) return - settled = true - clearTimeout(timeout) - - const finalize = () => { - callback() - } - - if (!isListening) { - finalize() - return - } - - server.close(() => { - isListening = false - finalize() - }) - server.closeIdleConnections?.() - } - - const settleWithError = (error: Error) => { - settle(() => reject(error)) - } - - server.on('error', (error: NodeJS.ErrnoException) => { - if (error.code === 'EADDRINUSE') { - settleWithError( - new AbortError( - `Port ${port} is already in use.`, - `Free port ${port} and re-run ${outputToken.genericShellCommand(`shopify store auth --store ${store} --scopes `).value}. Ensure that redirect URI is allowed in the app configuration.`, - ), - ) - return - } - - settleWithError(error) - }) - - server.listen(port, '127.0.0.1', async () => { - isListening = true - outputDebug( - outputContent`PKCE callback server listening on http://127.0.0.1:${outputToken.raw(String(port))}${outputToken.raw(STORE_AUTH_CALLBACK_PATH)}`, - ) - - if (!onListening) return - - try { - await onListening() - } catch (error) { - settleWithError(error instanceof Error ? error : new Error(String(error))) - } - }) - }) -} - -function constantTimeEqual(a: string, b: string): boolean { - if (a.length !== b.length) return false - return timingSafeEqual(Buffer.from(a, 'utf8'), Buffer.from(b, 'utf8')) -} - -export async function exchangeStoreAuthCodeForToken(options: { - store: string - code: string - codeVerifier: string - redirectUri: string -}): Promise { - const endpoint = `https://${options.store}/admin/oauth/access_token` - - outputDebug(outputContent`Exchanging authorization code for token at ${outputToken.raw(endpoint)}`) - - const response = await fetch(endpoint, { - method: 'POST', - headers: {'Content-Type': 'application/json'}, - body: JSON.stringify({ - client_id: STORE_AUTH_APP_CLIENT_ID, - code: options.code, - code_verifier: options.codeVerifier, - redirect_uri: options.redirectUri, - }), - }) - - const body = await response.text() - if (!response.ok) { - outputDebug( - outputContent`Token exchange failed with HTTP ${outputToken.raw(String(response.status))}: ${outputToken.raw(body.slice(0, 300))}`, - ) - throw new AbortError( - `Failed to exchange OAuth code for an access token (HTTP ${response.status}).`, - body || response.statusText, - ) - } - - let parsed: StoreTokenResponse - try { - parsed = JSON.parse(body) as StoreTokenResponse - } catch { - throw new AbortError('Received an invalid token response from Shopify.') - } - - outputDebug( - outputContent`Token exchange succeeded: access_token=${outputToken.raw(maskToken(parsed.access_token))}, refresh_token=${outputToken.raw(parsed.refresh_token ? maskToken(parsed.refresh_token) : 'none')}, expires_in=${outputToken.raw(String(parsed.expires_in ?? 'unknown'))}s, user=${outputToken.raw(String(parsed.associated_user?.id ?? 'unknown'))} (${outputToken.raw(parsed.associated_user?.email ?? 'no email')})`, - ) - - return parsed -} - -interface StoreAuthPresenter { - openingBrowser: () => void - manualAuthUrl: (authorizationUrl: string) => void - success: (store: string, email?: string) => void -} - -interface StoreAuthDependencies { - openURL: typeof openURL - waitForStoreAuthCode: typeof waitForStoreAuthCode - exchangeStoreAuthCodeForToken: typeof exchangeStoreAuthCodeForToken - resolveExistingScopes?: (store: string) => Promise - presenter: StoreAuthPresenter -} - -const defaultStoreAuthPresenter: StoreAuthPresenter = { - openingBrowser() { - outputInfo('Shopify CLI will open the app authorization page in your browser.') - outputInfo('') - }, - manualAuthUrl(authorizationUrl: string) { - outputInfo('Browser did not open automatically. Open this URL manually:') - outputInfo(outputContent`${outputToken.link(authorizationUrl)}`) - outputInfo('') - }, - success(store: string, email?: string) { - const displayName = email ? ` as ${email}` : '' - - outputCompleted('Logged in.') - outputCompleted(`Authenticated${displayName} against ${store}.`) - outputInfo('') - outputInfo('To verify that authentication worked, run:') - outputInfo(`shopify store execute --store ${store} --query 'query { shop { name id } }'`) - }, -} - -const defaultStoreAuthDependencies: StoreAuthDependencies = { - openURL, - waitForStoreAuthCode, - exchangeStoreAuthCodeForToken, - presenter: defaultStoreAuthPresenter, -} - -function createPkceBootstrap(options: { - store: string - scopes: string[] - exchangeCodeForToken: typeof exchangeStoreAuthCodeForToken -}): StoreAuthBootstrap { - const {store, scopes, exchangeCodeForToken} = options - const port = DEFAULT_STORE_AUTH_PORT - const state = randomUUID() - const redirectUri = storeAuthRedirectUri(port) - const codeVerifier = generateCodeVerifier() - const codeChallenge = computeCodeChallenge(codeVerifier) - const authorizationUrl = buildStoreAuthUrl({store, scopes, state, redirectUri, codeChallenge}) - - outputDebug( - outputContent`Starting PKCE auth for ${outputToken.raw(store)} with scopes ${outputToken.raw(scopes.join(','))} (redirect_uri=${outputToken.raw(redirectUri)})`, - ) - - return { - authorization: { - store, - scopes, - state, - port, - redirectUri, - authorizationUrl, - codeVerifier, - }, - waitForAuthCodeOptions: { - store, - state, - port, - }, - exchangeCodeForToken: (code: string) => exchangeCodeForToken({store, code, codeVerifier, redirectUri}), - } -} - -export async function authenticateStoreWithApp( - input: StoreAuthInput, - dependencies: StoreAuthDependencies = defaultStoreAuthDependencies, -): Promise { - const store = normalizeStoreFqdn(input.store) - const requestedScopes = parseStoreAuthScopes(input.scopes) - const existingScopeResolution = await (dependencies.resolveExistingScopes ?? resolveExistingStoreAuthScopes)(store) - const scopes = mergeRequestedAndStoredScopes(requestedScopes, existingScopeResolution.scopes) - const validationScopes = existingScopeResolution.authoritative ? scopes : requestedScopes - - if (existingScopeResolution.scopes.length > 0) { - outputDebug( - outputContent`Merged requested scopes ${outputToken.raw(requestedScopes.join(','))} with existing scopes ${outputToken.raw(existingScopeResolution.scopes.join(','))} for ${outputToken.raw(store)}`, - ) - } - - const bootstrap = createPkceBootstrap({store, scopes, exchangeCodeForToken: dependencies.exchangeStoreAuthCodeForToken}) - const {authorization: {authorizationUrl}} = bootstrap - - dependencies.presenter.openingBrowser() - - const code = await dependencies.waitForStoreAuthCode({ - ...bootstrap.waitForAuthCodeOptions, - onListening: async () => { - const opened = await dependencies.openURL(authorizationUrl) - if (!opened) dependencies.presenter.manualAuthUrl(authorizationUrl) - }, - }) - const tokenResponse = await bootstrap.exchangeCodeForToken(code) - - const userId = tokenResponse.associated_user?.id?.toString() - if (!userId) { - throw new AbortError('Shopify did not return associated user information for the online access token.') - } - - const now = Date.now() - const expiresAt = tokenResponse.expires_in ? new Date(now + tokenResponse.expires_in * 1000).toISOString() : undefined - - setStoredStoreAppSession({ - store, - clientId: STORE_AUTH_APP_CLIENT_ID, - userId, - accessToken: tokenResponse.access_token, - refreshToken: tokenResponse.refresh_token, - // Store the raw scopes returned by Shopify. Validation may treat implied - // write_* -> read_* permissions as satisfied, so callers should not assume - // session.scopes is an expanded/effective permission set. - scopes: resolveGrantedScopes(tokenResponse, validationScopes), - acquiredAt: new Date(now).toISOString(), - expiresAt, - refreshTokenExpiresAt: tokenResponse.refresh_token_expires_in - ? new Date(now + tokenResponse.refresh_token_expires_in * 1000).toISOString() - : undefined, - associatedUser: tokenResponse.associated_user - ? { - id: tokenResponse.associated_user.id, - email: tokenResponse.associated_user.email, - firstName: tokenResponse.associated_user.first_name, - lastName: tokenResponse.associated_user.last_name, - accountOwner: tokenResponse.associated_user.account_owner, - } - : undefined, - }) - - outputDebug( - outputContent`Session persisted for ${outputToken.raw(store)} (user ${outputToken.raw(userId)}, expires ${outputToken.raw(expiresAt ?? 'unknown')})`, - ) - - dependencies.presenter.success(store, tokenResponse.associated_user?.email) -} diff --git a/packages/cli/src/cli/services/store/auth/callback.test.ts b/packages/cli/src/cli/services/store/auth/callback.test.ts new file mode 100644 index 00000000000..2cdb74427c7 --- /dev/null +++ b/packages/cli/src/cli/services/store/auth/callback.test.ts @@ -0,0 +1,189 @@ +import {createServer} from 'http' +import {describe, expect, test} from 'vitest' +import {waitForStoreAuthCode} from './callback.js' + +async function getAvailablePort(): Promise { + return await new Promise((resolve, reject) => { + const server = createServer() + + server.on('error', reject) + server.listen(0, '127.0.0.1', () => { + const address = server.address() + if (!address || typeof address === 'string') { + reject(new Error('Expected an ephemeral port.')) + return + } + + server.close((error) => { + if (error) { + reject(error) + return + } + + resolve(address.port) + }) + }) + }) +} + +function callbackParams(options?: { + code?: string + shop?: string + state?: string + error?: string +}): URLSearchParams { + const params = new URLSearchParams() + params.set('shop', options?.shop ?? 'shop.myshopify.com') + params.set('state', options?.state ?? 'state-123') + + if (options?.code) params.set('code', options.code) + if (options?.error) params.set('error', options.error) + if (!options?.code && !options?.error) params.set('code', 'abc123') + + return params +} + +describe('store auth callback server', () => { + test('waitForStoreAuthCode resolves after a valid callback', async () => { + const port = await getAvailablePort() + const params = callbackParams() + const onListening = async () => { + const response = await globalThis.fetch(`http://127.0.0.1:${port}/auth/callback?${params.toString()}`) + expect(response.status).toBe(200) + await response.text() + } + + await expect( + waitForStoreAuthCode({ + store: 'shop.myshopify.com', + state: 'state-123', + port, + timeoutMs: 1000, + onListening, + }), + ).resolves.toBe('abc123') + }) + + test('waitForStoreAuthCode rejects when callback state does not match', async () => { + const port = await getAvailablePort() + const params = callbackParams({state: 'wrong-state'}) + + await expect( + waitForStoreAuthCode({ + store: 'shop.myshopify.com', + state: 'state-123', + port, + timeoutMs: 1000, + onListening: async () => { + const response = await globalThis.fetch(`http://127.0.0.1:${port}/auth/callback?${params.toString()}`) + expect(response.status).toBe(400) + await response.text() + }, + }), + ).rejects.toThrow('OAuth callback state does not match the original request.') + }) + + test('waitForStoreAuthCode rejects when callback store does not match and suggests the returned permanent domain', async () => { + const port = await getAvailablePort() + const params = callbackParams({shop: 'other-shop.myshopify.com'}) + + await expect( + waitForStoreAuthCode({ + store: 'shop.myshopify.com', + state: 'state-123', + port, + timeoutMs: 1000, + onListening: async () => { + const response = await globalThis.fetch(`http://127.0.0.1:${port}/auth/callback?${params.toString()}`) + expect(response.status).toBe(400) + await response.text() + }, + }), + ).rejects.toMatchObject({ + message: 'OAuth callback store does not match the requested store.', + tryMessage: 'Shopify returned other-shop.myshopify.com during authentication. Re-run using the permanent store domain:', + nextSteps: [[{command: 'shopify store auth --store other-shop.myshopify.com --scopes '}]], + }) + }) + + test('waitForStoreAuthCode rejects when Shopify returns an OAuth error', async () => { + const port = await getAvailablePort() + const params = callbackParams({error: 'access_denied'}) + + await expect( + waitForStoreAuthCode({ + store: 'shop.myshopify.com', + state: 'state-123', + port, + timeoutMs: 1000, + onListening: async () => { + const response = await globalThis.fetch(`http://127.0.0.1:${port}/auth/callback?${params.toString()}`) + expect(response.status).toBe(400) + await response.text() + }, + }), + ).rejects.toThrow('Shopify returned an OAuth error: access_denied') + }) + + test('waitForStoreAuthCode rejects when callback does not include an authorization code', async () => { + const port = await getAvailablePort() + const params = callbackParams() + params.delete('code') + + await expect( + waitForStoreAuthCode({ + store: 'shop.myshopify.com', + state: 'state-123', + port, + timeoutMs: 1000, + onListening: async () => { + const response = await globalThis.fetch(`http://127.0.0.1:${port}/auth/callback?${params.toString()}`) + expect(response.status).toBe(400) + await response.text() + }, + }), + ).rejects.toThrow('OAuth callback did not include an authorization code.') + }) + + test('waitForStoreAuthCode rejects when the port is already in use', async () => { + const port = await getAvailablePort() + const server = createServer() + await new Promise((resolve, reject) => { + server.on('error', reject) + server.listen(port, '127.0.0.1', () => resolve()) + }) + + await expect( + waitForStoreAuthCode({ + store: 'shop.myshopify.com', + state: 'state-123', + port, + timeoutMs: 1000, + }), + ).rejects.toThrow(`Port ${port} is already in use.`) + + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error) + return + } + + resolve() + }) + }) + }) + + test('waitForStoreAuthCode rejects on timeout', async () => { + const port = await getAvailablePort() + + await expect( + waitForStoreAuthCode({ + store: 'shop.myshopify.com', + state: 'state-123', + port, + timeoutMs: 25, + }), + ).rejects.toThrow('Timed out waiting for OAuth callback.') + }) +}) diff --git a/packages/cli/src/cli/services/store/auth/callback.ts b/packages/cli/src/cli/services/store/auth/callback.ts new file mode 100644 index 00000000000..6cf394c5952 --- /dev/null +++ b/packages/cli/src/cli/services/store/auth/callback.ts @@ -0,0 +1,185 @@ +import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' +import {AbortError} from '@shopify/cli-kit/node/error' +import {outputContent, outputDebug, outputToken} from '@shopify/cli-kit/node/output' +import {timingSafeEqual} from 'crypto' +import {createServer} from 'http' +import {STORE_AUTH_CALLBACK_PATH, maskToken} from './config.js' +import {retryStoreAuthWithPermanentDomainError} from './recovery.js' + +export interface WaitForAuthCodeOptions { + store: string + state: string + port: number + timeoutMs?: number + onListening?: () => void | Promise +} + +function renderAuthCallbackPage(title: string, message: string): string { + const safeTitle = title + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + const safeMessage = message + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + + return ` + + + + + ${safeTitle} + + +
+
+

${safeTitle}

+

${safeMessage}

+
+
+ +` +} + +export async function waitForStoreAuthCode({ + store, + state, + port, + timeoutMs = 5 * 60 * 1000, + onListening, +}: WaitForAuthCodeOptions): Promise { + const normalizedStore = normalizeStoreFqdn(store) + + return new Promise((resolve, reject) => { + let settled = false + let isListening = false + + const timeout = setTimeout(() => { + settleWithError(new AbortError('Timed out waiting for OAuth callback.')) + }, timeoutMs) + + const server = createServer((req, res) => { + const requestUrl = new URL(req.url ?? '/', `http://127.0.0.1:${port}`) + + if (requestUrl.pathname !== STORE_AUTH_CALLBACK_PATH) { + res.statusCode = 404 + res.end('Not found') + return + } + + const {searchParams} = requestUrl + + const fail = (error: AbortError | string, tryMessage?: string) => { + const abortError = typeof error === 'string' ? new AbortError(error, tryMessage) : error + + res.statusCode = 400 + res.setHeader('Content-Type', 'text/html') + res.setHeader('Connection', 'close') + res.once('finish', () => settleWithError(abortError)) + res.end(renderAuthCallbackPage('Authentication failed', abortError.message)) + } + + const returnedStore = searchParams.get('shop') + outputDebug(outputContent`Received OAuth callback for shop ${outputToken.raw(returnedStore ?? 'unknown')}`) + + if (!returnedStore) { + fail('OAuth callback store does not match the requested store.') + return + } + + const normalizedReturnedStore = normalizeStoreFqdn(returnedStore) + if (normalizedReturnedStore !== normalizedStore) { + fail(retryStoreAuthWithPermanentDomainError(normalizedReturnedStore)) + return + } + + const returnedState = searchParams.get('state') + if (!returnedState || !constantTimeEqual(returnedState, state)) { + fail('OAuth callback state does not match the original request.') + return + } + + const error = searchParams.get('error') + if (error) { + fail(`Shopify returned an OAuth error: ${error}`) + return + } + + const code = searchParams.get('code') + if (!code) { + fail('OAuth callback did not include an authorization code.') + return + } + + outputDebug(outputContent`Received authorization code ${outputToken.raw(maskToken(code))}`) + + res.statusCode = 200 + res.setHeader('Content-Type', 'text/html') + res.setHeader('Connection', 'close') + res.once('finish', () => settle(() => resolve(code))) + res.end(renderAuthCallbackPage('Authentication succeeded', 'You can close this window and return to the terminal.')) + }) + + const settle = (callback: () => void) => { + if (settled) return + settled = true + clearTimeout(timeout) + + const finalize = () => { + callback() + } + + if (!isListening) { + finalize() + return + } + + server.close(() => { + isListening = false + finalize() + }) + server.closeIdleConnections?.() + } + + const settleWithError = (error: Error) => { + settle(() => reject(error)) + } + + server.on('error', (error: NodeJS.ErrnoException) => { + if (error.code === 'EADDRINUSE') { + settleWithError( + new AbortError( + `Port ${port} is already in use.`, + `Free port ${port} and re-run ${outputToken.genericShellCommand(`shopify store auth --store ${store} --scopes `).value}. Ensure that redirect URI is allowed in the app configuration.`, + ), + ) + return + } + + settleWithError(error) + }) + + server.listen(port, '127.0.0.1', async () => { + isListening = true + outputDebug( + outputContent`PKCE callback server listening on http://127.0.0.1:${outputToken.raw(String(port))}${outputToken.raw(STORE_AUTH_CALLBACK_PATH)}`, + ) + + if (!onListening) return + + try { + await onListening() + } catch (error) { + settleWithError(error instanceof Error ? error : new Error(String(error))) + } + }) + }) +} + +function constantTimeEqual(a: string, b: string): boolean { + if (a.length !== b.length) return false + return timingSafeEqual(Buffer.from(a, 'utf8'), Buffer.from(b, 'utf8')) +} diff --git a/packages/cli/src/cli/services/store/auth-config.ts b/packages/cli/src/cli/services/store/auth/config.ts similarity index 100% rename from packages/cli/src/cli/services/store/auth-config.ts rename to packages/cli/src/cli/services/store/auth/config.ts diff --git a/packages/cli/src/cli/services/store/auth/existing-scopes.test.ts b/packages/cli/src/cli/services/store/auth/existing-scopes.test.ts new file mode 100644 index 00000000000..ac53c16f13f --- /dev/null +++ b/packages/cli/src/cli/services/store/auth/existing-scopes.test.ts @@ -0,0 +1,164 @@ +import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest' +import {adminUrl} from '@shopify/cli-kit/node/api/admin' +import {graphqlRequest} from '@shopify/cli-kit/node/api/graphql' +import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' +import {STORE_AUTH_APP_CLIENT_ID} from './config.js' +import {resolveExistingStoreAuthScopes} from './existing-scopes.js' +import {loadStoredStoreSession} from './session-lifecycle.js' +import {getCurrentStoredStoreAppSession} from './session-store.js' + +vi.mock('./session-store.js') +vi.mock('./session-lifecycle.js', () => ({loadStoredStoreSession: vi.fn()})) +vi.mock('@shopify/cli-kit/node/api/graphql') +vi.mock('@shopify/cli-kit/node/api/admin', async () => { + const actual = await vi.importActual('@shopify/cli-kit/node/api/admin') + return { + ...actual, + adminUrl: vi.fn(), + } +}) + +describe('resolveExistingStoreAuthScopes', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(adminUrl).mockReturnValue('https://shop.myshopify.com/admin/api/unstable/graphql.json') + }) + + afterEach(() => { + vi.restoreAllMocks() + mockAndCaptureOutput().clear() + }) + + test('returns no scopes when no stored auth exists', async () => { + vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue(undefined) + + await expect(resolveExistingStoreAuthScopes('shop.myshopify.com')).resolves.toEqual({scopes: [], authoritative: true}) + expect(loadStoredStoreSession).not.toHaveBeenCalled() + expect(graphqlRequest).not.toHaveBeenCalled() + }) + + test('prefers current remote scopes over stale local scopes', async () => { + vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue({ + store: 'shop.myshopify.com', + clientId: STORE_AUTH_APP_CLIENT_ID, + userId: '42', + accessToken: 'existing-token', + refreshToken: 'existing-refresh-token', + scopes: ['read_orders'], + acquiredAt: '2026-04-02T00:00:00.000Z', + }) + vi.mocked(loadStoredStoreSession).mockResolvedValue({ + store: 'shop.myshopify.com', + clientId: STORE_AUTH_APP_CLIENT_ID, + userId: '42', + accessToken: 'fresh-token', + refreshToken: 'fresh-refresh-token', + scopes: ['read_orders'], + acquiredAt: '2026-04-02T00:00:00.000Z', + } as any) + vi.mocked(graphqlRequest).mockResolvedValue({ + currentAppInstallation: {accessScopes: [{handle: 'read_products'}, {handle: 'read_customers'}]}, + } as any) + + await expect(resolveExistingStoreAuthScopes('shop.myshopify.com')).resolves.toEqual({ + scopes: ['read_products', 'read_customers'], + authoritative: true, + }) + expect(adminUrl).toHaveBeenCalledWith('shop.myshopify.com', 'unstable') + expect(graphqlRequest).toHaveBeenCalledWith({ + query: expect.stringContaining('currentAppInstallation'), + api: 'Admin', + url: 'https://shop.myshopify.com/admin/api/unstable/graphql.json', + token: 'fresh-token', + responseOptions: {handleErrors: false}, + }) + }) + + test('falls back to locally stored scopes when remote lookup fails', async () => { + vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue({ + store: 'shop.myshopify.com', + clientId: STORE_AUTH_APP_CLIENT_ID, + userId: '42', + accessToken: 'existing-token', + refreshToken: 'existing-refresh-token', + scopes: ['read_orders'], + acquiredAt: '2026-04-02T00:00:00.000Z', + }) + vi.mocked(loadStoredStoreSession).mockRejectedValue(new Error('boom')) + + await expect(resolveExistingStoreAuthScopes('shop.myshopify.com')).resolves.toEqual({ + scopes: ['read_orders'], + authoritative: false, + }) + }) + + test('falls back to locally stored scopes when access scopes request fails', async () => { + const output = mockAndCaptureOutput() + + vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue({ + store: 'shop.myshopify.com', + clientId: STORE_AUTH_APP_CLIENT_ID, + userId: '42', + accessToken: 'existing-token', + refreshToken: 'existing-refresh-token', + scopes: ['read_orders'], + acquiredAt: '2026-04-02T00:00:00.000Z', + }) + vi.mocked(loadStoredStoreSession).mockResolvedValue({ + store: 'shop.myshopify.com', + clientId: STORE_AUTH_APP_CLIENT_ID, + userId: '42', + accessToken: 'fresh-token', + refreshToken: 'fresh-refresh-token', + scopes: ['read_orders'], + acquiredAt: '2026-04-02T00:00:00.000Z', + } as any) + const scopeLookupError = new Error('GraphQL Error (Code: 401)') + Object.assign(scopeLookupError, { + response: { + status: 401, + errors: '[API] Invalid API key or access token (unrecognized login or wrong password)', + }, + request: { + query: '#graphql query CurrentAppInstallationAccessScopes { currentAppInstallation { accessScopes { handle } } }', + }, + }) + vi.mocked(graphqlRequest).mockRejectedValue(scopeLookupError) + + await expect(resolveExistingStoreAuthScopes('shop.myshopify.com')).resolves.toEqual({ + scopes: ['read_orders'], + authoritative: false, + }) + expect(output.debug()).toContain('after remote scope lookup failed: HTTP 401: [API] Invalid API key or access token') + expect(output.debug()).not.toContain('CurrentAppInstallationAccessScopes') + }) + + test('falls back to locally stored scopes when access scopes response is invalid', async () => { + vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue({ + store: 'shop.myshopify.com', + clientId: STORE_AUTH_APP_CLIENT_ID, + userId: '42', + accessToken: 'existing-token', + refreshToken: 'existing-refresh-token', + scopes: ['read_orders'], + acquiredAt: '2026-04-02T00:00:00.000Z', + }) + vi.mocked(loadStoredStoreSession).mockResolvedValue({ + store: 'shop.myshopify.com', + clientId: STORE_AUTH_APP_CLIENT_ID, + userId: '42', + accessToken: 'fresh-token', + refreshToken: 'fresh-refresh-token', + scopes: ['read_orders'], + acquiredAt: '2026-04-02T00:00:00.000Z', + } as any) + vi.mocked(graphqlRequest).mockResolvedValue({ + currentAppInstallation: undefined, + } as any) + + await expect(resolveExistingStoreAuthScopes('shop.myshopify.com')).resolves.toEqual({ + scopes: ['read_orders'], + authoritative: false, + }) + }) +}) diff --git a/packages/cli/src/cli/services/store/auth/existing-scopes.ts b/packages/cli/src/cli/services/store/auth/existing-scopes.ts new file mode 100644 index 00000000000..0dcc398e1be --- /dev/null +++ b/packages/cli/src/cli/services/store/auth/existing-scopes.ts @@ -0,0 +1,54 @@ +import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' +import {outputContent, outputDebug, outputToken} from '@shopify/cli-kit/node/output' +import {getCurrentStoredStoreAppSession} from './session-store.js' +import {loadStoredStoreSession} from './session-lifecycle.js' +import {fetchCurrentStoreAuthScopes} from './token-client.js' + +export interface ResolvedStoreAuthScopes { + scopes: string[] + authoritative: boolean +} + +function truncateDebugMessage(message: string, length = 300): string { + return message.slice(0, length) +} + +function formatStoreScopeLookupError(error: unknown): string { + if (error && typeof error === 'object' && 'response' in error) { + const response = (error as {response?: {status?: number; errors?: unknown}}).response + const status = response?.status + const details = response?.errors + + if (typeof status === 'number') { + const summary = typeof details === 'string' ? details : JSON.stringify(details) + return truncateDebugMessage(summary ? `HTTP ${status}: ${summary}` : `HTTP ${status}`) + } + } + + return truncateDebugMessage(error instanceof Error ? error.message : String(error)) +} + +export async function resolveExistingStoreAuthScopes(store: string): Promise { + const normalizedStore = normalizeStoreFqdn(store) + const storedSession = getCurrentStoredStoreAppSession(normalizedStore) + if (!storedSession) return {scopes: [], authoritative: true} + + try { + const usableSession = await loadStoredStoreSession(normalizedStore) + const remoteScopes = await fetchCurrentStoreAuthScopes({ + store: usableSession.store, + accessToken: usableSession.accessToken, + }) + + outputDebug( + outputContent`Resolved current remote scopes for ${outputToken.raw(normalizedStore)}: ${outputToken.raw(remoteScopes.join(',') || 'none')}`, + ) + + return {scopes: remoteScopes, authoritative: true} + } catch (error) { + outputDebug( + outputContent`Falling back to locally stored scopes for ${outputToken.raw(normalizedStore)} after remote scope lookup failed: ${outputToken.raw(formatStoreScopeLookupError(error))}`, + ) + return {scopes: storedSession.scopes, authoritative: false} + } +} diff --git a/packages/cli/src/cli/services/store/auth/index.test.ts b/packages/cli/src/cli/services/store/auth/index.test.ts new file mode 100644 index 00000000000..3d438c29770 --- /dev/null +++ b/packages/cli/src/cli/services/store/auth/index.test.ts @@ -0,0 +1,447 @@ +import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest' +import {authenticateStoreWithApp} from './index.js' +import {setStoredStoreAppSession} from './session-store.js' +import {STORE_AUTH_APP_CLIENT_ID} from './config.js' + +vi.mock('./session-store.js') +vi.mock('@shopify/cli-kit/node/system', () => ({openURL: vi.fn().mockResolvedValue(true)})) +vi.mock('@shopify/cli-kit/node/crypto', () => ({randomUUID: vi.fn().mockReturnValue('state-123')})) + +describe('store auth service', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + test('authenticateStoreWithApp opens the browser and stores the session with refresh token', async () => { + const openURL = vi.fn().mockResolvedValue(true) + const presenter = { + openingBrowser: vi.fn(), + manualAuthUrl: vi.fn(), + success: vi.fn(), + } + const waitForStoreAuthCodeMock = vi.fn().mockImplementation(async (options) => { + await options.onListening?.() + return 'abc123' + }) + + await authenticateStoreWithApp( + { + store: 'shop.myshopify.com', + scopes: 'read_products', + }, + { + openURL, + waitForStoreAuthCode: waitForStoreAuthCodeMock, + exchangeStoreAuthCodeForToken: vi.fn().mockResolvedValue({ + access_token: 'token', + scope: 'read_products', + expires_in: 86400, + refresh_token: 'refresh-token', + associated_user: {id: 42, email: 'test@example.com'}, + }), + presenter, + }, + ) + + expect(presenter.openingBrowser).toHaveBeenCalledOnce() + expect(openURL).toHaveBeenCalledWith(expect.stringContaining('/admin/oauth/authorize?')) + expect(presenter.manualAuthUrl).not.toHaveBeenCalled() + expect(presenter.success).toHaveBeenCalledWith('shop.myshopify.com', 'test@example.com') + + const storedSession = vi.mocked(setStoredStoreAppSession).mock.calls[0]![0] + expect(storedSession.store).toBe('shop.myshopify.com') + expect(storedSession.clientId).toBe(STORE_AUTH_APP_CLIENT_ID) + expect(storedSession.userId).toBe('42') + expect(storedSession.accessToken).toBe('token') + expect(storedSession.refreshToken).toBe('refresh-token') + expect(storedSession.scopes).toEqual(['read_products']) + expect(storedSession.expiresAt).toBeDefined() + expect(storedSession.associatedUser).toEqual({ + id: 42, + email: 'test@example.com', + firstName: undefined, + lastName: undefined, + accountOwner: undefined, + }) + }) + + test('authenticateStoreWithApp uses remote scopes by default when available', async () => { + const openURL = vi.fn().mockResolvedValue(true) + const presenter = { + openingBrowser: vi.fn(), + manualAuthUrl: vi.fn(), + success: vi.fn(), + } + const waitForStoreAuthCodeMock = vi.fn().mockImplementation(async (options) => { + await options.onListening?.() + return 'abc123' + }) + + await authenticateStoreWithApp( + { + store: 'shop.myshopify.com', + scopes: 'read_products', + }, + { + openURL, + waitForStoreAuthCode: waitForStoreAuthCodeMock, + exchangeStoreAuthCodeForToken: vi.fn().mockResolvedValue({ + access_token: 'token', + scope: 'read_customers,read_products', + expires_in: 86400, + associated_user: {id: 42, email: 'test@example.com'}, + }), + resolveExistingScopes: vi.fn().mockResolvedValue({scopes: ['read_customers'], authoritative: true}), + presenter, + }, + ) + + const authorizationUrl = new URL(openURL.mock.calls[0]![0]) + expect(authorizationUrl.searchParams.get('scope')).toBe('read_customers,read_products') + }) + + test('authenticateStoreWithApp reuses resolved existing scopes when requesting additional access', async () => { + const openURL = vi.fn().mockResolvedValue(true) + const presenter = { + openingBrowser: vi.fn(), + manualAuthUrl: vi.fn(), + success: vi.fn(), + } + const waitForStoreAuthCodeMock = vi.fn().mockImplementation(async (options) => { + await options.onListening?.() + return 'abc123' + }) + + await authenticateStoreWithApp( + { + store: 'shop.myshopify.com', + scopes: 'read_products', + }, + { + openURL, + waitForStoreAuthCode: waitForStoreAuthCodeMock, + exchangeStoreAuthCodeForToken: vi.fn().mockResolvedValue({ + access_token: 'token', + scope: 'read_orders,read_products', + expires_in: 86400, + associated_user: {id: 42, email: 'test@example.com'}, + }), + resolveExistingScopes: vi.fn().mockResolvedValue({scopes: ['read_orders'], authoritative: true}), + presenter, + }, + ) + + const authorizationUrl = new URL(openURL.mock.calls[0]![0]) + expect(authorizationUrl.searchParams.get('scope')).toBe('read_orders,read_products') + expect(setStoredStoreAppSession).toHaveBeenCalledWith( + expect.objectContaining({ + store: 'shop.myshopify.com', + scopes: ['read_orders', 'read_products'], + }), + ) + }) + + test('authenticateStoreWithApp does not require non-authoritative cached scopes to still be granted', async () => { + const openURL = vi.fn().mockResolvedValue(true) + const presenter = { + openingBrowser: vi.fn(), + manualAuthUrl: vi.fn(), + success: vi.fn(), + } + const waitForStoreAuthCodeMock = vi.fn().mockImplementation(async (options) => { + await options.onListening?.() + return 'abc123' + }) + + await authenticateStoreWithApp( + { + store: 'shop.myshopify.com', + scopes: 'read_products', + }, + { + openURL, + waitForStoreAuthCode: waitForStoreAuthCodeMock, + exchangeStoreAuthCodeForToken: vi.fn().mockResolvedValue({ + access_token: 'token', + scope: 'read_products', + expires_in: 86400, + associated_user: {id: 42, email: 'test@example.com'}, + }), + resolveExistingScopes: vi.fn().mockResolvedValue({scopes: ['read_orders'], authoritative: false}), + presenter, + }, + ) + + const authorizationUrl = new URL(openURL.mock.calls[0]![0]) + expect(authorizationUrl.searchParams.get('scope')).toBe('read_orders,read_products') + expect(setStoredStoreAppSession).toHaveBeenCalledWith( + expect.objectContaining({ + store: 'shop.myshopify.com', + scopes: ['read_products'], + }), + ) + }) + + test('authenticateStoreWithApp avoids requesting redundant read scopes already implied by existing write scopes', async () => { + const openURL = vi.fn().mockResolvedValue(true) + const presenter = { + openingBrowser: vi.fn(), + manualAuthUrl: vi.fn(), + success: vi.fn(), + } + const waitForStoreAuthCodeMock = vi.fn().mockImplementation(async (options) => { + await options.onListening?.() + return 'abc123' + }) + + await authenticateStoreWithApp( + { + store: 'shop.myshopify.com', + scopes: 'read_products', + }, + { + openURL, + waitForStoreAuthCode: waitForStoreAuthCodeMock, + exchangeStoreAuthCodeForToken: vi.fn().mockResolvedValue({ + access_token: 'token', + scope: 'write_products', + expires_in: 86400, + associated_user: {id: 42, email: 'test@example.com'}, + }), + resolveExistingScopes: vi.fn().mockResolvedValue({scopes: ['write_products'], authoritative: true}), + presenter, + }, + ) + + const authorizationUrl = new URL(openURL.mock.calls[0]![0]) + expect(authorizationUrl.searchParams.get('scope')).toBe('write_products') + expect(setStoredStoreAppSession).toHaveBeenCalledWith( + expect.objectContaining({ + store: 'shop.myshopify.com', + scopes: ['write_products'], + }), + ) + }) + + test('authenticateStoreWithApp shows a manual auth URL when the browser does not open automatically', async () => { + const openURL = vi.fn().mockResolvedValue(false) + const presenter = { + openingBrowser: vi.fn(), + manualAuthUrl: vi.fn(), + success: vi.fn(), + } + const waitForStoreAuthCodeMock = vi.fn().mockImplementation(async (options) => { + await options.onListening?.() + return 'abc123' + }) + + await authenticateStoreWithApp( + { + store: 'shop.myshopify.com', + scopes: 'read_products', + }, + { + openURL, + waitForStoreAuthCode: waitForStoreAuthCodeMock, + exchangeStoreAuthCodeForToken: vi.fn().mockResolvedValue({ + access_token: 'token', + scope: 'read_products', + expires_in: 86400, + associated_user: {id: 42, email: 'test@example.com'}, + }), + presenter, + }, + ) + + expect(presenter.openingBrowser).toHaveBeenCalledOnce() + expect(presenter.manualAuthUrl).toHaveBeenCalledWith( + expect.stringContaining('https://shop.myshopify.com/admin/oauth/authorize?'), + ) + expect(presenter.success).toHaveBeenCalledWith('shop.myshopify.com', 'test@example.com') + }) + + test('authenticateStoreWithApp rejects when Shopify grants fewer scopes than requested', async () => { + const waitForStoreAuthCodeMock = vi.fn().mockImplementation(async (options) => { + await options.onListening?.() + return 'abc123' + }) + + await expect( + authenticateStoreWithApp( + { + store: 'shop.myshopify.com', + scopes: 'read_products,write_products', + }, + { + openURL: vi.fn().mockResolvedValue(true), + waitForStoreAuthCode: waitForStoreAuthCodeMock, + exchangeStoreAuthCodeForToken: vi.fn().mockResolvedValue({ + access_token: 'token', + scope: 'read_products', + expires_in: 86400, + associated_user: {id: 42, email: 'test@example.com'}, + }), + presenter: { + openingBrowser: vi.fn(), + manualAuthUrl: vi.fn(), + success: vi.fn(), + }, + }, + ), + ).rejects.toMatchObject({ + message: 'Shopify granted fewer scopes than were requested.', + tryMessage: 'Missing scopes: write_products.', + nextSteps: [ + 'Update the app or store installation scopes.', + 'See https://shopify.dev/app/scopes', + 'Re-run shopify store auth.', + ], + }) + + expect(setStoredStoreAppSession).not.toHaveBeenCalled() + }) + + test('authenticateStoreWithApp accepts compressed write scopes that imply requested read scopes', async () => { + const waitForStoreAuthCodeMock = vi.fn().mockImplementation(async (options) => { + await options.onListening?.() + return 'abc123' + }) + + await authenticateStoreWithApp( + { + store: 'shop.myshopify.com', + scopes: 'read_products,write_products', + }, + { + openURL: vi.fn().mockResolvedValue(true), + waitForStoreAuthCode: waitForStoreAuthCodeMock, + exchangeStoreAuthCodeForToken: vi.fn().mockResolvedValue({ + access_token: 'token', + scope: 'write_products', + expires_in: 86400, + associated_user: {id: 42, email: 'test@example.com'}, + }), + presenter: { + openingBrowser: vi.fn(), + manualAuthUrl: vi.fn(), + success: vi.fn(), + }, + }, + ) + + expect(setStoredStoreAppSession).toHaveBeenCalledWith( + expect.objectContaining({ + store: 'shop.myshopify.com', + scopes: ['write_products'], + }), + ) + }) + + test('authenticateStoreWithApp still rejects when other requested scopes are missing', async () => { + const waitForStoreAuthCodeMock = vi.fn().mockImplementation(async (options) => { + await options.onListening?.() + return 'abc123' + }) + + await expect( + authenticateStoreWithApp( + { + store: 'shop.myshopify.com', + scopes: 'read_products,write_products,read_orders', + }, + { + openURL: vi.fn().mockResolvedValue(true), + waitForStoreAuthCode: waitForStoreAuthCodeMock, + exchangeStoreAuthCodeForToken: vi.fn().mockResolvedValue({ + access_token: 'token', + scope: 'write_products', + expires_in: 86400, + associated_user: {id: 42, email: 'test@example.com'}, + }), + presenter: { + openingBrowser: vi.fn(), + manualAuthUrl: vi.fn(), + success: vi.fn(), + }, + }, + ), + ).rejects.toThrow('Shopify granted fewer scopes than were requested.') + + expect(setStoredStoreAppSession).not.toHaveBeenCalled() + }) + + test('authenticateStoreWithApp falls back to requested scopes when Shopify omits granted scopes', async () => { + const waitForStoreAuthCodeMock = vi.fn().mockImplementation(async (options) => { + await options.onListening?.() + return 'abc123' + }) + + await authenticateStoreWithApp( + { + store: 'shop.myshopify.com', + scopes: 'read_products', + }, + { + openURL: vi.fn().mockResolvedValue(true), + waitForStoreAuthCode: waitForStoreAuthCodeMock, + exchangeStoreAuthCodeForToken: vi.fn().mockResolvedValue({ + access_token: 'token', + expires_in: 86400, + associated_user: {id: 42, email: 'test@example.com'}, + }), + presenter: { + openingBrowser: vi.fn(), + manualAuthUrl: vi.fn(), + success: vi.fn(), + }, + }, + ) + + expect(setStoredStoreAppSession).toHaveBeenCalledWith( + expect.objectContaining({ + store: 'shop.myshopify.com', + scopes: ['read_products'], + }), + ) + }) + + test('authenticateStoreWithApp accepts compressed unauthenticated write scopes that imply requested unauthenticated read scopes', async () => { + const waitForStoreAuthCodeMock = vi.fn().mockImplementation(async (options) => { + await options.onListening?.() + return 'abc123' + }) + + await authenticateStoreWithApp( + { + store: 'shop.myshopify.com', + scopes: 'unauthenticated_read_product_listings,unauthenticated_write_product_listings', + }, + { + openURL: vi.fn().mockResolvedValue(true), + waitForStoreAuthCode: waitForStoreAuthCodeMock, + exchangeStoreAuthCodeForToken: vi.fn().mockResolvedValue({ + access_token: 'token', + scope: 'unauthenticated_write_product_listings', + expires_in: 86400, + associated_user: {id: 42, email: 'test@example.com'}, + }), + presenter: { + openingBrowser: vi.fn(), + manualAuthUrl: vi.fn(), + success: vi.fn(), + }, + }, + ) + + expect(setStoredStoreAppSession).toHaveBeenCalledWith( + expect.objectContaining({ + store: 'shop.myshopify.com', + scopes: ['unauthenticated_write_product_listings'], + }), + ) + }) +}) diff --git a/packages/cli/src/cli/services/store/auth/index.ts b/packages/cli/src/cli/services/store/auth/index.ts new file mode 100644 index 00000000000..d96e287f364 --- /dev/null +++ b/packages/cli/src/cli/services/store/auth/index.ts @@ -0,0 +1,134 @@ +import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' +import {AbortError} from '@shopify/cli-kit/node/error' +import {outputCompleted, outputContent, outputDebug, outputInfo, outputToken} from '@shopify/cli-kit/node/output' +import {openURL} from '@shopify/cli-kit/node/system' +import {STORE_AUTH_APP_CLIENT_ID} from './config.js' +import {setStoredStoreAppSession} from './session-store.js' +import {exchangeStoreAuthCodeForToken} from './token-client.js' +import {waitForStoreAuthCode} from './callback.js' +import {createPkceBootstrap} from './pkce.js' +import {mergeRequestedAndStoredScopes, parseStoreAuthScopes, resolveGrantedScopes} from './scopes.js' +import {resolveExistingStoreAuthScopes, type ResolvedStoreAuthScopes} from './existing-scopes.js' + +interface StoreAuthInput { + store: string + scopes: string +} + +interface StoreAuthPresenter { + openingBrowser: () => void + manualAuthUrl: (authorizationUrl: string) => void + success: (store: string, email?: string) => void +} + +interface StoreAuthDependencies { + openURL: typeof openURL + waitForStoreAuthCode: typeof waitForStoreAuthCode + exchangeStoreAuthCodeForToken: typeof exchangeStoreAuthCodeForToken + resolveExistingScopes: (store: string) => Promise + presenter: StoreAuthPresenter +} + +const defaultStoreAuthPresenter: StoreAuthPresenter = { + openingBrowser() { + outputInfo('Shopify CLI will open the app authorization page in your browser.') + outputInfo('') + }, + manualAuthUrl(authorizationUrl: string) { + outputInfo('Browser did not open automatically. Open this URL manually:') + outputInfo(outputContent`${outputToken.link(authorizationUrl)}`) + outputInfo('') + }, + success(store: string, email?: string) { + const displayName = email ? ` as ${email}` : '' + + outputCompleted('Logged in.') + outputCompleted(`Authenticated${displayName} against ${store}.`) + outputInfo('') + outputInfo('To verify that authentication worked, run:') + outputInfo(`shopify store execute --store ${store} --query 'query { shop { name id } }'`) + }, +} + +const defaultStoreAuthDependencies: StoreAuthDependencies = { + openURL, + waitForStoreAuthCode, + exchangeStoreAuthCodeForToken, + resolveExistingScopes: resolveExistingStoreAuthScopes, + presenter: defaultStoreAuthPresenter, +} + +export async function authenticateStoreWithApp( + input: StoreAuthInput, + dependencies: Partial = {}, +): Promise { + const resolvedDependencies: StoreAuthDependencies = {...defaultStoreAuthDependencies, ...dependencies} + const store = normalizeStoreFqdn(input.store) + const requestedScopes = parseStoreAuthScopes(input.scopes) + const existingScopeResolution = await resolvedDependencies.resolveExistingScopes(store) + const scopes = mergeRequestedAndStoredScopes(requestedScopes, existingScopeResolution.scopes) + const validationScopes = existingScopeResolution.authoritative ? scopes : requestedScopes + + if (existingScopeResolution.scopes.length > 0) { + outputDebug( + outputContent`Merged requested scopes ${outputToken.raw(requestedScopes.join(','))} with existing scopes ${outputToken.raw(existingScopeResolution.scopes.join(','))} for ${outputToken.raw(store)}`, + ) + } + + const bootstrap = createPkceBootstrap({ + store, + scopes, + exchangeCodeForToken: resolvedDependencies.exchangeStoreAuthCodeForToken, + }) + const { + authorization: {authorizationUrl}, + } = bootstrap + + resolvedDependencies.presenter.openingBrowser() + + const code = await resolvedDependencies.waitForStoreAuthCode({ + ...bootstrap.waitForAuthCodeOptions, + onListening: async () => { + const opened = await resolvedDependencies.openURL(authorizationUrl) + if (!opened) resolvedDependencies.presenter.manualAuthUrl(authorizationUrl) + }, + }) + const tokenResponse = await bootstrap.exchangeCodeForToken(code) + + const userId = tokenResponse.associated_user?.id?.toString() + if (!userId) { + throw new AbortError('Shopify did not return associated user information for the online access token.') + } + + const now = Date.now() + const expiresAt = tokenResponse.expires_in ? new Date(now + tokenResponse.expires_in * 1000).toISOString() : undefined + + setStoredStoreAppSession({ + store, + clientId: STORE_AUTH_APP_CLIENT_ID, + userId, + accessToken: tokenResponse.access_token, + refreshToken: tokenResponse.refresh_token, + scopes: resolveGrantedScopes(tokenResponse, validationScopes), + acquiredAt: new Date(now).toISOString(), + expiresAt, + refreshTokenExpiresAt: tokenResponse.refresh_token_expires_in + ? new Date(now + tokenResponse.refresh_token_expires_in * 1000).toISOString() + : undefined, + associatedUser: tokenResponse.associated_user + ? { + id: tokenResponse.associated_user.id, + email: tokenResponse.associated_user.email, + firstName: tokenResponse.associated_user.first_name, + lastName: tokenResponse.associated_user.last_name, + accountOwner: tokenResponse.associated_user.account_owner, + } + : undefined, + }) + + outputDebug( + outputContent`Session persisted for ${outputToken.raw(store)} (user ${outputToken.raw(userId)}, expires ${outputToken.raw(expiresAt ?? 'unknown')})`, + ) + + resolvedDependencies.presenter.success(store, tokenResponse.associated_user?.email) +} diff --git a/packages/cli/src/cli/services/store/auth/pkce.test.ts b/packages/cli/src/cli/services/store/auth/pkce.test.ts new file mode 100644 index 00000000000..d096446c94d --- /dev/null +++ b/packages/cli/src/cli/services/store/auth/pkce.test.ts @@ -0,0 +1,45 @@ +import {describe, expect, test} from 'vitest' +import {STORE_AUTH_APP_CLIENT_ID} from './config.js' +import {buildStoreAuthUrl, computeCodeChallenge, generateCodeVerifier} from './pkce.js' + +describe('store auth PKCE helpers', () => { + test('generateCodeVerifier produces a base64url string of 43 chars', () => { + const verifier = generateCodeVerifier() + expect(verifier).toMatch(/^[A-Za-z0-9_-]{43}$/) + }) + + test('generateCodeVerifier produces unique values', () => { + const a = generateCodeVerifier() + const b = generateCodeVerifier() + expect(a).not.toBe(b) + }) + + test('computeCodeChallenge produces a deterministic S256 hash', () => { + const verifier = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk' + const expected = 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM' + expect(computeCodeChallenge(verifier)).toBe(expected) + }) + + test('buildStoreAuthUrl includes PKCE params and response_type=code', () => { + const url = new URL( + buildStoreAuthUrl({ + store: 'shop.myshopify.com', + scopes: ['read_products', 'write_products'], + state: 'state-123', + redirectUri: 'http://127.0.0.1:13387/auth/callback', + codeChallenge: 'test-challenge-value', + }), + ) + + expect(url.hostname).toBe('shop.myshopify.com') + expect(url.pathname).toBe('/admin/oauth/authorize') + expect(url.searchParams.get('client_id')).toBe(STORE_AUTH_APP_CLIENT_ID) + expect(url.searchParams.get('scope')).toBe('read_products,write_products') + expect(url.searchParams.get('state')).toBe('state-123') + expect(url.searchParams.get('redirect_uri')).toBe('http://127.0.0.1:13387/auth/callback') + expect(url.searchParams.get('response_type')).toBe('code') + expect(url.searchParams.get('code_challenge')).toBe('test-challenge-value') + expect(url.searchParams.get('code_challenge_method')).toBe('S256') + expect(url.searchParams.get('grant_options[]')).toBeNull() + }) +}) diff --git a/packages/cli/src/cli/services/store/auth/pkce.ts b/packages/cli/src/cli/services/store/auth/pkce.ts new file mode 100644 index 00000000000..c9944eee30f --- /dev/null +++ b/packages/cli/src/cli/services/store/auth/pkce.ts @@ -0,0 +1,90 @@ +import {randomUUID} from '@shopify/cli-kit/node/crypto' +import {outputContent, outputDebug, outputToken} from '@shopify/cli-kit/node/output' +import {createHash, randomBytes} from 'crypto' +import {DEFAULT_STORE_AUTH_PORT, STORE_AUTH_APP_CLIENT_ID, storeAuthRedirectUri} from './config.js' +import type {StoreTokenResponse} from './token-client.js' +import type {WaitForAuthCodeOptions} from './callback.js' + +interface StoreAuthorizationContext { + store: string + scopes: string[] + state: string + port: number + redirectUri: string + authorizationUrl: string + codeVerifier: string +} + +interface StoreAuthBootstrap { + authorization: StoreAuthorizationContext + waitForAuthCodeOptions: WaitForAuthCodeOptions + exchangeCodeForToken: (code: string) => Promise +} + +export function generateCodeVerifier(): string { + return randomBytes(32).toString('base64url') +} + +export function computeCodeChallenge(verifier: string): string { + return createHash('sha256').update(verifier).digest('base64url') +} + +export function buildStoreAuthUrl(options: { + store: string + scopes: string[] + state: string + redirectUri: string + codeChallenge: string +}): string { + const params = new URLSearchParams() + params.set('client_id', STORE_AUTH_APP_CLIENT_ID) + params.set('scope', options.scopes.join(',')) + params.set('redirect_uri', options.redirectUri) + params.set('state', options.state) + params.set('response_type', 'code') + params.set('code_challenge', options.codeChallenge) + params.set('code_challenge_method', 'S256') + + return `https://${options.store}/admin/oauth/authorize?${params.toString()}` +} + +export function createPkceBootstrap(options: { + store: string + scopes: string[] + exchangeCodeForToken: (options: { + store: string + code: string + codeVerifier: string + redirectUri: string + }) => Promise +}): StoreAuthBootstrap { + const {store, scopes, exchangeCodeForToken} = options + const port = DEFAULT_STORE_AUTH_PORT + const state = randomUUID() + const redirectUri = storeAuthRedirectUri(port) + const codeVerifier = generateCodeVerifier() + const codeChallenge = computeCodeChallenge(codeVerifier) + const authorizationUrl = buildStoreAuthUrl({store, scopes, state, redirectUri, codeChallenge}) + + outputDebug( + outputContent`Starting PKCE auth for ${outputToken.raw(store)} with scopes ${outputToken.raw(scopes.join(','))} (redirect_uri=${outputToken.raw(redirectUri)})`, + ) + + return { + authorization: { + store, + scopes, + state, + port, + redirectUri, + authorizationUrl, + codeVerifier, + }, + waitForAuthCodeOptions: { + store, + state, + port, + }, + exchangeCodeForToken: (code: string) => exchangeCodeForToken({store, code, codeVerifier, redirectUri}), + } +} diff --git a/packages/cli/src/cli/services/store/auth-recovery.ts b/packages/cli/src/cli/services/store/auth/recovery.ts similarity index 100% rename from packages/cli/src/cli/services/store/auth-recovery.ts rename to packages/cli/src/cli/services/store/auth/recovery.ts diff --git a/packages/cli/src/cli/services/store/auth/scopes.test.ts b/packages/cli/src/cli/services/store/auth/scopes.test.ts new file mode 100644 index 00000000000..6df7fa07512 --- /dev/null +++ b/packages/cli/src/cli/services/store/auth/scopes.test.ts @@ -0,0 +1,54 @@ +import {describe, expect, test} from 'vitest' +import {mergeRequestedAndStoredScopes, parseStoreAuthScopes, resolveGrantedScopes} from './scopes.js' + +describe('store auth scope helpers', () => { + test('parseStoreAuthScopes splits and deduplicates scopes', () => { + expect(parseStoreAuthScopes('read_products, write_products,read_products')).toEqual([ + 'read_products', + 'write_products', + ]) + }) + + test('mergeRequestedAndStoredScopes avoids redundant reads already implied by existing writes', () => { + expect(mergeRequestedAndStoredScopes(['read_products'], ['write_products'])).toEqual(['write_products']) + }) + + test('mergeRequestedAndStoredScopes adds newly requested scopes', () => { + expect(mergeRequestedAndStoredScopes(['read_products'], ['read_orders'])).toEqual(['read_orders', 'read_products']) + }) + + test('resolveGrantedScopes accepts compressed write scopes that imply requested reads', () => { + expect( + resolveGrantedScopes( + { + access_token: 'token', + scope: 'write_products', + }, + ['read_products', 'write_products'], + ), + ).toEqual(['write_products']) + }) + + test('resolveGrantedScopes falls back to requested scopes when Shopify omits scope', () => { + expect( + resolveGrantedScopes( + { + access_token: 'token', + }, + ['read_products'], + ), + ).toEqual(['read_products']) + }) + + test('resolveGrantedScopes rejects when required scopes are missing', () => { + expect(() => + resolveGrantedScopes( + { + access_token: 'token', + scope: 'read_products', + }, + ['read_products', 'write_products'], + ), + ).toThrow('Shopify granted fewer scopes than were requested.') + }) +}) diff --git a/packages/cli/src/cli/services/store/auth/scopes.ts b/packages/cli/src/cli/services/store/auth/scopes.ts new file mode 100644 index 00000000000..efe8a2b5913 --- /dev/null +++ b/packages/cli/src/cli/services/store/auth/scopes.ts @@ -0,0 +1,70 @@ +import {AbortError} from '@shopify/cli-kit/node/error' +import {outputContent, outputDebug} from '@shopify/cli-kit/node/output' +import type {StoreTokenResponse} from './token-client.js' + +export function parseStoreAuthScopes(input: string): string[] { + const scopes = input + .split(',') + .map((scope) => scope.trim()) + .filter(Boolean) + + if (scopes.length === 0) { + throw new AbortError('At least one scope is required.', 'Pass --scopes as a comma-separated list.') + } + + return [...new Set(scopes)] +} + +function expandImpliedStoreScopes(scopes: string[]): Set { + const expandedScopes = new Set(scopes) + + for (const scope of scopes) { + const matches = scope.match(/^(unauthenticated_)?write_(.*)$/) + if (matches) { + expandedScopes.add(`${matches[1] ?? ''}read_${matches[2]}`) + } + } + + return expandedScopes +} + +export function mergeRequestedAndStoredScopes(requestedScopes: string[], storedScopes: string[]): string[] { + const mergedScopes = [...storedScopes] + const expandedScopes = expandImpliedStoreScopes(storedScopes) + + for (const scope of requestedScopes) { + if (expandedScopes.has(scope)) continue + + mergedScopes.push(scope) + for (const expandedScope of expandImpliedStoreScopes([scope])) { + expandedScopes.add(expandedScope) + } + } + + return mergedScopes +} + +export function resolveGrantedScopes(tokenResponse: StoreTokenResponse, requestedScopes: string[]): string[] { + if (!tokenResponse.scope) { + outputDebug(outputContent`Token response did not include scope; falling back to requested scopes`) + return requestedScopes + } + + const grantedScopes = parseStoreAuthScopes(tokenResponse.scope) + const expandedGrantedScopes = expandImpliedStoreScopes(grantedScopes) + const missingScopes = requestedScopes.filter((scope) => !expandedGrantedScopes.has(scope)) + + if (missingScopes.length > 0) { + throw new AbortError( + 'Shopify granted fewer scopes than were requested.', + `Missing scopes: ${missingScopes.join(', ')}.`, + [ + 'Update the app or store installation scopes.', + 'See https://shopify.dev/app/scopes', + 'Re-run shopify store auth.', + ], + ) + } + + return grantedScopes +} diff --git a/packages/cli/src/cli/services/store/auth/session-lifecycle.test.ts b/packages/cli/src/cli/services/store/auth/session-lifecycle.test.ts new file mode 100644 index 00000000000..c1d3c652870 --- /dev/null +++ b/packages/cli/src/cli/services/store/auth/session-lifecycle.test.ts @@ -0,0 +1,179 @@ +import {beforeEach, describe, expect, test, vi} from 'vitest' +import {AbortError} from '@shopify/cli-kit/node/error' +import { + isSessionExpired, + loadStoredStoreSession, +} from './session-lifecycle.js' +import { + clearStoredStoreAppSession, + getCurrentStoredStoreAppSession, + setStoredStoreAppSession, + type StoredStoreAppSession, +} from './session-store.js' +import {refreshStoreAccessToken} from './token-client.js' +import {STORE_AUTH_APP_CLIENT_ID} from './config.js' + +vi.mock('./session-store.js') +vi.mock('./token-client.js') + +function buildSession(overrides: Partial = {}): StoredStoreAppSession { + return { + store: 'shop.myshopify.com', + clientId: STORE_AUTH_APP_CLIENT_ID, + userId: '42', + accessToken: 'token', + refreshToken: 'refresh-token', + scopes: ['read_products'], + acquiredAt: '2026-03-27T00:00:00.000Z', + associatedUser: {id: 42, email: 'merchant@example.com'}, + ...overrides, + } +} + +describe('isSessionExpired', () => { + test('returns false when expiresAt is not set', () => { + expect(isSessionExpired(buildSession())).toBe(false) + }) + + test('returns false when token is still valid', () => { + const future = new Date(Date.now() + 60 * 60 * 1000).toISOString() + expect(isSessionExpired(buildSession({expiresAt: future}))).toBe(false) + }) + + test('returns true when token is expired', () => { + const past = new Date(Date.now() - 60 * 1000).toISOString() + expect(isSessionExpired(buildSession({expiresAt: past}))).toBe(true) + }) + + test('returns true within the 4-minute expiry margin', () => { + const almostExpired = new Date(Date.now() + 3 * 60 * 1000).toISOString() + expect(isSessionExpired(buildSession({expiresAt: almostExpired}))).toBe(true) + }) + + test('returns false just outside the 4-minute expiry margin', () => { + const safelyValid = new Date(Date.now() + 5 * 60 * 1000).toISOString() + expect(isSessionExpired(buildSession({expiresAt: safelyValid}))).toBe(false) + }) + + test('returns true when expiresAt is invalid', () => { + expect(isSessionExpired(buildSession({expiresAt: 'not-a-date'}))).toBe(true) + }) +}) + +describe('loadStoredStoreSession', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + test('throws when no stored auth exists', async () => { + vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue(undefined) + + await expect(loadStoredStoreSession('shop.myshopify.com')).rejects.toMatchObject({ + message: 'No stored app authentication found for shop.myshopify.com.', + }) + }) + + test('returns the current stored session when it is still valid', async () => { + const session = buildSession({expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString()}) + vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue(session) + + await expect(loadStoredStoreSession('shop.myshopify.com')).resolves.toEqual(session) + expect(refreshStoreAccessToken).not.toHaveBeenCalled() + expect(setStoredStoreAppSession).not.toHaveBeenCalled() + }) + + test('throws when an expired session has no refresh token', async () => { + vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue( + buildSession({refreshToken: undefined, expiresAt: new Date(Date.now() - 60 * 1000).toISOString()}), + ) + + await expect(loadStoredStoreSession('shop.myshopify.com')).rejects.toMatchObject({ + message: 'No refresh token stored for shop.myshopify.com.', + }) + expect(clearStoredStoreAppSession).not.toHaveBeenCalled() + }) + + test('refreshes expired sessions and persists the refreshed identity-preserving session', async () => { + const session = buildSession({expiresAt: new Date(Date.now() - 60 * 1000).toISOString()}) + vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue(session) + vi.mocked(refreshStoreAccessToken).mockResolvedValue({ + accessToken: 'fresh-token', + refreshToken: 'fresh-refresh-token', + expiresIn: 3600, + refreshTokenExpiresIn: 7200, + }) + + const refreshed = await loadStoredStoreSession('shop.myshopify.com') + + expect(refreshStoreAccessToken).toHaveBeenCalledWith({ + store: 'shop.myshopify.com', + refreshToken: 'refresh-token', + }) + expect(setStoredStoreAppSession).toHaveBeenCalledWith( + expect.objectContaining({ + store: session.store, + clientId: session.clientId, + userId: session.userId, + scopes: session.scopes, + associatedUser: session.associatedUser, + accessToken: 'fresh-token', + refreshToken: 'fresh-refresh-token', + }), + ) + expect(refreshed).toEqual(expect.objectContaining({accessToken: 'fresh-token', userId: '42'})) + }) + + test('preserves existing optional refresh fields when Shopify omits them', async () => { + const session = buildSession({ + expiresAt: new Date(Date.now() - 60 * 1000).toISOString(), + refreshTokenExpiresAt: '2026-04-03T00:00:00.000Z', + }) + vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue(session) + vi.mocked(refreshStoreAccessToken).mockResolvedValue({ + accessToken: 'fresh-token', + }) + + const refreshed = await loadStoredStoreSession('shop.myshopify.com') + + expect(refreshed.refreshToken).toBe('refresh-token') + expect(refreshed.refreshTokenExpiresAt).toBe('2026-04-03T00:00:00.000Z') + expect(refreshed.expiresAt).toBe(session.expiresAt) + }) + + test('clears only the current stored auth and throws re-auth when refresh fails', async () => { + const session = buildSession({expiresAt: new Date(Date.now() - 60 * 1000).toISOString()}) + vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue(session) + vi.mocked(refreshStoreAccessToken).mockRejectedValue( + new AbortError('Token refresh failed for shop.myshopify.com (HTTP 401).'), + ) + + await expect(loadStoredStoreSession('shop.myshopify.com')).rejects.toMatchObject({ + message: 'Token refresh failed for shop.myshopify.com (HTTP 401).', + tryMessage: 'To re-authenticate, run:', + }) + expect(clearStoredStoreAppSession).toHaveBeenCalledWith('shop.myshopify.com', '42') + }) + + test('clears only the current stored auth and throws on malformed refresh JSON', async () => { + const session = buildSession({expiresAt: new Date(Date.now() - 60 * 1000).toISOString()}) + vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue(session) + vi.mocked(refreshStoreAccessToken).mockRejectedValue(new AbortError('Received an invalid refresh response from Shopify.')) + + await expect(loadStoredStoreSession('shop.myshopify.com')).rejects.toThrow('Received an invalid refresh response') + expect(clearStoredStoreAppSession).toHaveBeenCalledWith('shop.myshopify.com', '42') + }) + + test('clears only the current stored auth and throws re-auth when refresh returns an invalid response', async () => { + const session = buildSession({expiresAt: new Date(Date.now() - 60 * 1000).toISOString()}) + vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue(session) + vi.mocked(refreshStoreAccessToken).mockRejectedValue( + new AbortError('Token refresh returned an invalid response for shop.myshopify.com.'), + ) + + await expect(loadStoredStoreSession('shop.myshopify.com')).rejects.toMatchObject({ + message: 'Token refresh returned an invalid response for shop.myshopify.com.', + tryMessage: 'To re-authenticate, run:', + }) + expect(clearStoredStoreAppSession).toHaveBeenCalledWith('shop.myshopify.com', '42') + }) +}) diff --git a/packages/cli/src/cli/services/store/auth/session-lifecycle.ts b/packages/cli/src/cli/services/store/auth/session-lifecycle.ts new file mode 100644 index 00000000000..309e6db71a2 --- /dev/null +++ b/packages/cli/src/cli/services/store/auth/session-lifecycle.ts @@ -0,0 +1,105 @@ +import {AbortError} from '@shopify/cli-kit/node/error' +import {outputContent, outputDebug, outputToken} from '@shopify/cli-kit/node/output' +import {maskToken} from './config.js' +import {createStoredStoreAuthError, reauthenticateStoreAuthError} from './recovery.js' +import { + clearStoredStoreAppSession, + getCurrentStoredStoreAppSession, + setStoredStoreAppSession, +} from './session-store.js' +import type {StoredStoreAppSession} from './session-store.js' +import {refreshStoreAccessToken} from './token-client.js' + +const EXPIRY_MARGIN_MS = 4 * 60 * 1000 + +export function isSessionExpired(session: StoredStoreAppSession): boolean { + if (!session.expiresAt) return false + + const expiresAtMs = new Date(session.expiresAt).getTime() + if (Number.isNaN(expiresAtMs)) return true + + return expiresAtMs - EXPIRY_MARGIN_MS < Date.now() +} + +function buildRefreshedStoredSession( + session: StoredStoreAppSession, + refresh: { + accessToken: string + refreshToken?: string + expiresIn?: number + refreshTokenExpiresIn?: number + }, +): StoredStoreAppSession { + const now = Date.now() + const expiresAt = refresh.expiresIn ? new Date(now + refresh.expiresIn * 1000).toISOString() : session.expiresAt + + return { + ...session, + accessToken: refresh.accessToken, + refreshToken: refresh.refreshToken ?? session.refreshToken, + expiresAt, + refreshTokenExpiresAt: refresh.refreshTokenExpiresIn + ? new Date(now + refresh.refreshTokenExpiresIn * 1000).toISOString() + : session.refreshTokenExpiresAt, + acquiredAt: new Date(now).toISOString(), + } +} + +export async function loadStoredStoreSession(store: string): Promise { + let session = getCurrentStoredStoreAppSession(store) + + if (!session) { + throw createStoredStoreAuthError(store) + } + + outputDebug( + outputContent`Loaded stored session for ${outputToken.raw(store)}: token=${outputToken.raw(maskToken(session.accessToken))}, expires=${outputToken.raw(session.expiresAt ?? 'unknown')}`, + ) + + if (!isSessionExpired(session)) { + return session + } + + if (!session.refreshToken) { + throw reauthenticateStoreAuthError(`No refresh token stored for ${session.store}.`, session.store, session.scopes.join(',')) + } + + outputDebug( + outputContent`Refreshing expired token for ${outputToken.raw(session.store)} (expired at ${outputToken.raw(session.expiresAt ?? 'unknown')}, refresh_token=${outputToken.raw(maskToken(session.refreshToken))})`, + ) + + const previousAccessToken = session.accessToken + + let refreshed + try { + refreshed = await refreshStoreAccessToken({ + store: session.store, + refreshToken: session.refreshToken, + }) + } catch (error) { + clearStoredStoreAppSession(session.store, session.userId) + + if (error instanceof AbortError && error.message.startsWith(`Token refresh failed for ${session.store} (HTTP `)) { + throw reauthenticateStoreAuthError(error.message, session.store, session.scopes.join(',')) + } + + if (error instanceof AbortError && error.message === `Token refresh returned an invalid response for ${session.store}.`) { + throw reauthenticateStoreAuthError(error.message, session.store, session.scopes.join(',')) + } + + if (error instanceof AbortError && error.message === 'Received an invalid refresh response from Shopify.') { + throw error + } + + throw error + } + + session = buildRefreshedStoredSession(session, refreshed) + + outputDebug( + outputContent`Token refresh succeeded for ${outputToken.raw(session.store)}: ${outputToken.raw(maskToken(previousAccessToken))} → ${outputToken.raw(maskToken(session.accessToken))}, new expiry ${outputToken.raw(session.expiresAt ?? 'unknown')}`, + ) + + setStoredStoreAppSession(session) + return session +} diff --git a/packages/cli/src/cli/services/store/session.test.ts b/packages/cli/src/cli/services/store/auth/session-store.test.ts similarity index 50% rename from packages/cli/src/cli/services/store/session.test.ts rename to packages/cli/src/cli/services/store/auth/session-store.test.ts index 373fb0d4286..db501895604 100644 --- a/packages/cli/src/cli/services/store/session.test.ts +++ b/packages/cli/src/cli/services/store/auth/session-store.test.ts @@ -1,13 +1,12 @@ import {describe, test, expect} from 'vitest' import {LocalStorage} from '@shopify/cli-kit/node/local-storage' -import {STORE_AUTH_APP_CLIENT_ID, storeAuthSessionKey} from './auth-config.js' +import {STORE_AUTH_APP_CLIENT_ID, storeAuthSessionKey} from './config.js' import { clearStoredStoreAppSession, - getStoredStoreAppSession, + getCurrentStoredStoreAppSession, setStoredStoreAppSession, - isSessionExpired, type StoredStoreAppSession, -} from './session.js' +} from './session-store.js' function inMemoryStorage() { const values = new Map() @@ -43,7 +42,7 @@ describe('store session storage', () => { setStoredStoreAppSession(buildSession(), storage as any) - expect(getStoredStoreAppSession('shop.myshopify.com', storage as any)).toEqual(buildSession()) + expect(getCurrentStoredStoreAppSession('shop.myshopify.com', storage as any)).toEqual(buildSession()) }) test('keeps multiple user sessions per store and returns the current one', () => { @@ -54,7 +53,7 @@ describe('store session storage', () => { setStoredStoreAppSession(firstSession, storage as any) setStoredStoreAppSession(secondSession, storage as any) - expect(getStoredStoreAppSession('shop.myshopify.com', storage as any)).toEqual(secondSession) + expect(getCurrentStoredStoreAppSession('shop.myshopify.com', storage as any)).toEqual(secondSession) }) test('clears all stored sessions for a store', () => { @@ -63,7 +62,7 @@ describe('store session storage', () => { setStoredStoreAppSession(buildSession(), storage as any) clearStoredStoreAppSession('shop.myshopify.com', storage as any) - expect(getStoredStoreAppSession('shop.myshopify.com', storage as any)).toBeUndefined() + expect(getCurrentStoredStoreAppSession('shop.myshopify.com', storage as any)).toBeUndefined() }) test('clears only the specified user session and preserves the rest of the bucket', () => { @@ -75,7 +74,7 @@ describe('store session storage', () => { setStoredStoreAppSession(secondSession, storage as any) clearStoredStoreAppSession('shop.myshopify.com', '84', storage as any) - expect(getStoredStoreAppSession('shop.myshopify.com', storage as any)).toEqual(firstSession) + expect(getCurrentStoredStoreAppSession('shop.myshopify.com', storage as any)).toEqual(firstSession) }) test('returns undefined and clears the bucket when the current user session is missing', () => { @@ -87,7 +86,7 @@ describe('store session storage', () => { }, }) - expect(getStoredStoreAppSession('shop.myshopify.com', storage as any)).toBeUndefined() + expect(getCurrentStoredStoreAppSession('shop.myshopify.com', storage as any)).toBeUndefined() expect(storage.get(storeAuthSessionKey('shop.myshopify.com'))).toBeUndefined() }) @@ -98,37 +97,73 @@ describe('store session storage', () => { sessionsByUserId: null, }) - expect(getStoredStoreAppSession('shop.myshopify.com', storage as any)).toBeUndefined() + expect(getCurrentStoredStoreAppSession('shop.myshopify.com', storage as any)).toBeUndefined() expect(storage.get(storeAuthSessionKey('shop.myshopify.com'))).toBeUndefined() }) -}) -describe('isSessionExpired', () => { - test('returns false when expiresAt is not set', () => { - expect(isSessionExpired(buildSession())).toBe(false) - }) + test('returns undefined and clears the bucket when the current stored session is malformed', () => { + const storage = inMemoryStorage() + storage.set(storeAuthSessionKey('shop.myshopify.com'), { + currentUserId: '42', + sessionsByUserId: { + '42': {userId: '42'}, + }, + }) - test('returns false when token is still valid', () => { - const future = new Date(Date.now() + 60 * 60 * 1000).toISOString() - expect(isSessionExpired(buildSession({expiresAt: future}))).toBe(false) + expect(getCurrentStoredStoreAppSession('shop.myshopify.com', storage as any)).toBeUndefined() + expect(storage.get(storeAuthSessionKey('shop.myshopify.com'))).toBeUndefined() }) - test('returns true when token is expired', () => { - const past = new Date(Date.now() - 60 * 1000).toISOString() - expect(isSessionExpired(buildSession({expiresAt: past}))).toBe(true) - }) + test('drops malformed optional fields from a stored session instead of rejecting the whole session', () => { + const storage = inMemoryStorage() + storage.set(storeAuthSessionKey('shop.myshopify.com'), { + currentUserId: '42', + sessionsByUserId: { + '42': { + ...buildSession(), + refreshToken: 123, + expiresAt: 456, + refreshTokenExpiresAt: true, + associatedUser: { + id: 42, + email: 123, + firstName: 'Merchant', + lastName: false, + accountOwner: 'yes', + }, + }, + }, + }) - test('returns true within the 4-minute expiry margin', () => { - const almostExpired = new Date(Date.now() + 3 * 60 * 1000).toISOString() - expect(isSessionExpired(buildSession({expiresAt: almostExpired}))).toBe(true) + expect(getCurrentStoredStoreAppSession('shop.myshopify.com', storage as any)).toEqual({ + ...buildSession(), + associatedUser: { + id: 42, + firstName: 'Merchant', + }, + }) }) - test('returns false just outside the 4-minute expiry margin', () => { - const safelyValid = new Date(Date.now() + 5 * 60 * 1000).toISOString() - expect(isSessionExpired(buildSession({expiresAt: safelyValid}))).toBe(false) + test('overwrites a malformed bucket when writing a new session', () => { + const storage = inMemoryStorage() + storage.set(storeAuthSessionKey('shop.myshopify.com'), { + currentUserId: '42', + sessionsByUserId: null, + }) + + setStoredStoreAppSession(buildSession(), storage as any) + + expect(getCurrentStoredStoreAppSession('shop.myshopify.com', storage as any)).toEqual(buildSession()) }) - test('returns true when expiresAt is invalid', () => { - expect(isSessionExpired(buildSession({expiresAt: 'not-a-date'}))).toBe(true) + test('clears malformed buckets without throwing when removing a specific user', () => { + const storage = inMemoryStorage() + storage.set(storeAuthSessionKey('shop.myshopify.com'), { + currentUserId: '42', + sessionsByUserId: null, + }) + + expect(() => clearStoredStoreAppSession('shop.myshopify.com', '42', storage as any)).not.toThrow() + expect(storage.get(storeAuthSessionKey('shop.myshopify.com'))).toBeUndefined() }) }) diff --git a/packages/cli/src/cli/services/store/auth/session-store.ts b/packages/cli/src/cli/services/store/auth/session-store.ts new file mode 100644 index 00000000000..868db8487cc --- /dev/null +++ b/packages/cli/src/cli/services/store/auth/session-store.ts @@ -0,0 +1,192 @@ +import {LocalStorage} from '@shopify/cli-kit/node/local-storage' +import {storeAuthSessionKey} from './config.js' + +export interface StoredStoreAppSession { + store: string + clientId: string + userId: string + accessToken: string + refreshToken?: string + scopes: string[] + acquiredAt: string + expiresAt?: string + refreshTokenExpiresAt?: string + associatedUser?: { + id: number + email?: string + firstName?: string + lastName?: string + accountOwner?: boolean + } +} + +interface StoredStoreAppSessionBucket { + currentUserId: string + sessionsByUserId: {[userId: string]: StoredStoreAppSession} +} + +interface StoreSessionSchema { + [key: string]: StoredStoreAppSessionBucket +} + +let _storeSessionStorage: LocalStorage | undefined + +function storeSessionStorage() { + _storeSessionStorage ??= new LocalStorage({projectName: 'shopify-cli-store'}) + return _storeSessionStorage +} + +function isString(value: unknown): value is string { + return typeof value === 'string' +} + +function sanitizeAssociatedUser(value: unknown): StoredStoreAppSession['associatedUser'] | undefined { + if (!value || typeof value !== 'object') return undefined + + const associatedUser = value as Record + if (typeof associatedUser.id !== 'number') return undefined + + return { + id: associatedUser.id, + ...(isString(associatedUser.email) ? {email: associatedUser.email} : {}), + ...(isString(associatedUser.firstName) ? {firstName: associatedUser.firstName} : {}), + ...(isString(associatedUser.lastName) ? {lastName: associatedUser.lastName} : {}), + ...(typeof associatedUser.accountOwner === 'boolean' ? {accountOwner: associatedUser.accountOwner} : {}), + } +} + +function sanitizeStoredStoreAppSession(value: unknown): StoredStoreAppSession | undefined { + if (!value || typeof value !== 'object') return undefined + + const session = value as Record + if ( + !isString(session.store) || + !isString(session.clientId) || + !isString(session.userId) || + !isString(session.accessToken) || + !Array.isArray(session.scopes) || + !session.scopes.every(isString) || + !isString(session.acquiredAt) + ) { + return undefined + } + + return { + store: session.store, + clientId: session.clientId, + userId: session.userId, + accessToken: session.accessToken, + scopes: session.scopes, + acquiredAt: session.acquiredAt, + ...(isString(session.refreshToken) ? {refreshToken: session.refreshToken} : {}), + ...(isString(session.expiresAt) ? {expiresAt: session.expiresAt} : {}), + ...(isString(session.refreshTokenExpiresAt) ? {refreshTokenExpiresAt: session.refreshTokenExpiresAt} : {}), + ...(sanitizeAssociatedUser(session.associatedUser) ? {associatedUser: sanitizeAssociatedUser(session.associatedUser)} : {}), + } +} + +function readStoredStoreAppSessionBucket( + store: string, + storage: LocalStorage, +): StoredStoreAppSessionBucket | undefined { + const key = storeAuthSessionKey(store) + const storedBucket = storage.get(key) + if (!storedBucket || typeof storedBucket !== 'object') return undefined + + const {sessionsByUserId, currentUserId} = storedBucket as Partial + if (!sessionsByUserId || typeof sessionsByUserId !== 'object' || Array.isArray(sessionsByUserId) || typeof currentUserId !== 'string') { + storage.delete(key) + return undefined + } + + const sanitizedSessionsByUserId = Object.fromEntries( + Object.entries(sessionsByUserId).flatMap(([userId, session]) => { + const sanitizedSession = sanitizeStoredStoreAppSession(session) + return sanitizedSession ? [[userId, sanitizedSession]] : [] + }), + ) + + if (Object.keys(sanitizedSessionsByUserId).length !== Object.keys(sessionsByUserId).length) { + if (sanitizedSessionsByUserId[currentUserId]) { + storage.set(key, { + currentUserId, + sessionsByUserId: sanitizedSessionsByUserId, + }) + } else { + storage.delete(key) + return undefined + } + } + + return { + currentUserId, + sessionsByUserId: sanitizedSessionsByUserId, + } +} + +export function getCurrentStoredStoreAppSession( + store: string, + storage: LocalStorage = storeSessionStorage(), +): StoredStoreAppSession | undefined { + const bucket = readStoredStoreAppSessionBucket(store, storage) + if (!bucket) return undefined + + const session = bucket.sessionsByUserId[bucket.currentUserId] + if (!session) { + storage.delete(storeAuthSessionKey(store)) + return undefined + } + + return session +} + +export function setStoredStoreAppSession( + session: StoredStoreAppSession, + storage: LocalStorage = storeSessionStorage(), +): void { + const key = storeAuthSessionKey(session.store) + const existingBucket = readStoredStoreAppSessionBucket(session.store, storage) + + const nextBucket: StoredStoreAppSessionBucket = { + currentUserId: session.userId, + sessionsByUserId: { + ...(existingBucket?.sessionsByUserId ?? {}), + [session.userId]: session, + }, + } + + storage.set(key, nextBucket) +} + +export function clearStoredStoreAppSession( + store: string, + userIdOrStorage?: string | LocalStorage, + maybeStorage?: LocalStorage, +): void { + const userId = typeof userIdOrStorage === 'string' ? userIdOrStorage : undefined + const storage = + (typeof userIdOrStorage === 'string' ? maybeStorage : userIdOrStorage) ?? storeSessionStorage() + + const key = storeAuthSessionKey(store) + + if (!userId) { + storage.delete(key) + return + } + + const existingBucket = readStoredStoreAppSessionBucket(store, storage) + if (!existingBucket) return + + const {[userId]: _removedSession, ...remainingSessions} = existingBucket.sessionsByUserId + + const remainingUserIds = Object.keys(remainingSessions) + if (remainingUserIds.length === 0) { + storage.delete(key) + return + } + + storage.set(key, { + currentUserId: existingBucket.currentUserId === userId ? remainingUserIds[0]! : existingBucket.currentUserId, + sessionsByUserId: remainingSessions, + }) +} diff --git a/packages/cli/src/cli/services/store/auth/token-client.test.ts b/packages/cli/src/cli/services/store/auth/token-client.test.ts new file mode 100644 index 00000000000..eab22b6abd0 --- /dev/null +++ b/packages/cli/src/cli/services/store/auth/token-client.test.ts @@ -0,0 +1,167 @@ +import {beforeEach, describe, expect, test, vi} from 'vitest' +import {adminUrl} from '@shopify/cli-kit/node/api/admin' +import {graphqlRequest} from '@shopify/cli-kit/node/api/graphql' +import {fetch} from '@shopify/cli-kit/node/http' +import {outputDebug} from '@shopify/cli-kit/node/output' +import { + exchangeStoreAuthCodeForToken, + fetchCurrentStoreAuthScopes, + refreshStoreAccessToken, +} from './token-client.js' +import {STORE_AUTH_APP_CLIENT_ID} from './config.js' + +vi.mock('@shopify/cli-kit/node/http') +vi.mock('@shopify/cli-kit/node/api/graphql') +vi.mock('@shopify/cli-kit/node/api/admin', async () => { + const actual = await vi.importActual('@shopify/cli-kit/node/api/admin') + return { + ...actual, + adminUrl: vi.fn(), + } +}) +vi.mock('@shopify/cli-kit/node/output', async () => { + const actual = await vi.importActual('@shopify/cli-kit/node/output') + return { + ...actual, + outputDebug: vi.fn(), + } +}) + +describe('token client', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(adminUrl).mockReturnValue('https://shop.myshopify.com/admin/api/unstable/graphql.json') + }) + + test('exchangeStoreAuthCodeForToken sends PKCE params and returns token response', async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, + text: vi.fn().mockResolvedValue( + JSON.stringify({ + access_token: 'token', + scope: 'read_products', + expires_in: 86400, + refresh_token: 'refresh-token', + associated_user: {id: 42, email: 'test@example.com'}, + }), + ), + } as any) + + const response = await exchangeStoreAuthCodeForToken({ + store: 'shop.myshopify.com', + code: 'abc123', + codeVerifier: 'test-verifier', + redirectUri: 'http://127.0.0.1:13387/auth/callback', + }) + + expect(response.access_token).toBe('token') + expect(response.refresh_token).toBe('refresh-token') + expect(fetch).toHaveBeenCalledWith( + 'https://shop.myshopify.com/admin/oauth/access_token', + expect.objectContaining({ + method: 'POST', + body: expect.stringContaining('"code_verifier":"test-verifier"'), + }), + ) + + const sentBody = JSON.parse((fetch as any).mock.calls[0][1].body) + expect(sentBody.client_id).toBe(STORE_AUTH_APP_CLIENT_ID) + expect(sentBody.code).toBe('abc123') + expect(sentBody.code_verifier).toBe('test-verifier') + expect(sentBody.redirect_uri).toBe('http://127.0.0.1:13387/auth/callback') + }) + + test('refreshStoreAccessToken sends refresh params and returns normalized payload', async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, + text: vi.fn().mockResolvedValue( + JSON.stringify({ + access_token: 'fresh-token', + refresh_token: 'fresh-refresh-token', + expires_in: 3600, + refresh_token_expires_in: 7200, + }), + ), + } as any) + + await expect( + refreshStoreAccessToken({store: 'shop.myshopify.com', refreshToken: 'refresh-token'}), + ).resolves.toEqual({ + accessToken: 'fresh-token', + refreshToken: 'fresh-refresh-token', + expiresIn: 3600, + refreshTokenExpiresIn: 7200, + }) + + expect(fetch).toHaveBeenCalledWith( + 'https://shop.myshopify.com/admin/oauth/access_token', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + client_id: STORE_AUTH_APP_CLIENT_ID, + grant_type: 'refresh_token', + refresh_token: 'refresh-token', + }), + }), + ) + }) + + test('refreshStoreAccessToken throws on malformed JSON', async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, + text: vi.fn().mockResolvedValue('not-json'), + } as any) + + await expect( + refreshStoreAccessToken({store: 'shop.myshopify.com', refreshToken: 'refresh-token'}), + ).rejects.toThrow('Received an invalid refresh response from Shopify.') + }) + + test('refreshStoreAccessToken throws when access token is missing', async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, + text: vi.fn().mockResolvedValue(JSON.stringify({refresh_token: 'fresh-refresh-token'})), + } as any) + + await expect( + refreshStoreAccessToken({store: 'shop.myshopify.com', refreshToken: 'refresh-token'}), + ).rejects.toThrow('Token refresh returned an invalid response for shop.myshopify.com.') + }) + + test('fetchCurrentStoreAuthScopes returns current scope handles', async () => { + vi.mocked(graphqlRequest).mockResolvedValue({ + currentAppInstallation: {accessScopes: [{handle: 'read_products'}, {handle: 'read_orders'}]}, + } as any) + + await expect( + fetchCurrentStoreAuthScopes({store: 'shop.myshopify.com', accessToken: 'token'}), + ).resolves.toEqual(['read_products', 'read_orders']) + + expect(adminUrl).toHaveBeenCalledWith('shop.myshopify.com', 'unstable') + expect(graphqlRequest).toHaveBeenCalledWith({ + query: expect.stringContaining('currentAppInstallation'), + api: 'Admin', + url: 'https://shop.myshopify.com/admin/api/unstable/graphql.json', + token: 'token', + responseOptions: {handleErrors: false}, + }) + }) + + test('fetchCurrentStoreAuthScopes throws on GraphQL lookup failure', async () => { + vi.mocked(graphqlRequest).mockRejectedValue(new Error('shopify exploded')) + + await expect( + fetchCurrentStoreAuthScopes({store: 'shop.myshopify.com', accessToken: 'token'}), + ).rejects.toThrow('shopify exploded') + }) + + test('fetchCurrentStoreAuthScopes throws on invalid response shape', async () => { + vi.mocked(graphqlRequest).mockResolvedValue({ + currentAppInstallation: undefined, + } as any) + + await expect( + fetchCurrentStoreAuthScopes({store: 'shop.myshopify.com', accessToken: 'token'}), + ).rejects.toThrow('Shopify did not return currentAppInstallation.accessScopes.') + }) +}) diff --git a/packages/cli/src/cli/services/store/auth/token-client.ts b/packages/cli/src/cli/services/store/auth/token-client.ts new file mode 100644 index 00000000000..5931e4de9ae --- /dev/null +++ b/packages/cli/src/cli/services/store/auth/token-client.ts @@ -0,0 +1,171 @@ +import {adminUrl} from '@shopify/cli-kit/node/api/admin' +import {graphqlRequest} from '@shopify/cli-kit/node/api/graphql' +import {AbortError} from '@shopify/cli-kit/node/error' +import {fetch} from '@shopify/cli-kit/node/http' +import {outputContent, outputDebug, outputToken} from '@shopify/cli-kit/node/output' +import {maskToken, STORE_AUTH_APP_CLIENT_ID} from './config.js' + +export interface StoreTokenResponse { + access_token: string + token_type?: string + scope?: string + expires_in?: number + refresh_token?: string + refresh_token_expires_in?: number + associated_user_scope?: string + associated_user?: { + id: number + first_name?: string + last_name?: string + email?: string + account_owner?: boolean + locale?: string + collaborator?: boolean + email_verified?: boolean + } +} + +interface StoreAccessScopesResponse { + currentAppInstallation?: { + accessScopes?: {handle?: string}[] + } +} + +interface StoreTokenRefreshPayload { + accessToken: string + refreshToken?: string + expiresIn?: number + refreshTokenExpiresIn?: number +} + +function truncateHttpErrorBody(body: string, length = 300): string { + return body.slice(0, length) +} + +export async function exchangeStoreAuthCodeForToken(options: { + store: string + code: string + codeVerifier: string + redirectUri: string +}): Promise { + const endpoint = `https://${options.store}/admin/oauth/access_token` + + outputDebug(outputContent`Exchanging authorization code for token at ${outputToken.raw(endpoint)}`) + + const response = await fetch(endpoint, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + client_id: STORE_AUTH_APP_CLIENT_ID, + code: options.code, + code_verifier: options.codeVerifier, + redirect_uri: options.redirectUri, + }), + }) + + const body = await response.text() + if (!response.ok) { + outputDebug( + outputContent`Token exchange failed with HTTP ${outputToken.raw(String(response.status))}: ${outputToken.raw(truncateHttpErrorBody(body || response.statusText))}`, + ) + throw new AbortError( + `Failed to exchange OAuth code for an access token (HTTP ${response.status}).`, + body || response.statusText, + ) + } + + let parsed: StoreTokenResponse + try { + parsed = JSON.parse(body) as StoreTokenResponse + } catch { + throw new AbortError('Received an invalid token response from Shopify.') + } + + outputDebug( + outputContent`Token exchange succeeded: access_token=${outputToken.raw(maskToken(parsed.access_token))}, refresh_token=${outputToken.raw(parsed.refresh_token ? maskToken(parsed.refresh_token) : 'none')}, expires_in=${outputToken.raw(String(parsed.expires_in ?? 'unknown'))}s, user=${outputToken.raw(String(parsed.associated_user?.id ?? 'unknown'))} (${outputToken.raw(parsed.associated_user?.email ?? 'no email')})`, + ) + + return parsed +} + +export async function refreshStoreAccessToken(options: { + store: string + refreshToken: string +}): Promise { + const endpoint = `https://${options.store}/admin/oauth/access_token` + + outputDebug( + outputContent`Refreshing access token for ${outputToken.raw(options.store)} using refresh_token=${outputToken.raw(maskToken(options.refreshToken))}`, + ) + + const response = await fetch(endpoint, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + client_id: STORE_AUTH_APP_CLIENT_ID, + grant_type: 'refresh_token', + refresh_token: options.refreshToken, + }), + }) + + const body = await response.text() + if (!response.ok) { + outputDebug( + outputContent`Token refresh failed with HTTP ${outputToken.raw(String(response.status))}: ${outputToken.raw(truncateHttpErrorBody(body || response.statusText))}`, + ) + throw new AbortError(`Token refresh failed for ${options.store} (HTTP ${response.status}).`) + } + + let parsed: StoreTokenResponse + try { + parsed = JSON.parse(body) as StoreTokenResponse + } catch { + throw new AbortError('Received an invalid refresh response from Shopify.') + } + + if (!parsed.access_token) { + throw new AbortError(`Token refresh returned an invalid response for ${options.store}.`) + } + + return { + accessToken: parsed.access_token, + refreshToken: parsed.refresh_token, + expiresIn: parsed.expires_in, + refreshTokenExpiresIn: parsed.refresh_token_expires_in, + } +} + +const CurrentAppInstallationAccessScopesQuery = `#graphql + query CurrentAppInstallationAccessScopes { + currentAppInstallation { + accessScopes { + handle + } + } + } +` + +export async function fetchCurrentStoreAuthScopes(options: { + store: string + accessToken: string +}): Promise { + outputDebug( + outputContent`Fetching current app installation scopes for ${outputToken.raw(options.store)} using token ${outputToken.raw(maskToken(options.accessToken))}`, + ) + + const data = await graphqlRequest({ + query: CurrentAppInstallationAccessScopesQuery, + api: 'Admin', + url: adminUrl(options.store, 'unstable'), + token: options.accessToken, + responseOptions: {handleErrors: false}, + }) + + if (!Array.isArray(data.currentAppInstallation?.accessScopes)) { + throw new Error('Shopify did not return currentAppInstallation.accessScopes.') + } + + return data.currentAppInstallation.accessScopes.flatMap((scope) => + typeof scope.handle === 'string' ? [scope.handle] : [], + ) +} diff --git a/packages/cli/src/cli/services/store/execute.test.ts b/packages/cli/src/cli/services/store/execute.test.ts index e9ab1276937..0f40ed1d9af 100644 --- a/packages/cli/src/cli/services/store/execute.test.ts +++ b/packages/cli/src/cli/services/store/execute.test.ts @@ -1,14 +1,14 @@ import {describe, test, expect, vi, beforeEach, afterEach} from 'vitest' import {executeStoreOperation} from './execute.js' -import {getStoredStoreAppSession} from './session.js' -import {STORE_AUTH_APP_CLIENT_ID} from './auth-config.js' +import {getCurrentStoredStoreAppSession} from './auth/session-store.js' +import {STORE_AUTH_APP_CLIENT_ID} from './auth/config.js' import {fetchApiVersions, adminUrl} from '@shopify/cli-kit/node/api/admin' import {graphqlRequest} from '@shopify/cli-kit/node/api/graphql' import {renderSingleTask, renderSuccess} from '@shopify/cli-kit/node/ui' import {fileExists, readFile, writeFile} from '@shopify/cli-kit/node/fs' import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' -vi.mock('./session.js') +vi.mock('./auth/session-store.js') vi.mock('@shopify/cli-kit/node/api/graphql') vi.mock('@shopify/cli-kit/node/ui') vi.mock('@shopify/cli-kit/node/fs') @@ -35,7 +35,7 @@ describe('executeStoreOperation', () => { beforeEach(() => { vi.clearAllMocks() - vi.mocked(getStoredStoreAppSession).mockReturnValue(storedSession) + vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue(storedSession) vi.mocked(fetchApiVersions).mockResolvedValue([ {handle: '2025-10', supported: true}, {handle: '2025-07', supported: true}, @@ -63,7 +63,7 @@ describe('executeStoreOperation', () => { title: expect.anything(), }), ) - expect(getStoredStoreAppSession).toHaveBeenCalledWith(store) + expect(getCurrentStoredStoreAppSession).toHaveBeenCalledWith(store) expect(fetchApiVersions).toHaveBeenCalledWith(session) expect(graphqlRequest).toHaveBeenCalledWith({ query: 'query { shop { name } }', @@ -131,11 +131,11 @@ describe('executeStoreOperation', () => { }), ).rejects.toThrow('Mutations are disabled by default') - expect(getStoredStoreAppSession).not.toHaveBeenCalled() + expect(getCurrentStoredStoreAppSession).not.toHaveBeenCalled() }) test('throws when no stored app session exists', async () => { - vi.mocked(getStoredStoreAppSession).mockReturnValue(undefined) + vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue(undefined) await expect( executeStoreOperation({ diff --git a/packages/cli/src/cli/services/store/session.ts b/packages/cli/src/cli/services/store/session.ts deleted file mode 100644 index 53100a27e87..00000000000 --- a/packages/cli/src/cli/services/store/session.ts +++ /dev/null @@ -1,125 +0,0 @@ -import {LocalStorage} from '@shopify/cli-kit/node/local-storage' -import {storeAuthSessionKey} from './auth-config.js' - -export interface StoredStoreAppSession { - store: string - clientId: string - userId: string - accessToken: string - refreshToken?: string - scopes: string[] - acquiredAt: string - expiresAt?: string - refreshTokenExpiresAt?: string - associatedUser?: { - id: number - email?: string - firstName?: string - lastName?: string - accountOwner?: boolean - } -} - -interface StoredStoreAppSessionBucket { - currentUserId: string - sessionsByUserId: {[userId: string]: StoredStoreAppSession} -} - -interface StoreSessionSchema { - [key: string]: StoredStoreAppSessionBucket -} - -let _storeSessionStorage: LocalStorage | undefined - -// Per-store, per-user session storage for PKCE online tokens. -function storeSessionStorage() { - _storeSessionStorage ??= new LocalStorage({projectName: 'shopify-cli-store'}) - return _storeSessionStorage -} - -export function getStoredStoreAppSession( - store: string, - storage: LocalStorage = storeSessionStorage(), -): StoredStoreAppSession | undefined { - const key = storeAuthSessionKey(store) - const storedBucket = storage.get(key) - if (!storedBucket || typeof storedBucket !== 'object') return undefined - - const {sessionsByUserId, currentUserId} = storedBucket as Partial - - if (!sessionsByUserId || typeof sessionsByUserId !== 'object' || typeof currentUserId !== 'string') { - storage.delete(key) - return undefined - } - - const session = sessionsByUserId[currentUserId] - if (!session) { - storage.delete(key) - return undefined - } - - return session -} - -export function setStoredStoreAppSession( - session: StoredStoreAppSession, - storage: LocalStorage = storeSessionStorage(), -): void { - const key = storeAuthSessionKey(session.store) - const existingBucket = storage.get(key) - - const nextBucket: StoredStoreAppSessionBucket = { - currentUserId: session.userId, - sessionsByUserId: { - ...(existingBucket?.sessionsByUserId ?? {}), - [session.userId]: session, - }, - } - - storage.set(key, nextBucket) -} - -export function clearStoredStoreAppSession( - store: string, - userIdOrStorage?: string | LocalStorage, - maybeStorage?: LocalStorage, -): void { - const userId = typeof userIdOrStorage === 'string' ? userIdOrStorage : undefined - const storage = - (typeof userIdOrStorage === 'string' ? maybeStorage : userIdOrStorage) ?? storeSessionStorage() - - const key = storeAuthSessionKey(store) - - if (!userId) { - storage.delete(key) - return - } - - const existingBucket = storage.get(key) - if (!existingBucket) return - - const {[userId]: _removedSession, ...remainingSessions} = existingBucket.sessionsByUserId - - const remainingUserIds = Object.keys(remainingSessions) - if (remainingUserIds.length === 0) { - storage.delete(key) - return - } - - storage.set(key, { - currentUserId: - existingBucket.currentUserId === userId ? remainingUserIds[0]! : existingBucket.currentUserId, - sessionsByUserId: remainingSessions, - }) -} - -const EXPIRY_MARGIN_MS = 4 * 60 * 1000 - -export function isSessionExpired(session: StoredStoreAppSession): boolean { - if (!session.expiresAt) return false - - const expiresAtMs = new Date(session.expiresAt).getTime() - if (Number.isNaN(expiresAtMs)) return true - - return expiresAtMs - EXPIRY_MARGIN_MS < Date.now() -} diff --git a/packages/cli/src/cli/services/store/stored-session.ts b/packages/cli/src/cli/services/store/stored-session.ts deleted file mode 100644 index f41329b6b0b..00000000000 --- a/packages/cli/src/cli/services/store/stored-session.ts +++ /dev/null @@ -1,104 +0,0 @@ -import {AbortError} from '@shopify/cli-kit/node/error' -import {fetch} from '@shopify/cli-kit/node/http' -import {outputContent, outputDebug, outputToken} from '@shopify/cli-kit/node/output' -import {maskToken, STORE_AUTH_APP_CLIENT_ID} from './auth-config.js' -import {createStoredStoreAuthError, reauthenticateStoreAuthError} from './auth-recovery.js' -import { - clearStoredStoreAppSession, - getStoredStoreAppSession, - isSessionExpired, - setStoredStoreAppSession, -} from './session.js' -import type {StoredStoreAppSession} from './session.js' - -async function refreshStoreToken(session: StoredStoreAppSession): Promise { - if (!session.refreshToken) { - throw reauthenticateStoreAuthError(`No refresh token stored for ${session.store}.`, session.store, session.scopes.join(',')) - } - - const endpoint = `https://${session.store}/admin/oauth/access_token` - - outputDebug( - outputContent`Refreshing expired token for ${outputToken.raw(session.store)} (expired at ${outputToken.raw(session.expiresAt ?? 'unknown')}, refresh_token=${outputToken.raw(maskToken(session.refreshToken))})`, - ) - - const response = await fetch(endpoint, { - method: 'POST', - headers: {'Content-Type': 'application/json'}, - body: JSON.stringify({ - client_id: STORE_AUTH_APP_CLIENT_ID, - grant_type: 'refresh_token', - refresh_token: session.refreshToken, - }), - }) - - const body = await response.text() - - if (!response.ok) { - outputDebug( - outputContent`Token refresh failed with HTTP ${outputToken.raw(String(response.status))}: ${outputToken.raw(body.slice(0, 300))}`, - ) - clearStoredStoreAppSession(session.store, session.userId) - throw reauthenticateStoreAuthError( - `Token refresh failed for ${session.store} (HTTP ${response.status}).`, - session.store, - session.scopes.join(','), - ) - } - - let data: {access_token?: string; refresh_token?: string; expires_in?: number; refresh_token_expires_in?: number} - try { - data = JSON.parse(body) - } catch { - clearStoredStoreAppSession(session.store, session.userId) - throw new AbortError('Received an invalid refresh response from Shopify.') - } - - if (!data.access_token) { - clearStoredStoreAppSession(session.store, session.userId) - throw reauthenticateStoreAuthError( - `Token refresh returned an invalid response for ${session.store}.`, - session.store, - session.scopes.join(','), - ) - } - - const now = Date.now() - const expiresAt = data.expires_in ? new Date(now + data.expires_in * 1000).toISOString() : session.expiresAt - - const refreshedSession: StoredStoreAppSession = { - ...session, - accessToken: data.access_token, - refreshToken: data.refresh_token ?? session.refreshToken, - expiresAt, - refreshTokenExpiresAt: data.refresh_token_expires_in - ? new Date(now + data.refresh_token_expires_in * 1000).toISOString() - : session.refreshTokenExpiresAt, - acquiredAt: new Date(now).toISOString(), - } - - outputDebug( - outputContent`Token refresh succeeded for ${outputToken.raw(session.store)}: ${outputToken.raw(maskToken(session.accessToken))} → ${outputToken.raw(maskToken(refreshedSession.accessToken))}, new expiry ${outputToken.raw(expiresAt ?? 'unknown')}`, - ) - - setStoredStoreAppSession(refreshedSession) - return refreshedSession -} - -export async function loadStoredStoreSession(store: string): Promise { - let session = getStoredStoreAppSession(store) - - if (!session) { - throw createStoredStoreAuthError(store) - } - - outputDebug( - outputContent`Loaded stored session for ${outputToken.raw(store)}: token=${outputToken.raw(maskToken(session.accessToken))}, expires=${outputToken.raw(session.expiresAt ?? 'unknown')}`, - ) - - if (isSessionExpired(session)) { - session = await refreshStoreToken(session) - } - - return session -} From 239e75aafd3832161121908d06e3b9d4b38efca9 Mon Sep 17 00:00:00 2001 From: Donald Merand Date: Fri, 3 Apr 2026 11:32:57 -0400 Subject: [PATCH 29/31] store execute restructure --- .../src/cli/commands/store/execute.test.ts | 4 +- .../cli/src/cli/commands/store/execute.ts | 2 +- .../store/admin-graphql-context.test.ts | 182 --------------- .../src/cli/services/store/execute.test.ts | 219 ------------------ .../store/execute/admin-context.test.ts | 116 ++++++++++ .../admin-context.ts} | 15 +- .../admin-transport.test.ts} | 46 ++-- .../admin-transport.ts} | 25 +- .../cli/services/store/execute/index.test.ts | 77 ++++++ .../store/{execute.ts => execute/index.ts} | 12 +- .../request.test.ts} | 2 +- .../request.ts} | 0 .../result.test.ts} | 2 +- .../{execute-result.ts => execute/result.ts} | 0 .../targets.test.ts} | 35 ++- .../targets.ts} | 19 +- 16 files changed, 265 insertions(+), 491 deletions(-) delete mode 100644 packages/cli/src/cli/services/store/admin-graphql-context.test.ts delete mode 100644 packages/cli/src/cli/services/store/execute.test.ts create mode 100644 packages/cli/src/cli/services/store/execute/admin-context.test.ts rename packages/cli/src/cli/services/store/{admin-graphql-context.ts => execute/admin-context.ts} (80%) rename packages/cli/src/cli/services/store/{admin-graphql-transport.test.ts => execute/admin-transport.test.ts} (68%) rename packages/cli/src/cli/services/store/{admin-graphql-transport.ts => execute/admin-transport.ts} (66%) create mode 100644 packages/cli/src/cli/services/store/execute/index.test.ts rename packages/cli/src/cli/services/store/{execute.ts => execute/index.ts} (78%) rename packages/cli/src/cli/services/store/{execute-request.test.ts => execute/request.test.ts} (98%) rename packages/cli/src/cli/services/store/{execute-request.ts => execute/request.ts} (100%) rename packages/cli/src/cli/services/store/{execute-result.test.ts => execute/result.test.ts} (94%) rename packages/cli/src/cli/services/store/{execute-result.ts => execute/result.ts} (100%) rename packages/cli/src/cli/services/store/{graphql-targets.test.ts => execute/targets.test.ts} (57%) rename packages/cli/src/cli/services/store/{graphql-targets.ts => execute/targets.ts} (61%) diff --git a/packages/cli/src/cli/commands/store/execute.test.ts b/packages/cli/src/cli/commands/store/execute.test.ts index e890b5332a0..4d7bd1e2ba7 100644 --- a/packages/cli/src/cli/commands/store/execute.test.ts +++ b/packages/cli/src/cli/commands/store/execute.test.ts @@ -1,8 +1,8 @@ import {describe, test, expect, vi, beforeEach} from 'vitest' import StoreExecute from './execute.js' -import {executeStoreOperation} from '../../services/store/execute.js' +import {executeStoreOperation} from '../../services/store/execute/index.js' -vi.mock('../../services/store/execute.js') +vi.mock('../../services/store/execute/index.js') describe('store execute command', () => { beforeEach(() => { diff --git a/packages/cli/src/cli/commands/store/execute.ts b/packages/cli/src/cli/commands/store/execute.ts index 661c8af9a24..bffc3e21ec4 100644 --- a/packages/cli/src/cli/commands/store/execute.ts +++ b/packages/cli/src/cli/commands/store/execute.ts @@ -3,7 +3,7 @@ import {globalFlags} from '@shopify/cli-kit/node/cli' import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' import {resolvePath} from '@shopify/cli-kit/node/path' import {Flags} from '@oclif/core' -import {executeStoreOperation} from '../../services/store/execute.js' +import {executeStoreOperation} from '../../services/store/execute/index.js' export default class StoreExecute extends Command { static summary = 'Execute GraphQL queries and mutations on a store.' diff --git a/packages/cli/src/cli/services/store/admin-graphql-context.test.ts b/packages/cli/src/cli/services/store/admin-graphql-context.test.ts deleted file mode 100644 index 83371130436..00000000000 --- a/packages/cli/src/cli/services/store/admin-graphql-context.test.ts +++ /dev/null @@ -1,182 +0,0 @@ -import {beforeEach, describe, expect, test, vi} from 'vitest' -import {fetchApiVersions} from '@shopify/cli-kit/node/api/admin' -import {AbortError} from '@shopify/cli-kit/node/error' -import {fetch} from '@shopify/cli-kit/node/http' -import { - clearStoredStoreAppSession, - getCurrentStoredStoreAppSession, - setStoredStoreAppSession, -} from './auth/session-store.js' -import {STORE_AUTH_APP_CLIENT_ID} from './auth/config.js' -import {prepareAdminStoreGraphQLContext} from './admin-graphql-context.js' - -vi.mock('./auth/session-store.js') -vi.mock('@shopify/cli-kit/node/http') -vi.mock('@shopify/cli-kit/node/api/admin', async () => { - const actual = await vi.importActual('@shopify/cli-kit/node/api/admin') - return { - ...actual, - fetchApiVersions: vi.fn(), - } -}) - -describe('prepareAdminStoreGraphQLContext', () => { - const store = 'shop.myshopify.com' - const futureExpiry = new Date(Date.now() + 60 * 60 * 1000).toISOString() - const expiredAt = new Date(Date.now() - 60 * 1000).toISOString() - - const storedSession = { - store, - clientId: STORE_AUTH_APP_CLIENT_ID, - userId: '42', - accessToken: 'token', - refreshToken: 'refresh-token', - scopes: ['read_products'], - acquiredAt: '2026-03-27T00:00:00.000Z', - expiresAt: futureExpiry, - } - - beforeEach(() => { - vi.clearAllMocks() - vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue(storedSession) - vi.mocked(fetchApiVersions).mockResolvedValue([ - {handle: '2025-10', supported: true}, - {handle: '2025-07', supported: true}, - {handle: 'unstable', supported: false}, - ] as any) - }) - - test('returns the stored admin session and latest supported version by default', async () => { - const result = await prepareAdminStoreGraphQLContext({store}) - - expect(result).toEqual({ - adminSession: { - token: 'token', - storeFqdn: store, - }, - version: '2025-10', - sessionUserId: '42', - }) - }) - - test('refreshes expired sessions before resolving the API version', async () => { - vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue({...storedSession, expiresAt: expiredAt}) - vi.mocked(fetch).mockResolvedValue({ - ok: true, - text: vi.fn().mockResolvedValue( - JSON.stringify({ - access_token: 'fresh-token', - refresh_token: 'fresh-refresh-token', - expires_in: 3600, - refresh_token_expires_in: 7200, - }), - ), - } as any) - - const result = await prepareAdminStoreGraphQLContext({store}) - - expect(result.adminSession.token).toBe('fresh-token') - expect(setStoredStoreAppSession).toHaveBeenCalledWith( - expect.objectContaining({ - store, - accessToken: 'fresh-token', - refreshToken: 'fresh-refresh-token', - }), - ) - }) - - test('returns the requested API version when provided', async () => { - const result = await prepareAdminStoreGraphQLContext({store, userSpecifiedVersion: '2025-07'}) - - expect(result.version).toBe('2025-07') - }) - - test('allows unstable without validating against fetched versions', async () => { - const result = await prepareAdminStoreGraphQLContext({store, userSpecifiedVersion: 'unstable'}) - - expect(result.version).toBe('unstable') - expect(fetchApiVersions).not.toHaveBeenCalled() - }) - - test('throws when no stored auth exists', async () => { - vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue(undefined) - - await expect(prepareAdminStoreGraphQLContext({store})).rejects.toMatchObject({ - message: `No stored app authentication found for ${store}.`, - tryMessage: 'To create stored auth for this store, run:', - nextSteps: [[{command: `shopify store auth --store ${store} --scopes `}]], - }) - }) - - test('clears stored auth when token refresh fails', async () => { - vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue({...storedSession, expiresAt: expiredAt}) - vi.mocked(fetch).mockResolvedValue({ - ok: false, - status: 401, - text: vi.fn().mockResolvedValue('bad refresh'), - } as any) - - await expect(prepareAdminStoreGraphQLContext({store})).rejects.toMatchObject({ - message: `Token refresh failed for ${store} (HTTP 401).`, - tryMessage: 'To re-authenticate, run:', - nextSteps: [[{command: `shopify store auth --store ${store} --scopes read_products`}]], - }) - expect(clearStoredStoreAppSession).toHaveBeenCalledWith(store, '42') - }) - - test('throws when an expired session cannot be refreshed because no refresh token is stored', async () => { - vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue({...storedSession, refreshToken: undefined, expiresAt: expiredAt}) - - await expect(prepareAdminStoreGraphQLContext({store})).rejects.toMatchObject({ - message: `No refresh token stored for ${store}.`, - tryMessage: 'To re-authenticate, run:', - nextSteps: [[{command: `shopify store auth --store ${store} --scopes read_products`}]], - }) - expect(clearStoredStoreAppSession).not.toHaveBeenCalled() - }) - - test('clears only the current stored auth when token refresh returns an invalid response body', async () => { - vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue({...storedSession, expiresAt: expiredAt}) - vi.mocked(fetch).mockResolvedValue({ - ok: true, - text: vi.fn().mockResolvedValue(JSON.stringify({refresh_token: 'fresh-refresh-token'})), - } as any) - - await expect(prepareAdminStoreGraphQLContext({store})).rejects.toMatchObject({ - message: `Token refresh returned an invalid response for ${store}.`, - tryMessage: 'To re-authenticate, run:', - nextSteps: [[{command: `shopify store auth --store ${store} --scopes read_products`}]], - }) - expect(clearStoredStoreAppSession).toHaveBeenCalledWith(store, '42') - }) - - test('clears only the current stored auth when token refresh returns malformed JSON', async () => { - vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue({...storedSession, expiresAt: expiredAt}) - vi.mocked(fetch).mockResolvedValue({ - ok: true, - text: vi.fn().mockResolvedValue('not-json'), - } as any) - - await expect(prepareAdminStoreGraphQLContext({store})).rejects.toThrow('Received an invalid refresh response') - expect(clearStoredStoreAppSession).toHaveBeenCalledWith(store, '42') - }) - - test('clears stored auth and prompts re-auth when API version lookup fails with invalid auth', async () => { - vi.mocked(fetchApiVersions).mockRejectedValue( - new AbortError(`Error connecting to your store ${store}: unauthorized 401 {}`), - ) - - await expect(prepareAdminStoreGraphQLContext({store})).rejects.toMatchObject({ - message: `Stored app authentication for ${store} is no longer valid.`, - tryMessage: 'To re-authenticate, run:', - nextSteps: [[{command: `shopify store auth --store ${store} --scopes read_products`}]], - }) - expect(clearStoredStoreAppSession).toHaveBeenCalledWith(store, '42') - }) - - test('throws when the requested API version is invalid', async () => { - await expect(prepareAdminStoreGraphQLContext({store, userSpecifiedVersion: '1999-01'})).rejects.toThrow( - 'Invalid API version', - ) - }) -}) diff --git a/packages/cli/src/cli/services/store/execute.test.ts b/packages/cli/src/cli/services/store/execute.test.ts deleted file mode 100644 index 0f40ed1d9af..00000000000 --- a/packages/cli/src/cli/services/store/execute.test.ts +++ /dev/null @@ -1,219 +0,0 @@ -import {describe, test, expect, vi, beforeEach, afterEach} from 'vitest' -import {executeStoreOperation} from './execute.js' -import {getCurrentStoredStoreAppSession} from './auth/session-store.js' -import {STORE_AUTH_APP_CLIENT_ID} from './auth/config.js' -import {fetchApiVersions, adminUrl} from '@shopify/cli-kit/node/api/admin' -import {graphqlRequest} from '@shopify/cli-kit/node/api/graphql' -import {renderSingleTask, renderSuccess} from '@shopify/cli-kit/node/ui' -import {fileExists, readFile, writeFile} from '@shopify/cli-kit/node/fs' -import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' - -vi.mock('./auth/session-store.js') -vi.mock('@shopify/cli-kit/node/api/graphql') -vi.mock('@shopify/cli-kit/node/ui') -vi.mock('@shopify/cli-kit/node/fs') -vi.mock('@shopify/cli-kit/node/api/admin', async () => { - const actual = await vi.importActual('@shopify/cli-kit/node/api/admin') - return { - ...actual, - fetchApiVersions: vi.fn(), - adminUrl: vi.fn(), - } -}) - -describe('executeStoreOperation', () => { - const store = 'shop.myshopify.com' - const session = {token: 'token', storeFqdn: store} - const storedSession = { - store, - clientId: STORE_AUTH_APP_CLIENT_ID, - userId: '42', - accessToken: 'token', - scopes: ['read_products'], - acquiredAt: '2026-03-27T00:00:00.000Z', - } - - beforeEach(() => { - vi.clearAllMocks() - vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue(storedSession) - vi.mocked(fetchApiVersions).mockResolvedValue([ - {handle: '2025-10', supported: true}, - {handle: '2025-07', supported: true}, - {handle: 'unstable', supported: false}, - ] as any) - vi.mocked(adminUrl).mockReturnValue('https://shop.myshopify.com/admin/api/2025-10/graphql.json') - vi.mocked(renderSingleTask).mockImplementation(async ({task}) => task(() => {})) - }) - - afterEach(() => { - mockAndCaptureOutput().clear() - }) - - test('executes a query successfully', async () => { - vi.mocked(graphqlRequest).mockResolvedValue({data: {shop: {name: 'Test shop'}}}) - const output = mockAndCaptureOutput() - - await executeStoreOperation({ - store, - query: 'query { shop { name } }', - }) - - expect(renderSingleTask).toHaveBeenCalledWith( - expect.objectContaining({ - title: expect.anything(), - }), - ) - expect(getCurrentStoredStoreAppSession).toHaveBeenCalledWith(store) - expect(fetchApiVersions).toHaveBeenCalledWith(session) - expect(graphqlRequest).toHaveBeenCalledWith({ - query: 'query { shop { name } }', - api: 'Admin', - url: 'https://shop.myshopify.com/admin/api/2025-10/graphql.json', - token: 'token', - variables: undefined, - responseOptions: {handleErrors: false}, - }) - expect(output.info()).toContain('"name": "Test shop"') - expect(renderSuccess).toHaveBeenCalledWith({headline: 'Operation succeeded.'}) - }) - - test('passes parsed variables when provided inline', async () => { - vi.mocked(graphqlRequest).mockResolvedValue({data: {shop: {id: 'gid://shopify/Shop/1'}}}) - - await executeStoreOperation({ - store, - query: 'query Shop($id: ID!) { shop { id } }', - variables: '{"id":"gid://shopify/Shop/1"}', - }) - - expect(graphqlRequest).toHaveBeenCalledWith( - expect.objectContaining({ - variables: {id: 'gid://shopify/Shop/1'}, - }), - ) - }) - - test('reads variables from a file', async () => { - vi.mocked(fileExists).mockResolvedValue(true) - vi.mocked(readFile).mockResolvedValue('{"id":"gid://shopify/Shop/1"}' as any) - vi.mocked(graphqlRequest).mockResolvedValue({data: {shop: {id: 'gid://shopify/Shop/1'}}}) - - await executeStoreOperation({ - store, - query: 'query Shop($id: ID!) { shop { id } }', - variableFile: '/tmp/variables.json', - }) - - expect(graphqlRequest).toHaveBeenCalledWith( - expect.objectContaining({ - variables: {id: 'gid://shopify/Shop/1'}, - }), - ) - }) - - test('throws when variables contain invalid JSON', async () => { - await expect( - executeStoreOperation({ - store, - query: 'query { shop { name } }', - variables: '{invalid json}', - }), - ).rejects.toThrow('Invalid JSON') - - expect(graphqlRequest).not.toHaveBeenCalled() - }) - - test('throws when mutations are not explicitly allowed', async () => { - await expect( - executeStoreOperation({ - store, - query: 'mutation { productCreate(product: {title: "Hat"}) { product { id } } }', - }), - ).rejects.toThrow('Mutations are disabled by default') - - expect(getCurrentStoredStoreAppSession).not.toHaveBeenCalled() - }) - - test('throws when no stored app session exists', async () => { - vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue(undefined) - - await expect( - executeStoreOperation({ - store, - query: 'query { shop { name } }', - }), - ).rejects.toThrow('No stored app authentication found') - }) - - test('allows mutations when explicitly enabled', async () => { - vi.mocked(graphqlRequest).mockResolvedValue({data: {productCreate: {product: {id: 'gid://shopify/Product/1'}}}}) - - await executeStoreOperation({ - store, - query: 'mutation { productCreate(product: {title: "Hat"}) { product { id } } }', - allowMutations: true, - }) - - expect(graphqlRequest).toHaveBeenCalled() - }) - - test('uses the specified API version when provided', async () => { - vi.mocked(graphqlRequest).mockResolvedValue({data: {shop: {name: 'Test shop'}}}) - vi.mocked(adminUrl).mockReturnValue('https://shop.myshopify.com/admin/api/2025-07/graphql.json') - - await executeStoreOperation({ - store, - query: 'query { shop { name } }', - version: '2025-07', - }) - - expect(adminUrl).toHaveBeenCalledWith(store, '2025-07', session) - }) - - test('writes results to a file when outputFile is provided', async () => { - vi.mocked(graphqlRequest).mockResolvedValue({data: {shop: {name: 'Test shop'}}}) - - await executeStoreOperation({ - store, - query: 'query { shop { name } }', - outputFile: '/tmp/results.json', - }) - - expect(writeFile).toHaveBeenCalledWith('/tmp/results.json', expect.stringContaining('Test shop')) - expect(renderSuccess).toHaveBeenCalledWith({ - headline: 'Operation succeeded.', - body: 'Results written to /tmp/results.json', - }) - }) - - test('throws when stored auth is no longer valid', async () => { - vi.mocked(graphqlRequest).mockRejectedValue({ - response: { - status: 401, - }, - }) - - await expect( - executeStoreOperation({ - store, - query: 'query { shop { name } }', - }), - ).rejects.toThrow('Stored app authentication for') - }) - - test('throws on GraphQL errors', async () => { - vi.mocked(graphqlRequest).mockRejectedValue({ - response: { - errors: [{message: 'Field does not exist'}], - }, - }) - - await expect( - executeStoreOperation({ - store, - query: 'query { nope }', - }), - ).rejects.toThrow('GraphQL operation failed.') - }) - -}) diff --git a/packages/cli/src/cli/services/store/execute/admin-context.test.ts b/packages/cli/src/cli/services/store/execute/admin-context.test.ts new file mode 100644 index 00000000000..d82ed61de96 --- /dev/null +++ b/packages/cli/src/cli/services/store/execute/admin-context.test.ts @@ -0,0 +1,116 @@ +import {beforeEach, describe, expect, test, vi} from 'vitest' +import {fetchApiVersions} from '@shopify/cli-kit/node/api/admin' +import {AbortError} from '@shopify/cli-kit/node/error' +import {prepareAdminStoreGraphQLContext} from './admin-context.js' +import {clearStoredStoreAppSession} from '../auth/session-store.js' +import {loadStoredStoreSession} from '../auth/session-lifecycle.js' +import {STORE_AUTH_APP_CLIENT_ID} from '../auth/config.js' + +vi.mock('../auth/session-store.js') +vi.mock('../auth/session-lifecycle.js', () => ({loadStoredStoreSession: vi.fn()})) +vi.mock('@shopify/cli-kit/node/api/admin', async () => { + const actual = await vi.importActual('@shopify/cli-kit/node/api/admin') + return { + ...actual, + fetchApiVersions: vi.fn(), + } +}) + +describe('prepareAdminStoreGraphQLContext', () => { + const store = 'shop.myshopify.com' + const storedSession = { + store, + clientId: STORE_AUTH_APP_CLIENT_ID, + userId: '42', + accessToken: 'token', + refreshToken: 'refresh-token', + scopes: ['read_products', 'write_orders'], + acquiredAt: '2026-03-27T00:00:00.000Z', + } + + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(loadStoredStoreSession).mockResolvedValue(storedSession) + vi.mocked(fetchApiVersions).mockResolvedValue([ + {handle: '2025-10', supported: true}, + {handle: '2025-07', supported: true}, + {handle: 'unstable', supported: false}, + ] as any) + }) + + test('returns the stored admin session, version, and full auth session', async () => { + const result = await prepareAdminStoreGraphQLContext({store}) + + expect(loadStoredStoreSession).toHaveBeenCalledWith(store) + expect(fetchApiVersions).toHaveBeenCalledWith({ + token: 'token', + storeFqdn: store, + }) + expect(result).toEqual({ + adminSession: { + token: 'token', + storeFqdn: store, + }, + version: '2025-10', + session: storedSession, + }) + }) + + test('uses the loaded refreshed session for both admin auth and returned context', async () => { + const refreshedSession = { + ...storedSession, + accessToken: 'fresh-token', + refreshToken: 'fresh-refresh-token', + expiresAt: '2026-04-03T00:00:00.000Z', + } + vi.mocked(loadStoredStoreSession).mockResolvedValue(refreshedSession) + + const result = await prepareAdminStoreGraphQLContext({store}) + + expect(fetchApiVersions).toHaveBeenCalledWith({ + token: 'fresh-token', + storeFqdn: store, + }) + expect(result.adminSession.token).toBe('fresh-token') + expect(result.session).toEqual(refreshedSession) + }) + + test('returns the requested API version when provided', async () => { + const result = await prepareAdminStoreGraphQLContext({store, userSpecifiedVersion: '2025-07'}) + + expect(result.version).toBe('2025-07') + }) + + test('allows unstable without validating against fetched versions', async () => { + const result = await prepareAdminStoreGraphQLContext({store, userSpecifiedVersion: 'unstable'}) + + expect(result.version).toBe('unstable') + expect(fetchApiVersions).not.toHaveBeenCalled() + }) + + test('clears the current stored auth and prompts re-auth with real scopes when API version lookup gets invalid auth', async () => { + vi.mocked(fetchApiVersions).mockRejectedValue( + new AbortError(`Error connecting to your store ${store}: unauthorized 401 {}`), + ) + + await expect(prepareAdminStoreGraphQLContext({store})).rejects.toMatchObject({ + message: `Stored app authentication for ${store} is no longer valid.`, + tryMessage: 'To re-authenticate, run:', + nextSteps: [[{command: `shopify store auth --store ${store} --scopes read_products,write_orders`}]], + }) + expect(clearStoredStoreAppSession).toHaveBeenCalledWith(store, '42') + }) + + test('rethrows unrelated API version lookup failures', async () => { + vi.mocked(fetchApiVersions).mockRejectedValue(new AbortError('upstream exploded')) + + await expect(prepareAdminStoreGraphQLContext({store})).rejects.toThrow('upstream exploded') + expect(clearStoredStoreAppSession).not.toHaveBeenCalled() + }) + + test('throws when the requested API version is invalid', async () => { + await expect(prepareAdminStoreGraphQLContext({store, userSpecifiedVersion: '1999-01'})).rejects.toThrow( + 'Invalid API version', + ) + }) +}) diff --git a/packages/cli/src/cli/services/store/admin-graphql-context.ts b/packages/cli/src/cli/services/store/execute/admin-context.ts similarity index 80% rename from packages/cli/src/cli/services/store/admin-graphql-context.ts rename to packages/cli/src/cli/services/store/execute/admin-context.ts index e0a493fbc09..dfabaa2eb6f 100644 --- a/packages/cli/src/cli/services/store/admin-graphql-context.ts +++ b/packages/cli/src/cli/services/store/execute/admin-context.ts @@ -1,16 +1,15 @@ import {fetchApiVersions} from '@shopify/cli-kit/node/api/admin' import {AbortError} from '@shopify/cli-kit/node/error' -import {outputContent, outputDebug, outputToken} from '@shopify/cli-kit/node/output' -import {AdminSession} from '@shopify/cli-kit/node/session' -import {reauthenticateStoreAuthError} from './auth/recovery.js' -import {clearStoredStoreAppSession} from './auth/session-store.js' -import type {StoredStoreAppSession} from './auth/session-store.js' -import {loadStoredStoreSession} from './auth/session-lifecycle.js' +import type {AdminSession} from '@shopify/cli-kit/node/session' +import {reauthenticateStoreAuthError} from '../auth/recovery.js' +import {clearStoredStoreAppSession} from '../auth/session-store.js' +import type {StoredStoreAppSession} from '../auth/session-store.js' +import {loadStoredStoreSession} from '../auth/session-lifecycle.js' export interface AdminStoreGraphQLContext { adminSession: AdminSession version: string - sessionUserId: string + session: StoredStoreAppSession } async function resolveApiVersion(options: { @@ -64,5 +63,5 @@ export async function prepareAdminStoreGraphQLContext(input: { } const version = await resolveApiVersion({session, adminSession, userSpecifiedVersion: input.userSpecifiedVersion}) - return {adminSession, version, sessionUserId: session.userId} + return {adminSession, version, session} } diff --git a/packages/cli/src/cli/services/store/admin-graphql-transport.test.ts b/packages/cli/src/cli/services/store/execute/admin-transport.test.ts similarity index 68% rename from packages/cli/src/cli/services/store/admin-graphql-transport.test.ts rename to packages/cli/src/cli/services/store/execute/admin-transport.test.ts index 3a994321fce..0d39950d7aa 100644 --- a/packages/cli/src/cli/services/store/admin-graphql-transport.test.ts +++ b/packages/cli/src/cli/services/store/execute/admin-transport.test.ts @@ -2,11 +2,12 @@ import {beforeEach, describe, expect, test, vi} from 'vitest' import {adminUrl} from '@shopify/cli-kit/node/api/admin' import {graphqlRequest} from '@shopify/cli-kit/node/api/graphql' import {renderSingleTask} from '@shopify/cli-kit/node/ui' -import {clearStoredStoreAppSession} from './auth/session-store.js' -import {prepareStoreExecuteRequest} from './execute-request.js' -import {runAdminStoreGraphQLOperation} from './admin-graphql-transport.js' +import {clearStoredStoreAppSession} from '../auth/session-store.js' +import {prepareStoreExecuteRequest} from './request.js' +import {runAdminStoreGraphQLOperation} from './admin-transport.js' +import {STORE_AUTH_APP_CLIENT_ID} from '../auth/config.js' -vi.mock('./auth/session-store.js') +vi.mock('../auth/session-store.js') vi.mock('@shopify/cli-kit/node/api/graphql') vi.mock('@shopify/cli-kit/node/ui') vi.mock('@shopify/cli-kit/node/api/admin', async () => { @@ -19,7 +20,18 @@ vi.mock('@shopify/cli-kit/node/api/admin', async () => { describe('runAdminStoreGraphQLOperation', () => { const store = 'shop.myshopify.com' - const adminSession = {token: 'token', storeFqdn: store} + const context = { + adminSession: {token: 'token', storeFqdn: store}, + version: '2025-10', + session: { + store, + clientId: STORE_AUTH_APP_CLIENT_ID, + userId: '42', + accessToken: 'token', + scopes: ['read_products', 'write_orders'], + acquiredAt: '2026-03-27T00:00:00.000Z', + }, + } beforeEach(() => { vi.clearAllMocks() @@ -31,13 +43,7 @@ describe('runAdminStoreGraphQLOperation', () => { vi.mocked(graphqlRequest).mockResolvedValue({data: {shop: {name: 'Test shop'}}}) const request = await prepareStoreExecuteRequest({query: 'query { shop { name } }'}) - const result = await runAdminStoreGraphQLOperation({ - store, - adminSession, - sessionUserId: '42', - version: '2025-10', - request, - }) + const result = await runAdminStoreGraphQLOperation({context, request}) expect(result).toEqual({data: {shop: {name: 'Test shop'}}}) expect(graphqlRequest).toHaveBeenCalledWith({ @@ -50,16 +56,14 @@ describe('runAdminStoreGraphQLOperation', () => { }) }) - test('clears stored auth and throws a re-auth error on 401', async () => { + test('clears stored auth and throws a re-auth error on 401 using the real session scopes', async () => { vi.mocked(graphqlRequest).mockRejectedValue({response: {status: 401}}) const request = await prepareStoreExecuteRequest({query: 'query { shop { name } }'}) - await expect( - runAdminStoreGraphQLOperation({store, adminSession, sessionUserId: '42', version: '2025-10', request}), - ).rejects.toMatchObject({ + await expect(runAdminStoreGraphQLOperation({context, request})).rejects.toMatchObject({ message: `Stored app authentication for ${store} is no longer valid.`, tryMessage: 'To re-authenticate, run:', - nextSteps: [[{command: `shopify store auth --store ${store} --scopes `}]], + nextSteps: [[{command: `shopify store auth --store ${store} --scopes read_products,write_orders`}]], }) expect(clearStoredStoreAppSession).toHaveBeenCalledWith(store, '42') }) @@ -68,17 +72,13 @@ describe('runAdminStoreGraphQLOperation', () => { vi.mocked(graphqlRequest).mockRejectedValue({response: {errors: [{message: 'Field does not exist'}]}}) const request = await prepareStoreExecuteRequest({query: 'query { nope }'}) - await expect( - runAdminStoreGraphQLOperation({store, adminSession, sessionUserId: '42', version: '2025-10', request}), - ).rejects.toThrow('GraphQL operation failed.') + await expect(runAdminStoreGraphQLOperation({context, request})).rejects.toThrow('GraphQL operation failed.') }) test('rethrows non-GraphQL errors', async () => { vi.mocked(graphqlRequest).mockRejectedValue(new Error('boom')) const request = await prepareStoreExecuteRequest({query: 'query { shop { name } }'}) - await expect( - runAdminStoreGraphQLOperation({store, adminSession, sessionUserId: '42', version: '2025-10', request}), - ).rejects.toThrow('boom') + await expect(runAdminStoreGraphQLOperation({context, request})).rejects.toThrow('boom') }) }) diff --git a/packages/cli/src/cli/services/store/admin-graphql-transport.ts b/packages/cli/src/cli/services/store/execute/admin-transport.ts similarity index 66% rename from packages/cli/src/cli/services/store/admin-graphql-transport.ts rename to packages/cli/src/cli/services/store/execute/admin-transport.ts index 6c1a3f8a5db..9ba1dced9c1 100644 --- a/packages/cli/src/cli/services/store/admin-graphql-transport.ts +++ b/packages/cli/src/cli/services/store/execute/admin-transport.ts @@ -2,11 +2,11 @@ import {adminUrl} from '@shopify/cli-kit/node/api/admin' import {graphqlRequest} from '@shopify/cli-kit/node/api/graphql' import {AbortError} from '@shopify/cli-kit/node/error' import {outputContent} from '@shopify/cli-kit/node/output' -import {AdminSession} from '@shopify/cli-kit/node/session' import {renderSingleTask} from '@shopify/cli-kit/node/ui' -import {reauthenticateStoreAuthError} from './auth/recovery.js' -import {PreparedStoreExecuteRequest} from './execute-request.js' -import {clearStoredStoreAppSession} from './auth/session-store.js' +import {reauthenticateStoreAuthError} from '../auth/recovery.js' +import {clearStoredStoreAppSession} from '../auth/session-store.js' +import type {PreparedStoreExecuteRequest} from './request.js' +import type {AdminStoreGraphQLContext} from './admin-context.js' function isGraphQLClientError(error: unknown): error is {response: {errors?: unknown; status?: number}} { if (!error || typeof error !== 'object' || !('response' in error)) return false @@ -15,10 +15,7 @@ function isGraphQLClientError(error: unknown): error is {response: {errors?: unk } export async function runAdminStoreGraphQLOperation(input: { - store: string - adminSession: AdminSession - sessionUserId: string - version: string + context: AdminStoreGraphQLContext request: PreparedStoreExecuteRequest }): Promise { try { @@ -28,8 +25,8 @@ export async function runAdminStoreGraphQLOperation(input: { return graphqlRequest({ query: input.request.query, api: 'Admin', - url: adminUrl(input.adminSession.storeFqdn, input.version, input.adminSession), - token: input.adminSession.token, + url: adminUrl(input.context.adminSession.storeFqdn, input.context.version, input.context.adminSession), + token: input.context.adminSession.token, variables: input.request.parsedVariables, responseOptions: {handleErrors: false}, }) @@ -38,11 +35,11 @@ export async function runAdminStoreGraphQLOperation(input: { }) } catch (error) { if (isGraphQLClientError(error) && error.response.status === 401) { - clearStoredStoreAppSession(input.store, input.sessionUserId) + clearStoredStoreAppSession(input.context.session.store, input.context.session.userId) throw reauthenticateStoreAuthError( - `Stored app authentication for ${input.store} is no longer valid.`, - input.store, - '', + `Stored app authentication for ${input.context.session.store} is no longer valid.`, + input.context.session.store, + input.context.session.scopes.join(','), ) } diff --git a/packages/cli/src/cli/services/store/execute/index.test.ts b/packages/cli/src/cli/services/store/execute/index.test.ts new file mode 100644 index 00000000000..5c69b6fa0c3 --- /dev/null +++ b/packages/cli/src/cli/services/store/execute/index.test.ts @@ -0,0 +1,77 @@ +import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest' +import {renderSingleTask} from '@shopify/cli-kit/node/ui' +import {executeStoreOperation} from './index.js' +import {prepareStoreExecuteRequest} from './request.js' +import {writeOrOutputStoreExecuteResult} from './result.js' +import {getStoreGraphQLTarget} from './targets.js' +import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' + +vi.mock('./request.js') +vi.mock('./result.js') +vi.mock('./targets.js') +vi.mock('@shopify/cli-kit/node/ui') + +describe('executeStoreOperation', () => { + const request = { + query: 'query { shop { name } }', + parsedOperation: {operationDefinition: {operation: 'query'}}, + parsedVariables: {id: 'gid://shopify/Shop/1'}, + requestedVersion: '2025-10', + outputFile: '/tmp/result.json', + } as any + const context = {kind: 'admin-context'} as any + const result = {data: {shop: {name: 'Test shop'}}} + const target = { + prepareContext: vi.fn(), + execute: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(prepareStoreExecuteRequest).mockResolvedValue(request) + vi.mocked(getStoreGraphQLTarget).mockReturnValue(target as any) + target.prepareContext.mockResolvedValue(context) + target.execute.mockResolvedValue(result) + vi.mocked(renderSingleTask).mockImplementation(async ({task}) => task(() => {})) + }) + + afterEach(() => { + mockAndCaptureOutput().clear() + }) + + test('prepares the request, loads context, executes the target, and writes the result', async () => { + await executeStoreOperation({ + store: 'shop.myshopify.com', + query: 'query { shop { name } }', + variables: '{"id":"gid://shopify/Shop/1"}', + outputFile: '/tmp/result.json', + version: '2025-10', + }) + + expect(getStoreGraphQLTarget).toHaveBeenCalledWith('admin') + expect(prepareStoreExecuteRequest).toHaveBeenCalledWith({ + query: 'query { shop { name } }', + queryFile: undefined, + variables: '{"id":"gid://shopify/Shop/1"}', + variableFile: undefined, + outputFile: '/tmp/result.json', + version: '2025-10', + allowMutations: undefined, + }) + expect(target.prepareContext).toHaveBeenCalledWith({ + store: 'shop.myshopify.com', + requestedVersion: '2025-10', + }) + expect(target.execute).toHaveBeenCalledWith({context, request}) + expect(writeOrOutputStoreExecuteResult).toHaveBeenCalledWith(result, '/tmp/result.json') + }) + + test('defaults to the admin target', async () => { + await executeStoreOperation({ + store: 'shop.myshopify.com', + query: 'query { shop { name } }', + }) + + expect(getStoreGraphQLTarget).toHaveBeenCalledWith('admin') + }) +}) diff --git a/packages/cli/src/cli/services/store/execute.ts b/packages/cli/src/cli/services/store/execute/index.ts similarity index 78% rename from packages/cli/src/cli/services/store/execute.ts rename to packages/cli/src/cli/services/store/execute/index.ts index 899bf5a662d..a25556e2de9 100644 --- a/packages/cli/src/cli/services/store/execute.ts +++ b/packages/cli/src/cli/services/store/execute/index.ts @@ -1,8 +1,8 @@ import {renderSingleTask} from '@shopify/cli-kit/node/ui' import {outputContent} from '@shopify/cli-kit/node/output' -import {prepareStoreExecuteRequest} from './execute-request.js' -import {writeOrOutputStoreExecuteResult} from './execute-result.js' -import {getStoreGraphQLTarget, StoreGraphQLApi} from './graphql-targets.js' +import {prepareStoreExecuteRequest} from './request.js' +import {writeOrOutputStoreExecuteResult} from './result.js' +import {getStoreGraphQLTarget, type StoreGraphQLApi} from './targets.js' interface ExecuteStoreOperationInput { store: string @@ -35,11 +35,7 @@ export async function executeStoreOperation(input: ExecuteStoreOperationInput): renderOptions: {stdout: process.stderr}, }) - const result = await target.execute({ - store: input.store, - context, - request, - }) + const result = await target.execute({context, request}) await writeOrOutputStoreExecuteResult(result, request.outputFile) } diff --git a/packages/cli/src/cli/services/store/execute-request.test.ts b/packages/cli/src/cli/services/store/execute/request.test.ts similarity index 98% rename from packages/cli/src/cli/services/store/execute-request.test.ts rename to packages/cli/src/cli/services/store/execute/request.test.ts index 7146746bced..df625bb994f 100644 --- a/packages/cli/src/cli/services/store/execute-request.test.ts +++ b/packages/cli/src/cli/services/store/execute/request.test.ts @@ -1,6 +1,6 @@ import {beforeEach, describe, expect, test, vi} from 'vitest' import {fileExists, readFile} from '@shopify/cli-kit/node/fs' -import {prepareStoreExecuteRequest} from './execute-request.js' +import {prepareStoreExecuteRequest} from './request.js' vi.mock('@shopify/cli-kit/node/fs') diff --git a/packages/cli/src/cli/services/store/execute-request.ts b/packages/cli/src/cli/services/store/execute/request.ts similarity index 100% rename from packages/cli/src/cli/services/store/execute-request.ts rename to packages/cli/src/cli/services/store/execute/request.ts diff --git a/packages/cli/src/cli/services/store/execute-result.test.ts b/packages/cli/src/cli/services/store/execute/result.test.ts similarity index 94% rename from packages/cli/src/cli/services/store/execute-result.test.ts rename to packages/cli/src/cli/services/store/execute/result.test.ts index 2a370560acf..bbde05a73cf 100644 --- a/packages/cli/src/cli/services/store/execute-result.test.ts +++ b/packages/cli/src/cli/services/store/execute/result.test.ts @@ -2,7 +2,7 @@ import {beforeEach, describe, expect, test, vi} from 'vitest' import {writeFile} from '@shopify/cli-kit/node/fs' import {renderSuccess} from '@shopify/cli-kit/node/ui' import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' -import {writeOrOutputStoreExecuteResult} from './execute-result.js' +import {writeOrOutputStoreExecuteResult} from './result.js' vi.mock('@shopify/cli-kit/node/fs') vi.mock('@shopify/cli-kit/node/ui') diff --git a/packages/cli/src/cli/services/store/execute-result.ts b/packages/cli/src/cli/services/store/execute/result.ts similarity index 100% rename from packages/cli/src/cli/services/store/execute-result.ts rename to packages/cli/src/cli/services/store/execute/result.ts diff --git a/packages/cli/src/cli/services/store/graphql-targets.test.ts b/packages/cli/src/cli/services/store/execute/targets.test.ts similarity index 57% rename from packages/cli/src/cli/services/store/graphql-targets.test.ts rename to packages/cli/src/cli/services/store/execute/targets.test.ts index a5fba30398d..28fc67d8b92 100644 --- a/packages/cli/src/cli/services/store/graphql-targets.test.ts +++ b/packages/cli/src/cli/services/store/execute/targets.test.ts @@ -1,11 +1,12 @@ import {beforeEach, describe, expect, test, vi} from 'vitest' -import {prepareStoreExecuteRequest} from './execute-request.js' -import {prepareAdminStoreGraphQLContext} from './admin-graphql-context.js' -import {runAdminStoreGraphQLOperation} from './admin-graphql-transport.js' -import {getStoreGraphQLTarget} from './graphql-targets.js' +import {prepareStoreExecuteRequest} from './request.js' +import {prepareAdminStoreGraphQLContext} from './admin-context.js' +import {runAdminStoreGraphQLOperation} from './admin-transport.js' +import {getStoreGraphQLTarget} from './targets.js' +import {STORE_AUTH_APP_CLIENT_ID} from '../auth/config.js' -vi.mock('./admin-graphql-context.js') -vi.mock('./admin-graphql-transport.js') +vi.mock('./admin-context.js') +vi.mock('./admin-transport.js') describe('getStoreGraphQLTarget', () => { beforeEach(() => { @@ -18,28 +19,26 @@ describe('getStoreGraphQLTarget', () => { const context = { adminSession: {token: 'token', storeFqdn: 'shop.myshopify.com'}, version: '2025-10', - sessionUserId: '42', + session: { + store: 'shop.myshopify.com', + clientId: STORE_AUTH_APP_CLIENT_ID, + userId: '42', + accessToken: 'token', + scopes: ['read_products'], + acquiredAt: '2026-03-27T00:00:00.000Z', + }, } vi.mocked(prepareAdminStoreGraphQLContext).mockResolvedValue(context) vi.mocked(runAdminStoreGraphQLOperation).mockResolvedValue({data: {shop: {name: 'Test shop'}}}) await expect(target.prepareContext({store: 'shop.myshopify.com', requestedVersion: '2025-10'})).resolves.toEqual(context) - - await expect(target.execute({store: 'shop.myshopify.com', context, request})).resolves.toEqual({ - data: {shop: {name: 'Test shop'}}, - }) + await expect(target.execute({context, request})).resolves.toEqual({data: {shop: {name: 'Test shop'}}}) expect(prepareAdminStoreGraphQLContext).toHaveBeenCalledWith({ store: 'shop.myshopify.com', userSpecifiedVersion: '2025-10', }) - expect(runAdminStoreGraphQLOperation).toHaveBeenCalledWith({ - store: 'shop.myshopify.com', - adminSession: context.adminSession, - sessionUserId: context.sessionUserId, - version: context.version, - request, - }) + expect(runAdminStoreGraphQLOperation).toHaveBeenCalledWith({context, request}) }) }) diff --git a/packages/cli/src/cli/services/store/graphql-targets.ts b/packages/cli/src/cli/services/store/execute/targets.ts similarity index 61% rename from packages/cli/src/cli/services/store/graphql-targets.ts rename to packages/cli/src/cli/services/store/execute/targets.ts index 809568d7e2e..125a86410b1 100644 --- a/packages/cli/src/cli/services/store/graphql-targets.ts +++ b/packages/cli/src/cli/services/store/execute/targets.ts @@ -1,7 +1,7 @@ import {BugError} from '@shopify/cli-kit/node/error' -import {PreparedStoreExecuteRequest} from './execute-request.js' -import {prepareAdminStoreGraphQLContext, AdminStoreGraphQLContext} from './admin-graphql-context.js' -import {runAdminStoreGraphQLOperation} from './admin-graphql-transport.js' +import type {PreparedStoreExecuteRequest} from './request.js' +import {prepareAdminStoreGraphQLContext, type AdminStoreGraphQLContext} from './admin-context.js' +import {runAdminStoreGraphQLOperation} from './admin-transport.js' export type StoreGraphQLApi = 'admin' @@ -11,13 +11,10 @@ interface PrepareStoreGraphQLTargetContextInput { } interface ExecuteStoreGraphQLTargetInput { - store: string context: TContext request: PreparedStoreExecuteRequest } -// Internal seam for store-scoped GraphQL APIs. Different targets may need different -// auth/context preparation and execution behavior, so each target owns both phases. interface StoreGraphQLTarget { id: StoreGraphQLApi prepareContext(input: PrepareStoreGraphQLTargetContextInput): Promise @@ -29,14 +26,8 @@ const adminStoreGraphQLTarget: StoreGraphQLTarget = { prepareContext: async ({store, requestedVersion}) => { return prepareAdminStoreGraphQLContext({store, userSpecifiedVersion: requestedVersion}) }, - execute: async ({store, context, request}) => { - return runAdminStoreGraphQLOperation({ - store, - adminSession: context.adminSession, - sessionUserId: context.sessionUserId, - version: context.version, - request, - }) + execute: async ({context, request}) => { + return runAdminStoreGraphQLOperation({context, request}) }, } From cc775f87458654bd5a6ab540a6cc53140451916a Mon Sep 17 00:00:00 2001 From: Donald Merand Date: Fri, 3 Apr 2026 16:05:05 -0400 Subject: [PATCH 30/31] Add/enforce --json for store commands --- .../interfaces/store-auth.interface.ts | 6 ++ .../interfaces/store-execute.interface.ts | 6 ++ .../generated/generated_docs_data.json | 22 +++- packages/cli/README.md | 10 +- packages/cli/oclif.manifest.json | 24 ++++- .../cli/src/cli/commands/store/auth.test.ts | 32 +++++- packages/cli/src/cli/commands/store/auth.ts | 22 ++-- .../src/cli/commands/store/execute.test.ts | 24 ++++- .../cli/src/cli/commands/store/execute.ts | 10 +- .../src/cli/services/store/auth/index.test.ts | 19 +++- .../cli/src/cli/services/store/auth/index.ts | 56 ++++------ .../cli/services/store/auth/result.test.ts | 102 ++++++++++++++++++ .../cli/src/cli/services/store/auth/result.ts | 71 ++++++++++++ .../cli/services/store/execute/index.test.ts | 22 ++-- .../src/cli/services/store/execute/index.ts | 9 +- .../services/store/execute/request.test.ts | 3 +- .../src/cli/services/store/execute/request.ts | 3 - .../cli/services/store/execute/result.test.ts | 63 ++++++++++- .../src/cli/services/store/execute/result.ts | 32 ++++-- 19 files changed, 440 insertions(+), 96 deletions(-) create mode 100644 packages/cli/src/cli/services/store/auth/result.test.ts create mode 100644 packages/cli/src/cli/services/store/auth/result.ts diff --git a/docs-shopify.dev/commands/interfaces/store-auth.interface.ts b/docs-shopify.dev/commands/interfaces/store-auth.interface.ts index e38a6d147e8..66957c80ac8 100644 --- a/docs-shopify.dev/commands/interfaces/store-auth.interface.ts +++ b/docs-shopify.dev/commands/interfaces/store-auth.interface.ts @@ -1,5 +1,11 @@ // This is an autogenerated file. Don't edit this file manually. export interface storeauth { + /** + * Output the result as JSON. Automatically disables color output. + * @environment SHOPIFY_FLAG_JSON + */ + '-j, --json'?: '' + /** * Disable color output. * @environment SHOPIFY_FLAG_NO_COLOR diff --git a/docs-shopify.dev/commands/interfaces/store-execute.interface.ts b/docs-shopify.dev/commands/interfaces/store-execute.interface.ts index 3bff68a8f95..39550404db6 100644 --- a/docs-shopify.dev/commands/interfaces/store-execute.interface.ts +++ b/docs-shopify.dev/commands/interfaces/store-execute.interface.ts @@ -6,6 +6,12 @@ export interface storeexecute { */ '--allow-mutations'?: '' + /** + * Output the result as JSON. Automatically disables color output. + * @environment SHOPIFY_FLAG_JSON + */ + '-j, --json'?: '' + /** * Disable color output. * @environment SHOPIFY_FLAG_NO_COLOR diff --git a/docs-shopify.dev/generated/generated_docs_data.json b/docs-shopify.dev/generated/generated_docs_data.json index 75162409d11..caac62cb2d3 100644 --- a/docs-shopify.dev/generated/generated_docs_data.json +++ b/docs-shopify.dev/generated/generated_docs_data.json @@ -5829,6 +5829,15 @@ "isOptional": true, "environmentValue": "SHOPIFY_FLAG_VERBOSE" }, + { + "filePath": "docs-shopify.dev/commands/interfaces/store-auth.interface.ts", + "syntaxKind": "PropertySignature", + "name": "-j, --json", + "value": "\"\"", + "description": "Output the result as JSON. Automatically disables color output.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_JSON" + }, { "filePath": "docs-shopify.dev/commands/interfaces/store-auth.interface.ts", "syntaxKind": "PropertySignature", @@ -5838,7 +5847,7 @@ "environmentValue": "SHOPIFY_FLAG_STORE" } ], - "value": "export interface storeauth {\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Comma-separated Admin API scopes to request for the app.\n * @environment SHOPIFY_FLAG_SCOPES\n */\n '--scopes ': string\n\n /**\n * The myshopify.com domain of the store to authenticate against.\n * @environment SHOPIFY_FLAG_STORE\n */\n '-s, --store ': string\n\n /**\n * Increase the verbosity of the output.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" + "value": "export interface storeauth {\n /**\n * Output the result as JSON. Automatically disables color output.\n * @environment SHOPIFY_FLAG_JSON\n */\n '-j, --json'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Comma-separated Admin API scopes to request for the app.\n * @environment SHOPIFY_FLAG_SCOPES\n */\n '--scopes ': string\n\n /**\n * The myshopify.com domain of the store to authenticate against.\n * @environment SHOPIFY_FLAG_STORE\n */\n '-s, --store ': string\n\n /**\n * Increase the verbosity of the output.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" } } } @@ -5938,6 +5947,15 @@ "isOptional": true, "environmentValue": "SHOPIFY_FLAG_VERSION" }, + { + "filePath": "docs-shopify.dev/commands/interfaces/store-execute.interface.ts", + "syntaxKind": "PropertySignature", + "name": "-j, --json", + "value": "\"\"", + "description": "Output the result as JSON. Automatically disables color output.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_JSON" + }, { "filePath": "docs-shopify.dev/commands/interfaces/store-execute.interface.ts", "syntaxKind": "PropertySignature", @@ -5965,7 +5983,7 @@ "environmentValue": "SHOPIFY_FLAG_VARIABLES" } ], - "value": "export interface storeexecute {\n /**\n * Allow GraphQL mutations to run against the target store.\n * @environment SHOPIFY_FLAG_ALLOW_MUTATIONS\n */\n '--allow-mutations'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * The file name where results should be written, instead of STDOUT.\n * @environment SHOPIFY_FLAG_OUTPUT_FILE\n */\n '--output-file '?: string\n\n /**\n * The GraphQL query or mutation, as a string.\n * @environment SHOPIFY_FLAG_QUERY\n */\n '-q, --query '?: string\n\n /**\n * Path to a file containing the GraphQL query or mutation. Can't be used with --query.\n * @environment SHOPIFY_FLAG_QUERY_FILE\n */\n '--query-file '?: string\n\n /**\n * The myshopify.com domain of the store to execute against.\n * @environment SHOPIFY_FLAG_STORE\n */\n '-s, --store ': string\n\n /**\n * Path to a file containing GraphQL variables in JSON format. Can't be used with --variables.\n * @environment SHOPIFY_FLAG_VARIABLE_FILE\n */\n '--variable-file '?: string\n\n /**\n * The values for any GraphQL variables in your query or mutation, in JSON format.\n * @environment SHOPIFY_FLAG_VARIABLES\n */\n '-v, --variables '?: string\n\n /**\n * Increase the verbosity of the output.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n\n /**\n * The API version to use for the query or mutation. Defaults to the latest stable version.\n * @environment SHOPIFY_FLAG_VERSION\n */\n '--version '?: string\n}" + "value": "export interface storeexecute {\n /**\n * Allow GraphQL mutations to run against the target store.\n * @environment SHOPIFY_FLAG_ALLOW_MUTATIONS\n */\n '--allow-mutations'?: ''\n\n /**\n * Output the result as JSON. Automatically disables color output.\n * @environment SHOPIFY_FLAG_JSON\n */\n '-j, --json'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * The file name where results should be written, instead of STDOUT.\n * @environment SHOPIFY_FLAG_OUTPUT_FILE\n */\n '--output-file '?: string\n\n /**\n * The GraphQL query or mutation, as a string.\n * @environment SHOPIFY_FLAG_QUERY\n */\n '-q, --query '?: string\n\n /**\n * Path to a file containing the GraphQL query or mutation. Can't be used with --query.\n * @environment SHOPIFY_FLAG_QUERY_FILE\n */\n '--query-file '?: string\n\n /**\n * The myshopify.com domain of the store to execute against.\n * @environment SHOPIFY_FLAG_STORE\n */\n '-s, --store ': string\n\n /**\n * Path to a file containing GraphQL variables in JSON format. Can't be used with --variables.\n * @environment SHOPIFY_FLAG_VARIABLE_FILE\n */\n '--variable-file '?: string\n\n /**\n * The values for any GraphQL variables in your query or mutation, in JSON format.\n * @environment SHOPIFY_FLAG_VARIABLES\n */\n '-v, --variables '?: string\n\n /**\n * Increase the verbosity of the output.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n\n /**\n * The API version to use for the query or mutation. Defaults to the latest stable version.\n * @environment SHOPIFY_FLAG_VERSION\n */\n '--version '?: string\n}" } } } diff --git a/packages/cli/README.md b/packages/cli/README.md index ce2d7756c14..9a25dfeb007 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -2057,9 +2057,10 @@ Authenticate an app against a store for store commands. ``` USAGE - $ shopify store auth --scopes -s [--no-color] [--verbose] + $ shopify store auth --scopes -s [-j] [--no-color] [--verbose] FLAGS + -j, --json [env: SHOPIFY_FLAG_JSON] Output the result as JSON. Automatically disables color output. -s, --store= (required) [env: SHOPIFY_FLAG_STORE] The myshopify.com domain of the store to authenticate against. --no-color [env: SHOPIFY_FLAG_NO_COLOR] Disable color output. @@ -2076,6 +2077,8 @@ DESCRIPTION EXAMPLES $ shopify store auth --store shop.myshopify.com --scopes read_products,write_products + + $ shopify store auth --store shop.myshopify.com --scopes read_products,write_products --json ``` ## `shopify store execute` @@ -2084,10 +2087,11 @@ Execute GraphQL queries and mutations on a store. ``` USAGE - $ shopify store execute -s [--allow-mutations] [--no-color] [--output-file ] [-q ] + $ shopify store execute -s [--allow-mutations] [-j] [--no-color] [--output-file ] [-q ] [--query-file ] [--variable-file | -v ] [--verbose] [--version ] FLAGS + -j, --json [env: SHOPIFY_FLAG_JSON] Output the result as JSON. Automatically disables color output. -q, --query= [env: SHOPIFY_FLAG_QUERY] The GraphQL query or mutation, as a string. -s, --store= (required) [env: SHOPIFY_FLAG_STORE] The myshopify.com domain of the store to execute against. @@ -2121,6 +2125,8 @@ EXAMPLES $ shopify store execute --store shop.myshopify.com --query-file ./operation.graphql --variables '{"id":"gid://shopify/Product/1"}' $ shopify store execute --store shop.myshopify.com --query "mutation { shop { id } }" --allow-mutations + + $ shopify store execute --store shop.myshopify.com --query "query { shop { name } }" --json ``` ## `shopify theme check` diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index b9be73bdcd4..82dbca3b896 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -5743,9 +5743,19 @@ "descriptionWithMarkdown": "Authenticates the app against the specified store for store commands and stores an online access token for later reuse.\n\nRe-run this command if the stored token is missing, expires, or no longer has the scopes you need.", "enableJsonFlag": false, "examples": [ - "<%= config.bin %> <%= command.id %> --store shop.myshopify.com --scopes read_products,write_products" + "<%= config.bin %> <%= command.id %> --store shop.myshopify.com --scopes read_products,write_products", + "<%= config.bin %> <%= command.id %> --store shop.myshopify.com --scopes read_products,write_products --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.", @@ -5803,7 +5813,8 @@ "examples": [ "<%= config.bin %> <%= command.id %> --store shop.myshopify.com --query \"query { shop { name } }\"", "<%= config.bin %> <%= command.id %> --store shop.myshopify.com --query-file ./operation.graphql --variables '{\"id\":\"gid://shopify/Product/1\"}'", - "<%= config.bin %> <%= command.id %> --store shop.myshopify.com --query \"mutation { shop { id } }\" --allow-mutations" + "<%= config.bin %> <%= command.id %> --store shop.myshopify.com --query \"mutation { shop { id } }\" --allow-mutations", + "<%= config.bin %> <%= command.id %> --store shop.myshopify.com --query \"query { shop { name } }\" --json" ], "flags": { "allow-mutations": { @@ -5813,6 +5824,15 @@ "name": "allow-mutations", "type": "boolean" }, + "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.", diff --git a/packages/cli/src/cli/commands/store/auth.test.ts b/packages/cli/src/cli/commands/store/auth.test.ts index 21701dff48b..d3b5276b1ba 100644 --- a/packages/cli/src/cli/commands/store/auth.test.ts +++ b/packages/cli/src/cli/commands/store/auth.test.ts @@ -1,8 +1,12 @@ -import {describe, test, expect, vi, beforeEach} from 'vitest' +import {beforeEach, describe, expect, test, vi} from 'vitest' import StoreAuth from './auth.js' import {authenticateStoreWithApp} from '../../services/store/auth/index.js' +import {createStoreAuthPresenter} from '../../services/store/auth/result.js' vi.mock('../../services/store/auth/index.js') +vi.mock('../../services/store/auth/result.js', () => ({ + createStoreAuthPresenter: vi.fn((format: 'text' | 'json') => ({format})), +})) describe('store auth command', () => { beforeEach(() => { @@ -12,15 +16,33 @@ describe('store auth command', () => { test('passes parsed flags through to the auth service', async () => { await StoreAuth.run(['--store', 'shop.myshopify.com', '--scopes', 'read_products,write_products']) - expect(authenticateStoreWithApp).toHaveBeenCalledWith({ - store: 'shop.myshopify.com', - scopes: 'read_products,write_products', - }) + expect(createStoreAuthPresenter).toHaveBeenCalledWith('text') + expect(authenticateStoreWithApp).toHaveBeenCalledWith( + { + store: 'shop.myshopify.com', + scopes: 'read_products,write_products', + }, + {presenter: {format: 'text'}}, + ) + }) + + test('passes a json presenter when --json is provided', async () => { + await StoreAuth.run(['--store', 'shop.myshopify.com', '--scopes', 'read_products', '--json']) + + expect(createStoreAuthPresenter).toHaveBeenCalledWith('json') + expect(authenticateStoreWithApp).toHaveBeenCalledWith( + { + store: 'shop.myshopify.com', + scopes: 'read_products', + }, + {presenter: {format: 'json'}}, + ) }) test('defines the expected flags', () => { expect(StoreAuth.flags.store).toBeDefined() expect(StoreAuth.flags.scopes).toBeDefined() + expect(StoreAuth.flags.json).toBeDefined() expect('port' in StoreAuth.flags).toBe(false) expect('client-secret-file' in StoreAuth.flags).toBe(false) }) diff --git a/packages/cli/src/cli/commands/store/auth.ts b/packages/cli/src/cli/commands/store/auth.ts index 6b46ece1257..a42d4dc327e 100644 --- a/packages/cli/src/cli/commands/store/auth.ts +++ b/packages/cli/src/cli/commands/store/auth.ts @@ -1,8 +1,9 @@ import Command from '@shopify/cli-kit/node/base-command' -import {globalFlags} from '@shopify/cli-kit/node/cli' +import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli' import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' import {Flags} from '@oclif/core' import {authenticateStoreWithApp} from '../../services/store/auth/index.js' +import {createStoreAuthPresenter} from '../../services/store/auth/result.js' export default class StoreAuth extends Command { static summary = 'Authenticate an app against a store for store commands.' @@ -13,10 +14,14 @@ Re-run this command if the stored token is missing, expires, or no longer has th static description = this.descriptionWithoutMarkdown() - static examples = ['<%= config.bin %> <%= command.id %> --store shop.myshopify.com --scopes read_products,write_products'] + static examples = [ + '<%= config.bin %> <%= command.id %> --store shop.myshopify.com --scopes read_products,write_products', + '<%= config.bin %> <%= command.id %> --store shop.myshopify.com --scopes read_products,write_products --json', + ] static flags = { ...globalFlags, + ...jsonFlag, store: Flags.string({ char: 's', description: 'The myshopify.com domain of the store to authenticate against.', @@ -34,9 +39,14 @@ Re-run this command if the stored token is missing, expires, or no longer has th async run(): Promise { const {flags} = await this.parse(StoreAuth) - await authenticateStoreWithApp({ - store: flags.store, - scopes: flags.scopes, - }) + await authenticateStoreWithApp( + { + store: flags.store, + scopes: flags.scopes, + }, + { + presenter: createStoreAuthPresenter(flags.json ? 'json' : 'text'), + }, + ) } } diff --git a/packages/cli/src/cli/commands/store/execute.test.ts b/packages/cli/src/cli/commands/store/execute.test.ts index 4d7bd1e2ba7..ffd339ac323 100644 --- a/packages/cli/src/cli/commands/store/execute.test.ts +++ b/packages/cli/src/cli/commands/store/execute.test.ts @@ -1,15 +1,18 @@ -import {describe, test, expect, vi, beforeEach} from 'vitest' +import {beforeEach, describe, expect, test, vi} from 'vitest' import StoreExecute from './execute.js' import {executeStoreOperation} from '../../services/store/execute/index.js' +import {writeOrOutputStoreExecuteResult} from '../../services/store/execute/result.js' vi.mock('../../services/store/execute/index.js') +vi.mock('../../services/store/execute/result.js') describe('store execute command', () => { beforeEach(() => { vi.clearAllMocks() + vi.mocked(executeStoreOperation).mockResolvedValue({data: {shop: {name: 'Test shop'}}}) }) - test('passes the inline query through to the service', async () => { + test('passes the inline query through to the service and writes the result', async () => { await StoreExecute.run(['--store', 'shop.myshopify.com', '--query', 'query { shop { name } }']) expect(executeStoreOperation).toHaveBeenCalledWith({ @@ -18,10 +21,14 @@ describe('store execute command', () => { queryFile: undefined, variables: undefined, variableFile: undefined, - outputFile: undefined, version: undefined, allowMutations: false, }) + expect(writeOrOutputStoreExecuteResult).toHaveBeenCalledWith( + {data: {shop: {name: 'Test shop'}}}, + undefined, + 'text', + ) }) test('passes the query file through to the service', async () => { @@ -36,6 +43,16 @@ describe('store execute command', () => { ) }) + test('writes json output when --json is provided', async () => { + await StoreExecute.run(['--store', 'shop.myshopify.com', '--query', 'query { shop { name } }', '--json']) + + expect(writeOrOutputStoreExecuteResult).toHaveBeenCalledWith( + {data: {shop: {name: 'Test shop'}}}, + undefined, + 'json', + ) + }) + test('defines the expected flags', () => { expect(StoreExecute.flags.store).toBeDefined() expect(StoreExecute.flags.query).toBeDefined() @@ -43,5 +60,6 @@ describe('store execute command', () => { expect(StoreExecute.flags.variables).toBeDefined() expect(StoreExecute.flags['variable-file']).toBeDefined() expect(StoreExecute.flags['allow-mutations']).toBeDefined() + expect(StoreExecute.flags.json).toBeDefined() }) }) diff --git a/packages/cli/src/cli/commands/store/execute.ts b/packages/cli/src/cli/commands/store/execute.ts index bffc3e21ec4..a184d6d3d8a 100644 --- a/packages/cli/src/cli/commands/store/execute.ts +++ b/packages/cli/src/cli/commands/store/execute.ts @@ -1,9 +1,10 @@ import Command from '@shopify/cli-kit/node/base-command' -import {globalFlags} from '@shopify/cli-kit/node/cli' +import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli' import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' import {resolvePath} from '@shopify/cli-kit/node/path' import {Flags} from '@oclif/core' import {executeStoreOperation} from '../../services/store/execute/index.js' +import {writeOrOutputStoreExecuteResult} from '../../services/store/execute/result.js' export default class StoreExecute extends Command { static summary = 'Execute GraphQL queries and mutations on a store.' @@ -20,10 +21,12 @@ Mutations are disabled by default. Re-run with \`--allow-mutations\` if you inte '<%= config.bin %> <%= command.id %> --store shop.myshopify.com --query "query { shop { name } }"', `<%= config.bin %> <%= command.id %> --store shop.myshopify.com --query-file ./operation.graphql --variables '{"id":"gid://shopify/Product/1"}'`, '<%= config.bin %> <%= command.id %> --store shop.myshopify.com --query "mutation { shop { id } }" --allow-mutations', + '<%= config.bin %> <%= command.id %> --store shop.myshopify.com --query "query { shop { name } }" --json', ] static flags = { ...globalFlags, + ...jsonFlag, query: Flags.string({ char: 'q', description: 'The GraphQL query or mutation, as a string.', @@ -75,15 +78,16 @@ Mutations are disabled by default. Re-run with \`--allow-mutations\` if you inte async run(): Promise { const {flags} = await this.parse(StoreExecute) - await executeStoreOperation({ + const result = await executeStoreOperation({ store: flags.store, query: flags.query, queryFile: flags['query-file'], variables: flags.variables, variableFile: flags['variable-file'], - outputFile: flags['output-file'], version: flags.version, allowMutations: flags['allow-mutations'], }) + + await writeOrOutputStoreExecuteResult(result, flags['output-file'], flags.json ? 'json' : 'text') } } diff --git a/packages/cli/src/cli/services/store/auth/index.test.ts b/packages/cli/src/cli/services/store/auth/index.test.ts index 3d438c29770..2a4323cb4e4 100644 --- a/packages/cli/src/cli/services/store/auth/index.test.ts +++ b/packages/cli/src/cli/services/store/auth/index.test.ts @@ -16,7 +16,7 @@ describe('store auth service', () => { vi.restoreAllMocks() }) - test('authenticateStoreWithApp opens the browser and stores the session with refresh token', async () => { + test('authenticateStoreWithApp opens the browser, stores the session, and returns auth result', async () => { const openURL = vi.fn().mockResolvedValue(true) const presenter = { openingBrowser: vi.fn(), @@ -28,7 +28,7 @@ describe('store auth service', () => { return 'abc123' }) - await authenticateStoreWithApp( + const result = await authenticateStoreWithApp( { store: 'shop.myshopify.com', scopes: 'read_products', @@ -50,7 +50,16 @@ describe('store auth service', () => { expect(presenter.openingBrowser).toHaveBeenCalledOnce() expect(openURL).toHaveBeenCalledWith(expect.stringContaining('/admin/oauth/authorize?')) expect(presenter.manualAuthUrl).not.toHaveBeenCalled() - expect(presenter.success).toHaveBeenCalledWith('shop.myshopify.com', 'test@example.com') + expect(result).toEqual( + expect.objectContaining({ + store: 'shop.myshopify.com', + userId: '42', + scopes: ['read_products'], + hasRefreshToken: true, + associatedUser: expect.objectContaining({email: 'test@example.com'}), + }), + ) + expect(presenter.success).toHaveBeenCalledWith(result) const storedSession = vi.mocked(setStoredStoreAppSession).mock.calls[0]![0] expect(storedSession.store).toBe('shop.myshopify.com') @@ -239,7 +248,7 @@ describe('store auth service', () => { return 'abc123' }) - await authenticateStoreWithApp( + const result = await authenticateStoreWithApp( { store: 'shop.myshopify.com', scopes: 'read_products', @@ -261,7 +270,7 @@ describe('store auth service', () => { expect(presenter.manualAuthUrl).toHaveBeenCalledWith( expect.stringContaining('https://shop.myshopify.com/admin/oauth/authorize?'), ) - expect(presenter.success).toHaveBeenCalledWith('shop.myshopify.com', 'test@example.com') + expect(presenter.success).toHaveBeenCalledWith(result) }) test('authenticateStoreWithApp rejects when Shopify grants fewer scopes than requested', async () => { diff --git a/packages/cli/src/cli/services/store/auth/index.ts b/packages/cli/src/cli/services/store/auth/index.ts index d96e287f364..eabd3bf3b37 100644 --- a/packages/cli/src/cli/services/store/auth/index.ts +++ b/packages/cli/src/cli/services/store/auth/index.ts @@ -1,6 +1,6 @@ import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' import {AbortError} from '@shopify/cli-kit/node/error' -import {outputCompleted, outputContent, outputDebug, outputInfo, outputToken} from '@shopify/cli-kit/node/output' +import {outputContent, outputDebug, outputToken} from '@shopify/cli-kit/node/output' import {openURL} from '@shopify/cli-kit/node/system' import {STORE_AUTH_APP_CLIENT_ID} from './config.js' import {setStoredStoreAppSession} from './session-store.js' @@ -9,18 +9,13 @@ import {waitForStoreAuthCode} from './callback.js' import {createPkceBootstrap} from './pkce.js' import {mergeRequestedAndStoredScopes, parseStoreAuthScopes, resolveGrantedScopes} from './scopes.js' import {resolveExistingStoreAuthScopes, type ResolvedStoreAuthScopes} from './existing-scopes.js' +import {createStoreAuthPresenter, type StoreAuthPresenter, type StoreAuthResult} from './result.js' interface StoreAuthInput { store: string scopes: string } -interface StoreAuthPresenter { - openingBrowser: () => void - manualAuthUrl: (authorizationUrl: string) => void - success: (store: string, email?: string) => void -} - interface StoreAuthDependencies { openURL: typeof openURL waitForStoreAuthCode: typeof waitForStoreAuthCode @@ -29,39 +24,18 @@ interface StoreAuthDependencies { presenter: StoreAuthPresenter } -const defaultStoreAuthPresenter: StoreAuthPresenter = { - openingBrowser() { - outputInfo('Shopify CLI will open the app authorization page in your browser.') - outputInfo('') - }, - manualAuthUrl(authorizationUrl: string) { - outputInfo('Browser did not open automatically. Open this URL manually:') - outputInfo(outputContent`${outputToken.link(authorizationUrl)}`) - outputInfo('') - }, - success(store: string, email?: string) { - const displayName = email ? ` as ${email}` : '' - - outputCompleted('Logged in.') - outputCompleted(`Authenticated${displayName} against ${store}.`) - outputInfo('') - outputInfo('To verify that authentication worked, run:') - outputInfo(`shopify store execute --store ${store} --query 'query { shop { name id } }'`) - }, -} - const defaultStoreAuthDependencies: StoreAuthDependencies = { openURL, waitForStoreAuthCode, exchangeStoreAuthCodeForToken, resolveExistingScopes: resolveExistingStoreAuthScopes, - presenter: defaultStoreAuthPresenter, + presenter: createStoreAuthPresenter('text'), } export async function authenticateStoreWithApp( input: StoreAuthInput, dependencies: Partial = {}, -): Promise { +): Promise { const resolvedDependencies: StoreAuthDependencies = {...defaultStoreAuthDependencies, ...dependencies} const store = normalizeStoreFqdn(input.store) const requestedScopes = parseStoreAuthScopes(input.scopes) @@ -103,18 +77,16 @@ export async function authenticateStoreWithApp( const now = Date.now() const expiresAt = tokenResponse.expires_in ? new Date(now + tokenResponse.expires_in * 1000).toISOString() : undefined - setStoredStoreAppSession({ + const result: StoreAuthResult = { store, - clientId: STORE_AUTH_APP_CLIENT_ID, userId, - accessToken: tokenResponse.access_token, - refreshToken: tokenResponse.refresh_token, scopes: resolveGrantedScopes(tokenResponse, validationScopes), acquiredAt: new Date(now).toISOString(), expiresAt, refreshTokenExpiresAt: tokenResponse.refresh_token_expires_in ? new Date(now + tokenResponse.refresh_token_expires_in * 1000).toISOString() : undefined, + hasRefreshToken: !!tokenResponse.refresh_token, associatedUser: tokenResponse.associated_user ? { id: tokenResponse.associated_user.id, @@ -124,11 +96,25 @@ export async function authenticateStoreWithApp( accountOwner: tokenResponse.associated_user.account_owner, } : undefined, + } + + setStoredStoreAppSession({ + store, + clientId: STORE_AUTH_APP_CLIENT_ID, + userId, + accessToken: tokenResponse.access_token, + refreshToken: tokenResponse.refresh_token, + scopes: result.scopes, + acquiredAt: result.acquiredAt, + expiresAt, + refreshTokenExpiresAt: result.refreshTokenExpiresAt, + associatedUser: result.associatedUser, }) outputDebug( outputContent`Session persisted for ${outputToken.raw(store)} (user ${outputToken.raw(userId)}, expires ${outputToken.raw(expiresAt ?? 'unknown')})`, ) - resolvedDependencies.presenter.success(store, tokenResponse.associated_user?.email) + resolvedDependencies.presenter.success(result) + return result } diff --git a/packages/cli/src/cli/services/store/auth/result.test.ts b/packages/cli/src/cli/services/store/auth/result.test.ts new file mode 100644 index 00000000000..83c1e9e9280 --- /dev/null +++ b/packages/cli/src/cli/services/store/auth/result.test.ts @@ -0,0 +1,102 @@ +import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest' +import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' +import {createStoreAuthPresenter} from './result.js' + +function captureStandardStreams() { + const stdout: string[] = [] + const stderr: string[] = [] + + const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: string | Uint8Array) => { + stdout.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8')) + return true + }) as typeof process.stdout.write) + const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(((chunk: string | Uint8Array) => { + stderr.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8')) + return true + }) as typeof process.stderr.write) + + return { + stdout: () => stdout.join(''), + stderr: () => stderr.join(''), + restore: () => { + stdoutSpy.mockRestore() + stderrSpy.mockRestore() + }, + } +} + +describe('store auth presenter', () => { + const originalUnitTestEnv = process.env.SHOPIFY_UNIT_TEST + + beforeEach(() => { + mockAndCaptureOutput().clear() + }) + + afterEach(() => { + process.env.SHOPIFY_UNIT_TEST = originalUnitTestEnv + }) + + test('renders human success output in text mode', () => { + const output = mockAndCaptureOutput() + const presenter = createStoreAuthPresenter('text') + + presenter.success({ + store: 'shop.myshopify.com', + userId: '42', + scopes: ['read_products'], + acquiredAt: '2026-04-02T00:00:00.000Z', + hasRefreshToken: true, + associatedUser: {id: 42, email: 'merchant@example.com'}, + }) + + expect(output.completed()).toContain('Logged in.') + expect(output.completed()).toContain('Authenticated as merchant@example.com against shop.myshopify.com.') + expect(output.info()).toContain("shopify store execute --store shop.myshopify.com --query 'query { shop { name id } }'") + expect(output.output()).not.toContain('"store": "shop.myshopify.com"') + }) + + test('writes json success output through the result channel', () => { + const output = mockAndCaptureOutput() + const presenter = createStoreAuthPresenter('json') + + presenter.success({ + store: 'shop.myshopify.com', + userId: '42', + scopes: ['read_products'], + acquiredAt: '2026-04-02T00:00:00.000Z', + hasRefreshToken: true, + associatedUser: {id: 42, email: 'merchant@example.com'}, + }) + + expect(output.output()).toContain('"store": "shop.myshopify.com"') + expect(output.completed()).not.toContain('Authenticated') + expect(output.info()).not.toContain('shopify store execute') + }) + + test('writes browser guidance to stderr and json success to stdout', () => { + process.env.SHOPIFY_UNIT_TEST = 'false' + const streams = captureStandardStreams() + const presenter = createStoreAuthPresenter('json') + + try { + presenter.openingBrowser() + presenter.manualAuthUrl('https://shop.myshopify.com/admin/oauth/authorize?client_id=test') + presenter.success({ + store: 'shop.myshopify.com', + userId: '42', + scopes: ['read_products'], + acquiredAt: '2026-04-02T00:00:00.000Z', + hasRefreshToken: true, + associatedUser: {id: 42, email: 'merchant@example.com'}, + }) + } finally { + streams.restore() + } + + expect(streams.stderr()).toContain('Shopify CLI will open the app authorization page in your browser.') + expect(streams.stderr()).toContain('Browser did not open automatically. Open this URL manually:') + expect(streams.stderr()).toContain('https://shop.myshopify.com/admin/oauth/authorize?client_id=test') + expect(streams.stdout()).toContain('"store": "shop.myshopify.com"') + expect(streams.stdout()).not.toContain('Authenticated') + }) +}) diff --git a/packages/cli/src/cli/services/store/auth/result.ts b/packages/cli/src/cli/services/store/auth/result.ts new file mode 100644 index 00000000000..4b7eec807e2 --- /dev/null +++ b/packages/cli/src/cli/services/store/auth/result.ts @@ -0,0 +1,71 @@ +import {outputCompleted, outputInfo, outputResult, outputToken, outputContent} from '@shopify/cli-kit/node/output' + +export interface StoreAuthResult { + store: string + userId: string + scopes: string[] + acquiredAt: string + expiresAt?: string + refreshTokenExpiresAt?: string + hasRefreshToken: boolean + associatedUser?: { + id: number + email?: string + firstName?: string + lastName?: string + accountOwner?: boolean + } +} + +type StoreAuthOutputFormat = 'text' | 'json' + +export interface StoreAuthPresenter { + openingBrowser: () => void + manualAuthUrl: (authorizationUrl: string) => void + success: (result: StoreAuthResult) => void +} + +function serializeStoreAuthResult(result: StoreAuthResult): string { + return JSON.stringify(result, null, 2) +} + +function buildStoreAuthSuccessText(result: StoreAuthResult): {completed: string[]; info: string[]} { + const displayName = result.associatedUser?.email ? ` as ${result.associatedUser.email}` : '' + + return { + completed: ['Logged in.', `Authenticated${displayName} against ${result.store}.`], + info: ['', 'To verify that authentication worked, run:', `shopify store execute --store ${result.store} --query 'query { shop { name id } }'`], + } +} + +function displayStoreAuthOpeningBrowser(): void { + outputInfo('Shopify CLI will open the app authorization page in your browser.') + outputInfo('') +} + +function displayStoreAuthManualAuthUrl(authorizationUrl: string): void { + outputInfo('Browser did not open automatically. Open this URL manually:') + outputInfo(outputContent`${outputToken.link(authorizationUrl)}`) + outputInfo('') +} + +function displayStoreAuthResult(result: StoreAuthResult, format: StoreAuthOutputFormat = 'text'): void { + if (format === 'json') { + outputResult(serializeStoreAuthResult(result)) + return + } + + const text = buildStoreAuthSuccessText(result) + text.completed.forEach((line) => outputCompleted(line)) + text.info.forEach((line) => outputInfo(line)) +} + +export function createStoreAuthPresenter(format: StoreAuthOutputFormat = 'text'): StoreAuthPresenter { + return { + openingBrowser: displayStoreAuthOpeningBrowser, + manualAuthUrl: displayStoreAuthManualAuthUrl, + success(result: StoreAuthResult) { + displayStoreAuthResult(result, format) + }, + } +} diff --git a/packages/cli/src/cli/services/store/execute/index.test.ts b/packages/cli/src/cli/services/store/execute/index.test.ts index 5c69b6fa0c3..f29b1aed02a 100644 --- a/packages/cli/src/cli/services/store/execute/index.test.ts +++ b/packages/cli/src/cli/services/store/execute/index.test.ts @@ -2,12 +2,10 @@ import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest' import {renderSingleTask} from '@shopify/cli-kit/node/ui' import {executeStoreOperation} from './index.js' import {prepareStoreExecuteRequest} from './request.js' -import {writeOrOutputStoreExecuteResult} from './result.js' import {getStoreGraphQLTarget} from './targets.js' import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' vi.mock('./request.js') -vi.mock('./result.js') vi.mock('./targets.js') vi.mock('@shopify/cli-kit/node/ui') @@ -17,7 +15,6 @@ describe('executeStoreOperation', () => { parsedOperation: {operationDefinition: {operation: 'query'}}, parsedVariables: {id: 'gid://shopify/Shop/1'}, requestedVersion: '2025-10', - outputFile: '/tmp/result.json', } as any const context = {kind: 'admin-context'} as any const result = {data: {shop: {name: 'Test shop'}}} @@ -39,14 +36,15 @@ describe('executeStoreOperation', () => { mockAndCaptureOutput().clear() }) - test('prepares the request, loads context, executes the target, and writes the result', async () => { - await executeStoreOperation({ - store: 'shop.myshopify.com', - query: 'query { shop { name } }', - variables: '{"id":"gid://shopify/Shop/1"}', - outputFile: '/tmp/result.json', - version: '2025-10', - }) + test('prepares the request, loads context, and returns the execution result', async () => { + await expect( + executeStoreOperation({ + store: 'shop.myshopify.com', + query: 'query { shop { name } }', + variables: '{"id":"gid://shopify/Shop/1"}', + version: '2025-10', + }), + ).resolves.toEqual(result) expect(getStoreGraphQLTarget).toHaveBeenCalledWith('admin') expect(prepareStoreExecuteRequest).toHaveBeenCalledWith({ @@ -54,7 +52,6 @@ describe('executeStoreOperation', () => { queryFile: undefined, variables: '{"id":"gid://shopify/Shop/1"}', variableFile: undefined, - outputFile: '/tmp/result.json', version: '2025-10', allowMutations: undefined, }) @@ -63,7 +60,6 @@ describe('executeStoreOperation', () => { requestedVersion: '2025-10', }) expect(target.execute).toHaveBeenCalledWith({context, request}) - expect(writeOrOutputStoreExecuteResult).toHaveBeenCalledWith(result, '/tmp/result.json') }) test('defaults to the admin target', async () => { diff --git a/packages/cli/src/cli/services/store/execute/index.ts b/packages/cli/src/cli/services/store/execute/index.ts index a25556e2de9..1394a37d262 100644 --- a/packages/cli/src/cli/services/store/execute/index.ts +++ b/packages/cli/src/cli/services/store/execute/index.ts @@ -1,7 +1,6 @@ import {renderSingleTask} from '@shopify/cli-kit/node/ui' import {outputContent} from '@shopify/cli-kit/node/output' import {prepareStoreExecuteRequest} from './request.js' -import {writeOrOutputStoreExecuteResult} from './result.js' import {getStoreGraphQLTarget, type StoreGraphQLApi} from './targets.js' interface ExecuteStoreOperationInput { @@ -11,12 +10,11 @@ interface ExecuteStoreOperationInput { queryFile?: string variables?: string variableFile?: string - outputFile?: string version?: string allowMutations?: boolean } -export async function executeStoreOperation(input: ExecuteStoreOperationInput): Promise { +export async function executeStoreOperation(input: ExecuteStoreOperationInput): Promise { const target = getStoreGraphQLTarget(input.api ?? 'admin') const request = await prepareStoreExecuteRequest({ @@ -24,7 +22,6 @@ export async function executeStoreOperation(input: ExecuteStoreOperationInput): queryFile: input.queryFile, variables: input.variables, variableFile: input.variableFile, - outputFile: input.outputFile, version: input.version, allowMutations: input.allowMutations, }) @@ -35,7 +32,5 @@ export async function executeStoreOperation(input: ExecuteStoreOperationInput): renderOptions: {stdout: process.stderr}, }) - const result = await target.execute({context, request}) - - await writeOrOutputStoreExecuteResult(result, request.outputFile) + return await target.execute({context, request}) } diff --git a/packages/cli/src/cli/services/store/execute/request.test.ts b/packages/cli/src/cli/services/store/execute/request.test.ts index df625bb994f..ce995fa9919 100644 --- a/packages/cli/src/cli/services/store/execute/request.test.ts +++ b/packages/cli/src/cli/services/store/execute/request.test.ts @@ -13,16 +13,15 @@ describe('prepareStoreExecuteRequest', () => { const request = await prepareStoreExecuteRequest({ query: 'query { shop { name } }', variables: '{"id":"gid://shopify/Shop/1"}', - outputFile: '/tmp/result.json', version: '2025-07', }) expect(request).toMatchObject({ query: 'query { shop { name } }', parsedVariables: {id: 'gid://shopify/Shop/1'}, - outputFile: '/tmp/result.json', requestedVersion: '2025-07', }) + expect(request).not.toHaveProperty('outputFile') }) test('reads the query from a file', async () => { diff --git a/packages/cli/src/cli/services/store/execute/request.ts b/packages/cli/src/cli/services/store/execute/request.ts index 7524b74b87d..2e605392682 100644 --- a/packages/cli/src/cli/services/store/execute/request.ts +++ b/packages/cli/src/cli/services/store/execute/request.ts @@ -11,7 +11,6 @@ export interface PreparedStoreExecuteRequest { query: string parsedOperation: ParsedGraphQLOperation parsedVariables?: {[key: string]: unknown} - outputFile?: string requestedVersion?: string } @@ -132,7 +131,6 @@ export async function prepareStoreExecuteRequest(input: { queryFile?: string variables?: string variableFile?: string - outputFile?: string version?: string allowMutations?: boolean }): Promise { @@ -145,7 +143,6 @@ export async function prepareStoreExecuteRequest(input: { query, parsedOperation, parsedVariables, - outputFile: input.outputFile, requestedVersion: input.version, } } diff --git a/packages/cli/src/cli/services/store/execute/result.test.ts b/packages/cli/src/cli/services/store/execute/result.test.ts index bbde05a73cf..083e5329019 100644 --- a/packages/cli/src/cli/services/store/execute/result.test.ts +++ b/packages/cli/src/cli/services/store/execute/result.test.ts @@ -1,4 +1,4 @@ -import {beforeEach, describe, expect, test, vi} from 'vitest' +import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest' import {writeFile} from '@shopify/cli-kit/node/fs' import {renderSuccess} from '@shopify/cli-kit/node/ui' import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' @@ -7,12 +7,41 @@ import {writeOrOutputStoreExecuteResult} from './result.js' vi.mock('@shopify/cli-kit/node/fs') vi.mock('@shopify/cli-kit/node/ui') +function captureStandardStreams() { + const stdout: string[] = [] + const stderr: string[] = [] + + const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: string | Uint8Array) => { + stdout.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8')) + return true + }) as typeof process.stdout.write) + const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(((chunk: string | Uint8Array) => { + stderr.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8')) + return true + }) as typeof process.stderr.write) + + return { + stdout: () => stdout.join(''), + stderr: () => stderr.join(''), + restore: () => { + stdoutSpy.mockRestore() + stderrSpy.mockRestore() + }, + } +} + describe('writeOrOutputStoreExecuteResult', () => { + const originalUnitTestEnv = process.env.SHOPIFY_UNIT_TEST + beforeEach(() => { vi.clearAllMocks() mockAndCaptureOutput().clear() }) + afterEach(() => { + process.env.SHOPIFY_UNIT_TEST = originalUnitTestEnv + }) + test('writes results to a file when outputFile is provided', async () => { await writeOrOutputStoreExecuteResult({data: {shop: {name: 'Test shop'}}}, '/tmp/results.json') @@ -29,6 +58,36 @@ describe('writeOrOutputStoreExecuteResult', () => { await writeOrOutputStoreExecuteResult({data: {shop: {name: 'Test shop'}}}) expect(renderSuccess).toHaveBeenCalledWith({headline: 'Operation succeeded.'}) - expect(output.info()).toContain('Test shop') + expect(output.output()).toContain('Test shop') + }) + + test('suppresses success rendering in json mode', async () => { + const output = mockAndCaptureOutput() + + await writeOrOutputStoreExecuteResult({data: {shop: {name: 'Test shop'}}}, undefined, 'json') + + expect(renderSuccess).not.toHaveBeenCalled() + expect(output.output()).toContain('Test shop') + }) + + test('writes json results to stdout without writing to stderr', async () => { + process.env.SHOPIFY_UNIT_TEST = 'false' + const streams = captureStandardStreams() + + try { + await writeOrOutputStoreExecuteResult({data: {shop: {name: 'Test shop'}}}, undefined, 'json') + } finally { + streams.restore() + } + + expect(streams.stdout()).toContain('"name": "Test shop"') + expect(streams.stderr()).toBe('') + }) + + test('suppresses success rendering when writing a file in json mode', async () => { + await writeOrOutputStoreExecuteResult({data: {shop: {name: 'Test shop'}}}, '/tmp/results.json', 'json') + + expect(writeFile).toHaveBeenCalledWith('/tmp/results.json', expect.stringContaining('Test shop')) + expect(renderSuccess).not.toHaveBeenCalled() }) }) diff --git a/packages/cli/src/cli/services/store/execute/result.ts b/packages/cli/src/cli/services/store/execute/result.ts index dd284a5deae..47e71bd678f 100644 --- a/packages/cli/src/cli/services/store/execute/result.ts +++ b/packages/cli/src/cli/services/store/execute/result.ts @@ -2,17 +2,37 @@ import {writeFile} from '@shopify/cli-kit/node/fs' import {outputResult} from '@shopify/cli-kit/node/output' import {renderSuccess} from '@shopify/cli-kit/node/ui' -export async function writeOrOutputStoreExecuteResult(result: unknown, outputFile?: string): Promise { - const resultString = JSON.stringify(result, null, 2) +type StoreExecuteOutputFormat = 'text' | 'json' +function serializeStoreExecuteResult(result: unknown): string { + return JSON.stringify(result, null, 2) +} + +function renderStoreExecuteSuccess(outputFile?: string): void { if (outputFile) { - await writeFile(outputFile, resultString) renderSuccess({ headline: 'Operation succeeded.', body: `Results written to ${outputFile}`, }) - } else { - renderSuccess({headline: 'Operation succeeded.'}) - outputResult(resultString) + return } + + renderSuccess({headline: 'Operation succeeded.'}) +} + +export async function writeOrOutputStoreExecuteResult( + result: unknown, + outputFile?: string, + format: StoreExecuteOutputFormat = 'text', +): Promise { + const serializedResult = serializeStoreExecuteResult(result) + + if (outputFile) { + await writeFile(outputFile, serializedResult) + if (format === 'text') renderStoreExecuteSuccess(outputFile) + return + } + + if (format === 'text') renderStoreExecuteSuccess() + outputResult(serializedResult) } From ba40871d16633ec1dbc1e805acf0f2383373db12 Mon Sep 17 00:00:00 2001 From: Melissa Luu Date: Wed, 8 Apr 2026 16:31:11 -0400 Subject: [PATCH 31/31] Use App object to resolve app assets --- .../src/cli/services/dev/extension.test.ts | 31 +++++++------------ .../app/src/cli/services/dev/extension.ts | 12 +++---- .../dev/processes/previewable-extension.ts | 5 ++- .../dev/processes/setup-dev-processes.ts | 1 + 4 files changed, 22 insertions(+), 27 deletions(-) diff --git a/packages/app/src/cli/services/dev/extension.test.ts b/packages/app/src/cli/services/dev/extension.test.ts index d69838e49cb..0acc5c5daf2 100644 --- a/packages/app/src/cli/services/dev/extension.test.ts +++ b/packages/app/src/cli/services/dev/extension.test.ts @@ -182,35 +182,28 @@ describe('devUIExtensions()', () => { }) describe('resolveAppAssets()', () => { - test('returns empty object when no admin extension exists', () => { - const extensions = [ - {specification: {identifier: 'ui_extension'}, configuration: {}, directory: '/app'}, - ] as unknown as Parameters[0] + test('returns empty object when app has no admin config', () => { + const app = {configuration: {}, directory: '/app'} as unknown as Parameters[0] - expect(resolveAppAssets(extensions)).toStrictEqual({}) + expect(resolveAppAssets(app)).toStrictEqual({}) }) - test('returns empty object when admin extension has no static_root', () => { - const extensions = [ - {specification: {identifier: 'admin'}, configuration: {admin: {}}, directory: '/app/extensions/admin'}, - ] as unknown as Parameters[0] + test('returns empty object when admin config has no static_root', () => { + const app = {configuration: {admin: {}}, directory: '/app'} as unknown as Parameters[0] - expect(resolveAppAssets(extensions)).toStrictEqual({}) + expect(resolveAppAssets(app)).toStrictEqual({}) }) test('returns staticRoot mapped to resolved absolute path when static_root is set', () => { - const extensions = [ - { - specification: {identifier: 'admin'}, - configuration: {admin: {static_root: 'public'}}, - directory: '/app/extensions/admin', - }, - ] as unknown as Parameters[0] + const app = { + configuration: {admin: {static_root: 'public'}}, + directory: '/app', + } as unknown as Parameters[0] - const result = resolveAppAssets(extensions) + const result = resolveAppAssets(app) expect(result).toStrictEqual({ - staticRoot: '/app/extensions/admin/public', + staticRoot: '/app/public', }) }) }) diff --git a/packages/app/src/cli/services/dev/extension.ts b/packages/app/src/cli/services/dev/extension.ts index 98cb1bc8918..c20c325d619 100644 --- a/packages/app/src/cli/services/dev/extension.ts +++ b/packages/app/src/cli/services/dev/extension.ts @@ -8,6 +8,7 @@ import { } from './extension/payload/store.js' import {AppEvent, AppEventWatcher, EventType} from './app-events/app-event-watcher.js' import {buildCartURLIfNeeded} from './extension/utilities.js' +import {AppLinkedInterface} from '../../models/app/app.js' import {ExtensionInstance} from '../../models/extensions/extension-instance.js' import {AbortSignal} from '@shopify/cli-kit/node/abort' import {outputDebug} from '@shopify/cli-kit/node/output' @@ -125,14 +126,11 @@ export interface ExtensionDevOptions { appAssets?: AppAssets } -export function resolveAppAssets(allExtensions: ExtensionInstance[]): Record { +export function resolveAppAssets(app: AppLinkedInterface): Record { const appAssets: Record = {} - const adminExtension = allExtensions.find((ext) => ext.specification.identifier === 'admin') - if (adminExtension) { - const staticRootPath = getPathValue(adminExtension.configuration, 'admin.static_root') - if (staticRootPath) { - appAssets.staticRoot = joinPath(adminExtension.directory, staticRootPath) - } + const staticRootPath = getPathValue(app.configuration, 'admin.static_root') + if (staticRootPath) { + appAssets.staticRoot = joinPath(app.directory, staticRootPath) } return appAssets } diff --git a/packages/app/src/cli/services/dev/processes/previewable-extension.ts b/packages/app/src/cli/services/dev/processes/previewable-extension.ts index c5fc8317c45..d9eee74cc20 100644 --- a/packages/app/src/cli/services/dev/processes/previewable-extension.ts +++ b/packages/app/src/cli/services/dev/processes/previewable-extension.ts @@ -1,5 +1,6 @@ import {BaseProcess, DevProcessFunction} from './types.js' import {devUIExtensions, resolveAppAssets} from '../extension.js' +import {AppLinkedInterface} from '../../../models/app/app.js' import {ExtensionInstance} from '../../../models/extensions/extension-instance.js' import {buildCartURLIfNeeded} from '../extension/utilities.js' import {AppEventWatcher} from '../app-events/app-event-watcher.js' @@ -75,11 +76,13 @@ export const launchPreviewableExtensionProcess: DevProcessFunction & { + app: AppLinkedInterface allExtensions: ExtensionInstance[] checkoutCartUrl?: string }): Promise { @@ -87,7 +90,7 @@ export async function setupPreviewableExtensionsProcess({ const cartUrl = await buildCartURLIfNeeded(previewableExtensions, storeFqdn, checkoutCartUrl) - const appAssets = resolveAppAssets(allExtensions) + const appAssets = resolveAppAssets(app) return { prefix: 'extensions', diff --git a/packages/app/src/cli/services/dev/processes/setup-dev-processes.ts b/packages/app/src/cli/services/dev/processes/setup-dev-processes.ts index 9a2a3723740..87b862f4c61 100644 --- a/packages/app/src/cli/services/dev/processes/setup-dev-processes.ts +++ b/packages/app/src/cli/services/dev/processes/setup-dev-processes.ts @@ -149,6 +149,7 @@ export async function setupDevProcesses({ }) : undefined, await setupPreviewableExtensionsProcess({ + app: reloadedApp, allExtensions: reloadedApp.allExtensions, storeFqdn, storeId,