toolfunnel 0.5.0 → 0.6.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.
Files changed (69) hide show
  1. package/README.md +231 -167
  2. package/bin/toolfunnel.js +372 -147
  3. package/docs/KNOWN_BUGS.md +99 -33
  4. package/docs/MANUAL.pdf +0 -0
  5. package/docs/design.md +70 -0
  6. package/docs/packaging.md +115 -115
  7. package/hooks/hooks.manifest.json +27 -27
  8. package/hooks/scripts/example-audit.js +21 -21
  9. package/hooks/scripts/example-deny-dangerous.js +31 -31
  10. package/mcp/expose.example.json +30 -30
  11. package/mcp/servers/mock-modern/server.js +147 -0
  12. package/mcp/servers/mock-upstream/server.js +192 -12
  13. package/package.json +1 -1
  14. package/src/auth/config.js +174 -174
  15. package/src/auth/install.js +181 -181
  16. package/src/auth/resource-server.js +344 -344
  17. package/src/core/config-home.js +134 -134
  18. package/src/core/events.js +105 -105
  19. package/src/core/hook-engine.js +275 -275
  20. package/src/core/hook-loader.js +791 -791
  21. package/src/core/hook-runner.js +346 -346
  22. package/src/core/logger.js +171 -171
  23. package/src/core/matcher.js +51 -51
  24. package/src/core/metrics.js +75 -75
  25. package/src/core/requires.js +138 -138
  26. package/src/core/server-config.js +98 -77
  27. package/src/core/tool-registry.js +180 -180
  28. package/src/extend/add-hook.md +152 -142
  29. package/src/extend/add-mcp.md +122 -121
  30. package/src/extend/configure.md +87 -0
  31. package/src/extend/create-tool.md +168 -152
  32. package/src/extend/howto.js +102 -98
  33. package/src/extend/package.md +187 -187
  34. package/src/extend/wrap.md +99 -0
  35. package/src/mcp/aggregator.js +1246 -807
  36. package/src/mcp/expose-store.js +460 -430
  37. package/src/mcp/gated-run.js +201 -194
  38. package/src/mcp/http-transport.js +1311 -1012
  39. package/src/mcp/mcp-client.js +907 -530
  40. package/src/mcp/modern.js +372 -0
  41. package/src/mcp/protocol.js +468 -466
  42. package/src/mcp/server.js +2688 -1405
  43. package/src/packages/packager.js +715 -715
  44. package/src/tools/drift.js +138 -138
  45. package/src/tools/registry.js +513 -513
  46. package/src/tools/tool-state.js +170 -104
  47. package/src/ui/public/app.js +1174 -1055
  48. package/src/ui/public/index.html +286 -249
  49. package/src/ui/public/styles.css +535 -535
  50. package/src/ui/server.js +1348 -1190
  51. package/tools/scripts/base64.js +139 -139
  52. package/tools/scripts/danger.js +61 -61
  53. package/tools/scripts/echo.js +39 -39
  54. package/tools/scripts/hash.js +101 -101
  55. package/tools/scripts/json.js +109 -109
  56. package/tools/scripts/text-stats.js +131 -131
  57. package/tools/scripts/tf-env.js +43 -43
  58. package/tools/scripts/tf-hook-add.js +151 -151
  59. package/tools/scripts/tf-hook-set.js +112 -112
  60. package/tools/scripts/tf-list.js +192 -192
  61. package/tools/scripts/tf-log.js +86 -86
  62. package/tools/scripts/tf-mcp-add.js +117 -117
  63. package/tools/scripts/tf-mcp-set.js +121 -102
  64. package/tools/scripts/tf-pack.js +237 -237
  65. package/tools/scripts/tf-tool-add.js +134 -129
  66. package/tools/scripts/tf-tool-set.js +142 -142
  67. package/tools/scripts/tf-wrap.js +103 -0
  68. package/tools/scripts/uuid.js +102 -102
  69. package/tools/tools.register.json +227 -216
@@ -1,715 +1,715 @@
1
- 'use strict';
2
-
3
- /**
4
- * packager.js — export/import shareable {tool|hook|mcp} packages
5
- * (the authoritative package format is src/extend/package.md §2/§3/§4).
6
- *
7
- * Packaging is a BYPRODUCT of the gateway design: every unit is already structured and
8
- * self-documented, so a package is just a folder under `packages/` carrying a
9
- * `package.json` manifest (with each unit's FULL metadata embedded so import needs
10
- * no source register/manifest) plus a mirrored tree of the units' script files.
11
- *
12
- * - tool unit = a tools.register.json `entry` + its `tools/scripts/<file>`
13
- * - hook unit = a hooks.manifest.json `entry` + its enabled flag + `hooks/scripts/<file>`
14
- * - mcp unit = an expose.json `upstream` block + its `expose[]` selections (+ vendored files)
15
- *
16
- * This module ONLY touches files under the gateway (the packages/ tree for export, and the
17
- * injected destination roots for import). Every destination path is guarded INSIDE the
18
- * resolved root before any byte is written, so neither a crafted `path` in a register
19
- * entry nor a `files` list in a package manifest can escape the sandbox (defence-in-depth
20
- * for the HARD ISOLATION RULE).
21
- *
22
- * Safety contract (mirrors src/tools/registry.js and src/mcp/expose-store.js):
23
- * - loadPackageList NEVER throws — a malformed package is skipped with a `note`.
24
- * - exportPackage / importPackage VALIDATE fully BEFORE writing, so a mid-operation
25
- * failure cannot leave a half-written register/manifest/expose store. Validation
26
- * errors throw a clear message; nothing is persisted on a validation failure.
27
- * - Atomic writes use the EXACT temp-in-same-dir + fsync + rename pattern copied from
28
- * src/tools/registry.js::atomicWriteJson (a crash leaves the old OR the new file).
29
- *
30
- * CommonJS only. Node built-ins only. No new npm dependency.
31
- */
32
-
33
- const fs = require('node:fs');
34
- const path = require('node:path');
35
-
36
- // ROOT = the CONFIG HOME (TOOLFUNNEL_HOME / --config-dir; defaults to the package root — see
37
- // src/core/config-home.js). Packages are config-tree citizens: they export FROM and import INTO
38
- // the home's register/manifest/expose stores, and the packages/ tree itself lives in the home.
39
- const { resolveConfigHome } = require('../core/config-home');
40
- const ROOT = resolveConfigHome(); // <…> config home
41
-
42
- const PACKAGE_MANIFEST = 'package.json';
43
-
44
- // ──────────────────────────────────────────────────────────────────────────
45
- // Atomic write (copied verbatim from src/tools/registry.js::atomicWriteJson so
46
- // the three stores share one proven persistence pattern).
47
- // ──────────────────────────────────────────────────────────────────────────
48
-
49
- /**
50
- * Atomic write: serialise to a temp file in the SAME directory as the target (so the
51
- * rename is atomic on the same filesystem/drive), fsync, then rename over the target.
52
- * A crash leaves either the old file or the new file, never a half-written file.
53
- *
54
- * @param {string} targetPath absolute destination
55
- * @param {object} obj JSON-serialisable value
56
- */
57
- function atomicWriteJson(targetPath, obj) {
58
- const dir = path.dirname(targetPath);
59
- const base = path.basename(targetPath);
60
- const tmp = path.join(dir, `.${base}.${process.pid}.${Date.now()}.tmp`);
61
- const data = JSON.stringify(obj, null, 2) + '\n';
62
- const fd = fs.openSync(tmp, 'w');
63
- try {
64
- fs.writeSync(fd, data, 0, 'utf8');
65
- fs.fsyncSync(fd);
66
- } finally {
67
- fs.closeSync(fd);
68
- }
69
- fs.renameSync(tmp, targetPath);
70
- }
71
-
72
- // ──────────────────────────────────────────────────────────────────────────
73
- // Path guards
74
- // ──────────────────────────────────────────────────────────────────────────
75
-
76
- /** True for a non-empty string. */
77
- function isNonEmptyString(v) {
78
- return typeof v === 'string' && v.length > 0;
79
- }
80
-
81
- /**
82
- * Assert that `target` resolves strictly inside `root` (root itself is allowed as the
83
- * boundary). Throws on any traversal escape. Compared with a trailing separator so
84
- * "<root>extra" cannot pass as "<root>".
85
- *
86
- * @param {string} target absolute candidate path
87
- * @param {string} root absolute boundary directory
88
- * @param {string} [label] context for the error message
89
- */
90
- function assertInside(target, root, label) {
91
- const resolvedTarget = path.resolve(target);
92
- const resolvedRoot = path.resolve(root);
93
- const rootWithSep = resolvedRoot.endsWith(path.sep) ? resolvedRoot : resolvedRoot + path.sep;
94
- const inside = resolvedTarget === resolvedRoot || resolvedTarget.startsWith(rootWithSep);
95
- if (!inside) {
96
- throw new Error(
97
- `packager: path-traversal blocked — "${resolvedTarget}" is outside ${label || 'the allowed root'} "${resolvedRoot}"`
98
- );
99
- }
100
- return resolvedTarget;
101
- }
102
-
103
- /**
104
- * The injectable roots may live under os.tmpdir() during testing rather than under the
105
- * real ROOT. The HARD ISOLATION guard therefore confines writes to BOTH: a destination
106
- * must sit inside the caller-supplied destination root AND that destination root must be a
107
- * directory the caller chose (we never derive a write path from untrusted package content
108
- * alone — we always re-anchor it under the supplied root via path.basename for files).
109
- */
110
-
111
- /** Ensure a directory exists (recursive). Never throws on an already-existing dir. */
112
- function ensureDir(dir) {
113
- fs.mkdirSync(dir, { recursive: true });
114
- }
115
-
116
- /** Copy a single file, creating the destination directory if needed. */
117
- function copyFileInto(srcPath, destPath) {
118
- ensureDir(path.dirname(destPath));
119
- fs.copyFileSync(srcPath, destPath);
120
- }
121
-
122
- /**
123
- * The basename of a register/manifest script reference (e.g. "scripts/echo.js" → "echo.js").
124
- * Returns null for an empty/inline reference. Tolerates either path separator.
125
- * @param {*} ref
126
- * @returns {string|null}
127
- */
128
- function scriptBasename(ref) {
129
- if (!isNonEmptyString(ref)) return null;
130
- const norm = ref.split('\\').join('/');
131
- const base = norm.slice(norm.lastIndexOf('/') + 1);
132
- return base.length ? base : null;
133
- }
134
-
135
- // ──────────────────────────────────────────────────────────────────────────
136
- // loadPackageList — scan packagesDir/*/package.json (never throws)
137
- // ──────────────────────────────────────────────────────────────────────────
138
-
139
- /**
140
- * Scan `packagesDir` for installed packages and return a brief summary of each.
141
- *
142
- * For every immediate subdirectory containing a readable, well-formed `package.json`,
143
- * returns a row. A subdirectory whose manifest is missing/unreadable/malformed is
144
- * SKIPPED with a `note` (never throws — the safety contract). `counts` reports how
145
- * many tools/hooks/mcp units the package declares.
146
- *
147
- * @param {string} packagesDir absolute path to the packages/ directory
148
- * @returns {Array<{name,version,description,author,path,counts:{tools,hooks,mcp},note?}>}
149
- */
150
- function loadPackageList(packagesDir) {
151
- const out = [];
152
- if (!isNonEmptyString(packagesDir)) return out;
153
-
154
- let entries;
155
- try {
156
- entries = fs.readdirSync(packagesDir, { withFileTypes: true });
157
- } catch (_) {
158
- return out; // missing/unreadable packages dir → empty list
159
- }
160
-
161
- for (const ent of entries) {
162
- if (!ent.isDirectory || !ent.isDirectory()) continue;
163
- const pkgDir = path.join(packagesDir, ent.name);
164
- const manifestPath = path.join(pkgDir, PACKAGE_MANIFEST);
165
-
166
- let manifest;
167
- try {
168
- manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
169
- } catch (err) {
170
- out.push({
171
- name: ent.name,
172
- version: '',
173
- description: '',
174
- author: '',
175
- path: pkgDir,
176
- counts: { tools: 0, hooks: 0, mcp: 0 },
177
- note: `skipped: cannot read/parse package.json (${err.message})`,
178
- });
179
- continue;
180
- }
181
-
182
- if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) {
183
- out.push({
184
- name: ent.name,
185
- version: '',
186
- description: '',
187
- author: '',
188
- path: pkgDir,
189
- counts: { tools: 0, hooks: 0, mcp: 0 },
190
- note: 'skipped: package.json is not an object',
191
- });
192
- continue;
193
- }
194
-
195
- const units = manifest.units && typeof manifest.units === 'object' ? manifest.units : {};
196
- const counts = {
197
- tools: Array.isArray(units.tools) ? units.tools.length : 0,
198
- hooks: Array.isArray(units.hooks) ? units.hooks.length : 0,
199
- mcp: Array.isArray(units.mcp) ? units.mcp.length : 0,
200
- };
201
-
202
- out.push({
203
- name: isNonEmptyString(manifest.name) ? manifest.name : ent.name,
204
- version: isNonEmptyString(manifest.version) ? manifest.version : '',
205
- description: isNonEmptyString(manifest.description) ? manifest.description : '',
206
- author: isNonEmptyString(manifest.author) ? manifest.author : '',
207
- path: pkgDir,
208
- counts,
209
- });
210
- }
211
-
212
- return out;
213
- }
214
-
215
- // ──────────────────────────────────────────────────────────────────────────
216
- // exportPackage — bundle units into packages/<name>/
217
- // ──────────────────────────────────────────────────────────────────────────
218
-
219
- /**
220
- * Build a package folder under `packagesDir/<name>/` (package.md §3).
221
- *
222
- * For each requested unit, the FULL metadata is embedded into the manifest and the
223
- * referenced script file is copied into the mirrored tree (tools/scripts/, hooks/scripts/).
224
- * Hooks read their seed `enabled` from the loader's hooks.state.json overlay if present,
225
- * else from the manifest's own enabled flag. MCP units embed the upstream block plus its
226
- * ENABLED expose[] selections.
227
- *
228
- * Validation is performed FULLY before any write: every requested id must exist in its
229
- * source and every referenced script must exist on disk. On a validation failure the
230
- * function throws and writes nothing.
231
- *
232
- * @param {object} args
233
- * @param {string} args.packagesDir absolute packages/ dir (the package lands at <packagesDir>/<name>)
234
- * @param {string} args.name package name (folder name + import key)
235
- * @param {string} [args.version='1.0.0']
236
- * @param {string} [args.description='']
237
- * @param {string} [args.author='']
238
- * @param {{tools?:string[],hooks?:string[],mcp?:string[]}} args.units ids to include
239
- * - tools: register entry ids
240
- * - hooks: manifest entry ids
241
- * - mcp: upstream ids
242
- * @param {object} args.sources
243
- * @param {object} args.sources.registry a Registry instance (has/getEntry)
244
- * @param {string} [args.sources.manifestPath] absolute path to hooks.manifest.json (for hook units)
245
- * @param {object} [args.sources.exposeStore] an ExposeStore instance (for mcp units)
246
- * @param {string} [args.sources.toolScriptsRoot] absolute dir holding tool scripts to copy
247
- * @param {string} [args.sources.hookScriptsRoot] absolute dir holding hook scripts to copy
248
- * @returns {{ dir:string, manifestPath:string, manifest:object }}
249
- */
250
- function exportPackage(args) {
251
- const opts = args && typeof args === 'object' ? args : {};
252
- const {
253
- packagesDir,
254
- name,
255
- version = '1.0.0',
256
- description = '',
257
- author = '',
258
- units,
259
- sources,
260
- } = opts;
261
-
262
- if (!isNonEmptyString(packagesDir)) throw new Error('exportPackage: packagesDir is required');
263
- if (!isNonEmptyString(name)) throw new Error('exportPackage: name is required');
264
- if (/[\\/]|\.\./.test(name)) {
265
- throw new Error(`exportPackage: name "${name}" must be a bare folder name (no separators or "..")`);
266
- }
267
- const u = units && typeof units === 'object' ? units : {};
268
- const toolIds = Array.isArray(u.tools) ? u.tools : [];
269
- const hookIds = Array.isArray(u.hooks) ? u.hooks : [];
270
- const mcpIds = Array.isArray(u.mcp) ? u.mcp : [];
271
-
272
- const src = sources && typeof sources === 'object' ? sources : {};
273
- const { registry, manifestPath, exposeStore, toolScriptsRoot, hookScriptsRoot } = src;
274
-
275
- // ── PHASE 1: gather + validate everything (no writes yet) ────────────────
276
-
277
- // Tools
278
- const toolUnits = [];
279
- for (const id of toolIds) {
280
- if (!registry || typeof registry.has !== 'function' || !registry.has(id)) {
281
- throw new Error(`exportPackage: tool id "${id}" not found in the registry`);
282
- }
283
- const entry = registry.getEntry(id); // a clone
284
- let files = [];
285
- if (entry.invoke && entry.invoke.type === 'script') {
286
- const base = scriptBasename(entry.invoke.path);
287
- if (!base) throw new Error(`exportPackage: tool "${id}" has a script invoke with no path`);
288
- if (!isNonEmptyString(toolScriptsRoot)) {
289
- throw new Error(`exportPackage: tool "${id}" is a script tool but sources.toolScriptsRoot was not provided`);
290
- }
291
- const srcFile = path.resolve(toolScriptsRoot, base);
292
- assertInside(srcFile, toolScriptsRoot, 'the tool scripts root');
293
- if (!fs.existsSync(srcFile)) {
294
- throw new Error(`exportPackage: tool "${id}" script not found on disk ("${srcFile}")`);
295
- }
296
- // Normalise the embedded entry's invoke.path to the package-relative scripts/<base>.
297
- entry.invoke = Object.assign({}, entry.invoke, { path: `scripts/${base}` });
298
- files = [`tools/scripts/${base}`];
299
- toolUnits.push({ entry, files, _srcFile: srcFile, _destRel: `tools/scripts/${base}` });
300
- } else {
301
- // shell (or other) invoke — no file travels with it
302
- toolUnits.push({ entry, files });
303
- }
304
- }
305
-
306
- // Hooks
307
- const hookUnits = [];
308
- if (hookIds.length) {
309
- if (!isNonEmptyString(manifestPath)) {
310
- throw new Error('exportPackage: hook units requested but sources.manifestPath was not provided');
311
- }
312
- const { manifest, state } = readHookSources(manifestPath);
313
- const byId = new Map();
314
- for (const h of manifest.hooks || []) {
315
- if (h && typeof h.id === 'string') byId.set(h.id, h);
316
- }
317
- for (const id of hookIds) {
318
- const entry = byId.get(id);
319
- if (!entry) throw new Error(`exportPackage: hook id "${id}" not found in the manifest`);
320
- // enabled: overlay (hooks.state.json) WINS, else the manifest's own flag.
321
- const enabled = Object.prototype.hasOwnProperty.call(state, id)
322
- ? state[id] === true
323
- : entry.enabled === true;
324
- // Embed a CLEAN copy of the manifest entry (drop the live enabled — it travels as `enabled`).
325
- const cleanEntry = Object.assign({}, entry);
326
- delete cleanEntry.enabled;
327
- let files = [];
328
- let srcFile = null;
329
- let destRel = null;
330
- const base = scriptBasename(entry.script);
331
- if (base) {
332
- if (!isNonEmptyString(hookScriptsRoot)) {
333
- throw new Error(`exportPackage: hook "${id}" references a script but sources.hookScriptsRoot was not provided`);
334
- }
335
- srcFile = path.resolve(hookScriptsRoot, base);
336
- assertInside(srcFile, hookScriptsRoot, 'the hook scripts root');
337
- if (!fs.existsSync(srcFile)) {
338
- throw new Error(`exportPackage: hook "${id}" script not found on disk ("${srcFile}")`);
339
- }
340
- cleanEntry.script = `scripts/${base}`;
341
- files = [`hooks/scripts/${base}`];
342
- destRel = `hooks/scripts/${base}`;
343
- }
344
- hookUnits.push({ entry: cleanEntry, enabled, files, _srcFile: srcFile, _destRel: destRel });
345
- }
346
- }
347
-
348
- // MCP
349
- const mcpUnits = [];
350
- if (mcpIds.length) {
351
- if (!exposeStore || typeof exposeStore.getUpstream !== 'function') {
352
- throw new Error('exportPackage: mcp units requested but sources.exposeStore was not provided');
353
- }
354
- for (const id of mcpIds) {
355
- const upstream = exposeStore.getUpstream(id);
356
- if (!upstream) throw new Error(`exportPackage: mcp upstream id "${id}" not found in the expose store`);
357
- // Embed the upstream block + only its ENABLED expose[] selections.
358
- const expose = exposeStore.listExposed({ upstream: id, enabledOnly: true });
359
- mcpUnits.push({ upstream, expose, files: [] });
360
- }
361
- }
362
-
363
- // ── PHASE 2: write the package (validation passed) ───────────────────────
364
-
365
- const pkgDir = path.resolve(packagesDir, name);
366
- assertInside(pkgDir, packagesDir, 'the packages dir');
367
- ensureDir(pkgDir);
368
-
369
- // Copy script files into the mirrored tree.
370
- for (const tu of toolUnits) {
371
- if (tu._srcFile && tu._destRel) {
372
- const dest = path.resolve(pkgDir, tu._destRel);
373
- assertInside(dest, pkgDir, 'the package dir');
374
- copyFileInto(tu._srcFile, dest);
375
- }
376
- }
377
- for (const hu of hookUnits) {
378
- if (hu._srcFile && hu._destRel) {
379
- const dest = path.resolve(pkgDir, hu._destRel);
380
- assertInside(dest, pkgDir, 'the package dir');
381
- copyFileInto(hu._srcFile, dest);
382
- }
383
- }
384
-
385
- // Build the manifest (strip internal bookkeeping fields).
386
- const manifest = {
387
- name,
388
- version: isNonEmptyString(version) ? version : '1.0.0',
389
- description: typeof description === 'string' ? description : '',
390
- author: isNonEmptyString(author) ? author : '',
391
- units: {
392
- tools: toolUnits.map((t) => ({ entry: t.entry, files: t.files })),
393
- hooks: hookUnits.map((h) => ({ entry: h.entry, enabled: h.enabled, files: h.files })),
394
- mcp: mcpUnits.map((m) => ({ upstream: m.upstream, expose: m.expose, files: m.files })),
395
- },
396
- };
397
-
398
- const manifestOut = path.join(pkgDir, PACKAGE_MANIFEST);
399
- atomicWriteJson(manifestOut, manifest);
400
-
401
- return { dir: pkgDir, manifestPath: manifestOut, manifest };
402
- }
403
-
404
- // ──────────────────────────────────────────────────────────────────────────
405
- // importPackage — install a package's units into the gateway stores
406
- // ──────────────────────────────────────────────────────────────────────────
407
-
408
- /**
409
- * Install a package's units (package.md §4).
410
- *
411
- * Tools → copied into `toolScriptsRoot` + `registry.add(entry)`.
412
- * Hooks → copied into `hookScriptsRoot` + appended to `hooks.manifest.json` (atomic) and
413
- * their enabled flag written into `hooks.state.json` (atomic).
414
- * MCP → `exposeStore.addUpstream(upstream)` + `exposeStore.addExpose(selection)` per selection.
415
- *
416
- * The whole package.json is VALIDATED first; nothing is written until validation passes,
417
- * so a mid-import error never leaves a partially-corrupt store. Every destination path is
418
- * guarded inside its supplied root (a crafted `files`/`script`/`path` cannot escape).
419
- *
420
- * Collision policy (`onCollision`):
421
- * - 'skip' (default): a colliding id is recorded in `skipped[]` and NOT installed; no throw.
422
- * - 'error': a colliding id throws (before any write).
423
- *
424
- * @param {object} args
425
- * @param {string} args.packageDir absolute path to the package folder (contains package.json)
426
- * @param {object} args.registry a Registry instance (has/add)
427
- * @param {string} [args.manifestPath] absolute hooks.manifest.json path (required iff hooks present)
428
- * @param {object} [args.exposeStore] an ExposeStore instance (required iff mcp present)
429
- * @param {string} [args.toolScriptsRoot] absolute dir to copy tool scripts into
430
- * @param {string} [args.hookScriptsRoot] absolute dir to copy hook scripts into
431
- * @param {'skip'|'error'} [args.onCollision='skip']
432
- * @returns {{ tools:string[], hooks:string[], mcp:string[], skipped:Array<{kind,id,reason}> }}
433
- */
434
- function importPackage(args) {
435
- const opts = args && typeof args === 'object' ? args : {};
436
- const {
437
- packageDir,
438
- registry,
439
- manifestPath,
440
- exposeStore,
441
- toolScriptsRoot,
442
- hookScriptsRoot,
443
- onCollision = 'skip',
444
- } = opts;
445
-
446
- if (!isNonEmptyString(packageDir)) throw new Error('importPackage: packageDir is required');
447
- if (onCollision !== 'skip' && onCollision !== 'error') {
448
- throw new Error(`importPackage: onCollision must be 'skip' or 'error' (got "${onCollision}")`);
449
- }
450
-
451
- // ── Read + validate the manifest FULLY before any write ──────────────────
452
- const manifestFile = path.join(packageDir, PACKAGE_MANIFEST);
453
- let pkg;
454
- try {
455
- pkg = JSON.parse(fs.readFileSync(manifestFile, 'utf8'));
456
- } catch (err) {
457
- throw new Error(`importPackage: cannot read package.json at "${manifestFile}": ${err.message}`);
458
- }
459
- if (!pkg || typeof pkg !== 'object' || Array.isArray(pkg)) {
460
- throw new Error('importPackage: package.json is not an object');
461
- }
462
- const units = pkg.units && typeof pkg.units === 'object' ? pkg.units : {};
463
- const toolUnits = Array.isArray(units.tools) ? units.tools : [];
464
- const hookUnits = Array.isArray(units.hooks) ? units.hooks : [];
465
- const mcpUnits = Array.isArray(units.mcp) ? units.mcp : [];
466
-
467
- if (hookUnits.length && !isNonEmptyString(manifestPath)) {
468
- throw new Error('importPackage: package has hook units but manifestPath was not provided');
469
- }
470
- if (mcpUnits.length && (!exposeStore || typeof exposeStore.addUpstream !== 'function')) {
471
- throw new Error('importPackage: package has mcp units but exposeStore was not provided');
472
- }
473
-
474
- // A "plan" of validated, ready-to-apply actions. Building it fully first means a
475
- // validation error throws before we mutate any store.
476
- const skipped = [];
477
-
478
- // --- Tools plan ---
479
- const toolPlan = [];
480
- for (const unit of toolUnits) {
481
- const entry = unit && typeof unit === 'object' ? unit.entry : null;
482
- if (!entry || typeof entry !== 'object' || !isNonEmptyString(entry.id)) {
483
- throw new Error('importPackage: a tool unit is missing a valid entry.id');
484
- }
485
- const id = entry.id;
486
- const collides = registry && typeof registry.has === 'function' && registry.has(id);
487
- if (collides) {
488
- if (onCollision === 'error') {
489
- throw new Error(`importPackage: tool id "${id}" already exists in the registry`);
490
- }
491
- skipped.push({ kind: 'tool', id, reason: 'id-collision' });
492
- continue;
493
- }
494
- // Resolve any script file to copy.
495
- let srcFile = null;
496
- let destFile = null;
497
- if (entry.invoke && entry.invoke.type === 'script') {
498
- const base = scriptBasename(entry.invoke.path);
499
- if (!base) throw new Error(`importPackage: tool "${id}" has a script invoke with no path`);
500
- if (!isNonEmptyString(toolScriptsRoot)) {
501
- throw new Error(`importPackage: tool "${id}" needs a script copied but toolScriptsRoot was not provided`);
502
- }
503
- const fileRel = pickFileForBase(unit.files, base);
504
- srcFile = path.resolve(packageDir, fileRel);
505
- assertInside(srcFile, packageDir, 'the package dir');
506
- if (!fs.existsSync(srcFile)) {
507
- throw new Error(`importPackage: tool "${id}" script not found in package ("${srcFile}")`);
508
- }
509
- destFile = path.resolve(toolScriptsRoot, base);
510
- assertInside(destFile, toolScriptsRoot, 'the tool scripts root');
511
- }
512
- toolPlan.push({ id, entry, srcFile, destFile });
513
- }
514
-
515
- // --- Hooks plan ---
516
- const hookPlan = [];
517
- let manifestObj = null;
518
- let manifestIds = null;
519
- if (hookUnits.length) {
520
- manifestObj = readManifestRaw(manifestPath);
521
- manifestIds = new Set((manifestObj.hooks || []).map((h) => h && h.id).filter(Boolean));
522
- }
523
- for (const unit of hookUnits) {
524
- const entry = unit && typeof unit === 'object' ? unit.entry : null;
525
- if (!entry || typeof entry !== 'object' || !isNonEmptyString(entry.id)) {
526
- throw new Error('importPackage: a hook unit is missing a valid entry.id');
527
- }
528
- const id = entry.id;
529
- if (manifestIds.has(id)) {
530
- if (onCollision === 'error') {
531
- throw new Error(`importPackage: hook id "${id}" already exists in the manifest`);
532
- }
533
- skipped.push({ kind: 'hook', id, reason: 'id-collision' });
534
- continue;
535
- }
536
- let srcFile = null;
537
- let destFile = null;
538
- const base = scriptBasename(entry.script);
539
- if (base) {
540
- if (!isNonEmptyString(hookScriptsRoot)) {
541
- throw new Error(`importPackage: hook "${id}" needs a script copied but hookScriptsRoot was not provided`);
542
- }
543
- const fileRel = pickFileForBase(unit.files, base);
544
- srcFile = path.resolve(packageDir, fileRel);
545
- assertInside(srcFile, packageDir, 'the package dir');
546
- if (!fs.existsSync(srcFile)) {
547
- throw new Error(`importPackage: hook "${id}" script not found in package ("${srcFile}")`);
548
- }
549
- destFile = path.resolve(hookScriptsRoot, base);
550
- assertInside(destFile, hookScriptsRoot, 'the hook scripts root');
551
- }
552
- const enabled = unit.enabled === true;
553
- hookPlan.push({ id, entry, enabled, srcFile, destFile });
554
- }
555
-
556
- // --- MCP plan ---
557
- const mcpPlan = [];
558
- for (const unit of mcpUnits) {
559
- const upstream = unit && typeof unit === 'object' ? unit.upstream : null;
560
- if (!upstream || typeof upstream !== 'object' || !isNonEmptyString(upstream.id)) {
561
- throw new Error('importPackage: an mcp unit is missing a valid upstream.id');
562
- }
563
- const id = upstream.id;
564
- const exposeList = Array.isArray(unit.expose) ? unit.expose : [];
565
- const collides = !!exposeStore.getUpstream(id);
566
- if (collides) {
567
- if (onCollision === 'error') {
568
- throw new Error(`importPackage: mcp upstream id "${id}" already exists in the expose store`);
569
- }
570
- skipped.push({ kind: 'mcp', id, reason: 'id-collision' });
571
- continue;
572
- }
573
- mcpPlan.push({ id, upstream, expose: exposeList });
574
- }
575
-
576
- // ── APPLY (validation passed; collisions resolved) ───────────────────────
577
-
578
- const installed = { tools: [], hooks: [], mcp: [], skipped };
579
-
580
- // Tools: copy scripts then register.add.
581
- for (const t of toolPlan) {
582
- if (t.srcFile && t.destFile) copyFileInto(t.srcFile, t.destFile);
583
- registry.add(t.entry);
584
- installed.tools.push(t.id);
585
- }
586
-
587
- // Hooks: copy scripts, append manifest entries (one atomic write), write state overlay.
588
- if (hookPlan.length) {
589
- for (const h of hookPlan) {
590
- if (h.srcFile && h.destFile) copyFileInto(h.srcFile, h.destFile);
591
- }
592
- if (!Array.isArray(manifestObj.hooks)) manifestObj.hooks = [];
593
- for (const h of hookPlan) {
594
- // The manifest stores enabled as a faithful default; the state overlay is authoritative.
595
- const manifestEntry = Object.assign({}, h.entry, { enabled: h.enabled });
596
- manifestObj.hooks.push(manifestEntry);
597
- }
598
- atomicWriteJson(path.resolve(manifestPath), manifestObj);
599
-
600
- // State overlay (hooks.state.json) sits next to the manifest.
601
- const statePath = path.join(path.dirname(path.resolve(manifestPath)), 'hooks.state.json');
602
- const state = readStateRaw(statePath);
603
- for (const h of hookPlan) state[h.id] = h.enabled === true;
604
- atomicWriteJson(statePath, state);
605
-
606
- for (const h of hookPlan) installed.hooks.push(h.id);
607
- }
608
-
609
- // MCP: addUpstream then addExpose per enabled selection.
610
- for (const m of mcpPlan) {
611
- exposeStore.addUpstream(m.upstream);
612
- for (const sel of m.expose) {
613
- if (!sel || typeof sel !== 'object') continue;
614
- exposeStore.addExpose(sel);
615
- }
616
- installed.mcp.push(m.id);
617
- }
618
-
619
- return installed;
620
- }
621
-
622
- // ──────────────────────────────────────────────────────────────────────────
623
- // internals
624
- // ──────────────────────────────────────────────────────────────────────────
625
-
626
- /**
627
- * Read a hooks manifest + its state overlay for export. Both are read leniently:
628
- * a missing/unreadable state overlay yields an empty overlay (the manifest's own
629
- * enabled flags then stand). Throws only if the manifest itself is unreadable.
630
- *
631
- * @param {string} manifestPath
632
- * @returns {{ manifest: object, state: Object<string,boolean> }}
633
- */
634
- function readHookSources(manifestPath) {
635
- const manifest = readManifestRaw(manifestPath);
636
- const statePath = path.join(path.dirname(path.resolve(manifestPath)), 'hooks.state.json');
637
- const state = readStateRaw(statePath);
638
- return { manifest, state };
639
- }
640
-
641
- /** Read + parse a hooks.manifest.json (throws on unreadable/malformed). */
642
- function readManifestRaw(manifestPath) {
643
- let raw;
644
- try {
645
- raw = fs.readFileSync(path.resolve(manifestPath), 'utf8');
646
- } catch (err) {
647
- throw new Error(`packager: cannot read hooks manifest at "${manifestPath}": ${err.message}`);
648
- }
649
- let obj;
650
- try {
651
- obj = JSON.parse(raw);
652
- } catch (err) {
653
- throw new Error(`packager: hooks manifest at "${manifestPath}" is not valid JSON: ${err.message}`);
654
- }
655
- if (!obj || typeof obj !== 'object' || Array.isArray(obj)) {
656
- throw new Error(`packager: hooks manifest at "${manifestPath}" is not an object`);
657
- }
658
- if (!Array.isArray(obj.hooks)) obj.hooks = [];
659
- return obj;
660
- }
661
-
662
- /**
663
- * Read a hooks.state.json overlay leniently. Missing/unreadable/malformed → {} (never
664
- * throws). Coerces values to strict booleans, keyed by hook id.
665
- *
666
- * @param {string} statePath
667
- * @returns {Object<string, boolean>}
668
- */
669
- function readStateRaw(statePath) {
670
- let raw;
671
- try {
672
- raw = JSON.parse(fs.readFileSync(statePath, 'utf8'));
673
- } catch (_) {
674
- return {};
675
- }
676
- const out = {};
677
- if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out;
678
- for (const key of Object.keys(raw)) out[key] = raw[key] === true;
679
- return out;
680
- }
681
-
682
- /**
683
- * Choose the package-relative file path for a given script basename. Prefer an entry in
684
- * the unit's declared `files[]` whose basename matches; otherwise fall back to the mirrored
685
- * convention. The basename is always re-derived (path.basename) so a crafted `files` entry
686
- * cannot smuggle a traversal segment past the later assertInside guard.
687
- *
688
- * @param {*} files the unit's declared files array
689
- * @param {string} base the script basename (already sanitised)
690
- * @returns {string} a package-relative path (forward-slashed)
691
- */
692
- function pickFileForBase(files, base) {
693
- if (Array.isArray(files)) {
694
- for (const f of files) {
695
- if (isNonEmptyString(f) && scriptBasename(f) === base) {
696
- // Re-anchor on the basename only; never trust the directory portion blindly —
697
- // but keep the mirrored prefix for clarity. assertInside re-validates anyway.
698
- const norm = f.split('\\').join('/');
699
- return norm;
700
- }
701
- }
702
- }
703
- return `scripts/${base}`;
704
- }
705
-
706
- module.exports = {
707
- loadPackageList,
708
- exportPackage,
709
- importPackage,
710
- // exported for unit tests / reuse
711
- atomicWriteJson,
712
- assertInside,
713
- scriptBasename,
714
- ROOT,
715
- };
1
+ 'use strict';
2
+
3
+ /**
4
+ * packager.js - export/import shareable {tool|hook|mcp} packages
5
+ * (the authoritative package format is src/extend/package.md §2/§3/§4).
6
+ *
7
+ * Packaging is a BYPRODUCT of the gateway design: every unit is already structured and
8
+ * self-documented, so a package is just a folder under `packages/` carrying a
9
+ * `package.json` manifest (with each unit's FULL metadata embedded so import needs
10
+ * no source register/manifest) plus a mirrored tree of the units' script files.
11
+ *
12
+ * - tool unit = a tools.register.json `entry` + its `tools/scripts/<file>`
13
+ * - hook unit = a hooks.manifest.json `entry` + its enabled flag + `hooks/scripts/<file>`
14
+ * - mcp unit = an expose.json `upstream` block + its `expose[]` selections (+ vendored files)
15
+ *
16
+ * This module ONLY touches files under the gateway (the packages/ tree for export, and the
17
+ * injected destination roots for import). Every destination path is guarded INSIDE the
18
+ * resolved root before any byte is written, so neither a crafted `path` in a register
19
+ * entry nor a `files` list in a package manifest can escape the sandbox (defence-in-depth
20
+ * for the HARD ISOLATION RULE).
21
+ *
22
+ * Safety contract (mirrors src/tools/registry.js and src/mcp/expose-store.js):
23
+ * - loadPackageList NEVER throws - a malformed package is skipped with a `note`.
24
+ * - exportPackage / importPackage VALIDATE fully BEFORE writing, so a mid-operation
25
+ * failure cannot leave a half-written register/manifest/expose store. Validation
26
+ * errors throw a clear message; nothing is persisted on a validation failure.
27
+ * - Atomic writes use the EXACT temp-in-same-dir + fsync + rename pattern copied from
28
+ * src/tools/registry.js::atomicWriteJson (a crash leaves the old OR the new file).
29
+ *
30
+ * CommonJS only. Node built-ins only. No new npm dependency.
31
+ */
32
+
33
+ const fs = require('node:fs');
34
+ const path = require('node:path');
35
+
36
+ // ROOT = the CONFIG HOME (TOOLFUNNEL_HOME / --config-dir; defaults to the package root - see
37
+ // src/core/config-home.js). Packages are config-tree citizens: they export FROM and import INTO
38
+ // the home's register/manifest/expose stores, and the packages/ tree itself lives in the home.
39
+ const { resolveConfigHome } = require('../core/config-home');
40
+ const ROOT = resolveConfigHome(); // <...> config home
41
+
42
+ const PACKAGE_MANIFEST = 'package.json';
43
+
44
+ // ──────────────────────────────────────────────────────────────────────────
45
+ // Atomic write (copied verbatim from src/tools/registry.js::atomicWriteJson so
46
+ // the three stores share one proven persistence pattern).
47
+ // ──────────────────────────────────────────────────────────────────────────
48
+
49
+ /**
50
+ * Atomic write: serialise to a temp file in the SAME directory as the target (so the
51
+ * rename is atomic on the same filesystem/drive), fsync, then rename over the target.
52
+ * A crash leaves either the old file or the new file, never a half-written file.
53
+ *
54
+ * @param {string} targetPath absolute destination
55
+ * @param {object} obj JSON-serialisable value
56
+ */
57
+ function atomicWriteJson(targetPath, obj) {
58
+ const dir = path.dirname(targetPath);
59
+ const base = path.basename(targetPath);
60
+ const tmp = path.join(dir, `.${base}.${process.pid}.${Date.now()}.tmp`);
61
+ const data = JSON.stringify(obj, null, 2) + '\n';
62
+ const fd = fs.openSync(tmp, 'w');
63
+ try {
64
+ fs.writeSync(fd, data, 0, 'utf8');
65
+ fs.fsyncSync(fd);
66
+ } finally {
67
+ fs.closeSync(fd);
68
+ }
69
+ fs.renameSync(tmp, targetPath);
70
+ }
71
+
72
+ // ──────────────────────────────────────────────────────────────────────────
73
+ // Path guards
74
+ // ──────────────────────────────────────────────────────────────────────────
75
+
76
+ /** True for a non-empty string. */
77
+ function isNonEmptyString(v) {
78
+ return typeof v === 'string' && v.length > 0;
79
+ }
80
+
81
+ /**
82
+ * Assert that `target` resolves strictly inside `root` (root itself is allowed as the
83
+ * boundary). Throws on any traversal escape. Compared with a trailing separator so
84
+ * "<root>extra" cannot pass as "<root>".
85
+ *
86
+ * @param {string} target absolute candidate path
87
+ * @param {string} root absolute boundary directory
88
+ * @param {string} [label] context for the error message
89
+ */
90
+ function assertInside(target, root, label) {
91
+ const resolvedTarget = path.resolve(target);
92
+ const resolvedRoot = path.resolve(root);
93
+ const rootWithSep = resolvedRoot.endsWith(path.sep) ? resolvedRoot : resolvedRoot + path.sep;
94
+ const inside = resolvedTarget === resolvedRoot || resolvedTarget.startsWith(rootWithSep);
95
+ if (!inside) {
96
+ throw new Error(
97
+ `packager: path-traversal blocked - "${resolvedTarget}" is outside ${label || 'the allowed root'} "${resolvedRoot}"`
98
+ );
99
+ }
100
+ return resolvedTarget;
101
+ }
102
+
103
+ /**
104
+ * The injectable roots may live under os.tmpdir() during testing rather than under the
105
+ * real ROOT. The HARD ISOLATION guard therefore confines writes to BOTH: a destination
106
+ * must sit inside the caller-supplied destination root AND that destination root must be a
107
+ * directory the caller chose (we never derive a write path from untrusted package content
108
+ * alone - we always re-anchor it under the supplied root via path.basename for files).
109
+ */
110
+
111
+ /** Ensure a directory exists (recursive). Never throws on an already-existing dir. */
112
+ function ensureDir(dir) {
113
+ fs.mkdirSync(dir, { recursive: true });
114
+ }
115
+
116
+ /** Copy a single file, creating the destination directory if needed. */
117
+ function copyFileInto(srcPath, destPath) {
118
+ ensureDir(path.dirname(destPath));
119
+ fs.copyFileSync(srcPath, destPath);
120
+ }
121
+
122
+ /**
123
+ * The basename of a register/manifest script reference (e.g. "scripts/echo.js" -> "echo.js").
124
+ * Returns null for an empty/inline reference. Tolerates either path separator.
125
+ * @param {*} ref
126
+ * @returns {string|null}
127
+ */
128
+ function scriptBasename(ref) {
129
+ if (!isNonEmptyString(ref)) return null;
130
+ const norm = ref.split('\\').join('/');
131
+ const base = norm.slice(norm.lastIndexOf('/') + 1);
132
+ return base.length ? base : null;
133
+ }
134
+
135
+ // ──────────────────────────────────────────────────────────────────────────
136
+ // loadPackageList - scan packagesDir/*/package.json (never throws)
137
+ // ──────────────────────────────────────────────────────────────────────────
138
+
139
+ /**
140
+ * Scan `packagesDir` for installed packages and return a brief summary of each.
141
+ *
142
+ * For every immediate subdirectory containing a readable, well-formed `package.json`,
143
+ * returns a row. A subdirectory whose manifest is missing/unreadable/malformed is
144
+ * SKIPPED with a `note` (never throws - the safety contract). `counts` reports how
145
+ * many tools/hooks/mcp units the package declares.
146
+ *
147
+ * @param {string} packagesDir absolute path to the packages/ directory
148
+ * @returns {Array<{name,version,description,author,path,counts:{tools,hooks,mcp},note?}>}
149
+ */
150
+ function loadPackageList(packagesDir) {
151
+ const out = [];
152
+ if (!isNonEmptyString(packagesDir)) return out;
153
+
154
+ let entries;
155
+ try {
156
+ entries = fs.readdirSync(packagesDir, { withFileTypes: true });
157
+ } catch (_) {
158
+ return out; // missing/unreadable packages dir -> empty list
159
+ }
160
+
161
+ for (const ent of entries) {
162
+ if (!ent.isDirectory || !ent.isDirectory()) continue;
163
+ const pkgDir = path.join(packagesDir, ent.name);
164
+ const manifestPath = path.join(pkgDir, PACKAGE_MANIFEST);
165
+
166
+ let manifest;
167
+ try {
168
+ manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
169
+ } catch (err) {
170
+ out.push({
171
+ name: ent.name,
172
+ version: '',
173
+ description: '',
174
+ author: '',
175
+ path: pkgDir,
176
+ counts: { tools: 0, hooks: 0, mcp: 0 },
177
+ note: `skipped: cannot read/parse package.json (${err.message})`,
178
+ });
179
+ continue;
180
+ }
181
+
182
+ if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) {
183
+ out.push({
184
+ name: ent.name,
185
+ version: '',
186
+ description: '',
187
+ author: '',
188
+ path: pkgDir,
189
+ counts: { tools: 0, hooks: 0, mcp: 0 },
190
+ note: 'skipped: package.json is not an object',
191
+ });
192
+ continue;
193
+ }
194
+
195
+ const units = manifest.units && typeof manifest.units === 'object' ? manifest.units : {};
196
+ const counts = {
197
+ tools: Array.isArray(units.tools) ? units.tools.length : 0,
198
+ hooks: Array.isArray(units.hooks) ? units.hooks.length : 0,
199
+ mcp: Array.isArray(units.mcp) ? units.mcp.length : 0,
200
+ };
201
+
202
+ out.push({
203
+ name: isNonEmptyString(manifest.name) ? manifest.name : ent.name,
204
+ version: isNonEmptyString(manifest.version) ? manifest.version : '',
205
+ description: isNonEmptyString(manifest.description) ? manifest.description : '',
206
+ author: isNonEmptyString(manifest.author) ? manifest.author : '',
207
+ path: pkgDir,
208
+ counts,
209
+ });
210
+ }
211
+
212
+ return out;
213
+ }
214
+
215
+ // ──────────────────────────────────────────────────────────────────────────
216
+ // exportPackage - bundle units into packages/<name>/
217
+ // ──────────────────────────────────────────────────────────────────────────
218
+
219
+ /**
220
+ * Build a package folder under `packagesDir/<name>/` (package.md §3).
221
+ *
222
+ * For each requested unit, the FULL metadata is embedded into the manifest and the
223
+ * referenced script file is copied into the mirrored tree (tools/scripts/, hooks/scripts/).
224
+ * Hooks read their seed `enabled` from the loader's hooks.state.json overlay if present,
225
+ * else from the manifest's own enabled flag. MCP units embed the upstream block plus its
226
+ * ENABLED expose[] selections.
227
+ *
228
+ * Validation is performed FULLY before any write: every requested id must exist in its
229
+ * source and every referenced script must exist on disk. On a validation failure the
230
+ * function throws and writes nothing.
231
+ *
232
+ * @param {object} args
233
+ * @param {string} args.packagesDir absolute packages/ dir (the package lands at <packagesDir>/<name>)
234
+ * @param {string} args.name package name (folder name + import key)
235
+ * @param {string} [args.version='1.0.0']
236
+ * @param {string} [args.description='']
237
+ * @param {string} [args.author='']
238
+ * @param {{tools?:string[],hooks?:string[],mcp?:string[]}} args.units ids to include
239
+ * - tools: register entry ids
240
+ * - hooks: manifest entry ids
241
+ * - mcp: upstream ids
242
+ * @param {object} args.sources
243
+ * @param {object} args.sources.registry a Registry instance (has/getEntry)
244
+ * @param {string} [args.sources.manifestPath] absolute path to hooks.manifest.json (for hook units)
245
+ * @param {object} [args.sources.exposeStore] an ExposeStore instance (for mcp units)
246
+ * @param {string} [args.sources.toolScriptsRoot] absolute dir holding tool scripts to copy
247
+ * @param {string} [args.sources.hookScriptsRoot] absolute dir holding hook scripts to copy
248
+ * @returns {{ dir:string, manifestPath:string, manifest:object }}
249
+ */
250
+ function exportPackage(args) {
251
+ const opts = args && typeof args === 'object' ? args : {};
252
+ const {
253
+ packagesDir,
254
+ name,
255
+ version = '1.0.0',
256
+ description = '',
257
+ author = '',
258
+ units,
259
+ sources,
260
+ } = opts;
261
+
262
+ if (!isNonEmptyString(packagesDir)) throw new Error('exportPackage: packagesDir is required');
263
+ if (!isNonEmptyString(name)) throw new Error('exportPackage: name is required');
264
+ if (/[\\/]|\.\./.test(name)) {
265
+ throw new Error(`exportPackage: name "${name}" must be a bare folder name (no separators or "..")`);
266
+ }
267
+ const u = units && typeof units === 'object' ? units : {};
268
+ const toolIds = Array.isArray(u.tools) ? u.tools : [];
269
+ const hookIds = Array.isArray(u.hooks) ? u.hooks : [];
270
+ const mcpIds = Array.isArray(u.mcp) ? u.mcp : [];
271
+
272
+ const src = sources && typeof sources === 'object' ? sources : {};
273
+ const { registry, manifestPath, exposeStore, toolScriptsRoot, hookScriptsRoot } = src;
274
+
275
+ // ── PHASE 1: gather + validate everything (no writes yet) ────────────────
276
+
277
+ // Tools
278
+ const toolUnits = [];
279
+ for (const id of toolIds) {
280
+ if (!registry || typeof registry.has !== 'function' || !registry.has(id)) {
281
+ throw new Error(`exportPackage: tool id "${id}" not found in the registry`);
282
+ }
283
+ const entry = registry.getEntry(id); // a clone
284
+ let files = [];
285
+ if (entry.invoke && entry.invoke.type === 'script') {
286
+ const base = scriptBasename(entry.invoke.path);
287
+ if (!base) throw new Error(`exportPackage: tool "${id}" has a script invoke with no path`);
288
+ if (!isNonEmptyString(toolScriptsRoot)) {
289
+ throw new Error(`exportPackage: tool "${id}" is a script tool but sources.toolScriptsRoot was not provided`);
290
+ }
291
+ const srcFile = path.resolve(toolScriptsRoot, base);
292
+ assertInside(srcFile, toolScriptsRoot, 'the tool scripts root');
293
+ if (!fs.existsSync(srcFile)) {
294
+ throw new Error(`exportPackage: tool "${id}" script not found on disk ("${srcFile}")`);
295
+ }
296
+ // Normalise the embedded entry's invoke.path to the package-relative scripts/<base>.
297
+ entry.invoke = Object.assign({}, entry.invoke, { path: `scripts/${base}` });
298
+ files = [`tools/scripts/${base}`];
299
+ toolUnits.push({ entry, files, _srcFile: srcFile, _destRel: `tools/scripts/${base}` });
300
+ } else {
301
+ // shell (or other) invoke - no file travels with it
302
+ toolUnits.push({ entry, files });
303
+ }
304
+ }
305
+
306
+ // Hooks
307
+ const hookUnits = [];
308
+ if (hookIds.length) {
309
+ if (!isNonEmptyString(manifestPath)) {
310
+ throw new Error('exportPackage: hook units requested but sources.manifestPath was not provided');
311
+ }
312
+ const { manifest, state } = readHookSources(manifestPath);
313
+ const byId = new Map();
314
+ for (const h of manifest.hooks || []) {
315
+ if (h && typeof h.id === 'string') byId.set(h.id, h);
316
+ }
317
+ for (const id of hookIds) {
318
+ const entry = byId.get(id);
319
+ if (!entry) throw new Error(`exportPackage: hook id "${id}" not found in the manifest`);
320
+ // enabled: overlay (hooks.state.json) WINS, else the manifest's own flag.
321
+ const enabled = Object.prototype.hasOwnProperty.call(state, id)
322
+ ? state[id] === true
323
+ : entry.enabled === true;
324
+ // Embed a CLEAN copy of the manifest entry (drop the live enabled - it travels as `enabled`).
325
+ const cleanEntry = Object.assign({}, entry);
326
+ delete cleanEntry.enabled;
327
+ let files = [];
328
+ let srcFile = null;
329
+ let destRel = null;
330
+ const base = scriptBasename(entry.script);
331
+ if (base) {
332
+ if (!isNonEmptyString(hookScriptsRoot)) {
333
+ throw new Error(`exportPackage: hook "${id}" references a script but sources.hookScriptsRoot was not provided`);
334
+ }
335
+ srcFile = path.resolve(hookScriptsRoot, base);
336
+ assertInside(srcFile, hookScriptsRoot, 'the hook scripts root');
337
+ if (!fs.existsSync(srcFile)) {
338
+ throw new Error(`exportPackage: hook "${id}" script not found on disk ("${srcFile}")`);
339
+ }
340
+ cleanEntry.script = `scripts/${base}`;
341
+ files = [`hooks/scripts/${base}`];
342
+ destRel = `hooks/scripts/${base}`;
343
+ }
344
+ hookUnits.push({ entry: cleanEntry, enabled, files, _srcFile: srcFile, _destRel: destRel });
345
+ }
346
+ }
347
+
348
+ // MCP
349
+ const mcpUnits = [];
350
+ if (mcpIds.length) {
351
+ if (!exposeStore || typeof exposeStore.getUpstream !== 'function') {
352
+ throw new Error('exportPackage: mcp units requested but sources.exposeStore was not provided');
353
+ }
354
+ for (const id of mcpIds) {
355
+ const upstream = exposeStore.getUpstream(id);
356
+ if (!upstream) throw new Error(`exportPackage: mcp upstream id "${id}" not found in the expose store`);
357
+ // Embed the upstream block + only its ENABLED expose[] selections.
358
+ const expose = exposeStore.listExposed({ upstream: id, enabledOnly: true });
359
+ mcpUnits.push({ upstream, expose, files: [] });
360
+ }
361
+ }
362
+
363
+ // ── PHASE 2: write the package (validation passed) ───────────────────────
364
+
365
+ const pkgDir = path.resolve(packagesDir, name);
366
+ assertInside(pkgDir, packagesDir, 'the packages dir');
367
+ ensureDir(pkgDir);
368
+
369
+ // Copy script files into the mirrored tree.
370
+ for (const tu of toolUnits) {
371
+ if (tu._srcFile && tu._destRel) {
372
+ const dest = path.resolve(pkgDir, tu._destRel);
373
+ assertInside(dest, pkgDir, 'the package dir');
374
+ copyFileInto(tu._srcFile, dest);
375
+ }
376
+ }
377
+ for (const hu of hookUnits) {
378
+ if (hu._srcFile && hu._destRel) {
379
+ const dest = path.resolve(pkgDir, hu._destRel);
380
+ assertInside(dest, pkgDir, 'the package dir');
381
+ copyFileInto(hu._srcFile, dest);
382
+ }
383
+ }
384
+
385
+ // Build the manifest (strip internal bookkeeping fields).
386
+ const manifest = {
387
+ name,
388
+ version: isNonEmptyString(version) ? version : '1.0.0',
389
+ description: typeof description === 'string' ? description : '',
390
+ author: isNonEmptyString(author) ? author : '',
391
+ units: {
392
+ tools: toolUnits.map((t) => ({ entry: t.entry, files: t.files })),
393
+ hooks: hookUnits.map((h) => ({ entry: h.entry, enabled: h.enabled, files: h.files })),
394
+ mcp: mcpUnits.map((m) => ({ upstream: m.upstream, expose: m.expose, files: m.files })),
395
+ },
396
+ };
397
+
398
+ const manifestOut = path.join(pkgDir, PACKAGE_MANIFEST);
399
+ atomicWriteJson(manifestOut, manifest);
400
+
401
+ return { dir: pkgDir, manifestPath: manifestOut, manifest };
402
+ }
403
+
404
+ // ──────────────────────────────────────────────────────────────────────────
405
+ // importPackage - install a package's units into the gateway stores
406
+ // ──────────────────────────────────────────────────────────────────────────
407
+
408
+ /**
409
+ * Install a package's units (package.md §4).
410
+ *
411
+ * Tools -> copied into `toolScriptsRoot` + `registry.add(entry)`.
412
+ * Hooks -> copied into `hookScriptsRoot` + appended to `hooks.manifest.json` (atomic) and
413
+ * their enabled flag written into `hooks.state.json` (atomic).
414
+ * MCP -> `exposeStore.addUpstream(upstream)` + `exposeStore.addExpose(selection)` per selection.
415
+ *
416
+ * The whole package.json is VALIDATED first; nothing is written until validation passes,
417
+ * so a mid-import error never leaves a partially-corrupt store. Every destination path is
418
+ * guarded inside its supplied root (a crafted `files`/`script`/`path` cannot escape).
419
+ *
420
+ * Collision policy (`onCollision`):
421
+ * - 'skip' (default): a colliding id is recorded in `skipped[]` and NOT installed; no throw.
422
+ * - 'error': a colliding id throws (before any write).
423
+ *
424
+ * @param {object} args
425
+ * @param {string} args.packageDir absolute path to the package folder (contains package.json)
426
+ * @param {object} args.registry a Registry instance (has/add)
427
+ * @param {string} [args.manifestPath] absolute hooks.manifest.json path (required iff hooks present)
428
+ * @param {object} [args.exposeStore] an ExposeStore instance (required iff mcp present)
429
+ * @param {string} [args.toolScriptsRoot] absolute dir to copy tool scripts into
430
+ * @param {string} [args.hookScriptsRoot] absolute dir to copy hook scripts into
431
+ * @param {'skip'|'error'} [args.onCollision='skip']
432
+ * @returns {{ tools:string[], hooks:string[], mcp:string[], skipped:Array<{kind,id,reason}> }}
433
+ */
434
+ function importPackage(args) {
435
+ const opts = args && typeof args === 'object' ? args : {};
436
+ const {
437
+ packageDir,
438
+ registry,
439
+ manifestPath,
440
+ exposeStore,
441
+ toolScriptsRoot,
442
+ hookScriptsRoot,
443
+ onCollision = 'skip',
444
+ } = opts;
445
+
446
+ if (!isNonEmptyString(packageDir)) throw new Error('importPackage: packageDir is required');
447
+ if (onCollision !== 'skip' && onCollision !== 'error') {
448
+ throw new Error(`importPackage: onCollision must be 'skip' or 'error' (got "${onCollision}")`);
449
+ }
450
+
451
+ // ── Read + validate the manifest FULLY before any write ──────────────────
452
+ const manifestFile = path.join(packageDir, PACKAGE_MANIFEST);
453
+ let pkg;
454
+ try {
455
+ pkg = JSON.parse(fs.readFileSync(manifestFile, 'utf8'));
456
+ } catch (err) {
457
+ throw new Error(`importPackage: cannot read package.json at "${manifestFile}": ${err.message}`);
458
+ }
459
+ if (!pkg || typeof pkg !== 'object' || Array.isArray(pkg)) {
460
+ throw new Error('importPackage: package.json is not an object');
461
+ }
462
+ const units = pkg.units && typeof pkg.units === 'object' ? pkg.units : {};
463
+ const toolUnits = Array.isArray(units.tools) ? units.tools : [];
464
+ const hookUnits = Array.isArray(units.hooks) ? units.hooks : [];
465
+ const mcpUnits = Array.isArray(units.mcp) ? units.mcp : [];
466
+
467
+ if (hookUnits.length && !isNonEmptyString(manifestPath)) {
468
+ throw new Error('importPackage: package has hook units but manifestPath was not provided');
469
+ }
470
+ if (mcpUnits.length && (!exposeStore || typeof exposeStore.addUpstream !== 'function')) {
471
+ throw new Error('importPackage: package has mcp units but exposeStore was not provided');
472
+ }
473
+
474
+ // A "plan" of validated, ready-to-apply actions. Building it fully first means a
475
+ // validation error throws before we mutate any store.
476
+ const skipped = [];
477
+
478
+ // --- Tools plan ---
479
+ const toolPlan = [];
480
+ for (const unit of toolUnits) {
481
+ const entry = unit && typeof unit === 'object' ? unit.entry : null;
482
+ if (!entry || typeof entry !== 'object' || !isNonEmptyString(entry.id)) {
483
+ throw new Error('importPackage: a tool unit is missing a valid entry.id');
484
+ }
485
+ const id = entry.id;
486
+ const collides = registry && typeof registry.has === 'function' && registry.has(id);
487
+ if (collides) {
488
+ if (onCollision === 'error') {
489
+ throw new Error(`importPackage: tool id "${id}" already exists in the registry`);
490
+ }
491
+ skipped.push({ kind: 'tool', id, reason: 'id-collision' });
492
+ continue;
493
+ }
494
+ // Resolve any script file to copy.
495
+ let srcFile = null;
496
+ let destFile = null;
497
+ if (entry.invoke && entry.invoke.type === 'script') {
498
+ const base = scriptBasename(entry.invoke.path);
499
+ if (!base) throw new Error(`importPackage: tool "${id}" has a script invoke with no path`);
500
+ if (!isNonEmptyString(toolScriptsRoot)) {
501
+ throw new Error(`importPackage: tool "${id}" needs a script copied but toolScriptsRoot was not provided`);
502
+ }
503
+ const fileRel = pickFileForBase(unit.files, base);
504
+ srcFile = path.resolve(packageDir, fileRel);
505
+ assertInside(srcFile, packageDir, 'the package dir');
506
+ if (!fs.existsSync(srcFile)) {
507
+ throw new Error(`importPackage: tool "${id}" script not found in package ("${srcFile}")`);
508
+ }
509
+ destFile = path.resolve(toolScriptsRoot, base);
510
+ assertInside(destFile, toolScriptsRoot, 'the tool scripts root');
511
+ }
512
+ toolPlan.push({ id, entry, srcFile, destFile });
513
+ }
514
+
515
+ // --- Hooks plan ---
516
+ const hookPlan = [];
517
+ let manifestObj = null;
518
+ let manifestIds = null;
519
+ if (hookUnits.length) {
520
+ manifestObj = readManifestRaw(manifestPath);
521
+ manifestIds = new Set((manifestObj.hooks || []).map((h) => h && h.id).filter(Boolean));
522
+ }
523
+ for (const unit of hookUnits) {
524
+ const entry = unit && typeof unit === 'object' ? unit.entry : null;
525
+ if (!entry || typeof entry !== 'object' || !isNonEmptyString(entry.id)) {
526
+ throw new Error('importPackage: a hook unit is missing a valid entry.id');
527
+ }
528
+ const id = entry.id;
529
+ if (manifestIds.has(id)) {
530
+ if (onCollision === 'error') {
531
+ throw new Error(`importPackage: hook id "${id}" already exists in the manifest`);
532
+ }
533
+ skipped.push({ kind: 'hook', id, reason: 'id-collision' });
534
+ continue;
535
+ }
536
+ let srcFile = null;
537
+ let destFile = null;
538
+ const base = scriptBasename(entry.script);
539
+ if (base) {
540
+ if (!isNonEmptyString(hookScriptsRoot)) {
541
+ throw new Error(`importPackage: hook "${id}" needs a script copied but hookScriptsRoot was not provided`);
542
+ }
543
+ const fileRel = pickFileForBase(unit.files, base);
544
+ srcFile = path.resolve(packageDir, fileRel);
545
+ assertInside(srcFile, packageDir, 'the package dir');
546
+ if (!fs.existsSync(srcFile)) {
547
+ throw new Error(`importPackage: hook "${id}" script not found in package ("${srcFile}")`);
548
+ }
549
+ destFile = path.resolve(hookScriptsRoot, base);
550
+ assertInside(destFile, hookScriptsRoot, 'the hook scripts root');
551
+ }
552
+ const enabled = unit.enabled === true;
553
+ hookPlan.push({ id, entry, enabled, srcFile, destFile });
554
+ }
555
+
556
+ // --- MCP plan ---
557
+ const mcpPlan = [];
558
+ for (const unit of mcpUnits) {
559
+ const upstream = unit && typeof unit === 'object' ? unit.upstream : null;
560
+ if (!upstream || typeof upstream !== 'object' || !isNonEmptyString(upstream.id)) {
561
+ throw new Error('importPackage: an mcp unit is missing a valid upstream.id');
562
+ }
563
+ const id = upstream.id;
564
+ const exposeList = Array.isArray(unit.expose) ? unit.expose : [];
565
+ const collides = !!exposeStore.getUpstream(id);
566
+ if (collides) {
567
+ if (onCollision === 'error') {
568
+ throw new Error(`importPackage: mcp upstream id "${id}" already exists in the expose store`);
569
+ }
570
+ skipped.push({ kind: 'mcp', id, reason: 'id-collision' });
571
+ continue;
572
+ }
573
+ mcpPlan.push({ id, upstream, expose: exposeList });
574
+ }
575
+
576
+ // ── APPLY (validation passed; collisions resolved) ───────────────────────
577
+
578
+ const installed = { tools: [], hooks: [], mcp: [], skipped };
579
+
580
+ // Tools: copy scripts then register.add.
581
+ for (const t of toolPlan) {
582
+ if (t.srcFile && t.destFile) copyFileInto(t.srcFile, t.destFile);
583
+ registry.add(t.entry);
584
+ installed.tools.push(t.id);
585
+ }
586
+
587
+ // Hooks: copy scripts, append manifest entries (one atomic write), write state overlay.
588
+ if (hookPlan.length) {
589
+ for (const h of hookPlan) {
590
+ if (h.srcFile && h.destFile) copyFileInto(h.srcFile, h.destFile);
591
+ }
592
+ if (!Array.isArray(manifestObj.hooks)) manifestObj.hooks = [];
593
+ for (const h of hookPlan) {
594
+ // The manifest stores enabled as a faithful default; the state overlay is authoritative.
595
+ const manifestEntry = Object.assign({}, h.entry, { enabled: h.enabled });
596
+ manifestObj.hooks.push(manifestEntry);
597
+ }
598
+ atomicWriteJson(path.resolve(manifestPath), manifestObj);
599
+
600
+ // State overlay (hooks.state.json) sits next to the manifest.
601
+ const statePath = path.join(path.dirname(path.resolve(manifestPath)), 'hooks.state.json');
602
+ const state = readStateRaw(statePath);
603
+ for (const h of hookPlan) state[h.id] = h.enabled === true;
604
+ atomicWriteJson(statePath, state);
605
+
606
+ for (const h of hookPlan) installed.hooks.push(h.id);
607
+ }
608
+
609
+ // MCP: addUpstream then addExpose per enabled selection.
610
+ for (const m of mcpPlan) {
611
+ exposeStore.addUpstream(m.upstream);
612
+ for (const sel of m.expose) {
613
+ if (!sel || typeof sel !== 'object') continue;
614
+ exposeStore.addExpose(sel);
615
+ }
616
+ installed.mcp.push(m.id);
617
+ }
618
+
619
+ return installed;
620
+ }
621
+
622
+ // ──────────────────────────────────────────────────────────────────────────
623
+ // internals
624
+ // ──────────────────────────────────────────────────────────────────────────
625
+
626
+ /**
627
+ * Read a hooks manifest + its state overlay for export. Both are read leniently:
628
+ * a missing/unreadable state overlay yields an empty overlay (the manifest's own
629
+ * enabled flags then stand). Throws only if the manifest itself is unreadable.
630
+ *
631
+ * @param {string} manifestPath
632
+ * @returns {{ manifest: object, state: Object<string,boolean> }}
633
+ */
634
+ function readHookSources(manifestPath) {
635
+ const manifest = readManifestRaw(manifestPath);
636
+ const statePath = path.join(path.dirname(path.resolve(manifestPath)), 'hooks.state.json');
637
+ const state = readStateRaw(statePath);
638
+ return { manifest, state };
639
+ }
640
+
641
+ /** Read + parse a hooks.manifest.json (throws on unreadable/malformed). */
642
+ function readManifestRaw(manifestPath) {
643
+ let raw;
644
+ try {
645
+ raw = fs.readFileSync(path.resolve(manifestPath), 'utf8');
646
+ } catch (err) {
647
+ throw new Error(`packager: cannot read hooks manifest at "${manifestPath}": ${err.message}`);
648
+ }
649
+ let obj;
650
+ try {
651
+ obj = JSON.parse(raw);
652
+ } catch (err) {
653
+ throw new Error(`packager: hooks manifest at "${manifestPath}" is not valid JSON: ${err.message}`);
654
+ }
655
+ if (!obj || typeof obj !== 'object' || Array.isArray(obj)) {
656
+ throw new Error(`packager: hooks manifest at "${manifestPath}" is not an object`);
657
+ }
658
+ if (!Array.isArray(obj.hooks)) obj.hooks = [];
659
+ return obj;
660
+ }
661
+
662
+ /**
663
+ * Read a hooks.state.json overlay leniently. Missing/unreadable/malformed -> {} (never
664
+ * throws). Coerces values to strict booleans, keyed by hook id.
665
+ *
666
+ * @param {string} statePath
667
+ * @returns {Object<string, boolean>}
668
+ */
669
+ function readStateRaw(statePath) {
670
+ let raw;
671
+ try {
672
+ raw = JSON.parse(fs.readFileSync(statePath, 'utf8'));
673
+ } catch (_) {
674
+ return {};
675
+ }
676
+ const out = {};
677
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out;
678
+ for (const key of Object.keys(raw)) out[key] = raw[key] === true;
679
+ return out;
680
+ }
681
+
682
+ /**
683
+ * Choose the package-relative file path for a given script basename. Prefer an entry in
684
+ * the unit's declared `files[]` whose basename matches; otherwise fall back to the mirrored
685
+ * convention. The basename is always re-derived (path.basename) so a crafted `files` entry
686
+ * cannot smuggle a traversal segment past the later assertInside guard.
687
+ *
688
+ * @param {*} files the unit's declared files array
689
+ * @param {string} base the script basename (already sanitised)
690
+ * @returns {string} a package-relative path (forward-slashed)
691
+ */
692
+ function pickFileForBase(files, base) {
693
+ if (Array.isArray(files)) {
694
+ for (const f of files) {
695
+ if (isNonEmptyString(f) && scriptBasename(f) === base) {
696
+ // Re-anchor on the basename only; never trust the directory portion blindly -
697
+ // but keep the mirrored prefix for clarity. assertInside re-validates anyway.
698
+ const norm = f.split('\\').join('/');
699
+ return norm;
700
+ }
701
+ }
702
+ }
703
+ return `scripts/${base}`;
704
+ }
705
+
706
+ module.exports = {
707
+ loadPackageList,
708
+ exportPackage,
709
+ importPackage,
710
+ // exported for unit tests / reuse
711
+ atomicWriteJson,
712
+ assertInside,
713
+ scriptBasename,
714
+ ROOT,
715
+ };