Skip to content
Open
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
6 changes: 6 additions & 0 deletions .changeset/app-webhook-trigger-json.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@shopify/app': minor
'@shopify/cli': minor
---

Add typed JSON output to `app webhook trigger`.
6 changes: 6 additions & 0 deletions .changeset/webhook-debug-secret.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@shopify/cli-kit': patch
'@shopify/cli': patch
---

Hide webhook shared secrets in GraphQL request diagnostics.
38 changes: 37 additions & 1 deletion docs-shopify.dev/generated/generated_docs_data_v2.json
Original file line number Diff line number Diff line change
Expand Up @@ -4687,6 +4687,24 @@
"isOptional": true,
"environmentValue": "SHOPIFY_FLAG_JSON_SCHEMA"
},
{
"filePath": "docs-shopify.dev/commands/interfaces/app-webhook-trigger.interface.ts",
"syntaxKind": "PropertySignature",
"name": "--no-color",
"value": "''",
"description": "Disable color output.",
"isOptional": true,
"environmentValue": "SHOPIFY_FLAG_NO_COLOR"
},
{
"filePath": "docs-shopify.dev/commands/interfaces/app-webhook-trigger.interface.ts",
"syntaxKind": "PropertySignature",
"name": "--no-input",
"value": "''",
"description": "Disable interactive prompts and browser authentication.",
"isOptional": true,
"environmentValue": "SHOPIFY_FLAG_NO_INPUT"
},
{
"filePath": "docs-shopify.dev/commands/interfaces/app-webhook-trigger.interface.ts",
"syntaxKind": "PropertySignature",
Expand Down Expand Up @@ -4714,6 +4732,15 @@
"isOptional": true,
"environmentValue": "SHOPIFY_FLAG_TOPIC"
},
{
"filePath": "docs-shopify.dev/commands/interfaces/app-webhook-trigger.interface.ts",
"syntaxKind": "PropertySignature",
"name": "--verbose",
"value": "''",
"description": "Increase the verbosity of the output. May include sensitive data.",
"isOptional": true,
"environmentValue": "SHOPIFY_FLAG_VERBOSE"
},
{
"filePath": "docs-shopify.dev/commands/interfaces/app-webhook-trigger.interface.ts",
"syntaxKind": "PropertySignature",
Expand All @@ -4722,9 +4749,18 @@
"description": "The name of the app configuration.",
"isOptional": true,
"environmentValue": "SHOPIFY_FLAG_APP_CONFIG"
},
{
"filePath": "docs-shopify.dev/commands/interfaces/app-webhook-trigger.interface.ts",
"syntaxKind": "PropertySignature",
"name": "-j, --json",
"value": "''",
"description": "Output the result as JSON. Automatically disables color output.",
"isOptional": true,
"environmentValue": "SHOPIFY_FLAG_JSON"
}
],
"value": "export interface appwebhooktrigger {\n /**\n * The URL where the webhook payload should be sent.\n You will need a different address type for each delivery-method:\n · For remote HTTP testing, use a URL that starts with https://\n · For local HTTP testing, use http://localhost:{port}/{url-path}\n · For Google Pub/Sub, use pubsub://{project-id}:{topic-id}\n · For Amazon EventBridge, use an Amazon Resource Name (ARN) starting with arn:aws:events:. Required if non interactive.\n * @environment SHOPIFY_FLAG_ADDRESS\n */\n '--address <value>'?: string\n\n /**\n * The API Version of the webhook topic. Required if non interactive.\n * @environment SHOPIFY_FLAG_API_VERSION\n */\n '--api-version <value>'?: string\n\n /**\n * Alias of the Shopify account to use for authentication.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--auth-alias <value>'?: string\n\n /**\n * The Client ID of your app.\n * @environment SHOPIFY_FLAG_CLIENT_ID\n */\n '--client-id <value>'?: string\n\n /**\n * Your app's client secret. This secret allows us to return the X-Shopify-Hmac-SHA256 header that lets you validate the origin of the response that you receive.\n * @environment SHOPIFY_FLAG_CLIENT_SECRET\n */\n '--client-secret <value>'?: string\n\n /**\n * The name of the app configuration.\n * @environment SHOPIFY_FLAG_APP_CONFIG\n */\n '-c, --config <value>'?: string\n\n /**\n * Method chosen to deliver the topic payload. If not passed, it's inferred from the address.\n * @environment SHOPIFY_FLAG_DELIVERY_METHOD\n */\n '--delivery-method <value>'?: string\n\n /**\n * This help. When you run the trigger command the CLI will prompt you for any information that isn't passed using flags.\n * @environment SHOPIFY_FLAG_HELP\n */\n '--help'?: ''\n\n /**\n * Print the command's JSON schemas.\n * @environment SHOPIFY_FLAG_JSON_SCHEMA\n */\n '--json-schema'?: ''\n\n /**\n * The path to your app directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path <value>'?: string\n\n /**\n * Reset all your settings.\n * @environment SHOPIFY_FLAG_RESET\n */\n '--reset'?: ''\n\n /**\n * The requested webhook topic. Required if non interactive.\n * @environment SHOPIFY_FLAG_TOPIC\n */\n '--topic <value>'?: string\n}"
"value": "export interface appwebhooktrigger {\n /**\n * The URL where the webhook payload should be sent.\n You will need a different address type for each delivery-method:\n · For remote HTTP testing, use a URL that starts with https://\n · For local HTTP testing, use http://localhost:{port}/{url-path}\n · For Google Pub/Sub, use pubsub://{project-id}:{topic-id}\n · For Amazon EventBridge, use an Amazon Resource Name (ARN) starting with arn:aws:events:. Required if non interactive.\n * @environment SHOPIFY_FLAG_ADDRESS\n */\n '--address <value>'?: string\n\n /**\n * The API Version of the webhook topic. Required if non interactive.\n * @environment SHOPIFY_FLAG_API_VERSION\n */\n '--api-version <value>'?: string\n\n /**\n * Alias of the Shopify account to use for authentication.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--auth-alias <value>'?: string\n\n /**\n * The Client ID of your app.\n * @environment SHOPIFY_FLAG_CLIENT_ID\n */\n '--client-id <value>'?: string\n\n /**\n * Your app's client secret. This secret allows us to return the X-Shopify-Hmac-SHA256 header that lets you validate the origin of the response that you receive.\n * @environment SHOPIFY_FLAG_CLIENT_SECRET\n */\n '--client-secret <value>'?: string\n\n /**\n * The name of the app configuration.\n * @environment SHOPIFY_FLAG_APP_CONFIG\n */\n '-c, --config <value>'?: string\n\n /**\n * Method chosen to deliver the topic payload. If not passed, it's inferred from the address.\n * @environment SHOPIFY_FLAG_DELIVERY_METHOD\n */\n '--delivery-method <value>'?: string\n\n /**\n * This help. When you run the trigger command the CLI will prompt you for any information that isn't passed using flags.\n * @environment SHOPIFY_FLAG_HELP\n */\n '--help'?: ''\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 * Disable interactive prompts and browser authentication.\n * @environment SHOPIFY_FLAG_NO_INPUT\n */\n '--no-input'?: ''\n\n /**\n * The path to your app directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path <value>'?: string\n\n /**\n * Reset all your settings.\n * @environment SHOPIFY_FLAG_RESET\n */\n '--reset'?: ''\n\n /**\n * The requested webhook topic. Required if non interactive.\n * @environment SHOPIFY_FLAG_TOPIC\n */\n '--topic <value>'?: string\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}"
}
},
"authlogin": {
Expand Down
99 changes: 99 additions & 0 deletions packages/app/src/cli/commands/app/webhook/trigger.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import WebhookTrigger from './trigger.js'
import {linkedAppContext} from '../../../services/app-context.js'
import {webhookTriggerService} from '../../../services/webhook/trigger.js'
import {WebhookTriggerResult} from '../../../services/webhook/trigger/types.js'
import {
testAppLinked,
testDeveloperPlatformClient,
testOrganization,
testOrganizationApp,
testProject,
} from '../../../models/app/app.test-data.js'
import {Config} from '@oclif/core'
import {afterEach, beforeEach, expect, test, vi} from 'vitest'
import {handler} from '@shopify/cli-kit/node/error'
import {runWithCommandEventsForCommand} from '@shopify/cli-kit/node/command-events'
import {outputInfo} from '@shopify/cli-kit/node/output'
import {withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output'

vi.mock('../../../services/app-context.js')
vi.mock('../../../services/webhook/trigger.js')

const app = testAppLinked()
const result: WebhookTriggerResult = {
status: 'success',
delivery: {
topic: 'orders/create',
apiVersion: '2026-10',
deliveryMethod: 'http',
address: 'https://example.com/webhooks',
status: 'enqueued',
},
samplePayloadIsEmpty: true,
}

beforeEach(() => {
vi.mocked(linkedAppContext).mockResolvedValue({
app,
remoteApp: testOrganizationApp(),
developerPlatformClient: testDeveloperPlatformClient(),
organization: testOrganization(),
specifications: [],
project: testProject(),
activeConfig: {} as never,
})
})

afterEach(() => vi.unstubAllEnvs())

async function runCommand(flags: string[] = ['--json']) {
const argv = [
...flags,
'--topic',
'orders/create',
'--api-version',
'2026-10',
'--address',
'https://example.com/webhooks',
]
const command = new WebhookTrigger(argv, await Config.load())
return runWithCommandEventsForCommand(argv, () => command.run())
}

test.each([['--json'], ['--json', '--no-input']])('writes one JSON result with flags %j', async (...flags) => {
vi.mocked(webhookTriggerService).mockImplementation(async () => {
outputInfo('Sending webhook sample.')
return result
})
await withCapturedStandardStreams(async ({stdout, stderr}) => {
await runCommand(flags)
expect(JSON.parse(stdout())).toEqual({status: 'success', delivery: result.delivery})
expect(JSON.parse(stderr())).toMatchObject({type: 'diagnostic', message: 'Sending webhook sample.'})
})
})

test.each([
{
result: {status: 'failed', reason: 'sample-request', userErrors: [{message: 'Invalid topic', fields: ['topic']}]},
error: {
type: 'abort',
message: 'Webhook sample request failed.',
details: {userErrors: [{message: 'Invalid topic', fieldPath: ['topic']}]},
},
},
{
result: {status: 'failed', reason: 'localhost-delivery'},
error: {type: 'abort', message: 'Localhost delivery failed'},
},
] satisfies {result: WebhookTriggerResult; error: object}[])(
'writes one fatal JSON document for $result.reason',
async ({result, error}) => {
vi.stubEnv('SHOPIFY_FLAG_JSON', '1')
vi.mocked(webhookTriggerService).mockResolvedValue(result)
await withCapturedStandardStreams(async ({stdout, stderr}) => {
await runCommand().catch(handler)
expect(JSON.parse(stdout())).toEqual({error})
expect(stderr()).toBe('')
})
},
)
13 changes: 11 additions & 2 deletions packages/app/src/cli/commands/app/webhook/trigger.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import {DELIVERY_METHOD} from '../../../services/webhook/trigger-flags.js'
import {WebhookTriggerInput, webhookTriggerService} from '../../../services/webhook/trigger.js'
import {appWebhookTriggerJsonOutputSchema} from '../../../services/webhook/trigger/types.js'
import {renderWebhookTriggerResult} from '../../../services/webhook/trigger/result.js'
import {deliveryMethodInstructionsAsString} from '../../../prompts/webhook/trigger.js'
import {appFlags} from '../../../flags.js'
import AppLinkedCommand, {AppLinkedCommandOutput} from '../../../utilities/app-linked-command.js'
import {linkedAppContext} from '../../../services/app-context.js'
import {Flags} from '@oclif/core'
import {requiredIfNonInteractive} from '@shopify/cli-kit/node/cli'
import {globalFlags, jsonFlag, requiredIfNonInteractive} from '@shopify/cli-kit/node/cli'

export default class WebhookTrigger extends AppLinkedCommand {
static summary = 'Trigger delivery of a sample webhook topic payload to a designated address.'
Expand All @@ -27,10 +29,16 @@ export default class WebhookTrigger extends AppLinkedCommand {
- You can't use this method to validate your API webhook subscriptions.
`

static get jsonOutputSchema() {
return appWebhookTriggerJsonOutputSchema
}

static description = this.descriptionForHelp()

static flags = {
...globalFlags,
...appFlags,
...jsonFlag,
help: Flags.help({
required: false,
hidden: false,
Expand Down Expand Up @@ -103,7 +111,8 @@ export default class WebhookTrigger extends AppLinkedCommand {
organizationId: appContextResult.organization.id,
}

await webhookTriggerService(usedFlags)
const result = await webhookTriggerService(usedFlags)
renderWebhookTriggerResult(result, flags.json ? 'json' : 'text')
return {app: appContextResult.app}
}
}
32 changes: 26 additions & 6 deletions packages/app/src/cli/services/webhook/trigger.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {SendSampleWebhookVariables, getWebhookSample} from './request-sample.js'
import {requestApiVersions} from './request-api-versions.js'
import {requestTopics} from './request-topics.js'
import {triggerLocalWebhook} from './trigger-local-webhook.js'
import {renderWebhookTriggerResult} from './trigger/result.js'
import {
testApp,
testAppLinked,
Expand Down Expand Up @@ -79,10 +80,11 @@ describe('webhookTriggerService', () => {
vi.mocked(getWebhookSample).mockResolvedValue(response)

// When
await webhookTriggerService(sampleFlags())
const result = await webhookTriggerService(sampleFlags())

// Then
expectCalls(aVersion, anOrganizationId)
renderWebhookTriggerResult(result, 'text')
expect(outputWarn).toHaveBeenCalledWith(`Request errors:\n · Some error\n · Another error`)
})

Expand All @@ -102,10 +104,11 @@ describe('webhookTriggerService', () => {
vi.mocked(getWebhookSample).mockResolvedValue(response)

// When
await webhookTriggerService(sampleFlags())
const result = await webhookTriggerService(sampleFlags())

// Then
expectCalls(aVersion, anOrganizationId)
renderWebhookTriggerResult(result, 'text')
expect(outputWarn).toHaveBeenCalledWith(`Request errors:\n${JSON.stringify(response.userErrors)}`)
})

Expand All @@ -123,7 +126,7 @@ describe('webhookTriggerService', () => {
}

// When
await webhookTriggerService(sampleFlags())
const result = await webhookTriggerService(sampleFlags())

// Then
expectCalls(aVersion, anOrganizationId)
Expand All @@ -133,6 +136,18 @@ describe('webhookTriggerService', () => {
anOrganizationId,
)
expect(triggerLocalWebhook).toHaveBeenCalledTimes(0)
expect(result).toEqual({
status: 'success',
delivery: {
topic: aTopic,
apiVersion: aVersion,
deliveryMethod: expectedSampleWebhookVariables.delivery_method,
address: expectedSampleWebhookVariables.address,
status: 'enqueued',
},
samplePayloadIsEmpty: true,
})
renderWebhookTriggerResult(result, 'text')
expect(outputSuccess).toHaveBeenCalledWith('Webhook has been enqueued for delivery')
})

Expand Down Expand Up @@ -176,7 +191,7 @@ describe('webhookTriggerService', () => {
}

// When
await webhookTriggerService(flags)
const result = await webhookTriggerService(flags)

// Then
expectCalls(aVersion, anOrganizationId)
Expand All @@ -185,6 +200,7 @@ describe('webhookTriggerService', () => {
expectedSampleWebhookVariables,
anOrganizationId,
)
renderWebhookTriggerResult(result, 'text')
expect(outputSuccess).toHaveBeenCalledWith('Webhook has been enqueued for delivery')
})

Expand All @@ -203,7 +219,7 @@ describe('webhookTriggerService', () => {
}

// When
await webhookTriggerService(sampleLocalhostFlags())
const result = await webhookTriggerService(sampleLocalhostFlags())

// Then
expectCalls(aVersion, anOrganizationId)
Expand All @@ -213,6 +229,8 @@ describe('webhookTriggerService', () => {
anOrganizationId,
)
expect(triggerLocalWebhook).toHaveBeenCalledWith(aFullLocalAddress, samplePayload, sampleHeaders)
expect(result).toMatchObject({status: 'success', delivery: {status: 'delivered'}})
renderWebhookTriggerResult(result, 'text')
expect(outputSuccess).toHaveBeenCalledWith('Localhost delivery sucessful')
})

Expand All @@ -230,7 +248,7 @@ describe('webhookTriggerService', () => {
}

// When
await webhookTriggerService(sampleLocalhostFlags())
const result = await webhookTriggerService(sampleLocalhostFlags())

// Then
expectCalls(aVersion, anOrganizationId)
Expand All @@ -240,6 +258,8 @@ describe('webhookTriggerService', () => {
anOrganizationId,
)
expect(triggerLocalWebhook).toHaveBeenCalledWith(aFullLocalAddress, samplePayload, sampleHeaders)
expect(result).toEqual({status: 'failed', reason: 'localhost-delivery'})
renderWebhookTriggerResult(result, 'text')
expect(outputWarn).toHaveBeenCalledWith('Localhost delivery failed')
})
})
Expand Down
Loading
Loading