@makerstufferupper/flypi 0.1.3 → 0.1.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@makerstufferupper/flypi",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "type": "module",
5
5
  "description": "Local multi-model agent tool with a browser UI and a personal knowledge wiki, built on the pi SDK.",
6
6
  "license": "MIT",
@@ -24,10 +24,12 @@
24
24
  },
25
25
  "scripts": {
26
26
  "probe": "tsx scripts/probe.ts",
27
+ "probe:pi": "tsx scripts/probe-pi.ts",
27
28
  "probe:turn": "tsx scripts/probe-turn-boundary.ts",
28
29
  "probe:boundary": "tsx scripts/turn-boundary.ts",
29
30
  "start": "node scripts/start.mjs",
30
31
  "dev": "vite",
32
+ "dev:container": "node scripts/dev-container.mjs",
31
33
  "models:warm": "tsx scripts/warm-models.ts",
32
34
  "build": "vite build",
33
35
  "prepublishOnly": "npm run typecheck && npm test && npm run build",
@@ -43,20 +45,21 @@
43
45
  "serve": "tsx scripts/serve.ts",
44
46
  "daemon": "tsx scripts/serve-daemon.ts",
45
47
  "flypi": "node scripts/flypi.mjs",
46
- "release": "node scripts/release.mjs"
48
+ "release": "node scripts/release.mjs",
49
+ "release:check": "node scripts/release-check.mjs"
47
50
  },
48
51
  "dependencies": {
49
- "@earendil-works/pi-coding-agent": "^1.0.4",
52
+ "@earendil-works/pi-coding-agent": "^1.4.0",
50
53
  "@huggingface/transformers": "^4.3.1",
51
54
  "@modelcontextprotocol/sdk": "^1.32.1",
52
- "tsx": "^4.23.15",
53
55
  "marked": "18.0.11",
56
+ "tsx": "^4.23.15",
54
57
  "typebox": "^1.3.27",
55
58
  "ws": "^8.22.0",
56
59
  "yaml": "^2.9.0"
57
60
  },
58
61
  "devDependencies": {
59
- "@earendil-works/pi-ai": "^1.0.4",
62
+ "@earendil-works/pi-ai": "^1.4.0",
60
63
  "@modelcontextprotocol/server-filesystem": "^1.0.0",
61
64
  "@sveltejs/vite-plugin-svelte": "^7.3.1",
62
65
  "@testing-library/svelte": "^5.4.2",
@@ -68,5 +71,12 @@
68
71
  "typescript": "^7.0.2",
69
72
  "vite": "^8.3.3",
70
73
  "vitest": "^5.0.3"
74
+ },
75
+ "allowScripts": {
76
+ "@google/genai@2.21.0": true,
77
+ "esbuild@0.28.2": true,
78
+ "fsevents@2.3.3": true,
79
+ "onnxruntime-node@1.30.0": true,
80
+ "protobufjs@7.6.6": true
71
81
  }
72
82
  }
@@ -0,0 +1,131 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * The local dev container, rebuilt from the working tree (docs/release.md, "Deploying something that is
4
+ * *not* released"). One command for what used to be four.
5
+ *
6
+ * npm run dev:container
7
+ *
8
+ * build → pack → `docker build` with `docker/Dockerfile.local` and the tarball's directory as the build
9
+ * context (the repo's `.dockerignore` excludes `*.tgz`, so the repo is never the context) → replace the
10
+ * `flypi` container with one off the new image → wait for `/healthz`.
11
+ *
12
+ * It removes the `flypi` container and the `flypi:local-*` images older than the one it built. It never
13
+ * removes the `flypi-data` volume: that holds the memory models (~933 MB), and re-downloading them is the
14
+ * slowest part of the next start — which is also why the healthcheck gets a 15-minute start period.
15
+ *
16
+ * No gates here; `npm run release:check` is the rehearsal that runs them. This is the fast path, and it
17
+ * touches only the names above: never a published image, never a container this script did not build.
18
+ */
19
+ import { spawnSync } from "node:child_process";
20
+ import { copyFileSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
21
+ import { tmpdir } from "node:os";
22
+ import { join } from "node:path";
23
+
24
+ const NAME = "flypi";
25
+ const PORT = 8791;
26
+ const VOLUME = "flypi-data";
27
+
28
+ if (process.argv.includes("--help") || process.argv.includes("-h")) {
29
+ console.log(`flypi dev container — docs/release.md "Deploying something that is *not* released"
30
+
31
+ npm run dev:container build → pack → image → replace the ${NAME} container on 127.0.0.1:${PORT}
32
+
33
+ Removes: the ${NAME} container, and flypi:local-* images older than the one it just built.
34
+ Keeps: the ${VOLUME} volume — the memory models live there, and this script never deletes a volume.
35
+ Tags: flypi:local-<commit sha>, plus -dirty if the tree is not clean, because the package version
36
+ inside such an image collides with the published one.
37
+ Stages: build → pack → image → replace → serve → prune`);
38
+ process.exit(0);
39
+ }
40
+
41
+ const version = JSON.parse(readFileSync("package.json", "utf8")).version;
42
+ const die = (message) => {
43
+ console.error(`dev:container: ${message}`);
44
+ process.exit(1);
45
+ };
46
+ function git(args) {
47
+ const result = spawnSync("git", args, { encoding: "utf8" });
48
+ if (result.status !== 0) die(`git ${args.join(" ")}: ${result.stderr.trim()}`);
49
+ return result.stdout.trim();
50
+ }
51
+ /** One step. It stops at the first failure: a half-replaced dev container is worse than an old one. */
52
+ function run(command, args, { quiet = false } = {}) {
53
+ const result = spawnSync(command, args, { encoding: "utf8" });
54
+ if (result.status !== 0) {
55
+ die(`${command} ${args.join(" ")} exited ${result.status}${result.stderr ? `: ${result.stderr.trim().split("\n").slice(-3).join(" ")}` : ""}`);
56
+ }
57
+ if (!quiet && result.stdout) process.stdout.write(result.stdout);
58
+ return result;
59
+ }
60
+ const step = (what) => console.log(`\n${what}`);
61
+
62
+ const sha = git(["rev-parse", "--short", "HEAD"]);
63
+ const tag = `flypi:local-${sha}${git(["status", "--porcelain"]) ? "-dirty" : ""}`;
64
+ const packDir = mkdtempSync(join(tmpdir(), "flypi-dev-"));
65
+
66
+ try {
67
+ step("build — dist/ui, which the tarball ships");
68
+ run("npm", ["run", "build"]);
69
+
70
+ step("pack — the same artifact npm would publish, installed by the image");
71
+ run("npm", ["pack", "--pack-destination", packDir]);
72
+ const tgz = `makerstufferupper-flypi-${version}.tgz`;
73
+ copyFileSync("docker/Dockerfile.local", join(packDir, "Dockerfile"));
74
+
75
+ step(`image — ${tag} from ${tgz}`);
76
+ run("docker", ["build", "-q", "--build-arg", `FLYPI_TGZ=${tgz}`, "-t", tag, packDir]);
77
+
78
+ step(`replace — container ${NAME} on 127.0.0.1:${PORT}, volume ${VOLUME} kept`);
79
+ // `rm -f` on a name that is not there exits 1; that is the normal first run, not a failure.
80
+ spawnSync("docker", ["rm", "-f", NAME], { encoding: "utf8" });
81
+ run("docker", [
82
+ "run",
83
+ "-d",
84
+ "--name",
85
+ NAME,
86
+ "-p",
87
+ `127.0.0.1:${PORT}:${PORT}`,
88
+ "-v",
89
+ `${VOLUME}:/data`,
90
+ "-e",
91
+ "FLYPI_ROOT=/data",
92
+ // The image's own CMD downloads the memory models on a fresh state root, so "unhealthy" for the
93
+ // first quarter hour is the download, not a broken build.
94
+ "--health-cmd",
95
+ `node -e "fetch('http://127.0.0.1:${PORT}/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"`,
96
+ "--health-interval",
97
+ "30s",
98
+ "--health-timeout",
99
+ "5s",
100
+ "--health-start-period",
101
+ "900s",
102
+ "--health-retries",
103
+ "3",
104
+ tag,
105
+ ]);
106
+
107
+ step(`serve — waiting on http://127.0.0.1:${PORT}/healthz`);
108
+ const deadline = Date.now() + 180_000;
109
+ let body = null;
110
+ while (Date.now() < deadline && !body) {
111
+ try {
112
+ const response = await fetch(`http://127.0.0.1:${PORT}/healthz`);
113
+ if (response.ok) body = await response.json();
114
+ } catch {}
115
+ if (!body) await new Promise((resolve) => setTimeout(resolve, 2000));
116
+ }
117
+ if (!body) die(`${NAME} never answered /healthz — docker logs ${NAME}`);
118
+ console.log(` ok flypi ${body.version} · pi ${body.pi?.version ?? "?"} · ${tag}`);
119
+
120
+ step("prune — the flypi:local-* images this replaces");
121
+ const stale = run("docker", ["image", "ls", "flypi:local-*", "--format", "{{.Repository}}:{{.Tag}}"], { quiet: true }).stdout
122
+ .split("\n")
123
+ .map((image) => image.trim())
124
+ .filter((image) => image && image !== tag);
125
+ for (const image of stale) run("docker", ["rmi", image], { quiet: true });
126
+ console.log(stale.length ? ` removed ${stale.join(", ")}` : " nothing older to remove");
127
+
128
+ console.log(`\n${NAME} is on http://127.0.0.1:${PORT} — docker logs -f ${NAME} to watch it`);
129
+ } finally {
130
+ rmSync(packDir, { recursive: true, force: true });
131
+ }
package/scripts/flypi.mjs CHANGED
@@ -10,13 +10,14 @@
10
10
  * node scripts/flypi.mjs start --foreground --port 8791 --host 0.0.0.0 # the container entry point
11
11
  * node scripts/flypi.mjs status | stop | restart | logs [-n 50] [-f]
12
12
  * node scripts/flypi.mjs update [--check] # the newest published flypi (docs/architecture.md Phase 7)
13
+ * node scripts/flypi.mjs pi [list | available | use <v> | rollback | unpin] # which pi it runs (docs/pi-upgrades.md Phase 8.3)
13
14
  *
14
15
  * `--root DIR` (or FLYPI_ROOT) picks which `.flypi/` the CLI owns; in a checkout it defaults to the cwd,
15
16
  * and the installed `bin/flypi.mjs` sets FLYPI_ROOT to the home directory before importing this.
16
17
  */
17
18
  import { mkdirSync, openSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
18
19
  import { dirname, resolve } from "node:path";
19
- import { spawn } from "node:child_process";
20
+ import { spawn, spawnSync } from "node:child_process";
20
21
  import { fileURLToPath } from "node:url";
21
22
 
22
23
  const argv = process.argv.slice(2);
@@ -32,6 +33,7 @@ const checkout = resolve(fileURLToPath(import.meta.url), "..", "..");
32
33
  const serveDaemon = resolve(fileURLToPath(import.meta.url), "..", "serve-daemon.ts");
33
34
  const secretCli = resolve(fileURLToPath(import.meta.url), "..", "secret.ts");
34
35
  const updateCli = resolve(fileURLToPath(import.meta.url), "..", "update.ts");
36
+ const piCli = resolve(fileURLToPath(import.meta.url), "..", "pi.ts");
35
37
  const infoPath = resolve(root, ".flypi", "daemon.json");
36
38
  const logPath = resolve(root, ".flypi", "daemon.log");
37
39
  /**
@@ -161,6 +163,8 @@ function report(info, status) {
161
163
  console.log(`flypi: agent http://${info.host}:${info.port} (token ${info.token})`);
162
164
  console.log(`flypi: memory http://127.0.0.1:${info.memoryPort}/mcp`);
163
165
  if (status) console.log(`flypi: started ${status.startedAt}, version ${status.version}, ${status.jobs} job(s) queued`);
166
+ // Which pi it loaded (src/daemon/pi.ts) — not the range package.json declares, which is the point.
167
+ if (status?.pi) console.log(`flypi: pi ${status.pi.version}${status.pi.pinned ? " (pinned)" : ""}${status.pi.bundled ? ` — the installed tree holds ${status.pi.bundled}` : ""}`);
164
168
  // The daemon's own daily answer (src/daemon/update.ts) — `status` does not reach the network itself.
165
169
  if (status?.updateAvailable) console.log(`flypi: ${status.updateAvailable} is available — \`flypi update\` installs it`);
166
170
  if (status?.updateReady) console.log(`flypi: ${status.updateReady} is downloaded — it installs once the daemon has been idle`);
@@ -186,6 +190,13 @@ async function start({ foreground = false, previous } = {}) {
186
190
  // sticky — and it merges, so naming just `--port` does not drop an `--host` that is still in force.
187
191
  const namedFlags = { ...(flagValue("--port") ? { port: Number(flagValue("--port")) } : {}), ...(flagValue("--host") ? { host: flagValue("--host") } : {}) };
188
192
  if (Object.keys(namedFlags).length > 0) writeFileSync(flagsPath, `${JSON.stringify({ ...readJson(flagsPath), ...namedFlags })}\n`);
193
+ // Which pi the daemon is about to resolve (src/daemon/pi.ts). A flypi upgrade can replace the package
194
+ // directory and take the pointer with it, so a pin whose tree exists is re-applied here — in this process,
195
+ // which has not imported pi, and before the daemon exists, because a daemon resolves its module graph at
196
+ // import and cannot re-pick its own pi.
197
+ const relink = spawnSync(process.execPath, ["--import", "tsx", piCli, "--root", root, "relink"], { cwd: checkout, encoding: "utf8" });
198
+ if (relink.stdout?.trim()) console.log(relink.stdout.trim());
199
+ if (relink.status !== 0) console.error(`flypi: the pi pin could not be applied: ${(relink.stderr ?? "").trim() || `it exited ${relink.status}`}`);
189
200
  if (foreground) {
190
201
  const child = spawn(process.execPath, ["--import", "tsx", serveDaemon, "--root", root, ...flags], { cwd: checkout, stdio: "inherit" });
191
202
  // Ctrl-C and `docker stop` reach this process, not the daemon. PID 1 in a pid namespace drops a signal it
@@ -288,6 +299,12 @@ const flagNumber = (name) => {
288
299
  const i = argv.indexOf(name);
289
300
  return i === -1 ? undefined : Number(argv[i + 1]);
290
301
  };
302
+ /** `flypi pi …` (docs/pi-upgrades.md Phase 8.3): which pi this flypi runs, and moving it. */
303
+ async function pi(args) {
304
+ const child = spawn(process.execPath, ["--import", "tsx", piCli, "--root", root, ...args], { cwd: checkout, stdio: "inherit" });
305
+ return new Promise((done) => child.on("exit", (code) => done(code ?? 0)));
306
+ }
307
+
291
308
  const result = await {
292
309
  start: () => start({ foreground: argv.includes("--foreground") }),
293
310
  stop,
@@ -302,9 +319,10 @@ const result = await {
302
319
  logs: () => logs({ ...(flagNumber("-n") ? { lines: flagNumber("-n") } : {}), follow: argv.includes("-f") || argv.includes("--follow") }),
303
320
  secret: () => secret(argv.slice(1)),
304
321
  update: () => update(argv.slice(1)),
322
+ pi: () => pi(argv.slice(1)),
305
323
  }[command]?.();
306
324
  if (result === undefined) {
307
- console.log("usage: flypi [--root DIR] start [--foreground] [--port N] [--host H] [--embeddings none] [--judge none] | stop | restart | status | logs [-n N] [-f] | update [--check] | secret …");
325
+ console.log("usage: flypi [--root DIR] start [--foreground] [--port N] [--host H] [--embeddings none] [--judge none] | stop | restart | status | logs [-n N] [-f] | update [--check] | pi [list | available | use <version> | rollback | unpin] | secret …");
308
326
  process.exit(2);
309
327
  }
310
328
  process.exit(result);
package/scripts/pi.ts ADDED
@@ -0,0 +1,150 @@
1
+ /**
2
+ * `flypi pi …` — which pi this flypi runs, and how to move it (docs/pi-upgrades.md Phase 8.3).
3
+ *
4
+ * flypi pi list the loaded version, the pin, and the trees on disk
5
+ * flypi pi available what the registry publishes, one rung at a time
6
+ * flypi pi use <version> probe it, flip the pointer, restart, keep it only if it answers
7
+ * flypi pi rollback back to the pinned predecessor or the tree flypi shipped with
8
+ * flypi pi unpin put the installed pi back where npm put it
9
+ * flypi pi relink re-apply the pin after an upgrade dropped the link (run by `start`)
10
+ *
11
+ * Run through the CLI (`scripts/flypi.mjs` spawns this the way it spawns `update.ts`), because the flip has
12
+ * to happen in a process that has not imported pi yet — the daemon resolves its module graph at import, so
13
+ * it cannot re-pick its own pi.
14
+ */
15
+ import { readFileSync } from "node:fs";
16
+ import { spawn, spawnSync } from "node:child_process";
17
+ import { resolve } from "node:path";
18
+ import { readDaemonInfo } from "../src/daemon/index.ts";
19
+ import {
20
+ bundledPiVersion,
21
+ loadedPi,
22
+ piTrees,
23
+ readPiPin,
24
+ piScope,
25
+ scopeVersion,
26
+ restoreBundledScope,
27
+ relinkFromPin,
28
+ switchPiVersion,
29
+ PI_PACKAGE,
30
+ } from "../src/daemon/pi.ts";
31
+ import { checkForUpdate } from "../src/daemon/update.ts";
32
+
33
+ const argv = process.argv.slice(2);
34
+ const rootAt = argv.indexOf("--root");
35
+ const root = resolve(rootAt === -1 ? process.env.FLYPI_ROOT ?? process.cwd() : argv.splice(rootAt, 2)[1]!);
36
+ const command = argv[0] ?? "list";
37
+ const cli = resolve(import.meta.dirname, "flypi.mjs");
38
+
39
+ /**
40
+ * Stop and start the daemon through the CLI, so the port and interface carry over as they always do — but
41
+ * only when one is running. `flypi restart` on a stopped daemon *starts* it, which would turn "move pi" into
42
+ * a first run: a new `.flypi/`, a model download, a server on a port nobody asked for.
43
+ */
44
+ const restart = async (): Promise<void> => {
45
+ if (!(await piFromHealth())) return;
46
+ await new Promise<void>((done) => {
47
+ const child = spawn(process.execPath, [cli, "--root", root, "restart"], { stdio: "inherit" });
48
+ child.on("exit", () => done());
49
+ });
50
+ };
51
+
52
+ /** What `/healthz` says about pi — null when no daemon is answering. */
53
+ const piFromHealth = async () => {
54
+ const info = readDaemonInfo(root);
55
+ if (!info) return null;
56
+ try {
57
+ return ((await (await fetch(`http://127.0.0.1:${info.port}/healthz`)).json()) as { pi?: { version: string; pinned: boolean; bundled?: string } }).pi ?? null;
58
+ } catch {
59
+ return null;
60
+ }
61
+ };
62
+
63
+ /**
64
+ * What the switch should believe about what is running: the daemon's own answer when one is up, and otherwise
65
+ * what the pointer now says — the version the next process will load. Without the second half a switch made
66
+ * while the daemon was stopped would be judged a failure and reverted.
67
+ */
68
+ const piAfterSwitch = async (): Promise<{ version: string; pinned: boolean; bundled?: string } | null> => {
69
+ const health = await piFromHealth();
70
+ if (health) return health;
71
+ const loaded = scopeVersion(piScope());
72
+ return loaded ? { version: loaded, pinned: true } : null;
73
+ };
74
+
75
+ const logTail = () => {
76
+ try {
77
+ return readFileSync(resolve(root, ".flypi", "daemon.log"), "utf8").trimEnd().split("\n").slice(-20).join("\n");
78
+ } catch {
79
+ return "";
80
+ }
81
+ };
82
+
83
+ const report = async ({ ok, message }: { ok: boolean; message: string }): Promise<number> => {
84
+ console.log(message);
85
+ return ok ? 0 : 1;
86
+ };
87
+
88
+ switch (command) {
89
+ case "relink": {
90
+ // `flypi start` runs this before it spawns the daemon: the daemon resolves pi at import, so the link
91
+ // has to be right before it starts, not after.
92
+ // Silent when there is nothing to say: `flypi start` runs this every time, and a line about a pin that
93
+ // did nothing would be noise on every start of every machine that never pinned pi.
94
+ const line = relinkFromPin(root, piScope());
95
+ if (line) console.log(`flypi: ${line}`);
96
+ break;
97
+ }
98
+ case "list": {
99
+ const pi = loadedPi(root);
100
+ const pin = readPiPin(root);
101
+ console.log(`flypi: pi ${pi.version}${pi.pinned ? " (pinned)" : ""}${pi.bundled ? `, installed tree holds ${pi.bundled}` : ""}`);
102
+ console.log(`flypi: trees on disk: ${piTrees(root).join(", ") || "none"}${bundledPiVersion(root) ? `; bundled ${bundledPiVersion(root)}` : ""}`);
103
+ if (pin) console.log(`flypi: pinned to ${pin.version} since ${pin.pinnedAt}${pin.verified ? "" : " — never confirmed to have started"}`);
104
+ break;
105
+ }
106
+ case "available": {
107
+ // The same registry answer flypi's own ladder is built from, pointed at pi's package name: one rung at
108
+ // a time, because a jump runs one version's session/config format change over a file the intermediate
109
+ // version would have stepped.
110
+ const current = loadedPi(root).version;
111
+ const check = await checkForUpdate({ current, name: PI_PACKAGE });
112
+ if (check.error) {
113
+ console.log(`flypi: ${check.error}`);
114
+ break;
115
+ }
116
+ console.log(`flypi: pi ${current} is running; the newest published is ${check.latest}`);
117
+ console.log(`flypi: one rung at a time — ${check.next ? `flypi pi use ${check.next}` : "nothing newer than what is running"}`);
118
+ break;
119
+ }
120
+ case "use": {
121
+ const version = argv[1];
122
+ if (!version) {
123
+ console.log("flypi: `flypi pi use <version>` — `flypi pi available` lists what is published");
124
+ process.exit(2);
125
+ }
126
+ process.exit(await report(await switchPiVersion(version, { root, scope: piScope(), restart, pi: piFromHealth, logTail })));
127
+ }
128
+ case "rollback": {
129
+ const pin = readPiPin(root);
130
+ const back = pin?.from ?? bundledPiVersion(root);
131
+ if (!back) {
132
+ console.log("flypi: nothing to roll back to — no pin, and the tree flypi was installed with is not on disk");
133
+ process.exit(1);
134
+ }
135
+ process.exit(await report(await switchPiVersion(back, { root, scope: piScope(), restart, pi: piFromHealth, logTail })));
136
+ }
137
+ case "unpin": {
138
+ const restored = restoreBundledScope(root, piScope());
139
+ if (!restored.ok) {
140
+ console.log(`flypi: ${restored.message}`);
141
+ process.exit(1);
142
+ }
143
+ if (await piFromHealth()) spawnSync(process.execPath, [cli, "--root", root, "restart"], { stdio: "inherit" });
144
+ console.log(`flypi: ${restored.message}; the pin is gone`);
145
+ break;
146
+ }
147
+ default:
148
+ console.log("usage: flypi pi [list | available | use <version> | rollback | unpin]");
149
+ process.exit(2);
150
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Stage 1 of the pi upgrade probe (docs/pi-upgrades.md Phase 8.2): does a candidate pi still offer the
3
+ * surface flypi imports? Imports, deep paths and the Node floor, in a child process, with nothing restarted.
4
+ *
5
+ * Run: `npm run probe:pi -- --candidate DIR` — DIR is a tree built by `npm install --prefix` (Phase 8.3), or
6
+ * this repo's own root to check the pi that is already installed. Exit 0 clean, 1 with one line per problem.
7
+ *
8
+ * A clean run means "nothing renamed, moved or raised a floor", not "flypi works on it". The behavioural
9
+ * half of pi's API is only covered by `npm test` and `npm run smoke` (docs/risks.md risk 14).
10
+ */
11
+ import { checkPiCompat } from "../src/agent/pi-compat.ts";
12
+
13
+ const argv = process.argv.slice(2);
14
+ const candidate = argv[argv.indexOf("--candidate") + 1] ?? process.cwd();
15
+ const failures = await checkPiCompat(candidate);
16
+ for (const failure of failures) console.error(`pi compat: ${failure}`);
17
+ console.log(failures.length ? `pi compat: ${failures.length} problem(s) in ${candidate}` : `pi compat: ok — ${candidate}`);
18
+ process.exit(failures.length ? 1 : 0);