@cardstack/boxel-cli 0.1.4 → 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.
@@ -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
+ }