@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.
- package/dist/index.js +99 -93
- package/package.json +3 -3
- 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/lib/realm-local-paths.ts +243 -0
|
@@ -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
|
+
}
|