@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
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "@cardstack/boxel-cli",
3
- "version": "0.0.1",
3
+ "version": "0.1.1",
4
4
  "license": "MIT",
5
5
  "description": "CLI tools for Boxel workspace management",
6
6
  "main": "./dist/index.js",
7
7
  "bin": {
8
- "boxel": "./dist/index.js"
8
+ "boxel": "./bin/boxel.js"
9
9
  },
10
10
  "files": [
11
11
  "dist/**/*",
@@ -28,36 +28,37 @@
28
28
  },
29
29
  "author": "Cardstack",
30
30
  "dependencies": {
31
- "@aws-crypto/sha256-js": "^5.2.0",
31
+ "@aws-crypto/sha256-js": "catalog:",
32
32
  "commander": "^13.1.0",
33
33
  "dotenv": "^16.4.7",
34
- "ignore": "^5.2.0",
34
+ "ignore": "catalog:",
35
+ "jsonwebtoken": "catalog:",
35
36
  "p-limit": "^7.3.0"
36
37
  },
37
38
  "devDependencies": {
38
- "@types/node": "^24.3.0",
39
- "@typescript-eslint/eslint-plugin": "^7.18.0",
40
- "@typescript-eslint/parser": "^7.18.0",
41
- "concurrently": "^8.2.2",
39
+ "@cardstack/local-types": "workspace:*",
40
+ "@cardstack/postgres": "workspace:*",
41
+ "@cardstack/runtime-common": "workspace:*",
42
+ "content-tag": "catalog:",
43
+ "@types/jsonwebtoken": "catalog:",
44
+ "@types/node": "catalog:",
45
+ "@typescript-eslint/eslint-plugin": "catalog:",
46
+ "@typescript-eslint/parser": "catalog:",
47
+ "concurrently": "catalog:",
42
48
  "esbuild": "^0.19.0",
43
- "eslint": "^8.57.1",
44
- "eslint-plugin-n": "^17.17.0",
45
- "eslint-plugin-prettier": "^5.5.4",
46
- "prettier": "^3.6.2",
49
+ "eslint": "catalog:",
50
+ "eslint-plugin-n": "catalog:",
51
+ "eslint-plugin-prettier": "catalog:",
52
+ "prettier": "catalog:",
47
53
  "ts-node": "^10.9.1",
48
- "tsx": "^4.0.0",
49
- "typescript": "~5.9.3",
50
- "vite": "^6.3.2",
51
- "vitest": "^2.1.9",
52
- "@cardstack/local-types": "0.0.0",
53
- "@cardstack/runtime-common": "1.0.0",
54
- "@cardstack/postgres": "0.0.0"
55
- },
56
- "publishConfig": {
57
- "access": "public"
54
+ "typescript": "catalog:",
55
+ "vite": "catalog:",
56
+ "vitest": "catalog:"
58
57
  },
59
58
  "scripts": {
60
- "build": "pnpm clean && tsx scripts/build.ts",
59
+ "build": "pnpm clean && NODE_NO_WARNINGS=1 ts-node --transpileOnly scripts/build.ts",
60
+ "build:plugin": "NODE_NO_WARNINGS=1 ts-node --transpileOnly scripts/build-plugin.ts",
61
+ "build:skills": "NODE_NO_WARNINGS=1 ts-node --transpileOnly scripts/build-skills.ts",
61
62
  "clean": "rm -rf dist/*",
62
63
  "start": "NODE_NO_WARNINGS=1 ts-node --transpileOnly src/index.ts",
63
64
  "lint": "concurrently \"pnpm:lint:*(!fix)\" --names \"lint:\"",
@@ -67,12 +68,20 @@
67
68
  "lint:types": "tsc --noEmit",
68
69
  "test": "vitest run",
69
70
  "test:unit": "vitest run --exclude 'tests/integration/**'",
71
+ "test:unit-exclude-smoke": "vitest run --exclude 'tests/integration/**' --exclude 'tests/smoke.test.ts'",
70
72
  "test:integration": "./tests/scripts/run-integration-with-test-pg.sh",
71
73
  "test:watch": "vitest",
72
74
  "version:patch": "npm version patch",
73
75
  "version:minor": "npm version minor",
74
76
  "version:major": "npm version major",
75
- "publish:npm": "npm publish",
76
- "publish:dry": "npm publish --dry-run"
77
+ "publish:npm": "pnpm publish --no-git-checks",
78
+ "publish:dry": "pnpm publish --dry-run --no-git-checks"
79
+ },
80
+ "publishConfig": {
81
+ "access": "public",
82
+ "provenance": true,
83
+ "bin": {
84
+ "boxel": "./dist/index.js"
85
+ }
77
86
  }
78
- }
87
+ }
@@ -0,0 +1,91 @@
1
+ import { Command } from 'commander';
2
+ import { profileCommand } from './commands/profile';
3
+ import { registerReadTranspiledCommand } from './commands/read-transpiled';
4
+ import { registerRealmCommand } from './commands/realm/index';
5
+ import { registerFileCommand } from './commands/file/index';
6
+ import { registerRunCommand } from './commands/run-command';
7
+ import { registerSearchCommand } from './commands/search';
8
+ import { setQuiet } from './lib/cli-log';
9
+
10
+ /**
11
+ * Construct the boxel CLI program with every command registered. Pure builder
12
+ * — does not call `program.parse()` and has no side effects on argv. Both the
13
+ * runtime entry point (`src/index.ts`) and the plugin generator
14
+ * (`scripts/build-plugin.ts`) call this so the Commander tree is one source of
15
+ * truth.
16
+ */
17
+ export function buildBoxelProgram(version: string): Command {
18
+ const program = new Command();
19
+
20
+ program
21
+ .name('boxel')
22
+ .description('CLI tools for Boxel workspace management')
23
+ .version(version)
24
+ .option(
25
+ '-q, --quiet',
26
+ '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.',
27
+ )
28
+ .hook('preAction', (thisCommand) => {
29
+ let opts = thisCommand.optsWithGlobals?.() ?? thisCommand.opts();
30
+ if (opts.quiet) {
31
+ setQuiet(true);
32
+ }
33
+ });
34
+
35
+ program
36
+ .command('profile')
37
+ .description('Manage saved profiles for different users/environments')
38
+ .argument('[subcommand]', 'list | add | switch | remove | migrate')
39
+ .argument('[arg]', 'Profile ID (for switch/remove)')
40
+ .option('-u, --user <matrixId>', 'Matrix user ID (e.g., @user:boxel.ai)')
41
+ .option('-p, --password <password>', 'Password (for add command)')
42
+ .option('-n, --name <displayName>', 'Display name (for add command)')
43
+ .option(
44
+ '-m, --matrix-url <url>',
45
+ 'Matrix server URL (for add command with non-standard domains)',
46
+ )
47
+ .option(
48
+ '-r, --realm-server-url <url>',
49
+ 'Realm server URL (for add command with non-standard domains)',
50
+ )
51
+ .addHelpText(
52
+ 'after',
53
+ `
54
+ Environment variables (for 'add'):
55
+ BOXEL_PASSWORD Password; preferred over -p to avoid shell history.
56
+ BOXEL_ENVIRONMENT An env-mode slug (e.g. a branch name), interpreted
57
+ like scripts/env-slug.sh: URLs are derived as
58
+ http://matrix.<slug>.localhost and
59
+ http://realm-server.<slug>.localhost/. Overridden
60
+ by --matrix-url / --realm-server-url if provided.`,
61
+ )
62
+ .action(
63
+ async (
64
+ subcommand?: string,
65
+ arg?: string,
66
+ options?: {
67
+ user?: string;
68
+ password?: string;
69
+ name?: string;
70
+ matrixUrl?: string;
71
+ realmServerUrl?: string;
72
+ },
73
+ ) => {
74
+ if (options?.password) {
75
+ console.warn(
76
+ 'Warning: Supplying a password via -p/--password may expose it in shell history and process listings. ' +
77
+ 'For non-interactive usage, prefer the BOXEL_PASSWORD environment variable or use "boxel profile add" interactively.',
78
+ );
79
+ }
80
+ await profileCommand(subcommand, arg, options);
81
+ },
82
+ );
83
+
84
+ registerFileCommand(program);
85
+ registerRealmCommand(program);
86
+ registerRunCommand(program);
87
+ registerSearchCommand(program);
88
+ registerReadTranspiledCommand(program);
89
+
90
+ return program;
91
+ }
@@ -0,0 +1,110 @@
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 { isProtectedFile } from '../../lib/realm-sync-base';
8
+ import { ensureTrailingSlash } from '@cardstack/runtime-common/paths';
9
+ import { SupportedMimeType } from '@cardstack/runtime-common/supported-mime-type';
10
+ import { FG_RED, DIM, RESET } from '../../lib/colors';
11
+ import { cliLog } from '../../lib/cli-log';
12
+
13
+ export interface DeleteResult {
14
+ ok: boolean;
15
+ error?: string;
16
+ }
17
+
18
+ export interface DeleteCommandOptions {
19
+ profileManager?: ProfileManager;
20
+ }
21
+
22
+ interface DeleteCliOptions {
23
+ realm: string;
24
+ json?: boolean;
25
+ }
26
+
27
+ /**
28
+ * Delete a file from a realm.
29
+ *
30
+ * Sends an HTTP DELETE request for the given path within the realm.
31
+ * Uses the per-realm JWT via `ProfileManager.authedRealmFetch`.
32
+ */
33
+ export async function deleteFile(
34
+ realmUrl: string,
35
+ path: string,
36
+ options?: DeleteCommandOptions,
37
+ ): Promise<DeleteResult> {
38
+ let pm = options?.profileManager ?? getProfileManager();
39
+ let active = pm.getActiveProfile();
40
+ if (!active) {
41
+ return {
42
+ ok: false,
43
+ error: NO_ACTIVE_PROFILE_ERROR,
44
+ };
45
+ }
46
+
47
+ if (isProtectedFile(path)) {
48
+ return {
49
+ ok: false,
50
+ error: `Cannot delete protected file: ${path}`,
51
+ };
52
+ }
53
+
54
+ let url = new URL(path, ensureTrailingSlash(realmUrl)).href;
55
+
56
+ let response: Response;
57
+ try {
58
+ response = await pm.authedRealmFetch(url, {
59
+ method: 'DELETE',
60
+ headers: { Accept: SupportedMimeType.CardSource },
61
+ });
62
+ } catch (err) {
63
+ return {
64
+ ok: false,
65
+ error: err instanceof Error ? err.message : String(err),
66
+ };
67
+ }
68
+
69
+ if (!response.ok) {
70
+ let body = await response.text().catch(() => '(no body)');
71
+ return {
72
+ ok: false,
73
+ error: `HTTP ${response.status}: ${body.slice(0, 300)}`,
74
+ };
75
+ }
76
+
77
+ return { ok: true };
78
+ }
79
+
80
+ export function registerDeleteCommand(parent: Command): void {
81
+ parent
82
+ .command('delete')
83
+ .description('Delete a file from a realm')
84
+ .argument('<path>', 'Realm-relative file path to delete')
85
+ .requiredOption('--realm <realm-url>', 'The realm URL to delete from')
86
+ .option('--json', 'Output raw JSON response')
87
+ .action(async (filePath: string, opts: DeleteCliOptions) => {
88
+ let result: DeleteResult;
89
+ try {
90
+ result = await deleteFile(opts.realm, filePath);
91
+ } catch (err) {
92
+ console.error(
93
+ `${FG_RED}Error:${RESET} ${err instanceof Error ? err.message : String(err)}`,
94
+ );
95
+ process.exit(1);
96
+ }
97
+
98
+ if (opts.json) {
99
+ cliLog.output(JSON.stringify(result, null, 2));
100
+ } else if (result.ok) {
101
+ console.log(`${DIM}Deleted:${RESET} ${filePath}`);
102
+ } else {
103
+ console.error(`${FG_RED}Error:${RESET} ${result.error}`);
104
+ }
105
+
106
+ if (!result.ok) {
107
+ process.exit(1);
108
+ }
109
+ });
110
+ }
@@ -0,0 +1,20 @@
1
+ import type { Command } from 'commander';
2
+ import { registerDeleteCommand } from './delete';
3
+ import { registerListCommand } from './list';
4
+ import { registerLintCommand } from './lint';
5
+ import { registerReadCommand } from './read';
6
+ import { registerTouchCommand } from './touch';
7
+ import { registerWriteCommand } from './write';
8
+
9
+ export function registerFileCommand(program: Command): void {
10
+ let file = program
11
+ .command('file')
12
+ .description('Read, write, search, and manage files in a realm');
13
+
14
+ registerDeleteCommand(file);
15
+ registerListCommand(file);
16
+ registerLintCommand(file);
17
+ registerReadCommand(file);
18
+ registerTouchCommand(file);
19
+ registerWriteCommand(file);
20
+ }
@@ -0,0 +1,235 @@
1
+ import type { Command } from 'commander';
2
+ import { readFileSync, writeFileSync } from 'fs';
3
+ import {
4
+ getProfileManager,
5
+ NO_ACTIVE_PROFILE_ERROR,
6
+ type ProfileManager,
7
+ } from '../../lib/profile-manager';
8
+ import { ensureTrailingSlash } from '@cardstack/runtime-common/paths';
9
+ import { SupportedMimeType } from '@cardstack/runtime-common/supported-mime-type';
10
+ import { FG_GREEN, FG_RED, FG_YELLOW, DIM, RESET } from '../../lib/colors';
11
+ import { cliLog } from '../../lib/cli-log';
12
+ import { write } from './write';
13
+
14
+ export interface LintMessage {
15
+ ruleId: string | null;
16
+ severity: 1 | 2;
17
+ message: string;
18
+ line: number;
19
+ column: number;
20
+ endLine?: number;
21
+ endColumn?: number;
22
+ }
23
+
24
+ export interface LintResult {
25
+ ok: boolean;
26
+ error?: string;
27
+ fixed?: boolean;
28
+ output?: string;
29
+ messages?: LintMessage[];
30
+ }
31
+
32
+ export interface LintCommandOptions {
33
+ profileManager?: ProfileManager;
34
+ }
35
+
36
+ /**
37
+ * Lint a single file's source code via the realm's `_lint` endpoint.
38
+ *
39
+ * Sends the source to `POST <realmUrl>/_lint` with `X-Filename` and
40
+ * `X-HTTP-Method-Override: QUERY` headers. Returns the lint result
41
+ * containing messages and optionally auto-fixed output.
42
+ *
43
+ * Uses the per-realm JWT via `ProfileManager.authedRealmFetch`.
44
+ */
45
+ export async function lint(
46
+ realmUrl: string,
47
+ source: string,
48
+ filename: string,
49
+ options?: LintCommandOptions,
50
+ ): Promise<LintResult> {
51
+ let pm = options?.profileManager ?? getProfileManager();
52
+ let active = pm.getActiveProfile();
53
+ if (!active) {
54
+ return {
55
+ ok: false,
56
+ error: NO_ACTIVE_PROFILE_ERROR,
57
+ };
58
+ }
59
+
60
+ let lintUrl = `${ensureTrailingSlash(realmUrl)}_lint`;
61
+
62
+ try {
63
+ let response = await pm.authedRealmFetch(lintUrl, {
64
+ method: 'POST',
65
+ headers: {
66
+ Accept: 'application/json',
67
+ 'Content-Type': SupportedMimeType.CardSource,
68
+ 'X-Filename': filename,
69
+ 'X-HTTP-Method-Override': 'QUERY',
70
+ },
71
+ body: source,
72
+ });
73
+
74
+ if (!response.ok) {
75
+ let body = await response.text().catch(() => '(no body)');
76
+ return {
77
+ ok: false,
78
+ error: `HTTP ${response.status}: ${body.slice(0, 300)}`,
79
+ };
80
+ }
81
+
82
+ let json = (await response.json()) as {
83
+ fixed: boolean;
84
+ output: string;
85
+ messages: LintMessage[];
86
+ };
87
+
88
+ return {
89
+ ok: true,
90
+ fixed: json.fixed,
91
+ output: json.output,
92
+ messages: json.messages,
93
+ };
94
+ } catch (err) {
95
+ return {
96
+ ok: false,
97
+ error: err instanceof Error ? err.message : String(err),
98
+ };
99
+ }
100
+ }
101
+
102
+ interface LintCliOptions {
103
+ realm: string;
104
+ file?: string;
105
+ json?: boolean;
106
+ fix?: boolean;
107
+ }
108
+
109
+ export function registerLintCommand(parent: Command): void {
110
+ parent
111
+ .command('lint')
112
+ .description('Lint a file in a realm using the realm lint endpoint')
113
+ .argument('<path>', 'Realm-relative file path to lint (e.g., my-card.gts)')
114
+ .requiredOption('--realm <realm-url>', 'The realm URL to lint against')
115
+ .option(
116
+ '--file <local-filepath>',
117
+ 'Read source from a local file instead of fetching from the realm',
118
+ )
119
+ .option('--json', 'Output raw JSON response')
120
+ .option('--fix', 'Write auto-fixed output back to the source')
121
+ .action(async (filePath: string, opts: LintCliOptions) => {
122
+ let pm = getProfileManager();
123
+ let active = pm.getActiveProfile();
124
+ if (!active) {
125
+ console.error(`${FG_RED}Error:${RESET} ${NO_ACTIVE_PROFILE_ERROR}`);
126
+ process.exit(1);
127
+ }
128
+
129
+ let source: string;
130
+
131
+ if (opts.file) {
132
+ try {
133
+ source = readFileSync(opts.file, 'utf-8');
134
+ } catch (err) {
135
+ console.error(
136
+ `${FG_RED}Error:${RESET} Could not read local file: ${err instanceof Error ? err.message : String(err)}`,
137
+ );
138
+ process.exit(1);
139
+ }
140
+ } else {
141
+ let readUrl = new URL(filePath, ensureTrailingSlash(opts.realm)).href;
142
+ try {
143
+ let response = await pm.authedRealmFetch(readUrl, {
144
+ method: 'GET',
145
+ headers: { Accept: SupportedMimeType.CardSource },
146
+ });
147
+ if (!response.ok) {
148
+ let body = await response.text().catch(() => '(no body)');
149
+ console.error(
150
+ `${FG_RED}Error:${RESET} Could not read file from realm: HTTP ${response.status}: ${body.slice(0, 300)}`,
151
+ );
152
+ process.exit(1);
153
+ }
154
+ source = await response.text();
155
+ } catch (err) {
156
+ console.error(
157
+ `${FG_RED}Error:${RESET} ${err instanceof Error ? err.message : String(err)}`,
158
+ );
159
+ process.exit(1);
160
+ }
161
+ }
162
+
163
+ let result: LintResult;
164
+ try {
165
+ result = await lint(opts.realm, source, filePath, {
166
+ profileManager: pm,
167
+ });
168
+ } catch (err) {
169
+ console.error(
170
+ `${FG_RED}Error:${RESET} ${err instanceof Error ? err.message : String(err)}`,
171
+ );
172
+ process.exit(1);
173
+ }
174
+
175
+ if (opts.json) {
176
+ cliLog.output(JSON.stringify(result, null, 2));
177
+ if (!result.ok) {
178
+ process.exit(1);
179
+ }
180
+ return;
181
+ }
182
+
183
+ if (!result.ok) {
184
+ console.error(`${FG_RED}Error:${RESET} ${result.error}`);
185
+ process.exit(1);
186
+ }
187
+
188
+ // Handle --fix: write fixed output back to the source
189
+ if (opts.fix && result.fixed && result.output) {
190
+ if (opts.file) {
191
+ writeFileSync(opts.file, result.output, 'utf-8');
192
+ console.log(`${FG_GREEN}Fixed:${RESET} ${opts.file}`);
193
+ } else {
194
+ let writeResult = await write(opts.realm, filePath, result.output, {
195
+ profileManager: pm,
196
+ });
197
+ if (!writeResult.ok) {
198
+ console.error(
199
+ `${FG_RED}Error:${RESET} Could not write fixed file: ${writeResult.error}`,
200
+ );
201
+ process.exit(1);
202
+ }
203
+ console.log(
204
+ `${FG_GREEN}Fixed:${RESET} ${filePath} ${DIM}→${RESET} ${opts.realm}`,
205
+ );
206
+ }
207
+ }
208
+
209
+ let messages = result.messages ?? [];
210
+ let errors = messages.filter((m) => m.severity === 2);
211
+ let warnings = messages.filter((m) => m.severity === 1);
212
+
213
+ if (messages.length === 0) {
214
+ console.log(`${DIM}No lint issues found.${RESET}`);
215
+ return;
216
+ }
217
+
218
+ for (let msg of messages) {
219
+ let color = msg.severity === 2 ? FG_RED : FG_YELLOW;
220
+ let level = msg.severity === 2 ? 'error' : 'warning';
221
+ let rule = msg.ruleId ? ` (${msg.ruleId})` : '';
222
+ console.log(
223
+ `${color}${level}${RESET} ${msg.line}:${msg.column} ${msg.message}${DIM}${rule}${RESET}`,
224
+ );
225
+ }
226
+
227
+ console.log(
228
+ `\n${DIM}${errors.length} error(s), ${warnings.length} warning(s)${RESET}`,
229
+ );
230
+
231
+ if (errors.length > 0) {
232
+ process.exit(1);
233
+ }
234
+ });
235
+ }
@@ -0,0 +1,121 @@
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 { SupportedMimeType } from '@cardstack/runtime-common/supported-mime-type';
9
+ import { FG_RED, DIM, RESET } from '../../lib/colors';
10
+ import { cliLog } from '../../lib/cli-log';
11
+
12
+ export interface ListFilesResult {
13
+ filenames: string[];
14
+ error?: string;
15
+ }
16
+
17
+ export interface ListFilesCommandOptions {
18
+ profileManager?: ProfileManager;
19
+ }
20
+
21
+ interface ListFilesCliOptions {
22
+ realm: string;
23
+ json?: boolean;
24
+ }
25
+
26
+ /**
27
+ * List all file paths in a realm via the `_mtimes` endpoint.
28
+ * Returns relative paths (e.g., `hello.gts`, `Cards/my-card.json`).
29
+ */
30
+ export async function listFiles(
31
+ realmUrl: string,
32
+ options?: ListFilesCommandOptions,
33
+ ): Promise<ListFilesResult> {
34
+ let pm = options?.profileManager ?? getProfileManager();
35
+ let active = pm.getActiveProfile();
36
+ if (!active) {
37
+ return {
38
+ filenames: [],
39
+ error: NO_ACTIVE_PROFILE_ERROR,
40
+ };
41
+ }
42
+
43
+ let normalizedRealmUrl = ensureTrailingSlash(realmUrl);
44
+ let mtimesUrl = `${normalizedRealmUrl}_mtimes`;
45
+
46
+ try {
47
+ let response = await pm.authedRealmFetch(mtimesUrl, {
48
+ method: 'GET',
49
+ headers: { Accept: SupportedMimeType.Mtimes },
50
+ });
51
+
52
+ if (!response.ok) {
53
+ let body = await response.text().catch(() => '(no body)');
54
+ return {
55
+ filenames: [],
56
+ error: `_mtimes returned HTTP ${response.status}: ${body.slice(0, 300)}`,
57
+ };
58
+ }
59
+
60
+ let json = (await response.json()) as {
61
+ data?: { attributes?: { mtimes?: Record<string, number> } };
62
+ };
63
+ let mtimes =
64
+ json?.data?.attributes?.mtimes ??
65
+ (json as unknown as Record<string, number>);
66
+
67
+ let filenames: string[] = [];
68
+ for (let fullUrl of Object.keys(mtimes)) {
69
+ if (!fullUrl.startsWith(normalizedRealmUrl)) {
70
+ continue;
71
+ }
72
+ let relativePath = fullUrl.slice(normalizedRealmUrl.length);
73
+ if (!relativePath || relativePath.endsWith('/')) {
74
+ continue;
75
+ }
76
+ filenames.push(relativePath);
77
+ }
78
+
79
+ return { filenames: filenames.sort() };
80
+ } catch (err) {
81
+ return {
82
+ filenames: [],
83
+ error: err instanceof Error ? err.message : String(err),
84
+ };
85
+ }
86
+ }
87
+
88
+ export function registerListCommand(file: Command): void {
89
+ file
90
+ .command('list')
91
+ .alias('ls')
92
+ .description('List all files in a realm')
93
+ .requiredOption('--realm <realm-url>', 'The realm URL to list files from')
94
+ .option('--json', 'Output raw JSON response')
95
+ .action(async (opts: ListFilesCliOptions) => {
96
+ let result: ListFilesResult;
97
+ try {
98
+ result = await listFiles(opts.realm);
99
+ } catch (err) {
100
+ console.error(
101
+ `${FG_RED}Error:${RESET} ${err instanceof Error ? err.message : String(err)}`,
102
+ );
103
+ process.exit(1);
104
+ }
105
+
106
+ if (opts.json) {
107
+ cliLog.output(JSON.stringify(result, null, 2));
108
+ if (result.error) {
109
+ process.exit(1);
110
+ }
111
+ } else if (result.error) {
112
+ console.error(`${FG_RED}Error:${RESET} ${result.error}`);
113
+ process.exit(1);
114
+ } else {
115
+ for (let filename of result.filenames) {
116
+ console.log(`${DIM}${filename}${RESET}`);
117
+ }
118
+ console.log(`\n${DIM}${result.filenames.length} file(s)${RESET}`);
119
+ }
120
+ });
121
+ }