From d702767b983631c68dd8662130cfc858ae16140d Mon Sep 17 00:00:00 2001 From: Elliot Taylor Date: Mon, 13 Jul 2026 20:10:28 +0200 Subject: [PATCH] Make the guide state-aware and shorten the agent install prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Field-tested with two simulated vibe-platform agent installs (Lovable-style bun fixture + mock manifest API); every change below traces to a finding. - New src/guide.ts: `guide` now inspects the project first (package manager, install state, .patchstackrc.json, build hooks, widget presence, framework) and prints a live ✔/✖ checklist with tailored commands — real site UUID substituted, framework-specific widget placement — before the full AGENT-INSTALL.md reference. Colors are TTY/NO_COLOR-gated. - Widget check verifies the userToken matches the provisioned siteUuid; a stale token (which silently no-ops) is flagged instead of passing. - scan/status/guide surface a non-default endpoint override loudly; both test agents had to read CLI source to rule out manifest redirection. - scan: fix dangling duplicate-versions list (>10 printed a bare colon), say "(npm ecosystem)" instead of "npm lockfile" (wrong for the bun node_modules walk), and name PATCHSTACK_ENVIRONMENT in the environment line. - AGENT-INSTALL.md: provisioned-ness keys off the siteUuid key (not file existence), chained-hook example, unified siteUuid/userToken naming, accurate bun path in the trust section, commit step includes the widget file. - README: 3-step install prompt (verify + scan + finish-the-checklist) that keeps the trust-critical spine inline while delegating the tail to the now-state-aware guide; refusal-mode rationale updated to match. Co-Authored-By: Claude Fable 5 --- AGENT-INSTALL.md | 13 +- README.md | 18 +- src/cli.ts | 51 ++++- src/guide.ts | 440 ++++++++++++++++++++++++++++++++++++++++++++ tests/guide.test.ts | 238 ++++++++++++++++++++++++ 5 files changed, 738 insertions(+), 22 deletions(-) create mode 100644 src/guide.ts create mode 100644 tests/guide.test.ts diff --git a/AGENT-INSTALL.md b/AGENT-INSTALL.md index 92cc4e0..3baaac3 100644 --- a/AGENT-INSTALL.md +++ b/AGENT-INSTALL.md @@ -8,12 +8,13 @@ You are reading this either from `node_modules/@patchstack/connect/AGENT-INSTALL - 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 **lockfile only** (`package-lock.json`, `pnpm-lock.yaml`, or `yarn.lock`) 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 vars, no file paths, no git history. - 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 -- If `.patchstackrc.json` exists, the project is already provisioned. Reuse its UUID; run `npx @patchstack/connect status` to re-print it and the claim URL. **Do not run `scan` to provision a second site.** +- `npx @patchstack/connect guide` prints a live checklist above this document showing which steps are already done in the current project (install, provisioning, build hooks, widget) with commands tailored to it. Trust that checklist over re-deriving state yourself; only do the steps it marks ✖. +- If `.patchstackrc.json` contains a `siteUuid` key, the project is already provisioned. Reuse that UUID; run `npx @patchstack/connect status` to re-print it and the claim URL. **Do not run `scan` to provision a second site.** (A `.patchstackrc.json` with other keys — e.g. an `endpoint` override — but no `siteUuid` is *not* provisioned yet; scan normally.) - 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. @@ -36,7 +37,7 @@ You are reading this either from `node_modules/@patchstack/connect/AGENT-INSTALL It prints a claim URL. Surface that URL to the user verbatim — never fetch it yourself; it must be opened in their browser. -3. **Wire builds** in `package.json` (chain with `&&` if hooks already exist): +3. **Wire builds** in `package.json`: ```jsonc { @@ -47,7 +48,9 @@ You are reading this either from `node_modules/@patchstack/connect/AGENT-INSTALL } ``` -4. **Install the disclosure widget** — a floating "Report a vulnerability" button. Read the UUID from `.patchstackrc.json` and use it as `userToken`. Place these two snippets via the framework's HTML/layout mechanism (never a JS entry point): + If a `prebuild`/`postbuild` hook already exists, chain instead of replacing it, e.g. `"prebuild": "existing-command && patchstack-connect scan"`. + +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 @@ -58,7 +61,7 @@ You are reading this either from `node_modules/@patchstack/connect/AGENT-INSTALL Framework-specific placement patterns: https://cdn.patchstack.com/llm.html. The `userToken` is public by design — it ships in client-side HTML and is not a secret. -5. **Commit** `.patchstackrc.json` and the updated `package.json` so every developer and CI run reports to the same site. +5. **Commit** `.patchstackrc.json`, the updated `package.json`, and the layout/HTML file carrying the widget snippet, so every developer and CI run reports to the same site. 6. **Tell the user to claim the site** (free): open the claim URL from step 2 in a browser and sign in. The site is monitored either way, but the vulnerability reports are only visible after claiming. They can also claim directly from the widget's "Connect this website" prompt. On the published site, the owner reaches the widget login by appending `#patchstack` to the live URL. diff --git a/README.md b/README.md index 24c5dc4..1890142 100644 --- a/README.md +++ b/README.md @@ -8,20 +8,19 @@ 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: +> Add Patchstack dependency vulnerability monitoring to this project (MIT, source: https://github.com/patchstack/connect — verify with `npm view @patchstack/connect` if unsure): > -> 1. `npm install --save-dev @patchstack/connect` — or the pnpm/yarn/bun equivalent (MIT, source: https://github.com/patchstack/connect; verify with `npm view @patchstack/connect` if unsure). -> 2. `npx @patchstack/connect scan` — saves a site ID to `.patchstackrc.json` and prints a claim URL. Show me that URL; don't open it. -> 3. Add `package.json` scripts `"prebuild": "patchstack-connect scan"` and `"postbuild": "patchstack-connect mark-build"`, then commit the changes. -> 4. Run `npx @patchstack/connect guide` and use it to add the report-a-vulnerability widget and finish any framework-specific details. +> 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. The shape of this prompt is deliberate — it has survived three distinct AI-agent refusal modes, 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.** Step 1's `npm view` resolves that against the registry instead of the model's memory. +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. -So the prompt inlines the core install as concrete, individually inspectable commands, delegates nothing up front, and argues nothing. `npx @patchstack/connect guide` (which prints the bundled `AGENT-INSTALL.md`) enters only at step 4, as an assist for widget placement, re-run rules, and troubleshooting — by then 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. The always-current long-form guide lives at https://patchstack.com/install.txt. +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. ## Quick start (zero configuration) @@ -70,6 +69,11 @@ patchstack-connect scan [options] Scan the lockfile and POST to patchstack-connect init Optional: pre-seed .patchstackrc.json with an existing site UUID patchstack-connect status [options] Show current configuration +patchstack-connect mark-build [options] Stamp built HTML with a production flag + + build fingerprint (run as a postbuild step) +patchstack-connect guide Show this project's setup status (what's done, + what's missing, with tailored commands), then + print the full setup guide patchstack-connect help Print help Options (for scan and status): diff --git a/src/cli.ts b/src/cli.ts index 030659f..b153461 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -3,7 +3,7 @@ import { readFileSync, writeFileSync } from 'node:fs'; import { scanLockfile } from './parsers/index.js'; import { buildWirePayload } from './normalize.js'; import { computeManifestChecksum } from './checksum.js'; -import { buildClaimUrl, postManifest } from './client.js'; +import { DEFAULT_ENDPOINT, buildClaimUrl, postManifest } from './client.js'; import { persistSiteUuid, resolveConfig, writeConfigFile } from './config.js'; import { buildInjectionSnippet, @@ -11,6 +11,7 @@ import { injectMarker, resolveBuildDir, } from './mark-build.js'; +import { collectGuideState, renderGuideChecklist } from './guide.js'; import { runProtect } from './protect/install.js'; import { detectStack, type StackDescriptor } from './stack.js'; import { PatchstackError } from './types.js'; @@ -29,8 +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 Print the full setup guide for AI coding - agents (also at https://patchstack.com/install.txt) + patchstack-connect guide Show this project's setup status (what's done, + what's missing, with tailored commands), then + print the full setup guide + (also at https://patchstack.com/install.txt) patchstack-connect help Print this message Options (for scan and status): @@ -136,14 +139,23 @@ async function runScan(args: ParsedArgs): Promise { const { payload, stats } = buildWirePayload(manifest); console.log( - `Found ${payload.packages.length} unique package versions across ${stats.uniqueNames} package names in ${manifest.ecosystem} lockfile.`, + `Found ${payload.packages.length} unique package versions across ${stats.uniqueNames} package names (${manifest.ecosystem} ecosystem).`, ); - console.log(`Reporting under the ${config.environment} environment.`); + console.log( + `Reporting under the ${config.environment} environment (override with PATCHSTACK_ENVIRONMENT).`, + ); + if (config.endpoint !== DEFAULT_ENDPOINT) { + console.log( + `Using endpoint override: ${config.endpoint} (set via --endpoint, PATCHSTACK_ENDPOINT, or .patchstackrc.json).`, + ); + } if (stats.duplicateNames.length > 0) { - console.log(`${stats.duplicateNames.length} package(s) appear at multiple versions:`); - if (stats.duplicateNames.length <= 10) { - console.log(` ${stats.duplicateNames.join(', ')}`); - } + const sample = stats.duplicateNames.slice(0, 10).join(', '); + const more = + stats.duplicateNames.length > 10 ? `, +${stats.duplicateNames.length - 10} more` : ''; + console.log( + `${stats.duplicateNames.length} package(s) appear at multiple versions: ${sample}${more}`, + ); } if (dryRun) { @@ -191,6 +203,9 @@ async function runScan(args: ParsedArgs): Promise { console.log(''); console.log('Claim this site to view vulnerability reports in your Patchstack dashboard:'); console.log(` ${buildClaimUrl(config.endpoint, response.uuid)}`); + if (config.endpoint !== DEFAULT_ENDPOINT) { + console.log(' (this URL inherits the endpoint override above)'); + } } return 0; @@ -207,6 +222,20 @@ async function runProtectCommand(_args: ParsedArgs): Promise { } async function runGuide(): Promise { + // 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. + 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(''); + } catch { + // fall through to the static guide + } + // 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); @@ -221,7 +250,9 @@ async function runStatus(args: ParsedArgs): Promise { cliEndpoint: getStringFlag(args.flags, 'endpoint'), }); console.log(`Site UUID: ${config.siteUuid ?? '(none yet — the next `scan` will provision one)'}`); - console.log(`Endpoint: ${config.endpoint}`); + console.log( + `Endpoint: ${config.endpoint}${config.endpoint === DEFAULT_ENDPOINT ? '' : ' (override)'}`, + ); console.log(`Timeout: ${config.timeoutMs}ms`); console.log(`Environment: ${config.environment}`); if (config.siteUuid !== null) { diff --git a/src/guide.ts b/src/guide.ts new file mode 100644 index 0000000..baeda73 --- /dev/null +++ b/src/guide.ts @@ -0,0 +1,440 @@ +// `patchstack-connect guide` — a state-aware setup checklist. +// +// Instead of only printing the generic AGENT-INSTALL.md, the guide first inspects +// the current project (package.json, lockfile, .patchstackrc.json, source tree) +// and renders a checklist of what is already done and what is still missing, with +// the exact commands/snippets for THIS project (right package manager, real site +// UUID, framework-specific widget placement). Every probe is best-effort: an +// unreadable project degrades to an all-todo checklist, never a crash. + +import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; +import path from 'node:path'; + +import { DEFAULT_ENDPOINT, buildClaimUrl } from './client.js'; +import { resolveConfig } from './config.js'; +import { detectStack } from './stack.js'; + +export const WIDGET_SCRIPT_URL = 'https://cdn.patchstack.com/patchstack-widget.js'; + +/** Substring that marks the widget as installed anywhere in the source tree. */ +const WIDGET_NEEDLE = 'patchstack-widget'; + +export type PackageManager = 'npm' | 'pnpm' | 'yarn' | 'bun'; + +export interface GuideState { + /** package.json `name`, when readable. */ + projectName: string | null; + hasPackageJson: boolean; + packageManager: PackageManager; + /** Version + section when @patchstack/connect is declared, else null. */ + installed: { version: string; section: 'devDependencies' | 'dependencies' } | null; + siteUuid: string | null; + claimUrl: string | null; + /** Non-default API endpoint in effect (rc file, env, or flag), else null. */ + endpointOverride: string | null; + prebuildWired: boolean; + postbuildWired: boolean; + widgetInstalled: boolean; + /** False when the widget is present but its userToken isn't the site UUID. */ + widgetTokenMatches: boolean | null; + /** Framework label from the declared dependencies (e.g. "next"), best-effort. */ + framework: string | null; + /** Existing file the widget snippet belongs in, best-effort. */ + widgetFileHint: string | null; +} + +const INSTALL_COMMANDS: Record = { + npm: 'npm install --save-dev @patchstack/connect', + pnpm: 'pnpm add -D @patchstack/connect', + yarn: 'yarn add -D @patchstack/connect', + bun: 'bun add -d @patchstack/connect', +}; + +/** Lockfile → package manager, same priority order as lockfile detection. */ +const PM_BY_LOCKFILE: ReadonlyArray<{ filename: string; pm: PackageManager }> = [ + { filename: 'package-lock.json', pm: 'npm' }, + { filename: 'bun.lock', pm: 'bun' }, + { filename: 'bun.lockb', pm: 'bun' }, + { filename: 'pnpm-lock.yaml', pm: 'pnpm' }, + { filename: 'yarn.lock', pm: 'yarn' }, +]; + +/** + * Framework → candidate layout files the widget snippet belongs in, most + * specific first. The first candidate that exists in the project wins. + */ +const WIDGET_FILE_CANDIDATES: Record = { + next: [ + 'app/layout.tsx', + 'app/layout.jsx', + 'src/app/layout.tsx', + 'src/app/layout.jsx', + 'pages/_document.tsx', + 'pages/_document.jsx', + 'src/pages/_document.tsx', + ], + nuxt: ['app.vue', 'src/app.vue', 'app/app.vue'], + remix: ['app/root.tsx', 'app/root.jsx'], + 'react-router': ['app/root.tsx', 'src/root.tsx'], + 'tanstack-start': ['src/routes/__root.tsx', 'app/routes/__root.tsx'], + sveltekit: ['src/app.html'], + astro: ['src/layouts/Layout.astro'], + gatsby: ['src/html.js'], +}; + +/** Fallback candidates for plain Vite / CRA / static projects. */ +const GENERIC_WIDGET_FILES = ['index.html', 'public/index.html']; + +/** Directories never worth searching for the widget snippet. */ +const SKIPPED_DIRS = new Set([ + 'node_modules', + '.git', + 'dist', + 'build', + 'out', + '.output', + '.next', + '.nuxt', + '.svelte-kit', + '.vercel', + '.netlify', + 'coverage', + 'vendor', +]); + +/** Source extensions that can carry the widget `)); + const token = state.siteUuid ?? ''; + lines.push(detail(` `)); + lines.push(detail('The userToken is public by design — it ships in client-side HTML.')); + } + + // 5. Claim — the conversion moment; always the loudest line. + lines.push(''); + if (state.claimUrl !== null) { + lines.push(` ${paint(ANSI.cyan, '➜')} ${paint(ANSI.bold, 'Claim the site (free, opens the dashboard):')}`); + lines.push(` ${paint(ANSI.cyan, state.claimUrl)}`); + lines.push(detail('Open in a browser. AI agents: show this URL to the user verbatim.')); + if (state.endpointOverride !== null) { + lines.push(detail('(this URL inherits the endpoint override above)')); + } + } else { + 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; + 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.`, + ); + + return lines.join('\n'); +} diff --git a/tests/guide.test.ts b/tests/guide.test.ts new file mode 100644 index 0000000..fde4272 --- /dev/null +++ b/tests/guide.test.ts @@ -0,0 +1,238 @@ +import { mkdirSync, writeFileSync } from 'node:fs'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + collectGuideState, + detectPackageManager, + findWidgetMarker, + installCommand, + renderGuideChecklist, +} from '../src/guide.js'; + +const VALID_UUID = '550e8400-e29b-41d4-a716-446655440000'; + +describe('guide', () => { + let cwd: string; + const originalEnv = { ...process.env }; + + beforeEach(async () => { + cwd = await mkdtemp(path.join(tmpdir(), 'patchstack-guide-')); + delete process.env.PATCHSTACK_SITE_UUID; + delete process.env.PATCHSTACK_ENDPOINT; + delete process.env.PATCHSTACK_TIMEOUT_MS; + delete process.env.PATCHSTACK_ENVIRONMENT; + }); + + afterEach(async () => { + process.env = { ...originalEnv }; + await rm(cwd, { recursive: true, force: true }); + }); + + const writeJson = (relative: string, value: unknown): void => { + writeFileSync(path.join(cwd, relative), JSON.stringify(value, null, 2)); + }; + + describe('detectPackageManager', () => { + it('maps lockfiles to their package manager', () => { + writeFileSync(path.join(cwd, 'bun.lock'), ''); + expect(detectPackageManager(cwd)).toBe('bun'); + }); + + it('defaults to npm when no lockfile exists', () => { + expect(detectPackageManager(cwd)).toBe('npm'); + }); + + it('prefers package-lock.json over other lockfiles', () => { + writeFileSync(path.join(cwd, 'yarn.lock'), ''); + writeFileSync(path.join(cwd, 'package-lock.json'), '{}'); + expect(detectPackageManager(cwd)).toBe('npm'); + }); + }); + + describe('collectGuideState', () => { + it('reports a fresh project as all-todo with tailored hints', async () => { + writeJson('package.json', { + name: 'my-app', + dependencies: { next: '15.0.0', react: '19.0.0', 'react-dom': '19.0.0' }, + }); + mkdirSync(path.join(cwd, 'app')); + writeFileSync(path.join(cwd, 'app', 'layout.tsx'), 'export default function Layout() {}'); + writeFileSync(path.join(cwd, 'pnpm-lock.yaml'), ''); + + const state = await collectGuideState(cwd); + + expect(state.projectName).toBe('my-app'); + expect(state.packageManager).toBe('pnpm'); + expect(state.installed).toBeNull(); + expect(state.siteUuid).toBeNull(); + expect(state.claimUrl).toBeNull(); + expect(state.prebuildWired).toBe(false); + expect(state.postbuildWired).toBe(false); + expect(state.widgetInstalled).toBe(false); + expect(state.framework).toBe('next'); + expect(state.widgetFileHint).toBe('app/layout.tsx'); + }); + + it('reports a fully wired project as all-done', async () => { + writeJson('package.json', { + name: 'done-app', + devDependencies: { '@patchstack/connect': '^0.2.11' }, + scripts: { + prebuild: 'lint && patchstack-connect scan', + postbuild: 'patchstack-connect mark-build', + }, + }); + writeJson('.patchstackrc.json', { siteUuid: VALID_UUID }); + mkdirSync(path.join(cwd, 'src')); + writeFileSync( + path.join(cwd, 'src', 'layout.tsx'), + '' + + ``, + ); + + const state = await collectGuideState(cwd); + + expect(state.installed).toEqual({ version: '^0.2.11', section: 'devDependencies' }); + expect(state.siteUuid).toBe(VALID_UUID); + expect(state.claimUrl).toContain(VALID_UUID); + expect(state.prebuildWired).toBe(true); + expect(state.postbuildWired).toBe(true); + expect(state.widgetInstalled).toBe(true); + expect(state.widgetTokenMatches).toBe(true); + }); + + it('survives a project with no package.json', async () => { + const state = await collectGuideState(cwd); + expect(state.hasPackageJson).toBe(false); + expect(state.installed).toBeNull(); + }); + + it('surfaces a non-default endpoint as an override', async () => { + writeJson('package.json', { name: 'override-app' }); + writeJson('.patchstackrc.json', { endpoint: 'http://127.0.0.1:4870/monitor/pulse/manifest' }); + + const state = await collectGuideState(cwd); + expect(state.endpointOverride).toBe('http://127.0.0.1:4870/monitor/pulse/manifest'); + expect(state.siteUuid).toBeNull(); + + const output = renderGuideChecklist(state, false); + expect(output).toContain('endpoint override in effect: http://127.0.0.1:4870'); + }); + + it('reports no override on the default endpoint', async () => { + writeJson('package.json', { name: 'default-app' }); + const state = await collectGuideState(cwd); + expect(state.endpointOverride).toBeNull(); + }); + + it('survives an invalid .patchstackrc.json', async () => { + writeJson('package.json', { name: 'broken-rc' }); + writeFileSync(path.join(cwd, '.patchstackrc.json'), 'not json'); + const state = await collectGuideState(cwd); + expect(state.siteUuid).toBeNull(); + }); + }); + + describe('findWidgetMarker', () => { + it('ignores node_modules and dot-directories', () => { + mkdirSync(path.join(cwd, 'node_modules'), { recursive: true }); + writeFileSync( + path.join(cwd, 'node_modules', 'index.js'), + 'patchstack-widget.js', + ); + mkdirSync(path.join(cwd, '.cache')); + writeFileSync(path.join(cwd, '.cache', 'page.html'), 'patchstack-widget.js'); + expect(findWidgetMarker(cwd)).toEqual({ found: false, uuidMatches: null }); + }); + + it('finds the marker in nested source files', () => { + mkdirSync(path.join(cwd, 'src', 'routes'), { recursive: true }); + writeFileSync( + path.join(cwd, 'src', 'routes', '__root.tsx'), + 'const s = "https://cdn.patchstack.com/patchstack-widget.js";', + ); + expect(findWidgetMarker(cwd)).toEqual({ found: true, uuidMatches: null }); + }); + + it('checks the userToken against the site UUID when one is known', () => { + writeFileSync( + path.join(cwd, 'index.html'), + `patchstack-widget.js userToken: '${VALID_UUID}'`, + ); + expect(findWidgetMarker(cwd, VALID_UUID)).toEqual({ found: true, uuidMatches: true }); + expect(findWidgetMarker(cwd, '11111111-1111-1111-1111-111111111111')).toEqual({ + found: true, + uuidMatches: false, + }); + }); + }); + + describe('renderGuideChecklist', () => { + it('prints the package-manager-specific install command for missing installs', async () => { + writeJson('package.json', { name: 'bun-app' }); + writeFileSync(path.join(cwd, 'bun.lock'), ''); + + const output = renderGuideChecklist(await collectGuideState(cwd), false); + + expect(output).toContain(installCommand('bun')); + expect(output).toContain('npx @patchstack/connect scan'); + expect(output).toContain('"prebuild": "patchstack-connect scan"'); + expect(output).toContain('"postbuild": "patchstack-connect mark-build"'); + expect(output).not.toContain('\u001B['); + }); + + it('substitutes the real UUID into the widget snippet once provisioned', async () => { + writeJson('package.json', { name: 'uuid-app' }); + writeJson('.patchstackrc.json', { siteUuid: VALID_UUID }); + + const output = renderGuideChecklist(await collectGuideState(cwd), false); + + expect(output).toContain(`userToken: '${VALID_UUID}'`); + expect(output).toContain('/monitor/claim?site='); + }); + + it('celebrates a complete setup and keeps the claim URL visible', async () => { + writeJson('package.json', { + name: 'done-app', + devDependencies: { '@patchstack/connect': '0.2.11' }, + scripts: { + prebuild: 'patchstack-connect scan', + postbuild: 'patchstack-connect mark-build', + }, + }); + writeJson('.patchstackrc.json', { siteUuid: VALID_UUID }); + writeFileSync(path.join(cwd, 'index.html'), `patchstack-widget.js userToken: '${VALID_UUID}'`); + + const output = renderGuideChecklist(await collectGuideState(cwd), false); + + expect(output).toContain('All setup steps complete'); + expect(output).toContain('/monitor/claim?site='); + expect(output).not.toContain('✖'); + }); + + it('flags a widget whose userToken does not match the site UUID', async () => { + writeJson('package.json', { name: 'stale-token-app' }); + writeJson('.patchstackrc.json', { siteUuid: VALID_UUID }); + writeFileSync( + path.join(cwd, 'index.html'), + "patchstack-widget.js userToken: '11111111-1111-1111-1111-111111111111'", + ); + + const state = await collectGuideState(cwd); + expect(state.widgetInstalled).toBe(true); + expect(state.widgetTokenMatches).toBe(false); + + const output = renderGuideChecklist(state, false); + expect(output).toContain("userToken doesn't match"); + expect(output).toContain(VALID_UUID); + }); + + it('points at the project root when package.json is missing', async () => { + const output = renderGuideChecklist(await collectGuideState(cwd), false); + expect(output).toContain('No package.json found'); + }); + }); +});