@edgehero/pi-dispatch 0.1.2 → 0.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/src/import-pi.mjs CHANGED
@@ -8,28 +8,46 @@
8
8
  * command copies only the safe subset and REFUSES a `models.json` that embeds a literal key.
9
9
  *
10
10
  * Copied: models.json (definitions only, sanitized), skills/, APPEND_SYSTEM.md, and extensions/ (verbatim;
11
- * the admin extension is hard-blocked). Extensions come along BY DEFAULT -- staging is the vetting
12
- * step, and an overlay missing the operator's own extensions is not the setup they asked for --
13
- * with `--no-extensions` as the escape hatch. Every extension staged is PRINTED by name, because
14
- * this is the moment the operator can still see what is about to run inside every job container.
11
+ * the admin extension is hard-blocked, and one the operator disabled in `pi config` is left behind).
12
+ * Extensions come along BY DEFAULT -- staging is the vetting step, and an overlay missing the
13
+ * operator's own extensions is not the setup they asked for -- with `--no-extensions` as the escape
14
+ * hatch. Every extension staged is PRINTED by name, because this is the moment the operator can
15
+ * still see what is about to run inside every job container.
15
16
  * Staged: packages/ — only under --with-packages — pinned third-party pi packages, installed here on the
16
- * host so a job container can load them from the overlay with NO network access (issue #58).
17
- * Never: auth.json, settings.json, sessions/, themes/, prompts/, tools/.
17
+ * host so a job container can load them from the overlay with NO network access (issue #58). Under
18
+ * that flag the packages the operator installed with `pi install` are DISCOVERED from their own pi
19
+ * setup and staged at the exact version their host runs (issue #102); `pi-packages.json` becomes
20
+ * the override-and-addition layer rather than the only road in, and `--no-host-packages` restores
21
+ * the declared-only behaviour exactly.
22
+ * Never: auth.json, settings.json, sessions/, themes/, prompts/, tools/. Discovery READS settings.json to
23
+ * learn what pi has installed and what the operator turned off; reading is not copying, and no part
24
+ * of that file reaches the overlay.
18
25
  */
19
- import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, statSync, copyFileSync, renameSync, rmSync } from "node:fs";
26
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, lstatSync, statSync, copyFileSync, renameSync, rmSync } from "node:fs";
20
27
  import { execFile } from "node:child_process";
21
- import { homedir } from "node:os";
22
28
  import { join } from "node:path";
23
29
  import { promisify } from "node:util";
24
- import { PACKAGES_SUBDIR, STAGE_MANIFEST, parsePackagesFile } from "./packages.mjs";
30
+ import { FROM_HOST, PACKAGES_SUBDIR, RESOURCE_DIRS, STAGE_MANIFEST, mergeHostPackages, parsePackagesFile } from "./packages.mjs";
31
+ import { agentDirFrom, readHostPi } from "./host-pi.mjs";
32
+ import { ENTRY_NAME_RE, copyDirContents, copySkillTree } from "./copy-tree.mjs";
25
33
 
26
- /** A valid skill/extension entry name: lowercase kebab/underscore, no dots (no "..") and no slashes. */
27
- export const ENTRY_NAME_RE = /^[a-z0-9](?:[a-z0-9_.-]{0,62}[a-z0-9])?$/i;
34
+ /**
35
+ * A valid skill/extension entry name. Defined in copy-tree.mjs, which is the module that enforces it,
36
+ * and re-exported from here because this is the address its readers already use (the materialiser's
37
+ * drift test, this module's own tests). Renaming an import path that has readers is not a tidy-up.
38
+ */
39
+ export { ENTRY_NAME_RE };
28
40
  /** The admin extension — never duplicated into a job overlay (it can enqueue paid jobs: a recursion vector). */
29
41
  export const ADMIN_RE = /pi-dispatch|dispatch-admin/i;
30
42
 
31
- /** The pi resource kinds a package may contribute by convention dir, when it carries no `pi` manifest. */
32
- const RESOURCE_DIRS = ["extensions", "skills", "prompts", "themes"];
43
+ /**
44
+ * Every flag this command accepts. Unknown ones are REFUSED (issue #102) rather than ignored: argv here is
45
+ * parsed by a bare indexOf, so before discovery a typo was harmless, but `--no-host-package` (singular) now
46
+ * silently means "third-party code you did not expect runs in every job container". A typo must not be able
47
+ * to widen what loads.
48
+ */
49
+ const BOOL_FLAGS = new Set(["--no-extensions", "--with-extensions", "--with-packages", "--host-packages", "--no-host-packages"]);
50
+ const VALUE_FLAGS = new Set(["--from", "--to", "--packages-file"]);
33
51
 
34
52
  const execFileAsync = promisify(execFile);
35
53
 
@@ -42,12 +60,10 @@ function defaultExec(file, args, options) {
42
60
  return execFileAsync(file, args, options);
43
61
  }
44
62
 
45
- // Resolve the host's pi agent dir the way pi's getAgentDir() does (env override, else ~/.pi/agent).
46
- // Custom: the worker CLI depends on @earendil-works/pi-ai/compat, not the whole pi-coding-agent SDK;
47
- // importing the SDK just to read one well-known path is not worth the weight.
48
- function defaultFrom(env) {
49
- return env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent");
50
- }
63
+ // The host's pi agent dir (env override, else ~/.pi/agent). The resolution moved to host-pi.mjs, which now
64
+ // needs the same answer for discovery and hands it to doctor as well; this alias keeps the call site here
65
+ // reading the way it always did.
66
+ const defaultFrom = agentDirFrom;
51
67
 
52
68
  /** A config value that defers to the environment/a command rather than embedding a literal secret. */
53
69
  function isIndirection(v) {
@@ -80,17 +96,30 @@ export async function runImportPi(argv = [], deps = {}) {
80
96
  env = process.env,
81
97
  cwd = process.cwd(),
82
98
  out = (s) => process.stdout.write(s),
83
- fs = { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, statSync, copyFileSync, renameSync, rmSync },
99
+ // lstatSync rides along for the shared copier, whose symlink guard is the whole point of it: the
100
+ // walker this dir's copies used to go through guarded with statSync, which FOLLOWS links.
101
+ fs = { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, lstatSync, statSync, copyFileSync, renameSync, rmSync },
84
102
  exec = defaultExec,
85
103
  // Injected like `fs`/`exec`/`out` so the win32 npm branch below is reachable from a test on any host;
86
104
  // it is the branch that was dead on arrival precisely because nothing could exercise it here.
87
105
  platform = process.platform,
88
106
  } = deps;
89
107
 
108
+ const unknown = unknownFlag(argv);
109
+ if (unknown) {
110
+ out(`error: unknown flag ${JSON.stringify(unknown)}\n Accepted: ${[...VALUE_FLAGS].map((f) => `${f} <value>`).join(", ")}, ${[...BOOL_FLAGS].join(", ")}.\n`);
111
+ return 1;
112
+ }
113
+
90
114
  // Extensions are copied unless the operator says otherwise. `--with-extensions` is still accepted and is
91
115
  // now a no-op, so an existing setup script keeps working and keeps meaning what it always meant.
92
116
  const withExtensions = !argv.includes("--no-extensions");
93
117
  const withPackages = argv.includes("--with-packages");
118
+ // Discovery rides inside --with-packages rather than arriving as a third gate. A flagless import still
119
+ // stages nothing, exactly as before; the only run whose behaviour moved is one that already asked for
120
+ // "the packages" and until now silently got a subset that excluded whatever `pi install` had put there.
121
+ // `--host-packages` is accepted as a no-op for symmetry with `--with-extensions`.
122
+ const withHostPackages = withPackages && !argv.includes("--no-host-packages");
94
123
  const from = flagValue(argv, "--from") ?? defaultFrom(env);
95
124
  const to = flagValue(argv, "--to") ?? join(cwd, "pi-global");
96
125
  const packagesFile = flagValue(argv, "--packages-file") ?? env.PI_PACKAGES_FILE ?? join(cwd, "pi-packages.json");
@@ -125,6 +154,10 @@ export async function runImportPi(argv = [], deps = {}) {
125
154
  }
126
155
  }
127
156
 
157
+ // Read the operator's own pi setup ONCE, after the secret gate so a refused import never spawned a
158
+ // package manager, and before the extensions copy because that copy now depends on it (issue #102).
159
+ const hostPi = await readHostPi({ agentDir: from, fs, exec, platform, withPackages: withHostPackages });
160
+
128
161
  const results = [];
129
162
  fs.mkdirSync(to, { recursive: true });
130
163
 
@@ -155,12 +188,24 @@ export async function runImportPi(argv = [], deps = {}) {
155
188
  // would leave them re-deriving the list from a directory they cannot see from the worker host.
156
189
  const extSrc = join(from, "extensions");
157
190
  if (withExtensions && fs.existsSync(extSrc)) {
158
- const { copied, blocked } = copyExtensions(fs, extSrc, join(to, "extensions"), out);
191
+ const { copied, blocked, disabled } = copyExtensions(fs, extSrc, join(to, "extensions"), out, hostPi.extensions.disabled);
159
192
  if (copied.length > 0) {
160
193
  results.push(["extensions/", `${copied.length} extension${copied.length === 1 ? "" : "s"} -- these LOAD in every job; VET THESE`]);
161
194
  // Note-less rows: the printer emits them bare, so the names read as a list under the count above.
162
195
  for (const name of copied) results.push([` - ${name}`, ""]);
163
196
  }
197
+ // Reported by OMISSION plus its own row, never as a suffix on a name row: the names above are the
198
+ // vetting list, and a reader scanning it must not have to parse each line to learn what is live.
199
+ if (disabled.length > 0) {
200
+ results.push(["extensions (off)", `${disabled.length} disabled in your pi settings, not copied`]);
201
+ for (const name of disabled) results.push([` - ${name}`, ""]);
202
+ }
203
+ if (hostPi.extensions.unevaluated.length > 0) {
204
+ out(
205
+ `\nnote: ${hostPi.settingsPath} disables extensions with a pattern this command cannot evaluate,\n` +
206
+ ` so these were copied and may be ones you turned off: ${hostPi.extensions.unevaluated.join(", ")}\n`,
207
+ );
208
+ }
164
209
  for (const name of blocked) out(` blocked extension "${name}" — the admin extension must never run inside a job.\n`);
165
210
  out(
166
211
  "\n⚠ Extensions run code against adversarial input with open network egress and are NOT scanned for\n" +
@@ -174,14 +219,45 @@ export async function runImportPi(argv = [], deps = {}) {
174
219
  // packages/ — pinned third-party pi packages, staged from npm on THIS host so the job container never
175
220
  // needs the network (issue #58). All-or-nothing: a failure leaves no half-staged set to load.
176
221
  if (withPackages) {
177
- const staged = await stagePackages({ fs, exec, out, packagesFile, to, platform });
222
+ // Discovered candidates split in two: the ones we can stage, and the ones we name a reason for. Both
223
+ // are printed. A discovery that quietly ignored half the host's packages would be the same silent
224
+ // no-op this feature exists to remove.
225
+ const stageable = hostPi.packages.filter((p) => !p.skip);
226
+ const byName = new Map(hostPi.packages.map((p) => [p.name, p]));
227
+ const staged = await stagePackages({ fs, exec, out, packagesFile, to, platform, discovered: stageable, requireFile: !withHostPackages });
178
228
  if (staged.error) {
179
229
  out(`error: ${staged.error}\n`);
180
230
  return 1;
181
231
  }
182
232
  const n = staged.packages.length;
183
233
  results.push(["packages/", `${n} package${n === 1 ? "" : "s"} -- third-party code, VET THESE`]);
234
+ const overrides = new Map(staged.overrides.map((o) => [o.name, o]));
235
+ for (const entry of staged.packages) {
236
+ const override = overrides.get(entry.name);
237
+ const provenance = entry.from === FROM_HOST
238
+ ? "from your pi setup"
239
+ : override
240
+ ? `from pi-packages.json, overrides your pi setup's ${override.host}`
241
+ : "from pi-packages.json";
242
+ results.push([` - ${entry.name}@${entry.version} (${provenance})`, ""]);
243
+ }
184
244
  for (const warn of staged.warnings) results.push([`packages/${warn.dir}`, `WARN: ${warn.reason}`]);
245
+ // A package pi only partly loads still stages WHOLE: staging copies a directory, so "the package minus
246
+ // one skill" is not expressible. Say that rather than pretend the host's filter travelled.
247
+ for (const entry of staged.packages) {
248
+ const host = entry.from === FROM_HOST ? byName.get(entry.name) : null;
249
+ if (host && (host.filtered || host.autoload === false)) {
250
+ results.push([`packages/${entry.dir}`, `WARN: your pi settings load only part of ${entry.name}; the overlay stages ALL of it`]);
251
+ }
252
+ }
253
+ const skipped = [...hostPi.packages.filter((p) => p.skip).map((p) => ({ name: p.source, reason: p.skip })), ...staged.dropped];
254
+ if (skipped.length > 0) {
255
+ results.push(["host packages", `${skipped.length} skipped -- not staged`]);
256
+ for (const item of skipped) results.push([` - ${item.name} (${item.reason})`, ""]);
257
+ }
258
+ if (withHostPackages && hostPi.settingsState !== "ok") {
259
+ results.push(["settings.json", hostSettingsNote(hostPi)]);
260
+ }
185
261
  } else if (fs.existsSync(join(to, PACKAGES_SUBDIR))) {
186
262
  results.push(["packages/", "kept -- re-run with --with-packages to refresh"]);
187
263
  }
@@ -191,34 +267,48 @@ export async function runImportPi(argv = [], deps = {}) {
191
267
  // than padded out to a column that has nothing to hold.
192
268
  for (const [name, note] of results) out(note ? ` ${name.padEnd(18)} ${note}\n` : ` ${name}\n`);
193
269
  out(`\n (auth.json, settings.json, sessions/ are never copied — your credential stays in env/auth.json.)\n`);
194
- out(nextSteps(to, withExtensions, withPackages));
270
+ out(nextSteps(to, withExtensions, withPackages, withHostPackages && hostPi.packages.length > 0));
195
271
  return 0;
196
272
  }
197
273
 
198
- /** Copy `<src>/<name>/**` for each valid, non-symlink child dir. Returns the count copied. */
274
+ /**
275
+ * Copy `<src>/<name>/**` for each valid, non-symlink child dir. Returns the count copied.
276
+ *
277
+ * Delegates to the shared copier (issue #60), which is where the symlink guard actually works. The
278
+ * guard here USED to be `fs.statSync(p).isSymbolicLink?.()`, and `statSync` FOLLOWS links, so it was
279
+ * permanently false: a symlinked skill directory in `~/.pi/agent/skills` was staged as its target's
280
+ * CONTENTS into an overlay that is :ro-mounted into every job container. `copySkillTree` uses lstat.
281
+ *
282
+ * Caps are off and source modes are preserved, so this command behaves exactly as it did apart from
283
+ * the repair: the overlay is deploy-time operator config, not a per-job input, and a staged file that
284
+ * changed mode would break a re-import (copyFileSync onto a 0444 file is EACCES).
285
+ */
199
286
  function copyNamedDirs(fs, src, dst, out) {
200
287
  if (!fs.existsSync(src)) return 0;
201
- let n = 0;
202
- for (const name of fs.readdirSync(src)) {
203
- if (!ENTRY_NAME_RE.test(name)) {
204
- out(` skipped "${name}" — unexpected name\n`);
205
- continue;
206
- }
207
- const childSrc = join(src, name);
208
- if (fs.statSync(childSrc).isSymbolicLink?.() || !fs.statSync(childSrc).isDirectory()) continue;
209
- copyTree(fs, childSrc, join(dst, name));
210
- n++;
211
- }
212
- return n;
288
+ const result = copySkillTree(src, dst, {
289
+ fs,
290
+ limits: null,
291
+ mode: null,
292
+ onSkip: (name, reason) => out(` skipped "${name}" — ${reason === "symlink" ? "symlink" : "unexpected name"}\n`),
293
+ });
294
+ // "empty" is a refusal for a TRIGGER that asked for skills; here it just means the operator has
295
+ // none, which is the ordinary case for a host that never wrote a skill.
296
+ return result.refused ? 0 : result.dirs;
213
297
  }
214
298
 
215
299
  /**
216
300
  * Like copyNamedDirs but reports the admin extension it refuses to copy. Returns the NAMES copied, not a
217
301
  * count: the caller prints them, so the operator sees exactly what is now going into job containers.
302
+ *
303
+ * `hostDisabled` holds the names the operator turned off with `pi config` (issue #102). They are not copied,
304
+ * and they are returned separately rather than merged into `copied`, because `copied` IS the vetting list and
305
+ * a list that mixed live and inert entries would be worse than no list. Skipping them is a correction, not a
306
+ * feature: until this landed, an extension explicitly disabled on the host still ran in every job container.
218
307
  */
219
- function copyExtensions(fs, src, dst, out) {
308
+ function copyExtensions(fs, src, dst, out, hostDisabled = new Set()) {
220
309
  const copied = [];
221
310
  const blocked = [];
311
+ const disabled = [];
222
312
  for (const name of fs.readdirSync(src)) {
223
313
  if (ADMIN_RE.test(name)) {
224
314
  blocked.push(name);
@@ -228,50 +318,71 @@ function copyExtensions(fs, src, dst, out) {
228
318
  out(` skipped "${name}" — unexpected name\n`);
229
319
  continue;
230
320
  }
321
+ if (hostDisabled.has(name)) {
322
+ disabled.push(name);
323
+ continue;
324
+ }
231
325
  const childSrc = join(src, name);
232
- const st = fs.statSync(childSrc);
326
+ // lstat, NEVER stat: stat follows the link, so the old `statSync(p).isSymbolicLink?.()` here was
327
+ // permanently false and a symlinked extension was staged as its target's contents (issue #60).
328
+ const st = (fs.lstatSync ?? fs.statSync)(childSrc);
233
329
  if (st.isSymbolicLink?.()) continue;
234
330
  if (st.isDirectory()) copyTree(fs, childSrc, join(dst, name));
235
331
  else fs.copyFileSync(childSrc, join(dst, name));
236
332
  copied.push(name);
237
333
  }
238
- return { copied, blocked };
334
+ return { copied, blocked, disabled };
239
335
  }
240
336
 
241
- /** Recursively copy a directory tree, skipping symlinks (a symlink could point outside the source). */
337
+ /**
338
+ * Recursively copy one directory's contents, skipping symlinks (a symlink could point outside the
339
+ * source). A thin adapter over the shared walker, so extensions and skills cannot drift apart on the
340
+ * guard that matters: it is `lstat` there, where it used to be a `statSync` that followed every link.
341
+ */
242
342
  function copyTree(fs, src, dst) {
243
- fs.mkdirSync(dst, { recursive: true });
244
- for (const entry of fs.readdirSync(src)) {
245
- const s = join(src, entry);
246
- const st = fs.statSync(s);
247
- if (st.isSymbolicLink?.()) continue;
248
- if (st.isDirectory()) copyTree(fs, s, join(dst, entry));
249
- else fs.copyFileSync(s, join(dst, entry));
250
- }
343
+ copyDirContents(src, dst, { fs, limits: null, mode: null });
251
344
  }
252
345
 
253
346
  /**
254
347
  * Stage every package pinned in `packagesFile` into `<to>/packages/<dir>` and write the stage manifest.
255
- * Returns `{ packages, warnings }`, or `{ error }` -- ALL-OR-NOTHING, because a half-staged set is worse
256
- * than none: pi would load the packages that made it and silently skip the rest (issue #58).
348
+ * Returns `{ packages, warnings, overrides, dropped }`, or `{ error }`.
349
+ *
350
+ * ALL-OR-NOTHING for DECLARED packages, because a half-staged set is worse than none: pi would load the
351
+ * packages that made it and silently skip the rest (issue #58). Scoped to the declared set once discovery
352
+ * landed (issue #102): a declared pin is a promise the operator made, so failing it still refuses everything,
353
+ * but a discovered package is an inference WE made and it is DROPPED with a printed reason instead. Without
354
+ * that split, discovery would multiply the entry count from two pins to twenty and one bad host package
355
+ * would zero an overlay that was working. A named drop is not the silent skip the original rule forbade.
257
356
  *
258
357
  * Each package is installed into a private `.staging-<i>` dir, asserted there, and only renamed into place
259
358
  * once EVERY package has passed. A staged dir must be SELF-CONTAINED (`package.json` + its own
260
359
  * `node_modules/`) because at job time it is resolved from a read-only mount with no network and no
261
360
  * install step -- so every assertion below is about that property.
361
+ *
362
+ * `discovered` is what host-pi.mjs found in the operator's own pi setup (issue #102). Discovery adds
363
+ * CANDIDATES, never exemptions: every assertion below runs on a discovered entry exactly as it does on a
364
+ * declared one, and the merge that produced the list refused an admin package and a colliding dir using the
365
+ * same validator the declared path uses.
262
366
  */
263
- async function stagePackages({ fs, exec, out, packagesFile, to, platform = process.platform }) {
264
- if (!fs.existsSync(packagesFile)) {
367
+ async function stagePackages({ fs, exec, out, packagesFile, to, platform = process.platform, discovered = [], requireFile = true }) {
368
+ const haveFile = fs.existsSync(packagesFile);
369
+ // The missing-file refusal is now conditional. With discovery on there is a second source of entries, so
370
+ // no file simply means "nothing declared"; with `--no-host-packages` there is no other source and the
371
+ // refusal is the same one it always was.
372
+ if (!haveFile && requireFile) {
265
373
  return { error: `--with-packages needs a packages file, none at ${packagesFile}\n Run \`pi-dispatch init\` to scaffold one, or pass --packages-file <path>.` };
266
374
  }
267
375
 
268
- let entries;
269
- try {
270
- entries = parsePackagesFile(fs.readFileSync(packagesFile, "utf8"), packagesFile);
271
- } catch (error) {
272
- // Refused before a single directory is created, so a bad file stages nothing at all.
273
- return { error: error.message };
376
+ let declared = [];
377
+ if (haveFile) {
378
+ try {
379
+ declared = parsePackagesFile(fs.readFileSync(packagesFile, "utf8"), packagesFile);
380
+ } catch (error) {
381
+ // Refused before a single directory is created, so a bad file stages nothing at all.
382
+ return { error: error.message };
383
+ }
274
384
  }
385
+ const { entries, overrides, dropped } = mergeHostPackages(declared, discovered);
275
386
 
276
387
  const packagesRoot = join(to, PACKAGES_SUBDIR);
277
388
  const rootExisted = fs.existsSync(packagesRoot);
@@ -282,94 +393,25 @@ async function stagePackages({ fs, exec, out, packagesFile, to, platform = proce
282
393
  const prepared = [];
283
394
  const renamed = [];
284
395
  const warnings = [];
396
+ const softDropped = [];
285
397
 
286
398
  try {
287
399
  for (const [index, entry] of entries.entries()) {
288
400
  const staging = join(packagesRoot, `.staging-${index}`);
289
401
  fs.rmSync(staging, { recursive: true, force: true }); // a crashed earlier run may have left one
290
402
  stagingDirs.push(staging);
291
- fs.mkdirSync(staging, { recursive: true });
292
- // A private root package.json pins npm's idea of "the project" to the staging dir, so it cannot
293
- // walk up and install into (or read config from) the operator's own checkout.
294
- fs.writeFileSync(join(staging, "package.json"), `${JSON.stringify({ name: "pi-dispatch-staging", private: true }, null, 2)}\n`);
295
-
296
- // ARRAY argv, never a shell string: the name and version come from a config file and must never be
297
- // able to become shell syntax on the operator's host. The install target is the exec's `cwd`, NOT a
298
- // `--prefix <staging>` pair -- npm installs into the cwd's node_modules by default, and dropping the
299
- // flag removes the only filesystem PATH from argv. What is left is nothing but literal flags and one
300
- // `name@version` token already validated against NPM_NAME_RE + EXACT_VERSION_RE; that is the property
301
- // npmExecOptions relies on below.
302
- //
303
- // --ignore-scripts is load-bearing: without it the lifecycle scripts of this package AND of every
304
- // transitive dependency would run as the operator, on the operator's host, at stage time.
305
- // --omit=peer because pi aliases its own packages for extensions at load time, so a staged peer
306
- // copy is ignored dead weight -- and a floating pi version at that (CONST-PI-VERSION-PINNED).
307
- // --install-strategy=nested asks npm to keep every dependency inside the package dir; step 4 below
308
- // ASSERTS the result rather than trusting the flag, whose name and default have moved across npm
309
- // versions.
310
- const args = [
311
- "install",
312
- `${entry.name}@${entry.version}`,
313
- "--omit=dev",
314
- "--omit=peer",
315
- "--omit=optional",
316
- "--ignore-scripts",
317
- "--install-strategy=nested",
318
- "--no-audit",
319
- "--no-fund",
320
- "--loglevel=error",
321
- ];
322
- out(` staging ${entry.name}@${entry.version} -> packages/${entry.dir}\n`);
403
+ let source;
323
404
  try {
324
- await exec(npmBin, args, npmExecOptions(platform, staging));
405
+ source = await prepareOne({ fs, exec, out, entry, staging, npmBin, platform, warnings });
325
406
  } catch (error) {
326
- const detail = String(error?.stderr ?? error?.message ?? "").trim();
327
- throw new Error(`npm install failed for ${entry.name}@${entry.version}: ${detail}`);
328
- }
329
-
330
- const source = join(staging, "node_modules", entry.name);
331
- let pkg;
332
- try {
333
- pkg = JSON.parse(fs.readFileSync(join(source, "package.json"), "utf8"));
334
- } catch {
335
- throw new Error(`${entry.name}@${entry.version}: npm reported success but there is no readable package.json at ${join(source, "package.json")}`);
336
- }
337
- if (pkg.version !== entry.version) {
338
- throw new Error(`${entry.name}: npm staged version ${JSON.stringify(pkg.version)}, not the pinned ${JSON.stringify(entry.version)} (CONST-PI-VERSION-PINNED)`);
339
- }
340
-
341
- // Dependency completeness -- catches hoisting whatever npm's flag defaults do this month. A
342
- // hoisted dependency would only surface as an import failure inside a job, hours later.
343
- for (const dep of Object.keys(pkg.dependencies ?? {})) {
344
- if (!fs.existsSync(join(source, "node_modules", dep))) {
345
- throw new Error(`${entry.name}: dependency "${dep}" is not inside the package dir -- npm hoisted it out, so the staged copy could not import it at run time (no network, no install)`);
407
+ // The declared/discovered split: an entry the operator pinned is fatal, one we inferred from
408
+ // their pi setup is dropped by name so the rest of the stage still lands.
409
+ if (entry.from === FROM_HOST) {
410
+ softDropped.push({ name: entry.name, reason: error.message });
411
+ continue;
346
412
  }
413
+ throw error;
347
414
  }
348
-
349
- // A package that contributes no pi resources loads as a silent no-op; staging exists to turn that
350
- // run-time nothing into a stage-time error the operator can act on.
351
- const manifest = pkg.pi !== null && typeof pkg.pi === "object" ? pkg.pi : null;
352
- const hasResourceDir = RESOURCE_DIRS.some((name) => fs.existsSync(join(source, name)));
353
- if (!manifest && !hasResourceDir) {
354
- throw new Error(`${entry.name} is not a pi package -- no "pi" manifest in package.json and none of ${RESOURCE_DIRS.join("/")}; it would load as a silent no-op`);
355
- }
356
-
357
- // Containment: manifest entries are resolved relative to the package dir at job time, so one that
358
- // climbs out of it would reach the rest of the read-only overlay.
359
- const escaping = manifest && findEscapingEntry(manifest);
360
- if (escaping) {
361
- throw new Error(`${entry.name}: pi manifest entry ${JSON.stringify(escaping)} leaves the package dir (no ".." segment, no leading "/")`);
362
- }
363
-
364
- // Warn, do not refuse: --ignore-scripts means a build/postinstall step did NOT run and an optional
365
- // dependency was NOT fetched, so such a package is staged INCOMPLETE and may fail at run time.
366
- const scriptKeys = ["install", "preinstall", "postinstall"].filter((key) => typeof pkg.scripts?.[key] === "string");
367
- const hasOptional = Object.keys(pkg.optionalDependencies ?? {}).length > 0;
368
- if (scriptKeys.length > 0 || hasOptional) {
369
- const declares = [...scriptKeys.map((key) => `scripts.${key}`), ...(hasOptional ? ["optionalDependencies"] : [])].join(", ");
370
- warnings.push({ dir: entry.dir, reason: `${entry.name} declares ${declares} -- staged with --ignore-scripts, so it is INCOMPLETE and may fail at run time` });
371
- }
372
-
373
415
  prepared.push({ entry, source });
374
416
  }
375
417
 
@@ -392,9 +434,105 @@ async function stagePackages({ fs, exec, out, packagesFile, to, platform = proce
392
434
 
393
435
  for (const staging of stagingDirs) fs.rmSync(staging, { recursive: true, force: true });
394
436
 
395
- const stageManifest = { stagedAt: new Date().toISOString(), packages: entries.map(({ name, version, dir }) => ({ name, version, dir })) };
437
+ // A dropped entry never made it into `prepared`, so it must not appear in the receipt either.
438
+ const staged = entries.filter((entry) => prepared.some((p) => p.entry === entry));
439
+ // `from` and nothing more. This receipt is bind-mounted into every job container, so it must never carry
440
+ // an install path off the operator's machine -- provenance is the fact doctor needs, the host path is not.
441
+ const stageManifest = { stagedAt: new Date().toISOString(), packages: staged.map(({ name, version, dir, from }) => ({ name, version, dir, from })) };
396
442
  fs.writeFileSync(join(packagesRoot, STAGE_MANIFEST), `${JSON.stringify(stageManifest, null, 2)}\n`);
397
- return { packages: entries, warnings };
443
+ return { packages: staged, warnings, overrides, dropped: [...dropped, ...softDropped] };
444
+ }
445
+
446
+ /**
447
+ * Install and ASSERT one package inside its private staging dir, returning the path to assert-clean source.
448
+ * Throws on any failure; the caller decides whether that is fatal (a declared pin) or a drop (a discovered
449
+ * one). Every assertion here is about one property: the staged dir must be SELF-CONTAINED, because at job
450
+ * time it is resolved from a read-only mount with no network and no install step.
451
+ */
452
+ async function prepareOne({ fs, exec, out, entry, staging, npmBin, platform, warnings }) {
453
+ fs.mkdirSync(staging, { recursive: true });
454
+ // A private root package.json pins npm's idea of "the project" to the staging dir, so it cannot
455
+ // walk up and install into (or read config from) the operator's own checkout.
456
+ fs.writeFileSync(join(staging, "package.json"), `${JSON.stringify({ name: "pi-dispatch-staging", private: true }, null, 2)}\n`);
457
+
458
+ // ARRAY argv, never a shell string: the name and version come from a config file and must never be
459
+ // able to become shell syntax on the operator's host. The install target is the exec's `cwd`, NOT a
460
+ // `--prefix <staging>` pair -- npm installs into the cwd's node_modules by default, and dropping the
461
+ // flag removes the only filesystem PATH from argv. What is left is nothing but literal flags and one
462
+ // `name@version` token already validated against NPM_NAME_RE + EXACT_VERSION_RE; that is the property
463
+ // npmExecOptions relies on below.
464
+ //
465
+ // --ignore-scripts is load-bearing: without it the lifecycle scripts of this package AND of every
466
+ // transitive dependency would run as the operator, on the operator's host, at stage time.
467
+ // --omit=peer because pi aliases its own packages for extensions at load time, so a staged peer
468
+ // copy is ignored dead weight -- and a floating pi version at that (CONST-PI-VERSION-PINNED).
469
+ // --install-strategy=nested asks npm to keep every dependency inside the package dir; step 4 below
470
+ // ASSERTS the result rather than trusting the flag, whose name and default have moved across npm
471
+ // versions.
472
+ const args = [
473
+ "install",
474
+ `${entry.name}@${entry.version}`,
475
+ "--omit=dev",
476
+ "--omit=peer",
477
+ "--omit=optional",
478
+ "--ignore-scripts",
479
+ "--install-strategy=nested",
480
+ "--no-audit",
481
+ "--no-fund",
482
+ "--loglevel=error",
483
+ ];
484
+ out(` staging ${entry.name}@${entry.version} -> packages/${entry.dir}\n`);
485
+ try {
486
+ await exec(npmBin, args, npmExecOptions(platform, staging));
487
+ } catch (error) {
488
+ const detail = String(error?.stderr ?? error?.message ?? "").trim();
489
+ throw new Error(`npm install failed for ${entry.name}@${entry.version}: ${detail}`);
490
+ }
491
+
492
+ const source = join(staging, "node_modules", entry.name);
493
+ let pkg;
494
+ try {
495
+ pkg = JSON.parse(fs.readFileSync(join(source, "package.json"), "utf8"));
496
+ } catch {
497
+ throw new Error(`${entry.name}@${entry.version}: npm reported success but there is no readable package.json at ${join(source, "package.json")}`);
498
+ }
499
+ if (pkg.version !== entry.version) {
500
+ throw new Error(`${entry.name}: npm staged version ${JSON.stringify(pkg.version)}, not the pinned ${JSON.stringify(entry.version)} (CONST-PI-VERSION-PINNED)`);
501
+ }
502
+
503
+ // Dependency completeness -- catches hoisting whatever npm's flag defaults do this month. A
504
+ // hoisted dependency would only surface as an import failure inside a job, hours later.
505
+ for (const dep of Object.keys(pkg.dependencies ?? {})) {
506
+ if (!fs.existsSync(join(source, "node_modules", dep))) {
507
+ throw new Error(`${entry.name}: dependency "${dep}" is not inside the package dir -- npm hoisted it out, so the staged copy could not import it at run time (no network, no install)`);
508
+ }
509
+ }
510
+
511
+ // A package that contributes no pi resources loads as a silent no-op; staging exists to turn that
512
+ // run-time nothing into a stage-time error the operator can act on.
513
+ const manifest = pkg.pi !== null && typeof pkg.pi === "object" ? pkg.pi : null;
514
+ const hasResourceDir = RESOURCE_DIRS.some((name) => fs.existsSync(join(source, name)));
515
+ if (!manifest && !hasResourceDir) {
516
+ throw new Error(`${entry.name} is not a pi package -- no "pi" manifest in package.json and none of ${RESOURCE_DIRS.join("/")}; it would load as a silent no-op`);
517
+ }
518
+
519
+ // Containment: manifest entries are resolved relative to the package dir at job time, so one that
520
+ // climbs out of it would reach the rest of the read-only overlay.
521
+ const escaping = manifest && findEscapingEntry(manifest);
522
+ if (escaping) {
523
+ throw new Error(`${entry.name}: pi manifest entry ${JSON.stringify(escaping)} leaves the package dir (no ".." segment, no leading "/")`);
524
+ }
525
+
526
+ // Warn, do not refuse: --ignore-scripts means a build/postinstall step did NOT run and an optional
527
+ // dependency was NOT fetched, so such a package is staged INCOMPLETE and may fail at run time.
528
+ const scriptKeys = ["install", "preinstall", "postinstall"].filter((key) => typeof pkg.scripts?.[key] === "string");
529
+ const hasOptional = Object.keys(pkg.optionalDependencies ?? {}).length > 0;
530
+ if (scriptKeys.length > 0 || hasOptional) {
531
+ const declares = [...scriptKeys.map((key) => `scripts.${key}`), ...(hasOptional ? ["optionalDependencies"] : [])].join(", ");
532
+ warnings.push({ dir: entry.dir, reason: `${entry.name} declares ${declares} -- staged with --ignore-scripts, so it is INCOMPLETE and may fail at run time` });
533
+ }
534
+
535
+ return source;
398
536
  }
399
537
 
400
538
  /**
@@ -437,7 +575,26 @@ function flagValue(argv, flag) {
437
575
  return i >= 0 && i + 1 < argv.length ? argv[i + 1] : undefined;
438
576
  }
439
577
 
440
- function nextSteps(to, withExtensions, withPackages) {
578
+ /** The first argument that is neither a known flag nor the value of one, or null when argv is clean. */
579
+ function unknownFlag(argv) {
580
+ for (let i = 0; i < argv.length; i++) {
581
+ if (VALUE_FLAGS.has(argv[i])) {
582
+ i++; // its value, whatever it is
583
+ continue;
584
+ }
585
+ if (!BOOL_FLAGS.has(argv[i])) return argv[i];
586
+ }
587
+ return null;
588
+ }
589
+
590
+ /** Why discovery found nothing, when pi's settings file is the reason. */
591
+ function hostSettingsNote(hostPi) {
592
+ if (hostPi.settingsState === "absent") return `none at ${hostPi.settingsPath} -- nothing discovered from your pi setup`;
593
+ if (hostPi.settingsState === "packages-not-an-array") return `"packages" is not an array in ${hostPi.settingsPath} -- nothing discovered`;
594
+ return `UNREADABLE at ${hostPi.settingsPath} -- host packages NOT discovered, extension state NOT applied`;
595
+ }
596
+
597
+ function nextSteps(to, withExtensions, withPackages, withHostPackages) {
441
598
  const steps = [`Set PI_GLOBAL_PI_DIR=${to} in .env`, "pi-dispatch doctor # verifies the overlay is credential-free"];
442
599
  // The vetting step is no longer a switch to flip -- it already happened by staging. What is left is the
443
600
  // off switch, named here so an operator who does not want the extensions is not left hunting for it.
@@ -445,6 +602,9 @@ function nextSteps(to, withExtensions, withPackages) {
445
602
  // Same inversion for packages: staging is what loads them, so the step worth naming is how to withhold
446
603
  // them from a trigger that should not run third-party code.
447
604
  if (withPackages) steps.push('Staged packages load in every job -- set `run.packages: false` on any trigger in triggers.json that must not load them');
605
+ // Named here rather than only in the docs: this is the run whose meaning changed, so the operator who
606
+ // wanted the old one should not have to go looking for how to get it back.
607
+ if (withHostPackages) steps.push("Packages from your pi setup were staged too -- re-run with --no-host-packages to stage only what pi-packages.json declares");
448
608
  return `
449
609
  Next:
450
610
  ${steps.map((step, i) => ` ${i + 1}. ${step}\n`).join("")}`;