@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.
@@ -364,41 +364,46 @@ export class CheckpointManager {
364
364
  });
365
365
 
366
366
  return Promise.all(
367
- lines.map(async (line) => {
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
- const all = await this.getCheckpoints(100);
579
- return all.filter((cp) => cp.isMilestone);
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.authenticator.authedRealmFetch(url, {
129
- headers: {
130
- Accept: 'application/vnd.api+json',
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 this.remoteLimit(async () => {
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
  );
@@ -17,7 +17,7 @@ function lockPath(localDir: string): string {
17
17
  return path.join(localDir, LOCK_FILE);
18
18
  }
19
19
 
20
- function isProcessAlive(pid: number): boolean {
20
+ export function isProcessAlive(pid: number): boolean {
21
21
  try {
22
22
  process.kill(pid, 0);
23
23
  return true;
@@ -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
+ }