@relayfile/local-mount 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/dotfiles.d.ts +18 -0
- package/dist/dotfiles.js +43 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/launch.d.ts +40 -0
- package/dist/launch.js +117 -0
- package/dist/symlink-mount.d.ts +16 -0
- package/dist/symlink-mount.js +360 -0
- package/package.json +46 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export interface ReadAgentDotfilesOptions {
|
|
2
|
+
/**
|
|
3
|
+
* If provided, also reads `.{agentName}.agentignore` and `.{agentName}.agentreadonly`
|
|
4
|
+
* from the project directory and merges the resulting patterns.
|
|
5
|
+
*/
|
|
6
|
+
agentName?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface AgentDotfilePatterns {
|
|
9
|
+
ignoredPatterns: string[];
|
|
10
|
+
readonlyPatterns: string[];
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Read `.agentignore` and `.agentreadonly` dotfiles from a project directory
|
|
14
|
+
* and return the compiled pattern lists. If `agentName` is supplied, also
|
|
15
|
+
* reads `.{agentName}.agentignore` and `.{agentName}.agentreadonly` and
|
|
16
|
+
* appends their patterns.
|
|
17
|
+
*/
|
|
18
|
+
export declare function readAgentDotfiles(projectDir: string, options?: ReadAgentDotfilesOptions): AgentDotfilePatterns;
|
package/dist/dotfiles.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
function cleanPatterns(content) {
|
|
4
|
+
return content
|
|
5
|
+
.split(/\r?\n/)
|
|
6
|
+
.map((line) => line.trim())
|
|
7
|
+
.filter((line) => line !== '' && !line.startsWith('#'));
|
|
8
|
+
}
|
|
9
|
+
function loadPatternsFromFile(filePath) {
|
|
10
|
+
if (!existsSync(filePath)) {
|
|
11
|
+
return [];
|
|
12
|
+
}
|
|
13
|
+
const content = readFileSync(filePath, 'utf8');
|
|
14
|
+
return cleanPatterns(content);
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Read `.agentignore` and `.agentreadonly` dotfiles from a project directory
|
|
18
|
+
* and return the compiled pattern lists. If `agentName` is supplied, also
|
|
19
|
+
* reads `.{agentName}.agentignore` and `.{agentName}.agentreadonly` and
|
|
20
|
+
* appends their patterns.
|
|
21
|
+
*/
|
|
22
|
+
export function readAgentDotfiles(projectDir, options = {}) {
|
|
23
|
+
const resolvedProjectDir = path.resolve(projectDir);
|
|
24
|
+
const ignoredPatterns = [
|
|
25
|
+
...loadPatternsFromFile(path.join(resolvedProjectDir, '.agentignore')),
|
|
26
|
+
];
|
|
27
|
+
const readonlyPatterns = [
|
|
28
|
+
...loadPatternsFromFile(path.join(resolvedProjectDir, '.agentreadonly')),
|
|
29
|
+
];
|
|
30
|
+
if (options.agentName) {
|
|
31
|
+
const safeAgentName = sanitizeAgentName(options.agentName);
|
|
32
|
+
ignoredPatterns.push(...loadPatternsFromFile(path.join(resolvedProjectDir, `.${safeAgentName}.agentignore`)));
|
|
33
|
+
readonlyPatterns.push(...loadPatternsFromFile(path.join(resolvedProjectDir, `.${safeAgentName}.agentreadonly`)));
|
|
34
|
+
}
|
|
35
|
+
return { ignoredPatterns, readonlyPatterns };
|
|
36
|
+
}
|
|
37
|
+
const AGENT_NAME_PATTERN = /^[A-Za-z0-9_-]+$/;
|
|
38
|
+
function sanitizeAgentName(agentName) {
|
|
39
|
+
if (!AGENT_NAME_PATTERN.test(agentName) || path.basename(agentName) !== agentName) {
|
|
40
|
+
throw new Error(`Invalid agentName ${JSON.stringify(agentName)}: only [A-Za-z0-9_-] are allowed`);
|
|
41
|
+
}
|
|
42
|
+
return agentName;
|
|
43
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { createSymlinkMount, type SymlinkMountOptions, type SymlinkMountHandle, } from './symlink-mount.js';
|
|
2
|
+
export { readAgentDotfiles, type ReadAgentDotfilesOptions, type AgentDotfilePatterns, } from './dotfiles.js';
|
|
3
|
+
export { launchOnMount, type LaunchOnMountOptions, type LaunchOnMountResult, } from './launch.js';
|
package/dist/index.js
ADDED
package/dist/launch.d.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export interface LaunchOnMountOptions {
|
|
2
|
+
/** Binary name or absolute path to the CLI to spawn, e.g. 'claude'. */
|
|
3
|
+
cli: string;
|
|
4
|
+
/** The real project directory to mirror. */
|
|
5
|
+
projectDir: string;
|
|
6
|
+
/** Where to create the mount. Must differ from projectDir. */
|
|
7
|
+
mountDir: string;
|
|
8
|
+
/** Argv to pass to the CLI after its binary name. */
|
|
9
|
+
args: string[];
|
|
10
|
+
/** Glob-style ignore patterns (files excluded entirely from the mount). */
|
|
11
|
+
ignoredPatterns?: string[];
|
|
12
|
+
/** Glob-style readonly patterns (files copied with mode 0o444). */
|
|
13
|
+
readonlyPatterns?: string[];
|
|
14
|
+
/** Extra directory names to exclude from the mount on top of defaults. */
|
|
15
|
+
excludeDirs?: string[];
|
|
16
|
+
/** Extra env vars merged on top of `process.env`. */
|
|
17
|
+
env?: NodeJS.ProcessEnv;
|
|
18
|
+
/** Optional agent name, used in the _MOUNT_README.md "Agent:" line. */
|
|
19
|
+
agentName?: string;
|
|
20
|
+
/**
|
|
21
|
+
* Invoked after the mount is created but before the CLI is spawned.
|
|
22
|
+
* Useful for writing additional files into the mount (overrides, extra docs).
|
|
23
|
+
*/
|
|
24
|
+
onBeforeLaunch?: (mountDir: string) => void | Promise<void>;
|
|
25
|
+
/**
|
|
26
|
+
* Invoked after sync-back completes, before cleanup. Receives the number of
|
|
27
|
+
* files that were written back to the project directory.
|
|
28
|
+
*/
|
|
29
|
+
onAfterSync?: (syncedFileCount: number) => void | Promise<void>;
|
|
30
|
+
}
|
|
31
|
+
export interface LaunchOnMountResult {
|
|
32
|
+
exitCode: number;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Create a mount of `projectDir` at `mountDir`, spawn `cli` with `args` using
|
|
36
|
+
* the mount as its cwd, forward SIGINT/SIGTERM to the child, sync writable
|
|
37
|
+
* changes back on exit, then clean up the mount. Resolves with the child's
|
|
38
|
+
* exit code.
|
|
39
|
+
*/
|
|
40
|
+
export declare function launchOnMount(opts: LaunchOnMountOptions): Promise<LaunchOnMountResult>;
|
package/dist/launch.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { createSymlinkMount } from './symlink-mount.js';
|
|
3
|
+
/**
|
|
4
|
+
* Create a mount of `projectDir` at `mountDir`, spawn `cli` with `args` using
|
|
5
|
+
* the mount as its cwd, forward SIGINT/SIGTERM to the child, sync writable
|
|
6
|
+
* changes back on exit, then clean up the mount. Resolves with the child's
|
|
7
|
+
* exit code.
|
|
8
|
+
*/
|
|
9
|
+
export async function launchOnMount(opts) {
|
|
10
|
+
const handle = createSymlinkMount(opts.projectDir, opts.mountDir, {
|
|
11
|
+
ignoredPatterns: opts.ignoredPatterns ?? [],
|
|
12
|
+
readonlyPatterns: opts.readonlyPatterns ?? [],
|
|
13
|
+
excludeDirs: opts.excludeDirs ?? [],
|
|
14
|
+
agentName: opts.agentName,
|
|
15
|
+
});
|
|
16
|
+
let syncedCount = 0;
|
|
17
|
+
let finalized = false;
|
|
18
|
+
const finalize = async () => {
|
|
19
|
+
if (finalized)
|
|
20
|
+
return;
|
|
21
|
+
finalized = true;
|
|
22
|
+
try {
|
|
23
|
+
syncedCount = await handle.syncBack();
|
|
24
|
+
if (opts.onAfterSync) {
|
|
25
|
+
await opts.onAfterSync(syncedCount);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
finally {
|
|
29
|
+
handle.cleanup();
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
try {
|
|
33
|
+
if (opts.onBeforeLaunch) {
|
|
34
|
+
await opts.onBeforeLaunch(handle.mountDir);
|
|
35
|
+
}
|
|
36
|
+
const envVars = {
|
|
37
|
+
...process.env,
|
|
38
|
+
...(opts.env ?? {}),
|
|
39
|
+
};
|
|
40
|
+
const exitCode = await new Promise((resolve, reject) => {
|
|
41
|
+
const child = spawn(opts.cli, opts.args, {
|
|
42
|
+
cwd: handle.mountDir,
|
|
43
|
+
stdio: 'inherit',
|
|
44
|
+
env: envVars,
|
|
45
|
+
});
|
|
46
|
+
let resolvedExit = 0;
|
|
47
|
+
let cleanupInProgress;
|
|
48
|
+
const forwardAndCleanup = (signal) => {
|
|
49
|
+
if (!child.killed && child.exitCode === null) {
|
|
50
|
+
try {
|
|
51
|
+
child.kill(signal);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
// ignore; we'll rely on 'close' handler
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (!cleanupInProgress) {
|
|
58
|
+
cleanupInProgress = new Promise((r) => {
|
|
59
|
+
if (child.exitCode !== null) {
|
|
60
|
+
r();
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const t = setTimeout(r, 2000);
|
|
64
|
+
child.once('close', () => {
|
|
65
|
+
clearTimeout(t);
|
|
66
|
+
r();
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
const onSigint = () => forwardAndCleanup('SIGINT');
|
|
72
|
+
const onSigterm = () => forwardAndCleanup('SIGTERM');
|
|
73
|
+
process.once('SIGINT', onSigint);
|
|
74
|
+
process.once('SIGTERM', onSigterm);
|
|
75
|
+
child.on('error', (err) => {
|
|
76
|
+
process.removeListener('SIGINT', onSigint);
|
|
77
|
+
process.removeListener('SIGTERM', onSigterm);
|
|
78
|
+
reject(err);
|
|
79
|
+
});
|
|
80
|
+
child.on('close', (code, signal) => {
|
|
81
|
+
process.removeListener('SIGINT', onSigint);
|
|
82
|
+
process.removeListener('SIGTERM', onSigterm);
|
|
83
|
+
if (typeof code === 'number') {
|
|
84
|
+
resolvedExit = code;
|
|
85
|
+
}
|
|
86
|
+
else if (signal === 'SIGINT') {
|
|
87
|
+
resolvedExit = 130;
|
|
88
|
+
}
|
|
89
|
+
else if (signal === 'SIGTERM') {
|
|
90
|
+
resolvedExit = 143;
|
|
91
|
+
}
|
|
92
|
+
else if (typeof signal === 'string') {
|
|
93
|
+
// Conventional mapping: 128 + signal number. We don't have the
|
|
94
|
+
// numeric signal handy, so fall back to 1 for unknown signals.
|
|
95
|
+
resolvedExit = 1;
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
resolvedExit = 1;
|
|
99
|
+
}
|
|
100
|
+
if (cleanupInProgress) {
|
|
101
|
+
cleanupInProgress.then(() => resolve(resolvedExit));
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
resolve(resolvedExit);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
await finalize();
|
|
109
|
+
return { exitCode };
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
await finalize().catch(() => {
|
|
113
|
+
// Already errored; swallow cleanup failure so the original error surfaces.
|
|
114
|
+
});
|
|
115
|
+
throw err;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export interface SymlinkMountOptions {
|
|
2
|
+
ignoredPatterns: string[];
|
|
3
|
+
readonlyPatterns: string[];
|
|
4
|
+
excludeDirs: string[];
|
|
5
|
+
/**
|
|
6
|
+
* Optional agent name used in the _MOUNT_README.md "Agent:" line.
|
|
7
|
+
* If omitted, the doc uses a generic "agent" value.
|
|
8
|
+
*/
|
|
9
|
+
agentName?: string;
|
|
10
|
+
}
|
|
11
|
+
export interface SymlinkMountHandle {
|
|
12
|
+
mountDir: string;
|
|
13
|
+
syncBack(): Promise<number>;
|
|
14
|
+
cleanup(): void;
|
|
15
|
+
}
|
|
16
|
+
export declare function createSymlinkMount(projectDir: string, mountDir: string, options: SymlinkMountOptions): SymlinkMountHandle;
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync, } from 'node:fs';
|
|
2
|
+
import ignore from 'ignore';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
const DEFAULT_EXCLUDED_DIRS = ['.git', 'node_modules'];
|
|
5
|
+
const MOUNT_README_FILENAME = '_MOUNT_README.md';
|
|
6
|
+
const MOUNT_MARKER_FILENAME = '.relayfile-local-mount';
|
|
7
|
+
const MOUNT_MARKER_CONTENT = 'This directory is managed by @relayfile/local-mount. Do not place unrelated files here; the directory will be deleted when the mount is torn down.\n';
|
|
8
|
+
export function createSymlinkMount(projectDir, mountDir, options) {
|
|
9
|
+
const resolvedProjectDir = realpathSync(projectDir);
|
|
10
|
+
const resolvedMountDir = path.resolve(mountDir);
|
|
11
|
+
const readonlyPatterns = [...options.readonlyPatterns];
|
|
12
|
+
const ignoredPatterns = [...options.ignoredPatterns];
|
|
13
|
+
const readonlyMatcher = createPathMatcher(readonlyPatterns);
|
|
14
|
+
const ignoredMatcher = createPathMatcher(ignoredPatterns);
|
|
15
|
+
const excludeSet = new Set([...DEFAULT_EXCLUDED_DIRS, ...options.excludeDirs]
|
|
16
|
+
.map((entry) => normalizeRelativePosix(entry).replace(/^\/+|\/+$/g, ''))
|
|
17
|
+
.filter(Boolean));
|
|
18
|
+
// Guard against mountDir === projectDir. We compare both the realpath'd
|
|
19
|
+
// project dir and the plain resolved project dir so callers that pass the
|
|
20
|
+
// same argument for both are caught even when the path is a symlink (e.g.
|
|
21
|
+
// /tmp → /private/tmp on macOS). The mountDir itself may not yet exist,
|
|
22
|
+
// so we cannot realpath it.
|
|
23
|
+
if (resolvedMountDir === resolvedProjectDir ||
|
|
24
|
+
resolvedMountDir === path.resolve(projectDir)) {
|
|
25
|
+
throw new Error('mountDir must be different from projectDir');
|
|
26
|
+
}
|
|
27
|
+
assertMountDirSafeToRemove(resolvedMountDir, resolvedProjectDir);
|
|
28
|
+
removeMountDir(resolvedMountDir);
|
|
29
|
+
mkdirSync(resolvedMountDir, { recursive: true });
|
|
30
|
+
const realMountDir = realpathSync(resolvedMountDir);
|
|
31
|
+
writeFileSync(path.join(realMountDir, MOUNT_MARKER_FILENAME), MOUNT_MARKER_CONTENT, 'utf8');
|
|
32
|
+
walkProjectTree(resolvedProjectDir, resolvedProjectDir, realMountDir, excludeSet, readonlyMatcher, ignoredMatcher);
|
|
33
|
+
const readmePath = resolveSafeCopyTarget(realMountDir, path.join(realMountDir, MOUNT_README_FILENAME));
|
|
34
|
+
if (!readmePath) {
|
|
35
|
+
throw new Error('Failed to create mount readme inside mountDir');
|
|
36
|
+
}
|
|
37
|
+
writeFileSync(readmePath, buildMountReadme(options.agentName, readonlyPatterns, ignoredPatterns), 'utf8');
|
|
38
|
+
return {
|
|
39
|
+
mountDir: resolvedMountDir,
|
|
40
|
+
async syncBack() {
|
|
41
|
+
let synced = 0;
|
|
42
|
+
const realProjectDir = realpathSync(resolvedProjectDir);
|
|
43
|
+
const realMountDir = realpathSync(resolvedMountDir);
|
|
44
|
+
const files = listFiles(realMountDir);
|
|
45
|
+
for (const sourceFile of files) {
|
|
46
|
+
synced += syncMountedFileBack(sourceFile, realMountDir, realProjectDir, readonlyMatcher, ignoredMatcher);
|
|
47
|
+
}
|
|
48
|
+
return synced;
|
|
49
|
+
},
|
|
50
|
+
cleanup() {
|
|
51
|
+
removeMountDir(resolvedMountDir);
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
function assertMountDirSafeToRemove(mountDir, projectDir) {
|
|
56
|
+
const resolved = path.resolve(mountDir);
|
|
57
|
+
const parsed = path.parse(resolved);
|
|
58
|
+
// Refuse filesystem roots (posix '/' or a Windows drive root like 'C:\').
|
|
59
|
+
if (resolved === parsed.root) {
|
|
60
|
+
throw new Error(`Refusing to use filesystem root as mountDir: ${resolved}`);
|
|
61
|
+
}
|
|
62
|
+
// Refuse anything that would overlap the project directory (destroying the
|
|
63
|
+
// project would be catastrophic). `path.resolve(projectDir)` avoids following
|
|
64
|
+
// symlinks so both the symlink-target and the literal argument are rejected.
|
|
65
|
+
const resolvedProject = path.resolve(projectDir);
|
|
66
|
+
if (resolved === resolvedProject ||
|
|
67
|
+
resolved.startsWith(`${resolvedProject}${path.sep}`) ||
|
|
68
|
+
resolvedProject.startsWith(`${resolved}${path.sep}`)) {
|
|
69
|
+
throw new Error(`mountDir ${resolved} overlaps projectDir ${resolvedProject}`);
|
|
70
|
+
}
|
|
71
|
+
// If the directory already exists, require it to be a directory we created
|
|
72
|
+
// previously (identified by the mount marker file). Otherwise the caller
|
|
73
|
+
// pointed us at an unrelated directory and removing it would destroy data.
|
|
74
|
+
if (!existsSync(resolved)) {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
let stat;
|
|
78
|
+
try {
|
|
79
|
+
stat = lstatSync(resolved);
|
|
80
|
+
}
|
|
81
|
+
catch (err) {
|
|
82
|
+
throw new Error(`Failed to stat mountDir ${resolved}: ${err.message}`);
|
|
83
|
+
}
|
|
84
|
+
if (!stat.isDirectory()) {
|
|
85
|
+
throw new Error(`mountDir ${resolved} exists and is not a directory`);
|
|
86
|
+
}
|
|
87
|
+
const markerPath = path.join(resolved, MOUNT_MARKER_FILENAME);
|
|
88
|
+
if (!existsSync(markerPath)) {
|
|
89
|
+
throw new Error(`Refusing to remove ${resolved}: missing ${MOUNT_MARKER_FILENAME} marker. ` +
|
|
90
|
+
`Only directories previously created by createSymlinkMount can be reused as mountDir.`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
function walkProjectTree(projectDir, currentDir, mountDir, excludeSet, readonlyMatcher, ignoredMatcher) {
|
|
94
|
+
const entries = readdirSync(currentDir, { withFileTypes: true });
|
|
95
|
+
for (const entry of entries) {
|
|
96
|
+
const absolutePath = path.join(currentDir, entry.name);
|
|
97
|
+
const relativePath = normalizeRelativePosix(path.relative(projectDir, absolutePath));
|
|
98
|
+
if (!relativePath || relativePath.startsWith('..')) {
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (isPathWithinRoot(absolutePath, mountDir)) {
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (isExcludedPath(relativePath, excludeSet)) {
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (isPathMatched(relativePath, ignoredMatcher, entry.isDirectory())) {
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
const mountPath = path.join(mountDir, relativePath);
|
|
111
|
+
if (entry.isDirectory()) {
|
|
112
|
+
if (!ensureDirectoryWithinRoot(mountDir, mountPath)) {
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
walkProjectTree(projectDir, absolutePath, mountDir, excludeSet, readonlyMatcher, ignoredMatcher);
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (entry.isSymbolicLink()) {
|
|
119
|
+
copySymlinkedFile(projectDir, mountDir, absolutePath, mountPath, relativePath, readonlyMatcher);
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (!entry.isFile()) {
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
copyMountedFile(projectDir, mountDir, absolutePath, mountPath, relativePath, readonlyMatcher);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
function copySymlinkedFile(projectDir, mountDir, sourcePath, mountPath, relativePath, readonlyMatcher) {
|
|
129
|
+
let realSource;
|
|
130
|
+
let resolvedStat;
|
|
131
|
+
try {
|
|
132
|
+
realSource = realpathSync(sourcePath);
|
|
133
|
+
resolvedStat = statSync(sourcePath);
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (!isPathWithinRoot(realSource, projectDir) || !resolvedStat.isFile()) {
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
copyMountedFile(projectDir, mountDir, realSource, mountPath, relativePath, readonlyMatcher, resolvedStat.mode);
|
|
142
|
+
}
|
|
143
|
+
function copyMountedFile(sourceRoot, mountDir, sourcePath, mountPath, relativePath, readonlyMatcher, sourceMode) {
|
|
144
|
+
const safeMountPath = resolveSafeCopyTarget(mountDir, mountPath);
|
|
145
|
+
if (!safeMountPath) {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
const safeSourcePath = resolveVerifiedFilePath(sourceRoot, sourcePath);
|
|
149
|
+
if (!safeSourcePath) {
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
copyFileSync(safeSourcePath, safeMountPath);
|
|
153
|
+
if (isPathMatched(relativePath, readonlyMatcher)) {
|
|
154
|
+
chmodSync(safeMountPath, 0o444);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
const mode = sourceMode ?? statSync(safeSourcePath).mode;
|
|
158
|
+
chmodSync(safeMountPath, mode & 0o777);
|
|
159
|
+
}
|
|
160
|
+
function ensureDirectory(pathValue) {
|
|
161
|
+
mkdirSync(pathValue, { recursive: true });
|
|
162
|
+
}
|
|
163
|
+
function ensureDirectoryWithinRoot(rootPath, dirPath) {
|
|
164
|
+
if (!isPathWithinRoot(dirPath, rootPath)) {
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
try {
|
|
168
|
+
ensureDirectory(dirPath);
|
|
169
|
+
const realDir = realpathSync(dirPath);
|
|
170
|
+
return isPathWithinRoot(realDir, rootPath);
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
function listFiles(baseDir) {
|
|
177
|
+
const files = [];
|
|
178
|
+
const stack = [baseDir];
|
|
179
|
+
while (stack.length > 0) {
|
|
180
|
+
const current = stack.pop();
|
|
181
|
+
if (!current)
|
|
182
|
+
continue;
|
|
183
|
+
const entries = readdirSync(current, { withFileTypes: true });
|
|
184
|
+
for (const entry of entries) {
|
|
185
|
+
const entryPath = path.join(current, entry.name);
|
|
186
|
+
if (entry.isDirectory()) {
|
|
187
|
+
stack.push(entryPath);
|
|
188
|
+
}
|
|
189
|
+
else if (entry.isFile() || entry.isSymbolicLink()) {
|
|
190
|
+
files.push(entryPath);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return files;
|
|
195
|
+
}
|
|
196
|
+
function normalizeRelativePosix(filePath) {
|
|
197
|
+
return filePath.split(path.sep).join('/');
|
|
198
|
+
}
|
|
199
|
+
function createPathMatcher(patterns) {
|
|
200
|
+
return ignore().add(patterns.map((pattern) => pattern.trim()).filter((pattern) => pattern !== '' && !pattern.startsWith('#')));
|
|
201
|
+
}
|
|
202
|
+
function isPathMatched(relPath, matcher, isDirectory = false) {
|
|
203
|
+
const normalized = normalizeRelativePosix(relPath);
|
|
204
|
+
return matcher.ignores(normalized) || (isDirectory && matcher.ignores(`${normalized}/`));
|
|
205
|
+
}
|
|
206
|
+
function hasSameContent(left, right) {
|
|
207
|
+
try {
|
|
208
|
+
const leftStat = statSync(left);
|
|
209
|
+
const rightStat = statSync(right);
|
|
210
|
+
if (leftStat.size !== rightStat.size) {
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
// Same size: fall back to a full byte comparison. Buffer.equals short-
|
|
214
|
+
// circuits internally but we still read both files; for very large files
|
|
215
|
+
// callers may want a streaming approach, though in practice mounts are
|
|
216
|
+
// dominated by source code where this is cheap.
|
|
217
|
+
const leftContent = readFileSync(left);
|
|
218
|
+
const rightContent = readFileSync(right);
|
|
219
|
+
return leftContent.equals(rightContent);
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
function syncMountedFileBack(sourceFile, mountDir, projectDir, readonlyMatcher, ignoredMatcher) {
|
|
226
|
+
const relative = resolveSyncRelativePath(sourceFile, mountDir, readonlyMatcher, ignoredMatcher);
|
|
227
|
+
if (!relative)
|
|
228
|
+
return 0;
|
|
229
|
+
const safeTargetPath = resolveVerifiedSyncTarget(projectDir, relative);
|
|
230
|
+
if (!safeTargetPath)
|
|
231
|
+
return 0;
|
|
232
|
+
if (existsSync(safeTargetPath) && hasSameContent(sourceFile, safeTargetPath)) {
|
|
233
|
+
return 0;
|
|
234
|
+
}
|
|
235
|
+
copyFileSync(sourceFile, safeTargetPath);
|
|
236
|
+
return 1;
|
|
237
|
+
}
|
|
238
|
+
function resolveSyncRelativePath(sourceFile, mountDir, readonlyMatcher, ignoredMatcher) {
|
|
239
|
+
const relative = path.relative(mountDir, sourceFile);
|
|
240
|
+
if (relative === '' || relative.startsWith('..'))
|
|
241
|
+
return null;
|
|
242
|
+
const relativePosix = normalizeRelativePosix(relative);
|
|
243
|
+
if (relativePosix === MOUNT_README_FILENAME)
|
|
244
|
+
return null;
|
|
245
|
+
if (relativePosix === MOUNT_MARKER_FILENAME)
|
|
246
|
+
return null;
|
|
247
|
+
if (isPathMatched(relative, readonlyMatcher) || isPathMatched(relative, ignoredMatcher))
|
|
248
|
+
return null;
|
|
249
|
+
try {
|
|
250
|
+
if (lstatSync(sourceFile).isSymbolicLink())
|
|
251
|
+
return null;
|
|
252
|
+
const realSource = realpathSync(sourceFile);
|
|
253
|
+
if (!isPathWithinRoot(realSource, mountDir))
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
catch {
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
return relative;
|
|
260
|
+
}
|
|
261
|
+
function resolveVerifiedSyncTarget(projectDir, relativePath) {
|
|
262
|
+
const targetPath = path.resolve(projectDir, relativePath);
|
|
263
|
+
if (!isPathWithinRoot(targetPath, projectDir))
|
|
264
|
+
return null;
|
|
265
|
+
const safeTargetPath = resolveSafeCopyTarget(projectDir, targetPath);
|
|
266
|
+
if (!safeTargetPath || !existsSync(safeTargetPath)) {
|
|
267
|
+
return safeTargetPath;
|
|
268
|
+
}
|
|
269
|
+
try {
|
|
270
|
+
const targetStat = lstatSync(safeTargetPath);
|
|
271
|
+
if (targetStat.isSymbolicLink())
|
|
272
|
+
return null;
|
|
273
|
+
const realTarget = realpathSync(safeTargetPath);
|
|
274
|
+
return isPathWithinRoot(realTarget, projectDir) ? safeTargetPath : null;
|
|
275
|
+
}
|
|
276
|
+
catch {
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
function isExcludedPath(relativePath, excludeSet) {
|
|
281
|
+
const normalized = normalizeRelativePosix(relativePath).replace(/^\/+|\/+$/g, '');
|
|
282
|
+
if (!normalized)
|
|
283
|
+
return false;
|
|
284
|
+
const segments = normalized.split('/');
|
|
285
|
+
return segments.some((segment, index) => {
|
|
286
|
+
const prefix = segments.slice(0, index + 1).join('/');
|
|
287
|
+
return excludeSet.has(segment) || excludeSet.has(prefix);
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
function isPathWithinRoot(candidatePath, rootPath) {
|
|
291
|
+
const resolvedCandidate = path.resolve(candidatePath);
|
|
292
|
+
const resolvedRoot = path.resolve(rootPath);
|
|
293
|
+
return resolvedCandidate === resolvedRoot || resolvedCandidate.startsWith(`${resolvedRoot}${path.sep}`);
|
|
294
|
+
}
|
|
295
|
+
function resolveSafeCopyTarget(rootPath, candidatePath) {
|
|
296
|
+
if (!isPathWithinRoot(candidatePath, rootPath)) {
|
|
297
|
+
return null;
|
|
298
|
+
}
|
|
299
|
+
const parentPath = path.dirname(candidatePath);
|
|
300
|
+
if (!ensureDirectoryWithinRoot(rootPath, parentPath)) {
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
303
|
+
try {
|
|
304
|
+
const realParent = realpathSync(parentPath);
|
|
305
|
+
if (!isPathWithinRoot(realParent, rootPath)) {
|
|
306
|
+
return null;
|
|
307
|
+
}
|
|
308
|
+
return path.join(realParent, path.basename(candidatePath));
|
|
309
|
+
}
|
|
310
|
+
catch {
|
|
311
|
+
return null;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
function resolveVerifiedFilePath(rootPath, candidatePath) {
|
|
315
|
+
try {
|
|
316
|
+
const realCandidate = realpathSync(candidatePath);
|
|
317
|
+
const candidateStat = statSync(candidatePath);
|
|
318
|
+
if (!candidateStat.isFile()) {
|
|
319
|
+
return null;
|
|
320
|
+
}
|
|
321
|
+
return isPathWithinRoot(realCandidate, rootPath) ? realCandidate : null;
|
|
322
|
+
}
|
|
323
|
+
catch {
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
function removeMountDir(mountDir) {
|
|
328
|
+
if (!existsSync(mountDir)) {
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
try {
|
|
332
|
+
rmSync(mountDir, { recursive: true, force: true });
|
|
333
|
+
}
|
|
334
|
+
catch {
|
|
335
|
+
// Best-effort cleanup.
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
function buildMountReadme(agentName, readonlyPatterns, ignoredPatterns) {
|
|
339
|
+
const readonlyList = readonlyPatterns.length > 0 ? readonlyPatterns.join('\n') : '(none)';
|
|
340
|
+
const ignoredList = ignoredPatterns.length > 0 ? ignoredPatterns.join('\n') : '(none)';
|
|
341
|
+
const agentLine = agentName ? `\nAgent: ${agentName}\n` : '';
|
|
342
|
+
return `# Workspace Permissions
|
|
343
|
+
|
|
344
|
+
This workspace is a mounted mirror of the project directory.
|
|
345
|
+
File access is controlled by project-local .agentignore and .agentreadonly.
|
|
346
|
+
|
|
347
|
+
## Read-only files (cannot be modified)
|
|
348
|
+
${readonlyList}
|
|
349
|
+
|
|
350
|
+
## Hidden files (not available in this workspace)
|
|
351
|
+
${ignoredList}
|
|
352
|
+
|
|
353
|
+
## Writable files
|
|
354
|
+
All other files can be read and modified freely.
|
|
355
|
+
|
|
356
|
+
If you get "permission denied", the file is read-only.
|
|
357
|
+
Changes to read-only files are not synced back to the source project.
|
|
358
|
+
Edits or permission changes to read-only files inside this mount may be discarded or overwritten when the mount is recreated.
|
|
359
|
+
${agentLine}`;
|
|
360
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@relayfile/local-mount",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Create a symlink/copy mount of a project directory with .agentignore/.agentreadonly semantics, then launch a CLI inside it and sync writable changes back on exit",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"files": [
|
|
9
|
+
"dist"
|
|
10
|
+
],
|
|
11
|
+
"scripts": {
|
|
12
|
+
"build": "tsc",
|
|
13
|
+
"typecheck": "tsc --noEmit",
|
|
14
|
+
"test": "vitest run",
|
|
15
|
+
"prepublishOnly": "npm run build"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"ignore": "^7.0.5"
|
|
19
|
+
},
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"@types/node": "^22.0.0",
|
|
22
|
+
"typescript": "^5.7.3",
|
|
23
|
+
"vitest": "^3.0.0"
|
|
24
|
+
},
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"access": "public"
|
|
27
|
+
},
|
|
28
|
+
"repository": {
|
|
29
|
+
"type": "git",
|
|
30
|
+
"url": "https://github.com/AgentWorkforce/relayfile",
|
|
31
|
+
"directory": "packages/local-mount"
|
|
32
|
+
},
|
|
33
|
+
"license": "MIT",
|
|
34
|
+
"keywords": [
|
|
35
|
+
"relayfile",
|
|
36
|
+
"mount",
|
|
37
|
+
"symlink",
|
|
38
|
+
"agent",
|
|
39
|
+
"sandbox",
|
|
40
|
+
"agentignore",
|
|
41
|
+
"agentreadonly"
|
|
42
|
+
],
|
|
43
|
+
"engines": {
|
|
44
|
+
"node": ">=18"
|
|
45
|
+
}
|
|
46
|
+
}
|