Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
af95c3e
Add App Doctor scan command
jplhomer Aug 28, 2026
bb13882
Add App Doctor agent instructions
jplhomer Aug 31, 2026
c66290d
Prioritize copying App Doctor instructions
jplhomer Aug 31, 2026
8930172
Complete App Doctor deterministic scan
jplhomer Sep 1, 2026
3e95b63
Simplify App Doctor Git and filesystem access
jplhomer Sep 1, 2026
e3241dc
Rename App Doctor test suites
jplhomer Sep 1, 2026
81be795
Render App Doctor findings with CLI UI banners
jplhomer Sep 1, 2026
66a786f
Reuse CLI Kit glob and TOML utilities
jplhomer Sep 1, 2026
e6f886b
Fix App Doctor ReDoS, Knip exports, and CLI test
jplhomer Sep 1, 2026
922b1e1
Fix App Doctor prefer-template lint
jplhomer Sep 1, 2026
01c55be
Fix Windows app root discovery test
jplhomer Sep 1, 2026
91159a1
Fix App Doctor client ID false positives, large-app traces, and npm a…
jplhomer Sep 1, 2026
57b5efd
Narrow App Doctor CVE findings to production packages
jplhomer Sep 1, 2026
3380b7e
Improve App Doctor UX for unsupported backends and agent compiles
jplhomer Sep 1, 2026
6319f4c
Stop exporting App Doctor internal ParsedAdvisory type
jplhomer Sep 1, 2026
3cdb978
Remove App Doctor coverage-incomplete warning copy
jplhomer Sep 1, 2026
fe8b5b3
Use --path instead of a positional directory for App Doctor
jplhomer Sep 1, 2026
0aab6f4
Wait for App Doctor audit processes to stop before sandbox cleanup
jplhomer Sep 2, 2026
5615b70
Generate App Doctor instructions from the resolved app root
jplhomer Sep 2, 2026
65aeeb5
Convert App Doctor path discovery failures to AbortError
jplhomer Sep 2, 2026
b12165e
Warn when App Doctor coverage is incomplete
jplhomer Sep 2, 2026
82a26cb
Guard App Doctor rejection check ID parsing
jplhomer Sep 2, 2026
fb49719
Add App Doctor findings file boundary tests
jplhomer Sep 2, 2026
143c2c4
Remove unused agentic tier from App Doctor check frontmatter
jplhomer Sep 2, 2026
be20595
Route App Doctor service imports through the engine facade
jplhomer Sep 2, 2026
fda80f6
Move App Doctor evidence types out of the rules layer
jplhomer Sep 2, 2026
a874409
Reuse CLI Kit sha256 in App Doctor hashing
jplhomer Sep 2, 2026
b87226a
Remove unused App Doctor WebhookSubscription re-export
jplhomer Sep 2, 2026
edb5370
Escape backslashes in App Doctor Windows path quoting
jplhomer Sep 2, 2026
451c42d
Document App Doctor scan boundaries versus Project.load
jplhomer Sep 2, 2026
1caf14f
Reuse CLI app config schema fragments in App Doctor
jplhomer Sep 2, 2026
f2d295c
Fix App Doctor unconfigured-extension test syntax
jplhomer Sep 2, 2026
e5ef447
Split App Doctor execution, artifacts, and JSON output
jplhomer Sep 2, 2026
e76d88f
Add remaining App Doctor command-boundary tests
jplhomer Sep 2, 2026
eebf26c
Hide unused App Doctor artifact and JSON exports
jplhomer Sep 2, 2026
3e4f16a
Stabilize App Doctor audit process tests across platforms
jplhomer Sep 2, 2026
8ec8809
Run Windows package-manager audits through cmd.exe shims
jplhomer Sep 2, 2026
d5192b5
Fix App Doctor Windows spawn types and JSON fixture comparison
jplhomer Sep 2, 2026
c26df98
Document App Doctor CVE audits against the public npm registry
jplhomer Sep 2, 2026
38a916a
Fix Windows App Doctor npm audit cmd quoting
jplhomer Sep 2, 2026
252171e
Keep Windows cmd audit quoting helper unexported
jplhomer Sep 2, 2026
f9f2560
Resolve Windows App Doctor audit commands to absolute shims
jplhomer Sep 2, 2026
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
2 changes: 2 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 @@ -64,6 +65,7 @@
"@shopify/theme-check-node": "3.29.0",
"@shopify/toml-patch": "0.3.0",
"chokidar": "3.6.0",
"clipboardy": "4.0.0",
"csv-parse": "7.0.2",
"diff": "5.2.2",
"esbuild": "0.28.1",
Expand Down
74 changes: 74 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,74 @@
import Doctor from './doctor.js'
import {appFlags} from '../../flags.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)
expect(Doctor.flags.path).toBe(appFlags.path)
expect(Doctor.args).not.toHaveProperty('directory')
})

test('forwards --path and flags to the service', async () => {
await Doctor.run(
['--path', './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(['--path', '/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')
expect(Doctor.descriptionWithMarkdown).toContain('npm audit')
expect(Doctor.descriptionWithMarkdown).toContain('https://registry.npmjs.org/')
})

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}))
})
})
66 changes: 66 additions & 0 deletions packages/app/src/cli/commands/app/doctor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import {appFlags} from '../../flags.js'
import doctor from '../../services/doctor.js'
import {Flags} from '@oclif/core'
import BaseCommand from '@shopify/cli-kit/node/base-command'
import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli'
import {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.

CVE detection runs \`npm audit\` (or the pnpm/yarn equivalent) in an isolated sandbox and sends package names and versions to the public npm registry at https://registry.npmjs.org/.`

static description = this.descriptionWithoutMarkdown()

static flags = {
...globalFlags,
path: appFlags.path,
...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 {flags} = await this.parse(Doctor)

await doctor({
directory: flags.path,
json: flags.json,
verbose: Boolean(flags.verbose),
blocking: flags.blocking as AppDoctorBlockingLevel,
yes: flags.yes,
skipInstructions: flags['skip-instructions'],
findingsPath: flags.findings,
})
}
}
54 changes: 54 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,54 @@
import DoctorInstructions from './instructions.js'
import {appFlags} from '../../../flags.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)
expect(DoctorInstructions.flags.path).toBe(appFlags.path)
expect(DoctorInstructions.args).not.toHaveProperty('directory')
})

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 --path and --copy', async () => {
await DoctorInstructions.run(['--path', './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'])
})
})
45 changes: 45 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,45 @@
import {appFlags} from '../../../flags.js'
import deliverAppDoctorInstructions from '../../../services/app-doctor-instructions.js'
import {Flags} from '@oclif/core'
import BaseCommand from '@shopify/cli-kit/node/base-command'
import {globalFlags} from '@shopify/cli-kit/node/cli'
import {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 flags = {
...globalFlags,
path: appFlags.path,
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 {flags} = await this.parse(DoctorInstructions)

await deliverAppDoctorInstructions({
directory: flags.path,
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 @@ -52,6 +54,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
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,20 @@ import {BaseSchemaWithoutHandle} from '../schemas.js'
import {normalizeDelimitedString} from '@shopify/cli-kit/common/string'
import {zod} from '@shopify/cli-kit/node/schema'

export const AppAccessScopesSchema = zod.object({
scopes: zod
.string()
.transform((scopes) => normalizeDelimitedString(scopes) ?? '')
.optional(),
required_scopes: zod.array(zod.string()).optional(),
optional_scopes: zod.array(zod.string()).optional(),
use_legacy_install_flow: zod.boolean().optional(),
})

export const AppAuthSchema = zod.object({
redirect_urls: zod.array(validateUrl(zod.string())),
})

const AppAccessSchema = BaseSchemaWithoutHandle.extend({
access: zod
.object({
Expand All @@ -15,20 +29,8 @@ const AppAccessSchema = BaseSchemaWithoutHandle.extend({
.optional(),
})
.optional(),
access_scopes: zod
.object({
scopes: zod
.string()
.transform((scopes) => normalizeDelimitedString(scopes) ?? '')
.optional(),
required_scopes: zod.array(zod.string()).optional(),
optional_scopes: zod.array(zod.string()).optional(),
use_legacy_install_flow: zod.boolean().optional(),
})
.optional(),
auth: zod.object({
redirect_urls: zod.array(validateUrl(zod.string())),
}),
access_scopes: AppAccessScopesSchema.optional(),
auth: AppAuthSchema,
})

export const AppAccessSpecIdentifier = 'app_access'
Expand Down
Loading
Loading