Files
ccs/src/management/shared-manager/shared-dir-linker.ts
T
Nelson Melo 481f013ec2 fix(shared-manager): adopt diverged settings.json content before re-linking
Claude Code saves settings.json atomically (temp file + rename), which
replaces the managed shared symlink with a regular file holding the
user's latest changes (see #57). The launch-time relink then deleted
that file without reading it, silently reverting plugin enables and any
other in-session settings change on every profile relaunch.

Adopt the diverged file's content into the canonical ~/.claude file
(with a .bak-ccs-adopt backup) before restoring the symlink, at both
the shared-level and instance-level reconciliation points.

Fixes #1681
2026-07-31 12:14:29 -04:00

285 lines
9.0 KiB
TypeScript

/**
* SharedManager - shared directory linking entrypoints.
*
* Extracted from the original monolithic shared-manager.ts. Owns the
* creation of the ~/.ccs/shared/* symlinks pointing at ~/.claude/* and
* the per-instance links for commands/skills/agents/plugins/settings.
*
* Plugin-layout internals (the four functions that operate on the plugins/
* subtree) live in plugin-layout-internals.ts. This file keeps only the
* high-level entrypoints and circular-symlink detection so each file stays
* focused and under the 400 LOC target.
*
* These functions take an explicit roots object so they remain decoupled
* from SharedManager instance state.
*/
import * as fs from 'fs';
import * as path from 'path';
import { info, warn } from '../../utils/ui';
import {
copyDirectoryFallback,
getLstatSync,
isPathWithinDirectory,
removeExistingPath,
resolveCanonicalPath,
symlinkPointsTo,
} from './fs-helpers';
import type { PluginMetadataRoots } from './plugin-metadata-normalizer';
import {
normalizeMarketplaceRegistryPaths,
normalizePluginRegistryPaths,
} from './plugin-metadata-normalizer';
import {
detachManagedPluginLayout,
ensureSharedPluginLayoutDefaults,
linkInstancePlugins,
} from './plugin-layout-internals';
import { SHARED_ITEMS } from './types';
/**
* Roots for the shared-dir linker. Reuses PluginMetadataRoots because the
* linker operates on the same three roots.
*/
export type LinkerRoots = PluginMetadataRoots;
/**
* Detect a circular symlink before creation. A symlink is circular when its
* target (raw or canonical) points back inside the shared root.
*/
export function detectCircularSymlink(target: string, sharedDir: string): boolean {
try {
const stats = fs.lstatSync(target);
if (!stats.isSymbolicLink()) {
return false;
}
const targetLink = fs.readlinkSync(target);
const resolvedTarget = path.resolve(path.dirname(target), targetLink);
const sharedDirPath = path.resolve(sharedDir);
// A raw target path pointing back into ~/.ccs/shared is already unsafe.
// Re-pointing ~/.ccs/shared/* to ~/.claude/* would turn it into a real
// loop, even if the current ~/.ccs/shared entry ultimately resolves to
// an external path.
if (isPathWithinDirectory(resolvedTarget, sharedDirPath)) {
console.log(warn(`Circular symlink detected: ${target} → ${resolvedTarget}`));
return true;
}
// 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 sharedDirCanonical = resolveCanonicalPath(sharedDirPath);
const canonicalResolvedTarget = resolveCanonicalPath(resolvedTarget);
if (isPathWithinDirectory(canonicalResolvedTarget, sharedDirCanonical)) {
console.log(warn(`Circular symlink detected: ${target} → ${resolvedTarget}`));
return true;
}
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
return false;
}
throw err;
}
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.
*/
export function ensureSharedDirectories(roots: LinkerRoots): void {
const claudeDir = roots.claudeDir;
const sharedDir = roots.sharedDir;
if (!getLstatSync(claudeDir)) {
console.log(info('Creating ~/.claude/ directory structure'));
fs.mkdirSync(claudeDir, { recursive: true, mode: 0o700 });
}
if (!getLstatSync(sharedDir)) {
fs.mkdirSync(sharedDir, { recursive: true, mode: 0o700 });
}
ensureSharedPluginLayoutDefaults(claudeDir);
for (const item of SHARED_ITEMS) {
const claudePath = path.join(claudeDir, item.name);
const sharedPath = path.join(sharedDir, item.name);
if (!getLstatSync(claudePath)) {
if (item.type === 'directory') {
fs.mkdirSync(claudePath, { recursive: true, mode: 0o700 });
} else if (item.type === 'file') {
fs.writeFileSync(claudePath, JSON.stringify({}, null, 2), 'utf8');
}
}
if (detectCircularSymlink(claudePath, sharedDir)) {
console.log(warn(`Skipping ${item.name}: circular symlink detected`));
continue;
}
if (getLstatSync(sharedPath)) {
try {
const stats = fs.lstatSync(sharedPath);
if (stats.isSymbolicLink()) {
const currentTarget = fs.readlinkSync(sharedPath);
const resolvedTarget = path.resolve(path.dirname(sharedPath), currentTarget);
if (resolvedTarget === claudePath) {
continue;
}
}
} catch (_err) {
// Continue to recreate
}
if (item.type === 'file') {
adoptDivergedFileContent(sharedPath, claudePath);
}
if (item.type === 'directory') {
fs.rmSync(sharedPath, { recursive: true, force: true });
} else {
fs.unlinkSync(sharedPath);
}
}
try {
const symlinkType = item.type === 'directory' ? 'dir' : 'file';
fs.symlinkSync(claudePath, sharedPath, symlinkType);
} catch (_err) {
if (process.platform === 'win32') {
if (item.type === 'directory') {
copyDirectoryFallback(claudePath, sharedPath);
} else if (item.type === 'file') {
fs.copyFileSync(claudePath, sharedPath);
}
console.log(
warn(`Symlink failed for ${item.name}, copied instead (enable Developer Mode)`)
);
} else {
throw _err;
}
}
}
}
/**
* Link shared directories into a specific instance path.
*/
export function linkSharedDirectories(roots: LinkerRoots, instancePath: string): void {
ensureSharedDirectories(roots);
const sharedDir = roots.sharedDir;
for (const item of SHARED_ITEMS) {
if (item.name === 'plugins') {
linkInstancePlugins(roots, instancePath);
continue;
}
const linkPath = path.join(instancePath, item.name);
const targetPath = path.join(sharedDir, item.name);
if (item.type === 'file') {
adoptDivergedFileContent(linkPath, path.join(roots.claudeDir, item.name));
}
removeExistingPath(linkPath, item.type);
try {
const symlinkType = item.type === 'directory' ? 'dir' : 'file';
fs.symlinkSync(targetPath, linkPath, symlinkType);
} catch (_err) {
if (process.platform === 'win32') {
if (item.type === 'directory') {
copyDirectoryFallback(targetPath, linkPath);
} else if (item.type === 'file') {
fs.copyFileSync(targetPath, linkPath);
}
console.log(
warn(`Symlink failed for ${item.name}, copied instead (enable Developer Mode)`)
);
} else {
throw _err;
}
}
}
// Preserve original behavior: linkSharedDirectories always concludes by
// normalizing plugin + marketplace metadata for the freshly linked
// instance. migrateFromV311 relies on this side effect.
normalizePluginRegistryPaths(roots, instancePath);
normalizeMarketplaceRegistryPaths(roots, instancePath);
}
/**
* Detach shared-directory symlinks from an instance, removing only entries
* that point back at the shared root.
*/
export function detachSharedDirectories(roots: LinkerRoots, instancePath: string): void {
ensureSharedDirectories(roots);
const sharedDir = roots.sharedDir;
for (const item of SHARED_ITEMS) {
const managedPath = path.join(instancePath, item.name);
if (!fs.existsSync(managedPath)) {
continue;
}
if (item.name === 'plugins') {
detachManagedPluginLayout(roots, instancePath);
continue;
}
const stats = fs.lstatSync(managedPath);
if (!stats.isSymbolicLink()) {
continue;
}
if (symlinkPointsTo(managedPath, path.join(sharedDir, item.name))) {
removeExistingPath(managedPath, item.type);
}
}
}