@mebular/mcp 0.0.0-stage → 0.1.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/bin/mebular.mjs +1133 -0
- package/package.json +30 -4
- package/scripts/publish-manifest.mjs +44 -0
- package/src/admin.mjs +1320 -0
- package/src/apply-supervisor.mjs +48 -0
- package/src/build-sha.mjs +36 -0
- package/src/client.mjs +49 -0
- package/src/clients.mjs +1242 -0
- package/src/cloud-switch.mjs +258 -0
- package/src/config-apply.mjs +251 -0
- package/src/config-schema.mjs +690 -0
- package/src/config.mjs +265 -0
- package/src/fleet-events.mjs +207 -0
- package/src/home.mjs +16 -0
- package/src/jointoken.mjs +31 -0
- package/src/lan-host.mjs +36 -0
- package/src/profiles.mjs +681 -0
- package/src/provision.mjs +673 -0
- package/src/qr.mjs +4 -0
- package/src/runtime-file.mjs +91 -0
- package/src/serve.mjs +3478 -0
- package/src/server.mjs +416 -0
- package/src/service-control.mjs +107 -0
- package/src/service-facade.mjs +43 -0
- package/src/task-tools.mjs +45 -0
- package/src/tool-envelope.mjs +50 -0
- package/src/tools.mjs +397 -0
- package/README.md +0 -3
package/src/profiles.mjs
ADDED
|
@@ -0,0 +1,681 @@
|
|
|
1
|
+
// 忆云档案(profile)注册表:root 解析、v1 注册表读写、锁、默认条目合成、命名/指纹/路径安全。
|
|
2
|
+
//
|
|
3
|
+
// 红线:
|
|
4
|
+
// - 默认忆云 = root 自身(~/.mebular),原地保留、不迁移、不 symlink。
|
|
5
|
+
// - 注册表只存公开元数据(名字/相对路径/公开指纹),**不含**令牌、私钥、主密钥、TOTP 种子、恢复码。
|
|
6
|
+
// - 已注册档案永不删除;删除只允许「本次独占创建且尚未注册」的目录(由调用方校验)。
|
|
7
|
+
// - HTTP 不接受任意 home/path:id 只允许 `^[a-z0-9_]+$`,home 只允许 `.` / `profiles/<id>`。
|
|
8
|
+
|
|
9
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
10
|
+
import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
|
|
11
|
+
import { chmod, copyFile, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
12
|
+
import { homedir } from 'node:os';
|
|
13
|
+
import { isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
14
|
+
|
|
15
|
+
export const REGISTRY_FILE = 'profiles.json';
|
|
16
|
+
export const REGISTRY_LOCK_FILE = 'profiles.lock';
|
|
17
|
+
export const ACTIVE_LOCK_FILE = 'active.lock';
|
|
18
|
+
export const PROFILES_DIR = 'profiles';
|
|
19
|
+
export const DEFAULT_PROFILE_ID = 'default';
|
|
20
|
+
export const DEFAULT_PROFILE_NAME = '忆云 1';
|
|
21
|
+
export const REGISTRY_VERSION = 1;
|
|
22
|
+
|
|
23
|
+
const ID_RE = /^[a-z0-9_]+$/;
|
|
24
|
+
const PROFILE_HOME_RE = /^(.*)[\\/]profiles[\\/]([a-z0-9_]+)$/;
|
|
25
|
+
const FORBIDDEN_ENV = [
|
|
26
|
+
'MEBULAR_CONFIG',
|
|
27
|
+
'MEBULAR_STORAGE_PATH',
|
|
28
|
+
'MEBULAR_USER_MASTER_KEY',
|
|
29
|
+
'MEBULAR_USER_MASTER_KEY_FILE',
|
|
30
|
+
'MEBULAR_ENDPOINTS_FILE',
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
/** 构造带稳定错误码的异常(HTTP 层按 code 映射状态码)。 */
|
|
34
|
+
export function cloudError(code, message, extra = {}) {
|
|
35
|
+
const error = new Error(message);
|
|
36
|
+
error.code = code;
|
|
37
|
+
Object.assign(error, extra);
|
|
38
|
+
return error;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function registryPath(root) {
|
|
42
|
+
return join(root, REGISTRY_FILE);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function registryBakPath(root) {
|
|
46
|
+
return `${registryPath(root)}.bak`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function registryLockPath(root) {
|
|
50
|
+
return join(root, REGISTRY_LOCK_FILE);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function activeLockPath(root) {
|
|
54
|
+
return join(root, ACTIVE_LOCK_FILE);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function defaultCloudHome({ userHome = homedir() } = {}) {
|
|
58
|
+
return join(userHome, '.mebular');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** 展示用路径:用户主目录前缀 → `~`;统一正斜杠(控制台跨平台一致,不含秘密)。 */
|
|
62
|
+
export function displayPath(path, { userHome = homedir() } = {}) {
|
|
63
|
+
const toPosix = (value) => value.split(sep).join('/');
|
|
64
|
+
const absolute = resolve(path);
|
|
65
|
+
const home = resolve(userHome);
|
|
66
|
+
if (absolute === home) return '~';
|
|
67
|
+
if (absolute.startsWith(`${home}${sep}`)) return `~${toPosix(absolute.slice(home.length))}`;
|
|
68
|
+
return toPosix(absolute);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** host 是否位于 root 自身或 root/profiles/<id>(id 白名单)。 */
|
|
72
|
+
export function isHomeInRoot(root, home) {
|
|
73
|
+
const rootPath = resolve(root);
|
|
74
|
+
const homePath = resolve(home);
|
|
75
|
+
if (homePath === rootPath) return true;
|
|
76
|
+
const rel = relative(rootPath, homePath);
|
|
77
|
+
if (rel.length === 0 || rel.startsWith('..') || isAbsolute(rel)) return false;
|
|
78
|
+
const parts = rel.split(sep);
|
|
79
|
+
return parts.length === 2 && parts[0] === PROFILES_DIR && ID_RE.test(parts[1]);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* 解析档案 root(唯一入口):
|
|
84
|
+
* MEBULAR_CLOUD_ROOT > MEBULAR_HOME 形如 <root>/profiles/<id> 推 root > 默认 ~/.mebular
|
|
85
|
+
* 其它自定义 home / 危险 env(会泄漏进新档案)→ 返回 available:false(CLOUD_UNAVAILABLE)。
|
|
86
|
+
*/
|
|
87
|
+
export function resolveCloudRoot({ home, env = process.env, userHome = homedir() } = {}) {
|
|
88
|
+
const defaultHome = resolve(defaultCloudHome({ userHome }));
|
|
89
|
+
const effectiveHome = resolve(home ?? env.MEBULAR_HOME ?? defaultHome);
|
|
90
|
+
const unavailable = (reason, root = null) => ({
|
|
91
|
+
available: false,
|
|
92
|
+
root,
|
|
93
|
+
home: effectiveHome,
|
|
94
|
+
defaultHome,
|
|
95
|
+
reason,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
let root;
|
|
99
|
+
const explicitRoot = typeof env.MEBULAR_CLOUD_ROOT === 'string' ? env.MEBULAR_CLOUD_ROOT.trim() : '';
|
|
100
|
+
if (explicitRoot.length > 0) {
|
|
101
|
+
root = resolve(explicitRoot);
|
|
102
|
+
} else {
|
|
103
|
+
const envHome = typeof env.MEBULAR_HOME === 'string' && env.MEBULAR_HOME.trim().length > 0
|
|
104
|
+
? resolve(env.MEBULAR_HOME)
|
|
105
|
+
: null;
|
|
106
|
+
if (envHome === null || envHome === defaultHome) {
|
|
107
|
+
root = envHome ?? defaultHome;
|
|
108
|
+
} else {
|
|
109
|
+
const match = PROFILE_HOME_RE.exec(envHome);
|
|
110
|
+
if (!match) return unavailable('custom_home');
|
|
111
|
+
root = resolve(match[1]);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const forbidden = FORBIDDEN_ENV.find((key) => typeof env[key] === 'string' && env[key].trim().length > 0);
|
|
116
|
+
if (forbidden) return unavailable(`env:${forbidden}`, root);
|
|
117
|
+
// 显式 CLOUD_ROOT 是 App/CLI 的内部契约(profile 子进程会同时给 MEBULAR_HOME),
|
|
118
|
+
// 不再用 home 猜 root;只有从 MEBULAR_HOME 推导时才校验 home 必须位于 root 内。
|
|
119
|
+
if (explicitRoot.length === 0 && !isHomeInRoot(root, effectiveHome)) {
|
|
120
|
+
return unavailable('home_outside_root', root);
|
|
121
|
+
}
|
|
122
|
+
return { available: true, root, home: effectiveHome, defaultHome, reason: null };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function cloudRegistryInvalid(message, extra = {}) {
|
|
126
|
+
return cloudError('CLOUD_REGISTRY_INVALID', message, extra);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function pidAlive(pid) {
|
|
130
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
131
|
+
try {
|
|
132
|
+
process.kill(pid, 0);
|
|
133
|
+
return true;
|
|
134
|
+
} catch {
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function readJsonFile(path) {
|
|
140
|
+
try {
|
|
141
|
+
return { ok: true, value: JSON.parse(await readFile(path, 'utf-8')) };
|
|
142
|
+
} catch (error) {
|
|
143
|
+
return { ok: false, missing: error?.code === 'ENOENT', error };
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function isIsoTime(value) {
|
|
148
|
+
return typeof value === 'string' && Number.isFinite(Date.parse(value));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function inferKind(home, config) {
|
|
152
|
+
const explicit = config?.identity?.mode;
|
|
153
|
+
if (explicit === 'root' || explicit === 'delegated') return explicit;
|
|
154
|
+
const storage = typeof config?.storagePath === 'string' && config.storagePath.length > 0
|
|
155
|
+
? resolve(home, config.storagePath)
|
|
156
|
+
: join(home, 'store.jsonl');
|
|
157
|
+
if (!existsSync(`${storage}.identity.json`)) return 'root';
|
|
158
|
+
const masterFile = config?.encryption?.userMasterPublicKeyFile ?? join(home, 'user-master-key.json');
|
|
159
|
+
try {
|
|
160
|
+
const record = JSON.parse(readFileSync(masterFile, 'utf-8'));
|
|
161
|
+
return typeof record.privateKeyPkcs8 === 'string' ? 'root' : 'delegated';
|
|
162
|
+
} catch {
|
|
163
|
+
return 'delegated';
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** 公开指纹:sha256(bytes) 前 12 hex。输入无效返回 null(不阻塞加入/展示)。 */
|
|
168
|
+
export function masterFingerprintFromBytes(bytes) {
|
|
169
|
+
try {
|
|
170
|
+
const buffer = Buffer.from(bytes);
|
|
171
|
+
if (buffer.length === 0) return null;
|
|
172
|
+
return `sha256:${createHash('sha256').update(buffer).digest('hex').slice(0, 12)}`;
|
|
173
|
+
} catch {
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function masterFingerprintFromBase64(publicKey) {
|
|
179
|
+
if (typeof publicKey !== 'string' || publicKey.length === 0) return null;
|
|
180
|
+
try {
|
|
181
|
+
return masterFingerprintFromBytes(Buffer.from(publicKey, 'base64'));
|
|
182
|
+
} catch {
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function masterFingerprintFromHex(publicKeyHex) {
|
|
188
|
+
if (typeof publicKeyHex !== 'string' || !/^[0-9a-fA-F]{64}$/.test(publicKeyHex)) return null;
|
|
189
|
+
try {
|
|
190
|
+
return masterFingerprintFromBytes(Buffer.from(publicKeyHex, 'hex'));
|
|
191
|
+
} catch {
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** 从 home 的公开主公钥文件算指纹;只读 publicKey,绝不读主私钥。 */
|
|
197
|
+
export function masterFingerprintForHome(home, config = null) {
|
|
198
|
+
const candidates = [];
|
|
199
|
+
if (typeof config?.encryption?.userMasterPublicKeyFile === 'string' && config.encryption.userMasterPublicKeyFile.length > 0) {
|
|
200
|
+
candidates.push(resolve(home, config.encryption.userMasterPublicKeyFile));
|
|
201
|
+
}
|
|
202
|
+
candidates.push(join(home, 'user-master-key.json'), join(home, 'master-key.json'));
|
|
203
|
+
for (const file of candidates) {
|
|
204
|
+
if (!existsSync(file)) continue;
|
|
205
|
+
try {
|
|
206
|
+
const record = JSON.parse(readFileSync(file, 'utf-8'));
|
|
207
|
+
const fingerprint = masterFingerprintFromBase64(record?.publicKey);
|
|
208
|
+
if (fingerprint !== null) return fingerprint;
|
|
209
|
+
} catch {
|
|
210
|
+
// 文件损坏 → 下一个候选;不阻塞
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** 注册表缺省时在内存里合成默认忆云(不写盘)。 */
|
|
217
|
+
function synthesizeDefaultRegistry(home) {
|
|
218
|
+
const configFile = join(home, 'config.json');
|
|
219
|
+
if (!existsSync(configFile)) {
|
|
220
|
+
return { v: REGISTRY_VERSION, current: null, profiles: [] };
|
|
221
|
+
}
|
|
222
|
+
let config = {};
|
|
223
|
+
try {
|
|
224
|
+
config = JSON.parse(readFileSync(configFile, 'utf-8'));
|
|
225
|
+
} catch {
|
|
226
|
+
config = {};
|
|
227
|
+
}
|
|
228
|
+
let createdAt = new Date().toISOString();
|
|
229
|
+
try {
|
|
230
|
+
const stat = statSync(configFile);
|
|
231
|
+
const ms = stat.birthtimeMs > 0 ? stat.birthtimeMs : stat.mtimeMs;
|
|
232
|
+
if (Number.isFinite(ms) && ms > 0) createdAt = new Date(ms).toISOString();
|
|
233
|
+
} catch {
|
|
234
|
+
// 保持当前时间
|
|
235
|
+
}
|
|
236
|
+
return {
|
|
237
|
+
v: REGISTRY_VERSION,
|
|
238
|
+
current: DEFAULT_PROFILE_ID,
|
|
239
|
+
profiles: [{
|
|
240
|
+
id: DEFAULT_PROFILE_ID,
|
|
241
|
+
name: DEFAULT_PROFILE_NAME,
|
|
242
|
+
home: '.',
|
|
243
|
+
kind: inferKind(home, config),
|
|
244
|
+
createdAt,
|
|
245
|
+
lastUsedAt: null,
|
|
246
|
+
masterFingerprint: masterFingerprintForHome(home, config),
|
|
247
|
+
deviceId: typeof config.deviceId === 'string' ? config.deviceId : null,
|
|
248
|
+
}],
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function validateRegistry(home, raw) {
|
|
253
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
254
|
+
throw cloudRegistryInvalid('注册表形状非法(应为对象)');
|
|
255
|
+
}
|
|
256
|
+
if (!Number.isInteger(raw.v) || raw.v < 1) {
|
|
257
|
+
throw cloudRegistryInvalid('注册表版本非法');
|
|
258
|
+
}
|
|
259
|
+
if (raw.v > REGISTRY_VERSION) {
|
|
260
|
+
throw cloudRegistryInvalid(`注册表版本 ${raw.v} 高于本机支持的 v${REGISTRY_VERSION}:请升级后重试`, { reason: 'version' });
|
|
261
|
+
}
|
|
262
|
+
if (!Array.isArray(raw.profiles)) {
|
|
263
|
+
throw cloudRegistryInvalid('注册表 profiles 非数组');
|
|
264
|
+
}
|
|
265
|
+
const seen = new Set();
|
|
266
|
+
const profiles = raw.profiles.map((entry, index) => {
|
|
267
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
268
|
+
throw cloudRegistryInvalid(`第 ${index + 1} 个档案条目形状非法`);
|
|
269
|
+
}
|
|
270
|
+
if (typeof entry.id !== 'string' || !ID_RE.test(entry.id)) {
|
|
271
|
+
throw cloudRegistryInvalid(`第 ${index + 1} 个档案 id 非法`);
|
|
272
|
+
}
|
|
273
|
+
if (seen.has(entry.id)) throw cloudRegistryInvalid(`档案 id 重复:${entry.id}`);
|
|
274
|
+
seen.add(entry.id);
|
|
275
|
+
const expectedHome = entry.id === DEFAULT_PROFILE_ID ? '.' : `${PROFILES_DIR}/${entry.id}`;
|
|
276
|
+
if (entry.home !== expectedHome) {
|
|
277
|
+
throw cloudRegistryInvalid(`档案 ${entry.id} 的 home 必须是 ${expectedHome}`);
|
|
278
|
+
}
|
|
279
|
+
if (typeof entry.name !== 'string' || entry.name.trim().length === 0) {
|
|
280
|
+
throw cloudRegistryInvalid(`档案 ${entry.id} 缺少名字`);
|
|
281
|
+
}
|
|
282
|
+
if (typeof entry.kind !== 'string' || !['root', 'delegated'].includes(entry.kind)) {
|
|
283
|
+
throw cloudRegistryInvalid(`档案 ${entry.id} 的 kind 非法`);
|
|
284
|
+
}
|
|
285
|
+
if (entry.masterFingerprint !== null && entry.masterFingerprint !== undefined
|
|
286
|
+
&& (typeof entry.masterFingerprint !== 'string' || !/^sha256:[0-9a-f]{12}$/.test(entry.masterFingerprint))) {
|
|
287
|
+
throw cloudRegistryInvalid(`档案 ${entry.id} 的指纹格式非法`);
|
|
288
|
+
}
|
|
289
|
+
if (entry.createdAt !== undefined && !isIsoTime(entry.createdAt)) {
|
|
290
|
+
throw cloudRegistryInvalid(`档案 ${entry.id} 的 createdAt 非法`);
|
|
291
|
+
}
|
|
292
|
+
// 旧 registry 没有 nameUpdatedAt(迁移:用 createdAt/null 回退,不拒绝)。
|
|
293
|
+
if (entry.nameUpdatedAt !== null && entry.nameUpdatedAt !== undefined && !isIsoTime(entry.nameUpdatedAt)) {
|
|
294
|
+
throw cloudRegistryInvalid(`档案 ${entry.id} 的 nameUpdatedAt 非法`);
|
|
295
|
+
}
|
|
296
|
+
if (entry.lastUsedAt !== null && entry.lastUsedAt !== undefined && !isIsoTime(entry.lastUsedAt)) {
|
|
297
|
+
throw cloudRegistryInvalid(`档案 ${entry.id} 的 lastUsedAt 非法`);
|
|
298
|
+
}
|
|
299
|
+
if (entry.deviceId !== null && entry.deviceId !== undefined && typeof entry.deviceId !== 'string') {
|
|
300
|
+
throw cloudRegistryInvalid(`档案 ${entry.id} 的 deviceId 非法`);
|
|
301
|
+
}
|
|
302
|
+
// 路径 containment:默认 home 必须就是 root;其它必须是 root 内 root/profiles/<id>。
|
|
303
|
+
const entryHome = resolve(home, expectedHome);
|
|
304
|
+
if (!isHomeInRoot(home, entryHome)) {
|
|
305
|
+
throw cloudRegistryInvalid(`档案 ${entry.id} 的 home 越界`);
|
|
306
|
+
}
|
|
307
|
+
return entry;
|
|
308
|
+
});
|
|
309
|
+
return {
|
|
310
|
+
...raw,
|
|
311
|
+
v: REGISTRY_VERSION,
|
|
312
|
+
current: typeof raw.current === 'string' ? raw.current : null,
|
|
313
|
+
profiles,
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* 读注册表(不写盘):
|
|
319
|
+
* 主文件解析失败 → .bak;再失败 → CLOUD_REGISTRY_INVALID(fail-closed,不自动重建)。
|
|
320
|
+
* 注册表不存在但有 config.json → 内存合成默认忆云(第一次改名/切换/加入才落盘)。
|
|
321
|
+
*/
|
|
322
|
+
export async function readRegistry(home) {
|
|
323
|
+
const main = await readJsonFile(registryPath(home));
|
|
324
|
+
let raw = null;
|
|
325
|
+
let source = 'synthesized';
|
|
326
|
+
if (main.ok) {
|
|
327
|
+
raw = main.value;
|
|
328
|
+
source = 'main';
|
|
329
|
+
} else if (!main.missing) {
|
|
330
|
+
const backup = await readJsonFile(registryBakPath(home));
|
|
331
|
+
if (!backup.ok) throw cloudRegistryInvalid('注册表主文件与 .bak 都无法解析(fail-closed)');
|
|
332
|
+
raw = backup.value;
|
|
333
|
+
source = 'bak';
|
|
334
|
+
} else {
|
|
335
|
+
const backup = await readJsonFile(registryBakPath(home));
|
|
336
|
+
if (backup.ok) {
|
|
337
|
+
raw = backup.value;
|
|
338
|
+
source = 'bak';
|
|
339
|
+
} else if (!backup.missing) {
|
|
340
|
+
throw cloudRegistryInvalid('注册表不存在且 .bak 无法解析(fail-closed)');
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (raw === null) {
|
|
345
|
+
return { registry: synthesizeDefaultRegistry(home), source: 'synthesized', synthesized: true, warning: null };
|
|
346
|
+
}
|
|
347
|
+
const registry = validateRegistry(home, raw);
|
|
348
|
+
let warning = null;
|
|
349
|
+
const ids = new Set(registry.profiles.map((entry) => entry.id));
|
|
350
|
+
if (registry.current === null || !ids.has(registry.current)) {
|
|
351
|
+
const fallback = ids.has(DEFAULT_PROFILE_ID) ? DEFAULT_PROFILE_ID : (registry.profiles[0]?.id ?? null);
|
|
352
|
+
warning = `当前忆云不存在,已回退到 ${fallback ?? '(无)'}`;
|
|
353
|
+
registry.current = fallback;
|
|
354
|
+
}
|
|
355
|
+
return { registry, source, synthesized: false, warning };
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** 原子写注册表:tmp-<pid>(0600) → rename;rename 前把上一版复制为 .bak。 */
|
|
359
|
+
export async function writeRegistry(home, registry) {
|
|
360
|
+
if (!registry || typeof registry !== 'object' || registry.v !== REGISTRY_VERSION) {
|
|
361
|
+
throw cloudRegistryInvalid('拒绝写入非 v1 注册表');
|
|
362
|
+
}
|
|
363
|
+
await mkdir(home, { recursive: true });
|
|
364
|
+
const path = registryPath(home);
|
|
365
|
+
const tmp = `${path}.tmp-${process.pid}`;
|
|
366
|
+
const text = `${JSON.stringify(registry, null, 2)}\n`;
|
|
367
|
+
try {
|
|
368
|
+
await writeFile(tmp, text, { mode: 0o600 });
|
|
369
|
+
await chmod(tmp, 0o600).catch(() => undefined);
|
|
370
|
+
if (existsSync(path)) {
|
|
371
|
+
try {
|
|
372
|
+
// 只把**可解析的上一次成功版本**复制为 .bak;主文件已损坏时保留现有 .bak。
|
|
373
|
+
JSON.parse(await readFile(path, 'utf-8'));
|
|
374
|
+
await copyFile(path, registryBakPath(home));
|
|
375
|
+
await chmod(registryBakPath(home), 0o600);
|
|
376
|
+
} catch {
|
|
377
|
+
// .bak 只是恢复兜底;复制失败不阻塞本次成功写入
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
await rename(tmp, path);
|
|
381
|
+
await chmod(path, 0o600).catch(() => undefined);
|
|
382
|
+
} catch (error) {
|
|
383
|
+
await rm(tmp, { force: true }).catch(() => undefined);
|
|
384
|
+
throw error;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** 注册表读改写锁(wx + PID 存活 + 陈旧回收);临界区只做文件操作。 */
|
|
389
|
+
export async function withRegistryLock(home, fn) {
|
|
390
|
+
await mkdir(home, { recursive: true });
|
|
391
|
+
const path = registryLockPath(home);
|
|
392
|
+
const payload = JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() });
|
|
393
|
+
const acquire = async () => writeFile(path, payload, { mode: 0o600, flag: 'wx' });
|
|
394
|
+
|
|
395
|
+
let acquired = false;
|
|
396
|
+
try {
|
|
397
|
+
await acquire();
|
|
398
|
+
acquired = true;
|
|
399
|
+
} catch (error) {
|
|
400
|
+
if (error?.code !== 'EEXIST') throw error;
|
|
401
|
+
let held = null;
|
|
402
|
+
try {
|
|
403
|
+
held = JSON.parse(await readFile(path, 'utf-8'));
|
|
404
|
+
} catch {
|
|
405
|
+
held = null;
|
|
406
|
+
}
|
|
407
|
+
if (held?.pid && pidAlive(held.pid)) {
|
|
408
|
+
throw cloudError(
|
|
409
|
+
'CLOUD_REGISTRY_LOCKED',
|
|
410
|
+
`忆云注册表正被其他进程写入(pid ${held.pid});请稍后重试`,
|
|
411
|
+
{ pid: held.pid },
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
await rm(path, { force: true }).catch(() => undefined);
|
|
415
|
+
try {
|
|
416
|
+
await acquire();
|
|
417
|
+
acquired = true;
|
|
418
|
+
} catch (retryError) {
|
|
419
|
+
if (retryError?.code === 'EEXIST') {
|
|
420
|
+
throw cloudError('CLOUD_REGISTRY_LOCKED', '忆云注册表正被其他进程写入;请稍后重试');
|
|
421
|
+
}
|
|
422
|
+
throw retryError;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
try {
|
|
427
|
+
return await fn();
|
|
428
|
+
} finally {
|
|
429
|
+
if (acquired) {
|
|
430
|
+
try {
|
|
431
|
+
const held = JSON.parse(await readFile(path, 'utf-8'));
|
|
432
|
+
if (held?.pid === process.pid) await rm(path, { force: true });
|
|
433
|
+
} catch {
|
|
434
|
+
// 已被回收/删除:无需处理
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** 读改写:保留未知字段(向前兼容),写入前校验 v1。 */
|
|
441
|
+
export async function mutateRegistry(home, mutator) {
|
|
442
|
+
return withRegistryLock(home, async () => {
|
|
443
|
+
const state = await readRegistry(home);
|
|
444
|
+
const draft = {
|
|
445
|
+
...state.registry,
|
|
446
|
+
profiles: state.registry.profiles.map((entry) => ({ ...entry })),
|
|
447
|
+
};
|
|
448
|
+
const value = await mutator(draft, state);
|
|
449
|
+
const registry = { ...draft, v: REGISTRY_VERSION };
|
|
450
|
+
await writeRegistry(home, registry);
|
|
451
|
+
return { registry, value, source: state.source, warning: state.warning };
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function sameName(a, b) {
|
|
456
|
+
return String(a).trim().toLocaleLowerCase() === String(b).trim().toLocaleLowerCase();
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* 名字:与 core `normalizeDisplayName` 同一口径(去控制字符/trim;1–24 Unicode 字符;
|
|
461
|
+
* 拒绝 `/`、`\`、`..`)。**不在这里 import @mebular/core**:profiles.mjs 也被 Jest
|
|
462
|
+
* `--coverage` 的 ESM 链接直接加载,core 桶会触发命名导出链接错误;口径一致性由
|
|
463
|
+
* `tests/sync/display-name-parity.test.ts` 的对照用例锁定。
|
|
464
|
+
*/
|
|
465
|
+
export function normalizeProfileName(raw) {
|
|
466
|
+
if (typeof raw !== 'string') throw cloudError('INVALID_NAME', '忆云名字必须是字符串');
|
|
467
|
+
const name = raw.replace(/[\u0000-\u001f\u007f]/g, '').trim();
|
|
468
|
+
if (name.length === 0) throw cloudError('INVALID_NAME', '忆云名字不能为空');
|
|
469
|
+
if (name.includes('/') || name.includes('\\')) throw cloudError('INVALID_NAME', '忆云名字不能包含 / 或 \\');
|
|
470
|
+
if (name.includes('..')) throw cloudError('INVALID_NAME', '忆云名字不能包含 ..');
|
|
471
|
+
if ([...name].length > 24) throw cloudError('INVALID_NAME', '忆云名字最多 24 个字符');
|
|
472
|
+
return name;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** 新档案默认名:未占用的最小整数 N >= 2 → 忆云 N(大小写不敏感)。 */
|
|
476
|
+
export function nextProfileName(profiles) {
|
|
477
|
+
for (let n = 2; n < 10_000; n += 1) {
|
|
478
|
+
const candidate = `忆云 ${n}`;
|
|
479
|
+
if (!profiles.some((entry) => sameName(entry.name, candidate))) return candidate;
|
|
480
|
+
}
|
|
481
|
+
return `忆云 ${Date.now()}`;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
export function newProfileId(profiles, { random = randomBytes } = {}) {
|
|
485
|
+
const used = new Set(profiles.map((entry) => entry.id));
|
|
486
|
+
for (let i = 0; i < 100; i += 1) {
|
|
487
|
+
const id = `p_${random(4).toString('hex')}`;
|
|
488
|
+
if (!used.has(id)) return id;
|
|
489
|
+
}
|
|
490
|
+
throw cloudError('PROFILE_ID_EXHAUSTED', '无法分配新的忆云 id');
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* 注册档案(锁内校验 id/名字/指纹重复);current 不变。
|
|
495
|
+
* 成功返回 { registry, value: entry };失败不写盘。
|
|
496
|
+
*/
|
|
497
|
+
export async function registerProfile(home, input = {}) {
|
|
498
|
+
return mutateRegistry(home, (registry) => {
|
|
499
|
+
const id = input.id;
|
|
500
|
+
if (typeof id !== 'string' || !ID_RE.test(id)) {
|
|
501
|
+
throw cloudError('INVALID_PROFILE_ID', '忆云 id 非法');
|
|
502
|
+
}
|
|
503
|
+
if (registry.profiles.some((entry) => entry.id === id)) {
|
|
504
|
+
throw cloudError('PROFILE_EXISTS', `忆云 ${id} 已存在`);
|
|
505
|
+
}
|
|
506
|
+
const name = input.name === undefined
|
|
507
|
+
? nextProfileName(registry.profiles)
|
|
508
|
+
: normalizeProfileName(input.name);
|
|
509
|
+
if (registry.profiles.some((entry) => sameName(entry.name, name))) {
|
|
510
|
+
throw cloudError('NAME_TAKEN', '已有同名忆云,请换一个名字');
|
|
511
|
+
}
|
|
512
|
+
const masterFingerprint = typeof input.masterFingerprint === 'string' ? input.masterFingerprint : null;
|
|
513
|
+
if (masterFingerprint !== null) {
|
|
514
|
+
const existing = registry.profiles.find((entry) => entry.masterFingerprint === masterFingerprint);
|
|
515
|
+
if (existing) {
|
|
516
|
+
throw cloudError(
|
|
517
|
+
'CLOUD_ALREADY_JOINED',
|
|
518
|
+
`这台设备已加入「${existing.name}」,可直接切换`,
|
|
519
|
+
{ existing: { id: existing.id, name: existing.name } },
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
const entry = {
|
|
524
|
+
id,
|
|
525
|
+
name,
|
|
526
|
+
home: id === DEFAULT_PROFILE_ID ? '.' : `${PROFILES_DIR}/${id}`,
|
|
527
|
+
kind: input.kind === 'delegated' ? 'delegated' : 'root',
|
|
528
|
+
createdAt: isIsoTime(input.createdAt) ? input.createdAt : new Date().toISOString(),
|
|
529
|
+
lastUsedAt: null,
|
|
530
|
+
masterFingerprint,
|
|
531
|
+
deviceId: typeof input.deviceId === 'string' ? input.deviceId : null,
|
|
532
|
+
};
|
|
533
|
+
registry.profiles = [...registry.profiles, entry];
|
|
534
|
+
return entry;
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/** 改名(current 不变;大小写不敏感重名拒绝)。 */
|
|
539
|
+
export async function renameProfile(home, id, rawName) {
|
|
540
|
+
return mutateRegistry(home, (registry) => {
|
|
541
|
+
const entry = registry.profiles.find((profile) => profile.id === id);
|
|
542
|
+
if (!entry) throw cloudError('PROFILE_NOT_FOUND', `未找到忆云:${id}`);
|
|
543
|
+
const name = normalizeProfileName(rawName);
|
|
544
|
+
if (registry.profiles.some((profile) => profile.id !== id && sameName(profile.name, name))) {
|
|
545
|
+
throw cloudError('NAME_TAKEN', '已有同名忆云,请换一个名字');
|
|
546
|
+
}
|
|
547
|
+
entry.name = name;
|
|
548
|
+
// 本机 registry 缓存时间:重名时判断“最近更新”的依据;旧条目缺失时回退 createdAt。
|
|
549
|
+
entry.nameUpdatedAt = new Date().toISOString();
|
|
550
|
+
return entry;
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/** 写 current(切换引擎在停旧后、起新前调用);可同时更新 lastUsedAt。 */
|
|
555
|
+
export async function setCurrentProfile(home, id, { lastUsedAt } = {}) {
|
|
556
|
+
return mutateRegistry(home, (registry) => {
|
|
557
|
+
const entry = registry.profiles.find((profile) => profile.id === id);
|
|
558
|
+
if (!entry) throw cloudError('PROFILE_NOT_FOUND', `未找到忆云:${id}`);
|
|
559
|
+
registry.current = id;
|
|
560
|
+
if (lastUsedAt !== undefined) entry.lastUsedAt = lastUsedAt;
|
|
561
|
+
return entry;
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
export async function findProfile(home, id) {
|
|
566
|
+
const state = await readRegistry(home);
|
|
567
|
+
const entry = state.registry.profiles.find((profile) => profile.id === id) ?? null;
|
|
568
|
+
return { entry, ...state };
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* 档案 home 解析 + containment:
|
|
573
|
+
* - 只接受注册表里的 `.` / `profiles/<id>`;
|
|
574
|
+
* - 存在时 realpath 必须仍在 root 内(防符号链接逃逸)。
|
|
575
|
+
*/
|
|
576
|
+
export function profileHomeForEntry(root, entry) {
|
|
577
|
+
const rootPath = resolve(root);
|
|
578
|
+
const expected = entry.id === DEFAULT_PROFILE_ID ? '.' : `${PROFILES_DIR}/${entry.id}`;
|
|
579
|
+
if (entry.home !== expected) {
|
|
580
|
+
throw cloudRegistryInvalid(`档案 ${entry.id} 的 home 必须是 ${expected}`);
|
|
581
|
+
}
|
|
582
|
+
const home = resolve(rootPath, expected);
|
|
583
|
+
if (!isHomeInRoot(rootPath, home)) {
|
|
584
|
+
throw cloudError('CLOUD_PROFILE_PATH_INVALID', `档案 ${entry.id} 的 home 越界`);
|
|
585
|
+
}
|
|
586
|
+
if (existsSync(home)) {
|
|
587
|
+
let realHome;
|
|
588
|
+
let realRoot;
|
|
589
|
+
try {
|
|
590
|
+
realHome = realpathSync(home);
|
|
591
|
+
realRoot = realpathSync(rootPath);
|
|
592
|
+
} catch {
|
|
593
|
+
throw cloudError('CLOUD_PROFILE_PATH_INVALID', `档案 ${entry.id} 的 home 无法解析`);
|
|
594
|
+
}
|
|
595
|
+
if (realHome !== realRoot && !realHome.startsWith(`${realRoot}${sep}`)) {
|
|
596
|
+
throw cloudError('CLOUD_PROFILE_PATH_INVALID', `档案 ${entry.id} 的 home 指向 root 之外`);
|
|
597
|
+
}
|
|
598
|
+
if (realHome === realRoot && entry.id !== DEFAULT_PROFILE_ID) {
|
|
599
|
+
throw cloudError('CLOUD_PROFILE_PATH_INVALID', `档案 ${entry.id} 的 home 不能是 root 自身`);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
return home;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
export async function profileHomeForId(root, id) {
|
|
606
|
+
const state = await readRegistry(root);
|
|
607
|
+
const entry = state.registry.profiles.find((profile) => profile.id === id);
|
|
608
|
+
if (!entry) throw cloudError('PROFILE_NOT_FOUND', `未找到忆云:${id}`);
|
|
609
|
+
return { entry, home: profileHomeForEntry(root, entry), ...state };
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/** 由 home 反查档案 id(healthz 身份用);读失败/未知返回 null。 */
|
|
613
|
+
export async function profileIdForHome(root, home) {
|
|
614
|
+
const homePath = resolve(home);
|
|
615
|
+
const rootPath = resolve(root);
|
|
616
|
+
if (homePath === rootPath) return DEFAULT_PROFILE_ID;
|
|
617
|
+
const rel = relative(rootPath, homePath);
|
|
618
|
+
const parts = rel.split(sep);
|
|
619
|
+
if (parts.length === 2 && parts[0] === PROFILES_DIR && ID_RE.test(parts[1])) {
|
|
620
|
+
try {
|
|
621
|
+
const state = await readRegistry(rootPath);
|
|
622
|
+
if (state.registry.profiles.some((entry) => entry.id === parts[1])) return parts[1];
|
|
623
|
+
} catch {
|
|
624
|
+
// 注册表损坏:仍返回路径 id(healthz 只用于身份比对)
|
|
625
|
+
}
|
|
626
|
+
return parts[1];
|
|
627
|
+
}
|
|
628
|
+
return null;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/** 实例身份:home 的 sha256 前 12 hex(与 @mebular/service homeKey 同定义)。 */
|
|
632
|
+
export function homeKey(home) {
|
|
633
|
+
const normalized = resolve(home).replace(/[/\\]+$/, '') || resolve(home);
|
|
634
|
+
return createHash('sha256').update(normalized).digest('hex').slice(0, 12);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* root 级单活锁:`<root>/active.lock` `{pid, profileId, home, startedAt}`。
|
|
639
|
+
* 被活 PID 占用且 home 不同 → CLOUD_ALREADY_ACTIVE;陈旧锁回收。
|
|
640
|
+
*/
|
|
641
|
+
export async function acquireActiveLock({ root, home, profileId = null, startedAt = new Date().toISOString() }) {
|
|
642
|
+
await mkdir(root, { recursive: true });
|
|
643
|
+
const path = activeLockPath(root);
|
|
644
|
+
const homePath = resolve(home);
|
|
645
|
+
const payload = JSON.stringify({ pid: process.pid, profileId, home: homePath, startedAt });
|
|
646
|
+
const release = async () => {
|
|
647
|
+
try {
|
|
648
|
+
const held = JSON.parse(await readFile(path, 'utf-8'));
|
|
649
|
+
if (held?.pid === process.pid && resolve(held.home ?? '') === homePath) {
|
|
650
|
+
await rm(path, { force: true });
|
|
651
|
+
}
|
|
652
|
+
} catch {
|
|
653
|
+
// 已不存在/损坏:无需处理
|
|
654
|
+
}
|
|
655
|
+
};
|
|
656
|
+
const write = () => writeFile(path, payload, { mode: 0o600, flag: 'wx' });
|
|
657
|
+
|
|
658
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
659
|
+
try {
|
|
660
|
+
await write();
|
|
661
|
+
return { path, release };
|
|
662
|
+
} catch (error) {
|
|
663
|
+
if (error?.code !== 'EEXIST') throw error;
|
|
664
|
+
let held = null;
|
|
665
|
+
try {
|
|
666
|
+
held = JSON.parse(await readFile(path, 'utf-8'));
|
|
667
|
+
} catch {
|
|
668
|
+
held = null;
|
|
669
|
+
}
|
|
670
|
+
if (held?.pid && pidAlive(held.pid)) {
|
|
671
|
+
throw cloudError(
|
|
672
|
+
'CLOUD_ALREADY_ACTIVE',
|
|
673
|
+
`已有忆云守护在运行(pid ${held.pid},home=${held.home ?? '未知'});请先停止它`,
|
|
674
|
+
{ held, path },
|
|
675
|
+
);
|
|
676
|
+
}
|
|
677
|
+
await rm(path, { force: true }).catch(() => undefined);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
throw cloudError('CLOUD_ALREADY_ACTIVE', '忆云单活锁竞争失败:请稍后重试', { path });
|
|
681
|
+
}
|