@pandablessing/dsh-user-sync 1.0.7 → 1.0.10

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 CHANGED
@@ -87,6 +87,8 @@ powershell -File scripts/smoke.ps1 # 真实 DSH 路由冒烟
87
87
 
88
88
  ## 发布
89
89
 
90
+ > **发布时机由维护者决定**:`scripts/publish.mjs` 只在手动执行时发布,agent 不自动发布(改版本、构建、dry-run 可以提前做,发布等确认)。
91
+
90
92
  token 只放本地文件,绝不进仓库(`.env.local` 已在 `.gitignore`):
91
93
 
92
94
  ```bash
@@ -99,6 +101,12 @@ node scripts/publish.mjs --otp=123456 # 账号开 2FA 时
99
101
  - CI 用 secret 注入 `NPM_TOKEN` 环境变量即可,优先级高于 `.env.local`。
100
102
  - 发布前记得先 `pnpm run build && pnpm run build:server`,产物与源码不一致时 tarball 里会是旧代码。
101
103
 
104
+ ## 开发与真实环境
105
+
106
+ - **日常开发(junction)**:`scripts/install-dev.ps1` 把仓库 link 进 profile,改源码 → `pnpm run build` → 热重载即生效;调试最快。
107
+ - **真实安装(npm 包)**:`node scripts/verify-npm-install.mjs` 在临时 profile 里 `pnpm add` 当前发布版本,校验 patch name、宿主/client 两个入口、locale 与 patch 资源,用完自动删目录,不影响开发 profile。
108
+ - 为什么分开:npm 安装的是 `lib/*.js` 编译产物副本,改仓库源码不会生效;所以开发用 link,验证真实安装路径用临时 profile。
109
+
102
110
  ## 仓库结构
103
111
 
104
112
  ```text
package/lib/client.js CHANGED
@@ -162,24 +162,12 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
162
162
  }
163
163
  return parts.length > 0 ? parts.join(' · ') : '尚未同步';
164
164
  }
165
- function ResultCard({ notice, onApply, onConfirmModules, onDismiss, onInstallPlugins, onForceUpload }) {
165
+ function ResultCard({ notice, onConfirmModules, onDismiss, onForceUpload }) {
166
166
  if (!notice)
167
167
  return null;
168
168
  const { kind, data } = notice;
169
169
  if (kind === 'check') {
170
170
  const decision = data.decision ?? {};
171
- if (decision.action === 'pull' || decision.action === 'confirm') {
172
- const plan = data.plan ?? { added: [], overwritten: [], skipped: [] };
173
- const missing = data.plugins?.missing ?? [];
174
- const linkDeps = data.plugins?.linkDeps ?? [];
175
- return R.createElement('div', { style: styles.card }, R.createElement('strong', null, decision.action === 'confirm' ? '两侧都有更新,需要你决定' : '远端有更新'), R.createElement('p', { style: styles.muted }, `远端包 ${data.latest?.name ?? ''}(${data.meta?.createdAt ?? '时间未知'}),新增 ${(plan.added ?? []).length} / 覆盖 ${(plan.overwritten ?? []).length} / 跳过 ${(plan.skipped ?? []).length} 个文件。`), missing.length > 0
176
- ? R.createElement('p', { style: styles.muted }, `缺失插件:${missing.map((item) => `${item.name}@${item.spec}`).join('、')}`)
177
- : null, linkDeps.length > 0
178
- ? R.createElement('p', { style: styles.muted }, `本地包需手动放回:${linkDeps.map((item) => item.name).join('、')}`)
179
- : null, R.createElement('div', { style: styles.actions }, missing.length > 0
180
- ? R.createElement('button', { style: styles.primary, onClick: () => onInstallPlugins(missing) }, '先安装缺失插件')
181
- : null, R.createElement('button', { style: styles.secondary, onClick: () => onDismiss() }, '取消')), R.createElement('p', { style: styles.muted }, '覆盖动作会在弹窗中确认。'));
182
- }
183
171
  return R.createElement('div', { style: styles.card }, R.createElement('strong', null, decision.action === 'skip' ? '本机已是最新' : '本机有改动'), R.createElement('p', { style: styles.muted }, `判定:${decision.reason ?? decision.action}`));
184
172
  }
185
173
  if (kind === 'error') {
@@ -225,6 +213,8 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
225
213
  const [confirmDialog, setConfirmDialog] = R.useState(null);
226
214
  // 覆盖确认弹窗:上传发现远端更新 / 从远端同步发现远端更新时弹出(不再用结果卡按钮)
227
215
  const [overwriteDialog, setOverwriteDialog] = R.useState(null);
216
+ // 版本检查结果:有新版本时在面板顶部提示(不自动安装)
217
+ const [updateInfo, setUpdateInfo] = R.useState(null);
228
218
  // 当前展开文件清单的模块(null 表示都收起)
229
219
  const [expandedModule, setExpandedModule] = R.useState(null);
230
220
  // 设置改动后自动保存(600ms 防抖),不再提供「保存设置」按钮
@@ -270,6 +260,10 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
270
260
  };
271
261
  R.useEffect(() => {
272
262
  refresh();
263
+ // 版本检查失败不影响面板(网络不可用时静默)
264
+ fetchJson(`${API}/update-check`)
265
+ .then((data) => setUpdateInfo(data))
266
+ .catch(() => setUpdateInfo(null));
273
267
  }, []);
274
268
  // 设置改动后 600ms 自动保存(输入间隔时不会每敲一下都发请求)
275
269
  R.useEffect(() => {
@@ -411,29 +405,6 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
411
405
  setBusy(false);
412
406
  }
413
407
  };
414
- const installPlugins = async (packages) => {
415
- setBusy(true);
416
- try {
417
- const result = await fetchJson(`${API}/install`, {
418
- method: 'POST',
419
- headers: { 'content-type': 'application/json' },
420
- body: JSON.stringify({ packages }),
421
- });
422
- if (result.ok) {
423
- setNotice({ kind: 'result', data: { detail: `已安装 ${result.results?.length ?? 0} 个插件,重启 DSH 后生效` } });
424
- }
425
- else {
426
- setNotice({ kind: 'error', data: { detail: `安装失败:${(result.results ?? []).filter((item) => !item.ok).map((item) => `${item.name}(${item.error})`).join(';') || result.error}` } });
427
- }
428
- refresh();
429
- }
430
- catch (error) {
431
- setNotice({ kind: 'error', data: { detail: String(error) } });
432
- }
433
- finally {
434
- setBusy(false);
435
- }
436
- };
437
408
  const checkRemote = async () => {
438
409
  setBusy(true);
439
410
  setNotice(null);
@@ -488,8 +459,8 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
488
459
  // 计数优先用 status 的宿主探针(与 /scan 同一条扫描路径),
489
460
  // 这样 /scan 请求失败时面板仍能显示真实数量。
490
461
  const counts = status?.probe?.counts ?? scan?.counts ?? {};
491
- return R.createElement('div', { style: styles.section }, R.createElement('h2', { style: styles.heading }, '设置同步'), status?.scheduler?.lastDecision?.action === 'pull' && consentGiven
492
- ? R.createElement('div', { style: styles.card }, R.createElement('strong', null, '远端有更新'), R.createElement('p', { style: styles.muted }, `检测到 ${fmtTime(status.scheduler.lastCheckAt)} 的远端更新(来自 ${status.scheduler.lastDecision.packageId ?? '远端'})。`), R.createElement('div', { style: styles.actions }, R.createElement('button', { style: styles.secondary, disabled: busy, onClick: checkRemote }, '立即同步')))
462
+ return R.createElement('div', { style: styles.section }, R.createElement('h2', { style: styles.heading }, '设置同步'), updateInfo?.available
463
+ ? R.createElement('p', { style: styles.warn }, `插件有新版本 v${updateInfo.latest}(当前 v${updateInfo.current}):pnpm add @pandablessing/dsh-user-sync@${updateInfo.latest}`)
493
464
  : null, !consentGiven
494
465
  ? R.createElement('div', { style: styles.card }, R.createElement('strong', null, '启用前请确认'), R.createElement('p', { style: styles.muted }, '上传内容:技能、插件清单、配置、用户记忆。数据经运营方服务中转并存放在运营方账号(非 DSH 官方服务器);远端只保留 1 份,上传即覆盖。'), R.createElement('div', { style: styles.actions },
495
466
  // 用普通按钮:主色按钮看起来像「已选中」,同意是主动作但不应暗示状态
@@ -552,10 +523,8 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
552
523
  // 服务地址只读展示:默认内置,用户覆盖后标注来源(面板不再提供输入框)
553
524
  R.createElement('p', { style: styles.muted }, `服务地址:${status?.baseUrl ?? '(未解析)'}${status?.baseUrlSource === 'user' ? '(自定义)' : '(内置)'}`), R.createElement(ResultCard, {
554
525
  notice,
555
- onApply: applyRemote,
556
526
  onConfirmModules: confirmModules,
557
527
  onDismiss: () => setNotice(null),
558
- onInstallPlugins: installPlugins,
559
528
  onForceUpload: () => upload(true),
560
529
  }), confirmDialog
561
530
  ? 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) }, '取消'))))
package/lib/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { spawn } from 'node:child_process';
2
- import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
2
+ import { appendFileSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, symlinkSync, writeFileSync } from 'node:fs';
3
3
  import { homedir } from 'node:os';
4
4
  import { dirname, join } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
@@ -28,7 +28,13 @@ const NON_USER_LEVEL = ['projectMemory', 'sessions'];
28
28
  /** 异步执行外部命令:绝不阻塞宿主事件循环(Electron 主进程卡死会拖垮整个界面)。 */
29
29
  function runCommand(file, args, options) {
30
30
  return new Promise((resolve, reject) => {
31
- const child = spawn(file, args, { cwd: options.cwd, stdio: 'ignore' });
31
+ // 保留 stderr:安装失败时把它写进 plugin-error.log,便于定位(此前只有退出码)
32
+ const child = spawn(file, args, { cwd: options.cwd, stdio: ['ignore', 'pipe', 'pipe'] });
33
+ let stderr = '';
34
+ child.stderr?.on('data', (chunk) => {
35
+ if (stderr.length < 4000)
36
+ stderr += chunk.toString('utf8');
37
+ });
32
38
  const timer = setTimeout(() => {
33
39
  child.kill();
34
40
  reject(new Error(`命令超时(${options.timeout} ms): ${file}`));
@@ -42,7 +48,7 @@ function runCommand(file, args, options) {
42
48
  if (code === 0)
43
49
  resolve();
44
50
  else
45
- reject(new Error(`命令退出码 ${code}: ${file}`));
51
+ reject(new Error(`命令退出码 ${code}: ${file} ${args.join(' ')}\n${stderr.slice(-800)}`));
46
52
  });
47
53
  });
48
54
  }
@@ -211,8 +217,10 @@ export function apply(ctx) {
211
217
  };
212
218
  /**
213
219
  * 按 profile 的 package.json 安装依赖。
214
- * `link:` / `file:` 指向不存在的本地路径时,pnpm install 会整体失败(连带普通包也装不上),
215
- * 因此安装期间临时把这些坏 link 从 package.json 摘掉,装完原样恢复。
220
+ * 两类依赖会让 pnpm install 整体失败(连带普通包都装不上):
221
+ * - `link:` / `file:` 指向不存在的本地路径
222
+ * - `node_modules` 里是 junction/符号链接(开发期 link 装的插件,pnpm 重装会 EPERM)
223
+ * 因此安装期间临时把这些依赖从 package.json 摘掉,装完原样恢复,并重建被 pnpm 移走的链接。
216
224
  */
217
225
  const installProfileDependencies = async () => {
218
226
  const rt = pnpmRuntime();
@@ -222,6 +230,7 @@ export function apply(ctx) {
222
230
  const workspacePath = join(paths.profileDir, 'pnpm-workspace.yaml');
223
231
  const raw = readFileSync(pkgPath, 'utf8');
224
232
  const removedLinks = [];
233
+ const linkTargets = new Map();
225
234
  let workspaceRaw = null;
226
235
  const missingPatchLines = [];
227
236
  try {
@@ -236,25 +245,44 @@ export function apply(ctx) {
236
245
  missingPatchLines.push(line);
237
246
  }
238
247
  if (missingPatchLines.length > 0) {
239
- const patched = workspaceRaw
240
- .split('\n')
241
- .filter((line) => !missingPatchLines.includes(line))
242
- .join('\n');
243
- writeFileSync(workspacePath, patched, 'utf8');
248
+ const kept = workspaceRaw.split('\n').filter((line) => !missingPatchLines.includes(line));
249
+ // 段内条目全删光时,连 `patchedDependencies:` 键一起删:pnpm 不接受 null
250
+ const keyIndex = kept.findIndex((line) => line.trim() === 'patchedDependencies:');
251
+ if (keyIndex >= 0) {
252
+ const next = kept[keyIndex + 1];
253
+ if (next === undefined || !/^\s{2}\S/.test(next))
254
+ kept.splice(keyIndex, 1);
255
+ }
256
+ writeFileSync(workspacePath, kept.join('\n'), 'utf8');
244
257
  }
245
258
  }
246
259
  catch {
247
260
  workspaceRaw = null;
248
261
  }
249
- const brokenLinks = Object.entries(dependencies).filter(([, spec]) => {
250
- if (!spec.startsWith('link:') && !spec.startsWith('file:'))
251
- return false;
252
- const target = join(paths.profileDir, spec.slice(5));
253
- return !existsSync(target);
262
+ // 需要临时摘除的依赖:坏 link / 文件依赖 + 已经是 junction 的已装包
263
+ const detached = Object.entries(dependencies).filter(([name, spec]) => {
264
+ if (spec.startsWith('link:') || spec.startsWith('file:')) {
265
+ const target = join(paths.profileDir, spec.slice(5));
266
+ if (!existsSync(target))
267
+ return true;
268
+ linkTargets.set(name, target);
269
+ return true;
270
+ }
271
+ const abs = join(paths.profileDir, 'node_modules', name);
272
+ try {
273
+ if (lstatSync(abs).isSymbolicLink()) {
274
+ linkTargets.set(name, realpathSync(abs));
275
+ return true;
276
+ }
277
+ }
278
+ catch {
279
+ // 没装或读不到:交给 pnpm 正常安装
280
+ }
281
+ return false;
254
282
  });
255
- if (brokenLinks.length > 0) {
283
+ if (detached.length > 0) {
256
284
  const patched = { ...pkg, dependencies: { ...dependencies } };
257
- for (const [name] of brokenLinks) {
285
+ for (const [name] of detached) {
258
286
  delete patched.dependencies[name];
259
287
  removedLinks.push(name);
260
288
  }
@@ -265,8 +293,21 @@ export function apply(ctx) {
265
293
  finally {
266
294
  if (removedLinks.length > 0)
267
295
  writeFileSync(pkgPath, raw, 'utf8');
268
- if (workspaceRaw !== null && missingPatchLines?.length > 0)
269
- writeFileSync(join(paths.profileDir, 'pnpm-workspace.yaml'), workspaceRaw, 'utf8');
296
+ if (workspaceRaw !== null && missingPatchLines.length > 0)
297
+ writeFileSync(workspacePath, workspaceRaw, 'utf8');
298
+ // pnpm 可能把不在依赖树里的链接移进 .ignored;装完重建,保证开发期 link 不断
299
+ for (const [name, target] of linkTargets) {
300
+ const abs = join(paths.profileDir, 'node_modules', name);
301
+ if (existsSync(abs))
302
+ continue;
303
+ try {
304
+ mkdirSync(dirname(abs), { recursive: true });
305
+ symlinkSync(target, abs, process.platform === 'win32' ? 'junction' : 'dir');
306
+ }
307
+ catch (error) {
308
+ log('relink', error);
309
+ }
310
+ }
270
311
  }
271
312
  return { removedLinks };
272
313
  };
@@ -312,7 +353,62 @@ export function apply(ctx) {
312
353
  }));
313
354
  return { installed, linkDeps: plan.linkDeps.map(({ name, spec }) => ({ name, spec })) };
314
355
  };
356
+ /** 判断 latest 是否比 current 新(只比较 x.y.z 数字段)。 */
357
+ const isNewerVersion = (latest, current) => {
358
+ const toParts = (value) => value.split('-')[0].split('.').map((part) => Number.parseInt(part, 10) || 0);
359
+ const a = toParts(latest);
360
+ const b = toParts(current);
361
+ for (let i = 0; i < Math.max(a.length, b.length); i++) {
362
+ const left = a[i] ?? 0;
363
+ const right = b[i] ?? 0;
364
+ if (left !== right)
365
+ return left > right;
366
+ }
367
+ return false;
368
+ };
369
+ /** 版本检查:查 npm registry 的 dist-tags 与当前版本比对(30 分钟缓存,失败静默)。 */
370
+ let versionCache = null;
371
+ const checkLatestVersion = async () => {
372
+ const now = Date.now();
373
+ if (versionCache && now - versionCache.at < 30 * 60_000) {
374
+ const latest = versionCache.latest;
375
+ return { current: PLUGIN_VERSION, latest, available: Boolean(latest && isNewerVersion(latest, PLUGIN_VERSION)) };
376
+ }
377
+ const registry = process.env.NPM_REGISTRY || 'https://registry.npmjs.org';
378
+ const encoded = encodeURIComponent('@pandablessing/dsh-user-sync').replace('%2F', '%2f');
379
+ const controller = new AbortController();
380
+ const timer = setTimeout(() => controller.abort(), 5000);
381
+ try {
382
+ const response = await fetch(`${registry}/-/package/${encoded}/dist-tags`, { signal: controller.signal });
383
+ const tags = (await response.json());
384
+ const latest = typeof tags.latest === 'string' ? tags.latest : null;
385
+ versionCache = { at: now, latest };
386
+ return { current: PLUGIN_VERSION, latest, available: Boolean(latest && isNewerVersion(latest, PLUGIN_VERSION)) };
387
+ }
388
+ catch {
389
+ versionCache = { at: now, latest: null };
390
+ return { current: PLUGIN_VERSION, latest: null, available: false };
391
+ }
392
+ finally {
393
+ clearTimeout(timer);
394
+ }
395
+ };
315
396
  const routes = [
397
+ {
398
+ // 版本检查:面板据此提示「有新版本可升级」,不做自动安装
399
+ path: '/api/dsh-user-sync/update-check',
400
+ methods: ['GET'],
401
+ requestBody: 'buffered',
402
+ fetch: async () => {
403
+ try {
404
+ return json(await checkLatestVersion());
405
+ }
406
+ catch (error) {
407
+ recordHandlerError('update-check', error);
408
+ return json({ current: PLUGIN_VERSION, latest: null, available: false });
409
+ }
410
+ },
411
+ },
316
412
  {
317
413
  // 最小同步路由:用于区分「同步 handler 本身不可用」与「status/scan 具体逻辑抛错」
318
414
  path: '/api/dsh-user-sync/ping',
@@ -1,175 +1,175 @@
1
- import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2
- import { dirname } from 'node:path';
3
- import { isPackageId } from '../archive.js';
4
- const OFFICIAL_HOST = /(^|\.)deepseek\.com$/i;
5
- export class ApiError extends Error {
6
- code;
7
- status;
8
- constructor(code, message, status) {
9
- super(message);
10
- this.code = code;
11
- this.status = status;
12
- }
13
- }
14
- /**
15
- * 运营方 API 传输(B 模型)。插件侧只持有用户自己的 token,不接触存储凭据。
16
- * 认证:register 首次建账号,login 取回同一 token;token 存 auth.json(0600)。
17
- */
18
- export function createApiTransport({ baseUrl, authPath, token: initialToken, fetchImpl = globalThis.fetch, timeoutMs = 30_000, retries = 3, }) {
19
- if (!baseUrl)
20
- throw new Error('缺少 baseUrl');
21
- const host = new URL(baseUrl).hostname;
22
- if (OFFICIAL_HOST.test(host)) {
23
- throw new ApiError('E_OFFICIAL_HOST', `拒绝官方域名: ${host}`);
24
- }
25
- let token = initialToken ?? null;
26
- if (!token && authPath && existsSync(authPath)) {
27
- try {
28
- token = JSON.parse(readFileSync(authPath, 'utf8')).token ?? null;
29
- }
30
- catch {
31
- token = null;
32
- }
33
- }
34
- function persistAuth(next) {
35
- token = next.token;
36
- if (!authPath)
37
- return;
38
- mkdirSync(dirname(authPath), { recursive: true });
39
- writeFileSync(authPath, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
40
- }
41
- function clearAuth() {
42
- token = null;
43
- if (authPath)
44
- rmSync(authPath, { force: true });
45
- }
46
- const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
47
- async function request(method, path, { body, headers = {}, expectBytes = false } = {}) {
48
- const url = `${baseUrl.replace(/\/$/, '')}${path}`;
49
- let lastError;
50
- for (let attempt = 0; attempt <= retries; attempt++) {
51
- try {
52
- const response = await fetchImpl(url, {
53
- method,
54
- headers: {
55
- ...(token ? { authorization: `Bearer ${token}` } : {}),
56
- ...(body && !expectBytes ? { 'content-type': 'application/json' } : {}),
57
- ...headers,
58
- },
59
- body: body,
60
- signal: AbortSignal.timeout(timeoutMs),
61
- });
62
- if (response.status === 401 || response.status === 403) {
63
- throw new ApiError('E_REMOTE_AUTH', `认证失败(${response.status})`, response.status);
64
- }
65
- if (response.status >= 400 && response.status < 500) {
66
- const detail = await response.text().catch(() => '');
67
- throw new ApiError(`E_HTTP_${response.status}`, detail.slice(0, 200) || `HTTP ${response.status}`, response.status);
68
- }
69
- if (!response.ok) {
70
- throw new ApiError('E_REMOTE_5XX', `服务端错误(${response.status})`, response.status);
71
- }
72
- return expectBytes ? Buffer.from(await response.arrayBuffer()) : response;
73
- }
74
- catch (error) {
75
- lastError = error;
76
- const retryable = !(error instanceof ApiError) || error.code === 'E_REMOTE_5XX';
77
- if (!retryable || attempt === retries)
78
- throw error;
79
- await sleep(200 * 2 ** attempt);
80
- }
81
- }
82
- throw lastError;
83
- }
84
- const parseJson = async (response) => {
85
- const text = await response.text();
86
- return text ? JSON.parse(text) : null;
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
- }
99
- return {
100
- id: 'api',
101
- baseUrl,
102
- get token() {
103
- return token;
104
- },
105
- /** 首次注册;已存在(409)时改走 login,取回同一个 token。 */
106
- async register(accountKey) {
107
- try {
108
- const response = await request('POST', '/v1/auth/register', { body: JSON.stringify({ accountKey }) });
109
- const data = await parseJson(response);
110
- const token = String(data?.token ?? '');
111
- persistAuth({ token, accountKeyHint: accountKey.slice(0, 6), registeredAt: new Date().toISOString() });
112
- return { ok: true, token };
113
- }
114
- catch (error) {
115
- if (error.code === 'E_HTTP_409')
116
- return loginWith(accountKey);
117
- throw error;
118
- }
119
- },
120
- login: loginWith,
121
- async reset() {
122
- await request('POST', '/v1/auth/reset');
123
- clearAuth();
124
- return { ok: true };
125
- },
126
- async test() {
127
- try {
128
- const response = await request('GET', '/v1/quota');
129
- const data = await parseJson(response);
130
- return { ok: true, detail: `配额 ${data?.used ?? 0}/${data?.limit ?? 0} 字节` };
131
- }
132
- catch (error) {
133
- return { ok: false, detail: error.message };
134
- }
135
- },
136
- async list() {
137
- const response = await request('GET', '/v1/packages');
138
- const data = await parseJson(response);
139
- return (data?.packages ?? []).map((item) => ({
140
- name: item.id,
141
- size: item.size,
142
- modifiedAt: item.createdAt,
143
- }));
144
- },
145
- async meta(id) {
146
- if (!isPackageId(id))
147
- throw new ApiError('E_BAD_NAME', `非法包名: ${id}`);
148
- const response = await request('GET', `/v1/packages/${encodeURIComponent(id)}/meta`);
149
- return parseJson(response);
150
- },
151
- async get(id) {
152
- if (!isPackageId(id))
153
- throw new ApiError('E_BAD_NAME', `非法包名: ${id}`);
154
- return request('GET', `/v1/packages/${encodeURIComponent(id)}`, { expectBytes: true });
155
- },
156
- async put(id, data) {
157
- if (!isPackageId(id))
158
- throw new ApiError('E_BAD_NAME', `非法包名: ${id}`);
159
- await request('PUT', `/v1/packages/${encodeURIComponent(id)}`, {
160
- body: data,
161
- expectBytes: true,
162
- headers: { 'content-type': 'application/octet-stream' },
163
- });
164
- },
165
- async remove(id) {
166
- if (!isPackageId(id))
167
- throw new ApiError('E_BAD_NAME', `非法包名: ${id}`);
168
- await request('DELETE', `/v1/packages/${encodeURIComponent(id)}`);
169
- },
170
- /** 导出我的全部数据(服务端打包当前包 + 元数据)。 */
171
- async exportPackage() {
172
- return request('GET', '/v1/export', { expectBytes: true });
173
- },
174
- };
175
- }
1
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+ import { isPackageId } from '../archive.js';
4
+ const OFFICIAL_HOST = /(^|\.)deepseek\.com$/i;
5
+ export class ApiError extends Error {
6
+ code;
7
+ status;
8
+ constructor(code, message, status) {
9
+ super(message);
10
+ this.code = code;
11
+ this.status = status;
12
+ }
13
+ }
14
+ /**
15
+ * 运营方 API 传输(B 模型)。插件侧只持有用户自己的 token,不接触存储凭据。
16
+ * 认证:register 首次建账号,login 取回同一 token;token 存 auth.json(0600)。
17
+ */
18
+ export function createApiTransport({ baseUrl, authPath, token: initialToken, fetchImpl = globalThis.fetch, timeoutMs = 30_000, retries = 3, }) {
19
+ if (!baseUrl)
20
+ throw new Error('缺少 baseUrl');
21
+ const host = new URL(baseUrl).hostname;
22
+ if (OFFICIAL_HOST.test(host)) {
23
+ throw new ApiError('E_OFFICIAL_HOST', `拒绝官方域名: ${host}`);
24
+ }
25
+ let token = initialToken ?? null;
26
+ if (!token && authPath && existsSync(authPath)) {
27
+ try {
28
+ token = JSON.parse(readFileSync(authPath, 'utf8')).token ?? null;
29
+ }
30
+ catch {
31
+ token = null;
32
+ }
33
+ }
34
+ function persistAuth(next) {
35
+ token = next.token;
36
+ if (!authPath)
37
+ return;
38
+ mkdirSync(dirname(authPath), { recursive: true });
39
+ writeFileSync(authPath, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
40
+ }
41
+ function clearAuth() {
42
+ token = null;
43
+ if (authPath)
44
+ rmSync(authPath, { force: true });
45
+ }
46
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
47
+ async function request(method, path, { body, headers = {}, expectBytes = false } = {}) {
48
+ const url = `${baseUrl.replace(/\/$/, '')}${path}`;
49
+ let lastError;
50
+ for (let attempt = 0; attempt <= retries; attempt++) {
51
+ try {
52
+ const response = await fetchImpl(url, {
53
+ method,
54
+ headers: {
55
+ ...(token ? { authorization: `Bearer ${token}` } : {}),
56
+ ...(body && !expectBytes ? { 'content-type': 'application/json' } : {}),
57
+ ...headers,
58
+ },
59
+ body: body,
60
+ signal: AbortSignal.timeout(timeoutMs),
61
+ });
62
+ if (response.status === 401 || response.status === 403) {
63
+ throw new ApiError('E_REMOTE_AUTH', `认证失败(${response.status})`, response.status);
64
+ }
65
+ if (response.status >= 400 && response.status < 500) {
66
+ const detail = await response.text().catch(() => '');
67
+ throw new ApiError(`E_HTTP_${response.status}`, detail.slice(0, 200) || `HTTP ${response.status}`, response.status);
68
+ }
69
+ if (!response.ok) {
70
+ throw new ApiError('E_REMOTE_5XX', `服务端错误(${response.status})`, response.status);
71
+ }
72
+ return expectBytes ? Buffer.from(await response.arrayBuffer()) : response;
73
+ }
74
+ catch (error) {
75
+ lastError = error;
76
+ const retryable = !(error instanceof ApiError) || error.code === 'E_REMOTE_5XX';
77
+ if (!retryable || attempt === retries)
78
+ throw error;
79
+ await sleep(200 * 2 ** attempt);
80
+ }
81
+ }
82
+ throw lastError;
83
+ }
84
+ const parseJson = async (response) => {
85
+ const text = await response.text();
86
+ return text ? JSON.parse(text) : null;
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
+ }
99
+ return {
100
+ id: 'api',
101
+ baseUrl,
102
+ get token() {
103
+ return token;
104
+ },
105
+ /** 首次注册;已存在(409)时改走 login,取回同一个 token。 */
106
+ async register(accountKey) {
107
+ try {
108
+ const response = await request('POST', '/v1/auth/register', { body: JSON.stringify({ accountKey }) });
109
+ const data = await parseJson(response);
110
+ const token = String(data?.token ?? '');
111
+ persistAuth({ token, accountKeyHint: accountKey.slice(0, 6), registeredAt: new Date().toISOString() });
112
+ return { ok: true, token };
113
+ }
114
+ catch (error) {
115
+ if (error.code === 'E_HTTP_409')
116
+ return loginWith(accountKey);
117
+ throw error;
118
+ }
119
+ },
120
+ login: loginWith,
121
+ async reset() {
122
+ await request('POST', '/v1/auth/reset');
123
+ clearAuth();
124
+ return { ok: true };
125
+ },
126
+ async test() {
127
+ try {
128
+ const response = await request('GET', '/v1/quota');
129
+ const data = await parseJson(response);
130
+ return { ok: true, detail: `配额 ${data?.used ?? 0}/${data?.limit ?? 0} 字节` };
131
+ }
132
+ catch (error) {
133
+ return { ok: false, detail: error.message };
134
+ }
135
+ },
136
+ async list() {
137
+ const response = await request('GET', '/v1/packages');
138
+ const data = await parseJson(response);
139
+ return (data?.packages ?? []).map((item) => ({
140
+ name: item.id,
141
+ size: item.size,
142
+ modifiedAt: item.createdAt,
143
+ }));
144
+ },
145
+ async meta(id) {
146
+ if (!isPackageId(id))
147
+ throw new ApiError('E_BAD_NAME', `非法包名: ${id}`);
148
+ const response = await request('GET', `/v1/packages/${encodeURIComponent(id)}/meta`);
149
+ return parseJson(response);
150
+ },
151
+ async get(id) {
152
+ if (!isPackageId(id))
153
+ throw new ApiError('E_BAD_NAME', `非法包名: ${id}`);
154
+ return request('GET', `/v1/packages/${encodeURIComponent(id)}`, { expectBytes: true });
155
+ },
156
+ async put(id, data) {
157
+ if (!isPackageId(id))
158
+ throw new ApiError('E_BAD_NAME', `非法包名: ${id}`);
159
+ await request('PUT', `/v1/packages/${encodeURIComponent(id)}`, {
160
+ body: data,
161
+ expectBytes: true,
162
+ headers: { 'content-type': 'application/octet-stream' },
163
+ });
164
+ },
165
+ async remove(id) {
166
+ if (!isPackageId(id))
167
+ throw new ApiError('E_BAD_NAME', `非法包名: ${id}`);
168
+ await request('DELETE', `/v1/packages/${encodeURIComponent(id)}`);
169
+ },
170
+ /** 导出我的全部数据(服务端打包当前包 + 元数据)。 */
171
+ async exportPackage() {
172
+ return request('GET', '/v1/export', { expectBytes: true });
173
+ },
174
+ };
175
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pandablessing/dsh-user-sync",
3
- "version": "1.0.7",
3
+ "version": "1.0.10",
4
4
  "description": "DeepSeek Harness 用户级配置同步插件",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -60,6 +60,7 @@
60
60
  "build:server": "tsc -p server/tsconfig.json",
61
61
  "test": "node --test test/*.test.js",
62
62
  "publish:npm": "node scripts/publish.mjs",
63
- "publish:dry": "node scripts/publish.mjs --dry-run"
63
+ "publish:dry": "node scripts/publish.mjs --dry-run",
64
+ "smoke:npm": "node scripts/verify-npm-install.mjs"
64
65
  }
65
66
  }