@aloud/runner 0.3.0 → 0.3.2
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 +15 -2
- package/dist/cli.js +367 -42
- package/package.json +2 -2
- package/src/cli.ts +371 -31
- package/src/protocol/presence.ts +83 -0
- package/src/version.ts +56 -1
package/src/cli.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { createInterface } from "node:readline/promises";
|
|
10
10
|
import { spawn } from "node:child_process";
|
|
11
11
|
import { startApproval, waitForApproval, type ApprovalStart } from "./protocol/approval";
|
|
12
|
+
import { readRunnerPresence, waitForRunnerCheckIn } from "./protocol/presence";
|
|
12
13
|
import {
|
|
13
14
|
clearMcpCredentials,
|
|
14
15
|
mcpCredentialsPath,
|
|
@@ -31,7 +32,14 @@ import {
|
|
|
31
32
|
} from "./config/credentials";
|
|
32
33
|
import { policyFrom, type LocalPolicy } from "./config/policy";
|
|
33
34
|
import { clearRunning, readRunning, runningPath, writeRunning, type RunningState } from "./config/running";
|
|
34
|
-
import {
|
|
35
|
+
import {
|
|
36
|
+
RUNNER_VERSION,
|
|
37
|
+
compareRunnerVersions,
|
|
38
|
+
runnerUpdateFor,
|
|
39
|
+
runnerVersionPolicyFrom,
|
|
40
|
+
type RunnerUpdate,
|
|
41
|
+
type RunnerVersionPolicy,
|
|
42
|
+
} from "./version";
|
|
35
43
|
import { RunnerClient } from "./protocol/client";
|
|
36
44
|
import { installChromium, preflight } from "./preflight";
|
|
37
45
|
import { TerminalReporter } from "./ui/output";
|
|
@@ -57,6 +65,11 @@ export async function main(argv: readonly string[] = process.argv.slice(2)): Pro
|
|
|
57
65
|
return allow(rest);
|
|
58
66
|
case "setup":
|
|
59
67
|
return setup();
|
|
68
|
+
case "version":
|
|
69
|
+
case "--version":
|
|
70
|
+
case "-v":
|
|
71
|
+
process.stdout.write(RUNNER_VERSION + "\n");
|
|
72
|
+
return 0;
|
|
60
73
|
case "mcp":
|
|
61
74
|
// `aloud mcp` is the server an MCP host launches; `aloud mcp connect` is how it gets a
|
|
62
75
|
// credential in the first place. Same word, because from the outside they are one feature.
|
|
@@ -86,12 +99,14 @@ function printHelp(): void {
|
|
|
86
99
|
"",
|
|
87
100
|
" aloud setup What to do next, for a person or an agent",
|
|
88
101
|
" aloud login [--token <token>] Connect this machine, approving it in your browser",
|
|
89
|
-
" aloud start [--once] [--quiet]
|
|
102
|
+
" aloud start [--once] [--quiet] [--no-update]",
|
|
103
|
+
" Update, then wait for studies and run them here",
|
|
90
104
|
" aloud status What is set up, and whether it is running",
|
|
91
105
|
" aloud allow <host> Let studies open this host from this machine",
|
|
92
106
|
" aloud mcp Serve MCP to an editor, using the saved credential",
|
|
93
107
|
" aloud mcp connect Connect an editor, approving it in your browser",
|
|
94
108
|
" aloud logout Forget the token on this machine",
|
|
109
|
+
" aloud --version Print the installed runner version",
|
|
95
110
|
"",
|
|
96
111
|
`Server: ${DEFAULT_SERVER} (override with ALOUD_SERVER)`,
|
|
97
112
|
"",
|
|
@@ -329,7 +344,7 @@ async function setup(): Promise<number> {
|
|
|
329
344
|
const running = await readRunning();
|
|
330
345
|
const installed = onPath("aloud");
|
|
331
346
|
const latest = await latestVersion();
|
|
332
|
-
const stale = latest !== null && latest
|
|
347
|
+
const stale = latest !== null && compareRunnerVersions(latest, RUNNER_VERSION) === 1;
|
|
333
348
|
const signedIn = await signedInState(credentials);
|
|
334
349
|
|
|
335
350
|
const out = (line = "") => process.stdout.write(line + "\n");
|
|
@@ -352,6 +367,21 @@ async function setup(): Promise<number> {
|
|
|
352
367
|
);
|
|
353
368
|
out(` chromium ${checks.chromiumInstalled ? "ready" : "downloads on first start, about 350 MB"}`);
|
|
354
369
|
out(` running ${running ? `yes (pid ${running.pid})` : "no"}`);
|
|
370
|
+
out(
|
|
371
|
+
` Aloud sees ${
|
|
372
|
+
signedIn.state === "ok"
|
|
373
|
+
? signedIn.online
|
|
374
|
+
? `yes${signedIn.lastVersion ? ` (v${signedIn.lastVersion})` : ""}${
|
|
375
|
+
signedIn.lastSeenAt ? `, checked in ${when(signedIn.lastSeenAt)}` : ""
|
|
376
|
+
}`
|
|
377
|
+
: running
|
|
378
|
+
? "not yet. The local process exists, but no current check-in is confirmed"
|
|
379
|
+
: "no"
|
|
380
|
+
: signedIn.state === "unreachable"
|
|
381
|
+
? "unknown, the server did not answer"
|
|
382
|
+
: "no"
|
|
383
|
+
}`,
|
|
384
|
+
);
|
|
355
385
|
out();
|
|
356
386
|
|
|
357
387
|
// Each step is a command plus the lines that qualify it. Only the command gets a number, or a
|
|
@@ -359,7 +389,17 @@ async function setup(): Promise<number> {
|
|
|
359
389
|
// into a shell.
|
|
360
390
|
// A person in a terminal gets setup done, not a list of things to go and do. An agent, which has
|
|
361
391
|
// no terminal, gets the list. Same command, and the difference is who can answer a prompt.
|
|
362
|
-
if (process.stdin.isTTY)
|
|
392
|
+
if (process.stdin.isTTY) {
|
|
393
|
+
return interactiveSetup({
|
|
394
|
+
installed,
|
|
395
|
+
stale,
|
|
396
|
+
latest,
|
|
397
|
+
signedIn,
|
|
398
|
+
running,
|
|
399
|
+
credentials,
|
|
400
|
+
chromiumInstalled: checks.chromiumInstalled,
|
|
401
|
+
});
|
|
402
|
+
}
|
|
363
403
|
|
|
364
404
|
const steps: string[][] = [];
|
|
365
405
|
if (!installed) {
|
|
@@ -369,11 +409,11 @@ async function setup(): Promise<number> {
|
|
|
369
409
|
}
|
|
370
410
|
if (signedIn.state === "revoked") {
|
|
371
411
|
steps.push([
|
|
372
|
-
|
|
373
|
-
"The saved
|
|
412
|
+
"aloud login",
|
|
413
|
+
"The saved connection was revoked. This prints a new browser-approval link and code.",
|
|
374
414
|
]);
|
|
375
415
|
}
|
|
376
|
-
if (signedIn.state === "none"
|
|
416
|
+
if (signedIn.state === "none") {
|
|
377
417
|
steps.push([
|
|
378
418
|
"aloud login",
|
|
379
419
|
"Prints a link and a short code, then waits. Give both to the person; they approve in",
|
|
@@ -391,6 +431,28 @@ async function setup(): Promise<number> {
|
|
|
391
431
|
return 1;
|
|
392
432
|
}
|
|
393
433
|
|
|
434
|
+
if (steps.length === 0 && running && signedIn.state === "ok" && !signedIn.online) {
|
|
435
|
+
out("The runner process exists locally, but Aloud has not confirmed a current check-in.");
|
|
436
|
+
out(`Read ${join(dirname(credentialsPath()), "runner.log")} and run \`aloud status\` again.`);
|
|
437
|
+
out("Do not report this machine ready until `Aloud sees` says yes.");
|
|
438
|
+
out();
|
|
439
|
+
return 1;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
if (
|
|
443
|
+
steps.length === 0 &&
|
|
444
|
+
running &&
|
|
445
|
+
signedIn.state === "ok" &&
|
|
446
|
+
signedIn.online &&
|
|
447
|
+
signedIn.lastVersion !== RUNNER_VERSION
|
|
448
|
+
) {
|
|
449
|
+
out(`The live process is checking in as v${signedIn.lastVersion ?? "unknown"}, not v${RUNNER_VERSION}.`);
|
|
450
|
+
out(`Restart it with: kill ${running.pid} && aloud start`);
|
|
451
|
+
out("Do not report the update complete until `aloud status` shows the expected version.");
|
|
452
|
+
out();
|
|
453
|
+
return 1;
|
|
454
|
+
}
|
|
455
|
+
|
|
394
456
|
if (steps.length === 0) {
|
|
395
457
|
out("Nothing to do. This machine is set up and waiting for studies.");
|
|
396
458
|
out();
|
|
@@ -418,7 +480,7 @@ async function setup(): Promise<number> {
|
|
|
418
480
|
);
|
|
419
481
|
}
|
|
420
482
|
out(" - The package is `@aloud/runner`, exactly. If that name does not resolve, stop and say so.");
|
|
421
|
-
out(" Do not install a similarly named package
|
|
483
|
+
out(" Do not install a similarly named package.");
|
|
422
484
|
out(" - There is nothing secret for you to handle. `aloud login` prints a link and a code,");
|
|
423
485
|
out(" and the credential goes straight to this machine. Never ask anyone for a token.");
|
|
424
486
|
out(" - `aloud start` does not exit. Run it in a background shell and do not block on it.");
|
|
@@ -444,9 +506,10 @@ async function interactiveSetup(state: {
|
|
|
444
506
|
installed: boolean;
|
|
445
507
|
stale: boolean;
|
|
446
508
|
latest: string | null;
|
|
447
|
-
signedIn:
|
|
509
|
+
signedIn: SignedInState;
|
|
448
510
|
running: RunningState | null;
|
|
449
511
|
credentials: Credentials | null;
|
|
512
|
+
chromiumInstalled: boolean;
|
|
450
513
|
}): Promise<number> {
|
|
451
514
|
const out = (line = "") => process.stdout.write(line + "\n");
|
|
452
515
|
const server = state.credentials?.server ?? DEFAULT_SERVER;
|
|
@@ -489,13 +552,35 @@ async function interactiveSetup(state: {
|
|
|
489
552
|
out("Start it when you are ready, and leave it running: aloud start");
|
|
490
553
|
return 1;
|
|
491
554
|
}
|
|
492
|
-
return startDetached(out);
|
|
555
|
+
return startDetached(out, { waitForConfirmation: state.chromiumInstalled });
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
if (
|
|
559
|
+
state.signedIn.state === "ok" &&
|
|
560
|
+
state.signedIn.online &&
|
|
561
|
+
state.signedIn.lastVersion === RUNNER_VERSION
|
|
562
|
+
) {
|
|
563
|
+
out("");
|
|
564
|
+
out("Set up and confirmed by Aloud. This machine is waiting for eligible studies.");
|
|
565
|
+
out("");
|
|
566
|
+
return 0;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
if (state.signedIn.state === "ok" && state.signedIn.online) {
|
|
570
|
+
out("");
|
|
571
|
+
out(
|
|
572
|
+
`The live process is checking in as v${state.signedIn.lastVersion ?? "unknown"}, not v${RUNNER_VERSION}.`,
|
|
573
|
+
);
|
|
574
|
+
out(`Restart it with: kill ${state.running.pid} && aloud start`);
|
|
575
|
+
out("");
|
|
576
|
+
return 1;
|
|
493
577
|
}
|
|
494
578
|
|
|
495
579
|
out("");
|
|
496
|
-
out("
|
|
580
|
+
out("The runner process exists locally, but Aloud has not confirmed a current check-in.");
|
|
581
|
+
out(`Read ${join(dirname(credentialsPath()), "runner.log")} and run \`aloud status\` again.`);
|
|
497
582
|
out("");
|
|
498
|
-
return
|
|
583
|
+
return 1;
|
|
499
584
|
} finally {
|
|
500
585
|
rl.close();
|
|
501
586
|
}
|
|
@@ -526,9 +611,16 @@ async function run(command: string, args: readonly string[], out: (line?: string
|
|
|
526
611
|
* Output goes to a file rather than nowhere, because the first start downloads Chromium and a
|
|
527
612
|
* silent five minutes is indistinguishable from a hang.
|
|
528
613
|
*/
|
|
529
|
-
async function startDetached(
|
|
614
|
+
async function startDetached(
|
|
615
|
+
out: (line?: string) => void,
|
|
616
|
+
options: { waitForConfirmation: boolean },
|
|
617
|
+
): Promise<number> {
|
|
530
618
|
const log = join(dirname(credentialsPath()), "runner.log");
|
|
531
619
|
await mkdir(dirname(log), { recursive: true, mode: 0o700 });
|
|
620
|
+
const credentials = await readCredentials();
|
|
621
|
+
const before = credentials
|
|
622
|
+
? await readRunnerPresence({ server: credentials.server, token: credentials.token })
|
|
623
|
+
: null;
|
|
532
624
|
const handle = openSync(log, "a");
|
|
533
625
|
const child = spawn(process.execPath, [process.argv[1] ?? "", "start"], {
|
|
534
626
|
detached: true,
|
|
@@ -542,9 +634,40 @@ async function startDetached(out: (line?: string) => void): Promise<number> {
|
|
|
542
634
|
out(" Check it aloud status");
|
|
543
635
|
out(` Stop it kill ${child.pid}`);
|
|
544
636
|
out("");
|
|
545
|
-
|
|
637
|
+
if (!options.waitForConfirmation) {
|
|
638
|
+
out("The runner is preparing Chromium in the background. It is started locally, but not yet");
|
|
639
|
+
out("confirmed by Aloud. The Machines page updates automatically after its first check-in.");
|
|
640
|
+
out("");
|
|
641
|
+
return 0;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
if (!credentials || before?.state !== "ok") {
|
|
645
|
+
out("Started locally, but Aloud could not establish a before-start status to confirm this launch.");
|
|
646
|
+
out(`Read ${log} and run \`aloud status\`; do not call it ready until \`Aloud sees\` says yes.`);
|
|
647
|
+
out("");
|
|
648
|
+
return 1;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
out("Waiting for Aloud to confirm the first check-in…");
|
|
652
|
+
const confirmation = await waitForRunnerCheckIn({
|
|
653
|
+
server: credentials.server,
|
|
654
|
+
token: credentials.token,
|
|
655
|
+
previousLastSeenAt: before.presence.lastSeenAt,
|
|
656
|
+
});
|
|
657
|
+
if (confirmation === "confirmed") {
|
|
658
|
+
out("Confirmed by Aloud. This machine is online and waiting for eligible studies.");
|
|
659
|
+
out("");
|
|
660
|
+
return 0;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
if (confirmation === "revoked") {
|
|
664
|
+
out("Aloud refused the saved connection. Run `aloud login` to approve this machine again.");
|
|
665
|
+
} else {
|
|
666
|
+
out("The process started, but Aloud did not confirm a check-in within 15 seconds.");
|
|
667
|
+
out(`Read ${log} and run \`aloud status\`; do not call it ready until \`Aloud sees\` says yes.`);
|
|
668
|
+
}
|
|
546
669
|
out("");
|
|
547
|
-
return
|
|
670
|
+
return 1;
|
|
548
671
|
}
|
|
549
672
|
|
|
550
673
|
/**
|
|
@@ -555,21 +678,30 @@ async function startDetached(out: (line?: string) => void): Promise<number> {
|
|
|
555
678
|
* presence sent an agent off to `aloud start`, which died on its first poll with a 401 and no
|
|
556
679
|
* explanation of what to do. This is the same endpoint `login` checks a token against.
|
|
557
680
|
*/
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
681
|
+
type SignedInState =
|
|
682
|
+
| { state: "none" }
|
|
683
|
+
| {
|
|
684
|
+
state: "ok";
|
|
685
|
+
name: string;
|
|
686
|
+
online: boolean;
|
|
687
|
+
lastSeenAt: string | null;
|
|
688
|
+
lastVersion: string | null;
|
|
689
|
+
}
|
|
690
|
+
| { state: "revoked" }
|
|
691
|
+
| { state: "unreachable"; server: string };
|
|
692
|
+
|
|
693
|
+
async function signedInState(credentials: Credentials | null): Promise<SignedInState> {
|
|
561
694
|
if (!credentials) return { state: "none" };
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
}
|
|
695
|
+
const result = await readRunnerPresence({ server: credentials.server, token: credentials.token });
|
|
696
|
+
if (result.state === "revoked") return { state: "revoked" };
|
|
697
|
+
if (result.state === "unreachable") return { state: "unreachable", server: credentials.server };
|
|
698
|
+
return {
|
|
699
|
+
state: "ok",
|
|
700
|
+
name: credentials.runnerName,
|
|
701
|
+
online: result.presence.online,
|
|
702
|
+
lastSeenAt: result.presence.lastSeenAt,
|
|
703
|
+
lastVersion: result.presence.lastVersion,
|
|
704
|
+
};
|
|
573
705
|
}
|
|
574
706
|
|
|
575
707
|
/**
|
|
@@ -624,6 +756,182 @@ async function latestVersion(): Promise<string | null> {
|
|
|
624
756
|
}
|
|
625
757
|
}
|
|
626
758
|
|
|
759
|
+
/**
|
|
760
|
+
* The release policy belongs to the control plane this machine is connected to, not to npm.
|
|
761
|
+
*
|
|
762
|
+
* That distinction lets a self-hosted deployment deliberately lag the hosted service and lets the
|
|
763
|
+
* hosted service publish a runner before recommending it. The response is validated before any
|
|
764
|
+
* part of it reaches an install command; even a compromised control plane can select only an exact
|
|
765
|
+
* numeric release of the official scoped package.
|
|
766
|
+
*/
|
|
767
|
+
async function serverVersionPolicy(credentials: Credentials): Promise<RunnerVersionPolicy | null> {
|
|
768
|
+
try {
|
|
769
|
+
const client = new RunnerClient({ server: credentials.server, token: credentials.token });
|
|
770
|
+
const response = await client.request<{ runnerVersionPolicy?: unknown }>("api/runner/me", {
|
|
771
|
+
retry: false,
|
|
772
|
+
});
|
|
773
|
+
return runnerVersionPolicyFrom(response.body?.runnerVersionPolicy);
|
|
774
|
+
} catch {
|
|
775
|
+
// Startup still reaches the ordinary authenticated claim below. A revoked token, unreachable
|
|
776
|
+
// server, or old self-hosted control plane will be explained there; an update convenience must
|
|
777
|
+
// not replace the runner's established connection behavior.
|
|
778
|
+
return null;
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/**
|
|
783
|
+
* Updates before any browser or lease exists, then starts the newly installed bundle.
|
|
784
|
+
*
|
|
785
|
+
* `null` means the current process should continue. A number means startup has been handed to the
|
|
786
|
+
* replacement process, or could not safely continue because the server requires that replacement.
|
|
787
|
+
*/
|
|
788
|
+
export interface StartUpdateDependencies {
|
|
789
|
+
versionPolicy(credentials: Credentials): Promise<RunnerVersionPolicy | null>;
|
|
790
|
+
npmPrefix(): Promise<{ path: string; writable: boolean | null } | null>;
|
|
791
|
+
install(target: string, write: (text: string) => void): Promise<boolean>;
|
|
792
|
+
installedEntry(target: string): Promise<string | null>;
|
|
793
|
+
relaunch(entry: string, argv: readonly string[]): Promise<number>;
|
|
794
|
+
stdout(text: string): void;
|
|
795
|
+
stderr(text: string): void;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
export async function updateBeforeStart(
|
|
799
|
+
credentials: Credentials,
|
|
800
|
+
argv: readonly string[],
|
|
801
|
+
overrides: Partial<StartUpdateDependencies> = {},
|
|
802
|
+
): Promise<number | null> {
|
|
803
|
+
const stdout = overrides.stdout ?? ((text: string) => process.stdout.write(text));
|
|
804
|
+
const stderr = overrides.stderr ?? ((text: string) => process.stderr.write(text));
|
|
805
|
+
const policy = await (overrides.versionPolicy ?? serverVersionPolicy)(credentials);
|
|
806
|
+
if (!policy) return null;
|
|
807
|
+
const update = runnerUpdateFor(policy);
|
|
808
|
+
if (!update) return null;
|
|
809
|
+
|
|
810
|
+
if (argv.includes("--no-update")) {
|
|
811
|
+
if (!update.required) return null;
|
|
812
|
+
stderr(
|
|
813
|
+
`Runner ${RUNNER_VERSION} cannot start studies on this server; ${policy.minimum} or newer is required.\n` +
|
|
814
|
+
`Automatic updates were disabled. Remove --no-update or run npm install -g @aloud/runner@${update.target}.\n`,
|
|
815
|
+
);
|
|
816
|
+
return 1;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
stdout(`\nUpdating Aloud runner ${RUNNER_VERSION} → ${update.target} before it starts.\n`);
|
|
820
|
+
const prefix = await (overrides.npmPrefix ?? npmPrefix)();
|
|
821
|
+
if (prefix?.writable === false) {
|
|
822
|
+
return failedAutomaticUpdate(
|
|
823
|
+
update,
|
|
824
|
+
policy,
|
|
825
|
+
`npm's global install directory (${prefix.path}) is not writable by this user.`,
|
|
826
|
+
stderr,
|
|
827
|
+
);
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
const installed = overrides.install
|
|
831
|
+
? await overrides.install(update.target, stdout)
|
|
832
|
+
: await run(
|
|
833
|
+
"npm",
|
|
834
|
+
["install", "-g", `@aloud/runner@${update.target}`],
|
|
835
|
+
(line = "") => stdout(line + "\n"),
|
|
836
|
+
);
|
|
837
|
+
if (!installed) {
|
|
838
|
+
return failedAutomaticUpdate(update, policy, "npm did not complete the update.", stderr);
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
// Do not relaunch `process.argv[1]`: a runner started through npx or a project-local shim can
|
|
842
|
+
// live somewhere entirely different from the global package npm just replaced. Resolve npm's
|
|
843
|
+
// canonical global root and verify the exact installed version before handing it any work.
|
|
844
|
+
const entry = await (overrides.installedEntry ?? installedRunnerEntry)(update.target);
|
|
845
|
+
if (!entry) {
|
|
846
|
+
return failedAutomaticUpdate(
|
|
847
|
+
update,
|
|
848
|
+
policy,
|
|
849
|
+
`npm completed, but the installed @aloud/runner@${update.target} could not be verified.`,
|
|
850
|
+
stderr,
|
|
851
|
+
);
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
stdout(`\nUpdated to ${update.target}. Starting it now.\n\n`);
|
|
855
|
+
return (overrides.relaunch ?? relaunchUpdatedRunner)(entry, argv);
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
function failedAutomaticUpdate(
|
|
859
|
+
update: RunnerUpdate,
|
|
860
|
+
policy: RunnerVersionPolicy,
|
|
861
|
+
reason: string,
|
|
862
|
+
stderr: (text: string) => void,
|
|
863
|
+
): number | null {
|
|
864
|
+
const command = `npm install -g @aloud/runner@${update.target}`;
|
|
865
|
+
if (update.required) {
|
|
866
|
+
stderr(
|
|
867
|
+
`\n${reason}\nRunner ${RUNNER_VERSION} is below this server's minimum ${policy.minimum}, so nothing was started.\n` +
|
|
868
|
+
`Do not use sudo. Fix npm's global prefix, then run: ${command}\n`,
|
|
869
|
+
);
|
|
870
|
+
return 1;
|
|
871
|
+
}
|
|
872
|
+
stderr(
|
|
873
|
+
`\n${reason}\nRunner ${RUNNER_VERSION} is still compatible, so it will start without updating.\n` +
|
|
874
|
+
`To update it later, run: ${command}\n\n`,
|
|
875
|
+
);
|
|
876
|
+
return null;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
/** Finds the exact global bundle npm just installed, independently of how this process was run. */
|
|
880
|
+
async function installedRunnerEntry(target: string): Promise<string | null> {
|
|
881
|
+
const root = await commandOutput("npm", ["root", "--global"]);
|
|
882
|
+
if (!root) return null;
|
|
883
|
+
|
|
884
|
+
const directory = join(root, "@aloud", "runner");
|
|
885
|
+
const entry = join(directory, "dist", "cli.js");
|
|
886
|
+
try {
|
|
887
|
+
const manifest = JSON.parse(readFileSync(join(directory, "package.json"), "utf8")) as {
|
|
888
|
+
version?: unknown;
|
|
889
|
+
};
|
|
890
|
+
return manifest.version === target && existsSync(entry) ? entry : null;
|
|
891
|
+
} catch {
|
|
892
|
+
return null;
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
/** Captures one short command result without involving a shell. */
|
|
897
|
+
async function commandOutput(command: string, args: readonly string[]): Promise<string | null> {
|
|
898
|
+
return new Promise((resolve) => {
|
|
899
|
+
const child = spawn(command, [...args], { stdio: ["ignore", "pipe", "ignore"] });
|
|
900
|
+
let output = "";
|
|
901
|
+
let settled = false;
|
|
902
|
+
const finish = (value: string | null) => {
|
|
903
|
+
if (settled) return;
|
|
904
|
+
settled = true;
|
|
905
|
+
resolve(value);
|
|
906
|
+
};
|
|
907
|
+
child.stdout?.on("data", (chunk: Buffer) => (output += chunk.toString("utf8")));
|
|
908
|
+
child.on("error", () => finish(null));
|
|
909
|
+
child.on("close", (code: number | null) => finish(code === 0 && output.trim() ? output.trim() : null));
|
|
910
|
+
});
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
/** The old bundle stays only as a transparent parent while the verified new bundle runs. */
|
|
914
|
+
async function relaunchUpdatedRunner(entry: string, argv: readonly string[]): Promise<number> {
|
|
915
|
+
return new Promise((resolve) => {
|
|
916
|
+
let settled = false;
|
|
917
|
+
const finish = (code: number) => {
|
|
918
|
+
if (settled) return;
|
|
919
|
+
settled = true;
|
|
920
|
+
resolve(code);
|
|
921
|
+
};
|
|
922
|
+
const child = spawn(process.execPath, [entry, "start", ...argv, "--no-update"], {
|
|
923
|
+
stdio: "inherit",
|
|
924
|
+
});
|
|
925
|
+
child.on("error", (error: Error) => {
|
|
926
|
+
process.stderr.write(`The runner updated but could not relaunch: ${error.message}\nRun \`aloud start\` again.\n`);
|
|
927
|
+
finish(1);
|
|
928
|
+
});
|
|
929
|
+
child.on("close", (code: number | null, signal: NodeJS.Signals | null) => {
|
|
930
|
+
finish(code ?? (signal ? 130 : 1));
|
|
931
|
+
});
|
|
932
|
+
});
|
|
933
|
+
}
|
|
934
|
+
|
|
627
935
|
/* --------------------------------- status --------------------------------- */
|
|
628
936
|
|
|
629
937
|
async function status(): Promise<number> {
|
|
@@ -633,6 +941,7 @@ async function status(): Promise<number> {
|
|
|
633
941
|
});
|
|
634
942
|
const checks = await preflight();
|
|
635
943
|
const running = await readRunning();
|
|
944
|
+
const signedIn = await signedInState(credentials);
|
|
636
945
|
|
|
637
946
|
process.stdout.write("\n");
|
|
638
947
|
if (!credentials) {
|
|
@@ -648,11 +957,27 @@ async function status(): Promise<number> {
|
|
|
648
957
|
process.stdout.write(
|
|
649
958
|
`Running ${running ? `yes, since ${when(running.startedAt)} (pid ${running.pid})` : "no. Run `aloud start`."}\n`,
|
|
650
959
|
);
|
|
960
|
+
process.stdout.write(
|
|
961
|
+
`Aloud sees ${
|
|
962
|
+
signedIn.state === "ok"
|
|
963
|
+
? signedIn.online
|
|
964
|
+
? `yes${signedIn.lastVersion ? ` (v${signedIn.lastVersion})` : ""}${
|
|
965
|
+
signedIn.lastSeenAt ? `, checked in ${when(signedIn.lastSeenAt)}` : ""
|
|
966
|
+
}`
|
|
967
|
+
: "no current check-in"
|
|
968
|
+
: signedIn.state === "unreachable"
|
|
969
|
+
? "unknown, server did not answer"
|
|
970
|
+
: "no"
|
|
971
|
+
}\n`,
|
|
972
|
+
);
|
|
651
973
|
process.stdout.write("\n");
|
|
652
974
|
|
|
653
975
|
// Zero means "a study started now would run here", which is the only question worth asking of
|
|
654
|
-
// this command.
|
|
655
|
-
|
|
976
|
+
// this command. A local PID without a server-confirmed claim poll is not ready either: that exact
|
|
977
|
+
// disagreement is what previously made setup and the Machines page contradict one another.
|
|
978
|
+
return credentials && checks.chromiumInstalled && running && signedIn.state === "ok" && signedIn.online
|
|
979
|
+
? 0
|
|
980
|
+
: 1;
|
|
656
981
|
}
|
|
657
982
|
|
|
658
983
|
/* --------------------------------- allow --------------------------------- */
|
|
@@ -689,6 +1014,21 @@ async function start(argv: readonly string[]): Promise<number> {
|
|
|
689
1014
|
return 1;
|
|
690
1015
|
}
|
|
691
1016
|
|
|
1017
|
+
// Two starts using one runner identity can claim two studies while status reports only the last
|
|
1018
|
+
// pid written. Refuse before updating or polling; a restart has to stop the existing process
|
|
1019
|
+
// first, which also guarantees we never replace its package while it is inside an active study.
|
|
1020
|
+
const existing = await readRunning();
|
|
1021
|
+
if (existing && existing.pid !== process.pid) {
|
|
1022
|
+
process.stderr.write(
|
|
1023
|
+
`Aloud runner is already running as pid ${existing.pid}. Stop that process before starting another.\n` +
|
|
1024
|
+
`To restart it: kill ${existing.pid} && aloud start\n`,
|
|
1025
|
+
);
|
|
1026
|
+
return 1;
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
const updated = await updateBeforeStart(credentials, argv);
|
|
1030
|
+
if (updated !== null) return updated;
|
|
1031
|
+
|
|
692
1032
|
const reporter = new TerminalReporter(process.stdout, credentials.token, !argv.includes("--quiet"));
|
|
693
1033
|
|
|
694
1034
|
// Preflight runs before the first claim, deliberately. A missing Chromium makes
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { RUNNER_VERSION, RUNNER_VERSION_HEADER } from "../version";
|
|
2
|
+
|
|
3
|
+
export interface RunnerPresence {
|
|
4
|
+
online: boolean;
|
|
5
|
+
lastSeenAt: string | null;
|
|
6
|
+
lastVersion: string | null;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export type PresenceRead =
|
|
10
|
+
| { state: "ok"; presence: RunnerPresence }
|
|
11
|
+
| { state: "revoked" }
|
|
12
|
+
| { state: "unreachable" };
|
|
13
|
+
|
|
14
|
+
/** Reads server-confirmed presence without itself updating the runner's last-seen clock. */
|
|
15
|
+
export async function readRunnerPresence(input: {
|
|
16
|
+
server: string;
|
|
17
|
+
token: string;
|
|
18
|
+
fetchImpl?: typeof fetch;
|
|
19
|
+
timeoutMs?: number;
|
|
20
|
+
}): Promise<PresenceRead> {
|
|
21
|
+
const fetchImpl = input.fetchImpl ?? fetch;
|
|
22
|
+
try {
|
|
23
|
+
const response = await fetchImpl(new URL("api/runner/me", input.server + "/"), {
|
|
24
|
+
headers: {
|
|
25
|
+
authorization: `Bearer ${input.token}`,
|
|
26
|
+
accept: "application/json",
|
|
27
|
+
[RUNNER_VERSION_HEADER]: RUNNER_VERSION,
|
|
28
|
+
},
|
|
29
|
+
signal: AbortSignal.timeout(input.timeoutMs ?? 5_000),
|
|
30
|
+
});
|
|
31
|
+
if (response.status === 401 || response.status === 403) return { state: "revoked" };
|
|
32
|
+
if (!response.ok) return { state: "unreachable" };
|
|
33
|
+
const body = (await response.json().catch(() => ({}))) as Partial<RunnerPresence>;
|
|
34
|
+
return {
|
|
35
|
+
state: "ok",
|
|
36
|
+
presence: {
|
|
37
|
+
online: body.online === true,
|
|
38
|
+
lastSeenAt: typeof body.lastSeenAt === "string" ? body.lastSeenAt : null,
|
|
39
|
+
lastVersion: typeof body.lastVersion === "string" ? body.lastVersion : null,
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
} catch {
|
|
43
|
+
return { state: "unreachable" };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Waits for a newly launched process to make a claim poll the control plane has actually recorded.
|
|
49
|
+
*
|
|
50
|
+
* A PID is local evidence only. Requiring `lastSeenAt` to change prevents a recent heartbeat from a
|
|
51
|
+
* previous process being mistaken for confirmation of this launch.
|
|
52
|
+
*/
|
|
53
|
+
export async function waitForRunnerCheckIn(input: {
|
|
54
|
+
server: string;
|
|
55
|
+
token: string;
|
|
56
|
+
previousLastSeenAt: string | null;
|
|
57
|
+
expectedVersion?: string;
|
|
58
|
+
fetchImpl?: typeof fetch;
|
|
59
|
+
sleep?: (ms: number) => Promise<void>;
|
|
60
|
+
now?: () => number;
|
|
61
|
+
timeoutMs?: number;
|
|
62
|
+
pollMs?: number;
|
|
63
|
+
}): Promise<"confirmed" | "revoked" | "timeout"> {
|
|
64
|
+
const now = input.now ?? Date.now;
|
|
65
|
+
const sleep = input.sleep ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
66
|
+
const deadline = now() + (input.timeoutMs ?? 15_000);
|
|
67
|
+
|
|
68
|
+
for (;;) {
|
|
69
|
+
const result = await readRunnerPresence(input);
|
|
70
|
+
if (result.state === "revoked") return "revoked";
|
|
71
|
+
if (
|
|
72
|
+
result.state === "ok" &&
|
|
73
|
+
result.presence.online &&
|
|
74
|
+
result.presence.lastSeenAt !== null &&
|
|
75
|
+
result.presence.lastSeenAt !== input.previousLastSeenAt &&
|
|
76
|
+
result.presence.lastVersion === (input.expectedVersion ?? RUNNER_VERSION)
|
|
77
|
+
) {
|
|
78
|
+
return "confirmed";
|
|
79
|
+
}
|
|
80
|
+
if (now() >= deadline) return "timeout";
|
|
81
|
+
await sleep(Math.min(input.pollMs ?? 750, Math.max(0, deadline - now())));
|
|
82
|
+
}
|
|
83
|
+
}
|