codex-configurator 0.1.0 → 0.2.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/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # Codex Configurator
2
2
 
3
3
  Codex Configurator is a terminal user interface (TUI) built with Node.js, React, and Ink.
4
- It shows the current contents of `~/.codex/config.toml` and can reload them on demand.
4
+ It shows the current contents of a Codex TOML configuration file and can reload it on demand.
5
5
 
6
6
  ## Requirements
7
7
 
@@ -10,19 +10,26 @@ It shows the current contents of `~/.codex/config.toml` and can reload them on d
10
10
  ## Install
11
11
 
12
12
  ```bash
13
- npm install
13
+ npm i -g codex-configurator
14
14
  ```
15
15
 
16
16
  ## Usage
17
17
 
18
18
  ```bash
19
- npm start
19
+ codex-configurator
20
20
  ```
21
21
 
22
- To run directly from an installed package:
22
+ Optional config-path override:
23
23
 
24
24
  ```bash
25
- codex-configurator
25
+ codex-configurator --config /path/to/config.toml
26
+ ```
27
+
28
+ For local development in this repository:
29
+
30
+ ```bash
31
+ npm install
32
+ npm start
26
33
  ```
27
34
 
28
35
  ## Controls
@@ -33,7 +40,7 @@ codex-configurator
33
40
  - `Enter`: open selected table; for boolean settings, toggle directly; for string settings, open inline input; for other preset values, open picker
34
41
  - `Del`: unset selected value or remove selected custom `<id>` entry from `config.toml`
35
42
  - `←` / `Backspace`: move up one level (to parent table)
36
- - `r`: reload `~/.codex/config.toml`
43
+ - `r`: reload the active config file
37
44
  - `q`: quit
38
45
 
39
46
  The right-hand pane shows what each setting means, plus a picker when a value has preset options.
@@ -52,7 +59,7 @@ The table view follows TOML structure, with a root catalog of common keys:
52
59
  - Selected sections such as `history`, `tui`, `feedback`, and `shell_environment_policy` also show common unset keys.
53
60
  - Attributes and subattributes are shown in strict alphabetical order.
54
61
  - Unset boolean settings display explicit defaults as `true [default]` or `false [default]`.
55
- - For placeholder keys like `<path>`, IDs entered in the UI are normalized under your home directory.
62
+ - For placeholder keys like `<path>`, IDs entered in the UI are normalized under your home directory, and traversal outside home is rejected.
56
63
 
57
64
  - Dotted/table sections become navigable table nodes.
58
65
  - Inline key-value pairs are shown as leaf entries.
@@ -61,13 +68,39 @@ The table view follows TOML structure, with a root catalog of common keys:
61
68
 
62
69
  ## Configuration source
63
70
 
64
- The app reads from:
71
+ By default the app reads from:
65
72
 
66
73
  ```bash
67
74
  ~/.codex/config.toml
68
75
  ```
69
76
 
70
- If the file is missing or unreadable, the TUI displays the read error and the expected path.
77
+ You can override this path with either:
78
+
79
+ - CLI: `--config /absolute/or/relative/path.toml`
80
+ - Env: `CODEX_CONFIGURATOR_CONFIG_PATH`
81
+
82
+ Precedence is CLI first, then environment variable, then the default path.
83
+ If the file is missing or unreadable, the TUI displays the read error and the resolved path.
84
+ Configuration writes are atomic and create the target file (and parent directories) when missing.
85
+
86
+ ## Error logging
87
+
88
+ Read/write failures are appended to a JSON-lines log file.
89
+
90
+ - Default path: `~/.codex-configurator-errors.log`
91
+ - Override path: `CODEX_CONFIGURATOR_ERROR_LOG_PATH`
92
+ - Max log size before single-file rotation: `CODEX_CONFIGURATOR_ERROR_LOG_MAX_BYTES` (default `1048576`)
93
+
94
+ When the log exceeds the size limit, it is rotated to `<log-path>.1` before writing the new event.
95
+
96
+ ## Version checks
97
+
98
+ Codex version checks are disabled by default to avoid executing unknown binaries from `PATH`.
99
+ To enable checks, all of the following must be set:
100
+
101
+ - `CODEX_CONFIGURATOR_ENABLE_VERSION_CHECK=1`
102
+ - `CODEX_CONFIGURATOR_CODEX_BIN=/absolute/path/to/codex`
103
+ - `CODEX_CONFIGURATOR_NPM_BIN=/absolute/path/to/npm`
71
104
 
72
105
  ## Upstream reference
73
106
 
@@ -77,9 +110,16 @@ If the file is missing or unreadable, the TUI displays the read error and the ex
77
110
 
78
111
  - `npm start`: run the TUI
79
112
  - `npm run dev`: same as `npm start`
80
- - `npm run lint`: syntax check for all source files
81
- - `npm run build`: syntax check for distributable entrypoint and modules
82
- - `npm test`: runs lint
113
+ - `npm run lint`: ESLint static analysis for `index.js`, `src`, and `test`
114
+ - `npm run build`: validates the npm package archive (`npm pack --dry-run --ignore-scripts --cache .npm-cache`)
115
+ - `npm test`: runs the Node.js unit test suite (`node --test`)
116
+
117
+ ## Continuous integration
118
+
119
+ GitHub Actions runs production dependency audit (`npm audit --omit=dev --audit-level=high`), `npm run lint`, `npm test`, `npm run build`, and `npm pack --dry-run` on every push and pull request across:
120
+
121
+ - `ubuntu-latest`, `macos-latest`, and `windows-latest`
122
+ - Node.js `18` and `20`
83
123
 
84
124
  ## Project structure
85
125
 
package/index.js CHANGED
@@ -1,9 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import React, { useState, useEffect } from 'react';
4
- import os from 'os';
5
- import path from 'path';
6
- import { execSync } from 'node:child_process';
4
+ import { execFile } from 'node:child_process';
5
+ import path from 'node:path';
7
6
  import { render, useInput, useApp, useStdout, Text, Box } from 'ink';
8
7
  import { CONTROL_HINT, EDIT_CONTROL_HINT } from './src/constants.js';
9
8
  import {
@@ -19,6 +18,7 @@ import {
19
18
  getReferenceOptionForPath,
20
19
  getReferenceCustomIdPlaceholder,
21
20
  } from './src/configReference.js';
21
+ import { normalizeCustomPathId } from './src/customPathId.js';
22
22
  import { pathToKey, clamp } from './src/layout.js';
23
23
  import {
24
24
  isBackspaceKey,
@@ -57,34 +57,69 @@ const isCustomIdTableRow = (pathSegments, row) =>
57
57
  Boolean(getReferenceCustomIdPlaceholder(pathSegments));
58
58
 
59
59
  const isInlineTextMode = (mode) => mode === 'text' || mode === 'add-id';
60
+ const VERSION_COMMAND_TIMEOUT_MS = 3000;
61
+ const VERSION_DISABLED_LABEL = 'version check disabled';
62
+ const VERSION_CHECK_ENABLED_ENV_VAR = 'CODEX_CONFIGURATOR_ENABLE_VERSION_CHECK';
63
+ const CODEX_BIN_ENV_VAR = 'CODEX_CONFIGURATOR_CODEX_BIN';
64
+ const NPM_BIN_ENV_VAR = 'CODEX_CONFIGURATOR_NPM_BIN';
65
+
66
+ const runCommand = (command, args = []) =>
67
+ new Promise((resolve) => {
68
+ execFile(
69
+ command,
70
+ args,
71
+ {
72
+ encoding: 'utf8',
73
+ timeout: VERSION_COMMAND_TIMEOUT_MS,
74
+ maxBuffer: 1024 * 1024,
75
+ windowsHide: true,
76
+ },
77
+ (error, stdout) => {
78
+ if (error) {
79
+ resolve('');
80
+ return;
81
+ }
82
+
83
+ resolve(String(stdout || '').trim());
84
+ }
85
+ );
86
+ });
60
87
 
61
- const normalizeCustomPathId = (value) => {
62
- const trimmedValue = String(value || '').trim();
63
- if (!trimmedValue) {
88
+ const getAbsoluteCommandPath = (environmentVariableName) => {
89
+ const configuredPath = String(process.env[environmentVariableName] || '').trim();
90
+ if (!configuredPath || !path.isAbsolute(configuredPath)) {
64
91
  return '';
65
92
  }
66
93
 
67
- const withoutTilde = trimmedValue.startsWith('~')
68
- ? trimmedValue.slice(1)
69
- : trimmedValue;
70
- const relativePath = withoutTilde.replace(/^[/\\]+/, '');
71
-
72
- return path.join(os.homedir(), relativePath);
94
+ return configuredPath;
73
95
  };
74
96
 
75
- const getCodexVersion = () => {
76
- try {
77
- const output = execSync('codex --version', { encoding: 'utf8', stdio: 'pipe' }).trim();
78
- const firstLine = output.split('\n')[0].trim();
97
+ const getVersionCommands = () => {
98
+ if (process.env[VERSION_CHECK_ENABLED_ENV_VAR] !== '1') {
99
+ return null;
100
+ }
79
101
 
80
- if (!firstLine) {
81
- return 'version unavailable';
82
- }
102
+ const codexCommand = getAbsoluteCommandPath(CODEX_BIN_ENV_VAR);
103
+ const npmCommand = getAbsoluteCommandPath(NPM_BIN_ENV_VAR);
104
+ if (!codexCommand || !npmCommand) {
105
+ return null;
106
+ }
107
+
108
+ return {
109
+ codexCommand,
110
+ npmCommand,
111
+ };
112
+ };
113
+
114
+ const getCodexVersion = async (codexCommand) => {
115
+ const output = await runCommand(codexCommand, ['--version']);
116
+ const firstLine = output.split('\n')[0]?.trim();
83
117
 
84
- return firstLine.startsWith('codex') ? firstLine : `version ${firstLine}`;
85
- } catch {
118
+ if (!firstLine) {
86
119
  return 'version unavailable';
87
120
  }
121
+
122
+ return firstLine.startsWith('codex') ? firstLine : `version ${firstLine}`;
88
123
  };
89
124
 
90
125
  const normalizeVersion = (value) => {
@@ -120,53 +155,57 @@ const compareVersions = (left, right) => {
120
155
  return 0;
121
156
  };
122
157
 
123
- const getCodexUpdateStatus = () => {
124
- const installed = normalizeVersion(getCodexVersion());
158
+ const getCodexUpdateStatus = async () => {
159
+ const commands = getVersionCommands();
160
+ if (!commands) {
161
+ return {
162
+ installed: VERSION_DISABLED_LABEL,
163
+ latest: 'unknown',
164
+ status: '',
165
+ };
166
+ }
167
+
168
+ const installedLabel = await getCodexVersion(commands.codexCommand);
169
+ const installed = normalizeVersion(installedLabel);
125
170
 
126
171
  if (!installed) {
127
172
  return {
128
- installed: 'version unavailable',
173
+ installed: installedLabel,
129
174
  latest: 'unknown',
130
175
  status: 'version check unavailable',
131
176
  };
132
177
  }
133
178
 
134
- try {
135
- const latestOutput = execSync('npm view @openai/codex version --json', {
136
- encoding: 'utf8',
137
- stdio: 'pipe',
138
- }).trim();
139
- const latest = normalizeVersion(latestOutput) || latestOutput.trim();
140
-
141
- if (!latest) {
142
- return {
143
- installed,
144
- latest: 'unknown',
145
- status: 'version check unavailable',
146
- };
147
- }
148
-
149
- const comparison = compareVersions(installed, latest);
150
- if (comparison < 0) {
151
- return {
152
- installed,
153
- latest,
154
- status: 'update available',
155
- };
156
- }
179
+ const latestOutput = await runCommand(commands.npmCommand, [
180
+ 'view',
181
+ '@openai/codex',
182
+ 'version',
183
+ '--json',
184
+ ]);
185
+ const latest = normalizeVersion(latestOutput) || latestOutput.trim();
157
186
 
187
+ if (!latest) {
158
188
  return {
159
189
  installed,
160
- latest,
161
- status: 'up to date',
190
+ latest: 'unknown',
191
+ status: 'version check unavailable',
162
192
  };
163
- } catch {
193
+ }
194
+
195
+ const comparison = compareVersions(installed, latest);
196
+ if (comparison < 0) {
164
197
  return {
165
198
  installed,
166
- latest: 'unknown',
167
- status: 'version check unavailable',
199
+ latest,
200
+ status: 'update available',
168
201
  };
169
202
  }
203
+
204
+ return {
205
+ installed,
206
+ latest,
207
+ status: 'up to date',
208
+ };
170
209
  };
171
210
 
172
211
  const App = () => {
@@ -187,9 +226,24 @@ const App = () => {
187
226
  const { exit } = useApp();
188
227
 
189
228
  useEffect(() => {
190
- const check = getCodexUpdateStatus();
191
- setCodexVersion(check.installed);
192
- setCodexVersionStatus(check.status);
229
+ let isCancelled = false;
230
+
231
+ const loadVersionStatus = async () => {
232
+ const check = await getCodexUpdateStatus();
233
+
234
+ if (isCancelled) {
235
+ return;
236
+ }
237
+
238
+ setCodexVersion(check.installed);
239
+ setCodexVersionStatus(check.status);
240
+ };
241
+
242
+ loadVersionStatus();
243
+
244
+ return () => {
245
+ isCancelled = true;
246
+ };
193
247
  }, []);
194
248
 
195
249
  const currentNode = getNodeAtPath(snapshot.ok ? snapshot.data : {}, pathSegments);
@@ -316,9 +370,17 @@ const App = () => {
316
370
 
317
371
  const nextIdInput = String(editMode.draftValue || '').trim();
318
372
  const placeholder = getReferenceCustomIdPlaceholder(editMode.path);
319
- const nextId = placeholder === '<path>'
320
- ? normalizeCustomPathId(nextIdInput)
321
- : nextIdInput;
373
+ let nextId = nextIdInput;
374
+
375
+ if (placeholder === '<path>') {
376
+ const normalizedPath = normalizeCustomPathId(nextIdInput);
377
+ if (!normalizedPath.ok) {
378
+ setEditError(normalizedPath.error);
379
+ return;
380
+ }
381
+
382
+ nextId = normalizedPath.value;
383
+ }
322
384
 
323
385
  if (!nextId) {
324
386
  setEditError('ID cannot be empty.');
@@ -712,7 +774,7 @@ const App = () => {
712
774
  setEditError('');
713
775
  return;
714
776
  }
715
- });
777
+ }, { isActive: isInteractive });
716
778
 
717
779
  useEffect(() => {
718
780
  const maxOffset = Math.max(0, rows.length - listViewportHeight);
package/package.json CHANGED
@@ -1,42 +1,55 @@
1
1
  {
2
- "name": "codex-configurator",
3
- "version": "0.1.0",
4
- "description": "TOML-aware Ink TUI for Codex Configurator",
5
- "main": "index.js",
6
- "type": "module",
7
- "keywords": [
8
- "tui",
9
- "terminal",
10
- "cli",
11
- "react",
12
- "ink",
13
- "node",
14
- "configurator"
15
- ],
16
- "author": "Codex Configurator",
17
- "license": "MIT",
18
- "bin": {
19
- "codex-configurator": "index.js"
20
- },
21
- "scripts": {
22
- "start": "node index.js",
23
- "dev": "node index.js",
24
- "lint": "node --check index.js && node --check src/configHelp.js src/configParser.js src/constants.js src/interaction.js src/layout.js src/components/Header.js src/components/ConfigNavigator.js",
25
- "build": "node --check index.js && node --check src/configHelp.js src/configParser.js src/constants.js src/interaction.js src/layout.js src/components/Header.js src/components/ConfigNavigator.js",
26
- "test": "npm run lint",
27
- "prepack": "npm run test"
28
- },
29
- "engines": {
30
- "node": ">=18"
31
- },
32
- "files": [
33
- "index.js",
34
- "src"
35
- ],
36
- "dependencies": {
37
- "@iarna/toml": "^2.2.5",
38
- "ink": "^6.2.1",
39
- "react": "^19.0.0",
40
- "toml": "^3.0.0"
41
- }
2
+ "name": "codex-configurator",
3
+ "version": "0.2.0",
4
+ "description": "TOML-aware Ink TUI for Codex Configurator",
5
+ "main": "index.js",
6
+ "type": "module",
7
+ "keywords": [
8
+ "tui",
9
+ "terminal",
10
+ "cli",
11
+ "react",
12
+ "ink",
13
+ "node",
14
+ "configurator"
15
+ ],
16
+ "author": "Codex Configurator",
17
+ "license": "MIT",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/enriquemorenotent/codex-configurator.git"
21
+ },
22
+ "homepage": "https://github.com/enriquemorenotent/codex-configurator#readme",
23
+ "bugs": {
24
+ "url": "https://github.com/enriquemorenotent/codex-configurator/issues"
25
+ },
26
+ "funding": "https://github.com/sponsors/enriquemorenotent",
27
+ "bin": {
28
+ "codex-configurator": "index.js"
29
+ },
30
+ "scripts": {
31
+ "start": "node index.js",
32
+ "dev": "node index.js",
33
+ "lint": "eslint index.js src test --max-warnings=0",
34
+ "build": "npm pack --dry-run --ignore-scripts --cache .npm-cache",
35
+ "test": "node --test",
36
+ "prepack": "npm run lint && npm test"
37
+ },
38
+ "engines": {
39
+ "node": ">=18"
40
+ },
41
+ "packageManager": "npm@10.9.4",
42
+ "files": [
43
+ "index.js",
44
+ "src"
45
+ ],
46
+ "dependencies": {
47
+ "@iarna/toml": "^2.2.5",
48
+ "ink": "^6.2.1",
49
+ "react": "^19.0.0",
50
+ "toml": "^3.0.0"
51
+ },
52
+ "devDependencies": {
53
+ "eslint": "^8.57.1"
54
+ }
42
55
  }
@@ -7,7 +7,7 @@ import {
7
7
  getConfigDefaultOption,
8
8
  } from '../configHelp.js';
9
9
  import { computePaneWidths, clamp } from '../layout.js';
10
- import { getNodeAtPath, buildRows, formatDetails } from '../configParser.js';
10
+ import { getNodeAtPath, buildRows } from '../configParser.js';
11
11
 
12
12
  const MenuItem = ({ isSelected, isDimmed, isDeprecated, label }) =>
13
13
  React.createElement(
@@ -21,8 +21,8 @@ export const Header = ({ codexVersion, codexVersionStatus }) =>
21
21
  React.createElement(
22
22
  Box,
23
23
  { flexDirection: 'column', marginBottom: 1, gap: 0 },
24
- ...WORDMARK.map((line) =>
25
- React.createElement(Text, { color: 'magentaBright', bold: true, key: `word-${line}` }, line)
24
+ ...WORDMARK.map((line, index) =>
25
+ React.createElement(Text, { color: 'magentaBright', bold: true, key: `word-${index}` }, line)
26
26
  )
27
27
  ),
28
28
  React.createElement(
@@ -13,8 +13,12 @@ import {
13
13
  getReferenceRootDefinitions,
14
14
  getReferenceTableDefinitions,
15
15
  } from './configReference.js';
16
+ import { logConfiguratorError } from './errorLogger.js';
16
17
 
17
- export const CONFIG_PATH = path.join(os.homedir(), '.codex', 'config.toml');
18
+ export const CONFIG_PATH_ENV_VAR = 'CODEX_CONFIGURATOR_CONFIG_PATH';
19
+ const CONFIG_PATH_FLAG = '--config';
20
+ const DEFAULT_CONFIG_FILE_SEGMENTS = ['.codex', 'config.toml'];
21
+ export const DEFAULT_CONFIG_PATH = path.join(os.homedir(), ...DEFAULT_CONFIG_FILE_SEGMENTS);
18
22
  export const MAX_DETAIL_CHARS = 2200;
19
23
  const MAX_ARRAY_PREVIEW_ITEMS = 3;
20
24
  const MAX_ARRAY_PREVIEW_CHARS = 52;
@@ -77,21 +81,91 @@ const formatArrayPreview = (value) => {
77
81
  return truncateText(joined, MAX_ARRAY_PREVIEW_CHARS);
78
82
  };
79
83
 
80
- export const readConfig = () => {
84
+ const expandHomePath = (value, homeDir) => {
85
+ if (value === '~') {
86
+ return homeDir;
87
+ }
88
+
89
+ if (value.startsWith('~/') || value.startsWith('~\\')) {
90
+ return path.join(homeDir, value.slice(2));
91
+ }
92
+
93
+ return value;
94
+ };
95
+
96
+ const normalizeConfiguredPath = (value, homeDir) => {
97
+ const normalizedValue = typeof value === 'string' ? value.trim() : '';
98
+ if (!normalizedValue) {
99
+ return '';
100
+ }
101
+
102
+ const expanded = expandHomePath(normalizedValue, homeDir);
103
+ return path.resolve(expanded);
104
+ };
105
+
106
+ const getCliConfigPath = (argv = process.argv.slice(2)) => {
107
+ for (let index = 0; index < argv.length; index += 1) {
108
+ const argument = typeof argv[index] === 'string' ? argv[index].trim() : '';
109
+ if (!argument) {
110
+ continue;
111
+ }
112
+
113
+ if (argument === CONFIG_PATH_FLAG) {
114
+ const nextArgument = argv[index + 1];
115
+ return typeof nextArgument === 'string' ? nextArgument : '';
116
+ }
117
+
118
+ if (argument.startsWith(`${CONFIG_PATH_FLAG}=`)) {
119
+ return argument.slice(CONFIG_PATH_FLAG.length + 1);
120
+ }
121
+ }
122
+
123
+ return '';
124
+ };
125
+
126
+ export const resolveConfigPath = ({
127
+ argv = process.argv.slice(2),
128
+ env = process.env,
129
+ homeDir = os.homedir(),
130
+ } = {}) => {
131
+ const cliConfiguredPath = normalizeConfiguredPath(getCliConfigPath(argv), homeDir);
132
+ if (cliConfiguredPath) {
133
+ return cliConfiguredPath;
134
+ }
135
+
136
+ const envConfiguredPath = normalizeConfiguredPath(env?.[CONFIG_PATH_ENV_VAR], homeDir);
137
+ if (envConfiguredPath) {
138
+ return envConfiguredPath;
139
+ }
140
+
141
+ return path.join(homeDir, ...DEFAULT_CONFIG_FILE_SEGMENTS);
142
+ };
143
+
144
+ export const CONFIG_PATH = resolveConfigPath();
145
+
146
+ export const readConfig = (configPath = CONFIG_PATH) => {
81
147
  try {
82
- const fileContents = fs.readFileSync(CONFIG_PATH, 'utf8');
148
+ const targetPath = configPath;
149
+ const fileContents = fs.readFileSync(targetPath, 'utf8');
83
150
  const data = toml.parse(fileContents);
84
151
 
85
152
  return {
86
153
  ok: true,
87
- path: CONFIG_PATH,
154
+ path: targetPath,
88
155
  data,
89
156
  };
90
157
  } catch (error) {
158
+ const targetPath = configPath;
159
+ const errorMessage = error?.message || 'Unable to read or parse configuration file.';
160
+ logConfiguratorError('config.read.failed', {
161
+ configPath: targetPath,
162
+ error: errorMessage,
163
+ });
164
+
91
165
  return {
92
166
  ok: false,
93
- path: CONFIG_PATH,
94
- error: error?.message || 'Unable to read or parse configuration file.',
167
+ path: targetPath,
168
+ error: errorMessage,
95
169
  };
96
170
  }
97
171
  };
@@ -99,17 +173,57 @@ export const readConfig = () => {
99
173
  const normalizeFilePath = (outputPath) => outputPath || CONFIG_PATH;
100
174
 
101
175
  export const writeConfig = (data, outputPath = CONFIG_PATH) => {
176
+ const targetPath = normalizeFilePath(outputPath);
177
+ const directoryPath = path.dirname(targetPath);
178
+ const fileName = path.basename(targetPath);
179
+ const tempPath = path.join(
180
+ directoryPath,
181
+ `.${fileName}.${process.pid}.${Date.now()}.tmp`
182
+ );
183
+
102
184
  try {
185
+ if (!fs.existsSync(directoryPath)) {
186
+ fs.mkdirSync(directoryPath, { recursive: true, mode: 0o700 });
187
+ }
188
+
103
189
  const payload = stringify(data);
104
- fs.writeFileSync(normalizeFilePath(outputPath), `${payload}\n`);
190
+ let tempFd = null;
191
+
192
+ try {
193
+ tempFd = fs.openSync(tempPath, 'wx', 0o600);
194
+ fs.writeFileSync(tempFd, `${payload}\n`, 'utf8');
195
+ fs.fsyncSync(tempFd);
196
+ fs.closeSync(tempFd);
197
+ tempFd = null;
198
+
199
+ fs.renameSync(tempPath, targetPath);
200
+ } finally {
201
+ if (tempFd !== null) {
202
+ try {
203
+ fs.closeSync(tempFd);
204
+ } catch {}
205
+ }
206
+
207
+ if (fs.existsSync(tempPath)) {
208
+ try {
209
+ fs.unlinkSync(tempPath);
210
+ } catch {}
211
+ }
212
+ }
105
213
 
106
214
  return {
107
215
  ok: true,
108
216
  };
109
217
  } catch (error) {
218
+ const errorMessage = error?.message || 'Unable to write configuration file.';
219
+ logConfiguratorError('config.write.failed', {
220
+ configPath: targetPath,
221
+ error: errorMessage,
222
+ });
223
+
110
224
  return {
111
225
  ok: false,
112
- error: error?.message || 'Unable to write configuration file.',
226
+ error: errorMessage,
113
227
  };
114
228
  }
115
229
  };
@@ -1,4 +1,7 @@
1
- import CONFIG_REFERENCE_DATA from './reference/config-reference.json' with { type: 'json' };
1
+ import { createRequire } from 'node:module';
2
+
3
+ const require = createRequire(import.meta.url);
4
+ const CONFIG_REFERENCE_DATA = require('./reference/config-reference.json');
2
5
 
3
6
  const DOCUMENT_ID = 'config.toml';
4
7
  const PLACEHOLDER_SEGMENT = /^<[^>]+>$/;
@@ -0,0 +1,42 @@
1
+ import os from 'os';
2
+ import path from 'path';
3
+
4
+ const normalizePathSegments = (value) =>
5
+ String(value || '')
6
+ .split(/[\\/]+/)
7
+ .filter((segment) => segment.length > 0)
8
+ .join(path.sep);
9
+
10
+ const resolveCustomPath = (value, homePath) => {
11
+ const withoutTilde = value.startsWith('~') ? value.slice(1) : value;
12
+ const relativePath = normalizePathSegments(withoutTilde.replace(/^[/\\]+/, ''));
13
+ return path.resolve(homePath, relativePath);
14
+ };
15
+
16
+ const isWithinHomePath = (resolvedPath, homePath) => {
17
+ const relative = path.relative(homePath, resolvedPath);
18
+ return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
19
+ };
20
+
21
+ export const normalizeCustomPathId = (value, homePath = os.homedir()) => {
22
+ const trimmedValue = String(value || '').trim();
23
+ if (!trimmedValue) {
24
+ return {
25
+ ok: false,
26
+ error: 'ID cannot be empty.',
27
+ };
28
+ }
29
+
30
+ const resolvedPath = resolveCustomPath(trimmedValue, homePath);
31
+ if (!isWithinHomePath(resolvedPath, homePath)) {
32
+ return {
33
+ ok: false,
34
+ error: `Path must stay inside ${homePath}.`,
35
+ };
36
+ }
37
+
38
+ return {
39
+ ok: true,
40
+ value: resolvedPath,
41
+ };
42
+ };
@@ -0,0 +1,70 @@
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+
5
+ const DEFAULT_ERROR_LOG_PATH = path.join(os.homedir(), '.codex-configurator-errors.log');
6
+ const DEFAULT_ERROR_LOG_MAX_BYTES = 1024 * 1024;
7
+ const ERROR_LOG_MAX_BYTES_ENV_VAR = 'CODEX_CONFIGURATOR_ERROR_LOG_MAX_BYTES';
8
+
9
+ const getErrorLogPath = () =>
10
+ process.env.CODEX_CONFIGURATOR_ERROR_LOG_PATH || DEFAULT_ERROR_LOG_PATH;
11
+
12
+ const parsePositiveInteger = (value) => {
13
+ const parsed = Number.parseInt(String(value || ''), 10);
14
+ if (!Number.isInteger(parsed) || parsed < 1) {
15
+ return null;
16
+ }
17
+
18
+ return parsed;
19
+ };
20
+
21
+ const getErrorLogMaxBytes = () =>
22
+ parsePositiveInteger(process.env[ERROR_LOG_MAX_BYTES_ENV_VAR]) || DEFAULT_ERROR_LOG_MAX_BYTES;
23
+
24
+ const ensureLogDirectory = (logPath) => {
25
+ const directoryPath = path.dirname(logPath);
26
+ if (!fs.existsSync(directoryPath)) {
27
+ fs.mkdirSync(directoryPath, { recursive: true, mode: 0o700 });
28
+ }
29
+ };
30
+
31
+ const rotateLogFileIfNeeded = (logPath, maxBytes) => {
32
+ try {
33
+ const stats = fs.statSync(logPath);
34
+ if (!stats.isFile() || stats.size < maxBytes) {
35
+ return;
36
+ }
37
+
38
+ const rotatedPath = `${logPath}.1`;
39
+ if (fs.existsSync(rotatedPath)) {
40
+ fs.unlinkSync(rotatedPath);
41
+ }
42
+
43
+ fs.renameSync(logPath, rotatedPath);
44
+ } catch {}
45
+ };
46
+
47
+ export const logConfiguratorError = (event, details = {}) => {
48
+ const payload = {
49
+ timestamp: new Date().toISOString(),
50
+ event: String(event || 'unknown'),
51
+ ...details,
52
+ };
53
+ const logPath = getErrorLogPath();
54
+ const maxBytes = getErrorLogMaxBytes();
55
+
56
+ try {
57
+ ensureLogDirectory(logPath);
58
+ rotateLogFileIfNeeded(logPath, maxBytes);
59
+ fs.appendFileSync(logPath, `${JSON.stringify(payload)}\n`, 'utf8');
60
+ return {
61
+ ok: true,
62
+ path: logPath,
63
+ };
64
+ } catch {
65
+ return {
66
+ ok: false,
67
+ path: logPath,
68
+ };
69
+ }
70
+ };