dsh-config-manager 0.2.3 → 0.3.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.
Files changed (132) hide show
  1. package/README.md +1 -1
  2. package/README.zh-CN.md +1 -1
  3. package/lib/adapters/self.d.ts +44 -4
  4. package/lib/adapters/self.js +63 -3
  5. package/lib/client.d.ts +11 -6
  6. package/lib/client.js +1256 -1185
  7. package/lib/core/analyzer.d.ts +3 -0
  8. package/lib/core/analyzer.js +32 -56
  9. package/lib/core/backup.d.ts +36 -0
  10. package/lib/core/backup.js +125 -3
  11. package/lib/core/import-defaults.d.ts +13 -0
  12. package/lib/core/import-defaults.js +14 -0
  13. package/lib/core/importer.d.ts +10 -1
  14. package/lib/core/importer.js +7 -1
  15. package/lib/core/messages.d.ts +4 -0
  16. package/lib/core/messages.js +8 -0
  17. package/lib/core/model-tools.js +19 -3
  18. package/lib/index.d.ts +1 -1
  19. package/lib/index.js +12 -22
  20. package/lib/routes/import.js +3 -1
  21. package/lib/routes/recovery.js +226 -2
  22. package/lib/routes/sync.js +26 -7
  23. package/lib/security/integrity.d.ts +24 -0
  24. package/lib/security/integrity.js +30 -0
  25. package/lib/security/unverified-file-blocks.d.ts +73 -0
  26. package/lib/security/unverified-file-blocks.js +151 -0
  27. package/lib/sync/autosync-config.d.ts +8 -4
  28. package/lib/sync/autosync-config.js +2 -2
  29. package/lib/sync/autosync-scheduler.d.ts +61 -3
  30. package/lib/sync/autosync-scheduler.js +87 -10
  31. package/lib/sync/config-issue-reporting.d.ts +32 -0
  32. package/lib/sync/config-issue-reporting.js +63 -0
  33. package/lib/sync/gc-skip.d.ts +42 -0
  34. package/lib/sync/gc-skip.js +57 -0
  35. package/lib/sync/git/git-transport.d.ts +56 -2
  36. package/lib/sync/git/git-transport.js +344 -73
  37. package/lib/sync/merge-preview-text.d.ts +11 -0
  38. package/lib/sync/merge-preview-text.js +38 -0
  39. package/lib/sync/s3/s3-transport.d.ts +8 -1
  40. package/lib/sync/s3/s3-transport.js +19 -5
  41. package/lib/sync/session-tombstones.d.ts +53 -0
  42. package/lib/sync/session-tombstones.js +52 -0
  43. package/lib/sync/sync-config.d.ts +55 -5
  44. package/lib/sync/sync-config.js +188 -15
  45. package/lib/sync/sync-engine.d.ts +21 -7
  46. package/lib/sync/sync-engine.js +63 -85
  47. package/lib/sync/webdav/webdav-transport.d.ts +11 -1
  48. package/lib/sync/webdav/webdav-transport.js +22 -6
  49. package/lib/ui/i18n.js +56 -36
  50. package/lib/ui/import-wizard.d.ts +1 -1
  51. package/lib/ui/import-wizard.js +2 -1
  52. package/lib/ui/overview-view.d.ts +6 -2
  53. package/lib/ui/overview-view.js +8 -1
  54. package/lib/ui/report.js +2 -1
  55. package/lib/utils/git-fast-forward.d.ts +7 -2
  56. package/lib/utils/git-fast-forward.js +8 -3
  57. package/package.json +2 -2
  58. package/src/adapters/self.test.ts +126 -85
  59. package/src/adapters/self.ts +73 -6
  60. package/src/client/ConfigManagerSection.tsx +0 -3
  61. package/src/client/common/Icon.tsx +3 -0
  62. package/src/client/common/SectionComposition.tsx +6 -1
  63. package/src/client/common/info-hint-guard.test.ts +7 -5
  64. package/src/client/config-manager.module.css +254 -13
  65. package/src/client/environment/EnvironmentPanel.tsx +57 -47
  66. package/src/client/environment/maintenance/DiskUsageCard.tsx +3 -1
  67. package/src/client/export/ExportView.tsx +3 -1
  68. package/src/client/home/HomePanel.tsx +25 -5
  69. package/src/client/import/ImportWizardView.tsx +1 -3
  70. package/src/client/import/import-wizard-steps.tsx +1 -17
  71. package/src/client/library/ArtifactRow.tsx +13 -2
  72. package/src/client/library/LibraryPanel.tsx +12 -2
  73. package/src/client/locales.ts +53 -34
  74. package/src/client/lucide-icons.d.ts +2 -0
  75. package/src/client/market/MarketPanel.tsx +12 -11
  76. package/src/client/market/market-locales.ts +16 -16
  77. package/src/client/recovery/RollbackCard.tsx +4 -2
  78. package/src/client/recovery/recovery-locales.test.ts +4 -2
  79. package/src/client/recovery/recovery-locales.ts +23 -23
  80. package/src/client/sync/AutosyncCard.tsx +36 -10
  81. package/src/client/sync/SyncChannelEntryCard.tsx +18 -19
  82. package/src/client/sync/SyncPanel.tsx +32 -33
  83. package/src/client/sync/SyncProbeRow.tsx +23 -6
  84. package/src/client/sync/SyncSectionPickerDialog.tsx +2 -2
  85. package/src/client/sync/sync-locales.ts +24 -24
  86. package/src/core/analyzer.ts +37 -56
  87. package/src/core/backup.test.ts +139 -1
  88. package/src/core/backup.ts +145 -4
  89. package/src/core/import-defaults.ts +13 -0
  90. package/src/core/importer.ts +12 -2
  91. package/src/core/integrity-extra-entries.test.ts +205 -0
  92. package/src/core/messages.test.ts +3 -1
  93. package/src/core/messages.ts +8 -0
  94. package/src/core/model-tools.test.ts +33 -0
  95. package/src/core/model-tools.ts +20 -4
  96. package/src/index.ts +12 -21
  97. package/src/routes/import.ts +4 -1
  98. package/src/routes/recovery.test.ts +198 -1
  99. package/src/routes/recovery.ts +194 -1
  100. package/src/routes/sync.ts +27 -8
  101. package/src/security/integrity.ts +37 -0
  102. package/src/security/unverified-file-blocks.ts +192 -0
  103. package/src/sync/autosync-config.ts +9 -5
  104. package/src/sync/autosync-scheduler.test.ts +133 -6
  105. package/src/sync/autosync-scheduler.ts +118 -14
  106. package/src/sync/config-issue-reporting.test.ts +148 -0
  107. package/src/sync/config-issue-reporting.ts +75 -0
  108. package/src/sync/gc-skip.test.ts +66 -0
  109. package/src/sync/gc-skip.ts +64 -0
  110. package/src/sync/git/git-probe.test.ts +6 -1
  111. package/src/sync/git/git-transport.test.ts +351 -14
  112. package/src/sync/git/git-transport.ts +343 -79
  113. package/src/sync/merge-preview-text.ts +37 -0
  114. package/src/sync/s3/s3-delete-index-404.test.ts +26 -1
  115. package/src/sync/s3/s3-transport.ts +23 -4
  116. package/src/sync/session-tombstones.ts +90 -0
  117. package/src/sync/sync-config.test.ts +170 -1
  118. package/src/sync/sync-config.ts +184 -14
  119. package/src/sync/sync-engine.test.ts +238 -0
  120. package/src/sync/sync-engine.ts +79 -83
  121. package/src/sync/webdav/webdav-delete-index-404.test.ts +29 -1
  122. package/src/sync/webdav/webdav-transport.ts +26 -5
  123. package/src/ui/disk-usage-view.test.ts +2 -1
  124. package/src/ui/i18n.test.ts +47 -0
  125. package/src/ui/i18n.ts +58 -38
  126. package/src/ui/import-wizard.test.ts +18 -1
  127. package/src/ui/import-wizard.ts +3 -2
  128. package/src/ui/overview-view.test.ts +9 -0
  129. package/src/ui/overview-view.ts +9 -2
  130. package/src/ui/report.test.ts +4 -3
  131. package/src/ui/report.ts +2 -1
  132. package/src/utils/git-fast-forward.ts +8 -3
package/src/ui/i18n.ts CHANGED
@@ -528,14 +528,14 @@ export const uiEn: Record<UiTextKey, string> = {
528
528
  'report.backupCreated': 'Backup Created',
529
529
  'report.importComplete': 'Import Complete',
530
530
  'report.importFailed': 'Import Failed',
531
- 'report.tombstonedSkipped': '{count} item(s) skipped by deletion records',
531
+ 'report.tombstonedSkipped': '{count} {count, plural, item|items} skipped by deletion records',
532
532
  'report.included': 'Included:',
533
533
  'report.excluded': 'Excluded:',
534
534
  'report.security': 'Security:',
535
535
  'report.apiKeysExcluded': 'API Keys excluded:',
536
536
  'report.containsSecrets': 'Contains secrets:',
537
537
  'report.encrypted': 'Encrypted:',
538
- 'report.redacted': '{count} sensitive field(s) redacted',
538
+ 'report.redacted': '{count} sensitive {count, plural, field|fields} redacted',
539
539
  'report.file': 'File:',
540
540
  'report.yes': 'yes',
541
541
  'report.yesEncrypted': 'yes (encrypted)',
@@ -553,12 +553,12 @@ export const uiEn: Record<UiTextKey, string> = {
553
553
  'report.other': 'Other',
554
554
  'report.fullText': 'Full text report (with per-item reasons)',
555
555
  'report.secrets': 'Secrets:',
556
- 'report.credentialsNeedEntry': '{count} credential(s) need to be entered',
556
+ 'report.credentialsNeedEntry': '{count} {count, plural, credential needs|credentials need} to be entered',
557
557
  'report.restartPluginsMcp': 'Plugin / MCP changes take effect after restarting DSH',
558
558
  'report.rollbackFull': 'Rollback: fully restored the pre-import configuration.',
559
559
  'report.rollbackPartial': 'Rollback partially completed.',
560
560
  'report.rollbackManual': 'These items may require manual recovery:',
561
- 'report.restored': 'Restored: {count} item(s)',
561
+ 'report.restored': 'Restored: {count} {count, plural, item|items}',
562
562
  'report.includedCount': '{count} included',
563
563
  'report.excludedCount': '{count} excluded',
564
564
  'report.badgeEncrypted': 'encrypted',
@@ -569,18 +569,18 @@ export const uiEn: Record<UiTextKey, string> = {
569
569
  'report.includedSections': 'Included sections',
570
570
  'report.excludedSections': 'Excluded sections',
571
571
  'report.warnings': 'Warnings',
572
- 'report.unit.namespaces': 'namespace(s)',
573
- 'report.unit.files': 'file(s)',
574
- 'report.unit.servers': 'server(s)',
575
- 'report.unit.providers': 'provider(s)',
576
- 'report.unit.plugins': 'plugin(s)',
577
- 'report.unit.patchLines': 'patch line(s)',
578
- 'report.unit.patchFiles': 'patch file(s)',
579
- 'report.unit.localTarballs': 'local package(s)',
580
- 'report.unit.prompts': 'prompt(s)',
581
- 'report.unit.workspaces': 'workspace(s)',
582
- 'report.unit.credentials': 'credential status item(s)',
583
- 'report.unit.notes': 'note(s)',
572
+ 'report.unit.namespaces': '{n, plural, namespace|namespaces}',
573
+ 'report.unit.files': '{n, plural, file|files}',
574
+ 'report.unit.servers': '{n, plural, server|servers}',
575
+ 'report.unit.providers': '{n, plural, provider|providers}',
576
+ 'report.unit.plugins': '{n, plural, plugin|plugins}',
577
+ 'report.unit.patchLines': '{n, plural, patch line|patch lines}',
578
+ 'report.unit.patchFiles': '{n, plural, patch file|patch files}',
579
+ 'report.unit.localTarballs': '{n, plural, local package|local packages}',
580
+ 'report.unit.prompts': '{n, plural, prompt|prompts}',
581
+ 'report.unit.workspaces': '{n, plural, workspace|workspaces}',
582
+ 'report.unit.credentials': '{n, plural, credential status item|credential status items}',
583
+ 'report.unit.notes': '{n, plural, note|notes}',
584
584
  'error.notMounted': 'config-manager service is not mounted (plugin not loaded): make sure dsh-config-manager is installed in the profile and restart DSH',
585
585
  'error.apiPathMissed': 'API path not matched (HTTP {status}): the request prefix is {base} but that path did not return JSON — if you serve DSH under a reverse-proxy sub-path, check that this prefix is proxied (issue #108)',
586
586
  'error.httpInvalidJson': 'HTTP {status}: invalid JSON response',
@@ -671,16 +671,16 @@ export const uiEn: Record<UiTextKey, string> = {
671
671
  'sync.pushFailed': 'Push failed',
672
672
  'sync.pushOk': 'Push succeeded (snapshot {id})',
673
673
  'sync.conflictUnresolved': 'Conflict item {id} must be resolved first (keep current / use imported)',
674
- 'sync.pushPreviewHeadline': 'Will push {total} section(s) ({changed} changed)',
674
+ 'sync.pushPreviewHeadline': 'Will push {total} {total, plural, section|sections} ({changed} changed)',
675
675
  'sync.pushPreviewEncrypted': 'Encrypted snapshot: the payload is encrypted as a whole; per-section changes vs the baseline cannot be compared.',
676
676
  'sync.pushPreviewCredentials': 'This push includes real credential values (encrypted with the payload; the remote only ever sees ciphertext).',
677
677
  'sync.pullFailed': 'Pull failed',
678
- 'sync.pullOk': 'Remote snapshot {id} diff preview: {count} change(s)',
678
+ 'sync.pullOk': 'Remote snapshot {id} diff preview: {count} {count, plural, change|changes}',
679
679
  'sync.pullEmpty': 'Remote snapshot matches local (no changes)',
680
680
  'sync.previewHint': 'Read-only diff preview above; nothing is imported. One-click import is not supported in this version — use the Import wizard to apply a downloaded backup if needed.',
681
681
  'sync.syncing': 'Syncing…',
682
- 'sync.importDone': 'Imported {n} section(s)',
683
- 'sync.importPartial': 'Partly successful: {n} item(s) did not take effect (the rest were written; nothing rolled back)',
682
+ 'sync.importDone': 'Imported {n} {n, plural, section|sections}',
683
+ 'sync.importPartial': 'Partly successful: {n} {n, plural, item did|items did} not take effect (the rest were written; nothing rolled back)',
684
684
  'sync.importFailed': 'Import failed (rolled back)',
685
685
  'sync.autosyncNever': 'Never run',
686
686
  'sync.autosyncLastRun': 'Last run: {time}',
@@ -688,7 +688,7 @@ export const uiEn: Record<UiTextKey, string> = {
688
688
  'sync.autosyncSkipped': 'Skipped',
689
689
  'sync.autosyncFailed': 'Failed',
690
690
  'sync.autosyncPartial': 'Partial',
691
- 'sync.autosyncFailCount': '{n} consecutive failure(s)',
691
+ 'sync.autosyncFailCount': '{n} consecutive {n, plural, failure|failures}',
692
692
  'sync.duration.min': '{n} min',
693
693
  'sync.duration.hour': '{n} hr',
694
694
  'sync.duration.day': '{n} day',
@@ -707,7 +707,7 @@ export const uiEn: Record<UiTextKey, string> = {
707
707
  'sync.github.pollError': 'GitHub OAuth error: {detail}',
708
708
  'sync.github.unknownError': 'Unknown error',
709
709
  'market.statusUnconfigured': 'No markets added (enter a public Git repository URL)',
710
- 'market.statusConfigured': '{count} market(s) added',
710
+ 'market.statusConfigured': '{count} {count, plural, market|markets} added',
711
711
  'market.supplyUnofficial': 'From the public network, not officially reviewed',
712
712
  'market.supplySource': 'Source repo: {url}',
713
713
  'market.supplyDownloadedAt': 'Downloaded at: {time}',
@@ -739,18 +739,18 @@ export const uiEn: Record<UiTextKey, string> = {
739
739
  'consult.title': 'Migration Pre-flight Consultation',
740
740
  'consult.healthScore': 'Health score {score}',
741
741
  'consult.dryRun': 'Read-only analysis',
742
- 'consult.blockers': '{count} hard blocker(s) (cannot import safely until resolved)',
743
- 'consult.attention': '{count} item(s) need attention',
742
+ 'consult.blockers': '{count} hard {count, plural, blocker|blockers} (cannot import safely until resolved)',
743
+ 'consult.attention': '{count} {count, plural, item needs|items need} attention',
744
744
  'consult.reasons': 'Recommendation basis',
745
745
  'consult.willApply': 'Will apply',
746
- 'consult.sections': '{count} section(s)',
747
- 'consult.items': '{count} item(s)',
748
- 'consult.conflicts': '{count} conflict(s)',
749
- 'consult.risks': '{count} risk(s)',
746
+ 'consult.sections': '{count} {count, plural, section|sections}',
747
+ 'consult.items': '{count} {count, plural, item|items}',
748
+ 'consult.conflicts': '{count} {count, plural, conflict|conflicts}',
749
+ 'consult.risks': '{count} {count, plural, risk|risks}',
750
750
  'consult.dimensions': 'Score dimensions',
751
751
  'consult.loading': 'Generating consultation report…',
752
752
  'diskUsage.title': 'Disk Usage',
753
- 'diskUsage.files': '{count} file(s)',
753
+ 'diskUsage.files': '{count} {count, plural, file|files}',
754
754
  'diskUsage.refresh': 'Re-scan',
755
755
  'diskUsage.loading': 'Measuring disk usage…',
756
756
  'diskUsage.unreadable': 'not measured',
@@ -784,9 +784,9 @@ export const uiEn: Record<UiTextKey, string> = {
784
784
  'diskUsage.area.vault': 'Sensitive file mirror',
785
785
  'diskUsage.area.vaultDesc': 'Mirror for the local editor write channel (enabled on demand)',
786
786
  'diskUsage.retention.none': 'Retention disabled (never auto-removed)',
787
- 'diskUsage.retention.days': '{count} day(s)',
788
- 'diskUsage.retention.hours': '{count} hour(s)',
789
- 'diskUsage.retention.minutes': '{count} minute(s)',
787
+ 'diskUsage.retention.days': '{count} {count, plural, day|days}',
788
+ 'diskUsage.retention.hours': '{count} {count, plural, hour|hours}',
789
+ 'diskUsage.retention.minutes': '{count} {count, plural, minute|minutes}',
790
790
  'diskUsage.backupRetention': 'Scheduled backups keep the latest {count}; exports are auto-removed after {days} days.',
791
791
  'diskUsage.clean.title': 'Reclaim space',
792
792
  'diskUsage.clean.hint': 'Only regenerable caches and staging are cleaned; snapshots and sync data are never removed here.',
@@ -796,10 +796,10 @@ export const uiEn: Record<UiTextKey, string> = {
796
796
  'diskUsage.clean.tmpMarketDesc': 'Clears regardless of retention — all of it regenerates (avoid while an import is in flight).',
797
797
  'diskUsage.clean.expiredExports': 'Reclaim expired backups',
798
798
  'diskUsage.clean.expiredExportsDesc': 'Only exports past their retention window (scheduled backups are excluded).',
799
- 'diskUsage.clean.done': 'Freed {size}, removed {count} item(s)',
799
+ 'diskUsage.clean.done': 'Freed {size}, removed {count} {count, plural, item|items}',
800
800
  'diskUsage.clean.nothing': 'Nothing to reclaim',
801
801
  'diskUsage.clean.nothingAbsent': 'Nothing to clean (the target directories are missing or already empty)',
802
- 'diskUsage.clean.failed': 'Cleanup finished with {count} failure(s) (skipped)',
802
+ 'diskUsage.clean.failed': 'Cleanup finished with {count} {count, plural, failure|failures} (skipped)',
803
803
  'diskUsage.clean.confirmTitle': 'Clean disk usage',
804
804
  'diskUsage.clean.confirmMessage': 'This deletes regenerable caches/staging plus expired backup files; snapshots and sync data are untouched. It cannot be undone, but everything removed can be regenerated.',
805
805
  'diskUsage.clean.confirm': 'Clean',
@@ -904,7 +904,7 @@ export const uiEn: Record<UiTextKey, string> = {
904
904
  'foreign.found.no': 'Not detected',
905
905
  'foreign.paths': 'Matched paths: {paths}',
906
906
  'foreign.pathsNone': 'No truth-table path matched',
907
- 'foreign.credentialRefs': '{count} credential(s) to enter manually: {refs}',
907
+ 'foreign.credentialRefs': '{count} {count, plural, credential|credentials} to enter manually: {refs}',
908
908
  'foreign.skip.unsupported-hooks': 'settings hooks: DSH has no equivalent, not migrated',
909
909
  'foreign.skip.unsupported-commands': 'Slash commands: not the same thing as DSH prompts, not migrated this round',
910
910
  'foreign.skip.credentials-not-migrated': 'Credential fields: only the names are recorded; the values are never read or migrated',
@@ -988,6 +988,18 @@ export const uiEn: Record<UiTextKey, string> = {
988
988
  /** UI 翻译函数:key → 当前语言文案({param} 插值;未知键回退 zh/键名)。 */
989
989
  export type UiT = (key: UiTextKey, params?: Record<string, string | number>) => string;
990
990
 
991
+ /**
992
+ * 单复数段:`{count, plural, one-form|other-form}`(ICU 的极小子集,2026-11-01)。
993
+ *
994
+ * 为什么要它:英文旧写法把复数塞进括号 —— `{count} item(s)`、`{count} copy(ies)`。
995
+ * 界面读不出来:用户看到 "3 item(s)" 会先怀疑渲染漏了,单数时也照样写 "1 item(s)"。
996
+ * 真复数得按数量选形式,而模板是死字符串,只能把两种形式都放进值里、由插值层挑一个。
997
+ *
998
+ * 规则:`Number(v)` 的绝对值 === 1 取 one,其余(含 0、小数、非数字)取 other —— 英语常规
999
+ * ("0 items")。中文值不写这段(中文没有单复数),同一键的中文照旧。
1000
+ */
1001
+ const PLURAL_SEGMENT = /\{(\w+),\s*plural,\s*([^{}|]*)\|([^{}]*)\}/g;
1002
+
991
1003
  /** 构造 翻译函数(zh 源 / en 镜像;缺 key 回退)。 */
992
1004
  export function makeUiT(lang: 'zh' | 'en'): UiT {
993
1005
  const dict = lang === 'en' ? uiEn : uiZh;
@@ -996,9 +1008,17 @@ export function makeUiT(lang: 'zh' | 'en'): UiT {
996
1008
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
997
1009
  const template = (dict as any)[key] ?? (fallback as any)?.[key] ?? key;
998
1010
  if (params === undefined) return template;
999
- return String(template).replace(/\{(\w+)\}/g, (match, k: string) =>
1000
- params[k] === undefined ? match : String(params[k]),
1001
- );
1011
+ return String(template)
1012
+ .replace(PLURAL_SEGMENT, (match, k: string, one: string, other: string) => {
1013
+ const value = params[k];
1014
+ if (value === undefined) return match;
1015
+ const n = Number(value);
1016
+ const form = Number.isFinite(n) && Math.abs(n) === 1 ? one : other;
1017
+ return form.trim();
1018
+ })
1019
+ .replace(/\{(\w+)\}/g, (match, k: string) =>
1020
+ params[k] === undefined ? match : String(params[k]),
1021
+ );
1002
1022
  };
1003
1023
  }
1004
1024
 
@@ -13,6 +13,7 @@ import {
13
13
  importPreviewStageAfter, isSkippablePluginInstall, mergeSecretInput, nextImportPhase, pendingSecretRequests,
14
14
  } from './import-wizard.ts';
15
15
  import { MockImportPort, makeAnalysis, makeImportResult, makePlan, makePlanItem } from './test-helpers.ts';
16
+ import { DEFAULT_ROLLBACK_ON_ERROR } from '../core/importer.ts';
16
17
  import type { ImportPlan, PathIssue } from '../core/types.ts';
17
18
  import type { SectionId } from '../schema/types.ts';
18
19
  import { ConfigManagerApiError } from '../client/common/http.ts';
@@ -101,7 +102,23 @@ test('import-wizard: execute 默认显式传 rollbackOnError=true(场景 E)'
101
102
  assert.equal(wiz.currentStep, 'result');
102
103
  assert.equal(port.executeCalls.length, 1);
103
104
  assert.equal(port.executeCalls[0]!.confirm, true);
104
- assert.equal(port.executeCalls[0]!.rollbackOnError, true, '默认整体回滚');
105
+ assert.equal(port.executeCalls[0]!.rollbackOnError, DEFAULT_ROLLBACK_ON_ERROR, '默认整体回滚(与引擎同一缺省符号)');
106
+ });
107
+
108
+ test('IE-02: 向导缺省取引擎同一符号(IE-02 四入口同缺省)', async () => {
109
+ const port = new MockImportPort();
110
+ const wiz = new ImportWizard({ port });
111
+ assert.equal(wiz.snapshot().rollbackOnError, DEFAULT_ROLLBACK_ON_ERROR, '未传 defaultRollbackOnError → 引擎同一缺省');
112
+
113
+ await wiz.selectZip('x.zip');
114
+ await wiz.confirmCompatibility();
115
+ await wiz.execute({ confirm: true });
116
+ assert.equal(port.executeCalls[0]!.rollbackOnError, DEFAULT_ROLLBACK_ON_ERROR, '交给 /execute 的值也是同一缺省');
117
+
118
+ // 显式缺省仍以调用方为准(UI 可选项不被缺省吃掉)
119
+ const offPort = new MockImportPort();
120
+ const off = new ImportWizard({ port: offPort, defaultRollbackOnError: false });
121
+ assert.equal(off.snapshot().rollbackOnError, false);
105
122
  });
106
123
 
107
124
  test('import-wizard: 用户可选 rollbackOnError=false(单项失败继续 §34.17)', async () => {
@@ -25,11 +25,12 @@ import { EXECUTING_STAGE, IMPORT_STAGES, ProgressTracker } from './progress.ts';
25
25
  import { formatActionableError, toActionableError } from './errors.ts';
26
26
  import { liveUiT, type UiT } from './i18n.ts';
27
27
  import { nextFlowPhase, type FlowPhase } from './flow.ts';
28
+ import { DEFAULT_ROLLBACK_ON_ERROR } from '../core/import-defaults.ts';
28
29
 
29
30
  export interface ImportWizardOptions {
30
31
  port: ImportPort;
31
32
  onProgress?: ProgressListener;
32
- /** 默认回滚策略(场景 E:true=整体回滚;UI 可在执行前覆盖) */
33
+ /** 默认回滚策略(场景 E:true=整体回滚;UI 可在执行前覆盖;缺省取 `DEFAULT_ROLLBACK_ON_ERROR`) */
33
34
  defaultRollbackOnError?: boolean;
34
35
  /**
35
36
  * 渲染层翻译器(UiT)。缺省 = **活**翻译器 `liveUiT`(随 DSH 语言变化)——
@@ -282,7 +283,7 @@ export class ImportWizard {
282
283
  this.port = opts.port;
283
284
  this.onProgress = opts.onProgress;
284
285
  this.tracker = new ProgressTracker(IMPORT_STAGES, this.onProgress);
285
- this.rollbackOnError = opts.defaultRollbackOnError ?? true;
286
+ this.rollbackOnError = opts.defaultRollbackOnError ?? DEFAULT_ROLLBACK_ON_ERROR;
286
287
  this.t = opts.t ?? liveUiT;
287
288
  }
288
289
 
@@ -155,6 +155,15 @@ test('overviewHealth: SAFE MODE 优先 error;全空 warn;正常 ok', () => {
155
155
  assert.equal(scheduleFail.kind, 'warn')
156
156
  assert.equal(scheduleFail.textKey, 'health.scheduleFailed')
157
157
 
158
+ /* 有备份、但自动备份是关的 → 仍要 warn:状态条不能与脚下「Auto backup Off」并列报健康 */
159
+ const autoOff = overviewHealth(baseInputs({
160
+ backups: [{ name: 'a.zip', sizeBytes: 1, mtimeMs: NOW, source: 'manual' }],
161
+ snapshots: [{ createdAt: '2026-09-10T00:00:00Z', entryCount: 1 }],
162
+ schedule: { enabled: false, lastRunStatus: 'ok' },
163
+ }))
164
+ assert.equal(autoOff.kind, 'warn')
165
+ assert.equal(autoOff.textKey, 'health.autoBackupOff')
166
+
158
167
  assert.equal(overviewHealth(baseInputs()).kind, 'ok', '数据未加载不降级')
159
168
  })
160
169
 
@@ -237,7 +237,7 @@ export type OverviewHealthKind = 'ok' | 'warn' | 'error'
237
237
  export interface OverviewHealth {
238
238
  kind: OverviewHealthKind
239
239
  /** UI 渲染 key(`overview.health.*`)。 */
240
- textKey: 'health.ok' | 'health.noBackup' | 'health.scheduleFailed' | 'health.recovery'
240
+ textKey: 'health.ok' | 'health.noBackup' | 'health.scheduleFailed' | 'health.autoBackupOff' | 'health.recovery'
241
241
  }
242
242
 
243
243
  /**
@@ -245,7 +245,11 @@ export interface OverviewHealth {
245
245
  * 1. SAFE MODE 激活(有未解决恢复事项)→ error(最高优先,需用户处理);
246
246
  * 2. 已加载且备份与快照均为空 → warn「尚无任何备份」(新装机引导场景);
247
247
  * 3. 定时备份开启但上次运行失败 → warn;
248
- * 4. 其余 → ok。
248
+ * 4. 手上已有备份、但定时备份是关的 → warn「自动备份已关闭」(2026-11-01 视觉 pass 新增:
249
+ * 此前关闭档不判定,于是「All healthy」与「Auto backup Off / Backups 1」同屏并列,
250
+ * 状态条给出的结论与它脚下的指标自相矛盾 —— 一页只在回答「现在安全吗」,
251
+ * 在没有任何后续保护时报健康等于放弃这个结论);
252
+ * 5. 其余 → ok。
249
253
  * 数据未加载(null)不降级健康状态(避免加载闪红)。
250
254
  */
251
255
  export function overviewHealth(inputs: OverviewInputs): OverviewHealth {
@@ -259,6 +263,9 @@ export function overviewHealth(inputs: OverviewInputs): OverviewHealth {
259
263
  if (inputs.schedule !== null && inputs.schedule.enabled && inputs.schedule.lastRunStatus === 'failed') {
260
264
  return { kind: 'warn', textKey: 'health.scheduleFailed' }
261
265
  }
266
+ if (inputs.schedule !== null && !inputs.schedule.enabled) {
267
+ return { kind: 'warn', textKey: 'health.autoBackupOff' }
268
+ }
262
269
  return { kind: 'ok', textKey: 'health.ok' }
263
270
  }
264
271
 
@@ -36,7 +36,7 @@ test('report: 导出报告含 Included/Excluded/Security/File(en / zh)', ()
36
36
  assert.ok(enText.includes('○ sessions'));
37
37
  assert.ok(enText.includes('API Keys excluded: yes'));
38
38
  assert.ok(enText.includes('File: dsh-config-2026-08-14.zip'));
39
- assert.ok(enText.includes('2 sensitive field(s) redacted'));
39
+ assert.ok(enText.includes('2 sensitive fields redacted'));
40
40
  const zhText = renderExportReport(makeExportReport(), zhT);
41
41
  assert.ok(zhText.includes('备份已创建'));
42
42
  });
@@ -44,7 +44,8 @@ test('report: 导出报告含 Included/Excluded/Security/File(en / zh)', ()
44
44
  test('report: exportCountsText —— 计数单位中文化(zh/en),未知单位键原样回退', () => {
45
45
  assert.equal(exportCountsText({ namespaces: 18 }, zhT), '18 个命名空间');
46
46
  assert.equal(exportCountsText({ plugins: 10, patchLines: 11 }, zhT), '10 个插件,11 行补丁');
47
- assert.equal(exportCountsText({ namespaces: 18 }, enT), '18 namespace(s)');
47
+ assert.equal(exportCountsText({ namespaces: 18 }, enT), '18 namespaces', '单位词按数量选形(1 → namespace)');
48
+ assert.equal(exportCountsText({ namespaces: 1 }, enT), '1 namespace', '单数不再写成 1 namespace(s)');
48
49
  assert.equal(exportCountsText({ servers: 0 }, zhT), '0 个服务器', '0 是有效值,不能当空');
49
50
  assert.equal(exportCountsText({ customThing: 2 }, zhT), '2 customThing', '未知单位键原样显示,不吞信息');
50
51
  assert.equal(exportCountsText({}, zhT), '', '空 counts → 空串(由调用方渲染占位符)');
@@ -66,7 +67,7 @@ test('report: 导入报告渲染含分节/重启/缺失 secret(en)', () => {
66
67
  const text = renderImportReport(makeImportResult(), enT);
67
68
  assert.ok(text.includes('Import Complete'));
68
69
  assert.ok(text.includes('settings: ✓ 2 imported/restored'));
69
- assert.ok(text.includes('Secrets: ⚠ 1 credential(s) need to be entered'));
70
+ assert.ok(text.includes('Secrets: ⚠ 1 credential needs to be entered'), '动词单复数跟着数量走');
70
71
  assert.ok(text.includes('Plugin / MCP changes take effect'));
71
72
  });
72
73
 
package/src/ui/report.ts CHANGED
@@ -45,7 +45,8 @@ export function exportCountsText(counts: Record<string, number>, t: UiT): string
45
45
  for (const [key, value] of Object.entries(counts)) {
46
46
  if (typeof value !== 'number' || !Number.isFinite(value)) continue;
47
47
  const unitKey = COUNT_UNIT_KEY[key];
48
- parts.push(unitKey === undefined ? String(value) + ' ' + key : String(value) + ' ' + t(unitKey));
48
+ // 单位词按数量选单复数(en 的 `{n, plural, file|files}`);中文单位不受影响
49
+ parts.push(unitKey === undefined ? String(value) + ' ' + key : String(value) + ' ' + t(unitKey, { n: value }));
49
50
  }
50
51
  // 分隔符也走字典:英文用半角逗号(全角标点在英文界面同样是缺陷)
51
52
  return parts.join(t('report.unitSeparator'));
@@ -9,9 +9,14 @@
9
9
  * 语义与 ff-only pull 一致:能快进就快进,不能快进就报「历史分叉」。
10
10
  */
11
11
 
12
- /** `git fetch` 参数:只更新 origin/<branch> 这个远端跟踪引用,不写 FETCH_HEAD */
13
- export function fastForwardFetchArgs(branch: string): string[] {
14
- return ['fetch', 'origin', `+refs/heads/${branch}:refs/remotes/origin/${branch}`];
12
+ /**
13
+ * `git fetch` 参数:只更新 origin/<branch> 这个远端跟踪引用,不写 FETCH_HEAD。
14
+ *
15
+ * `flags` 是给调用方补的拉取姿态(如 `--filter=blob:none`/`--no-tags`),必须排在 `origin` 之前:
16
+ * git 只认「子命令后、URL 前」的选项,放到 refspec 之后会被当成 refspec 解析。
17
+ */
18
+ export function fastForwardFetchArgs(branch: string, flags: readonly string[] = []): string[] {
19
+ return ['fetch', ...flags, 'origin', `+refs/heads/${branch}:refs/remotes/origin/${branch}`];
15
20
  }
16
21
 
17
22
  /** `git merge` 参数:快进到显式远端跟踪分支(不依赖 upstream 配置,也不读 FETCH_HEAD) */