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.
- package/README.md +231 -167
- package/bin/toolfunnel.js +372 -147
- package/docs/KNOWN_BUGS.md +99 -33
- package/docs/MANUAL.pdf +0 -0
- package/docs/design.md +70 -0
- package/docs/packaging.md +115 -115
- package/hooks/hooks.manifest.json +27 -27
- package/hooks/scripts/example-audit.js +21 -21
- package/hooks/scripts/example-deny-dangerous.js +31 -31
- package/mcp/expose.example.json +30 -30
- package/mcp/servers/mock-modern/server.js +147 -0
- package/mcp/servers/mock-upstream/server.js +192 -12
- package/package.json +1 -1
- package/src/auth/config.js +174 -174
- package/src/auth/install.js +181 -181
- package/src/auth/resource-server.js +344 -344
- package/src/core/config-home.js +134 -134
- package/src/core/events.js +105 -105
- package/src/core/hook-engine.js +275 -275
- package/src/core/hook-loader.js +791 -791
- package/src/core/hook-runner.js +346 -346
- package/src/core/logger.js +171 -171
- package/src/core/matcher.js +51 -51
- package/src/core/metrics.js +75 -75
- package/src/core/requires.js +138 -138
- package/src/core/server-config.js +98 -77
- package/src/core/tool-registry.js +180 -180
- package/src/extend/add-hook.md +152 -142
- package/src/extend/add-mcp.md +122 -121
- package/src/extend/configure.md +87 -0
- package/src/extend/create-tool.md +168 -152
- package/src/extend/howto.js +102 -98
- package/src/extend/package.md +187 -187
- package/src/extend/wrap.md +99 -0
- package/src/mcp/aggregator.js +1246 -807
- package/src/mcp/expose-store.js +460 -430
- package/src/mcp/gated-run.js +201 -194
- package/src/mcp/http-transport.js +1311 -1012
- package/src/mcp/mcp-client.js +907 -530
- package/src/mcp/modern.js +372 -0
- package/src/mcp/protocol.js +468 -466
- package/src/mcp/server.js +2688 -1405
- package/src/packages/packager.js +715 -715
- package/src/tools/drift.js +138 -138
- package/src/tools/registry.js +513 -513
- package/src/tools/tool-state.js +170 -104
- package/src/ui/public/app.js +1174 -1055
- package/src/ui/public/index.html +286 -249
- package/src/ui/public/styles.css +535 -535
- package/src/ui/server.js +1348 -1190
- package/tools/scripts/base64.js +139 -139
- package/tools/scripts/danger.js +61 -61
- package/tools/scripts/echo.js +39 -39
- package/tools/scripts/hash.js +101 -101
- package/tools/scripts/json.js +109 -109
- package/tools/scripts/text-stats.js +131 -131
- package/tools/scripts/tf-env.js +43 -43
- package/tools/scripts/tf-hook-add.js +151 -151
- package/tools/scripts/tf-hook-set.js +112 -112
- package/tools/scripts/tf-list.js +192 -192
- package/tools/scripts/tf-log.js +86 -86
- package/tools/scripts/tf-mcp-add.js +117 -117
- package/tools/scripts/tf-mcp-set.js +121 -102
- package/tools/scripts/tf-pack.js +237 -237
- package/tools/scripts/tf-tool-add.js +134 -129
- package/tools/scripts/tf-tool-set.js +142 -142
- package/tools/scripts/tf-wrap.js +103 -0
- package/tools/scripts/uuid.js +102 -102
- package/tools/tools.register.json +227 -216
package/src/packages/packager.js
CHANGED
|
@@ -1,715 +1,715 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* packager.js
|
|
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
|
|
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
|
|
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(); //
|
|
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
|
|
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
|
|
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"
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
406
|
-
// ──────────────────────────────────────────────────────────────────────────
|
|
407
|
-
|
|
408
|
-
/**
|
|
409
|
-
* Install a package's units (package.md §4).
|
|
410
|
-
*
|
|
411
|
-
* Tools
|
|
412
|
-
* Hooks
|
|
413
|
-
* their enabled flag written into `hooks.state.json` (atomic).
|
|
414
|
-
* MCP
|
|
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
|
|
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
|
+
};
|