dsh-config-manager 0.1.48 → 0.1.49

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.
@@ -21,8 +21,8 @@ import { randomBytes } from 'node:crypto'
21
21
  import { zhMsg } from './messages.ts'
22
22
  import type { MsgFunc } from './messages.ts'
23
23
 
24
- /** run 类型:导出 / 导入 / 自动同步 / 一键同步逐项应用 / 定时备份。 */
25
- export type RunKind = 'export' | 'import' | 'autosync' | 'sync-apply' | 'backup-schedule'
24
+ /** run 类型:导出 / 导入 / 自动同步 / 一键同步逐项应用 / 定时备份 / 快照恢复。 */
25
+ export type RunKind = 'export' | 'import' | 'autosync' | 'sync-apply' | 'backup-schedule' | 'restore'
26
26
 
27
27
  /** run 状态:进行中 / 完成 / 失败。 */
28
28
  export type RunStatus = 'running' | 'done' | 'failed'
@@ -71,7 +71,9 @@ export class RunConflictError extends Error {
71
71
  ? '一键同步已在进行中'
72
72
  : kind === 'backup-schedule'
73
73
  ? '定时备份已在进行中'
74
- : msg('run.conflict.import', { runId }),
74
+ : kind === 'restore'
75
+ ? '快照恢复已在进行中(请等待当前恢复完成)'
76
+ : msg('run.conflict.import', { runId }),
75
77
  )
76
78
  this.name = 'RunConflictError'
77
79
  this.runId = runId
@@ -142,17 +144,22 @@ export class RunRegistry {
142
144
  return { ...run }
143
145
  }
144
146
 
145
- /** 追加一行执行日志(导入逐计划项操作 / 子进程命令);run 不存在返回 undefined,
147
+ /**
148
+ * 追加一行执行日志(导入逐计划项操作 / 子进程命令);run 不存在返回 undefined,
146
149
  * 已完成/失败后的晚到追加被忽略(防御异步回调竞态)。行数封顶 MAX_RUN_LOG_LINES,
147
- * 超限时截断保留最新行。 */
150
+ * 超限时截断保留最新行。
151
+ *
152
+ * **不可变写入(性能正确性关键)**:每次 append 生成新数组(push + splice 会原地
153
+ * mutation —— /progress 轮询返回的 RunState.log 与浏览器侧 store 共享同一引用,
154
+ * 行数封顶后长度恒定,React memo 按引用/长度比较将无法感知新行,日志面板冻结)。
155
+ * 调用方(mapRunProgress → ImportLogPanel)据此以「数组引用变化」判断有新输出。
156
+ */
148
157
  appendLog(runId: string, line: string): RunState | undefined {
149
158
  const run = this.runs.get(runId)
150
159
  if (run === undefined) return undefined
151
160
  if (run.status !== 'running') return { ...run }
152
- run.log.push(line)
153
- if (run.log.length > MAX_RUN_LOG_LINES) {
154
- run.log.splice(0, run.log.length - MAX_RUN_LOG_LINES)
155
- }
161
+ // 不可变追加:保留最新 MAX_RUN_LOG_LINES 行(slice(-(MAX-1)) 在长度不足时取全量)
162
+ run.log = [...run.log.slice(-(MAX_RUN_LOG_LINES - 1)), line]
156
163
  run.updatedAt = this.now()
157
164
  return { ...run }
158
165
  }
package/src/index.ts CHANGED
@@ -141,7 +141,7 @@ export const name = 'config-manager'
141
141
  export const inject = ['settings', 'credentials']
142
142
 
143
143
  /** Plugin version, kept in sync with package.json ("version"). */
144
- const PLUGIN_VERSION = '0.1.48'
144
+ const PLUGIN_VERSION = '0.1.49'
145
145
 
146
146
  /** Plugin own package name — excluded from its own exported plugins list. */
147
147
  const PLUGIN_NAME = 'dsh-config-manager'
@@ -1234,10 +1234,13 @@ export interface RestoreExecutor {
1234
1234
  /**
1235
1235
  * 按计划执行恢复动作(纯执行器;逐项 try/catch 不拖垮其余),
1236
1236
  * 返回与 CLI 一致的诚实报告。顺序 = 计划顺序(整文件 → 插件 → file 补偿)。
1237
+ * @param onAction - 每项动作执行回调(宿主路由埋点:更新 RunRegistry 进度;
1238
+ * index/1-based、total=计划动作数、detail=动作描述)
1237
1239
  */
1238
1240
  export async function executeRestorePlan(
1239
1241
  plan: RestorePlan,
1240
1242
  exec: RestoreExecutor,
1243
+ onAction?: (info: { index: number; total: number; detail: string }) => void,
1241
1244
  ): Promise<RestoreReport> {
1242
1245
  const report: RestoreReport = {
1243
1246
  snapshotId: plan.snapshotId,
@@ -1247,7 +1250,11 @@ export async function executeRestorePlan(
1247
1250
  failed: [],
1248
1251
  skipped: [],
1249
1252
  }
1253
+ const total = plan.actions.length
1254
+ let index = 0
1250
1255
  for (const action of plan.actions) {
1256
+ index += 1
1257
+ onAction?.({ index, total, detail: action.description })
1251
1258
  try {
1252
1259
  switch (action.kind) {
1253
1260
  case 'hostFileRestore':
@@ -2096,6 +2103,13 @@ function makeRoutes(deps: RoutesDeps): { routes: WebRoute[]; scheduler: AutoSync
2096
2103
  // M4:快照恢复。dryRun=true 只返回动作计划(planRestore,零写入);
2097
2104
  // 真实执行 = 计划 → 宿主执行器(ctx.fs 整文件/文件还原 + runDshPlugin 卸载插件)
2098
2105
  // → 与 CLI 一致的诚实报告 { restored/removedPlugins/manualHints/failed/skipped }。
2106
+ //
2107
+ // **并发防护(P1-1)**:真实执行(dryRun=false)经 runs.register('restore') 登记——
2108
+ // 同 kind 已有 running 时抛 RunConflictError → 409 拒绝。这是宿主侧的权威防重
2109
+ // (前端 loading 只是 UX):即使两个 tab / 刷新后重复点击,同一时刻至多一个
2110
+ // restore 在执行(不同快照并发恢复会交错写文件,同快照并发会互相覆盖
2111
+ // pre-restore 双保险备份,都是真实数据风险)。进度经 onAction 埋点更新
2112
+ // RunRegistry(/progress 轮询 + /runs 刷新恢复可见);响应含 runId。
2099
2113
  {
2100
2114
  kind: 'exact',
2101
2115
  path: API.restore,
@@ -2118,12 +2132,41 @@ function makeRoutes(deps: RoutesDeps): { routes: WebRoute[]; scheduler: AutoSync
2118
2132
  }
2119
2133
  try {
2120
2134
  if (dryRun) {
2135
+ // dry-run 零写入、只读探测:不登记 run(并发 dry-run 无害)
2121
2136
  writeJson(res, 200, { dryRun: true, plan: await planRestore(restoreOpts) })
2122
2137
  return
2123
2138
  }
2124
- const plan = await planRestore(restoreOpts)
2125
- const report = await executeRestorePlan(plan, makeRestoreExecutor(snapshotDir, host, host.profile))
2126
- writeJson(res, 200, { dryRun: false, report })
2139
+ // 真实执行:先登记 run(同 kind running → 409 拒绝重复恢复)
2140
+ let run: RunState
2141
+ try {
2142
+ run = runs.register('restore')
2143
+ } catch (error) {
2144
+ writeJson(res, 409, { error: error instanceof Error ? error.message : String(error) })
2145
+ return
2146
+ }
2147
+ const runId = run.runId
2148
+ try {
2149
+ const plan = await planRestore(restoreOpts)
2150
+ const report = await executeRestorePlan(
2151
+ plan,
2152
+ makeRestoreExecutor(snapshotDir, host, host.profile),
2153
+ // m1 埋点:每执行一个恢复动作实时更新 run 状态(/progress 轮询可见)
2154
+ (info) => {
2155
+ runs.update(runId, {
2156
+ section: 'restore',
2157
+ item: info.index,
2158
+ itemTotal: info.total,
2159
+ detail: info.detail,
2160
+ })
2161
+ },
2162
+ )
2163
+ runs.finish(runId, report)
2164
+ writeJson(res, 200, { dryRun: false, report, runId })
2165
+ } catch (error) {
2166
+ const message = error instanceof Error ? error.message : String(error)
2167
+ runs.fail(runId, message)
2168
+ writeJson(res, 400, { error: message, runId })
2169
+ }
2127
2170
  } catch (error) {
2128
2171
  writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) })
2129
2172
  }
@@ -2132,11 +2175,14 @@ function makeRoutes(deps: RoutesDeps): { routes: WebRoute[]; scheduler: AutoSync
2132
2175
  // -------------------------------------------------- backup-schedule
2133
2176
  // 定时全量备份设置(GET 读 / PUT 存 sync/backup-schedule.json;无敏感字段):
2134
2177
  // 保存后重排调度器(reload);恒不含 secret、不加密(与自动同步同语义)。
2178
+ // 与全仓一致:每个方法分支都过 loopback fence(guard)——其他 /api/dsh-config-manager/*
2179
+ // 路由全部首行 guard,新增路由不得遗漏(安全不变量:仅 loopback + 同源可访问)。
2135
2180
  {
2136
2181
  kind: 'exact',
2137
2182
  path: API.backupSchedule,
2138
2183
  handler: async (req, res) => {
2139
2184
  if (req.method === 'GET') {
2185
+ if (!guard(req, res, 'GET')) return
2140
2186
  try {
2141
2187
  writeJson(res, 200, { schedule: await readBackupSchedule(syncDir) })
2142
2188
  } catch (error) {
@@ -2145,6 +2191,7 @@ function makeRoutes(deps: RoutesDeps): { routes: WebRoute[]; scheduler: AutoSync
2145
2191
  return
2146
2192
  }
2147
2193
  if (req.method === 'PUT') {
2194
+ if (!guard(req, res, 'PUT')) return
2148
2195
  try {
2149
2196
  const body = await readJsonBody(req)
2150
2197
  const parsed = validateBackupScheduleDraft(body)
@@ -2168,14 +2215,12 @@ function makeRoutes(deps: RoutesDeps): { routes: WebRoute[]; scheduler: AutoSync
2168
2215
  // ------------------------------------------------- backup-schedule/run
2169
2216
  // 立即执行一次全量备份(复用 BackupScheduler.runOnce,同一时刻防重):
2170
2217
  // 返回执行结果(status/zip/skipReason/error)+ 最新配置(含 lastRun 状态)。
2218
+ // 同全仓:loopback fence(guard)——远程调用方不得触发宿主写盘操作。
2171
2219
  {
2172
2220
  kind: 'exact',
2173
2221
  path: API.backupScheduleRun,
2174
2222
  handler: async (req, res) => {
2175
- if (req.method !== 'POST') {
2176
- writeJson(res, 405, { error: `method ${req.method} not allowed` })
2177
- return
2178
- }
2223
+ if (!guard(req, res, 'POST')) return
2179
2224
  try {
2180
2225
  const run = await backupScheduler.runOnce()
2181
2226
  const schedule = await readBackupSchedule(syncDir)