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
5 changes: 5 additions & 0 deletions .changeset/bright-doctors-scan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/app': minor
---

Add `shopify app doctor` commands for Shopify-specific security reviews and coding-agent handoffs.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,8 @@
"ignoreDependencies": [
"@ast-grep/napi",
"@shopify/theme-check-docs-updater",
"@shopify/theme-check-node"
"@shopify/theme-check-node",
"clipboardy"
],
"vite": {
"config": [
Expand Down
4 changes: 4 additions & 0 deletions packages/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
"scripts": {
"build": "nx build",
"clean": "nx clean",
"generate:app-doctor-checks": "node src/cli/services/app-doctor-engine/embed-checks.mjs",
"lint": "nx lint",
"lint:fix": "nx lint:fix",
"prepack": "NODE_ENV=production pnpm nx build && cp ../../README.md README.md",
Expand All @@ -55,6 +56,7 @@
},
"dependencies": {
"@graphql-typed-document-node/core": "3.2.0",
"@iarna/toml": "2.2.5",
"@luckycatfactory/esbuild-graphql-loader": "3.8.1",
"@oclif/core": "4.8.3",
"@shopify/cli-kit": "4.7.0",
Expand All @@ -64,8 +66,10 @@
"@shopify/theme-check-node": "3.29.0",
"@shopify/toml-patch": "0.3.0",
"chokidar": "3.6.0",
"clipboardy": "4.0.0",
"diff": "5.2.2",
"esbuild": "0.28.1",
"fast-glob": "3.3.3",
"graphql-request": "6.1.0",
"h3": "1.15.11",
"http-proxy-node16": "1.0.6",
Expand Down
69 changes: 69 additions & 0 deletions packages/app/src/cli/commands/app/doctor.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import Doctor from './doctor.js'
import doctor from '../../services/doctor.js'
import AppLinkedCommand from '../../utilities/app-linked-command.js'
import BaseCommand from '@shopify/cli-kit/node/base-command'
import {resolvePath} from '@shopify/cli-kit/node/path'
import {describe, expect, test, vi} from 'vitest'

vi.mock('../../services/doctor.js')

describe('app doctor command', () => {
test('is hidden and does not require linked app context', () => {
expect(Doctor.hidden).toBe(true)
expect(Doctor.prototype).toBeInstanceOf(BaseCommand)
expect(Doctor.prototype).not.toBeInstanceOf(AppLinkedCommand)
})

test('forwards the directory and flags to the service', async () => {
await Doctor.run(
['./fixtures/unlinked-app', '--json', '--verbose', '--blocking', 'high', '--skip-instructions'],
import.meta.url,
)

expect(doctor).toHaveBeenCalledWith({
directory: resolvePath('./fixtures/unlinked-app'),
json: true,
verbose: true,
blocking: 'high',
yes: false,
skipInstructions: true,
findingsPath: undefined,
})
})

test('forwards --yes without requiring an app configuration', async () => {
await Doctor.run(['/tmp/directory-without-shopify-toml', '--yes'], import.meta.url)

expect(doctor).toHaveBeenCalledWith({
directory: '/tmp/directory-without-shopify-toml',
json: false,
verbose: false,
blocking: 'none',
yes: true,
skipInstructions: false,
findingsPath: undefined,
})
})

test('resolves and forwards an agent findings file', async () => {
await Doctor.run(['.', '--findings', './findings.json', '--skip-instructions'], import.meta.url)

expect(doctor).toHaveBeenCalledWith(expect.objectContaining({findingsPath: resolvePath('./findings.json')}))
})

test('describes --yes as printing instructions and keeps it mutually exclusive with --skip-instructions', () => {
expect(Doctor.flags.yes.description).toBe('Print coding-agent instructions without prompting.')
expect(Doctor.flags['skip-instructions'].description).toBe("Don't offer to show coding-agent instructions.")
expect(Doctor.flags.yes.exclusive).toEqual(['skip-instructions'])
expect(Doctor.flags['skip-instructions'].exclusive).toEqual(['yes'])
expect(Doctor.descriptionWithMarkdown).toContain('copy the coding-agent instructions')
expect(Doctor.descriptionWithMarkdown).toContain('copying is the default')
expect(Doctor.descriptionWithMarkdown).toContain('shopify app doctor instructions')
})

test('allows --yes in JSON mode while preserving non-interactive output behavior', async () => {
await Doctor.run(['--json', '--yes'], import.meta.url)

expect(doctor).toHaveBeenCalledWith(expect.objectContaining({json: true, yes: true}))
})
})
69 changes: 69 additions & 0 deletions packages/app/src/cli/commands/app/doctor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import doctor from '../../services/doctor.js'
import {Args, Flags} from '@oclif/core'
import BaseCommand from '@shopify/cli-kit/node/base-command'
import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli'
import {cwd, resolvePath} from '@shopify/cli-kit/node/path'
import type {AppDoctorBlockingLevel} from '../../services/app-doctor-api.js'

const blockingLevels: AppDoctorBlockingLevel[] = ['high', 'medium', 'low', 'none']

export default class Doctor extends BaseCommand {
static hidden = true

static summary = 'Check an app for Shopify-specific security issues.'

static descriptionWithMarkdown = `Runs Shopify App Doctor locally and creates its review pack and trace.

Pass \`--findings\` after completing the review pack to validate agent findings and compile them into the trace. In interactive terminals, the command offers to copy the coding-agent instructions, print them, or choose nothing; copying is the default. In CI and other non-interactive environments, instructions aren't offered unless you pass \`--yes\`, which prints them. JSON output never prompts or prints those instructions. You can also run \`shopify app doctor instructions\` to print, copy, or write them later.`

static description = this.descriptionWithoutMarkdown()

static args = {
directory: Args.string({
description: 'The app directory to check. Defaults to the current directory.',
parse: async (input) => resolvePath(input),
}),
}

static flags = {
...globalFlags,
...jsonFlag,
findings: Flags.string({
description: 'Validate agent findings from a JSON file and compile them into the trace.',
parse: async (input) => resolvePath(input),
env: 'SHOPIFY_FLAG_APP_DOCTOR_FINDINGS',
}),
blocking: Flags.string({
description: 'The minimum finding severity that causes a non-zero exit code.',
options: blockingLevels,
default: 'none',
env: 'SHOPIFY_FLAG_APP_DOCTOR_BLOCKING',
}),
yes: Flags.boolean({
description: 'Print coding-agent instructions without prompting.',
default: false,
exclusive: ['skip-instructions'],
env: 'SHOPIFY_FLAG_YES',
}),
'skip-instructions': Flags.boolean({
description: "Don't offer to show coding-agent instructions.",
default: false,
exclusive: ['yes'],
env: 'SHOPIFY_FLAG_APP_DOCTOR_SKIP_INSTRUCTIONS',
}),
}

public async run(): Promise<void> {
const {args, flags} = await this.parse(Doctor)

await doctor({
directory: args.directory ?? cwd(),
json: flags.json,
verbose: Boolean(flags.verbose),
blocking: flags.blocking as AppDoctorBlockingLevel,
yes: flags.yes,
skipInstructions: flags['skip-instructions'],
findingsPath: flags.findings,
})
}
}
51 changes: 51 additions & 0 deletions packages/app/src/cli/commands/app/doctor/instructions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import DoctorInstructions from './instructions.js'
import deliverAppDoctorInstructions from '../../../services/app-doctor-instructions.js'
import AppLinkedCommand from '../../../utilities/app-linked-command.js'
import BaseCommand from '@shopify/cli-kit/node/base-command'
import {cwd, resolvePath} from '@shopify/cli-kit/node/path'
import {describe, expect, test, vi} from 'vitest'

vi.mock('../../../services/app-doctor-instructions.js')

describe('app doctor instructions command', () => {
test('is hidden and does not require linked app context', () => {
expect(DoctorInstructions.hidden).toBe(true)
expect(DoctorInstructions.prototype).toBeInstanceOf(BaseCommand)
expect(DoctorInstructions.prototype).not.toBeInstanceOf(AppLinkedCommand)
})

test('prints instructions for the current directory by default', async () => {
await DoctorInstructions.run([], import.meta.url)

expect(deliverAppDoctorInstructions).toHaveBeenCalledWith({
directory: cwd(),
copy: false,
writePath: undefined,
})
})

test('forwards an app directory and --copy', async () => {
await DoctorInstructions.run(['./fixtures/unlinked-app', '--copy'], import.meta.url)

expect(deliverAppDoctorInstructions).toHaveBeenCalledWith({
directory: resolvePath('./fixtures/unlinked-app'),
copy: true,
writePath: undefined,
})
})

test('resolves and forwards --write', async () => {
await DoctorInstructions.run(['--write', './instructions.md'], import.meta.url)

expect(deliverAppDoctorInstructions).toHaveBeenCalledWith({
directory: cwd(),
copy: false,
writePath: resolvePath('./instructions.md'),
})
})

test('keeps --copy and --write mutually exclusive', () => {
expect(DoctorInstructions.flags.copy.exclusive).toEqual(['write'])
expect(DoctorInstructions.flags.write.exclusive).toEqual(['copy'])
})
})
50 changes: 50 additions & 0 deletions packages/app/src/cli/commands/app/doctor/instructions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import deliverAppDoctorInstructions from '../../../services/app-doctor-instructions.js'
import {Args, Flags} from '@oclif/core'
import BaseCommand from '@shopify/cli-kit/node/base-command'
import {globalFlags} from '@shopify/cli-kit/node/cli'
import {cwd, resolvePath} from '@shopify/cli-kit/node/path'

export default class DoctorInstructions extends BaseCommand {
static hidden = true

static summary = 'Provide App Doctor instructions to a coding agent.'

static descriptionWithMarkdown = `Prints the complete workflow that a coding agent should follow to review App Doctor results.

By default, the instructions are printed to stdout. Use \`--copy\` to copy them to the clipboard or \`--write\` to write them to a file. Standalone instructions always start by running \`shopify app doctor\`; only that invocation's generated review pack is trusted as workflow input.`

static description = this.descriptionWithoutMarkdown()

static args = {
directory: Args.string({
description: 'The app directory containing App Doctor results. Defaults to the current directory.',
parse: async (input) => resolvePath(input),
}),
}

static flags = {
...globalFlags,
copy: Flags.boolean({
description: 'Copy the instructions to the clipboard instead of printing them.',
default: false,
exclusive: ['write'],
env: 'SHOPIFY_FLAG_APP_DOCTOR_INSTRUCTIONS_COPY',
}),
write: Flags.string({
description: 'Write the instructions to a file instead of printing them.',
exclusive: ['copy'],
parse: async (input) => resolvePath(input),
env: 'SHOPIFY_FLAG_APP_DOCTOR_INSTRUCTIONS_WRITE',
}),
}

public async run(): Promise<void> {
const {args, flags} = await this.parse(DoctorInstructions)

await deliverAppDoctorInstructions({
directory: args.directory ?? cwd(),
copy: flags.copy,
writePath: flags.write,
})
}
}
12 changes: 12 additions & 0 deletions packages/app/src/cli/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import {commands} from './index.js'
import DoctorInstructions from './commands/app/doctor/instructions.js'
import Doctor from './commands/app/doctor.js'
import {describe, expect, test} from 'vitest'

describe('@shopify/app command registration', () => {
test('registers App Doctor commands', () => {
expect(commands['app:doctor:instructions']).toBe(DoctorInstructions)
expect(commands['app:doctor']).toBe(Doctor)
expect(commands['app:doctor:scan']).toBeUndefined()
})
})
4 changes: 4 additions & 0 deletions packages/app/src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import ConfigPull from './commands/app/config/pull.js'
import DemoWatcher from './commands/app/demo/watcher.js'
import Deploy from './commands/app/deploy.js'
import Dev from './commands/app/dev.js'
import DoctorInstructions from './commands/app/doctor/instructions.js'
import Doctor from './commands/app/doctor.js'
import Logs from './commands/app/logs.js'
import Sources from './commands/app/app-logs/sources.js'
import EnvPull from './commands/app/env/pull.js'
Expand Down Expand Up @@ -48,6 +50,8 @@ export const commands: {[key: string]: typeof AppLinkedCommand | typeof AppUnlin
'app:deploy': Deploy,
'app:dev': Dev,
'app:dev:clean': DevClean,
'app:doctor:instructions': DoctorInstructions,
'app:doctor': Doctor,
'app:logs': Logs,
'app:logs:sources': Sources,
'app:import-custom-data-definitions': ImportCustomDataDefinitions,
Expand Down
Loading
Loading