@jesonliu/lark-claudecode-bridge 1.3.7 → 1.4.0
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/README.md +17 -0
- package/assets/web/js/main.js +2 -1
- package/assets/web/js/pages/memory.js +561 -0
- package/config.example.yaml +34 -0
- package/dist/config.d.ts +3 -0
- package/dist/config.js +133 -1
- package/dist/config.js.map +1 -1
- package/dist/executor/claude-executor.d.ts +26 -3
- package/dist/executor/claude-executor.js +718 -293
- package/dist/executor/claude-executor.js.map +1 -1
- package/dist/executor/notify-server.d.ts +10 -0
- package/dist/executor/notify-server.js +11 -0
- package/dist/executor/notify-server.js.map +1 -1
- package/dist/executor/permission-gate.d.ts +8 -1
- package/dist/executor/permission-gate.js +7 -3
- package/dist/executor/permission-gate.js.map +1 -1
- package/dist/gateway/card-builder.d.ts +15 -3
- package/dist/gateway/card-builder.js +21 -10
- package/dist/gateway/card-builder.js.map +1 -1
- package/dist/gateway/feishu-gateway.d.ts +13 -0
- package/dist/gateway/feishu-gateway.js +34 -2
- package/dist/gateway/feishu-gateway.js.map +1 -1
- package/dist/gateway/progress-card.d.ts +4 -2
- package/dist/gateway/progress-card.js +52 -25
- package/dist/gateway/progress-card.js.map +1 -1
- package/dist/index.d.ts +7 -1
- package/dist/index.js +225 -32
- package/dist/index.js.map +1 -1
- package/dist/memory-installer.d.ts +174 -0
- package/dist/memory-installer.js +1035 -0
- package/dist/memory-installer.js.map +1 -0
- package/dist/memory.d.ts +88 -0
- package/dist/memory.js +195 -0
- package/dist/memory.js.map +1 -0
- package/dist/security/paths.d.ts +3 -1
- package/dist/security/paths.js +28 -4
- package/dist/security/paths.js.map +1 -1
- package/dist/session/session-store.d.ts +15 -0
- package/dist/session/session-store.js +16 -0
- package/dist/session/session-store.js.map +1 -1
- package/dist/types.d.ts +56 -1
- package/dist/web/config-api.js +32 -0
- package/dist/web/config-api.js.map +1 -1
- package/dist/web/memory-api.d.ts +25 -0
- package/dist/web/memory-api.js +197 -0
- package/dist/web/memory-api.js.map +1 -0
- package/dist/web/server.js +73 -2
- package/dist/web/server.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,1035 @@
|
|
|
1
|
+
// B3 嵌入式安装器(生命周期方案 §6.7 一键流水线):五步——runtime(uv)→ service(openviking)
|
|
2
|
+
// → config(ov.conf 渲染)→ start(受管子进程)→ plugin(记忆插件)。每步「检测就绪则跳过 /
|
|
3
|
+
// 失败可重试」,本机已有部件(uv / 服务 / 插件 / 现有服务探活通过)全部自动复用——安装器对
|
|
4
|
+
// 已部署环境是幂等升级器而非重复安装。零新依赖:zip/tar.gz 解压用系统自带 tar(Win10+
|
|
5
|
+
// bsdtar 支持 zip),下载用原生 fetch,子进程 node:child_process。
|
|
6
|
+
//
|
|
7
|
+
// 事实依据(2026-10-13 本机探源):
|
|
8
|
+
// - uv 官方单文件:github.com/astral-sh/uv/releases/latest/download/<平台包>(win=.zip / mac-linux=.tar.gz)
|
|
9
|
+
// - 服务安装:`uv tool install openviking==<pin>` → bin 里出现 openviking-server;`--config/--host/--port` 全自管
|
|
10
|
+
// - ov.conf schema(本机蓝本):storage.workspace / embedding.dense{provider:"openai",model,api_key,api_base}
|
|
11
|
+
// / vlm{provider("glm"|"openai"),model,api_base,api_key} / server{host,port,root_api_key}
|
|
12
|
+
// - 插件源:codeload.github.com/volcengine/OpenViking/<zip|tar.gz>/refs/<ref> → examples/claude-code-memory-plugin
|
|
13
|
+
import { spawn } from 'node:child_process';
|
|
14
|
+
import { randomBytes } from 'node:crypto';
|
|
15
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync, copyFileSync, constants as FS_CONSTANTS } from 'node:fs';
|
|
16
|
+
import { access } from 'node:fs/promises';
|
|
17
|
+
import { tmpdir } from 'node:os';
|
|
18
|
+
import { dirname, join } from 'node:path';
|
|
19
|
+
import { parseDocument } from 'yaml';
|
|
20
|
+
import { probeMemoryHealth, MEMORY_PLUGIN_NAME, memoryEndpoint } from './memory.js';
|
|
21
|
+
/** 服务默认 pin 版本(实测环境;memory.version 可覆盖) */
|
|
22
|
+
export const DEFAULT_OPENVIKING_PIN = '0.5.0';
|
|
23
|
+
/** 插件源(市场清单 git-subdir 事实):主仓库 examples 子目录 */
|
|
24
|
+
const PLUGIN_REPO_ZIP = (ref) => `https://codeload.github.com/volcengine/OpenViking/zip/refs/heads/${ref}`;
|
|
25
|
+
const PLUGIN_REPO_TGZ = (ref) => `https://codeload.github.com/volcengine/OpenViking/tar.gz/refs/heads/${ref}`;
|
|
26
|
+
const PLUGIN_SUBDIR = 'examples/claude-code-memory-plugin';
|
|
27
|
+
/** CLI 市场渠道(真实安装:插件页可见、enable/disable/update 全套 CLI 语义可用)。
|
|
28
|
+
* 市场名与上游 .claude-plugin/marketplace.json 一致(name: "openviking")。
|
|
29
|
+
* 注册源按序尝试(实测教训:CLI 内部 fetch 对 raw.githubusercontent 直链只有 10s 超时,
|
|
30
|
+
* 国内网络易撞;github 简写走 git clone 稳得多):github 简写优先,清单直链兜底 */
|
|
31
|
+
const MARKETPLACE_NAME = 'openviking';
|
|
32
|
+
const MARKETPLACE_ADD_SOURCES = [
|
|
33
|
+
'volcengine/OpenViking',
|
|
34
|
+
'https://raw.githubusercontent.com/volcengine/OpenViking/main/.claude-plugin/marketplace.json',
|
|
35
|
+
];
|
|
36
|
+
const MARKETPLACE_KEY = `${MEMORY_PLUGIN_NAME}@${MARKETPLACE_NAME}`;
|
|
37
|
+
const FRESH_STEPS = () => [
|
|
38
|
+
{ id: 'runtime', title: '运行时(uv)', status: 'pending' },
|
|
39
|
+
{ id: 'service', title: '安装 OpenViking 服务', status: 'pending' },
|
|
40
|
+
{ id: 'config', title: '生成服务配置(ov.conf)', status: 'pending' },
|
|
41
|
+
{ id: 'start', title: '拉起受管服务', status: 'pending' },
|
|
42
|
+
{ id: 'plugin', title: '接入记忆插件', status: 'pending' },
|
|
43
|
+
];
|
|
44
|
+
/** 「启动服务」流水线步骤(§6.8 启动接管):与安装同款 stepper 展示,启动 ≠ 只拉进程 */
|
|
45
|
+
const START_STEPS = () => [
|
|
46
|
+
{ id: 'locate', title: '检查服务与配置', status: 'pending' },
|
|
47
|
+
{ id: 'launch', title: '拉起并探活', status: 'pending' },
|
|
48
|
+
{ id: 'plugin', title: '接入记忆插件', status: 'pending' },
|
|
49
|
+
];
|
|
50
|
+
let installState = { kind: 'install', running: false, steps: FRESH_STEPS() };
|
|
51
|
+
export function getInstallState() { return installState; }
|
|
52
|
+
let managed;
|
|
53
|
+
/** 停受管实例(页面 stop / LCB shutdown / 重装前):幂等,未运行为 no-op */
|
|
54
|
+
export function stopManagedServer() {
|
|
55
|
+
if (!managed)
|
|
56
|
+
return;
|
|
57
|
+
managed.stopped = true;
|
|
58
|
+
try {
|
|
59
|
+
managed.proc.kill();
|
|
60
|
+
}
|
|
61
|
+
catch { /* 已退出 */ }
|
|
62
|
+
managed = undefined;
|
|
63
|
+
}
|
|
64
|
+
/** LCB 优雅关闭钩子注册点(index.ts shutdown 调用) */
|
|
65
|
+
export function isManagedRunning() { return managed !== undefined; }
|
|
66
|
+
/** 受管实例本次拉起时间(ISO;崩溃自动重启会重置)——status 页「运行时间」展示源,仅受管时有值 */
|
|
67
|
+
export function managedStartedAt() { return managed?.startedAt; }
|
|
68
|
+
// ============ 小工具 ============
|
|
69
|
+
/** 跑命令收 stdout(trim);非零退出抛带 stderr 摘要的 Error */
|
|
70
|
+
function run(cmd, args, opts = {}) {
|
|
71
|
+
return new Promise((resolveP, rejectP) => {
|
|
72
|
+
const p = spawn(cmd, args, {
|
|
73
|
+
windowsHide: true,
|
|
74
|
+
env: { ...process.env, ...(opts.env ?? {}) },
|
|
75
|
+
});
|
|
76
|
+
let out = '';
|
|
77
|
+
let err = '';
|
|
78
|
+
const timer = setTimeout(() => {
|
|
79
|
+
p.kill();
|
|
80
|
+
rejectP(new Error(`${cmd} 超时(>${opts.timeoutMs ?? 120_000}ms)`));
|
|
81
|
+
}, opts.timeoutMs ?? 120_000);
|
|
82
|
+
p.stdout.on('data', (d) => { out += String(d); });
|
|
83
|
+
p.stderr.on('data', (d) => { err += String(d); });
|
|
84
|
+
p.on('error', (e) => { clearTimeout(timer); rejectP(e); });
|
|
85
|
+
p.on('close', (code) => {
|
|
86
|
+
clearTimeout(timer);
|
|
87
|
+
if (code === 0)
|
|
88
|
+
resolveP(out.trim());
|
|
89
|
+
else
|
|
90
|
+
rejectP(new Error(`${cmd} ${args.join(' ')} 退出码 ${code}${err ? `:${err.trim().slice(0, 300)}` : ''}`));
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
async function canExec(p) {
|
|
95
|
+
try {
|
|
96
|
+
await access(p, FS_CONSTANTS.X_OK);
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/** 解压命令定位(实测教训:Windows PATH 里 Git Bash 的 GNU tar 常排在 System32 之前,
|
|
104
|
+
* GNU tar 不认 zip → 解压失败。Windows 显式用系统自带 bsdtar(Win10 1803+ 必有),
|
|
105
|
+
* 缺失时回退 PATH tar(mac/linux 均系统自带)。返回 [cmd, 前置参数]。 */
|
|
106
|
+
function tarCommand() {
|
|
107
|
+
if (process.platform === 'win32') {
|
|
108
|
+
const bsdtar = join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'tar.exe');
|
|
109
|
+
if (existsSync(bsdtar))
|
|
110
|
+
return { cmd: bsdtar, args: [] };
|
|
111
|
+
console.warn('[memory-installer] 未找到系统 bsdtar(System32\\tar.exe),回退 PATH tar(若为 Git Bash GNU tar 将无法解 zip)');
|
|
112
|
+
}
|
|
113
|
+
return { cmd: 'tar', args: [] };
|
|
114
|
+
}
|
|
115
|
+
/** 下载到临时文件返回路径(fetch 流式写盘;root key 等不涉密,普通下载) */
|
|
116
|
+
async function downloadTo(url, dest) {
|
|
117
|
+
const res = await fetch(url, { redirect: 'follow' });
|
|
118
|
+
if (!res.ok || !res.body)
|
|
119
|
+
throw new Error(`下载失败:HTTP ${res.status}(${url})`);
|
|
120
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
121
|
+
writeFileSync(dest, buf);
|
|
122
|
+
}
|
|
123
|
+
// ============ 步骤 1:uv ============
|
|
124
|
+
const UV_INSTALL_DIR = join(process.env.LCB_CONFIG_DIR ?? String(process.env.USERPROFILE ?? process.env.HOME ?? ''), '.lark-claudecode-bridge', 'bin');
|
|
125
|
+
/** 检测 uv:PATH → 常见安装位 → LCB 管理域。返回可执行路径或 null */
|
|
126
|
+
export async function detectUv() {
|
|
127
|
+
const candidates = [
|
|
128
|
+
'uv', // PATH
|
|
129
|
+
join(String(process.env.USERPROFILE ?? process.env.HOME ?? ''), '.local', 'bin', process.platform === 'win32' ? 'uv.exe' : 'uv'),
|
|
130
|
+
join(UV_INSTALL_DIR, process.platform === 'win32' ? 'uv.exe' : 'uv'),
|
|
131
|
+
];
|
|
132
|
+
if (process.platform === 'win32') {
|
|
133
|
+
candidates.splice(2, 0, join(String(process.env.LOCALAPPDATA ?? ''), 'hermes', 'bin', 'uv.exe'));
|
|
134
|
+
}
|
|
135
|
+
for (const c of candidates) {
|
|
136
|
+
if (c === 'uv') {
|
|
137
|
+
try {
|
|
138
|
+
await run('uv', ['--version'], { timeoutMs: 10_000 });
|
|
139
|
+
return 'uv';
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (existsSync(c)) {
|
|
146
|
+
try {
|
|
147
|
+
await run(c, ['--version'], { timeoutMs: 10_000 });
|
|
148
|
+
return c;
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
/** uv 官方单文件包名(win 用 zip / mac-linux 用 tar.gz——后者保证 GNU tar 也能解) */
|
|
158
|
+
function uvAssetName() {
|
|
159
|
+
const arch = process.arch === 'arm64' ? 'aarch64' : 'x86_64';
|
|
160
|
+
if (process.platform === 'win32')
|
|
161
|
+
return { file: `uv-${arch}-pc-windows-msvc.zip`, extract: 'zip' };
|
|
162
|
+
if (process.platform === 'darwin')
|
|
163
|
+
return { file: `uv-${arch}-apple-darwin.tar.gz`, extract: 'tgz' };
|
|
164
|
+
return { file: `uv-${arch}-unknown-linux-gnu.tar.gz`, extract: 'tgz' };
|
|
165
|
+
}
|
|
166
|
+
async function installUv() {
|
|
167
|
+
const { file, extract } = uvAssetName();
|
|
168
|
+
mkdirSync(UV_INSTALL_DIR, { recursive: true });
|
|
169
|
+
const tmp = mkdtempSync(join(tmpdir(), 'lcb-uv-'));
|
|
170
|
+
try {
|
|
171
|
+
const archive = join(tmp, file);
|
|
172
|
+
await downloadTo(`https://github.com/astral-sh/uv/releases/latest/download/${file}`, archive);
|
|
173
|
+
// 系统 bsdtar/GNU tar 解压(Windows 显式 System32 bsdtar——PATH 里的 Git Bash tar 不认 zip)
|
|
174
|
+
const t = tarCommand();
|
|
175
|
+
await run(t.cmd, [...t.args, '-xf', archive, '-C', tmp], { timeoutMs: 60_000 });
|
|
176
|
+
// 解包结构:uv-<target>/uv(.exe) —— 遍历两层的候选路径直接定位
|
|
177
|
+
const exe = process.platform === 'win32' ? 'uv.exe' : 'uv';
|
|
178
|
+
const candidates = [
|
|
179
|
+
join(tmp, exe),
|
|
180
|
+
...existsSync(join(tmp, 'uv-' + file.replace(extract === 'zip' ? '.zip' : '.tar.gz', '')))
|
|
181
|
+
? [join(tmp, 'uv-' + file.replace(extract === 'zip' ? '.zip' : '.tar.gz', ''), exe)]
|
|
182
|
+
: [],
|
|
183
|
+
];
|
|
184
|
+
const src = candidates.find((p) => existsSync(p));
|
|
185
|
+
if (!src)
|
|
186
|
+
throw new Error('解包后未找到 uv 可执行文件(归档结构可能已变化)');
|
|
187
|
+
const dest = join(UV_INSTALL_DIR, exe);
|
|
188
|
+
copyFileSync(src, dest);
|
|
189
|
+
await run(dest, ['--version'], { timeoutMs: 15_000 });
|
|
190
|
+
return dest;
|
|
191
|
+
}
|
|
192
|
+
finally {
|
|
193
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
// ============ 步骤 2:openviking 服务 ============
|
|
197
|
+
/** uv tool 安装的 openviking server 可执行路径(uv tool dir/openviking/{Scripts|bin}/openviking-server[.exe]) */
|
|
198
|
+
export async function resolveServerBin(uv) {
|
|
199
|
+
try {
|
|
200
|
+
const toolDir = (await run(uv, ['tool', 'dir'], { timeoutMs: 15_000 })).trim();
|
|
201
|
+
const exe = process.platform === 'win32' ? 'openviking-server.exe' : 'openviking-server';
|
|
202
|
+
const candidates = [
|
|
203
|
+
join(toolDir, 'openviking', process.platform === 'win32' ? 'Scripts' : 'bin', exe),
|
|
204
|
+
join(String(process.env.USERPROFILE ?? process.env.HOME ?? ''), '.local', 'bin', exe),
|
|
205
|
+
];
|
|
206
|
+
for (const c of candidates)
|
|
207
|
+
if (existsSync(c))
|
|
208
|
+
return c;
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
async function stepService(uv, pin) {
|
|
216
|
+
const existing = await resolveServerBin(uv);
|
|
217
|
+
if (existing) {
|
|
218
|
+
let ver = '';
|
|
219
|
+
try {
|
|
220
|
+
ver = (await run(existing, ['--version'], { timeoutMs: 15_000 })).replace(/^[^0-9]*/, '');
|
|
221
|
+
}
|
|
222
|
+
catch { /* 版本查询失败不阻塞(二进制在即视为可用) */ }
|
|
223
|
+
return { skipped: true, detail: `已安装${ver ? `(v${ver})` : ''}:${existing}`, bin: existing };
|
|
224
|
+
}
|
|
225
|
+
// 尊重 pip/uv 镜像配置(UV_INDEX_URL / UV_DEFAULT_INDEX / PIP_INDEX_URL 经 process.env 自动透传)
|
|
226
|
+
await run(uv, ['tool', 'install', `openviking==${pin}`], { timeoutMs: 300_000 });
|
|
227
|
+
const bin = await resolveServerBin(uv);
|
|
228
|
+
if (!bin)
|
|
229
|
+
throw new Error(`安装完成但未定位到 openviking-server(uv tool dir 下无产物,请反馈)`);
|
|
230
|
+
return { skipped: false, detail: `已安装 openviking==${pin}:${bin}`, bin };
|
|
231
|
+
}
|
|
232
|
+
// ============ 步骤 3:ov.conf 渲染 ============
|
|
233
|
+
/** LCB 记忆管理域(嵌入式数据全收敛于此:卸载即整目录删) */
|
|
234
|
+
export const MEMORY_MANAGED_DIR = join(process.env.LCB_CONFIG_DIR ?? String(process.env.USERPROFILE ?? process.env.HOME ?? ''), '.lark-claudecode-bridge', 'openviking');
|
|
235
|
+
export const MANAGED_OV_CONF = join(MEMORY_MANAGED_DIR, 'ov.conf');
|
|
236
|
+
/** vlm provider 映射:glm 预设用服务端原生 "glm",其余 OpenAI 协议端点统一 "openai"(本机蓝本口径) */
|
|
237
|
+
function vlmProvider(p) {
|
|
238
|
+
return p === 'glm' ? 'glm' : 'openai';
|
|
239
|
+
}
|
|
240
|
+
/** 渲染 ov.conf(纯函数 exported 便于单测)。rootKey 已有则复用(重装不换 key,否则插件侧缓存/用户侧配置全部失效) */
|
|
241
|
+
export function renderOvConf(model, opts) {
|
|
242
|
+
const rootApiKey = opts.rootApiKey ?? randomBytes(24).toString('hex');
|
|
243
|
+
const conf = {
|
|
244
|
+
storage: { workspace: opts.workspaceDir },
|
|
245
|
+
embedding: {
|
|
246
|
+
dense: {
|
|
247
|
+
provider: 'openai',
|
|
248
|
+
model: model.embeddingModel ?? '',
|
|
249
|
+
api_key: model.apiKey ?? '',
|
|
250
|
+
api_base: model.apiBase ?? '',
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
vlm: {
|
|
254
|
+
provider: vlmProvider(model.provider),
|
|
255
|
+
model: model.vlmModel ?? '',
|
|
256
|
+
api_base: model.apiBase ?? '',
|
|
257
|
+
temperature: 0.0,
|
|
258
|
+
max_retries: 2,
|
|
259
|
+
api_key: model.apiKey ?? '',
|
|
260
|
+
},
|
|
261
|
+
server: {
|
|
262
|
+
host: '127.0.0.1',
|
|
263
|
+
port: opts.port,
|
|
264
|
+
root_api_key: rootApiKey,
|
|
265
|
+
},
|
|
266
|
+
};
|
|
267
|
+
return { conf, rootApiKey };
|
|
268
|
+
}
|
|
269
|
+
/** 读现有受管 conf 的 root_api_key(存在时复用) */
|
|
270
|
+
function existingRootKey() {
|
|
271
|
+
try {
|
|
272
|
+
const doc = JSON.parse(readFileSync(MANAGED_OV_CONF, 'utf8'));
|
|
273
|
+
return doc.server?.root_api_key;
|
|
274
|
+
}
|
|
275
|
+
catch {
|
|
276
|
+
return undefined;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
/** 外部(用户手动 init,如 `openviking-server init`)ov.conf 的默认位置——已装未跑时
|
|
280
|
+
* 「启动服务」按钮的兜底配置源(LCB 只代为拉起,不改写其内容) */
|
|
281
|
+
export const EXTERNAL_OV_CONF = join(String(process.env.USERPROFILE ?? process.env.HOME ?? ''), '.openviking', 'ov.conf');
|
|
282
|
+
/** 读 conf 的 server.port(JSON;读取失败按缺省 1933 兜底,不让状态展示路径抛错) */
|
|
283
|
+
function readConfPort(confPath) {
|
|
284
|
+
try {
|
|
285
|
+
return JSON.parse(readFileSync(confPath, 'utf8')).server?.port ?? 1933;
|
|
286
|
+
}
|
|
287
|
+
catch {
|
|
288
|
+
return 1933;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
// ============ 客户端接入配置(ovcli.conf,§6.7 第 4 步落地) ============
|
|
292
|
+
// 插件 isPluginEnabled() 的第 3 优先级判据 + 连接信息兜底层(env > ovcli.conf > ov.conf,
|
|
293
|
+
// 插件 scripts/shared/credentials.mjs 实测口径)。LCB 只写「无 key 骨架」:key 永远走 env
|
|
294
|
+
// 下发(§6.6),绝不落盘到 ~/.openviking/ 域。全新机器一键安装后即启用插件,用户零手工。
|
|
295
|
+
/** 客户端配置文件位置(与 EXTERNAL_OV_CONF 同款 home 解析口径) */
|
|
296
|
+
export const OVCLI_CONF = join(String(process.env.USERPROFILE ?? process.env.HOME ?? ''), '.openviking', 'ovcli.conf');
|
|
297
|
+
/** LCB 骨架形状签名:恰好 {url, account, user} 三个 string 键。这是「文件归 LCB 所有」的判定
|
|
298
|
+
* 依据——插件 setup 向导 / `ov login` 恒写 api_key(含 api_key 的文件永不与本形状同形),
|
|
299
|
+
* 且形状判定与端点/账号现值解耦:重装 findFreePort 换端口后旧骨架仍被识别为 LCB 所有
|
|
300
|
+
* (这是 ensure 能刷新、cleanup 能删中的关键;勿改成内容比对,否则端口漂移后清理永不触发) */
|
|
301
|
+
function isLcbClientConfShape(v) {
|
|
302
|
+
if (!v || typeof v !== 'object' || Array.isArray(v))
|
|
303
|
+
return false;
|
|
304
|
+
const o = v;
|
|
305
|
+
const keys = Object.keys(o);
|
|
306
|
+
return keys.length === 3 && ['url', 'account', 'user'].every((k) => typeof o[k] === 'string')
|
|
307
|
+
&& keys.every((k) => ['url', 'account', 'user'].includes(k));
|
|
308
|
+
}
|
|
309
|
+
/** 渲染 ovcli.conf(纯函数 exported 便于单测)。刻意不含 api_key / plugin 段;user 取 account
|
|
310
|
+
* 值:保证直连场景 identity 非空 → 插件判 trusted header 身份(dev 模式服务可用);留空会被
|
|
311
|
+
* 判成 api_key 模式而 401 */
|
|
312
|
+
export function renderOvcliConf(memory) {
|
|
313
|
+
return { url: memoryEndpoint(memory), account: memory.account ?? 'lcb', user: memory.account ?? 'lcb' };
|
|
314
|
+
}
|
|
315
|
+
/** 写/刷新客户端配置(幂等可重跑)。决策表:
|
|
316
|
+
* ① EXTERNAL_OV_CONF 存在 → 'skipped-external'(~/.openviking 域归用户自管,整体不碰;
|
|
317
|
+
* 外部 ov.conf 本身就满足插件启用判定,LCB 会话另有 env 通道兜底)
|
|
318
|
+
* ② 文件缺失 → mkdir recursive + writeAtomic(渲染值) → 'written'
|
|
319
|
+
* ③ 文件存在且为 LCB 骨架:内容与渲染值全等 → 'kept'(幂等零写);否则重写 → 'updated'
|
|
320
|
+
* (端口漂移/账号改名/remote↔embedded 切换后的自愈路径)
|
|
321
|
+
* ④ 其余(含 api_key 的用户手配、plugin 段、损坏 JSON)→ 'skipped-foreign'(尊重不动)
|
|
322
|
+
* dirOverride 供单测注入临时目录(ov.conf/ovcli.conf 一并重定向,摆脱模块常量的 home 依赖) */
|
|
323
|
+
export function ensureClientCliConf(memory, dirOverride) {
|
|
324
|
+
const ovConf = dirOverride ? join(dirOverride, 'ov.conf') : EXTERNAL_OV_CONF;
|
|
325
|
+
const ovcli = dirOverride ? join(dirOverride, 'ovcli.conf') : OVCLI_CONF;
|
|
326
|
+
if (existsSync(ovConf))
|
|
327
|
+
return 'skipped-external';
|
|
328
|
+
const next = renderOvcliConf(memory);
|
|
329
|
+
let cur;
|
|
330
|
+
try {
|
|
331
|
+
cur = JSON.parse(readFileSync(ovcli, 'utf8'));
|
|
332
|
+
}
|
|
333
|
+
catch {
|
|
334
|
+
cur = undefined;
|
|
335
|
+
}
|
|
336
|
+
if (cur === undefined && !existsSync(ovcli)) {
|
|
337
|
+
writeAtomic(ovcli, JSON.stringify(next, null, 2));
|
|
338
|
+
return 'written';
|
|
339
|
+
}
|
|
340
|
+
if (isLcbClientConfShape(cur)) {
|
|
341
|
+
if (JSON.stringify(cur) === JSON.stringify(next))
|
|
342
|
+
return 'kept';
|
|
343
|
+
writeAtomic(ovcli, JSON.stringify(next, null, 2));
|
|
344
|
+
return 'updated';
|
|
345
|
+
}
|
|
346
|
+
return 'skipped-foreign';
|
|
347
|
+
}
|
|
348
|
+
/** 关记忆的对称清理:仅当解析后命中 LCB 骨架形状才删(返回是否删除)。刻意不做内容匹配
|
|
349
|
+
* (见 isLcbClientConfShape 注释);损坏/手配文件一律保留 */
|
|
350
|
+
export function cleanupClientCliConf(dirOverride) {
|
|
351
|
+
const ovcli = dirOverride ? join(dirOverride, 'ovcli.conf') : OVCLI_CONF;
|
|
352
|
+
let cur;
|
|
353
|
+
try {
|
|
354
|
+
cur = JSON.parse(readFileSync(ovcli, 'utf8'));
|
|
355
|
+
}
|
|
356
|
+
catch {
|
|
357
|
+
return false;
|
|
358
|
+
}
|
|
359
|
+
if (!isLcbClientConfShape(cur))
|
|
360
|
+
return false;
|
|
361
|
+
rmSync(ovcli, { force: true });
|
|
362
|
+
return true;
|
|
363
|
+
}
|
|
364
|
+
/** 状态页分类(exported 便于 memory-api 与单测复用;路径入参可注入摆脱 home 依赖)。
|
|
365
|
+
* external 优先:外部 ov.conf 存在即整个 ~/.openviking 域归用户(与 ensure 的守卫口径一致) */
|
|
366
|
+
export function classifyClientConf(paths) {
|
|
367
|
+
if (existsSync(paths.ovConf))
|
|
368
|
+
return { state: 'external', path: paths.ovConf };
|
|
369
|
+
let cur;
|
|
370
|
+
try {
|
|
371
|
+
cur = JSON.parse(readFileSync(paths.ovcli, 'utf8'));
|
|
372
|
+
}
|
|
373
|
+
catch {
|
|
374
|
+
cur = undefined;
|
|
375
|
+
}
|
|
376
|
+
if (isLcbClientConfShape(cur))
|
|
377
|
+
return { state: 'lcb-managed', path: paths.ovcli };
|
|
378
|
+
if (existsSync(paths.ovcli))
|
|
379
|
+
return { state: 'user-managed', path: paths.ovcli };
|
|
380
|
+
return { state: 'missing', path: paths.ovcli };
|
|
381
|
+
}
|
|
382
|
+
// ============ 步骤 4:受管拉起 ============
|
|
383
|
+
/** 找空闲端口:从 1933 起「net 试探 + HTTP 探活」双重确认(Windows SO_REUSEADDR 允许抢绑,
|
|
384
|
+
* net listen 成功 ≠ 端口空闲——已有 OpenViking 服务在监听时 listen 可能误报空闲;探活有
|
|
385
|
+
* 响应 = 确定被占)。候选端口同时两者皆过才采用。 */
|
|
386
|
+
export async function findFreePort(startPort = 1933, tries = 20) {
|
|
387
|
+
const { createServer } = await import('node:net');
|
|
388
|
+
for (let p = startPort; p < startPort + tries; p++) {
|
|
389
|
+
const netOk = await new Promise((res) => {
|
|
390
|
+
const s = createServer();
|
|
391
|
+
s.once('error', () => res(false));
|
|
392
|
+
s.listen(p, '127.0.0.1', () => s.close(() => res(true)));
|
|
393
|
+
});
|
|
394
|
+
if (!netOk)
|
|
395
|
+
continue;
|
|
396
|
+
const health = await probeMemoryHealth(`http://127.0.0.1:${p}`, 800);
|
|
397
|
+
if (health.healthy)
|
|
398
|
+
continue; // 有活服务:占用
|
|
399
|
+
return p;
|
|
400
|
+
}
|
|
401
|
+
throw new Error(`${startPort}-${startPort + tries - 1} 端口段均被占用`);
|
|
402
|
+
}
|
|
403
|
+
/** 拉起受管实例并探活等待就绪(安装流水线第 4 步与「启动服务」按钮共用)。
|
|
404
|
+
* 探活轮询给 30s(服务冷启含依赖加载);启动即崩/超时统一抛带指引的 Error(调用方决定展示)。 */
|
|
405
|
+
async function launchManagedAndWait(bin, confPath, port, external = false) {
|
|
406
|
+
spawnManaged(bin, confPath, port);
|
|
407
|
+
for (let i = 0; i < 30; i++) {
|
|
408
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
409
|
+
const h = await probeMemoryHealth(`http://127.0.0.1:${port}`, 1500);
|
|
410
|
+
if (h.healthy) {
|
|
411
|
+
return { detail: `服务已就绪:http://127.0.0.1:${port}${external ? '(外部配置 ~/.openviking/ov.conf,LCB 仅代为拉起、不改写)' : ''}`, port };
|
|
412
|
+
}
|
|
413
|
+
if (!managed)
|
|
414
|
+
break; // 启动即崩(config 错等)
|
|
415
|
+
}
|
|
416
|
+
const hint = `服务启动后探活失败(127.0.0.1:${port})——请检查 ov.conf 的模型配置(api_base/key/模型名)${external ? ',或改用「一键安装」生成 LCB 受管配置' : ''}`;
|
|
417
|
+
stopManagedServer();
|
|
418
|
+
throw new Error(hint);
|
|
419
|
+
}
|
|
420
|
+
async function stepStart(bin, confPath) {
|
|
421
|
+
const port = readConfPort(confPath);
|
|
422
|
+
// 已有受管实例:探活通即跳过(幂等重跑)
|
|
423
|
+
if (managed && !managed.stopped) {
|
|
424
|
+
const h = await probeMemoryHealth(`http://127.0.0.1:${managed.port}`, 2000);
|
|
425
|
+
if (h.healthy)
|
|
426
|
+
return { skipped: true, detail: `受管实例已在运行(127.0.0.1:${managed.port})`, port: managed.port };
|
|
427
|
+
stopManagedServer();
|
|
428
|
+
}
|
|
429
|
+
const r = await launchManagedAndWait(bin, confPath, port);
|
|
430
|
+
return { skipped: false, detail: r.detail, port: r.port };
|
|
431
|
+
}
|
|
432
|
+
// ============ 单独启动(「启动服务」按钮 → LCB 接管式启动流水线) ============
|
|
433
|
+
/**
|
|
434
|
+
* 启动流水线(kind='start',后台执行 + 与安装同款 stepper 轮询展示):LCB 已接管 OpenViking,
|
|
435
|
+
* 「启动」就应当是接管的启动——不只拉进程,还要把记忆链路整段拉就绪:
|
|
436
|
+
* ① 检查服务与配置(bin / ov.conf 定位,缺失即 failed + 指引改走「检查修复 / 一键安装」)
|
|
437
|
+
* ② 拉起并探活(受管拉起 + 最长 30s 探活;已在运行则 skipped,幂等重跑)
|
|
438
|
+
* ③ 接入记忆插件(CLI 市场渠道真实安装:已登记启用 → skipped;缺失 → marketplace add/install/enable
|
|
439
|
+
* 三步幂等安装,插件页可见可管理;CLI 失败回退 zip 下载自管副本)
|
|
440
|
+
* 完成后无须再点「检查修复」——启动结束即完整接管态。任何一步失败即停(同安装流水线语义)。
|
|
441
|
+
*/
|
|
442
|
+
export async function runStartPipeline(opts) {
|
|
443
|
+
if (installState.running)
|
|
444
|
+
return installState;
|
|
445
|
+
installState = { kind: 'start', running: true, startedAt: new Date().toISOString(), steps: START_STEPS() };
|
|
446
|
+
const steps = installState.steps;
|
|
447
|
+
const set = (id, status, detail) => {
|
|
448
|
+
const s = steps.find((x) => x.id === id);
|
|
449
|
+
s.status = status;
|
|
450
|
+
if (detail !== undefined)
|
|
451
|
+
s.detail = detail;
|
|
452
|
+
};
|
|
453
|
+
try {
|
|
454
|
+
// ① 定位 bin + conf(受管 conf 优先 → 外部 conf 兜底;缺失即挡下并给改道指引)
|
|
455
|
+
set('locate', 'running');
|
|
456
|
+
const uv = await detectUv();
|
|
457
|
+
const bin = uv ? await resolveServerBin(uv) : null;
|
|
458
|
+
if (!bin)
|
|
459
|
+
throw new Error('未检测到已安装的 OpenViking 服务(openviking-server)——请点「检查修复」自动补齐运行时与服务');
|
|
460
|
+
const confPath = existsSync(MANAGED_OV_CONF) ? MANAGED_OV_CONF
|
|
461
|
+
: existsSync(EXTERNAL_OV_CONF) ? EXTERNAL_OV_CONF : undefined;
|
|
462
|
+
if (!confPath)
|
|
463
|
+
throw new Error('未找到服务配置(ov.conf)——请点「检查修复」自动生成(需先在「模型配置」卡填好并保存)');
|
|
464
|
+
const external = confPath === EXTERNAL_OV_CONF;
|
|
465
|
+
set('locate', 'done', `服务:${bin}${external ? `;配置:${confPath}(外部,LCB 仅代为拉起、不改写)` : ';配置:受管 ov.conf'}`);
|
|
466
|
+
// ② 拉起 + 探活(已有受管实例且探活通过 → skipped)
|
|
467
|
+
set('launch', 'running');
|
|
468
|
+
let port = managed?.port;
|
|
469
|
+
let needLaunch = true;
|
|
470
|
+
if (managed && !managed.stopped) {
|
|
471
|
+
const h = await probeMemoryHealth(`http://127.0.0.1:${managed.port}`, 2000);
|
|
472
|
+
if (h.healthy) {
|
|
473
|
+
set('launch', 'skipped', `受管实例已在运行(127.0.0.1:${managed.port})`);
|
|
474
|
+
needLaunch = false;
|
|
475
|
+
}
|
|
476
|
+
else {
|
|
477
|
+
stopManagedServer();
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
if (needLaunch) {
|
|
481
|
+
const r = await launchManagedAndWait(bin, confPath, readConfPort(confPath), external);
|
|
482
|
+
port = r.port;
|
|
483
|
+
set('launch', 'done', r.detail);
|
|
484
|
+
}
|
|
485
|
+
// ③ 插件接入(启动完整性的另一半:插件缺失时记忆照样不工作——补齐并回写显式引用;
|
|
486
|
+
// client 传 endpoint/account:顺带落客户端接入配置 ovcli.conf,外部模式由 ensure 自守卫跳过)
|
|
487
|
+
set('plugin', 'running');
|
|
488
|
+
const pl = await stepPlugin({
|
|
489
|
+
endpoint: `http://127.0.0.1:${port ?? 1933}`,
|
|
490
|
+
account: await memoryAccount(opts.configPath),
|
|
491
|
+
});
|
|
492
|
+
set('plugin', pl.skipped ? 'skipped' : 'done', pl.detail);
|
|
493
|
+
if (pl.writeback) {
|
|
494
|
+
// 插件新装/调和才回写:受管场景顺带对齐 endpoint/mode;外部场景不动用户配置(只补插件引用)
|
|
495
|
+
writeBackConfig(opts.configPath, external ? {} : { endpoint: `http://127.0.0.1:${port ?? 1933}`, mode: 'embedded' }, pl.writeback.pluginPath, pl.writeback.opts);
|
|
496
|
+
}
|
|
497
|
+
installState.running = false;
|
|
498
|
+
installState.finishedAt = new Date().toISOString();
|
|
499
|
+
return installState;
|
|
500
|
+
}
|
|
501
|
+
catch (e) {
|
|
502
|
+
installState.running = false;
|
|
503
|
+
installState.finishedAt = new Date().toISOString();
|
|
504
|
+
const failed = steps.find((x) => x.status === 'running');
|
|
505
|
+
if (failed) {
|
|
506
|
+
failed.status = 'failed';
|
|
507
|
+
failed.detail = e instanceof Error ? e.message : String(e);
|
|
508
|
+
}
|
|
509
|
+
return installState;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
function spawnManaged(bin, confPath, port) {
|
|
513
|
+
const proc = spawn(bin, ['--config', confPath, '--host', '127.0.0.1', '--port', String(port)], {
|
|
514
|
+
windowsHide: true,
|
|
515
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
516
|
+
});
|
|
517
|
+
managed = { proc, port, restarts: 0, stopped: false, confPath, startedAt: new Date().toISOString() };
|
|
518
|
+
proc.on('exit', () => {
|
|
519
|
+
if (!managed || managed.proc !== proc)
|
|
520
|
+
return;
|
|
521
|
+
if (managed.stopped) {
|
|
522
|
+
managed = undefined;
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
// 崩溃自动重启(上限 3 次,防坏配置循环拉起刷屏)
|
|
526
|
+
if (managed.restarts < 3) {
|
|
527
|
+
managed.restarts += 1;
|
|
528
|
+
console.warn(`[memory] 受管服务退出,${2}s 后自动重启(第 ${managed.restarts}/3 次):${bin}`);
|
|
529
|
+
setTimeout(() => {
|
|
530
|
+
if (managed && !managed.stopped && managed.restarts <= 3) {
|
|
531
|
+
try {
|
|
532
|
+
spawnManaged(bin, confPath, port);
|
|
533
|
+
}
|
|
534
|
+
catch (e) {
|
|
535
|
+
console.error('[memory] 重启失败:', e);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
}, 2000);
|
|
539
|
+
}
|
|
540
|
+
else {
|
|
541
|
+
console.error('[memory] 受管服务连续崩溃 3 次,已停止自动重启——请检查 ov.conf 后在页面重新拉起');
|
|
542
|
+
managed = undefined;
|
|
543
|
+
}
|
|
544
|
+
});
|
|
545
|
+
proc.stderr?.on('data', (d) => {
|
|
546
|
+
const line = String(d).trim();
|
|
547
|
+
if (line)
|
|
548
|
+
console.warn('[memory-server]', line.slice(0, 300));
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
// ============ 步骤 5:插件 ============
|
|
552
|
+
/** LCB 自管插件目录:zip 下载兜底路径的落点(CLI 市场渠道失败时;正常安装走 marketplace 登记,
|
|
553
|
+
* 插件页可见可管理),<根>/<版本>/ 版本目录化可并存 */
|
|
554
|
+
const MANAGED_PLUGIN_ROOT = join(process.env.LCB_CONFIG_DIR ?? String(process.env.USERPROFILE ?? process.env.HOME ?? ''), '.lark-claudecode-bridge', 'plugins', MEMORY_PLUGIN_NAME);
|
|
555
|
+
/** 自管目录最新合法副本:扫 MANAGED_PLUGIN_ROOT 下一层版本目录,取语义化版本最高且
|
|
556
|
+
* plugin.json 在的(字符串比较会把 0.7.6 排在 0.7.11 后——按数字段比较)。目录缺失/无合法副本返回 null */
|
|
557
|
+
function latestManagedCopy() {
|
|
558
|
+
let entries;
|
|
559
|
+
try {
|
|
560
|
+
entries = readdirSync(MANAGED_PLUGIN_ROOT, { withFileTypes: true });
|
|
561
|
+
}
|
|
562
|
+
catch {
|
|
563
|
+
return null;
|
|
564
|
+
} // 目录不存在 = 从未下载过,正常态
|
|
565
|
+
const candidates = [];
|
|
566
|
+
for (const e of entries) {
|
|
567
|
+
if (!e.isDirectory())
|
|
568
|
+
continue;
|
|
569
|
+
const marker = join(MANAGED_PLUGIN_ROOT, e.name, '.claude-plugin', 'plugin.json');
|
|
570
|
+
if (!existsSync(marker))
|
|
571
|
+
continue;
|
|
572
|
+
let version = e.name;
|
|
573
|
+
try {
|
|
574
|
+
version = JSON.parse(readFileSync(marker, 'utf8')).version ?? e.name;
|
|
575
|
+
}
|
|
576
|
+
catch { /* 清单损坏按目录名 */ }
|
|
577
|
+
candidates.push({ path: join(MANAGED_PLUGIN_ROOT, e.name), version, parts: version.split('.').map((n) => parseInt(n, 10) || 0) });
|
|
578
|
+
}
|
|
579
|
+
if (candidates.length === 0)
|
|
580
|
+
return null;
|
|
581
|
+
candidates.sort((a, b) => {
|
|
582
|
+
for (let i = 0; i < 3; i++) {
|
|
583
|
+
const d = (a.parts[i] ?? 0) - (b.parts[i] ?? 0);
|
|
584
|
+
if (d)
|
|
585
|
+
return d;
|
|
586
|
+
}
|
|
587
|
+
return 0;
|
|
588
|
+
});
|
|
589
|
+
return candidates[candidates.length - 1];
|
|
590
|
+
}
|
|
591
|
+
/**
|
|
592
|
+
* 记忆插件三渠道定位(状态页与安装流水线的共用口径,取代「只看 marketplace」的旧探测——
|
|
593
|
+
* CLI 卸载只清 installed_plugins.json/enabledPlugins 登记,config.yaml 显式引用与自管副本
|
|
594
|
+
* 依旧在工作的场景曾被误报「未安装」)。探测顺序对齐 resolvePluginPaths 的装配语义
|
|
595
|
+
* (同名显式优先):运行时实际加载谁,状态就报谁。
|
|
596
|
+
* ① config.yaml apps[].plugins 显式引用(任意 bot 命中即可;路径须 plugin.json 在——SDK 对
|
|
597
|
+
* 无效路径静默跳过,死路径按未装处理,交给修复流水线改指);
|
|
598
|
+
* ② marketplace 自动发现(与 executeTask 同款双目录口径);
|
|
599
|
+
* ③ LCB 自管副本(仅 opts.includeManaged=true 时计入——未被引用的副本运行时不会加载,
|
|
600
|
+
* 状态页不算已装;修复流水线算,据其免重装并回写补引用)。
|
|
601
|
+
* 全程容错:单渠道异常按未命中继续,全 miss 返回 null。
|
|
602
|
+
*/
|
|
603
|
+
export async function locateMemoryPlugin(config, opts = {}) {
|
|
604
|
+
for (const app of config.apps ?? []) {
|
|
605
|
+
const ref = (app.plugins ?? []).find((p) => p.name === MEMORY_PLUGIN_NAME);
|
|
606
|
+
if (ref?.path && existsSync(join(ref.path, '.claude-plugin', 'plugin.json'))) {
|
|
607
|
+
return { path: ref.path, where: 'explicit' };
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
try {
|
|
611
|
+
const { discoverPlugins } = await import('./executor/plugin-discovery.js');
|
|
612
|
+
const { DEFAULT_CLAUDE_DIR, resolveClaudeDir } = await import('./claude-config.js');
|
|
613
|
+
const claudeDir = resolveClaudeDir(config);
|
|
614
|
+
const dirs = claudeDir === DEFAULT_CLAUDE_DIR ? [claudeDir] : [claudeDir, DEFAULT_CLAUDE_DIR];
|
|
615
|
+
for (const d of dirs) {
|
|
616
|
+
const found = discoverPlugins(d).find((p) => p.name === MEMORY_PLUGIN_NAME);
|
|
617
|
+
if (found) {
|
|
618
|
+
return {
|
|
619
|
+
path: found.path, where: 'marketplace',
|
|
620
|
+
...(found.version ? { version: found.version } : {}),
|
|
621
|
+
...(found.marketplace ? { marketplace: found.marketplace } : {}),
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
catch { /* 插件发现容错契约(配置损坏等):本渠道按未命中继续 */ }
|
|
627
|
+
if (opts.includeManaged) {
|
|
628
|
+
const managed = latestManagedCopy();
|
|
629
|
+
if (managed)
|
|
630
|
+
return { path: managed.path, where: 'managed', version: managed.version };
|
|
631
|
+
}
|
|
632
|
+
return null;
|
|
633
|
+
}
|
|
634
|
+
/** installed_plugins.json(v2)合并登记:保留既有插件条目,仅 upsert 本插件(scope=user)。
|
|
635
|
+
* 文件缺失/损坏重建为最小合法文档;installedAt 沿用旧值(重装不洗安装时间)。
|
|
636
|
+
* exported 便于单测验证合并语义 */
|
|
637
|
+
export function registerInstalledPlugin(claudeDir, key, item) {
|
|
638
|
+
const path = join(claudeDir, 'plugins', 'installed_plugins.json');
|
|
639
|
+
const doc = (() => {
|
|
640
|
+
try {
|
|
641
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
642
|
+
if (parsed.version === 2 && parsed.plugins && typeof parsed.plugins === 'object') {
|
|
643
|
+
return { version: 2, plugins: parsed.plugins };
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
catch { /* 缺失/损坏 → 重建 */ }
|
|
647
|
+
return { version: 2, plugins: {} };
|
|
648
|
+
})();
|
|
649
|
+
const prev = Array.isArray(doc.plugins[key]) ? doc.plugins[key].find((e) => e.scope === 'user') : undefined;
|
|
650
|
+
const now = new Date().toISOString();
|
|
651
|
+
doc.plugins[key] = [{
|
|
652
|
+
scope: 'user',
|
|
653
|
+
installPath: item.installPath,
|
|
654
|
+
...(item.version ? { version: item.version } : {}),
|
|
655
|
+
installedAt: prev?.installedAt ?? now,
|
|
656
|
+
lastUpdated: now,
|
|
657
|
+
}];
|
|
658
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
659
|
+
writeFileSync(path, JSON.stringify(doc, null, 2), 'utf8');
|
|
660
|
+
}
|
|
661
|
+
/** settings.json enabledPlugins 启用(整体读改写保留其余键;文件缺失/损坏建最小对象)。
|
|
662
|
+
* exported 便于单测验证保留语义 */
|
|
663
|
+
export function enablePluginInSettings(claudeDir, key) {
|
|
664
|
+
const path = join(claudeDir, 'settings.json');
|
|
665
|
+
let doc = {};
|
|
666
|
+
try {
|
|
667
|
+
doc = JSON.parse(readFileSync(path, 'utf8'));
|
|
668
|
+
}
|
|
669
|
+
catch { /* 缺失/损坏 → 新建 */ }
|
|
670
|
+
const ep = (doc.enabledPlugins && typeof doc.enabledPlugins === 'object' && !Array.isArray(doc.enabledPlugins))
|
|
671
|
+
? doc.enabledPlugins : {};
|
|
672
|
+
ep[key] = true;
|
|
673
|
+
doc.enabledPlugins = ep;
|
|
674
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
675
|
+
writeFileSync(path, JSON.stringify(doc, null, 2), 'utf8');
|
|
676
|
+
}
|
|
677
|
+
/** 已登记市场名集合(known_marketplaces.json 键;url/github 源市场不物化清单副本,登记表即事实源) */
|
|
678
|
+
function knownMarketplaceNames(claudeDir) {
|
|
679
|
+
try {
|
|
680
|
+
const doc = JSON.parse(readFileSync(join(claudeDir, 'plugins', 'known_marketplaces.json'), 'utf8'));
|
|
681
|
+
return new Set(Object.keys(doc));
|
|
682
|
+
}
|
|
683
|
+
catch {
|
|
684
|
+
return new Set();
|
|
685
|
+
} // 从未添加过任何市场
|
|
686
|
+
}
|
|
687
|
+
/**
|
|
688
|
+
* CLI 市场渠道真实安装:marketplace add(缺登记时)→ plugin install(缺装/产物失效时)
|
|
689
|
+
* → plugin enable(未启用时)→ discoverPlugins 终验(双登记 + 路径有效 = 任务装配可加载)。
|
|
690
|
+
* 三步各自幂等可重跑;产物路径从安装清单回读(installPath 供调和回写)。
|
|
691
|
+
* 任何一步失败返回 { detail } 不抛出——调用方回退 zip 下载兜底。
|
|
692
|
+
*/
|
|
693
|
+
async function cliInstallViaMarketplace(claudeDir, dirs) {
|
|
694
|
+
const { runPluginCli } = await import('./executor/plugin-manager.js');
|
|
695
|
+
const { discoverPlugins, listInstalledPlugins } = await import('./executor/plugin-discovery.js');
|
|
696
|
+
if (!knownMarketplaceNames(claudeDir).has(MARKETPLACE_NAME)) {
|
|
697
|
+
let lastErr = '';
|
|
698
|
+
for (const src of MARKETPLACE_ADD_SOURCES) {
|
|
699
|
+
const r = await runPluginCli(['marketplace', 'add', src], { claudeConfigDir: claudeDir });
|
|
700
|
+
if (r.ok && knownMarketplaceNames(claudeDir).has(MARKETPLACE_NAME)) {
|
|
701
|
+
lastErr = '';
|
|
702
|
+
break;
|
|
703
|
+
}
|
|
704
|
+
lastErr = r.text;
|
|
705
|
+
}
|
|
706
|
+
if (lastErr || !knownMarketplaceNames(claudeDir).has(MARKETPLACE_NAME)) {
|
|
707
|
+
return { detail: `注册市场 ${MARKETPLACE_NAME} 失败:${lastErr || '登记表未见该市场'}` };
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
// 安装(已装且产物在位则跳过;install 自带 -y 非交互确认)
|
|
711
|
+
const entry = listInstalledPlugins(claudeDir).find((p) => p.key === MARKETPLACE_KEY || p.name === MEMORY_PLUGIN_NAME);
|
|
712
|
+
if (!entry?.path || !existsSync(entry.path)) {
|
|
713
|
+
const r = await runPluginCli(['install', MARKETPLACE_KEY], { claudeConfigDir: claudeDir });
|
|
714
|
+
if (!r.ok)
|
|
715
|
+
return { detail: `安装 ${MARKETPLACE_KEY} 失败:${r.text}` };
|
|
716
|
+
}
|
|
717
|
+
// 启用(install 不保证 enabledPlugins 落 true;enable 对已启用为幂等 no-op)
|
|
718
|
+
const discoverHit = () => {
|
|
719
|
+
for (const d of dirs) {
|
|
720
|
+
const hit = discoverPlugins(d).find((p) => p.name === MEMORY_PLUGIN_NAME);
|
|
721
|
+
if (hit)
|
|
722
|
+
return hit.path;
|
|
723
|
+
}
|
|
724
|
+
return undefined;
|
|
725
|
+
};
|
|
726
|
+
if (!discoverHit()) {
|
|
727
|
+
const r = await runPluginCli(['enable', MARKETPLACE_KEY], { claudeConfigDir: claudeDir });
|
|
728
|
+
if (!r.ok)
|
|
729
|
+
return { detail: `启用 ${MARKETPLACE_KEY} 失败:${r.text}` };
|
|
730
|
+
}
|
|
731
|
+
const installPath = discoverHit();
|
|
732
|
+
return installPath
|
|
733
|
+
? { detail: `已安装并启用:${installPath}`, installPath }
|
|
734
|
+
: { detail: `安装动作已完成但未能自动发现 ${MARKETPLACE_KEY}(installed_plugins/enabledPlugins 未生效,请反馈)` };
|
|
735
|
+
}
|
|
736
|
+
/** 接入记忆插件(安装流水线第 5 步/启动流水线第 3 步共用;exported 便于运维脚本与单测直调):
|
|
737
|
+
* ① CLI marketplace 双登记命中 → skipped;② CLI 真实安装(幂等);③ zip 兜底 + CLI 同构登记。
|
|
738
|
+
* client 传入时(endpoint/account)同时落客户端接入配置 ovcli.conf(§6.7 第 4 步:插件启用判定
|
|
739
|
+
* 的文件兜底层;写入失败只 warn 不阻塞插件接入——LCB 会话另有 env 通道兜底) */
|
|
740
|
+
export async function stepPlugin(client) {
|
|
741
|
+
// 客户端配置先落(三个 return 分支都要携带:①分支是高频提前 return——「已装但无文件」的
|
|
742
|
+
// 存量机器最常见形态,放函数体后段会漏掉)
|
|
743
|
+
let clientConf;
|
|
744
|
+
if (client) {
|
|
745
|
+
try {
|
|
746
|
+
clientConf = ensureClientCliConf(client);
|
|
747
|
+
}
|
|
748
|
+
catch (e) {
|
|
749
|
+
console.warn('[memory-installer] ovcli.conf 写入失败(不阻塞插件接入):', e instanceof Error ? e.message : e);
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
const detailSuffix = clientConf ? `;客户端配置 ${clientConf}` : '';
|
|
753
|
+
const { loadConfig } = await import('./config.js');
|
|
754
|
+
const { DEFAULT_CLAUDE_DIR, resolveClaudeDir } = await import('./claude-config.js');
|
|
755
|
+
let claudeDir = DEFAULT_CLAUDE_DIR;
|
|
756
|
+
try {
|
|
757
|
+
claudeDir = resolveClaudeDir(loadConfig());
|
|
758
|
+
}
|
|
759
|
+
catch { /* 配置损坏按默认目录装配口径 */ }
|
|
760
|
+
const dirs = claudeDir === DEFAULT_CLAUDE_DIR ? [claudeDir] : [claudeDir, DEFAULT_CLAUDE_DIR];
|
|
761
|
+
// ① CLI marketplace 双登记命中 = 完整安装态(插件页可见、可启停可更新)。仍携带调和回写:
|
|
762
|
+
// 把产物域旧显式引用对齐当前登记版本(writeBackConfig 值一致时零写盘,幂等)——否则
|
|
763
|
+
// CLI update 装新版后旧引用仍钉死旧版本目录,运行时与插件页版本错位
|
|
764
|
+
try {
|
|
765
|
+
const { discoverPlugins } = await import('./executor/plugin-discovery.js');
|
|
766
|
+
for (const d of dirs) {
|
|
767
|
+
const hit = discoverPlugins(d).find((p) => p.name === MEMORY_PLUGIN_NAME);
|
|
768
|
+
if (hit)
|
|
769
|
+
return {
|
|
770
|
+
skipped: true,
|
|
771
|
+
detail: `已安装(CLI marketplace 已启用):${hit.path}${detailSuffix}`,
|
|
772
|
+
clientConf,
|
|
773
|
+
writeback: {
|
|
774
|
+
pluginPath: hit.path,
|
|
775
|
+
opts: { reconcileOnly: true, artifactRoots: [...dirs.map((x) => join(x, 'plugins', 'cache')), MANAGED_PLUGIN_ROOT] },
|
|
776
|
+
},
|
|
777
|
+
};
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
catch { /* 发现容错契约:继续走安装 */ }
|
|
781
|
+
// ② CLI 市场渠道真实安装(幂等可重跑)。成功即调和 config.yaml:把指向旧安装产物
|
|
782
|
+
// (plugins/cache / 自管副本域)或已失效路径的显式引用就地改指新 installPath——
|
|
783
|
+
// 不新增引用(发现渠道已覆盖加载),用户显式指的 dev 目录不动(reconcileOnly 语义)
|
|
784
|
+
const cli = await cliInstallViaMarketplace(claudeDir, dirs);
|
|
785
|
+
if (cli.installPath) {
|
|
786
|
+
return {
|
|
787
|
+
skipped: false,
|
|
788
|
+
detail: `${cli.detail}(插件页可见,可启停/更新)${detailSuffix}`,
|
|
789
|
+
clientConf,
|
|
790
|
+
writeback: {
|
|
791
|
+
pluginPath: cli.installPath,
|
|
792
|
+
opts: { reconcileOnly: true, artifactRoots: [...dirs.map((d) => join(d, 'plugins', 'cache')), MANAGED_PLUGIN_ROOT] },
|
|
793
|
+
},
|
|
794
|
+
};
|
|
795
|
+
}
|
|
796
|
+
console.warn(`[memory-installer] CLI 市场安装未成功,回退自管下载:${cli.detail}`);
|
|
797
|
+
// ③ 兜底:CLI 市场安装不可用(partial-clone blob 拉取被网络掐断等)→ 仍以 CLI 同构状态落地:
|
|
798
|
+
// codeload 全量归档(实测比 partial-clone 稳定)→ 解出插件子目录 → 物化到 CLI 缓存布局
|
|
799
|
+
// plugins/cache/<市场>/<插件>/<版本>/ → 合并登记(installed_plugins.json v2 + enabledPlugins)
|
|
800
|
+
// → 插件页可见、CLI enable/disable/uninstall 均原生可用,与 ② 成功态完全等价
|
|
801
|
+
// 下载主仓库归档 → 取 examples/claude-code-memory-plugin → 校验 plugin.json → 拷入缓存布局
|
|
802
|
+
const isWin = process.platform === 'win32';
|
|
803
|
+
const ref = 'main'; // pin 到 tag 的能力随 memory.version 演进(版本受控,风险 8.4)
|
|
804
|
+
const tmp = mkdtempSync(join(tmpdir(), 'lcb-ovp-'));
|
|
805
|
+
try {
|
|
806
|
+
const archive = join(tmp, isWin ? 'repo.zip' : 'repo.tgz');
|
|
807
|
+
await downloadTo(isWin ? PLUGIN_REPO_ZIP(ref) : PLUGIN_REPO_TGZ(ref), archive);
|
|
808
|
+
// 只提取需要的子目录成员(实测教训:整仓解压时无关条目会触发 bsdtar 的 \\?\ 长路径 bug
|
|
809
|
+
// 报错退出;且只取子目录比解 60MB 全仓快得多)。成员名与 codeload 解包根一致
|
|
810
|
+
const member = `OpenViking-${ref}/${PLUGIN_SUBDIR}`;
|
|
811
|
+
const t = tarCommand();
|
|
812
|
+
await run(t.cmd, [...t.args, '-xf', archive, '-C', tmp, member], { timeoutMs: 180_000 });
|
|
813
|
+
const srcRoot = join(tmp, `OpenViking-${ref}`, ...PLUGIN_SUBDIR.split('/'));
|
|
814
|
+
const marker = join(srcRoot, '.claude-plugin', 'plugin.json');
|
|
815
|
+
if (!existsSync(marker))
|
|
816
|
+
throw new Error('归档中未找到 examples/claude-code-memory-plugin/.claude-plugin/plugin.json(上游结构可能已变化)');
|
|
817
|
+
// 版本目录化(升级可并存):读 plugin.json 的 version
|
|
818
|
+
const pkg = JSON.parse(readFileSync(marker, 'utf8'));
|
|
819
|
+
const version = pkg.version ?? '0.0.0';
|
|
820
|
+
const dest = join(claudeDir, 'plugins', 'cache', MARKETPLACE_NAME, MEMORY_PLUGIN_NAME, version);
|
|
821
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
822
|
+
rmSync(dest, { recursive: true, force: true });
|
|
823
|
+
await run(process.platform === 'win32' ? 'xcopy' : 'cp', process.platform === 'win32'
|
|
824
|
+
? [srcRoot, dest, '/E', '/I', '/Q', '/Y']
|
|
825
|
+
: ['-r', srcRoot, dest], { timeoutMs: 60_000 });
|
|
826
|
+
if (!existsSync(join(dest, '.claude-plugin', 'plugin.json')))
|
|
827
|
+
throw new Error('插件拷贝后校验失败');
|
|
828
|
+
// CLI 同构登记 + 启用(幂等合并写,保留既有条目/其余键),清发现缓存让本进程立即可见
|
|
829
|
+
registerInstalledPlugin(claudeDir, MARKETPLACE_KEY, { installPath: dest, version });
|
|
830
|
+
enablePluginInSettings(claudeDir, MARKETPLACE_KEY);
|
|
831
|
+
const { invalidatePluginCache } = await import('./executor/plugin-discovery.js');
|
|
832
|
+
invalidatePluginCache(claudeDir);
|
|
833
|
+
return {
|
|
834
|
+
skipped: false,
|
|
835
|
+
detail: `已安装 v${version}(CLI 同构登记)→ ${dest}(插件页可见,可启停/更新)${detailSuffix}`,
|
|
836
|
+
clientConf,
|
|
837
|
+
writeback: {
|
|
838
|
+
pluginPath: dest,
|
|
839
|
+
opts: { reconcileOnly: true, artifactRoots: [...dirs.map((d) => join(d, 'plugins', 'cache')), MANAGED_PLUGIN_ROOT] },
|
|
840
|
+
},
|
|
841
|
+
};
|
|
842
|
+
}
|
|
843
|
+
finally {
|
|
844
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
// ============ config.yaml 定向写(保注释,YAML AST set) ============
|
|
848
|
+
/** 原子写(tmp + rename):与 web/server.ts / claude-profile.ts 的 writeAtomic 同款实现,独立内联避免反向依赖 */
|
|
849
|
+
function writeAtomic(path, text) {
|
|
850
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
851
|
+
const tmp = `${path}.tmp-${Date.now()}`;
|
|
852
|
+
writeFileSync(tmp, text, 'utf8');
|
|
853
|
+
renameSync(tmp, path);
|
|
854
|
+
}
|
|
855
|
+
/** 只写 memory 段的指定键(endpoint/mode/api_key,仅写提供的键)与 apps[].plugins 显式引用;值未变跳过不重写。
|
|
856
|
+
* exported 便于单测直接验证(YAML AST 保注释 / 幂等重写行为) */
|
|
857
|
+
export function writeBackConfig(configPath, wb, pluginPath, opts = {}) {
|
|
858
|
+
const raw = readFileSync(configPath, 'utf8');
|
|
859
|
+
const doc = parseDocument(raw);
|
|
860
|
+
let changed = false;
|
|
861
|
+
const mem = doc.get('memory');
|
|
862
|
+
const ensureSet = (node, key, val) => {
|
|
863
|
+
if (val !== undefined && JSON.stringify(node.get(key)) !== JSON.stringify(val)) {
|
|
864
|
+
node.set(key, val);
|
|
865
|
+
changed = true;
|
|
866
|
+
}
|
|
867
|
+
};
|
|
868
|
+
if (mem && typeof mem === 'object') {
|
|
869
|
+
ensureSet(mem, 'endpoint', wb.endpoint);
|
|
870
|
+
ensureSet(mem, 'mode', wb.mode);
|
|
871
|
+
if (wb.apiKey)
|
|
872
|
+
ensureSet(mem, 'api_key', wb.apiKey);
|
|
873
|
+
}
|
|
874
|
+
else if (wb.endpoint) {
|
|
875
|
+
doc.set('memory', { enabled: true, mode: wb.mode ?? 'embedded', endpoint: wb.endpoint, ...(wb.apiKey ? { api_key: wb.apiKey } : {}) });
|
|
876
|
+
changed = true;
|
|
877
|
+
}
|
|
878
|
+
if (pluginPath) {
|
|
879
|
+
// YAMLSeq 非原生 Array(Array.isArray 恒 false)——统一经 .items 访问成员;追加节点用
|
|
880
|
+
// doc.createNode 包装(官方 API,普通对象 → YAML 节点),避免裸对象进 AST 的序列化歧义
|
|
881
|
+
const apps = doc.get('apps');
|
|
882
|
+
for (const app of apps?.items ?? []) {
|
|
883
|
+
const appNode = app;
|
|
884
|
+
if (!appNode || typeof appNode.get !== 'function')
|
|
885
|
+
continue;
|
|
886
|
+
const plugins = appNode.get('plugins');
|
|
887
|
+
// 同名条目就地更新而非整跳过(实测缺陷:CLI 卸载后旧引用指向已失效路径,按名去重
|
|
888
|
+
// 恒跳过 → 修复装好的新副本永远接不进配置):路径一致 = 幂等不写;不同 = 改指新路径
|
|
889
|
+
const existing = (plugins?.items ?? []).find((p) => p?.get?.('name') === MEMORY_PLUGIN_NAME);
|
|
890
|
+
if (existing) {
|
|
891
|
+
const cur = existing.get('path');
|
|
892
|
+
if (cur === pluginPath)
|
|
893
|
+
continue;
|
|
894
|
+
if (opts.reconcileOnly) {
|
|
895
|
+
// 调和口径:旧路径属产物域(cache/自管根)或已不存在/为空 → 改指;其余(用户显式
|
|
896
|
+
// 指的 dev 源码目录等)不动——CLI 安装后运行时经发现渠道取新版,显式 dev 引用照旧生效
|
|
897
|
+
const curStr = typeof cur === 'string' ? cur : '';
|
|
898
|
+
const artifact = !curStr || !existsSync(curStr)
|
|
899
|
+
|| (opts.artifactRoots ?? []).some((root) => curStr.startsWith(root));
|
|
900
|
+
if (!artifact)
|
|
901
|
+
continue;
|
|
902
|
+
}
|
|
903
|
+
existing.set('path', pluginPath);
|
|
904
|
+
changed = true;
|
|
905
|
+
continue;
|
|
906
|
+
}
|
|
907
|
+
if (opts.reconcileOnly)
|
|
908
|
+
continue; // 调和模式不新增引用(发现渠道已覆盖,新增反而钉死版本路径)
|
|
909
|
+
if (plugins && Array.isArray(plugins.items)) {
|
|
910
|
+
plugins.items.push(doc.createNode({ name: MEMORY_PLUGIN_NAME, path: pluginPath }));
|
|
911
|
+
}
|
|
912
|
+
else {
|
|
913
|
+
appNode.set('plugins', doc.createNode([{ name: MEMORY_PLUGIN_NAME, path: pluginPath }]));
|
|
914
|
+
}
|
|
915
|
+
changed = true;
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
if (!changed)
|
|
919
|
+
return;
|
|
920
|
+
writeAtomic(configPath, doc.toString({ lineWidth: 0 }));
|
|
921
|
+
}
|
|
922
|
+
// ============ 主流水线 ============
|
|
923
|
+
/** 读 config.yaml 的 memory.account(懒加载;损坏配置降级 undefined → 渲染取缺省 'lcb')。
|
|
924
|
+
* 两条流水线原本不读 config.yaml(只有 configPath),stepPlugin 落 ovcli.conf 时需要它 */
|
|
925
|
+
async function memoryAccount(configPath) {
|
|
926
|
+
try {
|
|
927
|
+
const { loadConfig } = await import('./config.js');
|
|
928
|
+
return loadConfig(configPath).memory?.account;
|
|
929
|
+
}
|
|
930
|
+
catch {
|
|
931
|
+
return undefined;
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
/**
|
|
935
|
+
* 一键安装流水线(幂等锁:running 中重复调用直接返回当前状态)。
|
|
936
|
+
* 任何一步失败即停(状态留 failed + detail),页面可整链重跑——已就绪步骤自动 skipped。
|
|
937
|
+
*/
|
|
938
|
+
export async function runInstall(opts) {
|
|
939
|
+
if (installState.running)
|
|
940
|
+
return installState;
|
|
941
|
+
installState = { kind: 'install', running: true, startedAt: new Date().toISOString(), steps: FRESH_STEPS() };
|
|
942
|
+
const steps = installState.steps;
|
|
943
|
+
const set = (id, status, detail) => {
|
|
944
|
+
const s = steps.find((x) => x.id === id);
|
|
945
|
+
s.status = status;
|
|
946
|
+
if (detail !== undefined)
|
|
947
|
+
s.detail = detail;
|
|
948
|
+
};
|
|
949
|
+
try {
|
|
950
|
+
// 首装生成的 root_api_key(conf 步骤产出,⑥ 回写消费;重装/沿用场景为 undefined)
|
|
951
|
+
let firstInstallKey;
|
|
952
|
+
// ① uv
|
|
953
|
+
set('runtime', 'running');
|
|
954
|
+
let uv = await detectUv();
|
|
955
|
+
if (uv)
|
|
956
|
+
set('runtime', 'skipped', `已检测到 uv:${uv === 'uv' ? 'PATH' : uv}`);
|
|
957
|
+
else {
|
|
958
|
+
uv = await installUv();
|
|
959
|
+
set('runtime', 'done', `已安装到 ${uv}`);
|
|
960
|
+
}
|
|
961
|
+
// ② 服务
|
|
962
|
+
set('service', 'running');
|
|
963
|
+
const svc = await stepService(uv, opts.pin ?? DEFAULT_OPENVIKING_PIN);
|
|
964
|
+
set('service', svc.skipped ? 'skipped' : 'done', svc.detail);
|
|
965
|
+
// ③ ov.conf(模型缺失 + conf 已存在 = 重跑:沿用现值;端口沿用已分配值,避免每次重跑漂移)
|
|
966
|
+
set('config', 'running');
|
|
967
|
+
// 首装前置校验(实测教训:空模型 conf 会让服务启动即崩、反复重启后才暴露——在生成配置
|
|
968
|
+
// 这一步就把缺件挡下并给出指引,而不是让坏 conf 落盘)。conf 已存在(重跑/升级)则放行:
|
|
969
|
+
// 现值来自上次有效安装
|
|
970
|
+
const confExists = existsSync(MANAGED_OV_CONF);
|
|
971
|
+
const m = opts.model;
|
|
972
|
+
if (!confExists && (!m || !m.apiBase || !m.vlmModel || !m.embeddingModel)) {
|
|
973
|
+
throw new Error('首装需要记忆模型三件套:api_base / vlm_model(提炼)/ embedding_model(向量)——请先在「记忆模型配置」卡填表并保存(可先 [测试连接] 验证),再点一键开启');
|
|
974
|
+
}
|
|
975
|
+
if (confExists && !m) {
|
|
976
|
+
// 重跑且未传模型:沿用现有 conf 原文(不重渲染——重渲染空模型会生成坏 conf 且更换 root_api_key)
|
|
977
|
+
set('config', 'skipped', `沿用现有配置:${MANAGED_OV_CONF}`);
|
|
978
|
+
}
|
|
979
|
+
else {
|
|
980
|
+
const model = m;
|
|
981
|
+
const prevRootKey = existingRootKey();
|
|
982
|
+
const prevPort = (() => { try {
|
|
983
|
+
return JSON.parse(readFileSync(MANAGED_OV_CONF, 'utf8')).server?.port;
|
|
984
|
+
}
|
|
985
|
+
catch {
|
|
986
|
+
return undefined;
|
|
987
|
+
} })();
|
|
988
|
+
const port = prevPort ?? await findFreePort();
|
|
989
|
+
const { conf, rootApiKey } = renderOvConf(model, {
|
|
990
|
+
workspaceDir: join(MEMORY_MANAGED_DIR, 'data'),
|
|
991
|
+
port,
|
|
992
|
+
rootApiKey: prevRootKey,
|
|
993
|
+
});
|
|
994
|
+
mkdirSync(MEMORY_MANAGED_DIR, { recursive: true });
|
|
995
|
+
mkdirSync(join(MEMORY_MANAGED_DIR, 'data'), { recursive: true });
|
|
996
|
+
writeFileSync(MANAGED_OV_CONF, JSON.stringify(conf, null, 2), 'utf8');
|
|
997
|
+
// 首装(此前无 root_api_key)→ 本次生成的 key 需回写 memory.api_key;重装沿用则不写
|
|
998
|
+
firstInstallKey = prevRootKey ? undefined : rootApiKey;
|
|
999
|
+
set('config', 'done', `配置已生成:${MANAGED_OV_CONF}(端口 ${port})`);
|
|
1000
|
+
}
|
|
1001
|
+
// ④ 拉起
|
|
1002
|
+
set('start', 'running');
|
|
1003
|
+
const st = await stepStart(svc.bin, MANAGED_OV_CONF);
|
|
1004
|
+
set('start', st.skipped ? 'skipped' : 'done', st.detail);
|
|
1005
|
+
// ⑤ 插件(client 传 endpoint/account:顺带落客户端接入配置 ovcli.conf,§6.7 第 4 步)
|
|
1006
|
+
set('plugin', 'running');
|
|
1007
|
+
const pl = await stepPlugin({
|
|
1008
|
+
endpoint: `http://127.0.0.1:${st.port}`,
|
|
1009
|
+
account: await memoryAccount(opts.configPath),
|
|
1010
|
+
});
|
|
1011
|
+
set('plugin', pl.skipped ? 'skipped' : 'done', pl.detail);
|
|
1012
|
+
// ⑥ 回写 config.yaml——全链成功才落盘(半成品不污染用户配置):endpoint / mode=embedded /
|
|
1013
|
+
// 首装生成的 root_api_key 回写 memory.api_key(复用已有 key 不写)+ 插件引用回写(CLI 渠道为
|
|
1014
|
+
// 调和模式、zip 兜底为补齐模式,选项由 stepPlugin 携带)
|
|
1015
|
+
writeBackConfig(opts.configPath, {
|
|
1016
|
+
endpoint: `http://127.0.0.1:${st.port}`,
|
|
1017
|
+
mode: 'embedded',
|
|
1018
|
+
...(firstInstallKey ? { apiKey: firstInstallKey } : {}),
|
|
1019
|
+
}, pl.writeback?.pluginPath, pl.writeback?.opts);
|
|
1020
|
+
installState.running = false;
|
|
1021
|
+
installState.finishedAt = new Date().toISOString();
|
|
1022
|
+
return installState;
|
|
1023
|
+
}
|
|
1024
|
+
catch (e) {
|
|
1025
|
+
installState.running = false;
|
|
1026
|
+
installState.finishedAt = new Date().toISOString();
|
|
1027
|
+
const failed = steps.find((x) => x.status === 'running');
|
|
1028
|
+
if (failed) {
|
|
1029
|
+
failed.status = 'failed';
|
|
1030
|
+
failed.detail = e instanceof Error ? e.message : String(e);
|
|
1031
|
+
}
|
|
1032
|
+
return installState;
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
//# sourceMappingURL=memory-installer.js.map
|