@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
@@ -1,13 +1,12 @@
1
- import * as readline from 'readline';
2
- import { Writable } from 'stream';
3
1
  import type { ProfileManager } from '../lib/profile-manager';
4
2
  import {
5
3
  getProfileManager,
6
4
  formatProfileBadge,
5
+ getDomainFromMatrixId,
7
6
  getEnvironmentFromMatrixId,
8
- getEnvironmentLabel,
9
7
  getUsernameFromMatrixId,
10
8
  } from '../lib/profile-manager';
9
+ import { prompt, promptPassword } from '../lib/prompt';
11
10
  import {
12
11
  FG_GREEN,
13
12
  FG_YELLOW,
@@ -19,92 +18,94 @@ import {
19
18
  RESET,
20
19
  } from '../lib/colors';
21
20
 
22
- function prompt(question: string): Promise<string> {
23
- const rl = readline.createInterface({
24
- input: process.stdin,
25
- output: process.stdout,
26
- });
27
-
28
- return new Promise((resolve) => {
29
- rl.question(question, (answer) => {
30
- rl.close();
31
- resolve(answer.trim());
32
- });
33
- });
21
+ export interface ProfileCommandOptions {
22
+ user?: string;
23
+ password?: string;
24
+ name?: string;
25
+ matrixUrl?: string;
26
+ realmServerUrl?: string;
34
27
  }
35
28
 
36
- function promptPassword(question: string): Promise<string> {
37
- const mutableOutput = new Writable({
38
- write: (_chunk, _encoding, callback) => callback(),
39
- });
40
- const rl = readline.createInterface({
41
- input: process.stdin,
42
- output: mutableOutput,
43
- terminal: true,
44
- });
45
-
46
- return new Promise((resolve, reject) => {
47
- const stdin = process.stdin;
48
- const wasFlowing = stdin.readableFlowing;
49
-
50
- if (stdin.isTTY) {
51
- stdin.setRawMode(true);
52
- }
53
-
54
- const cleanup = () => {
55
- stdin.removeListener('data', onData);
56
- if (stdin.isTTY) {
57
- stdin.setRawMode(false);
58
- }
59
- rl.close();
60
- if (!wasFlowing) {
61
- stdin.pause();
62
- }
63
- };
29
+ interface EnvironmentDefaults {
30
+ domain: string;
31
+ matrixUrl: string;
32
+ realmServerUrl: string;
33
+ }
64
34
 
65
- const onData = (char: Buffer) => {
66
- try {
67
- const c = char.toString();
68
- if (c === '\n' || c === '\r') {
69
- cleanup();
70
- process.stdout.write('\n');
71
- resolve(password);
72
- } else if (c === '\u0003') {
73
- // Ctrl+C
74
- cleanup();
75
- process.exit();
76
- } else if (c === '\u007F' || c === '\b') {
77
- // Backspace
78
- if (password.length > 0) {
79
- password = password.slice(0, -1);
80
- process.stdout.write('\b \b');
81
- }
82
- } else {
83
- password += c;
84
- process.stdout.write('*');
85
- }
86
- } catch (e) {
87
- cleanup();
88
- reject(e);
89
- }
90
- };
35
+ const MENU_ENVIRONMENTS: Record<
36
+ 'staging' | 'production' | 'local',
37
+ EnvironmentDefaults
38
+ > = {
39
+ staging: {
40
+ domain: 'stack.cards',
41
+ matrixUrl: 'https://matrix-staging.stack.cards',
42
+ realmServerUrl: 'https://realms-staging.stack.cards/',
43
+ },
44
+ production: {
45
+ domain: 'boxel.ai',
46
+ matrixUrl: 'https://matrix.boxel.ai',
47
+ realmServerUrl: 'https://app.boxel.ai/',
48
+ },
49
+ local: {
50
+ domain: 'localhost',
51
+ matrixUrl: 'http://localhost:8008',
52
+ realmServerUrl: 'http://localhost:4201/',
53
+ },
54
+ };
55
+
56
+ // Validate and normalize a Matrix or realm-server URL provided by the user
57
+ // (via --matrix-url / --realm-server-url or the interactive Custom prompt).
58
+ // Returns the trimmed input on success; exits 1 with a clear message
59
+ // otherwise. Without this, downstream code (fetch, realm auth, etc.) would
60
+ // throw on invalid input far away from where the value was entered.
61
+ function validateUrl(input: string, label: string): string {
62
+ const trimmed = input.trim();
63
+ let parsed: URL;
64
+ try {
65
+ parsed = new URL(trimmed);
66
+ } catch {
67
+ console.error(
68
+ `${FG_RED}Error:${RESET} ${label} "${input}" is not a valid URL.`,
69
+ );
70
+ process.exit(1);
71
+ }
72
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
73
+ console.error(
74
+ `${FG_RED}Error:${RESET} ${label} "${input}" must use http:// or https://.`,
75
+ );
76
+ process.exit(1);
77
+ }
78
+ return trimmed;
79
+ }
91
80
 
92
- let password = '';
93
- try {
94
- process.stdout.write(question);
95
- stdin.on('data', onData);
96
- stdin.resume();
97
- } catch (e) {
98
- cleanup();
99
- reject(e);
100
- }
101
- });
81
+ // Matches scripts/env-slug.sh: lowercase, "/" -> "-", strip chars outside
82
+ // [a-z0-9-], collapse runs of "-", trim leading/trailing "-".
83
+ function computeEnvSlug(name: string): string {
84
+ return name
85
+ .toLowerCase()
86
+ .replace(/\//g, '-')
87
+ .replace(/[^a-z0-9-]/g, '')
88
+ .replace(/-+/g, '-')
89
+ .replace(/^-+|-+$/g, '');
102
90
  }
103
91
 
104
- export interface ProfileCommandOptions {
105
- user?: string;
106
- password?: string;
107
- name?: string;
92
+ // Derive URLs from BOXEL_ENVIRONMENT using the same ".${slug}.localhost"
93
+ // pattern that mise-tasks/lib/env-vars.sh produces for env-mode local dev.
94
+ function resolveBoxelEnvironment(): EnvironmentDefaults | null {
95
+ const raw = process.env.BOXEL_ENVIRONMENT;
96
+ if (!raw || !raw.trim()) return null;
97
+ const slug = computeEnvSlug(raw);
98
+ if (!slug) {
99
+ console.error(
100
+ `${FG_RED}Error:${RESET} BOXEL_ENVIRONMENT="${raw}" contains no slug characters (expected letters, digits, or "-").`,
101
+ );
102
+ process.exit(1);
103
+ }
104
+ return {
105
+ domain: `${slug}.localhost`,
106
+ matrixUrl: `http://matrix.${slug}.localhost`,
107
+ realmServerUrl: `http://realm-server.${slug}.localhost/`,
108
+ };
108
109
  }
109
110
 
110
111
  export async function profileCommand(
@@ -122,14 +123,39 @@ export async function profileCommand(
122
123
  case 'add': {
123
124
  const password = options?.password || process.env.BOXEL_PASSWORD;
124
125
  if (options?.user && password) {
126
+ const matrixUrl = options.matrixUrl
127
+ ? validateUrl(options.matrixUrl, '--matrix-url')
128
+ : undefined;
129
+ const realmServerUrl = options.realmServerUrl
130
+ ? validateUrl(options.realmServerUrl, '--realm-server-url')
131
+ : undefined;
132
+ // BOXEL_ENVIRONMENT only fills in URLs when (a) at least one flag is
133
+ // missing, AND (b) the Matrix ID's domain isn't a known standard
134
+ // (stack.cards / boxel.ai / localhost). Otherwise an unrelated
135
+ // BOXEL_ENVIRONMENT in the shell would silently produce a profile
136
+ // whose Matrix ID and URLs disagree — and an invalid env value
137
+ // (e.g. one that slugs to empty) would kill a fully-specified
138
+ // invocation where the env was meant to be overridden anyway.
139
+ const matrixIdEnv = getEnvironmentFromMatrixId(options.user);
140
+ const isStandardDomain = matrixIdEnv !== 'unknown';
141
+ const needsEnvDefaults =
142
+ !isStandardDomain && (!matrixUrl || !realmServerUrl);
143
+ const envDefaults = needsEnvDefaults ? resolveBoxelEnvironment() : null;
144
+ if (envDefaults) {
145
+ console.log(
146
+ `${DIM}Using BOXEL_ENVIRONMENT=${process.env.BOXEL_ENVIRONMENT}${RESET}`,
147
+ );
148
+ }
125
149
  await addProfileNonInteractive(
126
150
  manager,
127
151
  options.user,
128
152
  password,
129
153
  options.name,
154
+ matrixUrl ?? envDefaults?.matrixUrl,
155
+ realmServerUrl ?? envDefaults?.realmServerUrl,
130
156
  );
131
157
  } else {
132
- await addProfile(manager);
158
+ await addProfile(manager, resolveBoxelEnvironment());
133
159
  }
134
160
  break;
135
161
  }
@@ -200,14 +226,12 @@ async function listProfiles(manager: ProfileManager): Promise<void> {
200
226
  const env = getEnvironmentFromMatrixId(id);
201
227
 
202
228
  const marker = isActive ? `${FG_GREEN}\u2605${RESET} ` : ' ';
203
- const envLabel = getEnvironmentLabel(env);
229
+ const domain = getDomainFromMatrixId(id);
204
230
  const envColor = env === 'production' ? FG_MAGENTA : FG_CYAN;
205
231
 
206
232
  console.log(`${marker}${BOLD}${id}${RESET}`);
207
233
  console.log(` ${DIM}Name:${RESET} ${profile.displayName}`);
208
- console.log(
209
- ` ${DIM}Environment:${RESET} ${envColor}${envLabel}${RESET}`,
210
- );
234
+ console.log(` ${DIM}Environment:${RESET} ${envColor}${domain}${RESET}`);
211
235
  console.log(` ${DIM}Realm Server:${RESET} ${profile.realmServerUrl}`);
212
236
  console.log('');
213
237
  }
@@ -217,34 +241,77 @@ async function listProfiles(manager: ProfileManager): Promise<void> {
217
241
  }
218
242
  }
219
243
 
220
- async function addProfile(manager: ProfileManager): Promise<void> {
221
- console.log(`\n${BOLD}Add New Profile${RESET}\n`);
222
-
244
+ async function promptEnvironmentMenu(): Promise<{
245
+ domain: string;
246
+ matrixUrl: string;
247
+ realmServerUrl: string;
248
+ }> {
223
249
  console.log(`Which environment?`);
224
250
  console.log(` ${FG_CYAN}1${RESET}) Staging (realms-staging.stack.cards)`);
225
251
  console.log(` ${FG_MAGENTA}2${RESET}) Production (app.boxel.ai)`);
226
252
  console.log(` ${FG_GREEN}3${RESET}) Local (localhost:4201)`);
253
+ console.log(` ${FG_YELLOW}4${RESET}) Custom (enter your own URLs)`);
254
+
255
+ const envChoice = await prompt('\nChoice [1/2/3/4]: ');
256
+
257
+ if (envChoice === '4') {
258
+ const matrixUrlInput = await prompt('Matrix server URL: ');
259
+ if (!matrixUrlInput) {
260
+ console.error(`${FG_RED}Error:${RESET} Matrix server URL is required.`);
261
+ process.exit(1);
262
+ }
263
+ const matrixUrl = validateUrl(matrixUrlInput, 'Matrix server URL');
264
+ const realmServerUrlInput = await prompt('Realm server URL: ');
265
+ if (!realmServerUrlInput) {
266
+ console.error(`${FG_RED}Error:${RESET} Realm server URL is required.`);
267
+ process.exit(1);
268
+ }
269
+ const realmServerUrl = validateUrl(realmServerUrlInput, 'Realm server URL');
270
+ // matrixUrl is already validated by validateUrl above, so new URL won't
271
+ // throw — the hostname fallback is just for the unlikely edge case of
272
+ // a parseable URL with empty hostname (e.g. "http:///path").
273
+ const defaultDomain = new URL(matrixUrl).hostname || 'custom';
274
+ const domainInput = await prompt(
275
+ `Domain for Matrix ID [${defaultDomain}]: `,
276
+ );
277
+ return {
278
+ domain: domainInput || defaultDomain,
279
+ matrixUrl,
280
+ realmServerUrl,
281
+ };
282
+ }
283
+
284
+ if (envChoice === '3') {
285
+ return { ...MENU_ENVIRONMENTS.local };
286
+ }
287
+ if (envChoice === '2') {
288
+ return { ...MENU_ENVIRONMENTS.production };
289
+ }
290
+ return { ...MENU_ENVIRONMENTS.staging };
291
+ }
227
292
 
228
- const envChoice = await prompt('\nChoice [1/2/3]: ');
229
- const isProduction = envChoice === '2';
230
- const isLocal = envChoice === '3';
293
+ async function addProfile(
294
+ manager: ProfileManager,
295
+ envDefaults?: EnvironmentDefaults | null,
296
+ ): Promise<void> {
297
+ console.log(`\n${BOLD}Add New Profile${RESET}\n`);
231
298
 
232
299
  let domain: string;
233
300
  let defaultMatrixUrl: string;
234
301
  let defaultRealmUrl: string;
235
302
 
236
- if (isLocal) {
237
- domain = 'localhost';
238
- defaultMatrixUrl = 'http://localhost:8008';
239
- defaultRealmUrl = 'http://localhost:4201/';
240
- } else if (isProduction) {
241
- domain = 'boxel.ai';
242
- defaultMatrixUrl = 'https://matrix.boxel.ai';
243
- defaultRealmUrl = 'https://app.boxel.ai/';
303
+ if (envDefaults) {
304
+ console.log(
305
+ `${DIM}Using BOXEL_ENVIRONMENT=${process.env.BOXEL_ENVIRONMENT}${RESET}`,
306
+ );
307
+ domain = envDefaults.domain;
308
+ defaultMatrixUrl = envDefaults.matrixUrl;
309
+ defaultRealmUrl = envDefaults.realmServerUrl;
244
310
  } else {
245
- domain = 'stack.cards';
246
- defaultMatrixUrl = 'https://matrix-staging.stack.cards';
247
- defaultRealmUrl = 'https://realms-staging.stack.cards/';
311
+ const menuResult = await promptEnvironmentMenu();
312
+ domain = menuResult.domain;
313
+ defaultMatrixUrl = menuResult.matrixUrl;
314
+ defaultRealmUrl = menuResult.realmServerUrl;
248
315
  }
249
316
 
250
317
  console.log(`\nEnter your Boxel username (without @ or domain)`);
@@ -381,6 +448,8 @@ async function addProfileNonInteractive(
381
448
  matrixId: string,
382
449
  password: string,
383
450
  displayName?: string,
451
+ matrixUrl?: string,
452
+ realmServerUrl?: string,
384
453
  ): Promise<void> {
385
454
  if (!matrixId.startsWith('@') || !matrixId.includes(':')) {
386
455
  console.error(
@@ -397,13 +466,37 @@ async function addProfileNonInteractive(
397
466
  if (displayName) {
398
467
  manager.updateDisplayName(matrixId, displayName);
399
468
  }
469
+ if (matrixUrl || realmServerUrl) {
470
+ const urlsChanged = manager.updateUrls(matrixId, {
471
+ matrixUrl,
472
+ realmServerUrl,
473
+ });
474
+ if (urlsChanged) {
475
+ console.log(
476
+ `${DIM}Updated server URLs and cleared cached realm tokens.${RESET}`,
477
+ );
478
+ }
479
+ }
400
480
  console.log(
401
481
  `${FG_GREEN}\u2713${RESET} Profile updated: ${formatProfileBadge(matrixId)}`,
402
482
  );
403
483
  return;
404
484
  }
405
485
 
406
- await manager.addProfile(matrixId, password, displayName);
486
+ try {
487
+ await manager.addProfile(
488
+ matrixId,
489
+ password,
490
+ displayName,
491
+ matrixUrl,
492
+ realmServerUrl,
493
+ );
494
+ } catch (err) {
495
+ console.error(
496
+ `${FG_RED}Error:${RESET} ${err instanceof Error ? err.message : String(err)}`,
497
+ );
498
+ process.exit(1);
499
+ }
407
500
  console.log(
408
501
  `${FG_GREEN}\u2713${RESET} Profile created: ${formatProfileBadge(matrixId)}`,
409
502
  );
@@ -0,0 +1,120 @@
1
+ import type { Command } from 'commander';
2
+ import { getProfileManager, type ProfileManager } from '../lib/profile-manager';
3
+ import { ensureTrailingSlash } from '@cardstack/runtime-common/paths';
4
+ import { FG_RED, DIM, RESET } from '../lib/colors';
5
+ import { cliLog } from '../lib/cli-log';
6
+
7
+ export interface ReadTranspiledResult {
8
+ ok: boolean;
9
+ status?: number;
10
+ /** Transpiled JavaScript output as text. */
11
+ content?: string;
12
+ error?: string;
13
+ }
14
+
15
+ export interface ReadTranspiledOptions {
16
+ profileManager?: ProfileManager;
17
+ }
18
+
19
+ interface ReadTranspiledCliOptions {
20
+ realm: string;
21
+ json?: boolean;
22
+ }
23
+
24
+ /**
25
+ * Fetch the TRANSPILED JavaScript output for a realm module.
26
+ *
27
+ * Runtime evaluation errors carry line/column references that point to
28
+ * the transpiled output, not the raw .gts source — this lets callers
29
+ * inspect what the realm actually compiled. The realm accepts the
30
+ * module path either with or without the `.gts` extension and returns
31
+ * the compiled JS when fetched with `Accept: *\/*`.
32
+ *
33
+ * Uses the per-realm JWT via `ProfileManager.authedRealmFetch`.
34
+ */
35
+ export async function readTranspiledModule(
36
+ realmUrl: string,
37
+ modulePath: string,
38
+ options?: ReadTranspiledOptions,
39
+ ): Promise<ReadTranspiledResult> {
40
+ let pm = options?.profileManager ?? getProfileManager();
41
+ let active = pm.getActiveProfile();
42
+ if (!active) {
43
+ throw new Error(
44
+ 'No active profile. Run `boxel profile add` to create one.',
45
+ );
46
+ }
47
+
48
+ let url = new URL(modulePath, ensureTrailingSlash(realmUrl)).href;
49
+
50
+ let response: Response;
51
+ try {
52
+ response = await pm.authedRealmFetch(url, {
53
+ method: 'GET',
54
+ headers: { Accept: '*/*' },
55
+ });
56
+ } catch (err) {
57
+ return {
58
+ ok: false,
59
+ error: err instanceof Error ? err.message : String(err),
60
+ };
61
+ }
62
+
63
+ if (!response.ok) {
64
+ let body = await response.text().catch(() => '(no body)');
65
+ return {
66
+ ok: false,
67
+ status: response.status,
68
+ error: `HTTP ${response.status}: ${body.slice(0, 300)}`,
69
+ };
70
+ }
71
+
72
+ let text = await response.text();
73
+ return { ok: true, status: response.status, content: text };
74
+ }
75
+
76
+ export function registerReadTranspiledCommand(program: Command): void {
77
+ program
78
+ .command('read-transpiled')
79
+ .description(
80
+ "Debugging tool ONLY for investigating runtime errors in .gts modules you've written. " +
81
+ 'Use when an eval or instantiate error reports a line/column number — those line ' +
82
+ 'numbers refer to the transpiled output, not your .gts source, so fetching the ' +
83
+ 'transpiled output is how you locate the offending source construct. Never use the ' +
84
+ 'transpiled output as a reference for how to write code: do not copy its patterns ' +
85
+ '(setComponentTemplate, precompileTemplate, wire-format templates, base64 CSS ' +
86
+ 'imports) into source. Always write idiomatic Ember / <template>-tag / CardDef source.',
87
+ )
88
+ .argument(
89
+ '<path>',
90
+ 'Realm-relative module path. The .gts extension is optional — the realm accepts either form.',
91
+ )
92
+ .requiredOption('--realm <realm-url>', 'The realm URL to fetch from')
93
+ .option('--json', 'Output raw JSON response')
94
+ .action(async (modulePath: string, opts: ReadTranspiledCliOptions) => {
95
+ let result: ReadTranspiledResult;
96
+ try {
97
+ result = await readTranspiledModule(opts.realm, modulePath);
98
+ } catch (err) {
99
+ console.error(
100
+ `${FG_RED}Error:${RESET} ${err instanceof Error ? err.message : String(err)}`,
101
+ );
102
+ process.exit(1);
103
+ }
104
+
105
+ if (opts.json) {
106
+ cliLog.output(JSON.stringify(result, null, 2));
107
+ } else if (result.ok) {
108
+ cliLog.output(result.content ?? '');
109
+ } else {
110
+ console.error(
111
+ `${DIM}Status:${RESET} ${result.status ?? '(no status)'}`,
112
+ );
113
+ console.error(`${FG_RED}Error:${RESET} ${result.error}`);
114
+ }
115
+
116
+ if (!result.ok) {
117
+ process.exit(1);
118
+ }
119
+ });
120
+ }
@@ -0,0 +1,113 @@
1
+ import type { Command } from 'commander';
2
+ import {
3
+ getProfileManager,
4
+ NO_ACTIVE_PROFILE_ERROR,
5
+ type ProfileManager,
6
+ } from '../../lib/profile-manager';
7
+ import { ensureTrailingSlash } from '@cardstack/runtime-common/paths';
8
+ import { FG_GREEN, FG_RED, RESET } from '../../lib/colors';
9
+ import { cliLog } from '../../lib/cli-log';
10
+
11
+ export interface CancelIndexingCommandOptions {
12
+ profileManager?: ProfileManager;
13
+ /** Also cancel queued/pending jobs. Defaults to false (running-only). */
14
+ cancelPending?: boolean;
15
+ }
16
+
17
+ export interface CancelIndexingResult {
18
+ ok: boolean;
19
+ error?: string;
20
+ }
21
+
22
+ interface CancelIndexingCliOptions {
23
+ realm: string;
24
+ cancelPending?: boolean;
25
+ json?: boolean;
26
+ }
27
+
28
+ /**
29
+ * Cancel indexing jobs for a realm.
30
+ *
31
+ * Sends a POST to `<realmUrl>/_cancel-indexing-job` with `{ cancelPending }`.
32
+ * By default cancels only running jobs; pass `cancelPending: true` to also
33
+ * cancel queued/pending jobs.
34
+ */
35
+ export async function cancelIndexing(
36
+ realmUrl: string,
37
+ options?: CancelIndexingCommandOptions,
38
+ ): Promise<CancelIndexingResult> {
39
+ let pm = options?.profileManager ?? getProfileManager();
40
+ let active = pm.getActiveProfile();
41
+ if (!active) {
42
+ return {
43
+ ok: false,
44
+ error: NO_ACTIVE_PROFILE_ERROR,
45
+ };
46
+ }
47
+
48
+ let cancelPending = options?.cancelPending ?? false;
49
+ let cancelUrl = `${ensureTrailingSlash(realmUrl)}_cancel-indexing-job`;
50
+
51
+ try {
52
+ let response = await pm.authedRealmFetch(cancelUrl, {
53
+ method: 'POST',
54
+ headers: {
55
+ Accept: 'application/json',
56
+ 'Content-Type': 'application/json',
57
+ },
58
+ body: JSON.stringify({ cancelPending }),
59
+ });
60
+
61
+ if (!response.ok) {
62
+ let body = await response.text().catch(() => '(no body)');
63
+ return {
64
+ ok: false,
65
+ error: `HTTP ${response.status}: ${body.slice(0, 300)}`,
66
+ };
67
+ }
68
+
69
+ return { ok: true };
70
+ } catch (err) {
71
+ return {
72
+ ok: false,
73
+ error: err instanceof Error ? err.message : String(err),
74
+ };
75
+ }
76
+ }
77
+
78
+ export function registerCancelIndexingCommand(realm: Command): void {
79
+ realm
80
+ .command('cancel-indexing')
81
+ .description(
82
+ 'Cancel running indexing jobs for a realm (use --cancel-pending to also cancel queued jobs)',
83
+ )
84
+ .requiredOption(
85
+ '--realm <realm-url>',
86
+ 'URL of the realm to cancel indexing for',
87
+ )
88
+ .option(
89
+ '--cancel-pending',
90
+ 'Also cancel queued/pending indexing jobs (default: cancel running only)',
91
+ )
92
+ .option('--json', 'Output raw JSON response')
93
+ .action(async (opts: CancelIndexingCliOptions) => {
94
+ let result = await cancelIndexing(opts.realm, {
95
+ cancelPending: opts.cancelPending,
96
+ });
97
+
98
+ if (opts.json) {
99
+ cliLog.output(JSON.stringify(result, null, 2));
100
+ if (!result.ok) {
101
+ process.exit(1);
102
+ }
103
+ } else if (result.ok) {
104
+ let scope = opts.cancelPending ? 'running and pending' : 'running';
105
+ console.log(
106
+ `${FG_GREEN}Cancelled ${scope} indexing jobs for ${opts.realm}${RESET}`,
107
+ );
108
+ } else {
109
+ console.error(`${FG_RED}Error:${RESET} ${result.error}`);
110
+ process.exit(1);
111
+ }
112
+ });
113
+ }
@@ -3,6 +3,7 @@ import {
3
3
  iconURLFor,
4
4
  getRandomBackgroundURL,
5
5
  } from '@cardstack/runtime-common/realm-display-defaults';
6
+ import { ensureTrailingSlash } from '@cardstack/runtime-common/paths';
6
7
  import {
7
8
  getProfileManager,
8
9
  type ProfileManager,
@@ -239,7 +240,3 @@ function extractRealmUrlFromError(
239
240
  `Could not determine realm URL from server error response for endpoint "${endpoint}" on "${realmServerUrl}". The response did not include an explicit realm URL.`,
240
241
  );
241
242
  }
242
-
243
- function ensureTrailingSlash(url: string): string {
244
- return url.endsWith('/') ? url : `${url}/`;
245
- }