@edgehero/pi-dispatch 5.0.0 → 5.2.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/cancel-cli.mjs +102 -14
- package/src/cancel-state.mjs +9 -3
- package/src/cli.mjs +8 -0
- package/src/connection.mjs +7 -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-bundles.mjs +6 -2
- package/src/doctor-model-proposals.mjs +4 -2
- package/src/doctor.mjs +223 -30
- package/src/envelope.mjs +5 -3
- package/src/mcp-config.mjs +3 -1
- package/src/models-json.mjs +8 -1
- package/src/pause-windows.mjs +19 -3
- package/src/setup-host-entry.mjs +4 -1
- package/src/up.mjs +6 -4
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `pi-dispatch console`: get me into the console (issue #849). One command to remember, whatever state things are in.
|
|
3
|
+
*
|
|
4
|
+
* The work is split by who owns what. This side owns the service: is the console answering, and if not, can it be started. The admin's
|
|
5
|
+
* `pi-dispatch-console open` owns the credential: which operator, the audit row, the one-time code and the browser. So a code is minted
|
|
6
|
+
* only by the package that has always minted one.
|
|
7
|
+
*
|
|
8
|
+
* It starts a service that is already installed and stopped, because that is what someone typing this wants, and starting one is not a
|
|
9
|
+
* new grant: the unit exists because they installed it. It never installs one, which is `up --console`'s decision and asks first.
|
|
10
|
+
*/
|
|
11
|
+
import * as nodeFs from "node:fs";
|
|
12
|
+
import { homedir } from "node:os";
|
|
13
|
+
import { join, resolve } from "node:path";
|
|
14
|
+
import { parseArgs } from "node:util";
|
|
15
|
+
import { attachedCommand, commandsHere, waitForConsole } from "./console-bootstrap.mjs";
|
|
16
|
+
import { makeConsoleAudit } from "./console-audit.mjs";
|
|
17
|
+
import { consoleProbeTarget, probeConsole } from "./console-probe.mjs";
|
|
18
|
+
import { logsDirPath } from "./config.mjs";
|
|
19
|
+
import { findOnPath, installedConsoleUnitPath, resolveConsoleEntry, runService } from "./service.mjs";
|
|
20
|
+
import { deploymentServiceEnv } from "./service-env.mjs";
|
|
21
|
+
import { consoleTokensPathFor } from "./service.mjs";
|
|
22
|
+
import { readTokensFile, tokenNames } from "./console-tokens.mjs";
|
|
23
|
+
|
|
24
|
+
const SHELL = Object.freeze({ name: "shell", role: "shell", session: null });
|
|
25
|
+
|
|
26
|
+
const USAGE = "usage: pi-dispatch console [--name <name>] [--next /setup] [--port <n>] [--deployment <dir>]\n";
|
|
27
|
+
|
|
28
|
+
export async function runConsoleOpen(argv, deps = {}) {
|
|
29
|
+
const fs = deps.fs ?? nodeFs;
|
|
30
|
+
const env = deps.env ?? process.env;
|
|
31
|
+
const platform = deps.platform ?? process.platform;
|
|
32
|
+
const uid = deps.euid ?? process.geteuid?.();
|
|
33
|
+
const home = deps.home ?? homedir();
|
|
34
|
+
const out = deps.out ?? ((text) => process.stdout.write(text));
|
|
35
|
+
const refuse = (text) => { out(`pi-dispatch console: ${text}\n`); return 2; };
|
|
36
|
+
// Strict, as the console's own command is: a misread flag here would start a service or sign in the wrong person.
|
|
37
|
+
let values;
|
|
38
|
+
try { ({ values } = parseArgs({ args: argv, options: { deployment: { type: "string" }, name: { type: "string" }, next: { type: "string" }, port: { type: "string" }, help: { type: "boolean", short: "h" } } })); }
|
|
39
|
+
catch (e) { return refuse(`${e?.message ?? "the arguments could not be read"}; run pi-dispatch console --help`); }
|
|
40
|
+
if (values.help) { out(USAGE); return 0; }
|
|
41
|
+
// Free refusals first: a typo must not start a service and write a row before anyone finds out.
|
|
42
|
+
if (values.deployment === "") return refuse("--deployment needs a folder.");
|
|
43
|
+
if (values.next !== undefined && values.next !== "/setup") return refuse("--next must be /setup.");
|
|
44
|
+
if (values.port !== undefined && !(/^\d{1,5}$/.test(values.port) && Number(values.port) >= 1 && Number(values.port) <= 65535)) return refuse("--port must be a port number (1 to 65535).");
|
|
45
|
+
const realpath = fs.realpathSync;
|
|
46
|
+
const from = realpath(deps.cwd ?? process.cwd());
|
|
47
|
+
let dir;
|
|
48
|
+
try { dir = realpath(resolve(from, values.deployment ?? ".")); }
|
|
49
|
+
catch { return refuse("that deployment folder does not exist."); }
|
|
50
|
+
const here = commandsHere(dir, fs, { from });
|
|
51
|
+
if (!fs.existsSync(join(dir, ".env"))) return refuse(values.deployment === undefined ? "this folder is not a deployment. Run this from your deployment folder, or pass --deployment <dir>." : "that folder is not a deployment: it has no .env.");
|
|
52
|
+
// Read whatever `--name` says: a file the admin would refuse, or a choice it could not make, is known now, before a service starts.
|
|
53
|
+
let tokens = null;
|
|
54
|
+
try { tokens = (deps.readTokens ?? readTokensFile)({ path: (deps.consoleTokensPath ?? consoleTokensPathFor)(dir, platform), fs, uid }); } catch { /* the admin's own read says what is wrong */ }
|
|
55
|
+
if (tokens?.refusal) return refuse(`the console tokens file cannot be used: ${tokens.refusal}. Run ${here.worker} doctor.`);
|
|
56
|
+
if (tokens?.file) {
|
|
57
|
+
// `tokenNames`: an entry kept for a role this build does not know is no missing name, and the admin explains it (issue #761).
|
|
58
|
+
if (values.name !== undefined && !tokenNames(tokens.file).includes(values.name)) return refuse(`there is no token named "${values.name}" in this deployment.`);
|
|
59
|
+
const operators = tokens.file.tokens.filter((entry) => entry.role === "operator").length;
|
|
60
|
+
if (values.name === undefined && operators !== 1) return refuse(operators === 0 ? `there is no operator token yet. Run ${here.worker} up --console.` : `there are ${operators} operator tokens, so name one with --name <name>.`);
|
|
61
|
+
}
|
|
62
|
+
// env-internal PATH: find the installed admin binary, as `up --console` does.
|
|
63
|
+
const consoleEntry = (deps.resolveConsole ?? (() => resolveConsoleEntry({ findBin: (name) => findOnPath(name, env.PATH), realpath })))();
|
|
64
|
+
if (!consoleEntry) return refuse(`the console package is not installed here. Run ${here.worker} up --console in your deployment folder to set it up.`);
|
|
65
|
+
|
|
66
|
+
// PI_LOGS_DIR too: the service start's row and the sign-in's row must land in one audit chain.
|
|
67
|
+
const shellConsoleKeys = Object.keys(env).filter((key) => key.startsWith("PI_CONSOLE_") || key === "PI_LOGS_DIR");
|
|
68
|
+
const read = deploymentServiceEnv({ env: {}, dir, keys: [...new Set(["PI_CONSOLE_LISTEN", "PI_LOGS_DIR", ...shellConsoleKeys])], fs, uid, platform });
|
|
69
|
+
if (read.untrusted || (read.unreadable && read.unreadable !== "ENOENT")) return refuse(`this deployment's .env is unsafe or cannot be read. Run ${here.worker} doctor in this folder.`);
|
|
70
|
+
// The admin's `open` reads this shell's values first; the probe and the service read the file. Two answers would put the link, or the
|
|
71
|
+
// code, somewhere the running console never looks.
|
|
72
|
+
if (shellConsoleKeys.some((key) => env[key] !== read.env[key])) return refuse("console settings in this shell differ from the deployment's .env. Unset them here.");
|
|
73
|
+
const target = consoleProbeTarget(read.env.PI_CONSOLE_LISTEN);
|
|
74
|
+
if (target.unknown) return refuse("the console has no concrete local address (PI_CONSOLE_LISTEN), so no link could reach it.");
|
|
75
|
+
|
|
76
|
+
const probe = deps.probeConsole ?? probeConsole;
|
|
77
|
+
if ((await probe(read.env)).state !== "running") {
|
|
78
|
+
// This deployment's own unit, never just any console unit on the account: another's could carry a `--host-setup` grant.
|
|
79
|
+
if ((deps.consoleUnit ?? installedConsoleUnitPath)({ platform, home, cwd: dir }) === null) return refuse(`the console is not running, and no service for this deployment is installed. Run ${here.worker} up --console to set it up, or ${here.console} serve to run it in this terminal.`);
|
|
80
|
+
out("The console is not running. Starting its service.\n");
|
|
81
|
+
const audit = deps.audit ?? makeConsoleAudit({ logsDir: resolve(dir, logsDirPath(read.env)), fs, now: deps.now ?? Date.now });
|
|
82
|
+
const row = { actor: SHELL, action: "write", kind: "setup-console-start", target: "console", outcome: "ok" };
|
|
83
|
+
try { audit.append(row); } catch { return refuse(`the audit log could not be written, so the console service was not started. Run ${here.worker} doctor.`); }
|
|
84
|
+
const started = await (deps.runServiceFn ?? runService)(["start", "--console"], { cwd: dir, env, platform, euid: uid, home, fs, resolveConsole: () => consoleEntry });
|
|
85
|
+
if (started !== 0) {
|
|
86
|
+
try { audit.append({ ...row, outcome: "failed", reason: "console-start-refused" }); } catch { /* the refusal below is what the person must see */ }
|
|
87
|
+
return refuse(`the console service did not start. ${here.worker} service status --console says why.`);
|
|
88
|
+
}
|
|
89
|
+
const ready = await waitForConsole(read.env, { probe, now: deps.now ?? Date.now, ms: deps.consoleWaitMs ?? 15_000, ...(deps.sleep ? { sleep: deps.sleep } : {}) });
|
|
90
|
+
if (!ready) return refuse(`the console service started but did not answer in time. ${here.worker} service status --console says why.`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const passed = [];
|
|
94
|
+
for (const flag of ["name", "next", "port"]) if (values[flag] !== undefined) passed.push(`--${flag}`, values[flag]);
|
|
95
|
+
// A unix socket has no port of its own; 4790 is the local end of the tunnel the printed `ssh -L` line sets up.
|
|
96
|
+
if (target.kind === "unix" && values.port === undefined) passed.push("--port", "4790");
|
|
97
|
+
const code = await (deps.consoleOpen ?? attachedCommand)(process.execPath, [consoleEntry, "open", "--deployment", dir, ...passed], { cwd: dir, env });
|
|
98
|
+
return typeof code === "number" ? code : refuse("the console command could not be started.");
|
|
99
|
+
}
|
package/src/doctor-bundles.mjs
CHANGED
|
@@ -49,7 +49,7 @@ export function parseSecretBundleProfilesSafe(raw) {
|
|
|
49
49
|
* - `realExecutablePath`: the resolver's own probe (secrets.mjs `realExecutable`), or null when pi did not load;
|
|
50
50
|
* - `resolveBundle`: `(binding) => resolve result`, only with `--secrets`.
|
|
51
51
|
*/
|
|
52
|
-
export async function secretBundleChecks({ bundles = {}, triggersPresent = true, projects = [], projectsError = null, envRaw, overlayBundles = {}, roots = [], realExecutablePath = null, resolveBundle = null } = {}) {
|
|
52
|
+
export async function secretBundleChecks({ bundles = {}, triggersPresent = true, projects = [], projectsError = null, projectsUnread = false, envRaw, overlayBundles = {}, roots = [], realExecutablePath = null, resolveBundle = null } = {}) {
|
|
53
53
|
const checks = [];
|
|
54
54
|
const { bundles: envBundles, error } = parseSecretBundleProfilesSafe(envRaw);
|
|
55
55
|
if (error) {
|
|
@@ -91,7 +91,11 @@ export async function secretBundleChecks({ bundles = {}, triggersPresent = true,
|
|
|
91
91
|
} else if (!fromEnv && !Object.hasOwn(usableOverlay, b.profile)) {
|
|
92
92
|
checks.push({ ok: false, label: `secret bundle ${id} names profile ${b.profile}, declared in the settings overlay but not usable`, fix: roots.length === 0 ? "PI_SECRET_RESOLVER_ROOTS is unset, so the worker uses no overlay-declared resolver: set it, or declare the profile in PI_SECRET_BUNDLES" : "the resolver must be an executable file whose real path sits inside PI_SECRET_RESOLVER_ROOTS" });
|
|
93
93
|
}
|
|
94
|
-
if (
|
|
94
|
+
if (projectsUnread) {
|
|
95
|
+
// Issue #859: the `.env` line naming the projects file could not be read (its own line says why), so whether the
|
|
96
|
+
// service's file holds the project is not known: a warning, never the ✗ a file with no such project earns.
|
|
97
|
+
checks.push({ ok: false, warn: true, label: `secret bundle ${id} names project ${b.project}, and whether the projects file the service reads holds it is not known: doctor could not read what the deployment .env sets for PI_PROJECTS_FILE`, fix: "fix what the boot-file line above names, then re-run doctor" });
|
|
98
|
+
} else if (projectsError !== null) {
|
|
95
99
|
checks.push({ ok: false, label: `secret bundle ${id} names project ${b.project}, and projects.json does not load`, fix: "fix projects.json (its own line above says why) -- a bundle applies to a project's jobs only once the worker knows the project" });
|
|
96
100
|
} else if (!projectIds.has(b.project)) {
|
|
97
101
|
checks.push({ ok: false, label: `secret bundle ${id} names project ${b.project}, which projects.json does not hold`, fix: "add the project to PI_PROJECTS_FILE, or correct the bundle's project -- until then the bundle reaches no job, while the file reads as granting it" });
|
|
@@ -77,7 +77,7 @@ export function cheaperRouteChecks(roles, routes, models, endpoints, builtin = g
|
|
|
77
77
|
return checks;
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
-
export async function doctorModelProposalChecks(env, { cwd, credentials = {}, now = Date.now, read = safeRead, fetch = fetchLifecycle, endpoints: suppliedEndpoints } = {}) {
|
|
80
|
+
export async function doctorModelProposalChecks(env, { cwd, credentials = {}, now = Date.now, read = safeRead, fetch = fetchLifecycle, endpoints: suppliedEndpoints, declaredEndpoints = null } = {}) {
|
|
81
81
|
let config;
|
|
82
82
|
try { config = parseProposalConfig(read(join(cwd, "model-proposals.json"))); }
|
|
83
83
|
catch (error) {
|
|
@@ -102,7 +102,9 @@ export async function doctorModelProposalChecks(env, { cwd, credentials = {}, no
|
|
|
102
102
|
if (config.routes?.length) try {
|
|
103
103
|
const parsed = parseModelsJson(read(join(env.PI_GLOBAL_PI_DIR, "models.json")));
|
|
104
104
|
if (parsed.error) throw new Error("overlay unavailable");
|
|
105
|
-
|
|
105
|
+
// Issue #859: the endpoints as doctor's other endpoint checks read them (`declaredEndpointsIn`: this shell's file, else
|
|
106
|
+
// the deployment `.env`'s), handed in and asked only here; this shell's alone when a caller hands none.
|
|
107
|
+
const endpoints = suppliedEndpoints ?? (typeof declaredEndpoints === "function" ? declaredEndpoints() : loadModelEndpoints({ modelEndpointsFile: env.PI_MODEL_ENDPOINTS_FILE }, { cwd }));
|
|
106
108
|
checks.push(...cheaperRouteChecks(roles, config.routes, parsed.value, endpoints));
|
|
107
109
|
} catch { checks.push({ proposal: { kind: "unavailable" }, ok: false, warn: true, label: "Model proposals: declared route prices or endpoints unavailable", fix: "check the overlay models.json and declared endpoints; no route was changed" }); }
|
|
108
110
|
return checks;
|