mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-03 20:13:02 +00:00
fix(shared-manager): adopt diverged plugin registry files in linkInstancePlugins
The same atomic-rename divergence occurs in the plugins subtree: a plugin install inside a session rewrites plugins/installed_plugins.json, replacing the instance-level symlink with a regular file. The per-launch relink then discarded it, so the plugin was effectively uninstalled on every relaunch while settings.json still marked it enabled — sessions then fail with 'Unknown skill: <plugin>:<skill>'. Move adoptDivergedFileContent to fs-helpers (avoids a circular import) and apply it to file-type plugin entries before re-linking.
This commit is contained in:
1 parent
481f013ec2
commit
fa6b27f159
4 files changed
+70
-35
No files matched your search
@@ -11,8 +11,44 @@
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
import { info } from '../../utils/ui';
|
||||
import type { SharedItem } from './types';
|
||||
|
||||
/**
|
||||
* Claude Code saves managed files (settings.json, plugins/installed_plugins.json)
|
||||
* with an atomic write (temp file + rename), which replaces a managed symlink
|
||||
* with a regular file holding the user's latest changes — see #57 and #1681.
|
||||
* When reconciliation finds such a diverged regular file, adopt its content
|
||||
* into the canonical ~/.claude file before re-creating the symlink, instead
|
||||
* of discarding the user's changes. The previous canonical content is kept
|
||||
* in a `.bak-ccs-adopt` backup alongside it.
|
||||
*/
|
||||
export function adoptDivergedFileContent(divergedPath: string, canonicalPath: string): void {
|
||||
try {
|
||||
const stats = fs.lstatSync(divergedPath);
|
||||
if (!stats.isFile()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const diverged = fs.readFileSync(divergedPath);
|
||||
const current = fs.existsSync(canonicalPath) ? fs.readFileSync(canonicalPath) : null;
|
||||
if (current && diverged.equals(current)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (current) {
|
||||
fs.copyFileSync(canonicalPath, `${canonicalPath}.bak-ccs-adopt`);
|
||||
}
|
||||
fs.writeFileSync(canonicalPath, diverged);
|
||||
console.log(
|
||||
info(`Adopted diverged ${path.basename(divergedPath)} content into ${canonicalPath}`)
|
||||
);
|
||||
} catch (_err) {
|
||||
// Best effort: fall through to standard re-link behavior.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return canonical realpath for a path. Falls back to the lexical resolve
|
||||
* when realpath fails (e.g. path does not exist).
|
||||
|
||||
@@ -14,7 +14,12 @@ import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
import { warn } from '../../utils/ui';
|
||||
import { copyDirectoryFallback, removeExistingPath, symlinkPointsTo } from './fs-helpers';
|
||||
import {
|
||||
adoptDivergedFileContent,
|
||||
copyDirectoryFallback,
|
||||
removeExistingPath,
|
||||
symlinkPointsTo,
|
||||
} from './fs-helpers';
|
||||
import type { PluginMetadataRoots } from './plugin-metadata-normalizer';
|
||||
import { reconcileLocalMarketplaceRegistry } from './plugin-metadata-normalizer';
|
||||
import {
|
||||
@@ -87,6 +92,10 @@ export function linkInstancePlugins(roots: PluginLayoutRoots, instancePath: stri
|
||||
const targetEntryPath = path.join(targetPath, item.name);
|
||||
const linkEntryPath = path.join(linkPath, item.name);
|
||||
|
||||
if (item.type === 'file') {
|
||||
adoptDivergedFileContent(linkEntryPath, path.join(roots.claudeDir, 'plugins', item.name));
|
||||
}
|
||||
|
||||
removeExistingPath(linkEntryPath, item.type);
|
||||
|
||||
try {
|
||||
|
||||
@@ -19,6 +19,7 @@ import * as path from 'path';
|
||||
|
||||
import { info, warn } from '../../utils/ui';
|
||||
import {
|
||||
adoptDivergedFileContent,
|
||||
copyDirectoryFallback,
|
||||
getLstatSync,
|
||||
isPathWithinDirectory,
|
||||
@@ -88,40 +89,6 @@ export function detectCircularSymlink(target: string, sharedDir: string): boolea
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Claude Code saves settings.json with an atomic write (temp file + rename),
|
||||
* which replaces a managed symlink with a regular file holding the user's
|
||||
* latest changes (e.g. enabledPlugins toggles from /plugins) — see #57.
|
||||
* When reconciliation finds such a diverged regular file, adopt its content
|
||||
* into the canonical ~/.claude file before re-creating the symlink, instead
|
||||
* of discarding the user's changes. The previous canonical content is kept
|
||||
* in a `.bak-ccs-adopt` backup alongside it.
|
||||
*/
|
||||
function adoptDivergedFileContent(divergedPath: string, canonicalPath: string): void {
|
||||
try {
|
||||
const stats = fs.lstatSync(divergedPath);
|
||||
if (!stats.isFile()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const diverged = fs.readFileSync(divergedPath);
|
||||
const current = fs.existsSync(canonicalPath) ? fs.readFileSync(canonicalPath) : null;
|
||||
if (current && diverged.equals(current)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (current) {
|
||||
fs.copyFileSync(canonicalPath, `${canonicalPath}.bak-ccs-adopt`);
|
||||
}
|
||||
fs.writeFileSync(canonicalPath, diverged);
|
||||
console.log(
|
||||
info(`Adopted diverged ${path.basename(divergedPath)} content into ${canonicalPath}`)
|
||||
);
|
||||
} catch (_err) {
|
||||
// Best effort: fall through to standard re-link behavior.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure shared directories exist as symlinks to ~/.claude/ and that the
|
||||
* plugin layout default directories and registry files are present.
|
||||
|
||||
@@ -347,6 +347,29 @@ describe('SharedManager', () => {
|
||||
expect(readJson(claudeSettingsPath)).toEqual({ theme: 'dark' });
|
||||
});
|
||||
|
||||
it('adopts a diverged instance plugin registry during instance linking', () => {
|
||||
const manager = new SharedManager();
|
||||
const instancePath = instanceDir('work');
|
||||
const claudeRegistryPath = path.join(claudeDir(), 'plugins', 'installed_plugins.json');
|
||||
const instanceRegistryPath = path.join(instancePath, 'plugins', 'installed_plugins.json');
|
||||
|
||||
fs.mkdirSync(claudeDir(), { recursive: true });
|
||||
fs.mkdirSync(path.join(instancePath, 'plugins'), { recursive: true });
|
||||
writeJson(claudeRegistryPath, { version: 2, plugins: {} });
|
||||
// Simulate Claude Code's atomic save replacing the managed symlink with
|
||||
// a regular file that records a plugin installed inside a session.
|
||||
writeJson(instanceRegistryPath, {
|
||||
version: 2,
|
||||
plugins: { 'demo@demo-market': [{ scope: 'user', version: '1.0.0' }] },
|
||||
});
|
||||
|
||||
manager.linkSharedDirectories(instancePath);
|
||||
|
||||
expect(fs.lstatSync(instanceRegistryPath).isSymbolicLink()).toBe(true);
|
||||
const adopted = readJson(claudeRegistryPath) as { plugins: Record<string, unknown> };
|
||||
expect(Object.keys(adopted.plugins)).toContain('demo@demo-market');
|
||||
});
|
||||
|
||||
it('does not create a backup when the diverged copy matches the canonical file', () => {
|
||||
const manager = new SharedManager();
|
||||
const claudeSettingsPath = path.join(claudeDir(), 'settings.json');
|
||||
|
||||
Reference in new issue
Block a user