@nemus-cli/nemus 0.8.0 → 0.9.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 +12 -0
- package/README.md +19 -0
- package/dist/commands/config.js +82 -0
- package/dist/utils/config-schema.js +57 -0
- package/dist/utils/editor.js +35 -0
- package/package.json +1 -1
- package/src/commands/config.ts +52 -1
- package/src/utils/config-schema.test.ts +49 -0
- package/src/utils/config-schema.ts +65 -0
- package/src/utils/editor.test.ts +37 -0
- package/src/utils/editor.ts +48 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.9.0] - 2026-09-01
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **`nemus config edit`** opens the config file in `$VISUAL`/`$EDITOR` (seeding
|
|
15
|
+
it with the current resolved config on first run) and re-validates the JSON
|
|
16
|
+
afterward, warning about unrecognized keys. Refuses to run without an
|
|
17
|
+
interactive terminal.
|
|
18
|
+
- **Environment-variable reference** in the README documenting every variable
|
|
19
|
+
Nemus reads (`NEMUS_*`, `WORKSPACE_*`, `NO_COLOR`/`FORCE_COLOR`,
|
|
20
|
+
`VISUAL`/`EDITOR`).
|
|
21
|
+
|
|
10
22
|
## [0.8.0] - 2026-09-01
|
|
11
23
|
|
|
12
24
|
### Added
|
package/README.md
CHANGED
|
@@ -259,6 +259,25 @@ nemus config path # print the config file location
|
|
|
259
259
|
|
|
260
260
|
`get`/`list` also accept `--json`. An unknown key or an invalid value exits
|
|
261
261
|
non-zero with a clear message (e.g. `cloneProtocol must be one of: ssh, https`).
|
|
262
|
+
`config edit` opens the file in `$VISUAL`/`$EDITOR` (seeding it with the current
|
|
263
|
+
resolved config first) and re-validates the JSON afterward.
|
|
264
|
+
|
|
265
|
+
### Environment variables
|
|
266
|
+
|
|
267
|
+
Everything Nemus reads from the environment (all optional):
|
|
268
|
+
|
|
269
|
+
| Variable | Effect |
|
|
270
|
+
| --- | --- |
|
|
271
|
+
| `NEMUS_DIR` | Override where workspaces are created (also `WORKSPACE_MANAGER_DIR`). |
|
|
272
|
+
| `NEMUS_CACHE_DIR` | Override the cache/config/state dir, default `~/.nemus` (also `WORKSPACE_MANAGER_CACHE_DIR`). |
|
|
273
|
+
| `NEMUS_JUDGE_MODEL` | Model for the `reflect` judge (overrides `--model`'s default). |
|
|
274
|
+
| `NEMUS_JUDGE_THINKING` | Thinking level for the `reflect` judge on pi (`off`…`max`). |
|
|
275
|
+
| `NEMUS_JUDGE_TIMEOUT_MS` | Timeout for the `reflect` judge call. |
|
|
276
|
+
| `NEMUS_BUG_REPORT_REPO` | Repo that `report-bug` files issues against. |
|
|
277
|
+
| `NEMUS_SKIP_CONFIGURE` | Skip the one-time post-install `configure` prompt. |
|
|
278
|
+
| `WORKSPACE_CLONE_TIMEOUT_MS` | Git clone timeout (default 15 min). |
|
|
279
|
+
| `NO_COLOR` / `FORCE_COLOR` | Disable / force ANSI color (see [Global flags](#global-flags)). |
|
|
280
|
+
| `VISUAL` / `EDITOR` | Editor launched by `nemus config edit`. |
|
|
262
281
|
|
|
263
282
|
### Global flags
|
|
264
283
|
|
package/dist/commands/config.js
CHANGED
|
@@ -1,7 +1,42 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
2
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
36
|
exports.registerConfigCommand = registerConfigCommand;
|
|
37
|
+
const fs = __importStar(require("fs"));
|
|
4
38
|
const config_1 = require("../utils/config");
|
|
39
|
+
const editor_1 = require("../utils/editor");
|
|
5
40
|
const config_schema_1 = require("../utils/config-schema");
|
|
6
41
|
const output_1 = require("../utils/output");
|
|
7
42
|
const logger_1 = require("../utils/logger");
|
|
@@ -94,6 +129,53 @@ function registerConfigCommand(parent) {
|
|
|
94
129
|
.action(() => {
|
|
95
130
|
process.stdout.write(config_1.CONFIG_PATH + '\n');
|
|
96
131
|
});
|
|
132
|
+
config
|
|
133
|
+
.command('edit')
|
|
134
|
+
.description('Open the config file in $EDITOR (or $VISUAL)')
|
|
135
|
+
.action(() => {
|
|
136
|
+
handleEdit();
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
function handleEdit() {
|
|
140
|
+
if (!process.stdout.isTTY) {
|
|
141
|
+
(0, logger_1.logError)('`config edit` needs an interactive terminal. Use `config set <key> <value>` in scripts.');
|
|
142
|
+
process.exitCode = 1;
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
// Seed the file with the fully-resolved config so there's something complete
|
|
146
|
+
// to edit on a first run (getUserConfig merges defaults + any overrides).
|
|
147
|
+
if (!fs.existsSync(config_1.CONFIG_PATH))
|
|
148
|
+
(0, config_1.saveUserConfig)((0, config_1.getUserConfig)());
|
|
149
|
+
const result = (0, editor_1.openInEditor)(config_1.CONFIG_PATH);
|
|
150
|
+
if (!result.ok) {
|
|
151
|
+
(0, logger_1.logError)(result.error ?? `editor exited with code ${result.code}`);
|
|
152
|
+
process.exitCode = 1;
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
// Re-validate: a hand-edit can produce invalid JSON or invalid values, which
|
|
156
|
+
// getUserConfig would silently ignore (falling back to defaults). Surface that
|
|
157
|
+
// instead, using the SAME schema `config set` uses so both write paths agree.
|
|
158
|
+
const review = (0, config_schema_1.reviewConfigFileText)(fs.readFileSync(config_1.CONFIG_PATH, 'utf-8'));
|
|
159
|
+
if (review.parseError) {
|
|
160
|
+
(0, logger_1.logError)(`${config_1.CONFIG_PATH} is not valid JSON after editing — changes are kept, but Nemus will use defaults until it parses.`);
|
|
161
|
+
process.exitCode = 1;
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
if (review.notObject) {
|
|
165
|
+
(0, logger_1.logError)(`${config_1.CONFIG_PATH} must contain a JSON object — changes are kept, but Nemus will use defaults until it does.`);
|
|
166
|
+
process.exitCode = 1;
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (review.unknownKeys.length > 0)
|
|
170
|
+
(0, logger_1.logWarning)(`Ignoring unrecognized key(s): ${review.unknownKeys.join(', ')}`);
|
|
171
|
+
if (!review.ok) {
|
|
172
|
+
for (const e of review.invalid)
|
|
173
|
+
(0, logger_1.logWarning)(e);
|
|
174
|
+
(0, logger_1.logError)('Some values are invalid and will fall back to their defaults until fixed.');
|
|
175
|
+
process.exitCode = 1;
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
(0, logger_1.logSuccess)('Config saved.');
|
|
97
179
|
}
|
|
98
180
|
function printAll(cfg, json) {
|
|
99
181
|
if (json) {
|
|
@@ -3,9 +3,11 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.CONFIG_KEYS = exports.CONFIG_SCHEMA = void 0;
|
|
4
4
|
exports.isConfigKey = isConfigKey;
|
|
5
5
|
exports.parseConfigValue = parseConfigValue;
|
|
6
|
+
exports.validateTypedValue = validateTypedValue;
|
|
6
7
|
exports.applyConfigSet = applyConfigSet;
|
|
7
8
|
exports.applyConfigUnset = applyConfigUnset;
|
|
8
9
|
exports.formatConfigValue = formatConfigValue;
|
|
10
|
+
exports.reviewConfigFileText = reviewConfigFileText;
|
|
9
11
|
const config_1 = require("./config");
|
|
10
12
|
const AGENT_VALUES = ['claude', 'pi', 'opencode', 'codex', 'gemini'];
|
|
11
13
|
exports.CONFIG_SCHEMA = {
|
|
@@ -64,6 +66,30 @@ function parseConfigValue(key, raw) {
|
|
|
64
66
|
}
|
|
65
67
|
return { ok: true, value: trimmed };
|
|
66
68
|
}
|
|
69
|
+
/**
|
|
70
|
+
* Validate an already-typed value (as it appears in the JSON config file) for
|
|
71
|
+
* `key` against its field spec. This is the parse-free sibling of
|
|
72
|
+
* parseConfigValue (which coerces a CLI string): it checks that a boolean field
|
|
73
|
+
* holds a boolean, an enum holds an allowed string, and a string field holds a
|
|
74
|
+
* (non-empty, unless allowEmpty) string. Used by `config edit` so a hand-edit
|
|
75
|
+
* is validated the same way `config set` validates. Pure + unit-tested.
|
|
76
|
+
*/
|
|
77
|
+
function validateTypedValue(key, value) {
|
|
78
|
+
const spec = exports.CONFIG_SCHEMA[key];
|
|
79
|
+
if (spec.type === 'boolean') {
|
|
80
|
+
return typeof value === 'boolean' ? { ok: true } : { ok: false, error: `${key} must be a boolean` };
|
|
81
|
+
}
|
|
82
|
+
if (spec.type === 'enum') {
|
|
83
|
+
return typeof value === 'string' && spec.values.includes(value)
|
|
84
|
+
? { ok: true }
|
|
85
|
+
: { ok: false, error: `${key} must be one of: ${spec.values.join(', ')}` };
|
|
86
|
+
}
|
|
87
|
+
if (typeof value !== 'string')
|
|
88
|
+
return { ok: false, error: `${key} must be a string` };
|
|
89
|
+
if (!spec.allowEmpty && value.trim() === '')
|
|
90
|
+
return { ok: false, error: `${key} cannot be empty` };
|
|
91
|
+
return { ok: true };
|
|
92
|
+
}
|
|
67
93
|
/** Apply a `set` to a config object, returning a NEW config or an error. Pure. */
|
|
68
94
|
function applyConfigSet(current, key, raw) {
|
|
69
95
|
if (!isConfigKey(key)) {
|
|
@@ -86,3 +112,34 @@ function applyConfigUnset(current, key) {
|
|
|
86
112
|
function formatConfigValue(value) {
|
|
87
113
|
return typeof value === 'boolean' ? String(value) : String(value ?? '');
|
|
88
114
|
}
|
|
115
|
+
/**
|
|
116
|
+
* Review the raw text of a hand-edited config file the same way `config set`
|
|
117
|
+
* validates: it must parse, be a plain object, only use known keys, and every
|
|
118
|
+
* known key present must hold a schema-valid value. Pure so `config edit` can be
|
|
119
|
+
* fully unit-tested without spawning an editor.
|
|
120
|
+
*/
|
|
121
|
+
function reviewConfigFileText(text) {
|
|
122
|
+
const base = { parseError: false, notObject: false, unknownKeys: [], invalid: [] };
|
|
123
|
+
let raw;
|
|
124
|
+
try {
|
|
125
|
+
raw = JSON.parse(text);
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
return { ...base, parseError: true, ok: false };
|
|
129
|
+
}
|
|
130
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
131
|
+
return { ...base, notObject: true, ok: false };
|
|
132
|
+
}
|
|
133
|
+
const obj = raw;
|
|
134
|
+
const known = new Set(exports.CONFIG_KEYS);
|
|
135
|
+
const unknownKeys = Object.keys(obj).filter((k) => !known.has(k));
|
|
136
|
+
const invalid = [];
|
|
137
|
+
for (const key of exports.CONFIG_KEYS) {
|
|
138
|
+
if (!(key in obj))
|
|
139
|
+
continue;
|
|
140
|
+
const res = validateTypedValue(key, obj[key]);
|
|
141
|
+
if (!res.ok)
|
|
142
|
+
invalid.push(res.error);
|
|
143
|
+
}
|
|
144
|
+
return { parseError: false, notObject: false, unknownKeys, invalid, ok: invalid.length === 0 };
|
|
145
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.resolveEditor = resolveEditor;
|
|
4
|
+
exports.openInEditor = openInEditor;
|
|
5
|
+
const child_process_1 = require("child_process");
|
|
6
|
+
/**
|
|
7
|
+
* Resolve the user's preferred editor as an argv array. Honors `$VISUAL` then
|
|
8
|
+
* `$EDITOR` (the long-standing Unix convention — `VISUAL` wins for full-screen
|
|
9
|
+
* editors), falling back to `notepad` on Windows and `vi` elsewhere. The env
|
|
10
|
+
* value may include flags (e.g. `code --wait`, `emacs -nw`), so it's split on
|
|
11
|
+
* whitespace into a command + args. Pure + unit-tested.
|
|
12
|
+
*/
|
|
13
|
+
function resolveEditor(env = process.env, platform = process.platform) {
|
|
14
|
+
const raw = (env.VISUAL || env.EDITOR || '').trim();
|
|
15
|
+
if (raw)
|
|
16
|
+
return raw.split(/\s+/);
|
|
17
|
+
return platform === 'win32' ? ['notepad'] : ['vi'];
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Open `file` in the resolved editor, inheriting the terminal so the editor is
|
|
21
|
+
* interactive. Returns a structured result rather than throwing so the caller
|
|
22
|
+
* controls messaging/exit. `spawn` is injected for tests.
|
|
23
|
+
*/
|
|
24
|
+
function openInEditor(file, deps = {}) {
|
|
25
|
+
const spawn = deps.spawn ?? child_process_1.spawnSync;
|
|
26
|
+
const [cmd, ...args] = resolveEditor(deps.env, deps.platform);
|
|
27
|
+
const res = spawn(cmd, [...args, file], { stdio: 'inherit' });
|
|
28
|
+
if (res.error) {
|
|
29
|
+
const err = res.error;
|
|
30
|
+
const reason = err.code === 'ENOENT' ? `editor "${cmd}" not found` : err.message;
|
|
31
|
+
return { ok: false, editor: cmd, error: reason };
|
|
32
|
+
}
|
|
33
|
+
const code = typeof res.status === 'number' ? res.status : 1;
|
|
34
|
+
return { ok: code === 0, editor: cmd, code };
|
|
35
|
+
}
|
package/package.json
CHANGED
package/src/commands/config.ts
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
|
+
import * as fs from 'fs';
|
|
2
3
|
import { getUserConfig, saveUserConfig, CONFIG_PATH } from '../utils/config';
|
|
4
|
+
import { openInEditor } from '../utils/editor';
|
|
3
5
|
import {
|
|
4
6
|
CONFIG_KEYS,
|
|
5
7
|
CONFIG_SCHEMA,
|
|
6
8
|
isConfigKey,
|
|
7
9
|
applyConfigSet,
|
|
8
10
|
applyConfigUnset,
|
|
11
|
+
reviewConfigFileText,
|
|
9
12
|
formatConfigValue,
|
|
10
13
|
} from '../utils/config-schema';
|
|
11
14
|
import { outputJson, outputJsonError } from '../utils/output';
|
|
12
|
-
import { logSuccess, logError } from '../utils/logger';
|
|
15
|
+
import { logSuccess, logError, logWarning } from '../utils/logger';
|
|
13
16
|
import { colorize } from '../utils/colors';
|
|
14
17
|
|
|
15
18
|
/**
|
|
@@ -93,6 +96,54 @@ export function registerConfigCommand(parent: Command): void {
|
|
|
93
96
|
.action(() => {
|
|
94
97
|
process.stdout.write(CONFIG_PATH + '\n');
|
|
95
98
|
});
|
|
99
|
+
|
|
100
|
+
config
|
|
101
|
+
.command('edit')
|
|
102
|
+
.description('Open the config file in $EDITOR (or $VISUAL)')
|
|
103
|
+
.action(() => {
|
|
104
|
+
handleEdit();
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function handleEdit(): void {
|
|
109
|
+
if (!process.stdout.isTTY) {
|
|
110
|
+
logError('`config edit` needs an interactive terminal. Use `config set <key> <value>` in scripts.');
|
|
111
|
+
process.exitCode = 1;
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
// Seed the file with the fully-resolved config so there's something complete
|
|
115
|
+
// to edit on a first run (getUserConfig merges defaults + any overrides).
|
|
116
|
+
if (!fs.existsSync(CONFIG_PATH)) saveUserConfig(getUserConfig());
|
|
117
|
+
|
|
118
|
+
const result = openInEditor(CONFIG_PATH);
|
|
119
|
+
if (!result.ok) {
|
|
120
|
+
logError(result.error ?? `editor exited with code ${result.code}`);
|
|
121
|
+
process.exitCode = 1;
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Re-validate: a hand-edit can produce invalid JSON or invalid values, which
|
|
126
|
+
// getUserConfig would silently ignore (falling back to defaults). Surface that
|
|
127
|
+
// instead, using the SAME schema `config set` uses so both write paths agree.
|
|
128
|
+
const review = reviewConfigFileText(fs.readFileSync(CONFIG_PATH, 'utf-8'));
|
|
129
|
+
if (review.parseError) {
|
|
130
|
+
logError(`${CONFIG_PATH} is not valid JSON after editing — changes are kept, but Nemus will use defaults until it parses.`);
|
|
131
|
+
process.exitCode = 1;
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (review.notObject) {
|
|
135
|
+
logError(`${CONFIG_PATH} must contain a JSON object — changes are kept, but Nemus will use defaults until it does.`);
|
|
136
|
+
process.exitCode = 1;
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (review.unknownKeys.length > 0) logWarning(`Ignoring unrecognized key(s): ${review.unknownKeys.join(', ')}`);
|
|
140
|
+
if (!review.ok) {
|
|
141
|
+
for (const e of review.invalid) logWarning(e);
|
|
142
|
+
logError('Some values are invalid and will fall back to their defaults until fixed.');
|
|
143
|
+
process.exitCode = 1;
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
logSuccess('Config saved.');
|
|
96
147
|
}
|
|
97
148
|
|
|
98
149
|
function printAll(cfg: ReturnType<typeof getUserConfig>, json?: boolean): void {
|
|
@@ -7,6 +7,8 @@ import {
|
|
|
7
7
|
parseConfigValue,
|
|
8
8
|
applyConfigSet,
|
|
9
9
|
applyConfigUnset,
|
|
10
|
+
validateTypedValue,
|
|
11
|
+
reviewConfigFileText,
|
|
10
12
|
formatConfigValue,
|
|
11
13
|
} from './config-schema';
|
|
12
14
|
|
|
@@ -95,6 +97,53 @@ describe('applyConfigSet / applyConfigUnset (pure, immutable)', () => {
|
|
|
95
97
|
});
|
|
96
98
|
});
|
|
97
99
|
|
|
100
|
+
describe('validateTypedValue (already-typed, as from the JSON file)', () => {
|
|
101
|
+
it('accepts correctly-typed values', () => {
|
|
102
|
+
expect(validateTypedValue('autoReportBugs', true)).toEqual({ ok: true });
|
|
103
|
+
expect(validateTypedValue('cloneProtocol', 'https')).toEqual({ ok: true });
|
|
104
|
+
expect(validateTypedValue('githubOrg', '')).toEqual({ ok: true }); // allowEmpty
|
|
105
|
+
expect(validateTypedValue('workspacesDir', '/x')).toEqual({ ok: true });
|
|
106
|
+
});
|
|
107
|
+
it('rejects wrong types and bad enum/empty values', () => {
|
|
108
|
+
expect(validateTypedValue('autoReportBugs', 'yes').ok).toBe(false); // string, not boolean
|
|
109
|
+
expect(validateTypedValue('cloneProtocol', 'ftp').ok).toBe(false);
|
|
110
|
+
expect(validateTypedValue('cloneProtocol', 42 as unknown).ok).toBe(false);
|
|
111
|
+
expect(validateTypedValue('workspacesDir', ' ').ok).toBe(false); // required, blank
|
|
112
|
+
expect(validateTypedValue('installMcp', 1 as unknown).ok).toBe(false);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe('reviewConfigFileText (for `config edit`)', () => {
|
|
117
|
+
it('flags unparseable JSON', () => {
|
|
118
|
+
const r = reviewConfigFileText('{ not json');
|
|
119
|
+
expect(r.parseError).toBe(true);
|
|
120
|
+
expect(r.ok).toBe(false);
|
|
121
|
+
});
|
|
122
|
+
it('flags non-object JSON (null / array / scalar) without throwing', () => {
|
|
123
|
+
for (const t of ['null', '42', '"str"', '[1,2]']) {
|
|
124
|
+
const r = reviewConfigFileText(t);
|
|
125
|
+
expect(r.notObject).toBe(true);
|
|
126
|
+
expect(r.ok).toBe(false);
|
|
127
|
+
expect(r.unknownKeys).toEqual([]); // no numeric-index "keys" from an array
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
it('reports unknown keys but stays ok if known values are valid', () => {
|
|
131
|
+
const r = reviewConfigFileText(JSON.stringify({ githubOrg: 'acme', bogus: 1, nope: true }));
|
|
132
|
+
expect(r.unknownKeys.sort()).toEqual(['bogus', 'nope']);
|
|
133
|
+
expect(r.ok).toBe(true);
|
|
134
|
+
});
|
|
135
|
+
it('catches invalid VALUES the same way config set would', () => {
|
|
136
|
+
const r = reviewConfigFileText(JSON.stringify({ cloneProtocol: 'ftp', autoReportBugs: 'yes' }));
|
|
137
|
+
expect(r.ok).toBe(false);
|
|
138
|
+
expect(r.invalid.join('\n')).toMatch(/cloneProtocol must be one of/);
|
|
139
|
+
expect(r.invalid.join('\n')).toMatch(/autoReportBugs must be a boolean/);
|
|
140
|
+
});
|
|
141
|
+
it('accepts a clean object', () => {
|
|
142
|
+
const r = reviewConfigFileText(JSON.stringify({ cloneProtocol: 'https', installMcp: true }));
|
|
143
|
+
expect(r).toMatchObject({ parseError: false, notObject: false, unknownKeys: [], invalid: [], ok: true });
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
98
147
|
describe('formatConfigValue', () => {
|
|
99
148
|
it('renders booleans and empty strings predictably', () => {
|
|
100
149
|
expect(formatConfigValue(true)).toBe('true');
|
|
@@ -80,6 +80,29 @@ export function parseConfigValue(key: ConfigKey, raw: string): ParseResult {
|
|
|
80
80
|
return { ok: true, value: trimmed };
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
/**
|
|
84
|
+
* Validate an already-typed value (as it appears in the JSON config file) for
|
|
85
|
+
* `key` against its field spec. This is the parse-free sibling of
|
|
86
|
+
* parseConfigValue (which coerces a CLI string): it checks that a boolean field
|
|
87
|
+
* holds a boolean, an enum holds an allowed string, and a string field holds a
|
|
88
|
+
* (non-empty, unless allowEmpty) string. Used by `config edit` so a hand-edit
|
|
89
|
+
* is validated the same way `config set` validates. Pure + unit-tested.
|
|
90
|
+
*/
|
|
91
|
+
export function validateTypedValue(key: ConfigKey, value: unknown): { ok: true } | { ok: false; error: string } {
|
|
92
|
+
const spec: FieldSpec = CONFIG_SCHEMA[key];
|
|
93
|
+
if (spec.type === 'boolean') {
|
|
94
|
+
return typeof value === 'boolean' ? { ok: true } : { ok: false, error: `${key} must be a boolean` };
|
|
95
|
+
}
|
|
96
|
+
if (spec.type === 'enum') {
|
|
97
|
+
return typeof value === 'string' && (spec.values as readonly string[]).includes(value)
|
|
98
|
+
? { ok: true }
|
|
99
|
+
: { ok: false, error: `${key} must be one of: ${spec.values.join(', ')}` };
|
|
100
|
+
}
|
|
101
|
+
if (typeof value !== 'string') return { ok: false, error: `${key} must be a string` };
|
|
102
|
+
if (!spec.allowEmpty && value.trim() === '') return { ok: false, error: `${key} cannot be empty` };
|
|
103
|
+
return { ok: true };
|
|
104
|
+
}
|
|
105
|
+
|
|
83
106
|
/** Apply a `set` to a config object, returning a NEW config or an error. Pure. */
|
|
84
107
|
export function applyConfigSet(
|
|
85
108
|
current: UserConfig,
|
|
@@ -110,3 +133,45 @@ export function applyConfigUnset(
|
|
|
110
133
|
export function formatConfigValue(value: unknown): string {
|
|
111
134
|
return typeof value === 'boolean' ? String(value) : String(value ?? '');
|
|
112
135
|
}
|
|
136
|
+
|
|
137
|
+
export interface ConfigFileReview {
|
|
138
|
+
/** JSON.parse failed. */
|
|
139
|
+
parseError: boolean;
|
|
140
|
+
/** Parsed, but not a plain object (null / array / scalar). */
|
|
141
|
+
notObject: boolean;
|
|
142
|
+
/** Keys present in the file that aren't recognized config keys. */
|
|
143
|
+
unknownKeys: string[];
|
|
144
|
+
/** Validation error messages for known keys holding invalid values. */
|
|
145
|
+
invalid: string[];
|
|
146
|
+
/** True when the file is a usable config object (may still have warnings). */
|
|
147
|
+
ok: boolean;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Review the raw text of a hand-edited config file the same way `config set`
|
|
152
|
+
* validates: it must parse, be a plain object, only use known keys, and every
|
|
153
|
+
* known key present must hold a schema-valid value. Pure so `config edit` can be
|
|
154
|
+
* fully unit-tested without spawning an editor.
|
|
155
|
+
*/
|
|
156
|
+
export function reviewConfigFileText(text: string): ConfigFileReview {
|
|
157
|
+
const base = { parseError: false, notObject: false, unknownKeys: [] as string[], invalid: [] as string[] };
|
|
158
|
+
let raw: unknown;
|
|
159
|
+
try {
|
|
160
|
+
raw = JSON.parse(text);
|
|
161
|
+
} catch {
|
|
162
|
+
return { ...base, parseError: true, ok: false };
|
|
163
|
+
}
|
|
164
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
165
|
+
return { ...base, notObject: true, ok: false };
|
|
166
|
+
}
|
|
167
|
+
const obj = raw as Record<string, unknown>;
|
|
168
|
+
const known = new Set<string>(CONFIG_KEYS);
|
|
169
|
+
const unknownKeys = Object.keys(obj).filter((k) => !known.has(k));
|
|
170
|
+
const invalid: string[] = [];
|
|
171
|
+
for (const key of CONFIG_KEYS) {
|
|
172
|
+
if (!(key in obj)) continue;
|
|
173
|
+
const res = validateTypedValue(key, obj[key]);
|
|
174
|
+
if (!res.ok) invalid.push(res.error);
|
|
175
|
+
}
|
|
176
|
+
return { parseError: false, notObject: false, unknownKeys, invalid, ok: invalid.length === 0 };
|
|
177
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { resolveEditor, openInEditor } from './editor';
|
|
3
|
+
|
|
4
|
+
describe('resolveEditor', () => {
|
|
5
|
+
it('prefers $VISUAL over $EDITOR', () => {
|
|
6
|
+
expect(resolveEditor({ VISUAL: 'code --wait', EDITOR: 'vim' }, 'darwin')).toEqual(['code', '--wait']);
|
|
7
|
+
});
|
|
8
|
+
it('falls back to $EDITOR, splitting flags', () => {
|
|
9
|
+
expect(resolveEditor({ EDITOR: 'emacs -nw' }, 'linux')).toEqual(['emacs', '-nw']);
|
|
10
|
+
});
|
|
11
|
+
it('platform default when neither is set', () => {
|
|
12
|
+
expect(resolveEditor({}, 'win32')).toEqual(['notepad']);
|
|
13
|
+
expect(resolveEditor({}, 'linux')).toEqual(['vi']);
|
|
14
|
+
expect(resolveEditor({ EDITOR: ' ' }, 'darwin')).toEqual(['vi']); // blank ignored
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe('openInEditor', () => {
|
|
19
|
+
it('launches editor argv[0] + flags + file, inheriting stdio', () => {
|
|
20
|
+
const spawn = vi.fn().mockReturnValue({ status: 0 }) as any;
|
|
21
|
+
const res = openInEditor('/tmp/config.json', { spawn, env: { EDITOR: 'code --wait' }, platform: 'darwin' });
|
|
22
|
+
expect(spawn).toHaveBeenCalledWith('code', ['--wait', '/tmp/config.json'], { stdio: 'inherit' });
|
|
23
|
+
expect(res).toEqual({ ok: true, editor: 'code', code: 0 });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('reports a non-zero editor exit as not-ok', () => {
|
|
27
|
+
const spawn = vi.fn().mockReturnValue({ status: 1 }) as any;
|
|
28
|
+
expect(openInEditor('/f', { spawn, env: { EDITOR: 'vi' } }).ok).toBe(false);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('reports a missing editor (ENOENT) with a clear message', () => {
|
|
32
|
+
const spawn = vi.fn().mockReturnValue({ error: Object.assign(new Error('x'), { code: 'ENOENT' }) }) as any;
|
|
33
|
+
const res = openInEditor('/f', { spawn, env: { EDITOR: 'nope' } });
|
|
34
|
+
expect(res.ok).toBe(false);
|
|
35
|
+
expect(res.error).toMatch(/not found/);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { spawnSync } from 'child_process';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Resolve the user's preferred editor as an argv array. Honors `$VISUAL` then
|
|
5
|
+
* `$EDITOR` (the long-standing Unix convention — `VISUAL` wins for full-screen
|
|
6
|
+
* editors), falling back to `notepad` on Windows and `vi` elsewhere. The env
|
|
7
|
+
* value may include flags (e.g. `code --wait`, `emacs -nw`), so it's split on
|
|
8
|
+
* whitespace into a command + args. Pure + unit-tested.
|
|
9
|
+
*/
|
|
10
|
+
export function resolveEditor(
|
|
11
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
12
|
+
platform: NodeJS.Platform = process.platform,
|
|
13
|
+
): string[] {
|
|
14
|
+
const raw = (env.VISUAL || env.EDITOR || '').trim();
|
|
15
|
+
if (raw) return raw.split(/\s+/);
|
|
16
|
+
return platform === 'win32' ? ['notepad'] : ['vi'];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface EditorResult {
|
|
20
|
+
ok: boolean;
|
|
21
|
+
/** Editor argv[0] that was launched. */
|
|
22
|
+
editor: string;
|
|
23
|
+
/** Process exit code, when the editor ran and exited normally. */
|
|
24
|
+
code?: number;
|
|
25
|
+
/** Populated when the editor couldn't be launched at all. */
|
|
26
|
+
error?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Open `file` in the resolved editor, inheriting the terminal so the editor is
|
|
31
|
+
* interactive. Returns a structured result rather than throwing so the caller
|
|
32
|
+
* controls messaging/exit. `spawn` is injected for tests.
|
|
33
|
+
*/
|
|
34
|
+
export function openInEditor(
|
|
35
|
+
file: string,
|
|
36
|
+
deps: { spawn?: typeof spawnSync; env?: NodeJS.ProcessEnv; platform?: NodeJS.Platform } = {},
|
|
37
|
+
): EditorResult {
|
|
38
|
+
const spawn = deps.spawn ?? spawnSync;
|
|
39
|
+
const [cmd, ...args] = resolveEditor(deps.env, deps.platform);
|
|
40
|
+
const res = spawn(cmd, [...args, file], { stdio: 'inherit' });
|
|
41
|
+
if (res.error) {
|
|
42
|
+
const err = res.error as NodeJS.ErrnoException;
|
|
43
|
+
const reason = err.code === 'ENOENT' ? `editor "${cmd}" not found` : err.message;
|
|
44
|
+
return { ok: false, editor: cmd, error: reason };
|
|
45
|
+
}
|
|
46
|
+
const code = typeof res.status === 'number' ? res.status : 1;
|
|
47
|
+
return { ok: code === 0, editor: cmd, code };
|
|
48
|
+
}
|