kandown 0.14.1 → 0.15.1

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/bin/kandown.js CHANGED
@@ -63,15 +63,17 @@ import {
63
63
  rmdirSync,
64
64
  } from 'node:fs';
65
65
  import { spawnSync, spawn, execSync } from 'node:child_process';
66
+ import { createConnection } from 'node:net';
66
67
 
67
68
  const __filename = fileURLToPath(import.meta.url);
68
69
  const __dirname = dirname(__filename);
69
70
  const PKG_ROOT = resolve(__dirname, '..');
70
71
  // 📖 Default localhost range for the zero-config `kandown` web UI server.
71
- const DEFAULT_SERVE_PORT = 2048;
72
- const MAX_SERVE_PORT = 2060;
72
+ // 📖 Single source of truth for the daemon port range. Each kandown project
73
+ // gets its own daemon on the first free port in this range, so multiple
74
+ // projects can run in parallel (A=2048, B=2049, C=2050, ...).
73
75
  const START_PORT_RANGE = 2048;
74
- const END_PORT_RANGE = 2060;
76
+ const END_PORT_RANGE = 2150;
75
77
  const DAEMON_FILE = 'daemon.json';
76
78
 
77
79
  // 📖 Get current CLI version from package.json at PKG_ROOT
@@ -306,7 +308,7 @@ ${c.bold}Commands:${c.reset}
306
308
  ${c.cyan}help${c.reset} Show this help
307
309
 
308
310
  ${c.bold}Options:${c.reset}
309
- ${c.cyan}--port <n>${c.reset} Preferred local HTTP port for ${c.cyan}kandown${c.reset} (default: ${DEFAULT_SERVE_PORT}-${MAX_SERVE_PORT})
311
+ ${c.cyan}--port <n>${c.reset} Preferred local HTTP port for ${c.cyan}kandown${c.reset} (default: ${START_PORT_RANGE}, auto-increments to ${END_PORT_RANGE})
310
312
 
311
313
  ${c.bold}Examples:${c.reset}
312
314
  ${c.dim}$${c.reset} npx kandown ${c.dim}# local web server + board TUI${c.reset}
@@ -1280,6 +1282,31 @@ async function fetchDaemonInfo(port) {
1280
1282
  }
1281
1283
  }
1282
1284
 
1285
+ /**
1286
+ * 📖 Fast, dependency-free TCP probe. Returns true the instant a port accepts
1287
+ * a connection — i.e. the moment the daemon has actually bound its socket.
1288
+ * Used instead of an HTTP fetch to detect startup, because Node's fetch
1289
+ * (undici) can fail to recover from the initial ECONNREFUSED window (fetches
1290
+ * sent before the server binds) and report a healthy local server as down for
1291
+ * seconds, which orphaned freshly-started multi-project daemons. A raw TCP
1292
+ * connect has no connection-pool state to poison, so it reliably tracks the
1293
+ * real liveness of the socket.
1294
+ */
1295
+ function isPortListening(port, timeoutMs = 400) {
1296
+ return new Promise((resolve) => {
1297
+ const socket = createConnection({ port, host: '127.0.0.1' }, () => {
1298
+ socket.destroy();
1299
+ resolve(true);
1300
+ });
1301
+ socket.on('error', () => resolve(false));
1302
+ socket.setTimeout(timeoutMs);
1303
+ socket.on('timeout', () => {
1304
+ socket.destroy();
1305
+ resolve(false);
1306
+ });
1307
+ });
1308
+ }
1309
+
1283
1310
  async function getDaemonStatus(kandownDir) {
1284
1311
  const metadata = readDaemonMetadata(kandownDir);
1285
1312
  if (!metadata) return { running: false, metadata: null };
@@ -1288,7 +1315,17 @@ async function getDaemonStatus(kandownDir) {
1288
1315
  return { running: false, metadata: null };
1289
1316
  }
1290
1317
  const remote = await fetchDaemonInfo(metadata.port);
1291
- if (!remote || remote.kandownDir !== kandownDir || remote.pid !== metadata.pid) {
1318
+ if (!remote) {
1319
+ // 📖 Transient fetch failure (server still warming up, undici connection
1320
+ // hiccup, high load). The owning process IS alive (checked above), so we
1321
+ // must NOT destroy the metadata here — otherwise starting a 2nd kandown
1322
+ // project races the just-written metadata and orphans a healthy daemon.
1323
+ // Return non-running so callers retry; the metadata stays intact.
1324
+ return { running: false, metadata: null };
1325
+ }
1326
+ if (remote.kandownDir !== kandownDir || remote.pid !== metadata.pid) {
1327
+ // 📖 Real conflict: the port is owned by a DIFFERENT kandown (another
1328
+ // project, or a reincarnated PID). That is a genuine stale entry — remove.
1292
1329
  removeDaemonMetadata(kandownDir);
1293
1330
  return { running: false, metadata: null };
1294
1331
  }
@@ -1305,12 +1342,22 @@ function refreshKandownHtml(kandownDir) {
1305
1342
  return false;
1306
1343
  }
1307
1344
 
1308
- async function waitForDaemon(kandownDir, timeoutMs = 5000) {
1345
+ async function waitForDaemon(kandownDir, timeoutMs = 8000) {
1346
+ // 📖 Detect daemon startup via TCP probe + process liveness rather than HTTP
1347
+ // fetch. The freshly spawned child is known to be ours, so once its process
1348
+ // is alive AND its port accepts a TCP connection, the daemon is up — no need
1349
+ // to wait for an HTTP round-trip that undici may not serve reliably during
1350
+ // the bind window. The dir/pid ownership check still happens later via
1351
+ // getDaemonStatus() for the status/stop commands.
1309
1352
  const started = Date.now();
1310
1353
  while (Date.now() - started < timeoutMs) {
1311
- const status = await getDaemonStatus(kandownDir);
1312
- if (status.running) return status;
1313
- await new Promise(r => setTimeout(r, 150));
1354
+ const metadata = readDaemonMetadata(kandownDir);
1355
+ if (metadata && isProcessAlive(metadata.pid)) {
1356
+ if (await isPortListening(metadata.port)) {
1357
+ return { running: true, metadata };
1358
+ }
1359
+ }
1360
+ await new Promise(r => setTimeout(r, 120));
1314
1361
  }
1315
1362
  return { running: false, metadata: null };
1316
1363
  }
@@ -1835,10 +1882,13 @@ async function listenOnAvailablePort(kandownDir, preferredPort) {
1835
1882
  const port = preferredPort ?? START_PORT_RANGE;
1836
1883
  const isSinglePort = preferredPort !== null;
1837
1884
 
1838
- // 📖 Check if the target port is already occupied by a stale kandown process.
1839
- // This happens when the TUI crashes but the HTTP server stays alive.
1885
+ // 📖 Check if the target port is already occupied by a stale kandown process
1886
+ // from the SAME project. A stale process is one whose TUI exited but the HTTP
1887
+ // server is still alive. We ONLY reclaim same-project daemons here — a daemon
1888
+ // belonging to a DIFFERENT project (reason === null) must never be touched;
1889
+ // it is handled by the scan loop below (which skips it).
1840
1890
  const staleInfo = detectStaleKandown(port, kandownDir);
1841
- if (staleInfo) {
1891
+ if (staleInfo && staleInfo.reason) {
1842
1892
  warn(staleInfo.reason);
1843
1893
  try {
1844
1894
  process.kill(staleInfo.pid, 'SIGTERM');