mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-11 12:09:03 +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 fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
|
import { info } from '../../utils/ui';
|
||||||
import type { SharedItem } from './types';
|
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
|
* Return canonical realpath for a path. Falls back to the lexical resolve
|
||||||
* when realpath fails (e.g. path does not exist).
|
* when realpath fails (e.g. path does not exist).
|
||||||
|
|||||||
@@ -14,7 +14,12 @@ import * as fs from 'fs';
|
|||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
import { warn } from '../../utils/ui';
|
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 type { PluginMetadataRoots } from './plugin-metadata-normalizer';
|
||||||
import { reconcileLocalMarketplaceRegistry } from './plugin-metadata-normalizer';
|
import { reconcileLocalMarketplaceRegistry } from './plugin-metadata-normalizer';
|
||||||
import {
|
import {
|
||||||
@@ -87,6 +92,10 @@ export function linkInstancePlugins(roots: PluginLayoutRoots, instancePath: stri
|
|||||||
const targetEntryPath = path.join(targetPath, item.name);
|
const targetEntryPath = path.join(targetPath, item.name);
|
||||||
const linkEntryPath = path.join(linkPath, 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);
|
removeExistingPath(linkEntryPath, item.type);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import * as path from 'path';
|
|||||||
|
|
||||||
import { info, warn } from '../../utils/ui';
|
import { info, warn } from '../../utils/ui';
|
||||||
import {
|
import {
|
||||||
|
adoptDivergedFileContent,
|
||||||
copyDirectoryFallback,
|
copyDirectoryFallback,
|
||||||
getLstatSync,
|
getLstatSync,
|
||||||
isPathWithinDirectory,
|
isPathWithinDirectory,
|
||||||
@@ -88,40 +89,6 @@ export function detectCircularSymlink(target: string, sharedDir: string): boolea
|
|||||||
return false;
|
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
|
* Ensure shared directories exist as symlinks to ~/.claude/ and that the
|
||||||
* plugin layout default directories and registry files are present.
|
* plugin layout default directories and registry files are present.
|
||||||
|
|||||||
@@ -347,6 +347,29 @@ describe('SharedManager', () => {
|
|||||||
expect(readJson(claudeSettingsPath)).toEqual({ theme: 'dark' });
|
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', () => {
|
it('does not create a backup when the diverged copy matches the canonical file', () => {
|
||||||
const manager = new SharedManager();
|
const manager = new SharedManager();
|
||||||
const claudeSettingsPath = path.join(claudeDir(), 'settings.json');
|
const claudeSettingsPath = path.join(claudeDir(), 'settings.json');
|
||||||
|
|||||||
Reference in new issue
Block a user