@nexusbloom/cli 0.3.4 → 0.9.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 +9 -2
- 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 +353 -281
- 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 +238 -104
- 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
|
-
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");
|
|
69
91
|
}
|
|
70
92
|
|
|
71
|
-
|
|
72
|
-
if (
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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);
|
|
97
|
+
}
|
|
98
|
+
|
|
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,73 @@ 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
|
+
/**
|
|
204
|
+
* True when an error is inquirer's "the user pressed Ctrl+C" signal.
|
|
205
|
+
*
|
|
206
|
+
* @inquirer/core rejects with an `ExitPromptError` from inside a readline
|
|
207
|
+
* `SIGINT` handler. Because the CLI parses with `program.parse()` — which does
|
|
208
|
+
* not await the async action handlers — that rejection used to escape as an
|
|
209
|
+
* unhandled rejection and dump an inquirer stack trace over the user's
|
|
210
|
+
* terminal. The class is not exported from `@inquirer/prompts`, and importing
|
|
211
|
+
* `@inquirer/core` directly would tie us to a transitive dependency, so it is
|
|
212
|
+
* matched by name.
|
|
213
|
+
*/
|
|
214
|
+
export function isExitPromptError(err) {
|
|
215
|
+
return Boolean(err) && err.name === "ExitPromptError";
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Print an error on stderr (or as JSON) and set a non-zero exit code. */
|
|
219
|
+
export function reportError(jsonMode, message) {
|
|
220
|
+
if (jsonMode) {
|
|
221
|
+
console.log(JSON.stringify({ success: false, error: message }));
|
|
222
|
+
} else {
|
|
223
|
+
console.error(chalk.red(` ✗ ${message}`));
|
|
224
|
+
}
|
|
225
|
+
process.exitCode = 1;
|
|
226
|
+
return { ok: false, error: message };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Progress output belongs on stderr so stdout stays machine-readable. */
|
|
230
|
+
function log(line) {
|
|
231
|
+
console.error(line);
|
|
160
232
|
}
|
|
161
233
|
|
|
162
234
|
// ─── Input building ─────────────────────────────────────────────────────────
|
|
@@ -221,9 +293,9 @@ export async function buildInputBody(opts, args) {
|
|
|
221
293
|
async function renderQRCode(data, slug) {
|
|
222
294
|
try {
|
|
223
295
|
const qr = await QRCode.toString(data, { type: "terminal", small: true });
|
|
224
|
-
console.
|
|
225
|
-
console.
|
|
226
|
-
console.
|
|
296
|
+
console.error(chalk.cyan("\n QR Code:"));
|
|
297
|
+
console.error(qr);
|
|
298
|
+
console.error(chalk.dim(` Data: ${data.slice(0, 80)}${data.length > 80 ? "..." : ""}`));
|
|
227
299
|
} catch {
|
|
228
300
|
/* silently skip */
|
|
229
301
|
}
|
|
@@ -234,20 +306,72 @@ async function renderQRCode(data, slug) {
|
|
|
234
306
|
/**
|
|
235
307
|
* `nxb run` with no slug: interactively select a tool and its inputs,
|
|
236
308
|
* then auto-build & execute the exact command.
|
|
309
|
+
*
|
|
310
|
+
* With `-s/--search`, the list is filtered first (search matches slug, name,
|
|
311
|
+
* description, category and abbreviation). Without a TTY there is nothing to
|
|
312
|
+
* prompt with, so the tool list is printed as a plain searchable dump and the
|
|
313
|
+
* command exits — it never hangs a pipe or a CI job.
|
|
237
314
|
*/
|
|
238
315
|
export async function runInteractive(opts = {}) {
|
|
239
|
-
const { json: jsonMode } = opts;
|
|
240
|
-
const tools = await fetchTools({ offline:
|
|
316
|
+
const { json: jsonMode, search } = opts;
|
|
317
|
+
const tools = await fetchTools({ offline: false });
|
|
241
318
|
|
|
242
319
|
if (!tools || tools.length === 0) {
|
|
243
|
-
console.log(chalk.yellow(" No tools available.
|
|
320
|
+
console.log(chalk.yellow(" No tools available. Check your connection, or run `nxb cache list`."));
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// ── Non-interactive: print the (optionally filtered) list and stop ──
|
|
325
|
+
if (!isInteractive()) {
|
|
326
|
+
const filtered = search ? filterTools(tools, search) : tools;
|
|
327
|
+
|
|
328
|
+
if (jsonMode) {
|
|
329
|
+
console.log(JSON.stringify({ tools: filtered, ...(search ? { query: search } : {}) }, null, 2));
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (filtered.length === 0) {
|
|
334
|
+
console.log(chalk.yellow(` No tools matching "${search}".`));
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
console.log(chalk.bold(`\n Available tools (${filtered.length})${search ? ` matching "${search}"` : ""}\n`));
|
|
339
|
+
for (const t of filtered) {
|
|
340
|
+
const desc = (t.short_description || t.description || "").slice(0, 60);
|
|
341
|
+
console.log(` ${(t.icon || "🔧")} ${chalk.cyan((t.slug || "").padEnd(30))} ${chalk.dim(desc)}`);
|
|
342
|
+
}
|
|
343
|
+
console.log(chalk.dim(`\n Run a tool with: nxb run <slug> [key=value ...]`));
|
|
344
|
+
console.log(chalk.dim(` Narrow the list: nxb run -s <query> · nxb search <query>`));
|
|
345
|
+
console.log();
|
|
244
346
|
return;
|
|
245
347
|
}
|
|
246
348
|
|
|
247
|
-
|
|
349
|
+
// ── Interactive: optional search, then the tool picker ──
|
|
350
|
+
let pool = tools;
|
|
351
|
+
let query = search;
|
|
352
|
+
|
|
353
|
+
// Long catalogue + no explicit query → offer search before the picker,
|
|
354
|
+
// since scrolling a plain select through hundreds of tools is unusable.
|
|
355
|
+
if (!query && tools.length > PAGE_SIZE) {
|
|
356
|
+
const answer = await promptInput(
|
|
357
|
+
chalk.cyan(` Search ${tools.length} tools (blank to browse):`)
|
|
358
|
+
);
|
|
359
|
+
if (answer && answer.trim()) query = answer.trim();
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (query) {
|
|
363
|
+
pool = filterTools(tools, query);
|
|
364
|
+
if (pool.length === 0) {
|
|
365
|
+
console.log(chalk.yellow(` No tools matching "${query}".`));
|
|
366
|
+
console.log(chalk.dim(" Try a broader query, or run `nxb list` to browse everything."));
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
renderHeader("Search Results", { subtitle: `${pool.length} matching "${query}"` });
|
|
370
|
+
} else {
|
|
371
|
+
renderHeader("NexusBloom — Interactive Runner", { subtitle: "Select a tool to run" });
|
|
372
|
+
}
|
|
248
373
|
|
|
249
|
-
|
|
250
|
-
const choices = tools.map((t) => ({
|
|
374
|
+
const choices = pool.map((t) => ({
|
|
251
375
|
name: `${t.icon || "🔧"} ${t.name || t.slug} ${typeBadge(t)}`,
|
|
252
376
|
value: t,
|
|
253
377
|
description: t.short_description || t.description || "",
|
|
@@ -256,15 +380,30 @@ export async function runInteractive(opts = {}) {
|
|
|
256
380
|
const selected = await promptSelect("Choose a tool:", choices, { pageSize: 15 });
|
|
257
381
|
if (!selected) return;
|
|
258
382
|
|
|
259
|
-
|
|
260
|
-
|
|
383
|
+
return await runInteractiveFor(selected.slug, { ...opts, json: jsonMode, tools });
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Interactive configuration for a slug that is already known.
|
|
388
|
+
*
|
|
389
|
+
* Shared by the tool picker above and by `nxb run <slug>` with no input of any
|
|
390
|
+
* kind: a bare `nxb run text-statistics` has nothing to send, so asking for the
|
|
391
|
+
* fields is the useful behaviour rather than failing schema validation with
|
|
392
|
+
* "Missing required fields: text".
|
|
393
|
+
*
|
|
394
|
+
* Returns the executed slug, or undefined when the user backs out or the tool
|
|
395
|
+
* has no schema to configure.
|
|
396
|
+
*/
|
|
397
|
+
export async function runInteractiveFor(slug, opts = {}) {
|
|
398
|
+
const { json: jsonMode, tools: knownTools } = opts;
|
|
399
|
+
const resolved = await resolveToolSlug(slug, knownTools);
|
|
261
400
|
|
|
262
401
|
// 2. Fetch manifest / schema
|
|
263
402
|
const toolInfo = await fetchToolSource(resolved);
|
|
264
403
|
if (!toolInfo?.manifest) {
|
|
265
404
|
console.log(chalk.yellow(` No schema available for "${resolved}". Falling back to raw input.`));
|
|
266
|
-
await runCommand(resolved, {}, { ...opts, jsonMode });
|
|
267
|
-
return;
|
|
405
|
+
await runCommand(resolved, {}, { ...opts, json: jsonMode });
|
|
406
|
+
return resolved;
|
|
268
407
|
}
|
|
269
408
|
|
|
270
409
|
const { manifest } = toolInfo;
|
|
@@ -278,12 +417,7 @@ export async function runInteractive(opts = {}) {
|
|
|
278
417
|
const required = schema.required || [];
|
|
279
418
|
|
|
280
419
|
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);
|
|
420
|
+
const val = await promptField(key, prop, required.includes(key));
|
|
287
421
|
if (val !== undefined) body[key] = val;
|
|
288
422
|
}
|
|
289
423
|
|
|
@@ -315,42 +449,43 @@ export async function runInteractive(opts = {}) {
|
|
|
315
449
|
if (!runIt) {
|
|
316
450
|
console.log(chalk.dim(" Command copied to clipboard (if available):"));
|
|
317
451
|
console.log(chalk.gray(` ${fullCommand}`));
|
|
318
|
-
return;
|
|
452
|
+
return resolved;
|
|
319
453
|
}
|
|
320
454
|
|
|
321
|
-
|
|
322
|
-
|
|
455
|
+
await runCommand(resolved, body, { ...opts, json: jsonMode, resolved });
|
|
456
|
+
return resolved;
|
|
323
457
|
}
|
|
324
458
|
|
|
325
459
|
/** Prompt for a single schema field based on its type. */
|
|
326
|
-
async function promptField(key, prop) {
|
|
460
|
+
async function promptField(key, prop, required = false) {
|
|
327
461
|
const { type, enum: enumVals, default: def } = prop;
|
|
462
|
+
const label = `${key}${required ? chalk.red(" *required") : chalk.dim(" (optional)")}`;
|
|
328
463
|
|
|
329
464
|
// Enum → select
|
|
330
465
|
if (enumVals && Array.isArray(enumVals) && enumVals.length > 0) {
|
|
331
466
|
const choices = enumVals.map((v) => ({
|
|
332
467
|
name: String(v),
|
|
333
468
|
value: v,
|
|
334
|
-
...(v === def
|
|
469
|
+
...(v === def ? { checked: true } : {}),
|
|
335
470
|
}));
|
|
336
471
|
if (def !== undefined) choices.unshift({ name: chalk.gray("(default)"), value: def });
|
|
337
|
-
return promptSelect(`${
|
|
472
|
+
return promptSelect(`${label}:`, choices);
|
|
338
473
|
}
|
|
339
474
|
|
|
340
475
|
// Number / integer
|
|
341
476
|
if (type === "number" || type === "integer") {
|
|
342
|
-
return promptNumber(`${
|
|
477
|
+
return promptNumber(`${label}:`, { min: prop.minimum, max: prop.maximum, default: def });
|
|
343
478
|
}
|
|
344
479
|
|
|
345
480
|
// Boolean
|
|
346
481
|
if (type === "boolean") {
|
|
347
|
-
return promptConfirm(`${
|
|
482
|
+
return promptConfirm(`${label}:`, { default: def ?? true });
|
|
348
483
|
}
|
|
349
484
|
|
|
350
485
|
// Object / array → JSON string input
|
|
351
486
|
if (type === "object" || type === "array") {
|
|
352
|
-
const defaultStr = def !== undefined ? JSON.stringify(def) :
|
|
353
|
-
const raw = await promptInput(`${
|
|
487
|
+
const defaultStr = def !== undefined ? JSON.stringify(def) : "";
|
|
488
|
+
const raw = await promptInput(`${label}: (JSON)`, { defaultValue: defaultStr });
|
|
354
489
|
if (!raw) return def;
|
|
355
490
|
try {
|
|
356
491
|
return JSON.parse(raw);
|
|
@@ -361,7 +496,7 @@ async function promptField(key, prop) {
|
|
|
361
496
|
}
|
|
362
497
|
|
|
363
498
|
// Default: string input
|
|
364
|
-
return promptInput(`${
|
|
499
|
+
return promptInput(`${label}:`, { defaultValue: def });
|
|
365
500
|
}
|
|
366
501
|
|
|
367
502
|
/** Execute a resolved slug+body, choosing local vs remote. */
|
|
@@ -370,8 +505,7 @@ async function runCommand(slug, body, opts = {}) {
|
|
|
370
505
|
const apiKey = opts.key || process.env.NEXUSBLOOM_API_KEY || configGet("apiKey") || configGet("key");
|
|
371
506
|
|
|
372
507
|
if (local || !apiKey) {
|
|
373
|
-
|
|
374
|
-
return;
|
|
508
|
+
return runLocal(slug, body, { jsonMode, slugResolved: resolved });
|
|
375
509
|
}
|
|
376
|
-
|
|
510
|
+
return runRemote(slug, body, { apiKey, jsonMode, slugResolved: resolved });
|
|
377
511
|
}
|
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
|
+
});
|