@relayfile/local-mount 0.6.14 → 0.6.15

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/README.md CHANGED
@@ -29,7 +29,7 @@ Behavior:
29
29
  - Copies regular files into the mount
30
30
  - Applies ignore rules from `ignoredPatterns`
31
31
  - Marks read-only matches as mode `0o444`
32
- - Excludes `.git`, `node_modules`, and `.npm-cache` by default. Pass `includeGit: true` to opt the project's `.git` directory back in (see [Including `.git`](#including-git))
32
+ - Excludes `.git`, `node_modules`, `.npm-cache`, and common build/cache output directories by default. Pass `includeGit: true` to opt the project's `.git` directory back in (see [Including `.git`](#including-git))
33
33
  - Writes `_MOUNT_README.md` and `.relayfile-local-mount` into the mount
34
34
  - Skips syncing `_MOUNT_README.md`, `.relayfile-local-mount`, ignored files, read-only files, and symlinks back to the source project
35
35
 
@@ -110,6 +110,47 @@ Conflict and delete rules:
110
110
  - readonly paths never flow mount→project; project-side edits still flow into the mount (the mount copy is re-chmodded `0o444`)
111
111
  - `_MOUNT_README.md`, `.relayfile-local-mount`, ignored paths, and excluded directories never cross
112
112
 
113
+ ## Default Excludes
114
+
115
+ By default, mounts skip directories and files that are usually large generated output
116
+ or local caches. These names match at any path depth:
117
+
118
+ ```txt
119
+ .git
120
+ node_modules
121
+ .npm-cache
122
+ __pycache__
123
+ .pytest_cache
124
+ .mypy_cache
125
+ .ruff_cache
126
+ .gradle
127
+ .nyc_output
128
+ .turbo
129
+ .cache
130
+ .DS_Store
131
+ ```
132
+
133
+ These more generic names match only at the project root so source paths such as
134
+ `src/build/` or `packages/env/` are still mounted:
135
+
136
+ ```txt
137
+ target
138
+ .next
139
+ dist
140
+ build
141
+ out
142
+ .venv
143
+ venv
144
+ env
145
+ coverage
146
+ ```
147
+
148
+ Pass `includeDefaultExcludeDirs: false` to opt out of the broad build/cache list.
149
+ For safety, `.git` stays excluded unless you also pass `includeGit: true`.
150
+ `excludeDirs` still appends to whichever default set is active; bare caller entries
151
+ retain the historical any-depth behavior, while path-style entries are root-relative
152
+ prefixes.
153
+
113
154
  ## Including `.git`
114
155
 
115
156
  By default, the project's `.git` directory is excluded from the mount, which means git commands inside the mount fail with `fatal: not a git repository`. Pass `includeGit: true` (on `createMount` or `launchOnMount`) to copy `.git` into the mount with **one-way project→mount sync**:
@@ -192,7 +233,7 @@ const result = await launchOnMount({
192
233
  mountDir,
193
234
  ignoredPatterns,
194
235
  readonlyPatterns,
195
- excludeDirs: ['dist'],
236
+ excludeDirs: ['vendor-cache'],
196
237
  agentName: 'reviewer',
197
238
  onBeforeLaunch: async (dir) => {
198
239
  // Add extra instructions or scratch files inside the mount if needed.
@@ -3,14 +3,17 @@ export interface AutoSyncContext {
3
3
  realProjectDir: string;
4
4
  isExcluded: (relPosix: string) => boolean;
5
5
  /**
6
- * The exact set of normalized directory names/prefixes that drive
7
- * `isExcluded` (i.e., {@link MountOptions.excludeDirs} merged with the
8
- * library defaults). Used purely to hint `@parcel/watcher` which subtrees
9
- * to skip subscribing to. The in-handler `isSyncCandidate` filter remains
10
- * authoritative; this is just a perf hint that keeps the watcher from
11
- * recursing into heavy trees like `node_modules` or `.npm-cache`.
6
+ * Normalized directory names that drive any-depth `isExcluded` matches.
7
+ * Used purely to hint `@parcel/watcher` which subtrees to skip subscribing
8
+ * to. The in-handler `isSyncCandidate` filter remains authoritative.
12
9
  */
13
- excludedNames: readonly string[];
10
+ excludedAnyDepthNames: readonly string[];
11
+ /**
12
+ * Root-anchored excluded names/prefixes such as `build` or `packages/cache`.
13
+ * These are matched only from the watch root to avoid hiding legitimate
14
+ * nested source directories like `src/build`.
15
+ */
16
+ excludedRootPrefixes: readonly string[];
14
17
  /**
15
18
  * Directory-only ignore patterns (ending in `/`) must only match when the
16
19
  * path is a directory. Callers that know the path's type pass `isDirectory`;
package/dist/auto-sync.js CHANGED
@@ -170,23 +170,21 @@ function buildIgnoreGlobs(ctx, watchRoot) {
170
170
  // `isExcludedPath`'s semantics, so a watcher-suppressed event never differs
171
171
  // from what the in-handler filter would have rejected.
172
172
  //
173
- // - Bare directory names (e.g. `node_modules`) match at any depth, so
174
- // emit `**/<name>` plus `**/<name>/**`. picomatch turns both into
175
- // depth-agnostic regexes that catch the dir and its descendants.
176
- // - Path-style entries (e.g. `build/cache`) are root-anchored prefixes
173
+ // - Any-depth names (e.g. `node_modules`) emit `**/<name>` plus
174
+ // `**/<name>/**`. picomatch turns both into depth-agnostic regexes
175
+ // that catch the dir and its descendants.
176
+ // - Root prefixes (e.g. `build` or `build/cache`) are root-anchored
177
177
  // in `isExcludedPath` — they only match `<root>/build/cache`, NOT
178
178
  // `<root>/src/build/cache`. Emit absolute patterns rooted at the
179
179
  // watch dir so the watcher hides the same set: a literal absolute
180
180
  // path (which the wrapper routes to ignorePaths) plus an anchored
181
181
  // descendant glob.
182
182
  const globs = [];
183
- for (const name of ctx.excludedNames) {
184
- if (name.includes('/')) {
185
- globs.push(`${watchRoot}/${name}`, `${watchRoot}/${name}/**`);
186
- }
187
- else {
188
- globs.push(`**/${name}`, `**/${name}/**`);
189
- }
183
+ for (const name of ctx.excludedAnyDepthNames) {
184
+ globs.push(`**/${name}`, `**/${name}/**`);
185
+ }
186
+ for (const prefix of ctx.excludedRootPrefixes) {
187
+ globs.push(`${watchRoot}/${prefix}`, `${watchRoot}/${prefix}/**`);
190
188
  }
191
189
  return globs;
192
190
  }
package/dist/launch.d.ts CHANGED
@@ -14,6 +14,11 @@ export interface LaunchOnMountOptions {
14
14
  readonlyPatterns?: string[];
15
15
  /** Extra directory names to exclude from the mount on top of defaults. */
16
16
  excludeDirs?: string[];
17
+ /**
18
+ * Include the built-in cache/build exclusion list. Defaults to true. `.git`
19
+ * remains excluded unless `includeGit` is true.
20
+ */
21
+ includeDefaultExcludeDirs?: boolean;
17
22
  /**
18
23
  * Include the project's `.git` directory inside the mount with one-way
19
24
  * project→mount sync. Defaults to false. See {@link MountOptions.includeGit}
package/dist/launch.js CHANGED
@@ -13,6 +13,7 @@ export async function launchOnMount(opts) {
13
13
  excludeDirs: opts.excludeDirs ?? [],
14
14
  agentName: opts.agentName,
15
15
  includeGit: opts.includeGit,
16
+ includeDefaultExcludeDirs: opts.includeDefaultExcludeDirs,
16
17
  });
17
18
  let syncedCount = 0;
18
19
  let finalized = false;
package/dist/mount.d.ts CHANGED
@@ -21,6 +21,12 @@ export interface MountOptions {
21
21
  * are discarded with the mount on cleanup. Push to a remote to keep them.
22
22
  */
23
23
  includeGit?: boolean;
24
+ /**
25
+ * Include the built-in list of large cache/build output directories in
26
+ * the mount exclusion set. Default: true. The `.git` directory remains
27
+ * excluded unless `includeGit` is true, even when this is false.
28
+ */
29
+ includeDefaultExcludeDirs?: boolean;
24
30
  }
25
31
  export interface MountHandle {
26
32
  mountDir: string;
package/dist/mount.js CHANGED
@@ -2,7 +2,31 @@ import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, readdirSync,
2
2
  import ignore from 'ignore';
3
3
  import path from 'node:path';
4
4
  import { startAutoSync, } from './auto-sync.js';
5
- const DEFAULT_EXCLUDED_DIRS = ['.git', 'node_modules', '.npm-cache'];
5
+ const DEFAULT_ANY_DEPTH_EXCLUDES = [
6
+ '.git',
7
+ 'node_modules',
8
+ '.npm-cache',
9
+ '__pycache__',
10
+ '.pytest_cache',
11
+ '.mypy_cache',
12
+ '.ruff_cache',
13
+ '.gradle',
14
+ '.nyc_output',
15
+ '.turbo',
16
+ '.cache',
17
+ '.DS_Store',
18
+ ];
19
+ const DEFAULT_ROOT_EXCLUDES = [
20
+ 'target',
21
+ '.next',
22
+ 'dist',
23
+ 'build',
24
+ 'out',
25
+ '.venv',
26
+ 'venv',
27
+ 'env',
28
+ 'coverage',
29
+ ];
6
30
  const MOUNT_README_FILENAME = '_MOUNT_README.md';
7
31
  const MOUNT_MARKER_FILENAME = '.relayfile-local-mount';
8
32
  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';
@@ -14,16 +38,12 @@ export function createMount(projectDir, mountDir, options) {
14
38
  const includeGit = options.includeGit === true;
15
39
  const readonlyMatcher = createPathMatcher(readonlyPatterns);
16
40
  const ignoredMatcher = createPathMatcher(ignoredPatterns);
17
- // `.git` is in DEFAULT_EXCLUDED_DIRS so the mount stays small and git
41
+ const includeDefaultExcludeDirs = options.includeDefaultExcludeDirs !== false;
42
+ // `.git` is in the default any-depth excludes so the mount stays small and git
18
43
  // operations don't accidentally cross-mutate the host repo. When the caller
19
44
  // opts in via `includeGit`, drop it from the defaults and instead route it
20
45
  // through the noSyncBack matcher below so it stays one-way.
21
- const defaultExcludes = includeGit
22
- ? DEFAULT_EXCLUDED_DIRS.filter((d) => d !== '.git')
23
- : DEFAULT_EXCLUDED_DIRS;
24
- const excludeSet = new Set([...defaultExcludes, ...options.excludeDirs]
25
- .map((entry) => normalizeRelativePosix(entry).replace(/^\/+|\/+$/g, ''))
26
- .filter(Boolean));
46
+ const excludeRules = createExcludeRules(options.excludeDirs, includeGit, includeDefaultExcludeDirs);
27
47
  const noSyncBackPatterns = includeGit ? ['.git', '.git/**'] : [];
28
48
  const noSyncBackMatcher = createPathMatcher(noSyncBackPatterns);
29
49
  // Guard against mountDir === projectDir. We compare both the realpath'd
@@ -40,7 +60,7 @@ export function createMount(projectDir, mountDir, options) {
40
60
  mkdirSync(resolvedMountDir, { recursive: true });
41
61
  const realMountDir = realpathSync(resolvedMountDir);
42
62
  writeFileSync(path.join(realMountDir, MOUNT_MARKER_FILENAME), MOUNT_MARKER_CONTENT, 'utf8');
43
- walkProjectTree(resolvedProjectDir, resolvedProjectDir, realMountDir, excludeSet, readonlyMatcher, ignoredMatcher);
63
+ walkProjectTree(resolvedProjectDir, resolvedProjectDir, realMountDir, realMountDir, excludeRules, readonlyMatcher, ignoredMatcher);
44
64
  const readmePath = resolveSafeCopyTarget(realMountDir, path.join(realMountDir, MOUNT_README_FILENAME));
45
65
  if (!readmePath) {
46
66
  throw new Error('Failed to create mount readme inside mountDir');
@@ -49,8 +69,9 @@ export function createMount(projectDir, mountDir, options) {
49
69
  const autoSyncContext = {
50
70
  realMountDir,
51
71
  realProjectDir: resolvedProjectDir,
52
- isExcluded: (relPosix) => isExcludedPath(relPosix, excludeSet),
53
- excludedNames: [...excludeSet],
72
+ isExcluded: (relPosix) => isExcludedPath(relPosix, excludeRules),
73
+ excludedAnyDepthNames: [...excludeRules.anyDepthNames],
74
+ excludedRootPrefixes: [...excludeRules.rootPrefixes],
54
75
  isIgnored: (relPosix, isDir) => isPathMatched(relPosix, ignoredMatcher, isDir),
55
76
  isReadonly: (relPosix) => isPathMatched(relPosix, readonlyMatcher),
56
77
  isNoSyncBack: (relPosix) => isPathMatched(relPosix, noSyncBackMatcher),
@@ -68,7 +89,7 @@ export function createMount(projectDir, mountDir, options) {
68
89
  if (signal?.aborted) {
69
90
  break;
70
91
  }
71
- const syncedForFile = syncMountedFileBack(sourceFile, realMountDir, realProjectDir, readonlyMatcher, ignoredMatcher, noSyncBackMatcher);
92
+ const syncedForFile = syncMountedFileBack(sourceFile, realMountDir, realProjectDir, readonlyMatcher, ignoredMatcher, noSyncBackMatcher, (relPosix) => isExcludedPath(relPosix, excludeRules));
72
93
  synced += syncedForFile;
73
94
  if (signal && syncedForFile > 0 && !signal.aborted) {
74
95
  await new Promise((resolve) => setImmediate(resolve));
@@ -122,7 +143,7 @@ function assertMountDirSafeToRemove(mountDir, projectDir) {
122
143
  `Only directories previously created by createMount can be reused as mountDir.`);
123
144
  }
124
145
  }
125
- function walkProjectTree(projectDir, currentDir, mountDir, excludeSet, readonlyMatcher, ignoredMatcher) {
146
+ function walkProjectTree(projectDir, currentDir, mountDir, currentMountDir, excludeRules, readonlyMatcher, ignoredMatcher) {
126
147
  const entries = readdirSync(currentDir, { withFileTypes: true });
127
148
  for (const entry of entries) {
128
149
  const absolutePath = path.join(currentDir, entry.name);
@@ -133,18 +154,19 @@ function walkProjectTree(projectDir, currentDir, mountDir, excludeSet, readonlyM
133
154
  if (isPathWithinRoot(absolutePath, mountDir)) {
134
155
  continue;
135
156
  }
136
- if (isExcludedPath(relativePath, excludeSet)) {
157
+ if (isExcludedPath(relativePath, excludeRules)) {
137
158
  continue;
138
159
  }
139
160
  if (isPathMatched(relativePath, ignoredMatcher, entry.isDirectory())) {
140
161
  continue;
141
162
  }
142
- const mountPath = path.join(mountDir, relativePath);
163
+ const mountPath = path.join(currentMountDir, entry.name);
143
164
  if (entry.isDirectory()) {
144
- if (!ensureDirectoryWithinRoot(mountDir, mountPath)) {
165
+ const safeMountDir = ensureDirectoryWithinRoot(mountDir, mountPath);
166
+ if (!safeMountDir) {
145
167
  continue;
146
168
  }
147
- walkProjectTree(projectDir, absolutePath, mountDir, excludeSet, readonlyMatcher, ignoredMatcher);
169
+ walkProjectTree(projectDir, absolutePath, mountDir, safeMountDir, excludeRules, readonlyMatcher, ignoredMatcher);
148
170
  continue;
149
171
  }
150
172
  if (entry.isSymbolicLink()) {
@@ -194,15 +216,15 @@ function ensureDirectory(pathValue) {
194
216
  }
195
217
  function ensureDirectoryWithinRoot(rootPath, dirPath) {
196
218
  if (!isPathWithinRoot(dirPath, rootPath)) {
197
- return false;
219
+ return null;
198
220
  }
199
221
  try {
200
222
  ensureDirectory(dirPath);
201
223
  const realDir = realpathSync(dirPath);
202
- return isPathWithinRoot(realDir, rootPath);
224
+ return isPathWithinRoot(realDir, rootPath) ? realDir : null;
203
225
  }
204
226
  catch {
205
- return false;
227
+ return null;
206
228
  }
207
229
  }
208
230
  function listFiles(baseDir) {
@@ -231,6 +253,38 @@ function normalizeRelativePosix(filePath) {
231
253
  function createPathMatcher(patterns) {
232
254
  return ignore().add(patterns.map((pattern) => pattern.trim()).filter((pattern) => pattern !== '' && !pattern.startsWith('#')));
233
255
  }
256
+ function createExcludeRules(excludeDirs, includeGit, includeDefaultExcludeDirs) {
257
+ const anyDepthNames = new Set();
258
+ const rootPrefixes = new Set();
259
+ if (includeDefaultExcludeDirs) {
260
+ addExcludeEntries(anyDepthNames, rootPrefixes, DEFAULT_ANY_DEPTH_EXCLUDES, 'any-depth');
261
+ addExcludeEntries(anyDepthNames, rootPrefixes, DEFAULT_ROOT_EXCLUDES, 'root-prefix');
262
+ }
263
+ else if (!includeGit) {
264
+ addExcludeEntries(anyDepthNames, rootPrefixes, ['.git'], 'any-depth');
265
+ }
266
+ if (includeGit) {
267
+ anyDepthNames.delete('.git');
268
+ }
269
+ // Preserve caller-supplied excludeDirs semantics: bare names match at any
270
+ // depth, while path-style entries are root-anchored prefixes.
271
+ addExcludeEntries(anyDepthNames, rootPrefixes, excludeDirs, 'legacy');
272
+ return { anyDepthNames, rootPrefixes };
273
+ }
274
+ function addExcludeEntries(anyDepthNames, rootPrefixes, entries, mode) {
275
+ for (const entry of entries) {
276
+ const normalized = normalizeRelativePosix(entry).replace(/^\/+|\/+$/g, '');
277
+ if (!normalized) {
278
+ continue;
279
+ }
280
+ if (mode === 'root-prefix' || (mode === 'legacy' && normalized.includes('/'))) {
281
+ rootPrefixes.add(normalized);
282
+ }
283
+ else {
284
+ anyDepthNames.add(normalized);
285
+ }
286
+ }
287
+ }
234
288
  function isPathMatched(relPath, matcher, isDirectory = false) {
235
289
  const normalized = normalizeRelativePosix(relPath);
236
290
  return matcher.ignores(normalized) || (isDirectory && matcher.ignores(`${normalized}/`));
@@ -254,8 +308,8 @@ function hasSameContent(left, right) {
254
308
  return false;
255
309
  }
256
310
  }
257
- function syncMountedFileBack(sourceFile, mountDir, projectDir, readonlyMatcher, ignoredMatcher, noSyncBackMatcher) {
258
- const relative = resolveSyncRelativePath(sourceFile, mountDir, readonlyMatcher, ignoredMatcher, noSyncBackMatcher);
311
+ function syncMountedFileBack(sourceFile, mountDir, projectDir, readonlyMatcher, ignoredMatcher, noSyncBackMatcher, isExcluded) {
312
+ const relative = resolveSyncRelativePath(sourceFile, mountDir, readonlyMatcher, ignoredMatcher, noSyncBackMatcher, isExcluded);
259
313
  if (!relative)
260
314
  return 0;
261
315
  const safeTargetPath = resolveVerifiedSyncTarget(projectDir, relative);
@@ -267,7 +321,7 @@ function syncMountedFileBack(sourceFile, mountDir, projectDir, readonlyMatcher,
267
321
  copyFileSync(sourceFile, safeTargetPath);
268
322
  return 1;
269
323
  }
270
- function resolveSyncRelativePath(sourceFile, mountDir, readonlyMatcher, ignoredMatcher, noSyncBackMatcher) {
324
+ function resolveSyncRelativePath(sourceFile, mountDir, readonlyMatcher, ignoredMatcher, noSyncBackMatcher, isExcluded) {
271
325
  const relative = path.relative(mountDir, sourceFile);
272
326
  if (relative === '' || relative.startsWith('..'))
273
327
  return null;
@@ -276,7 +330,8 @@ function resolveSyncRelativePath(sourceFile, mountDir, readonlyMatcher, ignoredM
276
330
  return null;
277
331
  if (relativePosix === MOUNT_MARKER_FILENAME)
278
332
  return null;
279
- if (isPathMatched(relative, readonlyMatcher) ||
333
+ if (isExcluded(relativePosix) ||
334
+ isPathMatched(relative, readonlyMatcher) ||
280
335
  isPathMatched(relative, ignoredMatcher) ||
281
336
  isPathMatched(relative, noSyncBackMatcher))
282
337
  return null;
@@ -311,14 +366,14 @@ function resolveVerifiedSyncTarget(projectDir, relativePath) {
311
366
  return null;
312
367
  }
313
368
  }
314
- function isExcludedPath(relativePath, excludeSet) {
369
+ function isExcludedPath(relativePath, excludeRules) {
315
370
  const normalized = normalizeRelativePosix(relativePath).replace(/^\/+|\/+$/g, '');
316
371
  if (!normalized)
317
372
  return false;
318
373
  const segments = normalized.split('/');
319
374
  return segments.some((segment, index) => {
320
375
  const prefix = segments.slice(0, index + 1).join('/');
321
- return excludeSet.has(segment) || excludeSet.has(prefix);
376
+ return excludeRules.anyDepthNames.has(segment) || excludeRules.rootPrefixes.has(prefix);
322
377
  });
323
378
  }
324
379
  function isPathWithinRoot(candidatePath, rootPath) {
@@ -331,19 +386,11 @@ function resolveSafeCopyTarget(rootPath, candidatePath) {
331
386
  return null;
332
387
  }
333
388
  const parentPath = path.dirname(candidatePath);
334
- if (!ensureDirectoryWithinRoot(rootPath, parentPath)) {
335
- return null;
336
- }
337
- try {
338
- const realParent = realpathSync(parentPath);
339
- if (!isPathWithinRoot(realParent, rootPath)) {
340
- return null;
341
- }
342
- return path.join(realParent, path.basename(candidatePath));
343
- }
344
- catch {
389
+ const realParent = ensureDirectoryWithinRoot(rootPath, parentPath);
390
+ if (!realParent) {
345
391
  return null;
346
392
  }
393
+ return path.join(realParent, path.basename(candidatePath));
347
394
  }
348
395
  function resolveVerifiedFilePath(rootPath, candidatePath) {
349
396
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@relayfile/local-mount",
3
- "version": "0.6.14",
3
+ "version": "0.6.15",
4
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
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",