@juspay/neurolink 12.3.0 → 12.4.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.
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Gemini CLI client configurator.
3
+ *
4
+ * Gemini CLI was reachable through the proxy long before this file existed —
5
+ * `GOOGLE_GEMINI_BASE_URL` pointed at the proxy's `/v1beta` door works — but
6
+ * nothing wrote it, so every user had to know that and export it by hand. The
7
+ * proxy already serves the door (`geminiProxyRoutes`, `POST
8
+ * /v1beta/models/{model}:generateContent`); this only closes the onboarding
9
+ * gap.
10
+ *
11
+ * **Why a file and not an env script.** Copilot is configured through
12
+ * `~/.neurolink/copilot-env.sh`, which the user must source from their shell
13
+ * profile. On the machine this was developed against, nothing sourced it: the
14
+ * writer reported success, `applyAllClients` counted it applied, and Copilot
15
+ * had never once used the proxy. A file the CLI reads on its own has no such
16
+ * silent-failure mode. Gemini CLI loads `~/.gemini/.env` (its own error text
17
+ * says "set it in your environment or ~/.gemini/.env"), so that is what this
18
+ * writes.
19
+ *
20
+ * The snapshot lives in `~/.neurolink/`, following `codex.ts`, never inside
21
+ * the file being managed — see `openCode.ts` for what that cost.
22
+ */
23
+ import type { CliProxyClientConfigurator } from "../../types/index.js";
24
+ /** Gemini CLI's config directory. Fixed; it has no XDG override. */
25
+ declare function getGeminiConfigDir(): string;
26
+ declare function getGeminiEnvPath(): string;
27
+ declare function getGeminiSnapshotPath(): string;
28
+ /**
29
+ * Rewrite the two managed variables, preserving every other line verbatim.
30
+ *
31
+ * A whole-file rewrite would be simpler and wrong: `.env` is the user's file,
32
+ * it may hold unrelated keys for other tools, and comments and ordering are
33
+ * theirs to keep.
34
+ */
35
+ declare function upsertEnvVars(original: string, vars: Record<string, string>): string;
36
+ /** Remove the managed variables, leaving the rest of the file untouched. */
37
+ declare function removeEnvVars(original: string, keys: string[]): string;
38
+ export declare function setGeminiProxySettings(baseUrl: string, proxyKey?: string): Promise<boolean>;
39
+ export declare function clearGeminiProxySettings(expectedBaseUrl?: string): Promise<boolean>;
40
+ /** Test-only export (CLAUDE.md rule 15 determinism exception). See openCode.ts. */
41
+ export declare const __geminiTestHooks: {
42
+ getGeminiConfigDir: typeof getGeminiConfigDir;
43
+ getGeminiEnvPath: typeof getGeminiEnvPath;
44
+ getGeminiSnapshotPath: typeof getGeminiSnapshotPath;
45
+ setGeminiProxySettings: typeof setGeminiProxySettings;
46
+ clearGeminiProxySettings: typeof clearGeminiProxySettings;
47
+ upsertEnvVars: typeof upsertEnvVars;
48
+ removeEnvVars: typeof removeEnvVars;
49
+ };
50
+ export declare const geminiConfigurator: CliProxyClientConfigurator;
51
+ export {};
@@ -0,0 +1,278 @@
1
+ /**
2
+ * Gemini CLI client configurator.
3
+ *
4
+ * Gemini CLI was reachable through the proxy long before this file existed —
5
+ * `GOOGLE_GEMINI_BASE_URL` pointed at the proxy's `/v1beta` door works — but
6
+ * nothing wrote it, so every user had to know that and export it by hand. The
7
+ * proxy already serves the door (`geminiProxyRoutes`, `POST
8
+ * /v1beta/models/{model}:generateContent`); this only closes the onboarding
9
+ * gap.
10
+ *
11
+ * **Why a file and not an env script.** Copilot is configured through
12
+ * `~/.neurolink/copilot-env.sh`, which the user must source from their shell
13
+ * profile. On the machine this was developed against, nothing sourced it: the
14
+ * writer reported success, `applyAllClients` counted it applied, and Copilot
15
+ * had never once used the proxy. A file the CLI reads on its own has no such
16
+ * silent-failure mode. Gemini CLI loads `~/.gemini/.env` (its own error text
17
+ * says "set it in your environment or ~/.gemini/.env"), so that is what this
18
+ * writes.
19
+ *
20
+ * The snapshot lives in `~/.neurolink/`, following `codex.ts`, never inside
21
+ * the file being managed — see `openCode.ts` for what that cost.
22
+ */
23
+ import { homedir } from "os";
24
+ import { join } from "path";
25
+ import { logger } from "../../utils/logger.js";
26
+ import { isUsableSnapshot, writeFileAtomic } from "./snapshot.js";
27
+ /** Gemini CLI's config directory. Fixed; it has no XDG override. */
28
+ function getGeminiConfigDir() {
29
+ return join(homedir(), ".gemini");
30
+ }
31
+ function getGeminiEnvPath() {
32
+ return join(getGeminiConfigDir(), ".env");
33
+ }
34
+ function getGeminiSnapshotPath() {
35
+ return join(homedir(), ".neurolink", "gemini-proxy-snapshot.json");
36
+ }
37
+ /** The two variables this writer owns. Every other line is the user's. */
38
+ const BASE_URL_VAR = "GOOGLE_GEMINI_BASE_URL";
39
+ const API_KEY_VAR = "GEMINI_API_KEY";
40
+ /**
41
+ * The proxy's Gemini door needs no real credential — it terminates the
42
+ * client's key and swaps in a pooled account — but Gemini CLI refuses to start
43
+ * in API-key mode without *something* set, exactly as OpenCode's placeholder
44
+ * does.
45
+ */
46
+ const PLACEHOLDER_KEY = "neurolink-proxy";
47
+ /**
48
+ * Rewrite the two managed variables, preserving every other line verbatim.
49
+ *
50
+ * A whole-file rewrite would be simpler and wrong: `.env` is the user's file,
51
+ * it may hold unrelated keys for other tools, and comments and ordering are
52
+ * theirs to keep.
53
+ */
54
+ function upsertEnvVars(original, vars) {
55
+ let text = original;
56
+ for (const [key, value] of Object.entries(vars)) {
57
+ // Match an assignment at line start, tolerating `export ` and surrounding
58
+ // spaces. Anchored per-line so a key mentioned inside a comment or another
59
+ // value is not rewritten.
60
+ const re = new RegExp(`^[ \\t]*(?:export[ \\t]+)?${key}[ \\t]*=.*$`, "m");
61
+ const line = `${key}=${value}`;
62
+ // A function replacement, never a string: `String.replace` expands `$&`,
63
+ // `$1` and friends inside a replacement *string*, so a proxy key
64
+ // containing `$&` would be stored as the matched assignment instead of
65
+ // itself. The callback form treats the value as literal.
66
+ text = re.test(text)
67
+ ? text.replace(re, () => line)
68
+ : `${text.length > 0 && !text.endsWith("\n") ? `${text}\n` : text}${line}\n`;
69
+ }
70
+ return text;
71
+ }
72
+ /**
73
+ * Read the managed variables' values out of an `.env` body.
74
+ *
75
+ * Restore needs the original *values*, not the original file: replaying a
76
+ * whole snapshot would discard everything the user changed after apply().
77
+ */
78
+ function readManagedVars(envText) {
79
+ const out = {};
80
+ for (const key of [BASE_URL_VAR, API_KEY_VAR]) {
81
+ const m = new RegExp(`^[ \\t]*(?:export[ \\t]+)?${key}[ \\t]*=(.*)$`, "m").exec(envText);
82
+ if (m) {
83
+ out[key] = m[1] ?? "";
84
+ }
85
+ }
86
+ return out;
87
+ }
88
+ /** Remove the managed variables, leaving the rest of the file untouched. */
89
+ function removeEnvVars(original, keys) {
90
+ let text = original;
91
+ for (const key of keys) {
92
+ const re = new RegExp(`^[ \\t]*(?:export[ \\t]+)?${key}[ \\t]*=.*(?:\\r?\\n|$)`, "m");
93
+ text = text.replace(re, "");
94
+ }
95
+ return text;
96
+ }
97
+ async function readGeminiSnapshot() {
98
+ const fs = await import("fs");
99
+ let parsed;
100
+ try {
101
+ parsed = JSON.parse(fs.readFileSync(getGeminiSnapshotPath(), "utf8"));
102
+ }
103
+ catch {
104
+ return null;
105
+ }
106
+ // Without `originalEnv` there is nothing to write back, and the restore path
107
+ // would hand `undefined` to writeFileAtomic and throw — which
108
+ // restoreAllClients catches, leaving the user pointed at a dead proxy with
109
+ // no visible failure.
110
+ if (!isUsableSnapshot(parsed, "originalEnv")) {
111
+ logger.debug("[proxy] Gemini: ignoring a malformed snapshot rather than treating it as empty");
112
+ return null;
113
+ }
114
+ const originalEnv = parsed.originalEnv;
115
+ if (originalEnv !== null && typeof originalEnv !== "string") {
116
+ logger.debug("[proxy] Gemini: snapshot originalEnv has the wrong type");
117
+ return null;
118
+ }
119
+ return parsed;
120
+ }
121
+ export async function setGeminiProxySettings(baseUrl, proxyKey) {
122
+ const fs = await import("fs");
123
+ try {
124
+ fs.accessSync(getGeminiConfigDir());
125
+ }
126
+ catch {
127
+ // Gemini CLI not installed. Report the skip rather than creating a config
128
+ // directory for a CLI the user does not have.
129
+ return false;
130
+ }
131
+ let original;
132
+ try {
133
+ original = fs.readFileSync(getGeminiEnvPath(), "utf8");
134
+ }
135
+ catch {
136
+ original = null;
137
+ }
138
+ // "No usable snapshot" is not the same as "no snapshot file", and the
139
+ // difference is the user's API key. A file that exists but cannot be parsed
140
+ // used to satisfy the existence check, so apply() skipped recording, wrote
141
+ // the placeholder over the real key, and restore later found nothing to put
142
+ // back and simply removed the variable — the key was gone with no record of
143
+ // it anywhere. Refuse to touch .env instead: returning false is the
144
+ // configurator contract for "nothing was written", so the caller reports a
145
+ // skip rather than a success.
146
+ const existingSnapshot = await readGeminiSnapshot();
147
+ if (existingSnapshot === null && fs.existsSync(getGeminiSnapshotPath())) {
148
+ logger.warn("[proxy] Gemini: snapshot file is unreadable; leaving .env untouched rather than overwriting credentials with no way back");
149
+ return false;
150
+ }
151
+ // Snapshot once — but only while the existing record still describes the
152
+ // file. If restore ran and could not delete the snapshot, or the user edited
153
+ // a managed variable afterwards, the stored record is stale: reusing it would
154
+ // make the NEXT restore write yesterday's values over today's. Re-capture
155
+ // whenever what is on disk is not what we last wrote.
156
+ const currentManaged = readManagedVars(original ?? "");
157
+ const lastWritten = existingSnapshot?.written;
158
+ const snapshotIsStale = existingSnapshot !== null &&
159
+ (lastWritten === undefined ||
160
+ currentManaged[BASE_URL_VAR] !== lastWritten.baseUrl ||
161
+ currentManaged[API_KEY_VAR] !== lastWritten.apiKey);
162
+ if (snapshotIsStale) {
163
+ logger.debug("[proxy] Gemini: stored snapshot no longer matches .env; re-capturing");
164
+ }
165
+ if (existingSnapshot === null || snapshotIsStale) {
166
+ fs.mkdirSync(join(homedir(), ".neurolink"), { recursive: true });
167
+ // 0o600: a pre-existing .env routinely holds the user's real API key.
168
+ await writeFileAtomic(getGeminiSnapshotPath(), JSON.stringify({
169
+ originalEnv: original,
170
+ written: { baseUrl, apiKey: proxyKey || PLACEHOLDER_KEY },
171
+ }, null, 2), 0o600);
172
+ }
173
+ const next = upsertEnvVars(original ?? "", {
174
+ [BASE_URL_VAR]: baseUrl,
175
+ [API_KEY_VAR]: proxyKey || PLACEHOLDER_KEY,
176
+ });
177
+ // 0o600 on create: this file carries credentials. An existing file keeps its
178
+ // own mode, which is writeFileAtomic's documented behaviour.
179
+ await writeFileAtomic(getGeminiEnvPath(), next, original === null ? 0o600 : undefined);
180
+ return true;
181
+ }
182
+ export async function clearGeminiProxySettings(expectedBaseUrl) {
183
+ const fs = await import("fs");
184
+ let current;
185
+ try {
186
+ current = fs.readFileSync(getGeminiEnvPath(), "utf8");
187
+ }
188
+ catch {
189
+ return false;
190
+ }
191
+ const configured = new RegExp(`^[ \\t]*(?:export[ \\t]+)?${BASE_URL_VAR}[ \\t]*=[ \\t]*(.*)$`, "m").exec(current);
192
+ if (!configured) {
193
+ return false;
194
+ }
195
+ if (expectedBaseUrl && configured[1]?.trim() !== expectedBaseUrl) {
196
+ // Pointed somewhere else — the user's choice, not ours to revert.
197
+ logger.debug("[proxy] Gemini clear: base URL is not the one we wrote, leaving it intact");
198
+ return false;
199
+ }
200
+ const snapshot = await readGeminiSnapshot();
201
+ if (snapshot === null) {
202
+ // No record of what was here before. Stripping the managed variables looks
203
+ // tidy and is destructive: GEMINI_API_KEY may hold the user's real key,
204
+ // and once removed there is nothing left to restore it from. Leaving a
205
+ // stale base URL behind costs the user a failed request they can diagnose;
206
+ // deleting a credential costs them something they cannot get back. Report
207
+ // the skip instead — false is the contract for "nothing was written".
208
+ logger.warn("[proxy] Gemini clear: no usable snapshot, leaving .env untouched rather than removing variables we cannot restore");
209
+ return false;
210
+ }
211
+ if (snapshot.originalEnv === null) {
212
+ // The user had no .env before the proxy created one. Remove it, unless the
213
+ // user has since added lines of their own — then keep theirs.
214
+ const remainder = removeEnvVars(current, [
215
+ BASE_URL_VAR,
216
+ API_KEY_VAR,
217
+ ]).trim();
218
+ if (remainder.length === 0) {
219
+ fs.rmSync(getGeminiEnvPath(), { force: true });
220
+ }
221
+ else {
222
+ await writeFileAtomic(getGeminiEnvPath(), `${remainder}\n`);
223
+ }
224
+ }
225
+ else {
226
+ // Undo our two variables against the CURRENT file rather than writing the
227
+ // snapshot over it. Anything the user changed or added after apply() is
228
+ // theirs and must survive; replaying the whole snapshot would silently
229
+ // discard it. Restoring each managed variable in place also keeps its
230
+ // original position, so an untouched file round-trips byte-for-byte.
231
+ const originalValues = readManagedVars(snapshot.originalEnv);
232
+ let next = current;
233
+ for (const key of [BASE_URL_VAR, API_KEY_VAR]) {
234
+ const originalValue = originalValues[key];
235
+ next =
236
+ originalValue === undefined
237
+ ? removeEnvVars(next, [key])
238
+ : upsertEnvVars(next, { [key]: originalValue });
239
+ }
240
+ await writeFileAtomic(getGeminiEnvPath(), next);
241
+ }
242
+ try {
243
+ fs.rmSync(getGeminiSnapshotPath(), { force: true });
244
+ }
245
+ catch {
246
+ // Harmless: the next apply() overwrites it.
247
+ }
248
+ return true;
249
+ }
250
+ /** Test-only export (CLAUDE.md rule 15 determinism exception). See openCode.ts. */
251
+ export const __geminiTestHooks = {
252
+ getGeminiConfigDir,
253
+ getGeminiEnvPath,
254
+ getGeminiSnapshotPath,
255
+ setGeminiProxySettings,
256
+ clearGeminiProxySettings,
257
+ upsertEnvVars,
258
+ removeEnvVars,
259
+ };
260
+ export const geminiConfigurator = {
261
+ id: "gemini-cli",
262
+ displayName: "Gemini CLI",
263
+ detect: async () => {
264
+ const fs = await import("fs");
265
+ try {
266
+ fs.accessSync(getGeminiConfigDir());
267
+ return true;
268
+ }
269
+ catch {
270
+ return false;
271
+ }
272
+ },
273
+ // Gemini CLI appends `/v1beta/models/...` itself, so it takes the bare
274
+ // origin — unlike OpenCode and Qwen, which need the `/v1` suffix.
275
+ apply: (proxyBaseUrl) => setGeminiProxySettings(proxyBaseUrl),
276
+ restore: (proxyBaseUrl) => clearGeminiProxySettings(proxyBaseUrl),
277
+ };
278
+ //# sourceMappingURL=gemini.js.map
@@ -3,10 +3,50 @@
3
3
  *
4
4
  * Moved verbatim out of `proxy.ts` so that adding a CLI means adding a file
5
5
  * here rather than editing a 5,000-line command module in seven places.
6
+ *
7
+ * Two defects made every config this writer produced unusable, and both are
8
+ * fixed here. They are recorded because each was invisible to the tests that
9
+ * were supposed to cover this file.
10
+ *
11
+ * 1. The snapshot lived in `opencode.json` itself, under two `__proxy_*` keys
12
+ * at the top level. OpenCode validates its config against a closed schema
13
+ * and rejects unknown top-level keys outright:
14
+ *
15
+ * Error: Configuration is invalid at ~/.config/opencode/opencode.json
16
+ * ↳ Unrecognized keys: "__proxy_original_neurolink", "__proxy_written_neurolink"
17
+ *
18
+ * Every `opencode` invocation failed at startup — not just proxied ones —
19
+ * so auto-configuration bricked the CLI it was meant to onboard. The
20
+ * snapshot now lives beside Codex's, in `~/.neurolink/`, which is what
21
+ * `codex.ts` has always done. Claude Code and Qwen embed a snapshot the
22
+ * same way and survive it only because their schemas ignore unknown keys;
23
+ * that is tolerance, not permission, and new writers should not rely on it.
24
+ *
25
+ * 2. `models` was written as `{}`. OpenCode resolves `--model provider/id`
26
+ * against that map and never calls `/v1/models`, so an empty map meant
27
+ * every id was unknown:
28
+ *
29
+ * ProviderModelNotFoundError: providerID "neurolink", suggestions: []
30
+ *
31
+ * Fixing only the keys exposed this one immediately underneath.
32
+ *
33
+ * Configs written by the previous version are repaired in place: both apply()
34
+ * and restore() adopt a legacy in-file snapshot before deleting the keys, so
35
+ * an existing broken config heals on the next `proxy start` without losing the
36
+ * user's original provider block.
6
37
  */
7
38
  import type { CliProxyClientConfigurator } from "../../types/index.js";
8
39
  declare function getOpenCodeConfigDir(): string;
9
40
  declare function getOpenCodeConfigPath(): string;
41
+ /**
42
+ * Where the snapshot of the user's pre-existing `provider.neurolink` lives.
43
+ *
44
+ * Outside `opencode.json`, for the reason in the file header. Persisting it on
45
+ * disk (rather than in process memory) means restoration still works when the
46
+ * proxy crashes or shutdown runs in a different process — the property the
47
+ * in-file version was reaching for.
48
+ */
49
+ declare function getOpenCodeSnapshotPath(): string;
10
50
  export declare function setOpenCodeProxySettings(baseUrl: string, proxyKey?: string): Promise<boolean>;
11
51
  export declare function clearOpenCodeProxySettings(expectedBaseUrl?: string): Promise<boolean>;
12
52
  /**
@@ -19,6 +59,7 @@ export declare function clearOpenCodeProxySettings(expectedBaseUrl?: string): Pr
19
59
  export declare const __openCodeTestHooks: {
20
60
  getOpenCodeConfigDir: typeof getOpenCodeConfigDir;
21
61
  getOpenCodeConfigPath: typeof getOpenCodeConfigPath;
62
+ getOpenCodeSnapshotPath: typeof getOpenCodeSnapshotPath;
22
63
  setOpenCodeProxySettings: typeof setOpenCodeProxySettings;
23
64
  clearOpenCodeProxySettings: typeof clearOpenCodeProxySettings;
24
65
  };