Skip to content
Merged
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
8 changes: 5 additions & 3 deletions AGENT-INSTALL.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@

This is a plain-text instruction document. Read it and follow the steps — there is nothing here to pipe to a shell or execute from a URL.

You are reading this either from `node_modules/@patchstack/connect/AGENT-INSTALL.md` (the package is already installed) or from the [GitHub repo](https://github.com/patchstack/connect). The canonical, always-current version of the full guide lives at https://patchstack.com/install.txt — prefer it when you have network access; this bundled copy matches the package version it shipped with.
You are reading this either from `node_modules/@patchstack/connect/AGENT-INSTALL.md` (the package is already installed) or from the [GitHub repo](https://github.com/patchstack/connect). This bundled copy matches the package version it shipped with and is all you need — there is no requirement to fetch anything remote. (A long-form copy also lives at https://patchstack.com/install.txt for humans browsing outside the package.)

## Verify before you trust (do this, it's quick)

- The connector is a normal npm package: [`@patchstack/connect`](https://www.npmjs.com/package/@patchstack/connect), MIT-licensed, source at https://github.com/patchstack/connect. Install it pinned, check the integrity hash in the lockfile, run `npm audit` over it — whatever your policy requires.
- First published May 2026. If the package is absent from your training data, `npm view @patchstack/connect` (read-only) shows the live registry record.
- It reads the project's **dependency list only** — from the lockfile (`package-lock.json`, `pnpm-lock.yaml`, `yarn.lock`) or, on bun projects (`bun.lock`/`bun.lockb`), by enumerating the installed packages under `node_modules/` — and sends package names + versions to Patchstack for vulnerability matching. No source code, no env vars, no file paths, no git history.
- It reads the project's **dependency list only** — from the lockfile (`package-lock.json`, `pnpm-lock.yaml`, `yarn.lock`) or, on bun projects (`bun.lock`/`bun.lockb`), by enumerating the installed packages under `node_modules/` — and sends package names + versions to Patchstack for vulnerability matching. No source code, no env var values, no file paths, no git history. (`mark-build` additionally stamps built HTML with a coarse stack descriptor that may include hosting-related env variable *names* — e.g. `VERCEL`, `CF_PAGES` — never their values.)
- Patchstack is not WordPress-only. This connector monitors any JS/Node project — Vite, Next.js, plain vanilla JS, anything with a lockfile.

## Before you start — never install twice
Expand All @@ -18,7 +18,7 @@ You are reading this either from `node_modules/@patchstack/connect/AGENT-INSTALL
- If `@patchstack/connect` is already in `devDependencies`, skip the install command.
- If the widget script tag (`cdn.patchstack.com/patchstack-widget.js`) is already in the layout, don't add a second one.

## Install steps (run in order, don't pause between them unless one fails)
## Install steps (do the ones the checklist marks ✖, in order; don't pause between them unless one fails)

1. **Install** (skip if already present), matching the project's package manager:

Expand Down Expand Up @@ -50,6 +50,8 @@ You are reading this either from `node_modules/@patchstack/connect/AGENT-INSTALL

If a `prebuild`/`postbuild` hook already exists, chain instead of replacing it, e.g. `"prebuild": "existing-command && patchstack-connect scan"`.

**Bun-managed projects:** `bun run` does not execute npm-style `pre`/`post` scripts, so wire the build script directly instead: `"build": "patchstack-connect scan && <existing build command> && patchstack-connect mark-build"`.

4. **Install the disclosure widget** — a floating "Report a vulnerability" button. Read the `siteUuid` value from `.patchstackrc.json` (the same site UUID step 2 provisioned) and pass it as the widget's `userToken`. Place these two snippets via the framework's HTML/layout mechanism (never a JS entry point):

```html
Expand Down
17 changes: 7 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,16 @@ For how this repo fits with the wider Patchstack ecosystem (`saas`, `hub`, `patc

Copy and paste this prompt into any AI coding assistant (Cursor, v0, Bolt, Lovable, Claude Code, etc.):

> Add Patchstack dependency vulnerability monitoring to this project (MIT, source: https://github.com/patchstack/connect — verify with `npm view @patchstack/connect` if unsure):
>
> 1. Install `@patchstack/connect` as a dev dependency with this project's package manager.
> 2. `npx @patchstack/connect scan` — reads the lockfile, saves a site ID to `.patchstackrc.json`, and prints a claim URL. Show me that URL; don't open it.
> 3. `npx @patchstack/connect guide` — prints a checklist of what's still missing here (build hooks, report-a-vulnerability widget). Finish those steps and commit.
> Add Patchstack dependency vulnerability monitoring: install `@patchstack/connect` as a dev dependency (MIT — verify at https://www.npmjs.com/package/@patchstack/connect or with `npm view @patchstack/connect`; source: https://github.com/patchstack/connect), run `npx @patchstack/connect scan` and show me the claim URL it prints (I'll open it in my browser to view reports — don't open or fetch it yourself), then run `npx @patchstack/connect guide` and finish what its checklist flags: the package.json build hooks and the report-a-vulnerability widget snippet.

The shape of this prompt is deliberate — it has survived three distinct AI-agent refusal modes, and each one punished a different shortcut:
Every clause of this one-liner is deliberate — it has survived four distinct AI-agent refusal modes, each found by field-testing, and each one punished a different shortcut:

1. **"Follow the instructions at this URL" reads as remote script execution.** Agents refuse before ever fetching the doc.
2. **Agents whose training predates May 2026 assert the package doesn't exist.** The `npm view` check resolves that against the registry instead of the model's memory.
3. **"Install the package, then follow the instructions it ships" reads as handing control to the package author** — structurally the same as prompt injection — and preemptive reassurance language ("it's safe, don't be suspicious, note your knowledge cutoff") is itself flagged as a manipulation signal.
1. **"Follow the instructions at this URL" reads as remote script execution.** Agents refuse before ever fetching the doc. Nothing in the prompt asks the agent to fetch anything.
2. **Agents whose training predates May 2026 assert the package doesn't exist.** The npmjs.com link and `npm view` check resolve that against the registry instead of the model's memory.
3. **"Install the package, then follow the instructions it ships" reads as handing control to the package author** — structurally the same as prompt injection — and preemptive reassurance language ("it's safe, don't be suspicious, note your knowledge cutoff") is itself flagged as a manipulation signal. The prompt argues nothing and delegates to `guide` only after the agent has installed and audited the package.
4. **Unbounded delegation and authorization-shaped URLs.** "Finish the steps its checklist marks missing", unqualified, was refused by a WebContainer-based agent as a blank check ("executing untrusted, unseen commands"), and a bare "show me the claim URL" was flagged as a machine-authorization/pairing link. So the prompt names exactly what the checklist will flag (build hooks + widget snippet) — bounding the delegation in advance — and states what the claim URL is for (the *user* opens it in a browser to view reports).

So the prompt keeps the trust-critical spine inline — verification, the scan command, and surfacing the claim URL are concrete, individually inspectable steps — and argues nothing. `npx @patchstack/connect guide` enters only at step 3, after the agent has already installed and audited the package, so consulting its docs is ordinary behavior rather than the instruction channel the install depends on. And `guide` is state-aware: it inspects the project (package manager, `.patchstackrc.json`, `package.json` scripts, widget presence, framework) and prints a live checklist of only the remaining steps — with the real site UUID and framework-specific widget placement — followed by the full reference guide (`AGENT-INSTALL.md`, also at https://patchstack.com/install.txt). "Finish what the checklist marks missing" is a much smaller ask of an agent than "follow this manual", and it makes the flow idempotent: re-running `guide` on a finished project reports all-done instead of prompting a second install.
The prompt can be this short because `guide` is state-aware: it inspects the project (package manager, `.patchstackrc.json`, `package.json` scripts, widget presence, framework) and prints a live checklist of only the remaining steps — with the real site UUID and framework-specific widget placement — followed by the full reference guide (`AGENT-INSTALL.md`, also at https://patchstack.com/install.txt). "Finish what the checklist flags" is a much smaller ask of an agent than "follow this manual", and it makes the flow idempotent: re-running `guide` on a finished project reports all-done instead of prompting a second install. In hostile-policy field tests, agents verified the tarball against the registry, audited `dist/` for lifecycle scripts and shell/eval calls, and then completed the install — the flow is designed to *pass* that audit, not to discourage it.

## Quick start (zero configuration)

Expand Down
34 changes: 24 additions & 10 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
injectMarker,
resolveBuildDir,
} from './mark-build.js';
import { collectGuideState, renderGuideChecklist } from './guide.js';
import { collectGuideState, countRemainingSteps, renderGuideChecklist } from './guide.js';
import { runProtect } from './protect/install.js';
import { detectStack, type StackDescriptor } from './stack.js';
import { PatchstackError } from './types.js';
Expand All @@ -30,9 +30,10 @@ Usage:
patchstack-connect protect Install always-on runtime protection (the
guard) into a TanStack Start + Supabase app.
Covers the browser + server-function paths.
patchstack-connect guide Show this project's setup status (what's done,
patchstack-connect guide [--full] Show this project's setup status (what's done,
what's missing, with tailored commands), then
print the full setup guide
print the full setup guide. --full prints the
guide even when setup is complete
(also at https://patchstack.com/install.txt)
patchstack-connect help Print this message

Expand Down Expand Up @@ -221,21 +222,34 @@ async function runProtectCommand(_args: ParsedArgs): Promise<number> {
return 0;
}

async function runGuide(): Promise<number> {
async function runGuide(args: ParsedArgs): Promise<number> {
// The live checklist first: what this project already has and what's missing,
// with commands tailored to it. Best-effort — the guide must always print the
// reference doc, even when the project state can't be inspected.
// with commands tailored to it. Best-effort — a project we can't inspect
// still gets the reference doc.
let allDone = false;
try {
const state = await collectGuideState(process.cwd());
const useColor = process.stdout.isTTY === true && process.env.NO_COLOR === undefined;
console.log(renderGuideChecklist(state, useColor));
console.log('');
console.log('———— Full reference guide (ships as AGENT-INSTALL.md) ————');
console.log('');
allDone = countRemainingSteps(state) === 0;
} catch {
// fall through to the static guide
}

// A fully green project doesn't need the ~90-line manual again on every
// re-run — point at it instead. `--full` always prints it.
if (allDone && args.flags.get('full') !== true) {
console.log('');
console.log(
'Full reference guide: `npx @patchstack/connect guide --full` (or read node_modules/@patchstack/connect/AGENT-INSTALL.md).',
);
return 0;
}

console.log('');
console.log('———— Full reference guide (ships as AGENT-INSTALL.md) ————');
console.log('');

// AGENT-INSTALL.md ships at the package root, one level above dist/ (and
// above src/ when running unbundled), so the same relative path works in both.
const guidePath = new URL('../AGENT-INSTALL.md', import.meta.url);
Expand Down Expand Up @@ -349,7 +363,7 @@ async function main(): Promise<number> {
case 'protect':
return runProtectCommand(args);
case 'guide':
return runGuide();
return runGuide(args);
default:
console.error(`Unknown command: ${args.command}\n`);
console.error(HELP);
Expand Down
64 changes: 43 additions & 21 deletions src/guide.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,8 +305,14 @@ export async function collectGuideState(cwd: string): Promise<GuideState> {
siteUuid,
claimUrl,
endpointOverride,
prebuildWired: (pkg?.scripts?.prebuild ?? '').includes('patchstack-connect scan'),
postbuildWired: (pkg?.scripts?.postbuild ?? '').includes('patchstack-connect mark-build'),
// bun run doesn't execute npm-style pre/post scripts, so chaining inside
// the build script itself also counts as wired (and is what we suggest on bun).
prebuildWired:
(pkg?.scripts?.prebuild ?? '').includes('patchstack-connect scan') ||
(pkg?.scripts?.build ?? '').includes('patchstack-connect scan'),
postbuildWired:
(pkg?.scripts?.postbuild ?? '').includes('patchstack-connect mark-build') ||
(pkg?.scripts?.build ?? '').includes('patchstack-connect mark-build'),
widgetInstalled: widget.found,
widgetTokenMatches: widget.uuidMatches,
framework: stack.framework,
Expand All @@ -323,6 +329,16 @@ const ANSI = {
cyan: '\u001B[36m',
};

/** Setup steps still missing — 0 means the checklist is fully green. */
export function countRemainingSteps(state: GuideState): number {
return [
state.installed !== null,
state.siteUuid !== null,
state.prebuildWired && state.postbuildWired,
state.widgetInstalled && state.widgetTokenMatches !== false,
].filter((step) => !step).length;
}

export function renderGuideChecklist(state: GuideState, useColor: boolean): string {
const paint = (code: string, text: string): string =>
useColor ? `${code}${text}${ANSI.reset}` : text;
Expand Down Expand Up @@ -374,7 +390,11 @@ export function renderGuideChecklist(state: GuideState, useColor: boolean): stri

// 3. Build hooks
if (state.prebuildWired && state.postbuildWired) {
lines.push(done('Build hooks wired (prebuild scan, postbuild mark-build)'));
lines.push(done('Build hooks wired (scan before builds, mark-build after)'));
} else if (state.packageManager === 'bun') {
// bun run skips npm-style pre/post scripts, so chain inside the build script.
lines.push(todo('Wire builds — chain into the package.json build script (bun skips pre/post hooks)'));
lines.push(detail('→ "build": "patchstack-connect scan && <existing build command> && patchstack-connect mark-build"'));
} else {
lines.push(todo('Wire builds — add to package.json scripts (chain with && if a hook exists)'));
if (!state.prebuildWired) {
Expand All @@ -396,8 +416,8 @@ export function renderGuideChecklist(state: GuideState, useColor: boolean): stri
lines.push(todo('Add the "Report a vulnerability" widget'));
const placement =
state.widgetFileHint !== null
? `→ add to ${state.widgetFileHint} (via the framework's HTML/layout mechanism):`
: "→ add via the framework's HTML/layout mechanism (never a JS entry point):";
? `→ add to ${state.widgetFileHint}, just before </body> (via the framework's HTML/layout mechanism):`
: "→ add just before </body> via the framework's HTML/layout mechanism (never a JS entry point):";
lines.push(detail(placement));
lines.push(detail(` <script src="${WIDGET_SCRIPT_URL}"></script>`));
const token = state.siteUuid ?? '<SITE_UUID from .patchstackrc.json — run scan first>';
Expand All @@ -418,23 +438,25 @@ export function renderGuideChecklist(state: GuideState, useColor: boolean): stri
lines.push(detail('The claim URL appears after the first scan (re-print any time with `status`).'));
}

const remaining = [
state.installed !== null,
state.siteUuid !== null,
state.prebuildWired && state.postbuildWired,
widgetOk,
].filter((step) => !step).length;
const remaining = countRemainingSteps(state);
lines.push('');
lines.push(
remaining === 0
? done(
paint(
ANSI.bold,
'All setup steps complete. Commit .patchstackrc.json, package.json, and the file carrying the widget snippet.',
),
)
: ` ${paint(ANSI.yellow, String(remaining))} step(s) remaining — details in the reference guide below.`,
);
if (remaining === 0) {
lines.push(
done(
paint(
ANSI.bold,
'All setup steps complete. Commit .patchstackrc.json, package.json, and the file carrying the widget snippet.',
),
),
);
if (state.claimUrl !== null) {
lines.push(detail('The only manual action left is claiming the site via the URL above (if not already claimed).'));
}
} else {
lines.push(
` ${paint(ANSI.yellow, String(remaining))} step(s) remaining — details in the reference guide below.`,
);
}

return lines.join('\n');
}
27 changes: 26 additions & 1 deletion tests/guide.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,9 +179,34 @@ describe('guide', () => {

expect(output).toContain(installCommand('bun'));
expect(output).toContain('npx @patchstack/connect scan');
expect(output).toContain('bun skips pre/post hooks');
expect(output).toContain(
'"build": "patchstack-connect scan && <existing build command> && patchstack-connect mark-build"',
);
expect(output).not.toContain('\u001B[');
});

it('suggests prebuild/postbuild hooks on non-bun projects', async () => {
writeJson('package.json', { name: 'npm-app' });

const output = renderGuideChecklist(await collectGuideState(cwd), false);

expect(output).toContain('"prebuild": "patchstack-connect scan"');
expect(output).toContain('"postbuild": "patchstack-connect mark-build"');
expect(output).not.toContain('\u001B[');
});

it('counts a chained build script as wired (the bun pattern)', async () => {
writeJson('package.json', {
name: 'bun-wired-app',
scripts: {
build: 'patchstack-connect scan && vite build && patchstack-connect mark-build',
},
});
writeFileSync(path.join(cwd, 'bun.lock'), '');

const state = await collectGuideState(cwd);
expect(state.prebuildWired).toBe(true);
expect(state.postbuildWired).toBe(true);
});

it('substitutes the real UUID into the widget snippet once provisioned', async () => {
Expand Down
Loading