mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-12 20:15:40 +00:00
fix(shared-manager): preserve diverged files safely
This commit is contained in:
1 parent
fa6b27f159
commit
7ae617f0ee
5 files changed
+847
-37
No files matched your search
@@ -0,0 +1,367 @@
|
|||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
import { info, warn } from '../../utils/ui';
|
||||||
|
import { getLstatSync } from './fs-helpers';
|
||||||
|
|
||||||
|
export type DivergedFileAdoption = 'not-claimed' | 'claimed';
|
||||||
|
|
||||||
|
let adoptionClaimSequence = 0;
|
||||||
|
|
||||||
|
function resolveLexicalSymlinkChain(targetPath: string): string {
|
||||||
|
let currentPath = path.resolve(targetPath);
|
||||||
|
const visited = new Set<string>();
|
||||||
|
while (true) {
|
||||||
|
if (visited.has(currentPath)) {
|
||||||
|
throw Object.assign(new TypeError(`Symlink loop while resolving ${targetPath}`), {
|
||||||
|
code: 'ELOOP',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
visited.add(currentPath);
|
||||||
|
|
||||||
|
const stats = getLstatSync(currentPath);
|
||||||
|
if (!stats?.isSymbolicLink()) return currentPath;
|
||||||
|
currentPath = path.resolve(path.dirname(currentPath), fs.readlinkSync(currentPath));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertAdoptionPathAbsent(
|
||||||
|
managedPath: string,
|
||||||
|
adoption: DivergedFileAdoption
|
||||||
|
): void {
|
||||||
|
if (adoption === 'claimed' && getLstatSync(managedPath)) {
|
||||||
|
throw Object.assign(new TypeError(`Path reappeared during reconciliation: ${managedPath}`), {
|
||||||
|
code: 'EEXIST',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recoverOrphanedCanonicalClaim(canonicalPath: string): boolean {
|
||||||
|
const writePath = resolveLexicalSymlinkChain(canonicalPath);
|
||||||
|
const directory = path.dirname(writePath);
|
||||||
|
const prefix = `${path.basename(writePath)}.ccs-canonical-claim-`;
|
||||||
|
let candidates: string[];
|
||||||
|
try {
|
||||||
|
candidates = fs
|
||||||
|
.readdirSync(directory)
|
||||||
|
.filter((entry) => entry.startsWith(prefix))
|
||||||
|
.map((entry) => path.join(directory, entry))
|
||||||
|
.filter((entryPath) => fs.lstatSync(entryPath).isFile())
|
||||||
|
.sort((left, right) => fs.statSync(right).mtimeMs - fs.statSync(left).mtimeMs);
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return false;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
const claimPath = candidates[0];
|
||||||
|
if (!claimPath) return false;
|
||||||
|
|
||||||
|
let recovered = false;
|
||||||
|
if (!getLstatSync(writePath)) {
|
||||||
|
fs.linkSync(claimPath, writePath);
|
||||||
|
fs.unlinkSync(claimPath);
|
||||||
|
candidates.shift();
|
||||||
|
recovered = true;
|
||||||
|
console.log(warn(`Recovered interrupted canonical adoption at ${canonicalPath}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const leftoverClaim of candidates) {
|
||||||
|
const recoveryBase = `${writePath}.ccs-canonical-recovery`;
|
||||||
|
let sequence = 0;
|
||||||
|
while (true) {
|
||||||
|
const recoveryPath = sequence === 0 ? recoveryBase : `${recoveryBase}-${sequence}`;
|
||||||
|
try {
|
||||||
|
fs.linkSync(leftoverClaim, recoveryPath);
|
||||||
|
fs.unlinkSync(leftoverClaim);
|
||||||
|
console.log(warn(`Quarantined interrupted canonical claim at ${recoveryPath}`));
|
||||||
|
break;
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err;
|
||||||
|
sequence++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return recovered;
|
||||||
|
}
|
||||||
|
|
||||||
|
function preserveClaim(claimPath: string, divergedPath: string, reason: string): string {
|
||||||
|
const recoveryBase = `${divergedPath}.ccs-adopt-recovery`;
|
||||||
|
let sequence = 0;
|
||||||
|
while (true) {
|
||||||
|
const recoveryPath = sequence === 0 ? recoveryBase : `${recoveryBase}-${sequence}`;
|
||||||
|
try {
|
||||||
|
// link() is an atomic no-replace operation, so concurrent CCS
|
||||||
|
// processes cannot overwrite each other's recovery artifacts.
|
||||||
|
fs.linkSync(claimPath, recoveryPath);
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(claimPath);
|
||||||
|
} catch {
|
||||||
|
// Both names preserve the same bytes; 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;
|
||||||
|
}
|
||||||
|
console.log(warn(`${reason}; content remains at ${claimPath}${code ? ` (${code})` : ''}`));
|
||||||
|
return claimPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateManagedJson(filePath: string, content: Buffer): boolean {
|
||||||
|
if (path.extname(filePath).toLowerCase() !== '.json') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(content.toString('utf8')) as unknown;
|
||||||
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path.basename(filePath) === 'installed_plugins.json') {
|
||||||
|
const registry = parsed as Record<string, unknown>;
|
||||||
|
return (
|
||||||
|
typeof registry.plugins === 'object' &&
|
||||||
|
registry.plugins !== null &&
|
||||||
|
!Array.isArray(registry.plugins)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function atomicWriteFile(targetPath: string, content: Buffer, mode: number): void {
|
||||||
|
const tempPath = `${targetPath}.ccs-write-${process.pid}-${Date.now()}-${adoptionClaimSequence++}`;
|
||||||
|
let descriptor: number | null = null;
|
||||||
|
try {
|
||||||
|
descriptor = fs.openSync(tempPath, 'wx', mode);
|
||||||
|
fs.fchmodSync(descriptor, mode);
|
||||||
|
fs.writeFileSync(descriptor, content);
|
||||||
|
fs.fsyncSync(descriptor);
|
||||||
|
fs.closeSync(descriptor);
|
||||||
|
descriptor = null;
|
||||||
|
fs.linkSync(tempPath, targetPath);
|
||||||
|
fs.unlinkSync(tempPath);
|
||||||
|
} catch (err) {
|
||||||
|
if (descriptor !== null) {
|
||||||
|
fs.closeSync(descriptor);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(tempPath);
|
||||||
|
} catch {
|
||||||
|
// The temp file may not have been created or may already have been renamed.
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function publishBackupNoReplace(sourcePath: string, canonicalPath: string): string {
|
||||||
|
const basePath = `${canonicalPath}.bak-ccs-adopt`;
|
||||||
|
const content = fs.readFileSync(sourcePath);
|
||||||
|
const mode = fs.statSync(sourcePath).mode & 0o777;
|
||||||
|
let sequence = 0;
|
||||||
|
while (true) {
|
||||||
|
const backupPath = sequence === 0 ? basePath : `${basePath}-${sequence}`;
|
||||||
|
try {
|
||||||
|
atomicWriteFile(backupPath, content, mode);
|
||||||
|
return backupPath;
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err;
|
||||||
|
sequence++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function publishAdoptedRecoveryNoReplace(sourcePath: string, divergedPath: string): string {
|
||||||
|
const basePath = `${divergedPath}.ccs-adopted-recovery`;
|
||||||
|
const content = fs.readFileSync(sourcePath);
|
||||||
|
const mode = fs.statSync(sourcePath).mode & 0o777;
|
||||||
|
let sequence = 0;
|
||||||
|
while (true) {
|
||||||
|
const recoveryPath = sequence === 0 ? basePath : `${basePath}-${sequence}`;
|
||||||
|
try {
|
||||||
|
atomicWriteFile(recoveryPath, content, mode);
|
||||||
|
return recoveryPath;
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err;
|
||||||
|
sequence++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreCanonicalClaim(claimPath: string, writePath: string, canonicalPath: string): void {
|
||||||
|
try {
|
||||||
|
fs.linkSync(claimPath, writePath);
|
||||||
|
fs.unlinkSync(claimPath);
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err;
|
||||||
|
publishBackupNoReplace(claimPath, canonicalPath);
|
||||||
|
fs.unlinkSync(claimPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCanonicalFile(canonicalPath: string): {
|
||||||
|
content: Buffer | null;
|
||||||
|
mode: number;
|
||||||
|
mtimeMs: number | null;
|
||||||
|
writePath: string;
|
||||||
|
} {
|
||||||
|
const canonicalLstat = getLstatSync(canonicalPath);
|
||||||
|
if (!canonicalLstat) {
|
||||||
|
return { content: null, mode: 0o600, mtimeMs: null, writePath: canonicalPath };
|
||||||
|
}
|
||||||
|
|
||||||
|
let writePath = canonicalPath;
|
||||||
|
if (canonicalLstat.isSymbolicLink()) {
|
||||||
|
writePath = fs.realpathSync.native(canonicalPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
const canonicalStats = fs.statSync(canonicalPath);
|
||||||
|
if (!canonicalStats.isFile()) {
|
||||||
|
throw Object.assign(new TypeError(`Canonical path is not a regular file: ${canonicalPath}`), {
|
||||||
|
code: 'EINVAL',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: fs.readFileSync(canonicalPath),
|
||||||
|
mode: canonicalStats.mode & 0o777,
|
||||||
|
mtimeMs: canonicalStats.mtimeMs,
|
||||||
|
writePath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adoptDivergedFileContent(
|
||||||
|
divergedPath: string,
|
||||||
|
canonicalPath: string
|
||||||
|
): DivergedFileAdoption {
|
||||||
|
const initialStats = getLstatSync(divergedPath);
|
||||||
|
if (!initialStats?.isFile()) {
|
||||||
|
return 'not-claimed';
|
||||||
|
}
|
||||||
|
|
||||||
|
const claimPath = `${divergedPath}.ccs-adopt-claim-${process.pid}-${Date.now()}-${adoptionClaimSequence++}`;
|
||||||
|
try {
|
||||||
|
fs.renameSync(divergedPath, claimPath);
|
||||||
|
} catch (err) {
|
||||||
|
const code = (err as NodeJS.ErrnoException).code;
|
||||||
|
console.log(
|
||||||
|
warn(
|
||||||
|
`Unable to claim diverged ${divergedPath}; preserving original${code ? ` (${code})` : ''}`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
let canonicalClaimPath: string | null = null;
|
||||||
|
let canonicalWritePath: string | null = null;
|
||||||
|
let divergencePreserved = false;
|
||||||
|
try {
|
||||||
|
const claimedStats = fs.lstatSync(claimPath);
|
||||||
|
if (!claimedStats.isFile()) {
|
||||||
|
preserveClaim(claimPath, divergedPath, `Refusing non-regular divergence at ${divergedPath}`);
|
||||||
|
divergencePreserved = true;
|
||||||
|
throw Object.assign(new TypeError(`Refusing non-regular divergence at ${divergedPath}`), {
|
||||||
|
code: 'EINVAL',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (getLstatSync(divergedPath)) {
|
||||||
|
preserveClaim(claimPath, divergedPath, `Concurrent replacement detected at ${divergedPath}`);
|
||||||
|
divergencePreserved = true;
|
||||||
|
throw Object.assign(new TypeError(`Concurrent replacement detected at ${divergedPath}`), {
|
||||||
|
code: 'EEXIST',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const diverged = fs.readFileSync(claimPath);
|
||||||
|
if (!validateManagedJson(divergedPath, diverged)) {
|
||||||
|
preserveClaim(claimPath, divergedPath, `Refusing malformed managed JSON at ${divergedPath}`);
|
||||||
|
return 'claimed';
|
||||||
|
}
|
||||||
|
|
||||||
|
const canonical = getCanonicalFile(canonicalPath);
|
||||||
|
let current = canonical.content;
|
||||||
|
let publishMode = canonical.mode;
|
||||||
|
if (current) {
|
||||||
|
canonicalWritePath = canonical.writePath;
|
||||||
|
canonicalClaimPath = `${canonical.writePath}.ccs-canonical-claim-${process.pid}-${Date.now()}-${adoptionClaimSequence++}`;
|
||||||
|
fs.renameSync(canonical.writePath, canonicalClaimPath);
|
||||||
|
const currentStats = fs.statSync(canonicalClaimPath);
|
||||||
|
publishMode = currentStats.mode & 0o777;
|
||||||
|
current = fs.readFileSync(canonicalClaimPath);
|
||||||
|
if (diverged.equals(current)) {
|
||||||
|
restoreCanonicalClaim(canonicalClaimPath, canonical.writePath, canonicalPath);
|
||||||
|
canonicalClaimPath = null;
|
||||||
|
fs.unlinkSync(claimPath);
|
||||||
|
return 'claimed';
|
||||||
|
}
|
||||||
|
if (claimedStats.mtimeMs <= currentStats.mtimeMs) {
|
||||||
|
restoreCanonicalClaim(canonicalClaimPath, canonical.writePath, canonicalPath);
|
||||||
|
canonicalClaimPath = null;
|
||||||
|
preserveClaim(
|
||||||
|
claimPath,
|
||||||
|
divergedPath,
|
||||||
|
`Refusing stale or ambiguously-timed divergence at ${divergedPath}`
|
||||||
|
);
|
||||||
|
return 'claimed';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (canonicalClaimPath) {
|
||||||
|
publishBackupNoReplace(canonicalClaimPath, canonicalPath);
|
||||||
|
}
|
||||||
|
publishAdoptedRecoveryNoReplace(claimPath, divergedPath);
|
||||||
|
|
||||||
|
atomicWriteFile(canonical.writePath, diverged, publishMode);
|
||||||
|
if (!fs.readFileSync(canonicalPath).equals(diverged)) {
|
||||||
|
throw Object.assign(
|
||||||
|
new TypeError(`Canonical adoption postcondition failed: ${canonicalPath}`),
|
||||||
|
{
|
||||||
|
code: 'EAGAIN',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (canonicalClaimPath) {
|
||||||
|
fs.unlinkSync(canonicalClaimPath);
|
||||||
|
canonicalClaimPath = null;
|
||||||
|
}
|
||||||
|
if (getLstatSync(divergedPath)) {
|
||||||
|
preserveClaim(claimPath, divergedPath, `Concurrent replacement detected at ${divergedPath}`);
|
||||||
|
divergencePreserved = true;
|
||||||
|
throw Object.assign(new TypeError(`Concurrent replacement detected at ${divergedPath}`), {
|
||||||
|
code: 'EEXIST',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
fs.unlinkSync(claimPath);
|
||||||
|
console.log(
|
||||||
|
info(`Adopted diverged ${path.basename(divergedPath)} content into ${canonicalPath}`)
|
||||||
|
);
|
||||||
|
return 'claimed';
|
||||||
|
} catch (err) {
|
||||||
|
if (canonicalClaimPath && canonicalWritePath) {
|
||||||
|
restoreCanonicalClaim(canonicalClaimPath, canonicalWritePath, canonicalPath);
|
||||||
|
canonicalClaimPath = null;
|
||||||
|
}
|
||||||
|
if (divergencePreserved) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
if (!getLstatSync(divergedPath)) {
|
||||||
|
try {
|
||||||
|
fs.renameSync(claimPath, divergedPath);
|
||||||
|
} catch {
|
||||||
|
preserveClaim(claimPath, divergedPath, `Unable to restore diverged ${divergedPath}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
preserveClaim(claimPath, divergedPath, `Unable to restore diverged ${divergedPath}`);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,7 +12,6 @@
|
|||||||
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';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -24,31 +23,6 @@ import type { SharedItem } from './types';
|
|||||||
* of discarding the user's changes. The previous canonical content is kept
|
* of discarding the user's changes. The previous canonical content is kept
|
||||||
* in a `.bak-ccs-adopt` backup alongside it.
|
* 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).
|
||||||
|
|||||||
@@ -16,7 +16,12 @@ import * as path from 'path';
|
|||||||
import { warn } from '../../utils/ui';
|
import { warn } from '../../utils/ui';
|
||||||
import {
|
import {
|
||||||
adoptDivergedFileContent,
|
adoptDivergedFileContent,
|
||||||
|
assertAdoptionPathAbsent,
|
||||||
|
recoverOrphanedCanonicalClaim,
|
||||||
|
} from './diverged-file-adopter';
|
||||||
|
import {
|
||||||
copyDirectoryFallback,
|
copyDirectoryFallback,
|
||||||
|
getLstatSync,
|
||||||
removeExistingPath,
|
removeExistingPath,
|
||||||
symlinkPointsTo,
|
symlinkPointsTo,
|
||||||
} from './fs-helpers';
|
} from './fs-helpers';
|
||||||
@@ -45,6 +50,9 @@ export function ensureSharedPluginLayoutDefaults(claudeDir: string): void {
|
|||||||
|
|
||||||
for (const entry of SHARED_PLUGIN_ENTRIES) {
|
for (const entry of SHARED_PLUGIN_ENTRIES) {
|
||||||
const entryPath = path.join(pluginsDir, entry.name);
|
const entryPath = path.join(pluginsDir, entry.name);
|
||||||
|
if (entry.type === 'file') {
|
||||||
|
recoverOrphanedCanonicalClaim(entryPath);
|
||||||
|
}
|
||||||
if (fs.existsSync(entryPath)) {
|
if (fs.existsSync(entryPath)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -91,22 +99,40 @@ export function linkInstancePlugins(roots: PluginLayoutRoots, instancePath: stri
|
|||||||
for (const item of getSharedPluginLinkItems(roots.sharedDir)) {
|
for (const item of getSharedPluginLinkItems(roots.sharedDir)) {
|
||||||
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);
|
||||||
|
let adoption: ReturnType<typeof adoptDivergedFileContent> = 'not-claimed';
|
||||||
|
|
||||||
if (item.type === 'file') {
|
if (item.type === 'file') {
|
||||||
adoptDivergedFileContent(linkEntryPath, path.join(roots.claudeDir, 'plugins', item.name));
|
adoption = adoptDivergedFileContent(
|
||||||
|
linkEntryPath,
|
||||||
|
path.join(roots.claudeDir, 'plugins', item.name)
|
||||||
|
);
|
||||||
|
if (adoption === 'not-claimed') {
|
||||||
|
removeExistingPath(linkEntryPath, item.type);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
removeExistingPath(linkEntryPath, item.type);
|
||||||
}
|
}
|
||||||
|
assertAdoptionPathAbsent(linkEntryPath, adoption);
|
||||||
|
|
||||||
removeExistingPath(linkEntryPath, item.type);
|
if (getLstatSync(linkEntryPath)) {
|
||||||
|
console.log(warn(`Skipping plugins/${item.name}: path reappeared during reconciliation`));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const symlinkType = item.type === 'directory' ? 'dir' : 'file';
|
const symlinkType = item.type === 'directory' ? 'dir' : 'file';
|
||||||
fs.symlinkSync(targetEntryPath, linkEntryPath, symlinkType);
|
fs.symlinkSync(targetEntryPath, linkEntryPath, symlinkType);
|
||||||
} catch (_err) {
|
} catch (_err) {
|
||||||
|
assertAdoptionPathAbsent(linkEntryPath, adoption);
|
||||||
|
if (getLstatSync(linkEntryPath)) {
|
||||||
|
console.log(warn(`Skipping plugins/${item.name}: path reappeared during reconciliation`));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (process.platform === 'win32') {
|
if (process.platform === 'win32') {
|
||||||
if (item.type === 'directory') {
|
if (item.type === 'directory') {
|
||||||
copyDirectoryFallback(targetEntryPath, linkEntryPath);
|
copyDirectoryFallback(targetEntryPath, linkEntryPath);
|
||||||
} else {
|
} else {
|
||||||
fs.copyFileSync(targetEntryPath, linkEntryPath);
|
fs.copyFileSync(targetEntryPath, linkEntryPath, fs.constants.COPYFILE_EXCL);
|
||||||
}
|
}
|
||||||
console.log(
|
console.log(
|
||||||
warn(`Symlink failed for plugins/${item.name}, copied instead (enable Developer Mode)`)
|
warn(`Symlink failed for plugins/${item.name}, copied instead (enable Developer Mode)`)
|
||||||
@@ -130,7 +156,16 @@ export function getSharedPluginLinkItems(sharedDir: string): SharedItem[] {
|
|||||||
);
|
);
|
||||||
|
|
||||||
for (const entry of fs.readdirSync(sharedPluginsPath, { withFileTypes: true })) {
|
for (const entry of fs.readdirSync(sharedPluginsPath, { withFileTypes: true })) {
|
||||||
if (items.has(entry.name) || INSTANCE_LOCAL_PLUGIN_METADATA_FILES.has(entry.name)) {
|
if (
|
||||||
|
items.has(entry.name) ||
|
||||||
|
INSTANCE_LOCAL_PLUGIN_METADATA_FILES.has(entry.name) ||
|
||||||
|
entry.name.includes('.bak-ccs-adopt') ||
|
||||||
|
entry.name.includes('.ccs-adopt-') ||
|
||||||
|
entry.name.includes('.ccs-adopted-recovery') ||
|
||||||
|
entry.name.includes('.ccs-canonical-claim-') ||
|
||||||
|
entry.name.includes('.ccs-canonical-recovery') ||
|
||||||
|
entry.name.includes('.ccs-write-')
|
||||||
|
) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ import * as path from 'path';
|
|||||||
import { info, warn } from '../../utils/ui';
|
import { info, warn } from '../../utils/ui';
|
||||||
import {
|
import {
|
||||||
adoptDivergedFileContent,
|
adoptDivergedFileContent,
|
||||||
|
assertAdoptionPathAbsent,
|
||||||
|
recoverOrphanedCanonicalClaim,
|
||||||
|
} from './diverged-file-adopter';
|
||||||
|
import {
|
||||||
copyDirectoryFallback,
|
copyDirectoryFallback,
|
||||||
getLstatSync,
|
getLstatSync,
|
||||||
isPathWithinDirectory,
|
isPathWithinDirectory,
|
||||||
@@ -112,6 +116,10 @@ export function ensureSharedDirectories(roots: LinkerRoots): void {
|
|||||||
const claudePath = path.join(claudeDir, item.name);
|
const claudePath = path.join(claudeDir, item.name);
|
||||||
const sharedPath = path.join(sharedDir, item.name);
|
const sharedPath = path.join(sharedDir, item.name);
|
||||||
|
|
||||||
|
if (item.type === 'file') {
|
||||||
|
recoverOrphanedCanonicalClaim(claudePath);
|
||||||
|
}
|
||||||
|
|
||||||
if (!getLstatSync(claudePath)) {
|
if (!getLstatSync(claudePath)) {
|
||||||
if (item.type === 'directory') {
|
if (item.type === 'directory') {
|
||||||
fs.mkdirSync(claudePath, { recursive: true, mode: 0o700 });
|
fs.mkdirSync(claudePath, { recursive: true, mode: 0o700 });
|
||||||
@@ -125,7 +133,9 @@ export function ensureSharedDirectories(roots: LinkerRoots): void {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let sharedAdoption: ReturnType<typeof adoptDivergedFileContent> = 'not-claimed';
|
||||||
if (getLstatSync(sharedPath)) {
|
if (getLstatSync(sharedPath)) {
|
||||||
|
let removeExisting = true;
|
||||||
try {
|
try {
|
||||||
const stats = fs.lstatSync(sharedPath);
|
const stats = fs.lstatSync(sharedPath);
|
||||||
if (stats.isSymbolicLink()) {
|
if (stats.isSymbolicLink()) {
|
||||||
@@ -140,25 +150,37 @@ export function ensureSharedDirectories(roots: LinkerRoots): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (item.type === 'file') {
|
if (item.type === 'file') {
|
||||||
adoptDivergedFileContent(sharedPath, claudePath);
|
sharedAdoption = adoptDivergedFileContent(sharedPath, claudePath);
|
||||||
|
removeExisting = sharedAdoption === 'not-claimed';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.type === 'directory') {
|
if (item.type === 'directory' && removeExisting) {
|
||||||
fs.rmSync(sharedPath, { recursive: true, force: true });
|
fs.rmSync(sharedPath, { recursive: true, force: true });
|
||||||
} else {
|
} else if (removeExisting) {
|
||||||
fs.unlinkSync(sharedPath);
|
fs.unlinkSync(sharedPath);
|
||||||
}
|
}
|
||||||
|
assertAdoptionPathAbsent(sharedPath, sharedAdoption);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (getLstatSync(sharedPath)) {
|
||||||
|
console.log(warn(`Skipping ${item.name}: path reappeared during reconciliation`));
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const symlinkType = item.type === 'directory' ? 'dir' : 'file';
|
const symlinkType = item.type === 'directory' ? 'dir' : 'file';
|
||||||
fs.symlinkSync(claudePath, sharedPath, symlinkType);
|
fs.symlinkSync(claudePath, sharedPath, symlinkType);
|
||||||
} catch (_err) {
|
} catch (_err) {
|
||||||
|
assertAdoptionPathAbsent(sharedPath, sharedAdoption);
|
||||||
|
if (getLstatSync(sharedPath)) {
|
||||||
|
console.log(warn(`Skipping ${item.name}: path reappeared during reconciliation`));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (process.platform === 'win32') {
|
if (process.platform === 'win32') {
|
||||||
if (item.type === 'directory') {
|
if (item.type === 'directory') {
|
||||||
copyDirectoryFallback(claudePath, sharedPath);
|
copyDirectoryFallback(claudePath, sharedPath);
|
||||||
} else if (item.type === 'file') {
|
} else if (item.type === 'file') {
|
||||||
fs.copyFileSync(claudePath, sharedPath);
|
fs.copyFileSync(claudePath, sharedPath, fs.constants.COPYFILE_EXCL);
|
||||||
}
|
}
|
||||||
console.log(
|
console.log(
|
||||||
warn(`Symlink failed for ${item.name}, copied instead (enable Developer Mode)`)
|
warn(`Symlink failed for ${item.name}, copied instead (enable Developer Mode)`)
|
||||||
@@ -186,22 +208,37 @@ export function linkSharedDirectories(roots: LinkerRoots, instancePath: string):
|
|||||||
|
|
||||||
const linkPath = path.join(instancePath, item.name);
|
const linkPath = path.join(instancePath, item.name);
|
||||||
const targetPath = path.join(sharedDir, item.name);
|
const targetPath = path.join(sharedDir, item.name);
|
||||||
|
let adoption: ReturnType<typeof adoptDivergedFileContent> = 'not-claimed';
|
||||||
|
|
||||||
if (item.type === 'file') {
|
if (item.type === 'file') {
|
||||||
adoptDivergedFileContent(linkPath, path.join(roots.claudeDir, item.name));
|
adoption = adoptDivergedFileContent(linkPath, path.join(roots.claudeDir, item.name));
|
||||||
|
if (adoption === 'not-claimed') {
|
||||||
|
removeExistingPath(linkPath, item.type);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
removeExistingPath(linkPath, item.type);
|
||||||
}
|
}
|
||||||
|
assertAdoptionPathAbsent(linkPath, adoption);
|
||||||
|
|
||||||
removeExistingPath(linkPath, item.type);
|
if (getLstatSync(linkPath)) {
|
||||||
|
console.log(warn(`Skipping ${item.name}: path reappeared during reconciliation`));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const symlinkType = item.type === 'directory' ? 'dir' : 'file';
|
const symlinkType = item.type === 'directory' ? 'dir' : 'file';
|
||||||
fs.symlinkSync(targetPath, linkPath, symlinkType);
|
fs.symlinkSync(targetPath, linkPath, symlinkType);
|
||||||
} catch (_err) {
|
} catch (_err) {
|
||||||
|
assertAdoptionPathAbsent(linkPath, adoption);
|
||||||
|
if (getLstatSync(linkPath)) {
|
||||||
|
console.log(warn(`Skipping ${item.name}: path reappeared during reconciliation`));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (process.platform === 'win32') {
|
if (process.platform === 'win32') {
|
||||||
if (item.type === 'directory') {
|
if (item.type === 'directory') {
|
||||||
copyDirectoryFallback(targetPath, linkPath);
|
copyDirectoryFallback(targetPath, linkPath);
|
||||||
} else if (item.type === 'file') {
|
} else if (item.type === 'file') {
|
||||||
fs.copyFileSync(targetPath, linkPath);
|
fs.copyFileSync(targetPath, linkPath, fs.constants.COPYFILE_EXCL);
|
||||||
}
|
}
|
||||||
console.log(
|
console.log(
|
||||||
warn(`Symlink failed for ${item.name}, copied instead (enable Developer Mode)`)
|
warn(`Symlink failed for ${item.name}, copied instead (enable Developer Mode)`)
|
||||||
|
|||||||
@@ -35,6 +35,18 @@ describe('SharedManager', () => {
|
|||||||
fs.writeFileSync(filePath, JSON.stringify(value, null, 2), 'utf8');
|
fs.writeFileSync(filePath, JSON.stringify(value, null, 2), 'utf8');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setMtime(filePath: string, mtimeMs: number): void {
|
||||||
|
const timestamp = new Date(mtimeMs);
|
||||||
|
fs.utimesSync(filePath, timestamp, timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
function findRecovery(filePath: string): string | undefined {
|
||||||
|
const prefix = `${path.basename(filePath)}.ccs-adopt-recovery`;
|
||||||
|
return fs
|
||||||
|
.readdirSync(path.dirname(filePath))
|
||||||
|
.find((entry) => entry === prefix || entry.startsWith(`${prefix}-`));
|
||||||
|
}
|
||||||
|
|
||||||
function readMarketplaceLocation(filePath: string, name = 'claude-code-plugins'): string {
|
function readMarketplaceLocation(filePath: string, name = 'claude-code-plugins'): string {
|
||||||
const parsed = readJson(filePath) as Record<string, { installLocation?: string }>;
|
const parsed = readJson(filePath) as Record<string, { installLocation?: string }>;
|
||||||
return parsed[name]?.installLocation ?? '';
|
return parsed[name]?.installLocation ?? '';
|
||||||
@@ -318,6 +330,7 @@ describe('SharedManager', () => {
|
|||||||
// Simulate Claude Code's atomic save (temp file + rename) replacing the
|
// Simulate Claude Code's atomic save (temp file + rename) replacing the
|
||||||
// managed symlink with a regular file carrying the user's latest change.
|
// managed symlink with a regular file carrying the user's latest change.
|
||||||
writeJson(sharedSettingsPath, { enabledPlugins: { 'demo@market': true } });
|
writeJson(sharedSettingsPath, { enabledPlugins: { 'demo@market': true } });
|
||||||
|
setMtime(sharedSettingsPath, fs.statSync(claudeSettingsPath).mtimeMs + 2_000);
|
||||||
|
|
||||||
manager.ensureSharedDirectories();
|
manager.ensureSharedDirectories();
|
||||||
|
|
||||||
@@ -340,6 +353,7 @@ describe('SharedManager', () => {
|
|||||||
fs.mkdirSync(instancePath, { recursive: true });
|
fs.mkdirSync(instancePath, { recursive: true });
|
||||||
writeJson(claudeSettingsPath, { theme: 'light' });
|
writeJson(claudeSettingsPath, { theme: 'light' });
|
||||||
writeJson(instanceSettingsPath, { theme: 'dark' });
|
writeJson(instanceSettingsPath, { theme: 'dark' });
|
||||||
|
setMtime(instanceSettingsPath, fs.statSync(claudeSettingsPath).mtimeMs + 2_000);
|
||||||
|
|
||||||
manager.linkSharedDirectories(instancePath);
|
manager.linkSharedDirectories(instancePath);
|
||||||
|
|
||||||
@@ -362,6 +376,7 @@ describe('SharedManager', () => {
|
|||||||
version: 2,
|
version: 2,
|
||||||
plugins: { 'demo@demo-market': [{ scope: 'user', version: '1.0.0' }] },
|
plugins: { 'demo@demo-market': [{ scope: 'user', version: '1.0.0' }] },
|
||||||
});
|
});
|
||||||
|
setMtime(instanceRegistryPath, fs.statSync(claudeRegistryPath).mtimeMs + 2_000);
|
||||||
|
|
||||||
manager.linkSharedDirectories(instancePath);
|
manager.linkSharedDirectories(instancePath);
|
||||||
|
|
||||||
@@ -386,6 +401,388 @@ describe('SharedManager', () => {
|
|||||||
expect(readJson(claudeSettingsPath)).toEqual({ theme: 'dark' });
|
expect(readJson(claudeSettingsPath)).toEqual({ theme: 'dark' });
|
||||||
expect(fs.existsSync(`${claudeSettingsPath}.bak-ccs-adopt`)).toBe(false);
|
expect(fs.existsSync(`${claudeSettingsPath}.bak-ccs-adopt`)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('preserves diverged bytes when canonical backup creation fails', () => {
|
||||||
|
const manager = new SharedManager();
|
||||||
|
const instancePath = instanceDir('work');
|
||||||
|
const claudeSettingsPath = path.join(claudeDir(), 'settings.json');
|
||||||
|
const instanceSettingsPath = path.join(instancePath, 'settings.json');
|
||||||
|
|
||||||
|
fs.mkdirSync(claudeDir(), { recursive: true });
|
||||||
|
fs.mkdirSync(instancePath, { recursive: true });
|
||||||
|
writeJson(claudeSettingsPath, { theme: 'light' });
|
||||||
|
writeJson(instanceSettingsPath, { theme: 'dark' });
|
||||||
|
setMtime(instanceSettingsPath, fs.statSync(claudeSettingsPath).mtimeMs + 2_000);
|
||||||
|
|
||||||
|
const originalLinkSync = fs.linkSync;
|
||||||
|
const linkSpy = spyOn(fs, 'linkSync').mockImplementation(((
|
||||||
|
existingPath: fs.PathLike,
|
||||||
|
newPath: fs.PathLike
|
||||||
|
) => {
|
||||||
|
if (String(newPath).includes('.bak-ccs-adopt')) {
|
||||||
|
throw Object.assign(new Error('simulated backup failure'), { code: 'EIO' });
|
||||||
|
}
|
||||||
|
return originalLinkSync(existingPath, newPath);
|
||||||
|
}) as typeof fs.linkSync);
|
||||||
|
|
||||||
|
expect(() => manager.linkSharedDirectories(instancePath)).toThrow('simulated backup failure');
|
||||||
|
linkSpy.mockRestore();
|
||||||
|
|
||||||
|
expect(readJson(claudeSettingsPath)).toEqual({ theme: 'light' });
|
||||||
|
expect(readJson(instanceSettingsPath)).toEqual({ theme: 'dark' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not let a stale Windows fallback roll back newer canonical settings', () => {
|
||||||
|
const manager = new SharedManager();
|
||||||
|
const instancePath = instanceDir('windows-copy');
|
||||||
|
const claudeSettingsPath = path.join(claudeDir(), 'settings.json');
|
||||||
|
const instanceSettingsPath = path.join(instancePath, 'settings.json');
|
||||||
|
|
||||||
|
fs.mkdirSync(claudeDir(), { recursive: true });
|
||||||
|
writeJson(claudeSettingsPath, { generation: 2 });
|
||||||
|
manager.ensureSharedDirectories();
|
||||||
|
fs.mkdirSync(instancePath, { recursive: true });
|
||||||
|
writeJson(instanceSettingsPath, { generation: 1 });
|
||||||
|
setMtime(instanceSettingsPath, fs.statSync(claudeSettingsPath).mtimeMs - 2_000);
|
||||||
|
|
||||||
|
Object.defineProperty(process, 'platform', { value: 'win32' });
|
||||||
|
spyOn(fs, 'symlinkSync').mockImplementation(() => {
|
||||||
|
throw Object.assign(new Error('simulated symlink failure'), { code: 'EPERM' });
|
||||||
|
});
|
||||||
|
|
||||||
|
manager.linkSharedDirectories(instancePath);
|
||||||
|
|
||||||
|
expect(readJson(claudeSettingsPath)).toEqual({ generation: 2 });
|
||||||
|
expect(readJson(instanceSettingsPath)).toEqual({ generation: 2 });
|
||||||
|
const recoveryName = findRecovery(instanceSettingsPath);
|
||||||
|
expect(recoveryName).toBeDefined();
|
||||||
|
expect(readJson(path.join(path.dirname(instanceSettingsPath), recoveryName!))).toEqual({
|
||||||
|
generation: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves divergence when the canonical settings symlink is dangling', () => {
|
||||||
|
const manager = new SharedManager();
|
||||||
|
const instancePath = instanceDir('dangling');
|
||||||
|
const claudeSettingsPath = path.join(claudeDir(), 'settings.json');
|
||||||
|
const missingTarget = path.join(tempRoot, 'missing', 'settings.json');
|
||||||
|
const instanceSettingsPath = path.join(instancePath, 'settings.json');
|
||||||
|
|
||||||
|
fs.mkdirSync(claudeDir(), { recursive: true });
|
||||||
|
fs.mkdirSync(instancePath, { recursive: true });
|
||||||
|
fs.symlinkSync(missingTarget, claudeSettingsPath, 'file');
|
||||||
|
writeJson(instanceSettingsPath, { preserved: true });
|
||||||
|
|
||||||
|
expect(() => manager.linkSharedDirectories(instancePath)).toThrow();
|
||||||
|
|
||||||
|
expect(fs.lstatSync(claudeSettingsPath).isSymbolicLink()).toBe(true);
|
||||||
|
expect(fs.existsSync(missingTarget)).toBe(false);
|
||||||
|
expect(readJson(instanceSettingsPath)).toEqual({ preserved: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves malformed managed JSON instead of poisoning canonical settings', () => {
|
||||||
|
const manager = new SharedManager();
|
||||||
|
const instancePath = instanceDir('malformed');
|
||||||
|
const claudeSettingsPath = path.join(claudeDir(), 'settings.json');
|
||||||
|
const instanceSettingsPath = path.join(instancePath, 'settings.json');
|
||||||
|
|
||||||
|
fs.mkdirSync(claudeDir(), { recursive: true });
|
||||||
|
fs.mkdirSync(instancePath, { recursive: true });
|
||||||
|
writeJson(claudeSettingsPath, { valid: true });
|
||||||
|
fs.writeFileSync(instanceSettingsPath, '{broken-json', 'utf8');
|
||||||
|
setMtime(instanceSettingsPath, fs.statSync(claudeSettingsPath).mtimeMs + 2_000);
|
||||||
|
|
||||||
|
manager.linkSharedDirectories(instancePath);
|
||||||
|
|
||||||
|
expect(readJson(claudeSettingsPath)).toEqual({ valid: true });
|
||||||
|
expect(fs.lstatSync(instanceSettingsPath).isSymbolicLink()).toBe(true);
|
||||||
|
const recoveryName = findRecovery(instanceSettingsPath);
|
||||||
|
expect(recoveryName).toBeDefined();
|
||||||
|
expect(
|
||||||
|
fs.readFileSync(path.join(path.dirname(instanceSettingsPath), recoveryName!), 'utf8')
|
||||||
|
).toBe('{broken-json');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aborts relinking when a writer replaces the path after it is claimed', () => {
|
||||||
|
const manager = new SharedManager();
|
||||||
|
const instancePath = instanceDir('concurrent');
|
||||||
|
const claudeSettingsPath = path.join(claudeDir(), 'settings.json');
|
||||||
|
const instanceSettingsPath = path.join(instancePath, 'settings.json');
|
||||||
|
|
||||||
|
fs.mkdirSync(claudeDir(), { recursive: true });
|
||||||
|
fs.mkdirSync(instancePath, { recursive: true });
|
||||||
|
writeJson(claudeSettingsPath, { generation: 0 });
|
||||||
|
writeJson(instanceSettingsPath, { generation: 1 });
|
||||||
|
setMtime(instanceSettingsPath, fs.statSync(claudeSettingsPath).mtimeMs + 2_000);
|
||||||
|
|
||||||
|
const originalRenameSync = fs.renameSync;
|
||||||
|
const renameSpy = spyOn(fs, 'renameSync').mockImplementation(((
|
||||||
|
oldPath: fs.PathLike,
|
||||||
|
newPath: fs.PathLike
|
||||||
|
) => {
|
||||||
|
originalRenameSync(oldPath, newPath);
|
||||||
|
if (
|
||||||
|
String(oldPath) === instanceSettingsPath &&
|
||||||
|
String(newPath).includes('.ccs-adopt-claim-')
|
||||||
|
) {
|
||||||
|
writeJson(instanceSettingsPath, { generation: 2 });
|
||||||
|
}
|
||||||
|
}) as typeof fs.renameSync);
|
||||||
|
|
||||||
|
expect(() => manager.linkSharedDirectories(instancePath)).toThrow(
|
||||||
|
'Concurrent replacement detected'
|
||||||
|
);
|
||||||
|
renameSpy.mockRestore();
|
||||||
|
|
||||||
|
expect(readJson(instanceSettingsPath)).toEqual({ generation: 2 });
|
||||||
|
expect(fs.lstatSync(instanceSettingsPath).isSymbolicLink()).toBe(false);
|
||||||
|
expect(readJson(claudeSettingsPath)).toEqual({ generation: 0 });
|
||||||
|
const recoveryName = findRecovery(instanceSettingsPath);
|
||||||
|
expect(recoveryName).toBeDefined();
|
||||||
|
expect(readJson(path.join(path.dirname(instanceSettingsPath), recoveryName!))).toEqual({
|
||||||
|
generation: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves a canonical write that lands during no-replace publication', () => {
|
||||||
|
const manager = new SharedManager();
|
||||||
|
const instancePath = instanceDir('canonical-race');
|
||||||
|
const canonicalPath = path.join(claudeDir(), 'settings.json');
|
||||||
|
const divergedPath = path.join(instancePath, 'settings.json');
|
||||||
|
fs.mkdirSync(claudeDir(), { recursive: true });
|
||||||
|
fs.mkdirSync(instancePath, { recursive: true });
|
||||||
|
writeJson(canonicalPath, { generation: 0 });
|
||||||
|
writeJson(divergedPath, { generation: 1 });
|
||||||
|
setMtime(divergedPath, fs.statSync(canonicalPath).mtimeMs + 2_000);
|
||||||
|
|
||||||
|
const originalLinkSync = fs.linkSync;
|
||||||
|
const linkSpy = spyOn(fs, 'linkSync').mockImplementation(((
|
||||||
|
existingPath: fs.PathLike,
|
||||||
|
newPath: fs.PathLike
|
||||||
|
) => {
|
||||||
|
if (String(newPath) === canonicalPath && String(existingPath).includes('.ccs-write-')) {
|
||||||
|
writeJson(canonicalPath, { generation: 99 });
|
||||||
|
}
|
||||||
|
return originalLinkSync(existingPath, newPath);
|
||||||
|
}) as typeof fs.linkSync);
|
||||||
|
|
||||||
|
expect(() => manager.linkSharedDirectories(instancePath)).toThrow();
|
||||||
|
linkSpy.mockRestore();
|
||||||
|
expect(readJson(canonicalPath)).toEqual({ generation: 99 });
|
||||||
|
expect(readJson(divergedPath)).toEqual({ generation: 1 });
|
||||||
|
expect(readJson(`${canonicalPath}.bak-ccs-adopt`)).toEqual({ generation: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps adopted bytes recoverable when canonical changes after verification', () => {
|
||||||
|
const manager = new SharedManager();
|
||||||
|
const instancePath = instanceDir('late-canonical-writer');
|
||||||
|
const canonicalPath = path.join(claudeDir(), 'settings.json');
|
||||||
|
const divergedPath = path.join(instancePath, 'settings.json');
|
||||||
|
fs.mkdirSync(claudeDir(), { recursive: true });
|
||||||
|
fs.mkdirSync(instancePath, { recursive: true });
|
||||||
|
writeJson(canonicalPath, { generation: 0 });
|
||||||
|
writeJson(divergedPath, { generation: 1 });
|
||||||
|
setMtime(divergedPath, fs.statSync(canonicalPath).mtimeMs + 2_000);
|
||||||
|
|
||||||
|
const originalUnlinkSync = fs.unlinkSync;
|
||||||
|
let injected = false;
|
||||||
|
const unlinkSpy = spyOn(fs, 'unlinkSync').mockImplementation(((targetPath: fs.PathLike) => {
|
||||||
|
if (!injected && String(targetPath).includes('.ccs-canonical-claim-')) {
|
||||||
|
injected = true;
|
||||||
|
writeJson(canonicalPath, { generation: 99 });
|
||||||
|
}
|
||||||
|
return originalUnlinkSync(targetPath);
|
||||||
|
}) as typeof fs.unlinkSync);
|
||||||
|
|
||||||
|
manager.linkSharedDirectories(instancePath);
|
||||||
|
unlinkSpy.mockRestore();
|
||||||
|
expect(readJson(canonicalPath)).toEqual({ generation: 99 });
|
||||||
|
expect(readJson(`${divergedPath}.ccs-adopted-recovery`)).toEqual({ generation: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails reconciliation when the managed source reappears during late cleanup', () => {
|
||||||
|
const manager = new SharedManager();
|
||||||
|
const instancePath = instanceDir('late-source-writer');
|
||||||
|
const canonicalPath = path.join(claudeDir(), 'settings.json');
|
||||||
|
const divergedPath = path.join(instancePath, 'settings.json');
|
||||||
|
fs.mkdirSync(claudeDir(), { recursive: true });
|
||||||
|
fs.mkdirSync(instancePath, { recursive: true });
|
||||||
|
writeJson(canonicalPath, { generation: 0 });
|
||||||
|
writeJson(divergedPath, { generation: 1 });
|
||||||
|
setMtime(divergedPath, fs.statSync(canonicalPath).mtimeMs + 2_000);
|
||||||
|
|
||||||
|
const originalUnlinkSync = fs.unlinkSync;
|
||||||
|
let injected = false;
|
||||||
|
const unlinkSpy = spyOn(fs, 'unlinkSync').mockImplementation(((targetPath: fs.PathLike) => {
|
||||||
|
if (!injected && String(targetPath).includes('.ccs-canonical-claim-')) {
|
||||||
|
injected = true;
|
||||||
|
writeJson(divergedPath, { generation: 2 });
|
||||||
|
}
|
||||||
|
return originalUnlinkSync(targetPath);
|
||||||
|
}) as typeof fs.unlinkSync);
|
||||||
|
|
||||||
|
expect(() => manager.linkSharedDirectories(instancePath)).toThrow(
|
||||||
|
'Concurrent replacement detected'
|
||||||
|
);
|
||||||
|
unlinkSpy.mockRestore();
|
||||||
|
expect(readJson(divergedPath)).toEqual({ generation: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retries backup publication without replacing a concurrent backup', () => {
|
||||||
|
const manager = new SharedManager();
|
||||||
|
const instancePath = instanceDir('backup-race');
|
||||||
|
const canonicalPath = path.join(claudeDir(), 'settings.json');
|
||||||
|
const divergedPath = path.join(instancePath, 'settings.json');
|
||||||
|
fs.mkdirSync(claudeDir(), { recursive: true });
|
||||||
|
fs.mkdirSync(instancePath, { recursive: true });
|
||||||
|
writeJson(canonicalPath, { generation: 0 });
|
||||||
|
writeJson(divergedPath, { generation: 1 });
|
||||||
|
setMtime(divergedPath, fs.statSync(canonicalPath).mtimeMs + 2_000);
|
||||||
|
|
||||||
|
const backupPath = `${canonicalPath}.bak-ccs-adopt`;
|
||||||
|
const originalLinkSync = fs.linkSync;
|
||||||
|
let injected = false;
|
||||||
|
const linkSpy = spyOn(fs, 'linkSync').mockImplementation(((
|
||||||
|
existingPath: fs.PathLike,
|
||||||
|
newPath: fs.PathLike
|
||||||
|
) => {
|
||||||
|
if (!injected && String(newPath) === backupPath) {
|
||||||
|
injected = true;
|
||||||
|
writeJson(backupPath, { competing: true });
|
||||||
|
}
|
||||||
|
return originalLinkSync(existingPath, newPath);
|
||||||
|
}) as typeof fs.linkSync);
|
||||||
|
|
||||||
|
manager.linkSharedDirectories(instancePath);
|
||||||
|
linkSpy.mockRestore();
|
||||||
|
expect(readJson(backupPath)).toEqual({ competing: true });
|
||||||
|
expect(readJson(`${backupPath}-1`)).toEqual({ generation: 0 });
|
||||||
|
expect(readJson(canonicalPath)).toEqual({ generation: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves regular and symlink-target modes under a restrictive umask', () => {
|
||||||
|
const manager = new SharedManager();
|
||||||
|
const canonicalPath = path.join(claudeDir(), 'settings.json');
|
||||||
|
const regularInstance = instanceDir('regular-mode');
|
||||||
|
fs.mkdirSync(claudeDir(), { recursive: true });
|
||||||
|
fs.mkdirSync(regularInstance, { recursive: true });
|
||||||
|
writeJson(canonicalPath, { generation: 0 });
|
||||||
|
fs.chmodSync(canonicalPath, 0o640);
|
||||||
|
const regularDiverged = path.join(regularInstance, 'settings.json');
|
||||||
|
writeJson(regularDiverged, { generation: 1 });
|
||||||
|
setMtime(regularDiverged, fs.statSync(canonicalPath).mtimeMs + 2_000);
|
||||||
|
|
||||||
|
const previousUmask = process.umask(0o077);
|
||||||
|
try {
|
||||||
|
manager.linkSharedDirectories(regularInstance);
|
||||||
|
expect(fs.statSync(canonicalPath).mode & 0o777).toBe(0o640);
|
||||||
|
|
||||||
|
const targetPath = path.join(tempRoot, 'external-settings.json');
|
||||||
|
fs.renameSync(canonicalPath, targetPath);
|
||||||
|
fs.symlinkSync(targetPath, canonicalPath, 'file');
|
||||||
|
fs.chmodSync(targetPath, 0o664);
|
||||||
|
const symlinkInstance = instanceDir('symlink-mode');
|
||||||
|
fs.mkdirSync(symlinkInstance, { recursive: true });
|
||||||
|
const symlinkDiverged = path.join(symlinkInstance, 'settings.json');
|
||||||
|
writeJson(symlinkDiverged, { generation: 2 });
|
||||||
|
setMtime(symlinkDiverged, fs.statSync(targetPath).mtimeMs + 2_000);
|
||||||
|
manager.linkSharedDirectories(symlinkInstance);
|
||||||
|
expect(fs.statSync(targetPath).mode & 0o777).toBe(0o664);
|
||||||
|
expect(fs.lstatSync(canonicalPath).isSymbolicLink()).toBe(true);
|
||||||
|
} finally {
|
||||||
|
process.umask(previousUmask);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the mode of the canonical inode actually claimed for publication', () => {
|
||||||
|
const manager = new SharedManager();
|
||||||
|
const instancePath = instanceDir('concurrent-mode');
|
||||||
|
const canonicalPath = path.join(claudeDir(), 'settings.json');
|
||||||
|
const divergedPath = path.join(instancePath, 'settings.json');
|
||||||
|
fs.mkdirSync(claudeDir(), { recursive: true });
|
||||||
|
fs.mkdirSync(instancePath, { recursive: true });
|
||||||
|
writeJson(canonicalPath, { generation: 0 });
|
||||||
|
fs.chmodSync(canonicalPath, 0o640);
|
||||||
|
writeJson(divergedPath, { generation: 1 });
|
||||||
|
setMtime(divergedPath, fs.statSync(canonicalPath).mtimeMs + 2_000);
|
||||||
|
|
||||||
|
const originalRenameSync = fs.renameSync;
|
||||||
|
const renameSpy = spyOn(fs, 'renameSync').mockImplementation(((
|
||||||
|
oldPath: fs.PathLike,
|
||||||
|
newPath: fs.PathLike
|
||||||
|
) => {
|
||||||
|
if (
|
||||||
|
String(oldPath) === canonicalPath &&
|
||||||
|
String(newPath).includes('.ccs-canonical-claim-')
|
||||||
|
) {
|
||||||
|
fs.chmodSync(canonicalPath, 0o664);
|
||||||
|
}
|
||||||
|
return originalRenameSync(oldPath, newPath);
|
||||||
|
}) as typeof fs.renameSync);
|
||||||
|
|
||||||
|
manager.linkSharedDirectories(instancePath);
|
||||||
|
renameSpy.mockRestore();
|
||||||
|
expect(fs.statSync(canonicalPath).mode & 0o777).toBe(0o664);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recovers an interrupted canonical claim before provisioning defaults', () => {
|
||||||
|
const manager = new SharedManager();
|
||||||
|
const canonicalPath = path.join(claudeDir(), 'settings.json');
|
||||||
|
const claimPath = `${canonicalPath}.ccs-canonical-claim-interrupted`;
|
||||||
|
fs.mkdirSync(claudeDir(), { recursive: true });
|
||||||
|
writeJson(canonicalPath, { preserved: true });
|
||||||
|
fs.renameSync(canonicalPath, claimPath);
|
||||||
|
|
||||||
|
manager.ensureSharedDirectories();
|
||||||
|
|
||||||
|
expect(readJson(canonicalPath)).toEqual({ preserved: true });
|
||||||
|
expect(fs.existsSync(claimPath)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('quarantines a leftover plugin canonical claim without replacing a live symlink target', () => {
|
||||||
|
const manager = new SharedManager();
|
||||||
|
const canonicalPath = path.join(claudeDir(), 'plugins', 'installed_plugins.json');
|
||||||
|
const intermediatePath = path.join(tempRoot, 'external', 'registry-link.json');
|
||||||
|
const targetPath = path.join(tempRoot, 'external', 'installed_plugins.json');
|
||||||
|
const claimPath = `${targetPath}.ccs-canonical-claim-interrupted`;
|
||||||
|
fs.mkdirSync(path.dirname(canonicalPath), { recursive: true });
|
||||||
|
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
||||||
|
writeJson(targetPath, { version: 2, plugins: { live: [] } });
|
||||||
|
writeJson(claimPath, { version: 2, plugins: { preserved: [] } });
|
||||||
|
fs.symlinkSync(targetPath, intermediatePath, 'file');
|
||||||
|
fs.symlinkSync(intermediatePath, canonicalPath, 'file');
|
||||||
|
|
||||||
|
manager.ensureSharedDirectories();
|
||||||
|
|
||||||
|
expect(fs.lstatSync(canonicalPath).isSymbolicLink()).toBe(true);
|
||||||
|
expect(fs.lstatSync(intermediatePath).isSymbolicLink()).toBe(true);
|
||||||
|
expect(readJson(targetPath)).toEqual({ version: 2, plugins: { live: [] } });
|
||||||
|
expect(readJson(`${targetPath}.ccs-canonical-recovery`)).toEqual({
|
||||||
|
version: 2,
|
||||||
|
plugins: { preserved: [] },
|
||||||
|
});
|
||||||
|
expect(fs.existsSync(claimPath)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recovers a claimed plugin registry through a multi-hop dangling symlink chain', () => {
|
||||||
|
const manager = new SharedManager();
|
||||||
|
const canonicalPath = path.join(claudeDir(), 'plugins', 'installed_plugins.json');
|
||||||
|
const intermediatePath = path.join(tempRoot, 'external', 'registry-link.json');
|
||||||
|
const targetPath = path.join(tempRoot, 'external', 'missing', 'installed_plugins.json');
|
||||||
|
const claimPath = `${targetPath}.ccs-canonical-claim-interrupted`;
|
||||||
|
fs.mkdirSync(path.dirname(canonicalPath), { recursive: true });
|
||||||
|
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
||||||
|
writeJson(claimPath, { version: 2, plugins: { recovered: [] } });
|
||||||
|
fs.symlinkSync(targetPath, intermediatePath, 'file');
|
||||||
|
fs.symlinkSync(intermediatePath, canonicalPath, 'file');
|
||||||
|
|
||||||
|
manager.ensureSharedDirectories();
|
||||||
|
|
||||||
|
expect(readJson(targetPath)).toEqual({ version: 2, plugins: { recovered: [] } });
|
||||||
|
expect(fs.lstatSync(canonicalPath).isSymbolicLink()).toBe(true);
|
||||||
|
expect(fs.lstatSync(intermediatePath).isSymbolicLink()).toBe(true);
|
||||||
|
expect(fs.existsSync(claimPath)).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('marketplace registry ownership', () => {
|
describe('marketplace registry ownership', () => {
|
||||||
|
|||||||
Reference in new issue
Block a user