@makerstufferupper/flypi 0.1.2 → 0.1.4

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.2",
3
+ "version": "0.1.4",
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,6 +24,7 @@
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",
@@ -46,17 +47,17 @@
46
47
  "release": "node scripts/release.mjs"
47
48
  },
48
49
  "dependencies": {
49
- "@earendil-works/pi-coding-agent": "^1.0.4",
50
+ "@earendil-works/pi-coding-agent": "^1.4.0",
50
51
  "@huggingface/transformers": "^4.3.1",
51
52
  "@modelcontextprotocol/sdk": "^1.32.1",
52
- "tsx": "^4.23.15",
53
53
  "marked": "18.0.11",
54
+ "tsx": "^4.23.15",
54
55
  "typebox": "^1.3.27",
55
56
  "ws": "^8.22.0",
56
57
  "yaml": "^2.9.0"
57
58
  },
58
59
  "devDependencies": {
59
- "@earendil-works/pi-ai": "^1.0.4",
60
+ "@earendil-works/pi-ai": "^1.4.0",
60
61
  "@modelcontextprotocol/server-filesystem": "^1.0.0",
61
62
  "@sveltejs/vite-plugin-svelte": "^7.3.1",
62
63
  "@testing-library/svelte": "^5.4.2",
@@ -68,5 +69,12 @@
68
69
  "typescript": "^7.0.2",
69
70
  "vite": "^8.3.3",
70
71
  "vitest": "^5.0.3"
72
+ },
73
+ "allowScripts": {
74
+ "@google/genai@2.21.0": true,
75
+ "esbuild@0.28.2": true,
76
+ "fsevents@2.3.3": true,
77
+ "onnxruntime-node@1.30.0": true,
78
+ "protobufjs@7.6.6": true
71
79
  }
72
80
  }
package/scripts/flypi.mjs CHANGED
@@ -7,15 +7,17 @@
7
7
  * detached, wait until `/healthz` answers, and report the URL you would otherwise have to dig out of a log.
8
8
  *
9
9
  * node scripts/flypi.mjs start [--foreground] # or: npm run flypi -- start
10
+ * node scripts/flypi.mjs start --foreground --port 8791 --host 0.0.0.0 # the container entry point
10
11
  * node scripts/flypi.mjs status | stop | restart | logs [-n 50] [-f]
11
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)
12
14
  *
13
15
  * `--root DIR` (or FLYPI_ROOT) picks which `.flypi/` the CLI owns; in a checkout it defaults to the cwd,
14
16
  * and the installed `bin/flypi.mjs` sets FLYPI_ROOT to the home directory before importing this.
15
17
  */
16
18
  import { mkdirSync, openSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
17
19
  import { dirname, resolve } from "node:path";
18
- import { spawn } from "node:child_process";
20
+ import { spawn, spawnSync } from "node:child_process";
19
21
  import { fileURLToPath } from "node:url";
20
22
 
21
23
  const argv = process.argv.slice(2);
@@ -31,6 +33,7 @@ const checkout = resolve(fileURLToPath(import.meta.url), "..", "..");
31
33
  const serveDaemon = resolve(fileURLToPath(import.meta.url), "..", "serve-daemon.ts");
32
34
  const secretCli = resolve(fileURLToPath(import.meta.url), "..", "secret.ts");
33
35
  const updateCli = resolve(fileURLToPath(import.meta.url), "..", "update.ts");
36
+ const piCli = resolve(fileURLToPath(import.meta.url), "..", "pi.ts");
34
37
  const infoPath = resolve(root, ".flypi", "daemon.json");
35
38
  const logPath = resolve(root, ".flypi", "daemon.log");
36
39
  /**
@@ -160,6 +163,8 @@ function report(info, status) {
160
163
  console.log(`flypi: agent http://${info.host}:${info.port} (token ${info.token})`);
161
164
  console.log(`flypi: memory http://127.0.0.1:${info.memoryPort}/mcp`);
162
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}` : ""}`);
163
168
  // The daemon's own daily answer (src/daemon/update.ts) — `status` does not reach the network itself.
164
169
  if (status?.updateAvailable) console.log(`flypi: ${status.updateAvailable} is available — \`flypi update\` installs it`);
165
170
  if (status?.updateReady) console.log(`flypi: ${status.updateReady} is downloaded — it installs once the daemon has been idle`);
@@ -185,8 +190,21 @@ async function start({ foreground = false, previous } = {}) {
185
190
  // sticky — and it merges, so naming just `--port` does not drop an `--host` that is still in force.
186
191
  const namedFlags = { ...(flagValue("--port") ? { port: Number(flagValue("--port")) } : {}), ...(flagValue("--host") ? { host: flagValue("--host") } : {}) };
187
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}`}`);
188
200
  if (foreground) {
189
201
  const child = spawn(process.execPath, ["--import", "tsx", serveDaemon, "--root", root, ...flags], { cwd: checkout, stdio: "inherit" });
202
+ // Ctrl-C and `docker stop` reach this process, not the daemon. PID 1 in a pid namespace drops a signal it
203
+ // has no handler for, so without this the SIGTERM never arrives: docker waits out its grace period and
204
+ // SIGKILLs a daemon that never got to remove its pid file, close its sockets or record an interrupted
205
+ // turn. Installing the handler is what makes the signal be delivered; forwarding is what makes the
206
+ // daemon run its own shutdown, and the exit code comes from the daemon rather than from this wrapper.
207
+ for (const signal of ["SIGINT", "SIGTERM"]) process.on(signal, () => child.kill(signal));
190
208
  return new Promise((done) => child.on("exit", (code) => done(code ?? 0)));
191
209
  }
192
210
  // The log's directory is created above, not assumed.
@@ -281,6 +299,12 @@ const flagNumber = (name) => {
281
299
  const i = argv.indexOf(name);
282
300
  return i === -1 ? undefined : Number(argv[i + 1]);
283
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
+
284
308
  const result = await {
285
309
  start: () => start({ foreground: argv.includes("--foreground") }),
286
310
  stop,
@@ -295,9 +319,10 @@ const result = await {
295
319
  logs: () => logs({ ...(flagNumber("-n") ? { lines: flagNumber("-n") } : {}), follow: argv.includes("-f") || argv.includes("--follow") }),
296
320
  secret: () => secret(argv.slice(1)),
297
321
  update: () => update(argv.slice(1)),
322
+ pi: () => pi(argv.slice(1)),
298
323
  }[command]?.();
299
324
  if (result === undefined) {
300
- 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 …");
301
326
  process.exit(2);
302
327
  }
303
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);
@@ -14,7 +14,10 @@ import { spawnSync } from "node:child_process";
14
14
  import { readFileSync } from "node:fs";
15
15
 
16
16
  const argv = process.argv.slice(2);
17
- const dryRun = argv.includes("--dry-run");
17
+ // `npm run release patch --dry-run` hands `--dry-run` to npm, not to this script, but npm does export it as
18
+ // config — so a "dry run" of the release really bumped the version, committed, tagged and pushed, and only the
19
+ // publish was dry. Read both, so the flag means what it says whichever way it arrives.
20
+ const dryRun = argv.includes("--dry-run") || process.env.npm_config_dry_run === "true";
18
21
  const kind = argv.find((a) => ["patch", "minor", "major", "preminor", "premajor"].includes(a)) ?? "patch";
19
22
 
20
23
  /** `remote: true` marks the two steps that leave this machine, so the dry-run output says which is which. */
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
  }