From efbdee409f700c1191cba161af3b78e75e813d79 Mon Sep 17 00:00:00 2001 From: Rod Mcnew Date: Mon, 14 Sep 2026 17:56:05 -0500 Subject: [PATCH 01/11] Add a local-only app logs query prototype Co-authored by AI (GPT-5) --- .../src/cli/commands/app/app-logs/query.ts | 58 +++++++++++ packages/app/src/cli/index.ts | 2 + .../src/cli/services/app-logs/query.test.ts | 88 +++++++++++++++++ .../app/src/cli/services/app-logs/query.ts | 73 ++++++++++++++ packages/cli/oclif.manifest.json | 95 ++++++++++++++++++- packages/cli/src/command-registry.ts | 1 + 6 files changed, 316 insertions(+), 1 deletion(-) create mode 100644 packages/app/src/cli/commands/app/app-logs/query.ts create mode 100644 packages/app/src/cli/services/app-logs/query.test.ts create mode 100644 packages/app/src/cli/services/app-logs/query.ts diff --git a/packages/app/src/cli/commands/app/app-logs/query.ts b/packages/app/src/cli/commands/app/app-logs/query.ts new file mode 100644 index 00000000000..53f4e96fe1a --- /dev/null +++ b/packages/app/src/cli/commands/app/app-logs/query.ts @@ -0,0 +1,58 @@ +import {queryAppLogs} from '../../../services/app-logs/query.js' +import BaseCommand from '@shopify/cli-kit/node/base-command' +import {globalFlags} from '@shopify/cli-kit/node/cli' +import {outputResult} from '@shopify/cli-kit/node/output' +import {Flags} from '@oclif/core' + +export default class Query extends BaseCommand { + static hidden = true + static summary = 'Prototype only: query app log summaries through the local App Management API.' + + static flags = { + ...globalFlags, + 'organization-id': Flags.string({ + required: true, + env: 'SHOPIFY_FLAG_ORGANIZATION_ID', + description: 'Local Business Platform organization ID.', + }), + 'client-id': Flags.string({required: true, env: 'SHOPIFY_FLAG_CLIENT_ID', description: 'App API key.'}), + minutes: Flags.integer({ + default: 15, + min: 1, + max: 60, + env: 'SHOPIFY_FLAG_MINUTES', + description: 'Query the last N minutes.', + }), + limit: Flags.integer({ + default: 10, + min: 1, + max: 100, + env: 'SHOPIFY_FLAG_LIMIT', + description: 'Maximum summary events to return.', + }), + type: Flags.string({ + multiple: true, + env: 'SHOPIFY_FLAG_TYPE', + options: ['WEBHOOK_DELIVERY', 'GRAPHQL_REQUEST', 'REST_REQUEST', 'FUNCTION_RUN'], + description: 'Restrict results to these event types.', + }), + demo: Flags.boolean({ + default: false, + env: 'SHOPIFY_FLAG_DEMO', + description: 'Use the loopback demo with seeded auth, not Identity login.', + }), + } + + public async run(): Promise { + const {flags} = await this.parse(Query) + const result = await queryAppLogs({ + organizationId: flags['organization-id'], + clientId: flags['client-id'], + minutes: flags.minutes, + limit: flags.limit, + types: flags.type, + demo: flags.demo, + }) + outputResult(JSON.stringify(result, null, 2)) + } +} diff --git a/packages/app/src/cli/index.ts b/packages/app/src/cli/index.ts index a6289bba107..bec72cb4dae 100644 --- a/packages/app/src/cli/index.ts +++ b/packages/app/src/cli/index.ts @@ -12,6 +12,7 @@ import DoctorSubmit from './commands/app/doctor/submit.js' import Doctor from './commands/app/doctor.js' import Logs from './commands/app/logs.js' import Sources from './commands/app/app-logs/sources.js' +import QueryLogs from './commands/app/app-logs/query.js' import EnvPull from './commands/app/env/pull.js' import EnvShow from './commands/app/env/show.js' import BulkExecute from './commands/app/bulk/execute.js' @@ -61,6 +62,7 @@ export const commands: {[key: string]: typeof AppLinkedCommand | typeof AppUnlin 'app:doctor': Doctor, 'app:logs': Logs, 'app:logs:sources': Sources, + 'app:logs:query': QueryLogs, 'app:import-custom-data-definitions': ImportCustomDataDefinitions, 'app:import-extensions': ImportExtensions, 'app:info': AppInfo, diff --git a/packages/app/src/cli/services/app-logs/query.test.ts b/packages/app/src/cli/services/app-logs/query.test.ts new file mode 100644 index 00000000000..052f1ac7697 --- /dev/null +++ b/packages/app/src/cli/services/app-logs/query.test.ts @@ -0,0 +1,88 @@ +import {queryAppLogs} from './query.js' +import {appManagementFqdn} from '@shopify/cli-kit/node/context/fqdn' +import {fetch} from '@shopify/cli-kit/node/http' +import {ensureAuthenticatedAppManagementAndBusinessPlatform} from '@shopify/cli-kit/node/session' +import {inTemporaryDirectory, writeFile} from '@shopify/cli-kit/node/fs' +import {joinPath} from '@shopify/cli-kit/node/path' +import {afterEach, beforeEach, expect, test, vi} from 'vitest' +import {Response} from 'node-fetch' + +vi.mock('@shopify/cli-kit/node/context/fqdn') +vi.mock('@shopify/cli-kit/node/http') +vi.mock('@shopify/cli-kit/node/session') + +const options = {organizationId: '1', clientId: 'test-app', minutes: 15, limit: 3, demo: false} + +beforeEach(() => { + vi.stubEnv('SHOPIFY_APP_LOG_QUERY_PROTOTYPE', '1') + vi.stubEnv('SHOPIFY_SERVICE_ENV', 'local') + vi.mocked(appManagementFqdn).mockResolvedValue('app.shop.dev') + vi.mocked(ensureAuthenticatedAppManagementAndBusinessPlatform).mockResolvedValue({ + appManagementToken: 'atkn_local-identity', + userId: 'local-user', + businessPlatformToken: 'unused', + }) +}) + +afterEach(() => { + vi.unstubAllEnvs() +}) + +test('refuses production services before authenticating or sending a request', async () => { + vi.stubEnv('SHOPIFY_SERVICE_ENV', 'production') + await expect(queryAppLogs(options)).rejects.toThrow('Prototype only') + expect(ensureAuthenticatedAppManagementAndBusinessPlatform).not.toHaveBeenCalled() + expect(fetch).not.toHaveBeenCalled() +}) + +test('refuses an unexpected host before obtaining a token', async () => { + vi.mocked(appManagementFqdn).mockResolvedValue('app.shopify.com') + await expect(queryAppLogs(options)).rejects.toThrow('only call app.shop.dev') + expect(ensureAuthenticatedAppManagementAndBusinessPlatform).not.toHaveBeenCalled() +}) + +test('sends a bounded app query using the normal CLI authentication helper', async () => { + const result = {app_key: 'test-app', events: [{'payload.type': 'WEBHOOK_DELIVERY'}], exhaustive: false} + vi.mocked(fetch).mockResolvedValue(new Response(JSON.stringify(result), {status: 200})) + await expect(queryAppLogs({...options, types: ['WEBHOOK_DELIVERY']})).resolves.toEqual(result) + expect(fetch).toHaveBeenCalledWith( + 'https://app.shop.dev/app_management/unstable/organizations/1/app_logs/query', + expect.objectContaining({ + method: 'POST', + redirect: 'error', + size: 1024 * 1024, + headers: expect.objectContaining({authorization: 'Bearer atkn_local-identity'}), + body: expect.stringContaining('"types":["WEBHOOK_DELIVERY"]'), + }), + ) + const input = JSON.parse(vi.mocked(fetch).mock.calls[0]![1]!.body as string) + expect(input.api_key).toBe('test-app') + expect(input.limit).toBe(3) + expect(Date.parse(input.end_time) - Date.parse(input.start_time)).toBe(15 * 60 * 1000) +}) + +test('uses the temporary token only for the fixed loopback demo, without logging into Identity', async () => { + await inTemporaryDirectory(async (directory) => { + const path = joinPath(directory, 'token') + await writeFile(path, 'atkn_demo-only') + vi.stubEnv('APP_LOG_QUERY_DEMO_TOKEN_FILE', path) + vi.mocked(fetch).mockResolvedValue(new Response('{"events":[]}', {status: 200})) + await expect(queryAppLogs({...options, demo: true})).resolves.toEqual({events: []}) + expect(fetch).toHaveBeenCalledWith( + 'http://127.0.0.1:4387/app_management/unstable/organizations/1/app_logs/query', + expect.objectContaining({headers: expect.objectContaining({authorization: 'Bearer atkn_demo-only'})}), + ) + expect(ensureAuthenticatedAppManagementAndBusinessPlatform).not.toHaveBeenCalled() + }) +}) + +test('surfaces API errors instead of turning them into an empty result', async () => { + vi.mocked(fetch).mockResolvedValue(new Response('private upstream details', {status: 502})) + await expect(queryAppLogs(options)).rejects.toThrow('HTTP 502') +}) + +test('rejects a path-injection organization ID and oversized request limits', async () => { + await expect(queryAppLogs({...options, organizationId: '../2'})).rejects.toThrow('numeric organization ID') + await expect(queryAppLogs({...options, limit: 101})).rejects.toThrow('limit of 1–100') + expect(fetch).not.toHaveBeenCalled() +}) diff --git a/packages/app/src/cli/services/app-logs/query.ts b/packages/app/src/cli/services/app-logs/query.ts new file mode 100644 index 00000000000..d4ac61a5a1f --- /dev/null +++ b/packages/app/src/cli/services/app-logs/query.ts @@ -0,0 +1,73 @@ +import {appManagementHeaders} from '@shopify/cli-kit/node/api/app-management' +import {appManagementFqdn} from '@shopify/cli-kit/node/context/fqdn' +import {AbortError} from '@shopify/cli-kit/node/error' +import {readFile} from '@shopify/cli-kit/node/fs' +import {fetch} from '@shopify/cli-kit/node/http' +import {ensureAuthenticatedAppManagementAndBusinessPlatform} from '@shopify/cli-kit/node/session' + +interface QueryOptions { + organizationId: string + clientId: string + minutes: number + limit: number + types?: string[] + demo: boolean +} + +export async function queryAppLogs(options: QueryOptions): Promise { + if (process.env.SHOPIFY_APP_LOG_QUERY_PROTOTYPE !== '1' || process.env.SHOPIFY_SERVICE_ENV !== 'local') { + throw new AbortError('Prototype only: set SHOPIFY_APP_LOG_QUERY_PROTOTYPE=1 and SHOPIFY_SERVICE_ENV=local.') + } + if (!/^\d+$/.test(options.organizationId) || !options.clientId) { + throw new AbortError('Provide a numeric organization ID and a nonempty app client ID.') + } + if ( + !Number.isInteger(options.minutes) || + options.minutes < 1 || + options.minutes > 60 || + !Number.isInteger(options.limit) || + options.limit < 1 || + options.limit > 100 + ) { + throw new AbortError('Use 1–60 minutes and a limit of 1–100 events.') + } + + const {origin, token} = await queryConnection(options.demo) + const end = new Date() + const response = await fetch( + `${origin}/app_management/unstable/organizations/${options.organizationId}/app_logs/query`, + { + method: 'POST', + redirect: 'error', + signal: AbortSignal.timeout(15000), + size: 1024 * 1024, + headers: appManagementHeaders(token), + body: JSON.stringify({ + api_key: options.clientId, + start_time: new Date(end.getTime() - options.minutes * 60 * 1000).toISOString(), + end_time: end.toISOString(), + limit: options.limit, + ...(options.types ? {types: options.types} : {}), + }), + }, + ) + if (!response.ok) { + throw new AbortError(`Local log query failed (HTTP ${response.status}). Check the local API server output.`) + } + return response.json() +} + +async function queryConnection(demo: boolean): Promise<{origin: string; token: string}> { + if (demo) { + const path = process.env.APP_LOG_QUERY_DEMO_TOKEN_FILE + if (!path) throw new AbortError('Set APP_LOG_QUERY_DEMO_TOKEN_FILE to the file printed by the local demo server.') + const token = (await readFile(path)).trim() + if (!token.startsWith('atkn_') || token.length > 4096) throw new AbortError('Invalid demo token file.') + return {origin: 'http://127.0.0.1:4387', token} + } + + const host = await appManagementFqdn() + if (host !== 'app.shop.dev') throw new AbortError('This prototype can only call app.shop.dev.') + const {appManagementToken} = await ensureAuthenticatedAppManagementAndBusinessPlatform() + return {origin: `https://${host}`, token: appManagementToken} +} diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index e643cbb6194..7d63c233645 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -3313,6 +3313,99 @@ "strict": true, "summary": "Stream detailed logs for your Shopify app." }, + "app:logs:query": { + "aliases": [ + ], + "args": { + }, + "customPluginName": "@shopify/app", + "enableJsonFlag": false, + "flags": { + "client-id": { + "description": "App API key.", + "env": "SHOPIFY_FLAG_CLIENT_ID", + "hasDynamicHelp": false, + "multiple": false, + "name": "client-id", + "required": true, + "type": "option" + }, + "demo": { + "allowNo": false, + "description": "Use the loopback demo with seeded auth, not Identity login.", + "env": "SHOPIFY_FLAG_DEMO", + "name": "demo", + "type": "boolean" + }, + "limit": { + "default": 10, + "description": "Maximum summary events to return.", + "env": "SHOPIFY_FLAG_LIMIT", + "hasDynamicHelp": false, + "multiple": false, + "name": "limit", + "type": "option" + }, + "minutes": { + "default": 15, + "description": "Query the last N minutes.", + "env": "SHOPIFY_FLAG_MINUTES", + "hasDynamicHelp": false, + "multiple": false, + "name": "minutes", + "type": "option" + }, + "no-color": { + "allowNo": false, + "description": "Disable color output.", + "env": "SHOPIFY_FLAG_NO_COLOR", + "hidden": false, + "name": "no-color", + "type": "boolean" + }, + "organization-id": { + "description": "Local Business Platform organization ID.", + "env": "SHOPIFY_FLAG_ORGANIZATION_ID", + "hasDynamicHelp": false, + "multiple": false, + "name": "organization-id", + "required": true, + "type": "option" + }, + "type": { + "description": "Restrict results to these event types.", + "env": "SHOPIFY_FLAG_TYPE", + "hasDynamicHelp": false, + "multiple": true, + "name": "type", + "options": [ + "WEBHOOK_DELIVERY", + "GRAPHQL_REQUEST", + "REST_REQUEST", + "FUNCTION_RUN" + ], + "type": "option" + }, + "verbose": { + "allowNo": false, + "description": "Increase the verbosity of the output. May include sensitive data.", + "env": "SHOPIFY_FLAG_VERBOSE", + "hidden": false, + "name": "verbose", + "type": "boolean" + } + }, + "hasDynamicHelp": false, + "hidden": true, + "hiddenAliases": [ + ], + "id": "app:logs:query", + "pluginAlias": "@shopify/cli", + "pluginName": "@shopify/cli", + "pluginType": "core", + "strict": true, + "summary": "Prototype only: query app log summaries through the local App Management API." + }, "app:logs:sources": { "aliases": [ ], @@ -10632,4 +10725,4 @@ } }, "version": "4.8.0" -} \ No newline at end of file +} diff --git a/packages/cli/src/command-registry.ts b/packages/cli/src/command-registry.ts index 2f8180831e6..e60bd7293d8 100644 --- a/packages/cli/src/command-registry.ts +++ b/packages/cli/src/command-registry.ts @@ -62,6 +62,7 @@ function resolvePackageDir(packageName: string): string { const entryPointOverrides: Record = { 'app:logs:sources': 'dist/cli/commands/app/app-logs/sources.js', + 'app:logs:query': 'dist/cli/commands/app/app-logs/query.js', 'demo:watcher': 'dist/cli/commands/app/demo/watcher.js', 'kitchen-sink': 'dist/cli/commands/kitchen-sink/index.js', 'doctor-release': 'dist/cli/commands/doctor-release/doctor-release.js', From 1207e729dd52cea2193e887910919c9d6fb7dadc Mon Sep 17 00:00:00 2001 From: Rod Mcnew Date: Mon, 14 Sep 2026 18:28:21 -0500 Subject: [PATCH 02/11] Fix app log query prototype packaging Co-authored by AI (GPT-5) --- packages/cli/bin/bundle.js | 1 + packages/cli/oclif.manifest.json | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/cli/bin/bundle.js b/packages/cli/bin/bundle.js index 537d650a5be..0f4854ab846 100644 --- a/packages/cli/bin/bundle.js +++ b/packages/cli/bin/bundle.js @@ -55,6 +55,7 @@ const hookEntryPoints = glob.sync('./src/hooks/*.ts', { const manifest = JSON.parse(readFileSync(joinPath(process.cwd(), 'oclif.manifest.json'), 'utf8')) const commandEntryPointOverrides = { 'app:logs:sources': 'cli/commands/app/app-logs/sources', + 'app:logs:query': 'cli/commands/app/app-logs/query', 'demo:watcher': 'cli/commands/app/demo/watcher', 'kitchen-sink': 'cli/commands/kitchen-sink/index', 'doctor-release': 'cli/commands/doctor-release/doctor-release', diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 7d63c233645..a5a3186a6a7 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -10725,4 +10725,4 @@ } }, "version": "4.8.0" -} +} \ No newline at end of file From 3acc3776459f1f52037d4d96f77d00f4f67c51a5 Mon Sep 17 00:00:00 2001 From: Rod Mcnew Date: Tue, 15 Sep 2026 11:07:44 -0500 Subject: [PATCH 03/11] Add row offsets to the log query prototype and leave limits to the API Co-authored by AI (GPT-5) Assisted-By: devx/77cd7a80-dbde-4d20-9b34-75ed3bac34bb --- .../src/cli/commands/app/app-logs/query.ts | 9 +++- .../src/cli/services/app-logs/query.test.ts | 42 +++++++++++++++++-- .../app/src/cli/services/app-logs/query.ts | 9 ++-- packages/cli/oclif.manifest.json | 9 ++++ 4 files changed, 59 insertions(+), 10 deletions(-) diff --git a/packages/app/src/cli/commands/app/app-logs/query.ts b/packages/app/src/cli/commands/app/app-logs/query.ts index 53f4e96fe1a..5c1b6bea295 100644 --- a/packages/app/src/cli/commands/app/app-logs/query.ts +++ b/packages/app/src/cli/commands/app/app-logs/query.ts @@ -19,17 +19,21 @@ export default class Query extends BaseCommand { minutes: Flags.integer({ default: 15, min: 1, - max: 60, env: 'SHOPIFY_FLAG_MINUTES', description: 'Query the last N minutes.', }), limit: Flags.integer({ default: 10, min: 1, - max: 100, env: 'SHOPIFY_FLAG_LIMIT', description: 'Maximum summary events to return.', }), + offset: Flags.integer({ + default: 0, + min: 0, + env: 'SHOPIFY_FLAG_OFFSET', + description: 'Number of matching rows to skip. Results are unordered; this is not a reliable export cursor.', + }), type: Flags.string({ multiple: true, env: 'SHOPIFY_FLAG_TYPE', @@ -50,6 +54,7 @@ export default class Query extends BaseCommand { clientId: flags['client-id'], minutes: flags.minutes, limit: flags.limit, + offset: flags.offset, types: flags.type, demo: flags.demo, }) diff --git a/packages/app/src/cli/services/app-logs/query.test.ts b/packages/app/src/cli/services/app-logs/query.test.ts index 052f1ac7697..489f075d066 100644 --- a/packages/app/src/cli/services/app-logs/query.test.ts +++ b/packages/app/src/cli/services/app-logs/query.test.ts @@ -11,7 +11,7 @@ vi.mock('@shopify/cli-kit/node/context/fqdn') vi.mock('@shopify/cli-kit/node/http') vi.mock('@shopify/cli-kit/node/session') -const options = {organizationId: '1', clientId: 'test-app', minutes: 15, limit: 3, demo: false} +const options = {organizationId: '1', clientId: 'test-app', minutes: 15, limit: 3, offset: 0, demo: false} beforeEach(() => { vi.stubEnv('SHOPIFY_APP_LOG_QUERY_PROTOTYPE', '1') @@ -50,7 +50,6 @@ test('sends a bounded app query using the normal CLI authentication helper', asy expect.objectContaining({ method: 'POST', redirect: 'error', - size: 1024 * 1024, headers: expect.objectContaining({authorization: 'Bearer atkn_local-identity'}), body: expect.stringContaining('"types":["WEBHOOK_DELIVERY"]'), }), @@ -58,6 +57,7 @@ test('sends a bounded app query using the normal CLI authentication helper', asy const input = JSON.parse(vi.mocked(fetch).mock.calls[0]![1]!.body as string) expect(input.api_key).toBe('test-app') expect(input.limit).toBe(3) + expect(input.offset).toBe(0) expect(Date.parse(input.end_time) - Date.parse(input.start_time)).toBe(15 * 60 * 1000) }) @@ -81,8 +81,42 @@ test('surfaces API errors instead of turning them into an empty result', async ( await expect(queryAppLogs(options)).rejects.toThrow('HTTP 502') }) -test('rejects a path-injection organization ID and oversized request limits', async () => { +test('forwards a 10000 event limit and 1000000 offset independently', async () => { + vi.mocked(fetch).mockResolvedValue(new Response('{"events":[]}', {status: 200})) + await expect(queryAppLogs({...options, limit: 10_000, offset: 1_000_000})).resolves.toEqual({events: []}) + const input = JSON.parse(vi.mocked(fetch).mock.calls[0]![1]!.body as string) + expect(input.limit).toBe(10_000) + expect(input.offset).toBe(1_000_000) +}) + +test('leaves upper policy limits to the server', async () => { + vi.mocked(fetch).mockResolvedValue(new Response('{"error":"Query limits exceeded"}', {status: 400})) + await expect(queryAppLogs({...options, minutes: 61, limit: 10_001, offset: 1_000_001})).rejects.toThrow('HTTP 400') + const input = JSON.parse(vi.mocked(fetch).mock.calls[0]![1]!.body as string) + expect(input.limit).toBe(10_001) + expect(input.offset).toBe(1_000_001) +}) + +test('reads valid responses larger than one MiB without a client byte cap', async () => { + const result = {events: [{'payload.target': 'x'.repeat(2 * 1024 * 1024)}]} + vi.mocked(fetch).mockImplementation(async (_url, init) => { + const responseOptions = {status: 200, size: init?.size} + return new Response(JSON.stringify(result), responseOptions) + }) + + await expect(queryAppLogs(options)).resolves.toEqual(result) + expect(vi.mocked(fetch).mock.calls[0]![1]?.size).toBeUndefined() +}) + +test('rejects a path-injection organization ID', async () => { await expect(queryAppLogs({...options, organizationId: '../2'})).rejects.toThrow('numeric organization ID') - await expect(queryAppLogs({...options, limit: 101})).rejects.toThrow('limit of 1–100') expect(fetch).not.toHaveBeenCalled() }) + +test.each([{minutes: 0}, {minutes: 1.5}, {limit: 0}, {limit: 1.5}, {offset: -1}, {offset: 1.5}])( + 'rejects malformed numeric options %j before making a request', + async (invalidOptions) => { + await expect(queryAppLogs({...options, ...invalidOptions})).rejects.toThrow('positive integers') + expect(fetch).not.toHaveBeenCalled() + }, +) diff --git a/packages/app/src/cli/services/app-logs/query.ts b/packages/app/src/cli/services/app-logs/query.ts index d4ac61a5a1f..e7556b68d0d 100644 --- a/packages/app/src/cli/services/app-logs/query.ts +++ b/packages/app/src/cli/services/app-logs/query.ts @@ -10,6 +10,7 @@ interface QueryOptions { clientId: string minutes: number limit: number + offset: number types?: string[] demo: boolean } @@ -24,12 +25,12 @@ export async function queryAppLogs(options: QueryOptions): Promise { if ( !Number.isInteger(options.minutes) || options.minutes < 1 || - options.minutes > 60 || !Number.isInteger(options.limit) || options.limit < 1 || - options.limit > 100 + !Number.isInteger(options.offset) || + options.offset < 0 ) { - throw new AbortError('Use 1–60 minutes and a limit of 1–100 events.') + throw new AbortError('Use positive integers for minutes and limit, and a nonnegative integer for offset.') } const {origin, token} = await queryConnection(options.demo) @@ -40,13 +41,13 @@ export async function queryAppLogs(options: QueryOptions): Promise { method: 'POST', redirect: 'error', signal: AbortSignal.timeout(15000), - size: 1024 * 1024, headers: appManagementHeaders(token), body: JSON.stringify({ api_key: options.clientId, start_time: new Date(end.getTime() - options.minutes * 60 * 1000).toISOString(), end_time: end.toISOString(), limit: options.limit, + offset: options.offset, ...(options.types ? {types: options.types} : {}), }), }, diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index a5a3186a6a7..ab1800731b7 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -3363,6 +3363,15 @@ "name": "no-color", "type": "boolean" }, + "offset": { + "default": 0, + "description": "Number of matching rows to skip. Results are unordered; this is not a reliable export cursor.", + "env": "SHOPIFY_FLAG_OFFSET", + "hasDynamicHelp": false, + "multiple": false, + "name": "offset", + "type": "option" + }, "organization-id": { "description": "Local Business Platform organization ID.", "env": "SHOPIFY_FLAG_ORGANIZATION_ID", From 2377f7c20617b6ec84b75237e9dc344028a0dbab Mon Sep 17 00:00:00 2001 From: Rod Mcnew Date: Tue, 15 Sep 2026 13:58:45 -0500 Subject: [PATCH 04/11] Prototype GraphQL transport for app log queries Co-authored by AI (GPT-5) Assisted-By: devx/77cd7a80-dbde-4d20-9b34-75ed3bac34bb --- .../src/cli/commands/app/app-logs/query.ts | 2 +- .../src/cli/services/app-logs/query.test.ts | 55 +++++++++++----- .../app/src/cli/services/app-logs/query.ts | 63 +++++++++++++------ packages/cli/oclif.manifest.json | 4 +- 4 files changed, 88 insertions(+), 36 deletions(-) diff --git a/packages/app/src/cli/commands/app/app-logs/query.ts b/packages/app/src/cli/commands/app/app-logs/query.ts index 5c1b6bea295..f1e75b2ff0f 100644 --- a/packages/app/src/cli/commands/app/app-logs/query.ts +++ b/packages/app/src/cli/commands/app/app-logs/query.ts @@ -6,7 +6,7 @@ import {Flags} from '@oclif/core' export default class Query extends BaseCommand { static hidden = true - static summary = 'Prototype only: query app log summaries through the local App Management API.' + static summary = 'Prototype only: query app log summaries through the local Dev Platform API.' static flags = { ...globalFlags, diff --git a/packages/app/src/cli/services/app-logs/query.test.ts b/packages/app/src/cli/services/app-logs/query.test.ts index 489f075d066..06ac0f47fae 100644 --- a/packages/app/src/cli/services/app-logs/query.test.ts +++ b/packages/app/src/cli/services/app-logs/query.test.ts @@ -42,11 +42,11 @@ test('refuses an unexpected host before obtaining a token', async () => { }) test('sends a bounded app query using the normal CLI authentication helper', async () => { - const result = {app_key: 'test-app', events: [{'payload.type': 'WEBHOOK_DELIVERY'}], exhaustive: false} - vi.mocked(fetch).mockResolvedValue(new Response(JSON.stringify(result), {status: 200})) + const result = {appKey: 'test-app', events: [{type: 'WEBHOOK_DELIVERY'}], exhaustive: false} + vi.mocked(fetch).mockResolvedValue(new Response(JSON.stringify({data: {appLogs: result}}), {status: 200})) await expect(queryAppLogs({...options, types: ['WEBHOOK_DELIVERY']})).resolves.toEqual(result) expect(fetch).toHaveBeenCalledWith( - 'https://app.shop.dev/app_management/unstable/organizations/1/app_logs/query', + 'https://app.shop.dev/dev_platform/unstable/organizations/1/graphql', expect.objectContaining({ method: 'POST', redirect: 'error', @@ -54,11 +54,15 @@ test('sends a bounded app query using the normal CLI authentication helper', asy body: expect.stringContaining('"types":["WEBHOOK_DELIVERY"]'), }), ) - const input = JSON.parse(vi.mocked(fetch).mock.calls[0]![1]!.body as string) - expect(input.api_key).toBe('test-app') + const request = JSON.parse(vi.mocked(fetch).mock.calls[0]![1]!.body as string) + expect(request.query).toContain('query AppLogs($input: AppLogQueryInput!)') + expect(request.query).toContain('recordUid timestamp type') + expect(request.operationName).toBe('AppLogs') + const input = request.variables.input + expect(input.appKey).toBe('test-app') expect(input.limit).toBe(3) expect(input.offset).toBe(0) - expect(Date.parse(input.end_time) - Date.parse(input.start_time)).toBe(15 * 60 * 1000) + expect(Date.parse(input.endTime) - Date.parse(input.startTime)).toBe(15 * 60 * 1000) }) test('uses the temporary token only for the fixed loopback demo, without logging into Identity', async () => { @@ -66,10 +70,10 @@ test('uses the temporary token only for the fixed loopback demo, without logging const path = joinPath(directory, 'token') await writeFile(path, 'atkn_demo-only') vi.stubEnv('APP_LOG_QUERY_DEMO_TOKEN_FILE', path) - vi.mocked(fetch).mockResolvedValue(new Response('{"events":[]}', {status: 200})) + vi.mocked(fetch).mockResolvedValue(new Response('{"data":{"appLogs":{"events":[]}}}', {status: 200})) await expect(queryAppLogs({...options, demo: true})).resolves.toEqual({events: []}) expect(fetch).toHaveBeenCalledWith( - 'http://127.0.0.1:4387/app_management/unstable/organizations/1/app_logs/query', + 'http://127.0.0.1:4387/dev_platform/unstable/organizations/1/graphql', expect.objectContaining({headers: expect.objectContaining({authorization: 'Bearer atkn_demo-only'})}), ) expect(ensureAuthenticatedAppManagementAndBusinessPlatform).not.toHaveBeenCalled() @@ -82,26 +86,28 @@ test('surfaces API errors instead of turning them into an empty result', async ( }) test('forwards a 10000 event limit and 1000000 offset independently', async () => { - vi.mocked(fetch).mockResolvedValue(new Response('{"events":[]}', {status: 200})) + vi.mocked(fetch).mockResolvedValue(new Response('{"data":{"appLogs":{"events":[]}}}', {status: 200})) await expect(queryAppLogs({...options, limit: 10_000, offset: 1_000_000})).resolves.toEqual({events: []}) - const input = JSON.parse(vi.mocked(fetch).mock.calls[0]![1]!.body as string) + const input = JSON.parse(vi.mocked(fetch).mock.calls[0]![1]!.body as string).variables.input expect(input.limit).toBe(10_000) expect(input.offset).toBe(1_000_000) }) test('leaves upper policy limits to the server', async () => { - vi.mocked(fetch).mockResolvedValue(new Response('{"error":"Query limits exceeded"}', {status: 400})) - await expect(queryAppLogs({...options, minutes: 61, limit: 10_001, offset: 1_000_001})).rejects.toThrow('HTTP 400') - const input = JSON.parse(vi.mocked(fetch).mock.calls[0]![1]!.body as string) + vi.mocked(fetch).mockResolvedValue(new Response('{"errors":[{"message":"Query limits exceeded"}]}', {status: 200})) + await expect(queryAppLogs({...options, minutes: 61, limit: 10_001, offset: 1_000_001})).rejects.toThrow( + 'Query limits exceeded', + ) + const input = JSON.parse(vi.mocked(fetch).mock.calls[0]![1]!.body as string).variables.input expect(input.limit).toBe(10_001) expect(input.offset).toBe(1_000_001) }) test('reads valid responses larger than one MiB without a client byte cap', async () => { - const result = {events: [{'payload.target': 'x'.repeat(2 * 1024 * 1024)}]} + const result = {events: [{target: 'x'.repeat(2 * 1024 * 1024)}]} vi.mocked(fetch).mockImplementation(async (_url, init) => { const responseOptions = {status: 200, size: init?.size} - return new Response(JSON.stringify(result), responseOptions) + return new Response(JSON.stringify({data: {appLogs: result}}), responseOptions) }) await expect(queryAppLogs(options)).resolves.toEqual(result) @@ -113,6 +119,25 @@ test('rejects a path-injection organization ID', async () => { expect(fetch).not.toHaveBeenCalled() }) +test('surfaces GraphQL errors even when HTTP succeeds and partial data is present', async () => { + vi.mocked(fetch).mockResolvedValue( + new Response(JSON.stringify({data: {appLogs: {events: []}}, errors: [{message: 'Log query failed'}]}), { + status: 200, + }), + ) + await expect(queryAppLogs(options)).rejects.toThrow('Log query failed') +}) + +test.each([{data: {appLogs: null}}, {data: null}, {}])('rejects a missing log result %j', async (body) => { + vi.mocked(fetch).mockResolvedValue(new Response(JSON.stringify(body), {status: 200})) + await expect(queryAppLogs(options)).rejects.toThrow('no appLogs result') +}) + +test.each([null, [], {data: {appLogs: []}}])('rejects a malformed GraphQL envelope %j', async (body) => { + vi.mocked(fetch).mockResolvedValue(new Response(JSON.stringify(body), {status: 200})) + await expect(queryAppLogs(options)).rejects.toThrow('invalid GraphQL response') +}) + test.each([{minutes: 0}, {minutes: 1.5}, {limit: 0}, {limit: 1.5}, {offset: -1}, {offset: 1.5}])( 'rejects malformed numeric options %j before making a request', async (invalidOptions) => { diff --git a/packages/app/src/cli/services/app-logs/query.ts b/packages/app/src/cli/services/app-logs/query.ts index e7556b68d0d..1a0db2a31a2 100644 --- a/packages/app/src/cli/services/app-logs/query.ts +++ b/packages/app/src/cli/services/app-logs/query.ts @@ -4,6 +4,24 @@ import {AbortError} from '@shopify/cli-kit/node/error' import {readFile} from '@shopify/cli-kit/node/fs' import {fetch} from '@shopify/cli-kit/node/http' import {ensureAuthenticatedAppManagementAndBusinessPlatform} from '@shopify/cli-kit/node/session' +import {z} from 'zod' + +const query = ` + query AppLogs($input: AppLogQueryInput!) { + appLogs(input: $input) { + appKey limit offset limitReached exhaustive ordering + events { recordUid timestamp type resultStatus target shopDomain } + } + } +` + +const responseSchema = z.object({ + data: z + .object({appLogs: z.record(z.unknown()).nullable()}) + .nullable() + .optional(), + errors: z.array(z.object({message: z.string()})).optional(), +}) interface QueryOptions { organizationId: string @@ -35,27 +53,36 @@ export async function queryAppLogs(options: QueryOptions): Promise { const {origin, token} = await queryConnection(options.demo) const end = new Date() - const response = await fetch( - `${origin}/app_management/unstable/organizations/${options.organizationId}/app_logs/query`, - { - method: 'POST', - redirect: 'error', - signal: AbortSignal.timeout(15000), - headers: appManagementHeaders(token), - body: JSON.stringify({ - api_key: options.clientId, - start_time: new Date(end.getTime() - options.minutes * 60 * 1000).toISOString(), - end_time: end.toISOString(), - limit: options.limit, - offset: options.offset, - ...(options.types ? {types: options.types} : {}), - }), - }, - ) + const response = await fetch(`${origin}/dev_platform/unstable/organizations/${options.organizationId}/graphql`, { + method: 'POST', + redirect: 'error', + signal: AbortSignal.timeout(15000), + headers: appManagementHeaders(token), + body: JSON.stringify({ + query, + operationName: 'AppLogs', + variables: { + input: { + appKey: options.clientId, + startTime: new Date(end.getTime() - options.minutes * 60 * 1000).toISOString(), + endTime: end.toISOString(), + limit: options.limit, + offset: options.offset, + ...(options.types ? {types: options.types} : {}), + }, + }, + }), + }) if (!response.ok) { throw new AbortError(`Local log query failed (HTTP ${response.status}). Check the local API server output.`) } - return response.json() + const result = responseSchema.safeParse(await response.json()) + if (!result.success) throw new AbortError('Local log query returned an invalid GraphQL response.') + if (result.data.errors?.length) { + throw new AbortError(`Local log query failed: ${result.data.errors.map((error) => error.message).join('; ')}`) + } + if (!result.data.data?.appLogs) throw new AbortError('Local log query returned no appLogs result.') + return result.data.data.appLogs } async function queryConnection(demo: boolean): Promise<{origin: string; token: string}> { diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index ab1800731b7..0744688a679 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -3413,7 +3413,7 @@ "pluginName": "@shopify/cli", "pluginType": "core", "strict": true, - "summary": "Prototype only: query app log summaries through the local App Management API." + "summary": "Prototype only: query app log summaries through the local Dev Platform API." }, "app:logs:sources": { "aliases": [ @@ -10734,4 +10734,4 @@ } }, "version": "4.8.0" -} \ No newline at end of file +} From 0645a511bddac140242b505479916b9c77d29794 Mon Sep 17 00:00:00 2001 From: Rod Mcnew Date: Tue, 15 Sep 2026 14:03:08 -0500 Subject: [PATCH 05/11] Preserve generated CLI manifest formatting Co-authored by AI (GPT-5) Assisted-By: devx/77cd7a80-dbde-4d20-9b34-75ed3bac34bb --- packages/cli/oclif.manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 0744688a679..ca1af5eb72e 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -10734,4 +10734,4 @@ } }, "version": "4.8.0" -} +} \ No newline at end of file From 790a3c95d8b0240feadb04cd74e085a012d3e7cc Mon Sep 17 00:00:00 2001 From: Rod Mcnew Date: Tue, 15 Sep 2026 15:44:13 -0500 Subject: [PATCH 06/11] Query prototype app logs by app key without an organization flag Co-authored by AI (GPT-5) Assisted-By: devx/77cd7a80-dbde-4d20-9b34-75ed3bac34bb --- .../src/cli/commands/app/app-logs/query.ts | 6 -- .../src/cli/services/app-logs/query.test.ts | 68 ++++++++++++------- .../app/src/cli/services/app-logs/query.ts | 27 ++++---- packages/cli/oclif.manifest.json | 11 +-- 4 files changed, 60 insertions(+), 52 deletions(-) diff --git a/packages/app/src/cli/commands/app/app-logs/query.ts b/packages/app/src/cli/commands/app/app-logs/query.ts index f1e75b2ff0f..d0a0c9d3a4c 100644 --- a/packages/app/src/cli/commands/app/app-logs/query.ts +++ b/packages/app/src/cli/commands/app/app-logs/query.ts @@ -10,11 +10,6 @@ export default class Query extends BaseCommand { static flags = { ...globalFlags, - 'organization-id': Flags.string({ - required: true, - env: 'SHOPIFY_FLAG_ORGANIZATION_ID', - description: 'Local Business Platform organization ID.', - }), 'client-id': Flags.string({required: true, env: 'SHOPIFY_FLAG_CLIENT_ID', description: 'App API key.'}), minutes: Flags.integer({ default: 15, @@ -50,7 +45,6 @@ export default class Query extends BaseCommand { public async run(): Promise { const {flags} = await this.parse(Query) const result = await queryAppLogs({ - organizationId: flags['organization-id'], clientId: flags['client-id'], minutes: flags.minutes, limit: flags.limit, diff --git a/packages/app/src/cli/services/app-logs/query.test.ts b/packages/app/src/cli/services/app-logs/query.test.ts index 06ac0f47fae..fd64e587858 100644 --- a/packages/app/src/cli/services/app-logs/query.test.ts +++ b/packages/app/src/cli/services/app-logs/query.test.ts @@ -11,7 +11,7 @@ vi.mock('@shopify/cli-kit/node/context/fqdn') vi.mock('@shopify/cli-kit/node/http') vi.mock('@shopify/cli-kit/node/session') -const options = {organizationId: '1', clientId: 'test-app', minutes: 15, limit: 3, offset: 0, demo: false} +const options = {clientId: 'test-app', minutes: 15, limit: 3, offset: 0, demo: false} beforeEach(() => { vi.stubEnv('SHOPIFY_APP_LOG_QUERY_PROTOTYPE', '1') @@ -43,10 +43,10 @@ test('refuses an unexpected host before obtaining a token', async () => { test('sends a bounded app query using the normal CLI authentication helper', async () => { const result = {appKey: 'test-app', events: [{type: 'WEBHOOK_DELIVERY'}], exhaustive: false} - vi.mocked(fetch).mockResolvedValue(new Response(JSON.stringify({data: {appLogs: result}}), {status: 200})) + vi.mocked(fetch).mockResolvedValue(new Response(JSON.stringify({data: {app: {logs: result}}}), {status: 200})) await expect(queryAppLogs({...options, types: ['WEBHOOK_DELIVERY']})).resolves.toEqual(result) expect(fetch).toHaveBeenCalledWith( - 'https://app.shop.dev/dev_platform/unstable/organizations/1/graphql', + 'https://app.shop.dev/dev_platform/unstable/graphql', expect.objectContaining({ method: 'POST', redirect: 'error', @@ -55,11 +55,14 @@ test('sends a bounded app query using the normal CLI authentication helper', asy }), ) const request = JSON.parse(vi.mocked(fetch).mock.calls[0]![1]!.body as string) - expect(request.query).toContain('query AppLogs($input: AppLogQueryInput!)') + expect(request.query).toContain('query AppLogs($appKey: String!, $search: AppLogSearchInput!)') + expect(request.query).toContain('app(key: $appKey)') + expect(request.query).toContain('logs(input: $search)') expect(request.query).toContain('recordUid timestamp type') expect(request.operationName).toBe('AppLogs') - const input = request.variables.input - expect(input.appKey).toBe('test-app') + expect(request.variables.appKey).toBe('test-app') + const input = request.variables.search + expect(input).not.toHaveProperty('appKey') expect(input.limit).toBe(3) expect(input.offset).toBe(0) expect(Date.parse(input.endTime) - Date.parse(input.startTime)).toBe(15 * 60 * 1000) @@ -70,10 +73,10 @@ test('uses the temporary token only for the fixed loopback demo, without logging const path = joinPath(directory, 'token') await writeFile(path, 'atkn_demo-only') vi.stubEnv('APP_LOG_QUERY_DEMO_TOKEN_FILE', path) - vi.mocked(fetch).mockResolvedValue(new Response('{"data":{"appLogs":{"events":[]}}}', {status: 200})) + vi.mocked(fetch).mockResolvedValue(new Response('{"data":{"app":{"logs":{"events":[]}}}}', {status: 200})) await expect(queryAppLogs({...options, demo: true})).resolves.toEqual({events: []}) expect(fetch).toHaveBeenCalledWith( - 'http://127.0.0.1:4387/dev_platform/unstable/organizations/1/graphql', + 'http://127.0.0.1:4387/dev_platform/unstable/graphql', expect.objectContaining({headers: expect.objectContaining({authorization: 'Bearer atkn_demo-only'})}), ) expect(ensureAuthenticatedAppManagementAndBusinessPlatform).not.toHaveBeenCalled() @@ -86,9 +89,9 @@ test('surfaces API errors instead of turning them into an empty result', async ( }) test('forwards a 10000 event limit and 1000000 offset independently', async () => { - vi.mocked(fetch).mockResolvedValue(new Response('{"data":{"appLogs":{"events":[]}}}', {status: 200})) + vi.mocked(fetch).mockResolvedValue(new Response('{"data":{"app":{"logs":{"events":[]}}}}', {status: 200})) await expect(queryAppLogs({...options, limit: 10_000, offset: 1_000_000})).resolves.toEqual({events: []}) - const input = JSON.parse(vi.mocked(fetch).mock.calls[0]![1]!.body as string).variables.input + const input = JSON.parse(vi.mocked(fetch).mock.calls[0]![1]!.body as string).variables.search expect(input.limit).toBe(10_000) expect(input.offset).toBe(1_000_000) }) @@ -98,7 +101,7 @@ test('leaves upper policy limits to the server', async () => { await expect(queryAppLogs({...options, minutes: 61, limit: 10_001, offset: 1_000_001})).rejects.toThrow( 'Query limits exceeded', ) - const input = JSON.parse(vi.mocked(fetch).mock.calls[0]![1]!.body as string).variables.input + const input = JSON.parse(vi.mocked(fetch).mock.calls[0]![1]!.body as string).variables.search expect(input.limit).toBe(10_001) expect(input.offset).toBe(1_000_001) }) @@ -107,36 +110,55 @@ test('reads valid responses larger than one MiB without a client byte cap', asyn const result = {events: [{target: 'x'.repeat(2 * 1024 * 1024)}]} vi.mocked(fetch).mockImplementation(async (_url, init) => { const responseOptions = {status: 200, size: init?.size} - return new Response(JSON.stringify({data: {appLogs: result}}), responseOptions) + return new Response(JSON.stringify({data: {app: {logs: result}}}), responseOptions) }) await expect(queryAppLogs(options)).resolves.toEqual(result) expect(vi.mocked(fetch).mock.calls[0]![1]?.size).toBeUndefined() }) -test('rejects a path-injection organization ID', async () => { - await expect(queryAppLogs({...options, organizationId: '../2'})).rejects.toThrow('numeric organization ID') +test('rejects an empty app key before making a request', async () => { + await expect(queryAppLogs({...options, clientId: ''})).rejects.toThrow('nonempty app client ID') expect(fetch).not.toHaveBeenCalled() }) +test('passes the app key as a variable instead of interpolating query syntax or paths', async () => { + const clientId = '../app" } other: app(key: "another-app") { key } #' + vi.mocked(fetch).mockResolvedValue(new Response('{"data":{"app":{"logs":{"events":[]}}}}', {status: 200})) + + await queryAppLogs({...options, clientId}) + + const [url, init] = vi.mocked(fetch).mock.calls[0]! + const request = JSON.parse(init!.body as string) + expect(url).toBe('https://app.shop.dev/dev_platform/unstable/graphql') + expect(request.variables.appKey).toBe(clientId) + expect(request.query).not.toContain(clientId) +}) + test('surfaces GraphQL errors even when HTTP succeeds and partial data is present', async () => { vi.mocked(fetch).mockResolvedValue( - new Response(JSON.stringify({data: {appLogs: {events: []}}, errors: [{message: 'Log query failed'}]}), { + new Response(JSON.stringify({data: {app: {logs: {events: []}}}, errors: [{message: 'Log query failed'}]}), { status: 200, }), ) await expect(queryAppLogs(options)).rejects.toThrow('Log query failed') }) -test.each([{data: {appLogs: null}}, {data: null}, {}])('rejects a missing log result %j', async (body) => { - vi.mocked(fetch).mockResolvedValue(new Response(JSON.stringify(body), {status: 200})) - await expect(queryAppLogs(options)).rejects.toThrow('no appLogs result') -}) +test.each([{data: {app: {logs: null}}}, {data: {app: null}}, {data: null}, {}])( + 'rejects a missing log result %j', + async (body) => { + vi.mocked(fetch).mockResolvedValue(new Response(JSON.stringify(body), {status: 200})) + await expect(queryAppLogs(options)).rejects.toThrow('no app logs result') + }, +) -test.each([null, [], {data: {appLogs: []}}])('rejects a malformed GraphQL envelope %j', async (body) => { - vi.mocked(fetch).mockResolvedValue(new Response(JSON.stringify(body), {status: 200})) - await expect(queryAppLogs(options)).rejects.toThrow('invalid GraphQL response') -}) +test.each([null, [], {data: {app: []}}, {data: {app: {}}}, {data: {app: {logs: []}}}])( + 'rejects a malformed GraphQL envelope %j', + async (body) => { + vi.mocked(fetch).mockResolvedValue(new Response(JSON.stringify(body), {status: 200})) + await expect(queryAppLogs(options)).rejects.toThrow('invalid GraphQL response') + }, +) test.each([{minutes: 0}, {minutes: 1.5}, {limit: 0}, {limit: 1.5}, {offset: -1}, {offset: 1.5}])( 'rejects malformed numeric options %j before making a request', diff --git a/packages/app/src/cli/services/app-logs/query.ts b/packages/app/src/cli/services/app-logs/query.ts index 1a0db2a31a2..ae725be3fc0 100644 --- a/packages/app/src/cli/services/app-logs/query.ts +++ b/packages/app/src/cli/services/app-logs/query.ts @@ -7,24 +7,25 @@ import {ensureAuthenticatedAppManagementAndBusinessPlatform} from '@shopify/cli- import {z} from 'zod' const query = ` - query AppLogs($input: AppLogQueryInput!) { - appLogs(input: $input) { - appKey limit offset limitReached exhaustive ordering - events { recordUid timestamp type resultStatus target shopDomain } + query AppLogs($appKey: String!, $search: AppLogSearchInput!) { + app(key: $appKey) { + logs(input: $search) { + appKey limit offset limitReached exhaustive ordering + events { recordUid timestamp type resultStatus target shopDomain } + } } } ` const responseSchema = z.object({ data: z - .object({appLogs: z.record(z.unknown()).nullable()}) + .object({app: z.object({logs: z.record(z.unknown()).nullable()}).nullable()}) .nullable() .optional(), errors: z.array(z.object({message: z.string()})).optional(), }) interface QueryOptions { - organizationId: string clientId: string minutes: number limit: number @@ -37,8 +38,8 @@ export async function queryAppLogs(options: QueryOptions): Promise { if (process.env.SHOPIFY_APP_LOG_QUERY_PROTOTYPE !== '1' || process.env.SHOPIFY_SERVICE_ENV !== 'local') { throw new AbortError('Prototype only: set SHOPIFY_APP_LOG_QUERY_PROTOTYPE=1 and SHOPIFY_SERVICE_ENV=local.') } - if (!/^\d+$/.test(options.organizationId) || !options.clientId) { - throw new AbortError('Provide a numeric organization ID and a nonempty app client ID.') + if (!options.clientId) { + throw new AbortError('Provide a nonempty app client ID.') } if ( !Number.isInteger(options.minutes) || @@ -53,7 +54,7 @@ export async function queryAppLogs(options: QueryOptions): Promise { const {origin, token} = await queryConnection(options.demo) const end = new Date() - const response = await fetch(`${origin}/dev_platform/unstable/organizations/${options.organizationId}/graphql`, { + const response = await fetch(`${origin}/dev_platform/unstable/graphql`, { method: 'POST', redirect: 'error', signal: AbortSignal.timeout(15000), @@ -62,8 +63,8 @@ export async function queryAppLogs(options: QueryOptions): Promise { query, operationName: 'AppLogs', variables: { - input: { - appKey: options.clientId, + appKey: options.clientId, + search: { startTime: new Date(end.getTime() - options.minutes * 60 * 1000).toISOString(), endTime: end.toISOString(), limit: options.limit, @@ -81,8 +82,8 @@ export async function queryAppLogs(options: QueryOptions): Promise { if (result.data.errors?.length) { throw new AbortError(`Local log query failed: ${result.data.errors.map((error) => error.message).join('; ')}`) } - if (!result.data.data?.appLogs) throw new AbortError('Local log query returned no appLogs result.') - return result.data.data.appLogs + if (!result.data.data?.app?.logs) throw new AbortError('Local log query returned no app logs result.') + return result.data.data.app.logs } async function queryConnection(demo: boolean): Promise<{origin: string; token: string}> { diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index ca1af5eb72e..34320ef5a39 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -3372,15 +3372,6 @@ "name": "offset", "type": "option" }, - "organization-id": { - "description": "Local Business Platform organization ID.", - "env": "SHOPIFY_FLAG_ORGANIZATION_ID", - "hasDynamicHelp": false, - "multiple": false, - "name": "organization-id", - "required": true, - "type": "option" - }, "type": { "description": "Restrict results to these event types.", "env": "SHOPIFY_FLAG_TYPE", @@ -10734,4 +10725,4 @@ } }, "version": "4.8.0" -} \ No newline at end of file +} From 55d6518842bec70224ec15e8a8444abb71d26cd9 Mon Sep 17 00:00:00 2001 From: Rod Mcnew Date: Tue, 15 Sep 2026 15:47:45 -0500 Subject: [PATCH 07/11] Match generated manifest end-of-file formatting Co-authored by AI (GPT-5) Assisted-By: devx/77cd7a80-dbde-4d20-9b34-75ed3bac34bb --- packages/cli/oclif.manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 34320ef5a39..ee149672995 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -10725,4 +10725,4 @@ } }, "version": "4.8.0" -} +} \ No newline at end of file From c1fa3e0872fe4559d0fad3ea0514098a6520c6d8 Mon Sep 17 00:00:00 2001 From: Rod Mcnew Date: Tue, 15 Sep 2026 17:13:23 -0500 Subject: [PATCH 08/11] Point log query prototype at the Dev Dashboard API URL Co-authored by AI (GPT-5) Assisted-By: devx/77cd7a80-dbde-4d20-9b34-75ed3bac34bb --- .../app/src/cli/services/app-logs/query.test.ts | 14 +++++++------- packages/app/src/cli/services/app-logs/query.ts | 8 ++++---- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/app/src/cli/services/app-logs/query.test.ts b/packages/app/src/cli/services/app-logs/query.test.ts index fd64e587858..c404ca82170 100644 --- a/packages/app/src/cli/services/app-logs/query.test.ts +++ b/packages/app/src/cli/services/app-logs/query.test.ts @@ -1,5 +1,5 @@ import {queryAppLogs} from './query.js' -import {appManagementFqdn} from '@shopify/cli-kit/node/context/fqdn' +import {developerDashboardFqdn} from '@shopify/cli-kit/node/context/fqdn' import {fetch} from '@shopify/cli-kit/node/http' import {ensureAuthenticatedAppManagementAndBusinessPlatform} from '@shopify/cli-kit/node/session' import {inTemporaryDirectory, writeFile} from '@shopify/cli-kit/node/fs' @@ -16,7 +16,7 @@ const options = {clientId: 'test-app', minutes: 15, limit: 3, offset: 0, demo: f beforeEach(() => { vi.stubEnv('SHOPIFY_APP_LOG_QUERY_PROTOTYPE', '1') vi.stubEnv('SHOPIFY_SERVICE_ENV', 'local') - vi.mocked(appManagementFqdn).mockResolvedValue('app.shop.dev') + vi.mocked(developerDashboardFqdn).mockResolvedValue('dev.shop.dev') vi.mocked(ensureAuthenticatedAppManagementAndBusinessPlatform).mockResolvedValue({ appManagementToken: 'atkn_local-identity', userId: 'local-user', @@ -36,8 +36,8 @@ test('refuses production services before authenticating or sending a request', a }) test('refuses an unexpected host before obtaining a token', async () => { - vi.mocked(appManagementFqdn).mockResolvedValue('app.shopify.com') - await expect(queryAppLogs(options)).rejects.toThrow('only call app.shop.dev') + vi.mocked(developerDashboardFqdn).mockResolvedValue('dev.shopify.com') + await expect(queryAppLogs(options)).rejects.toThrow('only call dev.shop.dev') expect(ensureAuthenticatedAppManagementAndBusinessPlatform).not.toHaveBeenCalled() }) @@ -46,7 +46,7 @@ test('sends a bounded app query using the normal CLI authentication helper', asy vi.mocked(fetch).mockResolvedValue(new Response(JSON.stringify({data: {app: {logs: result}}}), {status: 200})) await expect(queryAppLogs({...options, types: ['WEBHOOK_DELIVERY']})).resolves.toEqual(result) expect(fetch).toHaveBeenCalledWith( - 'https://app.shop.dev/dev_platform/unstable/graphql', + 'https://dev.shop.dev/api/unstable/graphql', expect.objectContaining({ method: 'POST', redirect: 'error', @@ -76,7 +76,7 @@ test('uses the temporary token only for the fixed loopback demo, without logging vi.mocked(fetch).mockResolvedValue(new Response('{"data":{"app":{"logs":{"events":[]}}}}', {status: 200})) await expect(queryAppLogs({...options, demo: true})).resolves.toEqual({events: []}) expect(fetch).toHaveBeenCalledWith( - 'http://127.0.0.1:4387/dev_platform/unstable/graphql', + 'http://127.0.0.1:4387/api/unstable/graphql', expect.objectContaining({headers: expect.objectContaining({authorization: 'Bearer atkn_demo-only'})}), ) expect(ensureAuthenticatedAppManagementAndBusinessPlatform).not.toHaveBeenCalled() @@ -130,7 +130,7 @@ test('passes the app key as a variable instead of interpolating query syntax or const [url, init] = vi.mocked(fetch).mock.calls[0]! const request = JSON.parse(init!.body as string) - expect(url).toBe('https://app.shop.dev/dev_platform/unstable/graphql') + expect(url).toBe('https://dev.shop.dev/api/unstable/graphql') expect(request.variables.appKey).toBe(clientId) expect(request.query).not.toContain(clientId) }) diff --git a/packages/app/src/cli/services/app-logs/query.ts b/packages/app/src/cli/services/app-logs/query.ts index ae725be3fc0..0d0d37dec7c 100644 --- a/packages/app/src/cli/services/app-logs/query.ts +++ b/packages/app/src/cli/services/app-logs/query.ts @@ -1,5 +1,5 @@ import {appManagementHeaders} from '@shopify/cli-kit/node/api/app-management' -import {appManagementFqdn} from '@shopify/cli-kit/node/context/fqdn' +import {developerDashboardFqdn} from '@shopify/cli-kit/node/context/fqdn' import {AbortError} from '@shopify/cli-kit/node/error' import {readFile} from '@shopify/cli-kit/node/fs' import {fetch} from '@shopify/cli-kit/node/http' @@ -54,7 +54,7 @@ export async function queryAppLogs(options: QueryOptions): Promise { const {origin, token} = await queryConnection(options.demo) const end = new Date() - const response = await fetch(`${origin}/dev_platform/unstable/graphql`, { + const response = await fetch(`${origin}/api/unstable/graphql`, { method: 'POST', redirect: 'error', signal: AbortSignal.timeout(15000), @@ -95,8 +95,8 @@ async function queryConnection(demo: boolean): Promise<{origin: string; token: s return {origin: 'http://127.0.0.1:4387', token} } - const host = await appManagementFqdn() - if (host !== 'app.shop.dev') throw new AbortError('This prototype can only call app.shop.dev.') + const host = await developerDashboardFqdn() + if (host !== 'dev.shop.dev') throw new AbortError('This prototype can only call dev.shop.dev.') const {appManagementToken} = await ensureAuthenticatedAppManagementAndBusinessPlatform() return {origin: `https://${host}`, token: appManagementToken} } From c0cf0ff8cffff6a76fee67da0355c2b622a36c41 Mon Sep 17 00:00:00 2001 From: Rod Mcnew Date: Wed, 16 Sep 2026 10:17:53 -0500 Subject: [PATCH 09/11] Prototype log filter discovery and equality flags Co-authored by AI (unknown) Assisted-By: devx/77cd7a80-dbde-4d20-9b34-75ed3bac34bb --- .../src/cli/commands/app/app-logs/query.ts | 13 ++++ .../src/cli/services/app-logs/query.test.ts | 56 +++++++++++++++ .../app/src/cli/services/app-logs/query.ts | 68 +++++++++++++++---- packages/cli/oclif.manifest.json | 20 +++++- 4 files changed, 141 insertions(+), 16 deletions(-) diff --git a/packages/app/src/cli/commands/app/app-logs/query.ts b/packages/app/src/cli/commands/app/app-logs/query.ts index d0a0c9d3a4c..d50e1b03cf3 100644 --- a/packages/app/src/cli/commands/app/app-logs/query.ts +++ b/packages/app/src/cli/commands/app/app-logs/query.ts @@ -35,6 +35,17 @@ export default class Query extends BaseCommand { options: ['WEBHOOK_DELIVERY', 'GRAPHQL_REQUEST', 'REST_REQUEST', 'FUNCTION_RUN'], description: 'Restrict results to these event types.', }), + filter: Flags.string({ + multiple: true, + env: 'SHOPIFY_FLAG_FILTER', + description: 'Equality filter FIELD=value. Repeat to AND filters; use --list-filters for fields.', + }), + 'list-filters': Flags.boolean({ + default: false, + env: 'SHOPIFY_FLAG_LIST_FILTERS', + exclusive: ['filter'], + description: 'List filter definitions for the selected types, without searching logs.', + }), demo: Flags.boolean({ default: false, env: 'SHOPIFY_FLAG_DEMO', @@ -50,6 +61,8 @@ export default class Query extends BaseCommand { limit: flags.limit, offset: flags.offset, types: flags.type, + filters: flags.filter, + listFilters: flags['list-filters'], demo: flags.demo, }) outputResult(JSON.stringify(result, null, 2)) diff --git a/packages/app/src/cli/services/app-logs/query.test.ts b/packages/app/src/cli/services/app-logs/query.test.ts index c404ca82170..6098e5449f7 100644 --- a/packages/app/src/cli/services/app-logs/query.test.ts +++ b/packages/app/src/cli/services/app-logs/query.test.ts @@ -83,6 +83,62 @@ test('uses the temporary token only for the fixed loopback demo, without logging }) }) +test('sends repeatable equality filters as AND predicates using variables', async () => { + vi.mocked(fetch).mockResolvedValue(new Response('{"data":{"app":{"logs":{"events":[]}}}}', {status: 200})) + + await queryAppLogs({...options, types: ['FUNCTION_RUN'], filters: ['function_handle=discount', 'TARGET=some=value"']}) + + const request = JSON.parse(vi.mocked(fetch).mock.calls[0]![1]!.body as string) + expect(request.variables.search.filterGroup).toEqual({ + conjunction: 'AND', + filters: [ + {column: 'FUNCTION_HANDLE', op: 'EQUALS', values: ['discount']}, + {column: 'TARGET', op: 'EQUALS', values: ['some=value"']}, + ], + }) + expect(request.query).not.toContain('some=value') +}) + +test('discovers server-owned filter definitions without a log search or time window', async () => { + const definitions = [ + {field: 'FUNCTION_HANDLE', description: 'Function handle.', valueType: 'STRING', operators: ['EQUALS', 'IN']}, + ] + vi.mocked(fetch).mockResolvedValue(new Response(JSON.stringify({data: {app: {logFilterDefinitions: definitions}}}))) + + await expect(queryAppLogs({...options, types: ['FUNCTION_RUN'], listFilters: true})).resolves.toEqual(definitions) + + const request = JSON.parse(vi.mocked(fetch).mock.calls[0]![1]!.body as string) + expect(request.operationName).toBe('AppLogFilters') + expect(request.query).toContain('logFilterDefinitions(types: $types)') + expect(request.query).not.toContain('logs(input:') + expect(request.variables).toEqual({appKey: 'test-app', types: ['FUNCTION_RUN']}) +}) + +test.each(['missing-equals', '=value', 'TARGET=', 'bad.field=value'])('rejects malformed filter %s', async (filter) => { + await expect(queryAppLogs({...options, filters: [filter]})).rejects.toThrow('FIELD=value') + expect(fetch).not.toHaveBeenCalled() +}) + +test('leaves allowed fields and type compatibility to the server', async () => { + vi.mocked(fetch).mockResolvedValue(new Response('{"errors":[{"message":"Unsupported filter for selected types"}]}')) + + await expect(queryAppLogs({...options, filters: ['NEW_FIELD=value']})).rejects.toThrow('Unsupported filter') + expect(fetch).toHaveBeenCalledOnce() +}) + +test('rejects discovery combined with filtering instead of ignoring filters', async () => { + await expect(queryAppLogs({...options, listFilters: true, filters: ['TARGET=orders/create']})).rejects.toThrow( + 'Use --list-filters separately', + ) + expect(fetch).not.toHaveBeenCalled() +}) + +test('rejects missing or malformed discovery results', async () => { + vi.mocked(fetch).mockResolvedValue(new Response('{"data":{"app":{"logFilterDefinitions":null}}}')) + + await expect(queryAppLogs({...options, listFilters: true})).rejects.toThrow('invalid filter definitions') +}) + test('surfaces API errors instead of turning them into an empty result', async () => { vi.mocked(fetch).mockResolvedValue(new Response('private upstream details', {status: 502})) await expect(queryAppLogs(options)).rejects.toThrow('HTTP 502') diff --git a/packages/app/src/cli/services/app-logs/query.ts b/packages/app/src/cli/services/app-logs/query.ts index 0d0d37dec7c..7ce5cfb4a36 100644 --- a/packages/app/src/cli/services/app-logs/query.ts +++ b/packages/app/src/cli/services/app-logs/query.ts @@ -17,9 +17,21 @@ const query = ` } ` +const filterDefinitionsQuery = ` + query AppLogFilters($appKey: String!, $types: [LogEventType!]) { + app(key: $appKey) { + logFilterDefinitions(types: $types) { field description valueType operators } + } + } +` + +const filterDefinitionsSchema = z.array( + z.object({field: z.string(), description: z.string(), valueType: z.string(), operators: z.array(z.string())}), +) + const responseSchema = z.object({ data: z - .object({app: z.object({logs: z.record(z.unknown()).nullable()}).nullable()}) + .object({app: z.record(z.unknown()).nullable()}) .nullable() .optional(), errors: z.array(z.object({message: z.string()})).optional(), @@ -31,6 +43,8 @@ interface QueryOptions { limit: number offset: number types?: string[] + filters?: string[] + listFilters?: boolean demo: boolean } @@ -41,6 +55,9 @@ export async function queryAppLogs(options: QueryOptions): Promise { if (!options.clientId) { throw new AbortError('Provide a nonempty app client ID.') } + if (options.listFilters && options.filters?.length) { + throw new AbortError('Use --list-filters separately from --filter.') + } if ( !Number.isInteger(options.minutes) || options.minutes < 1 || @@ -52,6 +69,15 @@ export async function queryAppLogs(options: QueryOptions): Promise { throw new AbortError('Use positive integers for minutes and limit, and a nonnegative integer for offset.') } + const filters = options.filters?.map((filter) => { + const separator = filter.indexOf('=') + const column = filter.slice(0, separator).trim().toUpperCase() + const value = filter.slice(separator + 1) + if (separator < 1 || !/^[A-Z][A-Z_]*$/.test(column) || !value) { + throw new AbortError('Use --filter FIELD=value. Use --list-filters to discover supported fields.') + } + return {column, op: 'EQUALS', values: [value]} + }) const {origin, token} = await queryConnection(options.demo) const end = new Date() const response = await fetch(`${origin}/api/unstable/graphql`, { @@ -60,18 +86,21 @@ export async function queryAppLogs(options: QueryOptions): Promise { signal: AbortSignal.timeout(15000), headers: appManagementHeaders(token), body: JSON.stringify({ - query, - operationName: 'AppLogs', - variables: { - appKey: options.clientId, - search: { - startTime: new Date(end.getTime() - options.minutes * 60 * 1000).toISOString(), - endTime: end.toISOString(), - limit: options.limit, - offset: options.offset, - ...(options.types ? {types: options.types} : {}), - }, - }, + query: options.listFilters ? filterDefinitionsQuery : query, + operationName: options.listFilters ? 'AppLogFilters' : 'AppLogs', + variables: options.listFilters + ? {appKey: options.clientId, types: options.types} + : { + appKey: options.clientId, + search: { + startTime: new Date(end.getTime() - options.minutes * 60 * 1000).toISOString(), + endTime: end.toISOString(), + limit: options.limit, + offset: options.offset, + ...(options.types ? {types: options.types} : {}), + ...(filters?.length ? {filterGroup: {conjunction: 'AND', filters}} : {}), + }, + }, }), }) if (!response.ok) { @@ -82,8 +111,17 @@ export async function queryAppLogs(options: QueryOptions): Promise { if (result.data.errors?.length) { throw new AbortError(`Local log query failed: ${result.data.errors.map((error) => error.message).join('; ')}`) } - if (!result.data.data?.app?.logs) throw new AbortError('Local log query returned no app logs result.') - return result.data.data.app.logs + const app = result.data.data?.app + if (options.listFilters) { + const definitions = filterDefinitionsSchema.safeParse(app?.logFilterDefinitions) + if (!definitions.success) throw new AbortError('Local log query returned invalid filter definitions.') + return definitions.data + } + if (app && !('logs' in app)) throw new AbortError('Local log query returned an invalid GraphQL response.') + if (!app?.logs) throw new AbortError('Local log query returned no app logs result.') + const logs = z.record(z.unknown()).safeParse(app.logs) + if (!logs.success) throw new AbortError('Local log query returned an invalid GraphQL response.') + return logs.data } async function queryConnection(demo: boolean): Promise<{origin: string; token: string}> { diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index ee149672995..36047eb405f 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -3337,6 +3337,14 @@ "name": "demo", "type": "boolean" }, + "filter": { + "description": "Equality filter FIELD=value. Repeat to AND filters; use --list-filters for fields.", + "env": "SHOPIFY_FLAG_FILTER", + "hasDynamicHelp": false, + "multiple": true, + "name": "filter", + "type": "option" + }, "limit": { "default": 10, "description": "Maximum summary events to return.", @@ -3346,6 +3354,16 @@ "name": "limit", "type": "option" }, + "list-filters": { + "allowNo": false, + "description": "List filter definitions for the selected types, without searching logs.", + "env": "SHOPIFY_FLAG_LIST_FILTERS", + "exclusive": [ + "filter" + ], + "name": "list-filters", + "type": "boolean" + }, "minutes": { "default": 15, "description": "Query the last N minutes.", @@ -10725,4 +10743,4 @@ } }, "version": "4.8.0" -} \ No newline at end of file +} From 0e27789c82b9960150571a070bd83adc7f8b2466 Mon Sep 17 00:00:00 2001 From: Rod Mcnew Date: Wed, 16 Sep 2026 10:22:28 -0500 Subject: [PATCH 10/11] Match canonical CLI manifest formatting Co-authored by AI (unknown) Assisted-By: devx/77cd7a80-dbde-4d20-9b34-75ed3bac34bb --- packages/cli/oclif.manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 36047eb405f..dd2db67834a 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -10743,4 +10743,4 @@ } }, "version": "4.8.0" -} +} \ No newline at end of file From d6b380418f7ba5088b707ac987d2118df3b0c3c6 Mon Sep 17 00:00:00 2001 From: Rod Mcnew Date: Wed, 16 Sep 2026 17:26:14 -0500 Subject: [PATCH 11/11] Replace prototype log flags with GraphQL execution Co-authored by AI (GPT-5) Assisted-By: devx/77cd7a80-dbde-4d20-9b34-75ed3bac34bb --- .../src/cli/commands/app/app-logs/query.ts | 70 ----- .../app/src/cli/commands/dev/execute.test.ts | 79 ++++++ packages/app/src/cli/commands/dev/execute.ts | 49 ++++ packages/app/src/cli/index.ts | 4 +- .../src/cli/services/app-logs/query.test.ts | 225 --------------- .../app/src/cli/services/app-logs/query.ts | 140 ---------- .../app/src/cli/services/dev/execute.test.ts | 256 ++++++++++++++++++ packages/app/src/cli/services/dev/execute.ts | 102 +++++++ packages/cli/bin/bundle.js | 1 - packages/cli/oclif.manifest.json | 204 +++++++------- packages/cli/src/command-registry.ts | 1 - 11 files changed, 581 insertions(+), 550 deletions(-) delete mode 100644 packages/app/src/cli/commands/app/app-logs/query.ts create mode 100644 packages/app/src/cli/commands/dev/execute.test.ts create mode 100644 packages/app/src/cli/commands/dev/execute.ts delete mode 100644 packages/app/src/cli/services/app-logs/query.test.ts delete mode 100644 packages/app/src/cli/services/app-logs/query.ts create mode 100644 packages/app/src/cli/services/dev/execute.test.ts create mode 100644 packages/app/src/cli/services/dev/execute.ts diff --git a/packages/app/src/cli/commands/app/app-logs/query.ts b/packages/app/src/cli/commands/app/app-logs/query.ts deleted file mode 100644 index d50e1b03cf3..00000000000 --- a/packages/app/src/cli/commands/app/app-logs/query.ts +++ /dev/null @@ -1,70 +0,0 @@ -import {queryAppLogs} from '../../../services/app-logs/query.js' -import BaseCommand from '@shopify/cli-kit/node/base-command' -import {globalFlags} from '@shopify/cli-kit/node/cli' -import {outputResult} from '@shopify/cli-kit/node/output' -import {Flags} from '@oclif/core' - -export default class Query extends BaseCommand { - static hidden = true - static summary = 'Prototype only: query app log summaries through the local Dev Platform API.' - - static flags = { - ...globalFlags, - 'client-id': Flags.string({required: true, env: 'SHOPIFY_FLAG_CLIENT_ID', description: 'App API key.'}), - minutes: Flags.integer({ - default: 15, - min: 1, - env: 'SHOPIFY_FLAG_MINUTES', - description: 'Query the last N minutes.', - }), - limit: Flags.integer({ - default: 10, - min: 1, - env: 'SHOPIFY_FLAG_LIMIT', - description: 'Maximum summary events to return.', - }), - offset: Flags.integer({ - default: 0, - min: 0, - env: 'SHOPIFY_FLAG_OFFSET', - description: 'Number of matching rows to skip. Results are unordered; this is not a reliable export cursor.', - }), - type: Flags.string({ - multiple: true, - env: 'SHOPIFY_FLAG_TYPE', - options: ['WEBHOOK_DELIVERY', 'GRAPHQL_REQUEST', 'REST_REQUEST', 'FUNCTION_RUN'], - description: 'Restrict results to these event types.', - }), - filter: Flags.string({ - multiple: true, - env: 'SHOPIFY_FLAG_FILTER', - description: 'Equality filter FIELD=value. Repeat to AND filters; use --list-filters for fields.', - }), - 'list-filters': Flags.boolean({ - default: false, - env: 'SHOPIFY_FLAG_LIST_FILTERS', - exclusive: ['filter'], - description: 'List filter definitions for the selected types, without searching logs.', - }), - demo: Flags.boolean({ - default: false, - env: 'SHOPIFY_FLAG_DEMO', - description: 'Use the loopback demo with seeded auth, not Identity login.', - }), - } - - public async run(): Promise { - const {flags} = await this.parse(Query) - const result = await queryAppLogs({ - clientId: flags['client-id'], - minutes: flags.minutes, - limit: flags.limit, - offset: flags.offset, - types: flags.type, - filters: flags.filter, - listFilters: flags['list-filters'], - demo: flags.demo, - }) - outputResult(JSON.stringify(result, null, 2)) - } -} diff --git a/packages/app/src/cli/commands/dev/execute.test.ts b/packages/app/src/cli/commands/dev/execute.test.ts new file mode 100644 index 00000000000..345680ae99b --- /dev/null +++ b/packages/app/src/cli/commands/dev/execute.test.ts @@ -0,0 +1,79 @@ +import Execute from './execute.js' +import {executeDevPlatformOperation} from '../../services/dev/execute.js' +import {outputResult} from '@shopify/cli-kit/node/output' +import {afterEach, beforeEach, expect, test, vi} from 'vitest' +import {Parser} from '@oclif/core' + +vi.mock('../../services/dev/execute.js') +vi.mock('@shopify/cli-kit/node/output', async (importOriginal) => ({ + ...(await importOriginal()), + outputResult: vi.fn(), +})) + +const originalExitCode = process.exitCode + +beforeEach(() => { + process.exitCode = undefined +}) + +afterEach(() => { + process.exitCode = originalExitCode +}) + +test('forwards a GraphQL request without an app project or log-specific flags and prints the full response', async () => { + const query = '{ __schema { queryType { name } } }' + const response = {data: {__schema: {queryType: {name: 'QueryRoot'}}}} + vi.mocked(executeDevPlatformOperation).mockResolvedValue({response, failed: false}) + + await Execute.run(['--query', query], import.meta.url) + + expect(executeDevPlatformOperation).toHaveBeenCalledWith({ + query, + queryFile: undefined, + variables: undefined, + variableFile: undefined, + operationName: undefined, + demo: false, + }) + expect(outputResult).toHaveBeenCalledExactlyOnceWith(JSON.stringify(response, null, 2)) + expect(process.exitCode).toBeUndefined() +}) + +test('forwards stdin, variables, operation name and the local demo selection', async () => { + vi.mocked(executeDevPlatformOperation).mockResolvedValue({response: {data: {app: null}}, failed: false}) + + await Execute.run( + ['--query-file', '-', '--variables', '{"key":"test-app"}', '--operation-name', 'Logs', '--demo'], + import.meta.url, + ) + + expect(executeDevPlatformOperation).toHaveBeenCalledWith({ + query: undefined, + queryFile: '-', + variables: '{"key":"test-app"}', + variableFile: undefined, + operationName: 'Logs', + demo: true, + }) +}) + +test('preserves partial data and errors in stdout while setting a failing exit status', async () => { + const response = {data: {app: null}, errors: [{message: 'Access denied', path: ['app']}]} + vi.mocked(executeDevPlatformOperation).mockResolvedValue({response, failed: true}) + + await Execute.run(['--query', '{ app(key: "test-app") { key } }'], import.meta.url) + + expect(outputResult).toHaveBeenCalledExactlyOnceWith(JSON.stringify(response, null, 2)) + expect(process.exitCode).toBe(1) +}) + +test.each([ + {args: []}, + {args: ['--query', '{ __typename }', '--query-file', 'query.graphql']}, + {args: ['--query', '{ __typename }', '--variables', '{}', '--variable-file', 'variables.json']}, + {args: ['--query', '{ __typename }', '--minutes', '15']}, + {args: ['--query', '{ __typename }', '--client-id', 'test-app']}, + {args: ['--query', '{ __typename }', '--list-filters']}, +])('rejects conflicting, missing or log-specific flags %j', async ({args}) => { + await expect(Parser.parse(args, {flags: Execute.flags})).rejects.toThrow() +}) diff --git a/packages/app/src/cli/commands/dev/execute.ts b/packages/app/src/cli/commands/dev/execute.ts new file mode 100644 index 00000000000..6f0c2f3c352 --- /dev/null +++ b/packages/app/src/cli/commands/dev/execute.ts @@ -0,0 +1,49 @@ +import {executeDevPlatformOperation} from '../../services/dev/execute.js' +import {operationFlags} from '../../flags.js' +import BaseCommand from '@shopify/cli-kit/node/base-command' +import {globalFlags} from '@shopify/cli-kit/node/cli' +import {outputResult} from '@shopify/cli-kit/node/output' +import {Flags} from '@oclif/core' + +export default class Execute extends BaseCommand { + static hidden = true + static summary = 'Prototype only: execute a GraphQL request against the local Dev Platform API.' + static description = + 'Prints the complete GraphQL JSON response. The query selects apps, filters, and returned fields. ' + + 'GraphQL or HTTP errors produce a nonzero exit status, preserving partial data when available.' + + static flags = { + ...globalFlags, + query: operationFlags.query, + 'query-file': Flags.string({ + description: 'Path to a GraphQL document, or - to read from stdin.', + env: 'SHOPIFY_FLAG_QUERY_FILE', + exactlyOne: ['query', 'query-file'], + }), + variables: operationFlags.variables, + 'variable-file': operationFlags['variable-file'], + 'operation-name': Flags.string({ + description: 'The operation to execute when the document contains multiple operations.', + env: 'SHOPIFY_FLAG_OPERATION_NAME', + }), + demo: Flags.boolean({ + default: false, + env: 'SHOPIFY_FLAG_DEMO', + description: 'Use the loopback demo with seeded auth, not Identity login.', + }), + } + + public async run(): Promise { + const {flags} = await this.parse(Execute) + const {response, failed} = await executeDevPlatformOperation({ + query: flags.query, + queryFile: flags['query-file'], + variables: flags.variables, + variableFile: flags['variable-file'], + operationName: flags['operation-name'], + demo: flags.demo, + }) + outputResult(JSON.stringify(response, null, 2)) + if (failed) process.exitCode = 1 + } +} diff --git a/packages/app/src/cli/index.ts b/packages/app/src/cli/index.ts index bec72cb4dae..f2fe8e475ee 100644 --- a/packages/app/src/cli/index.ts +++ b/packages/app/src/cli/index.ts @@ -12,7 +12,7 @@ import DoctorSubmit from './commands/app/doctor/submit.js' import Doctor from './commands/app/doctor.js' import Logs from './commands/app/logs.js' import Sources from './commands/app/app-logs/sources.js' -import QueryLogs from './commands/app/app-logs/query.js' +import DevExecute from './commands/dev/execute.js' import EnvPull from './commands/app/env/pull.js' import EnvShow from './commands/app/env/show.js' import BulkExecute from './commands/app/bulk/execute.js' @@ -62,7 +62,7 @@ export const commands: {[key: string]: typeof AppLinkedCommand | typeof AppUnlin 'app:doctor': Doctor, 'app:logs': Logs, 'app:logs:sources': Sources, - 'app:logs:query': QueryLogs, + 'dev:execute': DevExecute, 'app:import-custom-data-definitions': ImportCustomDataDefinitions, 'app:import-extensions': ImportExtensions, 'app:info': AppInfo, diff --git a/packages/app/src/cli/services/app-logs/query.test.ts b/packages/app/src/cli/services/app-logs/query.test.ts deleted file mode 100644 index 6098e5449f7..00000000000 --- a/packages/app/src/cli/services/app-logs/query.test.ts +++ /dev/null @@ -1,225 +0,0 @@ -import {queryAppLogs} from './query.js' -import {developerDashboardFqdn} from '@shopify/cli-kit/node/context/fqdn' -import {fetch} from '@shopify/cli-kit/node/http' -import {ensureAuthenticatedAppManagementAndBusinessPlatform} from '@shopify/cli-kit/node/session' -import {inTemporaryDirectory, writeFile} from '@shopify/cli-kit/node/fs' -import {joinPath} from '@shopify/cli-kit/node/path' -import {afterEach, beforeEach, expect, test, vi} from 'vitest' -import {Response} from 'node-fetch' - -vi.mock('@shopify/cli-kit/node/context/fqdn') -vi.mock('@shopify/cli-kit/node/http') -vi.mock('@shopify/cli-kit/node/session') - -const options = {clientId: 'test-app', minutes: 15, limit: 3, offset: 0, demo: false} - -beforeEach(() => { - vi.stubEnv('SHOPIFY_APP_LOG_QUERY_PROTOTYPE', '1') - vi.stubEnv('SHOPIFY_SERVICE_ENV', 'local') - vi.mocked(developerDashboardFqdn).mockResolvedValue('dev.shop.dev') - vi.mocked(ensureAuthenticatedAppManagementAndBusinessPlatform).mockResolvedValue({ - appManagementToken: 'atkn_local-identity', - userId: 'local-user', - businessPlatformToken: 'unused', - }) -}) - -afterEach(() => { - vi.unstubAllEnvs() -}) - -test('refuses production services before authenticating or sending a request', async () => { - vi.stubEnv('SHOPIFY_SERVICE_ENV', 'production') - await expect(queryAppLogs(options)).rejects.toThrow('Prototype only') - expect(ensureAuthenticatedAppManagementAndBusinessPlatform).not.toHaveBeenCalled() - expect(fetch).not.toHaveBeenCalled() -}) - -test('refuses an unexpected host before obtaining a token', async () => { - vi.mocked(developerDashboardFqdn).mockResolvedValue('dev.shopify.com') - await expect(queryAppLogs(options)).rejects.toThrow('only call dev.shop.dev') - expect(ensureAuthenticatedAppManagementAndBusinessPlatform).not.toHaveBeenCalled() -}) - -test('sends a bounded app query using the normal CLI authentication helper', async () => { - const result = {appKey: 'test-app', events: [{type: 'WEBHOOK_DELIVERY'}], exhaustive: false} - vi.mocked(fetch).mockResolvedValue(new Response(JSON.stringify({data: {app: {logs: result}}}), {status: 200})) - await expect(queryAppLogs({...options, types: ['WEBHOOK_DELIVERY']})).resolves.toEqual(result) - expect(fetch).toHaveBeenCalledWith( - 'https://dev.shop.dev/api/unstable/graphql', - expect.objectContaining({ - method: 'POST', - redirect: 'error', - headers: expect.objectContaining({authorization: 'Bearer atkn_local-identity'}), - body: expect.stringContaining('"types":["WEBHOOK_DELIVERY"]'), - }), - ) - const request = JSON.parse(vi.mocked(fetch).mock.calls[0]![1]!.body as string) - expect(request.query).toContain('query AppLogs($appKey: String!, $search: AppLogSearchInput!)') - expect(request.query).toContain('app(key: $appKey)') - expect(request.query).toContain('logs(input: $search)') - expect(request.query).toContain('recordUid timestamp type') - expect(request.operationName).toBe('AppLogs') - expect(request.variables.appKey).toBe('test-app') - const input = request.variables.search - expect(input).not.toHaveProperty('appKey') - expect(input.limit).toBe(3) - expect(input.offset).toBe(0) - expect(Date.parse(input.endTime) - Date.parse(input.startTime)).toBe(15 * 60 * 1000) -}) - -test('uses the temporary token only for the fixed loopback demo, without logging into Identity', async () => { - await inTemporaryDirectory(async (directory) => { - const path = joinPath(directory, 'token') - await writeFile(path, 'atkn_demo-only') - vi.stubEnv('APP_LOG_QUERY_DEMO_TOKEN_FILE', path) - vi.mocked(fetch).mockResolvedValue(new Response('{"data":{"app":{"logs":{"events":[]}}}}', {status: 200})) - await expect(queryAppLogs({...options, demo: true})).resolves.toEqual({events: []}) - expect(fetch).toHaveBeenCalledWith( - 'http://127.0.0.1:4387/api/unstable/graphql', - expect.objectContaining({headers: expect.objectContaining({authorization: 'Bearer atkn_demo-only'})}), - ) - expect(ensureAuthenticatedAppManagementAndBusinessPlatform).not.toHaveBeenCalled() - }) -}) - -test('sends repeatable equality filters as AND predicates using variables', async () => { - vi.mocked(fetch).mockResolvedValue(new Response('{"data":{"app":{"logs":{"events":[]}}}}', {status: 200})) - - await queryAppLogs({...options, types: ['FUNCTION_RUN'], filters: ['function_handle=discount', 'TARGET=some=value"']}) - - const request = JSON.parse(vi.mocked(fetch).mock.calls[0]![1]!.body as string) - expect(request.variables.search.filterGroup).toEqual({ - conjunction: 'AND', - filters: [ - {column: 'FUNCTION_HANDLE', op: 'EQUALS', values: ['discount']}, - {column: 'TARGET', op: 'EQUALS', values: ['some=value"']}, - ], - }) - expect(request.query).not.toContain('some=value') -}) - -test('discovers server-owned filter definitions without a log search or time window', async () => { - const definitions = [ - {field: 'FUNCTION_HANDLE', description: 'Function handle.', valueType: 'STRING', operators: ['EQUALS', 'IN']}, - ] - vi.mocked(fetch).mockResolvedValue(new Response(JSON.stringify({data: {app: {logFilterDefinitions: definitions}}}))) - - await expect(queryAppLogs({...options, types: ['FUNCTION_RUN'], listFilters: true})).resolves.toEqual(definitions) - - const request = JSON.parse(vi.mocked(fetch).mock.calls[0]![1]!.body as string) - expect(request.operationName).toBe('AppLogFilters') - expect(request.query).toContain('logFilterDefinitions(types: $types)') - expect(request.query).not.toContain('logs(input:') - expect(request.variables).toEqual({appKey: 'test-app', types: ['FUNCTION_RUN']}) -}) - -test.each(['missing-equals', '=value', 'TARGET=', 'bad.field=value'])('rejects malformed filter %s', async (filter) => { - await expect(queryAppLogs({...options, filters: [filter]})).rejects.toThrow('FIELD=value') - expect(fetch).not.toHaveBeenCalled() -}) - -test('leaves allowed fields and type compatibility to the server', async () => { - vi.mocked(fetch).mockResolvedValue(new Response('{"errors":[{"message":"Unsupported filter for selected types"}]}')) - - await expect(queryAppLogs({...options, filters: ['NEW_FIELD=value']})).rejects.toThrow('Unsupported filter') - expect(fetch).toHaveBeenCalledOnce() -}) - -test('rejects discovery combined with filtering instead of ignoring filters', async () => { - await expect(queryAppLogs({...options, listFilters: true, filters: ['TARGET=orders/create']})).rejects.toThrow( - 'Use --list-filters separately', - ) - expect(fetch).not.toHaveBeenCalled() -}) - -test('rejects missing or malformed discovery results', async () => { - vi.mocked(fetch).mockResolvedValue(new Response('{"data":{"app":{"logFilterDefinitions":null}}}')) - - await expect(queryAppLogs({...options, listFilters: true})).rejects.toThrow('invalid filter definitions') -}) - -test('surfaces API errors instead of turning them into an empty result', async () => { - vi.mocked(fetch).mockResolvedValue(new Response('private upstream details', {status: 502})) - await expect(queryAppLogs(options)).rejects.toThrow('HTTP 502') -}) - -test('forwards a 10000 event limit and 1000000 offset independently', async () => { - vi.mocked(fetch).mockResolvedValue(new Response('{"data":{"app":{"logs":{"events":[]}}}}', {status: 200})) - await expect(queryAppLogs({...options, limit: 10_000, offset: 1_000_000})).resolves.toEqual({events: []}) - const input = JSON.parse(vi.mocked(fetch).mock.calls[0]![1]!.body as string).variables.search - expect(input.limit).toBe(10_000) - expect(input.offset).toBe(1_000_000) -}) - -test('leaves upper policy limits to the server', async () => { - vi.mocked(fetch).mockResolvedValue(new Response('{"errors":[{"message":"Query limits exceeded"}]}', {status: 200})) - await expect(queryAppLogs({...options, minutes: 61, limit: 10_001, offset: 1_000_001})).rejects.toThrow( - 'Query limits exceeded', - ) - const input = JSON.parse(vi.mocked(fetch).mock.calls[0]![1]!.body as string).variables.search - expect(input.limit).toBe(10_001) - expect(input.offset).toBe(1_000_001) -}) - -test('reads valid responses larger than one MiB without a client byte cap', async () => { - const result = {events: [{target: 'x'.repeat(2 * 1024 * 1024)}]} - vi.mocked(fetch).mockImplementation(async (_url, init) => { - const responseOptions = {status: 200, size: init?.size} - return new Response(JSON.stringify({data: {app: {logs: result}}}), responseOptions) - }) - - await expect(queryAppLogs(options)).resolves.toEqual(result) - expect(vi.mocked(fetch).mock.calls[0]![1]?.size).toBeUndefined() -}) - -test('rejects an empty app key before making a request', async () => { - await expect(queryAppLogs({...options, clientId: ''})).rejects.toThrow('nonempty app client ID') - expect(fetch).not.toHaveBeenCalled() -}) - -test('passes the app key as a variable instead of interpolating query syntax or paths', async () => { - const clientId = '../app" } other: app(key: "another-app") { key } #' - vi.mocked(fetch).mockResolvedValue(new Response('{"data":{"app":{"logs":{"events":[]}}}}', {status: 200})) - - await queryAppLogs({...options, clientId}) - - const [url, init] = vi.mocked(fetch).mock.calls[0]! - const request = JSON.parse(init!.body as string) - expect(url).toBe('https://dev.shop.dev/api/unstable/graphql') - expect(request.variables.appKey).toBe(clientId) - expect(request.query).not.toContain(clientId) -}) - -test('surfaces GraphQL errors even when HTTP succeeds and partial data is present', async () => { - vi.mocked(fetch).mockResolvedValue( - new Response(JSON.stringify({data: {app: {logs: {events: []}}}, errors: [{message: 'Log query failed'}]}), { - status: 200, - }), - ) - await expect(queryAppLogs(options)).rejects.toThrow('Log query failed') -}) - -test.each([{data: {app: {logs: null}}}, {data: {app: null}}, {data: null}, {}])( - 'rejects a missing log result %j', - async (body) => { - vi.mocked(fetch).mockResolvedValue(new Response(JSON.stringify(body), {status: 200})) - await expect(queryAppLogs(options)).rejects.toThrow('no app logs result') - }, -) - -test.each([null, [], {data: {app: []}}, {data: {app: {}}}, {data: {app: {logs: []}}}])( - 'rejects a malformed GraphQL envelope %j', - async (body) => { - vi.mocked(fetch).mockResolvedValue(new Response(JSON.stringify(body), {status: 200})) - await expect(queryAppLogs(options)).rejects.toThrow('invalid GraphQL response') - }, -) - -test.each([{minutes: 0}, {minutes: 1.5}, {limit: 0}, {limit: 1.5}, {offset: -1}, {offset: 1.5}])( - 'rejects malformed numeric options %j before making a request', - async (invalidOptions) => { - await expect(queryAppLogs({...options, ...invalidOptions})).rejects.toThrow('positive integers') - expect(fetch).not.toHaveBeenCalled() - }, -) diff --git a/packages/app/src/cli/services/app-logs/query.ts b/packages/app/src/cli/services/app-logs/query.ts deleted file mode 100644 index 7ce5cfb4a36..00000000000 --- a/packages/app/src/cli/services/app-logs/query.ts +++ /dev/null @@ -1,140 +0,0 @@ -import {appManagementHeaders} from '@shopify/cli-kit/node/api/app-management' -import {developerDashboardFqdn} from '@shopify/cli-kit/node/context/fqdn' -import {AbortError} from '@shopify/cli-kit/node/error' -import {readFile} from '@shopify/cli-kit/node/fs' -import {fetch} from '@shopify/cli-kit/node/http' -import {ensureAuthenticatedAppManagementAndBusinessPlatform} from '@shopify/cli-kit/node/session' -import {z} from 'zod' - -const query = ` - query AppLogs($appKey: String!, $search: AppLogSearchInput!) { - app(key: $appKey) { - logs(input: $search) { - appKey limit offset limitReached exhaustive ordering - events { recordUid timestamp type resultStatus target shopDomain } - } - } - } -` - -const filterDefinitionsQuery = ` - query AppLogFilters($appKey: String!, $types: [LogEventType!]) { - app(key: $appKey) { - logFilterDefinitions(types: $types) { field description valueType operators } - } - } -` - -const filterDefinitionsSchema = z.array( - z.object({field: z.string(), description: z.string(), valueType: z.string(), operators: z.array(z.string())}), -) - -const responseSchema = z.object({ - data: z - .object({app: z.record(z.unknown()).nullable()}) - .nullable() - .optional(), - errors: z.array(z.object({message: z.string()})).optional(), -}) - -interface QueryOptions { - clientId: string - minutes: number - limit: number - offset: number - types?: string[] - filters?: string[] - listFilters?: boolean - demo: boolean -} - -export async function queryAppLogs(options: QueryOptions): Promise { - if (process.env.SHOPIFY_APP_LOG_QUERY_PROTOTYPE !== '1' || process.env.SHOPIFY_SERVICE_ENV !== 'local') { - throw new AbortError('Prototype only: set SHOPIFY_APP_LOG_QUERY_PROTOTYPE=1 and SHOPIFY_SERVICE_ENV=local.') - } - if (!options.clientId) { - throw new AbortError('Provide a nonempty app client ID.') - } - if (options.listFilters && options.filters?.length) { - throw new AbortError('Use --list-filters separately from --filter.') - } - if ( - !Number.isInteger(options.minutes) || - options.minutes < 1 || - !Number.isInteger(options.limit) || - options.limit < 1 || - !Number.isInteger(options.offset) || - options.offset < 0 - ) { - throw new AbortError('Use positive integers for minutes and limit, and a nonnegative integer for offset.') - } - - const filters = options.filters?.map((filter) => { - const separator = filter.indexOf('=') - const column = filter.slice(0, separator).trim().toUpperCase() - const value = filter.slice(separator + 1) - if (separator < 1 || !/^[A-Z][A-Z_]*$/.test(column) || !value) { - throw new AbortError('Use --filter FIELD=value. Use --list-filters to discover supported fields.') - } - return {column, op: 'EQUALS', values: [value]} - }) - const {origin, token} = await queryConnection(options.demo) - const end = new Date() - const response = await fetch(`${origin}/api/unstable/graphql`, { - method: 'POST', - redirect: 'error', - signal: AbortSignal.timeout(15000), - headers: appManagementHeaders(token), - body: JSON.stringify({ - query: options.listFilters ? filterDefinitionsQuery : query, - operationName: options.listFilters ? 'AppLogFilters' : 'AppLogs', - variables: options.listFilters - ? {appKey: options.clientId, types: options.types} - : { - appKey: options.clientId, - search: { - startTime: new Date(end.getTime() - options.minutes * 60 * 1000).toISOString(), - endTime: end.toISOString(), - limit: options.limit, - offset: options.offset, - ...(options.types ? {types: options.types} : {}), - ...(filters?.length ? {filterGroup: {conjunction: 'AND', filters}} : {}), - }, - }, - }), - }) - if (!response.ok) { - throw new AbortError(`Local log query failed (HTTP ${response.status}). Check the local API server output.`) - } - const result = responseSchema.safeParse(await response.json()) - if (!result.success) throw new AbortError('Local log query returned an invalid GraphQL response.') - if (result.data.errors?.length) { - throw new AbortError(`Local log query failed: ${result.data.errors.map((error) => error.message).join('; ')}`) - } - const app = result.data.data?.app - if (options.listFilters) { - const definitions = filterDefinitionsSchema.safeParse(app?.logFilterDefinitions) - if (!definitions.success) throw new AbortError('Local log query returned invalid filter definitions.') - return definitions.data - } - if (app && !('logs' in app)) throw new AbortError('Local log query returned an invalid GraphQL response.') - if (!app?.logs) throw new AbortError('Local log query returned no app logs result.') - const logs = z.record(z.unknown()).safeParse(app.logs) - if (!logs.success) throw new AbortError('Local log query returned an invalid GraphQL response.') - return logs.data -} - -async function queryConnection(demo: boolean): Promise<{origin: string; token: string}> { - if (demo) { - const path = process.env.APP_LOG_QUERY_DEMO_TOKEN_FILE - if (!path) throw new AbortError('Set APP_LOG_QUERY_DEMO_TOKEN_FILE to the file printed by the local demo server.') - const token = (await readFile(path)).trim() - if (!token.startsWith('atkn_') || token.length > 4096) throw new AbortError('Invalid demo token file.') - return {origin: 'http://127.0.0.1:4387', token} - } - - const host = await developerDashboardFqdn() - if (host !== 'dev.shop.dev') throw new AbortError('This prototype can only call dev.shop.dev.') - const {appManagementToken} = await ensureAuthenticatedAppManagementAndBusinessPlatform() - return {origin: `https://${host}`, token: appManagementToken} -} diff --git a/packages/app/src/cli/services/dev/execute.test.ts b/packages/app/src/cli/services/dev/execute.test.ts new file mode 100644 index 00000000000..f35001f190b --- /dev/null +++ b/packages/app/src/cli/services/dev/execute.test.ts @@ -0,0 +1,256 @@ +import {executeDevPlatformOperation} from './execute.js' +import {developerDashboardFqdn} from '@shopify/cli-kit/node/context/fqdn' +import {fetch, Response} from '@shopify/cli-kit/node/http' +import {ensureAuthenticatedAppManagementAndBusinessPlatform} from '@shopify/cli-kit/node/session' +import {inTemporaryDirectory, writeFile} from '@shopify/cli-kit/node/fs' +import {joinPath} from '@shopify/cli-kit/node/path' +import {readStdinString} from '@shopify/cli-kit/node/system' +import {afterEach, beforeEach, expect, test, vi} from 'vitest' + +vi.mock('@shopify/cli-kit/node/context/fqdn') +vi.mock('@shopify/cli-kit/node/http', async (importOriginal) => ({ + ...(await importOriginal()), + fetch: vi.fn(), +})) +vi.mock('@shopify/cli-kit/node/session') +vi.mock('@shopify/cli-kit/node/system', async (importOriginal) => ({ + ...(await importOriginal()), + readStdinString: vi.fn(), +})) + +const options = {query: '{ __typename }', demo: false} + +beforeEach(() => { + vi.stubEnv('SHOPIFY_APP_LOG_QUERY_PROTOTYPE', '1') + vi.stubEnv('SHOPIFY_SERVICE_ENV', 'local') + vi.mocked(developerDashboardFqdn).mockResolvedValue('dev.shop.dev') + vi.mocked(ensureAuthenticatedAppManagementAndBusinessPlatform).mockResolvedValue({ + appManagementToken: 'atkn_local-identity', + userId: 'local-user', + businessPlatformToken: 'unused', + }) +}) + +afterEach(() => { + vi.unstubAllEnvs() +}) + +test.each([ + ['SHOPIFY_SERVICE_ENV', 'production'], + ['SHOPIFY_APP_LOG_QUERY_PROTOTYPE', '0'], +])('refuses %s=%s before authenticating or sending a request', async (name, value) => { + vi.stubEnv(name, value) + await expect(executeDevPlatformOperation(options)).rejects.toThrow('Prototype only') + expect(ensureAuthenticatedAppManagementAndBusinessPlatform).not.toHaveBeenCalled() + expect(fetch).not.toHaveBeenCalled() +}) + +test('refuses an unexpected host before obtaining a token', async () => { + vi.mocked(developerDashboardFqdn).mockResolvedValue('dev.shopify.com') + await expect(executeDevPlatformOperation(options)).rejects.toThrow('only call dev.shop.dev') + expect(ensureAuthenticatedAppManagementAndBusinessPlatform).not.toHaveBeenCalled() + expect(fetch).not.toHaveBeenCalled() +}) + +test('forwards the exact document, variables and operation name without building a logs query', async () => { + const query = ` + query Logs($key: String!, $search: AppLogSearchInput!) { + chosenApp: app(key: $key) { + logs(input: $search) { events { ...Details } } + } + } + fragment Details on LogRecord { timestamp webhook { headers { name } } } + ` + const variables = {key: 'test-app', search: {limit: 10001, offset: 1000001}} + const response = {data: {chosenApp: {logs: {events: [{timestamp: '2026-09-16T20:00:00Z'}]}}}} + vi.mocked(fetch).mockResolvedValue(new Response(JSON.stringify(response))) + + await expect( + executeDevPlatformOperation({...options, query, variables: JSON.stringify(variables), operationName: 'Logs'}), + ).resolves.toEqual({response, failed: false}) + + expect(fetch).toHaveBeenCalledExactlyOnceWith( + 'https://dev.shop.dev/api/unstable/graphql', + expect.objectContaining({ + method: 'POST', + redirect: 'error', + headers: expect.objectContaining({authorization: 'Bearer atkn_local-identity'}), + body: JSON.stringify({query, variables, operationName: 'Logs'}), + }), + ) +}) + +test('supports introspection without an app key and preserves extensions', async () => { + const query = '{ __schema { queryType { name } } }' + const response = {data: {__schema: {queryType: {name: 'QueryRoot'}}}, extensions: {requestId: 'example'}} + vi.mocked(fetch).mockResolvedValue(new Response(JSON.stringify(response))) + + await expect(executeDevPlatformOperation({...options, query})).resolves.toEqual({response, failed: false}) + const request = JSON.parse(vi.mocked(fetch).mock.calls[0]![1]!.body as string) + expect(request).toEqual({query}) +}) + +test('lets the server select an operation in a multi-operation document', async () => { + const query = 'query One { __typename } query Two { __schema { queryType { name } } }' + const response = {data: {__typename: 'QueryRoot'}} + vi.mocked(fetch).mockResolvedValue(new Response(JSON.stringify(response))) + + await executeDevPlatformOperation({...options, query, operationName: 'One'}) + + expect(JSON.parse(vi.mocked(fetch).mock.calls[0]![1]!.body as string)).toEqual({query, operationName: 'One'}) +}) + +test('reads the document and variables from real files', async () => { + await inTemporaryDirectory(async (directory) => { + const queryFile = joinPath(directory, 'query.graphql') + const variableFile = joinPath(directory, 'variables.json') + const query = 'query App($key: String!) { app(key: $key) { key } }\n' + await writeFile(queryFile, query) + await writeFile(variableFile, '{"key":"test-app"}') + vi.mocked(fetch).mockResolvedValue(new Response('{"data":{"app":{"key":"test-app"}}}')) + + await executeDevPlatformOperation({queryFile, variableFile, demo: false}) + + expect(JSON.parse(vi.mocked(fetch).mock.calls[0]![1]!.body as string)).toEqual({ + query, + variables: {key: 'test-app'}, + }) + }) +}) + +test('reads a document from stdin when query-file is a dash', async () => { + vi.mocked(readStdinString).mockResolvedValue(options.query) + vi.mocked(fetch).mockResolvedValue(new Response('{"data":{"__typename":"QueryRoot"}}')) + + await executeDevPlatformOperation({queryFile: '-', demo: false}) + + expect(readStdinString).toHaveBeenCalledOnce() + expect(JSON.parse(vi.mocked(fetch).mock.calls[0]![1]!.body as string)).toEqual({query: options.query}) +}) + +test('uses the temporary token only for the fixed loopback demo', async () => { + await inTemporaryDirectory(async (directory) => { + const path = joinPath(directory, 'token') + await writeFile(path, 'atkn_demo-only\n') + vi.stubEnv('APP_LOG_QUERY_DEMO_TOKEN_FILE', path) + vi.mocked(fetch).mockResolvedValue(new Response('{"data":{"__typename":"QueryRoot"}}')) + + await expect(executeDevPlatformOperation({...options, demo: true})).resolves.toEqual({ + response: {data: {__typename: 'QueryRoot'}}, + failed: false, + }) + expect(fetch).toHaveBeenCalledWith( + 'http://127.0.0.1:4387/api/unstable/graphql', + expect.objectContaining({headers: expect.objectContaining({authorization: 'Bearer atkn_demo-only'})}), + ) + expect(ensureAuthenticatedAppManagementAndBusinessPlatform).not.toHaveBeenCalled() + }) +}) + +test('requires a demo token file instead of falling back to Identity', async () => { + vi.stubEnv('APP_LOG_QUERY_DEMO_TOKEN_FILE', '') + await expect(executeDevPlatformOperation({...options, demo: true})).rejects.toThrow('APP_LOG_QUERY_DEMO_TOKEN_FILE') + expect(ensureAuthenticatedAppManagementAndBusinessPlatform).not.toHaveBeenCalled() + expect(fetch).not.toHaveBeenCalled() +}) + +test.each(['', 'not-a-token', `atkn_${'x'.repeat(4096)}`])('rejects an invalid demo token %#', async (token) => { + await inTemporaryDirectory(async (directory) => { + const path = joinPath(directory, 'token') + await writeFile(path, token) + vi.stubEnv('APP_LOG_QUERY_DEMO_TOKEN_FILE', path) + await expect(executeDevPlatformOperation({...options, demo: true})).rejects.toThrow('Invalid demo token') + expect(fetch).not.toHaveBeenCalled() + }) +}) + +test.each([200, 400])('preserves errors, paths, extensions and partial data for HTTP %s', async (status) => { + const response = { + data: {app: null}, + errors: [ + {message: 'Access denied', path: ['app'], locations: [{line: 1, column: 3}], extensions: {code: 'DENIED'}}, + ], + extensions: {requestId: 'example'}, + } + vi.mocked(fetch).mockResolvedValue(new Response(JSON.stringify(response), {status})) + + await expect(executeDevPlatformOperation(options)).resolves.toEqual({response, failed: true}) +}) + +test('preserves request errors without data', async () => { + const response = {errors: [{message: 'Unknown field'}]} + vi.mocked(fetch).mockResolvedValue(new Response(JSON.stringify(response))) + + await expect(executeDevPlatformOperation({...options, query: '{ unknownField }'})).resolves.toEqual({ + response, + failed: true, + }) +}) + +test('does not turn an HTTP failure with data into success', async () => { + const response = {data: null} + vi.mocked(fetch).mockResolvedValue(new Response(JSON.stringify(response), {status: 503})) + await expect(executeDevPlatformOperation(options)).resolves.toEqual({response, failed: true}) +}) + +test.each([null, [], {}, {data: []}, {errors: []}, {errors: [{}]}])( + 'rejects a malformed GraphQL envelope %j', + async (body) => { + vi.mocked(fetch).mockResolvedValue(new Response(JSON.stringify(body))) + await expect(executeDevPlatformOperation(options)).rejects.toThrow('invalid GraphQL response') + }, +) + +test('reports non-JSON HTTP failures without echoing their bodies', async () => { + vi.mocked(fetch).mockResolvedValue(new Response('private upstream diagnostic', {status: 502})) + await expect(executeDevPlatformOperation(options)).rejects.toThrow('invalid JSON (HTTP 502)') +}) + +test('propagates network failures without retrying', async () => { + const error = new Error('Connection refused') + vi.mocked(fetch).mockRejectedValue(error) + await expect(executeDevPlatformOperation(options)).rejects.toBe(error) + expect(fetch).toHaveBeenCalledOnce() +}) + +test('accepts responses larger than one MiB without a client byte cap', async () => { + const response = {data: {largeValue: 'x'.repeat(2 * 1024 * 1024)}} + vi.mocked(fetch).mockImplementation(async (_url, init) => { + const responseOptions = {status: 200, size: init?.size} + return new Response(JSON.stringify(response), responseOptions) + }) + + await expect(executeDevPlatformOperation(options)).resolves.toEqual({response, failed: false}) + expect(vi.mocked(fetch).mock.calls[0]![1]?.size).toBeUndefined() +}) + +test.each([ + {query: undefined}, + {queryFile: 'also.graphql'}, + {query: ''}, + {query: ' '}, + {variables: '{}', variableFile: 'also.json'}, + {variables: 'not json'}, + {variables: 'null'}, + {variables: '[]'}, + {variables: '1'}, +])('rejects invalid input %j before authentication', async (input) => { + await expect(executeDevPlatformOperation({...options, ...input})).rejects.toThrow() + expect(ensureAuthenticatedAppManagementAndBusinessPlatform).not.toHaveBeenCalled() + expect(fetch).not.toHaveBeenCalled() +}) + +test('rejects stdin without a document before authentication', async () => { + vi.mocked(readStdinString).mockResolvedValue(undefined) + await expect(executeDevPlatformOperation({queryFile: '-', demo: false})).rejects.toThrow('nonempty GraphQL') + expect(ensureAuthenticatedAppManagementAndBusinessPlatform).not.toHaveBeenCalled() +}) + +test('reports missing query files without making a request', async () => { + await inTemporaryDirectory(async (directory) => { + await expect( + executeDevPlatformOperation({queryFile: joinPath(directory, 'missing.graphql'), demo: false}), + ).rejects.toThrow() + expect(fetch).not.toHaveBeenCalled() + }) +}) diff --git a/packages/app/src/cli/services/dev/execute.ts b/packages/app/src/cli/services/dev/execute.ts new file mode 100644 index 00000000000..ee59fbe51e5 --- /dev/null +++ b/packages/app/src/cli/services/dev/execute.ts @@ -0,0 +1,102 @@ +import {appManagementHeaders} from '@shopify/cli-kit/node/api/app-management' +import {developerDashboardFqdn} from '@shopify/cli-kit/node/context/fqdn' +import {AbortError} from '@shopify/cli-kit/node/error' +import {readFile} from '@shopify/cli-kit/node/fs' +import {fetch} from '@shopify/cli-kit/node/http' +import {ensureAuthenticatedAppManagementAndBusinessPlatform} from '@shopify/cli-kit/node/session' +import {readStdinString} from '@shopify/cli-kit/node/system' +import {z} from 'zod' + +const responseSchema = z + .object({ + data: z.record(z.unknown()).nullable().optional(), + errors: z + .array(z.object({message: z.string()}).passthrough()) + .min(1) + .optional(), + extensions: z.record(z.unknown()).optional(), + }) + .passthrough() + .refine((response) => response.data !== undefined || response.errors !== undefined) + +interface ExecuteOptions { + query?: string + queryFile?: string + variables?: string + variableFile?: string + operationName?: string + demo: boolean +} + +interface ExecuteResult { + response: z.infer + failed: boolean +} + +export async function executeDevPlatformOperation(options: ExecuteOptions): Promise { + if (process.env.SHOPIFY_APP_LOG_QUERY_PROTOTYPE !== '1' || process.env.SHOPIFY_SERVICE_ENV !== 'local') { + throw new AbortError('Prototype only: set SHOPIFY_APP_LOG_QUERY_PROTOTYPE=1 and SHOPIFY_SERVICE_ENV=local.') + } + if ((options.query === undefined) === (options.queryFile === undefined)) { + throw new AbortError('Provide exactly one of --query or --query-file.') + } + if (options.variables !== undefined && options.variableFile !== undefined) { + throw new AbortError('Provide either --variables or --variable-file, not both.') + } + + const query = + options.query ?? (options.queryFile === '-' ? await readStdinString() : await readFile(options.queryFile!)) + if (!query?.trim()) throw new AbortError('Provide a nonempty GraphQL document.') + + const variableText = + options.variables ?? (options.variableFile === undefined ? undefined : await readFile(options.variableFile)) + let variables: Record | undefined + if (variableText !== undefined) { + let parsed: unknown + try { + parsed = JSON.parse(variableText) + } catch (error) { + if (!(error instanceof SyntaxError)) throw error + throw new AbortError('GraphQL variables must be a valid JSON object.') + } + const result = z.record(z.unknown()).safeParse(parsed) + if (!result.success) throw new AbortError('GraphQL variables must be a JSON object.') + variables = result.data + } + + const {origin, token} = await queryConnection(options.demo) + const response = await fetch(`${origin}/api/unstable/graphql`, { + method: 'POST', + redirect: 'error', + signal: AbortSignal.timeout(15000), + headers: appManagementHeaders(token), + body: JSON.stringify({query, variables, operationName: options.operationName}), + }) + let body: unknown + try { + body = await response.json() + } catch (error) { + if (!(error instanceof SyntaxError)) throw error + throw new AbortError(`Local Dev Platform API returned invalid JSON (HTTP ${response.status}).`) + } + const result = responseSchema.safeParse(body) + if (!result.success) { + throw new AbortError(`Local Dev Platform API returned an invalid GraphQL response (HTTP ${response.status}).`) + } + return {response: result.data, failed: !response.ok || Boolean(result.data.errors?.length)} +} + +async function queryConnection(demo: boolean): Promise<{origin: string; token: string}> { + if (demo) { + const path = process.env.APP_LOG_QUERY_DEMO_TOKEN_FILE + if (!path) throw new AbortError('Set APP_LOG_QUERY_DEMO_TOKEN_FILE to the file printed by the local demo server.') + const token = (await readFile(path)).trim() + if (!token.startsWith('atkn_') || token.length > 4096) throw new AbortError('Invalid demo token file.') + return {origin: 'http://127.0.0.1:4387', token} + } + + const host = await developerDashboardFqdn() + if (host !== 'dev.shop.dev') throw new AbortError('This prototype can only call dev.shop.dev.') + const {appManagementToken} = await ensureAuthenticatedAppManagementAndBusinessPlatform() + return {origin: `https://${host}`, token: appManagementToken} +} diff --git a/packages/cli/bin/bundle.js b/packages/cli/bin/bundle.js index 0f4854ab846..537d650a5be 100644 --- a/packages/cli/bin/bundle.js +++ b/packages/cli/bin/bundle.js @@ -55,7 +55,6 @@ const hookEntryPoints = glob.sync('./src/hooks/*.ts', { const manifest = JSON.parse(readFileSync(joinPath(process.cwd(), 'oclif.manifest.json'), 'utf8')) const commandEntryPointOverrides = { 'app:logs:sources': 'cli/commands/app/app-logs/sources', - 'app:logs:query': 'cli/commands/app/app-logs/query', 'demo:watcher': 'cli/commands/app/demo/watcher', 'kitchen-sink': 'cli/commands/kitchen-sink/index', 'doctor-release': 'cli/commands/doctor-release/doctor-release', diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index dd2db67834a..4c6380da419 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -3313,117 +3313,6 @@ "strict": true, "summary": "Stream detailed logs for your Shopify app." }, - "app:logs:query": { - "aliases": [ - ], - "args": { - }, - "customPluginName": "@shopify/app", - "enableJsonFlag": false, - "flags": { - "client-id": { - "description": "App API key.", - "env": "SHOPIFY_FLAG_CLIENT_ID", - "hasDynamicHelp": false, - "multiple": false, - "name": "client-id", - "required": true, - "type": "option" - }, - "demo": { - "allowNo": false, - "description": "Use the loopback demo with seeded auth, not Identity login.", - "env": "SHOPIFY_FLAG_DEMO", - "name": "demo", - "type": "boolean" - }, - "filter": { - "description": "Equality filter FIELD=value. Repeat to AND filters; use --list-filters for fields.", - "env": "SHOPIFY_FLAG_FILTER", - "hasDynamicHelp": false, - "multiple": true, - "name": "filter", - "type": "option" - }, - "limit": { - "default": 10, - "description": "Maximum summary events to return.", - "env": "SHOPIFY_FLAG_LIMIT", - "hasDynamicHelp": false, - "multiple": false, - "name": "limit", - "type": "option" - }, - "list-filters": { - "allowNo": false, - "description": "List filter definitions for the selected types, without searching logs.", - "env": "SHOPIFY_FLAG_LIST_FILTERS", - "exclusive": [ - "filter" - ], - "name": "list-filters", - "type": "boolean" - }, - "minutes": { - "default": 15, - "description": "Query the last N minutes.", - "env": "SHOPIFY_FLAG_MINUTES", - "hasDynamicHelp": false, - "multiple": false, - "name": "minutes", - "type": "option" - }, - "no-color": { - "allowNo": false, - "description": "Disable color output.", - "env": "SHOPIFY_FLAG_NO_COLOR", - "hidden": false, - "name": "no-color", - "type": "boolean" - }, - "offset": { - "default": 0, - "description": "Number of matching rows to skip. Results are unordered; this is not a reliable export cursor.", - "env": "SHOPIFY_FLAG_OFFSET", - "hasDynamicHelp": false, - "multiple": false, - "name": "offset", - "type": "option" - }, - "type": { - "description": "Restrict results to these event types.", - "env": "SHOPIFY_FLAG_TYPE", - "hasDynamicHelp": false, - "multiple": true, - "name": "type", - "options": [ - "WEBHOOK_DELIVERY", - "GRAPHQL_REQUEST", - "REST_REQUEST", - "FUNCTION_RUN" - ], - "type": "option" - }, - "verbose": { - "allowNo": false, - "description": "Increase the verbosity of the output. May include sensitive data.", - "env": "SHOPIFY_FLAG_VERBOSE", - "hidden": false, - "name": "verbose", - "type": "boolean" - } - }, - "hasDynamicHelp": false, - "hidden": true, - "hiddenAliases": [ - ], - "id": "app:logs:query", - "pluginAlias": "@shopify/cli", - "pluginName": "@shopify/cli", - "pluginType": "core", - "strict": true, - "summary": "Prototype only: query app log summaries through the local Dev Platform API." - }, "app:logs:sources": { "aliases": [ ], @@ -4844,6 +4733,99 @@ "strict": true, "summary": "Watch and prints out changes to an app." }, + "dev:execute": { + "aliases": [ + ], + "args": { + }, + "customPluginName": "@shopify/app", + "description": "Prints the complete GraphQL JSON response. The query selects apps, filters, and returned fields. GraphQL or HTTP errors produce a nonzero exit status, preserving partial data when available.", + "enableJsonFlag": false, + "flags": { + "demo": { + "allowNo": false, + "description": "Use the loopback demo with seeded auth, not Identity login.", + "env": "SHOPIFY_FLAG_DEMO", + "name": "demo", + "type": "boolean" + }, + "no-color": { + "allowNo": false, + "description": "Disable color output.", + "env": "SHOPIFY_FLAG_NO_COLOR", + "hidden": false, + "name": "no-color", + "type": "boolean" + }, + "operation-name": { + "description": "The operation to execute when the document contains multiple operations.", + "env": "SHOPIFY_FLAG_OPERATION_NAME", + "hasDynamicHelp": false, + "multiple": false, + "name": "operation-name", + "type": "option" + }, + "query": { + "char": "q", + "description": "The GraphQL query or mutation, as a string.", + "env": "SHOPIFY_FLAG_QUERY", + "hasDynamicHelp": false, + "multiple": false, + "name": "query", + "required": false, + "type": "option" + }, + "query-file": { + "description": "Path to a GraphQL document, or - to read from stdin.", + "env": "SHOPIFY_FLAG_QUERY_FILE", + "hasDynamicHelp": false, + "multiple": false, + "name": "query-file", + "type": "option" + }, + "variable-file": { + "description": "Path to a file containing GraphQL variables in JSON format. Can't be used with --variables.", + "env": "SHOPIFY_FLAG_VARIABLE_FILE", + "exclusive": [ + "variables" + ], + "hasDynamicHelp": false, + "multiple": false, + "name": "variable-file", + "type": "option" + }, + "variables": { + "char": "v", + "description": "The values for any GraphQL variables in your query or mutation, in JSON format.", + "env": "SHOPIFY_FLAG_VARIABLES", + "exclusive": [ + "variable-file" + ], + "hasDynamicHelp": false, + "multiple": false, + "name": "variables", + "type": "option" + }, + "verbose": { + "allowNo": false, + "description": "Increase the verbosity of the output. May include sensitive data.", + "env": "SHOPIFY_FLAG_VERBOSE", + "hidden": false, + "name": "verbose", + "type": "boolean" + } + }, + "hasDynamicHelp": false, + "hidden": true, + "hiddenAliases": [ + ], + "id": "dev:execute", + "pluginAlias": "@shopify/cli", + "pluginName": "@shopify/cli", + "pluginType": "core", + "strict": true, + "summary": "Prototype only: execute a GraphQL request against the local Dev Platform API." + }, "doc:fetch": { "aliases": [ ], diff --git a/packages/cli/src/command-registry.ts b/packages/cli/src/command-registry.ts index e60bd7293d8..2f8180831e6 100644 --- a/packages/cli/src/command-registry.ts +++ b/packages/cli/src/command-registry.ts @@ -62,7 +62,6 @@ function resolvePackageDir(packageName: string): string { const entryPointOverrides: Record = { 'app:logs:sources': 'dist/cli/commands/app/app-logs/sources.js', - 'app:logs:query': 'dist/cli/commands/app/app-logs/query.js', 'demo:watcher': 'dist/cli/commands/app/demo/watcher.js', 'kitchen-sink': 'dist/cli/commands/kitchen-sink/index.js', 'doctor-release': 'dist/cli/commands/doctor-release/doctor-release.js',