@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/README.md +96 -19
- package/package.json +2 -1
- package/src/abbr.js +111 -0
- package/src/index.js +278 -940
- package/src/list.js +107 -0
- package/src/paths.js +42 -0
- package/src/run.js +380 -0
- package/src/tools.js +561 -0
- package/src/ui.js +371 -0
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
|
+
}
|