@makerstufferupper/flypi 0.0.0-stage → 0.1.3
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/LICENSE +21 -0
- package/README.md +64 -2
- package/assets/screenshot.png +0 -0
- package/bin/flypi.mjs +28 -0
- package/dist/ui/assets/index-CG1CuGxu.js +105 -0
- package/dist/ui/assets/index-qaKjH5fH.css +1 -0
- package/dist/ui/flypi-logo-dark.jpeg +0 -0
- package/dist/ui/flypi-logo-light.jpeg +0 -0
- package/dist/ui/index.html +17 -0
- package/package.json +71 -5
- package/scripts/benchmark-nli.ts +273 -0
- package/scripts/flypi.mjs +310 -0
- package/scripts/probe-turn-boundary.ts +97 -0
- package/scripts/probe.ts +119 -0
- package/scripts/record-transcript.ts +63 -0
- package/scripts/reembed-memory.ts +41 -0
- package/scripts/release.mjs +38 -0
- package/scripts/secret.ts +24 -0
- package/scripts/serve-daemon.ts +101 -0
- package/scripts/serve-memory.ts +78 -0
- package/scripts/serve.ts +89 -0
- package/scripts/smoke-memory.mjs +239 -0
- package/scripts/smoke-secrets.mjs +153 -0
- package/scripts/smoke.mjs +59 -0
- package/scripts/start.mjs +294 -0
- package/scripts/turn-boundary.ts +52 -0
- package/scripts/update.ts +92 -0
- package/scripts/warm-models.ts +41 -0
- package/src/agent/config-files.ts +529 -0
- package/src/agent/mcp-signin.ts +137 -0
- package/src/agent/mcp-status.ts +168 -0
- package/src/agent/onboarding.ts +74 -0
- package/src/agent/packages.ts +150 -0
- package/src/agent/paths.ts +80 -0
- package/src/agent/permission-gate.ts +70 -0
- package/src/agent/pi-mcp.ts +97 -0
- package/src/agent/runtime.ts +347 -0
- package/src/daemon/index.ts +442 -0
- package/src/daemon/jobs.ts +202 -0
- package/src/daemon/update.ts +248 -0
- package/src/memory/capture.ts +109 -0
- package/src/memory/config.ts +90 -0
- package/src/memory/consolidate.ts +218 -0
- package/src/memory/db.ts +393 -0
- package/src/memory/embed.ts +154 -0
- package/src/memory/judge.ts +167 -0
- package/src/memory/mcp.ts +496 -0
- package/src/memory/media.ts +135 -0
- package/src/memory/progress.ts +39 -0
- package/src/memory/recall.ts +528 -0
- package/src/memory/review.ts +86 -0
- package/src/memory/store.ts +1034 -0
- package/src/memory/wiki.ts +140 -0
- package/src/secrets/cli.ts +248 -0
- package/src/secrets/config.ts +133 -0
- package/src/secrets/crypto.ts +180 -0
- package/src/secrets/extension.ts +253 -0
- package/src/secrets/redact.ts +132 -0
- package/src/secrets/vault.ts +396 -0
- package/src/server/event-log.ts +49 -0
- package/src/server/extension-ui.ts +118 -0
- package/src/server/index.ts +398 -0
- package/src/server/memory-api.ts +158 -0
- package/src/server/protocol.ts +947 -0
- package/src/server/run-store.ts +136 -0
- package/src/server/secrets-api.ts +88 -0
- package/src/server/session-registry.ts +593 -0
- package/src/shared/attachments.ts +40 -0
- package/src/shared/cache-probe.ts +47 -0
- package/src/shared/markdown.ts +17 -0
- package/src/shared/metrics.ts +212 -0
- package/src/shared/sqlite.ts +74 -0
- package/src/shared/transcript.ts +404 -0
- package/src/shared/turn-boundary.ts +98 -0
- package/src/shared/views.ts +31 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 5.5's gate in one command (docs/secrets.md, docs/testing.md): the vault in the memory file, the CLI
|
|
3
|
+
* as a second process while a writer holds the database, redaction at the boundary where text is stored, the
|
|
4
|
+
* `!flypi secret print NAME` reference form, and a key rotation with the re-encrypt job that finishes it.
|
|
5
|
+
*
|
|
6
|
+
* It is a driver, not a test: the assertions live in tests/secrets/*. What it adds is that these are real
|
|
7
|
+
* processes and a real file — the two things a unit test can only imitate are a second process reading while
|
|
8
|
+
* the daemon writes, and the bytes on disk that a backup would carry.
|
|
9
|
+
*
|
|
10
|
+
* Run: npm run smoke:secrets
|
|
11
|
+
*/
|
|
12
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
13
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { tmpdir } from "node:os";
|
|
15
|
+
import { dirname, join, resolve } from "node:path";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
import { flypiPaths } from "../src/agent/paths.ts";
|
|
18
|
+
import { openMemoryDb } from "../src/memory/db.ts";
|
|
19
|
+
import { MemoryStore } from "../src/memory/store.ts";
|
|
20
|
+
import { createEmbedder } from "../src/memory/embed.ts";
|
|
21
|
+
import { memoryConfig } from "../src/memory/config.ts";
|
|
22
|
+
import { secretReferences } from "../src/secrets/config.ts";
|
|
23
|
+
import { openSecretVault } from "../src/secrets/vault.ts";
|
|
24
|
+
import { schemaVersion } from "../src/shared/sqlite.ts";
|
|
25
|
+
|
|
26
|
+
// The checkout this script lives in — where `tsx` and the app's imports resolve from, which is not the
|
|
27
|
+
// temp root the smoke runs against (same reason scripts/flypi.mjs has it).
|
|
28
|
+
const checkout = resolve(fileURLToPath(import.meta.url), "..", "..");
|
|
29
|
+
|
|
30
|
+
const ok = (line) => console.log(`ok: ${line}`);
|
|
31
|
+
const fail = (line) => {
|
|
32
|
+
console.error(`FAIL: ${line}`);
|
|
33
|
+
process.exit(1);
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const root = mkdtempSync(join(tmpdir(), "flypi-smoke-secrets-"));
|
|
37
|
+
const paths = flypiPaths(root);
|
|
38
|
+
const TOKEN = "ghp_Smoke1111111111111111AAAAAAAA";
|
|
39
|
+
|
|
40
|
+
// 1. The writer: the same database the memory store uses, opened the way the daemon opens it.
|
|
41
|
+
const db = openMemoryDb(paths.memoryDbPath);
|
|
42
|
+
const vault = openSecretVault(paths, { db, write: true });
|
|
43
|
+
ok(`vault in the memory file — ${paths.memoryDbPath} at schema v${schemaVersion(db)}, one file for memories and secrets`);
|
|
44
|
+
|
|
45
|
+
// 2. The CLI as another process, value on stdin so it never appears in the process list, while this process
|
|
46
|
+
// still holds the write handle. This is the everyday case: the daemon is running and someone types a command.
|
|
47
|
+
const cli = (args, input) => {
|
|
48
|
+
const run = spawnSync(process.execPath, ["--import", "tsx", join(checkout, "scripts", "secret.ts"), "--root", root, ...args], {
|
|
49
|
+
cwd: checkout,
|
|
50
|
+
encoding: "utf8",
|
|
51
|
+
...(input === undefined ? {} : { input }),
|
|
52
|
+
});
|
|
53
|
+
return { code: run.status ?? 1, out: run.stdout ?? "", err: run.stderr ?? "" };
|
|
54
|
+
};
|
|
55
|
+
const stored = cli(["put", "github_token", "--kind", "password", "--meta", "purpose=smoke"], TOKEN);
|
|
56
|
+
if (stored.code !== 0) fail(`secret put failed: ${stored.err || stored.out}`);
|
|
57
|
+
const printed = cli(["print", "github_token"]);
|
|
58
|
+
// pi trims a `!command`'s stdout (resolve-config-value.js), so a trailing newline is what a config file wants.
|
|
59
|
+
if (printed.out.trim() !== TOKEN) fail(`a second process did not read the value back: ${JSON.stringify(printed.out)}`);
|
|
60
|
+
ok(`second process reads while the writer holds the file — print answered ${TOKEN.slice(0, 4)}…${TOKEN.slice(-4)} with the daemon's writer busy (exit ${printed.code})`);
|
|
61
|
+
|
|
62
|
+
const listed = cli(["list"]);
|
|
63
|
+
if (!listed.out.includes("ghp…AAAA") || listed.out.includes(TOKEN)) fail(`list leaked a value or lost its mask:\n${listed.out}`);
|
|
64
|
+
const described = cli(["get", "github_token"]);
|
|
65
|
+
if (described.code !== 0 || !described.out.includes("purpose=smoke") || described.out.includes(TOKEN)) fail(`get did not describe the row without the value:\n${described.out}`);
|
|
66
|
+
ok("list and get answer with masks — no plaintext on either path");
|
|
67
|
+
|
|
68
|
+
// 3. The file itself. A backup of this database must not carry the credential in the clear.
|
|
69
|
+
for (const file of [paths.memoryDbPath, `${paths.memoryDbPath}-wal`]) {
|
|
70
|
+
if (existsSync(file) && readFileSync(file).includes(TOKEN)) fail(`${file} contains the plaintext`);
|
|
71
|
+
}
|
|
72
|
+
const row = db.prepare("SELECT name, mask, length(value_cipher) AS bytes, length(value_nonce) AS nonce, length(value_tag) AS tag, fingerprint, key_version FROM secrets WHERE name = 'github_token'").get();
|
|
73
|
+
if (row.bytes === 0 || row.nonce !== 12 || row.tag !== 16 || !/^[0-9a-f]{64}$/.test(row.fingerprint) || row.key_version !== 1) {
|
|
74
|
+
fail(`the row is not sealed the way the design says: ${JSON.stringify(row)}`);
|
|
75
|
+
}
|
|
76
|
+
ok(`bytes on disk are ciphertext — ${row.bytes} B cipher, ${row.nonce} B nonce, ${row.tag} B tag, key v${row.key_version}, and the file does not contain the value`);
|
|
77
|
+
|
|
78
|
+
// 4. The redaction boundary, at the place it matters: text being stored. A memory and a turn's tool calls go
|
|
79
|
+
// through the same function the transcript hooks call (src/memory/store.ts).
|
|
80
|
+
const embeddings = memoryConfig(paths).embeddings;
|
|
81
|
+
embeddings.provider = "none";
|
|
82
|
+
const store = new MemoryStore(db, await createEmbedder(embeddings));
|
|
83
|
+
const remembered = await store.addMemory({
|
|
84
|
+
kind: "smoke",
|
|
85
|
+
body: `the github token is ${TOKEN} — do not paste it in a wiki`,
|
|
86
|
+
tags: ["smoke"],
|
|
87
|
+
origin_class: "agent",
|
|
88
|
+
session_kind: "interactive",
|
|
89
|
+
actor: "smoke",
|
|
90
|
+
});
|
|
91
|
+
const storedBody = store.memory(remembered.id).body;
|
|
92
|
+
if (storedBody.includes(TOKEN)) fail("a memory body kept the secret in the clear");
|
|
93
|
+
if (!storedBody.includes("[secret:github_token]")) fail(`a memory body did not name what it hid: ${storedBody}`);
|
|
94
|
+
// A turn row belongs to a conversation the memory DB knows (`turns.conversation_id` is a foreign key), so the
|
|
95
|
+
// row exists before the turn is recorded — the same order the server keeps them in (src/memory/store.ts).
|
|
96
|
+
db.prepare("INSERT INTO conversations (id, cwd, pi_session_file, created_at) VALUES ('smoke-conv', ?, '/tmp/smoke.jsonl', ?)").run(paths.root, new Date().toISOString());
|
|
97
|
+
store.recordTurn("smoke-conv", {
|
|
98
|
+
userText: `look up ${TOKEN}`,
|
|
99
|
+
assistantText: `found it: ${TOKEN}`,
|
|
100
|
+
toolCalls: [{ name: "bash", arguments: { command: `gh api -H "Authorization: Bearer ${TOKEN}"` } }],
|
|
101
|
+
});
|
|
102
|
+
const turn = db.prepare("SELECT user_text, assistant_text, tool_calls_json FROM turns WHERE conversation_id = 'smoke-conv'").get();
|
|
103
|
+
for (const column of [turn.user_text, turn.assistant_text, turn.tool_calls_json]) {
|
|
104
|
+
if (String(column).includes(TOKEN)) fail(`a turn column kept the secret: ${column}`);
|
|
105
|
+
}
|
|
106
|
+
ok(`redaction at the store — memory #${remembered.id} and the turn's tool calls both say [secret:github_token]`);
|
|
107
|
+
|
|
108
|
+
// 5. A command that only takes a path, and the reference form pi's config files use.
|
|
109
|
+
const pathOut = cli(["path", "github_token"]);
|
|
110
|
+
const secretFile = pathOut.out.trim();
|
|
111
|
+
if (statSync(secretFile).mode & 0o077 || !readFileSync(secretFile, "utf8").includes(TOKEN)) fail(`path did not write a 0400 file with the value: ${pathOut.out}`);
|
|
112
|
+
// The agent dir is there only once something has been written into it; a config file is what creates it.
|
|
113
|
+
mkdirSync(dirname(paths.mcpPath), { recursive: true });
|
|
114
|
+
writeFileSync(paths.mcpPath, JSON.stringify({ mcpServers: { github: { url: "https://api.github.com/mcp", headers: { Authorization: "!flypi secret print github_token" } } } }, null, 2));
|
|
115
|
+
const found = secretReferences(paths);
|
|
116
|
+
if (found.length !== 1 || found[0].name !== "github_token" || !found[0].path.includes("headers.Authorization")) fail(`the reference in mcp.json was not found: ${JSON.stringify(found)}`);
|
|
117
|
+
// Run that reference exactly as pi would run it. `flypi secret print` is the command a config file would hold.
|
|
118
|
+
const viaFlypi = execFileSync(process.execPath, [join(checkout, "scripts", "flypi.mjs"), "secret", "print", "github_token"], { cwd: checkout, encoding: "utf8", env: { ...process.env, FLYPI_ROOT: root } });
|
|
119
|
+
if (viaFlypi.trim() !== TOKEN) fail(`\`!flypi secret print github_token\` printed ${JSON.stringify(viaFlypi)}`);
|
|
120
|
+
ok(`a config reference resolves — mcp.json at ${found[0].path} → \`flypi secret print github_token\` → the value; a path-only tool gets a 0400 file`);
|
|
121
|
+
|
|
122
|
+
// 6. Key rotation: the old key keeps working, the drift is reported, and the job that fixes it runs here
|
|
123
|
+
// because the daemon would have queued it.
|
|
124
|
+
const before = vault.row("github_token");
|
|
125
|
+
const rotated = vault.rotateKey("smoke");
|
|
126
|
+
const drift = vault.keyDrift();
|
|
127
|
+
// Rotating a key must not silently strand a row: the drift is visible and the job to fix it is already queued
|
|
128
|
+
// (src/daemon/index.ts queues it at startup too, in case the process died between the two).
|
|
129
|
+
if (drift.stale !== 1 || drift.current !== rotated.version || !drift.reencryptQueued) fail(`key rotation did not report the drift it caused: ${JSON.stringify(drift)}`);
|
|
130
|
+
const stillOpens = vault.value("github_token", { actor: "smoke", action: "used" });
|
|
131
|
+
if (stillOpens !== TOKEN) fail("a row under the old key stopped opening — a missing key must be loud, not wrong");
|
|
132
|
+
const resealed = vault.reencrypt();
|
|
133
|
+
const after = vault.row("github_token");
|
|
134
|
+
if (resealed.rows !== 1 || after.key_version !== rotated.version) fail(`re-encrypt did not move the row: ${JSON.stringify(resealed)}`);
|
|
135
|
+
if (vault.value("github_token", { actor: "smoke", action: "used" }) !== TOKEN) fail("the row stopped opening after re-encrypting");
|
|
136
|
+
if (vault.keyDrift().stale !== 0) fail("the drift is still reported after the job ran");
|
|
137
|
+
const audit = db.prepare("SELECT action, count(*) AS n FROM secret_audit WHERE secret_id = ? GROUP BY action ORDER BY action").all(before.id);
|
|
138
|
+
ok(`key rotation — v${before.key_version} → v${rotated.version}, the old key kept opening until the job re-sealed ${resealed.rows} row(s) under v${after.key_version}, audit ${audit.map((a) => `${a.action}×${a.n}`).join(" ")}`);
|
|
139
|
+
|
|
140
|
+
// 7. Deletion is a tombstone, and a tombstone is restorable without anyone holding the value.
|
|
141
|
+
cli(["delete", "github_token"]);
|
|
142
|
+
if (vault.row("github_token").status !== "deleted") fail("delete did not leave a tombstone");
|
|
143
|
+
if (cli(["list"]).out.includes(TOKEN)) fail("the tombstone list leaked a value");
|
|
144
|
+
cli(["undelete", "github_token"]);
|
|
145
|
+
if (vault.row("github_token").status !== "active") fail("undelete did not bring the row back");
|
|
146
|
+
ok("delete is a tombstone, and `undelete` finds it again — the value never had to be typed twice");
|
|
147
|
+
|
|
148
|
+
db.close();
|
|
149
|
+
vault.close();
|
|
150
|
+
rmSync(root, { recursive: true, force: true });
|
|
151
|
+
console.log(`\nthe checkout: ${root} (removed)`);
|
|
152
|
+
console.log(" the same checks against a real store: flypi secret list; sqlite3 .flypi/memory/flypi.db 'select name, mask, key_version from secrets'");
|
|
153
|
+
process.exit(0);
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one command that answers "does end-to-end agent execution still work?" — docs/testing.md.
|
|
3
|
+
*
|
|
4
|
+
* It is a driver, not a test: the assertions live in the test files. What this adds is the two
|
|
5
|
+
* things a suite cannot show you, the recorded fixture rendered by the same mapper the UI uses
|
|
6
|
+
* (`tests/fixtures/transcript-log`, "if they disagree, one of them is wrong") and a real
|
|
7
|
+
* end-to-end turn through the server printed as the transcript a user would see.
|
|
8
|
+
*
|
|
9
|
+
* Run: npm run smoke
|
|
10
|
+
*/
|
|
11
|
+
import { readFileSync } from "node:fs";
|
|
12
|
+
import { resolve } from "node:path";
|
|
13
|
+
import { WebSocket } from "ws";
|
|
14
|
+
import { startFlypiServer } from "../src/server/index.ts";
|
|
15
|
+
import { reduceEvents, renderTranscriptText } from "../src/shared/transcript.ts";
|
|
16
|
+
import { harness, toolTurn } from "../tests/helpers/flypi-harness.ts";
|
|
17
|
+
|
|
18
|
+
const fixtureDir = resolve(import.meta.dirname, "../tests/fixtures/transcript-log");
|
|
19
|
+
const events = JSON.parse(readFileSync(resolve(fixtureDir, "events.json"), "utf8"));
|
|
20
|
+
const golden = readFileSync(resolve(fixtureDir, "transcript.txt"), "utf8");
|
|
21
|
+
|
|
22
|
+
const rendered = renderTranscriptText(reduceEvents(events));
|
|
23
|
+
// Byte for byte, the same comparison tests/shared/transcript.test.ts makes.
|
|
24
|
+
if (rendered !== golden) {
|
|
25
|
+
console.error("FAIL: the shared mapper no longer renders the recorded fixture like the tests do.");
|
|
26
|
+
console.error(`rendered ${rendered.trimEnd().split("\n").length} lines, golden ${golden.trimEnd().split("\n").length} lines`);
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
|
29
|
+
console.log(`ok: fixture — ${events.length} events rendered as ${rendered.trimEnd().split("\n").length} lines`);
|
|
30
|
+
|
|
31
|
+
// An end-to-end turn: scripted stub model → session → registry → WS server → shared mapper.
|
|
32
|
+
const h = await harness(toolTurn());
|
|
33
|
+
const server = await startFlypiServer({ registry: h.registry });
|
|
34
|
+
const socket = new WebSocket(`${server.url}/?token=${encodeURIComponent(server.token)}`);
|
|
35
|
+
const send = (command) => socket.send(JSON.stringify(command));
|
|
36
|
+
|
|
37
|
+
await new Promise((opened) => socket.once("open", opened));
|
|
38
|
+
const settled = new Promise((done, failed) => {
|
|
39
|
+
socket.on("message", (raw) => {
|
|
40
|
+
const frame = JSON.parse(String(raw));
|
|
41
|
+
if (frame.type === "error") return failed(new Error(`server said: ${frame.message}`));
|
|
42
|
+
if (frame.type === "result" && frame.requestId === "1") {
|
|
43
|
+
send({ type: "prompt", conversationId: frame.conversationId, text: "list the files" });
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (frame.type === "event" && frame.event.type === "agent_settled") done(frame.conversationId);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
send({ type: "newSession", requestId: "1" });
|
|
50
|
+
const conversationId = await settled;
|
|
51
|
+
|
|
52
|
+
const eventsOfTurn = h.registry.get(conversationId).session.messages;
|
|
53
|
+
const answer = JSON.stringify(eventsOfTurn);
|
|
54
|
+
console.log(`ok: end-to-end — ${eventsOfTurn.length} messages, the model's answer reached the session: ${/listed the files/.test(answer)}`);
|
|
55
|
+
console.log(renderTranscriptText(reduceEvents(eventsOfTurn.map((message) => ({ type: "message_end", message })))).split("\n").slice(0, 6).join("\n"));
|
|
56
|
+
socket.close();
|
|
57
|
+
await server.close();
|
|
58
|
+
h.cleanup();
|
|
59
|
+
process.exit(/listed the files/.test(answer) ? 0 : 1);
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Phase 7 startup (docs/architecture.md): one command to bring up the **dev** instance on a machine
|
|
4
|
+
* that has nothing but Node. `start.sh` (macOS/Linux) and `start.cmd` (Windows) exist only to check
|
|
5
|
+
* that Node is there — everything below is one Node script, so all three OSes behave identically.
|
|
6
|
+
*
|
|
7
|
+
* ./start.sh | start.cmd # or: npm run start
|
|
8
|
+
* node scripts/start.mjs --skip-models --skip-verify --turn --root DIR
|
|
9
|
+
*
|
|
10
|
+
* In order, and never silently:
|
|
11
|
+
* 1. Node >= 22.19 (pi 1.0.4 engines; 24+ recommended for `node:sqlite`)
|
|
12
|
+
* 2. `npm install` when node_modules is missing or package-lock.json changed since the last install
|
|
13
|
+
* 3. `.flypi/agent/` config: settings.json + models.json templates when absent, and the
|
|
14
|
+
* `flypi-memory` entry in mcp.json with a token generated once and kept (0600, gitignored)
|
|
15
|
+
* 4. download the memory models into `.flypi/models` (`npm run models:warm`)
|
|
16
|
+
* 5. verify: `npm run probe -- --no-turn` (config + isolation) always, and one real model turn on a
|
|
17
|
+
* fresh config, where a working endpoint is the thing that has just been wired up
|
|
18
|
+
* 6. start both halves of the dev instance — the memory MCP server and Vite — and print the URL
|
|
19
|
+
*
|
|
20
|
+
* It runs *before* `npm install`, so it imports nothing from `src/` and needs no dependency: the agent
|
|
21
|
+
* config is plain JSON, read and written here directly. `memoryEndpoint()` in src/agent/config-files.ts
|
|
22
|
+
* stays the authority for a running app; this is only what makes that file exist the first time.
|
|
23
|
+
*/
|
|
24
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
25
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
26
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
27
|
+
import { connect } from "node:net";
|
|
28
|
+
import { join, resolve } from "node:path";
|
|
29
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
30
|
+
|
|
31
|
+
const MIN_NODE = "22.19.0";
|
|
32
|
+
const MEMORY_PORT = 8790;
|
|
33
|
+
const DEV_AGENT_PORT = Number(process.env.FLYPI_DEV_PORT ?? 56789);
|
|
34
|
+
const DEV_UI_PORT = Number(process.env.FLYPI_UI_PORT ?? 5173);
|
|
35
|
+
|
|
36
|
+
/** The starting config for a machine with nothing configured: a local OpenAI-compatible endpoint. */
|
|
37
|
+
const SETTINGS_TEMPLATE = {
|
|
38
|
+
defaultProvider: "lmstudio",
|
|
39
|
+
defaultModel: "replace-with-your-model-id",
|
|
40
|
+
defaultThinkingLevel: "low",
|
|
41
|
+
defaultTools: ["read", "bash", "edit", "write"],
|
|
42
|
+
};
|
|
43
|
+
const MODELS_TEMPLATE = {
|
|
44
|
+
providers: {
|
|
45
|
+
lmstudio: {
|
|
46
|
+
baseUrl: "http://127.0.0.1:1234/v1",
|
|
47
|
+
api: "openai-completions",
|
|
48
|
+
// A placeholder: pi accepts any value here, which is how a local server stays usable
|
|
49
|
+
// without a credential (src/agent/onboarding.ts). Replace it for a real provider.
|
|
50
|
+
apiKey: "lmstudio-local",
|
|
51
|
+
compat: { supportsDeveloperRole: false, supportsReasoningEffort: false },
|
|
52
|
+
models: [
|
|
53
|
+
{
|
|
54
|
+
id: "replace-with-your-model-id",
|
|
55
|
+
input: ["text"],
|
|
56
|
+
contextWindow: 32768,
|
|
57
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** The Node check, apart from the process so it can be tested against a version string. */
|
|
65
|
+
export function nodeProblem(version = process.versions.node) {
|
|
66
|
+
const want = MIN_NODE.split(".").map(Number);
|
|
67
|
+
const have = version.split(".").map(Number);
|
|
68
|
+
for (let i = 0; i < want.length; i++) {
|
|
69
|
+
if ((have[i] ?? 0) < want[i]) {
|
|
70
|
+
return `Node ${MIN_NODE} or newer is required (found ${version}) — install it from https://nodejs.org. 24+ is recommended: flypi uses the built-in node:sqlite.`;
|
|
71
|
+
}
|
|
72
|
+
if ((have[i] ?? 0) > want[i]) break;
|
|
73
|
+
}
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function readJson(file, fallback) {
|
|
78
|
+
if (!existsSync(file)) return fallback;
|
|
79
|
+
try {
|
|
80
|
+
return JSON.parse(readFileSync(file, "utf8"));
|
|
81
|
+
} catch (error) {
|
|
82
|
+
throw new Error(`${file} is not valid JSON: ${error.message}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** 0600 like every other file flypi writes here: mcp.json holds the memory token. */
|
|
87
|
+
function writeJson(file, value) {
|
|
88
|
+
mkdirSync(join(file, ".."), { recursive: true });
|
|
89
|
+
writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
|
90
|
+
chmodSync(file, 0o600);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Make `.flypi/agent/` exist and hold something that starts. Existing files are never rewritten: a
|
|
95
|
+
* working `models.json` carries compat, cost and context windows this script has no idea about, and
|
|
96
|
+
* clobbering one is worse than a fresh clone.
|
|
97
|
+
*
|
|
98
|
+
* The memory token is generated once and then read back from the file, so re-running the script does
|
|
99
|
+
* not invalidate a store that already has memories. An entry already using `${FLYPI_MEMORY_TOKEN}` is
|
|
100
|
+
* left alone — the token then comes from the environment, which `startDev` supplies either way.
|
|
101
|
+
*/
|
|
102
|
+
export function ensureConfig(root, env = process.env) {
|
|
103
|
+
const dir = join(root, ".flypi", "agent");
|
|
104
|
+
mkdirSync(dir, { recursive: true });
|
|
105
|
+
const created = [];
|
|
106
|
+
|
|
107
|
+
for (const [name, template] of [
|
|
108
|
+
["settings.json", SETTINGS_TEMPLATE],
|
|
109
|
+
["models.json", MODELS_TEMPLATE],
|
|
110
|
+
]) {
|
|
111
|
+
const file = join(dir, name);
|
|
112
|
+
// Absent, or the empty pair the daemon writes for a root that has no config of its own: an empty
|
|
113
|
+
// object holds nothing to lose, and a checkout that ran `flypi start` once still gets the template.
|
|
114
|
+
if (!existsSync(file) || Object.keys(readJson(file, {})).length === 0) {
|
|
115
|
+
writeJson(file, template);
|
|
116
|
+
created.push(name);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const mcpPath = join(dir, "mcp.json");
|
|
121
|
+
const file = readJson(mcpPath, {});
|
|
122
|
+
const servers = file.mcpServers ?? {};
|
|
123
|
+
const entry = servers["flypi-memory"];
|
|
124
|
+
let port = MEMORY_PORT;
|
|
125
|
+
let token;
|
|
126
|
+
if (!entry) {
|
|
127
|
+
token = randomBytes(24).toString("hex");
|
|
128
|
+
servers["flypi-memory"] = {
|
|
129
|
+
url: `http://127.0.0.1:${MEMORY_PORT}/mcp`,
|
|
130
|
+
description: "flypi memory store: memory_search, memory_write, memory_read, memory_supersede, memory_forget",
|
|
131
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
132
|
+
exposure: "direct",
|
|
133
|
+
};
|
|
134
|
+
writeJson(mcpPath, { ...file, mcpServers: servers });
|
|
135
|
+
created.push("mcp.json (flypi-memory entry, token generated)");
|
|
136
|
+
} else {
|
|
137
|
+
const bearer = /^Bearer\s+(.*)$/.exec(String(entry.headers?.Authorization ?? ""))?.[1];
|
|
138
|
+
token = bearer && bearer !== "${FLYPI_MEMORY_TOKEN}" ? bearer : env.FLYPI_MEMORY_TOKEN || randomBytes(24).toString("hex");
|
|
139
|
+
const url = entry.url ? new URL(entry.url) : undefined;
|
|
140
|
+
if (url?.port) port = Number(url.port);
|
|
141
|
+
}
|
|
142
|
+
return { token, port, created };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Why an install is needed, or undefined when the tree already matches the lockfile. */
|
|
146
|
+
export function installReason(root) {
|
|
147
|
+
if (!existsSync(join(root, "node_modules"))) return "no node_modules";
|
|
148
|
+
const lock = join(root, "package-lock.json");
|
|
149
|
+
if (!existsSync(lock)) return undefined;
|
|
150
|
+
const hash = createHash("sha256").update(readFileSync(lock)).digest("hex");
|
|
151
|
+
const stamp = join(root, ".flypi", "install-stamp");
|
|
152
|
+
if (existsSync(stamp) && readFileSync(stamp, "utf8").trim() === hash) return undefined;
|
|
153
|
+
return "package-lock.json changed since the last install";
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** True when something is already listening — the dev instance dies on EADDRINUSE, so say so first. */
|
|
157
|
+
export function portInUse(port, host = "127.0.0.1") {
|
|
158
|
+
return new Promise((done) => {
|
|
159
|
+
const socket = connect({ port, host });
|
|
160
|
+
socket.setTimeout(500);
|
|
161
|
+
socket.once("connect", () => {
|
|
162
|
+
socket.destroy();
|
|
163
|
+
done(true);
|
|
164
|
+
});
|
|
165
|
+
socket.once("timeout", () => {
|
|
166
|
+
socket.destroy();
|
|
167
|
+
done(false);
|
|
168
|
+
});
|
|
169
|
+
socket.once("error", () => done(false));
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** npm on Windows is a .cmd, which Node refuses to spawn without a shell. The args here are fixed. */
|
|
174
|
+
const NPM = process.platform === "win32" ? "npm.cmd" : "npm";
|
|
175
|
+
const SHELL = process.platform === "win32";
|
|
176
|
+
|
|
177
|
+
function runNpm(checkout, ...args) {
|
|
178
|
+
return spawnSync(NPM, ["run", ...args], { cwd: checkout, stdio: "inherit", shell: SHELL }).status ?? 1;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function main() {
|
|
182
|
+
const argv = process.argv.slice(2);
|
|
183
|
+
const flagValue = (name) => {
|
|
184
|
+
const i = argv.indexOf(name);
|
|
185
|
+
return i === -1 ? undefined : argv[i + 1];
|
|
186
|
+
};
|
|
187
|
+
// The checkout this script lives in is where npm runs — that is where package.json, node_modules and
|
|
188
|
+
// tsx are. `--root` says which `.flypi/` the instance owns, the same split scripts/flypi.mjs uses.
|
|
189
|
+
const checkout = resolve(fileURLToPath(import.meta.url), "..", "..");
|
|
190
|
+
const root = resolve(flagValue("--root") ?? checkout);
|
|
191
|
+
|
|
192
|
+
const node = nodeProblem();
|
|
193
|
+
if (node) {
|
|
194
|
+
console.error(`flypi: ${node}`);
|
|
195
|
+
process.exit(1);
|
|
196
|
+
}
|
|
197
|
+
console.log(`flypi: node ${process.versions.node}, root ${root}`);
|
|
198
|
+
|
|
199
|
+
const why = installReason(checkout);
|
|
200
|
+
if (why) {
|
|
201
|
+
console.log(`flypi: npm install (${why})`);
|
|
202
|
+
if (spawnSync(NPM, ["install"], { cwd: checkout, stdio: "inherit", shell: SHELL }).status !== 0) {
|
|
203
|
+
console.error("flypi: npm install failed — nothing else can run until it succeeds");
|
|
204
|
+
process.exit(1);
|
|
205
|
+
}
|
|
206
|
+
const stamp = join(checkout, ".flypi", "install-stamp");
|
|
207
|
+
mkdirSync(join(stamp, ".."), { recursive: true });
|
|
208
|
+
writeFileSync(stamp, createHash("sha256").update(readFileSync(join(checkout, "package-lock.json"))).digest("hex"));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const config = ensureConfig(root);
|
|
212
|
+
for (const name of config.created) console.log(`flypi: wrote ${join(root, ".flypi", "agent", name)}`);
|
|
213
|
+
if (config.created.includes("models.json")) {
|
|
214
|
+
console.log("flypi: models.json is a template — set the provider, baseUrl and model id your server actually serves (the settings gear in the UI edits the same file)");
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (!argv.includes("--skip-models")) {
|
|
218
|
+
console.log("flypi: fetching the memory models into .flypi/models (first run downloads them, later runs are instant)");
|
|
219
|
+
if (runNpm(checkout, "models:warm", "--", "--root", root) !== 0) {
|
|
220
|
+
console.error("flypi: the embedding model did not load. Memory search would fall back to keywords; re-run with --skip-models to start anyway, or fix the network and run again.");
|
|
221
|
+
process.exit(1);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (!argv.includes("--skip-verify")) {
|
|
226
|
+
console.log("flypi: verifying the config (probe --no-turn)");
|
|
227
|
+
if (runNpm(checkout, "probe", "--", "--no-turn", "--root", root) !== 0) {
|
|
228
|
+
console.error("flypi: the config does not start a session — fix what probe says above");
|
|
229
|
+
process.exit(1);
|
|
230
|
+
}
|
|
231
|
+
// A real turn only on a config that was just created: that is the thing worth proving, and on
|
|
232
|
+
// every later start it would spend tokens and a local server's time to prove nothing new.
|
|
233
|
+
if (config.created.length > 0 || argv.includes("--turn")) {
|
|
234
|
+
console.log("flypi: running one real turn through the configured model");
|
|
235
|
+
if (runNpm(checkout, "probe", "--", "--root", root) !== 0) {
|
|
236
|
+
console.error("flypi: the config is wired, but the model did not answer. Start the model server, or fix .flypi/agent/models.json — the UI and its settings gear will still come up.");
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
for (const [what, port] of [
|
|
242
|
+
["memory server", config.port],
|
|
243
|
+
["dev agent server", DEV_AGENT_PORT],
|
|
244
|
+
["dev UI (Vite)", DEV_UI_PORT],
|
|
245
|
+
]) {
|
|
246
|
+
if (await portInUse(port)) {
|
|
247
|
+
console.error(`flypi: something is already listening on ${port} (${what}). Stop it first — for flypi's own daemon: node scripts/flypi.mjs stop`);
|
|
248
|
+
process.exit(1);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
startDev(checkout, root, config);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Both halves of dev in one command: the memory MCP server (a separate process by design — the store
|
|
257
|
+
* has one writer) and Vite, which starts the agent server in its own process. Ctrl-C stops all three;
|
|
258
|
+
* they share the terminal's process group, which is also how Windows delivers it.
|
|
259
|
+
* shortcut: no restart-on-crash, dev is a foreground thing; if a half needs to survive, that is the
|
|
260
|
+
* daemon (`node scripts/flypi.mjs start`), not this.
|
|
261
|
+
*/
|
|
262
|
+
function startDev(checkout, root, config) {
|
|
263
|
+
const env = { ...process.env, FLYPI_MEMORY_TOKEN: config.token };
|
|
264
|
+
const children = [
|
|
265
|
+
spawn(NPM, ["run", "memory", "--", "--root", root, "--port", String(config.port), "--token", config.token], { cwd: checkout, stdio: "inherit", shell: SHELL, env }),
|
|
266
|
+
// --strictPort so the URL printed below is the URL Vite is on: without it Vite silently takes the
|
|
267
|
+
// next free port and the line the user clicks would be a guess.
|
|
268
|
+
spawn(NPM, ["run", "dev", "--", "--port", String(DEV_UI_PORT), "--strictPort"], { cwd: checkout, stdio: "inherit", shell: SHELL, env }),
|
|
269
|
+
];
|
|
270
|
+
console.log(`flypi: UI http://localhost:${DEV_UI_PORT} — agent server on port ${DEV_AGENT_PORT}, memory MCP on ${config.port}`);
|
|
271
|
+
let stopping = false;
|
|
272
|
+
const stop = () => {
|
|
273
|
+
if (stopping) return;
|
|
274
|
+
stopping = true;
|
|
275
|
+
for (const child of children) child.kill("SIGTERM");
|
|
276
|
+
};
|
|
277
|
+
process.on("SIGINT", stop);
|
|
278
|
+
process.on("SIGTERM", stop);
|
|
279
|
+
for (const child of children) {
|
|
280
|
+
child.on("exit", (code) => {
|
|
281
|
+
// One half dying is a broken dev instance, so neither stays up: the UI without memory is a
|
|
282
|
+
// chat that silently forgets, and memory without the UI has nobody to talk to.
|
|
283
|
+
if (!stopping) {
|
|
284
|
+
console.error(`flypi: a half of the dev instance exited (code ${code}) — stopping the other`);
|
|
285
|
+
stop();
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Run when invoked directly, import as a module when a test wants the helpers (tests/scripts/start.test.ts).
|
|
292
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
|
|
293
|
+
await main();
|
|
294
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* C1's instrument for real conversations (docs/prompt-cache.md Phase 4.7): what the turn boundary cost, in
|
|
3
|
+
* tokens, in the conversations flypi actually ran.
|
|
4
|
+
*
|
|
5
|
+
* npm run probe:boundary # every .flypi/sessions/*.jsonl
|
|
6
|
+
* npm run probe:boundary -- path # one session file, or another sessions dir
|
|
7
|
+
*
|
|
8
|
+
* It reads the provider's own numbers out of pi's session log — the same accounting `turns.usage_json` holds
|
|
9
|
+
* (Phase 4.6 A1), summed per turn there — and splits the model calls at the only place the prompt cache is
|
|
10
|
+
* allowed to break: the first call of a turn. Nothing here asks a model anything, so it re-runs on any
|
|
11
|
+
* machine that has the session files.
|
|
12
|
+
*
|
|
13
|
+
* `probe:turn` is the other half: it runs one two-turn conversation on a live route, and `--no-memory` runs
|
|
14
|
+
* it with nothing transient injected, which is how you tell the break apart from the route forgetting its
|
|
15
|
+
* cache anyway.
|
|
16
|
+
*/
|
|
17
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
18
|
+
import { resolve } from "node:path";
|
|
19
|
+
import { flypiPaths } from "../src/agent/paths.ts";
|
|
20
|
+
import { modelCalls, spread, summarise, type ModelCall } from "../src/shared/turn-boundary.ts";
|
|
21
|
+
|
|
22
|
+
const sessionsDir = flypiPaths(process.cwd()).sessionsDir;
|
|
23
|
+
const target = resolve(process.argv[2] ?? sessionsDir);
|
|
24
|
+
if (!existsSync(target)) {
|
|
25
|
+
console.error(`no session files at ${target} — flypi keeps them in ${sessionsDir}`);
|
|
26
|
+
process.exit(1);
|
|
27
|
+
}
|
|
28
|
+
const files = statSync(target).isDirectory()
|
|
29
|
+
? readdirSync(target).filter((name) => name.endsWith(".jsonl")).sort().map((name) => `${target}/${name}`)
|
|
30
|
+
: [target];
|
|
31
|
+
|
|
32
|
+
for (const file of files) {
|
|
33
|
+
const calls = modelCalls(readFileSync(file, "utf8"));
|
|
34
|
+
const summary = summarise(calls);
|
|
35
|
+
if (summary.boundaries === 0) continue; // one-turn conversations have no boundary to price
|
|
36
|
+
console.log(`${file.split("/").pop()} ${calls[0]?.model ?? "?"} model calls=${calls.length}`);
|
|
37
|
+
for (const call of calls.filter((c: ModelCall) => c.firstCallOfTurn && c.carry > 0)) {
|
|
38
|
+
console.log(
|
|
39
|
+
` boundary prompt=${call.prompt} reused=${call.reused} repaid=${call.repaid} could_reuse=${call.carry}`,
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const all = summarise(files.flatMap((file) => modelCalls(readFileSync(file, "utf8"))));
|
|
45
|
+
console.log(
|
|
46
|
+
[
|
|
47
|
+
`turn boundaries after the first turn: ${all.boundaries}`,
|
|
48
|
+
` re-paid (min/median/max tokens): ${spread(all.paid)} total=${all.paid.reduce((a, b) => a + b, 0)}`,
|
|
49
|
+
` the prefix that was there to reuse: ${spread(all.carry)}`,
|
|
50
|
+
` inside a turn, the same conversations: n=${all.withinTurn.length} re-paid ${spread(all.withinTurn)}`,
|
|
51
|
+
].join("\n"),
|
|
52
|
+
);
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `flypi update` (docs/architecture.md Phase 7): put the newest published flypi on this machine.
|
|
4
|
+
*
|
|
5
|
+
* flypi update # check, download, stop the daemon, install it, start the daemon again
|
|
6
|
+
* flypi update --check # the answer only — touches nothing but the stamp
|
|
7
|
+
* flypi update --dry-run # every step, none of them run
|
|
8
|
+
*
|
|
9
|
+
* The same package the daemon downloads on its own and installs while it is idle (`src/daemon/update.ts`);
|
|
10
|
+
* this is the version of that you can ask for at a keyboard. The daemon is stopped before the install because
|
|
11
|
+
* it replaces the files it is running from, and started again through this same CLI, which is what keeps the
|
|
12
|
+
* port and interface it was reached by (`flypi restart`'s rule). If the install fails, the daemon is started
|
|
13
|
+
* again on the version it had: a failed update leaves a running flypi, not a dead one.
|
|
14
|
+
*/
|
|
15
|
+
import { spawnSync } from "node:child_process";
|
|
16
|
+
import { resolve } from "node:path";
|
|
17
|
+
import { checkForUpdate, clearUpdateCheck, downloadUpdate, installArgs, isGlobalInstall, isNewer, keepDownload, packageDir, packageInfo, readUpdateCheck, updateAvailable, writeUpdateCheck } from "../src/daemon/update.ts";
|
|
18
|
+
|
|
19
|
+
const argv = process.argv.slice(2);
|
|
20
|
+
const rootAt = argv.indexOf("--root");
|
|
21
|
+
const root = resolve(rootAt === -1 ? process.env.FLYPI_ROOT ?? process.cwd() : (argv[rootAt + 1] ?? ""));
|
|
22
|
+
const checkOnly = argv.includes("--check");
|
|
23
|
+
const dryRun = argv.includes("--dry-run");
|
|
24
|
+
const pkg = packageInfo();
|
|
25
|
+
|
|
26
|
+
/** `remote: true` marks the steps that leave this machine, so the dry-run output says which is which. */
|
|
27
|
+
function run(command: string, args: string[], { remote = false } = {}): number {
|
|
28
|
+
if (dryRun) {
|
|
29
|
+
console.log(`flypi update: would run${remote ? " [remote]" : ""}: ${command} ${args.join(" ")}`);
|
|
30
|
+
return 0;
|
|
31
|
+
}
|
|
32
|
+
return spawnSync(command, args, { stdio: "inherit" }).status ?? 1;
|
|
33
|
+
}
|
|
34
|
+
/** The daemon CLI, for the stop and the start — so the port/interface carry-over lives in one place. */
|
|
35
|
+
const cli = (...args: string[]) => run(process.execPath, [resolve(import.meta.dirname, "flypi.mjs"), "--root", root, ...args]);
|
|
36
|
+
|
|
37
|
+
if (pkg.checkout) {
|
|
38
|
+
// The registry only knows what `npm publish` put there, and a checkout has the source in front of it.
|
|
39
|
+
console.log(`flypi: ${pkg.version} is a checkout at ${packageDir()} — update it with git:`);
|
|
40
|
+
console.log("flypi: git pull --ff-only && npm install && npm run build, then `flypi restart`");
|
|
41
|
+
process.exit(0);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const stamp = await checkForUpdate({ current: pkg.version, name: pkg.name });
|
|
45
|
+
// A package the daemon already downloaded stays staged; `--check` leaves the stamp as it is.
|
|
46
|
+
stamp.download = keepDownload(stamp, readUpdateCheck(root));
|
|
47
|
+
if (!dryRun) writeUpdateCheck(root, stamp);
|
|
48
|
+
if (stamp.error) {
|
|
49
|
+
console.log(`flypi: no update check: ${stamp.error}`);
|
|
50
|
+
process.exit(0);
|
|
51
|
+
}
|
|
52
|
+
const step = updateAvailable(stamp, pkg.version);
|
|
53
|
+
if (!step) {
|
|
54
|
+
console.log(`flypi: ${pkg.version} is the newest published version of ${pkg.name}`);
|
|
55
|
+
process.exit(0);
|
|
56
|
+
}
|
|
57
|
+
// One step, never a jump: flypi's databases migrate when the version that needs the migration starts, so
|
|
58
|
+
// 0.1.0 → 0.3.0 would skip what 0.2.0 would have done to the schema (src/daemon/update.ts).
|
|
59
|
+
const further = stamp.latest && isNewer(step, stamp.latest) ? ` — one step at a time; ${stamp.latest} is the newest published` : "";
|
|
60
|
+
console.log(`flypi: ${pkg.version} → ${step}${further}`);
|
|
61
|
+
if (checkOnly) {
|
|
62
|
+
console.log(stamp.download ? `flypi: ${step} is downloaded; it installs the next time the daemon is idle` : "flypi: `flypi update` installs it");
|
|
63
|
+
process.exit(0);
|
|
64
|
+
}
|
|
65
|
+
if (!stamp.dist?.tarball) {
|
|
66
|
+
console.error(`flypi: the registry named no package for ${step} to download`);
|
|
67
|
+
process.exit(1);
|
|
68
|
+
}
|
|
69
|
+
if (!isGlobalInstall()) console.log("flypi: note — this code is not the npm global package, so `npm install -g` may not replace it");
|
|
70
|
+
|
|
71
|
+
const downloaded = await downloadUpdate({ root, version: step, dist: stamp.dist });
|
|
72
|
+
if (dryRun) {
|
|
73
|
+
console.log(`flypi update: would download ${stamp.dist.tarball}, verify it against the registry's checksum, then:`);
|
|
74
|
+
cli("stop");
|
|
75
|
+
run("npm", installArgs("<the downloaded package>"), { remote: true });
|
|
76
|
+
cli("start");
|
|
77
|
+
process.exit(0);
|
|
78
|
+
}
|
|
79
|
+
if (!downloaded.file) {
|
|
80
|
+
console.error(`flypi: ${step} could not be downloaded: ${downloaded.error}`);
|
|
81
|
+
process.exit(1);
|
|
82
|
+
}
|
|
83
|
+
stamp.download = { version: step, file: downloaded.file, at: new Date().toISOString() };
|
|
84
|
+
writeUpdateCheck(root, stamp);
|
|
85
|
+
|
|
86
|
+
cli("stop");
|
|
87
|
+
const installed = run("npm", installArgs(downloaded.file), { remote: true });
|
|
88
|
+
if (installed === 0) clearUpdateCheck(root);
|
|
89
|
+
cli("start");
|
|
90
|
+
if (installed !== 0) console.error("flypi: the install failed — the daemon is running the version it had");
|
|
91
|
+
else console.log(`flypi: ${pkg.name}@${step} installed${further}`);
|
|
92
|
+
process.exit(installed);
|