@getrouter/getrouter-cli 0.1.2 → 0.1.4
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/dist/bin.mjs +133 -37
- package/package.json +1 -1
- package/src/cmd/codex.ts +17 -7
- package/src/cmd/env.ts +1 -1
- package/src/cmd/keys.ts +34 -19
- package/src/core/api/providerModels.ts +32 -0
- package/src/core/config/fs.ts +33 -2
- package/src/core/config/index.ts +2 -8
- package/src/core/config/paths.ts +6 -3
- package/src/core/interactive/codex.ts +21 -0
- package/src/core/setup/codex.ts +4 -0
- package/src/core/setup/env.ts +14 -6
- package/tests/cmd/codex.test.ts +87 -1
- package/tests/cmd/keys.test.ts +44 -3
- package/tests/config/fs.test.ts +22 -1
- package/tests/config/index.test.ts +16 -1
- package/tests/config/paths.test.ts +23 -0
- package/tests/core/interactive/codex.test.ts +25 -1
- package/tests/core/setup/env.test.ts +18 -4
- package/tsconfig.json +1 -1
- package/src/core/paths.ts +0 -4
- package/tests/paths.test.ts +0 -9
package/dist/bin.mjs
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { Command } from "commander";
|
|
3
3
|
import fs from "node:fs";
|
|
4
|
-
import os from "node:os";
|
|
5
4
|
import path from "node:path";
|
|
5
|
+
import os from "node:os";
|
|
6
6
|
import { execSync, spawn } from "node:child_process";
|
|
7
7
|
import { randomInt } from "node:crypto";
|
|
8
8
|
import prompts from "prompts";
|
|
9
9
|
|
|
10
10
|
//#region package.json
|
|
11
|
-
var version = "0.1.
|
|
11
|
+
var version = "0.1.4";
|
|
12
12
|
|
|
13
13
|
//#endregion
|
|
14
14
|
//#region src/generated/router/dashboard/v1/index.ts
|
|
@@ -204,16 +204,47 @@ function createUsageServiceClient(handler) {
|
|
|
204
204
|
|
|
205
205
|
//#endregion
|
|
206
206
|
//#region src/core/config/fs.ts
|
|
207
|
+
const getCorruptBackupPath = (filePath) => {
|
|
208
|
+
const dir = path.dirname(filePath);
|
|
209
|
+
const ext = path.extname(filePath);
|
|
210
|
+
const base = path.basename(filePath, ext);
|
|
211
|
+
const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
212
|
+
const rand = Math.random().toString(16).slice(2, 8);
|
|
213
|
+
return path.join(dir, `${base}.corrupt-${stamp}-${rand}${ext}`);
|
|
214
|
+
};
|
|
207
215
|
const readJsonFile = (filePath) => {
|
|
208
216
|
if (!fs.existsSync(filePath)) return null;
|
|
209
|
-
|
|
210
|
-
|
|
217
|
+
let raw;
|
|
218
|
+
try {
|
|
219
|
+
raw = fs.readFileSync(filePath, "utf8");
|
|
220
|
+
} catch {
|
|
221
|
+
console.warn(`⚠️ Unable to read ${filePath}. Continuing with defaults.`);
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
try {
|
|
225
|
+
return JSON.parse(raw);
|
|
226
|
+
} catch {
|
|
227
|
+
const backupPath = getCorruptBackupPath(filePath);
|
|
228
|
+
try {
|
|
229
|
+
fs.renameSync(filePath, backupPath);
|
|
230
|
+
console.warn(`⚠️ Invalid JSON in ${filePath}. Moved to ${backupPath} and continuing with defaults.`);
|
|
231
|
+
} catch {
|
|
232
|
+
console.warn(`⚠️ Invalid JSON in ${filePath}. Please fix or delete this file, then try again.`);
|
|
233
|
+
}
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
211
236
|
};
|
|
212
237
|
const writeJsonFile = (filePath, value) => {
|
|
213
238
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
214
239
|
fs.writeFileSync(filePath, JSON.stringify(value, null, 2), "utf8");
|
|
215
240
|
};
|
|
216
241
|
|
|
242
|
+
//#endregion
|
|
243
|
+
//#region src/core/config/paths.ts
|
|
244
|
+
const resolveConfigDir = () => process.env.GETROUTER_CONFIG_DIR || path.join(os.homedir(), ".getrouter");
|
|
245
|
+
const getConfigPath = () => path.join(resolveConfigDir(), "config.json");
|
|
246
|
+
const getAuthPath = () => path.join(resolveConfigDir(), "auth.json");
|
|
247
|
+
|
|
217
248
|
//#endregion
|
|
218
249
|
//#region src/core/config/types.ts
|
|
219
250
|
const defaultConfig = () => ({
|
|
@@ -229,9 +260,6 @@ const defaultAuthState = () => ({
|
|
|
229
260
|
|
|
230
261
|
//#endregion
|
|
231
262
|
//#region src/core/config/index.ts
|
|
232
|
-
const resolveConfigDir$1 = () => process.env.GETROUTER_CONFIG_DIR || path.join(os.homedir(), ".getrouter");
|
|
233
|
-
const getConfigPath = () => path.join(resolveConfigDir$1(), "config.json");
|
|
234
|
-
const getAuthPath = () => path.join(resolveConfigDir$1(), "auth.json");
|
|
235
263
|
const readConfig = () => ({
|
|
236
264
|
...defaultConfig(),
|
|
237
265
|
...readJsonFile(getConfigPath()) ?? {}
|
|
@@ -649,9 +677,13 @@ const confirmDelete = async (consumer) => {
|
|
|
649
677
|
|
|
650
678
|
//#endregion
|
|
651
679
|
//#region src/core/setup/env.ts
|
|
680
|
+
const quoteEnvValue = (shell, value) => {
|
|
681
|
+
if (shell === "ps1") return `'${value.replaceAll("'", "''")}'`;
|
|
682
|
+
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
683
|
+
};
|
|
652
684
|
const renderLine = (shell, key, value) => {
|
|
653
|
-
if (shell === "ps1") return `$env:${key}
|
|
654
|
-
return `export ${key}=${value}`;
|
|
685
|
+
if (shell === "ps1") return `$env:${key}=${quoteEnvValue(shell, value)}`;
|
|
686
|
+
return `export ${key}=${quoteEnvValue(shell, value)}`;
|
|
655
687
|
};
|
|
656
688
|
const renderEnv = (shell, vars) => {
|
|
657
689
|
const lines = [];
|
|
@@ -787,7 +819,6 @@ const appendRcIfMissing = (rcPath, line) => {
|
|
|
787
819
|
fs.writeFileSync(rcPath, `${content}${prefix}${line}\n`, "utf8");
|
|
788
820
|
return true;
|
|
789
821
|
};
|
|
790
|
-
const resolveConfigDir = () => process.env.GETROUTER_CONFIG_DIR || path.join(os.homedir(), ".getrouter");
|
|
791
822
|
|
|
792
823
|
//#endregion
|
|
793
824
|
//#region src/cmd/env.ts
|
|
@@ -847,6 +878,29 @@ const registerClaudeCommand = (program) => {
|
|
|
847
878
|
});
|
|
848
879
|
};
|
|
849
880
|
|
|
881
|
+
//#endregion
|
|
882
|
+
//#region src/core/api/providerModels.ts
|
|
883
|
+
const asTrimmedString = (value) => {
|
|
884
|
+
if (typeof value !== "string") return null;
|
|
885
|
+
const trimmed = value.trim();
|
|
886
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
887
|
+
};
|
|
888
|
+
const buildProviderModelsPath = (tag) => {
|
|
889
|
+
const query = new URLSearchParams();
|
|
890
|
+
if (tag) query.set("tag", tag);
|
|
891
|
+
const qs = query.toString();
|
|
892
|
+
return `v1/dashboard/providers/models${qs ? `?${qs}` : ""}`;
|
|
893
|
+
};
|
|
894
|
+
const listProviderModels = async ({ tag, fetchImpl }) => {
|
|
895
|
+
const raw = (await requestJson({
|
|
896
|
+
path: buildProviderModelsPath(tag),
|
|
897
|
+
method: "GET",
|
|
898
|
+
fetchImpl,
|
|
899
|
+
maxRetries: 0
|
|
900
|
+
}))?.models;
|
|
901
|
+
return (Array.isArray(raw) ? raw : []).map(asTrimmedString).filter(Boolean);
|
|
902
|
+
};
|
|
903
|
+
|
|
850
904
|
//#endregion
|
|
851
905
|
//#region src/core/interactive/codex.ts
|
|
852
906
|
const MODEL_CHOICES = [
|
|
@@ -875,6 +929,20 @@ const MODEL_CHOICES = [
|
|
|
875
929
|
keywords: ["gpt-5.2"]
|
|
876
930
|
}
|
|
877
931
|
];
|
|
932
|
+
const getCodexModelChoices = async () => {
|
|
933
|
+
try {
|
|
934
|
+
const remoteChoices = (await listProviderModels({ tag: "codex" })).map((model) => ({
|
|
935
|
+
title: model,
|
|
936
|
+
value: model,
|
|
937
|
+
keywords: [model, "codex"]
|
|
938
|
+
}));
|
|
939
|
+
if (remoteChoices.length > 0) {
|
|
940
|
+
remoteChoices.sort((a, b) => a.title.localeCompare(b.title));
|
|
941
|
+
return remoteChoices;
|
|
942
|
+
}
|
|
943
|
+
} catch {}
|
|
944
|
+
return MODEL_CHOICES;
|
|
945
|
+
};
|
|
878
946
|
const REASONING_CHOICES = [
|
|
879
947
|
{
|
|
880
948
|
id: "low",
|
|
@@ -1019,19 +1087,21 @@ const ensureCodexDir = () => {
|
|
|
1019
1087
|
const requireInteractive$1 = () => {
|
|
1020
1088
|
if (!process.stdin.isTTY) throw new Error("Interactive mode required for codex configuration.");
|
|
1021
1089
|
};
|
|
1022
|
-
const promptModel = async () =>
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1090
|
+
const promptModel = async () => {
|
|
1091
|
+
return await fuzzySelect({
|
|
1092
|
+
message: "Select Model and Effort\nAccess legacy models by running getrouter codex -m <model_name> or in your config.toml",
|
|
1093
|
+
choices: await getCodexModelChoices()
|
|
1094
|
+
});
|
|
1095
|
+
};
|
|
1026
1096
|
const promptReasoning = async (model) => await fuzzySelect({
|
|
1027
1097
|
message: `Select Reasoning Level for ${model}`,
|
|
1028
1098
|
choices: REASONING_FUZZY_CHOICES
|
|
1029
1099
|
});
|
|
1030
1100
|
const formatReasoningLabel = (id) => REASONING_CHOICES.find((choice) => choice.id === id)?.label ?? id;
|
|
1031
1101
|
const registerCodexCommand = (program) => {
|
|
1032
|
-
program.command("codex").description("Configure Codex").action(async () => {
|
|
1102
|
+
program.command("codex").description("Configure Codex").option("-m, --model <model>", "Set codex model (skips model selection)").action(async (options) => {
|
|
1033
1103
|
requireInteractive$1();
|
|
1034
|
-
const model = await promptModel();
|
|
1104
|
+
const model = options.model && options.model.trim().length > 0 ? options.model.trim() : await promptModel();
|
|
1035
1105
|
if (!model) return;
|
|
1036
1106
|
const reasoningId = await promptReasoning(model);
|
|
1037
1107
|
if (!reasoningId) return;
|
|
@@ -1070,6 +1140,27 @@ const registerCodexCommand = (program) => {
|
|
|
1070
1140
|
});
|
|
1071
1141
|
};
|
|
1072
1142
|
|
|
1143
|
+
//#endregion
|
|
1144
|
+
//#region src/core/config/redact.ts
|
|
1145
|
+
const SECRET_KEYS = new Set([
|
|
1146
|
+
"accessToken",
|
|
1147
|
+
"refreshToken",
|
|
1148
|
+
"apiKey"
|
|
1149
|
+
]);
|
|
1150
|
+
const mask = (value) => {
|
|
1151
|
+
if (!value) return "";
|
|
1152
|
+
if (value.length <= 8) return "****";
|
|
1153
|
+
return `${value.slice(0, 4)}...${value.slice(-4)}`;
|
|
1154
|
+
};
|
|
1155
|
+
const redactSecrets = (obj) => {
|
|
1156
|
+
const out = { ...obj };
|
|
1157
|
+
for (const key of Object.keys(out)) {
|
|
1158
|
+
const value = out[key];
|
|
1159
|
+
if (SECRET_KEYS.has(key) && typeof value === "string") out[key] = mask(value);
|
|
1160
|
+
}
|
|
1161
|
+
return out;
|
|
1162
|
+
};
|
|
1163
|
+
|
|
1073
1164
|
//#endregion
|
|
1074
1165
|
//#region src/core/output/table.ts
|
|
1075
1166
|
const truncate = (value, max) => {
|
|
@@ -1100,18 +1191,21 @@ const consumerHeaders = [
|
|
|
1100
1191
|
"CREATED_AT",
|
|
1101
1192
|
"API_KEY"
|
|
1102
1193
|
];
|
|
1103
|
-
const consumerRow = (consumer) =>
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
};
|
|
1113
|
-
const
|
|
1114
|
-
|
|
1194
|
+
const consumerRow = (consumer, showApiKey) => {
|
|
1195
|
+
const { apiKey } = showApiKey ? consumer : redactSecrets(consumer);
|
|
1196
|
+
return [
|
|
1197
|
+
String(consumer.name ?? ""),
|
|
1198
|
+
String(consumer.enabled ?? ""),
|
|
1199
|
+
String(consumer.lastAccess ?? ""),
|
|
1200
|
+
String(consumer.createdAt ?? ""),
|
|
1201
|
+
String(apiKey ?? "")
|
|
1202
|
+
];
|
|
1203
|
+
};
|
|
1204
|
+
const outputConsumerTable = (consumer, showApiKey) => {
|
|
1205
|
+
console.log(renderTable(consumerHeaders, [consumerRow(consumer, showApiKey)], { maxColWidth: 64 }));
|
|
1206
|
+
};
|
|
1207
|
+
const outputConsumers = (consumers, showApiKey) => {
|
|
1208
|
+
const rows = consumers.map((consumer) => consumerRow(consumer, showApiKey));
|
|
1115
1209
|
console.log(renderTable(consumerHeaders, rows, { maxColWidth: 64 }));
|
|
1116
1210
|
};
|
|
1117
1211
|
const requireInteractive = (message) => {
|
|
@@ -1131,11 +1225,11 @@ const updateConsumer = async (consumerService, consumer, name, enabled) => {
|
|
|
1131
1225
|
updateMask
|
|
1132
1226
|
});
|
|
1133
1227
|
};
|
|
1134
|
-
const listConsumers = async (consumerService) => {
|
|
1228
|
+
const listConsumers = async (consumerService, showApiKey) => {
|
|
1135
1229
|
outputConsumers(await fetchAllPages((pageToken) => consumerService.ListConsumers({
|
|
1136
1230
|
pageSize: void 0,
|
|
1137
1231
|
pageToken
|
|
1138
|
-
}), (res) => res?.consumers ?? [], (res) => res?.nextPageToken || void 0));
|
|
1232
|
+
}), (res) => res?.consumers ?? [], (res) => res?.nextPageToken || void 0), showApiKey);
|
|
1139
1233
|
};
|
|
1140
1234
|
const resolveConsumerForUpdate = async (consumerService, id) => {
|
|
1141
1235
|
if (id) return consumerService.GetConsumer({ id });
|
|
@@ -1153,14 +1247,14 @@ const createConsumer = async (consumerService) => {
|
|
|
1153
1247
|
const enabled = await promptKeyEnabled(true);
|
|
1154
1248
|
let consumer = await consumerService.CreateConsumer({});
|
|
1155
1249
|
consumer = await updateConsumer(consumerService, consumer, name, enabled);
|
|
1156
|
-
outputConsumerTable(consumer);
|
|
1250
|
+
outputConsumerTable(consumer, true);
|
|
1157
1251
|
console.log("Please store this API key securely.");
|
|
1158
1252
|
};
|
|
1159
1253
|
const updateConsumerById = async (consumerService, id) => {
|
|
1160
1254
|
requireInteractiveForAction("update");
|
|
1161
1255
|
const selected = await resolveConsumerForUpdate(consumerService, id);
|
|
1162
1256
|
if (!selected?.id) return;
|
|
1163
|
-
outputConsumerTable(await updateConsumer(consumerService, selected, await promptKeyName(selected.name), await promptKeyEnabled(selected.enabled ?? true)));
|
|
1257
|
+
outputConsumerTable(await updateConsumer(consumerService, selected, await promptKeyName(selected.name), await promptKeyEnabled(selected.enabled ?? true)), false);
|
|
1164
1258
|
};
|
|
1165
1259
|
const deleteConsumerById = async (consumerService, id) => {
|
|
1166
1260
|
requireInteractiveForAction("delete");
|
|
@@ -1168,18 +1262,20 @@ const deleteConsumerById = async (consumerService, id) => {
|
|
|
1168
1262
|
if (!selected?.id) return;
|
|
1169
1263
|
if (!await confirmDelete(selected)) return;
|
|
1170
1264
|
await consumerService.DeleteConsumer({ id: selected.id });
|
|
1171
|
-
outputConsumerTable(selected);
|
|
1265
|
+
outputConsumerTable(selected, false);
|
|
1172
1266
|
};
|
|
1173
1267
|
const registerKeysCommands = (program) => {
|
|
1174
1268
|
const keys = program.command("keys").description("Manage API keys");
|
|
1269
|
+
keys.option("--show", "Show full API keys");
|
|
1175
1270
|
keys.allowExcessArguments(false);
|
|
1176
|
-
keys.action(async () => {
|
|
1271
|
+
keys.action(async (options) => {
|
|
1177
1272
|
const { consumerService } = createApiClients({});
|
|
1178
|
-
await listConsumers(consumerService);
|
|
1273
|
+
await listConsumers(consumerService, Boolean(options.show));
|
|
1179
1274
|
});
|
|
1180
|
-
keys.command("list").description("List API keys").action(async () => {
|
|
1275
|
+
keys.command("list").description("List API keys").option("--show", "Show full API keys").action(async (options, command) => {
|
|
1181
1276
|
const { consumerService } = createApiClients({});
|
|
1182
|
-
|
|
1277
|
+
const parentShow = Boolean(command.parent?.opts().show);
|
|
1278
|
+
await listConsumers(consumerService, Boolean(options.show) || parentShow);
|
|
1183
1279
|
});
|
|
1184
1280
|
keys.command("create").description("Create an API key").action(async () => {
|
|
1185
1281
|
const { consumerService } = createApiClients({});
|
package/package.json
CHANGED
package/src/cmd/codex.ts
CHANGED
|
@@ -5,7 +5,7 @@ import type { Command } from "commander";
|
|
|
5
5
|
import prompts from "prompts";
|
|
6
6
|
import { createApiClients } from "../core/api/client";
|
|
7
7
|
import {
|
|
8
|
-
|
|
8
|
+
getCodexModelChoices,
|
|
9
9
|
mapReasoningValue,
|
|
10
10
|
REASONING_CHOICES,
|
|
11
11
|
REASONING_FUZZY_CHOICES,
|
|
@@ -42,12 +42,14 @@ const requireInteractive = () => {
|
|
|
42
42
|
}
|
|
43
43
|
};
|
|
44
44
|
|
|
45
|
-
const promptModel = async () =>
|
|
46
|
-
await
|
|
45
|
+
const promptModel = async () => {
|
|
46
|
+
const choices = await getCodexModelChoices();
|
|
47
|
+
return await fuzzySelect({
|
|
47
48
|
message:
|
|
48
|
-
"Select Model and Effort\nAccess legacy models by running codex -m <model_name> or in your config.toml",
|
|
49
|
-
choices
|
|
49
|
+
"Select Model and Effort\nAccess legacy models by running getrouter codex -m <model_name> or in your config.toml",
|
|
50
|
+
choices,
|
|
50
51
|
});
|
|
52
|
+
};
|
|
51
53
|
|
|
52
54
|
const promptReasoning = async (model: string) =>
|
|
53
55
|
await fuzzySelect({
|
|
@@ -58,13 +60,21 @@ const promptReasoning = async (model: string) =>
|
|
|
58
60
|
const formatReasoningLabel = (id: string) =>
|
|
59
61
|
REASONING_CHOICES.find((choice) => choice.id === id)?.label ?? id;
|
|
60
62
|
|
|
63
|
+
type CodexCommandOptions = {
|
|
64
|
+
model?: string;
|
|
65
|
+
};
|
|
66
|
+
|
|
61
67
|
export const registerCodexCommand = (program: Command) => {
|
|
62
68
|
program
|
|
63
69
|
.command("codex")
|
|
64
70
|
.description("Configure Codex")
|
|
65
|
-
.
|
|
71
|
+
.option("-m, --model <model>", "Set codex model (skips model selection)")
|
|
72
|
+
.action(async (options: CodexCommandOptions) => {
|
|
66
73
|
requireInteractive();
|
|
67
|
-
const model =
|
|
74
|
+
const model =
|
|
75
|
+
options.model && options.model.trim().length > 0
|
|
76
|
+
? options.model.trim()
|
|
77
|
+
: await promptModel();
|
|
68
78
|
if (!model) return;
|
|
69
79
|
const reasoningId = await promptReasoning(model);
|
|
70
80
|
if (!reasoningId) return;
|
package/src/cmd/env.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import os from "node:os";
|
|
2
2
|
import type { Command } from "commander";
|
|
3
3
|
import { createApiClients } from "../core/api/client";
|
|
4
|
+
import { resolveConfigDir } from "../core/config/paths";
|
|
4
5
|
import { selectConsumer } from "../core/interactive/keys";
|
|
5
6
|
import {
|
|
6
7
|
appendRcIfMissing,
|
|
@@ -12,7 +13,6 @@ import {
|
|
|
12
13
|
getHookFilePath,
|
|
13
14
|
renderEnv,
|
|
14
15
|
renderHook,
|
|
15
|
-
resolveConfigDir,
|
|
16
16
|
resolveEnvShell,
|
|
17
17
|
resolveShellRcPath,
|
|
18
18
|
trySourceEnv,
|
package/src/cmd/keys.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Command } from "commander";
|
|
2
2
|
import { createApiClients } from "../core/api/client";
|
|
3
3
|
import { fetchAllPages } from "../core/api/pagination";
|
|
4
|
+
import { redactSecrets } from "../core/config/redact";
|
|
4
5
|
import {
|
|
5
6
|
confirmDelete,
|
|
6
7
|
promptKeyEnabled,
|
|
@@ -23,22 +24,32 @@ const consumerHeaders = [
|
|
|
23
24
|
"API_KEY",
|
|
24
25
|
];
|
|
25
26
|
|
|
26
|
-
const consumerRow = (consumer: ConsumerLike) =>
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
27
|
+
const consumerRow = (consumer: ConsumerLike, showApiKey: boolean) => {
|
|
28
|
+
const { apiKey } = showApiKey
|
|
29
|
+
? consumer
|
|
30
|
+
: (redactSecrets(consumer as Record<string, unknown>) as ConsumerLike);
|
|
31
|
+
return [
|
|
32
|
+
String(consumer.name ?? ""),
|
|
33
|
+
String(consumer.enabled ?? ""),
|
|
34
|
+
String(consumer.lastAccess ?? ""),
|
|
35
|
+
String(consumer.createdAt ?? ""),
|
|
36
|
+
String(apiKey ?? ""),
|
|
37
|
+
];
|
|
38
|
+
};
|
|
33
39
|
|
|
34
|
-
const outputConsumerTable = (consumer: ConsumerLike) => {
|
|
40
|
+
const outputConsumerTable = (consumer: ConsumerLike, showApiKey: boolean) => {
|
|
35
41
|
console.log(
|
|
36
|
-
renderTable(consumerHeaders, [consumerRow(consumer)], {
|
|
42
|
+
renderTable(consumerHeaders, [consumerRow(consumer, showApiKey)], {
|
|
43
|
+
maxColWidth: 64,
|
|
44
|
+
}),
|
|
37
45
|
);
|
|
38
46
|
};
|
|
39
47
|
|
|
40
|
-
const outputConsumers = (
|
|
41
|
-
|
|
48
|
+
const outputConsumers = (
|
|
49
|
+
consumers: routercommonv1_Consumer[],
|
|
50
|
+
showApiKey: boolean,
|
|
51
|
+
) => {
|
|
52
|
+
const rows = consumers.map((consumer) => consumerRow(consumer, showApiKey));
|
|
42
53
|
console.log(renderTable(consumerHeaders, rows, { maxColWidth: 64 }));
|
|
43
54
|
};
|
|
44
55
|
|
|
@@ -81,6 +92,7 @@ const updateConsumer = async (
|
|
|
81
92
|
|
|
82
93
|
const listConsumers = async (
|
|
83
94
|
consumerService: Pick<ConsumerService, "ListConsumers">,
|
|
95
|
+
showApiKey: boolean,
|
|
84
96
|
) => {
|
|
85
97
|
const consumers = await fetchAllPages(
|
|
86
98
|
(pageToken) =>
|
|
@@ -91,7 +103,7 @@ const listConsumers = async (
|
|
|
91
103
|
(res) => res?.consumers ?? [],
|
|
92
104
|
(res) => res?.nextPageToken || undefined,
|
|
93
105
|
);
|
|
94
|
-
outputConsumers(consumers);
|
|
106
|
+
outputConsumers(consumers, showApiKey);
|
|
95
107
|
};
|
|
96
108
|
|
|
97
109
|
const resolveConsumerForUpdate = async (
|
|
@@ -124,7 +136,7 @@ const createConsumer = async (
|
|
|
124
136
|
const enabled = await promptKeyEnabled(true);
|
|
125
137
|
let consumer = await consumerService.CreateConsumer({});
|
|
126
138
|
consumer = await updateConsumer(consumerService, consumer, name, enabled);
|
|
127
|
-
outputConsumerTable(consumer);
|
|
139
|
+
outputConsumerTable(consumer, true);
|
|
128
140
|
console.log("Please store this API key securely.");
|
|
129
141
|
};
|
|
130
142
|
|
|
@@ -146,7 +158,7 @@ const updateConsumerById = async (
|
|
|
146
158
|
name,
|
|
147
159
|
enabled,
|
|
148
160
|
);
|
|
149
|
-
outputConsumerTable(consumer);
|
|
161
|
+
outputConsumerTable(consumer, false);
|
|
150
162
|
};
|
|
151
163
|
|
|
152
164
|
const deleteConsumerById = async (
|
|
@@ -162,24 +174,27 @@ const deleteConsumerById = async (
|
|
|
162
174
|
const confirmed = await confirmDelete(selected);
|
|
163
175
|
if (!confirmed) return;
|
|
164
176
|
await consumerService.DeleteConsumer({ id: selected.id });
|
|
165
|
-
outputConsumerTable(selected);
|
|
177
|
+
outputConsumerTable(selected, false);
|
|
166
178
|
};
|
|
167
179
|
|
|
168
180
|
export const registerKeysCommands = (program: Command) => {
|
|
169
181
|
const keys = program.command("keys").description("Manage API keys");
|
|
182
|
+
keys.option("--show", "Show full API keys");
|
|
170
183
|
keys.allowExcessArguments(false);
|
|
171
184
|
|
|
172
|
-
keys.action(async () => {
|
|
185
|
+
keys.action(async (options: { show?: boolean }) => {
|
|
173
186
|
const { consumerService } = createApiClients({});
|
|
174
|
-
await listConsumers(consumerService);
|
|
187
|
+
await listConsumers(consumerService, Boolean(options.show));
|
|
175
188
|
});
|
|
176
189
|
|
|
177
190
|
keys
|
|
178
191
|
.command("list")
|
|
179
192
|
.description("List API keys")
|
|
180
|
-
.
|
|
193
|
+
.option("--show", "Show full API keys")
|
|
194
|
+
.action(async (options: { show?: boolean }, command: Command) => {
|
|
181
195
|
const { consumerService } = createApiClients({});
|
|
182
|
-
|
|
196
|
+
const parentShow = Boolean(command.parent?.opts().show);
|
|
197
|
+
await listConsumers(consumerService, Boolean(options.show) || parentShow);
|
|
183
198
|
});
|
|
184
199
|
|
|
185
200
|
keys
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { requestJson } from "../http/request";
|
|
2
|
+
|
|
3
|
+
const asTrimmedString = (value: unknown): string | null => {
|
|
4
|
+
if (typeof value !== "string") return null;
|
|
5
|
+
const trimmed = value.trim();
|
|
6
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
const buildProviderModelsPath = (tag?: string) => {
|
|
10
|
+
const query = new URLSearchParams();
|
|
11
|
+
if (tag) query.set("tag", tag);
|
|
12
|
+
const qs = query.toString();
|
|
13
|
+
return `v1/dashboard/providers/models${qs ? `?${qs}` : ""}`;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const listProviderModels = async ({
|
|
17
|
+
tag,
|
|
18
|
+
fetchImpl,
|
|
19
|
+
}: {
|
|
20
|
+
tag?: string;
|
|
21
|
+
fetchImpl?: typeof fetch;
|
|
22
|
+
}): Promise<string[]> => {
|
|
23
|
+
const res = await requestJson<{ models?: unknown }>({
|
|
24
|
+
path: buildProviderModelsPath(tag),
|
|
25
|
+
method: "GET",
|
|
26
|
+
fetchImpl,
|
|
27
|
+
maxRetries: 0,
|
|
28
|
+
});
|
|
29
|
+
const raw = res?.models;
|
|
30
|
+
const models = Array.isArray(raw) ? raw : [];
|
|
31
|
+
return models.map(asTrimmedString).filter(Boolean) as string[];
|
|
32
|
+
};
|
package/src/core/config/fs.ts
CHANGED
|
@@ -1,10 +1,41 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
|
|
4
|
+
const getCorruptBackupPath = (filePath: string) => {
|
|
5
|
+
const dir = path.dirname(filePath);
|
|
6
|
+
const ext = path.extname(filePath);
|
|
7
|
+
const base = path.basename(filePath, ext);
|
|
8
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
9
|
+
const rand = Math.random().toString(16).slice(2, 8);
|
|
10
|
+
return path.join(dir, `${base}.corrupt-${stamp}-${rand}${ext}`);
|
|
11
|
+
};
|
|
12
|
+
|
|
4
13
|
export const readJsonFile = <T = unknown>(filePath: string): T | null => {
|
|
5
14
|
if (!fs.existsSync(filePath)) return null;
|
|
6
|
-
|
|
7
|
-
|
|
15
|
+
let raw: string;
|
|
16
|
+
try {
|
|
17
|
+
raw = fs.readFileSync(filePath, "utf8");
|
|
18
|
+
} catch {
|
|
19
|
+
console.warn(`⚠️ Unable to read ${filePath}. Continuing with defaults.`);
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
return JSON.parse(raw) as T;
|
|
25
|
+
} catch {
|
|
26
|
+
const backupPath = getCorruptBackupPath(filePath);
|
|
27
|
+
try {
|
|
28
|
+
fs.renameSync(filePath, backupPath);
|
|
29
|
+
console.warn(
|
|
30
|
+
`⚠️ Invalid JSON in ${filePath}. Moved to ${backupPath} and continuing with defaults.`,
|
|
31
|
+
);
|
|
32
|
+
} catch {
|
|
33
|
+
console.warn(
|
|
34
|
+
`⚠️ Invalid JSON in ${filePath}. Please fix or delete this file, then try again.`,
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
8
39
|
};
|
|
9
40
|
|
|
10
41
|
export const writeJsonFile = (filePath: string, value: unknown) => {
|
package/src/core/config/index.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
|
-
import os from "node:os";
|
|
3
|
-
import path from "node:path";
|
|
4
2
|
import { readJsonFile, writeJsonFile } from "./fs";
|
|
3
|
+
import { getAuthPath, getConfigPath } from "./paths";
|
|
5
4
|
import {
|
|
6
5
|
type AuthState,
|
|
7
6
|
type ConfigFile,
|
|
@@ -9,12 +8,6 @@ import {
|
|
|
9
8
|
defaultConfig,
|
|
10
9
|
} from "./types";
|
|
11
10
|
|
|
12
|
-
const resolveConfigDir = () =>
|
|
13
|
-
process.env.GETROUTER_CONFIG_DIR || path.join(os.homedir(), ".getrouter");
|
|
14
|
-
|
|
15
|
-
const getConfigPath = () => path.join(resolveConfigDir(), "config.json");
|
|
16
|
-
const getAuthPath = () => path.join(resolveConfigDir(), "auth.json");
|
|
17
|
-
|
|
18
11
|
export const readConfig = (): ConfigFile => ({
|
|
19
12
|
...defaultConfig(),
|
|
20
13
|
...(readJsonFile<ConfigFile>(getConfigPath()) ?? {}),
|
|
@@ -32,6 +25,7 @@ export const writeAuth = (auth: AuthState) => {
|
|
|
32
25
|
const authPath = getAuthPath();
|
|
33
26
|
writeJsonFile(authPath, auth);
|
|
34
27
|
if (process.platform !== "win32") {
|
|
28
|
+
// Restrict token file permissions on Unix-like systems.
|
|
35
29
|
fs.chmodSync(authPath, 0o600);
|
|
36
30
|
}
|
|
37
31
|
};
|
package/src/core/config/paths.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
import os from "node:os";
|
|
1
2
|
import path from "node:path";
|
|
2
|
-
import { getConfigDir } from "../paths";
|
|
3
3
|
|
|
4
|
-
export const
|
|
5
|
-
|
|
4
|
+
export const resolveConfigDir = () =>
|
|
5
|
+
process.env.GETROUTER_CONFIG_DIR || path.join(os.homedir(), ".getrouter");
|
|
6
|
+
|
|
7
|
+
export const getConfigPath = () => path.join(resolveConfigDir(), "config.json");
|
|
8
|
+
export const getAuthPath = () => path.join(resolveConfigDir(), "auth.json");
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { listProviderModels } from "../api/providerModels";
|
|
1
2
|
import type { FuzzyChoice } from "./fuzzy";
|
|
2
3
|
|
|
3
4
|
export type ReasoningChoice = {
|
|
@@ -35,6 +36,26 @@ export const MODEL_CHOICES: FuzzyChoice<string>[] = [
|
|
|
35
36
|
},
|
|
36
37
|
];
|
|
37
38
|
|
|
39
|
+
export const getCodexModelChoices = async (): Promise<
|
|
40
|
+
FuzzyChoice<string>[]
|
|
41
|
+
> => {
|
|
42
|
+
try {
|
|
43
|
+
const remoteModels = await listProviderModels({ tag: "codex" });
|
|
44
|
+
const remoteChoices = remoteModels.map((model) => ({
|
|
45
|
+
title: model,
|
|
46
|
+
value: model,
|
|
47
|
+
keywords: [model, "codex"],
|
|
48
|
+
}));
|
|
49
|
+
|
|
50
|
+
if (remoteChoices.length > 0) {
|
|
51
|
+
remoteChoices.sort((a, b) => a.title.localeCompare(b.title));
|
|
52
|
+
return remoteChoices;
|
|
53
|
+
}
|
|
54
|
+
} catch {}
|
|
55
|
+
|
|
56
|
+
return MODEL_CHOICES;
|
|
57
|
+
};
|
|
58
|
+
|
|
38
59
|
export const REASONING_CHOICES: ReasoningChoice[] = [
|
|
39
60
|
{
|
|
40
61
|
id: "low",
|
package/src/core/setup/codex.ts
CHANGED
|
@@ -45,6 +45,7 @@ export const mergeCodexToml = (content: string, input: CodexConfigInput) => {
|
|
|
45
45
|
let firstHeaderIndex: number | null = null;
|
|
46
46
|
const rootFound = new Set<string>();
|
|
47
47
|
|
|
48
|
+
// Update root keys that appear before any section headers.
|
|
48
49
|
for (let i = 0; i < updated.length; i += 1) {
|
|
49
50
|
const headerMatch = matchHeader(updated[i] ?? "");
|
|
50
51
|
if (headerMatch) {
|
|
@@ -66,6 +67,7 @@ export const mergeCodexToml = (content: string, input: CodexConfigInput) => {
|
|
|
66
67
|
}
|
|
67
68
|
}
|
|
68
69
|
|
|
70
|
+
// Insert missing root keys before the first section header (or at EOF).
|
|
69
71
|
const insertIndex = firstHeaderIndex ?? updated.length;
|
|
70
72
|
const missingRoot = ROOT_KEYS.filter((key) => !rootFound.has(key)).map(
|
|
71
73
|
(key) => `${key} = ${rootValueMap[key]}`,
|
|
@@ -76,6 +78,7 @@ export const mergeCodexToml = (content: string, input: CodexConfigInput) => {
|
|
|
76
78
|
updated.splice(insertIndex, 0, ...missingRoot, ...(needsBlank ? [""] : []));
|
|
77
79
|
}
|
|
78
80
|
|
|
81
|
+
// Ensure the provider section exists and keep its keys in sync.
|
|
79
82
|
const providerHeader = `[${PROVIDER_SECTION}]`;
|
|
80
83
|
const providerHeaderIndex = updated.findIndex(
|
|
81
84
|
(line) => line.trim() === providerHeader,
|
|
@@ -91,6 +94,7 @@ export const mergeCodexToml = (content: string, input: CodexConfigInput) => {
|
|
|
91
94
|
return updated.join("\n");
|
|
92
95
|
}
|
|
93
96
|
|
|
97
|
+
// Find the provider section bounds for in-place updates.
|
|
94
98
|
let providerEnd = updated.length;
|
|
95
99
|
for (let i = providerHeaderIndex + 1; i < updated.length; i += 1) {
|
|
96
100
|
if (matchHeader(updated[i] ?? "")) {
|
package/src/core/setup/env.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { execSync } from "node:child_process";
|
|
2
2
|
import fs from "node:fs";
|
|
3
|
-
import os from "node:os";
|
|
4
3
|
import path from "node:path";
|
|
5
4
|
|
|
6
5
|
export type EnvVars = {
|
|
@@ -14,11 +13,21 @@ export type EnvShell = "sh" | "ps1";
|
|
|
14
13
|
|
|
15
14
|
export type RcShell = "zsh" | "bash" | "fish" | "pwsh";
|
|
16
15
|
|
|
16
|
+
const quoteEnvValue = (shell: EnvShell, value: string) => {
|
|
17
|
+
if (shell === "ps1") {
|
|
18
|
+
// PowerShell: single quotes are literal; escape by doubling.
|
|
19
|
+
return `'${value.replaceAll("'", "''")}'`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// POSIX shell: use single quotes; escape embedded single quotes with: '\''.
|
|
23
|
+
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
24
|
+
};
|
|
25
|
+
|
|
17
26
|
const renderLine = (shell: EnvShell, key: string, value: string) => {
|
|
18
27
|
if (shell === "ps1") {
|
|
19
|
-
return `$env:${key}
|
|
28
|
+
return `$env:${key}=${quoteEnvValue(shell, value)}`;
|
|
20
29
|
}
|
|
21
|
-
return `export ${key}=${value}`;
|
|
30
|
+
return `export ${key}=${quoteEnvValue(shell, value)}`;
|
|
22
31
|
};
|
|
23
32
|
|
|
24
33
|
export const renderEnv = (shell: EnvShell, vars: EnvVars) => {
|
|
@@ -39,6 +48,7 @@ export const renderEnv = (shell: EnvShell, vars: EnvVars) => {
|
|
|
39
48
|
return lines.join("\n");
|
|
40
49
|
};
|
|
41
50
|
|
|
51
|
+
// Wrap getrouter to source env after successful codex/claude runs.
|
|
42
52
|
export const renderHook = (shell: RcShell) => {
|
|
43
53
|
if (shell === "pwsh") {
|
|
44
54
|
return [
|
|
@@ -126,6 +136,7 @@ export const writeEnvFile = (filePath: string, content: string) => {
|
|
|
126
136
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
127
137
|
fs.writeFileSync(filePath, content, "utf8");
|
|
128
138
|
if (process.platform !== "win32") {
|
|
139
|
+
// Limit env file readability since it can contain API keys.
|
|
129
140
|
fs.chmodSync(filePath, 0o600);
|
|
130
141
|
}
|
|
131
142
|
};
|
|
@@ -215,6 +226,3 @@ export const appendRcIfMissing = (rcPath: string, line: string) => {
|
|
|
215
226
|
fs.writeFileSync(rcPath, `${content}${prefix}${line}\n`, "utf8");
|
|
216
227
|
return true;
|
|
217
228
|
};
|
|
218
|
-
|
|
219
|
-
export const resolveConfigDir = () =>
|
|
220
|
-
process.env.GETROUTER_CONFIG_DIR || path.join(os.homedir(), ".getrouter");
|
package/tests/cmd/codex.test.ts
CHANGED
|
@@ -2,7 +2,7 @@ import fs from "node:fs";
|
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import prompts from "prompts";
|
|
5
|
-
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
6
6
|
import { createProgram } from "../../src/cli";
|
|
7
7
|
import { createApiClients } from "../../src/core/api/client";
|
|
8
8
|
import type { ConsumerService } from "../../src/generated/router/dashboard/v1";
|
|
@@ -32,6 +32,7 @@ const originalEnv = Object.fromEntries(
|
|
|
32
32
|
);
|
|
33
33
|
|
|
34
34
|
afterEach(() => {
|
|
35
|
+
vi.unstubAllGlobals();
|
|
35
36
|
setStdinTTY(originalIsTTY);
|
|
36
37
|
prompts.inject([]);
|
|
37
38
|
for (const key of ENV_KEYS) {
|
|
@@ -44,6 +45,57 @@ afterEach(() => {
|
|
|
44
45
|
});
|
|
45
46
|
|
|
46
47
|
describe("codex command", () => {
|
|
48
|
+
beforeEach(() => {
|
|
49
|
+
vi.stubGlobal(
|
|
50
|
+
"fetch",
|
|
51
|
+
vi.fn().mockResolvedValue({
|
|
52
|
+
ok: true,
|
|
53
|
+
status: 200,
|
|
54
|
+
json: vi.fn().mockResolvedValue({ models: [] }),
|
|
55
|
+
}),
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("fetches codex models from remote endpoint", async () => {
|
|
60
|
+
setStdinTTY(true);
|
|
61
|
+
const dir = makeDir();
|
|
62
|
+
process.env.HOME = dir;
|
|
63
|
+
|
|
64
|
+
const fetchMock = vi.fn().mockResolvedValue({
|
|
65
|
+
ok: true,
|
|
66
|
+
status: 200,
|
|
67
|
+
json: vi.fn().mockResolvedValue({
|
|
68
|
+
models: ["gpt-5.2-codex"],
|
|
69
|
+
}),
|
|
70
|
+
});
|
|
71
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
72
|
+
|
|
73
|
+
prompts.inject(["gpt-5.2-codex", "extra_high", mockConsumer, true]);
|
|
74
|
+
(createApiClients as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
|
75
|
+
consumerService: {
|
|
76
|
+
ListConsumers: vi.fn().mockResolvedValue({
|
|
77
|
+
consumers: [
|
|
78
|
+
{
|
|
79
|
+
id: "c1",
|
|
80
|
+
name: "dev",
|
|
81
|
+
enabled: true,
|
|
82
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
83
|
+
},
|
|
84
|
+
],
|
|
85
|
+
}),
|
|
86
|
+
GetConsumer: vi.fn().mockResolvedValue(mockConsumer),
|
|
87
|
+
} as unknown as ConsumerService,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const program = createProgram();
|
|
91
|
+
await program.parseAsync(["node", "getrouter", "codex"]);
|
|
92
|
+
|
|
93
|
+
expect(fetchMock).toHaveBeenCalled();
|
|
94
|
+
expect(String(fetchMock.mock.calls[0]?.[0] ?? "")).toContain(
|
|
95
|
+
"/v1/dashboard/providers/models?tag=codex",
|
|
96
|
+
);
|
|
97
|
+
});
|
|
98
|
+
|
|
47
99
|
it("writes codex config and auth after interactive flow", async () => {
|
|
48
100
|
setStdinTTY(true);
|
|
49
101
|
const dir = makeDir();
|
|
@@ -144,4 +196,38 @@ describe("codex command", () => {
|
|
|
144
196
|
expect(auth.OPENAI_API_KEY).toBe("key-123");
|
|
145
197
|
expect(auth.OTHER).toBe("keep");
|
|
146
198
|
});
|
|
199
|
+
|
|
200
|
+
it("supports -m to set a custom model", async () => {
|
|
201
|
+
setStdinTTY(true);
|
|
202
|
+
const dir = makeDir();
|
|
203
|
+
process.env.HOME = dir;
|
|
204
|
+
prompts.inject(["extra_high", mockConsumer, true]);
|
|
205
|
+
(createApiClients as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
|
206
|
+
consumerService: {
|
|
207
|
+
ListConsumers: vi.fn().mockResolvedValue({
|
|
208
|
+
consumers: [
|
|
209
|
+
{
|
|
210
|
+
id: "c1",
|
|
211
|
+
name: "dev",
|
|
212
|
+
enabled: true,
|
|
213
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
214
|
+
},
|
|
215
|
+
],
|
|
216
|
+
}),
|
|
217
|
+
GetConsumer: vi.fn().mockResolvedValue(mockConsumer),
|
|
218
|
+
} as unknown as ConsumerService,
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
const program = createProgram();
|
|
222
|
+
await program.parseAsync([
|
|
223
|
+
"node",
|
|
224
|
+
"getrouter",
|
|
225
|
+
"codex",
|
|
226
|
+
"-m",
|
|
227
|
+
"legacy-model",
|
|
228
|
+
]);
|
|
229
|
+
|
|
230
|
+
const config = fs.readFileSync(codexConfigPath(dir), "utf8");
|
|
231
|
+
expect(config).toContain('model = "legacy-model"');
|
|
232
|
+
});
|
|
147
233
|
});
|
package/tests/cmd/keys.test.ts
CHANGED
|
@@ -38,7 +38,7 @@ afterEach(() => {
|
|
|
38
38
|
});
|
|
39
39
|
|
|
40
40
|
describe("keys command", () => {
|
|
41
|
-
it("lists keys with
|
|
41
|
+
it("lists keys with redacted apiKey by default", async () => {
|
|
42
42
|
setStdinTTY(false);
|
|
43
43
|
(createApiClients as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
|
44
44
|
consumerService: {
|
|
@@ -54,6 +54,24 @@ describe("keys command", () => {
|
|
|
54
54
|
expect(output).toContain("API_KEY");
|
|
55
55
|
expect(output).toContain("NAME");
|
|
56
56
|
expect(output).not.toContain("ID");
|
|
57
|
+
expect(output).toContain("abcd...WXYZ");
|
|
58
|
+
expect(output).not.toContain("abcd1234WXYZ");
|
|
59
|
+
log.mockRestore();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("lists keys with full apiKey when --show is provided", async () => {
|
|
63
|
+
setStdinTTY(false);
|
|
64
|
+
(createApiClients as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
|
65
|
+
consumerService: {
|
|
66
|
+
ListConsumers: vi.fn().mockResolvedValue({ consumers: [mockConsumer] }),
|
|
67
|
+
} as unknown as ConsumerService,
|
|
68
|
+
subscriptionService: emptySubscriptionService,
|
|
69
|
+
authService: emptyAuthService,
|
|
70
|
+
});
|
|
71
|
+
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
72
|
+
const program = createProgram();
|
|
73
|
+
await program.parseAsync(["node", "getrouter", "keys", "list", "--show"]);
|
|
74
|
+
const output = log.mock.calls.map((c) => c[0]).join("\n");
|
|
57
75
|
expect(output).toContain("abcd1234WXYZ");
|
|
58
76
|
log.mockRestore();
|
|
59
77
|
});
|
|
@@ -73,6 +91,24 @@ describe("keys command", () => {
|
|
|
73
91
|
const output = log.mock.calls.map((c) => c[0]).join("\n");
|
|
74
92
|
expect(output).toContain("NAME");
|
|
75
93
|
expect(output).not.toContain("ID");
|
|
94
|
+
expect(output).toContain("abcd...WXYZ");
|
|
95
|
+
expect(output).not.toContain("abcd1234WXYZ");
|
|
96
|
+
log.mockRestore();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("lists keys when no subcommand and --show is provided", async () => {
|
|
100
|
+
setStdinTTY(true);
|
|
101
|
+
(createApiClients as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
|
102
|
+
consumerService: {
|
|
103
|
+
ListConsumers: vi.fn().mockResolvedValue({ consumers: [mockConsumer] }),
|
|
104
|
+
} as unknown as ConsumerService,
|
|
105
|
+
subscriptionService: emptySubscriptionService,
|
|
106
|
+
authService: emptyAuthService,
|
|
107
|
+
});
|
|
108
|
+
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
109
|
+
const program = createProgram();
|
|
110
|
+
await program.parseAsync(["node", "getrouter", "keys", "--show"]);
|
|
111
|
+
const output = log.mock.calls.map((c) => c[0]).join("\n");
|
|
76
112
|
expect(output).toContain("abcd1234WXYZ");
|
|
77
113
|
log.mockRestore();
|
|
78
114
|
});
|
|
@@ -81,10 +117,15 @@ describe("keys command", () => {
|
|
|
81
117
|
setStdinTTY(false);
|
|
82
118
|
const program = createProgram();
|
|
83
119
|
program.exitOverride();
|
|
84
|
-
|
|
120
|
+
const silentOutput = {
|
|
85
121
|
writeErr: () => {},
|
|
86
122
|
writeOut: () => {},
|
|
87
|
-
|
|
123
|
+
outputError: () => {},
|
|
124
|
+
};
|
|
125
|
+
program.configureOutput(silentOutput);
|
|
126
|
+
program.commands
|
|
127
|
+
.find((command) => command.name() === "keys")
|
|
128
|
+
?.configureOutput(silentOutput);
|
|
88
129
|
await expect(
|
|
89
130
|
program.parseAsync(["node", "getrouter", "keys", "get", "c1"]),
|
|
90
131
|
).rejects.toBeTruthy();
|
package/tests/config/fs.test.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { describe, expect, it, vi } from "vitest";
|
|
5
5
|
import { readJsonFile, writeJsonFile } from "../../src/core/config/fs";
|
|
6
6
|
|
|
7
7
|
describe("config fs", () => {
|
|
@@ -11,4 +11,25 @@ describe("config fs", () => {
|
|
|
11
11
|
writeJsonFile(file, { hello: "world" });
|
|
12
12
|
expect(readJsonFile(file)).toEqual({ hello: "world" });
|
|
13
13
|
});
|
|
14
|
+
|
|
15
|
+
it("tolerates invalid JSON by returning null and backing up the file", () => {
|
|
16
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "getrouter-"));
|
|
17
|
+
const file = path.join(dir, "config.json");
|
|
18
|
+
fs.writeFileSync(file, "{", "utf8");
|
|
19
|
+
|
|
20
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
21
|
+
try {
|
|
22
|
+
expect(readJsonFile(file)).toBeNull();
|
|
23
|
+
} finally {
|
|
24
|
+
warnSpy.mockRestore();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
expect(fs.existsSync(file)).toBe(false);
|
|
28
|
+
const backups = fs
|
|
29
|
+
.readdirSync(dir)
|
|
30
|
+
.filter(
|
|
31
|
+
(name) => name.startsWith("config.corrupt-") && name.endsWith(".json"),
|
|
32
|
+
);
|
|
33
|
+
expect(backups.length).toBe(1);
|
|
34
|
+
});
|
|
14
35
|
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { describe, expect, it, vi } from "vitest";
|
|
5
5
|
import {
|
|
6
6
|
readAuth,
|
|
7
7
|
readConfig,
|
|
@@ -34,6 +34,21 @@ describe("config read/write", () => {
|
|
|
34
34
|
expect(auth.expiresAt).toBe("c");
|
|
35
35
|
});
|
|
36
36
|
|
|
37
|
+
it("falls back to defaults when config.json is invalid JSON", () => {
|
|
38
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "getrouter-"));
|
|
39
|
+
process.env.GETROUTER_CONFIG_DIR = dir;
|
|
40
|
+
fs.writeFileSync(path.join(dir, "config.json"), "{", "utf8");
|
|
41
|
+
|
|
42
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
43
|
+
try {
|
|
44
|
+
const cfg = readConfig();
|
|
45
|
+
expect(cfg.apiBase).toBe("https://getrouter.dev");
|
|
46
|
+
expect(cfg.json).toBe(false);
|
|
47
|
+
} finally {
|
|
48
|
+
warnSpy.mockRestore();
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
37
52
|
it("defaults tokenType to Bearer", () => {
|
|
38
53
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "getrouter-"));
|
|
39
54
|
process.env.GETROUTER_CONFIG_DIR = dir;
|
|
@@ -1,10 +1,33 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
1
4
|
import { describe, expect, it } from "vitest";
|
|
2
5
|
import { getAuthPath, getConfigPath } from "../../src/core/config/paths";
|
|
3
6
|
|
|
4
7
|
describe("config paths", () => {
|
|
8
|
+
const originalConfigDir = process.env.GETROUTER_CONFIG_DIR;
|
|
9
|
+
|
|
10
|
+
const restore = () => {
|
|
11
|
+
if (originalConfigDir === undefined) {
|
|
12
|
+
delete process.env.GETROUTER_CONFIG_DIR;
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
process.env.GETROUTER_CONFIG_DIR = originalConfigDir;
|
|
16
|
+
};
|
|
17
|
+
|
|
5
18
|
it("returns ~/.getrouter paths", () => {
|
|
19
|
+
delete process.env.GETROUTER_CONFIG_DIR;
|
|
6
20
|
expect(getConfigPath()).toContain(".getrouter");
|
|
7
21
|
expect(getConfigPath()).toContain("config.json");
|
|
8
22
|
expect(getAuthPath()).toContain("auth.json");
|
|
23
|
+
restore();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("uses GETROUTER_CONFIG_DIR when set", () => {
|
|
27
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "getrouter-"));
|
|
28
|
+
process.env.GETROUTER_CONFIG_DIR = dir;
|
|
29
|
+
expect(getConfigPath()).toBe(path.join(dir, "config.json"));
|
|
30
|
+
expect(getAuthPath()).toBe(path.join(dir, "auth.json"));
|
|
31
|
+
restore();
|
|
9
32
|
});
|
|
10
33
|
});
|
|
@@ -1,11 +1,16 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
import {
|
|
3
|
+
getCodexModelChoices,
|
|
3
4
|
MODEL_CHOICES,
|
|
4
5
|
mapReasoningValue,
|
|
5
6
|
REASONING_CHOICES,
|
|
6
7
|
} from "../../../src/core/interactive/codex";
|
|
7
8
|
|
|
8
9
|
describe("codex interactive helpers", () => {
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
vi.unstubAllGlobals();
|
|
12
|
+
});
|
|
13
|
+
|
|
9
14
|
it("maps extra high to xhigh", () => {
|
|
10
15
|
expect(mapReasoningValue("extra_high")).toBe("xhigh");
|
|
11
16
|
});
|
|
@@ -14,4 +19,23 @@ describe("codex interactive helpers", () => {
|
|
|
14
19
|
expect(MODEL_CHOICES.length).toBeGreaterThan(0);
|
|
15
20
|
expect(REASONING_CHOICES.length).toBeGreaterThan(0);
|
|
16
21
|
});
|
|
22
|
+
|
|
23
|
+
it("fetches codex models from ListProviderModels(tag=codex)", async () => {
|
|
24
|
+
const fetchMock = vi.fn().mockResolvedValue({
|
|
25
|
+
ok: true,
|
|
26
|
+
status: 200,
|
|
27
|
+
json: vi.fn().mockResolvedValue({
|
|
28
|
+
models: ["new-codex-model-xyz"],
|
|
29
|
+
}),
|
|
30
|
+
});
|
|
31
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
32
|
+
|
|
33
|
+
const choices = await getCodexModelChoices();
|
|
34
|
+
expect(
|
|
35
|
+
choices.some((choice) => choice.value === "new-codex-model-xyz"),
|
|
36
|
+
).toBe(true);
|
|
37
|
+
expect(String(fetchMock.mock.calls[0]?.[0] ?? "")).toContain(
|
|
38
|
+
"/v1/dashboard/providers/models?tag=codex",
|
|
39
|
+
);
|
|
40
|
+
});
|
|
17
41
|
});
|
|
@@ -23,17 +23,31 @@ describe("setup env helpers", () => {
|
|
|
23
23
|
it("renders sh env", () => {
|
|
24
24
|
const output = renderEnv("sh", vars);
|
|
25
25
|
expect(output).toContain(
|
|
26
|
-
"export OPENAI_BASE_URL=https://api.getrouter.dev/codex",
|
|
26
|
+
"export OPENAI_BASE_URL='https://api.getrouter.dev/codex'",
|
|
27
27
|
);
|
|
28
|
-
expect(output).toContain("export ANTHROPIC_API_KEY=key-123");
|
|
28
|
+
expect(output).toContain("export ANTHROPIC_API_KEY='key-123'");
|
|
29
29
|
});
|
|
30
30
|
|
|
31
31
|
it("renders ps1 env", () => {
|
|
32
32
|
const output = renderEnv("ps1", vars);
|
|
33
33
|
expect(output).toContain(
|
|
34
|
-
|
|
34
|
+
"$env:OPENAI_BASE_URL='https://api.getrouter.dev/codex'",
|
|
35
35
|
);
|
|
36
|
-
expect(output).toContain(
|
|
36
|
+
expect(output).toContain("$env:ANTHROPIC_API_KEY='key-123'");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("escapes values safely", () => {
|
|
40
|
+
expect(
|
|
41
|
+
renderEnv("sh", {
|
|
42
|
+
openaiApiKey: "a'b",
|
|
43
|
+
}),
|
|
44
|
+
).toContain("export OPENAI_API_KEY='a'\\''b'");
|
|
45
|
+
|
|
46
|
+
expect(
|
|
47
|
+
renderEnv("ps1", {
|
|
48
|
+
openaiApiKey: "a'b",
|
|
49
|
+
}),
|
|
50
|
+
).toContain("$env:OPENAI_API_KEY='a''b'");
|
|
37
51
|
});
|
|
38
52
|
|
|
39
53
|
it("writes env file", () => {
|
package/tsconfig.json
CHANGED
package/src/core/paths.ts
DELETED
package/tests/paths.test.ts
DELETED