From b707ae085923b27a63c8c655ccab63d3dc624042 Mon Sep 17 00:00:00 2001 From: Mario Tarosso Date: Mon, 26 Oct 2026 11:18:27 +0000 Subject: [PATCH 1/2] Add a doctor command that checks everything and says how to fix it `npx @patchstack/connect doctor` runs every check Patchstack depends on in one go and prints each problem with its fix: the install, the site, the account, the guard's wiring, its rules file, the read-only rules credential (and whether Patchstack still accepts it), the map link and the build steps. With --url it also checks the live app: that it carries the widget, and that its guard blocks a test attack. Problems only the app's owner can fix (connecting the account, publishing) are marked as theirs, so an assistant fixes the rest and passes those on instead of waiting on them. It writes nothing and exits 1 when it finds a problem. AGENT-INSTALL.md tells the agent to run it after setup and fix each problem until none is left, and discloses what it sends. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENT-INSTALL.md | 5 +- README.md | 2 + src/cli.ts | 29 ++++- src/doctor.ts | 274 +++++++++++++++++++++++++++++++++++++++++++ tests/doctor.test.ts | 163 +++++++++++++++++++++++++ 5 files changed, 470 insertions(+), 3 deletions(-) create mode 100644 src/doctor.ts create mode 100644 tests/doctor.test.ts diff --git a/AGENT-INSTALL.md b/AGENT-INSTALL.md index 8d07487..990371c 100644 --- a/AGENT-INSTALL.md +++ b/AGENT-INSTALL.md @@ -62,7 +62,8 @@ The pages get a build step, even though nothing is compiled. The build is what r 2. Install `@patchstack/connect` as a runtime dependency with the project's package manager. If it is missing, `setup` adds it itself. 3. Run `npx @patchstack/connect setup`. In a hosted builder, scope `PATCHSTACK_ENVIRONMENT=sandbox` to that command — see "Automated setup". 4. Finish any `✘` line under `To do` in the report at the end of `setup`. "Automated setup" names each one. Do not start, restart or stop the person's app, and do not touch its data, to check the install: `setup` never runs the app, and the protection check needs an app the person already has running. -5. End by passing on the `──── Summary ────` that `setup` prints: what Patchstack found, the link, what is left to do and the next step, with a reminder to deploy when they are ready. See "The message you end on". +5. Run `npx @patchstack/connect doctor`. Fix every `✗` it reports, as its `Fix:` line says, and run it again until none is left. A `✗` marked "for the app owner" (connecting their Patchstack account, publishing the app) is not yours to fix: the summary already hands it to the person. +6. End by passing on the `──── Summary ────` that `setup` prints: what Patchstack found, the link, what is left to do and the next step, with a reminder to deploy when they are ready. See "The message you end on". **In a workspace repo** — a root `package.json` that declares `workspaces`, with the app in a package such as `apps/web` (Hostinger Horizons generates this shape) — run every command from the repo root. The site is recorded in the root's `.patchstackrc.json`, and `setup` wires the guard and the widget into the app package from there. Do not run `setup` inside the app package, and do not create a `.patchstackrc.json` there. When `setup`, `scan`, `widget` or `protect` is run inside a package of a repo whose root records the site, it runs at the root instead and prints `Running at the project root (…)`. @@ -85,6 +86,7 @@ Every command at a glance — what it does, whether it reads your source, what i | `guide` | Print this project's setup status: what is left to do and the four steps, with tailored commands. `--full` adds this reference. | Reads local protection wiring for verification | Nothing | Nothing | | `status` | Re-print the site UUID + dashboard URL and check whether the site still exists (active / removed / could not verify) and whether it is connected to an account. | No | The connected note in `.patchstackrc.json`, when it changed | Site-existence and connection checks | | `next` | Say where the project stands and the one thing to do now: connect the account (with the link), or go live (with the same steps `go-live` prints, or asking where the app is hosted when the project does not say). Answers the "done" the summary ends on. | Reads the project files `guide` and `go-live` read | The connected note in `.patchstackrc.json`, when it changed | The site UUID, to the public claim page that says whether it is connected (skipped once `.patchstackrc.json` records that it is) | +| `doctor [--url ]` | Check that everything Patchstack needs works, and print each problem with its fix: the install, the site, the account, the guard's wiring, its rules file, the read-only rules credential, the map link and the build steps. A problem only the app's owner can fix (connecting the account, publishing) is marked as theirs. Exits 1 when it finds a problem. | Reads the project files `guide` and `protect --check` read | Nothing | The site UUID, to the public claim page that says whether it is connected (skipped once `.patchstackrc.json` records that it is); the read-only rules credential from the guard's rules file, once, to `monitor/pulse/token`, to check Patchstack still accepts it (the token it returns is not kept or used); with `--url`, two GET requests to that address: its page, and the same page with a test attack in a `file` parameter | | `vulns` | Show what Patchstack found in this project's packages: vulnerabilities by severity and outdated packages, as counts, with the link that shows the details. Package names are shown in the dashboard once the site is connected. | No | Nothing | The site UUID, to the public claim page that returns the counts | | `go-live [--host ]` | Show how to add the project's API key to its host and deploy, in three steps. The host is read from project files (`vercel.json`, `netlify.toml`, `wrangler.toml`, `fly.toml`, `render.yaml`, `railway.json`, `amplify.yml`, `Procfile`, `.replit`, a Lovable project's packages) or named with `--host`. The commands it prints read the key from `.patchstackrc.local.json` as they run; it never prints the key. | Reads those project files only | Nothing | Nothing | | `widget [--remove]` | Add the Patchstack Connector widget tag to the root page (in a workspace repo, the app package's root page), or take the tag Connect added out with `--remove` (a tag added by hand is left alone). Saves the choice as `"widget"` in `.patchstackrc.json`, which `scan` and `mark-build` follow; `setup --widget` saves the same choice. | Reads the root page to find where the tag goes | `.patchstackrc.json`; the widget ``, { status: page }); + }) as unknown as typeof fetch; + return { fetchImpl, calls }; +} + +const claimed = async (): Promise => ({ state: 'claimed', summary: null }); +async function doctor(cwd: string, options: { url?: string; claimPreview?: () => Promise; net?: ReturnType; config?: Partial } = {}) { + const net = options.net ?? network(); + const state = await collectGuideState(cwd); + return runDoctorChecks(cwd, state, config(options.config), { url: options.url ?? null, fetchImpl: net.fetchImpl, claimPreview: options.claimPreview ?? claimed }); +} +const problems = (findings: DoctorFinding[]) => findings.filter((f) => f.status === 'problem').map((f) => f.text); + +describe('doctor', () => { + it('finds no problem in a project set up the way setup leaves it', async () => { + const findings = await doctor(project()); + expect(problems(findings)).toEqual([]); + expect(findings.map((f) => f.text)).toContain('The read-only rules credential works: the published app can fetch rule updates and report attacks'); + expect(renderDoctor(findings).join('\n')).toContain('No problems found.'); + }); + + it('says what to run when the project is not on Patchstack', async () => { + const cwd = project(); + rmSync(join(cwd, '.patchstackrc.json')); + expect(await doctor(cwd, { config: { siteUuid: null } as never })).toEqual([ + { status: 'problem', text: 'This project is not on Patchstack yet', fix: ['Run: npx @patchstack/connect setup'] }, + ]); + }); + + it('flags a site with no owner as report-only, with the link to connect it', async () => { + const cwd = project(); + writeFileSync(join(cwd, '.patchstackrc.json'), JSON.stringify({ siteUuid: UUID, widget: false })); + const findings = await doctor(cwd, { claimPreview: async () => ({ state: 'unclaimed', summary: null }) }); + const finding = findings.find((f) => f.text.startsWith('Not connected')); + expect(finding?.status).toBe('problem'); + expect(finding?.fix?.[0]).toContain(`claim?site=${UUID}`); + expect(findings.filter((f) => /connected/i.test(f.text))).toHaveLength(1); + }); + + it('flags a rules file without the read-only credential, and one Patchstack refuses', async () => { + expect(problems(await doctor(project({ build_id: 'b'.repeat(64) })))).toEqual([ + "The guard's rules file has no read-only rules credential, so the published app cannot fetch rule updates or report attacks without the site key", + ]); + expect(problems(await doctor(project(), { net: network({ token: 401 }) }))).toEqual([ + 'Patchstack refused the read-only rules credential (the site key was replaced, or the site was removed)', + ]); + const offline = await doctor(project(), { net: network({ token: 'down' }) }); + expect(problems(offline)).toEqual([]); + expect(offline.find((f) => f.status === 'unknown')?.text).toBe('Could not reach Patchstack to check the read-only rules credential'); + }); + + it('flags a map that is not linked to the guard', async () => { + expect(problems(await doctor(project({ rules_reader: READER })))).toEqual([ + "The app's map is not linked to the guard, so rules written for this app's own inputs can only report, not block", + ]); + }); + + it('flags a guard that is not wired', async () => { + const cwd = project(); + writeFileSync(join(cwd, 'server.js'), "import http from 'node:http';\nhttp.createServer((req, res) => res.end('ok')).listen(3000);\n"); + expect(problems(await doctor(cwd))).toEqual(['Blocking attacks needs a server']); + }); + + describe('with --url', () => { + it('passes a live app that carries the widget and blocks the test attack', async () => { + const cwd = project(); + writeFileSync(join(cwd, '.patchstackrc.json'), JSON.stringify({ siteUuid: UUID, widget: true, claimed: true })); + const findings = await doctor(cwd, { url: 'https://app.example' }); + expect(findings.filter((f) => f.text.startsWith('The live app')).map((f) => [f.status, f.text])).toEqual([ + ['ok', 'The live app carries the Patchstack widget for this site'], + ['ok', "The live app's guard blocked a test attack"], + ]); + }); + + it('flags a live app that lets the test attack through', async () => { + const findings = await doctor(project(), { url: 'https://app.example', net: network({ attack: 200 }) }); + expect(problems(findings)).toEqual(['The live app let a test attack through (it answered 200 to ?file=../../../../etc/passwd)']); + }); + + it('sends no test attack to a page that does not load', async () => { + const net = network({ page: 404 }); + const findings = await doctor(project(), { url: 'https://app.example', net }); + expect(findings.find((f) => f.text.startsWith('The live app'))?.status).toBe('unknown'); + expect(net.calls.some((url) => url.includes('file='))).toBe(false); + }); + }); + + it('ends by telling whoever runs it to fix each problem and run it again', () => { + const lines = renderDoctor([{ status: 'problem', text: 'x', fix: ['Run: a', 'Then: b'] }], { url: 'https://app.example' }).join('\n'); + expect(lines).toContain(' ✗ x\n Fix: Run: a\n Then: b'); + expect(lines).toContain('1 problem to fix in the project. Fix each ✗ above as its Fix line says, then run npx @patchstack/connect doctor again until none is left.'); + }); + + it('keeps the owner\'s steps apart from what the project needs, so an assistant does not wait on them', () => { + const lines = renderDoctor([{ status: 'problem', text: 'Not connected', fix: ['Open the link'], owner: true }]).join('\n'); + expect(lines).toContain('✗ Not connected (for the app owner)'); + expect(lines).not.toContain('to fix in the project'); + expect(lines).toContain('1 step for the app owner'); + }); +}); From 7696f1e204f77c7186d91fa662d819f81e26482f Mon Sep 17 00:00:00 2001 From: Mario Tarosso Date: Mon, 26 Oct 2026 11:44:16 +0000 Subject: [PATCH 2/2] doctor: say whether the published app fetches its rules doctor now reads Patchstack's record of the app's rules fetches from monitor/pulse/sync-status/, with the token from the credential it already checks (the site key when this machine holds one, otherwise the read-only credential). It says when the published app last fetched its rules and how many, and never fetches rules itself, which would be recorded as a fetch and fake the signal. An app with no visitors does not fetch, so an old or missing fetch is a question until doctor --url has just visited the app, and a problem after. A fetch only on the site key is reported as such, not as the published app. A server without the endpoint is reported as not reporting syncing yet. AGENT-INSTALL.md discloses the extra token request and the read. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENT-INSTALL.md | 2 +- src/cli.ts | 10 +-- src/client.ts | 7 +++ src/doctor.ts | 145 ++++++++++++++++++++++++++++++++++++------- tests/doctor.test.ts | 54 ++++++++++++++-- 5 files changed, 185 insertions(+), 33 deletions(-) diff --git a/AGENT-INSTALL.md b/AGENT-INSTALL.md index 990371c..020c28e 100644 --- a/AGENT-INSTALL.md +++ b/AGENT-INSTALL.md @@ -86,7 +86,7 @@ Every command at a glance — what it does, whether it reads your source, what i | `guide` | Print this project's setup status: what is left to do and the four steps, with tailored commands. `--full` adds this reference. | Reads local protection wiring for verification | Nothing | Nothing | | `status` | Re-print the site UUID + dashboard URL and check whether the site still exists (active / removed / could not verify) and whether it is connected to an account. | No | The connected note in `.patchstackrc.json`, when it changed | Site-existence and connection checks | | `next` | Say where the project stands and the one thing to do now: connect the account (with the link), or go live (with the same steps `go-live` prints, or asking where the app is hosted when the project does not say). Answers the "done" the summary ends on. | Reads the project files `guide` and `go-live` read | The connected note in `.patchstackrc.json`, when it changed | The site UUID, to the public claim page that says whether it is connected (skipped once `.patchstackrc.json` records that it is) | -| `doctor [--url ]` | Check that everything Patchstack needs works, and print each problem with its fix: the install, the site, the account, the guard's wiring, its rules file, the read-only rules credential, the map link and the build steps. A problem only the app's owner can fix (connecting the account, publishing) is marked as theirs. Exits 1 when it finds a problem. | Reads the project files `guide` and `protect --check` read | Nothing | The site UUID, to the public claim page that says whether it is connected (skipped once `.patchstackrc.json` records that it is); the read-only rules credential from the guard's rules file, once, to `monitor/pulse/token`, to check Patchstack still accepts it (the token it returns is not kept or used); with `--url`, two GET requests to that address: its page, and the same page with a test attack in a `file` parameter | +| `doctor [--url ]` | Check that everything Patchstack needs works, and print each problem with its fix: the install, the site, the account, the guard's wiring, its rules file, the read-only rules credential, the map link, the build steps, and whether the published app fetches its rules. A problem only the app's owner can fix (connecting the account, publishing) is marked as theirs. Exits 1 when it finds a problem. | Reads the project files `guide` and `protect --check` read | Nothing | The site UUID, to the public claim page that says whether it is connected (skipped once `.patchstackrc.json` records that it is); the read-only rules credential from the guard's rules file, and the site key when this machine holds one, each once to `monitor/pulse/token`, to check Patchstack accepts them; one GET with that token to `monitor/pulse/sync-status/`, which answers only when the published app (read-only credential) and a key-holding copy last fetched rules, and how many (doctor never fetches rules itself, which would be recorded as a fetch); with `--url`, two GET requests to that address: its page, and the same page with a test attack in a `file` parameter, which another firewall in front of the app may also flag | | `vulns` | Show what Patchstack found in this project's packages: vulnerabilities by severity and outdated packages, as counts, with the link that shows the details. Package names are shown in the dashboard once the site is connected. | No | Nothing | The site UUID, to the public claim page that returns the counts | | `go-live [--host ]` | Show how to add the project's API key to its host and deploy, in three steps. The host is read from project files (`vercel.json`, `netlify.toml`, `wrangler.toml`, `fly.toml`, `render.yaml`, `railway.json`, `amplify.yml`, `Procfile`, `.replit`, a Lovable project's packages) or named with `--host`. The commands it prints read the key from `.patchstackrc.local.json` as they run; it never prints the key. | Reads those project files only | Nothing | Nothing | | `widget [--remove]` | Add the Patchstack Connector widget tag to the root page (in a workspace repo, the app package's root page), or take the tag Connect added out with `--remove` (a tag added by hand is left alone). Saves the choice as `"widget"` in `.patchstackrc.json`, which `scan` and `mark-build` follow; `setup --widget` saves the same choice. | Reads the root page to find where the tag goes | `.patchstackrc.json`; the widget ``, { status: page }); }) as unknown as typeof fetch; - return { fetchImpl, calls }; + return { fetchImpl, calls, auth }; } const claimed = async (): Promise => ({ state: 'claimed', summary: null }); async function doctor(cwd: string, options: { url?: string; claimPreview?: () => Promise; net?: ReturnType; config?: Partial } = {}) { const net = options.net ?? network(); const state = await collectGuideState(cwd); - return runDoctorChecks(cwd, state, config(options.config), { url: options.url ?? null, fetchImpl: net.fetchImpl, claimPreview: options.claimPreview ?? claimed }); + return runDoctorChecks(cwd, state, config(options.config), { url: options.url ?? null, fetchImpl: net.fetchImpl, claimPreview: options.claimPreview ?? claimed, settleMs: 0 }); } const problems = (findings: DoctorFinding[]) => findings.filter((f) => f.status === 'problem').map((f) => f.text); @@ -148,6 +156,40 @@ describe('doctor', () => { }); }); + describe('whether the published app fetches its rules', () => { + const syncText = (findings: DoctorFinding[]) => findings.find((f) => /fetched|never fetched|rules syncing/.test(f.text)); + + it('passes an app that fetched them recently, reading the record with the token and never fetching rules itself', async () => { + const net = network(); + const finding = syncText(await doctor(project(), { net })); + expect(finding).toMatchObject({ status: 'ok', text: 'The published app fetched its rules from Patchstack 3 minutes ago (17 rules)' }); + expect(net.auth).toEqual(['Bearer reader-token']); + expect(net.calls.some((url) => url.includes('/rules/'))).toBe(false); + }); + + it('treats an old fetch as a question until doctor has visited the app, then as a problem', async () => { + const stale = { reader: { at: minutesAgo(300), rule_count: 17 } }; + expect(syncText(await doctor(project(), { net: network({ sync: stale }) }))?.status).toBe('unknown'); + const visited = syncText(await doctor(project(), { url: 'https://app.example', net: network({ sync: stale }) })); + expect(visited).toMatchObject({ status: 'problem', text: 'The published app last fetched its rules from Patchstack 5 hours ago (17 rules), and did not fetch them when doctor just visited it' }); + }); + + it('flags an app that has never fetched them once doctor has visited it', async () => { + const finding = syncText(await doctor(project(), { url: 'https://app.example', net: network({ sync: { reader: null } }) })); + expect(finding).toMatchObject({ status: 'problem', text: 'The published app has never fetched its rules from Patchstack, even when doctor just visited it' }); + }); + + it('does not call a key-holding copy the published app', async () => { + const finding = syncText(await doctor(project(), { net: network({ sync: { reader: null, site_key: { at: minutesAgo(10), rule_count: 17 } } }) })); + expect(finding?.status).toBe('unknown'); + expect(finding?.text).toContain('Only a copy holding the site key has fetched rules (10 minutes ago)'); + }); + + it('says so when Patchstack does not report syncing yet', async () => { + expect(syncText(await doctor(project(), { net: network({ sync: 404 }) }))).toMatchObject({ status: 'unknown', text: 'Patchstack does not report rules syncing for this site yet' }); + }); + }); + it('ends by telling whoever runs it to fix each problem and run it again', () => { const lines = renderDoctor([{ status: 'problem', text: 'x', fix: ['Run: a', 'Then: b'] }], { url: 'https://app.example' }).join('\n'); expect(lines).toContain(' ✗ x\n Fix: Run: a\n Then: b');