Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 59 additions & 59 deletions docs-shopify.dev/generated/generated_docs_data_v2.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions packages/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
55 changes: 55 additions & 0 deletions packages/app/src/cli/commands/app/app-logs/schema.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import('@shopify/cli-kit/node/output')>()),
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()
})
51 changes: 51 additions & 0 deletions packages/app/src/cli/commands/app/app-logs/schema.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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
}
}
130 changes: 130 additions & 0 deletions packages/app/src/cli/commands/app/logs.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import('../../services/logs-query.js')>()),
executeLogsQuery: vi.fn(),
}))
vi.mock('@shopify/cli-kit/node/output', async (importOriginal) => ({
...(await importOriginal<typeof import('@shopify/cli-kit/node/output')>()),
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()
},
)
123 changes: 63 additions & 60 deletions packages/app/src/cli/commands/app/logs.ts
Original file line number Diff line number Diff line change
@@ -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> | --query-file <path>) [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<AppLinkedCommandOutput> {
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<void> {
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
}
}
Loading
Loading