@rerouter/helper 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,38 @@
1
1
  {
2
2
  "name": "@rerouter/helper",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Connect coding agents (Claude, Codex, Cursor, opencode, Continue) to Rerouter in seconds",
5
+ "type": "module",
6
+ "bin": {
7
+ "rerouter-helper": "src/cli.js"
8
+ },
9
+ "engines": {
10
+ "node": ">=22"
11
+ },
12
+ "files": [
13
+ "src"
14
+ ],
15
+ "scripts": {
16
+ "start": "node src/cli.js",
17
+ "test": "node --test src/*.test.js"
18
+ },
19
+ "dependencies": {
20
+ "yaml": "^2.6.1"
21
+ },
22
+ "license": "MIT",
23
+ "keywords": [
24
+ "rerouter",
25
+ "llm",
26
+ "gateway",
27
+ "claude",
28
+ "codex",
29
+ "cursor",
30
+ "opencode",
31
+ "continue",
32
+ "ai",
33
+ "coding-agent"
34
+ ],
35
+ "publishConfig": {
36
+ "access": "public"
37
+ }
38
+ }
package/src/cli.js ADDED
@@ -0,0 +1,266 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from "node:util";
3
+ import readline from "node:readline/promises";
4
+ import { keyInfo, listModels } from "./lib/api.js";
5
+ import { DEFAULT_BRIDGE_PORT, startBridge } from "./lib/bridge-server.js";
6
+ import { MissingKeyError, apiKey, baseUrl } from "./lib/env.js";
7
+ import {
8
+ ALL_TARGETS,
9
+ AUTOMATIC_TARGETS,
10
+ GUIDE_TARGETS,
11
+ SNIPPET_TARGETS,
12
+ applyPlan,
13
+ guide,
14
+ pickSmallModel,
15
+ planTarget,
16
+ snippet,
17
+ } from "./lib/targets.js";
18
+
19
+ const VERSION = "0.1.0";
20
+
21
+ const HELP = `Rerouter helper — connect coding agents to Rerouter.
22
+
23
+ Usage:
24
+ npx @rerouter/helper Interactive wizard
25
+ npx @rerouter/helper models [--search X] List models from catalog (auth)
26
+ npx @rerouter/helper configure --target T --model ID [--small-model ID] [--port N] [--write [--yes]]
27
+ npx @rerouter/helper check-key Key info without inference/billing
28
+ npx @rerouter/helper targets List configuration targets
29
+ npx @rerouter/helper bridge [--port N] [--host H]
30
+ Local Responses API bridge (Codex) → Rerouter
31
+ npx @rerouter/helper --version | --help
32
+
33
+ Automatic targets (config file merge): ${AUTOMATIC_TARGETS.join(", ")}
34
+ Shell snippet targets: ${SNIPPET_TARGETS.join(", ")}
35
+ UI guide targets: ${GUIDE_TARGETS.join(", ")}
36
+
37
+ Env:
38
+ REROUTER_API_KEY Rerouter key (sk-rr-...). Never printed or written to any file.
39
+ REROUTER_BASE_URL Override base URL (default: https://api.rerouter.id).
40
+ `;
41
+
42
+ function fail(err) {
43
+ console.error(`error: ${err.message}`);
44
+ process.exitCode = 1;
45
+ }
46
+
47
+ async function cmdModels(values) {
48
+ const models = await listModels();
49
+ const needle = values.search?.toLowerCase();
50
+ const rows = models
51
+ .map((m) => String(m?.id ?? ""))
52
+ .filter(Boolean)
53
+ .filter((id) => !needle || id.toLowerCase().includes(needle))
54
+ .sort();
55
+ if (!rows.length) {
56
+ console.log(needle ? `No models match "${values.search}".` : "Catalog is empty.");
57
+ return;
58
+ }
59
+ for (const id of rows) console.log(id);
60
+ console.log(`\n${rows.length} models.`);
61
+ }
62
+
63
+ async function cmdCheckKey() {
64
+ const info = await keyInfo();
65
+ const lines = [
66
+ `key: ${info.prefix ?? "????"}…`,
67
+ `status: ${info.status ?? "unknown"}`,
68
+ ];
69
+ if (info.name) lines.push(`name: ${info.name}`);
70
+ if (info.expires_at) lines.push(`expires: ${info.expires_at}`);
71
+ if (Array.isArray(info.model_scope) && info.model_scope.length) {
72
+ lines.push(`model scope: ${info.model_scope.join(", ")}`);
73
+ } else {
74
+ lines.push("model scope: (all models)");
75
+ }
76
+ if (typeof info.has_balance === "boolean") lines.push(`balance: ${info.has_balance ? "ok" : "empty"}`);
77
+ if (info.rate_limits) lines.push(`rate limit: ${JSON.stringify(info.rate_limits)}`);
78
+ console.log(lines.join("\n"));
79
+ }
80
+
81
+ function cmdTargets() {
82
+ console.log("Automatic targets (helper merges config file + backup):");
83
+ for (const t of AUTOMATIC_TARGETS) console.log(` ${t}`);
84
+ console.log("\nShell snippet targets (printed, no files changed):");
85
+ for (const t of SNIPPET_TARGETS) console.log(` ${t}`);
86
+ console.log("\nUI guide targets (manual steps in IDE):");
87
+ for (const t of GUIDE_TARGETS) console.log(` ${t}`);
88
+ }
89
+
90
+ async function confirm(question) {
91
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
92
+ try {
93
+ const answer = await rl.question(`${question} [y/N] `);
94
+ return /^y(es)?$/i.test(answer.trim());
95
+ } finally {
96
+ rl.close();
97
+ }
98
+ }
99
+
100
+ async function cmdConfigure(values) {
101
+ const target = values.target;
102
+ if (!target || !ALL_TARGETS.includes(target)) {
103
+ throw new Error(`--target is required and must be one of: ${ALL_TARGETS.join(", ")}`);
104
+ }
105
+ const opts = { baseUrl: baseUrl(), model: values.model, smallModel: values["small-model"], port: parsePort(values.port) };
106
+
107
+ if (GUIDE_TARGETS.includes(target)) {
108
+ console.log(guide(target, opts));
109
+ return;
110
+ }
111
+ if (SNIPPET_TARGETS.includes(target)) {
112
+ console.log(snippet(target, opts));
113
+ return;
114
+ }
115
+
116
+ // Automatic target: fill small-model from catalog if key available.
117
+ if (target === "claude" && !opts.smallModel && apiKey()) {
118
+ try {
119
+ opts.smallModel = pickSmallModel(await listModels(), opts.model);
120
+ } catch {
121
+ /* catalog failed → fallback to main model */
122
+ }
123
+ }
124
+ const plan = planTarget(target, opts);
125
+ const content = await plan.build();
126
+
127
+ console.log(`# ${plan.description}`);
128
+ console.log(`# file: ${plan.file}\n`);
129
+ console.log(content);
130
+ for (const note of plan.notes ?? []) console.log(`# ${note}`);
131
+
132
+ if (!values.write) {
133
+ console.log("\nPreview only. Add --write to write (with automatic backup).");
134
+ return;
135
+ }
136
+ if (!values.yes) {
137
+ const ok = await confirm(`Write changes to ${plan.file}?`);
138
+ if (!ok) {
139
+ console.log("Cancelled — no files changed.");
140
+ return;
141
+ }
142
+ }
143
+ const results = await applyPlan(plan, content);
144
+ for (const r of results) {
145
+ console.log(`\nwritten: ${r.file}`);
146
+ if (r.backupPath) console.log(`backup: ${r.backupPath}`);
147
+ }
148
+ }
149
+
150
+ function parsePort(value) {
151
+ if (value == null) return undefined;
152
+ const port = Number(value);
153
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) {
154
+ throw new Error(`--port invalid: "${value}" (must be an integer 1-65535)`);
155
+ }
156
+ return port;
157
+ }
158
+
159
+ async function cmdBridge(values) {
160
+ const port = parsePort(values.port) ?? DEFAULT_BRIDGE_PORT;
161
+ const host = values.host?.trim() || "127.0.0.1";
162
+ if (host !== "127.0.0.1" && host !== "localhost" && host !== "::1") {
163
+ throw new Error(`--host "${host}" rejected — bridge must be loopback only (127.0.0.1).`);
164
+ }
165
+ const key = apiKey();
166
+ const bridge = await startBridge({ host, port, baseUrl: baseUrl(), apiKey: key });
167
+
168
+ console.log(`Responses API bridge active: ${bridge.url}`);
169
+ console.log(`Upstream gateway: ${baseUrl()}`);
170
+ if (!key) {
171
+ console.log("\nNote: REROUTER_API_KEY not set — clients must send their own Authorization.");
172
+ }
173
+ console.log("\nConnect Codex (one-time):");
174
+ console.log(` npx @rerouter/helper configure --target codex --model <ID>${port !== DEFAULT_BRIDGE_PORT ? ` --port ${port}` : ""} --write`);
175
+ console.log("\nPress Ctrl+C to stop.");
176
+
177
+ await new Promise((resolve) => {
178
+ const stop = () => {
179
+ void bridge.close().then(resolve, resolve);
180
+ };
181
+ process.once("SIGINT", stop);
182
+ process.once("SIGTERM", stop);
183
+ });
184
+ }
185
+
186
+ async function wizard() {
187
+ console.log(`Rerouter helper v${VERSION} — base URL: ${baseUrl()}`);
188
+ if (!apiKey()) {
189
+ console.log("\nNote: REROUTER_API_KEY not set (model listing disabled).\n");
190
+ }
191
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
192
+ try {
193
+ console.log("\nSelect target:");
194
+ ALL_TARGETS.forEach((t, i) => console.log(` ${i + 1}. ${t}`));
195
+ const pick = await rl.question(`\nTarget [1-${ALL_TARGETS.length}]: `);
196
+ const target = ALL_TARGETS[Number(pick.trim()) - 1];
197
+ if (!target) throw new Error("invalid selection");
198
+
199
+ let model;
200
+ if (apiKey()) {
201
+ try {
202
+ const models = (await listModels()).map((m) => String(m?.id ?? "")).filter(Boolean);
203
+ if (models.length) {
204
+ console.log(`\nAvailable models (first 10): ${models.slice(0, 10).join(", ")}${models.length > 10 ? ` … (+${models.length - 10})` : ""}`);
205
+ }
206
+ } catch {
207
+ /* ignore — manual input */
208
+ }
209
+ }
210
+ model = (await rl.question("Model ID: ")).trim();
211
+ if (!model) throw new Error("model is required");
212
+
213
+ const values = { target, model, write: true, yes: false };
214
+ rl.close();
215
+ await cmdConfigure(values);
216
+ } finally {
217
+ rl.close();
218
+ }
219
+ }
220
+
221
+ async function main() {
222
+ const { values, positionals } = parseArgs({
223
+ allowPositionals: true,
224
+ options: {
225
+ search: { type: "string" },
226
+ target: { type: "string" },
227
+ model: { type: "string" },
228
+ "small-model": { type: "string" },
229
+ port: { type: "string" },
230
+ host: { type: "string" },
231
+ write: { type: "boolean", default: false },
232
+ yes: { type: "boolean", default: false },
233
+ help: { type: "boolean", short: "h", default: false },
234
+ version: { type: "boolean", short: "v", default: false },
235
+ },
236
+ });
237
+
238
+ const command = positionals[0];
239
+ if (values.help) return console.log(HELP);
240
+ if (values.version) return console.log(VERSION);
241
+
242
+ switch (command) {
243
+ case undefined:
244
+ return wizard();
245
+ case "models":
246
+ return cmdModels(values);
247
+ case "configure":
248
+ return cmdConfigure(values);
249
+ case "check-key":
250
+ return cmdCheckKey();
251
+ case "targets":
252
+ return cmdTargets();
253
+ case "bridge":
254
+ return cmdBridge(values);
255
+ default:
256
+ throw new Error(`unknown command: "${command}". Run with --help.`);
257
+ }
258
+ }
259
+
260
+ main().catch((err) => {
261
+ if (err instanceof MissingKeyError) {
262
+ fail(err);
263
+ return;
264
+ }
265
+ fail(err);
266
+ });
package/src/lib/api.js ADDED
@@ -0,0 +1,35 @@
1
+ import { baseUrl, requireApiKey } from "./env.js";
2
+
3
+ async function fetchJson(path, { timeoutMs = 15_000 } = {}) {
4
+ const key = requireApiKey();
5
+ const res = await fetch(`${baseUrl()}${path}`, {
6
+ headers: { authorization: `Bearer ${key}`, accept: "application/json" },
7
+ signal: AbortSignal.timeout(timeoutMs),
8
+ });
9
+ const text = await res.text();
10
+ let body = null;
11
+ try {
12
+ body = text ? JSON.parse(text) : null;
13
+ } catch {
14
+ /* leave null — handled below */
15
+ }
16
+ if (!res.ok) {
17
+ const message =
18
+ (body && typeof body.error === "string" && body.error) ||
19
+ body?.error?.message ||
20
+ `HTTP ${res.status}`;
21
+ throw new Error(`${path} failed: ${message}`);
22
+ }
23
+ return body;
24
+ }
25
+
26
+ /** GET /v1/models — catalog remains auth (decision #1). */
27
+ export async function listModels() {
28
+ const body = await fetchJson("/v1/models");
29
+ return Array.isArray(body?.data) ? body.data : [];
30
+ }
31
+
32
+ /** GET /v1/key/info — zero-inference, no billing. */
33
+ export async function keyInfo() {
34
+ return fetchJson("/v1/key/info");
35
+ }
@@ -0,0 +1,217 @@
1
+ import http from "node:http";
2
+ import {
3
+ ResponsesBridgeError,
4
+ chatJsonToResponse,
5
+ responsesToChatRequest,
6
+ wrapResponsesStream,
7
+ } from "./responses-bridge.js";
8
+
9
+ /**
10
+ * Local Responses API → Chat Completions bridge.
11
+ *
12
+ * Codex CLI only speaks the Responses API (`POST /v1/responses`); the
13
+ * Rerouter gateway speaks Chat Completions. This bridge runs on localhost,
14
+ * translates both ways, and forwards auth as-is — billing, failover, and
15
+ * rate limiting stay at the gateway. No state, no local billing.
16
+ */
17
+
18
+ const MAX_BODY_BYTES = 32 * 1024 * 1024;
19
+
20
+ export const DEFAULT_BRIDGE_PORT = 8787;
21
+
22
+ function sendJson(res, status, body, headers = {}) {
23
+ const payload = JSON.stringify(body);
24
+ res.writeHead(status, {
25
+ "content-type": "application/json; charset=utf-8",
26
+ "content-length": Buffer.byteLength(payload),
27
+ ...headers,
28
+ });
29
+ res.end(payload);
30
+ }
31
+
32
+ function sendError(res, status, message, type = "invalid_request_error", code = null) {
33
+ sendJson(res, status, { error: { type, message, code, param: null } });
34
+ }
35
+
36
+ async function readBody(req, limit = MAX_BODY_BYTES) {
37
+ const chunks = [];
38
+ let size = 0;
39
+ for await (const chunk of req) {
40
+ size += chunk.length;
41
+ if (size > limit) throw new ResponsesBridgeError(413, "Body too large (>32MB).");
42
+ chunks.push(chunk);
43
+ }
44
+ return Buffer.concat(chunks).toString("utf8");
45
+ }
46
+
47
+ function resolveAuth(req, fallbackKey) {
48
+ const header = req.headers.authorization;
49
+ if (typeof header === "string" && header.trim()) return header;
50
+ if (fallbackKey) return `Bearer ${fallbackKey}`;
51
+ return null;
52
+ }
53
+
54
+ /**
55
+ * @param {object} opts
56
+ * @param {string} [opts.host] default 127.0.0.1 — bridge must NOT be exposed.
57
+ * @param {number} [opts.port]
58
+ * @param {string} opts.baseUrl gateway base URL (no trailing slash)
59
+ * @param {string} [opts.apiKey] fallback if the client does not send Authorization
60
+ * @param {typeof fetch} [opts.fetchImpl] for testing
61
+ */
62
+ export async function startBridge({ host = "127.0.0.1", port = DEFAULT_BRIDGE_PORT, baseUrl, apiKey, fetchImpl = fetch }) {
63
+ if (!baseUrl) throw new Error("startBridge: baseUrl is required.");
64
+ const chatUrl = `${baseUrl}/v1/chat/completions`;
65
+ const modelsUrl = `${baseUrl}/v1/models`;
66
+
67
+ const server = http.createServer((req, res) => {
68
+ void handle(req, res).catch((err) => {
69
+ if (res.headersSent) {
70
+ res.end();
71
+ return;
72
+ }
73
+ if (err instanceof ResponsesBridgeError) {
74
+ sendError(res, err.status, err.message, err.type);
75
+ } else {
76
+ sendError(res, 500, `bridge internal error: ${err?.message ?? err}`, "server_error");
77
+ }
78
+ });
79
+ });
80
+
81
+ async function handle(req, res) {
82
+ const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
83
+ const path = url.pathname.replace(/\/+$/, "") || "/";
84
+
85
+ if (req.method === "GET" && (path === "/health" || path === "/")) {
86
+ sendJson(res, 200, { ok: true, bridge: "rerouter-responses", upstream: baseUrl });
87
+ return;
88
+ }
89
+
90
+ if (req.method === "GET" && path === "/v1/models") {
91
+ const auth = resolveAuth(req, apiKey);
92
+ if (!auth) {
93
+ sendError(res, 401, "Missing bearer token (set REROUTER_API_KEY or Authorization header).", "authentication_error");
94
+ return;
95
+ }
96
+ const upstream = await fetchImpl(modelsUrl, { headers: { authorization: auth } });
97
+ const text = await upstream.text();
98
+ res.writeHead(upstream.status, { "content-type": upstream.headers.get("content-type") ?? "application/json" });
99
+ res.end(text);
100
+ return;
101
+ }
102
+
103
+ if (req.method === "POST" && (path === "/v1/responses" || path === "/responses")) {
104
+ await handleResponses(req, res);
105
+ return;
106
+ }
107
+
108
+ sendError(res, 404, `Unknown endpoint: ${req.method} ${path}`);
109
+ }
110
+
111
+ async function handleResponses(req, res) {
112
+ const auth = resolveAuth(req, apiKey);
113
+ if (!auth) {
114
+ sendError(res, 401, "Missing bearer token (set REROUTER_API_KEY or Authorization header).", "authentication_error");
115
+ return;
116
+ }
117
+
118
+ let body;
119
+ try {
120
+ body = JSON.parse(await readBody(req));
121
+ } catch (err) {
122
+ if (err instanceof ResponsesBridgeError) throw err;
123
+ throw new ResponsesBridgeError(400, "Body is not valid JSON.");
124
+ }
125
+
126
+ const { request, customToolNames } = responsesToChatRequest(body);
127
+ const wantsStream = request.stream === true;
128
+
129
+ let upstream;
130
+ try {
131
+ upstream = await fetchImpl(chatUrl, {
132
+ method: "POST",
133
+ headers: {
134
+ "content-type": "application/json",
135
+ accept: wantsStream ? "text/event-stream" : "application/json",
136
+ authorization: auth,
137
+ },
138
+ body: JSON.stringify(request),
139
+ });
140
+ } catch (err) {
141
+ sendError(res, 502, `Gateway unreachable: ${err?.message ?? err}`, "api_error", "upstream_unreachable");
142
+ return;
143
+ }
144
+
145
+ if (!upstream.ok) {
146
+ // Forward the gateway error message (billing/rate-limit/etc.) as clearly as possible.
147
+ const text = await upstream.text();
148
+ let message = text.slice(0, 500);
149
+ try {
150
+ const parsed = JSON.parse(text);
151
+ message = parsed?.error?.message ?? (typeof parsed?.error === "string" ? parsed.error : null) ?? parsed?.message ?? message;
152
+ } catch {
153
+ /* body is not JSON */
154
+ }
155
+ sendError(res, upstream.status, `Upstream ${upstream.status}: ${message}`, "api_error", "upstream_error");
156
+ return;
157
+ }
158
+
159
+ if (!wantsStream) {
160
+ let chat;
161
+ try {
162
+ chat = await upstream.json();
163
+ } catch {
164
+ sendError(res, 502, "Gateway response is not valid JSON.", "api_error", "invalid_upstream_response");
165
+ return;
166
+ }
167
+ sendJson(res, 200, chatJsonToResponse(chat, { requestedModel: request.model, customToolNames }));
168
+ return;
169
+ }
170
+
171
+ if (!upstream.body) {
172
+ sendError(res, 502, "Gateway stream response has no body.", "api_error", "invalid_upstream_response");
173
+ return;
174
+ }
175
+
176
+ res.writeHead(200, {
177
+ "content-type": "text/event-stream; charset=utf-8",
178
+ "cache-control": "no-cache, no-transform",
179
+ connection: "keep-alive",
180
+ "x-accel-buffering": "no",
181
+ });
182
+
183
+ const stream = wrapResponsesStream(upstream.body, { requestedModel: request.model, customToolNames });
184
+ const reader = stream.getReader();
185
+ let closed = false;
186
+ req.on("close", () => {
187
+ closed = true;
188
+ void reader.cancel().catch(() => {});
189
+ });
190
+ try {
191
+ for (;;) {
192
+ const { done, value } = await reader.read();
193
+ if (done || closed) break;
194
+ if (!res.write(value)) {
195
+ await new Promise((resolve) => res.once("drain", resolve));
196
+ }
197
+ }
198
+ } finally {
199
+ res.end();
200
+ }
201
+ }
202
+
203
+ await new Promise((resolve, reject) => {
204
+ server.once("error", reject);
205
+ server.listen(port, host, resolve);
206
+ });
207
+ const address = server.address();
208
+ const boundPort = typeof address === "object" && address ? address.port : port;
209
+
210
+ return {
211
+ server,
212
+ host,
213
+ port: boundPort,
214
+ url: `http://${host}:${boundPort}/v1`,
215
+ close: () => new Promise((resolve) => server.close(resolve)),
216
+ };
217
+ }
package/src/lib/env.js ADDED
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Helper configuration source. SECURITY RULE: the API key is only read from
3
+ * env REROUTER_API_KEY — never accepted as an argument, printed, or written
4
+ * to any file.
5
+ */
6
+
7
+ export const DEFAULT_BASE_URL = "https://api.rerouter.id";
8
+
9
+ export function baseUrl() {
10
+ return (process.env.REROUTER_BASE_URL?.trim() || DEFAULT_BASE_URL).replace(/\/+$/, "");
11
+ }
12
+
13
+ export function apiKey() {
14
+ return process.env.REROUTER_API_KEY?.trim() || null;
15
+ }
16
+
17
+ export class MissingKeyError extends Error {
18
+ constructor() {
19
+ super(
20
+ "REROUTER_API_KEY is not set.\n" +
21
+ " POSIX: export REROUTER_API_KEY=sk-rr-...\n" +
22
+ " PowerShell: $env:REROUTER_API_KEY = \"sk-rr-...\"\n" +
23
+ "Create a key in Rerouter dashboard → API Keys.",
24
+ );
25
+ }
26
+ }
27
+
28
+ export function requireApiKey() {
29
+ const key = apiKey();
30
+ if (!key) throw new MissingKeyError();
31
+ return key;
32
+ }
@@ -0,0 +1,85 @@
1
+ import { promises as fs } from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
5
+ /** Expand `~` to the user home directory. */
6
+ export function expandHome(p) {
7
+ if (p === "~") return os.homedir();
8
+ if (p.startsWith("~/") || p.startsWith("~\\")) return path.join(os.homedir(), p.slice(2));
9
+ return p;
10
+ }
11
+
12
+ export async function readTextIfExists(file) {
13
+ try {
14
+ return await fs.readFile(file, "utf8");
15
+ } catch (err) {
16
+ if (err?.code === "ENOENT") return null;
17
+ throw err;
18
+ }
19
+ }
20
+
21
+ function stamp() {
22
+ return new Date().toISOString().replace(/[-:T]/g, "").slice(0, 14);
23
+ }
24
+
25
+ /** Copy file to `${file}.rerouter-backup-<timestamp>`; no-op if missing. */
26
+ export async function backupFile(file) {
27
+ const existing = await readTextIfExists(file);
28
+ if (existing === null) return null;
29
+ const backupPath = `${file}.rerouter-backup-${stamp()}`;
30
+ await fs.writeFile(backupPath, existing, "utf8");
31
+ return backupPath;
32
+ }
33
+
34
+ export async function writeFileEnsured(file, content) {
35
+ await fs.mkdir(path.dirname(file), { recursive: true });
36
+ await fs.writeFile(file, content, "utf8");
37
+ }
38
+
39
+ /**
40
+ * Parse loose JSONC (line and block comments outside strings + trailing comma).
41
+ * Throws Error if ambiguous/broken — the caller asks for a manual merge and
42
+ * leaves the file intact.
43
+ */
44
+ export function parseJsonc(text) {
45
+ let out = "";
46
+ let i = 0;
47
+ let inString = false;
48
+ let quote = "";
49
+ while (i < text.length) {
50
+ const ch = text[i];
51
+ const next = text[i + 1];
52
+ if (inString) {
53
+ out += ch;
54
+ if (ch === "\\") {
55
+ out += next ?? "";
56
+ i += 2;
57
+ continue;
58
+ }
59
+ if (ch === quote) inString = false;
60
+ i++;
61
+ continue;
62
+ }
63
+ if (ch === '"' || ch === "'") {
64
+ inString = true;
65
+ quote = ch;
66
+ out += ch;
67
+ i++;
68
+ continue;
69
+ }
70
+ if (ch === "/" && next === "/") {
71
+ while (i < text.length && text[i] !== "\n") i++;
72
+ continue;
73
+ }
74
+ if (ch === "/" && next === "*") {
75
+ i += 2;
76
+ while (i < text.length && !(text[i] === "*" && text[i + 1] === "/")) i++;
77
+ i += 2;
78
+ continue;
79
+ }
80
+ out += ch;
81
+ i++;
82
+ }
83
+ const stripped = out.replace(/,\s*([}\]])/g, "$1");
84
+ return JSON.parse(stripped);
85
+ }