@sanqianx/project-knowledge 5.0.5 → 5.0.7

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.
@@ -9,7 +9,7 @@
9
9
  "plugins": [
10
10
  {
11
11
  "name": "project-knowledge",
12
- "version": "5.0.5",
12
+ "version": "5.0.7",
13
13
  "source": "./plugins/project-knowledge",
14
14
  "description": "Search durable project decisions and implementation history through MCP."
15
15
  }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## [5.0.7] - 2026-10-10
4
+
5
+ - Normalize CLI command case and reject unknown commands, extra arguments and
6
+ invalid ports before any service or data is created.
7
+ - Replace the shared temporary PID and blind orphan scan with per-data-directory
8
+ local control channels. Stop waits for the backend and owned module shutdown;
9
+ stale or unrelated PIDs are never signaled.
10
+ - Add verified local instance inventory (`status --all`), explicit targeted stop
11
+ (`stop --port`) and data-directory selection (`--data`). Display the actual
12
+ port, version and data directory; refuse unverifiable or mismatched targets.
13
+
14
+ ## [5.0.6] - 2026-10-10
15
+
16
+ - Do not mistake unregistered, abandoned legacy chat records for a knowledge
17
+ database on first launch. Preserve their bytes without restoring sessions or
18
+ importing projects; continue rejecting actual old registries and maintenance.
19
+ - Wait for the background server's real HTTP readiness before reporting success
20
+ or opening the browser. Surface startup failures in the invoking terminal with
21
+ a nonzero exit code, and name the exact conflicting data entries.
22
+ - Isolate regression data from the user's default data directory. Add a detached
23
+ npm CLI startup regression from an unrelated working directory.
24
+
3
25
  ## [5.0.5] - 2026-10-10
4
26
 
5
27
  - Invalidate in-flight registry snapshots after domain creation, project import,
package/README.md CHANGED
@@ -58,12 +58,24 @@ project-knowledge # start in the background and open the UI
58
58
  project-knowledge --fg # run in the foreground
59
59
  project-knowledge status # show the active local backend
60
60
  project-knowledge stop # stop the background backend
61
+ project-knowledge status --all # list verified local instances and data directories
62
+ project-knowledge stop --port 5804 # stop a specific verified instance
63
+ project-knowledge --data "D:/data/knowledge" # select an independent data directory
61
64
  project-knowledge --port 9000
62
65
  project-knowledge --no-open
63
66
  ```
64
67
 
65
68
  Requires Node.js 22 or newer and Git on `PATH`.
66
69
 
70
+ Commands such as `stop`, `STOP`, and `Stop` are equivalent. Unknown commands and
71
+ invalid arguments fail without starting a service. Default start/status/stop are
72
+ scoped to the selected data directory; stopping another directory requires an
73
+ explicit port or `--data`. The CLI uses a local control channel, not a shared PID
74
+ file or blind process termination. Stop succeeds only after the backend and its
75
+ owned modules finish shutdown; independently started modules are preserved.
76
+ An older or unverifiable instance is reported explicitly, never treated as safely
77
+ stopped. Stop the previous running version before upgrading.
78
+
67
79
  ### Windows desktop
68
80
 
69
81
  Version 5.0.0 delivers the web product through npm. A new Windows installer
@@ -14,9 +14,9 @@ const { spawn, spawnSync } = require('child_process');
14
14
  const ROOT = path.resolve(__dirname, '..', '..');
15
15
  const BIN = path.join(ROOT, 'bin', 'project-knowledge.js');
16
16
  const KB_BIN = path.join(ROOT, 'bin', 'project-knowledge-kb.js');
17
- const PID_FILE = path.join(os.tmpdir(), '.project-knowledge.pid');
18
17
  const ISOLATED_STATUS_PORT = 19000 + (process.pid % 1000);
19
18
  const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), `kb-bin-cli-${process.pid}-`));
19
+ const PID_FILE = path.join(TEST_DATA_DIR, 'runtime-endpoint.json');
20
20
  const TEST_BRIDGE_HOME = fs.mkdtempSync(path.join(os.tmpdir(), `kb-bin-cli-bridge-${process.pid}-`));
21
21
  const TEST_ENV = { ...process.env, KB_DATA_DIR: TEST_DATA_DIR, KB_SKIP_MIGRATION: '1', AI_CODING_EVENT_BRIDGE_HOME: TEST_BRIDGE_HOME, KB_MODULES_AUTOSTART: '0' };
22
22
 
@@ -35,8 +35,7 @@ function run(args, opts = {}) {
35
35
  function readPidFile() {
36
36
  try {
37
37
  if (!fs.existsSync(PID_FILE)) return null;
38
- const lines = fs.readFileSync(PID_FILE, 'utf-8').trim().split('\n');
39
- return { pid: parseInt(lines[0], 10), port: lines[1] ? parseInt(lines[1], 10) : null };
38
+ return JSON.parse(fs.readFileSync(PID_FILE, 'utf8'));
40
39
  } catch { return null; }
41
40
  }
42
41
 
@@ -9,6 +9,10 @@
9
9
 
10
10
  const fs = require('fs');
11
11
  const path = require('path');
12
+ const os = require('os');
13
+ const TEST_DATA = fs.mkdtempSync(path.join(os.tmpdir(), 'kb-workbench-unit-'));
14
+ process.env.KB_DATA_DIR = TEST_DATA;
15
+ process.on('exit', () => fs.rmSync(TEST_DATA, { recursive: true, force: true }));
12
16
  const runner = require('../lib/claude-cli-runner');
13
17
 
14
18
  const ROOT = path.resolve(__dirname, '..', '..');
@@ -0,0 +1,62 @@
1
+ 'use strict';
2
+ const assert = require('node:assert/strict');
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const os = require('node:os');
6
+ const http = require('node:http');
7
+ const { spawn } = require('node:child_process');
8
+ const endpoint = require('../lib/runtime-endpoint');
9
+ const control = require('../lib/runtime-control');
10
+ const packageRoot = process.env.KB_CLI_TEST_PACKAGE || path.resolve(__dirname, '../..');
11
+ const bin = path.join(packageRoot, 'bin/project-knowledge.js');
12
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'kb-instance-management-'));
13
+ const a = path.join(root, '知识库 A'), b = path.join(root, '知识库 B');
14
+ const temp = path.join(root, 'isolated-temp'); fs.mkdirSync(temp);
15
+ const env = { ...process.env, KB_DATA_DIR: a, TEMP: temp, TMP: temp, KB_MODULES_AUTOSTART: '0', AI_CODING_EVENT_BRIDGE_HOME: path.join(root, 'bridge') };
16
+ const pids = [];
17
+ function run(args) {
18
+ return new Promise((resolve, reject) => {
19
+ const child = spawn(process.execPath, [bin, ...args], { cwd: root, env, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
20
+ let stdout = '', stderr = '';
21
+ const timer = setTimeout(() => { child.kill(); reject(new Error(`CLI timeout: ${stdout} ${stderr}`)); }, 25000);
22
+ child.stdout.on('data', c => { stdout += c; }); child.stderr.on('data', c => { stderr += c; });
23
+ child.once('error', reject); child.once('exit', code => { clearTimeout(timer); resolve({ code, stdout, stderr }); });
24
+ });
25
+ }
26
+ async function usable(port) { try { return (await fetch(`http://127.0.0.1:${port}/api/health`, { signal: AbortSignal.timeout(700) })).ok; } catch { return false; } }
27
+ let foreign;
28
+ (async () => {
29
+ foreign = http.createServer((req, res) => { res.writeHead(404); res.end('not knowledge'); });
30
+ await new Promise(resolve => foreign.listen(0, '127.0.0.1', resolve));
31
+ const foreignPort = foreign.address().port;
32
+ fs.writeFileSync(path.join(temp, '.project-knowledge.pid'), `${process.pid}\n${foreignPort}`);
33
+ for (const args of [['stoop'], ['STOP', 'unexpected'], ['stop', '--port', '12junk'], ['stop', '--port', '65536'], ['stop', '--port'], ['stop', '--all']]) {
34
+ const result = await run(args); assert.equal(result.code, 1, JSON.stringify(result));
35
+ assert(!/Already running|starting in background/.test(result.stdout));
36
+ }
37
+ assert.equal(fs.existsSync(a), false, 'invalid commands must not create data or services');
38
+ const noInstance = await run(['STOP']); assert.equal(noInstance.code, 0); assert.match(noInstance.stdout, /No background/);
39
+ assert.equal(await usable(foreignPort), false); assert(foreign.listening, 'shared legacy PID must not kill an unrelated process');
40
+ const launchedA = await run(['START', '--no-open']); assert.equal(launchedA.code, 0, launchedA.stderr);
41
+ const infoA = endpoint.readEndpoint(a); pids.push(infoA.pid); assert(await usable(infoA.port));
42
+ const launchedB = await run(['start', '--data', b, '--no-open']); assert.equal(launchedB.code, 0, launchedB.stderr);
43
+ const infoB = endpoint.readEndpoint(b); pids.push(infoB.pid); assert(await usable(infoB.port)); assert.notEqual(infoA.port, infoB.port);
44
+ const status = await run(['STATUS']); assert.equal(status.code, 0); assert.match(status.stdout, new RegExp(`PID ${infoA.pid}`));
45
+ const inventory = await run(['STATUS', '--all']); assert.equal(inventory.code, 0, inventory.stderr);
46
+ assert.match(inventory.stdout, new RegExp(`PID ${infoA.pid}`)); assert.match(inventory.stdout, new RegExp(`PID ${infoB.pid}`));
47
+ const wrongPort = await run(['STOP', '--port', String(foreignPort)]); assert.equal(wrongPort.code, 1); assert(foreign.listening);
48
+ const conflict = await run(['stop', '--data', a, '--port', String(infoB.port)]); assert.equal(conflict.code, 1); assert(await usable(infoB.port));
49
+ await assert.rejects(() => control.request(a, { action: 'stop', pid: infoA.pid + 1, port: infoA.port, startedAt: infoA.startedAt }), /身份/);
50
+ assert(await usable(infoA.port));
51
+ const stoppedA = await run(['STOP']); assert.equal(stoppedA.code, 0, stoppedA.stderr); assert.match(stoppedA.stdout, new RegExp(`:${infoA.port}`));
52
+ assert.equal(await usable(infoA.port), false); assert(await usable(infoB.port), 'default stop must preserve a different data directory');
53
+ const stoppedB = await run(['stop', '--port', String(infoB.port)]); assert.equal(stoppedB.code, 0, stoppedB.stderr); assert.equal(await usable(infoB.port), false);
54
+ assert.equal((await run(['stop'])).code, 0, 'repeated stop is harmless');
55
+ fs.writeFileSync(path.join(a, 'runtime-endpoint.json'), JSON.stringify({ schema: endpoint.SCHEMA, pid: process.pid, host: '127.0.0.1', port: foreignPort }));
56
+ const stale = await run(['STOP']); assert.equal(stale.code, 1); assert.match(stale.stderr, /控制通道不可用/); assert(foreign.listening, 'a live but unrelated recorded PID is never signaled');
57
+ const refused = await run(['start', '--no-open']); assert.equal(refused.code, 1); assert(foreign.listening);
58
+ console.log('CLI instance management PASS: case, strict arguments, inventory, two directories, targeted stop, spoof protection, stale/shared PID safety');
59
+ })().catch(error => { console.error(error); process.exitCode = 1; }).finally(async () => {
60
+ foreign?.closeAllConnections(); if (foreign?.listening) await new Promise(resolve => foreign.close(resolve));
61
+ for (const data of [a, b]) try { const info = await control.request(data, { action: 'status' }, 500); await control.request(data, { action: 'stop', pid: info.instance.pid, port: info.instance.port, startedAt: info.instance.startedAt }); } catch {}
62
+ });
@@ -16,6 +16,20 @@ const { connectEngine } = require('../lib/engine-connection');
16
16
  assert.equal(fs.readFileSync(path.join(old, 'projects.json'), 'utf8'), bytes);
17
17
  const fresh = path.join(root, 'fresh'); initializeProductData(fresh); initializeProductData(fresh);
18
18
  assert.equal(JSON.parse(fs.readFileSync(path.join(fresh, 'product-data.json'))).schema, DATA_SCHEMA);
19
+ const auxiliary = path.join(root, 'auxiliary');
20
+ const session = path.join(auxiliary, 'runtime', 'claude-sessions', 'unregistered-test', 'session.json');
21
+ fs.mkdirSync(path.dirname(session), { recursive: true });
22
+ fs.writeFileSync(session, 'untouched abandoned chat');
23
+ fs.writeFileSync(path.join(auxiliary, 'launcher.log'), 'old startup failure');
24
+ initializeProductData(auxiliary);
25
+ assert.equal(fs.readFileSync(session, 'utf8'), 'untouched abandoned chat');
26
+ for (const entry of ['claims', 'runs', 'promotions', 'transactions', 'unknown-business-state']) {
27
+ const pending = path.join(root, entry);
28
+ fs.mkdirSync(path.join(pending, 'runtime', entry), { recursive: true });
29
+ fs.writeFileSync(path.join(pending, 'runtime', entry, 'record.json'), '{}');
30
+ assert.throws(() => initializeProductData(pending), error => error.code === 'SCHEMA_UNSUPPORTED' && error.message.includes(`runtime/${entry}`));
31
+ assert.equal(fs.existsSync(path.join(pending, 'product-data.json')), false);
32
+ }
19
33
  const paths = [];
20
34
  let activationStatus = 403;
21
35
  const logs = [];
@@ -0,0 +1,80 @@
1
+ 'use strict';
2
+ const assert = require('node:assert/strict');
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const os = require('node:os');
6
+ const net = require('node:net');
7
+ const { spawn } = require('node:child_process');
8
+ const endpoint = require('../lib/runtime-endpoint');
9
+
10
+ const packageRoot = process.env.KB_LAUNCHER_TEST_PACKAGE || path.resolve(__dirname, '../..');
11
+ const bin = path.join(packageRoot, 'bin/project-knowledge.js');
12
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'kb-launcher-readiness-'));
13
+ const data = path.join(root, '.project-knowledge-v5');
14
+ const record = path.join(data, 'runtime/claude-sessions/project-a/session.json');
15
+ const recordBytes = JSON.stringify({ schema: 'claude-workbench-session/v1', sessionId: 'old-test', projectSlug: 'project-a', state: 'running', metadata: { automation: true } });
16
+ fs.mkdirSync(path.dirname(record), { recursive: true });
17
+ fs.writeFileSync(record, recordBytes);
18
+ const env = { ...process.env, KB_DATA_DIR: data, KB_MODULES_AUTOSTART: '0', AI_CODING_EVENT_BRIDGE_HOME: path.join(root, 'bridge') };
19
+ let serverPid;
20
+
21
+ function run(args, overrides = {}) {
22
+ return new Promise((resolve, reject) => {
23
+ const child = spawn(process.execPath, [bin, ...args], { cwd: root, env: { ...env, ...overrides }, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
24
+ let stdout = '', stderr = '';
25
+ const timer = setTimeout(() => { child.kill(); reject(new Error(`CLI timed out: ${stdout} ${stderr}`)); }, 25000);
26
+ child.stdout.on('data', chunk => { stdout += chunk; });
27
+ child.stderr.on('data', chunk => { stderr += chunk; });
28
+ child.once('error', reject);
29
+ child.once('exit', code => { clearTimeout(timer); resolve({ code, stdout, stderr }); });
30
+ });
31
+ }
32
+ async function freePort() {
33
+ const server = net.createServer();
34
+ await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
35
+ const port = server.address().port;
36
+ await new Promise(resolve => server.close(resolve));
37
+ return port;
38
+ }
39
+
40
+ (async () => {
41
+ const port = await freePort();
42
+ const launched = await run(['--no-open'], { KB_SITE_PORT: String(port) });
43
+ serverPid = endpoint.readEndpoint(data)?.pid;
44
+ assert.equal(launched.code, 0, launched.stderr);
45
+ assert.match(launched.stdout, new RegExp(`is ready at http://127.0.0.1:${port}`));
46
+ assert(serverPid && endpoint.isProcessAlive(serverPid));
47
+ const response = await fetch(`http://127.0.0.1:${port}/api/state`);
48
+ assert.equal(response.status, 200, 'ready must mean HTTP is usable, not only a PID exists');
49
+ const state = await response.json();
50
+ assert.deepEqual(state.projects, [], 'abandoned sessions must not import projects or enqueue analysis');
51
+ assert.equal(fs.readFileSync(record, 'utf8'), recordBytes);
52
+ const again = await run(['--no-open']);
53
+ assert.equal(again.code, 0);
54
+ assert.match(again.stdout, /Already running/);
55
+ const stopped = await run(['stop']);
56
+ assert.equal(stopped.code, 0);
57
+ for (let attempt = 0; attempt < 50 && endpoint.isProcessAlive(serverPid); attempt++) await new Promise(resolve => setTimeout(resolve, 100));
58
+ assert.equal(endpoint.isProcessAlive(serverPid), false);
59
+ serverPid = null;
60
+
61
+ const oldData = path.join(root, 'actual-old-data');
62
+ fs.mkdirSync(oldData);
63
+ const original = JSON.stringify({ projectOrder: ['old-project'] });
64
+ fs.writeFileSync(path.join(oldData, 'projects.json'), original);
65
+ const failed = await run(['--no-open', '--port', String(port)], { KB_DATA_DIR: oldData });
66
+ assert.equal(failed.code, 1, 'background failures must return nonzero to the invoking terminal');
67
+ assert.match(failed.stderr, /启动失败/);
68
+ assert.match(failed.stderr, /projects\.json/);
69
+ assert(!/is ready|Listening at/.test(failed.stdout));
70
+ assert.equal(fs.readFileSync(path.join(oldData, 'projects.json'), 'utf8'), original);
71
+ assert.equal(fs.existsSync(path.join(oldData, 'product-data.json')), false);
72
+ const log = fs.readFileSync(path.join(oldData, 'launcher.log'), 'utf8');
73
+ assert.match(log, /startup failed/);
74
+ assert(!/Listening at/.test(log), 'never advertise a listener before initialization succeeds');
75
+ console.log('launcher readiness PASS: unrelated cwd, abandoned records, HTTP readiness, stop, terminal failure, legacy preservation');
76
+ })().catch(error => { console.error(error); process.exitCode = 1; }).finally(() => {
77
+ if (serverPid && endpoint.readEndpoint(data)?.pid === serverPid) {
78
+ try { process.kill(serverPid); } catch {}
79
+ }
80
+ });
@@ -54,13 +54,15 @@ function runOne(file) {
54
54
  // Every test child gets its own Bridge journal sandbox so hook-trigger /
55
55
  // server runtimes never touch the developer's ~/.ai-coding-event-bridge.
56
56
  const bridgeHome = fs.mkdtempSync(path.join(os.tmpdir(), `kb-runall-bridge-${process.pid}-`));
57
+ const dataHome = fs.mkdtempSync(path.join(os.tmpdir(), `kb-runall-data-${process.pid}-`));
57
58
  const result = spawnSync(process.execPath, [path.join(TEST_DIR, file)], {
58
59
  cwd: ROOT,
59
60
  encoding: 'utf-8',
60
61
  timeout: PER_TEST_TIMEOUT_MS,
61
- env: { ...process.env, FORCE_COLOR: '0', AI_CODING_EVENT_BRIDGE_HOME: bridgeHome, KB_MODULES_AUTOSTART: '0' },
62
+ env: { ...process.env, FORCE_COLOR: '0', KB_DATA_DIR: dataHome, AI_CODING_EVENT_BRIDGE_HOME: bridgeHome, KB_MODULES_AUTOSTART: '0' },
62
63
  });
63
64
  try { fs.rmSync(bridgeHome, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); } catch {}
65
+ try { fs.rmSync(dataHome, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); } catch {}
64
66
  const failure = result.status === 0 ? null : classifyFailure(result);
65
67
  return {
66
68
  file,
@@ -0,0 +1,43 @@
1
+ 'use strict';
2
+ const path = require('node:path');
3
+ const { execFileSync } = require('node:child_process');
4
+ const control = require('./runtime-control');
5
+
6
+ function url(info) { return `http://${info.host === '::1' ? '[::1]' : info.host}:${info.port}`; }
7
+ async function current(dataDir) {
8
+ try { const response = await control.request(dataDir, { action: 'status' }, 1500); return { ...response.instance, status: response.status }; }
9
+ catch (error) { if (['ENOENT', 'ECONNREFUSED', 'EPIPE'].includes(error.code)) return null; throw error; }
10
+ }
11
+ async function atPort(port) {
12
+ const response = await fetch(`http://127.0.0.1:${port}/api/runtime/identity`, { signal: AbortSignal.timeout(1200), headers: { Connection: 'close' } });
13
+ if (!response.ok) throw new Error(`端口 ${port} 不是可控制的知识库实例(HTTP ${response.status})。`);
14
+ const info = await response.json();
15
+ if (info.schema !== control.SCHEMA || info.name !== 'project-knowledge' || info.port !== port || !path.isAbsolute(info.dataDir || '')) throw new Error(`端口 ${port} 的实例身份无效,已拒绝控制。`);
16
+ const verified = await current(info.dataDir);
17
+ if (!verified || verified.pid !== info.pid || verified.port !== port || verified.startedAt !== info.startedAt) throw new Error(`端口 ${port} 的实例已变化,已拒绝控制。`);
18
+ return verified;
19
+ }
20
+ function listeningPorts() {
21
+ let output;
22
+ if (process.platform === 'win32') {
23
+ output = execFileSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', "Get-NetTCPConnection -State Listen -ErrorAction Stop | Where-Object { $_.LocalAddress -in @('127.0.0.1','0.0.0.0','::1','::') } | Select-Object -ExpandProperty LocalPort -Unique"], { encoding: 'utf8', windowsHide: true, timeout: 10000 });
24
+ return [...new Set(output.split(/\s+/).map(Number).filter(n => Number.isInteger(n) && n > 0 && n <= 65535))];
25
+ }
26
+ if (process.platform === 'darwin') output = execFileSync('lsof', ['-nP', '-iTCP', '-sTCP:LISTEN', '-Fn'], { encoding: 'utf8', timeout: 5000 });
27
+ else output = execFileSync('ss', ['-tlnH'], { encoding: 'utf8', timeout: 5000 });
28
+ return [...new Set([...output.matchAll(/:(\d+)(?:\s|$)/gm)].map(match => Number(match[1])))];
29
+ }
30
+ async function all() {
31
+ const ports = listeningPorts(), instances = [], seen = new Set();
32
+ // Bounded read-only probes; no PID is ever killed by inventory discovery.
33
+ for (let i = 0; i < ports.length; i += 8) {
34
+ const found = await Promise.all(ports.slice(i, i + 8).map(port => atPort(port).catch(() => null)));
35
+ for (const info of found) if (info && !seen.has(info.pid + ':' + info.port)) { seen.add(info.pid + ':' + info.port); instances.push(info); }
36
+ }
37
+ return instances.sort((a, b) => a.port - b.port);
38
+ }
39
+ async function stop(info) {
40
+ const result = await control.request(info.dataDir, { action: 'stop', pid: info.pid, port: info.port, startedAt: info.startedAt });
41
+ if (result.status !== 'stopped') throw new Error('停止尚未确认。');
42
+ }
43
+ module.exports = { current, atPort, all, stop, url };
@@ -5,6 +5,10 @@ const { DomainError } = require('./contracts');
5
5
 
6
6
  const DATA_SCHEMA = 'project-knowledge/fresh-product-data/v1';
7
7
 
8
+ function hasData(file) {
9
+ return fs.existsSync(file) && (!fs.statSync(file).isDirectory() || fs.readdirSync(file).length > 0);
10
+ }
11
+
8
12
  function initializeProductData(dataPath) {
9
13
  const marker = path.join(dataPath, 'product-data.json');
10
14
  if (fs.existsSync(marker)) {
@@ -13,11 +17,18 @@ function initializeProductData(dataPath) {
13
17
  return;
14
18
  }
15
19
  // Old registries and unfinished claims must never become fresh product work.
16
- const oldData = ['settings.json', 'projects.json', 'projects', 'runtime', 'knowledge-engine', 'workbench', 'vectorhub', 'events', 'knowledge-store.json', 'ai-profiles.json', 'embedding-config.json'].some(name => {
17
- const file = path.join(dataPath, name);
18
- return fs.existsSync(file) && (!fs.statSync(file).isDirectory() || fs.readdirSync(file).length > 0);
19
- });
20
- if (oldData) throw new DomainError('SCHEMA_UNSUPPORTED', '不再迁移旧版知识库数据。请取消旧 KB_DATA_DIR 设置,或指定新的空数据目录,然后重新导入项目。', { status: 409 });
20
+ const oldData = ['settings.json', 'projects.json', 'projects', 'knowledge-engine', 'workbench', 'vectorhub', 'events', 'knowledge-store.json', 'ai-profiles.json', 'embedding-config.json']
21
+ .filter(name => hasData(path.join(dataPath, name)));
22
+ const runtimePath = path.join(dataPath, 'runtime');
23
+ if (fs.existsSync(runtimePath)) {
24
+ if (!fs.statSync(runtimePath).isDirectory()) oldData.push('runtime');
25
+ else for (const name of fs.readdirSync(runtimePath)) {
26
+ // Unregistered legacy chat records cannot be resumed by a fresh registry.
27
+ // They are neither knowledge nor pending maintenance, and stay untouched.
28
+ if (name !== 'claude-sessions' && hasData(path.join(runtimePath, name))) oldData.push(`runtime/${name}`);
29
+ }
30
+ }
31
+ if (oldData.length) throw new DomainError('SCHEMA_UNSUPPORTED', `数据目录 ${dataPath} 包含旧版业务数据(${oldData.join('、')})。新版不会迁移或删除它们;请将 KB_DATA_DIR 指向新的空目录,然后重新导入项目。`, { status: 409, details: { dataPath, entries: oldData } });
21
32
  AtomicFile.writeJsonAtomic(marker, { schema: DATA_SCHEMA, createdAt: new Date().toISOString() });
22
33
  }
23
34
 
@@ -0,0 +1,89 @@
1
+ 'use strict';
2
+ const net = require('node:net');
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const crypto = require('node:crypto');
6
+
7
+ const SCHEMA = 'project-knowledge/runtime-control/v1';
8
+ function normalizeDataDir(value) {
9
+ const absolute = path.resolve(value);
10
+ return process.platform === 'win32' ? absolute.toLowerCase() : absolute;
11
+ }
12
+ function controlPath(dataDir) {
13
+ const hash = crypto.createHash('sha256').update(normalizeDataDir(dataDir)).digest('hex').slice(0, 32);
14
+ return process.platform === 'win32' ? `\\\\.\\pipe\\project-knowledge-${hash}` : path.join(dataDir, 'runtime-control.sock');
15
+ }
16
+ function identity(dataDir, endpoint = {}) {
17
+ return { schema: SCHEMA, name: 'project-knowledge', pid: process.pid, dataDir: path.resolve(dataDir), ...endpoint };
18
+ }
19
+ function request(dataDir, message, timeoutMs = 45000) {
20
+ return new Promise((resolve, reject) => {
21
+ const socket = net.createConnection(controlPath(dataDir));
22
+ let buffer = '', done = false;
23
+ const timer = setTimeout(() => socket.destroy(new Error('知识库控制请求超时,停止尚未确认。')), timeoutMs);
24
+ const finish = (error, result) => {
25
+ if (done) return; done = true;
26
+ clearTimeout(timer); socket.destroy();
27
+ if (error) reject(error); else resolve(result);
28
+ };
29
+ socket.once('connect', () => socket.write(JSON.stringify(message) + '\n'));
30
+ socket.on('data', chunk => {
31
+ buffer += chunk.toString();
32
+ if (buffer.length > 65536) return finish(new Error('无效的实例响应。'));
33
+ if (!buffer.includes('\n')) return;
34
+ try {
35
+ const value = JSON.parse(buffer.slice(0, buffer.indexOf('\n')));
36
+ if (!value.ok) return finish(new Error(value.message || '实例控制失败。'));
37
+ if (value.instance?.schema !== SCHEMA || normalizeDataDir(value.instance.dataDir) !== normalizeDataDir(dataDir)) return finish(new Error('实例身份不匹配,已拒绝控制。'));
38
+ finish(null, value);
39
+ } catch (error) { finish(error); }
40
+ });
41
+ socket.once('error', error => finish(error));
42
+ socket.once('end', () => { if (!buffer.includes('\n')) finish(new Error('实例断开连接,停止尚未确认。')); });
43
+ });
44
+ }
45
+
46
+ async function listen(dataDir, info, stop) {
47
+ const address = controlPath(dataDir);
48
+ if (process.platform !== 'win32' && fs.existsSync(address)) {
49
+ try { await request(dataDir, { action: 'status' }, 1000); throw new Error('另一个知识库实例已占用控制通道。'); }
50
+ catch (error) {
51
+ if (!['ECONNREFUSED', 'ENOENT'].includes(error.code)) throw error;
52
+ fs.unlinkSync(address);
53
+ }
54
+ }
55
+ const sockets = new Set();
56
+ let stopping = false, stoppingSocket;
57
+ const server = net.createServer(socket => {
58
+ sockets.add(socket); socket.once('close', () => sockets.delete(socket));
59
+ socket.on('error', () => {});
60
+ socket.setTimeout(45000, () => socket.destroy());
61
+ let buffer = '';
62
+ socket.on('data', chunk => {
63
+ buffer += chunk.toString();
64
+ if (buffer.length > 16384) return socket.destroy();
65
+ if (!buffer.includes('\n')) return;
66
+ socket.removeAllListeners('data');
67
+ const respond = value => { if (!socket.destroyed) socket.end(JSON.stringify({ ...value, instance: info }) + '\n'); };
68
+ let message;
69
+ try { message = JSON.parse(buffer.slice(0, buffer.indexOf('\n'))); }
70
+ catch { return respond({ ok: false, message: '无效的控制请求。' }); }
71
+ if (message.action === 'status') return respond({ ok: true, status: stopping ? 'stopping' : 'running' });
72
+ // A stale endpoint/port must never become a signal to an unrelated PID.
73
+ if (message.action !== 'stop' || message.pid !== info.pid || message.port !== info.port || message.startedAt !== info.startedAt) return respond({ ok: false, message: '实例身份已变化,已拒绝停止。' });
74
+ if (stopping) return respond({ ok: false, message: '实例正在停止,请稍后查询状态。' });
75
+ stopping = true; stoppingSocket = socket;
76
+ Promise.resolve().then(stop).then(() => respond({ ok: true, status: 'stopped' })).catch(error => respond({ ok: false, message: error.message }));
77
+ });
78
+ });
79
+ await new Promise((resolve, reject) => { server.once('error', reject); server.listen(address, resolve); });
80
+ if (process.platform !== 'win32') fs.chmodSync(address, 0o600);
81
+ return {
82
+ close() {
83
+ // Keep only the requesting socket for the final shutdown acknowledgement.
84
+ return new Promise(resolve => { server.close(resolve); for (const socket of sockets) if (socket !== stoppingSocket) socket.destroy(); if (stopping) resolve(); });
85
+ },
86
+ };
87
+ }
88
+
89
+ module.exports = { SCHEMA, normalizeDataDir, identity, controlPath, request, listen };
@@ -5,6 +5,7 @@ const crypto = require('crypto');
5
5
 
6
6
  const dataDir = require('./data-dir');
7
7
  const runtimeEndpoint = require('./runtime-endpoint');
8
+ const runtimeControl = require('./runtime-control');
8
9
  const hookManager = require('./hook-manager');
9
10
  const claudeCliRunner = require('./claude-cli-runner');
10
11
  const { StorageLayout } = require('./storage-layout');
@@ -965,6 +966,10 @@ function createRequestHandler(runtime, options = {}) {
965
966
  if (method === 'GET' && pathname === '/api/health') {
966
967
  return send(res, 200, { ok: true, schema: 'server-health/v2', logger: runtime.logger.getHealth(), projects: runtime.registryStore.listIds().length });
967
968
  }
969
+ if (method === 'GET' && pathname === '/api/runtime/identity') {
970
+ if (!isLoopback(req.socket?.remoteAddress?.replace(/^::ffff:/, ''))) throw new DomainError('ORIGIN_FORBIDDEN', 'Runtime identity is local-only.', { status: 403 });
971
+ return send(res, 200, runtime.instanceIdentity);
972
+ }
968
973
  if (method === 'GET' && pathname === '/api/state') {
969
974
  const settings = runtime.settingsStore.readPublicView();
970
975
  return send(res, 200, { ok: true, schema: 'server-state/v2', settings, projects: listProjectViews(runtime.registryStore, runtime.projectStore), logger: runtime.logger.getHealth() });
@@ -1467,6 +1472,7 @@ async function startServer(options = {}) {
1467
1472
  }
1468
1473
  let startup;
1469
1474
  let server;
1475
+ let controlServer;
1470
1476
  try {
1471
1477
  startup = await initializeRuntime(runtime);
1472
1478
  const handler = createRequestHandler(runtime, { ...options, port, host });
@@ -1533,10 +1539,14 @@ async function startServer(options = {}) {
1533
1539
  if (runtime.bridgeConsumerService) await runtime.bridgeConsumerService.stop().catch(() => {});
1534
1540
  await runtime.moduleBridge.stopSupervised();
1535
1541
  await runtime.logger.close();
1542
+ await controlServer?.close();
1536
1543
  runtimeEndpoint.clearEndpoint(runtime.dataPath, { pid: process.pid });
1537
1544
  })();
1538
1545
  return stopping;
1539
1546
  };
1547
+ runtime.instanceIdentity = runtimeControl.identity(runtime.dataPath, { host, port, version: require('../../package.json').version, startedAt: runtimeEndpoint.readEndpoint(runtime.dataPath)?.startedAt || new Date().toISOString() });
1548
+ try { controlServer = await runtimeControl.listen(runtime.dataPath, runtime.instanceIdentity, () => stop('cli-control')); }
1549
+ catch (error) { await stop('control-startup-failed'); throw error; }
1540
1550
  return { server, runtime, startup, startupPromise, stop, host, port };
1541
1551
  }
1542
1552
 
package/_site/server.js CHANGED
@@ -3,10 +3,13 @@ const { startServer, installProcessHandlers } = require('./lib/server-app');
3
3
 
4
4
  const instancePromise = startServer();
5
5
  installProcessHandlers(instancePromise);
6
+ module.exports = instancePromise;
6
7
 
7
8
  instancePromise.then(instance => {
8
9
  process.stdout.write(`Project Knowledge listening on http://${instance.host}:${instance.port}\n`);
9
10
  }).catch(error => {
10
- process.stderr.write(`[project-knowledge] startup failed: ${String(error && error.message || error)}\n`);
11
- process.exitCode = 1;
11
+ if (require.main === module) {
12
+ process.stderr.write(`[project-knowledge] startup failed: ${String(error && error.message || error)}\n`);
13
+ process.exitCode = 1;
14
+ }
12
15
  });
@@ -1,439 +1,170 @@
1
1
  #!/usr/bin/env node
2
-
3
- // project-knowledge CLI — start in background by default (auto-opens browser).
4
- // Pattern modeled on tokmeter's bin.js: detached child + PID file + port fallback.
5
- // Run `project-knowledge --help` for usage.
6
-
7
- const path = require('path');
8
- const { spawn, spawnSync, exec, execSync } = require('child_process');
9
- const { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync, openSync } = require('fs');
10
- const net = require('net');
11
- const os = require('os');
12
- const { getDataDir } = require('../_site/lib/data-dir');
13
- const runtimeEndpoint = require('../_site/lib/runtime-endpoint');
14
-
2
+ 'use strict';
3
+ const path = require('node:path');
4
+ const fs = require('node:fs');
5
+ const net = require('node:net');
6
+ const { spawn, spawnSync, exec } = require('node:child_process');
7
+ const { resolveDataDirPath } = require('../_site/lib/data-dir');
8
+ const endpoint = require('../_site/lib/runtime-endpoint');
9
+ const instances = require('../_site/lib/cli-instances');
15
10
  const pkg = require('../package.json');
16
- const DEFAULT_PORT = parseInt(process.env.KB_SITE_PORT || '5757', 10);
17
- const PORT_RANGE = 20;
18
- const PID_FILE = path.join(os.tmpdir(), '.project-knowledge.pid');
19
- const DATA_DIR = getDataDir();
20
-
21
- function readPid() {
22
- try {
23
- if (!existsSync(PID_FILE)) return null;
24
- const lines = readFileSync(PID_FILE, 'utf8').trim().split('\n');
25
- return parseInt(lines[0], 10);
26
- } catch { return null; }
27
- }
28
-
29
- function readPort() {
30
- try {
31
- if (!existsSync(PID_FILE)) return null;
32
- const lines = readFileSync(PID_FILE, 'utf8').trim().split('\n');
33
- return lines[1] ? parseInt(lines[1], 10) : null;
34
- } catch { return null; }
35
- }
36
-
37
- function writePid(pid, port) {
38
- try {
39
- writeFileSync(PID_FILE, `${pid}\n${port}`, 'utf8');
40
- } catch (err) {
41
- console.error(`Warning: failed to write PID file at ${PID_FILE}: ${err.message}`);
42
- }
43
- }
44
-
45
- function removePid(expectedPid) {
46
- try {
47
- if (expectedPid != null && readPid() !== Number(expectedPid)) return;
48
- unlinkSync(PID_FILE);
49
- } catch { /* ignore */ }
50
- }
51
-
52
- function isProcessAlive(pid) {
53
- if (!pid || Number.isNaN(pid)) return false;
54
- try {
55
- process.kill(pid, 0);
56
- return true;
57
- } catch {
58
- return false;
59
- }
60
- }
61
-
62
- function openBrowser(url) {
63
- let cmd;
64
- if (process.platform === 'win32') {
65
- cmd = `start "" "${url}"`;
66
- } else if (process.platform === 'darwin') {
67
- cmd = `open "${url}"`;
68
- } else {
69
- cmd = `xdg-open "${url}"`;
70
- }
71
- try { exec(cmd); } catch { /* best-effort */ }
72
- }
73
-
74
- function isPortFree(port, host = '127.0.0.1') {
75
- return new Promise((resolve) => {
76
- const tester = net.createServer();
77
- let settled = false;
78
- const finish = (free) => {
79
- if (settled) return;
80
- settled = true;
81
- tester.removeAllListeners();
82
- tester.close(() => resolve(free));
83
- };
84
- tester.once('error', () => finish(false));
85
- tester.once('listening', () => finish(true));
86
- tester.listen(port, host);
87
- // Under AV scan / cold cache a listen() can be slower than the probe
88
- // timeout; a false "busy" cascades into "No free port found". 3s is slow
89
- // enough for one probe while still bounding the 20-port scan.
90
- setTimeout(() => finish(false), 3000);
91
- });
92
- }
93
-
94
- async function findFreePort(start, host = '127.0.0.1') {
95
- for (let offset = 0; offset < PORT_RANGE; offset++) {
96
- const port = start + offset;
97
- if (await isPortFree(port, host)) return port;
98
- }
99
- throw new Error(`No free port found in range ${start}-${start + PORT_RANGE - 1}`);
100
- }
101
-
102
- // Find PIDs whose TCP socket is LISTENING on `port`. Cross-platform wrapper
103
- // around netstat / lsof / ss. Returns a deduped list, possibly empty.
104
- function findListeningPids(port) {
105
- try {
106
- let cmd;
107
- if (process.platform === 'win32') {
108
- cmd = `netstat -ano | findstr ":${port} " | findstr "LISTENING"`;
109
- } else if (process.platform === 'darwin') {
110
- cmd = `lsof -nP -iTCP:${port} -sTCP:LISTEN -t 2>/dev/null`;
111
- } else {
112
- cmd = `ss -tlnpH 'sport = :${port}' 2>/dev/null | grep -oP 'pid=\\K[0-9]+'`;
113
- }
114
- const stdout = execSync(cmd, { windowsHide: true, encoding: 'utf8', timeout: 3000 });
115
- const pids = stdout.split(/\r?\n/).map((line) => {
116
- const nums = line.match(/\d+/g);
117
- return nums && nums.length ? parseInt(nums[nums.length - 1], 10) : null;
118
- }).filter(Boolean);
119
- return Array.from(new Set(pids));
120
- } catch {
121
- return [];
122
- }
123
- }
124
-
125
- // Read another process's command line so we can verify it's ours before
126
- // killing it. Returns '' on any failure (permissions, process gone, etc).
127
- function getProcessCommandLine(pid) {
128
- try {
129
- let cmd, stdout;
130
- if (process.platform === 'win32') {
131
- stdout = execSync(`wmic process where "ProcessId=${pid}" get CommandLine /value`,
132
- { windowsHide: true, encoding: 'utf8', timeout: 3000 });
133
- const m = stdout.match(/CommandLine=(.+)/);
134
- return m ? m[1].trim() : '';
135
- }
136
- stdout = execSync(`ps -p ${pid} -o args= 2>/dev/null`,
137
- { encoding: 'utf8', timeout: 3000 });
138
- return stdout.trim();
139
- } catch {
140
- return '';
141
- }
142
- }
143
-
144
- // Scan DEFAULT_PORT..+PORT_RANGE for a LISTENING PID whose command line looks
145
- // like our server. Used as a fallback when the PID file is missing/stale so the
146
- // CLI isn't blind to long-running orphans.
147
- function findOrphanProcess() {
148
- const startPort = readPort() || DEFAULT_PORT;
149
- for (let offset = 0; offset < PORT_RANGE; offset++) {
150
- const port = startPort + offset;
151
- for (const candidate of findListeningPids(port)) {
152
- const cmdline = getProcessCommandLine(candidate).toLowerCase();
153
- if (cmdline.includes('project-knowledge') && cmdline.includes('node')) {
154
- return { pid: candidate, port };
155
- }
156
- }
157
- }
158
- return null;
159
- }
160
-
161
- // ── Subcommands ──
162
- function cmdStop() {
163
- const endpoint = runtimeEndpoint.readLiveEndpoint(DATA_DIR);
164
- if (endpoint) {
165
- try {
166
- process.kill(endpoint.pid);
167
- runtimeEndpoint.clearEndpoint(DATA_DIR, { pid: endpoint.pid });
168
- if (readPid() === endpoint.pid) removePid(endpoint.pid);
169
- console.log(`project-knowledge stopped (PID ${endpoint.pid}).`);
170
- } catch {
171
- console.error(`Failed to stop process ${endpoint.pid}`);
172
- process.exit(1);
173
- }
174
- process.exit(0);
175
- }
176
- const pid = readPid();
177
- if (pid && isProcessAlive(pid)) {
178
- try {
179
- process.kill(pid);
180
- removePid();
181
- console.log(`project-knowledge stopped (PID ${pid}).`);
182
- } catch {
183
- console.error(`Failed to stop process ${pid}`);
184
- process.exit(1);
185
- }
186
- process.exit(0);
187
- }
188
- if (pid) {
189
- // Stale PID file — record pointed at a dead process. Drop it so the
190
- // orphan-scan below can take over without being misled.
191
- removePid();
192
- console.log('Process already stopped.');
193
- }
194
-
195
- // Fallback: the PID file can disappear (manual cleanup, antivirus, OS temp
196
- // cleanup) while the server keeps running. Without this scan the CLI has no
197
- // way to stop an orphan whose PID it never recorded.
198
- const orphan = findOrphanProcess();
199
- if (orphan) {
200
- try {
201
- process.kill(orphan.pid);
202
- console.log(`Stopped orphan project-knowledge (PID ${orphan.pid}) on port ${orphan.port}.`);
203
- process.exit(0);
204
- } catch {
205
- console.error(`Found PID ${orphan.pid} on port ${orphan.port} but failed to stop it.`);
206
- process.exit(1);
207
- }
208
- }
209
- console.log('No background process found.');
210
- process.exit(0);
211
- }
212
-
213
- function cmdStatus() {
214
- const endpoint = runtimeEndpoint.readLiveEndpoint(DATA_DIR);
215
- if (endpoint) {
216
- console.log(`project-knowledge is running (PID ${endpoint.pid}) at http://${endpoint.host}:${endpoint.port} [${endpoint.mode}]`);
217
- process.exit(0);
218
- }
219
- const pid = readPid();
220
- if (pid && isProcessAlive(pid)) {
221
- const port = readPort() || DEFAULT_PORT;
222
- console.log(`project-knowledge is running (PID ${pid}) at http://localhost:${port}`);
223
- process.exit(0);
224
- }
225
- if (pid) removePid();
226
-
227
- // PID file is gone or stale — check the port directly.
228
- const orphan = findOrphanProcess();
229
- if (orphan) {
230
- console.log(`project-knowledge is running (orphan, PID ${orphan.pid}) at http://localhost:${orphan.port}`);
231
- console.log('(No PID file on disk — recovered via port scan. Run "project-knowledge stop" to clean up.)');
232
- process.exit(0);
233
- }
234
- console.log('project-knowledge is not running.');
235
- process.exit(0);
236
- }
237
11
 
238
- function printHelp() {
12
+ function help() {
239
13
  console.log(`project-knowledge ${pkg.version}
240
14
 
241
- Local knowledge-base dashboard manager.
242
-
243
15
  Usage:
244
- project-knowledge Start in background (default), auto-open browser
245
- project-knowledge --fg Start in foreground (Ctrl+C to stop)
246
- project-knowledge stop Stop the background process
247
- project-knowledge status Check if running
248
- project-knowledge install Install coding-agent integrations
249
- project-knowledge update Update coding-agent integrations
250
- project-knowledge uninstall Remove coding-agent integrations
251
- project-knowledge integrations status
252
- Check Claude Code, OpenCode, and Codex integrations
16
+ project-knowledge [start] Start in background and open the UI
17
+ project-knowledge --fg Start in foreground
18
+ project-knowledge stop Stop this data directory and its owned modules
19
+ project-knowledge status Show this data directory's instance
20
+ project-knowledge status --all List controllable local knowledge instances
21
+ project-knowledge stop --port 5804
22
+ Stop the verified instance on this port
23
+ project-knowledge install|update|uninstall|integrations
24
+ Manage coding-agent integrations
253
25
 
254
26
  Options:
255
- -p, --port <port> Port to run on (default: ${DEFAULT_PORT}, auto-fallback ±${PORT_RANGE})
256
- --host <host> Host to bind on (default: 127.0.0.1)
257
- --no-open Don't auto-open browser
258
- --fg Run in foreground
259
- -v, --version Print version and exit
260
- -h, --help Show this help message
261
-
262
- Runtime data lives next to the npm global root; PID file at ${PID_FILE}.
263
- `);
264
- process.exit(0);
27
+ -p, --port <port> Port (default: 5757, startup auto-fallback +19)
28
+ --data <dir> Select data directory (default: ~/.project-knowledge-v5)
29
+ --host <host> Bind host (default: 127.0.0.1)
30
+ --no-open Do not open browser
31
+ --fg Run in foreground
32
+ -v, --version Print version
33
+ -h, --help Show help
34
+
35
+ Commands are case-insensitive. Unknown commands never start a service.`);
36
+ }
37
+ function parse(args) {
38
+ const commands = new Set(['start', 'stop', 'status', 'install', 'update', 'uninstall', 'integrations']);
39
+ let command = 'start';
40
+ if (args[0] && !args[0].startsWith('-')) {
41
+ command = args.shift().toLowerCase();
42
+ if (!commands.has(command)) throw new Error(`Unknown command: ${command}. Run project-knowledge --help.`);
43
+ }
44
+ if (['install', 'update', 'uninstall', 'integrations'].includes(command)) return { command, forwarded: args };
45
+ const options = { command, port: Number(process.env.KB_SITE_PORT || 5757), host: '127.0.0.1', open: true };
46
+ for (let i = 0; i < args.length; i++) {
47
+ const arg = args[i];
48
+ if (arg === '--help' || arg === '-h') options.help = true;
49
+ else if (arg === '--version' || arg === '-v') options.version = true;
50
+ else if (['--port', '-p', '--host', '--data'].includes(arg)) {
51
+ const value = args[++i];
52
+ if (!value || value.startsWith('-')) throw new Error(`Missing value for ${arg}.`);
53
+ if (arg === '--port' || arg === '-p') { options.port = Number(value); options.portExplicit = true; }
54
+ else if (arg === '--data') options.dataDir = value;
55
+ else options.host = value;
56
+ } else if (arg === '--no-open') options.open = false;
57
+ else if (arg === '--fg') options.foreground = true;
58
+ else if (arg === '--all') options.all = true;
59
+ else throw new Error(`${arg.startsWith('-') ? 'Unknown option' : 'Unexpected argument'}: ${arg}.`);
60
+ }
61
+ if (!Number.isInteger(options.port) || options.port < 1 || options.port > 65535) throw new Error('Port must be an integer from 1 to 65535.');
62
+ if (options.all && (command !== 'status' || options.portExplicit)) throw new Error('--all is only supported by status, without --port.');
63
+ if (command !== 'start' && (options.foreground || !options.open || options.host !== '127.0.0.1')) throw new Error('Startup options cannot be used with stop/status.');
64
+ return options;
265
65
  }
266
-
267
- // ── Parse args ──
268
- const args = process.argv.slice(2);
269
-
270
- const integrationCommands = new Set(['install', 'update', 'uninstall', 'integrations']);
271
- if (integrationCommands.has(args[0])) {
272
- const forwarded = args[0] === 'integrations'
273
- ? (args.slice(1).length ? args.slice(1) : ['status'])
274
- : args;
275
- const result = spawnSync(
276
- process.execPath,
277
- [path.join(__dirname, 'project-knowledge-integrations.js'), ...forwarded],
278
- { stdio: 'inherit', windowsHide: true }
279
- );
280
- process.exit(result.status == null ? 1 : result.status);
66
+ function openBrowser(url) {
67
+ const command = process.platform === 'win32' ? `start "" "${url}"` : process.platform === 'darwin' ? `open "${url}"` : `xdg-open "${url}"`;
68
+ exec(command, () => {});
69
+ }
70
+ function notify(message) { if (process.connected) process.send(message, () => {}); }
71
+ function waitForStartup(child, log) {
72
+ return new Promise((resolve, reject) => {
73
+ const finish = (error, value) => {
74
+ clearTimeout(timer); child.removeAllListeners('message'); child.removeAllListeners('exit');
75
+ if (child.connected) child.disconnect();
76
+ if (error) reject(error); else resolve(value);
77
+ };
78
+ const timer = setTimeout(() => finish(new Error(`启动尚未完成,请运行 project-knowledge status 并查看 ${log}`)), 45000);
79
+ child.on('message', message => {
80
+ if (message?.type === 'kb:launcher-ready') finish(null, message);
81
+ else if (message?.type === 'kb:launcher-failed') finish(new Error(message.message));
82
+ });
83
+ child.once('error', error => finish(error));
84
+ child.once('exit', code => finish(new Error(`后台进程在启动完成前退出(${code}),请查看 ${log}`)));
85
+ });
281
86
  }
282
-
283
- // Subcommands first
284
- if (args[0] === 'stop') cmdStop();
285
- if (args[0] === 'status') cmdStatus();
286
-
287
- let port = DEFAULT_PORT;
288
- let host = '127.0.0.1';
289
- let shouldOpen = true;
290
- let foreground = false;
291
- let portExplicit = false;
292
- let hostExplicit = false;
293
-
294
- for (let i = 0; i < args.length; i++) {
295
- const arg = args[i];
296
- if (arg === '--help' || arg === '-h') {
297
- printHelp();
298
- } else if (arg === '--version' || arg === '-v') {
299
- console.log(pkg.version);
300
- process.exit(0);
301
- } else if ((arg === '--port' || arg === '-p') && args[i + 1]) {
302
- const parsed = parseInt(args[i + 1], 10);
303
- if (Number.isNaN(parsed)) {
304
- console.error('Error: --port must be a number');
305
- process.exit(1);
87
+ function portFree(port, host) {
88
+ return new Promise(resolve => {
89
+ const tester = net.createServer();
90
+ let done = false;
91
+ const timer = setTimeout(() => finish(false), 3000);
92
+ function finish(free) {
93
+ if (done) return; done = true; clearTimeout(timer); tester.close(() => resolve(free));
306
94
  }
307
- port = parsed;
308
- portExplicit = true;
309
- i++;
310
- } else if (arg === '--host' && args[i + 1]) {
311
- host = args[i + 1];
312
- hostExplicit = true;
313
- i++;
314
- } else if (arg === '--no-open') {
315
- shouldOpen = false;
316
- } else if (arg === '--fg') {
317
- foreground = true;
318
- } else if (arg.startsWith('-')) {
319
- console.error(`Unknown option: ${arg}\nRun \`project-knowledge --help\` for usage.`);
320
- process.exit(1);
321
- }
95
+ tester.once('error', () => finish(false)); tester.once('listening', () => finish(true)); tester.listen(port, host);
96
+ });
322
97
  }
323
-
324
- // ── Background launch ──
325
- if (!foreground) {
326
- const endpoint = runtimeEndpoint.readLiveEndpoint(DATA_DIR);
327
- if (endpoint) {
328
- const url = `http://${endpoint.host}:${endpoint.port}`;
329
- console.log(`Already running (PID ${endpoint.pid}) at ${url}`);
330
- if (shouldOpen) openBrowser(url);
331
- process.exit(0);
332
- }
333
- const existingPid = readPid();
334
- if (existingPid && isProcessAlive(existingPid)) {
335
- const actualPort = readPort() || DEFAULT_PORT;
336
- const url = `http://localhost:${actualPort}`;
337
- console.log(`Already running (PID ${existingPid}) at ${url}`);
338
- if (shouldOpen) openBrowser(url);
339
- process.exit(0);
340
- }
341
- removePid();
342
-
343
- // Re-spawn self with --fg so the child owns the PID file
344
- const forwarded = [];
345
- if (portExplicit) forwarded.push('--port', String(port));
346
- if (hostExplicit) forwarded.push('--host', host);
347
- if (!shouldOpen) forwarded.push('--no-open');
348
-
349
- // The parent doesn't know the final port (the child may fall back when the
350
- // default is busy), so it must not print a URL or open the browser — the
351
- // child does both once it is actually listening. The child's output goes to
352
- // a log file: a detached child with stdio:'ignore' is undiagnosable when
353
- // startup fails or the port falls back.
354
- const LAUNCH_LOG = path.join(DATA_DIR, 'launcher.log');
355
- let logFd;
356
- try {
357
- mkdirSync(path.dirname(LAUNCH_LOG), { recursive: true });
358
- logFd = openSync(LAUNCH_LOG, 'a');
359
- } catch { /* best-effort: fall back to discarding output */ }
360
- const stdio = logFd == null ? 'ignore' : ['ignore', logFd, logFd];
361
-
362
- const child = spawn(
363
- process.execPath,
364
- [...process.argv.slice(1), '--fg', ...forwarded],
365
- { detached: true, stdio, windowsHide: true }
366
- );
367
- child.unref();
368
-
369
- console.log('project-knowledge starting in background');
370
- console.log(`Diagnostics: ${LAUNCH_LOG}`);
371
- console.log(`Use "project-knowledge status" to check, "project-knowledge stop" to stop.`);
372
- process.exit(0);
98
+ async function freePort(port, host) {
99
+ for (let offset = 0; offset < 20 && port + offset <= 65535; offset++) if (await portFree(port + offset, host)) return port + offset;
100
+ throw new Error(`No free port found near ${port}.`);
373
101
  }
374
-
375
- // ── Foreground: start server ──
376
- process.env.KB_SITE_HOST = host;
102
+ function show(info) { console.log(`project-knowledge is ${info.status === 'stopping' ? 'stopping' : 'running'} (PID ${info.pid}) at ${instances.url(info)} [${info.version}]\nData dir: ${info.dataDir}`); }
377
103
 
378
104
  async function main() {
379
- const existingEndpoint = runtimeEndpoint.readLiveEndpoint(DATA_DIR);
380
- if (existingEndpoint && existingEndpoint.pid !== process.pid) {
381
- console.log(`Already running (PID ${existingEndpoint.pid}) at http://${existingEndpoint.host}:${existingEndpoint.port}`);
382
- process.exit(0);
383
- }
384
- const actualPort = portExplicit ? port : await findFreePort(port, host);
385
- process.env.KB_SITE_PORT = String(actualPort);
386
-
387
- const claim = runtimeEndpoint.claimEndpoint(DATA_DIR, {
388
- pid: process.pid,
389
- host,
390
- port: actualPort,
391
- mode: process.env.KB_RUNTIME_MODE || 'cli',
392
- });
393
- if (!claim.claimed) {
394
- const active = claim.endpoint;
395
- if (active) {
396
- console.log(`Already running (PID ${active.pid}) at http://${active.host}:${active.port}`);
397
- } else {
398
- console.error('Another project-knowledge process is starting. Please try again in a moment.');
399
- }
400
- process.exit(active ? 0 : 1);
401
- }
402
- writePid(process.pid, actualPort);
403
- const cleanup = () => {
404
- removePid(process.pid);
405
- runtimeEndpoint.clearEndpoint(DATA_DIR, { pid: process.pid });
406
- };
407
- process.on('exit', cleanup);
408
- process.on('SIGINT', () => { cleanup(); process.exit(0); });
409
- process.on('SIGTERM', () => { cleanup(); process.exit(0); });
410
- // Windows console-close sends SIGBREAK; Node doesn't run exit handlers after
411
- // a forceful TerminateProcess, but SIGBREAK does fire before the process is
412
- // torn down, so we can still clean up here.
413
- if (process.platform === 'win32') {
414
- process.on('SIGBREAK', () => { cleanup(); process.exit(0); });
415
- }
416
-
417
- const url = `http://localhost:${actualPort}`;
418
- if (actualPort !== port) {
419
- console.log(`(Port ${port} was busy, using ${actualPort} instead)`);
420
- }
421
- console.log(`project-knowledge ${pkg.version}`);
422
- console.log(`Listening at ${url}`);
423
- // Resolve data dir the same way server.js will, so we can show the user
424
- // where their config and KB files live BEFORE the server boots.
425
- try {
426
- console.log(`Data dir: ${getDataDir()}`);
427
- } catch {}
428
-
429
- require(path.join(__dirname, '..', '_site', 'server.js'));
430
-
431
- if (shouldOpen) setTimeout(() => openBrowser(url), 500);
432
- }
433
-
434
- main().catch((err) => {
435
- console.error(err.message);
436
- removePid(process.pid);
437
- runtimeEndpoint.clearEndpoint(DATA_DIR, { pid: process.pid });
438
- process.exit(1);
105
+ const options = parse(process.argv.slice(2));
106
+ if (options.help) { help(); return; }
107
+ if (options.version) { console.log(pkg.version); return; }
108
+ if (options.forwarded) {
109
+ const args = options.command === 'integrations' ? (options.forwarded.length ? options.forwarded : ['status']) : [options.command, ...options.forwarded];
110
+ const result = spawnSync(process.execPath, [path.join(__dirname, 'project-knowledge-integrations.js'), ...args], { stdio: 'inherit', windowsHide: true });
111
+ process.exitCode = result.status ?? 1; return;
112
+ }
113
+ const dataDir = resolveDataDirPath({ dataDir: options.dataDir });
114
+ process.env.KB_DATA_DIR = dataDir;
115
+ if (options.all) {
116
+ const found = await instances.all();
117
+ if (!found.length) console.log('No controllable project-knowledge instances found.');
118
+ for (const info of found) show(info);
119
+ return;
120
+ }
121
+ const existing = options.command !== 'start' && options.portExplicit ? await instances.atPort(options.port) : await instances.current(dataDir);
122
+ const unverified = !existing && endpoint.readLiveEndpoint(dataDir, { removeStale: false });
123
+ if (unverified) throw new Error(`数据目录已有实例记录(PID ${unverified.pid},端口 ${unverified.port}),但控制通道不可用。请先确认该实例,不会重复启动、误停止或报告已停止。`);
124
+ if (existing && options.dataDir && options.command !== 'start' && path.resolve(existing.dataDir).toLowerCase() !== dataDir.toLowerCase()) throw new Error('指定端口不属于指定数据目录,已拒绝控制。');
125
+ if (options.command === 'status') {
126
+ if (existing) show(existing);
127
+ else console.log(`project-knowledge is not running for ${dataDir}.\nUse "project-knowledge status --all" to inspect other data directories.`);
128
+ return;
129
+ }
130
+ if (options.command === 'stop') {
131
+ if (!existing) { console.log(`No background process found for ${dataDir}.`); return; }
132
+ await instances.stop(existing);
133
+ console.log(`project-knowledge stopped (PID ${existing.pid}) at ${instances.url(existing)}.\nData dir: ${existing.dataDir}`);
134
+ return;
135
+ }
136
+ if (existing) {
137
+ if (existing.status === 'stopping') throw new Error('实例正在停止,请稍后再启动。');
138
+ const url = instances.url(existing); console.log(`Already running (PID ${existing.pid}) at ${url}\nData dir: ${existing.dataDir}`);
139
+ notify({ type: 'kb:launcher-ready', url }); if (options.open) openBrowser(url); return;
140
+ }
141
+ // A live claim with no control channel is starting, legacy, or unverified.
142
+ // Never trust a reused/shared PID as proof or kill it to make room.
143
+ if (!options.foreground) {
144
+ fs.mkdirSync(dataDir, { recursive: true });
145
+ const log = path.join(dataDir, 'launcher.log'), fd = fs.openSync(log, 'a');
146
+ const child = spawn(process.execPath, [...process.argv.slice(1), '--fg'], { detached: true, stdio: ['ignore', fd, fd, 'ipc'], windowsHide: true });
147
+ fs.closeSync(fd); child.unref();
148
+ console.log(`project-knowledge starting in background\nDiagnostics: ${log}`);
149
+ const ready = await waitForStartup(child, log);
150
+ console.log(`project-knowledge is ready at ${ready.url}\nUse "project-knowledge status" to check, "project-knowledge stop" to stop.`);
151
+ return;
152
+ }
153
+ const port = options.portExplicit ? options.port : await freePort(options.port, options.host);
154
+ process.env.KB_SITE_HOST = options.host; process.env.KB_SITE_PORT = String(port);
155
+ console.log(`project-knowledge ${pkg.version}\nData dir: ${dataDir}`);
156
+ if (port !== options.port) console.log(`(Port ${options.port} was busy, using ${port} instead)`);
157
+ const { startServer, installProcessHandlers } = require('../_site/lib/server-app');
158
+ const promise = startServer(); installProcessHandlers(promise);
159
+ const instance = await promise;
160
+ if (process.platform === 'win32') process.once('SIGBREAK', () => instance.stop('SIGBREAK'));
161
+ const url = instances.url({ host: options.host, port });
162
+ console.log(`Listening at ${url}`); notify({ type: 'kb:launcher-ready', url });
163
+ if (options.open) setTimeout(() => openBrowser(url), 500);
164
+ }
165
+ main().catch(error => {
166
+ const control = ['stop', 'status'].includes(process.argv[2]?.toLowerCase());
167
+ console.error(process.argv.includes('--fg') ? `[project-knowledge] startup failed: ${error.message}` : `${control ? '控制失败' : '启动失败'}:${error.message}`);
168
+ notify({ type: 'kb:launcher-failed', message: error.message });
169
+ process.exitCode = 1;
439
170
  });
@@ -13,6 +13,8 @@ project-knowledge
13
13
 
14
14
  需要 Node ≥22 和 Git。默认使用 `~/.project-knowledge-v5`,四模块的托管数据也在此目录内。若环境变量 `KB_DATA_DIR` 仍指向旧目录,请取消该设置或改为新的空目录。不要把旧 `projects.json`、设置或引擎状态复制进新目录。
15
15
 
16
+ 5.0.6 修复首次启动被未登记的旧工作台会话误拦截:仅有日志、默认提示词及孤立会话的目录可以创建空的新登记,孤立会话保留原字节但不会恢复或触发任务。实际旧项目、设置、引擎状态或维护任务仍拒绝迁移,错误列出具体目录和冲突项。后台启动命令等待 HTTP 就绪后才报成功;启动失败直接在终端显示原因并返回非零退出码,不提前打开浏览器。
17
+
16
18
  在“系统状态”重新配置模型与后台默认模型、embedding 和知识根目录,然后在“领域与项目”导入代码目录及新的空知识目录。原代码目录无需移动。若残留旧知识库托管 Hook,导入预检会要求明确确认替换;不会覆盖第三方 Hook。导入建立 HEAD 基线,不分析已有历史。
17
19
 
18
20
  如果模块是自己独立启动的旧进程,请在它原来的终端中停止后重启新版外壳。外壳不会擅自杀死其他服务;版本或运行期鉴权不一致时显示具体连接阶段,不接管旧服务数据。
@@ -25,6 +27,12 @@ project-knowledge
25
27
 
26
28
  ## 解绑与恢复
27
29
 
30
+ 5.0.7 的命令不区分大小写,`STOP` 与 `stop` 相同。未知命令不会误启动。
31
+ `status` 和 `stop` 默认只控制当前数据目录;`status --all` 查看可控制的本机实例,
32
+ `stop --port 5804` 指定停止目标,`--data "D:/data/knowledge"` 明确选择数据目录。
33
+ 停止通过本机控制通道等待外壳及其托管模块退出,不再使用共享临时 PID 或盲杀。
34
+ 未验证或正在启动的实例不会误报已停止,独立启动的模块也不会被结束。
35
+
28
36
  “解绑并保留数据”从项目列表移除登记、取消未开始任务并卸下本项目托管 Hook,保留代码、知识、会话及证据。实际分析或索引写入尚未结束时仍禁止解绑,防止丢失写入状态。队列本身不再被当成正在运行。
29
37
 
30
38
  删除的是旧版本兼容路径,不是新版的故障恢复能力:v5 自身的重启恢复、提交去重、并发限制、失败重试和索引修复仍有效。`upgrade-recovery` 不再存在。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sanqianx/project-knowledge",
3
- "version": "5.0.5",
3
+ "version": "5.0.7",
4
4
  "description": "Knowledge base manager with Git integration, AI-driven analysis, and bilingual (zh-CN/en-US) knowledge output",
5
5
  "main": "_site/server.js",
6
6
  "scripts": {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
3
3
  "name": "project-knowledge",
4
- "version": "5.0.5",
4
+ "version": "5.0.7",
5
5
  "description": "Read durable project decisions and implementation history from Project Knowledge.",
6
6
  "author": {
7
7
  "name": "SanQianX",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "project-knowledge",
3
- "version": "5.0.5",
3
+ "version": "5.0.7",
4
4
  "description": "Read durable project decisions and implementation history from Project Knowledge.",
5
5
  "author": {
6
6
  "name": "SanQianX",
@@ -6,7 +6,7 @@
6
6
  "args": [
7
7
  "-y",
8
8
  "--package",
9
- "@sanqianx/project-knowledge@5.0.5",
9
+ "@sanqianx/project-knowledge@5.0.7",
10
10
  "project-knowledge-mcp"
11
11
  ]
12
12
  }