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

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.
@@ -1,27 +1,31 @@
1
1
  import { InvalidArgumentError, type Command } from 'commander';
2
2
  import * as fs from 'fs/promises';
3
3
  import * as path from 'path';
4
- import { RealmSyncBase, isProtectedFile } from '../../lib/realm-sync-base';
4
+ import { RealmSyncBase, isProtectedFile } from '../../../lib/realm-sync-base';
5
5
  import {
6
6
  CheckpointManager,
7
7
  type Checkpoint,
8
8
  type CheckpointChange,
9
- } from '../../lib/checkpoint-manager';
9
+ } from '../../../lib/checkpoint-manager';
10
10
  import {
11
11
  type SyncManifest,
12
12
  computeFileHash,
13
13
  loadManifest,
14
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';
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
20
  import {
21
21
  acquireWatchLock,
22
22
  releaseWatchLock,
23
23
  type WatchLockInfo,
24
- } from '../../lib/watch-lock';
24
+ } from '../../../lib/watch-lock';
25
+ import {
26
+ registerProcess,
27
+ unregisterCurrentProcess,
28
+ } from '../../../lib/watch-process-registry';
25
29
  import {
26
30
  FG_CYAN,
27
31
  FG_GREEN,
@@ -29,7 +33,7 @@ import {
29
33
  FG_YELLOW,
30
34
  DIM,
31
35
  RESET,
32
- } from '../../lib/colors';
36
+ } from '../../../lib/colors';
33
37
 
34
38
  export interface WatchRealmSpec {
35
39
  realmUrl: string;
@@ -472,6 +476,12 @@ export async function watchRealms(
472
476
  }, intervalMs);
473
477
  };
474
478
 
479
+ try {
480
+ await registerProcess(specs.map((s) => s.localDir).join(', '));
481
+ } catch {
482
+ // Best effort — registry failures must never block the watch.
483
+ }
484
+
475
485
  await tickAll();
476
486
  scheduleNextTick();
477
487
 
@@ -496,6 +506,11 @@ export async function watchRealms(
496
506
  // Best effort \u2014 a leftover lock will be detected as stale next run.
497
507
  }
498
508
  }
509
+ try {
510
+ await unregisterCurrentProcess();
511
+ } catch {
512
+ // Best effort \u2014 leftover entries are pruned on next read.
513
+ }
499
514
  resolve();
500
515
  };
501
516
 
@@ -572,11 +587,11 @@ function parseNonNegativeSeconds(name: string): (value: string) => number {
572
587
  };
573
588
  }
574
589
 
575
- export function registerWatchCommand(realm: Command): void {
576
- realm
577
- .command('watch')
590
+ export function registerStartCommand(watch: Command): void {
591
+ watch
592
+ .command('start')
578
593
  .description(
579
- 'Watch a Boxel realm for server-side changes and pull them into a local directory',
594
+ 'Start watching a Boxel realm for server-side changes and pull them into a local directory',
580
595
  )
581
596
  .argument(
582
597
  '<realm-url>',
@@ -0,0 +1,151 @@
1
+ import { execSync } from 'child_process';
2
+ import type { Command } from 'commander';
3
+ import { listRegisteredProcesses } from '../../../lib/watch-process-registry';
4
+ import { DIM, FG_GREEN, FG_RED, RESET } from '../../../lib/colors';
5
+
6
+ export interface StoppedProcess {
7
+ pid: number;
8
+ workspace: string;
9
+ }
10
+
11
+ export interface StopResult {
12
+ stopped: StoppedProcess[];
13
+ failed: StoppedProcess[];
14
+ }
15
+
16
+ const SETTLE_MS = 200;
17
+
18
+ function sleep(ms: number): Promise<void> {
19
+ return new Promise((resolve) => setTimeout(resolve, ms));
20
+ }
21
+
22
+ function signalProcess(pid: number): { ok: boolean; alreadyGone: boolean } {
23
+ try {
24
+ if (process.platform === 'win32') {
25
+ try {
26
+ process.kill(pid);
27
+ } catch {
28
+ execSync(`taskkill /PID ${pid} /F`, { stdio: 'ignore' });
29
+ }
30
+ } else {
31
+ process.kill(pid, 'SIGINT');
32
+ }
33
+ return { ok: true, alreadyGone: false };
34
+ } catch (err: any) {
35
+ if (err?.code === 'ESRCH') {
36
+ return { ok: true, alreadyGone: true };
37
+ }
38
+ return { ok: false, alreadyGone: false };
39
+ }
40
+ }
41
+
42
+ interface PsHit {
43
+ pid: number;
44
+ workspace: string;
45
+ }
46
+
47
+ function findViaProcessTable(): PsHit[] {
48
+ if (process.platform === 'win32') return [];
49
+ let output: string;
50
+ try {
51
+ output = execSync(
52
+ 'ps aux | grep -E "(tsx[[:space:]].*src/index\\.ts[[:space:]]+realm[[:space:]]+watch[[:space:]]+start|[[:space:]]boxel[[:space:]]+realm[[:space:]]+watch[[:space:]]+start|node[[:space:]].*boxel[[:space:]]+realm[[:space:]]+watch[[:space:]]+start)" | grep -v grep | grep -v "[[:space:]]stop"',
53
+ { encoding: 'utf8' },
54
+ ).trim();
55
+ } catch {
56
+ return [];
57
+ }
58
+ if (!output) return [];
59
+
60
+ const hits: PsHit[] = [];
61
+ const seen = new Set<number>();
62
+ for (const line of output.split('\n')) {
63
+ if (!line) continue;
64
+ const parts = line.trim().split(/\s+/);
65
+ const pid = Number.parseInt(parts[1] ?? '', 10);
66
+ if (!Number.isFinite(pid) || seen.has(pid)) continue;
67
+ seen.add(pid);
68
+
69
+ let workspace = '.';
70
+ const match = line.match(/\bstart\s+\S+\s+(\S+)/);
71
+ if (match && match[1] && !match[1].startsWith('-')) {
72
+ workspace = match[1];
73
+ }
74
+ hits.push({ pid, workspace });
75
+ }
76
+ return hits;
77
+ }
78
+
79
+ export async function stopWatchProcesses(): Promise<StopResult> {
80
+ const stopped: StoppedProcess[] = [];
81
+ const failed: StoppedProcess[] = [];
82
+ const targetedPids = new Set<number>();
83
+
84
+ const registered = await listRegisteredProcesses();
85
+ for (const proc of registered) {
86
+ if (proc.pid === process.pid) continue;
87
+ targetedPids.add(proc.pid);
88
+ const result = signalProcess(proc.pid);
89
+ const record: StoppedProcess = { pid: proc.pid, workspace: proc.workspace };
90
+ if (result.ok) {
91
+ stopped.push(record);
92
+ } else {
93
+ failed.push(record);
94
+ }
95
+ }
96
+
97
+ for (const hit of findViaProcessTable()) {
98
+ if (hit.pid === process.pid) continue;
99
+ if (targetedPids.has(hit.pid)) continue;
100
+ targetedPids.add(hit.pid);
101
+ const result = signalProcess(hit.pid);
102
+ const record: StoppedProcess = { pid: hit.pid, workspace: hit.workspace };
103
+ if (result.ok) {
104
+ stopped.push(record);
105
+ } else {
106
+ failed.push(record);
107
+ }
108
+ }
109
+
110
+ if (stopped.length > 0) {
111
+ await sleep(SETTLE_MS);
112
+ // Trigger another prune so the registry doesn't keep stale entries
113
+ // for processes that exited cleanly above.
114
+ await listRegisteredProcesses();
115
+ }
116
+
117
+ return { stopped, failed };
118
+ }
119
+
120
+ function printResult(result: StopResult): void {
121
+ if (result.stopped.length === 0 && result.failed.length === 0) {
122
+ console.log('No running watch processes found.');
123
+ return;
124
+ }
125
+ for (const proc of result.stopped) {
126
+ console.log(
127
+ ` ${DIM}⇅${RESET} Stopped: boxel realm watch ${proc.workspace} (PID ${proc.pid})`,
128
+ );
129
+ }
130
+ for (const proc of result.failed) {
131
+ console.log(
132
+ ` ${FG_RED}×${RESET} Failed to stop: boxel realm watch ${proc.workspace} (PID ${proc.pid})`,
133
+ );
134
+ }
135
+ if (result.stopped.length > 0) {
136
+ const plural = result.stopped.length > 1 ? 'es' : '';
137
+ console.log(
138
+ `\n${FG_GREEN}✓ Stopped ${result.stopped.length} process${plural}${RESET}`,
139
+ );
140
+ }
141
+ }
142
+
143
+ export function registerStopCommand(watch: Command): void {
144
+ watch
145
+ .command('stop')
146
+ .description('Stop all running boxel realm watch processes')
147
+ .action(async () => {
148
+ const result = await stopWatchProcesses();
149
+ printResult(result);
150
+ });
151
+ }
@@ -450,6 +450,18 @@ export class BoxelCLIClient {
450
450
  return this.pm.authedRealmServerFetch(input, init);
451
451
  }
452
452
 
453
+ /**
454
+ * Return the realm-server JWT, fetching one via Matrix login if no token
455
+ * is cached. Use only when you need to hand the bare token to a downstream
456
+ * client that can't go through `authedServerFetch` (e.g. opencode's
457
+ * static-Authorization provider config). Prefer `authedServerFetch` for
458
+ * server endpoints called from JS — it handles per-request 401 retries
459
+ * that this getter cannot.
460
+ */
461
+ async getServerToken(): Promise<string> {
462
+ return this.pm.getOrRefreshServerToken();
463
+ }
464
+
453
465
  async pull(
454
466
  realmUrl: string,
455
467
  localDir: string,
@@ -364,41 +364,46 @@ export class CheckpointManager {
364
364
  });
365
365
 
366
366
  return Promise.all(
367
- lines.map(async (line) => {
368
- const [hash, shortHash, subject, dateStr] = line.split('|');
369
-
370
- const isMajor = subject.includes('[MAJOR]');
371
- const source = subject.includes('[local]')
372
- ? ('local' as const)
373
- : subject.includes('[remote]')
374
- ? ('remote' as const)
375
- : ('manual' as const);
376
-
377
- const message = subject
378
- .replace(/\[(MAJOR|minor)\]\s*/i, '')
379
- .replace(/\[(local|remote|manual)\]\s*/i, '');
380
-
381
- const stats = await this.getCommitStats(hash);
382
-
383
- const milestoneName = milestones.get(hash);
384
- const isMilestone = !!milestoneName;
385
-
386
- return {
387
- hash,
388
- shortHash,
389
- message,
390
- description: '',
391
- date: new Date(dateStr),
392
- isMajor,
393
- source,
394
- isMilestone,
395
- milestoneName,
396
- ...stats,
397
- };
398
- }),
367
+ lines.map((line) => this.parseCheckpointLine(line, milestones)),
399
368
  );
400
369
  }
401
370
 
371
+ private async parseCheckpointLine(
372
+ line: string,
373
+ milestones: Map<string, string>,
374
+ ): Promise<Checkpoint> {
375
+ const [hash, shortHash, subject, dateStr] = line.split('|');
376
+
377
+ const isMajor = subject.includes('[MAJOR]');
378
+ const source = subject.includes('[local]')
379
+ ? ('local' as const)
380
+ : subject.includes('[remote]')
381
+ ? ('remote' as const)
382
+ : ('manual' as const);
383
+
384
+ const message = subject
385
+ .replace(/\[(MAJOR|minor)\]\s*/i, '')
386
+ .replace(/\[(local|remote|manual)\]\s*/i, '');
387
+
388
+ const stats = await this.getCommitStats(hash);
389
+
390
+ const milestoneName = milestones.get(hash);
391
+ const isMilestone = !!milestoneName;
392
+
393
+ return {
394
+ hash,
395
+ shortHash,
396
+ message,
397
+ description: '',
398
+ date: new Date(dateStr),
399
+ isMajor,
400
+ source,
401
+ isMilestone,
402
+ milestoneName,
403
+ ...stats,
404
+ };
405
+ }
406
+
402
407
  private async getCommitStats(hash: string): Promise<{
403
408
  filesChanged: number;
404
409
  insertions: number;
@@ -575,8 +580,36 @@ export class CheckpointManager {
575
580
  }
576
581
 
577
582
  async getMilestones(): Promise<Checkpoint[]> {
578
- const all = await this.getCheckpoints(100);
579
- return all.filter((cp) => cp.isMilestone);
583
+ if (!(await this.isInitialized())) {
584
+ return [];
585
+ }
586
+ const milestones = await this.getAllMilestones();
587
+ if (milestones.size === 0) {
588
+ return [];
589
+ }
590
+
591
+ // Enumerate from the milestone tags directly so the result is complete
592
+ // regardless of how deep the tagged checkpoints sit in history. `--no-walk`
593
+ // limits `git log` to just the listed commits — no traversal, no implicit
594
+ // cap.
595
+ const format = '%H|%h|%s|%aI|%an';
596
+ const log = await this.git(
597
+ 'log',
598
+ '--no-walk',
599
+ `--format=${format}`,
600
+ ...milestones.keys(),
601
+ );
602
+
603
+ if (!log.trim()) {
604
+ return [];
605
+ }
606
+
607
+ return Promise.all(
608
+ log
609
+ .trim()
610
+ .split('\n')
611
+ .map((line) => this.parseCheckpointLine(line, milestones)),
612
+ );
580
613
  }
581
614
 
582
615
  private git(...args: string[]): Promise<string> {
@@ -1,6 +1,7 @@
1
1
  import * as fs from 'fs';
2
2
  import * as path from 'path';
3
3
  import * as os from 'os';
4
+ import jwt from 'jsonwebtoken';
4
5
  import { FG_YELLOW, FG_CYAN, FG_MAGENTA, DIM, BOLD, RESET } from './colors';
5
6
  import {
6
7
  matrixLogin,
@@ -16,6 +17,31 @@ import type { RealmAuthenticator } from './realm-authenticator';
16
17
  const DEFAULT_CONFIG_DIR = path.join(os.homedir(), '.boxel-cli');
17
18
  const PROFILES_FILENAME = 'profiles.json';
18
19
 
20
+ /**
21
+ * Tokens issued by the realm server carry a 7-day TTL. Re-mint when
22
+ * there's less than a day left so a long-running operation (or a
23
+ * downstream consumer that bakes the token into a static header, like
24
+ * opencode's passthrough provider) doesn't get a 401 mid-flight.
25
+ *
26
+ * Decode-only — we don't verify the signature; the realm server does
27
+ * that on every request. We only care about the `exp` claim.
28
+ */
29
+ const SERVER_TOKEN_EXPIRY_SAFETY_MARGIN_SEC = 86400; // 1 day
30
+
31
+ function isJwtNearExpiry(
32
+ token: string,
33
+ safetyMarginSec = SERVER_TOKEN_EXPIRY_SAFETY_MARGIN_SEC,
34
+ ): boolean {
35
+ // Tokens are cached verbatim from the realm server's `Authorization`
36
+ // response header, so they're prefixed with `Bearer ` — strip it before
37
+ // decoding or jsonwebtoken returns null and we'd refresh on every call.
38
+ let raw = token.replace(/^Bearer\s+/i, '');
39
+ let decoded = jwt.decode(raw) as { exp?: number } | null;
40
+ if (!decoded?.exp) return true; // unparseable / missing exp → treat as expired
41
+ let nowSec = Math.floor(Date.now() / 1000);
42
+ return decoded.exp - nowSec < safetyMarginSec;
43
+ }
44
+
19
45
  export const NO_ACTIVE_PROFILE_ERROR =
20
46
  'No active profile. Run `boxel profile add` to create one.';
21
47
 
@@ -371,7 +397,7 @@ export class ProfileManager implements RealmAuthenticator {
371
397
 
372
398
  async getOrRefreshServerToken(): Promise<string> {
373
399
  let cached = this.getRealmServerToken();
374
- if (cached) {
400
+ if (cached && !isJwtNearExpiry(cached)) {
375
401
  return cached;
376
402
  }
377
403
  let matrixAuth = await this.loginToMatrix();
@@ -0,0 +1,243 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import { isQuiet } from './cli-log';
4
+
5
+ export interface MisplacedLocalRealmEntry {
6
+ manifestPath: string;
7
+ currentDir: string;
8
+ expectedDir: string;
9
+ realmUrl: string;
10
+ }
11
+
12
+ interface MinimalManifest {
13
+ realmUrl: string;
14
+ }
15
+
16
+ let didWarnInProcess = false;
17
+
18
+ const SKIPPABLE_DIR_NAMES = new Set([
19
+ '.git',
20
+ 'node_modules',
21
+ 'dist',
22
+ '.boxel-history',
23
+ '.claude',
24
+ ]);
25
+
26
+ function isSkippableDir(dirName: string): boolean {
27
+ return SKIPPABLE_DIR_NAMES.has(dirName);
28
+ }
29
+
30
+ function canonicalDomainFromHost(hostname: string): string {
31
+ if (hostname === 'stack.cards' || hostname.endsWith('.stack.cards')) {
32
+ return 'stack.cards';
33
+ }
34
+ if (hostname === 'boxel.ai' || hostname.endsWith('.boxel.ai')) {
35
+ return 'boxel.ai';
36
+ }
37
+ return hostname;
38
+ }
39
+
40
+ // Reject segments that would let a crafted realmUrl escape the rootDir tree
41
+ // (`.`, `..`, anything containing a path separator or NUL) — defence in depth
42
+ // against malformed manifests; `findMisplacedLocalRealmDirs` also re-checks
43
+ // containment after `path.resolve` collapses any `..` it didn't catch.
44
+ function isSafePathSegment(segment: string): boolean {
45
+ if (!segment) return false;
46
+ let decoded: string;
47
+ try {
48
+ decoded = decodeURIComponent(segment);
49
+ } catch {
50
+ return false;
51
+ }
52
+ if (decoded === '.' || decoded === '..') return false;
53
+ if (
54
+ decoded.includes('/') ||
55
+ decoded.includes('\\') ||
56
+ decoded.includes('\0')
57
+ ) {
58
+ return false;
59
+ }
60
+ return true;
61
+ }
62
+
63
+ export function relativeStructuredPathForRealmUrl(
64
+ realmUrl: string,
65
+ ): string | null {
66
+ let url: URL;
67
+ try {
68
+ url = new URL(realmUrl);
69
+ } catch {
70
+ return null;
71
+ }
72
+ const domain = canonicalDomainFromHost(url.hostname);
73
+ if (!isSafePathSegment(domain)) return null;
74
+ const parts = url.pathname
75
+ .replace(/^\/|\/$/g, '')
76
+ .split('/')
77
+ .filter(Boolean);
78
+ const owner = parts[0] ?? 'unknown-owner';
79
+ const realm = parts[1] ?? parts[0] ?? 'workspace';
80
+ if (!isSafePathSegment(owner) || !isSafePathSegment(realm)) return null;
81
+ return path.join(domain, owner, realm);
82
+ }
83
+
84
+ export function absoluteStructuredPathForRealmUrl(
85
+ realmUrl: string,
86
+ rootDir: string,
87
+ ): string | null {
88
+ const rel = relativeStructuredPathForRealmUrl(realmUrl);
89
+ if (rel === null) return null;
90
+ return path.resolve(rootDir, rel);
91
+ }
92
+
93
+ function tryReadRealmUrl(manifestPath: string): MinimalManifest | null {
94
+ let content: string;
95
+ try {
96
+ content = fs.readFileSync(manifestPath, 'utf-8');
97
+ } catch {
98
+ return null;
99
+ }
100
+ let parsed: unknown;
101
+ try {
102
+ parsed = JSON.parse(content);
103
+ } catch {
104
+ return null;
105
+ }
106
+ if (typeof parsed !== 'object' || parsed === null) {
107
+ return null;
108
+ }
109
+ const candidate = (parsed as Record<string, unknown>).realmUrl;
110
+ if (typeof candidate !== 'string' || candidate === '') {
111
+ return null;
112
+ }
113
+ return { realmUrl: candidate };
114
+ }
115
+
116
+ function addManifestIfExists(dir: string, manifests: string[]): void {
117
+ const manifestPath = path.join(dir, '.boxel-sync.json');
118
+ if (fs.existsSync(manifestPath)) {
119
+ manifests.push(manifestPath);
120
+ }
121
+ }
122
+
123
+ function listSubdirs(dir: string): string[] {
124
+ try {
125
+ return fs
126
+ .readdirSync(dir, { withFileTypes: true })
127
+ .filter((entry) => entry.isDirectory() && !isSkippableDir(entry.name))
128
+ .map((entry) => path.join(dir, entry.name));
129
+ } catch {
130
+ return [];
131
+ }
132
+ }
133
+
134
+ function findManifestPaths(rootDir: string): string[] {
135
+ const manifests: string[] = [];
136
+ const absoluteRoot = path.resolve(rootDir);
137
+
138
+ // Legacy layout: <root>/<realm>/.boxel-sync.json
139
+ for (const childDir of listSubdirs(absoluteRoot)) {
140
+ addManifestIfExists(childDir, manifests);
141
+ }
142
+
143
+ // Canonical layout: <root>/<domain>/<owner>/<realm>/.boxel-sync.json
144
+ for (const domainDir of listSubdirs(absoluteRoot)) {
145
+ for (const ownerDir of listSubdirs(domainDir)) {
146
+ for (const realmDir of listSubdirs(ownerDir)) {
147
+ addManifestIfExists(realmDir, manifests);
148
+ }
149
+ }
150
+ }
151
+
152
+ return manifests;
153
+ }
154
+
155
+ // True iff `child` is `root` or a descendant of `root`. Belt-and-suspenders
156
+ // containment check after `path.resolve` — even if a crafted realmUrl made
157
+ // it past `isSafePathSegment`, the resolved path must stay inside rootDir
158
+ // before we move anything.
159
+ function isWithin(root: string, child: string): boolean {
160
+ const rel = path.relative(root, child);
161
+ if (rel === '') return true;
162
+ if (rel.startsWith('..')) return false;
163
+ return !path.isAbsolute(rel);
164
+ }
165
+
166
+ export function findMisplacedLocalRealmDirs(
167
+ rootDir: string,
168
+ ): MisplacedLocalRealmEntry[] {
169
+ const absoluteRoot = path.resolve(rootDir);
170
+ const manifestPaths = findManifestPaths(absoluteRoot);
171
+
172
+ const seenManifestPaths = new Set<string>();
173
+ const entries: MisplacedLocalRealmEntry[] = [];
174
+ for (const manifestPath of manifestPaths) {
175
+ if (seenManifestPaths.has(manifestPath)) {
176
+ continue;
177
+ }
178
+ seenManifestPaths.add(manifestPath);
179
+
180
+ const manifest = tryReadRealmUrl(manifestPath);
181
+ if (!manifest) {
182
+ continue;
183
+ }
184
+
185
+ const expectedDir = absoluteStructuredPathForRealmUrl(
186
+ manifest.realmUrl,
187
+ absoluteRoot,
188
+ );
189
+ if (expectedDir === null) {
190
+ continue;
191
+ }
192
+ if (!isWithin(absoluteRoot, expectedDir)) {
193
+ continue;
194
+ }
195
+
196
+ const currentDir = path.dirname(manifestPath);
197
+ if (path.resolve(currentDir) !== path.resolve(expectedDir)) {
198
+ entries.push({
199
+ manifestPath,
200
+ currentDir,
201
+ expectedDir,
202
+ realmUrl: manifest.realmUrl,
203
+ });
204
+ }
205
+ }
206
+
207
+ return entries;
208
+ }
209
+
210
+ export function warnIfMisplacedLocalRealmDirs(rootDir: string): void {
211
+ if (didWarnInProcess) return;
212
+ if (process.env.BOXEL_DISABLE_PATH_WARNING === '1') return;
213
+ if (isQuiet()) return;
214
+
215
+ const entries = findMisplacedLocalRealmDirs(rootDir);
216
+ if (entries.length === 0) return;
217
+
218
+ didWarnInProcess = true;
219
+
220
+ console.warn('\n⚠️ Detected local realm directories at legacy local paths:');
221
+ const absoluteRoot = path.resolve(rootDir);
222
+ for (const entry of entries.slice(0, 5)) {
223
+ const from = path.relative(absoluteRoot, entry.currentDir) || '.';
224
+ const to = path.relative(absoluteRoot, entry.expectedDir) || '.';
225
+ console.warn(` - ${from} -> ${to}`);
226
+ }
227
+ if (entries.length > 5) {
228
+ console.warn(` ...and ${entries.length - 5} more`);
229
+ }
230
+ console.warn('\nRun to preview:');
231
+ console.warn(' boxel consolidate-workspaces . --dry-run');
232
+ console.warn('Then apply:');
233
+ console.warn(' boxel consolidate-workspaces .\n');
234
+ }
235
+
236
+ /**
237
+ * Test-only escape hatch — resets the once-per-process warning latch so tests
238
+ * can exercise `warnIfMisplacedLocalRealmDirs` repeatedly within a single
239
+ * Node process.
240
+ */
241
+ export function resetWarnedFlagForTests(): void {
242
+ didWarnInProcess = false;
243
+ }
@@ -125,11 +125,13 @@ export abstract class RealmSyncBase {
125
125
  try {
126
126
  const url = this.buildDirectoryUrl(dir);
127
127
 
128
- const response = await this.authenticator.authedRealmFetch(url, {
129
- headers: {
130
- Accept: 'application/vnd.api+json',
131
- },
132
- });
128
+ const response = await this.remoteLimit(() =>
129
+ this.authenticator.authedRealmFetch(url, {
130
+ headers: {
131
+ Accept: 'application/vnd.api+json',
132
+ },
133
+ }),
134
+ );
133
135
 
134
136
  if (!response.ok) {
135
137
  if (response.status === 404) {
@@ -167,10 +169,10 @@ export abstract class RealmSyncBase {
167
169
  }
168
170
  return [] as Array<[string, boolean]>;
169
171
  } else {
170
- return this.remoteLimit(async () => {
172
+ return (async () => {
171
173
  const subdirFiles = await this.getRemoteFileList(entryPath);
172
174
  return Array.from(subdirFiles.entries());
173
- });
175
+ })();
174
176
  }
175
177
  }),
176
178
  );