@edgehero/pi-dispatch 4.3.1 → 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.
Files changed (260) hide show
  1. package/.env.example +84 -3
  2. package/README.md +21 -7
  3. package/deploy/console.service +52 -0
  4. package/deploy/docker-compose.yml +3 -1
  5. package/deploy/pi-dispatch-valkey.container +4 -2
  6. package/deploy/sandbox-gateway.service +23 -0
  7. package/deploy/worker-env-wrapper.cmd +6 -0
  8. package/deploy/worker-env-wrapper.sh +10 -1
  9. package/package.json +118 -19
  10. package/src/agent-snapshot.mjs +49 -0
  11. package/src/answerer-runtime.mjs +194 -0
  12. package/src/answerer.mjs +178 -0
  13. package/src/approval-policy.mjs +76 -0
  14. package/src/approval-runtime.mjs +188 -0
  15. package/src/approval-session.mjs +137 -0
  16. package/src/approval-state.mjs +170 -0
  17. package/src/auth-cli.mjs +86 -0
  18. package/src/auth-connections.mjs +145 -0
  19. package/src/auth-policy.mjs +32 -0
  20. package/src/auth-store.mjs +97 -0
  21. package/src/azure-host.mjs +66 -4
  22. package/src/azure-prompt.mjs +5 -4
  23. package/src/backend-conformance.mjs +34 -2
  24. package/src/backend-local.mjs +103 -34
  25. package/src/backend-openshell.mjs +1540 -0
  26. package/src/backend-podman.mjs +63 -2
  27. package/src/backend-registry.mjs +61 -4
  28. package/src/backends.mjs +395 -6
  29. package/src/bullmq-internals.mjs +208 -0
  30. package/src/canary-cli.mjs +28 -0
  31. package/src/canary-lock.mjs +41 -0
  32. package/src/canary-runtime.mjs +237 -0
  33. package/src/canary.mjs +53 -0
  34. package/src/cancel-cli.mjs +47 -34
  35. package/src/cancel-state.mjs +213 -15
  36. package/src/capabilities.mjs +36 -1
  37. package/src/capacity-cli.mjs +28 -8
  38. package/src/capacity-records.mjs +75 -16
  39. package/src/capacity.mjs +65 -0
  40. package/src/chat-cli.mjs +288 -0
  41. package/src/chat-fork.mjs +547 -0
  42. package/src/chat-mode.mjs +156 -0
  43. package/src/chat-view.mjs +720 -0
  44. package/src/checkpoint-paths.mjs +113 -0
  45. package/src/cli.mjs +136 -41
  46. package/src/comment-tables.mjs +322 -0
  47. package/src/community-auth-adapters.mjs +87 -0
  48. package/src/community-auth-helper.mjs +76 -0
  49. package/src/community-auth-metadata.mjs +6 -0
  50. package/src/community-auth-output-pins.mjs +38 -0
  51. package/src/community-auth-patches.mjs +168 -0
  52. package/src/community-auth-pins.mjs +38 -0
  53. package/src/config.mjs +190 -29
  54. package/src/connection.mjs +83 -7
  55. package/src/console-answer.mjs +113 -0
  56. package/src/console-audit.mjs +604 -0
  57. package/src/console-bootstrap.mjs +325 -0
  58. package/src/console-client.mjs +848 -0
  59. package/src/console-devices.mjs +372 -0
  60. package/src/console-install.mjs +239 -0
  61. package/src/console-open.mjs +99 -0
  62. package/src/console-probe.mjs +106 -0
  63. package/src/console-tokens.mjs +459 -0
  64. package/src/container-names.mjs +51 -0
  65. package/src/container-spec.mjs +20 -1
  66. package/src/cpu-reserve.mjs +4 -1
  67. package/src/defer-reasons.mjs +200 -0
  68. package/src/defer-write.mjs +208 -0
  69. package/src/deployment-venue.mjs +23 -6
  70. package/src/docker-run.mjs +11 -0
  71. package/src/doctor-bundles.mjs +130 -0
  72. package/src/doctor-model-proposals.mjs +109 -0
  73. package/src/doctor-openshell.mjs +238 -0
  74. package/src/doctor-results.mjs +602 -0
  75. package/src/doctor-spawner.mjs +215 -0
  76. package/src/doctor.mjs +1657 -196
  77. package/src/dollar-budget.mjs +212 -3
  78. package/src/endpoint-status.mjs +100 -0
  79. package/src/env-allowlist.mjs +96 -6
  80. package/src/env-file.mjs +6 -4
  81. package/src/envelope.mjs +5 -3
  82. package/src/file-expect.mjs +47 -0
  83. package/src/forgejo-host.mjs +42 -5
  84. package/src/forgejo-prompt.mjs +5 -4
  85. package/src/forges.mjs +33 -3
  86. package/src/fork-job.mjs +58 -0
  87. package/src/fork-policy.mjs +23 -0
  88. package/src/fork-store.mjs +338 -0
  89. package/src/get-token.mjs +1 -1
  90. package/src/git-dirty.mjs +119 -14
  91. package/src/git-hardening.mjs +85 -0
  92. package/src/github-host.mjs +31 -6
  93. package/src/github-prompt.mjs +7 -16
  94. package/src/gitlab-host.mjs +42 -3
  95. package/src/gitlab-prompt.mjs +5 -4
  96. package/src/held-document.mjs +23 -0
  97. package/src/held-replies.mjs +325 -0
  98. package/src/held-settle.mjs +266 -0
  99. package/src/hook-runs.mjs +188 -0
  100. package/src/host-budget.mjs +8 -5
  101. package/src/host-clone.mjs +72 -0
  102. package/src/host-loads.mjs +557 -0
  103. package/src/host-registry.mjs +66 -6
  104. package/src/host-telemetry.mjs +92 -0
  105. package/src/http-body.mjs +78 -0
  106. package/src/image-preflight.mjs +156 -13
  107. package/src/import-pi.mjs +60 -172
  108. package/src/inbox-relay.mjs +482 -0
  109. package/src/inbox-settle.mjs +196 -0
  110. package/src/index.mjs +734 -129
  111. package/src/init.mjs +35 -5
  112. package/src/job-data-version.mjs +75 -0
  113. package/src/job-id.mjs +30 -15
  114. package/src/job-layout.mjs +22 -0
  115. package/src/job-size.mjs +16 -0
  116. package/src/live-jobs.mjs +14 -6
  117. package/src/live-probes.mjs +6 -2
  118. package/src/live-transcript.mjs +872 -0
  119. package/src/loaded-record.mjs +609 -0
  120. package/src/mcp-config.mjs +4 -2
  121. package/src/mcp-preflight.mjs +36 -5
  122. package/src/model-attempts.mjs +84 -0
  123. package/src/model-catalog.mjs +83 -0
  124. package/src/model-cooldowns.mjs +22 -0
  125. package/src/model-failover-protocol.mjs +11 -0
  126. package/src/model-failover.mjs +134 -0
  127. package/src/model-observation.mjs +50 -0
  128. package/src/model-proposal-protocol.mjs +21 -0
  129. package/src/model-proposals.mjs +75 -0
  130. package/src/model-ref.mjs +67 -10
  131. package/src/model-roles-file.mjs +24 -0
  132. package/src/model-roles.mjs +73 -0
  133. package/src/model-status.mjs +34 -0
  134. package/src/models-json.mjs +12 -2
  135. package/src/money.mjs +1 -1
  136. package/src/oauth-openai.mjs +121 -0
  137. package/src/older-state.mjs +249 -0
  138. package/src/on-failure.mjs +17 -13
  139. package/src/open-browser.mjs +31 -2
  140. package/src/opener-cli.mjs +135 -0
  141. package/src/opener-protocol.mjs +27 -0
  142. package/src/opener-terminal.mjs +128 -0
  143. package/src/openshell-cli.mjs +155 -0
  144. package/src/openshell-policy.mjs +163 -0
  145. package/src/outbox.mjs +14 -0
  146. package/src/output-cap.mjs +1 -0
  147. package/src/package-canary-record.mjs +26 -0
  148. package/src/package-canary.mjs +135 -0
  149. package/src/package-names.mjs +9 -0
  150. package/src/package-stage.mjs +418 -0
  151. package/src/packages.mjs +24 -16
  152. package/src/parked.mjs +2 -1
  153. package/src/pause-windows.mjs +136 -17
  154. package/src/podman-stack.mjs +40 -14
  155. package/src/portfolio-snapshot.mjs +26 -7
  156. package/src/prepare-github.mjs +41 -77
  157. package/src/prepare-local.mjs +32 -2
  158. package/src/prepare.mjs +45 -11
  159. package/src/private-store-boundary.mjs +70 -0
  160. package/src/processor.mjs +1550 -173
  161. package/src/projects.mjs +23 -0
  162. package/src/prompt-fence.mjs +18 -0
  163. package/src/provider-key.mjs +7 -0
  164. package/src/provider-steering.mjs +1 -0
  165. package/src/question-context.mjs +35 -0
  166. package/src/question-group-runtime.mjs +91 -0
  167. package/src/question-groups.mjs +171 -0
  168. package/src/queue.mjs +264 -55
  169. package/src/reason-catalog.mjs +448 -0
  170. package/src/receiver-status.mjs +230 -0
  171. package/src/record-reason.mjs +37 -0
  172. package/src/recovery-queue.mjs +566 -0
  173. package/src/redact-secrets.mjs +219 -0
  174. package/src/reserve-estimate.mjs +101 -0
  175. package/src/reserved-env.mjs +36 -1
  176. package/src/retention.mjs +67 -0
  177. package/src/run-budget-ledger.mjs +283 -0
  178. package/src/run-checkpoint.mjs +627 -0
  179. package/src/run-container.mjs +749 -33
  180. package/src/run-history.mjs +791 -45
  181. package/src/run-mirror.mjs +75 -7
  182. package/src/run-progress.mjs +751 -0
  183. package/src/runtime-observations.mjs +3 -1
  184. package/src/runtime-settings.mjs +272 -37
  185. package/src/sandbox-cli.mjs +78 -2
  186. package/src/sandbox-deployment.mjs +51 -0
  187. package/src/sandbox-gateway-protocol.mjs +378 -0
  188. package/src/sandbox-hosts.mjs +100 -0
  189. package/src/sandbox-inventory.mjs +83 -0
  190. package/src/sandbox-policy.mjs +225 -0
  191. package/src/sandbox-prepare-requests.mjs +145 -0
  192. package/src/sandbox-prepare.mjs +285 -0
  193. package/src/sandbox-refs.mjs +70 -0
  194. package/src/sandbox-session-monitor.mjs +4 -0
  195. package/src/sandbox-session-watch.mjs +124 -0
  196. package/src/sandbox-sessions.mjs +161 -0
  197. package/src/sandbox-store.mjs +63 -6
  198. package/src/sandbox-workspace-guard.mjs +39 -0
  199. package/src/sandbox.mjs +27 -200
  200. package/src/scheduler-stall-guard.mjs +14 -8
  201. package/src/schedules.mjs +19 -6
  202. package/src/secret-bundles.mjs +245 -0
  203. package/src/secret-profiles.mjs +103 -3
  204. package/src/secrets.mjs +19 -9
  205. package/src/service-env.mjs +38 -5
  206. package/src/service.mjs +638 -106
  207. package/src/session-key.mjs +60 -0
  208. package/src/session-store.mjs +168 -10
  209. package/src/setup-contract.mjs +40 -0
  210. package/src/setup-egress.mjs +113 -0
  211. package/src/setup-host-entry.mjs +36 -0
  212. package/src/setup-host.mjs +242 -0
  213. package/src/setup-lock.mjs +41 -0
  214. package/src/size-records.mjs +15 -3
  215. package/src/start.mjs +1353 -135
  216. package/src/status-comment.mjs +415 -0
  217. package/src/stderr-transcript.mjs +318 -0
  218. package/src/steer-ack.mjs +122 -0
  219. package/src/steer-cli.mjs +213 -0
  220. package/src/steer-framing.mjs +185 -0
  221. package/src/steer-inbox.mjs +693 -0
  222. package/src/store-version.mjs +139 -0
  223. package/src/subscription-config.mjs +56 -0
  224. package/src/subscription-limits.mjs +3 -0
  225. package/src/subscription-quota.mjs +259 -0
  226. package/src/subscription-runtime.mjs +123 -0
  227. package/src/subscription-status.mjs +42 -0
  228. package/src/subscription-wait.mjs +11 -0
  229. package/src/subscriptions.mjs +7 -1
  230. package/src/thinking-level.mjs +19 -0
  231. package/src/touch-outbox.mjs +61 -0
  232. package/src/touch-state.mjs +133 -0
  233. package/src/triggers-file.mjs +299 -39
  234. package/src/triggers.mjs +780 -38
  235. package/src/trust-paths.mjs +171 -0
  236. package/src/up.mjs +402 -53
  237. package/src/updates-cli.mjs +61 -0
  238. package/src/updates-look.mjs +127 -0
  239. package/src/updates-reconcile.mjs +182 -0
  240. package/src/updates-subscription-adapters.mjs +8 -0
  241. package/src/updates.mjs +252 -0
  242. package/src/valkey-auth.mjs +32 -9
  243. package/src/valkey-endpoint.mjs +14 -4
  244. package/src/wait-for.mjs +1 -1
  245. package/src/wait-state.mjs +62 -3
  246. package/src/watch-board.mjs +38 -0
  247. package/src/watch-closer.mjs +1 -1
  248. package/src/watch-contract.mjs +102 -0
  249. package/src/watch-decision.mjs +78 -0
  250. package/src/watch-drainer.mjs +14 -0
  251. package/src/watch-issue-adapter.mjs +44 -0
  252. package/src/watch-issue.mjs +111 -0
  253. package/src/watch-record.mjs +10 -0
  254. package/src/watch-runtime.mjs +167 -0
  255. package/src/watch-store.mjs +183 -0
  256. package/src/watch-trace.mjs +34 -0
  257. package/src/workspace-checkpoint.mjs +1750 -0
  258. package/src/zero-touch-fixes.mjs +99 -0
  259. package/src/zero-touch.mjs +225 -0
  260. package/src/repeat-slot.mjs +0 -13
@@ -0,0 +1,325 @@
1
+ /** A terminal-only composition of existing bootstrap, token, service and login-link commands (#774). */
2
+ import * as nodeFs from "node:fs";
3
+ import { execFile, spawn as nodeSpawn } from "node:child_process";
4
+ import { homedir } from "node:os";
5
+ import { dirname, join, resolve } from "node:path";
6
+ import { resolveConsoleEntry, findOnPath, consoleTokensPathFor, installedUnitPaths, planServiceSetup, runService } from "./service.mjs";
7
+ import { addToken, readTokensFile, TOKEN_NAME_RE, tokenNames } from "./console-tokens.mjs";
8
+ import { makeConsoleAudit } from "./console-audit.mjs";
9
+ import { deploymentServiceEnv } from "./service-env.mjs";
10
+ import { logsDirPath } from "./config.mjs";
11
+ import { probeConsole, consoleProbeTarget } from "./console-probe.mjs";
12
+ import { resolveSetupWorkerEntry, setupCachePath, trustedSetupFile } from "./setup-host-entry.mjs";
13
+ import { fileURLToPath } from "node:url";
14
+
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,
17
+ * and more on a busier one. execFile kills a child that passes its bound and reports a string code, so the small bound below read a
18
+ * manager that answered as one that did not, and `up --console` refused the README's install on that Mac. Only this probe's exit code
19
+ * is read, so it gets a bound for what it may print, and every other command keeps the small one. REJECTED: reading the overflow as an
20
+ * answer, since the kill means no exit status was ever seen.
21
+ */
22
+ export const MANAGER_PROBE_MAX_BUFFER = 16 * 1024 * 1024;
23
+
24
+ export function boundedCommand(bin, args, { maxBuffer = 65536, ...options } = {}) {
25
+ return new Promise((done) => {
26
+ try { execFile(bin, args, { ...options, timeout: 15_000, maxBuffer, killSignal: "SIGKILL" }, (error, stdout) => done({ code: error ? typeof error.code === "number" ? error.code : null : 0, stdout: String(stdout ?? "") })); }
27
+ catch { done({ code: null, stdout: "" }); }
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
+ }
38
+ const shellWord = (value) => /^[A-Za-z0-9_./+-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
39
+
40
+ /** Inspect the installed peer's metadata without importing pi or accepting npm's floating peer resolution. */
41
+ export function verifyConsolePeer(consoleEntry, { fs = nodeFs, uid = process.geteuid?.(), expected = JSON.parse(nodeFs.readFileSync(new URL("../package.json", import.meta.url), "utf8")).dependencies["@earendil-works/pi-ai"] } = {}) {
42
+ let parent = dirname(dirname(consoleEntry));
43
+ for (;;) {
44
+ const path = join(parent, "node_modules", "@earendil-works", "pi-coding-agent", "package.json");
45
+ try {
46
+ if (!trustedSetupFile(path, { uid, stat: fs.statSync, realpath: fs.realpathSync })) {
47
+ if (!fs.existsSync(path)) throw Object.assign(new Error(), { code: "ENOENT" });
48
+ return false;
49
+ }
50
+ const pkg = JSON.parse(fs.readFileSync(path, "utf8"));
51
+ const bin = pkg.bin?.pi;
52
+ return pkg.name === "@earendil-works/pi-coding-agent" && pkg.version === expected && bin === "dist/bundle/cli.js" && trustedSetupFile(join(dirname(path), bin), { uid, stat: fs.statSync, realpath: fs.realpathSync }) !== null && Object.entries(pkg.dependencies ?? {}).filter(([name]) => name.startsWith("@earendil-works/")).every(([, version]) => version === expected);
53
+ } catch (e) { if (e?.code !== "ENOENT" && e?.code !== "ENOTDIR") return false; }
54
+ if (parent === dirname(parent)) return false;
55
+ parent = dirname(parent);
56
+ }
57
+ }
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
+
213
+ export async function runConsoleUp(argv, deps = {}) {
214
+ const fs = deps.fs ?? nodeFs;
215
+ const env = deps.env ?? process.env;
216
+ const cwd = deps.cwd ?? process.cwd();
217
+ const platform = deps.platform ?? process.platform;
218
+ const uid = deps.euid ?? process.geteuid?.();
219
+ const home = deps.home ?? homedir();
220
+ const out = deps.out ?? ((text) => process.stdout.write(text));
221
+ const prompt = deps.prompt ?? (await import("./up.mjs")).defaultPrompt;
222
+ const run = deps.consoleCommand ?? boundedCommand;
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
+ }
232
+ const worker = (deps.resolveSetupEntry ?? resolveSetupWorkerEntry)();
233
+ // env-internal PATH: find the installed admin binary; never a browser-supplied program search path.
234
+ const consoleEntry = (deps.resolveConsole ?? (() => resolveConsoleEntry({ findBin: (name) => findOnPath(name, env.PATH), realpath: fs.realpathSync })))();
235
+ // Every determinate bootstrap refusal comes before init, image pulls, tokens or unit writes.
236
+ if (!consoleEntry) return refuse("the admin package's built console is missing. Install exact matching @edgehero/pi-dispatch and @edgehero/pi-dispatch-admin versions durably in this deployment, then run its installed pi-dispatch up --console.");
237
+ if (!worker.entry || setupCachePath(consoleEntry)) return refuse("service entries must come from durable installed packages, never an npm execution cache. Install both packages into the deployment and use node_modules/.bin/pi-dispatch up --console.");
238
+ try {
239
+ const metadata = join(dirname(consoleEntry), "..", "package.json");
240
+ if (!trustedSetupFile(consoleEntry, { uid, stat: fs.statSync, realpath: fs.realpathSync }) || !trustedSetupFile(metadata, { uid, stat: fs.statSync, realpath: fs.realpathSync })) return refuse("the built admin entry and its installation directories must belong to this account or root and be writable by them alone.");
241
+ const pkg = JSON.parse(fs.readFileSync(metadata, "utf8"));
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.");
243
+ } catch { return refuse("the admin package's built entry or package metadata could not be validated."); }
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.");
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;
248
+ const units = installedUnitPaths(platform, home).filter((row) => row.which === "console" && fs.existsSync(row.path));
249
+ let consolePlan;
250
+ const serviceDeps = { ...deps, cwd: dir, env, platform, euid: uid, home, fs, resolveConsole: () => consoleEntry, setupConsoleGrant: true };
251
+ for (const unit of units) {
252
+ try {
253
+ if (unit.scope !== "user") return refuse("a console unit already exists without this exact setup grant or for another deployment. Review it and reinstall explicitly with service install --console --host-setup --force.");
254
+ consolePlan = await (deps.consoleServicePlan ?? planServiceSetup)(["start", "--console"], serviceDeps);
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.");
256
+ } catch { return refuse("the existing console service could not be validated."); }
257
+ }
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.");
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);
263
+ let name = operators.length === 1 ? operators[0].name : null;
264
+ if (operators.length > 1) {
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
+ }
270
+ if (!operators.some((row) => row.name === name)) return refuse("no existing operator was selected; nothing was changed.");
271
+ }
272
+ if (operators.length === 0) {
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";
284
+ }
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");
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`);
289
+ const logRead = deploymentServiceEnv({ env: {}, dir, keys: ["PI_LOGS_DIR"], fs, uid, platform });
290
+ const audit = deps.audit ?? makeConsoleAudit({ logsDir: resolve(dir, logsDirPath(logRead.env)), fs, now: deps.now ?? Date.now });
291
+ if (operators.length === 0) {
292
+ try { audit.append({ actor: { name: "shell", role: "shell", session: null }, action: "token-added", target: name, outcome: "ok" }); }
293
+ catch { return refuse("the audit log could not be written, so no operator token was created."); }
294
+ const made = (deps.addToken ?? addToken)({ path: tokensPath, name, role: "operator", fs, uid, now: deps.now ?? Date.now });
295
+ if (made.invalid) {
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. */ }
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.`);
298
+ }
299
+ delete made.token;
300
+ // Deliberately retain the hash only; recovering another link needs the account's CLI, not this plaintext.
301
+ out(`Created operator entry ${name}; its durable plaintext was discarded.\n`);
302
+ } else out(`Using existing operator entry ${name}.\n`);
303
+ const service = deps.runServiceFn ?? runService;
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" };
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."); }
306
+ const code = await service([units.length ? "start" : "install", "--console", ...(!units.length ? ["--host-setup"] : [])], { ...serviceDeps, ...(consolePlan ? { setupExpected: consolePlan } : {}) });
307
+ const before = commandsHere(dir, fs);
308
+ const recovery = `${before.console} link --deployment ${shellWord(dir)} --name ${name}${target.kind === "unix" ? " --port 4790" : ""} --next /setup`;
309
+ if (code !== 0) {
310
+ try { audit.append({ ...serviceRow, outcome: "failed", reason: "console-start-refused" }); } catch { /* Never repeat an uncertain service action for its audit row. */ }
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}`);
312
+ }
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 } : {}) });
314
+ if (!ready) return refuse(`the console did not answer within the startup bound. Its token/unit remain. Once it answers, run ${recovery}`);
315
+ ensureConsoleShortcut(dir, fs);
316
+ const here = commandsHere(dir, fs);
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}`);
324
+ return 0;
325
+ }