@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.
@@ -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.mjs CHANGED
@@ -134,6 +134,7 @@ import { DOLLAR_ENV_NAMES, DOLLAR_SETTING_KEYS, checkDollarInvariant, effectiveC
134
134
  import { cronPlacement, envelopeJobPaths } from "./schedules.mjs";
135
135
  import { envelopeDigest, envelopeInsideJobPaths, loadEnvelopeChecked } from "./envelope.mjs";
136
136
  import { keptTokens, operatorCount, readTokensFile, tokensFilePath } from "./console-tokens.mjs";
137
+ import { consoleDevicesDir, devicesState } from "./console-devices.mjs";
137
138
  import { RUN_MIRROR_MAX_RUNS_DEFAULT, RUN_MIRROR_MAX_RUNS_MAX, RUN_MIRROR_MAX_RUNS_MIN, runMirrorMaxRunsOf } from "./retention.mjs";
138
139
  import { verifyConsoleAudit } from "./console-audit.mjs";
139
140
  import { OTHER } from "./priorities.mjs";
@@ -5552,6 +5553,16 @@ export function consoleChecks(env, { cwd = process.cwd(), fs = nodeFs, uid = pro
5552
5553
  } else if (operators > 0) {
5553
5554
  checks.push({ ok: true, label: `console: tokens file ${shownPath} is owner-only and outside every job folder (${operators} operator${operators === 1 ? "" : "s"}, ${viewers} viewer${viewers === 1 ? "" : "s"})` });
5554
5555
  }
5556
+ // Issue #849: the remembered devices, which sit beside the tokens file and are as sensitive as it is about who may sign in.
5557
+ const devicesDir = consoleDevicesDir(path);
5558
+ const devices = devicesState({ dir: devicesDir, fs, uid, platform });
5559
+ if (devices.problem) {
5560
+ checks.push({ ok: false, label: "console: the remembered-device folder cannot be used, so a remembered browser must sign in again", fix: escapeControls(devices.problem) });
5561
+ } else if (!devices.absent) {
5562
+ const devicesInside = consoleTokensInsideJobPaths(devicesDir, env, { cwd, fs, folders, skillsDirs, realpath, directory: true });
5563
+ if (devicesInside.problem) checks.push({ ok: false, label: `console: the remembered-device folder ${quotedShown(devicesDir)} ${devicesInside.problem}`, fix: devicesInside.fix });
5564
+ if (devices.unreadable > 0) checks.push({ ok: false, label: `console: ${devices.unreadable} remembered-device record${devices.unreadable === 1 ? "" : "s"} in ${quotedShown(devicesDir)} cannot be read, and those browsers must sign in again`, fix: `if a newer pi-dispatch wrote ${devices.unreadable === 1 ? "it" : "them"}, upgrade this host; otherwise remove the file${devices.unreadable === 1 ? "" : "s"} whose name${devices.unreadable === 1 ? " starts" : "s start"} with ${devices.unreadableIds.slice(0, 5).join(", ")} from that folder` });
5565
+ }
5555
5566
  const kept = keptTokens(read.file).length;
5556
5567
  if (kept > 0) {
5557
5568
  const said = kept === 1 ? "1 token carries a role this pi-dispatch does not know, so a console of this release signs it in nowhere" : `${kept} tokens carry a role this pi-dispatch does not know, so a console of this release signs them in nowhere`;
@@ -5567,6 +5578,11 @@ export function consoleChecks(env, { cwd = process.cwd(), fs = nodeFs, uid = pro
5567
5578
  } else {
5568
5579
  checks.push({ ok: true, label: `console: listens on ${escapeControls(listen.shown)}` });
5569
5580
  }
5581
+ // Only on an address that really is not loopback: PI_CONSOLE_BIND_PUBLIC=1 alone changes nothing about where a loopback console's cookies travel.
5582
+ if (bindPublic && !listen.refusal && !listen.loopback) {
5583
+ const devices = devicesState({ dir: consoleDevicesDir(path), fs, uid, platform });
5584
+ if (devices.live > 0) checks.push({ ok: false, warn: true, label: `console: ${devices.live} remembered device${devices.live === 1 ? "" : "s"} on a console that listens on a non-loopback address, where cookies cross the network in plain HTTP`, fix: "listen on a loopback address and reach the console through a tunnel, or put it behind a proxy that terminates TLS" });
5585
+ }
5570
5586
  if (bindPublic) {
5571
5587
  checks.push({ ok: false, warn: true, label: "console: PI_CONSOLE_BIND_PUBLIC=1 lets the console listen on a non-loopback address, where tokens and session cookies cross the network in plain HTTP", fix: "keep it only behind a TLS proxy on a network you trust; otherwise unset PI_CONSOLE_BIND_PUBLIC, listen on 127.0.0.1, [::1] or a unix: socket, and reach it with ssh -L" });
5572
5588
  }
@@ -5653,17 +5669,19 @@ export async function consoleServiceChecks(env, seams = {}) {
5653
5669
  * relative run root is skipped here (the envelope refuses one; the console has no such rule to borrow), and a path
5654
5670
  * that cannot be judged is said, not passed.
5655
5671
  */
5656
- function consoleTokensInsideJobPaths(path, env, { cwd, fs, folders, skillsDirs, realpath }) {
5672
+ function consoleTokensInsideJobPaths(path, env, { cwd, fs, folders, skillsDirs, realpath, directory = false }) {
5657
5673
  const fix = "move the tokens file out of every local trigger's run.folder and run.skillsDir, every PI_DISPATCH_RUN_ROOTS root and PI_GLOBAL_PI_DIR, and point PI_CONSOLE_TOKENS_FILE at its new path";
5658
5674
  try {
5659
5675
  const st = fs.statSync(path);
5660
- if (st.nlink > 1) return { problem: `has ${st.nlink} hard links: another name of the file could sit inside a folder a job can see, so keep exactly one`, fix: `remove the other names (\`find / -samefile ${path}\` lists them)` };
5676
+ // A folder always has more than one link (its own name, `.`, and each child's `..`), so the count says nothing about one.
5677
+ if (!directory && st.nlink > 1) return { problem: `has ${st.nlink} hard links: another name of the file could sit inside a folder a job can see, so keep exactly one`, fix: `remove the other names (\`find / -samefile ${path}\` lists them)` };
5661
5678
  const real = realpath(path);
5662
5679
  const runRoots = delimitedList(env.PI_DISPATCH_RUN_ROOTS).filter((r) => isAbsolute(r));
5663
5680
  const globalPiDir = typeof env.PI_GLOBAL_PI_DIR === "string" && env.PI_GLOBAL_PI_DIR !== "" ? env.PI_GLOBAL_PI_DIR : null;
5664
5681
  // PWD comes from the process environment inside the check, as on doctor's envelope load (`loadEnvelopeChecked`).
5665
- const inside = envelopeInsideJobPaths(real, { cronFolders: folders, runRoots, skillsDirs, globalPiDir }, { cwd });
5682
+ const inside = envelopeInsideJobPaths(real, { cronFolders: folders, runRoots, skillsDirs, globalPiDir }, { cwd, directory });
5666
5683
  if (inside === null) return {};
5684
+ if (directory) return { problem: `lies inside a folder a job container can see (${quotedShown(inside.path)}): a job there could plant a remembered device`, fix: "move the tokens file and this folder out of every folder a job can see, point PI_CONSOLE_TOKENS_FILE at the new path, and remove the old folder" };
5667
5685
  const what = { "cron-folder": "a local trigger's run.folder", "run-root": "a PI_DISPATCH_RUN_ROOTS root", "skills-dir": "a trigger's run.skillsDir", "global-pi-dir": "PI_GLOBAL_PI_DIR" }[inside.kind] ?? inside.kind;
5668
5686
  return { problem: `lies inside ${what} (${quotedShown(inside.path)}), which a job container can see: a job there could read the token hashes or add a token of its own`, fix };
5669
5687
  } catch (err) {
package/src/envelope.mjs CHANGED
@@ -290,7 +290,7 @@ function jobPathCandidates(path, cwd, env, statSync) {
290
290
  * to write instead, when there is one. The FILE must exist (the check runs with a successful load) and must have one
291
291
  * link: a hard link elsewhere is the same file under a name this check cannot see, and it could sit in a job path.
292
292
  */
293
- function canonicalEnvelopePath(path, realpath, statSync) {
293
+ function canonicalEnvelopePath(path, realpath, statSync, directory = false) {
294
294
  const text = String(path);
295
295
  if (!isAbsolute(text)) throw configError(`the envelope path ${JSON.stringify(text)} must be absolute: write its canonical path, with no symlink, "." or ".." in it`);
296
296
  let real;
@@ -304,6 +304,8 @@ function canonicalEnvelopePath(path, realpath, statSync) {
304
304
  }
305
305
  const stat = statSync(text, { bigint: true });
306
306
  // Before the link count: a directory's nlink counts its subdirectories, so "has 3 hard links" would misname it.
307
+ // `directory`: the console's device store is a folder, judged by the same identity rules. A folder has no link count to speak of.
308
+ if (directory && stat.isDirectory()) return text;
307
309
  if (!stat.isFile()) {
308
310
  throw configError(`the envelope path ${JSON.stringify(text)} is not a regular file; point it at the envelope file itself`);
309
311
  }
@@ -366,8 +368,8 @@ export function fileIdentity(stat) {
366
368
  * relative, and when a path cannot be read for a reason other than its absence. `realpathSync`, `statSync`, `cwd` and
367
369
  * `env` are injectable for tests.
368
370
  */
369
- export function envelopeInsideJobPaths(envelopePath, { cronFolders = [], runRoots = [], skillsDirs = [], globalPiDir = null } = {}, { realpathSync = fsRealpathSync.native, statSync = fsStatSync, cwd = process.cwd(), env = process.env } = {}) {
370
- const real = canonicalEnvelopePath(envelopePath, realpathSync, statSync);
371
+ export function envelopeInsideJobPaths(envelopePath, { cronFolders = [], runRoots = [], skillsDirs = [], globalPiDir = null } = {}, { realpathSync = fsRealpathSync.native, statSync = fsStatSync, cwd = process.cwd(), env = process.env, directory = false } = {}) {
372
+ const real = canonicalEnvelopePath(envelopePath, realpathSync, statSync, directory);
371
373
  // A relative run root is refused under an envelope, as `PI_GLOBAL_PI_DIR` and `run.skillsDir` already must be
372
374
  // absolute: the worker would judge it against its own working directory, while the admin's `dispatch_run` resolves
373
375
  // it in the operator's pi process, so the two could mean different folders.
@@ -17,7 +17,9 @@
17
17
  * name or password. pi's own resolver would run a value starting with `!` and expand bare `$NAME`; the job resolves
18
18
  * the references itself, never pi.
19
19
  *
20
- * Accepted is a subset of pi 1.1.0's schema (core/mcp-servers.js `validateMcpServerConfig`), strict on keys: a key
20
+ * Accepted is a subset of pi 1.1.0's schema (core/mcp-servers.js `validateMcpServerConfig`; since 1.5.2 pi-mcp's
21
+ * dist/config.js checks the fields MCP clients share and core/mcp-servers.js adds exposure and `auth`, accepting the
22
+ * same entries as before), strict on keys: a key
21
23
  * pi-dispatch does not know is refused, so a key a later pi adds is read before a job relies on it. Refused outright:
22
24
  * `auth` and `oauth` (a credential flow the container cannot finish, and a token file it must not hold), `cwd` (a job
23
25
  * runs its servers in the checkout), and the legacy `sse` type, which pi refuses too. `allowPackageRunner` is
@@ -154,6 +154,8 @@ const SamplingParams = T.rec(T.unknown());
154
154
  const SamplingParamsByThinkingLevel = T.obj({ off: opt(SamplingParams), minimal: opt(SamplingParams), low: opt(SamplingParams), medium: opt(SamplingParams), high: opt(SamplingParams), xhigh: opt(SamplingParams), max: opt(SamplingParams) });
155
155
  const KwargValue = T.union(T.str(), T.num(), T.bool(), T.nul(), T.obj({ $var: req(T.union(T.lit("thinking.enabled"), T.lit("thinking.effort"), T.lit("thinking.budget"))), omitWhenOff: opt(T.bool()) }));
156
156
  const affinity = T.union(T.lit("openai"), T.lit("openai-nosession"), T.lit("openrouter"));
157
+ // pi 1.5.2: the prompt_cache_key switch, the same three values in the completions, responses and generic schemas.
158
+ const cacheKeyMode = T.union(T.lit("auto"), T.lit("enabled"), T.lit("disabled"));
157
159
  const costRates = { input: req(T.num()), output: req(T.num()), cacheRead: req(T.num()), cacheWrite: req(T.num()) };
158
160
  const ModelCostTier = T.obj({ inputTokensAbove: req(T.num()), ...costRates });
159
161
  const ModelCost = T.obj({ ...costRates, tiers: opt(T.arr(ModelCostTier)) });
@@ -164,7 +166,8 @@ const ImageResize = T.obj({ maxWidth: opt(posInt), maxHeight: opt(posInt), maxBy
164
166
  const ModelInputLimits = T.obj({ maxRequestBytes: opt(posInt), images: opt(T.obj({ resize: opt(ImageResize), maxPerMessage: opt(posInt), maxPerRequest: opt(posInt) })) });
165
167
  // compat-schema.js's ProviderCompatSchema: the merged properties of the OpenAI completions, OpenAI responses, Anthropic
166
168
  // messages, Bedrock, Mistral and System One schemas. A key two of them share takes pi's generic override, which for every shared
167
- // key here is the same type as each api's own (a boolean, or the three-way affinity format), so the superset is exact.
169
+ // key here is the same type as each api's own (a boolean, the three-way affinity format, or 1.5.2's three-way
170
+ // promptCacheKeyMode), so the superset is exact.
168
171
  const bool = opt(T.bool());
169
172
  const ProviderCompat = T.obj({
170
173
  // OpenAI completions
@@ -176,6 +179,8 @@ const ProviderCompat = T.obj({
176
179
  maxTokensField: opt(T.union(T.lit("max_completion_tokens"), T.lit("max_tokens"))),
177
180
  requiresToolResultName: bool,
178
181
  requiresAssistantAfterToolResult: bool,
182
+ requiresNonNullAssistantContent: bool,
183
+ requiresToolsWithToolHistory: bool,
179
184
  requiresThinkingAsText: bool,
180
185
  requiresReasoningContentOnAssistantMessages: bool,
181
186
  thinkingFormat: opt(T.union(...["openai", "openrouter", "deepseek", "together", "baseten", "zai", "qwen", "chat-template", "qwen-chat-template", "string-thinking", "ant-ling"].map((x) => T.lit(x)))),
@@ -193,6 +198,7 @@ const ProviderCompat = T.obj({
193
198
  cacheControlFormat: opt(T.lit("anthropic")),
194
199
  sendSessionAffinityHeaders: bool,
195
200
  sessionAffinityFormat: opt(affinity),
201
+ promptCacheKeyMode: opt(cacheKeyMode),
196
202
  supportsLongCacheRetention: bool,
197
203
  vllmPriority: opt(T.num()),
198
204
  // OpenAI responses
@@ -209,6 +215,7 @@ const ProviderCompat = T.obj({
209
215
  supportsStrictTools: bool,
210
216
  supportsMidConvoEffort: bool,
211
217
  supportsMidConvoToolChanges: bool,
218
+ supportsMessageThreads: bool,
212
219
  // System One classifier image transport, checked before pi reads the overlay.
213
220
  imageInput: opt(T.union(T.lit("assets"), T.lit("images"), T.lit("state-image-urls"))),
214
221
  sendImageOptInHeader: bool,
@@ -4,7 +4,10 @@ import { dirname, join } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
 
6
6
  export function setupCachePath(path) {
7
- return /(?:^|[/\\])(?:_npx|_cacache|npm-cache|\.cache)(?:[/\\]|$)/.test(String(path));
7
+ // pnpm's, yarn's and bun's temporary stores are as disposable as npm's own. Matched by their whole shape, because a folder someone named
8
+ // `dlx` is an ordinary folder.
9
+ const text = String(path);
10
+ return /(?:^|[/\\])(?:_npx|_cacache|npm-cache|\.cache)(?:[/\\]|$)/.test(text) || /(?:^|[/\\])pnpm[/\\]dlx(?:[/\\]|$)/.test(text) || /(?:^|[/\\])xfs-[^/\\]+[/\\]dlx-\d+(?:[/\\]|$)/.test(text) || /(?:^|[/\\])bunx-\d+-[^/\\]+(?:[/\\]|$)/.test(text) || /(?:^|[/\\])\.yarn[/\\]berry[/\\]cache(?:[/\\]|$)/.test(text);
8
11
  }
9
12
  export function trustedSetupFile(entry, { uid = process.geteuid?.(), realpath = realpathSync, stat = statSync } = {}) {
10
13
  try {
package/src/up.mjs CHANGED
@@ -1155,7 +1155,9 @@ export async function runUp(argv = [], deps = {}) {
1155
1155
  for (const [name, note] of summary) {
1156
1156
  out(` ${name.padEnd(21)} ${note}\n`);
1157
1157
  }
1158
- out(`
1158
+ // `up --console` goes on to /setup, whose checklist is the next step, and these bare commands are not on a local install's PATH.
1159
+ const nextOut = deps.skipNextSteps ? () => {} : out;
1160
+ nextOut(`
1159
1161
  Next:
1160
1162
  edit ${envPath}
1161
1163
  set ANTHROPIC_API_KEY (or your provider's key) — already logged into pi? leave it blank
@@ -1167,7 +1169,7 @@ Next:
1167
1169
  // A worker run BY HAND reads only its shell, so on the podman venue it needs the list exported; the service reads
1168
1170
  // `.env` itself. Printed only there, so every other deployment's closing text is unchanged.
1169
1171
  if (venues.podmanUsed) {
1170
- out(" (podman venue: a worker started by hand reads only its shell, so export the same PI_BACKENDS there first, or run it as a service with `pi-dispatch service install`, which reads .env)\n");
1172
+ nextOut(" (podman venue: a worker started by hand reads only its shell, so export the same PI_BACKENDS there first, or run it as a service with `pi-dispatch service install`, which reads .env)\n");
1171
1173
  }
1172
1174
  // Issue #708: the console as a service, offered only where it could start (an operator token exists): without one,
1173
1175
  // `service install --console` refuses, and a "Next:" line that leads to a refusal is not a next step.
@@ -1175,10 +1177,10 @@ Next:
1175
1177
  try {
1176
1178
  consoleReady = consoleOperators(cwd) > 0 && !consoleUnitInstalled(cwd);
1177
1179
  } catch {}
1178
- if (consoleReady) out(" pi-dispatch service install --console\n keep the operator console running as a service (open it with /dispatch console)\n");
1180
+ if (consoleReady) nextOut(" pi-dispatch service install --console\n keep the operator console running as a service (open it with /dispatch console)\n");
1179
1181
  // Issue #707: the openshell venues' own next step, said only where one is blessed.
1180
1182
  if (openshell.used) {
1181
- out(` pi-dispatch service install\n the worker as a ${platform === "darwin" ? "LaunchAgent (the gateway runs in this login session: `brew services start openshell`, the service of OpenShell's release formula, keeps it there)" : `user service that pulls in the gateway's own unit (${OPENSHELL_GATEWAY_UNIT})`}; then \`pi-dispatch doctor\` reads the gateway, its engine and the image back. A worker started by hand reads only its shell, so export the same PI_BACKENDS (and PI_OPENSHELL_ENGINE) there first\n`);
1183
+ nextOut(` pi-dispatch service install\n the worker as a ${platform === "darwin" ? "LaunchAgent (the gateway runs in this login session: `brew services start openshell`, the service of OpenShell's release formula, keeps it there)" : `user service that pulls in the gateway's own unit (${OPENSHELL_GATEWAY_UNIT})`}; then \`pi-dispatch doctor\` reads the gateway, its engine and the image back. A worker started by hand reads only its shell, so export the same PI_BACKENDS (and PI_OPENSHELL_ENGINE) there first\n`);
1182
1184
  }
1183
1185
  // Issue #464: a Valkey this pass refused (another account's, or one no one could name) is a failed `up` whatever doctor
1184
1186
  // says: a worker started now would take that queue's jobs, and an exit 0 read by a script or the wizard says ready.