@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,160 @@
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_RED, DIM, RESET } from '../lib/colors';
9
+ import { cliLog } from '../lib/cli-log';
10
+
11
+ export interface SearchResult {
12
+ ok: boolean;
13
+ status?: number;
14
+ data?: Record<string, unknown>[];
15
+ error?: string;
16
+ }
17
+
18
+ export interface SearchCommandOptions {
19
+ profileManager?: ProfileManager;
20
+ }
21
+
22
+ /**
23
+ * Federated search across one or more realms via the `_federated-search`
24
+ * server endpoint.
25
+ *
26
+ * Sends a QUERY request with the provided query object and a `realms` array
27
+ * merged into the request body. Uses the server JWT via
28
+ * `ProfileManager.authedRealmServerFetch`.
29
+ */
30
+ export async function search(
31
+ realmUrls: string | string[],
32
+ query: Record<string, unknown>,
33
+ options?: SearchCommandOptions,
34
+ ): Promise<SearchResult> {
35
+ let pm = options?.profileManager ?? getProfileManager();
36
+ let active = pm.getActiveProfile();
37
+ if (!active) {
38
+ return {
39
+ ok: false,
40
+ error: NO_ACTIVE_PROFILE_ERROR,
41
+ };
42
+ }
43
+
44
+ let realmServerUrl = active.profile.realmServerUrl.replace(/\/$/, '');
45
+ let searchUrl = `${realmServerUrl}/_federated-search`;
46
+
47
+ let realms = (Array.isArray(realmUrls) ? realmUrls : [realmUrls]).map(
48
+ ensureTrailingSlash,
49
+ );
50
+
51
+ try {
52
+ let response = await pm.authedRealmServerFetch(searchUrl, {
53
+ method: 'QUERY',
54
+ headers: {
55
+ Accept: 'application/vnd.card+json',
56
+ 'Content-Type': 'application/json',
57
+ },
58
+ body: JSON.stringify({ realms, ...query }),
59
+ });
60
+
61
+ if (!response.ok) {
62
+ let body = await response.text();
63
+ return {
64
+ ok: false,
65
+ status: response.status,
66
+ error: `HTTP ${response.status}: ${body.slice(0, 300)}`,
67
+ };
68
+ }
69
+
70
+ let result = (await response.json()) as {
71
+ data?: Record<string, unknown>[];
72
+ };
73
+ return { ok: true, status: response.status, data: result.data };
74
+ } catch (err) {
75
+ return {
76
+ ok: false,
77
+ status: 0,
78
+ error: err instanceof Error ? err.message : String(err),
79
+ };
80
+ }
81
+ }
82
+
83
+ interface SearchCliOptions {
84
+ realm: string[];
85
+ query: string;
86
+ json?: boolean;
87
+ }
88
+
89
+ export function registerSearchCommand(program: Command): void {
90
+ program
91
+ .command('search')
92
+ .description('Federated search across realms using a JSON query')
93
+ .requiredOption(
94
+ '--realm <realm-url>',
95
+ 'Realm URL to search (repeatable)',
96
+ (val: string, acc: string[]) => {
97
+ acc.push(val);
98
+ return acc;
99
+ },
100
+ [] as string[],
101
+ )
102
+ .requiredOption('--query <json>', 'JSON query object (as a string)')
103
+ .option('--json', 'Output raw JSON response')
104
+ .action(async (opts: SearchCliOptions) => {
105
+ if (opts.realm.length === 0) {
106
+ console.error(
107
+ `${FG_RED}Error:${RESET} At least one --realm is required`,
108
+ );
109
+ process.exit(1);
110
+ }
111
+
112
+ let query: Record<string, unknown>;
113
+ try {
114
+ let parsed = JSON.parse(opts.query);
115
+ if (
116
+ typeof parsed !== 'object' ||
117
+ parsed === null ||
118
+ Array.isArray(parsed)
119
+ ) {
120
+ console.error(
121
+ `${FG_RED}Error:${RESET} --query must be a JSON object, got ${Array.isArray(parsed) ? 'array' : typeof parsed}`,
122
+ );
123
+ process.exit(1);
124
+ }
125
+ query = parsed as Record<string, unknown>;
126
+ } catch (err) {
127
+ console.error(
128
+ `${FG_RED}Error:${RESET} Invalid JSON in --query: ${err instanceof Error ? err.message : String(err)}`,
129
+ );
130
+ process.exit(1);
131
+ return; // unreachable, but helps TS
132
+ }
133
+
134
+ let result: SearchResult;
135
+ try {
136
+ result = await search(opts.realm, query);
137
+ } catch (err) {
138
+ console.error(
139
+ `${FG_RED}Error:${RESET} ${err instanceof Error ? err.message : String(err)}`,
140
+ );
141
+ process.exit(1);
142
+ return;
143
+ }
144
+
145
+ if (opts.json) {
146
+ cliLog.output(JSON.stringify(result, null, 2));
147
+ } else if (result.ok) {
148
+ cliLog.output(JSON.stringify(result.data ?? [], null, 2));
149
+ } else {
150
+ console.error(
151
+ `${DIM}Status:${RESET} ${result.status ?? '(no status)'}`,
152
+ );
153
+ console.error(`${FG_RED}Error:${RESET} ${result.error}`);
154
+ }
155
+
156
+ if (!result.ok) {
157
+ process.exit(1);
158
+ }
159
+ });
160
+ }
package/src/index.ts CHANGED
@@ -3,8 +3,12 @@ import { Command } from 'commander';
3
3
  import { readFileSync } from 'fs';
4
4
  import { resolve } from 'path';
5
5
  import { profileCommand } from './commands/profile';
6
+ import { registerReadTranspiledCommand } from './commands/read-transpiled';
6
7
  import { registerRealmCommand } from './commands/realm/index';
8
+ import { registerFileCommand } from './commands/file/index';
7
9
  import { registerRunCommand } from './commands/run-command';
10
+ import { registerSearchCommand } from './commands/search';
11
+ import { setQuiet } from './lib/cli-log';
8
12
 
9
13
  const pkg = JSON.parse(
10
14
  readFileSync(resolve(__dirname, '../package.json'), 'utf-8'),
@@ -12,10 +16,36 @@ const pkg = JSON.parse(
12
16
 
13
17
  const program = new Command();
14
18
 
19
+ // `--quiet` is implemented by intercepting `console.log/info/debug`.
20
+ // New commands: write decorative output (status, confirmations, colored
21
+ // lines) with `console.log` — it's silenced for free under `--quiet`.
22
+ // For programmatic output (`--json` payloads, raw file bytes), use
23
+ // `cliLog.output(...)`. Full guidance: see `lib/cli-log.ts`.
15
24
  program
16
25
  .name('boxel')
17
26
  .description('CLI tools for Boxel workspace management')
18
- .version(pkg.version);
27
+ .version(pkg.version)
28
+ .option(
29
+ '-q, --quiet',
30
+ 'Suppress informational progress logs (info/log/debug). Errors and warnings, plus command result payloads (JSON, file contents), are still emitted. Use this when invoking the CLI from automation (e.g. the software factory test harness) to keep stdout focused on the result.',
31
+ )
32
+ // Toggle quiet mode as soon as the global option is parsed, so that any
33
+ // module-level setup happening inside command actions sees the right
34
+ // state. Commander invokes this hook before any subcommand action.
35
+ .hook('preAction', (thisCommand) => {
36
+ let opts = thisCommand.optsWithGlobals?.() ?? thisCommand.opts();
37
+ if (opts.quiet) {
38
+ setQuiet(true);
39
+ }
40
+ });
41
+
42
+ // Belt-and-suspenders: also flip quiet mode based on a raw scan of argv,
43
+ // so any code that runs between Commander's option parsing and the
44
+ // `preAction` hook sees the right state. We scan for the long form only;
45
+ // `-q` could legitimately be the value of another option in the future.
46
+ if (process.argv.includes('--quiet')) {
47
+ setQuiet(true);
48
+ }
19
49
 
20
50
  program
21
51
  .command('profile')
@@ -25,11 +55,36 @@ program
25
55
  .option('-u, --user <matrixId>', 'Matrix user ID (e.g., @user:boxel.ai)')
26
56
  .option('-p, --password <password>', 'Password (for add command)')
27
57
  .option('-n, --name <displayName>', 'Display name (for add command)')
58
+ .option(
59
+ '-m, --matrix-url <url>',
60
+ 'Matrix server URL (for add command with non-standard domains)',
61
+ )
62
+ .option(
63
+ '-r, --realm-server-url <url>',
64
+ 'Realm server URL (for add command with non-standard domains)',
65
+ )
66
+ .addHelpText(
67
+ 'after',
68
+ `
69
+ Environment variables (for 'add'):
70
+ BOXEL_PASSWORD Password; preferred over -p to avoid shell history.
71
+ BOXEL_ENVIRONMENT An env-mode slug (e.g. a branch name), interpreted
72
+ like scripts/env-slug.sh: URLs are derived as
73
+ http://matrix.<slug>.localhost and
74
+ http://realm-server.<slug>.localhost/. Overridden
75
+ by --matrix-url / --realm-server-url if provided.`,
76
+ )
28
77
  .action(
29
78
  async (
30
79
  subcommand?: string,
31
80
  arg?: string,
32
- options?: { user?: string; password?: string; name?: string },
81
+ options?: {
82
+ user?: string;
83
+ password?: string;
84
+ name?: string;
85
+ matrixUrl?: string;
86
+ realmServerUrl?: string;
87
+ },
33
88
  ) => {
34
89
  if (options?.password) {
35
90
  console.warn(
@@ -41,7 +96,10 @@ program
41
96
  },
42
97
  );
43
98
 
99
+ registerFileCommand(program);
44
100
  registerRealmCommand(program);
45
101
  registerRunCommand(program);
102
+ registerSearchCommand(program);
103
+ registerReadTranspiledCommand(program);
46
104
 
47
105
  program.parse();
@@ -0,0 +1,58 @@
1
+ import {
2
+ getProfileManager,
3
+ type ProfileManager,
4
+ NO_ACTIVE_PROFILE_ERROR,
5
+ } from './profile-manager';
6
+ import type { RealmAuthenticator } from './realm-authenticator';
7
+ import { SeedAuthenticator } from './seed-auth';
8
+
9
+ export interface AuthResolverOptions {
10
+ /** Realm URL the command is operating on (used for registering the seed-auth cache). */
11
+ realmUrl: string;
12
+ /**
13
+ * Already-resolved realm secret seed. Callers who want env + prompt
14
+ * resolution should go through `resolveRealmSecretSeed` in `./prompt` first.
15
+ */
16
+ realmSecretSeed?: string;
17
+ /** Override the ProfileManager (tests). When seed mode is active we won't touch it. */
18
+ profileManager?: ProfileManager;
19
+ }
20
+
21
+ export type AuthResolution =
22
+ | { ok: true; authenticator: RealmAuthenticator; mode: 'seed' | 'profile' }
23
+ | { ok: false; error: string };
24
+
25
+ /**
26
+ * Pick between seed-based auth and profile-based auth.
27
+ *
28
+ * - If `realmSecretSeed` is present, use `SeedAuthenticator`. We do NOT
29
+ * require a profile in this mode — operators using the seed typically
30
+ * don't have a Matrix account configured.
31
+ * - Otherwise, fall back to the profile flow and require an active profile.
32
+ */
33
+ export function resolveRealmAuthenticator(
34
+ options: AuthResolverOptions,
35
+ ): AuthResolution {
36
+ if (options.realmSecretSeed) {
37
+ // registerRealmUrl throws on a malformed realm URL; surface that as a
38
+ // resolver error so pull/push/sync keep their friendly CLI error path.
39
+ try {
40
+ const authenticator = new SeedAuthenticator({
41
+ seed: options.realmSecretSeed,
42
+ });
43
+ authenticator.registerRealmUrl(options.realmUrl);
44
+ return { ok: true, authenticator, mode: 'seed' };
45
+ } catch (error) {
46
+ return {
47
+ ok: false,
48
+ error: error instanceof Error ? error.message : String(error),
49
+ };
50
+ }
51
+ }
52
+
53
+ const pm = options.profileManager ?? getProfileManager();
54
+ if (!pm.getActiveProfile()) {
55
+ return { ok: false, error: NO_ACTIVE_PROFILE_ERROR };
56
+ }
57
+ return { ok: true, authenticator: pm, mode: 'profile' };
58
+ }
package/src/lib/auth.ts CHANGED
@@ -14,6 +14,7 @@ interface MatrixLoginResponse {
14
14
  }
15
15
 
16
16
  import { APP_BOXEL_REALMS_EVENT_TYPE } from '@cardstack/runtime-common/matrix-constants';
17
+ import { ensureTrailingSlash } from '@cardstack/runtime-common/paths';
17
18
 
18
19
  export async function matrixLogin(
19
20
  matrixUrl: string,
@@ -127,31 +128,40 @@ export async function getRealmTokens(
127
128
  return (await response.json()) as RealmTokens;
128
129
  }
129
130
 
130
- export async function addRealmToMatrixAccountData(
131
- matrixAuth: MatrixAuth,
132
- realmUrl: string,
133
- ): Promise<void> {
134
- let accountDataUrl = new URL(
131
+ function userRealmsAccountDataUrl(matrixAuth: MatrixAuth): string {
132
+ return new URL(
135
133
  `_matrix/client/v3/user/${encodeURIComponent(matrixAuth.userId)}/account_data/${APP_BOXEL_REALMS_EVENT_TYPE}`,
136
134
  matrixAuth.matrixUrl,
137
135
  ).href;
136
+ }
138
137
 
139
- let existingRealms: string[] = [];
138
+ export async function getUserRealmsFromMatrixAccountData(
139
+ matrixAuth: MatrixAuth,
140
+ ): Promise<string[]> {
140
141
  try {
141
- let getResponse = await fetch(accountDataUrl, {
142
+ let response = await fetch(userRealmsAccountDataUrl(matrixAuth), {
142
143
  headers: { Authorization: `Bearer ${matrixAuth.accessToken}` },
143
144
  });
144
- if (getResponse.ok) {
145
- let data = (await getResponse.json()) as { realms?: string[] };
146
- existingRealms = Array.isArray(data.realms) ? [...data.realms] : [];
145
+ if (!response.ok) {
146
+ return [];
147
147
  }
148
+ let data = (await response.json()) as { realms?: string[] };
149
+ return Array.isArray(data.realms) ? [...data.realms] : [];
148
150
  } catch {
149
- // Best-effort — if we can't read existing realms, start fresh
151
+ // Best-effort — treat unreachable account data as an empty list
152
+ return [];
150
153
  }
154
+ }
155
+
156
+ export async function addRealmToMatrixAccountData(
157
+ matrixAuth: MatrixAuth,
158
+ realmUrl: string,
159
+ ): Promise<void> {
160
+ let existingRealms = await getUserRealmsFromMatrixAccountData(matrixAuth);
151
161
 
152
162
  if (!existingRealms.includes(realmUrl)) {
153
163
  existingRealms.push(realmUrl);
154
- let putResponse = await fetch(accountDataUrl, {
164
+ let putResponse = await fetch(userRealmsAccountDataUrl(matrixAuth), {
155
165
  method: 'PUT',
156
166
  headers: {
157
167
  'Content-Type': 'application/json',
@@ -167,3 +177,37 @@ export async function addRealmToMatrixAccountData(
167
177
  }
168
178
  }
169
179
  }
180
+
181
+ // Returns true when at least one entry was removed and a write occurred,
182
+ // false when no entry matched the URL (caller decides how to surface that
183
+ // to the user). Comparison is normalized via `ensureTrailingSlash` and every
184
+ // matching entry is dropped, so legacy duplicates like `https://host/realm`
185
+ // + `https://host/realm/` are both cleaned out in a single PUT.
186
+ export async function removeRealmFromMatrixAccountData(
187
+ matrixAuth: MatrixAuth,
188
+ realmUrl: string,
189
+ ): Promise<boolean> {
190
+ let target = ensureTrailingSlash(realmUrl);
191
+ let existingRealms = await getUserRealmsFromMatrixAccountData(matrixAuth);
192
+ let next = existingRealms.filter(
193
+ (url) => ensureTrailingSlash(url) !== target,
194
+ );
195
+ if (next.length === existingRealms.length) {
196
+ return false;
197
+ }
198
+ let putResponse = await fetch(userRealmsAccountDataUrl(matrixAuth), {
199
+ method: 'PUT',
200
+ headers: {
201
+ 'Content-Type': 'application/json',
202
+ Authorization: `Bearer ${matrixAuth.accessToken}`,
203
+ },
204
+ body: JSON.stringify({ realms: next }),
205
+ });
206
+ if (!putResponse.ok) {
207
+ let text = await putResponse.text();
208
+ throw new Error(
209
+ `Failed to update Matrix account data: ${putResponse.status} ${text}`,
210
+ );
211
+ }
212
+ return true;
213
+ }