mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-03 20:13:02 +00:00
1 parent
7fb56d62e1
commit
6841025bb4
14 files changed
+1011
-70
No files matched your search
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"scope": "src/**/*.{ts,tsx,js,jsx,mjs,cjs}",
|
||||
"syncFs": {
|
||||
"totalOccurrences": 2427,
|
||||
"filesAffected": 258,
|
||||
"hotpathOccurrences": 1142,
|
||||
"hotpathFilesAffected": 152,
|
||||
"totalOccurrences": 2446,
|
||||
"filesAffected": 260,
|
||||
"hotpathOccurrences": 1161,
|
||||
"hotpathFilesAffected": 154,
|
||||
"topHotpathFiles": [
|
||||
{
|
||||
"file": "src/management/shared-manager/diverged-file-adopter.ts",
|
||||
@@ -498,10 +498,10 @@
|
||||
"maintainability": {
|
||||
"typedErrors": {
|
||||
"totalThrows": 452,
|
||||
"typedThrows": 80,
|
||||
"plainThrows": 312,
|
||||
"typedThrows": 81,
|
||||
"plainThrows": 311,
|
||||
"otherThrows": 60,
|
||||
"adoptionRatio": 0.177,
|
||||
"adoptionRatio": 0.1792,
|
||||
"topSubdomainsByThrows": [
|
||||
{
|
||||
"subdomain": "web-server",
|
||||
@@ -579,8 +579,8 @@
|
||||
},
|
||||
"loggerCoverage": {
|
||||
"filesWithCreateLogger": 65,
|
||||
"totalSourceFiles": 759,
|
||||
"coverageRatio": 0.0856,
|
||||
"totalSourceFiles": 761,
|
||||
"coverageRatio": 0.0854,
|
||||
"subdomainsWithZeroCreateLogger": [
|
||||
"api",
|
||||
"bin",
|
||||
@@ -616,7 +616,7 @@
|
||||
},
|
||||
{
|
||||
"subdomain": "management",
|
||||
"count": 33,
|
||||
"count": 35,
|
||||
"withLogger": 1
|
||||
},
|
||||
{
|
||||
|
||||
@@ -6,10 +6,10 @@ Scope: `src/**/*.{ts,tsx,js,jsx,mjs,cjs}`
|
||||
|
||||
| Metric | Value |
|
||||
|---|---:|
|
||||
| Sync fs occurrences (all) | 2427 |
|
||||
| Sync fs files affected (all) | 258 |
|
||||
| Sync fs occurrences (runtime hotpaths) | 1142 |
|
||||
| Sync fs files affected (runtime hotpaths) | 152 |
|
||||
| Sync fs occurrences (all) | 2446 |
|
||||
| Sync fs files affected (all) | 260 |
|
||||
| Sync fs occurrences (runtime hotpaths) | 1161 |
|
||||
| Sync fs files affected (runtime hotpaths) | 154 |
|
||||
| Legacy shim markers | 458 |
|
||||
| Legacy shim files affected | 173 |
|
||||
|
||||
@@ -56,11 +56,11 @@ Scope: `src/**/*.{ts,tsx,js,jsx,mjs,cjs}`
|
||||
|
||||
| Metric | Value |
|
||||
|---|---:|
|
||||
| typed-error adoption (typed/total throws) | 17.7% (80/452) |
|
||||
| typed-error adoption (typed/total throws) | 17.9% (81/452) |
|
||||
| typed-error adoption (P4 locked subdomains) | 93.3% (28/30), target 40% |
|
||||
| hotpath console.error/warn occurrences | 266 (571 total, 305 CLI-UX exempt) |
|
||||
| hotpath console.error/warn files | 82 |
|
||||
| files with createLogger | 65/759 |
|
||||
| files with createLogger | 65/761 |
|
||||
| subdomains with zero createLogger | 15 (api, bin, channels, cliproxy, cliproxy/accounts, cliproxy/ai-providers, cliproxy/binary, cliproxy/config, cliproxy/management, cliproxy/sync, cliproxy/types, config, dispatcher, shared, types) |
|
||||
| files > 400 LOC | 91 |
|
||||
| files > 600 LOC | 42 |
|
||||
|
||||
@@ -142,7 +142,7 @@ class AuthCommands {
|
||||
` ${color('--deeper-continuity', 'command')} Advanced shared mode: sync additional continuity artifacts`
|
||||
);
|
||||
console.log(
|
||||
` ${color('--bare', 'command')} Create clean profile without shared symlinks (no CK/commands/skills)`
|
||||
` ${color('--bare', 'command')} Create clean profile without shared settings/instructions/resources`
|
||||
);
|
||||
console.log(
|
||||
` ${color('--mode <mode>', 'command')} Shared resource mode for resources: shared|profile-local`
|
||||
@@ -169,10 +169,10 @@ class AuthCommands {
|
||||
);
|
||||
console.log(` Account logins, tokens, and .anthropic stay isolated for every profile.`);
|
||||
console.log(
|
||||
` Non-bare account profiles share basic ${color('settings.json', 'path')} with ${color('~/.claude/settings.json', 'path')}; ${color('ccs auth show <profile>', 'command')} shows the link state.`
|
||||
` Non-bare account profiles share ${color('settings.json', 'path')} and ${color('CLAUDE.md', 'path')} from ${color('~/.claude/', 'path')}; ${color('ccs auth show <profile>', 'command')} shows resource mode and settings link state.`
|
||||
);
|
||||
console.log(
|
||||
` Shared Resources control plugins/commands/skills/agents/settings.json; History Sync controls project/session continuity only.`
|
||||
` Shared Resources control plugins/commands/skills/agents/settings.json/CLAUDE.md; History Sync controls project/session continuity only.`
|
||||
);
|
||||
console.log(
|
||||
` History sync is opt-in: both accounts need shared mode and the same ${color('context_group', 'path')}.`
|
||||
|
||||
@@ -27,9 +27,9 @@ export interface AccountConfig {
|
||||
context_group?: string;
|
||||
/** Shared continuity depth when context_mode='shared' */
|
||||
continuity_mode?: 'standard' | 'deeper';
|
||||
/** Account-level shared resource behavior for plugins, commands, skills, agents, and settings.json */
|
||||
/** Account-level shared resource behavior for plugins, commands, skills, agents, settings.json, and CLAUDE.md */
|
||||
shared_resource_mode?: 'shared' | 'profile-local';
|
||||
/** Bare profile: no shared symlinks (commands, skills, agents, settings.json) */
|
||||
/** Bare profile: no shared symlinks (commands, skills, agents, settings.json, CLAUDE.md) */
|
||||
bare?: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,116 @@ export function isAccountInstanceName(name: string): boolean {
|
||||
return !name.startsWith('.');
|
||||
}
|
||||
|
||||
export function normalizeAccountInstanceName(name: string): string {
|
||||
return name.replace(/[^a-zA-Z0-9_-]/g, '-').toLowerCase();
|
||||
}
|
||||
|
||||
function unsafeInstancePath(targetPath: string): never {
|
||||
throw Object.assign(new TypeError(`Unsafe account instance path: ${targetPath}`), {
|
||||
code: 'EINVAL',
|
||||
});
|
||||
}
|
||||
|
||||
interface DirectoryIdentity {
|
||||
device: number;
|
||||
inode: number;
|
||||
realPath: string;
|
||||
}
|
||||
|
||||
function readDirectoryIdentity(directoryPath: string): DirectoryIdentity {
|
||||
const stats = fs.lstatSync(directoryPath);
|
||||
if (stats.isSymbolicLink() || !stats.isDirectory()) unsafeInstancePath(directoryPath);
|
||||
return {
|
||||
device: stats.dev,
|
||||
inode: stats.ino,
|
||||
realPath: fs.realpathSync.native(directoryPath),
|
||||
};
|
||||
}
|
||||
|
||||
function identitiesMatch(left: DirectoryIdentity, right: DirectoryIdentity): boolean {
|
||||
return (
|
||||
left.device === right.device && left.inode === right.inode && left.realPath === right.realPath
|
||||
);
|
||||
}
|
||||
|
||||
function createDirectoryUnderStableParent(directoryPath: string): void {
|
||||
const parentPath = path.dirname(directoryPath);
|
||||
const parentBefore = readDirectoryIdentity(parentPath);
|
||||
|
||||
try {
|
||||
fs.mkdirSync(directoryPath, { mode: 0o700 });
|
||||
readDirectoryIdentity(directoryPath);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error;
|
||||
}
|
||||
|
||||
let parentAfter: DirectoryIdentity;
|
||||
try {
|
||||
parentAfter = readDirectoryIdentity(parentPath);
|
||||
} catch {
|
||||
unsafeInstancePath(parentPath);
|
||||
}
|
||||
|
||||
if (!identitiesMatch(parentBefore, parentAfter)) {
|
||||
unsafeInstancePath(parentPath);
|
||||
}
|
||||
|
||||
readDirectoryIdentity(directoryPath);
|
||||
}
|
||||
|
||||
function isPathWithinDirectory(candidatePath: string, directoryPath: string): boolean {
|
||||
const relative = path.relative(directoryPath, candidatePath);
|
||||
return relative.length > 0 && relative !== '..' && !relative.startsWith(`..${path.sep}`);
|
||||
}
|
||||
|
||||
/** Reject symlink roots/entries and require the real instance path to stay under the real root. */
|
||||
export function isSafeAccountInstancePath(instancesDir: string, instancePath: string): boolean {
|
||||
try {
|
||||
const rootStats = fs.lstatSync(instancesDir);
|
||||
if (rootStats.isSymbolicLink() || !rootStats.isDirectory()) return false;
|
||||
|
||||
const instanceStats = fs.lstatSync(instancePath);
|
||||
if (instanceStats.isSymbolicLink() || !instanceStats.isDirectory()) return false;
|
||||
|
||||
const realRoot = fs.realpathSync.native(instancesDir);
|
||||
const realInstance = fs.realpathSync.native(instancePath);
|
||||
return isPathWithinDirectory(realInstance, realRoot);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Create a missing managed instances root without traversing symlink parents. */
|
||||
export function ensureSafeAccountInstancesDirectory(instancesDir: string): void {
|
||||
const missingPaths: string[] = [];
|
||||
let currentPath = instancesDir;
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
const stats = fs.lstatSync(currentPath);
|
||||
if (stats.isSymbolicLink() || !stats.isDirectory()) unsafeInstancePath(currentPath);
|
||||
break;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
||||
missingPaths.push(currentPath);
|
||||
const parentPath = path.dirname(currentPath);
|
||||
if (parentPath === currentPath) unsafeInstancePath(instancesDir);
|
||||
currentPath = parentPath;
|
||||
}
|
||||
}
|
||||
|
||||
for (const directoryPath of missingPaths.reverse()) {
|
||||
createDirectoryUnderStableParent(directoryPath);
|
||||
}
|
||||
|
||||
const rootStats = fs.lstatSync(instancesDir);
|
||||
if (rootStats.isSymbolicLink() || !rootStats.isDirectory()) unsafeInstancePath(instancesDir);
|
||||
}
|
||||
|
||||
export function assertSafeAccountInstancePath(instancesDir: string, instancePath: string): void {
|
||||
if (!isSafeAccountInstancePath(instancesDir, instancePath)) unsafeInstancePath(instancePath);
|
||||
}
|
||||
|
||||
export function listAccountInstanceNames(instancesDir: string): string[] {
|
||||
if (!fs.existsSync(instancesDir)) {
|
||||
return [];
|
||||
@@ -15,11 +125,7 @@ export function listAccountInstanceNames(instancesDir: string): string[] {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return fs.statSync(path.join(instancesDir, name)).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return isSafeAccountInstancePath(instancesDir, path.join(instancesDir, name));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,13 @@ import type { AccountContextPolicy } from '../auth/account-context';
|
||||
import { getCcsHome } from '../utils/config-manager';
|
||||
import { createLogger } from '../services/logging';
|
||||
import { getCcsDir } from '../config/config-loader-facade';
|
||||
import { listAccountInstanceNames } from './instance-directory';
|
||||
import { ProfileError } from '../errors/error-types';
|
||||
import {
|
||||
assertSafeAccountInstancePath,
|
||||
ensureSafeAccountInstancesDirectory,
|
||||
listAccountInstanceNames,
|
||||
normalizeAccountInstanceName,
|
||||
} from './instance-directory';
|
||||
|
||||
const logger = createLogger('management:instance-manager');
|
||||
|
||||
@@ -23,7 +29,7 @@ const MANAGED_MCP_SERVER_NAMES = new Set(['ccs-websearch', 'ccs-image-analysis',
|
||||
|
||||
/** Options for instance creation */
|
||||
export interface InstanceOptions {
|
||||
/** Skip shared symlinks (commands, skills, agents, settings.json) */
|
||||
/** Skip shared symlinks (commands, skills, agents, settings.json, CLAUDE.md) */
|
||||
bare?: boolean;
|
||||
}
|
||||
|
||||
@@ -52,11 +58,18 @@ class InstanceManager {
|
||||
options: InstanceOptions = {}
|
||||
): Promise<string> {
|
||||
const instancePath = this.getInstancePath(profileName);
|
||||
const initialInstanceStats = this.getInstanceStats(instancePath);
|
||||
if (initialInstanceStats) {
|
||||
assertSafeAccountInstancePath(this.instancesDir, instancePath);
|
||||
} else {
|
||||
ensureSafeAccountInstancesDirectory(this.instancesDir);
|
||||
}
|
||||
|
||||
// Serialize context sync operations per profile across processes.
|
||||
await this.contextSyncLock.withLock(profileName, async () => {
|
||||
// Lazy initialization
|
||||
if (!fs.existsSync(instancePath)) {
|
||||
if (!this.getInstanceStats(instancePath)) {
|
||||
ensureSafeAccountInstancesDirectory(this.instancesDir);
|
||||
logger.stage('route', 'instance.init', 'Initializing new profile instance', {
|
||||
profile: profileName,
|
||||
bare: options.bare === true,
|
||||
@@ -64,6 +77,9 @@ class InstanceManager {
|
||||
this.initializeInstance(profileName, instancePath, options);
|
||||
}
|
||||
|
||||
// Re-check after locking/creation before any operation can mutate the instance.
|
||||
assertSafeAccountInstancePath(this.instancesDir, instancePath);
|
||||
|
||||
// Validate structure (auto-fix missing dirs)
|
||||
this.validateInstance(instancePath);
|
||||
|
||||
@@ -94,7 +110,7 @@ class InstanceManager {
|
||||
* Get instance path for profile
|
||||
*/
|
||||
getInstancePath(profileName: string): string {
|
||||
const safeName = this.sanitizeName(profileName);
|
||||
const safeName = normalizeAccountInstanceName(profileName);
|
||||
return path.join(this.instancesDir, safeName);
|
||||
}
|
||||
|
||||
@@ -108,30 +124,14 @@ class InstanceManager {
|
||||
): void {
|
||||
try {
|
||||
// Create base directory
|
||||
fs.mkdirSync(instancePath, { recursive: true, mode: 0o700 });
|
||||
fs.mkdirSync(instancePath, { mode: 0o700 });
|
||||
|
||||
// Create Claude-expected subdirectories (profile-specific only)
|
||||
const subdirs = [
|
||||
'session-env',
|
||||
'todos',
|
||||
'logs',
|
||||
'file-history',
|
||||
'shell-snapshots',
|
||||
'debug',
|
||||
'.anthropic',
|
||||
];
|
||||
|
||||
subdirs.forEach((dir) => {
|
||||
const dirPath = path.join(instancePath, dir);
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
fs.mkdirSync(dirPath, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
});
|
||||
|
||||
// Shared links are created during ensureInstance() under the plugin layout lock.
|
||||
// Expected subdirectories and shared links are created only after the
|
||||
// caller validates that this new root stayed inside the managed parent.
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to initialize instance for ${profileName}: ${(error as Error).message}`
|
||||
throw new ProfileError(
|
||||
`Failed to initialize instance for ${profileName}: ${(error as Error).message}`,
|
||||
profileName
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -268,12 +268,13 @@ class InstanceManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize profile name for filesystem
|
||||
*/
|
||||
private sanitizeName(name: string): string {
|
||||
// Replace unsafe characters with dash
|
||||
return name.replace(/[^a-zA-Z0-9_-]/g, '-').toLowerCase();
|
||||
private getInstanceStats(instancePath: string): fs.Stats | null {
|
||||
try {
|
||||
return fs.lstatSync(instancePath);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
import { warn } from '../../utils/ui';
|
||||
import { getLstatSync } from './fs-helpers';
|
||||
|
||||
let claimSequence = 0;
|
||||
|
||||
export function readRegularFileContent(filePath: string): Buffer | null {
|
||||
const stats = getLstatSync(filePath);
|
||||
return stats?.isFile() ? fs.readFileSync(filePath) : null;
|
||||
}
|
||||
|
||||
function preserveClaim(filePath: string, claimPath: string, reason: string): string {
|
||||
const basePath = `${filePath}.ccs-shared-conflict`;
|
||||
let sequence = 0;
|
||||
while (true) {
|
||||
const recoveryPath = sequence === 0 ? basePath : `${basePath}-${sequence}`;
|
||||
try {
|
||||
fs.linkSync(claimPath, recoveryPath);
|
||||
try {
|
||||
fs.unlinkSync(claimPath);
|
||||
} catch {
|
||||
// Both names preserve the same inode, so leaving the claim is safe.
|
||||
}
|
||||
console.log(warn(`${reason}; preserved content at ${recoveryPath}`));
|
||||
return recoveryPath;
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
if (code === 'EEXIST') {
|
||||
sequence++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
fs.copyFileSync(claimPath, recoveryPath, fs.constants.COPYFILE_EXCL);
|
||||
fs.chmodSync(recoveryPath, fs.statSync(claimPath).mode & 0o777);
|
||||
fs.unlinkSync(claimPath);
|
||||
console.log(
|
||||
warn(`${reason}; copied content to ${recoveryPath} (${code ?? 'link failed'})`)
|
||||
);
|
||||
return recoveryPath;
|
||||
} catch (copyError) {
|
||||
if ((copyError as NodeJS.ErrnoException).code === 'EEXIST') {
|
||||
sequence++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!getLstatSync(filePath)) {
|
||||
try {
|
||||
fs.renameSync(claimPath, filePath);
|
||||
} catch {
|
||||
// The claim remains the only recoverable copy if restoration loses a race.
|
||||
}
|
||||
}
|
||||
console.log(warn(`${reason}; unable to publish recovery, restored original when possible`));
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function interruptedClaimPaths(filePath: string): string[] {
|
||||
const directory = path.dirname(filePath);
|
||||
const prefix = `${path.basename(filePath)}.ccs-shared-claim-`;
|
||||
try {
|
||||
return fs
|
||||
.readdirSync(directory)
|
||||
.filter((entry) => entry.startsWith(prefix))
|
||||
.map((entry) => path.join(directory, entry))
|
||||
.filter((entryPath) => fs.lstatSync(entryPath).isFile())
|
||||
.sort();
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return [];
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export function recoverInterruptedClaims(filePath: string): void {
|
||||
const claims = interruptedClaimPaths(filePath);
|
||||
if (claims.length === 0) return;
|
||||
|
||||
if (claims.length === 1 && !getLstatSync(filePath)) {
|
||||
fs.renameSync(claims[0], filePath);
|
||||
console.log(warn(`Recovered interrupted shared-file claim at ${filePath}`));
|
||||
return;
|
||||
}
|
||||
|
||||
for (const claimPath of claims) {
|
||||
preserveClaim(filePath, claimPath, `Quarantined interrupted shared-file claim for ${filePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function claimCanonicalFirstCopy(
|
||||
filePath: string,
|
||||
canonicalContent: Buffer | null,
|
||||
reason: string
|
||||
): boolean {
|
||||
const initialStats = getLstatSync(filePath);
|
||||
if (!initialStats?.isFile()) return false;
|
||||
|
||||
const claimPath = `${filePath}.ccs-shared-claim-${process.pid}-${Date.now()}-${claimSequence++}`;
|
||||
fs.renameSync(filePath, claimPath);
|
||||
if (getLstatSync(filePath)) {
|
||||
preserveClaim(filePath, claimPath, `Concurrent replacement detected at ${filePath}`);
|
||||
throw Object.assign(new TypeError(`Concurrent replacement detected at ${filePath}`), {
|
||||
code: 'EEXIST',
|
||||
});
|
||||
}
|
||||
|
||||
const claimedContent = fs.readFileSync(claimPath);
|
||||
if (canonicalContent?.equals(claimedContent)) {
|
||||
fs.unlinkSync(claimPath);
|
||||
} else {
|
||||
preserveClaim(filePath, claimPath, reason);
|
||||
}
|
||||
if (getLstatSync(filePath)) {
|
||||
throw Object.assign(new TypeError(`Path reappeared during reconciliation: ${filePath}`), {
|
||||
code: 'EEXIST',
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
import { info, warn } from '../../utils/ui';
|
||||
import ProfileRegistry from '../../auth/profile-registry';
|
||||
import { isProfileLocalSharedResourceMode } from '../../auth/shared-resource-policy';
|
||||
import {
|
||||
isSafeAccountInstancePath,
|
||||
listAccountInstancePaths,
|
||||
normalizeAccountInstanceName,
|
||||
} from '../instance-directory';
|
||||
import { adoptDivergedFileContent } from './diverged-file-adopter';
|
||||
import { getLstatSync } from './fs-helpers';
|
||||
import {
|
||||
claimCanonicalFirstCopy,
|
||||
readRegularFileContent,
|
||||
recoverInterruptedClaims,
|
||||
} from './canonical-first-file-claims';
|
||||
|
||||
interface CanonicalFirstRoots {
|
||||
claudeDir: string;
|
||||
sharedDir: string;
|
||||
instancesDir: string;
|
||||
}
|
||||
|
||||
function candidatePaths(roots: CanonicalFirstRoots, fileName: string): string[] {
|
||||
const profiles = new ProfileRegistry().getAllProfilesMerged();
|
||||
const profilesByInstanceName = new Map<string, Array<(typeof profiles)[string]>>();
|
||||
for (const [profileName, profile] of Object.entries(profiles)) {
|
||||
const instanceName = normalizeAccountInstanceName(profileName);
|
||||
const matches = profilesByInstanceName.get(instanceName) ?? [];
|
||||
matches.push(profile);
|
||||
profilesByInstanceName.set(instanceName, matches);
|
||||
}
|
||||
|
||||
return [
|
||||
path.join(roots.sharedDir, fileName),
|
||||
...listAccountInstancePaths(roots.instancesDir)
|
||||
.filter((instancePath) => {
|
||||
const instanceName = normalizeAccountInstanceName(path.basename(instancePath));
|
||||
const matches = profilesByInstanceName.get(instanceName) ?? [];
|
||||
if (matches.length > 1) return false;
|
||||
return matches.length === 0 || !isProfileLocalSharedResourceMode(matches[0]);
|
||||
})
|
||||
.map((instancePath) => path.join(instancePath, fileName)),
|
||||
];
|
||||
}
|
||||
|
||||
function reconcileCandidatesAgainstCanonical(
|
||||
canonicalPath: string,
|
||||
candidates: readonly string[]
|
||||
): void {
|
||||
let canonicalContent: Buffer | null = null;
|
||||
try {
|
||||
canonicalContent = fs.readFileSync(canonicalPath);
|
||||
} catch {
|
||||
// A present but unreadable or dangling canonical path still owns precedence.
|
||||
}
|
||||
|
||||
for (const candidatePath of candidates) {
|
||||
claimCanonicalFirstCopy(
|
||||
candidatePath,
|
||||
canonicalContent,
|
||||
`Canonical ${path.basename(canonicalPath)} wins over divergent copy at ${candidatePath}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile a canonical-first shared file before any seed or symlink mutation.
|
||||
* Existing canonical content always wins. With no canonical, one unique byte
|
||||
* variant is adopted; conflicting variants are all quarantined for recovery.
|
||||
*/
|
||||
export function reconcileCanonicalFirstFile(roots: CanonicalFirstRoots, fileName: string): void {
|
||||
const canonicalPath = path.join(roots.claudeDir, fileName);
|
||||
const candidates = candidatePaths(roots, fileName);
|
||||
for (const candidatePath of candidates) {
|
||||
if (candidatePath !== path.join(roots.sharedDir, fileName)) {
|
||||
const instancePath = path.dirname(candidatePath);
|
||||
if (!isSafeAccountInstancePath(roots.instancesDir, instancePath)) continue;
|
||||
}
|
||||
recoverInterruptedClaims(candidatePath);
|
||||
}
|
||||
|
||||
if (getLstatSync(canonicalPath)) {
|
||||
reconcileCandidatesAgainstCanonical(canonicalPath, candidates);
|
||||
return;
|
||||
}
|
||||
|
||||
const variants: Array<{ path: string; content: Buffer }> = [];
|
||||
for (const candidatePath of candidates) {
|
||||
const content = readRegularFileContent(candidatePath);
|
||||
if (content) variants.push({ path: candidatePath, content });
|
||||
}
|
||||
if (variants.length === 0) return;
|
||||
|
||||
const uniqueContents: Buffer[] = [];
|
||||
for (const variant of variants) {
|
||||
if (!uniqueContents.some((content) => content.equals(variant.content))) {
|
||||
uniqueContents.push(variant.content);
|
||||
}
|
||||
}
|
||||
|
||||
if (uniqueContents.length === 1) {
|
||||
adoptDivergedFileContent(variants[0].path, canonicalPath);
|
||||
reconcileCandidatesAgainstCanonical(canonicalPath, candidates);
|
||||
console.log(info(`Adopted the only existing ${fileName} variant as canonical content`));
|
||||
return;
|
||||
}
|
||||
|
||||
for (const variant of variants) {
|
||||
claimCanonicalFirstCopy(
|
||||
variant.path,
|
||||
null,
|
||||
`Conflicting ${fileName} variants found while no canonical file exists`
|
||||
);
|
||||
}
|
||||
console.log(
|
||||
warn(
|
||||
`No ${fileName} variant was selected; recover one of the .ccs-shared-conflict files explicitly`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/** Preserve a later divergent copy without allowing it to replace canonical content. */
|
||||
export function preserveCanonicalFirstDivergence(
|
||||
divergedPath: string,
|
||||
canonicalPath: string
|
||||
): boolean {
|
||||
let canonicalContent: Buffer | null = null;
|
||||
try {
|
||||
canonicalContent = fs.readFileSync(canonicalPath);
|
||||
} catch {
|
||||
// Preserve the divergence when canonical cannot be compared safely.
|
||||
}
|
||||
|
||||
return claimCanonicalFirstCopy(
|
||||
divergedPath,
|
||||
canonicalContent,
|
||||
`Canonical ${path.basename(canonicalPath)} wins over divergent copy at ${divergedPath}`
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
import { info, warn } from '../../utils/ui';
|
||||
import { isSafeAccountInstancePath } from '../instance-directory';
|
||||
import {
|
||||
adoptDivergedFileContent,
|
||||
assertAdoptionPathAbsent,
|
||||
@@ -41,6 +42,10 @@ import {
|
||||
ensureSharedPluginLayoutDefaults,
|
||||
linkInstancePlugins,
|
||||
} from './plugin-layout-internals';
|
||||
import {
|
||||
preserveCanonicalFirstDivergence,
|
||||
reconcileCanonicalFirstFile,
|
||||
} from './canonical-first-file-reconciler';
|
||||
import { SHARED_ITEMS } from './types';
|
||||
|
||||
/**
|
||||
@@ -120,11 +125,15 @@ export function ensureSharedDirectories(roots: LinkerRoots): void {
|
||||
recoverOrphanedCanonicalClaim(claudePath);
|
||||
}
|
||||
|
||||
if (item.type === 'file' && item.divergencePolicy === 'canonical-first') {
|
||||
reconcileCanonicalFirstFile(roots, 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');
|
||||
fs.writeFileSync(claudePath, item.seedContent, 'utf8');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,7 +158,9 @@ export function ensureSharedDirectories(roots: LinkerRoots): void {
|
||||
// Continue to recreate
|
||||
}
|
||||
|
||||
if (item.type === 'file') {
|
||||
if (item.type === 'file' && item.divergencePolicy === 'canonical-first') {
|
||||
removeExisting = !preserveCanonicalFirstDivergence(sharedPath, claudePath);
|
||||
} else if (item.type === 'file') {
|
||||
sharedAdoption = adoptDivergedFileContent(sharedPath, claudePath);
|
||||
removeExisting = sharedAdoption === 'not-claimed';
|
||||
}
|
||||
@@ -196,6 +207,11 @@ export function ensureSharedDirectories(roots: LinkerRoots): void {
|
||||
* Link shared directories into a specific instance path.
|
||||
*/
|
||||
export function linkSharedDirectories(roots: LinkerRoots, instancePath: string): void {
|
||||
if (!isSafeAccountInstancePath(roots.instancesDir, instancePath)) {
|
||||
throw Object.assign(new TypeError(`Unsafe account instance path: ${instancePath}`), {
|
||||
code: 'EINVAL',
|
||||
});
|
||||
}
|
||||
ensureSharedDirectories(roots);
|
||||
|
||||
const sharedDir = roots.sharedDir;
|
||||
@@ -210,7 +226,11 @@ export function linkSharedDirectories(roots: LinkerRoots, instancePath: string):
|
||||
const targetPath = path.join(sharedDir, item.name);
|
||||
let adoption: ReturnType<typeof adoptDivergedFileContent> = 'not-claimed';
|
||||
|
||||
if (item.type === 'file') {
|
||||
if (item.type === 'file' && item.divergencePolicy === 'canonical-first') {
|
||||
if (!preserveCanonicalFirstDivergence(linkPath, path.join(roots.claudeDir, item.name))) {
|
||||
removeExistingPath(linkPath, item.type);
|
||||
}
|
||||
} else if (item.type === 'file') {
|
||||
adoption = adoptDivergedFileContent(linkPath, path.join(roots.claudeDir, item.name));
|
||||
if (adoption === 'not-claimed') {
|
||||
removeExistingPath(linkPath, item.type);
|
||||
|
||||
@@ -16,6 +16,20 @@ export interface SharedItem {
|
||||
type: 'directory' | 'file';
|
||||
}
|
||||
|
||||
export interface ManagedSharedDirectoryItem extends SharedItem {
|
||||
type: 'directory';
|
||||
}
|
||||
|
||||
export interface ManagedSharedFileItem extends SharedItem {
|
||||
type: 'file';
|
||||
/** Exact bytes used when no canonical or adoptable source file exists. */
|
||||
seedContent: string;
|
||||
/** Controls whether an instance may replace an existing canonical file. */
|
||||
divergencePolicy: 'newer-wins' | 'canonical-first';
|
||||
}
|
||||
|
||||
export type ManagedSharedItem = ManagedSharedDirectoryItem | ManagedSharedFileItem;
|
||||
|
||||
/**
|
||||
* Default content for a freshly provisioned installed_plugins.json registry.
|
||||
* Version 2 schema with an empty plugins map.
|
||||
@@ -36,12 +50,23 @@ export const DEFAULT_INSTALLED_PLUGIN_REGISTRY = JSON.stringify(
|
||||
* Order matters: consumers rely on a stable iteration order when reconciling
|
||||
* symlinks, and 'plugins' is special-cased by the linker.
|
||||
*/
|
||||
export const SHARED_ITEMS: readonly SharedItem[] = [
|
||||
export const SHARED_ITEMS: readonly ManagedSharedItem[] = [
|
||||
{ name: 'commands', type: 'directory' },
|
||||
{ name: 'skills', type: 'directory' },
|
||||
{ name: 'agents', type: 'directory' },
|
||||
{ name: 'plugins', type: 'directory' },
|
||||
{ name: 'settings.json', type: 'file' },
|
||||
{
|
||||
name: 'settings.json',
|
||||
type: 'file',
|
||||
seedContent: JSON.stringify({}, null, 2),
|
||||
divergencePolicy: 'newer-wins',
|
||||
},
|
||||
{
|
||||
name: 'CLAUDE.md',
|
||||
type: 'file',
|
||||
seedContent: '',
|
||||
divergencePolicy: 'canonical-first',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
+2
-2
@@ -121,9 +121,9 @@ export interface ProfileMetadata {
|
||||
context_group?: string;
|
||||
/** Shared continuity depth when context_mode='shared' */
|
||||
continuity_mode?: 'standard' | 'deeper';
|
||||
/** Account-level shared resource behavior for plugins, commands, skills, agents, and settings.json */
|
||||
/** Account-level shared resource behavior for plugins, commands, skills, agents, settings.json, and CLAUDE.md */
|
||||
shared_resource_mode?: 'shared' | 'profile-local';
|
||||
/** Bare profile: no shared symlinks (commands, skills, agents, settings.json) */
|
||||
/** Bare profile: no shared symlinks (commands, skills, agents, settings.json, CLAUDE.md) */
|
||||
bare?: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -284,6 +284,168 @@ describe('InstanceManager MCP sync', () => {
|
||||
expect(manager.listInstances()).toEqual(['work']);
|
||||
});
|
||||
|
||||
for (const testCase of [
|
||||
{
|
||||
label: 'isolated bare',
|
||||
policy: { mode: 'isolated' as const },
|
||||
options: { bare: true },
|
||||
},
|
||||
{
|
||||
label: 'shared context',
|
||||
policy: { mode: 'shared' as const, group: 'review-safety' },
|
||||
options: {},
|
||||
},
|
||||
]) {
|
||||
it(`rejects an existing symlink instance root before ${testCase.label} mutation`, async () => {
|
||||
const externalInstance = path.join(tempRoot, `external-${testCase.label.replace(' ', '-')}`);
|
||||
const instancesDir = path.join(tempRoot, '.ccs', 'instances');
|
||||
fs.mkdirSync(externalInstance, { recursive: true });
|
||||
fs.writeFileSync(path.join(externalInstance, 'sentinel.txt'), 'unchanged', 'utf8');
|
||||
fs.mkdirSync(instancesDir, { recursive: true });
|
||||
fs.symlinkSync(externalInstance, path.join(instancesDir, 'work'), 'dir');
|
||||
|
||||
const manager = new InstanceManager();
|
||||
await expect(
|
||||
manager.ensureInstance('work', testCase.policy, testCase.options)
|
||||
).rejects.toThrow('Unsafe account instance path');
|
||||
|
||||
expect(fs.readdirSync(externalInstance)).toEqual(['sentinel.txt']);
|
||||
expect(fs.readFileSync(path.join(externalInstance, 'sentinel.txt'), 'utf8')).toBe(
|
||||
'unchanged'
|
||||
);
|
||||
expect(fs.readdirSync(instancesDir)).toEqual(['work']);
|
||||
});
|
||||
}
|
||||
|
||||
it('rejects a symlinked instances parent before creating a new instance or lock', async () => {
|
||||
const externalInstances = path.join(tempRoot, 'external-instances');
|
||||
const managedInstances = path.join(tempRoot, '.ccs', 'instances');
|
||||
fs.mkdirSync(externalInstances, { recursive: true });
|
||||
fs.mkdirSync(path.dirname(managedInstances), { recursive: true });
|
||||
fs.symlinkSync(externalInstances, managedInstances, 'dir');
|
||||
|
||||
const manager = new InstanceManager();
|
||||
await expect(
|
||||
manager.ensureInstance('new-profile', { mode: 'shared', group: 'review-safety' })
|
||||
).rejects.toThrow('Unsafe account instance path');
|
||||
|
||||
expect(fs.readdirSync(externalInstances)).toEqual([]);
|
||||
});
|
||||
|
||||
it('detects parent replacement and leaves only an empty redirected artifact', async () => {
|
||||
const managedCcsDir = path.join(tempRoot, '.ccs');
|
||||
const managedInstances = path.join(managedCcsDir, 'instances');
|
||||
const displacedCcsDir = path.join(tempRoot, 'displaced-ccs');
|
||||
const externalTarget = path.join(tempRoot, 'parent-race-target');
|
||||
fs.mkdirSync(managedCcsDir, { recursive: true });
|
||||
fs.mkdirSync(externalTarget, { recursive: true });
|
||||
fs.writeFileSync(path.join(externalTarget, 'sentinel.txt'), 'unchanged', 'utf8');
|
||||
|
||||
const originalMkdirSync = fs.mkdirSync;
|
||||
let replacedParent = false;
|
||||
spyOn(fs, 'mkdirSync').mockImplementation((directoryPath, options) => {
|
||||
if (directoryPath === managedInstances && !replacedParent) {
|
||||
replacedParent = true;
|
||||
fs.renameSync(managedCcsDir, displacedCcsDir);
|
||||
fs.symlinkSync(externalTarget, managedCcsDir, 'dir');
|
||||
}
|
||||
return originalMkdirSync(directoryPath, options);
|
||||
});
|
||||
|
||||
const manager = new InstanceManager();
|
||||
await expect(
|
||||
manager.ensureInstance('new-profile', { mode: 'isolated' }, { bare: true })
|
||||
).rejects.toThrow('Unsafe account instance path');
|
||||
|
||||
expect(replacedParent).toBe(true);
|
||||
expect(fs.readdirSync(externalTarget).sort()).toEqual(['instances', 'sentinel.txt']);
|
||||
expect(fs.readdirSync(path.join(externalTarget, 'instances'))).toEqual([]);
|
||||
expect(fs.existsSync(path.join(externalTarget, 'instances', '.locks'))).toBe(false);
|
||||
expect(fs.existsSync(path.join(externalTarget, 'instances', 'new-profile'))).toBe(false);
|
||||
expect(fs.readdirSync(displacedCcsDir)).toEqual([]);
|
||||
});
|
||||
|
||||
it('preserves created and unrelated directories when the redirected path is replaced', async () => {
|
||||
const managedCcsDir = path.join(tempRoot, '.ccs');
|
||||
const managedInstances = path.join(managedCcsDir, 'instances');
|
||||
const displacedCcsDir = path.join(tempRoot, 'displaced-ccs-cleanup-race');
|
||||
const externalTarget = path.join(tempRoot, 'cleanup-race-target');
|
||||
const createdDirectory = path.join(externalTarget, 'created-instances');
|
||||
const redirectedInstances = path.join(externalTarget, 'instances');
|
||||
fs.mkdirSync(managedCcsDir, { recursive: true });
|
||||
fs.mkdirSync(externalTarget, { recursive: true });
|
||||
|
||||
const originalMkdirSync = fs.mkdirSync;
|
||||
const originalLstatSync = fs.lstatSync;
|
||||
let replacedParent = false;
|
||||
let createdIdentityObserved = false;
|
||||
let substitutedUnrelatedDirectory = false;
|
||||
|
||||
spyOn(fs, 'mkdirSync').mockImplementation((directoryPath, options) => {
|
||||
if (directoryPath === managedInstances && !replacedParent) {
|
||||
replacedParent = true;
|
||||
fs.renameSync(managedCcsDir, displacedCcsDir);
|
||||
fs.symlinkSync(externalTarget, managedCcsDir, 'dir');
|
||||
}
|
||||
return originalMkdirSync(directoryPath, options);
|
||||
});
|
||||
spyOn(fs, 'lstatSync').mockImplementation((targetPath, options) => {
|
||||
if (
|
||||
targetPath === managedCcsDir &&
|
||||
createdIdentityObserved &&
|
||||
!substitutedUnrelatedDirectory
|
||||
) {
|
||||
substitutedUnrelatedDirectory = true;
|
||||
fs.renameSync(redirectedInstances, createdDirectory);
|
||||
originalMkdirSync(redirectedInstances);
|
||||
}
|
||||
const stats = originalLstatSync(targetPath, options);
|
||||
if (targetPath === managedInstances && replacedParent && stats.isDirectory()) {
|
||||
createdIdentityObserved = true;
|
||||
}
|
||||
return stats;
|
||||
});
|
||||
|
||||
const manager = new InstanceManager();
|
||||
await expect(
|
||||
manager.ensureInstance('new-profile', { mode: 'isolated' }, { bare: true })
|
||||
).rejects.toThrow('Unsafe account instance path');
|
||||
|
||||
expect(substitutedUnrelatedDirectory).toBe(true);
|
||||
expect(fs.existsSync(redirectedInstances)).toBe(true);
|
||||
expect(fs.readdirSync(redirectedInstances)).toEqual([]);
|
||||
expect(fs.existsSync(createdDirectory)).toBe(true);
|
||||
expect(fs.readdirSync(createdDirectory)).toEqual([]);
|
||||
expect(fs.existsSync(path.join(redirectedInstances, '.locks'))).toBe(false);
|
||||
expect(fs.existsSync(path.join(redirectedInstances, 'new-profile'))).toBe(false);
|
||||
expect(fs.existsSync(path.join(createdDirectory, '.locks'))).toBe(false);
|
||||
expect(fs.existsSync(path.join(createdDirectory, 'new-profile'))).toBe(false);
|
||||
});
|
||||
|
||||
it('revalidates a newly created instance root before initializing its contents', async () => {
|
||||
const externalInstance = path.join(tempRoot, 'post-create-race-target');
|
||||
fs.mkdirSync(externalInstance, { recursive: true });
|
||||
fs.writeFileSync(path.join(externalInstance, 'sentinel.txt'), 'unchanged', 'utf8');
|
||||
|
||||
const manager = new InstanceManager();
|
||||
const instancePath = manager.getInstancePath('new-profile');
|
||||
const originalMkdirSync = fs.mkdirSync;
|
||||
spyOn(fs, 'mkdirSync').mockImplementation((directoryPath, options) => {
|
||||
if (directoryPath === instancePath) {
|
||||
fs.symlinkSync(externalInstance, instancePath, 'dir');
|
||||
return undefined;
|
||||
}
|
||||
return originalMkdirSync(directoryPath, options);
|
||||
});
|
||||
|
||||
await expect(
|
||||
manager.ensureInstance('new-profile', { mode: 'isolated' }, { bare: true })
|
||||
).rejects.toThrow('Unsafe account instance path');
|
||||
|
||||
expect(fs.readdirSync(externalInstance)).toEqual(['sentinel.txt']);
|
||||
expect(fs.readFileSync(path.join(externalInstance, 'sentinel.txt'), 'utf8')).toBe('unchanged');
|
||||
});
|
||||
|
||||
it('skips shared symlinks and MCP sync for bare instance creation', async () => {
|
||||
const linkSharedSpy = spyOn(
|
||||
SharedManager.prototype,
|
||||
@@ -491,7 +653,9 @@ describe('InstanceManager MCP sync', () => {
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
>;
|
||||
const personalRegistry = readJson(path.join(personalPath, 'plugins', 'known_marketplaces.json')) as Record<
|
||||
const personalRegistry = readJson(
|
||||
path.join(personalPath, 'plugins', 'known_marketplaces.json')
|
||||
) as Record<
|
||||
string,
|
||||
{
|
||||
installLocation?: string;
|
||||
@@ -552,10 +716,7 @@ describe('InstanceManager MCP sync', () => {
|
||||
|
||||
const legacyRegistry = readJson(
|
||||
path.join(legacyPath, 'plugins', 'known_marketplaces.json')
|
||||
) as Record<
|
||||
string,
|
||||
{ installLocation?: string; label?: string; refreshToken?: string }
|
||||
>;
|
||||
) as Record<string, { installLocation?: string; label?: string; refreshToken?: string }>;
|
||||
expect(legacyRegistry['claude-code-plugins']).toMatchObject({
|
||||
installLocation: marketplacePath(legacyPath),
|
||||
label: 'Legacy marketplace',
|
||||
|
||||
@@ -86,6 +86,26 @@ describe('account instance directory enumeration', () => {
|
||||
expect(listAccountInstanceNames(instancesDir())).toEqual(['work']);
|
||||
});
|
||||
|
||||
it('rejects instance entries that symlink outside the managed root', () => {
|
||||
const externalInstance = path.join(tempRoot, 'external-instance');
|
||||
fs.mkdirSync(externalInstance, { recursive: true });
|
||||
fs.mkdirSync(instancesDir(), { recursive: true });
|
||||
fs.symlinkSync(externalInstance, path.join(instancesDir(), 'escaped'), 'dir');
|
||||
|
||||
expect(listAccountInstanceNames(instancesDir())).toEqual([]);
|
||||
expect(listAccountInstancePaths(instancesDir())).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects an instances root that is itself a symlink', () => {
|
||||
const externalInstances = path.join(tempRoot, 'external-instances');
|
||||
fs.mkdirSync(path.join(externalInstances, 'work'), { recursive: true });
|
||||
fs.mkdirSync(path.dirname(instancesDir()), { recursive: true });
|
||||
fs.symlinkSync(externalInstances, instancesDir(), 'dir');
|
||||
|
||||
expect(listAccountInstanceNames(instancesDir())).toEqual([]);
|
||||
expect(listAccountInstancePaths(instancesDir())).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps ccs doctor settings symlinks healthy when .locks exists', () => {
|
||||
createValidSettingsLayout();
|
||||
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
import SharedManager from '../../src/management/shared-manager';
|
||||
|
||||
describe('shared CLAUDE.md memory', () => {
|
||||
let tempHome = '';
|
||||
let originalHome: string | undefined;
|
||||
let originalCcsHome: string | undefined;
|
||||
let originalCcsDir: string | undefined;
|
||||
|
||||
const claudeFile = () => path.join(tempHome, '.claude', 'CLAUDE.md');
|
||||
const ccsDir = () => path.join(tempHome, '.ccs');
|
||||
const sharedFile = () => path.join(ccsDir(), 'shared', 'CLAUDE.md');
|
||||
const instanceDir = (name: string) => path.join(ccsDir(), 'instances', name);
|
||||
const instanceFile = (name: string) => path.join(instanceDir(name), 'CLAUDE.md');
|
||||
|
||||
function writeFile(filePath: string, content: string): void {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, content, 'utf8');
|
||||
}
|
||||
|
||||
function conflictFiles(filePath: string): string[] {
|
||||
const prefix = `${path.basename(filePath)}.ccs-shared-conflict`;
|
||||
return fs
|
||||
.readdirSync(path.dirname(filePath))
|
||||
.filter((entry) => entry === prefix || entry.startsWith(`${prefix}-`))
|
||||
.map((entry) => path.join(path.dirname(filePath), entry));
|
||||
}
|
||||
|
||||
function writeLegacyProfiles(
|
||||
profiles: Record<string, { shared_resource_mode?: string; bare?: boolean }>
|
||||
): void {
|
||||
writeFile(
|
||||
path.join(ccsDir(), 'profiles.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
version: '2.0.0',
|
||||
default: null,
|
||||
profiles: Object.fromEntries(
|
||||
Object.entries(profiles).map(([name, metadata]) => [
|
||||
name,
|
||||
{
|
||||
type: 'account',
|
||||
created: '2026-08-08T00:00:00.000Z',
|
||||
last_used: null,
|
||||
...metadata,
|
||||
},
|
||||
])
|
||||
),
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function expectLinkTo(filePath: string, targetPath: string): void {
|
||||
expect(fs.lstatSync(filePath).isSymbolicLink()).toBe(true);
|
||||
expect(path.resolve(path.dirname(filePath), fs.readlinkSync(filePath))).toBe(targetPath);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-shared-claude-memory-'));
|
||||
originalHome = process.env.HOME;
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
originalCcsDir = process.env.CCS_DIR;
|
||||
|
||||
spyOn(os, 'homedir').mockReturnValue(tempHome);
|
||||
process.env.HOME = tempHome;
|
||||
process.env.CCS_HOME = tempHome;
|
||||
delete process.env.CCS_DIR;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
|
||||
if (originalHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = originalHome;
|
||||
if (originalCcsHome === undefined) delete process.env.CCS_HOME;
|
||||
else process.env.CCS_HOME = originalCcsHome;
|
||||
if (originalCcsDir === undefined) delete process.env.CCS_DIR;
|
||||
else process.env.CCS_DIR = originalCcsDir;
|
||||
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('seeds missing Markdown as empty text and links it through the shared root', () => {
|
||||
const manager = new SharedManager();
|
||||
const workInstance = instanceDir('work');
|
||||
fs.mkdirSync(workInstance, { recursive: true });
|
||||
|
||||
manager.linkSharedDirectories(workInstance);
|
||||
|
||||
expect(fs.readFileSync(claudeFile(), 'utf8')).toBe('');
|
||||
expectLinkTo(sharedFile(), claudeFile());
|
||||
expectLinkTo(instanceFile('work'), sharedFile());
|
||||
});
|
||||
|
||||
it('preserves canonical instructions and quarantines an older instance variant', () => {
|
||||
const manager = new SharedManager();
|
||||
writeFile(claudeFile(), '# Canonical\n');
|
||||
writeFile(instanceFile('work'), '# Stale local copy\n');
|
||||
|
||||
manager.linkSharedDirectories(instanceDir('work'));
|
||||
|
||||
expect(fs.readFileSync(claudeFile(), 'utf8')).toBe('# Canonical\n');
|
||||
expectLinkTo(instanceFile('work'), sharedFile());
|
||||
const recoveries = conflictFiles(instanceFile('work'));
|
||||
expect(recoveries).toHaveLength(1);
|
||||
expect(fs.readFileSync(recoveries[0], 'utf8')).toBe('# Stale local copy\n');
|
||||
});
|
||||
|
||||
it('adopts the only instance variant when the canonical file is absent', () => {
|
||||
const manager = new SharedManager();
|
||||
writeFile(instanceFile('work'), '# Adopt me\n');
|
||||
|
||||
manager.linkSharedDirectories(instanceDir('work'));
|
||||
|
||||
expect(fs.readFileSync(claudeFile(), 'utf8')).toBe('# Adopt me\n');
|
||||
expectLinkTo(instanceFile('work'), sharedFile());
|
||||
});
|
||||
|
||||
it('never inventories or mutates profile-local CLAUDE.md content', () => {
|
||||
const manager = new SharedManager();
|
||||
writeLegacyProfiles({
|
||||
work: { shared_resource_mode: 'shared' },
|
||||
sandbox: { shared_resource_mode: 'profile-local', bare: true },
|
||||
});
|
||||
writeFile(instanceFile('work'), '# Shared account\n');
|
||||
writeFile(instanceFile('sandbox'), '# Isolated account\n');
|
||||
|
||||
manager.linkSharedDirectories(instanceDir('work'));
|
||||
|
||||
expect(fs.readFileSync(claudeFile(), 'utf8')).toBe('# Shared account\n');
|
||||
expect(fs.readFileSync(instanceFile('sandbox'), 'utf8')).toBe('# Isolated account\n');
|
||||
expect(conflictFiles(instanceFile('sandbox'))).toEqual([]);
|
||||
expectLinkTo(instanceFile('work'), sharedFile());
|
||||
});
|
||||
|
||||
it('matches uppercase profile metadata to its normalized instance name', () => {
|
||||
const manager = new SharedManager();
|
||||
writeLegacyProfiles({
|
||||
work: { shared_resource_mode: 'shared' },
|
||||
Sandbox: { shared_resource_mode: 'profile-local', bare: true },
|
||||
});
|
||||
writeFile(instanceFile('work'), '# Shared account\n');
|
||||
writeFile(instanceFile('sandbox'), '# Uppercase isolated account\n');
|
||||
|
||||
manager.linkSharedDirectories(instanceDir('work'));
|
||||
|
||||
expect(fs.readFileSync(claudeFile(), 'utf8')).toBe('# Shared account\n');
|
||||
expect(fs.readFileSync(instanceFile('sandbox'), 'utf8')).toBe('# Uppercase isolated account\n');
|
||||
expect(conflictFiles(instanceFile('sandbox'))).toEqual([]);
|
||||
});
|
||||
|
||||
it('excludes legacy profile-name normalization collisions from inventory', () => {
|
||||
const manager = new SharedManager();
|
||||
writeLegacyProfiles({
|
||||
work: { shared_resource_mode: 'shared' },
|
||||
Sandbox: { shared_resource_mode: 'profile-local', bare: true },
|
||||
sandbox: { shared_resource_mode: 'shared' },
|
||||
});
|
||||
writeFile(instanceFile('work'), '# Shared account\n');
|
||||
writeFile(instanceFile('sandbox'), '# Ambiguous legacy account\n');
|
||||
|
||||
manager.linkSharedDirectories(instanceDir('work'));
|
||||
|
||||
expect(fs.readFileSync(claudeFile(), 'utf8')).toBe('# Shared account\n');
|
||||
expect(fs.readFileSync(instanceFile('sandbox'), 'utf8')).toBe('# Ambiguous legacy account\n');
|
||||
expect(conflictFiles(instanceFile('sandbox'))).toEqual([]);
|
||||
});
|
||||
|
||||
it('quarantines every distinct variant instead of selecting by mtime', () => {
|
||||
const manager = new SharedManager();
|
||||
const logSpy = spyOn(console, 'log').mockImplementation(() => {});
|
||||
writeFile(instanceFile('work'), '# Work\n');
|
||||
writeFile(instanceFile('personal'), '# Personal\n');
|
||||
const now = Date.now();
|
||||
fs.utimesSync(instanceFile('work'), new Date(now - 60_000), new Date(now - 60_000));
|
||||
fs.utimesSync(instanceFile('personal'), new Date(now), new Date(now));
|
||||
|
||||
manager.linkSharedDirectories(instanceDir('work'));
|
||||
|
||||
expect(fs.readFileSync(claudeFile(), 'utf8')).toBe('');
|
||||
expect(fs.readFileSync(conflictFiles(instanceFile('work'))[0], 'utf8')).toBe('# Work\n');
|
||||
expect(fs.readFileSync(conflictFiles(instanceFile('personal'))[0], 'utf8')).toBe(
|
||||
'# Personal\n'
|
||||
);
|
||||
expect(
|
||||
logSpy.mock.calls.some(([message]) =>
|
||||
String(message).includes('No CLAUDE.md variant was selected')
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('is idempotent after canonical-first reconciliation', () => {
|
||||
const manager = new SharedManager();
|
||||
writeFile(claudeFile(), '# Canonical\n');
|
||||
writeFile(instanceFile('work'), '# Diverged\n');
|
||||
|
||||
manager.linkSharedDirectories(instanceDir('work'));
|
||||
const firstRecoveries = conflictFiles(instanceFile('work'));
|
||||
manager.linkSharedDirectories(instanceDir('work'));
|
||||
|
||||
expect(conflictFiles(instanceFile('work'))).toEqual(firstRecoveries);
|
||||
expect(fs.readFileSync(claudeFile(), 'utf8')).toBe('# Canonical\n');
|
||||
expectLinkTo(instanceFile('work'), sharedFile());
|
||||
});
|
||||
|
||||
it('never lets a later diverged instance overwrite canonical instructions', () => {
|
||||
const manager = new SharedManager();
|
||||
writeFile(claudeFile(), '# Canonical\n');
|
||||
fs.mkdirSync(instanceDir('work'), { recursive: true });
|
||||
manager.linkSharedDirectories(instanceDir('work'));
|
||||
|
||||
fs.unlinkSync(instanceFile('work'));
|
||||
writeFile(instanceFile('work'), '# Newer but local\n');
|
||||
const future = new Date(Date.now() + 60_000);
|
||||
fs.utimesSync(instanceFile('work'), future, future);
|
||||
|
||||
manager.linkSharedDirectories(instanceDir('work'));
|
||||
|
||||
expect(fs.readFileSync(claudeFile(), 'utf8')).toBe('# Canonical\n');
|
||||
expect(fs.readFileSync(conflictFiles(instanceFile('work'))[0], 'utf8')).toBe(
|
||||
'# Newer but local\n'
|
||||
);
|
||||
expectLinkTo(instanceFile('work'), sharedFile());
|
||||
});
|
||||
|
||||
for (const linkErrorCode of ['EPERM', 'EACCES', 'ENOTSUP']) {
|
||||
it(`copies conflict recovery when hard links fail with ${linkErrorCode}`, () => {
|
||||
const manager = new SharedManager();
|
||||
const originalLinkSync = fs.linkSync;
|
||||
writeFile(claudeFile(), '# Canonical\n');
|
||||
writeFile(instanceFile('work'), '# Diverged\n');
|
||||
spyOn(fs, 'linkSync').mockImplementation((existingPath, newPath) => {
|
||||
if (String(newPath).includes('.ccs-shared-conflict')) {
|
||||
throw Object.assign(new Error('simulated hard-link failure'), { code: linkErrorCode });
|
||||
}
|
||||
return originalLinkSync(existingPath, newPath);
|
||||
});
|
||||
|
||||
manager.linkSharedDirectories(instanceDir('work'));
|
||||
|
||||
expect(fs.readFileSync(conflictFiles(instanceFile('work'))[0], 'utf8')).toBe('# Diverged\n');
|
||||
expectLinkTo(instanceFile('work'), sharedFile());
|
||||
});
|
||||
}
|
||||
|
||||
it('restores the original divergence when hard-link and copy recovery both fail', () => {
|
||||
const manager = new SharedManager();
|
||||
const originalCopyFileSync = fs.copyFileSync;
|
||||
writeFile(claudeFile(), '# Canonical\n');
|
||||
writeFile(instanceFile('work'), '# Diverged\n');
|
||||
spyOn(fs, 'linkSync').mockImplementation(() => {
|
||||
throw Object.assign(new Error('simulated hard-link failure'), { code: 'EPERM' });
|
||||
});
|
||||
spyOn(fs, 'copyFileSync').mockImplementation((source, destination, mode) => {
|
||||
if (String(destination).includes('.ccs-shared-conflict')) {
|
||||
throw Object.assign(new Error('simulated copy failure'), { code: 'EACCES' });
|
||||
}
|
||||
return originalCopyFileSync(source, destination, mode);
|
||||
});
|
||||
|
||||
expect(() => manager.linkSharedDirectories(instanceDir('work'))).toThrow(
|
||||
'simulated hard-link failure'
|
||||
);
|
||||
expect(fs.readFileSync(instanceFile('work'), 'utf8')).toBe('# Diverged\n');
|
||||
expect(
|
||||
fs.readdirSync(instanceDir('work')).some((entry) => entry.includes('.ccs-shared-claim-'))
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('recovers an interrupted claim before inventory and adoption', () => {
|
||||
const manager = new SharedManager();
|
||||
const claimPath = `${instanceFile('work')}.ccs-shared-claim-interrupted`;
|
||||
writeFile(claimPath, '# Interrupted\n');
|
||||
|
||||
manager.linkSharedDirectories(instanceDir('work'));
|
||||
|
||||
expect(fs.readFileSync(claudeFile(), 'utf8')).toBe('# Interrupted\n');
|
||||
expect(fs.existsSync(claimPath)).toBe(false);
|
||||
expectLinkTo(instanceFile('work'), sharedFile());
|
||||
});
|
||||
|
||||
it('preserves both copies and aborts when a concurrent writer replaces the claimed path', () => {
|
||||
const manager = new SharedManager();
|
||||
const originalRenameSync = fs.renameSync;
|
||||
writeFile(claudeFile(), '# Canonical\n');
|
||||
writeFile(instanceFile('work'), '# Claimed\n');
|
||||
spyOn(fs, 'renameSync').mockImplementation((oldPath, newPath) => {
|
||||
originalRenameSync(oldPath, newPath);
|
||||
if (oldPath === instanceFile('work') && String(newPath).includes('.ccs-shared-claim-')) {
|
||||
writeFile(instanceFile('work'), '# Concurrent writer\n');
|
||||
}
|
||||
});
|
||||
|
||||
expect(() => manager.linkSharedDirectories(instanceDir('work'))).toThrow(
|
||||
'Concurrent replacement detected'
|
||||
);
|
||||
expect(fs.readFileSync(instanceFile('work'), 'utf8')).toBe('# Concurrent writer\n');
|
||||
expect(fs.readFileSync(conflictFiles(instanceFile('work'))[0], 'utf8')).toBe('# Claimed\n');
|
||||
expect(fs.readFileSync(claudeFile(), 'utf8')).toBe('# Canonical\n');
|
||||
});
|
||||
|
||||
it('aborts when a late writer recreates the path during recovery publication', () => {
|
||||
const manager = new SharedManager();
|
||||
const originalLinkSync = fs.linkSync;
|
||||
writeFile(claudeFile(), '# Canonical\n');
|
||||
writeFile(instanceFile('work'), '# Claimed\n');
|
||||
spyOn(fs, 'linkSync').mockImplementation((existingPath, newPath) => {
|
||||
const result = originalLinkSync(existingPath, newPath);
|
||||
if (String(newPath).includes('.ccs-shared-conflict')) {
|
||||
writeFile(instanceFile('work'), '# Late writer\n');
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
expect(() => manager.linkSharedDirectories(instanceDir('work'))).toThrow(
|
||||
'Path reappeared during reconciliation'
|
||||
);
|
||||
expect(fs.readFileSync(instanceFile('work'), 'utf8')).toBe('# Late writer\n');
|
||||
expect(fs.readFileSync(conflictFiles(instanceFile('work'))[0], 'utf8')).toBe('# Claimed\n');
|
||||
});
|
||||
|
||||
it('rejects linking through a symlinked instances root without touching its target', () => {
|
||||
const manager = new SharedManager();
|
||||
const managedInstances = path.join(ccsDir(), 'instances');
|
||||
const externalInstances = path.join(tempHome, 'external-instances');
|
||||
const externalWork = path.join(externalInstances, 'work');
|
||||
writeFile(path.join(externalWork, 'CLAUDE.md'), '# External\n');
|
||||
fs.mkdirSync(path.dirname(managedInstances), { recursive: true });
|
||||
fs.symlinkSync(externalInstances, managedInstances, 'dir');
|
||||
|
||||
expect(() => manager.linkSharedDirectories(path.join(managedInstances, 'work'))).toThrow(
|
||||
'Unsafe account instance path'
|
||||
);
|
||||
expect(fs.readFileSync(path.join(externalWork, 'CLAUDE.md'), 'utf8')).toBe('# External\n');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user