@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,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 { 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 ReadResult {
13
+ ok: boolean;
14
+ status?: number;
15
+ /** Raw text content of the file. */
16
+ content?: string;
17
+ error?: string;
18
+ }
19
+
20
+ export interface ReadCommandOptions {
21
+ profileManager?: ProfileManager;
22
+ }
23
+
24
+ interface ReadCliOptions {
25
+ realm: string;
26
+ json?: boolean;
27
+ }
28
+
29
+ /**
30
+ * Read a file from a realm. Always returns the raw text content.
31
+ * Callers should parse the content themselves if needed (e.g. JSON).
32
+ *
33
+ * Uses the per-realm JWT via `ProfileManager.authedRealmFetch`.
34
+ */
35
+ export async function read(
36
+ realmUrl: string,
37
+ path: string,
38
+ options?: ReadCommandOptions,
39
+ ): Promise<ReadResult> {
40
+ let pm = options?.profileManager ?? getProfileManager();
41
+ let active = pm.getActiveProfile();
42
+ if (!active) {
43
+ return {
44
+ ok: false,
45
+ error: NO_ACTIVE_PROFILE_ERROR,
46
+ };
47
+ }
48
+
49
+ let url = new URL(path, ensureTrailingSlash(realmUrl)).href;
50
+
51
+ let response: Response;
52
+ try {
53
+ response = await pm.authedRealmFetch(url, {
54
+ method: 'GET',
55
+ headers: { Accept: SupportedMimeType.CardSource },
56
+ });
57
+ } catch (err) {
58
+ return {
59
+ ok: false,
60
+ error: err instanceof Error ? err.message : String(err),
61
+ };
62
+ }
63
+
64
+ if (!response.ok) {
65
+ let body = await response.text().catch(() => '(no body)');
66
+ return {
67
+ ok: false,
68
+ status: response.status,
69
+ error: `HTTP ${response.status}: ${body.slice(0, 300)}`,
70
+ };
71
+ }
72
+
73
+ let text = await response.text();
74
+ return { ok: true, status: response.status, content: text };
75
+ }
76
+
77
+ export function registerReadCommand(parent: Command): void {
78
+ parent
79
+ .command('read')
80
+ .description('Read a file from a realm')
81
+ .argument(
82
+ '<path>',
83
+ 'Realm-relative file path (e.g., hello-world.json, Cards/my-card.gts)',
84
+ )
85
+ .requiredOption('--realm <realm-url>', 'The realm URL to read from')
86
+ .option('--json', 'Output raw JSON response')
87
+ .action(async (filePath: string, opts: ReadCliOptions) => {
88
+ let result: ReadResult;
89
+ try {
90
+ result = await read(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
+ cliLog.output(result.content ?? '');
102
+ } else {
103
+ console.error(
104
+ `${DIM}Status:${RESET} ${result.status ?? '(no status)'}`,
105
+ );
106
+ console.error(`${FG_RED}Error:${RESET} ${result.error}`);
107
+ }
108
+
109
+ if (!result.ok) {
110
+ process.exit(1);
111
+ }
112
+ });
113
+ }
@@ -0,0 +1,222 @@
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 { listFiles } from './list';
9
+ import { read } from './read';
10
+ import { write } from './write';
11
+ import { FG_GREEN, FG_RED, DIM, RESET } from '../../lib/colors';
12
+
13
+ export interface TouchResult {
14
+ ok: boolean;
15
+ touched: string[];
16
+ skipped: { path: string; reason: string }[];
17
+ error?: string;
18
+ }
19
+
20
+ export interface TouchCommandOptions {
21
+ /** When true, enumerate every `.json` and `.gts` file in the realm. */
22
+ all?: boolean;
23
+ /** When true, do not perform any state-changing requests. */
24
+ dryRun?: boolean;
25
+ profileManager?: ProfileManager;
26
+ }
27
+
28
+ interface TouchCliOptions {
29
+ realm: string;
30
+ all?: boolean;
31
+ dryRun?: boolean;
32
+ json?: boolean;
33
+ }
34
+
35
+ const TOUCH_COMMENT = '// touched for re-index';
36
+
37
+ /**
38
+ * Touch one or more files in a realm to force re-indexing. The touch is a
39
+ * semantically-neutral mutation: a `_touched` timestamp in JSON `meta`,
40
+ * or a toggle of `// touched for re-index` for `.gts` files.
41
+ *
42
+ * Pass `all: true` (with empty `paths`) to touch every `.json` and `.gts`
43
+ * file enumerated via the realm's `_mtimes` endpoint.
44
+ */
45
+ export async function touchFiles(
46
+ realmUrl: string,
47
+ paths: string[],
48
+ options?: TouchCommandOptions,
49
+ ): Promise<TouchResult> {
50
+ let pm = options?.profileManager ?? getProfileManager();
51
+ let active = pm.getActiveProfile();
52
+ if (!active) {
53
+ return {
54
+ ok: false,
55
+ touched: [],
56
+ skipped: [],
57
+ error: NO_ACTIVE_PROFILE_ERROR,
58
+ };
59
+ }
60
+
61
+ let targets: string[];
62
+
63
+ if (options?.all) {
64
+ if (paths.length > 0) {
65
+ return {
66
+ ok: false,
67
+ touched: [],
68
+ skipped: [],
69
+ error: 'Cannot pass file paths together with --all',
70
+ };
71
+ }
72
+ let listed = await listFiles(realmUrl, { profileManager: pm });
73
+ if (listed.error) {
74
+ return {
75
+ ok: false,
76
+ touched: [],
77
+ skipped: [],
78
+ error: listed.error,
79
+ };
80
+ }
81
+ targets = listed.filenames.filter(
82
+ (p) => (p.endsWith('.json') || p.endsWith('.gts')) && !isProtectedFile(p),
83
+ );
84
+ } else {
85
+ if (paths.length === 0) {
86
+ return {
87
+ ok: false,
88
+ touched: [],
89
+ skipped: [],
90
+ error: 'No file paths provided. Pass paths or use --all.',
91
+ };
92
+ }
93
+ targets = paths;
94
+ }
95
+
96
+ let touched: string[] = [];
97
+ let skipped: { path: string; reason: string }[] = [];
98
+
99
+ for (let path of targets) {
100
+ if (!path.endsWith('.json') && !path.endsWith('.gts')) {
101
+ skipped.push({ path, reason: 'unsupported extension' });
102
+ continue;
103
+ }
104
+
105
+ if (isProtectedFile(path)) {
106
+ skipped.push({ path, reason: 'protected file' });
107
+ continue;
108
+ }
109
+
110
+ let readResult = await read(realmUrl, path, { profileManager: pm });
111
+ if (!readResult.ok || readResult.content == null) {
112
+ skipped.push({ path, reason: readResult.error ?? 'read failed' });
113
+ continue;
114
+ }
115
+
116
+ if (options?.dryRun) {
117
+ touched.push(path);
118
+ continue;
119
+ }
120
+
121
+ let next = path.endsWith('.json')
122
+ ? touchJson(readResult.content)
123
+ : touchGts(readResult.content);
124
+
125
+ let writeResult = await write(realmUrl, path, next, {
126
+ profileManager: pm,
127
+ });
128
+ if (!writeResult.ok) {
129
+ skipped.push({ path, reason: writeResult.error ?? 'write failed' });
130
+ continue;
131
+ }
132
+
133
+ touched.push(path);
134
+ }
135
+
136
+ return { ok: skipped.length === 0, touched, skipped };
137
+ }
138
+
139
+ function touchJson(content: string): string {
140
+ try {
141
+ let data = JSON.parse(content);
142
+ if (data?.data) {
143
+ data.data.meta = { ...(data.data.meta ?? {}), _touched: Date.now() };
144
+ return JSON.stringify(data, null, 2) + '\n';
145
+ }
146
+ } catch {
147
+ // fall through to whitespace toggle
148
+ }
149
+ return toggleTrailingNewline(content);
150
+ }
151
+
152
+ function toggleTrailingNewline(content: string): string {
153
+ return content.endsWith('\n\n') ? content.slice(0, -1) : content + '\n';
154
+ }
155
+
156
+ function touchGts(content: string): string {
157
+ // Only strip the dedicated trailing marker line we ourselves appended,
158
+ // so occurrences inside string literals or unrelated comments are left
159
+ // untouched and the mutation stays semantically neutral.
160
+ let trailingMarker = `\n${TOUCH_COMMENT}\n`;
161
+ if (content.endsWith(trailingMarker)) {
162
+ return content.slice(0, -trailingMarker.length) + '\n';
163
+ }
164
+ return content.endsWith('\n')
165
+ ? content + TOUCH_COMMENT + '\n'
166
+ : content + '\n' + TOUCH_COMMENT + '\n';
167
+ }
168
+
169
+ export function registerTouchCommand(parent: Command): void {
170
+ parent
171
+ .command('touch')
172
+ .description(
173
+ 'Force realm re-indexing of one or more files by making a semantically-neutral edit. ' +
174
+ '--all touches every .json/.gts in the realm without confirmation; use with care.',
175
+ )
176
+ .argument(
177
+ '[paths...]',
178
+ 'Realm-relative file path(s) to touch (omit when using --all)',
179
+ )
180
+ .requiredOption('--realm <realm-url>', 'The realm URL to touch files in')
181
+ .option('--all', 'Touch every .json and .gts file in the realm')
182
+ .option('--dry-run', 'Print files that would be touched without writing')
183
+ .option('--json', 'Output raw JSON response')
184
+ .action(async (paths: string[], opts: TouchCliOptions) => {
185
+ let result: TouchResult;
186
+ try {
187
+ result = await touchFiles(opts.realm, paths, {
188
+ all: opts.all,
189
+ dryRun: opts.dryRun,
190
+ });
191
+ } catch (err) {
192
+ console.error(
193
+ `${FG_RED}Error:${RESET} ${err instanceof Error ? err.message : String(err)}`,
194
+ );
195
+ process.exit(1);
196
+ }
197
+
198
+ if (opts.json) {
199
+ console.log(JSON.stringify(result, null, 2));
200
+ } else if (result.error) {
201
+ console.error(`${FG_RED}Error:${RESET} ${result.error}`);
202
+ } else {
203
+ let prefix = opts.dryRun ? `${DIM}[dry-run]${RESET} ` : '';
204
+ for (let path of result.touched) {
205
+ console.log(`${prefix}${FG_GREEN}touched${RESET} ${path}`);
206
+ }
207
+ for (let { path, reason } of result.skipped) {
208
+ console.log(
209
+ `${FG_RED}skipped${RESET} ${path} ${DIM}(${reason})${RESET}`,
210
+ );
211
+ }
212
+ let verb = opts.dryRun ? 'would touch' : 'touched';
213
+ console.log(
214
+ `\n${DIM}${verb} ${result.touched.length} file(s)${result.skipped.length > 0 ? `, skipped ${result.skipped.length}` : ''}${RESET}`,
215
+ );
216
+ }
217
+
218
+ if (!result.ok) {
219
+ process.exit(1);
220
+ }
221
+ });
222
+ }
@@ -0,0 +1,152 @@
1
+ import type { Command } from 'commander';
2
+ import { readFileSync } 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, DIM, RESET } from '../../lib/colors';
11
+ import { cliLog } from '../../lib/cli-log';
12
+
13
+ export interface WriteResult {
14
+ ok: boolean;
15
+ error?: string;
16
+ }
17
+
18
+ export interface WriteCommandOptions {
19
+ profileManager?: ProfileManager;
20
+ }
21
+
22
+ interface WriteCliOptions {
23
+ realm: string;
24
+ file?: string;
25
+ json?: boolean;
26
+ }
27
+
28
+ /**
29
+ * Write a file to a realm. Content is sent as-is with card+source MIME type.
30
+ * Path should include the file extension.
31
+ *
32
+ * Uses the per-realm JWT via `ProfileManager.authedRealmFetch`.
33
+ */
34
+ export async function write(
35
+ realmUrl: string,
36
+ path: string,
37
+ content: string,
38
+ options?: WriteCommandOptions,
39
+ ): Promise<WriteResult> {
40
+ let pm = options?.profileManager ?? getProfileManager();
41
+ let active = pm.getActiveProfile();
42
+ if (!active) {
43
+ return {
44
+ ok: false,
45
+ error: NO_ACTIVE_PROFILE_ERROR,
46
+ };
47
+ }
48
+
49
+ let url = new URL(path, ensureTrailingSlash(realmUrl)).href;
50
+
51
+ try {
52
+ let response = await pm.authedRealmFetch(url, {
53
+ method: 'POST',
54
+ headers: {
55
+ Accept: SupportedMimeType.CardSource,
56
+ 'Content-Type': SupportedMimeType.CardSource,
57
+ },
58
+ body: content,
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
+ /** Write to stderr so hints don't pollute stdout (important when piping/--json). */
79
+ function stderr(msg: string): void {
80
+ process.stderr.write(msg + '\n');
81
+ }
82
+
83
+ async function readStdin(): Promise<string> {
84
+ let chunks: Buffer[] = [];
85
+ for await (let chunk of process.stdin) {
86
+ chunks.push(chunk);
87
+ }
88
+ return Buffer.concat(chunks).toString('utf-8');
89
+ }
90
+
91
+ export function registerWriteCommand(parent: Command): void {
92
+ parent
93
+ .command('write')
94
+ .description('Write a file to a realm (reads content from STDIN or --file)')
95
+ .argument(
96
+ '<path>',
97
+ 'Realm-relative file path (e.g., hello.gts, Cards/my-card.json)',
98
+ )
99
+ .requiredOption('--realm <realm-url>', 'The realm URL to write to')
100
+ .option(
101
+ '--file <filepath>',
102
+ 'Read content from a local file instead of STDIN',
103
+ )
104
+ .option('--json', 'Output raw JSON response')
105
+ .action(async (filePath: string, opts: WriteCliOptions) => {
106
+ let content: string;
107
+ if (opts.file) {
108
+ try {
109
+ content = readFileSync(opts.file, 'utf-8');
110
+ } catch (err) {
111
+ stderr(
112
+ `${FG_RED}Error:${RESET} Could not read file: ${err instanceof Error ? err.message : String(err)}`,
113
+ );
114
+ process.exit(1);
115
+ }
116
+ } else {
117
+ if (process.stdin.isTTY) {
118
+ stderr(
119
+ `${DIM}Reading from STDIN. Type or paste content, then press Enter followed by Ctrl+D to finish.${RESET}`,
120
+ );
121
+ }
122
+ content = await readStdin();
123
+ stderr(
124
+ `${DIM}Received ${content.length} bytes. Writing to realm...${RESET}`,
125
+ );
126
+ }
127
+
128
+ let result: WriteResult;
129
+ try {
130
+ result = await write(opts.realm, filePath, content);
131
+ } catch (err) {
132
+ stderr(
133
+ `${FG_RED}Error:${RESET} ${err instanceof Error ? err.message : String(err)}`,
134
+ );
135
+ process.exit(1);
136
+ }
137
+
138
+ if (opts.json) {
139
+ cliLog.output(JSON.stringify(result, null, 2));
140
+ } else if (result.ok) {
141
+ console.log(
142
+ `${FG_GREEN}Written:${RESET} ${filePath} ${DIM}→${RESET} ${opts.realm}`,
143
+ );
144
+ } else {
145
+ stderr(`${FG_RED}Error:${RESET} ${result.error}`);
146
+ }
147
+
148
+ if (!result.ok) {
149
+ process.exit(1);
150
+ }
151
+ });
152
+ }