@cardstack/boxel-cli 0.0.1 → 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/README.md +124 -0
- package/api.ts +3 -0
- package/bin/boxel.js +15 -0
- package/dist/index.js +107 -66
- package/package.json +31 -24
- package/src/commands/file/delete.ts +110 -0
- package/src/commands/file/index.ts +20 -0
- package/src/commands/file/lint.ts +235 -0
- package/src/commands/file/list.ts +121 -0
- package/src/commands/file/read.ts +113 -0
- package/src/commands/file/touch.ts +222 -0
- package/src/commands/file/write.ts +152 -0
- package/src/commands/profile.ts +199 -106
- package/src/commands/read-transpiled.ts +120 -0
- package/src/commands/realm/cancel-indexing.ts +113 -0
- package/src/commands/realm/create.ts +1 -4
- package/src/commands/realm/history.ts +388 -0
- package/src/commands/realm/index.ts +12 -0
- package/src/commands/realm/list.ts +156 -0
- package/src/commands/realm/pull.ts +51 -17
- package/src/commands/realm/push.ts +52 -16
- package/src/commands/realm/remove.ts +281 -0
- package/src/commands/realm/sync.ts +153 -60
- package/src/commands/realm/wait-for-ready.ts +120 -0
- package/src/commands/realm/watch.ts +626 -0
- package/src/commands/run-command.ts +4 -3
- package/src/commands/search.ts +160 -0
- package/src/index.ts +60 -2
- package/src/lib/auth-resolver.ts +58 -0
- package/src/lib/auth.ts +56 -12
- package/src/lib/boxel-cli-client.ts +135 -279
- package/src/lib/cli-log.ts +132 -0
- package/src/lib/colors.ts +14 -9
- package/src/lib/find-checkpoint.ts +65 -0
- package/src/lib/profile-manager.ts +49 -4
- package/src/lib/prompt.ts +133 -0
- package/src/lib/realm-authenticator.ts +12 -0
- package/src/lib/realm-sync-base.ts +47 -10
- package/src/lib/seed-auth.ts +214 -0
- package/src/lib/watch-lock.ts +81 -0
- package/LICENSE +0 -21
|
@@ -0,0 +1,626 @@
|
|
|
1
|
+
import { InvalidArgumentError, type Command } from 'commander';
|
|
2
|
+
import * as fs from 'fs/promises';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import { RealmSyncBase, isProtectedFile } from '../../lib/realm-sync-base';
|
|
5
|
+
import {
|
|
6
|
+
CheckpointManager,
|
|
7
|
+
type Checkpoint,
|
|
8
|
+
type CheckpointChange,
|
|
9
|
+
} from '../../lib/checkpoint-manager';
|
|
10
|
+
import {
|
|
11
|
+
type SyncManifest,
|
|
12
|
+
computeFileHash,
|
|
13
|
+
loadManifest,
|
|
14
|
+
saveManifest,
|
|
15
|
+
} from '../../lib/sync-manifest';
|
|
16
|
+
import type { ProfileManager } from '../../lib/profile-manager';
|
|
17
|
+
import type { RealmAuthenticator } from '../../lib/realm-authenticator';
|
|
18
|
+
import { resolveRealmAuthenticator } from '../../lib/auth-resolver';
|
|
19
|
+
import { resolveRealmSecretSeed } from '../../lib/prompt';
|
|
20
|
+
import {
|
|
21
|
+
acquireWatchLock,
|
|
22
|
+
releaseWatchLock,
|
|
23
|
+
type WatchLockInfo,
|
|
24
|
+
} from '../../lib/watch-lock';
|
|
25
|
+
import {
|
|
26
|
+
FG_CYAN,
|
|
27
|
+
FG_GREEN,
|
|
28
|
+
FG_RED,
|
|
29
|
+
FG_YELLOW,
|
|
30
|
+
DIM,
|
|
31
|
+
RESET,
|
|
32
|
+
} from '../../lib/colors';
|
|
33
|
+
|
|
34
|
+
export interface WatchRealmSpec {
|
|
35
|
+
realmUrl: string;
|
|
36
|
+
localDir: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface PendingChange {
|
|
40
|
+
status: 'added' | 'modified' | 'deleted';
|
|
41
|
+
mtime: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface FlushResult {
|
|
45
|
+
pulled: string[];
|
|
46
|
+
deleted: string[];
|
|
47
|
+
checkpoint: Checkpoint | null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Watches a single realm by polling `_mtimes`, accumulating changes between
|
|
52
|
+
* ticks, and applying them in a debounced batch (download + delete + write
|
|
53
|
+
* a checkpoint). One instance per realm; `watchRealms()` orchestrates many.
|
|
54
|
+
*/
|
|
55
|
+
export class RealmWatcher extends RealmSyncBase {
|
|
56
|
+
readonly name: string;
|
|
57
|
+
private readonly debounceMs: number;
|
|
58
|
+
private readonly checkpointManager: CheckpointManager;
|
|
59
|
+
private lastKnownMtimes = new Map<string, number>();
|
|
60
|
+
private pendingChanges = new Map<string, PendingChange>();
|
|
61
|
+
private debounceTimer: NodeJS.Timeout | null = null;
|
|
62
|
+
private isShutdown = false;
|
|
63
|
+
|
|
64
|
+
constructor(
|
|
65
|
+
spec: WatchRealmSpec,
|
|
66
|
+
authenticator: RealmAuthenticator,
|
|
67
|
+
options: { debounceMs: number },
|
|
68
|
+
) {
|
|
69
|
+
super({ realmUrl: spec.realmUrl, localDir: spec.localDir }, authenticator);
|
|
70
|
+
this.debounceMs = options.debounceMs;
|
|
71
|
+
this.checkpointManager = new CheckpointManager(spec.localDir);
|
|
72
|
+
this.name = deriveRealmName(this.normalizedRealmUrl);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** RealmSyncBase requires `sync()`. For the watcher, run one poll+apply. */
|
|
76
|
+
async sync(): Promise<void> {
|
|
77
|
+
await this.poll();
|
|
78
|
+
await this.flushPending();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Override: base swallows errors → empty map, which the watcher would
|
|
82
|
+
// read as "every file deleted" and wipe the local dir on a network blip.
|
|
83
|
+
protected override async getRemoteMtimes(): Promise<Map<string, number>> {
|
|
84
|
+
const url = `${this.normalizedRealmUrl}_mtimes`;
|
|
85
|
+
const response = await this.authenticator.authedRealmFetch(url, {
|
|
86
|
+
headers: { Accept: 'application/vnd.api+json' },
|
|
87
|
+
});
|
|
88
|
+
if (!response.ok) {
|
|
89
|
+
throw new Error(
|
|
90
|
+
`_mtimes fetch failed for ${this.normalizedRealmUrl}: ${response.status} ${response.statusText}`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
const data = (await response.json()) as {
|
|
94
|
+
data?: { attributes?: { mtimes?: Record<string, number> } };
|
|
95
|
+
};
|
|
96
|
+
const mtimes = new Map<string, number>();
|
|
97
|
+
for (const [fileUrl, mtime] of Object.entries(
|
|
98
|
+
data.data?.attributes?.mtimes ?? {},
|
|
99
|
+
)) {
|
|
100
|
+
mtimes.set(fileUrl.replace(this.normalizedRealmUrl, ''), mtime);
|
|
101
|
+
}
|
|
102
|
+
return mtimes;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
get localDir(): string {
|
|
106
|
+
return this.options.localDir;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
get realmUrl(): string {
|
|
110
|
+
return this.normalizedRealmUrl;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
get pendingCount(): number {
|
|
114
|
+
return this.pendingChanges.size;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Verify realm access (via the throw-on-error override), ensure the
|
|
119
|
+
* checkpoint history is initialized, and seed `lastKnownMtimes` from the
|
|
120
|
+
* on-disk manifest if one exists.
|
|
121
|
+
*/
|
|
122
|
+
async initialize(): Promise<void> {
|
|
123
|
+
await this.getRemoteMtimes();
|
|
124
|
+
|
|
125
|
+
if (!(await this.checkpointManager.isInitialized())) {
|
|
126
|
+
await this.checkpointManager.init();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const manifest = await loadManifest(this.options.localDir);
|
|
130
|
+
if (
|
|
131
|
+
manifest &&
|
|
132
|
+
manifest.realmUrl === this.normalizedRealmUrl &&
|
|
133
|
+
manifest.remoteMtimes
|
|
134
|
+
) {
|
|
135
|
+
for (const [file, mtime] of Object.entries(manifest.remoteMtimes)) {
|
|
136
|
+
this.lastKnownMtimes.set(file, mtime);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Poll the realm once and accumulate changes into `pendingChanges`. Returns
|
|
143
|
+
* true if the poll discovered changes that weren't already pending.
|
|
144
|
+
*/
|
|
145
|
+
async poll(): Promise<boolean> {
|
|
146
|
+
const remoteMtimes = await this.getRemoteMtimes();
|
|
147
|
+
let hasNewChanges = false;
|
|
148
|
+
|
|
149
|
+
for (const [file, mtime] of remoteMtimes) {
|
|
150
|
+
if (isProtectedFile(file)) continue;
|
|
151
|
+
const last = this.lastKnownMtimes.get(file);
|
|
152
|
+
if (last === undefined) {
|
|
153
|
+
if (this.recordPending(file, { status: 'added', mtime })) {
|
|
154
|
+
hasNewChanges = true;
|
|
155
|
+
}
|
|
156
|
+
} else if (mtime > last) {
|
|
157
|
+
if (this.recordPending(file, { status: 'modified', mtime })) {
|
|
158
|
+
hasNewChanges = true;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
for (const file of this.lastKnownMtimes.keys()) {
|
|
164
|
+
if (isProtectedFile(file)) continue;
|
|
165
|
+
if (!remoteMtimes.has(file)) {
|
|
166
|
+
const pending = this.pendingChanges.get(file);
|
|
167
|
+
if (pending?.status !== 'deleted') {
|
|
168
|
+
this.pendingChanges.set(file, { status: 'deleted', mtime: 0 });
|
|
169
|
+
hasNewChanges = true;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return hasNewChanges;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Apply all currently pending changes immediately, bypassing the debounce. */
|
|
178
|
+
async flushPending(): Promise<FlushResult> {
|
|
179
|
+
if (this.debounceTimer) {
|
|
180
|
+
clearTimeout(this.debounceTimer);
|
|
181
|
+
this.debounceTimer = null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (this.pendingChanges.size === 0) {
|
|
185
|
+
return { pulled: [], deleted: [], checkpoint: null };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Snapshot then clear before any await — anything an interleaved poll()
|
|
189
|
+
// records during this flush rolls into the next one instead of being
|
|
190
|
+
// dropped by a trailing clear().
|
|
191
|
+
const drained = new Map(this.pendingChanges);
|
|
192
|
+
this.pendingChanges.clear();
|
|
193
|
+
|
|
194
|
+
const pulled: string[] = [];
|
|
195
|
+
const deleted: string[] = [];
|
|
196
|
+
const changes: CheckpointChange[] = [];
|
|
197
|
+
|
|
198
|
+
for (const [file, info] of drained) {
|
|
199
|
+
if (info.status === 'deleted') {
|
|
200
|
+
const localPath = path.join(this.options.localDir, file);
|
|
201
|
+
try {
|
|
202
|
+
await fs.unlink(localPath);
|
|
203
|
+
} catch (err: any) {
|
|
204
|
+
if (err.code !== 'ENOENT') throw err;
|
|
205
|
+
}
|
|
206
|
+
deleted.push(file);
|
|
207
|
+
changes.push({ file, status: 'deleted' });
|
|
208
|
+
} else {
|
|
209
|
+
const localPath = path.join(this.options.localDir, file);
|
|
210
|
+
await this.downloadFile(file, localPath);
|
|
211
|
+
pulled.push(file);
|
|
212
|
+
changes.push({ file, status: info.status });
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
for (const [file, info] of drained) {
|
|
217
|
+
if (info.status === 'deleted') {
|
|
218
|
+
this.lastKnownMtimes.delete(file);
|
|
219
|
+
} else {
|
|
220
|
+
this.lastKnownMtimes.set(file, info.mtime);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
await this.persistManifest(pulled, deleted);
|
|
225
|
+
|
|
226
|
+
const checkpoint = await this.checkpointManager.createCheckpoint(
|
|
227
|
+
'remote',
|
|
228
|
+
changes,
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
return { pulled, deleted, checkpoint };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Schedule a debounced flush. Subsequent calls reset the timer so a burst
|
|
236
|
+
* of changes lands in a single checkpoint.
|
|
237
|
+
*/
|
|
238
|
+
scheduleFlush(onFlush?: (result: FlushResult) => void): void {
|
|
239
|
+
// Closes the race where a poll() in flight at cleanup() resolves AFTER
|
|
240
|
+
// shutdown() and would otherwise arm a new debounceTimer that nothing
|
|
241
|
+
// clears — i.e. work scheduled past the watcher's lifetime.
|
|
242
|
+
if (this.isShutdown) return;
|
|
243
|
+
if (this.debounceTimer) {
|
|
244
|
+
clearTimeout(this.debounceTimer);
|
|
245
|
+
}
|
|
246
|
+
this.debounceTimer = setTimeout(async () => {
|
|
247
|
+
this.debounceTimer = null;
|
|
248
|
+
try {
|
|
249
|
+
const result = await this.flushPending();
|
|
250
|
+
onFlush?.(result);
|
|
251
|
+
} catch (err) {
|
|
252
|
+
console.error(
|
|
253
|
+
`${FG_RED}[${this.name}] Error applying changes:${RESET}`,
|
|
254
|
+
err,
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
}, this.debounceMs);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
shutdown(): void {
|
|
261
|
+
// Set the flag before clearing the timer so a concurrent scheduleFlush()
|
|
262
|
+
// racing the in-flight poll path observes the shutdown state.
|
|
263
|
+
this.isShutdown = true;
|
|
264
|
+
if (this.debounceTimer) {
|
|
265
|
+
clearTimeout(this.debounceTimer);
|
|
266
|
+
this.debounceTimer = null;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
private recordPending(file: string, change: PendingChange): boolean {
|
|
271
|
+
const existing = this.pendingChanges.get(file);
|
|
272
|
+
if (existing && existing.mtime === change.mtime) {
|
|
273
|
+
return false;
|
|
274
|
+
}
|
|
275
|
+
this.pendingChanges.set(file, change);
|
|
276
|
+
return true;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Mutate just the entries that changed in this flush instead of
|
|
280
|
+
// rehashing everything in lastKnownMtimes — keeps each apply O(changed).
|
|
281
|
+
private async persistManifest(
|
|
282
|
+
pulled: string[],
|
|
283
|
+
deleted: string[],
|
|
284
|
+
): Promise<void> {
|
|
285
|
+
const prior = await loadManifest(this.options.localDir);
|
|
286
|
+
const files: Record<string, string> = prior?.files
|
|
287
|
+
? { ...prior.files }
|
|
288
|
+
: {};
|
|
289
|
+
|
|
290
|
+
for (const file of deleted) {
|
|
291
|
+
delete files[file];
|
|
292
|
+
}
|
|
293
|
+
for (const file of pulled) {
|
|
294
|
+
const localPath = path.join(this.options.localDir, file);
|
|
295
|
+
try {
|
|
296
|
+
files[file] = await computeFileHash(localPath);
|
|
297
|
+
} catch (err: any) {
|
|
298
|
+
if (err.code !== 'ENOENT') throw err;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const remoteMtimes: Record<string, number> = {};
|
|
303
|
+
for (const [file, mtime] of this.lastKnownMtimes) {
|
|
304
|
+
if (mtime !== 0) {
|
|
305
|
+
remoteMtimes[file] = mtime;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const manifest: SyncManifest = {
|
|
310
|
+
realmUrl: this.normalizedRealmUrl,
|
|
311
|
+
files,
|
|
312
|
+
};
|
|
313
|
+
if (Object.keys(remoteMtimes).length > 0) {
|
|
314
|
+
manifest.remoteMtimes = remoteMtimes;
|
|
315
|
+
}
|
|
316
|
+
await saveManifest(this.options.localDir, manifest);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export interface WatchRealmsOptions {
|
|
321
|
+
intervalMs?: number;
|
|
322
|
+
debounceMs?: number;
|
|
323
|
+
quiet?: boolean;
|
|
324
|
+
profileManager?: ProfileManager;
|
|
325
|
+
/** Pre-resolved realm secret seed (resolve via `resolveRealmSecretSeed` first). */
|
|
326
|
+
realmSecretSeed?: string;
|
|
327
|
+
/** @internal Test hook: supply an already-constructed authenticator. */
|
|
328
|
+
authenticator?: RealmAuthenticator;
|
|
329
|
+
/** Stops the watch loop when aborted. SIGINT/SIGTERM are wired up when omitted. */
|
|
330
|
+
signal?: AbortSignal;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export interface WatchRealmsResult {
|
|
334
|
+
watchers: RealmWatcher[];
|
|
335
|
+
error?: string;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Programmatic entry point. Returns when the abort signal fires (or the
|
|
340
|
+
* process receives SIGINT/SIGTERM when no signal is supplied). The CLI
|
|
341
|
+
* passes a single spec; the array shape exists for programmatic / test
|
|
342
|
+
* use. The authenticator is resolved once (from `specs[0].realmUrl`) and
|
|
343
|
+
* shared across all specs — multi-realm callers must use realms that
|
|
344
|
+
* share a profile / secret seed.
|
|
345
|
+
*/
|
|
346
|
+
export async function watchRealms(
|
|
347
|
+
specs: WatchRealmSpec[],
|
|
348
|
+
options: WatchRealmsOptions = {},
|
|
349
|
+
): Promise<WatchRealmsResult> {
|
|
350
|
+
if (specs.length === 0) {
|
|
351
|
+
return { watchers: [], error: 'No realms provided to watch.' };
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const intervalMs = options.intervalMs ?? 30_000;
|
|
355
|
+
const debounceMs = options.debounceMs ?? 5_000;
|
|
356
|
+
const quiet = options.quiet ?? false;
|
|
357
|
+
|
|
358
|
+
if (!Number.isFinite(intervalMs) || intervalMs <= 0) {
|
|
359
|
+
return { watchers: [], error: '`intervalMs` must be a positive number.' };
|
|
360
|
+
}
|
|
361
|
+
if (!Number.isFinite(debounceMs) || debounceMs < 0) {
|
|
362
|
+
return {
|
|
363
|
+
watchers: [],
|
|
364
|
+
error: '`debounceMs` must be a non-negative number.',
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
let authenticator: RealmAuthenticator;
|
|
369
|
+
if (options.authenticator) {
|
|
370
|
+
authenticator = options.authenticator;
|
|
371
|
+
} else {
|
|
372
|
+
const resolution = resolveRealmAuthenticator({
|
|
373
|
+
realmUrl: specs[0].realmUrl,
|
|
374
|
+
realmSecretSeed: options.realmSecretSeed,
|
|
375
|
+
profileManager: options.profileManager,
|
|
376
|
+
});
|
|
377
|
+
if (!resolution.ok) {
|
|
378
|
+
return { watchers: [], error: resolution.error };
|
|
379
|
+
}
|
|
380
|
+
authenticator = resolution.authenticator;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Acquire one lock per spec.localDir before initializing any watcher, so a
|
|
384
|
+
// failure rolls back all earlier locks rather than leaving them dangling.
|
|
385
|
+
const lockedDirs: string[] = [];
|
|
386
|
+
for (const spec of specs) {
|
|
387
|
+
const result = await acquireWatchLock(spec.localDir, spec.realmUrl);
|
|
388
|
+
if (!result.ok) {
|
|
389
|
+
for (const dir of lockedDirs) await releaseWatchLock(dir);
|
|
390
|
+
return {
|
|
391
|
+
watchers: [],
|
|
392
|
+
error: formatLockedError(spec.localDir, result.existing),
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
if (result.staleOverwrote && !quiet) {
|
|
396
|
+
console.log(
|
|
397
|
+
`${DIM}[${timestamp()}] overwrote stale lock at ${spec.localDir}${RESET}`,
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
lockedDirs.push(spec.localDir);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const watchers: RealmWatcher[] = [];
|
|
404
|
+
for (const spec of specs) {
|
|
405
|
+
const watcher = new RealmWatcher(spec, authenticator, {
|
|
406
|
+
debounceMs,
|
|
407
|
+
});
|
|
408
|
+
try {
|
|
409
|
+
await watcher.initialize();
|
|
410
|
+
} catch (err) {
|
|
411
|
+
for (const w of watchers) w.shutdown();
|
|
412
|
+
for (const dir of lockedDirs) await releaseWatchLock(dir);
|
|
413
|
+
return {
|
|
414
|
+
watchers: [],
|
|
415
|
+
error: `Failed to initialize watch on ${spec.realmUrl}: ${
|
|
416
|
+
err instanceof Error ? err.message : String(err)
|
|
417
|
+
}`,
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
watchers.push(watcher);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
if (!quiet) {
|
|
424
|
+
console.log(
|
|
425
|
+
`${FG_CYAN}\u21c5 Watching ${watchers.length} realm${watchers.length > 1 ? 's' : ''}:${RESET}`,
|
|
426
|
+
);
|
|
427
|
+
for (const w of watchers) {
|
|
428
|
+
console.log(` ${w.name} ${DIM}\u2192${RESET} ${w.localDir}`);
|
|
429
|
+
}
|
|
430
|
+
console.log(
|
|
431
|
+
` ${DIM}Interval: ${intervalMs / 1000}s, Debounce: ${debounceMs / 1000}s${RESET}`,
|
|
432
|
+
);
|
|
433
|
+
console.log(` ${DIM}Press Ctrl+C to stop${RESET}\n`);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const tickAll = async () => {
|
|
437
|
+
await Promise.all(
|
|
438
|
+
watchers.map(async (w) => {
|
|
439
|
+
try {
|
|
440
|
+
const hasNew = await w.poll();
|
|
441
|
+
if (hasNew) {
|
|
442
|
+
if (!quiet) {
|
|
443
|
+
console.log(
|
|
444
|
+
`${DIM}[${timestamp()}]${RESET} [${w.name}] ${FG_YELLOW}\u26a1 ${w.pendingCount} change(s) detected${RESET}`,
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
w.scheduleFlush((result) => {
|
|
448
|
+
if (!quiet) logFlush(w.name, result);
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
} catch (err) {
|
|
452
|
+
console.error(
|
|
453
|
+
`${FG_RED}[${w.name}] poll error:${RESET}`,
|
|
454
|
+
err instanceof Error ? err.message : err,
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
}),
|
|
458
|
+
);
|
|
459
|
+
};
|
|
460
|
+
|
|
461
|
+
// Self-scheduling tick: the next setTimeout is only armed after the
|
|
462
|
+
// current tickAll resolves, so two polls can never overlap.
|
|
463
|
+
let stopped = false;
|
|
464
|
+
let timeoutId: NodeJS.Timeout | null = null;
|
|
465
|
+
const scheduleNextTick = () => {
|
|
466
|
+
if (stopped) return;
|
|
467
|
+
timeoutId = setTimeout(async () => {
|
|
468
|
+
timeoutId = null;
|
|
469
|
+
if (stopped) return;
|
|
470
|
+
await tickAll();
|
|
471
|
+
scheduleNextTick();
|
|
472
|
+
}, intervalMs);
|
|
473
|
+
};
|
|
474
|
+
|
|
475
|
+
await tickAll();
|
|
476
|
+
scheduleNextTick();
|
|
477
|
+
|
|
478
|
+
await new Promise<void>((resolve) => {
|
|
479
|
+
let sigintHandler: (() => void) | null = null;
|
|
480
|
+
let sigtermHandler: (() => void) | null = null;
|
|
481
|
+
|
|
482
|
+
const cleanup = async () => {
|
|
483
|
+
if (stopped) return;
|
|
484
|
+
stopped = true;
|
|
485
|
+
if (timeoutId !== null) {
|
|
486
|
+
clearTimeout(timeoutId);
|
|
487
|
+
timeoutId = null;
|
|
488
|
+
}
|
|
489
|
+
for (const w of watchers) w.shutdown();
|
|
490
|
+
if (sigintHandler) process.off('SIGINT', sigintHandler);
|
|
491
|
+
if (sigtermHandler) process.off('SIGTERM', sigtermHandler);
|
|
492
|
+
for (const dir of lockedDirs) {
|
|
493
|
+
try {
|
|
494
|
+
await releaseWatchLock(dir);
|
|
495
|
+
} catch {
|
|
496
|
+
// Best effort \u2014 a leftover lock will be detected as stale next run.
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
resolve();
|
|
500
|
+
};
|
|
501
|
+
|
|
502
|
+
if (options.signal) {
|
|
503
|
+
if (options.signal.aborted) {
|
|
504
|
+
void cleanup();
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
options.signal.addEventListener('abort', () => void cleanup(), {
|
|
508
|
+
once: true,
|
|
509
|
+
});
|
|
510
|
+
} else {
|
|
511
|
+
sigintHandler = () => {
|
|
512
|
+
if (!quiet) console.log(`\n${FG_CYAN}\u21c5 Watch stopped${RESET}`);
|
|
513
|
+
void cleanup();
|
|
514
|
+
};
|
|
515
|
+
sigtermHandler = sigintHandler;
|
|
516
|
+
process.on('SIGINT', sigintHandler);
|
|
517
|
+
process.on('SIGTERM', sigtermHandler);
|
|
518
|
+
}
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
return { watchers };
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function formatLockedError(localDir: string, info: WatchLockInfo): string {
|
|
525
|
+
return (
|
|
526
|
+
`A boxel realm watch process is already active for ${localDir} ` +
|
|
527
|
+
`(pid ${info.pid}, started ${info.startedAt}). Stop it before starting ` +
|
|
528
|
+
`a new one, or remove ${path.join(localDir, '.boxel-watch.lock')} if it's stale.`
|
|
529
|
+
);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function logFlush(name: string, result: FlushResult): void {
|
|
533
|
+
const total = result.pulled.length + result.deleted.length;
|
|
534
|
+
if (total === 0) return;
|
|
535
|
+
console.log(
|
|
536
|
+
`${DIM}[${timestamp()}]${RESET} [${name}] ${FG_GREEN}applied ${total} change(s)${RESET} (${result.pulled.length} pulled, ${result.deleted.length} deleted)`,
|
|
537
|
+
);
|
|
538
|
+
if (result.checkpoint) {
|
|
539
|
+
const tag = result.checkpoint.isMajor ? '[MAJOR]' : '[minor]';
|
|
540
|
+
console.log(
|
|
541
|
+
` ${DIM}Checkpoint:${RESET} ${result.checkpoint.shortHash} ${tag} ${result.checkpoint.message}`,
|
|
542
|
+
);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function deriveRealmName(normalizedUrl: string): string {
|
|
547
|
+
const parts = normalizedUrl.replace(/\/$/, '').split('/');
|
|
548
|
+
return parts.slice(-2).join('/');
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function timestamp(): string {
|
|
552
|
+
return new Date().toLocaleTimeString();
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function parsePositiveSeconds(name: string): (value: string) => number {
|
|
556
|
+
return (value: string) => {
|
|
557
|
+
const n = Number.parseFloat(value);
|
|
558
|
+
if (!Number.isFinite(n) || n <= 0) {
|
|
559
|
+
throw new InvalidArgumentError(`${name} must be a positive number.`);
|
|
560
|
+
}
|
|
561
|
+
return n;
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function parseNonNegativeSeconds(name: string): (value: string) => number {
|
|
566
|
+
return (value: string) => {
|
|
567
|
+
const n = Number.parseFloat(value);
|
|
568
|
+
if (!Number.isFinite(n) || n < 0) {
|
|
569
|
+
throw new InvalidArgumentError(`${name} must be a non-negative number.`);
|
|
570
|
+
}
|
|
571
|
+
return n;
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
export function registerWatchCommand(realm: Command): void {
|
|
576
|
+
realm
|
|
577
|
+
.command('watch')
|
|
578
|
+
.description(
|
|
579
|
+
'Watch a Boxel realm for server-side changes and pull them into a local directory',
|
|
580
|
+
)
|
|
581
|
+
.argument(
|
|
582
|
+
'<realm-url>',
|
|
583
|
+
'The URL of the realm to watch (e.g., https://app.boxel.ai/demo/)',
|
|
584
|
+
)
|
|
585
|
+
.argument('<local-dir>', 'The local directory to write changes into')
|
|
586
|
+
.option(
|
|
587
|
+
'-i, --interval <seconds>',
|
|
588
|
+
'Polling interval in seconds',
|
|
589
|
+
parsePositiveSeconds('--interval'),
|
|
590
|
+
30,
|
|
591
|
+
)
|
|
592
|
+
.option(
|
|
593
|
+
'-d, --debounce <seconds>',
|
|
594
|
+
'Seconds to wait after a burst of changes before applying them',
|
|
595
|
+
parseNonNegativeSeconds('--debounce'),
|
|
596
|
+
5,
|
|
597
|
+
)
|
|
598
|
+
.option(
|
|
599
|
+
'--realm-secret-seed',
|
|
600
|
+
'Administrative auth: prompt for a realm secret seed and mint a JWT locally instead of using a Matrix profile (env: BOXEL_REALM_SECRET_SEED)',
|
|
601
|
+
)
|
|
602
|
+
.action(
|
|
603
|
+
async (
|
|
604
|
+
realmUrl: string,
|
|
605
|
+
localDir: string,
|
|
606
|
+
options: {
|
|
607
|
+
interval: number;
|
|
608
|
+
debounce: number;
|
|
609
|
+
realmSecretSeed?: boolean;
|
|
610
|
+
},
|
|
611
|
+
) => {
|
|
612
|
+
const realmSecretSeed = await resolveRealmSecretSeed(
|
|
613
|
+
options.realmSecretSeed === true,
|
|
614
|
+
);
|
|
615
|
+
const result = await watchRealms([{ realmUrl, localDir }], {
|
|
616
|
+
intervalMs: options.interval * 1000,
|
|
617
|
+
debounceMs: options.debounce * 1000,
|
|
618
|
+
realmSecretSeed,
|
|
619
|
+
});
|
|
620
|
+
if (result.error) {
|
|
621
|
+
console.error(`${FG_RED}Error:${RESET} ${result.error}`);
|
|
622
|
+
process.exit(1);
|
|
623
|
+
}
|
|
624
|
+
},
|
|
625
|
+
);
|
|
626
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Command } from 'commander';
|
|
2
2
|
import { getProfileManager, type ProfileManager } from '../lib/profile-manager';
|
|
3
3
|
import { FG_GREEN, FG_RED, FG_CYAN, DIM, RESET } from '../lib/colors';
|
|
4
|
+
import { cliLog } from '../lib/cli-log';
|
|
4
5
|
|
|
5
6
|
export interface RunCommandResult {
|
|
6
7
|
status: 'ready' | 'error' | 'unusable';
|
|
@@ -150,7 +151,7 @@ export function registerRunCommand(program: Command): void {
|
|
|
150
151
|
}
|
|
151
152
|
|
|
152
153
|
if (opts.json) {
|
|
153
|
-
|
|
154
|
+
cliLog.output(JSON.stringify(result, null, 2));
|
|
154
155
|
} else {
|
|
155
156
|
console.log(
|
|
156
157
|
`${DIM}Status:${RESET} ${statusColor(result.status)}${result.status}${RESET}`,
|
|
@@ -158,9 +159,9 @@ export function registerRunCommand(program: Command): void {
|
|
|
158
159
|
if (result.result) {
|
|
159
160
|
console.log(`${DIM}Result:${RESET}`);
|
|
160
161
|
try {
|
|
161
|
-
|
|
162
|
+
cliLog.output(JSON.stringify(JSON.parse(result.result), null, 2));
|
|
162
163
|
} catch {
|
|
163
|
-
|
|
164
|
+
cliLog.output(result.result);
|
|
164
165
|
}
|
|
165
166
|
}
|
|
166
167
|
if (result.error) {
|