@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/pipelines.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pipelines.js — Named pipelines stored as plain files.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately not a database: pipelines are small, shareable, diffable text,
|
|
5
|
+
* so they live in the user's config directory where git and rsync reach them.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fs from "fs";
|
|
9
|
+
import path from "path";
|
|
10
|
+
|
|
11
|
+
import { PIPELINES_DIR, ensurePipelinesDir } from "./paths.js";
|
|
12
|
+
|
|
13
|
+
const SAFE_NAME = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/;
|
|
14
|
+
|
|
15
|
+
export function pipelinePath(name) {
|
|
16
|
+
// Require a string outright rather than coercing: `["ok"]` and `123` would
|
|
17
|
+
// otherwise pass the charset check by way of String(), so the guard would be
|
|
18
|
+
// a string check pretending to be a type check. Coercion is not exploitable
|
|
19
|
+
// here — the coerced value is what gets validated *and* what builds the path
|
|
20
|
+
// — but accepting a non-string here means a caller's type mistake surfaces as
|
|
21
|
+
// a confusingly-named file instead of an error.
|
|
22
|
+
if (typeof name !== "string" || !SAFE_NAME.test(name)) {
|
|
23
|
+
throw new Error(`Invalid pipeline name "${String(name)}". Use letters, digits, dots, dashes and underscores.`);
|
|
24
|
+
}
|
|
25
|
+
return path.join(PIPELINES_DIR, `${name}.json`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Write a pipeline definition. Returns the file path. */
|
|
29
|
+
export function savePipeline(name, definition) {
|
|
30
|
+
ensurePipelinesDir();
|
|
31
|
+
const file = pipelinePath(name);
|
|
32
|
+
const record = { name, ...definition, savedAt: new Date().toISOString() };
|
|
33
|
+
fs.writeFileSync(file, `${JSON.stringify(record, null, 2)}\n`, { encoding: "utf-8", mode: 0o600 });
|
|
34
|
+
return file;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function loadPipeline(name) {
|
|
38
|
+
const file = pipelinePath(name);
|
|
39
|
+
if (!fs.existsSync(file)) return null;
|
|
40
|
+
try {
|
|
41
|
+
return JSON.parse(fs.readFileSync(file, "utf-8"));
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function listPipelines() {
|
|
48
|
+
if (!fs.existsSync(PIPELINES_DIR)) return [];
|
|
49
|
+
return fs
|
|
50
|
+
.readdirSync(PIPELINES_DIR)
|
|
51
|
+
.filter((f) => f.endsWith(".json"))
|
|
52
|
+
.map((f) => f.replace(/\.json$/, ""))
|
|
53
|
+
// The directory is shared with anything the user drops there. A stray
|
|
54
|
+
// `.json` whose stem is not a legal name (a dotfile, a partial download)
|
|
55
|
+
// must be skipped, not allowed to make `nxb pipeline list` throw.
|
|
56
|
+
.filter((name) => {
|
|
57
|
+
try {
|
|
58
|
+
pipelinePath(name);
|
|
59
|
+
return true;
|
|
60
|
+
} catch {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
})
|
|
64
|
+
.map((name) => {
|
|
65
|
+
const def = loadPipeline(name);
|
|
66
|
+
return { name, pipeline: def?.pipeline || "", steps: def?.steps?.length || 0, savedAt: def?.savedAt };
|
|
67
|
+
})
|
|
68
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function deletePipeline(name) {
|
|
72
|
+
const file = pipelinePath(name);
|
|
73
|
+
if (!fs.existsSync(file)) return false;
|
|
74
|
+
fs.unlinkSync(file);
|
|
75
|
+
return true;
|
|
76
|
+
}
|
package/src/run.js
CHANGED
|
@@ -14,10 +14,14 @@ import path from "path";
|
|
|
14
14
|
import chalk from "chalk";
|
|
15
15
|
import QRCode from "qrcode";
|
|
16
16
|
|
|
17
|
+
import { execInSandbox } from "./sandbox.js";
|
|
18
|
+
import { validateInput } from "./types.js";
|
|
19
|
+
|
|
17
20
|
import {
|
|
18
21
|
fetchTools,
|
|
19
22
|
fetchToolSource,
|
|
20
23
|
resolveToolSlug,
|
|
24
|
+
filterTools,
|
|
21
25
|
configGet,
|
|
22
26
|
API_BASE,
|
|
23
27
|
readStdin,
|
|
@@ -30,18 +34,43 @@ import {
|
|
|
30
34
|
promptInput,
|
|
31
35
|
promptConfirm,
|
|
32
36
|
promptNumber,
|
|
37
|
+
isInteractive,
|
|
38
|
+
PAGE_SIZE,
|
|
33
39
|
} from "./ui.js";
|
|
34
40
|
|
|
41
|
+
// ─── Telemetry ────────────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Fire-and-forget run counter. Opt out with `nxb config set telemetry false`
|
|
45
|
+
* or NEXUSBLOOM_TELEMETRY=false. Only the slug is ever sent — never the input
|
|
46
|
+
* payload, the API key, or the result.
|
|
47
|
+
*/
|
|
48
|
+
function trackRun(slug, source) {
|
|
49
|
+
const enabled = process.env.NEXUSBLOOM_TELEMETRY || configGet("telemetry");
|
|
50
|
+
if (String(enabled).toLowerCase() === "false") return;
|
|
51
|
+
const headers = { "Content-Type": "application/json" };
|
|
52
|
+
const apiKey = process.env.NEXUSBLOOM_API_KEY || configGet("apiKey") || configGet("key");
|
|
53
|
+
if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
|
|
54
|
+
// AbortSignal keeps the fire-and-forget request from holding the event
|
|
55
|
+
// loop open — an unbounded pending fetch delays process exit.
|
|
56
|
+
fetch(`${API_BASE}/api/track-run`, {
|
|
57
|
+
method: "POST",
|
|
58
|
+
headers,
|
|
59
|
+
body: JSON.stringify({ slug, source }),
|
|
60
|
+
signal: AbortSignal.timeout(1500),
|
|
61
|
+
}).catch(() => {});
|
|
62
|
+
}
|
|
63
|
+
|
|
35
64
|
// ─── Remote execution ────────────────────────────────────────────────────────
|
|
36
65
|
|
|
37
|
-
export async function runRemote(slug, body, { apiKey, jsonMode, slugResolved }) {
|
|
66
|
+
export async function runRemote(slug, body, { apiKey, jsonMode, slugResolved, quiet } = {}) {
|
|
38
67
|
const key = apiKey || process.env.NEXUSBLOOM_API_KEY || configGet("apiKey") || configGet("key");
|
|
39
68
|
if (!key) {
|
|
40
|
-
if (!
|
|
41
|
-
return runLocal(slug, body, { jsonMode, slugResolved });
|
|
69
|
+
if (!quiet) log(chalk.dim(" No API key — falling back to local execution..."));
|
|
70
|
+
return runLocal(slug, body, { jsonMode, slugResolved, quiet });
|
|
42
71
|
}
|
|
43
72
|
|
|
44
|
-
if (!
|
|
73
|
+
if (!quiet) log(chalk.dim(` Running ${slug} remotely...`));
|
|
45
74
|
|
|
46
75
|
try {
|
|
47
76
|
const res = await fetch(`${API_BASE}/api/run/${slug}`, {
|
|
@@ -51,74 +80,80 @@ export async function runRemote(slug, body, { apiKey, jsonMode, slugResolved })
|
|
|
51
80
|
});
|
|
52
81
|
|
|
53
82
|
if (!res.ok && !res.headers.get("content-type")?.includes("json")) {
|
|
54
|
-
if (!
|
|
55
|
-
return runLocal(slug, body, { jsonMode, slugResolved });
|
|
83
|
+
if (!quiet) log(chalk.dim(" API not deployed — falling back to local"));
|
|
84
|
+
return runLocal(slug, body, { jsonMode, slugResolved, quiet });
|
|
56
85
|
}
|
|
57
86
|
|
|
58
87
|
const data = await res.json();
|
|
59
88
|
|
|
60
|
-
if (data.success) {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
return;
|
|
89
|
+
if (!data.success) {
|
|
90
|
+
return reportError(jsonMode, data.error || "Unknown error");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const result = data.data;
|
|
94
|
+
if (!quiet) log(chalk.green(` ✓ ${slug} completed`));
|
|
95
|
+
if (result && typeof result === "object" && result.svg) {
|
|
96
|
+
await renderQRCode(result.data || result.text || JSON.stringify(result), slug);
|
|
69
97
|
}
|
|
70
98
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
99
|
+
trackRun(slug, "remote");
|
|
100
|
+
|
|
101
|
+
if (jsonMode) {
|
|
102
|
+
console.log(JSON.stringify({ success: true, data: result }, null, 2));
|
|
103
|
+
} else {
|
|
104
|
+
log(chalk.green("\n Result:"));
|
|
105
|
+
log(` ${JSON.stringify(result, null, 2)}`);
|
|
106
|
+
}
|
|
107
|
+
return { ok: true, result };
|
|
108
|
+
} catch {
|
|
109
|
+
if (!quiet) log(chalk.dim(" API unreachable — falling back to local"));
|
|
110
|
+
return runLocal(slug, body, { jsonMode, slugResolved, quiet });
|
|
78
111
|
}
|
|
79
112
|
}
|
|
80
113
|
|
|
81
114
|
// ─── Local execution ────────────────────────────────────────────────────────
|
|
82
115
|
|
|
83
|
-
export async function runLocal(slug, input, { jsonMode, slugResolved,
|
|
116
|
+
export async function runLocal(slug, input, { jsonMode, slugResolved, quiet } = {}) {
|
|
84
117
|
const toolInfo = await fetchToolSource(slug);
|
|
85
118
|
if (!toolInfo) {
|
|
86
|
-
|
|
87
|
-
console.error(chalk.red(`Tool "${slug}" not found. Set NEXUSBLOOM_SUPABASE_ANON_KEY or deploy the API.`));
|
|
88
|
-
process.exitCode = 1;
|
|
89
|
-
return;
|
|
119
|
+
return reportError(jsonMode, `Tool "${slug}" not found. Run \`nxb search ${slug}\` to see available tools.`);
|
|
90
120
|
}
|
|
91
121
|
|
|
92
122
|
const { manifest, coreLogicSource } = toolInfo;
|
|
123
|
+
// Where the code came from: "local" | "cache" | "api" | "supabase".
|
|
124
|
+
const src = toolInfo.source;
|
|
125
|
+
|
|
93
126
|
if (!coreLogicSource) {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
return;
|
|
127
|
+
return reportError(
|
|
128
|
+
jsonMode,
|
|
129
|
+
`No runnable source published for "${slug}" — try \`nxb run ${slug}\` without --local.`
|
|
130
|
+
);
|
|
99
131
|
}
|
|
100
132
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
133
|
+
// All human-facing output goes to stderr so `--json` and `transform` piping
|
|
134
|
+
// get clean stdout.
|
|
135
|
+
if (!quiet) {
|
|
136
|
+
log(` Using ${slugResolved ? "local" : src || "cached"} tool: ${slug}`);
|
|
137
|
+
if (src && src !== "local") {
|
|
138
|
+
log(
|
|
139
|
+
chalk.yellow(
|
|
140
|
+
` ⚠ About to run ${src === "cache" ? "previously downloaded" : "downloaded"} code for "${slug}" on this machine.`
|
|
141
|
+
)
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
110
145
|
|
|
111
146
|
const schema = manifest.input_schema || {};
|
|
112
147
|
const required = schema.required || [];
|
|
113
148
|
const missing = required.filter((k) => input[k] === undefined || input[k] === null);
|
|
114
149
|
if (missing.length > 0) {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
return;
|
|
150
|
+
const message = `Missing required fields: ${missing.join(", ")}`;
|
|
151
|
+
// A quiet caller reports this itself; doing it here too would print the
|
|
152
|
+
// identical line twice in a row.
|
|
153
|
+
if (quiet) return { ok: false, error: message };
|
|
154
|
+
return reportError(jsonMode, message);
|
|
119
155
|
}
|
|
120
156
|
|
|
121
|
-
// Apply defaults
|
|
122
157
|
if (schema.properties) {
|
|
123
158
|
for (const [key, prop] of Object.entries(schema.properties)) {
|
|
124
159
|
if (input[key] === undefined && prop.default !== undefined) {
|
|
@@ -127,36 +162,58 @@ export async function runLocal(slug, input, { jsonMode, slugResolved, source: sr
|
|
|
127
162
|
}
|
|
128
163
|
}
|
|
129
164
|
|
|
165
|
+
// Type-check against the declared schema before spending a process on it.
|
|
166
|
+
const typeProblems = validateInput(input, schema);
|
|
167
|
+
if (typeProblems.length) {
|
|
168
|
+
const detail = typeProblems
|
|
169
|
+
.map((p) => `${p.field}: ${p.error}${p.suggestion ? ` (${p.suggestion})` : ""}`)
|
|
170
|
+
.join("; ");
|
|
171
|
+
return reportError(jsonMode, `Invalid input for "${slug}" — ${detail}`);
|
|
172
|
+
}
|
|
173
|
+
|
|
130
174
|
const timeoutMs = manifest.timeout_ms || 5000;
|
|
131
|
-
|
|
175
|
+
if (!quiet) log(chalk.dim(` Executing ${slug} in sandbox (${timeoutMs}ms budget)...`));
|
|
132
176
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
177
|
+
// Execution happens in a child process: a tool that loops forever, allocates
|
|
178
|
+
// without bound, or calls process.exit() cannot take the CLI with it.
|
|
179
|
+
const res = await execInSandbox({ source: coreLogicSource, input, timeoutMs });
|
|
180
|
+
|
|
181
|
+
if (!res.ok) {
|
|
182
|
+
return reportError(jsonMode, `Execution failed for "${slug}": ${res.error}`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (!quiet) log(chalk.green(` ✓ ${slug} completed`));
|
|
186
|
+
trackRun(slug, "cli");
|
|
187
|
+
|
|
188
|
+
// `quiet` means the caller renders the result itself. `transform` relies on
|
|
189
|
+
// this: it owns stdout, because its whole purpose is to be piped. Printing
|
|
190
|
+
// here as well would put the payload on stderr and stdout both, and a
|
|
191
|
+
// `transform | jq` would see it twice.
|
|
192
|
+
if (!quiet) {
|
|
193
|
+
if (jsonMode) {
|
|
194
|
+
console.log(JSON.stringify({ success: true, data: res.result }, null, 2));
|
|
140
195
|
} else {
|
|
141
|
-
|
|
196
|
+
log(chalk.green("\n Result (local):"));
|
|
197
|
+
log(` ${JSON.stringify(res.result, null, 2)}`);
|
|
142
198
|
}
|
|
143
|
-
if (!jsonMode) console.log(chalk.green(` ✓ ${slug} completed`));
|
|
144
|
-
|
|
145
|
-
// Fire-and-forget telemetry
|
|
146
|
-
const apiKey = process.env.NEXUSBLOOM_API_KEY || configGet("apiKey") || configGet("key");
|
|
147
|
-
const trackHeaders = { "Content-Type": "application/json" };
|
|
148
|
-
if (apiKey) trackHeaders["Authorization"] = `Bearer ${apiKey}`;
|
|
149
|
-
fetch(`${API_BASE}/api/track-run`, { method: "POST", headers: trackHeaders, body: JSON.stringify({ slug, source: "cli" }) }).catch(() => {});
|
|
150
|
-
|
|
151
|
-
if (jsonMode) return console.log(JSON.stringify({ success: true, data: result }, null, 2));
|
|
152
|
-
console.log(chalk.green("\n Result (local):"));
|
|
153
|
-
console.log(` ${JSON.stringify(result, null, 2)}`);
|
|
154
|
-
} catch (err) {
|
|
155
|
-
if (!jsonMode) console.log(chalk.red(` ✗ Execution error`));
|
|
156
|
-
if (jsonMode) return console.log(JSON.stringify({ error: err.message }));
|
|
157
|
-
console.error(chalk.red(`\n Local execution error: ${err.message}`));
|
|
158
|
-
process.exitCode = 1;
|
|
159
199
|
}
|
|
200
|
+
return { ok: true, result: res.result, durationMs: res.durationMs };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Print an error on stderr (or as JSON) and set a non-zero exit code. */
|
|
204
|
+
export function reportError(jsonMode, message) {
|
|
205
|
+
if (jsonMode) {
|
|
206
|
+
console.log(JSON.stringify({ success: false, error: message }));
|
|
207
|
+
} else {
|
|
208
|
+
console.error(chalk.red(` ✗ ${message}`));
|
|
209
|
+
}
|
|
210
|
+
process.exitCode = 1;
|
|
211
|
+
return { ok: false, error: message };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Progress output belongs on stderr so stdout stays machine-readable. */
|
|
215
|
+
function log(line) {
|
|
216
|
+
console.error(line);
|
|
160
217
|
}
|
|
161
218
|
|
|
162
219
|
// ─── Input building ─────────────────────────────────────────────────────────
|
|
@@ -221,9 +278,9 @@ export async function buildInputBody(opts, args) {
|
|
|
221
278
|
async function renderQRCode(data, slug) {
|
|
222
279
|
try {
|
|
223
280
|
const qr = await QRCode.toString(data, { type: "terminal", small: true });
|
|
224
|
-
console.
|
|
225
|
-
console.
|
|
226
|
-
console.
|
|
281
|
+
console.error(chalk.cyan("\n QR Code:"));
|
|
282
|
+
console.error(qr);
|
|
283
|
+
console.error(chalk.dim(` Data: ${data.slice(0, 80)}${data.length > 80 ? "..." : ""}`));
|
|
227
284
|
} catch {
|
|
228
285
|
/* silently skip */
|
|
229
286
|
}
|
|
@@ -234,20 +291,72 @@ async function renderQRCode(data, slug) {
|
|
|
234
291
|
/**
|
|
235
292
|
* `nxb run` with no slug: interactively select a tool and its inputs,
|
|
236
293
|
* then auto-build & execute the exact command.
|
|
294
|
+
*
|
|
295
|
+
* With `-s/--search`, the list is filtered first (search matches slug, name,
|
|
296
|
+
* description, category and abbreviation). Without a TTY there is nothing to
|
|
297
|
+
* prompt with, so the tool list is printed as a plain searchable dump and the
|
|
298
|
+
* command exits — it never hangs a pipe or a CI job.
|
|
237
299
|
*/
|
|
238
300
|
export async function runInteractive(opts = {}) {
|
|
239
|
-
const { json: jsonMode } = opts;
|
|
240
|
-
const tools = await fetchTools({ offline:
|
|
301
|
+
const { json: jsonMode, search } = opts;
|
|
302
|
+
const tools = await fetchTools({ offline: false });
|
|
241
303
|
|
|
242
304
|
if (!tools || tools.length === 0) {
|
|
243
|
-
console.log(chalk.yellow(" No tools available.
|
|
305
|
+
console.log(chalk.yellow(" No tools available. Check your connection, or run `nxb cache list`."));
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// ── Non-interactive: print the (optionally filtered) list and stop ──
|
|
310
|
+
if (!isInteractive()) {
|
|
311
|
+
const filtered = search ? filterTools(tools, search) : tools;
|
|
312
|
+
|
|
313
|
+
if (jsonMode) {
|
|
314
|
+
console.log(JSON.stringify({ tools: filtered, ...(search ? { query: search } : {}) }, null, 2));
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (filtered.length === 0) {
|
|
319
|
+
console.log(chalk.yellow(` No tools matching "${search}".`));
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
console.log(chalk.bold(`\n Available tools (${filtered.length})${search ? ` matching "${search}"` : ""}\n`));
|
|
324
|
+
for (const t of filtered) {
|
|
325
|
+
const desc = (t.short_description || t.description || "").slice(0, 60);
|
|
326
|
+
console.log(` ${(t.icon || "🔧")} ${chalk.cyan((t.slug || "").padEnd(30))} ${chalk.dim(desc)}`);
|
|
327
|
+
}
|
|
328
|
+
console.log(chalk.dim(`\n Run a tool with: nxb run <slug> [key=value ...]`));
|
|
329
|
+
console.log(chalk.dim(` Narrow the list: nxb run -s <query> · nxb search <query>`));
|
|
330
|
+
console.log();
|
|
244
331
|
return;
|
|
245
332
|
}
|
|
246
333
|
|
|
247
|
-
|
|
334
|
+
// ── Interactive: optional search, then the tool picker ──
|
|
335
|
+
let pool = tools;
|
|
336
|
+
let query = search;
|
|
337
|
+
|
|
338
|
+
// Long catalogue + no explicit query → offer search before the picker,
|
|
339
|
+
// since scrolling a plain select through hundreds of tools is unusable.
|
|
340
|
+
if (!query && tools.length > PAGE_SIZE) {
|
|
341
|
+
const answer = await promptInput(
|
|
342
|
+
chalk.cyan(` Search ${tools.length} tools (blank to browse):`)
|
|
343
|
+
);
|
|
344
|
+
if (answer && answer.trim()) query = answer.trim();
|
|
345
|
+
}
|
|
248
346
|
|
|
249
|
-
|
|
250
|
-
|
|
347
|
+
if (query) {
|
|
348
|
+
pool = filterTools(tools, query);
|
|
349
|
+
if (pool.length === 0) {
|
|
350
|
+
console.log(chalk.yellow(` No tools matching "${query}".`));
|
|
351
|
+
console.log(chalk.dim(" Try a broader query, or run `nxb list` to browse everything."));
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
renderHeader("Search Results", { subtitle: `${pool.length} matching "${query}"` });
|
|
355
|
+
} else {
|
|
356
|
+
renderHeader("NexusBloom — Interactive Runner", { subtitle: "Select a tool to run" });
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const choices = pool.map((t) => ({
|
|
251
360
|
name: `${t.icon || "🔧"} ${t.name || t.slug} ${typeBadge(t)}`,
|
|
252
361
|
value: t,
|
|
253
362
|
description: t.short_description || t.description || "",
|
|
@@ -263,7 +372,7 @@ export async function runInteractive(opts = {}) {
|
|
|
263
372
|
const toolInfo = await fetchToolSource(resolved);
|
|
264
373
|
if (!toolInfo?.manifest) {
|
|
265
374
|
console.log(chalk.yellow(` No schema available for "${resolved}". Falling back to raw input.`));
|
|
266
|
-
await runCommand(resolved, {}, { ...opts, jsonMode });
|
|
375
|
+
await runCommand(resolved, {}, { ...opts, json: jsonMode });
|
|
267
376
|
return;
|
|
268
377
|
}
|
|
269
378
|
|
|
@@ -278,12 +387,7 @@ export async function runInteractive(opts = {}) {
|
|
|
278
387
|
const required = schema.required || [];
|
|
279
388
|
|
|
280
389
|
for (const [key, prop] of Object.entries(props)) {
|
|
281
|
-
const
|
|
282
|
-
const hint = prop.description || "";
|
|
283
|
-
const suffix = req ? chalk.red(" *required") : chalk.dim(" (optional)");
|
|
284
|
-
const label = `${key}${suffix}${hint ? chalk.dim(` — ${hint}`) : ""}`;
|
|
285
|
-
|
|
286
|
-
const val = await promptField(key, prop);
|
|
390
|
+
const val = await promptField(key, prop, required.includes(key));
|
|
287
391
|
if (val !== undefined) body[key] = val;
|
|
288
392
|
}
|
|
289
393
|
|
|
@@ -318,39 +422,39 @@ export async function runInteractive(opts = {}) {
|
|
|
318
422
|
return;
|
|
319
423
|
}
|
|
320
424
|
|
|
321
|
-
|
|
322
|
-
await runCommand(resolved, body, { ...opts, jsonMode, resolved });
|
|
425
|
+
await runCommand(resolved, body, { ...opts, json: jsonMode, resolved });
|
|
323
426
|
}
|
|
324
427
|
|
|
325
428
|
/** Prompt for a single schema field based on its type. */
|
|
326
|
-
async function promptField(key, prop) {
|
|
429
|
+
async function promptField(key, prop, required = false) {
|
|
327
430
|
const { type, enum: enumVals, default: def } = prop;
|
|
431
|
+
const label = `${key}${required ? chalk.red(" *required") : chalk.dim(" (optional)")}`;
|
|
328
432
|
|
|
329
433
|
// Enum → select
|
|
330
434
|
if (enumVals && Array.isArray(enumVals) && enumVals.length > 0) {
|
|
331
435
|
const choices = enumVals.map((v) => ({
|
|
332
436
|
name: String(v),
|
|
333
437
|
value: v,
|
|
334
|
-
...(v === def
|
|
438
|
+
...(v === def ? { checked: true } : {}),
|
|
335
439
|
}));
|
|
336
440
|
if (def !== undefined) choices.unshift({ name: chalk.gray("(default)"), value: def });
|
|
337
|
-
return promptSelect(`${
|
|
441
|
+
return promptSelect(`${label}:`, choices);
|
|
338
442
|
}
|
|
339
443
|
|
|
340
444
|
// Number / integer
|
|
341
445
|
if (type === "number" || type === "integer") {
|
|
342
|
-
return promptNumber(`${
|
|
446
|
+
return promptNumber(`${label}:`, { min: prop.minimum, max: prop.maximum, default: def });
|
|
343
447
|
}
|
|
344
448
|
|
|
345
449
|
// Boolean
|
|
346
450
|
if (type === "boolean") {
|
|
347
|
-
return promptConfirm(`${
|
|
451
|
+
return promptConfirm(`${label}:`, { default: def ?? true });
|
|
348
452
|
}
|
|
349
453
|
|
|
350
454
|
// Object / array → JSON string input
|
|
351
455
|
if (type === "object" || type === "array") {
|
|
352
|
-
const defaultStr = def !== undefined ? JSON.stringify(def) :
|
|
353
|
-
const raw = await promptInput(`${
|
|
456
|
+
const defaultStr = def !== undefined ? JSON.stringify(def) : "";
|
|
457
|
+
const raw = await promptInput(`${label}: (JSON)`, { defaultValue: defaultStr });
|
|
354
458
|
if (!raw) return def;
|
|
355
459
|
try {
|
|
356
460
|
return JSON.parse(raw);
|
|
@@ -361,7 +465,7 @@ async function promptField(key, prop) {
|
|
|
361
465
|
}
|
|
362
466
|
|
|
363
467
|
// Default: string input
|
|
364
|
-
return promptInput(`${
|
|
468
|
+
return promptInput(`${label}:`, { defaultValue: def });
|
|
365
469
|
}
|
|
366
470
|
|
|
367
471
|
/** Execute a resolved slug+body, choosing local vs remote. */
|
|
@@ -370,8 +474,7 @@ async function runCommand(slug, body, opts = {}) {
|
|
|
370
474
|
const apiKey = opts.key || process.env.NEXUSBLOOM_API_KEY || configGet("apiKey") || configGet("key");
|
|
371
475
|
|
|
372
476
|
if (local || !apiKey) {
|
|
373
|
-
|
|
374
|
-
return;
|
|
477
|
+
return runLocal(slug, body, { jsonMode, slugResolved: resolved });
|
|
375
478
|
}
|
|
376
|
-
|
|
479
|
+
return runRemote(slug, body, { apiKey, jsonMode, slugResolved: resolved });
|
|
377
480
|
}
|
package/src/runner.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* runner.js — Sandbox child process for a single tool execution.
|
|
3
|
+
*
|
|
4
|
+
* Never run directly. `sandbox.js` forks this file, sends one `run` message,
|
|
5
|
+
* and hard-kills the process when the timeout expires.
|
|
6
|
+
*
|
|
7
|
+
* Living in a separate process is what makes a hostile tool survivable:
|
|
8
|
+
* - an infinite loop can be killed (it cannot be interrupted in-process)
|
|
9
|
+
* - an OOM kills only this child, not the CLI
|
|
10
|
+
* - a crash or process.exit() in the tool does not take the CLI down
|
|
11
|
+
*
|
|
12
|
+
* This is isolation, not a security sandbox: the child runs with the same
|
|
13
|
+
* user and filesystem permissions as the CLI. Treat downloaded tool source as
|
|
14
|
+
* trusted-but-unverified, and prefer remote execution for untrusted tools.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { compileCoreLogic } from "./compile.js";
|
|
18
|
+
|
|
19
|
+
/** Serialise a value for IPC, tolerating cycles and non-JSON values. */
|
|
20
|
+
function safe(value, depth = 0) {
|
|
21
|
+
if (value === undefined) return { __undefined: true };
|
|
22
|
+
if (value === null) return null;
|
|
23
|
+
const t = typeof value;
|
|
24
|
+
if (t === "number") {
|
|
25
|
+
if (Number.isNaN(value)) return { __nan: true };
|
|
26
|
+
if (!Number.isFinite(value)) return { __inf: value > 0 ? 1 : -1 };
|
|
27
|
+
return value;
|
|
28
|
+
}
|
|
29
|
+
if (t === "string" || t === "boolean") return value;
|
|
30
|
+
if (t === "bigint") return { __bigint: value.toString() };
|
|
31
|
+
if (t === "function") return { __function: true };
|
|
32
|
+
if (t === "symbol") return { __symbol: String(value) };
|
|
33
|
+
if (depth > 12) return null;
|
|
34
|
+
if (Array.isArray(value)) return value.map((v) => safe(v, depth + 1));
|
|
35
|
+
if (value instanceof Date) return { __date: value.toISOString() };
|
|
36
|
+
if (value instanceof Error) return { __error: value.message };
|
|
37
|
+
if (typeof value.toJSON === "function") {
|
|
38
|
+
try {
|
|
39
|
+
return safe(value.toJSON(), depth + 1);
|
|
40
|
+
} catch {
|
|
41
|
+
/* fall through to plain object handling */
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (t === "object") {
|
|
45
|
+
const out = {};
|
|
46
|
+
for (const [k, v] of Object.entries(value)) {
|
|
47
|
+
out[k] = safe(v, depth + 1);
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
return String(value);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function reply(message) {
|
|
55
|
+
try {
|
|
56
|
+
process.send?.(message);
|
|
57
|
+
} catch {
|
|
58
|
+
/* parent went away — nothing useful to do */
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
process.on("message", async (msg) => {
|
|
63
|
+
if (!msg || msg.type !== "run") return;
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
const coreLogic = compileCoreLogic(msg.source);
|
|
67
|
+
const result = await coreLogic(msg.input);
|
|
68
|
+
reply({ type: "result", ok: true, result: safe(result) });
|
|
69
|
+
} catch (err) {
|
|
70
|
+
reply({ type: "result", ok: false, error: err?.message || String(err) });
|
|
71
|
+
} finally {
|
|
72
|
+
// Exit explicitly: a tool may have left timers/handles open, and we do
|
|
73
|
+
// not want to wait for them — the parent's timeout is the real deadline.
|
|
74
|
+
process.exit(0);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// If the parent dies, do not linger.
|
|
79
|
+
process.on("disconnect", () => process.exit(0));
|
|
80
|
+
|
|
81
|
+
// An unhandled rejection or fatal error must still report back, not vanish.
|
|
82
|
+
process.on("uncaughtException", (err) => {
|
|
83
|
+
reply({ type: "result", ok: false, error: err?.message || String(err) });
|
|
84
|
+
process.exit(0);
|
|
85
|
+
});
|
|
86
|
+
process.on("unhandledRejection", (err) => {
|
|
87
|
+
reply({ type: "result", ok: false, error: err?.message || String(err) });
|
|
88
|
+
process.exit(0);
|
|
89
|
+
});
|