codex-configurator 0.2.5 → 0.2.6

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
@@ -120,6 +120,8 @@ You can override either command with:
120
120
  - `CODEX_CONFIGURATOR_CODEX_BIN=<command-or-path>`
121
121
  - `CODEX_CONFIGURATOR_NPM_BIN=<command-or-path>`
122
122
 
123
+ The status bar shows `Using Codex v<version>` only after Codex version detection completes. During startup schema sync it shows `Checking schema`, and after that it shows `Checking Codex version` until the Codex check finishes. When needed, it shows Codex update state as `Update available` or `Version check unavailable`; if there is no warning badge, assume Codex is up to date. The schema-progress state is kept visible for at least `1.2s` so it is observable even on very fast connections. The Codex version check starts only after schema sync completes.
124
+
123
125
  ## Self-update behavior
124
126
 
125
127
  On startup, the app checks the npm registry for the latest `codex-configurator` version.
@@ -132,7 +134,17 @@ This uses the `npm` command from `PATH` (or `CODEX_CONFIGURATOR_NPM_BIN` if set)
132
134
  ## Upstream reference
133
135
 
134
136
  - Canonical config schema: https://developers.openai.com/codex/config-schema.json
135
- - The local menu reads directly from `src/reference/config-schema.json`, which is downloaded from the canonical schema.
137
+ - Runtime behavior:
138
+ - App starts with bundled fallback schema from `src/reference/config-schema.json`.
139
+ - App then checks for updates on startup in the background.
140
+ - If a valid cached schema exists, it is loaded immediately.
141
+ - If upstream has a newer valid schema, it is applied immediately and cached for future launches.
142
+ - If the check fails, the app silently continues with the already-active schema.
143
+ - Runtime cache location: `<codex-dir>/codex-configurator-cache/`
144
+ - Default codex dir: `~/.codex`
145
+ - Cached files:
146
+ - `config-schema.json`
147
+ - `config-schema.meta.json`
136
148
  - To refresh the reference snapshot after a new stable Codex release:
137
149
  - `npm run sync:reference`
138
150
  - Snapshot currently synced against Codex stable reference updates published on 2026-02-25.
package/index.js CHANGED
@@ -36,6 +36,7 @@ import {
36
36
  import {
37
37
  APP_MODES,
38
38
  APP_STATE_ACTION,
39
+ REFERENCE_SCHEMA_CHANGED_ACTION,
39
40
  appStateReducer,
40
41
  buildInitialAppState,
41
42
  } from './src/appState.js';
@@ -43,6 +44,7 @@ import { pathToKey, clamp, computeListViewportRows } from './src/layout.js';
43
44
  import { Header } from './src/components/Header.js';
44
45
  import { ConfigNavigator } from './src/components/ConfigNavigator.js';
45
46
  import { filterRowsByQuery } from './src/fuzzySearch.js';
47
+ import { syncReferenceSchemaAtStartup } from './src/schemaRuntimeSync.js';
46
48
  import { executeInputCommand, getModeHint } from './src/ui/commands.js';
47
49
  import { CommandBar } from './src/ui/panes/CommandBar.js';
48
50
  import { HelpBubble } from './src/ui/panes/HelpBubble.js';
@@ -75,6 +77,7 @@ const UPDATE_MAX_BUFFER_BYTES = 10 * 1024 * 1024;
75
77
  const CODEX_BIN_ENV_VAR = 'CODEX_CONFIGURATOR_CODEX_BIN';
76
78
  const NPM_BIN_ENV_VAR = 'CODEX_CONFIGURATOR_NPM_BIN';
77
79
  const CONFIGURATOR_PACKAGE_NAME = 'codex-configurator';
80
+ const SCHEMA_STATUS_MIN_VISIBLE_MS = 1200;
78
81
  const FILE_SWITCH_MAX_VISIBLE_ENTRIES = 6;
79
82
  const FILE_SWITCH_PANEL_BASE_ROWS = 3;
80
83
  const FILE_SWITCH_LAYOUT_EXTRA_GAP_ROWS = 1;
@@ -174,6 +177,11 @@ const runCommand = async (command, args = [], options = {}) => {
174
177
  return result.stdout;
175
178
  };
176
179
 
180
+ const waitForMilliseconds = (milliseconds) =>
181
+ new Promise((resolve) => {
182
+ setTimeout(resolve, Math.max(0, Number(milliseconds) || 0));
183
+ });
184
+
177
185
  const getConfiguredCommand = (environmentVariableName, fallbackCommand) => {
178
186
  const configuredCommand = String(
179
187
  process.env[environmentVariableName] || '',
@@ -364,7 +372,7 @@ const getCodexUpdateStatus = async () => {
364
372
  return {
365
373
  installed: installedLabel,
366
374
  latest: 'unknown',
367
- status: 'version check unavailable',
375
+ status: 'Version check unavailable',
368
376
  };
369
377
  }
370
378
 
@@ -380,7 +388,7 @@ const getCodexUpdateStatus = async () => {
380
388
  return {
381
389
  installed,
382
390
  latest: 'unknown',
383
- status: 'version check unavailable',
391
+ status: 'Version check unavailable',
384
392
  };
385
393
  }
386
394
 
@@ -389,14 +397,14 @@ const getCodexUpdateStatus = async () => {
389
397
  return {
390
398
  installed,
391
399
  latest,
392
- status: `update available: ${latest}`,
400
+ status: 'Update available',
393
401
  };
394
402
  }
395
403
 
396
404
  return {
397
405
  installed,
398
406
  latest,
399
- status: 'up to date',
407
+ status: 'Up to date',
400
408
  };
401
409
  };
402
410
 
@@ -470,6 +478,8 @@ const App = () => {
470
478
  });
471
479
  const setStateBatch = (updates) =>
472
480
  dispatch({ type: APP_STATE_ACTION, payload: { updates } });
481
+ const resetSchemaDerivedEditState = () =>
482
+ dispatch({ type: REFERENCE_SCHEMA_CHANGED_ACTION });
473
483
 
474
484
  const setSnapshot = (valueOrUpdater) =>
475
485
  setAppState('snapshot', valueOrUpdater);
@@ -517,6 +527,10 @@ const App = () => {
517
527
  setAppState('codexVersion', valueOrUpdater);
518
528
  const setCodexVersionStatus = (valueOrUpdater) =>
519
529
  setAppState('codexVersionStatus', valueOrUpdater);
530
+ const setIsSchemaCheckInProgress = (valueOrUpdater) =>
531
+ setAppState('isSchemaCheckInProgress', valueOrUpdater);
532
+ const setSchemaCheckStatusText = (valueOrUpdater) =>
533
+ setAppState('schemaCheckStatusText', valueOrUpdater);
520
534
  const {
521
535
  snapshot,
522
536
  snapshotByFileId,
@@ -538,6 +552,8 @@ const App = () => {
538
552
  showHelp,
539
553
  codexVersion,
540
554
  codexVersionStatus,
555
+ isSchemaCheckInProgress,
556
+ schemaCheckStatusText,
541
557
  } = state;
542
558
  commandInputRef.current = String(commandInput || '');
543
559
  const { exit } = useApp();
@@ -552,6 +568,10 @@ const App = () => {
552
568
  : APP_MODES.BROWSE;
553
569
 
554
570
  useEffect(() => {
571
+ if (isSchemaCheckInProgress) {
572
+ return undefined;
573
+ }
574
+
555
575
  let isCancelled = false;
556
576
 
557
577
  const loadVersionStatus = async () => {
@@ -570,9 +590,56 @@ const App = () => {
570
590
  await ensureLatestConfiguratorVersion(commands.npmCommand);
571
591
  };
572
592
 
593
+ setCodexVersionStatus('Checking Codex version');
573
594
  loadVersionStatus();
574
595
  ensureLatestConfigurator();
575
596
 
597
+ return () => {
598
+ isCancelled = true;
599
+ };
600
+ }, [isSchemaCheckInProgress]);
601
+
602
+ useEffect(() => {
603
+ let isCancelled = false;
604
+
605
+ const syncSchemaReference = async () => {
606
+ const checkStartedAt = Date.now();
607
+ const updateSchemaStatus = (status) => {
608
+ if (isCancelled) {
609
+ return;
610
+ }
611
+
612
+ setSchemaCheckStatusText(status);
613
+ };
614
+
615
+ await syncReferenceSchemaAtStartup({
616
+ mainConfigPath: initialMainSnapshot.path,
617
+ onSchemaChange: () => {
618
+ if (isCancelled) {
619
+ return;
620
+ }
621
+
622
+ resetSchemaDerivedEditState();
623
+ },
624
+ onStatus: updateSchemaStatus,
625
+ });
626
+
627
+ const elapsed = Date.now() - checkStartedAt;
628
+ const remainingVisibleMs = SCHEMA_STATUS_MIN_VISIBLE_MS - elapsed;
629
+ if (remainingVisibleMs > 0) {
630
+ await waitForMilliseconds(remainingVisibleMs);
631
+ }
632
+
633
+ if (isCancelled) {
634
+ return;
635
+ }
636
+
637
+ setSchemaCheckStatusText('');
638
+ setIsSchemaCheckInProgress(false);
639
+ };
640
+
641
+ syncSchemaReference();
642
+
576
643
  return () => {
577
644
  isCancelled = true;
578
645
  };
@@ -1447,6 +1514,8 @@ const App = () => {
1447
1514
  React.createElement(StatusLine, {
1448
1515
  codexVersion,
1449
1516
  codexVersionStatus,
1517
+ isSchemaCheckInProgress,
1518
+ schemaCheckStatusText,
1450
1519
  activeConfigFile,
1451
1520
  appMode,
1452
1521
  }),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-configurator",
3
- "version": "0.2.5",
3
+ "version": "0.2.6",
4
4
  "description": "TOML-aware Ink TUI for Codex Configurator",
5
5
  "main": "index.js",
6
6
  "type": "module",
package/src/appState.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export const APP_STATE_ACTION = 'APP_STATE_ACTION';
2
+ export const REFERENCE_SCHEMA_CHANGED_ACTION = 'REFERENCE_SCHEMA_CHANGED_ACTION';
2
3
 
3
4
  export const APP_MODES = {
4
5
  BROWSE: 'browse',
@@ -9,6 +10,14 @@ export const APP_MODES = {
9
10
  };
10
11
 
11
12
  export const appStateReducer = (state, action) => {
13
+ if (action.type === REFERENCE_SCHEMA_CHANGED_ACTION) {
14
+ return {
15
+ ...state,
16
+ editMode: null,
17
+ editError: '',
18
+ };
19
+ }
20
+
12
21
  if (action.type !== APP_STATE_ACTION) {
13
22
  return state;
14
23
  }
@@ -66,4 +75,6 @@ export const buildInitialAppState = (initialMainSnapshot, initialCatalog, initia
66
75
  showHelp: false,
67
76
  codexVersion: 'version loading...',
68
77
  codexVersionStatus: '',
78
+ isSchemaCheckInProgress: true,
79
+ schemaCheckStatusText: 'Checking schema',
69
80
  });
@@ -26,9 +26,4 @@ export const Header = ({ packageVersion }) =>
26
26
  `v${packageVersion || 'unknown'}`,
27
27
  ),
28
28
  ),
29
- React.createElement(
30
- Text,
31
- { color: 'white', dimColor: false },
32
- 'TUI Mode ',
33
- ),
34
29
  );
@@ -35,25 +35,24 @@ const buildFeatureDefinitionFromReference = (key) => {
35
35
  };
36
36
  };
37
37
 
38
- const DOCUMENTED_REFERENCE_FEATURE_KEYS = getReferenceFeatureKeys();
39
-
40
- export const CONFIG_FEATURE_DEFINITIONS = DOCUMENTED_REFERENCE_FEATURE_KEYS
41
- .map((key) => buildFeatureDefinitionFromReference(key))
42
- .filter(Boolean);
43
-
44
- const FEATURE_DEFINITION_MAP = CONFIG_FEATURE_DEFINITIONS.reduce((acc, definition) => {
45
- acc[definition.key] = definition;
46
-
47
- return acc;
48
- }, {});
38
+ const buildFeatureDefinitionMap = () =>
39
+ getReferenceFeatureKeys()
40
+ .map((key) => buildFeatureDefinitionFromReference(key))
41
+ .filter(Boolean)
42
+ .reduce((acc, definition) => {
43
+ acc[definition.key] = definition;
44
+ return acc;
45
+ }, {});
49
46
 
50
47
  export const getConfigFeatureKeys = () => {
51
- return DOCUMENTED_REFERENCE_FEATURE_KEYS;
48
+ return getReferenceFeatureKeys();
52
49
  };
53
50
 
54
- export const getConfigFeatureDefinition = (key) => FEATURE_DEFINITION_MAP[key];
51
+ export const getConfigFeatureDefinition = (key) =>
52
+ buildFeatureDefinitionMap()[String(key || '').trim()];
55
53
 
56
54
  export const getConfigFeatureDefinitionOrFallback = (key) => {
55
+ const normalizedKey = String(key || '').trim();
57
56
  if (!key) {
58
57
  return {
59
58
  short: `${prettifyFeatureName(String(key))}`,
@@ -64,9 +63,9 @@ export const getConfigFeatureDefinitionOrFallback = (key) => {
64
63
  }
65
64
 
66
65
  return (
67
- FEATURE_DEFINITION_MAP[key] || {
68
- key,
69
- short: prettifyFeatureName(String(key)),
66
+ getConfigFeatureDefinition(normalizedKey) || {
67
+ key: normalizedKey,
68
+ short: prettifyFeatureName(normalizedKey),
70
69
  usage: 'This configured key is not in the official feature list.',
71
70
  defaultValue: false,
72
71
  isDocumented: false,
@@ -1,7 +1,7 @@
1
1
  import { createRequire } from 'node:module';
2
2
 
3
3
  const require = createRequire(import.meta.url);
4
- const CONFIG_SCHEMA_DATA = require('./reference/config-schema.json');
4
+ const BUNDLED_CONFIG_SCHEMA_DATA = require('./reference/config-schema.json');
5
5
 
6
6
  const ROOT_PLACEHOLDER = '<name>';
7
7
  const PLACEHOLDER_SEGMENT = /^<[^>]+>$/;
@@ -38,6 +38,8 @@ const normalizeType = (type) => String(type || '').replace(/\s+/g, ' ').trim();
38
38
  const isObject = (value) =>
39
39
  value !== null && typeof value === 'object' && !Array.isArray(value);
40
40
 
41
+ const buildSchemaRevision = (schema) => JSON.stringify(schema);
42
+
41
43
  const isPrimitiveValue = (type) =>
42
44
  type === 'string' || type === 'number' || type === 'integer' || type === 'boolean';
43
45
 
@@ -966,9 +968,62 @@ const fullPathMatches = (referencePath, actualPath) =>
966
968
  const countConcreteSegments = (segments) =>
967
969
  segments.reduce((count, segment) => count + (isPlaceholderSegment(segment) ? 0 : 1), 0);
968
970
 
969
- const referenceOptions = buildReferenceOptions(CONFIG_SCHEMA_DATA);
971
+ const buildFeatureKeys = (referenceOptions) => [
972
+ ...new Set(
973
+ referenceOptions
974
+ .filter(
975
+ (option) =>
976
+ option.keyPath.length === 2 &&
977
+ option.keyPath[0] === 'features' &&
978
+ !isPlaceholderSegment(option.keyPath[1])
979
+ )
980
+ .map((option) => option.keyPath[1])
981
+ ),
982
+ ].sort((left, right) => left.localeCompare(right));
983
+
984
+ const prepareReferenceState = (schema) => {
985
+ if (!isObject(schema)) {
986
+ return null;
987
+ }
988
+
989
+ const referenceOptions = buildReferenceOptions(schema);
990
+ if (!Array.isArray(referenceOptions) || referenceOptions.length === 0) {
991
+ return null;
992
+ }
993
+
994
+ return {
995
+ schema,
996
+ revision: buildSchemaRevision(schema),
997
+ referenceOptions,
998
+ optionsByKey: new Map(referenceOptions.map((option) => [option.key, option])),
999
+ featureKeys: buildFeatureKeys(referenceOptions),
1000
+ };
1001
+ };
1002
+
1003
+ let activeReferenceState = prepareReferenceState(BUNDLED_CONFIG_SCHEMA_DATA);
1004
+ if (!activeReferenceState) {
1005
+ throw new Error('Bundled config schema is invalid.');
1006
+ }
970
1007
 
971
- const optionsByKey = new Map(referenceOptions.map((option) => [option.key, option]));
1008
+ const getReferenceState = () => activeReferenceState;
1009
+
1010
+ export const isReferenceSchemaValid = (schema) => Boolean(prepareReferenceState(schema));
1011
+
1012
+ export const getReferenceSchemaRevision = () => getReferenceState().revision;
1013
+
1014
+ export const setReferenceSchema = (schema) => {
1015
+ const nextState = prepareReferenceState(schema);
1016
+ if (!nextState) {
1017
+ return false;
1018
+ }
1019
+
1020
+ if (activeReferenceState?.revision === nextState.revision) {
1021
+ return true;
1022
+ }
1023
+
1024
+ activeReferenceState = nextState;
1025
+ return true;
1026
+ };
972
1027
 
973
1028
  const mergeDefinition = (map, definition) => {
974
1029
  const existing = map.get(definition.key);
@@ -988,6 +1043,7 @@ const mergeDefinition = (map, definition) => {
988
1043
  };
989
1044
 
990
1045
  export const getReferenceOptionForPath = (pathSegments) => {
1046
+ const { optionsByKey, referenceOptions } = getReferenceState();
991
1047
  const normalizedPath = normalizeSegments(pathSegments);
992
1048
  if (normalizedPath.length === 0) {
993
1049
  return null;
@@ -1016,7 +1072,7 @@ export const getReferenceOptionForPath = (pathSegments) => {
1016
1072
  return candidates[0];
1017
1073
  };
1018
1074
 
1019
- const getVariantObjectPaths = (normalizedPath) => {
1075
+ const getVariantObjectPaths = (normalizedPath, referenceOptions) => {
1020
1076
  const depth = normalizedPath.length;
1021
1077
  const paths = referenceOptions
1022
1078
  .filter((option) => pathPrefixMatches(option.keyPath, normalizedPath))
@@ -1028,6 +1084,7 @@ const getVariantObjectPaths = (normalizedPath) => {
1028
1084
  };
1029
1085
 
1030
1086
  export const getReferenceVariantForPath = (pathSegments = []) => {
1087
+ const { referenceOptions } = getReferenceState();
1031
1088
  const normalizedPath = normalizeSegments(pathSegments);
1032
1089
  if (normalizedPath.length === 0) {
1033
1090
  return null;
@@ -1061,11 +1118,12 @@ export const getReferenceVariantForPath = (pathSegments = []) => {
1061
1118
  : {},
1062
1119
  }))
1063
1120
  : [],
1064
- objectSchemaPaths: getVariantObjectPaths(normalizedPath),
1121
+ objectSchemaPaths: getVariantObjectPaths(normalizedPath, referenceOptions),
1065
1122
  };
1066
1123
  };
1067
1124
 
1068
1125
  export const getReferenceTableDefinitions = (pathSegments = []) => {
1126
+ const { referenceOptions } = getReferenceState();
1069
1127
  const normalizedPath = normalizeSegments(pathSegments);
1070
1128
  const childDefinitions = new Map();
1071
1129
  const depth = normalizedPath.length;
@@ -1099,22 +1157,10 @@ export const getReferenceTableDefinitions = (pathSegments = []) => {
1099
1157
 
1100
1158
  export const getReferenceRootDefinitions = () => getReferenceTableDefinitions([]);
1101
1159
 
1102
- const featureKeys = [
1103
- ...new Set(
1104
- referenceOptions
1105
- .filter(
1106
- (option) =>
1107
- option.keyPath.length === 2 &&
1108
- option.keyPath[0] === 'features' &&
1109
- !isPlaceholderSegment(option.keyPath[1])
1110
- )
1111
- .map((option) => option.keyPath[1])
1112
- ),
1113
- ].sort((left, right) => left.localeCompare(right));
1114
-
1115
- export const getReferenceFeatureKeys = () => featureKeys;
1160
+ export const getReferenceFeatureKeys = () => [...getReferenceState().featureKeys];
1116
1161
 
1117
1162
  export const getReferenceCustomIdPlaceholder = (pathSegments = []) => {
1163
+ const { referenceOptions } = getReferenceState();
1118
1164
  const normalizedPath = normalizeSegments(pathSegments);
1119
1165
  const depth = normalizedPath.length;
1120
1166
  const placeholders = new Set();
@@ -1139,6 +1185,7 @@ export const getReferenceCustomIdPlaceholder = (pathSegments = []) => {
1139
1185
  };
1140
1186
 
1141
1187
  export const getReferenceDescendantOptions = (pathSegments = []) => {
1188
+ const { referenceOptions } = getReferenceState();
1142
1189
  const normalizedPath = normalizeSegments(pathSegments);
1143
1190
 
1144
1191
  return referenceOptions
@@ -0,0 +1,282 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import {
5
+ getReferenceSchemaRevision,
6
+ setReferenceSchema,
7
+ } from './configReference.js';
8
+ import { logConfiguratorError } from './errorLogger.js';
9
+
10
+ const CONFIG_SCHEMA_URL = 'https://developers.openai.com/codex/config-schema.json';
11
+ const SCHEMA_CACHE_DIRECTORY_NAME = 'codex-configurator-cache';
12
+ const SCHEMA_CACHE_FILE_NAME = 'config-schema.json';
13
+ const SCHEMA_METADATA_FILE_NAME = 'config-schema.meta.json';
14
+ const FETCH_TIMEOUT_MS = 10000;
15
+ const MAX_SCHEMA_BYTES = 512 * 1024;
16
+
17
+ const isObject = (value) =>
18
+ value !== null && typeof value === 'object' && !Array.isArray(value);
19
+
20
+ const normalizeText = (value) => String(value || '').trim();
21
+
22
+ const resolveCodexDirectory = ({ mainConfigPath = '', homeDir = os.homedir() } = {}) => {
23
+ const normalizedPath = normalizeText(mainConfigPath);
24
+ if (!normalizedPath) {
25
+ return path.join(homeDir, '.codex');
26
+ }
27
+
28
+ return path.dirname(path.resolve(normalizedPath));
29
+ };
30
+
31
+ export const resolveSchemaCachePaths = ({ mainConfigPath = '', homeDir = os.homedir() } = {}) => {
32
+ const codexDirectory = resolveCodexDirectory({ mainConfigPath, homeDir });
33
+ const cacheDirectory = path.join(codexDirectory, SCHEMA_CACHE_DIRECTORY_NAME);
34
+ return {
35
+ codexDirectory,
36
+ cacheDirectory,
37
+ schemaPath: path.join(cacheDirectory, SCHEMA_CACHE_FILE_NAME),
38
+ metadataPath: path.join(cacheDirectory, SCHEMA_METADATA_FILE_NAME),
39
+ };
40
+ };
41
+
42
+ const readJsonFile = (targetPath) => {
43
+ try {
44
+ const payload = fs.readFileSync(targetPath, 'utf8');
45
+ return JSON.parse(payload);
46
+ } catch {
47
+ return null;
48
+ }
49
+ };
50
+
51
+ const writeJsonFileAtomic = (targetPath, payload) => {
52
+ const directoryPath = path.dirname(targetPath);
53
+ const fileName = path.basename(targetPath);
54
+ const tempPath = path.join(
55
+ directoryPath,
56
+ `.${fileName}.${process.pid}.${Date.now()}.tmp`
57
+ );
58
+
59
+ if (!fs.existsSync(directoryPath)) {
60
+ fs.mkdirSync(directoryPath, { recursive: true, mode: 0o700 });
61
+ }
62
+
63
+ let fileDescriptor = null;
64
+
65
+ try {
66
+ fileDescriptor = fs.openSync(tempPath, 'wx', 0o600);
67
+ fs.writeFileSync(fileDescriptor, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
68
+ fs.fsyncSync(fileDescriptor);
69
+ fs.closeSync(fileDescriptor);
70
+ fileDescriptor = null;
71
+ fs.renameSync(tempPath, targetPath);
72
+ } finally {
73
+ if (fileDescriptor !== null) {
74
+ try {
75
+ fs.closeSync(fileDescriptor);
76
+ } catch {}
77
+ }
78
+
79
+ if (fs.existsSync(tempPath)) {
80
+ try {
81
+ fs.unlinkSync(tempPath);
82
+ } catch {}
83
+ }
84
+ }
85
+ };
86
+
87
+ const applySchema = (schema) => {
88
+ const previousRevision = getReferenceSchemaRevision();
89
+ const ok = setReferenceSchema(schema);
90
+ if (!ok) {
91
+ return {
92
+ ok: false,
93
+ changed: false,
94
+ };
95
+ }
96
+
97
+ return {
98
+ ok: true,
99
+ changed: previousRevision !== getReferenceSchemaRevision(),
100
+ };
101
+ };
102
+
103
+ const buildConditionalHeaders = (metadata = {}) => {
104
+ const headers = {
105
+ accept: 'application/json',
106
+ };
107
+ const etag = normalizeText(metadata.etag);
108
+ const lastModified = normalizeText(metadata.lastModified);
109
+
110
+ if (etag) {
111
+ headers['if-none-match'] = etag;
112
+ }
113
+
114
+ if (lastModified) {
115
+ headers['if-modified-since'] = lastModified;
116
+ }
117
+
118
+ return headers;
119
+ };
120
+
121
+ const fetchRemoteSchema = async ({
122
+ fetchImpl = fetch,
123
+ metadata = {},
124
+ }) => {
125
+ const controller = new AbortController();
126
+ const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
127
+
128
+ try {
129
+ const response = await fetchImpl(CONFIG_SCHEMA_URL, {
130
+ headers: buildConditionalHeaders(metadata),
131
+ signal: controller.signal,
132
+ });
133
+
134
+ if (response.status === 304) {
135
+ return {
136
+ status: 'not-modified',
137
+ };
138
+ }
139
+
140
+ if (!response.ok) {
141
+ throw new Error(
142
+ `Schema request failed (${response.status} ${response.statusText})`
143
+ );
144
+ }
145
+
146
+ const contentType = normalizeText(response.headers.get('content-type')).toLowerCase();
147
+ if (!contentType.includes('application/json')) {
148
+ throw new Error('Schema response content-type is not application/json.');
149
+ }
150
+
151
+ const bytes = Buffer.from(await response.arrayBuffer());
152
+ if (bytes.length > MAX_SCHEMA_BYTES) {
153
+ throw new Error(
154
+ `Schema response exceeded max size (${bytes.length} bytes > ${MAX_SCHEMA_BYTES} bytes).`
155
+ );
156
+ }
157
+
158
+ let parsedSchema = null;
159
+ try {
160
+ parsedSchema = JSON.parse(bytes.toString('utf8'));
161
+ } catch (error) {
162
+ throw new Error(
163
+ `Schema response was not valid JSON (${String(error?.message || 'parse failed')}).`
164
+ );
165
+ }
166
+
167
+ return {
168
+ status: 'updated',
169
+ schema: parsedSchema,
170
+ metadata: {
171
+ etag: normalizeText(response.headers.get('etag')) || null,
172
+ lastModified: normalizeText(response.headers.get('last-modified')) || null,
173
+ },
174
+ };
175
+ } finally {
176
+ clearTimeout(timeoutId);
177
+ }
178
+ };
179
+
180
+ export const syncReferenceSchemaAtStartup = async ({
181
+ mainConfigPath = '',
182
+ fetchImpl = fetch,
183
+ onSchemaChange,
184
+ onStatus,
185
+ } = {}) => {
186
+ const cachePaths = resolveSchemaCachePaths({ mainConfigPath });
187
+ const notifySchemaChange = (source) => {
188
+ if (typeof onSchemaChange === 'function') {
189
+ onSchemaChange({ source });
190
+ }
191
+ };
192
+ const updateStatus = (value) => {
193
+ if (typeof onStatus === 'function') {
194
+ onStatus(String(value || '').trim());
195
+ }
196
+ };
197
+
198
+ let source = 'bundled';
199
+ let canReuseCachedValidators = false;
200
+
201
+ try {
202
+ updateStatus('Schema: Loading cache...');
203
+
204
+ const cachedSchema = readJsonFile(cachePaths.schemaPath);
205
+ const metadataPayload = readJsonFile(cachePaths.metadataPath);
206
+ const cachedMetadata = isObject(metadataPayload) ? metadataPayload : {};
207
+
208
+ if (cachedSchema !== null) {
209
+ const cacheApplyResult = applySchema(cachedSchema);
210
+ if (cacheApplyResult.ok) {
211
+ source = 'cache';
212
+ canReuseCachedValidators = true;
213
+ if (cacheApplyResult.changed) {
214
+ notifySchemaChange('cache');
215
+ }
216
+ } else {
217
+ logConfiguratorError('schema.cache.invalid', {
218
+ schemaPath: cachePaths.schemaPath,
219
+ });
220
+ }
221
+ }
222
+
223
+ updateStatus('Schema: Checking upstream...');
224
+
225
+ const fetchResult = await fetchRemoteSchema({
226
+ fetchImpl,
227
+ metadata: canReuseCachedValidators ? cachedMetadata : {},
228
+ });
229
+
230
+ if (fetchResult.status === 'not-modified') {
231
+ return {
232
+ ok: true,
233
+ updated: false,
234
+ source,
235
+ };
236
+ }
237
+
238
+ const remoteApplyResult = applySchema(fetchResult.schema);
239
+ if (!remoteApplyResult.ok) {
240
+ throw new Error('Downloaded schema did not pass local validation.');
241
+ }
242
+ if (remoteApplyResult.changed) {
243
+ notifySchemaChange('remote');
244
+ }
245
+
246
+ updateStatus('Schema: Saving cache...');
247
+
248
+ const mergedMetadata = {
249
+ etag: fetchResult.metadata.etag || normalizeText(cachedMetadata.etag) || null,
250
+ lastModified:
251
+ fetchResult.metadata.lastModified ||
252
+ normalizeText(cachedMetadata.lastModified) ||
253
+ null,
254
+ updatedAt: new Date().toISOString(),
255
+ sourceUrl: CONFIG_SCHEMA_URL,
256
+ };
257
+
258
+ writeJsonFileAtomic(cachePaths.schemaPath, fetchResult.schema);
259
+ writeJsonFileAtomic(cachePaths.metadataPath, mergedMetadata);
260
+
261
+ return {
262
+ ok: true,
263
+ updated: true,
264
+ source: 'remote',
265
+ };
266
+ } catch (error) {
267
+ const message = String(error?.message || 'Unknown schema sync error.');
268
+ logConfiguratorError('schema.sync.failed', {
269
+ error: message,
270
+ source,
271
+ schemaPath: cachePaths.schemaPath,
272
+ metadataPath: cachePaths.metadataPath,
273
+ });
274
+
275
+ return {
276
+ ok: false,
277
+ updated: false,
278
+ source,
279
+ error: message,
280
+ };
281
+ }
282
+ };
@@ -2,18 +2,57 @@ import React from 'react';
2
2
  import { Box, Text } from 'ink';
3
3
  import { formatActiveFileSummary } from '../../layout.js';
4
4
 
5
+ const SEMVER_PREFIX_PATTERN = /^\d+\.\d+\.\d+(?:[-+._][0-9A-Za-z.-]+)*$/;
6
+ const CHECKING_BADGE_TEXT_COLOR = 'white';
7
+ const CHECKING_BADGE_BACKGROUND_COLOR = 'green';
8
+
9
+ const formatCodexVersion = (version) => {
10
+ const versionText = String(version || '').trim();
11
+ if (!versionText) {
12
+ return '—';
13
+ }
14
+
15
+ return SEMVER_PREFIX_PATTERN.test(versionText)
16
+ ? `v${versionText}`
17
+ : versionText;
18
+ };
19
+
5
20
  export const StatusLine = ({
6
21
  codexVersion,
7
22
  codexVersionStatus,
23
+ isSchemaCheckInProgress,
24
+ schemaCheckStatusText,
8
25
  activeConfigFile,
9
26
  appMode,
10
27
  }) => {
11
- const statusText = codexVersionStatus || 'checking...';
12
- const isUpToDate = statusText === 'up to date';
28
+ const codexStatusText = codexVersionStatus || 'Checking Codex version';
29
+ const schemaStatus =
30
+ String(schemaCheckStatusText || '').trim() || 'Checking schema';
31
+ const statusText = codexStatusText;
32
+ const isUpToDate = !isSchemaCheckInProgress && statusText === 'Up to date';
33
+ const isCodexChecking =
34
+ !isSchemaCheckInProgress &&
35
+ String(statusText).trim() === 'Checking Codex version';
13
36
  const isUpdateAvailable =
37
+ !isSchemaCheckInProgress &&
14
38
  typeof statusText === 'string' &&
15
- statusText.startsWith('update available');
16
- const statusPrefix = isUpToDate ? '✓' : isUpdateAvailable ? '⚠️' : '';
39
+ statusText.startsWith('Update available');
40
+ const shouldShowCodexStatus =
41
+ !isSchemaCheckInProgress && !isUpToDate && !isCodexChecking;
42
+ const statusPrefix = isUpdateAvailable ? '⚠️ ' : '';
43
+ const statusTextColor = isUpToDate ? 'white' : 'black';
44
+ const statusBackgroundColor = isUpToDate ? undefined : 'yellow';
45
+ const isCheckingLabelVisible = isSchemaCheckInProgress || isCodexChecking;
46
+ const versionText = isCheckingLabelVisible
47
+ ? ` ${isSchemaCheckInProgress ? schemaStatus : codexStatusText} `
48
+ : `Using Codex ${formatCodexVersion(codexVersion)}`;
49
+ const versionTextColor = isCheckingLabelVisible
50
+ ? CHECKING_BADGE_TEXT_COLOR
51
+ : 'white';
52
+ const versionTextBackgroundColor = isCheckingLabelVisible
53
+ ? CHECKING_BADGE_BACKGROUND_COLOR
54
+ : undefined;
55
+ const codexStatusTextLabel = `${statusText} `;
17
56
  const activeFileSummary = formatActiveFileSummary(activeConfigFile);
18
57
 
19
58
  return React.createElement(
@@ -47,34 +86,38 @@ export const StatusLine = ({
47
86
  { flexDirection: 'row', gap: 2 },
48
87
  React.createElement(
49
88
  Text,
50
- { color: 'white' },
51
- `Codex installed: ${codexVersion || '—'}`,
89
+ {
90
+ color: versionTextColor,
91
+ backgroundColor: versionTextBackgroundColor,
92
+ bold: isCheckingLabelVisible,
93
+ },
94
+ versionText,
52
95
  ),
53
- React.createElement(
54
- Box,
55
- { flexDirection: 'row' },
56
- statusPrefix
57
- ? React.createElement(
96
+ shouldShowCodexStatus
97
+ ? React.createElement(
98
+ Box,
99
+ { flexDirection: 'row' },
100
+ statusPrefix
101
+ ? React.createElement(
102
+ Text,
103
+ {
104
+ color: statusTextColor,
105
+ backgroundColor: statusBackgroundColor,
106
+ },
107
+ ` ${statusPrefix} `,
108
+ )
109
+ : null,
110
+ React.createElement(
58
111
  Text,
59
112
  {
60
- color: isUpToDate ? 'white' : 'black',
61
- backgroundColor: isUpToDate
62
- ? undefined
63
- : 'yellow',
113
+ color: statusTextColor,
114
+ backgroundColor: statusBackgroundColor,
115
+ bold: true,
64
116
  },
65
- ` ${statusPrefix} `,
66
- )
67
- : null,
68
- React.createElement(
69
- Text,
70
- {
71
- color: isUpToDate ? 'white' : 'black',
72
- backgroundColor: isUpToDate ? undefined : 'yellow',
73
- bold: true,
74
- },
75
- `${statusText} `,
76
- ),
77
- ),
117
+ codexStatusTextLabel,
118
+ ),
119
+ )
120
+ : null,
78
121
  ),
79
122
  );
80
123
  };