@cardstack/boxel-cli 0.0.1 → 0.1.0

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.
Files changed (41) hide show
  1. package/README.md +124 -0
  2. package/api.ts +3 -0
  3. package/bin/boxel.js +15 -0
  4. package/dist/index.js +107 -66
  5. package/package.json +31 -24
  6. package/src/commands/file/delete.ts +110 -0
  7. package/src/commands/file/index.ts +20 -0
  8. package/src/commands/file/lint.ts +235 -0
  9. package/src/commands/file/list.ts +121 -0
  10. package/src/commands/file/read.ts +113 -0
  11. package/src/commands/file/touch.ts +222 -0
  12. package/src/commands/file/write.ts +152 -0
  13. package/src/commands/profile.ts +199 -106
  14. package/src/commands/read-transpiled.ts +120 -0
  15. package/src/commands/realm/cancel-indexing.ts +113 -0
  16. package/src/commands/realm/create.ts +1 -4
  17. package/src/commands/realm/history.ts +388 -0
  18. package/src/commands/realm/index.ts +12 -0
  19. package/src/commands/realm/list.ts +156 -0
  20. package/src/commands/realm/pull.ts +51 -17
  21. package/src/commands/realm/push.ts +52 -16
  22. package/src/commands/realm/remove.ts +281 -0
  23. package/src/commands/realm/sync.ts +153 -60
  24. package/src/commands/realm/wait-for-ready.ts +120 -0
  25. package/src/commands/realm/watch.ts +626 -0
  26. package/src/commands/run-command.ts +4 -3
  27. package/src/commands/search.ts +160 -0
  28. package/src/index.ts +60 -2
  29. package/src/lib/auth-resolver.ts +58 -0
  30. package/src/lib/auth.ts +56 -12
  31. package/src/lib/boxel-cli-client.ts +135 -279
  32. package/src/lib/cli-log.ts +132 -0
  33. package/src/lib/colors.ts +14 -9
  34. package/src/lib/find-checkpoint.ts +65 -0
  35. package/src/lib/profile-manager.ts +49 -4
  36. package/src/lib/prompt.ts +133 -0
  37. package/src/lib/realm-authenticator.ts +12 -0
  38. package/src/lib/realm-sync-base.ts +47 -10
  39. package/src/lib/seed-auth.ts +214 -0
  40. package/src/lib/watch-lock.ts +81 -0
  41. package/LICENSE +0 -21
@@ -0,0 +1,388 @@
1
+ import * as fs from 'fs';
2
+ import type { Command } from 'commander';
3
+ import {
4
+ CheckpointManager,
5
+ type Checkpoint,
6
+ } from '../../lib/checkpoint-manager';
7
+ import { findCheckpoint } from '../../lib/find-checkpoint';
8
+ import { prompt } from '../../lib/prompt';
9
+ import {
10
+ BOLD,
11
+ DIM,
12
+ FG_CYAN,
13
+ FG_GREEN,
14
+ FG_MAGENTA,
15
+ FG_RED,
16
+ FG_YELLOW,
17
+ RESET,
18
+ } from '../../lib/colors';
19
+
20
+ const DEFAULT_LIMIT = 100;
21
+
22
+ export interface HistoryOptions {
23
+ /** A 1-based index, short hash, or full hash to restore. */
24
+ restore?: string;
25
+ /** Create a manual checkpoint with this commit message. */
26
+ message?: string;
27
+ /** Max checkpoints to list or consider for restore. Defaults to 100. */
28
+ limit?: number;
29
+ }
30
+
31
+ export interface HistoryResult {
32
+ ok: boolean;
33
+ /** Populated in view mode. */
34
+ checkpoints?: Checkpoint[];
35
+ /** True when the listing was capped by `limit` (view mode only). */
36
+ truncated?: boolean;
37
+ /** Populated when `--message` created a checkpoint. */
38
+ created?: Checkpoint;
39
+ /** Populated when `--restore` restored a checkpoint. */
40
+ restored?: Checkpoint;
41
+ error?: string;
42
+ }
43
+
44
+ interface HistoryCliOptions {
45
+ restore?: string;
46
+ message?: string;
47
+ yes?: boolean;
48
+ limit?: string;
49
+ }
50
+
51
+ type StepResult<T> = ({ ok: true } & T) | { ok: false; error: string };
52
+
53
+ function errorMessage(e: unknown): string {
54
+ return e instanceof Error ? e.message : String(e);
55
+ }
56
+
57
+ async function listCheckpointsStep(
58
+ workspaceDir: string,
59
+ limit: number,
60
+ ): Promise<StepResult<{ checkpoints: Checkpoint[]; truncated: boolean }>> {
61
+ if (!fs.existsSync(workspaceDir)) {
62
+ return { ok: false, error: `Directory not found: ${workspaceDir}` };
63
+ }
64
+ try {
65
+ const manager = new CheckpointManager(workspaceDir);
66
+ if (!(await manager.isInitialized())) {
67
+ return { ok: true, checkpoints: [], truncated: false };
68
+ }
69
+ // Fetch one extra so we can detect truncation without a separate count query.
70
+ const fetched = await manager.getCheckpoints(limit + 1);
71
+ const truncated = fetched.length > limit;
72
+ const checkpoints = truncated ? fetched.slice(0, limit) : fetched;
73
+ return { ok: true, checkpoints, truncated };
74
+ } catch (e) {
75
+ return {
76
+ ok: false,
77
+ error: `Failed to read checkpoint history: ${errorMessage(e)}`,
78
+ };
79
+ }
80
+ }
81
+
82
+ async function createManualCheckpointStep(
83
+ workspaceDir: string,
84
+ rawMessage: string,
85
+ ): Promise<StepResult<{ created: Checkpoint }>> {
86
+ if (!fs.existsSync(workspaceDir)) {
87
+ return { ok: false, error: `Directory not found: ${workspaceDir}` };
88
+ }
89
+ const message = rawMessage.trim();
90
+ if (!message) {
91
+ return { ok: false, error: '--message must not be empty.' };
92
+ }
93
+ try {
94
+ const manager = new CheckpointManager(workspaceDir);
95
+ if (!(await manager.isInitialized())) {
96
+ await manager.init();
97
+ }
98
+ const changes = await manager.detectCurrentChanges();
99
+ const created = await manager.createCheckpoint('manual', changes, message);
100
+ if (!created) {
101
+ return { ok: false, error: 'No changes to checkpoint.' };
102
+ }
103
+ return { ok: true, created };
104
+ } catch (e) {
105
+ return {
106
+ ok: false,
107
+ error: `Failed to create checkpoint: ${errorMessage(e)}`,
108
+ };
109
+ }
110
+ }
111
+
112
+ async function resolveCheckpointRefStep(
113
+ workspaceDir: string,
114
+ ref: string,
115
+ limit: number,
116
+ ): Promise<StepResult<{ target: Checkpoint }>> {
117
+ if (!fs.existsSync(workspaceDir)) {
118
+ return { ok: false, error: `Directory not found: ${workspaceDir}` };
119
+ }
120
+ try {
121
+ const manager = new CheckpointManager(workspaceDir);
122
+ if (!(await manager.isInitialized())) {
123
+ return {
124
+ ok: false,
125
+ error:
126
+ 'No checkpoint history found for this workspace. ' +
127
+ 'Checkpoints are created automatically during sync operations.',
128
+ };
129
+ }
130
+ const checkpoints = await manager.getCheckpoints(limit);
131
+ const found = findCheckpoint(ref, checkpoints);
132
+ if (found.kind === 'none') {
133
+ return {
134
+ ok: false,
135
+ error: `Checkpoint not found: ${ref}. Use a number (1-${checkpoints.length}) or a commit hash.`,
136
+ };
137
+ }
138
+ if (found.kind === 'ambiguous') {
139
+ const sample = found.matches
140
+ .slice(0, 5)
141
+ .map((cp) => cp.shortHash)
142
+ .join(', ');
143
+ const more = found.matches.length > 5 ? ', …' : '';
144
+ return {
145
+ ok: false,
146
+ error: `Ambiguous reference: ${ref} matches ${found.matches.length} checkpoints (${sample}${more}). Use a longer prefix or full hash.`,
147
+ };
148
+ }
149
+ return { ok: true, target: found.target };
150
+ } catch (e) {
151
+ return {
152
+ ok: false,
153
+ error: `Failed to read checkpoint history: ${errorMessage(e)}`,
154
+ };
155
+ }
156
+ }
157
+
158
+ async function restoreCheckpointStep(
159
+ workspaceDir: string,
160
+ hash: string,
161
+ ): Promise<{ ok: true } | { ok: false; error: string }> {
162
+ if (!fs.existsSync(workspaceDir)) {
163
+ return { ok: false, error: `Directory not found: ${workspaceDir}` };
164
+ }
165
+ try {
166
+ const manager = new CheckpointManager(workspaceDir);
167
+ if (!(await manager.isInitialized())) {
168
+ return {
169
+ ok: false,
170
+ error: 'No checkpoint history found for this workspace.',
171
+ };
172
+ }
173
+ await manager.restore(hash);
174
+ return { ok: true };
175
+ } catch (e) {
176
+ return {
177
+ ok: false,
178
+ error: `Failed to restore checkpoint: ${errorMessage(e)}`,
179
+ };
180
+ }
181
+ }
182
+
183
+ /**
184
+ * View, restore, or create checkpoints in a workspace's local
185
+ * `.boxel-history/` git repo. Pure local — does not touch the realm server.
186
+ *
187
+ * Programmatic API. Restores immediately without prompting; the CLI wraps
188
+ * this with a TTY confirmation step (see `registerHistoryCommand`).
189
+ */
190
+ export async function realmHistory(
191
+ workspaceDir: string,
192
+ options: HistoryOptions = {},
193
+ ): Promise<HistoryResult> {
194
+ if (options.restore !== undefined && options.message !== undefined) {
195
+ return {
196
+ ok: false,
197
+ error: 'Only one of --restore or --message may be specified.',
198
+ };
199
+ }
200
+ if (
201
+ options.limit !== undefined &&
202
+ (!Number.isInteger(options.limit) || options.limit <= 0)
203
+ ) {
204
+ return { ok: false, error: 'limit must be a positive integer.' };
205
+ }
206
+ const limit = options.limit ?? DEFAULT_LIMIT;
207
+
208
+ if (options.message !== undefined) {
209
+ const r = await createManualCheckpointStep(workspaceDir, options.message);
210
+ return r.ok
211
+ ? { ok: true, created: r.created }
212
+ : { ok: false, error: r.error };
213
+ }
214
+
215
+ if (options.restore !== undefined) {
216
+ const resolved = await resolveCheckpointRefStep(
217
+ workspaceDir,
218
+ options.restore,
219
+ limit,
220
+ );
221
+ if (!resolved.ok) return { ok: false, error: resolved.error };
222
+ const restored = await restoreCheckpointStep(
223
+ workspaceDir,
224
+ resolved.target.hash,
225
+ );
226
+ if (!restored.ok) return { ok: false, error: restored.error };
227
+ return { ok: true, restored: resolved.target };
228
+ }
229
+
230
+ const r = await listCheckpointsStep(workspaceDir, limit);
231
+ return r.ok
232
+ ? { ok: true, checkpoints: r.checkpoints, truncated: r.truncated }
233
+ : { ok: false, error: r.error };
234
+ }
235
+
236
+ function formatSourceTag(source: 'local' | 'remote' | 'manual'): string {
237
+ if (source === 'local') return `${FG_GREEN}LOCAL${RESET}`;
238
+ if (source === 'remote') return `${FG_CYAN}SERVER${RESET}`;
239
+ return `${FG_MAGENTA}MANUAL${RESET}`;
240
+ }
241
+
242
+ function formatRelativeDate(date: Date): string {
243
+ const diffMs = Date.now() - date.getTime();
244
+ const minutes = Math.floor(diffMs / 60_000);
245
+ const hours = Math.floor(minutes / 60);
246
+ const days = Math.floor(hours / 24);
247
+ if (days > 7)
248
+ return `${date.toLocaleDateString()} ${date.toLocaleTimeString()}`;
249
+ if (days > 0) return `${days} day${days === 1 ? '' : 's'} ago`;
250
+ if (hours > 0) return `${hours} hour${hours === 1 ? '' : 's'} ago`;
251
+ if (minutes > 0) return `${minutes} minute${minutes === 1 ? '' : 's'} ago`;
252
+ return 'just now';
253
+ }
254
+
255
+ function printCheckpoints(
256
+ checkpoints: Checkpoint[],
257
+ truncated: boolean,
258
+ limit: number,
259
+ ): void {
260
+ if (checkpoints.length === 0) {
261
+ console.log('No checkpoints found.');
262
+ return;
263
+ }
264
+ console.log(`\n${BOLD}Checkpoint History${RESET}\n`);
265
+ const width = String(checkpoints.length).length;
266
+ checkpoints.forEach((cp, i) => {
267
+ const num = i + 1;
268
+ const numLabel = `${DIM}${String(num).padStart(width, ' ')}${RESET}`;
269
+ const majorTag = cp.isMajor
270
+ ? `${FG_YELLOW}[MAJOR]${RESET}`
271
+ : `${DIM}[minor]${RESET}`;
272
+ const milestoneTag = cp.isMilestone
273
+ ? `${FG_YELLOW}⭐${RESET} ${FG_MAGENTA}[${cp.milestoneName}]${RESET} `
274
+ : '';
275
+ console.log(
276
+ `${numLabel} ${FG_YELLOW}${cp.shortHash}${RESET} ${milestoneTag}${formatSourceTag(cp.source)} ${majorTag} ${cp.message} ${DIM}(${cp.filesChanged} files)${RESET}`,
277
+ );
278
+ console.log(` ${DIM}${formatRelativeDate(cp.date)}${RESET}\n`);
279
+ });
280
+ if (truncated) {
281
+ console.log(
282
+ `${DIM}Showing first ${limit} checkpoints. Pass --limit <n> to see more.${RESET}`,
283
+ );
284
+ }
285
+ console.log(
286
+ `${DIM}Restore: boxel realm history <local-dir> -r <ref>${RESET}`,
287
+ );
288
+ }
289
+
290
+ function parseLimit(raw: string | undefined): number | null {
291
+ if (raw === undefined) return DEFAULT_LIMIT;
292
+ if (!/^\d+$/.test(raw)) return null;
293
+ const n = parseInt(raw, 10);
294
+ return n > 0 ? n : null;
295
+ }
296
+
297
+ function bailout(msg: string): never {
298
+ console.error(`${FG_RED}Error:${RESET} ${msg}`);
299
+ process.exit(1);
300
+ }
301
+
302
+ export function registerHistoryCommand(realm: Command): void {
303
+ realm
304
+ .command('history')
305
+ .alias('hist')
306
+ .description(
307
+ 'View, restore, or create local checkpoints stored under .boxel-history/',
308
+ )
309
+ .argument('<local-dir>', 'The local workspace directory')
310
+ .option(
311
+ '-r, --restore <ref>',
312
+ 'Restore the workspace to a checkpoint (1-based index, short hash, or full hash)',
313
+ )
314
+ .option(
315
+ '-m, --message <message>',
316
+ 'Create a manual checkpoint with the given message',
317
+ )
318
+ .option(
319
+ '-y, --yes',
320
+ 'Skip the interactive confirmation prompt before --restore',
321
+ )
322
+ .option(
323
+ '--limit <n>',
324
+ `Maximum number of checkpoints to list or consider for --restore (default: ${DEFAULT_LIMIT})`,
325
+ )
326
+ .action(async (localDir: string, opts: HistoryCliOptions) => {
327
+ if (opts.restore !== undefined && opts.message !== undefined) {
328
+ bailout('Only one of --restore or --message may be specified.');
329
+ }
330
+
331
+ const limit = parseLimit(opts.limit);
332
+ if (limit === null) {
333
+ bailout('--limit must be a positive integer.');
334
+ }
335
+
336
+ if (opts.message !== undefined) {
337
+ const r = await createManualCheckpointStep(localDir, opts.message);
338
+ if (!r.ok) bailout(r.error);
339
+ console.log(
340
+ `${FG_GREEN}✓${RESET} Checkpoint created: ${FG_YELLOW}${r.created.shortHash}${RESET} ${r.created.message}`,
341
+ );
342
+ return;
343
+ }
344
+
345
+ if (opts.restore !== undefined) {
346
+ const resolved = await resolveCheckpointRefStep(
347
+ localDir,
348
+ opts.restore,
349
+ limit,
350
+ );
351
+ if (!resolved.ok) bailout(resolved.error);
352
+ const target = resolved.target;
353
+
354
+ if (!opts.yes) {
355
+ if (!process.stdin.isTTY) {
356
+ bailout(
357
+ '--restore overwrites local files. Pass --yes to confirm in non-interactive mode.',
358
+ );
359
+ }
360
+ console.log(
361
+ `\n${BOLD}Restoring to:${RESET} ${FG_YELLOW}${target.shortHash}${RESET} - ${target.message}`,
362
+ );
363
+ console.log(`${DIM}${formatRelativeDate(target.date)}${RESET}\n`);
364
+ const answer = await prompt(
365
+ `${FG_YELLOW}This will overwrite current files. Continue? (y/N) ${RESET}`,
366
+ );
367
+ if (!/^y/i.test(answer)) {
368
+ console.log(`${DIM}Restore cancelled.${RESET}`);
369
+ return;
370
+ }
371
+ }
372
+
373
+ const restored = await restoreCheckpointStep(localDir, target.hash);
374
+ if (!restored.ok) bailout(restored.error);
375
+ console.log(
376
+ `${FG_GREEN}✓${RESET} Restored to ${FG_YELLOW}${target.shortHash}${RESET} ${target.message}`,
377
+ );
378
+ console.log(
379
+ `${DIM}Run 'boxel realm sync <local-dir> <realm-url> --prefer-local' to push the restored state to the realm.${RESET}`,
380
+ );
381
+ return;
382
+ }
383
+
384
+ const r = await listCheckpointsStep(localDir, limit);
385
+ if (!r.ok) bailout(r.error);
386
+ printCheckpoints(r.checkpoints, r.truncated, limit);
387
+ });
388
+ }
@@ -1,16 +1,28 @@
1
1
  import type { Command } from 'commander';
2
+ import { registerCancelIndexingCommand } from './cancel-indexing';
2
3
  import { registerCreateCommand } from './create';
4
+ import { registerHistoryCommand } from './history';
5
+ import { registerListCommand } from './list';
3
6
  import { registerPullCommand } from './pull';
4
7
  import { registerPushCommand } from './push';
8
+ import { registerRemoveCommand } from './remove';
5
9
  import { registerSyncCommand } from './sync';
10
+ import { registerWaitForReadyCommand } from './wait-for-ready';
11
+ import { registerWatchCommand } from './watch';
6
12
 
7
13
  export function registerRealmCommand(program: Command): void {
8
14
  let realm = program
9
15
  .command('realm')
10
16
  .description('Manage realms on the realm server');
11
17
 
18
+ registerCancelIndexingCommand(realm);
12
19
  registerCreateCommand(realm);
20
+ registerHistoryCommand(realm);
21
+ registerListCommand(realm);
13
22
  registerPullCommand(realm);
14
23
  registerPushCommand(realm);
24
+ registerRemoveCommand(realm);
15
25
  registerSyncCommand(realm);
26
+ registerWaitForReadyCommand(realm);
27
+ registerWatchCommand(realm);
16
28
  }
@@ -0,0 +1,156 @@
1
+ import type { Command } from 'commander';
2
+ import { ensureTrailingSlash } from '@cardstack/runtime-common/paths';
3
+ import {
4
+ getProfileManager,
5
+ NO_ACTIVE_PROFILE_ERROR,
6
+ type ProfileManager,
7
+ } from '../../lib/profile-manager';
8
+ import { BOLD, DIM, FG_CYAN, FG_RED, RESET } from '../../lib/colors';
9
+
10
+ const MUTUALLY_EXCLUSIVE_FLAGS_ERROR =
11
+ '--all-accessible and --hidden are mutually exclusive';
12
+
13
+ export interface RealmSummary {
14
+ url: string;
15
+ hidden: boolean;
16
+ }
17
+
18
+ export interface ListRealmsResult {
19
+ realms: RealmSummary[];
20
+ error?: string;
21
+ }
22
+
23
+ export interface ListRealmsOptions {
24
+ allAccessible?: boolean;
25
+ hidden?: boolean;
26
+ profileManager?: ProfileManager;
27
+ }
28
+
29
+ interface ListCliOptions {
30
+ json?: boolean;
31
+ allAccessible?: boolean;
32
+ hidden?: boolean;
33
+ }
34
+
35
+ /**
36
+ * List realms accessible to the active profile.
37
+ *
38
+ * Calls `_realm-auth` to discover all realms the user can access, then
39
+ * marks each as `hidden` based on whether it appears in the user's
40
+ * `app.boxel.realms` Matrix account data (the UI realm list).
41
+ *
42
+ * Default mode shows only non-hidden realms; `--all-accessible` shows
43
+ * everything; `--hidden` shows only hidden ones.
44
+ */
45
+ export async function listRealms(
46
+ options: ListRealmsOptions = {},
47
+ ): Promise<ListRealmsResult> {
48
+ if (options.allAccessible && options.hidden) {
49
+ return { realms: [], error: MUTUALLY_EXCLUSIVE_FLAGS_ERROR };
50
+ }
51
+
52
+ let pm = options.profileManager ?? getProfileManager();
53
+ let active = pm.getActiveProfile();
54
+ if (!active) {
55
+ return { realms: [], error: NO_ACTIVE_PROFILE_ERROR };
56
+ }
57
+
58
+ let realmServerUrl = active.profile.realmServerUrl.replace(/\/$/, '');
59
+ let response = await pm.authedRealmServerFetch(
60
+ `${realmServerUrl}/_realm-auth`,
61
+ {
62
+ method: 'POST',
63
+ headers: {
64
+ Accept: 'application/json',
65
+ 'Content-Type': 'application/json',
66
+ },
67
+ },
68
+ );
69
+ if (!response.ok) {
70
+ let text = await response.text();
71
+ return {
72
+ realms: [],
73
+ error: `Realm auth lookup failed: ${response.status} ${text}`,
74
+ };
75
+ }
76
+ let tokens = (await response.json()) as Record<string, string>;
77
+ let accessibleUrls = Object.keys(tokens).map(ensureTrailingSlash);
78
+
79
+ let userRealms: string[];
80
+ try {
81
+ userRealms = await pm.getUserRealms();
82
+ } catch (err) {
83
+ return {
84
+ realms: [],
85
+ error: `Failed to load UI realm list: ${
86
+ err instanceof Error ? err.message : String(err)
87
+ }`,
88
+ };
89
+ }
90
+ let userRealmsSet = new Set(userRealms.map(ensureTrailingSlash));
91
+
92
+ let summaries: RealmSummary[] = accessibleUrls.map((url) => ({
93
+ url,
94
+ hidden: !userRealmsSet.has(url),
95
+ }));
96
+
97
+ if (options.allAccessible) {
98
+ // no filter
99
+ } else if (options.hidden) {
100
+ summaries = summaries.filter((r) => r.hidden);
101
+ } else {
102
+ summaries = summaries.filter((r) => !r.hidden);
103
+ }
104
+
105
+ summaries.sort((a, b) => a.url.localeCompare(b.url));
106
+ return { realms: summaries };
107
+ }
108
+
109
+ export function registerListCommand(realm: Command): void {
110
+ realm
111
+ .command('list')
112
+ .alias('ls')
113
+ .description('List realms accessible to the active profile')
114
+ .option('--json', 'Output JSON')
115
+ .option(
116
+ '--all-accessible',
117
+ 'Show all accessible realms, including hidden ones',
118
+ )
119
+ .option('--hidden', "Show only realms not in the user's UI realm list")
120
+ .action(async (opts: ListCliOptions) => {
121
+ let result: ListRealmsResult;
122
+ try {
123
+ result = await listRealms({
124
+ allAccessible: opts.allAccessible,
125
+ hidden: opts.hidden,
126
+ });
127
+ } catch (err) {
128
+ console.error(
129
+ `${FG_RED}Error:${RESET} ${err instanceof Error ? err.message : String(err)}`,
130
+ );
131
+ process.exit(1);
132
+ }
133
+
134
+ if (opts.json) {
135
+ console.log(JSON.stringify(result, null, 2));
136
+ if (result.error) process.exit(1);
137
+ return;
138
+ }
139
+
140
+ if (result.error) {
141
+ console.error(`${FG_RED}Error:${RESET} ${result.error}`);
142
+ process.exit(1);
143
+ }
144
+
145
+ if (result.realms.length === 0) {
146
+ console.log(`${DIM}No realms found.${RESET}`);
147
+ return;
148
+ }
149
+
150
+ console.log(`${BOLD}${result.realms.length} realm(s):${RESET}`);
151
+ for (let r of result.realms) {
152
+ let tag = r.hidden ? ` ${DIM}(hidden)${RESET}` : '';
153
+ console.log(` ${FG_CYAN}${r.url}${RESET}${tag}`);
154
+ }
155
+ });
156
+ }
@@ -4,10 +4,10 @@ import {
4
4
  CheckpointManager,
5
5
  type CheckpointChange,
6
6
  } from '../../lib/checkpoint-manager';
7
- import {
8
- getProfileManager,
9
- type ProfileManager,
10
- } from '../../lib/profile-manager';
7
+ import type { ProfileManager } from '../../lib/profile-manager';
8
+ import type { RealmAuthenticator } from '../../lib/realm-authenticator';
9
+ import { resolveRealmAuthenticator } from '../../lib/auth-resolver';
10
+ import { resolveRealmSecretSeed } from '../../lib/prompt';
11
11
  import * as fs from 'fs/promises';
12
12
  import * as path from 'path';
13
13
 
@@ -21,9 +21,9 @@ class RealmPuller extends RealmSyncBase {
21
21
 
22
22
  constructor(
23
23
  private pullOptions: PullOptions,
24
- profileManager: ProfileManager,
24
+ authenticator: RealmAuthenticator,
25
25
  ) {
26
- super(pullOptions, profileManager);
26
+ super(pullOptions, authenticator);
27
27
  }
28
28
 
29
29
  async sync(): Promise<void> {
@@ -38,7 +38,7 @@ class RealmPuller extends RealmSyncBase {
38
38
  console.error('Failed to access realm:', error);
39
39
  throw new Error(
40
40
  'Cannot proceed with pull: Authentication or access failed. ' +
41
- 'Please check your Matrix credentials and realm permissions.',
41
+ 'Please check your credentials and realm permissions.',
42
42
  );
43
43
  }
44
44
  console.log('Realm access verified');
@@ -171,6 +171,19 @@ export interface PullCommandOptions {
171
171
  delete?: boolean;
172
172
  dryRun?: boolean;
173
173
  profileManager?: ProfileManager;
174
+ /**
175
+ * Pre-resolved realm secret seed for administrative access. When set, the
176
+ * CLI mints a JWT locally and skips Matrix login + /_server-session +
177
+ * /_realm-auth. The `--realm-secret-seed` CLI flag is resolved via
178
+ * `resolveRealmSecretSeed` (env var or interactive prompt) before being
179
+ * passed here.
180
+ */
181
+ realmSecretSeed?: string;
182
+ /**
183
+ * @internal Test hook: supply an already-constructed authenticator,
184
+ * bypassing both seed resolution and the profile flow.
185
+ */
186
+ authenticator?: RealmAuthenticator;
174
187
  }
175
188
 
176
189
  export function registerPullCommand(realm: Command): void {
@@ -184,13 +197,28 @@ export function registerPullCommand(realm: Command): void {
184
197
  .argument('<local-dir>', 'The local directory to sync files to')
185
198
  .option('--delete', 'Delete local files that do not exist in the realm')
186
199
  .option('--dry-run', 'Show what would be done without making changes')
200
+ .option(
201
+ '--realm-secret-seed',
202
+ 'Administrative auth: prompt for a realm secret seed and mint a JWT locally instead of using a Matrix profile (env: BOXEL_REALM_SECRET_SEED)',
203
+ )
187
204
  .action(
188
205
  async (
189
206
  realmUrl: string,
190
207
  localDir: string,
191
- options: { delete?: boolean; dryRun?: boolean },
208
+ options: {
209
+ delete?: boolean;
210
+ dryRun?: boolean;
211
+ realmSecretSeed?: boolean;
212
+ },
192
213
  ) => {
193
- let result = await pull(realmUrl, localDir, options);
214
+ const realmSecretSeed = await resolveRealmSecretSeed(
215
+ options.realmSecretSeed === true,
216
+ );
217
+ const result = await pull(realmUrl, localDir, {
218
+ delete: options.delete,
219
+ dryRun: options.dryRun,
220
+ realmSecretSeed,
221
+ });
194
222
  if (result.error) {
195
223
  console.error(`Error: ${result.error}`);
196
224
  process.exit(result.files.length > 0 ? 2 : 1);
@@ -205,13 +233,19 @@ export async function pull(
205
233
  localDir: string,
206
234
  options: PullCommandOptions,
207
235
  ): Promise<{ files: string[]; error?: string }> {
208
- let pm = options.profileManager ?? getProfileManager();
209
- let active = pm.getActiveProfile();
210
- if (!active) {
211
- return {
212
- files: [],
213
- error: 'No active profile. Run `boxel profile add` to create one.',
214
- };
236
+ let authenticator: RealmAuthenticator;
237
+ if (options.authenticator) {
238
+ authenticator = options.authenticator;
239
+ } else {
240
+ const resolution = resolveRealmAuthenticator({
241
+ realmUrl,
242
+ realmSecretSeed: options.realmSecretSeed,
243
+ profileManager: options.profileManager,
244
+ });
245
+ if (!resolution.ok) {
246
+ return { files: [], error: resolution.error };
247
+ }
248
+ authenticator = resolution.authenticator;
215
249
  }
216
250
 
217
251
  try {
@@ -222,7 +256,7 @@ export async function pull(
222
256
  deleteLocal: options.delete,
223
257
  dryRun: options.dryRun,
224
258
  },
225
- pm,
259
+ authenticator,
226
260
  );
227
261
 
228
262
  await puller.sync();