@edgehero/pi-dispatch 0.1.2 → 0.3.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/package.json +3 -1
- package/src/azure-prompt.mjs +14 -8
- package/src/cli.mjs +2 -2
- package/src/config.mjs +19 -2
- package/src/copy-tree.mjs +215 -0
- package/src/doctor.mjs +242 -8
- package/src/flow-gate.mjs +3 -1
- package/src/forgejo-prompt.mjs +14 -8
- package/src/github-app-setup.mjs +11 -20
- package/src/github-prompt.mjs +97 -17
- package/src/gitlab-prompt.mjs +14 -8
- package/src/host-pi.mjs +478 -0
- package/src/import-pi.mjs +299 -139
- package/src/materialize.mjs +262 -40
- package/src/open-browser.mjs +24 -0
- package/src/outbox.mjs +5 -0
- package/src/packages.mjs +95 -2
- package/src/prepare-github.mjs +23 -4
- package/src/prepare-local.mjs +6 -1
- package/src/prepare.mjs +73 -14
- package/src/processor.mjs +34 -4
- package/src/queue.mjs +16 -2
- package/src/run-container.mjs +7 -2
- package/src/run-history.mjs +13 -0
- package/src/schedules.mjs +16 -1
- package/src/session-store.mjs +6 -0
- package/src/start.mjs +49 -9
- package/src/triggers.mjs +177 -8
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
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
-
*
|
|
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
|
-
/**
|
|
27
|
-
|
|
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
|
-
/**
|
|
32
|
-
|
|
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
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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 }
|
|
256
|
-
*
|
|
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
|
-
|
|
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
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
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
|
|
405
|
+
source = await prepareOne({ fs, exec, out, entry, staging, npmBin, platform, warnings });
|
|
325
406
|
} catch (error) {
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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("")}`;
|