@lumi.ai/runner 0.5.6 → 0.5.8
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 +39 -1
- package/dist/cli.js +1454 -803
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -4,12 +4,12 @@
|
|
|
4
4
|
import { Command } from "commander";
|
|
5
5
|
|
|
6
6
|
// src/daemon.ts
|
|
7
|
-
import
|
|
7
|
+
import os4 from "node:os";
|
|
8
8
|
import {
|
|
9
9
|
collection as collection5,
|
|
10
10
|
deleteField as deleteField2,
|
|
11
11
|
doc as doc7,
|
|
12
|
-
getDoc as
|
|
12
|
+
getDoc as getDoc6,
|
|
13
13
|
getDocs as getDocs4,
|
|
14
14
|
onSnapshot as onSnapshot2,
|
|
15
15
|
orderBy as orderBy4,
|
|
@@ -242,15 +242,18 @@ function clampStepText(text, max) {
|
|
|
242
242
|
function firstLine(text) {
|
|
243
243
|
return text.split("\n").find((l) => l.trim()) ?? "";
|
|
244
244
|
}
|
|
245
|
+
function step(kind, label, detail) {
|
|
246
|
+
return detail ? { kind, label, detail } : { kind, label };
|
|
247
|
+
}
|
|
245
248
|
function str(input, key) {
|
|
246
249
|
if (!input || typeof input !== "object")
|
|
247
250
|
return void 0;
|
|
248
251
|
const value = input[key];
|
|
249
252
|
return typeof value === "string" && value.trim() ? value : void 0;
|
|
250
253
|
}
|
|
251
|
-
function basename(
|
|
252
|
-
const parts =
|
|
253
|
-
return parts[parts.length - 1] ??
|
|
254
|
+
function basename(path8) {
|
|
255
|
+
const parts = path8.split(/[\\/]/).filter(Boolean);
|
|
256
|
+
return parts[parts.length - 1] ?? path8;
|
|
254
257
|
}
|
|
255
258
|
function hostOf(url) {
|
|
256
259
|
try {
|
|
@@ -333,8 +336,8 @@ function builtinDetail(tool, input) {
|
|
|
333
336
|
case "Write":
|
|
334
337
|
case "Edit":
|
|
335
338
|
case "MultiEdit": {
|
|
336
|
-
const
|
|
337
|
-
return
|
|
339
|
+
const path8 = str(input, "file_path");
|
|
340
|
+
return path8 ? basename(path8) : void 0;
|
|
338
341
|
}
|
|
339
342
|
case "Glob":
|
|
340
343
|
case "Grep":
|
|
@@ -354,36 +357,28 @@ function builtinDetail(tool, input) {
|
|
|
354
357
|
function describeToolUse(name, input, opts) {
|
|
355
358
|
if (name.startsWith(WORKSPACE_PREFIX)) {
|
|
356
359
|
const tool = name.slice(WORKSPACE_PREFIX.length);
|
|
357
|
-
return
|
|
358
|
-
kind: "tool",
|
|
359
|
-
label: WORKSPACE_LABELS[tool] ?? "Working",
|
|
360
|
-
detail: workspaceDetail(tool, input)
|
|
361
|
-
};
|
|
360
|
+
return step("tool", WORKSPACE_LABELS[tool] ?? "Working", workspaceDetail(tool, input));
|
|
362
361
|
}
|
|
363
362
|
if (name.startsWith(MCP_PREFIX)) {
|
|
364
363
|
const rest = name.slice(MCP_PREFIX.length);
|
|
365
364
|
const split = rest.indexOf("__");
|
|
366
365
|
const key = split < 0 ? rest : rest.slice(0, split);
|
|
367
366
|
const tool = split < 0 ? void 0 : rest.slice(split + 2);
|
|
368
|
-
return {
|
|
369
|
-
kind: "tool",
|
|
370
|
-
label: `Using ${opts?.mcpNames?.[key] ?? key}`,
|
|
371
|
-
detail: tool || void 0
|
|
372
|
-
};
|
|
367
|
+
return step("tool", `Using ${opts?.mcpNames?.[key] ?? key}`, tool);
|
|
373
368
|
}
|
|
374
369
|
const label = BUILTIN_LABELS[name];
|
|
375
370
|
if (label)
|
|
376
|
-
return
|
|
377
|
-
return
|
|
371
|
+
return step("tool", label, builtinDetail(name, input));
|
|
372
|
+
return step("tool", "Working");
|
|
378
373
|
}
|
|
379
374
|
function describeAssistantText(text) {
|
|
380
|
-
return
|
|
375
|
+
return step("saying", "Working it out", firstLine(text));
|
|
381
376
|
}
|
|
382
|
-
function pushJobStep(progress,
|
|
383
|
-
const steps = [...progress?.steps ?? [],
|
|
377
|
+
function pushJobStep(progress, step2, max = MAX_JOB_STEPS) {
|
|
378
|
+
const steps = [...progress?.steps ?? [], step2];
|
|
384
379
|
return {
|
|
385
380
|
seq: (progress?.seq ?? 0) + 1,
|
|
386
|
-
updatedAt:
|
|
381
|
+
updatedAt: step2.at,
|
|
387
382
|
// Keep the NEWEST `max`, oldest first — a reader renders top to bottom.
|
|
388
383
|
steps: steps.slice(Math.max(0, steps.length - max))
|
|
389
384
|
};
|
|
@@ -739,7 +734,7 @@ function mcpUrl(config2) {
|
|
|
739
734
|
}
|
|
740
735
|
|
|
741
736
|
// src/version.ts
|
|
742
|
-
var RUNNER_VERSION = true ? "0.5.
|
|
737
|
+
var RUNNER_VERSION = true ? "0.5.8" : "0.0.0-dev";
|
|
743
738
|
|
|
744
739
|
// src/auth.ts
|
|
745
740
|
import { signInWithCustomToken } from "firebase/auth";
|
|
@@ -1777,7 +1772,7 @@ async function runSession(input, bin, dirs) {
|
|
|
1777
1772
|
if (event.type === "result") resultEvent = event;
|
|
1778
1773
|
if (event.type === "assistant") input.log("claude: assistant turn");
|
|
1779
1774
|
if (input.onStep) {
|
|
1780
|
-
for (const
|
|
1775
|
+
for (const step2 of stepsFromClaudeEvent(event, { mcpNames })) input.onStep(step2);
|
|
1781
1776
|
}
|
|
1782
1777
|
} catch {
|
|
1783
1778
|
}
|
|
@@ -2054,6 +2049,7 @@ import {
|
|
|
2054
2049
|
collection as collection4,
|
|
2055
2050
|
deleteField,
|
|
2056
2051
|
doc as doc6,
|
|
2052
|
+
getDoc as getDoc5,
|
|
2057
2053
|
getDocs as getDocs3,
|
|
2058
2054
|
limit as fsLimit,
|
|
2059
2055
|
orderBy as orderBy3,
|
|
@@ -2076,11 +2072,11 @@ function redactTranscript(transcript, knownSecrets) {
|
|
|
2076
2072
|
return out;
|
|
2077
2073
|
}
|
|
2078
2074
|
async function uploadTranscript(storage, shipId, jobId, redacted) {
|
|
2079
|
-
const
|
|
2080
|
-
await uploadBytes(storageRef(storage,
|
|
2075
|
+
const path8 = `crew/${shipId}/transcripts/${jobId}.jsonl`;
|
|
2076
|
+
await uploadBytes(storageRef(storage, path8), new TextEncoder().encode(redacted), {
|
|
2081
2077
|
contentType: "application/x-ndjson"
|
|
2082
2078
|
});
|
|
2083
|
-
return
|
|
2079
|
+
return path8;
|
|
2084
2080
|
}
|
|
2085
2081
|
function utcDay(millis) {
|
|
2086
2082
|
return new Date(millis).toISOString().slice(0, 10);
|
|
@@ -2190,16 +2186,27 @@ ${error.slice(0, 800)}
|
|
|
2190
2186
|
}
|
|
2191
2187
|
async function markTaskStopped(db, shipId, job, stoppedBy) {
|
|
2192
2188
|
const taskRef = doc6(db, COLLECTIONS.ships, shipId, COLLECTIONS.tasks, job.taskId);
|
|
2189
|
+
const who = await actorName(db, shipId, stoppedBy);
|
|
2193
2190
|
await addDoc(collection4(taskRef, COLLECTIONS.activity), {
|
|
2194
2191
|
author: { type: "agent", id: job.agentId },
|
|
2195
2192
|
createdAt: Date.now(),
|
|
2196
2193
|
kind: "comment",
|
|
2197
|
-
content: `This run was stopped by ${
|
|
2194
|
+
content: `This run was stopped by ${who} before it finished. The task is unchanged.`
|
|
2198
2195
|
});
|
|
2199
2196
|
}
|
|
2197
|
+
async function actorName(db, shipId, actorId) {
|
|
2198
|
+
try {
|
|
2199
|
+
const snap = await getDoc5(doc6(db, COLLECTIONS.ships, shipId, COLLECTIONS.members, actorId));
|
|
2200
|
+
const m = snap.data();
|
|
2201
|
+
return m?.displayName || m?.email || actorId;
|
|
2202
|
+
} catch {
|
|
2203
|
+
return actorId;
|
|
2204
|
+
}
|
|
2205
|
+
}
|
|
2200
2206
|
async function markChatStopped(db, shipId, job, stoppedBy) {
|
|
2201
2207
|
const chatRef = doc6(db, COLLECTIONS.ships, shipId, COLLECTIONS.chats, job.chatId);
|
|
2202
|
-
const
|
|
2208
|
+
const who = await actorName(db, shipId, stoppedBy);
|
|
2209
|
+
const content = `Stopped by ${who} before I finished. Write again to start a fresh run.`;
|
|
2203
2210
|
await addDoc(collection4(chatRef, COLLECTIONS.chatMessages), {
|
|
2204
2211
|
author: { type: "agent", id: job.agentId },
|
|
2205
2212
|
content,
|
|
@@ -2264,16 +2271,24 @@ async function ensureChatReply(db, shipId, job, resultText) {
|
|
|
2264
2271
|
// src/jobs/progress.ts
|
|
2265
2272
|
var PROGRESS_FLUSH_MS = 3e3;
|
|
2266
2273
|
var PROGRESS_DENIALS_BEFORE_PROBE = 2;
|
|
2267
|
-
function redactStep(
|
|
2268
|
-
const label = clampStepText(redactTranscript(
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2274
|
+
function redactStep(step2, secrets) {
|
|
2275
|
+
const label = clampStepText(redactTranscript(step2.label, secrets), MAX_STEP_LABEL);
|
|
2276
|
+
const base = { at: step2.at, kind: step2.kind, label };
|
|
2277
|
+
if (step2.detail === void 0) return base;
|
|
2278
|
+
const detail = clampStepText(redactTranscript(step2.detail, secrets), MAX_STEP_DETAIL);
|
|
2279
|
+
return detail ? { ...base, detail } : base;
|
|
2280
|
+
}
|
|
2281
|
+
function errorCode(error) {
|
|
2282
|
+
return error?.code ?? "";
|
|
2272
2283
|
}
|
|
2273
2284
|
function isDenied(error) {
|
|
2274
|
-
const code = error
|
|
2285
|
+
const code = errorCode(error);
|
|
2275
2286
|
return code === "permission-denied" || code.endsWith("/permission-denied");
|
|
2276
2287
|
}
|
|
2288
|
+
function isUnwritable(error) {
|
|
2289
|
+
const code = errorCode(error);
|
|
2290
|
+
return code === "invalid-argument" || code.endsWith("/invalid-argument");
|
|
2291
|
+
}
|
|
2277
2292
|
function createProgressWriter(deps) {
|
|
2278
2293
|
const now = deps.now ?? Date.now;
|
|
2279
2294
|
const flushMs = deps.flushMs ?? PROGRESS_FLUSH_MS;
|
|
@@ -2292,12 +2307,18 @@ function createProgressWriter(deps) {
|
|
|
2292
2307
|
}
|
|
2293
2308
|
if (closed || inFlight || pending.length === 0) return;
|
|
2294
2309
|
const secrets = deps.secrets();
|
|
2295
|
-
for (const
|
|
2310
|
+
for (const step2 of pending) progress = pushJobStep(progress, redactStep(step2, secrets));
|
|
2296
2311
|
pending = [];
|
|
2297
2312
|
lastWriteAt = now();
|
|
2298
2313
|
inFlight = true;
|
|
2299
2314
|
const snapshot = progress;
|
|
2300
|
-
|
|
2315
|
+
let written;
|
|
2316
|
+
try {
|
|
2317
|
+
written = deps.write(snapshot);
|
|
2318
|
+
} catch (e) {
|
|
2319
|
+
written = Promise.reject(e);
|
|
2320
|
+
}
|
|
2321
|
+
void written.then(() => {
|
|
2301
2322
|
denials = 0;
|
|
2302
2323
|
}).catch((e) => {
|
|
2303
2324
|
if (isDenied(e)) {
|
|
@@ -2309,9 +2330,12 @@ function createProgressWriter(deps) {
|
|
|
2309
2330
|
return;
|
|
2310
2331
|
}
|
|
2311
2332
|
denials = 0;
|
|
2333
|
+
if (isUnwritable(e)) closed = true;
|
|
2312
2334
|
if (!loggedFailure) {
|
|
2313
2335
|
loggedFailure = true;
|
|
2314
|
-
deps.log(
|
|
2336
|
+
deps.log(
|
|
2337
|
+
`progress write failed${closed ? " \u2014 live steps are off for this job" : " (further ones silent)"}: ${e instanceof Error ? e.message : e}`
|
|
2338
|
+
);
|
|
2315
2339
|
}
|
|
2316
2340
|
}).finally(() => {
|
|
2317
2341
|
inFlight = false;
|
|
@@ -2329,9 +2353,9 @@ function createProgressWriter(deps) {
|
|
|
2329
2353
|
timer.unref?.();
|
|
2330
2354
|
}
|
|
2331
2355
|
return {
|
|
2332
|
-
push(
|
|
2356
|
+
push(step2) {
|
|
2333
2357
|
if (closed) return;
|
|
2334
|
-
pending.push({ ...
|
|
2358
|
+
pending.push({ ...step2, at: now() });
|
|
2335
2359
|
schedule();
|
|
2336
2360
|
},
|
|
2337
2361
|
stop() {
|
|
@@ -2345,188 +2369,809 @@ function createProgressWriter(deps) {
|
|
|
2345
2369
|
};
|
|
2346
2370
|
}
|
|
2347
2371
|
|
|
2348
|
-
// src/
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
const pruned = pruneDeadShips(config2, [...failures, ...orphanFailures]);
|
|
2368
|
-
if (pruned.dropped.length > 0) {
|
|
2369
|
-
saveConfig(pruned.config);
|
|
2370
|
-
config2 = pruned.config;
|
|
2371
|
-
console.log(
|
|
2372
|
-
`Forgot ${pruned.dropped.length} Ship(s) this machine can no longer open: ${pruned.dropped.join(", ")}. Deleted, or this machine was revoked \u2014 run \`lumi-runner login\` if that was not expected.`
|
|
2373
|
-
);
|
|
2374
|
-
}
|
|
2375
|
-
if (sessions.length === 0) {
|
|
2376
|
-
console.error("No Ship could be signed in to. Run: lumi-runner login");
|
|
2377
|
-
process.exit(1);
|
|
2378
|
-
}
|
|
2379
|
-
const byShip = new Map(sessions.map((s) => [s.shipId, s]));
|
|
2380
|
-
const sess = (shipId) => {
|
|
2381
|
-
const s = byShip.get(shipId);
|
|
2382
|
-
if (!s) throw new Error(`No session for Ship ${shipId}`);
|
|
2383
|
-
return s;
|
|
2384
|
-
};
|
|
2385
|
-
const serving = new Set(sessions.map((s) => s.shipId));
|
|
2386
|
-
console.log(
|
|
2387
|
-
`Runner ${config2.runnerId} serving ${serving.size} Ship(s): ${[...serving].join(", ")}`
|
|
2388
|
-
);
|
|
2389
|
-
const logLines = [];
|
|
2390
|
-
const log2 = (line) => {
|
|
2391
|
-
const stamped = `${(/* @__PURE__ */ new Date()).toISOString()} ${line}`;
|
|
2392
|
-
console.log(stamped);
|
|
2393
|
-
appendLog(stamped);
|
|
2394
|
-
logLines.push(stamped);
|
|
2395
|
-
if (logLines.length > 20) logLines.shift();
|
|
2396
|
-
};
|
|
2397
|
-
if (moved?.migrated) log2(`Moved runner config from ${moved.from} to ${moved.to}.`);
|
|
2398
|
-
const startedAt = Date.now();
|
|
2399
|
-
const running = /* @__PURE__ */ new Map();
|
|
2400
|
-
const runningByShip = () => {
|
|
2401
|
-
const counts = /* @__PURE__ */ new Map();
|
|
2402
|
-
for (const r of running.values()) counts.set(r.shipId, (counts.get(r.shipId) ?? 0) + 1);
|
|
2403
|
-
return counts;
|
|
2372
|
+
// src/service.ts
|
|
2373
|
+
import { spawnSync } from "node:child_process";
|
|
2374
|
+
import fs4 from "node:fs";
|
|
2375
|
+
import os3 from "node:os";
|
|
2376
|
+
import path4 from "node:path";
|
|
2377
|
+
import { fileURLToPath } from "node:url";
|
|
2378
|
+
var SERVICE_LABEL = "com.lumi.runner";
|
|
2379
|
+
var LINUX_UNIT = "lumi-runner.service";
|
|
2380
|
+
var WINDOWS_TASK = "LumiRunner";
|
|
2381
|
+
var LEGACY_SERVICE_LABEL = "com.lumi.crew-runner";
|
|
2382
|
+
var LEGACY_LINUX_UNIT = "crew-runner.service";
|
|
2383
|
+
var LEGACY_WINDOWS_TASK = "CrewRunner";
|
|
2384
|
+
var ServiceError = class extends Error {
|
|
2385
|
+
};
|
|
2386
|
+
function run(command, args) {
|
|
2387
|
+
const result = spawnSync(command, args, { encoding: "utf8" });
|
|
2388
|
+
return {
|
|
2389
|
+
ok: result.status === 0,
|
|
2390
|
+
out: `${result.stdout ?? ""}${result.stderr ?? ""}`.trim()
|
|
2404
2391
|
};
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
{
|
|
2440
|
-
hostname: os3.hostname(),
|
|
2441
|
-
version: RUNNER_VERSION,
|
|
2442
|
-
status: "online",
|
|
2443
|
-
lastSeenAt: now,
|
|
2444
|
-
startedAt,
|
|
2445
|
-
currentJob: live[0] ?? null,
|
|
2446
|
-
currentJobs: live,
|
|
2447
|
-
parallelLimit: shipCap(config2, shipId),
|
|
2448
|
-
lastLogLines: [...logLines]
|
|
2449
|
-
},
|
|
2450
|
-
{ merge: true }
|
|
2451
|
-
);
|
|
2452
|
-
} catch (e) {
|
|
2453
|
-
if (isEnrolmentGone(e)) {
|
|
2454
|
-
forgetShipLocally(shipId, "this machine is no longer enrolled on that Ship");
|
|
2455
|
-
continue;
|
|
2456
|
-
}
|
|
2457
|
-
console.error(`heartbeat failed (${shipId}):`, e instanceof Error ? e.message : e);
|
|
2458
|
-
}
|
|
2459
|
-
}
|
|
2460
|
-
} finally {
|
|
2461
|
-
beating = false;
|
|
2392
|
+
}
|
|
2393
|
+
function cliPath() {
|
|
2394
|
+
return fileURLToPath(import.meta.url);
|
|
2395
|
+
}
|
|
2396
|
+
function uid() {
|
|
2397
|
+
return String(process.getuid?.() ?? 0);
|
|
2398
|
+
}
|
|
2399
|
+
function launchAgentPath() {
|
|
2400
|
+
return path4.join(os3.homedir(), "Library/LaunchAgents", `${SERVICE_LABEL}.plist`);
|
|
2401
|
+
}
|
|
2402
|
+
function systemdUnitPath() {
|
|
2403
|
+
return path4.join(os3.homedir(), ".config/systemd/user", LINUX_UNIT);
|
|
2404
|
+
}
|
|
2405
|
+
function legacyLaunchAgentPath() {
|
|
2406
|
+
return path4.join(os3.homedir(), "Library/LaunchAgents", `${LEGACY_SERVICE_LABEL}.plist`);
|
|
2407
|
+
}
|
|
2408
|
+
function legacySystemdUnitPath() {
|
|
2409
|
+
return path4.join(os3.homedir(), ".config/systemd/user", LEGACY_LINUX_UNIT);
|
|
2410
|
+
}
|
|
2411
|
+
function serviceEnv() {
|
|
2412
|
+
const env = { PATH: process.env.PATH ?? "" };
|
|
2413
|
+
if (process.env.LUMI_RUNNER_HOME || process.env.CREW_RUNNER_HOME) {
|
|
2414
|
+
env.LUMI_RUNNER_HOME = configDir();
|
|
2415
|
+
}
|
|
2416
|
+
return env;
|
|
2417
|
+
}
|
|
2418
|
+
function removeLegacyService() {
|
|
2419
|
+
const removed = [];
|
|
2420
|
+
if (process.platform === "darwin") {
|
|
2421
|
+
const unitPath = legacyLaunchAgentPath();
|
|
2422
|
+
if (fs4.existsSync(unitPath)) {
|
|
2423
|
+
run("launchctl", ["bootout", `gui/${uid()}/${LEGACY_SERVICE_LABEL}`]);
|
|
2424
|
+
fs4.rmSync(unitPath, { force: true });
|
|
2425
|
+
removed.push(unitPath);
|
|
2462
2426
|
}
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
)
|
|
2473
|
-
);
|
|
2474
|
-
for (const d of snap.docs) {
|
|
2475
|
-
pending.set(`${shipId}/${d.id}`, {
|
|
2476
|
-
shipId,
|
|
2477
|
-
job: { id: d.id, ...d.data() }
|
|
2478
|
-
});
|
|
2479
|
-
}
|
|
2480
|
-
log2(`Ship ${shipId}: approved \u2014 picking up ${snap.size} queued job(s).`);
|
|
2481
|
-
} catch (e) {
|
|
2482
|
-
console.error(`queue refill failed (${shipId}):`, e instanceof Error ? e.message : e);
|
|
2483
|
-
}
|
|
2484
|
-
poke();
|
|
2427
|
+
return removed;
|
|
2428
|
+
}
|
|
2429
|
+
if (process.platform === "linux") {
|
|
2430
|
+
const unitPath = legacySystemdUnitPath();
|
|
2431
|
+
if (fs4.existsSync(unitPath)) {
|
|
2432
|
+
run("systemctl", ["--user", "disable", "--now", LEGACY_LINUX_UNIT]);
|
|
2433
|
+
fs4.rmSync(unitPath, { force: true });
|
|
2434
|
+
run("systemctl", ["--user", "daemon-reload"]);
|
|
2435
|
+
removed.push(unitPath);
|
|
2485
2436
|
}
|
|
2437
|
+
return removed;
|
|
2486
2438
|
}
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
const listenerError = (shipId, what) => (e) => {
|
|
2492
|
-
if (isEnrolmentGone(e)) {
|
|
2493
|
-
forgetShipLocally(shipId, "a captain removed this machine on the Daemons page");
|
|
2494
|
-
return;
|
|
2439
|
+
if (process.platform === "win32") {
|
|
2440
|
+
if (run("schtasks", ["/Query", "/TN", LEGACY_WINDOWS_TASK]).ok) {
|
|
2441
|
+
run("schtasks", ["/Delete", "/TN", LEGACY_WINDOWS_TASK, "/F"]);
|
|
2442
|
+
removed.push(LEGACY_WINDOWS_TASK);
|
|
2495
2443
|
}
|
|
2496
|
-
|
|
2497
|
-
}
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
|
|
2510
|
-
|
|
2511
|
-
|
|
2512
|
-
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
2525
|
-
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
|
|
2444
|
+
return removed;
|
|
2445
|
+
}
|
|
2446
|
+
return removed;
|
|
2447
|
+
}
|
|
2448
|
+
var BOOTSTRAP_RETRY_DELAYS_MS = [250, 500, 1e3, 2e3, 2e3];
|
|
2449
|
+
function isTransientBootstrapError(out) {
|
|
2450
|
+
const text = out.toLowerCase();
|
|
2451
|
+
return (
|
|
2452
|
+
// The observed one: `Bootstrap failed: 5: Input/output error`.
|
|
2453
|
+
text.includes("input/output error") || // Same cause, different report: the old generation is still registered.
|
|
2454
|
+
text.includes("service already loaded") || text.includes("service is already loaded") || text.includes("eexist") || // launchd is mid-teardown and says so.
|
|
2455
|
+
text.includes("operation already in progress") || text.includes("operation now in progress")
|
|
2456
|
+
);
|
|
2457
|
+
}
|
|
2458
|
+
function retryWhileTransient(attempt, wait, delays = BOOTSTRAP_RETRY_DELAYS_MS) {
|
|
2459
|
+
let result = attempt();
|
|
2460
|
+
for (const delay of delays) {
|
|
2461
|
+
if (result.ok || !isTransientBootstrapError(result.out)) return result;
|
|
2462
|
+
wait(delay);
|
|
2463
|
+
result = attempt();
|
|
2464
|
+
}
|
|
2465
|
+
return result;
|
|
2466
|
+
}
|
|
2467
|
+
function sleepSync(ms) {
|
|
2468
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
2469
|
+
}
|
|
2470
|
+
function plistXml() {
|
|
2471
|
+
const env = serviceEnv();
|
|
2472
|
+
const envEntries = Object.entries(env).map(([k, v]) => ` <key>${k}</key>
|
|
2473
|
+
<string>${escapeXml(v)}</string>`).join("\n");
|
|
2474
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
2475
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
2476
|
+
<plist version="1.0">
|
|
2477
|
+
<dict>
|
|
2478
|
+
<key>Label</key>
|
|
2479
|
+
<string>${SERVICE_LABEL}</string>
|
|
2480
|
+
<key>ProgramArguments</key>
|
|
2481
|
+
<array>
|
|
2482
|
+
<string>${escapeXml(process.execPath)}</string>
|
|
2483
|
+
<string>${escapeXml(cliPath())}</string>
|
|
2484
|
+
<string>start</string>
|
|
2485
|
+
</array>
|
|
2486
|
+
<key>RunAtLoad</key>
|
|
2487
|
+
<true/>
|
|
2488
|
+
<key>KeepAlive</key>
|
|
2489
|
+
<true/>
|
|
2490
|
+
<key>ExitTimeOut</key>
|
|
2491
|
+
<integer>20</integer>
|
|
2492
|
+
<key>EnvironmentVariables</key>
|
|
2493
|
+
<dict>
|
|
2494
|
+
${envEntries}
|
|
2495
|
+
</dict>
|
|
2496
|
+
<key>StandardOutPath</key>
|
|
2497
|
+
<string>${escapeXml(path4.join(logDir(), "service.out.log"))}</string>
|
|
2498
|
+
<key>StandardErrorPath</key>
|
|
2499
|
+
<string>${escapeXml(path4.join(logDir(), "service.err.log"))}</string>
|
|
2500
|
+
</dict>
|
|
2501
|
+
</plist>
|
|
2502
|
+
`;
|
|
2503
|
+
}
|
|
2504
|
+
function escapeXml(value) {
|
|
2505
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
2506
|
+
}
|
|
2507
|
+
function unescapeXml(value) {
|
|
2508
|
+
return value.replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/&/g, "&");
|
|
2509
|
+
}
|
|
2510
|
+
function parsePlistCliPath(plist) {
|
|
2511
|
+
const block = /<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/.exec(plist);
|
|
2512
|
+
if (!block) return void 0;
|
|
2513
|
+
const args = [...block[1].matchAll(/<string>([\s\S]*?)<\/string>/g)].map((m) => unescapeXml(m[1]));
|
|
2514
|
+
return args.length >= 2 ? args[1] : void 0;
|
|
2515
|
+
}
|
|
2516
|
+
function parseSystemdCliPath(unit) {
|
|
2517
|
+
const line = /^ExecStart=(.*)$/m.exec(unit);
|
|
2518
|
+
if (!line) return void 0;
|
|
2519
|
+
const command = line[1].trim();
|
|
2520
|
+
if (!command.endsWith(" start")) return void 0;
|
|
2521
|
+
const parts = command.slice(0, -" start".length).split(" ");
|
|
2522
|
+
return parts.length === 2 ? parts[1] : void 0;
|
|
2523
|
+
}
|
|
2524
|
+
function execFacts(unitPath, parse) {
|
|
2525
|
+
let text;
|
|
2526
|
+
try {
|
|
2527
|
+
text = fs4.readFileSync(unitPath, "utf8");
|
|
2528
|
+
} catch {
|
|
2529
|
+
return {};
|
|
2530
|
+
}
|
|
2531
|
+
const execPath = parse(text);
|
|
2532
|
+
if (!execPath || !path4.isAbsolute(execPath)) return {};
|
|
2533
|
+
return { execPath, execMissing: !fs4.existsSync(execPath) };
|
|
2534
|
+
}
|
|
2535
|
+
function systemdUnit() {
|
|
2536
|
+
const env = serviceEnv();
|
|
2537
|
+
const envLines = Object.entries(env).map(([k, v]) => `Environment="${k}=${v}"`).join("\n");
|
|
2538
|
+
return `[Unit]
|
|
2539
|
+
Description=Lumi Crew runner daemon
|
|
2540
|
+
After=network-online.target
|
|
2541
|
+
|
|
2542
|
+
[Service]
|
|
2543
|
+
Type=simple
|
|
2544
|
+
ExecStart=${process.execPath} ${cliPath()} start
|
|
2545
|
+
Restart=always
|
|
2546
|
+
RestartSec=5
|
|
2547
|
+
# Must exceed the daemon's SHUTDOWN_GRACE_MS: a SIGKILL mid-release is exactly the stranded
|
|
2548
|
+
# job the graceful shutdown exists to prevent.
|
|
2549
|
+
TimeoutStopSec=20
|
|
2550
|
+
${envLines}
|
|
2551
|
+
|
|
2552
|
+
[Install]
|
|
2553
|
+
WantedBy=default.target
|
|
2554
|
+
`;
|
|
2555
|
+
}
|
|
2556
|
+
function serviceStatus() {
|
|
2557
|
+
if (process.platform === "darwin") {
|
|
2558
|
+
const unitPath = launchAgentPath();
|
|
2559
|
+
if (!fs4.existsSync(unitPath)) return { state: "not-installed", detail: "No LaunchAgent installed." };
|
|
2560
|
+
const exec = execFacts(unitPath, parsePlistCliPath);
|
|
2561
|
+
const printed = run("launchctl", ["print", `gui/${uid()}/${SERVICE_LABEL}`]);
|
|
2562
|
+
if (!printed.ok) {
|
|
2563
|
+
return { state: "installed", detail: "LaunchAgent present but not loaded.", unitPath, ...exec };
|
|
2564
|
+
}
|
|
2565
|
+
const running = /\bpid = \d+/.test(printed.out);
|
|
2566
|
+
return {
|
|
2567
|
+
state: running ? "running" : "installed",
|
|
2568
|
+
detail: running ? "LaunchAgent loaded and running." : "LaunchAgent loaded but not running.",
|
|
2569
|
+
unitPath,
|
|
2570
|
+
...exec
|
|
2571
|
+
};
|
|
2572
|
+
}
|
|
2573
|
+
if (process.platform === "linux") {
|
|
2574
|
+
const unitPath = systemdUnitPath();
|
|
2575
|
+
if (!fs4.existsSync(unitPath)) return { state: "not-installed", detail: "No systemd user unit installed." };
|
|
2576
|
+
const active = run("systemctl", ["--user", "is-active", LINUX_UNIT]);
|
|
2577
|
+
return {
|
|
2578
|
+
state: active.out === "active" ? "running" : "installed",
|
|
2579
|
+
detail: `systemd user unit is ${active.out || "unknown"}.`,
|
|
2580
|
+
unitPath,
|
|
2581
|
+
...execFacts(unitPath, parseSystemdCliPath)
|
|
2582
|
+
};
|
|
2583
|
+
}
|
|
2584
|
+
if (process.platform === "win32") {
|
|
2585
|
+
const query6 = run("schtasks", ["/Query", "/TN", WINDOWS_TASK]);
|
|
2586
|
+
if (!query6.ok) return { state: "not-installed", detail: "No scheduled task installed." };
|
|
2587
|
+
return {
|
|
2588
|
+
state: /\bRunning\b/i.test(query6.out) ? "running" : "installed",
|
|
2589
|
+
detail: "Scheduled task installed (runs at logon).",
|
|
2590
|
+
unitPath: WINDOWS_TASK
|
|
2591
|
+
};
|
|
2592
|
+
}
|
|
2593
|
+
return { state: "unsupported", detail: `No service integration for ${process.platform}.` };
|
|
2594
|
+
}
|
|
2595
|
+
function installService() {
|
|
2596
|
+
const notes = [];
|
|
2597
|
+
fs4.mkdirSync(logDir(), { recursive: true, mode: 448 });
|
|
2598
|
+
for (const unit of removeLegacyService()) {
|
|
2599
|
+
notes.push(`Removed the previous crew-runner service (${unit}).`);
|
|
2600
|
+
}
|
|
2601
|
+
if (process.platform === "darwin") {
|
|
2602
|
+
const unitPath = launchAgentPath();
|
|
2603
|
+
fs4.mkdirSync(path4.dirname(unitPath), { recursive: true });
|
|
2604
|
+
fs4.writeFileSync(unitPath, plistXml());
|
|
2605
|
+
run("launchctl", ["bootout", `gui/${uid()}/${SERVICE_LABEL}`]);
|
|
2606
|
+
const boot = retryWhileTransient(
|
|
2607
|
+
() => run("launchctl", ["bootstrap", `gui/${uid()}`, unitPath]),
|
|
2608
|
+
sleepSync
|
|
2609
|
+
);
|
|
2610
|
+
if (!boot.ok) throw new ServiceError(`launchctl bootstrap failed: ${boot.out}`);
|
|
2611
|
+
return { unitPath, notes };
|
|
2612
|
+
}
|
|
2613
|
+
if (process.platform === "linux") {
|
|
2614
|
+
const unitPath = systemdUnitPath();
|
|
2615
|
+
fs4.mkdirSync(path4.dirname(unitPath), { recursive: true });
|
|
2616
|
+
fs4.writeFileSync(unitPath, systemdUnit());
|
|
2617
|
+
const reload = run("systemctl", ["--user", "daemon-reload"]);
|
|
2618
|
+
if (!reload.ok) throw new ServiceError(`systemctl daemon-reload failed: ${reload.out}`);
|
|
2619
|
+
const enable = run("systemctl", ["--user", "enable", "--now", LINUX_UNIT]);
|
|
2620
|
+
if (!enable.ok) throw new ServiceError(`systemctl enable failed: ${enable.out}`);
|
|
2621
|
+
const linger = run("loginctl", ["show-user", os3.userInfo().username, "--property=Linger"]);
|
|
2622
|
+
if (!linger.out.includes("Linger=yes")) {
|
|
2623
|
+
notes.push(
|
|
2624
|
+
`Run \`sudo loginctl enable-linger ${os3.userInfo().username}\` so the daemon survives logout and starts at boot.`
|
|
2625
|
+
);
|
|
2626
|
+
}
|
|
2627
|
+
return { unitPath, notes };
|
|
2628
|
+
}
|
|
2629
|
+
if (process.platform === "win32") {
|
|
2630
|
+
const command = `"${process.execPath}" "${cliPath()}" start`;
|
|
2631
|
+
const create = run("schtasks", [
|
|
2632
|
+
"/Create",
|
|
2633
|
+
"/TN",
|
|
2634
|
+
WINDOWS_TASK,
|
|
2635
|
+
"/TR",
|
|
2636
|
+
command,
|
|
2637
|
+
"/SC",
|
|
2638
|
+
"ONLOGON",
|
|
2639
|
+
"/RL",
|
|
2640
|
+
"LIMITED",
|
|
2641
|
+
"/F"
|
|
2642
|
+
]);
|
|
2643
|
+
if (!create.ok) throw new ServiceError(`schtasks /Create failed: ${create.out}`);
|
|
2644
|
+
notes.push(
|
|
2645
|
+
"Task Scheduler starts the daemon at logon but does not restart it if it exits. For a true always-on box, run the daemon under pm2 or a Windows service wrapper instead."
|
|
2646
|
+
);
|
|
2647
|
+
return { unitPath: WINDOWS_TASK, notes };
|
|
2648
|
+
}
|
|
2649
|
+
throw new ServiceError(`Service install is not supported on ${process.platform}.`);
|
|
2650
|
+
}
|
|
2651
|
+
function uninstallService() {
|
|
2652
|
+
removeLegacyService();
|
|
2653
|
+
if (process.platform === "darwin") {
|
|
2654
|
+
run("launchctl", ["bootout", `gui/${uid()}/${SERVICE_LABEL}`]);
|
|
2655
|
+
fs4.rmSync(launchAgentPath(), { force: true });
|
|
2656
|
+
return;
|
|
2657
|
+
}
|
|
2658
|
+
if (process.platform === "linux") {
|
|
2659
|
+
run("systemctl", ["--user", "disable", "--now", LINUX_UNIT]);
|
|
2660
|
+
fs4.rmSync(systemdUnitPath(), { force: true });
|
|
2661
|
+
run("systemctl", ["--user", "daemon-reload"]);
|
|
2662
|
+
return;
|
|
2663
|
+
}
|
|
2664
|
+
if (process.platform === "win32") {
|
|
2665
|
+
run("schtasks", ["/Delete", "/TN", WINDOWS_TASK, "/F"]);
|
|
2666
|
+
return;
|
|
2667
|
+
}
|
|
2668
|
+
throw new ServiceError(`Service uninstall is not supported on ${process.platform}.`);
|
|
2669
|
+
}
|
|
2670
|
+
function restartService() {
|
|
2671
|
+
if (process.platform === "darwin") {
|
|
2672
|
+
const result = run("launchctl", ["kickstart", "-k", `gui/${uid()}/${SERVICE_LABEL}`]);
|
|
2673
|
+
if (!result.ok) throw new ServiceError(`launchctl kickstart failed: ${result.out}`);
|
|
2674
|
+
return;
|
|
2675
|
+
}
|
|
2676
|
+
if (process.platform === "linux") {
|
|
2677
|
+
const result = run("systemctl", ["--user", "restart", LINUX_UNIT]);
|
|
2678
|
+
if (!result.ok) throw new ServiceError(`systemctl restart failed: ${result.out}`);
|
|
2679
|
+
return;
|
|
2680
|
+
}
|
|
2681
|
+
if (process.platform === "win32") {
|
|
2682
|
+
run("schtasks", ["/End", "/TN", WINDOWS_TASK]);
|
|
2683
|
+
const result = run("schtasks", ["/Run", "/TN", WINDOWS_TASK]);
|
|
2684
|
+
if (!result.ok) throw new ServiceError(`schtasks /Run failed: ${result.out}`);
|
|
2685
|
+
return;
|
|
2686
|
+
}
|
|
2687
|
+
throw new ServiceError(`Service restart is not supported on ${process.platform}.`);
|
|
2688
|
+
}
|
|
2689
|
+
|
|
2690
|
+
// src/update/plan.ts
|
|
2691
|
+
import path5 from "node:path";
|
|
2692
|
+
|
|
2693
|
+
// src/update/semver.ts
|
|
2694
|
+
var DEV_VERSION = "0.0.0-dev";
|
|
2695
|
+
var NUMERIC = /^(0|[1-9]\d*)$/;
|
|
2696
|
+
function parseVersion(raw) {
|
|
2697
|
+
if (typeof raw !== "string") return null;
|
|
2698
|
+
const trimmed = raw.trim();
|
|
2699
|
+
if (!trimmed) return null;
|
|
2700
|
+
const [withoutBuild] = trimmed.split("+", 1);
|
|
2701
|
+
const dash = withoutBuild.indexOf("-");
|
|
2702
|
+
const core = dash < 0 ? withoutBuild : withoutBuild.slice(0, dash);
|
|
2703
|
+
const pre = dash < 0 ? "" : withoutBuild.slice(dash + 1);
|
|
2704
|
+
const parts = core.split(".");
|
|
2705
|
+
if (parts.length !== 3) return null;
|
|
2706
|
+
if (!parts.every((p) => NUMERIC.test(p))) return null;
|
|
2707
|
+
const [major, minor, patch] = parts.map(Number);
|
|
2708
|
+
if (dash >= 0 && pre === "") return null;
|
|
2709
|
+
const prerelease = pre === "" ? [] : pre.split(".");
|
|
2710
|
+
if (prerelease.some((id) => id === "")) return null;
|
|
2711
|
+
return {
|
|
2712
|
+
major,
|
|
2713
|
+
minor,
|
|
2714
|
+
patch,
|
|
2715
|
+
prerelease: prerelease.map((id) => NUMERIC.test(id) ? Number(id) : id)
|
|
2716
|
+
};
|
|
2717
|
+
}
|
|
2718
|
+
function hasPrerelease(raw) {
|
|
2719
|
+
const parsed = parseVersion(raw);
|
|
2720
|
+
return !!parsed && parsed.prerelease.length > 0;
|
|
2721
|
+
}
|
|
2722
|
+
function comparePrerelease(a, b) {
|
|
2723
|
+
if (a.length === 0) return b.length === 0 ? 0 : 1;
|
|
2724
|
+
if (b.length === 0) return -1;
|
|
2725
|
+
for (let i = 0; i < Math.min(a.length, b.length); i++) {
|
|
2726
|
+
const x = a[i];
|
|
2727
|
+
const y = b[i];
|
|
2728
|
+
if (x === y) continue;
|
|
2729
|
+
const xNum = typeof x === "number";
|
|
2730
|
+
const yNum = typeof y === "number";
|
|
2731
|
+
if (xNum !== yNum) return xNum ? -1 : 1;
|
|
2732
|
+
if (xNum && yNum) return x < y ? -1 : 1;
|
|
2733
|
+
return x < y ? -1 : 1;
|
|
2734
|
+
}
|
|
2735
|
+
if (a.length === b.length) return 0;
|
|
2736
|
+
return a.length < b.length ? -1 : 1;
|
|
2737
|
+
}
|
|
2738
|
+
function compareVersions(a, b) {
|
|
2739
|
+
for (const key of ["major", "minor", "patch"]) {
|
|
2740
|
+
if (a[key] !== b[key]) return a[key] < b[key] ? -1 : 1;
|
|
2741
|
+
}
|
|
2742
|
+
return comparePrerelease(a.prerelease, b.prerelease);
|
|
2743
|
+
}
|
|
2744
|
+
function isNewer(candidate, current) {
|
|
2745
|
+
if (current === DEV_VERSION) return false;
|
|
2746
|
+
const a = parseVersion(candidate);
|
|
2747
|
+
const b = parseVersion(current);
|
|
2748
|
+
if (!a || !b) return false;
|
|
2749
|
+
return compareVersions(a, b) === 1;
|
|
2750
|
+
}
|
|
2751
|
+
|
|
2752
|
+
// src/update/plan.ts
|
|
2753
|
+
var CHANNELS = ["latest", "dev"];
|
|
2754
|
+
var PACKAGE_NAME = "@lumi.ai/runner";
|
|
2755
|
+
var UPDATE_CHECK_MS = 30 * 6e4;
|
|
2756
|
+
var UPDATE_FIRST_CHECK_MS = 2 * 6e4;
|
|
2757
|
+
var UPDATE_JITTER_MS = 10 * 6e4;
|
|
2758
|
+
var CHECK_MINUTES_MIN = 5;
|
|
2759
|
+
var CHECK_MINUTES_MAX = 24 * 60;
|
|
2760
|
+
var UPDATE_DRAIN_MAX_MS = 30 * 6e4;
|
|
2761
|
+
var MAX_UPDATE_ATTEMPTS = 3;
|
|
2762
|
+
var UPDATE_BACKOFF_MS = [15 * 6e4, 2 * 36e5, 12 * 36e5];
|
|
2763
|
+
var channelFor = (version) => hasPrerelease(version) ? "dev" : "latest";
|
|
2764
|
+
function resolveChannel(current, config2, env = {}) {
|
|
2765
|
+
const fromEnv = env.LUMI_RUNNER_CHANNEL;
|
|
2766
|
+
if (fromEnv && CHANNELS.includes(fromEnv)) return fromEnv;
|
|
2767
|
+
const fromConfig = config2.updateChannel;
|
|
2768
|
+
if (typeof fromConfig === "string" && CHANNELS.includes(fromConfig)) {
|
|
2769
|
+
return fromConfig;
|
|
2770
|
+
}
|
|
2771
|
+
return channelFor(current);
|
|
2772
|
+
}
|
|
2773
|
+
function checkIntervalMs(config2) {
|
|
2774
|
+
const raw = config2.updateCheckMinutes;
|
|
2775
|
+
if (typeof raw !== "number" || !Number.isFinite(raw)) return UPDATE_CHECK_MS;
|
|
2776
|
+
const clamped = Math.min(CHECK_MINUTES_MAX, Math.max(CHECK_MINUTES_MIN, Math.floor(raw)));
|
|
2777
|
+
return clamped * 6e4;
|
|
2778
|
+
}
|
|
2779
|
+
function nextAttemptAllowed(state, target, now) {
|
|
2780
|
+
if (!state || typeof state !== "object") return { allowed: true };
|
|
2781
|
+
if (state.target !== target) return { allowed: true };
|
|
2782
|
+
const attempts = typeof state.attempts === "number" && state.attempts > 0 ? state.attempts : 0;
|
|
2783
|
+
if (attempts === 0) return { allowed: true };
|
|
2784
|
+
if (attempts >= MAX_UPDATE_ATTEMPTS) return { allowed: false, reason: "exhausted" };
|
|
2785
|
+
if (typeof state.at !== "number" || !Number.isFinite(state.at)) return { allowed: true };
|
|
2786
|
+
const wait = UPDATE_BACKOFF_MS[Math.min(attempts - 1, UPDATE_BACKOFF_MS.length - 1)];
|
|
2787
|
+
return now - state.at >= wait ? { allowed: true } : { allowed: false, reason: "backoff" };
|
|
2788
|
+
}
|
|
2789
|
+
function reconcile(state, current) {
|
|
2790
|
+
if (!state || typeof state.target !== "string") return { action: "none" };
|
|
2791
|
+
if (state.target === current) return { action: "clear", target: state.target };
|
|
2792
|
+
return { action: "keep", state };
|
|
2793
|
+
}
|
|
2794
|
+
function packageRootFrom(cliPath2) {
|
|
2795
|
+
return path5.dirname(path5.dirname(cliPath2));
|
|
2796
|
+
}
|
|
2797
|
+
function globalPrefixFrom(cliPath2) {
|
|
2798
|
+
const root = packageRootFrom(cliPath2);
|
|
2799
|
+
const parts = root.split(path5.sep);
|
|
2800
|
+
const i = parts.lastIndexOf("node_modules");
|
|
2801
|
+
if (i < 1) return null;
|
|
2802
|
+
const before = parts.slice(0, i);
|
|
2803
|
+
if (before[before.length - 1] === "lib") before.pop();
|
|
2804
|
+
const prefix = before.join(path5.sep);
|
|
2805
|
+
return prefix || null;
|
|
2806
|
+
}
|
|
2807
|
+
function decideUpdate(f) {
|
|
2808
|
+
if (f.platform === "win32") return { update: false, reason: "windows" };
|
|
2809
|
+
if (f.current === DEV_VERSION) return { update: false, reason: "dev-build" };
|
|
2810
|
+
if (!f.autoUpdate) return { update: false, reason: "disabled" };
|
|
2811
|
+
const service2 = f.service();
|
|
2812
|
+
if (service2.state !== "running") return { update: false, reason: "service-not-running" };
|
|
2813
|
+
if (!service2.execPath || service2.execPath !== f.cliPath) {
|
|
2814
|
+
return { update: false, reason: "foreign-unit" };
|
|
2815
|
+
}
|
|
2816
|
+
if (!f.npmPresent()) return { update: false, reason: "no-npm" };
|
|
2817
|
+
if (f.distTags === null) return { update: false, reason: "registry-unreachable" };
|
|
2818
|
+
const target = f.distTags[f.channel];
|
|
2819
|
+
if (!target) return { update: false, reason: "no-such-tag" };
|
|
2820
|
+
if (!isNewer(target, f.current)) return { update: false, reason: "up-to-date" };
|
|
2821
|
+
const attempt = nextAttemptAllowed(f.state, target, f.now);
|
|
2822
|
+
if (!attempt.allowed) return { update: false, reason: attempt.reason };
|
|
2823
|
+
return { update: true, target, channel: f.channel };
|
|
2824
|
+
}
|
|
2825
|
+
function describeRefusal(reason, f) {
|
|
2826
|
+
switch (reason) {
|
|
2827
|
+
case "windows":
|
|
2828
|
+
return "Auto-update is not available on Windows \u2014 run `lumi-runner update` to upgrade.";
|
|
2829
|
+
case "dev-build":
|
|
2830
|
+
return "Development build \u2014 auto-update is off.";
|
|
2831
|
+
case "disabled":
|
|
2832
|
+
return "Auto-update is off (`lumi-runner config set autoUpdate on` to enable).";
|
|
2833
|
+
case "service-not-running":
|
|
2834
|
+
return "Auto-update needs the background service (this daemon would have nothing to restart it). Run `lumi-runner service install`, or `lumi-runner update` by hand.";
|
|
2835
|
+
case "foreign-unit":
|
|
2836
|
+
return "This process is not the one the installed service runs, so auto-update is off for it. Run `lumi-runner update` if you meant to upgrade the installed copy.";
|
|
2837
|
+
case "no-npm":
|
|
2838
|
+
return "Auto-update needs `npm` on the service PATH and could not find it.";
|
|
2839
|
+
case "registry-unreachable":
|
|
2840
|
+
return "Could not reach the npm registry to check for updates \u2014 will retry.";
|
|
2841
|
+
case "no-such-tag":
|
|
2842
|
+
return "The npm registry has no release on this channel yet.";
|
|
2843
|
+
case "up-to-date":
|
|
2844
|
+
return `Up to date (${f.current}).`;
|
|
2845
|
+
case "backoff":
|
|
2846
|
+
return "A recent update attempt did not take \u2014 waiting before trying again.";
|
|
2847
|
+
case "exhausted":
|
|
2848
|
+
return `Gave up updating after ${MAX_UPDATE_ATTEMPTS} attempts. Still on ${f.current} \u2014 run \`lumi-runner update\` to see why, or wait for the next release.`;
|
|
2849
|
+
}
|
|
2850
|
+
}
|
|
2851
|
+
|
|
2852
|
+
// src/update/registry.ts
|
|
2853
|
+
var DEFAULT_REGISTRY = "https://registry.npmjs.org";
|
|
2854
|
+
var REGISTRY_TIMEOUT_MS = 1e4;
|
|
2855
|
+
function registryBase(env = process.env) {
|
|
2856
|
+
return (env.LUMI_RUNNER_REGISTRY || DEFAULT_REGISTRY).replace(/\/+$/, "");
|
|
2857
|
+
}
|
|
2858
|
+
async function fetchDistTags(options = {}) {
|
|
2859
|
+
const base = options.registry ?? registryBase();
|
|
2860
|
+
const doFetch = options.fetchImpl ?? fetch;
|
|
2861
|
+
const url = `${base}/${encodeURIComponent(PACKAGE_NAME)}`;
|
|
2862
|
+
const controller = new AbortController();
|
|
2863
|
+
const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? REGISTRY_TIMEOUT_MS);
|
|
2864
|
+
try {
|
|
2865
|
+
const response = await doFetch(url, {
|
|
2866
|
+
headers: { accept: "application/vnd.npm.install-v1+json" },
|
|
2867
|
+
signal: controller.signal
|
|
2868
|
+
});
|
|
2869
|
+
if (!response.ok) return null;
|
|
2870
|
+
const body = await response.json();
|
|
2871
|
+
const tags = body?.["dist-tags"];
|
|
2872
|
+
if (!tags || typeof tags !== "object" || Array.isArray(tags)) return null;
|
|
2873
|
+
const out = {};
|
|
2874
|
+
for (const [tag, version] of Object.entries(tags)) {
|
|
2875
|
+
if (typeof version === "string") out[tag] = version;
|
|
2876
|
+
}
|
|
2877
|
+
return out;
|
|
2878
|
+
} catch {
|
|
2879
|
+
return null;
|
|
2880
|
+
} finally {
|
|
2881
|
+
clearTimeout(timer);
|
|
2882
|
+
}
|
|
2883
|
+
}
|
|
2884
|
+
|
|
2885
|
+
// src/update/state.ts
|
|
2886
|
+
import fs5 from "node:fs";
|
|
2887
|
+
import path6 from "node:path";
|
|
2888
|
+
var UPDATE_STATE_FILE = "update.json";
|
|
2889
|
+
function updateStatePath() {
|
|
2890
|
+
return path6.join(configDir(), UPDATE_STATE_FILE);
|
|
2891
|
+
}
|
|
2892
|
+
function readUpdateState() {
|
|
2893
|
+
try {
|
|
2894
|
+
const parsed = JSON.parse(fs5.readFileSync(updateStatePath(), "utf8"));
|
|
2895
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
|
2896
|
+
return parsed;
|
|
2897
|
+
} catch {
|
|
2898
|
+
return null;
|
|
2899
|
+
}
|
|
2900
|
+
}
|
|
2901
|
+
function writeUpdateState(state) {
|
|
2902
|
+
try {
|
|
2903
|
+
fs5.mkdirSync(configDir(), { recursive: true, mode: 448 });
|
|
2904
|
+
fs5.writeFileSync(updateStatePath(), `${JSON.stringify(state, null, 2)}
|
|
2905
|
+
`, { mode: 384 });
|
|
2906
|
+
return true;
|
|
2907
|
+
} catch {
|
|
2908
|
+
return false;
|
|
2909
|
+
}
|
|
2910
|
+
}
|
|
2911
|
+
function clearUpdateState() {
|
|
2912
|
+
try {
|
|
2913
|
+
fs5.rmSync(updateStatePath(), { force: true });
|
|
2914
|
+
} catch {
|
|
2915
|
+
}
|
|
2916
|
+
}
|
|
2917
|
+
|
|
2918
|
+
// src/update/install.ts
|
|
2919
|
+
import { spawn as spawn4, spawnSync as spawnSync2 } from "node:child_process";
|
|
2920
|
+
import fs6 from "node:fs";
|
|
2921
|
+
import path7 from "node:path";
|
|
2922
|
+
var INSTALL_TIMEOUT_MS = 5 * 6e4;
|
|
2923
|
+
var STDERR_KEEP = 400;
|
|
2924
|
+
function npmPresent() {
|
|
2925
|
+
const probe = process.platform === "win32" ? "where" : "which";
|
|
2926
|
+
try {
|
|
2927
|
+
return spawnSync2(probe, ["npm"], { encoding: "utf8" }).status === 0;
|
|
2928
|
+
} catch {
|
|
2929
|
+
return false;
|
|
2930
|
+
}
|
|
2931
|
+
}
|
|
2932
|
+
function installArgs(target, cliPath2) {
|
|
2933
|
+
const args = ["install", "--global", "--no-fund", "--no-audit", `${PACKAGE_NAME}@${target}`];
|
|
2934
|
+
const prefix = globalPrefixFrom(cliPath2);
|
|
2935
|
+
if (prefix) args.push("--prefix", prefix);
|
|
2936
|
+
return args;
|
|
2937
|
+
}
|
|
2938
|
+
async function installGlobal(target, cliPath2, options = {}) {
|
|
2939
|
+
const args = installArgs(target, cliPath2);
|
|
2940
|
+
return new Promise((resolve) => {
|
|
2941
|
+
let child;
|
|
2942
|
+
try {
|
|
2943
|
+
child = spawn4("npm", args, { stdio: ["ignore", "ignore", "pipe"] });
|
|
2944
|
+
} catch (e) {
|
|
2945
|
+
resolve({ ok: false, detail: e instanceof Error ? e.message : String(e) });
|
|
2946
|
+
return;
|
|
2947
|
+
}
|
|
2948
|
+
let stderr = "";
|
|
2949
|
+
child.stderr?.on("data", (chunk) => {
|
|
2950
|
+
stderr = `${stderr}${chunk.toString()}`.slice(-STDERR_KEEP);
|
|
2951
|
+
});
|
|
2952
|
+
let settled = false;
|
|
2953
|
+
const finish = (result) => {
|
|
2954
|
+
if (settled) return;
|
|
2955
|
+
settled = true;
|
|
2956
|
+
clearTimeout(timer);
|
|
2957
|
+
resolve(result);
|
|
2958
|
+
};
|
|
2959
|
+
const timeoutMs = options.timeoutMs ?? INSTALL_TIMEOUT_MS;
|
|
2960
|
+
const timer = setTimeout(() => {
|
|
2961
|
+
child.kill("SIGTERM");
|
|
2962
|
+
setTimeout(() => child.kill("SIGKILL"), 5e3).unref();
|
|
2963
|
+
finish({ ok: false, detail: `npm install timed out after ${timeoutMs / 6e4} minutes.` });
|
|
2964
|
+
}, timeoutMs);
|
|
2965
|
+
timer.unref();
|
|
2966
|
+
child.on("error", (e) => finish({ ok: false, detail: e.message }));
|
|
2967
|
+
child.on(
|
|
2968
|
+
"close",
|
|
2969
|
+
(code) => finish({ ok: code === 0, detail: stderr.trim() || `npm exited ${code}` })
|
|
2970
|
+
);
|
|
2971
|
+
});
|
|
2972
|
+
}
|
|
2973
|
+
function installedVersionAt(cliPath2) {
|
|
2974
|
+
try {
|
|
2975
|
+
const pkg = JSON.parse(
|
|
2976
|
+
fs6.readFileSync(path7.join(packageRootFrom(cliPath2), "package.json"), "utf8")
|
|
2977
|
+
);
|
|
2978
|
+
const version = pkg?.version;
|
|
2979
|
+
return typeof version === "string" ? version : null;
|
|
2980
|
+
} catch {
|
|
2981
|
+
return null;
|
|
2982
|
+
}
|
|
2983
|
+
}
|
|
2984
|
+
|
|
2985
|
+
// src/daemon.ts
|
|
2986
|
+
var HEARTBEAT_MS = 3e4;
|
|
2987
|
+
var JOB_TIMEOUT_MS = 20 * 60 * 1e3;
|
|
2988
|
+
var MAX_ATTEMPTS = 2;
|
|
2989
|
+
var SHUTDOWN_GRACE_MS = 1e4;
|
|
2990
|
+
var STOP_POLL_MS = 5e3;
|
|
2991
|
+
async function startDaemon() {
|
|
2992
|
+
const moved = migrateLegacyDir();
|
|
2993
|
+
let config2 = requireConfig();
|
|
2994
|
+
if (config2.ships.length === 0) {
|
|
2995
|
+
console.error("No Ships assigned. Run: lumi-runner ship add <shipId>");
|
|
2996
|
+
process.exit(1);
|
|
2997
|
+
}
|
|
2998
|
+
const { sessions, failures } = await openShipSessions(
|
|
2999
|
+
config2,
|
|
3000
|
+
(shipId) => initFirebase(config2, `ship-${shipId}`)
|
|
3001
|
+
);
|
|
3002
|
+
for (const f of failures) console.error(f.message);
|
|
3003
|
+
const orphanFailures = (await Promise.all(orphanShipKeys(config2).map((shipId) => probeShipKey(config2, shipId)))).filter((f) => f !== null);
|
|
3004
|
+
const pruned = pruneDeadShips(config2, [...failures, ...orphanFailures]);
|
|
3005
|
+
if (pruned.dropped.length > 0) {
|
|
3006
|
+
saveConfig(pruned.config);
|
|
3007
|
+
config2 = pruned.config;
|
|
3008
|
+
console.log(
|
|
3009
|
+
`Forgot ${pruned.dropped.length} Ship(s) this machine can no longer open: ${pruned.dropped.join(", ")}. Deleted, or this machine was revoked \u2014 run \`lumi-runner login\` if that was not expected.`
|
|
3010
|
+
);
|
|
3011
|
+
}
|
|
3012
|
+
if (sessions.length === 0) {
|
|
3013
|
+
console.error("No Ship could be signed in to. Run: lumi-runner login");
|
|
3014
|
+
process.exit(1);
|
|
3015
|
+
}
|
|
3016
|
+
const byShip = new Map(sessions.map((s) => [s.shipId, s]));
|
|
3017
|
+
const sess = (shipId) => {
|
|
3018
|
+
const s = byShip.get(shipId);
|
|
3019
|
+
if (!s) throw new Error(`No session for Ship ${shipId}`);
|
|
3020
|
+
return s;
|
|
3021
|
+
};
|
|
3022
|
+
const serving = new Set(sessions.map((s) => s.shipId));
|
|
3023
|
+
console.log(
|
|
3024
|
+
`Runner ${config2.runnerId} serving ${serving.size} Ship(s): ${[...serving].join(", ")}`
|
|
3025
|
+
);
|
|
3026
|
+
const logLines = [];
|
|
3027
|
+
const log2 = (line) => {
|
|
3028
|
+
const stamped = `${(/* @__PURE__ */ new Date()).toISOString()} ${line}`;
|
|
3029
|
+
console.log(stamped);
|
|
3030
|
+
appendLog(stamped);
|
|
3031
|
+
logLines.push(stamped);
|
|
3032
|
+
if (logLines.length > 20) logLines.shift();
|
|
3033
|
+
};
|
|
3034
|
+
if (moved?.migrated) log2(`Moved runner config from ${moved.from} to ${moved.to}.`);
|
|
3035
|
+
const startedAt = Date.now();
|
|
3036
|
+
const running = /* @__PURE__ */ new Map();
|
|
3037
|
+
const runningByShip = () => {
|
|
3038
|
+
const counts = /* @__PURE__ */ new Map();
|
|
3039
|
+
for (const r of running.values()) counts.set(r.shipId, (counts.get(r.shipId) ?? 0) + 1);
|
|
3040
|
+
return counts;
|
|
3041
|
+
};
|
|
3042
|
+
const liveJobsOn = (shipId) => [...running.values()].filter((r) => r.shipId === shipId && r.mirror).map((r) => r.mirror).sort((a, b) => a.startedAt - b.startedAt);
|
|
3043
|
+
const approved = /* @__PURE__ */ new Map();
|
|
3044
|
+
const warnedUnapproved = /* @__PURE__ */ new Set();
|
|
3045
|
+
const shipRunnerRef = (shipId) => doc7(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.runners, config2.runnerId);
|
|
3046
|
+
const needsRefill = /* @__PURE__ */ new Set();
|
|
3047
|
+
let beating = false;
|
|
3048
|
+
async function heartbeat() {
|
|
3049
|
+
if (beating) return;
|
|
3050
|
+
beating = true;
|
|
3051
|
+
const now = Date.now();
|
|
3052
|
+
try {
|
|
3053
|
+
for (const shipId of [...serving]) {
|
|
3054
|
+
try {
|
|
3055
|
+
const snap = await getDoc6(shipRunnerRef(shipId));
|
|
3056
|
+
if (!snap.exists()) {
|
|
3057
|
+
forgetShipLocally(shipId, "a captain removed this machine on the Daemons page");
|
|
3058
|
+
continue;
|
|
3059
|
+
}
|
|
3060
|
+
const isApproved = snap.data()?.approved === true;
|
|
3061
|
+
const wasApproved = approved.get(shipId) === true;
|
|
3062
|
+
approved.set(shipId, isApproved);
|
|
3063
|
+
if (!isApproved && !warnedUnapproved.has(shipId)) {
|
|
3064
|
+
warnedUnapproved.add(shipId);
|
|
3065
|
+
log2(
|
|
3066
|
+
`Ship ${shipId}: this machine's approval was withdrawn \u2014 idle here until a captain approves it again on the Daemons page.`
|
|
3067
|
+
);
|
|
3068
|
+
}
|
|
3069
|
+
if (isApproved && !wasApproved) {
|
|
3070
|
+
warnedUnapproved.delete(shipId);
|
|
3071
|
+
needsRefill.add(shipId);
|
|
3072
|
+
}
|
|
3073
|
+
const live = liveJobsOn(shipId);
|
|
3074
|
+
await setDoc2(
|
|
3075
|
+
shipRunnerRef(shipId),
|
|
3076
|
+
{
|
|
3077
|
+
hostname: os4.hostname(),
|
|
3078
|
+
version: RUNNER_VERSION,
|
|
3079
|
+
status: "online",
|
|
3080
|
+
lastSeenAt: now,
|
|
3081
|
+
startedAt,
|
|
3082
|
+
currentJob: live[0] ?? null,
|
|
3083
|
+
currentJobs: live,
|
|
3084
|
+
parallelLimit: shipCap(config2, shipId),
|
|
3085
|
+
lastLogLines: [...logLines],
|
|
3086
|
+
// §15.37. Written on EVERY heartbeat, `null` when idle, exactly like `currentJob`
|
|
3087
|
+
// above — so a machine that finishes or abandons an update clears its own badge
|
|
3088
|
+
// without needing a separate write. Both names must appear in `liveFields()` in
|
|
3089
|
+
// firestore.rules, and that rules change must be deployed BEFORE this version is
|
|
3090
|
+
// published: `hasOnly()` fails the whole write for one unlisted key, which would
|
|
3091
|
+
// take every heartbeat down, not just the badge.
|
|
3092
|
+
updateAvailable: updateAvailableVersion,
|
|
3093
|
+
updatePhase
|
|
3094
|
+
},
|
|
3095
|
+
{ merge: true }
|
|
3096
|
+
);
|
|
3097
|
+
} catch (e) {
|
|
3098
|
+
if (isEnrolmentGone(e)) {
|
|
3099
|
+
forgetShipLocally(shipId, "this machine is no longer enrolled on that Ship");
|
|
3100
|
+
continue;
|
|
3101
|
+
}
|
|
3102
|
+
console.error(`heartbeat failed (${shipId}):`, e instanceof Error ? e.message : e);
|
|
3103
|
+
}
|
|
3104
|
+
}
|
|
3105
|
+
} finally {
|
|
3106
|
+
beating = false;
|
|
3107
|
+
}
|
|
3108
|
+
for (const shipId of [...needsRefill]) {
|
|
3109
|
+
needsRefill.delete(shipId);
|
|
3110
|
+
if (!serving.has(shipId) || approved.get(shipId) !== true) continue;
|
|
3111
|
+
try {
|
|
3112
|
+
const snap = await getDocs4(
|
|
3113
|
+
query4(
|
|
3114
|
+
collection5(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.jobs),
|
|
3115
|
+
where4("status", "==", "queued"),
|
|
3116
|
+
orderBy4("createdAt", "asc")
|
|
3117
|
+
)
|
|
3118
|
+
);
|
|
3119
|
+
for (const d of snap.docs) {
|
|
3120
|
+
pending.set(`${shipId}/${d.id}`, {
|
|
3121
|
+
shipId,
|
|
3122
|
+
job: { id: d.id, ...d.data() }
|
|
3123
|
+
});
|
|
3124
|
+
}
|
|
3125
|
+
log2(`Ship ${shipId}: approved \u2014 picking up ${snap.size} queued job(s).`);
|
|
3126
|
+
} catch (e) {
|
|
3127
|
+
console.error(`queue refill failed (${shipId}):`, e instanceof Error ? e.message : e);
|
|
3128
|
+
}
|
|
3129
|
+
poke();
|
|
3130
|
+
}
|
|
3131
|
+
}
|
|
3132
|
+
const pending = /* @__PURE__ */ new Map();
|
|
3133
|
+
const engineLimits = /* @__PURE__ */ new Map();
|
|
3134
|
+
const agentEngines = /* @__PURE__ */ new Map();
|
|
3135
|
+
const unsubsByShip = /* @__PURE__ */ new Map();
|
|
3136
|
+
const listenerError = (shipId, what) => (e) => {
|
|
3137
|
+
if (isEnrolmentGone(e)) {
|
|
3138
|
+
forgetShipLocally(shipId, "a captain removed this machine on the Daemons page");
|
|
3139
|
+
return;
|
|
3140
|
+
}
|
|
3141
|
+
console.error(`${what} listener error (${shipId}):`, e.message);
|
|
3142
|
+
};
|
|
3143
|
+
for (const shipId of serving) {
|
|
3144
|
+
const q = query4(
|
|
3145
|
+
collection5(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.jobs),
|
|
3146
|
+
where4("status", "==", "queued"),
|
|
3147
|
+
orderBy4("createdAt", "asc")
|
|
3148
|
+
);
|
|
3149
|
+
unsubsByShip.set(shipId, [
|
|
3150
|
+
onSnapshot2(
|
|
3151
|
+
q,
|
|
3152
|
+
(snap) => {
|
|
3153
|
+
for (const key of [...pending.keys()]) {
|
|
3154
|
+
if (pending.get(key)?.shipId === shipId) pending.delete(key);
|
|
3155
|
+
}
|
|
3156
|
+
for (const d of snap.docs) {
|
|
3157
|
+
pending.set(`${shipId}/${d.id}`, {
|
|
3158
|
+
shipId,
|
|
3159
|
+
job: { id: d.id, ...d.data() }
|
|
3160
|
+
});
|
|
3161
|
+
}
|
|
3162
|
+
poke();
|
|
3163
|
+
},
|
|
3164
|
+
listenerError(shipId, "jobs")
|
|
3165
|
+
),
|
|
3166
|
+
// Agents, purely so the claim gate can resolve a queued job's engine without a read.
|
|
3167
|
+
onSnapshot2(
|
|
3168
|
+
collection5(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.agents),
|
|
3169
|
+
(snap) => {
|
|
3170
|
+
for (const d of snap.docs) {
|
|
3171
|
+
agentEngines.set(`${shipId}/${d.id}`, agentEngine(d.data()));
|
|
3172
|
+
}
|
|
3173
|
+
poke();
|
|
3174
|
+
},
|
|
2530
3175
|
listenerError(shipId, "agents")
|
|
2531
3176
|
),
|
|
2532
3177
|
// Exhausted usage windows. The FIRST snapshot is the startup seed: a restarted daemon —
|
|
@@ -2596,9 +3241,14 @@ async function startDaemon() {
|
|
|
2596
3241
|
let dispatching = false;
|
|
2597
3242
|
let pokeRequested = false;
|
|
2598
3243
|
let shuttingDown = false;
|
|
3244
|
+
let updating = null;
|
|
3245
|
+
let updateDrainTimer = null;
|
|
3246
|
+
let nextUpdateCheckAt = Date.now() + UPDATE_FIRST_CHECK_MS + Math.floor(Math.random() * UPDATE_JITTER_MS);
|
|
3247
|
+
let updateAvailableVersion = null;
|
|
3248
|
+
let updatePhase = null;
|
|
2599
3249
|
function poke() {
|
|
2600
|
-
if (dispatching || shuttingDown) {
|
|
2601
|
-
pokeRequested = !shuttingDown;
|
|
3250
|
+
if (dispatching || shuttingDown || updating) {
|
|
3251
|
+
pokeRequested = !shuttingDown && !updating;
|
|
2602
3252
|
return;
|
|
2603
3253
|
}
|
|
2604
3254
|
dispatch();
|
|
@@ -2632,7 +3282,7 @@ async function startDaemon() {
|
|
|
2632
3282
|
try {
|
|
2633
3283
|
for (; ; ) {
|
|
2634
3284
|
pokeRequested = false;
|
|
2635
|
-
if (shuttingDown) break;
|
|
3285
|
+
if (shuttingDown || updating) break;
|
|
2636
3286
|
const now = Date.now();
|
|
2637
3287
|
const picks = selectDispatch({
|
|
2638
3288
|
pending: pending.values(),
|
|
@@ -2664,6 +3314,10 @@ async function startDaemon() {
|
|
|
2664
3314
|
log2(`job ${job.id} ended abnormally: ${e instanceof Error ? e.message : e}`);
|
|
2665
3315
|
}).finally(() => {
|
|
2666
3316
|
running.delete(job.id);
|
|
3317
|
+
if (updating) {
|
|
3318
|
+
if (running.size === 0) void applyUpdate();
|
|
3319
|
+
return;
|
|
3320
|
+
}
|
|
2667
3321
|
if (!shuttingDown) poke();
|
|
2668
3322
|
});
|
|
2669
3323
|
}
|
|
@@ -2741,7 +3395,7 @@ async function startDaemon() {
|
|
|
2741
3395
|
void (async () => {
|
|
2742
3396
|
if (slot.stop) return;
|
|
2743
3397
|
try {
|
|
2744
|
-
const snap = await
|
|
3398
|
+
const snap = await getDoc6(
|
|
2745
3399
|
doc7(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.jobs, job.id)
|
|
2746
3400
|
);
|
|
2747
3401
|
const fresh = snap.data();
|
|
@@ -2796,7 +3450,7 @@ async function startDaemon() {
|
|
|
2796
3450
|
onDenied: () => {
|
|
2797
3451
|
void (async () => {
|
|
2798
3452
|
try {
|
|
2799
|
-
const snap = await
|
|
3453
|
+
const snap = await getDoc6(
|
|
2800
3454
|
doc7(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.jobs, job.id)
|
|
2801
3455
|
);
|
|
2802
3456
|
const fresh = snap.data();
|
|
@@ -2889,7 +3543,7 @@ async function startDaemon() {
|
|
|
2889
3543
|
// §15.36. The driver emits Crew's step vocabulary; the writer coalesces, redacts and
|
|
2890
3544
|
// persists. A throw here must never reach the driver's stdout loop, which is why `push`
|
|
2891
3545
|
// swallows everything.
|
|
2892
|
-
onStep: (
|
|
3546
|
+
onStep: (step2) => progress.push(step2)
|
|
2893
3547
|
});
|
|
2894
3548
|
transcript = redactTranscript(session.transcript, knownSecrets);
|
|
2895
3549
|
usage = session.usage;
|
|
@@ -3038,623 +3692,491 @@ async function startDaemon() {
|
|
|
3038
3692
|
const heartbeatTimer = setInterval(() => {
|
|
3039
3693
|
void heartbeat();
|
|
3040
3694
|
sweepLimits();
|
|
3695
|
+
void maybeCheckForUpdate();
|
|
3041
3696
|
}, HEARTBEAT_MS);
|
|
3042
3697
|
let stopping = false;
|
|
3043
|
-
const shutdown = async (
|
|
3698
|
+
const shutdown = async (reason) => {
|
|
3044
3699
|
if (stopping) return;
|
|
3045
3700
|
stopping = true;
|
|
3046
3701
|
shuttingDown = true;
|
|
3047
3702
|
clearInterval(heartbeatTimer);
|
|
3048
|
-
if (limitTimer) clearTimeout(limitTimer);
|
|
3049
|
-
|
|
3050
|
-
const
|
|
3051
|
-
|
|
3052
|
-
|
|
3053
|
-
|
|
3054
|
-
|
|
3055
|
-
|
|
3056
|
-
|
|
3057
|
-
|
|
3058
|
-
|
|
3059
|
-
|
|
3060
|
-
|
|
3061
|
-
|
|
3062
|
-
|
|
3063
|
-
|
|
3064
|
-
|
|
3065
|
-
|
|
3066
|
-
|
|
3067
|
-
|
|
3068
|
-
|
|
3069
|
-
|
|
3070
|
-
|
|
3071
|
-
|
|
3072
|
-
|
|
3073
|
-
|
|
3074
|
-
|
|
3075
|
-
|
|
3076
|
-
|
|
3077
|
-
|
|
3078
|
-
|
|
3079
|
-
|
|
3080
|
-
|
|
3081
|
-
|
|
3082
|
-
|
|
3083
|
-
|
|
3084
|
-
}
|
|
3085
|
-
|
|
3086
|
-
|
|
3087
|
-
|
|
3088
|
-
process.on("
|
|
3089
|
-
|
|
3090
|
-
|
|
3091
|
-
|
|
3092
|
-
|
|
3093
|
-
|
|
3094
|
-
});
|
|
3095
|
-
}
|
|
3096
|
-
|
|
3097
|
-
// src/cli/ui.ts
|
|
3098
|
-
import * as clack from "@clack/prompts";
|
|
3099
|
-
import { createColors } from "picocolors";
|
|
3100
|
-
var CliError = class extends Error {
|
|
3101
|
-
constructor(message, exitCode = 1) {
|
|
3102
|
-
super(message);
|
|
3103
|
-
this.exitCode = exitCode;
|
|
3104
|
-
this.name = "CliError";
|
|
3105
|
-
}
|
|
3106
|
-
exitCode;
|
|
3107
|
-
};
|
|
3108
|
-
var jsonMode = false;
|
|
3109
|
-
var assumeYes = false;
|
|
3110
|
-
var palette = createColors();
|
|
3111
|
-
var delegate = (name) => (input) => palette[name](input);
|
|
3112
|
-
var pc = {
|
|
3113
|
-
bold: delegate("bold"),
|
|
3114
|
-
dim: delegate("dim"),
|
|
3115
|
-
black: delegate("black"),
|
|
3116
|
-
red: delegate("red"),
|
|
3117
|
-
green: delegate("green"),
|
|
3118
|
-
yellow: delegate("yellow"),
|
|
3119
|
-
cyan: delegate("cyan"),
|
|
3120
|
-
bgCyan: delegate("bgCyan")
|
|
3121
|
-
};
|
|
3122
|
-
function configureOutput(options) {
|
|
3123
|
-
jsonMode = options.json === true;
|
|
3124
|
-
assumeYes = options.yes === true;
|
|
3125
|
-
if (options.color === false || jsonMode) palette = createColors(false);
|
|
3126
|
-
}
|
|
3127
|
-
function isJson() {
|
|
3128
|
-
return jsonMode;
|
|
3129
|
-
}
|
|
3130
|
-
function emitJson(value) {
|
|
3131
|
-
process.stdout.write(`${JSON.stringify(value, null, 2)}
|
|
3132
|
-
`);
|
|
3133
|
-
}
|
|
3134
|
-
function canPrompt() {
|
|
3135
|
-
return !jsonMode && process.stdin.isTTY === true && process.stdout.isTTY === true;
|
|
3136
|
-
}
|
|
3137
|
-
function requireInteractive(what, flagHint) {
|
|
3138
|
-
if (canPrompt()) return;
|
|
3139
|
-
throw new CliError(
|
|
3140
|
-
`${what} needs an interactive terminal. Run it in a terminal, or pass ${pc.bold(flagHint)}.`
|
|
3141
|
-
);
|
|
3142
|
-
}
|
|
3143
|
-
var say = {
|
|
3144
|
-
intro(title) {
|
|
3145
|
-
if (!jsonMode) clack.intro(pc.bgCyan(pc.black(` ${title} `)));
|
|
3146
|
-
},
|
|
3147
|
-
outro(message) {
|
|
3148
|
-
if (!jsonMode) clack.outro(message);
|
|
3149
|
-
},
|
|
3150
|
-
info(message) {
|
|
3151
|
-
if (!jsonMode) clack.log.info(message);
|
|
3152
|
-
},
|
|
3153
|
-
success(message) {
|
|
3154
|
-
if (!jsonMode) clack.log.success(message);
|
|
3155
|
-
},
|
|
3156
|
-
warn(message) {
|
|
3157
|
-
if (!jsonMode) clack.log.warn(message);
|
|
3158
|
-
},
|
|
3159
|
-
error(message) {
|
|
3160
|
-
if (!jsonMode) clack.log.error(message);
|
|
3161
|
-
},
|
|
3162
|
-
step(message) {
|
|
3163
|
-
if (!jsonMode) clack.log.step(message);
|
|
3164
|
-
},
|
|
3165
|
-
note(body, title) {
|
|
3166
|
-
if (!jsonMode) clack.note(body, title);
|
|
3167
|
-
},
|
|
3168
|
-
/** Raw line straight to stdout — for `logs`, where the content IS the output. */
|
|
3169
|
-
line(message) {
|
|
3170
|
-
process.stdout.write(`${message}
|
|
3171
|
-
`);
|
|
3172
|
-
}
|
|
3173
|
-
};
|
|
3174
|
-
function spinner2() {
|
|
3175
|
-
if (jsonMode) {
|
|
3176
|
-
return { start: () => {
|
|
3177
|
-
}, update: () => {
|
|
3178
|
-
}, stop: () => {
|
|
3179
|
-
} };
|
|
3180
|
-
}
|
|
3181
|
-
if (!canPrompt()) {
|
|
3703
|
+
if (limitTimer) clearTimeout(limitTimer);
|
|
3704
|
+
if (updateDrainTimer) clearTimeout(updateDrainTimer);
|
|
3705
|
+
for (const shipUnsubs of unsubsByShip.values()) shipUnsubs.forEach((u) => u());
|
|
3706
|
+
const inFlight = [...running.values()];
|
|
3707
|
+
if (inFlight.length > 0) {
|
|
3708
|
+
log2(
|
|
3709
|
+
`${reason} with ${inFlight.length} job(s) in flight \u2014 stopping the sessions and releasing them.`
|
|
3710
|
+
);
|
|
3711
|
+
notify(
|
|
3712
|
+
"Crew runner stopping",
|
|
3713
|
+
`${inFlight.length} job(s) were in progress; they have been returned to the queue.`
|
|
3714
|
+
);
|
|
3715
|
+
for (const r of inFlight) r.abort.abort();
|
|
3716
|
+
} else {
|
|
3717
|
+
log2(`${reason} \u2014 shutting down.`);
|
|
3718
|
+
}
|
|
3719
|
+
if (inFlight.length > 0) {
|
|
3720
|
+
await Promise.race([
|
|
3721
|
+
Promise.all(inFlight.map((r) => r.promise)),
|
|
3722
|
+
new Promise((resolve) => setTimeout(resolve, SHUTDOWN_GRACE_MS).unref())
|
|
3723
|
+
]);
|
|
3724
|
+
}
|
|
3725
|
+
const now = Date.now();
|
|
3726
|
+
try {
|
|
3727
|
+
for (const shipId of serving) {
|
|
3728
|
+
await setDoc2(
|
|
3729
|
+
doc7(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.runners, config2.runnerId),
|
|
3730
|
+
{
|
|
3731
|
+
status: "offline",
|
|
3732
|
+
lastSeenAt: now,
|
|
3733
|
+
currentJob: deleteField2(),
|
|
3734
|
+
currentJobs: deleteField2()
|
|
3735
|
+
},
|
|
3736
|
+
{ merge: true }
|
|
3737
|
+
);
|
|
3738
|
+
}
|
|
3739
|
+
} catch {
|
|
3740
|
+
}
|
|
3741
|
+
process.exit(0);
|
|
3742
|
+
};
|
|
3743
|
+
process.on("SIGINT", () => void shutdown("SIGINT received"));
|
|
3744
|
+
process.on("SIGTERM", () => void shutdown("SIGTERM received"));
|
|
3745
|
+
const loggedRefusals = /* @__PURE__ */ new Set();
|
|
3746
|
+
let checkingUpdate = false;
|
|
3747
|
+
let applyingUpdate = false;
|
|
3748
|
+
function updateFacts(distTags) {
|
|
3182
3749
|
return {
|
|
3183
|
-
|
|
3184
|
-
|
|
3185
|
-
|
|
3186
|
-
|
|
3187
|
-
|
|
3188
|
-
|
|
3750
|
+
platform: process.platform,
|
|
3751
|
+
current: RUNNER_VERSION,
|
|
3752
|
+
autoUpdate: config2.autoUpdate !== false,
|
|
3753
|
+
// absent means on
|
|
3754
|
+
channel: resolveChannel(RUNNER_VERSION, config2, process.env),
|
|
3755
|
+
// THUNKS. Both shell out synchronously, and the ladder refuses most machines before it
|
|
3756
|
+
// reaches them — see the note on `UpdateFacts.service`.
|
|
3757
|
+
service: () => {
|
|
3758
|
+
const s = serviceStatus();
|
|
3759
|
+
return { state: s.state, execPath: s.execPath };
|
|
3760
|
+
},
|
|
3761
|
+
npmPresent,
|
|
3762
|
+
cliPath: cliPath(),
|
|
3763
|
+
distTags,
|
|
3764
|
+
state: readUpdateState(),
|
|
3765
|
+
now: Date.now()
|
|
3189
3766
|
};
|
|
3190
3767
|
}
|
|
3191
|
-
|
|
3192
|
-
|
|
3193
|
-
|
|
3194
|
-
|
|
3195
|
-
stop: (msg) => s.stop(msg)
|
|
3196
|
-
};
|
|
3197
|
-
}
|
|
3198
|
-
function unwrap(value) {
|
|
3199
|
-
if (clack.isCancel(value)) throw new CliError("Cancelled.", 130);
|
|
3200
|
-
return value;
|
|
3201
|
-
}
|
|
3202
|
-
async function promptMultiSelect(options) {
|
|
3203
|
-
requireInteractive(options.message, options.flagHint);
|
|
3204
|
-
return unwrap(
|
|
3205
|
-
await clack.multiselect({
|
|
3206
|
-
message: options.message,
|
|
3207
|
-
options: options.choices,
|
|
3208
|
-
initialValues: options.initialValues,
|
|
3209
|
-
required: options.required ?? false
|
|
3210
|
-
})
|
|
3211
|
-
);
|
|
3212
|
-
}
|
|
3213
|
-
async function promptConfirm(options) {
|
|
3214
|
-
if (assumeYes && options.yesFlagApplies !== false) return true;
|
|
3215
|
-
requireInteractive(options.message, "--yes");
|
|
3216
|
-
return unwrap(await clack.confirm({ message: options.message, initialValue: options.initialValue }));
|
|
3217
|
-
}
|
|
3218
|
-
function glyph(level) {
|
|
3219
|
-
if (level === "ok") return pc.green("\u2714");
|
|
3220
|
-
if (level === "warn") return pc.yellow("\u25B2");
|
|
3221
|
-
return pc.red("\u2716");
|
|
3222
|
-
}
|
|
3223
|
-
|
|
3224
|
-
// src/cli/commands/config.ts
|
|
3225
|
-
var TOGGLES = {
|
|
3226
|
-
notifications: "Desktop notifications when a job starts, finishes or fails",
|
|
3227
|
-
keepAwake: "Keep this machine awake while a job is running"
|
|
3228
|
-
};
|
|
3229
|
-
var PARALLEL = "parallel";
|
|
3230
|
-
var PARALLEL_HELP = "How many jobs to run at once (1 = one at a time, as before)";
|
|
3231
|
-
var DEFAULT_WORDS = ["default", "auto", "reset"];
|
|
3232
|
-
function isToggle(key) {
|
|
3233
|
-
return Object.hasOwn(TOGGLES, key);
|
|
3234
|
-
}
|
|
3235
|
-
function parseBool(value) {
|
|
3236
|
-
if (["on", "true", "yes", "1"].includes(value.toLowerCase())) return true;
|
|
3237
|
-
if (["off", "false", "no", "0"].includes(value.toLowerCase())) return false;
|
|
3238
|
-
throw new CliError(`Expected on/off, got "${value}".`);
|
|
3239
|
-
}
|
|
3240
|
-
function parseParallel(value) {
|
|
3241
|
-
if (DEFAULT_WORDS.includes(value.toLowerCase())) return null;
|
|
3242
|
-
const n = Number(value);
|
|
3243
|
-
if (!Number.isInteger(n) || n < 1 || n > PARALLEL_MAX) {
|
|
3244
|
-
throw new CliError(
|
|
3245
|
-
`Expected a whole number from 1 to ${PARALLEL_MAX} (or "default"), got "${value}".`
|
|
3246
|
-
);
|
|
3247
|
-
}
|
|
3248
|
-
return n;
|
|
3249
|
-
}
|
|
3250
|
-
async function runConfigList() {
|
|
3251
|
-
const config2 = requireConfig();
|
|
3252
|
-
const values = Object.fromEntries(
|
|
3253
|
-
Object.keys(TOGGLES).map((key) => [key, config2[key] !== false])
|
|
3254
|
-
);
|
|
3255
|
-
const machine = machineCap(config2);
|
|
3256
|
-
const ships = config2.ships.map((shipId) => ({
|
|
3257
|
-
shipId,
|
|
3258
|
-
parallel: shipCap(config2, shipId),
|
|
3259
|
-
explicit: config2.shipParallelJobs?.[shipId] !== void 0
|
|
3260
|
-
}));
|
|
3261
|
-
if (isJson()) {
|
|
3262
|
-
emitJson({ configDir: configDir(), ...values, parallel: machine, ships });
|
|
3263
|
-
return 0;
|
|
3264
|
-
}
|
|
3265
|
-
say.line(` ${configDir()}`);
|
|
3266
|
-
say.line("");
|
|
3267
|
-
for (const key of Object.keys(TOGGLES)) {
|
|
3268
|
-
say.line(` ${key.padEnd(16)} ${values[key] ? "on" : "off"} ${TOGGLES[key]}`);
|
|
3768
|
+
function refuse(reason) {
|
|
3769
|
+
if (loggedRefusals.has(reason)) return;
|
|
3770
|
+
loggedRefusals.add(reason);
|
|
3771
|
+
if (reason !== "up-to-date") log2(describeRefusal(reason, { current: RUNNER_VERSION }));
|
|
3269
3772
|
}
|
|
3270
|
-
|
|
3271
|
-
|
|
3272
|
-
|
|
3273
|
-
|
|
3274
|
-
const
|
|
3275
|
-
|
|
3773
|
+
async function maybeCheckForUpdate() {
|
|
3774
|
+
if (checkingUpdate || updating || shuttingDown || Date.now() < nextUpdateCheckAt) return;
|
|
3775
|
+
checkingUpdate = true;
|
|
3776
|
+
try {
|
|
3777
|
+
const local = decideUpdate(updateFacts(null));
|
|
3778
|
+
if (!local.update && local.reason !== "registry-unreachable") {
|
|
3779
|
+
refuse(local.reason);
|
|
3780
|
+
return;
|
|
3781
|
+
}
|
|
3782
|
+
const decision = decideUpdate(updateFacts(await fetchDistTags()));
|
|
3783
|
+
if (!decision.update) {
|
|
3784
|
+
refuse(decision.reason);
|
|
3785
|
+
return;
|
|
3786
|
+
}
|
|
3787
|
+
loggedRefusals.clear();
|
|
3788
|
+
beginUpdate(decision.target, decision.channel);
|
|
3789
|
+
} catch (e) {
|
|
3790
|
+
log2(`Update check failed: ${e instanceof Error ? e.message : e}`);
|
|
3791
|
+
} finally {
|
|
3792
|
+
checkingUpdate = false;
|
|
3793
|
+
nextUpdateCheckAt = Date.now() + checkIntervalMs(config2);
|
|
3276
3794
|
}
|
|
3277
3795
|
}
|
|
3278
|
-
|
|
3279
|
-
}
|
|
3280
|
-
|
|
3281
|
-
|
|
3282
|
-
|
|
3283
|
-
|
|
3284
|
-
throw new CliError(
|
|
3285
|
-
`Unknown setting "${key}". Known: ${[...Object.keys(TOGGLES), PARALLEL].join(", ")}.`
|
|
3796
|
+
function beginUpdate(target, channel) {
|
|
3797
|
+
updating = { target, channel };
|
|
3798
|
+
updateAvailableVersion = target;
|
|
3799
|
+
updatePhase = "draining";
|
|
3800
|
+
log2(
|
|
3801
|
+
`Update available: ${RUNNER_VERSION} \u2192 ${target} (${channel}). Not claiming new jobs; ${running.size} in flight.`
|
|
3286
3802
|
);
|
|
3287
|
-
|
|
3288
|
-
|
|
3289
|
-
|
|
3290
|
-
}
|
|
3291
|
-
config2[key] = parseBool(value);
|
|
3292
|
-
saveConfig(config2);
|
|
3293
|
-
if (isJson()) {
|
|
3294
|
-
emitJson({ [key]: config2[key] });
|
|
3295
|
-
return 0;
|
|
3296
|
-
}
|
|
3297
|
-
say.success(`${key} = ${config2[key] ? "on" : "off"}`);
|
|
3298
|
-
return 0;
|
|
3299
|
-
}
|
|
3300
|
-
function setParallel(config2, value, ship2) {
|
|
3301
|
-
const parsed = parseParallel(value);
|
|
3302
|
-
if (ship2) {
|
|
3303
|
-
if (!config2.ships.includes(ship2)) {
|
|
3304
|
-
throw new CliError(
|
|
3305
|
-
`This machine does not serve Ship "${ship2}". Run \`lumi-runner ship list\` to see which Ships it serves, or \`lumi-runner ship add ${ship2}\` to add it.`
|
|
3306
|
-
);
|
|
3307
|
-
}
|
|
3308
|
-
const overrides = { ...config2.shipParallelJobs ?? {} };
|
|
3309
|
-
if (parsed === null) delete overrides[ship2];
|
|
3310
|
-
else overrides[ship2] = parsed;
|
|
3311
|
-
config2.shipParallelJobs = overrides;
|
|
3312
|
-
} else if (parsed === null) {
|
|
3313
|
-
delete config2.maxParallelJobs;
|
|
3314
|
-
} else {
|
|
3315
|
-
config2.maxParallelJobs = parsed;
|
|
3316
|
-
}
|
|
3317
|
-
saveConfig(config2);
|
|
3318
|
-
const effective = ship2 ? shipCap(config2, ship2) : machineCap(config2);
|
|
3319
|
-
if (isJson()) {
|
|
3320
|
-
emitJson({ parallel: effective, ...ship2 ? { shipId: ship2 } : {} });
|
|
3321
|
-
return 0;
|
|
3322
|
-
}
|
|
3323
|
-
const machine = machineCap(config2);
|
|
3324
|
-
say.success(
|
|
3325
|
-
ship2 ? `${PARALLEL} for Ship ${ship2} = ${effective}${parsed === null ? " (machine default)" : ""}` : `${PARALLEL} = ${effective}`
|
|
3326
|
-
);
|
|
3327
|
-
if (ship2 && parsed !== null && parsed > machine) {
|
|
3328
|
-
say.line(
|
|
3329
|
-
` Capped at the machine-wide limit of ${machine}. Raise it with \`lumi-runner config set ${PARALLEL} ${parsed}\`.`
|
|
3803
|
+
updateDrainTimer = setTimeout(
|
|
3804
|
+
() => abandonUpdate("jobs did not finish in time"),
|
|
3805
|
+
UPDATE_DRAIN_MAX_MS
|
|
3330
3806
|
);
|
|
3331
|
-
|
|
3332
|
-
|
|
3333
|
-
|
|
3334
|
-
|
|
3335
|
-
|
|
3336
|
-
|
|
3337
|
-
|
|
3338
|
-
|
|
3339
|
-
|
|
3340
|
-
|
|
3341
|
-
|
|
3342
|
-
|
|
3343
|
-
import os4 from "node:os";
|
|
3344
|
-
import path4 from "node:path";
|
|
3345
|
-
import { fileURLToPath } from "node:url";
|
|
3346
|
-
var SERVICE_LABEL = "com.lumi.runner";
|
|
3347
|
-
var LINUX_UNIT = "lumi-runner.service";
|
|
3348
|
-
var WINDOWS_TASK = "LumiRunner";
|
|
3349
|
-
var LEGACY_SERVICE_LABEL = "com.lumi.crew-runner";
|
|
3350
|
-
var LEGACY_LINUX_UNIT = "crew-runner.service";
|
|
3351
|
-
var LEGACY_WINDOWS_TASK = "CrewRunner";
|
|
3352
|
-
var ServiceError = class extends Error {
|
|
3353
|
-
};
|
|
3354
|
-
function run(command, args) {
|
|
3355
|
-
const result = spawnSync(command, args, { encoding: "utf8" });
|
|
3356
|
-
return {
|
|
3357
|
-
ok: result.status === 0,
|
|
3358
|
-
out: `${result.stdout ?? ""}${result.stderr ?? ""}`.trim()
|
|
3359
|
-
};
|
|
3360
|
-
}
|
|
3361
|
-
function cliPath() {
|
|
3362
|
-
return fileURLToPath(import.meta.url);
|
|
3363
|
-
}
|
|
3364
|
-
function uid() {
|
|
3365
|
-
return String(process.getuid?.() ?? 0);
|
|
3366
|
-
}
|
|
3367
|
-
function launchAgentPath() {
|
|
3368
|
-
return path4.join(os4.homedir(), "Library/LaunchAgents", `${SERVICE_LABEL}.plist`);
|
|
3369
|
-
}
|
|
3370
|
-
function systemdUnitPath() {
|
|
3371
|
-
return path4.join(os4.homedir(), ".config/systemd/user", LINUX_UNIT);
|
|
3372
|
-
}
|
|
3373
|
-
function legacyLaunchAgentPath() {
|
|
3374
|
-
return path4.join(os4.homedir(), "Library/LaunchAgents", `${LEGACY_SERVICE_LABEL}.plist`);
|
|
3375
|
-
}
|
|
3376
|
-
function legacySystemdUnitPath() {
|
|
3377
|
-
return path4.join(os4.homedir(), ".config/systemd/user", LEGACY_LINUX_UNIT);
|
|
3378
|
-
}
|
|
3379
|
-
function serviceEnv() {
|
|
3380
|
-
const env = { PATH: process.env.PATH ?? "" };
|
|
3381
|
-
if (process.env.LUMI_RUNNER_HOME || process.env.CREW_RUNNER_HOME) {
|
|
3382
|
-
env.LUMI_RUNNER_HOME = configDir();
|
|
3383
|
-
}
|
|
3384
|
-
return env;
|
|
3385
|
-
}
|
|
3386
|
-
function removeLegacyService() {
|
|
3387
|
-
const removed = [];
|
|
3388
|
-
if (process.platform === "darwin") {
|
|
3389
|
-
const unitPath = legacyLaunchAgentPath();
|
|
3390
|
-
if (fs4.existsSync(unitPath)) {
|
|
3391
|
-
run("launchctl", ["bootout", `gui/${uid()}/${LEGACY_SERVICE_LABEL}`]);
|
|
3392
|
-
fs4.rmSync(unitPath, { force: true });
|
|
3393
|
-
removed.push(unitPath);
|
|
3807
|
+
updateDrainTimer.unref();
|
|
3808
|
+
if (running.size === 0) void applyUpdate();
|
|
3809
|
+
}
|
|
3810
|
+
function abandonUpdate(why) {
|
|
3811
|
+
if (!updating) return;
|
|
3812
|
+
const { target } = updating;
|
|
3813
|
+
updating = null;
|
|
3814
|
+
updateAvailableVersion = null;
|
|
3815
|
+
updatePhase = null;
|
|
3816
|
+
if (updateDrainTimer) {
|
|
3817
|
+
clearTimeout(updateDrainTimer);
|
|
3818
|
+
updateDrainTimer = null;
|
|
3394
3819
|
}
|
|
3395
|
-
|
|
3396
|
-
|
|
3397
|
-
|
|
3398
|
-
|
|
3399
|
-
if (
|
|
3400
|
-
|
|
3401
|
-
|
|
3402
|
-
|
|
3403
|
-
|
|
3820
|
+
log2(`Update to ${target} abandoned \u2014 ${why}. Claiming again; will retry later.`);
|
|
3821
|
+
poke();
|
|
3822
|
+
}
|
|
3823
|
+
async function applyUpdate() {
|
|
3824
|
+
if (!updating || applyingUpdate || shuttingDown) return;
|
|
3825
|
+
applyingUpdate = true;
|
|
3826
|
+
const { target } = updating;
|
|
3827
|
+
if (updateDrainTimer) {
|
|
3828
|
+
clearTimeout(updateDrainTimer);
|
|
3829
|
+
updateDrainTimer = null;
|
|
3830
|
+
}
|
|
3831
|
+
try {
|
|
3832
|
+
updatePhase = "installing";
|
|
3833
|
+
log2(`No jobs in flight \u2014 installing ${PACKAGE_NAME}@${target}\u2026`);
|
|
3834
|
+
await heartbeat();
|
|
3835
|
+
const previous = readUpdateState();
|
|
3836
|
+
const attempts = previous?.target === target ? (previous.attempts ?? 0) + 1 : 1;
|
|
3837
|
+
writeUpdateState({ target, from: RUNNER_VERSION, attempts, at: Date.now() });
|
|
3838
|
+
const result = await installGlobal(target, cliPath());
|
|
3839
|
+
if (!result.ok) {
|
|
3840
|
+
applyingUpdate = false;
|
|
3841
|
+
abandonUpdate(`npm install failed: ${result.detail.slice(0, 200)}`);
|
|
3842
|
+
return;
|
|
3843
|
+
}
|
|
3844
|
+
const onDisk = installedVersionAt(cliPath());
|
|
3845
|
+
if (onDisk !== target) {
|
|
3846
|
+
applyingUpdate = false;
|
|
3847
|
+
abandonUpdate(
|
|
3848
|
+
`npm reported success but ${cliPath()} is still ${onDisk ?? "unreadable"} \u2014 the install probably went to a different npm prefix`
|
|
3849
|
+
);
|
|
3850
|
+
return;
|
|
3851
|
+
}
|
|
3852
|
+
log2(`Updated to ${target} \u2014 restarting to pick it up.`);
|
|
3853
|
+
await shutdown(`Updated to ${target}`);
|
|
3854
|
+
} catch (e) {
|
|
3855
|
+
applyingUpdate = false;
|
|
3856
|
+
abandonUpdate(`the install threw: ${e instanceof Error ? e.message : e}`);
|
|
3404
3857
|
}
|
|
3405
|
-
return removed;
|
|
3406
3858
|
}
|
|
3407
|
-
|
|
3408
|
-
|
|
3409
|
-
|
|
3410
|
-
|
|
3859
|
+
{
|
|
3860
|
+
const verdict = reconcile(readUpdateState(), RUNNER_VERSION);
|
|
3861
|
+
if (verdict.action === "clear") {
|
|
3862
|
+
log2(`Self-update to ${verdict.target} completed.`);
|
|
3863
|
+
clearUpdateState();
|
|
3864
|
+
} else if (verdict.action === "keep") {
|
|
3865
|
+
log2(
|
|
3866
|
+
`A previous self-update to ${verdict.state.target} did not take \u2014 still on ${RUNNER_VERSION} (attempt ${verdict.state.attempts} of ${MAX_UPDATE_ATTEMPTS}).`
|
|
3867
|
+
);
|
|
3411
3868
|
}
|
|
3412
|
-
return removed;
|
|
3413
3869
|
}
|
|
3414
|
-
|
|
3415
|
-
|
|
3416
|
-
|
|
3417
|
-
function isTransientBootstrapError(out) {
|
|
3418
|
-
const text = out.toLowerCase();
|
|
3419
|
-
return (
|
|
3420
|
-
// The observed one: `Bootstrap failed: 5: Input/output error`.
|
|
3421
|
-
text.includes("input/output error") || // Same cause, different report: the old generation is still registered.
|
|
3422
|
-
text.includes("service already loaded") || text.includes("service is already loaded") || text.includes("eexist") || // launchd is mid-teardown and says so.
|
|
3423
|
-
text.includes("operation already in progress") || text.includes("operation now in progress")
|
|
3870
|
+
const caps = [...serving].map((s) => `${s}:${shipCap(config2, s)}`).join(" ");
|
|
3871
|
+
log2(
|
|
3872
|
+
`Runner online \u2014 watching ${serving.size} Ship(s), up to ${machineCap(config2)} job(s) at once (${caps}), MCP: ${mcpUrl(config2)}`
|
|
3424
3873
|
);
|
|
3874
|
+
log2(
|
|
3875
|
+
`Version ${RUNNER_VERSION} on the ${resolveChannel(RUNNER_VERSION, config2, process.env)} channel; auto-update ${config2.autoUpdate === false ? "off" : "on"}.`
|
|
3876
|
+
);
|
|
3877
|
+
await new Promise(() => {
|
|
3878
|
+
});
|
|
3425
3879
|
}
|
|
3426
|
-
|
|
3427
|
-
|
|
3428
|
-
|
|
3429
|
-
|
|
3430
|
-
|
|
3431
|
-
|
|
3880
|
+
|
|
3881
|
+
// src/cli/ui.ts
|
|
3882
|
+
import * as clack from "@clack/prompts";
|
|
3883
|
+
import { createColors } from "picocolors";
|
|
3884
|
+
var CliError = class extends Error {
|
|
3885
|
+
constructor(message, exitCode = 1) {
|
|
3886
|
+
super(message);
|
|
3887
|
+
this.exitCode = exitCode;
|
|
3888
|
+
this.name = "CliError";
|
|
3432
3889
|
}
|
|
3433
|
-
|
|
3434
|
-
}
|
|
3435
|
-
|
|
3436
|
-
|
|
3437
|
-
|
|
3438
|
-
|
|
3439
|
-
|
|
3440
|
-
|
|
3441
|
-
|
|
3442
|
-
|
|
3443
|
-
|
|
3444
|
-
|
|
3445
|
-
|
|
3446
|
-
|
|
3447
|
-
|
|
3448
|
-
|
|
3449
|
-
|
|
3450
|
-
|
|
3451
|
-
|
|
3452
|
-
|
|
3453
|
-
</array>
|
|
3454
|
-
<key>RunAtLoad</key>
|
|
3455
|
-
<true/>
|
|
3456
|
-
<key>KeepAlive</key>
|
|
3457
|
-
<true/>
|
|
3458
|
-
<key>ExitTimeOut</key>
|
|
3459
|
-
<integer>20</integer>
|
|
3460
|
-
<key>EnvironmentVariables</key>
|
|
3461
|
-
<dict>
|
|
3462
|
-
${envEntries}
|
|
3463
|
-
</dict>
|
|
3464
|
-
<key>StandardOutPath</key>
|
|
3465
|
-
<string>${escapeXml(path4.join(logDir(), "service.out.log"))}</string>
|
|
3466
|
-
<key>StandardErrorPath</key>
|
|
3467
|
-
<string>${escapeXml(path4.join(logDir(), "service.err.log"))}</string>
|
|
3468
|
-
</dict>
|
|
3469
|
-
</plist>
|
|
3470
|
-
`;
|
|
3890
|
+
exitCode;
|
|
3891
|
+
};
|
|
3892
|
+
var jsonMode = false;
|
|
3893
|
+
var assumeYes = false;
|
|
3894
|
+
var palette = createColors();
|
|
3895
|
+
var delegate = (name) => (input) => palette[name](input);
|
|
3896
|
+
var pc = {
|
|
3897
|
+
bold: delegate("bold"),
|
|
3898
|
+
dim: delegate("dim"),
|
|
3899
|
+
black: delegate("black"),
|
|
3900
|
+
red: delegate("red"),
|
|
3901
|
+
green: delegate("green"),
|
|
3902
|
+
yellow: delegate("yellow"),
|
|
3903
|
+
cyan: delegate("cyan"),
|
|
3904
|
+
bgCyan: delegate("bgCyan")
|
|
3905
|
+
};
|
|
3906
|
+
function configureOutput(options) {
|
|
3907
|
+
jsonMode = options.json === true;
|
|
3908
|
+
assumeYes = options.yes === true;
|
|
3909
|
+
if (options.color === false || jsonMode) palette = createColors(false);
|
|
3471
3910
|
}
|
|
3472
|
-
function
|
|
3473
|
-
return
|
|
3911
|
+
function isJson() {
|
|
3912
|
+
return jsonMode;
|
|
3474
3913
|
}
|
|
3475
|
-
function
|
|
3476
|
-
|
|
3914
|
+
function emitJson(value) {
|
|
3915
|
+
process.stdout.write(`${JSON.stringify(value, null, 2)}
|
|
3916
|
+
`);
|
|
3477
3917
|
}
|
|
3478
|
-
function
|
|
3479
|
-
|
|
3480
|
-
if (!block) return void 0;
|
|
3481
|
-
const args = [...block[1].matchAll(/<string>([\s\S]*?)<\/string>/g)].map((m) => unescapeXml(m[1]));
|
|
3482
|
-
return args.length >= 2 ? args[1] : void 0;
|
|
3918
|
+
function canPrompt() {
|
|
3919
|
+
return !jsonMode && process.stdin.isTTY === true && process.stdout.isTTY === true;
|
|
3483
3920
|
}
|
|
3484
|
-
function
|
|
3485
|
-
|
|
3486
|
-
|
|
3487
|
-
|
|
3488
|
-
|
|
3489
|
-
const parts = command.slice(0, -" start".length).split(" ");
|
|
3490
|
-
return parts.length === 2 ? parts[1] : void 0;
|
|
3921
|
+
function requireInteractive(what, flagHint) {
|
|
3922
|
+
if (canPrompt()) return;
|
|
3923
|
+
throw new CliError(
|
|
3924
|
+
`${what} needs an interactive terminal. Run it in a terminal, or pass ${pc.bold(flagHint)}.`
|
|
3925
|
+
);
|
|
3491
3926
|
}
|
|
3492
|
-
|
|
3493
|
-
|
|
3494
|
-
|
|
3495
|
-
|
|
3496
|
-
|
|
3497
|
-
|
|
3927
|
+
var say = {
|
|
3928
|
+
intro(title) {
|
|
3929
|
+
if (!jsonMode) clack.intro(pc.bgCyan(pc.black(` ${title} `)));
|
|
3930
|
+
},
|
|
3931
|
+
outro(message) {
|
|
3932
|
+
if (!jsonMode) clack.outro(message);
|
|
3933
|
+
},
|
|
3934
|
+
info(message) {
|
|
3935
|
+
if (!jsonMode) clack.log.info(message);
|
|
3936
|
+
},
|
|
3937
|
+
success(message) {
|
|
3938
|
+
if (!jsonMode) clack.log.success(message);
|
|
3939
|
+
},
|
|
3940
|
+
warn(message) {
|
|
3941
|
+
if (!jsonMode) clack.log.warn(message);
|
|
3942
|
+
},
|
|
3943
|
+
error(message) {
|
|
3944
|
+
if (!jsonMode) clack.log.error(message);
|
|
3945
|
+
},
|
|
3946
|
+
step(message) {
|
|
3947
|
+
if (!jsonMode) clack.log.step(message);
|
|
3948
|
+
},
|
|
3949
|
+
note(body, title) {
|
|
3950
|
+
if (!jsonMode) clack.note(body, title);
|
|
3951
|
+
},
|
|
3952
|
+
/** Raw line straight to stdout — for `logs`, where the content IS the output. */
|
|
3953
|
+
line(message) {
|
|
3954
|
+
process.stdout.write(`${message}
|
|
3955
|
+
`);
|
|
3498
3956
|
}
|
|
3499
|
-
|
|
3500
|
-
|
|
3501
|
-
|
|
3502
|
-
|
|
3503
|
-
|
|
3504
|
-
|
|
3505
|
-
|
|
3506
|
-
return `[Unit]
|
|
3507
|
-
Description=Lumi Crew runner daemon
|
|
3508
|
-
After=network-online.target
|
|
3509
|
-
|
|
3510
|
-
[Service]
|
|
3511
|
-
Type=simple
|
|
3512
|
-
ExecStart=${process.execPath} ${cliPath()} start
|
|
3513
|
-
Restart=always
|
|
3514
|
-
RestartSec=5
|
|
3515
|
-
# Must exceed the daemon's SHUTDOWN_GRACE_MS: a SIGKILL mid-release is exactly the stranded
|
|
3516
|
-
# job the graceful shutdown exists to prevent.
|
|
3517
|
-
TimeoutStopSec=20
|
|
3518
|
-
${envLines}
|
|
3519
|
-
|
|
3520
|
-
[Install]
|
|
3521
|
-
WantedBy=default.target
|
|
3522
|
-
`;
|
|
3523
|
-
}
|
|
3524
|
-
function serviceStatus() {
|
|
3525
|
-
if (process.platform === "darwin") {
|
|
3526
|
-
const unitPath = launchAgentPath();
|
|
3527
|
-
if (!fs4.existsSync(unitPath)) return { state: "not-installed", detail: "No LaunchAgent installed." };
|
|
3528
|
-
const exec = execFacts(unitPath, parsePlistCliPath);
|
|
3529
|
-
const printed = run("launchctl", ["print", `gui/${uid()}/${SERVICE_LABEL}`]);
|
|
3530
|
-
if (!printed.ok) {
|
|
3531
|
-
return { state: "installed", detail: "LaunchAgent present but not loaded.", unitPath, ...exec };
|
|
3532
|
-
}
|
|
3533
|
-
const running = /\bpid = \d+/.test(printed.out);
|
|
3534
|
-
return {
|
|
3535
|
-
state: running ? "running" : "installed",
|
|
3536
|
-
detail: running ? "LaunchAgent loaded and running." : "LaunchAgent loaded but not running.",
|
|
3537
|
-
unitPath,
|
|
3538
|
-
...exec
|
|
3539
|
-
};
|
|
3957
|
+
};
|
|
3958
|
+
function spinner2() {
|
|
3959
|
+
if (jsonMode) {
|
|
3960
|
+
return { start: () => {
|
|
3961
|
+
}, update: () => {
|
|
3962
|
+
}, stop: () => {
|
|
3963
|
+
} };
|
|
3540
3964
|
}
|
|
3541
|
-
if (
|
|
3542
|
-
const unitPath = systemdUnitPath();
|
|
3543
|
-
if (!fs4.existsSync(unitPath)) return { state: "not-installed", detail: "No systemd user unit installed." };
|
|
3544
|
-
const active = run("systemctl", ["--user", "is-active", LINUX_UNIT]);
|
|
3965
|
+
if (!canPrompt()) {
|
|
3545
3966
|
return {
|
|
3546
|
-
|
|
3547
|
-
|
|
3548
|
-
|
|
3549
|
-
|
|
3967
|
+
start: (msg) => process.stderr.write(`${msg}
|
|
3968
|
+
`),
|
|
3969
|
+
update: (msg) => process.stderr.write(`${msg}
|
|
3970
|
+
`),
|
|
3971
|
+
stop: (msg) => msg && process.stderr.write(`${msg}
|
|
3972
|
+
`)
|
|
3550
3973
|
};
|
|
3551
3974
|
}
|
|
3552
|
-
|
|
3553
|
-
|
|
3554
|
-
|
|
3555
|
-
|
|
3556
|
-
|
|
3557
|
-
|
|
3558
|
-
|
|
3559
|
-
|
|
3975
|
+
const s = clack.spinner();
|
|
3976
|
+
return {
|
|
3977
|
+
start: (msg) => s.start(msg),
|
|
3978
|
+
update: (msg) => s.message(msg),
|
|
3979
|
+
stop: (msg) => s.stop(msg)
|
|
3980
|
+
};
|
|
3981
|
+
}
|
|
3982
|
+
function unwrap(value) {
|
|
3983
|
+
if (clack.isCancel(value)) throw new CliError("Cancelled.", 130);
|
|
3984
|
+
return value;
|
|
3985
|
+
}
|
|
3986
|
+
async function promptMultiSelect(options) {
|
|
3987
|
+
requireInteractive(options.message, options.flagHint);
|
|
3988
|
+
return unwrap(
|
|
3989
|
+
await clack.multiselect({
|
|
3990
|
+
message: options.message,
|
|
3991
|
+
options: options.choices,
|
|
3992
|
+
initialValues: options.initialValues,
|
|
3993
|
+
required: options.required ?? false
|
|
3994
|
+
})
|
|
3995
|
+
);
|
|
3996
|
+
}
|
|
3997
|
+
async function promptConfirm(options) {
|
|
3998
|
+
if (assumeYes && options.yesFlagApplies !== false) return true;
|
|
3999
|
+
requireInteractive(options.message, "--yes");
|
|
4000
|
+
return unwrap(await clack.confirm({ message: options.message, initialValue: options.initialValue }));
|
|
4001
|
+
}
|
|
4002
|
+
function glyph(level) {
|
|
4003
|
+
if (level === "ok") return pc.green("\u2714");
|
|
4004
|
+
if (level === "warn") return pc.yellow("\u25B2");
|
|
4005
|
+
return pc.red("\u2716");
|
|
4006
|
+
}
|
|
4007
|
+
|
|
4008
|
+
// src/cli/commands/config.ts
|
|
4009
|
+
var TOGGLES = {
|
|
4010
|
+
notifications: "Desktop notifications when a job starts, finishes or fails",
|
|
4011
|
+
keepAwake: "Keep this machine awake while a job is running",
|
|
4012
|
+
autoUpdate: "Install new versions of the runner by itself and restart (PRD \xA715.37)"
|
|
4013
|
+
};
|
|
4014
|
+
var PARALLEL = "parallel";
|
|
4015
|
+
var PARALLEL_HELP = "How many jobs to run at once (1 = one at a time, as before)";
|
|
4016
|
+
var CHANNEL = "channel";
|
|
4017
|
+
var CHANNEL_HELP = "Which npm release channel to follow (default: derived from the version)";
|
|
4018
|
+
var CHECK_EVERY = "checkEvery";
|
|
4019
|
+
var CHECK_EVERY_HELP = `Minutes between update checks (${CHECK_MINUTES_MIN}-${CHECK_MINUTES_MAX})`;
|
|
4020
|
+
var DEFAULT_WORDS = ["default", "auto", "reset"];
|
|
4021
|
+
function isToggle(key) {
|
|
4022
|
+
return Object.hasOwn(TOGGLES, key);
|
|
4023
|
+
}
|
|
4024
|
+
function parseChannel(value) {
|
|
4025
|
+
if (DEFAULT_WORDS.includes(value.toLowerCase())) return null;
|
|
4026
|
+
const lower = value.toLowerCase();
|
|
4027
|
+
if (CHANNELS.includes(lower)) return lower;
|
|
4028
|
+
throw new CliError(`Expected ${CHANNELS.join(" or ")} (or "default"), got "${value}".`);
|
|
4029
|
+
}
|
|
4030
|
+
function parseCheckEvery(value) {
|
|
4031
|
+
if (DEFAULT_WORDS.includes(value.toLowerCase())) return null;
|
|
4032
|
+
const n = Number(value);
|
|
4033
|
+
if (!Number.isInteger(n) || n < CHECK_MINUTES_MIN || n > CHECK_MINUTES_MAX) {
|
|
4034
|
+
throw new CliError(
|
|
4035
|
+
`Expected a whole number of minutes from ${CHECK_MINUTES_MIN} to ${CHECK_MINUTES_MAX} (or "default"), got "${value}".`
|
|
4036
|
+
);
|
|
4037
|
+
}
|
|
4038
|
+
return n;
|
|
4039
|
+
}
|
|
4040
|
+
function parseBool(value) {
|
|
4041
|
+
if (["on", "true", "yes", "1"].includes(value.toLowerCase())) return true;
|
|
4042
|
+
if (["off", "false", "no", "0"].includes(value.toLowerCase())) return false;
|
|
4043
|
+
throw new CliError(`Expected on/off, got "${value}".`);
|
|
4044
|
+
}
|
|
4045
|
+
function parseParallel(value) {
|
|
4046
|
+
if (DEFAULT_WORDS.includes(value.toLowerCase())) return null;
|
|
4047
|
+
const n = Number(value);
|
|
4048
|
+
if (!Number.isInteger(n) || n < 1 || n > PARALLEL_MAX) {
|
|
4049
|
+
throw new CliError(
|
|
4050
|
+
`Expected a whole number from 1 to ${PARALLEL_MAX} (or "default"), got "${value}".`
|
|
4051
|
+
);
|
|
3560
4052
|
}
|
|
3561
|
-
return
|
|
4053
|
+
return n;
|
|
3562
4054
|
}
|
|
3563
|
-
function
|
|
3564
|
-
const
|
|
3565
|
-
|
|
3566
|
-
|
|
3567
|
-
|
|
4055
|
+
async function runConfigList() {
|
|
4056
|
+
const config2 = requireConfig();
|
|
4057
|
+
const values = Object.fromEntries(
|
|
4058
|
+
Object.keys(TOGGLES).map((key) => [key, config2[key] !== false])
|
|
4059
|
+
);
|
|
4060
|
+
const machine = machineCap(config2);
|
|
4061
|
+
const ships = config2.ships.map((shipId) => ({
|
|
4062
|
+
shipId,
|
|
4063
|
+
parallel: shipCap(config2, shipId),
|
|
4064
|
+
explicit: config2.shipParallelJobs?.[shipId] !== void 0
|
|
4065
|
+
}));
|
|
4066
|
+
const channel = resolveChannel(RUNNER_VERSION, config2, process.env);
|
|
4067
|
+
const checkEvery = checkIntervalMs(config2) / 6e4;
|
|
4068
|
+
if (isJson()) {
|
|
4069
|
+
emitJson({ configDir: configDir(), ...values, parallel: machine, channel, checkEvery, ships });
|
|
4070
|
+
return 0;
|
|
3568
4071
|
}
|
|
3569
|
-
|
|
3570
|
-
|
|
3571
|
-
|
|
3572
|
-
|
|
3573
|
-
run("launchctl", ["bootout", `gui/${uid()}/${SERVICE_LABEL}`]);
|
|
3574
|
-
const boot = retryWhileTransient(
|
|
3575
|
-
() => run("launchctl", ["bootstrap", `gui/${uid()}`, unitPath]),
|
|
3576
|
-
sleepSync
|
|
3577
|
-
);
|
|
3578
|
-
if (!boot.ok) throw new ServiceError(`launchctl bootstrap failed: ${boot.out}`);
|
|
3579
|
-
return { unitPath, notes };
|
|
4072
|
+
say.line(` ${configDir()}`);
|
|
4073
|
+
say.line("");
|
|
4074
|
+
for (const key of Object.keys(TOGGLES)) {
|
|
4075
|
+
say.line(` ${key.padEnd(16)} ${values[key] ? "on" : "off"} ${TOGGLES[key]}`);
|
|
3580
4076
|
}
|
|
3581
|
-
|
|
3582
|
-
|
|
3583
|
-
|
|
3584
|
-
|
|
3585
|
-
|
|
3586
|
-
|
|
3587
|
-
|
|
3588
|
-
|
|
3589
|
-
const linger = run("loginctl", ["show-user", os4.userInfo().username, "--property=Linger"]);
|
|
3590
|
-
if (!linger.out.includes("Linger=yes")) {
|
|
3591
|
-
notes.push(
|
|
3592
|
-
`Run \`sudo loginctl enable-linger ${os4.userInfo().username}\` so the daemon survives logout and starts at boot.`
|
|
3593
|
-
);
|
|
4077
|
+
say.line(` ${PARALLEL.padEnd(16)} ${String(machine).padEnd(2)} ${PARALLEL_HELP}`);
|
|
4078
|
+
say.line(` ${CHANNEL.padEnd(16)} ${channel.padEnd(2)} ${CHANNEL_HELP}`);
|
|
4079
|
+
say.line(` ${CHECK_EVERY.padEnd(16)} ${String(checkEvery).padEnd(2)} ${CHECK_EVERY_HELP}`);
|
|
4080
|
+
if (ships.length > 0) {
|
|
4081
|
+
say.line("");
|
|
4082
|
+
for (const ship2 of ships) {
|
|
4083
|
+
const note2 = ship2.explicit ? "" : " (machine default)";
|
|
4084
|
+
say.line(` ${`${PARALLEL} --ship`.padEnd(16)} ${ship2.shipId} = ${ship2.parallel}${note2}`);
|
|
3594
4085
|
}
|
|
3595
|
-
return { unitPath, notes };
|
|
3596
4086
|
}
|
|
3597
|
-
|
|
3598
|
-
|
|
3599
|
-
|
|
3600
|
-
|
|
3601
|
-
|
|
3602
|
-
|
|
3603
|
-
|
|
3604
|
-
|
|
3605
|
-
"
|
|
3606
|
-
"ONLOGON",
|
|
3607
|
-
"/RL",
|
|
3608
|
-
"LIMITED",
|
|
3609
|
-
"/F"
|
|
3610
|
-
]);
|
|
3611
|
-
if (!create.ok) throw new ServiceError(`schtasks /Create failed: ${create.out}`);
|
|
3612
|
-
notes.push(
|
|
3613
|
-
"Task Scheduler starts the daemon at logon but does not restart it if it exits. For a true always-on box, run the daemon under pm2 or a Windows service wrapper instead."
|
|
4087
|
+
return 0;
|
|
4088
|
+
}
|
|
4089
|
+
async function runConfigSet(key, value, options = {}) {
|
|
4090
|
+
const config2 = requireConfig();
|
|
4091
|
+
if (key === PARALLEL) return setParallel(config2, value, options.ship);
|
|
4092
|
+
if (key === CHANNEL || key === CHECK_EVERY) return setUpdateSetting(config2, key, value, options.ship);
|
|
4093
|
+
if (!isToggle(key)) {
|
|
4094
|
+
throw new CliError(
|
|
4095
|
+
`Unknown setting "${key}". Known: ${[...Object.keys(TOGGLES), PARALLEL, CHANNEL, CHECK_EVERY].join(", ")}.`
|
|
3614
4096
|
);
|
|
3615
|
-
return { unitPath: WINDOWS_TASK, notes };
|
|
3616
4097
|
}
|
|
3617
|
-
|
|
4098
|
+
if (options.ship) {
|
|
4099
|
+
throw new CliError(`"${key}" is a machine-wide setting \u2014 it takes no --ship.`);
|
|
4100
|
+
}
|
|
4101
|
+
config2[key] = parseBool(value);
|
|
4102
|
+
saveConfig(config2);
|
|
4103
|
+
if (isJson()) {
|
|
4104
|
+
emitJson({ [key]: config2[key] });
|
|
4105
|
+
return 0;
|
|
4106
|
+
}
|
|
4107
|
+
say.success(`${key} = ${config2[key] ? "on" : "off"}`);
|
|
4108
|
+
return 0;
|
|
3618
4109
|
}
|
|
3619
|
-
function
|
|
3620
|
-
|
|
3621
|
-
if (
|
|
3622
|
-
|
|
3623
|
-
|
|
3624
|
-
|
|
4110
|
+
function setUpdateSetting(config2, key, value, ship2) {
|
|
4111
|
+
if (ship2) throw new CliError(`"${key}" is a machine-wide setting \u2014 it takes no --ship.`);
|
|
4112
|
+
if (key === CHANNEL) {
|
|
4113
|
+
const parsed = parseChannel(value);
|
|
4114
|
+
if (parsed === null) delete config2.updateChannel;
|
|
4115
|
+
else config2.updateChannel = parsed;
|
|
4116
|
+
} else {
|
|
4117
|
+
const parsed = parseCheckEvery(value);
|
|
4118
|
+
if (parsed === null) delete config2.updateCheckMinutes;
|
|
4119
|
+
else config2.updateCheckMinutes = parsed;
|
|
3625
4120
|
}
|
|
3626
|
-
|
|
3627
|
-
|
|
3628
|
-
|
|
3629
|
-
|
|
3630
|
-
|
|
4121
|
+
saveConfig(config2);
|
|
4122
|
+
const channel = resolveChannel(RUNNER_VERSION, config2, process.env);
|
|
4123
|
+
const minutes = checkIntervalMs(config2) / 6e4;
|
|
4124
|
+
if (isJson()) {
|
|
4125
|
+
emitJson({ channel, checkEvery: minutes });
|
|
4126
|
+
return 0;
|
|
3631
4127
|
}
|
|
3632
|
-
|
|
3633
|
-
|
|
3634
|
-
|
|
4128
|
+
say.success(key === CHANNEL ? `${CHANNEL} = ${channel}` : `${CHECK_EVERY} = ${minutes}`);
|
|
4129
|
+
if (key === CHANNEL && config2.updateChannel === void 0) {
|
|
4130
|
+
say.line(` Derived from the installed version (${RUNNER_VERSION}).`);
|
|
3635
4131
|
}
|
|
3636
|
-
|
|
4132
|
+
if (key === CHANNEL && parseChannel(value) === "latest" && RUNNER_VERSION.includes("-")) {
|
|
4133
|
+
say.line(
|
|
4134
|
+
` This machine is on a prerelease (${RUNNER_VERSION}). If \`latest\` is behind it, nothing will install until a stable release passes it \u2014 to move now: \`npm i -g @lumi.ai/runner@latest\`.`
|
|
4135
|
+
);
|
|
4136
|
+
}
|
|
4137
|
+
say.line(" Restart the daemon for this to take effect: `lumi-runner service restart`.");
|
|
4138
|
+
return 0;
|
|
3637
4139
|
}
|
|
3638
|
-
function
|
|
3639
|
-
|
|
3640
|
-
|
|
3641
|
-
if (!
|
|
3642
|
-
|
|
4140
|
+
function setParallel(config2, value, ship2) {
|
|
4141
|
+
const parsed = parseParallel(value);
|
|
4142
|
+
if (ship2) {
|
|
4143
|
+
if (!config2.ships.includes(ship2)) {
|
|
4144
|
+
throw new CliError(
|
|
4145
|
+
`This machine does not serve Ship "${ship2}". Run \`lumi-runner ship list\` to see which Ships it serves, or \`lumi-runner ship add ${ship2}\` to add it.`
|
|
4146
|
+
);
|
|
4147
|
+
}
|
|
4148
|
+
const overrides = { ...config2.shipParallelJobs ?? {} };
|
|
4149
|
+
if (parsed === null) delete overrides[ship2];
|
|
4150
|
+
else overrides[ship2] = parsed;
|
|
4151
|
+
config2.shipParallelJobs = overrides;
|
|
4152
|
+
} else if (parsed === null) {
|
|
4153
|
+
delete config2.maxParallelJobs;
|
|
4154
|
+
} else {
|
|
4155
|
+
config2.maxParallelJobs = parsed;
|
|
3643
4156
|
}
|
|
3644
|
-
|
|
3645
|
-
|
|
3646
|
-
|
|
3647
|
-
|
|
4157
|
+
saveConfig(config2);
|
|
4158
|
+
const effective = ship2 ? shipCap(config2, ship2) : machineCap(config2);
|
|
4159
|
+
if (isJson()) {
|
|
4160
|
+
emitJson({ parallel: effective, ...ship2 ? { shipId: ship2 } : {} });
|
|
4161
|
+
return 0;
|
|
3648
4162
|
}
|
|
3649
|
-
|
|
3650
|
-
|
|
3651
|
-
|
|
3652
|
-
|
|
3653
|
-
|
|
4163
|
+
const machine = machineCap(config2);
|
|
4164
|
+
say.success(
|
|
4165
|
+
ship2 ? `${PARALLEL} for Ship ${ship2} = ${effective}${parsed === null ? " (machine default)" : ""}` : `${PARALLEL} = ${effective}`
|
|
4166
|
+
);
|
|
4167
|
+
if (ship2 && parsed !== null && parsed > machine) {
|
|
4168
|
+
say.line(
|
|
4169
|
+
` Capped at the machine-wide limit of ${machine}. Raise it with \`lumi-runner config set ${PARALLEL} ${parsed}\`.`
|
|
4170
|
+
);
|
|
3654
4171
|
}
|
|
3655
|
-
|
|
4172
|
+
say.line(" Restart the daemon for this to take effect: `lumi-runner service restart`.");
|
|
4173
|
+
return 0;
|
|
3656
4174
|
}
|
|
3657
4175
|
|
|
4176
|
+
// src/cli/commands/doctor.ts
|
|
4177
|
+
import { spawnSync as spawnSync3 } from "node:child_process";
|
|
4178
|
+
import { collection as collection6, doc as doc8, getDoc as getDoc7, getDocs as getDocs5 } from "firebase/firestore";
|
|
4179
|
+
|
|
3658
4180
|
// src/cli/session.ts
|
|
3659
4181
|
async function openShipSession(shipId) {
|
|
3660
4182
|
const config2 = requireConfig();
|
|
@@ -3681,7 +4203,7 @@ var fail = (id, label, detail, fix) => ({
|
|
|
3681
4203
|
});
|
|
3682
4204
|
function onPath(binary) {
|
|
3683
4205
|
const probe = process.platform === "win32" ? "where" : "which";
|
|
3684
|
-
return
|
|
4206
|
+
return spawnSync3(probe, [binary], { stdio: "ignore" }).status === 0;
|
|
3685
4207
|
}
|
|
3686
4208
|
function checkNode() {
|
|
3687
4209
|
const major = Number(process.versions.node.split(".")[0]);
|
|
@@ -3727,6 +4249,33 @@ function serviceCheckFrom(status) {
|
|
|
3727
4249
|
function checkService() {
|
|
3728
4250
|
return serviceCheckFrom(serviceStatus());
|
|
3729
4251
|
}
|
|
4252
|
+
function versionCheckFrom(input) {
|
|
4253
|
+
const { current, latest, channel, autoUpdate } = input;
|
|
4254
|
+
const label = "Runner version";
|
|
4255
|
+
if (current === DEV_VERSION) {
|
|
4256
|
+
return ok("version", label, `${current} \u2014 development build, not checked.`);
|
|
4257
|
+
}
|
|
4258
|
+
if (latest === null) {
|
|
4259
|
+
return warn("version", label, `${current} \u2014 could not reach the npm registry to check.`);
|
|
4260
|
+
}
|
|
4261
|
+
if (!isNewer(latest, current)) return ok("version", label, `${current} (${channel})`);
|
|
4262
|
+
return warn(
|
|
4263
|
+
"version",
|
|
4264
|
+
label,
|
|
4265
|
+
`${current} \u2014 ${latest} is available on \`${channel}\`.`,
|
|
4266
|
+
autoUpdate ? "Nothing to do \u2014 this machine installs it by itself. `lumi-runner update` does it now." : "Auto-update is off. Run `lumi-runner update`."
|
|
4267
|
+
);
|
|
4268
|
+
}
|
|
4269
|
+
async function checkVersion(config2) {
|
|
4270
|
+
const channel = resolveChannel(RUNNER_VERSION, config2, process.env);
|
|
4271
|
+
const tags = await fetchDistTags();
|
|
4272
|
+
return versionCheckFrom({
|
|
4273
|
+
current: RUNNER_VERSION,
|
|
4274
|
+
latest: tags?.[channel] ?? null,
|
|
4275
|
+
channel,
|
|
4276
|
+
autoUpdate: config2.autoUpdate !== false
|
|
4277
|
+
});
|
|
4278
|
+
}
|
|
3730
4279
|
async function checkShips(config2) {
|
|
3731
4280
|
const checks = [];
|
|
3732
4281
|
const engines = /* @__PURE__ */ new Set();
|
|
@@ -3754,7 +4303,7 @@ async function checkShips(config2) {
|
|
|
3754
4303
|
continue;
|
|
3755
4304
|
}
|
|
3756
4305
|
try {
|
|
3757
|
-
const snap = await
|
|
4306
|
+
const snap = await getDoc7(
|
|
3758
4307
|
doc8(fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.runners, config2.runnerId)
|
|
3759
4308
|
);
|
|
3760
4309
|
if (!snap.exists()) {
|
|
@@ -3862,6 +4411,7 @@ async function runDoctor() {
|
|
|
3862
4411
|
}
|
|
3863
4412
|
checks.push(await checkMcp(mcpUrl(config2)));
|
|
3864
4413
|
checks.push(checkService());
|
|
4414
|
+
checks.push(await checkVersion(config2));
|
|
3865
4415
|
progress.stop("Checks complete.");
|
|
3866
4416
|
return report(checks);
|
|
3867
4417
|
}
|
|
@@ -3894,7 +4444,7 @@ function report(checks) {
|
|
|
3894
4444
|
}
|
|
3895
4445
|
|
|
3896
4446
|
// src/cli/commands/login.ts
|
|
3897
|
-
import { spawn as
|
|
4447
|
+
import { spawn as spawn5 } from "node:child_process";
|
|
3898
4448
|
import os5 from "node:os";
|
|
3899
4449
|
import { signInWithCustomToken as signInWithCustomToken2 } from "firebase/auth";
|
|
3900
4450
|
function buildLoginResult(approved, existing, fallbackProjectId, mcpUrl2) {
|
|
@@ -3920,7 +4470,7 @@ function buildLoginResult(approved, existing, fallbackProjectId, mcpUrl2) {
|
|
|
3920
4470
|
function openBrowser(url) {
|
|
3921
4471
|
const [command, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
|
|
3922
4472
|
try {
|
|
3923
|
-
const child =
|
|
4473
|
+
const child = spawn5(command, args, { stdio: "ignore", detached: true });
|
|
3924
4474
|
child.on("error", () => {
|
|
3925
4475
|
});
|
|
3926
4476
|
child.unref();
|
|
@@ -4140,20 +4690,20 @@ async function runServiceStatus() {
|
|
|
4140
4690
|
}
|
|
4141
4691
|
|
|
4142
4692
|
// src/cli/commands/uninstall.ts
|
|
4143
|
-
import
|
|
4693
|
+
import fs7 from "node:fs";
|
|
4144
4694
|
async function runUninstall(options) {
|
|
4145
4695
|
const before = serviceStatus();
|
|
4146
4696
|
const dir = configDir();
|
|
4147
4697
|
const hadService = before.state !== "not-installed" && before.state !== "unsupported";
|
|
4148
4698
|
if (hadService) uninstallService();
|
|
4149
4699
|
let purged = false;
|
|
4150
|
-
if (options.purge &&
|
|
4700
|
+
if (options.purge && fs7.existsSync(dir)) {
|
|
4151
4701
|
const confirmed = await promptConfirm({
|
|
4152
4702
|
message: `Delete ${dir}? This machine loses its identity \u2014 a captain has to approve it again after reinstalling.`,
|
|
4153
4703
|
initialValue: false
|
|
4154
4704
|
});
|
|
4155
4705
|
if (confirmed) {
|
|
4156
|
-
|
|
4706
|
+
fs7.rmSync(dir, { recursive: true, force: true });
|
|
4157
4707
|
purged = true;
|
|
4158
4708
|
}
|
|
4159
4709
|
}
|
|
@@ -4179,7 +4729,7 @@ async function runUninstall(options) {
|
|
|
4179
4729
|
}
|
|
4180
4730
|
|
|
4181
4731
|
// src/cli/commands/ship.ts
|
|
4182
|
-
import { doc as doc9, getDoc as
|
|
4732
|
+
import { doc as doc9, getDoc as getDoc8 } from "firebase/firestore";
|
|
4183
4733
|
async function listMyShips() {
|
|
4184
4734
|
const config2 = requireConfig();
|
|
4185
4735
|
const ids = Object.keys(config2.shipKeys ?? {});
|
|
@@ -4187,7 +4737,7 @@ async function listMyShips() {
|
|
|
4187
4737
|
ids.map(async (id) => {
|
|
4188
4738
|
try {
|
|
4189
4739
|
const { fb } = await openShipSession(id);
|
|
4190
|
-
const snap = await
|
|
4740
|
+
const snap = await getDoc8(doc9(fb.db, COLLECTIONS.ships, id));
|
|
4191
4741
|
return { id, ...snap.data() };
|
|
4192
4742
|
} catch {
|
|
4193
4743
|
return null;
|
|
@@ -4314,7 +4864,7 @@ import {
|
|
|
4314
4864
|
collection as collection7,
|
|
4315
4865
|
doc as doc10,
|
|
4316
4866
|
getCountFromServer as getCountFromServer2,
|
|
4317
|
-
getDoc as
|
|
4867
|
+
getDoc as getDoc9,
|
|
4318
4868
|
getDocs as getDocs6,
|
|
4319
4869
|
query as query5,
|
|
4320
4870
|
where as where5
|
|
@@ -4352,7 +4902,7 @@ async function runStatus() {
|
|
|
4352
4902
|
continue;
|
|
4353
4903
|
}
|
|
4354
4904
|
const shipRef = doc10(fb.db, COLLECTIONS.ships, shipId);
|
|
4355
|
-
const mirrorSnap = await
|
|
4905
|
+
const mirrorSnap = await getDoc9(doc10(shipRef, COLLECTIONS.runners, config2.runnerId));
|
|
4356
4906
|
const mirror = mirrorSnap.data();
|
|
4357
4907
|
let queued = 0;
|
|
4358
4908
|
try {
|
|
@@ -4363,7 +4913,7 @@ async function runStatus() {
|
|
|
4363
4913
|
} catch {
|
|
4364
4914
|
queued = 0;
|
|
4365
4915
|
}
|
|
4366
|
-
const usageSnap = await
|
|
4916
|
+
const usageSnap = await getDoc9(doc10(shipRef, COLLECTIONS.usageDaily, utcDay(Date.now())));
|
|
4367
4917
|
const today = { ...EMPTY_USAGE_TOTALS, ...usageSnap.data()?.totals ?? {} };
|
|
4368
4918
|
const now = Date.now();
|
|
4369
4919
|
const limitsSnap = await getDocs6(collection7(shipRef, COLLECTIONS.engineLimits));
|
|
@@ -4427,6 +4977,106 @@ async function runStatus() {
|
|
|
4427
4977
|
return 0;
|
|
4428
4978
|
}
|
|
4429
4979
|
|
|
4980
|
+
// src/cli/commands/update.ts
|
|
4981
|
+
function pickChannel(option) {
|
|
4982
|
+
if (option) {
|
|
4983
|
+
const lower = option.toLowerCase();
|
|
4984
|
+
if (!CHANNELS.includes(lower)) {
|
|
4985
|
+
throw new CliError(`Expected ${CHANNELS.join(" or ")}, got "${option}".`);
|
|
4986
|
+
}
|
|
4987
|
+
return lower;
|
|
4988
|
+
}
|
|
4989
|
+
return resolveChannel(RUNNER_VERSION, loadConfig() ?? {}, process.env);
|
|
4990
|
+
}
|
|
4991
|
+
async function runUpdate(options = {}) {
|
|
4992
|
+
if (RUNNER_VERSION === DEV_VERSION) {
|
|
4993
|
+
if (isJson()) {
|
|
4994
|
+
emitJson({ current: RUNNER_VERSION, updateAvailable: null, reason: "dev-build" });
|
|
4995
|
+
return 0;
|
|
4996
|
+
}
|
|
4997
|
+
say.info("Development build \u2014 nothing to update.");
|
|
4998
|
+
return 0;
|
|
4999
|
+
}
|
|
5000
|
+
const channel = pickChannel(options.channel);
|
|
5001
|
+
const tags = await fetchDistTags();
|
|
5002
|
+
const latest = tags?.[channel] ?? null;
|
|
5003
|
+
if (latest === null) {
|
|
5004
|
+
const reason = tags === null ? "the npm registry could not be reached" : `nothing is published on \`${channel}\``;
|
|
5005
|
+
if (isJson()) {
|
|
5006
|
+
emitJson({ current: RUNNER_VERSION, channel, updateAvailable: null, reason });
|
|
5007
|
+
return options.check ? 0 : 1;
|
|
5008
|
+
}
|
|
5009
|
+
if (options.check) {
|
|
5010
|
+
say.info(`Could not check for updates \u2014 ${reason}.`);
|
|
5011
|
+
return 0;
|
|
5012
|
+
}
|
|
5013
|
+
throw new CliError(`Could not update \u2014 ${reason}.`);
|
|
5014
|
+
}
|
|
5015
|
+
if (!isNewer(latest, RUNNER_VERSION)) {
|
|
5016
|
+
if (isJson()) {
|
|
5017
|
+
emitJson({ current: RUNNER_VERSION, latest, channel, updateAvailable: null });
|
|
5018
|
+
return 0;
|
|
5019
|
+
}
|
|
5020
|
+
say.success(`Already up to date \u2014 ${RUNNER_VERSION} on \`${channel}\`.`);
|
|
5021
|
+
return 0;
|
|
5022
|
+
}
|
|
5023
|
+
if (options.check) {
|
|
5024
|
+
if (isJson()) {
|
|
5025
|
+
emitJson({ current: RUNNER_VERSION, latest, channel, updateAvailable: latest });
|
|
5026
|
+
return 0;
|
|
5027
|
+
}
|
|
5028
|
+
say.info(`${RUNNER_VERSION} \u2192 ${latest} is available on \`${channel}\`.`);
|
|
5029
|
+
say.line(" Install it with: lumi-runner update");
|
|
5030
|
+
return 0;
|
|
5031
|
+
}
|
|
5032
|
+
const args = installArgs(latest, cliPath());
|
|
5033
|
+
if (process.platform === "win32") {
|
|
5034
|
+
if (isJson()) {
|
|
5035
|
+
emitJson({ current: RUNNER_VERSION, latest, channel, updateAvailable: latest, command: `npm ${args.join(" ")}` });
|
|
5036
|
+
return 0;
|
|
5037
|
+
}
|
|
5038
|
+
say.info(`${RUNNER_VERSION} \u2192 ${latest} is available.`);
|
|
5039
|
+
say.line(" Windows locks the running file, so stop the task first, then install:");
|
|
5040
|
+
say.line(" schtasks /End /TN LumiRunner");
|
|
5041
|
+
say.line(` npm ${args.join(" ")}`);
|
|
5042
|
+
return 0;
|
|
5043
|
+
}
|
|
5044
|
+
if (options.dryRun) {
|
|
5045
|
+
if (isJson()) {
|
|
5046
|
+
emitJson({ current: RUNNER_VERSION, latest, channel, updateAvailable: latest, command: `npm ${args.join(" ")}`, installed: false });
|
|
5047
|
+
return 0;
|
|
5048
|
+
}
|
|
5049
|
+
say.info(`Would run: npm ${args.join(" ")}`);
|
|
5050
|
+
return 0;
|
|
5051
|
+
}
|
|
5052
|
+
if (!npmPresent()) throw new CliError("`npm` is not on PATH, so there is nothing to install with.");
|
|
5053
|
+
say.line(`Installing ${PACKAGE_NAME}@${latest}\u2026`);
|
|
5054
|
+
const result = await installGlobal(latest, cliPath());
|
|
5055
|
+
if (!result.ok) throw new CliError(`npm install failed: ${result.detail.slice(0, 400)}`);
|
|
5056
|
+
const onDisk = installedVersionAt(cliPath());
|
|
5057
|
+
if (onDisk !== latest) {
|
|
5058
|
+
throw new CliError(
|
|
5059
|
+
`npm reported success, but ${cliPath()} is still ${onDisk ?? "unreadable"}. The install probably went to a different npm prefix \u2014 check \`npm prefix -g\` and which node is on PATH.`
|
|
5060
|
+
);
|
|
5061
|
+
}
|
|
5062
|
+
clearUpdateState();
|
|
5063
|
+
const service2 = serviceStatus();
|
|
5064
|
+
let restarted = false;
|
|
5065
|
+
if (service2.state === "running" || service2.state === "installed") {
|
|
5066
|
+
restartService();
|
|
5067
|
+
restarted = true;
|
|
5068
|
+
}
|
|
5069
|
+
if (isJson()) {
|
|
5070
|
+
emitJson({ current: RUNNER_VERSION, latest, channel, installed: true, restarted });
|
|
5071
|
+
return 0;
|
|
5072
|
+
}
|
|
5073
|
+
say.success(`Updated to ${latest}.`);
|
|
5074
|
+
say.line(
|
|
5075
|
+
restarted ? " The background service has been restarted on the new version." : " Restart your daemon to pick it up."
|
|
5076
|
+
);
|
|
5077
|
+
return 0;
|
|
5078
|
+
}
|
|
5079
|
+
|
|
4430
5080
|
// src/cli/index.ts
|
|
4431
5081
|
var program = new Command();
|
|
4432
5082
|
program.name("lumi-runner").description("Lumi Crew runner daemon \u2014 runs agent jobs for your Ships on this machine.").version(RUNNER_VERSION).option("--json", "machine-readable output on stdout").option("-y, --yes", "assume yes for confirmations").option("--no-color", "disable coloured output").hook("preAction", (command) => {
|
|
@@ -4475,6 +5125,7 @@ program.command("start").description("Run the daemon in the foreground").action(
|
|
|
4475
5125
|
);
|
|
4476
5126
|
program.command("doctor").description("Check whether this machine can run jobs").action(action(runDoctor));
|
|
4477
5127
|
program.command("status").description("Show what this machine is doing").action(action(runStatus));
|
|
5128
|
+
program.command("update").description("Update this machine to the latest published version").option("--check", "report whether a newer version exists, and install nothing").option("--dry-run", "print the npm command that would run").option("--channel <name>", "latest or dev (default: whichever this version came from)").action(action((options) => runUpdate(options)));
|
|
4478
5129
|
program.command("logs").description("Show the daemon's log file").option("-n, --lines <count>", "how many lines to show", "50").option("-f, --follow", "keep printing new lines").action(
|
|
4479
5130
|
action(
|
|
4480
5131
|
async (options) => runLogs({ lines: Number(options.lines) || 50, follow: options.follow === true })
|