@cardstack/boxel-cli 0.0.1 → 0.1.1

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 (42) 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 +35 -26
  6. package/src/build-program.ts +91 -0
  7. package/src/commands/file/delete.ts +110 -0
  8. package/src/commands/file/index.ts +20 -0
  9. package/src/commands/file/lint.ts +235 -0
  10. package/src/commands/file/list.ts +121 -0
  11. package/src/commands/file/read.ts +113 -0
  12. package/src/commands/file/touch.ts +222 -0
  13. package/src/commands/file/write.ts +152 -0
  14. package/src/commands/profile.ts +199 -106
  15. package/src/commands/read-transpiled.ts +120 -0
  16. package/src/commands/realm/cancel-indexing.ts +113 -0
  17. package/src/commands/realm/create.ts +1 -4
  18. package/src/commands/realm/history.ts +388 -0
  19. package/src/commands/realm/index.ts +12 -0
  20. package/src/commands/realm/list.ts +156 -0
  21. package/src/commands/realm/pull.ts +51 -17
  22. package/src/commands/realm/push.ts +79 -27
  23. package/src/commands/realm/remove.ts +281 -0
  24. package/src/commands/realm/sync.ts +160 -60
  25. package/src/commands/realm/wait-for-ready.ts +120 -0
  26. package/src/commands/realm/watch.ts +626 -0
  27. package/src/commands/run-command.ts +4 -3
  28. package/src/commands/search.ts +160 -0
  29. package/src/index.ts +16 -38
  30. package/src/lib/auth-resolver.ts +58 -0
  31. package/src/lib/auth.ts +56 -12
  32. package/src/lib/boxel-cli-client.ts +146 -279
  33. package/src/lib/cli-log.ts +132 -0
  34. package/src/lib/colors.ts +14 -9
  35. package/src/lib/find-checkpoint.ts +65 -0
  36. package/src/lib/profile-manager.ts +49 -4
  37. package/src/lib/prompt.ts +133 -0
  38. package/src/lib/realm-authenticator.ts +12 -0
  39. package/src/lib/realm-sync-base.ts +122 -16
  40. package/src/lib/seed-auth.ts +214 -0
  41. package/src/lib/watch-lock.ts +81 -0
  42. 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,
@@ -25,14 +25,19 @@ interface PushOptions extends SyncOptions {
25
25
  force?: boolean;
26
26
  }
27
27
 
28
+ // Fresh realms always include these server-managed cards even when the local
29
+ // workspace has never pulled them. Treat them as realm artifacts, not user
30
+ // drift, so `push --delete` only removes genuine remote-only user files.
31
+ const REMOTE_DELETE_EXCLUSIONS = new Set(['index.json', 'realm.json']);
32
+
28
33
  class RealmPusher extends RealmSyncBase {
29
34
  hasError = false;
30
35
 
31
36
  constructor(
32
37
  private pushOptions: PushOptions,
33
- profileManager: ProfileManager,
38
+ authenticator: RealmAuthenticator,
34
39
  ) {
35
- super(pushOptions, profileManager);
40
+ super(pushOptions, authenticator);
36
41
  }
37
42
 
38
43
  async sync(): Promise<void> {
@@ -225,10 +230,16 @@ class RealmPusher extends RealmSyncBase {
225
230
 
226
231
  if (this.pushOptions.deleteRemote) {
227
232
  const filesToDelete = new Set(initialRemoteFiles.keys());
233
+ const skippedDeleteArtifacts: string[] = [];
228
234
 
229
235
  for (const relativePath of filesToDelete) {
230
236
  if (isProtectedFile(relativePath)) {
231
237
  filesToDelete.delete(relativePath);
238
+ continue;
239
+ }
240
+ if (REMOTE_DELETE_EXCLUSIONS.has(relativePath)) {
241
+ filesToDelete.delete(relativePath);
242
+ skippedDeleteArtifacts.push(relativePath);
232
243
  }
233
244
  }
234
245
 
@@ -236,21 +247,26 @@ class RealmPusher extends RealmSyncBase {
236
247
  filesToDelete.delete(relativePath);
237
248
  }
238
249
 
239
- if (filesToDelete.size > 0) {
250
+ if (skippedDeleteArtifacts.length > 0) {
240
251
  console.log(
241
- `Deleting ${filesToDelete.size} remote files that don't exist locally`,
252
+ `Skipping ${skippedDeleteArtifacts.length} realm-managed remote artifact(s): ${skippedDeleteArtifacts.join(', ')}`,
242
253
  );
254
+ }
243
255
 
244
- await Promise.all(
245
- Array.from(filesToDelete).map(async (relativePath) => {
246
- try {
247
- await this.deleteFile(relativePath);
248
- } catch (error) {
249
- this.hasError = true;
250
- console.error(`Error deleting ${relativePath}:`, error);
251
- }
252
- }),
256
+ if (filesToDelete.size > 0) {
257
+ const deletePlan = Array.from(filesToDelete).sort();
258
+ console.log(
259
+ `Deleting ${deletePlan.length} remote files that don't exist locally: ${deletePlan.join(', ')}`,
253
260
  );
261
+
262
+ for (const relativePath of deletePlan) {
263
+ try {
264
+ await this.deleteFile(relativePath);
265
+ } catch (error) {
266
+ this.hasError = true;
267
+ console.error(`Error deleting ${relativePath}:`, error);
268
+ }
269
+ }
254
270
  }
255
271
  }
256
272
 
@@ -308,6 +324,18 @@ export interface PushCommandOptions {
308
324
  dryRun?: boolean;
309
325
  force?: boolean;
310
326
  profileManager?: ProfileManager;
327
+ /**
328
+ * Pre-resolved realm secret seed for administrative access. When set, the
329
+ * CLI mints a JWT locally and skips Matrix login + /_server-session +
330
+ * /_realm-auth. The `--realm-secret-seed` CLI flag is resolved via
331
+ * `resolveRealmSecretSeed` (env var or interactive prompt) before being
332
+ * passed here.
333
+ */
334
+ realmSecretSeed?: string;
335
+ /**
336
+ * @internal Test hook: supply an already-constructed authenticator.
337
+ */
338
+ authenticator?: RealmAuthenticator;
311
339
  }
312
340
 
313
341
  export function registerPushCommand(realm: Command): void {
@@ -322,13 +350,30 @@ export function registerPushCommand(realm: Command): void {
322
350
  .option('--delete', 'Delete remote files that do not exist locally')
323
351
  .option('--dry-run', 'Show what would be done without making changes')
324
352
  .option('--force', 'Upload all files, even if unchanged')
353
+ .option(
354
+ '--realm-secret-seed',
355
+ 'Administrative auth: prompt for a realm secret seed and mint a JWT locally instead of using a Matrix profile (env: BOXEL_REALM_SECRET_SEED)',
356
+ )
325
357
  .action(
326
358
  async (
327
359
  localDir: string,
328
360
  realmUrl: string,
329
- options: { delete?: boolean; dryRun?: boolean; force?: boolean },
361
+ options: {
362
+ delete?: boolean;
363
+ dryRun?: boolean;
364
+ force?: boolean;
365
+ realmSecretSeed?: boolean;
366
+ },
330
367
  ) => {
331
- await pushCommand(localDir, realmUrl, options);
368
+ const realmSecretSeed = await resolveRealmSecretSeed(
369
+ options.realmSecretSeed === true,
370
+ );
371
+ await pushCommand(localDir, realmUrl, {
372
+ delete: options.delete,
373
+ dryRun: options.dryRun,
374
+ force: options.force,
375
+ realmSecretSeed,
376
+ });
332
377
  },
333
378
  );
334
379
  }
@@ -338,13 +383,20 @@ export async function pushCommand(
338
383
  realmUrl: string,
339
384
  options: PushCommandOptions,
340
385
  ): 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);
386
+ let authenticator: RealmAuthenticator;
387
+ if (options.authenticator) {
388
+ authenticator = options.authenticator;
389
+ } else {
390
+ const resolution = resolveRealmAuthenticator({
391
+ realmUrl,
392
+ realmSecretSeed: options.realmSecretSeed,
393
+ profileManager: options.profileManager,
394
+ });
395
+ if (!resolution.ok) {
396
+ console.error(`Error: ${resolution.error}`);
397
+ process.exit(1);
398
+ }
399
+ authenticator = resolution.authenticator;
348
400
  }
349
401
 
350
402
  if (!(await pathExists(localDir))) {
@@ -361,7 +413,7 @@ export async function pushCommand(
361
413
  dryRun: options.dryRun,
362
414
  force: options.force,
363
415
  },
364
- pm,
416
+ authenticator,
365
417
  );
366
418
 
367
419
  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
+ }