@kennyzhao/cgm 0.0.0-stage → 1.0.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/DEPLOY-Linux.md +75 -0
- package/LICENSE +21 -0
- package/README.en.md +220 -0
- package/README.md +220 -2
- package/cli.mjs +68 -0
- package/install.cmd +5 -0
- package/install.ps1 +55 -0
- package/install.sh +66 -0
- package/mcp-config.json +8 -0
- package/mcp.mjs +487 -0
- package/package.json +35 -4
- package/public/img/add-provider.png +0 -0
- package/public/img/index.png +0 -0
- package/public/img/repo-show.png +0 -0
- package/public/img/setting.png +0 -0
- package/public/index.html +892 -0
- package/server.mjs +1144 -0
- package/verify-mcp.mjs +207 -0
package/server.mjs
ADDED
|
@@ -0,0 +1,1144 @@
|
|
|
1
|
+
// CodeGraph Manager —— 服务器代码索引管理服务
|
|
2
|
+
//
|
|
3
|
+
// 零依赖 Node 服务 + 原生 JS 单页前端。管理多提供商仓库的 clone → codegraph 分析 → 移除。
|
|
4
|
+
// 支持:GitLab / Gitee / GitHub(可同时配置多个提供商实例)
|
|
5
|
+
//
|
|
6
|
+
// 运行:node server.mjs (默认 http://127.0.0.1:8801)
|
|
7
|
+
// 环境变量:
|
|
8
|
+
// HOST / PORT 监听地址端口(默认 127.0.0.1 / 8801;HOST 非回环时必须配 token,否则拒绝启动)
|
|
9
|
+
// DATA_DIR 数据目录(cgm.db(SQLite) 存放,默认 ./data)
|
|
10
|
+
// ADMIN_TOKEN /api/* 的 Bearer 令牌;未设置时首次启动自动生成随机令牌并持久化到 <DATA_DIR>/auth.json
|
|
11
|
+
// MCP_TOKEN /mcp* 的令牌(未设置回落 ADMIN_TOKEN,自用只配一个变量即可)
|
|
12
|
+
// LOCAL_NO_AUTH 回环免鉴权开关(默认 1 开启;设 0 关闭,本地也需令牌)
|
|
13
|
+
// TRUSTED_PROXIES 逗号分隔可信代理 IP:仅对可信代理采信 X-Forwarded-For 判定回环来源
|
|
14
|
+
// BASE_PATH 反代前缀部署路径(如 /repair-service-mcp),默认空
|
|
15
|
+
// MAX_BODY_BYTES 请求体大小上限(默认 1MB,超限返回 413)
|
|
16
|
+
// CODEGRAPH_BIN codegraph 可执行(未设置时自动探测:PATH 里的 codegraph → npm 全局包 shim)
|
|
17
|
+
// 数据:
|
|
18
|
+
// <DATA_DIR>/cgm.db SQLite(node:sqlite):repos 表一仓一行 + kv 表存 settings 快照;
|
|
19
|
+
// 旧 settings.json/repos.json 首启自动迁移,改名 *.json.migrated-* 保留
|
|
20
|
+
// <DATA_DIR>/auth.json 首启自动生成的默认令牌(bootstrap,保持文件形式)
|
|
21
|
+
import http from 'node:http';
|
|
22
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
23
|
+
import { promises as fs } from 'node:fs';
|
|
24
|
+
import fsSync from 'node:fs';
|
|
25
|
+
import path from 'node:path';
|
|
26
|
+
import crypto from 'node:crypto';
|
|
27
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
28
|
+
import { fileURLToPath } from 'node:url';
|
|
29
|
+
import { handleMcpMessage, handleSseConnection, broadcastSse } from './mcp.mjs';
|
|
30
|
+
|
|
31
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
32
|
+
const HOST = process.env.HOST || '127.0.0.1';
|
|
33
|
+
const PORT = parseInt(process.env.PORT || '8801', 10);
|
|
34
|
+
const DATA_DIR = process.env.DATA_DIR
|
|
35
|
+
? path.resolve(process.env.DATA_DIR)
|
|
36
|
+
: path.join(HERE, 'data');
|
|
37
|
+
// 令牌引导:显式 ADMIN_TOKEN 环境变量优先;未配置时首次启动生成随机令牌(192bit)并持久化到 <DATA_DIR>/auth.json,重启沿用
|
|
38
|
+
function bootstrapToken() {
|
|
39
|
+
const file = path.join(DATA_DIR, 'auth.json');
|
|
40
|
+
let saved = {};
|
|
41
|
+
try { saved = JSON.parse(fsSync.readFileSync(file, 'utf8')); } catch {}
|
|
42
|
+
if (process.env.ADMIN_TOKEN) return { token: process.env.ADMIN_TOKEN, file, generated: false };
|
|
43
|
+
if (typeof saved.adminToken === 'string' && saved.adminToken) return { token: saved.adminToken, file, generated: false };
|
|
44
|
+
const token = crypto.randomBytes(24).toString('base64url');
|
|
45
|
+
try {
|
|
46
|
+
fsSync.mkdirSync(DATA_DIR, { recursive: true });
|
|
47
|
+
fsSync.writeFileSync(file, JSON.stringify({ adminToken: token, createdAt: new Date().toISOString() }, null, 2));
|
|
48
|
+
return { token, file, generated: true };
|
|
49
|
+
} catch (e) {
|
|
50
|
+
log('warn: auth.json 写入失败,未能生成默认令牌', e.message);
|
|
51
|
+
return { token: '', file, generated: false };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const BOOTSTRAP_TOKEN = bootstrapToken();
|
|
55
|
+
const ADMIN_TOKEN = process.env.ADMIN_TOKEN || BOOTSTRAP_TOKEN.token;
|
|
56
|
+
const MCP_TOKEN = process.env.MCP_TOKEN || ADMIN_TOKEN; // 未设置回落 ADMIN_TOKEN(自用只配一个变量)
|
|
57
|
+
const LOCAL_NO_AUTH = process.env.LOCAL_NO_AUTH !== '0'; // 回环免鉴权默认开启(设 0 关闭,本地也强制鉴权)
|
|
58
|
+
const TRUSTED_PROXIES = new Set((process.env.TRUSTED_PROXIES || '').split(',').map(s => s.trim()).filter(Boolean));
|
|
59
|
+
// BASE_PATH 前缀部署:反代把 /<base>/* 转发进来,路由匹配前剥掉该前缀;未设置行为不变
|
|
60
|
+
export const BASE_PATH = process.env.BASE_PATH ? '/' + process.env.BASE_PATH.replace(/^\/+|\/+$/g, '') : '';
|
|
61
|
+
// 请求体上限(readBody 与 MCP 消息体共用同一上限,超限 413)
|
|
62
|
+
export const MAX_BODY_BYTES = Math.max(1, parseInt(process.env.MAX_BODY_BYTES || String(1024 * 1024), 10) || 1024 * 1024);
|
|
63
|
+
|
|
64
|
+
// CODEGRAPH_BIN 解析:显式配置原样保留(含 JSON 数组形式);未设置时自动探测——
|
|
65
|
+
// 1) PATH 里的 codegraph(Linux install.sh 方式);2) npm 全局包 @colbymchenry/codegraph 的
|
|
66
|
+
// npm-shim.js(Windows 全局装是 bash shim,PATH 调不通)→ 转 ["node", "<shim>"] 数组形式。
|
|
67
|
+
// 都失败回落 'codegraph',与旧行为一致。
|
|
68
|
+
function resolveCodegraphBin() {
|
|
69
|
+
const raw = (process.env.CODEGRAPH_BIN || '').trim();
|
|
70
|
+
if (raw) return raw;
|
|
71
|
+
// 探测必须与实际调用方式一致(run() 无 shell 直接 spawn):
|
|
72
|
+
// Windows 全局装的 bash shim 经 cmd 包装能通过 shell 探测,直接 spawn 却会失败,故此处不用 shell
|
|
73
|
+
const probe = spawnSync('codegraph', ['--version'], { timeout: 5000 });
|
|
74
|
+
if (!probe.error && probe.status === 0) return 'codegraph';
|
|
75
|
+
try {
|
|
76
|
+
// npm 本体是 npm.cmd(Windows),root 查询经 shell 执行;无用户输入拼接,无注入面
|
|
77
|
+
const root = spawnSync('npm', ['root', '-g'], { timeout: 10000, shell: process.platform === 'win32' }).stdout?.toString().trim();
|
|
78
|
+
if (root) {
|
|
79
|
+
const shim = path.join(root, '@colbymchenry', 'codegraph', 'npm-shim.js');
|
|
80
|
+
if (fsSync.existsSync(shim)) return JSON.stringify(['node', shim]);
|
|
81
|
+
}
|
|
82
|
+
} catch { /* 探测失败走默认 */ }
|
|
83
|
+
return 'codegraph';
|
|
84
|
+
}
|
|
85
|
+
const CODEGRAPH_BIN = resolveCodegraphBin();
|
|
86
|
+
// 回写 env:mcp.mjs 的 stdio 网关子进程读取 CODEGRAPH_BIN,必须拿到探测结果而非裸命令名
|
|
87
|
+
process.env.CODEGRAPH_BIN = CODEGRAPH_BIN;
|
|
88
|
+
|
|
89
|
+
const SETTINGS_FILE = path.join(DATA_DIR, 'settings.json');
|
|
90
|
+
const REPOS_FILE = path.join(DATA_DIR, 'repos.json');
|
|
91
|
+
// clone 基目录默认收敛进 DATA_DIR:备份/迁移 = 拷一个目录(避免相对 cwd 散落)
|
|
92
|
+
const CLONE_BASE_DIR_DEFAULT = path.join(DATA_DIR, 'repos');
|
|
93
|
+
|
|
94
|
+
/* ---------------- 日志 ---------------- */
|
|
95
|
+
function log(...a) { console.log(`[${new Date().toISOString()}]`, ...a); }
|
|
96
|
+
|
|
97
|
+
/* ---------------- 持久化(SQLite,node:sqlite 文档模型) ---------------- */
|
|
98
|
+
// repos 一仓一行(JSON 载荷),settings 整快照一行(kv 表)。node:sqlite 同步 API + 事务天然原子,
|
|
99
|
+
// 替代旧 tmp+rename 方案;旧 settings.json/repos.json 首启自动迁移,迁移后改名 *.json.migrated-* 保底不删。
|
|
100
|
+
const DB_FILE = path.join(DATA_DIR, 'cgm.db');
|
|
101
|
+
let db = null;
|
|
102
|
+
function openDb() {
|
|
103
|
+
fsSync.mkdirSync(DATA_DIR, { recursive: true });
|
|
104
|
+
db = new DatabaseSync(DB_FILE);
|
|
105
|
+
db.exec('PRAGMA journal_mode=WAL');
|
|
106
|
+
db.exec(`CREATE TABLE IF NOT EXISTS repos (id TEXT PRIMARY KEY, data TEXT NOT NULL);
|
|
107
|
+
CREATE TABLE IF NOT EXISTS kv (k TEXT PRIMARY KEY, data TEXT NOT NULL);
|
|
108
|
+
CREATE TABLE IF NOT EXISTS api_tokens (id TEXT PRIMARY KEY, name TEXT NOT NULL, token_hash TEXT NOT NULL UNIQUE, roles TEXT NOT NULL, space_ids TEXT NOT NULL, created_at INTEGER NOT NULL, last_used_at INTEGER, revoked_at INTEGER);`);
|
|
109
|
+
migrateJsonToSqlite();
|
|
110
|
+
}
|
|
111
|
+
function migrateJsonToSqlite() {
|
|
112
|
+
const stamp = '.migrated-' + new Date().toISOString().replace(/[:.]/g, '-');
|
|
113
|
+
if (db.prepare('SELECT COUNT(*) AS n FROM repos').get().n === 0) {
|
|
114
|
+
try {
|
|
115
|
+
const list = JSON.parse(fsSync.readFileSync(REPOS_FILE, 'utf8'));
|
|
116
|
+
const ins = db.prepare('INSERT OR REPLACE INTO repos (id, data) VALUES (?, ?)');
|
|
117
|
+
db.exec('BEGIN');
|
|
118
|
+
for (const r of list) if (r && r.id) ins.run(String(r.id), JSON.stringify(r));
|
|
119
|
+
db.exec('COMMIT');
|
|
120
|
+
try { fsSync.renameSync(REPOS_FILE, REPOS_FILE + stamp); } catch {}
|
|
121
|
+
log(`已迁移 repos.json → SQLite(${list.length} 条),原文件改名保留`);
|
|
122
|
+
} catch { /* 无旧文件/解析失败 = 无需迁移 */ }
|
|
123
|
+
}
|
|
124
|
+
if (db.prepare("SELECT COUNT(*) AS n FROM kv WHERE k='settings'").get().n === 0) {
|
|
125
|
+
try {
|
|
126
|
+
const saved = JSON.parse(fsSync.readFileSync(SETTINGS_FILE, 'utf8'));
|
|
127
|
+
db.prepare("INSERT OR REPLACE INTO kv (k, data) VALUES ('settings', ?)").run(JSON.stringify(saved));
|
|
128
|
+
try { fsSync.renameSync(SETTINGS_FILE, SETTINGS_FILE + stamp); } catch {}
|
|
129
|
+
log('已迁移 settings.json → SQLite,原文件改名保留');
|
|
130
|
+
} catch { /* 同上 */ }
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/* ---------------- 凭证加密(AES-256-GCM) ---------------- */
|
|
135
|
+
// 约定:内存中 provider.token 保持明文(供 API/克隆调用),落盘时加密。
|
|
136
|
+
// 密钥:优先 ENCRYPTION_KEY 环境变量(64 hex = 32 字节);否则从 DATA_DIR/.key 读,无则生成。
|
|
137
|
+
let encKey = null;
|
|
138
|
+
function getEncKey() {
|
|
139
|
+
if (encKey) return encKey;
|
|
140
|
+
const envK = (process.env.ENCRYPTION_KEY || '').trim();
|
|
141
|
+
if (/^[0-9a-f]{64}$/i.test(envK)) { encKey = Buffer.from(envK, 'hex'); return encKey; }
|
|
142
|
+
const keyFile = path.join(DATA_DIR, '.key');
|
|
143
|
+
try {
|
|
144
|
+
const k = fsSync.readFileSync(keyFile, 'utf8').trim();
|
|
145
|
+
if (/^[0-9a-f]{64}$/i.test(k)) { encKey = Buffer.from(k, 'hex'); return encKey; }
|
|
146
|
+
} catch {}
|
|
147
|
+
const key = crypto.randomBytes(32);
|
|
148
|
+
encKey = key;
|
|
149
|
+
try { fsSync.mkdirSync(DATA_DIR, { recursive: true }); fsSync.writeFileSync(keyFile, key.toString('hex'), { flag: 'wx' }); } catch {}
|
|
150
|
+
return encKey;
|
|
151
|
+
}
|
|
152
|
+
function encryptToken(t) {
|
|
153
|
+
if (!t) return t;
|
|
154
|
+
const key = getEncKey();
|
|
155
|
+
const iv = crypto.randomBytes(12);
|
|
156
|
+
const c = crypto.createCipheriv('aes-256-gcm', key, iv);
|
|
157
|
+
const enc = Buffer.concat([c.update(String(t), 'utf8'), c.final()]);
|
|
158
|
+
const tag = c.getAuthTag();
|
|
159
|
+
return 'enc:' + iv.toString('base64') + '.' + tag.toString('base64') + '.' + enc.toString('base64');
|
|
160
|
+
}
|
|
161
|
+
function decryptToken(t) {
|
|
162
|
+
if (!t || !String(t).startsWith('enc:')) return t; // 非加密(第三方明文/未迁移)按原样返回
|
|
163
|
+
try {
|
|
164
|
+
const key = getEncKey();
|
|
165
|
+
const [ivb, tagb, datab] = String(t).slice(4).split('.');
|
|
166
|
+
const d = crypto.createDecipheriv('aes-256-gcm', key, Buffer.from(ivb, 'base64'));
|
|
167
|
+
d.setAuthTag(Buffer.from(tagb, 'base64'));
|
|
168
|
+
return Buffer.concat([d.update(Buffer.from(datab, 'base64')), d.final()]).toString('utf8');
|
|
169
|
+
} catch { return ''; } // 密钥不符/损坏 → 视为无 token
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// 默认设置:providers 数组 + 全局配置
|
|
173
|
+
const DEFAULT_SPACE = { id: 'all', name: '全部', builtin: true, order: 0 };
|
|
174
|
+
let settings = {
|
|
175
|
+
providers: [], // [{id, type, name, url, token, createdAt}]
|
|
176
|
+
activeProviderId: '', // 当前激活的提供商 ID
|
|
177
|
+
cloneBaseDir: CLONE_BASE_DIR_DEFAULT,
|
|
178
|
+
autoSyncHours: null, // 定时同步频率(小时),null=用 env SYNC_HOURS 或默认 6;0=关闭
|
|
179
|
+
spaces: [DEFAULT_SPACE] // 空间(标签/分组):[{id,name,builtin?,order}]
|
|
180
|
+
};
|
|
181
|
+
let repos = []; // [{id,name,providerId,providerType,gitUrl,localPath,branch,status,message,addedAt,worktrees:[...],spaceIds:[]}]
|
|
182
|
+
// worktree 状态:registered(待建) / indexing(建+分析中) / indexed(可用) / failed
|
|
183
|
+
function ensureWorktreesField() { for (const r of repos) if (!Array.isArray(r.worktrees)) r.worktrees = []; }
|
|
184
|
+
function ensureSpaceIdsField() { for (const r of repos) if (!Array.isArray(r.spaceIds)) r.spaceIds = []; }
|
|
185
|
+
|
|
186
|
+
async function loadAll() {
|
|
187
|
+
openDb(); // 打开 SQLite(含旧 JSON 自动迁移)
|
|
188
|
+
try {
|
|
189
|
+
const row = db.prepare("SELECT data FROM kv WHERE k='settings'").get();
|
|
190
|
+
if (row) {
|
|
191
|
+
const saved = JSON.parse(row.data);
|
|
192
|
+
// 兼容旧格式:迁移旧的单一提供商配置到新格式
|
|
193
|
+
if (saved.gitlabUrl || saved.gitlabToken || saved.giteeUrl || saved.giteeToken || saved.githubToken) {
|
|
194
|
+
const providers = [];
|
|
195
|
+
if (saved.gitlabUrl || saved.gitlabToken) {
|
|
196
|
+
providers.push({ id: crypto.randomUUID(), type: 'gitlab', name: 'GitLab', url: saved.gitlabUrl || '', token: saved.gitlabToken || '', createdAt: Date.now() });
|
|
197
|
+
}
|
|
198
|
+
if (saved.giteeUrl || saved.giteeToken) {
|
|
199
|
+
providers.push({ id: crypto.randomUUID(), type: 'gitee', name: 'Gitee', url: saved.giteeUrl || 'https://gitee.com', token: saved.giteeToken || '', createdAt: Date.now() });
|
|
200
|
+
}
|
|
201
|
+
if (saved.githubToken) {
|
|
202
|
+
providers.push({ id: crypto.randomUUID(), type: 'github', name: 'GitHub', url: '', token: saved.githubToken, createdAt: Date.now() });
|
|
203
|
+
}
|
|
204
|
+
settings = {
|
|
205
|
+
providers,
|
|
206
|
+
activeProviderId: providers[0]?.id || '',
|
|
207
|
+
cloneBaseDir: saved.cloneBaseDir || ''
|
|
208
|
+
};
|
|
209
|
+
} else {
|
|
210
|
+
settings = { ...settings, ...saved };
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
} catch {}
|
|
214
|
+
if (!settings.cloneBaseDir) settings.cloneBaseDir = CLONE_BASE_DIR_DEFAULT; // 兼容旧数据/空值:兜底回 DATA_DIR/repos
|
|
215
|
+
try { repos = db.prepare('SELECT data FROM repos').all().map((r) => JSON.parse(r.data)); } catch {}
|
|
216
|
+
await healStaleStatus(); // 启动自愈:重置被重启中断的运行中状态
|
|
217
|
+
ensureWorktreesField(); // 兼容旧数据:确保 worktrees 字段存在
|
|
218
|
+
ensureSpaceIdsField(); // 兼容旧数据:确保 spaceIds 字段存在
|
|
219
|
+
if (!Array.isArray(settings.spaces) || !settings.spaces.length) settings.spaces = [DEFAULT_SPACE];
|
|
220
|
+
if (!settings.spaces.some(s => s.id === 'all')) settings.spaces.unshift(DEFAULT_SPACE);
|
|
221
|
+
// 清理 repos.spaceIds 中的脏值(null / 指向已不存在空间的 id)
|
|
222
|
+
const validSpaceIds = new Set(settings.spaces.map(s => s.id));
|
|
223
|
+
for (const r of repos) r.spaceIds = (r.spaceIds || []).filter(id => validSpaceIds.has(id));
|
|
224
|
+
// 解密 providers token 回内存明文;检测明文 token(未迁移/第三方)→ 迁移为加密落盘
|
|
225
|
+
let needsEncrypt = false;
|
|
226
|
+
settings.providers = settings.providers.map(p => {
|
|
227
|
+
const t = p.token;
|
|
228
|
+
if (t && !String(t).startsWith('enc:')) needsEncrypt = true;
|
|
229
|
+
return { ...p, token: decryptToken(t) };
|
|
230
|
+
});
|
|
231
|
+
if (needsEncrypt) await saveSettings();
|
|
232
|
+
}
|
|
233
|
+
async function saveSettings() {
|
|
234
|
+
// 落盘时对 providers token 加密(内存 settings 保持明文供调用);SQLite 单行事务,天然原子
|
|
235
|
+
const snapshot = {
|
|
236
|
+
...settings,
|
|
237
|
+
providers: settings.providers.map(p => ({ ...p, token: p.token ? encryptToken(p.token) : '' }))
|
|
238
|
+
};
|
|
239
|
+
db.prepare("INSERT OR REPLACE INTO kv (k, data) VALUES ('settings', ?)").run(JSON.stringify(snapshot));
|
|
240
|
+
}
|
|
241
|
+
async function saveRepos() {
|
|
242
|
+
// 整表快照式替换(与旧整文件写同语义):单事务原子,无需写队列
|
|
243
|
+
db.exec('BEGIN');
|
|
244
|
+
try {
|
|
245
|
+
db.prepare('DELETE FROM repos').run();
|
|
246
|
+
const ins = db.prepare('INSERT INTO repos (id, data) VALUES (?, ?)');
|
|
247
|
+
for (const r of repos) ins.run(String(r.id), JSON.stringify(r));
|
|
248
|
+
db.exec('COMMIT');
|
|
249
|
+
} catch (e) { try { db.exec('ROLLBACK'); } catch {} throw e; }
|
|
250
|
+
broadcastSse('repos-changed', { t: Date.now() }); // 推送前端刷新(替代 4s 轮询)
|
|
251
|
+
}
|
|
252
|
+
const RUNNING_STATUSES = ['cloning', 'indexing', 'removing', 'syncing'];
|
|
253
|
+
// 启动自愈:进程被重启中断时,把卡在"运行中状态"的仓库重置为可重试,防永久处理中
|
|
254
|
+
async function healStaleStatus() {
|
|
255
|
+
let changed = false;
|
|
256
|
+
for (const r of repos) {
|
|
257
|
+
if (RUNNING_STATUSES.includes(r.status)) {
|
|
258
|
+
// cloning→registered(重新克隆);其余有本地目录→cloned(重新 analyze/sync),无目录→registered
|
|
259
|
+
r.status = r.status === 'cloning' ? 'registered' : (r.localPath ? 'cloned' : 'registered');
|
|
260
|
+
r.message = 'Interrupted by restart — reset.';
|
|
261
|
+
changed = true;
|
|
262
|
+
}
|
|
263
|
+
// 分支 worktree 同理:重启打断的 indexing 永远不会自己结束,重置为 failed(可重新触发创建)
|
|
264
|
+
for (const w of (r.worktrees || [])) {
|
|
265
|
+
if (w.status === 'indexing') { w.status = 'failed'; w.message = 'Interrupted by restart — retry.'; changed = true; }
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
if (changed) await saveRepos();
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/* ---------------- 子进程运行器 ---------------- */
|
|
272
|
+
function run(cmd, args, opts = {}) {
|
|
273
|
+
// CODEGRAPH_NO_WATCHDOG=1:禁用 codegraph 看门狗子进程,避免其孤儿进程长期锁 .codegraph/codegraph.db(导致再 clone/analyze 时 EBUSY)
|
|
274
|
+
return new Promise((resolve) => {
|
|
275
|
+
const p = spawn(cmd, args, { cwd: opts.cwd, env: { ...process.env, CODEGRAPH_NO_WATCHDOG: '1', ...(opts.env||{}) } });
|
|
276
|
+
let out = '', err = '';
|
|
277
|
+
p.stdout?.on('data', (d) => (out += d));
|
|
278
|
+
p.stderr?.on('data', (d) => (err += d));
|
|
279
|
+
p.on('error', (e) => resolve({ code: -1, ok: false, out, err: e.message }));
|
|
280
|
+
p.on('close', (code) => resolve({ code, ok: code === 0, out, err }));
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** 解析 CODEGRAPH_BIN:支持 "codegraph"(空格分隔) 或 JSON 数组(可含空格路径)。mcp.mjs 复用。 */
|
|
285
|
+
export function parseBin(s) {
|
|
286
|
+
s = (s || '').trim();
|
|
287
|
+
if (s.startsWith('[')) { try { const a = JSON.parse(s); return a.map(String); } catch {} }
|
|
288
|
+
return s.split(/\s+/).filter(Boolean);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/* ---------------- 全局索引队列 ---------------- */
|
|
292
|
+
// codegraph init/sync 是 CPU/IO 密集型,多仓并发会互相抢资源 + 加剧 SQLite db 锁竞争(EBUSY)。
|
|
293
|
+
// 所有重量级索引操作经此队列限流执行(QUEUE_CONCURRENCY 可调,默认 2)。
|
|
294
|
+
const QUEUE_CONCURRENCY = Math.max(1, parseInt(process.env.QUEUE_CONCURRENCY || '2', 10) || 2);
|
|
295
|
+
let queueActive = 0;
|
|
296
|
+
const queueWaiting = [];
|
|
297
|
+
function enqueue(fn) {
|
|
298
|
+
return new Promise((resolve, reject) => {
|
|
299
|
+
queueWaiting.push({ fn, resolve, reject });
|
|
300
|
+
pumpQueue();
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
function pumpQueue() {
|
|
304
|
+
while (queueActive < QUEUE_CONCURRENCY && queueWaiting.length) {
|
|
305
|
+
const { fn, resolve, reject } = queueWaiting.shift();
|
|
306
|
+
queueActive++;
|
|
307
|
+
Promise.resolve().then(fn).then(resolve, reject).finally(() => { queueActive--; pumpQueue(); });
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/* ---------------- 单仓生命周期互斥 ---------------- */
|
|
312
|
+
// 同一 repo 的 clone/analyze/sync/remove/worktree 串行执行,防并发 git 操作互踩(如双击 Clone、analyze 与 remove 交叉)。
|
|
313
|
+
// 死锁规避:锁在任务真正开始执行时才获取(尾随链),而非入队时;持锁后等待全局索引队列槽是安全的——
|
|
314
|
+
// 全局队列里的任务(codegraph init/sync)从不反向获取仓锁,不存在循环等待。
|
|
315
|
+
const repoLocks = new Map(); // repoId -> 尾随 Promise
|
|
316
|
+
function withRepoLock(repoId, fn) {
|
|
317
|
+
const prev = repoLocks.get(repoId) || Promise.resolve();
|
|
318
|
+
const task = prev.catch(() => {}).then(fn); // 前一任务成败都不阻塞后续
|
|
319
|
+
const tail = task.catch(() => {});
|
|
320
|
+
repoLocks.set(repoId, tail);
|
|
321
|
+
tail.then(() => { if (repoLocks.get(repoId) === tail) repoLocks.delete(repoId); }); // 空闲即清理,防 Map 无限增长
|
|
322
|
+
return task;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/* ---------------- 提供商抽象层 ---------------- */
|
|
326
|
+
function getProviderById(id) {
|
|
327
|
+
return settings.providers.find(p => p.id === id) || null;
|
|
328
|
+
}
|
|
329
|
+
function getActiveProvider() {
|
|
330
|
+
return getProviderById(settings.activeProviderId) || settings.providers[0] || null;
|
|
331
|
+
}
|
|
332
|
+
function getProviderHeaders(provider) {
|
|
333
|
+
switch (provider.type) {
|
|
334
|
+
case 'gitlab': return { 'PRIVATE-TOKEN': provider.token, 'Content-Type': 'application/json' };
|
|
335
|
+
case 'gitee': return { 'Authorization': `Bearer ${provider.token}`, 'Content-Type': 'application/json' };
|
|
336
|
+
case 'github': return { 'Authorization': `token ${provider.token}`, 'Content-Type': 'application/json', 'User-Agent': 'CodeGraph-Manager' };
|
|
337
|
+
default: return { 'Content-Type': 'application/json' };
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
function getProviderApiBase(provider) {
|
|
341
|
+
switch (provider.type) {
|
|
342
|
+
case 'gitlab': return provider.url.replace(/\/$/, '');
|
|
343
|
+
case 'gitee': return provider.url.replace(/\/$/, '');
|
|
344
|
+
case 'github': return 'https://api.github.com';
|
|
345
|
+
default: return provider.url.replace(/\/$/, '');
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
async function providerFetch(pathname, search, provider) {
|
|
349
|
+
const base = getProviderApiBase(provider);
|
|
350
|
+
const headers = getProviderHeaders(provider);
|
|
351
|
+
const url = `${base}${pathname}${search || ''}`;
|
|
352
|
+
const r = await fetch(url, { headers });
|
|
353
|
+
if (!r.ok) { const t = await r.text().catch(()=>''); throw { status: r.status, msg: t.slice(0,300) }; }
|
|
354
|
+
return r.json();
|
|
355
|
+
}
|
|
356
|
+
async function checkProvider(provider) {
|
|
357
|
+
try {
|
|
358
|
+
let user, version = null;
|
|
359
|
+
switch (provider.type) {
|
|
360
|
+
case 'gitlab': {
|
|
361
|
+
const me = await providerFetch('/api/v4/user', '', provider);
|
|
362
|
+
user = me?.username || me?.name;
|
|
363
|
+
try { const v = await providerFetch('/api/v4/version', '', provider); version = v?.version || null; } catch {}
|
|
364
|
+
break;
|
|
365
|
+
}
|
|
366
|
+
case 'gitee': {
|
|
367
|
+
const me = await providerFetch('/api/v5/user', '', provider);
|
|
368
|
+
user = me?.login || me?.name;
|
|
369
|
+
break;
|
|
370
|
+
}
|
|
371
|
+
case 'github': {
|
|
372
|
+
const me = await providerFetch('/user', '', provider);
|
|
373
|
+
user = me?.login || me?.name;
|
|
374
|
+
break;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
return { ok: true, user, version };
|
|
378
|
+
} catch (e) {
|
|
379
|
+
const names = { gitlab: 'GitLab', gitee: 'Gitee', github: 'GitHub' };
|
|
380
|
+
return { ok: false, status: e.status, msg: (e instanceof TypeError ? `无法连接 ${names[provider.type] || provider.type},请检查地址与网络` : (e.msg || String(e))) };
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
async function searchProjects(query, providerId) {
|
|
384
|
+
const provider = providerId ? getProviderById(providerId) : getActiveProvider();
|
|
385
|
+
if (!provider) throw { msg: '未配置提供商' };
|
|
386
|
+
const q = (query || '').trim();
|
|
387
|
+
if (!q) return [];
|
|
388
|
+
switch (provider.type) {
|
|
389
|
+
case 'gitlab': {
|
|
390
|
+
const projects = await providerFetch('/api/v4/projects', `?search=${encodeURIComponent(q)}&simple=true&per_page=50`, provider);
|
|
391
|
+
return (Array.isArray(projects) ? projects : []).map(p => ({
|
|
392
|
+
id: p.id, name: p.path_with_namespace, gitUrl: p.http_url_to_repo || p.web_url + '.git',
|
|
393
|
+
defaultBranch: p.default_branch || 'main', providerId: provider.id, providerType: 'gitlab'
|
|
394
|
+
}));
|
|
395
|
+
}
|
|
396
|
+
case 'gitee': {
|
|
397
|
+
const projects = await providerFetch('/api/v5/repos/search', `?q=${encodeURIComponent(q)}&per_page=50`, provider);
|
|
398
|
+
return (Array.isArray(projects) ? projects : []).map(p => ({
|
|
399
|
+
id: p.id, name: p.full_name, gitUrl: p.html_url + '.git',
|
|
400
|
+
defaultBranch: p.default_branch || 'main', providerId: provider.id, providerType: 'gitee'
|
|
401
|
+
}));
|
|
402
|
+
}
|
|
403
|
+
case 'github': {
|
|
404
|
+
const data = await providerFetch('/search/repositories', `?q=${encodeURIComponent(q)}&per_page=50`, provider);
|
|
405
|
+
return (data?.items || []).map(p => ({
|
|
406
|
+
id: p.id, name: p.full_name, gitUrl: p.clone_url,
|
|
407
|
+
defaultBranch: p.default_branch || 'main', providerId: provider.id, providerType: 'github'
|
|
408
|
+
}));
|
|
409
|
+
}
|
|
410
|
+
default: return [];
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/* ---------------- 工具 ---------------- */
|
|
415
|
+
const sanitize = (s) => s.replace(/[\\/<>:"|?*\x00-\x1f]/g, '__');
|
|
416
|
+
function getCloneAuthUrl(gitUrl, provider) {
|
|
417
|
+
if (!provider.token) return gitUrl;
|
|
418
|
+
if (/^https?:\/\//i.test(gitUrl)) {
|
|
419
|
+
switch (provider.type) {
|
|
420
|
+
case 'gitlab': return gitUrl.replace(/^https:\/\//, `https://oauth2:${encodeURIComponent(provider.token)}@`);
|
|
421
|
+
case 'gitee': return gitUrl.replace(/^https:\/\//, `https://oauth2:${encodeURIComponent(provider.token)}@`);
|
|
422
|
+
case 'github': return gitUrl.replace(/^https:\/\//, `https://x-access-token:${encodeURIComponent(provider.token)}@`);
|
|
423
|
+
default: return gitUrl;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
return gitUrl;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/* ---------------- HTTP ---------------- */
|
|
430
|
+
function sendJson(res, code, obj) { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(obj)); }
|
|
431
|
+
function readBody(req) {
|
|
432
|
+
return new Promise((resolve, reject) => {
|
|
433
|
+
const cs = []; let size = 0, over = false;
|
|
434
|
+
req.on('data', (c) => {
|
|
435
|
+
if (over) return;
|
|
436
|
+
size += c.length;
|
|
437
|
+
if (size > MAX_BODY_BYTES) { // 超限:拒收并断开(413 由统一 catch 返回)
|
|
438
|
+
over = true; cs.length = 0; req.pause();
|
|
439
|
+
const e = new Error('payload too large'); e.statusCode = 413; reject(e);
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
cs.push(c);
|
|
443
|
+
});
|
|
444
|
+
req.on('end', () => { if (over) return; try { const t = Buffer.concat(cs).toString('utf8'); resolve(t ? JSON.parse(t) : {}); } catch (e) { reject(e); } });
|
|
445
|
+
req.on('error', reject);
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/* ---------------- 鉴权 ---------------- */
|
|
450
|
+
// 令牌比较:长度不等直接否;等长用 timingSafeEqual(防时序侧信道),绝不用 === 比字符串
|
|
451
|
+
function tokenEquals(a, b) {
|
|
452
|
+
if (!a || !b) return false;
|
|
453
|
+
const ba = Buffer.from(String(a));
|
|
454
|
+
const bb = Buffer.from(String(b));
|
|
455
|
+
if (ba.length !== bb.length) return false;
|
|
456
|
+
return crypto.timingSafeEqual(ba, bb);
|
|
457
|
+
}
|
|
458
|
+
// 令牌解析顺序:Authorization: Bearer X → 原始 Authorization(兼容现有写法)→ ?token=(仅 MCP,给只能填 URL 的客户端)→ Cookie cgm_token
|
|
459
|
+
function extractToken(req, query, allowQuery) {
|
|
460
|
+
const h = req.headers['authorization'];
|
|
461
|
+
if (h) {
|
|
462
|
+
const m = /^Bearer\s+(.+)$/i.exec(h.trim());
|
|
463
|
+
if (m) return m[1].trim();
|
|
464
|
+
return h.trim();
|
|
465
|
+
}
|
|
466
|
+
if (allowQuery && query.token) return String(query.token);
|
|
467
|
+
const ck = req.headers['cookie'];
|
|
468
|
+
if (ck) {
|
|
469
|
+
const m = /(?:^|;\s*)cgm_token=([^;]*)/.exec(ck);
|
|
470
|
+
if (m) { try { return decodeURIComponent(m[1]); } catch { return m[1]; } }
|
|
471
|
+
}
|
|
472
|
+
return '';
|
|
473
|
+
}
|
|
474
|
+
const LOOPBACK_ADDRS = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
|
|
475
|
+
// 真实来源:仅当对端在可信代理名单内才采信 X-Forwarded-For 最左值(否则一律忽略该头,防伪造)
|
|
476
|
+
function clientAddr(req) {
|
|
477
|
+
const peer = req.socket.remoteAddress || '';
|
|
478
|
+
if (TRUSTED_PROXIES.size && TRUSTED_PROXIES.has(peer)) {
|
|
479
|
+
const xff = req.headers['x-forwarded-for'];
|
|
480
|
+
if (typeof xff === 'string' && xff.trim()) return xff.split(',')[0].trim();
|
|
481
|
+
}
|
|
482
|
+
return peer;
|
|
483
|
+
}
|
|
484
|
+
// Origin 与请求 Host 是否同源(含端口);无 Origin(curl/MCP 客户端/同源 GET 导航)视为同源
|
|
485
|
+
function originMatchesHost(req) {
|
|
486
|
+
const origin = req.headers['origin'];
|
|
487
|
+
if (!origin) return true;
|
|
488
|
+
try { return new URL(origin).host.toLowerCase() === String(req.headers['host'] || '').toLowerCase(); } catch { return false; }
|
|
489
|
+
}
|
|
490
|
+
// 回环豁免:本机直连 且 无跨源 Origin(浏览器里任意网页都能 POST 127.0.0.1,必须靠 Origin 校验拦住)
|
|
491
|
+
function isLocalNoAuth(req) {
|
|
492
|
+
return LOCAL_NO_AUTH && LOOPBACK_ADDRS.has(clientAddr(req)) && originMatchesHost(req);
|
|
493
|
+
}
|
|
494
|
+
// /api/* 与 /mcp* 统一解析 principal:回环 admin → ADMIN_TOKEN → env MCP_TOKEN → 数据库 token
|
|
495
|
+
function tokenHash(t) { return crypto.createHash('sha256').update(String(t)).digest('hex'); }
|
|
496
|
+
function jsonArray(v) { try { const a = JSON.parse(v || '[]'); return Array.isArray(a) ? a : []; } catch { return []; } }
|
|
497
|
+
function tokenRowDto(r) { return { id: r.id, name: r.name, roles: jsonArray(r.roles), spaceIds: jsonArray(r.space_ids), createdAt: r.created_at, lastUsedAt: r.last_used_at, revokedAt: r.revoked_at }; }
|
|
498
|
+
function resolvePrincipal(req, query, allowQuery) {
|
|
499
|
+
if (isLocalNoAuth(req)) return { admin: true, roles: ['web', 'mcp'], spaceIds: null, name: 'local', source: 'loopback' };
|
|
500
|
+
const token = extractToken(req, query, allowQuery);
|
|
501
|
+
if (!token) return null;
|
|
502
|
+
if (tokenEquals(token, ADMIN_TOKEN)) return { admin: true, roles: ['web', 'mcp'], spaceIds: null, name: 'ADMIN_TOKEN', source: 'env' };
|
|
503
|
+
if (process.env.MCP_TOKEN && tokenEquals(token, MCP_TOKEN)) return { admin: false, roles: ['mcp'], spaceIds: null, name: 'MCP_TOKEN', source: 'env' };
|
|
504
|
+
let row = null;
|
|
505
|
+
try { row = db.prepare('SELECT * FROM api_tokens WHERE token_hash = ?').get(tokenHash(token)); } catch {}
|
|
506
|
+
if (!row || row.revoked_at) return null;
|
|
507
|
+
const now = Date.now();
|
|
508
|
+
if (!row.last_used_at || now - row.last_used_at > 60000) { try { db.prepare('UPDATE api_tokens SET last_used_at = ? WHERE id = ?').run(now, row.id); } catch {} }
|
|
509
|
+
const spaces = jsonArray(row.space_ids);
|
|
510
|
+
return { admin: false, roles: jsonArray(row.roles), spaceIds: spaces.length ? spaces : null, name: row.name, source: 'token', id: row.id };
|
|
511
|
+
}
|
|
512
|
+
const canWeb = (p) => !!p && (p.admin || p.roles.includes('web'));
|
|
513
|
+
const canMcp = (p) => !!p && (p.admin || p.roles.includes('mcp'));
|
|
514
|
+
function repoVisible(p, repo) { if (!p || p.admin || !p.spaceIds) return true; return (repo.spaceIds || []).some(id => p.spaceIds.includes(id)); }
|
|
515
|
+
function visibleSpaces(p) { if (!p || p.admin || !p.spaceIds) return settings.spaces; return settings.spaces.filter(s => s.builtin || p.spaceIds.includes(s.id)); }
|
|
516
|
+
function principalDto(p) { return { admin: p.admin, roles: p.roles, spaceIds: p.spaceIds, spaces: visibleSpaces(p).map(s => ({ id: s.id, name: s.name, builtin: !!s.builtin })), name: p.name, source: p.source }; }
|
|
517
|
+
async function staticServe(req, res, pathname) {
|
|
518
|
+
if (pathname === '/' || pathname === '/index.html') {
|
|
519
|
+
const f = path.join(HERE, 'public', 'index.html');
|
|
520
|
+
try { const b = await fs.readFile(f); res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); res.end(b); } catch { sendJson(res, 500, { error: 'missing public/index.html' }); }
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
sendJson(res, 404, { error: 'not_found' });
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/* ---------------- 仓库操作 ---------------- */
|
|
527
|
+
function findRepo(id) { return repos.find((r) => r.id === id); }
|
|
528
|
+
async function opClone(repo) {
|
|
529
|
+
// 防重复:已 indexed 且目录存在 → 直接返回(不重复 clone,也不把已成功状态打回 failed)
|
|
530
|
+
if (repo.status === 'indexed' && repo.localPath && fsSync.existsSync(repo.localPath)) return true;
|
|
531
|
+
repo.status = 'cloning'; await saveRepos();
|
|
532
|
+
const dest = path.join(settings.cloneBaseDir, sanitize(repo.name));
|
|
533
|
+
await fs.mkdir(settings.cloneBaseDir, { recursive: true }).catch(()=>{});
|
|
534
|
+
// 目标目录已存在(残留/上次失败)→ 清掉重新 clone,避免 "already exists" 失败
|
|
535
|
+
await fs.rm(dest, { recursive: true, force: true }).catch(()=>{});
|
|
536
|
+
const provider = getProviderById(repo.providerId) || getActiveProvider();
|
|
537
|
+
const url = provider ? getCloneAuthUrl(repo.gitUrl, provider) : repo.gitUrl;
|
|
538
|
+
const args = ['clone', '--depth', '1']; // 检索用途不需要历史:浅克隆通常 10x 提速(--depth 隐含 --single-branch)
|
|
539
|
+
if (repo.branch) args.push('--branch', repo.branch);
|
|
540
|
+
args.push(url, dest);
|
|
541
|
+
const r = await run('git', args);
|
|
542
|
+
if (!r.ok) { repo.status = 'failed'; repo.message = (r.err || r.out).slice(-400); await saveRepos(); return false; }
|
|
543
|
+
repo.localPath = dest; repo.status = 'cloned'; repo.message = r.out.slice(-200); await saveRepos();
|
|
544
|
+
// 主仓库 .gitignore 追加 worktrees/,豁免分支 worktree 被主索引扫入(多分支策略)
|
|
545
|
+
await fs.appendFile(path.join(dest, '.gitignore'), '\nworktrees/\n').catch(()=>{});
|
|
546
|
+
// clone 成功 → 自动建索引
|
|
547
|
+
return await opAnalyze(repo);
|
|
548
|
+
}
|
|
549
|
+
async function opAnalyze(repo) {
|
|
550
|
+
if (!repo.localPath || !fsSync.existsSync(repo.localPath)) { repo.status = 'failed'; repo.message = 'not cloned'; await saveRepos(); return false; }
|
|
551
|
+
repo.status = 'indexing'; repo.message = 'Queued: waiting for index slot...'; await saveRepos();
|
|
552
|
+
// 经全局队列执行 codegraph init(限流,避免多仓并发抢 CPU/IO + db 锁竞争)
|
|
553
|
+
return enqueue(async () => {
|
|
554
|
+
const [cmd, ...args] = parseBin(CODEGRAPH_BIN);
|
|
555
|
+
const r = await run(cmd, [...args, 'init', repo.localPath], { cwd: settings.cloneBaseDir });
|
|
556
|
+
repo.status = r.ok ? 'indexed' : 'failed';
|
|
557
|
+
repo.message = (r.ok ? (r.out || r.err) : (r.err || r.out)).slice(-400);
|
|
558
|
+
await saveRepos();
|
|
559
|
+
return r.ok;
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
// git pull:opSync 与 autoSync 共用。pull 失败 → 统一置 pull_failed、绝不继续索引(防"界面正常但索引是旧的")
|
|
563
|
+
// status 不得置 indexed;autoSync 目标含 pull_failed,下一轮自动重试
|
|
564
|
+
async function pullRepo(repo) {
|
|
565
|
+
const pull = await run('git', ['-C', repo.localPath, 'pull']); // 浅克隆下 fetch 保持 shallow 边界
|
|
566
|
+
if (pull.ok) return true;
|
|
567
|
+
repo.status = 'pull_failed';
|
|
568
|
+
repo.message = ('pull failed: ' + (pull.err || pull.out || '')).slice(-400);
|
|
569
|
+
await saveRepos();
|
|
570
|
+
log(`sync pull fail ${repo.name}: ${(pull.err || pull.out || '').slice(-150)}`);
|
|
571
|
+
return false;
|
|
572
|
+
}
|
|
573
|
+
async function opSync(repo) {
|
|
574
|
+
if (!repo.localPath || !fsSync.existsSync(repo.localPath)) { repo.status = 'failed'; repo.message = 'not cloned'; await saveRepos(); return false; }
|
|
575
|
+
repo.status = 'indexing'; repo.message = 'Pulling & syncing...'; await saveRepos();
|
|
576
|
+
if (!await pullRepo(repo)) return false;
|
|
577
|
+
const [cmd, ...args] = parseBin(CODEGRAPH_BIN);
|
|
578
|
+
const sc = await enqueue(() => run(cmd, [...args, 'sync', repo.localPath], { cwd: settings.cloneBaseDir })); // 增量重建索引(入队限流)
|
|
579
|
+
repo.status = sc.ok ? 'indexed' : 'failed';
|
|
580
|
+
repo.message = (sc.out || sc.err || '').slice(-400);
|
|
581
|
+
if (sc.ok) { // 记录同步/索引的 HEAD,便于判断索引新鲜度
|
|
582
|
+
const rev = await run('git', ['-C', repo.localPath, 'rev-parse', 'HEAD']);
|
|
583
|
+
if (rev.ok) repo.lastSyncedCommit = repo.lastIndexedCommit = rev.out.trim();
|
|
584
|
+
}
|
|
585
|
+
await saveRepos();
|
|
586
|
+
return sc.ok;
|
|
587
|
+
}
|
|
588
|
+
async function opStatus(repo) {
|
|
589
|
+
if (!repo.localPath || !fsSync.existsSync(repo.localPath)) { repo.status = 'failed'; repo.message = 'not cloned'; await saveRepos(); return false; }
|
|
590
|
+
const [cmd, ...args] = parseBin(CODEGRAPH_BIN);
|
|
591
|
+
const r = await run(cmd, [...args, 'status'], { cwd: repo.localPath });
|
|
592
|
+
repo.status = r.ok ? repo.status || 'indexed' : 'failed';
|
|
593
|
+
repo.message = (r.out || r.err).slice(-400);
|
|
594
|
+
return r.ok;
|
|
595
|
+
}
|
|
596
|
+
async function opRemove(repo) {
|
|
597
|
+
const tryRemove = async () => {
|
|
598
|
+
// 1. 先解所有 worktree 锁(SQLite db 持有者),否则 Windows 上 EBUSY
|
|
599
|
+
for (const w of (repo.worktrees || [])) {
|
|
600
|
+
if (w.localPath && fsSync.existsSync(w.localPath)) {
|
|
601
|
+
await run('git', ['worktree', 'remove', '--force', w.localPath], { cwd: repo.localPath }).catch(()=>{});
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
if (repo.localPath && fsSync.existsSync(repo.localPath)) {
|
|
605
|
+
// 主仓库自身若是 worktree 也解一下
|
|
606
|
+
await run('git', ['worktree', 'remove', '--force', repo.localPath], {}).catch(()=>{});
|
|
607
|
+
await fs.rm(repo.localPath, { recursive: true, force: true });
|
|
608
|
+
}
|
|
609
|
+
};
|
|
610
|
+
try {
|
|
611
|
+
await tryRemove();
|
|
612
|
+
} catch (e) {
|
|
613
|
+
if (/EBUSY/i.test(e.message)) {
|
|
614
|
+
// SQLite db 仍被锁(多半是另一 codegraph 实例/本进程 watchdog 占着),后台延迟重试
|
|
615
|
+
log('remove EBUSY, retry later', repo.localPath);
|
|
616
|
+
setTimeout(() => tryRemove().catch(err => log('remove retry failed', err.message)), 5000);
|
|
617
|
+
} else {
|
|
618
|
+
log('remove dir failed', e.message);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
const i = repos.findIndex((r) => r.id === repo.id);
|
|
622
|
+
if (i >= 0) repos.splice(i, 1);
|
|
623
|
+
await saveRepos();
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// 多分支策略:确保某个分支的 worktree 已建好并索引。
|
|
627
|
+
// 返回 { ready:true, localPath } 或 { ready:false, pending:true }(后台异步建,调用方应提示"稍后重试")
|
|
628
|
+
async function opEnsureWorktree(repo, branch) {
|
|
629
|
+
if (!repo.localPath || !fsSync.existsSync(repo.localPath)) return { ready: false, error: '主仓库尚未克隆' };
|
|
630
|
+
const existing = (repo.worktrees || []).find((w) => w.branch === branch);
|
|
631
|
+
if (existing) {
|
|
632
|
+
if (existing.status === 'indexed' && existing.localPath && fsSync.existsSync(existing.localPath)) return { ready: true, localPath: existing.localPath };
|
|
633
|
+
if (existing.status === 'indexing') return { ready: false, pending: true };
|
|
634
|
+
if (existing.status === 'failed') { /* 重建 */ }
|
|
635
|
+
}
|
|
636
|
+
// 触发后台异步建:fetch 分支引用 → worktree add → codegraph init
|
|
637
|
+
const wt = existing || { branch, localPath: path.join(repo.localPath, 'worktrees', sanitize(branch)), status: 'registered', message: '' };
|
|
638
|
+
if (!existing) { repo.worktrees = repo.worktrees || []; repo.worktrees.push(wt); }
|
|
639
|
+
wt.status = 'indexing'; wt.message = 'Creating worktree & indexing...'; await saveRepos();
|
|
640
|
+
// 构建全程持仓锁:fetch/worktree add/codegraph init 不与该仓其他操作交叉
|
|
641
|
+
withRepoLock(repo.id, async () => {
|
|
642
|
+
try {
|
|
643
|
+
// 主仓库 .gitignore 追加 worktrees/(豁免分支 worktree 被主索引扫入;兼容已存在的老仓库)
|
|
644
|
+
const gi = path.join(repo.localPath, '.gitignore');
|
|
645
|
+
let giContent = '';
|
|
646
|
+
try { giContent = await fs.readFile(gi, 'utf8'); } catch {}
|
|
647
|
+
if (!giContent.split('\n').map(s => s.trim()).includes('worktrees/')) {
|
|
648
|
+
await fs.appendFile(gi, (giContent && !giContent.endsWith('\n') ? '\n' : '') + 'worktrees/\n').catch(()=>{});
|
|
649
|
+
}
|
|
650
|
+
// fetch 分支引用(浅 fetch 保持 shallow 边界;若远端无此分支,给出友好提示而非堆 git 原始错误)
|
|
651
|
+
const fetchR = await run('git', ['fetch', '--depth', '1', 'origin', `${branch}:refs/remotes/origin/${branch}`], { cwd: repo.localPath });
|
|
652
|
+
if (!fetchR.ok) {
|
|
653
|
+
const errMsg = (fetchR.err || '').toLowerCase();
|
|
654
|
+
if (errMsg.includes('couldn') && errMsg.includes('remote ref')) {
|
|
655
|
+
throw new Error(`远端不存在分支 "${branch}"(请确认分支名拼写与远程分支)`);
|
|
656
|
+
}
|
|
657
|
+
throw new Error('fetch branch failed: ' + (fetchR.err || '').slice(-200));
|
|
658
|
+
}
|
|
659
|
+
// worktree add:若因残留/注册表脏失败,先 prune + 清注册 + 删目录再重试
|
|
660
|
+
let addR = await run('git', ['worktree', 'add', '-q', wt.localPath, `origin/${branch}`], { cwd: repo.localPath });
|
|
661
|
+
if (!addR.ok && /already exists|already registered|missing worktree|missing but|not a valid|is already checked out/i.test(addR.err || addR.out || '')) {
|
|
662
|
+
await run('git', ['worktree', 'prune', '--expire', 'now'], { cwd: repo.localPath }).catch(()=>{});
|
|
663
|
+
await run('git', ['worktree', 'prune'], { cwd: repo.localPath });
|
|
664
|
+
await fs.rm(wt.localPath, { recursive: true, force: true }).catch(()=>{});
|
|
665
|
+
// 清掉 git 内部 worktrees 注册目录(防 "missing worktree" 残留注册)
|
|
666
|
+
await fs.rm(path.join(repo.localPath, '.git', 'worktrees', sanitize(branch)), { recursive: true, force: true }).catch(()=>{});
|
|
667
|
+
addR = await run('git', ['worktree', 'add', '-q', wt.localPath, `origin/${branch}`], { cwd: repo.localPath });
|
|
668
|
+
}
|
|
669
|
+
if (!addR.ok) throw new Error('worktree add failed: ' + (addR.err || addR.out || '').slice(-200));
|
|
670
|
+
const [cmd, ...cargs] = parseBin(CODEGRAPH_BIN);
|
|
671
|
+
const anR = await enqueue(() => run(cmd, [...cargs, 'init', wt.localPath], { cwd: path.dirname(repo.localPath) })); // 入队限流
|
|
672
|
+
wt.status = anR.ok ? 'indexed' : 'failed';
|
|
673
|
+
wt.message = anR.ok ? (anR.out || '').slice(-200) : (anR.err || '').slice(-200);
|
|
674
|
+
} catch (e) {
|
|
675
|
+
wt.status = 'failed'; wt.message = e.message;
|
|
676
|
+
}
|
|
677
|
+
await saveRepos();
|
|
678
|
+
log(`worktree ${repo.name}@${branch} -> ${wt.status}`);
|
|
679
|
+
}).catch(() => {});
|
|
680
|
+
return { ready: false, pending: true };
|
|
681
|
+
}
|
|
682
|
+
export { opEnsureWorktree };
|
|
683
|
+
|
|
684
|
+
/* ---------------- 索引新鲜度自动刷新(SYNC_HOURS) ---------------- */
|
|
685
|
+
async function autoSync() {
|
|
686
|
+
const targets = repos.filter((r) => (r.status === 'indexed' || r.status === 'pull_failed') && r.localPath);
|
|
687
|
+
if (!targets.length) return;
|
|
688
|
+
log(`auto-sync: ${targets.length} 个已索引仓库 git pull + codegraph sync`);
|
|
689
|
+
// 并发 pull(网络型)+ 经队列限流 sync(CPU/IO 型);每仓持锁串行,避免与手动操作交叉
|
|
690
|
+
await Promise.all(targets.map((repo) => withRepoLock(repo.id, async () => {
|
|
691
|
+
try {
|
|
692
|
+
if (!await pullRepo(repo)) return; // pull 失败不索引,下轮重试
|
|
693
|
+
const [cmd, ...args] = parseBin(CODEGRAPH_BIN);
|
|
694
|
+
const sc = await enqueue(() => run(cmd, [...args, 'sync', repo.localPath], { cwd: settings.cloneBaseDir }));
|
|
695
|
+
log(`sync ${repo.name} -> ${sc.ok ? 'ok' : 'fail ' + (sc.err || '').slice(-150)}`);
|
|
696
|
+
} catch (e) { log(`sync error ${repo.name}: ${e.message}`); }
|
|
697
|
+
})));
|
|
698
|
+
}
|
|
699
|
+
// 有效同步频率:settings.autoSyncHours(配置页可调)> env SYNC_HOURS > 默认6;0=关闭
|
|
700
|
+
function getSyncHours() {
|
|
701
|
+
const s = settings.autoSyncHours;
|
|
702
|
+
if (typeof s === 'number' && s >= 0) return s;
|
|
703
|
+
const e = parseFloat(process.env.SYNC_HOURS || '6');
|
|
704
|
+
return Number.isFinite(e) && e >= 0 ? e : 6;
|
|
705
|
+
}
|
|
706
|
+
let syncTimer = null;
|
|
707
|
+
function scheduleSync() {
|
|
708
|
+
if (syncTimer) clearInterval(syncTimer);
|
|
709
|
+
syncTimer = null;
|
|
710
|
+
const h = getSyncHours();
|
|
711
|
+
if (h > 0) syncTimer = setInterval(autoSync, h * 3600 * 1000);
|
|
712
|
+
}
|
|
713
|
+
scheduleSync();
|
|
714
|
+
|
|
715
|
+
/* ---------------- 设置出参脱敏 ---------------- */
|
|
716
|
+
const TOKEN_MASK = '••••';
|
|
717
|
+
// token 出参一律脱敏(掩码+末4位),绝不返回明文;PUT 回传掩码值会被忽略、不覆盖真实 token
|
|
718
|
+
function maskToken(t) { return t ? TOKEN_MASK + String(t).slice(-4) : ''; }
|
|
719
|
+
function toSettingsDto(s) { return { ...s, providers: (s.providers || []).map(p => ({ ...p, token: maskToken(p.token) })) }; }
|
|
720
|
+
|
|
721
|
+
/* ---------------- 路由 ---------------- */
|
|
722
|
+
const server = http.createServer(async (req, res) => {
|
|
723
|
+
try {
|
|
724
|
+
let pathname = req.url.split('?')[0];
|
|
725
|
+
const query = Object.fromEntries(new URLSearchParams(req.url.split('?')[1] || ''));
|
|
726
|
+
// BASE_PATH 前缀部署:剥掉前缀后按原路由匹配;前缀外一律 404(未设置时行为不变)
|
|
727
|
+
if (BASE_PATH) {
|
|
728
|
+
if (pathname === BASE_PATH) { res.writeHead(301, { Location: BASE_PATH + '/' }); res.end(); return; }
|
|
729
|
+
if (!pathname.startsWith(BASE_PATH + '/')) return sendJson(res, 404, { error: 'not_found' });
|
|
730
|
+
pathname = pathname.slice(BASE_PATH.length);
|
|
731
|
+
}
|
|
732
|
+
// CORS 预检最先放行(不做鉴权;Allow-Headers 必须含 MCP-Protocol-Version,否则浏览器直连失败)
|
|
733
|
+
if (req.method === 'OPTIONS') {
|
|
734
|
+
res.writeHead(204, {
|
|
735
|
+
'Access-Control-Allow-Origin': '*',
|
|
736
|
+
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
|
|
737
|
+
'Access-Control-Allow-Headers': 'Content-Type, Authorization, MCP-Protocol-Version, Mcp-Session-Id, Last-Event-ID',
|
|
738
|
+
'Access-Control-Max-Age': '86400'
|
|
739
|
+
});
|
|
740
|
+
res.end();
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
// 跨源请求(Origin 与 Host 不符)一律拒绝且不回 CORS 头——防任意网页驱使浏览器打本地服务
|
|
744
|
+
if (!originMatchesHost(req)) {
|
|
745
|
+
log('warn: blocked cross-origin request', req.headers['origin'], req.url);
|
|
746
|
+
return sendJson(res, 401, { error: 'cross_origin_forbidden' });
|
|
747
|
+
}
|
|
748
|
+
// 健康检查(无鉴权,供 systemd/探针/监控)
|
|
749
|
+
if (pathname === '/healthz') return sendJson(res, 200, { ok: true, repos: repos.length, uptime: Math.round(process.uptime()) });
|
|
750
|
+
// MCP 端点(GET /mcp 走 SSE、POST 走消息;/mcp/message 走消息)—— 路由级守卫
|
|
751
|
+
if (pathname === '/mcp' || pathname === '/mcp/message') {
|
|
752
|
+
const principal = resolvePrincipal(req, query, true);
|
|
753
|
+
if (!canMcp(principal)) return sendJson(res, 401, { error: principal ? 'mcp_role_required' : 'unauthorized' });
|
|
754
|
+
if (pathname === '/mcp' && req.method === 'GET') return handleSseConnection(req, res);
|
|
755
|
+
const visible = principal.spaceIds ? repos.filter(r => repoVisible(principal, r)) : repos;
|
|
756
|
+
return await handleMcpMessage(req, res, visible);
|
|
757
|
+
}
|
|
758
|
+
if (!pathname.startsWith('/api/')) return staticServe(req, res, pathname); // 页面本身公开(否则前端拿不到令牌表单)
|
|
759
|
+
const principal = resolvePrincipal(req, {}, false);
|
|
760
|
+
if (!canWeb(principal)) return sendJson(res, 401, { error: principal ? 'web_role_required' : 'unauthorized' });
|
|
761
|
+
if (pathname === '/api/auth/me' && req.method === 'GET') return sendJson(res, 200, principalDto(principal));
|
|
762
|
+
|
|
763
|
+
// 访问令牌管理(仅 admin)
|
|
764
|
+
if (pathname === '/api/tokens') {
|
|
765
|
+
if (!principal.admin) return sendJson(res, 403, { error: 'admin_required' });
|
|
766
|
+
if (req.method === 'GET') return sendJson(res, 200, db.prepare('SELECT * FROM api_tokens ORDER BY created_at DESC').all().map(tokenRowDto));
|
|
767
|
+
if (req.method === 'POST') {
|
|
768
|
+
const b = await readBody(req);
|
|
769
|
+
const name = String(b.name || '').trim().slice(0, 80);
|
|
770
|
+
if (!name) return sendJson(res, 400, { error: 'missing name' });
|
|
771
|
+
const roles = [...new Set((Array.isArray(b.roles) ? b.roles : []).filter(r => r === 'web' || r === 'mcp'))];
|
|
772
|
+
if (!roles.length) return sendJson(res, 400, { error: 'missing role' });
|
|
773
|
+
const validSpaces = new Set(settings.spaces.filter(s => !s.builtin).map(s => s.id));
|
|
774
|
+
const requested = Array.isArray(b.spaceIds) ? b.spaceIds : [];
|
|
775
|
+
const spaceIds = [...new Set(requested.filter(id => validSpaces.has(id)))];
|
|
776
|
+
if (requested.length && !spaceIds.length) return sendJson(res, 400, { error: 'invalid_space' });
|
|
777
|
+
const token = 'cgm_' + crypto.randomBytes(24).toString('base64url');
|
|
778
|
+
const id = crypto.randomUUID();
|
|
779
|
+
db.prepare('INSERT INTO api_tokens (id, name, token_hash, roles, space_ids, created_at) VALUES (?, ?, ?, ?, ?, ?)').run(id, name, tokenHash(token), JSON.stringify(roles), JSON.stringify(spaceIds), Date.now());
|
|
780
|
+
return sendJson(res, 201, { ...tokenRowDto(db.prepare('SELECT * FROM api_tokens WHERE id = ?').get(id)), token });
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
const tkm = pathname.match(/^\/api\/tokens\/([^/]+)$/);
|
|
784
|
+
if (tkm && req.method === 'DELETE') {
|
|
785
|
+
if (!principal.admin) return sendJson(res, 403, { error: 'admin_required' });
|
|
786
|
+
const row = db.prepare('SELECT id FROM api_tokens WHERE id = ?').get(tkm[1]);
|
|
787
|
+
if (!row) return sendJson(res, 404, { error: 'no_such_token' });
|
|
788
|
+
db.prepare('UPDATE api_tokens SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL').run(Date.now(), tkm[1]);
|
|
789
|
+
return sendJson(res, 200, { ok: true });
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
// 设置(全局)
|
|
793
|
+
if (pathname === '/api/settings' && req.method === 'GET') {
|
|
794
|
+
const dto = toSettingsDto(settings);
|
|
795
|
+
if (!principal.admin) {
|
|
796
|
+
dto.providers = dto.providers.filter(p => p.id === settings.activeProviderId);
|
|
797
|
+
dto.spaces = visibleSpaces(principal);
|
|
798
|
+
}
|
|
799
|
+
return sendJson(res, 200, dto);
|
|
800
|
+
}
|
|
801
|
+
if (pathname === '/api/settings' && req.method === 'PUT') {
|
|
802
|
+
if (!principal.admin) return sendJson(res, 403, { error: 'admin_required' });
|
|
803
|
+
const b = await readBody(req);
|
|
804
|
+
// 只更新全局字段,providers 通过专用端点管理
|
|
805
|
+
if (b.cloneBaseDir !== undefined) settings.cloneBaseDir = String(b.cloneBaseDir || '').trim() || CLONE_BASE_DIR_DEFAULT;
|
|
806
|
+
if (b.activeProviderId !== undefined) settings.activeProviderId = b.activeProviderId;
|
|
807
|
+
if (b.autoSyncHours !== undefined) {
|
|
808
|
+
settings.autoSyncHours = (b.autoSyncHours === null || b.autoSyncHours === '') ? null : Number(b.autoSyncHours);
|
|
809
|
+
scheduleSync(); // 频率变更立即重建定时器
|
|
810
|
+
}
|
|
811
|
+
await saveSettings(); return sendJson(res, 200, toSettingsDto(settings));
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
// 空间(标签/分组)CRUD
|
|
815
|
+
if (pathname === '/api/spaces' && req.method === 'GET') {
|
|
816
|
+
const sorted = [...visibleSpaces(principal)].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
|
817
|
+
return sendJson(res, 200, sorted);
|
|
818
|
+
}
|
|
819
|
+
if (pathname === '/api/spaces' && req.method === 'POST') {
|
|
820
|
+
if (!principal.admin) return sendJson(res, 403, { error: 'admin_required' });
|
|
821
|
+
const b = await readBody(req);
|
|
822
|
+
const name = (b.name || '').trim();
|
|
823
|
+
if (!name) return sendJson(res, 400, { error: 'missing name' });
|
|
824
|
+
if (settings.spaces.some(s => s.name === name)) return sendJson(res, 409, { error: 'space exists' });
|
|
825
|
+
const space = { id: crypto.randomUUID(), name, builtin: false, order: (settings.spaces.reduce((m, s) => Math.max(m, s.order ?? 0), 0) + 1) };
|
|
826
|
+
settings.spaces.push(space);
|
|
827
|
+
await saveSettings();
|
|
828
|
+
return sendJson(res, 201, space);
|
|
829
|
+
}
|
|
830
|
+
const spm = pathname.match(/^\/api\/spaces\/([^/]+)$/);
|
|
831
|
+
if (spm) {
|
|
832
|
+
if (!principal.admin) return sendJson(res, 403, { error: 'admin_required' });
|
|
833
|
+
const space = settings.spaces.find(s => s.id === spm[1]);
|
|
834
|
+
if (!space) return sendJson(res, 404, { error: 'no_such_space' });
|
|
835
|
+
if (req.method === 'PUT') {
|
|
836
|
+
const b = await readBody(req);
|
|
837
|
+
if (b.name !== undefined && !space.builtin) space.name = (b.name || '').trim() || space.name;
|
|
838
|
+
if (b.order !== undefined) space.order = Number(b.order);
|
|
839
|
+
await saveSettings();
|
|
840
|
+
return sendJson(res, 200, space);
|
|
841
|
+
}
|
|
842
|
+
if (req.method === 'DELETE') {
|
|
843
|
+
if (space.builtin) return sendJson(res, 400, { error: 'cannot delete builtin space' });
|
|
844
|
+
// 解除所有仓库对该空间的绑定
|
|
845
|
+
for (const r of repos) r.spaceIds = (r.spaceIds || []).filter(id => id !== space.id);
|
|
846
|
+
settings.spaces = settings.spaces.filter(s => s.id !== space.id);
|
|
847
|
+
// 从访问令牌的空间范围中移除已删空间
|
|
848
|
+
try {
|
|
849
|
+
const rows = db.prepare('SELECT id, space_ids FROM api_tokens WHERE revoked_at IS NULL').all();
|
|
850
|
+
const upd = db.prepare('UPDATE api_tokens SET space_ids = ? WHERE id = ?');
|
|
851
|
+
for (const t of rows) {
|
|
852
|
+
const ids = jsonArray(t.space_ids);
|
|
853
|
+
const next = ids.filter(id => id !== space.id);
|
|
854
|
+
if (next.length !== ids.length) upd.run(JSON.stringify(next), t.id);
|
|
855
|
+
}
|
|
856
|
+
} catch {}
|
|
857
|
+
await saveRepos(); await saveSettings();
|
|
858
|
+
return sendJson(res, 200, { ok: true });
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
// 空间 ↔ 仓库绑定
|
|
862
|
+
const spbind = pathname.match(/^\/api\/spaces\/([^/]+)\/repos$/);
|
|
863
|
+
if (spbind && req.method === 'POST') {
|
|
864
|
+
if (!principal.admin) return sendJson(res, 403, { error: 'admin_required' });
|
|
865
|
+
const space = settings.spaces.find(s => s.id === spbind[1]);
|
|
866
|
+
if (!space) return sendJson(res, 404, { error: 'no_such_space' });
|
|
867
|
+
const b = await readBody(req);
|
|
868
|
+
const repoId = b.repoId;
|
|
869
|
+
const repo = repos.find(r => r.id === repoId);
|
|
870
|
+
if (!repo) return sendJson(res, 404, { error: 'no_such_repo' });
|
|
871
|
+
repo.spaceIds = [...new Set([...(repo.spaceIds || []), space.id])];
|
|
872
|
+
await saveRepos();
|
|
873
|
+
return sendJson(res, 200, repo);
|
|
874
|
+
}
|
|
875
|
+
const spunbind = pathname.match(/^\/api\/spaces\/([^/]+)\/repos\/([^/]+)$/);
|
|
876
|
+
if (spunbind && req.method === 'DELETE') {
|
|
877
|
+
if (!principal.admin) return sendJson(res, 403, { error: 'admin_required' });
|
|
878
|
+
const repo = repos.find(r => r.id === spunbind[2]);
|
|
879
|
+
if (!repo) return sendJson(res, 404, { error: 'no_such_repo' });
|
|
880
|
+
repo.spaceIds = (repo.spaceIds || []).filter(id => id !== spunbind[1]);
|
|
881
|
+
await saveRepos();
|
|
882
|
+
return sendJson(res, 200, repo);
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
// 提供商 CRUD
|
|
886
|
+
if (pathname === '/api/providers' && req.method === 'GET') {
|
|
887
|
+
// 返回提供商列表(隐藏 token);非 admin 只能看到当前激活提供商
|
|
888
|
+
let list = settings.providers.map(p => ({ ...p, token: p.token ? '***' : '' }));
|
|
889
|
+
if (!principal.admin) list = list.filter(p => p.id === settings.activeProviderId);
|
|
890
|
+
return sendJson(res, 200, list);
|
|
891
|
+
}
|
|
892
|
+
if (pathname === '/api/providers' && req.method === 'POST') {
|
|
893
|
+
if (!principal.admin) return sendJson(res, 403, { error: 'admin_required' });
|
|
894
|
+
const b = await readBody(req);
|
|
895
|
+
if (!b.type || !['gitlab','gitee','github'].includes(b.type)) return sendJson(res, 400, { error: 'invalid provider type' });
|
|
896
|
+
const provider = {
|
|
897
|
+
id: crypto.randomUUID(),
|
|
898
|
+
type: b.type,
|
|
899
|
+
name: b.name || {gitlab:'GitLab',gitee:'Gitee',github:'GitHub'}[b.type],
|
|
900
|
+
url: b.url || '',
|
|
901
|
+
token: b.token || '',
|
|
902
|
+
createdAt: Date.now()
|
|
903
|
+
};
|
|
904
|
+
settings.providers.push(provider);
|
|
905
|
+
if (!settings.activeProviderId) settings.activeProviderId = provider.id;
|
|
906
|
+
await saveSettings();
|
|
907
|
+
return sendJson(res, 201, { ...provider, token: provider.token ? '***' : '' });
|
|
908
|
+
}
|
|
909
|
+
if (pathname.match(/^\/api\/providers\/[^/]+$/) && req.method === 'PUT') {
|
|
910
|
+
if (!principal.admin) return sendJson(res, 403, { error: 'admin_required' });
|
|
911
|
+
const id = pathname.split('/')[3];
|
|
912
|
+
const provider = getProviderById(id);
|
|
913
|
+
if (!provider) return sendJson(res, 404, { error: 'provider not found' });
|
|
914
|
+
const b = await readBody(req);
|
|
915
|
+
if (b.name !== undefined) provider.name = b.name;
|
|
916
|
+
if (b.url !== undefined) provider.url = b.url;
|
|
917
|
+
// 回传脱敏掩码值 → 忽略,不得覆盖真实 token
|
|
918
|
+
if (b.token !== undefined && !String(b.token).startsWith(TOKEN_MASK)) provider.token = b.token;
|
|
919
|
+
await saveSettings();
|
|
920
|
+
return sendJson(res, 200, { ...provider, token: provider.token ? '***' : '' });
|
|
921
|
+
}
|
|
922
|
+
if (pathname.match(/^\/api\/providers\/[^/]+$/) && req.method === 'DELETE') {
|
|
923
|
+
if (!principal.admin) return sendJson(res, 403, { error: 'admin_required' });
|
|
924
|
+
const id = pathname.split('/')[3];
|
|
925
|
+
const i = settings.providers.findIndex(p => p.id === id);
|
|
926
|
+
if (i < 0) return sendJson(res, 404, { error: 'provider not found' });
|
|
927
|
+
settings.providers.splice(i, 1);
|
|
928
|
+
if (settings.activeProviderId === id) settings.activeProviderId = settings.providers[0]?.id || '';
|
|
929
|
+
await saveSettings();
|
|
930
|
+
return sendJson(res, 200, { ok: true });
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
// 提供商:测试连接
|
|
934
|
+
if (pathname === '/api/providers/check' && req.method === 'POST') {
|
|
935
|
+
if (!principal.admin) return sendJson(res, 403, { error: 'admin_required' });
|
|
936
|
+
const b = await readBody(req);
|
|
937
|
+
const provider = { type: b.type, url: b.url || '', token: b.token || '' };
|
|
938
|
+
if (!provider.type) return sendJson(res, 400, { error: 'missing provider type' });
|
|
939
|
+
if (provider.type !== 'github' && !provider.url) return sendJson(res, 200, { ok: false, msg: '请填写实例 URL' });
|
|
940
|
+
if (!provider.token) return sendJson(res, 200, { ok: false, msg: '请填写 Access Token' });
|
|
941
|
+
const result = await checkProvider(provider);
|
|
942
|
+
return sendJson(res, 200, result);
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
// 提供商:搜索项目(支持搜索全部)
|
|
946
|
+
if (pathname === '/api/providers/projects' && req.method === 'GET') {
|
|
947
|
+
if (!principal.admin) return sendJson(res, 403, { error: 'admin_required' });
|
|
948
|
+
try {
|
|
949
|
+
const providerId = query.providerId;
|
|
950
|
+
let projects = [];
|
|
951
|
+
if (providerId === 'all' || !providerId) {
|
|
952
|
+
// 搜索所有已配置的提供商
|
|
953
|
+
const searchPromises = settings.providers.map(async (provider) => {
|
|
954
|
+
try {
|
|
955
|
+
return await searchProjects(query.q, provider.id);
|
|
956
|
+
} catch (e) {
|
|
957
|
+
log(`search error for ${provider.name}:`, e);
|
|
958
|
+
return [];
|
|
959
|
+
}
|
|
960
|
+
});
|
|
961
|
+
const results = await Promise.all(searchPromises);
|
|
962
|
+
projects = results.flat();
|
|
963
|
+
} else {
|
|
964
|
+
projects = await searchProjects(query.q, providerId);
|
|
965
|
+
}
|
|
966
|
+
return sendJson(res, 200, projects);
|
|
967
|
+
} catch (e) { return sendJson(res, 200, { error: e.msg || String(e), status: e.status }); }
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
// 仓库注册表
|
|
971
|
+
if (pathname === '/api/repos' && req.method === 'GET') return sendJson(res, 200, principal.spaceIds ? repos.filter(r => repoVisible(principal, r)) : repos);
|
|
972
|
+
if (pathname === '/api/repos' && req.method === 'POST') {
|
|
973
|
+
if (!principal.admin) return sendJson(res, 403, { error: 'admin_required' });
|
|
974
|
+
const b = await readBody(req);
|
|
975
|
+
const spaceIds = Array.isArray(b.spaceIds) ? b.spaceIds : (b.spaceId ? [b.spaceId] : []);
|
|
976
|
+
// 去重:同 gitUrl 且已存在 → 仅追加空间绑定关系,不重复注册
|
|
977
|
+
const existing = b.gitUrl && repos.find(r => r.gitUrl === b.gitUrl && r.providerId === (b.providerId || settings.activeProviderId));
|
|
978
|
+
if (existing) {
|
|
979
|
+
const before = (existing.spaceIds || []).slice();
|
|
980
|
+
existing.spaceIds = [...new Set([...(existing.spaceIds || []), ...spaceIds])];
|
|
981
|
+
await saveRepos();
|
|
982
|
+
return sendJson(res, 200, { ...existing, _dedup: true, addedSpaces: existing.spaceIds.filter(id => !before.includes(id)) });
|
|
983
|
+
}
|
|
984
|
+
const id = crypto.randomUUID();
|
|
985
|
+
const repo = { id, name: b.name, providerId: b.providerId || settings.activeProviderId, providerType: b.providerType || '', gitUrl: b.gitUrl, branch: b.branch || '', localPath: null, status: 'registered', message: '', addedAt: Date.now(), worktrees: [], spaceIds };
|
|
986
|
+
repos.push(repo); await saveRepos();
|
|
987
|
+
return sendJson(res, 201, repo);
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
// 分支 worktree:GET 列出 / POST 触发创建 / DELETE 删除某分支(异步,返回 pending)
|
|
991
|
+
const wm = pathname.match(/^\/api\/repos\/([^/]+)\/worktree(?:\/([^/]+))?$/);
|
|
992
|
+
if (wm) {
|
|
993
|
+
const repo = findRepo(wm[1]);
|
|
994
|
+
if (!repo) return sendJson(res, 404, { error: 'no_such_repo' });
|
|
995
|
+
if (!repoVisible(principal, repo)) return sendJson(res, 403, { error: 'out_of_scope' });
|
|
996
|
+
if (req.method === 'GET') return sendJson(res, 200, repo.worktrees || []);
|
|
997
|
+
if (req.method === 'POST') {
|
|
998
|
+
const b = await readBody(req);
|
|
999
|
+
const branch = (b.branch || '').trim();
|
|
1000
|
+
if (!branch) return sendJson(res, 400, { error: 'missing branch' });
|
|
1001
|
+
const result = await opEnsureWorktree(repo, branch);
|
|
1002
|
+
return sendJson(res, result.ready ? 200 : 202, { branch, status: (repo.worktrees||[]).find(w=>w.branch===branch)?.status, ...result });
|
|
1003
|
+
}
|
|
1004
|
+
if (req.method === 'DELETE') {
|
|
1005
|
+
const branch = decodeURIComponent(wm[2] || '').trim();
|
|
1006
|
+
if (!branch) return sendJson(res, 400, { error: 'missing branch' });
|
|
1007
|
+
const wt = (repo.worktrees || []).find((w) => w.branch === branch);
|
|
1008
|
+
if (!wt) return sendJson(res, 404, { error: 'no_such_worktree' });
|
|
1009
|
+
// 持仓锁:解锁(SQLite db 锁)→ 删目录 → 移出列表,避免与构建/其他操作交叉
|
|
1010
|
+
await withRepoLock(repo.id, async () => {
|
|
1011
|
+
if (wt.localPath && fsSync.existsSync(wt.localPath)) {
|
|
1012
|
+
await run('git', ['worktree', 'remove', '--force', wt.localPath], { cwd: repo.localPath }).catch(()=>{});
|
|
1013
|
+
await fs.rm(wt.localPath, { recursive: true, force: true }).catch(()=>{});
|
|
1014
|
+
await fs.rm(path.join(repo.localPath, '.git', 'worktrees', sanitize(branch)), { recursive: true, force: true }).catch(()=>{});
|
|
1015
|
+
}
|
|
1016
|
+
repo.worktrees = (repo.worktrees || []).filter((w) => w.branch !== branch);
|
|
1017
|
+
await saveRepos();
|
|
1018
|
+
});
|
|
1019
|
+
return sendJson(res, 200, { ok: true, branch });
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
// 添加本地仓库
|
|
1024
|
+
if (pathname === '/api/repos/local' && req.method === 'POST') {
|
|
1025
|
+
if (!principal.admin) return sendJson(res, 403, { error: 'admin_required' });
|
|
1026
|
+
const b = await readBody(req);
|
|
1027
|
+
const localPath = (b.path || '').trim();
|
|
1028
|
+
if (!localPath) return sendJson(res, 400, { error: 'missing path' });
|
|
1029
|
+
|
|
1030
|
+
// 检查目录是否存在
|
|
1031
|
+
try { await fs.access(localPath); } catch { return sendJson(res, 400, { error: 'directory not found' }); }
|
|
1032
|
+
|
|
1033
|
+
// 检查是否是 git 仓库
|
|
1034
|
+
const gitDir = path.join(localPath, '.git');
|
|
1035
|
+
try { await fs.access(gitDir); } catch { return sendJson(res, 400, { error: 'not a git repository (no .git directory)' }); }
|
|
1036
|
+
|
|
1037
|
+
// 获取仓库信息
|
|
1038
|
+
let name = b.name || path.basename(localPath);
|
|
1039
|
+
let gitUrl = '';
|
|
1040
|
+
let branch = '';
|
|
1041
|
+
|
|
1042
|
+
try {
|
|
1043
|
+
// 尝试获取 remote origin URL
|
|
1044
|
+
const remoteResult = await run('git', ['remote', 'get-url', 'origin'], { cwd: localPath });
|
|
1045
|
+
if (remoteResult.ok) gitUrl = remoteResult.out.trim();
|
|
1046
|
+
} catch {}
|
|
1047
|
+
|
|
1048
|
+
try {
|
|
1049
|
+
// 尝试获取当前分支
|
|
1050
|
+
const branchResult = await run('git', ['branch', '--show-current'], { cwd: localPath });
|
|
1051
|
+
if (branchResult.ok) branch = branchResult.out.trim();
|
|
1052
|
+
} catch {}
|
|
1053
|
+
|
|
1054
|
+
const id = crypto.randomUUID();
|
|
1055
|
+
const repo = {
|
|
1056
|
+
id, name, providerId: '', providerType: 'local',
|
|
1057
|
+
gitUrl, branch, localPath, status: 'cloned', message: 'Added from local directory',
|
|
1058
|
+
addedAt: Date.now()
|
|
1059
|
+
};
|
|
1060
|
+
repos.push(repo); await saveRepos();
|
|
1061
|
+
return sendJson(res, 201, repo);
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
// 浏览服务器目录
|
|
1065
|
+
if (pathname === '/api/browse' && req.method === 'GET') {
|
|
1066
|
+
if (!principal.admin) return sendJson(res, 403, { error: 'admin_required' });
|
|
1067
|
+
const browsePath = (query.path || '').trim() || '/';
|
|
1068
|
+
try {
|
|
1069
|
+
const entries = await fs.readdir(browsePath, { withFileTypes: true });
|
|
1070
|
+
const dirs = [];
|
|
1071
|
+
for (const entry of entries) {
|
|
1072
|
+
if (entry.isDirectory() && !entry.name.startsWith('.')) {
|
|
1073
|
+
const fullPath = path.join(browsePath, entry.name);
|
|
1074
|
+
let isGitRepo = false;
|
|
1075
|
+
try {
|
|
1076
|
+
await fs.access(path.join(fullPath, '.git'));
|
|
1077
|
+
isGitRepo = true;
|
|
1078
|
+
} catch {}
|
|
1079
|
+
dirs.push({ name: entry.name, path: fullPath, isGitRepo });
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
// 按名称排序,git 仓库优先
|
|
1083
|
+
dirs.sort((a, b) => {
|
|
1084
|
+
if (a.isGitRepo && !b.isGitRepo) return -1;
|
|
1085
|
+
if (!a.isGitRepo && b.isGitRepo) return 1;
|
|
1086
|
+
return a.name.localeCompare(b.name);
|
|
1087
|
+
});
|
|
1088
|
+
return sendJson(res, 200, { path: browsePath, entries: dirs });
|
|
1089
|
+
} catch (e) {
|
|
1090
|
+
return sendJson(res, 400, { error: 'cannot read directory: ' + e.message });
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
// 单仓操作
|
|
1095
|
+
const m = pathname.match(/^\/api\/repos\/([^/]+)\/(clone|analyze|status|remove|sync)$/);
|
|
1096
|
+
if (m) {
|
|
1097
|
+
const repo = findRepo(m[1]);
|
|
1098
|
+
if (!repo) return sendJson(res, 404, { error: 'no_such_repo' });
|
|
1099
|
+
if (!repoVisible(principal, repo)) return sendJson(res, 403, { error: 'out_of_scope' });
|
|
1100
|
+
const action = m[2];
|
|
1101
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
1102
|
+
res.write(JSON.stringify({ ok: true, msg: 'started' }));
|
|
1103
|
+
res.end();
|
|
1104
|
+
// 同仓操作互斥:并发请求(双击/analyze+remove 同发)在仓锁上排队串行,不交叉执行
|
|
1105
|
+
withRepoLock(repo.id, async () => {
|
|
1106
|
+
try {
|
|
1107
|
+
let ok = false;
|
|
1108
|
+
if (action === 'clone') ok = await opClone(repo);
|
|
1109
|
+
else if (action === 'analyze') ok = await opAnalyze(repo);
|
|
1110
|
+
else if (action === 'status') ok = await opStatus(repo);
|
|
1111
|
+
else if (action === 'sync') ok = await opSync(repo);
|
|
1112
|
+
else if (action === 'remove') { await opRemove(repo); return; }
|
|
1113
|
+
log(`repo ${repo.name} ${action} -> ${ok ? 'ok' : 'fail'}`);
|
|
1114
|
+
} catch (e) { log('op error', e); }
|
|
1115
|
+
});
|
|
1116
|
+
return;
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
sendJson(res, 404, { error: 'not_found' });
|
|
1120
|
+
} catch (e) {
|
|
1121
|
+
if (e.statusCode === 413) { log('request body too large'); if (!res.headersSent) sendJson(res, 413, { error: 'payload_too_large' }); return; }
|
|
1122
|
+
log('req error', e);
|
|
1123
|
+
if (!res.headersSent) sendJson(res, 500, { error: String(e) });
|
|
1124
|
+
}
|
|
1125
|
+
});
|
|
1126
|
+
|
|
1127
|
+
await loadAll();
|
|
1128
|
+
// fail closed 兜底:令牌引导失败(auth.json 不可写等)导致完全无令牌且对外监听 → 拒绝启动
|
|
1129
|
+
const LOOPBACK_HOSTS = ['127.0.0.1', 'localhost', '::1'];
|
|
1130
|
+
if (!LOOPBACK_HOSTS.includes(HOST) && !ADMIN_TOKEN && !MCP_TOKEN) {
|
|
1131
|
+
console.error(`[cgm] 拒绝启动:HOST=${HOST} 是对外监听,必须配置 ADMIN_TOKEN 或 MCP_TOKEN(否则局域网内任何人可操作仓库与代码)。本地开发请保持 HOST=127.0.0.1。`);
|
|
1132
|
+
process.exit(1);
|
|
1133
|
+
}
|
|
1134
|
+
server.listen(PORT, HOST, () => {
|
|
1135
|
+
log(`CodeGraph Manager on http://${HOST}:${PORT}`);
|
|
1136
|
+
log(`data dir: ${DATA_DIR}`);
|
|
1137
|
+
log(`codegraph bin: ${CODEGRAPH_BIN}`);
|
|
1138
|
+
if (BOOTSTRAP_TOKEN.generated) {
|
|
1139
|
+
log(`首次启动已生成默认访问令牌(已持久化到 ${BOOTSTRAP_TOKEN.file}):${ADMIN_TOKEN}`);
|
|
1140
|
+
log('远程/局域网访问请携带该令牌:Authorization: Bearer <token>,或在界面令牌框输入;本机回环访问默认免鉴权');
|
|
1141
|
+
}
|
|
1142
|
+
log(`auth: admin_token=${ADMIN_TOKEN ? (BOOTSTRAP_TOKEN.generated ? 'on(自动生成,见 auth.json)' : 'on') : 'off'}, mcp_token=${process.env.MCP_TOKEN ? 'on' : 'off(回落 ADMIN_TOKEN)'}, local_no_auth=${LOCAL_NO_AUTH ? 'on(回环免鉴权)' : 'off(本地也需令牌)'}, base_path=${BASE_PATH || '(none)'}, max_body=${MAX_BODY_BYTES}B`);
|
|
1143
|
+
log(`providers: ${settings.providers.length} configured`);
|
|
1144
|
+
});
|