@nexusbloom/cli 0.2.12 → 0.3.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/src/tools.js ADDED
@@ -0,0 +1,561 @@
1
+ /**
2
+ * tools.js — Centralised tool catalogue for the nxb CLI.
3
+ *
4
+ * Resolution order for the *tool list*:
5
+ * 1. Cached snapshot (`.cache/tools-list.json`) — always written on a
6
+ * successful fetch so subsequent `list` / `abbr` calls are offline-fast.
7
+ * 2. Live API (`/api/tools`)
8
+ * 3. Supabase fallback (`/rest/v1/tools` + `/rest/v1/community_tools`)
9
+ * 4. Local filesystem scan (`src/tools/<slug>/v2.js`)
10
+ *
11
+ * Resolution order for a *single tool's source* (for `--local` execution):
12
+ * 1. Local filesystem → 2. cache (`tools/<slug>.json`) → 3. API/Supabase
13
+ *
14
+ * Exports:
15
+ * loadConfig / saveConfig / configGet / configSet
16
+ * fetchTools() — resolves list (cache-first on network failure)
17
+ * refreshTools() — force a fresh fetch + persist to cache
18
+ * fetchToolSource(slug) — { manifest, coreLogicSource }
19
+ * scanLocalTools()
20
+ * findLocalTool(slug)
21
+ * generateAbbreviations(slugs)
22
+ * resolveToolSlug(slug, tools?)
23
+ * getSupabaseKey / getSupabaseUrl / supabaseHeaders
24
+ * API_BASE
25
+ */
26
+
27
+ import fs from "fs";
28
+ import path from "path";
29
+ import os from "os";
30
+ import { createRequire } from "module";
31
+ import { fileURLToPath } from "url";
32
+
33
+ import {
34
+ CONFIG_DIR,
35
+ CONFIG_PATH,
36
+ ensureConfigDir,
37
+ ensureCacheDir,
38
+ TOOLS_CACHE_DIR,
39
+ TOOLS_LIST_CACHE_PATH,
40
+ } from "./paths.js";
41
+ import chalk from "chalk";
42
+
43
+ const require = createRequire(import.meta.url);
44
+
45
+ // ─── Constants ────────────────────────────────────────────────────────────────
46
+
47
+ export const API_BASE = process.env.NEXUSBLOOM_API_URL || "https://www.nexusbloom.dev";
48
+
49
+ const SUPABASE_URL_DEFAULT = "https://dhxaqatmuprehrkszxzs.supabase.co";
50
+ const SUPABASE_ANON_KEY_DEFAULT =
51
+ "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImRoeGFxYXRtdXByZWhya3N6eHpzIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzMxNjIzNTQsImV4cCI6MjA4ODczODM1NH0.pq7DxW66ETQt6QlIAu2WiLO08tfA7JHQVPd2SvvA3sM";
52
+
53
+ // Cache freshness threshold (30 min)
54
+ const CACHE_TTL_MS = 30 * 60 * 1000;
55
+
56
+ // Common local-tool search roots (relative to CWD)
57
+ const LOCAL_TOOL_DIRS = [
58
+ "src/tools",
59
+ "tools",
60
+ "apps/main-site/frontend/src/tools",
61
+ ];
62
+
63
+ // ─── Config helpers ─────────────────────────────────────────────────────────
64
+
65
+ export function loadConfig() {
66
+ try {
67
+ if (fs.existsSync(CONFIG_PATH)) {
68
+ return JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8"));
69
+ }
70
+ } catch {
71
+ /* corrupt config — ignore */
72
+ }
73
+ return {};
74
+ }
75
+
76
+ export function saveConfig(store = {}) {
77
+ ensureConfigDir();
78
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(store, null, 2), "utf-8");
79
+ }
80
+
81
+ export function configGet(key) {
82
+ const store = loadConfig();
83
+ return key ? store[key] : store;
84
+ }
85
+
86
+ export function configSet(key, value) {
87
+ const store = loadConfig();
88
+ store[key] = value;
89
+ saveConfig(store);
90
+ }
91
+
92
+ // ─── Supabase helpers ───────────────────────────────────────────────────────
93
+
94
+ export function getSupabaseKey() {
95
+ return process.env.NEXUSBLOOM_SUPABASE_ANON_KEY || configGet("supabaseKey") || SUPABASE_ANON_KEY_DEFAULT;
96
+ }
97
+
98
+ export function getSupabaseUrl() {
99
+ return process.env.NEXUSBLOOM_SUPABASE_URL || SUPABASE_URL_DEFAULT;
100
+ }
101
+
102
+ export function supabaseHeaders(extra = {}) {
103
+ const key = getSupabaseKey();
104
+ return { apikey: key, Authorization: `Bearer ${key}`, ...extra };
105
+ }
106
+
107
+ // ─── Tools list cache (tools-list.json) ──────────────────────────────────────
108
+
109
+ /**
110
+ * Read the cached tools-list snapshot from disk.
111
+ * Returns null if missing, corrupt, or stale (older than CACHE_TTL_MS).
112
+ */
113
+ export function loadToolsCache(allowStale = false) {
114
+ try {
115
+ if (!fs.existsSync(TOOLS_LIST_CACHE_PATH)) return null;
116
+ const raw = fs.readFileSync(TOOLS_LIST_CACHE_PATH, "utf-8");
117
+ const data = JSON.parse(raw);
118
+ const age = Date.now() - (data.cachedAt ? new Date(data.cachedAt).getTime() : 0);
119
+ if (!allowStale && age > CACHE_TTL_MS) return null;
120
+ const tools = data.tools;
121
+ if (!Array.isArray(tools) || tools.length === 0) return null;
122
+ return tools.map(normalizeTool);
123
+ } catch {
124
+ return null;
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Persist a tools array to `tools-list.json` so future calls are offline-fast.
130
+ */
131
+ export function saveToolsCache(tools) {
132
+ ensureCacheDir();
133
+ fs.writeFileSync(
134
+ TOOLS_LIST_CACHE_PATH,
135
+ JSON.stringify({ tools, cachedAt: new Date().toISOString() }, null, 2),
136
+ "utf-8"
137
+ );
138
+ }
139
+
140
+ // ─── Fetching ───────────────────────────────────────────────────────────────
141
+
142
+ /**
143
+ * Fetch the tool list from the live API.
144
+ * Normalises to the standard shape: { slug, name, description, short_description,
145
+ * category, runtime, version, icon, type, tags }.
146
+ */
147
+ async function fetchToolsFromAPI() {
148
+ try {
149
+ const url = new URL("/api/tools", API_BASE);
150
+ const res = await fetch(url);
151
+ const ct = res.headers.get("content-type") || "";
152
+ if (ct.includes("json")) {
153
+ const { tools } = await res.json();
154
+ if (Array.isArray(tools) && tools.length > 0) {
155
+ return tools.map(normalizeTool);
156
+ }
157
+ }
158
+ } catch {
159
+ /* API unreachable */
160
+ }
161
+ return null;
162
+ }
163
+
164
+ /** Normalise a raw tool object to the standard shape. */
165
+ function normalizeTool(t) {
166
+ if (!t || typeof t !== "object") return t;
167
+ const desc = t.short_description || t.description || "";
168
+ return {
169
+ slug: t.slug || "",
170
+ name: t.name || t.slug || "",
171
+ description: desc,
172
+ short_description: desc,
173
+ category: t.category || t.tool_type || "uncategorized",
174
+ runtime: t.runtime || "both",
175
+ version: t.version || "1.0.0",
176
+ icon: t.icon || "🔧",
177
+ type: t.type === "builtin" || t.type === "community" || t.type === "local" ? t.type : "builtin",
178
+ tags: t.tags || [],
179
+ ...(t.author_id ? { author_id: t.author_id } : {}),
180
+ ...(t.price_type ? { price_type: t.price_type } : {}),
181
+ };
182
+ }
183
+
184
+ /**
185
+ * Fetch builtin + community tools from Supabase directly (fallback).
186
+ * Normalises to the same shape the API returns.
187
+ */
188
+ async function fetchToolsFromSupabase() {
189
+ const headers = supabaseHeaders();
190
+ const supabaseUrl = getSupabaseUrl();
191
+ try {
192
+ const builtinRes = await fetch(
193
+ `${supabaseUrl}/rest/v1/tools?select=slug,name,short_description,tool_type,runtime,version,icon&published=eq.true&order=name.asc`,
194
+ { headers }
195
+ );
196
+ if (!builtinRes.ok) return null;
197
+ const builtins = await builtinRes.json();
198
+ if (!Array.isArray(builtins)) return null;
199
+
200
+ const communityRes = await fetch(
201
+ `${supabaseUrl}/rest/v1/community_tools?select=slug,name,short_description,category,version,icon,author_id&status=eq.published&order=name.asc`,
202
+ { headers }
203
+ );
204
+ const community = communityRes.ok ? await communityRes.json() : [];
205
+
206
+ return [
207
+ ...builtins.map((t) => ({
208
+ slug: t.slug,
209
+ name: t.name,
210
+ description: t.short_description || "",
211
+ short_description: t.short_description || "",
212
+ category: t.tool_type || "uncategorized",
213
+ runtime: t.runtime || "both",
214
+ version: t.version || "1.0.0",
215
+ icon: t.icon || "🔧",
216
+ type: "builtin",
217
+ })),
218
+ ...(Array.isArray(community)
219
+ ? community.map((t) => ({
220
+ slug: t.slug,
221
+ name: t.name,
222
+ description: t.short_description || "",
223
+ short_description: t.short_description || "",
224
+ category: t.category || "uncategorized",
225
+ runtime: "both",
226
+ version: t.version || "1.0.0",
227
+ icon: t.icon || "🛠️",
228
+ type: "community",
229
+ author_id: t.author_id,
230
+ }))
231
+ : []),
232
+ ];
233
+ } catch {
234
+ return null;
235
+ }
236
+ }
237
+
238
+ /**
239
+ * Public entry: resolve the full tool list.
240
+ *
241
+ * fetchTools({ force, offline } = {})
242
+ *
243
+ * - `force` → bypass cache, hit network, persist result
244
+ * - `offline`→ never touch the network, return cache (or local scan) only
245
+ */
246
+ export async function fetchTools({ force = false, offline = false } = {}) {
247
+ // 1. If we have a fresh cache and aren't forcing, return it (merge local scan)
248
+ if (!force && !offline) {
249
+ const cached = loadToolsCache();
250
+ if (cached) return mergeLocalTools(cached);
251
+ }
252
+
253
+ // 2. Offline-only mode — cache then local scan
254
+ if (offline) {
255
+ const cached = loadToolsCache(true);
256
+ if (cached) return mergeLocalTools(cached);
257
+ return mergeLocalTools(scanLocalTools());
258
+ }
259
+
260
+ // 3. Try API → Supabase, persist to cache on success
261
+ let tools = (await fetchToolsFromAPI()) || (await fetchToolsFromSupabase());
262
+ if (tools && tools.length > 0) {
263
+ saveToolsCache(tools);
264
+ return mergeLocalTools(tools);
265
+ }
266
+
267
+ // 4. Network failed entirely — fall back to stale cache or local scan
268
+ const cached = loadToolsCache(true);
269
+ if (cached) return mergeLocalTools(cached);
270
+ return mergeLocalTools(scanLocalTools());
271
+ }
272
+
273
+ /**
274
+ * Convenience: always refresh / re-fetch + re-cache.
275
+ */
276
+ export async function refreshTools() {
277
+ const tools = (await fetchToolsFromAPI()) || (await fetchToolsFromSupabase()) || [];
278
+ if (tools.length > 0) saveToolsCache(tools);
279
+ return mergeLocalTools(tools);
280
+ }
281
+
282
+ /**
283
+ * Merge locally-scanned tools ahead of (deduplicating by slug) the given list.
284
+ * Local tools always take priority so dev workflows see the freshest source.
285
+ */
286
+ function mergeLocalTools(remoteTools) {
287
+ const local = scanLocalTools();
288
+ const localSlugs = new Set(local.map((t) => t.slug));
289
+ const deduped = remoteTools.filter((t) => t && !localSlugs.has(t.slug));
290
+ return [...local, ...deduped];
291
+ }
292
+
293
+ // ─── Single tool source (for --local execution) ──────────────────────────────
294
+
295
+ /**
296
+ * Resolve a single tool's { manifest, coreLogicSource }.
297
+ *
298
+ * 1. Local filesystem → 2. per-tool cache → 3. API → 4. Supabase
299
+ */
300
+ export async function fetchToolSource(slug) {
301
+ ensureCacheDir();
302
+ const cacheFile = path.join(TOOLS_CACHE_DIR, `${slug}.json`);
303
+
304
+ // 1. Local filesystem
305
+ const localPath = findLocalTool(slug);
306
+ if (localPath) {
307
+ const source = fs.readFileSync(localPath, "utf-8");
308
+ const manifest = extractManifest(source);
309
+ if (manifest) {
310
+ // Refresh cache from local source
311
+ fs.writeFileSync(cacheFile, JSON.stringify({ manifest, coreLogicSource: source, cachedAt: new Date().toISOString() }), "utf-8");
312
+ return { manifest, coreLogicSource: source, source: "local" };
313
+ }
314
+ }
315
+
316
+ // 2. Per-tool cache (.cache/tools/<slug>.json)
317
+ if (fs.existsSync(cacheFile)) {
318
+ try {
319
+ const cached = JSON.parse(fs.readFileSync(cacheFile, "utf-8"));
320
+ if (cached.manifest && cached.coreLogicSource) {
321
+ return { manifest: cached.manifest, coreLogicSource: cached.coreLogicSource, source: "cache" };
322
+ }
323
+ } catch {
324
+ /* corrupt cache — ignore */
325
+ }
326
+ }
327
+
328
+ // 3. Live API
329
+ try {
330
+ const res = await fetch(`${API_BASE}/api/run/${slug}`);
331
+ if (res.ok) {
332
+ const data = await res.json();
333
+ const manifest = data.data?.manifest || data.manifest || data;
334
+ let coreLogicSource;
335
+ const sourceRes = await fetch(`${API_BASE}/api/run/${slug}?source=true`);
336
+ if (sourceRes.ok) {
337
+ const sourceData = await sourceRes.json();
338
+ coreLogicSource = sourceData.data?.v2_source;
339
+ }
340
+ if (manifest) {
341
+ fs.writeFileSync(cacheFile, JSON.stringify({ manifest, coreLogicSource: coreLogicSource || "", cachedAt: new Date().toISOString() }), "utf-8");
342
+ return { manifest, coreLogicSource: coreLogicSource || "", source: "api" };
343
+ }
344
+ }
345
+ } catch {
346
+ /* API unreachable */
347
+ }
348
+
349
+ // 4. Supabase fallback
350
+ const toolInfo = await fetchToolFromSupabase(slug);
351
+ if (toolInfo) {
352
+ fs.writeFileSync(cacheFile, JSON.stringify({ manifest: toolInfo.manifest, coreLogicSource: toolInfo.coreLogicSource, cachedAt: new Date().toISOString() }), "utf-8");
353
+ return { manifest: toolInfo.manifest, coreLogicSource: toolInfo.coreLogicSource, source: "supabase" };
354
+ }
355
+
356
+ return null;
357
+ }
358
+
359
+ /**
360
+ * Fetch a single tool's v2_source from Supabase (fallback).
361
+ * Returns { manifest, coreLogicSource } or null.
362
+ */
363
+ export async function fetchToolFromSupabase(slug) {
364
+ const headers = supabaseHeaders();
365
+ const supabaseUrl = getSupabaseUrl();
366
+ try {
367
+ // Try builtin tools first
368
+ const toolRes = await fetch(
369
+ `${supabaseUrl}/rest/v1/tools?slug=eq.${slug}&select=id&published=eq.true`,
370
+ { headers }
371
+ );
372
+ let tools = await toolRes.json();
373
+ let toolId = tools?.[0]?.id;
374
+ let toolType = "builtin";
375
+
376
+ // Fall back to community tools
377
+ if (!toolId) {
378
+ const comRes = await fetch(
379
+ `${supabaseUrl}/rest/v1/community_tools?slug=eq.${slug}&select=id&status=eq.published`,
380
+ { headers }
381
+ );
382
+ const comTools = await comRes.json();
383
+ toolId = comTools?.[0]?.id;
384
+ toolType = "community";
385
+ }
386
+
387
+ if (!toolId) return null;
388
+
389
+ const versionRes = await fetch(
390
+ `${supabaseUrl}/rest/v1/tool_versions?tool_id=eq.${toolId}&tool_type=eq.${toolType}&status=eq.published&select=v2_source&order=created_at.desc&limit=1`,
391
+ { headers }
392
+ );
393
+ const versions = await versionRes.json();
394
+ const v2Source = versions?.[0]?.v2_source;
395
+ if (!v2Source) return null;
396
+
397
+ const manifest = extractManifest(v2Source);
398
+ return { manifest, coreLogicSource: v2Source };
399
+ } catch {
400
+ return null;
401
+ }
402
+ }
403
+
404
+ // ─── Manifest extraction ────────────────────────────────────────────────────
405
+
406
+ /**
407
+ * Extract a MANIFEST object from v2 source text.
408
+ * Handles JS object literals (unquoted keys, single quotes) gracefully.
409
+ */
410
+ export function extractManifest(source) {
411
+ if (!source) return null;
412
+ const match = source.match(/export\s+const\s+MANIFEST\s*=\s*({[\s\S]*?});/);
413
+ if (!match) return null;
414
+ try {
415
+ // eslint-disable-next-line no-new-func
416
+ return new Function("return " + match[1])();
417
+ } catch {
418
+ try {
419
+ return JSON.parse(match[1].replace(/(\w+):/g, '"$1":').replace(/'/g, '"'));
420
+ } catch {
421
+ return null;
422
+ }
423
+ }
424
+ }
425
+
426
+ // ─── Local filesystem scanning ─────────────────────────────────────────────
427
+
428
+ /**
429
+ * Search the local filesystem for a single tool's v2.js file.
430
+ * Checks common locations relative to CWD, plus config.toolsDir override.
431
+ */
432
+ export function findLocalTool(slug) {
433
+ const cwd = process.cwd();
434
+ const candidates = LOCAL_TOOL_DIRS.map((d) => path.join(cwd, d, slug, "v2.js"));
435
+
436
+ try {
437
+ const toolsDir = configGet("toolsDir");
438
+ if (toolsDir) {
439
+ candidates.unshift(path.join(cwd, toolsDir, slug, "v2.js"));
440
+ }
441
+ } catch {}
442
+
443
+ for (const candidate of candidates) {
444
+ if (fs.existsSync(candidate)) return candidate;
445
+ }
446
+
447
+ // Last resort: search from workspace root parent dirs
448
+ const rootCandidates = [
449
+ path.join(cwd, "..", "apps", "main-site", "frontend", "src", "tools", slug, "v2.js"),
450
+ ];
451
+ for (const c of rootCandidates) {
452
+ if (fs.existsSync(c)) return c;
453
+ }
454
+ return null;
455
+ }
456
+
457
+ /**
458
+ * Scan the local filesystem for ALL tool v2.js files.
459
+ * Returns array of { slug, name, description, short_description, category, runtime, version, icon, type: "local" }.
460
+ */
461
+ export function scanLocalTools() {
462
+ const cwd = process.cwd();
463
+ const searchDirs = LOCAL_TOOL_DIRS.map((d) => path.join(cwd, d));
464
+ const tools = [];
465
+
466
+ for (const dir of searchDirs) {
467
+ if (!fs.existsSync(dir)) continue;
468
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
469
+ for (const entry of entries) {
470
+ if (!entry.isDirectory()) continue;
471
+ const v2Path = path.join(dir, entry.name, "v2.js");
472
+ if (!fs.existsSync(v2Path)) continue;
473
+ try {
474
+ const source = fs.readFileSync(v2Path, "utf-8");
475
+ const manifest = extractManifest(source);
476
+ if (!manifest) continue;
477
+ tools.push({
478
+ slug: manifest.slug || entry.name,
479
+ name: manifest.name || entry.name,
480
+ description: manifest.short_description || "",
481
+ short_description: manifest.short_description || "",
482
+ category: manifest.category || "uncategorized",
483
+ runtime: manifest.runtime || "both",
484
+ version: manifest.version || "1.0.0",
485
+ icon: manifest.icon || "🔧",
486
+ type: "local",
487
+ });
488
+ } catch {}
489
+ }
490
+ }
491
+ return tools;
492
+ }
493
+
494
+ // ─── Abbreviation engine ────────────────────────────────────────────────────
495
+
496
+ /**
497
+ * Generate unambiguous abbreviations for a list of slugs.
498
+ *
499
+ * Strategy:
500
+ * - Start with first letter of each hyphen-word (e.g. "ssl-certificate-checker" → "scc")
501
+ * - If that collides or is < 2 chars, grow a prefix until unique
502
+ *
503
+ * Returns a bidirectional map: { slug → abbr, abbr → slug }
504
+ */
505
+ export function generateAbbreviations(slugs) {
506
+ const abbrMap = {};
507
+ const used = new Set();
508
+
509
+ for (const slug of slugs) {
510
+ const parts = slug.split("-");
511
+ let abbr = parts.map((p) => p[0]).join("").toLowerCase();
512
+
513
+ // If taken or too short, try progressively longer prefixes
514
+ if (used.has(abbr) || abbr.length < 2) {
515
+ for (let len = 2; len <= slug.length; len++) {
516
+ abbr = slug.slice(0, len);
517
+ if (!used.has(abbr)) break;
518
+ }
519
+ }
520
+
521
+ used.add(abbr);
522
+ abbrMap[slug] = abbr;
523
+ abbrMap[abbr] = slug;
524
+ }
525
+
526
+ return abbrMap;
527
+ }
528
+
529
+ /**
530
+ * Resolve a slug or abbreviation to a canonical slug.
531
+ *
532
+ * resolveToolSlug("scc", tools) → "ssl-certificate-checker"
533
+ * resolveToolSlug("ssl-cert...", tools) → itself (already a full slug)
534
+ */
535
+ export async function resolveToolSlug(slug, toolsRef = null) {
536
+ if (!slug) return slug;
537
+ const allTools = toolsRef || (await fetchTools({ offline: true }));
538
+ if (!allTools || allTools.length === 0) return slug;
539
+
540
+ const slugs = allTools.map((t) => t.slug);
541
+ // Exact match first
542
+ if (slugs.includes(slug)) return slug;
543
+
544
+ const abbrMap = generateAbbreviations(slugs);
545
+ if (abbrMap[slug] && slugs.includes(abbrMap[slug])) {
546
+ return abbrMap[slug];
547
+ }
548
+ return slug;
549
+ }
550
+
551
+ // ─── stdin helper ────────────────────────────────────────────────────────────
552
+
553
+ export function readStdin() {
554
+ return new Promise((resolve) => {
555
+ const chunks = [];
556
+ process.stdin.setEncoding("utf8");
557
+ process.stdin.on("data", (chunk) => chunks.push(chunk));
558
+ process.stdin.on("end", () => resolve(chunks.join("")));
559
+ if (process.stdin.isTTY) resolve("");
560
+ });
561
+ }