@xiaoyuyu6420/dsh-backup 0.11.2 → 0.12.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/lib/index.js CHANGED
@@ -43,7 +43,7 @@
43
43
  */
44
44
  import { createHash, randomUUID } from 'node:crypto';
45
45
  import { createReadStream, readFileSync, realpathSync } from 'node:fs';
46
- import { mkdir, open, copyFile, readdir, readFile, rename, rm, stat as fsStat, unlink, writeFile } from 'node:fs/promises';
46
+ import { mkdir, open, copyFile, chmod, readdir, readFile, rename, rm, stat as fsStat, unlink, writeFile, link } from 'node:fs/promises';
47
47
  import { createRequire } from 'node:module';
48
48
  import { hostname } from 'node:os';
49
49
  import { basename, dirname, join } from 'node:path';
@@ -396,6 +396,8 @@ const PANEL_INVOCATIONS = Object.freeze([
396
396
  panelDescriptor('setGithubRepo', ['repo']),
397
397
  panelDescriptor('doctorScan', [], true),
398
398
  panelDescriptor('doctorRepair', ['selector'], true),
399
+ panelDescriptor('migrateCheck', [], true),
400
+ panelDescriptor('setGithubToken', ['token']),
399
401
  ]);
400
402
 
401
403
  /**
@@ -469,6 +471,16 @@ class BackupPanelService extends TypertRemoteService {
469
471
  doctorRepair(selector, signal) {
470
472
  return this.ops.doctorRepair(selector, signal);
471
473
  }
474
+
475
+ /** 迁移预检:只读扫描全部会话日志(含 v1/v2/v3 各代),预测升级后哪些会话打不开。 */
476
+ migrateCheck(signal) {
477
+ return this.ops.migrateCheck(signal);
478
+ }
479
+
480
+ /** 配置/清除 GitHub 同步 token(面板直配;本机存储,不进归档不进同步)。 */
481
+ setGithubToken(token) {
482
+ return this.ops.setGithubToken(token);
483
+ }
472
484
  }
473
485
 
474
486
  export function apply(ctx, pluginConfig) {
@@ -1089,12 +1101,75 @@ export function apply(ctx, pluginConfig) {
1089
1101
  : '');
1090
1102
  if (!raw) return null;
1091
1103
  const env = ctx.get('launchEnvironment');
1092
- const token = env?.get('DSH_BACKUP_GITHUB_TOKEN')?.value || env?.get('GITHUB_TOKEN')?.value;
1104
+ // token 来源优先级:面板/聊天配置(本机备份目录 github.token)> 环境变量。
1105
+ // 面板值存在本机、不进归档也不进同步,跨机需重填——与凭据脱敏同一哲学。
1106
+ const token = storedGithubToken
1107
+ || env?.get('DSH_BACKUP_GITHUB_TOKEN')?.value
1108
+ || env?.get('GITHUB_TOKEN')?.value;
1093
1109
  // 本地路径(测试/自托管)或 http(s) 全 URL 直接使用,否则视为 owner/repo。
1094
1110
  const repo = raw.includes('://') || /^[A-Za-z]:[\\/]|^\//.test(raw) ? raw : `https://github.com/${raw}.git`;
1095
1111
  return { repo: stripUserinfo(repo.split('\\').join('/')), token };
1096
1112
  }
1097
1113
 
1114
+ // ---------- GitHub token:面板/聊天直配(本机存储) ----------
1115
+ const GITHUB_TOKEN_FILE = 'github.token';
1116
+ let storedGithubToken = null;
1117
+
1118
+ /** 启动时从备份目录根读入面板配置的 token(0600 文件);缺失/不可读 = null。 */
1119
+ async function loadStoredGithubToken() {
1120
+ const { root } = paths();
1121
+ try {
1122
+ const t = (await readFile(`${root}/${GITHUB_TOKEN_FILE}`, 'utf8')).trim();
1123
+ storedGithubToken = t || null;
1124
+ } catch {
1125
+ storedGithubToken = null;
1126
+ }
1127
+ return storedGithubToken;
1128
+ }
1129
+
1130
+ /** token 形状校验:ghp_…/github_pat_…/40 位经典;拦住整段误粘贴的句子。非法返回原因。 */
1131
+ function validateGithubToken(raw) {
1132
+ const t = raw.trim();
1133
+ if (!t) return 'token 不能为空(清除请用清除按钮或 /backup github token off)';
1134
+ if (!/^[A-Za-z0-9_-]{20,255}$/.test(t)) return 'token 形状不对:应为 ghp_…/github_pat_… 或 40 位经典 token(字母数字与 _-,不含空格)';
1135
+ return null;
1136
+ }
1137
+
1138
+ /** 保存面板 token:备份目录根 github.token,独占创建 0600(同 vault 明文待遇)。 */
1139
+ async function writeStoredGithubToken(t) {
1140
+ const { root } = paths();
1141
+ const file = `${root}/${GITHUB_TOKEN_FILE}`;
1142
+ let fh;
1143
+ try {
1144
+ fh = await open(file, 'wx', 0o600);
1145
+ } catch (e) {
1146
+ if (!(e && e.code === 'EEXIST')) throw e;
1147
+ fh = await open(file, 'w', 0o600);
1148
+ if (!IS_WIN) await fh.chmod(0o600);
1149
+ }
1150
+ try {
1151
+ await fh.writeFile(`${t}\n`, 'utf8');
1152
+ } finally {
1153
+ await fh.close();
1154
+ }
1155
+ if (!IS_WIN) await chmod(file, 0o600).catch(() => {});
1156
+ storedGithubToken = t;
1157
+ }
1158
+
1159
+ /** 清除面板 token(文件删除;环境变量不受影响)。 */
1160
+ async function clearStoredGithubToken() {
1161
+ const { root } = paths();
1162
+ await rm(`${root}/${GITHUB_TOKEN_FILE}`, { force: true });
1163
+ storedGithubToken = null;
1164
+ }
1165
+
1166
+ /** 是否存在任何可用 token(面板存储或环境变量)——与是否配置仓库解耦,供状态徽标。 */
1167
+ function hasGithubToken() {
1168
+ if (storedGithubToken) return true;
1169
+ const env = ctx.get('launchEnvironment');
1170
+ return Boolean(env?.get('DSH_BACKUP_GITHUB_TOKEN')?.value || env?.get('GITHUB_TOKEN')?.value);
1171
+ }
1172
+
1098
1173
  /** 校验仓库地址格式(owner/repo、完整 URL 或本地路径),非法返回原因。 */
1099
1174
  function validateRepo(raw) {
1100
1175
  if (/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(raw)) return null;
@@ -1111,7 +1186,7 @@ export function apply(ctx, pluginConfig) {
1111
1186
  const cfg = githubConfig();
1112
1187
  if (!cfg) return { reason: '未配置 githubRepo(cordis.yml config.githubRepo)' };
1113
1188
  if (cfg.repo.startsWith('https://') && !cfg.token) {
1114
- return { reason: 'https 远端缺少 token(环境变量 DSH_BACKUP_GITHUB_TOKEN GITHUB_TOKEN' };
1189
+ return { reason: 'https 远端缺少 token:可在面板「GitHub Token」或 /backup github token <token> 配置,或环境变量 DSH_BACKUP_GITHUB_TOKEN / GITHUB_TOKEN' };
1115
1190
  }
1116
1191
  const syncDir = `${root}/${SYNC_DIR}`;
1117
1192
  const git = await ctx.subprocess.resolveExecutable('git');
@@ -1983,7 +2058,7 @@ export function apply(ctx, pluginConfig) {
1983
2058
  }
1984
2059
 
1985
2060
  /** 有界递归收集会话日志文件(相对 ~/.dsh 路径);跳过依赖/系统目录。 */
1986
- async function collectSessionLogs(dir, relPrefix, depth, out) {
2061
+ async function collectSessionLogs(dir, relPrefix, depth, out, names = SESSION_LOG_NAMES) {
1987
2062
  if (depth > SCAN_MAX_DEPTH || out.length >= SCAN_MAX_FILES) return;
1988
2063
  let dirents;
1989
2064
  try {
@@ -1996,8 +2071,8 @@ export function apply(ctx, pluginConfig) {
1996
2071
  const rel = relPrefix ? `${relPrefix}/${d.name}` : d.name;
1997
2072
  if (d.isDirectory()) {
1998
2073
  if (SCAN_SKIP_DIRS.has(d.name)) continue;
1999
- await collectSessionLogs(`${dir}/${d.name}`, rel, depth + 1, out);
2000
- } else if (SESSION_LOG_NAMES.has(d.name)) {
2074
+ await collectSessionLogs(`${dir}/${d.name}`, rel, depth + 1, out, names);
2075
+ } else if (names.has(d.name)) {
2001
2076
  out.push({ abs: `${dir}/${d.name}`, rel });
2002
2077
  }
2003
2078
  }
@@ -2071,6 +2146,238 @@ export function apply(ctx, pluginConfig) {
2071
2146
  return { scanned: files.length, files, corrupt, corruptCount: corrupt.length, skippedCount };
2072
2147
  }
2073
2148
 
2149
+ // ---------- 迁移预检(migrate-check) ----------
2150
+ //
2151
+ // 预测「宿主升级 / 旧会话被再次打开」后哪些会话会打不开。宿主的会话格式迁移
2152
+ // 是读时触发的纯函数:任何 open 都会跑迁移解码,写开再把旧代日志发布成 v3
2153
+ // 新代文件——旧代文件永不改写,POSIX 硬链接原子发布(exFAT 等无硬链接文件
2154
+ // 系统在这一步 ENOTSUP,官方讨论 #6358)。本预检把宿主
2155
+ // @deepseek-ai/dsh-session-format* 的冻结事件清单与静态拒绝规则烤入本地
2156
+ // (0.1.5-rc.2 校准,与 0.11.2 对齐 isHeaderLine 同一套路),覆盖"解析即判"
2157
+ // 的规则;需要重放迁移过程的规则(chunk 折叠溯源、引用重映射、worker 复核)
2158
+ // 不在覆盖范围,结果里如实标注。三层判定:fail=会话将打不开;migratable=
2159
+ // 旧代但静态规则全过(首次写开会发布 v3 新代);ok=已是当前代。
2160
+ const MIGRATE_CURRENT_VERSION = 3;
2161
+ /** 物理代文件名 ↔ 格式代(宿主 sessionFormatLogFilename;文件名与 header 不一致宿主拒载)。 */
2162
+ const MIGRATE_LOG_NAMES = [
2163
+ { name: 'session.jsonl', version: 0 },
2164
+ { name: 'session.v1.jsonl', version: 1 },
2165
+ { name: 'session.v2.jsonl', version: 2 },
2166
+ { name: 'session.v3.jsonl', version: 3 },
2167
+ ];
2168
+ const MIGRATE_LOG_SET = new Set(MIGRATE_LOG_NAMES.flatMap((g) => [g.name, `${g.name}.zstd`]));
2169
+ // 冻结事件清单(RELEASED_V0_EVENT_TYPES / RELEASED_V2_EVENT_TYPES,宿主
2170
+ // 按契约不增长旧代清单);migration 对清单外类型连 ignorable 也拒(#6355)。
2171
+ const MIGRATE_V0_EVENT_TYPES = new Set(['agent-preset/selected', 'agent/inbox/spliced', 'approval/asked', 'approval/decided', 'approval/policy', 'assistant/chunk', 'assistant/message', 'command/done', 'command/run', 'compaction/end', 'compaction/prune', 'compaction/start', 'compaction/summary', 'feedback/record', 'goal/change', 'hook/invoked', 'hook/result', 'llm/retry', 'llm/retry-started', 'model/selection', 'permission/preset', 'plan/mode', 'request/context', 'request/header', 'sandbox/mode', 'schedule/change', 'session-log-deepseek/delivery-accepted', 'session/end-seed', 'session/title', 'session/title-llm-request', 'step/end', 'step/start', 'subagent/descriptor', 'subagent/model-selection-policy', 'team/member', 'team/message/delivered', 'team/message/queued', 'team/task', 'todo/write', 'tool-workflow/agent-end', 'tool-workflow/agent-start', 'tool-workflow/run-end', 'tool-workflow/run-start', 'tool/call', 'tool/code-dispatch', 'tool/code-dispatch-start', 'tool/result', 'turn/end', 'turn/start', 'user/message', 'web/deepseek-search-llm-request']);
2172
+ const MIGRATE_V2_EVENT_TYPES = new Set(['agent-preset/selected', 'agent/inbox/spliced', 'approval/asked', 'approval/decided', 'approval/policy', 'assistant/attempt', 'assistant/message', 'command/done', 'command/run', 'compaction/end', 'compaction/prune', 'compaction/start', 'compaction/summary', 'feedback/record', 'goal/change', 'hook/invoked', 'hook/result', 'llm/retry', 'llm/retry-started', 'model/selection', 'permission/preset', 'plan/mode', 'request/context', 'request/header', 'sandbox/mode', 'schedule/change', 'session-log-deepseek/delivery-accepted', 'session/end-seed', 'session/title', 'session/title-llm-request', 'step/end', 'step/start', 'subagent/descriptor', 'subagent/model-selection-policy', 'team/member', 'team/message/delivered', 'team/message/queued', 'team/task', 'todo/write', 'tool-workflow/agent-end', 'tool-workflow/agent-start', 'tool-workflow/run-end', 'tool-workflow/run-start', 'tool/call', 'tool/code-dispatch', 'tool/code-dispatch-start', 'tool/result', 'turn/end', 'turn/start', 'user/message', 'web/deepseek-search-llm-request']);
2173
+ /** v0 侧退役类型(pre-react-loop 预发布形态;宿主点名拒绝)。 */
2174
+ const MIGRATE_V0_RETIRED_TYPES = new Set(['request/header-delta', 'mode/set']);
2175
+ /** 消息来源 kind 白名单(v3 侧 15 类;未知 kind 在 v2→v3 边被拒——#6355 主症状)。 */
2176
+ const MIGRATE_SOURCE_KINDS = new Set(['user', 'plugin', 'model', 'tool', 'agent-instructions', 'session-reference', 'team-message', 'goal', 'skill-invocation', 'skill-catalog', 'coordinator', 'subagent-report', 'subagent-settled', 'webhook', 'agent-message']);
2177
+ const MIGRATE_MAX_FINDINGS = 3;
2178
+
2179
+ /** 从物理文件名推格式代;非代文件名(未来扩展)返回 null。 */
2180
+ function migrateFilenameVersion(fileName) {
2181
+ const base = fileName.replace(/\.zstd$/, '');
2182
+ const g = MIGRATE_LOG_NAMES.find((x) => x.name === base);
2183
+ return g ? g.version : null;
2184
+ }
2185
+
2186
+ /**
2187
+ * 对一个解出的逻辑行流做迁移静态判定。header 解析失败返回 null(交给
2188
+ * doctor 的损坏判定,这里不重复报)。返回 { sourceVersion, findings, total }
2189
+ * ——findings 截断到前 MIGRATE_MAX_FINDINGS 条,total 是全量计数。
2190
+ */
2191
+ function migrateAnalyzeLines(lines, fileVersion) {
2192
+ let header;
2193
+ try {
2194
+ header = JSON.parse(lines[0]);
2195
+ } catch {
2196
+ return null;
2197
+ }
2198
+ if (!header || typeof header !== 'object' || typeof header.version !== 'number') return null;
2199
+ const sourceVersion = header.version;
2200
+ const findings = [];
2201
+ let total = 0;
2202
+ const fail = (rule, detail) => {
2203
+ total += 1;
2204
+ if (findings.length < MIGRATE_MAX_FINDINGS) findings.push({ rule, detail });
2205
+ };
2206
+ if (sourceVersion > MIGRATE_CURRENT_VERSION) {
2207
+ fail('newer-than-host', `header version=${sourceVersion} 高于本宿主可读的 v${MIGRATE_CURRENT_VERSION}——日志由更新版本的宿主写出,读/写都会被拒(提示语会要求升级宿主)`);
2208
+ return { sourceVersion, findings, total };
2209
+ }
2210
+ if (fileVersion !== null && fileVersion !== sourceVersion) {
2211
+ fail('name-version-mismatch', `文件名标 v${fileVersion},header 标 v${sourceVersion}——宿主按不一致直接拒载`);
2212
+ return { sourceVersion, findings, total };
2213
+ }
2214
+ // 事件行静态规则。seq 游标语义与 validateSessionLines 一致:v0 有 packed 行
2215
+ //(一条行多个 chunk),v1 起一行一事件。
2216
+ let nextSeq = 0;
2217
+ for (let i = 1; i < lines.length; i++) {
2218
+ let rec;
2219
+ try {
2220
+ rec = JSON.parse(lines[i]);
2221
+ } catch {
2222
+ fail('bad-json', `第 ${i + 1} 行不是合法 JSON(${snippet(String(lines[i]))})`);
2223
+ continue;
2224
+ }
2225
+ if (!rec || typeof rec !== 'object') continue;
2226
+ const type = typeof rec.type === 'string' ? rec.type : undefined;
2227
+ const isPacked = type === 'text-chunks' || type === 'reasoning-chunks' || type === 'tool-call-chunks';
2228
+ // seq 密度:宿主要求 seq 与位置严格一致(手工改号不同步引用即打不开——#6348 根因)
2229
+ if (isPacked) {
2230
+ const members = Array.isArray(rec.data?.texts) ? rec.data.texts.length : Array.isArray(rec.data?.args) ? rec.data.args.length : 0;
2231
+ nextSeq += Math.max(members, 0);
2232
+ } else if (typeof rec.seq === 'number') {
2233
+ if (rec.seq !== nextSeq) fail('seq-gap', `第 ${i + 1} 行 seq=${rec.seq},期望 ${nextSeq}——seq 与位置不一致,宿主判 non-dense seq 拒载`);
2234
+ nextSeq = rec.seq + 1;
2235
+ }
2236
+ if (sourceVersion === 0 && type) {
2237
+ if (MIGRATE_V0_RETIRED_TYPES.has(type)) fail('retired-type', `第 ${i + 1} 行 ${type} 是预发布退役类型,迁移点名拒绝`);
2238
+ else if (!MIGRATE_V0_EVENT_TYPES.has(type)) fail('unknown-type-v0', `第 ${i + 1} 行事件类型 "${type}" 不在 v0 冻结清单——迁移连 ignorable 也拒(第三方插件注入的历史事件,#6297/#6355)`);
2239
+ if (type === 'request/header' && rec.data?.reason === 'fallback') fail('request-fallback', `第 ${i + 1} 行 request/header reason="fallback" 是预发布遗留,迁移拒绝`);
2240
+ if (type === 'subagent/descriptor' && rec.data?.version !== 3) fail('descriptor-version', `第 ${i + 1} 行 subagent/descriptor version=${JSON.stringify(rec.data?.version)}——迁移只认 3(0.1.0/0.1.1 写的是 2,#6297/#6045 主症状)`);
2241
+ if (type === 'permission/preset') {
2242
+ const keys = Object.keys(rec.data ?? {});
2243
+ if (!keys.every((k) => k === 'preset') || typeof rec.data?.preset !== 'string' || !rec.data.preset) fail('permission-preset-members', `第 ${i + 1} 行 permission/preset data 含 preset 之外的成员(如 origin)或 preset 非非空字符串——0.1.1 起写入 origin 的历史日志,迁移拒绝(#6297)`);
2244
+ }
2245
+ }
2246
+ if (sourceVersion === 2 && type && !MIGRATE_V2_EVENT_TYPES.has(type)) fail('unknown-type-v2', `第 ${i + 1} 行事件类型 "${type}" 不在 v2 冻结清单——v2→v3 无法安全变换(ignorable 也拒)`);
2247
+ if (sourceVersion === 2 && (type === 'tool/code-dispatch' || type === 'tool/code-dispatch-start')) fail('ptc-reserved-v2', `第 ${i + 1} 行 ${type} 是 v3 保留 PTC 标签,v2→v3 拒绝(ignorable 也拒)`);
2248
+ if ((sourceVersion === 2 || sourceVersion === 3) && type === 'request/header' && rec.data?.header?.system !== undefined) fail('retired-header-system', `第 ${i + 1} 行 request/header 携带退役的 header.system——v2→v3 无法保留时序地提升它${sourceVersion === 3 ? ',当前代读取同样拒绝' : ''}`);
2249
+ const kind = rec?.data?.source?.kind;
2250
+ if (typeof kind === 'string' && !MIGRATE_SOURCE_KINDS.has(kind)) fail('unknown-source-kind', `第 ${i + 1} 行消息来源 kind="${kind}" 不在白名单——v2→v3 边拒绝(社区插件自定义 kind,#6355)`);
2251
+ }
2252
+ return { sourceVersion, findings, total };
2253
+ }
2254
+
2255
+ /**
2256
+ * 单文件迁移预检:解码(复用 doctor 的帧走查)→ 判代 → 静态规则。
2257
+ * 返回 { verdict: 'fail'|'migratable'|'ok'|'skipped', version, findings, total, reason }。
2258
+ */
2259
+ async function migrateAnalyzeFile(f) {
2260
+ const fileVersion = migrateFilenameVersion(f.abs.split('/').pop());
2261
+ try {
2262
+ const info = await fsStat(f.abs);
2263
+ if (info.size === 0) return { verdict: 'fail', version: fileVersion, findings: [{ rule: 'empty', detail: '空文件(0 字节)' }], total: 1 };
2264
+ if (f.abs.endsWith('.zstd') && !HAS_NODE_ZSTD) return { verdict: 'skipped', version: fileVersion, findings: [], total: 0, reason: `运行时无内置 zstd 解码(需 Node ≥22.15/23.8),跳过预检` };
2265
+ if (info.size > HASH_MAX_BYTES) return { verdict: 'skipped', version: fileVersion, findings: [], total: 0, reason: '文件超过读入上限,未预检' };
2266
+ let lines;
2267
+ if (f.abs.endsWith('.zstd')) ({ lines } = decodeZstdLog(await readFile(f.abs)));
2268
+ else lines = (await readFile(f.abs, 'utf8')).split('\n').filter((l) => l.length > 0);
2269
+ if (!lines.length) return { verdict: 'fail', version: fileVersion, findings: [{ rule: 'empty', detail: '解出的逻辑行为空' }], total: 1 };
2270
+ const r = migrateAnalyzeLines(lines, fileVersion);
2271
+ if (!r) return { verdict: 'skipped', version: fileVersion, findings: [], total: 0, reason: 'header 行不可解析——按损坏处理(doctor 体检覆盖),迁移预检跳过' };
2272
+ if (r.total > 0) return { verdict: 'fail', version: r.sourceVersion, findings: r.findings, total: r.total };
2273
+ return { verdict: r.sourceVersion < MIGRATE_CURRENT_VERSION ? 'migratable' : 'ok', version: r.sourceVersion, findings: [], total: 0 };
2274
+ } catch (err) {
2275
+ return { verdict: 'skipped', version: fileVersion, findings: [], total: 0, reason: `解码失败(${String(err && err.message ? err.message : err)})——doctor 体检会按损坏处理` };
2276
+ }
2277
+ }
2278
+
2279
+ /** .credentials.yaml 是否还是扁平布局(顶层无 version 键)——宿主首次读档会原子替换成 version:1 且不可逆(#6358:旧宿主读不回 versioned)。 */
2280
+ async function credentialsIsFlatLayout(dshHome) {
2281
+ let text;
2282
+ try {
2283
+ text = await readFile(`${dshHome}/.credentials.yaml`, 'utf8');
2284
+ } catch {
2285
+ return false; // 不存在/读不了 → 无事可做
2286
+ }
2287
+ if (!text.trim()) return false;
2288
+ return !/^version\s*:/m.test(text) && !/^"version"\s*:/m.test(text);
2289
+ }
2290
+
2291
+ /** 凭据哨兵:把扁平布局的凭据文件存进 vault/preserved(保留最近 3 份),返回存档路径。 */
2292
+ async function preserveFlatCredentials() {
2293
+ const { dshHome, root } = paths();
2294
+ if (!(await credentialsIsFlatLayout(dshHome))) return null;
2295
+ const dir = `${root}/${VAULT_DIR}/preserved`;
2296
+ await mkdir(dir, { recursive: true });
2297
+ const dest = `${dir}/credentials-flat-${new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)}.yaml`;
2298
+ await copyFile(`${dshHome}/.credentials.yaml`, dest);
2299
+ // 轮换:只留最近 3 份(按文件名时间戳排序即时间序)
2300
+ const old = (await readdir(dir)).filter((n) => n.startsWith('credentials-flat-')).sort();
2301
+ for (const n of old.slice(0, Math.max(old.length - 3, 0))) await rm(`${dir}/${n}`, { force: true });
2302
+ return dest;
2303
+ }
2304
+
2305
+ /** 环境级探测:硬链接支持(v3 发布用 fs.link,exFAT 等会 ENOTSUP)+ 凭据布局。 */
2306
+ async function migrateProbeEnvironment() {
2307
+ const { dshHome, root } = paths();
2308
+ const env = { hardlink: true, hardlinkNote: null, credentialsFlat: false, credentialsPreserved: null };
2309
+ const probe = `${dshHome}/.dsh-backup-migrate-probe-${process.pid}`;
2310
+ try {
2311
+ await writeFile(probe, 'dsh-backup link probe');
2312
+ try {
2313
+ await link(probe, `${probe}.lnk`);
2314
+ } finally {
2315
+ await rm(`${probe}.lnk`, { force: true });
2316
+ await rm(probe, { force: true });
2317
+ }
2318
+ } catch (err) {
2319
+ env.hardlink = false;
2320
+ env.hardlinkNote = String(err && err.code ? err.code : err);
2321
+ }
2322
+ env.credentialsFlat = await credentialsIsFlatLayout(dshHome);
2323
+ if (env.credentialsFlat) {
2324
+ try {
2325
+ env.credentialsPreserved = await preserveFlatCredentials();
2326
+ } catch { /* 哨兵失败不阻塞预检:vault 不可写等,摘要里仍会提示 */ }
2327
+ }
2328
+ return env;
2329
+ }
2330
+
2331
+ /** 只读扫描:全部代文件迁移静态预检 + 环境探测;不写会话文件。 */
2332
+ async function runMigrateCheck(signal) {
2333
+ const { dshHome, root } = paths();
2334
+ if (signal?.aborted) throw new Error('操作已取消');
2335
+ const found = [];
2336
+ await collectSessionLogs(dshHome, '', 0, found, MIGRATE_LOG_SET);
2337
+ const sessions = [];
2338
+ for (const f of found) {
2339
+ if (signal?.aborted) throw new Error('操作已取消');
2340
+ sessions.push({ rel: f.rel, ...(await migrateAnalyzeFile(f)) });
2341
+ }
2342
+ const env = await migrateProbeEnvironment();
2343
+ const fails = sessions.filter((s) => s.verdict === 'fail');
2344
+ const migratable = sessions.filter((s) => s.verdict === 'migratable');
2345
+ const skipped = sessions.filter((s) => s.verdict === 'skipped');
2346
+ const lines = [];
2347
+ lines.push(`扫描 ${sessions.length} 个会话日志(含 v1/v2/v3 代):${fails.length} 个升级后将打不开、${migratable.length} 个旧代待迁移(静态规则全过)、${skipped.length} 个未能判定`);
2348
+ if (fails.length) {
2349
+ lines.push('');
2350
+ for (const s of fails.slice(0, 15)) {
2351
+ lines.push(` ❌ ${s.rel}(v${s.version})`);
2352
+ for (const fd of s.findings) lines.push(` · [${fd.rule}] ${fd.detail}`);
2353
+ if (s.total > s.findings.length) lines.push(` …另有 ${s.total - s.findings.length} 处问题`);
2354
+ }
2355
+ if (fails.length > 15) lines.push(` …另有 ${fails.length - 15} 个文件`);
2356
+ lines.push('');
2357
+ lines.push('升级前建议:先拍一份升级前快照(/backup),坏的会话可从更早归档定点修复(/backup doctor --repair)。');
2358
+ }
2359
+ if (!env.hardlink) {
2360
+ lines.push('');
2361
+ lines.push(`⚠️ 文件系统不支持硬链接(${env.hardlinkNote})——宿主发布 v3 新代文件会失败(官方 #6358 场景):旧会话读得了、写开就报错,且升级无法继续。建议把 ~/.dsh 挪回 APFS/ext4 等本地文件系统。`);
2362
+ }
2363
+ if (env.credentialsFlat) {
2364
+ lines.push('');
2365
+ lines.push(`⚠️ .credentials.yaml 还是旧扁平布局——宿主下次读档会把它原子替换成 version:1(不可逆,旧宿主将读不回)。${env.credentialsPreserved ? `已自动存底:${env.credentialsPreserved.replace(`${root}/`, '')}` : '自动存底失败(vault 不可写?),建议立即手动 /backup 一次。'}`);
2366
+ }
2367
+ lines.push('');
2368
+ lines.push(`覆盖说明:以上为静态规则判定(宿主 ${detectHostTrain() || '未知列车'} 校准)。需要重放迁移过程的检查(chunk 折叠溯源、引用重映射、发布前 worker 复核)不在本版覆盖内——静态全过仍可能在新代发布时被宿主拒载。`);
2369
+ const head = fails.length || !env.hardlink || env.credentialsFlat ? '⚠️' : '✅';
2370
+ return {
2371
+ scanned: sessions.length,
2372
+ sessions: sessions.map((s) => ({ rel: s.rel, verdict: s.verdict, version: s.version, total: s.total, findings: s.findings, ...(s.reason ? { reason: s.reason } : {}) })),
2373
+ env,
2374
+ failCount: fails.length,
2375
+ migratableCount: migratable.length,
2376
+ skippedCount: skipped.length,
2377
+ summary: `${head} ${lines.join('\n')}`,
2378
+ };
2379
+ }
2380
+
2074
2381
  /**
2075
2382
  * 定点修复:扫描出损坏的会话日志后,从指定归档(缺省最新一份,须通过
2076
2383
  * sha256 校验)里提取同名条目覆盖。只动损坏的文件本身——不做整库 aside
@@ -2293,7 +2600,7 @@ export function apply(ctx, pluginConfig) {
2293
2600
  // ---------- /backup 命令 ----------
2294
2601
  ctx.commands.register({
2295
2602
  name: 'backup',
2296
- description: '备份/恢复 DSH 数据;子命令: list | verify [前缀|all] | restore <前缀|latest> [--dry-run] [--sync-deps] | doctor [--repair <前缀|latest>] | auto [N小时|off] | github status|sync|pull|repo | [--keep N]',
2603
+ 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> | [--keep N]',
2297
2604
  handler: async (invocation) => {
2298
2605
  const input = invocation.rawInput.trim();
2299
2606
  try {
@@ -2352,6 +2659,11 @@ export function apply(ctx, pluginConfig) {
2352
2659
  return { kind: 'success', text: r.summary };
2353
2660
  }
2354
2661
 
2662
+ if (head === 'migrate-check' || head === 'migrate') {
2663
+ const r = await runMigrateCheck(invocation.signal);
2664
+ return { kind: r.failCount || !r.env.hardlink || r.env.credentialsFlat ? 'error' : 'success', text: r.summary };
2665
+ }
2666
+
2355
2667
  if (head === 'github') {
2356
2668
  const arg = parts[1] || 'status';
2357
2669
  if (arg === 'repo') {
@@ -2368,6 +2680,18 @@ export function apply(ctx, pluginConfig) {
2368
2680
  await saveAutoState();
2369
2681
  return { kind: 'success', text: `GitHub 同步仓库已设为: ${clean}\n${autoSummary()}` };
2370
2682
  }
2683
+ if (arg === 'token') {
2684
+ // 面板同款:存本机备份目录 github.token(0600);off 清除。任何回执不回显 token。
2685
+ const value = parts.slice(2).join(' ').trim();
2686
+ if (!value || value === 'off') {
2687
+ await clearStoredGithubToken();
2688
+ return { kind: 'success', text: `GitHub token 已清除。${githubConfig()?.token ? '环境变量 token 仍在生效。' : ''}` };
2689
+ }
2690
+ const invalid = validateGithubToken(value);
2691
+ if (invalid) return { kind: 'error', text: invalid };
2692
+ await writeStoredGithubToken(value);
2693
+ return { kind: 'success', text: 'GitHub token 已保存:存在本机备份目录(0600),不进归档、不进 GitHub 同步。' };
2694
+ }
2371
2695
  if (arg === 'sync') {
2372
2696
  const s = await githubSync(invocation.signal);
2373
2697
  if (s.pushed) {
@@ -2452,9 +2776,9 @@ export function apply(ctx, pluginConfig) {
2452
2776
  // ---------- backup_dsh 模型工具 ----------
2453
2777
  ctx.tools.register(defineTool({
2454
2778
  name: 'backup_dsh',
2455
- description: '备份、校验、恢复或体检 DSH 用户数据(~/.dsh 的会话、配置、技能)。凭据文件默认脱敏:不进归档、不进 GitHub 同步,明文只存本机 vault,恢复时自动还原。mode=backup 立即备份(keep 指定保留份数);mode=list 列出备份;mode=verify 校验完整性(selector=前缀或 all,缺省最新一份);mode=restore 恢复(selector=前缀或 latest,syncDeps 恢复后重装 profile 依赖;恢复前自动校验并快照当前数据,解压失败自动还原;真实恢复前必须先 dryRun=true 预览并向用户展示确认);mode=auto 设置定时备份(hours 间隔小时数,0=关闭,缺省查询);mode=doctor 会话日志体检——检测损坏的会话历史(seq 撞号/坏帧/截断),repair=true 时从 selector 归档定点修复',
2779
+ description: '备份、校验、恢复或体检 DSH 用户数据(~/.dsh 的会话、配置、技能)。凭据文件默认脱敏:不进归档、不进 GitHub 同步,明文只存本机 vault,恢复时自动还原。mode=backup 立即备份(keep 指定保留份数);mode=list 列出备份;mode=verify 校验完整性(selector=前缀或 all,缺省最新一份);mode=restore 恢复(selector=前缀或 latest,syncDeps 恢复后重装 profile 依赖;恢复前自动校验并快照当前数据,解压失败自动还原;真实恢复前必须先 dryRun=true 预览并向用户展示确认);mode=auto 设置定时备份(hours 间隔小时数,0=关闭,缺省查询);mode=doctor 会话日志体检——检测损坏的会话历史(seq 撞号/坏帧/截断),repair=true 时从 selector 归档定点修复;mode=migrate 迁移预检——静态扫描全部代会话日志(含 v1/v2/v3 代),预测宿主升级后哪些会话会打不开、挂在哪条规则,并探测文件系统硬链接支持与凭据文件旧布局',
2456
2780
  parameters: {
2457
- mode: { type: 'string', required: true, enum: ['backup', 'list', 'verify', 'restore', 'auto', 'doctor'], description: 'backup=执行备份,list=列出备份,verify=校验完整性,restore=恢复,auto=定时备份,doctor=会话日志体检' },
2781
+ mode: { type: 'string', required: true, enum: ['backup', 'list', 'verify', 'restore', 'auto', 'doctor', 'migrate'], description: 'backup=执行备份,list=列出备份,verify=校验完整性,restore=恢复,auto=定时备份,doctor=会话日志体检,migrate=升级迁移预检' },
2458
2782
  keep: { type: 'number', description: '保留的备份份数(mode=backup,默认 7)' },
2459
2783
  hours: { type: 'number', description: '定时备份间隔小时数(mode=auto;0=关闭;缺省=查询状态)' },
2460
2784
  selector: { type: 'string', description: '备份选择器(mode=verify/restore/doctor):归档名前缀、latest 或 all' },
@@ -2509,6 +2833,19 @@ export function apply(ctx, pluginConfig) {
2509
2833
  corrupt: r.corrupt.map((c) => ({ path: c.rel, reason: c.reason })),
2510
2834
  };
2511
2835
  }
2836
+ if (mode === 'migrate') {
2837
+ const r = await runMigrateCheck(signal);
2838
+ return {
2839
+ ok: r.failCount === 0 && r.env.hardlink && !r.env.credentialsFlat,
2840
+ scanned: r.scanned,
2841
+ failCount: r.failCount,
2842
+ migratableCount: r.migratableCount,
2843
+ skippedCount: r.skippedCount,
2844
+ summary: r.summary,
2845
+ sessions: r.sessions.filter((s) => s.verdict === 'fail').map((s) => ({ path: s.rel, version: s.version, findings: s.findings })),
2846
+ env: r.env,
2847
+ };
2848
+ }
2512
2849
  if (mode === 'auto') {
2513
2850
  const h = args && args.hours !== undefined ? args.hours : null;
2514
2851
  if (h === null) return { ok: true, summary: autoSummary() };
@@ -2629,7 +2966,7 @@ export function apply(ctx, pluginConfig) {
2629
2966
  // 两路都经 stripUserinfo:避免 cordis.yml config.githubRepo 内嵌 token 时经面板泄露
2630
2967
  repoRaw: githubState.repo ?? (typeof resolved.githubRepo === 'string' ? stripUserinfo(resolved.githubRepo) : null),
2631
2968
  repo: cfg ? cfg.repo : null,
2632
- tokenSet: Boolean(cfg?.token),
2969
+ tokenSet: hasGithubToken(),
2633
2970
  syncDir: `${root}/${SYNC_DIR}`,
2634
2971
  lastPush: githubState.lastPush,
2635
2972
  lastError: githubState.lastError,
@@ -2737,6 +3074,25 @@ export function apply(ctx, pluginConfig) {
2737
3074
  return { ok: false, summary: String(err && err.message ? err.message : err), repaired: [], unrecoverable: [], stillBad: [] };
2738
3075
  }
2739
3076
  },
3077
+ migrateCheck: async (signal) => {
3078
+ try {
3079
+ const r = await runMigrateCheck(signal);
3080
+ return { ok: r.failCount === 0 && r.env.hardlink && !r.env.credentialsFlat, ...r };
3081
+ } catch (err) {
3082
+ return { ok: false, summary: String(err && err.message ? err.message : err), sessions: [], scanned: 0, failCount: 0, migratableCount: 0, skippedCount: 0, env: {} };
3083
+ }
3084
+ },
3085
+ setGithubToken: async (token) => {
3086
+ const raw = typeof token === 'string' ? token.trim() : '';
3087
+ if (!raw || raw === 'off') {
3088
+ await clearStoredGithubToken();
3089
+ return { ok: true, tokenSet: hasGithubToken(), summary: `GitHub token 已清除。${hasGithubToken() ? '环境变量 token 仍在生效。' : ''}` };
3090
+ }
3091
+ const invalid = validateGithubToken(raw);
3092
+ if (invalid) return { ok: false, tokenSet: hasGithubToken(), summary: invalid };
3093
+ await writeStoredGithubToken(raw);
3094
+ return { ok: true, tokenSet: true, summary: 'GitHub token 已保存:存在本机备份目录(0600),不进归档、不进 GitHub 同步。' };
3095
+ },
2740
3096
  };
2741
3097
 
2742
3098
  // 仅在装配了 Typert registry 的 profile(Web)里挂载面板服务;其余 profile 安静跳过。
@@ -2826,11 +3182,21 @@ export function apply(ctx, pluginConfig) {
2826
3182
  // 再继续——命中社区"想试新版怕搞坏"的最大恐惧。首见列车只记录不拍。
2827
3183
  void (async () => {
2828
3184
  await waitForSettingsReady();
3185
+ await loadStoredGithubToken();
2829
3186
  const h = await loadAutoState();
2830
3187
  if (h > 0 && !autoDispose) {
2831
3188
  autoHours = h;
2832
3189
  scheduleAuto();
2833
3190
  }
3191
+ // 凭据哨兵:宿主首次读档会把扁平布局的 .credentials.yaml 原子替换成
3192
+ // version:1(不可逆——旧宿主读不回 versioned,官方 #6358)。启动即检测,
3193
+ // 是扁平就先存一份进 vault/preserved,给「升级失败想回退」留后路。
3194
+ try {
3195
+ const kept = await preserveFlatCredentials();
3196
+ if (kept) console.log(`[dsh-backup] 检测到旧版扁平布局凭据文件,已先存底再让宿主迁移: ${kept}`);
3197
+ } catch (err) {
3198
+ console.warn(`[dsh-backup] 凭据存底失败(不影响运行): ${String(err && err.message ? err.message : err)}`);
3199
+ }
2834
3200
  const train = detectHostTrain();
2835
3201
  if (!train) return;
2836
3202
  if (lastTrain === null) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xiaoyuyu6420/dsh-backup",
3
- "version": "0.11.2",
3
+ "version": "0.12.0",
4
4
  "description": "Backup, restore, download and GitHub-sync DeepSeek Harness user data (~/.dsh): /backup, scheduled auto-backup that survives restarts, sha256 checksums, integrity verify, rotation, credential redaction with a local vault (plaintext never leaves the machine), cross-machine restore preflight with github pull, and a visual Settings panel. Cross-platform (macOS/Linux/Windows). 一键备份与恢复 DSH 数据:定时自动备份、完整性校验、凭据默认脱敏(明文只存本机 vault)、跨机恢复预检与 github pull 拉取,附 Settings 可视面板。",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@11.24.0",
@@ -53,12 +53,12 @@
53
53
  ],
54
54
  "peerDependencies": {
55
55
  "@deepseek-ai/cordis": "^4.0.1",
56
- "@deepseek-ai/dsh-commands": "^0.1.1-rc.2 || ^0.1.2-rc.1",
57
- "@deepseek-ai/dsh-fs": "^0.1.1-rc.2 || ^0.1.2-rc.1",
58
- "@deepseek-ai/dsh-settings": "^0.1.1-rc.2 || ^0.1.2-rc.1",
59
- "@deepseek-ai/dsh-subprocess": "^0.1.1-rc.2 || ^0.1.2-rc.1",
60
- "@deepseek-ai/dsh-tools": "^0.1.1-rc.2 || ^0.1.2-rc.1",
61
- "@deepseek-ai/dsh-typert-protocol": "^0.1.1-rc.2 || ^0.1.2-rc.1",
56
+ "@deepseek-ai/dsh-commands": "^0.1.1-rc.2 || ^0.1.2-rc.1 || ^0.1.5-rc.1",
57
+ "@deepseek-ai/dsh-fs": "^0.1.1-rc.2 || ^0.1.2-rc.1 || ^0.1.5-rc.1",
58
+ "@deepseek-ai/dsh-settings": "^0.1.1-rc.2 || ^0.1.2-rc.1 || ^0.1.5-rc.1",
59
+ "@deepseek-ai/dsh-subprocess": "^0.1.1-rc.2 || ^0.1.2-rc.1 || ^0.1.5-rc.1",
60
+ "@deepseek-ai/dsh-tools": "^0.1.1-rc.2 || ^0.1.2-rc.1 || ^0.1.5-rc.1",
61
+ "@deepseek-ai/dsh-typert-protocol": "^0.1.1-rc.2 || ^0.1.2-rc.1 || ^0.1.5-rc.1",
62
62
  "@deepseek-ai/schemastery": "^3.18.1"
63
63
  },
64
64
  "devDependencies": {
@@ -68,7 +68,8 @@
68
68
  "zod": "^4.4.3"
69
69
  },
70
70
  "engines": {
71
- "node": ">=20"
71
+ "node": ">=20",
72
+ "dsh": "^0.1.1-rc.2 || ^0.1.2-rc.1 || ^0.1.5-rc.1"
72
73
  },
73
74
  "license": "MIT",
74
75
  "repository": {