@lumi.ai/runner 0.5.7 → 0.5.9
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 +1404 -777
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
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,
|
|
@@ -251,9 +251,9 @@ function str(input, key) {
|
|
|
251
251
|
const value = input[key];
|
|
252
252
|
return typeof value === "string" && value.trim() ? value : void 0;
|
|
253
253
|
}
|
|
254
|
-
function basename(
|
|
255
|
-
const parts =
|
|
256
|
-
return parts[parts.length - 1] ??
|
|
254
|
+
function basename(path8) {
|
|
255
|
+
const parts = path8.split(/[\\/]/).filter(Boolean);
|
|
256
|
+
return parts[parts.length - 1] ?? path8;
|
|
257
257
|
}
|
|
258
258
|
function hostOf(url) {
|
|
259
259
|
try {
|
|
@@ -336,8 +336,8 @@ function builtinDetail(tool, input) {
|
|
|
336
336
|
case "Write":
|
|
337
337
|
case "Edit":
|
|
338
338
|
case "MultiEdit": {
|
|
339
|
-
const
|
|
340
|
-
return
|
|
339
|
+
const path8 = str(input, "file_path");
|
|
340
|
+
return path8 ? basename(path8) : void 0;
|
|
341
341
|
}
|
|
342
342
|
case "Glob":
|
|
343
343
|
case "Grep":
|
|
@@ -734,7 +734,7 @@ function mcpUrl(config2) {
|
|
|
734
734
|
}
|
|
735
735
|
|
|
736
736
|
// src/version.ts
|
|
737
|
-
var RUNNER_VERSION = true ? "0.5.
|
|
737
|
+
var RUNNER_VERSION = true ? "0.5.9" : "0.0.0-dev";
|
|
738
738
|
|
|
739
739
|
// src/auth.ts
|
|
740
740
|
import { signInWithCustomToken } from "firebase/auth";
|
|
@@ -2072,11 +2072,11 @@ function redactTranscript(transcript, knownSecrets) {
|
|
|
2072
2072
|
return out;
|
|
2073
2073
|
}
|
|
2074
2074
|
async function uploadTranscript(storage, shipId, jobId, redacted) {
|
|
2075
|
-
const
|
|
2076
|
-
await uploadBytes(storageRef(storage,
|
|
2075
|
+
const path8 = `crew/${shipId}/transcripts/${jobId}.jsonl`;
|
|
2076
|
+
await uploadBytes(storageRef(storage, path8), new TextEncoder().encode(redacted), {
|
|
2077
2077
|
contentType: "application/x-ndjson"
|
|
2078
2078
|
});
|
|
2079
|
-
return
|
|
2079
|
+
return path8;
|
|
2080
2080
|
}
|
|
2081
2081
|
function utcDay(millis) {
|
|
2082
2082
|
return new Date(millis).toISOString().slice(0, 10);
|
|
@@ -2369,204 +2369,825 @@ function createProgressWriter(deps) {
|
|
|
2369
2369
|
};
|
|
2370
2370
|
}
|
|
2371
2371
|
|
|
2372
|
-
// src/
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
const pruned = pruneDeadShips(config2, [...failures, ...orphanFailures]);
|
|
2392
|
-
if (pruned.dropped.length > 0) {
|
|
2393
|
-
saveConfig(pruned.config);
|
|
2394
|
-
config2 = pruned.config;
|
|
2395
|
-
console.log(
|
|
2396
|
-
`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.`
|
|
2397
|
-
);
|
|
2398
|
-
}
|
|
2399
|
-
if (sessions.length === 0) {
|
|
2400
|
-
console.error("No Ship could be signed in to. Run: lumi-runner login");
|
|
2401
|
-
process.exit(1);
|
|
2402
|
-
}
|
|
2403
|
-
const byShip = new Map(sessions.map((s) => [s.shipId, s]));
|
|
2404
|
-
const sess = (shipId) => {
|
|
2405
|
-
const s = byShip.get(shipId);
|
|
2406
|
-
if (!s) throw new Error(`No session for Ship ${shipId}`);
|
|
2407
|
-
return s;
|
|
2408
|
-
};
|
|
2409
|
-
const serving = new Set(sessions.map((s) => s.shipId));
|
|
2410
|
-
console.log(
|
|
2411
|
-
`Runner ${config2.runnerId} serving ${serving.size} Ship(s): ${[...serving].join(", ")}`
|
|
2412
|
-
);
|
|
2413
|
-
const logLines = [];
|
|
2414
|
-
const log2 = (line) => {
|
|
2415
|
-
const stamped = `${(/* @__PURE__ */ new Date()).toISOString()} ${line}`;
|
|
2416
|
-
console.log(stamped);
|
|
2417
|
-
appendLog(stamped);
|
|
2418
|
-
logLines.push(stamped);
|
|
2419
|
-
if (logLines.length > 20) logLines.shift();
|
|
2420
|
-
};
|
|
2421
|
-
if (moved?.migrated) log2(`Moved runner config from ${moved.from} to ${moved.to}.`);
|
|
2422
|
-
const startedAt = Date.now();
|
|
2423
|
-
const running = /* @__PURE__ */ new Map();
|
|
2424
|
-
const runningByShip = () => {
|
|
2425
|
-
const counts = /* @__PURE__ */ new Map();
|
|
2426
|
-
for (const r of running.values()) counts.set(r.shipId, (counts.get(r.shipId) ?? 0) + 1);
|
|
2427
|
-
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()
|
|
2428
2391
|
};
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
{
|
|
2464
|
-
hostname: os3.hostname(),
|
|
2465
|
-
version: RUNNER_VERSION,
|
|
2466
|
-
status: "online",
|
|
2467
|
-
lastSeenAt: now,
|
|
2468
|
-
startedAt,
|
|
2469
|
-
currentJob: live[0] ?? null,
|
|
2470
|
-
currentJobs: live,
|
|
2471
|
-
parallelLimit: shipCap(config2, shipId),
|
|
2472
|
-
lastLogLines: [...logLines]
|
|
2473
|
-
},
|
|
2474
|
-
{ merge: true }
|
|
2475
|
-
);
|
|
2476
|
-
} catch (e) {
|
|
2477
|
-
if (isEnrolmentGone(e)) {
|
|
2478
|
-
forgetShipLocally(shipId, "this machine is no longer enrolled on that Ship");
|
|
2479
|
-
continue;
|
|
2480
|
-
}
|
|
2481
|
-
console.error(`heartbeat failed (${shipId}):`, e instanceof Error ? e.message : e);
|
|
2482
|
-
}
|
|
2483
|
-
}
|
|
2484
|
-
} finally {
|
|
2485
|
-
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);
|
|
2486
2426
|
}
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
)
|
|
2497
|
-
);
|
|
2498
|
-
for (const d of snap.docs) {
|
|
2499
|
-
pending.set(`${shipId}/${d.id}`, {
|
|
2500
|
-
shipId,
|
|
2501
|
-
job: { id: d.id, ...d.data() }
|
|
2502
|
-
});
|
|
2503
|
-
}
|
|
2504
|
-
log2(`Ship ${shipId}: approved \u2014 picking up ${snap.size} queued job(s).`);
|
|
2505
|
-
} catch (e) {
|
|
2506
|
-
console.error(`queue refill failed (${shipId}):`, e instanceof Error ? e.message : e);
|
|
2507
|
-
}
|
|
2508
|
-
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);
|
|
2509
2436
|
}
|
|
2437
|
+
return removed;
|
|
2510
2438
|
}
|
|
2511
|
-
|
|
2512
|
-
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
const listenerError = (shipId, what) => (e) => {
|
|
2516
|
-
if (isEnrolmentGone(e)) {
|
|
2517
|
-
forgetShipLocally(shipId, "a captain removed this machine on the Daemons page");
|
|
2518
|
-
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);
|
|
2519
2443
|
}
|
|
2520
|
-
|
|
2521
|
-
}
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
2525
|
-
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
|
|
2530
|
-
|
|
2531
|
-
|
|
2532
|
-
|
|
2533
|
-
|
|
2534
|
-
|
|
2535
|
-
|
|
2536
|
-
|
|
2537
|
-
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
|
|
2543
|
-
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
|
|
2549
|
-
|
|
2550
|
-
|
|
2551
|
-
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
|
|
2555
|
-
|
|
2556
|
-
|
|
2557
|
-
|
|
2558
|
-
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
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
|
+
},
|
|
3175
|
+
listenerError(shipId, "agents")
|
|
3176
|
+
),
|
|
3177
|
+
// Exhausted usage windows. The FIRST snapshot is the startup seed: a restarted daemon —
|
|
3178
|
+
// and a second daemon on this Ship — inherits the pause instead of running a doomed
|
|
3179
|
+
// session to rediscover it.
|
|
3180
|
+
subscribeEngineLimits(
|
|
3181
|
+
sess(shipId).fb.db,
|
|
3182
|
+
shipId,
|
|
3183
|
+
(docs) => {
|
|
3184
|
+
for (const key of [...engineLimits.keys()]) {
|
|
3185
|
+
if (engineLimits.get(key)?.shipId === shipId) engineLimits.delete(key);
|
|
3186
|
+
}
|
|
3187
|
+
for (const d of docs) {
|
|
3188
|
+
engineLimits.set(limitKey(shipId, d.engineId), {
|
|
3189
|
+
shipId,
|
|
3190
|
+
engineId: d.engineId,
|
|
2570
3191
|
resetsAt: d.resetsAt,
|
|
2571
3192
|
resetsAtReported: d.resetsAtReported,
|
|
2572
3193
|
detail: d.detail
|
|
@@ -2620,9 +3241,14 @@ async function startDaemon() {
|
|
|
2620
3241
|
let dispatching = false;
|
|
2621
3242
|
let pokeRequested = false;
|
|
2622
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;
|
|
2623
3249
|
function poke() {
|
|
2624
|
-
if (dispatching || shuttingDown) {
|
|
2625
|
-
pokeRequested = !shuttingDown;
|
|
3250
|
+
if (dispatching || shuttingDown || updating) {
|
|
3251
|
+
pokeRequested = !shuttingDown && !updating;
|
|
2626
3252
|
return;
|
|
2627
3253
|
}
|
|
2628
3254
|
dispatch();
|
|
@@ -2656,7 +3282,7 @@ async function startDaemon() {
|
|
|
2656
3282
|
try {
|
|
2657
3283
|
for (; ; ) {
|
|
2658
3284
|
pokeRequested = false;
|
|
2659
|
-
if (shuttingDown) break;
|
|
3285
|
+
if (shuttingDown || updating) break;
|
|
2660
3286
|
const now = Date.now();
|
|
2661
3287
|
const picks = selectDispatch({
|
|
2662
3288
|
pending: pending.values(),
|
|
@@ -2688,6 +3314,10 @@ async function startDaemon() {
|
|
|
2688
3314
|
log2(`job ${job.id} ended abnormally: ${e instanceof Error ? e.message : e}`);
|
|
2689
3315
|
}).finally(() => {
|
|
2690
3316
|
running.delete(job.id);
|
|
3317
|
+
if (updating) {
|
|
3318
|
+
if (running.size === 0) void applyUpdate();
|
|
3319
|
+
return;
|
|
3320
|
+
}
|
|
2691
3321
|
if (!shuttingDown) poke();
|
|
2692
3322
|
});
|
|
2693
3323
|
}
|
|
@@ -3062,623 +3692,491 @@ async function startDaemon() {
|
|
|
3062
3692
|
const heartbeatTimer = setInterval(() => {
|
|
3063
3693
|
void heartbeat();
|
|
3064
3694
|
sweepLimits();
|
|
3695
|
+
void maybeCheckForUpdate();
|
|
3065
3696
|
}, HEARTBEAT_MS);
|
|
3066
3697
|
let stopping = false;
|
|
3067
|
-
const shutdown = async (
|
|
3698
|
+
const shutdown = async (reason) => {
|
|
3068
3699
|
if (stopping) return;
|
|
3069
3700
|
stopping = true;
|
|
3070
3701
|
shuttingDown = true;
|
|
3071
3702
|
clearInterval(heartbeatTimer);
|
|
3072
3703
|
if (limitTimer) clearTimeout(limitTimer);
|
|
3704
|
+
if (updateDrainTimer) clearTimeout(updateDrainTimer);
|
|
3073
3705
|
for (const shipUnsubs of unsubsByShip.values()) shipUnsubs.forEach((u) => u());
|
|
3074
|
-
const inFlight = [...running.values()];
|
|
3075
|
-
if (inFlight.length > 0) {
|
|
3076
|
-
log2(
|
|
3077
|
-
`${
|
|
3078
|
-
);
|
|
3079
|
-
notify(
|
|
3080
|
-
"Crew runner stopping",
|
|
3081
|
-
`${inFlight.length} job(s) were in progress; they have been returned to the queue.`
|
|
3082
|
-
);
|
|
3083
|
-
for (const r of inFlight) r.abort.abort();
|
|
3084
|
-
} else {
|
|
3085
|
-
log2(`${
|
|
3086
|
-
}
|
|
3087
|
-
if (inFlight.length > 0) {
|
|
3088
|
-
await Promise.race([
|
|
3089
|
-
Promise.all(inFlight.map((r) => r.promise)),
|
|
3090
|
-
new Promise((resolve) => setTimeout(resolve, SHUTDOWN_GRACE_MS).unref())
|
|
3091
|
-
]);
|
|
3092
|
-
}
|
|
3093
|
-
const now = Date.now();
|
|
3094
|
-
try {
|
|
3095
|
-
for (const shipId of serving) {
|
|
3096
|
-
await setDoc2(
|
|
3097
|
-
doc7(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.runners, config2.runnerId),
|
|
3098
|
-
{
|
|
3099
|
-
status: "offline",
|
|
3100
|
-
lastSeenAt: now,
|
|
3101
|
-
currentJob: deleteField2(),
|
|
3102
|
-
currentJobs: deleteField2()
|
|
3103
|
-
},
|
|
3104
|
-
{ merge: true }
|
|
3105
|
-
);
|
|
3106
|
-
}
|
|
3107
|
-
} catch {
|
|
3108
|
-
}
|
|
3109
|
-
process.exit(0);
|
|
3110
|
-
};
|
|
3111
|
-
process.on("SIGINT", () => void shutdown("SIGINT"));
|
|
3112
|
-
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
|
3113
|
-
const
|
|
3114
|
-
|
|
3115
|
-
|
|
3116
|
-
)
|
|
3117
|
-
await new Promise(() => {
|
|
3118
|
-
});
|
|
3119
|
-
}
|
|
3120
|
-
|
|
3121
|
-
// src/cli/ui.ts
|
|
3122
|
-
import * as clack from "@clack/prompts";
|
|
3123
|
-
import { createColors } from "picocolors";
|
|
3124
|
-
var CliError = class extends Error {
|
|
3125
|
-
constructor(message, exitCode = 1) {
|
|
3126
|
-
super(message);
|
|
3127
|
-
this.exitCode = exitCode;
|
|
3128
|
-
this.name = "CliError";
|
|
3129
|
-
}
|
|
3130
|
-
exitCode;
|
|
3131
|
-
};
|
|
3132
|
-
var jsonMode = false;
|
|
3133
|
-
var assumeYes = false;
|
|
3134
|
-
var palette = createColors();
|
|
3135
|
-
var delegate = (name) => (input) => palette[name](input);
|
|
3136
|
-
var pc = {
|
|
3137
|
-
bold: delegate("bold"),
|
|
3138
|
-
dim: delegate("dim"),
|
|
3139
|
-
black: delegate("black"),
|
|
3140
|
-
red: delegate("red"),
|
|
3141
|
-
green: delegate("green"),
|
|
3142
|
-
yellow: delegate("yellow"),
|
|
3143
|
-
cyan: delegate("cyan"),
|
|
3144
|
-
bgCyan: delegate("bgCyan")
|
|
3145
|
-
};
|
|
3146
|
-
function configureOutput(options) {
|
|
3147
|
-
jsonMode = options.json === true;
|
|
3148
|
-
assumeYes = options.yes === true;
|
|
3149
|
-
if (options.color === false || jsonMode) palette = createColors(false);
|
|
3150
|
-
}
|
|
3151
|
-
function isJson() {
|
|
3152
|
-
return jsonMode;
|
|
3153
|
-
}
|
|
3154
|
-
function emitJson(value) {
|
|
3155
|
-
process.stdout.write(`${JSON.stringify(value, null, 2)}
|
|
3156
|
-
`);
|
|
3157
|
-
}
|
|
3158
|
-
function canPrompt() {
|
|
3159
|
-
return !jsonMode && process.stdin.isTTY === true && process.stdout.isTTY === true;
|
|
3160
|
-
}
|
|
3161
|
-
function requireInteractive(what, flagHint) {
|
|
3162
|
-
if (canPrompt()) return;
|
|
3163
|
-
throw new CliError(
|
|
3164
|
-
`${what} needs an interactive terminal. Run it in a terminal, or pass ${pc.bold(flagHint)}.`
|
|
3165
|
-
);
|
|
3166
|
-
}
|
|
3167
|
-
var say = {
|
|
3168
|
-
intro(title) {
|
|
3169
|
-
if (!jsonMode) clack.intro(pc.bgCyan(pc.black(` ${title} `)));
|
|
3170
|
-
},
|
|
3171
|
-
outro(message) {
|
|
3172
|
-
if (!jsonMode) clack.outro(message);
|
|
3173
|
-
},
|
|
3174
|
-
info(message) {
|
|
3175
|
-
if (!jsonMode) clack.log.info(message);
|
|
3176
|
-
},
|
|
3177
|
-
success(message) {
|
|
3178
|
-
if (!jsonMode) clack.log.success(message);
|
|
3179
|
-
},
|
|
3180
|
-
warn(message) {
|
|
3181
|
-
if (!jsonMode) clack.log.warn(message);
|
|
3182
|
-
},
|
|
3183
|
-
error(message) {
|
|
3184
|
-
if (!jsonMode) clack.log.error(message);
|
|
3185
|
-
},
|
|
3186
|
-
step(message) {
|
|
3187
|
-
if (!jsonMode) clack.log.step(message);
|
|
3188
|
-
},
|
|
3189
|
-
note(body, title) {
|
|
3190
|
-
if (!jsonMode) clack.note(body, title);
|
|
3191
|
-
},
|
|
3192
|
-
/** Raw line straight to stdout — for `logs`, where the content IS the output. */
|
|
3193
|
-
line(message) {
|
|
3194
|
-
process.stdout.write(`${message}
|
|
3195
|
-
`);
|
|
3196
|
-
}
|
|
3197
|
-
};
|
|
3198
|
-
function spinner2() {
|
|
3199
|
-
if (jsonMode) {
|
|
3200
|
-
return { start: () => {
|
|
3201
|
-
}, update: () => {
|
|
3202
|
-
}, stop: () => {
|
|
3203
|
-
} };
|
|
3204
|
-
}
|
|
3205
|
-
if (!canPrompt()) {
|
|
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) {
|
|
3206
3749
|
return {
|
|
3207
|
-
|
|
3208
|
-
|
|
3209
|
-
|
|
3210
|
-
|
|
3211
|
-
|
|
3212
|
-
|
|
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()
|
|
3213
3766
|
};
|
|
3214
3767
|
}
|
|
3215
|
-
|
|
3216
|
-
|
|
3217
|
-
|
|
3218
|
-
|
|
3219
|
-
stop: (msg) => s.stop(msg)
|
|
3220
|
-
};
|
|
3221
|
-
}
|
|
3222
|
-
function unwrap(value) {
|
|
3223
|
-
if (clack.isCancel(value)) throw new CliError("Cancelled.", 130);
|
|
3224
|
-
return value;
|
|
3225
|
-
}
|
|
3226
|
-
async function promptMultiSelect(options) {
|
|
3227
|
-
requireInteractive(options.message, options.flagHint);
|
|
3228
|
-
return unwrap(
|
|
3229
|
-
await clack.multiselect({
|
|
3230
|
-
message: options.message,
|
|
3231
|
-
options: options.choices,
|
|
3232
|
-
initialValues: options.initialValues,
|
|
3233
|
-
required: options.required ?? false
|
|
3234
|
-
})
|
|
3235
|
-
);
|
|
3236
|
-
}
|
|
3237
|
-
async function promptConfirm(options) {
|
|
3238
|
-
if (assumeYes && options.yesFlagApplies !== false) return true;
|
|
3239
|
-
requireInteractive(options.message, "--yes");
|
|
3240
|
-
return unwrap(await clack.confirm({ message: options.message, initialValue: options.initialValue }));
|
|
3241
|
-
}
|
|
3242
|
-
function glyph(level) {
|
|
3243
|
-
if (level === "ok") return pc.green("\u2714");
|
|
3244
|
-
if (level === "warn") return pc.yellow("\u25B2");
|
|
3245
|
-
return pc.red("\u2716");
|
|
3246
|
-
}
|
|
3247
|
-
|
|
3248
|
-
// src/cli/commands/config.ts
|
|
3249
|
-
var TOGGLES = {
|
|
3250
|
-
notifications: "Desktop notifications when a job starts, finishes or fails",
|
|
3251
|
-
keepAwake: "Keep this machine awake while a job is running"
|
|
3252
|
-
};
|
|
3253
|
-
var PARALLEL = "parallel";
|
|
3254
|
-
var PARALLEL_HELP = "How many jobs to run at once (1 = one at a time, as before)";
|
|
3255
|
-
var DEFAULT_WORDS = ["default", "auto", "reset"];
|
|
3256
|
-
function isToggle(key) {
|
|
3257
|
-
return Object.hasOwn(TOGGLES, key);
|
|
3258
|
-
}
|
|
3259
|
-
function parseBool(value) {
|
|
3260
|
-
if (["on", "true", "yes", "1"].includes(value.toLowerCase())) return true;
|
|
3261
|
-
if (["off", "false", "no", "0"].includes(value.toLowerCase())) return false;
|
|
3262
|
-
throw new CliError(`Expected on/off, got "${value}".`);
|
|
3263
|
-
}
|
|
3264
|
-
function parseParallel(value) {
|
|
3265
|
-
if (DEFAULT_WORDS.includes(value.toLowerCase())) return null;
|
|
3266
|
-
const n = Number(value);
|
|
3267
|
-
if (!Number.isInteger(n) || n < 1 || n > PARALLEL_MAX) {
|
|
3268
|
-
throw new CliError(
|
|
3269
|
-
`Expected a whole number from 1 to ${PARALLEL_MAX} (or "default"), got "${value}".`
|
|
3270
|
-
);
|
|
3271
|
-
}
|
|
3272
|
-
return n;
|
|
3273
|
-
}
|
|
3274
|
-
async function runConfigList() {
|
|
3275
|
-
const config2 = requireConfig();
|
|
3276
|
-
const values = Object.fromEntries(
|
|
3277
|
-
Object.keys(TOGGLES).map((key) => [key, config2[key] !== false])
|
|
3278
|
-
);
|
|
3279
|
-
const machine = machineCap(config2);
|
|
3280
|
-
const ships = config2.ships.map((shipId) => ({
|
|
3281
|
-
shipId,
|
|
3282
|
-
parallel: shipCap(config2, shipId),
|
|
3283
|
-
explicit: config2.shipParallelJobs?.[shipId] !== void 0
|
|
3284
|
-
}));
|
|
3285
|
-
if (isJson()) {
|
|
3286
|
-
emitJson({ configDir: configDir(), ...values, parallel: machine, ships });
|
|
3287
|
-
return 0;
|
|
3288
|
-
}
|
|
3289
|
-
say.line(` ${configDir()}`);
|
|
3290
|
-
say.line("");
|
|
3291
|
-
for (const key of Object.keys(TOGGLES)) {
|
|
3292
|
-
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 }));
|
|
3293
3772
|
}
|
|
3294
|
-
|
|
3295
|
-
|
|
3296
|
-
|
|
3297
|
-
|
|
3298
|
-
const
|
|
3299
|
-
|
|
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);
|
|
3300
3794
|
}
|
|
3301
3795
|
}
|
|
3302
|
-
|
|
3303
|
-
}
|
|
3304
|
-
|
|
3305
|
-
|
|
3306
|
-
|
|
3307
|
-
|
|
3308
|
-
throw new CliError(
|
|
3309
|
-
`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.`
|
|
3310
3802
|
);
|
|
3311
|
-
|
|
3312
|
-
|
|
3313
|
-
|
|
3314
|
-
}
|
|
3315
|
-
config2[key] = parseBool(value);
|
|
3316
|
-
saveConfig(config2);
|
|
3317
|
-
if (isJson()) {
|
|
3318
|
-
emitJson({ [key]: config2[key] });
|
|
3319
|
-
return 0;
|
|
3320
|
-
}
|
|
3321
|
-
say.success(`${key} = ${config2[key] ? "on" : "off"}`);
|
|
3322
|
-
return 0;
|
|
3323
|
-
}
|
|
3324
|
-
function setParallel(config2, value, ship2) {
|
|
3325
|
-
const parsed = parseParallel(value);
|
|
3326
|
-
if (ship2) {
|
|
3327
|
-
if (!config2.ships.includes(ship2)) {
|
|
3328
|
-
throw new CliError(
|
|
3329
|
-
`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.`
|
|
3330
|
-
);
|
|
3331
|
-
}
|
|
3332
|
-
const overrides = { ...config2.shipParallelJobs ?? {} };
|
|
3333
|
-
if (parsed === null) delete overrides[ship2];
|
|
3334
|
-
else overrides[ship2] = parsed;
|
|
3335
|
-
config2.shipParallelJobs = overrides;
|
|
3336
|
-
} else if (parsed === null) {
|
|
3337
|
-
delete config2.maxParallelJobs;
|
|
3338
|
-
} else {
|
|
3339
|
-
config2.maxParallelJobs = parsed;
|
|
3340
|
-
}
|
|
3341
|
-
saveConfig(config2);
|
|
3342
|
-
const effective = ship2 ? shipCap(config2, ship2) : machineCap(config2);
|
|
3343
|
-
if (isJson()) {
|
|
3344
|
-
emitJson({ parallel: effective, ...ship2 ? { shipId: ship2 } : {} });
|
|
3345
|
-
return 0;
|
|
3346
|
-
}
|
|
3347
|
-
const machine = machineCap(config2);
|
|
3348
|
-
say.success(
|
|
3349
|
-
ship2 ? `${PARALLEL} for Ship ${ship2} = ${effective}${parsed === null ? " (machine default)" : ""}` : `${PARALLEL} = ${effective}`
|
|
3350
|
-
);
|
|
3351
|
-
if (ship2 && parsed !== null && parsed > machine) {
|
|
3352
|
-
say.line(
|
|
3353
|
-
` 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
|
|
3354
3806
|
);
|
|
3355
|
-
|
|
3356
|
-
|
|
3357
|
-
|
|
3358
|
-
|
|
3359
|
-
|
|
3360
|
-
|
|
3361
|
-
|
|
3362
|
-
|
|
3363
|
-
|
|
3364
|
-
|
|
3365
|
-
|
|
3366
|
-
|
|
3367
|
-
import os4 from "node:os";
|
|
3368
|
-
import path4 from "node:path";
|
|
3369
|
-
import { fileURLToPath } from "node:url";
|
|
3370
|
-
var SERVICE_LABEL = "com.lumi.runner";
|
|
3371
|
-
var LINUX_UNIT = "lumi-runner.service";
|
|
3372
|
-
var WINDOWS_TASK = "LumiRunner";
|
|
3373
|
-
var LEGACY_SERVICE_LABEL = "com.lumi.crew-runner";
|
|
3374
|
-
var LEGACY_LINUX_UNIT = "crew-runner.service";
|
|
3375
|
-
var LEGACY_WINDOWS_TASK = "CrewRunner";
|
|
3376
|
-
var ServiceError = class extends Error {
|
|
3377
|
-
};
|
|
3378
|
-
function run(command, args) {
|
|
3379
|
-
const result = spawnSync(command, args, { encoding: "utf8" });
|
|
3380
|
-
return {
|
|
3381
|
-
ok: result.status === 0,
|
|
3382
|
-
out: `${result.stdout ?? ""}${result.stderr ?? ""}`.trim()
|
|
3383
|
-
};
|
|
3384
|
-
}
|
|
3385
|
-
function cliPath() {
|
|
3386
|
-
return fileURLToPath(import.meta.url);
|
|
3387
|
-
}
|
|
3388
|
-
function uid() {
|
|
3389
|
-
return String(process.getuid?.() ?? 0);
|
|
3390
|
-
}
|
|
3391
|
-
function launchAgentPath() {
|
|
3392
|
-
return path4.join(os4.homedir(), "Library/LaunchAgents", `${SERVICE_LABEL}.plist`);
|
|
3393
|
-
}
|
|
3394
|
-
function systemdUnitPath() {
|
|
3395
|
-
return path4.join(os4.homedir(), ".config/systemd/user", LINUX_UNIT);
|
|
3396
|
-
}
|
|
3397
|
-
function legacyLaunchAgentPath() {
|
|
3398
|
-
return path4.join(os4.homedir(), "Library/LaunchAgents", `${LEGACY_SERVICE_LABEL}.plist`);
|
|
3399
|
-
}
|
|
3400
|
-
function legacySystemdUnitPath() {
|
|
3401
|
-
return path4.join(os4.homedir(), ".config/systemd/user", LEGACY_LINUX_UNIT);
|
|
3402
|
-
}
|
|
3403
|
-
function serviceEnv() {
|
|
3404
|
-
const env = { PATH: process.env.PATH ?? "" };
|
|
3405
|
-
if (process.env.LUMI_RUNNER_HOME || process.env.CREW_RUNNER_HOME) {
|
|
3406
|
-
env.LUMI_RUNNER_HOME = configDir();
|
|
3407
|
-
}
|
|
3408
|
-
return env;
|
|
3409
|
-
}
|
|
3410
|
-
function removeLegacyService() {
|
|
3411
|
-
const removed = [];
|
|
3412
|
-
if (process.platform === "darwin") {
|
|
3413
|
-
const unitPath = legacyLaunchAgentPath();
|
|
3414
|
-
if (fs4.existsSync(unitPath)) {
|
|
3415
|
-
run("launchctl", ["bootout", `gui/${uid()}/${LEGACY_SERVICE_LABEL}`]);
|
|
3416
|
-
fs4.rmSync(unitPath, { force: true });
|
|
3417
|
-
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;
|
|
3418
3819
|
}
|
|
3419
|
-
|
|
3420
|
-
|
|
3421
|
-
|
|
3422
|
-
|
|
3423
|
-
if (
|
|
3424
|
-
|
|
3425
|
-
|
|
3426
|
-
|
|
3427
|
-
|
|
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}`);
|
|
3428
3857
|
}
|
|
3429
|
-
return removed;
|
|
3430
3858
|
}
|
|
3431
|
-
|
|
3432
|
-
|
|
3433
|
-
|
|
3434
|
-
|
|
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
|
+
);
|
|
3435
3868
|
}
|
|
3436
|
-
return removed;
|
|
3437
3869
|
}
|
|
3438
|
-
|
|
3439
|
-
|
|
3440
|
-
|
|
3441
|
-
|
|
3442
|
-
|
|
3443
|
-
|
|
3444
|
-
// The observed one: `Bootstrap failed: 5: Input/output error`.
|
|
3445
|
-
text.includes("input/output error") || // Same cause, different report: the old generation is still registered.
|
|
3446
|
-
text.includes("service already loaded") || text.includes("service is already loaded") || text.includes("eexist") || // launchd is mid-teardown and says so.
|
|
3447
|
-
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)}`
|
|
3873
|
+
);
|
|
3874
|
+
log2(
|
|
3875
|
+
`Version ${RUNNER_VERSION} on the ${resolveChannel(RUNNER_VERSION, config2, process.env)} channel; auto-update ${config2.autoUpdate === false ? "off" : "on"}.`
|
|
3448
3876
|
);
|
|
3877
|
+
await new Promise(() => {
|
|
3878
|
+
});
|
|
3449
3879
|
}
|
|
3450
|
-
|
|
3451
|
-
|
|
3452
|
-
|
|
3453
|
-
|
|
3454
|
-
|
|
3455
|
-
|
|
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";
|
|
3456
3889
|
}
|
|
3457
|
-
|
|
3458
|
-
}
|
|
3459
|
-
|
|
3460
|
-
|
|
3461
|
-
|
|
3462
|
-
|
|
3463
|
-
|
|
3464
|
-
|
|
3465
|
-
|
|
3466
|
-
|
|
3467
|
-
|
|
3468
|
-
|
|
3469
|
-
|
|
3470
|
-
|
|
3471
|
-
|
|
3472
|
-
|
|
3473
|
-
|
|
3474
|
-
|
|
3475
|
-
|
|
3476
|
-
|
|
3477
|
-
</array>
|
|
3478
|
-
<key>RunAtLoad</key>
|
|
3479
|
-
<true/>
|
|
3480
|
-
<key>KeepAlive</key>
|
|
3481
|
-
<true/>
|
|
3482
|
-
<key>ExitTimeOut</key>
|
|
3483
|
-
<integer>20</integer>
|
|
3484
|
-
<key>EnvironmentVariables</key>
|
|
3485
|
-
<dict>
|
|
3486
|
-
${envEntries}
|
|
3487
|
-
</dict>
|
|
3488
|
-
<key>StandardOutPath</key>
|
|
3489
|
-
<string>${escapeXml(path4.join(logDir(), "service.out.log"))}</string>
|
|
3490
|
-
<key>StandardErrorPath</key>
|
|
3491
|
-
<string>${escapeXml(path4.join(logDir(), "service.err.log"))}</string>
|
|
3492
|
-
</dict>
|
|
3493
|
-
</plist>
|
|
3494
|
-
`;
|
|
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);
|
|
3495
3910
|
}
|
|
3496
|
-
function
|
|
3497
|
-
return
|
|
3911
|
+
function isJson() {
|
|
3912
|
+
return jsonMode;
|
|
3498
3913
|
}
|
|
3499
|
-
function
|
|
3500
|
-
|
|
3914
|
+
function emitJson(value) {
|
|
3915
|
+
process.stdout.write(`${JSON.stringify(value, null, 2)}
|
|
3916
|
+
`);
|
|
3501
3917
|
}
|
|
3502
|
-
function
|
|
3503
|
-
|
|
3504
|
-
if (!block) return void 0;
|
|
3505
|
-
const args = [...block[1].matchAll(/<string>([\s\S]*?)<\/string>/g)].map((m) => unescapeXml(m[1]));
|
|
3506
|
-
return args.length >= 2 ? args[1] : void 0;
|
|
3918
|
+
function canPrompt() {
|
|
3919
|
+
return !jsonMode && process.stdin.isTTY === true && process.stdout.isTTY === true;
|
|
3507
3920
|
}
|
|
3508
|
-
function
|
|
3509
|
-
|
|
3510
|
-
|
|
3511
|
-
|
|
3512
|
-
|
|
3513
|
-
const parts = command.slice(0, -" start".length).split(" ");
|
|
3514
|
-
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
|
+
);
|
|
3515
3926
|
}
|
|
3516
|
-
|
|
3517
|
-
|
|
3518
|
-
|
|
3519
|
-
|
|
3520
|
-
|
|
3521
|
-
|
|
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
|
+
`);
|
|
3522
3956
|
}
|
|
3523
|
-
|
|
3524
|
-
|
|
3525
|
-
|
|
3526
|
-
|
|
3527
|
-
|
|
3528
|
-
|
|
3529
|
-
|
|
3530
|
-
return `[Unit]
|
|
3531
|
-
Description=Lumi Crew runner daemon
|
|
3532
|
-
After=network-online.target
|
|
3533
|
-
|
|
3534
|
-
[Service]
|
|
3535
|
-
Type=simple
|
|
3536
|
-
ExecStart=${process.execPath} ${cliPath()} start
|
|
3537
|
-
Restart=always
|
|
3538
|
-
RestartSec=5
|
|
3539
|
-
# Must exceed the daemon's SHUTDOWN_GRACE_MS: a SIGKILL mid-release is exactly the stranded
|
|
3540
|
-
# job the graceful shutdown exists to prevent.
|
|
3541
|
-
TimeoutStopSec=20
|
|
3542
|
-
${envLines}
|
|
3543
|
-
|
|
3544
|
-
[Install]
|
|
3545
|
-
WantedBy=default.target
|
|
3546
|
-
`;
|
|
3547
|
-
}
|
|
3548
|
-
function serviceStatus() {
|
|
3549
|
-
if (process.platform === "darwin") {
|
|
3550
|
-
const unitPath = launchAgentPath();
|
|
3551
|
-
if (!fs4.existsSync(unitPath)) return { state: "not-installed", detail: "No LaunchAgent installed." };
|
|
3552
|
-
const exec = execFacts(unitPath, parsePlistCliPath);
|
|
3553
|
-
const printed = run("launchctl", ["print", `gui/${uid()}/${SERVICE_LABEL}`]);
|
|
3554
|
-
if (!printed.ok) {
|
|
3555
|
-
return { state: "installed", detail: "LaunchAgent present but not loaded.", unitPath, ...exec };
|
|
3556
|
-
}
|
|
3557
|
-
const running = /\bpid = \d+/.test(printed.out);
|
|
3558
|
-
return {
|
|
3559
|
-
state: running ? "running" : "installed",
|
|
3560
|
-
detail: running ? "LaunchAgent loaded and running." : "LaunchAgent loaded but not running.",
|
|
3561
|
-
unitPath,
|
|
3562
|
-
...exec
|
|
3563
|
-
};
|
|
3957
|
+
};
|
|
3958
|
+
function spinner2() {
|
|
3959
|
+
if (jsonMode) {
|
|
3960
|
+
return { start: () => {
|
|
3961
|
+
}, update: () => {
|
|
3962
|
+
}, stop: () => {
|
|
3963
|
+
} };
|
|
3564
3964
|
}
|
|
3565
|
-
if (
|
|
3566
|
-
const unitPath = systemdUnitPath();
|
|
3567
|
-
if (!fs4.existsSync(unitPath)) return { state: "not-installed", detail: "No systemd user unit installed." };
|
|
3568
|
-
const active = run("systemctl", ["--user", "is-active", LINUX_UNIT]);
|
|
3965
|
+
if (!canPrompt()) {
|
|
3569
3966
|
return {
|
|
3570
|
-
|
|
3571
|
-
|
|
3572
|
-
|
|
3573
|
-
|
|
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
|
+
`)
|
|
3574
3973
|
};
|
|
3575
3974
|
}
|
|
3576
|
-
|
|
3577
|
-
|
|
3578
|
-
|
|
3579
|
-
|
|
3580
|
-
|
|
3581
|
-
|
|
3582
|
-
|
|
3583
|
-
|
|
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
|
+
);
|
|
3584
4052
|
}
|
|
3585
|
-
return
|
|
4053
|
+
return n;
|
|
3586
4054
|
}
|
|
3587
|
-
function
|
|
3588
|
-
const
|
|
3589
|
-
|
|
3590
|
-
|
|
3591
|
-
|
|
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;
|
|
3592
4071
|
}
|
|
3593
|
-
|
|
3594
|
-
|
|
3595
|
-
|
|
3596
|
-
|
|
3597
|
-
run("launchctl", ["bootout", `gui/${uid()}/${SERVICE_LABEL}`]);
|
|
3598
|
-
const boot = retryWhileTransient(
|
|
3599
|
-
() => run("launchctl", ["bootstrap", `gui/${uid()}`, unitPath]),
|
|
3600
|
-
sleepSync
|
|
3601
|
-
);
|
|
3602
|
-
if (!boot.ok) throw new ServiceError(`launchctl bootstrap failed: ${boot.out}`);
|
|
3603
|
-
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]}`);
|
|
3604
4076
|
}
|
|
3605
|
-
|
|
3606
|
-
|
|
3607
|
-
|
|
3608
|
-
|
|
3609
|
-
|
|
3610
|
-
|
|
3611
|
-
|
|
3612
|
-
|
|
3613
|
-
const linger = run("loginctl", ["show-user", os4.userInfo().username, "--property=Linger"]);
|
|
3614
|
-
if (!linger.out.includes("Linger=yes")) {
|
|
3615
|
-
notes.push(
|
|
3616
|
-
`Run \`sudo loginctl enable-linger ${os4.userInfo().username}\` so the daemon survives logout and starts at boot.`
|
|
3617
|
-
);
|
|
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}`);
|
|
3618
4085
|
}
|
|
3619
|
-
return { unitPath, notes };
|
|
3620
4086
|
}
|
|
3621
|
-
|
|
3622
|
-
|
|
3623
|
-
|
|
3624
|
-
|
|
3625
|
-
|
|
3626
|
-
|
|
3627
|
-
|
|
3628
|
-
|
|
3629
|
-
"
|
|
3630
|
-
"ONLOGON",
|
|
3631
|
-
"/RL",
|
|
3632
|
-
"LIMITED",
|
|
3633
|
-
"/F"
|
|
3634
|
-
]);
|
|
3635
|
-
if (!create.ok) throw new ServiceError(`schtasks /Create failed: ${create.out}`);
|
|
3636
|
-
notes.push(
|
|
3637
|
-
"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(", ")}.`
|
|
3638
4096
|
);
|
|
3639
|
-
return { unitPath: WINDOWS_TASK, notes };
|
|
3640
4097
|
}
|
|
3641
|
-
|
|
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;
|
|
3642
4109
|
}
|
|
3643
|
-
function
|
|
3644
|
-
|
|
3645
|
-
if (
|
|
3646
|
-
|
|
3647
|
-
|
|
3648
|
-
|
|
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;
|
|
3649
4120
|
}
|
|
3650
|
-
|
|
3651
|
-
|
|
3652
|
-
|
|
3653
|
-
|
|
3654
|
-
|
|
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;
|
|
3655
4127
|
}
|
|
3656
|
-
|
|
3657
|
-
|
|
3658
|
-
|
|
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}).`);
|
|
3659
4131
|
}
|
|
3660
|
-
|
|
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;
|
|
3661
4139
|
}
|
|
3662
|
-
function
|
|
3663
|
-
|
|
3664
|
-
|
|
3665
|
-
if (!
|
|
3666
|
-
|
|
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;
|
|
3667
4156
|
}
|
|
3668
|
-
|
|
3669
|
-
|
|
3670
|
-
|
|
3671
|
-
|
|
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;
|
|
3672
4162
|
}
|
|
3673
|
-
|
|
3674
|
-
|
|
3675
|
-
|
|
3676
|
-
|
|
3677
|
-
|
|
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
|
+
);
|
|
3678
4171
|
}
|
|
3679
|
-
|
|
4172
|
+
say.line(" Restart the daemon for this to take effect: `lumi-runner service restart`.");
|
|
4173
|
+
return 0;
|
|
3680
4174
|
}
|
|
3681
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
|
+
|
|
3682
4180
|
// src/cli/session.ts
|
|
3683
4181
|
async function openShipSession(shipId) {
|
|
3684
4182
|
const config2 = requireConfig();
|
|
@@ -3705,7 +4203,7 @@ var fail = (id, label, detail, fix) => ({
|
|
|
3705
4203
|
});
|
|
3706
4204
|
function onPath(binary) {
|
|
3707
4205
|
const probe = process.platform === "win32" ? "where" : "which";
|
|
3708
|
-
return
|
|
4206
|
+
return spawnSync3(probe, [binary], { stdio: "ignore" }).status === 0;
|
|
3709
4207
|
}
|
|
3710
4208
|
function checkNode() {
|
|
3711
4209
|
const major = Number(process.versions.node.split(".")[0]);
|
|
@@ -3751,6 +4249,33 @@ function serviceCheckFrom(status) {
|
|
|
3751
4249
|
function checkService() {
|
|
3752
4250
|
return serviceCheckFrom(serviceStatus());
|
|
3753
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
|
+
}
|
|
3754
4279
|
async function checkShips(config2) {
|
|
3755
4280
|
const checks = [];
|
|
3756
4281
|
const engines = /* @__PURE__ */ new Set();
|
|
@@ -3886,6 +4411,7 @@ async function runDoctor() {
|
|
|
3886
4411
|
}
|
|
3887
4412
|
checks.push(await checkMcp(mcpUrl(config2)));
|
|
3888
4413
|
checks.push(checkService());
|
|
4414
|
+
checks.push(await checkVersion(config2));
|
|
3889
4415
|
progress.stop("Checks complete.");
|
|
3890
4416
|
return report(checks);
|
|
3891
4417
|
}
|
|
@@ -3918,7 +4444,7 @@ function report(checks) {
|
|
|
3918
4444
|
}
|
|
3919
4445
|
|
|
3920
4446
|
// src/cli/commands/login.ts
|
|
3921
|
-
import { spawn as
|
|
4447
|
+
import { spawn as spawn5 } from "node:child_process";
|
|
3922
4448
|
import os5 from "node:os";
|
|
3923
4449
|
import { signInWithCustomToken as signInWithCustomToken2 } from "firebase/auth";
|
|
3924
4450
|
function buildLoginResult(approved, existing, fallbackProjectId, mcpUrl2) {
|
|
@@ -3944,7 +4470,7 @@ function buildLoginResult(approved, existing, fallbackProjectId, mcpUrl2) {
|
|
|
3944
4470
|
function openBrowser(url) {
|
|
3945
4471
|
const [command, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
|
|
3946
4472
|
try {
|
|
3947
|
-
const child =
|
|
4473
|
+
const child = spawn5(command, args, { stdio: "ignore", detached: true });
|
|
3948
4474
|
child.on("error", () => {
|
|
3949
4475
|
});
|
|
3950
4476
|
child.unref();
|
|
@@ -4164,20 +4690,20 @@ async function runServiceStatus() {
|
|
|
4164
4690
|
}
|
|
4165
4691
|
|
|
4166
4692
|
// src/cli/commands/uninstall.ts
|
|
4167
|
-
import
|
|
4693
|
+
import fs7 from "node:fs";
|
|
4168
4694
|
async function runUninstall(options) {
|
|
4169
4695
|
const before = serviceStatus();
|
|
4170
4696
|
const dir = configDir();
|
|
4171
4697
|
const hadService = before.state !== "not-installed" && before.state !== "unsupported";
|
|
4172
4698
|
if (hadService) uninstallService();
|
|
4173
4699
|
let purged = false;
|
|
4174
|
-
if (options.purge &&
|
|
4700
|
+
if (options.purge && fs7.existsSync(dir)) {
|
|
4175
4701
|
const confirmed = await promptConfirm({
|
|
4176
4702
|
message: `Delete ${dir}? This machine loses its identity \u2014 a captain has to approve it again after reinstalling.`,
|
|
4177
4703
|
initialValue: false
|
|
4178
4704
|
});
|
|
4179
4705
|
if (confirmed) {
|
|
4180
|
-
|
|
4706
|
+
fs7.rmSync(dir, { recursive: true, force: true });
|
|
4181
4707
|
purged = true;
|
|
4182
4708
|
}
|
|
4183
4709
|
}
|
|
@@ -4451,6 +4977,106 @@ async function runStatus() {
|
|
|
4451
4977
|
return 0;
|
|
4452
4978
|
}
|
|
4453
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
|
+
|
|
4454
5080
|
// src/cli/index.ts
|
|
4455
5081
|
var program = new Command();
|
|
4456
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) => {
|
|
@@ -4499,6 +5125,7 @@ program.command("start").description("Run the daemon in the foreground").action(
|
|
|
4499
5125
|
);
|
|
4500
5126
|
program.command("doctor").description("Check whether this machine can run jobs").action(action(runDoctor));
|
|
4501
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)));
|
|
4502
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(
|
|
4503
5130
|
action(
|
|
4504
5131
|
async (options) => runLogs({ lines: Number(options.lines) || 50, follow: options.follow === true })
|