nodedex 0.1.2 → 0.1.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodedex",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "mcpName": "io.github.NodeDex/nodedex",
5
5
  "description": "NodeDex — persistent knowledge-graph memory (MCP server) for AI agents: decisions with their why, dead-ends, and causal chains, built automatically from your agent's conversations",
6
6
  "license": "AGPL-3.0-or-later",
@@ -12,12 +12,20 @@
12
12
  * In the repo, build first with `npm run build` in server/ (this loads ../dist/server.js);
13
13
  * the published npm package ships dist/ + the compiled TUI (tui-dist/) ready to run.
14
14
  */
15
- import { spawn } from "node:child_process";
16
- import { existsSync, readFileSync } from "node:fs";
15
+ import { spawn, execSync } from "node:child_process";
16
+ import { existsSync, readFileSync, mkdirSync, writeFileSync, unlinkSync, readdirSync } from "node:fs";
17
17
  import { fileURLToPath, pathToFileURL } from "node:url";
18
18
  import { dirname, resolve, join } from "node:path";
19
19
  import { homedir } from "node:os";
20
20
 
21
+ // Fail CLEARLY on old Node — the entry + server use global fetch / AbortSignal.timeout
22
+ // (Node ≥ 18); without this guard an old runtime dies with a cryptic ReferenceError.
23
+ const nodeMajor = Number(process.versions.node.split(".")[0]);
24
+ if (nodeMajor < 18) {
25
+ console.error(`[nodedex] Node ${process.versions.node} is too old — NodeDex needs Node >= 18. Upgrade at https://nodejs.org`);
26
+ process.exit(1);
27
+ }
28
+
21
29
  const here = dirname(fileURLToPath(import.meta.url));
22
30
  const args = process.argv.slice(2);
23
31
  const cmd = (args[0] || "").toLowerCase();
@@ -35,6 +43,10 @@ Usage:
35
43
  --provider openrouter --key sk-or-... [--model google/gemini-2.5-flash-lite]
36
44
  --provider local --base-url http://localhost:11434/v1 --model <id>
37
45
  [--port 3001] [--db <name>] [--capture hermes,claude-code | none] [--dry-run]
46
+ nodedex stop Stop running NodeDex servers: \`stop\` = the ones it knows
47
+ (pidfiles + config port), \`stop 3002\` = that port,
48
+ \`stop --all\` = sweep the whole discovery range. Only kills
49
+ a process after confirming a NodeDex answers on the port.
38
50
  nodedex uninstall Remove ALL local data + config (~/.nodedex) — asks first;
39
51
  --yes skips the prompt (scripts). Does not remove the package.
40
52
  nodedex help Show this message
@@ -95,12 +107,103 @@ function startServer() {
95
107
  );
96
108
  process.exit(1);
97
109
  }
110
+ writePidFile();
98
111
  // On Windows a bare absolute path ("C:\\...") is rejected by the ESM loader;
99
112
  // it must be a file:// URL.
100
113
  import(pathToFileURL(distServer).href);
101
114
  startEnabledWatchers();
102
115
  }
103
116
 
117
+ // ─── pidfiles + `nodedex stop` ─────────────────────────────────────────────────
118
+ // The server runs IN-PROCESS of this entry (import above), so this pid IS the
119
+ // server pid. `nodedex stop [port…|--all]` reads these first; for servers it
120
+ // didn't start (TUI-launched, bare node) it falls back to a port→PID lookup.
121
+ function runDir() { return join(homedir(), ".nodedex", "run"); }
122
+ function pidFileFor(port) { return join(runDir(), `server-${port}.pid`); }
123
+
124
+ function writePidFile() {
125
+ try {
126
+ const port = Number(process.env.PORT) || 3001;
127
+ mkdirSync(runDir(), { recursive: true });
128
+ writeFileSync(pidFileFor(port), String(process.pid));
129
+ process.on("exit", () => { try { unlinkSync(pidFileFor(port)); } catch { /* */ } });
130
+ } catch { /* best-effort — stop falls back to port lookup */ }
131
+ }
132
+
133
+ /** PID listening on a local port — Windows netstat / unix lsof. Null if none. */
134
+ function pidOnPort(port) {
135
+ try {
136
+ if (process.platform === "win32") {
137
+ const out = execSync("netstat -ano -p tcp", { encoding: "utf8" });
138
+ for (const line of out.split(/\r?\n/)) {
139
+ const m = line.trim().match(/^TCP\s+\S+:(\d+)\s+\S+\s+LISTENING\s+(\d+)$/i);
140
+ if (m && Number(m[1]) === port) return Number(m[2]);
141
+ }
142
+ return null;
143
+ }
144
+ const out = execSync(`lsof -ti tcp:${port} -sTCP:LISTEN`, { encoding: "utf8" });
145
+ const pid = Number(out.trim().split(/\s+/)[0]);
146
+ return Number.isInteger(pid) ? pid : null;
147
+ } catch { return null; }
148
+ }
149
+
150
+ function killPid(pid) {
151
+ try {
152
+ if (process.platform === "win32") execSync(`taskkill /PID ${pid} /T /F`, { stdio: "ignore" });
153
+ else process.kill(pid, "SIGTERM");
154
+ return true;
155
+ } catch { return false; }
156
+ }
157
+
158
+ /** `nodedex stop [port…] | --all` — stop NodeDex servers by port. Only kills a
159
+ * process after confirming a NodeDex answers on that port (never blind-kills). */
160
+ async function stopServers() {
161
+ const wantAll = args.includes("--all");
162
+ const explicit = args.slice(1).map(Number).filter((n) => Number.isInteger(n) && n > 0);
163
+
164
+ // Candidate ports: explicit args, or (for --all / bare stop) pidfiles + config
165
+ // port + the discovery range.
166
+ let ports = explicit;
167
+ if (ports.length === 0) {
168
+ const set = new Set();
169
+ try {
170
+ for (const f of readdirSync(runDir())) {
171
+ const m = f.match(/^server-(\d+)\.pid$/);
172
+ if (m) set.add(Number(m[1]));
173
+ }
174
+ } catch { /* no run dir */ }
175
+ try {
176
+ const c = JSON.parse(readFileSync(join(homedir(), ".nodedex", "config.json"), "utf8"));
177
+ if (Number.isInteger(c.port)) set.add(c.port);
178
+ } catch { /* */ }
179
+ if (wantAll) for (const p of [3001, 3002, 3003, 3004, 3005, 3006, 3007, 3008, 3009, 3099]) set.add(p);
180
+ ports = [...set];
181
+ }
182
+ if (ports.length === 0) { console.log("[nodedex stop] no known servers (no pidfiles, no config port). Pass a port: nodedex stop 3001"); return; }
183
+
184
+ let stopped = 0;
185
+ for (const port of ports.sort((a, b) => a - b)) {
186
+ // Confirm it's a NodeDex before killing anything on the port.
187
+ let isNodedex = false;
188
+ try {
189
+ const r = await fetch(`http://127.0.0.1:${port}/api/health`, { signal: AbortSignal.timeout(1200) });
190
+ isNodedex = r.ok;
191
+ } catch { /* not up */ }
192
+ if (!isNodedex) {
193
+ if (explicit.length) console.log(` :${port} — no NodeDex answering, skipped`);
194
+ continue;
195
+ }
196
+ let pid = null;
197
+ try { pid = Number(readFileSync(pidFileFor(port), "utf8").trim()) || null; } catch { /* no pidfile */ }
198
+ if (!pid) pid = pidOnPort(port);
199
+ if (!pid) { console.log(` :${port} — NodeDex is up but its PID couldn't be resolved (kill it by hand)`); continue; }
200
+ const ok = killPid(pid);
201
+ console.log(` :${port} — ${ok ? `stopped (pid ${pid})` : `failed to kill pid ${pid}`}`);
202
+ if (ok) { stopped++; try { unlinkSync(pidFileFor(port)); } catch { /* */ } }
203
+ }
204
+ console.log(`[nodedex stop] ${stopped} server(s) stopped.`);
205
+ }
206
+
104
207
  // Headless path parity with the TUI: `nodedex run` also brings up whichever capture
105
208
  // watchers the config enables (the TUI spawns its own when it runs; this covers
106
209
  // server-only / agent-driven installs where no TUI is ever opened).
@@ -371,6 +474,9 @@ switch (cmd) {
371
474
  case "doctor":
372
475
  void connectCard();
373
476
  break;
477
+ case "stop":
478
+ void stopServers();
479
+ break;
374
480
  case "uninstall":
375
481
  void uninstall();
376
482
  break;
package/tui-dist/api.js CHANGED
@@ -9,9 +9,20 @@
9
9
  export function prefer127(url) {
10
10
  return url.replace(/\/\/localhost(?=[:/]|$)/i, "//127.0.0.1");
11
11
  }
12
+ import { loadConfig } from "./config.js";
12
13
  // The active server. Mutable so the Servers pane can switch which graph the
13
14
  // TUI reads at runtime (was a const — multi-server switching needs it live).
14
- let currentBase = prefer127((process.env.NODEDEX_TUI_API || "http://localhost:3001").replace(/\/$/, ""));
15
+ // Default base honors the CONFIG port — `nodedex run` starts the server on
16
+ // config.json's port, so the TUI must aim there by default, not a hardcoded 3001
17
+ // (the "ran nodedex, then nodedex tui, can't connect" bug).
18
+ const configPort = (() => { try {
19
+ const p = loadConfig().port;
20
+ return Number.isInteger(p) && p > 0 ? p : null;
21
+ }
22
+ catch {
23
+ return null;
24
+ } })();
25
+ let currentBase = prefer127((process.env.NODEDEX_TUI_API || `http://localhost:${configPort || 3001}`).replace(/\/$/, ""));
15
26
  // Per-server API tokens. A server launched with NODEDEX_API_TOKEN gates its WHOLE REST API,
16
27
  // so the TUI must send the token to read it. Keyed by normalized base url; setBase picks the
17
28
  // active one up automatically, so every connect path authenticates without extra plumbing.
@@ -16,7 +16,7 @@ import { Box, Text, useApp, useInput } from "ink";
16
16
  import { Logo } from "./components.js";
17
17
  import { theme } from "./theme.js";
18
18
  import { saveConfig, DEFAULT_PORT, DEFAULT_LOCAL_BASE_URL, RECOMMENDED_MODELS, isTrainsOnPrompts, validateOpenRouterKey, listDbs, dbPathForName, scanLocalModels, scanCaptureHosts, setHermesCapture, setClaudeCapture, } from "./config.js";
19
- import { launchServer, genToken, launchWatcher, stopWatcher } from "./servers.js";
19
+ import { launchServer, genToken, launchWatcher, stopWatcher, scanFreePorts } from "./servers.js";
20
20
  import { probeServer, setBase } from "./api.js";
21
21
  import { writeConnectSnippets } from "./connect-snippets.js";
22
22
  const README_URL = "https://github.com/NodeDex/NodeDex-v0.1#connect-your-agent";
@@ -106,22 +106,16 @@ export function Onboarding({ onDone }) {
106
106
  });
107
107
  return () => { cancelled = true; };
108
108
  }, [step, localScanNonce]);
109
- // Detect free ports when entering the port step (reuse probeServer: a port with no
110
- // Nodedex responding is free enough to claim; launch fails loudly otherwise).
109
+ // Detect free ports when entering the port step. scanFreePorts checks actual
110
+ // BINDABILITY (momentary bind + close), not just "no NodeDex answering" — a port
111
+ // held by any other app must never be offered, or the launch hangs and fails.
111
112
  useEffect(() => {
112
113
  if (step !== "port" || freePorts.length > 0)
113
114
  return;
114
115
  let cancelled = false;
115
116
  (async () => {
116
117
  setStatus("Scanning for free ports…");
117
- const found = [];
118
- for (const p of [3001, 3002, 3003, 3004, 3005, 3006, 3007, 3008]) {
119
- const probe = await probeServer(`http://localhost:${p}`);
120
- if (!probe.up)
121
- found.push(p);
122
- if (found.length >= 5)
123
- break;
124
- }
118
+ const found = await scanFreePorts(undefined, 5);
125
119
  if (cancelled)
126
120
  return;
127
121
  setStatus("");
@@ -144,7 +138,9 @@ export function Onboarding({ onDone }) {
144
138
  return;
145
139
  }
146
140
  const url = `http://localhost:${port}`;
147
- const deadline = Date.now() + 30000;
141
+ // 60s: a first boot on a slow disk / AV-scanned Windows can take a while to
142
+ // import 400+ files; the embedder download is background and doesn't block listen.
143
+ const deadline = Date.now() + 60000;
148
144
  while (Date.now() < deadline) {
149
145
  const probe = await probeServer(url);
150
146
  if (probe.up) {
@@ -23,7 +23,7 @@ import { dirname, resolve } from "path";
23
23
  import { homedir } from "os";
24
24
  import { mkdirSync, createWriteStream, readFileSync, writeFileSync, existsSync, readdirSync, statSync, renameSync, rmSync } from "fs";
25
25
  import { probeServer, prefer127, registerToken } from "./api.js";
26
- import { providerEnv } from "./config.js";
26
+ import { providerEnv, loadConfig } from "./config.js";
27
27
  import { randomBytes } from "node:crypto";
28
28
  // Server dir — two layouts:
29
29
  // repo/dev: <repo>/tui/src/servers.ts → ../../server (runs src via tsx)
@@ -64,7 +64,16 @@ const SESSION_FILE = resolve(NODEDEX_HOME, "tui-session.json");
64
64
  const LOG_DIR = resolve(NODEDEX_HOME, "tui-logs");
65
65
  // ports probed during discovery (plus any pinned urls). 127.0.0.1, not localhost —
66
66
  // the IPv4 host skips Windows's ::1-first penalty (see prefer127 in api.ts).
67
- const CANDIDATE_PORTS = [3001, 3002, 3003, 3004, 3005, 3099];
67
+ // The CONFIG port comes first: it's where `nodedex run` puts the server, and the
68
+ // wizard's picker offers up to 3009 — both must be discoverable.
69
+ const CONFIG_PORT = (() => { try {
70
+ const p = loadConfig().port;
71
+ return Number.isInteger(p) && p > 0 ? p : null;
72
+ }
73
+ catch {
74
+ return null;
75
+ } })();
76
+ const CANDIDATE_PORTS = [...new Set([...(CONFIG_PORT ? [CONFIG_PORT] : []), 3001, 3002, 3003, 3004, 3005, 3006, 3007, 3008, 3009, 3099])];
68
77
  const candidateUrl = (p) => `http://127.0.0.1:${p}`;
69
78
  // ─── available DB files (for the launch/swap picker) ────────────────────────
70
79
  // A port is just an access point; the DB is the content. Let the user pick the
@@ -260,8 +269,14 @@ export async function restoreSession() {
260
269
  // must NOT strand the TUI on a dead url, so fall back to a live MANAGED server and adopt it
261
270
  // as the focus so the next restart is clean.
262
271
  const connected = s.connected?.url ?? null;
263
- if (!connected && s.managed.length === 0)
272
+ // No TUI session at all — but `nodedex run` (headless) starts the server on the
273
+ // CONFIG port and never writes tui-session.json. The config is the shared source
274
+ // of truth, so probe it before concluding there's nothing to connect to.
275
+ if (!connected && s.managed.length === 0) {
276
+ if (CONFIG_PORT && (await probeServer(candidateUrl(CONFIG_PORT))).up)
277
+ return candidateUrl(CONFIG_PORT);
264
278
  return null;
279
+ }
265
280
  const deadline = Date.now() + 12000;
266
281
  while (Date.now() < deadline) {
267
282
  if (connected && (await probeServer(connected)).up)
@@ -274,6 +289,10 @@ export async function restoreSession() {
274
289
  return url;
275
290
  }
276
291
  }
292
+ // Session servers all down — a CLI-launched server on the config port still wins
293
+ // over waiting out the deadline on dead entries.
294
+ if (CONFIG_PORT && (await probeServer(candidateUrl(CONFIG_PORT))).up)
295
+ return candidateUrl(CONFIG_PORT);
277
296
  await new Promise((res) => setTimeout(res, 300));
278
297
  }
279
298
  return connected ?? (s.managed[0] ? candidateUrl(s.managed[0].port) : null); // hand back; poll shows state