dsh-config-manager 0.1.4 → 0.1.6

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.
Files changed (45) hide show
  1. package/README.md +6 -0
  2. package/README.zh-CN.md +6 -0
  3. package/lib/adapters/providers.js +13 -3
  4. package/lib/adapters/providers.js.map +1 -1
  5. package/lib/adapters/settings.d.ts +5 -1
  6. package/lib/adapters/settings.js +24 -3
  7. package/lib/adapters/settings.js.map +1 -1
  8. package/lib/adapters/test-helpers.d.ts +21 -3
  9. package/lib/adapters/test-helpers.js +29 -4
  10. package/lib/adapters/test-helpers.js.map +1 -1
  11. package/lib/adapters/workspaces.js +14 -2
  12. package/lib/adapters/workspaces.js.map +1 -1
  13. package/lib/client.d.ts +2 -1
  14. package/lib/client.js +81 -78
  15. package/lib/client.js.map +1 -1
  16. package/lib/core/analyzer.js +9 -2
  17. package/lib/core/analyzer.js.map +1 -1
  18. package/lib/core/rollback.js +4 -2
  19. package/lib/core/rollback.js.map +1 -1
  20. package/lib/core/types.d.ts +5 -1
  21. package/lib/core/types.js.map +1 -1
  22. package/lib/profiles/profile-manager.js +3 -1
  23. package/lib/profiles/profile-manager.js.map +1 -1
  24. package/lib/ui/report.js +8 -4
  25. package/lib/ui/report.js.map +1 -1
  26. package/lib/ui/types.d.ts +2 -1
  27. package/lib/ui/types.js.map +1 -1
  28. package/package.json +1 -1
  29. package/src/adapters/providers.test.ts +1 -0
  30. package/src/adapters/providers.ts +12 -3
  31. package/src/adapters/roundtrip.test.ts +2 -0
  32. package/src/adapters/settings.test.ts +11 -4
  33. package/src/adapters/settings.ts +22 -3
  34. package/src/adapters/test-helpers.ts +27 -3
  35. package/src/adapters/ui.test.ts +1 -0
  36. package/src/adapters/workspaces.test.ts +27 -0
  37. package/src/adapters/workspaces.ts +13 -2
  38. package/src/client/import/ImportWizardView.tsx +7 -4
  39. package/src/core/analyzer.ts +11 -2
  40. package/src/core/rollback.ts +4 -2
  41. package/src/core/types.ts +5 -2
  42. package/src/profiles/profile-manager.test.ts +2 -0
  43. package/src/profiles/profile-manager.ts +3 -1
  44. package/src/ui/report.ts +6 -4
  45. package/src/ui/types.ts +2 -1
@@ -77,3 +77,30 @@ test('workspaces: applyItem 写入映射后数据(PathMapper 先行)', async
77
77
  const items2 = await adapter.analyzeImport(mappedData, makeImportContext(dst, sections));
78
78
  assert.equal(items2.find((i) => i.id === 'workspace:ws-ops')?.kind, 'Skip');
79
79
  });
80
+
81
+ test('workspaces: applyItem 写入失败(路径 realpath ENOENT)→ warning 非致命,不抛错', async () => {
82
+ const src = makeContext('win32', 'C:\\Users\\alice');
83
+ src.workspace.records.set('ws-ops', {
84
+ id: 'ws-ops', path: 'C:\\Users\\alice\\projects\\ops', title: 'OpsFlow', sessionIds: [],
85
+ });
86
+ const adapter = new WorkspacesAdapter();
87
+ const exported = await adapter.export(src, { includeSecrets: false });
88
+ const sections = new Map([['workspaces', exported.data]]);
89
+ const dst = makeContext('linux', '/home/bob');
90
+ // 模拟目标端工作区服务对不存在路径 realpath 失败(dsh-workspace 真实行为)
91
+ const original = dst.workspace.writeRecord.bind(dst.workspace);
92
+ dst.workspace.writeRecord = async () => {
93
+ throw new Error("ENOENT: no such file or directory, realpath '/home/bob/projects/ops'");
94
+ };
95
+ try {
96
+ const items = await adapter.analyzeImport(exported.data, makeImportContext(dst, sections));
97
+ const create = items.find((i) => i.id === 'workspace:ws-ops') as PlanItem;
98
+ const r = await adapter.applyItem(create, makeImportContext(dst, sections));
99
+ assert.equal(r.ok, false, '写入失败');
100
+ assert.equal(r.warning, true, '应为非致命警告(§34.17:不拖垮整体导入)');
101
+ assert.match(r.message ?? '', /未能写入|realpath/);
102
+ } finally {
103
+ dst.workspace.writeRecord = original;
104
+ }
105
+ });
106
+
@@ -69,8 +69,19 @@ export class WorkspacesAdapter implements ConfigAdapter<WorkspacesSection> {
69
69
  const data = ctx.sections.get('workspaces') as WorkspacesSection | undefined;
70
70
  const rec = data?.workspaces.find((r) => r.id === ref);
71
71
  if (!rec) return { ok: false, message: `导入数据缺少工作区 ${ref}` };
72
- await ctx.target.workspace.writeRecord(rec);
73
- return { ok: true };
72
+ try {
73
+ await ctx.target.workspace.writeRecord(rec);
74
+ return { ok: true };
75
+ } catch (err) {
76
+ // 目标端无法写入(如路径 realpath 失败/目录不存在)→ 非致命警告(§34.17),
77
+ // 不触发整体回滚——否则一个失效路径会拖垮已成功导入的其余配置。
78
+ const msg = err instanceof Error ? err.message : String(err);
79
+ return {
80
+ ok: false,
81
+ warning: true,
82
+ message: `工作区 ${ref} 未能写入:${msg}(需映射路径或先在目标创建目录)`,
83
+ };
84
+ }
74
85
  }
75
86
 
76
87
  async validate(data: WorkspacesSection): Promise<ValidationResult> {
@@ -312,7 +312,10 @@ export function ImportWizardView({ api, t }: ImportWizardViewProps) {
312
312
  )
313
313
  }
314
314
 
315
- if (phase === 'conflicts') {
315
+ // 流程阶段页只在 wizard.step === 'preview' 时渲染:execute 开始后 step 变
316
+ // importing/result,若 phase 仍是 confirm,必须让位给导入中/结果页(否则点「导入」
317
+ // 无反应——confirm 页一直挡着,要回退一步才露出结果)。
318
+ if (phase === 'conflicts' && step === 'preview') {
316
319
  if (conflictCollector === null || snapshot.plan === null) return null
317
320
  return (
318
321
  <div className={css.viewBody}>
@@ -328,7 +331,7 @@ export function ImportWizardView({ api, t }: ImportWizardViewProps) {
328
331
  )
329
332
  }
330
333
 
331
- if (phase === 'path-mapping') {
334
+ if (phase === 'path-mapping' && step === 'preview') {
332
335
  const issues = snapshot.analysis?.pathIssues ?? []
333
336
  return (
334
337
  <div className={css.viewBody}>
@@ -342,7 +345,7 @@ export function ImportWizardView({ api, t }: ImportWizardViewProps) {
342
345
  )
343
346
  }
344
347
 
345
- if (phase === 'secrets') {
348
+ if (phase === 'secrets' && step === 'preview') {
346
349
  const missing = snapshot.plan?.missingSecrets ?? []
347
350
  return (
348
351
  <div className={css.viewBody}>
@@ -356,7 +359,7 @@ export function ImportWizardView({ api, t }: ImportWizardViewProps) {
356
359
  )
357
360
  }
358
361
 
359
- if (phase === 'confirm') {
362
+ if (phase === 'confirm' && step === 'preview') {
360
363
  return (
361
364
  <div className={css.viewBody}>
362
365
  <Card className={css.optionsCard}>
@@ -412,6 +412,7 @@ export class Analyzer {
412
412
  for (const item of byAdapter.get(adapterId) ?? []) {
413
413
  const outcome = await this.applyOne(adapter, item, importCtx);
414
414
  executed.push(outcome.executed);
415
+ // 仅硬失败计入 anyFailed(warning 属非致命:目标不可达等,不触发回滚,§34.17)
415
416
  if (outcome.executed.status === 'failed') anyFailed = true;
416
417
  if (outcome.needsRestart) needsRestart = true;
417
418
  if (outcome.warning) warnings.push(outcome.warning);
@@ -477,7 +478,13 @@ export class Analyzer {
477
478
  item: PlanItem,
478
479
  ctx: ImportContext,
479
480
  ): Promise<{ executed: ExecutedItem; needsRestart: boolean; warning?: string }> {
480
- if (item.kind === 'Skip' || (item.kind === 'Conflict' && item.conflict?.resolution !== 'useImported')) {
481
+ if (
482
+ item.kind === 'Skip' ||
483
+ item.kind === 'Warning' ||
484
+ item.kind === 'MissingDependency' ||
485
+ (item.kind === 'Conflict' && item.conflict?.resolution !== 'useImported')
486
+ ) {
487
+ // Warning / MissingDependency 是信息项(依赖缺失、需人工注意),不调用 applyItem。
481
488
  return { executed: { itemId: item.id, status: 'skipped' }, needsRestart: false };
482
489
  }
483
490
  if (item.kind === 'Error') {
@@ -493,9 +500,11 @@ export class Analyzer {
493
500
  }
494
501
  try {
495
502
  const result: ApplyResult = await adapter.applyItem(item, ctx);
503
+ const status = result.ok ? 'ok' : (result.warning === true ? 'warning' : 'failed');
496
504
  return {
497
- executed: { itemId: item.id, status: result.ok ? 'ok' : 'failed', message: result.message },
505
+ executed: { itemId: item.id, status, message: result.message },
498
506
  needsRestart: result.needsRestart === true,
507
+ warning: result.warning === true ? `${item.id}: ${result.message ?? item.description}` : undefined,
499
508
  };
500
509
  } catch (err) {
501
510
  this.ctx.log.error(`应用计划项失败 ${item.id}: ${err instanceof Error ? err.message : String(err)}`);
@@ -67,8 +67,10 @@ async function compensateOne(
67
67
  }
68
68
  case 'patchLine': {
69
69
  try {
70
- const file = entry.ref.split('\u0000')[0] ?? 'cordis.patch.yml';
71
- const lineId = entry.ref.includes('\u0000') ? entry.ref.split('\u0000')[1]! : entry.ref;
70
+ // 引擎只管理 profile 的 cordis.patch.yml(backup.ts 的 patchLine 快照只记 lineId 作 ref,
71
+ // 不含文件编码)——回滚固定写回该文件;切勿把 lineId 当文件名(否则 patchPath 抛「仅支持管理」)。
72
+ const file = 'cordis.patch.yml';
73
+ const lineId = entry.ref;
72
74
  await ctx.patchFile.applyPatchChanges(file, [
73
75
  { lineId, raw: entry.before, action: entry.before === null ? 'remove' : 'update' },
74
76
  ]);
package/src/core/types.ts CHANGED
@@ -154,7 +154,9 @@ export interface PlanItem {
154
154
  target?: SnapshotTarget; // 该项将修改的目标(快照登记用)
155
155
  }
156
156
 
157
- export interface ApplyResult { ok: boolean; message?: string; needsRestart?: boolean; }
157
+ /** applyItem 结果。warning=true 表示“未应用但属非致命”(如目标路径不可达/需人工映射),
158
+ * 引擎记为警告、不触发失败回滚(§34.17 单项失败不拖垮整体)。 */
159
+ export interface ApplyResult { ok: boolean; message?: string; needsRestart?: boolean; warning?: boolean; }
158
160
 
159
161
  /* ---------------- 三段式输入输出 ---------------- */
160
162
 
@@ -187,7 +189,8 @@ export interface ImportPlan {
187
189
 
188
190
  export interface ExecutedItem {
189
191
  itemId: string;
190
- status: 'ok' | 'skipped' | 'failed';
192
+ /** warning = 未应用但非致命(目标不可达等),不计入失败、不触发回滚 */
193
+ status: 'ok' | 'skipped' | 'warning' | 'failed';
191
194
  message?: string;
192
195
  }
193
196
 
@@ -134,6 +134,7 @@ test('analyzeSwitch Preview + executeSwitch(confirm 安全阀 + Create 应用
134
134
  await srcMgr.saveCurrent('work');
135
135
 
136
136
  const dst = makeContext('linux', '/home/bob');
137
+ for (const n of NS) dst.settings.registered.add(n); // 目标已装同插件(注册但为空 → Create 初始化)
137
138
  const dstMgr = new ProfileManager({ dataDir, ctx: dst, adapters, snapshotStore: new MemSnapshotStore() });
138
139
 
139
140
  // 未确认 → 拒绝且零写入
@@ -250,6 +251,7 @@ test('exportProfile / importProfile 往返(完整验证)', async () => {
250
251
 
251
252
  // 导入后的 profile 可切换到空目标
252
253
  const dst = makeContext('linux', '/home/bob');
254
+ for (const n of NS) dst.settings.registered.add(n); // 目标已装同插件
253
255
  const dstMgr = new ProfileManager({ dataDir, ctx: dst, adapters, snapshotStore: new MemSnapshotStore() });
254
256
  const r = await dstMgr.executeSwitch('work-copy', { confirm: true });
255
257
  assert.equal(r.ok, true);
@@ -510,9 +510,11 @@ export class ProfileManager {
510
510
  }
511
511
  try {
512
512
  const result: ApplyResult = await adapter.applyItem(item, ctx);
513
+ const status = result.ok ? 'ok' : (result.warning === true ? 'warning' : 'failed');
513
514
  return {
514
- executed: { itemId: item.id, status: result.ok ? 'ok' : 'failed', message: result.message },
515
+ executed: { itemId: item.id, status, message: result.message },
515
516
  needsRestart: result.needsRestart === true,
517
+ warning: result.warning === true ? `${item.id}: ${result.message ?? item.description}` : undefined,
516
518
  };
517
519
  } catch (err) {
518
520
  this.ctx.log.error(`切换 Profile 应用计划项失败 ${item.id}: ${err instanceof Error ? err.message : String(err)}`);
package/src/ui/report.ts CHANGED
@@ -64,11 +64,12 @@ export function importSectionStats(executed: readonly ExecutedItem[]): ImportSec
64
64
  const section = sectionFromItemId(e.itemId);
65
65
  let stat = bySection.get(section);
66
66
  if (!stat) {
67
- stat = { section: section as SectionId, ok: 0, skipped: 0, failed: 0, items: [] };
67
+ stat = { section: section as SectionId, ok: 0, skipped: 0, warned: 0, failed: 0, items: [] };
68
68
  bySection.set(section, stat);
69
69
  }
70
70
  if (e.status === 'ok') stat.ok += 1;
71
71
  else if (e.status === 'skipped') stat.skipped += 1;
72
+ else if (e.status === 'warning') stat.warned += 1;
72
73
  else stat.failed += 1;
73
74
  stat.items.push(e);
74
75
  }
@@ -83,12 +84,13 @@ export function renderImportReport(result: ImportResult): string {
83
84
  const parts: string[] = [];
84
85
  if (s.ok > 0) parts.push(`✓ ${s.ok} imported/restored`);
85
86
  if (s.skipped > 0) parts.push(`- ${s.skipped} skipped`);
87
+ if (s.warned > 0) parts.push(`⚠ ${s.warned} need attention`);
86
88
  if (s.failed > 0) parts.push(`✗ ${s.failed} failed`);
87
89
  lines.push(`${s.section}: ${parts.join(' ') || 'no changes'}`);
88
- // 失败项给出可操作原因(§23)
90
+ // 失败/警告项给出可操作原因(§23)
89
91
  for (const it of s.items) {
90
- if (it.status === 'failed') {
91
- lines.push(` Reason: ${it.message ?? '未知原因'}`);
92
+ if (it.status === 'failed' || it.status === 'warning') {
93
+ lines.push(` ${it.status === 'failed' ? 'Reason' : 'Note'}: ${it.message ?? '未知原因'}`);
92
94
  }
93
95
  }
94
96
  }
package/src/ui/types.ts CHANGED
@@ -156,8 +156,9 @@ export interface ImportSectionStat {
156
156
  section: SectionId;
157
157
  ok: number;
158
158
  skipped: number;
159
+ warned: number;
159
160
  failed: number;
160
- items: { itemId: string; status: 'ok' | 'skipped' | 'failed'; message?: string }[];
161
+ items: { itemId: string; status: 'ok' | 'skipped' | 'warning' | 'failed'; message?: string }[];
161
162
  }
162
163
 
163
164
  export type ImportResultAction = 'fixIssues' | 'viewDetails' | 'done';