@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +22 -0
- package/README.md +12 -0
- package/_site/_test/bin-cli-test.js +2 -3
- package/_site/_test/claude-workbench-test.js +4 -0
- package/_site/_test/cli-instance-management-test.js +62 -0
- package/_site/_test/fresh-product-data-test.js +14 -0
- package/_site/_test/launcher-readiness-test.js +80 -0
- package/_site/_test/run-all-tests.js +3 -1
- package/_site/lib/cli-instances.js +43 -0
- package/_site/lib/product-data.js +16 -5
- package/_site/lib/runtime-control.js +89 -0
- package/_site/lib/server-app.js +10 -0
- package/_site/server.js +5 -2
- package/bin/project-knowledge.js +154 -423
- package/docs/fresh-v5-start.md +8 -0
- package/package.json +1 -1
- package/plugins/project-knowledge/.claude-plugin/plugin.json +1 -1
- package/plugins/project-knowledge/.codex-plugin/plugin.json +1 -1
- package/plugins/project-knowledge/.mcp.json +1 -1
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
|
-
|
|
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', '
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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 };
|
package/_site/lib/server-app.js
CHANGED
|
@@ -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
|
-
|
|
11
|
-
|
|
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
|
});
|
package/bin/project-knowledge.js
CHANGED
|
@@ -1,439 +1,170 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
const
|
|
8
|
-
const
|
|
9
|
-
const
|
|
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
|
|
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
|
|
245
|
-
project-knowledge --fg Start in foreground
|
|
246
|
-
project-knowledge stop Stop
|
|
247
|
-
project-knowledge status
|
|
248
|
-
project-knowledge
|
|
249
|
-
project-knowledge
|
|
250
|
-
|
|
251
|
-
project-knowledge integrations
|
|
252
|
-
|
|
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>
|
|
256
|
-
--
|
|
257
|
-
--
|
|
258
|
-
--
|
|
259
|
-
|
|
260
|
-
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
|
|
308
|
-
|
|
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
|
-
|
|
325
|
-
|
|
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
|
|
380
|
-
if (
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
const
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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
|
});
|
package/docs/fresh-v5-start.md
CHANGED
|
@@ -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,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.
|
|
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",
|