@pandablessing/dsh-user-sync 1.0.6 → 1.0.8

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
@@ -178,7 +178,7 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
178
178
  ? R.createElement('p', { style: styles.muted }, `本地包需手动放回:${linkDeps.map((item) => item.name).join('、')}`)
179
179
  : null, R.createElement('div', { style: styles.actions }, missing.length > 0
180
180
  ? R.createElement('button', { style: styles.primary, onClick: () => onInstallPlugins(missing) }, '先安装缺失插件')
181
- : null, R.createElement('button', { style: styles.primary, onClick: () => onApply(data.latest?.name ?? '') }, '用远端覆盖本机'), R.createElement('button', { style: styles.secondary, onClick: () => onDismiss() }, '取消')));
181
+ : null, R.createElement('button', { style: styles.secondary, onClick: () => onDismiss() }, '取消')), R.createElement('p', { style: styles.muted }, '覆盖动作会在弹窗中确认。'));
182
182
  }
183
183
  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
184
  }
@@ -187,10 +187,8 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
187
187
  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
188
188
  ? R.createElement('p', { style: styles.error }, `敏感扫描命中 ${data.hits.length} 处:${data.hits.map((h) => `${h.file}:${h.line}(${h.pattern})`).join(';')}`)
189
189
  : null,
190
- // 上传前发现远端更新:默认不覆盖,用户确认后才用本机覆盖远端(G2)
191
- remoteNewer
192
- ? R.createElement('div', { style: styles.actions }, R.createElement('button', { style: styles.primary, onClick: () => onForceUpload() }, '用本机覆盖远端'), R.createElement('button', { style: styles.secondary, onClick: () => onDismiss() }, '取消'))
193
- : null);
190
+ // 覆盖确认已改为弹窗;卡片只提示
191
+ remoteNewer ? R.createElement('p', { style: styles.muted }, '覆盖动作会在弹窗中确认。') : null);
194
192
  }
195
193
  // 同步后自动安装的结果:优先展示「装了哪些插件 / 哪些失败 / 哪些需手动放回」
196
194
  const pluginSummary = (() => {
@@ -225,6 +223,8 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
225
223
  const [notice, setNotice] = R.useState(null);
226
224
  // 非用户级模块确认走弹窗(dialog),不再用底部结果卡
227
225
  const [confirmDialog, setConfirmDialog] = R.useState(null);
226
+ // 覆盖确认弹窗:上传发现远端更新 / 从远端同步发现远端更新时弹出(不再用结果卡按钮)
227
+ const [overwriteDialog, setOverwriteDialog] = R.useState(null);
228
228
  // 当前展开文件清单的模块(null 表示都收起)
229
229
  const [expandedModule, setExpandedModule] = R.useState(null);
230
230
  // 设置改动后自动保存(600ms 防抖),不再提供「保存设置」按钮
@@ -389,7 +389,19 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
389
389
  headers: { 'content-type': 'application/json' },
390
390
  body: JSON.stringify({ force }),
391
391
  });
392
- setNotice({ kind: result.ok ? 'result' : 'error', data: result });
392
+ if (result.ok) {
393
+ setNotice({ kind: 'result', data: result });
394
+ refresh();
395
+ }
396
+ else if (result.code === 'E_REMOTE_NEWER') {
397
+ setOverwriteDialog({
398
+ kind: 'upload',
399
+ detail: `远端已有更新的包 ${result.latest?.name ?? ''};继续会用本机配置覆盖它。`,
400
+ });
401
+ }
402
+ else {
403
+ setNotice({ kind: 'error', data: result });
404
+ }
393
405
  refresh();
394
406
  }
395
407
  catch (error) {
@@ -427,7 +439,22 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
427
439
  setNotice(null);
428
440
  try {
429
441
  const result = await fetchJson(`${API}/check`);
430
- setNotice(result.error ? { kind: 'error', data: result } : { kind: 'check', data: result });
442
+ if (result.error) {
443
+ setNotice({ kind: 'error', data: result });
444
+ }
445
+ else {
446
+ // 详情仍用结果卡展示;覆盖动作改为弹窗确认
447
+ setNotice({ kind: 'check', data: result });
448
+ const action = result.decision?.action;
449
+ if ((action === 'pull' || action === 'confirm') && result.latest?.name) {
450
+ const overwritten = (result.plan?.overwritten ?? []).length;
451
+ setOverwriteDialog({
452
+ kind: 'pull',
453
+ name: result.latest.name,
454
+ detail: `远端包 ${result.latest.name}(${result.meta?.createdAt ?? '时间未知'}),将覆盖本机 ${overwritten} 个文件。`,
455
+ });
456
+ }
457
+ }
431
458
  }
432
459
  catch (error) {
433
460
  setNotice({ kind: 'error', data: { detail: String(error) } });
@@ -532,6 +559,20 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
532
559
  onForceUpload: () => upload(true),
533
560
  }), confirmDialog
534
561
  ? 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) }, '取消'))))
562
+ : null, overwriteDialog
563
+ ? R.createElement('div', { style: styles.overlay, onClick: () => setOverwriteDialog(null) }, R.createElement('div', { style: styles.dialog, onClick: (event) => event.stopPropagation?.() }, R.createElement('strong', null, overwriteDialog.kind === 'upload' ? '用本机覆盖远端?' : '用远端覆盖本机?'), R.createElement('p', { style: styles.muted }, overwriteDialog.detail), R.createElement('p', { style: styles.muted }, overwriteDialog.kind === 'upload'
564
+ ? '远端已有的包会被本机内容替换(远端只保留 1 份)。'
565
+ : '本机会先做快照,再用远端包覆盖同步范围内的文件。'), R.createElement('div', { style: styles.actions }, R.createElement('button', {
566
+ style: styles.primary,
567
+ onClick: () => {
568
+ const target = overwriteDialog;
569
+ setOverwriteDialog(null);
570
+ if (target.kind === 'upload')
571
+ void upload(true);
572
+ else if (target.name)
573
+ void applyRemote(target.name);
574
+ },
575
+ }, overwriteDialog.kind === 'upload' ? '用本机覆盖远端' : '用远端覆盖本机'), R.createElement('button', { style: styles.secondary, onClick: () => setOverwriteDialog(null) }, '取消'))))
535
576
  : null);
536
577
  }
537
578
  function apply(ctx) {
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
  };
@@ -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.6",
3
+ "version": "1.0.8",
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
  }