@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
@@ -8,10 +8,10 @@ import {
8
8
  CheckpointManager,
9
9
  type CheckpointChange,
10
10
  } from '../../lib/checkpoint-manager';
11
- import {
12
- getProfileManager,
13
- type ProfileManager,
14
- } from '../../lib/profile-manager';
11
+ import type { ProfileManager } from '../../lib/profile-manager';
12
+ import type { RealmAuthenticator } from '../../lib/realm-authenticator';
13
+ import { resolveRealmAuthenticator } from '../../lib/auth-resolver';
14
+ import { resolveRealmSecretSeed } from '../../lib/prompt';
15
15
  import {
16
16
  type SyncManifest,
17
17
  computeFileHash,
@@ -30,9 +30,9 @@ class RealmPusher extends RealmSyncBase {
30
30
 
31
31
  constructor(
32
32
  private pushOptions: PushOptions,
33
- profileManager: ProfileManager,
33
+ authenticator: RealmAuthenticator,
34
34
  ) {
35
- super(pushOptions, profileManager);
35
+ super(pushOptions, authenticator);
36
36
  }
37
37
 
38
38
  async sync(): Promise<void> {
@@ -308,6 +308,18 @@ export interface PushCommandOptions {
308
308
  dryRun?: boolean;
309
309
  force?: boolean;
310
310
  profileManager?: ProfileManager;
311
+ /**
312
+ * Pre-resolved realm secret seed for administrative access. When set, the
313
+ * CLI mints a JWT locally and skips Matrix login + /_server-session +
314
+ * /_realm-auth. The `--realm-secret-seed` CLI flag is resolved via
315
+ * `resolveRealmSecretSeed` (env var or interactive prompt) before being
316
+ * passed here.
317
+ */
318
+ realmSecretSeed?: string;
319
+ /**
320
+ * @internal Test hook: supply an already-constructed authenticator.
321
+ */
322
+ authenticator?: RealmAuthenticator;
311
323
  }
312
324
 
313
325
  export function registerPushCommand(realm: Command): void {
@@ -322,13 +334,30 @@ export function registerPushCommand(realm: Command): void {
322
334
  .option('--delete', 'Delete remote files that do not exist locally')
323
335
  .option('--dry-run', 'Show what would be done without making changes')
324
336
  .option('--force', 'Upload all files, even if unchanged')
337
+ .option(
338
+ '--realm-secret-seed',
339
+ 'Administrative auth: prompt for a realm secret seed and mint a JWT locally instead of using a Matrix profile (env: BOXEL_REALM_SECRET_SEED)',
340
+ )
325
341
  .action(
326
342
  async (
327
343
  localDir: string,
328
344
  realmUrl: string,
329
- options: { delete?: boolean; dryRun?: boolean; force?: boolean },
345
+ options: {
346
+ delete?: boolean;
347
+ dryRun?: boolean;
348
+ force?: boolean;
349
+ realmSecretSeed?: boolean;
350
+ },
330
351
  ) => {
331
- await pushCommand(localDir, realmUrl, options);
352
+ const realmSecretSeed = await resolveRealmSecretSeed(
353
+ options.realmSecretSeed === true,
354
+ );
355
+ await pushCommand(localDir, realmUrl, {
356
+ delete: options.delete,
357
+ dryRun: options.dryRun,
358
+ force: options.force,
359
+ realmSecretSeed,
360
+ });
332
361
  },
333
362
  );
334
363
  }
@@ -338,13 +367,20 @@ export async function pushCommand(
338
367
  realmUrl: string,
339
368
  options: PushCommandOptions,
340
369
  ): Promise<void> {
341
- let pm = options.profileManager ?? getProfileManager();
342
- let active = pm.getActiveProfile();
343
- if (!active) {
344
- console.error(
345
- 'Error: no active profile. Run `boxel profile add` to create one.',
346
- );
347
- process.exit(1);
370
+ let authenticator: RealmAuthenticator;
371
+ if (options.authenticator) {
372
+ authenticator = options.authenticator;
373
+ } else {
374
+ const resolution = resolveRealmAuthenticator({
375
+ realmUrl,
376
+ realmSecretSeed: options.realmSecretSeed,
377
+ profileManager: options.profileManager,
378
+ });
379
+ if (!resolution.ok) {
380
+ console.error(`Error: ${resolution.error}`);
381
+ process.exit(1);
382
+ }
383
+ authenticator = resolution.authenticator;
348
384
  }
349
385
 
350
386
  if (!(await pathExists(localDir))) {
@@ -361,7 +397,7 @@ export async function pushCommand(
361
397
  dryRun: options.dryRun,
362
398
  force: options.force,
363
399
  },
364
- pm,
400
+ authenticator,
365
401
  );
366
402
 
367
403
  await pusher.sync();
@@ -0,0 +1,281 @@
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 { prompt } from '../../lib/prompt';
9
+ import { DIM, FG_CYAN, FG_GREEN, FG_RED, RESET } from '../../lib/colors';
10
+
11
+ export interface RemoveRealmOptions {
12
+ realmUrl: string;
13
+ dryRun?: boolean;
14
+ profileManager?: ProfileManager;
15
+ }
16
+
17
+ export interface RemoveRealmResult {
18
+ /** Normalized URL the operation targeted (always trailing-slashed). */
19
+ realmUrl: string;
20
+ /** True only when both server delete and Matrix unlink completed. */
21
+ removed: boolean;
22
+ /** True when DELETE /_delete-realm returned 204. */
23
+ serverDeleted: boolean;
24
+ /** True when Matrix `app.boxel.realms` was rewritten without the URL. */
25
+ unlinked: boolean;
26
+ /** Number of entries before the change. */
27
+ previousCount: number;
28
+ /** Number of entries the next list would contain (computed even on dry-run). */
29
+ nextCount: number;
30
+ /**
31
+ * True when the URL was not present in `app.boxel.realms`. Mutually
32
+ * exclusive with a successful real removal.
33
+ */
34
+ notInList?: boolean;
35
+ error?: string;
36
+ }
37
+
38
+ /**
39
+ * Remove a realm: delete server-side files / index / registry via
40
+ * `DELETE /_delete-realm`, then unlink the URL from the active profile's
41
+ * `app.boxel.realms` Matrix account_data list. Mirrors the host UI's
42
+ * workspace delete flow and inverts `boxel realm create`.
43
+ *
44
+ * Programmatic API. Returns a result object on every code path; never
45
+ * prompts and never calls `process.exit`. The CLI wraps this with a TTY
46
+ * confirmation step (see `registerRemoveCommand`).
47
+ */
48
+ export async function removeRealm(
49
+ options: RemoveRealmOptions,
50
+ ): Promise<RemoveRealmResult> {
51
+ let realmUrl = ensureTrailingSlash(options.realmUrl.trim());
52
+ let pm = options.profileManager ?? getProfileManager();
53
+ let active = pm.getActiveProfile();
54
+ if (!active) {
55
+ return {
56
+ realmUrl,
57
+ removed: false,
58
+ serverDeleted: false,
59
+ unlinked: false,
60
+ previousCount: 0,
61
+ nextCount: 0,
62
+ error: NO_ACTIVE_PROFILE_ERROR,
63
+ };
64
+ }
65
+
66
+ let existing: string[];
67
+ try {
68
+ existing = await pm.getUserRealms();
69
+ } catch (err) {
70
+ return {
71
+ realmUrl,
72
+ removed: false,
73
+ serverDeleted: false,
74
+ unlinked: false,
75
+ previousCount: 0,
76
+ nextCount: 0,
77
+ error: `Failed to load realm list: ${
78
+ err instanceof Error ? err.message : String(err)
79
+ }`,
80
+ };
81
+ }
82
+ let normalized = existing.map(ensureTrailingSlash);
83
+ let previousCount = normalized.length;
84
+ let matchCount = normalized.filter((u) => u === realmUrl).length;
85
+
86
+ if (matchCount === 0) {
87
+ return {
88
+ realmUrl,
89
+ removed: false,
90
+ serverDeleted: false,
91
+ unlinked: false,
92
+ previousCount,
93
+ nextCount: previousCount,
94
+ notInList: true,
95
+ error: 'Realm is not in app.boxel.realms. Nothing to remove.',
96
+ };
97
+ }
98
+
99
+ let nextCount = previousCount - matchCount;
100
+
101
+ if (options.dryRun) {
102
+ return {
103
+ realmUrl,
104
+ removed: false,
105
+ serverDeleted: false,
106
+ unlinked: false,
107
+ previousCount,
108
+ nextCount,
109
+ };
110
+ }
111
+
112
+ let realmServerUrl = active.profile.realmServerUrl.replace(/\/$/, '');
113
+ let response: Response;
114
+ try {
115
+ response = await pm.authedRealmServerFetch(
116
+ `${realmServerUrl}/_delete-realm`,
117
+ {
118
+ method: 'DELETE',
119
+ headers: { 'Content-Type': 'application/vnd.api+json' },
120
+ body: JSON.stringify({
121
+ data: { type: 'realm', id: realmUrl },
122
+ }),
123
+ },
124
+ );
125
+ } catch (err) {
126
+ return {
127
+ realmUrl,
128
+ removed: false,
129
+ serverDeleted: false,
130
+ unlinked: false,
131
+ previousCount,
132
+ nextCount: previousCount,
133
+ error: `Failed to reach realm server: ${
134
+ err instanceof Error ? err.message : String(err)
135
+ }`,
136
+ };
137
+ }
138
+
139
+ if (!response.ok) {
140
+ let body = await safeReadResponseText(response);
141
+ let error =
142
+ response.status === 403
143
+ ? `You do not own this realm and cannot delete it on the server. Server returned 403: ${body}`
144
+ : `Realm server returned ${response.status}: ${body}`;
145
+ return {
146
+ realmUrl,
147
+ removed: false,
148
+ serverDeleted: false,
149
+ unlinked: false,
150
+ previousCount,
151
+ nextCount: previousCount,
152
+ error,
153
+ };
154
+ }
155
+
156
+ let unlinked: boolean;
157
+ try {
158
+ unlinked = await pm.removeFromUserRealms(realmUrl);
159
+ } catch (err) {
160
+ return {
161
+ realmUrl,
162
+ removed: false,
163
+ serverDeleted: true,
164
+ unlinked: false,
165
+ previousCount,
166
+ nextCount: previousCount,
167
+ error: `Server delete succeeded, but Matrix unlink failed: ${
168
+ err instanceof Error ? err.message : String(err)
169
+ }`,
170
+ };
171
+ }
172
+
173
+ if (!unlinked) {
174
+ return {
175
+ realmUrl,
176
+ removed: false,
177
+ serverDeleted: true,
178
+ unlinked: false,
179
+ previousCount,
180
+ nextCount: previousCount,
181
+ error:
182
+ 'Server delete succeeded, but Matrix account_data did not contain the URL by the time we PUT (concurrent edit?). Server-side files are gone; please refresh and check your realm list.',
183
+ };
184
+ }
185
+
186
+ return {
187
+ realmUrl,
188
+ removed: true,
189
+ serverDeleted: true,
190
+ unlinked,
191
+ previousCount,
192
+ nextCount,
193
+ };
194
+ }
195
+
196
+ async function safeReadResponseText(response: Response): Promise<string> {
197
+ try {
198
+ return await response.text();
199
+ } catch {
200
+ return '<no response body>';
201
+ }
202
+ }
203
+
204
+ interface RemoveCliOptions {
205
+ yes?: boolean;
206
+ dryRun?: boolean;
207
+ }
208
+
209
+ export function registerRemoveCommand(realm: Command): void {
210
+ realm
211
+ .command('remove')
212
+ .description(
213
+ 'Remove a realm — deletes server-side files and unlinks it from your realm list',
214
+ )
215
+ .argument('<realm-url>', 'realm URL to remove')
216
+ .option('-y, --yes', 'Skip the interactive confirmation prompt')
217
+ .option('--dry-run', 'Preview the change without writing to Matrix')
218
+ .action(async (realmUrlInput: string, opts: RemoveCliOptions) => {
219
+ let normalized = ensureTrailingSlash(realmUrlInput.trim());
220
+
221
+ let preview = await removeRealm({
222
+ realmUrl: normalized,
223
+ dryRun: true,
224
+ });
225
+
226
+ if (preview.error && !preview.notInList) {
227
+ console.error(`${FG_RED}Error:${RESET} ${preview.error}`);
228
+ process.exit(1);
229
+ }
230
+
231
+ if (preview.notInList) {
232
+ console.error(`${FG_RED}Error:${RESET} ${preview.error}`);
233
+ process.exit(1);
234
+ }
235
+
236
+ console.log(`Remove target: ${FG_CYAN}${preview.realmUrl}${RESET}`);
237
+ console.log(
238
+ `${DIM}app.boxel.realms: ${preview.previousCount} -> ${preview.nextCount}${RESET}`,
239
+ );
240
+
241
+ if (opts.dryRun) {
242
+ console.log(
243
+ `${DIM}[DRY RUN] No server delete or Matrix changes sent.${RESET}`,
244
+ );
245
+ return;
246
+ }
247
+
248
+ if (!opts.yes) {
249
+ if (!process.stdin.isTTY) {
250
+ console.error(
251
+ `${FG_RED}Error:${RESET} stdin is not a TTY. Pass --yes to confirm in non-interactive mode.`,
252
+ );
253
+ process.exit(1);
254
+ }
255
+ let answer = await prompt(
256
+ 'This will permanently delete the realm files, indexer state, and registry entry on the server. Proceed? (y/N) ',
257
+ );
258
+ if (!/^y/i.test(answer)) {
259
+ console.log(`${DIM}Cancelled.${RESET}`);
260
+ return;
261
+ }
262
+ }
263
+
264
+ let result = await removeRealm({ realmUrl: normalized });
265
+ if (result.error || !result.removed) {
266
+ console.error(
267
+ `${FG_RED}Error:${RESET} ${result.error ?? 'Removal did not complete.'}`,
268
+ );
269
+ if (result.serverDeleted && !result.unlinked) {
270
+ console.error(
271
+ `${DIM}The realm is gone, but your account_data still references ${result.realmUrl}.${RESET}`,
272
+ );
273
+ }
274
+ process.exit(1);
275
+ }
276
+
277
+ console.log(
278
+ `${FG_GREEN}Removed:${RESET} ${FG_CYAN}${result.realmUrl}${RESET}`,
279
+ );
280
+ });
281
+ }