toolfunnel 0.7.0 → 0.8.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 +238 -235
- package/SECURITY.md +84 -84
- package/bin/toolfunnel.js +530 -451
- package/docs/KNOWN_BUGS.md +100 -80
- package/docs/MANUAL.pdf +0 -0
- package/docs/design.md +84 -84
- package/docs/packaging.md +116 -115
- package/hooks/hooks.manifest.json +27 -27
- package/hooks/scripts/example-audit.js +21 -21
- package/hooks/scripts/example-deny-dangerous.js +39 -39
- package/mcp/expose.example.json +30 -30
- package/mcp/servers/mock-modern/server.js +147 -147
- package/mcp/servers/mock-upstream/server.js +379 -380
- package/package.json +67 -67
- package/src/auth/config.js +172 -174
- package/src/auth/install.js +181 -181
- package/src/auth/resource-server.js +358 -353
- package/src/core/atomic-write.js +97 -0
- package/src/core/child-reaper.js +61 -61
- package/src/core/config-home.js +226 -134
- package/src/core/events.js +105 -105
- package/src/core/hook-engine.js +304 -329
- package/src/core/hook-loader.js +766 -824
- package/src/core/hook-runner.js +615 -566
- package/src/core/logger.js +295 -297
- package/src/core/matcher.js +100 -100
- package/src/core/metrics.js +90 -75
- package/src/core/output-cap.js +31 -0
- package/src/core/rename-migration.js +178 -163
- package/src/core/requires.js +138 -138
- package/src/core/server-config.js +98 -98
- package/src/extend/add-hook.md +155 -152
- package/src/extend/add-mcp.md +119 -119
- package/src/extend/configure.md +88 -88
- package/src/extend/create-tool.md +168 -168
- package/src/extend/howto.js +102 -102
- package/src/extend/package.md +190 -190
- package/src/extend/wrap.md +99 -99
- package/src/mcp/aggregator.js +2248 -1674
- package/src/mcp/expose-store.js +755 -577
- package/src/mcp/gated-run.js +210 -210
- package/src/mcp/http-transport.js +1657 -1563
- package/src/mcp/mcp-client.js +1174 -1070
- package/src/mcp/modern.js +409 -406
- package/src/mcp/protocol.js +473 -468
- package/src/mcp/server.js +3317 -3112
- package/src/mcp/tool-approvals.js +396 -0
- package/src/tools/registry.js +607 -661
- package/src/tools/tool-state.js +170 -170
- package/src/ui/public/app.js +1246 -1187
- package/src/ui/public/index.html +286 -286
- package/src/ui/public/styles.css +535 -535
- package/src/ui/server.js +1742 -1616
- 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-accept-change.js +67 -0
- package/tools/scripts/tf-env.js +43 -43
- package/tools/scripts/tf-hook-add.js +151 -151
- package/tools/scripts/tf-hook-set.js +120 -120
- package/tools/scripts/tf-list.js +192 -192
- package/tools/scripts/tf-log.js +86 -86
- package/tools/scripts/tf-mcp-add.js +130 -117
- package/tools/scripts/tf-mcp-set.js +121 -121
- package/tools/scripts/tf-pack.js +237 -237
- package/tools/scripts/tf-tool-add.js +134 -134
- package/tools/scripts/tf-tool-set.js +142 -142
- package/tools/scripts/tf-wrap.js +103 -103
- package/tools/scripts/uuid.js +102 -102
- package/tools/tools.register.json +16 -5
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* atomic-write.js - THE atomic file writer (temp in the same directory + fsync + rename).
|
|
5
|
+
*
|
|
6
|
+
* One implementation for every store the gateway persists: the tool register and its scripts,
|
|
7
|
+
* the tool-state overlay, the expose store, the hooks manifest / state / scripts, toolfunnel.json
|
|
8
|
+
* and the rename migration. Four copies of this pattern had drifted apart (one lacked the
|
|
9
|
+
* failure-path unlink and the stale sweep; three wrote with a single writeSync where the loader's
|
|
10
|
+
* looped) - the exact "hardening decision made in one place and never carried to its sibling"
|
|
11
|
+
* fingerprint. orphan-tmp.test.js pins both the contract below and the fact that this is the
|
|
12
|
+
* ONLY module under src/ that performs the rename.
|
|
13
|
+
*
|
|
14
|
+
* Contract:
|
|
15
|
+
* - A crash leaves either the old file or the complete new file, never a half-written one: the
|
|
16
|
+
* data goes to `.<base>.<pid>.<ts>.tmp` beside the target (same volume, so the rename is
|
|
17
|
+
* atomic), is fsync'd, then renamed over the target.
|
|
18
|
+
* - The temp NEVER outlives a failure: any throw (a refused rename - win32 scanners hold targets
|
|
19
|
+
* briefly - a full disk, a write error) unlinks it before rethrowing.
|
|
20
|
+
* - STALE strandings from writers that died mid-flight (same temp shape, older than
|
|
21
|
+
* STALE_TMP_MS) are swept on the next write of the same target; a FRESH sibling (a concurrent
|
|
22
|
+
* writer's in-flight temp) is left alone. Sweeping is best-effort and never fails a write.
|
|
23
|
+
* - writeFileSync(fd, ...) loops until every byte is written (a bare writeSync may return short).
|
|
24
|
+
* - Bytes are written exactly as given (no line-ending normalisation - shell scripts may be
|
|
25
|
+
* CRLF-sensitive).
|
|
26
|
+
*
|
|
27
|
+
* CommonJS, Node built-ins only.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const fs = require('node:fs');
|
|
31
|
+
const path = require('node:path');
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A sibling temp older than this is a STRANDING (a crashed/killed writer), not an in-flight
|
|
35
|
+
* write - a live writer's temp exists for milliseconds. The generous age keeps a concurrent
|
|
36
|
+
* writer safe.
|
|
37
|
+
*/
|
|
38
|
+
const STALE_TMP_MS = 60 * 60 * 1000;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Remove STALE stranded temps for `base` in `dir` (the `.<base>.<pid>.<ts>.tmp` shape the
|
|
42
|
+
* writer below produces). Scoped to the target's own temp shape - never another file's - and
|
|
43
|
+
* entirely best-effort: sweeping must never fail a write.
|
|
44
|
+
*/
|
|
45
|
+
function sweepStaleTmp(dir, base) {
|
|
46
|
+
let names;
|
|
47
|
+
try { names = fs.readdirSync(dir); } catch (_e) { return; }
|
|
48
|
+
const prefix = `.${base}.`;
|
|
49
|
+
const now = Date.now();
|
|
50
|
+
for (const name of names) {
|
|
51
|
+
if (!name.startsWith(prefix) || !name.endsWith('.tmp')) continue;
|
|
52
|
+
const p = path.join(dir, name);
|
|
53
|
+
try {
|
|
54
|
+
if (now - fs.statSync(p).mtimeMs > STALE_TMP_MS) fs.unlinkSync(p);
|
|
55
|
+
} catch (_e) { /* a concurrent writer renamed/removed it - fine */ }
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Atomically write text to `targetPath`. See the module contract.
|
|
61
|
+
* @param {string} targetPath absolute destination
|
|
62
|
+
* @param {string|Buffer} data
|
|
63
|
+
* @param {string} [tmpDir] directory for the temp file (default: the target's own; must be the
|
|
64
|
+
* same volume as the target for the rename to be atomic)
|
|
65
|
+
*/
|
|
66
|
+
function atomicWriteText(targetPath, data, tmpDir) {
|
|
67
|
+
const dir = tmpDir || path.dirname(targetPath);
|
|
68
|
+
const base = path.basename(targetPath);
|
|
69
|
+
sweepStaleTmp(dir, base);
|
|
70
|
+
const tmp = path.join(dir, `.${base}.${process.pid}.${Date.now()}.tmp`);
|
|
71
|
+
try {
|
|
72
|
+
const fd = fs.openSync(tmp, 'w');
|
|
73
|
+
try {
|
|
74
|
+
fs.writeFileSync(fd, data); // loops until every byte is written
|
|
75
|
+
try { fs.fsyncSync(fd); } catch (_e) { /* fsync may be refused on some filesystems; the rename is still ordered */ }
|
|
76
|
+
} finally {
|
|
77
|
+
try { fs.closeSync(fd); } catch (_e) { /* ignore */ }
|
|
78
|
+
}
|
|
79
|
+
// rename is atomic on the same filesystem and overwrites the destination on POSIX; on Windows
|
|
80
|
+
// it overwrites too as long as the target isn't locked.
|
|
81
|
+
fs.renameSync(tmp, targetPath);
|
|
82
|
+
} catch (err) {
|
|
83
|
+
try { fs.unlinkSync(tmp); } catch (_e) { /* best-effort - the error below is the story */ }
|
|
84
|
+
throw err;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Atomically write a JS object as pretty JSON (2-space, trailing newline). See the module contract.
|
|
90
|
+
* @param {string} targetPath absolute destination
|
|
91
|
+
* @param {object} obj
|
|
92
|
+
*/
|
|
93
|
+
function atomicWriteJson(targetPath, obj) {
|
|
94
|
+
atomicWriteText(targetPath, JSON.stringify(obj, null, 2) + '\n');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
module.exports = { atomicWriteJson, atomicWriteText, sweepStaleTmp, STALE_TMP_MS };
|
package/src/core/child-reaper.js
CHANGED
|
@@ -1,61 +1,61 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* child-reaper.js - last-resort cleanup for spawned hook/tool children.
|
|
5
|
-
*
|
|
6
|
-
* The hook runner and both local executors spawn their children `detached` on POSIX so the
|
|
7
|
-
* process GROUP can be killed as a tree. setsid() has a consequence: the children leave the
|
|
8
|
-
* gateway's session, so a terminal Ctrl-C no longer reaches them, and a gateway that dies
|
|
9
|
-
* without executing its per-child kill path leaves them reparented to init running to their
|
|
10
|
-
* natural end - with their supervising timeout timers dead alongside the gateway.
|
|
11
|
-
*
|
|
12
|
-
* This module is the counterpart: every spawn site registers its child here and unregisters on
|
|
13
|
-
* settle; process exit / SIGINT / SIGTERM sweeps whatever is still tracked. Synchronous kills
|
|
14
|
-
* only - an 'exit' handler cannot await. Windows gets child.kill() (the shell only; taskkill is
|
|
15
|
-
* async and cannot run here) - best effort, matching the platform's weaker group semantics.
|
|
16
|
-
* A SIGKILLed gateway sweeps nothing; that limit is inherent to SIGKILL.
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
const live = new Set();
|
|
20
|
-
let installed = false;
|
|
21
|
-
|
|
22
|
-
function sweep() {
|
|
23
|
-
for (const child of live) {
|
|
24
|
-
try {
|
|
25
|
-
if (child.pid != null && process.platform !== 'win32') {
|
|
26
|
-
try { process.kill(-child.pid, 'SIGKILL'); } catch (_eg) { child.kill('SIGKILL'); }
|
|
27
|
-
} else {
|
|
28
|
-
child.kill();
|
|
29
|
-
}
|
|
30
|
-
} catch (_e) { /* already gone */ }
|
|
31
|
-
}
|
|
32
|
-
live.clear();
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function install() {
|
|
36
|
-
if (installed) return;
|
|
37
|
-
installed = true;
|
|
38
|
-
process.on('exit', sweep);
|
|
39
|
-
// On a signal, sweep and re-raise the default behaviour by exiting: only do so if nobody
|
|
40
|
-
// else handles the signal (respect an embedding host's own handlers).
|
|
41
|
-
for (const sig of ['SIGINT', 'SIGTERM']) {
|
|
42
|
-
process.on(sig, () => {
|
|
43
|
-
sweep();
|
|
44
|
-
if (process.listenerCount(sig) === 1) process.exit(sig === 'SIGINT' ? 130 : 143);
|
|
45
|
-
});
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/** Register a spawned child for the last-resort sweep. Call untrack() when it settles. */
|
|
50
|
-
function track(child) {
|
|
51
|
-
if (!child || typeof child.kill !== 'function') return;
|
|
52
|
-
install();
|
|
53
|
-
live.add(child);
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/** Remove a settled child from the sweep set. Safe to call twice. */
|
|
57
|
-
function untrack(child) {
|
|
58
|
-
live.delete(child);
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
module.exports = { track, untrack };
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* child-reaper.js - last-resort cleanup for spawned hook/tool children.
|
|
5
|
+
*
|
|
6
|
+
* The hook runner and both local executors spawn their children `detached` on POSIX so the
|
|
7
|
+
* process GROUP can be killed as a tree. setsid() has a consequence: the children leave the
|
|
8
|
+
* gateway's session, so a terminal Ctrl-C no longer reaches them, and a gateway that dies
|
|
9
|
+
* without executing its per-child kill path leaves them reparented to init running to their
|
|
10
|
+
* natural end - with their supervising timeout timers dead alongside the gateway.
|
|
11
|
+
*
|
|
12
|
+
* This module is the counterpart: every spawn site registers its child here and unregisters on
|
|
13
|
+
* settle; process exit / SIGINT / SIGTERM sweeps whatever is still tracked. Synchronous kills
|
|
14
|
+
* only - an 'exit' handler cannot await. Windows gets child.kill() (the shell only; taskkill is
|
|
15
|
+
* async and cannot run here) - best effort, matching the platform's weaker group semantics.
|
|
16
|
+
* A SIGKILLed gateway sweeps nothing; that limit is inherent to SIGKILL.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const live = new Set();
|
|
20
|
+
let installed = false;
|
|
21
|
+
|
|
22
|
+
function sweep() {
|
|
23
|
+
for (const child of live) {
|
|
24
|
+
try {
|
|
25
|
+
if (child.pid != null && process.platform !== 'win32') {
|
|
26
|
+
try { process.kill(-child.pid, 'SIGKILL'); } catch (_eg) { child.kill('SIGKILL'); }
|
|
27
|
+
} else {
|
|
28
|
+
child.kill();
|
|
29
|
+
}
|
|
30
|
+
} catch (_e) { /* already gone */ }
|
|
31
|
+
}
|
|
32
|
+
live.clear();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function install() {
|
|
36
|
+
if (installed) return;
|
|
37
|
+
installed = true;
|
|
38
|
+
process.on('exit', sweep);
|
|
39
|
+
// On a signal, sweep and re-raise the default behaviour by exiting: only do so if nobody
|
|
40
|
+
// else handles the signal (respect an embedding host's own handlers).
|
|
41
|
+
for (const sig of ['SIGINT', 'SIGTERM']) {
|
|
42
|
+
process.on(sig, () => {
|
|
43
|
+
sweep();
|
|
44
|
+
if (process.listenerCount(sig) === 1) process.exit(sig === 'SIGINT' ? 130 : 143);
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Register a spawned child for the last-resort sweep. Call untrack() when it settles. */
|
|
50
|
+
function track(child) {
|
|
51
|
+
if (!child || typeof child.kill !== 'function') return;
|
|
52
|
+
install();
|
|
53
|
+
live.add(child);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Remove a settled child from the sweep set. Safe to call twice. */
|
|
57
|
+
function untrack(child) {
|
|
58
|
+
live.delete(child);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
module.exports = { track, untrack };
|
package/src/core/config-home.js
CHANGED
|
@@ -1,134 +1,226 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* config-home.js - WHERE the gateway's mutable config lives (board item 9, the packaging enabler).
|
|
5
|
-
*
|
|
6
|
-
* The problem this solves: an npm/npx-installed gateway keeps its config INSIDE the package
|
|
7
|
-
* directory, so `npm update toolfunnel` replaces the package tree and EATS the user's tools,
|
|
8
|
-
* hooks, curation, and identity. Config must be able to live in a home of its own:
|
|
9
|
-
*
|
|
10
|
-
* resolution precedence: --config-dir flag > TOOLFUNNEL_HOME env > the package root
|
|
11
|
-
*
|
|
12
|
-
* The default is UNCHANGED: a git clone keeps everything in the repo root exactly as before
|
|
13
|
-
* (home === package root, no seeding, byte-identical behaviour). Pointing TOOLFUNNEL_HOME at an
|
|
14
|
-
* empty directory makes the gateway SEED it on first use - the shipped register + its scripts, an
|
|
15
|
-
* empty expose.json, the default hooks manifest - and every mutable path (tools/ mcp/ hooks/
|
|
16
|
-
* auth/ logs/ toolfunnel.json packages/) anchors there from then on. Seeding never overwrites an
|
|
17
|
-
* existing file, so a home survives package updates by construction
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
*
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* config-home.js - WHERE the gateway's mutable config lives (board item 9, the packaging enabler).
|
|
5
|
+
*
|
|
6
|
+
* The problem this solves: an npm/npx-installed gateway keeps its config INSIDE the package
|
|
7
|
+
* directory, so `npm update toolfunnel` replaces the package tree and EATS the user's tools,
|
|
8
|
+
* hooks, curation, and identity. Config must be able to live in a home of its own:
|
|
9
|
+
*
|
|
10
|
+
* resolution precedence: --config-dir flag > TOOLFUNNEL_HOME env > the package root
|
|
11
|
+
*
|
|
12
|
+
* The default is UNCHANGED: a git clone keeps everything in the repo root exactly as before
|
|
13
|
+
* (home === package root, no seeding, byte-identical behaviour). Pointing TOOLFUNNEL_HOME at an
|
|
14
|
+
* empty directory makes the gateway SEED it on first use - the shipped register + its scripts, an
|
|
15
|
+
* empty expose.json, the default hooks manifest - and every mutable path (tools/ mcp/ hooks/
|
|
16
|
+
* auth/ logs/ toolfunnel.json packages/) anchors there from then on. Seeding never overwrites an
|
|
17
|
+
* existing file, so a home survives package updates by construction; the one addition is a tool
|
|
18
|
+
* new in a release, appended to the register once (offerNewShippedTools).
|
|
19
|
+
*
|
|
20
|
+
* Two env vars are the process-tree contract (children inherit them via defaultRunScript's
|
|
21
|
+
* spawn env):
|
|
22
|
+
* TOOLFUNNEL_HOME - the resolved ABSOLUTE config home (normalised at init so a relative value
|
|
23
|
+
* can't skew between processes with different cwds).
|
|
24
|
+
* TOOLFUNNEL_PKG - the package root of the gateway that spawned the tool, so a management
|
|
25
|
+
* script SEEDED INTO an external home can still require the engine code
|
|
26
|
+
* (src/...) it was copied away from. A script always prefers its own
|
|
27
|
+
* __dirname-derived root when that root contains the engine (the git-clone
|
|
28
|
+
* layout), so a stale inherited value can never shadow a real local install.
|
|
29
|
+
*
|
|
30
|
+
* Zero new dependencies. Node built-ins only.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
const fs = require('node:fs');
|
|
34
|
+
const path = require('node:path');
|
|
35
|
+
const { atomicWriteJson } = require('./atomic-write');
|
|
36
|
+
|
|
37
|
+
/** The package root: this file lives at <pkg>/src/core/config-home.js. */
|
|
38
|
+
const PKG_ROOT = path.resolve(__dirname, '..', '..');
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Resolve the config home. Pure - no filesystem writes, no env mutation.
|
|
42
|
+
* @param {{dir?: string}} [opts] dir = an explicit --config-dir value (wins over the env)
|
|
43
|
+
* @returns {string} absolute config-home path
|
|
44
|
+
*/
|
|
45
|
+
function resolveConfigHome(opts) {
|
|
46
|
+
const o = opts || {};
|
|
47
|
+
const fromFlag = typeof o.dir === 'string' && o.dir.trim().length > 0 ? o.dir.trim() : null;
|
|
48
|
+
const fromEnv = typeof process.env.TOOLFUNNEL_HOME === 'string' && process.env.TOOLFUNNEL_HOME.trim().length > 0
|
|
49
|
+
? process.env.TOOLFUNNEL_HOME.trim()
|
|
50
|
+
: null;
|
|
51
|
+
const chosen = fromFlag || fromEnv;
|
|
52
|
+
return chosen ? path.resolve(chosen) : PKG_ROOT;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Recursively copy a directory, skipping any destination file that already exists. */
|
|
56
|
+
function copyTreeIfAbsent(srcDir, destDir, seeded) {
|
|
57
|
+
let entries;
|
|
58
|
+
try {
|
|
59
|
+
entries = fs.readdirSync(srcDir, { withFileTypes: true });
|
|
60
|
+
} catch (_e) {
|
|
61
|
+
return; // a missing source dir seeds nothing (defensive)
|
|
62
|
+
}
|
|
63
|
+
// A home the user has made read-only still starts: what cannot be written is not seeded, and the
|
|
64
|
+
// gateway runs with the files the home already has. Any other failure is real and is thrown.
|
|
65
|
+
if (!writeOrSkip(() => fs.mkdirSync(destDir, { recursive: true }))) return;
|
|
66
|
+
for (const e of entries) {
|
|
67
|
+
const from = path.join(srcDir, e.name);
|
|
68
|
+
const to = path.join(destDir, e.name);
|
|
69
|
+
if (e.isDirectory()) {
|
|
70
|
+
copyTreeIfAbsent(from, to, seeded);
|
|
71
|
+
} else if (e.isFile() && !fs.existsSync(to)) {
|
|
72
|
+
if (writeOrSkip(() => fs.copyFileSync(from, to))) seeded.push(to);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Runs a write; false when the file system refuses it for permission (the caller skips that file). */
|
|
78
|
+
function writeOrSkip(write) {
|
|
79
|
+
try {
|
|
80
|
+
write();
|
|
81
|
+
return true;
|
|
82
|
+
} catch (e) {
|
|
83
|
+
if (e && (e.code === 'EACCES' || e.code === 'EPERM' || e.code === 'EROFS')) return false;
|
|
84
|
+
throw e;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// The shipped register ids of 0.7.x, the releases before tools/tools.seeded.json existed: a home
|
|
89
|
+
// upgraded from one of them has already been offered these, so only tools shipped since are added.
|
|
90
|
+
const SHIPPED_BEFORE_LEDGER = ['echo', 'base64', 'hash', 'uuid', 'json', 'text-stats', 'danger',
|
|
91
|
+
'tf_tool_add', 'tf_tool_set', 'tf_mcp_add', 'tf_mcp_set', 'tf_wrap', 'tf_hook_add', 'tf_hook_set',
|
|
92
|
+
'tf_list', 'tf_log', 'tf_pack'];
|
|
93
|
+
|
|
94
|
+
// Windows paths are case-insensitive: g:\dev\toolfunnel IS the package root.
|
|
95
|
+
const samePath = (a, b) => (process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b);
|
|
96
|
+
const readJson = (file) => { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch (_e) { return null; } };
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Add the shipped register entries this home has never been offered, once each. The ledger
|
|
100
|
+
* (tools/tools.seeded.json) records every id offered, so a shipped tool the user deleted is never
|
|
101
|
+
* added back; existing entries are never touched, and an unreadable register or ledger is left
|
|
102
|
+
* alone. Seeding alone cannot do this: it never overwrites a file, and the register is one file.
|
|
103
|
+
* @param {string} home
|
|
104
|
+
* @param {boolean} registerSeeded the register was copied in on this call (a fresh home)
|
|
105
|
+
* @returns {string[]} the ids added to the register
|
|
106
|
+
*/
|
|
107
|
+
function offerNewShippedTools(home, registerSeeded) {
|
|
108
|
+
const regPath = path.join(home, 'tools', 'tools.register.json');
|
|
109
|
+
const ledgerPath = path.join(home, 'tools', 'tools.seeded.json');
|
|
110
|
+
const shipped = readJson(path.join(PKG_ROOT, 'tools', 'tools.register.json'));
|
|
111
|
+
const reg = readJson(regPath);
|
|
112
|
+
if (!shipped || !Array.isArray(shipped.tools) || !reg || !Array.isArray(reg.tools)) return [];
|
|
113
|
+
const shippedIds = shipped.tools.map((t) => t && t.id).filter((id) => typeof id === 'string');
|
|
114
|
+
const hadLedger = fs.existsSync(ledgerPath);
|
|
115
|
+
let offered;
|
|
116
|
+
if (hadLedger) {
|
|
117
|
+
const ledger = readJson(ledgerPath);
|
|
118
|
+
if (!ledger || !Array.isArray(ledger.ids)) return [];
|
|
119
|
+
offered = new Set(ledger.ids);
|
|
120
|
+
} else {
|
|
121
|
+
offered = new Set(registerSeeded ? shippedIds : SHIPPED_BEFORE_LEDGER);
|
|
122
|
+
}
|
|
123
|
+
const offeredBefore = offered.size;
|
|
124
|
+
const present = new Set(reg.tools.map((t) => t && t.id));
|
|
125
|
+
const added = [];
|
|
126
|
+
for (const t of shipped.tools) {
|
|
127
|
+
if (!t || offered.has(t.id)) continue;
|
|
128
|
+
if (!present.has(t.id)) { reg.tools.push(t); added.push(t.id); }
|
|
129
|
+
offered.add(t.id);
|
|
130
|
+
}
|
|
131
|
+
try {
|
|
132
|
+
if (added.length) atomicWriteJson(regPath, reg);
|
|
133
|
+
if (!hadLedger || offered.size !== offeredBefore) atomicWriteJson(ledgerPath, { version: 1, ids: [...offered] });
|
|
134
|
+
} catch (err) {
|
|
135
|
+
// A home that cannot be rewritten (read-only file or mount) ran fine before: keep it running,
|
|
136
|
+
// without the new tools, and try again next start.
|
|
137
|
+
process.stderr.write(`[toolfunnel] could not add new shipped tools to ${regPath}: ${(err && err.message) || err}\n`);
|
|
138
|
+
return [];
|
|
139
|
+
}
|
|
140
|
+
return added;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Make `home` a working config home, seeding whatever is ABSENT from the shipped defaults.
|
|
145
|
+
* Idempotent and never destructive: an existing file is NEVER overwritten (a home must survive
|
|
146
|
+
* package updates - that is the whole point), bar the register gaining a new shipped tool once. home === package root -> a no-op (the git-clone
|
|
147
|
+
* layout is already complete). Throws only if the home cannot be created at all.
|
|
148
|
+
*
|
|
149
|
+
* Deliberately NOT seeded: tools.state.json (absent = default visibility), auth/ (absent = auth
|
|
150
|
+
* off), logs/ (absent = logging off), toolfunnel.json (absent = the default identity - an
|
|
151
|
+
* npm-wrap ships its OWN copy inside its bundled home).
|
|
152
|
+
*
|
|
153
|
+
* @param {string} home absolute config-home path (from resolveConfigHome)
|
|
154
|
+
* @returns {{home: string, seeded: string[], added: string[]}} the files seeded on THIS call (empty =
|
|
155
|
+
* was complete) and the shipped tool ids newly added to an existing register
|
|
156
|
+
*/
|
|
157
|
+
function ensureConfigHome(home) {
|
|
158
|
+
const seeded = [];
|
|
159
|
+
if (samePath(path.resolve(home), PKG_ROOT)) return { home: PKG_ROOT, seeded, added: [] };
|
|
160
|
+
fs.mkdirSync(home, { recursive: true });
|
|
161
|
+
|
|
162
|
+
// The register + every shipped script (management tf_* + demo tools). The management scripts
|
|
163
|
+
// are config-tree citizens (the register's invoke paths point at them), so they travel.
|
|
164
|
+
copyTreeIfAbsent(path.join(PKG_ROOT, 'tools'), path.join(home, 'tools'), seeded);
|
|
165
|
+
const added = offerNewShippedTools(home, seeded.includes(path.join(home, 'tools', 'tools.register.json')));
|
|
166
|
+
// The state overlay is user-state, not a shipped default - drop a copied one if the package
|
|
167
|
+
// tree happened to carry local toggles (dev clone); a FRESH home starts with default visibility.
|
|
168
|
+
const strayState = path.join(home, 'tools', 'tools.state.json');
|
|
169
|
+
if (seeded.includes(strayState)) {
|
|
170
|
+
try { fs.unlinkSync(strayState); } catch (_e) { /* best-effort */ }
|
|
171
|
+
seeded.splice(seeded.indexOf(strayState), 1);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// The upstream/curation store and the hooks tree. NOTE the npm tarball ships
|
|
175
|
+
// mcp/expose.example.json but NOT mcp/expose.json (the live store is user-state), so after the
|
|
176
|
+
// tree copy the live store is synthesised EMPTY when absent. Never seed it from the example -
|
|
177
|
+
// the example is a POPULATED demo (it attaches the mock upstream), and a fresh home must start
|
|
178
|
+
// connected to nothing, exactly like a fresh clone.
|
|
179
|
+
copyTreeIfAbsent(path.join(PKG_ROOT, 'mcp'), path.join(home, 'mcp'), seeded);
|
|
180
|
+
// That tarball fact does not hold for a git clone: the package tree then carries the LIVE
|
|
181
|
+
// store (mcp/expose.json) and any operator backups of it, both user-state, and the copy above
|
|
182
|
+
// brought them across - so the "synthesise EMPTY when absent" guard below was dead and a fresh
|
|
183
|
+
// home started attached to the developer's real upstreams. Same class as the tools.state.json
|
|
184
|
+
// case above, same remedy: at this level the package ships ONLY expose.example.json (see
|
|
185
|
+
// package.json "files"), so drop anything else the copy seeded here.
|
|
186
|
+
const mcpHome = path.join(home, 'mcp');
|
|
187
|
+
for (const copied of seeded.slice()) {
|
|
188
|
+
if (path.dirname(copied) === mcpHome && path.basename(copied) !== 'expose.example.json') {
|
|
189
|
+
try { fs.unlinkSync(copied); } catch (_e) { /* best-effort */ }
|
|
190
|
+
seeded.splice(seeded.indexOf(copied), 1);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
const exposePath = path.join(home, 'mcp', 'expose.json');
|
|
194
|
+
if (!fs.existsSync(exposePath)) {
|
|
195
|
+
fs.mkdirSync(path.dirname(exposePath), { recursive: true });
|
|
196
|
+
fs.writeFileSync(exposePath, JSON.stringify({ version: 1, upstreams: [], expose: [] }, null, 2) + '\n');
|
|
197
|
+
seeded.push(exposePath);
|
|
198
|
+
}
|
|
199
|
+
copyTreeIfAbsent(path.join(PKG_ROOT, 'hooks'), path.join(home, 'hooks'), seeded);
|
|
200
|
+
// hooks.state.json is user state too - the live hook toggles - same class as tools.state.json above:
|
|
201
|
+
// a git clone's copy must not travel into a fresh home, which starts from the shipped manifest's own
|
|
202
|
+
// enabled flags (config-home-hookstate.test.js).
|
|
203
|
+
const strayHookState = path.join(home, 'hooks', 'hooks.state.json');
|
|
204
|
+
if (seeded.includes(strayHookState)) {
|
|
205
|
+
try { fs.unlinkSync(strayHookState); } catch (_e) { /* best-effort */ }
|
|
206
|
+
seeded.splice(seeded.indexOf(strayHookState), 1);
|
|
207
|
+
}
|
|
208
|
+
return { home, seeded, added };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* The one-call init used by bin/toolfunnel.js for every mode: resolve, seed, and write the
|
|
213
|
+
* process-tree contract back into the env (see the header) so every module loaded AFTER this -
|
|
214
|
+
* and every child tool spawned later - reads the same resolved home.
|
|
215
|
+
* @param {{dir?: string}} [opts]
|
|
216
|
+
* @returns {{home: string, seeded: string[]}}
|
|
217
|
+
*/
|
|
218
|
+
function initConfigHome(opts) {
|
|
219
|
+
const home = resolveConfigHome(opts);
|
|
220
|
+
const res = ensureConfigHome(home);
|
|
221
|
+
process.env.TOOLFUNNEL_HOME = res.home;
|
|
222
|
+
process.env.TOOLFUNNEL_PKG = PKG_ROOT;
|
|
223
|
+
return res;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
module.exports = { PKG_ROOT, resolveConfigHome, ensureConfigHome, initConfigHome };
|