@aloud/runner 0.2.3 → 0.2.5
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/dist/cli.js +548 -224
- package/package.json +1 -1
- package/src/cli.ts +194 -16
- package/src/run/execute.ts +17 -1
- package/src/version.ts +1 -1
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { createInterface } from "node:readline/promises";
|
|
10
10
|
import { spawn } from "node:child_process";
|
|
11
|
-
import {
|
|
11
|
+
import { mkdir } from "node:fs/promises";
|
|
12
|
+
import { accessSync, constants, existsSync, openSync, readFileSync, unlinkSync } from "node:fs";
|
|
12
13
|
import { hostname } from "node:os";
|
|
13
14
|
import { delimiter, dirname, join } from "node:path";
|
|
14
15
|
import { normaliseHosts } from "@aloud/core";
|
|
@@ -22,7 +23,7 @@ import {
|
|
|
22
23
|
type Credentials,
|
|
23
24
|
} from "./config/credentials";
|
|
24
25
|
import { policyFrom, type LocalPolicy } from "./config/policy";
|
|
25
|
-
import { clearRunning, readRunning, runningPath, writeRunning } from "./config/running";
|
|
26
|
+
import { clearRunning, readRunning, runningPath, writeRunning, type RunningState } from "./config/running";
|
|
26
27
|
import { RUNNER_VERSION } from "./version";
|
|
27
28
|
import { RunnerClient } from "./protocol/client";
|
|
28
29
|
import { installChromium, preflight } from "./preflight";
|
|
@@ -210,6 +211,7 @@ async function setup(): Promise<number> {
|
|
|
210
211
|
const installed = onPath("aloud");
|
|
211
212
|
const latest = await latestVersion();
|
|
212
213
|
const stale = latest !== null && latest !== RUNNER_VERSION;
|
|
214
|
+
const signedIn = await signedInState(credentials);
|
|
213
215
|
|
|
214
216
|
const out = (line = "") => process.stdout.write(line + "\n");
|
|
215
217
|
|
|
@@ -218,27 +220,56 @@ async function setup(): Promise<number> {
|
|
|
218
220
|
out();
|
|
219
221
|
out(` installed ${installed ? `yes (${RUNNER_VERSION})` : "no"}`);
|
|
220
222
|
out(` up to date ${latest === null ? "unknown, could not reach the registry" : stale ? `no, ${latest} is out` : "yes"}`);
|
|
221
|
-
out(
|
|
223
|
+
out(
|
|
224
|
+
` signed in ${
|
|
225
|
+
signedIn.state === "ok"
|
|
226
|
+
? signedIn.name
|
|
227
|
+
: signedIn.state === "revoked"
|
|
228
|
+
? `no. The saved token for ${credentials?.runnerName ?? "this machine"} was revoked`
|
|
229
|
+
: signedIn.state === "unreachable"
|
|
230
|
+
? `cannot tell, ${signedIn.server} did not answer`
|
|
231
|
+
: "no"
|
|
232
|
+
}`,
|
|
233
|
+
);
|
|
222
234
|
out(` chromium ${checks.chromiumInstalled ? "ready" : "downloads on first start, about 350 MB"}`);
|
|
223
235
|
out(` running ${running ? `yes (pid ${running.pid})` : "no"}`);
|
|
224
236
|
out();
|
|
225
237
|
|
|
226
|
-
|
|
238
|
+
// Each step is a command plus the lines that qualify it. Only the command gets a number, or a
|
|
239
|
+
// continuation reads as a step of its own, and an agent following "step 2" ends up pasting a URL
|
|
240
|
+
// into a shell.
|
|
241
|
+
// A person in a terminal gets setup done, not a list of things to go and do. An agent, which has
|
|
242
|
+
// no terminal, gets the list. Same command, and the difference is who can answer a prompt.
|
|
243
|
+
if (process.stdin.isTTY) return interactiveSetup({ installed, stale, latest, signedIn, running, credentials });
|
|
244
|
+
|
|
245
|
+
const steps: string[][] = [];
|
|
227
246
|
if (!installed) {
|
|
228
|
-
steps.push("npm install -g @aloud/runner");
|
|
247
|
+
steps.push(["npm install -g @aloud/runner"]);
|
|
229
248
|
} else if (stale) {
|
|
230
|
-
steps.push(`npm install -g @aloud/runner@latest
|
|
249
|
+
steps.push([`npm install -g @aloud/runner@latest`, `${RUNNER_VERSION} is installed, ${latest} is out.`]);
|
|
231
250
|
}
|
|
232
|
-
if (
|
|
233
|
-
steps.push(
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
251
|
+
if (signedIn.state === "revoked") {
|
|
252
|
+
steps.push([
|
|
253
|
+
`Create a new token at ${credentials?.server ?? DEFAULT_SERVER}/app/settings/runners`,
|
|
254
|
+
"The saved one was revoked and cannot be reused.",
|
|
255
|
+
]);
|
|
256
|
+
}
|
|
257
|
+
if (signedIn.state === "none" || signedIn.state === "revoked") {
|
|
258
|
+
steps.push([
|
|
259
|
+
"aloud login",
|
|
260
|
+
"Needs a terminal. If you are an agent, hand this step to the person: they run it",
|
|
261
|
+
"themselves and paste the token at the prompt. Do not ask them to paste it to you.",
|
|
262
|
+
]);
|
|
239
263
|
}
|
|
240
264
|
if (!running) {
|
|
241
|
-
steps.push("aloud start
|
|
265
|
+
steps.push(["aloud start", "In a background shell. It never exits, so do not block on it."]);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (steps.length === 0 && signedIn.state === "unreachable") {
|
|
269
|
+
out(`Cannot reach ${signedIn.server}, so there is nothing useful to say about what is left.`);
|
|
270
|
+
out("Check the connection and run this again.");
|
|
271
|
+
out();
|
|
272
|
+
return 1;
|
|
242
273
|
}
|
|
243
274
|
|
|
244
275
|
if (steps.length === 0) {
|
|
@@ -249,8 +280,12 @@ async function setup(): Promise<number> {
|
|
|
249
280
|
|
|
250
281
|
out("Do these, in order:");
|
|
251
282
|
out();
|
|
252
|
-
for (const [index,
|
|
253
|
-
|
|
283
|
+
for (const [index, [command, ...notes]] of steps.entries()) {
|
|
284
|
+
out(` ${index + 1}. ${command}`);
|
|
285
|
+
for (const note of notes) out(` ${note}`);
|
|
286
|
+
}
|
|
287
|
+
out(` ${steps.length + 1}. aloud status`);
|
|
288
|
+
out(" Exits non-zero until all of the above are true.");
|
|
254
289
|
out();
|
|
255
290
|
out("Rules, if you are an agent doing this:");
|
|
256
291
|
out();
|
|
@@ -277,6 +312,149 @@ async function setup(): Promise<number> {
|
|
|
277
312
|
return 1;
|
|
278
313
|
}
|
|
279
314
|
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Setup, performed rather than described.
|
|
319
|
+
*
|
|
320
|
+
* Everything here is a question first. Installing globally, writing a credential and starting a
|
|
321
|
+
* long-lived process are all things someone should agree to on their own machine, and the printed
|
|
322
|
+
* version of this command exists for the case where nobody can be asked.
|
|
323
|
+
*/
|
|
324
|
+
async function interactiveSetup(state: {
|
|
325
|
+
installed: boolean;
|
|
326
|
+
stale: boolean;
|
|
327
|
+
latest: string | null;
|
|
328
|
+
signedIn: { state: string };
|
|
329
|
+
running: RunningState | null;
|
|
330
|
+
credentials: Credentials | null;
|
|
331
|
+
}): Promise<number> {
|
|
332
|
+
const out = (line = "") => process.stdout.write(line + "\n");
|
|
333
|
+
const server = state.credentials?.server ?? DEFAULT_SERVER;
|
|
334
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
335
|
+
|
|
336
|
+
try {
|
|
337
|
+
if (!state.installed || state.stale) {
|
|
338
|
+
const what = state.installed ? `Update to ${state.latest}` : "Install it globally";
|
|
339
|
+
if (await confirm(rl, `${what} with npm?`)) {
|
|
340
|
+
const ok = await run("npm", ["install", "-g", "@aloud/runner@latest"], out);
|
|
341
|
+
if (!ok) {
|
|
342
|
+
out("");
|
|
343
|
+
out("That install did not work. If it asked for permissions, do not use sudo:");
|
|
344
|
+
out("npm's global prefix belongs to you or it does not, and sudo papers over the wrong one.");
|
|
345
|
+
return 1;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if (state.signedIn.state !== "ok") {
|
|
351
|
+
out("");
|
|
352
|
+
if (state.signedIn.state === "revoked") {
|
|
353
|
+
out(`The token saved here was revoked, so this machine needs a new one.`);
|
|
354
|
+
}
|
|
355
|
+
out(`Create a token at ${server}/app/settings/runners`);
|
|
356
|
+
out("It is shown once. Copy it, then paste it below.");
|
|
357
|
+
out("");
|
|
358
|
+
const token = (await rl.question("Token: ")).trim();
|
|
359
|
+
rl.close();
|
|
360
|
+
const code = await login(["--token", token, "--server", server]);
|
|
361
|
+
if (code !== 0) return code;
|
|
362
|
+
} else {
|
|
363
|
+
rl.close();
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (!state.running) {
|
|
367
|
+
const second = createInterface({ input: process.stdin, output: process.stdout });
|
|
368
|
+
const start = await confirm(second, "Start the runner now, in the background?");
|
|
369
|
+
second.close();
|
|
370
|
+
if (!start) {
|
|
371
|
+
out("");
|
|
372
|
+
out("Start it when you are ready, and leave it running: aloud start");
|
|
373
|
+
return 1;
|
|
374
|
+
}
|
|
375
|
+
return startDetached(out);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
out("");
|
|
379
|
+
out("Set up. This machine is waiting for studies.");
|
|
380
|
+
out("");
|
|
381
|
+
return 0;
|
|
382
|
+
} finally {
|
|
383
|
+
rl.close();
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** Yes unless clearly refused: the answer to every question here is the reason they ran this. */
|
|
388
|
+
async function confirm(rl: ReturnType<typeof createInterface>, question: string): Promise<boolean> {
|
|
389
|
+
const answer = (await rl.question(`${question} [Y/n] `)).trim().toLowerCase();
|
|
390
|
+
return answer === "" || answer === "y" || answer === "yes";
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** Runs a command, showing its output, because an install that says nothing looks like a hang. */
|
|
394
|
+
async function run(command: string, args: readonly string[], out: (line?: string) => void): Promise<boolean> {
|
|
395
|
+
out("");
|
|
396
|
+
out(` ${command} ${args.join(" ")}`);
|
|
397
|
+
return new Promise((resolve) => {
|
|
398
|
+
const child = spawn(command, [...args], { stdio: ["ignore", "pipe", "pipe"] });
|
|
399
|
+
child.stdout?.on("data", (chunk: Buffer) => out(" " + chunk.toString("utf8").trimEnd()));
|
|
400
|
+
child.stderr?.on("data", (chunk: Buffer) => out(" " + chunk.toString("utf8").trimEnd()));
|
|
401
|
+
child.on("error", () => resolve(false));
|
|
402
|
+
child.on("close", (code: number | null) => resolve(code === 0));
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Starts the runner and lets go of it, so closing this terminal does not stop the machine.
|
|
408
|
+
*
|
|
409
|
+
* Output goes to a file rather than nowhere, because the first start downloads Chromium and a
|
|
410
|
+
* silent five minutes is indistinguishable from a hang.
|
|
411
|
+
*/
|
|
412
|
+
async function startDetached(out: (line?: string) => void): Promise<number> {
|
|
413
|
+
const log = join(dirname(credentialsPath()), "runner.log");
|
|
414
|
+
await mkdir(dirname(log), { recursive: true, mode: 0o700 });
|
|
415
|
+
const handle = openSync(log, "a");
|
|
416
|
+
const child = spawn(process.execPath, [process.argv[1] ?? "", "start"], {
|
|
417
|
+
detached: true,
|
|
418
|
+
stdio: ["ignore", handle, handle],
|
|
419
|
+
});
|
|
420
|
+
child.unref();
|
|
421
|
+
|
|
422
|
+
out("");
|
|
423
|
+
out(`Started in the background, pid ${child.pid}.`);
|
|
424
|
+
out(` Output ${log}`);
|
|
425
|
+
out(" Check it aloud status");
|
|
426
|
+
out(` Stop it kill ${child.pid}`);
|
|
427
|
+
out("");
|
|
428
|
+
out("The first start downloads Chromium, about 350 MB, once. Studies will wait until it is done.");
|
|
429
|
+
out("");
|
|
430
|
+
return 0;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Whether the saved credential still works, asked of the server rather than assumed from the file.
|
|
435
|
+
*
|
|
436
|
+
* A credentials file proves a token was written here once, not that it is still good. Revoking a
|
|
437
|
+
* machine from the web app leaves the file exactly as it was, so reporting "signed in" from its
|
|
438
|
+
* presence sent an agent off to `aloud start`, which died on its first poll with a 401 and no
|
|
439
|
+
* explanation of what to do. This is the same endpoint `login` checks a token against.
|
|
440
|
+
*/
|
|
441
|
+
async function signedInState(
|
|
442
|
+
credentials: Credentials | null,
|
|
443
|
+
): Promise<{ state: "none" } | { state: "ok"; name: string } | { state: "revoked" } | { state: "unreachable"; server: string }> {
|
|
444
|
+
if (!credentials) return { state: "none" };
|
|
445
|
+
try {
|
|
446
|
+
const response = await fetch(new URL("api/runner/me", credentials.server + "/"), {
|
|
447
|
+
headers: { authorization: `Bearer ${credentials.token}` },
|
|
448
|
+
signal: AbortSignal.timeout(5_000),
|
|
449
|
+
});
|
|
450
|
+
if (response.status === 401 || response.status === 403) return { state: "revoked" };
|
|
451
|
+
if (!response.ok) return { state: "unreachable", server: credentials.server };
|
|
452
|
+
return { state: "ok", name: credentials.runnerName };
|
|
453
|
+
} catch {
|
|
454
|
+
return { state: "unreachable", server: credentials.server };
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
280
458
|
/**
|
|
281
459
|
* Where npm would put a global install, and whether it can be written to.
|
|
282
460
|
*
|
package/src/run/execute.ts
CHANGED
|
@@ -336,8 +336,24 @@ function failureFrom(outcome: RunOutcome): string | null {
|
|
|
336
336
|
return null;
|
|
337
337
|
}
|
|
338
338
|
|
|
339
|
-
|
|
339
|
+
/**
|
|
340
|
+
* The one line the person watching their own machine sees when a participant stops.
|
|
341
|
+
*
|
|
342
|
+
* The cap cases are named rather than folded into "finished", which is what they used to read as.
|
|
343
|
+
* A session the runtime cut off produced truncated evidence, and MONETIZATION treats that as a
|
|
344
|
+
* platform failure rather than a delivery, so the operator watching it happen should be told that
|
|
345
|
+
* plainly instead of being shown a word that means the opposite.
|
|
346
|
+
*/
|
|
347
|
+
function describeOutcome(result: {
|
|
348
|
+
status: string;
|
|
349
|
+
endReason?: string | null;
|
|
350
|
+
selfReportedOutcome?: string | null;
|
|
351
|
+
selfReportedReason?: string | null;
|
|
352
|
+
}): string {
|
|
340
353
|
if (result.status === "error") return "the browser stopped unexpectedly";
|
|
354
|
+
if (result.endReason === "cap_actions") return "ran out of actions before finishing";
|
|
355
|
+
if (result.endReason === "cap_elapsed") return "ran out of time before finishing";
|
|
356
|
+
if (result.endReason === "cap_tokens") return "ran out of its token budget before finishing";
|
|
341
357
|
if (result.selfReportedOutcome === "success") return "reached the goal";
|
|
342
358
|
if (result.selfReportedOutcome === "abandoned") return result.selfReportedReason ?? "gave up";
|
|
343
359
|
return result.selfReportedOutcome ?? "finished";
|
package/src/version.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* package.json beside it to read, and importing one into the source trips the composite build's
|
|
11
11
|
* rootDir. `version.test.ts` asserts this matches, so the drift this invites cannot survive CI.
|
|
12
12
|
*/
|
|
13
|
-
export const RUNNER_VERSION = "0.2.
|
|
13
|
+
export const RUNNER_VERSION = "0.2.5";
|
|
14
14
|
|
|
15
15
|
/** The header the server reads it from. */
|
|
16
16
|
export const RUNNER_VERSION_HEADER = "x-aloud-runner-version";
|