@getrouter/getrouter-cli 0.1.10 → 0.1.11
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.ja.md +1 -0
- package/README.md +9 -0
- package/README.zh-cn.md +1 -0
- package/dist/bin.mjs +176 -49
- package/package.json +1 -1
- package/src/cmd/codex.ts +112 -3
- package/src/core/setup/codex.ts +200 -47
- package/tests/cmd/codex.test.ts +100 -0
- package/tests/core/setup/codex.test.ts +87 -1
package/README.ja.md
CHANGED
|
@@ -86,6 +86,7 @@ getrouter codex uninstall
|
|
|
86
86
|
|
|
87
87
|
- `~/.codex/config.toml`(model + reasoning + provider 設定)
|
|
88
88
|
- `~/.codex/auth.json`(OPENAI_API_KEY)
|
|
89
|
+
- `~/.getrouter/codex-backup.json`(`getrouter codex uninstall` 用のバックアップ。uninstall で削除)
|
|
89
90
|
|
|
90
91
|
`getrouter claude` は Anthropic 互換の環境変数を `~/.getrouter/env.sh`(または `env.ps1`)へ書き込みます。
|
|
91
92
|
|
package/README.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# GetRouter CLI
|
|
2
2
|
|
|
3
|
+
[](https://github.com/getrouter/getrouter-cli/actions/workflows/ci.yml)
|
|
4
|
+
[](https://www.npmjs.com/package/@getrouter/getrouter-cli)
|
|
5
|
+
[](https://www.npmjs.com/package/@getrouter/getrouter-cli)
|
|
6
|
+
[](https://www.npmjs.com/package/@getrouter/getrouter-cli)
|
|
7
|
+
[](https://bun.sh)
|
|
8
|
+
|
|
3
9
|
CLI for getrouter.dev — manage API keys, subscriptions, and configure vibecoding tools.
|
|
4
10
|
|
|
5
11
|
English | [简体中文](README.zh-cn.md) | [日本語](README.ja.md)
|
|
@@ -86,6 +92,7 @@ Files written (codex):
|
|
|
86
92
|
|
|
87
93
|
- `~/.codex/config.toml` (model + reasoning + provider settings)
|
|
88
94
|
- `~/.codex/auth.json` (OPENAI_API_KEY)
|
|
95
|
+
- `~/.getrouter/codex-backup.json` (backup for `getrouter codex uninstall`; deleted on uninstall)
|
|
89
96
|
|
|
90
97
|
`getrouter claude` writes Anthropic-compatible env vars to `~/.getrouter/env.sh` (or `env.ps1`).
|
|
91
98
|
|
|
@@ -121,3 +128,5 @@ Edit `~/.getrouter/config.json` directly to update CLI settings.
|
|
|
121
128
|
- `bun run format` — format and lint code with Biome
|
|
122
129
|
- `bun run test` — run the test suite
|
|
123
130
|
- `bun run typecheck` — run TypeScript type checks
|
|
131
|
+
|
|
132
|
+
[](https://www.npmjs.com/package/@getrouter/getrouter-cli)
|
package/README.zh-cn.md
CHANGED
|
@@ -86,6 +86,7 @@ getrouter codex uninstall
|
|
|
86
86
|
|
|
87
87
|
- `~/.codex/config.toml`(model + reasoning + provider 设置)
|
|
88
88
|
- `~/.codex/auth.json`(OPENAI_API_KEY)
|
|
89
|
+
- `~/.getrouter/codex-backup.json`(用于 `getrouter codex uninstall` 的备份;卸载后会删除)
|
|
89
90
|
|
|
90
91
|
`getrouter claude` 写入 Anthropic 兼容环境变量到 `~/.getrouter/env.sh`(或 `env.ps1`)。
|
|
91
92
|
|
package/dist/bin.mjs
CHANGED
|
@@ -1011,6 +1011,15 @@ const mapReasoningValue = (id) => REASONING_CHOICES.find((choice) => choice.id =
|
|
|
1011
1011
|
//#region src/core/setup/codex.ts
|
|
1012
1012
|
const CODEX_PROVIDER = "getrouter";
|
|
1013
1013
|
const CODEX_BASE_URL = "https://api.getrouter.dev/codex";
|
|
1014
|
+
const LEGACY_TOML_ROOT_MARKERS = [
|
|
1015
|
+
"_getrouter_codex_backup_model",
|
|
1016
|
+
"_getrouter_codex_backup_model_reasoning_effort",
|
|
1017
|
+
"_getrouter_codex_backup_model_provider",
|
|
1018
|
+
"_getrouter_codex_installed_model",
|
|
1019
|
+
"_getrouter_codex_installed_model_reasoning_effort",
|
|
1020
|
+
"_getrouter_codex_installed_model_provider"
|
|
1021
|
+
];
|
|
1022
|
+
const LEGACY_AUTH_MARKERS = ["_getrouter_codex_backup_openai_api_key", "_getrouter_codex_installed_openai_api_key"];
|
|
1014
1023
|
const ROOT_KEYS = [
|
|
1015
1024
|
"model",
|
|
1016
1025
|
"model_reasoning_effort",
|
|
@@ -1036,9 +1045,56 @@ const providerValues = () => ({
|
|
|
1036
1045
|
});
|
|
1037
1046
|
const matchHeader = (line) => line.match(/^\s*\[([^\]]+)\]\s*$/);
|
|
1038
1047
|
const matchKey = (line) => line.match(/^\s*([A-Za-z0-9_.-]+)\s*=/);
|
|
1039
|
-
const
|
|
1048
|
+
const parseTomlRhsValue = (rhs) => {
|
|
1049
|
+
const trimmed = rhs.trim();
|
|
1050
|
+
if (!trimmed) return "";
|
|
1051
|
+
const first = trimmed[0];
|
|
1052
|
+
if (first === "\"" || first === "'") {
|
|
1053
|
+
const end = trimmed.indexOf(first, 1);
|
|
1054
|
+
return end === -1 ? trimmed : trimmed.slice(0, end + 1);
|
|
1055
|
+
}
|
|
1056
|
+
const hashIndex = trimmed.indexOf("#");
|
|
1057
|
+
return (hashIndex === -1 ? trimmed : trimmed.slice(0, hashIndex)).trim();
|
|
1058
|
+
};
|
|
1059
|
+
const readRootValue = (lines, key) => {
|
|
1060
|
+
for (const line of lines) {
|
|
1061
|
+
if (matchHeader(line)) break;
|
|
1062
|
+
if (matchKey(line)?.[1] === key) {
|
|
1063
|
+
const parts = line.split("=");
|
|
1064
|
+
parts.shift();
|
|
1065
|
+
return parseTomlRhsValue(parts.join("="));
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
};
|
|
1069
|
+
const readCodexTomlRootValues = (content) => {
|
|
1070
|
+
const lines = content.length ? content.split(/\r?\n/) : [];
|
|
1071
|
+
return {
|
|
1072
|
+
model: readRootValue(lines, "model"),
|
|
1073
|
+
reasoning: readRootValue(lines, "model_reasoning_effort"),
|
|
1074
|
+
provider: readRootValue(lines, "model_provider")
|
|
1075
|
+
};
|
|
1076
|
+
};
|
|
1077
|
+
const normalizeTomlString = (value) => {
|
|
1078
|
+
if (!value) return "";
|
|
1079
|
+
const trimmed = value.trim();
|
|
1080
|
+
if (trimmed.startsWith("\"") && trimmed.endsWith("\"") || trimmed.startsWith("'") && trimmed.endsWith("'")) return trimmed.slice(1, -1).trim().toLowerCase();
|
|
1081
|
+
return trimmed.replace(/['"]/g, "").trim().toLowerCase();
|
|
1082
|
+
};
|
|
1083
|
+
const stripLegacyRootMarkers = (lines) => {
|
|
1084
|
+
const updated = [];
|
|
1085
|
+
let inRoot = true;
|
|
1086
|
+
for (const line of lines) {
|
|
1087
|
+
if (matchHeader(line)) inRoot = false;
|
|
1088
|
+
if (inRoot) {
|
|
1089
|
+
const key = matchKey(line)?.[1];
|
|
1090
|
+
if (key && LEGACY_TOML_ROOT_MARKERS.includes(key)) continue;
|
|
1091
|
+
}
|
|
1092
|
+
updated.push(line);
|
|
1093
|
+
}
|
|
1094
|
+
return updated;
|
|
1095
|
+
};
|
|
1040
1096
|
const mergeCodexToml = (content, input) => {
|
|
1041
|
-
const updated = [...content.length ? content.split(/\r?\n/) : []];
|
|
1097
|
+
const updated = [...stripLegacyRootMarkers(content.length ? content.split(/\r?\n/) : [])];
|
|
1042
1098
|
const rootValueMap = rootValues(input);
|
|
1043
1099
|
const providerValueMap = providerValues();
|
|
1044
1100
|
let currentSection = null;
|
|
@@ -1093,10 +1149,12 @@ const mergeCodexToml = (content, input) => {
|
|
|
1093
1149
|
if (missingProvider.length > 0) updated.splice(providerEnd, 0, ...missingProvider);
|
|
1094
1150
|
return updated.join("\n");
|
|
1095
1151
|
};
|
|
1096
|
-
const mergeAuthJson = (data, apiKey) =>
|
|
1097
|
-
...data
|
|
1098
|
-
|
|
1099
|
-
|
|
1152
|
+
const mergeAuthJson = (data, apiKey) => {
|
|
1153
|
+
const next = { ...data };
|
|
1154
|
+
for (const key of LEGACY_AUTH_MARKERS) if (key in next) delete next[key];
|
|
1155
|
+
next.OPENAI_API_KEY = apiKey;
|
|
1156
|
+
return next;
|
|
1157
|
+
};
|
|
1100
1158
|
const stripGetrouterProviderSection = (lines) => {
|
|
1101
1159
|
const updated = [];
|
|
1102
1160
|
let skipSection = false;
|
|
@@ -1114,61 +1172,82 @@ const stripGetrouterProviderSection = (lines) => {
|
|
|
1114
1172
|
}
|
|
1115
1173
|
return updated;
|
|
1116
1174
|
};
|
|
1117
|
-
const
|
|
1118
|
-
const
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
}
|
|
1127
|
-
if (currentSection === null) {
|
|
1128
|
-
if (/^\s*model\s*=/.test(line)) continue;
|
|
1129
|
-
if (/^\s*model_reasoning_effort\s*=/.test(line)) continue;
|
|
1130
|
-
if (/^\s*model_provider\s*=/.test(line)) continue;
|
|
1131
|
-
}
|
|
1132
|
-
updated.push(line);
|
|
1175
|
+
const stripLegacyMarkersFromRoot = (rootLines) => rootLines.filter((line) => {
|
|
1176
|
+
const key = matchKey(line)?.[1];
|
|
1177
|
+
return !(key && LEGACY_TOML_ROOT_MARKERS.includes(key));
|
|
1178
|
+
});
|
|
1179
|
+
const setOrDeleteRootKey = (rootLines, key, value) => {
|
|
1180
|
+
const idx = rootLines.findIndex((line) => matchKey(line)?.[1] === key);
|
|
1181
|
+
if (value === void 0) {
|
|
1182
|
+
if (idx !== -1) rootLines.splice(idx, 1);
|
|
1183
|
+
return;
|
|
1133
1184
|
}
|
|
1134
|
-
|
|
1185
|
+
if (idx !== -1) rootLines[idx] = `${key} = ${value}`;
|
|
1186
|
+
else rootLines.push(`${key} = ${value}`);
|
|
1135
1187
|
};
|
|
1136
|
-
const
|
|
1188
|
+
const deleteRootKey = (rootLines, key) => {
|
|
1189
|
+
setOrDeleteRootKey(rootLines, key, void 0);
|
|
1190
|
+
};
|
|
1191
|
+
const removeCodexConfig = (content, options) => {
|
|
1192
|
+
const { restoreRoot } = options ?? {};
|
|
1137
1193
|
const lines = content.length ? content.split(/\r?\n/) : [];
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1194
|
+
const providerIsGetrouter = normalizeTomlString(readRootValue(lines, "model_provider")) === CODEX_PROVIDER;
|
|
1195
|
+
const stripped = stripGetrouterProviderSection(lines);
|
|
1196
|
+
const firstHeaderIndex = stripped.findIndex((line) => matchHeader(line));
|
|
1197
|
+
const rootEnd = firstHeaderIndex === -1 ? stripped.length : firstHeaderIndex;
|
|
1198
|
+
const rootLines = stripLegacyMarkersFromRoot(stripped.slice(0, rootEnd));
|
|
1199
|
+
const restLines = stripped.slice(rootEnd);
|
|
1200
|
+
if (providerIsGetrouter) if (restoreRoot) {
|
|
1201
|
+
setOrDeleteRootKey(rootLines, "model", restoreRoot.model);
|
|
1202
|
+
setOrDeleteRootKey(rootLines, "model_reasoning_effort", restoreRoot.reasoning);
|
|
1203
|
+
setOrDeleteRootKey(rootLines, "model_provider", restoreRoot.provider);
|
|
1204
|
+
} else {
|
|
1205
|
+
deleteRootKey(rootLines, "model");
|
|
1206
|
+
deleteRootKey(rootLines, "model_reasoning_effort");
|
|
1207
|
+
deleteRootKey(rootLines, "model_provider");
|
|
1148
1208
|
}
|
|
1149
|
-
|
|
1150
|
-
if (
|
|
1151
|
-
|
|
1209
|
+
const recombined = [...rootLines];
|
|
1210
|
+
if (recombined.length > 0 && restLines.length > 0 && recombined[recombined.length - 1]?.trim() !== "") recombined.push("");
|
|
1211
|
+
recombined.push(...restLines);
|
|
1212
|
+
const nextContent = recombined.join("\n");
|
|
1152
1213
|
return {
|
|
1153
1214
|
content: nextContent,
|
|
1154
1215
|
changed: nextContent !== content
|
|
1155
1216
|
};
|
|
1156
1217
|
};
|
|
1157
|
-
const removeAuthJson = (data) => {
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1218
|
+
const removeAuthJson = (data, options) => {
|
|
1219
|
+
const { force = false, installed, restore } = options ?? {};
|
|
1220
|
+
const next = { ...data };
|
|
1221
|
+
let changed = false;
|
|
1222
|
+
for (const key of LEGACY_AUTH_MARKERS) if (key in next) {
|
|
1223
|
+
delete next[key];
|
|
1224
|
+
changed = true;
|
|
1225
|
+
}
|
|
1226
|
+
const current = typeof next.OPENAI_API_KEY === "string" ? next.OPENAI_API_KEY : void 0;
|
|
1227
|
+
const restoreValue = typeof restore === "string" && restore.trim().length > 0 ? restore : void 0;
|
|
1228
|
+
if (installed && current && current === installed) {
|
|
1229
|
+
if (restoreValue) next.OPENAI_API_KEY = restoreValue;
|
|
1230
|
+
else delete next.OPENAI_API_KEY;
|
|
1231
|
+
changed = true;
|
|
1232
|
+
return {
|
|
1233
|
+
data: next,
|
|
1234
|
+
changed
|
|
1235
|
+
};
|
|
1236
|
+
}
|
|
1237
|
+
if (force && current) {
|
|
1238
|
+
delete next.OPENAI_API_KEY;
|
|
1239
|
+
changed = true;
|
|
1240
|
+
}
|
|
1163
1241
|
return {
|
|
1164
|
-
data:
|
|
1165
|
-
changed
|
|
1242
|
+
data: next,
|
|
1243
|
+
changed
|
|
1166
1244
|
};
|
|
1167
1245
|
};
|
|
1168
1246
|
|
|
1169
1247
|
//#endregion
|
|
1170
1248
|
//#region src/cmd/codex.ts
|
|
1171
1249
|
const CODEX_DIR = ".codex";
|
|
1250
|
+
const CODEX_BACKUP_FILE = "codex-backup.json";
|
|
1172
1251
|
const readFileIfExists = (filePath) => fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8") : "";
|
|
1173
1252
|
const readAuthJson = (filePath) => {
|
|
1174
1253
|
if (!fs.existsSync(filePath)) return {};
|
|
@@ -1184,6 +1263,18 @@ const ensureCodexDir = () => {
|
|
|
1184
1263
|
return dir;
|
|
1185
1264
|
};
|
|
1186
1265
|
const resolveCodexDir = () => path.join(os.homedir(), CODEX_DIR);
|
|
1266
|
+
const resolveCodexBackupPath = () => path.join(resolveConfigDir(), CODEX_BACKUP_FILE);
|
|
1267
|
+
const readCodexBackup = () => {
|
|
1268
|
+
const raw = readJsonFile(resolveCodexBackupPath());
|
|
1269
|
+
if (!raw || typeof raw !== "object") return null;
|
|
1270
|
+
if (raw.version !== 1) return null;
|
|
1271
|
+
return raw;
|
|
1272
|
+
};
|
|
1273
|
+
const writeCodexBackup = (backup) => {
|
|
1274
|
+
const backupPath = resolveCodexBackupPath();
|
|
1275
|
+
writeJsonFile(backupPath, backup);
|
|
1276
|
+
if (process.platform !== "win32") fs.chmodSync(backupPath, 384);
|
|
1277
|
+
};
|
|
1187
1278
|
const requireInteractive$1 = () => {
|
|
1188
1279
|
if (!process.stdin.isTTY) throw new Error("Interactive mode required for codex configuration.");
|
|
1189
1280
|
};
|
|
@@ -1220,12 +1311,38 @@ const registerCodexCommand = (program) => {
|
|
|
1220
1311
|
const codexDir = ensureCodexDir();
|
|
1221
1312
|
const configPath = path.join(codexDir, "config.toml");
|
|
1222
1313
|
const authPath = path.join(codexDir, "auth.json");
|
|
1223
|
-
const
|
|
1314
|
+
const existingConfig = readFileIfExists(configPath);
|
|
1315
|
+
const existingRoot = readCodexTomlRootValues(existingConfig);
|
|
1316
|
+
const existingAuth = readAuthJson(authPath);
|
|
1317
|
+
const existingOpenaiKey = typeof existingAuth.OPENAI_API_KEY === "string" ? existingAuth.OPENAI_API_KEY : void 0;
|
|
1318
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1319
|
+
const installedRoot = {
|
|
1320
|
+
model: `"${model}"`,
|
|
1321
|
+
reasoning: `"${reasoningValue}"`,
|
|
1322
|
+
provider: `"getrouter"`
|
|
1323
|
+
};
|
|
1324
|
+
const backup = readCodexBackup() ?? {
|
|
1325
|
+
version: 1,
|
|
1326
|
+
createdAt: now,
|
|
1327
|
+
updatedAt: now
|
|
1328
|
+
};
|
|
1329
|
+
backup.updatedAt = now;
|
|
1330
|
+
backup.config ??= {};
|
|
1331
|
+
backup.config.previous ??= {};
|
|
1332
|
+
if (backup.config.previous.model === void 0 && existingRoot.model && existingRoot.model !== installedRoot.model) backup.config.previous.model = existingRoot.model;
|
|
1333
|
+
if (backup.config.previous.reasoning === void 0 && existingRoot.reasoning && existingRoot.reasoning !== installedRoot.reasoning) backup.config.previous.reasoning = existingRoot.reasoning;
|
|
1334
|
+
if (backup.config.previous.provider === void 0 && existingRoot.provider && existingRoot.provider !== installedRoot.provider) backup.config.previous.provider = existingRoot.provider;
|
|
1335
|
+
backup.config.installed = installedRoot;
|
|
1336
|
+
backup.auth ??= {};
|
|
1337
|
+
if (backup.auth.previousOpenaiKey === void 0 && existingOpenaiKey && existingOpenaiKey !== apiKey) backup.auth.previousOpenaiKey = existingOpenaiKey;
|
|
1338
|
+
backup.auth.installedOpenaiKey = apiKey;
|
|
1339
|
+
writeCodexBackup(backup);
|
|
1340
|
+
const mergedConfig = mergeCodexToml(existingConfig, {
|
|
1224
1341
|
model,
|
|
1225
1342
|
reasoning: reasoningValue
|
|
1226
1343
|
});
|
|
1227
1344
|
fs.writeFileSync(configPath, mergedConfig, "utf8");
|
|
1228
|
-
const mergedAuth = mergeAuthJson(
|
|
1345
|
+
const mergedAuth = mergeAuthJson(existingAuth, apiKey);
|
|
1229
1346
|
fs.writeFileSync(authPath, JSON.stringify(mergedAuth, null, 2));
|
|
1230
1347
|
if (process.platform !== "win32") fs.chmodSync(authPath, 384);
|
|
1231
1348
|
console.log("✅ Updated ~/.codex/config.toml");
|
|
@@ -1237,11 +1354,19 @@ const registerCodexCommand = (program) => {
|
|
|
1237
1354
|
const authPath = path.join(codexDir, "auth.json");
|
|
1238
1355
|
const configExists = fs.existsSync(configPath);
|
|
1239
1356
|
const authExists = fs.existsSync(authPath);
|
|
1357
|
+
const backup = readCodexBackup();
|
|
1358
|
+
const restoreRoot = backup?.config?.previous;
|
|
1359
|
+
const restoreOpenaiKey = backup?.auth?.previousOpenaiKey;
|
|
1360
|
+
const installedOpenaiKey = backup?.auth?.installedOpenaiKey;
|
|
1240
1361
|
const configContent = configExists ? readFileIfExists(configPath) : "";
|
|
1241
|
-
const configResult = configExists ? removeCodexConfig(configContent) : null;
|
|
1362
|
+
const configResult = configExists ? removeCodexConfig(configContent, { restoreRoot }) : null;
|
|
1242
1363
|
const authContent = authExists ? fs.readFileSync(authPath, "utf8").trim() : "";
|
|
1243
1364
|
const authData = authExists ? authContent ? JSON.parse(authContent) : {} : null;
|
|
1244
|
-
const authResult = authData ? removeAuthJson(authData
|
|
1365
|
+
const authResult = authData ? removeAuthJson(authData, {
|
|
1366
|
+
force: true,
|
|
1367
|
+
installed: installedOpenaiKey,
|
|
1368
|
+
restore: restoreOpenaiKey
|
|
1369
|
+
}) : null;
|
|
1245
1370
|
if (!configExists) console.log(`ℹ️ ${configPath} not found`);
|
|
1246
1371
|
else if (configResult?.changed) {
|
|
1247
1372
|
fs.writeFileSync(configPath, configResult.content, "utf8");
|
|
@@ -1252,6 +1377,8 @@ const registerCodexCommand = (program) => {
|
|
|
1252
1377
|
fs.writeFileSync(authPath, JSON.stringify(authResult.data, null, 2));
|
|
1253
1378
|
console.log(`✅ Removed getrouter entries from ${authPath}`);
|
|
1254
1379
|
} else console.log(`ℹ️ No getrouter entries in ${authPath}`);
|
|
1380
|
+
const backupPath = resolveCodexBackupPath();
|
|
1381
|
+
if (fs.existsSync(backupPath)) fs.unlinkSync(backupPath);
|
|
1255
1382
|
});
|
|
1256
1383
|
};
|
|
1257
1384
|
|
package/package.json
CHANGED
package/src/cmd/codex.ts
CHANGED
|
@@ -3,6 +3,8 @@ import os from "node:os";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import type { Command } from "commander";
|
|
5
5
|
import { createApiClients } from "../core/api/client";
|
|
6
|
+
import { readJsonFile, writeJsonFile } from "../core/config/fs";
|
|
7
|
+
import { resolveConfigDir } from "../core/config/paths";
|
|
6
8
|
import {
|
|
7
9
|
getCodexModelChoices,
|
|
8
10
|
mapReasoningValue,
|
|
@@ -12,13 +14,30 @@ import {
|
|
|
12
14
|
import { fuzzySelect } from "../core/interactive/fuzzy";
|
|
13
15
|
import { selectConsumer } from "../core/interactive/keys";
|
|
14
16
|
import {
|
|
17
|
+
type CodexTomlRootValues,
|
|
15
18
|
mergeAuthJson,
|
|
16
19
|
mergeCodexToml,
|
|
20
|
+
readCodexTomlRootValues,
|
|
17
21
|
removeAuthJson,
|
|
18
22
|
removeCodexConfig,
|
|
19
23
|
} from "../core/setup/codex";
|
|
20
24
|
|
|
21
25
|
const CODEX_DIR = ".codex";
|
|
26
|
+
const CODEX_BACKUP_FILE = "codex-backup.json";
|
|
27
|
+
|
|
28
|
+
type CodexBackupFile = {
|
|
29
|
+
version: 1;
|
|
30
|
+
createdAt: string;
|
|
31
|
+
updatedAt: string;
|
|
32
|
+
config?: {
|
|
33
|
+
previous?: CodexTomlRootValues;
|
|
34
|
+
installed?: Required<CodexTomlRootValues>;
|
|
35
|
+
};
|
|
36
|
+
auth?: {
|
|
37
|
+
previousOpenaiKey?: string;
|
|
38
|
+
installedOpenaiKey?: string;
|
|
39
|
+
};
|
|
40
|
+
};
|
|
22
41
|
|
|
23
42
|
const readFileIfExists = (filePath: string) =>
|
|
24
43
|
fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8") : "";
|
|
@@ -41,6 +60,24 @@ const ensureCodexDir = () => {
|
|
|
41
60
|
};
|
|
42
61
|
|
|
43
62
|
const resolveCodexDir = () => path.join(os.homedir(), CODEX_DIR);
|
|
63
|
+
const resolveCodexBackupPath = () =>
|
|
64
|
+
path.join(resolveConfigDir(), CODEX_BACKUP_FILE);
|
|
65
|
+
|
|
66
|
+
const readCodexBackup = (): CodexBackupFile | null => {
|
|
67
|
+
const backupPath = resolveCodexBackupPath();
|
|
68
|
+
const raw = readJsonFile<CodexBackupFile>(backupPath);
|
|
69
|
+
if (!raw || typeof raw !== "object") return null;
|
|
70
|
+
if ((raw as CodexBackupFile).version !== 1) return null;
|
|
71
|
+
return raw;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const writeCodexBackup = (backup: CodexBackupFile) => {
|
|
75
|
+
const backupPath = resolveCodexBackupPath();
|
|
76
|
+
writeJsonFile(backupPath, backup);
|
|
77
|
+
if (process.platform !== "win32") {
|
|
78
|
+
fs.chmodSync(backupPath, 0o600);
|
|
79
|
+
}
|
|
80
|
+
};
|
|
44
81
|
|
|
45
82
|
const requireInteractive = () => {
|
|
46
83
|
if (!process.stdin.isTTY) {
|
|
@@ -110,13 +147,69 @@ export const registerCodexCommand = (program: Command) => {
|
|
|
110
147
|
const authPath = path.join(codexDir, "auth.json");
|
|
111
148
|
|
|
112
149
|
const existingConfig = readFileIfExists(configPath);
|
|
150
|
+
const existingRoot = readCodexTomlRootValues(existingConfig);
|
|
151
|
+
|
|
152
|
+
const existingAuth = readAuthJson(authPath);
|
|
153
|
+
const existingOpenaiKey =
|
|
154
|
+
typeof existingAuth.OPENAI_API_KEY === "string"
|
|
155
|
+
? existingAuth.OPENAI_API_KEY
|
|
156
|
+
: undefined;
|
|
157
|
+
|
|
158
|
+
const now = new Date().toISOString();
|
|
159
|
+
const installedRoot = {
|
|
160
|
+
model: `"${model}"`,
|
|
161
|
+
reasoning: `"${reasoningValue}"`,
|
|
162
|
+
provider: `"getrouter"`,
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const backup = readCodexBackup() ?? {
|
|
166
|
+
version: 1 as const,
|
|
167
|
+
createdAt: now,
|
|
168
|
+
updatedAt: now,
|
|
169
|
+
};
|
|
170
|
+
backup.updatedAt = now;
|
|
171
|
+
backup.config ??= {};
|
|
172
|
+
backup.config.previous ??= {};
|
|
173
|
+
if (
|
|
174
|
+
backup.config.previous.model === undefined &&
|
|
175
|
+
existingRoot.model &&
|
|
176
|
+
existingRoot.model !== installedRoot.model
|
|
177
|
+
) {
|
|
178
|
+
backup.config.previous.model = existingRoot.model;
|
|
179
|
+
}
|
|
180
|
+
if (
|
|
181
|
+
backup.config.previous.reasoning === undefined &&
|
|
182
|
+
existingRoot.reasoning &&
|
|
183
|
+
existingRoot.reasoning !== installedRoot.reasoning
|
|
184
|
+
) {
|
|
185
|
+
backup.config.previous.reasoning = existingRoot.reasoning;
|
|
186
|
+
}
|
|
187
|
+
if (
|
|
188
|
+
backup.config.previous.provider === undefined &&
|
|
189
|
+
existingRoot.provider &&
|
|
190
|
+
existingRoot.provider !== installedRoot.provider
|
|
191
|
+
) {
|
|
192
|
+
backup.config.previous.provider = existingRoot.provider;
|
|
193
|
+
}
|
|
194
|
+
backup.config.installed = installedRoot;
|
|
195
|
+
|
|
196
|
+
backup.auth ??= {};
|
|
197
|
+
if (
|
|
198
|
+
backup.auth.previousOpenaiKey === undefined &&
|
|
199
|
+
existingOpenaiKey &&
|
|
200
|
+
existingOpenaiKey !== apiKey
|
|
201
|
+
) {
|
|
202
|
+
backup.auth.previousOpenaiKey = existingOpenaiKey;
|
|
203
|
+
}
|
|
204
|
+
backup.auth.installedOpenaiKey = apiKey;
|
|
205
|
+
writeCodexBackup(backup);
|
|
206
|
+
|
|
113
207
|
const mergedConfig = mergeCodexToml(existingConfig, {
|
|
114
208
|
model,
|
|
115
209
|
reasoning: reasoningValue,
|
|
116
210
|
});
|
|
117
211
|
fs.writeFileSync(configPath, mergedConfig, "utf8");
|
|
118
212
|
|
|
119
|
-
const existingAuth = readAuthJson(authPath);
|
|
120
213
|
const mergedAuth = mergeAuthJson(existingAuth, apiKey);
|
|
121
214
|
fs.writeFileSync(authPath, JSON.stringify(mergedAuth, null, 2));
|
|
122
215
|
if (process.platform !== "win32") {
|
|
@@ -138,9 +231,14 @@ export const registerCodexCommand = (program: Command) => {
|
|
|
138
231
|
const configExists = fs.existsSync(configPath);
|
|
139
232
|
const authExists = fs.existsSync(authPath);
|
|
140
233
|
|
|
234
|
+
const backup = readCodexBackup();
|
|
235
|
+
const restoreRoot = backup?.config?.previous;
|
|
236
|
+
const restoreOpenaiKey = backup?.auth?.previousOpenaiKey;
|
|
237
|
+
const installedOpenaiKey = backup?.auth?.installedOpenaiKey;
|
|
238
|
+
|
|
141
239
|
const configContent = configExists ? readFileIfExists(configPath) : "";
|
|
142
240
|
const configResult = configExists
|
|
143
|
-
? removeCodexConfig(configContent)
|
|
241
|
+
? removeCodexConfig(configContent, { restoreRoot })
|
|
144
242
|
: null;
|
|
145
243
|
|
|
146
244
|
const authContent = authExists
|
|
@@ -151,7 +249,13 @@ export const registerCodexCommand = (program: Command) => {
|
|
|
151
249
|
? (JSON.parse(authContent) as Record<string, unknown>)
|
|
152
250
|
: {}
|
|
153
251
|
: null;
|
|
154
|
-
const authResult = authData
|
|
252
|
+
const authResult = authData
|
|
253
|
+
? removeAuthJson(authData, {
|
|
254
|
+
force: true,
|
|
255
|
+
installed: installedOpenaiKey,
|
|
256
|
+
restore: restoreOpenaiKey,
|
|
257
|
+
})
|
|
258
|
+
: null;
|
|
155
259
|
|
|
156
260
|
if (!configExists) {
|
|
157
261
|
console.log(`ℹ️ ${configPath} not found`);
|
|
@@ -170,5 +274,10 @@ export const registerCodexCommand = (program: Command) => {
|
|
|
170
274
|
} else {
|
|
171
275
|
console.log(`ℹ️ No getrouter entries in ${authPath}`);
|
|
172
276
|
}
|
|
277
|
+
|
|
278
|
+
const backupPath = resolveCodexBackupPath();
|
|
279
|
+
if (fs.existsSync(backupPath)) {
|
|
280
|
+
fs.unlinkSync(backupPath);
|
|
281
|
+
}
|
|
173
282
|
});
|
|
174
283
|
};
|
package/src/core/setup/codex.ts
CHANGED
|
@@ -3,9 +3,29 @@ type CodexConfigInput = {
|
|
|
3
3
|
reasoning: string;
|
|
4
4
|
};
|
|
5
5
|
|
|
6
|
+
export type CodexTomlRootValues = {
|
|
7
|
+
model?: string;
|
|
8
|
+
reasoning?: string;
|
|
9
|
+
provider?: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
6
12
|
const CODEX_PROVIDER = "getrouter";
|
|
7
13
|
const CODEX_BASE_URL = "https://api.getrouter.dev/codex";
|
|
8
14
|
|
|
15
|
+
const LEGACY_TOML_ROOT_MARKERS = [
|
|
16
|
+
"_getrouter_codex_backup_model",
|
|
17
|
+
"_getrouter_codex_backup_model_reasoning_effort",
|
|
18
|
+
"_getrouter_codex_backup_model_provider",
|
|
19
|
+
"_getrouter_codex_installed_model",
|
|
20
|
+
"_getrouter_codex_installed_model_reasoning_effort",
|
|
21
|
+
"_getrouter_codex_installed_model_provider",
|
|
22
|
+
] as const;
|
|
23
|
+
|
|
24
|
+
const LEGACY_AUTH_MARKERS = [
|
|
25
|
+
"_getrouter_codex_backup_openai_api_key",
|
|
26
|
+
"_getrouter_codex_installed_openai_api_key",
|
|
27
|
+
] as const;
|
|
28
|
+
|
|
9
29
|
const ROOT_KEYS = [
|
|
10
30
|
"model",
|
|
11
31
|
"model_reasoning_effort",
|
|
@@ -34,12 +54,77 @@ const providerValues = () => ({
|
|
|
34
54
|
|
|
35
55
|
const matchHeader = (line: string) => line.match(/^\s*\[([^\]]+)\]\s*$/);
|
|
36
56
|
const matchKey = (line: string) => line.match(/^\s*([A-Za-z0-9_.-]+)\s*=/);
|
|
37
|
-
|
|
38
|
-
|
|
57
|
+
|
|
58
|
+
const parseTomlRhsValue = (rhs: string) => {
|
|
59
|
+
const trimmed = rhs.trim();
|
|
60
|
+
if (!trimmed) return "";
|
|
61
|
+
const first = trimmed[0];
|
|
62
|
+
if (first === '"' || first === "'") {
|
|
63
|
+
const end = trimmed.indexOf(first, 1);
|
|
64
|
+
return end === -1 ? trimmed : trimmed.slice(0, end + 1);
|
|
65
|
+
}
|
|
66
|
+
const hashIndex = trimmed.indexOf("#");
|
|
67
|
+
return (hashIndex === -1 ? trimmed : trimmed.slice(0, hashIndex)).trim();
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const readRootValue = (lines: string[], key: string) => {
|
|
71
|
+
for (const line of lines) {
|
|
72
|
+
if (matchHeader(line)) break;
|
|
73
|
+
const keyMatch = matchKey(line);
|
|
74
|
+
if (keyMatch?.[1] === key) {
|
|
75
|
+
const parts = line.split("=");
|
|
76
|
+
parts.shift();
|
|
77
|
+
return parseTomlRhsValue(parts.join("="));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return undefined;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export const readCodexTomlRootValues = (
|
|
84
|
+
content: string,
|
|
85
|
+
): CodexTomlRootValues => {
|
|
86
|
+
const lines = content.length ? content.split(/\r?\n/) : [];
|
|
87
|
+
return {
|
|
88
|
+
model: readRootValue(lines, "model"),
|
|
89
|
+
reasoning: readRootValue(lines, "model_reasoning_effort"),
|
|
90
|
+
provider: readRootValue(lines, "model_provider"),
|
|
91
|
+
};
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const normalizeTomlString = (value?: string) => {
|
|
95
|
+
if (!value) return "";
|
|
96
|
+
const trimmed = value.trim();
|
|
97
|
+
if (
|
|
98
|
+
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
|
|
99
|
+
(trimmed.startsWith("'") && trimmed.endsWith("'"))
|
|
100
|
+
) {
|
|
101
|
+
return trimmed.slice(1, -1).trim().toLowerCase();
|
|
102
|
+
}
|
|
103
|
+
return trimmed.replace(/['"]/g, "").trim().toLowerCase();
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const stripLegacyRootMarkers = (lines: string[]) => {
|
|
107
|
+
const updated: string[] = [];
|
|
108
|
+
let inRoot = true;
|
|
109
|
+
|
|
110
|
+
for (const line of lines) {
|
|
111
|
+
if (matchHeader(line)) {
|
|
112
|
+
inRoot = false;
|
|
113
|
+
}
|
|
114
|
+
if (inRoot) {
|
|
115
|
+
const keyMatch = matchKey(line);
|
|
116
|
+
const key = keyMatch?.[1];
|
|
117
|
+
if (key && LEGACY_TOML_ROOT_MARKERS.includes(key as never)) continue;
|
|
118
|
+
}
|
|
119
|
+
updated.push(line);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return updated;
|
|
123
|
+
};
|
|
39
124
|
|
|
40
125
|
export const mergeCodexToml = (content: string, input: CodexConfigInput) => {
|
|
41
126
|
const lines = content.length ? content.split(/\r?\n/) : [];
|
|
42
|
-
const updated = [...lines];
|
|
127
|
+
const updated = [...stripLegacyRootMarkers(lines)];
|
|
43
128
|
const rootValueMap = rootValues(input);
|
|
44
129
|
const providerValueMap = providerValues();
|
|
45
130
|
|
|
@@ -129,10 +214,16 @@ export const mergeCodexToml = (content: string, input: CodexConfigInput) => {
|
|
|
129
214
|
export const mergeAuthJson = (
|
|
130
215
|
data: Record<string, unknown>,
|
|
131
216
|
apiKey: string,
|
|
132
|
-
): Record<string, unknown> =>
|
|
133
|
-
...data
|
|
134
|
-
|
|
135
|
-
|
|
217
|
+
): Record<string, unknown> => {
|
|
218
|
+
const next: Record<string, unknown> = { ...data };
|
|
219
|
+
for (const key of LEGACY_AUTH_MARKERS) {
|
|
220
|
+
if (key in next) {
|
|
221
|
+
delete next[key];
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
next.OPENAI_API_KEY = apiKey;
|
|
225
|
+
return next;
|
|
226
|
+
};
|
|
136
227
|
|
|
137
228
|
const stripGetrouterProviderSection = (lines: string[]) => {
|
|
138
229
|
const updated: string[] = [];
|
|
@@ -156,62 +247,124 @@ const stripGetrouterProviderSection = (lines: string[]) => {
|
|
|
156
247
|
return updated;
|
|
157
248
|
};
|
|
158
249
|
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
|
|
250
|
+
const stripLegacyMarkersFromRoot = (rootLines: string[]) =>
|
|
251
|
+
rootLines.filter((line) => {
|
|
252
|
+
const keyMatch = matchKey(line);
|
|
253
|
+
const key = keyMatch?.[1];
|
|
254
|
+
return !(key && LEGACY_TOML_ROOT_MARKERS.includes(key as never));
|
|
255
|
+
});
|
|
162
256
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
257
|
+
const setOrDeleteRootKey = (
|
|
258
|
+
rootLines: string[],
|
|
259
|
+
key: string,
|
|
260
|
+
value: string | undefined,
|
|
261
|
+
) => {
|
|
262
|
+
const idx = rootLines.findIndex((line) => matchKey(line)?.[1] === key);
|
|
263
|
+
if (value === undefined) {
|
|
264
|
+
if (idx !== -1) {
|
|
265
|
+
rootLines.splice(idx, 1);
|
|
169
266
|
}
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
if (idx !== -1) {
|
|
270
|
+
rootLines[idx] = `${key} = ${value}`;
|
|
271
|
+
} else {
|
|
272
|
+
rootLines.push(`${key} = ${value}`);
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
const deleteRootKey = (rootLines: string[], key: string) => {
|
|
277
|
+
setOrDeleteRootKey(rootLines, key, undefined);
|
|
278
|
+
};
|
|
170
279
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
280
|
+
export const removeCodexConfig = (
|
|
281
|
+
content: string,
|
|
282
|
+
options?: { restoreRoot?: CodexTomlRootValues },
|
|
283
|
+
) => {
|
|
284
|
+
const { restoreRoot } = options ?? {};
|
|
285
|
+
const lines = content.length ? content.split(/\r?\n/) : [];
|
|
286
|
+
const providerIsGetrouter =
|
|
287
|
+
normalizeTomlString(readRootValue(lines, "model_provider")) ===
|
|
288
|
+
CODEX_PROVIDER;
|
|
289
|
+
|
|
290
|
+
const stripped = stripGetrouterProviderSection(lines);
|
|
291
|
+
const firstHeaderIndex = stripped.findIndex((line) => matchHeader(line));
|
|
292
|
+
const rootEnd = firstHeaderIndex === -1 ? stripped.length : firstHeaderIndex;
|
|
293
|
+
const rootLines = stripLegacyMarkersFromRoot(stripped.slice(0, rootEnd));
|
|
294
|
+
const restLines = stripped.slice(rootEnd);
|
|
295
|
+
|
|
296
|
+
if (providerIsGetrouter) {
|
|
297
|
+
if (restoreRoot) {
|
|
298
|
+
setOrDeleteRootKey(rootLines, "model", restoreRoot.model);
|
|
299
|
+
setOrDeleteRootKey(
|
|
300
|
+
rootLines,
|
|
301
|
+
"model_reasoning_effort",
|
|
302
|
+
restoreRoot.reasoning,
|
|
303
|
+
);
|
|
304
|
+
setOrDeleteRootKey(rootLines, "model_provider", restoreRoot.provider);
|
|
305
|
+
} else {
|
|
306
|
+
deleteRootKey(rootLines, "model");
|
|
307
|
+
deleteRootKey(rootLines, "model_reasoning_effort");
|
|
308
|
+
deleteRootKey(rootLines, "model_provider");
|
|
175
309
|
}
|
|
310
|
+
}
|
|
176
311
|
|
|
177
|
-
|
|
312
|
+
const recombined: string[] = [...rootLines];
|
|
313
|
+
if (
|
|
314
|
+
recombined.length > 0 &&
|
|
315
|
+
restLines.length > 0 &&
|
|
316
|
+
recombined[recombined.length - 1]?.trim() !== ""
|
|
317
|
+
) {
|
|
318
|
+
recombined.push("");
|
|
178
319
|
}
|
|
320
|
+
recombined.push(...restLines);
|
|
179
321
|
|
|
180
|
-
|
|
322
|
+
const nextContent = recombined.join("\n");
|
|
323
|
+
return { content: nextContent, changed: nextContent !== content };
|
|
181
324
|
};
|
|
182
325
|
|
|
183
|
-
export const
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
326
|
+
export const removeAuthJson = (
|
|
327
|
+
data: Record<string, unknown>,
|
|
328
|
+
options?: {
|
|
329
|
+
force?: boolean;
|
|
330
|
+
installed?: string;
|
|
331
|
+
restore?: string;
|
|
332
|
+
},
|
|
333
|
+
) => {
|
|
334
|
+
const { force = false, installed, restore } = options ?? {};
|
|
335
|
+
const next: Record<string, unknown> = { ...data };
|
|
336
|
+
let changed = false;
|
|
187
337
|
|
|
188
|
-
for (const
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
continue;
|
|
338
|
+
for (const key of LEGACY_AUTH_MARKERS) {
|
|
339
|
+
if (key in next) {
|
|
340
|
+
delete next[key];
|
|
341
|
+
changed = true;
|
|
193
342
|
}
|
|
343
|
+
}
|
|
194
344
|
|
|
195
|
-
|
|
345
|
+
const current =
|
|
346
|
+
typeof next.OPENAI_API_KEY === "string"
|
|
347
|
+
? (next.OPENAI_API_KEY as string)
|
|
348
|
+
: undefined;
|
|
349
|
+
const restoreValue =
|
|
350
|
+
typeof restore === "string" && restore.trim().length > 0
|
|
351
|
+
? restore
|
|
352
|
+
: undefined;
|
|
196
353
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
354
|
+
if (installed && current && current === installed) {
|
|
355
|
+
if (restoreValue) {
|
|
356
|
+
next.OPENAI_API_KEY = restoreValue;
|
|
357
|
+
} else {
|
|
358
|
+
delete next.OPENAI_API_KEY;
|
|
201
359
|
}
|
|
360
|
+
changed = true;
|
|
361
|
+
return { data: next, changed };
|
|
202
362
|
}
|
|
203
363
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
364
|
+
if (force && current) {
|
|
365
|
+
delete next.OPENAI_API_KEY;
|
|
366
|
+
changed = true;
|
|
207
367
|
}
|
|
208
368
|
|
|
209
|
-
|
|
210
|
-
return { content: nextContent, changed: nextContent !== content };
|
|
211
|
-
};
|
|
212
|
-
|
|
213
|
-
export const removeAuthJson = (data: Record<string, unknown>) => {
|
|
214
|
-
if (!("OPENAI_API_KEY" in data)) return { data, changed: false };
|
|
215
|
-
const { OPENAI_API_KEY: _ignored, ...rest } = data;
|
|
216
|
-
return { data: rest, changed: true };
|
|
369
|
+
return { data: next, changed };
|
|
217
370
|
};
|
package/tests/cmd/codex.test.ts
CHANGED
|
@@ -15,6 +15,8 @@ const makeDir = () => fs.mkdtempSync(path.join(os.tmpdir(), "getrouter-"));
|
|
|
15
15
|
const codexConfigPath = (dir: string) =>
|
|
16
16
|
path.join(dir, ".codex", "config.toml");
|
|
17
17
|
const codexAuthPath = (dir: string) => path.join(dir, ".codex", "auth.json");
|
|
18
|
+
const codexBackupPath = (dir: string) =>
|
|
19
|
+
path.join(dir, ".getrouter", "codex-backup.json");
|
|
18
20
|
|
|
19
21
|
const mockConsumer = { id: "c1", apiKey: "key-123" };
|
|
20
22
|
|
|
@@ -274,6 +276,104 @@ describe("codex command", () => {
|
|
|
274
276
|
expect(auth.OPENAI_API_KEY).toBeUndefined();
|
|
275
277
|
});
|
|
276
278
|
|
|
279
|
+
it("uninstall restores previous OPENAI_API_KEY when backup exists", async () => {
|
|
280
|
+
const dir = makeDir();
|
|
281
|
+
process.env.HOME = dir;
|
|
282
|
+
const codexDir = path.join(dir, ".codex");
|
|
283
|
+
fs.mkdirSync(codexDir, { recursive: true });
|
|
284
|
+
fs.mkdirSync(path.join(dir, ".getrouter"), { recursive: true });
|
|
285
|
+
fs.writeFileSync(
|
|
286
|
+
codexAuthPath(dir),
|
|
287
|
+
JSON.stringify(
|
|
288
|
+
{
|
|
289
|
+
OPENAI_API_KEY: "new-key",
|
|
290
|
+
_getrouter_codex_backup_openai_api_key: "legacy-backup",
|
|
291
|
+
_getrouter_codex_installed_openai_api_key: "legacy-installed",
|
|
292
|
+
OTHER: "keep",
|
|
293
|
+
},
|
|
294
|
+
null,
|
|
295
|
+
2,
|
|
296
|
+
),
|
|
297
|
+
);
|
|
298
|
+
fs.writeFileSync(
|
|
299
|
+
codexBackupPath(dir),
|
|
300
|
+
JSON.stringify(
|
|
301
|
+
{
|
|
302
|
+
version: 1,
|
|
303
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
304
|
+
updatedAt: "2026-01-01T00:00:00Z",
|
|
305
|
+
auth: {
|
|
306
|
+
previousOpenaiKey: "old-key",
|
|
307
|
+
installedOpenaiKey: "new-key",
|
|
308
|
+
},
|
|
309
|
+
},
|
|
310
|
+
null,
|
|
311
|
+
2,
|
|
312
|
+
),
|
|
313
|
+
);
|
|
314
|
+
|
|
315
|
+
const program = createProgram();
|
|
316
|
+
await program.parseAsync(["node", "getrouter", "codex", "uninstall"]);
|
|
317
|
+
|
|
318
|
+
const auth = JSON.parse(fs.readFileSync(codexAuthPath(dir), "utf8"));
|
|
319
|
+
expect(auth.OPENAI_API_KEY).toBe("old-key");
|
|
320
|
+
expect(auth._getrouter_codex_backup_openai_api_key).toBeUndefined();
|
|
321
|
+
expect(auth._getrouter_codex_installed_openai_api_key).toBeUndefined();
|
|
322
|
+
expect(auth.OTHER).toBe("keep");
|
|
323
|
+
expect(fs.existsSync(codexBackupPath(dir))).toBe(false);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it("uninstall restores previous model_provider and model", async () => {
|
|
327
|
+
setStdinTTY(true);
|
|
328
|
+
const dir = makeDir();
|
|
329
|
+
process.env.HOME = dir;
|
|
330
|
+
const codexDir = path.join(dir, ".codex");
|
|
331
|
+
fs.mkdirSync(codexDir, { recursive: true });
|
|
332
|
+
fs.writeFileSync(
|
|
333
|
+
codexConfigPath(dir),
|
|
334
|
+
[
|
|
335
|
+
'model = "user-model"',
|
|
336
|
+
'model_reasoning_effort = "low"',
|
|
337
|
+
'model_provider = "openai"',
|
|
338
|
+
"",
|
|
339
|
+
"[model_providers.openai]",
|
|
340
|
+
'name = "openai"',
|
|
341
|
+
].join("\n"),
|
|
342
|
+
);
|
|
343
|
+
|
|
344
|
+
prompts.inject(["gpt-5.2-codex", "extra_high", mockConsumer]);
|
|
345
|
+
(createApiClients as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
|
346
|
+
consumerService: {
|
|
347
|
+
ListConsumers: vi.fn().mockResolvedValue({
|
|
348
|
+
consumers: [
|
|
349
|
+
{
|
|
350
|
+
id: "c1",
|
|
351
|
+
name: "dev",
|
|
352
|
+
enabled: true,
|
|
353
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
354
|
+
},
|
|
355
|
+
],
|
|
356
|
+
}),
|
|
357
|
+
GetConsumer: vi.fn().mockResolvedValue(mockConsumer),
|
|
358
|
+
} as unknown as ConsumerService,
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
const program = createProgram();
|
|
362
|
+
await program.parseAsync(["node", "getrouter", "codex"]);
|
|
363
|
+
expect(fs.existsSync(codexBackupPath(dir))).toBe(true);
|
|
364
|
+
await program.parseAsync(["node", "getrouter", "codex", "uninstall"]);
|
|
365
|
+
expect(fs.existsSync(codexBackupPath(dir))).toBe(false);
|
|
366
|
+
|
|
367
|
+
const config = fs.readFileSync(codexConfigPath(dir), "utf8");
|
|
368
|
+
expect(config).toContain('model = "user-model"');
|
|
369
|
+
expect(config).toContain('model_reasoning_effort = "low"');
|
|
370
|
+
expect(config).toContain('model_provider = "openai"');
|
|
371
|
+
expect(config).toContain("[model_providers.openai]");
|
|
372
|
+
expect(config).not.toContain("[model_providers.getrouter]");
|
|
373
|
+
expect(config).not.toContain("_getrouter_codex_backup");
|
|
374
|
+
expect(config).not.toContain("_getrouter_codex_installed");
|
|
375
|
+
});
|
|
376
|
+
|
|
277
377
|
it("uninstall leaves root keys when provider is not getrouter", async () => {
|
|
278
378
|
const dir = makeDir();
|
|
279
379
|
process.env.HOME = dir;
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
mergeAuthJson,
|
|
4
|
+
mergeCodexToml,
|
|
5
|
+
removeAuthJson,
|
|
6
|
+
removeCodexConfig,
|
|
7
|
+
} from "../../../src/core/setup/codex";
|
|
3
8
|
|
|
4
9
|
describe("codex setup helpers", () => {
|
|
5
10
|
it("merges codex toml at root and provider table", () => {
|
|
@@ -35,4 +40,85 @@ describe("codex setup helpers", () => {
|
|
|
35
40
|
expect(output.OPENAI_API_KEY).toBe("key-123");
|
|
36
41
|
expect(output.existing).toBe("keep");
|
|
37
42
|
});
|
|
43
|
+
|
|
44
|
+
it("removes getrouter provider section and restores root keys when provided", () => {
|
|
45
|
+
const input = [
|
|
46
|
+
'model = "gpt-5.2-codex"',
|
|
47
|
+
'model_reasoning_effort = "xhigh"',
|
|
48
|
+
'model_provider = "getrouter"',
|
|
49
|
+
"",
|
|
50
|
+
"[model_providers.getrouter]",
|
|
51
|
+
'name = "getrouter"',
|
|
52
|
+
"",
|
|
53
|
+
"[model_providers.openai]",
|
|
54
|
+
'name = "openai"',
|
|
55
|
+
].join("\n");
|
|
56
|
+
|
|
57
|
+
const { content, changed } = removeCodexConfig(input, {
|
|
58
|
+
restoreRoot: {
|
|
59
|
+
model: '"user-model"',
|
|
60
|
+
reasoning: '"medium"',
|
|
61
|
+
provider: '"openai"',
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
expect(changed).toBe(true);
|
|
65
|
+
expect(content).toContain('model = "user-model"');
|
|
66
|
+
expect(content).toContain('model_reasoning_effort = "medium"');
|
|
67
|
+
expect(content).toContain('model_provider = "openai"');
|
|
68
|
+
expect(content).toContain("[model_providers.openai]");
|
|
69
|
+
expect(content).not.toContain("[model_providers.getrouter]");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("removes root keys when provider is getrouter and no restore is provided", () => {
|
|
73
|
+
const input = [
|
|
74
|
+
'model = "gpt-5.2-codex"',
|
|
75
|
+
'model_reasoning_effort = "xhigh"',
|
|
76
|
+
'model_provider = "getrouter"',
|
|
77
|
+
"",
|
|
78
|
+
"[model_providers.getrouter]",
|
|
79
|
+
'name = "getrouter"',
|
|
80
|
+
].join("\n");
|
|
81
|
+
|
|
82
|
+
const { content } = removeCodexConfig(input);
|
|
83
|
+
expect(content).not.toContain('model = "gpt-5.2-codex"');
|
|
84
|
+
expect(content).not.toContain('model_reasoning_effort = "xhigh"');
|
|
85
|
+
expect(content).not.toContain('model_provider = "getrouter"');
|
|
86
|
+
expect(content).not.toContain("[model_providers.getrouter]");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("restores OPENAI_API_KEY when installed key matches current", () => {
|
|
90
|
+
const input = {
|
|
91
|
+
OPENAI_API_KEY: "new-key",
|
|
92
|
+
OTHER: "keep",
|
|
93
|
+
} as Record<string, unknown>;
|
|
94
|
+
const { data, changed } = removeAuthJson(input, {
|
|
95
|
+
installed: "new-key",
|
|
96
|
+
restore: "old-key",
|
|
97
|
+
});
|
|
98
|
+
expect(changed).toBe(true);
|
|
99
|
+
expect(data.OPENAI_API_KEY).toBe("old-key");
|
|
100
|
+
expect(data.OTHER).toBe("keep");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("removes OPENAI_API_KEY when forced and no restore is available", () => {
|
|
104
|
+
const input = {
|
|
105
|
+
OPENAI_API_KEY: "new-key",
|
|
106
|
+
OTHER: "keep",
|
|
107
|
+
} as Record<string, unknown>;
|
|
108
|
+
const { data, changed } = removeAuthJson(input, { force: true });
|
|
109
|
+
expect(changed).toBe(true);
|
|
110
|
+
expect(data.OPENAI_API_KEY).toBeUndefined();
|
|
111
|
+
expect(data.OTHER).toBe("keep");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("leaves OPENAI_API_KEY when not forced and not installed", () => {
|
|
115
|
+
const input = {
|
|
116
|
+
OPENAI_API_KEY: "user-key",
|
|
117
|
+
OTHER: "keep",
|
|
118
|
+
} as Record<string, unknown>;
|
|
119
|
+
const { data, changed } = removeAuthJson(input);
|
|
120
|
+
expect(changed).toBe(false);
|
|
121
|
+
expect(data.OPENAI_API_KEY).toBe("user-key");
|
|
122
|
+
expect(data.OTHER).toBe("keep");
|
|
123
|
+
});
|
|
38
124
|
});
|