Skip to content
Open
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
1 change: 1 addition & 0 deletions AGENT-INSTALL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:

Expand Down
66 changes: 59 additions & 7 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -298,6 +301,36 @@ async function runMarkBuild(args: ParsedArgs): Promise<number> {
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<number> {
const args = parseArgs(process.argv);

Expand All @@ -319,20 +352,39 @@ async function main(): Promise<number> {
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));
});
23 changes: 23 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
35 changes: 34 additions & 1 deletion tests/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
});
});
Loading