@nemus-cli/nemus 0.11.0 → 0.13.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 +26 -0
- package/README.md +1 -0
- package/dist/commands/completion.js +106 -12
- package/dist/commands/version.js +37 -0
- package/dist/program.js +2 -0
- package/dist/utils/version-check.js +15 -0
- package/package.json +1 -1
- package/skills/nemus/references/completion.md +7 -2
- package/src/commands/completion.test.ts +103 -2
- package/src/commands/completion.ts +121 -12
- package/src/commands/version.test.ts +21 -0
- package/src/commands/version.ts +45 -0
- package/src/program.ts +2 -0
- package/src/utils/version-check.test.ts +28 -1
- package/src/utils/version-check.ts +15 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.13.0] - 2026-09-02
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **Shell completions now cover second-level subcommands and value arguments.**
|
|
15
|
+
`nemus config <TAB>` completes `get/set/unset/list/path/edit`, `nemus config
|
|
16
|
+
set <TAB>` completes the config **keys**, and `nemus reflect <TAB>` completes
|
|
17
|
+
`history/show` — across bash, zsh, and fish. Subcommands are auto-derived from
|
|
18
|
+
the command tree (so `suite`/`branch`/`cache`/`mcp` are covered too). (#73)
|
|
19
|
+
- **`nemus completion` infers the shell from `$SHELL`** when no argument is
|
|
20
|
+
given (`nemus completion` → emits your shell's script). An explicit
|
|
21
|
+
`bash|zsh|fish` still wins; an unknown/invalid value errors as before. (#75)
|
|
22
|
+
|
|
23
|
+
## [0.12.0] - 2026-09-02
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
|
|
27
|
+
- **`nemus version` subcommand** — a companion to the `-V/--version` flag for
|
|
28
|
+
people who type `nemus version`. `--json` additionally reports the Node.js
|
|
29
|
+
version, platform, and arch (handy for bug reports), as a single JSON document
|
|
30
|
+
to stdout. (#74)
|
|
31
|
+
- **`NEMUS_NO_UPDATE_CHECK`** — opt out of the background "update available"
|
|
32
|
+
check entirely (no cache read, no network). Also honors the de-facto
|
|
33
|
+
`NO_UPDATE_NOTIFIER`. An explicit falsey value (`0`/`false`/empty) does not
|
|
34
|
+
disable it. Documented in the README env-var table. (#76)
|
|
35
|
+
|
|
10
36
|
## [0.11.0] - 2026-09-02
|
|
11
37
|
|
|
12
38
|
### Added
|
package/README.md
CHANGED
|
@@ -288,6 +288,7 @@ Everything Nemus reads from the environment (all optional):
|
|
|
288
288
|
| `NEMUS_JUDGE_TIMEOUT_MS` | Timeout for the `reflect` judge call. |
|
|
289
289
|
| `NEMUS_BUG_REPORT_REPO` | Repo that `report-bug` files issues against. |
|
|
290
290
|
| `NEMUS_SKIP_CONFIGURE` | Skip the one-time post-install `configure` prompt. |
|
|
291
|
+
| `NEMUS_NO_UPDATE_CHECK` | Disable the background "update available" check (also honors `NO_UPDATE_NOTIFIER`). |
|
|
291
292
|
| `WORKSPACE_CLONE_TIMEOUT_MS` | Git clone timeout (default 15 min). |
|
|
292
293
|
| `NO_COLOR` / `FORCE_COLOR` | Disable / force ANSI color (see [Global flags](#global-flags)). |
|
|
293
294
|
| `VISUAL` / `EDITOR` | Editor launched by `nemus config edit`. |
|
|
@@ -1,13 +1,28 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.COMPLETION_BINS = void 0;
|
|
3
|
+
exports.SHELLS = exports.COMPLETION_BINS = void 0;
|
|
4
|
+
exports.detectShell = detectShell;
|
|
4
5
|
exports.generateCompletion = generateCompletion;
|
|
5
6
|
exports.specsFromProgram = specsFromProgram;
|
|
6
7
|
exports.registerCompletionCommand = registerCompletionCommand;
|
|
7
8
|
const workspace_meta_1 = require("../utils/workspace-meta");
|
|
8
9
|
const logger_1 = require("../utils/logger");
|
|
10
|
+
const config_schema_1 = require("../utils/config-schema");
|
|
9
11
|
/** Binaries that get completion registered (the CLI's bins). */
|
|
10
12
|
exports.COMPLETION_BINS = ['nemus', 'nem'];
|
|
13
|
+
exports.SHELLS = ['bash', 'zsh', 'fish'];
|
|
14
|
+
/** Commands whose subcommands are positional args (not nested commander commands). */
|
|
15
|
+
const POSITIONAL_SUBCOMMANDS = { reflect: ['history', 'show'] };
|
|
16
|
+
/**
|
|
17
|
+
* Infer a shell from a `$SHELL`-style path (e.g. `/bin/zsh` -> `zsh`). Returns
|
|
18
|
+
* null for an unknown/empty value. Pure + exported for testing.
|
|
19
|
+
*/
|
|
20
|
+
function detectShell(shellPath) {
|
|
21
|
+
if (!shellPath)
|
|
22
|
+
return null;
|
|
23
|
+
const base = shellPath.trim().split('/').pop()?.toLowerCase() ?? '';
|
|
24
|
+
return exports.SHELLS.find((s) => s === base) ?? null;
|
|
25
|
+
}
|
|
11
26
|
/** Every token (name + aliases) that should complete as a subcommand. */
|
|
12
27
|
function allTokens(cmds) {
|
|
13
28
|
return cmds.flatMap((c) => [c.name, ...c.aliases]);
|
|
@@ -16,23 +31,46 @@ function allTokens(cmds) {
|
|
|
16
31
|
function workspaceTokens(cmds) {
|
|
17
32
|
return cmds.filter((c) => c.takesWorkspace).flatMap((c) => [c.name, ...c.aliases]);
|
|
18
33
|
}
|
|
34
|
+
/** All tokens (name + aliases) that route to a given command spec. */
|
|
35
|
+
function cmdTokens(c) {
|
|
36
|
+
return [c.name, ...c.aliases];
|
|
37
|
+
}
|
|
19
38
|
/** Escape a description for a fish single-quoted string. */
|
|
20
39
|
function fishDesc(s) {
|
|
21
40
|
return s.replace(/\n/g, ' ').replace(/'/g, "'\\''");
|
|
22
41
|
}
|
|
23
42
|
/**
|
|
24
43
|
* Generate a shell completion script. Pure (no I/O) so it's unit-tested. The
|
|
25
|
-
* generated script completes
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
44
|
+
* generated script completes, in order: top-level commands (position 1); then
|
|
45
|
+
* either workspace names (for workspace-scoped commands) or a command's own
|
|
46
|
+
* subcommands (position 2); then value completions like `config` keys
|
|
47
|
+
* (position 3). Workspace names stay dynamic via a callback into
|
|
48
|
+
* `<bin> completion --workspaces`.
|
|
29
49
|
*/
|
|
30
50
|
function generateCompletion(shell, cmds, bins = exports.COMPLETION_BINS) {
|
|
31
51
|
const commands = allTokens(cmds).join(' ');
|
|
32
52
|
const wsCommands = workspaceTokens(cmds).join(' ');
|
|
53
|
+
const withSubs = cmds.filter((c) => c.subcommands && c.subcommands.length);
|
|
54
|
+
const withArgVals = cmds.filter((c) => c.argValues && c.argValues.values.length);
|
|
33
55
|
if (shell === 'bash') {
|
|
56
|
+
const subArms = withSubs
|
|
57
|
+
.map((c) => ` ${cmdTokens(c).join('|')}) echo "${c.subcommands.join(' ')}" ;;`)
|
|
58
|
+
.join('\n');
|
|
59
|
+
const argArms = withArgVals
|
|
60
|
+
.map((c) => ` ${cmdTokens(c).join('|')}) case "$2" in ${c.argValues.after.join('|')}) echo "${c.argValues.values.join(' ')}" ;; esac ;;`)
|
|
61
|
+
.join('\n');
|
|
34
62
|
return `# nemus bash completion. Install: nemus completion bash > /etc/bash_completion.d/nemus
|
|
35
63
|
# (or: nemus completion bash >> ~/.bashrc)
|
|
64
|
+
_nemus_subcmds() {
|
|
65
|
+
case "$1" in
|
|
66
|
+
${subArms}
|
|
67
|
+
esac
|
|
68
|
+
}
|
|
69
|
+
_nemus_argvals() {
|
|
70
|
+
case "$1" in
|
|
71
|
+
${argArms}
|
|
72
|
+
esac
|
|
73
|
+
}
|
|
36
74
|
_nemus_complete() {
|
|
37
75
|
local cur bin sub
|
|
38
76
|
# bash does not clear COMPREPLY between completions; reset so a stale result
|
|
@@ -54,6 +92,20 @@ _nemus_complete() {
|
|
|
54
92
|
COMPREPLY=( \$(compgen -W "\$names" -- "\$cur") )
|
|
55
93
|
return 0
|
|
56
94
|
fi
|
|
95
|
+
local subs
|
|
96
|
+
subs="\$(_nemus_subcmds "\$sub")"
|
|
97
|
+
if [ -n "\$subs" ]; then
|
|
98
|
+
COMPREPLY=( \$(compgen -W "\$subs" -- "\$cur") )
|
|
99
|
+
fi
|
|
100
|
+
return 0
|
|
101
|
+
fi
|
|
102
|
+
if [ "\$COMP_CWORD" -eq 3 ]; then
|
|
103
|
+
local vals
|
|
104
|
+
vals="\$(_nemus_argvals "\${COMP_WORDS[1]}" "\${COMP_WORDS[2]}")"
|
|
105
|
+
if [ -n "\$vals" ]; then
|
|
106
|
+
COMPREPLY=( \$(compgen -W "\$vals" -- "\$cur") )
|
|
107
|
+
fi
|
|
108
|
+
return 0
|
|
57
109
|
fi
|
|
58
110
|
return 0
|
|
59
111
|
}
|
|
@@ -61,6 +113,12 @@ ${bins.map((b) => `complete -F _nemus_complete ${b}`).join('\n')}
|
|
|
61
113
|
`;
|
|
62
114
|
}
|
|
63
115
|
if (shell === 'zsh') {
|
|
116
|
+
const subArms = withSubs
|
|
117
|
+
.map((c) => ` ${cmdTokens(c).join('|')}) compadd -- ${c.subcommands.join(' ')} ;;`)
|
|
118
|
+
.join('\n');
|
|
119
|
+
const argArms = withArgVals
|
|
120
|
+
.map((c) => ` ${cmdTokens(c).join('|')}) case \${words[3]} in ${c.argValues.after.join('|')}) compadd -- ${c.argValues.values.join(' ')} ;; esac ;;`)
|
|
121
|
+
.join('\n');
|
|
64
122
|
// Autoloaded form: save as a file named `_nemus` on your $fpath.
|
|
65
123
|
return `#compdef ${bins.join(' ')}
|
|
66
124
|
# nemus zsh completion. Install: nemus completion zsh > "\${fpath[1]}/_nemus"
|
|
@@ -77,7 +135,17 @@ if (( CURRENT == 3 )); then
|
|
|
77
135
|
local -a _nemus_names
|
|
78
136
|
_nemus_names=(\${(f)"$(\${words[1]} completion --workspaces 2>/dev/null)"})
|
|
79
137
|
compadd -- $_nemus_names
|
|
138
|
+
return
|
|
80
139
|
fi
|
|
140
|
+
case $sub in
|
|
141
|
+
${subArms}
|
|
142
|
+
esac
|
|
143
|
+
return
|
|
144
|
+
fi
|
|
145
|
+
if (( CURRENT == 4 )); then
|
|
146
|
+
case \${words[2]} in
|
|
147
|
+
${argArms}
|
|
148
|
+
esac
|
|
81
149
|
fi
|
|
82
150
|
`;
|
|
83
151
|
}
|
|
@@ -86,7 +154,7 @@ fi
|
|
|
86
154
|
for (const bin of bins) {
|
|
87
155
|
lines.push(`complete -c ${bin} -f`);
|
|
88
156
|
for (const c of cmds) {
|
|
89
|
-
for (const tok of
|
|
157
|
+
for (const tok of cmdTokens(c)) {
|
|
90
158
|
lines.push(`complete -c ${bin} -n __fish_use_subcommand -a '${tok}' -d '${fishDesc(c.description)}'`);
|
|
91
159
|
}
|
|
92
160
|
}
|
|
@@ -94,6 +162,14 @@ fi
|
|
|
94
162
|
if (wsToks) {
|
|
95
163
|
lines.push(`complete -c ${bin} -n '__fish_seen_subcommand_from ${wsToks}' -a '(${bin} completion --workspaces)'`);
|
|
96
164
|
}
|
|
165
|
+
// Second-level subcommands.
|
|
166
|
+
for (const c of withSubs) {
|
|
167
|
+
lines.push(`complete -c ${bin} -n '__fish_seen_subcommand_from ${cmdTokens(c).join(' ')}' -a '${c.subcommands.join(' ')}'`);
|
|
168
|
+
}
|
|
169
|
+
// Third-level value completions (e.g. config keys after get/set/unset).
|
|
170
|
+
for (const c of withArgVals) {
|
|
171
|
+
lines.push(`complete -c ${bin} -n '__fish_seen_subcommand_from ${cmdTokens(c).join(' ')}; and __fish_seen_subcommand_from ${c.argValues.after.join(' ')}' -a '${c.argValues.values.join(' ')}'`);
|
|
172
|
+
}
|
|
97
173
|
}
|
|
98
174
|
return lines.join('\n') + '\n';
|
|
99
175
|
}
|
|
@@ -103,11 +179,19 @@ function specsFromProgram(program) {
|
|
|
103
179
|
.map((c) => {
|
|
104
180
|
const args = c.registeredArguments ?? [];
|
|
105
181
|
const firstArg = args[0]?.name?.();
|
|
182
|
+
const name = c.name();
|
|
183
|
+
// Nested commander subcommands (config/suite/branch/cache/mcp), else a
|
|
184
|
+
// manual override for commands whose subcommands are positional (reflect).
|
|
185
|
+
const nested = c.commands.flatMap((s) => [s.name(), ...s.aliases()]).filter(Boolean);
|
|
186
|
+
const subcommands = nested.length ? nested : POSITIONAL_SUBCOMMANDS[name];
|
|
187
|
+
const argValues = name === 'config' ? { after: ['get', 'set', 'unset'], values: [...config_schema_1.CONFIG_KEYS] } : undefined;
|
|
106
188
|
return {
|
|
107
|
-
name
|
|
189
|
+
name,
|
|
108
190
|
aliases: c.aliases(),
|
|
109
191
|
takesWorkspace: typeof firstArg === 'string' && firstArg.toLowerCase().includes('workspace'),
|
|
110
192
|
description: c.description() ?? '',
|
|
193
|
+
...(subcommands ? { subcommands } : {}),
|
|
194
|
+
...(argValues ? { argValues } : {}),
|
|
111
195
|
};
|
|
112
196
|
})
|
|
113
197
|
// The completion command itself and any hidden helper needn't clutter, but
|
|
@@ -117,7 +201,7 @@ function specsFromProgram(program) {
|
|
|
117
201
|
function registerCompletionCommand(program) {
|
|
118
202
|
program
|
|
119
203
|
.command('completion [shell]')
|
|
120
|
-
.description('Output a shell completion script (bash|zsh|fish)')
|
|
204
|
+
.description('Output a shell completion script (bash|zsh|fish; inferred from $SHELL if omitted)')
|
|
121
205
|
.option('--workspaces', 'Print workspace names (used internally by completion scripts)')
|
|
122
206
|
.action(async (shell, opts) => {
|
|
123
207
|
// Data helper the generated scripts call back into.
|
|
@@ -132,12 +216,22 @@ function registerCompletionCommand(program) {
|
|
|
132
216
|
}
|
|
133
217
|
return;
|
|
134
218
|
}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
219
|
+
// An explicit argument wins; only fall back to $SHELL when none is given
|
|
220
|
+
// (an invalid explicit arg is an error, not a reason to guess).
|
|
221
|
+
let target = null;
|
|
222
|
+
if (shell) {
|
|
223
|
+
target = exports.SHELLS.includes(shell) ? shell : null;
|
|
224
|
+
}
|
|
225
|
+
else {
|
|
226
|
+
target = detectShell(process.env.SHELL);
|
|
227
|
+
if (target)
|
|
228
|
+
(0, logger_1.logInfo)(`completion: no shell given — using ${target} (from $SHELL)`);
|
|
229
|
+
}
|
|
230
|
+
if (!target) {
|
|
231
|
+
(0, logger_1.logError)(`completion: specify a shell — one of ${exports.SHELLS.join(', ')}`);
|
|
138
232
|
(0, logger_1.logError)('e.g. nemus completion bash');
|
|
139
233
|
process.exit(1);
|
|
140
234
|
}
|
|
141
|
-
process.stdout.write(generateCompletion(
|
|
235
|
+
process.stdout.write(generateCompletion(target, specsFromProgram(program)));
|
|
142
236
|
});
|
|
143
237
|
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.buildVersionInfo = buildVersionInfo;
|
|
4
|
+
exports.registerVersionCommand = registerVersionCommand;
|
|
5
|
+
const output_1 = require("../utils/output");
|
|
6
|
+
/**
|
|
7
|
+
* Build the version payload. Pure and process-injectable so the JSON shape is
|
|
8
|
+
* unit-testable without reading the real runtime.
|
|
9
|
+
*/
|
|
10
|
+
function buildVersionInfo(version, proc = process) {
|
|
11
|
+
return {
|
|
12
|
+
version,
|
|
13
|
+
node: proc.versions.node,
|
|
14
|
+
platform: proc.platform,
|
|
15
|
+
arch: proc.arch,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* `nemus version` — a subcommand companion to the `-V/--version` flag, for
|
|
20
|
+
* people who type `nemus version`. `--json` also reports the Node/OS runtime
|
|
21
|
+
* (handy for bug reports), emitting a single JSON document to stdout.
|
|
22
|
+
*/
|
|
23
|
+
function registerVersionCommand(program, version) {
|
|
24
|
+
program
|
|
25
|
+
.command('version')
|
|
26
|
+
.description('Print the Nemus version (with --json for version + runtime info)')
|
|
27
|
+
.option('--json', 'Output version + runtime info as JSON')
|
|
28
|
+
.action((opts) => {
|
|
29
|
+
const info = buildVersionInfo(version);
|
|
30
|
+
if (opts.json) {
|
|
31
|
+
(0, output_1.outputJson)(info);
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
process.stdout.write(`nemus ${info.version}\n`);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
}
|
package/dist/program.js
CHANGED
|
@@ -40,6 +40,7 @@ const fs = __importStar(require("fs"));
|
|
|
40
40
|
const colors_1 = require("./utils/colors");
|
|
41
41
|
const banner_1 = require("./utils/banner");
|
|
42
42
|
const global_flags_1 = require("./utils/global-flags");
|
|
43
|
+
const version_1 = require("./commands/version");
|
|
43
44
|
// Read version from package.json
|
|
44
45
|
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf-8'));
|
|
45
46
|
// --no-color must be applied BEFORE commander parses so it reaches the help
|
|
@@ -66,6 +67,7 @@ exports.program
|
|
|
66
67
|
// also pre-scanned above.
|
|
67
68
|
exports.program.hook('preAction', () => (0, global_flags_1.applyGlobalFlags)(exports.program.opts()));
|
|
68
69
|
// Register top-level commands
|
|
70
|
+
(0, version_1.registerVersionCommand)(exports.program, pkg.version);
|
|
69
71
|
const create_1 = require("./commands/create");
|
|
70
72
|
const list_1 = require("./commands/list");
|
|
71
73
|
const update_1 = require("./commands/update");
|
|
@@ -34,6 +34,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.checkForUpdate = checkForUpdate;
|
|
37
|
+
exports.updateCheckDisabled = updateCheckDisabled;
|
|
37
38
|
const fs = __importStar(require("fs/promises"));
|
|
38
39
|
const path = __importStar(require("path"));
|
|
39
40
|
const child_process_1 = require("child_process");
|
|
@@ -89,6 +90,11 @@ async function fetchLatestVersion() {
|
|
|
89
90
|
* This is designed to be non-blocking and best-effort — failures are silent.
|
|
90
91
|
*/
|
|
91
92
|
async function checkForUpdate() {
|
|
93
|
+
// Opt-out: skip the check entirely (no cache read, no network) when the user
|
|
94
|
+
// asks for it. NEMUS_NO_UPDATE_CHECK is ours; NO_UPDATE_NOTIFIER is the
|
|
95
|
+
// de-facto convention several Node CLIs honor.
|
|
96
|
+
if (updateCheckDisabled(process.env))
|
|
97
|
+
return null;
|
|
92
98
|
try {
|
|
93
99
|
const currentVersion = (0, config_1.getPackageVersion)();
|
|
94
100
|
const cache = await loadCache();
|
|
@@ -116,6 +122,15 @@ async function checkForUpdate() {
|
|
|
116
122
|
return null;
|
|
117
123
|
}
|
|
118
124
|
}
|
|
125
|
+
/**
|
|
126
|
+
* Whether the update check is opted out via env. A value is "set" unless it is
|
|
127
|
+
* empty or an explicit falsey token (`0`/`false`), so `NEMUS_NO_UPDATE_CHECK=0`
|
|
128
|
+
* does NOT disable the check. Pure + exported for testing.
|
|
129
|
+
*/
|
|
130
|
+
function updateCheckDisabled(env) {
|
|
131
|
+
const isSet = (v) => v !== undefined && v !== '' && v !== '0' && v.toLowerCase() !== 'false';
|
|
132
|
+
return isSet(env.NEMUS_NO_UPDATE_CHECK) || isSet(env.NO_UPDATE_NOTIFIER);
|
|
133
|
+
}
|
|
119
134
|
function formatUpdateMessage(current, latest) {
|
|
120
135
|
return `\x1b[33m[nemus] Update available: ${current} -> ${latest}. Run: npm install -g @nemus-cli/nemus@latest\x1b[0m`;
|
|
121
136
|
}
|
package/package.json
CHANGED
|
@@ -18,5 +18,10 @@ nemus completion zsh > "${fpath[1]}/_nemus"
|
|
|
18
18
|
nemus completion fish > ~/.config/fish/completions/nemus.fish
|
|
19
19
|
```
|
|
20
20
|
|
|
21
|
-
Then restart the shell (or `source` the file).
|
|
22
|
-
|
|
21
|
+
Then restart the shell (or `source` the file). The shell argument is inferred
|
|
22
|
+
from `$SHELL` when omitted (`nemus completion`), so you usually don't need to
|
|
23
|
+
pass it; an explicit `bash|zsh|fish` always wins.
|
|
24
|
+
|
|
25
|
+
Completions cover second-level subcommands too — e.g. `nemus config <TAB>`
|
|
26
|
+
(get/set/…), `nemus config set <TAB>` (config keys), and `nemus reflect <TAB>`
|
|
27
|
+
(history/show).
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
2
|
import { Command } from 'commander';
|
|
3
|
-
import { generateCompletion, specsFromProgram, CommandSpec } from './completion';
|
|
3
|
+
import { generateCompletion, specsFromProgram, detectShell, registerCompletionCommand, CommandSpec } from './completion';
|
|
4
4
|
|
|
5
5
|
const specs: CommandSpec[] = [
|
|
6
6
|
{ name: 'list', aliases: ['l'], takesWorkspace: false, description: 'List workspaces' },
|
|
@@ -8,6 +8,49 @@ const specs: CommandSpec[] = [
|
|
|
8
8
|
{ name: 'doctor', aliases: ['doc'], takesWorkspace: true, description: 'Health checks' },
|
|
9
9
|
];
|
|
10
10
|
|
|
11
|
+
// A spec set exercising second-level subcommands and third-level value completion.
|
|
12
|
+
const subSpecs: CommandSpec[] = [
|
|
13
|
+
{
|
|
14
|
+
name: 'config', aliases: [], takesWorkspace: false, description: 'Configure',
|
|
15
|
+
subcommands: ['get', 'set', 'list', 'ls'],
|
|
16
|
+
argValues: { after: ['get', 'set'], values: ['githubOrg', 'cloneProtocol'] },
|
|
17
|
+
},
|
|
18
|
+
{ name: 'reflect', aliases: ['retro'], takesWorkspace: false, description: 'Retrospective', subcommands: ['history', 'show'] },
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
describe('detectShell', () => {
|
|
22
|
+
it('infers the shell from a $SHELL-style path', () => {
|
|
23
|
+
expect(detectShell('/bin/zsh')).toBe('zsh');
|
|
24
|
+
expect(detectShell('/usr/bin/fish')).toBe('fish');
|
|
25
|
+
expect(detectShell('/bin/bash')).toBe('bash');
|
|
26
|
+
});
|
|
27
|
+
it('returns null for unknown/empty values', () => {
|
|
28
|
+
expect(detectShell('/bin/tcsh')).toBeNull();
|
|
29
|
+
expect(detectShell('')).toBeNull();
|
|
30
|
+
expect(detectShell(undefined)).toBeNull();
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe('generateCompletion — second/third level', () => {
|
|
35
|
+
it('bash completes subcommands and config-key values, and stays valid', () => {
|
|
36
|
+
const s = generateCompletion('bash', subSpecs);
|
|
37
|
+
expect(s).toContain('config) echo "get set list ls" ;;');
|
|
38
|
+
expect(s).toContain('reflect|retro) echo "history show" ;;');
|
|
39
|
+
expect(s).toContain('case "$2" in get|set) echo "githubOrg cloneProtocol"');
|
|
40
|
+
});
|
|
41
|
+
it('zsh completes subcommands and values at CURRENT 3/4', () => {
|
|
42
|
+
const s = generateCompletion('zsh', subSpecs);
|
|
43
|
+
expect(s).toContain('config) compadd -- get set list ls ;;');
|
|
44
|
+
expect(s).toContain('reflect|retro) compadd -- history show ;;');
|
|
45
|
+
expect(s).toContain('case ${words[3]} in get|set) compadd -- githubOrg cloneProtocol');
|
|
46
|
+
});
|
|
47
|
+
it('fish emits seen-subcommand conditions for subcommands and values', () => {
|
|
48
|
+
const s = generateCompletion('fish', subSpecs);
|
|
49
|
+
expect(s).toContain("-n '__fish_seen_subcommand_from config' -a 'get set list ls'");
|
|
50
|
+
expect(s).toContain("__fish_seen_subcommand_from config; and __fish_seen_subcommand_from get set");
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
11
54
|
describe('generateCompletion — bash', () => {
|
|
12
55
|
const s = generateCompletion('bash', specs);
|
|
13
56
|
it('resets COMPREPLY, lists tokens, and registers both bins', () => {
|
|
@@ -43,6 +86,42 @@ describe('generateCompletion — fish', () => {
|
|
|
43
86
|
});
|
|
44
87
|
});
|
|
45
88
|
|
|
89
|
+
describe('registerCompletionCommand — stdout hygiene on inferred shell', () => {
|
|
90
|
+
it('writes ONLY the script to stdout; the $SHELL inference note goes to stderr', async () => {
|
|
91
|
+
// The documented install path is `nemus completion zsh > _nemus`, so if the
|
|
92
|
+
// inference note leaked to stdout it would corrupt the generated script.
|
|
93
|
+
const program = new Command();
|
|
94
|
+
program.exitOverride();
|
|
95
|
+
program.command('list').description('list');
|
|
96
|
+
registerCompletionCommand(program);
|
|
97
|
+
|
|
98
|
+
const stdoutChunks: string[] = [];
|
|
99
|
+
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(((c: unknown) => {
|
|
100
|
+
stdoutChunks.push(String(c));
|
|
101
|
+
return true;
|
|
102
|
+
}) as typeof process.stdout.write);
|
|
103
|
+
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
104
|
+
const prevShell = process.env.SHELL;
|
|
105
|
+
process.env.SHELL = '/bin/zsh';
|
|
106
|
+
|
|
107
|
+
let errCalls: string[] = [];
|
|
108
|
+
try {
|
|
109
|
+
await program.parseAsync(['node', 'nemus', 'completion']);
|
|
110
|
+
errCalls = errSpy.mock.calls.map((c) => c.map(String).join(' '));
|
|
111
|
+
} finally {
|
|
112
|
+
if (prevShell === undefined) delete process.env.SHELL;
|
|
113
|
+
else process.env.SHELL = prevShell;
|
|
114
|
+
stdoutSpy.mockRestore();
|
|
115
|
+
errSpy.mockRestore();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const stdout = stdoutChunks.join('');
|
|
119
|
+
expect(stdout.startsWith('#compdef')).toBe(true); // the zsh script, nothing prepended
|
|
120
|
+
expect(stdout).not.toContain('no shell given'); // the note never reached stdout
|
|
121
|
+
expect(errCalls.some((l) => l.includes('no shell given'))).toBe(true); // it went to stderr
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
46
125
|
describe('specsFromProgram', () => {
|
|
47
126
|
it('detects workspace args + aliases from a commander program', () => {
|
|
48
127
|
const program = new Command();
|
|
@@ -57,4 +136,26 @@ describe('specsFromProgram', () => {
|
|
|
57
136
|
expect(byName.list.takesWorkspace).toBe(false);
|
|
58
137
|
expect(byName.create.takesWorkspace).toBe(false);
|
|
59
138
|
});
|
|
139
|
+
|
|
140
|
+
it('derives nested subcommands, the reflect override, and config keys', () => {
|
|
141
|
+
const program = new Command();
|
|
142
|
+
const cfg = program.command('config').description('config');
|
|
143
|
+
cfg.command('get').description('get');
|
|
144
|
+
cfg.command('set').description('set');
|
|
145
|
+
program.command('reflect').description('reflect'); // positional subcommands
|
|
146
|
+
program.command('list').description('list');
|
|
147
|
+
|
|
148
|
+
const byName = Object.fromEntries(specsFromProgram(program).map((s) => [s.name, s]));
|
|
149
|
+
// nested commander subcommands are auto-derived
|
|
150
|
+
expect(byName.config.subcommands).toContain('get');
|
|
151
|
+
expect(byName.config.subcommands).toContain('set');
|
|
152
|
+
// reflect's positional subcommands come from the override
|
|
153
|
+
expect(byName.reflect.subcommands).toEqual(['history', 'show']);
|
|
154
|
+
// config gets key-value completion from the authoritative CONFIG_KEYS
|
|
155
|
+
expect(byName.config.argValues?.after).toEqual(['get', 'set', 'unset']);
|
|
156
|
+
expect(byName.config.argValues?.values).toContain('githubOrg');
|
|
157
|
+
// a plain command has neither
|
|
158
|
+
expect(byName.list.subcommands).toBeUndefined();
|
|
159
|
+
expect(byName.list.argValues).toBeUndefined();
|
|
160
|
+
});
|
|
60
161
|
});
|
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
2
|
import { listWorkspaces } from '../utils/workspace-meta';
|
|
3
|
-
import { logError } from '../utils/logger';
|
|
3
|
+
import { logError, logInfo } from '../utils/logger';
|
|
4
|
+
import { CONFIG_KEYS } from '../utils/config-schema';
|
|
4
5
|
|
|
5
6
|
/** Binaries that get completion registered (the CLI's bins). */
|
|
6
7
|
export const COMPLETION_BINS = ['nemus', 'nem'];
|
|
7
8
|
|
|
8
9
|
export type Shell = 'bash' | 'zsh' | 'fish';
|
|
10
|
+
export const SHELLS: Shell[] = ['bash', 'zsh', 'fish'];
|
|
11
|
+
|
|
12
|
+
/** Commands whose subcommands are positional args (not nested commander commands). */
|
|
13
|
+
const POSITIONAL_SUBCOMMANDS: Record<string, string[]> = { reflect: ['history', 'show'] };
|
|
9
14
|
|
|
10
15
|
/** One top-level command, distilled to what a completion script needs. */
|
|
11
16
|
export interface CommandSpec {
|
|
@@ -14,6 +19,20 @@ export interface CommandSpec {
|
|
|
14
19
|
/** True if its first positional argument is a workspace name. */
|
|
15
20
|
takesWorkspace: boolean;
|
|
16
21
|
description: string;
|
|
22
|
+
/** Second-level tokens (e.g. `config get`, `reflect history`), if any. */
|
|
23
|
+
subcommands?: string[];
|
|
24
|
+
/** Third-level value completion: for these subcommands, complete `values`. */
|
|
25
|
+
argValues?: { after: string[]; values: string[] };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Infer a shell from a `$SHELL`-style path (e.g. `/bin/zsh` -> `zsh`). Returns
|
|
30
|
+
* null for an unknown/empty value. Pure + exported for testing.
|
|
31
|
+
*/
|
|
32
|
+
export function detectShell(shellPath: string | undefined): Shell | null {
|
|
33
|
+
if (!shellPath) return null;
|
|
34
|
+
const base = shellPath.trim().split('/').pop()?.toLowerCase() ?? '';
|
|
35
|
+
return SHELLS.find((s) => s === base) ?? null;
|
|
17
36
|
}
|
|
18
37
|
|
|
19
38
|
/** Every token (name + aliases) that should complete as a subcommand. */
|
|
@@ -26,6 +45,11 @@ function workspaceTokens(cmds: CommandSpec[]): string[] {
|
|
|
26
45
|
return cmds.filter((c) => c.takesWorkspace).flatMap((c) => [c.name, ...c.aliases]);
|
|
27
46
|
}
|
|
28
47
|
|
|
48
|
+
/** All tokens (name + aliases) that route to a given command spec. */
|
|
49
|
+
function cmdTokens(c: CommandSpec): string[] {
|
|
50
|
+
return [c.name, ...c.aliases];
|
|
51
|
+
}
|
|
52
|
+
|
|
29
53
|
/** Escape a description for a fish single-quoted string. */
|
|
30
54
|
function fishDesc(s: string): string {
|
|
31
55
|
return s.replace(/\n/g, ' ').replace(/'/g, "'\\''");
|
|
@@ -33,18 +57,40 @@ function fishDesc(s: string): string {
|
|
|
33
57
|
|
|
34
58
|
/**
|
|
35
59
|
* Generate a shell completion script. Pure (no I/O) so it's unit-tested. The
|
|
36
|
-
* generated script completes
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
60
|
+
* generated script completes, in order: top-level commands (position 1); then
|
|
61
|
+
* either workspace names (for workspace-scoped commands) or a command's own
|
|
62
|
+
* subcommands (position 2); then value completions like `config` keys
|
|
63
|
+
* (position 3). Workspace names stay dynamic via a callback into
|
|
64
|
+
* `<bin> completion --workspaces`.
|
|
40
65
|
*/
|
|
41
66
|
export function generateCompletion(shell: Shell, cmds: CommandSpec[], bins: string[] = COMPLETION_BINS): string {
|
|
42
67
|
const commands = allTokens(cmds).join(' ');
|
|
43
68
|
const wsCommands = workspaceTokens(cmds).join(' ');
|
|
69
|
+
const withSubs = cmds.filter((c) => c.subcommands && c.subcommands.length);
|
|
70
|
+
const withArgVals = cmds.filter((c) => c.argValues && c.argValues.values.length);
|
|
44
71
|
|
|
45
72
|
if (shell === 'bash') {
|
|
73
|
+
const subArms = withSubs
|
|
74
|
+
.map((c) => ` ${cmdTokens(c).join('|')}) echo "${c.subcommands!.join(' ')}" ;;`)
|
|
75
|
+
.join('\n');
|
|
76
|
+
const argArms = withArgVals
|
|
77
|
+
.map(
|
|
78
|
+
(c) =>
|
|
79
|
+
` ${cmdTokens(c).join('|')}) case "$2" in ${c.argValues!.after.join('|')}) echo "${c.argValues!.values.join(' ')}" ;; esac ;;`,
|
|
80
|
+
)
|
|
81
|
+
.join('\n');
|
|
46
82
|
return `# nemus bash completion. Install: nemus completion bash > /etc/bash_completion.d/nemus
|
|
47
83
|
# (or: nemus completion bash >> ~/.bashrc)
|
|
84
|
+
_nemus_subcmds() {
|
|
85
|
+
case "$1" in
|
|
86
|
+
${subArms}
|
|
87
|
+
esac
|
|
88
|
+
}
|
|
89
|
+
_nemus_argvals() {
|
|
90
|
+
case "$1" in
|
|
91
|
+
${argArms}
|
|
92
|
+
esac
|
|
93
|
+
}
|
|
48
94
|
_nemus_complete() {
|
|
49
95
|
local cur bin sub
|
|
50
96
|
# bash does not clear COMPREPLY between completions; reset so a stale result
|
|
@@ -66,6 +112,20 @@ _nemus_complete() {
|
|
|
66
112
|
COMPREPLY=( \$(compgen -W "\$names" -- "\$cur") )
|
|
67
113
|
return 0
|
|
68
114
|
fi
|
|
115
|
+
local subs
|
|
116
|
+
subs="\$(_nemus_subcmds "\$sub")"
|
|
117
|
+
if [ -n "\$subs" ]; then
|
|
118
|
+
COMPREPLY=( \$(compgen -W "\$subs" -- "\$cur") )
|
|
119
|
+
fi
|
|
120
|
+
return 0
|
|
121
|
+
fi
|
|
122
|
+
if [ "\$COMP_CWORD" -eq 3 ]; then
|
|
123
|
+
local vals
|
|
124
|
+
vals="\$(_nemus_argvals "\${COMP_WORDS[1]}" "\${COMP_WORDS[2]}")"
|
|
125
|
+
if [ -n "\$vals" ]; then
|
|
126
|
+
COMPREPLY=( \$(compgen -W "\$vals" -- "\$cur") )
|
|
127
|
+
fi
|
|
128
|
+
return 0
|
|
69
129
|
fi
|
|
70
130
|
return 0
|
|
71
131
|
}
|
|
@@ -74,6 +134,15 @@ ${bins.map((b) => `complete -F _nemus_complete ${b}`).join('\n')}
|
|
|
74
134
|
}
|
|
75
135
|
|
|
76
136
|
if (shell === 'zsh') {
|
|
137
|
+
const subArms = withSubs
|
|
138
|
+
.map((c) => ` ${cmdTokens(c).join('|')}) compadd -- ${c.subcommands!.join(' ')} ;;`)
|
|
139
|
+
.join('\n');
|
|
140
|
+
const argArms = withArgVals
|
|
141
|
+
.map(
|
|
142
|
+
(c) =>
|
|
143
|
+
` ${cmdTokens(c).join('|')}) case \${words[3]} in ${c.argValues!.after.join('|')}) compadd -- ${c.argValues!.values.join(' ')} ;; esac ;;`,
|
|
144
|
+
)
|
|
145
|
+
.join('\n');
|
|
77
146
|
// Autoloaded form: save as a file named `_nemus` on your $fpath.
|
|
78
147
|
return `#compdef ${bins.join(' ')}
|
|
79
148
|
# nemus zsh completion. Install: nemus completion zsh > "\${fpath[1]}/_nemus"
|
|
@@ -90,7 +159,17 @@ if (( CURRENT == 3 )); then
|
|
|
90
159
|
local -a _nemus_names
|
|
91
160
|
_nemus_names=(\${(f)"$(\${words[1]} completion --workspaces 2>/dev/null)"})
|
|
92
161
|
compadd -- $_nemus_names
|
|
162
|
+
return
|
|
93
163
|
fi
|
|
164
|
+
case $sub in
|
|
165
|
+
${subArms}
|
|
166
|
+
esac
|
|
167
|
+
return
|
|
168
|
+
fi
|
|
169
|
+
if (( CURRENT == 4 )); then
|
|
170
|
+
case \${words[2]} in
|
|
171
|
+
${argArms}
|
|
172
|
+
esac
|
|
94
173
|
fi
|
|
95
174
|
`;
|
|
96
175
|
}
|
|
@@ -100,7 +179,7 @@ fi
|
|
|
100
179
|
for (const bin of bins) {
|
|
101
180
|
lines.push(`complete -c ${bin} -f`);
|
|
102
181
|
for (const c of cmds) {
|
|
103
|
-
for (const tok of
|
|
182
|
+
for (const tok of cmdTokens(c)) {
|
|
104
183
|
lines.push(`complete -c ${bin} -n __fish_use_subcommand -a '${tok}' -d '${fishDesc(c.description)}'`);
|
|
105
184
|
}
|
|
106
185
|
}
|
|
@@ -110,6 +189,18 @@ fi
|
|
|
110
189
|
`complete -c ${bin} -n '__fish_seen_subcommand_from ${wsToks}' -a '(${bin} completion --workspaces)'`,
|
|
111
190
|
);
|
|
112
191
|
}
|
|
192
|
+
// Second-level subcommands.
|
|
193
|
+
for (const c of withSubs) {
|
|
194
|
+
lines.push(
|
|
195
|
+
`complete -c ${bin} -n '__fish_seen_subcommand_from ${cmdTokens(c).join(' ')}' -a '${c.subcommands!.join(' ')}'`,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
// Third-level value completions (e.g. config keys after get/set/unset).
|
|
199
|
+
for (const c of withArgVals) {
|
|
200
|
+
lines.push(
|
|
201
|
+
`complete -c ${bin} -n '__fish_seen_subcommand_from ${cmdTokens(c).join(' ')}; and __fish_seen_subcommand_from ${c.argValues!.after.join(' ')}' -a '${c.argValues!.values.join(' ')}'`,
|
|
202
|
+
);
|
|
203
|
+
}
|
|
113
204
|
}
|
|
114
205
|
return lines.join('\n') + '\n';
|
|
115
206
|
}
|
|
@@ -120,11 +211,20 @@ export function specsFromProgram(program: Command): CommandSpec[] {
|
|
|
120
211
|
.map((c) => {
|
|
121
212
|
const args = (c as any).registeredArguments ?? [];
|
|
122
213
|
const firstArg: string | undefined = args[0]?.name?.();
|
|
214
|
+
const name = c.name();
|
|
215
|
+
// Nested commander subcommands (config/suite/branch/cache/mcp), else a
|
|
216
|
+
// manual override for commands whose subcommands are positional (reflect).
|
|
217
|
+
const nested = c.commands.flatMap((s) => [s.name(), ...s.aliases()]).filter(Boolean);
|
|
218
|
+
const subcommands = nested.length ? nested : POSITIONAL_SUBCOMMANDS[name];
|
|
219
|
+
const argValues =
|
|
220
|
+
name === 'config' ? { after: ['get', 'set', 'unset'], values: [...CONFIG_KEYS] } : undefined;
|
|
123
221
|
return {
|
|
124
|
-
name
|
|
222
|
+
name,
|
|
125
223
|
aliases: c.aliases(),
|
|
126
224
|
takesWorkspace: typeof firstArg === 'string' && firstArg.toLowerCase().includes('workspace'),
|
|
127
225
|
description: c.description() ?? '',
|
|
226
|
+
...(subcommands ? { subcommands } : {}),
|
|
227
|
+
...(argValues ? { argValues } : {}),
|
|
128
228
|
};
|
|
129
229
|
})
|
|
130
230
|
// The completion command itself and any hidden helper needn't clutter, but
|
|
@@ -135,7 +235,7 @@ export function specsFromProgram(program: Command): CommandSpec[] {
|
|
|
135
235
|
export function registerCompletionCommand(program: Command) {
|
|
136
236
|
program
|
|
137
237
|
.command('completion [shell]')
|
|
138
|
-
.description('Output a shell completion script (bash|zsh|fish)')
|
|
238
|
+
.description('Output a shell completion script (bash|zsh|fish; inferred from $SHELL if omitted)')
|
|
139
239
|
.option('--workspaces', 'Print workspace names (used internally by completion scripts)')
|
|
140
240
|
.action(async (shell: string | undefined, opts: { workspaces?: boolean }) => {
|
|
141
241
|
// Data helper the generated scripts call back into.
|
|
@@ -149,12 +249,21 @@ export function registerCompletionCommand(program: Command) {
|
|
|
149
249
|
return;
|
|
150
250
|
}
|
|
151
251
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
252
|
+
// An explicit argument wins; only fall back to $SHELL when none is given
|
|
253
|
+
// (an invalid explicit arg is an error, not a reason to guess).
|
|
254
|
+
let target: Shell | null = null;
|
|
255
|
+
if (shell) {
|
|
256
|
+
target = SHELLS.includes(shell as Shell) ? (shell as Shell) : null;
|
|
257
|
+
} else {
|
|
258
|
+
target = detectShell(process.env.SHELL);
|
|
259
|
+
if (target) logInfo(`completion: no shell given — using ${target} (from $SHELL)`);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (!target) {
|
|
263
|
+
logError(`completion: specify a shell — one of ${SHELLS.join(', ')}`);
|
|
155
264
|
logError('e.g. nemus completion bash');
|
|
156
265
|
process.exit(1);
|
|
157
266
|
}
|
|
158
|
-
process.stdout.write(generateCompletion(
|
|
267
|
+
process.stdout.write(generateCompletion(target, specsFromProgram(program)));
|
|
159
268
|
});
|
|
160
269
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { buildVersionInfo } from './version';
|
|
3
|
+
|
|
4
|
+
describe('buildVersionInfo', () => {
|
|
5
|
+
it('combines the given version with the injected runtime fields', () => {
|
|
6
|
+
const info = buildVersionInfo('1.2.3', {
|
|
7
|
+
versions: { node: '22.13.0' } as NodeJS.ProcessVersions,
|
|
8
|
+
platform: 'linux',
|
|
9
|
+
arch: 'x64',
|
|
10
|
+
});
|
|
11
|
+
expect(info).toEqual({ version: '1.2.3', node: '22.13.0', platform: 'linux', arch: 'x64' });
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('defaults to the real process runtime', () => {
|
|
15
|
+
const info = buildVersionInfo('9.9.9');
|
|
16
|
+
expect(info.version).toBe('9.9.9');
|
|
17
|
+
expect(info.node).toBe(process.versions.node);
|
|
18
|
+
expect(info.platform).toBe(process.platform);
|
|
19
|
+
expect(info.arch).toBe(process.arch);
|
|
20
|
+
});
|
|
21
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { outputJson } from '../utils/output';
|
|
3
|
+
|
|
4
|
+
export interface VersionInfo {
|
|
5
|
+
version: string;
|
|
6
|
+
node: string;
|
|
7
|
+
platform: string;
|
|
8
|
+
arch: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Build the version payload. Pure and process-injectable so the JSON shape is
|
|
13
|
+
* unit-testable without reading the real runtime.
|
|
14
|
+
*/
|
|
15
|
+
export function buildVersionInfo(
|
|
16
|
+
version: string,
|
|
17
|
+
proc: Pick<NodeJS.Process, 'versions' | 'platform' | 'arch'> = process,
|
|
18
|
+
): VersionInfo {
|
|
19
|
+
return {
|
|
20
|
+
version,
|
|
21
|
+
node: proc.versions.node,
|
|
22
|
+
platform: proc.platform,
|
|
23
|
+
arch: proc.arch,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* `nemus version` — a subcommand companion to the `-V/--version` flag, for
|
|
29
|
+
* people who type `nemus version`. `--json` also reports the Node/OS runtime
|
|
30
|
+
* (handy for bug reports), emitting a single JSON document to stdout.
|
|
31
|
+
*/
|
|
32
|
+
export function registerVersionCommand(program: Command, version: string) {
|
|
33
|
+
program
|
|
34
|
+
.command('version')
|
|
35
|
+
.description('Print the Nemus version (with --json for version + runtime info)')
|
|
36
|
+
.option('--json', 'Output version + runtime info as JSON')
|
|
37
|
+
.action((opts: { json?: boolean }) => {
|
|
38
|
+
const info = buildVersionInfo(version);
|
|
39
|
+
if (opts.json) {
|
|
40
|
+
outputJson(info);
|
|
41
|
+
} else {
|
|
42
|
+
process.stdout.write(`nemus ${info.version}\n`);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
}
|
package/src/program.ts
CHANGED
|
@@ -4,6 +4,7 @@ import * as fs from 'fs';
|
|
|
4
4
|
import { setColorEnabled } from './utils/colors';
|
|
5
5
|
import { renderHelpBanner } from './utils/banner';
|
|
6
6
|
import { applyGlobalFlags } from './utils/global-flags';
|
|
7
|
+
import { registerVersionCommand } from './commands/version';
|
|
7
8
|
|
|
8
9
|
// Read version from package.json
|
|
9
10
|
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf-8'));
|
|
@@ -35,6 +36,7 @@ program
|
|
|
35
36
|
program.hook('preAction', () => applyGlobalFlags(program.opts()));
|
|
36
37
|
|
|
37
38
|
// Register top-level commands
|
|
39
|
+
registerVersionCommand(program, pkg.version);
|
|
38
40
|
import { registerCreateCommand } from './commands/create';
|
|
39
41
|
import { registerListCommand } from './commands/list';
|
|
40
42
|
import { registerUpdateCommand } from './commands/update';
|
|
@@ -24,14 +24,41 @@ vi.mock('./config', () => ({
|
|
|
24
24
|
getPackageVersion: () => '2.20.0',
|
|
25
25
|
}));
|
|
26
26
|
|
|
27
|
-
import { checkForUpdate } from './version-check';
|
|
27
|
+
import { checkForUpdate, updateCheckDisabled } from './version-check';
|
|
28
28
|
import * as fs from 'fs/promises';
|
|
29
29
|
|
|
30
|
+
describe('updateCheckDisabled', () => {
|
|
31
|
+
it('is true when NEMUS_NO_UPDATE_CHECK is set to a truthy value', () => {
|
|
32
|
+
expect(updateCheckDisabled({ NEMUS_NO_UPDATE_CHECK: '1' })).toBe(true);
|
|
33
|
+
expect(updateCheckDisabled({ NEMUS_NO_UPDATE_CHECK: 'yes' })).toBe(true);
|
|
34
|
+
});
|
|
35
|
+
it('honors the de-facto NO_UPDATE_NOTIFIER', () => {
|
|
36
|
+
expect(updateCheckDisabled({ NO_UPDATE_NOTIFIER: 'true' })).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
it('is false when unset, empty, or an explicit falsey token', () => {
|
|
39
|
+
expect(updateCheckDisabled({})).toBe(false);
|
|
40
|
+
expect(updateCheckDisabled({ NEMUS_NO_UPDATE_CHECK: '' })).toBe(false);
|
|
41
|
+
expect(updateCheckDisabled({ NEMUS_NO_UPDATE_CHECK: '0' })).toBe(false);
|
|
42
|
+
expect(updateCheckDisabled({ NEMUS_NO_UPDATE_CHECK: 'false' })).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
30
46
|
describe('checkForUpdate', () => {
|
|
31
47
|
beforeEach(() => {
|
|
32
48
|
vi.clearAllMocks();
|
|
33
49
|
});
|
|
34
50
|
|
|
51
|
+
it('returns null immediately when opted out via env (no fetch)', async () => {
|
|
52
|
+
process.env.NEMUS_NO_UPDATE_CHECK = '1';
|
|
53
|
+
try {
|
|
54
|
+
const result = await checkForUpdate();
|
|
55
|
+
expect(result).toBeNull();
|
|
56
|
+
expect(mockExecFile).not.toHaveBeenCalled();
|
|
57
|
+
} finally {
|
|
58
|
+
delete process.env.NEMUS_NO_UPDATE_CHECK;
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
35
62
|
it('returns null when current version matches latest', async () => {
|
|
36
63
|
// No cached check
|
|
37
64
|
vi.mocked(fs.readFile).mockRejectedValueOnce(new Error('ENOENT'));
|
|
@@ -60,6 +60,10 @@ async function fetchLatestVersion(): Promise<string | null> {
|
|
|
60
60
|
* This is designed to be non-blocking and best-effort — failures are silent.
|
|
61
61
|
*/
|
|
62
62
|
export async function checkForUpdate(): Promise<string | null> {
|
|
63
|
+
// Opt-out: skip the check entirely (no cache read, no network) when the user
|
|
64
|
+
// asks for it. NEMUS_NO_UPDATE_CHECK is ours; NO_UPDATE_NOTIFIER is the
|
|
65
|
+
// de-facto convention several Node CLIs honor.
|
|
66
|
+
if (updateCheckDisabled(process.env)) return null;
|
|
63
67
|
try {
|
|
64
68
|
const currentVersion = getPackageVersion();
|
|
65
69
|
const cache = await loadCache();
|
|
@@ -91,6 +95,17 @@ export async function checkForUpdate(): Promise<string | null> {
|
|
|
91
95
|
}
|
|
92
96
|
}
|
|
93
97
|
|
|
98
|
+
/**
|
|
99
|
+
* Whether the update check is opted out via env. A value is "set" unless it is
|
|
100
|
+
* empty or an explicit falsey token (`0`/`false`), so `NEMUS_NO_UPDATE_CHECK=0`
|
|
101
|
+
* does NOT disable the check. Pure + exported for testing.
|
|
102
|
+
*/
|
|
103
|
+
export function updateCheckDisabled(env: NodeJS.ProcessEnv): boolean {
|
|
104
|
+
const isSet = (v: string | undefined) =>
|
|
105
|
+
v !== undefined && v !== '' && v !== '0' && v.toLowerCase() !== 'false';
|
|
106
|
+
return isSet(env.NEMUS_NO_UPDATE_CHECK) || isSet(env.NO_UPDATE_NOTIFIER);
|
|
107
|
+
}
|
|
108
|
+
|
|
94
109
|
function formatUpdateMessage(current: string, latest: string): string {
|
|
95
110
|
return `\x1b[33m[nemus] Update available: ${current} -> ${latest}. Run: npm install -g @nemus-cli/nemus@latest\x1b[0m`;
|
|
96
111
|
}
|