cc-viewer 1.6.293 → 1.6.295
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/cli.js +7 -2
- package/dist/assets/App-Br-u2TKk.js +2 -0
- package/dist/assets/App-eFrjLzF_.css +1 -0
- package/dist/assets/{MdxEditorPanel-Cf01KF6Z.js → MdxEditorPanel-Cy4egsQx.js} +1 -1
- package/dist/assets/{Mobile-BJlGkvAP.js → Mobile-ZHF74GQs.js} +1 -1
- package/dist/assets/{_baseUniq-CPUnJ5bQ.js → _baseUniq-r3p3rodd.js} +1 -1
- package/dist/assets/{arc-WhuJ-oY5.js → arc-CjTV5gxc.js} +1 -1
- package/dist/assets/{architectureDiagram-Q4EWVU46-CWx77Yhd.js → architectureDiagram-Q4EWVU46-BqzjXpCq.js} +1 -1
- package/dist/assets/{blockDiagram-DXYQGD6D-D7AQLCoj.js → blockDiagram-DXYQGD6D-CLyFfeHh.js} +1 -1
- package/dist/assets/{c4Diagram-AHTNJAMY-BoPHNqCF.js → c4Diagram-AHTNJAMY-BaO-0tuc.js} +1 -1
- package/dist/assets/{channel-B9Ja6Xkc.js → channel-yOyhvOLV.js} +1 -1
- package/dist/assets/{chunk-4BX2VUAB-B-b0RYab.js → chunk-4BX2VUAB-CMTnvZkS.js} +1 -1
- package/dist/assets/{chunk-4TB4RGXK-BK_V34yf.js → chunk-4TB4RGXK-QI41m9WP.js} +1 -1
- package/dist/assets/{chunk-55IACEB6-D-kMbu-2.js → chunk-55IACEB6-C4ZO8bM3.js} +1 -1
- package/dist/assets/{chunk-EDXVE4YY-CEtSkZzd.js → chunk-EDXVE4YY-Bo8P4o65.js} +1 -1
- package/dist/assets/{chunk-FMBD7UC4-BXa_7Pn3.js → chunk-FMBD7UC4-CTHLGcHh.js} +1 -1
- package/dist/assets/{chunk-OYMX7WX6-tvM_OApS.js → chunk-OYMX7WX6-D0OHxKGd.js} +1 -1
- package/dist/assets/{chunk-QZHKN3VN-DrEmcVHf.js → chunk-QZHKN3VN-CoYnjUpS.js} +1 -1
- package/dist/assets/{chunk-YZCP3GAM-D2M9T_R5.js → chunk-YZCP3GAM-BY71mTXM.js} +1 -1
- package/dist/assets/classDiagram-6PBFFD2Q-C9o5ip5q.js +1 -0
- package/dist/assets/classDiagram-v2-HSJHXN6E-C9o5ip5q.js +1 -0
- package/dist/assets/clone-GDqN3kwT.js +1 -0
- package/dist/assets/{cose-bilkent-S5V4N54A-H7bkwu5F.js → cose-bilkent-S5V4N54A-DUNsA_MT.js} +1 -1
- package/dist/assets/{dagre-KV5264BT-DKXEGN18.js → dagre-KV5264BT-BzlT2Exr.js} +1 -1
- package/dist/assets/{diagram-5BDNPKRD-DZFhwpI3.js → diagram-5BDNPKRD-CiqQK3Ci.js} +1 -1
- package/dist/assets/{diagram-G4DWMVQ6-Crg9GlIk.js → diagram-G4DWMVQ6-BciK18tQ.js} +1 -1
- package/dist/assets/{diagram-MMDJMWI5-B8Qn1fKP.js → diagram-MMDJMWI5-C1WH1vfU.js} +1 -1
- package/dist/assets/{diagram-TYMM5635-BHE1LjtY.js → diagram-TYMM5635-CR5RzJ6u.js} +1 -1
- package/dist/assets/{erDiagram-SMLLAGMA-BaEqFWLd.js → erDiagram-SMLLAGMA-NJQKXu51.js} +1 -1
- package/dist/assets/{flowDiagram-DWJPFMVM-b2ukTawV.js → flowDiagram-DWJPFMVM-Cjx5t_1H.js} +1 -1
- package/dist/assets/{ganttDiagram-T4ZO3ILL-D5quyFgK.js → ganttDiagram-T4ZO3ILL-YFTDBBiU.js} +1 -1
- package/dist/assets/{gitGraphDiagram-UUTBAWPF-BE1H5_fN.js → gitGraphDiagram-UUTBAWPF-C2muKahz.js} +1 -1
- package/dist/assets/{graph-D_JLoOax.js → graph-I1olozIg.js} +1 -1
- package/dist/assets/{index-Cx8bk0Tp.js → index-7vxIrUNA.js} +1 -1
- package/dist/assets/{index-BDUs32pN.css → index-Be9T-kDq.css} +1 -1
- package/dist/assets/{index-CtrY6gFZ.js → index-C1RNAzAB.js} +1 -1
- package/dist/assets/{index-CQrdpZQb.js → index-Cf4FBg-V.js} +1 -1
- package/dist/assets/{index-B8UmlA4F.js → index-D-HPuqxB.js} +1 -1
- package/dist/assets/{index-k0AH8cvI.js → index-D2QUxu18.js} +1 -1
- package/dist/assets/index-DMuCrfTo.js +2 -0
- package/dist/assets/{index-DiZ9CErG.js → index-DhzoJ5wE.js} +1 -1
- package/dist/assets/{index-CWjqMDrs.js → index-fhI0i2p3.js} +1 -1
- package/dist/assets/{infoDiagram-42DDH7IO-DQKlrVkw.js → infoDiagram-42DDH7IO-C9bza97c.js} +1 -1
- package/dist/assets/{ishikawaDiagram-UXIWVN3A-BchFlpPc.js → ishikawaDiagram-UXIWVN3A-BtZGipfW.js} +1 -1
- package/dist/assets/{journeyDiagram-VCZTEJTY-Dg1mt4df.js → journeyDiagram-VCZTEJTY-CKTp590c.js} +1 -1
- package/dist/assets/{jszip.min-LIb2SFoK.js → jszip.min-DDU-_oA-.js} +1 -1
- package/dist/assets/{kanban-definition-6JOO6SKY-226va2PS.js → kanban-definition-6JOO6SKY-BHLNWfr5.js} +1 -1
- package/dist/assets/{layout-rSa8rcPi.js → layout-DBmqcl9N.js} +1 -1
- package/dist/assets/{linear-BeARi8nH.js → linear-Br9n7mCI.js} +1 -1
- package/dist/assets/{mermaid.core-CDgdx9l7.js → mermaid.core-BV3ugHFm.js} +2 -2
- package/dist/assets/{min-B9yebCuj.js → min-D-YA3MGY.js} +1 -1
- package/dist/assets/{mindmap-definition-QFDTVHPH-C3apVbdg.js → mindmap-definition-QFDTVHPH-CzrYj3cB.js} +1 -1
- package/dist/assets/{pieDiagram-DEJITSTG-xjOQoQeL.js → pieDiagram-DEJITSTG-BAvtfiT3.js} +1 -1
- package/dist/assets/{quadrantDiagram-34T5L4WZ-Dq8x_VN2.js → quadrantDiagram-34T5L4WZ-i4zhnBJq.js} +1 -1
- package/dist/assets/{requirementDiagram-MS252O5E-CLmO1Gai.js → requirementDiagram-MS252O5E-Cb2wX9Sk.js} +1 -1
- package/dist/assets/{sankeyDiagram-XADWPNL6-BuUP1Eqq.js → sankeyDiagram-XADWPNL6-CcpbP6z5.js} +1 -1
- package/dist/assets/seqResourceLoaders-C7X23dCJ.js +2 -0
- package/dist/assets/{seqResourceLoaders-DWKAvGtj.css → seqResourceLoaders-De_-fYhE.css} +2 -2
- package/dist/assets/{sequenceDiagram-FGHM5R23-B18koU20.js → sequenceDiagram-FGHM5R23-BcbUxMmI.js} +1 -1
- package/dist/assets/{stateDiagram-FHFEXIEX-Cj57OCcO.js → stateDiagram-FHFEXIEX-CpIa1qoO.js} +1 -1
- package/dist/assets/{stateDiagram-v2-QKLJ7IA2-C01a2p--.js → stateDiagram-v2-QKLJ7IA2-d3GoyW9S.js} +1 -1
- package/dist/assets/{timeline-definition-GMOUNBTQ-cOlsEN_F.js → timeline-definition-GMOUNBTQ-BfQPSOuT.js} +1 -1
- package/dist/assets/{vendor-antd-DqFS7Zj9.js → vendor-antd-Bur5ZxWE.js} +1 -1
- package/dist/assets/{vendor-codemirror-B_pF4DrA.js → vendor-codemirror-Si44UqBp.js} +1 -1
- package/dist/assets/{vendor-mdxeditor-B_IrHcWH.js → vendor-mdxeditor-Cco3AQJS.js} +2 -2
- package/dist/assets/{vendor-qrcode-C4PneAS5.js → vendor-qrcode-Dn3GYC4l.js} +1 -1
- package/dist/assets/{vendor-virtuoso-CEGeJyDP.js → vendor-virtuoso-CW9EqKMt.js} +1 -1
- package/dist/assets/{vennDiagram-DHZGUBPP-BCjdwiDk.js → vennDiagram-DHZGUBPP-hTgiYDQL.js} +1 -1
- package/dist/assets/{wardley-RL74JXVD-CRmLlBwn.js → wardley-RL74JXVD-ByDpAPp1.js} +1 -1
- package/dist/assets/{wardleyDiagram-NUSXRM2D-BJYVDJ4F.js → wardleyDiagram-NUSXRM2D-D7LJTuWq.js} +1 -1
- package/dist/assets/{xychartDiagram-5P7HB3ND-el5C4S1Z.js → xychartDiagram-5P7HB3ND-MW_KOomO.js} +1 -1
- package/dist/index.html +5 -5
- package/findcc.js +3 -3
- package/package.json +1 -1
- package/server/i18n.js +224 -8
- package/server/interceptor.js +21 -18
- package/server/lib/adapters/dingtalk-adapter.js +69 -0
- package/server/lib/adapters/discord-adapter.js +44 -1
- package/server/lib/adapters/feishu-adapter.js +56 -0
- package/server/lib/adapters/wecom-adapter.js +4 -0
- package/server/lib/ask-store.js +19 -90
- package/server/lib/async-file-lock.js +123 -0
- package/server/lib/async-write-queue.js +131 -0
- package/server/lib/git-diff.js +4 -1
- package/server/lib/im-bridge-core.js +178 -21
- package/server/lib/im-claude-md.js +37 -1
- package/server/lib/im-config.js +11 -6
- package/server/lib/im-process-manager.js +1 -1
- package/server/lib/im-senders.js +73 -0
- package/server/lib/jsonl-archive.js +0 -1
- package/server/lib/log-watcher.js +224 -177
- package/server/lib/plugin-manager.js +1 -1
- package/server/lib/updater.js +4 -2
- package/server/pty-manager.js +1 -1
- package/server/routes/ask-perm.js +2 -2
- package/server/routes/dingtalk.js +2 -0
- package/server/routes/files-fs.js +4 -4
- package/server/routes/im.js +117 -3
- package/server/routes/project-meta.js +18 -1
- package/server/routes/skills.js +180 -165
- package/server/routes/workspaces.js +7 -10
- package/server/server.js +23 -20
- package/server/workspace-registry.js +9 -53
- package/dist/assets/App-DRvRd96X.css +0 -1
- package/dist/assets/App-OM2oqZRW.js +0 -1
- package/dist/assets/classDiagram-6PBFFD2Q-CCwGJXEA.js +0 -1
- package/dist/assets/classDiagram-v2-HSJHXN6E-CCwGJXEA.js +0 -1
- package/dist/assets/clone-BuQbTPQO.js +0 -1
- package/dist/assets/index-CnWSVlWW.js +0 -2
- package/dist/assets/seqResourceLoaders-BZ6M3Jb-.js +0 -2
package/server/routes/im.js
CHANGED
|
@@ -15,11 +15,16 @@
|
|
|
15
15
|
// reports its own in-process adapter (deps.im.getBridgeStatus) — that's what the manager probes.
|
|
16
16
|
import { getDescriptor, loadConfig, loadState, saveConfig } from '../lib/im-config.js';
|
|
17
17
|
import { findRecentLog } from '../lib/interceptor-core.js';
|
|
18
|
+
import { readSenders } from '../lib/im-senders.js';
|
|
19
|
+
import { readImClaudeMd, writeImClaudeMd, MAX_CLAUDE_MD_CHARS } from '../lib/im-claude-md.js';
|
|
20
|
+
import { imDir } from '../lib/im-lock.js';
|
|
21
|
+
import { listSkills, moveSkill } from '../lib/skills-api.js';
|
|
22
|
+
import { importSkillTo } from './skills.js';
|
|
18
23
|
import { LOG_DIR } from '../../findcc.js';
|
|
19
24
|
import { join, basename } from 'node:path';
|
|
20
25
|
|
|
21
26
|
const JSON_HEADERS = { 'Content-Type': 'application/json' };
|
|
22
|
-
const IM_RE = /^\/api\/im\/([a-z0-9_-]+)\/(status|config|test|process|logs)$/;
|
|
27
|
+
const IM_RE = /^\/api\/im\/([a-z0-9_-]+)\/(status|config|test|process|logs|senders|claude-md|skills|skills\/toggle|skills\/import)$/;
|
|
23
28
|
|
|
24
29
|
/** Resolve a known platform id from the URL, or null (→ 404) for an unknown one. */
|
|
25
30
|
function platformOf(url) {
|
|
@@ -126,10 +131,15 @@ function imConfigPost(req, res, parsedUrl, isLocal, deps) {
|
|
|
126
131
|
}
|
|
127
132
|
}
|
|
128
133
|
const saved = saveConfig(id, incoming);
|
|
134
|
+
// applyProcess(默认 true,保持旧调用方语义):前端 onBlur 自动保存传 false → 仅存盘、不驱动进程,
|
|
135
|
+
// 否则每次输入框失焦都会重启 worker。显式「启动/停止」按钮则不传(=true),沿用下述驱动逻辑。
|
|
136
|
+
// 注:applyProcess 是未知字段,saveConfig/normalize 不会把它写盘。
|
|
129
137
|
// 驱动进程管理器(替代旧的在进程 reloadBridge):启用→重启 worker(吸收新凭证),停用→停 worker。
|
|
130
138
|
try {
|
|
131
|
-
if (
|
|
132
|
-
|
|
139
|
+
if (incoming.applyProcess !== false) {
|
|
140
|
+
if (saved.enabled) await deps.im.restartProcess(id);
|
|
141
|
+
else await deps.im.stopProcess(id);
|
|
142
|
+
}
|
|
133
143
|
} catch (e) {
|
|
134
144
|
// 进程操作失败不应阻塞配置保存的响应,但必须记录——否则 worker 起不来时用户看到乐观的
|
|
135
145
|
// running:true 却毫无线索(spawn 失败 / EACCES on process.out.log 等)。
|
|
@@ -209,10 +219,114 @@ function imLogs(req, res, parsedUrl, isLocal, deps) {
|
|
|
209
219
|
res.end(JSON.stringify({ project, latest }));
|
|
210
220
|
}
|
|
211
221
|
|
|
222
|
+
// 发送者身份映射(senderId → {name, avatar, ts}):供「对话记录」按 senderId 显示真实姓名+头像。
|
|
223
|
+
// loopback-only:姓名/头像属个人信息,不向局域网暴露(与 config/test/process 同级)。
|
|
224
|
+
function imSenders(req, res, parsedUrl, isLocal, deps) {
|
|
225
|
+
const id = platformOf(parsedUrl.pathname);
|
|
226
|
+
if (!id) { notFound(res); return; }
|
|
227
|
+
if (!isLocal) { loopbackOnly(res); return; }
|
|
228
|
+
res.writeHead(200, JSON_HEADERS);
|
|
229
|
+
res.end(JSON.stringify({ platform: id, senders: readSenders(id) }));
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// 「模型性格定义」= 该 IM worker 工作目录下的 CLAUDE.md。loopback-only:本地文件内容、admin-only。
|
|
233
|
+
// CLAUDE.md 仅在 worker 启动时读取一次,故保存后需重启该 IM worker 才生效(前端据此提示用户)。
|
|
234
|
+
function imClaudeMdGet(req, res, parsedUrl, isLocal, deps) {
|
|
235
|
+
const id = platformOf(parsedUrl.pathname);
|
|
236
|
+
if (!id) { notFound(res); return; }
|
|
237
|
+
if (!isLocal) { loopbackOnly(res); return; }
|
|
238
|
+
try {
|
|
239
|
+
res.writeHead(200, JSON_HEADERS);
|
|
240
|
+
res.end(JSON.stringify({ platform: id, content: readImClaudeMd(id) }));
|
|
241
|
+
} catch (e) {
|
|
242
|
+
res.writeHead(500, JSON_HEADERS);
|
|
243
|
+
res.end(JSON.stringify({ error: String(e?.message || e) }));
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function imClaudeMdPost(req, res, parsedUrl, isLocal, deps) {
|
|
248
|
+
const id = platformOf(parsedUrl.pathname);
|
|
249
|
+
if (!id) { notFound(res); return; }
|
|
250
|
+
if (!isLocal) { loopbackOnly(res); return; }
|
|
251
|
+
readBody(req, deps, (body) => {
|
|
252
|
+
let incoming;
|
|
253
|
+
try { incoming = JSON.parse(body); }
|
|
254
|
+
catch { res.writeHead(400, JSON_HEADERS); res.end(JSON.stringify({ error: 'Invalid JSON' })); return; }
|
|
255
|
+
if (typeof incoming.content !== 'string') {
|
|
256
|
+
res.writeHead(400, JSON_HEADERS); res.end(JSON.stringify({ error: 'content must be a string' })); return;
|
|
257
|
+
}
|
|
258
|
+
if (incoming.content.length > MAX_CLAUDE_MD_CHARS) {
|
|
259
|
+
res.writeHead(413, JSON_HEADERS); res.end(JSON.stringify({ error: 'content too large' })); return;
|
|
260
|
+
}
|
|
261
|
+
try {
|
|
262
|
+
writeImClaudeMd(id, incoming.content);
|
|
263
|
+
res.writeHead(200, JSON_HEADERS);
|
|
264
|
+
res.end(JSON.stringify({ ok: true, platform: id }));
|
|
265
|
+
} catch (e) {
|
|
266
|
+
res.writeHead(500, JSON_HEADERS);
|
|
267
|
+
res.end(JSON.stringify({ error: String(e?.message || e) }));
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// 「${IM} SKILL 管理」= 该 IM worker 工作目录下的 .claude/skills/。loopback-only(本地文件操作、admin-only)。
|
|
273
|
+
// 复用 skills-api 的 listSkills/moveSkill(按 projectDir 参数化)+ skills.js 的 importSkillTo(按 skillsRoot 参数化)。
|
|
274
|
+
// IM worker 仅在启动时读取 skills,故增删/启停后需重启该 IM worker 才生效(前端提示用户)。
|
|
275
|
+
function imSkills(req, res, parsedUrl, isLocal, deps) {
|
|
276
|
+
const id = platformOf(parsedUrl.pathname);
|
|
277
|
+
if (!id) { notFound(res); return; }
|
|
278
|
+
if (!isLocal) { loopbackOnly(res); return; }
|
|
279
|
+
try {
|
|
280
|
+
const dir = imDir(id);
|
|
281
|
+
// projectDir 与 homeDir 都指向 IM 目录:两次扫描命中同一 .claude/skills(user+project 重复),
|
|
282
|
+
// 过滤 source==='project' 去重并排除 plugin/builtin → 恰好是该 IM 自己的 skills(+skills-skip)。
|
|
283
|
+
const skills = listSkills({ projectDir: dir, homeDir: dir }).filter((s) => s.source === 'project');
|
|
284
|
+
res.writeHead(200, JSON_HEADERS);
|
|
285
|
+
res.end(JSON.stringify({ platform: id, skills }));
|
|
286
|
+
} catch (e) {
|
|
287
|
+
res.writeHead(500, JSON_HEADERS);
|
|
288
|
+
res.end(JSON.stringify({ error: String(e?.message || e) }));
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function imSkillsToggle(req, res, parsedUrl, isLocal, deps) {
|
|
293
|
+
const id = platformOf(parsedUrl.pathname);
|
|
294
|
+
if (!id) { notFound(res); return; }
|
|
295
|
+
if (!isLocal) { loopbackOnly(res); return; }
|
|
296
|
+
readBody(req, deps, (body) => {
|
|
297
|
+
let incoming;
|
|
298
|
+
try { incoming = JSON.parse(body); }
|
|
299
|
+
catch { res.writeHead(400, JSON_HEADERS); res.end(JSON.stringify({ error: 'Invalid JSON' })); return; }
|
|
300
|
+
try {
|
|
301
|
+
moveSkill({ source: 'project', name: incoming.name, enable: !!incoming.enable, projectDir: imDir(id) });
|
|
302
|
+
res.writeHead(200, JSON_HEADERS);
|
|
303
|
+
res.end(JSON.stringify({ ok: true }));
|
|
304
|
+
} catch (err) {
|
|
305
|
+
const statusMap = { INVALID_NAME: 400, INVALID_SOURCE: 400, PATH_ESCAPE: 400, SYMLINK: 400, SOURCE_MISSING: 404, DEST_CONFLICT: 409 };
|
|
306
|
+
const status = statusMap[err?.code] || 500;
|
|
307
|
+
res.writeHead(status, JSON_HEADERS);
|
|
308
|
+
res.end(JSON.stringify({ error: err?.message || 'internal_error', code: err?.code || 'unknown' }));
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function imSkillsImport(req, res, parsedUrl, isLocal, deps) {
|
|
314
|
+
const id = platformOf(parsedUrl.pathname);
|
|
315
|
+
if (!id) { notFound(res); return; }
|
|
316
|
+
if (!isLocal) { loopbackOnly(res); return; }
|
|
317
|
+
importSkillTo(req, res, { skillsRoot: join(imDir(id), '.claude', 'skills'), windowsReserved: deps.WINDOWS_RESERVED_NAMES });
|
|
318
|
+
}
|
|
319
|
+
|
|
212
320
|
export const imRoutes = [
|
|
213
321
|
{ predicate: imPredicate('status', 'GET'), handler: imStatus },
|
|
214
322
|
{ predicate: imPredicate('config', 'POST'), handler: imConfigPost },
|
|
215
323
|
{ predicate: imPredicate('test', 'POST'), handler: imTestPost },
|
|
216
324
|
{ predicate: imPredicate('process', 'POST'), handler: imProcessPost },
|
|
217
325
|
{ predicate: imPredicate('logs', 'GET'), handler: imLogs },
|
|
326
|
+
{ predicate: imPredicate('senders', 'GET'), handler: imSenders },
|
|
327
|
+
{ predicate: imPredicate('claude-md', 'GET'), handler: imClaudeMdGet },
|
|
328
|
+
{ predicate: imPredicate('claude-md', 'POST'), handler: imClaudeMdPost },
|
|
329
|
+
{ predicate: imPredicate('skills', 'GET'), handler: imSkills },
|
|
330
|
+
{ predicate: imPredicate('skills/toggle', 'POST'), handler: imSkillsToggle },
|
|
331
|
+
{ predicate: imPredicate('skills/import', 'POST'), handler: imSkillsImport },
|
|
218
332
|
];
|
|
@@ -4,6 +4,23 @@ import { join } from 'node:path';
|
|
|
4
4
|
import { PACKAGE_JSON } from '../_paths.js';
|
|
5
5
|
import { LOG_DIR } from '../../findcc.js';
|
|
6
6
|
import { _projectName } from '../interceptor.js';
|
|
7
|
+
import { detectHomebrewInstall } from '../lib/updater.js';
|
|
8
|
+
|
|
9
|
+
// 判定当前 cc-viewer 的安装渠道,供前端精准匹配升级命令。
|
|
10
|
+
// - electron:桌面版(in-process server),走 GitHub Releases 重新下载安装包。
|
|
11
|
+
// - brew:Homebrew Cellar 布局命中 → `brew upgrade cc-viewer`(npm install -g 会跟 Cellar 打架)。
|
|
12
|
+
// - npm:默认兜底 → `npm install -g cc-viewer --registry=...`(指定官方源避免镜像滞后拿到旧版本)。
|
|
13
|
+
// deps 仅供单测注入;失败安全:detect 抛异常时回落到 npm(不会误导 brew 用户走 npm)。
|
|
14
|
+
export function getInstallMethod({
|
|
15
|
+
electron = process.versions && process.versions.electron,
|
|
16
|
+
detect = detectHomebrewInstall,
|
|
17
|
+
} = {}) {
|
|
18
|
+
if (electron) return 'electron';
|
|
19
|
+
try {
|
|
20
|
+
if (detect()) return 'brew';
|
|
21
|
+
} catch { /* 探测失败 → 回落 npm */ }
|
|
22
|
+
return 'npm';
|
|
23
|
+
}
|
|
7
24
|
|
|
8
25
|
function projectName(req, res) {
|
|
9
26
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
@@ -20,7 +37,7 @@ function versionInfo(req, res) {
|
|
|
20
37
|
try {
|
|
21
38
|
const pkg = JSON.parse(readFileSync(PACKAGE_JSON, 'utf-8'));
|
|
22
39
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
23
|
-
res.end(JSON.stringify({ version: pkg.version }));
|
|
40
|
+
res.end(JSON.stringify({ version: pkg.version, installMethod: getInstallMethod() }));
|
|
24
41
|
} catch {
|
|
25
42
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
26
43
|
res.end(JSON.stringify({ error: 'Failed to read version' }));
|
package/server/routes/skills.js
CHANGED
|
@@ -44,16 +44,177 @@ function skillsToggle(req, res) {
|
|
|
44
44
|
});
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
// Skill
|
|
48
|
-
//
|
|
49
|
-
// ·
|
|
50
|
-
// ·
|
|
51
|
-
// ·
|
|
52
|
-
// ·
|
|
53
|
-
|
|
54
|
-
|
|
47
|
+
// ─── Skill 上传/导入:可复用核心(供用户级 /api/skills/import 与 IM 级 /api/im/:platform/skills/import 共用)───
|
|
48
|
+
// 设计要点(与原实现一致):
|
|
49
|
+
// · 扩展名白名单只放 zip / md(忽略大小写);其他类型 415;
|
|
50
|
+
// · zip 内必须含 SKILL.md(任意子目录、忽略大小写),取最浅的那个所在目录作为 skill 根;
|
|
51
|
+
// · skill 名优先取 SKILL.md frontmatter 的 name,回落 zip 根目录名 / 文件名(去扩展名);
|
|
52
|
+
// · zip bomb 防护:单文件 ≤50MB,总解压 ≤200MB;拒绝 symlink entry。
|
|
53
|
+
|
|
54
|
+
const parseNameFromMd = (text) => {
|
|
55
|
+
const m = /^---\s*\n([\s\S]*?)\n---/.exec(text);
|
|
56
|
+
if (!m) return null;
|
|
57
|
+
const nm = /^name\s*:\s*(.*)$/m.exec(m[1]);
|
|
58
|
+
if (!nm) return null;
|
|
59
|
+
return nm[1].trim().replace(/^["']|["']$/g, '');
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const fallbackBaseName = (filename, stripExt) => {
|
|
63
|
+
let n = filename.replace(/^.*[\\/]/, '');
|
|
64
|
+
if (stripExt) n = n.replace(/\.[^.]+$/, '');
|
|
65
|
+
return n;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 解析一段 multipart body(已组装好的 Buffer)→ { skillName, files: [{relPath, data}] }。
|
|
70
|
+
* 抛 { status, code } 形式的错误(与原 skillsImport 一致),由调用方映射 HTTP。
|
|
71
|
+
*/
|
|
72
|
+
export async function parseSkillUpload(buf, boundary, windowsReservedRe) {
|
|
73
|
+
const headerEnd = buf.indexOf('\r\n\r\n');
|
|
74
|
+
if (headerEnd === -1) throw Object.assign(new Error('Malformed multipart'), { status: 400 });
|
|
75
|
+
const headerStr = buf.slice(0, headerEnd).toString();
|
|
76
|
+
const nameMatch = headerStr.match(/filename="([^"]+)"/);
|
|
77
|
+
if (!nameMatch) throw Object.assign(new Error('No filename'), { status: 400 });
|
|
78
|
+
// NFKC 规范化 + 控制字符/路径分隔符过滤 + 零宽和方向覆盖字符过滤(防止 homoglyph / RLO 混淆)
|
|
79
|
+
const originalName = nameMatch[1]
|
|
80
|
+
.normalize('NFKC')
|
|
81
|
+
.replace(/[\x00-\x1f/\\]/g, '_')
|
|
82
|
+
.replace(/[--]/g, '');
|
|
83
|
+
// Windows 保留设备名守卫。
|
|
84
|
+
{
|
|
85
|
+
const base = originalName.split('.')[0].trim().toLowerCase();
|
|
86
|
+
if (windowsReservedRe && windowsReservedRe.test(base)) {
|
|
87
|
+
throw Object.assign(new Error('Reserved filename not allowed'), { status: 400 });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const lower = originalName.toLowerCase();
|
|
91
|
+
const isZip = lower.endsWith('.zip');
|
|
92
|
+
const isMd = lower.endsWith('.md');
|
|
93
|
+
if (!isZip && !isMd) {
|
|
94
|
+
throw Object.assign(new Error('Unsupported file type'), { status: 415, code: 'INVALID_TYPE' });
|
|
95
|
+
}
|
|
96
|
+
const bodyStart = headerEnd + 4;
|
|
97
|
+
const closingBoundary = Buffer.from('\r\n--' + boundary);
|
|
98
|
+
const bodyEnd = buf.indexOf(closingBoundary, bodyStart);
|
|
99
|
+
const fileData = bodyEnd !== -1 ? buf.slice(bodyStart, bodyEnd) : buf.slice(bodyStart);
|
|
100
|
+
|
|
101
|
+
let skillName = null;
|
|
102
|
+
let skillFiles = []; // { relPath, data }
|
|
103
|
+
|
|
104
|
+
if (isMd) {
|
|
105
|
+
const text = fileData.toString('utf8');
|
|
106
|
+
skillName = parseNameFromMd(text) || fallbackBaseName(originalName, true);
|
|
107
|
+
skillFiles = [{ relPath: 'SKILL.md', data: fileData }];
|
|
108
|
+
} else {
|
|
109
|
+
const AdmZip = (await import('adm-zip')).default;
|
|
110
|
+
let zip;
|
|
111
|
+
try {
|
|
112
|
+
zip = new AdmZip(fileData);
|
|
113
|
+
} catch {
|
|
114
|
+
throw Object.assign(new Error('Invalid zip archive'), { status: 400, code: 'INVALID_ZIP' });
|
|
115
|
+
}
|
|
116
|
+
const entries = zip.getEntries();
|
|
117
|
+
const MAX_PER_FILE = 50 * 1024 * 1024;
|
|
118
|
+
const MAX_TOTAL_UNCOMPRESSED = 200 * 1024 * 1024;
|
|
119
|
+
let totalUncompressed = 0;
|
|
120
|
+
for (const e of entries) {
|
|
121
|
+
if (e.isDirectory) continue;
|
|
122
|
+
const unixMode = (e.attr >>> 16) & 0xffff;
|
|
123
|
+
if ((unixMode & 0o170000) === 0o120000) {
|
|
124
|
+
throw Object.assign(new Error('Symlinks not allowed in zip'), { status: 400, code: 'INVALID_ZIP' });
|
|
125
|
+
}
|
|
126
|
+
const sizeRaw = e.header?.size || 0;
|
|
127
|
+
if (sizeRaw > MAX_PER_FILE) {
|
|
128
|
+
throw Object.assign(new Error('File too large in archive'), { status: 400, code: 'ZIP_BOMB' });
|
|
129
|
+
}
|
|
130
|
+
totalUncompressed += sizeRaw;
|
|
131
|
+
if (totalUncompressed > MAX_TOTAL_UNCOMPRESSED) {
|
|
132
|
+
throw Object.assign(new Error('Archive expands too large'), { status: 400, code: 'ZIP_BOMB' });
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
let bestSkillEntry = null;
|
|
136
|
+
let bestDepth = Infinity;
|
|
137
|
+
for (const e of entries) {
|
|
138
|
+
if (e.isDirectory) continue;
|
|
139
|
+
const en = e.entryName;
|
|
140
|
+
const base = en.split('/').pop() || '';
|
|
141
|
+
if (base.toLowerCase() === 'skill.md') {
|
|
142
|
+
const depth = en.split('/').length;
|
|
143
|
+
if (depth < bestDepth) { bestDepth = depth; bestSkillEntry = e; }
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (!bestSkillEntry) {
|
|
147
|
+
throw Object.assign(new Error('SKILL.md not found in zip'), { status: 400, code: 'MISSING_SKILL_MD' });
|
|
148
|
+
}
|
|
149
|
+
const lastSlash = bestSkillEntry.entryName.lastIndexOf('/');
|
|
150
|
+
const skillRootPrefix = lastSlash >= 0 ? bestSkillEntry.entryName.slice(0, lastSlash + 1) : '';
|
|
151
|
+
const skillMdText = bestSkillEntry.getData().toString('utf8');
|
|
152
|
+
skillName = parseNameFromMd(skillMdText)
|
|
153
|
+
|| (skillRootPrefix ? skillRootPrefix.replace(/\/$/, '').split('/').pop() : null)
|
|
154
|
+
|| fallbackBaseName(originalName, true);
|
|
155
|
+
|
|
156
|
+
// 二次校验:header.size 来自 zip 中央目录是攻击者可控的(可谎报 size=0),用真实 data.length 复核。
|
|
157
|
+
let actualTotal = 0;
|
|
158
|
+
for (const e of entries) {
|
|
159
|
+
if (e.isDirectory) continue;
|
|
160
|
+
if (skillRootPrefix && !e.entryName.startsWith(skillRootPrefix)) continue;
|
|
161
|
+
const rel = skillRootPrefix ? e.entryName.slice(skillRootPrefix.length) : e.entryName;
|
|
162
|
+
if (!rel || rel.includes('..')) continue;
|
|
163
|
+
const data = e.getData();
|
|
164
|
+
if (data.length > MAX_PER_FILE) {
|
|
165
|
+
throw Object.assign(new Error('File actual size too large'), { status: 400, code: 'ZIP_BOMB' });
|
|
166
|
+
}
|
|
167
|
+
actualTotal += data.length;
|
|
168
|
+
if (actualTotal > MAX_TOTAL_UNCOMPRESSED) {
|
|
169
|
+
throw Object.assign(new Error('Archive actual size too large'), { status: 400, code: 'ZIP_BOMB' });
|
|
170
|
+
}
|
|
171
|
+
const finalRel = rel.split('/').pop().toLowerCase() === 'skill.md'
|
|
172
|
+
? rel.replace(/[^/]*$/, 'SKILL.md')
|
|
173
|
+
: rel;
|
|
174
|
+
skillFiles.push({ relPath: finalRel, data });
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (!validateSkillName(skillName)) {
|
|
179
|
+
throw Object.assign(new Error(`Invalid skill name: ${skillName}`), { status: 400, code: 'INVALID_NAME' });
|
|
180
|
+
}
|
|
181
|
+
return { skillName, files: skillFiles };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* 把解析出的 skill 文件写入 <skillsRoot>/<skillName>/。原子 mkdir(已存在→409 EXISTS)+ 路径包含校验。
|
|
186
|
+
* @returns {string} targetDir
|
|
187
|
+
*/
|
|
188
|
+
export function writeSkillFiles(skillsRoot, skillName, files) {
|
|
189
|
+
mkdirSync(skillsRoot, { recursive: true });
|
|
190
|
+
const targetDir = join(skillsRoot, skillName);
|
|
191
|
+
// 原子创建:不带 recursive 让 mkdir 在已存在时直接抛 EEXIST,消除 TOCTOU 竞争窗口。
|
|
192
|
+
try {
|
|
193
|
+
mkdirSync(targetDir);
|
|
194
|
+
} catch (err) {
|
|
195
|
+
if (err.code === 'EEXIST') {
|
|
196
|
+
throw Object.assign(new Error(`Skill already exists: ${skillName}`), { status: 409, code: 'EXISTS' });
|
|
197
|
+
}
|
|
198
|
+
throw err;
|
|
199
|
+
}
|
|
200
|
+
// 二次防御:resolved + sep 后缀比较,防止 prefix 攻击。
|
|
201
|
+
const resolvedTarget = resolve(targetDir) + sep;
|
|
202
|
+
for (const f of files) {
|
|
203
|
+
const dest = join(targetDir, f.relPath);
|
|
204
|
+
if (!resolve(dest).startsWith(resolvedTarget)) continue;
|
|
205
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
206
|
+
writeFileSync(dest, f.data);
|
|
207
|
+
}
|
|
208
|
+
return targetDir;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* 处理一次 skill 上传请求(流式读 body + 大小守卫 + 解析 + 写入 + 应答)。
|
|
213
|
+
* 用户级与 IM 级 import 共用,差异只在 skillsRoot。
|
|
214
|
+
*/
|
|
215
|
+
export function importSkillTo(req, res, { skillsRoot, windowsReserved }) {
|
|
55
216
|
const contentType = req.headers['content-type'] || '';
|
|
56
|
-
// boundary 用 [^;]+
|
|
217
|
+
// boundary 用 [^;]+ 终止避免吞掉后续参数;长度封顶 200 防止超长串撑爆 buffer 比对。
|
|
57
218
|
const boundaryMatch = contentType.match(/boundary=([^;]+)/);
|
|
58
219
|
if (!boundaryMatch || boundaryMatch[1].length > 200) {
|
|
59
220
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
@@ -85,162 +246,8 @@ function skillsImport(req, res, parsedUrl, isLocal, deps) {
|
|
|
85
246
|
req.on('end', async () => {
|
|
86
247
|
if (aborted) return;
|
|
87
248
|
try {
|
|
88
|
-
const
|
|
89
|
-
const
|
|
90
|
-
if (headerEnd === -1) throw Object.assign(new Error('Malformed multipart'), { status: 400 });
|
|
91
|
-
const headerStr = buf.slice(0, headerEnd).toString();
|
|
92
|
-
const nameMatch = headerStr.match(/filename="([^"]+)"/);
|
|
93
|
-
if (!nameMatch) throw Object.assign(new Error('No filename'), { status: 400 });
|
|
94
|
-
// NFKC 规范化 + 控制字符/路径分隔符过滤 + 零宽和方向覆盖字符过滤(防止 homoglyph / RLO 混淆)
|
|
95
|
-
const originalName = nameMatch[1]
|
|
96
|
-
.normalize('NFKC')
|
|
97
|
-
.replace(/[\x00-\x1f/\\]/g, '_')
|
|
98
|
-
.replace(/[--]/g, '');
|
|
99
|
-
// Windows 保留设备名守卫(见 WINDOWS_RESERVED_NAMES 注释)。
|
|
100
|
-
{
|
|
101
|
-
const base = originalName.split('.')[0].trim().toLowerCase();
|
|
102
|
-
if (deps.WINDOWS_RESERVED_NAMES.test(base)) {
|
|
103
|
-
throw Object.assign(new Error('Reserved filename not allowed'), { status: 400 });
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
const lower = originalName.toLowerCase();
|
|
107
|
-
const isZip = lower.endsWith('.zip');
|
|
108
|
-
const isMd = lower.endsWith('.md');
|
|
109
|
-
if (!isZip && !isMd) {
|
|
110
|
-
throw Object.assign(new Error('Unsupported file type'), { status: 415, code: 'INVALID_TYPE' });
|
|
111
|
-
}
|
|
112
|
-
const bodyStart = headerEnd + 4;
|
|
113
|
-
const closingBoundary = Buffer.from('\r\n--' + boundary);
|
|
114
|
-
const bodyEnd = buf.indexOf(closingBoundary, bodyStart);
|
|
115
|
-
const fileData = bodyEnd !== -1 ? buf.slice(bodyStart, bodyEnd) : buf.slice(bodyStart);
|
|
116
|
-
|
|
117
|
-
const skillsRoot = join(getClaudeConfigDir(), 'skills');
|
|
118
|
-
mkdirSync(skillsRoot, { recursive: true });
|
|
119
|
-
|
|
120
|
-
// 简单 frontmatter name 解析:抓 `name: xxx` 单行(与 skills-api.js 的 description 解析风格保持一致,最小实现)
|
|
121
|
-
const parseNameFromMd = (text) => {
|
|
122
|
-
const m = /^---\s*\n([\s\S]*?)\n---/.exec(text);
|
|
123
|
-
if (!m) return null;
|
|
124
|
-
const nm = /^name\s*:\s*(.*)$/m.exec(m[1]);
|
|
125
|
-
if (!nm) return null;
|
|
126
|
-
return nm[1].trim().replace(/^["']|["']$/g, '');
|
|
127
|
-
};
|
|
128
|
-
|
|
129
|
-
const fallbackBaseName = (filename, stripExt) => {
|
|
130
|
-
let n = filename.replace(/^.*[\\/]/, '');
|
|
131
|
-
if (stripExt) n = n.replace(/\.[^.]+$/, '');
|
|
132
|
-
return n;
|
|
133
|
-
};
|
|
134
|
-
|
|
135
|
-
let skillName = null;
|
|
136
|
-
let skillFiles = []; // { relPath, data }
|
|
137
|
-
|
|
138
|
-
if (isMd) {
|
|
139
|
-
const text = fileData.toString('utf8');
|
|
140
|
-
skillName = parseNameFromMd(text) || fallbackBaseName(originalName, true);
|
|
141
|
-
skillFiles = [{ relPath: 'SKILL.md', data: fileData }];
|
|
142
|
-
} else {
|
|
143
|
-
// zip:用 adm-zip 解压到内存,校验 SKILL.md 存在(忽略大小写),找最浅的 SKILL.md 所在目录作为 skill 根
|
|
144
|
-
const AdmZip = (await import('adm-zip')).default;
|
|
145
|
-
let zip;
|
|
146
|
-
try {
|
|
147
|
-
zip = new AdmZip(fileData);
|
|
148
|
-
} catch {
|
|
149
|
-
throw Object.assign(new Error('Invalid zip archive'), { status: 400, code: 'INVALID_ZIP' });
|
|
150
|
-
}
|
|
151
|
-
const entries = zip.getEntries();
|
|
152
|
-
// zip bomb 防护:单文件 ≤50MB,总解压 ≤200MB;以及拒绝 symlink entry
|
|
153
|
-
// adm-zip 不直接暴露 isSymbolicLink,需用 attr 高 16 位的 unix mode 判断 0o120000
|
|
154
|
-
const MAX_PER_FILE = 50 * 1024 * 1024;
|
|
155
|
-
const MAX_TOTAL_UNCOMPRESSED = 200 * 1024 * 1024;
|
|
156
|
-
let totalUncompressed = 0;
|
|
157
|
-
for (const e of entries) {
|
|
158
|
-
if (e.isDirectory) continue;
|
|
159
|
-
const unixMode = (e.attr >>> 16) & 0xffff;
|
|
160
|
-
if ((unixMode & 0o170000) === 0o120000) {
|
|
161
|
-
throw Object.assign(new Error('Symlinks not allowed in zip'), { status: 400, code: 'INVALID_ZIP' });
|
|
162
|
-
}
|
|
163
|
-
const sizeRaw = e.header?.size || 0;
|
|
164
|
-
if (sizeRaw > MAX_PER_FILE) {
|
|
165
|
-
throw Object.assign(new Error('File too large in archive'), { status: 400, code: 'ZIP_BOMB' });
|
|
166
|
-
}
|
|
167
|
-
totalUncompressed += sizeRaw;
|
|
168
|
-
if (totalUncompressed > MAX_TOTAL_UNCOMPRESSED) {
|
|
169
|
-
throw Object.assign(new Error('Archive expands too large'), { status: 400, code: 'ZIP_BOMB' });
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
// 找所有 SKILL.md(忽略大小写)的 entry,挑最浅的
|
|
173
|
-
let bestSkillEntry = null;
|
|
174
|
-
let bestDepth = Infinity;
|
|
175
|
-
for (const e of entries) {
|
|
176
|
-
if (e.isDirectory) continue;
|
|
177
|
-
const en = e.entryName;
|
|
178
|
-
const base = en.split('/').pop() || '';
|
|
179
|
-
if (base.toLowerCase() === 'skill.md') {
|
|
180
|
-
const depth = en.split('/').length;
|
|
181
|
-
if (depth < bestDepth) { bestDepth = depth; bestSkillEntry = e; }
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
if (!bestSkillEntry) {
|
|
185
|
-
throw Object.assign(new Error('SKILL.md not found in zip'), { status: 400, code: 'MISSING_SKILL_MD' });
|
|
186
|
-
}
|
|
187
|
-
// skill 根 = SKILL.md 所在目录(如果在 zip 根则空字符串)
|
|
188
|
-
const lastSlash = bestSkillEntry.entryName.lastIndexOf('/');
|
|
189
|
-
const skillRootPrefix = lastSlash >= 0 ? bestSkillEntry.entryName.slice(0, lastSlash + 1) : '';
|
|
190
|
-
const skillMdText = bestSkillEntry.getData().toString('utf8');
|
|
191
|
-
skillName = parseNameFromMd(skillMdText)
|
|
192
|
-
|| (skillRootPrefix ? skillRootPrefix.replace(/\/$/, '').split('/').pop() : null)
|
|
193
|
-
|| fallbackBaseName(originalName, true);
|
|
194
|
-
|
|
195
|
-
// 收集 skill 根下的所有文件,规范化 relPath(去掉 root prefix)
|
|
196
|
-
// 二次校验:header.size 来自 zip 中央目录是攻击者可控的(可谎报 size=0),
|
|
197
|
-
// 上面 zip bomb 检查已用 header.size 做"廉价初检"快速拒绝;这里 getData() 后用真实 data.length 复核,
|
|
198
|
-
// 防止恶意 zip 在 header 上撒谎绕过总大小限制。任一阶段超额都抛 ZIP_BOMB。
|
|
199
|
-
let actualTotal = 0;
|
|
200
|
-
for (const e of entries) {
|
|
201
|
-
if (e.isDirectory) continue;
|
|
202
|
-
if (skillRootPrefix && !e.entryName.startsWith(skillRootPrefix)) continue;
|
|
203
|
-
const rel = skillRootPrefix ? e.entryName.slice(skillRootPrefix.length) : e.entryName;
|
|
204
|
-
if (!rel || rel.includes('..')) continue;
|
|
205
|
-
const data = e.getData();
|
|
206
|
-
if (data.length > MAX_PER_FILE) {
|
|
207
|
-
throw Object.assign(new Error('File actual size too large'), { status: 400, code: 'ZIP_BOMB' });
|
|
208
|
-
}
|
|
209
|
-
actualTotal += data.length;
|
|
210
|
-
if (actualTotal > MAX_TOTAL_UNCOMPRESSED) {
|
|
211
|
-
throw Object.assign(new Error('Archive actual size too large'), { status: 400, code: 'ZIP_BOMB' });
|
|
212
|
-
}
|
|
213
|
-
// SKILL.md 统一规范化大小写
|
|
214
|
-
const finalRel = rel.split('/').pop().toLowerCase() === 'skill.md'
|
|
215
|
-
? rel.replace(/[^/]*$/, 'SKILL.md')
|
|
216
|
-
: rel;
|
|
217
|
-
skillFiles.push({ relPath: finalRel, data });
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
if (!validateSkillName(skillName)) {
|
|
222
|
-
throw Object.assign(new Error(`Invalid skill name: ${skillName}`), { status: 400, code: 'INVALID_NAME' });
|
|
223
|
-
}
|
|
224
|
-
const targetDir = join(skillsRoot, skillName);
|
|
225
|
-
// 原子创建:不带 recursive 让 mkdir 在已存在时直接抛 EEXIST,消除 existsSync 与 mkdirSync 之间的 TOCTOU 竞争窗口。
|
|
226
|
-
// skillsRoot 已在前面 mkdirSync(skillsRoot, { recursive: true }) 兜底创建,所以这里 join 出的 parent 一定存在。
|
|
227
|
-
try {
|
|
228
|
-
mkdirSync(targetDir);
|
|
229
|
-
} catch (err) {
|
|
230
|
-
if (err.code === 'EEXIST') {
|
|
231
|
-
throw Object.assign(new Error(`Skill already exists: ${skillName}`), { status: 409, code: 'EXISTS' });
|
|
232
|
-
}
|
|
233
|
-
throw err;
|
|
234
|
-
}
|
|
235
|
-
// 二次防御:resolved + sep 后缀比较,防止 prefix 攻击
|
|
236
|
-
// (e.g. dest=/skills/my-skill-evil/x 不能以 /skills/my-skill 单独 startsWith 通过)
|
|
237
|
-
const resolvedTarget = resolve(targetDir) + sep;
|
|
238
|
-
for (const f of skillFiles) {
|
|
239
|
-
const dest = join(targetDir, f.relPath);
|
|
240
|
-
if (!resolve(dest).startsWith(resolvedTarget)) continue;
|
|
241
|
-
mkdirSync(dirname(dest), { recursive: true });
|
|
242
|
-
writeFileSync(dest, f.data);
|
|
243
|
-
}
|
|
249
|
+
const { skillName, files } = await parseSkillUpload(Buffer.concat(chunks), boundary, windowsReserved);
|
|
250
|
+
const targetDir = writeSkillFiles(skillsRoot, skillName, files);
|
|
244
251
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
245
252
|
res.end(JSON.stringify({ ok: true, name: skillName, path: targetDir }));
|
|
246
253
|
} catch (err) {
|
|
@@ -254,6 +261,14 @@ function skillsImport(req, res, parsedUrl, isLocal, deps) {
|
|
|
254
261
|
});
|
|
255
262
|
}
|
|
256
263
|
|
|
264
|
+
// Skill 上传/导入 API —— 接受 .zip 或 SKILL.md(忽略大小写),写入用户级 ~/.claude/skills/{name}/
|
|
265
|
+
function skillsImport(req, res, parsedUrl, isLocal, deps) {
|
|
266
|
+
importSkillTo(req, res, {
|
|
267
|
+
skillsRoot: join(getClaudeConfigDir(), 'skills'),
|
|
268
|
+
windowsReserved: deps.WINDOWS_RESERVED_NAMES,
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
257
272
|
export const skillsRoutes = [
|
|
258
273
|
{ method: 'GET', match: 'exact', path: '/api/skills', handler: skillsList },
|
|
259
274
|
{ method: 'POST', match: 'exact', path: '/api/skills/toggle', handler: skillsToggle },
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// Workspace routes (moved verbatim from server.js handleRequest).
|
|
2
|
-
import { existsSync, statSync
|
|
2
|
+
import { existsSync, statSync } from 'node:fs';
|
|
3
3
|
import { basename } from 'node:path';
|
|
4
4
|
import { LOG_FILE, initForWorkspace, resetWorkspace } from '../interceptor.js';
|
|
5
|
-
import { watchLogFile,
|
|
5
|
+
import { watchLogFile, unwatchAll } from '../lib/log-watcher.js';
|
|
6
6
|
import { readClaudeProjectModel } from '../lib/context-watcher.js';
|
|
7
7
|
import { countLogEntries, streamRawEntriesAsync } from '../lib/log-stream.js';
|
|
8
8
|
|
|
@@ -30,7 +30,7 @@ function workspacesLaunch(req, res, parsedUrl, isLocal, deps) {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
const { registerWorkspace } = await import('../workspace-registry.js');
|
|
33
|
-
registerWorkspace(wsPath);
|
|
33
|
+
await registerWorkspace(wsPath);
|
|
34
34
|
|
|
35
35
|
// Electron multi-tab 模式:管理 server 只触发 callback,不做日志初始化
|
|
36
36
|
// 所有日志相关操作(initForWorkspace、watchLogFile、spawnClaude)由 tab-worker 子进程负责
|
|
@@ -107,7 +107,7 @@ function workspacesAdd(req, res, parsedUrl, isLocal, deps) {
|
|
|
107
107
|
return;
|
|
108
108
|
}
|
|
109
109
|
const { registerWorkspace } = await import('../workspace-registry.js');
|
|
110
|
-
const entry = registerWorkspace(wsPath);
|
|
110
|
+
const entry = await registerWorkspace(wsPath);
|
|
111
111
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
112
112
|
res.end(JSON.stringify({ ok: true, workspace: entry }));
|
|
113
113
|
} catch (err) {
|
|
@@ -120,8 +120,8 @@ function workspacesAdd(req, res, parsedUrl, isLocal, deps) {
|
|
|
120
120
|
function workspacesDelete(req, res, parsedUrl) {
|
|
121
121
|
const url = parsedUrl.pathname;
|
|
122
122
|
const id = url.split('/').pop();
|
|
123
|
-
import('../workspace-registry.js').then(({ removeWorkspace }) => {
|
|
124
|
-
const removed = removeWorkspace(id);
|
|
123
|
+
import('../workspace-registry.js').then(async ({ removeWorkspace }) => {
|
|
124
|
+
const removed = await removeWorkspace(id);
|
|
125
125
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
126
126
|
res.end(JSON.stringify({ ok: removed }));
|
|
127
127
|
}).catch(err => {
|
|
@@ -138,10 +138,7 @@ function workspacesStop(req, res, parsedUrl, isLocal, deps) {
|
|
|
138
138
|
// 接续原有清理流程
|
|
139
139
|
|
|
140
140
|
// 停止日志监听
|
|
141
|
-
|
|
142
|
-
unwatchFile(logFile);
|
|
143
|
-
}
|
|
144
|
-
getWatchedFiles().clear();
|
|
141
|
+
unwatchAll();
|
|
145
142
|
|
|
146
143
|
// 重置 interceptor 状态
|
|
147
144
|
resetWorkspace();
|