@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.
@@ -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 (!jsonMode) console.log(chalk.dim(" No API key — falling back to local execution..."));
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 (!jsonMode) console.log(chalk.dim(` Running ${slug}...`));
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 (!jsonMode) console.log(chalk.dim(" API not deployed — falling back to local"));
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
- if (!jsonMode) console.log(chalk.green(` ✓ ${slug} completed`));
62
- if (data.data && typeof data.data === "object" && data.data.svg) {
63
- await renderQRCode(body.data || body.text || JSON.stringify(body), slug);
64
- }
65
- if (jsonMode) return console.log(JSON.stringify({ success: true, data: data.data }, null, 2));
66
- console.log(chalk.green("\n Result:"));
67
- console.log(` ${JSON.stringify(data.data, null, 2)}`);
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
- if (!jsonMode) console.log(chalk.red(` ✗ ${slug} failed`));
72
- if (jsonMode) return console.log(JSON.stringify({ success: false, error: data.error || "Unknown error" }, null, 2));
73
- console.error(chalk.red(`\n Error: ${data.error || "Unknown error"}`));
74
- process.exitCode = 1;
75
- } catch (err) {
76
- if (!jsonMode) console.log(chalk.dim(" API unreachable — falling back to local"));
77
- return runLocal(slug, body, { jsonMode, slugResolved });
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, source: src } = {}) {
116
+ export async function runLocal(slug, input, { jsonMode, slugResolved, quiet } = {}) {
84
117
  const toolInfo = await fetchToolSource(slug);
85
118
  if (!toolInfo) {
86
- if (jsonMode) return console.log(JSON.stringify({ error: `Tool "${slug}" not found.` }));
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
- if (jsonMode)
95
- return console.log(JSON.stringify({ error: "No v2 source available for local execution." }));
96
- console.error(chalk.yellow("No v2 source available for local execution. Set NEXUSBLOOM_SUPABASE_ANON_KEY or deploy the API."));
97
- process.exitCode = 1;
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
- if (!jsonMode) console.log(chalk.dim(` Using ${slugResolved ? "local" : src || "cached"} tool: ${slug}`));
102
-
103
- // Strip imports/exports, wrap in IIFE that returns coreLogic
104
- const cleanSource = coreLogicSource
105
- .replace(/^import\s+.*?;?\s*$/gm, "")
106
- .replace(/^export\s+/gm, "");
107
- const wrappedSource = `(function() {\n${cleanSource}\nreturn coreLogic;\n})()`;
108
- const coreLogicFn = new Function("return " + wrappedSource)();
109
- const isAsync = /export\s+async\s+function\s+coreLogic/.test(coreLogicSource);
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
- if (jsonMode) return console.log(JSON.stringify({ error: `Missing required fields: ${missing.join(", ")}` }));
116
- console.error(chalk.red(`Missing required fields: ${missing.join(", ")}`));
117
- process.exitCode = 1;
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
- let result;
175
+ if (!quiet) log(chalk.dim(` Executing ${slug} in sandbox (${timeoutMs}ms budget)...`));
132
176
 
133
- try {
134
- if (!jsonMode) console.log(chalk.dim(` Executing ${slug} (local)...`));
135
- if (isAsync) {
136
- result = await Promise.race([
137
- coreLogicFn(input),
138
- new Promise((_, reject) => setTimeout(() => reject(new Error(`Timed out after ${timeoutMs}ms`)), timeoutMs)),
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
- result = coreLogicFn(input);
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.log(chalk.cyan("\n QR Code:"));
225
- console.log(qr);
226
- console.log(chalk.dim(` Data: ${data.slice(0, 80)}${data.length > 80 ? "..." : ""}`));
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: true });
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. Set NEXUSBLOOM_SUPABASE_ANON_KEY or deploy the API."));
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
- renderHeader("NexusBloom — Interactive Runner", { subtitle: "Select a tool to run" });
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
- // 1. Select a tool
250
- const choices = tools.map((t) => ({
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 req = required.includes(key);
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
- // Execute directly via the same path
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 && { checked: true }),
438
+ ...(v === def ? { checked: true } : {}),
335
439
  }));
336
440
  if (def !== undefined) choices.unshift({ name: chalk.gray("(default)"), value: def });
337
- return promptSelect(`${key}:`, choices);
441
+ return promptSelect(`${label}:`, choices);
338
442
  }
339
443
 
340
444
  // Number / integer
341
445
  if (type === "number" || type === "integer") {
342
- return promptNumber(`${key}:`, { min: prop.minimum, max: prop.maximum, default: def });
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(`${key}:`, { default: def ?? true });
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(`${key}: (JSON)`, { defaultValue: defaultStr });
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(`${key}:`, { defaultValue: def });
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
- await runLocal(slug, body, { jsonMode, slugResolved: resolved });
374
- return;
477
+ return runLocal(slug, body, { jsonMode, slugResolved: resolved });
375
478
  }
376
- await runRemote(slug, body, { apiKey, jsonMode, slugResolved: resolved });
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
+ });