diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a4d30325..2dc9e8f0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -512,6 +512,10 @@ jobs: if: steps.classify.outputs.changelog_only != 'true' && steps.classify.outputs.docs_only != 'true' run: node .github/reviewer-routing.test.js + - name: Test npm installer smoke (prune, backup, publish) + if: steps.classify.outputs.changelog_only != 'true' && steps.classify.outputs.docs_only != 'true' + run: node test/scripts/install_js_smoke.mjs + test-focused: name: "Test (focused: ${{ matrix.shard }})" needs: lint diff --git a/build/npm/install.js b/build/npm/install.js index 1a243910..8ca1ec32 100755 --- a/build/npm/install.js +++ b/build/npm/install.js @@ -163,9 +163,21 @@ const SKILL_BACKUP_KEEP = 5; // transaction still needs them to roll back. const currentRunBackupRoots = new Set(); +// skillBackupStampPattern matches only directory names DWS itself writes: +// UTC YYYYmmdd-HHMMSS, with an optional -N collision suffix. Any other entry +// in the backup root is foreign (user data, unrelated tooling) and must be +// left untouched, so pruning is restricted to names it can prove DWS created. +const skillBackupStampPattern = /^[0-9]{8}-[0-9]{6}(-[0-9]+)?$/; + +function isSkillBackupStamp(name) { + return skillBackupStampPattern.test(name); +} + // pruneSkillBackups removes the oldest stamped backup directories once more -// than SKILL_BACKUP_KEEP remain. Stamps sort lexicographically in -// chronological order (`YYYYmmdd-HHMMSS`), so name order is age order. +// than SKILL_BACKUP_KEEP remain. Only directories whose names match the DWS +// backup stamp format are candidates; unknown directories are preserved. +// Stamps sort lexicographically in chronological order (`YYYYmmdd-HHMMSS`), +// so name order is age order. function pruneSkillBackups(homeDir) { const root = path.join(homeDir, ".dws", "skill-backups"); let entries; @@ -176,7 +188,7 @@ function pruneSkillBackups(homeDir) { throw err; } const names = entries - .filter((entry) => entry.isDirectory()) + .filter((entry) => entry.isDirectory() && isSkillBackupStamp(entry.name)) .map((entry) => entry.name) .sort((a, b) => Buffer.from(a).compare(Buffer.from(b))); let excess = names.length - SKILL_BACKUP_KEEP; @@ -1401,6 +1413,8 @@ module.exports = { UPSTREAM_AGENTS, resolvedAgentTargets, agentTargetDetected, + isSkillBackupStamp, + pruneSkillBackups, backupAndRemoveSkillDir, copyPathLexicallySync, movePathRecoverablySync, diff --git a/internal/app/upgrade_full_coverage_test.go b/internal/app/upgrade_full_coverage_test.go index ae777a9d..b96f5b7d 100644 --- a/internal/app/upgrade_full_coverage_test.go +++ b/internal/app/upgrade_full_coverage_test.go @@ -292,6 +292,12 @@ func TestCrossPlatformCoverageRunUpgradeAllStagesCoverage(t *testing.T) { if stage == "install-failed-dir" { return &upgradepkg.SkillUpgradeResult{Results: []upgradepkg.SkillDirResult{{Dir: "/failed", Status: upgradepkg.SkillDirFailed, Err: fail}}}, nil } + if stage == "install-retire-warning" { + return &upgradepkg.SkillUpgradeResult{Results: []upgradepkg.SkillDirResult{ + {Dir: "/ok", Status: upgradepkg.SkillDirOK}, + {Dir: "/stale", Status: upgradepkg.SkillDirRetireWarning, Err: errors.New("retirement refused")}, + }}, nil + } return &upgradepkg.SkillUpgradeResult{Results: []upgradepkg.SkillDirResult{{Dir: "/ok", Status: upgradepkg.SkillDirOK}}}, nil } } @@ -300,7 +306,7 @@ func TestCrossPlatformCoverageRunUpgradeAllStagesCoverage(t *testing.T) { "ensure", "tag-error", "latest-error", "not-needed", "cancel", "find-binary", "temp-fallback", "temp-both", "backup", "checksum-download", "checksum-read", "binary-download", "skills-download", "verify-binary", "verify-skills", "extract-binary", "extract-tar", "binary-missing", "validate", "extract-skills", "skill-missing", "replace", "install", "install-failed-dir", - "success", "success-no-skills", + "success", "success-no-skills", "install-retire-warning", } { t.Run(stage, func(t *testing.T) { configure(stage) @@ -330,14 +336,14 @@ func TestCrossPlatformCoverageRunUpgradeAllStagesCoverage(t *testing.T) { opts.skipSkills = true } err := runUpgrade(context.Background(), opts) - wantError := stage != "not-needed" && stage != "cancel" && stage != "backup" && stage != "checksum-download" && stage != "checksum-read" && stage != "success" && stage != "success-no-skills" + wantError := stage != "not-needed" && stage != "cancel" && stage != "backup" && stage != "checksum-download" && stage != "checksum-read" && stage != "success" && stage != "success-no-skills" && stage != "install-retire-warning" if wantError && err == nil { t.Fatalf("stage %s succeeded", stage) } if !wantError && err != nil { t.Fatalf("stage %s failed: %v", stage, err) } - if (stage == "success" || stage == "success-no-skills") && !rb.cleaned { + if (stage == "success" || stage == "success-no-skills" || stage == "install-retire-warning") && !rb.cleaned { t.Fatal("successful upgrade did not clean backups") } if stage == "success" { diff --git a/internal/upgrade/paths.go b/internal/upgrade/paths.go index f75d575d..9ef878c0 100644 --- a/internal/upgrade/paths.go +++ b/internal/upgrade/paths.go @@ -8,6 +8,7 @@ import ( "fmt" "os" "path/filepath" + "regexp" "runtime" "sort" "strings" @@ -383,9 +384,22 @@ func buildUpgradeProvenanceRecords(root string, names []string, version string) // backups are kept. const skillBackupKeep = 5 +// skillBackupStampRegexp matches only directory names DWS itself writes: +// UTC YYYYmmdd-HHMMSS, with an optional -N collision suffix. Any other entry +// in the backup root is foreign (user data, unrelated tooling) and must be +// left untouched, so pruning is restricted to names it can prove DWS created. +var skillBackupStampRegexp = regexp.MustCompile(`^[0-9]{8}-[0-9]{6}(-[0-9]+)?$`) + +// isSkillBackupStamp reports whether name is a DWS-created backup stamp. +func isSkillBackupStamp(name string) bool { + return skillBackupStampRegexp.MatchString(name) +} + // pruneSkillBackups removes the oldest backup directories when more than -// skillBackupKeep remain. Best-effort: a removal failure never aborts, but -// pruning failures are reported so callers can warn the user. +// skillBackupKeep remain. Only directories whose names match the DWS backup +// stamp format are candidates; unknown directories are preserved. Best-effort: +// a removal failure never aborts, but pruning failures are reported so +// callers can warn the user. func pruneSkillBackups(homeDir string) error { root := filepath.Join(homeDir, skillBackupSubdir) entries, err := upgradeReadDir(root) @@ -397,7 +411,7 @@ func pruneSkillBackups(homeDir string) error { } names := make([]string, 0, len(entries)) for _, e := range entries { - if e.IsDir() { + if e.IsDir() && isSkillBackupStamp(e.Name()) { names = append(names, e.Name()) } } diff --git a/internal/upgrade/paths_multi_test.go b/internal/upgrade/paths_multi_test.go index 9ae6579d..3a56594c 100644 --- a/internal/upgrade/paths_multi_test.go +++ b/internal/upgrade/paths_multi_test.go @@ -877,6 +877,60 @@ func TestCrossPlatformCoveragePruneSkillBackupsEdges(t *testing.T) { testseam.Swap(t, &upgradeRemoveAll, os.RemoveAll) } +// TestCrossPlatformCoveragePruneSkillBackupsPreservesUnknown pins that +// pruneSkillBackups only retires directories whose names match the DWS backup +// stamp format. Any foreign directory in the backup root must survive even when +// more than skillBackupKeep stamped backups exist. +func TestCrossPlatformCoveragePruneSkillBackupsPreservesUnknown(t *testing.T) { + home := t.TempDir() + root := filepath.Join(home, skillBackupSubdir) + for i := 0; i < skillBackupKeep+2; i++ { + if err := os.MkdirAll(filepath.Join(root, fmt.Sprintf("20260910-00000%d", i)), 0o755); err != nil { + t.Fatal(err) + } + } + // A collision-suffixed stamp is still a valid DWS backup and is eligible. + if err := os.MkdirAll(filepath.Join(root, "20260910-000000-7"), 0o755); err != nil { + t.Fatal(err) + } + unknown := []string{ + "user-personal-backup", + "20260910-000000-abc", // stamp-shaped but non-numeric suffix + "20260910-00000", // too short + "notes", + } + for _, name := range unknown { + if err := os.MkdirAll(filepath.Join(root, name), 0o755); err != nil { + t.Fatal(err) + } + } + + if err := pruneSkillBackups(home); err != nil { + t.Fatalf("pruneSkillBackups() error = %v", err) + } + + for _, name := range unknown { + if _, err := os.Stat(filepath.Join(root, name)); err != nil { + t.Errorf("unknown backup entry %s must be preserved, stat err=%v", name, err) + } + } + // Only stamped backups beyond skillBackupKeep are pruned; foreign entries + // are excluded from the count, so the stamp total stays at skillBackupKeep. + entries, err := os.ReadDir(root) + if err != nil { + t.Fatal(err) + } + stamps := 0 + for _, e := range entries { + if isSkillBackupStamp(e.Name()) { + stamps++ + } + } + if stamps != skillBackupKeep { + t.Errorf("stamped backups after prune = %d, want %d", stamps, skillBackupKeep) + } +} + // TestCrossPlatformCoverageResolveSkillSrcLayouts pins every fallback branch of // the mono-source resolver and the multi-bundle child lookup. func TestCrossPlatformCoverageResolveSkillSrcLayouts(t *testing.T) { diff --git a/internal/upgrade/skill_path_move_unix_test.go b/internal/upgrade/skill_path_move_unix_test.go index e5ccde16..288847ae 100644 --- a/internal/upgrade/skill_path_move_unix_test.go +++ b/internal/upgrade/skill_path_move_unix_test.go @@ -10,3 +10,7 @@ import ( func testCrossDeviceError() error { return &os.LinkError{Op: "rename", Old: "src", New: "dst", Err: syscall.EXDEV} } + +func testNoReplaceUnsupportedErrors() []error { + return []error{syscall.EINVAL, syscall.ENOSYS, errNoReplaceRenameUnsupported} +} diff --git a/internal/upgrade/skill_path_move_windows_test.go b/internal/upgrade/skill_path_move_windows_test.go index 401e4ed0..2da189d8 100644 --- a/internal/upgrade/skill_path_move_windows_test.go +++ b/internal/upgrade/skill_path_move_windows_test.go @@ -13,6 +13,10 @@ func testCrossDeviceError() error { return &os.LinkError{Op: "rename", Old: "src", New: "dst", Err: windows.ERROR_NOT_SAME_DEVICE} } +func testNoReplaceUnsupportedErrors() []error { + return []error{errNoReplaceRenameUnsupported} +} + func TestCrossPlatformCoverageWindowsCrossDeviceError(t *testing.T) { if !isCrossDeviceError(testCrossDeviceError()) { t.Fatal("ERROR_NOT_SAME_DEVICE must enter the cross-device fallback") diff --git a/internal/upgrade/skill_publish_rename_test.go b/internal/upgrade/skill_publish_rename_test.go index e3996aa3..dedca2c5 100644 --- a/internal/upgrade/skill_publish_rename_test.go +++ b/internal/upgrade/skill_publish_rename_test.go @@ -4,7 +4,6 @@ import ( "errors" "os" "path/filepath" - "syscall" "testing" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" @@ -15,7 +14,7 @@ import ( // Those installs must still publish, and must still refuse to clobber an // occupied destination. func TestCrossPlatformCoverageNoReplaceRenameFallback(t *testing.T) { - unsupported := []error{syscall.EINVAL, syscall.ENOSYS, errNoReplaceRenameUnsupported} + unsupported := testNoReplaceUnsupportedErrors() t.Run("publishes when the filesystem rejects the flag", func(t *testing.T) { for _, unsupportedErr := range unsupported { @@ -47,7 +46,7 @@ func TestCrossPlatformCoverageNoReplaceRenameFallback(t *testing.T) { t.Fatal(err) } } - testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return syscall.EINVAL }) + testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported }) err := renameSkillPathNoReplace(source, destination) if !errors.Is(err, os.ErrExist) { t.Fatalf("occupied destination must report ErrExist, got %v", err) @@ -80,8 +79,9 @@ func TestCrossPlatformCoverageNoReplaceRenameFallback(t *testing.T) { }) t.Run("cross-device errors still reach the copy fallback", func(t *testing.T) { - if isNoReplaceRenameUnsupported(syscall.EXDEV) { - t.Fatal("EXDEV must not be treated as an unsupported flag") + crossDevice := testCrossDeviceError() + if isNoReplaceRenameUnsupported(crossDevice) { + t.Fatal("cross-device error must not be treated as an unsupported flag") } dir := t.TempDir() source := filepath.Join(dir, "source") @@ -95,7 +95,7 @@ func TestCrossPlatformCoverageNoReplaceRenameFallback(t *testing.T) { original := skillPathRenameNoReplaceAtomic testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(from, to string) error { if from == source { - return syscall.EXDEV + return crossDevice } return original(from, to) }) diff --git a/scripts/install-devapp.ps1 b/scripts/install-devapp.ps1 index 50502caa..7a8b7fd2 100644 --- a/scripts/install-devapp.ps1 +++ b/scripts/install-devapp.ps1 @@ -290,7 +290,11 @@ $script:DevSkillBackupRootsThisRun = [System.Collections.Generic.HashSet[string] function Remove-OldDevSkillBackups { $root = Join-Path $HOME ".dws\skill-backups" if (!(Test-Path -LiteralPath $root -PathType Container)) { return } + # Only directories whose names match the DWS backup stamp format (UTC + # yyyyMMdd-HHmmss, optional -N collision suffix) are candidates; any + # other entry is foreign data and is preserved. $dirs = @(Get-ChildItem -LiteralPath $root -Directory -Force -ErrorAction SilentlyContinue | + Where-Object { $_.Name -match '^[0-9]{8}-[0-9]{6}(-[0-9]+)?$' } | Sort-Object -Property Name) $excess = $dirs.Count - $DevSkillBackupKeep foreach ($dir in $dirs) { diff --git a/scripts/install-devapp.sh b/scripts/install-devapp.sh index e2a9219e..0a21c72e 100755 --- a/scripts/install-devapp.sh +++ b/scripts/install-devapp.sh @@ -121,12 +121,34 @@ backup_skill_dir() { # YYYYmmdd-HHMMSS, so the oldest sort first. Best-effort: a removal failure is # reported and never fatal. SKILL_BACKUP_KEEP=5 + +# is_skill_backup_stamp accepts only directory names DWS itself writes: UTC +# YYYYmmdd-HHMMSS, with an optional -N collision suffix. Any other entry in +# the backup root is foreign (user data, unrelated tooling) and is preserved +# so pruning can never remove a directory it cannot prove DWS created. +is_skill_backup_stamp() { + case "$1" in + [0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]) + return 0 ;; + [0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]-*) + # base stamp is YYYYMMDD-HHMMSS (15 chars) + suffix dash (16th); strip + # those 16 chars and require the remainder to be all digits. + _isbs_suffix="${1#????????????????}" + case "$_isbs_suffix" in + ""|*[!0-9]*) return 1 ;; + esac + return 0 ;; + esac + return 1 +} + prune_skill_backups() { prune_root="$HOME/.dws/skill-backups" [ -d "$prune_root" ] || return 0 prune_total=0 for prune_entry in "$prune_root"/*; do [ -d "$prune_entry" ] || continue + is_skill_backup_stamp "${prune_entry##*/}" || continue prune_total=$((prune_total + 1)) done [ "$prune_total" -gt "$SKILL_BACKUP_KEEP" ] || return 0 @@ -134,6 +156,7 @@ prune_skill_backups() { prune_seen=0 for prune_entry in "$prune_root"/*; do [ -d "$prune_entry" ] || continue + is_skill_backup_stamp "${prune_entry##*/}" || continue prune_seen=$((prune_seen + 1)) [ "$prune_seen" -le "$prune_drop" ] || break rm -rf "$prune_entry" || printf ' ⚠️ 旧 Skill 备份清理失败: %s\n' "$prune_entry" >&2 diff --git a/scripts/install-event.sh b/scripts/install-event.sh index d4e65b6b..93a3d392 100755 --- a/scripts/install-event.sh +++ b/scripts/install-event.sh @@ -190,12 +190,34 @@ backup_skill_dir() { # YYYYmmdd-HHMMSS, so the oldest sort first. Best-effort: a removal failure is # reported and never fatal. SKILL_BACKUP_KEEP=5 + +# is_skill_backup_stamp accepts only directory names DWS itself writes: UTC +# YYYYmmdd-HHMMSS, with an optional -N collision suffix. Any other entry in +# the backup root is foreign (user data, unrelated tooling) and is preserved +# so pruning can never remove a directory it cannot prove DWS created. +is_skill_backup_stamp() { + case "$1" in + [0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]) + return 0 ;; + [0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]-*) + # base stamp is YYYYMMDD-HHMMSS (15 chars) + suffix dash (16th); strip + # those 16 chars and require the remainder to be all digits. + _isbs_suffix="${1#????????????????}" + case "$_isbs_suffix" in + ""|*[!0-9]*) return 1 ;; + esac + return 0 ;; + esac + return 1 +} + prune_skill_backups() { prune_root="$HOME/.dws/skill-backups" [ -d "$prune_root" ] || return 0 prune_total=0 for prune_entry in "$prune_root"/*; do [ -d "$prune_entry" ] || continue + is_skill_backup_stamp "${prune_entry##*/}" || continue prune_total=$((prune_total + 1)) done [ "$prune_total" -gt "$SKILL_BACKUP_KEEP" ] || return 0 @@ -203,6 +225,7 @@ prune_skill_backups() { prune_seen=0 for prune_entry in "$prune_root"/*; do [ -d "$prune_entry" ] || continue + is_skill_backup_stamp "${prune_entry##*/}" || continue prune_seen=$((prune_seen + 1)) [ "$prune_seen" -le "$prune_drop" ] || break rm -rf "$prune_entry" || printf ' ⚠️ 旧 Skill 备份清理失败: %s\n' "$prune_entry" >&2 diff --git a/scripts/install-skills.sh b/scripts/install-skills.sh index c3b3de3c..bd48c7a3 100755 --- a/scripts/install-skills.sh +++ b/scripts/install-skills.sh @@ -86,12 +86,34 @@ backup_and_remove_skill_dir() { # Only safe to call once every backup manifest of this run has been consumed, # because a single transaction can span more than one timestamp. DWS_SKILL_BACKUP_KEEP=5 + +# is_skill_backup_stamp accepts only directory names DWS itself writes: UTC +# YYYYmmdd-HHMMSS, with an optional -N collision suffix. Any other entry in +# the backup root is foreign (user data, unrelated tooling) and is preserved +# so pruning can never remove a directory it cannot prove DWS created. +is_skill_backup_stamp() { + case "$1" in + [0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]) + return 0 ;; + [0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]-*) + # base stamp is YYYYMMDD-HHMMSS (15 chars) + suffix dash (16th); strip + # those 16 chars and require the remainder to be all digits. + _isbs_suffix="${1#????????????????}" + case "$_isbs_suffix" in + ""|*[!0-9]*) return 1 ;; + esac + return 0 ;; + esac + return 1 +} + prune_skill_backups() { _psb_root="${HOME}/.dws/skill-backups" [ -d "$_psb_root" ] || return 0 _psb_total=0 for _psb_entry in "$_psb_root"/*; do [ -d "$_psb_entry" ] || continue + is_skill_backup_stamp "${_psb_entry##*/}" || continue _psb_total=$((_psb_total + 1)) done [ "$_psb_total" -gt "$DWS_SKILL_BACKUP_KEEP" ] || return 0 @@ -99,6 +121,7 @@ prune_skill_backups() { _psb_seen=0 for _psb_entry in "$_psb_root"/*; do [ -d "$_psb_entry" ] || continue + is_skill_backup_stamp "${_psb_entry##*/}" || continue _psb_seen=$((_psb_seen + 1)) [ "$_psb_seen" -le "$_psb_drop" ] || break rm -rf "$_psb_entry" || printf ' ⚠️ 旧 Skill 备份清理失败: %s\n' "$_psb_entry" diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 99712993..f170e556 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -744,7 +744,11 @@ $script:SkillBackupRootsThisRun = [System.Collections.Generic.HashSet[string]]:: function Remove-OldSkillBackups { $root = Join-Path $HOME ".dws\skill-backups" if (!(Test-Path -LiteralPath $root -PathType Container)) { return } + # Only directories whose names match the DWS backup stamp format (UTC + # yyyyMMdd-HHmmss, optional -N collision suffix) are candidates; any + # other entry is foreign data and is preserved. $dirs = @(Get-ChildItem -LiteralPath $root -Directory -Force -ErrorAction SilentlyContinue | + Where-Object { $_.Name -match '^[0-9]{8}-[0-9]{6}(-[0-9]+)?$' } | Sort-Object -Property Name) $excess = $dirs.Count - $SkillBackupKeep foreach ($dir in $dirs) { diff --git a/scripts/install.sh b/scripts/install.sh index a9aa5883..0ca489e4 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -104,12 +104,34 @@ backup_and_remove_skill_dir() { # Only safe to call once every backup manifest of this run has been consumed, # because a single transaction can span more than one timestamp. DWS_SKILL_BACKUP_KEEP=5 + +# is_skill_backup_stamp accepts only directory names DWS itself writes: UTC +# YYYYmmdd-HHMMSS, with an optional -N collision suffix. Any other entry in +# the backup root is foreign (user data, unrelated tooling) and is preserved +# so pruning can never remove a directory it cannot prove DWS created. +is_skill_backup_stamp() { + case "$1" in + [0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]) + return 0 ;; + [0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]-*) + # base stamp is YYYYMMDD-HHMMSS (15 chars) + suffix dash (16th); strip + # those 16 chars and require the remainder to be all digits. + _isbs_suffix="${1#????????????????}" + case "$_isbs_suffix" in + ""|*[!0-9]*) return 1 ;; + esac + return 0 ;; + esac + return 1 +} + prune_skill_backups() { _psb_root="${HOME}/.dws/skill-backups" [ -d "$_psb_root" ] || return 0 _psb_total=0 for _psb_entry in "$_psb_root"/*; do [ -d "$_psb_entry" ] || continue + is_skill_backup_stamp "${_psb_entry##*/}" || continue _psb_total=$((_psb_total + 1)) done [ "$_psb_total" -gt "$DWS_SKILL_BACKUP_KEEP" ] || return 0 @@ -117,6 +139,7 @@ prune_skill_backups() { _psb_seen=0 for _psb_entry in "$_psb_root"/*; do [ -d "$_psb_entry" ] || continue + is_skill_backup_stamp "${_psb_entry##*/}" || continue _psb_seen=$((_psb_seen + 1)) [ "$_psb_seen" -le "$_psb_drop" ] || break rm -rf "$_psb_entry" || say " ⚠️ 旧 Skill 备份清理失败: $_psb_entry" diff --git a/test/scripts/install_js_smoke.mjs b/test/scripts/install_js_smoke.mjs index 6af14c7a..79573471 100644 --- a/test/scripts/install_js_smoke.mjs +++ b/test/scripts/install_js_smoke.mjs @@ -44,9 +44,10 @@ * guard, exercised by swapping process.platform * instead of requiring a Windows host. * 14. backup retention — ~/.dws/skill-backups keeps the newest 5 stamps. - * 15. multi publish failure — restores the complete previous Skill set. - * 16. multi backup failure — restores every earlier successful backup. - * 17. mono transaction failure — restores every managed multi Skill after + * 15. unknown preservation — prune never removes non-DWS directories in skill-backups. + * 16. multi publish failure — restores the complete previous Skill set. + * 17. multi backup failure — restores every earlier successful backup. + * 18. mono transaction failure — restores every managed multi Skill after * later backup or mono publish failure. * * Requirements: unix host with tar/zip/unzip on PATH (the same tools @@ -80,6 +81,8 @@ const { UPSTREAM_AGENTS, resolvedAgentTargets, agentTargetDetected, + isSkillBackupStamp, + pruneSkillBackups, backupAndRemoveSkillDir, movePathRecoverablySync, publishCacheAtomically, @@ -1003,6 +1006,49 @@ scenario("skill backups stay bounded to the newest five stamps", () => { } }); +scenario("prune preserves unknown directories in skill-backups", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "dws-installjs-prune-unknown-")); + const home = path.join(tmp, "home"); + const backupRoot = path.join(home, ".dws", "skill-backups"); + try { + for (const stamp of [ + "20260101-000001", "20260101-000002", "20260101-000003", + "20260101-000004", "20260101-000005", "20260101-000006", "20260101-000007", + ]) { + writeFile(path.join(backupRoot, stamp, "placeholder", "SKILL.md"), `${stamp}\n`); + } + const unknowns = [ + "user-personal-backup", + "20260101-00000", // too few digits in the time part + "20260101-000000-abc", // non-numeric suffix + "notes", + "20260101", // missing time portion + ]; + for (const name of unknowns) { + writeFile(path.join(backupRoot, name, "marker.txt"), `unknown:${name}\n`); + } + + pruneSkillBackups(home); + + const remaining = fs.readdirSync(backupRoot).sort(); + const stamped = remaining.filter((n) => isSkillBackupStamp(n)); + assert.equal(stamped.length, 5, `stamped backups = ${stamped.length}, want 5`); + assert.ok(!stamped.includes("20260101-000001"), "oldest stamps pruned first"); + assert.ok(!stamped.includes("20260101-000002"), "second-oldest pruned"); + assert.ok(stamped.includes("20260101-000005"), "newest kept stamps retained"); + for (const name of unknowns) { + assert.ok(remaining.includes(name), `unknown directory ${name} must survive pruning`); + assert.equal( + fs.readFileSync(path.join(backupRoot, name, "marker.txt"), "utf8"), + `unknown:${name}\n`, + `unknown directory ${name} contents must be intact`, + ); + } + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } +}); + scenario("multi set publish failure restores the complete previous set", () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "dws-installjs-multi-set-")); const home = path.join(tmp, "home");