@relayfile/local-mount 0.3.2 → 0.5.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/README.md +16 -7
- package/dist/auto-sync.d.ts +13 -5
- package/dist/auto-sync.js +133 -59
- package/dist/launch.d.ts +8 -0
- package/dist/launch.js +2 -2
- package/dist/symlink-mount.d.ts +5 -3
- package/dist/symlink-mount.js +10 -2
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -19,7 +19,7 @@ Builds a mounted copy of `projectDir` at `mountDir` and returns a handle:
|
|
|
19
19
|
```ts
|
|
20
20
|
interface SymlinkMountHandle {
|
|
21
21
|
mountDir: string;
|
|
22
|
-
syncBack(): Promise<number>;
|
|
22
|
+
syncBack(opts?: { signal?: AbortSignal }): Promise<number>;
|
|
23
23
|
startAutoSync(opts?: AutoSyncOptions): AutoSyncHandle;
|
|
24
24
|
cleanup(): void;
|
|
25
25
|
}
|
|
@@ -62,6 +62,8 @@ High-level helper that:
|
|
|
62
62
|
|
|
63
63
|
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.
|
|
64
64
|
|
|
65
|
+
`launchOnMount({ shutdownSignal })` threads an optional `AbortSignal` into the shutdown phase only. It does not cancel the spawned CLI. If shutdown is aborted, `onAfterSync` still fires with the partial count gathered so far and the mount directory is still cleaned up.
|
|
66
|
+
|
|
65
67
|
### Auto-sync
|
|
66
68
|
|
|
67
69
|
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()`.
|
|
@@ -70,15 +72,15 @@ By default, `launchOnMount` keeps the mount and project directory in sync contin
|
|
|
70
72
|
interface AutoSyncOptions {
|
|
71
73
|
/** Full-reconcile interval as a safety net. Default: 10_000 ms. */
|
|
72
74
|
scanIntervalMs?: number;
|
|
73
|
-
/**
|
|
74
|
-
|
|
75
|
+
/** Per-path event debounce in ms. Default: 50 ms. */
|
|
76
|
+
debounceMs?: number;
|
|
75
77
|
/** Invoked on sync errors. Defaults to swallowing them. */
|
|
76
78
|
onError?: (err: Error) => void;
|
|
77
79
|
}
|
|
78
80
|
|
|
79
81
|
interface AutoSyncHandle {
|
|
80
|
-
stop(): Promise<void>;
|
|
81
|
-
reconcile(): Promise<number>;
|
|
82
|
+
stop(opts?: { signal?: AbortSignal }): Promise<void>;
|
|
83
|
+
reconcile(opts?: { signal?: AbortSignal }): Promise<number>;
|
|
82
84
|
totalChanges(): number;
|
|
83
85
|
ready(): Promise<void>;
|
|
84
86
|
}
|
|
@@ -91,12 +93,13 @@ Control it from `launchOnMount`:
|
|
|
91
93
|
launchOnMount({ /* ... */, autoSync: false });
|
|
92
94
|
|
|
93
95
|
// Tune it.
|
|
94
|
-
launchOnMount({ /* ... */, autoSync: { scanIntervalMs: 5_000,
|
|
96
|
+
launchOnMount({ /* ... */, autoSync: { scanIntervalMs: 5_000, debounceMs: 100 } });
|
|
95
97
|
```
|
|
96
98
|
|
|
97
99
|
How it works:
|
|
98
|
-
-
|
|
100
|
+
- [@parcel/watcher](https://www.npmjs.com/package/@parcel/watcher) watches both the mount and the project tree using native FSEvents/inotify/ReadDirectoryChangesW
|
|
99
101
|
- every `scanIntervalMs`, a full reconcile walks both trees as a safety net for missed events
|
|
102
|
+
- `stop({ signal })` still closes watchers if aborted, but skips the final draining reconcile
|
|
100
103
|
- per-file `mtime` is tracked at the last sync, so the scan skips files that haven't changed
|
|
101
104
|
|
|
102
105
|
Conflict and delete rules:
|
|
@@ -154,6 +157,7 @@ import os from 'node:os';
|
|
|
154
157
|
|
|
155
158
|
const projectDir = '/projects/acme-api';
|
|
156
159
|
const mountDir = path.join(os.tmpdir(), 'acme-api-agent-mount');
|
|
160
|
+
const abortController = new AbortController();
|
|
157
161
|
|
|
158
162
|
const { ignoredPatterns, readonlyPatterns } = readAgentDotfiles(projectDir, {
|
|
159
163
|
agentName: 'reviewer',
|
|
@@ -172,6 +176,7 @@ const result = await launchOnMount({
|
|
|
172
176
|
// Add extra instructions or scratch files inside the mount if needed.
|
|
173
177
|
console.log(`Mount ready at ${dir}`);
|
|
174
178
|
},
|
|
179
|
+
shutdownSignal: abortController.signal,
|
|
175
180
|
onAfterSync: async (count) => {
|
|
176
181
|
console.log(`Synced ${count} writable file(s) back to the project`);
|
|
177
182
|
},
|
|
@@ -193,6 +198,10 @@ console.log(result.exitCode);
|
|
|
193
198
|
|
|
194
199
|
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.
|
|
195
200
|
|
|
201
|
+
`syncBack({ signal })` checks for aborts between files. If the signal aborts during shutdown, it returns the partial count accumulated so far instead of throwing, which lets callers report a partial sync and still run cleanup.
|
|
202
|
+
|
|
203
|
+
`reconcile({ signal })` and the internal tree walk also poll for aborts between file visits so an in-flight draining scan can stop cooperatively.
|
|
204
|
+
|
|
196
205
|
## Safety constraints
|
|
197
206
|
|
|
198
207
|
The implementation is intentionally conservative about `mountDir`:
|
package/dist/auto-sync.d.ts
CHANGED
|
@@ -5,7 +5,8 @@ export interface AutoSyncContext {
|
|
|
5
5
|
/**
|
|
6
6
|
* Directory-only ignore patterns (ending in `/`) must only match when the
|
|
7
7
|
* path is a directory. Callers that know the path's type pass `isDirectory`;
|
|
8
|
-
* callers that don't
|
|
8
|
+
* callers that don't should omit the second argument and fall back to the
|
|
9
|
+
* file-form check.
|
|
9
10
|
*/
|
|
10
11
|
isIgnored: (relPosix: string, isDirectory?: boolean) => boolean;
|
|
11
12
|
isReadonly: (relPosix: string) => boolean;
|
|
@@ -14,15 +15,22 @@ export interface AutoSyncContext {
|
|
|
14
15
|
export interface AutoSyncOptions {
|
|
15
16
|
/** Full-reconcile interval as a safety net. Default: 10_000ms. */
|
|
16
17
|
scanIntervalMs?: number;
|
|
17
|
-
/**
|
|
18
|
-
|
|
18
|
+
/**
|
|
19
|
+
* Per-path event debounce in ms. Rapid watcher events for the same path
|
|
20
|
+
* are coalesced into a single sync. Default: 50.
|
|
21
|
+
*/
|
|
22
|
+
debounceMs?: number;
|
|
19
23
|
/** Invoked on errors during sync — logged by default consumer. */
|
|
20
24
|
onError?: (err: Error) => void;
|
|
21
25
|
}
|
|
22
26
|
export interface AutoSyncHandle {
|
|
23
|
-
stop(
|
|
27
|
+
stop(opts?: {
|
|
28
|
+
signal?: AbortSignal;
|
|
29
|
+
}): Promise<void>;
|
|
24
30
|
/** Force a reconcile now; returns number of files copied/deleted. */
|
|
25
|
-
reconcile(
|
|
31
|
+
reconcile(opts?: {
|
|
32
|
+
signal?: AbortSignal;
|
|
33
|
+
}): Promise<number>;
|
|
26
34
|
/** Cumulative files changed (copied or deleted) since autosync started. */
|
|
27
35
|
totalChanges(): number;
|
|
28
36
|
/** Resolves once both watchers have completed their initial scan. */
|
package/dist/auto-sync.js
CHANGED
|
@@ -1,16 +1,22 @@
|
|
|
1
1
|
import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import
|
|
3
|
+
import watcher from '@parcel/watcher';
|
|
4
4
|
export function startAutoSync(ctx, opts = {}) {
|
|
5
5
|
const scanIntervalMs = opts.scanIntervalMs ?? 10_000;
|
|
6
|
-
const
|
|
6
|
+
const debounceMs = opts.debounceMs ?? 50;
|
|
7
7
|
const onError = opts.onError ?? (() => { });
|
|
8
8
|
const state = new Map();
|
|
9
9
|
primeState(state, ctx);
|
|
10
10
|
let syncing = false;
|
|
11
11
|
let pending = false;
|
|
12
|
+
let stopped = false;
|
|
12
13
|
let totalChanges = 0;
|
|
13
|
-
const
|
|
14
|
+
const pendingDebounces = new Map();
|
|
15
|
+
const runReconcile = async (opts) => {
|
|
16
|
+
const signal = opts?.signal;
|
|
17
|
+
if (signal?.aborted) {
|
|
18
|
+
return 0;
|
|
19
|
+
}
|
|
14
20
|
if (syncing) {
|
|
15
21
|
pending = true;
|
|
16
22
|
return 0;
|
|
@@ -18,7 +24,7 @@ export function startAutoSync(ctx, opts = {}) {
|
|
|
18
24
|
syncing = true;
|
|
19
25
|
let count = 0;
|
|
20
26
|
try {
|
|
21
|
-
count = reconcile(state, ctx, onError);
|
|
27
|
+
count = reconcile(state, ctx, onError, signal);
|
|
22
28
|
}
|
|
23
29
|
catch (err) {
|
|
24
30
|
onError(err);
|
|
@@ -26,10 +32,10 @@ export function startAutoSync(ctx, opts = {}) {
|
|
|
26
32
|
finally {
|
|
27
33
|
syncing = false;
|
|
28
34
|
}
|
|
29
|
-
if (pending) {
|
|
35
|
+
if (pending && !signal?.aborted) {
|
|
30
36
|
pending = false;
|
|
31
37
|
try {
|
|
32
|
-
count += reconcile(state, ctx, onError);
|
|
38
|
+
count += reconcile(state, ctx, onError, signal);
|
|
33
39
|
}
|
|
34
40
|
catch (err) {
|
|
35
41
|
onError(err);
|
|
@@ -54,43 +60,101 @@ export function startAutoSync(ctx, opts = {}) {
|
|
|
54
60
|
onError(err);
|
|
55
61
|
}
|
|
56
62
|
};
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
return { watcher, ready };
|
|
63
|
+
const schedulePathSync = (root, absPath) => {
|
|
64
|
+
// Once stop() has begun, refuse to schedule new timers. Watcher
|
|
65
|
+
// callbacks can still fire during the unsubscribe await (native
|
|
66
|
+
// backends deliver queued events asynchronously); without this guard
|
|
67
|
+
// those events would create timers that outlive stop() and do file
|
|
68
|
+
// work against a mount the caller may have already cleaned up.
|
|
69
|
+
if (stopped)
|
|
70
|
+
return;
|
|
71
|
+
// Coalesce bursts of events for the same path. The reconcile path
|
|
72
|
+
// re-checks content via mtime+bytes, so a partial-write event that
|
|
73
|
+
// races a later write is harmless.
|
|
74
|
+
const existing = pendingDebounces.get(absPath);
|
|
75
|
+
if (existing)
|
|
76
|
+
clearTimeout(existing);
|
|
77
|
+
const t = setTimeout(() => {
|
|
78
|
+
pendingDebounces.delete(absPath);
|
|
79
|
+
syncPathFromRoot(root, absPath);
|
|
80
|
+
}, debounceMs);
|
|
81
|
+
pendingDebounces.set(absPath, t);
|
|
77
82
|
};
|
|
78
|
-
const
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
+
const ignoreGlobs = buildIgnoreGlobs(ctx);
|
|
84
|
+
const subscribeTo = (root) => watcher.subscribe(root, (err, events) => {
|
|
85
|
+
if (err) {
|
|
86
|
+
onError(err);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
for (const ev of events) {
|
|
90
|
+
schedulePathSync(root, ev.path);
|
|
91
|
+
}
|
|
92
|
+
}, { ignore: ignoreGlobs });
|
|
93
|
+
let mountSub;
|
|
94
|
+
let projectSub;
|
|
95
|
+
// Subscribe in parallel but track each outcome independently. With
|
|
96
|
+
// Promise.all, a failure on one side would reject before the other's
|
|
97
|
+
// assignment ran and leak the succeeded subscription. allSettled lets us
|
|
98
|
+
// tear down whichever fulfilled before re-throwing the first failure.
|
|
99
|
+
const watchersReady = (async () => {
|
|
100
|
+
const [mountResult, projectResult] = await Promise.allSettled([
|
|
101
|
+
subscribeTo(ctx.realMountDir),
|
|
102
|
+
subscribeTo(ctx.realProjectDir),
|
|
103
|
+
]);
|
|
104
|
+
if (mountResult.status === 'fulfilled')
|
|
105
|
+
mountSub = mountResult.value;
|
|
106
|
+
if (projectResult.status === 'fulfilled')
|
|
107
|
+
projectSub = projectResult.value;
|
|
108
|
+
if (mountResult.status === 'fulfilled' && projectResult.status === 'fulfilled') {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
await Promise.allSettled([
|
|
112
|
+
mountSub?.unsubscribe(),
|
|
113
|
+
projectSub?.unsubscribe(),
|
|
114
|
+
]);
|
|
115
|
+
mountSub = undefined;
|
|
116
|
+
projectSub = undefined;
|
|
117
|
+
throw mountResult.status === 'rejected'
|
|
118
|
+
? mountResult.reason
|
|
119
|
+
: projectResult.reason;
|
|
120
|
+
})();
|
|
121
|
+
// If subscription setup fails, surface via onError rather than an unhandled
|
|
122
|
+
// rejection. stop() still awaits the same promise and will observe the
|
|
123
|
+
// rejection after the cleanup above has already run.
|
|
124
|
+
watchersReady.catch((err) => onError(err));
|
|
83
125
|
const interval = setInterval(() => {
|
|
84
126
|
void runReconcile();
|
|
85
127
|
}, scanIntervalMs);
|
|
86
128
|
// Do not keep the event loop alive just because of our scan timer.
|
|
87
129
|
interval.unref?.();
|
|
88
130
|
return {
|
|
89
|
-
async stop() {
|
|
131
|
+
async stop(opts) {
|
|
132
|
+
// Flip the flag first so any watcher callbacks delivered during the
|
|
133
|
+
// awaits below refuse to schedule new timers.
|
|
134
|
+
stopped = true;
|
|
90
135
|
clearInterval(interval);
|
|
91
|
-
|
|
136
|
+
try {
|
|
137
|
+
await watchersReady;
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
// Setup failed and already cleaned up any partial subscription;
|
|
141
|
+
// mountSub / projectSub were reset to undefined before the throw.
|
|
142
|
+
}
|
|
143
|
+
await Promise.allSettled([
|
|
144
|
+
mountSub?.unsubscribe(),
|
|
145
|
+
projectSub?.unsubscribe(),
|
|
146
|
+
]);
|
|
147
|
+
// Clear debounces *after* unsubscribe resolves: any timer scheduled
|
|
148
|
+
// between stop() being called and the watcher actually quiescing is
|
|
149
|
+
// gathered here, so none fire after stop() returns.
|
|
150
|
+
for (const t of pendingDebounces.values())
|
|
151
|
+
clearTimeout(t);
|
|
152
|
+
pendingDebounces.clear();
|
|
153
|
+
if (opts?.signal?.aborted) {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
92
156
|
// Drain any pending work so callers can rely on "stopped means quiesced".
|
|
93
|
-
await runReconcile();
|
|
157
|
+
await runReconcile(opts);
|
|
94
158
|
},
|
|
95
159
|
reconcile: runReconcile,
|
|
96
160
|
totalChanges: () => totalChanges,
|
|
@@ -99,6 +163,23 @@ export function startAutoSync(ctx, opts = {}) {
|
|
|
99
163
|
},
|
|
100
164
|
};
|
|
101
165
|
}
|
|
166
|
+
function buildIgnoreGlobs(ctx) {
|
|
167
|
+
// @parcel/watcher matches globs against absolute paths via globset. For each
|
|
168
|
+
// excluded directory name, ignore both the directory itself and everything
|
|
169
|
+
// beneath it, anywhere under the watched root. The `isExcluded` predicate is
|
|
170
|
+
// driven by a Set of directory names, so we probe a small set of common
|
|
171
|
+
// exclusions rather than introspecting it. The in-handler `isSyncCandidate`
|
|
172
|
+
// filter is authoritative — this is just a perf hint so the watcher doesn't
|
|
173
|
+
// recurse into heavy trees like node_modules or .git.
|
|
174
|
+
const globs = [];
|
|
175
|
+
const candidates = ['.git', 'node_modules', 'dist', 'build', '.next', '.cache'];
|
|
176
|
+
for (const name of candidates) {
|
|
177
|
+
if (ctx.isExcluded(name)) {
|
|
178
|
+
globs.push(`**/${name}`, `**/${name}/**`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return globs;
|
|
182
|
+
}
|
|
102
183
|
function primeState(state, ctx) {
|
|
103
184
|
// Record current mtimes for every file that exists in both trees with the
|
|
104
185
|
// same content. Files that differ are left out so the first reconcile sees
|
|
@@ -124,7 +205,7 @@ function primeState(state, ctx) {
|
|
|
124
205
|
});
|
|
125
206
|
});
|
|
126
207
|
}
|
|
127
|
-
function reconcile(state, ctx, onError) {
|
|
208
|
+
function reconcile(state, ctx, onError, signal) {
|
|
128
209
|
const seen = new Set();
|
|
129
210
|
let count = 0;
|
|
130
211
|
const visit = (relPosix) => {
|
|
@@ -143,15 +224,25 @@ function reconcile(state, ctx, onError) {
|
|
|
143
224
|
}
|
|
144
225
|
};
|
|
145
226
|
walk(ctx.realMountDir, ctx, (abs) => {
|
|
227
|
+
if (signal?.aborted)
|
|
228
|
+
return;
|
|
146
229
|
const rel = toRelPosix(abs, ctx);
|
|
147
230
|
if (rel !== null)
|
|
148
231
|
visit(rel);
|
|
149
|
-
});
|
|
232
|
+
}, signal);
|
|
233
|
+
if (signal?.aborted) {
|
|
234
|
+
return count;
|
|
235
|
+
}
|
|
150
236
|
walk(ctx.realProjectDir, ctx, (abs) => {
|
|
237
|
+
if (signal?.aborted)
|
|
238
|
+
return;
|
|
151
239
|
const rel = toRelPosixFromProject(abs, ctx);
|
|
152
240
|
if (rel !== null)
|
|
153
241
|
visit(rel);
|
|
154
|
-
});
|
|
242
|
+
}, signal);
|
|
243
|
+
if (signal?.aborted) {
|
|
244
|
+
return count;
|
|
245
|
+
}
|
|
155
246
|
// Tombstone sweep: any path in state we didn't visit had both sides absent,
|
|
156
247
|
// so it's fully gone.
|
|
157
248
|
for (const rel of Array.from(state.keys())) {
|
|
@@ -414,9 +505,11 @@ function resolveSafeWriteTarget(root, candidate) {
|
|
|
414
505
|
return null;
|
|
415
506
|
}
|
|
416
507
|
}
|
|
417
|
-
function walk(root, ctx, visit) {
|
|
508
|
+
function walk(root, ctx, visit, signal) {
|
|
418
509
|
const stack = [root];
|
|
419
510
|
while (stack.length > 0) {
|
|
511
|
+
if (signal?.aborted)
|
|
512
|
+
return;
|
|
420
513
|
const cur = stack.pop();
|
|
421
514
|
if (!cur)
|
|
422
515
|
continue;
|
|
@@ -428,6 +521,8 @@ function walk(root, ctx, visit) {
|
|
|
428
521
|
continue;
|
|
429
522
|
}
|
|
430
523
|
for (const entry of entries) {
|
|
524
|
+
if (signal?.aborted)
|
|
525
|
+
return;
|
|
431
526
|
const abs = path.join(cur, entry.name);
|
|
432
527
|
const rel = path.relative(root, abs).split(path.sep).join('/');
|
|
433
528
|
if (!rel || rel.startsWith('..'))
|
|
@@ -443,24 +538,3 @@ function walk(root, ctx, visit) {
|
|
|
443
538
|
}
|
|
444
539
|
}
|
|
445
540
|
}
|
|
446
|
-
function shouldChokidarIgnore(candidate, root, ctx, stats) {
|
|
447
|
-
if (candidate === root)
|
|
448
|
-
return false;
|
|
449
|
-
const rel = path.relative(root, candidate);
|
|
450
|
-
if (rel === '' || rel.startsWith('..'))
|
|
451
|
-
return false;
|
|
452
|
-
const relPosix = rel.split(path.sep).join('/');
|
|
453
|
-
if (ctx.isExcluded(relPosix))
|
|
454
|
-
return true;
|
|
455
|
-
if (ctx.isReservedFile(relPosix))
|
|
456
|
-
return true;
|
|
457
|
-
// chokidar calls this filter twice: first without stats (pre-stat prune),
|
|
458
|
-
// then again with stats once it knows the entry type. Only apply the
|
|
459
|
-
// directory-form match when we have stats confirming it's a directory,
|
|
460
|
-
// otherwise a directory-only pattern like `cache/` would wrongly prune a
|
|
461
|
-
// same-named file.
|
|
462
|
-
if (stats) {
|
|
463
|
-
return ctx.isIgnored(relPosix, stats.isDirectory());
|
|
464
|
-
}
|
|
465
|
-
return ctx.isIgnored(relPosix);
|
|
466
|
-
}
|
package/dist/launch.d.ts
CHANGED
|
@@ -18,6 +18,14 @@ export interface LaunchOnMountOptions {
|
|
|
18
18
|
env?: NodeJS.ProcessEnv;
|
|
19
19
|
/** Optional agent name, used in the _MOUNT_README.md "Agent:" line. */
|
|
20
20
|
agentName?: string;
|
|
21
|
+
/**
|
|
22
|
+
* Optional signal used during shutdown work after the child exits.
|
|
23
|
+
* It is passed to both autosync shutdown and the final mount→project sync-back.
|
|
24
|
+
* If aborted, autosync stop may skip its draining reconcile and sync-back
|
|
25
|
+
* returns the partial count accumulated so far, so fewer changes may be
|
|
26
|
+
* propagated than during an uninterrupted shutdown.
|
|
27
|
+
*/
|
|
28
|
+
shutdownSignal?: AbortSignal;
|
|
21
29
|
/**
|
|
22
30
|
* Invoked after the mount is created but before the CLI is spawned.
|
|
23
31
|
* Useful for writing additional files into the mount (overrides, extra docs).
|
package/dist/launch.js
CHANGED
|
@@ -23,11 +23,11 @@ export async function launchOnMount(opts) {
|
|
|
23
23
|
try {
|
|
24
24
|
let autoSyncChanges = 0;
|
|
25
25
|
if (autoSync) {
|
|
26
|
-
await autoSync.stop();
|
|
26
|
+
await autoSync.stop({ signal: opts.shutdownSignal });
|
|
27
27
|
autoSyncChanges = autoSync.totalChanges();
|
|
28
28
|
autoSync = undefined;
|
|
29
29
|
}
|
|
30
|
-
const finalSynced = await handle.syncBack();
|
|
30
|
+
const finalSynced = await handle.syncBack({ signal: opts.shutdownSignal });
|
|
31
31
|
syncedCount = autoSyncChanges + finalSynced;
|
|
32
32
|
if (opts.onAfterSync) {
|
|
33
33
|
await opts.onAfterSync(syncedCount);
|
package/dist/symlink-mount.d.ts
CHANGED
|
@@ -11,11 +11,13 @@ export interface SymlinkMountOptions {
|
|
|
11
11
|
}
|
|
12
12
|
export interface SymlinkMountHandle {
|
|
13
13
|
mountDir: string;
|
|
14
|
-
syncBack(
|
|
14
|
+
syncBack(opts?: {
|
|
15
|
+
signal?: AbortSignal;
|
|
16
|
+
}): Promise<number>;
|
|
15
17
|
/**
|
|
16
18
|
* Start bidirectional auto-sync: watches both the mount and project trees
|
|
17
|
-
*
|
|
18
|
-
* safety net. Returns a handle you must `stop()` before teardown.
|
|
19
|
+
* via @parcel/watcher and runs a full reconcile every `scanIntervalMs`
|
|
20
|
+
* as a safety net. Returns a handle you must `stop()` before teardown.
|
|
19
21
|
*/
|
|
20
22
|
startAutoSync(opts?: AutoSyncOptions): AutoSyncHandle;
|
|
21
23
|
cleanup(): void;
|
package/dist/symlink-mount.js
CHANGED
|
@@ -46,13 +46,21 @@ export function createSymlinkMount(projectDir, mountDir, options) {
|
|
|
46
46
|
};
|
|
47
47
|
return {
|
|
48
48
|
mountDir: resolvedMountDir,
|
|
49
|
-
async syncBack() {
|
|
49
|
+
async syncBack(opts) {
|
|
50
50
|
let synced = 0;
|
|
51
51
|
const realProjectDir = realpathSync(resolvedProjectDir);
|
|
52
52
|
const realMountDir = realpathSync(resolvedMountDir);
|
|
53
53
|
const files = listFiles(realMountDir);
|
|
54
|
+
const signal = opts?.signal;
|
|
54
55
|
for (const sourceFile of files) {
|
|
55
|
-
|
|
56
|
+
if (signal?.aborted) {
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
const syncedForFile = syncMountedFileBack(sourceFile, realMountDir, realProjectDir, readonlyMatcher, ignoredMatcher);
|
|
60
|
+
synced += syncedForFile;
|
|
61
|
+
if (signal && syncedForFile > 0 && !signal.aborted) {
|
|
62
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
63
|
+
}
|
|
56
64
|
}
|
|
57
65
|
return synced;
|
|
58
66
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@relayfile/local-mount",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
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",
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"prepublishOnly": "npm run build"
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"
|
|
18
|
+
"@parcel/watcher": "^2.5.6",
|
|
19
19
|
"ignore": "^7.0.5"
|
|
20
20
|
},
|
|
21
21
|
"devDependencies": {
|