@cardstack/boxel-cli 0.1.4 → 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/dist/index.js +99 -93
- package/package.json +1 -1
- package/src/build-program.ts +4 -0
- package/src/commands/consolidate-workspaces.ts +104 -0
- package/src/commands/realm/index.ts +3 -1
- package/src/commands/realm/status.ts +668 -0
- package/src/commands/realm/sync.ts +3 -2
- package/src/commands/realm/watch/start.ts +114 -20
- package/src/lib/realm-local-paths.ts +243 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
changes,
|
|
233
|
-
);
|
|
277
|
+
return { pulled, deleted, skipped, checkpoint };
|
|
278
|
+
}
|
|
234
279
|
|
|
235
|
-
|
|
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
|
|
291
|
-
|
|
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
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
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
|
-
|
|
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}`);
|
|
@@ -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
|
+
}
|