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 +1 -1
- package/scripts/nodedex-entry.mjs +108 -2
- package/tui-dist/api.js +12 -1
- package/tui-dist/onboarding.js +8 -12
- package/tui-dist/servers.js +22 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nodedex",
|
|
3
|
-
"version": "0.1.
|
|
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
|
-
|
|
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.
|
package/tui-dist/onboarding.js
CHANGED
|
@@ -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
|
|
110
|
-
//
|
|
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
|
-
|
|
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) {
|
package/tui-dist/servers.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|