@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 +1 -0
- package/package.json +4 -3
- package/src/cli.mjs +8 -0
- package/src/console-audit.mjs +1 -1
- package/src/console-bootstrap.mjs +211 -45
- package/src/console-devices.mjs +372 -0
- package/src/console-install.mjs +239 -0
- package/src/console-open.mjs +99 -0
- package/src/doctor.mjs +21 -3
- package/src/envelope.mjs +5 -3
- package/src/mcp-config.mjs +3 -1
- package/src/models-json.mjs +8 -1
- package/src/setup-host-entry.mjs +4 -1
- package/src/up.mjs +6 -4
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.
|
|
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.
|
|
211
|
-
"@earendil-works/pi-tui": "1.5.
|
|
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.
|
package/src/console-audit.mjs
CHANGED
|
@@ -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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
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
|
-
|
|
111
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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 (!
|
|
121
|
-
const upCode = await deps.runUpFn(argv.filter((arg) => arg !== "--
|
|
122
|
-
if (!fs.existsSync(join(dir, ".env"))) return refuse(
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
154
|
-
|
|
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
|
}
|