@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/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
- * 传输选择:配了服务地址就用运营方 API,否则回退本地目录(无服务端调试)。
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
- 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
- }
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
- transportSetting: state.transport ?? 'folder',
217
- baseUrl: state.baseUrl ?? null,
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: state.transport ?? 'folder',
288
- baseUrl: state.baseUrl ?? null,
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: state.transport ?? 'folder',
337
- baseUrl: state.baseUrl ?? null,
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
- return json(await sync.upload());
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
- execFileSync(runtime, [pnpmCjs, 'install', '--force'], {
460
+ // 异步执行:pnpm 装插件可能要几十秒,同步调用会卡住宿主主线程
461
+ await runCommand(runtime, [pnpmCjs, 'install', '--force'], {
420
462
  cwd: paths.profileDir,
421
- stdio: 'ignore',
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
- 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
- }
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
- preRestore: preRestoreRoot ?? join(dshHome, 'user-sync', `pre-restore-${Date.now()}`),
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
- async function upload() {
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
- const latest = remoteList[0] ?? null;
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: paths.preRestore });
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) {
@@ -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 this.login(accountKey);
116
+ return loginWith(accountKey);
106
117
  throw error;
107
118
  }
108
119
  },
109
- async login(accountKey) {
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.2",
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
+ }