@bluenotelogic/harness 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/dist/oauth.js ADDED
@@ -0,0 +1,345 @@
1
+ /**
2
+ * OAuth 2.1 client for Blue Note Memory — authorization-code + PKCE over a loopback
3
+ * redirect, with a device-code fallback for headless machines.
4
+ *
5
+ * WHY LOOPBACK AND NOT A FIXED PORT: this is a public native client, so RFC 8252 §7.3
6
+ * applies — bind an ephemeral port and register a regex redirect. The `blue-note-mem-mcp`
7
+ * client on Blue Note ID already has `http://(127\.0\.0\.1|localhost):[0-9]{1,5}/.*`
8
+ * registered for exactly this. A fixed port would collide with whatever else is running.
9
+ *
10
+ * WHY WE STILL CALL /register: Authentik has no RFC 7591 dynamic client registration, so
11
+ * mem-mcp serves a shim that hands back the pre-registered public client_id. Calling it is
12
+ * the spec-correct discovery path and costs one request; `--client-id` skips it.
13
+ *
14
+ * NO CLIENT SECRET ANYWHERE. A public client cannot keep one, which is the whole point:
15
+ * PKCE is what binds the authorization code to this process.
16
+ */
17
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
18
+ import { createServer } from "node:http";
19
+ import { spawn } from "node:child_process";
20
+ /**
21
+ * PKCE S256. The verifier is 32 random bytes base64url-encoded (43 chars — comfortably
22
+ * inside RFC 7636's 43..128 range); the challenge is its SHA-256, also base64url.
23
+ */
24
+ export function createPkce() {
25
+ const verifier = base64url(randomBytes(32));
26
+ const challenge = base64url(createHash("sha256").update(verifier).digest());
27
+ return { verifier, challenge };
28
+ }
29
+ export function base64url(buf) {
30
+ return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
31
+ }
32
+ /**
33
+ * Constant-time compare for the `state` parameter. Not strictly required — state is an
34
+ * integrity check, not a secret — but a length-independent comparison keeps the callback
35
+ * handler free of an early-exit oracle, and it costs nothing.
36
+ */
37
+ export function safeEqual(a, b) {
38
+ const ba = Buffer.from(a);
39
+ const bb = Buffer.from(b);
40
+ if (ba.length !== bb.length)
41
+ return false;
42
+ return timingSafeEqual(ba, bb);
43
+ }
44
+ /**
45
+ * Every discovered endpoint must be https and live on the same host as the issuer, and
46
+ * must not smuggle credentials in the userinfo part. Same rule the PHP side applies in
47
+ * lib/Auth/BlueNoteOidc.php — a metadata document is attacker-influencable input, so a
48
+ * redirected authorization_endpoint is a credential-phishing primitive if left unchecked.
49
+ *
50
+ * Loopback (http://127.0.0.1) is exempt because that is our OWN redirect target, never a
51
+ * discovered endpoint.
52
+ */
53
+ export function assertSafeEndpoint(url, issuer, what) {
54
+ let u;
55
+ let iss;
56
+ try {
57
+ u = new URL(url);
58
+ iss = new URL(issuer);
59
+ }
60
+ catch {
61
+ throw new Error(`${what} is not a valid URL: ${url}`);
62
+ }
63
+ if (u.protocol !== "https:")
64
+ throw new Error(`${what} must be https, got: ${url}`);
65
+ if (u.username || u.password)
66
+ throw new Error(`${what} must not embed credentials: ${url}`);
67
+ if (u.host !== iss.host) {
68
+ throw new Error(`${what} host (${u.host}) does not match issuer host (${iss.host})`);
69
+ }
70
+ }
71
+ async function getJson(url, what) {
72
+ const res = await fetch(url, { headers: { accept: "application/json" } });
73
+ if (!res.ok)
74
+ throw new Error(`${what}: ${res.status} ${res.statusText} from ${url}`);
75
+ return (await res.json());
76
+ }
77
+ /**
78
+ * RFC 9728 protected-resource metadata -> RFC 8414 authorization-server metadata.
79
+ *
80
+ * The resource document is what a spec-compliant MCP client finds from the endpoint's
81
+ * `401 + WWW-Authenticate: Bearer resource_metadata="..."`. We follow the same chain
82
+ * rather than hardcoding paths, so a self-hosted deployment works with only `--url`.
83
+ */
84
+ export async function discover(baseUrl) {
85
+ const prm = await getJson(`${baseUrl}/.well-known/oauth-protected-resource`, "protected-resource metadata");
86
+ const servers = prm["authorization_servers"];
87
+ const asBase = Array.isArray(servers) && typeof servers[0] === "string" ? servers[0] : baseUrl;
88
+ const doc = await getJson(`${asBase.replace(/\/$/, "")}/.well-known/oauth-authorization-server`, "authorization-server metadata");
89
+ const issuer = String(doc["issuer"] ?? asBase);
90
+ const meta = {
91
+ issuer,
92
+ authorization_endpoint: String(doc["authorization_endpoint"] ?? ""),
93
+ token_endpoint: String(doc["token_endpoint"] ?? ""),
94
+ device_authorization_endpoint: optString(doc["device_authorization_endpoint"]),
95
+ registration_endpoint: optString(doc["registration_endpoint"]),
96
+ code_challenge_methods_supported: Array.isArray(doc["code_challenge_methods_supported"])
97
+ ? doc["code_challenge_methods_supported"]
98
+ : undefined,
99
+ };
100
+ if (!meta.authorization_endpoint || !meta.token_endpoint) {
101
+ throw new Error("authorization-server metadata is missing authorization_endpoint/token_endpoint");
102
+ }
103
+ assertSafeEndpoint(meta.authorization_endpoint, issuer, "authorization_endpoint");
104
+ assertSafeEndpoint(meta.token_endpoint, issuer, "token_endpoint");
105
+ if (meta.device_authorization_endpoint) {
106
+ assertSafeEndpoint(meta.device_authorization_endpoint, issuer, "device_authorization_endpoint");
107
+ }
108
+ if (meta.registration_endpoint) {
109
+ assertSafeEndpoint(meta.registration_endpoint, issuer, "registration_endpoint");
110
+ }
111
+ // Refuse to downgrade. If the server does not advertise S256 we would be left with
112
+ // `plain`, which offers no protection against an intercepted authorization code.
113
+ const methods = meta.code_challenge_methods_supported;
114
+ if (methods && !methods.includes("S256")) {
115
+ throw new Error("authorization server does not advertise PKCE S256 — refusing to continue");
116
+ }
117
+ return meta;
118
+ }
119
+ function optString(v) {
120
+ return typeof v === "string" && v !== "" ? v : undefined;
121
+ }
122
+ /** The DCR shim. Any failure falls back to the caller's default client_id. */
123
+ export async function registerClient(meta, fallbackClientId) {
124
+ if (!meta.registration_endpoint)
125
+ return fallbackClientId;
126
+ try {
127
+ const res = await fetch(meta.registration_endpoint, {
128
+ method: "POST",
129
+ headers: { "content-type": "application/json" },
130
+ body: JSON.stringify({
131
+ client_name: "Blue Note harness CLI",
132
+ redirect_uris: ["http://127.0.0.1/callback"],
133
+ grant_types: ["authorization_code", "refresh_token"],
134
+ response_types: ["code"],
135
+ token_endpoint_auth_method: "none",
136
+ }),
137
+ });
138
+ if (!res.ok)
139
+ return fallbackClientId;
140
+ const body = (await res.json());
141
+ return optString(body["client_id"]) ?? fallbackClientId;
142
+ }
143
+ catch {
144
+ return fallbackClientId;
145
+ }
146
+ }
147
+ async function postForm(url, form) {
148
+ return fetch(url, {
149
+ method: "POST",
150
+ headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
151
+ body: new URLSearchParams(form).toString(),
152
+ });
153
+ }
154
+ /**
155
+ * Browser flow. Resolves with an access token, or rejects on timeout / user denial.
156
+ *
157
+ * The listener binds 127.0.0.1 EXPLICITLY (not 0.0.0.0) so nothing off-box can reach the
158
+ * callback, and it shuts down the moment it has an answer — a lingering listener holding a
159
+ * valid `state` is a needless window.
160
+ */
161
+ export async function authorizeViaBrowser(opts) {
162
+ const { meta, clientId, scope, resource } = opts;
163
+ const log = opts.log ?? (() => { });
164
+ const timeoutMs = opts.timeoutMs ?? 5 * 60_000;
165
+ const pkce = createPkce();
166
+ const state = base64url(randomBytes(24));
167
+ const { code, redirectUri, server } = await new Promise((resolve, reject) => {
168
+ const srv = createServer((req, res) => {
169
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
170
+ if (url.pathname !== "/callback") {
171
+ res.writeHead(404).end("not found");
172
+ return;
173
+ }
174
+ const err = url.searchParams.get("error");
175
+ const gotState = url.searchParams.get("state") ?? "";
176
+ const gotCode = url.searchParams.get("code") ?? "";
177
+ if (err) {
178
+ respond(res, `Authorization failed: ${err}`);
179
+ reject(new Error(`authorization denied: ${err}`));
180
+ return;
181
+ }
182
+ if (!safeEqual(gotState, state)) {
183
+ // A mismatched state means this callback did not come from the request we made.
184
+ respond(res, "State mismatch — request rejected. You can close this tab.");
185
+ reject(new Error("state mismatch — possible CSRF, aborting"));
186
+ return;
187
+ }
188
+ if (!gotCode) {
189
+ respond(res, "No authorization code returned.");
190
+ reject(new Error("no authorization code in callback"));
191
+ return;
192
+ }
193
+ respond(res, "Signed in. You can close this tab and return to the terminal.");
194
+ const addr = srv.address();
195
+ resolve({ code: gotCode, redirectUri: loopbackUri(addr.port), server: srv });
196
+ });
197
+ srv.on("error", reject);
198
+ // Port 0 = let the OS pick a free ephemeral port.
199
+ srv.listen(0, "127.0.0.1", () => {
200
+ const addr = srv.address();
201
+ const authUrl = new URL(meta.authorization_endpoint);
202
+ authUrl.searchParams.set("response_type", "code");
203
+ authUrl.searchParams.set("client_id", clientId);
204
+ authUrl.searchParams.set("redirect_uri", loopbackUri(addr.port));
205
+ authUrl.searchParams.set("scope", scope);
206
+ authUrl.searchParams.set("state", state);
207
+ authUrl.searchParams.set("code_challenge", pkce.challenge);
208
+ authUrl.searchParams.set("code_challenge_method", "S256");
209
+ // RFC 8707: name the resource this token is for, so it cannot be replayed elsewhere.
210
+ authUrl.searchParams.set("resource", resource);
211
+ log(`Opening your browser to sign in to Blue Note ID…`);
212
+ log(` If it does not open, paste this into a browser:\n ${authUrl.toString()}\n`);
213
+ // Say how long the window is. Without this the process just dies at the deadline with no
214
+ // prior warning, which reads as a broken CLI rather than "you had five minutes".
215
+ log(`Waiting for you to sign in… (this window closes in ${Math.round(timeoutMs / 1000)}s — widen it with --timeout)`);
216
+ if (opts.openBrowser !== false)
217
+ openInBrowser(authUrl.toString());
218
+ });
219
+ setTimeout(() => {
220
+ srv.close();
221
+ reject(new Error(`timed out after ${Math.round(timeoutMs / 1000)}s waiting for the browser`));
222
+ }, timeoutMs).unref();
223
+ });
224
+ server.close();
225
+ const res = await postForm(meta.token_endpoint, {
226
+ grant_type: "authorization_code",
227
+ code,
228
+ redirect_uri: redirectUri,
229
+ client_id: clientId,
230
+ code_verifier: pkce.verifier,
231
+ resource,
232
+ });
233
+ if (!res.ok) {
234
+ throw new Error(`token exchange failed: ${res.status} ${await safeText(res)}`);
235
+ }
236
+ return (await res.json());
237
+ }
238
+ function loopbackUri(port) {
239
+ return `http://127.0.0.1:${port}/callback`;
240
+ }
241
+ function respond(res, message) {
242
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
243
+ res.end(`<!doctype html><meta charset="utf-8"><title>Blue Note Memory</title>` +
244
+ `<body style="font:16px/1.5 system-ui,sans-serif;padding:3rem;max-width:34rem;margin:auto">` +
245
+ `<h1 style="font-size:1.25rem">Blue Note Memory</h1><p>${escapeHtml(message)}</p></body>`);
246
+ }
247
+ function escapeHtml(s) {
248
+ return s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
249
+ }
250
+ /**
251
+ * Device-code flow — for a headless box, an SSH session, or any machine where opening a
252
+ * browser is not possible. Authentik supports this grant natively and `blue-note-mem-mcp`
253
+ * already has it enabled, so there is no server-side work behind this path.
254
+ */
255
+ export async function authorizeViaDevice(opts) {
256
+ const { meta, clientId, scope, resource } = opts;
257
+ const log = opts.log ?? (() => { });
258
+ if (!meta.device_authorization_endpoint) {
259
+ throw new Error("this server does not advertise a device_authorization_endpoint");
260
+ }
261
+ const startRes = await postForm(meta.device_authorization_endpoint, {
262
+ client_id: clientId,
263
+ scope,
264
+ });
265
+ if (!startRes.ok) {
266
+ throw new Error(`device authorization failed: ${startRes.status} ${await safeText(startRes)}`);
267
+ }
268
+ const start = (await startRes.json());
269
+ const deviceCode = String(start["device_code"] ?? "");
270
+ const userCode = String(start["user_code"] ?? "");
271
+ const verifyUri = String(start["verification_uri_complete"] ?? start["verification_uri"] ?? "");
272
+ // RFC 8628 §3.5: honour the server's interval; default 5s. Poll faster and you get
273
+ // slow_down responses, which only makes the flow take longer.
274
+ let intervalMs = Math.max(1, Number(start["interval"] ?? 5)) * 1000;
275
+ const serverExpiry = Date.now() + Math.max(60, Number(start["expires_in"] ?? 600)) * 1000;
276
+ // A client-side --timeout can only ever SHORTEN this. The device code's lifetime belongs to
277
+ // the IdP (ours issues 60s), so waiting past it would only poll a code the server has already
278
+ // discarded — take whichever deadline lands first.
279
+ const expiresAt = opts.timeoutMs
280
+ ? Math.min(serverExpiry, Date.now() + opts.timeoutMs)
281
+ : serverExpiry;
282
+ log(`\n Open: ${verifyUri}`);
283
+ log(` Code: ${userCode}\n`);
284
+ // Surface the window: our IdP issues 60-second device codes, which is tight enough that a
285
+ // user who wanders off to find their password comes back to an expired one. Saying so beats
286
+ // a bare "expired" error after the fact.
287
+ log(`Waiting for you to approve… (this code expires in ${Math.round((expiresAt - Date.now()) / 1000)}s)`);
288
+ for (;;) {
289
+ if (Date.now() > expiresAt)
290
+ throw new Error("device code expired before approval");
291
+ await sleep(intervalMs);
292
+ const res = await postForm(meta.token_endpoint, {
293
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
294
+ device_code: deviceCode,
295
+ client_id: clientId,
296
+ resource,
297
+ });
298
+ if (res.ok)
299
+ return (await res.json());
300
+ const body = (await res.json().catch(() => ({})));
301
+ const error = String(body["error"] ?? "");
302
+ if (error === "authorization_pending")
303
+ continue;
304
+ if (error === "slow_down") {
305
+ intervalMs += 5000;
306
+ continue;
307
+ }
308
+ // Blue Note ID (Authentik) answers a still-pending device grant with `invalid_grant`
309
+ // instead of RFC 8628 §3.5's `authorization_pending`. A spec-literal client therefore
310
+ // aborts on the FIRST poll — roughly five seconds in, long before a human can approve —
311
+ // which made --device unusable against our own IdP. Treat it as pending: the expiresAt
312
+ // deadline above still bounds the loop, so a genuinely bad or revoked device_code ends
313
+ // as "device code expired before approval" rather than hanging forever.
314
+ if (error === "invalid_grant")
315
+ continue;
316
+ throw new Error(`device authorization failed: ${error || res.status}`);
317
+ }
318
+ }
319
+ function sleep(ms) {
320
+ return new Promise((r) => setTimeout(r, ms));
321
+ }
322
+ async function safeText(res) {
323
+ try {
324
+ return (await res.text()).slice(0, 300);
325
+ }
326
+ catch {
327
+ return "";
328
+ }
329
+ }
330
+ /**
331
+ * Best-effort browser launch. Failure is non-fatal — the URL is always printed first, so a
332
+ * machine with no default browser still completes the flow by copy/paste.
333
+ */
334
+ function openInBrowser(url) {
335
+ const cmd = process.platform === "win32" ? "cmd" : process.platform === "darwin" ? "open" : "xdg-open";
336
+ // `cmd /c start ""` needs the empty title argument or a quoted URL is taken as the title.
337
+ const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
338
+ try {
339
+ spawn(cmd, args, { stdio: "ignore", detached: true }).unref();
340
+ }
341
+ catch {
342
+ /* printed above; nothing to do */
343
+ }
344
+ }
345
+ //# sourceMappingURL=oauth.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"oauth.js","sourceRoot":"","sources":["../src/oauth.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,OAAO,EAAE,UAAU,EAAE,WAAW,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AACvE,OAAO,EAAE,YAAY,EAAE,MAAM,WAAW,CAAC;AACzC,OAAO,EAAE,KAAK,EAAE,MAAM,oBAAoB,CAAC;AAkB3C;;;GAGG;AACH,MAAM,UAAU,UAAU;IACxB,MAAM,QAAQ,GAAG,SAAS,CAAC,WAAW,CAAC,EAAE,CAAC,CAAC,CAAC;IAC5C,MAAM,SAAS,GAAG,SAAS,CAAC,UAAU,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC,MAAM,EAAE,CAAC,CAAC;IAC5E,OAAO,EAAE,QAAQ,EAAE,SAAS,EAAE,CAAC;AACjC,CAAC;AAED,MAAM,UAAU,SAAS,CAAC,GAAW;IACnC,OAAO,GAAG,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC,OAAO,CAAC,KAAK,EAAE,GAAG,CAAC,CAAC,OAAO,CAAC,KAAK,EAAE,GAAG,CAAC,CAAC,OAAO,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC;AAC3F,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,SAAS,CAAC,CAAS,EAAE,CAAS;IAC5C,MAAM,EAAE,GAAG,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAC1B,MAAM,EAAE,GAAG,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAC1B,IAAI,EAAE,CAAC,MAAM,KAAK,EAAE,CAAC,MAAM;QAAE,OAAO,KAAK,CAAC;IAC1C,OAAO,eAAe,CAAC,EAAE,EAAE,EAAE,CAAC,CAAC;AACjC,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,UAAU,kBAAkB,CAAC,GAAW,EAAE,MAAc,EAAE,IAAY;IAC1E,IAAI,CAAM,CAAC;IACX,IAAI,GAAQ,CAAC;IACb,IAAI,CAAC;QACH,CAAC,GAAG,IAAI,GAAG,CAAC,GAAG,CAAC,CAAC;QACjB,GAAG,GAAG,IAAI,GAAG,CAAC,MAAM,CAAC,CAAC;IACxB,CAAC;IAAC,MAAM,CAAC;QACP,MAAM,IAAI,KAAK,CAAC,GAAG,IAAI,wBAAwB,GAAG,EAAE,CAAC,CAAC;IACxD,CAAC;IACD,IAAI,CAAC,CAAC,QAAQ,KAAK,QAAQ;QAAE,MAAM,IAAI,KAAK,CAAC,GAAG,IAAI,wBAAwB,GAAG,EAAE,CAAC,CAAC;IACnF,IAAI,CAAC,CAAC,QAAQ,IAAI,CAAC,CAAC,QAAQ;QAAE,MAAM,IAAI,KAAK,CAAC,GAAG,IAAI,gCAAgC,GAAG,EAAE,CAAC,CAAC;IAC5F,IAAI,CAAC,CAAC,IAAI,KAAK,GAAG,CAAC,IAAI,EAAE,CAAC;QACxB,MAAM,IAAI,KAAK,CAAC,GAAG,IAAI,UAAU,CAAC,CAAC,IAAI,iCAAiC,GAAG,CAAC,IAAI,GAAG,CAAC,CAAC;IACvF,CAAC;AACH,CAAC;AAED,KAAK,UAAU,OAAO,CAAC,GAAW,EAAE,IAAY;IAC9C,MAAM,GAAG,GAAG,MAAM,KAAK,CAAC,GAAG,EAAE,EAAE,OAAO,EAAE,EAAE,MAAM,EAAE,kBAAkB,EAAE,EAAE,CAAC,CAAC;IAC1E,IAAI,CAAC,GAAG,CAAC,EAAE;QAAE,MAAM,IAAI,KAAK,CAAC,GAAG,IAAI,KAAK,GAAG,CAAC,MAAM,IAAI,GAAG,CAAC,UAAU,SAAS,GAAG,EAAE,CAAC,CAAC;IACrF,OAAO,CAAC,MAAM,GAAG,CAAC,IAAI,EAAE,CAA4B,CAAC;AACvD,CAAC;AAED;;;;;;GAMG;AACH,MAAM,CAAC,KAAK,UAAU,QAAQ,CAAC,OAAe;IAC5C,MAAM,GAAG,GAAG,MAAM,OAAO,CACvB,GAAG,OAAO,uCAAuC,EACjD,6BAA6B,CAC9B,CAAC;IACF,MAAM,OAAO,GAAG,GAAG,CAAC,uBAAuB,CAAC,CAAC;IAC7C,MAAM,MAAM,GACV,KAAK,CAAC,OAAO,CAAC,OAAO,CAAC,IAAI,OAAO,OAAO,CAAC,CAAC,CAAC,KAAK,QAAQ,CAAC,CAAC,CAAE,OAAO,CAAC,CAAC,CAAY,CAAC,CAAC,CAAC,OAAO,CAAC;IAE9F,MAAM,GAAG,GAAG,MAAM,OAAO,CACvB,GAAG,MAAM,CAAC,OAAO,CAAC,KAAK,EAAE,EAAE,CAAC,yCAAyC,EACrE,+BAA+B,CAChC,CAAC;IAEF,MAAM,MAAM,GAAG,MAAM,CAAC,GAAG,CAAC,QAAQ,CAAC,IAAI,MAAM,CAAC,CAAC;IAC/C,MAAM,IAAI,GAAe;QACvB,MAAM;QACN,sBAAsB,EAAE,MAAM,CAAC,GAAG,CAAC,wBAAwB,CAAC,IAAI,EAAE,CAAC;QACnE,cAAc,EAAE,MAAM,CAAC,GAAG,CAAC,gBAAgB,CAAC,IAAI,EAAE,CAAC;QACnD,6BAA6B,EAAE,SAAS,CAAC,GAAG,CAAC,+BAA+B,CAAC,CAAC;QAC9E,qBAAqB,EAAE,SAAS,CAAC,GAAG,CAAC,uBAAuB,CAAC,CAAC;QAC9D,gCAAgC,EAAE,KAAK,CAAC,OAAO,CAAC,GAAG,CAAC,kCAAkC,CAAC,CAAC;YACtF,CAAC,CAAE,GAAG,CAAC,kCAAkC,CAAc;YACvD,CAAC,CAAC,SAAS;KACd,CAAC;IAEF,IAAI,CAAC,IAAI,CAAC,sBAAsB,IAAI,CAAC,IAAI,CAAC,cAAc,EAAE,CAAC;QACzD,MAAM,IAAI,KAAK,CAAC,gFAAgF,CAAC,CAAC;IACpG,CAAC;IACD,kBAAkB,CAAC,IAAI,CAAC,sBAAsB,EAAE,MAAM,EAAE,wBAAwB,CAAC,CAAC;IAClF,kBAAkB,CAAC,IAAI,CAAC,cAAc,EAAE,MAAM,EAAE,gBAAgB,CAAC,CAAC;IAClE,IAAI,IAAI,CAAC,6BAA6B,EAAE,CAAC;QACvC,kBAAkB,CAAC,IAAI,CAAC,6BAA6B,EAAE,MAAM,EAAE,+BAA+B,CAAC,CAAC;IAClG,CAAC;IACD,IAAI,IAAI,CAAC,qBAAqB,EAAE,CAAC;QAC/B,kBAAkB,CAAC,IAAI,CAAC,qBAAqB,EAAE,MAAM,EAAE,uBAAuB,CAAC,CAAC;IAClF,CAAC;IAED,mFAAmF;IACnF,iFAAiF;IACjF,MAAM,OAAO,GAAG,IAAI,CAAC,gCAAgC,CAAC;IACtD,IAAI,OAAO,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAC,EAAE,CAAC;QACzC,MAAM,IAAI,KAAK,CAAC,0EAA0E,CAAC,CAAC;IAC9F,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC;AAED,SAAS,SAAS,CAAC,CAAU;IAC3B,OAAO,OAAO,CAAC,KAAK,QAAQ,IAAI,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC;AAC3D,CAAC;AAED,8EAA8E;AAC9E,MAAM,CAAC,KAAK,UAAU,cAAc,CAClC,IAAgB,EAChB,gBAAwB;IAExB,IAAI,CAAC,IAAI,CAAC,qBAAqB;QAAE,OAAO,gBAAgB,CAAC;IACzD,IAAI,CAAC;QACH,MAAM,GAAG,GAAG,MAAM,KAAK,CAAC,IAAI,CAAC,qBAAqB,EAAE;YAClD,MAAM,EAAE,MAAM;YACd,OAAO,EAAE,EAAE,cAAc,EAAE,kBAAkB,EAAE;YAC/C,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC;gBACnB,WAAW,EAAE,uBAAuB;gBACpC,aAAa,EAAE,CAAC,2BAA2B,CAAC;gBAC5C,WAAW,EAAE,CAAC,oBAAoB,EAAE,eAAe,CAAC;gBACpD,cAAc,EAAE,CAAC,MAAM,CAAC;gBACxB,0BAA0B,EAAE,MAAM;aACnC,CAAC;SACH,CAAC,CAAC;QACH,IAAI,CAAC,GAAG,CAAC,EAAE;YAAE,OAAO,gBAAgB,CAAC;QACrC,MAAM,IAAI,GAAG,CAAC,MAAM,GAAG,CAAC,IAAI,EAAE,CAA4B,CAAC;QAC3D,OAAO,SAAS,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC,IAAI,gBAAgB,CAAC;IAC1D,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,gBAAgB,CAAC;IAC1B,CAAC;AACH,CAAC;AASD,KAAK,UAAU,QAAQ,CAAC,GAAW,EAAE,IAA4B;IAC/D,OAAO,KAAK,CAAC,GAAG,EAAE;QAChB,MAAM,EAAE,MAAM;QACd,OAAO,EAAE,EAAE,cAAc,EAAE,mCAAmC,EAAE,MAAM,EAAE,kBAAkB,EAAE;QAC5F,IAAI,EAAE,IAAI,eAAe,CAAC,IAAI,CAAC,CAAC,QAAQ,EAAE;KAC3C,CAAC,CAAC;AACL,CAAC;AAED;;;;;;GAMG;AACH,MAAM,CAAC,KAAK,UAAU,mBAAmB,CAAC,IAQzC;IACC,MAAM,EAAE,IAAI,EAAE,QAAQ,EAAE,KAAK,EAAE,QAAQ,EAAE,GAAG,IAAI,CAAC;IACjD,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,IAAI,CAAC,GAAG,EAAE,GAAE,CAAC,CAAC,CAAC;IACnC,MAAM,SAAS,GAAG,IAAI,CAAC,SAAS,IAAI,CAAC,GAAG,MAAM,CAAC;IAC/C,MAAM,IAAI,GAAG,UAAU,EAAE,CAAC;IAC1B,MAAM,KAAK,GAAG,SAAS,CAAC,WAAW,CAAC,EAAE,CAAC,CAAC,CAAC;IAEzC,MAAM,EAAE,IAAI,EAAE,WAAW,EAAE,MAAM,EAAE,GAAG,MAAM,IAAI,OAAO,CAIpD,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;QACrB,MAAM,GAAG,GAAG,YAAY,CAAC,CAAC,GAAG,EAAE,GAAG,EAAE,EAAE;YACpC,MAAM,GAAG,GAAG,IAAI,GAAG,CAAC,GAAG,CAAC,GAAG,IAAI,GAAG,EAAE,kBAAkB,CAAC,CAAC;YACxD,IAAI,GAAG,CAAC,QAAQ,KAAK,WAAW,EAAE,CAAC;gBACjC,GAAG,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC,GAAG,CAAC,WAAW,CAAC,CAAC;gBACpC,OAAO;YACT,CAAC;YACD,MAAM,GAAG,GAAG,GAAG,CAAC,YAAY,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC;YAC1C,MAAM,QAAQ,GAAG,GAAG,CAAC,YAAY,CAAC,GAAG,CAAC,OAAO,CAAC,IAAI,EAAE,CAAC;YACrD,MAAM,OAAO,GAAG,GAAG,CAAC,YAAY,CAAC,GAAG,CAAC,MAAM,CAAC,IAAI,EAAE,CAAC;YAEnD,IAAI,GAAG,EAAE,CAAC;gBACR,OAAO,CAAC,GAAG,EAAE,yBAAyB,GAAG,EAAE,CAAC,CAAC;gBAC7C,MAAM,CAAC,IAAI,KAAK,CAAC,yBAAyB,GAAG,EAAE,CAAC,CAAC,CAAC;gBAClD,OAAO;YACT,CAAC;YACD,IAAI,CAAC,SAAS,CAAC,QAAQ,EAAE,KAAK,CAAC,EAAE,CAAC;gBAChC,gFAAgF;gBAChF,OAAO,CAAC,GAAG,EAAE,4DAA4D,CAAC,CAAC;gBAC3E,MAAM,CAAC,IAAI,KAAK,CAAC,0CAA0C,CAAC,CAAC,CAAC;gBAC9D,OAAO;YACT,CAAC;YACD,IAAI,CAAC,OAAO,EAAE,CAAC;gBACb,OAAO,CAAC,GAAG,EAAE,iCAAiC,CAAC,CAAC;gBAChD,MAAM,CAAC,IAAI,KAAK,CAAC,mCAAmC,CAAC,CAAC,CAAC;gBACvD,OAAO;YACT,CAAC;YACD,OAAO,CAAC,GAAG,EAAE,+DAA+D,CAAC,CAAC;YAC9E,MAAM,IAAI,GAAG,GAAG,CAAC,OAAO,EAAiB,CAAC;YAC1C,OAAO,CAAC,EAAE,IAAI,EAAE,OAAO,EAAE,WAAW,EAAE,WAAW,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC,CAAC;QAC/E,CAAC,CAAC,CAAC;QAEH,GAAG,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC;QACxB,kDAAkD;QAClD,GAAG,CAAC,MAAM,CAAC,CAAC,EAAE,WAAW,EAAE,GAAG,EAAE;YAC9B,MAAM,IAAI,GAAG,GAAG,CAAC,OAAO,EAAiB,CAAC;YAC1C,MAAM,OAAO,GAAG,IAAI,GAAG,CAAC,IAAI,CAAC,sBAAsB,CAAC,CAAC;YACrD,OAAO,CAAC,YAAY,CAAC,GAAG,CAAC,eAAe,EAAE,MAAM,CAAC,CAAC;YAClD,OAAO,CAAC,YAAY,CAAC,GAAG,CAAC,WAAW,EAAE,QAAQ,CAAC,CAAC;YAChD,OAAO,CAAC,YAAY,CAAC,GAAG,CAAC,cAAc,EAAE,WAAW,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC;YACjE,OAAO,CAAC,YAAY,CAAC,GAAG,CAAC,OAAO,EAAE,KAAK,CAAC,CAAC;YACzC,OAAO,CAAC,YAAY,CAAC,GAAG,CAAC,OAAO,EAAE,KAAK,CAAC,CAAC;YACzC,OAAO,CAAC,YAAY,CAAC,GAAG,CAAC,gBAAgB,EAAE,IAAI,CAAC,SAAS,CAAC,CAAC;YAC3D,OAAO,CAAC,YAAY,CAAC,GAAG,CAAC,uBAAuB,EAAE,MAAM,CAAC,CAAC;YAC1D,qFAAqF;YACrF,OAAO,CAAC,YAAY,CAAC,GAAG,CAAC,UAAU,EAAE,QAAQ,CAAC,CAAC;YAE/C,GAAG,CAAC,kDAAkD,CAAC,CAAC;YACxD,GAAG,CAAC,wDAAwD,OAAO,CAAC,QAAQ,EAAE,IAAI,CAAC,CAAC;YACpF,yFAAyF;YACzF,iFAAiF;YACjF,GAAG,CAAC,sDAAsD,IAAI,CAAC,KAAK,CAAC,SAAS,GAAG,IAAI,CAAC,8BAA8B,CAAC,CAAC;YACtH,IAAI,IAAI,CAAC,WAAW,KAAK,KAAK;gBAAE,aAAa,CAAC,OAAO,CAAC,QAAQ,EAAE,CAAC,CAAC;QACpE,CAAC,CAAC,CAAC;QAEH,UAAU,CAAC,GAAG,EAAE;YACd,GAAG,CAAC,KAAK,EAAE,CAAC;YACZ,MAAM,CAAC,IAAI,KAAK,CAAC,mBAAmB,IAAI,CAAC,KAAK,CAAC,SAAS,GAAG,IAAI,CAAC,2BAA2B,CAAC,CAAC,CAAC;QAChG,CAAC,EAAE,SAAS,CAAC,CAAC,KAAK,EAAE,CAAC;IACxB,CAAC,CAAC,CAAC;IAEH,MAAM,CAAC,KAAK,EAAE,CAAC;IAEf,MAAM,GAAG,GAAG,MAAM,QAAQ,CAAC,IAAI,CAAC,cAAc,EAAE;QAC9C,UAAU,EAAE,oBAAoB;QAChC,IAAI;QACJ,YAAY,EAAE,WAAW;QACzB,SAAS,EAAE,QAAQ;QACnB,aAAa,EAAE,IAAI,CAAC,QAAQ;QAC5B,QAAQ;KACT,CAAC,CAAC;IACH,IAAI,CAAC,GAAG,CAAC,EAAE,EAAE,CAAC;QACZ,MAAM,IAAI,KAAK,CAAC,0BAA0B,GAAG,CAAC,MAAM,IAAI,MAAM,QAAQ,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;IACjF,CAAC;IACD,OAAO,CAAC,MAAM,GAAG,CAAC,IAAI,EAAE,CAAkB,CAAC;AAC7C,CAAC;AAED,SAAS,WAAW,CAAC,IAAY;IAC/B,OAAO,oBAAoB,IAAI,WAAW,CAAC;AAC7C,CAAC;AAED,SAAS,OAAO,CAAC,GAAuC,EAAE,OAAe;IACvE,GAAG,CAAC,SAAS,CAAC,GAAG,EAAE,EAAE,cAAc,EAAE,0BAA0B,EAAE,CAAC,CAAC;IACnE,GAAG,CAAC,GAAG,CACL,sEAAsE;QACpE,4FAA4F;QAC5F,yDAAyD,UAAU,CAAC,OAAO,CAAC,aAAa,CAC5F,CAAC;AACJ,CAAC;AAED,SAAS,UAAU,CAAC,CAAS;IAC3B,OAAO,CAAC,CAAC,OAAO,CAAC,UAAU,EAAE,CAAC,CAAC,EAAE,EAAE,CACjC,CAAC,EAAE,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,QAAQ,EAAE,GAAG,EAAE,OAAO,EAAE,CAAC,CAAC,CAAC,CAAW,CACvF,CAAC;AACJ,CAAC;AAED;;;;GAIG;AACH,MAAM,CAAC,KAAK,UAAU,kBAAkB,CAAC,IAOxC;IACC,MAAM,EAAE,IAAI,EAAE,QAAQ,EAAE,KAAK,EAAE,QAAQ,EAAE,GAAG,IAAI,CAAC;IACjD,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,IAAI,CAAC,GAAG,EAAE,GAAE,CAAC,CAAC,CAAC;IACnC,IAAI,CAAC,IAAI,CAAC,6BAA6B,EAAE,CAAC;QACxC,MAAM,IAAI,KAAK,CAAC,gEAAgE,CAAC,CAAC;IACpF,CAAC;IAED,MAAM,QAAQ,GAAG,MAAM,QAAQ,CAAC,IAAI,CAAC,6BAA6B,EAAE;QAClE,SAAS,EAAE,QAAQ;QACnB,KAAK;KACN,CAAC,CAAC;IACH,IAAI,CAAC,QAAQ,CAAC,EAAE,EAAE,CAAC;QACjB,MAAM,IAAI,KAAK,CAAC,gCAAgC,QAAQ,CAAC,MAAM,IAAI,MAAM,QAAQ,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC;IACjG,CAAC;IACD,MAAM,KAAK,GAAG,CAAC,MAAM,QAAQ,CAAC,IAAI,EAAE,CAA4B,CAAC;IACjE,MAAM,UAAU,GAAG,MAAM,CAAC,KAAK,CAAC,aAAa,CAAC,IAAI,EAAE,CAAC,CAAC;IACtD,MAAM,QAAQ,GAAG,MAAM,CAAC,KAAK,CAAC,WAAW,CAAC,IAAI,EAAE,CAAC,CAAC;IAClD,MAAM,SAAS,GAAG,MAAM,CAAC,KAAK,CAAC,2BAA2B,CAAC,IAAI,KAAK,CAAC,kBAAkB,CAAC,IAAI,EAAE,CAAC,CAAC;IAChG,mFAAmF;IACnF,8DAA8D;IAC9D,IAAI,UAAU,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,MAAM,CAAC,KAAK,CAAC,UAAU,CAAC,IAAI,CAAC,CAAC,CAAC,GAAG,IAAI,CAAC;IACpE,MAAM,YAAY,GAAG,IAAI,CAAC,GAAG,EAAE,GAAG,IAAI,CAAC,GAAG,CAAC,EAAE,EAAE,MAAM,CAAC,KAAK,CAAC,YAAY,CAAC,IAAI,GAAG,CAAC,CAAC,GAAG,IAAI,CAAC;IAC1F,4FAA4F;IAC5F,8FAA8F;IAC9F,mDAAmD;IACnD,MAAM,SAAS,GAAG,IAAI,CAAC,SAAS;QAC9B,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,YAAY,EAAE,IAAI,CAAC,GAAG,EAAE,GAAG,IAAI,CAAC,SAAS,CAAC;QACrD,CAAC,CAAC,YAAY,CAAC;IAEjB,GAAG,CAAC,cAAc,SAAS,EAAE,CAAC,CAAC;IAC/B,GAAG,CAAC,YAAY,QAAQ,IAAI,CAAC,CAAC;IAC9B,0FAA0F;IAC1F,4FAA4F;IAC5F,yCAAyC;IACzC,GAAG,CAAC,qDAAqD,IAAI,CAAC,KAAK,CAAC,CAAC,SAAS,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC,GAAG,IAAI,CAAC,IAAI,CAAC,CAAC;IAE1G,SAAS,CAAC;QACR,IAAI,IAAI,CAAC,GAAG,EAAE,GAAG,SAAS;YAAE,MAAM,IAAI,KAAK,CAAC,qCAAqC,CAAC,CAAC;QACnF,MAAM,KAAK,CAAC,UAAU,CAAC,CAAC;QACxB,MAAM,GAAG,GAAG,MAAM,QAAQ,CAAC,IAAI,CAAC,cAAc,EAAE;YAC9C,UAAU,EAAE,8CAA8C;YAC1D,WAAW,EAAE,UAAU;YACvB,SAAS,EAAE,QAAQ;YACnB,QAAQ;SACT,CAAC,CAAC;QACH,IAAI,GAAG,CAAC,EAAE;YAAE,OAAO,CAAC,MAAM,GAAG,CAAC,IAAI,EAAE,CAAkB,CAAC;QAEvD,MAAM,IAAI,GAAG,CAAC,MAAM,GAAG,CAAC,IAAI,EAAE,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,CAAC,EAAE,CAAC,CAAC,CAA4B,CAAC;QAC7E,MAAM,KAAK,GAAG,MAAM,CAAC,IAAI,CAAC,OAAO,CAAC,IAAI,EAAE,CAAC,CAAC;QAC1C,IAAI,KAAK,KAAK,uBAAuB;YAAE,SAAS;QAChD,IAAI,KAAK,KAAK,WAAW,EAAE,CAAC;YAC1B,UAAU,IAAI,IAAI,CAAC;YACnB,SAAS;QACX,CAAC;QACD,qFAAqF;QACrF,sFAAsF;QACtF,wFAAwF;QACxF,uFAAuF;QACvF,uFAAuF;QACvF,wEAAwE;QACxE,IAAI,KAAK,KAAK,eAAe;YAAE,SAAS;QACxC,MAAM,IAAI,KAAK,CAAC,gCAAgC,KAAK,IAAI,GAAG,CAAC,MAAM,EAAE,CAAC,CAAC;IACzE,CAAC;AACH,CAAC;AAED,SAAS,KAAK,CAAC,EAAU;IACvB,OAAO,IAAI,OAAO,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,UAAU,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC;AAC/C,CAAC;AAED,KAAK,UAAU,QAAQ,CAAC,GAAa;IACnC,IAAI,CAAC;QACH,OAAO,CAAC,MAAM,GAAG,CAAC,IAAI,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,GAAG,CAAC,CAAC;IAC1C,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,EAAE,CAAC;IACZ,CAAC;AACH,CAAC;AAED;;;GAGG;AACH,SAAS,aAAa,CAAC,GAAW;IAChC,MAAM,GAAG,GACP,OAAO,CAAC,QAAQ,KAAK,OAAO,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,OAAO,CAAC,QAAQ,KAAK,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,UAAU,CAAC;IAC7F,0FAA0F;IAC1F,MAAM,IAAI,GAAG,OAAO,CAAC,QAAQ,KAAK,OAAO,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,OAAO,EAAE,EAAE,EAAE,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC;IAC7E,IAAI,CAAC;QACH,KAAK,CAAC,GAAG,EAAE,IAAI,EAAE,EAAE,KAAK,EAAE,QAAQ,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC,CAAC,KAAK,EAAE,CAAC;IAChE,CAAC;IAAC,MAAM,CAAC;QACP,kCAAkC;IACpC,CAAC;AACH,CAAC"}
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Turning a bundle into files on disk.
3
+ *
4
+ * 🔴 THE MERGE RULE IS NOT A NICETY — IT IS A SCAR.
5
+ * On 2026-07-17 the predecessor of this code (`bnl-provision-site-mcpjson.sh`) blindly
6
+ * overwrote three pre-existing `.mcp.json` files that defined unrelated servers. Two were
7
+ * recoverable from git; one (`boris-console`) had never been tracked and had to be
8
+ * reconstructed by hand. So: every write reads what is on disk first and replaces ONLY the
9
+ * keys it owns. A file that is not valid JSON is SKIPPED with a message — never rewritten,
10
+ * because "it looked broken" is exactly when someone's hand edit is sitting in it.
11
+ *
12
+ * Everything here is planned as pure data first (`FileChange[]`) and applied second, so
13
+ * `--dry-run` shows precisely what would happen and the merge logic is unit-testable
14
+ * without touching a filesystem.
15
+ */
16
+ export interface FileChange {
17
+ path: string;
18
+ /** Current contents, or null when the file does not exist. */
19
+ before: string | null;
20
+ /** Contents to write, or null when nothing should be written. */
21
+ after: string | null;
22
+ /** Set when we deliberately declined to write; `after` is null and this explains why. */
23
+ skipped?: string;
24
+ /** Advisory shown to the user after a successful apply. */
25
+ note?: string;
26
+ }
27
+ /** True when the change would actually alter the file. */
28
+ export declare function isNoop(c: FileChange): boolean;
29
+ /**
30
+ * Merge our server entries into an existing `.mcp.json`.
31
+ *
32
+ * `servers` comes straight from the bundle's `mcp_json.mcpServers`, which is TOKENLESS —
33
+ * OAuth-capable hosts discover auth from the endpoint's 401 and keep their own credentials.
34
+ * That is what makes the resulting file safe to commit.
35
+ */
36
+ export declare function planMcpJson(path: string, existing: string | null, servers: Record<string, unknown>): FileChange;
37
+ /**
38
+ * `.claude/settings.local.json` — an `env` block Claude Code applies to every session.
39
+ *
40
+ * WHY THIS EXISTS AT ALL: the VS Code extension launches `claude` without sourcing a shell
41
+ * env file, so a `${VAR}` reference in `.mcp.json` would not expand there. This file is how
42
+ * the harness works from the VS Code button rather than only from `source .env && claude`.
43
+ * Only written when a token was minted — the OAuth path needs no env at all.
44
+ */
45
+ export declare function planSettingsLocal(path: string, existing: string | null, env: Record<string, string>): FileChange;
46
+ /**
47
+ * Codex `config.toml`.
48
+ *
49
+ * We do NOT parse TOML (that would mean a dependency, and this package has none). Instead:
50
+ * if a `[mcp_servers.<name>]` table already exists we leave the file completely alone and
51
+ * hand the user the snippet; otherwise we APPEND the table. Appending is safe because TOML
52
+ * tables are order-independent, and appending can never corrupt what is already there.
53
+ *
54
+ * The token itself never goes in here — it goes to `~/.codex/.env`, referenced by
55
+ * `bearer_token_env_var`, so the config file stays safe to read over someone's shoulder.
56
+ */
57
+ export declare function planCodexConfig(path: string, existing: string | null, serverName: string, mcpUrl: string, tokenEnvVar: string): FileChange;
58
+ /** `~/.codex/.env` — one KEY=value line, replaced in place if already present. */
59
+ export declare function planEnvVar(path: string, existing: string | null, key: string, value: string): FileChange;
60
+ /**
61
+ * `state/routing/{providers,profiles}.server.yaml` — materialized from the console's hosted
62
+ * routing. These are plain overwrites, and that is correct: the files live in a CLI-owned
63
+ * directory and their own banner says "Do not edit by hand". A local `providers.yaml` still
64
+ * takes precedence in the resolver, so nobody's hand-tuned routing is affected.
65
+ */
66
+ export declare function planRoutingFile(path: string, existing: string | null, body: string): FileChange;
67
+ /**
68
+ * Guard: refuse to write anything that looks like a credential into a repo-visible file.
69
+ *
70
+ * The default path must never mint or persist a secret, so this is a hard assertion rather
71
+ * than a warning — if it ever fires, a change has reintroduced exactly the problem this
72
+ * tool exists to remove.
73
+ */
74
+ export declare function assertNoSecret(c: FileChange): void;
75
+ export declare function readIfExists(path: string): string | null;
76
+ export declare function apply(changes: FileChange[], dryRun: boolean): void;
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Turning a bundle into files on disk.
3
+ *
4
+ * 🔴 THE MERGE RULE IS NOT A NICETY — IT IS A SCAR.
5
+ * On 2026-07-17 the predecessor of this code (`bnl-provision-site-mcpjson.sh`) blindly
6
+ * overwrote three pre-existing `.mcp.json` files that defined unrelated servers. Two were
7
+ * recoverable from git; one (`boris-console`) had never been tracked and had to be
8
+ * reconstructed by hand. So: every write reads what is on disk first and replaces ONLY the
9
+ * keys it owns. A file that is not valid JSON is SKIPPED with a message — never rewritten,
10
+ * because "it looked broken" is exactly when someone's hand edit is sitting in it.
11
+ *
12
+ * Everything here is planned as pure data first (`FileChange[]`) and applied second, so
13
+ * `--dry-run` shows precisely what would happen and the merge logic is unit-testable
14
+ * without touching a filesystem.
15
+ */
16
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
17
+ import { dirname } from "node:path";
18
+ /** True when the change would actually alter the file. */
19
+ export function isNoop(c) {
20
+ return c.after === null || c.after === c.before;
21
+ }
22
+ /**
23
+ * Merge our server entries into an existing `.mcp.json`.
24
+ *
25
+ * `servers` comes straight from the bundle's `mcp_json.mcpServers`, which is TOKENLESS —
26
+ * OAuth-capable hosts discover auth from the endpoint's 401 and keep their own credentials.
27
+ * That is what makes the resulting file safe to commit.
28
+ */
29
+ export function planMcpJson(path, existing, servers) {
30
+ let doc = {};
31
+ if (existing !== null && existing.trim() !== "") {
32
+ try {
33
+ const parsed = JSON.parse(existing);
34
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
35
+ throw new Error("not a JSON object");
36
+ }
37
+ doc = parsed;
38
+ }
39
+ catch (e) {
40
+ return {
41
+ path,
42
+ before: existing,
43
+ after: null,
44
+ skipped: `existing .mcp.json is not valid JSON (${e.message}) — ` +
45
+ `left untouched. Fix or remove it by hand, then re-run.`,
46
+ };
47
+ }
48
+ }
49
+ const current = typeof doc["mcpServers"] === "object" && doc["mcpServers"] !== null && !Array.isArray(doc["mcpServers"])
50
+ ? doc["mcpServers"]
51
+ : {};
52
+ // Only our own keys are replaced. Any other server entry survives byte-for-byte through
53
+ // the round trip, which is the whole point of parsing rather than templating.
54
+ doc["mcpServers"] = { ...current, ...servers };
55
+ return { path, before: existing, after: JSON.stringify(doc, null, 2) + "\n" };
56
+ }
57
+ /**
58
+ * `.claude/settings.local.json` — an `env` block Claude Code applies to every session.
59
+ *
60
+ * WHY THIS EXISTS AT ALL: the VS Code extension launches `claude` without sourcing a shell
61
+ * env file, so a `${VAR}` reference in `.mcp.json` would not expand there. This file is how
62
+ * the harness works from the VS Code button rather than only from `source .env && claude`.
63
+ * Only written when a token was minted — the OAuth path needs no env at all.
64
+ */
65
+ export function planSettingsLocal(path, existing, env) {
66
+ let doc = {};
67
+ if (existing !== null && existing.trim() !== "") {
68
+ try {
69
+ const parsed = JSON.parse(existing);
70
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
71
+ throw new Error("not a JSON object");
72
+ }
73
+ doc = parsed;
74
+ }
75
+ catch (e) {
76
+ return {
77
+ path,
78
+ before: existing,
79
+ after: null,
80
+ skipped: `existing settings.local.json is not valid JSON (${e.message}) — left untouched.`,
81
+ };
82
+ }
83
+ }
84
+ const currentEnv = typeof doc["env"] === "object" && doc["env"] !== null && !Array.isArray(doc["env"])
85
+ ? doc["env"]
86
+ : {};
87
+ doc["env"] = { ...currentEnv, ...env };
88
+ return {
89
+ path,
90
+ before: existing,
91
+ after: JSON.stringify(doc, null, 2) + "\n",
92
+ note: "holds a plaintext token — make sure .claude/settings.local.json is gitignored",
93
+ };
94
+ }
95
+ /**
96
+ * Codex `config.toml`.
97
+ *
98
+ * We do NOT parse TOML (that would mean a dependency, and this package has none). Instead:
99
+ * if a `[mcp_servers.<name>]` table already exists we leave the file completely alone and
100
+ * hand the user the snippet; otherwise we APPEND the table. Appending is safe because TOML
101
+ * tables are order-independent, and appending can never corrupt what is already there.
102
+ *
103
+ * The token itself never goes in here — it goes to `~/.codex/.env`, referenced by
104
+ * `bearer_token_env_var`, so the config file stays safe to read over someone's shoulder.
105
+ */
106
+ export function planCodexConfig(path, existing, serverName, mcpUrl, tokenEnvVar) {
107
+ const table = `[mcp_servers.${serverName}]`;
108
+ const block = `\n# Added by @bluenotelogic/harness. Codex cannot do OAuth yet, so it reads a token\n` +
109
+ `# from the environment; the value lives in ~/.codex/.env, never in this file.\n` +
110
+ `${table}\n` +
111
+ `url = "${mcpUrl}"\n` +
112
+ `bearer_token_env_var = "${tokenEnvVar}"\n`;
113
+ if (existing !== null && existing.includes(table)) {
114
+ return {
115
+ path,
116
+ before: existing,
117
+ after: null,
118
+ skipped: `${table} already present — left untouched. If the URL changed, update it by hand:\n` +
119
+ block.trim(),
120
+ };
121
+ }
122
+ const base = existing ?? "";
123
+ const sep = base === "" || base.endsWith("\n") ? "" : "\n";
124
+ return { path, before: existing, after: base + sep + block };
125
+ }
126
+ /** `~/.codex/.env` — one KEY=value line, replaced in place if already present. */
127
+ export function planEnvVar(path, existing, key, value) {
128
+ const line = `${key}=${value}`;
129
+ const base = existing ?? "";
130
+ const re = new RegExp(`^${escapeRe(key)}=.*$`, "m");
131
+ const after = re.test(base)
132
+ ? base.replace(re, line)
133
+ : base + (base === "" || base.endsWith("\n") ? "" : "\n") + line + "\n";
134
+ return {
135
+ path,
136
+ before: existing,
137
+ after,
138
+ note: "holds a plaintext token — never commit it",
139
+ };
140
+ }
141
+ function escapeRe(s) {
142
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
143
+ }
144
+ /**
145
+ * `state/routing/{providers,profiles}.server.yaml` — materialized from the console's hosted
146
+ * routing. These are plain overwrites, and that is correct: the files live in a CLI-owned
147
+ * directory and their own banner says "Do not edit by hand". A local `providers.yaml` still
148
+ * takes precedence in the resolver, so nobody's hand-tuned routing is affected.
149
+ */
150
+ export function planRoutingFile(path, existing, body) {
151
+ return { path, before: existing, after: body.endsWith("\n") ? body : body + "\n" };
152
+ }
153
+ /**
154
+ * Guard: refuse to write anything that looks like a credential into a repo-visible file.
155
+ *
156
+ * The default path must never mint or persist a secret, so this is a hard assertion rather
157
+ * than a warning — if it ever fires, a change has reintroduced exactly the problem this
158
+ * tool exists to remove.
159
+ */
160
+ export function assertNoSecret(c) {
161
+ if (c.after === null)
162
+ return;
163
+ const found = c.after.match(/\b(cmem|mcp)_[A-Za-z0-9_-]{16,}/);
164
+ if (found) {
165
+ throw new Error(`refusing to write what looks like a live token into ${c.path} ` +
166
+ `(matched ${found[0].slice(0, 8)}…). Tokens belong in ~/.codex/.env or ` +
167
+ `.claude/settings.local.json, never in a committed config.`);
168
+ }
169
+ }
170
+ export function readIfExists(path) {
171
+ try {
172
+ return readFileSync(path, "utf8");
173
+ }
174
+ catch {
175
+ return null;
176
+ }
177
+ }
178
+ export function apply(changes, dryRun) {
179
+ for (const c of changes) {
180
+ if (c.after === null || isNoop(c))
181
+ continue;
182
+ if (dryRun)
183
+ continue;
184
+ const dir = dirname(c.path);
185
+ if (!existsSync(dir))
186
+ mkdirSync(dir, { recursive: true });
187
+ writeFileSync(c.path, c.after, "utf8");
188
+ }
189
+ }
190
+ //# sourceMappingURL=writers.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"writers.js","sourceRoot":"","sources":["../src/writers.ts"],"names":[],"mappings":"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"}