@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aloud/runner",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
4
  "description": "Run Aloud usability studies in a real browser on your own machine, so a study can reach localhost and anything else behind your network.",
5
5
  "license": "ISC",
6
6
  "repository": {
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 { accessSync, constants, existsSync, readFileSync, unlinkSync } from "node:fs";
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(` signed in ${credentials ? credentials.runnerName : "no"}`);
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
- const steps: string[] = [];
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 # ${RUNNER_VERSION} is installed, ${latest} is out`);
249
+ steps.push([`npm install -g @aloud/runner@latest`, `${RUNNER_VERSION} is installed, ${latest} is out.`]);
231
250
  }
232
- if (!credentials) {
233
- steps.push(
234
- "aloud login # needs a terminal. If you are an agent, hand this step to the person:",
235
- );
236
- steps.push(
237
- " # they run it themselves and paste the token at the prompt.",
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 # in a background shell. It never exits.");
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, step] of steps.entries()) out(` ${index + 1}. ${step}`);
253
- out(` ${steps.length + 1}. aloud status # exits non-zero until all of the above are true`);
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
  *
@@ -336,8 +336,24 @@ function failureFrom(outcome: RunOutcome): string | null {
336
336
  return null;
337
337
  }
338
338
 
339
- function describeOutcome(result: { status: string; selfReportedOutcome?: string | null; selfReportedReason?: string | null }): string {
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.3";
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";