mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-03 00:17:47 +00:00
fix(shared-manager): allow external claude symlink chains
This commit is contained in:
@@ -149,7 +149,7 @@ class SharedManager {
|
|||||||
/**
|
/**
|
||||||
* Detect circular symlink before creation
|
* Detect circular symlink before creation
|
||||||
*/
|
*/
|
||||||
private detectCircularSymlink(target: string, linkPath: string): boolean {
|
private detectCircularSymlink(target: string): boolean {
|
||||||
// Check if target exists and is symlink
|
// Check if target exists and is symlink
|
||||||
if (!fs.existsSync(target)) {
|
if (!fs.existsSync(target)) {
|
||||||
return false;
|
return false;
|
||||||
@@ -165,15 +165,13 @@ class SharedManager {
|
|||||||
const targetLink = fs.readlinkSync(target);
|
const targetLink = fs.readlinkSync(target);
|
||||||
const resolvedTarget = path.resolve(path.dirname(target), targetLink);
|
const resolvedTarget = path.resolve(path.dirname(target), targetLink);
|
||||||
|
|
||||||
// Check if target points back to our shared dir or link path
|
// Only treat targets inside the managed shared root as circular.
|
||||||
|
// Existing shared symlinks may already resolve through ~/.claude/ to an
|
||||||
|
// external repo, which is a supported upgrade path rather than a loop.
|
||||||
const sharedDir = this.resolveCanonicalPath(path.join(getCcsDir(), 'shared'));
|
const sharedDir = this.resolveCanonicalPath(path.join(getCcsDir(), 'shared'));
|
||||||
const canonicalResolvedTarget = this.resolveCanonicalPath(resolvedTarget);
|
const canonicalResolvedTarget = this.resolveCanonicalPath(resolvedTarget);
|
||||||
const canonicalLinkPath = this.resolveCanonicalPath(linkPath);
|
|
||||||
|
|
||||||
if (
|
if (this.isPathWithinDirectory(canonicalResolvedTarget, sharedDir)) {
|
||||||
this.isPathWithinDirectory(canonicalResolvedTarget, sharedDir) ||
|
|
||||||
canonicalResolvedTarget === canonicalLinkPath
|
|
||||||
) {
|
|
||||||
console.log(warn(`Circular symlink detected: ${target} → ${resolvedTarget}`));
|
console.log(warn(`Circular symlink detected: ${target} → ${resolvedTarget}`));
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -219,7 +217,7 @@ class SharedManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check for circular symlink
|
// Check for circular symlink
|
||||||
if (this.detectCircularSymlink(claudePath, sharedPath)) {
|
if (this.detectCircularSymlink(claudePath)) {
|
||||||
console.log(warn(`Skipping ${item.name}: circular symlink detected`));
|
console.log(warn(`Skipping ${item.name}: circular symlink detected`));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -154,6 +154,71 @@ describe('SharedManager', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('shared symlink lifecycle', () => {
|
||||||
|
it('preserves external ~/.claude symlinks during upgrade reconciliation', () => {
|
||||||
|
const manager = new SharedManager();
|
||||||
|
const externalCommandsDir = path.join(tempRoot, 'Documents', 'claude-config', 'commands');
|
||||||
|
const externalSettingsPath = path.join(
|
||||||
|
tempRoot,
|
||||||
|
'Documents',
|
||||||
|
'claude-config',
|
||||||
|
'settings.json'
|
||||||
|
);
|
||||||
|
const claudeCommandsPath = path.join(claudeDir(), 'commands');
|
||||||
|
const claudeSettingsPath = path.join(claudeDir(), 'settings.json');
|
||||||
|
const sharedCommandsPath = path.join(ccsDir(), 'shared', 'commands');
|
||||||
|
const sharedSettingsPath = path.join(ccsDir(), 'shared', 'settings.json');
|
||||||
|
const logSpy = spyOn(console, 'log').mockImplementation(() => {});
|
||||||
|
|
||||||
|
fs.mkdirSync(externalCommandsDir, { recursive: true });
|
||||||
|
fs.mkdirSync(path.dirname(externalSettingsPath), { recursive: true });
|
||||||
|
fs.mkdirSync(claudeDir(), { recursive: true });
|
||||||
|
fs.mkdirSync(path.join(ccsDir(), 'shared'), { recursive: true });
|
||||||
|
fs.writeFileSync(externalSettingsPath, JSON.stringify({ theme: 'dark' }), 'utf8');
|
||||||
|
fs.symlinkSync(externalCommandsDir, claudeCommandsPath, 'dir');
|
||||||
|
fs.symlinkSync(externalSettingsPath, claudeSettingsPath, 'file');
|
||||||
|
fs.symlinkSync(claudeCommandsPath, sharedCommandsPath, 'dir');
|
||||||
|
fs.symlinkSync(claudeSettingsPath, sharedSettingsPath, 'file');
|
||||||
|
|
||||||
|
manager.ensureSharedDirectories();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
logSpy.mock.calls.some(([message]) =>
|
||||||
|
String(message).includes('Skipping commands: circular symlink detected') ||
|
||||||
|
String(message).includes('Skipping settings.json: circular symlink detected')
|
||||||
|
)
|
||||||
|
).toBe(false);
|
||||||
|
expect(fs.lstatSync(sharedCommandsPath).isSymbolicLink()).toBe(true);
|
||||||
|
expect(
|
||||||
|
path.resolve(path.dirname(sharedCommandsPath), fs.readlinkSync(sharedCommandsPath))
|
||||||
|
).toBe(claudeCommandsPath);
|
||||||
|
expect(fs.lstatSync(sharedSettingsPath).isSymbolicLink()).toBe(true);
|
||||||
|
expect(
|
||||||
|
path.resolve(path.dirname(sharedSettingsPath), fs.readlinkSync(sharedSettingsPath))
|
||||||
|
).toBe(claudeSettingsPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still blocks real circular links back into ~/.ccs/shared', () => {
|
||||||
|
const manager = new SharedManager();
|
||||||
|
const claudeCommandsPath = path.join(claudeDir(), 'commands');
|
||||||
|
const sharedCommandsPath = path.join(ccsDir(), 'shared', 'commands');
|
||||||
|
const logSpy = spyOn(console, 'log').mockImplementation(() => {});
|
||||||
|
|
||||||
|
fs.mkdirSync(claudeDir(), { recursive: true });
|
||||||
|
fs.mkdirSync(sharedCommandsPath, { recursive: true });
|
||||||
|
fs.symlinkSync(sharedCommandsPath, claudeCommandsPath, 'dir');
|
||||||
|
|
||||||
|
manager.ensureSharedDirectories();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
logSpy.mock.calls.some(([message]) =>
|
||||||
|
String(message).includes('Skipping commands: circular symlink detected')
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
expect(fs.lstatSync(sharedCommandsPath).isDirectory()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('marketplace registry ownership', () => {
|
describe('marketplace registry ownership', () => {
|
||||||
it('writes global and instance registries with different authoritative install locations', () => {
|
it('writes global and instance registries with different authoritative install locations', () => {
|
||||||
const globalRegistryPath = path.join(claudeDir(), 'plugins', 'known_marketplaces.json');
|
const globalRegistryPath = path.join(claudeDir(), 'plugins', 'known_marketplaces.json');
|
||||||
|
|||||||
Reference in New Issue
Block a user