@nemus-cli/nemus 0.4.0 → 0.5.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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.5.0] - 2026-09-01
11
+
12
+ ### Added
13
+
14
+ - **`--no-color` and `-q, --quiet` global flags.** `--no-color` disables ANSI
15
+ color; Nemus also honors the standard [`NO_COLOR`](https://no-color.org) env
16
+ var, auto-disables color when stdout isn't a TTY, and respects `FORCE_COLOR=1`.
17
+ `--quiet` suppresses routine progress logs (info/success/step) while still
18
+ showing warnings and errors; data output (including `--json`) is unaffected.
19
+
10
20
  ## [0.4.0] - 2026-09-01
11
21
 
12
22
  ### Changed
package/README.md CHANGED
@@ -243,6 +243,14 @@ The workspace-scoped ones (`status`/`doctor`/`analyze-deps`) need an explicit
243
243
  workspace name with `--json` (they never prompt). On failure, `--json` prints a
244
244
  parseable `{ "ok": false, "error": … }` to stdout and exits non-zero.
245
245
 
246
+ ### Global flags
247
+
248
+ - `--no-color` — disable ANSI color. Nemus also honors the standard
249
+ [`NO_COLOR`](https://no-color.org) env var and auto-disables color when stdout
250
+ isn't a TTY (piped/redirected); `FORCE_COLOR=1` forces it on.
251
+ - `-q, --quiet` — suppress routine progress logs (info/success/step) while still
252
+ showing warnings and errors. Data (including `--json`) is unaffected.
253
+
246
254
  ### Shell completions
247
255
 
248
256
  Tab-complete subcommands and workspace names. `nemus completion <shell>` prints
@@ -319,6 +327,20 @@ repos, create/update workspaces, check status, and more directly from a prompt.
319
327
 
320
328
  Run `nemus --help` for the full command reference.
321
329
 
330
+ ## Cloud (optional, self-hosted)
331
+
332
+ Nemus is **local-first** — everything above runs entirely on your machine. If you
333
+ want to hand a task to an agent that runs **headlessly on infrastructure you own**
334
+ (local Docker, AWS Fargate, or any Kubernetes cluster) and opens a PR for you,
335
+ there's an **optional, opt-in** package: [`@nemus-cli/cloud`](./packages/cloud).
336
+
337
+ It's a separate, vendor-neutral package built from swappable seams — runners
338
+ (`docker`, `aws-fargate`, `kubernetes`), IaC provisioners (OpenTofu/Terraform
339
+ modules), git forges (GitHub/GitLab), a bounded CI-fix loop, and notifiers — with
340
+ no cloud SDK in the core CLI. It is **not published to npm**; it lives in this
341
+ repo for you to build and run yourself. See
342
+ [`packages/cloud/README.md`](./packages/cloud/README.md) to get started.
343
+
322
344
  ## Configuration
323
345
 
324
346
  `nemus configure` writes `~/.nemus/config.json`. Environment
package/dist/program.js CHANGED
@@ -38,24 +38,18 @@ const commander_1 = require("commander");
38
38
  const path = __importStar(require("path"));
39
39
  const fs = __importStar(require("fs"));
40
40
  const colors_1 = require("./utils/colors");
41
+ const banner_1 = require("./utils/banner");
42
+ const global_flags_1 = require("./utils/global-flags");
41
43
  // Read version from package.json
42
44
  const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf-8'));
43
- const g = colors_1.colors.green;
44
- const d = colors_1.colors.dim;
45
- const b = colors_1.colors.bright;
46
- const r = colors_1.colors.reset;
47
- const INNER = 38;
48
- const titleLine = `>_ Nemus`;
49
- const titlePad = ' '.repeat(Math.max(0, INNER - 2 - titleLine.length));
50
- const versionLine = `v${pkg.version} · multi-repo workspaces`;
51
- const versionPad = ' '.repeat(Math.max(0, INNER - 7 - versionLine.length));
52
- const bar = '─'.repeat(INNER);
53
- const bannerText = `
54
- ${d} ╭${bar}╮${r}
55
- ${d} │${r} ${g}>_${r} ${b}Nemus${r}${titlePad}${d}│${r}
56
- ${d} │${r} ${d}${versionLine}${r}${versionPad}${d}│${r}
57
- ${d} ╰${bar}╯${r}
58
- `;
45
+ // --no-color must be applied BEFORE commander parses so it reaches the help
46
+ // banner (a preAction hook is too late for help output, and ES imports run
47
+ // before hooks). It's a long flag, so it can't be bundled — an argv scan is
48
+ // sufficient here. colors.ts already applied NO_COLOR / non-TTY at import.
49
+ // --quiet is handled in the preAction hook below (it only affects command
50
+ // logs, never help), which also catches bundled short forms like `-yq`.
51
+ if (process.argv.includes('--no-color'))
52
+ (0, colors_1.setColorEnabled)(false);
59
53
  exports.program = new commander_1.Command();
60
54
  exports.program
61
55
  .name('workspace')
@@ -63,7 +57,14 @@ exports.program
63
57
  .version(pkg.version, '-V, --version')
64
58
  .option('-f, --force-refresh', 'Force refresh GitHub repos (skip cache)')
65
59
  .option('-y, --yes', 'Skip confirmations')
66
- .addHelpText('before', bannerText);
60
+ .option('--no-color', 'Disable colored output (also honors NO_COLOR)')
61
+ .option('-q, --quiet', 'Suppress progress logs (keep warnings + errors)')
62
+ .addHelpText('before', () => (0, banner_1.renderHelpBanner)(pkg.version));
63
+ // Apply global --quiet / --color from commander's PARSED options (robust to
64
+ // bundled short flags like `-yq` that a raw argv scan misses). Runs before every
65
+ // command action; help output doesn't reach here, which is why --no-color is
66
+ // also pre-scanned above.
67
+ exports.program.hook('preAction', () => (0, global_flags_1.applyGlobalFlags)(exports.program.opts()));
67
68
  // Register top-level commands
68
69
  const create_1 = require("./commands/create");
69
70
  const list_1 = require("./commands/list");
@@ -1,24 +1,45 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.printBanner = exports.BANNER_PLAIN = exports.BANNER = void 0;
3
+ exports.renderHelpBanner = exports.printBanner = exports.renderBanner = exports.BANNER_PLAIN = void 0;
4
4
  const colors_1 = require("./colors");
5
- const g = colors_1.colors.green;
6
- const d = colors_1.colors.dim;
7
- const b = colors_1.colors.bright;
8
- const r = colors_1.colors.reset;
9
- exports.BANNER = `
10
- ${d} ╭──────────────────────────────────────╮${r}
11
- ${d} │${r} ${g}>_${r} ${b}Nemus${r} ${d}│${r}
12
- ${d} │${r} ${d}multi-repo workspaces${r} ${d}│${r}
13
- ${d} ╰──────────────────────────────────────╯${r}
14
- `;
15
5
  exports.BANNER_PLAIN = `
16
6
  ╭──────────────────────────────────────╮
17
7
  │ >_ Nemus │
18
8
  │ multi-repo workspaces │
19
9
  ╰──────────────────────────────────────╯
20
10
  `;
11
+ // Rendered live (not captured at import) so --no-color / NO_COLOR applied before
12
+ // this is called produce a plain banner. With color off, every colors.* is ''.
13
+ const renderBanner = () => {
14
+ const { green: g, dim: d, bright: b, reset: r } = colors_1.colors;
15
+ return `
16
+ ${d} ╭──────────────────────────────────────╮${r}
17
+ ${d} │${r} ${g}>_${r} ${b}Nemus${r} ${d}│${r}
18
+ ${d} │${r} ${d}multi-repo workspaces${r} ${d}│${r}
19
+ ${d} ╰──────────────────────────────────────╯${r}
20
+ `;
21
+ };
22
+ exports.renderBanner = renderBanner;
21
23
  const printBanner = () => {
22
- console.log(exports.BANNER);
24
+ console.log((0, exports.renderBanner)());
23
25
  };
24
26
  exports.printBanner = printBanner;
27
+ // The `--help` banner: a boxed splash with the version + tagline, width-fitted.
28
+ // Lives here alongside renderBanner() so the two renderers don't drift; also
29
+ // rendered live so --no-color / NO_COLOR yields a plain box.
30
+ const renderHelpBanner = (version) => {
31
+ const { green: g, dim: d, bright: b, reset: r } = colors_1.colors;
32
+ const INNER = 38;
33
+ const titleLine = `>_ Nemus`;
34
+ const titlePad = ' '.repeat(Math.max(0, INNER - 2 - titleLine.length));
35
+ const versionLine = `v${version} · multi-repo workspaces`;
36
+ const versionPad = ' '.repeat(Math.max(0, INNER - 7 - versionLine.length));
37
+ const bar = '─'.repeat(INNER);
38
+ return `
39
+ ${d} ╭${bar}╮${r}
40
+ ${d} │${r} ${g}>_${r} ${b}Nemus${r}${titlePad}${d}│${r}
41
+ ${d} │${r} ${d}${versionLine}${r}${versionPad}${d}│${r}
42
+ ${d} ╰${bar}╯${r}
43
+ `;
44
+ };
45
+ exports.renderHelpBanner = renderHelpBanner;
@@ -1,7 +1,12 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.colorize = exports.colors = void 0;
4
- exports.colors = {
3
+ exports.colorize = exports.isColorEnabled = exports.colors = void 0;
4
+ exports.setColorEnabled = setColorEnabled;
5
+ exports.detectColorEnabled = detectColorEnabled;
6
+ // ANSI escape codes. `colors` starts as a copy of these but is emptied in place
7
+ // when color is disabled (--no-color / NO_COLOR / non-TTY), so BOTH `colorize()`
8
+ // and inline `colors.x` template usage go plain without touching call sites.
9
+ const ANSI = {
5
10
  reset: '\x1b[0m',
6
11
  bright: '\x1b[1m',
7
12
  dim: '\x1b[2m',
@@ -20,7 +25,43 @@ exports.colors = {
20
25
  bgYellow: '\x1b[43m',
21
26
  bgBlue: '\x1b[44m',
22
27
  };
28
+ // Live map read at every use. Mutated in place by setColorEnabled so previously
29
+ // imported references (e.g. `colors.gray` inside a template) see the change.
30
+ exports.colors = { ...ANSI };
31
+ let enabled = true;
32
+ /** Whether colored output is currently on. */
33
+ const isColorEnabled = () => enabled;
34
+ exports.isColorEnabled = isColorEnabled;
35
+ /** Turn color on/off. When off, every `colors.*` code becomes '' so output is
36
+ * plain; when on, the ANSI codes are restored. */
37
+ function setColorEnabled(on) {
38
+ enabled = on;
39
+ for (const key of Object.keys(ANSI)) {
40
+ exports.colors[key] = on ? ANSI[key] : '';
41
+ }
42
+ }
43
+ /**
44
+ * Decide whether to use color by default, following the common conventions:
45
+ * - `NO_COLOR` (any value, even empty) disables — see https://no-color.org
46
+ * - `FORCE_COLOR` (and not "0"/"false") forces it on, even when not a TTY
47
+ * - otherwise on only when the stdout stream is a TTY and `TERM` isn't `dumb`
48
+ */
49
+ function detectColorEnabled(env = process.env, isTTY = !!process.stdout.isTTY) {
50
+ if ('NO_COLOR' in env)
51
+ return false;
52
+ const force = env.FORCE_COLOR;
53
+ if (force !== undefined && force !== '' && force !== '0' && force.toLowerCase() !== 'false') {
54
+ return true;
55
+ }
56
+ if (env.TERM === 'dumb')
57
+ return false;
58
+ return isTTY;
59
+ }
23
60
  const colorize = (text, color) => {
24
61
  return `${exports.colors[color]}${text}${exports.colors.reset}`;
25
62
  };
26
63
  exports.colorize = colorize;
64
+ // Apply the environment default at import time so NO_COLOR / a non-TTY pipe is
65
+ // respected even before any CLI flag is parsed (an explicit --no-color / --color
66
+ // flag overrides this later).
67
+ setColorEnabled(detectColorEnabled());
@@ -0,0 +1,21 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.applyGlobalFlags = applyGlobalFlags;
4
+ const colors_1 = require("./colors");
5
+ const logger_1 = require("./logger");
6
+ /**
7
+ * Apply parsed global flags to process-wide state. Kept pure over injected
8
+ * setters so it's testable without commander — the `preAction` hook in
9
+ * program.ts is a one-line call into this. Only ever turns features OFF here:
10
+ * color defaults on (and env/TTY detection already ran at import), so we act
11
+ * solely on an explicit `--no-color` (`color === false`).
12
+ */
13
+ function applyGlobalFlags(opts, deps = {
14
+ setColorEnabled: colors_1.setColorEnabled,
15
+ setQuiet: logger_1.setQuiet,
16
+ }) {
17
+ if (opts.color === false)
18
+ deps.setColorEnabled(false);
19
+ if (opts.quiet)
20
+ deps.setQuiet(true);
21
+ }
@@ -1,11 +1,20 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.logStep = exports.logWarning = exports.logError = exports.logSuccess = exports.logInfo = void 0;
3
+ exports.logStep = exports.logWarning = exports.logError = exports.logSuccess = exports.logInfo = exports.isQuiet = exports.setQuiet = void 0;
4
4
  const colors_1 = require("./colors");
5
5
  // Diagnostics (info/success/error/warn/step) go to STDERR so stdout carries only
6
6
  // a command's actual data — required for clean `--json` piping (nemus list
7
7
  // --json | jq …) and for non-TTY consumers. Use `outputJson`/stdout for data.
8
8
  const logStream = (line) => console.error(line);
9
+ // `--quiet` silences routine progress (info/success/step) while KEEPING warnings
10
+ // and errors, which a script or human still needs to see.
11
+ let quiet = false;
12
+ const setQuiet = (on) => {
13
+ quiet = on;
14
+ };
15
+ exports.setQuiet = setQuiet;
16
+ const isQuiet = () => quiet;
17
+ exports.isQuiet = isQuiet;
9
18
  const getTimestamp = () => {
10
19
  const now = new Date();
11
20
  return now.toLocaleTimeString('en-US', {
@@ -16,10 +25,14 @@ const getTimestamp = () => {
16
25
  });
17
26
  };
18
27
  const logInfo = (message) => {
28
+ if (quiet)
29
+ return;
19
30
  logStream(`${colors_1.colors.gray}[${getTimestamp()}]${colors_1.colors.reset} ${message}`);
20
31
  };
21
32
  exports.logInfo = logInfo;
22
33
  const logSuccess = (message) => {
34
+ if (quiet)
35
+ return;
23
36
  logStream(`${colors_1.colors.gray}[${getTimestamp()}]${colors_1.colors.reset} ${(0, colors_1.colorize)('✓', 'green')} ${message}`);
24
37
  };
25
38
  exports.logSuccess = logSuccess;
@@ -32,6 +45,8 @@ const logWarning = (message) => {
32
45
  };
33
46
  exports.logWarning = logWarning;
34
47
  const logStep = (stepOrMessage, total, message) => {
48
+ if (quiet)
49
+ return;
35
50
  if (typeof stepOrMessage === 'string') {
36
51
  // Single parameter version: just a message
37
52
  logStream(`${colors_1.colors.gray}[${getTimestamp()}]${colors_1.colors.reset} ${(0, colors_1.colorize)('▸', 'cyan')} ${stepOrMessage}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nemus-cli/nemus",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "workspaces": [
5
5
  "packages/*"
6
6
  ],
package/src/program.ts CHANGED
@@ -1,28 +1,20 @@
1
1
  import { Command } from 'commander';
2
2
  import * as path from 'path';
3
3
  import * as fs from 'fs';
4
- import { colors } from './utils/colors';
4
+ import { setColorEnabled } from './utils/colors';
5
+ import { renderHelpBanner } from './utils/banner';
6
+ import { applyGlobalFlags } from './utils/global-flags';
5
7
 
6
8
  // Read version from package.json
7
9
  const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf-8'));
8
10
 
9
- const g = colors.green;
10
- const d = colors.dim;
11
- const b = colors.bright;
12
- const r = colors.reset;
13
-
14
- const INNER = 38;
15
- const titleLine = `>_ Nemus`;
16
- const titlePad = ' '.repeat(Math.max(0, INNER - 2 - titleLine.length));
17
- const versionLine = `v${pkg.version} · multi-repo workspaces`;
18
- const versionPad = ' '.repeat(Math.max(0, INNER - 7 - versionLine.length));
19
- const bar = '─'.repeat(INNER);
20
- const bannerText = `
21
- ${d} ╭${bar}╮${r}
22
- ${d} │${r} ${g}>_${r} ${b}Nemus${r}${titlePad}${d}│${r}
23
- ${d} │${r} ${d}${versionLine}${r}${versionPad}${d}│${r}
24
- ${d} ╰${bar}╯${r}
25
- `;
11
+ // --no-color must be applied BEFORE commander parses so it reaches the help
12
+ // banner (a preAction hook is too late for help output, and ES imports run
13
+ // before hooks). It's a long flag, so it can't be bundled — an argv scan is
14
+ // sufficient here. colors.ts already applied NO_COLOR / non-TTY at import.
15
+ // --quiet is handled in the preAction hook below (it only affects command
16
+ // logs, never help), which also catches bundled short forms like `-yq`.
17
+ if (process.argv.includes('--no-color')) setColorEnabled(false);
26
18
 
27
19
  export const program = new Command();
28
20
 
@@ -32,7 +24,15 @@ program
32
24
  .version(pkg.version, '-V, --version')
33
25
  .option('-f, --force-refresh', 'Force refresh GitHub repos (skip cache)')
34
26
  .option('-y, --yes', 'Skip confirmations')
35
- .addHelpText('before', bannerText);
27
+ .option('--no-color', 'Disable colored output (also honors NO_COLOR)')
28
+ .option('-q, --quiet', 'Suppress progress logs (keep warnings + errors)')
29
+ .addHelpText('before', () => renderHelpBanner(pkg.version));
30
+
31
+ // Apply global --quiet / --color from commander's PARSED options (robust to
32
+ // bundled short flags like `-yq` that a raw argv scan misses). Runs before every
33
+ // command action; help output doesn't reach here, which is why --no-color is
34
+ // also pre-scanned above.
35
+ program.hook('preAction', () => applyGlobalFlags(program.opts()));
36
36
 
37
37
  // Register top-level commands
38
38
  import { registerCreateCommand } from './commands/create';
@@ -1,17 +1,5 @@
1
1
  import { colors } from './colors';
2
2
 
3
- const g = colors.green;
4
- const d = colors.dim;
5
- const b = colors.bright;
6
- const r = colors.reset;
7
-
8
- export const BANNER = `
9
- ${d} ╭──────────────────────────────────────╮${r}
10
- ${d} │${r} ${g}>_${r} ${b}Nemus${r} ${d}│${r}
11
- ${d} │${r} ${d}multi-repo workspaces${r} ${d}│${r}
12
- ${d} ╰──────────────────────────────────────╯${r}
13
- `;
14
-
15
3
  export const BANNER_PLAIN = `
16
4
  ╭──────────────────────────────────────╮
17
5
  │ >_ Nemus │
@@ -19,6 +7,37 @@ export const BANNER_PLAIN = `
19
7
  ╰──────────────────────────────────────╯
20
8
  `;
21
9
 
10
+ // Rendered live (not captured at import) so --no-color / NO_COLOR applied before
11
+ // this is called produce a plain banner. With color off, every colors.* is ''.
12
+ export const renderBanner = (): string => {
13
+ const { green: g, dim: d, bright: b, reset: r } = colors;
14
+ return `
15
+ ${d} ╭──────────────────────────────────────╮${r}
16
+ ${d} │${r} ${g}>_${r} ${b}Nemus${r} ${d}│${r}
17
+ ${d} │${r} ${d}multi-repo workspaces${r} ${d}│${r}
18
+ ${d} ╰──────────────────────────────────────╯${r}
19
+ `;
20
+ };
21
+
22
22
  export const printBanner = (): void => {
23
- console.log(BANNER);
23
+ console.log(renderBanner());
24
+ };
25
+
26
+ // The `--help` banner: a boxed splash with the version + tagline, width-fitted.
27
+ // Lives here alongside renderBanner() so the two renderers don't drift; also
28
+ // rendered live so --no-color / NO_COLOR yields a plain box.
29
+ export const renderHelpBanner = (version: string): string => {
30
+ const { green: g, dim: d, bright: b, reset: r } = colors;
31
+ const INNER = 38;
32
+ const titleLine = `>_ Nemus`;
33
+ const titlePad = ' '.repeat(Math.max(0, INNER - 2 - titleLine.length));
34
+ const versionLine = `v${version} · multi-repo workspaces`;
35
+ const versionPad = ' '.repeat(Math.max(0, INNER - 7 - versionLine.length));
36
+ const bar = '─'.repeat(INNER);
37
+ return `
38
+ ${d} ╭${bar}╮${r}
39
+ ${d} │${r} ${g}>_${r} ${b}Nemus${r}${titlePad}${d}│${r}
40
+ ${d} │${r} ${d}${versionLine}${r}${versionPad}${d}│${r}
41
+ ${d} ╰${bar}╯${r}
42
+ `;
24
43
  };
@@ -0,0 +1,51 @@
1
+ import { describe, it, expect, afterEach } from 'vitest';
2
+ import { colors, colorize, setColorEnabled, isColorEnabled, detectColorEnabled } from './colors';
3
+
4
+ // Tests toggle global color state; restore a known state afterward.
5
+ afterEach(() => setColorEnabled(true));
6
+
7
+ describe('setColorEnabled / colorize', () => {
8
+ it('wraps with ANSI when on, and is plain when off (same call sites)', () => {
9
+ setColorEnabled(true);
10
+ expect(isColorEnabled()).toBe(true);
11
+ const on = colorize('hi', 'green');
12
+ expect(on).toContain('\x1b[32m');
13
+ expect(on).toContain('\x1b[0m');
14
+ expect(on).toContain('hi');
15
+
16
+ setColorEnabled(false);
17
+ expect(isColorEnabled()).toBe(false);
18
+ expect(colorize('hi', 'green')).toBe('hi'); // no codes at all
19
+ });
20
+
21
+ it('empties inline colors.* codes in place when disabled', () => {
22
+ setColorEnabled(false);
23
+ expect(colors.gray).toBe('');
24
+ expect(colors.reset).toBe('');
25
+ setColorEnabled(true);
26
+ expect(colors.gray).toBe('\x1b[90m');
27
+ });
28
+ });
29
+
30
+ describe('detectColorEnabled', () => {
31
+ it('NO_COLOR disables regardless of value (even empty)', () => {
32
+ expect(detectColorEnabled({ NO_COLOR: '1' }, true)).toBe(false);
33
+ expect(detectColorEnabled({ NO_COLOR: '' }, true)).toBe(false);
34
+ });
35
+
36
+ it('FORCE_COLOR forces on even without a TTY (but 0/false do not)', () => {
37
+ expect(detectColorEnabled({ FORCE_COLOR: '1' }, false)).toBe(true);
38
+ expect(detectColorEnabled({ FORCE_COLOR: '0' }, false)).toBe(false);
39
+ expect(detectColorEnabled({ FORCE_COLOR: 'false' }, false)).toBe(false);
40
+ });
41
+
42
+ it('NO_COLOR beats FORCE_COLOR', () => {
43
+ expect(detectColorEnabled({ NO_COLOR: '1', FORCE_COLOR: '1' }, true)).toBe(false);
44
+ });
45
+
46
+ it('otherwise follows the TTY, and TERM=dumb disables', () => {
47
+ expect(detectColorEnabled({}, true)).toBe(true);
48
+ expect(detectColorEnabled({}, false)).toBe(false);
49
+ expect(detectColorEnabled({ TERM: 'dumb' }, true)).toBe(false);
50
+ });
51
+ });
@@ -1,4 +1,7 @@
1
- export const colors = {
1
+ // ANSI escape codes. `colors` starts as a copy of these but is emptied in place
2
+ // when color is disabled (--no-color / NO_COLOR / non-TTY), so BOTH `colorize()`
3
+ // and inline `colors.x` template usage go plain without touching call sites.
4
+ const ANSI = {
2
5
  reset: '\x1b[0m',
3
6
  bright: '\x1b[1m',
4
7
  dim: '\x1b[2m',
@@ -18,8 +21,52 @@ export const colors = {
18
21
  bgGreen: '\x1b[42m',
19
22
  bgYellow: '\x1b[43m',
20
23
  bgBlue: '\x1b[44m',
21
- };
24
+ } as const;
25
+
26
+ export type ColorName = keyof typeof ANSI;
27
+
28
+ // Live map read at every use. Mutated in place by setColorEnabled so previously
29
+ // imported references (e.g. `colors.gray` inside a template) see the change.
30
+ export const colors: Record<ColorName, string> = { ...ANSI };
31
+
32
+ let enabled = true;
22
33
 
23
- export const colorize = (text: string, color: keyof typeof colors): string => {
34
+ /** Whether colored output is currently on. */
35
+ export const isColorEnabled = (): boolean => enabled;
36
+
37
+ /** Turn color on/off. When off, every `colors.*` code becomes '' so output is
38
+ * plain; when on, the ANSI codes are restored. */
39
+ export function setColorEnabled(on: boolean): void {
40
+ enabled = on;
41
+ for (const key of Object.keys(ANSI) as ColorName[]) {
42
+ colors[key] = on ? ANSI[key] : '';
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Decide whether to use color by default, following the common conventions:
48
+ * - `NO_COLOR` (any value, even empty) disables — see https://no-color.org
49
+ * - `FORCE_COLOR` (and not "0"/"false") forces it on, even when not a TTY
50
+ * - otherwise on only when the stdout stream is a TTY and `TERM` isn't `dumb`
51
+ */
52
+ export function detectColorEnabled(
53
+ env: NodeJS.ProcessEnv = process.env,
54
+ isTTY: boolean = !!process.stdout.isTTY,
55
+ ): boolean {
56
+ if ('NO_COLOR' in env) return false;
57
+ const force = env.FORCE_COLOR;
58
+ if (force !== undefined && force !== '' && force !== '0' && force.toLowerCase() !== 'false') {
59
+ return true;
60
+ }
61
+ if (env.TERM === 'dumb') return false;
62
+ return isTTY;
63
+ }
64
+
65
+ export const colorize = (text: string, color: ColorName): string => {
24
66
  return `${colors[color]}${text}${colors.reset}`;
25
67
  };
68
+
69
+ // Apply the environment default at import time so NO_COLOR / a non-TTY pipe is
70
+ // respected even before any CLI flag is parsed (an explicit --no-color / --color
71
+ // flag overrides this later).
72
+ setColorEnabled(detectColorEnabled());
@@ -0,0 +1,44 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { Command } from 'commander';
3
+ import { applyGlobalFlags } from './global-flags';
4
+
5
+ const spies = () => ({ setColorEnabled: vi.fn(), setQuiet: vi.fn() });
6
+
7
+ // Mirror the real root-program global options so we test what commander actually
8
+ // parses (esp. bundled short flags), not a hand-built opts object.
9
+ const rootOpts = (argv: string[]) => {
10
+ const p = new Command();
11
+ p.option('-y, --yes', '').option('-q, --quiet', '').option('--no-color', '');
12
+ p.command('list').action(() => {});
13
+ p.parse(['node', 'nemus', 'list', ...argv]);
14
+ return p.opts();
15
+ };
16
+
17
+ describe('applyGlobalFlags', () => {
18
+ it('does nothing by default (color on, not quiet)', () => {
19
+ const d = spies();
20
+ applyGlobalFlags(rootOpts([]), d);
21
+ expect(d.setColorEnabled).not.toHaveBeenCalled();
22
+ expect(d.setQuiet).not.toHaveBeenCalled();
23
+ });
24
+
25
+ it('--no-color disables color', () => {
26
+ const d = spies();
27
+ applyGlobalFlags(rootOpts(['--no-color']), d);
28
+ expect(d.setColorEnabled).toHaveBeenCalledWith(false);
29
+ });
30
+
31
+ it('--quiet enables quiet', () => {
32
+ const d = spies();
33
+ applyGlobalFlags(rootOpts(['--quiet']), d);
34
+ expect(d.setQuiet).toHaveBeenCalledWith(true);
35
+ });
36
+
37
+ it('bundled short flags (-yq) still enable quiet', () => {
38
+ const d = spies();
39
+ const opts = rootOpts(['-yq']);
40
+ expect(opts.quiet).toBe(true); // commander expands the bundle
41
+ applyGlobalFlags(opts, d);
42
+ expect(d.setQuiet).toHaveBeenCalledWith(true);
43
+ });
44
+ });
@@ -0,0 +1,28 @@
1
+ import { setColorEnabled } from './colors';
2
+ import { setQuiet } from './logger';
3
+
4
+ /** Global options commander parses off the root program. */
5
+ export interface GlobalFlagOpts {
6
+ /** commander's negatable `--no-color` yields `color: false` when passed. */
7
+ color?: boolean;
8
+ /** `-q`/`--quiet`, including bundled short forms like `-yq`. */
9
+ quiet?: boolean;
10
+ }
11
+
12
+ /**
13
+ * Apply parsed global flags to process-wide state. Kept pure over injected
14
+ * setters so it's testable without commander — the `preAction` hook in
15
+ * program.ts is a one-line call into this. Only ever turns features OFF here:
16
+ * color defaults on (and env/TTY detection already ran at import), so we act
17
+ * solely on an explicit `--no-color` (`color === false`).
18
+ */
19
+ export function applyGlobalFlags(
20
+ opts: GlobalFlagOpts,
21
+ deps: { setColorEnabled: (on: boolean) => void; setQuiet: (on: boolean) => void } = {
22
+ setColorEnabled,
23
+ setQuiet,
24
+ },
25
+ ): void {
26
+ if (opts.color === false) deps.setColorEnabled(false);
27
+ if (opts.quiet) deps.setQuiet(true);
28
+ }
@@ -0,0 +1,36 @@
1
+ import { describe, it, expect, vi, afterEach } from 'vitest';
2
+ import { logInfo, logSuccess, logStep, logWarning, logError, setQuiet, isQuiet } from './logger';
3
+
4
+ afterEach(() => {
5
+ setQuiet(false);
6
+ vi.restoreAllMocks();
7
+ });
8
+
9
+ describe('--quiet (setQuiet)', () => {
10
+ it('suppresses info/success/step but keeps warnings + errors', () => {
11
+ const err = vi.spyOn(console, 'error').mockImplementation(() => {});
12
+ setQuiet(true);
13
+ expect(isQuiet()).toBe(true);
14
+
15
+ logInfo('i');
16
+ logSuccess('s');
17
+ logStep('st');
18
+ expect(err).not.toHaveBeenCalled(); // routine progress silenced
19
+
20
+ logWarning('w');
21
+ logError('e');
22
+ expect(err).toHaveBeenCalledTimes(2); // warnings + errors still shown
23
+ expect(err.mock.calls.map((c) => String(c[0])).join('\n')).toMatch(/w[\s\S]*e|e[\s\S]*w/);
24
+ });
25
+
26
+ it('emits everything when not quiet', () => {
27
+ const err = vi.spyOn(console, 'error').mockImplementation(() => {});
28
+ setQuiet(false);
29
+ logInfo('i');
30
+ logSuccess('s');
31
+ logStep('st');
32
+ logWarning('w');
33
+ logError('e');
34
+ expect(err).toHaveBeenCalledTimes(5);
35
+ });
36
+ });
@@ -5,6 +5,14 @@ import { colors, colorize } from './colors';
5
5
  // --json | jq …) and for non-TTY consumers. Use `outputJson`/stdout for data.
6
6
  const logStream = (line: string): void => console.error(line);
7
7
 
8
+ // `--quiet` silences routine progress (info/success/step) while KEEPING warnings
9
+ // and errors, which a script or human still needs to see.
10
+ let quiet = false;
11
+ export const setQuiet = (on: boolean): void => {
12
+ quiet = on;
13
+ };
14
+ export const isQuiet = (): boolean => quiet;
15
+
8
16
  const getTimestamp = (): string => {
9
17
  const now = new Date();
10
18
  return now.toLocaleTimeString('en-US', {
@@ -16,10 +24,12 @@ const getTimestamp = (): string => {
16
24
  };
17
25
 
18
26
  export const logInfo = (message: string): void => {
27
+ if (quiet) return;
19
28
  logStream(`${colors.gray}[${getTimestamp()}]${colors.reset} ${message}`);
20
29
  };
21
30
 
22
31
  export const logSuccess = (message: string): void => {
32
+ if (quiet) return;
23
33
  logStream(`${colors.gray}[${getTimestamp()}]${colors.reset} ${colorize('✓', 'green')} ${message}`);
24
34
  };
25
35
 
@@ -32,6 +42,7 @@ export const logWarning = (message: string): void => {
32
42
  };
33
43
 
34
44
  export const logStep = (stepOrMessage: number | string, total?: number, message?: string): void => {
45
+ if (quiet) return;
35
46
  if (typeof stepOrMessage === 'string') {
36
47
  // Single parameter version: just a message
37
48
  logStream(`${colors.gray}[${getTimestamp()}]${colors.reset} ${colorize('▸', 'cyan')} ${stepOrMessage}`);