@cardstack/boxel-cli 0.1.3 → 0.1.4
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 +85 -75
- package/package.json +3 -3
- package/src/commands/file/index.ts +1 -1
- package/src/commands/realm/index.ts +2 -0
- package/src/commands/realm/milestone.ts +375 -0
- 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-sync-base.ts +9 -7
- package/src/lib/watch-lock.ts +1 -1
- package/src/lib/watch-process-registry.ts +85 -0
|
@@ -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();
|
|
@@ -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
|
);
|
package/src/lib/watch-lock.ts
CHANGED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import * as fs from 'fs/promises';
|
|
2
|
+
import * as os from 'os';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import { isProcessAlive } from './watch-lock';
|
|
5
|
+
|
|
6
|
+
export interface RegisteredProcess {
|
|
7
|
+
pid: number;
|
|
8
|
+
workspace: string;
|
|
9
|
+
startedAt: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface Registry {
|
|
13
|
+
processes: RegisteredProcess[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function registryDir(): string {
|
|
17
|
+
return path.join(os.homedir(), '.boxel-cli');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function registryFile(): string {
|
|
21
|
+
return path.join(registryDir(), 'watch-processes.json');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function readRegistry(): Promise<Registry> {
|
|
25
|
+
try {
|
|
26
|
+
const raw = await fs.readFile(registryFile(), 'utf8');
|
|
27
|
+
const parsed = JSON.parse(raw) as Partial<Registry>;
|
|
28
|
+
if (!Array.isArray(parsed?.processes)) {
|
|
29
|
+
return { processes: [] };
|
|
30
|
+
}
|
|
31
|
+
const processes = parsed.processes.filter(
|
|
32
|
+
(entry): entry is RegisteredProcess =>
|
|
33
|
+
typeof entry?.pid === 'number' &&
|
|
34
|
+
typeof entry?.workspace === 'string' &&
|
|
35
|
+
typeof entry?.startedAt === 'string',
|
|
36
|
+
);
|
|
37
|
+
return { processes };
|
|
38
|
+
} catch {
|
|
39
|
+
return { processes: [] };
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function writeRegistry(registry: Registry): Promise<void> {
|
|
44
|
+
await fs.mkdir(registryDir(), { recursive: true });
|
|
45
|
+
const target = registryFile();
|
|
46
|
+
const tmp = `${target}.${process.pid}.tmp`;
|
|
47
|
+
await fs.writeFile(tmp, JSON.stringify(registry, null, 2) + '\n');
|
|
48
|
+
await fs.rename(tmp, target);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function pruneDead(): Promise<Registry> {
|
|
52
|
+
const registry = await readRegistry();
|
|
53
|
+
const alive = registry.processes.filter((entry) => isProcessAlive(entry.pid));
|
|
54
|
+
if (alive.length !== registry.processes.length) {
|
|
55
|
+
await writeRegistry({ processes: alive });
|
|
56
|
+
}
|
|
57
|
+
return { processes: alive };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function registerProcess(workspace: string): Promise<void> {
|
|
61
|
+
const registry = await pruneDead();
|
|
62
|
+
const withoutCurrent = registry.processes.filter(
|
|
63
|
+
(entry) => entry.pid !== process.pid,
|
|
64
|
+
);
|
|
65
|
+
withoutCurrent.push({
|
|
66
|
+
pid: process.pid,
|
|
67
|
+
workspace,
|
|
68
|
+
startedAt: new Date().toISOString(),
|
|
69
|
+
});
|
|
70
|
+
await writeRegistry({ processes: withoutCurrent });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function unregisterCurrentProcess(): Promise<void> {
|
|
74
|
+
const registry = await readRegistry();
|
|
75
|
+
const next = registry.processes.filter((entry) => entry.pid !== process.pid);
|
|
76
|
+
if (next.length === registry.processes.length) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
await writeRegistry({ processes: next });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function listRegisteredProcesses(): Promise<RegisteredProcess[]> {
|
|
83
|
+
const registry = await pruneDead();
|
|
84
|
+
return registry.processes;
|
|
85
|
+
}
|