@cardstack/boxel-cli 0.2.0-unstable.294 → 0.2.0-unstable.298

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cardstack/boxel-cli",
3
- "version": "0.2.0-unstable.294",
3
+ "version": "0.2.0-unstable.298",
4
4
  "license": "MIT",
5
5
  "description": "CLI tools for Boxel workspace management",
6
6
  "main": "./dist/index.js",
@@ -52,8 +52,8 @@
52
52
  "vite": "^6.3.2",
53
53
  "vitest": "^2.1.9",
54
54
  "@cardstack/local-types": "0.0.0",
55
- "@cardstack/postgres": "0.0.0",
56
- "@cardstack/runtime-common": "1.0.0"
55
+ "@cardstack/runtime-common": "1.0.0",
56
+ "@cardstack/postgres": "0.0.0"
57
57
  },
58
58
  "publishConfig": {
59
59
  "access": "public",
@@ -48,6 +48,12 @@ interface PendingChange {
48
48
  export interface FlushResult {
49
49
  pulled: string[];
50
50
  deleted: string[];
51
+ /**
52
+ * Files whose remote-side change was detected but not applied because the
53
+ * local copy diverges from the sync manifest. Cleared by passing
54
+ * `overwriteLocal: true`, or by reconciling via `boxel realm sync`.
55
+ */
56
+ skipped: string[];
51
57
  checkpoint: Checkpoint | null;
52
58
  }
53
59
 
@@ -59,6 +65,7 @@ export interface FlushResult {
59
65
  export class RealmWatcher extends RealmSyncBase {
60
66
  readonly name: string;
61
67
  private readonly debounceMs: number;
68
+ private readonly overwriteLocal: boolean;
62
69
  private readonly checkpointManager: CheckpointManager;
63
70
  private lastKnownMtimes = new Map<string, number>();
64
71
  private pendingChanges = new Map<string, PendingChange>();
@@ -68,10 +75,11 @@ export class RealmWatcher extends RealmSyncBase {
68
75
  constructor(
69
76
  spec: WatchRealmSpec,
70
77
  authenticator: RealmAuthenticator,
71
- options: { debounceMs: number },
78
+ options: { debounceMs: number; overwriteLocal?: boolean },
72
79
  ) {
73
80
  super({ realmUrl: spec.realmUrl, localDir: spec.localDir }, authenticator);
74
81
  this.debounceMs = options.debounceMs;
82
+ this.overwriteLocal = options.overwriteLocal ?? false;
75
83
  this.checkpointManager = new CheckpointManager(spec.localDir);
76
84
  this.name = deriveRealmName(this.normalizedRealmUrl);
77
85
  }
@@ -186,7 +194,7 @@ export class RealmWatcher extends RealmSyncBase {
186
194
  }
187
195
 
188
196
  if (this.pendingChanges.size === 0) {
189
- return { pulled: [], deleted: [], checkpoint: null };
197
+ return { pulled: [], deleted: [], skipped: [], checkpoint: null };
190
198
  }
191
199
 
192
200
  // Snapshot then clear before any await — anything an interleaved poll()
@@ -197,11 +205,39 @@ export class RealmWatcher extends RealmSyncBase {
197
205
 
198
206
  const pulled: string[] = [];
199
207
  const deleted: string[] = [];
208
+ const skipped: string[] = [];
200
209
  const changes: CheckpointChange[] = [];
201
210
 
211
+ // Load the manifest once per flush so we hash-compare against a single
212
+ // baseline. Skipped when `overwriteLocal` is on — we never look. A
213
+ // manifest from a different realm is treated as "no manifest" (same
214
+ // policy as `initialize()` and `sync()`), so every local file looks
215
+ // unrecorded and is protected by the divergence gate.
216
+ let manifest: SyncManifest | null = null;
217
+ if (!this.overwriteLocal) {
218
+ const loaded = await loadManifest(this.options.localDir);
219
+ if (loaded && loaded.realmUrl === this.normalizedRealmUrl) {
220
+ manifest = loaded;
221
+ }
222
+ }
223
+
202
224
  for (const [file, info] of drained) {
225
+ const localPath = path.join(this.options.localDir, file);
226
+
227
+ if (
228
+ !this.overwriteLocal &&
229
+ (await this.localDivergesFromManifest(
230
+ localPath,
231
+ file,
232
+ manifest,
233
+ info.status,
234
+ ))
235
+ ) {
236
+ skipped.push(file);
237
+ continue;
238
+ }
239
+
203
240
  if (info.status === 'deleted') {
204
- const localPath = path.join(this.options.localDir, file);
205
241
  try {
206
242
  await fs.unlink(localPath);
207
243
  } catch (err: any) {
@@ -210,14 +246,18 @@ export class RealmWatcher extends RealmSyncBase {
210
246
  deleted.push(file);
211
247
  changes.push({ file, status: 'deleted' });
212
248
  } else {
213
- const localPath = path.join(this.options.localDir, file);
214
249
  await this.downloadFile(file, localPath);
215
250
  pulled.push(file);
216
251
  changes.push({ file, status: info.status });
217
252
  }
218
253
  }
219
254
 
255
+ // Only advance mtimes for files we actually applied. Skipped entries
256
+ // keep their old `lastKnownMtimes` value (or absence) so the next poll
257
+ // re-detects them — the warning persists until reconciled.
258
+ const skippedSet = new Set(skipped);
220
259
  for (const [file, info] of drained) {
260
+ if (skippedSet.has(file)) continue;
221
261
  if (info.status === 'deleted') {
222
262
  this.lastKnownMtimes.delete(file);
223
263
  } else {
@@ -225,14 +265,45 @@ export class RealmWatcher extends RealmSyncBase {
225
265
  }
226
266
  }
227
267
 
228
- await this.persistManifest(pulled, deleted);
268
+ let checkpoint: Checkpoint | null = null;
269
+ if (changes.length > 0) {
270
+ await this.persistManifest(pulled, deleted);
271
+ checkpoint = await this.checkpointManager.createCheckpoint(
272
+ 'remote',
273
+ changes,
274
+ );
275
+ }
229
276
 
230
- const checkpoint = await this.checkpointManager.createCheckpoint(
231
- 'remote',
232
- changes,
233
- );
277
+ return { pulled, deleted, skipped, checkpoint };
278
+ }
234
279
 
235
- return { pulled, deleted, checkpoint };
280
+ /**
281
+ * True when the local copy of `relPath` no longer matches the sync
282
+ * manifest: hash mismatch, missing manifest record for a present file,
283
+ * or — for non-delete operations — the user deleted the file locally
284
+ * while the manifest still recorded it (the delete-vs-change conflict
285
+ * that `sync-logic.ts` classifies via `'deleted' + 'changed' = conflict`).
286
+ */
287
+ private async localDivergesFromManifest(
288
+ localPath: string,
289
+ relPath: string,
290
+ manifest: SyncManifest | null,
291
+ operation: 'added' | 'modified' | 'deleted',
292
+ ): Promise<boolean> {
293
+ let localHash: string;
294
+ try {
295
+ localHash = await computeFileHash(localPath);
296
+ } catch (err: any) {
297
+ if (err.code !== 'ENOENT') throw err;
298
+ // Remote also deleting → no local work to lose. Manifest had no
299
+ // record → first-time pull, nothing to protect. Manifest had a
300
+ // record and remote wants to write → that's the conflict.
301
+ if (operation === 'deleted') return false;
302
+ return manifest?.files[relPath] !== undefined;
303
+ }
304
+ const manifestHash = manifest?.files[relPath];
305
+ if (manifestHash === undefined) return true;
306
+ return localHash !== manifestHash;
236
307
  }
237
308
 
238
309
  /**
@@ -286,10 +357,13 @@ export class RealmWatcher extends RealmSyncBase {
286
357
  pulled: string[],
287
358
  deleted: string[],
288
359
  ): Promise<void> {
360
+ // Drop file hashes from a manifest belonging to a different realm —
361
+ // otherwise we'd persist cross-realm entries under our `realmUrl`.
362
+ // Matches the policy used by `flushPending()` and `initialize()`.
289
363
  const prior = await loadManifest(this.options.localDir);
290
- const files: Record<string, string> = prior?.files
291
- ? { ...prior.files }
292
- : {};
364
+ const priorFiles =
365
+ prior && prior.realmUrl === this.normalizedRealmUrl ? prior.files : null;
366
+ const files: Record<string, string> = priorFiles ? { ...priorFiles } : {};
293
367
 
294
368
  for (const file of deleted) {
295
369
  delete files[file];
@@ -332,6 +406,12 @@ export interface WatchRealmsOptions {
332
406
  authenticator?: RealmAuthenticator;
333
407
  /** Stops the watch loop when aborted. SIGINT/SIGTERM are wired up when omitted. */
334
408
  signal?: AbortSignal;
409
+ /**
410
+ * When true, downloads always overwrite the local file. When false
411
+ * (default), files whose local copy diverges from the sync manifest are
412
+ * skipped with a warning instead of overwritten.
413
+ */
414
+ overwriteLocal?: boolean;
335
415
  }
336
416
 
337
417
  export interface WatchRealmsResult {
@@ -358,6 +438,7 @@ export async function watchRealms(
358
438
  const intervalMs = options.intervalMs ?? 30_000;
359
439
  const debounceMs = options.debounceMs ?? 5_000;
360
440
  const quiet = options.quiet ?? false;
441
+ const overwriteLocal = options.overwriteLocal ?? false;
361
442
 
362
443
  if (!Number.isFinite(intervalMs) || intervalMs <= 0) {
363
444
  return { watchers: [], error: '`intervalMs` must be a positive number.' };
@@ -408,6 +489,7 @@ export async function watchRealms(
408
489
  for (const spec of specs) {
409
490
  const watcher = new RealmWatcher(spec, authenticator, {
410
491
  debounceMs,
492
+ overwriteLocal,
411
493
  });
412
494
  try {
413
495
  await watcher.initialize();
@@ -546,14 +628,20 @@ function formatLockedError(localDir: string, info: WatchLockInfo): string {
546
628
 
547
629
  function logFlush(name: string, result: FlushResult): void {
548
630
  const total = result.pulled.length + result.deleted.length;
549
- if (total === 0) return;
550
- console.log(
551
- `${DIM}[${timestamp()}]${RESET} [${name}] ${FG_GREEN}applied ${total} change(s)${RESET} (${result.pulled.length} pulled, ${result.deleted.length} deleted)`,
552
- );
553
- if (result.checkpoint) {
554
- const tag = result.checkpoint.isMajor ? '[MAJOR]' : '[minor]';
631
+ if (total > 0) {
632
+ console.log(
633
+ `${DIM}[${timestamp()}]${RESET} [${name}] ${FG_GREEN}applied ${total} change(s)${RESET} (${result.pulled.length} pulled, ${result.deleted.length} deleted)`,
634
+ );
635
+ if (result.checkpoint) {
636
+ const tag = result.checkpoint.isMajor ? '[MAJOR]' : '[minor]';
637
+ console.log(
638
+ ` ${DIM}Checkpoint:${RESET} ${result.checkpoint.shortHash} ${tag} ${result.checkpoint.message}`,
639
+ );
640
+ }
641
+ }
642
+ for (const file of result.skipped) {
555
643
  console.log(
556
- ` ${DIM}Checkpoint:${RESET} ${result.checkpoint.shortHash} ${tag} ${result.checkpoint.message}`,
644
+ `${DIM}[${timestamp()}]${RESET} [${name}] ${FG_YELLOW} skipped ${file}: local diverges from sync manifest (rerun with --overwrite-local to discard, or \`boxel realm sync\` to reconcile)${RESET}`,
557
645
  );
558
646
  }
559
647
  }
@@ -614,6 +702,10 @@ export function registerStartCommand(watch: Command): void {
614
702
  '--realm-secret-seed',
615
703
  'Administrative auth: prompt for a realm secret seed and mint a JWT locally instead of using a Matrix profile (env: BOXEL_REALM_SECRET_SEED)',
616
704
  )
705
+ .option(
706
+ '--overwrite-local',
707
+ 'Overwrite local files when the remote changes. Default: skip + warn when the local copy diverges from the sync manifest.',
708
+ )
617
709
  .action(
618
710
  async (
619
711
  realmUrl: string,
@@ -622,6 +714,7 @@ export function registerStartCommand(watch: Command): void {
622
714
  interval: number;
623
715
  debounce: number;
624
716
  realmSecretSeed?: boolean;
717
+ overwriteLocal?: boolean;
625
718
  },
626
719
  ) => {
627
720
  const realmSecretSeed = await resolveRealmSecretSeed(
@@ -631,6 +724,7 @@ export function registerStartCommand(watch: Command): void {
631
724
  intervalMs: options.interval * 1000,
632
725
  debounceMs: options.debounce * 1000,
633
726
  realmSecretSeed,
727
+ overwriteLocal: options.overwriteLocal === true,
634
728
  });
635
729
  if (result.error) {
636
730
  console.error(`${FG_RED}Error:${RESET} ${result.error}`);