@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.
- package/dist/index.js +101 -85
- package/package.json +1 -1
- package/src/build-program.ts +4 -0
- package/src/commands/consolidate-workspaces.ts +104 -0
- package/src/commands/file/index.ts +1 -1
- package/src/commands/realm/index.ts +5 -1
- package/src/commands/realm/milestone.ts +375 -0
- package/src/commands/realm/status.ts +668 -0
- package/src/commands/realm/sync.ts +3 -2
- package/src/commands/realm/watch/index.ts +12 -0
- package/src/commands/realm/{watch.ts → watch/start.ts} +28 -13
- package/src/commands/realm/watch/stop.ts +151 -0
- package/src/lib/boxel-cli-client.ts +12 -0
- package/src/lib/checkpoint-manager.ts +67 -34
- package/src/lib/profile-manager.ts +27 -1
- package/src/lib/realm-local-paths.ts +243 -0
- package/src/lib/realm-sync-base.ts +9 -7
- package/src/lib/watch-lock.ts +1 -1
- package/src/lib/watch-process-registry.ts +85 -0
|
@@ -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 '
|
|
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 '
|
|
9
|
+
} from '../../../lib/checkpoint-manager';
|
|
10
10
|
import {
|
|
11
11
|
type SyncManifest,
|
|
12
12
|
computeFileHash,
|
|
13
13
|
loadManifest,
|
|
14
14
|
saveManifest,
|
|
15
|
-
} from '
|
|
16
|
-
import type { ProfileManager } from '
|
|
17
|
-
import type { RealmAuthenticator } from '
|
|
18
|
-
import { resolveRealmAuthenticator } from '
|
|
19
|
-
import { resolveRealmSecretSeed } from '
|
|
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 '
|
|
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 '
|
|
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
|
|
576
|
-
|
|
577
|
-
.command('
|
|
590
|
+
export function registerStartCommand(watch: Command): void {
|
|
591
|
+
watch
|
|
592
|
+
.command('start')
|
|
578
593
|
.description(
|
|
579
|
-
'
|
|
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(
|
|
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
|
-
|
|
579
|
-
|
|
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.
|
|
129
|
-
|
|
130
|
-
|
|
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
|
|
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
|
);
|