@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.
@@ -0,0 +1,239 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * The release rehearsal (docs/release-checks.md). It runs the checks that have to pass before a version goes
4
+ * to the registry, and with `--published` the ones that prove what landed there is what was built.
5
+ *
6
+ * node scripts/release-check.mjs # gates → pack → npm-install → image
7
+ * node scripts/release-check.mjs --published # registry + pushed image + run the published image
8
+ * node scripts/release-check.mjs --skip-gates # the docker half only
9
+ * node scripts/release-check.mjs --keep # leave the containers and volumes for a look
10
+ *
11
+ * It never publishes, pushes an image, or touches a container it did not create: everything it makes is named
12
+ * `flypi-check-*` on ports 8792/8793, so the serving `flypi` container on 8791 and its `flypi-data` volume are
13
+ * out of reach. Exit code is the number of failed checks.
14
+ */
15
+ import { spawnSync } from "node:child_process";
16
+ import { copyFileSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
17
+ import { tmpdir } from "node:os";
18
+ import { join } from "node:path";
19
+
20
+ const argv = process.argv.slice(2);
21
+ const has = (flag) => argv.includes(flag);
22
+ const valueOf = (flag) => {
23
+ const at = argv.indexOf(flag);
24
+ return at === -1 ? undefined : argv[at + 1];
25
+ };
26
+ const version = valueOf("--version") ?? JSON.parse(readFileSync("package.json", "utf8")).version;
27
+
28
+ if (has("--help") || has("-h")) {
29
+ console.log(`flypi release rehearsal — docs/release-checks.md
30
+
31
+ npm run release:check gates → pack → npm-install → image
32
+ npm run release:check -- --published the registry and the pushed image, after the publish
33
+ npm run release:check -- --skip-gates the docker half only
34
+ npm run release:check -- --keep leave the containers and volumes for a look
35
+ npm run release:check -- --version <v> check a version other than package.json's
36
+ npm run release:check -- --help this list (-h is the same)
37
+
38
+ It never publishes or pushes, and only touches containers named flypi-check-*.`);
39
+ process.exit(0);
40
+ }
41
+ const IMAGE = "makerstufferupper/flypi";
42
+ const TARBALL_URL = `https://registry.npmjs.org/@makerstufferupper/flypi/-/flypi-${version}.tgz`;
43
+
44
+ let failures = 0;
45
+ const ok = (name, detail) => console.log(` ok ${name}${detail ? ` — ${detail}` : ""}`);
46
+ const bad = (name, detail) => {
47
+ failures++;
48
+ console.log(` FAIL ${name}${detail ? ` — ${detail}` : ""}`);
49
+ };
50
+ /** One check: run something, say whether it worked, keep going either way. */
51
+ function check(name, command, args, options = {}) {
52
+ const result = spawnSync(command, args, { encoding: "utf8", ...options });
53
+ if (result.status === 0) {
54
+ ok(name, options.show && result.stdout ? result.stdout.trim().split("\n").at(-1) : undefined);
55
+ return result;
56
+ }
57
+ bad(name, `${command} ${args.join(" ")} exited ${result.status}${result.stderr ? `: ${result.stderr.trim().split("\n")[0]}` : ""}`);
58
+ return result;
59
+ }
60
+
61
+ const packDir = mkdtempSync(join(tmpdir(), "flypi-release-"));
62
+ const created = { containers: [], volumes: [], images: [] };
63
+
64
+ function docker(args, options = {}) {
65
+ return spawnSync("docker", args, { encoding: "utf8", ...options });
66
+ }
67
+ /** A container of our own, on a port and a volume nothing else is using. */
68
+ function freshContainer(name, image, port, volume, command) {
69
+ docker(["rm", "-f", name]);
70
+ docker(["volume", "rm", volume]);
71
+ created.containers.push(name);
72
+ created.volumes.push(volume);
73
+ const run = docker(["run", "-d", "--name", name, "-p", `127.0.0.1:${port}:${port}`, "-v", `${volume}:/data`, "-e", "FLYPI_ROOT=/data", image, ...command]);
74
+ return run.status === 0 ? name : (bad(`docker run ${image}`, run.stderr.trim().split("\n")[0]), null);
75
+ }
76
+ /** Poll /healthz — the first start of a fresh state root is not instant. */
77
+ async function health(port, seconds = 180) {
78
+ const deadline = Date.now() + seconds * 1000;
79
+ while (Date.now() < deadline) {
80
+ try {
81
+ const response = await fetch(`http://127.0.0.1:${port}/healthz`);
82
+ if (response.ok) return await response.json();
83
+ } catch {}
84
+ await new Promise((resolve) => setTimeout(resolve, 2000));
85
+ }
86
+ return null;
87
+ }
88
+ /** The three things every serving flypi owes: its own version, the pi it loaded, and a page. */
89
+ async function assertServing(name, port, expectVersion) {
90
+ const body = await health(port);
91
+ if (!body) return bad(`${name}: /healthz never answered`, `port ${port}`);
92
+ body.version === expectVersion
93
+ ? ok(`${name}: /healthz reports flypi ${body.version}`)
94
+ : bad(`${name}: /healthz version`, `expected ${expectVersion}, got ${body.version}`);
95
+ body.pi?.version
96
+ ? ok(`${name}: it loaded pi ${body.pi.version}`, body.pi.pinned ? "pinned" : "not pinned")
97
+ : bad(`${name}: /healthz names no pi`, "Phase 8.1 — the loaded version must be reported");
98
+ const page = spawnSync("curl", ["-s", "-o", "/dev/null", "-w", "%{http_code}", `http://127.0.0.1:${port}/`], { encoding: "utf8" });
99
+ page.stdout === "200" ? ok(`${name}: GET / is 200`) : bad(`${name}: GET /`, `got ${page.stdout || "no answer"}`);
100
+ return body;
101
+ }
102
+ /**
103
+ * The agent has a bash tool in here, so the tools a turn reaches for have to be present *and* working: git over
104
+ * https, git-lfs, curl, wget. Slim has none of them, and a git-lfs binary without its filters registered still
105
+ * checks in an LFS file's contents rather than a pointer — so this tracks one and reads the blob back out.
106
+ */
107
+ function hasTools(name) {
108
+ const probe = docker([
109
+ "exec",
110
+ name,
111
+ "sh",
112
+ "-c",
113
+ "git --version >/dev/null && curl --version >/dev/null && wget --version >/dev/null" +
114
+ " && cd /tmp && rm -rf lfs-check && git init -q lfs-check && cd lfs-check && git lfs track '*.bin' >/dev/null" +
115
+ " && echo hello > a.bin && git -c user.email=flypi@example.com -c user.name=flypi add a.bin && git cat-file -p :a.bin",
116
+ ]);
117
+ /git-lfs\.github\.com\/spec\/v1/.test(probe.stdout ?? "")
118
+ ? ok(`${name}: it has git + git-lfs, curl and wget`)
119
+ : bad(`${name}: git + git-lfs, curl and wget`, `exit ${probe.status}: ${(probe.stderr || probe.stdout || "").trim().split("\n")[0]}`);
120
+ }
121
+ function cleanup() {
122
+ if (has("--keep")) return console.log(`\nkept: ${created.containers.join(", ")} · volumes ${created.volumes.join(", ")} · ${packDir}`);
123
+ for (const name of created.containers) docker(["rm", "-f", name]);
124
+ for (const volume of created.volumes) docker(["volume", "rm", volume]);
125
+ for (const image of created.images) docker(["rmi", image]);
126
+ rmSync(packDir, { recursive: true, force: true });
127
+ }
128
+
129
+ // ── stages ────────────────────────────────────────────────────────────────────────────────────────────────
130
+
131
+ function gates() {
132
+ console.log(`gates — the suite this version would be published with`);
133
+ check("tsc --noEmit", "npm", ["run", "typecheck"]);
134
+ check("npm test", "npm", ["test"]);
135
+ check("npm run smoke", "npm", ["run", "smoke"]);
136
+ check("npm run probe:pi", "npm", ["run", "probe:pi"]);
137
+ }
138
+
139
+ function pack() {
140
+ console.log(`\npack — the artifact, before anything installs it`);
141
+ check("npm run build (dist/ui)", "npm", ["run", "build"]);
142
+ check(`npm pack`, "npm", ["pack", "--pack-destination", packDir]);
143
+ const tgz = `makerstufferupper-flypi-${version}.tgz`;
144
+ const packed = spawnSync("tar", ["xzOf", join(packDir, tgz), "package/package.json"], { encoding: "utf8" });
145
+ if (packed.status !== 0) return bad(`read ${tgz}`, "npm pack produced nothing readable");
146
+ const pkg = JSON.parse(packed.stdout);
147
+ pkg.version === version ? ok(`tarball version ${pkg.version}`) : bad("tarball version", `package.json says ${pkg.version}`);
148
+ const self = Object.keys(pkg.dependencies).filter((d) => d.includes("makerstufferupper"));
149
+ self.length ? bad("no self-dependency", `${self.join(", ")} — flypi must not depend on flypi`) : ok("no self-dependency in dependencies");
150
+ const listed = spawnSync("tar", ["tzf", join(packDir, tgz)], { encoding: "utf8" }).stdout.split("\n");
151
+ for (const file of ["dist/ui/index.html", "bin/flypi.mjs", "src/daemon/pi.ts"]) {
152
+ listed.includes(`package/${file}`) ? ok(`ships ${file}`) : bad(`ships ${file}`, "missing from the tarball — check package.json `files`");
153
+ }
154
+ const stray = listed.filter((f) => /package\/(\.scratch|\.flypi|node_modules|coverage)\//.test(f));
155
+ stray.length ? bad("no stray files", stray.slice(0, 3).join(", ")) : ok("nothing stray (.scratch, .flypi, node_modules, coverage)");
156
+ return tgz;
157
+ }
158
+
159
+ async function npmInstall(tgz) {
160
+ console.log(`\nnpm-install — the tarball in a clean container, the way \`npm install -g\` does it`);
161
+ const name = "flypi-check-npm";
162
+ const container = freshContainer(name, "node:24-slim", 8792, "flypi-check-npm-data", ["sleep", "7200"]);
163
+ if (!container) return;
164
+ if (docker(["cp", join(packDir, tgz), `${name}:/tmp/flypi.tgz`]).status !== 0) return bad("docker cp the tarball");
165
+ check("npm i -g /tmp/flypi.tgz", "docker", ["exec", name, "npm", "i", "-g", "--no-audit", "--no-fund", "/tmp/flypi.tgz"]);
166
+ const installed = docker(["exec", name, "node", "-e", `console.log(require("/usr/local/lib/node_modules/@makerstufferupper/flypi/package.json").version)`]);
167
+ installed.stdout?.trim() === version ? ok(`installed package reports ${version}`) : bad("installed package version", installed.stdout?.trim() || installed.stderr?.trim());
168
+ docker(["exec", "-d", name, "flypi", "start", "--foreground", "--port", "8792", "--host", "0.0.0.0", "--embeddings", "none", "--judge", "none"]);
169
+ await assertServing(name, 8792, version);
170
+ }
171
+
172
+ async function image(tgz) {
173
+ console.log(`\nimage — the published image's runtime shape, built from the same tarball`);
174
+ copyFileSync("docker/Dockerfile.local", join(packDir, "Dockerfile"));
175
+ const tag = `flypi-check:${version}`;
176
+ const built = docker(["build", "-q", "--build-arg", `FLYPI_TGZ=${tgz}`, "-t", tag, packDir]);
177
+ if (built.status !== 0) return bad("docker build", built.stderr.trim().split("\n").slice(-3).join(" "));
178
+ created.images.push(tag);
179
+ ok(`docker build ${tag}`);
180
+ const name = "flypi-check-image";
181
+ // Keyword-only memory: the image's own CMD would fetch ~900 MB of models into a volume that is deleted after.
182
+ const container = freshContainer(name, tag, 8793, "flypi-check-image-data", ["flypi", "start", "--foreground", "--port", "8793", "--host", "0.0.0.0", "--embeddings", "none", "--judge", "none"]);
183
+ if (!container) return;
184
+ await assertServing(name, 8793, version);
185
+ // The container must refuse to move pi in one sentence, not a stack trace (docs/pi-upgrades.md 8.3).
186
+ const refused = docker(["exec", name, "flypi", "pi", "use", "0.0.0"]);
187
+ /std|not writable|not the npm global/.test(`${refused.stdout}${refused.stderr}`) && refused.status !== 0
188
+ ? ok("flypi pi use refuses in a container, in one sentence")
189
+ : bad("flypi pi use in a container", `exit ${refused.status}: ${(refused.stderr || refused.stdout).trim().split("\n")[0]}`);
190
+ const state = docker(["exec", name, "sh", "-c", "ls /data/.flypi/pi 2>&1"]);
191
+ /state/.test(state.stdout) ? bad("a refused switch leaves no pin behind", state.stdout.trim()) : ok("a refused switch left nothing behind");
192
+ hasTools(name);
193
+ }
194
+
195
+ async function published() {
196
+ console.log(`\npublished — that ${version} is really in the registry, and the image installs it`);
197
+ // The metadata and the tarball propagate at different speeds: `npm view` can answer minutes before the
198
+ // tarball URL stops 404-ing, which is what kills a pinned image build. Poll the URL, not the tag.
199
+ let code = "000";
200
+ for (let i = 0; i < 30 && code !== "200"; i++) {
201
+ code = spawnSync("curl", ["-s", "-o", "/dev/null", "-w", "%{http_code}", TARBALL_URL], { encoding: "utf8" }).stdout;
202
+ if (code !== "200") await new Promise((resolve) => setTimeout(resolve, 10000));
203
+ }
204
+ code === "200" ? ok("the registry serves the tarball") : bad("the registry serves the tarball", `${code} after 5 min — ${TARBALL_URL}`);
205
+ const latest = spawnSync("npm", ["view", "@makerstufferupper/flypi", "version"], { encoding: "utf8" });
206
+ latest.stdout?.trim() === version ? ok(`npm's latest is ${version}`) : bad("npm's latest", `the registry answers ${latest.stdout?.trim() || "nothing"}`);
207
+
208
+ const inspect = docker(["buildx", "imagetools", "inspect", `${IMAGE}:${version}`]);
209
+ const platforms = ["linux/amd64", "linux/arm64"].filter((p) => inspect.stdout?.includes(p));
210
+ platforms.length === 2 ? ok("the pushed image carries both platforms") : bad("the pushed image's platforms", platforms.join(", ") || "the manifest was not readable");
211
+
212
+ const name = "flypi-check-published";
213
+ const container = freshContainer(name, `${IMAGE}:${version}`, 8793, "flypi-check-published-data", ["flypi", "start", "--foreground", "--port", "8793", "--host", "0.0.0.0", "--embeddings", "none", "--judge", "none"]);
214
+ if (!container) return;
215
+ await assertServing(name, 8793, version);
216
+ // A stale push is exactly what this stage exists to catch, and the tools come from the Dockerfile — so the
217
+ // image Docker Hub serves, not just the one built here, has to carry them.
218
+ hasTools(name);
219
+ }
220
+
221
+ // ── run ───────────────────────────────────────────────────────────────────────────────────────────────────
222
+
223
+ try {
224
+ if (has("--published")) {
225
+ await published();
226
+ } else {
227
+ if (!has("--skip-gates")) gates();
228
+ const tgz = pack();
229
+ if (tgz) {
230
+ await npmInstall(tgz);
231
+ await image(tgz);
232
+ }
233
+ }
234
+ } finally {
235
+ cleanup();
236
+ }
237
+
238
+ console.log(failures ? `\n${failures} check(s) failed` : `\nall checks passed for ${version}`);
239
+ process.exit(failures ? 1 : 0);
package/scripts/serve.ts CHANGED
@@ -19,6 +19,7 @@ import { SessionRegistry } from "../src/server/session-registry.ts";
19
19
  import { openRunStore } from "../src/server/run-store.ts";
20
20
  import { secretsApi } from "../src/server/secrets-api.ts";
21
21
  import { openSecretVault, type SecretStore } from "../src/secrets/vault.ts";
22
+ import { loadedPi } from "../src/daemon/pi.ts";
22
23
  import { startFlypiServer } from "../src/server/index.ts";
23
24
 
24
25
  const argv = process.argv.slice(2);
@@ -63,6 +64,7 @@ const server = await startFlypiServer({
63
64
  port: flagValue("--port") ? Number(flagValue("--port")) : undefined,
64
65
  token: flagValue("--token") ?? process.env.FLYPI_TOKEN,
65
66
  serverVersion: "dev",
67
+ pi: () => loadedPi(paths.root),
66
68
  // An interrupted turn is a backend fact, so it is answered from the backend's store here too.
67
69
  interruptedRun: (sessionFile) => runs.interruptedFor(sessionFile),
68
70
  // Prod: the same server serves the built UI (docs/architecture.md: "Dev vs prod").
@@ -24,6 +24,7 @@
24
24
  import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
25
25
  import { dirname, join } from "node:path";
26
26
  import { DefaultPackageManager, SettingsManager } from "@earendil-works/pi-coding-agent";
27
+ import { isNewer, published } from "../daemon/update.ts";
27
28
  import { FlypiConfigError } from "./runtime.ts";
28
29
  import type { FlypiPaths } from "./paths.ts";
29
30
 
@@ -31,6 +32,8 @@ import type { FlypiPaths } from "./paths.ts";
31
32
  export const PACKAGE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
32
33
  /** The answer, next to flypi's own update stamp, so the panel reports it without reaching the network. */
33
34
  export const PACKAGE_CHECK_FILE = join(".flypi", "package-check.json");
35
+ /** shortcut: the picker offers the 30 newest releases; going further back means editing the source by hand. */
36
+ export const PUBLISHED_LIST_MAX = 30;
34
37
 
35
38
  /** One `packages` entry, as the panel shows it. */
36
39
  export interface PackageRow {
@@ -42,8 +45,16 @@ export interface PackageRow {
42
45
  installed: boolean;
43
46
  /** Where the code is: `.flypi/agent/npm/node_modules/<name>`, a checkout, or the local path. */
44
47
  path: string;
48
+ /** The version its own `package.json` says it is — what a rollback has to name (docs/pi-upgrades.md). */
49
+ version?: string;
45
50
  /** A newer release is published (npm) or the remote moved (git). Only from the daemon's stamp. */
46
51
  update?: boolean;
52
+ /** The npm package an `npm:` source names, version stripped — what a pin is written onto. Absent for a git URL or a path. */
53
+ name?: string;
54
+ /** The published releases the daemon's stamp knows, oldest first — what "install this version" offers. */
55
+ versions?: string[];
56
+ /** The newest of those that is newer than what is installed. Absent when nothing newer is published. */
57
+ available?: string;
47
58
  }
48
59
 
49
60
  export interface PackageSnapshot {
@@ -65,15 +76,53 @@ export interface PackageCheck {
65
76
  checkedAt: string;
66
77
  /** The `packages` entries, exactly as settings.json holds them. */
67
78
  updates: string[];
79
+ /** Published releases per npm source, oldest first — the rollback list the panel offers (docs/pi-upgrades.md). */
80
+ versions?: Record<string, string[]>;
68
81
  error?: string;
69
82
  }
70
83
 
84
+ /**
85
+ * The npm package an `npm:` source names, with any version or range it pins stripped: `npm:foo@^1.2` and
86
+ * `npm:foo` are both `foo`, and `npm:@scope/foo@1.0.0` is `@scope/foo`. Undefined for a git URL or a path,
87
+ * which have no registry to ask. This is also what tells a reader the source is pinned — a source that names
88
+ * a version is not `npm:<name>` any more.
89
+ */
90
+ export function npmName(source: string): string | undefined {
91
+ if (!source.startsWith("npm:")) return undefined;
92
+ const name = source.slice("npm:".length).trim().replace(/@[^/@]*$/, "");
93
+ return name || undefined;
94
+ }
95
+
71
96
  /** pi's package manager, on flypi's own agent dir, plus the settings file it writes through. */
72
97
  function packages(paths: FlypiPaths): { manager: DefaultPackageManager; settings: SettingsManager } {
73
98
  const settings = SettingsManager.create(paths.root, paths.agentDir);
74
99
  return { manager: new DefaultPackageManager({ cwd: paths.root, agentDir: paths.agentDir, settingsManager: settings }), settings };
75
100
  }
76
101
 
102
+ /** The version an installed extension's own `package.json` claims. Null when it cannot be read. */
103
+ function installedVersion(path: string): string | undefined {
104
+ try {
105
+ return (JSON.parse(readFileSync(join(path, "package.json"), "utf8")) as { version?: string }).version;
106
+ } catch {
107
+ return undefined;
108
+ }
109
+ }
110
+
111
+ /**
112
+ * `updates` in `.flypi/agent/settings.json`: may the daemon install an extension update by itself, on the
113
+ * same idle stretch it installs flypi with? Default true — the behaviour that predates the key. It is the
114
+ * path an incompatible change arrives by, so it is now something a user can turn off; pi itself has no
115
+ * equivalent and never updates unattended (docs/pi-upgrades.md).
116
+ */
117
+ export function autoExtensions(paths: FlypiPaths): boolean {
118
+ try {
119
+ const settings = JSON.parse(readFileSync(paths.settingsPath, "utf8")) as { updates?: { autoExtensions?: unknown } };
120
+ return settings.updates?.autoExtensions !== false;
121
+ } catch {
122
+ return true;
123
+ }
124
+ }
125
+
77
126
  /** What pi could not read or write, named by file — a settings.json it cannot parse is never silently "empty". */
78
127
  function settingsErrors(settings: SettingsManager): string[] {
79
128
  return settings.drainErrors().map((failure) => `${failure.path ?? failure.scope}: ${failure.error.message}`);
@@ -84,13 +133,23 @@ export function readPackages(paths: FlypiPaths): PackageSnapshot {
84
133
  const { manager, settings } = packages(paths);
85
134
  const check = readPackageCheck(paths.root);
86
135
  return {
87
- packages: manager.listConfiguredPackages().map((entry) => ({
88
- source: entry.source,
89
- scope: entry.scope,
90
- installed: entry.installedPath !== undefined,
91
- path: entry.installedPath ?? "",
92
- ...(check?.updates.includes(entry.source) ? { update: true } : {}),
93
- })),
136
+ packages: manager.listConfiguredPackages().map((entry) => {
137
+ const name = npmName(entry.source);
138
+ const version = entry.installedPath ? installedVersion(entry.installedPath) : undefined;
139
+ const versions = check?.versions?.[entry.source];
140
+ const available = versions?.filter((v) => !version || isNewer(version, v)).at(-1);
141
+ return {
142
+ source: entry.source,
143
+ scope: entry.scope,
144
+ installed: entry.installedPath !== undefined,
145
+ path: entry.installedPath ?? "",
146
+ ...(version ? { version } : {}),
147
+ ...(check?.updates.includes(entry.source) ? { update: true } : {}),
148
+ ...(name ? { name } : {}),
149
+ ...(versions?.length ? { versions } : {}),
150
+ ...(available ? { available } : {}),
151
+ };
152
+ }),
94
153
  ...(check ? { checkedAt: check.checkedAt } : {}),
95
154
  errors: settingsErrors(settings),
96
155
  };
@@ -117,13 +176,47 @@ export async function applyPackageEdits(paths: FlypiPaths, edits: PackageEdits):
117
176
  }
118
177
 
119
178
  /**
120
- * Ask the registries and remotes which configured sources have something newer. An unreachable registry is
121
- * the answer, not a crash — the daemon asks this on a machine that may be offline.
179
+ * The published releases of each npm source, for the panel's "install this version" — the way back for an
180
+ * extension is pi's own pin form, and a pin has to name a version to name (docs/pi-upgrades.md). One
181
+ * registry document per npm source per day, on the same rhythm as the update check below.
182
+ *
183
+ * A source with nothing to offer is simply absent from the answer: a package the registry has never heard of
184
+ * (404), an offline box, and a git URL or a path all leave the row with no list rather than failing the check.
185
+ */
186
+ export async function publishedVersions(paths: FlypiPaths, fetchImpl?: typeof fetch): Promise<Record<string, string[]>> {
187
+ const out: Record<string, string[]> = {};
188
+ for (const entry of packages(paths).manager.listConfiguredPackages()) {
189
+ const name = npmName(entry.source);
190
+ if (!name) continue;
191
+ try {
192
+ // The scoped form is `@scope%2Fname`; an unscoped name needs no escaping at all.
193
+ const response = await (fetchImpl ?? fetch)(`https://registry.npmjs.org/${name.replace("/", "%2F")}`, {
194
+ signal: AbortSignal.timeout(5_000),
195
+ headers: { accept: "application/json" },
196
+ });
197
+ if (!response.ok) continue;
198
+ const doc = (await response.json()) as { versions?: Record<string, unknown> };
199
+ // Released only, oldest first — the same rule flypi's own version ladder reads.
200
+ const list = published(doc.versions ?? {}).slice(-PUBLISHED_LIST_MAX);
201
+ if (list.length > 0) out[entry.source] = list;
202
+ } catch {
203
+ // Nothing to say: the row offers no version until a later check reaches the registry.
204
+ }
205
+ }
206
+ return out;
207
+ }
208
+
209
+ /**
210
+ * Ask the registries and remotes which configured sources have something newer, and what each npm source has
211
+ * ever published. An unreachable registry is the answer, not a crash — the daemon asks this on a machine that
212
+ * may be offline.
122
213
  */
123
- export async function checkPackageUpdates(paths: FlypiPaths): Promise<PackageCheck> {
214
+ export async function checkPackageUpdates(paths: FlypiPaths, fetchImpl?: typeof fetch): Promise<PackageCheck> {
124
215
  const { manager } = packages(paths);
125
216
  try {
126
- return { checkedAt: new Date().toISOString(), updates: (await manager.checkForAvailableUpdates()).map((entry) => entry.source) };
217
+ const updates = (await manager.checkForAvailableUpdates()).map((entry) => entry.source);
218
+ const versions = await publishedVersions(paths, fetchImpl);
219
+ return { checkedAt: new Date().toISOString(), updates, ...(Object.keys(versions).length > 0 ? { versions } : {}) };
127
220
  } catch (error) {
128
221
  return { checkedAt: new Date().toISOString(), updates: [], error: (error as Error).message };
129
222
  }
@@ -0,0 +1,204 @@
1
+ /**
2
+ * The surface of pi that flypi depends on, and a check that a candidate tree still offers it
3
+ * (docs/pi-upgrades.md Phase 8.2, risk 14).
4
+ *
5
+ * This is a **pre-filter, not a gate**. It catches the dominant breakage class — a renamed or dropped export,
6
+ * a moved internal file, a raised Node floor — in about a second and without restarting anything. It cannot
7
+ * see the behavioural half of pi-api.md: pi 1.4.0 kept every export and changed the *shape* of the
8
+ * `before_agent_start` event, which no `typeof` check can find. Only `npm test` and `npm run smoke` catch
9
+ * that class, and they are the gate.
10
+ *
11
+ * The list is **generated from the source, never maintained by hand**: `tests/scripts/pi-compat.test.ts`
12
+ * greps every `from "@earendil-works/…"` in `src/ scripts/ tests/ ui/` and fails when the two disagree. A
13
+ * hand-kept list that drifts reports a clean pass, which is worse than no list.
14
+ *
15
+ * Three specifiers, not one (docs/pi-upgrades.md C3): `pi-coding-agent` is the bulk, `pi-mcp` is imported by
16
+ * the memory smoke script and three tests without being a declared dependency of flypi, and
17
+ * `pi-ai/providers/faux` is the stub every model-touching test runs on. A probe that checked only
18
+ * `pi-coding-agent` would report a clean pass while the whole suite was broken.
19
+ */
20
+ import { existsSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
21
+ import { dirname, join } from "node:path";
22
+ import { fileURLToPath, pathToFileURL } from "node:url";
23
+
24
+ /** One specifier flypi imports from, and what it takes from it. */
25
+ export interface PiImport {
26
+ /** The bare specifier, exactly as the source writes it. */
27
+ specifier: string;
28
+ /** Names with a runtime presence. flypi calls every one of them, so each must stay a function. */
29
+ values: string[];
30
+ /** Type-only names: no runtime check is possible, so the entry's `.d.ts` is checked instead. */
31
+ types: string[];
32
+ }
33
+
34
+ export interface PiDeepImport {
35
+ /** The package whose internals flypi reaches into by path. */
36
+ specifier: string;
37
+ /** The file inside it, as `src/agent/pi-mcp.ts` joins it onto `getPackageDir()`. */
38
+ path: string;
39
+ }
40
+
41
+ /** What flypi imports from pi. Kept honest by `tests/scripts/pi-compat.test.ts`, not by anyone's memory. */
42
+ export const PI_COMPAT: { imports: PiImport[]; deep: PiDeepImport[] } = {
43
+ imports: [
44
+ {
45
+ specifier: "@earendil-works/pi-coding-agent",
46
+ values: [
47
+ "createAgentSession",
48
+ "DefaultResourceLoader",
49
+ "DefaultPackageManager",
50
+ "SettingsManager",
51
+ "SessionManager",
52
+ "ModelRuntime",
53
+ "getPackageDir",
54
+ "createMcpExtension",
55
+ "createToolSearchExtension",
56
+ ],
57
+ types: [
58
+ "AgentSessionEvent",
59
+ "CreateAgentSessionOptions",
60
+ "CustomMessageEntry",
61
+ "ExtensionAPI",
62
+ "ExtensionContext",
63
+ "ExtensionFactory",
64
+ "ExtensionUIContext",
65
+ "ExtensionUIDialogOptions",
66
+ "LoadedMcpConfig",
67
+ "McpExtensionOptions",
68
+ "McpServerConfig",
69
+ "McpServerEntry",
70
+ "SessionEntry",
71
+ ],
72
+ },
73
+ { specifier: "@earendil-works/pi-mcp", values: ["McpClient", "StreamableHttpTransport"], types: [] },
74
+ {
75
+ specifier: "@earendil-works/pi-ai/providers/faux",
76
+ values: ["fauxAssistantMessage", "fauxProvider", "fauxText", "fauxThinking", "fauxToolCall"],
77
+ types: ["FauxProviderHandle", "FauxResponseStep"],
78
+ },
79
+ ],
80
+ // Reached by path because the entry does not export them and its `exports` map hides the types
81
+ // (src/agent/pi-mcp.ts). A moved internal file breaks flypi while every export still passes.
82
+ deep: [
83
+ { specifier: "@earendil-works/pi-coding-agent", path: "dist/extensions/mcp/runtime.js" },
84
+ { specifier: "@earendil-works/pi-coding-agent", path: "dist/core/auth-storage.js" },
85
+ { specifier: "@earendil-works/pi-coding-agent", path: "dist/core/resolve-config-value.js" },
86
+ ],
87
+ };
88
+
89
+ /** `engines.node: ">=22.19.0"` against the Node that would run it. Numeric parts, left to right. */
90
+ function nodeSatisfied(range: string, version: string): boolean {
91
+ const min = /(\d+)\.(\d+)\.(\d+)/.exec(range);
92
+ if (!min) return true;
93
+ const parts = version.split(".").map(Number);
94
+ for (let i = 1; i <= 3; i++) {
95
+ const diff = Math.sign((parts[i - 1] ?? 0) - Number(min[i]));
96
+ if (diff) return diff > 0;
97
+ }
98
+ return true;
99
+ }
100
+
101
+ /** Walk up from a resolved file to the package that owns it — `dist/index.js` is not the package root. */
102
+ function packageRoot(fromFile: string, name: string): string | null {
103
+ let dir = dirname(fromFile);
104
+ while (dir !== dirname(dir)) {
105
+ try {
106
+ if ((JSON.parse(readFileSync(join(dir, "package.json"), "utf8")) as { name?: string }).name === name) return dir;
107
+ } catch {
108
+ /* keep walking */
109
+ }
110
+ dir = dirname(dir);
111
+ }
112
+ return null;
113
+ }
114
+
115
+ /**
116
+ * Resolve specifiers **from inside the candidate tree**. `import.meta.resolve(spec, parentURL)` cannot do
117
+ * it — Node 24 ignores the parent argument and resolves from the calling module, which would have the probe
118
+ * check flypi's own pi and report a clean pass for a candidate that is not even there. A module written at
119
+ * the candidate's root resolves from its own location, which is the tree being probed.
120
+ */
121
+ async function resolverFor(root: string): Promise<(specifier: string) => string | null> {
122
+ const helper = join(root, ".flypi-probe-resolver.mjs");
123
+ writeFileSync(helper, "export const resolveFrom = (specifier) => import.meta.resolve(specifier);\n");
124
+ let resolveFrom: (specifier: string) => string;
125
+ try {
126
+ ({ resolveFrom } = await import(pathToFileURL(helper).href));
127
+ } finally {
128
+ rmSync(helper, { force: true });
129
+ }
130
+ return (specifier) => {
131
+ try {
132
+ const url = resolveFrom(specifier);
133
+ return fileURLToPath(url).startsWith(join(root, "node_modules") + "/") ? url : null;
134
+ } catch {
135
+ return null;
136
+ }
137
+ };
138
+ }
139
+
140
+ /**
141
+ * What a candidate tree is missing — one sentence per problem, empty when it looks usable. A complete tree
142
+ * resolves its own third-party dependencies, so the probe asks the candidate rather than flypi.
143
+ */
144
+ export async function checkPiCompat(candidateRoot: string): Promise<string[]> {
145
+ const failures: string[] = [];
146
+ let root: string;
147
+ try {
148
+ root = realpathSync(candidateRoot);
149
+ } catch {
150
+ return [`${candidateRoot} is not a directory — nothing to probe`];
151
+ }
152
+ if (!existsSync(join(root, "node_modules", "@earendil-works"))) return [`${candidateRoot} has no @earendil-works in its node_modules — that is not a candidate pi tree`];
153
+ const resolveFrom = await resolverFor(root);
154
+
155
+ for (const entry of PI_COMPAT.imports) {
156
+ const url = resolveFrom(entry.specifier);
157
+ if (!url) {
158
+ failures.push(`${entry.specifier}: does not resolve from inside ${candidateRoot}`);
159
+ continue;
160
+ }
161
+ let mod: Record<string, unknown>;
162
+ try {
163
+ // The query busts the ESM module cache: asked twice in one process, the probe has to look at the
164
+ // tree again rather than at what the first look left loaded.
165
+ mod = (await import(`${url}?pi-probe=1`)) as Record<string, unknown>;
166
+ } catch (error) {
167
+ failures.push(`${entry.specifier}: importing it threw — ${(error as Error).message}`);
168
+ continue;
169
+ }
170
+ for (const name of entry.values) {
171
+ if (typeof mod[name] !== "function") failures.push(`${entry.specifier}: ${name} is ${typeof mod[name]}, and flypi calls it as a function`);
172
+ }
173
+ if (entry.types.length === 0) continue;
174
+ // The entry's own declaration file: `dist/index.d.ts` for the package entry,
175
+ // `dist/providers/faux.d.ts` for the subpath. Every type flypi imports is re-exported there by name.
176
+ const declaration = fileURLToPath(url.replace(/\.js$/, ".d.ts"));
177
+ if (!existsSync(declaration)) {
178
+ failures.push(`${entry.specifier}: no ${declaration.slice(declaration.lastIndexOf("/") + 1)} beside the entry, so its type exports cannot be checked`);
179
+ continue;
180
+ }
181
+ const text = readFileSync(declaration, "utf8");
182
+ for (const name of entry.types) {
183
+ if (!new RegExp(`\\b${name}\\b`).test(text)) failures.push(`${entry.specifier}: the type ${name} is no longer re-exported from the entry`);
184
+ }
185
+ }
186
+
187
+ for (const deep of PI_COMPAT.deep) {
188
+ const url = resolveFrom(deep.specifier);
189
+ const owner = url ? packageRoot(fileURLToPath(url), deep.specifier) : null;
190
+ const file = owner ? join(owner, deep.path) : "";
191
+ if (!url || !owner || !existsSync(file)) failures.push(`${deep.specifier}: ${deep.path} is not there, and flypi imports it by path`);
192
+ }
193
+
194
+ // The Node floor: a candidate that needs a newer Node than the daemon runs on fails at import, which is
195
+ // the failure that leaves no web UI to click rollback on (docs/risks.md risk 13).
196
+ const entry = resolveFrom("@earendil-works/pi-coding-agent");
197
+ if (entry) {
198
+ const packageJson = join(packageRoot(fileURLToPath(entry), "@earendil-works/pi-coding-agent") ?? "", "package.json");
199
+ const engines = existsSync(packageJson) ? (JSON.parse(readFileSync(packageJson, "utf8")) as { engines?: { node?: string } }).engines?.node : undefined;
200
+ if (engines && !nodeSatisfied(engines, process.versions.node)) failures.push(`this pi needs Node ${engines}; this process is ${process.versions.node}`);
201
+ }
202
+
203
+ return failures;
204
+ }