@nexusbloom/cli 0.3.4 → 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 +244 -34
- package/TESTING.md +125 -0
- package/package.json +10 -3
- package/src/abbr.js +27 -20
- package/src/commands.js +253 -0
- package/src/compile.js +69 -0
- package/src/diagnostics.js +41 -0
- package/src/index.js +330 -280
- package/src/jsonish.js +213 -0
- package/src/list.js +29 -23
- package/src/paths.js +42 -16
- package/src/pipe.js +709 -0
- package/src/pipelines.js +76 -0
- package/src/run.js +203 -100
- package/src/runner.js +89 -0
- package/src/sandbox.js +167 -0
- package/src/tools.js +177 -26
- package/src/types.js +178 -0
- package/src/ui.js +20 -0
- package/src/values.js +260 -0
- package/src/wizard.js +349 -0
package/src/sandbox.js
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sandbox.js — Execute tool source in a disposable child process.
|
|
3
|
+
*
|
|
4
|
+
* `Promise.race` cannot stop an infinite loop in the current process: the
|
|
5
|
+
* event loop never gets a turn, so the timeout never fires. A child process
|
|
6
|
+
* can simply be killed. This module is therefore the safety floor for any
|
|
7
|
+
* execution of downloaded code — single tools and pipelines alike.
|
|
8
|
+
*
|
|
9
|
+
* Guarantees:
|
|
10
|
+
* - the deadline is enforced with SIGKILL, which a tool cannot trap
|
|
11
|
+
* - the deadline starts at spawn, so a loop in the module top level (not
|
|
12
|
+
* just in coreLogic) is still killed
|
|
13
|
+
* - the child cannot write to the CLI's stdout/stderr
|
|
14
|
+
* - a crash, OOM, or `process.exit()` inside the tool does not kill the CLI
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { fork } from "node:child_process";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import { fileURLToPath } from "node:url";
|
|
20
|
+
|
|
21
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
22
|
+
const RUNNER_PATH = path.join(__dirname, "runner.js");
|
|
23
|
+
|
|
24
|
+
/** Default V8 heap ceiling for tool code, in MB. */
|
|
25
|
+
const DEFAULT_MEMORY_MB = 512;
|
|
26
|
+
|
|
27
|
+
/** Node's own boot time is not counted against the tool's timeout budget. */
|
|
28
|
+
const SPAWN_GRACE_MS = 750;
|
|
29
|
+
|
|
30
|
+
let runnerMissing = false;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Run a tool's source with the given input.
|
|
34
|
+
*
|
|
35
|
+
* @param {object} opts
|
|
36
|
+
* @param {string} opts.source v2 source text
|
|
37
|
+
* @param {object} opts.input input object passed to coreLogic
|
|
38
|
+
* @param {number} [opts.timeoutMs] wall-clock budget for the whole execution
|
|
39
|
+
* @param {number} [opts.memoryMb] V8 heap cap for the child
|
|
40
|
+
* @returns {Promise<{ok: boolean, result?: any, error?: string, timedOut?: boolean,
|
|
41
|
+
* durationMs: number, stderr?: string}>}
|
|
42
|
+
*/
|
|
43
|
+
export function execInSandbox({
|
|
44
|
+
source,
|
|
45
|
+
input = {},
|
|
46
|
+
timeoutMs = 5000,
|
|
47
|
+
memoryMb = DEFAULT_MEMORY_MB,
|
|
48
|
+
} = {}) {
|
|
49
|
+
return new Promise((resolve) => {
|
|
50
|
+
const startedAt = Date.now();
|
|
51
|
+
|
|
52
|
+
if (runnerMissing) {
|
|
53
|
+
return resolve(fail("Sandbox runner is unavailable (runner.js not found).", startedAt));
|
|
54
|
+
}
|
|
55
|
+
if (typeof source !== "string" || !source.trim()) {
|
|
56
|
+
return resolve(fail("No tool source to execute.", startedAt));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const budgetMs = Math.max(1, timeoutMs) + SPAWN_GRACE_MS;
|
|
60
|
+
let child;
|
|
61
|
+
let settled = false;
|
|
62
|
+
let timer;
|
|
63
|
+
|
|
64
|
+
const done = (payload) => {
|
|
65
|
+
if (settled) return;
|
|
66
|
+
settled = true;
|
|
67
|
+
clearTimeout(timer);
|
|
68
|
+
// Best-effort teardown; a tool may already be gone.
|
|
69
|
+
try {
|
|
70
|
+
if (child && !child.killed) child.kill("SIGKILL");
|
|
71
|
+
} catch {
|
|
72
|
+
/* already dead */
|
|
73
|
+
}
|
|
74
|
+
resolve({ durationMs: Date.now() - startedAt, ...payload });
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
child = fork(RUNNER_PATH, [], {
|
|
79
|
+
cwd: process.cwd(),
|
|
80
|
+
env: { ...process.env, NXB_SANDBOX: "1" },
|
|
81
|
+
stdio: ["ignore", "pipe", "pipe", "ipc"],
|
|
82
|
+
execArgv: [`--max-old-space-size=${memoryMb}`],
|
|
83
|
+
serialization: "advanced",
|
|
84
|
+
});
|
|
85
|
+
} catch (err) {
|
|
86
|
+
return done({ ok: false, error: `Could not start sandbox: ${err.message}` });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Tool stdout/stderr is captured, never forwarded — a tool must not be
|
|
90
|
+
// able to corrupt the CLI's output or the user's terminal.
|
|
91
|
+
let stderr = "";
|
|
92
|
+
child.stderr?.on("data", (d) => {
|
|
93
|
+
if (stderr.length < 2000) stderr += String(d);
|
|
94
|
+
});
|
|
95
|
+
child.stdout?.resume();
|
|
96
|
+
|
|
97
|
+
child.on("message", (msg) => {
|
|
98
|
+
if (!msg || msg.type !== "result") return;
|
|
99
|
+
if (msg.ok) done({ ok: true, result: revive(msg.result) });
|
|
100
|
+
else done({ ok: false, error: msg.error || "Tool execution failed.", stderr });
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// The deadline is armed immediately — before we know whether the source
|
|
104
|
+
// compiles — so a top-level infinite loop is still bounded.
|
|
105
|
+
timer = setTimeout(() => {
|
|
106
|
+
done({
|
|
107
|
+
ok: false,
|
|
108
|
+
timedOut: true,
|
|
109
|
+
error: `Timed out after ${timeoutMs}ms (tool process killed).`,
|
|
110
|
+
stderr,
|
|
111
|
+
});
|
|
112
|
+
}, budgetMs);
|
|
113
|
+
|
|
114
|
+
child.on("error", (err) => done({ ok: false, error: `Sandbox error: ${err.message}`, stderr }));
|
|
115
|
+
|
|
116
|
+
child.on("exit", (code, signal) => {
|
|
117
|
+
// Exiting without a result means the tool killed itself, or the child
|
|
118
|
+
// was OOM-killed. Distinguish the common case for a clearer message.
|
|
119
|
+
const reason =
|
|
120
|
+
stderr.includes("JavaScript heap out of memory") || stderr.includes("Allocation failed")
|
|
121
|
+
? "Tool ran out of memory."
|
|
122
|
+
: signal
|
|
123
|
+
? `Tool process terminated (${signal}).`
|
|
124
|
+
: `Tool process exited unexpectedly (code ${code}).`;
|
|
125
|
+
done({ ok: false, error: reason, stderr });
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
child.send({ type: "run", source, input });
|
|
130
|
+
} catch (err) {
|
|
131
|
+
done({ ok: false, error: `Could not send input to sandbox: ${err.message}` });
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Best-effort fallback for a CLI install with a broken/partial `src/`. */
|
|
137
|
+
function fail(message, startedAt) {
|
|
138
|
+
return Promise.resolve({ ok: false, error: message, durationMs: Date.now() - startedAt });
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Re-expand the sentinels runner.js uses for non-JSON values. */
|
|
142
|
+
function revive(value) {
|
|
143
|
+
if (value === null || typeof value !== "object") return value;
|
|
144
|
+
if (Array.isArray(value)) return value.map(revive);
|
|
145
|
+
if (value.__undefined) return undefined;
|
|
146
|
+
if (value.__nan) return NaN;
|
|
147
|
+
if (value.__inf) return value.__inf > 0 ? Infinity : -Infinity;
|
|
148
|
+
if (value.__bigint) return BigInt(value.__bigint);
|
|
149
|
+
if (value.__function) return undefined;
|
|
150
|
+
if (value.__symbol) return Symbol(value.__symbol);
|
|
151
|
+
if (value.__date) return new Date(value.__date);
|
|
152
|
+
if (value.__error) return new Error(value.__error);
|
|
153
|
+
const out = {};
|
|
154
|
+
for (const [k, v] of Object.entries(value)) out[k] = revive(v);
|
|
155
|
+
return out;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Verify the runner exists once, so failures explain themselves. */
|
|
159
|
+
export async function sandboxAvailable() {
|
|
160
|
+
if (runnerMissing) return false;
|
|
161
|
+
const fs = await import("node:fs");
|
|
162
|
+
if (!fs.existsSync(RUNNER_PATH)) {
|
|
163
|
+
runnerMissing = true;
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
return true;
|
|
167
|
+
}
|
package/src/tools.js
CHANGED
|
@@ -12,10 +12,12 @@
|
|
|
12
12
|
* 1. Local filesystem → 2. cache (`tools/<slug>.json`) → 3. API/Supabase
|
|
13
13
|
*
|
|
14
14
|
* Exports:
|
|
15
|
-
* loadConfig / saveConfig / configGet / configSet
|
|
15
|
+
* loadConfig / saveConfig / configGet / configSet / redactedConfig
|
|
16
16
|
* fetchTools() — resolves list (cache-first on network failure)
|
|
17
17
|
* refreshTools() — force a fresh fetch + persist to cache
|
|
18
18
|
* fetchToolSource(slug) — { manifest, coreLogicSource }
|
|
19
|
+
* filterTools / rankTools — shared search used by list / search / abbr / run
|
|
20
|
+
* isValidSlug / normalizeSlug
|
|
19
21
|
* scanLocalTools()
|
|
20
22
|
* findLocalTool(slug)
|
|
21
23
|
* generateAbbreviations(slugs)
|
|
@@ -26,33 +28,35 @@
|
|
|
26
28
|
|
|
27
29
|
import fs from "fs";
|
|
28
30
|
import path from "path";
|
|
29
|
-
|
|
30
|
-
import {
|
|
31
|
-
import { fileURLToPath } from "url";
|
|
31
|
+
|
|
32
|
+
import { parseJsonish } from "./jsonish.js";
|
|
32
33
|
|
|
33
34
|
import {
|
|
34
|
-
CONFIG_DIR,
|
|
35
35
|
CONFIG_PATH,
|
|
36
36
|
ensureConfigDir,
|
|
37
37
|
ensureCacheDir,
|
|
38
38
|
TOOLS_CACHE_DIR,
|
|
39
39
|
TOOLS_LIST_CACHE_PATH,
|
|
40
40
|
} from "./paths.js";
|
|
41
|
-
import chalk from "chalk";
|
|
42
|
-
|
|
43
|
-
const require = createRequire(import.meta.url);
|
|
44
41
|
|
|
45
42
|
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
46
43
|
|
|
47
44
|
export const API_BASE = process.env.NEXUSBLOOM_API_URL || "https://www.nexusbloom.dev";
|
|
48
45
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
46
|
+
/**
|
|
47
|
+
* Supabase fallback is opt-in. No key is bundled with the CLI: a shipped
|
|
48
|
+
* anon key in a public npm package is readable by anyone, and rotating it
|
|
49
|
+
* would break every installed copy. Set NEXUSBLOOM_SUPABASE_ANON_KEY (or run
|
|
50
|
+
* `nxb config set supabaseKey ...`) to enable the offline fallback.
|
|
51
|
+
*/
|
|
52
|
+
export const SUPABASE_URL_DEFAULT = "https://dhxaqatmuprehrkszxzs.supabase.co";
|
|
52
53
|
|
|
53
54
|
// Cache freshness threshold (30 min)
|
|
54
55
|
const CACHE_TTL_MS = 30 * 60 * 1000;
|
|
55
56
|
|
|
57
|
+
// Config keys whose values must never be printed or committed.
|
|
58
|
+
const SECRET_CONFIG_KEYS = new Set(["key", "apikey", "apiKey", "supabaseKey", "token"]);
|
|
59
|
+
|
|
56
60
|
// Common local-tool search roots (relative to CWD)
|
|
57
61
|
const LOCAL_TOOL_DIRS = [
|
|
58
62
|
"src/tools",
|
|
@@ -60,6 +64,24 @@ const LOCAL_TOOL_DIRS = [
|
|
|
60
64
|
"apps/main-site/frontend/src/tools",
|
|
61
65
|
];
|
|
62
66
|
|
|
67
|
+
// ─── Slug validation ─────────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
// Slugs are used as filenames (cache keys) and inside PostgREST query strings,
|
|
70
|
+
// so they must be a strict, closed character set — no path separators, no
|
|
71
|
+
// commas/parens that would let a caller rewrite the filter expression.
|
|
72
|
+
const SLUG_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
|
|
73
|
+
|
|
74
|
+
export function isValidSlug(slug) {
|
|
75
|
+
return typeof slug === "string" && SLUG_RE.test(slug);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Normalise CLI input into a safe slug, or return null if it is not one. */
|
|
79
|
+
export function normalizeSlug(slug) {
|
|
80
|
+
if (typeof slug !== "string") return null;
|
|
81
|
+
const s = slug.trim();
|
|
82
|
+
return SLUG_RE.test(s) ? s : null;
|
|
83
|
+
}
|
|
84
|
+
|
|
63
85
|
// ─── Config helpers ─────────────────────────────────────────────────────────
|
|
64
86
|
|
|
65
87
|
export function loadConfig() {
|
|
@@ -75,7 +97,14 @@ export function loadConfig() {
|
|
|
75
97
|
|
|
76
98
|
export function saveConfig(store = {}) {
|
|
77
99
|
ensureConfigDir();
|
|
78
|
-
fs.writeFileSync(CONFIG_PATH, JSON.stringify(store, null, 2), "utf-8");
|
|
100
|
+
fs.writeFileSync(CONFIG_PATH, JSON.stringify(store, null, 2), { encoding: "utf-8", mode: 0o600 });
|
|
101
|
+
// writeFileSync only applies `mode` when it creates the file — enforce it
|
|
102
|
+
// on every write so a pre-existing 0644 file gets tightened.
|
|
103
|
+
try {
|
|
104
|
+
fs.chmodSync(CONFIG_PATH, 0o600);
|
|
105
|
+
} catch {
|
|
106
|
+
/* best effort (e.g. non-POSIX fs) */
|
|
107
|
+
}
|
|
79
108
|
}
|
|
80
109
|
|
|
81
110
|
export function configGet(key) {
|
|
@@ -89,21 +118,62 @@ export function configSet(key, value) {
|
|
|
89
118
|
saveConfig(store);
|
|
90
119
|
}
|
|
91
120
|
|
|
121
|
+
/**
|
|
122
|
+
* Remove a config key. Without this there is no way to clear a stored API key
|
|
123
|
+
* from the CLI — `set` refuses an empty value, and hand-editing a 0600 file
|
|
124
|
+
* with a live secret in it is not a reasonable thing to ask of a user.
|
|
125
|
+
* @returns {boolean} true if the key was present
|
|
126
|
+
*/
|
|
127
|
+
export function configUnset(key) {
|
|
128
|
+
const store = loadConfig();
|
|
129
|
+
if (!(key in store)) return false;
|
|
130
|
+
delete store[key];
|
|
131
|
+
saveConfig(store);
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** True when a config key holds a secret that must be redacted on display. */
|
|
136
|
+
export function isSecretKey(key) {
|
|
137
|
+
return SECRET_CONFIG_KEYS.has(key) || /key|token|secret|password/i.test(key);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Config with secret values masked — safe to print or log. */
|
|
141
|
+
export function redactedConfig(store = loadConfig()) {
|
|
142
|
+
const out = {};
|
|
143
|
+
for (const [k, v] of Object.entries(store || {})) {
|
|
144
|
+
const s = v == null ? "" : String(v);
|
|
145
|
+
out[k] = isSecretKey(k)
|
|
146
|
+
? s.length <= 8
|
|
147
|
+
? "********"
|
|
148
|
+
: `${s.slice(0, 4)}…${s.slice(-4)}`
|
|
149
|
+
: v;
|
|
150
|
+
}
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
153
|
+
|
|
92
154
|
// ─── Supabase helpers ───────────────────────────────────────────────────────
|
|
93
155
|
|
|
94
156
|
export function getSupabaseKey() {
|
|
95
|
-
return process.env.NEXUSBLOOM_SUPABASE_ANON_KEY || configGet("supabaseKey") ||
|
|
157
|
+
return process.env.NEXUSBLOOM_SUPABASE_ANON_KEY || configGet("supabaseKey") || null;
|
|
96
158
|
}
|
|
97
159
|
|
|
98
160
|
export function getSupabaseUrl() {
|
|
99
|
-
return process.env.NEXUSBLOOM_SUPABASE_URL || SUPABASE_URL_DEFAULT;
|
|
161
|
+
return process.env.NEXUSBLOOM_SUPABASE_URL || configGet("supabaseUrl") || SUPABASE_URL_DEFAULT;
|
|
100
162
|
}
|
|
101
163
|
|
|
102
164
|
export function supabaseHeaders(extra = {}) {
|
|
103
165
|
const key = getSupabaseKey();
|
|
166
|
+
if (!key) return {};
|
|
104
167
|
return { apikey: key, Authorization: `Bearer ${key}`, ...extra };
|
|
105
168
|
}
|
|
106
169
|
|
|
170
|
+
/** Human-readable reason the Supabase fallback is unavailable (or null if it is). */
|
|
171
|
+
export function supabaseUnavailableReason() {
|
|
172
|
+
if (!getSupabaseKey())
|
|
173
|
+
return "Set NEXUSBLOOM_SUPABASE_ANON_KEY or run `nxb config set supabaseKey <key>`.";
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
|
|
107
177
|
// ─── Tools list cache (tools-list.json) ──────────────────────────────────────
|
|
108
178
|
|
|
109
179
|
/**
|
|
@@ -186,6 +256,7 @@ function normalizeTool(t) {
|
|
|
186
256
|
* Normalises to the same shape the API returns.
|
|
187
257
|
*/
|
|
188
258
|
async function fetchToolsFromSupabase() {
|
|
259
|
+
if (!getSupabaseKey()) return null; // no key configured — skip the round-trip
|
|
189
260
|
const headers = supabaseHeaders();
|
|
190
261
|
const supabaseUrl = getSupabaseUrl();
|
|
191
262
|
try {
|
|
@@ -290,6 +361,71 @@ function mergeLocalTools(remoteTools) {
|
|
|
290
361
|
return [...local, ...deduped];
|
|
291
362
|
}
|
|
292
363
|
|
|
364
|
+
// ─── Search ──────────────────────────────────────────────────────────────────
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Filter a tool list by a free-text query.
|
|
368
|
+
*
|
|
369
|
+
* Case-insensitive across slug, name, description, category, type and tags.
|
|
370
|
+
* A multi-word query ("json format") matches when *every* word matches at
|
|
371
|
+
* least one field, so extra words narrow the list rather than emptying it.
|
|
372
|
+
*
|
|
373
|
+
* filterTools(tools, "ssl") → tools whose slug/name/desc mentions ssl
|
|
374
|
+
* filterTools(tools, "json pd") → tools matching both "json" and "pd"
|
|
375
|
+
*/
|
|
376
|
+
export function filterTools(tools, query) {
|
|
377
|
+
const list = Array.isArray(tools) ? tools : [];
|
|
378
|
+
const q = (query || "").trim().toLowerCase();
|
|
379
|
+
if (!q) return list;
|
|
380
|
+
|
|
381
|
+
const words = q.split(/\s+/).filter(Boolean);
|
|
382
|
+
const haystack = (t) =>
|
|
383
|
+
[
|
|
384
|
+
t.slug,
|
|
385
|
+
t.name,
|
|
386
|
+
t.short_description,
|
|
387
|
+
t.description,
|
|
388
|
+
t.category,
|
|
389
|
+
t.type,
|
|
390
|
+
t.runtime,
|
|
391
|
+
...(Array.isArray(t.tags) ? t.tags : []),
|
|
392
|
+
]
|
|
393
|
+
.filter(Boolean)
|
|
394
|
+
.join(" ")
|
|
395
|
+
.toLowerCase();
|
|
396
|
+
|
|
397
|
+
return list.filter((t) => {
|
|
398
|
+
const hay = haystack(t);
|
|
399
|
+
return words.every((w) => hay.includes(w));
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Score + sort tools for a query so the closest match is first.
|
|
405
|
+
* Exact slug > slug prefix > slug contains > name/description contains.
|
|
406
|
+
*/
|
|
407
|
+
export function rankTools(tools, query) {
|
|
408
|
+
const q = (query || "").trim().toLowerCase();
|
|
409
|
+
if (!q) return Array.isArray(tools) ? tools : [];
|
|
410
|
+
|
|
411
|
+
const score = (t) => {
|
|
412
|
+
const slug = (t.slug || "").toLowerCase();
|
|
413
|
+
const name = (t.name || "").toLowerCase();
|
|
414
|
+
if (slug === q) return 0;
|
|
415
|
+
if (name === q) return 1;
|
|
416
|
+
if (slug.startsWith(q)) return 2;
|
|
417
|
+
if (slug.includes(q)) return 3;
|
|
418
|
+
if (name.startsWith(q)) return 4;
|
|
419
|
+
if (name.includes(q)) return 5;
|
|
420
|
+
return 6;
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
return filterTools(tools, q)
|
|
424
|
+
.map((t, i) => ({ t, i, s: score(t) }))
|
|
425
|
+
.sort((a, b) => a.s - b.s || a.i - b.i)
|
|
426
|
+
.map(({ t }) => t);
|
|
427
|
+
}
|
|
428
|
+
|
|
293
429
|
// ─── Single tool source (for --local execution) ──────────────────────────────
|
|
294
430
|
|
|
295
431
|
/**
|
|
@@ -297,7 +433,10 @@ function mergeLocalTools(remoteTools) {
|
|
|
297
433
|
*
|
|
298
434
|
* 1. Local filesystem → 2. per-tool cache → 3. API → 4. Supabase
|
|
299
435
|
*/
|
|
300
|
-
export async function fetchToolSource(
|
|
436
|
+
export async function fetchToolSource(rawSlug) {
|
|
437
|
+
const slug = normalizeSlug(rawSlug);
|
|
438
|
+
if (!slug) return null;
|
|
439
|
+
|
|
301
440
|
ensureCacheDir();
|
|
302
441
|
const cacheFile = path.join(TOOLS_CACHE_DIR, `${slug}.json`);
|
|
303
442
|
|
|
@@ -305,7 +444,7 @@ export async function fetchToolSource(slug) {
|
|
|
305
444
|
const localPath = findLocalTool(slug);
|
|
306
445
|
if (localPath) {
|
|
307
446
|
const source = fs.readFileSync(localPath, "utf-8");
|
|
308
|
-
const manifest = extractManifest(source);
|
|
447
|
+
const manifest = extractManifest(source, { trusted: true });
|
|
309
448
|
if (manifest) {
|
|
310
449
|
// Refresh cache from local source
|
|
311
450
|
fs.writeFileSync(cacheFile, JSON.stringify({ manifest, coreLogicSource: source, cachedAt: new Date().toISOString() }), "utf-8");
|
|
@@ -361,6 +500,7 @@ export async function fetchToolSource(slug) {
|
|
|
361
500
|
* Returns { manifest, coreLogicSource } or null.
|
|
362
501
|
*/
|
|
363
502
|
export async function fetchToolFromSupabase(slug) {
|
|
503
|
+
if (!getSupabaseKey()) return null;
|
|
364
504
|
const headers = supabaseHeaders();
|
|
365
505
|
const supabaseUrl = getSupabaseUrl();
|
|
366
506
|
try {
|
|
@@ -404,22 +544,33 @@ export async function fetchToolFromSupabase(slug) {
|
|
|
404
544
|
// ─── Manifest extraction ────────────────────────────────────────────────────
|
|
405
545
|
|
|
406
546
|
/**
|
|
407
|
-
* Extract
|
|
408
|
-
*
|
|
547
|
+
* Extract the MANIFEST object literal from v2 source text.
|
|
548
|
+
*
|
|
549
|
+
* The literal is parsed, never evaluated: manifest text fetched from the API or
|
|
550
|
+
* Supabase is untrusted input, and `new Function` on it would execute it.
|
|
551
|
+
* `trusted: true` is passed only for files read off the developer's own disk.
|
|
552
|
+
*
|
|
553
|
+
* @param {string} source
|
|
554
|
+
* @param {{trusted?: boolean}} [opts]
|
|
555
|
+
* @returns {object|null}
|
|
409
556
|
*/
|
|
410
|
-
export function extractManifest(source) {
|
|
557
|
+
export function extractManifest(source, { trusted = false } = {}) {
|
|
411
558
|
if (!source) return null;
|
|
412
559
|
const match = source.match(/export\s+const\s+MANIFEST\s*=\s*({[\s\S]*?});/);
|
|
413
560
|
if (!match) return null;
|
|
561
|
+
|
|
562
|
+
const parsed = parseJsonish(match[1]);
|
|
563
|
+
if (parsed && typeof parsed === "object") return parsed;
|
|
564
|
+
if (!trusted) return null;
|
|
565
|
+
|
|
566
|
+
// Developer-owned file that used syntax the literal parser does not accept.
|
|
567
|
+
// Only reached for local src/tools — never for network content.
|
|
414
568
|
try {
|
|
415
569
|
// eslint-disable-next-line no-new-func
|
|
416
|
-
|
|
570
|
+
const value = new Function(`return ${match[1]}`)();
|
|
571
|
+
return value && typeof value === "object" ? value : null;
|
|
417
572
|
} catch {
|
|
418
|
-
|
|
419
|
-
return JSON.parse(match[1].replace(/(\w+):/g, '"$1":').replace(/'/g, '"'));
|
|
420
|
-
} catch {
|
|
421
|
-
return null;
|
|
422
|
-
}
|
|
573
|
+
return null;
|
|
423
574
|
}
|
|
424
575
|
}
|
|
425
576
|
|
|
@@ -472,7 +623,7 @@ export function scanLocalTools() {
|
|
|
472
623
|
if (!fs.existsSync(v2Path)) continue;
|
|
473
624
|
try {
|
|
474
625
|
const source = fs.readFileSync(v2Path, "utf-8");
|
|
475
|
-
const manifest = extractManifest(source);
|
|
626
|
+
const manifest = extractManifest(source, { trusted: true });
|
|
476
627
|
if (!manifest) continue;
|
|
477
628
|
tools.push({
|
|
478
629
|
slug: manifest.slug || entry.name,
|
package/src/types.js
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* types.js — Type inference and the compatibility rules for pipeline wiring.
|
|
3
|
+
*
|
|
4
|
+
* The rule the whole pipeline design rests on: conversions that cannot lose
|
|
5
|
+
* information happen silently, conversions that can lose information require an
|
|
6
|
+
* explicit transform. That is what "keep types in mind" means in practice —
|
|
7
|
+
* `$max → count` (integer → integer) just works, `$text → count` does not.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Runtime type of a concrete value. */
|
|
11
|
+
export function inferType(value) {
|
|
12
|
+
if (value === null || value === undefined) return "null";
|
|
13
|
+
if (Array.isArray(value)) return "array";
|
|
14
|
+
const t = typeof value;
|
|
15
|
+
if (t === "number") return Number.isInteger(value) ? "integer" : "number";
|
|
16
|
+
if (t === "string") return "string";
|
|
17
|
+
if (t === "boolean") return "boolean";
|
|
18
|
+
if (t === "object") return "object";
|
|
19
|
+
return t;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Normalise a JSON-schema `type` field into a list of type names. */
|
|
23
|
+
export function schemaTypes(prop) {
|
|
24
|
+
if (!prop || typeof prop !== "object") return [];
|
|
25
|
+
const t = prop.type;
|
|
26
|
+
if (Array.isArray(t)) return t.filter((x) => typeof x === "string");
|
|
27
|
+
if (typeof t === "string") return [t];
|
|
28
|
+
if (t === undefined && prop.properties) return ["object"];
|
|
29
|
+
if (t === undefined && prop.items) return ["array"];
|
|
30
|
+
return [];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Human label for display, e.g. `integer`, `string`, `string|number`. */
|
|
34
|
+
export function typeLabel(types) {
|
|
35
|
+
const list = Array.isArray(types) ? types : [types];
|
|
36
|
+
if (!list.length) return "any";
|
|
37
|
+
return list.join("|");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Ordered by widening: an integer satisfies a number target. */
|
|
41
|
+
const WIDENS = { integer: ["number"] };
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Decide whether a value of `fromType` may feed a schema field accepting
|
|
45
|
+
* `toTypes`, ignoring transforms (the caller applies those first).
|
|
46
|
+
*
|
|
47
|
+
* @returns {{level: "ok"|"lossy"|"cast"|"invalid", note: string, suggestion?: string}}
|
|
48
|
+
*/
|
|
49
|
+
export function compatibility(fromType, toTypes) {
|
|
50
|
+
const targets = Array.isArray(toTypes) ? toTypes : [toTypes];
|
|
51
|
+
const from = Array.isArray(fromType) ? fromType[0] : fromType;
|
|
52
|
+
|
|
53
|
+
if (!targets.length) return { level: "ok", note: "untyped target" };
|
|
54
|
+
if (!from || from === "null" || from === "unknown") {
|
|
55
|
+
return { level: "cast", note: "source has no fixed type", suggestion: "default()" };
|
|
56
|
+
}
|
|
57
|
+
if (targets.includes(from)) return { level: "ok", note: "exact match" };
|
|
58
|
+
|
|
59
|
+
// Lossless widening: integer into number.
|
|
60
|
+
for (const w of WIDENS[from] || []) {
|
|
61
|
+
if (targets.includes(w)) return { level: "ok", note: `${from} widens to ${w}` };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Lossy narrowing: number into integer.
|
|
65
|
+
if (from === "number" && targets.includes("integer")) {
|
|
66
|
+
return { level: "lossy", note: "may lose the fraction", suggestion: "int()" };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Everything renders to a string, and nothing is lost in the text.
|
|
70
|
+
if (targets.includes("string") && ["number", "integer", "boolean"].includes(from)) {
|
|
71
|
+
return { level: "ok", note: `rendered as string` };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// String into a typed field needs a deliberate cast.
|
|
75
|
+
if (from === "string" && targets.some((t) => ["integer", "number"].includes(t))) {
|
|
76
|
+
return { level: "cast", note: "string is not a number", suggestion: targets.includes("integer") ? "int()" : "float()" };
|
|
77
|
+
}
|
|
78
|
+
if (from === "string" && targets.includes("boolean")) {
|
|
79
|
+
return { level: "cast", note: "string is not a boolean", suggestion: "bool()" };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Collections must be reduced before they can be scalars.
|
|
83
|
+
if (from === "array" && targets.some((t) => t !== "array")) {
|
|
84
|
+
return { level: "cast", note: "array used as a single value", suggestion: "first()" };
|
|
85
|
+
}
|
|
86
|
+
if (from === "object" && targets.some((t) => t !== "object")) {
|
|
87
|
+
return { level: "cast", note: "object used as a single value", suggestion: "pick(field)" };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
level: "invalid",
|
|
92
|
+
note: `${from} is not accepted by ${typeLabel(targets)}`,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Runtime check of a value against a schema field. Enums are enforced here,
|
|
98
|
+
* because a declared enum is the one place a "compatible" type can still be
|
|
99
|
+
* wrong at runtime.
|
|
100
|
+
*
|
|
101
|
+
* @returns {{ok: boolean, error?: string, suggestion?: string}}
|
|
102
|
+
*/
|
|
103
|
+
export function validateValue(value, prop) {
|
|
104
|
+
const targets = schemaTypes(prop);
|
|
105
|
+
const enumValues = Array.isArray(prop?.enum) ? prop.enum : null;
|
|
106
|
+
|
|
107
|
+
// Absent is not the same as null: report it as a missing value so the fix
|
|
108
|
+
// points at supplying one rather than at a type mismatch.
|
|
109
|
+
if (value === undefined) {
|
|
110
|
+
return { ok: false, error: "no value was supplied", suggestion: "add |default(...)" };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const actual = inferType(value);
|
|
114
|
+
|
|
115
|
+
if (value === null) {
|
|
116
|
+
// JSON-Schema "null" only when explicitly allowed; otherwise treated as missing.
|
|
117
|
+
if (targets.includes("null")) return { ok: true };
|
|
118
|
+
return { ok: false, error: `expected ${typeLabel(targets)}, got null`, suggestion: "default()" };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (enumValues && !enumValues.includes(value)) {
|
|
122
|
+
return {
|
|
123
|
+
ok: false,
|
|
124
|
+
error: `${JSON.stringify(value)} is not one of: ${enumValues.map((v) => JSON.stringify(v)).join(", ")}`,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (!targets.length) return { ok: true };
|
|
129
|
+
|
|
130
|
+
const widened = actual === "integer" && targets.includes("number");
|
|
131
|
+
if (targets.includes(actual) || widened) return { ok: true };
|
|
132
|
+
|
|
133
|
+
const hint =
|
|
134
|
+
actual === "string" && (targets.includes("integer") || targets.includes("number"))
|
|
135
|
+
? targets.includes("integer") ? "add |int()" : "add |float()"
|
|
136
|
+
: actual === "array" ? "add |first()"
|
|
137
|
+
: actual === "number" && targets.includes("integer") ? "add |int()"
|
|
138
|
+
: null;
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
ok: false,
|
|
142
|
+
error: `expected ${typeLabel(targets)}, got ${actual}`,
|
|
143
|
+
suggestion: hint,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Validate a whole input object against a tool's `input_schema`.
|
|
149
|
+
* Missing required fields are reported too, so a pipeline can fail before it
|
|
150
|
+
* executes rather than on step 3.
|
|
151
|
+
*
|
|
152
|
+
* @returns {Array<{field: string, error: string, suggestion?: string}>}
|
|
153
|
+
*/
|
|
154
|
+
export function validateInput(input, inputSchema) {
|
|
155
|
+
const problems = [];
|
|
156
|
+
if (!inputSchema || typeof inputSchema !== "object") return problems;
|
|
157
|
+
|
|
158
|
+
const props = inputSchema.properties || {};
|
|
159
|
+
const required = Array.isArray(inputSchema.required) ? inputSchema.required : [];
|
|
160
|
+
|
|
161
|
+
for (const field of required) {
|
|
162
|
+
const v = input?.[field];
|
|
163
|
+
// `false` and `0` are legitimate values, so only absent/null count as missing.
|
|
164
|
+
if (v === undefined || v === null) {
|
|
165
|
+
problems.push({ field, error: "required field is missing" });
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
for (const [field, value] of Object.entries(input || {})) {
|
|
170
|
+
if (!props[field]) continue; // extra fields are allowed by many tools
|
|
171
|
+
const check = validateValue(value, props[field]);
|
|
172
|
+
if (!check.ok) {
|
|
173
|
+
problems.push({ field, error: check.error, suggestion: check.suggestion });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return problems;
|
|
178
|
+
}
|