@pandablessing/dsh-user-sync 1.0.3 → 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 +18 -5
- 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/README.md
CHANGED
|
@@ -2,13 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
DeepSeek Harness(DSH)的**用户级配置同步插件**:把技能、插件清单、配置、记忆四类资产在设备间对齐。
|
|
4
4
|
|
|
5
|
-
> 当前状态:**v1.0.
|
|
5
|
+
> 当前状态:**v1.0.4**(已发布 npm:`@pandablessing/dsh-user-sync`)。本机打包/应用、跨机上传/恢复、缺插件装回、自动检查均已实现并实测;服务端已部署(见 [部署文档](docs/deploy.md))。
|
|
6
6
|
> 设计依据:[docs/design.md](docs/design.md);测试用例:[docs/test-cases.md](docs/test-cases.md)。
|
|
7
7
|
|
|
8
8
|
## 功能
|
|
9
9
|
|
|
10
10
|
- **四类用户级资产**:技能(`~/.agents/skills`)、插件清单(profile 声明)、配置(`cordis.patch.yml`)、用户记忆(`AGENTS.md` + `memories/` 根);项目记忆默认不选,会话不参与同步。
|
|
11
11
|
- **新覆盖旧**:包级 `createdAt` 比较,不做合并;覆盖前必须确认,先快照再写盘,失败可整批还原。
|
|
12
|
+
- **上传前查远端**:远端已被别的设备更新时先拦下(`E_REMOTE_NEWER`),用户点「用本机覆盖远端」才覆盖。
|
|
12
13
|
- **敏感数据硬拒绝**:文件名黑名单 + 内容模式,命中即拒绝打包并列出文件与行号。
|
|
13
14
|
- **官方账号自动配对**:用 `deepseekAccount` 取账号 id 派生 `accountKey`,自动向服务端换 token,**没有运营方账号登录入口**。
|
|
14
15
|
- **缺插件自动装回**:覆盖确认里列出缺失 bundle,一键 `installBundle`;`link:` 本地包列入手工清单。
|
|
@@ -23,6 +24,8 @@ cd ~/.dsh/profiles/desktop
|
|
|
23
24
|
pnpm add @pandablessing/dsh-user-sync
|
|
24
25
|
```
|
|
25
26
|
|
|
27
|
+
macOS / Linux 用同一组命令(`cd ~/.dsh/profiles/desktop && pnpm add @pandablessing/dsh-user-sync`);profile 的 `package.json` 与 `cordis.patch.yml` 里必须写 npm 包名 `@pandablessing/dsh-user-sync`(见 [v1.0.2 修复](CHANGELOG.md))。
|
|
28
|
+
|
|
26
29
|
安装后在 DSH 设置页启用「设置同步」。
|
|
27
30
|
|
|
28
31
|
### 开发期(junction 到本仓库)
|
|
@@ -41,10 +44,10 @@ scripts\uninstall-dev.cmd # 双击即可
|
|
|
41
44
|
|
|
42
45
|
1. 设置 → 设置同步 → 首次点「同意并启用上传」(说明上传范围与存储位置);
|
|
43
46
|
2. 勾选同步范围(默认四类用户级);
|
|
44
|
-
3. 「上传用户配置」→
|
|
47
|
+
3. 「上传用户配置」→ 打包并上传(远端更新时先确认再用本机覆盖);
|
|
45
48
|
4. 「从远端同步」→ 检查远端,覆盖前确认。
|
|
46
49
|
|
|
47
|
-
服务地址默认内置(`https://dsh-user-sync-test.wenzhi.info`),传输固定走运营方 API
|
|
50
|
+
服务地址默认内置(`https://dsh-user-sync-test.wenzhi.info`),传输固定走运营方 API;面板只读显示当前地址(`(内置)` / `(自定义)`),不提供输入框。需要改用自建服务时,在 `state.json` 写 `baseUrl` 即可覆盖。
|
|
48
51
|
|
|
49
52
|
## 服务端
|
|
50
53
|
|
|
@@ -71,8 +74,8 @@ pnpm install # 仅开发:安装 typescript 与 @types/node
|
|
|
71
74
|
pnpm run build # 插件:src/*.ts → lib/*.js
|
|
72
75
|
pnpm run build:server # 服务端:server/src/*.ts → server/dist/*.js
|
|
73
76
|
|
|
74
|
-
node --test test/*.test.js #
|
|
75
|
-
node scripts/audit-cases.mjs # 用例清单与结果记录一致性审计(
|
|
77
|
+
node --test test/*.test.js # 150 条:单元 / 组件 / 时序(直接跑源码,macOS 全绿)
|
|
78
|
+
node scripts/audit-cases.mjs # 用例清单与结果记录一致性审计(145/145)
|
|
76
79
|
node scripts/e2e-clone.mjs # 替代换机演练(默认本地模式,临时起服务端,不写真实 WebDAV)
|
|
77
80
|
# node scripts/e2e-clone.mjs --remote https://dsh-user-sync-test.wenzhi.info # 真实服务端(会留测试目录)
|
|
78
81
|
powershell -File scripts/smoke.ps1 # 真实 DSH 路由冒烟
|
|
@@ -93,10 +96,10 @@ legacy/ 需求反馈稿与原型脚本
|
|
|
93
96
|
|
|
94
97
|
## 已知限制
|
|
95
98
|
|
|
96
|
-
-
|
|
99
|
+
- macOS 上单测已全绿(Node 22.23.3),但 DSH 桌面端整机闭环只在 Windows 实测过;Linux 未验证。
|
|
97
100
|
- 服务端单节点,未做限流与多副本;坚果云单账号有流量配额限制。
|
|
98
101
|
- `link:` 本地依赖需要手工放回目标机。
|
|
99
|
-
- 远端只保留 1 份包,上传即覆盖(回滚靠本地 `pre-restore`
|
|
102
|
+
- 远端只保留 1 份包,上传即覆盖(回滚靠本地 `pre-restore` 快照;每次应用生成独立快照目录)。
|
|
100
103
|
|
|
101
104
|
## License
|
|
102
105
|
|
package/lib/client.js
CHANGED
|
@@ -159,7 +159,7 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
|
|
|
159
159
|
}
|
|
160
160
|
return parts.length > 0 ? parts.join(' · ') : '尚未同步';
|
|
161
161
|
}
|
|
162
|
-
function ResultCard({ notice, onApply, onConfirmModules, onDismiss, onInstallPlugins }) {
|
|
162
|
+
function ResultCard({ notice, onApply, onConfirmModules, onDismiss, onInstallPlugins, onForceUpload }) {
|
|
163
163
|
if (!notice)
|
|
164
164
|
return null;
|
|
165
165
|
const { kind, data } = notice;
|
|
@@ -180,8 +180,13 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
|
|
|
180
180
|
return R.createElement('div', { style: styles.card }, R.createElement('strong', null, decision.action === 'skip' ? '本机已是最新' : '本机有改动'), R.createElement('p', { style: styles.muted }, `判定:${decision.reason ?? decision.action}`));
|
|
181
181
|
}
|
|
182
182
|
if (kind === 'error') {
|
|
183
|
-
|
|
183
|
+
const remoteNewer = data.code === 'E_REMOTE_NEWER';
|
|
184
|
+
return R.createElement('div', { style: styles.card }, R.createElement('strong', { style: styles.error }, remoteNewer ? '远端有更新的包' : '操作失败'), R.createElement('p', { style: styles.muted }, data.detail ?? data.error ?? '未知错误'), data.hits
|
|
184
185
|
? R.createElement('p', { style: styles.error }, `敏感扫描命中 ${data.hits.length} 处:${data.hits.map((h) => `${h.file}:${h.line}(${h.pattern})`).join(';')}`)
|
|
186
|
+
: null,
|
|
187
|
+
// 上传前发现远端更新:默认不覆盖,用户确认后才用本机覆盖远端(G2)
|
|
188
|
+
remoteNewer
|
|
189
|
+
? R.createElement('div', { style: styles.actions }, R.createElement('button', { style: styles.primary, onClick: () => onForceUpload() }, '用本机覆盖远端'), R.createElement('button', { style: styles.secondary, onClick: () => onDismiss() }, '取消'))
|
|
185
190
|
: null);
|
|
186
191
|
}
|
|
187
192
|
return R.createElement('div', { style: styles.card }, R.createElement('strong', null, '完成'), R.createElement('p', { style: styles.muted }, data.detail
|
|
@@ -354,11 +359,16 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
|
|
|
354
359
|
setBusy(false);
|
|
355
360
|
}
|
|
356
361
|
};
|
|
357
|
-
|
|
362
|
+
/** force=true 表示用户已确认「用本机覆盖远端」(远端更新时才需要)。 */
|
|
363
|
+
const upload = async (force = false) => {
|
|
358
364
|
setBusy(true);
|
|
359
365
|
setNotice(null);
|
|
360
366
|
try {
|
|
361
|
-
const result = await fetchJson(`${API}/upload`, {
|
|
367
|
+
const result = await fetchJson(`${API}/upload`, {
|
|
368
|
+
method: 'POST',
|
|
369
|
+
headers: { 'content-type': 'application/json' },
|
|
370
|
+
body: JSON.stringify({ force }),
|
|
371
|
+
});
|
|
362
372
|
setNotice({ kind: result.ok ? 'result' : 'error', data: result });
|
|
363
373
|
refresh();
|
|
364
374
|
}
|
|
@@ -491,12 +501,15 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
|
|
|
491
501
|
style: { width: 72, padding: '2px 6px', fontSize: 13 },
|
|
492
502
|
}))), R.createElement('div', { style: styles.actions },
|
|
493
503
|
// 两个操作都用普通按钮:主色高亮会被看成「已选中」
|
|
494
|
-
R.createElement('button', { style: styles.secondary, disabled: busy || !consentGiven, onClick: upload }, busy ? '处理中…' : '上传用户配置'), R.createElement('button', { style: styles.secondary, disabled: busy, onClick: checkRemote }, '从远端同步')), R.createElement('p', { style: styles.muted }, lastSyncSummary(status)),
|
|
504
|
+
R.createElement('button', { style: styles.secondary, disabled: busy || !consentGiven, onClick: () => upload() }, busy ? '处理中…' : '上传用户配置'), R.createElement('button', { style: styles.secondary, disabled: busy, onClick: checkRemote }, '从远端同步')), R.createElement('p', { style: styles.muted }, lastSyncSummary(status)),
|
|
505
|
+
// 服务地址只读展示:默认内置,用户覆盖后标注来源(面板不再提供输入框)
|
|
506
|
+
R.createElement('p', { style: styles.muted }, `服务地址:${status?.baseUrl ?? '(未解析)'}${status?.baseUrlSource === 'user' ? '(自定义)' : '(内置)'}`), R.createElement(ResultCard, {
|
|
495
507
|
notice,
|
|
496
508
|
onApply: applyRemote,
|
|
497
509
|
onConfirmModules: confirmModules,
|
|
498
510
|
onDismiss: () => setNotice(null),
|
|
499
511
|
onInstallPlugins: installPlugins,
|
|
512
|
+
onForceUpload: () => upload(true),
|
|
500
513
|
}), confirmDialog
|
|
501
514
|
? R.createElement('div', { style: styles.overlay, onClick: () => setConfirmDialog(null) }, R.createElement('div', { style: styles.dialog, onClick: (event) => event.stopPropagation?.() }, R.createElement('strong', null, '同步项目/会话内容?'), R.createElement('p', { style: styles.muted }, `${confirmDialog.warning.map((id) => MODULE_LABELS[id] ?? id).join('、')} 属于项目/会话级,可能包含具体工作内容。确认要跨机器同步吗?`), R.createElement('div', { style: styles.actions }, R.createElement('button', { style: styles.primary, onClick: () => confirmModules(confirmDialog.modules) }, '仍要同步'), R.createElement('button', { style: styles.secondary, onClick: () => setConfirmDialog(null) }, '取消'))))
|
|
502
515
|
: null);
|
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
|
+
}
|