@makerstufferupper/flypi 0.1.3 → 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/README.md +4 -4
- package/dist/ui/assets/{index-qaKjH5fH.css → index-CBuNmrDT.css} +1 -1
- package/dist/ui/assets/index-Dv6ZykyY.js +102 -0
- package/dist/ui/index.html +2 -2
- package/package.json +12 -4
- package/scripts/flypi.mjs +20 -2
- package/scripts/pi.ts +150 -0
- package/scripts/probe-pi.ts +18 -0
- package/scripts/serve.ts +2 -0
- package/src/agent/packages.ts +104 -11
- package/src/agent/pi-compat.ts +204 -0
- package/src/daemon/index.ts +58 -4
- package/src/daemon/pi.ts +373 -0
- package/src/memory/recall.ts +11 -2
- package/src/server/index.ts +32 -2
- package/src/server/protocol.ts +79 -0
- package/dist/ui/assets/index-CG1CuGxu.js +0 -105
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@makerstufferupper/flypi",
|
|
3
|
-
"version": "0.1.
|
|
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
|
|
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
|
|
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
|
@@ -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);
|
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").
|
package/src/agent/packages.ts
CHANGED
|
@@ -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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
*
|
|
121
|
-
*
|
|
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
|
-
|
|
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
|
}
|