diff --git a/AGENT-INSTALL.md b/AGENT-INSTALL.md index 92cc4e0..90de02e 100644 --- a/AGENT-INSTALL.md +++ b/AGENT-INSTALL.md @@ -64,6 +64,7 @@ You are reading this either from `node_modules/@patchstack/connect/AGENT-INSTALL ## Rules +- Use only documented subcommands (`scan`, `init`, `status`, `mark-build`, `protect`, `guide`). Never invent one — there is no `verify` command, and the hooks are exactly `"prebuild": "patchstack-connect scan"` and `"postbuild": "patchstack-connect mark-build"`. - Never invent or guess a UUID — the scan provisions it, the widget silently no-ops on a fake one. - Never fetch the claim URL programmatically, and never ask the user for Patchstack credentials. - If a step fails, stop and report it. Don't proceed with placeholders. diff --git a/README.md b/README.md index 24c5dc4..f274dd5 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Copy and paste this prompt into any AI coding assistant (Cursor, v0, Bolt, Lovab > > 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. +> 3. Add `package.json` scripts `"prebuild": "patchstack-connect scan"` and `"postbuild": "patchstack-connect mark-build"` — use exactly these subcommands, do not invent others — 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. The shape of this prompt is deliberate — it has survived three distinct AI-agent refusal modes, and each one punished a different shortcut: @@ -46,11 +46,14 @@ Then wire it into builds: // package.json { "scripts": { - "prebuild": "patchstack-connect scan" + "prebuild": "patchstack-connect scan", + "postbuild": "patchstack-connect mark-build" } } ``` +> **Hooks never fail your build.** Inside `prebuild`/`build`/`postbuild` scripts the connector reports errors — a network blip, a mistyped subcommand — but exits 0, because platforms that build on publish (Lovable, Bolt, CI pipelines) fail the whole deploy on a non-zero hook. Manual runs still exit non-zero so you see real failures; `PATCHSTACK_SOFT_FAIL=0/1` overrides either way. + ## Quick start (existing site) If you already created an "Application" site in the Patchstack dashboard, pre-seed the UUID: @@ -91,6 +94,7 @@ Environment variables: - `PATCHSTACK_SITE_UUID` — the site UUID from your Patchstack dashboard - `PATCHSTACK_ENDPOINT` — override the API endpoint (default `https://api.patchstack.com/monitor/pulse/manifest`) - `PATCHSTACK_TIMEOUT_MS` — request timeout in milliseconds (default `30000`) +- `PATCHSTACK_SOFT_FAIL` — `1` reports errors but exits 0 so a connector problem can never fail the invoking build; `0` makes failures fatal everywhere. When unset, errors are soft inside `prebuild`/`build`/`postbuild` scripts (detected via `npm_lifecycle_event`, which npm, pnpm, yarn, and bun all set) and hard for manual runs. `.patchstackrc.json` example: diff --git a/src/cli.ts b/src/cli.ts index 030659f..05583c0 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -4,7 +4,7 @@ import { scanLockfile } from './parsers/index.js'; import { buildWirePayload } from './normalize.js'; import { computeManifestChecksum } from './checksum.js'; import { buildClaimUrl, postManifest } from './client.js'; -import { persistSiteUuid, resolveConfig, writeConfigFile } from './config.js'; +import { persistSiteUuid, resolveConfig, softFailEnabled, writeConfigFile } from './config.js'; import { buildInjectionSnippet, findHtmlFiles, @@ -47,6 +47,9 @@ Environment: PATCHSTACK_ENDPOINT API endpoint (default: https://api.patchstack.com/monitor/pulse/manifest) PATCHSTACK_TIMEOUT_MS Request timeout in ms (default: 30000) PATCHSTACK_ENVIRONMENT Manifest environment: production | sandbox (default: production) + PATCHSTACK_SOFT_FAIL 1 = report errors but exit 0; 0 = fail hard even in + build hooks. Default: soft inside prebuild/build/ + postbuild scripts, hard everywhere else. Precedence: CLI flag > environment variable > .patchstackrc.json. @@ -298,6 +301,36 @@ async function runMarkBuild(args: ParsedArgs): Promise { return 0; } +const COMMANDS = ['scan', 'init', 'status', 'mark-build', 'protect', 'guide', 'help']; + +function editDistance(a: string, b: string): number { + const row = Array.from({ length: b.length + 1 }, (_, i) => i); + for (let i = 1; i <= a.length; i++) { + let prev = row[0]!; + row[0] = i; + for (let j = 1; j <= b.length; j++) { + const current = row[j]!; + row[j] = Math.min(current + 1, row[j - 1]! + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1)); + prev = current; + } + } + return row[b.length]!; +} + +/** Nearest known command within edit distance 2 — catches typos and Unicode-dash mangling. */ +function suggestCommand(input: string): string | null { + let best: string | null = null; + let bestDistance = 3; + for (const command of COMMANDS) { + const distance = editDistance(input.toLowerCase(), command); + if (distance < bestDistance) { + best = command; + bestDistance = distance; + } + } + return best; +} + async function main(): Promise { const args = parseArgs(process.argv); @@ -319,20 +352,39 @@ async function main(): Promise { return runProtectCommand(args); case 'guide': return runGuide(); - default: - console.error(`Unknown command: ${args.command}\n`); - console.error(HELP); + default: { + // Build logs are often truncated to their tail, so keep this short and + // put the actual error last where it survives. + const suggestion = suggestCommand(args.command); + console.error(`Commands: ${COMMANDS.join(', ')} — run \`patchstack-connect help\` for details.`); + console.error( + `Unknown command: ${args.command}${suggestion !== null ? ` (did you mean \`${suggestion}\`?)` : ''}`, + ); return 1; + } + } +} + +function finalExitCode(code: number): number { + if (code === 0) { + return 0; + } + if (!softFailEnabled(process.env)) { + return code; } + console.error( + 'patchstack: continuing despite the error above so the build is not blocked (soft-fail; set PATCHSTACK_SOFT_FAIL=0 to make failures fatal).', + ); + return 0; } main() - .then((code) => process.exit(code)) + .then((code) => process.exit(finalExitCode(code))) .catch((err: unknown) => { if (err instanceof PatchstackError) { console.error(`Error (${err.code}): ${err.message}`); - process.exit(1); + process.exit(finalExitCode(1)); } console.error('Unexpected error:', err); - process.exit(2); + process.exit(finalExitCode(2)); }); diff --git a/src/config.ts b/src/config.ts index 8172d80..9236bc5 100644 --- a/src/config.ts +++ b/src/config.ts @@ -142,6 +142,29 @@ function readEnv(): ConfigFile { }; } +const SOFT_FAIL_OPT_OUTS = new Set(['0', 'false', 'no', 'off']); + +const BUILD_LIFECYCLE_EVENTS = new Set(['prebuild', 'build', 'postbuild']); + +/** + * Whether a failure should be reported without failing the invoking process. + * + * This CLI gets wired into build hooks by AI coding agents, and platforms like + * Lovable fail the whole publish when a hook exits non-zero — a monitoring tool + * must never take a customer's deploy down with it. npm, pnpm, yarn, and bun + * all expose the running script name as `npm_lifecycle_event`, so inside a + * build lifecycle script failures are soft by default. `PATCHSTACK_SOFT_FAIL` + * overrides in either direction (`1` forces soft anywhere, `0` restores hard + * failures even in hooks). + */ +export function softFailEnabled(env: NodeJS.ProcessEnv): boolean { + const explicit = env.PATCHSTACK_SOFT_FAIL; + if (explicit !== undefined && explicit.length > 0) { + return !SOFT_FAIL_OPT_OUTS.has(explicit.toLowerCase()); + } + return BUILD_LIFECYCLE_EVENTS.has(env.npm_lifecycle_event ?? ''); +} + function isUuid(value: string): boolean { return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value); } diff --git a/tests/config.test.ts b/tests/config.test.ts index f9b9844..e07877c 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; -import { persistSiteUuid, resolveConfig, writeConfigFile } from '../src/config.js'; +import { persistSiteUuid, resolveConfig, softFailEnabled, writeConfigFile } from '../src/config.js'; import { readFile } from 'node:fs/promises'; import { DEFAULT_ENDPOINT, DEFAULT_TIMEOUT_MS } from '../src/client.js'; import { PatchstackError } from '../src/types.js'; @@ -125,3 +125,36 @@ describe('resolveConfig', () => { }); }); }); + +describe('softFailEnabled', () => { + it('is off for plain interactive runs', () => { + expect(softFailEnabled({})).toBe(false); + expect(softFailEnabled({ npm_lifecycle_event: 'npx' })).toBe(false); + expect(softFailEnabled({ npm_lifecycle_event: 'test' })).toBe(false); + }); + + it('is on by default inside build lifecycle hooks', () => { + expect(softFailEnabled({ npm_lifecycle_event: 'prebuild' })).toBe(true); + expect(softFailEnabled({ npm_lifecycle_event: 'build' })).toBe(true); + expect(softFailEnabled({ npm_lifecycle_event: 'postbuild' })).toBe(true); + }); + + it('honors PATCHSTACK_SOFT_FAIL=1 anywhere', () => { + expect(softFailEnabled({ PATCHSTACK_SOFT_FAIL: '1' })).toBe(true); + expect(softFailEnabled({ PATCHSTACK_SOFT_FAIL: 'true' })).toBe(true); + expect(softFailEnabled({ PATCHSTACK_SOFT_FAIL: 'anything' })).toBe(true); + }); + + it('honors explicit opt-outs even inside build hooks', () => { + for (const value of ['0', 'false', 'FALSE', 'no', 'off']) { + expect( + softFailEnabled({ PATCHSTACK_SOFT_FAIL: value, npm_lifecycle_event: 'postbuild' }), + ).toBe(false); + } + }); + + it('treats an empty PATCHSTACK_SOFT_FAIL as unset', () => { + expect(softFailEnabled({ PATCHSTACK_SOFT_FAIL: '' })).toBe(false); + expect(softFailEnabled({ PATCHSTACK_SOFT_FAIL: '', npm_lifecycle_event: 'build' })).toBe(true); + }); +});