@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/README.md +20 -0
- 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 +27 -2
- package/scripts/pi.ts +150 -0
- package/scripts/probe-pi.ts +18 -0
- package/scripts/release.mjs +4 -1
- 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
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The surface of pi that flypi depends on, and a check that a candidate tree still offers it
|
|
3
|
+
* (docs/pi-upgrades.md Phase 8.2, risk 14).
|
|
4
|
+
*
|
|
5
|
+
* This is a **pre-filter, not a gate**. It catches the dominant breakage class — a renamed or dropped export,
|
|
6
|
+
* a moved internal file, a raised Node floor — in about a second and without restarting anything. It cannot
|
|
7
|
+
* see the behavioural half of pi-api.md: pi 1.4.0 kept every export and changed the *shape* of the
|
|
8
|
+
* `before_agent_start` event, which no `typeof` check can find. Only `npm test` and `npm run smoke` catch
|
|
9
|
+
* that class, and they are the gate.
|
|
10
|
+
*
|
|
11
|
+
* The list is **generated from the source, never maintained by hand**: `tests/scripts/pi-compat.test.ts`
|
|
12
|
+
* greps every `from "@earendil-works/…"` in `src/ scripts/ tests/ ui/` and fails when the two disagree. A
|
|
13
|
+
* hand-kept list that drifts reports a clean pass, which is worse than no list.
|
|
14
|
+
*
|
|
15
|
+
* Three specifiers, not one (docs/pi-upgrades.md C3): `pi-coding-agent` is the bulk, `pi-mcp` is imported by
|
|
16
|
+
* the memory smoke script and three tests without being a declared dependency of flypi, and
|
|
17
|
+
* `pi-ai/providers/faux` is the stub every model-touching test runs on. A probe that checked only
|
|
18
|
+
* `pi-coding-agent` would report a clean pass while the whole suite was broken.
|
|
19
|
+
*/
|
|
20
|
+
import { existsSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
21
|
+
import { dirname, join } from "node:path";
|
|
22
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
23
|
+
|
|
24
|
+
/** One specifier flypi imports from, and what it takes from it. */
|
|
25
|
+
export interface PiImport {
|
|
26
|
+
/** The bare specifier, exactly as the source writes it. */
|
|
27
|
+
specifier: string;
|
|
28
|
+
/** Names with a runtime presence. flypi calls every one of them, so each must stay a function. */
|
|
29
|
+
values: string[];
|
|
30
|
+
/** Type-only names: no runtime check is possible, so the entry's `.d.ts` is checked instead. */
|
|
31
|
+
types: string[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface PiDeepImport {
|
|
35
|
+
/** The package whose internals flypi reaches into by path. */
|
|
36
|
+
specifier: string;
|
|
37
|
+
/** The file inside it, as `src/agent/pi-mcp.ts` joins it onto `getPackageDir()`. */
|
|
38
|
+
path: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** What flypi imports from pi. Kept honest by `tests/scripts/pi-compat.test.ts`, not by anyone's memory. */
|
|
42
|
+
export const PI_COMPAT: { imports: PiImport[]; deep: PiDeepImport[] } = {
|
|
43
|
+
imports: [
|
|
44
|
+
{
|
|
45
|
+
specifier: "@earendil-works/pi-coding-agent",
|
|
46
|
+
values: [
|
|
47
|
+
"createAgentSession",
|
|
48
|
+
"DefaultResourceLoader",
|
|
49
|
+
"DefaultPackageManager",
|
|
50
|
+
"SettingsManager",
|
|
51
|
+
"SessionManager",
|
|
52
|
+
"ModelRuntime",
|
|
53
|
+
"getPackageDir",
|
|
54
|
+
"createMcpExtension",
|
|
55
|
+
"createToolSearchExtension",
|
|
56
|
+
],
|
|
57
|
+
types: [
|
|
58
|
+
"AgentSessionEvent",
|
|
59
|
+
"CreateAgentSessionOptions",
|
|
60
|
+
"CustomMessageEntry",
|
|
61
|
+
"ExtensionAPI",
|
|
62
|
+
"ExtensionContext",
|
|
63
|
+
"ExtensionFactory",
|
|
64
|
+
"ExtensionUIContext",
|
|
65
|
+
"ExtensionUIDialogOptions",
|
|
66
|
+
"LoadedMcpConfig",
|
|
67
|
+
"McpExtensionOptions",
|
|
68
|
+
"McpServerConfig",
|
|
69
|
+
"McpServerEntry",
|
|
70
|
+
"SessionEntry",
|
|
71
|
+
],
|
|
72
|
+
},
|
|
73
|
+
{ specifier: "@earendil-works/pi-mcp", values: ["McpClient", "StreamableHttpTransport"], types: [] },
|
|
74
|
+
{
|
|
75
|
+
specifier: "@earendil-works/pi-ai/providers/faux",
|
|
76
|
+
values: ["fauxAssistantMessage", "fauxProvider", "fauxText", "fauxThinking", "fauxToolCall"],
|
|
77
|
+
types: ["FauxProviderHandle", "FauxResponseStep"],
|
|
78
|
+
},
|
|
79
|
+
],
|
|
80
|
+
// Reached by path because the entry does not export them and its `exports` map hides the types
|
|
81
|
+
// (src/agent/pi-mcp.ts). A moved internal file breaks flypi while every export still passes.
|
|
82
|
+
deep: [
|
|
83
|
+
{ specifier: "@earendil-works/pi-coding-agent", path: "dist/extensions/mcp/runtime.js" },
|
|
84
|
+
{ specifier: "@earendil-works/pi-coding-agent", path: "dist/core/auth-storage.js" },
|
|
85
|
+
{ specifier: "@earendil-works/pi-coding-agent", path: "dist/core/resolve-config-value.js" },
|
|
86
|
+
],
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/** `engines.node: ">=22.19.0"` against the Node that would run it. Numeric parts, left to right. */
|
|
90
|
+
function nodeSatisfied(range: string, version: string): boolean {
|
|
91
|
+
const min = /(\d+)\.(\d+)\.(\d+)/.exec(range);
|
|
92
|
+
if (!min) return true;
|
|
93
|
+
const parts = version.split(".").map(Number);
|
|
94
|
+
for (let i = 1; i <= 3; i++) {
|
|
95
|
+
const diff = Math.sign((parts[i - 1] ?? 0) - Number(min[i]));
|
|
96
|
+
if (diff) return diff > 0;
|
|
97
|
+
}
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Walk up from a resolved file to the package that owns it — `dist/index.js` is not the package root. */
|
|
102
|
+
function packageRoot(fromFile: string, name: string): string | null {
|
|
103
|
+
let dir = dirname(fromFile);
|
|
104
|
+
while (dir !== dirname(dir)) {
|
|
105
|
+
try {
|
|
106
|
+
if ((JSON.parse(readFileSync(join(dir, "package.json"), "utf8")) as { name?: string }).name === name) return dir;
|
|
107
|
+
} catch {
|
|
108
|
+
/* keep walking */
|
|
109
|
+
}
|
|
110
|
+
dir = dirname(dir);
|
|
111
|
+
}
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Resolve specifiers **from inside the candidate tree**. `import.meta.resolve(spec, parentURL)` cannot do
|
|
117
|
+
* it — Node 24 ignores the parent argument and resolves from the calling module, which would have the probe
|
|
118
|
+
* check flypi's own pi and report a clean pass for a candidate that is not even there. A module written at
|
|
119
|
+
* the candidate's root resolves from its own location, which is the tree being probed.
|
|
120
|
+
*/
|
|
121
|
+
async function resolverFor(root: string): Promise<(specifier: string) => string | null> {
|
|
122
|
+
const helper = join(root, ".flypi-probe-resolver.mjs");
|
|
123
|
+
writeFileSync(helper, "export const resolveFrom = (specifier) => import.meta.resolve(specifier);\n");
|
|
124
|
+
let resolveFrom: (specifier: string) => string;
|
|
125
|
+
try {
|
|
126
|
+
({ resolveFrom } = await import(pathToFileURL(helper).href));
|
|
127
|
+
} finally {
|
|
128
|
+
rmSync(helper, { force: true });
|
|
129
|
+
}
|
|
130
|
+
return (specifier) => {
|
|
131
|
+
try {
|
|
132
|
+
const url = resolveFrom(specifier);
|
|
133
|
+
return fileURLToPath(url).startsWith(join(root, "node_modules") + "/") ? url : null;
|
|
134
|
+
} catch {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* What a candidate tree is missing — one sentence per problem, empty when it looks usable. A complete tree
|
|
142
|
+
* resolves its own third-party dependencies, so the probe asks the candidate rather than flypi.
|
|
143
|
+
*/
|
|
144
|
+
export async function checkPiCompat(candidateRoot: string): Promise<string[]> {
|
|
145
|
+
const failures: string[] = [];
|
|
146
|
+
let root: string;
|
|
147
|
+
try {
|
|
148
|
+
root = realpathSync(candidateRoot);
|
|
149
|
+
} catch {
|
|
150
|
+
return [`${candidateRoot} is not a directory — nothing to probe`];
|
|
151
|
+
}
|
|
152
|
+
if (!existsSync(join(root, "node_modules", "@earendil-works"))) return [`${candidateRoot} has no @earendil-works in its node_modules — that is not a candidate pi tree`];
|
|
153
|
+
const resolveFrom = await resolverFor(root);
|
|
154
|
+
|
|
155
|
+
for (const entry of PI_COMPAT.imports) {
|
|
156
|
+
const url = resolveFrom(entry.specifier);
|
|
157
|
+
if (!url) {
|
|
158
|
+
failures.push(`${entry.specifier}: does not resolve from inside ${candidateRoot}`);
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
let mod: Record<string, unknown>;
|
|
162
|
+
try {
|
|
163
|
+
// The query busts the ESM module cache: asked twice in one process, the probe has to look at the
|
|
164
|
+
// tree again rather than at what the first look left loaded.
|
|
165
|
+
mod = (await import(`${url}?pi-probe=1`)) as Record<string, unknown>;
|
|
166
|
+
} catch (error) {
|
|
167
|
+
failures.push(`${entry.specifier}: importing it threw — ${(error as Error).message}`);
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
for (const name of entry.values) {
|
|
171
|
+
if (typeof mod[name] !== "function") failures.push(`${entry.specifier}: ${name} is ${typeof mod[name]}, and flypi calls it as a function`);
|
|
172
|
+
}
|
|
173
|
+
if (entry.types.length === 0) continue;
|
|
174
|
+
// The entry's own declaration file: `dist/index.d.ts` for the package entry,
|
|
175
|
+
// `dist/providers/faux.d.ts` for the subpath. Every type flypi imports is re-exported there by name.
|
|
176
|
+
const declaration = fileURLToPath(url.replace(/\.js$/, ".d.ts"));
|
|
177
|
+
if (!existsSync(declaration)) {
|
|
178
|
+
failures.push(`${entry.specifier}: no ${declaration.slice(declaration.lastIndexOf("/") + 1)} beside the entry, so its type exports cannot be checked`);
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
const text = readFileSync(declaration, "utf8");
|
|
182
|
+
for (const name of entry.types) {
|
|
183
|
+
if (!new RegExp(`\\b${name}\\b`).test(text)) failures.push(`${entry.specifier}: the type ${name} is no longer re-exported from the entry`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
for (const deep of PI_COMPAT.deep) {
|
|
188
|
+
const url = resolveFrom(deep.specifier);
|
|
189
|
+
const owner = url ? packageRoot(fileURLToPath(url), deep.specifier) : null;
|
|
190
|
+
const file = owner ? join(owner, deep.path) : "";
|
|
191
|
+
if (!url || !owner || !existsSync(file)) failures.push(`${deep.specifier}: ${deep.path} is not there, and flypi imports it by path`);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// The Node floor: a candidate that needs a newer Node than the daemon runs on fails at import, which is
|
|
195
|
+
// the failure that leaves no web UI to click rollback on (docs/risks.md risk 13).
|
|
196
|
+
const entry = resolveFrom("@earendil-works/pi-coding-agent");
|
|
197
|
+
if (entry) {
|
|
198
|
+
const packageJson = join(packageRoot(fileURLToPath(entry), "@earendil-works/pi-coding-agent") ?? "", "package.json");
|
|
199
|
+
const engines = existsSync(packageJson) ? (JSON.parse(readFileSync(packageJson, "utf8")) as { engines?: { node?: string } }).engines?.node : undefined;
|
|
200
|
+
if (engines && !nodeSatisfied(engines, process.versions.node)) failures.push(`this pi needs Node ${engines}; this process is ${process.versions.node}`);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return failures;
|
|
204
|
+
}
|
package/src/daemon/index.ts
CHANGED
|
@@ -11,13 +11,13 @@
|
|
|
11
11
|
* and so is the run store, which is a file either entrypoint can open in a millisecond. Everything that makes
|
|
12
12
|
* it a *daemon* — the reconciliation, `/healthz`, `daemon.json`, the queue — lives here, and is tested here.
|
|
13
13
|
*/
|
|
14
|
-
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { existsSync, openSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
15
15
|
import { spawn } from "node:child_process";
|
|
16
16
|
import { join, resolve } from "node:path";
|
|
17
17
|
import type { DatabaseSync } from "node:sqlite";
|
|
18
18
|
import type { FlypiPaths } from "../agent/paths.ts";
|
|
19
19
|
import { applyConfigEdits, ensureAgentConfig, ensureMemoryEntry, memoryEndpoint } from "../agent/config-files.ts";
|
|
20
|
-
import { applyPackageEdits, checkPackageUpdates, dueForPackageCheck, readPackageCheck, writePackageCheck } from "../agent/packages.ts";
|
|
20
|
+
import { applyPackageEdits, autoExtensions, checkPackageUpdates, dueForPackageCheck, readPackageCheck, writePackageCheck } from "../agent/packages.ts";
|
|
21
21
|
import { CONSOLIDATE_JOB, consolidate, sweepDue } from "../memory/consolidate.ts";
|
|
22
22
|
import { startMemoryServer, type MemoryServerHandle } from "../memory/mcp.ts";
|
|
23
23
|
import type { MemoryStore } from "../memory/store.ts";
|
|
@@ -30,6 +30,7 @@ import { startFlypiServer, type FlypiServerHandle, type FlypiServerOptions } fro
|
|
|
30
30
|
import { SessionRegistry, type CreateSession } from "../server/session-registry.ts";
|
|
31
31
|
import type { RunStore } from "../server/run-store.ts";
|
|
32
32
|
import { JobQueue, startJobLoop, type JobHandler } from "./jobs.ts";
|
|
33
|
+
import { PI_PACKAGE, dueForPiCheck, loadedPi, piTrees, readPiCheck, writePiCheck } from "./pi.ts";
|
|
33
34
|
import { IDLE_BEFORE_INSTALL_MS, checkForUpdate, clearUpdateCheck, downloadUpdate, dueForCheck, installArgs, isGlobalInstall, isNewer, keepDownload, packageDir, packageInfo, readUpdateCheck, stagedUpdate, updateAvailable, writeUpdateCheck } from "./update.ts";
|
|
34
35
|
|
|
35
36
|
/** What `.flypi/daemon.json` holds: enough for `flypi status` to find a running daemon again. */
|
|
@@ -191,6 +192,7 @@ export async function startFlypiDaemon(options: DaemonOptions): Promise<DaemonHa
|
|
|
191
192
|
pid: process.pid,
|
|
192
193
|
startedAt: startedAt,
|
|
193
194
|
version,
|
|
195
|
+
pi: loadedPi(paths.root),
|
|
194
196
|
memoryPort: memory.port,
|
|
195
197
|
jobs: queue.queued(),
|
|
196
198
|
...(newer ? { updateAvailable: newer } : {}),
|
|
@@ -205,6 +207,42 @@ export async function startFlypiDaemon(options: DaemonOptions): Promise<DaemonHa
|
|
|
205
207
|
interruptedRun: (sessionFile) => options.runs.interruptedFor(sessionFile),
|
|
206
208
|
// The page's "install now": the same path the idle rule takes, without the wait.
|
|
207
209
|
installUpdate: () => installStaged(),
|
|
210
|
+
// Which pi this is, what is on disk to move to, and the daily registry answer. No network here: the
|
|
211
|
+
// stamp is what the page shows (docs/pi-upgrades.md Phase 8.4).
|
|
212
|
+
piVersions: () => {
|
|
213
|
+
const pi = loadedPi(paths.root);
|
|
214
|
+
const stamp = readPiCheck(paths.root);
|
|
215
|
+
return {
|
|
216
|
+
running: pi.version,
|
|
217
|
+
pinned: pi.pinned,
|
|
218
|
+
...(pi.bundled ? { bundled: pi.bundled } : {}),
|
|
219
|
+
trees: piTrees(paths.root),
|
|
220
|
+
...(stamp?.next ? { next: stamp.next } : {}),
|
|
221
|
+
...(stamp?.latest ? { latest: stamp.latest } : {}),
|
|
222
|
+
...(stamp ? { checkedAt: stamp.checkedAt } : {}),
|
|
223
|
+
};
|
|
224
|
+
},
|
|
225
|
+
// "Check now": the daily registry answer asked again on request, then read back through the same shape.
|
|
226
|
+
piCheck: async () => {
|
|
227
|
+
await checkPiVersions();
|
|
228
|
+
return serverOptions.piVersions!();
|
|
229
|
+
},
|
|
230
|
+
// Move pi and restart, through the same CLI path `flypi pi use` takes — the daemon cannot re-pick the
|
|
231
|
+
// module graph it already imported, and the child is the process that survives the restart to notice a
|
|
232
|
+
// failed start and put the pointer back (docs/risks.md risk 13).
|
|
233
|
+
usePi: async (version: string) => {
|
|
234
|
+
if (pkg.checkout || !isGlobalInstall()) {
|
|
235
|
+
return "this flypi is not the npm global package — in a checkout, `npm install @earendil-works/pi-coding-agent@X && npm test && npm run smoke`; in a container, rebuild the image";
|
|
236
|
+
}
|
|
237
|
+
const logFile = openSync(resolve(paths.root, ".flypi", "daemon.log"), "a");
|
|
238
|
+
const child = spawn(process.execPath, ["--import", "tsx", resolve(packageDir(), "scripts", "pi.ts"), "--root", paths.root, "use", version], { cwd: packageDir(), detached: true, stdio: ["ignore", logFile, logFile] });
|
|
239
|
+
child.on("error", (error) => log(`flypi: the pi switch could not start: ${error.message}`));
|
|
240
|
+
child.unref();
|
|
241
|
+
log(`flypi: moving to pi ${version} — the daemon restarts to run it, and goes back on its own if it does not come up`);
|
|
242
|
+
return null;
|
|
243
|
+
},
|
|
244
|
+
// `hello` carries it too, so a client knows what pi it is talking to without a round trip to /healthz.
|
|
245
|
+
pi: () => loadedPi(paths.root),
|
|
208
246
|
};
|
|
209
247
|
let server: FlypiServerHandle;
|
|
210
248
|
// The same rule as the memory port above, and the reason the pid file is written after the bind rather
|
|
@@ -344,12 +382,23 @@ export async function startFlypiDaemon(options: DaemonOptions): Promise<DaemonHa
|
|
|
344
382
|
if (check.error) log(`flypi: no extension update check: ${check.error}`);
|
|
345
383
|
else if (check.updates.length) log(`flypi: ${check.updates.length} extension package(s) have updates — ${check.updates.join(", ")}; they install the next time the daemon is idle`);
|
|
346
384
|
};
|
|
385
|
+
// The daily answer about published pi versions. It is only ever *shown*: moving pi restarts the process,
|
|
386
|
+
// so it never happens unattended (docs/pi-upgrades.md — the thing to recover from is exactly an unattended
|
|
387
|
+
// version change). The stamp is what the Versions section reads.
|
|
388
|
+
const checkPiVersions = async () => {
|
|
389
|
+
const check = await checkForUpdate({ current: loadedPi(paths.root).version, name: PI_PACKAGE });
|
|
390
|
+
writePiCheck(paths.root, { checkedAt: check.checkedAt, latest: check.latest, next: check.next, ...(check.error ? { error: check.error } : {}) });
|
|
391
|
+
if (check.next) log(`flypi: pi ${check.next} is available (running ${check.current}) — the Settings tab moves to it; nothing moves by itself`);
|
|
392
|
+
else if (check.error) log(`flypi: no pi version check: ${check.error}`);
|
|
393
|
+
};
|
|
394
|
+
|
|
347
395
|
const updatePass = (idle: boolean) => {
|
|
348
396
|
const stamp = readUpdateCheck(paths.root);
|
|
349
397
|
const pkgStamp = readPackageCheck(paths.root);
|
|
350
398
|
// A checkout is not an installed package, but the extensions it loads are: only flypi's own ladder is
|
|
351
399
|
// skipped there.
|
|
352
400
|
if (!pkg.checkout && dueForCheck(stamp)) void checkUpdates();
|
|
401
|
+
if (!pkg.checkout && dueForPiCheck(readPiCheck(paths.root))) void checkPiVersions();
|
|
353
402
|
if (dueForPackageCheck(pkgStamp)) void checkPackages();
|
|
354
403
|
const staged = pkg.checkout ? null : stagedUpdate(stamp, version);
|
|
355
404
|
const pending = pkgStamp?.updates ?? [];
|
|
@@ -360,12 +409,17 @@ export async function startFlypiDaemon(options: DaemonOptions): Promise<DaemonHa
|
|
|
360
409
|
if (!idleSince) idleSince = Date.now();
|
|
361
410
|
if (Date.now() - idleSince < IDLE_BEFORE_INSTALL_MS) return;
|
|
362
411
|
// Packages first: they need no restart, and the flypi install below may take this process away.
|
|
363
|
-
|
|
412
|
+
// The choice is the user's (`updates.autoExtensions` in settings.json): an unattended extension
|
|
413
|
+
// update is how an incompatible one arrives.
|
|
414
|
+
if (pending.length && autoExtensions(paths)) void updatePackages(pending);
|
|
364
415
|
if (staged && stamp?.download?.file) installPackage(staged, stamp.download.file);
|
|
365
416
|
// Start the clock again either way: a failed install retries after another idle stretch, not every pass.
|
|
366
417
|
idleSince = 0;
|
|
367
418
|
};
|
|
368
|
-
if (!pkg.checkout)
|
|
419
|
+
if (!pkg.checkout) {
|
|
420
|
+
void checkUpdates();
|
|
421
|
+
void checkPiVersions();
|
|
422
|
+
}
|
|
369
423
|
if (dueForPackageCheck(readPackageCheck(paths.root))) void checkPackages();
|
|
370
424
|
|
|
371
425
|
const handlers: Record<string, JobHandler> = {
|