@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 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
 
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nemus-cli/nemus",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "workspaces": [
5
5
  "packages/*"
6
6
  ],
@@ -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
+ }