fix(skills): guard pruneSkillBackups against non-DWS directories

Restrict backup pruning to directories whose names match the DWS stamp
format (YYYYmmdd-HHMMSS with optional -N suffix) across all 8 installer
surfaces (Go, npm, 4 shell, 2 PowerShell). Unknown directories in
~/.dws/skill-backups are now preserved. Also fixes Windows coverage test
portability and covers the remaining macOS changed-code gap (retire
warning loop in runUpgrade).
This commit is contained in:
玉澜
2026-08-17 11:42:43 +08:00
parent ae1565c0ff
commit 14c2569cfb
15 changed files with 264 additions and 18 deletions
+4
View File
@@ -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
+17 -3
View File
@@ -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,
+9 -3
View File
@@ -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" {
+17 -3
View File
@@ -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())
}
}
+54
View File
@@ -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) {
@@ -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}
}
@@ -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")
@@ -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)
})
+4
View File
@@ -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) {
+23
View File
@@ -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
+23
View File
@@ -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
+23
View File
@@ -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"
+4
View File
@@ -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) {
+23
View File
@@ -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"
+49 -3
View File
@@ -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");