@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,6 +1,49 @@
1
+ import { deleteFile, type DeleteResult } from '../commands/file/delete';
2
+ import { read as fileRead, type ReadResult } from '../commands/file/read';
3
+ import {
4
+ lint as coreLint,
5
+ type LintResult,
6
+ type LintMessage,
7
+ } from '../commands/file/lint';
8
+ import {
9
+ listFiles as coreListFiles,
10
+ type ListFilesResult,
11
+ } from '../commands/file/list';
12
+ import {
13
+ search as fileSearch,
14
+ type SearchResult,
15
+ type SearchCommandOptions,
16
+ } from '../commands/search';
17
+ import {
18
+ readTranspiledModule,
19
+ type ReadTranspiledResult,
20
+ } from '../commands/read-transpiled';
21
+ import {
22
+ touchFiles as coreTouchFiles,
23
+ type TouchResult,
24
+ type TouchCommandOptions,
25
+ } from '../commands/file/touch';
26
+ import { write as coreWrite, type WriteResult } from '../commands/file/write';
27
+ import {
28
+ cancelIndexing as coreCancelIndexing,
29
+ type CancelIndexingResult,
30
+ } from '../commands/realm/cancel-indexing';
1
31
  import { createRealm as coreCreateRealm } from '../commands/realm/create';
2
32
  import { pull as realmPull } from '../commands/realm/pull';
33
+ import { sync as realmSync, type SyncResult } from '../commands/realm/sync';
34
+ import { waitForReady as coreWaitForReady } from '../commands/realm/wait-for-ready';
3
35
  import { getProfileManager, type ProfileManager } from './profile-manager';
36
+ import { ensureTrailingSlash } from '@cardstack/runtime-common/paths';
37
+
38
+ export type {
39
+ ReadResult,
40
+ ListFilesResult,
41
+ ReadTranspiledResult,
42
+ SyncResult,
43
+ SearchResult,
44
+ SearchCommandOptions,
45
+ TouchResult,
46
+ };
4
47
 
5
48
  const MIME = {
6
49
  CardSource: 'application/vnd.card+source',
@@ -9,10 +52,6 @@ const MIME = {
9
52
  JSONAPI: 'application/vnd.api+json',
10
53
  } as const;
11
54
 
12
- function ensureTrailingSlash(url: string): string {
13
- return url.endsWith('/') ? url : `${url}/`;
14
- }
15
-
16
55
  export interface CreateRealmOptions {
17
56
  /** URL slug for the realm (lowercase, numbers, hyphens). */
18
57
  realmName: string;
@@ -40,37 +79,21 @@ export interface PullResult {
40
79
  error?: string;
41
80
  }
42
81
 
43
- export interface ReadResult {
44
- ok: boolean;
45
- status?: number;
46
- /** Parsed JSON document (for .json files). */
47
- document?: Record<string, unknown>;
48
- /** Raw text content (for non-JSON files like .gts). */
49
- content?: string;
50
- error?: string;
51
- }
52
-
53
- export interface WriteResult {
54
- ok: boolean;
55
- error?: string;
56
- }
57
-
58
- export interface DeleteResult {
59
- ok: boolean;
60
- error?: string;
82
+ export interface SyncOptions {
83
+ /** Resolve conflicts by keeping the local version. */
84
+ preferLocal?: boolean;
85
+ /** Resolve conflicts by keeping the remote version. */
86
+ preferRemote?: boolean;
87
+ /** Resolve conflicts by keeping the newest version. */
88
+ preferNewest?: boolean;
89
+ /** Propagate deletions in both directions. */
90
+ delete?: boolean;
91
+ /** Preview without making changes. */
92
+ dryRun?: boolean;
61
93
  }
62
94
 
63
- export interface SearchResult {
64
- ok: boolean;
65
- status?: number;
66
- data?: Record<string, unknown>[];
67
- error?: string;
68
- }
69
-
70
- export interface ListFilesResult {
71
- filenames: string[];
72
- error?: string;
73
- }
95
+ export type { DeleteResult };
96
+ export type { WriteResult };
74
97
 
75
98
  export interface RunCommandResult {
76
99
  status: 'ready' | 'error' | 'unusable';
@@ -79,21 +102,7 @@ export interface RunCommandResult {
79
102
  error?: string | null;
80
103
  }
81
104
 
82
- export interface LintMessage {
83
- ruleId: string | null;
84
- severity: 1 | 2;
85
- message: string;
86
- line: number;
87
- column: number;
88
- endLine?: number;
89
- endColumn?: number;
90
- }
91
-
92
- export interface LintResult {
93
- fixed: boolean;
94
- output: string;
95
- messages: LintMessage[];
96
- }
105
+ export type { LintMessage, LintResult };
97
106
 
98
107
  export interface WaitForReadyResult {
99
108
  ready: boolean;
@@ -111,10 +120,7 @@ export interface AtomicResult {
111
120
  error?: string;
112
121
  }
113
122
 
114
- export interface CancelIndexingResult {
115
- ok: boolean;
116
- error?: string;
117
- }
123
+ export type { CancelIndexingResult };
118
124
 
119
125
  export class BoxelCLIClient {
120
126
  private pm: ProfileManager;
@@ -155,148 +161,79 @@ export class BoxelCLIClient {
155
161
  }
156
162
 
157
163
  /**
158
- * Read a file from a realm. Returns parsed JSON for .json files,
159
- * raw text for everything else (.gts, etc.).
164
+ * Read a file from a realm. Always returns raw text content.
165
+ * Callers should parse the content themselves if needed (e.g. JSON).
166
+ *
167
+ * Delegates to the standalone `read()` in `commands/file/read.ts`
168
+ * so the CLI and programmatic API share one implementation.
160
169
  */
161
170
  async read(realmUrl: string, path: string): Promise<ReadResult> {
162
- let url = new URL(path, ensureTrailingSlash(realmUrl)).href;
163
-
164
- try {
165
- let response = await this.pm.authedRealmFetch(url, {
166
- method: 'GET',
167
- headers: { Accept: MIME.CardSource },
168
- });
169
-
170
- if (!response.ok) {
171
- let body = await response.text();
172
- return {
173
- ok: false,
174
- status: response.status,
175
- error: `HTTP ${response.status}: ${body.slice(0, 300)}`,
176
- };
177
- }
171
+ return fileRead(realmUrl, path, { profileManager: this.pm });
172
+ }
178
173
 
179
- let text = await response.text();
180
- try {
181
- let document = JSON.parse(text) as Record<string, unknown>;
182
- return { ok: true, status: response.status, document };
183
- } catch {
184
- return { ok: true, status: response.status, content: text };
185
- }
186
- } catch (err) {
187
- return {
188
- ok: false,
189
- error: err instanceof Error ? err.message : String(err),
190
- };
191
- }
174
+ /**
175
+ * Fetch the TRANSPILED JavaScript output for a realm module. Thin
176
+ * wrapper around the `read-transpiled` CLI command — delegates to
177
+ * `readTranspiledModule()` in `commands/read-transpiled.ts` so the
178
+ * CLI and programmatic API share one implementation.
179
+ */
180
+ async readTranspiled(
181
+ realmUrl: string,
182
+ path: string,
183
+ ): Promise<ReadTranspiledResult> {
184
+ return readTranspiledModule(realmUrl, path, { profileManager: this.pm });
192
185
  }
193
186
 
194
187
  /**
195
188
  * Write a file to a realm. Content is sent as-is with card+source MIME type.
196
189
  * Path should include the file extension.
190
+ *
191
+ * Delegates to `write()` in `commands/file/write.ts` so the CLI and
192
+ * programmatic API share one implementation.
197
193
  */
198
194
  async write(
199
195
  realmUrl: string,
200
196
  path: string,
201
197
  content: string,
202
198
  ): Promise<WriteResult> {
203
- let url = new URL(path, ensureTrailingSlash(realmUrl)).href;
204
-
205
- try {
206
- let response = await this.pm.authedRealmFetch(url, {
207
- method: 'POST',
208
- headers: {
209
- Accept: MIME.CardSource,
210
- 'Content-Type': MIME.CardSource,
211
- },
212
- body: content,
213
- });
214
-
215
- if (!response.ok) {
216
- let body = await response.text();
217
- return {
218
- ok: false,
219
- error: `HTTP ${response.status}: ${body.slice(0, 300)}`,
220
- };
221
- }
222
-
223
- return { ok: true };
224
- } catch (err) {
225
- return {
226
- ok: false,
227
- error: err instanceof Error ? err.message : String(err),
228
- };
229
- }
199
+ return coreWrite(realmUrl, path, content, { profileManager: this.pm });
230
200
  }
231
201
 
232
202
  /**
233
- * Delete a file from a realm.
203
+ * Delete a file from a realm. Delegates to the standalone
204
+ * `deleteFile()` command in `commands/file/delete.ts` so the CLI
205
+ * and programmatic API share one implementation.
234
206
  */
235
207
  async delete(realmUrl: string, path: string): Promise<DeleteResult> {
236
- let url = new URL(path, ensureTrailingSlash(realmUrl)).href;
237
-
238
- try {
239
- let response = await this.pm.authedRealmFetch(url, {
240
- method: 'DELETE',
241
- headers: { Accept: MIME.CardSource },
242
- });
243
-
244
- if (!response.ok) {
245
- let body = await response.text();
246
- return {
247
- ok: false,
248
- error: `HTTP ${response.status}: ${body.slice(0, 300)}`,
249
- };
250
- }
208
+ return deleteFile(realmUrl, path, {
209
+ profileManager: this.pm,
210
+ });
211
+ }
251
212
 
252
- return { ok: true };
253
- } catch (err) {
254
- return {
255
- ok: false,
256
- error: err instanceof Error ? err.message : String(err),
257
- };
258
- }
213
+ /**
214
+ * Touch one or more files in a realm to force re-indexing. Delegates to
215
+ * `touchFiles()` in `commands/file/touch.ts`.
216
+ */
217
+ async touch(
218
+ realmUrl: string,
219
+ paths: string[],
220
+ options?: Pick<TouchCommandOptions, 'all' | 'dryRun'>,
221
+ ): Promise<TouchResult> {
222
+ return coreTouchFiles(realmUrl, paths, {
223
+ ...options,
224
+ profileManager: this.pm,
225
+ });
259
226
  }
260
227
 
261
228
  /**
262
- * Search a realm using the `_search` endpoint.
229
+ * Federated search across one or more realms via `_federated-search`.
230
+ * Delegates to the standalone `search()` in `commands/search.ts`.
263
231
  */
264
232
  async search(
265
- realmUrl: string,
233
+ realmUrls: string | string[],
266
234
  query: Record<string, unknown>,
267
235
  ): Promise<SearchResult> {
268
- let searchUrl = `${ensureTrailingSlash(realmUrl)}_search`;
269
-
270
- try {
271
- let response = await this.pm.authedRealmFetch(searchUrl, {
272
- method: 'QUERY',
273
- headers: {
274
- Accept: MIME.CardJson,
275
- 'Content-Type': MIME.JSON,
276
- },
277
- body: JSON.stringify(query),
278
- });
279
-
280
- if (!response.ok) {
281
- let body = await response.text();
282
- return {
283
- ok: false,
284
- status: response.status,
285
- error: `HTTP ${response.status}: ${body.slice(0, 300)}`,
286
- };
287
- }
288
-
289
- let result = (await response.json()) as {
290
- data?: Record<string, unknown>[];
291
- };
292
- return { ok: true, data: result.data };
293
- } catch (err) {
294
- return {
295
- ok: false,
296
- status: 0,
297
- error: err instanceof Error ? err.message : String(err),
298
- };
299
- }
236
+ return fileSearch(realmUrls, query, { profileManager: this.pm });
300
237
  }
301
238
 
302
239
  /**
@@ -304,49 +241,7 @@ export class BoxelCLIClient {
304
241
  * Returns relative paths (e.g., `hello.gts`, `Cards/my-card.json`).
305
242
  */
306
243
  async listFiles(realmUrl: string): Promise<ListFilesResult> {
307
- let normalizedRealmUrl = ensureTrailingSlash(realmUrl);
308
- let mtimesUrl = `${normalizedRealmUrl}_mtimes`;
309
-
310
- try {
311
- let response = await this.pm.authedRealmFetch(mtimesUrl, {
312
- method: 'GET',
313
- headers: { Accept: MIME.JSONAPI },
314
- });
315
-
316
- if (!response.ok) {
317
- let body = await response.text();
318
- return {
319
- filenames: [],
320
- error: `_mtimes returned HTTP ${response.status}: ${body.slice(0, 300)}`,
321
- };
322
- }
323
-
324
- let json = (await response.json()) as {
325
- data?: { attributes?: { mtimes?: Record<string, number> } };
326
- };
327
- let mtimes =
328
- json?.data?.attributes?.mtimes ??
329
- (json as unknown as Record<string, number>);
330
-
331
- let filenames: string[] = [];
332
- for (let fullUrl of Object.keys(mtimes)) {
333
- if (!fullUrl.startsWith(normalizedRealmUrl)) {
334
- continue;
335
- }
336
- let relativePath = fullUrl.slice(normalizedRealmUrl.length);
337
- if (!relativePath || relativePath.endsWith('/')) {
338
- continue;
339
- }
340
- filenames.push(relativePath);
341
- }
342
-
343
- return { filenames: filenames.sort() };
344
- } catch (err) {
345
- return {
346
- filenames: [],
347
- error: err instanceof Error ? err.message : String(err),
348
- };
349
- }
244
+ return coreListFiles(realmUrl, { profileManager: this.pm });
350
245
  }
351
246
 
352
247
  /**
@@ -414,32 +309,14 @@ export class BoxelCLIClient {
414
309
 
415
310
  /**
416
311
  * Lint a single file's source code via the realm's `_lint` endpoint.
312
+ * Delegates to the standalone `lint()` in `commands/file/lint.ts`.
417
313
  */
418
314
  async lint(
419
315
  realmUrl: string,
420
316
  source: string,
421
317
  filename: string,
422
318
  ): Promise<LintResult> {
423
- let lintUrl = `${ensureTrailingSlash(realmUrl)}_lint`;
424
- let response = await this.pm.authedRealmFetch(lintUrl, {
425
- method: 'POST',
426
- headers: {
427
- Accept: MIME.JSON,
428
- 'Content-Type': MIME.CardSource,
429
- 'X-Filename': filename,
430
- 'X-HTTP-Method-Override': 'QUERY',
431
- },
432
- body: source,
433
- });
434
-
435
- if (!response.ok) {
436
- let body = await response.text().catch(() => '(no body)');
437
- throw new Error(
438
- `_lint returned HTTP ${response.status}: ${body.slice(0, 300)}`,
439
- );
440
- }
441
-
442
- return (await response.json()) as LintResult;
319
+ return coreLint(realmUrl, source, filename, { profileManager: this.pm });
443
320
  }
444
321
 
445
322
  /**
@@ -449,28 +326,10 @@ export class BoxelCLIClient {
449
326
  realmUrl: string,
450
327
  timeoutMs = 30_000,
451
328
  ): Promise<WaitForReadyResult> {
452
- let readinessUrl = `${ensureTrailingSlash(realmUrl)}_readiness-check`;
453
- let startedAt = Date.now();
454
-
455
- while (Date.now() - startedAt < timeoutMs) {
456
- try {
457
- let response = await this.pm.authedRealmFetch(readinessUrl, {
458
- method: 'GET',
459
- headers: { Accept: MIME.JSON },
460
- });
461
- if (response.ok) {
462
- return { ready: true };
463
- }
464
- } catch {
465
- // retry
466
- }
467
- await new Promise((r) => setTimeout(r, 1000));
468
- }
469
-
470
- return {
471
- ready: false,
472
- error: `Realm not ready after ${timeoutMs}ms: ${readinessUrl}`,
473
- };
329
+ return coreWaitForReady(realmUrl, {
330
+ timeoutMs,
331
+ profileManager: this.pm,
332
+ });
474
333
  }
475
334
 
476
335
  /**
@@ -540,33 +399,10 @@ export class BoxelCLIClient {
540
399
  * Cancel all indexing jobs (running + pending) for a realm.
541
400
  */
542
401
  async cancelAllIndexingJobs(realmUrl: string): Promise<CancelIndexingResult> {
543
- let cancelUrl = `${ensureTrailingSlash(realmUrl)}_cancel-indexing-job`;
544
-
545
- try {
546
- let response = await this.pm.authedRealmFetch(cancelUrl, {
547
- method: 'POST',
548
- headers: {
549
- Accept: MIME.JSON,
550
- 'Content-Type': MIME.JSON,
551
- },
552
- body: JSON.stringify({ cancelPending: true }),
553
- });
554
-
555
- if (!response.ok) {
556
- let body = await response.text();
557
- return {
558
- ok: false,
559
- error: `HTTP ${response.status}: ${body.slice(0, 300)}`,
560
- };
561
- }
562
-
563
- return { ok: true };
564
- } catch (err) {
565
- return {
566
- ok: false,
567
- error: err instanceof Error ? err.message : String(err),
568
- };
569
- }
402
+ return coreCancelIndexing(realmUrl, {
403
+ profileManager: this.pm,
404
+ cancelPending: true,
405
+ });
570
406
  }
571
407
 
572
408
  /**
@@ -615,6 +451,26 @@ export class BoxelCLIClient {
615
451
  });
616
452
  }
617
453
 
454
+ /**
455
+ * Bidirectional sync between a local workspace and a realm. Thin wrapper
456
+ * around the `realm sync` command's programmatic `sync()` function so the
457
+ * CLI and programmatic API share one implementation.
458
+ */
459
+ async sync(
460
+ realmUrl: string,
461
+ localDir: string,
462
+ options?: SyncOptions,
463
+ ): Promise<SyncResult> {
464
+ return realmSync(localDir, realmUrl, {
465
+ preferLocal: options?.preferLocal,
466
+ preferRemote: options?.preferRemote,
467
+ preferNewest: options?.preferNewest,
468
+ delete: options?.delete,
469
+ dryRun: options?.dryRun,
470
+ profileManager: this.pm,
471
+ });
472
+ }
473
+
618
474
  async createRealm(options: CreateRealmOptions): Promise<CreateRealmResult> {
619
475
  let result = await coreCreateRealm(options.realmName, options.displayName, {
620
476
  background: options.backgroundURL,
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Centralized logger for the boxel CLI.
3
+ *
4
+ * # Guidance for command authors
5
+ *
6
+ * Pick where output goes by asking: **does anything ever parse this
7
+ * line programmatically (`--json` consumer, shell pipeline, the
8
+ * software factory tool executor)?**
9
+ *
10
+ * - **No → `console.log` / `console.info` / `console.debug`.** This is
11
+ * the default for interactive/decorative output: progress messages,
12
+ * colored confirmations ("Fixed: foo.gts"), summaries, anything you'd
13
+ * write with ANSI escapes from `lib/colors`. Under `--quiet` these are
14
+ * intercepted and silenced — a fresh command author doesn't need to
15
+ * know quiet mode exists.
16
+ * - **Yes → `cliLog.output(...)`.** This is for raw payloads a caller
17
+ * parses: the `--json` branch (`cliLog.output(JSON.stringify(result, null, 2))`),
18
+ * raw file content from `read`/`read-transpiled`, and any other
19
+ * stdout-as-contract output. `cliLog.output` writes to stdout
20
+ * regardless of `--quiet`.
21
+ * - **Errors / warnings → `console.error` / `console.warn`.** Never
22
+ * silenced; goes to stderr.
23
+ *
24
+ * Quick heuristic: if the string you're about to print contains
25
+ * `FG_GREEN`/`DIM`/`RESET`/etc., it's decorative — use `console.log`.
26
+ * If it's `JSON.stringify(...)` or raw bytes, use `cliLog.output`.
27
+ *
28
+ * # How it works
29
+ *
30
+ * `--quiet` is a global flag in `src/index.ts`. When set, `setQuiet(true)`
31
+ * replaces `console.log`/`info`/`debug` with no-ops; `cliLog.output`
32
+ * writes directly to `process.stdout` and bypasses the interceptor.
33
+ * Software factory's tool executor passes `--quiet` by default so chatty
34
+ * progress lines don't pollute CI logs (see `factory-tool-executor.ts`).
35
+ */
36
+
37
+ let quiet = false;
38
+
39
+ /**
40
+ * Original references to the console methods we intercept. Captured at
41
+ * module load so `setQuiet(false)` can put them back if quiet mode is
42
+ * toggled off (the unit tests rely on this).
43
+ */
44
+ const originalConsoleLog = console.log.bind(console);
45
+ const originalConsoleInfo = console.info.bind(console);
46
+ const originalConsoleDebug = console.debug.bind(console);
47
+
48
+ /**
49
+ * Toggle quiet mode. When true, `console.log` / `console.info` /
50
+ * `console.debug` are replaced with no-ops for the lifetime of the
51
+ * process. `console.warn` and `console.error` are untouched.
52
+ *
53
+ * Idempotent: calling with the same value is a no-op.
54
+ */
55
+ export function setQuiet(value: boolean): void {
56
+ if (value === quiet) return;
57
+ quiet = value;
58
+ if (quiet) {
59
+ const noop = () => {
60
+ /* intentionally empty: silence info/log/debug under --quiet */
61
+ };
62
+ console.log = noop;
63
+ console.info = noop;
64
+ console.debug = noop;
65
+ } else {
66
+ console.log = originalConsoleLog;
67
+ console.info = originalConsoleInfo;
68
+ console.debug = originalConsoleDebug;
69
+ }
70
+ }
71
+
72
+ export function isQuiet(): boolean {
73
+ return quiet;
74
+ }
75
+
76
+ export const cliLog = {
77
+ /**
78
+ * Informational progress message. Silenced under `--quiet`. Goes to
79
+ * stderr (not stdout) so that it never contaminates stdout-payload
80
+ * commands even when they're inadvertently mixed in.
81
+ */
82
+ info(...args: unknown[]): void {
83
+ if (quiet) return;
84
+ process.stderr.write(args.map(stringifyArg).join(' ') + '\n');
85
+ },
86
+
87
+ /**
88
+ * Warning. Always written (even under quiet) — warnings should reach
89
+ * the operator. Goes to stderr.
90
+ */
91
+ warn(...args: unknown[]): void {
92
+ process.stderr.write(args.map(stringifyArg).join(' ') + '\n');
93
+ },
94
+
95
+ /**
96
+ * Error. Always written (even under quiet). Goes to stderr.
97
+ */
98
+ error(...args: unknown[]): void {
99
+ process.stderr.write(args.map(stringifyArg).join(' ') + '\n');
100
+ },
101
+
102
+ /**
103
+ * Programmatic command output — the "result payload" a caller parses.
104
+ * Writes to stdout and is **never** silenced by `--quiet`.
105
+ *
106
+ * Use this **only** for output where stdout is the contract:
107
+ * - `--json` branches: `cliLog.output(JSON.stringify(result, null, 2))`
108
+ * - raw file content: `read` / `read-transpiled` print bytes via
109
+ * `cliLog.output(result.content ?? '')`
110
+ * - any other stdout-as-API output a script or the software factory
111
+ * pipes/parses
112
+ *
113
+ * Do **not** use this for human-facing decorative lines (colored
114
+ * confirmations, status, summaries, progress). Those go to
115
+ * `console.log` so the `--quiet` interceptor can silence them.
116
+ * If you're tempted to wrap the string in ANSI escapes from
117
+ * `lib/colors`, you want `console.log`, not `cliLog.output`.
118
+ */
119
+ output(...args: unknown[]): void {
120
+ process.stdout.write(args.map(stringifyArg).join(' ') + '\n');
121
+ },
122
+ };
123
+
124
+ function stringifyArg(arg: unknown): string {
125
+ if (typeof arg === 'string') return arg;
126
+ if (arg instanceof Error) return arg.stack ?? arg.message;
127
+ try {
128
+ return JSON.stringify(arg);
129
+ } catch {
130
+ return String(arg);
131
+ }
132
+ }
package/src/lib/colors.ts CHANGED
@@ -1,9 +1,14 @@
1
- // ANSI color codes
2
- export const FG_GREEN = '\x1b[32m';
3
- export const FG_YELLOW = '\x1b[33m';
4
- export const FG_CYAN = '\x1b[36m';
5
- export const FG_MAGENTA = '\x1b[35m';
6
- export const FG_RED = '\x1b[31m';
7
- export const DIM = '\x1b[2m';
8
- export const BOLD = '\x1b[1m';
9
- export const RESET = '\x1b[0m';
1
+ // Disable ANSI escapes when stdout isn't a TTY (piped to file/pager) or
2
+ // NO_COLOR is set — see https://no-color.org. Evaluated at module load.
3
+ const COLOR_ENABLED = process.stdout.isTTY === true && !process.env.NO_COLOR;
4
+
5
+ const c = (code: string): string => (COLOR_ENABLED ? code : '');
6
+
7
+ export const FG_GREEN = c('\x1b[32m');
8
+ export const FG_YELLOW = c('\x1b[33m');
9
+ export const FG_CYAN = c('\x1b[36m');
10
+ export const FG_MAGENTA = c('\x1b[35m');
11
+ export const FG_RED = c('\x1b[31m');
12
+ export const DIM = c('\x1b[2m');
13
+ export const BOLD = c('\x1b[1m');
14
+ export const RESET = c('\x1b[0m');