@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
@@ -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,31 @@ 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;
61
- }
62
-
63
- export interface SearchResult {
64
- ok: boolean;
65
- status?: number;
66
- data?: Record<string, unknown>[];
67
- 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;
93
+ /**
94
+ * Block on the realm-server until uploaded cards have been indexed,
95
+ * not just durably written. Appends `?waitForIndex=true` to the
96
+ * `_atomic` POST. Trades upload latency for read-after-write
97
+ * consistency useful when the next step queries the realm's index
98
+ * (search / list) and can't tolerate the indexer lag introduced by
99
+ * CS-11003 PR 2's deferred `+source` POST. Off by default; flip on
100
+ * for hand-off boundaries like the factory's post-seed sync.
101
+ */
102
+ waitForIndex?: boolean;
68
103
  }
69
104
 
70
- export interface ListFilesResult {
71
- filenames: string[];
72
- error?: string;
73
- }
105
+ export type { DeleteResult };
106
+ export type { WriteResult };
74
107
 
75
108
  export interface RunCommandResult {
76
109
  status: 'ready' | 'error' | 'unusable';
@@ -79,21 +112,7 @@ export interface RunCommandResult {
79
112
  error?: string | null;
80
113
  }
81
114
 
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
- }
115
+ export type { LintMessage, LintResult };
97
116
 
98
117
  export interface WaitForReadyResult {
99
118
  ready: boolean;
@@ -111,10 +130,7 @@ export interface AtomicResult {
111
130
  error?: string;
112
131
  }
113
132
 
114
- export interface CancelIndexingResult {
115
- ok: boolean;
116
- error?: string;
117
- }
133
+ export type { CancelIndexingResult };
118
134
 
119
135
  export class BoxelCLIClient {
120
136
  private pm: ProfileManager;
@@ -155,148 +171,79 @@ export class BoxelCLIClient {
155
171
  }
156
172
 
157
173
  /**
158
- * Read a file from a realm. Returns parsed JSON for .json files,
159
- * raw text for everything else (.gts, etc.).
174
+ * Read a file from a realm. Always returns raw text content.
175
+ * Callers should parse the content themselves if needed (e.g. JSON).
176
+ *
177
+ * Delegates to the standalone `read()` in `commands/file/read.ts`
178
+ * so the CLI and programmatic API share one implementation.
160
179
  */
161
180
  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
- }
181
+ return fileRead(realmUrl, path, { profileManager: this.pm });
182
+ }
178
183
 
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
- }
184
+ /**
185
+ * Fetch the TRANSPILED JavaScript output for a realm module. Thin
186
+ * wrapper around the `read-transpiled` CLI command — delegates to
187
+ * `readTranspiledModule()` in `commands/read-transpiled.ts` so the
188
+ * CLI and programmatic API share one implementation.
189
+ */
190
+ async readTranspiled(
191
+ realmUrl: string,
192
+ path: string,
193
+ ): Promise<ReadTranspiledResult> {
194
+ return readTranspiledModule(realmUrl, path, { profileManager: this.pm });
192
195
  }
193
196
 
194
197
  /**
195
198
  * Write a file to a realm. Content is sent as-is with card+source MIME type.
196
199
  * Path should include the file extension.
200
+ *
201
+ * Delegates to `write()` in `commands/file/write.ts` so the CLI and
202
+ * programmatic API share one implementation.
197
203
  */
198
204
  async write(
199
205
  realmUrl: string,
200
206
  path: string,
201
207
  content: string,
202
208
  ): 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
- }
209
+ return coreWrite(realmUrl, path, content, { profileManager: this.pm });
230
210
  }
231
211
 
232
212
  /**
233
- * Delete a file from a realm.
213
+ * Delete a file from a realm. Delegates to the standalone
214
+ * `deleteFile()` command in `commands/file/delete.ts` so the CLI
215
+ * and programmatic API share one implementation.
234
216
  */
235
217
  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
- }
218
+ return deleteFile(realmUrl, path, {
219
+ profileManager: this.pm,
220
+ });
221
+ }
251
222
 
252
- return { ok: true };
253
- } catch (err) {
254
- return {
255
- ok: false,
256
- error: err instanceof Error ? err.message : String(err),
257
- };
258
- }
223
+ /**
224
+ * Touch one or more files in a realm to force re-indexing. Delegates to
225
+ * `touchFiles()` in `commands/file/touch.ts`.
226
+ */
227
+ async touch(
228
+ realmUrl: string,
229
+ paths: string[],
230
+ options?: Pick<TouchCommandOptions, 'all' | 'dryRun'>,
231
+ ): Promise<TouchResult> {
232
+ return coreTouchFiles(realmUrl, paths, {
233
+ ...options,
234
+ profileManager: this.pm,
235
+ });
259
236
  }
260
237
 
261
238
  /**
262
- * Search a realm using the `_search` endpoint.
239
+ * Federated search across one or more realms via `_federated-search`.
240
+ * Delegates to the standalone `search()` in `commands/search.ts`.
263
241
  */
264
242
  async search(
265
- realmUrl: string,
243
+ realmUrls: string | string[],
266
244
  query: Record<string, unknown>,
267
245
  ): 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
- }
246
+ return fileSearch(realmUrls, query, { profileManager: this.pm });
300
247
  }
301
248
 
302
249
  /**
@@ -304,49 +251,7 @@ export class BoxelCLIClient {
304
251
  * Returns relative paths (e.g., `hello.gts`, `Cards/my-card.json`).
305
252
  */
306
253
  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
- }
254
+ return coreListFiles(realmUrl, { profileManager: this.pm });
350
255
  }
351
256
 
352
257
  /**
@@ -414,32 +319,14 @@ export class BoxelCLIClient {
414
319
 
415
320
  /**
416
321
  * Lint a single file's source code via the realm's `_lint` endpoint.
322
+ * Delegates to the standalone `lint()` in `commands/file/lint.ts`.
417
323
  */
418
324
  async lint(
419
325
  realmUrl: string,
420
326
  source: string,
421
327
  filename: string,
422
328
  ): 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;
329
+ return coreLint(realmUrl, source, filename, { profileManager: this.pm });
443
330
  }
444
331
 
445
332
  /**
@@ -449,28 +336,10 @@ export class BoxelCLIClient {
449
336
  realmUrl: string,
450
337
  timeoutMs = 30_000,
451
338
  ): 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
- };
339
+ return coreWaitForReady(realmUrl, {
340
+ timeoutMs,
341
+ profileManager: this.pm,
342
+ });
474
343
  }
475
344
 
476
345
  /**
@@ -540,33 +409,10 @@ export class BoxelCLIClient {
540
409
  * Cancel all indexing jobs (running + pending) for a realm.
541
410
  */
542
411
  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
- }
412
+ return coreCancelIndexing(realmUrl, {
413
+ profileManager: this.pm,
414
+ cancelPending: true,
415
+ });
570
416
  }
571
417
 
572
418
  /**
@@ -615,6 +461,27 @@ export class BoxelCLIClient {
615
461
  });
616
462
  }
617
463
 
464
+ /**
465
+ * Bidirectional sync between a local workspace and a realm. Thin wrapper
466
+ * around the `realm sync` command's programmatic `sync()` function so the
467
+ * CLI and programmatic API share one implementation.
468
+ */
469
+ async sync(
470
+ realmUrl: string,
471
+ localDir: string,
472
+ options?: SyncOptions,
473
+ ): Promise<SyncResult> {
474
+ return realmSync(localDir, realmUrl, {
475
+ preferLocal: options?.preferLocal,
476
+ preferRemote: options?.preferRemote,
477
+ preferNewest: options?.preferNewest,
478
+ delete: options?.delete,
479
+ dryRun: options?.dryRun,
480
+ waitForIndex: options?.waitForIndex,
481
+ profileManager: this.pm,
482
+ });
483
+ }
484
+
618
485
  async createRealm(options: CreateRealmOptions): Promise<CreateRealmResult> {
619
486
  let result = await coreCreateRealm(options.realmName, options.displayName, {
620
487
  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');