-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathsymlink.ts
More file actions
129 lines (117 loc) · 4.81 KB
/
Copy pathsymlink.ts
File metadata and controls
129 lines (117 loc) · 4.81 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
import { symlink, lstat, readlink, rm, mkdir, unlink } from 'node:fs/promises';
import { dirname, relative, resolve } from 'node:path';
import { platform } from 'node:os';
import { realpath } from 'node:fs/promises';
/**
* Resolve a path's parent directory through symlinks, keeping the final component.
* This handles the case where a parent directory (e.g., ~/.claude/skills) is a symlink
* to another location (e.g., ~/.agents/skills). In that case, computing relative paths
* from the symlink path produces broken symlinks.
*
* Returns the real path of the parent + the original basename.
* If realpath fails (parent doesn't exist), returns the original resolved path.
*/
async function resolveParentSymlinks(path: string): Promise<string> {
const resolved = resolve(path);
const dir = dirname(resolved);
const base = resolved.substring(dir.length + 1); // preserve original basename
try {
const realDir = await realpath(dir);
return `${realDir}/${base}`;
} catch {
return resolved;
}
}
/**
* Resolve the target of a symlink to an absolute path
*/
function resolveSymlinkTarget(linkPath: string, linkTarget: string): string {
// If linkTarget is already absolute (e.g., Windows junction), resolve returns it as-is
// Otherwise, resolve it relative to the link's directory
return resolve(dirname(linkPath), linkTarget);
}
/**
* Creates a symlink, handling cross-platform differences.
* Returns true if symlink was created (or already exists pointing to correct target),
* false if symlink creation failed (caller should fall back to copy).
*
* @param target - The path the symlink should point to (canonical location)
* @param linkPath - The path where the symlink should be created
*/
export async function createSymlink(target: string, linkPath: string): Promise<boolean> {
try {
const resolvedTarget = resolve(target);
const resolvedLinkPath = resolve(linkPath);
// If target and link are the same path, nothing to do
if (resolvedTarget === resolvedLinkPath) {
return true;
}
// Also check with symlinks resolved in parent directories.
// This handles cases where e.g. ~/.claude/skills is a symlink to ~/.agents/skills,
// so ~/.claude/skills/<skill> and ~/.agents/skills/<skill> are physically the same.
const realTarget = await resolveParentSymlinks(target);
const realLinkPath = await resolveParentSymlinks(linkPath);
if (realTarget === realLinkPath) {
return true;
}
// Check if linkPath already exists
try {
const stats = await lstat(linkPath);
if (stats.isSymbolicLink()) {
// Check if it already points to the correct target
const existingTarget = await readlink(linkPath);
const resolvedExisting = resolveSymlinkTarget(linkPath, existingTarget);
if (resolvedExisting === resolvedTarget) {
return true; // Already correct, skip recreation
}
// Points to wrong target, remove it
// Use unlink for symlinks/junctions on Windows
await unlink(linkPath);
} else {
// Not a symlink (regular file/dir), remove it
await rm(linkPath, { recursive: true });
}
} catch (err: unknown) {
// ELOOP = circular symlink, ENOENT = doesn't exist
// For ELOOP, try to remove the broken symlink
if (err && typeof err === 'object' && 'code' in err && err.code === 'ELOOP') {
try {
await unlink(linkPath);
} catch {
// If we can't remove it, symlink creation will fail and trigger copy fallback
}
}
// For ENOENT or other errors, continue to symlink creation
}
// Ensure parent directory exists
const linkDir = dirname(linkPath);
await mkdir(linkDir, { recursive: true });
// Use the real (symlink-resolved) parent directory for computing the relative path.
// This ensures the symlink target is correct even when the link's parent dir is a symlink.
const realLinkDir = await resolveParentSymlinks(linkDir);
const relativePath = relative(realLinkDir, target);
// On Windows, use junction type for directory symlinks (no admin required)
const symlinkType = platform() === 'win32' ? 'junction' : undefined;
await symlink(relativePath, linkPath, symlinkType);
return true;
} catch {
return false;
}
}
/**
* Check if a path is a valid symlink pointing to the expected target
*/
export async function isValidSymlink(linkPath: string, expectedTarget: string): Promise<boolean> {
try {
const stats = await lstat(linkPath);
if (!stats.isSymbolicLink()) {
return false;
}
const existingTarget = await readlink(linkPath);
const resolvedExisting = resolveSymlinkTarget(linkPath, existingTarget);
const resolvedExpected = resolve(expectedTarget);
return resolvedExisting === resolvedExpected;
} catch {
return false;
}
}