From 469aa310261095f0978cd6f1ead1a10f0fb823d1 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Sat, 18 Apr 2026 20:11:09 -0400 Subject: [PATCH 1/4] Add bidirectional autosync for symlink mounts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a new autosync subsystem to keep mount↔project files in sync. Adds packages/local-mount/src/auto-sync.ts implementing a chokidar-based watcher + periodic reconcile with mount-wins resolution, readonly/ignored/excluded handling, safe write targets, and lifecycle APIs (start/stop/reconcile/ready/totalChanges). Exposes AutoSync types from the package index and wires a startAutoSync() method into the symlink mount handle. Integrates auto-sync into the launch flow via an autoSync option that runs during the spawned CLI and aggregates auto-sync changes into final sync counts. Adds comprehensive tests (auto-sync.test.ts) and records the chokidar dependency in package.json. --- package-lock.json | 29 ++ packages/local-mount/package.json | 1 + packages/local-mount/src/auto-sync.test.ts | 289 ++++++++++++ packages/local-mount/src/auto-sync.ts | 515 +++++++++++++++++++++ packages/local-mount/src/index.ts | 5 + packages/local-mount/src/launch.ts | 22 +- packages/local-mount/src/symlink-mount.ts | 27 ++ 7 files changed, 887 insertions(+), 1 deletion(-) create mode 100644 packages/local-mount/src/auto-sync.test.ts create mode 100644 packages/local-mount/src/auto-sync.ts diff --git a/package-lock.json b/package-lock.json index 6ea30c7..3e7f7b6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1265,6 +1265,21 @@ "node": ">= 16" } }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/chownr": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", @@ -1791,6 +1806,19 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/relayfile": { "resolved": "packages/cli", "link": true @@ -2423,6 +2451,7 @@ "version": "0.1.0", "license": "MIT", "dependencies": { + "chokidar": "^4.0.3", "ignore": "^7.0.5" }, "devDependencies": { diff --git a/packages/local-mount/package.json b/packages/local-mount/package.json index eee81d3..bd40c74 100644 --- a/packages/local-mount/package.json +++ b/packages/local-mount/package.json @@ -15,6 +15,7 @@ "prepublishOnly": "npm run build" }, "dependencies": { + "chokidar": "^4.0.3", "ignore": "^7.0.5" }, "devDependencies": { diff --git a/packages/local-mount/src/auto-sync.test.ts b/packages/local-mount/src/auto-sync.test.ts new file mode 100644 index 0000000..b39b2b7 --- /dev/null +++ b/packages/local-mount/src/auto-sync.test.ts @@ -0,0 +1,289 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { createSymlinkMount } from './symlink-mount.js'; + +function tmpDir(): string { + return mkdtempSync(path.join(os.tmpdir(), 'local-mount-autosync-')); +} + +function write(file: string, body: string): void { + mkdirSync(path.dirname(file), { recursive: true }); + writeFileSync(file, body, 'utf8'); +} + +/** + * Wait up to `timeoutMs` for `check` to return true. Useful for letting + * chokidar + awaitWriteFinish observe a write and propagate it. + */ +async function waitFor(check: () => boolean, timeoutMs = 3000): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (check()) return; + await new Promise((r) => setTimeout(r, 25)); + } + throw new Error(`waitFor timed out after ${timeoutMs}ms`); +} + +describe('startAutoSync', () => { + let projectDir: string; + let mountParent: string; + let mountDir: string; + + beforeEach(() => { + projectDir = tmpDir(); + mountParent = tmpDir(); + mountDir = path.join(mountParent, 'mount'); + }); + + afterEach(() => { + try { rmSync(projectDir, { recursive: true, force: true }); } catch { /* best effort */ } + try { rmSync(mountParent, { recursive: true, force: true }); } catch { /* best effort */ } + }); + + it('propagates mount→project edits without waiting for final syncBack', async () => { + write(path.join(projectDir, 'file.txt'), 'original'); + + const handle = createSymlinkMount(projectDir, mountDir, { + ignoredPatterns: [], + readonlyPatterns: [], + excludeDirs: [], + }); + + // Use a short writeFinish so the test runs quickly; still long enough + // to coalesce a single write. + const auto = handle.startAutoSync({ writeFinishMs: 50, scanIntervalMs: 10_000 }); + await auto.ready(); + try { + writeFileSync(path.join(handle.mountDir, 'file.txt'), 'edited-in-mount', 'utf8'); + await waitFor(() => readFileSync(path.join(projectDir, 'file.txt'), 'utf8') === 'edited-in-mount'); + } finally { + await auto.stop(); + handle.cleanup(); + } + }); + + it('propagates project→mount external edits', async () => { + write(path.join(projectDir, 'file.txt'), 'original'); + + const handle = createSymlinkMount(projectDir, mountDir, { + ignoredPatterns: [], + readonlyPatterns: [], + excludeDirs: [], + }); + + const auto = handle.startAutoSync({ writeFinishMs: 50, scanIntervalMs: 10_000 }); + await auto.ready(); + try { + writeFileSync(path.join(projectDir, 'file.txt'), 'edited-externally', 'utf8'); + await waitFor(() => + readFileSync(path.join(handle.mountDir, 'file.txt'), 'utf8') === 'edited-externally' + ); + } finally { + await auto.stop(); + handle.cleanup(); + } + }); + + it('propagates mount→project deletes', async () => { + write(path.join(projectDir, 'file.txt'), 'original'); + + const handle = createSymlinkMount(projectDir, mountDir, { + ignoredPatterns: [], + readonlyPatterns: [], + excludeDirs: [], + }); + + const auto = handle.startAutoSync({ writeFinishMs: 50, scanIntervalMs: 10_000 }); + await auto.ready(); + try { + rmSync(path.join(handle.mountDir, 'file.txt')); + await waitFor(() => !existsSync(path.join(projectDir, 'file.txt'))); + } finally { + await auto.stop(); + handle.cleanup(); + } + }); + + it('propagates project→mount deletes', async () => { + write(path.join(projectDir, 'file.txt'), 'original'); + + const handle = createSymlinkMount(projectDir, mountDir, { + ignoredPatterns: [], + readonlyPatterns: [], + excludeDirs: [], + }); + + const auto = handle.startAutoSync({ writeFinishMs: 50, scanIntervalMs: 10_000 }); + await auto.ready(); + try { + rmSync(path.join(projectDir, 'file.txt')); + await waitFor(() => !existsSync(path.join(handle.mountDir, 'file.txt'))); + } finally { + await auto.stop(); + handle.cleanup(); + } + }); + + it('respects readonly patterns: mount-side edits do not sync back', async () => { + write(path.join(projectDir, 'locked.txt'), 'original'); + + const handle = createSymlinkMount(projectDir, mountDir, { + ignoredPatterns: [], + readonlyPatterns: ['locked.txt'], + excludeDirs: [], + }); + + const auto = handle.startAutoSync({ writeFinishMs: 50, scanIntervalMs: 10_000 }); + await auto.ready(); + try { + // Bypass the 0o444 permission for the test. + const mountFile = path.join(handle.mountDir, 'locked.txt'); + chmodSync(mountFile, 0o644); + writeFileSync(mountFile, 'tampered', 'utf8'); + + // Give autosync time to notice and choose not to propagate. + await new Promise((r) => setTimeout(r, 300)); + await auto.reconcile(); + + expect(readFileSync(path.join(projectDir, 'locked.txt'), 'utf8')).toBe('original'); + } finally { + await auto.stop(); + handle.cleanup(); + } + }); + + it('readonly: project-side edits flow into the mount', async () => { + write(path.join(projectDir, 'locked.txt'), 'original'); + + const handle = createSymlinkMount(projectDir, mountDir, { + ignoredPatterns: [], + readonlyPatterns: ['locked.txt'], + excludeDirs: [], + }); + + const auto = handle.startAutoSync({ writeFinishMs: 50, scanIntervalMs: 10_000 }); + await auto.ready(); + try { + writeFileSync(path.join(projectDir, 'locked.txt'), 'updated-externally', 'utf8'); + await waitFor(() => + readFileSync(path.join(handle.mountDir, 'locked.txt'), 'utf8') === 'updated-externally' + ); + } finally { + await auto.stop(); + handle.cleanup(); + } + }); + + it('mount-wins: concurrent edits on both sides resolve to mount content', async () => { + write(path.join(projectDir, 'file.txt'), 'original'); + + const handle = createSymlinkMount(projectDir, mountDir, { + ignoredPatterns: [], + readonlyPatterns: [], + excludeDirs: [], + }); + + // Don't start autosync yet — set up the conflict state first, then + // trigger a single reconcile so we exercise the resolution rule. + const auto = handle.startAutoSync({ writeFinishMs: 10, scanIntervalMs: 10_000 }); + // Stop immediately to drain priming; then mutate and reconcile manually. + await auto.stop(); + + const auto2 = handle.startAutoSync({ writeFinishMs: 10, scanIntervalMs: 10_000 }); + await auto2.ready(); + try { + writeFileSync(path.join(projectDir, 'file.txt'), 'project-side', 'utf8'); + writeFileSync(path.join(handle.mountDir, 'file.txt'), 'mount-side', 'utf8'); + + await waitFor(() => + readFileSync(path.join(projectDir, 'file.txt'), 'utf8') === 'mount-side' + ); + expect(readFileSync(path.join(handle.mountDir, 'file.txt'), 'utf8')).toBe('mount-side'); + } finally { + await auto2.stop(); + handle.cleanup(); + } + }); + + it('ignored paths are never synced in either direction', async () => { + write(path.join(projectDir, 'keep.txt'), 'keep'); + + const handle = createSymlinkMount(projectDir, mountDir, { + ignoredPatterns: ['secrets/'], + readonlyPatterns: [], + excludeDirs: [], + }); + + const auto = handle.startAutoSync({ writeFinishMs: 50, scanIntervalMs: 10_000 }); + await auto.ready(); + try { + // File appearing in project under an ignored path — must NOT appear in mount. + write(path.join(projectDir, 'secrets/api-key.txt'), 'shhh'); + // File appearing in mount under an ignored path — must NOT leak back. + write(path.join(handle.mountDir, 'secrets/planted.txt'), 'evil'); + + await new Promise((r) => setTimeout(r, 300)); + await auto.reconcile(); + + expect(existsSync(path.join(handle.mountDir, 'secrets/api-key.txt'))).toBe(false); + expect(existsSync(path.join(projectDir, 'secrets/planted.txt'))).toBe(false); + } finally { + await auto.stop(); + handle.cleanup(); + } + }); + + it('periodic full scan catches changes even if watcher events are missed', async () => { + write(path.join(projectDir, 'file.txt'), 'original'); + + const handle = createSymlinkMount(projectDir, mountDir, { + ignoredPatterns: [], + readonlyPatterns: [], + excludeDirs: [], + }); + + // Start autosync but we'll rely on the explicit reconcile() call rather + // than waiting for the watcher, to simulate a missed event. + const auto = handle.startAutoSync({ writeFinishMs: 50, scanIntervalMs: 100 }); + try { + writeFileSync(path.join(handle.mountDir, 'file.txt'), 'edited', 'utf8'); + // Forcing a reconcile should find the change regardless of whether + // the watcher already fired. + await auto.reconcile(); + expect(readFileSync(path.join(projectDir, 'file.txt'), 'utf8')).toBe('edited'); + } finally { + await auto.stop(); + handle.cleanup(); + } + }); + + it('does not sync the _MOUNT_README.md or marker files', async () => { + const handle = createSymlinkMount(projectDir, mountDir, { + ignoredPatterns: [], + readonlyPatterns: [], + excludeDirs: [], + }); + + const auto = handle.startAutoSync({ writeFinishMs: 50, scanIntervalMs: 10_000 }); + await auto.ready(); + try { + writeFileSync(path.join(handle.mountDir, '_MOUNT_README.md'), 'mutated', 'utf8'); + await new Promise((r) => setTimeout(r, 300)); + await auto.reconcile(); + expect(existsSync(path.join(projectDir, '_MOUNT_README.md'))).toBe(false); + } finally { + await auto.stop(); + handle.cleanup(); + } + }); +}); diff --git a/packages/local-mount/src/auto-sync.ts b/packages/local-mount/src/auto-sync.ts new file mode 100644 index 0000000..dbee6f4 --- /dev/null +++ b/packages/local-mount/src/auto-sync.ts @@ -0,0 +1,515 @@ +import { + chmodSync, + copyFileSync, + existsSync, + lstatSync, + mkdirSync, + readdirSync, + readFileSync, + realpathSync, + rmSync, + statSync, +} from 'node:fs'; +import type { Stats } from 'node:fs'; +import path from 'node:path'; +import chokidar, { type FSWatcher } from 'chokidar'; + +export interface AutoSyncContext { + realMountDir: string; + realProjectDir: string; + isExcluded: (relPosix: string) => boolean; + isIgnored: (relPosix: string) => boolean; + isReadonly: (relPosix: string) => boolean; + isReservedFile: (relPosix: string) => boolean; +} + +export interface AutoSyncOptions { + /** Full-reconcile interval as a safety net. Default: 10_000ms. */ + scanIntervalMs?: number; + /** chokidar awaitWriteFinish stabilityThreshold in ms. Default: 200. */ + writeFinishMs?: number; + /** Invoked on errors during sync — logged by default consumer. */ + onError?: (err: Error) => void; +} + +export interface AutoSyncHandle { + stop(): Promise; + /** Force a reconcile now; returns number of files copied/deleted. */ + reconcile(): Promise; + /** Cumulative files changed (copied or deleted) since autosync started. */ + totalChanges(): number; + /** Resolves once both watchers have completed their initial scan. */ + ready(): Promise; +} + +interface FileState { + mountMtimeMs?: number; + projectMtimeMs?: number; +} + +export function startAutoSync( + ctx: AutoSyncContext, + opts: AutoSyncOptions = {} +): AutoSyncHandle { + const scanIntervalMs = opts.scanIntervalMs ?? 10_000; + const writeFinishMs = opts.writeFinishMs ?? 200; + const onError = opts.onError ?? (() => { /* ignore by default */ }); + + const state = new Map(); + + primeState(state, ctx); + + let syncing = false; + let pending = false; + let totalChanges = 0; + + const runReconcile = async (): Promise => { + if (syncing) { + pending = true; + return 0; + } + syncing = true; + let count = 0; + try { + count = reconcile(state, ctx); + } catch (err) { + onError(err as Error); + } finally { + syncing = false; + } + if (pending) { + pending = false; + try { + count += reconcile(state, ctx); + } catch (err) { + onError(err as Error); + } + } + totalChanges += count; + return count; + }; + + const syncPathFromRoot = (root: string, absPath: string): void => { + const rel = path.relative(root, absPath); + if (rel === '' || rel.startsWith('..')) return; + const relPosix = rel.split(path.sep).join('/'); + if (!isSyncCandidate(relPosix, ctx)) return; + try { + const changed = syncOneFile(relPosix, state, ctx); + if (changed) totalChanges += 1; + } catch (err) { + onError(err as Error); + } + }; + + const makeWatcher = (root: string): { watcher: FSWatcher; ready: Promise } => { + const watcher = chokidar.watch(root, { + ignoreInitial: true, + persistent: true, + followSymlinks: false, + awaitWriteFinish: { + stabilityThreshold: writeFinishMs, + pollInterval: 50, + }, + ignored: (candidate: string) => shouldChokidarIgnore(candidate, root, ctx), + }); + const onEvent = (p: string) => syncPathFromRoot(root, p); + watcher.on('add', onEvent); + watcher.on('change', onEvent); + watcher.on('unlink', onEvent); + watcher.on('error', (err) => onError(err as Error)); + const ready = new Promise((resolve) => { + watcher.once('ready', () => resolve()); + }); + return { watcher, ready }; + }; + + const mount = makeWatcher(ctx.realMountDir); + const project = makeWatcher(ctx.realProjectDir); + const mountWatcher = mount.watcher; + const projectWatcher = project.watcher; + const watchersReady = Promise.all([mount.ready, project.ready]); + + const interval = setInterval(() => { + void runReconcile(); + }, scanIntervalMs); + // Do not keep the event loop alive just because of our scan timer. + interval.unref?.(); + + return { + async stop() { + clearInterval(interval); + await Promise.all([mountWatcher.close(), projectWatcher.close()]); + // Drain any pending work so callers can rely on "stopped means quiesced". + await runReconcile(); + }, + reconcile: runReconcile, + totalChanges: () => totalChanges, + ready: async () => { + await watchersReady; + }, + }; +} + +function primeState(state: Map, ctx: AutoSyncContext): void { + // Record current mtimes for every file that exists in both trees. Files that + // differ, or exist on only one side, are left out so the first reconcile + // treats them as changed and syncs them. + walk(ctx.realMountDir, ctx, (abs) => { + const rel = toRelPosix(abs, ctx); + if (rel === null) return; + if (!isSyncCandidate(rel, ctx)) return; + const mountStat = safeFileStat(abs); + if (!mountStat) return; + const projectAbs = path.join(ctx.realProjectDir, rel); + const projectStat = safeFileStat(projectAbs); + if (!projectStat) return; + state.set(rel, { + mountMtimeMs: mountStat.mtimeMs, + projectMtimeMs: projectStat.mtimeMs, + }); + }); +} + +function reconcile(state: Map, ctx: AutoSyncContext): number { + const seen = new Set(); + let count = 0; + + const visit = (relPosix: string): void => { + if (seen.has(relPosix)) return; + seen.add(relPosix); + if (!isSyncCandidate(relPosix, ctx)) return; + const changed = syncOneFile(relPosix, state, ctx); + if (changed) count += 1; + }; + + walk(ctx.realMountDir, ctx, (abs) => { + const rel = toRelPosix(abs, ctx); + if (rel !== null) visit(rel); + }); + + walk(ctx.realProjectDir, ctx, (abs) => { + const rel = toRelPosixFromProject(abs, ctx); + if (rel !== null) visit(rel); + }); + + // Tombstone sweep: any path in state we didn't visit had both sides absent, + // so it's fully gone. + for (const rel of Array.from(state.keys())) { + if (!seen.has(rel)) { + const mountAbs = path.join(ctx.realMountDir, rel); + const projectAbs = path.join(ctx.realProjectDir, rel); + if (!existsSync(mountAbs) && !existsSync(projectAbs)) { + state.delete(rel); + } + } + } + + return count; +} + +/** + * Sync a single relPath and return true if a copy or delete actually happened. + * + * Resolution rules ("mount wins"): + * - If both sides changed since last sync → mount→project. + * - Only mount changed → mount→project (unless mount-side change is disallowed + * for readonly files; then drop the mount change). + * - Only project changed → project→mount. + * - One side missing: + * • Other side changed since last sync → recreate the missing side. + * • Otherwise → propagate the delete. + */ +function syncOneFile( + relPosix: string, + state: Map, + ctx: AutoSyncContext +): boolean { + const mountAbs = path.join(ctx.realMountDir, relPosix); + const projectAbs = path.join(ctx.realProjectDir, relPosix); + + const mountStat = safeFileStat(mountAbs); + const projectStat = safeFileStat(projectAbs); + + const prev = state.get(relPosix); + const readonly = ctx.isReadonly(relPosix); + + if (!mountStat && !projectStat) { + state.delete(relPosix); + return false; + } + + if (!prev) { + // First time we've seen this path. + if (mountStat && projectStat) { + if (sameContent(mountAbs, projectAbs)) { + state.set(relPosix, { + mountMtimeMs: mountStat.mtimeMs, + projectMtimeMs: projectStat.mtimeMs, + }); + return false; + } + // Differ with no history: arbitrary tiebreak → mount wins. + if (readonly) { + // Readonly can't accept mount-side writes; fall back to project→mount. + return doProjectToMount(relPosix, state, ctx, projectAbs, mountAbs, readonly); + } + return doMountToProject(relPosix, state, ctx, mountAbs, projectAbs); + } + if (mountStat && !projectStat) { + if (readonly) { + // New file in mount with a readonly pattern → cannot sync back. + return false; + } + return doMountToProject(relPosix, state, ctx, mountAbs, projectAbs); + } + if (!mountStat && projectStat) { + return doProjectToMount(relPosix, state, ctx, projectAbs, mountAbs, readonly); + } + } + + const mountChanged = mountStat + ? prev?.mountMtimeMs === undefined || mountStat.mtimeMs > prev.mountMtimeMs + : false; + const projectChanged = projectStat + ? prev?.projectMtimeMs === undefined || projectStat.mtimeMs > prev.projectMtimeMs + : false; + + if (mountStat && projectStat) { + if (!mountChanged && !projectChanged) return false; + if (mountChanged && !readonly) { + return doMountToProject(relPosix, state, ctx, mountAbs, projectAbs); + } + if (projectChanged) { + return doProjectToMount(relPosix, state, ctx, projectAbs, mountAbs, readonly); + } + return false; + } + + if (mountStat && !projectStat) { + if (mountChanged && !readonly) { + return doMountToProject(relPosix, state, ctx, mountAbs, projectAbs); + } + // Project deleted externally and mount hasn't been touched since → mirror. + return doDeleteMount(relPosix, state, mountAbs); + } + + if (!mountStat && projectStat) { + if (projectChanged) { + return doProjectToMount(relPosix, state, ctx, projectAbs, mountAbs, readonly); + } + // Mount deleted and project hasn't been touched since → mirror to project. + if (readonly) { + // Readonly deletes in mount don't sync back; recreate mount from project. + return doProjectToMount(relPosix, state, ctx, projectAbs, mountAbs, readonly); + } + return doDeleteProject(relPosix, state, projectAbs); + } + + return false; +} + +function doMountToProject( + relPosix: string, + state: Map, + ctx: AutoSyncContext, + mountAbs: string, + projectAbs: string +): boolean { + const target = resolveSafeWriteTarget(ctx.realProjectDir, projectAbs); + if (!target) return false; + if (existsSync(target) && sameContent(mountAbs, target)) { + updateState(state, relPosix, mountAbs, target); + return false; + } + copyFileSync(mountAbs, target); + updateState(state, relPosix, mountAbs, target); + return true; +} + +function doProjectToMount( + relPosix: string, + state: Map, + ctx: AutoSyncContext, + projectAbs: string, + mountAbs: string, + readonly: boolean +): boolean { + const target = resolveSafeWriteTarget(ctx.realMountDir, mountAbs); + if (!target) return false; + if (existsSync(target) && sameContent(projectAbs, target)) { + updateState(state, relPosix, target, projectAbs); + return false; + } + // The mount copy of a readonly file has mode 0o444, which blocks + // copyFileSync from overwriting it. Temporarily restore write permission. + if (existsSync(target)) { + try { chmodSync(target, 0o644); } catch { /* best effort */ } + } + copyFileSync(projectAbs, target); + if (readonly) { + try { chmodSync(target, 0o444); } catch { /* best effort */ } + } else { + const mode = safeFileStat(projectAbs)?.mode; + if (mode !== undefined) { + try { chmodSync(target, mode & 0o777); } catch { /* best effort */ } + } + } + updateState(state, relPosix, target, projectAbs); + return true; +} + +function doDeleteMount( + relPosix: string, + state: Map, + mountAbs: string +): boolean { + try { + rmSync(mountAbs, { force: true }); + } catch { + return false; + } + state.delete(relPosix); + return true; +} + +function doDeleteProject( + relPosix: string, + state: Map, + projectAbs: string +): boolean { + try { + rmSync(projectAbs, { force: true }); + } catch { + return false; + } + state.delete(relPosix); + return true; +} + +function updateState( + state: Map, + relPosix: string, + mountAbs: string, + projectAbs: string +): void { + const mountStat = safeFileStat(mountAbs); + const projectStat = safeFileStat(projectAbs); + state.set(relPosix, { + mountMtimeMs: mountStat?.mtimeMs, + projectMtimeMs: projectStat?.mtimeMs, + }); +} + +function isSyncCandidate(relPosix: string, ctx: AutoSyncContext): boolean { + if (!relPosix || relPosix.startsWith('..')) return false; + if (ctx.isReservedFile(relPosix)) return false; + if (ctx.isExcluded(relPosix)) return false; + if (ctx.isIgnored(relPosix)) return false; + return true; +} + +function toRelPosix(absPath: string, ctx: AutoSyncContext): string | null { + const rel = path.relative(ctx.realMountDir, absPath); + if (rel === '' || rel.startsWith('..')) return null; + return rel.split(path.sep).join('/'); +} + +function toRelPosixFromProject(absPath: string, ctx: AutoSyncContext): string | null { + const rel = path.relative(ctx.realProjectDir, absPath); + if (rel === '' || rel.startsWith('..')) return null; + return rel.split(path.sep).join('/'); +} + +function safeFileStat(p: string): Stats | null { + try { + const s = lstatSync(p); + if (s.isSymbolicLink()) return null; + if (!s.isFile()) return null; + return s; + } catch { + return null; + } +} + +function sameContent(left: string, right: string): boolean { + try { + const a = statSync(left); + const b = statSync(right); + if (a.size !== b.size) return false; + return readFileSync(left).equals(readFileSync(right)); + } catch { + return false; + } +} + +function resolveSafeWriteTarget(root: string, candidate: string): string | null { + const resolvedRoot = path.resolve(root); + const resolvedCandidate = path.resolve(candidate); + if ( + resolvedCandidate !== resolvedRoot && + !resolvedCandidate.startsWith(`${resolvedRoot}${path.sep}`) + ) { + return null; + } + const parent = path.dirname(resolvedCandidate); + try { + mkdirSync(parent, { recursive: true }); + const realParent = realpathSync(parent); + if ( + realParent !== resolvedRoot && + !realParent.startsWith(`${resolvedRoot}${path.sep}`) + ) { + return null; + } + return path.join(realParent, path.basename(resolvedCandidate)); + } catch { + return null; + } +} + +function walk( + root: string, + ctx: AutoSyncContext, + visit: (absPath: string) => void +): void { + const stack = [root]; + while (stack.length > 0) { + const cur = stack.pop(); + if (!cur) continue; + let entries; + try { + entries = readdirSync(cur, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + const abs = path.join(cur, entry.name); + const rel = path.relative(root, abs).split(path.sep).join('/'); + if (!rel || rel.startsWith('..')) continue; + if (ctx.isExcluded(rel) || ctx.isIgnored(rel)) continue; + if (entry.isDirectory()) { + stack.push(abs); + } else if (entry.isFile() || entry.isSymbolicLink()) { + visit(abs); + } + } + } +} + +function shouldChokidarIgnore( + candidate: string, + root: string, + ctx: AutoSyncContext +): boolean { + if (candidate === root) return false; + const rel = path.relative(root, candidate); + if (rel === '' || rel.startsWith('..')) return false; + const relPosix = rel.split(path.sep).join('/'); + // We can't tell from here whether it's a dir or a file, but the filters only + // reject paths; callers that hit an excluded dir will stop there. + if (ctx.isExcluded(relPosix)) return true; + if (ctx.isIgnored(relPosix)) return true; + if (ctx.isReservedFile(relPosix)) return true; + return false; +} diff --git a/packages/local-mount/src/index.ts b/packages/local-mount/src/index.ts index fc4e2ea..9f13a39 100644 --- a/packages/local-mount/src/index.ts +++ b/packages/local-mount/src/index.ts @@ -4,6 +4,11 @@ export { type SymlinkMountHandle, } from './symlink-mount.js'; +export { + type AutoSyncOptions, + type AutoSyncHandle, +} from './auto-sync.js'; + export { readAgentDotfiles, type ReadAgentDotfilesOptions, diff --git a/packages/local-mount/src/launch.ts b/packages/local-mount/src/launch.ts index 6c39374..5966dff 100644 --- a/packages/local-mount/src/launch.ts +++ b/packages/local-mount/src/launch.ts @@ -1,5 +1,6 @@ import { spawn } from 'node:child_process'; import { createSymlinkMount, type SymlinkMountHandle } from './symlink-mount.js'; +import type { AutoSyncHandle, AutoSyncOptions } from './auto-sync.js'; export interface LaunchOnMountOptions { /** Binary name or absolute path to the CLI to spawn, e.g. 'claude'. */ @@ -30,6 +31,12 @@ export interface LaunchOnMountOptions { * files that were written back to the project directory. */ onAfterSync?: (syncedFileCount: number) => void | Promise; + /** + * Auto-sync behavior. By default, bidirectional auto-sync runs during the + * lifetime of the spawned CLI. Pass `false` to disable, or an options object + * to tune the scan interval / write-finish debounce. + */ + autoSync?: boolean | AutoSyncOptions; } export interface LaunchOnMountResult { @@ -52,12 +59,20 @@ export async function launchOnMount(opts: LaunchOnMountOptions): Promise => { if (finalized) return; finalized = true; try { - syncedCount = await handle.syncBack(); + let autoSyncChanges = 0; + if (autoSync) { + await autoSync.stop(); + autoSyncChanges = autoSync.totalChanges(); + autoSync = undefined; + } + const finalSynced = await handle.syncBack(); + syncedCount = autoSyncChanges + finalSynced; if (opts.onAfterSync) { await opts.onAfterSync(syncedCount); } @@ -71,6 +86,11 @@ export async function launchOnMount(opts: LaunchOnMountOptions): Promise; + /** + * Start bidirectional auto-sync: watches both the mount and project trees + * with chokidar and runs a full reconcile every `scanIntervalMs` as a + * safety net. Returns a handle you must `stop()` before teardown. + */ + startAutoSync(opts?: AutoSyncOptions): AutoSyncHandle; cleanup(): void; } @@ -93,6 +105,18 @@ export function createSymlinkMount( 'utf8' ); + const autoSyncContext: AutoSyncContext = { + realMountDir, + realProjectDir: resolvedProjectDir, + isExcluded: (relPosix) => isExcludedPath(relPosix, excludeSet), + isIgnored: (relPosix) => + isPathMatched(relPosix, ignoredMatcher) || + isPathMatched(relPosix, ignoredMatcher, true), + isReadonly: (relPosix) => isPathMatched(relPosix, readonlyMatcher), + isReservedFile: (relPosix) => + relPosix === MOUNT_README_FILENAME || relPosix === MOUNT_MARKER_FILENAME, + }; + return { mountDir: resolvedMountDir, async syncBack(): Promise { @@ -113,6 +137,9 @@ export function createSymlinkMount( return synced; }, + startAutoSync(opts?: AutoSyncOptions): AutoSyncHandle { + return startAutoSync(autoSyncContext, opts); + }, cleanup(): void { removeMountDir(resolvedMountDir); }, From 319446e3a556f56dfebc66c93d201ac21ce55584 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Sat, 18 Apr 2026 20:15:20 -0400 Subject: [PATCH 2/4] Add auto-sync docs and API to README Update packages/local-mount/README.md to document the new auto-sync functionality and API surface. Adds startAutoSync to SymlinkMountHandle and describes AutoSyncOptions and AutoSyncHandle. Updates launchOnMount behavior to start/stop bidirectional auto-sync (or disable it), documents onAfterSync semantics, outlines how auto-sync works (chokidar + periodic reconcile, mtime tracking), lists conflict/delete rules and readonly/ignored path behavior, and clarifies that syncBack is a one-shot mount-only pass that writes files (never deletes). --- packages/local-mount/README.md | 61 ++++++++++++++++++++++++++++++---- 1 file changed, 54 insertions(+), 7 deletions(-) diff --git a/packages/local-mount/README.md b/packages/local-mount/README.md index f81fd58..a840931 100644 --- a/packages/local-mount/README.md +++ b/packages/local-mount/README.md @@ -20,6 +20,7 @@ Builds a mounted copy of `projectDir` at `mountDir` and returns a handle: interface SymlinkMountHandle { mountDir: string; syncBack(): Promise; + startAutoSync(opts?: AutoSyncOptions): AutoSyncHandle; cleanup(): void; } ``` @@ -53,12 +54,58 @@ const { ignoredPatterns, readonlyPatterns } = readAgentDotfiles(projectDir, { High-level helper that: 1. creates a mount, -2. runs a CLI inside it, -3. forwards `SIGINT` and `SIGTERM`, -4. syncs writable changes back after the child exits, -5. cleans up the mount directory. +2. starts bidirectional auto-sync (see below, controllable via `autoSync`), +3. runs a CLI inside the mount, +4. forwards `SIGINT` and `SIGTERM`, +5. stops auto-sync and runs a final sync-back pass after the child exits, +6. cleans up the mount directory. -It resolves with the child process exit code. +It resolves with the child process exit code. `onAfterSync(count)` receives the sum of files changed by auto-sync plus the final sync-back pass. + +### Auto-sync + +By default, `launchOnMount` keeps the mount and project directory in sync continuously while the CLI is running, rather than only at exit. The same machinery is available standalone via `handle.startAutoSync()`. + +```ts +interface AutoSyncOptions { + /** Full-reconcile interval as a safety net. Default: 10_000 ms. */ + scanIntervalMs?: number; + /** chokidar `awaitWriteFinish` stability threshold. Default: 200 ms. */ + writeFinishMs?: number; + /** Invoked on sync errors. Defaults to swallowing them. */ + onError?: (err: Error) => void; +} + +interface AutoSyncHandle { + stop(): Promise; + reconcile(): Promise; + totalChanges(): number; + ready(): Promise; +} +``` + +Control it from `launchOnMount`: + +```ts +// Disable entirely — only the final sync-back pass runs. +launchOnMount({ /* ... */, autoSync: false }); + +// Tune it. +launchOnMount({ /* ... */, autoSync: { scanIntervalMs: 5_000, writeFinishMs: 100 } }); +``` + +How it works: +- chokidar watches both the mount and the project tree +- every `scanIntervalMs`, a full reconcile walks both trees as a safety net for missed events +- per-file `mtime` is tracked at the last sync, so the scan skips files that haven't changed + +Conflict and delete rules: +- both sides changed since last sync → **mount wins** +- only one side changed → propagate that change +- one side deleted and the other unchanged since last sync → propagate the delete +- one side deleted and the other changed since last sync → recreate the missing file from the changed side +- readonly paths never flow mount→project; project-side edits still flow into the mount (the mount copy is re-chmodded `0o444`) +- `_MOUNT_README.md`, `.relayfile-local-mount`, ignored paths, and excluded directories never cross ## Dotfile semantics @@ -135,7 +182,7 @@ console.log(result.exitCode); ## Sync-back behavior -When `syncBack()` runs, the package only writes back files that are safe and writable: +`syncBack()` is the one-shot, mount-only sweep used as a final pass (and available on its own if you disable auto-sync). It only writes files that are safe and writable: - changed writable files are copied back - new writable files created in the mount are copied back @@ -144,7 +191,7 @@ When `syncBack()` runs, the package only writes back files that are safe and wri - read-only matches are skipped - symlinks inside the mount are skipped -The returned number is the count of files actually written back to `projectDir`. +The returned number is the count of files written back to `projectDir` in that pass. `syncBack()` never deletes — delete propagation is handled by auto-sync. ## Safety constraints From 96e690c25641e62496fc5d2dd462f9edef5fd728 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Sat, 18 Apr 2026 20:40:36 -0400 Subject: [PATCH 3/4] Address Copilot review on bidirectional autosync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - primeState now skips files whose content differs at startup, so the first reconcile actually resolves initial drift (previously seeded matching mtimes and then suppressed the sync) - reconcile() isolates per-path errors so one bad path no longer aborts the whole pass; errors are surfaced via onError - doMountToProject / doProjectToMount refuse to write through a target that already exists as a symlink, matching syncBack's behavior and preventing writes from escaping the root - mtime change detection uses !== instead of > so coarse-resolution or backdated writes aren't silently missed during periodic reconciles - Clarified onAfterSync doc: the count is now total changes in both directions (autosync + final syncBack), not a strict mount→project tally Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/local-mount/src/auto-sync.ts | 44 +++++++++++++++++++++------ packages/local-mount/src/launch.ts | 7 +++-- 2 files changed, 39 insertions(+), 12 deletions(-) diff --git a/packages/local-mount/src/auto-sync.ts b/packages/local-mount/src/auto-sync.ts index dbee6f4..f7d5650 100644 --- a/packages/local-mount/src/auto-sync.ts +++ b/packages/local-mount/src/auto-sync.ts @@ -71,7 +71,7 @@ export function startAutoSync( syncing = true; let count = 0; try { - count = reconcile(state, ctx); + count = reconcile(state, ctx, onError); } catch (err) { onError(err as Error); } finally { @@ -80,7 +80,7 @@ export function startAutoSync( if (pending) { pending = false; try { - count += reconcile(state, ctx); + count += reconcile(state, ctx, onError); } catch (err) { onError(err as Error); } @@ -152,9 +152,9 @@ export function startAutoSync( } function primeState(state: Map, ctx: AutoSyncContext): void { - // Record current mtimes for every file that exists in both trees. Files that - // differ, or exist on only one side, are left out so the first reconcile - // treats them as changed and syncs them. + // Record current mtimes for every file that exists in both trees with the + // same content. Files that differ are left out so the first reconcile sees + // no prev entry and picks a winner via the content-based resolution path. walk(ctx.realMountDir, ctx, (abs) => { const rel = toRelPosix(abs, ctx); if (rel === null) return; @@ -164,6 +164,7 @@ function primeState(state: Map, ctx: AutoSyncContext): void { const projectAbs = path.join(ctx.realProjectDir, rel); const projectStat = safeFileStat(projectAbs); if (!projectStat) return; + if (!sameContent(abs, projectAbs)) return; state.set(rel, { mountMtimeMs: mountStat.mtimeMs, projectMtimeMs: projectStat.mtimeMs, @@ -171,7 +172,11 @@ function primeState(state: Map, ctx: AutoSyncContext): void { }); } -function reconcile(state: Map, ctx: AutoSyncContext): number { +function reconcile( + state: Map, + ctx: AutoSyncContext, + onError: (err: Error) => void +): number { const seen = new Set(); let count = 0; @@ -179,8 +184,12 @@ function reconcile(state: Map, ctx: AutoSyncContext): number if (seen.has(relPosix)) return; seen.add(relPosix); if (!isSyncCandidate(relPosix, ctx)) return; - const changed = syncOneFile(relPosix, state, ctx); - if (changed) count += 1; + try { + const changed = syncOneFile(relPosix, state, ctx); + if (changed) count += 1; + } catch (err) { + onError(err as Error); + } }; walk(ctx.realMountDir, ctx, (abs) => { @@ -268,11 +277,14 @@ function syncOneFile( } } + // Use strict inequality rather than `>`: on filesystems with coarse mtime + // resolution, or after a backdated touch, a real content change can land + // with a non-greater mtime. const mountChanged = mountStat - ? prev?.mountMtimeMs === undefined || mountStat.mtimeMs > prev.mountMtimeMs + ? prev?.mountMtimeMs === undefined || mountStat.mtimeMs !== prev.mountMtimeMs : false; const projectChanged = projectStat - ? prev?.projectMtimeMs === undefined || projectStat.mtimeMs > prev.projectMtimeMs + ? prev?.projectMtimeMs === undefined || projectStat.mtimeMs !== prev.projectMtimeMs : false; if (mountStat && projectStat) { @@ -318,6 +330,7 @@ function doMountToProject( ): boolean { const target = resolveSafeWriteTarget(ctx.realProjectDir, projectAbs); if (!target) return false; + if (isSymlinkTarget(target)) return false; if (existsSync(target) && sameContent(mountAbs, target)) { updateState(state, relPosix, mountAbs, target); return false; @@ -337,6 +350,7 @@ function doProjectToMount( ): boolean { const target = resolveSafeWriteTarget(ctx.realMountDir, mountAbs); if (!target) return false; + if (isSymlinkTarget(target)) return false; if (existsSync(target) && sameContent(projectAbs, target)) { updateState(state, relPosix, target, projectAbs); return false; @@ -432,6 +446,16 @@ function safeFileStat(p: string): Stats | null { } } +function isSymlinkTarget(target: string): boolean { + // If the target already exists as a symlink, writing through it would + // follow the link and potentially escape the mount/project root. Refuse. + try { + return lstatSync(target).isSymbolicLink(); + } catch { + return false; + } +} + function sameContent(left: string, right: string): boolean { try { const a = statSync(left); diff --git a/packages/local-mount/src/launch.ts b/packages/local-mount/src/launch.ts index 5966dff..16247ea 100644 --- a/packages/local-mount/src/launch.ts +++ b/packages/local-mount/src/launch.ts @@ -27,8 +27,11 @@ export interface LaunchOnMountOptions { */ onBeforeLaunch?: (mountDir: string) => void | Promise; /** - * Invoked after sync-back completes, before cleanup. Receives the number of - * files that were written back to the project directory. + * Invoked after sync-back completes, before cleanup. Receives the total + * number of file changes propagated during the run — the sum of autosync + * activity in both directions (including deletes) and the final mount→ + * project syncBack. Use this as an "anything changed?" signal rather than + * a strict mount→project count. */ onAfterSync?: (syncedFileCount: number) => void | Promise; /** From 697b573ddc76070f8f746f7f14f109c4c9832614 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Sat, 18 Apr 2026 21:47:51 -0400 Subject: [PATCH 4/4] Fix directory-only ignore patterns swallowing like-named files AutoSyncContext.isIgnored's lambda in symlink-mount always OR'd the file-form and directory-form match, so a regular file whose path matched a directory-only pattern (e.g. `cache/`) was incorrectly ignored by autosync. The initial mount walk already distinguished the two via `entry.isDirectory()`, so the bug only affected the autosync path. - AutoSyncContext.isIgnored now accepts an optional `isDirectory` flag; symlink-mount constructs it as a thin pass-through to `isPathMatched` - walk() in auto-sync.ts passes `entry.isDirectory()` to isIgnored - isSyncCandidate() keeps the default (file), matching its callers - shouldChokidarIgnore() uses chokidar's optional `stats` second arg when available so dir-form matches are only applied to actual directories. Without stats (pre-stat prune call) we fall back to file-form to avoid false-positive pruning; chokidar calls the filter a second time with stats so pruning still happens at the directory level - Regression test covers `cache/` pattern with a file at `docs/cache` Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/local-mount/src/auto-sync.test.ts | 24 +++++++++++++++++++ packages/local-mount/src/auto-sync.ts | 28 +++++++++++++++------- packages/local-mount/src/symlink-mount.ts | 4 +--- 3 files changed, 45 insertions(+), 11 deletions(-) diff --git a/packages/local-mount/src/auto-sync.test.ts b/packages/local-mount/src/auto-sync.test.ts index b39b2b7..269c1b4 100644 --- a/packages/local-mount/src/auto-sync.test.ts +++ b/packages/local-mount/src/auto-sync.test.ts @@ -243,6 +243,30 @@ describe('startAutoSync', () => { } }); + it('directory-only ignore patterns do not swallow like-named files', async () => { + // Pattern `cache/` means "ignore the cache directory" — a *file* whose + // path happens to include a segment of the same name must still sync. + write(path.join(projectDir, 'docs/cache'), 'this is a file, not a dir'); + + const handle = createSymlinkMount(projectDir, mountDir, { + ignoredPatterns: ['cache/'], + readonlyPatterns: [], + excludeDirs: [], + }); + + const auto = handle.startAutoSync({ writeFinishMs: 50, scanIntervalMs: 10_000 }); + await auto.ready(); + try { + writeFileSync(path.join(handle.mountDir, 'docs/cache'), 'edited', 'utf8'); + await waitFor(() => + readFileSync(path.join(projectDir, 'docs/cache'), 'utf8') === 'edited' + ); + } finally { + await auto.stop(); + handle.cleanup(); + } + }); + it('periodic full scan catches changes even if watcher events are missed', async () => { write(path.join(projectDir, 'file.txt'), 'original'); diff --git a/packages/local-mount/src/auto-sync.ts b/packages/local-mount/src/auto-sync.ts index f7d5650..1cf5e08 100644 --- a/packages/local-mount/src/auto-sync.ts +++ b/packages/local-mount/src/auto-sync.ts @@ -18,7 +18,12 @@ export interface AutoSyncContext { realMountDir: string; realProjectDir: string; isExcluded: (relPosix: string) => boolean; - isIgnored: (relPosix: string) => boolean; + /** + * Directory-only ignore patterns (ending in `/`) must only match when the + * path is a directory. Callers that know the path's type pass `isDirectory`; + * callers that don't (chokidar's prune filter) should check both forms. + */ + isIgnored: (relPosix: string, isDirectory?: boolean) => boolean; isReadonly: (relPosix: string) => boolean; isReservedFile: (relPosix: string) => boolean; } @@ -111,7 +116,8 @@ export function startAutoSync( stabilityThreshold: writeFinishMs, pollInterval: 50, }, - ignored: (candidate: string) => shouldChokidarIgnore(candidate, root, ctx), + ignored: (candidate: string, stats?: Stats) => + shouldChokidarIgnore(candidate, root, ctx, stats), }); const onEvent = (p: string) => syncPathFromRoot(root, p); watcher.on('add', onEvent); @@ -511,7 +517,7 @@ function walk( const abs = path.join(cur, entry.name); const rel = path.relative(root, abs).split(path.sep).join('/'); if (!rel || rel.startsWith('..')) continue; - if (ctx.isExcluded(rel) || ctx.isIgnored(rel)) continue; + if (ctx.isExcluded(rel) || ctx.isIgnored(rel, entry.isDirectory())) continue; if (entry.isDirectory()) { stack.push(abs); } else if (entry.isFile() || entry.isSymbolicLink()) { @@ -524,16 +530,22 @@ function walk( function shouldChokidarIgnore( candidate: string, root: string, - ctx: AutoSyncContext + ctx: AutoSyncContext, + stats?: Stats ): boolean { if (candidate === root) return false; const rel = path.relative(root, candidate); if (rel === '' || rel.startsWith('..')) return false; const relPosix = rel.split(path.sep).join('/'); - // We can't tell from here whether it's a dir or a file, but the filters only - // reject paths; callers that hit an excluded dir will stop there. if (ctx.isExcluded(relPosix)) return true; - if (ctx.isIgnored(relPosix)) return true; if (ctx.isReservedFile(relPosix)) return true; - return false; + // chokidar calls this filter twice: first without stats (pre-stat prune), + // then again with stats once it knows the entry type. Only apply the + // directory-form match when we have stats confirming it's a directory, + // otherwise a directory-only pattern like `cache/` would wrongly prune a + // same-named file. + if (stats) { + return ctx.isIgnored(relPosix, stats.isDirectory()); + } + return ctx.isIgnored(relPosix); } diff --git a/packages/local-mount/src/symlink-mount.ts b/packages/local-mount/src/symlink-mount.ts index 628f4f6..2d19509 100644 --- a/packages/local-mount/src/symlink-mount.ts +++ b/packages/local-mount/src/symlink-mount.ts @@ -109,9 +109,7 @@ export function createSymlinkMount( realMountDir, realProjectDir: resolvedProjectDir, isExcluded: (relPosix) => isExcludedPath(relPosix, excludeSet), - isIgnored: (relPosix) => - isPathMatched(relPosix, ignoredMatcher) || - isPathMatched(relPosix, ignoredMatcher, true), + isIgnored: (relPosix, isDir) => isPathMatched(relPosix, ignoredMatcher, isDir), isReadonly: (relPosix) => isPathMatched(relPosix, readonlyMatcher), isReservedFile: (relPosix) => relPosix === MOUNT_README_FILENAME || relPosix === MOUNT_MARKER_FILENAME,