@edgehero/pi-dispatch 5.0.0 → 5.1.0

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 CHANGED
@@ -43,6 +43,7 @@ with at least one commit, and `run` refuses any other folder before it queues an
43
43
  |---|---|
44
44
  | `init` | write `.env` and the config files into this folder (never overwrites) |
45
45
  | `up` | one pass that asks before each container action: image, Valkey, egress proxy, `init`, `doctor` |
46
+ | `console` | get into the web console: starts its service if it stopped, then opens your browser signed in |
46
47
  | `doctor [--fix] [--live] [--openshell] [--secrets] [--no-publish]` | check the whole setup; `--live` reads the isolation guarantees back from real containers; each run publishes its checks (paths, URLs and secret values cut out) for the console and the panel, unless `--no-publish` |
47
48
  | `worker` | run jobs from the queue |
48
49
  | `run <folder> --task "..."` | queue one job against a local folder |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edgehero/pi-dispatch",
3
- "version": "5.0.0",
3
+ "version": "5.1.0",
4
4
  "type": "module",
5
5
  "description": "The pi-dispatch worker and CLI: go from one AI coding agent to many in the background, running the pi coding agent as a self-hosted service, one locked down Docker or Podman container per job, with spend caps checked before anything is spent, plus init, up, doctor and service install.",
6
6
  "keywords": [
@@ -54,6 +54,7 @@
54
54
  "./git-hardening": "./src/git-hardening.mjs",
55
55
  "./http-body": "./src/http-body.mjs",
56
56
  "./console-tokens": "./src/console-tokens.mjs",
57
+ "./console-devices": "./src/console-devices.mjs",
57
58
  "./console-probe": "./src/console-probe.mjs",
58
59
  "./console-audit": "./src/console-audit.mjs",
59
60
  "./console-answer": "./src/console-answer.mjs",
@@ -207,8 +208,8 @@
207
208
  "start": "node src/cli.mjs worker"
208
209
  },
209
210
  "dependencies": {
210
- "@earendil-works/pi-ai": "1.5.0",
211
- "@earendil-works/pi-tui": "1.5.0",
211
+ "@earendil-works/pi-ai": "1.5.2",
212
+ "@earendil-works/pi-tui": "1.5.2",
212
213
  "@octokit/auth-app": "8.2.0",
213
214
  "@octokit/rest": "22.0.1",
214
215
  "bullmq": "5.80.4",
package/src/cli.mjs CHANGED
@@ -27,7 +27,10 @@ const USAGE = `pi-dispatch — run pi coding-agent flows on your own folders
27
27
  --secrets resolves each project secret bundle here and prints its variable names, never values;
28
28
  every run publishes its checks to Valkey for the console and the panel (labels with paths
29
29
  and secret values redacted, kept 7 days), and --no-publish skips that
30
+ pi-dispatch console [--name NAME] [--next /setup] [--port N] [--deployment DIR]
31
+ get into the web console: starts its service if it stopped, then opens your browser signed in
30
32
  pi-dispatch up [--console] [--yes] [--adopt-volume] [--restart-proxy] [--refresh-proxy-rules]
33
+ with --console also: [--ask-each] [--no-open] [--name NAME]
31
34
  one consented pass: pull+tag the job image, start Valkey, init, doctor
32
35
  pi-dispatch setup github mint GitHub App credentials in one browser click (App Manifest flow);
33
36
  every write shown first and individually consented — no --yes here
@@ -161,6 +164,11 @@ export async function main(argv = process.argv.slice(2), env = process.env, { wr
161
164
  return runUp(argv.slice(1), { env });
162
165
  }
163
166
 
167
+ if (cmd === "console") {
168
+ const { runConsoleOpen } = await import("./console-open.mjs");
169
+ return runConsoleOpen(argv.slice(1), { env });
170
+ }
171
+
164
172
  if (cmd === "setup") {
165
173
  // Subcommand shape (`setup <forge>`) so future forges can land beside github without a new
166
174
  // top-level verb; an unknown or missing target prints guidance rather than guessing.
@@ -39,7 +39,7 @@ import { escapeControls } from "./projects.mjs";
39
39
 
40
40
  export const CONSOLE_AUDIT_DIR = "console-audit";
41
41
  export const CONSOLE_AUDIT_KEY = "console:audit";
42
- export const CONSOLE_AUDIT_ACTIONS = Object.freeze(["login", "login-failed", "login-code", "logout", "refused", "write", "write-applied", "token-added", "token-revoked", "token-rotated", "sessions-ended", "serve-started"]);
42
+ export const CONSOLE_AUDIT_ACTIONS = Object.freeze(["login", "login-failed", "login-code", "logout", "refused", "write", "write-applied", "token-added", "token-revoked", "token-rotated", "sessions-ended", "serve-started", "login-device", "device-remembered", "device-ended"]);
43
43
  export const CONSOLE_AUDIT_OUTCOMES = Object.freeze(["ok", "refused", "failed"]);
44
44
  // A string field's cap, in code points, AFTER its controls are written out: a row stays small enough for the bounded tail
45
45
  // read below whatever a caller hands in (a Host header, a refusal reason), and no field can bury the others.
@@ -1,6 +1,6 @@
1
1
  /** A terminal-only composition of existing bootstrap, token, service and login-link commands (#774). */
2
2
  import * as nodeFs from "node:fs";
3
- import { execFile } from "node:child_process";
3
+ import { execFile, spawn as nodeSpawn } from "node:child_process";
4
4
  import { homedir } from "node:os";
5
5
  import { dirname, join, resolve } from "node:path";
6
6
  import { resolveConsoleEntry, findOnPath, consoleTokensPathFor, installedUnitPaths, planServiceSetup, runService } from "./service.mjs";
@@ -10,6 +10,7 @@ import { deploymentServiceEnv } from "./service-env.mjs";
10
10
  import { logsDirPath } from "./config.mjs";
11
11
  import { probeConsole, consoleProbeTarget } from "./console-probe.mjs";
12
12
  import { resolveSetupWorkerEntry, setupCachePath, trustedSetupFile } from "./setup-host-entry.mjs";
13
+ import { fileURLToPath } from "node:url";
13
14
 
14
15
  /**
15
16
  * What the service manager's probe may print. `launchctl print gui/<uid>` describes the whole login session: 141 KiB on one real Mac,
@@ -26,6 +27,14 @@ export function boundedCommand(bin, args, { maxBuffer = 65536, ...options } = {}
26
27
  catch { done({ code: null, stdout: "" }); }
27
28
  });
28
29
  }
30
+ /**
31
+ * Is this very file running out of npx's own cache? Judged on the real path, because npx reaches it through a link. `_npx` only, and
32
+ * deliberately narrower than `setupCachePath`: a durable install under some `.cache` folder is no npx run, and telling it to install
33
+ * itself would send it round in a circle. pnpm dlx, yarn dlx and bunx are not recognised here and meet the ordinary refusal below.
34
+ */
35
+ export function runningFromNpmCache({ entry = fileURLToPath(import.meta.url), realpath = nodeFs.realpathSync } = {}) {
36
+ try { return /(?:^|[/\\])_npx(?:[/\\]|$)/.test(realpath(entry)); } catch { return false; }
37
+ }
29
38
  const shellWord = (value) => /^[A-Za-z0-9_./+-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
30
39
 
31
40
  /** Inspect the installed peer's metadata without importing pi or accepting npm's floating peer resolution. */
@@ -47,6 +56,160 @@ export function verifyConsolePeer(consoleEntry, { fs = nodeFs, uid = process.get
47
56
  }
48
57
  }
49
58
 
59
+ /** The flags only `up --console` reads. The plain `up` pass it composes never sees them. */
60
+ const CONSOLE_ONLY_FLAGS = Object.freeze(["--console", "--no-open", "--ask-each"]);
61
+
62
+ /** `--flag value` or `--flag=value` from an argv, or null. */
63
+ export function flagValue(argv, flag) {
64
+ const inline = argv.find((arg) => arg.startsWith(`${flag}=`));
65
+ if (inline !== undefined) return inline.slice(flag.length + 1);
66
+ const at = argv.indexOf(flag);
67
+ return at === -1 || typeof argv[at + 1] !== "string" || argv[at + 1].startsWith("--") ? null : argv[at + 1];
68
+ }
69
+
70
+ /** `argv` with `up --console`'s own flags, and `--name`'s value, taken out. */
71
+ export function upArgvOf(argv) {
72
+ const out = [];
73
+ for (let i = 0; i < argv.length; i++) {
74
+ if (argv[i].startsWith("--name=")) continue;
75
+ if (argv[i] === "--name") { i += flagValue(argv.slice(i), "--name") === null ? 0 : 1; continue; }
76
+ if (!CONSOLE_ONLY_FLAGS.includes(argv[i])) out.push(argv[i]);
77
+ }
78
+ return out;
79
+ }
80
+
81
+ /** The refusal for two flags that ask opposite things, or null. Refused rather than resolved, so neither reading is ever a surprise. */
82
+ export function consentFlagProblem(argv) {
83
+ return argv.includes("--yes") && argv.includes("--ask-each") ? "--yes and --ask-each ask for opposite things; pass one." : null;
84
+ }
85
+
86
+ /** Whether a name can be the new first operator of this tokens file: the refusal sentence, or null. Free, so it runs before anything is written. */
87
+ export function newOperatorNameProblem(name, existing) {
88
+ // Every name the file holds, an entry kept for a role this build does not know included (issue #761).
89
+ return !TOKEN_NAME_RE.test(name) || tokenNames(existing).includes(name) ? "choose an unused valid operator name with --name; nothing was changed." : null;
90
+ }
91
+
92
+ /** Is `name` in `dir` the shortcut this tooling makes? Anything else at the name is someone's file, which no message may tell a person to run. */
93
+ function isOurShortcut(dir, fs) {
94
+ try { return fs.lstatSync(join(dir, "pi-dispatch")).isSymbolicLink() && fs.readlinkSync(join(dir, "pi-dispatch")) === join("node_modules", ".bin", "pi-dispatch"); } catch { return false; }
95
+ }
96
+
97
+ /**
98
+ * What to type to run this deployment's own commands: the short name where it exists, because a local install puts nothing on PATH.
99
+ * Relative only when the person is standing in the folder (`from`), since `./pi-dispatch` means nothing from anywhere else.
100
+ */
101
+ export function commandsHere(dir, fs = nodeFs, { from = dir } = {}) {
102
+ const has = (rel) => { try { return fs.existsSync(join(dir, rel)); } catch { return false; } };
103
+ const at = (rel) => (from === dir ? `./${rel}` : shellWord(join(dir, rel)));
104
+ const workerBin = join("node_modules", ".bin", "pi-dispatch");
105
+ const consoleBin = join("node_modules", ".bin", "pi-dispatch-console");
106
+ return {
107
+ worker: isOurShortcut(dir, fs) && has(workerBin) ? at("pi-dispatch") : has(workerBin) ? at(workerBin) : "pi-dispatch",
108
+ console: has(consoleBin) ? at(consoleBin) : "pi-dispatch-console",
109
+ };
110
+ }
111
+
112
+ /** The bits a umask must always have, so what npm and the deployment write cannot be changed by another account. */
113
+ export function tightenedUmask(current) {
114
+ return current | 0o022;
115
+ }
116
+
117
+ /** Refusal for a `--name` that cannot be used with the operators already in this file, or null. */
118
+ export function operatorChoiceProblem(named, operators) {
119
+ if (named === null) return null;
120
+ if (operators.length === 1 && operators[0].name !== named) return `this deployment's operator is "${operators[0].name}", so --name ${named} names no one; nothing was changed.`;
121
+ if (operators.length > 1 && !operators.some((row) => row.name === named)) return "no existing operator was selected; nothing was changed.";
122
+ return null;
123
+ }
124
+
125
+ /** A child on this terminal, for the commands that have to talk to the person. Resolves to its exit code, null when it could not run. */
126
+ export function attachedCommand(bin, args, { cwd, env, stdio = "inherit", timeout, spawn = nodeSpawn } = {}) {
127
+ return new Promise((done) => {
128
+ try {
129
+ const child = spawn(bin, args, { cwd, env, stdio, ...(timeout ? { timeout, killSignal: "SIGKILL" } : {}) });
130
+ child.on("error", () => done(null));
131
+ child.on("close", (code) => done(typeof code === "number" ? code : null));
132
+ } catch { done(null); }
133
+ });
134
+ }
135
+
136
+ /** Poll until the console answers or `ms` has passed: true when it answered. */
137
+ export async function waitForConsole(env, { probe = probeConsole, now = Date.now, sleep = (ms) => new Promise((done) => setTimeout(done, ms)), ms = 15_000 } = {}) {
138
+ const until = now() + ms;
139
+ do { if ((await probe(env)).state === "running") return true; if (now() >= until) return false; await sleep(250); } while (true);
140
+ }
141
+
142
+ /**
143
+ * `./pi-dispatch`, a relative symlink to this folder's own pinned binary, so the command that gets someone back in is short enough to
144
+ * remember: true when the shortcut is there. Relative, so moving the folder keeps it working. REJECTED: a link in `~/.local/bin`, which
145
+ * is account-wide and wrong as soon as there are two deployments, and `npx pi-dispatch`, which outside this folder fetches whatever
146
+ * unrelated package holds that name. Anything else already at the name is left alone: it is not ours to replace.
147
+ */
148
+ export function ensureConsoleShortcut(dir, fs = nodeFs) {
149
+ const target = join("node_modules", ".bin", "pi-dispatch");
150
+ if (!fs.existsSync(join(dir, target))) return false;
151
+ const link = join(dir, "pi-dispatch");
152
+ try { const st = fs.lstatSync(link); return st.isSymbolicLink() && fs.readlinkSync(link) === target; }
153
+ catch (e) { if (e?.code !== "ENOENT") return false; }
154
+ try { fs.symlinkSync(target, link); return true; } catch { return false; }
155
+ }
156
+
157
+ /**
158
+ * The one question a first run asks. Yes is exactly `--yes`: commands still print, and what `--yes` never covers still asks, so this
159
+ * adds no authority a person could not already give by typing the flag. The list is by kind, not by command, because `up` picks its
160
+ * commands from what it finds as it goes. Anything but y or a is no, as every consent in `up` defaults to no.
161
+ */
162
+ export async function askFirstRun({ dir, installing, prompt, out }) {
163
+ out(`This sets up pi-dispatch in ${dir}:\n`);
164
+ if (installing) out(" - install the exact matching packages into this folder, with install scripts off\n");
165
+ out(" - run pi-dispatch up: write this folder's settings, get the job image, and start the queue and the egress proxy (one already running with no job attached is replaced)\n");
166
+ out(" - create an operator sign in, keeping only its hash\n");
167
+ out(" - start the console as a service for your account, with bounded local host setup (--host-setup)\n");
168
+ out(" - add a ./pi-dispatch shortcut in this folder, and open your browser signed in\n");
169
+ out("Each command is shown as it runs. The worker is started only by a separate confirmed action.\n");
170
+ const said = String(await prompt("Do all of this? y for yes to all, a to be asked before each step, n to stop [y/a/N] ")).trim();
171
+ return /^y(es)?$/i.test(said) ? "yes" : /^a(sk)?$/i.test(said) ? "each" : "no";
172
+ }
173
+
174
+ /**
175
+ * Every refusal that needs nothing installed: platform, folder, the deployment's settings, the console's address and the tokens file.
176
+ * Its own function because the npm-cache path has to run all of them BEFORE its install (`REQ-BROWSER-FIRST-RUN-SETUP`: refuse before
177
+ * any host mutation), and a refusal there must leave no `node_modules` behind. `{ refused }` is the exit code of a refusal already said.
178
+ */
179
+ export function deploymentPreflight({ fs, env, cwd, platform, uid, refuse, consoleTokensPath, readTokens, looseEmptyIgnoring = null }) {
180
+ const stop = (text) => ({ refused: refuse(text) });
181
+ if (!["linux", "darwin"].includes(platform) || (platform === "darwin" && uid === 0)) return stop("browser host setup needs a Linux user systemd manager or your macOS LaunchAgent session; use terminal setup on this platform.");
182
+ let dir;
183
+ try {
184
+ dir = fs.realpathSync(cwd);
185
+ const st = fs.statSync(dir);
186
+ // An empty folder of this account's with a loose mode is what `mkdir` makes under Ubuntu's default umask, and the self-install tightens it
187
+ // after the question and looks again. A caller that does not do both must leave this null.
188
+ if (st.uid !== uid || ((st.mode & 0o022) && !(looseEmptyIgnoring && fs.readdirSync(dir).every((name) => looseEmptyIgnoring.has(name))))) return stop("the deployment directory must belong to this account and be writable by this account alone.");
189
+ } catch { return stop("the deployment directory could not be validated."); }
190
+ const declaredConsoleKeys = [...new Set(["PI_CONSOLE_LISTEN", "PI_CONSOLE_TOKENS_FILE", "PI_LOGS_DIR", ...Object.keys(env).filter((key) => key.startsWith("PI_CONSOLE_"))])];
191
+ const read = deploymentServiceEnv({ env: {}, dir, keys: declaredConsoleKeys, fs, uid, platform });
192
+ if (read.untrusted || read.unreadable && read.unreadable !== "ENOENT" || read.unread?.length || read.hazardSkipped?.length) return stop("the deployment environment is unsafe or ambiguous; correct it before console bootstrap.");
193
+ if (declaredConsoleKeys.some((key) => typeof env[key] === "string" && env[key] !== read.env[key])) return stop("console settings in this shell differ from the deployment service's environment. Unset the shell overrides before bootstrap.");
194
+ const target = consoleProbeTarget(read.env.PI_CONSOLE_LISTEN);
195
+ if (target.unknown) return stop("the console needs a concrete local port or private socket so its one-time setup link can be reached.");
196
+ if (target.kind === "unix") {
197
+ try { const parent = fs.lstatSync(dirname(target.path)); if (!parent.isDirectory() || parent.isSymbolicLink() || parent.uid !== uid || (parent.mode & 0o077)) return stop("the console socket's parent must be a private directory owned by this account."); }
198
+ catch { return stop("the console socket's private parent directory must exist before bootstrap."); }
199
+ }
200
+ const tokensPath = (consoleTokensPath ?? consoleTokensPathFor)(dir, platform);
201
+ let existing = null;
202
+ try { if (fs.existsSync(tokensPath)) { const got = (readTokens ?? readTokensFile)({ path: tokensPath, fs, uid }); if (got.refusal) return stop("the console tokens file is unsafe or invalid; correct it before bootstrap."); existing = got.file; } }
203
+ catch { return stop("the console tokens file could not be inspected."); }
204
+ return { dir, read, target, tokensPath, existing };
205
+ }
206
+
207
+ /** Does this account's service manager answer? Printed output is ignored, so it gets the larger bound (`MANAGER_PROBE_MAX_BUFFER`). */
208
+ export async function serviceManagerAnswers({ run, platform, uid, dir, env }) {
209
+ const manager = await run(platform === "linux" ? "systemctl" : "launchctl", platform === "linux" ? ["--user", "show-environment"] : ["print", `gui/${uid}`], { cwd: dir, env, maxBuffer: MANAGER_PROBE_MAX_BUFFER });
210
+ return manager.code === 0;
211
+ }
212
+
50
213
  export async function runConsoleUp(argv, deps = {}) {
51
214
  const fs = deps.fs ?? nodeFs;
52
215
  const env = deps.env ?? process.env;
@@ -58,6 +221,14 @@ export async function runConsoleUp(argv, deps = {}) {
58
221
  const prompt = deps.prompt ?? (await import("./up.mjs")).defaultPrompt;
59
222
  const run = deps.consoleCommand ?? boundedCommand;
60
223
  const refuse = (text) => { out(`up --console: ${text}\n`); return 2; };
224
+ const flagProblem = consentFlagProblem(argv);
225
+ if (flagProblem) return refuse(flagProblem);
226
+ // A run from npm's cache has no admin package beside it, so this comes before the missing-admin refusal below, which still holds for
227
+ // a durable worker: the cache is the one case where installing is the right answer rather than a surprise.
228
+ if ((deps.fromNpmCache ?? runningFromNpmCache)()) {
229
+ const { runSelfInstall } = await import("./console-install.mjs");
230
+ return runSelfInstall(argv, { ...deps, fs, env, cwd, platform, euid: uid, home, out, prompt, consoleCommand: run, refuse });
231
+ }
61
232
  const worker = (deps.resolveSetupEntry ?? resolveSetupWorkerEntry)();
62
233
  // env-internal PATH: find the installed admin binary; never a browser-supplied program search path.
63
234
  const consoleEntry = (deps.resolveConsole ?? (() => resolveConsoleEntry({ findBin: (name) => findOnPath(name, env.PATH), realpath: fs.realpathSync })))();
@@ -71,27 +242,9 @@ export async function runConsoleUp(argv, deps = {}) {
71
242
  if (pkg.name !== "@edgehero/pi-dispatch-admin" || pkg.version !== worker.version || !fs.statSync(consoleEntry).isFile()) return refuse("the worker and built admin package must have the same exact version.");
72
243
  } catch { return refuse("the admin package's built entry or package metadata could not be validated."); }
73
244
  if (!(deps.verifyConsolePeer ?? verifyConsolePeer)(consoleEntry, { fs, uid })) return refuse("the installed admin's pi peer is missing, unsafe or differs from the worker's exact pin. Install the matching pinned pi-coding-agent package before bootstrap.");
74
- if (!["linux", "darwin"].includes(platform) || (platform === "darwin" && uid === 0)) return refuse("browser host setup needs a Linux user systemd manager or your macOS LaunchAgent session; use terminal setup on this platform.");
75
- let dir;
76
- try {
77
- dir = fs.realpathSync(cwd);
78
- const st = fs.statSync(dir);
79
- if (st.uid !== uid || (st.mode & 0o022)) return refuse("the deployment directory must belong to this account and be writable by this account alone.");
80
- } catch { return refuse("the deployment directory could not be validated."); }
81
- const declaredConsoleKeys = [...new Set(["PI_CONSOLE_LISTEN", "PI_CONSOLE_TOKENS_FILE", "PI_LOGS_DIR", ...Object.keys(env).filter((key) => key.startsWith("PI_CONSOLE_"))])];
82
- const read = deploymentServiceEnv({ env: {}, dir, keys: declaredConsoleKeys, fs, uid, platform });
83
- if (read.untrusted || read.unreadable && read.unreadable !== "ENOENT" || read.unread?.length || read.hazardSkipped?.length) return refuse("the deployment environment is unsafe or ambiguous; correct it before console bootstrap.");
84
- if (declaredConsoleKeys.some((key) => typeof env[key] === "string" && env[key] !== read.env[key])) return refuse("console settings in this shell differ from the deployment service's environment. Unset the shell overrides before bootstrap.");
85
- const target = consoleProbeTarget(read.env.PI_CONSOLE_LISTEN);
86
- if (target.unknown) return refuse("the console needs a concrete local port or private socket so its one-time setup link can be reached.");
87
- if (target.kind === "unix") {
88
- try { const parent = fs.lstatSync(dirname(target.path)); if (!parent.isDirectory() || parent.isSymbolicLink() || parent.uid !== uid || (parent.mode & 0o077)) return refuse("the console socket's parent must be a private directory owned by this account."); }
89
- catch { return refuse("the console socket's private parent directory must exist before bootstrap."); }
90
- }
91
- const tokensPath = (deps.consoleTokensPath ?? consoleTokensPathFor)(dir, platform);
92
- let existing = null;
93
- try { if (fs.existsSync(tokensPath)) { const got = (deps.readTokens ?? readTokensFile)({ path: tokensPath, fs, uid }); if (got.refusal) return refuse("the console tokens file is unsafe or invalid; correct it before bootstrap."); existing = got.file; } }
94
- catch { return refuse("the console tokens file could not be inspected."); }
245
+ const pre = deploymentPreflight({ fs, env, cwd, platform, uid, refuse, consoleTokensPath: deps.consoleTokensPath, readTokens: deps.readTokens });
246
+ if (pre.refused !== undefined) return pre.refused;
247
+ const { dir, read, target, tokensPath, existing } = pre;
95
248
  const units = installedUnitPaths(platform, home).filter((row) => row.which === "console" && fs.existsSync(row.path));
96
249
  let consolePlan;
97
250
  const serviceDeps = { ...deps, cwd: dir, env, platform, euid: uid, home, fs, resolveConsole: () => consoleEntry, setupConsoleGrant: true };
@@ -102,24 +255,37 @@ export async function runConsoleUp(argv, deps = {}) {
102
255
  if (!consolePlan || consolePlan.refusal || typeof consolePlan !== "object") return refuse("the existing console unit or loaded manager command differs from this exact setup grant. Review it and reinstall explicitly with service install --console --host-setup --force.");
103
256
  } catch { return refuse("the existing console service could not be validated."); }
104
257
  }
105
- const manager = await run(platform === "linux" ? "systemctl" : "launchctl", platform === "linux" ? ["--user", "show-environment"] : ["print", `gui/${uid}`], { cwd: dir, env, maxBuffer: MANAGER_PROBE_MAX_BUFFER });
106
- if (manager.code !== 0) return refuse("the account's user service manager did not answer. Start it before console bootstrap.");
258
+ if (!(await serviceManagerAnswers({ run, platform, uid, dir, env }))) return refuse("the account's user service manager did not answer. Start it before console bootstrap.");
107
259
  const operators = existing?.tokens.filter((entry) => entry.role === "operator") ?? [];
260
+ const named = flagValue(argv, "--name");
261
+ const choiceProblem = operatorChoiceProblem(named, operators);
262
+ if (choiceProblem) return refuse(choiceProblem);
108
263
  let name = operators.length === 1 ? operators[0].name : null;
109
264
  if (operators.length > 1) {
110
- out(`Operator entries: ${operators.map((row) => row.name).join(", ")}\n`);
111
- name = String(await prompt("Name the operator entry for this setup link: ")).trim();
265
+ name = named;
266
+ if (name === null) {
267
+ out(`Operator entries: ${operators.map((row) => row.name).join(", ")}\n`);
268
+ name = String(await prompt("Name the operator entry for this setup link: ")).trim();
269
+ }
112
270
  if (!operators.some((row) => row.name === name)) return refuse("no existing operator was selected; nothing was changed.");
113
271
  }
114
272
  if (operators.length === 0) {
115
- name = argv.includes("--yes") ? "operator" : String(await prompt("Name the new operator entry [operator]: ")).trim() || "operator";
116
- // Every name the file holds, an entry kept for a role this build does not know included (issue #761).
117
- if (!TOKEN_NAME_RE.test(name) || tokenNames(existing).includes(name)) return refuse("choose an unused valid operator name; nothing was changed.");
273
+ // No prompt: nobody who has not used this before knows what the name is for, and `--name` is there for anyone who does.
274
+ name = named ?? "operator";
275
+ const nameProblem = newOperatorNameProblem(name, existing);
276
+ if (nameProblem) return refuse(nameProblem);
277
+ }
278
+ let yes = argv.includes("--yes");
279
+ // Only with no `.env`: the question's list says "start the queue", and on a deployment already in use `--yes` can restart a live one.
280
+ if (!yes && !argv.includes("--ask-each") && operators.length === 0 && units.length === 0 && !fs.existsSync(join(dir, ".env"))) {
281
+ const answer = await askFirstRun({ dir, installing: false, prompt, out });
282
+ if (answer === "no") { out("Console bootstrap declined; nothing was changed.\n"); return 0; }
283
+ yes = answer === "yes";
118
284
  }
119
285
  out("Console bootstrap will grant this account's console bounded local host setup (--host-setup). It starts the console service; the worker is started only by a separate confirmed action.\n");
120
- if (!argv.includes("--yes") && !/^y(es)?$/i.test(String(await prompt("Proceed with console bootstrap? [y/N] ")).trim())) { out("Console bootstrap declined; nothing was changed.\n"); return 0; }
121
- const upCode = await deps.runUpFn(argv.filter((arg) => arg !== "--console"), { ...deps, cwd: dir });
122
- if (!fs.existsSync(join(dir, ".env"))) return refuse("up left no deployment .env, so no token or console unit was made; finish pi-dispatch up first.");
286
+ if (!yes && !/^y(es)?$/i.test(String(await prompt("Proceed with console bootstrap? [y/N] ")).trim())) { out("Console bootstrap declined; nothing was changed.\n"); return 0; }
287
+ const upCode = await deps.runUpFn([...upArgvOf(argv).filter((arg) => arg !== "--yes"), ...(yes ? ["--yes"] : [])], { ...deps, cwd: dir, skipNextSteps: true });
288
+ if (!fs.existsSync(join(dir, ".env"))) return refuse(`up did not finish, so no token or console unit was made. Fix what it printed above, then run ${commandsHere(dir, fs).worker} up --console`);
123
289
  const logRead = deploymentServiceEnv({ env: {}, dir, keys: ["PI_LOGS_DIR"], fs, uid, platform });
124
290
  const audit = deps.audit ?? makeConsoleAudit({ logsDir: resolve(dir, logsDirPath(logRead.env)), fs, now: deps.now ?? Date.now });
125
291
  if (operators.length === 0) {
@@ -128,7 +294,7 @@ export async function runConsoleUp(argv, deps = {}) {
128
294
  const made = (deps.addToken ?? addToken)({ path: tokensPath, name, role: "operator", fs, uid, now: deps.now ?? Date.now });
129
295
  if (made.invalid) {
130
296
  try { audit.append({ actor: { name: "shell", role: "shell", session: null }, action: "token-added", target: name, outcome: "failed", reason: "token-write-refused" }); } catch { /* The failed write is still reported by the command. */ }
131
- return refuse("the operator entry could not be created; inspect the token file and use pi-dispatch-console token add in the host terminal.");
297
+ return refuse(`the operator entry could not be created; inspect the token file and use ${commandsHere(dir, fs).console} token add in the host terminal.`);
132
298
  }
133
299
  delete made.token;
134
300
  // Deliberately retain the hash only; recovering another link needs the account's CLI, not this plaintext.
@@ -138,22 +304,22 @@ export async function runConsoleUp(argv, deps = {}) {
138
304
  const serviceRow = { actor: { name: "shell", role: "shell", session: null }, action: "write", kind: units.length ? "setup-console-start" : "setup-console-install", target: "console", outcome: "ok" };
139
305
  try { audit.append(serviceRow); } catch { return refuse("the audit log could not be written, so the console service was not changed; the operator entry remains."); }
140
306
  const code = await service([units.length ? "start" : "install", "--console", ...(!units.length ? ["--host-setup"] : [])], { ...serviceDeps, ...(consolePlan ? { setupExpected: consolePlan } : {}) });
141
- const recovery = `pi-dispatch-console link --deployment ${shellWord(dir)} --name ${name}${target.kind === "unix" ? " --port 4790" : ""} --next /setup`;
307
+ const before = commandsHere(dir, fs);
308
+ const recovery = `${before.console} link --deployment ${shellWord(dir)} --name ${name}${target.kind === "unix" ? " --port 4790" : ""} --next /setup`;
142
309
  if (code !== 0) {
143
310
  try { audit.append({ ...serviceRow, outcome: "failed", reason: "console-start-refused" }); } catch { /* Never repeat an uncertain service action for its audit row. */ }
144
- return refuse(`the operator entry remains, but the console service did not start. Fix it with pi-dispatch service install --console --host-setup; then ${recovery}`);
311
+ return refuse(`the operator entry remains, but the console service did not start. Fix it with ${before.worker} service install --console --host-setup; then ${recovery}`);
145
312
  }
146
- const now = deps.now ?? Date.now;
147
- const wait = deps.sleep ?? ((ms) => new Promise((done) => setTimeout(done, ms)));
148
- const probe = deps.probeConsole ?? probeConsole;
149
- const until = now() + (deps.consoleWaitMs ?? 15_000);
150
- let ready = false;
151
- do { if ((await probe(read.env)).state === "running") { ready = true; break; } if (now() >= until) break; await wait(250); } while (true);
313
+ const ready = await waitForConsole(read.env, { probe: deps.probeConsole ?? probeConsole, now: deps.now ?? Date.now, ms: deps.consoleWaitMs ?? 15_000, ...(deps.sleep ? { sleep: deps.sleep } : {}) });
152
314
  if (!ready) return refuse(`the console did not answer within the startup bound. Its token/unit remain. Once it answers, run ${recovery}`);
153
- const link = await run(process.execPath, [consoleEntry, "link", "--deployment", dir, "--name", name, ...(target.kind === "unix" ? ["--port", "4790"] : []), "--next", "/setup"], { cwd: dir, env });
154
- if (link.code !== 0 || !/^http:\/\/[^\s]+\/login\/code#[A-Za-z0-9_-]{43}&next=\/setup\s*$/.test(link.stdout.trim())) return refuse(`the console runs but no setup link was produced. Run ${recovery}`);
155
- if (target.kind === "unix") out(`Reach this private socket through your tunnel first: ssh -L 4790:${shellWord(target.path)} <this host>\n`);
156
- out(`${link.stdout.trim()}\nOpen it within 60 seconds; it works once. To make another link: ${recovery}\nTo replace the durable token: pi-dispatch-console token rotate --deployment ${shellWord(dir)} --name ${name}\n`);
315
+ ensureConsoleShortcut(dir, fs);
316
+ const here = commandsHere(dir, fs);
157
317
  if (upCode !== 0) out("Doctor reports remaining setup. Continue on /setup.\n");
318
+ // Before `open`, which waits for the browser: this is the line someone needs later, and it must not wait behind a prompt.
319
+ out(`Get back in any time: ${here.worker} console\n`);
320
+ // The wizard signs the person in itself, so a second link here would be one more live code for nothing.
321
+ if (argv.includes("--no-open")) return 0;
322
+ const opened = await (deps.consoleOpen ?? attachedCommand)(process.execPath, [consoleEntry, "open", "--deployment", dir, "--name", name, ...(target.kind === "unix" ? ["--port", "4790"] : []), "--next", "/setup"], { cwd: dir, env });
323
+ if (opened !== 0) return refuse(`the console runs but no sign-in was opened. Run ${recovery}`);
158
324
  return 0;
159
325
  }