Browse Source

fix(auto-update): surface staged/customized skill sync outcomes

Alvin Unreal 1 month ago
parent
commit
7cdaea1737

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

@@ -274,6 +274,45 @@ describe('auto-update-checker/index', () => {
     });
   });
 
+  test('includes staged and customized skills in success toast', async () => {
+    checkerMocks.findPluginEntry.mockImplementation(() => ({
+      pinnedVersion: null,
+      isPinned: false,
+    }));
+    checkerMocks.getCachedVersion.mockImplementation(() => '0.9.1');
+    checkerMocks.getLatestCompatibleVersion.mockImplementation(async () => ({
+      latestVersion: '0.9.11',
+      latestMajorVersion: null,
+      blockedByMajor: false,
+    }));
+    skillSyncMocks.syncBundledSkillsFromPackage.mockImplementation(() => ({
+      installed: ['reflect'],
+      skippedExisting: [],
+      failed: [],
+      staged: ['worktrees'],
+      customized: ['my-custom-skill'],
+    }));
+
+    const { createAutoUpdateCheckerHook } = await import(
+      `./index?test=${importCounter++}`
+    );
+    const { ctx, showToast } = createCtx();
+
+    const hook = createAutoUpdateCheckerHook(ctx as never);
+    hook.event({ event: { type: 'session.created', properties: {} } });
+    await waitForCalls(showToast);
+
+    expect(showToast).toHaveBeenCalledWith({
+      body: {
+        title: 'OMO-Slim Updated!',
+        message:
+          'v0.9.1 → v0.9.11\nAdded bundled skills: reflect\nStaged skill updates: worktrees\nCustomized skills: my-custom-skill\nRestart OpenCode to apply.',
+        variant: 'success',
+        duration: 8000,
+      },
+    });
+  });
+
   test('updates enabled companion after plugin auto-update', async () => {
     checkerMocks.findPluginEntry.mockImplementation(() => ({
       pinnedVersion: null,

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

@@ -203,12 +203,16 @@ async function runBackgroundUpdateCheck(
 
   if (installSuccess) {
     let installedSkills: string[] = [];
+    let stagedSkills: string[] = [];
+    let customizedSkills: string[] = [];
     let companionUpdated = false;
     let companionWillRetry = false;
     const packageRoot = path.join(installDir, 'node_modules', PACKAGE_NAME);
     try {
       const syncResult = syncBundledSkillsFromPackage(packageRoot);
       installedSkills = syncResult.installed;
+      stagedSkills = syncResult.staged ?? [];
+      customizedSkills = syncResult.customized ?? [];
       if (syncResult.failed.length > 0) {
         log(
           `[auto-update-checker] Skill sync warnings/failures: ${syncResult.failed.join(', ')}`,
@@ -257,6 +261,12 @@ async function runBackgroundUpdateCheck(
     if (installedSkills.length > 0) {
       messageLines.push(`Added bundled skills: ${installedSkills.join(', ')}`);
     }
+    if (stagedSkills.length > 0) {
+      messageLines.push(`Staged skill updates: ${stagedSkills.join(', ')}`);
+    }
+    if (customizedSkills.length > 0) {
+      messageLines.push(`Customized skills: ${customizedSkills.join(', ')}`);
+    }
     if (companionUpdated) {
       messageLines.push('Companion updated.');
     } else if (companionWillRetry) {

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

@@ -791,7 +791,7 @@ describe('syncBundledSkillsFromPackage', () => {
     expect(manifest.skills[skillName].updatedAt).not.toBe(originalTime);
   });
 
-  test('deleted to customized: refreshes packageVersion and sourceHash', async () => {
+  test('deleted to customized: stages and marks customized', async () => {
     const skillName = 'recreated-custom-skill';
     const skillSrcDir = path.join(fakePackageRoot, 'src', 'skills', skillName);
     fs.mkdirSync(skillSrcDir, { recursive: true });
@@ -842,12 +842,12 @@ describe('syncBundledSkillsFromPackage', () => {
     const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
     expect(manifest.skills[skillName].status).toBe('customized');
     expect(manifest.skills[skillName].packageVersion).toBe('1.2.3');
-    // Content hash of current source from fakePackageRoot
-    const { computeDirectoryHash } = await import(
-      `./skill-sync?test=${importCounter++}`
-    );
-    expect(manifest.skills[skillName].sourceHash).toBe(
-      computeDirectoryHash(skillSrcDir),
+    expect(manifest.skills[skillName].sourceHash).toBe('');
+    const stagedPath = manifest.skills[skillName].stagedPath as string;
+    expect(stagedPath).toBeDefined();
+    expect(fs.existsSync(stagedPath)).toBe(true);
+    expect(fs.readFileSync(path.join(stagedPath, 'SKILL.md'), 'utf-8')).toBe(
+      '# Current Bundled Content',
     );
   });
 

+ 41 - 9
src/hooks/auto-update-checker/skill-sync.ts

@@ -1049,15 +1049,47 @@ export function syncBundledSkillsFromPackage(
                 `[skill-sync] Skill ${skill.name} re-created by user (matching current). Adopted as managed.`,
               );
             } else {
-              entry.status = 'customized';
-              entry.packageVersion = packageVersion;
-              entry.sourceHash = sourceHash;
-              entry.lastSeenHash = destHash;
-              entry.updatedAt = new Date().toISOString();
-              skippedExisting.push(skill.name);
-              log(
-                `[skill-sync] Skill ${skill.name} re-created by user (custom). Marked customized.`,
-              );
+              try {
+                const stagedSkillDir = path.join(
+                  manifestDir,
+                  'skill-updates',
+                  packageVersion,
+                  skill.name,
+                );
+                if (entry.stagedPath && entry.stagedPath !== stagedSkillDir) {
+                  removeManagedStagedPath(
+                    entry.stagedPath,
+                    manifestDir,
+                    skill.name,
+                  );
+                }
+                if (existsSync(stagedSkillDir)) {
+                  rmSync(stagedSkillDir, { recursive: true, force: true });
+                }
+                mkdirSync(stagedSkillDir, { recursive: true });
+                copyDirRecursive(sourcePath, stagedSkillDir);
+
+                entry.status = 'customized';
+                entry.packageVersion = packageVersion;
+                entry.sourceHash = '';
+                entry.lastManagedHash = sourceHash;
+                entry.lastSeenHash = destHash;
+                entry.stagedPath = stagedSkillDir;
+                entry.updatedAt = new Date().toISOString();
+
+                staged.push(skill.name);
+                customized.push(skill.name);
+                skippedExisting.push(skill.name);
+                log(
+                  `[skill-sync] Skill ${skill.name} re-created by user (custom). Marked customized and staged.`,
+                );
+              } catch (err) {
+                log(
+                  `[skill-sync] Failed to stage update for deleted/recreated skill ${skill.name}:`,
+                  err,
+                );
+                failed.push(skill.name);
+              }
             }
           } else if (entry.status === 'conflict') {
             skippedExisting.push(skill.name);