failproofai 0.0.9 → 0.0.10-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.next/standalone/.cursor/hooks.json +47 -0
- package/.next/standalone/.gemini/settings.json +147 -0
- package/.next/standalone/.next/BUILD_ID +1 -1
- package/.next/standalone/.next/build-manifest.json +3 -3
- package/.next/standalone/.next/prerender-manifest.json +3 -3
- package/.next/standalone/.next/required-server-files.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page.js +1 -1
- package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_global-error.html +1 -1
- package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
- package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
- package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +3 -3
- package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +3 -3
- package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/_not-found/page.js +1 -1
- package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_not-found.html +2 -2
- package/.next/standalone/.next/server/app/_not-found.rsc +17 -17
- package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +17 -17
- package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
- package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +11 -11
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
- package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js +2 -1
- package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/index.html +1 -1
- package/.next/standalone/.next/server/app/index.rsc +16 -16
- package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +16 -16
- package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +4 -4
- package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +11 -11
- package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/page.js +1 -1
- package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +8 -8
- package/.next/standalone/.next/server/app/policies/page.js +1 -1
- package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page.js +2 -2
- package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js +5 -5
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/projects/page.js +2 -2
- package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0.~nmr9._.js +3 -0
- package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0yspgjy._.js → [root-of-the-server]__010i6f5._.js} +2 -2
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__08px0ym._.js +3 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0b57.gk._.js +3 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0dtn9lr._.js +3 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0kjo7d_._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0vlhtkc._.js +3 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0wu7fr7._.js +3 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0yfq1yr._.js +3 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0z4c5dj._.js +3 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0zso~62._.js +3 -0
- package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_0z7w.hh._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0-2wr.c._.js +4 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0.~m-w2._.js +4 -0
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__09icjsf._.js → [root-of-the-server]__0709m8.._.js} +3 -3
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0bz245.._.js +4 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0dl0kgt._.js +4 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0gmhxyo._.js +4 -0
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0_b7pgn._.js → [root-of-the-server]__0lkkjl_._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__01g_w_e._.js → [root-of-the-server]__0mb9b9d._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0mup1hi._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0ohb3gc._.js +4 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0qbpe_v._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0s~gy6y._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0t5l7a5._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0ymlddl._.js +152 -6
- package/.next/standalone/.next/server/chunks/ssr/_03d7qyt._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/{_07a1g.3._.js → _0zx~s__._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/_10lm7or._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/app_0cdqd9w._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_0xerkr6._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_0q-m0y-._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/lib_codex-projects_ts_0eosib~._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/lib_copilot-projects_ts_0r8xkn8._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/lib_cursor-projects_ts_0qt1scg._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/lib_gemini-projects_ts_0sl~yqr._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/lib_opencode-projects_ts_0op9gyp._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/lib_pi-projects_ts_103tsh1._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_0ef3uwk.js +1 -1
- package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
- package/.next/standalone/.next/server/pages/404.html +2 -2
- package/.next/standalone/.next/server/pages/500.html +1 -1
- package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/server-reference-manifest.json +9 -9
- package/.next/standalone/.next/static/chunks/{0n-_j_6fo6jex.js → 0-wd3kiz5wrsz.js} +2 -2
- package/.next/standalone/.next/static/chunks/{0756i.7omnnl6.js → 0222q~_4u7p6h.js} +1 -1
- package/.next/standalone/.next/static/chunks/{0t~iusm_fxoao.js → 02y~6tp1j1wkh.js} +1 -1
- package/.next/standalone/.next/static/chunks/{09ose_165ra4d.js → 09qdljea8j.3~.js} +1 -1
- package/.next/standalone/.next/static/chunks/0bi2r.m~yokoo.js +1 -0
- package/.next/standalone/.next/static/chunks/{11kt_9zaooda3.js → 0pt38lwlsaxvs.js} +1 -1
- package/.next/standalone/.next/static/chunks/0q5bmqop--9yk.js +1 -0
- package/.next/standalone/.next/static/chunks/{0u-ys71jc4y68.js → 0vl201wjmz17m.js} +2 -2
- package/.next/standalone/.next/static/chunks/{0pr7k36o_.du1.js → 0vl~p17i-4qt2.js} +1 -1
- package/.next/standalone/.next/static/chunks/0xkzmsj-sniqz.js +1 -0
- package/.next/standalone/.next/static/chunks/12po2vpc-4_c1.css +1 -0
- package/.next/standalone/.opencode/opencode.json +4 -0
- package/.next/standalone/.opencode/plugins/failproofai.mjs +131 -0
- package/.next/standalone/.pi/settings.json +5 -0
- package/.next/standalone/app/components/cli-badge.tsx +7 -11
- package/.next/standalone/app/components/project-list.tsx +32 -4
- package/.next/standalone/app/policies/hooks-client.tsx +31 -15
- package/.next/standalone/app/project/[name]/page.tsx +52 -16
- package/.next/standalone/app/project/[name]/session/[sessionId]/page.tsx +92 -15
- package/.next/standalone/assets/logos/copilot-dark.svg +1 -0
- package/.next/standalone/assets/logos/copilot-light.svg +1 -0
- package/.next/standalone/assets/logos/cursor-dark.svg +1 -0
- package/.next/standalone/assets/logos/cursor-light.svg +1 -0
- package/.next/standalone/assets/logos/gemini-dark.svg +13 -0
- package/.next/standalone/assets/logos/gemini-light.svg +13 -0
- package/.next/standalone/assets/logos/opencode-dark.svg +1 -0
- package/.next/standalone/assets/logos/opencode-light.svg +1 -0
- package/.next/standalone/assets/logos/pi-dark.svg +7 -0
- package/.next/standalone/assets/logos/pi-light.svg +7 -0
- package/.next/standalone/lib/cli-registry.ts +107 -0
- package/.next/standalone/lib/codex-projects.ts +3 -3
- package/.next/standalone/lib/copilot-projects.ts +224 -0
- package/.next/standalone/lib/copilot-sessions.ts +395 -0
- package/.next/standalone/lib/cursor-projects.ts +312 -0
- package/.next/standalone/lib/cursor-sessions.ts +467 -0
- package/.next/standalone/lib/gemini-projects.ts +203 -0
- package/.next/standalone/lib/gemini-sessions.ts +365 -0
- package/.next/standalone/lib/opencode-projects.ts +232 -0
- package/.next/standalone/lib/opencode-sessions.ts +237 -0
- package/.next/standalone/lib/pi-projects.ts +230 -0
- package/.next/standalone/lib/pi-sessions.ts +325 -0
- package/.next/standalone/lib/projects.ts +67 -31
- package/.next/standalone/next.config.ts +5 -4
- package/.next/standalone/package.json +2 -1
- package/.next/standalone/pi-extension/index.ts +373 -0
- package/.next/standalone/pi-extension/package.json +12 -0
- package/.next/standalone/server.js +1 -1
- package/README.md +37 -3
- package/bin/failproofai.mjs +61 -21
- package/dist/cli.mjs +2405 -253
- package/lib/cli-registry.ts +107 -0
- package/lib/codex-projects.ts +3 -3
- package/lib/copilot-projects.ts +224 -0
- package/lib/copilot-sessions.ts +395 -0
- package/lib/cursor-projects.ts +312 -0
- package/lib/cursor-sessions.ts +467 -0
- package/lib/gemini-projects.ts +203 -0
- package/lib/gemini-sessions.ts +365 -0
- package/lib/opencode-projects.ts +232 -0
- package/lib/opencode-sessions.ts +237 -0
- package/lib/pi-projects.ts +230 -0
- package/lib/pi-sessions.ts +325 -0
- package/lib/projects.ts +67 -31
- package/package.json +2 -1
- package/pi-extension/index.ts +373 -0
- package/pi-extension/package.json +12 -0
- package/scripts/install-diagnosis.mjs +190 -0
- package/scripts/launch.ts +32 -0
- package/scripts/postinstall.mjs +25 -0
- package/scripts/translate-docs/mdx-translator.ts +56 -2
- package/scripts/translate-docs/translator.ts +1 -1
- package/src/hooks/builtin-policies.ts +84 -14
- package/src/hooks/handler.ts +67 -5
- package/src/hooks/install-prompt.ts +33 -10
- package/src/hooks/integrations.ts +1007 -6
- package/src/hooks/policy-evaluator.ts +299 -3
- package/src/hooks/resolve-permission-mode.ts +23 -0
- package/src/hooks/types.ts +307 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0g72weg._.js +0 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0su~k6f._.js +0 -3
- package/.next/standalone/.next/server/chunks/lib_codex-projects_ts_07qqk1g._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__01743wx._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__092s1ta._.js +0 -4
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0g.lg8b._.js +0 -4
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0gs6wz4._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0h..k-e._.js +0 -4
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0it81ys._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0u4a9jq._.js +0 -4
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__11pa2ra._.js +0 -4
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__12.h2mg._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__12t-wym._.js +0 -4
- package/.next/standalone/.next/server/chunks/ssr/_04w00cm._.js +0 -3
- package/.next/standalone/.next/static/chunks/0.rk1iwdt1d7c.css +0 -1
- package/.next/standalone/.next/static/chunks/06x4-d1~o-opr.js +0 -1
- package/.next/standalone/.next/static/chunks/095l4hc7-h.~~.js +0 -1
- package/.next/standalone/.next/static/chunks/0n~s0gafwnp2y.js +0 -1
- /package/.next/standalone/.next/static/{A_Ax17P33facL0OmIwFXj → w0GG7S5UEj1-p5g9hfsh2}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{A_Ax17P33facL0OmIwFXj → w0GG7S5UEj1-p5g9hfsh2}/_clientMiddlewareManifest.js +0 -0
- /package/.next/standalone/.next/static/{A_Ax17P33facL0OmIwFXj → w0GG7S5UEj1-p5g9hfsh2}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* failproofai policy bridge for Pi (pi-coding-agent).
|
|
3
|
+
*
|
|
4
|
+
* This extension is loaded by Pi at startup and registered via
|
|
5
|
+
* `pi install <abs-path-to-this-dir> [-l]` (or by hand-authoring an entry in
|
|
6
|
+
* `<scope>/.pi/settings.json`). It subscribes to Pi's `tool_call`, `user_bash`,
|
|
7
|
+
* `input`, and `session_start` events and forwards them to the failproofai
|
|
8
|
+
* binary as `failproofai --hook <Event> --cli pi`. failproofai prints a
|
|
9
|
+
* decision JSON to stdout; this shim parses it and translates into Pi's
|
|
10
|
+
* `{ block: true, reason }` return shape so policy `deny` decisions cancel
|
|
11
|
+
* tool execution.
|
|
12
|
+
*
|
|
13
|
+
* Marker comment for failproofai's installer detection (do not remove):
|
|
14
|
+
* __failproofai_hook__: true
|
|
15
|
+
*
|
|
16
|
+
* Binary resolution. failproofai ships two entrypoints:
|
|
17
|
+
* • dist/cli.mjs — bundled, node-compatible (production npm install)
|
|
18
|
+
* • bin/failproofai.mjs — source, requires `bun` (dev / monorepo)
|
|
19
|
+
*
|
|
20
|
+
* dist/cli.mjs is preferred because spawning `node bin/failproofai.mjs`
|
|
21
|
+
* fails with ERR_IMPORT_ATTRIBUTE_MISSING (the source `import package.json`
|
|
22
|
+
* needs `with { type: "json" }` under node, which bun handles transparently
|
|
23
|
+
* but the build:cli step transpiles away in dist/cli.mjs). When dist/cli.mjs
|
|
24
|
+
* isn't present, fall back to running bin/failproofai.mjs with `bun`. Pi
|
|
25
|
+
* spawns extensions with an undefined cwd contract, so paths are resolved
|
|
26
|
+
* relative to this file via `import.meta.url`, NOT process.cwd().
|
|
27
|
+
*/
|
|
28
|
+
import { spawnSync } from "node:child_process";
|
|
29
|
+
import { resolve, dirname, join } from "node:path";
|
|
30
|
+
import { fileURLToPath } from "node:url";
|
|
31
|
+
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
32
|
+
import { homedir } from "node:os";
|
|
33
|
+
|
|
34
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
35
|
+
const DIST_BIN = resolve(HERE, "..", "dist", "cli.mjs");
|
|
36
|
+
const SRC_BIN = resolve(HERE, "..", "bin", "failproofai.mjs");
|
|
37
|
+
// Prefer the bundled dist/cli.mjs (node-compatible); fall back to source +
|
|
38
|
+
// bun for dev workflows where dist/ hasn't been built yet.
|
|
39
|
+
function resolveSpawn(): { cmd: string; args: string[] } {
|
|
40
|
+
if (process.env.FAILPROOFAI_BINARY_OVERRIDE) {
|
|
41
|
+
return { cmd: "node", args: [process.env.FAILPROOFAI_BINARY_OVERRIDE] };
|
|
42
|
+
}
|
|
43
|
+
if (existsSync(DIST_BIN)) {
|
|
44
|
+
return { cmd: "node", args: [DIST_BIN] };
|
|
45
|
+
}
|
|
46
|
+
return { cmd: "bun", args: [SRC_BIN] };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface PolicyDecision {
|
|
50
|
+
permission?: "allow" | "deny";
|
|
51
|
+
reason?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Spawn `failproofai --hook <eventName> --cli pi`, write the JSON payload to
|
|
56
|
+
* stdin, and parse the flat `{permission, reason}` JSON we expect failproofai
|
|
57
|
+
* to print on stdout. Fail-open on any subprocess / parse error.
|
|
58
|
+
*/
|
|
59
|
+
/** Optional stderr trace for debugging the shim. Enabled with
|
|
60
|
+
* FAILPROOFAI_PI_DEBUG=1; silent otherwise. */
|
|
61
|
+
function debug(msg: string): void {
|
|
62
|
+
if (process.env.FAILPROOFAI_PI_DEBUG === "1") {
|
|
63
|
+
process.stderr.write(`[failproofai-pi-shim] ${msg}\n`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function callPolicy(eventName: string, payload: unknown): { block: boolean; reason: string } {
|
|
68
|
+
const { cmd, args } = resolveSpawn();
|
|
69
|
+
debug(`callPolicy event=${eventName} cmd=${cmd}`);
|
|
70
|
+
try {
|
|
71
|
+
const result = spawnSync(
|
|
72
|
+
cmd,
|
|
73
|
+
[...args, "--hook", eventName, "--cli", "pi"],
|
|
74
|
+
{
|
|
75
|
+
input: JSON.stringify(payload),
|
|
76
|
+
encoding: "utf8",
|
|
77
|
+
timeout: 60_000,
|
|
78
|
+
},
|
|
79
|
+
);
|
|
80
|
+
if (result.status !== 0) return { block: false, reason: "" };
|
|
81
|
+
const stdout = (result.stdout || "").trim();
|
|
82
|
+
if (!stdout) return { block: false, reason: "" };
|
|
83
|
+
const parsed = JSON.parse(stdout) as PolicyDecision;
|
|
84
|
+
if (parsed.permission === "deny") {
|
|
85
|
+
debug(`DENY reason=${parsed.reason}`);
|
|
86
|
+
return { block: true, reason: parsed.reason ?? "Blocked by failproofai" };
|
|
87
|
+
}
|
|
88
|
+
} catch (err) {
|
|
89
|
+
debug(`EXCEPTION ${err instanceof Error ? err.message : String(err)}`);
|
|
90
|
+
// Fail-open: never block tool execution because of an infra failure.
|
|
91
|
+
}
|
|
92
|
+
return { block: false, reason: "" };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
interface PiToolCallEvent {
|
|
96
|
+
type?: string;
|
|
97
|
+
toolName?: string;
|
|
98
|
+
toolCallId?: string;
|
|
99
|
+
input?: Record<string, unknown>;
|
|
100
|
+
cwd?: string;
|
|
101
|
+
sessionId?: string;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Pi emits tool names in lowercase (`bash`, `read`, `edit`, `write`).
|
|
106
|
+
* failproofai's builtin policies match on Claude-shaped capitalized names
|
|
107
|
+
* (`Bash`, `Read`, `Edit`, `Write`). Map between the two so existing
|
|
108
|
+
* tool-name match clauses fire on Pi sessions.
|
|
109
|
+
*/
|
|
110
|
+
function canonicalizeToolName(piToolName: string | undefined): string | undefined {
|
|
111
|
+
if (!piToolName) return undefined;
|
|
112
|
+
return piToolName.charAt(0).toUpperCase() + piToolName.slice(1);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Resolve the cwd for the policy payload. Pi events don't include cwd, so
|
|
116
|
+
* fall back to the extension's process.cwd() — which is where Pi was
|
|
117
|
+
* launched and where `.failproofai/` config lives. */
|
|
118
|
+
function resolveCwd(eventCwd: string | undefined): string {
|
|
119
|
+
return eventCwd ?? process.cwd();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Pi (verified empirically against pi-coding-agent v0.71.1) does NOT
|
|
124
|
+
* populate `event.sessionId` on any of its events — `session_start`,
|
|
125
|
+
* `tool_call`, `user_bash`, `input`, `tool_result`, `agent_end`,
|
|
126
|
+
* `session_shutdown` all leave it undefined. Without help the shim can't
|
|
127
|
+
* tag activity records with a session id, so the dashboard renders
|
|
128
|
+
* `Session ID: —` for every Pi row.
|
|
129
|
+
*
|
|
130
|
+
* What Pi DOES do: at session start it creates a JSONL transcript at
|
|
131
|
+
* `~/.pi/agent/sessions/<encodedCwd>/<isoTimestamp>_<uuid>.jsonl` where
|
|
132
|
+
* the filename encodes the sessionId. We discover ours by scanning the
|
|
133
|
+
* encoded-cwd directory for the most-recently-modified matching file.
|
|
134
|
+
*
|
|
135
|
+
* Strategy: scan once and cache. Pi runs one session per process so the
|
|
136
|
+
* cache is per-process and lives for the session's lifetime. If Pi ever
|
|
137
|
+
* multiplexes, we'd need a keyed map.
|
|
138
|
+
*/
|
|
139
|
+
const PI_FILE_RE = /^[\d-]+T[\d-]+Z_([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i;
|
|
140
|
+
|
|
141
|
+
/** Encode a cwd into Pi's on-disk session-dir name. Pi strips the leading
|
|
142
|
+
* `/` before replacing remaining slashes with `-`, e.g.
|
|
143
|
+
* `/home/u/repo` → `--home-u-repo--`. */
|
|
144
|
+
function piEncodeCwd(cwd: string): string {
|
|
145
|
+
const inner = cwd.replace(/^\/+/, "").replace(/\//g, "-");
|
|
146
|
+
return `--${inner}--`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Process start boundary — files older than this aren't from the current
|
|
150
|
+
* Pi session. Captured at module load so cold-start in a cwd with stale
|
|
151
|
+
* transcripts doesn't pin a previous session's UUID. We allow a small
|
|
152
|
+
* tolerance below `processStartMs` because mtime resolution and clock
|
|
153
|
+
* skew can put a "current" file's mtime a few hundred ms before module
|
|
154
|
+
* load on slow startup. */
|
|
155
|
+
const PROCESS_START_MS = Date.now();
|
|
156
|
+
const STALE_TOLERANCE_MS = 2_000;
|
|
157
|
+
|
|
158
|
+
/** Find the newest `<ts>_<uuid>.jsonl` file under `~/.pi/agent/sessions/<encodedCwd>/`
|
|
159
|
+
* whose mtime indicates it belongs to the CURRENT Pi process (≥ process
|
|
160
|
+
* start, with a small tolerance). Files older than that are stale
|
|
161
|
+
* transcripts from prior sessions in the same cwd — caching their UUID
|
|
162
|
+
* would cross-attribute every event of the new session.
|
|
163
|
+
* Returns undefined when the dir doesn't exist, has no matching file, or
|
|
164
|
+
* every matching file is stale. */
|
|
165
|
+
function discoverPiSessionId(cwd: string): string | undefined {
|
|
166
|
+
const root = process.env.PI_SESSIONS_DIR || join(homedir(), ".pi", "agent", "sessions");
|
|
167
|
+
const dir = join(root, piEncodeCwd(cwd));
|
|
168
|
+
let entries: string[];
|
|
169
|
+
try { entries = readdirSync(dir); } catch { return undefined; }
|
|
170
|
+
const boundary = PROCESS_START_MS - STALE_TOLERANCE_MS;
|
|
171
|
+
let best: { sessionId: string; mtime: number } | undefined;
|
|
172
|
+
for (const name of entries) {
|
|
173
|
+
const m = PI_FILE_RE.exec(name);
|
|
174
|
+
if (!m) continue;
|
|
175
|
+
let mtime: number;
|
|
176
|
+
try { mtime = statSync(join(dir, name)).mtimeMs; } catch { continue; }
|
|
177
|
+
if (mtime < boundary) continue;
|
|
178
|
+
if (!best || mtime > best.mtime) best = { sessionId: m[1], mtime };
|
|
179
|
+
}
|
|
180
|
+
return best?.sessionId;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** sessionId cache, keyed by cwd. Per-cwd so a multi-cwd Pi (extension running
|
|
184
|
+
* across multiple workspace roots) can't cross-attribute. Cleared on
|
|
185
|
+
* session_shutdown reasons `new`/`resume`/`fork` (Pi reuses the process). */
|
|
186
|
+
const cachedSessionIdByCwd = new Map<string, string>();
|
|
187
|
+
function resolveSessionId(eventSessionId: string | undefined, cwd: string): string | undefined {
|
|
188
|
+
if (eventSessionId) {
|
|
189
|
+
cachedSessionIdByCwd.set(cwd, eventSessionId);
|
|
190
|
+
return eventSessionId;
|
|
191
|
+
}
|
|
192
|
+
const cached = cachedSessionIdByCwd.get(cwd);
|
|
193
|
+
if (cached) return cached;
|
|
194
|
+
// Pi v0.71.1 never sets sessionId — discover from disk.
|
|
195
|
+
const discovered = discoverPiSessionId(cwd);
|
|
196
|
+
if (discovered) cachedSessionIdByCwd.set(cwd, discovered);
|
|
197
|
+
return discovered;
|
|
198
|
+
}
|
|
199
|
+
/** Clear the cached sessionId for a cwd. Called on session_shutdown reasons
|
|
200
|
+
* that indicate a new session is starting in the same process (`new`,
|
|
201
|
+
* `resume`, `fork`). Without this, the next session would inherit the prior
|
|
202
|
+
* sessionId until disk discovery refreshed it. */
|
|
203
|
+
function resetSessionIdCache(cwd: string): void {
|
|
204
|
+
cachedSessionIdByCwd.delete(cwd);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
interface PiUserBashEvent {
|
|
208
|
+
type?: string;
|
|
209
|
+
command?: string;
|
|
210
|
+
cwd?: string;
|
|
211
|
+
sessionId?: string;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
interface PiInputEvent {
|
|
215
|
+
type?: string;
|
|
216
|
+
text?: string;
|
|
217
|
+
source?: string;
|
|
218
|
+
cwd?: string;
|
|
219
|
+
sessionId?: string;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
interface PiSessionStartEvent {
|
|
223
|
+
type?: string;
|
|
224
|
+
reason?: string;
|
|
225
|
+
cwd?: string;
|
|
226
|
+
sessionId?: string;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
interface PiSessionShutdownEvent {
|
|
230
|
+
type?: string;
|
|
231
|
+
/** "quit" | "reload" | "new" | "resume" | "fork" per pi-coding-agent v0.72.1 */
|
|
232
|
+
reason?: string;
|
|
233
|
+
targetSessionFile?: string;
|
|
234
|
+
cwd?: string;
|
|
235
|
+
sessionId?: string;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
interface PiToolResultEvent {
|
|
239
|
+
type?: string;
|
|
240
|
+
toolCallId?: string;
|
|
241
|
+
toolName?: string;
|
|
242
|
+
input?: Record<string, unknown>;
|
|
243
|
+
/** TextContent | ImageContent — opaque to us; forwarded as-is. */
|
|
244
|
+
content?: unknown[];
|
|
245
|
+
isError?: boolean;
|
|
246
|
+
cwd?: string;
|
|
247
|
+
sessionId?: string;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
interface PiAgentEndEvent {
|
|
251
|
+
type?: string;
|
|
252
|
+
/** AgentMessage[] — opaque; not forwarded (Stop policies don't need it). */
|
|
253
|
+
messages?: unknown[];
|
|
254
|
+
cwd?: string;
|
|
255
|
+
sessionId?: string;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
interface PiExtensionApi {
|
|
259
|
+
on(event: string, handler: (event: unknown) => unknown): void;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export default function failproofaiBridge(pi: PiExtensionApi) {
|
|
263
|
+
// tool_call → PreToolUse. Block tool execution when failproofai denies.
|
|
264
|
+
pi.on("tool_call", (event: unknown): unknown => {
|
|
265
|
+
const e = event as PiToolCallEvent;
|
|
266
|
+
const decision = callPolicy("tool_call", {
|
|
267
|
+
tool_name: canonicalizeToolName(e.toolName),
|
|
268
|
+
tool_input: e.input,
|
|
269
|
+
session_id: resolveSessionId(e.sessionId, resolveCwd(e.cwd)),
|
|
270
|
+
cwd: resolveCwd(e.cwd),
|
|
271
|
+
hook_event_name: "PreToolUse",
|
|
272
|
+
});
|
|
273
|
+
if (decision.block) return { block: true, reason: decision.reason };
|
|
274
|
+
return undefined;
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
// user_bash → PreToolUse with synthesized toolName=Bash.
|
|
278
|
+
pi.on("user_bash", (event: unknown): unknown => {
|
|
279
|
+
const e = event as PiUserBashEvent;
|
|
280
|
+
const decision = callPolicy("user_bash", {
|
|
281
|
+
tool_name: "Bash",
|
|
282
|
+
tool_input: { command: e.command },
|
|
283
|
+
session_id: resolveSessionId(e.sessionId, resolveCwd(e.cwd)),
|
|
284
|
+
cwd: resolveCwd(e.cwd),
|
|
285
|
+
hook_event_name: "PreToolUse",
|
|
286
|
+
});
|
|
287
|
+
if (decision.block) return { block: true, reason: decision.reason };
|
|
288
|
+
return undefined;
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
// input → UserPromptSubmit. Honor block decisions if Pi accepts them
|
|
292
|
+
// (Pi's docs describe block on input but it's not exhaustively tested).
|
|
293
|
+
pi.on("input", (event: unknown): unknown => {
|
|
294
|
+
const e = event as PiInputEvent;
|
|
295
|
+
const decision = callPolicy("input", {
|
|
296
|
+
prompt: e.text,
|
|
297
|
+
session_id: resolveSessionId(e.sessionId, resolveCwd(e.cwd)),
|
|
298
|
+
cwd: resolveCwd(e.cwd),
|
|
299
|
+
hook_event_name: "UserPromptSubmit",
|
|
300
|
+
});
|
|
301
|
+
if (decision.block) return { block: true, reason: decision.reason };
|
|
302
|
+
return undefined;
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
// session_start → SessionStart. Observe-only; we still forward so the
|
|
306
|
+
// activity feed records the session and any UserPromptSubmit policies that
|
|
307
|
+
// need session_id continuity see the metadata.
|
|
308
|
+
pi.on("session_start", (event: unknown): unknown => {
|
|
309
|
+
const e = event as PiSessionStartEvent;
|
|
310
|
+
callPolicy("session_start", {
|
|
311
|
+
session_id: resolveSessionId(e.sessionId, resolveCwd(e.cwd)),
|
|
312
|
+
cwd: resolveCwd(e.cwd),
|
|
313
|
+
reason: e.reason,
|
|
314
|
+
hook_event_name: "SessionStart",
|
|
315
|
+
});
|
|
316
|
+
return undefined;
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
// tool_result → PostToolUse. Observation-only on Pi: ToolResultEventResult
|
|
320
|
+
// exposes {content, details, isError} for mutation but no `block`. We
|
|
321
|
+
// forward to the failproofai binary so PostToolUse builtins (sanitize-jwt,
|
|
322
|
+
// sanitize-api-keys, sanitize-connection-strings, sanitize-private-key-
|
|
323
|
+
// content, sanitize-bearer-tokens) run and get their decisions logged to
|
|
324
|
+
// the activity store + stderr — but Pi keeps the original tool result.
|
|
325
|
+
pi.on("tool_result", (event: unknown): unknown => {
|
|
326
|
+
const e = event as PiToolResultEvent;
|
|
327
|
+
callPolicy("tool_result", {
|
|
328
|
+
tool_name: canonicalizeToolName(e.toolName),
|
|
329
|
+
tool_input: e.input ?? {},
|
|
330
|
+
tool_response: { content: e.content, isError: e.isError },
|
|
331
|
+
session_id: resolveSessionId(e.sessionId, resolveCwd(e.cwd)),
|
|
332
|
+
cwd: resolveCwd(e.cwd),
|
|
333
|
+
hook_event_name: "PostToolUse",
|
|
334
|
+
});
|
|
335
|
+
return undefined;
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
// agent_end → Stop. Observation-only on Pi: the agent loop has already
|
|
339
|
+
// exited when this fires, so a deny decision cannot keep Pi running the
|
|
340
|
+
// way Claude's exit-2-from-Stop can. We still forward so the 5
|
|
341
|
+
// require-*-before-stop builtins run and log their findings (visible in
|
|
342
|
+
// the dashboard's activity feed and stderr) — best-effort visibility.
|
|
343
|
+
pi.on("agent_end", (event: unknown): unknown => {
|
|
344
|
+
const e = event as PiAgentEndEvent;
|
|
345
|
+
callPolicy("agent_end", {
|
|
346
|
+
session_id: resolveSessionId(e.sessionId, resolveCwd(e.cwd)),
|
|
347
|
+
cwd: resolveCwd(e.cwd),
|
|
348
|
+
hook_event_name: "Stop",
|
|
349
|
+
});
|
|
350
|
+
return undefined;
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
// session_shutdown → SessionEnd. Observation-only; emits a SessionEnd
|
|
354
|
+
// record so per-session telemetry has a clean close. Reset the per-cwd
|
|
355
|
+
// sessionId cache for shutdown reasons that mean "Pi is starting a new
|
|
356
|
+
// session in the same process" — without the reset, the next session's
|
|
357
|
+
// events would inherit the prior session's id until disk discovery
|
|
358
|
+
// refreshed it.
|
|
359
|
+
pi.on("session_shutdown", (event: unknown): unknown => {
|
|
360
|
+
const e = event as PiSessionShutdownEvent;
|
|
361
|
+
const cwd = resolveCwd(e.cwd);
|
|
362
|
+
callPolicy("session_shutdown", {
|
|
363
|
+
session_id: resolveSessionId(e.sessionId, cwd),
|
|
364
|
+
cwd,
|
|
365
|
+
reason: e.reason,
|
|
366
|
+
hook_event_name: "SessionEnd",
|
|
367
|
+
});
|
|
368
|
+
if (e.reason === "new" || e.reason === "resume" || e.reason === "fork") {
|
|
369
|
+
resetSessionIdCache(cwd);
|
|
370
|
+
}
|
|
371
|
+
return undefined;
|
|
372
|
+
});
|
|
373
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@failproofai/pi-extension",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "failproofai policy bridge for Pi (pi-coding-agent)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./index.ts",
|
|
7
|
+
"private": true,
|
|
8
|
+
"keywords": [
|
|
9
|
+
"pi-extension",
|
|
10
|
+
"failproofai"
|
|
11
|
+
]
|
|
12
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detects when `failproofai` on the user's PATH is shadowed by a different,
|
|
3
|
+
* older install — typically a leftover `bun link` from a prior dev session, or
|
|
4
|
+
* a `bun install -g failproofai` whose prefix sorts ahead of npm's on PATH.
|
|
5
|
+
*
|
|
6
|
+
* Used by:
|
|
7
|
+
* - scripts/postinstall.mjs — warn at install time so the customer never sees
|
|
8
|
+
* the misleading "missing build output" runtime error.
|
|
9
|
+
* - scripts/launch.ts — when .next/standalone/server.js is missing,
|
|
10
|
+
* produce a shadow-shaped error if the cause is a shadow rather than a
|
|
11
|
+
* genuinely broken build.
|
|
12
|
+
*
|
|
13
|
+
* Pure Node.js built-ins, no external dependencies. Every probe is wrapped in
|
|
14
|
+
* try/catch — diagnoseShadow() is guaranteed not to throw.
|
|
15
|
+
*/
|
|
16
|
+
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
17
|
+
import { dirname, resolve } from "node:path";
|
|
18
|
+
import { homedir, platform } from "node:os";
|
|
19
|
+
import { spawnSync } from "node:child_process";
|
|
20
|
+
|
|
21
|
+
const PKG_NAME = "failproofai";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Walk up from `start` looking for a package.json whose name === "failproofai".
|
|
25
|
+
* Returns its directory, or null when no such package.json is reachable.
|
|
26
|
+
*/
|
|
27
|
+
function findPackageRoot(start) {
|
|
28
|
+
try {
|
|
29
|
+
let dir = realpathSync(start);
|
|
30
|
+
// If `start` was a file (e.g. /usr/local/bin/failproofai), step up to its dir.
|
|
31
|
+
if (existsSync(dir) && !existsSync(resolve(dir, "package.json"))) {
|
|
32
|
+
dir = dirname(dir);
|
|
33
|
+
}
|
|
34
|
+
while (true) {
|
|
35
|
+
const pkgPath = resolve(dir, "package.json");
|
|
36
|
+
if (existsSync(pkgPath)) {
|
|
37
|
+
try {
|
|
38
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
39
|
+
if (pkg.name === PKG_NAME) return dir;
|
|
40
|
+
} catch {
|
|
41
|
+
// unreadable or non-JSON — fall through to parent
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const parent = dirname(dir);
|
|
45
|
+
if (parent === dir) return null;
|
|
46
|
+
dir = parent;
|
|
47
|
+
}
|
|
48
|
+
} catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Read `version` from a package.json; null on any error. */
|
|
54
|
+
function readPackageVersion(packageRoot) {
|
|
55
|
+
if (!packageRoot) return null;
|
|
56
|
+
try {
|
|
57
|
+
const pkg = JSON.parse(readFileSync(resolve(packageRoot, "package.json"), "utf8"));
|
|
58
|
+
return typeof pkg.version === "string" ? pkg.version : null;
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Find which `failproofai` PATH would resolve. POSIX: `command -v`; Win32: `where`. */
|
|
65
|
+
function resolvePathFirstBinary() {
|
|
66
|
+
try {
|
|
67
|
+
const isWin = platform() === "win32";
|
|
68
|
+
const res = isWin
|
|
69
|
+
? spawnSync("where", [PKG_NAME], { encoding: "utf8" })
|
|
70
|
+
: spawnSync("sh", ["-c", `command -v ${PKG_NAME}`], { encoding: "utf8" });
|
|
71
|
+
if (res.status !== 0) return null;
|
|
72
|
+
const first = (res.stdout || "").split(/\r?\n/).find((l) => l.trim().length > 0);
|
|
73
|
+
return first ? first.trim() : null;
|
|
74
|
+
} catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Locate the npm global install of failproofai, if any. */
|
|
80
|
+
function locateNpmGlobal() {
|
|
81
|
+
try {
|
|
82
|
+
const res = spawnSync("npm", ["root", "-g"], { encoding: "utf8" });
|
|
83
|
+
if (res.status !== 0) return null;
|
|
84
|
+
const root = (res.stdout || "").trim();
|
|
85
|
+
if (!root) return null;
|
|
86
|
+
const candidate = resolve(root, PKG_NAME);
|
|
87
|
+
return existsSync(resolve(candidate, "package.json")) ? candidate : null;
|
|
88
|
+
} catch {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Locate the bun global install of failproofai, if any. */
|
|
94
|
+
function locateBunGlobal() {
|
|
95
|
+
try {
|
|
96
|
+
const candidate = resolve(homedir(), ".bun", "install", "global", "node_modules", PKG_NAME);
|
|
97
|
+
return existsSync(resolve(candidate, "package.json")) ? candidate : null;
|
|
98
|
+
} catch {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Build a copy-pasteable cleanup command for the offending install.
|
|
105
|
+
*
|
|
106
|
+
* The signal we trust is `pathFirstBin` — the un-resolved binary location PATH
|
|
107
|
+
* pointed to. For bun-link shadows the realpath'd package root is the dev tree
|
|
108
|
+
* (not under ~/.bun/), so checking the package root would mis-classify those
|
|
109
|
+
* shadows as npm and recommend the wrong cleanup.
|
|
110
|
+
*/
|
|
111
|
+
function buildRecommendation(pathFirstBin) {
|
|
112
|
+
if (!pathFirstBin) return null;
|
|
113
|
+
const bunBinPrefix = resolve(homedir(), ".bun", "bin") + "/";
|
|
114
|
+
const bunGlobalPrefix = resolve(homedir(), ".bun", "install", "global") + "/";
|
|
115
|
+
const isBun = pathFirstBin.startsWith(bunBinPrefix) || pathFirstBin.startsWith(bunGlobalPrefix);
|
|
116
|
+
if (isBun) {
|
|
117
|
+
return `rm -f ~/.bun/bin/${PKG_NAME} && rm -rf ~/.bun/install/global/node_modules/${PKG_NAME}`;
|
|
118
|
+
}
|
|
119
|
+
return `npm rm -g ${PKG_NAME}`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Diagnose whether the running binary is being shadowed on PATH by a different
|
|
124
|
+
* failproofai install.
|
|
125
|
+
*
|
|
126
|
+
* @param {{ selfPackageRoot: string, selfVersion: string | null }} self
|
|
127
|
+
* The package root and version of the binary calling diagnoseShadow().
|
|
128
|
+
* Callers (bin/failproofai.mjs, scripts/postinstall.mjs) already have these
|
|
129
|
+
* values; passing them in keeps the helper deterministic and free of
|
|
130
|
+
* import.meta.url assumptions.
|
|
131
|
+
*/
|
|
132
|
+
export function diagnoseShadow(self) {
|
|
133
|
+
const selfPackageRoot = (() => {
|
|
134
|
+
try { return self?.selfPackageRoot ? realpathSync(self.selfPackageRoot) : null; }
|
|
135
|
+
catch { return self?.selfPackageRoot ?? null; }
|
|
136
|
+
})();
|
|
137
|
+
const selfVersion = self?.selfVersion ?? null;
|
|
138
|
+
|
|
139
|
+
const pathFirstBin = resolvePathFirstBinary();
|
|
140
|
+
const pathFirstPackageRoot = pathFirstBin ? findPackageRoot(pathFirstBin) : null;
|
|
141
|
+
const pathFirstVersion = readPackageVersion(pathFirstPackageRoot);
|
|
142
|
+
|
|
143
|
+
const npmGlobalPath = locateNpmGlobal();
|
|
144
|
+
const npmGlobalVersion = readPackageVersion(npmGlobalPath);
|
|
145
|
+
|
|
146
|
+
const bunGlobalPath = locateBunGlobal();
|
|
147
|
+
const bunGlobalVersion = readPackageVersion(bunGlobalPath);
|
|
148
|
+
|
|
149
|
+
// "Shadow" covers two scenarios:
|
|
150
|
+
// 1. Postinstall case — `selfPackageRoot` is the just-installed copy and
|
|
151
|
+
// PATH resolves elsewhere. Flag when the two roots differ.
|
|
152
|
+
// 2. Runtime case — the running binary IS the shadow (so selfPackageRoot
|
|
153
|
+
// === pathFirstPackageRoot), but a *different* failproofai install
|
|
154
|
+
// exists at the npm or bun global. Flag when one of those differs from
|
|
155
|
+
// pathFirstPackageRoot.
|
|
156
|
+
let shadowed = false;
|
|
157
|
+
if (selfPackageRoot && pathFirstPackageRoot && pathFirstPackageRoot !== selfPackageRoot) {
|
|
158
|
+
shadowed = true;
|
|
159
|
+
} else if (pathFirstPackageRoot) {
|
|
160
|
+
if (npmGlobalPath && npmGlobalPath !== pathFirstPackageRoot) shadowed = true;
|
|
161
|
+
else if (bunGlobalPath && bunGlobalPath !== pathFirstPackageRoot) shadowed = true;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const recommendation = shadowed ? buildRecommendation(pathFirstBin) : null;
|
|
165
|
+
|
|
166
|
+
// A short human-readable summary used by callers that want a one-liner.
|
|
167
|
+
let shadowDescription = null;
|
|
168
|
+
if (shadowed) {
|
|
169
|
+
shadowDescription =
|
|
170
|
+
`PATH resolves to ${pathFirstPackageRoot}` +
|
|
171
|
+
(pathFirstVersion ? ` (v${pathFirstVersion})` : "") +
|
|
172
|
+
`, but you just installed ${selfPackageRoot}` +
|
|
173
|
+
(selfVersion ? ` (v${selfVersion})` : "") + ".";
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
selfPackageRoot,
|
|
178
|
+
selfVersion,
|
|
179
|
+
pathFirstBin,
|
|
180
|
+
pathFirstPath: pathFirstPackageRoot,
|
|
181
|
+
pathFirstVersion,
|
|
182
|
+
npmGlobalPath,
|
|
183
|
+
npmGlobalVersion,
|
|
184
|
+
bunGlobalPath,
|
|
185
|
+
bunGlobalVersion,
|
|
186
|
+
shadowed,
|
|
187
|
+
shadowDescription,
|
|
188
|
+
recommendation,
|
|
189
|
+
};
|
|
190
|
+
}
|
package/scripts/launch.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { realpathSync, existsSync } from "node:fs";
|
|
|
7
7
|
import { resolve, dirname } from "node:path";
|
|
8
8
|
import { fileURLToPath } from "node:url";
|
|
9
9
|
import { parseScriptArgs } from "./parse-script-args";
|
|
10
|
+
import { diagnoseShadow } from "./install-diagnosis.mjs";
|
|
10
11
|
import { version } from "../package.json";
|
|
11
12
|
|
|
12
13
|
export function launch(mode: "dev" | "start"): void {
|
|
@@ -49,7 +50,38 @@ export function launch(mode: "dev" | "start"): void {
|
|
|
49
50
|
?? resolve(dirname(realpathSync(fileURLToPath(import.meta.url))), "..");
|
|
50
51
|
const serverJsPath = resolve(packageRoot, ".next/standalone/server.js");
|
|
51
52
|
if (!existsSync(serverJsPath)) {
|
|
53
|
+
// Most "missing server.js" reports come from a PATH shadow (an older
|
|
54
|
+
// `bun link` or a `bun install -g` whose prefix wins over npm), not from
|
|
55
|
+
// a genuinely broken build. Diagnose first so the error message names
|
|
56
|
+
// the actual cause when that's what's going on.
|
|
57
|
+
let shadowMessage: string | null = null;
|
|
58
|
+
try {
|
|
59
|
+
const diag = diagnoseShadow({ selfPackageRoot: packageRoot, selfVersion: version });
|
|
60
|
+
if (diag.shadowed) {
|
|
61
|
+
// Pick whichever alternate install exists at npm/bun globals AND
|
|
62
|
+
// differs from PATH-first. In the runtime stale-binary scenario the
|
|
63
|
+
// running install IS the PATH-first one, so we'd otherwise point the
|
|
64
|
+
// user back at themselves.
|
|
65
|
+
const alt =
|
|
66
|
+
(diag.npmGlobalPath && diag.npmGlobalPath !== diag.pathFirstPath
|
|
67
|
+
? { path: diag.npmGlobalPath, version: diag.npmGlobalVersion }
|
|
68
|
+
: null)
|
|
69
|
+
?? (diag.bunGlobalPath && diag.bunGlobalPath !== diag.pathFirstPath
|
|
70
|
+
? { path: diag.bunGlobalPath, version: diag.bunGlobalVersion }
|
|
71
|
+
: null);
|
|
72
|
+
const newer = alt?.path ?? "(unknown)";
|
|
73
|
+
const newerVer = alt?.version ?? "?";
|
|
74
|
+
shadowMessage =
|
|
75
|
+
`\nError: failproofai on your PATH is a stale install that no longer has its build output.\n` +
|
|
76
|
+
` Running: ${diag.pathFirstPath}` + (diag.pathFirstVersion ? ` (v${diag.pathFirstVersion})` : "") + `\n` +
|
|
77
|
+
` Newer copy: ${newer} (v${newerVer})\n\n` +
|
|
78
|
+
`Remove the shadow with:\n ${diag.recommendation}\n`;
|
|
79
|
+
}
|
|
80
|
+
} catch {
|
|
81
|
+
// Diagnosis is best-effort; fall back to the original message.
|
|
82
|
+
}
|
|
52
83
|
console.error(
|
|
84
|
+
shadowMessage ??
|
|
53
85
|
`\nError: Cannot find server.js at:\n ${serverJsPath}\n\n` +
|
|
54
86
|
`The package may be missing its build output.\n` +
|
|
55
87
|
`Try reinstalling:\n npm install -g failproofai@latest\n`
|
package/scripts/postinstall.mjs
CHANGED
|
@@ -12,6 +12,7 @@ import { resolve } from "node:path";
|
|
|
12
12
|
import { platform, arch, release, homedir, hostname } from "node:os";
|
|
13
13
|
import { createHmac } from "node:crypto";
|
|
14
14
|
import { trackInstallEvent } from "./install-telemetry.mjs";
|
|
15
|
+
import { diagnoseShadow } from "./install-diagnosis.mjs";
|
|
15
16
|
|
|
16
17
|
// Skip when running in development context (e.g. `bun install` in the source repo).
|
|
17
18
|
// INIT_CWD is set by npm/bun to the directory where install was invoked; it differs
|
|
@@ -29,6 +30,30 @@ if (!existsSync(serverJsPath)) {
|
|
|
29
30
|
process.exit(1);
|
|
30
31
|
}
|
|
31
32
|
|
|
33
|
+
// Detect when an older `failproofai` is shadowing this fresh install on PATH —
|
|
34
|
+
// classic case is a leftover `bun link` from a prior dev session, or a
|
|
35
|
+
// `bun install -g` whose ~/.bun/bin sorts ahead of npm's prefix. Without this
|
|
36
|
+
// warning the user only finds out later via a confusing runtime error from
|
|
37
|
+
// scripts/launch.ts pointing at the *old* install's missing build output.
|
|
38
|
+
try {
|
|
39
|
+
let selfVersion = null;
|
|
40
|
+
try {
|
|
41
|
+
selfVersion = JSON.parse(readFileSync(resolve(process.cwd(), "package.json"), "utf8")).version ?? null;
|
|
42
|
+
} catch {}
|
|
43
|
+
const diag = diagnoseShadow({ selfPackageRoot: process.cwd(), selfVersion });
|
|
44
|
+
if (diag.shadowed) {
|
|
45
|
+
console.warn(
|
|
46
|
+
`\n[failproofai] Warning: another failproofai install is earlier on your PATH.\n` +
|
|
47
|
+
` Just installed: ${diag.selfPackageRoot}` + (diag.selfVersion ? ` (v${diag.selfVersion})` : "") + `\n` +
|
|
48
|
+
` PATH resolves : ${diag.pathFirstPath}` + (diag.pathFirstVersion ? ` (v${diag.pathFirstVersion})` : "") + `\n\n` +
|
|
49
|
+
` Your shell will run the older copy. Remove the shadow with:\n` +
|
|
50
|
+
` ${diag.recommendation}\n`
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
} catch {
|
|
54
|
+
// Diagnosis is best-effort — never fail the install over a warning.
|
|
55
|
+
}
|
|
56
|
+
|
|
32
57
|
const FAILPROOFAI_HOOK_MARKER = "__failproofai_hook__";
|
|
33
58
|
const NAMESPACE = "failproofai-telemetry-v1";
|
|
34
59
|
|