Просмотр исходного кода

fix(skill-sync): clean staged conflict artifacts

Alvin Unreal 1 месяц назад
Родитель
Сommit
16564d59a0

+ 3 - 0
src/hooks/auto-update-checker/index.test.ts

@@ -26,6 +26,9 @@ const skillSyncMocks = {
     installed: [],
     installed: [],
     skippedExisting: [],
     skippedExisting: [],
     failed: [],
     failed: [],
+    staged: [],
+    adopted: [],
+    customized: [],
   })),
   })),
 };
 };
 
 

+ 103 - 0
src/hooks/auto-update-checker/skill-sync.test.ts

@@ -545,6 +545,109 @@ describe('syncBundledSkillsFromPackage', () => {
     );
     );
   });
   });
 
 
+  test('conflict recovery deletes staged directory when adopted back as managed', async () => {
+    const skillName = 'conflict-adopt-skill';
+    const skillSrcDir = path.join(fakePackageRoot, 'src', 'skills', skillName);
+    fs.mkdirSync(skillSrcDir, { recursive: true });
+    fs.writeFileSync(path.join(skillSrcDir, 'SKILL.md'), '# Bundle Content');
+
+    const manifestDir = path.join(fakeDestConfigDir, '.oh-my-opencode-slim');
+    fs.mkdirSync(manifestDir, { recursive: true });
+    const manifestPath = path.join(manifestDir, 'skills-manifest.json');
+
+    const stagedDir = path.join(
+      manifestDir,
+      'skill-updates',
+      '1.0.0',
+      skillName,
+    );
+    fs.mkdirSync(stagedDir, { recursive: true });
+    fs.writeFileSync(path.join(stagedDir, 'SKILL.md'), '# Staged');
+
+    const initialManifest = {
+      schemaVersion: 1,
+      updatedAt: new Date().toISOString(),
+      skills: {
+        [skillName]: {
+          status: 'conflict',
+          packageVersion: '1.0.0',
+          sourceHash: 'old-source-hash',
+          lastManagedHash: 'old-managed-hash',
+          lastSeenHash: 'old-seen-hash',
+          stagedPath: stagedDir,
+          updatedAt: new Date().toISOString(),
+        },
+      },
+    };
+    fs.writeFileSync(manifestPath, JSON.stringify(initialManifest, null, 2));
+
+    const destSkillsDir = path.join(fakeDestConfigDir, 'skills');
+    fs.mkdirSync(destSkillsDir, { recursive: true });
+    // Destination matches the incoming source content exactly
+    const destSkillDir = path.join(destSkillsDir, skillName);
+    fs.mkdirSync(destSkillDir, { recursive: true });
+    fs.writeFileSync(path.join(destSkillDir, 'SKILL.md'), '# Bundle Content');
+
+    const result = await syncBundledSkillsFromPackage(fakePackageRoot);
+
+    expect(result.adopted).toContain(skillName);
+    const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
+    expect(manifest.skills[skillName].status).toBe('managed');
+    expect(manifest.skills[skillName].stagedPath).toBeUndefined();
+    expect(fs.existsSync(stagedDir)).toBe(false);
+  });
+
+  test('conflict overwrite deletes staged directory when destination becomes a non-directory file/symlink', async () => {
+    const skillName = 'conflict-file-overwrite-skill';
+    const skillSrcDir = path.join(fakePackageRoot, 'src', 'skills', skillName);
+    fs.mkdirSync(skillSrcDir, { recursive: true });
+    fs.writeFileSync(path.join(skillSrcDir, 'SKILL.md'), '# Bundle Content');
+
+    const manifestDir = path.join(fakeDestConfigDir, '.oh-my-opencode-slim');
+    fs.mkdirSync(manifestDir, { recursive: true });
+    const manifestPath = path.join(manifestDir, 'skills-manifest.json');
+
+    const stagedDir = path.join(
+      manifestDir,
+      'skill-updates',
+      '1.0.0',
+      skillName,
+    );
+    fs.mkdirSync(stagedDir, { recursive: true });
+    fs.writeFileSync(path.join(stagedDir, 'SKILL.md'), '# Staged');
+
+    const initialManifest = {
+      schemaVersion: 1,
+      updatedAt: new Date().toISOString(),
+      skills: {
+        [skillName]: {
+          status: 'customized',
+          packageVersion: '1.0.0',
+          sourceHash: 'old-source-hash',
+          lastManagedHash: 'old-managed-hash',
+          lastSeenHash: 'old-seen-hash',
+          stagedPath: stagedDir,
+          updatedAt: new Date().toISOString(),
+        },
+      },
+    };
+    fs.writeFileSync(manifestPath, JSON.stringify(initialManifest, null, 2));
+
+    const destSkillsDir = path.join(fakeDestConfigDir, 'skills');
+    fs.mkdirSync(destSkillsDir, { recursive: true });
+    // Destination is a file (conflict)
+    const destSkillPath = path.join(destSkillsDir, skillName);
+    fs.writeFileSync(destSkillPath, 'some conflict file');
+
+    const result = await syncBundledSkillsFromPackage(fakePackageRoot);
+
+    expect(result.skippedExisting).toContain(skillName);
+    const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
+    expect(manifest.skills[skillName].status).toBe('conflict');
+    expect(manifest.skills[skillName].stagedPath).toBeUndefined();
+    expect(fs.existsSync(stagedDir)).toBe(false);
+  });
+
   test('fails closed (only installs missing) when manifest validation fails (schemaVersion mismatch)', async () => {
   test('fails closed (only installs missing) when manifest validation fails (schemaVersion mismatch)', async () => {
     const missingSkill = 'missing-skill';
     const missingSkill = 'missing-skill';
     const existingSkill = 'existing-skill';
     const existingSkill = 'existing-skill';

+ 11 - 0
src/hooks/auto-update-checker/skill-sync.ts

@@ -709,6 +709,10 @@ export function syncBundledSkillsFromPackage(
           );
           );
           skippedExisting.push(skill.name);
           skippedExisting.push(skill.name);
           const sourceHash = computeDirectoryHash(sourcePath);
           const sourceHash = computeDirectoryHash(sourcePath);
+          const entry = manifest.skills[skill.name];
+          if (entry?.stagedPath) {
+            removeManagedStagedPath(entry.stagedPath, manifestDir, skill.name);
+          }
           manifest.skills[skill.name] = {
           manifest.skills[skill.name] = {
             status: 'conflict',
             status: 'conflict',
             packageVersion,
             packageVersion,
@@ -1086,6 +1090,13 @@ export function syncBundledSkillsFromPackage(
             }
             }
           } else if (entry.status === 'conflict') {
           } else if (entry.status === 'conflict') {
             if (destHash === sourceHash) {
             if (destHash === sourceHash) {
+              if (entry.stagedPath) {
+                removeManagedStagedPath(
+                  entry.stagedPath,
+                  manifestDir,
+                  skill.name,
+                );
+              }
               entry.status = 'managed';
               entry.status = 'managed';
               entry.packageVersion = packageVersion;
               entry.packageVersion = packageVersion;
               entry.sourceHash = sourceHash;
               entry.sourceHash = sourceHash;