@pandablessing/dsh-user-sync 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/LICENSE +77 -0
- package/README.en.md +36 -0
- package/README.md +102 -2
- package/cordis.patch.yml +5 -0
- package/lib/apply.js +111 -0
- package/lib/archive.js +160 -0
- package/lib/auth.js +10 -0
- package/lib/client.js +521 -0
- package/lib/hash.js +32 -0
- package/lib/host.js +5 -0
- package/lib/index.js +593 -0
- package/lib/lww.js +64 -0
- package/lib/paths.js +63 -0
- package/lib/plugins.js +37 -0
- package/lib/scan.js +335 -0
- package/lib/scheduler.js +80 -0
- package/lib/secret.js +62 -0
- package/lib/state.js +123 -0
- package/lib/sync.js +251 -0
- package/lib/transport/api.js +170 -0
- package/lib/transport/folder.js +71 -0
- package/lib/transport/types.js +2 -0
- package/locale/zh.json +14 -0
- package/package.json +60 -3
package/lib/index.js
ADDED
|
@@ -0,0 +1,593 @@
|
|
|
1
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { isPackageId } from './archive.js';
|
|
7
|
+
import { deriveAccountKey } from './auth.js';
|
|
8
|
+
import { resolvePaths } from './paths.js';
|
|
9
|
+
import { installMissing, planPluginRecovery } from './plugins.js';
|
|
10
|
+
import { ALL_MODULES, scanAssets } from './scan.js';
|
|
11
|
+
import { createScheduler } from './scheduler.js';
|
|
12
|
+
import { loadState, saveState } from './state.js';
|
|
13
|
+
import { createSync } from './sync.js';
|
|
14
|
+
import { createApiTransport } from './transport/api.js';
|
|
15
|
+
import { createFolderTransport } from './transport/folder.js';
|
|
16
|
+
export const name = 'dsh-user-sync';
|
|
17
|
+
/** 不硬依赖任何服务:依赖缺失时降级,不把条目停在 pending。 */
|
|
18
|
+
export const inject = [];
|
|
19
|
+
/** 非用户级模块:默认不选,首次勾选要提示一次。 */
|
|
20
|
+
const NON_USER_LEVEL = ['projectMemory', 'sessions'];
|
|
21
|
+
/**
|
|
22
|
+
* 记录插件自身错误。绝不向外抛:任何插件 bug 都不应阻止 DSH 启动。
|
|
23
|
+
* 日志落在 $DSH_HOME/user-sync/plugin-error.log(与状态文件同目录)。
|
|
24
|
+
* base 可显式传入,测试用它隔离到临时目录,避免污染真实日志。
|
|
25
|
+
*/
|
|
26
|
+
/** 找 DSH 运行时自带的 node 可执行文件(Electron 的 process.execPath 不能用)。 */
|
|
27
|
+
function findRuntimeNode() {
|
|
28
|
+
const root = join(homedir(), '.dsh', 'dsh-runtimes');
|
|
29
|
+
try {
|
|
30
|
+
for (const dir of readdirSync(root, { withFileTypes: true })) {
|
|
31
|
+
if (!dir.isDirectory())
|
|
32
|
+
continue;
|
|
33
|
+
for (const candidate of [
|
|
34
|
+
join(root, dir.name, 'dependencies', 'node', 'bin', 'node.exe'),
|
|
35
|
+
join(root, dir.name, 'dependencies', 'node', 'bin', 'node'),
|
|
36
|
+
]) {
|
|
37
|
+
if (existsSync(candidate))
|
|
38
|
+
return candidate;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
// 没有运行时就回退失败
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
const PLUGIN_VERSION = (() => {
|
|
48
|
+
try {
|
|
49
|
+
const pkg = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8'));
|
|
50
|
+
return pkg.version ?? '0.0.0';
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return '0.0.0';
|
|
54
|
+
}
|
|
55
|
+
})();
|
|
56
|
+
export function logPluginError(where, error, base = process.env.DSH_HOME || join(homedir(), '.dsh')) {
|
|
57
|
+
const line = `${new Date().toISOString()} [${where}] ${error?.stack ?? String(error)}\n`;
|
|
58
|
+
try {
|
|
59
|
+
const dir = join(base, 'user-sync');
|
|
60
|
+
mkdirSync(dir, { recursive: true });
|
|
61
|
+
appendFileSync(join(dir, 'plugin-error.log'), line);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
// 写日志失败也不能抛
|
|
65
|
+
}
|
|
66
|
+
try {
|
|
67
|
+
console.error(`[dsh-user-sync] ${where}:`, error);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
// 控制台不可用时忽略
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const json = (data, status = 200) => new Response(JSON.stringify(data), { status, headers: { 'content-type': 'application/json' } });
|
|
74
|
+
export function apply(ctx) {
|
|
75
|
+
let logBase = process.env.DSH_HOME || join(homedir(), '.dsh');
|
|
76
|
+
const log = (where, error) => logPluginError(where, error, logBase);
|
|
77
|
+
try {
|
|
78
|
+
const profileContext = readService(ctx, 'profileContext');
|
|
79
|
+
const paths = resolvePaths({
|
|
80
|
+
profileContext,
|
|
81
|
+
env: process.env,
|
|
82
|
+
userHome: homedir(),
|
|
83
|
+
});
|
|
84
|
+
// 日志跟随本 profile 的 DSH 主目录;测试里即临时目录,不污染真实日志
|
|
85
|
+
logBase = paths.dshHome;
|
|
86
|
+
const statePath = join(paths.dshHome, 'user-sync', 'state.json');
|
|
87
|
+
const authPath = join(paths.dshHome, 'user-sync', 'auth.json');
|
|
88
|
+
const folderDir = join(paths.dshHome, 'user-sync', 'remote');
|
|
89
|
+
/**
|
|
90
|
+
* 传输选择:配了服务地址就用运营方 API,否则回退本地目录(无服务端调试)。
|
|
91
|
+
* 面板已隐藏「传输方式」,不再读 state.transport——避免历史设置把正式环境切成 folder。
|
|
92
|
+
*/
|
|
93
|
+
const buildTransport = () => {
|
|
94
|
+
const state = loadState(statePath);
|
|
95
|
+
if (state.baseUrl) {
|
|
96
|
+
try {
|
|
97
|
+
return createApiTransport({ baseUrl: state.baseUrl, authPath });
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
log('transport-api', error);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return createFolderTransport({ dir: folderDir });
|
|
104
|
+
};
|
|
105
|
+
const sync = createSync({
|
|
106
|
+
dshHome: paths.dshHome,
|
|
107
|
+
userHome: paths.userHome,
|
|
108
|
+
profileName: paths.profileName,
|
|
109
|
+
transport: buildTransport(),
|
|
110
|
+
});
|
|
111
|
+
const scheduler = createScheduler({ sync, statePath });
|
|
112
|
+
const scanSummary = () => {
|
|
113
|
+
// 计数按全部模块扫描,勾选只决定打包范围;否则未勾选的模块会显示 0,误导用户。
|
|
114
|
+
const { entries, errors, skipped, totalBytes } = scanAssets({
|
|
115
|
+
dshHome: paths.dshHome,
|
|
116
|
+
userHome: paths.userHome,
|
|
117
|
+
profileName: paths.profileName,
|
|
118
|
+
modules: ALL_MODULES,
|
|
119
|
+
});
|
|
120
|
+
const counts = {};
|
|
121
|
+
for (const entry of entries)
|
|
122
|
+
counts[entry.module] = (counts[entry.module] ?? 0) + 1;
|
|
123
|
+
// 插件清单模块展示「装了什么插件」:直接读 profile 的 package.json dependencies
|
|
124
|
+
let plugins = [];
|
|
125
|
+
try {
|
|
126
|
+
const pkg = JSON.parse(readFileSync(join(paths.profileDir, 'package.json'), 'utf8'));
|
|
127
|
+
plugins = Object.entries(pkg.dependencies ?? {}).map(([name, spec]) => ({ name, spec: String(spec) }));
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
plugins = [];
|
|
131
|
+
}
|
|
132
|
+
// 已勾选模块的统计:面板「已选」口径,避免把未选的会话体积算进去
|
|
133
|
+
const selectedModules = sync.getModules();
|
|
134
|
+
const selected = entries.filter((entry) => selectedModules.includes(entry.module));
|
|
135
|
+
// 同步范围内(不含会话)的统计:面板「全部」显示用,避免会话体积误导
|
|
136
|
+
const syncable = entries.filter((entry) => entry.module !== 'sessions');
|
|
137
|
+
return {
|
|
138
|
+
modules: selectedModules,
|
|
139
|
+
allModules: ALL_MODULES,
|
|
140
|
+
counts,
|
|
141
|
+
plugins,
|
|
142
|
+
selectedFiles: selected.length,
|
|
143
|
+
syncableFiles: syncable.length,
|
|
144
|
+
syncableBytes: syncable.reduce((sum, entry) => sum + entry.size, 0),
|
|
145
|
+
selectedBytes: selected.reduce((sum, entry) => sum + entry.size, 0),
|
|
146
|
+
// 每个模块具体包含哪些文件:面板「查看」展开用,按模块分组、路径升序
|
|
147
|
+
files: entries.map((entry) => ({ module: entry.module, path: entry.path, size: entry.size, title: entry.title ?? null })),
|
|
148
|
+
totalFiles: entries.length,
|
|
149
|
+
totalBytes,
|
|
150
|
+
errors,
|
|
151
|
+
skipped: skipped.slice(0, 50),
|
|
152
|
+
};
|
|
153
|
+
};
|
|
154
|
+
/** handler 异常落盘:面板显示「接口错误」时可直接从 state.diagnostics.lastError 定位。 */
|
|
155
|
+
const recordHandlerError = (where, error) => {
|
|
156
|
+
log(`handler-${where}`, error);
|
|
157
|
+
try {
|
|
158
|
+
const state = loadState(statePath);
|
|
159
|
+
state.diagnostics = {
|
|
160
|
+
...(state.diagnostics ?? {}),
|
|
161
|
+
lastError: {
|
|
162
|
+
at: new Date().toISOString(),
|
|
163
|
+
where,
|
|
164
|
+
message: error?.message ?? String(error),
|
|
165
|
+
stack: String(error?.stack ?? error).slice(0, 1500),
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
saveState(statePath, state);
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
// 落盘失败不影响响应
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
let pluginManager = null;
|
|
175
|
+
const routes = [
|
|
176
|
+
{
|
|
177
|
+
// 最小同步路由:用于区分「同步 handler 本身不可用」与「status/scan 具体逻辑抛错」
|
|
178
|
+
path: '/api/dsh-user-sync/ping',
|
|
179
|
+
methods: ['GET'],
|
|
180
|
+
requestBody: 'buffered',
|
|
181
|
+
fetch: async () => {
|
|
182
|
+
try {
|
|
183
|
+
appendFileSync(join(logBase, 'user-sync', 'ping.log'), `${new Date().toISOString()} ping called\n`);
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
// 诊断失败不影响响应
|
|
187
|
+
}
|
|
188
|
+
return new Response('pong');
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
path: '/api/dsh-user-sync/status',
|
|
193
|
+
methods: ['GET'],
|
|
194
|
+
requestBody: 'buffered',
|
|
195
|
+
fetch: () => {
|
|
196
|
+
try {
|
|
197
|
+
const state = loadState(statePath);
|
|
198
|
+
const scheduled = scheduler.settings();
|
|
199
|
+
// 宿主侧探针:与 /scan 同一条扫描路径,便于对照面板是否拿不到数据
|
|
200
|
+
let probe = null;
|
|
201
|
+
try {
|
|
202
|
+
const summary = scanSummary();
|
|
203
|
+
probe = { totalFiles: summary.totalFiles, counts: summary.counts, errors: summary.errors.length };
|
|
204
|
+
}
|
|
205
|
+
catch (error) {
|
|
206
|
+
probe = { error: error.message };
|
|
207
|
+
}
|
|
208
|
+
return json({
|
|
209
|
+
installed: true,
|
|
210
|
+
version: PLUGIN_VERSION,
|
|
211
|
+
dshHome: paths.dshHome,
|
|
212
|
+
profile: paths.profileName,
|
|
213
|
+
resolvedBy: paths.resolvedBy,
|
|
214
|
+
probe,
|
|
215
|
+
transport: sync.getTransportId(),
|
|
216
|
+
transportSetting: state.transport ?? 'folder',
|
|
217
|
+
baseUrl: state.baseUrl ?? null,
|
|
218
|
+
account: { hint: state.accountKeyHint ?? null, paired: Boolean(state.pairedAt) },
|
|
219
|
+
deviceId: state.deviceId ?? null,
|
|
220
|
+
lastApplied: state.lastApplied ?? null,
|
|
221
|
+
lastUploaded: state.lastUploaded ?? null,
|
|
222
|
+
consent: state.consent ?? null,
|
|
223
|
+
warnedModules: state.warnedModules ?? [],
|
|
224
|
+
diagnostics: state.diagnostics ?? null,
|
|
225
|
+
modules: sync.getModules(),
|
|
226
|
+
scheduler: {
|
|
227
|
+
autoCheck: scheduled.autoCheck,
|
|
228
|
+
intervalHours: scheduled.intervalHours,
|
|
229
|
+
lastCheckAt: scheduled.lastCheckAt,
|
|
230
|
+
lastDecision: scheduled.lastDecision,
|
|
231
|
+
lastCheckError: scheduled.lastCheckError,
|
|
232
|
+
},
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
catch (error) {
|
|
236
|
+
recordHandlerError('status', error);
|
|
237
|
+
return json({ error: 'E_STATUS', detail: String(error?.stack ?? error) }, 500);
|
|
238
|
+
}
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
{
|
|
242
|
+
path: '/api/dsh-user-sync/scan',
|
|
243
|
+
methods: ['GET'],
|
|
244
|
+
requestBody: 'buffered',
|
|
245
|
+
fetch: () => {
|
|
246
|
+
try {
|
|
247
|
+
const summary = scanSummary();
|
|
248
|
+
// 落盘诊断:面板显示异常时可直接从 state 读到宿主真实扫描结果
|
|
249
|
+
try {
|
|
250
|
+
const state = loadState(statePath);
|
|
251
|
+
state.diagnostics = {
|
|
252
|
+
...(state.diagnostics ?? {}),
|
|
253
|
+
lastScan: {
|
|
254
|
+
at: new Date().toISOString(),
|
|
255
|
+
dshHome: paths.dshHome,
|
|
256
|
+
profile: paths.profileName,
|
|
257
|
+
modules: summary.modules,
|
|
258
|
+
totalFiles: summary.totalFiles,
|
|
259
|
+
counts: summary.counts,
|
|
260
|
+
},
|
|
261
|
+
};
|
|
262
|
+
saveState(statePath, state);
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
// 诊断失败不影响扫描
|
|
266
|
+
}
|
|
267
|
+
return json(summary);
|
|
268
|
+
}
|
|
269
|
+
catch (error) {
|
|
270
|
+
recordHandlerError('scan', error);
|
|
271
|
+
return json({ error: error.message, detail: String(error?.stack ?? '') }, 500);
|
|
272
|
+
}
|
|
273
|
+
},
|
|
274
|
+
},
|
|
275
|
+
{
|
|
276
|
+
path: '/api/dsh-user-sync/config',
|
|
277
|
+
methods: ['GET', 'POST'],
|
|
278
|
+
requestBody: 'buffered',
|
|
279
|
+
fetch: async (request) => {
|
|
280
|
+
if (request.method === 'GET') {
|
|
281
|
+
const state = loadState(statePath);
|
|
282
|
+
const settings = scheduler.settings();
|
|
283
|
+
return json({
|
|
284
|
+
modules: sync.getModules(),
|
|
285
|
+
autoCheck: settings.autoCheck,
|
|
286
|
+
intervalHours: settings.intervalHours,
|
|
287
|
+
transport: state.transport ?? 'folder',
|
|
288
|
+
baseUrl: state.baseUrl ?? null,
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
const body = (await request.json());
|
|
292
|
+
const state = loadState(statePath);
|
|
293
|
+
if (Array.isArray(body?.modules)) {
|
|
294
|
+
const requested = body.modules.filter((item) => ALL_MODULES.includes(item));
|
|
295
|
+
if (requested.length === 0)
|
|
296
|
+
return json({ error: 'E_NO_VALID_MODULE' }, 400);
|
|
297
|
+
const warned = Array.isArray(state.warnedModules) ? state.warnedModules : [];
|
|
298
|
+
const newly = requested.filter((item) => NON_USER_LEVEL.includes(item) && !warned.includes(item));
|
|
299
|
+
if (newly.length > 0 && body?.confirmNonUserLevel !== true) {
|
|
300
|
+
return json({ warning: newly, note: '非用户级内容可能包含工作内容' });
|
|
301
|
+
}
|
|
302
|
+
// 注意:必须写进本函数的 state 对象,由末尾统一 saveState;
|
|
303
|
+
// 之前调 sync.setModules(内部写盘)后再用旧 state 覆盖,导致设置被回滚。
|
|
304
|
+
state.modules = requested;
|
|
305
|
+
if (newly.length > 0) {
|
|
306
|
+
state.warnedModules = [...new Set([...warned, ...newly])];
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
if (typeof body?.transport === 'string') {
|
|
310
|
+
if (!['folder', 'api'].includes(body.transport))
|
|
311
|
+
return json({ error: 'E_BAD_TRANSPORT' }, 400);
|
|
312
|
+
state.transport = body.transport;
|
|
313
|
+
}
|
|
314
|
+
if (typeof body?.baseUrl === 'string') {
|
|
315
|
+
const url = body.baseUrl.trim();
|
|
316
|
+
if (url && !/^https?:\/\//.test(url))
|
|
317
|
+
return json({ error: 'E_BAD_BASE_URL' }, 400);
|
|
318
|
+
state.baseUrl = url || null;
|
|
319
|
+
}
|
|
320
|
+
if (typeof body?.autoCheck === 'boolean')
|
|
321
|
+
state.autoCheck = body.autoCheck;
|
|
322
|
+
if (body?.intervalHours !== undefined && Number.isFinite(Number(body.intervalHours))) {
|
|
323
|
+
state.intervalHours = Math.min(168, Math.max(1, Number(body.intervalHours)));
|
|
324
|
+
}
|
|
325
|
+
saveState(statePath, state);
|
|
326
|
+
try {
|
|
327
|
+
sync.setTransport(buildTransport());
|
|
328
|
+
}
|
|
329
|
+
catch (error) {
|
|
330
|
+
log('set-transport', error);
|
|
331
|
+
}
|
|
332
|
+
return json({
|
|
333
|
+
modules: Array.isArray(state.modules) ? state.modules : sync.getModules(),
|
|
334
|
+
autoCheck: state.autoCheck ?? true,
|
|
335
|
+
intervalHours: state.intervalHours ?? 6,
|
|
336
|
+
transport: state.transport ?? 'folder',
|
|
337
|
+
baseUrl: state.baseUrl ?? null,
|
|
338
|
+
transportNow: sync.getTransportId(),
|
|
339
|
+
});
|
|
340
|
+
},
|
|
341
|
+
},
|
|
342
|
+
{
|
|
343
|
+
path: '/api/dsh-user-sync/consent',
|
|
344
|
+
methods: ['POST'],
|
|
345
|
+
requestBody: 'buffered',
|
|
346
|
+
fetch: async (request) => {
|
|
347
|
+
const body = (await request.json());
|
|
348
|
+
const state = loadState(statePath);
|
|
349
|
+
if (body?.accepted) {
|
|
350
|
+
state.consent = { acceptedAt: new Date().toISOString(), version: 1 };
|
|
351
|
+
}
|
|
352
|
+
else {
|
|
353
|
+
delete state.consent;
|
|
354
|
+
}
|
|
355
|
+
saveState(statePath, state);
|
|
356
|
+
return json({ consent: state.consent ?? null });
|
|
357
|
+
},
|
|
358
|
+
},
|
|
359
|
+
{
|
|
360
|
+
path: '/api/dsh-user-sync/upload',
|
|
361
|
+
methods: ['POST'],
|
|
362
|
+
requestBody: 'buffered',
|
|
363
|
+
fetch: async () => {
|
|
364
|
+
const state = loadState(statePath);
|
|
365
|
+
if (!state.consent?.acceptedAt) {
|
|
366
|
+
return json({ ok: false, code: 'E_CONSENT_REQUIRED', detail: '请先同意隐私说明再上传' }, 403);
|
|
367
|
+
}
|
|
368
|
+
return json(await sync.upload());
|
|
369
|
+
},
|
|
370
|
+
},
|
|
371
|
+
{
|
|
372
|
+
path: '/api/dsh-user-sync/check',
|
|
373
|
+
methods: ['GET'],
|
|
374
|
+
requestBody: 'buffered',
|
|
375
|
+
fetch: async () => {
|
|
376
|
+
const result = (await sync.check());
|
|
377
|
+
if (pluginManager && result.meta?.dependencies) {
|
|
378
|
+
try {
|
|
379
|
+
const bundles = await pluginManager.listBundles();
|
|
380
|
+
const installed = bundles.filter((bundle) => bundle.installed).map((bundle) => bundle.name);
|
|
381
|
+
const dependencies = (result.meta?.dependencies ?? {});
|
|
382
|
+
result.plugins = planPluginRecovery({ dependencies, installed });
|
|
383
|
+
}
|
|
384
|
+
catch (error) {
|
|
385
|
+
result.plugins = { error: error.message };
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
return json(result);
|
|
389
|
+
},
|
|
390
|
+
},
|
|
391
|
+
{
|
|
392
|
+
path: '/api/dsh-user-sync/install',
|
|
393
|
+
methods: ['POST'],
|
|
394
|
+
requestBody: 'buffered',
|
|
395
|
+
fetch: async (request) => {
|
|
396
|
+
const pm = pluginManager;
|
|
397
|
+
if (!pm)
|
|
398
|
+
return json({ ok: false, error: 'E_NO_PLUGIN_MANAGER' }, 503);
|
|
399
|
+
const body = (await request.json());
|
|
400
|
+
const packages = Array.isArray(body?.packages)
|
|
401
|
+
? body.packages.filter((item) => typeof item?.name === 'string')
|
|
402
|
+
: [];
|
|
403
|
+
if (packages.length === 0)
|
|
404
|
+
return json({ ok: false, error: 'E_NO_PACKAGES' }, 400);
|
|
405
|
+
const results = await installMissing({
|
|
406
|
+
plan: { missing: packages },
|
|
407
|
+
installBundle: (spec) => pm.installBundle(spec),
|
|
408
|
+
});
|
|
409
|
+
// pnpm 的 .modules.yaml 可能残留「已安装」记录,导致 installBundle 返回成功却不落盘;
|
|
410
|
+
// 装完检查目录,缺失时强制重装(pnpm install --force),保证「装了就是真的装了」。
|
|
411
|
+
const missingDirs = packages.filter((item) => !existsSync(join(paths.profileDir, 'node_modules', item.name)));
|
|
412
|
+
if (missingDirs.length > 0) {
|
|
413
|
+
try {
|
|
414
|
+
// process.execPath 是 Electron 主程序,必须找 DSH 运行时的 node
|
|
415
|
+
const runtime = findRuntimeNode();
|
|
416
|
+
const pnpmCjs = runtime ? join(dirname(runtime), '..', '..', 'pnpm', 'bin', 'pnpm.cjs') : '';
|
|
417
|
+
if (!runtime || !existsSync(pnpmCjs))
|
|
418
|
+
throw new Error('runtime node/pnpm not found');
|
|
419
|
+
execFileSync(runtime, [pnpmCjs, 'install', '--force'], {
|
|
420
|
+
cwd: paths.profileDir,
|
|
421
|
+
stdio: 'ignore',
|
|
422
|
+
timeout: 300000,
|
|
423
|
+
});
|
|
424
|
+
for (const item of missingDirs) {
|
|
425
|
+
const ok = existsSync(join(paths.profileDir, 'node_modules', item.name));
|
|
426
|
+
const entry = results.find((result) => result.name === item.name);
|
|
427
|
+
if (entry)
|
|
428
|
+
entry.ok = ok;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
catch (error) {
|
|
432
|
+
log('pnpm-force', error);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
return json({ ok: results.every((item) => item.ok), results });
|
|
436
|
+
},
|
|
437
|
+
},
|
|
438
|
+
{
|
|
439
|
+
path: '/api/dsh-user-sync/apply',
|
|
440
|
+
methods: ['POST'],
|
|
441
|
+
requestBody: 'buffered',
|
|
442
|
+
fetch: async (request) => {
|
|
443
|
+
const body = (await request.json());
|
|
444
|
+
if (!isPackageId(String(body?.name ?? '')))
|
|
445
|
+
return json({ error: 'E_BAD_NAME' }, 400);
|
|
446
|
+
return json(await sync.applyRemote(String(body.name)));
|
|
447
|
+
},
|
|
448
|
+
},
|
|
449
|
+
];
|
|
450
|
+
// pluginManager 也可能晚到;用于缺失插件检测与安装(§7.8)
|
|
451
|
+
try {
|
|
452
|
+
ctx.inject?.(['pluginManager'], (c) => {
|
|
453
|
+
try {
|
|
454
|
+
pluginManager = c.get('pluginManager');
|
|
455
|
+
}
|
|
456
|
+
catch (error) {
|
|
457
|
+
log('plugin-manager', error);
|
|
458
|
+
}
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
catch (error) {
|
|
462
|
+
log('plugin-manager-inject', error);
|
|
463
|
+
}
|
|
464
|
+
// PLUG-07 探针:本插件条目是否在 configEditor.entries() 可见(设置面板能否自助写回)
|
|
465
|
+
try {
|
|
466
|
+
ctx.inject?.(['configEditor'], (c) => {
|
|
467
|
+
try {
|
|
468
|
+
const editor = c.get('configEditor');
|
|
469
|
+
const entries = typeof editor?.entries === 'function' ? editor.entries() : [];
|
|
470
|
+
const visible = entries.some((entry) => entry?.options?.id === 'user-sync');
|
|
471
|
+
const state = loadState(statePath);
|
|
472
|
+
state.diagnostics = {
|
|
473
|
+
...(state.diagnostics ?? {}),
|
|
474
|
+
configEditorVisible: visible,
|
|
475
|
+
configEditorEntries: entries.length,
|
|
476
|
+
checkedAt: new Date().toISOString(),
|
|
477
|
+
};
|
|
478
|
+
saveState(statePath, state);
|
|
479
|
+
}
|
|
480
|
+
catch (error) {
|
|
481
|
+
log('config-editor', error);
|
|
482
|
+
}
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
catch (error) {
|
|
486
|
+
log('config-editor-inject', error);
|
|
487
|
+
}
|
|
488
|
+
// 启动时把宿主扫描结果写进 diagnostics:面板异常时可直接从 state 读到宿主真相
|
|
489
|
+
try {
|
|
490
|
+
const summary = scanSummary();
|
|
491
|
+
const state = loadState(statePath);
|
|
492
|
+
state.diagnostics = {
|
|
493
|
+
...(state.diagnostics ?? {}),
|
|
494
|
+
scanAtBoot: {
|
|
495
|
+
at: new Date().toISOString(),
|
|
496
|
+
dshHome: paths.dshHome,
|
|
497
|
+
userHome: paths.userHome,
|
|
498
|
+
profile: paths.profileName,
|
|
499
|
+
totalFiles: summary.totalFiles,
|
|
500
|
+
counts: summary.counts,
|
|
501
|
+
},
|
|
502
|
+
};
|
|
503
|
+
saveState(statePath, state);
|
|
504
|
+
}
|
|
505
|
+
catch (error) {
|
|
506
|
+
log('scan-at-boot', error);
|
|
507
|
+
}
|
|
508
|
+
// connection 服务在 apply 时可能还没就绪(web 启动顺序),必须用 ctx.inject 等待;
|
|
509
|
+
// 直接 ctx.get 会拿到 undefined,导致所有 API 路由静默不注册、面板按钮全部 404。
|
|
510
|
+
try {
|
|
511
|
+
ctx.inject?.(['connection'], (c) => {
|
|
512
|
+
try {
|
|
513
|
+
const connection = c.get('connection');
|
|
514
|
+
const register = connection?.fetch?.register;
|
|
515
|
+
if (typeof register !== 'function') {
|
|
516
|
+
log('routes', new Error('connection 缺少 fetch.register'));
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
for (const route of routes) {
|
|
520
|
+
c.effect(() => register(route), `dsh-user-sync: ${route.path}`);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
catch (error) {
|
|
524
|
+
log('routes', error);
|
|
525
|
+
}
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
catch (error) {
|
|
529
|
+
log('routes-inject', error);
|
|
530
|
+
}
|
|
531
|
+
// 官方账号自动配对:拿 accountKey 后按配置自动 register/login,用户无感(§9.3、§17.2)。
|
|
532
|
+
try {
|
|
533
|
+
ctx.inject?.(['deepseekAccount'], (c) => {
|
|
534
|
+
void (async () => {
|
|
535
|
+
try {
|
|
536
|
+
const account = c.get('deepseekAccount');
|
|
537
|
+
const profile = await account.getProfile({
|
|
538
|
+
version: PLUGIN_VERSION,
|
|
539
|
+
locale: 'zh-CN',
|
|
540
|
+
timezoneOffsetSeconds: -new Date().getTimezoneOffset() * 60,
|
|
541
|
+
});
|
|
542
|
+
const accountId = profile?.status === 'ready' ? profile.value?.id : null;
|
|
543
|
+
if (!accountId)
|
|
544
|
+
return;
|
|
545
|
+
const accountKey = deriveAccountKey(accountId);
|
|
546
|
+
const state = loadState(statePath);
|
|
547
|
+
state.accountKeyHint = accountKey.slice(0, 6);
|
|
548
|
+
saveState(statePath, state);
|
|
549
|
+
if (state.transport === 'api' && state.baseUrl) {
|
|
550
|
+
const api = createApiTransport({ baseUrl: state.baseUrl, authPath });
|
|
551
|
+
await api.register(accountKey);
|
|
552
|
+
const next = loadState(statePath);
|
|
553
|
+
next.pairedAt = new Date().toISOString();
|
|
554
|
+
saveState(statePath, next);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
catch (error) {
|
|
558
|
+
log('pair', error);
|
|
559
|
+
}
|
|
560
|
+
})();
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
catch (error) {
|
|
564
|
+
log('pair-inject', error);
|
|
565
|
+
}
|
|
566
|
+
ctx.effect?.(() => {
|
|
567
|
+
scheduler.start();
|
|
568
|
+
return () => scheduler.stop();
|
|
569
|
+
}, 'dsh-user-sync: scheduler');
|
|
570
|
+
ctx.effect?.(() => () => {
|
|
571
|
+
// 宿主半边没有常驻资源;生命周期钩子留给后续清理
|
|
572
|
+
}, 'dsh-user-sync: lifecycle');
|
|
573
|
+
try {
|
|
574
|
+
ctx.logger?.('dsh-user-sync')?.info?.('已挂载', { dshHome: paths.dshHome, profile: paths.profileName });
|
|
575
|
+
}
|
|
576
|
+
catch {
|
|
577
|
+
// logger 形态不同不影响挂载
|
|
578
|
+
}
|
|
579
|
+
return { paths, sync, scheduler, routes };
|
|
580
|
+
}
|
|
581
|
+
catch (error) {
|
|
582
|
+
log('apply', error);
|
|
583
|
+
return undefined;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
function readService(ctx, key) {
|
|
587
|
+
try {
|
|
588
|
+
return ctx.get?.(key, false);
|
|
589
|
+
}
|
|
590
|
+
catch {
|
|
591
|
+
return undefined;
|
|
592
|
+
}
|
|
593
|
+
}
|
package/lib/lww.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 新覆盖旧判定(G7)。
|
|
3
|
+
* 只按包级 createdAt 比较,不看文件 mtime;两侧相差 ≤ 5 分钟视为并发,转人工确认。
|
|
4
|
+
*/
|
|
5
|
+
export const CLOCK_SKEW_MS = 5 * 60 * 1000;
|
|
6
|
+
export function decide({ remote = null, lastApplied = null, lastUploaded = null, localFiles, appliedFiles, clockSkewMs = CLOCK_SKEW_MS, } = {}) {
|
|
7
|
+
const localChanged = detectLocalChanges(localFiles, appliedFiles);
|
|
8
|
+
if (!remote) {
|
|
9
|
+
return { action: 'push', reason: 'remote-missing', localChanged, remoteUpdated: false };
|
|
10
|
+
}
|
|
11
|
+
// 远端就是本机上次上传的那份包:只看本机在上传后有没有再改动。
|
|
12
|
+
// 否则「刚上传 → 再检查」会被误判成两侧并发。
|
|
13
|
+
if (lastUploaded?.packageId && remote.packageId && lastUploaded.packageId === remote.packageId) {
|
|
14
|
+
return localChanged
|
|
15
|
+
? { action: 'push', reason: 'local-changed-since-upload', localChanged, remoteUpdated: false }
|
|
16
|
+
: { action: 'skip', reason: 'in-sync', localChanged, remoteUpdated: false };
|
|
17
|
+
}
|
|
18
|
+
const remoteUpdated = !lastApplied || compareTime(remote.createdAt, lastApplied.createdAt) > 0;
|
|
19
|
+
if (!localChanged && !remoteUpdated) {
|
|
20
|
+
return { action: 'skip', reason: 'in-sync', localChanged, remoteUpdated };
|
|
21
|
+
}
|
|
22
|
+
if (remoteUpdated && !localChanged) {
|
|
23
|
+
return { action: 'pull', reason: 'remote-newer', localChanged, remoteUpdated };
|
|
24
|
+
}
|
|
25
|
+
if (!remoteUpdated && localChanged) {
|
|
26
|
+
return { action: 'push', reason: 'local-newer', localChanged, remoteUpdated };
|
|
27
|
+
}
|
|
28
|
+
// 两侧都有更新:拿远端包与「本机上次上传」比;相差 ≤ 阈值转人工
|
|
29
|
+
const remoteTime = toEpoch(remote.createdAt);
|
|
30
|
+
const uploadTime = toEpoch(lastUploaded?.createdAt);
|
|
31
|
+
const skewMs = Math.abs(remoteTime - uploadTime);
|
|
32
|
+
if (skewMs <= clockSkewMs) {
|
|
33
|
+
return { action: 'confirm', reason: 'clock-skew', localChanged, remoteUpdated, skewMs };
|
|
34
|
+
}
|
|
35
|
+
if (remoteTime > uploadTime) {
|
|
36
|
+
return { action: 'pull', reason: 'remote-newer', localChanged, remoteUpdated, skewMs };
|
|
37
|
+
}
|
|
38
|
+
return { action: 'push', reason: 'local-newer', localChanged, remoteUpdated, skewMs };
|
|
39
|
+
}
|
|
40
|
+
/** 解析时间字符串为毫秒;无法解析视为 0(最旧)。 */
|
|
41
|
+
export function toEpoch(value) {
|
|
42
|
+
if (value === null || value === undefined)
|
|
43
|
+
return 0;
|
|
44
|
+
const time = Date.parse(value);
|
|
45
|
+
return Number.isFinite(time) ? time : 0;
|
|
46
|
+
}
|
|
47
|
+
/** 比较两个时间字符串;缺省视为 0(最旧)。 */
|
|
48
|
+
export function compareTime(a, b) {
|
|
49
|
+
const ta = toEpoch(a);
|
|
50
|
+
const tb = toEpoch(b);
|
|
51
|
+
return ta === tb ? 0 : ta > tb ? 1 : -1;
|
|
52
|
+
}
|
|
53
|
+
/** 本地是否相对上次应用有改动:新增、删除、内容变化都算。 */
|
|
54
|
+
export function detectLocalChanges(localFiles, appliedFiles) {
|
|
55
|
+
if (!localFiles || !appliedFiles)
|
|
56
|
+
return false;
|
|
57
|
+
if (localFiles.size !== appliedFiles.size)
|
|
58
|
+
return true;
|
|
59
|
+
for (const [path, sha] of localFiles) {
|
|
60
|
+
if (appliedFiles.get(path) !== sha)
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
return false;
|
|
64
|
+
}
|