diff --git a/docs-shopify.dev/generated/generated_docs_data_v2.json b/docs-shopify.dev/generated/generated_docs_data_v2.json index 275596295d2..6ea8406148c 100644 --- a/docs-shopify.dev/generated/generated_docs_data_v2.json +++ b/docs-shopify.dev/generated/generated_docs_data_v2.json @@ -2759,15 +2759,24 @@ "value": "export interface appinit {\n /**\n * Alias of the Shopify account to use for authentication.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--auth-alias '?: string\n\n /**\n * The Client ID of your app. Use this to automatically link your new project to an existing app. In non-interactive environments, provide this flag or both --name and --organization-id.\n * @environment SHOPIFY_FLAG_CLIENT_ID\n */\n '--client-id '?: string\n\n /**\n * Which flavor of the given template to use. Required in non-interactive environments when the selected template offers multiple flavors.\n * @environment SHOPIFY_FLAG_TEMPLATE_FLAVOR\n */\n '--flavor '?: string\n\n /**\n * Print the command's JSON schemas.\n * @environment SHOPIFY_FLAG_JSON_SCHEMA\n */\n '--json-schema'?: ''\n\n /**\n * The name for the new app. When provided, skips the app selection prompt and creates a new app with this name. Required in non-interactive environments unless --client-id is provided.\n * @environment SHOPIFY_FLAG_NAME\n */\n '-n, --name '?: string\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * The organization ID. Your organization ID can be found in your Dev Dashboard URL: https://dev.shopify.com/dashboard/. Required in non-interactive environments unless --client-id is provided.\n * @environment SHOPIFY_FLAG_ORGANIZATION_ID\n */\n '--organization-id '?: string\n\n /**\n * \n * @environment SHOPIFY_FLAG_PACKAGE_MANAGER\n */\n '-d, --package-manager '?: string\n\n /**\n * \n * @environment SHOPIFY_FLAG_PATH\n */\n '-p, --path '?: string\n\n /**\n * The app template. Accepts one of the following:\n - \n - Any GitHub repo with optional branch and subpath, e.g., https://github.com/Shopify//[subpath]#[branch]. Required if non interactive.\n * @environment SHOPIFY_FLAG_TEMPLATE\n */\n '--template '?: string\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" } }, - "applogssources": { - "docs-shopify.dev/commands/interfaces/app-logs-sources.interface.ts": { - "filePath": "docs-shopify.dev/commands/interfaces/app-logs-sources.interface.ts", - "name": "applogssources", - "description": "The following flags are available for the `app logs sources` command:", + "applogsschema": { + "docs-shopify.dev/commands/interfaces/app-logs-schema.interface.ts": { + "filePath": "docs-shopify.dev/commands/interfaces/app-logs-schema.interface.ts", + "name": "applogsschema", + "description": "The following flags are available for the `app logs schema` command:", "isPublicDocs": true, "members": [ { - "filePath": "docs-shopify.dev/commands/interfaces/app-logs-sources.interface.ts", + "filePath": "docs-shopify.dev/commands/interfaces/app-logs-schema.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--app ", + "value": "string", + "description": "Alias for --client-id.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_APP" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-logs-schema.interface.ts", "syntaxKind": "PropertySignature", "name": "--auth-alias ", "value": "string", @@ -2776,16 +2785,16 @@ "environmentValue": "SHOPIFY_FLAG_AUTH_ALIAS" }, { - "filePath": "docs-shopify.dev/commands/interfaces/app-logs-sources.interface.ts", + "filePath": "docs-shopify.dev/commands/interfaces/app-logs-schema.interface.ts", "syntaxKind": "PropertySignature", "name": "--client-id ", "value": "string", - "description": "The Client ID of your app.", + "description": "The Client ID of your app. Defaults to the current app configuration.", "isOptional": true, "environmentValue": "SHOPIFY_FLAG_CLIENT_ID" }, { - "filePath": "docs-shopify.dev/commands/interfaces/app-logs-sources.interface.ts", + "filePath": "docs-shopify.dev/commands/interfaces/app-logs-schema.interface.ts", "syntaxKind": "PropertySignature", "name": "--json-schema", "value": "''", @@ -2794,7 +2803,7 @@ "environmentValue": "SHOPIFY_FLAG_JSON_SCHEMA" }, { - "filePath": "docs-shopify.dev/commands/interfaces/app-logs-sources.interface.ts", + "filePath": "docs-shopify.dev/commands/interfaces/app-logs-schema.interface.ts", "syntaxKind": "PropertySignature", "name": "--no-color", "value": "''", @@ -2803,25 +2812,25 @@ "environmentValue": "SHOPIFY_FLAG_NO_COLOR" }, { - "filePath": "docs-shopify.dev/commands/interfaces/app-logs-sources.interface.ts", + "filePath": "docs-shopify.dev/commands/interfaces/app-logs-schema.interface.ts", "syntaxKind": "PropertySignature", - "name": "--path ", - "value": "string", - "description": "The path to your app directory.", + "name": "--no-prompt", + "value": "''", + "description": "Fail instead of prompting when no valid account session is available.", "isOptional": true, - "environmentValue": "SHOPIFY_FLAG_PATH" + "environmentValue": "SHOPIFY_FLAG_NO_PROMPT" }, { - "filePath": "docs-shopify.dev/commands/interfaces/app-logs-sources.interface.ts", + "filePath": "docs-shopify.dev/commands/interfaces/app-logs-schema.interface.ts", "syntaxKind": "PropertySignature", - "name": "--reset", - "value": "''", - "description": "Reset all your settings.", + "name": "--path ", + "value": "string", + "description": "The path to your app directory.", "isOptional": true, - "environmentValue": "SHOPIFY_FLAG_RESET" + "environmentValue": "SHOPIFY_FLAG_PATH" }, { - "filePath": "docs-shopify.dev/commands/interfaces/app-logs-sources.interface.ts", + "filePath": "docs-shopify.dev/commands/interfaces/app-logs-schema.interface.ts", "syntaxKind": "PropertySignature", "name": "--verbose", "value": "''", @@ -2830,7 +2839,7 @@ "environmentValue": "SHOPIFY_FLAG_VERBOSE" }, { - "filePath": "docs-shopify.dev/commands/interfaces/app-logs-sources.interface.ts", + "filePath": "docs-shopify.dev/commands/interfaces/app-logs-schema.interface.ts", "syntaxKind": "PropertySignature", "name": "-c, --config ", "value": "string", @@ -2839,7 +2848,7 @@ "environmentValue": "SHOPIFY_FLAG_APP_CONFIG" }, { - "filePath": "docs-shopify.dev/commands/interfaces/app-logs-sources.interface.ts", + "filePath": "docs-shopify.dev/commands/interfaces/app-logs-schema.interface.ts", "syntaxKind": "PropertySignature", "name": "-j, --json", "value": "''", @@ -2848,7 +2857,7 @@ "environmentValue": "SHOPIFY_FLAG_JSON" } ], - "value": "export interface applogssources {\n /**\n * Alias of the Shopify account to use for authentication.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--auth-alias '?: string\n\n /**\n * The Client ID of your app.\n * @environment SHOPIFY_FLAG_CLIENT_ID\n */\n '--client-id '?: string\n\n /**\n * The name of the app configuration.\n * @environment SHOPIFY_FLAG_APP_CONFIG\n */\n '-c, --config '?: string\n\n /**\n * Output the result as JSON. Automatically disables color output.\n * @environment SHOPIFY_FLAG_JSON\n */\n '-j, --json'?: ''\n\n /**\n * Print the command's JSON schemas.\n * @environment SHOPIFY_FLAG_JSON_SCHEMA\n */\n '--json-schema'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * The path to your app directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path '?: string\n\n /**\n * Reset all your settings.\n * @environment SHOPIFY_FLAG_RESET\n */\n '--reset'?: ''\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" + "value": "export interface applogsschema {\n /**\n * Alias for --client-id.\n * @environment SHOPIFY_FLAG_APP\n */\n '--app '?: string\n\n /**\n * Alias of the Shopify account to use for authentication.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--auth-alias '?: string\n\n /**\n * The Client ID of your app. Defaults to the current app configuration.\n * @environment SHOPIFY_FLAG_CLIENT_ID\n */\n '--client-id '?: string\n\n /**\n * The name of the app configuration.\n * @environment SHOPIFY_FLAG_APP_CONFIG\n */\n '-c, --config '?: string\n\n /**\n * Output the result as JSON. Automatically disables color output.\n * @environment SHOPIFY_FLAG_JSON\n */\n '-j, --json'?: ''\n\n /**\n * Print the command's JSON schemas.\n * @environment SHOPIFY_FLAG_JSON_SCHEMA\n */\n '--json-schema'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Fail instead of prompting when no valid account session is available.\n * @environment SHOPIFY_FLAG_NO_PROMPT\n */\n '--no-prompt'?: ''\n\n /**\n * The path to your app directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path '?: string\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" } }, "applogs": { @@ -2867,15 +2876,6 @@ "isOptional": true, "environmentValue": "SHOPIFY_FLAG_AUTH_ALIAS" }, - { - "filePath": "docs-shopify.dev/commands/interfaces/app-logs.interface.ts", - "syntaxKind": "PropertySignature", - "name": "--client-id ", - "value": "string", - "description": "The Client ID of your app.", - "isOptional": true, - "environmentValue": "SHOPIFY_FLAG_CLIENT_ID" - }, { "filePath": "docs-shopify.dev/commands/interfaces/app-logs.interface.ts", "syntaxKind": "PropertySignature", @@ -2897,38 +2897,38 @@ { "filePath": "docs-shopify.dev/commands/interfaces/app-logs.interface.ts", "syntaxKind": "PropertySignature", - "name": "--path ", - "value": "string", - "description": "The path to your app directory.", + "name": "--no-prompt", + "value": "''", + "description": "Fail instead of prompting when no valid account session is available.", "isOptional": true, - "environmentValue": "SHOPIFY_FLAG_PATH" + "environmentValue": "SHOPIFY_FLAG_NO_PROMPT" }, { "filePath": "docs-shopify.dev/commands/interfaces/app-logs.interface.ts", "syntaxKind": "PropertySignature", - "name": "--reset", - "value": "''", - "description": "Reset all your settings.", + "name": "--operation-name ", + "value": "string", + "description": "The operation to execute when the document contains multiple operations.", "isOptional": true, - "environmentValue": "SHOPIFY_FLAG_RESET" + "environmentValue": "SHOPIFY_FLAG_OPERATION_NAME" }, { "filePath": "docs-shopify.dev/commands/interfaces/app-logs.interface.ts", "syntaxKind": "PropertySignature", - "name": "--source ", + "name": "--query-file ", "value": "string", - "description": "Filters output to the specified log source.", + "description": "Path to a GraphQL document, or - to read from stdin.", "isOptional": true, - "environmentValue": "SHOPIFY_FLAG_SOURCE" + "environmentValue": "SHOPIFY_FLAG_QUERY_FILE" }, { "filePath": "docs-shopify.dev/commands/interfaces/app-logs.interface.ts", "syntaxKind": "PropertySignature", - "name": "--status ", + "name": "--variable-file ", "value": "string", - "description": "Filters output to the specified status (success or failure).", + "description": "Path to a JSON file containing GraphQL variables.", "isOptional": true, - "environmentValue": "SHOPIFY_FLAG_STATUS" + "environmentValue": "SHOPIFY_FLAG_VARIABLE_FILE" }, { "filePath": "docs-shopify.dev/commands/interfaces/app-logs.interface.ts", @@ -2942,32 +2942,32 @@ { "filePath": "docs-shopify.dev/commands/interfaces/app-logs.interface.ts", "syntaxKind": "PropertySignature", - "name": "-c, --config ", - "value": "string", - "description": "The name of the app configuration.", + "name": "-j, --json", + "value": "''", + "description": "Output the result as JSON. Automatically disables color output.", "isOptional": true, - "environmentValue": "SHOPIFY_FLAG_APP_CONFIG" + "environmentValue": "SHOPIFY_FLAG_JSON" }, { "filePath": "docs-shopify.dev/commands/interfaces/app-logs.interface.ts", "syntaxKind": "PropertySignature", - "name": "-j, --json", - "value": "''", - "description": "Output the result as JSON. Automatically disables color output.", + "name": "-q, --query ", + "value": "string", + "description": "The GraphQL query, as a string.", "isOptional": true, - "environmentValue": "SHOPIFY_FLAG_JSON" + "environmentValue": "SHOPIFY_FLAG_QUERY" }, { "filePath": "docs-shopify.dev/commands/interfaces/app-logs.interface.ts", "syntaxKind": "PropertySignature", - "name": "-s, --store ", + "name": "-v, --variables ", "value": "string", - "description": "Store URL. Must be an existing development or Shopify Plus sandbox store.", + "description": "GraphQL variables as a JSON object.", "isOptional": true, - "environmentValue": "SHOPIFY_FLAG_STORE" + "environmentValue": "SHOPIFY_FLAG_VARIABLES" } ], - "value": "export interface applogs {\n /**\n * Alias of the Shopify account to use for authentication.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--auth-alias '?: string\n\n /**\n * The Client ID of your app.\n * @environment SHOPIFY_FLAG_CLIENT_ID\n */\n '--client-id '?: string\n\n /**\n * The name of the app configuration.\n * @environment SHOPIFY_FLAG_APP_CONFIG\n */\n '-c, --config '?: string\n\n /**\n * Output the result as JSON. Automatically disables color output.\n * @environment SHOPIFY_FLAG_JSON\n */\n '-j, --json'?: ''\n\n /**\n * Print the command's JSON schemas.\n * @environment SHOPIFY_FLAG_JSON_SCHEMA\n */\n '--json-schema'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * The path to your app directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path '?: string\n\n /**\n * Reset all your settings.\n * @environment SHOPIFY_FLAG_RESET\n */\n '--reset'?: ''\n\n /**\n * Filters output to the specified log source.\n * @environment SHOPIFY_FLAG_SOURCE\n */\n '--source '?: string\n\n /**\n * Filters output to the specified status (success or failure).\n * @environment SHOPIFY_FLAG_STATUS\n */\n '--status '?: string\n\n /**\n * Store URL. Must be an existing development or Shopify Plus sandbox store.\n * @environment SHOPIFY_FLAG_STORE\n */\n '-s, --store '?: string\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" + "value": "export interface applogs {\n /**\n * Alias of the Shopify account to use for authentication.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--auth-alias '?: string\n\n /**\n * Output the result as JSON. Automatically disables color output.\n * @environment SHOPIFY_FLAG_JSON\n */\n '-j, --json'?: ''\n\n /**\n * Print the command's JSON schemas.\n * @environment SHOPIFY_FLAG_JSON_SCHEMA\n */\n '--json-schema'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Fail instead of prompting when no valid account session is available.\n * @environment SHOPIFY_FLAG_NO_PROMPT\n */\n '--no-prompt'?: ''\n\n /**\n * The operation to execute when the document contains multiple operations.\n * @environment SHOPIFY_FLAG_OPERATION_NAME\n */\n '--operation-name '?: string\n\n /**\n * The GraphQL query, as a string.\n * @environment SHOPIFY_FLAG_QUERY\n */\n '-q, --query '?: string\n\n /**\n * Path to a GraphQL document, or - to read from stdin.\n * @environment SHOPIFY_FLAG_QUERY_FILE\n */\n '--query-file '?: string\n\n /**\n * Path to a JSON file containing GraphQL variables.\n * @environment SHOPIFY_FLAG_VARIABLE_FILE\n */\n '--variable-file '?: string\n\n /**\n * GraphQL variables as a JSON object.\n * @environment SHOPIFY_FLAG_VARIABLES\n */\n '-v, --variables '?: string\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" } }, "apprelease": { diff --git a/packages/app/package.json b/packages/app/package.json index 0d6e9d9d7ba..ffc1b930a3f 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -69,6 +69,7 @@ "csv-parse": "7.0.2", "diff": "5.2.2", "esbuild": "0.28.1", + "graphql": "16.14.2", "graphql-request": "6.1.0", "h3": "1.15.11", "http-proxy-node16": "1.0.6", diff --git a/packages/app/src/cli/commands/app/app-logs/schema.test.ts b/packages/app/src/cli/commands/app/app-logs/schema.test.ts new file mode 100644 index 00000000000..4ee1d087f62 --- /dev/null +++ b/packages/app/src/cli/commands/app/app-logs/schema.test.ts @@ -0,0 +1,55 @@ +import LogsSchema from './schema.js' +import {fetchLogsSchema} from '../../../services/logs-schema.js' +import {outputResult} from '@shopify/cli-kit/node/output' +import {afterEach, expect, test, vi} from 'vitest' +import {Parser} from '@oclif/core' + +vi.mock('../../../services/logs-schema.js') +vi.mock('@shopify/cli-kit/node/output', async (importOriginal) => ({ + ...(await importOriginal()), + outputResult: vi.fn(), +})) + +const originalExitCode = process.exitCode +afterEach(() => { + process.exitCode = originalExitCode +}) + +test('prints SDL for the selected app without requiring a query', async () => { + vi.mocked(fetchLogsSchema).mockResolvedValue({output: 'type Query { ping: String }', failed: false}) + await LogsSchema.run(['--client-id', 'test-app'], import.meta.url) + expect(fetchLogsSchema).toHaveBeenCalledExactlyOnceWith({ + appKey: 'test-app', + noPrompt: false, + demo: false, + json: false, + }) + expect(outputResult).toHaveBeenCalledExactlyOnceWith('type Query { ping: String }') +}) + +test('forwards app, JSON and authentication options', async () => { + vi.mocked(fetchLogsSchema).mockResolvedValue({output: '{"data":{}}', failed: false}) + await LogsSchema.run(['--client-id', 'test-app', '--json', '--demo', '--no-prompt'], import.meta.url) + expect(fetchLogsSchema).toHaveBeenCalledExactlyOnceWith({ + appKey: 'test-app', + noPrompt: true, + demo: true, + json: true, + }) + expect(outputResult).toHaveBeenCalledExactlyOnceWith('{"data":{}}') +}) + +test('prints API errors with a failing exit status', async () => { + vi.mocked(fetchLogsSchema).mockResolvedValue({output: '{"errors":[{"message":"Denied"}]}', failed: true}) + await LogsSchema.run(['--client-id', 'test-app'], import.meta.url) + expect(outputResult).toHaveBeenCalledExactlyOnceWith('{"errors":[{"message":"Denied"}]}') + expect(process.exitCode).toBe(1) +}) + +test.each([ + {args: ['--api', 'unknown']}, + {args: ['--query', '{ __typename }']}, + {args: ['--variables', '{}', '--variable-file', 'variables.json']}, +])('rejects unsupported or conflicting flags: %j', async ({args}) => { + await expect(Parser.parse(args, {flags: LogsSchema.flags})).rejects.toThrow() +}) diff --git a/packages/app/src/cli/commands/app/app-logs/schema.ts b/packages/app/src/cli/commands/app/app-logs/schema.ts new file mode 100644 index 00000000000..78c88e4bda2 --- /dev/null +++ b/packages/app/src/cli/commands/app/app-logs/schema.ts @@ -0,0 +1,51 @@ +import {fetchLogsSchema} from '../../../services/logs-schema.js' +import {logsAccountFlags, logsScopeFlags} from '../../../services/logs-flags.js' +import {resolveLogsApp} from '../../../services/logs-app.js' +import {logsJsonOutputSchema} from '../../../services/logs-query.js' +import BaseCommand from '@shopify/cli-kit/node/base-command' +import {authAliasFlag, globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli' +import {outputResult} from '@shopify/cli-kit/node/output' + +export default class LogsSchema extends BaseCommand { + static baseFlags = {...BaseCommand.baseFlags, ...authAliasFlag} + + static summary = 'Fetch the app logs GraphQL schema.' + + static descriptionWithMarkdown = `Fetches the live GraphQL schema for your app. Uses the current app configuration unless you pass \`--client-id\`. Prints GraphQL SDL with descriptions, field arguments, defaults, enums, and deprecations. Save it to a file and reuse it while composing queries. + +Use \`--json\` for the full introspection JSON response. HTTP or GraphQL errors print the JSON response instead of SDL and produce a nonzero exit status.` + + static usage = 'app logs schema [flags]' + + static description = this.descriptionForHelp() + + static examples = [ + '<%= config.bin %> <%= command.id %> > app-logs.graphql', + '<%= config.bin %> <%= command.id %> --client-id APP_CLIENT_ID --json', + ] + + static flags = {...globalFlags, ...jsonFlag, ...logsAccountFlags, ...logsScopeFlags} + + static get jsonOutputSchema() { + return logsJsonOutputSchema + } + + async run(): Promise { + const {flags} = await this.parse(LogsSchema) + const appKey = await resolveLogsApp({ + clientId: flags['client-id'] ?? flags.app, + path: flags.path, + config: flags.config, + noPrompt: flags['no-prompt'], + demo: flags.demo, + }) + const {output, failed} = await fetchLogsSchema({ + appKey, + noPrompt: flags['no-prompt'], + demo: flags.demo, + json: flags.json, + }) + outputResult(output) + if (failed) process.exitCode = 1 + } +} diff --git a/packages/app/src/cli/commands/app/logs.test.ts b/packages/app/src/cli/commands/app/logs.test.ts new file mode 100644 index 00000000000..53d96589508 --- /dev/null +++ b/packages/app/src/cli/commands/app/logs.test.ts @@ -0,0 +1,130 @@ +import Logs from './logs.js' +import {executeLogsQuery, logsJsonOutputSchema} from '../../services/logs-query.js' +import {outputResult} from '@shopify/cli-kit/node/output' +import {cwd, joinPath} from '@shopify/cli-kit/node/path' +import {afterEach, beforeEach, expect, test, vi} from 'vitest' +import {Parser} from '@oclif/core' + +vi.mock('../../services/logs-query.js', async (importOriginal) => ({ + ...(await importOriginal()), + executeLogsQuery: vi.fn(), +})) +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.each([{flags: []}, {flags: ['--json']}])('prints the full JSON response with flags %j', async ({flags}) => { + const query = '{ __schema { queryType { name } } }' + const response = {data: {__schema: {queryType: {name: 'QueryRoot'}}}, extensions: {requestId: 'example'}} + vi.mocked(executeLogsQuery).mockResolvedValue({response, failed: false}) + + await Logs.run(['--query', query, ...flags], import.meta.url) + + expect(executeLogsQuery).toHaveBeenCalledExactlyOnceWith({ + query, + queryFile: undefined, + variables: undefined, + variableFile: undefined, + operationName: undefined, + noPrompt: false, + demo: false, + }) + expect(outputResult).toHaveBeenCalledExactlyOnceWith(JSON.stringify(response, null, 2)) + expect(process.exitCode).toBeUndefined() +}) + +test('forwards stdin, variables, operation name and local demo selection', async () => { + vi.mocked(executeLogsQuery).mockResolvedValue({response: {data: {app: null}}, failed: false}) + + await Logs.run( + ['--query-file', '-', '--variables', '{"key":"test-app"}', '--operation-name', 'Logs', '--demo', '--no-prompt'], + import.meta.url, + ) + + expect(executeLogsQuery).toHaveBeenCalledExactlyOnceWith({ + query: undefined, + queryFile: '-', + variables: '{"key":"test-app"}', + variableFile: undefined, + operationName: 'Logs', + noPrompt: true, + demo: true, + }) +}) + +test('forwards file paths', async () => { + vi.mocked(executeLogsQuery).mockResolvedValue({response: {data: null}, failed: false}) + + await Logs.run(['--query-file', 'logs.graphql', '--variable-file', 'variables.json'], import.meta.url) + + expect(executeLogsQuery).toHaveBeenCalledWith( + expect.objectContaining({queryFile: 'logs.graphql', variableFile: joinPath(cwd(), 'variables.json')}), + ) +}) + +test('preserves partial data and errors while setting a failing exit status', async () => { + const response = {data: {app: null}, errors: [{message: 'Access denied', path: ['app']}]} + vi.mocked(executeLogsQuery).mockResolvedValue({response, failed: true}) + + await Logs.run(['--query', '{ app(key: "test-app") { key } }'], import.meta.url) + + expect(outputResult).toHaveBeenCalledExactlyOnceWith(JSON.stringify(response, null, 2)) + expect(process.exitCode).toBe(1) +}) + +test('defines JSON output and account selection', async () => { + expect(Logs.jsonOutputSchema).toBe(logsJsonOutputSchema) + const parsed = await Parser.parse(['--auth-alias', 'demo', '--query', '{ __typename }'], { + flags: {...Logs.baseFlags, ...Logs.flags}, + }) + expect(parsed.flags['auth-alias']).toBe('demo') +}) + +test.each([ + {args: ['--api', 'unknown', '--query', '{ __typename }']}, + {args: ['--query', '{ __typename }', '--query-file', 'query.graphql']}, + {args: ['--query', '{ __typename }', '--variables', '{}', '--variable-file', 'variables.json']}, +])('rejects unsupported or conflicting flags %j', async ({args}) => { + await expect(Parser.parse(args, {flags: Logs.flags})).rejects.toThrow() +}) + +test.each([ + '--client-id', + '--app', + '--path', + '--config', + '--type', + '--since', + '--until', + '--limit', + '--shop', + '--status-code', + '--sort', + '--offset', +])('rejects the removed search flag %s', async (flag) => { + await expect(Parser.parse(['--query', '{ __typename }', flag, 'unused'], {flags: Logs.flags})).rejects.toThrow( + `Nonexistent flag: ${flag}`, + ) +}) + +test('requires an explicit GraphQL document', async () => { + await expect(Parser.parse([], {flags: Logs.flags})).rejects.toThrow('Exactly one of the following must be provided') +}) + +test.each(['--variables', '--variable-file', '--operation-name'])( + 'rejects %s without a GraphQL document', + async (flag) => { + await expect(Parser.parse([flag, 'unused'], {flags: Logs.flags})).rejects.toThrow() + }, +) diff --git a/packages/app/src/cli/commands/app/logs.ts b/packages/app/src/cli/commands/app/logs.ts index 54539ec1275..2f5d9b363c2 100644 --- a/packages/app/src/cli/commands/app/logs.ts +++ b/packages/app/src/cli/commands/app/logs.ts @@ -1,78 +1,81 @@ -import {checkFolderIsValidApp} from '../../models/app/loader.js' -import {logs, Format} from '../../services/logs.js' -import {appFlags} from '../../flags.js' -import AppLinkedCommand, {AppLinkedCommandOutput} from '../../utilities/app-linked-command.js' -import {linkedAppContext} from '../../services/app-context.js' -import {storeContext} from '../../services/store-context.js' +import {executeLogsQuery, logsJsonOutputSchema} from '../../services/logs-query.js' +import {logsAccountFlags} from '../../services/logs-flags.js' +import BaseCommand from '@shopify/cli-kit/node/base-command' +import {authAliasFlag, globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli' +import {resolvePath} from '@shopify/cli-kit/node/path' +import {outputResult} from '@shopify/cli-kit/node/output' import {Flags} from '@oclif/core' -import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' -import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli' -export default class Logs extends AppLinkedCommand { - static summary = 'Stream detailed logs for your Shopify app.' +export default class Logs extends BaseCommand { + static baseFlags = {...BaseCommand.baseFlags, ...authAliasFlag} - static descriptionWithMarkdown = ` - Opens a real-time stream of detailed app logs from the selected app and store. - Use the \`--source\` argument to limit output to a particular log source, such as a specific Shopify Function handle. Use the \`shopify app logs sources\` command to view a list of sources. - Use the \`--status\` argument to filter on status, either \`success\` or \`failure\`. - \`\`\` - shopify app logs --status=success --source=extension.discount-function - \`\`\` - ` + static summary = 'Query historical app logs with GraphQL.' + + static usage = 'app logs (--query | --query-file ) [flags]' + + static descriptionWithMarkdown = `Runs a GraphQL query against app logs. Pass the document with \`--query\`, load it with \`--query-file\`, or use \`--query-file -\` to read from stdin. Supply JSON variables with \`--variables\` or \`--variable-file\`. + +Specify the app, time range, filters, and selected fields in the GraphQL document or variables. Run \`shopify app logs schema\` to fetch the live GraphQL schema. + +Prints the full query response as JSON, including GraphQL errors, partial data, and query metadata. Use \`--json\` to also format CLI errors as JSON. HTTP or GraphQL errors produce a nonzero exit status. This command retrieves historical logs; it doesn’t stream.` static description = this.descriptionForHelp() + static examples = [ + '<%= config.bin %> <%= command.id %> --query \'{ app(key: "APP_CLIENT_ID") { key } }\'', + '<%= config.bin %> <%= command.id %> --query-file ./logs.graphql --variable-file ./variables.json', + '<%= config.bin %> <%= command.id %> --query-file - --operation-name Logs', + ] + static flags = { ...globalFlags, - ...appFlags, ...jsonFlag, - store: Flags.string({ - char: 's', - description: 'Store URL. Must be an existing development or Shopify Plus sandbox store.', - env: 'SHOPIFY_FLAG_STORE', - multiple: true, - parse: async (input) => normalizeStoreFqdn(input), + ...logsAccountFlags, + query: Flags.string({ + char: 'q', + description: 'The GraphQL query, as a string.', + env: 'SHOPIFY_FLAG_QUERY', + exactlyOne: ['query', 'query-file'], + }), + 'query-file': Flags.string({ + description: 'Path to a GraphQL document, or - to read from stdin.', + env: 'SHOPIFY_FLAG_QUERY_FILE', + exactlyOne: ['query', 'query-file'], }), - source: Flags.string({ - description: 'Filters output to the specified log source.', - env: 'SHOPIFY_FLAG_SOURCE', - multiple: true, + variables: Flags.string({ + char: 'v', + description: 'GraphQL variables as a JSON object.', + exclusive: ['variable-file'], + env: 'SHOPIFY_FLAG_VARIABLES', }), - status: Flags.string({ - description: 'Filters output to the specified status (success or failure).', - options: ['success', 'failure'], - env: 'SHOPIFY_FLAG_STATUS', + 'variable-file': Flags.string({ + description: 'Path to a JSON file containing GraphQL variables.', + exclusive: ['variables'], + parse: async (input) => resolvePath(input), + env: 'SHOPIFY_FLAG_VARIABLE_FILE', + }), + 'operation-name': Flags.string({ + description: 'The operation to execute when the document contains multiple operations.', + env: 'SHOPIFY_FLAG_OPERATION_NAME', }), } - public async run(): Promise { - const {flags} = await this.parse(Logs) - - await checkFolderIsValidApp(flags.path) - - const appContextResult = await linkedAppContext({ - directory: flags.path, - clientId: flags['client-id'], - forceRelink: flags.reset, - userProvidedConfigName: flags.config, - }) + static get jsonOutputSchema() { + return logsJsonOutputSchema + } - const primaryStore = await storeContext({ - appContextResult, - storeFqdn: flags.store?.[0], - forceReselectStore: flags.reset, + async run(): Promise { + const {flags} = await this.parse(Logs) + const {response, failed} = await executeLogsQuery({ + query: flags.query, + queryFile: flags['query-file'], + variables: flags.variables, + variableFile: flags['variable-file'], + operationName: flags['operation-name'], + noPrompt: flags['no-prompt'], + demo: flags.demo, }) - - const logOptions = { - ...appContextResult, - primaryStore, - storeFqdns: flags.store, - sources: flags.source, - status: flags.status, - format: (flags.json ? 'json' : 'text') as Format, - } - - await logs(logOptions) - return {app: appContextResult.app} + outputResult(logsJsonOutputSchema.encode(response)) + if (failed) process.exitCode = 1 } } diff --git a/packages/app/src/cli/index.ts b/packages/app/src/cli/index.ts index 6d31415a599..62c3fc707cb 100644 --- a/packages/app/src/cli/index.ts +++ b/packages/app/src/cli/index.ts @@ -11,7 +11,7 @@ import SecurityCheck from './commands/app/security/check.js' import SecurityInstructions from './commands/app/security/instructions.js' import SecuritySubmit from './commands/app/security/submit.js' import Logs from './commands/app/logs.js' -import Sources from './commands/app/app-logs/sources.js' +import LogsSchema from './commands/app/app-logs/schema.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:security:instructions': SecurityInstructions, 'app:security:submit': SecuritySubmit, 'app:logs': Logs, - 'app:logs:sources': Sources, + 'app:logs:schema': LogsSchema, 'app:import:custom-data-definitions': ImportCustomDataDefinitions, 'app:import:dashboard-extensions': ImportDashboardExtensions, // Deprecated paths, kept registered (and hidden) so existing scripts keep working. diff --git a/packages/app/src/cli/services/logs-app.test.ts b/packages/app/src/cli/services/logs-app.test.ts new file mode 100644 index 00000000000..65e2d7505d1 --- /dev/null +++ b/packages/app/src/cli/services/logs-app.test.ts @@ -0,0 +1,36 @@ +import {resolveLogsApp} from './logs-app.js' +import {inTemporaryDirectory, writeFile} from '@shopify/cli-kit/node/fs' +import {joinPath} from '@shopify/cli-kit/node/path' +import {expect, test, vi} from 'vitest' + +vi.mock('./local-storage.js') + +const account = {noPrompt: true, demo: false} + +test('explicit client ID works outside an app project', async () => { + await expect(resolveLogsApp({...account, clientId: 'selected-app', path: '/missing-project'})).resolves.toBe( + 'selected-app', + ) +}) + +test.each([undefined, 'staging'])('reads client ID from the selected TOML config (%s)', async (config) => { + await inTemporaryDirectory(async (path) => { + await writeFile(joinPath(path, 'shopify.app.toml'), 'client_id = "default-app"') + await writeFile(joinPath(path, 'shopify.app.staging.toml'), 'client_id = "staging-app"') + await expect(resolveLogsApp({...account, path, config})).resolves.toBe(config ? 'staging-app' : 'default-app') + }) +}) + +test('unlinked config reports how to select an app', async () => { + await inTemporaryDirectory(async (path) => { + await writeFile(joinPath(path, 'shopify.app.toml'), 'name = "Unlinked"') + await expect(resolveLogsApp({...account, path})).rejects.toThrow('Set client_id') + }) +}) + +test('malformed config fails visibly', async () => { + await inTemporaryDirectory(async (path) => { + await writeFile(joinPath(path, 'shopify.app.toml'), 'client_id = [') + await expect(resolveLogsApp({...account, path})).rejects.toThrow() + }) +}) diff --git a/packages/app/src/cli/services/logs-app.ts b/packages/app/src/cli/services/logs-app.ts new file mode 100644 index 00000000000..9730e5ba126 --- /dev/null +++ b/packages/app/src/cli/services/logs-app.ts @@ -0,0 +1,28 @@ +import {type LogsQueryOptions} from './logs-query.js' +import {getAppConfigurationContext} from '../models/app/loader.js' +import {AbortError} from '@shopify/cli-kit/node/error' +import {cwd} from '@shopify/cli-kit/node/path' + +export interface LogsScopeOptions extends Pick { + clientId?: string + path?: string + config?: string +} + +export async function resolveLogsApp(options: LogsScopeOptions): Promise { + if (options.clientId !== undefined) { + if (!options.clientId.trim()) throw new AbortError('Provide a nonempty --client-id.') + return options.clientId + } + const {activeConfig} = await getAppConfigurationContext(options.path ?? cwd(), options.config, { + skipPrompts: options.noPrompt, + }) + if (activeConfig.file.errors.length > 0) { + throw new AbortError(activeConfig.file.errors.map((error) => error.message).join('\n')) + } + const clientId = activeConfig.file.content.client_id + if (typeof clientId !== 'string' || !clientId.trim()) { + throw new AbortError('Set client_id in your app configuration or pass --client-id.') + } + return clientId +} diff --git a/packages/app/src/cli/services/logs-flags.ts b/packages/app/src/cli/services/logs-flags.ts new file mode 100644 index 00000000000..8b8c3d55f40 --- /dev/null +++ b/packages/app/src/cli/services/logs-flags.ts @@ -0,0 +1,31 @@ +import {appFlags} from '../flags.js' +import {Flags} from '@oclif/core' + +export const logsAccountFlags = { + 'no-prompt': Flags.boolean({ + description: 'Fail instead of prompting when no valid account session is available.', + env: 'SHOPIFY_FLAG_NO_PROMPT', + default: false, + }), + demo: Flags.boolean({ + env: 'SHOPIFY_FLAG_DEMO', + hidden: true, + default: false, + description: 'Use the loopback development server with local account authentication.', + }), +} + +export const logsScopeFlags = { + path: Flags.string({description: 'The path to your app directory.', env: 'SHOPIFY_FLAG_PATH'}), + config: appFlags.config, + 'client-id': Flags.string({ + description: 'The Client ID of your app. Defaults to the current app configuration.', + env: 'SHOPIFY_FLAG_CLIENT_ID', + exclusive: ['app', 'config', 'path'], + }), + app: Flags.string({ + env: 'SHOPIFY_FLAG_APP', + description: 'Alias for --client-id.', + exclusive: ['client-id', 'config', 'path'], + }), +} diff --git a/packages/app/src/cli/services/logs-query.test.ts b/packages/app/src/cli/services/logs-query.test.ts new file mode 100644 index 00000000000..a1521dce748 --- /dev/null +++ b/packages/app/src/cli/services/logs-query.test.ts @@ -0,0 +1,228 @@ +import {executeLogsQuery} from './logs-query.js' +import {appManagementFqdn} 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 }', noPrompt: false, demo: false} + +beforeEach(() => { + vi.stubEnv('SHOPIFY_APP_LOG_QUERY_PROTOTYPE', '1') + vi.stubEnv('SHOPIFY_SERVICE_ENV', 'local') + vi.stubEnv('SHOPIFY_APP_AUTOMATION_TOKEN', undefined) + vi.stubEnv('SHOPIFY_CLI_PARTNERS_TOKEN', undefined) + vi.mocked(appManagementFqdn).mockResolvedValue('app.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(executeLogsQuery(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(executeLogsQuery(options)).rejects.toThrow('only call app.shop.dev') + expect(ensureAuthenticatedAppManagementAndBusinessPlatform).not.toHaveBeenCalled() + expect(fetch).not.toHaveBeenCalled() +}) + +test.each(['SHOPIFY_APP_AUTOMATION_TOKEN', 'SHOPIFY_CLI_PARTNERS_TOKEN'])( + 'rejects %s instead of treating an automation token as an account session', + async (name) => { + vi.stubEnv(name, 'automation-token') + await expect(executeLogsQuery(options)).rejects.toThrow('requires a signed-in Shopify account') + expect(ensureAuthenticatedAppManagementAndBusinessPlatform).not.toHaveBeenCalled() + expect(fetch).not.toHaveBeenCalled() + }, +) + +test('forwards the 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 } + ` + const variables = {key: 'test-app', search: {limit: 10001, offset: 1000001}} + const response = {data: {chosenApp: {logs: {events: [{timestamp: '2026-09-28T20:00:00Z'}]}}}} + vi.mocked(fetch).mockResolvedValue(new Response(JSON.stringify(response))) + + await expect( + executeLogsQuery({...options, query, variables: JSON.stringify(variables), operationName: 'Logs', noPrompt: true}), + ).resolves.toEqual({response, failed: false}) + + expect(ensureAuthenticatedAppManagementAndBusinessPlatform).toHaveBeenCalledExactlyOnceWith({noPrompt: true}) + expect(fetch).toHaveBeenCalledExactlyOnceWith( + 'https://app.shop.dev/app_observability/unstable/graphql', + expect.objectContaining({ + method: 'POST', + redirect: 'error', + signal: expect.any(AbortSignal), + 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(executeLogsQuery({...options, query})).resolves.toEqual({response, failed: false}) + expect(JSON.parse(vi.mocked(fetch).mock.calls[0]![1]!.body as string)).toEqual({query}) +}) + +test('reads the document and variables from 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 executeLogsQuery({...options, query: undefined, queryFile, variableFile}) + + 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 executeLogsQuery({...options, query: undefined, queryFile: '-'}) + + expect(readStdinString).toHaveBeenCalledOnce() + expect(JSON.parse(vi.mocked(fetch).mock.calls[0]![1]!.body as string)).toEqual({query: options.query}) +}) + +test('authenticates the fixed loopback demo through local Identity too', async () => { + vi.mocked(fetch).mockResolvedValue(new Response('{"data":{"__typename":"QueryRoot"}}')) + + await executeLogsQuery({...options, demo: true}) + + expect(ensureAuthenticatedAppManagementAndBusinessPlatform).toHaveBeenCalledExactlyOnceWith({noPrompt: false}) + expect(fetch).toHaveBeenCalledWith( + 'http://127.0.0.1:4388/app_observability/unstable/graphql', + expect.objectContaining({headers: expect.objectContaining({authorization: 'Bearer atkn_local-identity'})}), + ) +}) + +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(executeLogsQuery(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(executeLogsQuery({...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(executeLogsQuery(options)).resolves.toEqual({response, failed: true}) +}) + +test.each([null, [], {}, {data: []}, {errors: []}, {errors: [{}]}].map((body) => ({body})))( + 'rejects a malformed GraphQL envelope %j', + async ({body}) => { + vi.mocked(fetch).mockResolvedValue(new Response(JSON.stringify(body))) + await expect(executeLogsQuery(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(executeLogsQuery(options)).rejects.toThrow('invalid JSON (HTTP 502)') +}) + +test.each([new Error('Connection refused'), new DOMException('Timed out', 'TimeoutError')])( + 'propagates transport failures without retrying: %s', + async (error) => { + vi.mocked(fetch).mockRejectedValue(error) + await expect(executeLogsQuery(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).mockResolvedValue(new Response(JSON.stringify(response))) + + await expect(executeLogsQuery(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(executeLogsQuery({...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(executeLogsQuery({...options, query: undefined, queryFile: '-'})).rejects.toThrow('nonempty GraphQL') + expect(ensureAuthenticatedAppManagementAndBusinessPlatform).not.toHaveBeenCalled() +}) + +test('reports missing query files without making a request', async () => { + await inTemporaryDirectory(async (directory) => { + await expect( + executeLogsQuery({...options, query: undefined, queryFile: joinPath(directory, 'missing.graphql')}), + ).rejects.toThrow() + expect(fetch).not.toHaveBeenCalled() + }) +}) diff --git a/packages/app/src/cli/services/logs-query.ts b/packages/app/src/cli/services/logs-query.ts new file mode 100644 index 00000000000..aad61ef5678 --- /dev/null +++ b/packages/app/src/cli/services/logs-query.ts @@ -0,0 +1,106 @@ +import {appManagementHeaders} from '@shopify/cli-kit/node/api/app-management' +import {appManagementFqdn} from '@shopify/cli-kit/node/context/fqdn' +import {getAppAutomationToken} from '@shopify/cli-kit/node/environment' +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 {defineJsonOutputSchema, type InferJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema' +import {zod} from '@shopify/cli-kit/node/schema' +import {ensureAuthenticatedAppManagementAndBusinessPlatform} from '@shopify/cli-kit/node/session' +import {readStdinString} from '@shopify/cli-kit/node/system' + +export const logsJsonOutputSchema = defineJsonOutputSchema({ + name: 'LogsGraphQLResponse', + schema: zod + .object({ + data: zod.record(zod.unknown()).nullable().optional(), + errors: zod + .array(zod.object({message: zod.string()}).passthrough()) + .min(1) + .optional(), + extensions: zod.record(zod.unknown()).optional(), + }) + .passthrough() + .refine((response) => response.data !== undefined || response.errors !== undefined), +}) + +export interface LogsQueryOptions { + query?: string + queryFile?: string + variables?: string + variableFile?: string + operationName?: string + noPrompt: boolean + demo: boolean +} + +interface LogsQueryResult { + response: InferJsonOutputSchema + failed: boolean +} + +export async function executeLogsQuery(options: LogsQueryOptions): 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 = zod.record(zod.unknown()).safeParse(parsed) + if (!result.success) throw new AbortError('GraphQL variables must be a JSON object.') + variables = result.data + } + + const origin = options.demo ? 'http://127.0.0.1:4388' : await localOrigin() + if (getAppAutomationToken()) { + throw new AbortError( + 'This prototype requires a signed-in Shopify account.', + 'Unset SHOPIFY_APP_AUTOMATION_TOKEN and SHOPIFY_CLI_PARTNERS_TOKEN before signing in.', + ) + } + const {appManagementToken} = await ensureAuthenticatedAppManagementAndBusinessPlatform({noPrompt: options.noPrompt}) + const response = await fetch(`${origin}/app_observability/unstable/graphql`, { + method: 'POST', + redirect: 'error', + signal: AbortSignal.timeout(60000), + headers: appManagementHeaders(appManagementToken), + 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(`App logs returned invalid JSON (HTTP ${response.status}).`) + } + const result = logsJsonOutputSchema.schema.safeParse(body) + if (!result.success) { + throw new AbortError(`App logs returned an invalid GraphQL response (HTTP ${response.status}).`) + } + return {response: result.data, failed: !response.ok || Boolean(result.data.errors?.length)} +} + +async function localOrigin(): Promise { + const host = await appManagementFqdn() + if (host !== 'app.shop.dev') throw new AbortError('This prototype can only call app.shop.dev.') + return `https://${host}` +} diff --git a/packages/app/src/cli/services/logs-schema.test.ts b/packages/app/src/cli/services/logs-schema.test.ts new file mode 100644 index 00000000000..aba02525c91 --- /dev/null +++ b/packages/app/src/cli/services/logs-schema.test.ts @@ -0,0 +1,115 @@ +import {fetchLogsSchema} from './logs-schema.js' +import {executeLogsQuery, logsJsonOutputSchema} from './logs-query.js' +import { + GraphQLSchema, + GraphQLObjectType, + GraphQLEnumType, + GraphQLList, + GraphQLNonNull, + GraphQLString, + GraphQLInt, + graphqlSync, +} from 'graphql' +import {expect, test, vi} from 'vitest' + +vi.mock('./logs-query.js', async (importOriginal) => ({ + ...(await importOriginal()), + executeLogsQuery: vi.fn(), +})) + +const options = {appKey: 'test-app', noPrompt: true, demo: true, json: false} + +const appField = { + type: new GraphQLObjectType({name: 'App', fields: {key: {type: GraphQLString}}}), + args: {key: {type: new GraphQLNonNull(GraphQLString)}}, + resolve: (_root: unknown, args: {key: string}) => ({key: args.key}), +} + +test('fetches live SDL with descriptions, defaults, nested types and deprecations', async () => { + const status = new GraphQLEnumType({ + name: 'Status', + values: {SUCCESS: {description: 'Delivery succeeded.'}, FAILED: {}}, + }) + const event = new GraphQLObjectType({ + name: 'Event', + fields: { + status: {type: new GraphQLNonNull(status)}, + old: {type: GraphQLString, deprecationReason: 'Use status'}, + }, + }) + const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + description: 'Available log queries.', + fields: { + app: appField, + logs: { + type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(event))), + args: {limit: {type: GraphQLInt, defaultValue: 50}}, + }, + }, + }), + }) + vi.mocked(executeLogsQuery).mockImplementation(async ({query}) => { + const response = graphqlSync({schema, source: query!}) + expect(response.errors).toBeUndefined() + return {response: logsJsonOutputSchema.schema.parse(response), failed: false} + }) + + const result = await fetchLogsSchema(options) + + expect(result.failed).toBe(false) + expect(vi.mocked(executeLogsQuery).mock.calls[0]![0].query).toContain('app(key: "test-app")') + expect(result.output).toContain('"""Available log queries."""') + expect(result.output).toContain('logs(limit: Int = 50): [Event!]!') + expect(result.output).toContain('@deprecated(reason: "Use status")') + expect(result.output).toContain('"""Delivery succeeded."""') + expect(executeLogsQuery).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({noPrompt: true, demo: true})) +}) + +test('JSON mode preserves the complete introspection response', async () => { + let expectedResponse + vi.mocked(executeLogsQuery).mockImplementation(async ({query}) => { + const response = { + ...graphqlSync({ + schema: new GraphQLSchema({ + query: new GraphQLObjectType({name: 'Query', fields: {app: appField, ping: {type: GraphQLString}}}), + }), + source: query!, + }), + extensions: {id: 'request'}, + } + expectedResponse = response + return {response: logsJsonOutputSchema.schema.parse(response), failed: false} + }) + + const result = await fetchLogsSchema({...options, json: true}) + + expect(JSON.parse(result.output)).toEqual(expectedResponse) + expect(result.failed).toBe(false) +}) + +test.each([false, true])('failed introspection preserves errors without printing SDL (json=%s)', async (json) => { + const response = {data: null, errors: [{message: 'Denied', extensions: {code: 'FORBIDDEN'}}]} + vi.mocked(executeLogsQuery).mockResolvedValue({response, failed: true}) + + await expect(fetchLogsSchema({...options, json})).resolves.toEqual({ + output: JSON.stringify(response, null, 2), + failed: true, + }) +}) + +test('HTTP failure without GraphQL errors remains a failure', async () => { + vi.mocked(executeLogsQuery).mockResolvedValue({response: {data: null}, failed: true}) + await expect(fetchLogsSchema(options)).resolves.toEqual({output: JSON.stringify({data: null}, null, 2), failed: true}) +}) + +test.each([null, {}, {__schema: {types: []}}])('incomplete introspection is rejected: %j', async (data) => { + vi.mocked(executeLogsQuery).mockResolvedValue({response: {data}, failed: false}) + await expect(fetchLogsSchema(options)).rejects.toThrow('incomplete or invalid introspection') +}) + +test('transport failure is not converted to an empty schema', async () => { + vi.mocked(executeLogsQuery).mockRejectedValue(new Error('Connection refused')) + await expect(fetchLogsSchema(options)).rejects.toThrow('Connection refused') +}) diff --git a/packages/app/src/cli/services/logs-schema.ts b/packages/app/src/cli/services/logs-schema.ts new file mode 100644 index 00000000000..bb750895b9c --- /dev/null +++ b/packages/app/src/cli/services/logs-schema.ts @@ -0,0 +1,59 @@ +import {executeLogsQuery, logsJsonOutputSchema, type LogsQueryOptions} from './logs-query.js' +import {AbortError} from '@shopify/cli-kit/node/error' +import { + buildClientSchema, + assertValidSchema, + getIntrospectionQuery, + lexicographicSortSchema, + printSchema, + type IntrospectionQuery, + Kind, + parse, + print, +} from 'graphql' + +interface LogsSchemaOptions extends Pick { + appKey: string + json: boolean +} + +export async function fetchLogsSchema(options: LogsSchemaOptions): Promise<{output: string; failed: boolean}> { + const document = parse( + getIntrospectionQuery({ + descriptions: true, + specifiedByUrl: true, + directiveIsRepeatable: true, + inputValueDeprecation: true, + }), + ) + const scope = parse(`{ app(key: ${JSON.stringify(options.appKey)}) { key } }`).definitions[0]! + if (scope.kind !== Kind.OPERATION_DEFINITION) throw new AbortError('Invalid schema scope.') + const query = print({ + ...document, + definitions: document.definitions.map((definition) => + definition.kind === Kind.OPERATION_DEFINITION + ? { + ...definition, + selectionSet: { + ...definition.selectionSet, + selections: [...scope.selectionSet.selections, ...definition.selectionSet.selections], + }, + } + : definition, + ), + }) + const {response, failed} = await executeLogsQuery({noPrompt: options.noPrompt, demo: options.demo, query}) + if (failed) return {output: logsJsonOutputSchema.encode(response), failed} + + let schema + try { + schema = buildClientSchema(response.data as unknown as IntrospectionQuery) + assertValidSchema(schema) + } catch { + throw new AbortError('App logs returned incomplete or invalid introspection data.') + } + return { + output: options.json ? logsJsonOutputSchema.encode(response) : printSchema(lexicographicSortSchema(schema)), + failed: false, + } +} diff --git a/packages/cli/README.md b/packages/cli/README.md index d216edfc326..058dff05199 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -26,8 +26,8 @@ * [`shopify app import dashboard-extensions`](#shopify-app-import-dashboard-extensions) * [`shopify app info`](#shopify-app-info) * [`shopify app init`](#shopify-app-init) -* [`shopify app logs`](#shopify-app-logs) -* [`shopify app logs sources`](#shopify-app-logs-sources) +* [`shopify app logs (--query | --query-file ) [flags]`](#shopify-app-logs---query-query----query-file-path-flags) +* [`shopify app logs schema [flags]`](#shopify-app-logs-schema-flags) * [`shopify app release --version `](#shopify-app-release---version-version) * [`shopify app subscription-migrations cancel`](#shopify-app-subscription-migrations-cancel) * [`shopify app subscription-migrations list`](#shopify-app-subscription-migrations-list) @@ -2632,37 +2632,31 @@ EXAMPLES $ shopify app init --client-id 123 --template none ``` -## `shopify app logs` +## `shopify app logs (--query | --query-file ) [flags]` -Stream detailed logs for your Shopify app. +Query historical app logs with GraphQL. ``` USAGE - $ shopify app logs [--auth-alias ] [--client-id | -c ] [-j] [--json-schema] - [--no-color] [--path ] [--reset | ] [--source ...] [--status success|failure] [-s ...] - [--verbose] + $ shopify app logs (--query | --query-file ) [flags] FLAGS - -c, --config= - The name of the app configuration. - [env: SHOPIFY_FLAG_APP_CONFIG] - -j, --json Output the result as JSON. Automatically disables color output. [env: SHOPIFY_FLAG_JSON] - -s, --store=... - Store URL. Must be an existing development or Shopify Plus sandbox store. - [env: SHOPIFY_FLAG_STORE] + -q, --query= + The GraphQL query, as a string. + [env: SHOPIFY_FLAG_QUERY] + + -v, --variables= + GraphQL variables as a JSON object. + [env: SHOPIFY_FLAG_VARIABLES] --auth-alias= Alias of the Shopify account to use for authentication. [env: SHOPIFY_FLAG_AUTH_ALIAS] - --client-id= - The Client ID of your app. - [env: SHOPIFY_FLAG_CLIENT_ID] - --json-schema Print the command's JSON schemas. [env: SHOPIFY_FLAG_JSON_SCHEMA] @@ -2671,48 +2665,100 @@ FLAGS Disable color output. [env: SHOPIFY_FLAG_NO_COLOR] - --path= - The path to your app directory. - [env: SHOPIFY_FLAG_PATH] + --no-prompt + Fail instead of prompting when no valid account session is available. + [env: SHOPIFY_FLAG_NO_PROMPT] - --reset - Reset all your settings. - [env: SHOPIFY_FLAG_RESET] + --operation-name= + The operation to execute when the document contains multiple operations. + [env: SHOPIFY_FLAG_OPERATION_NAME] - --source=... - Filters output to the specified log source. - [env: SHOPIFY_FLAG_SOURCE] + --query-file= + Path to a GraphQL document, or - to read from stdin. + [env: SHOPIFY_FLAG_QUERY_FILE] - --status=