@pandablessing/dsh-user-sync 1.0.2 → 1.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -7
- package/lib/client.js +509 -492
- package/lib/index.js +68 -28
- package/lib/sync.js +70 -5
- package/lib/transport/api.js +13 -8
- package/package.json +7 -7
package/lib/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
1
2
|
import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync } from 'node:fs';
|
|
2
|
-
import { execFileSync } from 'node:child_process';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { dirname, join } from 'node:path';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
@@ -16,6 +16,8 @@ import { createFolderTransport } from './transport/folder.js';
|
|
|
16
16
|
export const name = 'dsh-user-sync';
|
|
17
17
|
/** 不硬依赖任何服务:依赖缺失时降级,不把条目停在 pending。 */
|
|
18
18
|
export const inject = [];
|
|
19
|
+
/** 内置服务地址(运营方 API)。用户可在 state.baseUrl 覆盖;这里必须是可用的正式地址。 */
|
|
20
|
+
export const DEFAULT_BASE_URL = 'https://dsh-user-sync-test.wenzhi.info';
|
|
19
21
|
/** 非用户级模块:默认不选,首次勾选要提示一次。 */
|
|
20
22
|
const NON_USER_LEVEL = ['projectMemory', 'sessions'];
|
|
21
23
|
/**
|
|
@@ -23,6 +25,27 @@ const NON_USER_LEVEL = ['projectMemory', 'sessions'];
|
|
|
23
25
|
* 日志落在 $DSH_HOME/user-sync/plugin-error.log(与状态文件同目录)。
|
|
24
26
|
* base 可显式传入,测试用它隔离到临时目录,避免污染真实日志。
|
|
25
27
|
*/
|
|
28
|
+
/** 异步执行外部命令:绝不阻塞宿主事件循环(Electron 主进程卡死会拖垮整个界面)。 */
|
|
29
|
+
function runCommand(file, args, options) {
|
|
30
|
+
return new Promise((resolve, reject) => {
|
|
31
|
+
const child = spawn(file, args, { cwd: options.cwd, stdio: 'ignore' });
|
|
32
|
+
const timer = setTimeout(() => {
|
|
33
|
+
child.kill();
|
|
34
|
+
reject(new Error(`命令超时(${options.timeout} ms): ${file}`));
|
|
35
|
+
}, options.timeout);
|
|
36
|
+
child.on('error', (error) => {
|
|
37
|
+
clearTimeout(timer);
|
|
38
|
+
reject(error);
|
|
39
|
+
});
|
|
40
|
+
child.on('close', (code) => {
|
|
41
|
+
clearTimeout(timer);
|
|
42
|
+
if (code === 0)
|
|
43
|
+
resolve();
|
|
44
|
+
else
|
|
45
|
+
reject(new Error(`命令退出码 ${code}: ${file}`));
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
}
|
|
26
49
|
/** 找 DSH 运行时自带的 node 可执行文件(Electron 的 process.execPath 不能用)。 */
|
|
27
50
|
function findRuntimeNode() {
|
|
28
51
|
const root = join(homedir(), '.dsh', 'dsh-runtimes');
|
|
@@ -87,18 +110,24 @@ export function apply(ctx) {
|
|
|
87
110
|
const authPath = join(paths.dshHome, 'user-sync', 'auth.json');
|
|
88
111
|
const folderDir = join(paths.dshHome, 'user-sync', 'remote');
|
|
89
112
|
/**
|
|
90
|
-
*
|
|
113
|
+
* 内置服务地址:用户未在 state 里覆盖时用它。
|
|
114
|
+
* 没有它,全新安装的机器会静默落到本地目录,既不配对也不连服务端(面板已不提供地址输入)。
|
|
115
|
+
*/
|
|
116
|
+
const effectiveBaseUrl = () => {
|
|
117
|
+
const state = loadState(statePath);
|
|
118
|
+
const configured = typeof state.baseUrl === 'string' ? state.baseUrl.trim() : '';
|
|
119
|
+
return configured || DEFAULT_BASE_URL;
|
|
120
|
+
};
|
|
121
|
+
/**
|
|
122
|
+
* 传输选择:默认走运营方 API(内置地址),地址非法或用户显式清空时才回退本地目录。
|
|
91
123
|
* 面板已隐藏「传输方式」,不再读 state.transport——避免历史设置把正式环境切成 folder。
|
|
92
124
|
*/
|
|
93
125
|
const buildTransport = () => {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
catch (error) {
|
|
100
|
-
log('transport-api', error);
|
|
101
|
-
}
|
|
126
|
+
try {
|
|
127
|
+
return createApiTransport({ baseUrl: effectiveBaseUrl(), authPath });
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
log('transport-api', error);
|
|
102
131
|
}
|
|
103
132
|
return createFolderTransport({ dir: folderDir });
|
|
104
133
|
};
|
|
@@ -213,8 +242,10 @@ export function apply(ctx) {
|
|
|
213
242
|
resolvedBy: paths.resolvedBy,
|
|
214
243
|
probe,
|
|
215
244
|
transport: sync.getTransportId(),
|
|
216
|
-
|
|
217
|
-
|
|
245
|
+
// 实际生效的传输方式(transport 已按内置地址解析,不再由 state.transport 决定)
|
|
246
|
+
transportSetting: sync.getTransportId(),
|
|
247
|
+
baseUrl: effectiveBaseUrl(),
|
|
248
|
+
baseUrlSource: typeof state.baseUrl === 'string' && state.baseUrl.trim() ? 'user' : 'default',
|
|
218
249
|
account: { hint: state.accountKeyHint ?? null, paired: Boolean(state.pairedAt) },
|
|
219
250
|
deviceId: state.deviceId ?? null,
|
|
220
251
|
lastApplied: state.lastApplied ?? null,
|
|
@@ -284,8 +315,9 @@ export function apply(ctx) {
|
|
|
284
315
|
modules: sync.getModules(),
|
|
285
316
|
autoCheck: settings.autoCheck,
|
|
286
317
|
intervalHours: settings.intervalHours,
|
|
287
|
-
transport:
|
|
288
|
-
baseUrl:
|
|
318
|
+
transport: sync.getTransportId(),
|
|
319
|
+
baseUrl: effectiveBaseUrl(),
|
|
320
|
+
baseUrlSource: typeof state.baseUrl === 'string' && state.baseUrl.trim() ? 'user' : 'default',
|
|
289
321
|
});
|
|
290
322
|
}
|
|
291
323
|
const body = (await request.json());
|
|
@@ -333,8 +365,8 @@ export function apply(ctx) {
|
|
|
333
365
|
modules: Array.isArray(state.modules) ? state.modules : sync.getModules(),
|
|
334
366
|
autoCheck: state.autoCheck ?? true,
|
|
335
367
|
intervalHours: state.intervalHours ?? 6,
|
|
336
|
-
transport:
|
|
337
|
-
baseUrl:
|
|
368
|
+
transport: sync.getTransportId(),
|
|
369
|
+
baseUrl: effectiveBaseUrl(),
|
|
338
370
|
transportNow: sync.getTransportId(),
|
|
339
371
|
});
|
|
340
372
|
},
|
|
@@ -360,12 +392,21 @@ export function apply(ctx) {
|
|
|
360
392
|
path: '/api/dsh-user-sync/upload',
|
|
361
393
|
methods: ['POST'],
|
|
362
394
|
requestBody: 'buffered',
|
|
363
|
-
fetch: async () => {
|
|
395
|
+
fetch: async (request) => {
|
|
364
396
|
const state = loadState(statePath);
|
|
365
397
|
if (!state.consent?.acceptedAt) {
|
|
366
398
|
return json({ ok: false, code: 'E_CONSENT_REQUIRED', detail: '请先同意隐私说明再上传' }, 403);
|
|
367
399
|
}
|
|
368
|
-
|
|
400
|
+
// force=true:用户已看到「远端有更新」并确认用本机覆盖远端(G2)
|
|
401
|
+
let force = false;
|
|
402
|
+
try {
|
|
403
|
+
const body = (await request.json());
|
|
404
|
+
force = body?.force === true;
|
|
405
|
+
}
|
|
406
|
+
catch {
|
|
407
|
+
// 无 body(面板首次点击)时按未确认处理
|
|
408
|
+
}
|
|
409
|
+
return json(await sync.upload({ force }));
|
|
369
410
|
},
|
|
370
411
|
},
|
|
371
412
|
{
|
|
@@ -416,10 +457,10 @@ export function apply(ctx) {
|
|
|
416
457
|
const pnpmCjs = runtime ? join(dirname(runtime), '..', '..', 'pnpm', 'bin', 'pnpm.cjs') : '';
|
|
417
458
|
if (!runtime || !existsSync(pnpmCjs))
|
|
418
459
|
throw new Error('runtime node/pnpm not found');
|
|
419
|
-
|
|
460
|
+
// 异步执行:pnpm 装插件可能要几十秒,同步调用会卡住宿主主线程
|
|
461
|
+
await runCommand(runtime, [pnpmCjs, 'install', '--force'], {
|
|
420
462
|
cwd: paths.profileDir,
|
|
421
|
-
|
|
422
|
-
timeout: 300000,
|
|
463
|
+
timeout: 300_000,
|
|
423
464
|
});
|
|
424
465
|
for (const item of missingDirs) {
|
|
425
466
|
const ok = existsSync(join(paths.profileDir, 'node_modules', item.name));
|
|
@@ -546,13 +587,12 @@ export function apply(ctx) {
|
|
|
546
587
|
const state = loadState(statePath);
|
|
547
588
|
state.accountKeyHint = accountKey.slice(0, 6);
|
|
548
589
|
saveState(statePath, state);
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
}
|
|
590
|
+
// 传输固定走运营方 API(内置地址或用户覆盖),未登录/离线时只记日志、不阻塞启动
|
|
591
|
+
const api = createApiTransport({ baseUrl: effectiveBaseUrl(), authPath });
|
|
592
|
+
await api.register(accountKey);
|
|
593
|
+
const next = loadState(statePath);
|
|
594
|
+
next.pairedAt = new Date().toISOString();
|
|
595
|
+
saveState(statePath, next);
|
|
556
596
|
}
|
|
557
597
|
catch (error) {
|
|
558
598
|
log('pair', error);
|
package/lib/sync.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
1
2
|
import { readFileSync } from 'node:fs';
|
|
2
3
|
import { join } from 'node:path';
|
|
3
4
|
import { buildManifest, buildTarGz, MANIFEST_PATH, normalizeManifest, parseTarGz } from './archive.js';
|
|
@@ -21,8 +22,17 @@ export function createSync({ dshHome, userHome, profileName = 'desktop', transpo
|
|
|
21
22
|
state: statePath ?? join(dshHome, 'user-sync', 'state.json'),
|
|
22
23
|
history: historyPath ?? join(dshHome, 'user-sync', 'history.jsonl'),
|
|
23
24
|
lock: lockPath ?? join(dshHome, 'user-sync', 'lock'),
|
|
24
|
-
|
|
25
|
+
preRestoreBase: preRestoreRoot ?? join(dshHome, 'user-sync'),
|
|
25
26
|
};
|
|
27
|
+
/**
|
|
28
|
+
* 每次应用都生成独立快照目录:同进程内多次恢复不会互相覆盖快照(§7.5)。
|
|
29
|
+
* 显式传入 preRestoreRoot 时它作为基目录,快照仍落在其下的时间戳子目录。
|
|
30
|
+
*/
|
|
31
|
+
function nextPreRestoreRoot() {
|
|
32
|
+
// 精确到毫秒 + 随机后缀:同一秒内连续两次应用也不会撞目录
|
|
33
|
+
const stamp = `${new Date().toISOString().replace(/[:.]/g, '-')}-${randomUUID().slice(0, 6)}`;
|
|
34
|
+
return join(paths.preRestoreBase, `pre-restore-${stamp}`);
|
|
35
|
+
}
|
|
26
36
|
/** 包内路径 → 绝对路径:skills/ 落到 userHome,其余落到 dshHome。 */
|
|
27
37
|
const resolveTarget = (rel) => {
|
|
28
38
|
if (rel.startsWith('skills/'))
|
|
@@ -110,13 +120,67 @@ export function createSync({ dshHome, userHome, profileName = 'desktop', transpo
|
|
|
110
120
|
}
|
|
111
121
|
return { ok: true, buffer: buildTarGz(tarEntries), manifest };
|
|
112
122
|
}
|
|
113
|
-
/**
|
|
114
|
-
|
|
123
|
+
/**
|
|
124
|
+
* 只读远端最新包(list + meta),失败时返回 null。
|
|
125
|
+
* 用于上传前判断「远端是否已被别的设备更新」,离线时不影响上传。
|
|
126
|
+
*/
|
|
127
|
+
async function latestRemote() {
|
|
128
|
+
let remoteList;
|
|
129
|
+
try {
|
|
130
|
+
remoteList = await activeTransport.list();
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
if (remoteList.length === 0)
|
|
136
|
+
return null;
|
|
137
|
+
const latest = [...remoteList].sort((a, b) => (Date.parse(b.modifiedAt) || 0) - (Date.parse(a.modifiedAt) || 0))[0];
|
|
138
|
+
let packageId = null;
|
|
139
|
+
let createdAt = latest.modifiedAt;
|
|
140
|
+
try {
|
|
141
|
+
const meta = await activeTransport.meta(latest.name);
|
|
142
|
+
if (meta?.packageId)
|
|
143
|
+
packageId = String(meta.packageId);
|
|
144
|
+
if (meta?.createdAt)
|
|
145
|
+
createdAt = String(meta.createdAt);
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
// 元数据不可读时退回列表时间;不阻塞上传
|
|
149
|
+
}
|
|
150
|
+
return { name: latest.name, packageId, createdAt };
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* 上传当前本地包。上传前先只读检查远端:
|
|
154
|
+
* 远端是另一份更新的包时返回 `E_REMOTE_NEWER`,由用户确认后带 `force` 重试(G2)。
|
|
155
|
+
*/
|
|
156
|
+
async function upload({ force = false } = {}) {
|
|
115
157
|
const lock = acquireLock({ lockPath: paths.lock });
|
|
116
158
|
if (!lock.acquired)
|
|
117
159
|
return { ok: false, code: 'E_LOCKED', holder: lock.holder };
|
|
118
160
|
const started = Date.now();
|
|
119
161
|
try {
|
|
162
|
+
if (!force) {
|
|
163
|
+
const state = loadState(paths.state);
|
|
164
|
+
const lastUploaded = state.lastUploaded;
|
|
165
|
+
const lastApplied = state.lastApplied;
|
|
166
|
+
const remote = await latestRemote();
|
|
167
|
+
// 远端就是本机上次上传/应用的包:不算「别人更新」,直接覆盖自己
|
|
168
|
+
const isMine = remote?.packageId
|
|
169
|
+
? remote.packageId === lastUploaded?.packageId || remote.packageId === lastApplied?.packageId
|
|
170
|
+
: false;
|
|
171
|
+
if (remote && !isMine) {
|
|
172
|
+
const remoteTime = Date.parse(remote.createdAt) || 0;
|
|
173
|
+
const localTime = Math.max(Date.parse(lastUploaded?.createdAt ?? '') || 0, Date.parse(lastApplied?.createdAt ?? '') || 0);
|
|
174
|
+
if (remoteTime > localTime) {
|
|
175
|
+
return {
|
|
176
|
+
ok: false,
|
|
177
|
+
code: 'E_REMOTE_NEWER',
|
|
178
|
+
detail: `远端有更新的包(${remote.name},${remote.createdAt}),确认后再用本机覆盖`,
|
|
179
|
+
remote: { name: remote.name, createdAt: remote.createdAt },
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
120
184
|
const packed = pack();
|
|
121
185
|
if (!packed.ok)
|
|
122
186
|
return packed;
|
|
@@ -151,7 +215,8 @@ export function createSync({ dshHome, userHome, profileName = 'desktop', transpo
|
|
|
151
215
|
/** 只读远端元数据,返回判定结果;不下载、不写盘。 */
|
|
152
216
|
async function check() {
|
|
153
217
|
const remoteList = await activeTransport.list();
|
|
154
|
-
|
|
218
|
+
// 服务端按账号只留 1 份,但历史遗留多份时按时间取最新,不能依赖列表顺序
|
|
219
|
+
const latest = [...remoteList].sort((a, b) => (Date.parse(b.modifiedAt) || 0) - (Date.parse(a.modifiedAt) || 0))[0] ?? null;
|
|
155
220
|
const meta = latest ? await activeTransport.meta(latest.name) : null;
|
|
156
221
|
const state = loadState(paths.state);
|
|
157
222
|
const { map: localFiles } = currentFiles();
|
|
@@ -210,7 +275,7 @@ export function createSync({ dshHome, userHome, profileName = 'desktop', transpo
|
|
|
210
275
|
}
|
|
211
276
|
const { map: localFiles } = currentFiles();
|
|
212
277
|
const plan = planApply({ manifestFiles: manifest.files, localFiles });
|
|
213
|
-
const applied = applyEntries({ entries, resolveTarget, preRestoreRoot:
|
|
278
|
+
const applied = applyEntries({ entries, resolveTarget, preRestoreRoot: nextPreRestoreRoot() });
|
|
214
279
|
// PLUG-06:包内 cordis.patch.yml 引用的环境变量在本机缺失时提示(「插件装了但用不了」)
|
|
215
280
|
const missingEnv = [];
|
|
216
281
|
for (const entry of entries) {
|
package/lib/transport/api.js
CHANGED
|
@@ -85,6 +85,17 @@ export function createApiTransport({ baseUrl, authPath, token: initialToken, fet
|
|
|
85
85
|
const text = await response.text();
|
|
86
86
|
return text ? JSON.parse(text) : null;
|
|
87
87
|
};
|
|
88
|
+
/**
|
|
89
|
+
* 用同一 accountKey 取回同一个 token(不签发新 token)。
|
|
90
|
+
* 抽成独立函数:register 里改走 login 时不依赖 `this`,解构调用也不会炸。
|
|
91
|
+
*/
|
|
92
|
+
async function loginWith(accountKey) {
|
|
93
|
+
const response = await request('POST', '/v1/auth/login', { body: JSON.stringify({ accountKey }) });
|
|
94
|
+
const data = await parseJson(response);
|
|
95
|
+
const token = String(data?.token ?? '');
|
|
96
|
+
persistAuth({ token, accountKeyHint: accountKey.slice(0, 6), registeredAt: new Date().toISOString() });
|
|
97
|
+
return { ok: true, token };
|
|
98
|
+
}
|
|
88
99
|
return {
|
|
89
100
|
id: 'api',
|
|
90
101
|
baseUrl,
|
|
@@ -102,17 +113,11 @@ export function createApiTransport({ baseUrl, authPath, token: initialToken, fet
|
|
|
102
113
|
}
|
|
103
114
|
catch (error) {
|
|
104
115
|
if (error.code === 'E_HTTP_409')
|
|
105
|
-
return
|
|
116
|
+
return loginWith(accountKey);
|
|
106
117
|
throw error;
|
|
107
118
|
}
|
|
108
119
|
},
|
|
109
|
-
|
|
110
|
-
const response = await request('POST', '/v1/auth/login', { body: JSON.stringify({ accountKey }) });
|
|
111
|
-
const data = await parseJson(response);
|
|
112
|
-
const token = String(data?.token ?? '');
|
|
113
|
-
persistAuth({ token, accountKeyHint: accountKey.slice(0, 6), registeredAt: new Date().toISOString() });
|
|
114
|
-
return { ok: true, token };
|
|
115
|
-
},
|
|
120
|
+
login: loginWith,
|
|
116
121
|
async reset() {
|
|
117
122
|
await request('POST', '/v1/auth/reset');
|
|
118
123
|
clearAuth();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pandablessing/dsh-user-sync",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.4",
|
|
4
4
|
"description": "DeepSeek Harness 用户级配置同步插件",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -31,6 +31,11 @@
|
|
|
31
31
|
"platform": "web"
|
|
32
32
|
}
|
|
33
33
|
},
|
|
34
|
+
"scripts": {
|
|
35
|
+
"build": "tsc -p tsconfig.json",
|
|
36
|
+
"build:server": "tsc -p server/tsconfig.json",
|
|
37
|
+
"test": "node --test test/*.test.js"
|
|
38
|
+
},
|
|
34
39
|
"license": "MulanPSL-2.0",
|
|
35
40
|
"keywords": [
|
|
36
41
|
"dsh",
|
|
@@ -54,10 +59,5 @@
|
|
|
54
59
|
},
|
|
55
60
|
"publishConfig": {
|
|
56
61
|
"access": "public"
|
|
57
|
-
},
|
|
58
|
-
"scripts": {
|
|
59
|
-
"build": "tsc -p tsconfig.json",
|
|
60
|
-
"build:server": "tsc -p server/tsconfig.json",
|
|
61
|
-
"test": "node --test test/*.test.js"
|
|
62
62
|
}
|
|
63
|
-
}
|
|
63
|
+
}
|