remnote-bridge 0.1.10 → 0.1.12
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/dist/cli/addon/addon-manager.js +163 -0
- package/dist/cli/addon/registry.js +24 -0
- package/dist/cli/commands/addon.js +149 -0
- package/dist/cli/commands/clean.js +121 -52
- package/dist/cli/commands/connect.js +72 -33
- package/dist/cli/commands/disconnect.js +19 -19
- package/dist/cli/commands/edit-rem.js +3 -31
- package/dist/cli/commands/edit-tree.js +3 -20
- package/dist/cli/commands/health.js +19 -18
- package/dist/cli/commands/read-context.js +8 -23
- package/dist/cli/commands/read-globe.js +3 -20
- package/dist/cli/commands/read-rem.js +3 -31
- package/dist/cli/commands/read-tree.js +3 -20
- package/dist/cli/commands/search.js +97 -21
- package/dist/cli/config.js +148 -72
- package/dist/cli/daemon/daemon.js +104 -24
- package/dist/cli/daemon/dev-server.js +9 -1
- package/dist/cli/daemon/pid.js +36 -22
- package/dist/cli/daemon/registry.js +160 -0
- package/dist/cli/daemon/send-request.js +11 -11
- package/dist/cli/daemon/static-server.js +97 -34
- package/dist/cli/handlers/context-read-handler.js +5 -3
- package/dist/cli/handlers/read-handler.js +4 -3
- package/dist/cli/handlers/tree-parser.js +16 -9
- package/dist/cli/main.js +51 -9
- package/dist/cli/protocol.js +18 -4
- package/dist/cli/server/config-server.js +280 -14
- package/dist/cli/server/ws-server.js +93 -44
- package/dist/cli/utils/output.js +29 -0
- package/dist/mcp/instructions.js +103 -10
- package/dist/mcp/resources/edit-rem-guide.js +3 -4
- package/dist/mcp/resources/error-reference.js +5 -3
- package/dist/mcp/resources/rem-object-fields.js +3 -3
- package/dist/mcp/tools/infra-tools.js +54 -6
- package/dist/mcp/tools/read-tools.js +16 -3
- package/package.json +2 -2
- package/remnote-plugin/dist/bridge_widget-sandbox.js +17 -17
- package/remnote-plugin/dist/bridge_widget.js +17 -17
- package/remnote-plugin/dist/index-sandbox.js +31 -31
- package/remnote-plugin/dist/index.js +31 -31
- package/remnote-plugin/dist/manifest.json +1 -1
- package/remnote-plugin/package.json +1 -1
- package/remnote-plugin/public/manifest.json +1 -1
- package/remnote-plugin/src/bridge/message-router.ts +1 -1
- package/remnote-plugin/src/bridge/multi-connection-manager.ts +151 -0
- package/remnote-plugin/src/bridge/websocket-client.ts +62 -16
- package/remnote-plugin/src/services/index.ts +0 -8
- package/remnote-plugin/src/services/read-context.ts +13 -4
- package/remnote-plugin/src/services/read-rem.ts +1 -9
- package/remnote-plugin/src/services/search.ts +13 -10
- package/remnote-plugin/src/settings.ts +9 -7
- package/remnote-plugin/src/utils/index.ts +0 -5
- package/remnote-plugin/src/widgets/bridge_widget.tsx +105 -20
- package/remnote-plugin/src/widgets/index.tsx +41 -44
- package/remnote-plugin/webpack.config.js +35 -0
- package/skills/remnote-bridge/SKILL.md +14 -9
- package/skills/remnote-bridge/instructions/addon.md +134 -0
- package/skills/remnote-bridge/instructions/clean.md +110 -0
- package/skills/remnote-bridge/instructions/connect.md +80 -37
- package/skills/remnote-bridge/instructions/disconnect.md +22 -9
- package/skills/remnote-bridge/instructions/edit-rem.md +37 -9
- package/skills/remnote-bridge/instructions/health.md +23 -13
- package/skills/remnote-bridge/instructions/install-skill.md +58 -0
- package/skills/remnote-bridge/instructions/overall.md +76 -21
- package/skills/remnote-bridge/instructions/read-context.md +34 -8
- package/skills/remnote-bridge/instructions/read-rem.md +10 -10
- package/skills/remnote-bridge/instructions/search.md +73 -14
- package/skills/remnote-bridge/instructions/setup.md +1 -1
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
* - --json 结构化 JSON 输出
|
|
7
7
|
* - 退出码:0 成功 / 1 业务错误 / 2 守护进程不可达
|
|
8
8
|
*/
|
|
9
|
-
import { sendDaemonRequest
|
|
10
|
-
import { jsonOutput } from '../utils/output.js';
|
|
9
|
+
import { sendDaemonRequest } from '../daemon/send-request.js';
|
|
10
|
+
import { jsonOutput, handleCommandError } from '../utils/output.js';
|
|
11
11
|
export async function readTreeCommand(remId, options = {}) {
|
|
12
12
|
const { json } = options;
|
|
13
13
|
const depth = options.depth !== undefined ? parseInt(options.depth, 10) : undefined;
|
|
@@ -33,24 +33,7 @@ export async function readTreeCommand(remId, options = {}) {
|
|
|
33
33
|
});
|
|
34
34
|
}
|
|
35
35
|
catch (err) {
|
|
36
|
-
|
|
37
|
-
if (json) {
|
|
38
|
-
jsonOutput({ ok: false, command: 'read-tree', error: err.message });
|
|
39
|
-
}
|
|
40
|
-
else {
|
|
41
|
-
console.error(`错误: ${err.message}`);
|
|
42
|
-
}
|
|
43
|
-
process.exitCode = 2;
|
|
44
|
-
return;
|
|
45
|
-
}
|
|
46
|
-
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
47
|
-
if (json) {
|
|
48
|
-
jsonOutput({ ok: false, command: 'read-tree', error: errorMsg });
|
|
49
|
-
}
|
|
50
|
-
else {
|
|
51
|
-
console.error(`错误: ${errorMsg}`);
|
|
52
|
-
}
|
|
53
|
-
process.exitCode = 1;
|
|
36
|
+
handleCommandError(err, 'read-tree', json);
|
|
54
37
|
return;
|
|
55
38
|
}
|
|
56
39
|
const data = result;
|
|
@@ -4,9 +4,75 @@
|
|
|
4
4
|
* 在知识库中按文本搜索 Rem。
|
|
5
5
|
* - --limit N 结果数量上限(默认 20)
|
|
6
6
|
* - --json 结构化 JSON 输出
|
|
7
|
+
*
|
|
8
|
+
* 配置驱动:检查 addons.remnote-rag.enabled 决定是否使用 RAG 语义搜索。
|
|
9
|
+
* 已启用且已安装时使用 RAG,否则降级到 SDK 搜索。
|
|
10
|
+
*/
|
|
11
|
+
import { execFile } from 'node:child_process';
|
|
12
|
+
import { sendDaemonRequest } from '../daemon/send-request.js';
|
|
13
|
+
import { loadConfig } from '../config.js';
|
|
14
|
+
import { jsonOutput, handleCommandError } from '../utils/output.js';
|
|
15
|
+
const RAG_TIMEOUT_MS = 10_000;
|
|
16
|
+
/**
|
|
17
|
+
* 尝试通过 remnote-rag 子进程进行语义搜索(配置驱动)。
|
|
18
|
+
*
|
|
19
|
+
* 1. 检查 addons.remnote-rag.enabled — 未启用则跳过
|
|
20
|
+
* 2. remnote-rag 从 ~/.remnote-bridge/addons/remnote-rag/config.json 读取配置
|
|
21
|
+
* 3. 未安装(ENOENT)、超时、JSON 解析失败、ok:false 均返回 null(静默降级)
|
|
7
22
|
*/
|
|
8
|
-
|
|
9
|
-
|
|
23
|
+
async function tryRagSearch(query, numResults) {
|
|
24
|
+
const config = loadConfig();
|
|
25
|
+
const ragConfig = config.addons?.['remnote-rag'];
|
|
26
|
+
if (!ragConfig?.enabled) {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
const jsonPayload = JSON.stringify({ query, numResults });
|
|
30
|
+
const args = ['search', '--json', jsonPayload];
|
|
31
|
+
try {
|
|
32
|
+
const stdout = await new Promise((resolve, reject) => {
|
|
33
|
+
execFile('remnote-rag', args, {
|
|
34
|
+
timeout: RAG_TIMEOUT_MS,
|
|
35
|
+
maxBuffer: 5 * 1024 * 1024,
|
|
36
|
+
}, (error, stdout) => {
|
|
37
|
+
if (error) {
|
|
38
|
+
// execFile 的 error 对象包含 stdout,尝试从中提取 RAG 的诊断信息
|
|
39
|
+
const output = (stdout ?? '').trim();
|
|
40
|
+
if (output) {
|
|
41
|
+
try {
|
|
42
|
+
const errData = JSON.parse(output);
|
|
43
|
+
if (errData?.error) {
|
|
44
|
+
reject(new Error(`remnote-rag: ${errData.error}`));
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// stdout 不是有效 JSON,忽略
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
reject(error);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
resolve(stdout.trim());
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
const parsed = JSON.parse(stdout);
|
|
59
|
+
if (typeof parsed !== 'object' || parsed === null || !('ok' in parsed) || !('results' in parsed)) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
const result = parsed;
|
|
63
|
+
if (!result.ok || !Array.isArray(result.results)) {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
return result;
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
// 静默降级,但保留诊断信息到 stderr 供调试
|
|
70
|
+
if (err instanceof Error && err.message.startsWith('remnote-rag:')) {
|
|
71
|
+
process.stderr.write(`[search] RAG 降级: ${err.message}\n`);
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
10
76
|
export async function searchCommand(query, options = {}) {
|
|
11
77
|
const { json } = options;
|
|
12
78
|
const numResults = options.limit !== undefined ? parseInt(options.limit, 10) : undefined;
|
|
@@ -21,34 +87,44 @@ export async function searchCommand(query, options = {}) {
|
|
|
21
87
|
process.exitCode = 1;
|
|
22
88
|
return;
|
|
23
89
|
}
|
|
90
|
+
// 尝试 RAG 语义搜索
|
|
91
|
+
const ragResult = await tryRagSearch(query, numResults ?? 20);
|
|
92
|
+
if (ragResult !== null) {
|
|
93
|
+
if (json) {
|
|
94
|
+
jsonOutput({ ok: true, command: 'search', data: { query, ...ragResult, source: 'rag' } });
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
if (ragResult.results.length === 0) {
|
|
98
|
+
console.log(`未找到与 "${query}" 相关的结果 (RAG)`);
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
console.log(`搜索 "${query}",找到 ${ragResult.totalFound} 条结果 (RAG 语义搜索):\n`);
|
|
102
|
+
for (const item of ragResult.results) {
|
|
103
|
+
const docTag = item.isDocument ? ' [Doc]' : '';
|
|
104
|
+
const typeTag = item.type !== 'default' ? ` [${item.type}]` : '';
|
|
105
|
+
const path = item.ancestorPath.length > 0 ? ` (${item.ancestorPath.join(' > ')})` : '';
|
|
106
|
+
const score = ` [${(item.score * 100).toFixed(0)}%]`;
|
|
107
|
+
console.log(` [${item.remId}]${docTag}${typeTag}${score} ${item.text}${path}`);
|
|
108
|
+
if (item.backText) {
|
|
109
|
+
console.log(` → ${item.backText}`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
// 降级到 SDK 搜索
|
|
24
117
|
let result;
|
|
25
118
|
try {
|
|
26
119
|
result = await sendDaemonRequest('search', { query, numResults });
|
|
27
120
|
}
|
|
28
121
|
catch (err) {
|
|
29
|
-
|
|
30
|
-
if (json) {
|
|
31
|
-
jsonOutput({ ok: false, command: 'search', error: err.message });
|
|
32
|
-
}
|
|
33
|
-
else {
|
|
34
|
-
console.error(`错误: ${err.message}`);
|
|
35
|
-
}
|
|
36
|
-
process.exitCode = 2;
|
|
37
|
-
return;
|
|
38
|
-
}
|
|
39
|
-
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
40
|
-
if (json) {
|
|
41
|
-
jsonOutput({ ok: false, command: 'search', error: errorMsg });
|
|
42
|
-
}
|
|
43
|
-
else {
|
|
44
|
-
console.error(`错误: ${errorMsg}`);
|
|
45
|
-
}
|
|
46
|
-
process.exitCode = 1;
|
|
122
|
+
handleCommandError(err, 'search', json);
|
|
47
123
|
return;
|
|
48
124
|
}
|
|
49
125
|
const data = result;
|
|
50
126
|
if (json) {
|
|
51
|
-
jsonOutput({ ok: true, command: 'search', data });
|
|
127
|
+
jsonOutput({ ok: true, command: 'search', data: { ...data, source: 'sdk' } });
|
|
52
128
|
}
|
|
53
129
|
else {
|
|
54
130
|
if (data.results.length === 0) {
|
package/dist/cli/config.js
CHANGED
|
@@ -1,11 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 配置加载
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* 从 ~/.remnote-bridge/config.json 读取全局配置,合并默认值。
|
|
5
5
|
* 文件不存在时使用全部默认值,不报错。
|
|
6
|
+
*
|
|
7
|
+
* 端口字段由 slots.json 管理,不再出现在配置文件中。
|
|
8
|
+
* BridgeConfig 仍保留端口字段(填充默认值),供旧代码兼容。
|
|
6
9
|
*/
|
|
7
10
|
import fs from 'fs';
|
|
8
11
|
import path from 'path';
|
|
12
|
+
import os from 'os';
|
|
13
|
+
// ── 全局目录 ──
|
|
14
|
+
/** ~/.remnote-bridge/ — 所有运行时文件的根目录 */
|
|
15
|
+
export const GLOBAL_DIR = path.join(os.homedir(), '.remnote-bridge');
|
|
16
|
+
/** 确保全局目录和 instances/ 子目录存在 */
|
|
17
|
+
export function ensureGlobalDir() {
|
|
18
|
+
fs.mkdirSync(path.join(GLOBAL_DIR, 'instances'), { recursive: true });
|
|
19
|
+
}
|
|
9
20
|
export const DEFAULT_DEFAULTS = {
|
|
10
21
|
maxNodes: 200,
|
|
11
22
|
maxSiblings: 20,
|
|
@@ -20,37 +31,14 @@ export const DEFAULT_DEFAULTS = {
|
|
|
20
31
|
searchNumResults: 20,
|
|
21
32
|
};
|
|
22
33
|
export const DEFAULT_CONFIG = {
|
|
23
|
-
wsPort:
|
|
24
|
-
devServerPort:
|
|
25
|
-
configPort:
|
|
34
|
+
wsPort: 29100,
|
|
35
|
+
devServerPort: 29101,
|
|
36
|
+
configPort: 29102,
|
|
26
37
|
daemonTimeoutMinutes: 30,
|
|
27
38
|
defaults: { ...DEFAULT_DEFAULTS },
|
|
28
39
|
};
|
|
29
|
-
const CONFIG_FILENAME = '.
|
|
30
|
-
|
|
31
|
-
return typeof value === 'number' && Number.isInteger(value) && value >= 1 && value <= 65535;
|
|
32
|
-
}
|
|
33
|
-
/**
|
|
34
|
-
* 查找项目根目录(monorepo 根:包含 .git 目录的最近祖先)
|
|
35
|
-
*
|
|
36
|
-
* 从 startDir 向上查找 .git 目录,找到即返回。
|
|
37
|
-
* 到达文件系统根仍未找到时回退到 cwd。
|
|
38
|
-
*/
|
|
39
|
-
export function findProjectRoot(startDir = process.cwd()) {
|
|
40
|
-
let dir = path.resolve(startDir);
|
|
41
|
-
while (true) {
|
|
42
|
-
// 优先匹配 .git 目录(monorepo 根标识)
|
|
43
|
-
if (fs.existsSync(path.join(dir, '.git'))) {
|
|
44
|
-
return dir;
|
|
45
|
-
}
|
|
46
|
-
const parent = path.dirname(dir);
|
|
47
|
-
if (parent === dir) {
|
|
48
|
-
// 到达文件系统根,回退到 cwd
|
|
49
|
-
return process.cwd();
|
|
50
|
-
}
|
|
51
|
-
dir = parent;
|
|
52
|
-
}
|
|
53
|
-
}
|
|
40
|
+
const CONFIG_FILENAME = 'config.json';
|
|
41
|
+
const LEGACY_CONFIG_FILENAME = '.remnote-bridge.json';
|
|
54
42
|
function isPositiveNumber(value) {
|
|
55
43
|
return typeof value === 'number' && value > 0;
|
|
56
44
|
}
|
|
@@ -75,65 +63,153 @@ function mergeDefaults(parsed) {
|
|
|
75
63
|
searchNumResults: isPositiveNumber(parsed.searchNumResults) ? parsed.searchNumResults : DEFAULT_DEFAULTS.searchNumResults,
|
|
76
64
|
};
|
|
77
65
|
}
|
|
66
|
+
function mergeAddons(parsed) {
|
|
67
|
+
if (!parsed || typeof parsed !== 'object')
|
|
68
|
+
return undefined;
|
|
69
|
+
const result = {};
|
|
70
|
+
for (const [name, raw] of Object.entries(parsed)) {
|
|
71
|
+
if (typeof raw !== 'object' || raw === null)
|
|
72
|
+
continue;
|
|
73
|
+
const obj = raw;
|
|
74
|
+
result[name] = {
|
|
75
|
+
enabled: typeof obj.enabled === 'boolean' ? obj.enabled : false,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
return Object.keys(result).length > 0 ? result : undefined;
|
|
79
|
+
}
|
|
78
80
|
/**
|
|
79
|
-
*
|
|
81
|
+
* 解析配置 JSON(不含端口字段)。端口由 slots.json 管理,此处填充默认值。
|
|
80
82
|
*/
|
|
81
|
-
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
83
|
+
function parseConfig(raw) {
|
|
84
|
+
const parsed = JSON.parse(raw);
|
|
85
|
+
return {
|
|
86
|
+
// 端口填充默认值(实际使用 slots 分配的端口)
|
|
87
|
+
wsPort: DEFAULT_CONFIG.wsPort,
|
|
88
|
+
devServerPort: DEFAULT_CONFIG.devServerPort,
|
|
89
|
+
configPort: DEFAULT_CONFIG.configPort,
|
|
90
|
+
daemonTimeoutMinutes: typeof parsed.daemonTimeoutMinutes === 'number' && parsed.daemonTimeoutMinutes > 0
|
|
91
|
+
? parsed.daemonTimeoutMinutes
|
|
92
|
+
: DEFAULT_CONFIG.daemonTimeoutMinutes,
|
|
93
|
+
defaults: mergeDefaults(parsed.defaults),
|
|
94
|
+
addons: mergeAddons(parsed.addons),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* 全局配置文件路径 (~/.remnote-bridge/config.json)
|
|
99
|
+
* @param configDir 可选,覆盖全局目录(测试用)
|
|
100
|
+
*/
|
|
101
|
+
export function configFilePath(configDir) {
|
|
102
|
+
return path.join(configDir ?? GLOBAL_DIR, CONFIG_FILENAME);
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* 加载配置。从 ~/.remnote-bridge/config.json 读取。
|
|
106
|
+
* 不存在时返回默认值。
|
|
107
|
+
* @param configDir 可选,覆盖全局目录(测试用)
|
|
108
|
+
*/
|
|
109
|
+
export function loadConfig(configDir) {
|
|
110
|
+
const globalPath = configFilePath(configDir);
|
|
111
|
+
// 全局配置存在 → 直接读取
|
|
112
|
+
if (fs.existsSync(globalPath)) {
|
|
113
|
+
try {
|
|
114
|
+
return parseConfig(fs.readFileSync(globalPath, 'utf-8'));
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
return { ...DEFAULT_CONFIG, defaults: { ...DEFAULT_DEFAULTS } };
|
|
103
118
|
}
|
|
104
|
-
return config;
|
|
105
119
|
}
|
|
106
|
-
|
|
107
|
-
|
|
120
|
+
// 尝试从旧项目根迁移(仅在使用默认全局目录时)
|
|
121
|
+
if (configDir)
|
|
108
122
|
return { ...DEFAULT_CONFIG, defaults: { ...DEFAULT_DEFAULTS } };
|
|
123
|
+
const legacyPath = findLegacyConfigPath();
|
|
124
|
+
if (legacyPath) {
|
|
125
|
+
try {
|
|
126
|
+
const config = parseConfig(fs.readFileSync(legacyPath, 'utf-8'));
|
|
127
|
+
// 迁移到全局(去掉端口字段)
|
|
128
|
+
ensureGlobalDir();
|
|
129
|
+
saveConfig(globalPath, config);
|
|
130
|
+
return config;
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
// 迁移失败,使用默认值
|
|
134
|
+
}
|
|
109
135
|
}
|
|
136
|
+
return { ...DEFAULT_CONFIG, defaults: { ...DEFAULT_DEFAULTS } };
|
|
110
137
|
}
|
|
111
138
|
/**
|
|
112
|
-
*
|
|
113
|
-
|
|
114
|
-
export function configFilePath(projectRoot) {
|
|
115
|
-
const root = projectRoot ?? findProjectRoot();
|
|
116
|
-
return path.join(root, CONFIG_FILENAME);
|
|
117
|
-
}
|
|
118
|
-
/**
|
|
119
|
-
* 原子写入配置文件(写临时文件 → rename)
|
|
139
|
+
* 原子写入配置文件(写临时文件 → rename)。
|
|
140
|
+
* 只写非端口字段。
|
|
120
141
|
*/
|
|
121
142
|
export function saveConfig(filePath, config) {
|
|
143
|
+
ensureGlobalDir();
|
|
144
|
+
// 持久化时去掉端口字段(端口由 slots.json 管理)
|
|
145
|
+
const persisted = {
|
|
146
|
+
daemonTimeoutMinutes: config.daemonTimeoutMinutes,
|
|
147
|
+
defaults: config.defaults,
|
|
148
|
+
...(config.addons ? { addons: config.addons } : {}),
|
|
149
|
+
};
|
|
122
150
|
const tmpPath = filePath + '.tmp.' + process.pid;
|
|
123
|
-
fs.writeFileSync(tmpPath, JSON.stringify(
|
|
151
|
+
fs.writeFileSync(tmpPath, JSON.stringify(persisted, null, 2) + '\n', { encoding: 'utf-8', mode: 0o600 });
|
|
124
152
|
fs.renameSync(tmpPath, filePath);
|
|
125
153
|
}
|
|
154
|
+
// ── 旧配置迁移辅助 ──
|
|
126
155
|
/**
|
|
127
|
-
*
|
|
156
|
+
* 查找项目根目录(monorepo 根:包含 .git 目录的最近祖先)。
|
|
157
|
+
* 仅用于旧配置迁移,不再作为实例标识。
|
|
128
158
|
*/
|
|
129
|
-
export function
|
|
130
|
-
|
|
131
|
-
|
|
159
|
+
export function findProjectRoot(startDir = process.cwd()) {
|
|
160
|
+
let dir = path.resolve(startDir);
|
|
161
|
+
while (true) {
|
|
162
|
+
if (fs.existsSync(path.join(dir, '.git'))) {
|
|
163
|
+
return dir;
|
|
164
|
+
}
|
|
165
|
+
const parent = path.dirname(dir);
|
|
166
|
+
if (parent === dir) {
|
|
167
|
+
return process.cwd();
|
|
168
|
+
}
|
|
169
|
+
dir = parent;
|
|
170
|
+
}
|
|
132
171
|
}
|
|
133
172
|
/**
|
|
134
|
-
*
|
|
173
|
+
* 查找旧的项目根配置文件路径(.remnote-bridge.json)。
|
|
174
|
+
* 存在时返回路径,不存在返回 null。
|
|
135
175
|
*/
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
176
|
+
function findLegacyConfigPath() {
|
|
177
|
+
try {
|
|
178
|
+
const root = findProjectRoot();
|
|
179
|
+
const legacyPath = path.join(root, LEGACY_CONFIG_FILENAME);
|
|
180
|
+
return fs.existsSync(legacyPath) ? legacyPath : null;
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
// ── Addon 独立配置 ──
|
|
187
|
+
/** addon 数据根目录 ~/.remnote-bridge/addons/<name>/ */
|
|
188
|
+
export function addonDataDir(name) {
|
|
189
|
+
return path.join(GLOBAL_DIR, 'addons', name);
|
|
190
|
+
}
|
|
191
|
+
/** addon 配置文件路径 ~/.remnote-bridge/addons/<name>/config.json */
|
|
192
|
+
export function addonConfigPath(name) {
|
|
193
|
+
return path.join(addonDataDir(name), 'config.json');
|
|
194
|
+
}
|
|
195
|
+
/** 读取 addon 配置 JSON(文件不存在返回 null) */
|
|
196
|
+
export function loadAddonConfig(name) {
|
|
197
|
+
const p = addonConfigPath(name);
|
|
198
|
+
if (!fs.existsSync(p))
|
|
199
|
+
return null;
|
|
200
|
+
try {
|
|
201
|
+
return JSON.parse(fs.readFileSync(p, 'utf-8'));
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
/** 保存 addon 配置 JSON(原子写入) */
|
|
208
|
+
export function saveAddonConfig(name, data) {
|
|
209
|
+
const dir = addonDataDir(name);
|
|
210
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
211
|
+
const filePath = path.join(dir, 'config.json');
|
|
212
|
+
const tmpPath = filePath + '.tmp.' + process.pid;
|
|
213
|
+
fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2) + '\n', { encoding: 'utf-8', mode: 0o600 });
|
|
214
|
+
fs.renameSync(tmpPath, filePath);
|
|
139
215
|
}
|
|
@@ -7,6 +7,10 @@
|
|
|
7
7
|
* 3. 写入 PID 文件
|
|
8
8
|
* 4. 管理自动超时关闭
|
|
9
9
|
* 5. 通过 IPC 向父进程发送 ready 信号
|
|
10
|
+
*
|
|
11
|
+
* 端口来源:env SLOT_WS_PORT / SLOT_DEV_PORT / SLOT_CONFIG_PORT
|
|
12
|
+
* 实例标识:env REMNOTE_BRIDGE_INSTANCE
|
|
13
|
+
* 日志/PID:~/.remnote-bridge/instances/N.*
|
|
10
14
|
*/
|
|
11
15
|
import path from 'path';
|
|
12
16
|
import fs from 'fs';
|
|
@@ -16,16 +20,27 @@ import { DevServerManager } from './dev-server.js';
|
|
|
16
20
|
import { StaticServer } from './static-server.js';
|
|
17
21
|
import { HeadlessBrowserManager } from './headless-browser.js';
|
|
18
22
|
import { writePid, removePid } from './pid.js';
|
|
19
|
-
import {
|
|
23
|
+
import { instancePidPath, instanceLogPath } from './registry.js';
|
|
24
|
+
import { loadConfig, ensureGlobalDir } from '../config.js';
|
|
20
25
|
let shutdownInProgress = false;
|
|
21
26
|
async function main() {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
const
|
|
25
|
-
const
|
|
27
|
+
// 从环境变量获取槽位信息
|
|
28
|
+
const slotIndex = parseInt(process.env.SLOT_INDEX ?? '', 10);
|
|
29
|
+
const wsPort = parseInt(process.env.SLOT_WS_PORT ?? '', 10);
|
|
30
|
+
const devServerPort = parseInt(process.env.SLOT_DEV_PORT ?? '', 10);
|
|
31
|
+
const configPort = parseInt(process.env.SLOT_CONFIG_PORT ?? '', 10);
|
|
32
|
+
const instanceId = process.env.REMNOTE_BRIDGE_INSTANCE ?? 'default';
|
|
33
|
+
if (isNaN(slotIndex) || isNaN(wsPort) || isNaN(devServerPort) || isNaN(configPort)) {
|
|
34
|
+
console.error('守护进程缺少必要的环境变量 (SLOT_INDEX, SLOT_WS_PORT, SLOT_DEV_PORT, SLOT_CONFIG_PORT)');
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
let config = loadConfig();
|
|
38
|
+
ensureGlobalDir();
|
|
39
|
+
const pidPath = instancePidPath(slotIndex);
|
|
40
|
+
const logPath = instanceLogPath(slotIndex);
|
|
26
41
|
// 日志写入文件(追加模式,保留前次会话日志)
|
|
27
42
|
const logStream = fs.createWriteStream(logPath, { flags: 'a' });
|
|
28
|
-
logStream.write(`\n${'='.repeat(60)}\n[${new Date().toISOString()}] 守护进程启动 (PID: ${process.pid})\n${'='.repeat(60)}\n`);
|
|
43
|
+
logStream.write(`\n${'='.repeat(60)}\n[${new Date().toISOString()}] 守护进程启动 (PID: ${process.pid}, instance: ${instanceId}, slot: ${slotIndex})\n${'='.repeat(60)}\n`);
|
|
29
44
|
function log(message, level = 'info') {
|
|
30
45
|
const timestamp = new Date().toISOString();
|
|
31
46
|
const line = `[${timestamp}] [${level.toUpperCase()}] ${message}\n`;
|
|
@@ -50,8 +65,9 @@ async function main() {
|
|
|
50
65
|
// 创建 WS Server(抽取为函数,供软重启复用)
|
|
51
66
|
function createServer(cfg) {
|
|
52
67
|
const srv = new BridgeServer({
|
|
53
|
-
port:
|
|
68
|
+
port: wsPort,
|
|
54
69
|
host: '127.0.0.1',
|
|
70
|
+
slotIndex,
|
|
55
71
|
onLog: log,
|
|
56
72
|
getTimeoutRemaining,
|
|
57
73
|
defaults: cfg.defaults,
|
|
@@ -95,19 +111,18 @@ async function main() {
|
|
|
95
111
|
catch (err) {
|
|
96
112
|
log(`旧 WS Server 关闭失败: ${err}`, 'error');
|
|
97
113
|
}
|
|
98
|
-
config = loadConfig(
|
|
114
|
+
config = loadConfig();
|
|
99
115
|
timeoutMs = config.daemonTimeoutMinutes * 60 * 1000;
|
|
100
116
|
server = createServer(config);
|
|
101
117
|
await server.start();
|
|
102
|
-
log(`新 WS Server 已启动 (端口 ${
|
|
118
|
+
log(`新 WS Server 已启动 (端口 ${wsPort})`);
|
|
103
119
|
resetTimeout();
|
|
104
120
|
log('软重启完成');
|
|
105
121
|
}
|
|
106
122
|
// 启动 ConfigServer
|
|
107
123
|
const configServer = new ConfigServer({
|
|
108
|
-
port:
|
|
124
|
+
port: configPort,
|
|
109
125
|
host: '127.0.0.1',
|
|
110
|
-
projectRoot,
|
|
111
126
|
onRestart: reload,
|
|
112
127
|
onLog: log,
|
|
113
128
|
});
|
|
@@ -134,7 +149,7 @@ async function main() {
|
|
|
134
149
|
const pluginServer = devMode
|
|
135
150
|
? new DevServerManager({
|
|
136
151
|
pluginDir,
|
|
137
|
-
port:
|
|
152
|
+
port: devServerPort,
|
|
138
153
|
onLog: log,
|
|
139
154
|
onExit: (code) => {
|
|
140
155
|
if (!shutdownInProgress && code !== 0) {
|
|
@@ -145,7 +160,7 @@ async function main() {
|
|
|
145
160
|
})
|
|
146
161
|
: new StaticServer({
|
|
147
162
|
distDir,
|
|
148
|
-
port:
|
|
163
|
+
port: devServerPort,
|
|
149
164
|
onLog: log,
|
|
150
165
|
});
|
|
151
166
|
async function shutdown() {
|
|
@@ -197,7 +212,7 @@ async function main() {
|
|
|
197
212
|
process.on('SIGINT', shutdown);
|
|
198
213
|
try {
|
|
199
214
|
await server.start();
|
|
200
|
-
log(`WS Server 已启动 (端口 ${
|
|
215
|
+
log(`WS Server 已启动 (端口 ${wsPort})`);
|
|
201
216
|
}
|
|
202
217
|
catch (err) {
|
|
203
218
|
log(`WS Server 启动失败: ${err}`, 'error');
|
|
@@ -206,7 +221,7 @@ async function main() {
|
|
|
206
221
|
}
|
|
207
222
|
try {
|
|
208
223
|
await configServer.start();
|
|
209
|
-
log(`ConfigServer 已启动 (端口 ${
|
|
224
|
+
log(`ConfigServer 已启动 (端口 ${configPort})`);
|
|
210
225
|
}
|
|
211
226
|
catch (err) {
|
|
212
227
|
log(`ConfigServer 启动失败: ${err}`, 'warn');
|
|
@@ -214,7 +229,7 @@ async function main() {
|
|
|
214
229
|
}
|
|
215
230
|
try {
|
|
216
231
|
await pluginServer.start();
|
|
217
|
-
log(`${pluginServerLabel} 已启动 (端口 ${
|
|
232
|
+
log(`${pluginServerLabel} 已启动 (端口 ${devServerPort})`);
|
|
218
233
|
}
|
|
219
234
|
catch (err) {
|
|
220
235
|
log(`${pluginServerLabel} 启动失败: ${err}`, 'error');
|
|
@@ -222,12 +237,59 @@ async function main() {
|
|
|
222
237
|
process.send?.({ type: 'error', message: `${pluginServerLabel} 启动失败: ${err}` });
|
|
223
238
|
process.exit(1);
|
|
224
239
|
}
|
|
225
|
-
//
|
|
226
|
-
|
|
240
|
+
// 取实际绑定的端口(可能与 env 传入的不同,若原端口被占用则 OS 自动分配)
|
|
241
|
+
const actualWsPort = server.actualPort;
|
|
242
|
+
const actualConfigPort = configServer.actualPort;
|
|
243
|
+
// StaticServer 有 actualPort;DevServerManager 通过 spawn 无法获取,用 env 值
|
|
244
|
+
const actualDevPort = 'actualPort' in pluginServer
|
|
245
|
+
? pluginServer.actualPort
|
|
246
|
+
: devServerPort;
|
|
247
|
+
// Headless 模式:关键端口不允许回退(Plugin 无法发现新端口)
|
|
248
|
+
if (headlessMode) {
|
|
249
|
+
if (actualWsPort !== wsPort) {
|
|
250
|
+
log(`Headless 模式下 WS 端口被占用(${wsPort} → ${actualWsPort}),终止启动`, 'error');
|
|
251
|
+
await server.stop();
|
|
252
|
+
await configServer.stop();
|
|
253
|
+
await pluginServer.stop();
|
|
254
|
+
process.send?.({
|
|
255
|
+
type: 'error',
|
|
256
|
+
message: `Headless 模式不支持端口回退:WS 端口 ${wsPort} 被占用。请释放端口或修改配置后重试`,
|
|
257
|
+
});
|
|
258
|
+
process.exit(1);
|
|
259
|
+
}
|
|
260
|
+
if (actualDevPort !== devServerPort) {
|
|
261
|
+
log(`Headless 模式下 Plugin 服务端口被占用(${devServerPort} → ${actualDevPort}),终止启动`, 'error');
|
|
262
|
+
await server.stop();
|
|
263
|
+
await configServer.stop();
|
|
264
|
+
await pluginServer.stop();
|
|
265
|
+
process.send?.({
|
|
266
|
+
type: 'error',
|
|
267
|
+
message: `Headless 模式不支持端口回退:Plugin 服务端口 ${devServerPort} 被占用。请释放端口或修改配置后重试`,
|
|
268
|
+
});
|
|
269
|
+
process.exit(1);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
// 设置 discovery 数据(供 Plugin 通过 /api/discovery 自动发现端口)
|
|
273
|
+
if (pluginServer instanceof StaticServer) {
|
|
274
|
+
pluginServer.setDiscovery({
|
|
275
|
+
wsPort: actualWsPort,
|
|
276
|
+
configPort: actualConfigPort,
|
|
277
|
+
instance: instanceId,
|
|
278
|
+
slotIndex,
|
|
279
|
+
});
|
|
280
|
+
log(`Discovery 端点已就绪 (wsPort=${actualWsPort}, configPort=${actualConfigPort}, instance=${instanceId}, slotIndex=${slotIndex})`);
|
|
281
|
+
}
|
|
282
|
+
// 写入 PID 文件(JSON 格式,使用实际端口)
|
|
283
|
+
writePid(pidPath, {
|
|
284
|
+
pid: process.pid,
|
|
285
|
+
slotIndex,
|
|
286
|
+
instance: instanceId,
|
|
287
|
+
wsPort: actualWsPort,
|
|
288
|
+
devServerPort: actualDevPort,
|
|
289
|
+
configPort: actualConfigPort,
|
|
290
|
+
});
|
|
227
291
|
log(`PID 文件已写入: ${pidPath} (PID: ${process.pid})`);
|
|
228
292
|
// 启动 Headless Chrome(如果启用)
|
|
229
|
-
// headless Chrome 加载 RemNote 本身(不是 plugin 静态文件),
|
|
230
|
-
// RemNote 会自动加载已配置的 dev plugin(从 localhost:8080)
|
|
231
293
|
if (headlessMode) {
|
|
232
294
|
const remNoteUrl = 'https://www.remnote.com';
|
|
233
295
|
headlessBrowser = new HeadlessBrowserManager({
|
|
@@ -247,19 +309,37 @@ async function main() {
|
|
|
247
309
|
}
|
|
248
310
|
// 启动超时计时器
|
|
249
311
|
resetTimeout();
|
|
250
|
-
//
|
|
312
|
+
// 通知父进程就绪(使用实际端口)
|
|
251
313
|
process.send?.({
|
|
252
314
|
type: 'ready',
|
|
253
|
-
wsPort:
|
|
254
|
-
devServerPort:
|
|
255
|
-
configPort:
|
|
315
|
+
wsPort: actualWsPort,
|
|
316
|
+
devServerPort: actualDevPort,
|
|
317
|
+
configPort: actualConfigPort,
|
|
256
318
|
pid: process.pid,
|
|
257
319
|
headless: headlessMode,
|
|
320
|
+
slotIndex,
|
|
321
|
+
instance: instanceId,
|
|
258
322
|
});
|
|
259
323
|
// 断开 IPC 通道(让父进程可以退出)
|
|
260
324
|
if (process.channel) {
|
|
261
325
|
process.channel.unref();
|
|
262
326
|
}
|
|
327
|
+
// 自动安装已启用的 addon(非阻塞,不影响启动速度)
|
|
328
|
+
import('../addon/addon-manager.js').then(({ AddonManager }) => {
|
|
329
|
+
const addonManager = new AddonManager(config);
|
|
330
|
+
return addonManager.ensureEnabledAddons(log);
|
|
331
|
+
}).then((addonResults) => {
|
|
332
|
+
for (const r of addonResults) {
|
|
333
|
+
if (r.action === 'installed') {
|
|
334
|
+
log(`[addon] ${r.name} 已自动安装`);
|
|
335
|
+
}
|
|
336
|
+
else if (r.action === 'failed') {
|
|
337
|
+
log(`[addon] ${r.name} 自动安装失败: ${r.error}`, 'warn');
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}).catch((err) => {
|
|
341
|
+
log(`[addon] 自动安装检查失败: ${err}`, 'warn');
|
|
342
|
+
});
|
|
263
343
|
}
|
|
264
344
|
main().catch((err) => {
|
|
265
345
|
console.error('守护进程启动失败:', err);
|