@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 +8 -0
- package/lib/client.js +9 -40
- package/lib/index.js +115 -19
- package/lib/transport/api.js +175 -175
- package/package.json +3 -2
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,
|
|
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 }, '设置同步'),
|
|
492
|
-
? R.createElement('
|
|
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
|
-
|
|
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
|
-
*
|
|
215
|
-
*
|
|
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
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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 (
|
|
283
|
+
if (detached.length > 0) {
|
|
256
284
|
const patched = { ...pkg, dependencies: { ...dependencies } };
|
|
257
|
-
for (const [name] of
|
|
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
|
|
269
|
-
writeFileSync(
|
|
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',
|
package/lib/transport/api.js
CHANGED
|
@@ -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.
|
|
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
|
}
|