@aloud/runner 0.2.4 → 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 CHANGED
@@ -2578,9 +2578,10 @@ var init_src = __esm({
2578
2578
  init_src();
2579
2579
  import { createInterface } from "node:readline/promises";
2580
2580
  import { spawn as spawn2 } from "node:child_process";
2581
- import { accessSync, constants as constants2, existsSync as existsSync2, readFileSync, unlinkSync } from "node:fs";
2581
+ import { mkdir as mkdir4 } from "node:fs/promises";
2582
+ import { accessSync, constants as constants2, existsSync as existsSync2, openSync, readFileSync, unlinkSync } from "node:fs";
2582
2583
  import { hostname } from "node:os";
2583
- import { delimiter, join as join5 } from "node:path";
2584
+ import { delimiter, dirname as dirname4, join as join5 } from "node:path";
2584
2585
 
2585
2586
  // src/config/credentials.ts
2586
2587
  import { chmod, mkdir, readFile, stat, writeFile } from "node:fs/promises";
@@ -2749,7 +2750,7 @@ function alive(pid) {
2749
2750
  }
2750
2751
 
2751
2752
  // src/version.ts
2752
- var RUNNER_VERSION = "0.2.4";
2753
+ var RUNNER_VERSION = "0.2.5";
2753
2754
  var RUNNER_VERSION_HEADER = "x-aloud-runner-version";
2754
2755
 
2755
2756
  // src/protocol/client.ts
@@ -7292,13 +7293,13 @@ async function handleTool(context, name, input, execute) {
7292
7293
  }
7293
7294
  throw error;
7294
7295
  }
7295
- const { run, started, preflight: preflight2 } = outcome;
7296
- await execute?.(run.id, parsed.productId, started);
7296
+ const { run: run2, started, preflight: preflight2 } = outcome;
7297
+ await execute?.(run2.id, parsed.productId, started);
7297
7298
  return json({
7298
- runId: run.id,
7299
- resource: RESOURCE_SCHEMES.run(run.id),
7300
- webUrl: context.webBaseUrl ? `${context.webBaseUrl}/app/runs/${run.id}` : null,
7301
- status: run.status,
7299
+ runId: run2.id,
7300
+ resource: RESOURCE_SCHEMES.run(run2.id),
7301
+ webUrl: context.webBaseUrl ? `${context.webBaseUrl}/app/runs/${run2.id}` : null,
7302
+ status: run2.status,
7302
7303
  // SPEC FR-116: a retry with the same key reports that it found the existing run.
7303
7304
  alreadyRunning: !started,
7304
7305
  preflight: preflight2 ? summarisePreflight(preflight2) : null,
@@ -7326,8 +7327,8 @@ async function handleTool(context, name, input, execute) {
7326
7327
  }
7327
7328
  case "cancel_run": {
7328
7329
  const parsed = z25.object({ runId: z25.string(), reason: z25.string().optional() }).parse(input);
7329
- const run = await app.cancelRun(actor, { workspaceId, ...parsed });
7330
- return json({ runId: run.id, status: run.status });
7330
+ const run2 = await app.cancelRun(actor, { workspaceId, ...parsed });
7331
+ return json({ runId: run2.id, status: run2.status });
7331
7332
  }
7332
7333
  case "list_findings": {
7333
7334
  const parsed = RunIdInput.parse(input);
@@ -7699,7 +7700,7 @@ async function readResource(context, parsed) {
7699
7700
  name: study.name,
7700
7701
  goal: study.goal,
7701
7702
  status: study.status,
7702
- runs: runs.map((run) => ({ runId: run.id, status: run.status, resource: RESOURCE_SCHEMES.run(run.id) }))
7703
+ runs: runs.map((run2) => ({ runId: run2.id, status: run2.status, resource: RESOURCE_SCHEMES.run(run2.id) }))
7703
7704
  },
7704
7705
  null,
7705
7706
  2
@@ -7955,6 +7956,7 @@ async function setup() {
7955
7956
  out(` chromium ${checks.chromiumInstalled ? "ready" : "downloads on first start, about 350 MB"}`);
7956
7957
  out(` running ${running ? `yes (pid ${running.pid})` : "no"}`);
7957
7958
  out();
7959
+ if (process.stdin.isTTY) return interactiveSetup({ installed, stale, latest, signedIn, running, credentials });
7958
7960
  const steps = [];
7959
7961
  if (!installed) {
7960
7962
  steps.push(["npm install -g @aloud/runner"]);
@@ -8019,6 +8021,91 @@ async function setup() {
8019
8021
  }
8020
8022
  return 1;
8021
8023
  }
8024
+ async function interactiveSetup(state) {
8025
+ const out = (line = "") => process.stdout.write(line + "\n");
8026
+ const server = state.credentials?.server ?? DEFAULT_SERVER;
8027
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
8028
+ try {
8029
+ if (!state.installed || state.stale) {
8030
+ const what = state.installed ? `Update to ${state.latest}` : "Install it globally";
8031
+ if (await confirm(rl, `${what} with npm?`)) {
8032
+ const ok = await run("npm", ["install", "-g", "@aloud/runner@latest"], out);
8033
+ if (!ok) {
8034
+ out("");
8035
+ out("That install did not work. If it asked for permissions, do not use sudo:");
8036
+ out("npm's global prefix belongs to you or it does not, and sudo papers over the wrong one.");
8037
+ return 1;
8038
+ }
8039
+ }
8040
+ }
8041
+ if (state.signedIn.state !== "ok") {
8042
+ out("");
8043
+ if (state.signedIn.state === "revoked") {
8044
+ out(`The token saved here was revoked, so this machine needs a new one.`);
8045
+ }
8046
+ out(`Create a token at ${server}/app/settings/runners`);
8047
+ out("It is shown once. Copy it, then paste it below.");
8048
+ out("");
8049
+ const token = (await rl.question("Token: ")).trim();
8050
+ rl.close();
8051
+ const code = await login(["--token", token, "--server", server]);
8052
+ if (code !== 0) return code;
8053
+ } else {
8054
+ rl.close();
8055
+ }
8056
+ if (!state.running) {
8057
+ const second = createInterface({ input: process.stdin, output: process.stdout });
8058
+ const start2 = await confirm(second, "Start the runner now, in the background?");
8059
+ second.close();
8060
+ if (!start2) {
8061
+ out("");
8062
+ out("Start it when you are ready, and leave it running: aloud start");
8063
+ return 1;
8064
+ }
8065
+ return startDetached(out);
8066
+ }
8067
+ out("");
8068
+ out("Set up. This machine is waiting for studies.");
8069
+ out("");
8070
+ return 0;
8071
+ } finally {
8072
+ rl.close();
8073
+ }
8074
+ }
8075
+ async function confirm(rl, question) {
8076
+ const answer = (await rl.question(`${question} [Y/n] `)).trim().toLowerCase();
8077
+ return answer === "" || answer === "y" || answer === "yes";
8078
+ }
8079
+ async function run(command, args, out) {
8080
+ out("");
8081
+ out(` ${command} ${args.join(" ")}`);
8082
+ return new Promise((resolve) => {
8083
+ const child = spawn2(command, [...args], { stdio: ["ignore", "pipe", "pipe"] });
8084
+ child.stdout?.on("data", (chunk) => out(" " + chunk.toString("utf8").trimEnd()));
8085
+ child.stderr?.on("data", (chunk) => out(" " + chunk.toString("utf8").trimEnd()));
8086
+ child.on("error", () => resolve(false));
8087
+ child.on("close", (code) => resolve(code === 0));
8088
+ });
8089
+ }
8090
+ async function startDetached(out) {
8091
+ const log = join5(dirname4(credentialsPath()), "runner.log");
8092
+ await mkdir4(dirname4(log), { recursive: true, mode: 448 });
8093
+ const handle = openSync(log, "a");
8094
+ const child = spawn2(process.execPath, [process.argv[1] ?? "", "start"], {
8095
+ detached: true,
8096
+ stdio: ["ignore", handle, handle]
8097
+ });
8098
+ child.unref();
8099
+ out("");
8100
+ out(`Started in the background, pid ${child.pid}.`);
8101
+ out(` Output ${log}`);
8102
+ out(" Check it aloud status");
8103
+ out(` Stop it kill ${child.pid}`);
8104
+ out("");
8105
+ out("The first start downloads Chromium, about 350 MB, once. Studies will wait until it is done.");
8106
+ out("");
8107
+ return 0;
8108
+ }
8022
8109
  async function signedInState(credentials) {
8023
8110
  if (!credentials) return { state: "none" };
8024
8111
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aloud/runner",
3
- "version": "0.2.4",
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";
@@ -237,6 +238,10 @@ async function setup(): Promise<number> {
237
238
  // Each step is a command plus the lines that qualify it. Only the command gets a number, or a
238
239
  // continuation reads as a step of its own, and an agent following "step 2" ends up pasting a URL
239
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
+
240
245
  const steps: string[][] = [];
241
246
  if (!installed) {
242
247
  steps.push(["npm install -g @aloud/runner"]);
@@ -308,6 +313,123 @@ async function setup(): Promise<number> {
308
313
  }
309
314
 
310
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
+
311
433
  /**
312
434
  * Whether the saved credential still works, asked of the server rather than assumed from the file.
313
435
  *
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.4";
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";