@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 +10 -0
- package/README.md +22 -0
- package/dist/program.js +18 -17
- package/dist/utils/banner.js +33 -12
- package/dist/utils/colors.js +43 -2
- package/dist/utils/global-flags.js +21 -0
- package/dist/utils/logger.js +16 -1
- package/package.json +1 -1
- package/src/program.ts +19 -19
- package/src/utils/banner.ts +32 -13
- package/src/utils/colors.test.ts +51 -0
- package/src/utils/colors.ts +50 -3
- package/src/utils/global-flags.test.ts +44 -0
- package/src/utils/global-flags.ts +28 -0
- package/src/utils/logger.test.ts +36 -0
- package/src/utils/logger.ts +11 -0
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
.
|
|
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");
|
package/dist/utils/banner.js
CHANGED
|
@@ -1,24 +1,45 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.printBanner = exports.
|
|
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.
|
|
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;
|
package/dist/utils/colors.js
CHANGED
|
@@ -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.
|
|
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
|
+
}
|
package/dist/utils/logger.js
CHANGED
|
@@ -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
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 {
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
.
|
|
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';
|
package/src/utils/banner.ts
CHANGED
|
@@ -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(
|
|
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
|
+
});
|
package/src/utils/colors.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
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
|
+
});
|
package/src/utils/logger.ts
CHANGED
|
@@ -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}`);
|