@xiaoyuyu6420/dsh-backup 0.13.4 → 0.14.0
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.en.md +20 -5
- package/README.md +20 -5
- package/lib/client.js +135 -32
- package/lib/encrypt.js +135 -0
- package/lib/gfs.js +52 -0
- package/lib/index.js +3025 -172
- package/lib/oplock.js +123 -0
- package/lib/sync-channels.js +161 -0
- package/lib/zip.js +401 -0
- package/package.json +1 -1
- package/rescue/rescue.mjs +181 -1
package/lib/index.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* node_modules,生成 sha256 校验和,自动轮换旧备份;定时自动备份
|
|
6
6
|
* 状态落盘、重启续跑;跨平台(macOS / Linux / Windows)。
|
|
7
7
|
*
|
|
8
|
-
* - `/backup` 立即备份(默认到 ~/Desktop/
|
|
8
|
+
* - `/backup` 立即备份(默认到 ~/Desktop/DSH Backups/)
|
|
9
9
|
* - `/backup list` 列出已有备份(含大小)+ 自动备份状态
|
|
10
10
|
* - `/backup verify [前缀|all]` 校验备份完整性(缺省校验最新一份)
|
|
11
11
|
* - `/backup restore <前缀|latest> [--dry-run]` 恢复(先校验 + 自动快照当前数据)
|
|
@@ -27,6 +27,8 @@
|
|
|
27
27
|
* - 救援通道(进程外):每次备份把 rescue.mjs + 双击启动器(点我恢复.command/.bat/.sh)
|
|
28
28
|
* + RESCUE.txt 写进备份目录——DSH 起不来时双击/`node rescue.mjs` 打开零依赖
|
|
29
29
|
* 救援网页(列表/校验/恢复/doctor),Node 活着就能自救
|
|
30
|
+
* - 使用说明:每次备份在备份目录根写一份「使用说明.txt」,讲清每个文件是什么、
|
|
31
|
+
* 恢复走哪三条路——第一次打开这个文件夹的用户不用去翻 README
|
|
30
32
|
*
|
|
31
33
|
* 安全说明(v0.7.0 起):凭据文件(.credentials.yaml / .env / qq-bridge/config.json,
|
|
32
34
|
* config.redact 可增删)默认脱敏——不进归档、不进 GitHub 同步,明文只存备份目录下
|
|
@@ -35,6 +37,16 @@
|
|
|
35
37
|
* 家目录不一致时提示绝对路径风险。config.redact=false/'off' 可回到 v0.6.x 明文行为
|
|
36
38
|
* (不推荐)。归档与校验边车在 POSIX 上 chmod 600 仅本人可读写。
|
|
37
39
|
* restore 会拒绝恢复含归档根目录之外条目的文件(tar 路径穿越防护)。
|
|
40
|
+
* 可选加密备份(P2):backupPanel 的 backup({password}) 用 scrypt + AES-256-GCM 加密
|
|
41
|
+
* 归档(密文覆盖 .tar.gz 本体、参数进 .encrypted.json 边车、meta.encrypted=true);
|
|
42
|
+
* 口令只经 RPC 内存态传入、绝不写 settings.yaml/日志/磁盘;list 显示 🔒,verify 只做
|
|
43
|
+
* 完整性(无需口令),restore 需口令、口令错误给明确人话报错。
|
|
44
|
+
* 导出/导入(P2):把 ~/.dsh 打成单文件 ZIP(节段化、凭据默认脱敏、可选加密),导入
|
|
45
|
+
* 走 importPreview(dry-run 计划:节段/冲突/跨机 remap/兼容性评分)→ importApply
|
|
46
|
+
* (自动快照 → 应用 → 校验 → 失败回滚);ZIP 视为不可信输入,防 Zip Slip/恶意路径/异常条目。
|
|
47
|
+
* P3:同步多通道(WebDAV / S3 兼容 / GitHub Gist,零依赖签名,凭据只存本机 0600)、
|
|
48
|
+
* GFS 保留(最近 N + 每月/每年各一份)、操作锁 + 残留锁回收(心跳 + 死锁判定,不误踢活锁)、
|
|
49
|
+
* 磁盘占用报告 + 分级清理(快照/同步永不删)、定时备份自定义每周时刻。
|
|
38
50
|
*
|
|
39
51
|
* 存储说明:插件自有数据(归档、校验和、auto.json)直接经 node:fs 写入
|
|
40
52
|
* (与 dsh-session 持久化、skill-filesystem 同一模式)——ctx.fs 能力是
|
|
@@ -42,14 +54,20 @@
|
|
|
42
54
|
* 插件的自有存储。
|
|
43
55
|
*/
|
|
44
56
|
import { createHash, randomUUID } from 'node:crypto';
|
|
45
|
-
import { createReadStream, readFileSync, realpathSync } from 'node:fs';
|
|
46
|
-
import {
|
|
57
|
+
import { createReadStream, existsSync, readFileSync, realpathSync } from 'node:fs';
|
|
58
|
+
import { spawn } from 'node:child_process';
|
|
59
|
+
import { mkdir, open, copyFile, chmod, readdir, readFile, rename, rm, stat as fsStat, lstat, unlink, writeFile, link } from 'node:fs/promises';
|
|
47
60
|
import { createRequire } from 'node:module';
|
|
48
61
|
import { hostname } from 'node:os';
|
|
49
|
-
import { basename, dirname, join } from 'node:path';
|
|
62
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
50
63
|
import { StringDecoder } from 'node:string_decoder';
|
|
51
64
|
import { fileURLToPath } from 'node:url';
|
|
52
65
|
import * as zlib from 'node:zlib';
|
|
66
|
+
import { encryptBuffer, decryptBuffer } from './encrypt.js';
|
|
67
|
+
import { buildZip, listZip, extractZip, readZipEntry } from './zip.js';
|
|
68
|
+
import { gfsProtectedSet } from './gfs.js';
|
|
69
|
+
import { acquireLock, releaseLock, heartbeatLock, reclaimLock as reclaimOplock, isLockStale, LOCK_HEARTBEAT_MS } from './oplock.js';
|
|
70
|
+
import { s3Put, webdavPut, gistSync, gistContent } from './sync-channels.js';
|
|
53
71
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
54
72
|
import { TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol';
|
|
55
73
|
import { SettingsConflictError } from '@deepseek-ai/dsh-settings';
|
|
@@ -78,6 +96,14 @@ const MAX_GITHUB_BYTES = 90 * 1024 * 1024;
|
|
|
78
96
|
const SYNC_DIR = '.github-sync';
|
|
79
97
|
/** Web 下载路由前缀(无尾斜杠:prefix 匹配语义为 pathname.startsWith(prefix + '/'))。 */
|
|
80
98
|
const DOWNLOAD_PREFIX = '/backup-download';
|
|
99
|
+
/** WebDAV/S3 同步的单文件整读 PUT 上限(内存缓冲护栏;超大归档跳过并上报)。 */
|
|
100
|
+
const SYNC_MAX_BYTES = MAX_GITHUB_BYTES;
|
|
101
|
+
/** GitHub Gist 单文件上限(GitHub 硬性约 10MB;超限跳过,gist 本就只适合轻量配置)。 */
|
|
102
|
+
const GIST_MAX_BYTES = 10 * 1024 * 1024;
|
|
103
|
+
/** 多通道同步配置文件名(备份目录根,0600;凭据不进归档不进日志)。 */
|
|
104
|
+
const SYNC_CONFIG_FILE = 'sync.json';
|
|
105
|
+
/** 操作锁文件名(备份目录根;pid+token+心跳)。 */
|
|
106
|
+
const LOCK_FILE = '.oplock.json';
|
|
81
107
|
/**
|
|
82
108
|
* 默认脱敏的敏感文件(相对 ~/.dsh 根的 POSIX 路径):凭据与环境变量文件
|
|
83
109
|
* 明文绝不进归档、绝不进 GitHub 同步——真实值只落本机 vault(备份目录下
|
|
@@ -110,6 +136,39 @@ const BACKUP_TYPES = Object.freeze({
|
|
|
110
136
|
/** 分类型归档前缀(与全量 dsh- 区分:独立轮换、不进全量列表、旧版插件不可见不误删)。 */
|
|
111
137
|
const TYPED_PREFIX = 'dsh-t-';
|
|
112
138
|
|
|
139
|
+
/**
|
|
140
|
+
* 导出/导入(P2)的节段表:把 ~/.dsh 顶层内容归入 10 个语义节段 + 一个 `other`
|
|
141
|
+
* 兜底节段。`paths` 是相对 ~/.dsh 根的 POSIX 路径(文件精确名 / 目录名);
|
|
142
|
+
* credentials 节段默认脱敏(不进 ZIP),仅在 includeCredentials 且加密时入包。
|
|
143
|
+
*
|
|
144
|
+
* 节段划分与 BACKUP_TYPES(分类型备份)一致的最小集,但**按顶层非重叠**组织:
|
|
145
|
+
* profiles(插件)整目录归 plugins,家级 cordis*.yml 归 mcp(profile 内的 cordis
|
|
146
|
+
* 由 plugins 覆盖,避免重复入包)。`other` 收所有未匹配的顶层条目(attachments /
|
|
147
|
+
* cache / storages / dsh-market / .dshw-* 等 DSH 运行数据),保证「换机丢配置」
|
|
148
|
+
* 场景绝不静默丢文件——宁可多一个兜底节段,也不漏。
|
|
149
|
+
*/
|
|
150
|
+
const EXPORT_SECTIONS = Object.freeze([
|
|
151
|
+
{ key: 'settings', label: '设置', paths: ['settings.yaml', 'settings.json'] },
|
|
152
|
+
{ key: 'ui', label: '界面设置', paths: ['ui.json', 'ui-state.json', '.ui'] },
|
|
153
|
+
{ key: 'providers', label: '模型供应商', paths: ['providers'] },
|
|
154
|
+
{ key: 'plugins', label: '插件 profiles', paths: ['profiles'] },
|
|
155
|
+
{ key: 'mcp', label: 'MCP 配置', paths: ['cordis.patch.yml', 'cordis.yml'] },
|
|
156
|
+
{ key: 'skills', label: '技能', paths: ['skills'] },
|
|
157
|
+
{ key: 'prompts', label: '提示词', paths: ['prompts'] },
|
|
158
|
+
{ key: 'workspaces', label: '工作区', paths: ['workspaces'] },
|
|
159
|
+
{ key: 'sessions', label: '会话', paths: ['sessions'] },
|
|
160
|
+
{ key: 'credentials', label: '凭据/api key', paths: ['.credentials.yaml', '.env', 'qq-bridge'], credentials: true },
|
|
161
|
+
]);
|
|
162
|
+
/** 兜底节段 key:所有未匹配到上面 10 类的顶层文件/目录都归这里,绝不丢弃。 */
|
|
163
|
+
const EXPORT_OTHER_KEY = 'other';
|
|
164
|
+
/** 导出 manifest 的格式标识与版本(读端据此识别 dsh-config 包、拒绝未知格式)。 */
|
|
165
|
+
const EXPORT_FORMAT = 'dsh-config-export';
|
|
166
|
+
const EXPORT_FORMAT_VERSION = 1;
|
|
167
|
+
/** 导出递归走查的目录/文件上限(防病态目录树把导出拖死)。 */
|
|
168
|
+
const EXPORT_MAX_FILES = 100_000;
|
|
169
|
+
/** 导出内部快照前缀(importApply 前拍的当前数据安全网,不进用户列表)。 */
|
|
170
|
+
const IMPORT_SNAPSHOT_PREFIX = 'dsh-pre-import-';
|
|
171
|
+
|
|
113
172
|
/**
|
|
114
173
|
* 按类型 globs 匹配归档内条目(restore 侧):把 BACKUP_TYPES 的相对路径转成
|
|
115
174
|
* 对归档条目(${base}/ 前缀)的匹配——文件精确匹配、目录前缀匹配、含 * 的
|
|
@@ -158,6 +217,57 @@ const RESCUE_TXT = `DSH 出问题时的自救通道(不依赖 DSH 能否启动
|
|
|
158
217
|
凭据从本目录 vault/ 自动补回。rescue.mjs / 启动器 / 本说明由 dsh-backup 每次备份时更新。
|
|
159
218
|
`;
|
|
160
219
|
|
|
220
|
+
/** 备份目录根的使用说明文件名:用户第一次打开文件夹时最该看到的东西。 */
|
|
221
|
+
const GUIDE_FILE = '使用说明.txt';
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* 随备份落盘的人话版说明书。
|
|
225
|
+
*
|
|
226
|
+
* 背景(0.14.0 UX 审查):备份目录里混着归档、校验边车、救援工具、vault 和
|
|
227
|
+
* 调度状态,第一次打开的用户只看到一堆 tar.gz,既不知道能不能删,也不知道
|
|
228
|
+
* 恢复从哪下手。这份文件把「是什么 / 怎么恢复 / 哪些别动 / 怎么换位置」一次
|
|
229
|
+
* 讲清,和 RESCUE.txt(灾时专用)互补。每次备份都会重写,保持最新。
|
|
230
|
+
*/
|
|
231
|
+
const GUIDE_TXT = `DSH 备份文件夹 —— 使用说明
|
|
232
|
+
================================
|
|
233
|
+
|
|
234
|
+
这个文件夹由 dsh-backup 插件自动生成,保存你本机 DSH 的全部数据
|
|
235
|
+
(~/.dsh:会话、设置、技能、插件配置……)。日常不需要手动改动这里。
|
|
236
|
+
|
|
237
|
+
【怎么恢复】
|
|
238
|
+
1. DSH 还能打开:在 DSH 里输入 /backup list 查看,再用 /backup restore latest;
|
|
239
|
+
或者打开 设置 → 插件 → 备份,在面板里点「恢复」。
|
|
240
|
+
2. DSH 打不开:双击本文件夹里的「点我恢复」(macOS .command / Windows .bat /
|
|
241
|
+
Linux .sh),浏览器会打开救援页面,按提示校验/恢复。
|
|
242
|
+
3. 终端:node rescue.mjs 打开救援页面
|
|
243
|
+
node rescue.mjs list 列出备份
|
|
244
|
+
node rescue.mjs restore latest 预览恢复;加 --yes 执行
|
|
245
|
+
|
|
246
|
+
【里面的文件都是什么】
|
|
247
|
+
- dsh-<时间戳>.tar.gz 一份完整备份。别手动解压,也别改名。
|
|
248
|
+
- 同名 .sha256 校验和,用来验证备份没损坏。
|
|
249
|
+
- 同名 .meta.json 元数据(主机、时间等),恢复前预检用。
|
|
250
|
+
- 同名 .redacted.json 脱敏清单(哪些凭据没进归档),有它时恢复会提示。
|
|
251
|
+
- 点我恢复 / rescue.mjs 进程外救援工具:DSH 坏了也能跑。「RESCUE.txt」是它的说明。
|
|
252
|
+
- vault/ 本机凭据明文备份(只有你能读;不要上传、不要分享)。
|
|
253
|
+
- auto.json 自动备份的调度状态,删了会重置节奏。
|
|
254
|
+
- .github-sync/ GitHub 同步缓存(开了 GitHub 同步才有,别手动改)。
|
|
255
|
+
|
|
256
|
+
【想换个位置或名字】
|
|
257
|
+
DSH 能打开时:设置 → 插件 → 备份 → 备份目录,改完保存立即生效。
|
|
258
|
+
注意:改动只影响以后的新备份,已有的旧备份不会自动搬过去。
|
|
259
|
+
|
|
260
|
+
【保留策略】
|
|
261
|
+
默认保留最近 7 份(自动备份间隔不足 24 小时时保留 3 份),更旧的自动删除。
|
|
262
|
+
本说明由 dsh-backup 在每次备份时自动更新,不用管它。
|
|
263
|
+
|
|
264
|
+
【English】
|
|
265
|
+
This folder is auto-generated by the dsh-backup plugin
|
|
266
|
+
(https://github.com/xiaoyuyu6420/dsh-backup). It holds copies of your DSH data.
|
|
267
|
+
To restore: run /backup restore latest inside DSH, or double-click the rescue
|
|
268
|
+
launcher, or run "node rescue.mjs". Do not unpack or rename the archives by hand.
|
|
269
|
+
`;
|
|
270
|
+
|
|
161
271
|
/** 双击启动器:起救援服务并自动开浏览器。按当前平台生成,避免目录里躺三个系统的文件。 */
|
|
162
272
|
const RESCUE_LAUNCHERS = Object.freeze({
|
|
163
273
|
// 双击 .command 时 GUI 的 PATH 只有 /usr/bin:/bin:...,nvm/Homebrew 装的
|
|
@@ -299,13 +409,32 @@ const SELF_META = (() => {
|
|
|
299
409
|
* 避免同一字面量散落多处导致迁移逻辑漏改。
|
|
300
410
|
*/
|
|
301
411
|
const DEFAULTS = Object.freeze({
|
|
302
|
-
|
|
412
|
+
// 0.14.0 起默认目录改名为 `DSH Backups`:旧名 `dsh-backups`(全小写 + 连字符)
|
|
413
|
+
// 在桌面/Finder 里像临时垃圾目录,用户打开也不知道里面是干嘛的。位置仍留在
|
|
414
|
+
// 桌面——救援通道(点我恢复)要求 DSH 彻底起不来时也能被非技术用户一眼找到。
|
|
415
|
+
destination: '~/Desktop/DSH Backups',
|
|
303
416
|
exclude: [],
|
|
304
417
|
redact: [...SENSITIVE_DEFAULTS],
|
|
305
418
|
githubRepo: '',
|
|
306
419
|
updateCheck: false,
|
|
420
|
+
// GFS 保留:keep 窗口外,每月一份 / 每年一份各保留多少个桶。0=关(等价旧行为)。
|
|
421
|
+
gfsMonthly: 0,
|
|
422
|
+
gfsYearly: 0,
|
|
423
|
+
// 配置市场索引地址(空 = 市场禁用;用户填了才拉取)。
|
|
424
|
+
marketUrl: '',
|
|
307
425
|
});
|
|
308
426
|
|
|
427
|
+
/**
|
|
428
|
+
* ≤0.13.4 的默认目录。升级后由 migrateLegacyDefaultDir() 同盘改名为新默认名;
|
|
429
|
+
* 迁移失败时 paths() 会继续使用老目录,保证已有备份不"消失"。
|
|
430
|
+
*/
|
|
431
|
+
const LEGACY_DEFAULT_DESTINATIONS = Object.freeze(['~/Desktop/dsh-backups']);
|
|
432
|
+
|
|
433
|
+
/** existsSync 的安全包装:非法路径/权限异常一律按"不存在"处理。 */
|
|
434
|
+
const pathExists = (p) => {
|
|
435
|
+
try { return existsSync(p); } catch { return false; }
|
|
436
|
+
};
|
|
437
|
+
|
|
309
438
|
/**
|
|
310
439
|
* 解析 settings 命名空间的 schema,同时定义默认值。
|
|
311
440
|
* Base 层:pluginConfig(cordis.patch.yml,插件级或 profile 级)。
|
|
@@ -334,12 +463,15 @@ const SCHEMA = z.object({
|
|
|
334
463
|
redact: volatileField(z.union([z.array(z.string()), 'off', 'none']).default(DEFAULTS.redact)),
|
|
335
464
|
githubRepo: volatileField(z.string().default(DEFAULTS.githubRepo)),
|
|
336
465
|
updateCheck: volatileField(z.boolean().default(DEFAULTS.updateCheck)),
|
|
466
|
+
gfsMonthly: volatileField(z.number().min(0).default(DEFAULTS.gfsMonthly)),
|
|
467
|
+
gfsYearly: volatileField(z.number().min(0).default(DEFAULTS.gfsYearly)),
|
|
468
|
+
marketUrl: volatileField(z.string().default(DEFAULTS.marketUrl)),
|
|
337
469
|
});
|
|
338
470
|
|
|
339
471
|
/** 宿主装载条目时读取的 Config schema(0.1.7+ 的设置表单来源)。 */
|
|
340
472
|
export const Config = SCHEMA;
|
|
341
473
|
|
|
342
|
-
const SETTINGS_FIELDS = ['destination', 'keep', 'exclude', 'redact', 'githubRepo', 'updateCheck'];
|
|
474
|
+
const SETTINGS_FIELDS = ['destination', 'keep', 'exclude', 'redact', 'githubRepo', 'updateCheck', 'gfsMonthly', 'gfsYearly', 'marketUrl'];
|
|
343
475
|
|
|
344
476
|
/** 校验并归一化单个字段到 `out`;非法值忽略。 */
|
|
345
477
|
function normalizeField(out, field, value) {
|
|
@@ -358,6 +490,11 @@ function normalizeField(out, field, value) {
|
|
|
358
490
|
if (typeof value === 'string') out.githubRepo = value;
|
|
359
491
|
} else if (field === 'updateCheck') {
|
|
360
492
|
if (typeof value === 'boolean') out.updateCheck = value;
|
|
493
|
+
} else if (field === 'gfsMonthly' || field === 'gfsYearly') {
|
|
494
|
+
const n = Number(value);
|
|
495
|
+
if (Number.isFinite(n) && n >= 0) out[field] = Math.floor(n);
|
|
496
|
+
} else if (field === 'marketUrl') {
|
|
497
|
+
if (typeof value === 'string') out.marketUrl = value.trim();
|
|
361
498
|
}
|
|
362
499
|
}
|
|
363
500
|
|
|
@@ -428,11 +565,68 @@ function panelDescriptor(method, parameters, cancellation) {
|
|
|
428
565
|
});
|
|
429
566
|
}
|
|
430
567
|
|
|
568
|
+
/**
|
|
569
|
+
* ============================================================================
|
|
570
|
+
* backupPanel RPC 契约(P2 新增/变更)——客户端 strict descriptor 据此实现
|
|
571
|
+
* ============================================================================
|
|
572
|
+
* 宿主多注册方法不影响客户端挂载(客户端只挂载它声明的子集)。口令一律「只经 RPC
|
|
573
|
+
* 传入、内存态」,绝不写 settings.yaml / 日志 / 磁盘。
|
|
574
|
+
*
|
|
575
|
+
* @method backup(keep?, types?, password?, signal) → { ok, summary, path, sha,
|
|
576
|
+
* stale, keep, encrypted?, types?, hasCredentials? }
|
|
577
|
+
* - password: string 可选。非空即加密备份(scrypt+AES-256-GCM,密文覆盖归档、
|
|
578
|
+
* .encrypted.json 边书记参数,meta.encrypted=true)。
|
|
579
|
+
*
|
|
580
|
+
* @method restore(selector?, dryRun?, types?, syncDeps?, password?, signal)
|
|
581
|
+
* → 与旧 shape 相同,另加加密归档的恢复:password 提供时用于解密;缺口令或口令
|
|
582
|
+
* 错误 → ok:false + 人话 summary(「口令错误或归档已损坏」)。
|
|
583
|
+
*
|
|
584
|
+
* @method exportConfig(options, signal)
|
|
585
|
+
* options: { password?: string, includeCredentials?: boolean, sections?: string[] }
|
|
586
|
+
* → { ok, summary, path, name, size, encrypted, manifest, redactedFiles }
|
|
587
|
+
* manifest: { format:'dsh-config-export', version:1, createdAt, source:{host,home,
|
|
588
|
+
* platform,pluginVersion}, redacted[], encrypted, sections:[{key,label,
|
|
589
|
+
* files,bytes}], totalFiles, totalBytes }
|
|
590
|
+
*
|
|
591
|
+
* @method importPreview(options, signal) // dry-run,只读
|
|
592
|
+
* options: { sourcePath: string, password?: string }
|
|
593
|
+
* → { ok, summary, encrypted, manifest, sections:[{key,label,files,bytes,
|
|
594
|
+
* conflicts:[{name,rel,exists}]}], totalFiles, totalBytes, conflicts:[{name,rel,
|
|
595
|
+
* section,exists}], remap:[{section,file,count,sample[]}],
|
|
596
|
+
* compatibility:{score,reasons[]} }
|
|
597
|
+
*
|
|
598
|
+
* @method importApply(options, signal) // 自动快照 → 应用 → 校验 → 失败回滚
|
|
599
|
+
* options: { sourcePath, password?, sections?: string[], decisions?: {[name]:
|
|
600
|
+
* 'keep'|'overwrite'}, remap?: [{from,to}] }
|
|
601
|
+
* → { ok, summary, encrypted, applied, skipped, overwrote, snapshot, remapped }
|
|
602
|
+
*
|
|
603
|
+
* P3 新增:
|
|
604
|
+
* @method syncStatus() → { ok, channels: {github,webdav,s3,gist}, summary }
|
|
605
|
+
* 每通道:{ channel, type, configured, url?/endpoint?/bucket?/gistId?, tokenSet?/
|
|
606
|
+
* passwordSet?/secretKeySet?, lastPush, lastError }(凭据一律脱敏,不回显明文)
|
|
607
|
+
* @method syncNow(channel, signal) → { ok, summary, pushed, count?, at, tooBig?, gistUrl? }
|
|
608
|
+
* @method setSyncChannel(channel, config) → { ok, summary, channel? }
|
|
609
|
+
* config 按通道:webdav{url,username,password} / s3{endpoint,region,bucket,
|
|
610
|
+
* accessKey,secretKey,pathPrefix?,pathStyle?} / gist{token,gistId?};凭据只存本机 0600
|
|
611
|
+
* @method removeSyncChannel(channel) → { ok, summary }
|
|
612
|
+
* @method lockStatus() → { ok, held, stale, pid, operation, startedAt, heartbeatAt }
|
|
613
|
+
* @method reclaimLock(force) → { ok, summary, heldBy? }
|
|
614
|
+
* @method diskUsage(signal) → { ok, total, totalText, categories:[{key,label,bytes,files}], summary }
|
|
615
|
+
* @method cleanup(scope, signal) → { ok, removed, names, summary }(scope='cache'|'expired')
|
|
616
|
+
* @method setWeekly(schedules) → { ok, weekly:[{day,time}], summary }(day 1=周一..7=周日)
|
|
617
|
+
*
|
|
618
|
+
* P4 新增:
|
|
619
|
+
* @method about() → { ok, version, dshVersion, platform, node, profile, pluginDir }
|
|
620
|
+
* 环境信息脱敏:只含版本/平台/路径,绝不含凭据/密钥;home 不单独返回,pluginDir
|
|
621
|
+
* 若位于 home 下则 home 前缀脱敏成 ~(取不到一律 'unknown')。
|
|
622
|
+
* ============================================================================
|
|
623
|
+
*/
|
|
624
|
+
|
|
431
625
|
const PANEL_INVOCATIONS = Object.freeze([
|
|
432
626
|
panelDescriptor('status', []),
|
|
433
|
-
panelDescriptor('backup', ['keep', 'types'], true),
|
|
627
|
+
panelDescriptor('backup', ['keep', 'types', 'password'], true),
|
|
434
628
|
panelDescriptor('verify', ['selector'], true),
|
|
435
|
-
panelDescriptor('restore', ['selector', 'dryRun', 'types', 'syncDeps'], true),
|
|
629
|
+
panelDescriptor('restore', ['selector', 'dryRun', 'types', 'syncDeps', 'password'], true),
|
|
436
630
|
panelDescriptor('setAuto', ['hours']),
|
|
437
631
|
panelDescriptor('githubStatus', []),
|
|
438
632
|
panelDescriptor('githubSyncNow', [], true),
|
|
@@ -445,6 +639,37 @@ const PANEL_INVOCATIONS = Object.freeze([
|
|
|
445
639
|
panelDescriptor('setGithubToken', ['token']),
|
|
446
640
|
panelDescriptor('checkUpdate', [], true),
|
|
447
641
|
panelDescriptor('update', [], true),
|
|
642
|
+
panelDescriptor('exportConfig', ['options'], true),
|
|
643
|
+
panelDescriptor('importPreview', ['options'], true),
|
|
644
|
+
panelDescriptor('importApply', ['options'], true),
|
|
645
|
+
panelDescriptor('syncStatus', []),
|
|
646
|
+
panelDescriptor('syncNow', ['channel'], true),
|
|
647
|
+
panelDescriptor('setSyncChannel', ['channel', 'config']),
|
|
648
|
+
panelDescriptor('removeSyncChannel', ['channel']),
|
|
649
|
+
panelDescriptor('lockStatus', []),
|
|
650
|
+
panelDescriptor('reclaimLock', ['force']),
|
|
651
|
+
panelDescriptor('diskUsage', [], true),
|
|
652
|
+
panelDescriptor('cleanup', ['scope'], true),
|
|
653
|
+
panelDescriptor('setWeekly', ['schedules']),
|
|
654
|
+
panelDescriptor('about', []),
|
|
655
|
+
panelDescriptor('profilesList', []),
|
|
656
|
+
panelDescriptor('profileCreate', ['name']),
|
|
657
|
+
panelDescriptor('profileRename', ['old', 'new']),
|
|
658
|
+
panelDescriptor('profileDelete', ['name']),
|
|
659
|
+
panelDescriptor('profileLaunch', ['name']),
|
|
660
|
+
panelDescriptor('profileStop', ['name']),
|
|
661
|
+
panelDescriptor('marketList', [], true),
|
|
662
|
+
panelDescriptor('marketSearch', ['query'], true),
|
|
663
|
+
panelDescriptor('marketInstall', ['options'], true),
|
|
664
|
+
panelDescriptor('foreignSources', []),
|
|
665
|
+
panelDescriptor('foreignPreview', ['options'], true),
|
|
666
|
+
panelDescriptor('foreignImport', ['options'], true),
|
|
667
|
+
panelDescriptor('sessionRewindList', []),
|
|
668
|
+
panelDescriptor('sessionRewindPreview', ['session', 'seq'], true),
|
|
669
|
+
panelDescriptor('sessionRewindApply', ['options'], true),
|
|
670
|
+
panelDescriptor('chatSources', []),
|
|
671
|
+
panelDescriptor('chatPreview', ['options'], true),
|
|
672
|
+
panelDescriptor('chatApply', ['options'], true),
|
|
448
673
|
]);
|
|
449
674
|
|
|
450
675
|
/**
|
|
@@ -464,9 +689,9 @@ class BackupPanelService extends TypertRemoteService {
|
|
|
464
689
|
return this.ops.status();
|
|
465
690
|
}
|
|
466
691
|
|
|
467
|
-
/** 立即备份(types
|
|
468
|
-
backup(keep, types, signal) {
|
|
469
|
-
return this.ops.backup(keep, types, signal);
|
|
692
|
+
/** 立即备份(types 非空时分类型子集备份;password 非空时加密备份——口令内存态,绝不落盘)。 */
|
|
693
|
+
backup(keep, types, password, signal) {
|
|
694
|
+
return this.ops.backup(keep, types, password, signal);
|
|
470
695
|
}
|
|
471
696
|
|
|
472
697
|
/** 校验(selector=前缀|all|latest)。 */
|
|
@@ -474,9 +699,9 @@ class BackupPanelService extends TypertRemoteService {
|
|
|
474
699
|
return this.ops.verify(selector, signal);
|
|
475
700
|
}
|
|
476
701
|
|
|
477
|
-
/** 恢复(dryRun 仅预览;types 非空时分类型 merge 恢复;syncDeps 恢复后重装 profile
|
|
478
|
-
restore(selector, dryRun, types, syncDeps, signal) {
|
|
479
|
-
return this.ops.restore(selector, dryRun, types, syncDeps, signal);
|
|
702
|
+
/** 恢复(dryRun 仅预览;types 非空时分类型 merge 恢复;syncDeps 恢复后重装 profile 依赖;password 用于加密归档——内存态)。 */
|
|
703
|
+
restore(selector, dryRun, types, syncDeps, password, signal) {
|
|
704
|
+
return this.ops.restore(selector, dryRun, types, syncDeps, password, signal);
|
|
480
705
|
}
|
|
481
706
|
|
|
482
707
|
/** 设置自动备份(0=关闭)。 */
|
|
@@ -538,6 +763,161 @@ class BackupPanelService extends TypertRemoteService {
|
|
|
538
763
|
update(signal) {
|
|
539
764
|
return this.ops.update(signal);
|
|
540
765
|
}
|
|
766
|
+
|
|
767
|
+
/** 导出配置为单文件 ZIP(options: { password?, includeCredentials?, sections? })。 */
|
|
768
|
+
exportConfig(options, signal) {
|
|
769
|
+
return this.ops.exportConfig(options, signal);
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/** 导入预览(dry-run):options: { sourcePath, password? }。 */
|
|
773
|
+
importPreview(options, signal) {
|
|
774
|
+
return this.ops.importPreview(options, signal);
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/** 导入应用:options: { sourcePath, password?, sections?, decisions?, remap? }。 */
|
|
778
|
+
importApply(options, signal) {
|
|
779
|
+
return this.ops.importApply(options, signal);
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/** 多通道同步状态(含 github/webdav/s3/gist,凭据脱敏)。 */
|
|
783
|
+
syncStatus() {
|
|
784
|
+
return this.ops.syncStatus();
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
/** 立即推送到指定通道(github/webdav/s3/gist)。 */
|
|
788
|
+
syncNow(channel, signal) {
|
|
789
|
+
return this.ops.syncNow(channel, signal);
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
/** 设置同步通道配置(config 含凭据,只存本机 0600,不回显明文)。 */
|
|
793
|
+
setSyncChannel(channel, config) {
|
|
794
|
+
return this.ops.setSyncChannel(channel, config);
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
/** 清除同步通道。 */
|
|
798
|
+
removeSyncChannel(channel) {
|
|
799
|
+
return this.ops.removeSyncChannel(channel);
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/** 操作锁状态。 */
|
|
803
|
+
lockStatus() {
|
|
804
|
+
return this.ops.lockStatus();
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/** 回收操作锁(force=true 强删活锁,慎用)。 */
|
|
808
|
+
reclaimLock(force) {
|
|
809
|
+
return this.ops.reclaimLock(force);
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
/** 磁盘占用报告。 */
|
|
813
|
+
diskUsage(signal) {
|
|
814
|
+
return this.ops.diskUsage(signal);
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/** 分级清理(scope='cache' 只清可再生缓存;'expired' 再加过期备份,快照/同步永不删)。 */
|
|
818
|
+
cleanup(scope, signal) {
|
|
819
|
+
return this.ops.cleanup(scope, signal);
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
/** 设置每周调度:[{ day: 1-7, time: 'HH:MM' }];空数组关闭。 */
|
|
823
|
+
setWeekly(schedules) {
|
|
824
|
+
return this.ops.setWeekly(schedules);
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
/** About 环境信息(版本/平台/路径,脱敏,供 About 面板 + 一键复制)。 */
|
|
828
|
+
about() {
|
|
829
|
+
return this.ops.about();
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
/** 列出 profiles(app-owned 标注只读)。 */
|
|
833
|
+
profilesList() {
|
|
834
|
+
return this.ops.profilesList();
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
/** 从内置模板创建 profile。 */
|
|
838
|
+
profileCreate(name) {
|
|
839
|
+
return this.ops.profileCreate(name);
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/** 重命名 profile(内置只读,目标已存在拒绝)。 */
|
|
843
|
+
profileRename(oldName, newName) {
|
|
844
|
+
return this.ops.profileRename(oldName, newName);
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
/** 硬删 profile(内置只读,二次确认语义由客户端承担)。 */
|
|
848
|
+
profileDelete(name) {
|
|
849
|
+
return this.ops.profileDelete(name);
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/** 启动独立 profile 实例(detached+unref)。 */
|
|
853
|
+
profileLaunch(name) {
|
|
854
|
+
return this.ops.profileLaunch(name);
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
/** 停止独立 profile 实例。 */
|
|
858
|
+
profileStop(name) {
|
|
859
|
+
return this.ops.profileStop(name);
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
/** 配置市场:列出市场条目(只读;marketUrl 索引,不执行任何 bundle 代码)。 */
|
|
863
|
+
marketList(signal) {
|
|
864
|
+
return this.ops.marketList(signal);
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
/** 配置市场:搜索条目(只读,本地过滤 name/desc)。 */
|
|
868
|
+
marketSearch(query, signal) {
|
|
869
|
+
return this.ops.marketSearch(query, signal);
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
/** 配置市场:安装条目(默认 dry-run;confirm:true 才写,走 importPreview/importApply + 快照回滚)。 */
|
|
873
|
+
marketInstall(options, signal) {
|
|
874
|
+
return this.ops.marketInstall(options, signal);
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
/** 外部 agent:列出本机可导入来源(只读检测)。 */
|
|
878
|
+
foreignSources() {
|
|
879
|
+
return this.ops.foreignSources();
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
/** 外部 agent:预览导入计划(只读,不写 ~/.dsh)。 */
|
|
883
|
+
foreignPreview(options, signal) {
|
|
884
|
+
return this.ops.foreignPreview(options, signal);
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
/** 外部 agent:导入(默认 dry-run;confirm:true 才写,走 importPreview/importApply)。 */
|
|
888
|
+
foreignImport(options, signal) {
|
|
889
|
+
return this.ops.foreignImport(options, signal);
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
/** 会话 Rewind:列出可回退会话(只读)。 */
|
|
893
|
+
sessionRewindList(signal) {
|
|
894
|
+
return this.ops.sessionRewindList(signal);
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
/** 会话 Rewind:预览分叉计划(只读,不写盘)。 */
|
|
898
|
+
sessionRewindPreview(session, seq, signal) {
|
|
899
|
+
return this.ops.sessionRewindPreview(session, seq, signal);
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
/** 会话 Rewind:执行分叉(默认 dry-run;confirm:true 才写;原会话永不修改)。 */
|
|
903
|
+
sessionRewindApply(options, signal) {
|
|
904
|
+
return this.ops.sessionRewindApply(options, signal);
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
/** 外部聊天记录:列出可导入来源(v1 仅 Claude Code)。 */
|
|
908
|
+
chatSources() {
|
|
909
|
+
return this.ops.chatSources();
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
/** 外部聊天记录:预览导入计划(只读)。 */
|
|
913
|
+
chatPreview(options, signal) {
|
|
914
|
+
return this.ops.chatPreview(options, signal);
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
/** 外部聊天记录:导入为新会话(默认 dry-run;confirm:true 才写)。 */
|
|
918
|
+
chatApply(options, signal) {
|
|
919
|
+
return this.ops.chatApply(options, signal);
|
|
920
|
+
}
|
|
541
921
|
}
|
|
542
922
|
|
|
543
923
|
export function apply(ctx, pluginConfig) {
|
|
@@ -770,7 +1150,16 @@ export function apply(ctx, pluginConfig) {
|
|
|
770
1150
|
const raw = typeof cfg.destination === 'string' && cfg.destination.trim()
|
|
771
1151
|
? cfg.destination.trim()
|
|
772
1152
|
: DEFAULTS.destination;
|
|
773
|
-
const
|
|
1153
|
+
const expandHome = (p) => (p.startsWith('~') ? `${home}${p.slice(1)}` : toFwd(p));
|
|
1154
|
+
let root = expandHome(raw);
|
|
1155
|
+
// 0.14.0 改了默认目录名;还没完成迁移(或迁移失败)时继续用老默认目录,
|
|
1156
|
+
// 避免用户升级后看到"备份全没了"。用户显式配置过的目录不走这段兜底。
|
|
1157
|
+
if (raw === DEFAULTS.destination && !pathExists(root)) {
|
|
1158
|
+
for (const legacyRaw of LEGACY_DEFAULT_DESTINATIONS) {
|
|
1159
|
+
const legacyRoot = expandHome(legacyRaw);
|
|
1160
|
+
if (pathExists(legacyRoot)) { root = legacyRoot; break; }
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
774
1163
|
return { home, dshHome: toFwd(dshHome), root };
|
|
775
1164
|
}
|
|
776
1165
|
|
|
@@ -839,6 +1228,33 @@ export function apply(ctx, pluginConfig) {
|
|
|
839
1228
|
}
|
|
840
1229
|
}
|
|
841
1230
|
|
|
1231
|
+
/**
|
|
1232
|
+
* About 环境信息(P4):只含版本/平台/路径,**绝不含凭据/密钥/home 明文**。
|
|
1233
|
+
* home 不单独返回;pluginDir 若位于 home 下则把 home 前缀脱敏成 `~`。
|
|
1234
|
+
* dshVersion 用 detectHostTrain()(测正在跑的宿主,而非 profile 里 hoisted
|
|
1235
|
+
* 的 peer 包);取不到一律 'unknown',绝不让 About 面板因环境探测失败而崩。
|
|
1236
|
+
*/
|
|
1237
|
+
async function aboutInfo() {
|
|
1238
|
+
let dshVersion = 'unknown';
|
|
1239
|
+
try { dshVersion = detectHostTrain() || 'unknown'; } catch { dshVersion = 'unknown'; }
|
|
1240
|
+
let pluginDir = 'unknown';
|
|
1241
|
+
try {
|
|
1242
|
+
pluginDir = (fileURLToPath(new URL('..', import.meta.url)) || '').replace(/\/+$/, '') || '/';
|
|
1243
|
+
} catch { pluginDir = 'unknown'; }
|
|
1244
|
+
let home = null;
|
|
1245
|
+
try { home = paths().home; } catch { home = null; }
|
|
1246
|
+
// 脱敏:pluginDir 绝对路径若含 home 明文,替换成 ~ 前缀
|
|
1247
|
+
const maskHome = (p) => (home && typeof p === 'string' && p.startsWith(home)) ? `~${p.slice(home.length)}` : p;
|
|
1248
|
+
return {
|
|
1249
|
+
version: SELF_META.version,
|
|
1250
|
+
dshVersion,
|
|
1251
|
+
platform: process.platform,
|
|
1252
|
+
node: process.version,
|
|
1253
|
+
profile: 'web', // Web 面板 profile(launchEnvironment 无独立 profile 键时按默认)
|
|
1254
|
+
pluginDir: maskHome(pluginDir),
|
|
1255
|
+
};
|
|
1256
|
+
}
|
|
1257
|
+
|
|
842
1258
|
/**
|
|
843
1259
|
* 一键更新(B):先拍升级前快照(未过期的退路),再走官方 `dsh plugin
|
|
844
1260
|
* --profile <p> update --latest <pkg>`(pnpm 语义——profile 是 pnpm 项目,
|
|
@@ -903,7 +1319,7 @@ export function apply(ctx, pluginConfig) {
|
|
|
903
1319
|
updated: true,
|
|
904
1320
|
current: after || chk.latest,
|
|
905
1321
|
latest: chk.latest,
|
|
906
|
-
summary: `已更新到 ${after || chk.latest}。${snap ? `\n更新前已留升级前快照 ${snap}(想回退:/backup restore ${snap})。` : '\n更新前快照未生成(不影响本次更新)。'}\n请重启 dsh web
|
|
1322
|
+
summary: `已更新到 ${after || chk.latest}。${snap ? `\n更新前已留升级前快照 ${snap}(想回退:/backup restore ${snap})。` : '\n更新前快照未生成(不影响本次更新)。'}\n请重启 dsh web 使新版本生效(宿主 0.2.1-rc.1+ 开启热重载时部分插件可免重启,面板没变化就重启)。`,
|
|
907
1323
|
};
|
|
908
1324
|
}
|
|
909
1325
|
|
|
@@ -1074,7 +1490,7 @@ export function apply(ctx, pluginConfig) {
|
|
|
1074
1490
|
return;
|
|
1075
1491
|
}
|
|
1076
1492
|
const names = dirents
|
|
1077
|
-
.filter((d) => d.name.startsWith(prefix) && (d.name.endsWith('.tar.gz') || d.name.endsWith('.sha256') || d.name.endsWith('.meta.json') || d.name.endsWith('.redacted.json')))
|
|
1493
|
+
.filter((d) => d.name.startsWith(prefix) && (d.name.endsWith('.tar.gz') || d.name.endsWith('.sha256') || d.name.endsWith('.meta.json') || d.name.endsWith('.redacted.json') || d.name.endsWith('.encrypted.json')))
|
|
1078
1494
|
.map((d) => d.name)
|
|
1079
1495
|
.sort()
|
|
1080
1496
|
.reverse();
|
|
@@ -1096,11 +1512,11 @@ export function apply(ctx, pluginConfig) {
|
|
|
1096
1512
|
|
|
1097
1513
|
/** 归档伴生边车集:轮换、删除与 GitHub 同步对同一归档统一处理的文件列表。 */
|
|
1098
1514
|
function sidecarsFor(name) {
|
|
1099
|
-
return [name, `${name}.sha256`, `${name}.meta.json`, `${name}.redacted.json`];
|
|
1515
|
+
return [name, `${name}.sha256`, `${name}.meta.json`, `${name}.redacted.json`, `${name}.encrypted.json`];
|
|
1100
1516
|
}
|
|
1101
1517
|
|
|
1102
|
-
/** 内部快照前缀(恢复前 /
|
|
1103
|
-
const SNAPSHOT_PREFIXES = Object.freeze(['dsh-pre-restore-', 'dsh-pre-upgrade-']);
|
|
1518
|
+
/** 内部快照前缀(恢复前 / 升级前 / 导入前):不进用户列表、不参与轮换与 keep 配额。 */
|
|
1519
|
+
const SNAPSHOT_PREFIXES = Object.freeze(['dsh-pre-restore-', 'dsh-pre-upgrade-', IMPORT_SNAPSHOT_PREFIX]);
|
|
1104
1520
|
|
|
1105
1521
|
async function listBackups() {
|
|
1106
1522
|
const { root } = paths();
|
|
@@ -1129,7 +1545,9 @@ export function apply(ctx, pluginConfig) {
|
|
|
1129
1545
|
// 归档在列表与 stat 之间被删除(轮换/删除竞态)——大小显示为未知而非报错
|
|
1130
1546
|
size = undefined;
|
|
1131
1547
|
}
|
|
1132
|
-
|
|
1548
|
+
// 加密归档带 .encrypted.json 边车:列表标记 🔒,供 CLI/面板/工具展示。
|
|
1549
|
+
const encrypted = await existsStat(`${root}/${d.name}.encrypted.json`);
|
|
1550
|
+
backups.push({ name: d.name, size, encrypted });
|
|
1133
1551
|
}
|
|
1134
1552
|
return backups.sort((a, b) => (a.name < b.name ? 1 : a.name > b.name ? -1 : 0));
|
|
1135
1553
|
}
|
|
@@ -1155,6 +1573,12 @@ export function apply(ctx, pluginConfig) {
|
|
|
1155
1573
|
await writeFile(p, content, 'utf8');
|
|
1156
1574
|
}
|
|
1157
1575
|
|
|
1576
|
+
/** 二进制写盘(密文归档、解密的临时 tar.gz 等——绝不能用 utf8 写,会毁掉字节)。 */
|
|
1577
|
+
async function writeBinary(p, buf) {
|
|
1578
|
+
await mkdir(dirname(p), { recursive: true });
|
|
1579
|
+
await writeFile(p, buf);
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1158
1582
|
// 校验和:POSIX 优先 sha256sum(macOS 回退 shasum)流式计算;
|
|
1159
1583
|
// 都不可用时(Windows 典型)回退 node:crypto 流式哈希。
|
|
1160
1584
|
// #103:旧实现是 readFile 整包进内存 + 同步 update,且 >256MB 直接抛错——
|
|
@@ -1259,7 +1683,38 @@ export function apply(ctx, pluginConfig) {
|
|
|
1259
1683
|
return backups.sort((a, b) => (a.name < b.name ? 1 : a.name > b.name ? -1 : 0));
|
|
1260
1684
|
}
|
|
1261
1685
|
|
|
1686
|
+
// ---------- 操作锁(跨进程互斥 + 死锁回收,P3) ----------
|
|
1687
|
+
// 备份/恢复/导入/同步/doctor 这类重活会动 ~/.dsh 与备份目录(vault 刷新、
|
|
1688
|
+
// .dsh 挪旁、归档轮换),多实例/多 profile 并发会互相踩。锁文件在备份目录根
|
|
1689
|
+
// .oplock.json,记 pid+token+心跳;死锁(pid 已死)下次自动回收,活锁绝不误踢。
|
|
1690
|
+
// 同进程重入(restore 内部的快照 doBackup 等)经 heldLock 内存标记直接放行。
|
|
1691
|
+
let heldLock = null;
|
|
1692
|
+
let heartbeatTimer = null;
|
|
1693
|
+
|
|
1694
|
+
async function withOperationLock(operation, fn) {
|
|
1695
|
+
if (heldLock) return fn(); // 同进程重入:本进程已持锁,直接跑
|
|
1696
|
+
const { root } = paths();
|
|
1697
|
+
const lockPath = `${root}/${LOCK_FILE}`;
|
|
1698
|
+
const r = await acquireLock(lockPath, operation);
|
|
1699
|
+
if (!r.acquired) {
|
|
1700
|
+
throw new Error(`另一操作正在进行(${r.heldBy?.operation ?? '未知'},pid=${r.heldBy?.pid})——请稍后重试;若确认是残留死锁可用 /backup lock reclaim 回收`);
|
|
1701
|
+
}
|
|
1702
|
+
heldLock = r.lock;
|
|
1703
|
+
heartbeatTimer = setInterval(() => { heartbeatLock(lockPath, r.lock.token).catch(() => {}); }, LOCK_HEARTBEAT_MS);
|
|
1704
|
+
try {
|
|
1705
|
+
return await fn();
|
|
1706
|
+
} finally {
|
|
1707
|
+
if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; }
|
|
1708
|
+
await releaseLock(lockPath, r.lock.token).catch(() => {});
|
|
1709
|
+
heldLock = null;
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1262
1713
|
async function doBackup(keep, signal, opts = {}) {
|
|
1714
|
+
return withOperationLock('backup', () => doBackupInner(keep, signal, opts));
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
async function doBackupInner(keep, signal, opts = {}) {
|
|
1263
1718
|
const { home, dshHome, root } = paths();
|
|
1264
1719
|
const keepN = keep && keep > 0 ? Math.floor(keep) : defaultKeep();
|
|
1265
1720
|
// 状态文件先写:writeText 会自动创建备份目录,替代 mkdir -p(Windows 无 mkdir.exe)。
|
|
@@ -1315,6 +1770,20 @@ export function apply(ctx, pluginConfig) {
|
|
|
1315
1770
|
throw err;
|
|
1316
1771
|
}
|
|
1317
1772
|
|
|
1773
|
+
// 可选口令加密(P2):口令只经内存传入(面板 RPC 的 backup({password})),
|
|
1774
|
+
// **绝不写 settings.yaml、绝不进日志、绝不写盘**。密文原地覆盖归档本身,
|
|
1775
|
+
// nonce/tag/salt/kdf 参数进 .encrypted.json 边车;下方 sha256 算的是**密文**,
|
|
1776
|
+
// 故 verify 无需口令即可做完整性校验。加密前 tar.gz 已是完整明文,此处原地
|
|
1777
|
+
// 替换——旧版插件遇到它只会 tar 解不开,边车只增不改、不误删不误读。
|
|
1778
|
+
let encrypted = false;
|
|
1779
|
+
if (typeof opts?.password === 'string' && opts.password) {
|
|
1780
|
+
const plain = await readFile(out);
|
|
1781
|
+
const enc = await encryptBuffer(plain, opts.password);
|
|
1782
|
+
await writeBinary(out, enc.ciphertext);
|
|
1783
|
+
await writeOwned(`${out}.encrypted.json`, `${JSON.stringify(enc.sidecar)}\n`);
|
|
1784
|
+
encrypted = true;
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1318
1787
|
const shaText = await sha256File(out, home, signal);
|
|
1319
1788
|
await writeOwned(`${out}.sha256`, `${shaText} ${out}\n`);
|
|
1320
1789
|
// types 归档不写 .redacted.json:凭据类明文已在归档内(不靠 vault 还原),
|
|
@@ -1326,7 +1795,7 @@ export function apply(ctx, pluginConfig) {
|
|
|
1326
1795
|
// quarantined 记录体检隔离、未入档的损坏会话文件。
|
|
1327
1796
|
// meta 记 types(分类型归档标识;缺省=全量,旧版/旧归档照读不误);types
|
|
1328
1797
|
// 归档 redacted 记 0(未脱敏),hasCredentials 供 githubSync 跳过上云判断。
|
|
1329
|
-
await writeOwned(`${out}.meta.json`, `${JSON.stringify({ host: hostname(), home, dshHome, createdAt: new Date().toISOString(), redacted: isTyped ? 0 : redacted.length, ...(isTyped ? { types, hasCredentials } : {}), ...(quarantined.length ? { quarantined } : {}) })}\n`);
|
|
1798
|
+
await writeOwned(`${out}.meta.json`, `${JSON.stringify({ host: hostname(), home, dshHome, createdAt: new Date().toISOString(), redacted: isTyped ? 0 : redacted.length, ...(isTyped ? { types, hasCredentials } : {}), ...(encrypted ? { encrypted: true } : {}), ...(quarantined.length ? { quarantined } : {}) })}\n`);
|
|
1330
1799
|
|
|
1331
1800
|
// 救援通道:每次备份把进程外恢复工具落进备份目录——宿主起不来时
|
|
1332
1801
|
// `node rescue.mjs`(或双击启动器)仍可恢复(DSH 依赖 Node,宿主死了
|
|
@@ -1334,6 +1803,8 @@ export function apply(ctx, pluginConfig) {
|
|
|
1334
1803
|
try {
|
|
1335
1804
|
await copyFile(new URL('../rescue/rescue.mjs', import.meta.url), `${root}/rescue.mjs`);
|
|
1336
1805
|
await writeOwned(`${root}/RESCUE.txt`, RESCUE_TXT);
|
|
1806
|
+
// 人话版使用说明:用户第一次打开备份目录时最该看到的东西(0.14.0)
|
|
1807
|
+
await writeOwned(`${root}/${GUIDE_FILE}`, GUIDE_TXT);
|
|
1337
1808
|
const launcher = RESCUE_LAUNCHERS[process.platform];
|
|
1338
1809
|
if (launcher) {
|
|
1339
1810
|
const [name, content] = launcher;
|
|
@@ -1346,10 +1817,10 @@ export function apply(ctx, pluginConfig) {
|
|
|
1346
1817
|
} catch { /* 老安装缺文件等场景静默跳过 */ }
|
|
1347
1818
|
|
|
1348
1819
|
// 安全:备份含明文凭据(.credentials.yaml / qq-bridge/config.json),收紧为仅本人可读写。
|
|
1349
|
-
// Windows 无 chmod,用户目录 ACL
|
|
1820
|
+
// Windows 无 chmod,用户目录 ACL 默认私有。加密边车同权(无明文,但统一收紧)。
|
|
1350
1821
|
if (!IS_WIN) {
|
|
1351
1822
|
const chmod = await ctx.subprocess.resolveExecutable('chmod');
|
|
1352
|
-
await spawnRun([chmod, '600', out, `${out}.sha256`], home, signal);
|
|
1823
|
+
await spawnRun([chmod, '600', out, `${out}.sha256`, ...(encrypted ? [`${out}.encrypted.json`] : [])], home, signal);
|
|
1353
1824
|
}
|
|
1354
1825
|
|
|
1355
1826
|
// 分级轮换:keep 窗口之外的尾巴不再一刀切全删——保留最近 7 个不同自然日
|
|
@@ -1362,9 +1833,16 @@ export function apply(ctx, pluginConfig) {
|
|
|
1362
1833
|
// keep 窗口外,轮换会删掉马上要解压的文件(review P1-3)。
|
|
1363
1834
|
const rotateBase = isTyped ? await listTypedBackups() : await listBackups();
|
|
1364
1835
|
const rotateTail = rotateBase.slice(keepN);
|
|
1836
|
+
// GFS 保留(P3):在既有日/周分层之上,再按 gfsMonthly/gfsYearly 各留月/年
|
|
1837
|
+
// 首份。缺省 0 = 完全等价旧行为(只 keep N + 日/周分层)。
|
|
1838
|
+
const gfsCfg = resolvedConfig();
|
|
1839
|
+
const gfsProtect = gfsProtectedSet(rotateTail, rotateBase[0]?.name, {
|
|
1840
|
+
monthly: Number(gfsCfg.gfsMonthly) > 0 ? Math.floor(Number(gfsCfg.gfsMonthly)) : 0,
|
|
1841
|
+
yearly: Number(gfsCfg.gfsYearly) > 0 ? Math.floor(Number(gfsCfg.gfsYearly)) : 0,
|
|
1842
|
+
});
|
|
1365
1843
|
const rotateStale = opts.skipRotate
|
|
1366
1844
|
? []
|
|
1367
|
-
: rotateTail.filter((b) => !tierProtectedSet(rotateTail, rotateBase[0]?.name).has(b.name)).map((b) => b.name);
|
|
1845
|
+
: rotateTail.filter((b) => !tierProtectedSet(rotateTail, rotateBase[0]?.name).has(b.name) && !gfsProtect.has(b.name)).map((b) => b.name);
|
|
1368
1846
|
if (rotateStale.length) await removeFiles(rotateStale.flatMap((n) => sidecarsFor(n)), root, signal);
|
|
1369
1847
|
|
|
1370
1848
|
// GitHub 同步(失败不回滚备份;状态记入 auto.json)
|
|
@@ -1385,7 +1863,7 @@ export function apply(ctx, pluginConfig) {
|
|
|
1385
1863
|
await saveAutoState();
|
|
1386
1864
|
}
|
|
1387
1865
|
|
|
1388
|
-
return { path: out, name, sha: shaText, total: rotateBase.length, stale: rotateStale.length, keep: keepN, quarantined, sync, ...(isTyped ? { types, hasCredentials } : {}) };
|
|
1866
|
+
return { path: out, name, sha: shaText, total: rotateBase.length, stale: rotateStale.length, keep: keepN, quarantined, sync, ...(encrypted ? { encrypted: true } : {}), ...(isTyped ? { types, hasCredentials } : {}) };
|
|
1389
1867
|
}
|
|
1390
1868
|
|
|
1391
1869
|
/** 备份回执的隔离提示行:体检发现坏会话文件未入档时如实告知 + 指路修复。 */
|
|
@@ -1659,8 +2137,9 @@ export function apply(ctx, pluginConfig) {
|
|
|
1659
2137
|
continue;
|
|
1660
2138
|
}
|
|
1661
2139
|
await rename(staging, `${root}/${name}`);
|
|
1662
|
-
//
|
|
1663
|
-
|
|
2140
|
+
// 边车随归档转正(缺失的可选边车跳过);.encrypted.json 一并带回来,
|
|
2141
|
+
// 否则加密归档拉回后无法解密(P3 补)。
|
|
2142
|
+
for (const side of [`${name}.sha256`, `${name}.meta.json`, `${name}.redacted.json`, `${name}.encrypted.json`]) {
|
|
1664
2143
|
await copyFile(`${syncDir}/${side}`, `${root}/${side}`).catch((err) => {
|
|
1665
2144
|
if (err && err.code === 'ENOENT') return;
|
|
1666
2145
|
throw err;
|
|
@@ -1671,6 +2150,189 @@ export function apply(ctx, pluginConfig) {
|
|
|
1671
2150
|
return { pulled, corrupt, total: wanted.length };
|
|
1672
2151
|
}
|
|
1673
2152
|
|
|
2153
|
+
// ---------- 多通道同步(WebDAV / S3 兼容 / GitHub Gist,P3) ----------
|
|
2154
|
+
// 与现有 GitHub 同步并列:每通道独立配置,持久化到备份目录根 sync.json(0600),
|
|
2155
|
+
// 凭据(password/secretKey/token)只存本机、不进归档、不进日志、状态回显一律脱敏。
|
|
2156
|
+
// 同步产物与 GitHub 一致:全量归档 + .sha256 + .meta.json + .redacted.json +
|
|
2157
|
+
// .encrypted.json 的可移植集合。webdav/s3/gist 本轮只做**推送**(pull 仍走 GitHub)。
|
|
2158
|
+
let syncChannels = {};
|
|
2159
|
+
const SYNC_CHANNEL_TYPES = new Set(['webdav', 's3', 'gist']);
|
|
2160
|
+
|
|
2161
|
+
/** 校验并归一化通道配置;非法返回原因,合法返回归一化对象。 */
|
|
2162
|
+
function normalizeSyncChannel(channel, config) {
|
|
2163
|
+
if (!SYNC_CHANNEL_TYPES.has(channel)) return { error: `不支持的通道类型 ${channel}(支持 webdav/s3/gist)` };
|
|
2164
|
+
const c = config && typeof config === 'object' ? config : {};
|
|
2165
|
+
if (channel === 'webdav') {
|
|
2166
|
+
const url = typeof c.url === 'string' ? c.url.trim() : '';
|
|
2167
|
+
if (!/^https?:\/\//i.test(url)) return { error: 'WebDAV 需要 http(s):// 开头的 url' };
|
|
2168
|
+
return { value: { type: 'webdav', url, username: typeof c.username === 'string' ? c.username : '', password: typeof c.password === 'string' ? c.password : '' } };
|
|
2169
|
+
}
|
|
2170
|
+
if (channel === 's3') {
|
|
2171
|
+
const endpoint = typeof c.endpoint === 'string' ? c.endpoint.trim() : '';
|
|
2172
|
+
if (!/^https?:\/\//i.test(endpoint)) return { error: 'S3 需要 http(s):// 开头的 endpoint' };
|
|
2173
|
+
const region = typeof c.region === 'string' && c.region.trim() ? c.region.trim() : 'us-east-1';
|
|
2174
|
+
const bucket = typeof c.bucket === 'string' ? c.bucket.trim() : '';
|
|
2175
|
+
if (!bucket) return { error: 'S3 需要 bucket 名' };
|
|
2176
|
+
const accessKey = typeof c.accessKey === 'string' ? c.accessKey.trim() : '';
|
|
2177
|
+
const secretKey = typeof c.secretKey === 'string' ? c.secretKey : '';
|
|
2178
|
+
if (!accessKey || !secretKey) return { error: 'S3 需要 accessKey 与 secretKey' };
|
|
2179
|
+
return { value: { type: 's3', endpoint, region, bucket, accessKey, secretKey, pathPrefix: typeof c.pathPrefix === 'string' ? c.pathPrefix.replace(/^\/+|\/+$/g, '') : '', pathStyle: c.pathStyle !== false } };
|
|
2180
|
+
}
|
|
2181
|
+
if (channel === 'gist') {
|
|
2182
|
+
const token = typeof c.token === 'string' ? c.token.trim() : '';
|
|
2183
|
+
if (!/^[A-Za-z0-9_-]{20,255}$/.test(token)) return { error: 'Gist 需要合法 GitHub token(ghp_…/github_pat_…/经典 40 位)' };
|
|
2184
|
+
return { value: { type: 'gist', token, gistId: typeof c.gistId === 'string' ? c.gistId.trim() : '' } };
|
|
2185
|
+
}
|
|
2186
|
+
return { error: `不支持的通道类型 ${channel}` };
|
|
2187
|
+
}
|
|
2188
|
+
|
|
2189
|
+
/** 状态回显用脱敏视图:凭据只给「已配置/未配置」,绝不含明文。 */
|
|
2190
|
+
function redactSyncChannel(channel, ch) {
|
|
2191
|
+
if (!ch) return { channel, type: channel, configured: false };
|
|
2192
|
+
const out = { channel, type: ch.type, configured: true, lastPush: ch.lastPush ?? null, lastError: ch.lastError ?? null };
|
|
2193
|
+
if (ch.type === 'webdav') out.url = ch.url; out.username = ch.username; out.passwordSet = Boolean(ch.password);
|
|
2194
|
+
if (ch.type === 's3') { out.endpoint = ch.endpoint; out.region = ch.region; out.bucket = ch.bucket; out.pathPrefix = ch.pathPrefix || ''; out.accessKey = ch.accessKey; out.secretKeySet = Boolean(ch.secretKey); out.pathStyle = ch.pathStyle !== false; }
|
|
2195
|
+
if (ch.type === 'gist') { out.gistId = ch.gistId || ''; out.tokenSet = Boolean(ch.token); }
|
|
2196
|
+
return out;
|
|
2197
|
+
}
|
|
2198
|
+
|
|
2199
|
+
async function loadSyncChannels() {
|
|
2200
|
+
const { root } = paths();
|
|
2201
|
+
try {
|
|
2202
|
+
const parsed = JSON.parse(await readFile(`${root}/${SYNC_CONFIG_FILE}`, 'utf8'));
|
|
2203
|
+
const disk = parsed && typeof parsed === 'object' ? parsed : {};
|
|
2204
|
+
// 合并而非整对象覆盖:boot 钩子加载若与面板 setSyncChannel 并发,后者可能
|
|
2205
|
+
// 已先把某通道写进内存并落盘,此处整对象覆盖会把刚 set 的通道清成空(真实
|
|
2206
|
+
// 竞态)。只填充内存里还没有的通道;已有通道一律保留。显式 set/remove 才落盘。
|
|
2207
|
+
for (const [k, v] of Object.entries(disk)) {
|
|
2208
|
+
if (!(k in syncChannels)) syncChannels[k] = v;
|
|
2209
|
+
}
|
|
2210
|
+
} catch (err) {
|
|
2211
|
+
// ENOENT(首次使用)或读失败都保持内存现状,不覆盖——宁可少回填一次,
|
|
2212
|
+
// 也不把并发写入的内存态清掉。
|
|
2213
|
+
if (err && err.code !== 'ENOENT') console.warn(`[dsh-backup] 同步通道配置读取失败(不影响备份): ${String(err && err.message ? err.message : err)}`);
|
|
2214
|
+
}
|
|
2215
|
+
return syncChannels;
|
|
2216
|
+
}
|
|
2217
|
+
|
|
2218
|
+
async function saveSyncChannels() {
|
|
2219
|
+
const { root } = paths();
|
|
2220
|
+
await mkdir(root, { recursive: true });
|
|
2221
|
+
const target = `${root}/${SYNC_CONFIG_FILE}`;
|
|
2222
|
+
const tmp = `${target}.${randomUUID()}.tmp`;
|
|
2223
|
+
try {
|
|
2224
|
+
await writeOwned(tmp, `${JSON.stringify(syncChannels)}\n`);
|
|
2225
|
+
await rename(tmp, target);
|
|
2226
|
+
} catch (err) {
|
|
2227
|
+
await rm(tmp, { force: true }).catch(() => {});
|
|
2228
|
+
throw err;
|
|
2229
|
+
}
|
|
2230
|
+
if (!IS_WIN) await chmod(target, 0o600).catch(() => {});
|
|
2231
|
+
}
|
|
2232
|
+
|
|
2233
|
+
/** 全部通道(含 github)的脱敏状态视图。 */
|
|
2234
|
+
async function listSyncChannels() {
|
|
2235
|
+
const cfg = githubConfig();
|
|
2236
|
+
return {
|
|
2237
|
+
github: { channel: 'github', type: 'github', configured: Boolean(cfg), repo: cfg ? cfg.repo : null, tokenSet: hasGithubToken(), lastPush: githubState.lastPush, lastError: githubState.lastError },
|
|
2238
|
+
webdav: redactSyncChannel('webdav', syncChannels.webdav),
|
|
2239
|
+
s3: redactSyncChannel('s3', syncChannels.s3),
|
|
2240
|
+
gist: redactSyncChannel('gist', syncChannels.gist),
|
|
2241
|
+
};
|
|
2242
|
+
}
|
|
2243
|
+
|
|
2244
|
+
async function setSyncChannel(channel, config) {
|
|
2245
|
+
const n = normalizeSyncChannel(channel, config);
|
|
2246
|
+
if (n.error) return { ok: false, summary: n.error };
|
|
2247
|
+
syncChannels[channel] = { ...n.value, lastPush: syncChannels[channel]?.lastPush ?? null, lastError: null };
|
|
2248
|
+
await saveSyncChannels();
|
|
2249
|
+
return { ok: true, summary: `${channel} 同步通道已保存(凭据仅存本机备份目录 0600,不进归档不进日志)`, channel: redactSyncChannel(channel, syncChannels[channel]) };
|
|
2250
|
+
}
|
|
2251
|
+
|
|
2252
|
+
async function removeSyncChannel(channel) {
|
|
2253
|
+
delete syncChannels[channel];
|
|
2254
|
+
await saveSyncChannels();
|
|
2255
|
+
return { ok: true, summary: `${channel} 同步通道已清除` };
|
|
2256
|
+
}
|
|
2257
|
+
|
|
2258
|
+
/** 组装同步文件清单(全量归档 + 边车),返回已存在的文件绝对路径列表。 */
|
|
2259
|
+
async function syncFileSet() {
|
|
2260
|
+
const { root } = paths();
|
|
2261
|
+
const out = [];
|
|
2262
|
+
for (const b of await listBackups()) {
|
|
2263
|
+
for (const f of sidecarsFor(b.name)) {
|
|
2264
|
+
if (await existsStat(`${root}/${f}`)) out.push(f);
|
|
2265
|
+
}
|
|
2266
|
+
}
|
|
2267
|
+
return out;
|
|
2268
|
+
}
|
|
2269
|
+
|
|
2270
|
+
/**
|
|
2271
|
+
* 推送到指定通道。github 复用现有 githubSync(含拉取语义);webdav/s3/gist
|
|
2272
|
+
* 走 lib/sync-channels.js 的传输层,只推送。返回 { pushed, count, at, tooBig,
|
|
2273
|
+
* error?, skipped?, gistUrl? },凭据绝不进返回。
|
|
2274
|
+
*/
|
|
2275
|
+
async function syncNow(channel, signal) {
|
|
2276
|
+
if (channel === 'github') return githubSync(signal);
|
|
2277
|
+
const ch = syncChannels[channel];
|
|
2278
|
+
if (!ch) return { error: `未配置同步通道 ${channel}(/backup sync ${channel} set … 或面板配置)` };
|
|
2279
|
+
const { root } = paths();
|
|
2280
|
+
const set = await syncFileSet();
|
|
2281
|
+
const tooBig = [];
|
|
2282
|
+
const max = ch.type === 'gist' ? GIST_MAX_BYTES : SYNC_MAX_BYTES;
|
|
2283
|
+
const at = new Date().toISOString();
|
|
2284
|
+
try {
|
|
2285
|
+
if (ch.type === 'webdav' || ch.type === 's3') {
|
|
2286
|
+
let count = 0;
|
|
2287
|
+
for (const f of set) {
|
|
2288
|
+
if (signal?.aborted) throw new Error('操作已取消');
|
|
2289
|
+
const p = `${root}/${f}`;
|
|
2290
|
+
const st = await fsStat(p).then((s) => s, () => null);
|
|
2291
|
+
if (!st) continue;
|
|
2292
|
+
if (st.size > max) { tooBig.push(f); continue; }
|
|
2293
|
+
const body = await readFile(p);
|
|
2294
|
+
if (ch.type === 'webdav') {
|
|
2295
|
+
await webdavPut({ url: ch.url, username: ch.username, password: ch.password, key: f, body, signal });
|
|
2296
|
+
} else {
|
|
2297
|
+
const key = [ch.pathPrefix, f].filter(Boolean).join('/');
|
|
2298
|
+
await s3Put({ endpoint: ch.endpoint, region: ch.region, bucket: ch.bucket, key, body, accessKey: ch.accessKey, secretKey: ch.secretKey, pathStyle: ch.pathStyle !== false, signal });
|
|
2299
|
+
}
|
|
2300
|
+
count += 1;
|
|
2301
|
+
}
|
|
2302
|
+
syncChannels[channel] = { ...ch, lastPush: at, lastError: null };
|
|
2303
|
+
await saveSyncChannels();
|
|
2304
|
+
return { pushed: count > 0, count, at, tooBig };
|
|
2305
|
+
}
|
|
2306
|
+
if (ch.type === 'gist') {
|
|
2307
|
+
const files = {};
|
|
2308
|
+
for (const f of set) {
|
|
2309
|
+
if (signal?.aborted) throw new Error('操作已取消');
|
|
2310
|
+
const p = `${root}/${f}`;
|
|
2311
|
+
const st = await fsStat(p).then((s) => s, () => null);
|
|
2312
|
+
if (!st) continue;
|
|
2313
|
+
if (st.size > max) { tooBig.push(f); continue; }
|
|
2314
|
+
const buf = await readFile(p);
|
|
2315
|
+
files[f] = { content: gistContent(buf, { binary: f.endsWith('.tar.gz') }) };
|
|
2316
|
+
}
|
|
2317
|
+
const g = await gistSync({ token: ch.token, gistId: ch.gistId || null, files, description: 'dsh-backup sync', signal });
|
|
2318
|
+
if (g.gistId && g.gistId !== ch.gistId) {
|
|
2319
|
+
syncChannels[channel] = { ...ch, gistId: g.gistId, lastPush: at, lastError: null };
|
|
2320
|
+
await saveSyncChannels();
|
|
2321
|
+
} else {
|
|
2322
|
+
syncChannels[channel] = { ...ch, lastPush: at, lastError: null };
|
|
2323
|
+
await saveSyncChannels();
|
|
2324
|
+
}
|
|
2325
|
+
return { pushed: Object.keys(files).length > 0, count: Object.keys(files).length, at, tooBig, gistUrl: g.htmlUrl };
|
|
2326
|
+
}
|
|
2327
|
+
return { error: `未知通道类型 ${ch.type}` };
|
|
2328
|
+
} catch (err) {
|
|
2329
|
+
const message = String(err && err.message ? err.message : err);
|
|
2330
|
+
syncChannels[channel] = { ...ch, lastError: message };
|
|
2331
|
+
await saveSyncChannels().catch(() => {});
|
|
2332
|
+
return { error: message };
|
|
2333
|
+
}
|
|
2334
|
+
}
|
|
2335
|
+
|
|
1674
2336
|
// ---------- Web 下载路由(仅 loopback,附件形式) ----------
|
|
1675
2337
|
async function handleDownload(req, res) {
|
|
1676
2338
|
try {
|
|
@@ -1682,7 +2344,9 @@ export function apply(ctx, pluginConfig) {
|
|
|
1682
2344
|
}
|
|
1683
2345
|
const pathname = new URL(req.url || '', 'http://x').pathname;
|
|
1684
2346
|
const name = decodeURIComponent(pathname.slice(DOWNLOAD_PREFIX.length).replace(/^\//, ''));
|
|
1685
|
-
|
|
2347
|
+
const isTarGz = /^dsh-[A-Za-z0-9._-]+\.tar\.gz$/.test(name);
|
|
2348
|
+
const isZip = /^dsh-config-[A-Za-z0-9._-]+\.zip$/.test(name);
|
|
2349
|
+
if (!isTarGz && !isZip) {
|
|
1686
2350
|
res.writeHead(400);
|
|
1687
2351
|
res.end('bad name');
|
|
1688
2352
|
return;
|
|
@@ -1691,7 +2355,7 @@ export function apply(ctx, pluginConfig) {
|
|
|
1691
2355
|
const abs = `${root}/${name}`;
|
|
1692
2356
|
await fsStat(abs);
|
|
1693
2357
|
res.writeHead(200, {
|
|
1694
|
-
'Content-Type': 'application/gzip',
|
|
2358
|
+
'Content-Type': isZip ? 'application/zip' : 'application/gzip',
|
|
1695
2359
|
'Content-Disposition': `attachment; filename="${name}"`,
|
|
1696
2360
|
});
|
|
1697
2361
|
createReadStream(abs).on('error', () => { res.destroy(); }).pipe(res);
|
|
@@ -1703,6 +2367,53 @@ export function apply(ctx, pluginConfig) {
|
|
|
1703
2367
|
}
|
|
1704
2368
|
|
|
1705
2369
|
// ---------- 校验与恢复 ----------
|
|
2370
|
+
/**
|
|
2371
|
+
* 判断归档是否加密:优先看 .encrypted.json 边车(实际密文参数所在),
|
|
2372
|
+
* 再回退 meta.encrypted(老边车缺位但 meta 有标记时的兜底)。只增不改:
|
|
2373
|
+
* 边车存在即加密,不存在则不是——不把 meta 与边车不一致当作可恢复状态。
|
|
2374
|
+
*/
|
|
2375
|
+
async function isArchiveEncrypted(archiveName) {
|
|
2376
|
+
const { root } = paths();
|
|
2377
|
+
if (await existsStat(`${root}/${archiveName}.encrypted.json`)) return true;
|
|
2378
|
+
try {
|
|
2379
|
+
const meta = JSON.parse(await readFile(`${root}/${archiveName}.meta.json`, 'utf8'));
|
|
2380
|
+
// 用真值判定而非严格 === true:未来若 meta.encrypted 升级为版本号(如 2),
|
|
2381
|
+
// 真值仍会把归档判为「加密」,交给 decryptBuffer 的版本门安全拒绝,而不是
|
|
2382
|
+
// 静默当明文 tar 去啃密文。false/缺省仍判未加密。
|
|
2383
|
+
return Boolean(meta && meta.encrypted);
|
|
2384
|
+
} catch {
|
|
2385
|
+
return false;
|
|
2386
|
+
}
|
|
2387
|
+
}
|
|
2388
|
+
|
|
2389
|
+
/**
|
|
2390
|
+
* 把加密归档解密成备份目录内的一份临时 tar.gz,返回 { name, cleanup }。
|
|
2391
|
+
* 临时文件用相对名(cwd=备份目录),规避 Windows GNU tar 盘符冒号问题;
|
|
2392
|
+
* 解密失败(口令错/边车坏)抛人话报错,调用方直接展示。
|
|
2393
|
+
*/
|
|
2394
|
+
async function decryptArchiveFile(archiveName, password, signal) {
|
|
2395
|
+
const { root } = paths();
|
|
2396
|
+
if (signal?.aborted) throw new Error('操作已取消');
|
|
2397
|
+
if (typeof password !== 'string' || !password) {
|
|
2398
|
+
throw new Error('这份备份已加密,恢复需要口令(口令只经面板 RPC 传入、内存态,命令行暂不支持输入口令)');
|
|
2399
|
+
}
|
|
2400
|
+
let sidecar;
|
|
2401
|
+
try {
|
|
2402
|
+
sidecar = JSON.parse(await readFile(`${root}/${archiveName}.encrypted.json`, 'utf8'));
|
|
2403
|
+
} catch (err) {
|
|
2404
|
+
// meta.encrypted 标记了加密但边车缺失:这是不一致状态,拒绝而非猜着解
|
|
2405
|
+
throw new Error(`加密边车(.encrypted.json)缺失或损坏,无法解密——请核对备份是否完整(技术细节: ${snippet(String(err && err.message ? err.message : err), 80)})`);
|
|
2406
|
+
}
|
|
2407
|
+
const ciphertext = await readFile(`${root}/${archiveName}`);
|
|
2408
|
+
const plain = await decryptBuffer(ciphertext, sidecar, password);
|
|
2409
|
+
// 隐藏名 `.decrypt-<时间戳>.tar.gz`:listBackups 要求 `dsh-` 前缀,隐藏点开头
|
|
2410
|
+
// 的临时文件绝不进用户列表/轮换;位于备份目录内、相对名传 tar,规避 Windows
|
|
2411
|
+
// GNU tar 盘符冒号问题。finally 里必清理(明文 tar.gz 不能遗留)。
|
|
2412
|
+
const tmpName = `.decrypt-${stampNow()}.tar.gz`;
|
|
2413
|
+
await writeBinary(`${root}/${tmpName}`, plain);
|
|
2414
|
+
return { name: tmpName, cleanup: () => rm(`${root}/${tmpName}`, { force: true }) };
|
|
2415
|
+
}
|
|
2416
|
+
|
|
1706
2417
|
async function verifyOne(name, home, signal) {
|
|
1707
2418
|
const { root } = paths();
|
|
1708
2419
|
const archive = `${root}/${name}`;
|
|
@@ -1713,17 +2424,32 @@ export function apply(ctx, pluginConfig) {
|
|
|
1713
2424
|
} catch {
|
|
1714
2425
|
// 边车缺失
|
|
1715
2426
|
}
|
|
2427
|
+
const encrypted = await isArchiveEncrypted(name);
|
|
1716
2428
|
if (!/^[0-9a-f]{64}$/.test(expected)) {
|
|
1717
|
-
return { name, ok: false, note: '缺少或无效的配套校验文件(.sha256)——无法确认这份备份是否完好' };
|
|
2429
|
+
return { name, ok: false, encrypted, note: '缺少或无效的配套校验文件(.sha256)——无法确认这份备份是否完好' };
|
|
1718
2430
|
}
|
|
1719
2431
|
const actual = await sha256File(archive, home, signal);
|
|
1720
|
-
return {
|
|
2432
|
+
return {
|
|
2433
|
+
name,
|
|
2434
|
+
ok: actual === expected,
|
|
2435
|
+
encrypted,
|
|
2436
|
+
// 加密归档的 sha256 是密文的:完整性校验无需口令,口令只影响恢复时的解密。
|
|
2437
|
+
note: actual === expected ? (encrypted ? '完整(🔒 加密归档:完整性校验无需口令)' : '完整') : '校验和不匹配(归档或校验文件之一可能损坏)',
|
|
2438
|
+
};
|
|
1721
2439
|
}
|
|
1722
2440
|
|
|
1723
2441
|
async function pickArchive(selector) {
|
|
1724
2442
|
const all = await listBackups();
|
|
1725
|
-
|
|
1726
|
-
|
|
2443
|
+
// 无全量归档时不能早退「暂无备份」——跨机首恢复现场正是只有分类型归档
|
|
2444
|
+
// (#126:新机从另一台机器只拷来一份 dsh-t- 归档,全量列表为空)。下面
|
|
2445
|
+
// 的显式名/前缀分支会把分类型归档一并纳入候选;仅在候选全空时才报错。
|
|
2446
|
+
const wantsLatest = !selector || selector === 'latest';
|
|
2447
|
+
if (!all.length && wantsLatest) {
|
|
2448
|
+
const typedLatest = await listTypedBackups();
|
|
2449
|
+
if (typedLatest.length) throw new Error('本机只有分类型归档(dsh-t- 前缀),没有全量归档可用 latest 选中——请用完整归档名或前缀,并带 --types');
|
|
2450
|
+
throw new Error('暂无备份');
|
|
2451
|
+
}
|
|
2452
|
+
if (wantsLatest) return all[0];
|
|
1727
2453
|
const exact = all.filter((b) => b.name === selector);
|
|
1728
2454
|
const hits = exact.length ? exact : all.filter((b) => b.name.startsWith(selector));
|
|
1729
2455
|
if (hits.length === 1) return hits[0];
|
|
@@ -1929,6 +2655,10 @@ export function apply(ctx, pluginConfig) {
|
|
|
1929
2655
|
}
|
|
1930
2656
|
|
|
1931
2657
|
async function restoreArchive(selector, dryRun, signal, opts = {}) {
|
|
2658
|
+
return withOperationLock('restore', () => restoreArchiveInner(selector, dryRun, signal, opts));
|
|
2659
|
+
}
|
|
2660
|
+
|
|
2661
|
+
async function restoreArchiveInner(selector, dryRun, signal, opts = {}) {
|
|
1932
2662
|
const { home, dshHome, root } = paths();
|
|
1933
2663
|
const picked = await pickArchive(selector);
|
|
1934
2664
|
const archive = `${root}/${picked.name}`;
|
|
@@ -1937,9 +2667,29 @@ export function apply(ctx, pluginConfig) {
|
|
|
1937
2667
|
const v = await verifyOne(picked.name, home, signal);
|
|
1938
2668
|
if (!v.ok) throw new Error(`校验未通过(${v.note}),恢复已中止`);
|
|
1939
2669
|
|
|
2670
|
+
// 加密归档:先解密到备份目录内临时 tar.gz(隐藏名 .decrypt-*,不进列表/轮换),
|
|
2671
|
+
// 后续 tar 全部针对明文临时文件;口令只经 opts.password(面板 RPC 内存态)传入,
|
|
2672
|
+
// 绝不落盘。finally 必清临时文件——它是明文 tar.gz,遗留会绕过加密边界。
|
|
2673
|
+
const encrypted = await isArchiveEncrypted(picked.name);
|
|
2674
|
+
let workName = picked.name;
|
|
2675
|
+
let cleanupArchive = null;
|
|
2676
|
+
if (encrypted) {
|
|
2677
|
+
const dec = await decryptArchiveFile(picked.name, opts?.password, signal);
|
|
2678
|
+
workName = dec.name;
|
|
2679
|
+
cleanupArchive = dec.cleanup;
|
|
2680
|
+
}
|
|
2681
|
+
try {
|
|
2682
|
+
return await restoreArchiveFromPlain(picked, workName, archive, dryRun, signal, opts, { home, dshHome, root });
|
|
2683
|
+
} finally {
|
|
2684
|
+
if (cleanupArchive) await cleanupArchive().catch(() => { /* 清理失败尽力而为 */ });
|
|
2685
|
+
}
|
|
2686
|
+
}
|
|
2687
|
+
|
|
2688
|
+
async function restoreArchiveFromPlain(picked, workName, archive, dryRun, signal, opts, env) {
|
|
2689
|
+
const { home, dshHome, root } = env;
|
|
1940
2690
|
const base = dshHome.split('/').pop();
|
|
1941
2691
|
const parent = dshHome.slice(0, -(base.length + 1)) || '/';
|
|
1942
|
-
const entries = await safeArchiveEntries(
|
|
2692
|
+
const entries = await safeArchiveEntries(workName, signal);
|
|
1943
2693
|
|
|
1944
2694
|
// 预检(dry-run 与真实恢复共用):跨机 home 对比 + 脱敏清单 + profile 依赖。
|
|
1945
2695
|
const { meta, redactedFiles } = await readArchiveMeta(picked.name);
|
|
@@ -2017,7 +2767,7 @@ export function apply(ctx, pluginConfig) {
|
|
|
2017
2767
|
}
|
|
2018
2768
|
try {
|
|
2019
2769
|
const tar = await ctx.subprocess.resolveExecutable('tar');
|
|
2020
|
-
await spawnRun([tar, '-xzf',
|
|
2770
|
+
await spawnRun([tar, '-xzf', workName, '-C', parentTar(parent), ...fileTargets], root, signal);
|
|
2021
2771
|
} catch (err) {
|
|
2022
2772
|
// 对齐 #28 / doctorRepair 回滚:从留档还原每个被覆盖项;连还原都失败
|
|
2023
2773
|
// 时把留档路径交代清楚,绝不静默停在半截状态。
|
|
@@ -2091,7 +2841,7 @@ export function apply(ctx, pluginConfig) {
|
|
|
2091
2841
|
}
|
|
2092
2842
|
try {
|
|
2093
2843
|
const tar = await ctx.subprocess.resolveExecutable('tar');
|
|
2094
|
-
await spawnRun([tar, '-xzf',
|
|
2844
|
+
await spawnRun([tar, '-xzf', workName, '-C', parentTar(parent)], root, signal);
|
|
2095
2845
|
} catch (err) {
|
|
2096
2846
|
// 挪旁之后解压失败会让 .dsh 处于空位——这是恢复流程里用户最容易慌
|
|
2097
2847
|
// 的时刻。尽力自动回滚:清掉半截解压产物、把原数据改回原名;连回滚
|
|
@@ -2241,63 +2991,1264 @@ export function apply(ctx, pluginConfig) {
|
|
|
2241
2991
|
return lines.join('\n');
|
|
2242
2992
|
}
|
|
2243
2993
|
|
|
2244
|
-
// ----------
|
|
2994
|
+
// ---------- 导出 / 导入(P2 宿主半边) ----------
|
|
2245
2995
|
//
|
|
2246
|
-
//
|
|
2247
|
-
//
|
|
2248
|
-
//
|
|
2249
|
-
//
|
|
2250
|
-
//
|
|
2251
|
-
//
|
|
2252
|
-
//
|
|
2253
|
-
//
|
|
2254
|
-
//
|
|
2255
|
-
|
|
2256
|
-
//
|
|
2257
|
-
//
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
function sessionLogVersion(name) {
|
|
2264
|
-
const m = SESSION_LOG_RE.exec(name);
|
|
2265
|
-
return m ? Number(m[1] ?? 0) : null;
|
|
2996
|
+
// 导出:把 ~/.dsh 打包成单文件 ZIP(零依赖 STORE),节段化(settings/ui/providers/
|
|
2997
|
+
// plugins/mcp/skills/prompts/workspaces/sessions/credentials + other 兜底)。凭据节段
|
|
2998
|
+
// 默认脱敏(不进 ZIP),仅 includeCredentials + 口令加密才入包。可选加密复用
|
|
2999
|
+
// lib/encrypt.js(scrypt + AES-256-GCM),密文覆盖 ZIP 本体、参数进 `<zip>.encrypted.json`
|
|
3000
|
+
// 边车。导出 ZIP 落到备份目录 `dsh-config-<时间戳>.zip`,回执给绝对路径。
|
|
3001
|
+
//
|
|
3002
|
+
// 导入:ZIP 是不可信输入——listZip/extractZip 全套 Zip Slip / 符号链接 / zip bomb /
|
|
3003
|
+
// CRC 防线;先 importPreview(dry-run 计划:节段、目标冲突、跨机 remap 需求、
|
|
3004
|
+
// 兼容性评分),再 importApply(自动快照 → 应用 → 校验 → 失败回滚)。
|
|
3005
|
+
//
|
|
3006
|
+
// 节段归类:ZIP 条目名 = 相对 ~/.dsh 的 POSIX 路径;section = 按顶层名归类。
|
|
3007
|
+
// 这样既避免「sessions/sessions/…」双重前缀,又让导入目标路径 = dshHome + 条目名。
|
|
3008
|
+
|
|
3009
|
+
/** 按顶层条目名归类节段 key;未匹配 → other 兜底(绝不丢弃任何文件)。 */
|
|
3010
|
+
function sectionKeyForTop(top) {
|
|
3011
|
+
for (const s of EXPORT_SECTIONS) if (s.paths.includes(top)) return s.key;
|
|
3012
|
+
return EXPORT_OTHER_KEY;
|
|
2266
3013
|
}
|
|
2267
|
-
const SCAN_SKIP_DIRS = new Set(['node_modules', '.system', '.git', 'vault']);
|
|
2268
|
-
const SCAN_MAX_FILES = 4000;
|
|
2269
|
-
const SCAN_MAX_DEPTH = 8;
|
|
2270
|
-
// Node 内置 zstd API 要 v22.15 / v23.8 才进 node:zlib;更老运行时没有这个
|
|
2271
|
-
// 导出。必须用命名空间导入 + 运行时探测——具名导入是静态校验的,在缺导出
|
|
2272
|
-
// 的运行时上会让整个插件模块加载失败,比少一个体检功能严重得多。
|
|
2273
|
-
const HAS_NODE_ZSTD = typeof zlib.zstdDecompressSync === 'function';
|
|
2274
3014
|
|
|
2275
|
-
/**
|
|
2276
|
-
function
|
|
2277
|
-
|
|
3015
|
+
/** 节段 key → 人话标签(含 other 兜底)。 */
|
|
3016
|
+
function sectionLabel(key) {
|
|
3017
|
+
const hit = EXPORT_SECTIONS.find((s) => s.key === key);
|
|
3018
|
+
return hit ? hit.label : (key === EXPORT_OTHER_KEY ? '其他(未归类)' : key);
|
|
2278
3019
|
}
|
|
2279
3020
|
|
|
2280
3021
|
/**
|
|
2281
|
-
*
|
|
2282
|
-
*
|
|
2283
|
-
* skippable 帧直接判非法——宿主 scanZstdFrames 拒绝一切非数据帧魔数,
|
|
2284
|
-
* 这类文件宿主整体拒载,容忍它们会造成「doctor 报 OK、宿主编译系统读不了」。
|
|
3022
|
+
* 递归走查 ~/.dsh 收集可导出的普通文件。跳过符号链接/特殊文件/node_modules/
|
|
3023
|
+
* .git/.system(任意深度);凭据节段按 includeCredentials 决定入包或记入脱敏清单。
|
|
2285
3024
|
*/
|
|
2286
|
-
function
|
|
2287
|
-
const
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
if (
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
const
|
|
3025
|
+
async function walkExportFiles(dshHome, includeCredentials, signal) {
|
|
3026
|
+
const files = [];
|
|
3027
|
+
const redacted = [];
|
|
3028
|
+
const skipDirs = new Set(['node_modules', '.git', '.system']);
|
|
3029
|
+
async function walk(abs, rel) {
|
|
3030
|
+
if (signal?.aborted) throw new Error('操作已取消');
|
|
3031
|
+
if (files.length >= EXPORT_MAX_FILES) throw new Error(`导出文件数超过上限(${EXPORT_MAX_FILES}),请检查 ~/.dsh 是否有异常目录树`);
|
|
3032
|
+
let st;
|
|
3033
|
+
try { st = await lstat(abs); } catch { return; }
|
|
3034
|
+
if (st.isSymbolicLink()) return;
|
|
3035
|
+
if (st.isDirectory()) {
|
|
3036
|
+
if (rel && skipDirs.has(rel.split('/').pop())) return;
|
|
3037
|
+
let names;
|
|
3038
|
+
try { names = await readdir(abs); } catch { return; }
|
|
3039
|
+
for (const n of names) {
|
|
3040
|
+
if (n === '.DS_Store') continue;
|
|
3041
|
+
await walk(`${abs}/${n}`, rel ? `${rel}/${n}` : n);
|
|
3042
|
+
}
|
|
3043
|
+
return;
|
|
3044
|
+
}
|
|
3045
|
+
if (!st.isFile()) return;
|
|
3046
|
+
const section = sectionKeyForTop(rel.split('/')[0]);
|
|
3047
|
+
if (section === 'credentials' && !includeCredentials) { redacted.push(rel); return; }
|
|
3048
|
+
files.push({ rel, abs, bytes: st.size, section });
|
|
3049
|
+
}
|
|
3050
|
+
await walk(dshHome, '');
|
|
3051
|
+
return { files, redacted };
|
|
3052
|
+
}
|
|
3053
|
+
|
|
3054
|
+
/** 组装导出 manifest(JSON,作为 ZIP 首条目写入;导入读端据此识别包)。 */
|
|
3055
|
+
function buildManifest(home, encrypted, redacted, sectionStats) {
|
|
3056
|
+
const sections = [...sectionStats.entries()].map(([key, v]) => ({ key, label: sectionLabel(key), files: v.files, bytes: v.bytes }));
|
|
3057
|
+
return {
|
|
3058
|
+
format: EXPORT_FORMAT,
|
|
3059
|
+
version: EXPORT_FORMAT_VERSION,
|
|
3060
|
+
createdAt: new Date().toISOString(),
|
|
3061
|
+
source: { host: hostname(), home, platform: process.platform, pluginVersion: SELF_META.version },
|
|
3062
|
+
redacted,
|
|
3063
|
+
encrypted,
|
|
3064
|
+
sections,
|
|
3065
|
+
totalFiles: [...sectionStats.values()].reduce((s, v) => s + v.files, 0),
|
|
3066
|
+
totalBytes: [...sectionStats.values()].reduce((s, v) => s + v.bytes, 0),
|
|
3067
|
+
};
|
|
3068
|
+
}
|
|
3069
|
+
|
|
3070
|
+
/** 导出:把 ~/.dsh 打成单文件 ZIP(可选加密)。 */
|
|
3071
|
+
async function exportConfig(opts, signal) {
|
|
3072
|
+
const { home, dshHome, root } = paths();
|
|
3073
|
+
const includeCredentials = opts?.includeCredentials === true;
|
|
3074
|
+
const password = typeof opts?.password === 'string' && opts.password ? opts.password : null;
|
|
3075
|
+
// 凭据入包必须加密:明文凭据打成一个可随意拷贝的 ZIP 是自找泄密。
|
|
3076
|
+
if (includeCredentials && !password) {
|
|
3077
|
+
throw new Error('包含凭据的导出必须加密(口令只经面板 RPC 内存态传入,绝不落盘)——请勾选加密并输入口令');
|
|
3078
|
+
}
|
|
3079
|
+
const { files, redacted } = await walkExportFiles(dshHome, includeCredentials, signal);
|
|
3080
|
+
const sectionStats = new Map();
|
|
3081
|
+
for (const f of files) {
|
|
3082
|
+
const cur = sectionStats.get(f.section) || { files: 0, bytes: 0 };
|
|
3083
|
+
cur.files += 1; cur.bytes += f.bytes;
|
|
3084
|
+
sectionStats.set(f.section, cur);
|
|
3085
|
+
}
|
|
3086
|
+
// 本地 link:/file: 插件迁移:npm pack 成 tarball 打进导出,导入侧解包改 file:
|
|
3087
|
+
const localPlugins = await scanLocalPlugins(signal);
|
|
3088
|
+
const packedPlugins = localPlugins.filter((p) => p.status === 'packed');
|
|
3089
|
+
const encrypted = Boolean(password);
|
|
3090
|
+
const manifest = buildManifest(home, encrypted, redacted, sectionStats);
|
|
3091
|
+
manifest.localPlugins = localPlugins.map((p) => ({ profile: p.profile, name: p.name, spec: p.spec, status: p.status, tarball: p.tarballName ?? null, warning: p.warning ?? null }));
|
|
3092
|
+
const finalName = `dsh-config-${stampNow()}.zip`;
|
|
3093
|
+
const finalPath = `${root}/${finalName}`;
|
|
3094
|
+
const tmpPath = `${root}/.export-${stampNow()}.tmp.zip`;
|
|
3095
|
+
// 备份目录可能尚未创建(用户从没备份过就直接点导出):先 mkdir,避免
|
|
3096
|
+
// buildZip 的 createWriteStream 对不存在的 root 抛未捕获 ENOENT。
|
|
3097
|
+
await mkdir(root, { recursive: true });
|
|
3098
|
+
try {
|
|
3099
|
+
const entryList = [{ name: 'manifest.json', data: Buffer.from(JSON.stringify(manifest)) }];
|
|
3100
|
+
for (const f of files) entryList.push({ name: f.rel, sourcePath: f.abs });
|
|
3101
|
+
for (const p of packedPlugins) entryList.push({ name: `local-plugins/${p.tarballName}`, sourcePath: p.tarballPath });
|
|
3102
|
+
await buildZip(tmpPath, entryList, { signal });
|
|
3103
|
+
if (encrypted) {
|
|
3104
|
+
const plain = await readFile(tmpPath);
|
|
3105
|
+
const enc = await encryptBuffer(plain, password);
|
|
3106
|
+
await writeBinary(finalPath, enc.ciphertext);
|
|
3107
|
+
await writeOwned(`${finalPath}.encrypted.json`, `${JSON.stringify(enc.sidecar)}\n`);
|
|
3108
|
+
} else {
|
|
3109
|
+
await rename(tmpPath, finalPath);
|
|
3110
|
+
}
|
|
3111
|
+
} finally {
|
|
3112
|
+
await rm(tmpPath, { force: true }).catch(() => {});
|
|
3113
|
+
}
|
|
3114
|
+
const size = (await fsStat(finalPath)).size;
|
|
3115
|
+
const summary = `✅ 导出完成: ${finalName}(${encrypted ? '🔒 已加密' : '未加密'},${files.length} 个文件)\n位置: ${finalPath}${redacted.length ? `\n🔐 已脱敏 ${redacted.length} 个凭据文件(不在包里,跨机需重填)` : ''}`;
|
|
3116
|
+
return { ok: true, path: finalPath, name: finalName, size, encrypted, manifest, redactedFiles: redacted, summary };
|
|
3117
|
+
}
|
|
3118
|
+
|
|
3119
|
+
// ---------------- 配置市场(P4b-2) ----------------
|
|
3120
|
+
// 索引来自用户配置的 marketUrl,是**不可信输入**:只做 schema 校验与体积/条数
|
|
3121
|
+
// 上限,绝不执行 bundle 代码;断网/非法/超大一律优雅降级为空列表并如实说明。
|
|
3122
|
+
const MARKET_MAX_ENTRIES = 500;
|
|
3123
|
+
const MARKET_MAX_BYTES = 5 * 1024 * 1024;
|
|
3124
|
+
const MARKET_TIMEOUT_MS = 8000;
|
|
3125
|
+
|
|
3126
|
+
/** 把市场索引里的一条原始记录归一化;缺 id/name/url 或 URL 非 http(s) 的条目直接丢弃。 */
|
|
3127
|
+
function normalizeMarketEntry(raw) {
|
|
3128
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
3129
|
+
const id = typeof raw.id === 'string' ? raw.id.trim() : '';
|
|
3130
|
+
const name = typeof raw.name === 'string' ? raw.name.trim() : '';
|
|
3131
|
+
const url = typeof raw.url === 'string' ? raw.url.trim() : '';
|
|
3132
|
+
if (!id || !name || !url) return null;
|
|
3133
|
+
let parsed;
|
|
3134
|
+
try { parsed = new URL(url); } catch { return null; }
|
|
3135
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null;
|
|
3136
|
+
return {
|
|
3137
|
+
id,
|
|
3138
|
+
name,
|
|
3139
|
+
desc: typeof raw.desc === 'string' ? raw.desc.slice(0, 500) : '',
|
|
3140
|
+
sections: Array.isArray(raw.sections) ? raw.sections.filter((s) => typeof s === 'string').slice(0, 32) : [],
|
|
3141
|
+
url,
|
|
3142
|
+
source: typeof raw.source === 'string' ? raw.source.slice(0, 200) : '',
|
|
3143
|
+
risk: raw.risk === 'high' || raw.risk === 'medium' ? raw.risk : 'low',
|
|
3144
|
+
version: typeof raw.version === 'string' ? raw.version.slice(0, 64) : '',
|
|
3145
|
+
};
|
|
3146
|
+
}
|
|
3147
|
+
|
|
3148
|
+
/** 拉取并校验市场索引。返回 { enabled, entries, notice },永不抛网络错误。 */
|
|
3149
|
+
async function fetchMarketIndex(signal) {
|
|
3150
|
+
const cfg = resolvedConfig();
|
|
3151
|
+
const url = typeof cfg.marketUrl === 'string' ? cfg.marketUrl.trim() : '';
|
|
3152
|
+
if (!url) return { enabled: false, entries: [], notice: '未配置市场地址(设置 → 备份设置 → 市场地址)' };
|
|
3153
|
+
let parsed;
|
|
3154
|
+
try { parsed = new URL(url); } catch { return { enabled: false, entries: [], notice: '市场地址不是合法 URL' }; }
|
|
3155
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
3156
|
+
return { enabled: false, entries: [], notice: '市场地址必须是 http(s)' };
|
|
3157
|
+
}
|
|
3158
|
+
const ctrl = new AbortController();
|
|
3159
|
+
const timer = setTimeout(() => ctrl.abort(), MARKET_TIMEOUT_MS);
|
|
3160
|
+
const onAbort = () => ctrl.abort();
|
|
3161
|
+
signal?.addEventListener?.('abort', onAbort, { once: true });
|
|
3162
|
+
try {
|
|
3163
|
+
const res = await fetch(url, { signal: ctrl.signal, headers: { accept: 'application/json' } });
|
|
3164
|
+
if (!res.ok) return { enabled: true, entries: [], notice: `市场索引拉取失败:HTTP ${res.status}` };
|
|
3165
|
+
const len = Number(res.headers.get('content-length') || 0);
|
|
3166
|
+
if (len > MARKET_MAX_BYTES) return { enabled: true, entries: [], notice: '市场索引过大(>5MB),已拒绝' };
|
|
3167
|
+
const text = await res.text();
|
|
3168
|
+
if (text.length > MARKET_MAX_BYTES) return { enabled: true, entries: [], notice: '市场索引过大(>5MB),已拒绝' };
|
|
3169
|
+
let data;
|
|
3170
|
+
try { data = JSON.parse(text); } catch { return { enabled: true, entries: [], notice: '市场索引不是合法 JSON' }; }
|
|
3171
|
+
const list = Array.isArray(data) ? data : (Array.isArray(data?.entries) ? data.entries : null);
|
|
3172
|
+
if (!list) return { enabled: true, entries: [], notice: '市场索引格式不对(需要数组或 { entries: [...] })' };
|
|
3173
|
+
const entries = list.slice(0, MARKET_MAX_ENTRIES).map(normalizeMarketEntry).filter(Boolean);
|
|
3174
|
+
return { enabled: true, entries, notice: entries.length ? '' : '市场里没有有效条目' };
|
|
3175
|
+
} catch (err) {
|
|
3176
|
+
if (signal?.aborted) throw new Error('操作已取消');
|
|
3177
|
+
return { enabled: true, entries: [], notice: `市场索引拉取失败:${String(err && err.message ? err.message : err)}` };
|
|
3178
|
+
} finally {
|
|
3179
|
+
clearTimeout(timer);
|
|
3180
|
+
signal?.removeEventListener?.('abort', onAbort);
|
|
3181
|
+
}
|
|
3182
|
+
}
|
|
3183
|
+
|
|
3184
|
+
/** 市场列表(只读)。 */
|
|
3185
|
+
async function marketList(signal) {
|
|
3186
|
+
const { enabled, entries, notice } = await fetchMarketIndex(signal);
|
|
3187
|
+
let host = '市场';
|
|
3188
|
+
try { host = new URL(resolvedConfig().marketUrl).host; } catch { /* 保持默认 */ }
|
|
3189
|
+
const summary = enabled
|
|
3190
|
+
? (entries.length
|
|
3191
|
+
? `🛒 市场共 ${entries.length} 个配置(来源:${host})`
|
|
3192
|
+
: `🛒 市场暂无可用配置${notice ? `:${notice}` : ''}`)
|
|
3193
|
+
: `🛒 市场未启用${notice ? `:${notice}` : ''}`;
|
|
3194
|
+
return { ok: true, enabled, entries, summary };
|
|
3195
|
+
}
|
|
3196
|
+
|
|
3197
|
+
/** 市场搜索(只读,本地过滤 name/desc)。 */
|
|
3198
|
+
async function marketSearch(query, signal) {
|
|
3199
|
+
const r = await marketList(signal);
|
|
3200
|
+
const q = typeof query === 'string' ? query.trim().toLowerCase() : '';
|
|
3201
|
+
if (!q) return r;
|
|
3202
|
+
const entries = r.entries.filter((e) => `${e.name} ${e.desc}`.toLowerCase().includes(q));
|
|
3203
|
+
return { ...r, entries, summary: `🛒 搜索「${query.trim()}」:${entries.length} 个结果` };
|
|
3204
|
+
}
|
|
3205
|
+
|
|
3206
|
+
/**
|
|
3207
|
+
* 市场安装:下载条目 bundle ZIP → importPreview(只读计划)→ importApply
|
|
3208
|
+
* (自动快照 + 失败回滚);默认 dry-run,confirm:true 才真正写入。
|
|
3209
|
+
* 供应链提示始终随结果返回,绝不静默安装。
|
|
3210
|
+
*/
|
|
3211
|
+
async function marketInstall(opts, signal) {
|
|
3212
|
+
const id = typeof opts?.id === 'string' ? opts.id.trim() : '';
|
|
3213
|
+
if (!id) throw new Error('缺少市场条目 id');
|
|
3214
|
+
const { entries } = await fetchMarketIndex(signal);
|
|
3215
|
+
const entry = entries.find((e) => e.id === id);
|
|
3216
|
+
if (!entry) throw new Error(`市场里找不到条目:${id}`);
|
|
3217
|
+
const { root } = paths();
|
|
3218
|
+
await mkdir(root, { recursive: true });
|
|
3219
|
+
const tmp = `${root}/.market-${stampNow()}.zip`;
|
|
3220
|
+
const supplyChain = [];
|
|
3221
|
+
if (!entry.source) supplyChain.push('⚠️ 该条目未声明来源,无法确认发布者');
|
|
3222
|
+
if (entry.risk === 'high') supplyChain.push('⚠️ 该条目被标记为高风险配置');
|
|
3223
|
+
try {
|
|
3224
|
+
const host = new URL(entry.url).host;
|
|
3225
|
+
if (!/(^|\.)(github\.com|githubusercontent\.com|deepseek\.com)$/i.test(host)) {
|
|
3226
|
+
supplyChain.push(`⚠️ 下载来源为第三方域名:${host}`);
|
|
3227
|
+
}
|
|
3228
|
+
} catch { /* URL 已在归一化阶段过滤 */ }
|
|
3229
|
+
const ctrl = new AbortController();
|
|
3230
|
+
const timer = setTimeout(() => ctrl.abort(), MARKET_TIMEOUT_MS);
|
|
3231
|
+
const onAbort = () => ctrl.abort();
|
|
3232
|
+
signal?.addEventListener?.('abort', onAbort, { once: true });
|
|
3233
|
+
try {
|
|
3234
|
+
const res = await fetch(entry.url, { signal: ctrl.signal });
|
|
3235
|
+
if (!res.ok) throw new Error(`下载配置包失败:HTTP ${res.status}`);
|
|
3236
|
+
const len = Number(res.headers.get('content-length') || 0);
|
|
3237
|
+
if (len > MARKET_MAX_BYTES) throw new Error('配置包过大(>5MB),已拒绝');
|
|
3238
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
3239
|
+
if (buf.length > MARKET_MAX_BYTES) throw new Error('配置包过大(>5MB),已拒绝');
|
|
3240
|
+
await writeBinary(tmp, buf);
|
|
3241
|
+
const preview = await importPreview({ sourcePath: tmp }, signal);
|
|
3242
|
+
if (opts?.confirm !== true) {
|
|
3243
|
+
return {
|
|
3244
|
+
ok: true,
|
|
3245
|
+
dryRun: true,
|
|
3246
|
+
entry: { id: entry.id, name: entry.name },
|
|
3247
|
+
preview,
|
|
3248
|
+
supplyChain,
|
|
3249
|
+
summary: `🛒 市场预览:${entry.name}\n${preview.summary}${supplyChain.length ? `\n${supplyChain.join('\n')}` : ''}\n\n确认无误后再执行安装(confirm=true)。`,
|
|
3250
|
+
};
|
|
3251
|
+
}
|
|
3252
|
+
const applied = await importApply({ sourcePath: tmp, decisions: opts?.decisions || {}, sections: opts?.sections }, signal);
|
|
3253
|
+
return {
|
|
3254
|
+
ok: applied.ok !== false,
|
|
3255
|
+
dryRun: false,
|
|
3256
|
+
entry: { id: entry.id, name: entry.name },
|
|
3257
|
+
applied,
|
|
3258
|
+
supplyChain,
|
|
3259
|
+
summary: `🛒 市场安装:${entry.name}\n${applied.summary}${supplyChain.length ? `\n${supplyChain.join('\n')}` : ''}`,
|
|
3260
|
+
};
|
|
3261
|
+
} finally {
|
|
3262
|
+
clearTimeout(timer);
|
|
3263
|
+
signal?.removeEventListener?.('abort', onAbort);
|
|
3264
|
+
await rm(tmp, { force: true }).catch(() => {});
|
|
3265
|
+
}
|
|
3266
|
+
}
|
|
3267
|
+
|
|
3268
|
+
// ---------------- 外部 agent 导入(P4b-3) ----------------
|
|
3269
|
+
// 从本机已安装的其它 AI agent 读取「技能 + 全局指令」,翻译成标准 dsh-config
|
|
3270
|
+
// 包后走 importPreview/importApply(预览/冲突/快照/回滚)。MCP 服务器只检测
|
|
3271
|
+
// 并如实报告——DSH 的 MCP 载体是 cordis 配置,v1 不自动改写,避免装坏。
|
|
3272
|
+
const FOREIGN_SOURCES = Object.freeze([
|
|
3273
|
+
{ id: 'claude-code', label: 'Claude Code', skillsDir: '.claude/skills', instructions: ['.claude/CLAUDE.md'], mcpFiles: ['.claude/settings.json', '.claude.json'] },
|
|
3274
|
+
{ id: 'cursor', label: 'Cursor', skillsDir: null, instructions: ['.cursorrules'], mcpFiles: ['.cursor/mcp.json'] },
|
|
3275
|
+
{ id: 'codex', label: 'Codex', skillsDir: null, instructions: ['.codex/AGENTS.md'], mcpFiles: ['.codex/config.toml'] },
|
|
3276
|
+
]);
|
|
3277
|
+
const FOREIGN_MAX_FILES = 500;
|
|
3278
|
+
const FOREIGN_MAX_BYTES = 10 * 1024 * 1024;
|
|
3279
|
+
const FOREIGN_MAX_INSTRUCTION_BYTES = 200 * 1024;
|
|
3280
|
+
|
|
3281
|
+
/** 安全相对路径:拒绝空、绝对路径与 `..` 段。 */
|
|
3282
|
+
function safeForeignRel(rel) {
|
|
3283
|
+
return typeof rel === 'string' && rel !== '' && !rel.startsWith('/') && !rel.split('/').includes('..');
|
|
3284
|
+
}
|
|
3285
|
+
|
|
3286
|
+
async function collectForeignSkills(home, source, signal) {
|
|
3287
|
+
const out = [];
|
|
3288
|
+
if (!source.skillsDir) return out;
|
|
3289
|
+
let names;
|
|
3290
|
+
try { names = await readdir(`${home}/${source.skillsDir}`); } catch { return out; }
|
|
3291
|
+
const walkDir = async (abs, rel) => {
|
|
3292
|
+
let entries;
|
|
3293
|
+
try { entries = await readdir(abs); } catch { return; }
|
|
3294
|
+
for (const n of entries) {
|
|
3295
|
+
if (signal?.aborted) throw new Error('操作已取消');
|
|
3296
|
+
if (!safeForeignRel(n)) continue;
|
|
3297
|
+
const childAbs = `${abs}/${n}`;
|
|
3298
|
+
const childRel = `${rel}/${n}`;
|
|
3299
|
+
let st;
|
|
3300
|
+
try { st = await lstat(childAbs); } catch { continue; }
|
|
3301
|
+
if (st.isSymbolicLink()) continue;
|
|
3302
|
+
if (st.isDirectory()) { await walkDir(childAbs, childRel); continue; }
|
|
3303
|
+
if (!st.isFile()) continue;
|
|
3304
|
+
if (out.length >= FOREIGN_MAX_FILES) throw new Error(`外部技能文件数超过上限(${FOREIGN_MAX_FILES})`);
|
|
3305
|
+
out.push({ rel: childRel, abs: childAbs, bytes: st.size });
|
|
3306
|
+
}
|
|
3307
|
+
};
|
|
3308
|
+
for (const n of names) {
|
|
3309
|
+
if (!safeForeignRel(n)) continue;
|
|
3310
|
+
const abs = `${home}/${source.skillsDir}/${n}`;
|
|
3311
|
+
let st;
|
|
3312
|
+
try { st = await lstat(abs); } catch { continue; }
|
|
3313
|
+
if (!st.isDirectory()) continue;
|
|
3314
|
+
await walkDir(abs, `skills/${n}`);
|
|
3315
|
+
}
|
|
3316
|
+
return out;
|
|
3317
|
+
}
|
|
3318
|
+
|
|
3319
|
+
async function collectForeignInstructions(home, source) {
|
|
3320
|
+
for (const rel of source.instructions) {
|
|
3321
|
+
try {
|
|
3322
|
+
const st = await fsStat(`${home}/${rel}`);
|
|
3323
|
+
if (!st.isFile() || st.size > FOREIGN_MAX_INSTRUCTION_BYTES) continue;
|
|
3324
|
+
const content = await readFile(`${home}/${rel}`, 'utf8');
|
|
3325
|
+
return { rel, content };
|
|
3326
|
+
} catch { /* 试下一个 */ }
|
|
3327
|
+
}
|
|
3328
|
+
return null;
|
|
3329
|
+
}
|
|
3330
|
+
|
|
3331
|
+
async function detectForeignSource(home, source) {
|
|
3332
|
+
const files = await collectForeignSkills(home, source, null);
|
|
3333
|
+
const instructions = await collectForeignInstructions(home, source);
|
|
3334
|
+
const mcpServers = [];
|
|
3335
|
+
for (const rel of source.mcpFiles) {
|
|
3336
|
+
let text;
|
|
3337
|
+
try { text = await readFile(`${home}/${rel}`, 'utf8'); } catch { continue; }
|
|
3338
|
+
if (!rel.endsWith('.json')) { mcpServers.push(`${rel}(存在,未解析)`); continue; }
|
|
3339
|
+
try {
|
|
3340
|
+
const data = JSON.parse(text);
|
|
3341
|
+
const servers = data?.mcpServers && typeof data.mcpServers === 'object' ? Object.keys(data.mcpServers) : [];
|
|
3342
|
+
for (const s of servers) mcpServers.push(`${s}(来自 ${rel})`);
|
|
3343
|
+
} catch { mcpServers.push(`${rel}(存在,JSON 解析失败)`); }
|
|
3344
|
+
}
|
|
3345
|
+
return {
|
|
3346
|
+
id: source.id,
|
|
3347
|
+
label: source.label,
|
|
3348
|
+
detected: files.length > 0 || Boolean(instructions) || mcpServers.length > 0,
|
|
3349
|
+
files,
|
|
3350
|
+
instructions,
|
|
3351
|
+
mcpServers,
|
|
3352
|
+
};
|
|
3353
|
+
}
|
|
3354
|
+
|
|
3355
|
+
/** 外部 agent 列表(只读检测)。 */
|
|
3356
|
+
async function foreignSources() {
|
|
3357
|
+
const { home } = paths();
|
|
3358
|
+
const list = [];
|
|
3359
|
+
for (const source of FOREIGN_SOURCES) {
|
|
3360
|
+
const r = await detectForeignSource(home, source);
|
|
3361
|
+
list.push({
|
|
3362
|
+
id: r.id,
|
|
3363
|
+
label: r.label,
|
|
3364
|
+
detected: r.detected,
|
|
3365
|
+
skills: r.files.length,
|
|
3366
|
+
instructions: r.instructions ? r.instructions.rel : null,
|
|
3367
|
+
mcpServers: r.mcpServers,
|
|
3368
|
+
});
|
|
3369
|
+
}
|
|
3370
|
+
const found = list.filter((s) => s.detected).map((s) => s.label);
|
|
3371
|
+
return {
|
|
3372
|
+
ok: true,
|
|
3373
|
+
sources: list,
|
|
3374
|
+
summary: found.length ? `🧳 检测到 ${found.length} 个可导入来源:${found.join('、')}` : '🧳 没有检测到可导入的外部 agent 配置',
|
|
3375
|
+
};
|
|
3376
|
+
}
|
|
3377
|
+
|
|
3378
|
+
/** 把某来源翻译成标准 dsh-config 包(技能 + AGENTS.md),返回 { zipPath, report, cleanup }。 */
|
|
3379
|
+
async function buildForeignBundle(sourceId, signal) {
|
|
3380
|
+
const { home, root } = paths();
|
|
3381
|
+
const source = FOREIGN_SOURCES.find((s) => s.id === sourceId);
|
|
3382
|
+
if (!source) throw new Error(`未知来源:${sourceId}`);
|
|
3383
|
+
const r = await detectForeignSource(home, source);
|
|
3384
|
+
if (!r.detected) throw new Error(`本机没有检测到 ${source.label} 的配置`);
|
|
3385
|
+
let bytes = 0;
|
|
3386
|
+
for (const f of r.files) bytes += f.bytes;
|
|
3387
|
+
if (bytes > FOREIGN_MAX_BYTES) throw new Error(`外部配置体积超过上限(${(FOREIGN_MAX_BYTES / 1048576).toFixed(0)}MB)`);
|
|
3388
|
+
const instructionBytes = r.instructions ? Buffer.byteLength(r.instructions.content) : 0;
|
|
3389
|
+
const entries = r.files.map((f) => ({ name: f.rel, sourcePath: f.abs }));
|
|
3390
|
+
if (r.instructions) entries.unshift({ name: 'AGENTS.md', data: Buffer.from(r.instructions.content) });
|
|
3391
|
+
const manifest = {
|
|
3392
|
+
format: EXPORT_FORMAT,
|
|
3393
|
+
version: EXPORT_FORMAT_VERSION,
|
|
3394
|
+
createdAt: new Date().toISOString(),
|
|
3395
|
+
source: { host: r.label, platform: process.platform, pluginVersion: SELF_META.version, home: '~' },
|
|
3396
|
+
redacted: [],
|
|
3397
|
+
encrypted: false,
|
|
3398
|
+
sections: [
|
|
3399
|
+
...(r.instructions ? [{ key: 'other', label: '其他(未归类)', files: 1, bytes: instructionBytes }] : []),
|
|
3400
|
+
...(r.files.length ? [{ key: 'skills', label: '技能', files: r.files.length, bytes }] : []),
|
|
3401
|
+
],
|
|
3402
|
+
totalFiles: r.files.length + (r.instructions ? 1 : 0),
|
|
3403
|
+
totalBytes: bytes + instructionBytes,
|
|
3404
|
+
foreign: { source: r.id, label: r.label, mcpServers: r.mcpServers },
|
|
3405
|
+
};
|
|
3406
|
+
const zipPath = `${root}/.foreign-${stampNow()}.zip`;
|
|
3407
|
+
await mkdir(root, { recursive: true });
|
|
3408
|
+
try {
|
|
3409
|
+
await buildZip(zipPath, [{ name: 'manifest.json', data: Buffer.from(JSON.stringify(manifest)) }, ...entries], { signal });
|
|
3410
|
+
} catch (err) {
|
|
3411
|
+
await rm(zipPath, { force: true }).catch(() => {});
|
|
3412
|
+
throw err;
|
|
3413
|
+
}
|
|
3414
|
+
return {
|
|
3415
|
+
zipPath,
|
|
3416
|
+
report: {
|
|
3417
|
+
source: r.id,
|
|
3418
|
+
label: r.label,
|
|
3419
|
+
skills: r.files.length,
|
|
3420
|
+
instructions: r.instructions ? r.instructions.rel : null,
|
|
3421
|
+
mcpServers: r.mcpServers,
|
|
3422
|
+
totalFiles: manifest.totalFiles,
|
|
3423
|
+
totalBytes: manifest.totalBytes,
|
|
3424
|
+
},
|
|
3425
|
+
cleanup: () => rm(zipPath, { force: true }),
|
|
3426
|
+
};
|
|
3427
|
+
}
|
|
3428
|
+
|
|
3429
|
+
/** 外部 agent 预览(只读计划,不写 ~/.dsh)。 */
|
|
3430
|
+
async function foreignPreview(options, signal) {
|
|
3431
|
+
const sourceId = typeof options?.source === 'string' ? options.source : '';
|
|
3432
|
+
if (!sourceId) throw new Error('缺少来源(source)');
|
|
3433
|
+
const bundle = await buildForeignBundle(sourceId, signal);
|
|
3434
|
+
try {
|
|
3435
|
+
const preview = await importPreview({ sourcePath: bundle.zipPath }, signal);
|
|
3436
|
+
const mcp = bundle.report.mcpServers.length
|
|
3437
|
+
? `\n\n检测到 ${bundle.report.mcpServers.length} 个 MCP 服务器(v1 不自动导入,请手动配置):\n${bundle.report.mcpServers.map((m) => ` · ${m}`).join('\n')}`
|
|
3438
|
+
: '';
|
|
3439
|
+
return { ok: true, report: bundle.report, preview, summary: `🧳 ${bundle.report.label} 预览\n${preview.summary}${mcp}` };
|
|
3440
|
+
} finally {
|
|
3441
|
+
await bundle.cleanup().catch(() => {});
|
|
3442
|
+
}
|
|
3443
|
+
}
|
|
3444
|
+
|
|
3445
|
+
/** 外部 agent 导入:默认 dry-run;confirm:true 才真正写入(自动快照 + 回滚)。 */
|
|
3446
|
+
async function foreignImport(options, signal) {
|
|
3447
|
+
const sourceId = typeof options?.source === 'string' ? options.source : '';
|
|
3448
|
+
if (!sourceId) throw new Error('缺少来源(source)');
|
|
3449
|
+
const bundle = await buildForeignBundle(sourceId, signal);
|
|
3450
|
+
try {
|
|
3451
|
+
if (options?.confirm !== true) {
|
|
3452
|
+
const preview = await importPreview({ sourcePath: bundle.zipPath }, signal);
|
|
3453
|
+
return { ok: true, dryRun: true, report: bundle.report, preview, summary: `🧳 ${bundle.report.label} 预览(未写入)\n${preview.summary}` };
|
|
3454
|
+
}
|
|
3455
|
+
const applied = await importApply({ sourcePath: bundle.zipPath, decisions: options?.decisions || {} }, signal);
|
|
3456
|
+
return { ok: applied.ok !== false, dryRun: false, report: bundle.report, applied, summary: `🧳 ${bundle.report.label} 导入\n${applied.summary}` };
|
|
3457
|
+
} finally {
|
|
3458
|
+
await bundle.cleanup().catch(() => {});
|
|
3459
|
+
}
|
|
3460
|
+
}
|
|
3461
|
+
|
|
3462
|
+
// ---------------- 会话 Rewind(P4b-3b) ----------------
|
|
3463
|
+
// 把会话「分叉」到指定轮次:原会话一个字节都不改,新会话写到同一项目目录下
|
|
3464
|
+
// 的新会话目录,header.id 换成新 id、parentSession 指向原会话。v1 只复制会话
|
|
3465
|
+
// 日志(不做工作区文件回滚、不复制附件),预览里如实标注。
|
|
3466
|
+
const REWIND_MAX_SESSIONS = 50;
|
|
3467
|
+
|
|
3468
|
+
/** 与宿主 session-persistence-jsonl 的 encodeSegment 逐字符一致(见其源码注释)。 */
|
|
3469
|
+
function encodeSessionSegment(raw) {
|
|
3470
|
+
if (typeof raw !== 'string' || raw.length === 0) throw new Error('会话 id 不能为空');
|
|
3471
|
+
if (raw === '.') return '~002E';
|
|
3472
|
+
if (raw === '..') return '~002E~002E';
|
|
3473
|
+
let out = '';
|
|
3474
|
+
for (let i = 0; i < raw.length; i++) {
|
|
3475
|
+
const code = raw.charCodeAt(i);
|
|
3476
|
+
const ch = String.fromCharCode(code);
|
|
3477
|
+
if (ch !== '~' && /^[A-Za-z0-9._-]$/.test(ch)) out += ch;
|
|
3478
|
+
else out += '~' + code.toString(16).toUpperCase().padStart(4, '0');
|
|
3479
|
+
}
|
|
3480
|
+
return out;
|
|
3481
|
+
}
|
|
3482
|
+
|
|
3483
|
+
/** 只解首帧拿 header(列表用,避免整读大日志)。 */
|
|
3484
|
+
function readSessionHeader(buf) {
|
|
3485
|
+
const frames = walkZstdFrames(buf);
|
|
3486
|
+
if (!frames.length) throw new Error('空日志');
|
|
3487
|
+
const first = zlib.zstdDecompressSync(buf.subarray(frames[0][0], frames[0][1]));
|
|
3488
|
+
const line = first.toString('utf8').split('\n').find((l) => l.length > 0);
|
|
3489
|
+
if (!line) throw new Error('首帧没有 header 行');
|
|
3490
|
+
const header = JSON.parse(line);
|
|
3491
|
+
if (!header || header.type !== 'session' || typeof header.id !== 'string') throw new Error('首帧不是会话 header');
|
|
3492
|
+
return header;
|
|
3493
|
+
}
|
|
3494
|
+
|
|
3495
|
+
/** 一行记录的 seq 跨度 [start,end);无 seq 返回 null。 */
|
|
3496
|
+
function rowSeqSpan(rec) {
|
|
3497
|
+
if (!rec || typeof rec !== 'object') return null;
|
|
3498
|
+
if (rec.type === 'text-chunks' || rec.type === 'reasoning-chunks' || rec.type === 'tool-call-chunks') {
|
|
3499
|
+
const data = rec.data ?? {};
|
|
3500
|
+
const members = Array.isArray(data.texts) ? data.texts.length : Array.isArray(data.args) ? data.args.length : 0;
|
|
3501
|
+
if (Number.isSafeInteger(rec.seq0) && members > 0) return [rec.seq0, rec.seq0 + members];
|
|
3502
|
+
return null;
|
|
3503
|
+
}
|
|
3504
|
+
if (typeof rec.seq === 'number') return [rec.seq, rec.seq + 1];
|
|
3505
|
+
return null;
|
|
3506
|
+
}
|
|
3507
|
+
|
|
3508
|
+
/** 会话日志列表(只读;header + 文件名)。 */
|
|
3509
|
+
async function sessionRewindList(signal) {
|
|
3510
|
+
const { dshHome } = paths();
|
|
3511
|
+
if (!HAS_NODE_ZSTD) return { ok: true, sessions: [], summary: '🧭 运行时 Node 无内置 zstd,无法列出压缩会话日志' };
|
|
3512
|
+
const found = [];
|
|
3513
|
+
await collectSessionLogs(dshHome, '', 0, found);
|
|
3514
|
+
const out = [];
|
|
3515
|
+
for (const f of found.slice(0, REWIND_MAX_SESSIONS)) {
|
|
3516
|
+
if (signal?.aborted) throw new Error('操作已取消');
|
|
3517
|
+
if (!f.abs.endsWith('.zstd')) continue;
|
|
3518
|
+
try {
|
|
3519
|
+
const header = readSessionHeader(await readFile(f.abs));
|
|
3520
|
+
out.push({ id: header.id, rel: f.rel, file: f.rel.split('/').pop(), cwd: header.cwd ?? null, version: Number(header.version) || 0 });
|
|
3521
|
+
} catch { /* 损坏/无法解析的日志跳过(doctor 负责体检) */ }
|
|
3522
|
+
}
|
|
3523
|
+
out.sort((a, b) => a.id.localeCompare(b.id));
|
|
3524
|
+
return { ok: true, sessions: out, summary: out.length ? `🧭 找到 ${out.length} 个可回退会话` : '🧭 没有找到可回退的会话日志' };
|
|
3525
|
+
}
|
|
3526
|
+
|
|
3527
|
+
/** 按 id 找会话日志:header.id 精确匹配,或目录名 = encodeSegment(id)。 */
|
|
3528
|
+
async function findSessionLog(sessionId, signal) {
|
|
3529
|
+
const { dshHome } = paths();
|
|
3530
|
+
if (!HAS_NODE_ZSTD) throw new Error('运行时 Node 无内置 zstd 支持,无法分叉压缩会话日志');
|
|
3531
|
+
const found = [];
|
|
3532
|
+
await collectSessionLogs(dshHome, '', 0, found);
|
|
3533
|
+
const wantEnc = encodeSessionSegment(sessionId);
|
|
3534
|
+
for (const f of found) {
|
|
3535
|
+
if (signal?.aborted) throw new Error('操作已取消');
|
|
3536
|
+
if (!f.abs.endsWith('.zstd')) continue;
|
|
3537
|
+
const parts = f.abs.split('/');
|
|
3538
|
+
const dirName = parts[parts.length - 2];
|
|
3539
|
+
let header = null;
|
|
3540
|
+
try { header = readSessionHeader(await readFile(f.abs)); } catch { /* 坏日志 */ }
|
|
3541
|
+
if ((header && header.id === sessionId) || dirName === wantEnc || dirName === sessionId) {
|
|
3542
|
+
return { ...f, header };
|
|
3543
|
+
}
|
|
3544
|
+
}
|
|
3545
|
+
return null;
|
|
3546
|
+
}
|
|
3547
|
+
|
|
3548
|
+
/** 计算分叉计划(只读)。seq 缺省 = 最后一个用户消息。 */
|
|
3549
|
+
async function planSessionRewind(sessionId, seqInput, signal) {
|
|
3550
|
+
const target = await findSessionLog(sessionId, signal);
|
|
3551
|
+
if (!target) throw new Error(`找不到会话:${sessionId}`);
|
|
3552
|
+
const { lines } = decodeZstdLog(await readFile(target.abs));
|
|
3553
|
+
if (!lines.length) throw new Error('会话日志为空');
|
|
3554
|
+
const header = JSON.parse(lines[0]);
|
|
3555
|
+
const rows = lines.slice(1);
|
|
3556
|
+
let targetSeq = Number.isInteger(seqInput) ? seqInput : null;
|
|
3557
|
+
if (targetSeq === null) {
|
|
3558
|
+
for (let i = rows.length - 1; i >= 0; i--) {
|
|
3559
|
+
let rec;
|
|
3560
|
+
try { rec = JSON.parse(rows[i]); } catch { continue; }
|
|
3561
|
+
if (rec && typeof rec.seq === 'number' && (rec.role === 'user' || rec.type === 'user/message')) {
|
|
3562
|
+
targetSeq = rec.seq;
|
|
3563
|
+
break;
|
|
3564
|
+
}
|
|
3565
|
+
}
|
|
3566
|
+
if (targetSeq === null) targetSeq = rows.length;
|
|
3567
|
+
}
|
|
3568
|
+
const kept = [];
|
|
3569
|
+
let dropped = 0;
|
|
3570
|
+
for (const line of rows) {
|
|
3571
|
+
let rec;
|
|
3572
|
+
try { rec = JSON.parse(line); } catch { throw new Error('会话日志包含非法 JSON 行,拒绝分叉'); }
|
|
3573
|
+
const span = rowSeqSpan(rec);
|
|
3574
|
+
if (span && span[1] > targetSeq) {
|
|
3575
|
+
dropped = rows.length - kept.length;
|
|
3576
|
+
break;
|
|
3577
|
+
}
|
|
3578
|
+
kept.push(line);
|
|
3579
|
+
}
|
|
3580
|
+
const keptLines = [lines[0], ...kept];
|
|
3581
|
+
const defect = validateSessionLines(keptLines);
|
|
3582
|
+
if (defect !== null) throw new Error(`分叉后的日志未通过宿主容器校验,已拒绝:${defect}`);
|
|
3583
|
+
const newId = `${header.id}-rewind-${stampNow()}`;
|
|
3584
|
+
return { target, header, rows, kept, dropped, targetSeq, newId, keptLines };
|
|
3585
|
+
}
|
|
3586
|
+
|
|
3587
|
+
/** 预览分叉计划(只读,不写盘)。 */
|
|
3588
|
+
async function sessionRewindPreview(sessionId, seq, signal) {
|
|
3589
|
+
const plan = await planSessionRewind(sessionId, seq, signal);
|
|
3590
|
+
const parts = plan.target.abs.split('/');
|
|
3591
|
+
const file = parts[parts.length - 1];
|
|
3592
|
+
const projectDir = parts.slice(0, -2).join('/');
|
|
3593
|
+
const newPath = `${projectDir}/${encodeSessionSegment(plan.newId)}/${file}`;
|
|
3594
|
+
return {
|
|
3595
|
+
ok: true,
|
|
3596
|
+
session: plan.header.id,
|
|
3597
|
+
targetSeq: plan.targetSeq,
|
|
3598
|
+
keepRecords: plan.kept.length,
|
|
3599
|
+
dropRecords: plan.dropped,
|
|
3600
|
+
newId: plan.newId,
|
|
3601
|
+
newPath,
|
|
3602
|
+
warnings: ['v1 只复制会话日志,不复制附件、不回滚工作区文件'],
|
|
3603
|
+
summary: `🧭 回退预览:${plan.header.id}\n保留 ${plan.kept.length} 条记录(回退到 seq < ${plan.targetSeq})\n原会话保持不变;新分叉:${plan.newId}\n⚠️ v1 只复制会话日志,不复制附件、不回滚工作区文件`,
|
|
3604
|
+
};
|
|
3605
|
+
}
|
|
3606
|
+
|
|
3607
|
+
/** 执行分叉(默认 dry-run;confirm:true 才写)。原会话永不修改。 */
|
|
3608
|
+
/**
|
|
3609
|
+
* 分叉日志的宿主不变量:header.isSeeded 必须与「会话内 inherited end-seed 标记」一致,
|
|
3610
|
+
* 否则宿主解码器 finish() 直接报错(seeded 缺标记 / unseeded 带标记),分叉根本打不开。
|
|
3611
|
+
* 这里统一修正:seeded 缺标记 → 降级为普通会话;unseeded 带标记 → 丢弃标记并重编号。
|
|
3612
|
+
*/
|
|
3613
|
+
const SURFACE_EVENT_TYPES = new Set(['system/message', 'developer/message', 'user/message', 'assistant/message', 'tool/result']);
|
|
3614
|
+
|
|
3615
|
+
function normalizeForkLog(header, rows) {
|
|
3616
|
+
// 宿主附加不变量:surface 事件必须带 surfaceOp,log-only 事件必须不带。
|
|
3617
|
+
const normalized = rows.map((row) => {
|
|
3618
|
+
const surface = SURFACE_EVENT_TYPES.has(row.type);
|
|
3619
|
+
if (surface && row.surfaceOp === undefined) return { ...row, surfaceOp: 'append' };
|
|
3620
|
+
if (!surface && row.surfaceOp !== undefined) {
|
|
3621
|
+
const next = { ...row };
|
|
3622
|
+
delete next.surfaceOp;
|
|
3623
|
+
return next;
|
|
3624
|
+
}
|
|
3625
|
+
return row;
|
|
3626
|
+
});
|
|
3627
|
+
const isMarker = (row) => row.type === 'session/end-seed' && row.data?.inherited === true;
|
|
3628
|
+
const markerIndex = normalized.findIndex(isMarker);
|
|
3629
|
+
if (header.isSeeded === true) {
|
|
3630
|
+
if (markerIndex >= 0) return { header, rows: normalized };
|
|
3631
|
+
return { header: { ...header, isSeeded: false }, rows: normalized };
|
|
3632
|
+
}
|
|
3633
|
+
if (markerIndex < 0) return { header, rows: normalized };
|
|
3634
|
+
const kept = normalized.filter((row) => !isMarker(row));
|
|
3635
|
+
const map = new Map();
|
|
3636
|
+
kept.forEach((row, index) => map.set(row.seq, index));
|
|
3637
|
+
const remap = (seq) => (map.has(seq) ? map.get(seq) : undefined);
|
|
3638
|
+
const out = kept.map((row) => {
|
|
3639
|
+
const next = { ...row, seq: map.get(row.seq) };
|
|
3640
|
+
if (Array.isArray(row.sourceEventSeqs)) {
|
|
3641
|
+
const mapped = row.sourceEventSeqs.map(remap).filter((value) => value !== undefined);
|
|
3642
|
+
if (mapped.length) next.sourceEventSeqs = mapped;
|
|
3643
|
+
else delete next.sourceEventSeqs;
|
|
3644
|
+
}
|
|
3645
|
+
if (row.surfaceOp && typeof row.surfaceOp === 'object') {
|
|
3646
|
+
const start = remap(row.surfaceOp.startSeq);
|
|
3647
|
+
const end = remap(row.surfaceOp.endSeq);
|
|
3648
|
+
next.surfaceOp = start === undefined || end === undefined ? 'append' : { op: 'replace', startSeq: start, endSeq: end };
|
|
3649
|
+
}
|
|
3650
|
+
return next;
|
|
3651
|
+
});
|
|
3652
|
+
return { header, rows: out };
|
|
3653
|
+
}
|
|
3654
|
+
|
|
3655
|
+
async function sessionRewindApply(opts, signal) {
|
|
3656
|
+
const sessionId = typeof opts?.session === 'string' ? opts.session : '';
|
|
3657
|
+
if (!sessionId) throw new Error('缺少会话 id');
|
|
3658
|
+
const seq = Number.isInteger(opts?.seq) ? opts.seq : undefined;
|
|
3659
|
+
if (opts?.confirm !== true) {
|
|
3660
|
+
const preview = await sessionRewindPreview(sessionId, seq, signal);
|
|
3661
|
+
return { ...preview, dryRun: true };
|
|
3662
|
+
}
|
|
3663
|
+
const plan = await planSessionRewind(sessionId, seq, signal);
|
|
3664
|
+
const parts = plan.target.abs.split('/');
|
|
3665
|
+
const file = parts[parts.length - 1];
|
|
3666
|
+
const projectDir = parts.slice(0, -2).join('/');
|
|
3667
|
+
const newDir = `${projectDir}/${encodeSessionSegment(plan.newId)}`;
|
|
3668
|
+
const newPath = `${newDir}/${file}`;
|
|
3669
|
+
if (pathExists(newDir)) throw new Error('目标分叉目录已存在,拒绝覆盖');
|
|
3670
|
+
const header = { ...plan.header, id: plan.newId, parentSession: plan.header.id };
|
|
3671
|
+
const normalized = normalizeForkLog(header, plan.kept.map((line) => JSON.parse(line)));
|
|
3672
|
+
const keptLines = normalized.rows.map((row) => JSON.stringify(row));
|
|
3673
|
+
const headerLine = JSON.stringify({ ...normalized.header, id: plan.newId, parentSession: plan.header.id });
|
|
3674
|
+
let buf;
|
|
3675
|
+
if (file.endsWith('.zstd')) {
|
|
3676
|
+
const frames = [zlib.zstdCompressSync(Buffer.from(headerLine + '\n'))];
|
|
3677
|
+
if (keptLines.length) frames.push(zlib.zstdCompressSync(Buffer.from(keptLines.join('\n') + '\n')));
|
|
3678
|
+
buf = Buffer.concat(frames);
|
|
3679
|
+
} else {
|
|
3680
|
+
buf = Buffer.from([headerLine, ...keptLines].join('\n') + '\n');
|
|
3681
|
+
}
|
|
3682
|
+
await mkdir(newDir, { recursive: true });
|
|
3683
|
+
try {
|
|
3684
|
+
await writeFile(newPath, buf);
|
|
3685
|
+
// 写完立刻用同一套宿主容器校验回读,坏包不留
|
|
3686
|
+
const back = decodeZstdLog(await readFile(newPath));
|
|
3687
|
+
const defect = validateSessionLines(back.lines);
|
|
3688
|
+
if (defect !== null) throw new Error(`写出的分叉未通过校验:${defect}`);
|
|
3689
|
+
} catch (err) {
|
|
3690
|
+
await rm(newDir, { recursive: true, force: true }).catch(() => {});
|
|
3691
|
+
throw err;
|
|
3692
|
+
}
|
|
3693
|
+
return {
|
|
3694
|
+
ok: true,
|
|
3695
|
+
dryRun: false,
|
|
3696
|
+
session: plan.header.id,
|
|
3697
|
+
newId: plan.newId,
|
|
3698
|
+
newPath,
|
|
3699
|
+
keepRecords: plan.kept.length,
|
|
3700
|
+
summary: `🧭 已分叉会话:${plan.header.id} → ${plan.newId}\n保留 ${plan.kept.length} 条记录;原会话未改动。\n重启 dsh 后可在会话列表看到新分支。`,
|
|
3701
|
+
};
|
|
3702
|
+
}
|
|
3703
|
+
|
|
3704
|
+
// ---------------- 外部聊天记录导入 v1(Claude Code) ----------------
|
|
3705
|
+
// 把 Claude Code 的会话 JSONL 翻译成 DSH v4 会话日志(新建会话、绝不改原数据)。
|
|
3706
|
+
// 只映射「用户/助手文本 + 工具调用/结果」;图片/文件块与私有事件跳过并计数。
|
|
3707
|
+
// 行格式对齐宿主:{type,seq,time,data} + 消息事件带 surfaceOp:'append'。
|
|
3708
|
+
const CLAUDE_PROJECTS_DIR = '.claude/projects';
|
|
3709
|
+
const CHAT_IMPORT_MAX_MESSAGES = 5000;
|
|
3710
|
+
|
|
3711
|
+
function claudeSessionId(file, slug) {
|
|
3712
|
+
const tail = String(file).replace(/[^A-Za-z0-9]/g, '').slice(-12);
|
|
3713
|
+
const head = String(slug || 'session').replace(/[^A-Za-z0-9]/g, '').slice(-16);
|
|
3714
|
+
return `claude-${head || 'session'}-${tail || '0'}`;
|
|
3715
|
+
}
|
|
3716
|
+
|
|
3717
|
+
async function listClaudeSessions() {
|
|
3718
|
+
const { home } = paths();
|
|
3719
|
+
const root = `${home}/${CLAUDE_PROJECTS_DIR}`;
|
|
3720
|
+
let projects;
|
|
3721
|
+
try { projects = await readdir(root, { withFileTypes: true }); } catch { return []; }
|
|
3722
|
+
const out = [];
|
|
3723
|
+
for (const proj of projects) {
|
|
3724
|
+
if (!proj.isDirectory()) continue;
|
|
3725
|
+
let files;
|
|
3726
|
+
try { files = await readdir(`${root}/${proj.name}`); } catch { continue; }
|
|
3727
|
+
for (const f of files) {
|
|
3728
|
+
if (!f.endsWith('.jsonl')) continue;
|
|
3729
|
+
const abs = `${root}/${proj.name}/${f}`;
|
|
3730
|
+
let st;
|
|
3731
|
+
try { st = await fsStat(abs); } catch { continue; }
|
|
3732
|
+
out.push({ abs, rel: `${CLAUDE_PROJECTS_DIR}/${proj.name}/${f}`, slug: proj.name, file: f, id: claudeSessionId(f, proj.name), bytes: st.size, mtime: st.mtimeMs });
|
|
3733
|
+
}
|
|
3734
|
+
}
|
|
3735
|
+
return out;
|
|
3736
|
+
}
|
|
3737
|
+
|
|
3738
|
+
/** 解析 Claude Code JSONL → 归一化消息序列。 */
|
|
3739
|
+
async function parseClaudeSession(abs) {
|
|
3740
|
+
const text = await readFile(abs, 'utf8');
|
|
3741
|
+
const messages = [];
|
|
3742
|
+
let skipped = 0;
|
|
3743
|
+
for (const line of text.split('\n')) {
|
|
3744
|
+
if (!line.trim()) continue;
|
|
3745
|
+
if (messages.length >= CHAT_IMPORT_MAX_MESSAGES) break;
|
|
3746
|
+
let rec;
|
|
3747
|
+
try { rec = JSON.parse(line); } catch { skipped += 1; continue; }
|
|
3748
|
+
const type = rec.type || rec.message?.role;
|
|
3749
|
+
const msg = rec.message;
|
|
3750
|
+
if (!msg || (type !== 'user' && type !== 'assistant')) { skipped += 1; continue; }
|
|
3751
|
+
const blocks = Array.isArray(msg.content)
|
|
3752
|
+
? msg.content
|
|
3753
|
+
: (typeof msg.content === 'string' ? [{ type: 'text', text: msg.content }] : []);
|
|
3754
|
+
const time = rec.timestamp ? (Date.parse(rec.timestamp) || Date.now()) : Date.now();
|
|
3755
|
+
if (type === 'user') {
|
|
3756
|
+
const texts = [];
|
|
3757
|
+
const results = [];
|
|
3758
|
+
for (const b of blocks) {
|
|
3759
|
+
if (b && b.type === 'text' && typeof b.text === 'string') texts.push(b.text);
|
|
3760
|
+
else if (b && b.type === 'tool_result') {
|
|
3761
|
+
results.push({
|
|
3762
|
+
callId: String(b.tool_use_id || b.toolUseId || ''),
|
|
3763
|
+
text: typeof b.content === 'string' ? b.content : JSON.stringify(b.content ?? ''),
|
|
3764
|
+
isError: b.is_error === true,
|
|
3765
|
+
});
|
|
3766
|
+
} else skipped += 1;
|
|
3767
|
+
}
|
|
3768
|
+
if (texts.length) messages.push({ role: 'user', texts, reasoning: [], toolCalls: [], time });
|
|
3769
|
+
for (const r of results) messages.push({ role: 'tool', texts: [r.text], reasoning: [], toolCalls: [], callId: r.callId, isError: r.isError, time });
|
|
3770
|
+
} else {
|
|
3771
|
+
const texts = [];
|
|
3772
|
+
const reasoning = [];
|
|
3773
|
+
const toolCalls = [];
|
|
3774
|
+
for (const b of blocks) {
|
|
3775
|
+
if (b && b.type === 'text' && typeof b.text === 'string') texts.push(b.text);
|
|
3776
|
+
else if (b && b.type === 'thinking' && typeof b.thinking === 'string') reasoning.push(b.thinking);
|
|
3777
|
+
else if (b && b.type === 'tool_use') toolCalls.push({ id: String(b.id || `call-${toolCalls.length}`), name: String(b.name || 'tool'), input: b.input });
|
|
3778
|
+
else skipped += 1;
|
|
3779
|
+
}
|
|
3780
|
+
if (texts.length || reasoning.length || toolCalls.length) {
|
|
3781
|
+
messages.push({ role: 'assistant', texts, reasoning, toolCalls, time });
|
|
3782
|
+
}
|
|
3783
|
+
}
|
|
3784
|
+
}
|
|
3785
|
+
return { messages, skipped };
|
|
3786
|
+
}
|
|
3787
|
+
|
|
3788
|
+
/** 归一化消息 → DSH v4 事件行(seq 连续;消息事件带 surfaceOp)。 */
|
|
3789
|
+
function claudeMessagesToEvents(messages, baseTime) {
|
|
3790
|
+
const rows = [];
|
|
3791
|
+
let seq = 0;
|
|
3792
|
+
let turn = 0;
|
|
3793
|
+
const push = (type, data, surface = false) => {
|
|
3794
|
+
const row = { type, seq, time: baseTime + seq, data };
|
|
3795
|
+
if (surface) row.surfaceOp = 'append';
|
|
3796
|
+
rows.push(row);
|
|
3797
|
+
seq += 1;
|
|
3798
|
+
};
|
|
3799
|
+
for (const m of messages) {
|
|
3800
|
+
turn += 1;
|
|
3801
|
+
push('turn/start', { turn });
|
|
3802
|
+
push('step/start', { turn, step: 1 });
|
|
3803
|
+
if (m.role === 'user') {
|
|
3804
|
+
push('user/message', { id: `u${turn}`, role: 'user', content: m.texts.map((t) => ({ type: 'text', text: t })), source: { kind: 'user' } }, true);
|
|
3805
|
+
} else if (m.role === 'tool') {
|
|
3806
|
+
push('tool/result', {
|
|
3807
|
+
turn,
|
|
3808
|
+
step: 1,
|
|
3809
|
+
message: {
|
|
3810
|
+
id: `t${turn}`,
|
|
3811
|
+
role: 'tool',
|
|
3812
|
+
toolCallId: m.callId,
|
|
3813
|
+
content: [{ type: 'text', text: m.texts.join('') }],
|
|
3814
|
+
source: { kind: 'tool', callId: m.callId },
|
|
3815
|
+
...(m.isError ? { isError: true } : {}),
|
|
3816
|
+
},
|
|
3817
|
+
}, true);
|
|
3818
|
+
} else {
|
|
3819
|
+
const content = [];
|
|
3820
|
+
for (const r of m.reasoning) content.push({ type: 'reasoning', text: r });
|
|
3821
|
+
for (const t of m.texts) content.push({ type: 'text', text: t });
|
|
3822
|
+
for (const c of m.toolCalls) content.push({ type: 'tool-call', id: c.id, name: c.name, arguments: JSON.stringify(c.input ?? {}) });
|
|
3823
|
+
push('assistant/message', {
|
|
3824
|
+
turn,
|
|
3825
|
+
step: 1,
|
|
3826
|
+
message: { id: `a${turn}`, role: 'assistant', content, source: { kind: 'model', provider: 'claude-code', model: 'imported' } },
|
|
3827
|
+
stream: [],
|
|
3828
|
+
}, true);
|
|
3829
|
+
}
|
|
3830
|
+
push('step/end', { turn, step: 1 });
|
|
3831
|
+
push('turn/end', { turn, reason: { kind: 'completed' } });
|
|
3832
|
+
}
|
|
3833
|
+
return rows;
|
|
3834
|
+
}
|
|
3835
|
+
|
|
3836
|
+
/** 外部聊天来源(v1 只有 Claude Code)。 */
|
|
3837
|
+
async function chatSources() {
|
|
3838
|
+
const list = await listClaudeSessions();
|
|
3839
|
+
const sessions = [];
|
|
3840
|
+
for (const s of list.slice(0, 50)) {
|
|
3841
|
+
try {
|
|
3842
|
+
const parsed = await parseClaudeSession(s.abs);
|
|
3843
|
+
const firstUser = parsed.messages.find((m) => m.role === 'user');
|
|
3844
|
+
sessions.push({
|
|
3845
|
+
id: s.id,
|
|
3846
|
+
file: s.file,
|
|
3847
|
+
project: s.slug,
|
|
3848
|
+
messages: parsed.messages.length,
|
|
3849
|
+
title: firstUser ? String(firstUser.texts[0] || '').slice(0, 60) : '',
|
|
3850
|
+
});
|
|
3851
|
+
} catch { /* 单文件解析失败不影响其它会话 */ }
|
|
3852
|
+
}
|
|
3853
|
+
return {
|
|
3854
|
+
ok: true,
|
|
3855
|
+
source: 'claude-code',
|
|
3856
|
+
detected: list.length > 0,
|
|
3857
|
+
sessions,
|
|
3858
|
+
summary: list.length ? `💬 检测到 ${list.length} 个 Claude Code 会话` : '💬 没有检测到 Claude Code 会话(~/.claude/projects)',
|
|
3859
|
+
};
|
|
3860
|
+
}
|
|
3861
|
+
|
|
3862
|
+
async function buildClaudeImport(sessionId) {
|
|
3863
|
+
const list = await listClaudeSessions();
|
|
3864
|
+
const target = list.find((s) => s.id === sessionId || s.file === sessionId || s.abs === sessionId);
|
|
3865
|
+
if (!target) throw new Error(`找不到 Claude Code 会话:${sessionId}`);
|
|
3866
|
+
const parsed = await parseClaudeSession(target.abs);
|
|
3867
|
+
if (!parsed.messages.length) throw new Error('该会话没有可导入的文本消息');
|
|
3868
|
+
// 宿主要求 time 是安全整数:毫秒时间戳必须取整(mtimeMs 可能是小数)。
|
|
3869
|
+
const rows = claudeMessagesToEvents(parsed.messages, Math.floor(target.mtime || Date.now()));
|
|
3870
|
+
const id = `${target.id}-${stampNow()}`;
|
|
3871
|
+
const header = { type: 'session', version: 4, id, createdAt: Math.floor(target.mtime || Date.now()), isSeeded: false, delegationDepth: 0 };
|
|
3872
|
+
const lines = [JSON.stringify(header), ...rows.map((r) => JSON.stringify(r))];
|
|
3873
|
+
const defect = validateSessionLines(lines);
|
|
3874
|
+
if (defect !== null) throw new Error(`导入会话未通过宿主容器校验,已中止:${defect}`);
|
|
3875
|
+
return { target, parsed, rows, header, id, lines };
|
|
3876
|
+
}
|
|
3877
|
+
|
|
3878
|
+
/** 预览:只读,不写盘。 */
|
|
3879
|
+
async function chatPreview(opts, signal) {
|
|
3880
|
+
const sessionId = typeof opts?.session === 'string' ? opts.session : '';
|
|
3881
|
+
if (!sessionId) throw new Error('缺少会话 id');
|
|
3882
|
+
if (signal?.aborted) throw new Error('操作已取消');
|
|
3883
|
+
const bundle = await buildClaudeImport(sessionId);
|
|
3884
|
+
const userCount = bundle.parsed.messages.filter((m) => m.role === 'user').length;
|
|
3885
|
+
const asstCount = bundle.parsed.messages.filter((m) => m.role === 'assistant').length;
|
|
3886
|
+
const toolCount = bundle.parsed.messages.filter((m) => m.role === 'tool').length;
|
|
3887
|
+
return {
|
|
3888
|
+
ok: true,
|
|
3889
|
+
dryRun: true,
|
|
3890
|
+
source: 'claude-code',
|
|
3891
|
+
session: bundle.target.id,
|
|
3892
|
+
file: bundle.target.file,
|
|
3893
|
+
messages: bundle.parsed.messages.length,
|
|
3894
|
+
userCount,
|
|
3895
|
+
asstCount,
|
|
3896
|
+
toolCount,
|
|
3897
|
+
skipped: bundle.parsed.skipped,
|
|
3898
|
+
events: bundle.rows.length,
|
|
3899
|
+
newId: bundle.id,
|
|
3900
|
+
warnings: ['仅导入文本与工具调用/结果;图片/文件块与私有事件已跳过', '导入为新的 DSH 会话,不改动原会话'],
|
|
3901
|
+
summary: `💬 Claude Code 导入预览\n${bundle.parsed.messages.length} 条消息(用户 ${userCount} / 助手 ${asstCount} / 工具结果 ${toolCount})→ ${bundle.rows.length} 个事件\n跳过 ${bundle.parsed.skipped} 个非文本块;导入为新会话,原会话不动`,
|
|
3902
|
+
};
|
|
3903
|
+
}
|
|
3904
|
+
|
|
3905
|
+
/** 执行导入:默认 dry-run;confirm:true 才写(新会话目录,绝不覆盖)。 */
|
|
3906
|
+
async function chatApply(opts, signal) {
|
|
3907
|
+
const sessionId = typeof opts?.session === 'string' ? opts.session : '';
|
|
3908
|
+
if (!sessionId) throw new Error('缺少会话 id');
|
|
3909
|
+
if (opts?.confirm !== true) return chatPreview(opts, signal);
|
|
3910
|
+
const bundle = await buildClaudeImport(sessionId);
|
|
3911
|
+
const { dshHome } = paths();
|
|
3912
|
+
const dir = `${dshHome}/sessions/_no-cwd/${encodeSessionSegment(bundle.id)}`;
|
|
3913
|
+
if (pathExists(dir)) throw new Error('目标会话目录已存在,拒绝覆盖');
|
|
3914
|
+
await mkdir(dir, { recursive: true });
|
|
3915
|
+
const headerFrame = zlib.zstdCompressSync(Buffer.from(`${JSON.stringify(bundle.header)}\n`));
|
|
3916
|
+
const bodyFrame = zlib.zstdCompressSync(Buffer.from(`${bundle.rows.map((r) => JSON.stringify(r)).join('\n')}\n`));
|
|
3917
|
+
const file = `${dir}/session.v4.jsonl.zstd`;
|
|
3918
|
+
try {
|
|
3919
|
+
await writeFile(file, Buffer.concat([headerFrame, bodyFrame]));
|
|
3920
|
+
// 回读校验:同一套容器契约(首帧恰好一行 header + seq 连续)
|
|
3921
|
+
const back = decodeZstdLog(await readFile(file));
|
|
3922
|
+
const defect = validateSessionLines(back.lines);
|
|
3923
|
+
if (defect !== null) throw new Error(defect);
|
|
3924
|
+
} catch (err) {
|
|
3925
|
+
await rm(dir, { recursive: true, force: true }).catch(() => {});
|
|
3926
|
+
throw new Error(`写出的会话未通过校验:${String(err && err.message ? err.message : err)}`);
|
|
3927
|
+
}
|
|
3928
|
+
return {
|
|
3929
|
+
ok: true,
|
|
3930
|
+
dryRun: false,
|
|
3931
|
+
newId: bundle.id,
|
|
3932
|
+
path: file,
|
|
3933
|
+
messages: bundle.parsed.messages.length,
|
|
3934
|
+
events: bundle.rows.length,
|
|
3935
|
+
summary: `💬 已导入 Claude Code 会话:${bundle.parsed.messages.length} 条消息(${bundle.rows.length} 个事件)\n新会话 id: ${bundle.id}\n重启 dsh 后可在会话列表看到(仅文本与工具调用/结果)`,
|
|
3936
|
+
};
|
|
3937
|
+
}
|
|
3938
|
+
|
|
3939
|
+
/**
|
|
3940
|
+
* 打开导出 ZIP(必要时用口令解密到备份目录临时文件)。返回 { zipPath, encrypted,
|
|
3941
|
+
* cleanup };调用方在 finally 里必调 cleanup(解密出的明文 ZIP 不能遗留)。
|
|
3942
|
+
*/
|
|
3943
|
+
async function openExportZip(sourcePath, password, signal) {
|
|
3944
|
+
const src = typeof sourcePath === 'string' ? sourcePath : '';
|
|
3945
|
+
if (!src || !src.endsWith('.zip')) throw new Error('导出文件路径无效(需指向 .zip 文件)');
|
|
3946
|
+
if (signal?.aborted) throw new Error('操作已取消');
|
|
3947
|
+
const sidecarPath = `${src}.encrypted.json`;
|
|
3948
|
+
const encrypted = await existsStat(sidecarPath);
|
|
3949
|
+
if (encrypted) {
|
|
3950
|
+
if (!password) throw new Error('这份导出已加密,需要口令才能读取(口令只经面板 RPC 内存态传入,绝不落盘)');
|
|
3951
|
+
const sidecar = JSON.parse(await readFile(sidecarPath, 'utf8'));
|
|
3952
|
+
const ciphertext = await readFile(src);
|
|
3953
|
+
const plain = await decryptBuffer(ciphertext, sidecar, password);
|
|
3954
|
+
const { root } = paths();
|
|
3955
|
+
const tmp = `${root}/.export-dec-${stampNow()}.zip`;
|
|
3956
|
+
await writeBinary(tmp, plain);
|
|
3957
|
+
return { zipPath: tmp, encrypted: true, cleanup: () => rm(tmp, { force: true }) };
|
|
3958
|
+
}
|
|
3959
|
+
return { zipPath: src, encrypted: false, cleanup: null };
|
|
3960
|
+
}
|
|
3961
|
+
|
|
3962
|
+
/** 读取并校验导出 manifest(未知格式/过新版本一律拒绝)。 */
|
|
3963
|
+
async function readExportManifest(zipPath) {
|
|
3964
|
+
let manifest;
|
|
3965
|
+
try {
|
|
3966
|
+
manifest = JSON.parse((await readZipEntry(zipPath, 'manifest.json')).toString('utf8'));
|
|
3967
|
+
} catch (err) {
|
|
3968
|
+
throw new Error(`不是有效的 dsh-config 导出包(缺少 manifest.json): ${String(err && err.message ? err.message : err)}`);
|
|
3969
|
+
}
|
|
3970
|
+
if (manifest?.format !== EXPORT_FORMAT) throw new Error('不是 dsh-config 导出包(manifest.format 不匹配)——请选择本插件导出的 ZIP');
|
|
3971
|
+
if (Number(manifest?.version) > EXPORT_FORMAT_VERSION) {
|
|
3972
|
+
throw new Error(`导出包版本过新(version=${manifest.version},本机支持 ${EXPORT_FORMAT_VERSION})——请升级插件后导入`);
|
|
3973
|
+
}
|
|
3974
|
+
return manifest;
|
|
3975
|
+
}
|
|
3976
|
+
|
|
3977
|
+
/** 兼容性评分:来源平台/家目录/插件版本 vs 本地,结构化给原因(只提示,不阻断)。 */
|
|
3978
|
+
function compatScore(manifest, home) {
|
|
3979
|
+
const reasons = [];
|
|
3980
|
+
let score = 100;
|
|
3981
|
+
const src = manifest?.source || {};
|
|
3982
|
+
if (src.platform && src.platform !== process.platform) {
|
|
3983
|
+
score -= 30;
|
|
3984
|
+
reasons.push('来源平台与本机不同:绝对路径、换行符与权限语义可能需要调整');
|
|
3985
|
+
}
|
|
3986
|
+
if (src.home && src.home !== home) {
|
|
3987
|
+
score -= 25;
|
|
3988
|
+
reasons.push('来源家目录与本机不同:settings 里的绝对路径需要 remap');
|
|
3989
|
+
}
|
|
3990
|
+
if (src.pluginVersion && SELF_META.version !== 'unknown' && semverGt(src.pluginVersion, SELF_META.version)) {
|
|
3991
|
+
score -= 15;
|
|
3992
|
+
reasons.push(`导出由更新版本插件生成(${src.pluginVersion}),本机 ${SELF_META.version},个别新字段可能被忽略`);
|
|
3993
|
+
}
|
|
3994
|
+
return { score: Math.max(0, score), reasons };
|
|
3995
|
+
}
|
|
3996
|
+
|
|
3997
|
+
/** 按节段聚合条目并探测目标冲突(只读,不写盘)。 */
|
|
3998
|
+
async function buildImportPlan(entries, dshHome) {
|
|
3999
|
+
const sectionStats = new Map();
|
|
4000
|
+
const conflicts = [];
|
|
4001
|
+
for (const e of entries) {
|
|
4002
|
+
if (e.isDirectory || e.name === 'manifest.json') continue;
|
|
4003
|
+
const section = sectionKeyForTop(e.name.split('/')[0]);
|
|
4004
|
+
const cur = sectionStats.get(section) || { files: 0, bytes: 0, conflictNames: [] };
|
|
4005
|
+
cur.files += 1; cur.bytes += e.usize;
|
|
4006
|
+
const targetAbs = `${dshHome}/${e.name}`;
|
|
4007
|
+
if (await existsStat(targetAbs)) {
|
|
4008
|
+
cur.conflictNames.push(e.name);
|
|
4009
|
+
conflicts.push({ name: e.name, rel: e.name, section, exists: true });
|
|
4010
|
+
}
|
|
4011
|
+
sectionStats.set(section, cur);
|
|
4012
|
+
}
|
|
4013
|
+
const sections = [...sectionStats.entries()].map(([key, v]) => ({
|
|
4014
|
+
key, label: sectionLabel(key), files: v.files, bytes: v.bytes,
|
|
4015
|
+
conflicts: v.conflictNames.map((n) => ({ name: n, rel: n, exists: true })),
|
|
4016
|
+
}));
|
|
4017
|
+
return { sections, conflicts };
|
|
4018
|
+
}
|
|
4019
|
+
|
|
4020
|
+
/** 跨机 remap 需求扫描:settings 里出现来源家目录绝对路径时提示(只读)。 */
|
|
4021
|
+
async function remapScan(zipPath, manifest, home) {
|
|
4022
|
+
const srcHome = manifest?.source?.home;
|
|
4023
|
+
if (!srcHome || srcHome === home) return [];
|
|
4024
|
+
const out = [];
|
|
4025
|
+
for (const rel of ['settings.yaml', 'settings.json']) {
|
|
4026
|
+
let text;
|
|
4027
|
+
try { text = (await readZipEntry(zipPath, rel)).toString('utf8'); } catch { continue; }
|
|
4028
|
+
let count = 0;
|
|
4029
|
+
let idx = text.indexOf(srcHome);
|
|
4030
|
+
const sample = new Set();
|
|
4031
|
+
while (idx >= 0 && count < 20) {
|
|
4032
|
+
count += 1;
|
|
4033
|
+
if (sample.size < 3) sample.add(text.slice(Math.max(0, idx - 20), idx + srcHome.length + 20));
|
|
4034
|
+
idx = text.indexOf(srcHome, idx + 1);
|
|
4035
|
+
}
|
|
4036
|
+
if (count) out.push({ section: 'settings', file: rel, count, sample: [...sample] });
|
|
4037
|
+
}
|
|
4038
|
+
return out;
|
|
4039
|
+
}
|
|
4040
|
+
|
|
4041
|
+
/** 导入预览(dry-run):返回计划(节段 / 冲突 / remap / 兼容性评分),不写任何文件。 */
|
|
4042
|
+
async function importPreview(opts, signal) {
|
|
4043
|
+
const { home, dshHome } = paths();
|
|
4044
|
+
const { zipPath, encrypted, cleanup } = await openExportZip(opts?.sourcePath, opts?.password, signal);
|
|
4045
|
+
try {
|
|
4046
|
+
const manifest = await readExportManifest(zipPath);
|
|
4047
|
+
const entries = await listZip(zipPath);
|
|
4048
|
+
const { sections, conflicts } = await buildImportPlan(entries, dshHome);
|
|
4049
|
+
const remap = await remapScan(zipPath, manifest, home);
|
|
4050
|
+
const compatibility = compatScore(manifest, home);
|
|
4051
|
+
const totalFiles = sections.reduce((s, x) => s + x.files, 0);
|
|
4052
|
+
const summary = `📦 导入预览 —— 只读,还没动磁盘\n来源: ${manifest.source?.host ?? '未知'}(${manifest.source?.platform ?? '未知平台'})\n将导入 ${totalFiles} 个文件、${sections.length} 个节段${conflicts.length ? `\n⚠️ 其中 ${conflicts.length} 个目标文件已存在(默认保留本机,可在界面逐项选择覆盖)` : ''}${remap.length ? '\n⚠️ 需要跨机绝对路径 remap(见 remap 列表)' : ''}\n兼容性: ${compatibility.score}/100${compatibility.reasons.length ? `(${compatibility.reasons.join(';')})` : ''}`;
|
|
4053
|
+
return { ok: true, encrypted, manifest, sections, totalFiles, totalBytes: manifest.totalBytes ?? 0, conflicts, remap, compatibility, summary };
|
|
4054
|
+
} finally {
|
|
4055
|
+
if (cleanup) await cleanup().catch(() => {});
|
|
4056
|
+
}
|
|
4057
|
+
}
|
|
4058
|
+
|
|
4059
|
+
/** 导入应用:自动快照 → 解压到暂存 → 按决策拷入 → 校验 → 失败回滚。 */
|
|
4060
|
+
async function importApply(opts, signal) {
|
|
4061
|
+
return withOperationLock('import', () => importApplyInner(opts, signal));
|
|
4062
|
+
}
|
|
4063
|
+
|
|
4064
|
+
async function importApplyInner(opts, signal) {
|
|
4065
|
+
const { dshHome, root } = paths();
|
|
4066
|
+
const { zipPath, encrypted, cleanup } = await openExportZip(opts?.sourcePath, opts?.password, signal);
|
|
4067
|
+
let staging = null;
|
|
4068
|
+
try {
|
|
4069
|
+
const manifest = await readExportManifest(zipPath);
|
|
4070
|
+
const entries = await listZip(zipPath);
|
|
4071
|
+
// 选节段:显式 opts.sections 则用;否则导入全部非凭据节段(凭据需显式点选,默认不碰)
|
|
4072
|
+
const wantedSections = Array.isArray(opts?.sections) && opts.sections.length ? new Set(opts.sections) : null;
|
|
4073
|
+
const decisions = opts?.decisions && typeof opts.decisions === 'object' ? opts.decisions : {};
|
|
4074
|
+
const fileEntries = entries.filter((e) => !e.isDirectory && e.name !== 'manifest.json');
|
|
4075
|
+
const selected = fileEntries.filter((e) => {
|
|
4076
|
+
const section = sectionKeyForTop(e.name.split('/')[0]);
|
|
4077
|
+
if (section === 'credentials') return wantedSections ? wantedSections.has('credentials') : false;
|
|
4078
|
+
return wantedSections ? wantedSections.has(section) : true;
|
|
4079
|
+
});
|
|
4080
|
+
if (!selected.length) throw new Error('没有可导入的内容(未选中任何节段,或所选节段在包里为空)');
|
|
4081
|
+
|
|
4082
|
+
// 决定写集:默认 keep(目标已存在就跳过,绝不静默覆盖)
|
|
4083
|
+
const willWrite = [];
|
|
4084
|
+
const willSkip = [];
|
|
4085
|
+
for (const e of selected) {
|
|
4086
|
+
const targetAbs = `${dshHome}/${e.name}`;
|
|
4087
|
+
const exists = await existsStat(targetAbs);
|
|
4088
|
+
if (exists && decisions[e.name] !== 'overwrite') { willSkip.push(e.name); continue; }
|
|
4089
|
+
willWrite.push({ ...e, targetAbs, conflict: exists });
|
|
4090
|
+
}
|
|
4091
|
+
if (!willWrite.length) {
|
|
4092
|
+
// 全部冲突都选「保留本机」且没有新增文件:这是合法的无操作成功,而非错误。
|
|
4093
|
+
// 明确告知没改任何文件,别让用户以为导入失败。
|
|
4094
|
+
return {
|
|
4095
|
+
ok: true,
|
|
4096
|
+
encrypted,
|
|
4097
|
+
applied: 0,
|
|
4098
|
+
skipped: willSkip.length,
|
|
4099
|
+
overwrote: 0,
|
|
4100
|
+
snapshot: null,
|
|
4101
|
+
remapped: 0,
|
|
4102
|
+
summary: `✅ 导入完成(无需写入)\n 所有 ${willSkip.length} 个选中条目都已存在于本机且选择「保留本机」,未改动任何文件。`,
|
|
4103
|
+
};
|
|
4104
|
+
}
|
|
4105
|
+
|
|
4106
|
+
// 自动快照当前数据(导入前安全网);失败不阻断,如实告知。
|
|
4107
|
+
let snapshotName = null;
|
|
4108
|
+
try {
|
|
4109
|
+
const snap = await doBackup(undefined, signal, { namePrefix: IMPORT_SNAPSHOT_PREFIX, skipVault: true, skipRotate: true });
|
|
4110
|
+
snapshotName = snap.name;
|
|
4111
|
+
await prunePrefixedSnapshots(IMPORT_SNAPSHOT_PREFIX, 1, snap.name);
|
|
4112
|
+
} catch { /* 快照失败不阻断导入 */ }
|
|
4113
|
+
|
|
4114
|
+
// 测试专用故障注入(test-only):DSH_BACKUP_IMPORT_FAIL=1 时,在「自动快照完成
|
|
4115
|
+
// 之后、真正写入之前」抛一次错,模拟导入中途失败——零依赖 smoke 据此断言失败
|
|
4116
|
+
// 路径与快照安全网(~/.dsh 未被改动)。生产默认绝不触发:环境变量未显式命中
|
|
4117
|
+
// '1' 时此分支永不进入。
|
|
4118
|
+
if (process.env.DSH_BACKUP_IMPORT_FAIL === '1') {
|
|
4119
|
+
throw new Error('test-only import fault injection (DSH_BACKUP_IMPORT_FAIL=1)');
|
|
4120
|
+
}
|
|
4121
|
+
|
|
4122
|
+
// 解压到暂存目录(extractZip 内 Zip Slip/符号链接/zip bomb/CRC 全套防线),再拷入 ~/.dsh
|
|
4123
|
+
staging = `${root}/.import-stage-${stampNow()}`;
|
|
4124
|
+
await extractZip(zipPath, staging, { names: new Set(willWrite.map((e) => e.name)), signal });
|
|
4125
|
+
const stamp = stampNow();
|
|
4126
|
+
const backups = [];
|
|
4127
|
+
const applied = [];
|
|
4128
|
+
const overwrote = [];
|
|
4129
|
+
try {
|
|
4130
|
+
for (const e of willWrite) {
|
|
4131
|
+
if (signal?.aborted) throw new Error('操作已取消');
|
|
4132
|
+
await mkdir(dirname(e.targetAbs), { recursive: true });
|
|
4133
|
+
if (e.conflict) {
|
|
4134
|
+
const keep = `${e.targetAbs}.pre-import-${stamp}`;
|
|
4135
|
+
await copyFile(e.targetAbs, keep);
|
|
4136
|
+
backups.push({ name: e.name, targetAbs: e.targetAbs, keep });
|
|
4137
|
+
}
|
|
4138
|
+
await copyFile(`${staging}/${e.name}`, e.targetAbs);
|
|
4139
|
+
applied.push(e.name);
|
|
4140
|
+
if (e.conflict) overwrote.push(e.name);
|
|
4141
|
+
}
|
|
4142
|
+
} catch (err) {
|
|
4143
|
+
// 回滚:覆盖的先还原留档,新增的删除;回滚再失败则给出快照/留档指引,绝不静默停在半截。
|
|
4144
|
+
const failed = [];
|
|
4145
|
+
for (const b of backups) {
|
|
4146
|
+
try { await copyFile(b.keep, b.targetAbs); } catch { failed.push(b.name); }
|
|
4147
|
+
}
|
|
4148
|
+
for (const n of applied) {
|
|
4149
|
+
if (!backups.some((b) => b.name === n)) await rm(`${dshHome}/${n}`, { force: true }).catch(() => {});
|
|
4150
|
+
}
|
|
4151
|
+
const snapNote = snapshotName ? `\n已自动快照: ${snapshotName},可 /backup restore ${snapshotName} 整体回滚。` : '';
|
|
4152
|
+
if (failed.length) {
|
|
4153
|
+
throw new Error(`导入失败并已尽力回滚:${friendlyReason(err)}。部分留档未还原: ${failed.join(', ')}(对应 *.pre-import-${stamp} 留档在原目录旁)。${snapNote}`);
|
|
4154
|
+
}
|
|
4155
|
+
throw new Error(`导入失败,已自动回滚到导入前状态:${friendlyReason(err)}。${snapNote}`);
|
|
4156
|
+
}
|
|
4157
|
+
|
|
4158
|
+
// 校验:写入的文件必须存在(防半截写入被误判成功)
|
|
4159
|
+
const missing = [];
|
|
4160
|
+
for (const e of willWrite) if (!(await existsStat(e.targetAbs))) missing.push(e.name);
|
|
4161
|
+
if (missing.length) throw new Error(`导入校验失败,部分文件未落盘: ${missing.slice(0, 5).join(', ')}(快照: ${snapshotName ?? '未生成'})`);
|
|
4162
|
+
|
|
4163
|
+
// 跨机 remap:对写入的文本配置做 from→to 前缀替换(settings / cordis)
|
|
4164
|
+
let remapped = 0;
|
|
4165
|
+
if (Array.isArray(opts?.remap) && opts.remap.length) {
|
|
4166
|
+
for (const m of opts.remap) {
|
|
4167
|
+
if (!m || typeof m.from !== 'string' || !m.from || typeof m.to !== 'string') continue;
|
|
4168
|
+
for (const rel of ['settings.yaml', 'settings.json', 'cordis.yml', 'cordis.patch.yml']) {
|
|
4169
|
+
if (!willWrite.some((e) => e.name === rel)) continue;
|
|
4170
|
+
const p = `${dshHome}/${rel}`;
|
|
4171
|
+
if (!(await existsStat(p))) continue;
|
|
4172
|
+
const text = await readFile(p, 'utf8');
|
|
4173
|
+
const next = text.split(m.from).join(m.to);
|
|
4174
|
+
if (next !== text) { await writeFile(p, next, 'utf8'); remapped += 1; }
|
|
4175
|
+
}
|
|
4176
|
+
}
|
|
4177
|
+
}
|
|
4178
|
+
|
|
4179
|
+
// 本地 link:/file: 插件迁移:解包 local-plugins tarball 到 ~/.dsh/local-plugins/ 并改回 file:
|
|
4180
|
+
const localPluginEntries = entries.filter((e) => !e.isDirectory && e.name.startsWith('local-plugins/'));
|
|
4181
|
+
let pluginsRestored = { restored: [], skipped: [] };
|
|
4182
|
+
if (localPluginEntries.length && Array.isArray(manifest?.localPlugins)) {
|
|
4183
|
+
await extractZip(zipPath, dshHome, { names: new Set(localPluginEntries.map((e) => e.name)), signal });
|
|
4184
|
+
pluginsRestored = await restoreLocalPlugins(manifest.localPlugins, signal);
|
|
4185
|
+
}
|
|
4186
|
+
|
|
4187
|
+
const summary = `✅ 导入完成\n 已写入: ${applied.length} 个文件(覆盖 ${overwrote.length}、新增 ${applied.length - overwrote.length}、跳过已存在 ${willSkip.length})${remapped ? `\n 已 remap: ${remapped} 个配置文件` : ''}${pluginsRestored.restored.length ? `\n 本地插件已装回 file:: ${pluginsRestored.restored.map((p) => p.name).join(', ')}` : ''}${pluginsRestored.skipped.length ? `\n ⚠️ 本地插件需手动处理: ${pluginsRestored.skipped.map((p) => `${p.name}(${p.reason})`).join(', ')}` : ''}${snapshotName ? `\n 导入前快照: ${snapshotName}` : '\n ⚠️ 导入前快照未生成(建议确认无误后手动备份一次)'}${backups.length ? `\n 覆盖的旧文件已留档 *.pre-import-${stamp}(确认后可删)` : ''}\n ⏸️ 待办: 重启 dsh 生效`;
|
|
4188
|
+
return { ok: true, encrypted, applied: applied.length, skipped: willSkip.length, overwrote: overwrote.length, snapshot: snapshotName, remapped, pluginsRestored, summary };
|
|
4189
|
+
} finally {
|
|
4190
|
+
if (staging) await rm(staging, { recursive: true, force: true }).catch(() => {});
|
|
4191
|
+
if (cleanup) await cleanup().catch(() => {});
|
|
4192
|
+
}
|
|
4193
|
+
}
|
|
4194
|
+
|
|
4195
|
+
// ---------- 会话日志体检(doctor) ----------
|
|
4196
|
+
//
|
|
4197
|
+
// 布局与格式依据宿主内置的 @deepseek-ai/dsh-session-persistence-jsonl:
|
|
4198
|
+
// <会话根>/--<项目目录>--/<编码后 id>/session.jsonl.zstd(compression:'none' 时为 .jsonl)
|
|
4199
|
+
// zstd 文件是独立帧的拼接:首帧是带校验和的 SessionHeader 行,其后每个追加批一帧。
|
|
4200
|
+
// 两条硬契约都对齐宿主读端:①行级——首行 SessionHeader、seq 连续;②容器级——
|
|
4201
|
+
// 首帧解出来恰好一行 header(宿主 assertZstdHeaderFrame;外部工具把整份日志
|
|
4202
|
+
// 压成单帧时内容完整可解、宿主却拒载,行级校验覆盖不到,必须单独判)。
|
|
4203
|
+
// Node 内置 zstd 解码器不跨帧续读,因此先按 RFC 8878 走帧字节边界
|
|
4204
|
+
// (魔数/帧头/块序列/校验和),再逐帧解出逻辑行。检测目标正是社区高频的
|
|
4205
|
+
// "corrupt session log"灾难:并发写 seq 撞号、坏帧、尾部截断、单帧重写。
|
|
4206
|
+
const ZSTD_MAGIC = 0xfd2fb528;
|
|
4207
|
+
// 会话日志文件名 = 宿主 sessionFormatLogFilename 的形态:session[.v<N>].jsonl[.zstd]
|
|
4208
|
+
// (N≥1 可多位;无 .vN 后缀即第 0 代)。**代数是文件名的一部分、会随格式换代前进**,
|
|
4209
|
+
// 写死具体名字会在换代当天整体失明(#117:v4 机器上 doctor/migrate 一份都看不见),
|
|
4210
|
+
// 故按形态匹配,让新代文件至少进入校验/预检流程(代的判定另见迁移预检)。
|
|
4211
|
+
const SESSION_LOG_RE = /^session(?:\.v([1-9][0-9]*))?\.jsonl(?:\.zstd)?$/;
|
|
4212
|
+
const isSessionLogName = (name) => SESSION_LOG_RE.test(name);
|
|
4213
|
+
/** 从会话日志文件名取格式代;非会话日志名返回 null(无 .vN 后缀即 v0)。 */
|
|
4214
|
+
function sessionLogVersion(name) {
|
|
4215
|
+
const m = SESSION_LOG_RE.exec(name);
|
|
4216
|
+
return m ? Number(m[1] ?? 0) : null;
|
|
4217
|
+
}
|
|
4218
|
+
const SCAN_SKIP_DIRS = new Set(['node_modules', '.system', '.git', 'vault']);
|
|
4219
|
+
const SCAN_MAX_FILES = 4000;
|
|
4220
|
+
const SCAN_MAX_DEPTH = 8;
|
|
4221
|
+
// Node 内置 zstd API 要 v22.15 / v23.8 才进 node:zlib;更老运行时没有这个
|
|
4222
|
+
// 导出。必须用命名空间导入 + 运行时探测——具名导入是静态校验的,在缺导出
|
|
4223
|
+
// 的运行时上会让整个插件模块加载失败,比少一个体检功能严重得多。
|
|
4224
|
+
const HAS_NODE_ZSTD = typeof zlib.zstdDecompressSync === 'function';
|
|
4225
|
+
|
|
4226
|
+
/** 截断超长内容用于缺陷描述。 */
|
|
4227
|
+
function snippet(text, max = 60) {
|
|
4228
|
+
return text.length > max ? `${text.slice(0, max)}…` : text;
|
|
4229
|
+
}
|
|
4230
|
+
|
|
4231
|
+
/**
|
|
4232
|
+
* 按 RFC 8878 结构走查拼接帧的字节边界(不解压):返回 [start,end] 区间数组;
|
|
4233
|
+
* 结构不合法(截断/未知魔数/保留位/越界块)抛错。
|
|
4234
|
+
* skippable 帧直接判非法——宿主 scanZstdFrames 拒绝一切非数据帧魔数,
|
|
4235
|
+
* 这类文件宿主整体拒载,容忍它们会造成「doctor 报 OK、宿主编译系统读不了」。
|
|
4236
|
+
*/
|
|
4237
|
+
function walkZstdFrames(buf) {
|
|
4238
|
+
const frames = [];
|
|
4239
|
+
let off = 0;
|
|
4240
|
+
while (off < buf.length) {
|
|
4241
|
+
if (buf.length - off < 4) throw new Error(`尾部截断(余 ${buf.length - off} 字节不成帧)`);
|
|
4242
|
+
const magic = buf.readUInt32LE(off);
|
|
4243
|
+
if (magic === ZSTD_MAGIC) {
|
|
4244
|
+
let pos = off + 4;
|
|
4245
|
+
if (pos >= buf.length) throw new Error('帧头描述符缺失');
|
|
4246
|
+
const desc = buf[pos++];
|
|
4247
|
+
if (desc & 0x08) throw new Error('帧头保留位置位');
|
|
4248
|
+
const singleSegment = Boolean(desc & 0x20);
|
|
4249
|
+
const checksum = Boolean(desc & 0x04);
|
|
4250
|
+
const dictSize = [0, 1, 2, 4][desc & 0x03];
|
|
4251
|
+
const fcsSize = singleSegment ? [1, 2, 4, 8][desc >> 6] : [0, 2, 4, 8][desc >> 6];
|
|
2301
4252
|
if (!singleSegment) pos += 1; // Window_Descriptor
|
|
2302
4253
|
pos += dictSize + fcsSize;
|
|
2303
4254
|
if (pos > buf.length) throw new Error('帧头越界');
|
|
@@ -2773,6 +4724,10 @@ export function apply(ctx, pluginConfig) {
|
|
|
2773
4724
|
* 归档里没有对应条目的文件列入 unrecoverable(提示改用全量恢复)。
|
|
2774
4725
|
*/
|
|
2775
4726
|
async function runDoctorRepair(selector, signal) {
|
|
4727
|
+
return withOperationLock('doctor-repair', () => runDoctorRepairInner(selector, signal));
|
|
4728
|
+
}
|
|
4729
|
+
|
|
4730
|
+
async function runDoctorRepairInner(selector, signal) {
|
|
2776
4731
|
const { home, dshHome, root } = paths();
|
|
2777
4732
|
const scan = await runDoctorScan(signal);
|
|
2778
4733
|
if (!scan.corruptCount) return { repaired: [], unrecoverable: [], stillBad: [], scanned: scan.scanned, summary: summarizeDoctorScan(scan) };
|
|
@@ -2780,6 +4735,12 @@ export function apply(ctx, pluginConfig) {
|
|
|
2780
4735
|
const picked = await pickArchive(selector || 'latest');
|
|
2781
4736
|
const v = await verifyOne(picked.name, home, signal);
|
|
2782
4737
|
if (!v.ok) throw new Error(`归档校验未通过(${v.note}),定点修复已中止`);
|
|
4738
|
+
// 加密归档的定点修复暂不支持:doctor 定点修复直接从归档解出单个会话文件,
|
|
4739
|
+
// 解密整包再解压的成本与泄密面都更高(明文临时 tar.gz)。这里给清晰指引而非
|
|
4740
|
+
// 让 tar 去啃密文报一堆英文。用户可在面板先恢复(支持口令解密),或用未加密归档。
|
|
4741
|
+
if (await isArchiveEncrypted(picked.name)) {
|
|
4742
|
+
throw new Error('定点修复暂不支持加密归档——请在设置面板用「恢复」先解密恢复,或选一份未加密归档(dsh- 开头且无 🔒)再修复');
|
|
4743
|
+
}
|
|
2783
4744
|
const base = dshHome.split('/').pop();
|
|
2784
4745
|
const parent = dshHome.slice(0, -(base.length + 1)) || '/';
|
|
2785
4746
|
const entries = await safeArchiveEntries(picked.name, signal);
|
|
@@ -2803,12 +4764,23 @@ export function apply(ctx, pluginConfig) {
|
|
|
2803
4764
|
kept.push(keep);
|
|
2804
4765
|
}
|
|
2805
4766
|
|
|
2806
|
-
//
|
|
2807
|
-
//
|
|
2808
|
-
//
|
|
4767
|
+
// #131(Windows):bsdtar 遇到「同名已存在对象」会拒绝覆盖(附件对象/硬链接
|
|
4768
|
+
// 目标与活树同名时刷 `Can't unlink already-existing object` 并整条非零退出)。
|
|
4769
|
+
// 改为「解到备份目录下的暂存区 → 只把目标文件 copyFile 到位」,与平台 tar 的
|
|
4770
|
+
// 覆盖语义解耦;同时 guard 住「targets 为空时会退化成整包解压」的危险路径。
|
|
4771
|
+
let stage = null;
|
|
2809
4772
|
try {
|
|
2810
|
-
|
|
2811
|
-
|
|
4773
|
+
if (targets.length) {
|
|
4774
|
+
stage = `${root}/.repair-stage-${stamp}`;
|
|
4775
|
+
await mkdir(stage, { recursive: true });
|
|
4776
|
+
const tar = await ctx.subprocess.resolveExecutable('tar');
|
|
4777
|
+
await spawnRun([tar, '-xzf', picked.name, '-C', stage, ...targets.map((t) => t.entry)], root, signal);
|
|
4778
|
+
for (const t of targets) {
|
|
4779
|
+
const dest = `${dshHome}/${t.rel}`;
|
|
4780
|
+
await mkdir(dirname(dest), { recursive: true });
|
|
4781
|
+
await copyFile(`${stage}/${t.entry}`, dest);
|
|
4782
|
+
}
|
|
4783
|
+
}
|
|
2812
4784
|
} catch (err) {
|
|
2813
4785
|
const restored = new Set();
|
|
2814
4786
|
for (let i = 0; i < targets.length; i++) {
|
|
@@ -2822,6 +4794,8 @@ export function apply(ctx, pluginConfig) {
|
|
|
2822
4794
|
}
|
|
2823
4795
|
const missing = targets.filter((t) => !restored.has(t.rel)).map((t) => t.rel);
|
|
2824
4796
|
throw new Error(`定点修复失败:${friendlyReason(err)},且部分现场未自动还原: ${missing.join(', ')}。对应的 *.corrupt-${stamp} 留档仍在原目录旁,手工拷回原位即可`);
|
|
4797
|
+
} finally {
|
|
4798
|
+
if (stage) await rm(stage, { recursive: true, force: true }).catch(() => {});
|
|
2825
4799
|
}
|
|
2826
4800
|
|
|
2827
4801
|
// 修完复检:从归档解出的副本必须健康,否则如实报告(skipped 不算失败——
|
|
@@ -2844,6 +4818,9 @@ export function apply(ctx, pluginConfig) {
|
|
|
2844
4818
|
// ---------- 自动备份与 GitHub 同步状态(落盘,重启续跑) ----------
|
|
2845
4819
|
let autoDispose = null;
|
|
2846
4820
|
let autoHours = 0;
|
|
4821
|
+
// 每周调度(P3):[{ day: 1-7 (1=周一..7=周日), time: 'HH:MM' }],可多个。
|
|
4822
|
+
// 与 autoHours 互斥:设置 weekly 时 autoHours 归 0(反之亦然)。空数组=未设置。
|
|
4823
|
+
let weeklySchedules = [];
|
|
2847
4824
|
let lastAuto = null;
|
|
2848
4825
|
let lastAutoAt = null;
|
|
2849
4826
|
let githubState = { repo: null, lastPush: null, lastError: null };
|
|
@@ -2875,6 +4852,41 @@ export function apply(ctx, pluginConfig) {
|
|
|
2875
4852
|
return next;
|
|
2876
4853
|
}
|
|
2877
4854
|
|
|
4855
|
+
/**
|
|
4856
|
+
* 旧默认目录 → 新默认目录的一次性改名(0.14.0)。
|
|
4857
|
+
*
|
|
4858
|
+
* 只处理「从未配置过 destination」的用户:≤0.13.4 的默认值
|
|
4859
|
+
* `~/Desktop/dsh-backups` 同盘 rename 成 `~/Desktop/DSH Backups`——原子、
|
|
4860
|
+
* 不拷贝数据、不丢备份;用户显式配置过的目录一律不碰。迁移失败时
|
|
4861
|
+
* paths() 的兜底逻辑会继续用老目录,功能不受影响。
|
|
4862
|
+
*/
|
|
4863
|
+
async function migrateLegacyDefaultDir() {
|
|
4864
|
+
const cfg = resolvedConfig();
|
|
4865
|
+
if (cfg.destination !== DEFAULTS.destination) return;
|
|
4866
|
+
const { home } = paths();
|
|
4867
|
+
const expandHome = (p) => (p.startsWith('~') ? `${home}${p.slice(1)}` : p);
|
|
4868
|
+
const target = expandHome(DEFAULTS.destination);
|
|
4869
|
+
if (pathExists(target)) {
|
|
4870
|
+
// 新旧目录同时存在(用户手动建过新目录,或迁移后手动放回了老目录):
|
|
4871
|
+
// 继续用新默认目录,老目录不自动合并——只提示一次,避免静默丢视角。
|
|
4872
|
+
if (LEGACY_DEFAULT_DESTINATIONS.some((p) => pathExists(expandHome(p)))) {
|
|
4873
|
+
console.warn(`[dsh-backup] 检测到旧备份目录与 ${DEFAULTS.destination} 同时存在,继续使用新目录;旧目录里的备份如需保留请手动移入`);
|
|
4874
|
+
}
|
|
4875
|
+
return;
|
|
4876
|
+
}
|
|
4877
|
+
for (const legacyRaw of LEGACY_DEFAULT_DESTINATIONS) {
|
|
4878
|
+
const legacy = expandHome(legacyRaw);
|
|
4879
|
+
if (!pathExists(legacy)) continue;
|
|
4880
|
+
try {
|
|
4881
|
+
await rename(legacy, target);
|
|
4882
|
+
console.log(`[dsh-backup] 备份目录已改名为 ${DEFAULTS.destination}(旧版默认名不再使用;可在设置面板自定义位置)`);
|
|
4883
|
+
} catch (err) {
|
|
4884
|
+
console.warn(`[dsh-backup] 备份目录改名失败,继续使用旧目录 ${legacyRaw}: ${String(err && err.message ? err.message : err)}`);
|
|
4885
|
+
}
|
|
4886
|
+
return;
|
|
4887
|
+
}
|
|
4888
|
+
}
|
|
4889
|
+
|
|
2878
4890
|
/**
|
|
2879
4891
|
* 等待 settings 用户层加载完成(describe 出现 revision)。启动钩子若在
|
|
2880
4892
|
* settings 装配完成前运行,resolvedConfig 会拿到 base 默认值——升级快照
|
|
@@ -2916,7 +4928,7 @@ export function apply(ctx, pluginConfig) {
|
|
|
2916
4928
|
// (先到者 rename 走,后到者 ENOENT),UUID 跨实例唯一
|
|
2917
4929
|
const tmp = `${target}.${randomUUID()}.tmp`;
|
|
2918
4930
|
try {
|
|
2919
|
-
await writeOwned(tmp, `${JSON.stringify({ hours: autoHours, lastAutoAt, github: githubState, lastTrain, starHinted, lastAutoAttemptAt, lastAutoError })}\n`);
|
|
4931
|
+
await writeOwned(tmp, `${JSON.stringify({ hours: autoHours, weekly: weeklySchedules, lastAutoAt, github: githubState, lastTrain, starHinted, lastAutoAttemptAt, lastAutoError })}\n`);
|
|
2920
4932
|
await rename(tmp, target);
|
|
2921
4933
|
} catch (err) {
|
|
2922
4934
|
await rm(tmp, { force: true }).catch(() => {});
|
|
@@ -2975,6 +4987,11 @@ export function apply(ctx, pluginConfig) {
|
|
|
2975
4987
|
lastAutoError = e && typeof e === 'object' && typeof e.message === 'string'
|
|
2976
4988
|
? { at: typeof e.at === 'string' ? e.at : '', message: e.message }
|
|
2977
4989
|
: null;
|
|
4990
|
+
// weekly(P3):数组形态,每项 { day: 1-7, time: 'HH:MM' };非法项过滤
|
|
4991
|
+
weeklySchedules = Array.isArray(parsed?.weekly)
|
|
4992
|
+
? parsed.weekly.filter((s) => s && typeof s === 'object' && Number.isInteger(Number(s.day)) && Number(s.day) >= 1 && Number(s.day) <= 7 && /^\d{2}:\d{2}$/.test(String(s.time || '')))
|
|
4993
|
+
.map((s) => ({ day: Number(s.day), time: String(s.time) }))
|
|
4994
|
+
: [];
|
|
2978
4995
|
// 旧版写的 auto.json 没有这些字段 → 全部按缺失处理,行为与旧版一致
|
|
2979
4996
|
await refreshNewestArchiveAt();
|
|
2980
4997
|
return Number.isFinite(h) && h >= 1 && h <= 720 ? Math.floor(h) : 0;
|
|
@@ -2994,10 +5011,37 @@ export function apply(ctx, pluginConfig) {
|
|
|
2994
5011
|
return Math.max(Number(new Date(lastAutoAt)) || 0, newestArchiveAt || 0);
|
|
2995
5012
|
}
|
|
2996
5013
|
|
|
2997
|
-
/**
|
|
5014
|
+
/** 调度是否开启:每小时或每周任一模式非空即开启。 */
|
|
5015
|
+
function autoEnabled() {
|
|
5016
|
+
return autoHours > 0 || weeklySchedules.length > 0;
|
|
5017
|
+
}
|
|
5018
|
+
|
|
5019
|
+
/** 下一个每周触发时刻(毫秒);无 weekly 返回 Infinity。 */
|
|
5020
|
+
function nextWeeklyOccurrence(now = Date.now()) {
|
|
5021
|
+
let best = Infinity;
|
|
5022
|
+
for (const s of weeklySchedules) {
|
|
5023
|
+
const hh = Number(String(s.time || '').slice(0, 2));
|
|
5024
|
+
const mm = Number(String(s.time || '').slice(3, 5));
|
|
5025
|
+
const day = Number(s.day);
|
|
5026
|
+
if (!Number.isFinite(hh) || !Number.isFinite(mm) || !Number.isInteger(day) || day < 1 || day > 7) continue;
|
|
5027
|
+
const dow = day === 7 ? 0 : day; // ISO(1=周一..7=周日) → JS getDay(0=周日..6=周六)
|
|
5028
|
+
const base = new Date(now);
|
|
5029
|
+
const addDays = (dow - base.getDay() + 7) % 7;
|
|
5030
|
+
const cand = new Date(base);
|
|
5031
|
+
cand.setDate(cand.getDate() + addDays);
|
|
5032
|
+
cand.setHours(hh, mm, 0, 0);
|
|
5033
|
+
if (cand.getTime() < now) cand.setDate(cand.getDate() + 7); // 本周该时刻已过 → 下周
|
|
5034
|
+
if (cand.getTime() < best) best = cand.getTime();
|
|
5035
|
+
}
|
|
5036
|
+
return best;
|
|
5037
|
+
}
|
|
5038
|
+
|
|
5039
|
+
/** 下次自动备份触发时间(毫秒):每小时与每周取更早者;无锚点从当前时间起算。 */
|
|
2998
5040
|
function nextAutoAt() {
|
|
2999
|
-
|
|
3000
|
-
|
|
5041
|
+
let at = Infinity;
|
|
5042
|
+
if (autoHours > 0) at = Math.max(autoAnchorAt() + autoHours * 3600 * 1000, Date.now());
|
|
5043
|
+
if (weeklySchedules.length) at = Math.min(at, nextWeeklyOccurrence());
|
|
5044
|
+
return at === Infinity ? Date.now() : at;
|
|
3001
5045
|
}
|
|
3002
5046
|
|
|
3003
5047
|
/** 本地时间显示:YYYY-MM-DD HH:mm——不依赖运行时 locale(裸跑环境会落 en-US)。 */
|
|
@@ -3015,82 +5059,427 @@ export function apply(ctx, pluginConfig) {
|
|
|
3015
5059
|
return `\n⚠️ 上次自动备份未完成${when ? `(${when})` : ''}:${msg}`;
|
|
3016
5060
|
}
|
|
3017
5061
|
|
|
3018
|
-
function autoSummary() {
|
|
3019
|
-
if (!autoDispose) return `自动备份未开启(/backup auto <N小时>
|
|
3020
|
-
// 绝对节奏:下次 = 锚点 + 周期;错过(重启间隔超周期)则按现在显示,调度会在启动宽限后补跑。
|
|
3021
|
-
|
|
5062
|
+
function autoSummary() {
|
|
5063
|
+
if (!autoDispose) return `自动备份未开启(/backup auto <N小时> 开启,或 /backup auto weekly <周几-时:分>)${autoErrorNote()}`;
|
|
5064
|
+
// 绝对节奏:下次 = 锚点 + 周期;错过(重启间隔超周期)则按现在显示,调度会在启动宽限后补跑。
|
|
5065
|
+
const mode = weeklySchedules.length
|
|
5066
|
+
? `每周 ${weeklySchedules.map((s) => `${s.day}-${s.time}`).join('、')}`
|
|
5067
|
+
: `每 ${autoHours} 小时一次`;
|
|
5068
|
+
return `自动备份已开启:${mode}(已持久化,重启续跑),下次 ${formatWhen(nextAutoAt())}${lastAuto ? `;上次自动备份: ${lastAuto}` : ''}${autoErrorNote()}`;
|
|
5069
|
+
}
|
|
5070
|
+
|
|
5071
|
+
/**
|
|
5072
|
+
* 链式 timeout 调度:每次触发后按锚点推算下一次,重启后节奏不重置。
|
|
5073
|
+
* #103 两条断路,缺一不可:
|
|
5074
|
+
* 1. boot 宽限——启动路径的首次触发绝不落在宿主 boot 窗口内(delay≤0 的补跑
|
|
5075
|
+
* 正是把 GB 级 tar 塞进 boot 窗口、拖垮 HostRpc 的元凶);
|
|
5076
|
+
* 2. 尝试冷却——上次**起跑**(不要求成功)之后 30 分钟(失败按 2^n 退避)内
|
|
5077
|
+
* 不再起跑。锚点落盘,所以"干到一半被强杀 → 下次启动又立刻重跑"的循环断开。
|
|
5078
|
+
*/
|
|
5079
|
+
function scheduleAuto(opts = {}) {
|
|
5080
|
+
// 关闭竞态保护:auto off 时 in-flight 备份完成后的续链会以 autoHours=0
|
|
5081
|
+
// 算出 delay=0,形成无限备份循环——关闭后绝不续链。
|
|
5082
|
+
if (!autoEnabled()) return;
|
|
5083
|
+
const cycle = autoHours > 0 ? autoHours * 3600 * 1000 : 7 * 24 * 3600 * 1000;
|
|
5084
|
+
let delay = nextAutoAt() - Date.now();
|
|
5085
|
+
const attemptAt = Number(new Date(lastAutoAttemptAt)) || 0;
|
|
5086
|
+
const cooldown = autoFailStreak > 0
|
|
5087
|
+
? Math.min(AUTO_RETRY_MS * 2 ** (autoFailStreak - 1), cycle)
|
|
5088
|
+
: AUTO_RETRY_MS;
|
|
5089
|
+
if (attemptAt) delay = Math.max(delay, attemptAt + cooldown - Date.now());
|
|
5090
|
+
if (opts.boot) delay = Math.max(delay, AUTO_BOOT_GRACE_MS);
|
|
5091
|
+
autoDispose = ctx.timeout(async () => {
|
|
5092
|
+
// 无人值守重活串行:与列车快照等其它无人值守任务排队,不并行抢机器
|
|
5093
|
+
await enqueueHeavy(runAutoBackup);
|
|
5094
|
+
scheduleAuto();
|
|
5095
|
+
}, delay);
|
|
5096
|
+
}
|
|
5097
|
+
|
|
5098
|
+
async function runAutoBackup() {
|
|
5099
|
+
// 起跑留痕(先写盘再干活):进程被强杀也不丢"刚试过"这个事实,
|
|
5100
|
+
// 下次启动据此冷却,不会在 boot 窗口里重跑(#103)。
|
|
5101
|
+
lastAutoAttemptAt = new Date().toISOString();
|
|
5102
|
+
await saveAutoState().catch(() => { /* 记录失败不阻断备份本身 */ });
|
|
5103
|
+
try {
|
|
5104
|
+
const r = await doBackup(autoKeep());
|
|
5105
|
+
lastAuto = r.path.split('/').pop();
|
|
5106
|
+
lastAutoAt = new Date().toISOString();
|
|
5107
|
+
lastAutoError = null;
|
|
5108
|
+
autoFailStreak = 0;
|
|
5109
|
+
newestArchiveAt = Date.now();
|
|
5110
|
+
await saveAutoState();
|
|
5111
|
+
console.log(`[dsh-backup] 自动备份完成: ${r.path} (sha ${r.sha.slice(0, 12)}…)`);
|
|
5112
|
+
} catch (err) {
|
|
5113
|
+
// 失败不再"只推进内存锚点"(旧实现不落盘 → 每次启动 delay≤0 重跑,
|
|
5114
|
+
// 3GB 归档必然失败时就是无限重跑;#103)。改记失败原因 + 退避重试,
|
|
5115
|
+
// 由 lastAutoAttemptAt(已落盘)负责跨重启冷却。
|
|
5116
|
+
autoFailStreak += 1;
|
|
5117
|
+
const message = String(err && err.message ? err.message : err);
|
|
5118
|
+
lastAutoError = { at: new Date().toISOString(), message };
|
|
5119
|
+
await saveAutoState().catch(() => { /* 失败路径写盘也可能失败,不影响内存退避 */ });
|
|
5120
|
+
console.error(`[dsh-backup] 自动备份失败(第 ${autoFailStreak} 次,${Math.round(Math.min(AUTO_RETRY_MS * 2 ** (autoFailStreak - 1), autoHours * 3600 * 1000) / 60000)} 分钟后重试): ${message}`);
|
|
5121
|
+
}
|
|
5122
|
+
}
|
|
5123
|
+
|
|
5124
|
+
async function setAuto(h) {
|
|
5125
|
+
if (autoDispose) { autoDispose(); autoDispose = null; }
|
|
5126
|
+
autoHours = h;
|
|
5127
|
+
weeklySchedules = []; // 每小时与每周互斥:设置每小时即清掉每周调度
|
|
5128
|
+
// 开启/重设 = 用户要一次基线备份:清掉旧锚点与失败退避,让下次触发落在"现在"
|
|
5129
|
+
lastAutoAt = null;
|
|
5130
|
+
lastAutoAttemptAt = null;
|
|
5131
|
+
newestArchiveAt = 0;
|
|
5132
|
+
autoFailStreak = 0;
|
|
5133
|
+
lastAutoError = null;
|
|
5134
|
+
if (autoEnabled()) scheduleAuto();
|
|
5135
|
+
await saveAutoState();
|
|
5136
|
+
}
|
|
5137
|
+
|
|
5138
|
+
/** 校验并归一化每周调度列表:[{ day: 1-7, time: 'HH:MM' }];非法返回原因。 */
|
|
5139
|
+
function normalizeWeekly(schedules) {
|
|
5140
|
+
if (schedules === undefined || schedules === null || schedules === '') return { value: [] };
|
|
5141
|
+
if (!Array.isArray(schedules)) return { error: 'weekly 需为 [{ day: 1-7, time: "HH:MM" }] 数组(1=周一..7=周日)' };
|
|
5142
|
+
const out = [];
|
|
5143
|
+
const seen = new Set();
|
|
5144
|
+
for (const s of schedules) {
|
|
5145
|
+
if (!s || typeof s !== 'object') return { error: 'weekly 每项需为 { day, time } 对象' };
|
|
5146
|
+
const day = Number(s.day);
|
|
5147
|
+
const time = String(s.time || '');
|
|
5148
|
+
if (!Number.isInteger(day) || day < 1 || day > 7) return { error: `weekly 的 day 需为 1-7(1=周一..7=周日),收到 ${s.day}` };
|
|
5149
|
+
if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(time)) return { error: `weekly 的 time 需为 HH:MM(00:00-23:59),收到 ${s.time}` };
|
|
5150
|
+
const key = `${day}-${time}`;
|
|
5151
|
+
if (seen.has(key)) continue; // 去重
|
|
5152
|
+
seen.add(key);
|
|
5153
|
+
out.push({ day, time });
|
|
5154
|
+
}
|
|
5155
|
+
return { value: out };
|
|
5156
|
+
}
|
|
5157
|
+
|
|
5158
|
+
/** 设置每周调度(可多个);空数组=关闭每周模式。与每小时互斥:设置后 autoHours 归 0。 */
|
|
5159
|
+
async function setWeekly(schedules) {
|
|
5160
|
+
const n = normalizeWeekly(schedules);
|
|
5161
|
+
if (n.error) return { ok: false, summary: n.error };
|
|
5162
|
+
if (autoDispose) { autoDispose(); autoDispose = null; }
|
|
5163
|
+
weeklySchedules = n.value;
|
|
5164
|
+
if (weeklySchedules.length) autoHours = 0;
|
|
5165
|
+
lastAutoAt = null;
|
|
5166
|
+
lastAutoAttemptAt = null;
|
|
5167
|
+
newestArchiveAt = 0;
|
|
5168
|
+
autoFailStreak = 0;
|
|
5169
|
+
lastAutoError = null;
|
|
5170
|
+
if (autoEnabled()) scheduleAuto();
|
|
5171
|
+
await saveAutoState();
|
|
5172
|
+
return { ok: true, weekly: weeklySchedules, summary: weeklySchedules.length ? `每周备份已开启:${weeklySchedules.map((s) => `${s.day}-${s.time}`).join('、')}\n${autoSummary()}` : '自动备份已关闭。' };
|
|
5173
|
+
}
|
|
5174
|
+
|
|
5175
|
+
// ---------- 磁盘占用报告 + 分级清理(P3) ----------
|
|
5176
|
+
// 报告归档/快照/同步副本/缓存/暂存各自占用;一键清理只删**可再生缓存**与
|
|
5177
|
+
// **过期备份**,快照与同步数据永不删(它们是不可再生的安全网)。
|
|
5178
|
+
|
|
5179
|
+
async function diskUsage() {
|
|
5180
|
+
const { root } = paths();
|
|
5181
|
+
const cat = { archives: 0, typed: 0, snapshots: 0, sync: 0, vault: 0, cache: 0, other: 0 };
|
|
5182
|
+
const files = { archives: 0, typed: 0, snapshots: 0, sync: 0, vault: 0, cache: 0, other: 0 };
|
|
5183
|
+
async function du(dir) {
|
|
5184
|
+
let total = 0;
|
|
5185
|
+
let count = 0;
|
|
5186
|
+
let dirents;
|
|
5187
|
+
try { dirents = await readdir(dir, { withFileTypes: true }); } catch { return { total, count }; }
|
|
5188
|
+
for (const d of dirents) {
|
|
5189
|
+
if (d.isDirectory()) {
|
|
5190
|
+
const sub = await du(`${dir}/${d.name}`);
|
|
5191
|
+
total += sub.total; count += sub.count;
|
|
5192
|
+
} else if (d.isFile()) {
|
|
5193
|
+
try { total += (await fsStat(`${dir}/${d.name}`)).size; } catch { /* 竞态忽略 */ }
|
|
5194
|
+
count += 1;
|
|
5195
|
+
}
|
|
5196
|
+
}
|
|
5197
|
+
return { total, count };
|
|
5198
|
+
}
|
|
5199
|
+
let dirents;
|
|
5200
|
+
try { dirents = await readdir(root, { withFileTypes: true }); } catch { dirents = []; }
|
|
5201
|
+
for (const d of dirents) {
|
|
5202
|
+
const name = d.name;
|
|
5203
|
+
const isFile = d.isFile();
|
|
5204
|
+
const size = isFile ? await fsStat(`${root}/${name}`).then((s) => s.size, () => 0) : 0;
|
|
5205
|
+
if (name === '.github-sync') { const s = await du(`${root}/${name}`); cat.sync += s.total; files.sync += s.count; }
|
|
5206
|
+
else if (name === 'vault') { const s = await du(`${root}/${name}`); cat.vault += s.total; files.vault += s.count; }
|
|
5207
|
+
else if (name.startsWith('.import-stage-') || name.startsWith('.export-') || name.startsWith('.decrypt-') || name.startsWith('.pull-')) { cat.cache += size; files.cache += 1; }
|
|
5208
|
+
else if (SNAPSHOT_PREFIXES.some((p) => name.startsWith(p))) { cat.snapshots += size; files.snapshots += 1; }
|
|
5209
|
+
else if (name.startsWith(TYPED_PREFIX) && name.endsWith('.tar.gz')) { cat.typed += size; files.typed += 1; }
|
|
5210
|
+
else if (name.startsWith('dsh-') && name.endsWith('.tar.gz')) { cat.archives += size; files.archives += 1; }
|
|
5211
|
+
else if (isFile) { cat.other += size; files.other += 1; }
|
|
5212
|
+
}
|
|
5213
|
+
const labels = { archives: '全量归档', typed: '分类型归档', snapshots: '内部快照', sync: '同步副本', vault: '凭据 vault', cache: '缓存/暂存', other: '其他' };
|
|
5214
|
+
const categories = Object.keys(cat).map((k) => ({ key: k, label: labels[k], bytes: cat[k], files: files[k] }));
|
|
5215
|
+
const total = categories.reduce((s, c) => s + c.bytes, 0);
|
|
5216
|
+
const mb = (b) => `${(b / 1048576).toFixed(1)}MB`;
|
|
5217
|
+
const lines = categories.filter((c) => c.bytes > 0).map((c) => ` ${c.label}: ${mb(c.bytes)}(${c.files} 个)`);
|
|
5218
|
+
return { total, totalText: mb(total), categories, summary: `磁盘占用(备份目录): 共 ${mb(total)}\n${lines.join('\n') || ' (空)'}` };
|
|
5219
|
+
}
|
|
5220
|
+
|
|
5221
|
+
/** 分级清理:'cache' 只删可再生缓存/暂存;'expired' 再删过期备份(快照/同步/vault 永不删)。 */
|
|
5222
|
+
async function cleanup(scope, signal) {
|
|
5223
|
+
const { root } = paths();
|
|
5224
|
+
const removed = [];
|
|
5225
|
+
const rmdir = (dir) => rm(`${root}/${dir}`, { recursive: true, force: true });
|
|
5226
|
+
// 可再生缓存/暂存:永远安全删除
|
|
5227
|
+
let dirents;
|
|
5228
|
+
try { dirents = await readdir(root, { withFileTypes: true }); } catch { dirents = []; }
|
|
5229
|
+
for (const d of dirents) {
|
|
5230
|
+
const n = d.name;
|
|
5231
|
+
if (n.startsWith('.import-stage-') || n.startsWith('.export-') || n.startsWith('.decrypt-') || n.startsWith('.pull-')) {
|
|
5232
|
+
await rm(`${root}/${n}`, { recursive: true, force: true });
|
|
5233
|
+
removed.push(n);
|
|
5234
|
+
}
|
|
5235
|
+
}
|
|
5236
|
+
if (scope === 'expired') {
|
|
5237
|
+
// 过期备份:keep 窗口外且不在日/周/月/年分层保护内的全量归档才删
|
|
5238
|
+
const keepN = defaultKeep();
|
|
5239
|
+
const all = await listBackups();
|
|
5240
|
+
const tail = all.slice(keepN);
|
|
5241
|
+
const cfg = resolvedConfig();
|
|
5242
|
+
const gfsProtect = gfsProtectedSet(tail, all[0]?.name, {
|
|
5243
|
+
monthly: Number(cfg.gfsMonthly) > 0 ? Math.floor(Number(cfg.gfsMonthly)) : 0,
|
|
5244
|
+
yearly: Number(cfg.gfsYearly) > 0 ? Math.floor(Number(cfg.gfsYearly)) : 0,
|
|
5245
|
+
});
|
|
5246
|
+
const tierProtect = tierProtectedSet(tail, all[0]?.name);
|
|
5247
|
+
const stale = tail.filter((b) => !tierProtect.has(b.name) && !gfsProtect.has(b.name)).map((b) => b.name);
|
|
5248
|
+
if (stale.length) {
|
|
5249
|
+
await removeFiles(stale.flatMap((n) => sidecarsFor(n)), root, signal);
|
|
5250
|
+
removed.push(...stale);
|
|
5251
|
+
}
|
|
5252
|
+
}
|
|
5253
|
+
return { ok: true, removed: removed.length, names: removed, summary: removed.length ? `已清理 ${removed.length} 项(快照/同步/vault 未动):\n${removed.slice(0, 10).map((n) => ` ${n}`).join('\n')}` : '没有可清理的内容(快照/同步/vault 永不删)。' };
|
|
5254
|
+
}
|
|
5255
|
+
|
|
5256
|
+
// ---------- 操作锁:状态 + 回收入口(P3) ----------
|
|
5257
|
+
async function lockStatus() {
|
|
5258
|
+
const { root } = paths();
|
|
5259
|
+
const lockPath = `${root}/${LOCK_FILE}`;
|
|
5260
|
+
try {
|
|
5261
|
+
const lock = JSON.parse(await readFile(lockPath, 'utf8'));
|
|
5262
|
+
return { held: true, stale: isLockStale(lock), pid: lock.pid, operation: lock.operation, startedAt: lock.startedAt, heartbeatAt: lock.heartbeatAt };
|
|
5263
|
+
} catch (err) {
|
|
5264
|
+
if (err && err.code === 'ENOENT') return { held: false, stale: false, pid: null, operation: null, startedAt: null, heartbeatAt: null };
|
|
5265
|
+
return { held: true, stale: false, pid: null, operation: 'unreadable', startedAt: null, heartbeatAt: null };
|
|
5266
|
+
}
|
|
5267
|
+
}
|
|
5268
|
+
|
|
5269
|
+
async function reclaimLockEntry(force) {
|
|
5270
|
+
const { root } = paths();
|
|
5271
|
+
const r = await reclaimOplock(`${root}/${LOCK_FILE}`, { force: force === true });
|
|
5272
|
+
return { ok: r.reclaimed, summary: r.reclaimed ? `操作锁已回收${r.heldBy ? `(原 pid=${r.heldBy.pid},operation=${r.heldBy.operation})` : ''}` : (r.reason || '回收失败'), heldBy: r.heldBy ?? null };
|
|
5273
|
+
}
|
|
5274
|
+
|
|
5275
|
+
// ---------- Profiles 管理(P4b-1:list/create/rename/delete/launch/stop) ----------
|
|
5276
|
+
// 「应用自己的 desktop profile」与面板所在的 web profile 都是宿主内置、只读:
|
|
5277
|
+
// 官方说明应用运行期间命令行改不了它——本插件对二者只标注 app-owned,拒绝任何写/启停。
|
|
5278
|
+
const APP_OWNED_PROFILES = new Set(['desktop', 'web']);
|
|
5279
|
+
/** 独立实例的运行态(内存态 Map:name → { child, pid, startedAt })。 */
|
|
5280
|
+
const runningProfiles = new Map();
|
|
5281
|
+
|
|
5282
|
+
function profilesDir() {
|
|
5283
|
+
return `${paths().dshHome}/profiles`;
|
|
5284
|
+
}
|
|
5285
|
+
|
|
5286
|
+
/** 校验 profile 名(拒绝空/路径段/非法字符/保留名)。 */
|
|
5287
|
+
function validateProfileName(name) {
|
|
5288
|
+
if (typeof name !== 'string' || !name.trim()) return 'profile 名不能为空';
|
|
5289
|
+
if (!/^[A-Za-z0-9._-]+$/.test(name)) return `profile 名只能含字母数字 . _ -(收到 ${name})`;
|
|
5290
|
+
if (APP_OWNED_PROFILES.has(name)) return `profile ${name} 是应用内置 profile,只读不可改`;
|
|
5291
|
+
return null;
|
|
5292
|
+
}
|
|
5293
|
+
|
|
5294
|
+
async function profilesList() {
|
|
5295
|
+
const dir = profilesDir();
|
|
5296
|
+
let dirents;
|
|
5297
|
+
try { dirents = await readdir(dir, { withFileTypes: true }); } catch { return []; }
|
|
5298
|
+
const out = [];
|
|
5299
|
+
for (const d of dirents) {
|
|
5300
|
+
if (!d.isDirectory()) continue;
|
|
5301
|
+
let meta = null;
|
|
5302
|
+
try { meta = JSON.parse(await readFile(`${dir}/${d.name}/package.json`, 'utf8')); } catch { meta = null; }
|
|
5303
|
+
const appOwned = APP_OWNED_PROFILES.has(d.name);
|
|
5304
|
+
out.push({
|
|
5305
|
+
name: d.name,
|
|
5306
|
+
version: meta?.version ?? null,
|
|
5307
|
+
appOwned,
|
|
5308
|
+
readonly: appOwned,
|
|
5309
|
+
running: runningProfiles.has(d.name),
|
|
5310
|
+
pid: runningProfiles.get(d.name)?.pid ?? null,
|
|
5311
|
+
});
|
|
5312
|
+
}
|
|
5313
|
+
return out.sort((a, b) => (a.name < b.name ? -1 : 1));
|
|
5314
|
+
}
|
|
5315
|
+
|
|
5316
|
+
async function profileCreate(name) {
|
|
5317
|
+
const err = validateProfileName(name);
|
|
5318
|
+
if (err) return { ok: false, summary: err };
|
|
5319
|
+
const dir = `${profilesDir()}/${name}`;
|
|
5320
|
+
if (await existsStat(dir)) return { ok: false, summary: `profile ${name} 已存在` };
|
|
5321
|
+
await mkdir(dir, { recursive: true });
|
|
5322
|
+
await writeOwned(`${dir}/package.json`, `${JSON.stringify({ name, version: '0.1.0', private: true })}\n`);
|
|
5323
|
+
await writeOwned(`${dir}/cordis.yml`, '# dsh-backup 从内置模板创建的空 profile\n# 在此写插件条目,然后 dsh web --profile <name> 启动\n');
|
|
5324
|
+
return { ok: true, name, summary: `已创建 profile: ${name}(模板:package.json + cordis.yml)` };
|
|
5325
|
+
}
|
|
5326
|
+
|
|
5327
|
+
async function profileRename(oldName, newName) {
|
|
5328
|
+
const errOld = validateProfileName(oldName);
|
|
5329
|
+
if (errOld) return { ok: false, summary: errOld };
|
|
5330
|
+
const errNew = validateProfileName(newName);
|
|
5331
|
+
if (errNew) return { ok: false, summary: errNew };
|
|
5332
|
+
const dir = profilesDir();
|
|
5333
|
+
if (!(await existsStat(`${dir}/${oldName}`))) return { ok: false, summary: `profile ${oldName} 不存在` };
|
|
5334
|
+
if (await existsStat(`${dir}/${newName}`)) return { ok: false, summary: `目标 ${newName} 已存在` };
|
|
5335
|
+
await rename(`${dir}/${oldName}`, `${dir}/${newName}`);
|
|
5336
|
+
return { ok: true, from: oldName, to: newName, summary: `已把 profile ${oldName} 改名为 ${newName}` };
|
|
5337
|
+
}
|
|
5338
|
+
|
|
5339
|
+
/** 硬删(二次确认语义由客户端/CLI --yes 承担;宿主绝不删内置 profile)。 */
|
|
5340
|
+
async function profileDelete(name) {
|
|
5341
|
+
const err = validateProfileName(name);
|
|
5342
|
+
if (err) return { ok: false, summary: err };
|
|
5343
|
+
const dir = `${profilesDir()}/${name}`;
|
|
5344
|
+
if (!(await existsStat(dir))) return { ok: false, summary: `profile ${name} 不存在` };
|
|
5345
|
+
if (runningProfiles.has(name)) return { ok: false, summary: `profile ${name} 有正在运行的独立实例,请先 stop 再删除` };
|
|
5346
|
+
await rm(dir, { recursive: true, force: true });
|
|
5347
|
+
return { ok: true, name, summary: `已硬删 profile: ${name}(不可恢复)` };
|
|
5348
|
+
}
|
|
5349
|
+
|
|
5350
|
+
/** 启动独立实例:detached + unref,绝不把宿主进程拖死;找不到 dsh 给人话。 */
|
|
5351
|
+
async function profileLaunch(name) {
|
|
5352
|
+
const err = validateProfileName(name);
|
|
5353
|
+
if (err) return { ok: false, summary: err };
|
|
5354
|
+
const dir = `${profilesDir()}/${name}`;
|
|
5355
|
+
if (!(await existsStat(dir))) return { ok: false, summary: `profile ${name} 不存在` };
|
|
5356
|
+
if (runningProfiles.has(name)) return { ok: true, pid: runningProfiles.get(name).pid, running: true, summary: `profile ${name} 已在运行(pid=${runningProfiles.get(name).pid})` };
|
|
5357
|
+
let dsh;
|
|
5358
|
+
try {
|
|
5359
|
+
dsh = await ctx.subprocess.resolveExecutable('dsh');
|
|
5360
|
+
} catch {
|
|
5361
|
+
return { ok: false, summary: '找不到 dsh 命令——请先安装 DeepSeek Harness CLI(npm i -g @deepseek-ai/dsh)或从官方入口启动' };
|
|
5362
|
+
}
|
|
5363
|
+
try {
|
|
5364
|
+
const child = spawn(dsh, ['web', '--profile', name], { detached: true, stdio: 'ignore', cwd: profilesDir() });
|
|
5365
|
+
child.unref();
|
|
5366
|
+
child.on('exit', () => { runningProfiles.delete(name); });
|
|
5367
|
+
runningProfiles.set(name, { child, pid: child.pid, startedAt: new Date().toISOString() });
|
|
5368
|
+
return { ok: true, pid: child.pid, running: true, summary: `已启动独立实例 ${name}(pid=${child.pid})` };
|
|
5369
|
+
} catch (err) {
|
|
5370
|
+
return { ok: false, summary: `启动失败: ${friendlyReason(err)}` };
|
|
5371
|
+
}
|
|
5372
|
+
}
|
|
5373
|
+
|
|
5374
|
+
/** 停止独立实例(运行态按钮翻转)。 */
|
|
5375
|
+
async function profileStop(name) {
|
|
5376
|
+
const r = runningProfiles.get(name);
|
|
5377
|
+
if (!r) return { ok: true, running: false, summary: `profile ${name} 没有正在运行的独立实例(或已退出)` };
|
|
5378
|
+
try { r.child.kill(); } catch { /* 可能已退出 */ }
|
|
5379
|
+
runningProfiles.delete(name);
|
|
5380
|
+
return { ok: true, running: false, summary: `已停止独立实例 ${name}(pid=${r.pid})` };
|
|
3022
5381
|
}
|
|
3023
5382
|
|
|
3024
|
-
|
|
3025
|
-
|
|
3026
|
-
|
|
3027
|
-
|
|
3028
|
-
|
|
3029
|
-
|
|
3030
|
-
|
|
3031
|
-
|
|
3032
|
-
|
|
3033
|
-
|
|
3034
|
-
|
|
3035
|
-
|
|
3036
|
-
|
|
3037
|
-
|
|
3038
|
-
const attemptAt = Number(new Date(lastAutoAttemptAt)) || 0;
|
|
3039
|
-
const cooldown = autoFailStreak > 0
|
|
3040
|
-
? Math.min(AUTO_RETRY_MS * 2 ** (autoFailStreak - 1), cycle)
|
|
3041
|
-
: AUTO_RETRY_MS;
|
|
3042
|
-
if (attemptAt) delay = Math.max(delay, attemptAt + cooldown - Date.now());
|
|
3043
|
-
if (opts.boot) delay = Math.max(delay, AUTO_BOOT_GRACE_MS);
|
|
3044
|
-
autoDispose = ctx.timeout(async () => {
|
|
3045
|
-
// 无人值守重活串行:与列车快照等其它无人值守任务排队,不并行抢机器
|
|
3046
|
-
await enqueueHeavy(runAutoBackup);
|
|
3047
|
-
scheduleAuto();
|
|
3048
|
-
}, delay);
|
|
5383
|
+
// ---------- 本地 link:/file: 插件迁移(P4b-1) ----------
|
|
5384
|
+
/** 单个插件打包上限:>100MB 跳过并警告(避免导出包被巨型 node_modules 撑爆)。 */
|
|
5385
|
+
const PLUGIN_PACK_MAX_BYTES = 100 * 1024 * 1024;
|
|
5386
|
+
|
|
5387
|
+
/** 递归计算目录大小(用于 100MB 门槛)。 */
|
|
5388
|
+
async function dirSize(dir) {
|
|
5389
|
+
let total = 0;
|
|
5390
|
+
let dirents;
|
|
5391
|
+
try { dirents = await readdir(dir, { withFileTypes: true }); } catch { return 0; }
|
|
5392
|
+
for (const d of dirents) {
|
|
5393
|
+
if (d.isDirectory()) total += await dirSize(`${dir}/${d.name}`);
|
|
5394
|
+
else if (d.isFile()) total += await fsStat(`${dir}/${d.name}`).then((s) => s.size, () => 0);
|
|
5395
|
+
}
|
|
5396
|
+
return total;
|
|
3049
5397
|
}
|
|
3050
5398
|
|
|
3051
|
-
|
|
3052
|
-
|
|
3053
|
-
|
|
3054
|
-
|
|
3055
|
-
|
|
3056
|
-
|
|
3057
|
-
|
|
3058
|
-
|
|
3059
|
-
|
|
3060
|
-
|
|
3061
|
-
|
|
3062
|
-
|
|
3063
|
-
|
|
3064
|
-
|
|
3065
|
-
|
|
3066
|
-
|
|
3067
|
-
|
|
3068
|
-
|
|
3069
|
-
|
|
3070
|
-
|
|
3071
|
-
|
|
3072
|
-
|
|
3073
|
-
|
|
5399
|
+
/**
|
|
5400
|
+
* 扫描 profile 依赖里 link:/file: 的本地开发插件,npm pack 打包成 tarball。
|
|
5401
|
+
* 返回 [{ profile, name, spec, status, tarballName?, tarballPath?, warning? }]。
|
|
5402
|
+
* status: packed=已打包 / too-big=超 100MB 跳过 / npm-missing=无 npm 回退 / missing=路径不存在 / pack-failed。
|
|
5403
|
+
*/
|
|
5404
|
+
async function scanLocalPlugins(signal) {
|
|
5405
|
+
const { dshHome, root } = paths();
|
|
5406
|
+
const result = [];
|
|
5407
|
+
const profilesRoot = `${dshHome}/profiles`;
|
|
5408
|
+
let dirents;
|
|
5409
|
+
try { dirents = await readdir(profilesRoot, { withFileTypes: true }); } catch { return result; }
|
|
5410
|
+
for (const d of dirents) {
|
|
5411
|
+
if (!d.isDirectory()) continue;
|
|
5412
|
+
const profile = d.name;
|
|
5413
|
+
let pkg;
|
|
5414
|
+
try { pkg = JSON.parse(await readFile(`${profilesRoot}/${profile}/package.json`, 'utf8')); } catch { continue; }
|
|
5415
|
+
const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
|
|
5416
|
+
for (const [name, spec] of Object.entries(deps)) {
|
|
5417
|
+
if (typeof spec !== 'string') continue;
|
|
5418
|
+
const m = /^(link|file):(.*)$/.exec(spec);
|
|
5419
|
+
if (!m) continue;
|
|
5420
|
+
const ref = m[2];
|
|
5421
|
+
const abs = ref.startsWith('/') || /^[A-Za-z]:/.test(ref) ? ref : `${profilesRoot}/${profile}/${ref}`;
|
|
5422
|
+
const resolved = resolve(abs);
|
|
5423
|
+
if (!(await existsStat(resolved))) {
|
|
5424
|
+
result.push({ profile, name, spec, status: 'missing', warning: `本地插件 ${name} 路径不存在(${spec})` });
|
|
5425
|
+
continue;
|
|
5426
|
+
}
|
|
5427
|
+
if (await dirSize(resolved) > PLUGIN_PACK_MAX_BYTES) {
|
|
5428
|
+
result.push({ profile, name, spec, status: 'too-big', warning: `本地插件 ${name} 超过 100MB,跳过打包(新机需手动装)` });
|
|
5429
|
+
continue;
|
|
5430
|
+
}
|
|
5431
|
+
let npm;
|
|
5432
|
+
try { npm = await ctx.subprocess.resolveExecutable(IS_WIN ? 'npm.cmd' : 'npm'); }
|
|
5433
|
+
catch { result.push({ profile, name, spec, status: 'npm-missing', warning: '本机无 npm,保留原 spec(新机需手动装)' }); continue; }
|
|
5434
|
+
const packDir = `${root}/.plugin-pack-${stampNow()}`;
|
|
5435
|
+
await mkdir(packDir, { recursive: true });
|
|
5436
|
+
try {
|
|
5437
|
+
const r = await spawnRun([npm, 'pack', resolved, '--pack-destination', packDir], dshHome, signal);
|
|
5438
|
+
const tarballName = r.out.trim().split('\n').filter((l) => l.endsWith('.tgz')).pop()?.trim();
|
|
5439
|
+
if (!tarballName) throw new Error('npm pack 未产出 .tgz');
|
|
5440
|
+
result.push({ profile, name, spec, status: 'packed', tarballName, tarballPath: `${packDir}/${tarballName}` });
|
|
5441
|
+
} catch (err) {
|
|
5442
|
+
result.push({ profile, name, spec, status: 'pack-failed', warning: `npm pack 失败(${String(err && err.message ? err.message : err).slice(0, 120)})` });
|
|
5443
|
+
}
|
|
5444
|
+
}
|
|
3074
5445
|
}
|
|
5446
|
+
return result;
|
|
3075
5447
|
}
|
|
3076
5448
|
|
|
3077
|
-
|
|
3078
|
-
|
|
3079
|
-
|
|
3080
|
-
|
|
3081
|
-
|
|
3082
|
-
|
|
3083
|
-
|
|
3084
|
-
|
|
3085
|
-
|
|
3086
|
-
|
|
3087
|
-
|
|
5449
|
+
/** 导入侧:把打包的本地插件解包到 `<dshHome>/local-plugins/<name>/` 并改回 file:。 */
|
|
5450
|
+
async function restoreLocalPlugins(localPlugins, signal) {
|
|
5451
|
+
const { dshHome, root } = paths();
|
|
5452
|
+
const restored = [];
|
|
5453
|
+
const skipped = [];
|
|
5454
|
+
for (const p of Array.isArray(localPlugins) ? localPlugins : []) {
|
|
5455
|
+
if (p.status !== 'packed' || !p.tarballName) { skipped.push({ name: p.name, reason: p.warning || p.status }); continue; }
|
|
5456
|
+
// 导入包内 tarball 已随 export 打进了 ZIP(importApply 解压到暂存后经 names 落盘到 dshHome 下)
|
|
5457
|
+
const src = `${dshHome}/local-plugins/${p.tarballName}`;
|
|
5458
|
+
if (!(await existsStat(src))) { skipped.push({ name: p.name, reason: 'tarball 不在导入包内' }); continue; }
|
|
5459
|
+
const targetDir = `${dshHome}/local-plugins/${p.name}`;
|
|
5460
|
+
await mkdir(targetDir, { recursive: true });
|
|
5461
|
+
const tar = await ctx.subprocess.resolveExecutable('tar');
|
|
5462
|
+
await spawnRun([tar, '-xzf', src, '-C', parentTar(targetDir)], dshHome, signal);
|
|
5463
|
+
// 把 profile 的 package.json 里 link:/file: 改回 file: 指向解包目录
|
|
5464
|
+
const pkgPath = `${dshHome}/profiles/${p.profile}/package.json`;
|
|
5465
|
+
try {
|
|
5466
|
+
const pkg = JSON.parse(await readFile(pkgPath, 'utf8'));
|
|
5467
|
+
for (const field of ['dependencies', 'devDependencies']) {
|
|
5468
|
+
if (pkg[field] && typeof pkg[field] === 'object' && pkg[field][p.name]) {
|
|
5469
|
+
pkg[field][p.name] = `file:${targetDir}`;
|
|
5470
|
+
}
|
|
5471
|
+
}
|
|
5472
|
+
await writeOwned(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
|
|
5473
|
+
} catch { /* package.json 改不了就跳过,如实标注 */ }
|
|
5474
|
+
restored.push({ name: p.name, profile: p.profile, dir: targetDir });
|
|
5475
|
+
}
|
|
5476
|
+
return { restored, skipped };
|
|
3088
5477
|
}
|
|
3089
5478
|
|
|
3090
5479
|
// ---------- /backup 命令 ----------
|
|
3091
5480
|
ctx.commands.register({
|
|
3092
5481
|
name: 'backup',
|
|
3093
|
-
description: '备份/恢复 DSH 数据;子命令: list | verify [前缀|all] | restore <前缀|latest> [--dry-run] [--sync-deps] | doctor [--repair <前缀|latest>] | migrate-check | auto [N小时|off] | github status|sync|pull|repo|token <token|off> | check-update | update [--profile <名>] | [--keep N]',
|
|
5482
|
+
description: '备份/恢复 DSH 数据;子命令: list | verify [前缀|all] | restore <前缀|latest> [--dry-run] [--sync-deps] | doctor [--repair <前缀|latest>] | migrate-check | auto [N小时|off] | github status|sync|pull|repo|token <token|off> | market list|search|install | foreign list|preview|import | rewind list|preview|apply | chat list|preview|import | check-update | update [--profile <名>] | [--keep N]',
|
|
3094
5483
|
handler: async (invocation) => {
|
|
3095
5484
|
const input = invocation.rawInput.trim();
|
|
3096
5485
|
try {
|
|
@@ -3098,13 +5487,87 @@ export function apply(ctx, pluginConfig) {
|
|
|
3098
5487
|
const head = parts[0] || '';
|
|
3099
5488
|
const { home } = paths();
|
|
3100
5489
|
|
|
5490
|
+
if (head === 'market') {
|
|
5491
|
+
const sub = parts[1] || 'list';
|
|
5492
|
+
if (sub === 'list' || sub === 'search') {
|
|
5493
|
+
const r = sub === 'search'
|
|
5494
|
+
? await marketSearch(parts.slice(2).join(' '), invocation.signal)
|
|
5495
|
+
: await marketList(invocation.signal);
|
|
5496
|
+
const lines = r.entries.map((e) => ` ${e.id} ${e.name}${e.risk === 'high' ? ' ⚠️高风险' : ''}${e.source ? ` 来源:${e.source}` : ''}`);
|
|
5497
|
+
return { kind: 'success', text: `${r.summary}${lines.length ? `\n${lines.join('\n')}\n\n安装:/backup market install <id> [--yes]` : ''}` };
|
|
5498
|
+
}
|
|
5499
|
+
if (sub === 'install') {
|
|
5500
|
+
const id = parts[2];
|
|
5501
|
+
if (!id) return { kind: 'error', text: '用法:/backup market install <id> [--yes]' };
|
|
5502
|
+
const r = await marketInstall({ id, confirm: parts.includes('--yes') }, invocation.signal);
|
|
5503
|
+
return { kind: r.ok === false ? 'error' : 'success', text: r.summary };
|
|
5504
|
+
}
|
|
5505
|
+
return { kind: 'error', text: '用法:/backup market list | search <q> | install <id> [--yes]' };
|
|
5506
|
+
}
|
|
5507
|
+
|
|
5508
|
+
if (head === 'foreign') {
|
|
5509
|
+
const sub = parts[1] || 'list';
|
|
5510
|
+
if (sub === 'list') {
|
|
5511
|
+
const r = await foreignSources();
|
|
5512
|
+
const lines = r.sources.map((s) => ` ${s.detected ? '✅' : '·'} ${s.id} ${s.label}${s.detected ? `(技能 ${s.skills} · 指令 ${s.instructions ? '有' : '无'} · MCP ${s.mcpServers.length})` : ''}`);
|
|
5513
|
+
return { kind: 'success', text: `${r.summary}\n${lines.join('\n')}\n\n预览:/backup foreign preview <id>;导入:/backup foreign import <id> [--yes]` };
|
|
5514
|
+
}
|
|
5515
|
+
if (sub === 'preview' || sub === 'import') {
|
|
5516
|
+
const sourceId = parts[2];
|
|
5517
|
+
if (!sourceId) return { kind: 'error', text: `用法:/backup foreign ${sub} <来源 id>${sub === 'import' ? ' [--yes]' : ''}` };
|
|
5518
|
+
const r = sub === 'preview'
|
|
5519
|
+
? await foreignPreview({ source: sourceId }, invocation.signal)
|
|
5520
|
+
: await foreignImport({ source: sourceId, confirm: parts.includes('--yes') }, invocation.signal);
|
|
5521
|
+
return { kind: r.ok === false ? 'error' : 'success', text: r.summary };
|
|
5522
|
+
}
|
|
5523
|
+
return { kind: 'error', text: '用法:/backup foreign list | preview <id> | import <id> [--yes]' };
|
|
5524
|
+
}
|
|
5525
|
+
|
|
5526
|
+
if (head === 'rewind') {
|
|
5527
|
+
const sub = parts[1] || 'list';
|
|
5528
|
+
if (sub === 'list') {
|
|
5529
|
+
const r = await sessionRewindList(invocation.signal);
|
|
5530
|
+
const lines = r.sessions.map((s) => ` ${s.id} ${s.cwd ? `项目 ${s.cwd}` : ''} ${s.file}`);
|
|
5531
|
+
return { kind: 'success', text: `${r.summary}${lines.length ? `\n${lines.join('\n')}\n\n预览:/backup rewind preview <id> [seq];分叉:/backup rewind apply <id> [seq] [--yes]` : ''}` };
|
|
5532
|
+
}
|
|
5533
|
+
if (sub === 'preview' || sub === 'apply') {
|
|
5534
|
+
const id = parts[2];
|
|
5535
|
+
if (!id) return { kind: 'error', text: `用法:/backup rewind ${sub} <会话 id> [seq]${sub === 'apply' ? ' [--yes]' : ''}` };
|
|
5536
|
+
const seqArg = parts[3] && !parts[3].startsWith('--') ? Number(parts[3]) : undefined;
|
|
5537
|
+
const seq = Number.isInteger(seqArg) ? seqArg : undefined;
|
|
5538
|
+
const r = sub === 'preview'
|
|
5539
|
+
? await sessionRewindPreview(id, seq, invocation.signal)
|
|
5540
|
+
: await sessionRewindApply({ session: id, seq, confirm: parts.includes('--yes') }, invocation.signal);
|
|
5541
|
+
return { kind: r.ok === false ? 'error' : 'success', text: r.summary };
|
|
5542
|
+
}
|
|
5543
|
+
return { kind: 'error', text: '用法:/backup rewind list | preview <id> [seq] | apply <id> [seq] [--yes]' };
|
|
5544
|
+
}
|
|
5545
|
+
|
|
5546
|
+
if (head === 'chat') {
|
|
5547
|
+
const sub = parts[1] || 'list';
|
|
5548
|
+
if (sub === 'list') {
|
|
5549
|
+
const r = await chatSources();
|
|
5550
|
+
const lines = r.sessions.map((s) => ` ${s.id} ${s.messages} 条 ${s.title ? `「${s.title}」` : ''}`);
|
|
5551
|
+
return { kind: 'success', text: `${r.summary}${lines.length ? `\n${lines.join('\n')}\n\n预览:/backup chat preview <id>;导入:/backup chat import <id> [--yes]` : ''}` };
|
|
5552
|
+
}
|
|
5553
|
+
if (sub === 'preview' || sub === 'import') {
|
|
5554
|
+
const id = parts[2];
|
|
5555
|
+
if (!id) return { kind: 'error', text: `用法:/backup chat ${sub} <会话 id>${sub === 'import' ? ' [--yes]' : ''}` };
|
|
5556
|
+
const r = sub === 'preview'
|
|
5557
|
+
? await chatPreview({ session: id }, invocation.signal)
|
|
5558
|
+
: await chatApply({ session: id, confirm: parts.includes('--yes') }, invocation.signal);
|
|
5559
|
+
return { kind: r.ok === false ? 'error' : 'success', text: r.summary };
|
|
5560
|
+
}
|
|
5561
|
+
return { kind: 'error', text: '用法:/backup chat list | preview <id> | import <id> [--yes]' };
|
|
5562
|
+
}
|
|
5563
|
+
|
|
3101
5564
|
if (head === 'list') {
|
|
3102
5565
|
const all = await listBackups();
|
|
3103
5566
|
const total = all.reduce((s, b) => s + (b.size || 0), 0);
|
|
3104
|
-
const lines = all.map((b) => ` ${b.name}${b.size !== undefined ? ` ${(b.size / 1048576).toFixed(1)}MB` : ''}`);
|
|
5567
|
+
const lines = all.map((b) => ` ${b.encrypted ? '🔒 ' : ''}${b.name}${b.size !== undefined ? ` ${(b.size / 1048576).toFixed(1)}MB` : ''}`);
|
|
3105
5568
|
// 内部快照分区展示:升级前(最多 2 份)/ 恢复前(最近 1 份),可按前缀选用
|
|
3106
5569
|
const snaps = await listSnapshotNames('dsh-pre-');
|
|
3107
|
-
const snapLines = snaps.map((n) => ` ${n}${n.startsWith('dsh-pre-upgrade-') ? ' (升级前快照)' : ' (恢复前快照)'}`);
|
|
5570
|
+
const snapLines = snaps.map((n) => ` ${n}${n.startsWith('dsh-pre-upgrade-') ? ' (升级前快照)' : n.startsWith(IMPORT_SNAPSHOT_PREFIX) ? ' (导入前快照)' : ' (恢复前快照)'}`);
|
|
3108
5571
|
const snapText = snapLines.length ? `\n\n内部快照(可 restore 前缀选用,不占保留配额):\n${snapLines.join('\n')}` : '';
|
|
3109
5572
|
const text = all.length
|
|
3110
5573
|
? `已有备份 (${all.length} 份,共 ${(total / 1048576).toFixed(1)}MB):\n${lines.join('\n')}${snapText}\n\n${autoSummary()}`
|
|
@@ -3243,6 +5706,103 @@ export function apply(ctx, pluginConfig) {
|
|
|
3243
5706
|
return { kind: 'error', text: '用法: /backup github status|sync|pull [--restore <前缀|latest>]|repo <地址|off>' };
|
|
3244
5707
|
}
|
|
3245
5708
|
|
|
5709
|
+
if (head === 'sync') {
|
|
5710
|
+
const channel = parts[1];
|
|
5711
|
+
if (!channel) {
|
|
5712
|
+
const chs = await listSyncChannels();
|
|
5713
|
+
const text = Object.entries(chs).map(([k, c]) => ` ${k}: ${c.configured ? '已配置' : '未配置'}${c.url ? ` (${c.url})` : ''}${c.bucket ? ` (${c.bucket})` : ''}${c.lastPush ? ` · 上次 ${c.lastPush}` : ''}${c.lastError ? ` · ⚠️ ${c.lastError}` : ''}`).join('\n');
|
|
5714
|
+
return { kind: 'success', text: `同步通道:\n${text}\n/backup sync <webdav|s3|gist|github> sync 立即推送` };
|
|
5715
|
+
}
|
|
5716
|
+
const action = parts[2] || 'status';
|
|
5717
|
+
if (action === 'off') {
|
|
5718
|
+
const r = await removeSyncChannel(channel);
|
|
5719
|
+
return { kind: r.ok ? 'success' : 'error', text: r.summary };
|
|
5720
|
+
}
|
|
5721
|
+
if (action === 'status') {
|
|
5722
|
+
const chs = await listSyncChannels();
|
|
5723
|
+
const c = chs[channel];
|
|
5724
|
+
return { kind: 'success', text: c?.configured ? `通道 ${channel} 已配置: ${JSON.stringify(c)}` : `通道 ${channel} 未配置(/backup sync ${channel} set <JSON> 配置)` };
|
|
5725
|
+
}
|
|
5726
|
+
if (action === 'sync') {
|
|
5727
|
+
const s = await syncNow(channel, invocation.signal);
|
|
5728
|
+
if (s.error) return { kind: 'error', text: `同步失败: ${s.error}` };
|
|
5729
|
+
const text = s.pushed
|
|
5730
|
+
? `✅ ${channel} 同步完成: 推送 ${s.count ?? 'N'} 个文件,${s.at}${s.gistUrl ? `\nGist: ${s.gistUrl}` : ''}${s.tooBig?.length ? `\n跳过超大文件: ${s.tooBig.join(', ')}` : ''}`
|
|
5731
|
+
: `${channel} 同步: ${s.skipped || '无变更'}${s.tooBig?.length ? `\n跳过超大文件: ${s.tooBig.join(', ')}` : ''}`;
|
|
5732
|
+
return { kind: 'success', text };
|
|
5733
|
+
}
|
|
5734
|
+
if (action === 'set') {
|
|
5735
|
+
const jsonText = parts.slice(3).join(' ').trim();
|
|
5736
|
+
if (!jsonText) return { kind: 'error', text: '用法: /backup sync <channel> set \'{"url":"...",...}\'(JSON 配置,凭据只存本机 0600)' };
|
|
5737
|
+
let cfg;
|
|
5738
|
+
try { cfg = JSON.parse(jsonText); } catch { return { kind: 'error', text: '配置需为合法 JSON 对象' }; }
|
|
5739
|
+
const r = await setSyncChannel(channel, cfg);
|
|
5740
|
+
return { kind: r.ok ? 'success' : 'error', text: r.summary };
|
|
5741
|
+
}
|
|
5742
|
+
return { kind: 'error', text: '用法: /backup sync <channel> status|sync|set <JSON>|off' };
|
|
5743
|
+
}
|
|
5744
|
+
|
|
5745
|
+
if (head === 'lock') {
|
|
5746
|
+
const arg = parts[1] || 'status';
|
|
5747
|
+
if (arg === 'reclaim') {
|
|
5748
|
+
const r = await reclaimLockEntry(parts.includes('--force'));
|
|
5749
|
+
return { kind: r.ok ? 'success' : 'error', text: r.summary };
|
|
5750
|
+
}
|
|
5751
|
+
const st = await lockStatus();
|
|
5752
|
+
if (!st.held) return { kind: 'success', text: '操作锁: 空闲' };
|
|
5753
|
+
return { kind: 'success', text: `操作锁: 已被持有(pid=${st.pid}, operation=${st.operation}${st.stale ? ',已失效(死锁,下次操作会自动回收)' : ''})\n回收: /backup lock reclaim${st.stale ? '' : ' --force'}(--force 会强删活锁,慎用)` };
|
|
5754
|
+
}
|
|
5755
|
+
|
|
5756
|
+
if (head === 'disk' || head === 'usage') {
|
|
5757
|
+
const arg = parts[1];
|
|
5758
|
+
if (arg === 'clean') { const r = await cleanup('cache', invocation.signal); return { kind: 'success', text: r.summary }; }
|
|
5759
|
+
if (arg === 'clean-expired') { const r = await cleanup('expired', invocation.signal); return { kind: 'success', text: r.summary }; }
|
|
5760
|
+
const u = await diskUsage();
|
|
5761
|
+
return { kind: 'success', text: u.summary };
|
|
5762
|
+
}
|
|
5763
|
+
|
|
5764
|
+
if (head === 'profiles') {
|
|
5765
|
+
const sub = parts[1] || 'list';
|
|
5766
|
+
if (sub === 'list') {
|
|
5767
|
+
const list = await profilesList();
|
|
5768
|
+
const text = list.length
|
|
5769
|
+
? `Profiles (${list.length}):\n${list.map((p) => ` ${p.name}${p.appOwned ? '(app-owned,只读)' : ''}${p.version ? ` v${p.version}` : ''}${p.running ? ` ▶ 运行中(pid=${p.pid})` : ''}`).join('\n')}`
|
|
5770
|
+
: '还没有 profile(可用 /backup profiles create <名> 创建)';
|
|
5771
|
+
return { kind: 'success', text };
|
|
5772
|
+
}
|
|
5773
|
+
if (sub === 'create') {
|
|
5774
|
+
const name = parts[2];
|
|
5775
|
+
if (!name) return { kind: 'error', text: '用法: /backup profiles create <名>' };
|
|
5776
|
+
const r = await profileCreate(name);
|
|
5777
|
+
return { kind: r.ok ? 'success' : 'error', text: r.summary };
|
|
5778
|
+
}
|
|
5779
|
+
if (sub === 'rename') {
|
|
5780
|
+
const r = await profileRename(parts[2], parts[3]);
|
|
5781
|
+
if (!parts[2] || !parts[3]) return { kind: 'error', text: '用法: /backup profiles rename <旧名> <新名>' };
|
|
5782
|
+
return { kind: r.ok ? 'success' : 'error', text: r.summary };
|
|
5783
|
+
}
|
|
5784
|
+
if (sub === 'delete') {
|
|
5785
|
+
const name = parts[2];
|
|
5786
|
+
if (!name) return { kind: 'error', text: '用法: /backup profiles delete <名> --yes(硬删,不可恢复)' };
|
|
5787
|
+
if (!parts.includes('--yes')) return { kind: 'error', text: `硬删不可恢复——确认删除 ${name} 请加 --yes` };
|
|
5788
|
+
const r = await profileDelete(name);
|
|
5789
|
+
return { kind: r.ok ? 'success' : 'error', text: r.summary };
|
|
5790
|
+
}
|
|
5791
|
+
if (sub === 'launch') {
|
|
5792
|
+
const name = parts[2];
|
|
5793
|
+
if (!name) return { kind: 'error', text: '用法: /backup profiles launch <名>' };
|
|
5794
|
+
const r = await profileLaunch(name);
|
|
5795
|
+
return { kind: r.ok ? 'success' : 'error', text: r.summary };
|
|
5796
|
+
}
|
|
5797
|
+
if (sub === 'stop') {
|
|
5798
|
+
const name = parts[2];
|
|
5799
|
+
if (!name) return { kind: 'error', text: '用法: /backup profiles stop <名>' };
|
|
5800
|
+
const r = await profileStop(name);
|
|
5801
|
+
return { kind: r.ok ? 'success' : 'error', text: r.summary };
|
|
5802
|
+
}
|
|
5803
|
+
return { kind: 'error', text: '用法: /backup profiles list|create <名>|rename <旧> <新>|delete <名> --yes|launch <名>|stop <名>' };
|
|
5804
|
+
}
|
|
5805
|
+
|
|
3246
5806
|
if (head === 'delete' || head === 'rm') {
|
|
3247
5807
|
const sel = parts[1];
|
|
3248
5808
|
if (!sel) return { kind: 'error', text: '用法: /backup delete <归档名前缀|latest>(删除不可恢复,需要时可用 /backup 重新备份)' };
|
|
@@ -3257,9 +5817,22 @@ export function apply(ctx, pluginConfig) {
|
|
|
3257
5817
|
await setAuto(0);
|
|
3258
5818
|
return { kind: 'success', text: '自动备份已关闭。' };
|
|
3259
5819
|
}
|
|
5820
|
+
if (arg === 'weekly') {
|
|
5821
|
+
const specs = parts.slice(2).filter(Boolean);
|
|
5822
|
+
if (!specs.length || specs[0] === 'status') return { kind: 'success', text: autoSummary() };
|
|
5823
|
+
if (specs[0] === 'off') { const r = await setWeekly([]); return { kind: r.ok ? 'success' : 'error', text: r.summary }; }
|
|
5824
|
+
const list = [];
|
|
5825
|
+
for (const spec of specs) {
|
|
5826
|
+
const m = /^([1-7])-(\d{2}:\d{2})$/.exec(spec);
|
|
5827
|
+
if (!m) return { kind: 'error', text: `每周时刻格式应为「周几-时:分」(周几 1=周一..7=周日,可多个空格分隔),收到 ${spec}` };
|
|
5828
|
+
list.push({ day: Number(m[1]), time: m[2] });
|
|
5829
|
+
}
|
|
5830
|
+
const r = await setWeekly(list);
|
|
5831
|
+
return { kind: r.ok ? 'success' : 'error', text: r.summary };
|
|
5832
|
+
}
|
|
3260
5833
|
const h = Number(arg);
|
|
3261
5834
|
if (!Number.isFinite(h) || h < 1 || h > 720) {
|
|
3262
|
-
return { kind: 'error', text: '小时数需为 1~720 之间的数字(如 /backup auto 12
|
|
5835
|
+
return { kind: 'error', text: '小时数需为 1~720 之间的数字(如 /backup auto 12);或 /backup auto weekly 1-09:00 5-18:00' };
|
|
3263
5836
|
}
|
|
3264
5837
|
await setAuto(h);
|
|
3265
5838
|
return { kind: 'success', text: `✅ 自动备份已开启:每 ${h} 小时执行一次(保留 ${autoKeep()} 份,已持久化)。\n首次备份正在执行,之后每 ${h} 小时一次。\n${autoSummary()}` };
|
|
@@ -3290,6 +5863,155 @@ export function apply(ctx, pluginConfig) {
|
|
|
3290
5863
|
},
|
|
3291
5864
|
});
|
|
3292
5865
|
|
|
5866
|
+
// ---------- 配置管理 agent 工具(P4:1 → 5,对齐竞品语义) ----------
|
|
5867
|
+
// 安全不变量:config_restore 默认 dry-run、config_sync_pull 只读不写用户数据、
|
|
5868
|
+
// config_sync_push 是显式写远端、口令/密文值绝不进工具入参/输出/日志。
|
|
5869
|
+
|
|
5870
|
+
/** 列本地回滚快照(恢复前/升级前/导入前内部安全网):id/时间/来源/状态/条目数。 */
|
|
5871
|
+
async function listRollbackSnapshots(signal) {
|
|
5872
|
+
const { root } = paths();
|
|
5873
|
+
const names = await listSnapshotNames('dsh-pre-');
|
|
5874
|
+
const out = [];
|
|
5875
|
+
for (const name of names) {
|
|
5876
|
+
let meta = null;
|
|
5877
|
+
try { meta = JSON.parse(await readFile(`${root}/${name}.meta.json`, 'utf8')); } catch { /* 边车缺失 */ }
|
|
5878
|
+
const source = name.startsWith('dsh-pre-upgrade-') ? 'upgrade' : name.startsWith(IMPORT_SNAPSHOT_PREFIX) ? 'import' : 'restore';
|
|
5879
|
+
let entries = 0;
|
|
5880
|
+
try { entries = (await safeArchiveEntries(name, signal)).length; } catch { entries = 0; }
|
|
5881
|
+
const size = await fsStat(`${root}/${name}`).then((s) => s.size, () => 0);
|
|
5882
|
+
out.push({ id: name, time: meta?.createdAt ?? null, source, status: 'ok', entries, size });
|
|
5883
|
+
}
|
|
5884
|
+
return out;
|
|
5885
|
+
}
|
|
5886
|
+
|
|
5887
|
+
/** 拉取 diff 预览(零写用户数据:只更新同步缓存工作树,绝不写 ~/.dsh 或本地归档集)。 */
|
|
5888
|
+
async function configSyncPullPreview(channel, signal) {
|
|
5889
|
+
if (channel !== 'github') {
|
|
5890
|
+
return { ok: true, channel, total: 0, missing: [], alreadyHave: 0, summary: `通道 ${channel} 本轮仅支持推送,暂不支持拉取(拉取请用 github)` };
|
|
5891
|
+
}
|
|
5892
|
+
const { root } = paths();
|
|
5893
|
+
const wt = await ensureSyncWorktree(signal);
|
|
5894
|
+
if (wt.reason) return { ok: false, channel, total: 0, missing: [], alreadyHave: 0, summary: wt.reason };
|
|
5895
|
+
const { syncDir, git } = wt;
|
|
5896
|
+
await spawnRun([git, 'fetch', 'origin', 'main'], syncDir, signal);
|
|
5897
|
+
await spawnRun([git, 'reset', '--hard', 'origin/main'], syncDir, signal);
|
|
5898
|
+
let names = [];
|
|
5899
|
+
try {
|
|
5900
|
+
names = (await readdir(syncDir, { withFileTypes: true }))
|
|
5901
|
+
.filter((e) => e.isFile() && /^dsh-[A-Za-z0-9._-]+\.tar\.gz$/.test(e.name))
|
|
5902
|
+
.map((e) => e.name);
|
|
5903
|
+
} catch { names = []; }
|
|
5904
|
+
const missing = [];
|
|
5905
|
+
for (const n of names) if (!(await existsStat(`${root}/${n}`))) missing.push(n);
|
|
5906
|
+
const summary = `远端共 ${names.length} 份备份,本地缺 ${missing.length} 份${missing.length ? `:\n${missing.map((n) => ` ${n}`).join('\n')}` : '(本地均已存在)'}。落地请走 confirm 恢复流程(config_restore)。`;
|
|
5907
|
+
return { ok: true, channel, total: names.length, missing, alreadyHave: names.length - missing.length, summary };
|
|
5908
|
+
}
|
|
5909
|
+
|
|
5910
|
+
ctx.tools.register(defineTool({
|
|
5911
|
+
name: 'config_backup',
|
|
5912
|
+
description: '把 ~/.dsh 配置打包成单文件 ZIP(节段化 settings/ui/providers/plugins/mcp/skills/prompts/workspaces/sessions/credentials + other 兜底)。默认脱敏(凭据不入包);password 提供时加密(scrypt+AES-256-GCM),并允许 includeCredentials 入包。口令只经工具参数内存态传入,绝不落盘/入日志,密文值绝不进输出。',
|
|
5913
|
+
parameters: {
|
|
5914
|
+
password: { type: 'string', description: '可选口令:提供则加密导出(凭据可入包);不提供则明文导出且凭据脱敏' },
|
|
5915
|
+
includeCredentials: { type: 'boolean', description: '是否把凭据节段打入导出包(必须配 password 加密,否则拒绝)' },
|
|
5916
|
+
},
|
|
5917
|
+
output: { schema: { type: 'object', additionalProperties: true }, render: (_a, v) => [{ type: 'text', text: String(v.summary) }] },
|
|
5918
|
+
execute: async (args, exec) => {
|
|
5919
|
+
const signal = exec && exec.signal ? exec.signal : undefined;
|
|
5920
|
+
try {
|
|
5921
|
+
const password = typeof args?.password === 'string' && args.password ? args.password : undefined;
|
|
5922
|
+
const r = await exportConfig({ password, includeCredentials: args?.includeCredentials === true }, signal);
|
|
5923
|
+
return { ok: true, name: r.name, size: r.size, encrypted: r.encrypted, sections: r.manifest?.sections ?? [], redactedFiles: r.redactedFiles ?? [], summary: r.summary };
|
|
5924
|
+
} catch (err) {
|
|
5925
|
+
return { ok: false, summary: `操作失败: ${friendlyReason(err)}` };
|
|
5926
|
+
}
|
|
5927
|
+
},
|
|
5928
|
+
}));
|
|
5929
|
+
|
|
5930
|
+
ctx.tools.register(defineTool({
|
|
5931
|
+
name: 'config_list_snapshots',
|
|
5932
|
+
description: '列出本地回滚快照(恢复前/升级前/导入前自动拍的内部安全网)。每项给 id/时间/来源/状态/条目数。只读,不写任何文件。',
|
|
5933
|
+
parameters: {},
|
|
5934
|
+
output: { schema: { type: 'object', additionalProperties: true }, render: (_a, v) => [{ type: 'text', text: String(v.summary) }] },
|
|
5935
|
+
execute: async (_args, exec) => {
|
|
5936
|
+
const signal = exec && exec.signal ? exec.signal : undefined;
|
|
5937
|
+
try {
|
|
5938
|
+
const snapshots = await listRollbackSnapshots(signal);
|
|
5939
|
+
const summary = snapshots.length
|
|
5940
|
+
? `本地回滚快照 ${snapshots.length} 份:\n${snapshots.map((s) => ` ${s.id}(${s.source},${s.entries} 项${s.time ? `,${s.time}` : ''})`).join('\n')}`
|
|
5941
|
+
: '暂无回滚快照';
|
|
5942
|
+
return { ok: true, snapshots, summary };
|
|
5943
|
+
} catch (err) {
|
|
5944
|
+
return { ok: false, snapshots: [], summary: `操作失败: ${friendlyReason(err)}` };
|
|
5945
|
+
}
|
|
5946
|
+
},
|
|
5947
|
+
}));
|
|
5948
|
+
|
|
5949
|
+
ctx.tools.register(defineTool({
|
|
5950
|
+
name: 'config_restore',
|
|
5951
|
+
description: '从本地回滚快照恢复(整体替换 ~/.dsh,破坏性)。默认 dry-run=true 只出计划、零写入;confirm:true 才真正执行(执行前自动校验 + 再拍当前数据快照,解压失败自动回滚)。真实恢复前必须先 dry-run 预览并向用户展示确认。',
|
|
5952
|
+
parameters: {
|
|
5953
|
+
snapshot: { type: 'string', description: '快照 id/前缀(config_list_snapshots 里的 id;缺省 latest=最新全量备份)' },
|
|
5954
|
+
confirm: { type: 'boolean', description: 'true=真正执行恢复(破坏性);false/缺省=仅 dry-run 预览,零写入' },
|
|
5955
|
+
},
|
|
5956
|
+
output: { schema: { type: 'object', additionalProperties: true }, render: (_a, v) => [{ type: 'text', text: String(v.summary) }] },
|
|
5957
|
+
execute: async (args, exec) => {
|
|
5958
|
+
const signal = exec && exec.signal ? exec.signal : undefined;
|
|
5959
|
+
try {
|
|
5960
|
+
const selector = typeof args?.snapshot === 'string' && args.snapshot ? args.snapshot : 'latest';
|
|
5961
|
+
const confirm = args?.confirm === true;
|
|
5962
|
+
const r = await restoreArchive(selector, !confirm, signal);
|
|
5963
|
+
return { ok: true, dryRun: !confirm, archive: r.archive, files: r.files, preflight: r.preflight ?? [], summary: summarizeRestore(r) };
|
|
5964
|
+
} catch (err) {
|
|
5965
|
+
return { ok: false, dryRun: args?.confirm !== true, summary: `操作失败: ${friendlyReason(err)}` };
|
|
5966
|
+
}
|
|
5967
|
+
},
|
|
5968
|
+
}));
|
|
5969
|
+
|
|
5970
|
+
ctx.tools.register(defineTool({
|
|
5971
|
+
name: 'config_sync_push',
|
|
5972
|
+
description: '把本地备份集推送到指定同步通道(github/webdav/s3/gist),显式写远端。归档默认脱敏(凭据不进包);password 提供时先做一份加密全量备份再推送(引擎强制加密后同步)。口令绝不回显/落日志,密文值绝不进输出。',
|
|
5973
|
+
parameters: {
|
|
5974
|
+
channel: { type: 'string', required: true, description: '通道名:github/webdav/s3/gist' },
|
|
5975
|
+
password: { type: 'string', description: '可选口令:提供则先做加密全量备份再推送(强制加密后同步)' },
|
|
5976
|
+
},
|
|
5977
|
+
output: { schema: { type: 'object', additionalProperties: true }, render: (_a, v) => [{ type: 'text', text: String(v.summary) }] },
|
|
5978
|
+
execute: async (args, exec) => {
|
|
5979
|
+
const signal = exec && exec.signal ? exec.signal : undefined;
|
|
5980
|
+
try {
|
|
5981
|
+
const channel = typeof args?.channel === 'string' && args.channel ? args.channel : 'github';
|
|
5982
|
+
if (typeof args?.password === 'string' && args.password) {
|
|
5983
|
+
// 强制加密:先做一份加密全量备份(口令内存态),再推远端
|
|
5984
|
+
await doBackup(undefined, signal, { password: args.password });
|
|
5985
|
+
}
|
|
5986
|
+
const s = await syncNow(channel, signal);
|
|
5987
|
+
if (s.error) return { ok: false, summary: `同步失败: ${s.error}` };
|
|
5988
|
+
return { ok: true, pushed: Boolean(s.pushed), count: s.count ?? null, tooBig: s.tooBig ?? [], gistUrl: s.gistUrl ?? null, summary: s.pushed ? `✅ ${channel} 推送完成(${s.count ?? 'N'} 个文件)` : `无变更` };
|
|
5989
|
+
} catch (err) {
|
|
5990
|
+
return { ok: false, summary: `操作失败: ${friendlyReason(err)}` };
|
|
5991
|
+
}
|
|
5992
|
+
},
|
|
5993
|
+
}));
|
|
5994
|
+
|
|
5995
|
+
ctx.tools.register(defineTool({
|
|
5996
|
+
name: 'config_sync_pull',
|
|
5997
|
+
description: '拉取远端备份集的 diff 预览(只下载+分析,绝不写 ~/.dsh 或本地归档集合)。返回远端有哪些、本地缺哪些;落地导入走独立 confirm 流程(config_restore)。webdav/s3/gist 本轮仅推送。',
|
|
5998
|
+
parameters: {
|
|
5999
|
+
channel: { type: 'string', description: '通道名(缺省 github)' },
|
|
6000
|
+
},
|
|
6001
|
+
output: { schema: { type: 'object', additionalProperties: true }, render: (_a, v) => [{ type: 'text', text: String(v.summary) }] },
|
|
6002
|
+
execute: async (args, exec) => {
|
|
6003
|
+
const signal = exec && exec.signal ? exec.signal : undefined;
|
|
6004
|
+
try {
|
|
6005
|
+
const channel = typeof args?.channel === 'string' && args.channel ? args.channel : 'github';
|
|
6006
|
+
const p = await configSyncPullPreview(channel, signal);
|
|
6007
|
+
return { ok: true, ...p };
|
|
6008
|
+
} catch (err) {
|
|
6009
|
+
return { ok: false, summary: `操作失败: ${friendlyReason(err)}` };
|
|
6010
|
+
}
|
|
6011
|
+
},
|
|
6012
|
+
}));
|
|
6013
|
+
|
|
6014
|
+
|
|
3293
6015
|
// ---------- backup_dsh 模型工具 ----------
|
|
3294
6016
|
ctx.tools.register(defineTool({
|
|
3295
6017
|
name: 'backup_dsh',
|
|
@@ -3315,7 +6037,7 @@ export function apply(ctx, pluginConfig) {
|
|
|
3315
6037
|
try {
|
|
3316
6038
|
if (mode === 'list') {
|
|
3317
6039
|
const all = await listBackups();
|
|
3318
|
-
return { ok: true, summary: `已有 ${all.length} 份备份:\n${all.map((b) => ` ${b.name}`).join('\n') || '(无)'}\n\n${autoSummary()}` };
|
|
6040
|
+
return { ok: true, summary: `已有 ${all.length} 份备份:\n${all.map((b) => ` ${b.encrypted ? '🔒 ' : ''}${b.name}`).join('\n') || '(无)'}\n\n${autoSummary()}` };
|
|
3319
6041
|
}
|
|
3320
6042
|
if (mode === 'verify') {
|
|
3321
6043
|
const { home } = paths();
|
|
@@ -3406,23 +6128,26 @@ export function apply(ctx, pluginConfig) {
|
|
|
3406
6128
|
dshHome,
|
|
3407
6129
|
keepDefault: defaultKeep(),
|
|
3408
6130
|
autoHours,
|
|
6131
|
+
weeklySchedules,
|
|
3409
6132
|
lastAuto,
|
|
3410
|
-
backups: all.map((b) => ({ name: b.name, size: typeof b.size === 'number' ? b.size : null })),
|
|
6133
|
+
backups: all.map((b) => ({ name: b.name, size: typeof b.size === 'number' ? b.size : null, encrypted: Boolean(b.encrypted) })),
|
|
3411
6134
|
typedBackups,
|
|
3412
6135
|
};
|
|
3413
6136
|
},
|
|
3414
|
-
backup: async (keep, types, signal) => {
|
|
6137
|
+
backup: async (keep, types, password, signal) => {
|
|
3415
6138
|
const validTypes = Array.isArray(types) && types.length ? [...new Set(types.filter((t) => BACKUP_TYPES[t]))] : undefined;
|
|
3416
|
-
const
|
|
6139
|
+
const pass = typeof password === 'string' && password ? password : undefined;
|
|
6140
|
+
const r = await doBackup(keep, signal, { ...(validTypes ? { types: validTypes } : {}), ...(pass ? { password: pass } : {}) });
|
|
3417
6141
|
// 面板回执给"成了、叫什么、存哪了";64 位 sha 全文是噪音(完整值就在
|
|
3418
6142
|
// 归档旁的 .sha256),路径用文件名——目录已显示在上方总览卡
|
|
3419
6143
|
return {
|
|
3420
6144
|
ok: true,
|
|
3421
|
-
summary: `备份完成: ${r.path.split('/').pop()}${validTypes ? `(类型: ${validTypes.join(', ')}${r.hasCredentials ? ',含明文凭据·不进 GitHub 同步' : ''})` : ''}\nsha256: ${r.sha.slice(0, 16)}…(完整校验值在归档旁的 .sha256 文件)${r.stale ? `\n轮换删除 ${r.stale} 份旧备份(保留 ${r.keep} 份)` : ''}${quarantineNote(r)}`,
|
|
6145
|
+
summary: `备份完成: ${r.path.split('/').pop()}${r.encrypted ? ' 🔒' : ''}${validTypes ? `(类型: ${validTypes.join(', ')}${r.hasCredentials ? ',含明文凭据·不进 GitHub 同步' : ''})` : ''}\nsha256: ${r.sha.slice(0, 16)}…(完整校验值在归档旁的 .sha256 文件)${r.stale ? `\n轮换删除 ${r.stale} 份旧备份(保留 ${r.keep} 份)` : ''}${quarantineNote(r)}`,
|
|
3422
6146
|
path: r.path,
|
|
3423
6147
|
sha: r.sha,
|
|
3424
6148
|
stale: r.stale,
|
|
3425
6149
|
keep: r.keep,
|
|
6150
|
+
...(r.encrypted ? { encrypted: true } : {}),
|
|
3426
6151
|
...(validTypes ? { types: validTypes, hasCredentials: Boolean(r.hasCredentials) } : {}),
|
|
3427
6152
|
};
|
|
3428
6153
|
},
|
|
@@ -3441,10 +6166,11 @@ export function apply(ctx, pluginConfig) {
|
|
|
3441
6166
|
}
|
|
3442
6167
|
return { ok: bad === 0, summary: results.map((r) => `${r.ok ? '✅' : '❌'} ${r.name} — ${r.note}`).join('\n') || '暂无备份可校验。', results };
|
|
3443
6168
|
},
|
|
3444
|
-
restore: async (selector, dryRun, types, syncDeps, signal) => {
|
|
6169
|
+
restore: async (selector, dryRun, types, syncDeps, password, signal) => {
|
|
3445
6170
|
try {
|
|
3446
6171
|
const validTypes = Array.isArray(types) && types.length ? [...new Set(types.filter((t) => BACKUP_TYPES[t]))] : undefined;
|
|
3447
|
-
const
|
|
6172
|
+
const pass = typeof password === 'string' && password ? password : undefined;
|
|
6173
|
+
const r = await restoreArchive(selector || 'latest', Boolean(dryRun), signal, { syncDeps: Boolean(syncDeps), ...(validTypes ? { types: validTypes } : {}), ...(pass ? { password: pass } : {}) });
|
|
3448
6174
|
if (r.dryRun) {
|
|
3449
6175
|
return { ok: true, dryRun: true, archive: r.archive, files: r.files, sample: r.sample, preflight: r.preflight ?? [], targetExists: r.targetExists, ...(r.merge ? { merge: true, types: r.types, willOverwrite: r.willOverwrite ?? [] } : {}), summary: summarizeRestore(r) };
|
|
3450
6176
|
}
|
|
@@ -3629,6 +6355,130 @@ export function apply(ctx, pluginConfig) {
|
|
|
3629
6355
|
const r = await runUpdate(signal, 'web');
|
|
3630
6356
|
return { ok: r.ok, updated: r.updated, current: r.current ?? null, latest: r.latest ?? null, summary: r.summary };
|
|
3631
6357
|
},
|
|
6358
|
+
exportConfig: async (options, signal) => {
|
|
6359
|
+
try {
|
|
6360
|
+
return await exportConfig(options || {}, signal);
|
|
6361
|
+
} catch (err) {
|
|
6362
|
+
return { ok: false, summary: friendlyReason(err) };
|
|
6363
|
+
}
|
|
6364
|
+
},
|
|
6365
|
+
importPreview: async (options, signal) => {
|
|
6366
|
+
try {
|
|
6367
|
+
return await importPreview(options || {}, signal);
|
|
6368
|
+
} catch (err) {
|
|
6369
|
+
return { ok: false, summary: friendlyReason(err) };
|
|
6370
|
+
}
|
|
6371
|
+
},
|
|
6372
|
+
importApply: async (options, signal) => {
|
|
6373
|
+
try {
|
|
6374
|
+
return await importApply(options || {}, signal);
|
|
6375
|
+
} catch (err) {
|
|
6376
|
+
return { ok: false, summary: friendlyReason(err) };
|
|
6377
|
+
}
|
|
6378
|
+
},
|
|
6379
|
+
syncStatus: async () => {
|
|
6380
|
+
try {
|
|
6381
|
+
const chs = await listSyncChannels();
|
|
6382
|
+
return { ok: true, channels: chs, summary: Object.entries(chs).map(([k, c]) => `${k}: ${c.configured ? '已配置' : '未配置'}`).join('\n') };
|
|
6383
|
+
} catch (err) {
|
|
6384
|
+
return { ok: false, channels: {}, summary: friendlyReason(err) };
|
|
6385
|
+
}
|
|
6386
|
+
},
|
|
6387
|
+
syncNow: async (channel, signal) => {
|
|
6388
|
+
try {
|
|
6389
|
+
const s = await syncNow(channel, signal);
|
|
6390
|
+
if (s.error) return { ok: false, summary: s.error, ...s };
|
|
6391
|
+
return { ok: true, summary: s.pushed ? `✅ ${channel} 同步完成` : `无变更`, ...s };
|
|
6392
|
+
} catch (err) {
|
|
6393
|
+
return { ok: false, summary: friendlyReason(err) };
|
|
6394
|
+
}
|
|
6395
|
+
},
|
|
6396
|
+
setSyncChannel: async (channel, config) => {
|
|
6397
|
+
try {
|
|
6398
|
+
return await setSyncChannel(channel, config);
|
|
6399
|
+
} catch (err) {
|
|
6400
|
+
return { ok: false, summary: friendlyReason(err) };
|
|
6401
|
+
}
|
|
6402
|
+
},
|
|
6403
|
+
removeSyncChannel: async (channel) => {
|
|
6404
|
+
try {
|
|
6405
|
+
return await removeSyncChannel(channel);
|
|
6406
|
+
} catch (err) {
|
|
6407
|
+
return { ok: false, summary: friendlyReason(err) };
|
|
6408
|
+
}
|
|
6409
|
+
},
|
|
6410
|
+
lockStatus: async () => {
|
|
6411
|
+
try { return { ok: true, ...(await lockStatus()) }; } catch (err) { return { ok: false, summary: friendlyReason(err) }; }
|
|
6412
|
+
},
|
|
6413
|
+
reclaimLock: async (force) => {
|
|
6414
|
+
try { return await reclaimLockEntry(force === true); } catch (err) { return { ok: false, summary: friendlyReason(err) }; }
|
|
6415
|
+
},
|
|
6416
|
+
diskUsage: async () => {
|
|
6417
|
+
try { const u = await diskUsage(); return { ok: true, ...u }; } catch (err) { return { ok: false, summary: friendlyReason(err) }; }
|
|
6418
|
+
},
|
|
6419
|
+
cleanup: async (scope, signal) => {
|
|
6420
|
+
try { return await cleanup(scope === 'expired' ? 'expired' : 'cache', signal); } catch (err) { return { ok: false, summary: friendlyReason(err) }; }
|
|
6421
|
+
},
|
|
6422
|
+
setWeekly: async (schedules) => {
|
|
6423
|
+
try { return await setWeekly(schedules); } catch (err) { return { ok: false, summary: friendlyReason(err) }; }
|
|
6424
|
+
},
|
|
6425
|
+
about: async () => {
|
|
6426
|
+
try { return { ok: true, ...(await aboutInfo()) }; } catch (err) { return { ok: false, summary: friendlyReason(err) }; }
|
|
6427
|
+
},
|
|
6428
|
+
profilesList: async () => {
|
|
6429
|
+
try { const list = await profilesList(); return { ok: true, profiles: list, summary: `${list.length} 个 profile` }; } catch (err) { return { ok: false, profiles: [], summary: friendlyReason(err) }; }
|
|
6430
|
+
},
|
|
6431
|
+
profileCreate: async (name) => {
|
|
6432
|
+
try { return await profileCreate(name); } catch (err) { return { ok: false, summary: friendlyReason(err) }; }
|
|
6433
|
+
},
|
|
6434
|
+
profileRename: async (oldName, newName) => {
|
|
6435
|
+
try { return await profileRename(oldName, newName); } catch (err) { return { ok: false, summary: friendlyReason(err) }; }
|
|
6436
|
+
},
|
|
6437
|
+
profileDelete: async (name) => {
|
|
6438
|
+
try { return await profileDelete(name); } catch (err) { return { ok: false, summary: friendlyReason(err) }; }
|
|
6439
|
+
},
|
|
6440
|
+
profileLaunch: async (name) => {
|
|
6441
|
+
try { return await profileLaunch(name); } catch (err) { return { ok: false, summary: friendlyReason(err) }; }
|
|
6442
|
+
},
|
|
6443
|
+
profileStop: async (name) => {
|
|
6444
|
+
try { return await profileStop(name); } catch (err) { return { ok: false, summary: friendlyReason(err) }; }
|
|
6445
|
+
},
|
|
6446
|
+
marketList: async (signal) => {
|
|
6447
|
+
try { return await marketList(signal); } catch (err) { return { ok: false, enabled: false, entries: [], summary: friendlyReason(err) }; }
|
|
6448
|
+
},
|
|
6449
|
+
marketSearch: async (query, signal) => {
|
|
6450
|
+
try { return await marketSearch(query, signal); } catch (err) { return { ok: false, enabled: false, entries: [], summary: friendlyReason(err) }; }
|
|
6451
|
+
},
|
|
6452
|
+
marketInstall: async (options, signal) => {
|
|
6453
|
+
try { return await marketInstall(options || {}, signal); } catch (err) { return { ok: false, summary: friendlyReason(err) }; }
|
|
6454
|
+
},
|
|
6455
|
+
foreignSources: async () => {
|
|
6456
|
+
try { return await foreignSources(); } catch (err) { return { ok: false, sources: [], summary: friendlyReason(err) }; }
|
|
6457
|
+
},
|
|
6458
|
+
foreignPreview: async (options, signal) => {
|
|
6459
|
+
try { return await foreignPreview(options || {}, signal); } catch (err) { return { ok: false, summary: friendlyReason(err) }; }
|
|
6460
|
+
},
|
|
6461
|
+
foreignImport: async (options, signal) => {
|
|
6462
|
+
try { return await foreignImport(options || {}, signal); } catch (err) { return { ok: false, summary: friendlyReason(err) }; }
|
|
6463
|
+
},
|
|
6464
|
+
sessionRewindList: async (signal) => {
|
|
6465
|
+
try { return await sessionRewindList(signal); } catch (err) { return { ok: false, sessions: [], summary: friendlyReason(err) }; }
|
|
6466
|
+
},
|
|
6467
|
+
sessionRewindPreview: async (session, seq, signal) => {
|
|
6468
|
+
try { return await sessionRewindPreview(session, seq, signal); } catch (err) { return { ok: false, summary: friendlyReason(err) }; }
|
|
6469
|
+
},
|
|
6470
|
+
sessionRewindApply: async (options, signal) => {
|
|
6471
|
+
try { return await sessionRewindApply(options || {}, signal); } catch (err) { return { ok: false, summary: friendlyReason(err) }; }
|
|
6472
|
+
},
|
|
6473
|
+
chatSources: async () => {
|
|
6474
|
+
try { return await chatSources(); } catch (err) { return { ok: false, detected: false, sessions: [], summary: friendlyReason(err) }; }
|
|
6475
|
+
},
|
|
6476
|
+
chatPreview: async (options, signal) => {
|
|
6477
|
+
try { return await chatPreview(options || {}, signal); } catch (err) { return { ok: false, summary: friendlyReason(err) }; }
|
|
6478
|
+
},
|
|
6479
|
+
chatApply: async (options, signal) => {
|
|
6480
|
+
try { return await chatApply(options || {}, signal); } catch (err) { return { ok: false, summary: friendlyReason(err) }; }
|
|
6481
|
+
},
|
|
3632
6482
|
};
|
|
3633
6483
|
|
|
3634
6484
|
// 仅在装配了 Typert registry 的 profile(Web)里挂载面板服务;其余 profile 安静跳过。
|
|
@@ -3733,10 +6583,13 @@ export function apply(ctx, pluginConfig) {
|
|
|
3733
6583
|
// 再继续——命中社区"想试新版怕搞坏"的最大恐惧。首见列车只记录不拍。
|
|
3734
6584
|
void (async () => {
|
|
3735
6585
|
await waitForSettingsReady();
|
|
6586
|
+
// 0.14.0:旧默认目录改名(只动从未配置过 destination 的用户)
|
|
6587
|
+
await migrateLegacyDefaultDir();
|
|
3736
6588
|
await loadStoredGithubToken();
|
|
6589
|
+
await loadSyncChannels();
|
|
3737
6590
|
const h = await loadAutoState();
|
|
3738
|
-
|
|
3739
|
-
|
|
6591
|
+
autoHours = h;
|
|
6592
|
+
if (autoEnabled() && !autoDispose) {
|
|
3740
6593
|
scheduleAuto({ boot: true });
|
|
3741
6594
|
}
|
|
3742
6595
|
// 凭据哨兵:宿主首次读档会把扁平布局的 .credentials.yaml 原子替换成
|