@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/src/jsonish.js ADDED
@@ -0,0 +1,213 @@
1
+ /**
2
+ * jsonish.js — A small, non-evaluating parser for JavaScript object literals.
3
+ *
4
+ * Tool manifests are object literals written in source files, so they use
5
+ * unquoted keys, single quotes, trailing commas and comments — none of which
6
+ * `JSON.parse` accepts. The previous implementation recovered from that by
7
+ * calling `new Function(...)`, which evaluates manifest text that arrived over
8
+ * the network. This parser reads the literal instead of running it.
9
+ *
10
+ * Supports: objects, arrays, strings (single/double quoted, escapes),
11
+ * numbers (including exponents and hex), true/false/null, unquoted identifier
12
+ * keys, trailing commas, and // and block comments.
13
+ *
14
+ * Returns `undefined` when the text is not a single complete literal.
15
+ */
16
+
17
+ /**
18
+ * @param {string} text
19
+ * @returns {any|undefined} the parsed value, or undefined if unparseable
20
+ */
21
+ export function parseJsonish(text) {
22
+ if (typeof text !== "string") return undefined;
23
+ const p = new Parser(text);
24
+ try {
25
+ const value = p.parseValue();
26
+ p.skipTrivia();
27
+ // Trailing junk means this was not one clean literal.
28
+ return p.atEnd() ? value : undefined;
29
+ } catch {
30
+ return undefined;
31
+ }
32
+ }
33
+
34
+ class Parser {
35
+ constructor(src) {
36
+ this.src = src;
37
+ this.i = 0;
38
+ }
39
+
40
+ atEnd() {
41
+ return this.i >= this.src.length;
42
+ }
43
+
44
+ error(msg) {
45
+ const err = new Error(`${msg} at offset ${this.i}`);
46
+ err.isParseError = true;
47
+ throw err;
48
+ }
49
+
50
+ peek() {
51
+ return this.src[this.i];
52
+ }
53
+
54
+ /** Skip whitespace and both comment styles. */
55
+ skipTrivia() {
56
+ for (;;) {
57
+ const c = this.src[this.i];
58
+ if (c === undefined) return;
59
+ if (c === " " || c === "\t" || c === "\n" || c === "\r" || c === "\f" || c === "\v") {
60
+ this.i++;
61
+ continue;
62
+ }
63
+ if (c === "/" && this.src[this.i + 1] === "/") {
64
+ while (this.i < this.src.length && this.src[this.i] !== "\n") this.i++;
65
+ continue;
66
+ }
67
+ if (c === "/" && this.src[this.i + 1] === "*") {
68
+ const end = this.src.indexOf("*/", this.i + 2);
69
+ this.i = end === -1 ? this.src.length : end + 2;
70
+ continue;
71
+ }
72
+ return;
73
+ }
74
+ }
75
+
76
+ parseValue() {
77
+ this.skipTrivia();
78
+ const c = this.peek();
79
+ if (c === undefined) this.error("Unexpected end of input");
80
+ if (c === "{") return this.parseObject();
81
+ if (c === "[") return this.parseArray();
82
+ if (c === '"' || c === "'") return this.parseString();
83
+ return this.parsePrimitive();
84
+ }
85
+
86
+ parseObject() {
87
+ const out = {};
88
+ this.i++; // {
89
+ for (;;) {
90
+ this.skipTrivia();
91
+ if (this.peek() === "}") {
92
+ this.i++;
93
+ return out;
94
+ }
95
+ if (this.atEnd()) this.error("Unterminated object");
96
+
97
+ const key = this.parseKey();
98
+ this.skipTrivia();
99
+ if (this.peek() !== ":") this.error(`Expected ':' after key "${key}"`);
100
+ this.i++;
101
+ out[key] = this.parseValue();
102
+
103
+ this.skipTrivia();
104
+ const c = this.peek();
105
+ if (c === ",") {
106
+ this.i++;
107
+ continue;
108
+ }
109
+ if (c === "}") {
110
+ this.i++;
111
+ return out;
112
+ }
113
+ this.error("Expected ',' or '}'");
114
+ }
115
+ }
116
+
117
+ parseKey() {
118
+ const c = this.peek();
119
+ if (c === '"' || c === "'") return this.parseString();
120
+ // Unquoted identifier key: letter, _ or $ start, then word characters.
121
+ const start = this.i;
122
+ if (!/[A-Za-z_$]/.test(c || "")) this.error("Invalid object key");
123
+ while (this.i < this.src.length && /[A-Za-z0-9_$]/.test(this.src[this.i])) this.i++;
124
+ return this.src.slice(start, this.i);
125
+ }
126
+
127
+ parseArray() {
128
+ const out = [];
129
+ this.i++; // [
130
+ for (;;) {
131
+ this.skipTrivia();
132
+ if (this.peek() === "]") {
133
+ this.i++;
134
+ return out;
135
+ }
136
+ if (this.atEnd()) this.error("Unterminated array");
137
+
138
+ out.push(this.parseValue());
139
+ this.skipTrivia();
140
+ const c = this.peek();
141
+ if (c === ",") {
142
+ this.i++;
143
+ continue;
144
+ }
145
+ if (c === "]") {
146
+ this.i++;
147
+ return out;
148
+ }
149
+ this.error("Expected ',' or ']'");
150
+ }
151
+ }
152
+
153
+ parseString() {
154
+ const quote = this.src[this.i++];
155
+ let out = "";
156
+ while (this.i < this.src.length) {
157
+ const c = this.src[this.i++];
158
+ if (c === "\\") {
159
+ const esc = this.src[this.i++];
160
+ switch (esc) {
161
+ case "n": out += "\n"; break;
162
+ case "t": out += "\t"; break;
163
+ case "r": out += "\r"; break;
164
+ case "b": out += "\b"; break;
165
+ case "f": out += "\f"; break;
166
+ case "v": out += "\v"; break;
167
+ case "0": out += "\0"; break;
168
+ case "u": {
169
+ const hex = this.src.slice(this.i, this.i + 4);
170
+ if (!/^[0-9a-fA-F]{4}$/.test(hex)) this.error("Bad \\u escape");
171
+ out += String.fromCharCode(parseInt(hex, 16));
172
+ this.i += 4;
173
+ break;
174
+ }
175
+ case "x": {
176
+ const hex = this.src.slice(this.i, this.i + 2);
177
+ if (!/^[0-9a-fA-F]{2}$/.test(hex)) this.error("Bad \\x escape");
178
+ out += String.fromCharCode(parseInt(hex, 16));
179
+ this.i += 2;
180
+ break;
181
+ }
182
+ // Line continuation
183
+ case "\n": break;
184
+ default: out += esc;
185
+ }
186
+ continue;
187
+ }
188
+ if (c === quote) return out;
189
+ if (c === "\n") this.error("Unterminated string");
190
+ out += c;
191
+ }
192
+ this.error("Unterminated string");
193
+ }
194
+
195
+ parsePrimitive() {
196
+ const start = this.i;
197
+ while (this.i < this.src.length && /[^,}\]\s]/.test(this.src[this.i])) this.i++;
198
+ const raw = this.src.slice(start, this.i);
199
+ if (!raw) this.error("Expected a value");
200
+
201
+ if (raw === "true") return true;
202
+ if (raw === "false") return false;
203
+ if (raw === "null") return null;
204
+
205
+ if (/^[+-]?(?:0[xX][0-9a-fA-F]+|(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?)$/.test(raw)) {
206
+ return Number(raw);
207
+ }
208
+
209
+ // Identifiers that JS would evaluate (Infinity, undefined, functions) are
210
+ // rejected rather than guessed at — a manifest should not need them.
211
+ this.error(`Unsupported literal "${raw}"`);
212
+ }
213
+ }
package/src/list.js CHANGED
@@ -1,17 +1,18 @@
1
1
  /**
2
2
  * list.js — `nxb list` command.
3
3
  *
4
- * Simple paginated tool listing. Clean lines, no fancy backgrounds, no tables.
4
+ * Paginated tool listing. Long lists are always paired with a search option.
5
5
  *
6
- * nxb list — interactive pagination (10/page, prev/next)
7
- * nxb list -s ssl — filter + paginated
8
- * nxb list --json — raw JSON
9
- * nxb list --no-interactive— single dump
6
+ * nxb list — interactive pagination (20/page)
7
+ * nxb list -s ssl — filter + paginate
8
+ * nxb list --json — raw JSON
9
+ * nxb list --no-interactive — single dump
10
10
  */
11
11
 
12
12
  import chalk from "chalk";
13
- import { fetchTools } from "./tools.js";
13
+ import { fetchTools, rankTools } from "./tools.js";
14
14
  import { paginate, paginateInteractive, PAGE_SIZE } from "./ui.js";
15
+ import { diagnoseEmptyCatalogue } from "./diagnostics.js";
15
16
 
16
17
  function renderToolsPage(result) {
17
18
  if (result.total === 0) {
@@ -35,33 +36,31 @@ function renderToolsPage(result) {
35
36
  export async function listAction(opts = {}) {
36
37
  const { search, json: jsonMode, interactive = true, force = false, offline = false } = opts;
37
38
 
38
- let tools = await fetchTools({ force, offline });
39
- let filtered = tools;
40
-
41
- if (search) {
42
- const q = search.toLowerCase();
43
- filtered = tools.filter(
44
- (t) =>
45
- (t.slug || "").includes(q) ||
46
- (t.name || "").toLowerCase().includes(q) ||
47
- (t.short_description || t.description || "").toLowerCase().includes(q) ||
48
- (t.category || "").includes(q) ||
49
- (t.tags || []).some((tg) => tg.toLowerCase().includes(q))
50
- );
39
+ const tools = await fetchTools({ force, offline });
40
+ if (!tools || tools.length === 0) {
41
+ return diagnoseEmptyCatalogue({ offline, jsonMode, source: "catalogue" });
51
42
  }
43
+ const filtered = search ? rankTools(tools, search) : tools;
52
44
 
53
45
  if (jsonMode) {
54
46
  console.log(JSON.stringify({ tools: filtered }, null, 2));
55
47
  return;
56
48
  }
57
49
 
50
+ const hint = search
51
+ ? ""
52
+ : tools.length > PAGE_SIZE
53
+ ? chalk.dim(`\n Tip: narrow this down with nxb list -s <query> (or nxb search <query>)\n`)
54
+ : "";
55
+
58
56
  if (!interactive) {
59
57
  if (filtered.length === 0) {
60
- console.log(chalk.yellow(" No tools found."));
58
+ console.log(chalk.yellow(search ? ` No tools matching "${search}".` : " No tools found."));
61
59
  return;
62
60
  }
63
61
  const result = paginate(filtered, filtered.length, 1);
64
- console.log(chalk.bold(`\n NexusBloom Tools (${filtered.length} available)\n`));
62
+ console.log(chalk.bold(`\n NexusBloom Tools (${filtered.length} available)`));
63
+ console.log(hint);
65
64
  renderToolsPage(result);
66
65
  console.log();
67
66
  return;
@@ -72,10 +71,17 @@ export async function listAction(opts = {}) {
72
71
  PAGE_SIZE,
73
72
  (result) => {
74
73
  console.clear();
75
- console.log(chalk.bold(`\n NexusBloom Tools (${result.total} · page ${result.page}/${result.totalPages})\n`));
74
+ const title = search
75
+ ? `NexusBloom Tools (${result.total} matching "${search}")`
76
+ : `NexusBloom Tools (${result.total} · page ${result.page}/${result.totalPages})`;
77
+ console.log(chalk.bold(`\n ${title}\n`));
78
+ console.log(hint);
76
79
  renderToolsPage(result);
80
+ if (search && result.totalPages > 1) {
81
+ console.log(chalk.dim(`\n Refine with: nxb list -s <query>`));
82
+ }
77
83
  },
78
- {}
84
+ { interactive }
79
85
  );
80
86
  }
81
87
 
package/src/paths.js CHANGED
@@ -1,8 +1,14 @@
1
1
  /**
2
2
  * paths.js — Centralised filesystem paths for the nxb CLI.
3
3
  *
4
- * Keeps config + cache locations in one place so every module can share them
5
- * without duplicating the XDG / home-directory logic.
4
+ * Config and cache both live under XDG base directories so they survive
5
+ * `npm update -g` / reinstalls and are never written inside the installed
6
+ * package (which is often root-owned and wiped on upgrade).
7
+ *
8
+ * config : $XDG_CONFIG_HOME/nexusbloom/config.json (mode 0600 — secrets)
9
+ * cache : $XDG_CACHE_HOME/nexusbloom/ (mode 0700)
10
+ * ├── tools-list.json flat catalogue snapshot
11
+ * └── tools/<slug>.json per-tool v2 source snapshot
6
12
  */
7
13
 
8
14
  import fs from "fs";
@@ -12,31 +18,51 @@ import { fileURLToPath } from "url";
12
18
 
13
19
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
14
20
 
21
+ const xdg = (envVar, fallback) => {
22
+ const base = process.env[envVar];
23
+ return base && base.trim() ? path.resolve(base) : path.join(os.homedir(), fallback);
24
+ };
25
+
15
26
  // ─── Config (nxb config) ──────────────────────────────────────────────────────
16
- export const CONFIG_DIR = path.join(
17
- process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"),
18
- "nexusbloom"
19
- );
27
+ export const CONFIG_DIR = path.join(xdg("XDG_CONFIG_HOME", ".config"), "nexusbloom");
20
28
  export const CONFIG_PATH = path.join(CONFIG_DIR, "config.json");
21
29
 
22
- // ─── Tool list cache (offline tool catalogue) ─────────────────────────────────
23
- // Stored under ~/.cache/nexusbloom/tools.json — a flat snapshot of every tool
24
- // so `nxb list` / `nxb abbr` work without a network round-trip.
25
- export const CACHE_DIR = path.resolve(__dirname, "../.cache");
30
+ // ─── Cache (tool catalogue + tool source) ─────────────────────────────────────
31
+ export const CACHE_DIR = path.join(xdg("XDG_CACHE_HOME", ".cache"), "nexusbloom");
26
32
  export const TOOLS_CACHE_DIR = path.join(CACHE_DIR, "tools");
27
33
  export const TOOLS_LIST_CACHE_PATH = path.join(CACHE_DIR, "tools-list.json");
28
34
 
29
- // ─── Local tool source cache (for --local execution) ──────────────────────────
30
- export const TOOL_SOURCE_CACHE_DIR = TOOLS_CACHE_DIR;
35
+ // ─── Saved pipelines ─────────────────────────────────────────────────────────
36
+ export const PIPELINES_DIR = path.join(CONFIG_DIR, "pipelines");
37
+
38
+ // Legacy location — older versions cached inside the installed package dir,
39
+ // so it can be root-owned and is wiped by every `npm update -g`.
40
+ // Only used by `nxb cache clear` to remove the stale copy.
41
+ export const LEGACY_CACHE_DIR = path.resolve(__dirname, "../.cache");
31
42
 
32
- export function ensureDir(dir) {
33
- if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
43
+ export function ensureDir(dir, mode) {
44
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true, mode });
45
+ // mkdir's `mode` is masked by umask and ignored if the dir already exists,
46
+ // so tighten an existing directory explicitly.
47
+ if (mode !== undefined) {
48
+ try {
49
+ fs.chmodSync(dir, mode);
50
+ } catch {
51
+ /* best effort (non-POSIX fs) */
52
+ }
53
+ }
34
54
  }
35
55
 
36
56
  export function ensureConfigDir() {
37
- ensureDir(CONFIG_DIR);
57
+ // 0700 — config.json holds the API key.
58
+ ensureDir(CONFIG_DIR, 0o700);
59
+ }
60
+
61
+ export function ensurePipelinesDir() {
62
+ ensureDir(PIPELINES_DIR, 0o700);
38
63
  }
39
64
 
40
65
  export function ensureCacheDir() {
41
- ensureDir(TOOLS_CACHE_DIR);
66
+ ensureDir(CACHE_DIR, 0o700);
67
+ ensureDir(TOOLS_CACHE_DIR, 0o700);
42
68
  }