@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
@@ -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
@@ -1,47 +1,25 @@
1
1
  import 'dotenv/config';
2
- import { Command } from 'commander';
3
2
  import { readFileSync } from 'fs';
4
3
  import { resolve } from 'path';
5
- import { profileCommand } from './commands/profile';
6
- import { registerRealmCommand } from './commands/realm/index';
7
- import { registerRunCommand } from './commands/run-command';
4
+ import { buildBoxelProgram } from './build-program';
5
+ import { setQuiet } from './lib/cli-log';
8
6
 
9
7
  const pkg = JSON.parse(
10
8
  readFileSync(resolve(__dirname, '../package.json'), 'utf-8'),
11
9
  );
12
10
 
13
- const program = new Command();
11
+ // `--quiet` is implemented by intercepting `console.log/info/debug`.
12
+ // New commands: write decorative output (status, confirmations, colored
13
+ // lines) with `console.log` — it's silenced for free under `--quiet`.
14
+ // For programmatic output (`--json` payloads, raw file bytes), use
15
+ // `cliLog.output(...)`. Full guidance: see `lib/cli-log.ts`.
16
+ //
17
+ // Belt-and-suspenders: also flip quiet mode based on a raw scan of argv,
18
+ // so any code that runs between Commander's option parsing and the
19
+ // `preAction` hook sees the right state. We scan for the long form only;
20
+ // `-q` could legitimately be the value of another option in the future.
21
+ if (process.argv.includes('--quiet')) {
22
+ setQuiet(true);
23
+ }
14
24
 
15
- program
16
- .name('boxel')
17
- .description('CLI tools for Boxel workspace management')
18
- .version(pkg.version);
19
-
20
- program
21
- .command('profile')
22
- .description('Manage saved profiles for different users/environments')
23
- .argument('[subcommand]', 'list | add | switch | remove | migrate')
24
- .argument('[arg]', 'Profile ID (for switch/remove)')
25
- .option('-u, --user <matrixId>', 'Matrix user ID (e.g., @user:boxel.ai)')
26
- .option('-p, --password <password>', 'Password (for add command)')
27
- .option('-n, --name <displayName>', 'Display name (for add command)')
28
- .action(
29
- async (
30
- subcommand?: string,
31
- arg?: string,
32
- options?: { user?: string; password?: string; name?: string },
33
- ) => {
34
- if (options?.password) {
35
- console.warn(
36
- 'Warning: Supplying a password via -p/--password may expose it in shell history and process listings. ' +
37
- 'For non-interactive usage, prefer the BOXEL_PASSWORD environment variable or use "boxel profile add" interactively.',
38
- );
39
- }
40
- await profileCommand(subcommand, arg, options);
41
- },
42
- );
43
-
44
- registerRealmCommand(program);
45
- registerRunCommand(program);
46
-
47
- program.parse();
25
+ buildBoxelProgram(pkg.version).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
+ }