@etiennepasteur/jean-claude 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/LICENSE +21 -0
- package/README.md +422 -0
- package/dist/cli.mjs +1631 -0
- package/package.json +69 -0
package/dist/cli.mjs
ADDED
|
@@ -0,0 +1,1631 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command, InvalidArgumentError } from "commander";
|
|
3
|
+
import pc from "picocolors";
|
|
4
|
+
import { access, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import tls from "node:tls";
|
|
7
|
+
import { generateCACertificate, getLocal } from "mockttp";
|
|
8
|
+
import os from "node:os";
|
|
9
|
+
import { YAMLParseError, parse } from "yaml";
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
import { match } from "path-to-regexp";
|
|
12
|
+
import { execa } from "execa";
|
|
13
|
+
import { watch } from "chokidar";
|
|
14
|
+
import { createHash } from "node:crypto";
|
|
15
|
+
import { setTimeout } from "node:timers/promises";
|
|
16
|
+
//#region src/ca/store.ts
|
|
17
|
+
/** Candidate system trust stores, by distribution family. */
|
|
18
|
+
const SYSTEM_CA_BUNDLES = [
|
|
19
|
+
"/etc/ssl/certs/ca-certificates.crt",
|
|
20
|
+
"/etc/pki/tls/certs/ca-bundle.crt",
|
|
21
|
+
"/etc/ssl/ca-bundle.pem",
|
|
22
|
+
"/etc/ssl/cert.pem"
|
|
23
|
+
];
|
|
24
|
+
function caPathsIn(dir) {
|
|
25
|
+
return {
|
|
26
|
+
dir,
|
|
27
|
+
certPath: path.join(dir, "ca.pem"),
|
|
28
|
+
keyPath: path.join(dir, "ca.key"),
|
|
29
|
+
bundlePath: path.join(dir, "bundle.pem")
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
async function exists(filePath) {
|
|
33
|
+
try {
|
|
34
|
+
await access(filePath);
|
|
35
|
+
return true;
|
|
36
|
+
} catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
async function findSystemBundle() {
|
|
41
|
+
for (const candidate of SYSTEM_CA_BUNDLES) if (await exists(candidate)) return candidate;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Make sure the CA exists, and (re)generate the trust bundle.
|
|
45
|
+
*
|
|
46
|
+
* The bundle is rewritten on every call: the system store or the corporate CA
|
|
47
|
+
* may well have changed since the last run.
|
|
48
|
+
*/
|
|
49
|
+
async function ensureCa({ dir, inheritedCa }) {
|
|
50
|
+
const paths = caPathsIn(dir);
|
|
51
|
+
await mkdir(dir, {
|
|
52
|
+
recursive: true,
|
|
53
|
+
mode: 448
|
|
54
|
+
});
|
|
55
|
+
const created = !(await exists(paths.certPath) && await exists(paths.keyPath));
|
|
56
|
+
if (created) {
|
|
57
|
+
const { key, cert } = await generateCACertificate({ subject: {
|
|
58
|
+
commonName: "jean-claude MITM CA - DO NOT TRUST ELSEWHERE",
|
|
59
|
+
organizationName: "jean-claude"
|
|
60
|
+
} });
|
|
61
|
+
await writeFile(paths.certPath, cert, { mode: 420 });
|
|
62
|
+
await writeFile(paths.keyPath, key, { mode: 384 });
|
|
63
|
+
}
|
|
64
|
+
const ourCert = await readFile(paths.certPath, "utf8");
|
|
65
|
+
const systemBundle = await findSystemBundle();
|
|
66
|
+
const systemCerts = systemBundle ? await readFile(systemBundle, "utf8") : tls.rootCertificates.join("\n");
|
|
67
|
+
const inheritedCerts = inheritedCa && await exists(inheritedCa) ? await readFile(inheritedCa, "utf8") : "";
|
|
68
|
+
const resolvedInherited = inheritedCerts === "" ? void 0 : inheritedCa;
|
|
69
|
+
const bundle = [
|
|
70
|
+
"# Generated by jean-claude - do not edit by hand.",
|
|
71
|
+
"# jean-claude CA",
|
|
72
|
+
ourCert.trim(),
|
|
73
|
+
...resolvedInherited ? [`# CA inherited from NODE_EXTRA_CA_CERTS (${resolvedInherited})`, inheritedCerts.trim()] : [],
|
|
74
|
+
`# System trust store (${systemBundle ?? "Node's built-in roots"})`,
|
|
75
|
+
systemCerts.trim(),
|
|
76
|
+
""
|
|
77
|
+
].join("\n");
|
|
78
|
+
await writeFile(paths.bundlePath, bundle, { mode: 420 });
|
|
79
|
+
return {
|
|
80
|
+
...paths,
|
|
81
|
+
created,
|
|
82
|
+
systemBundle,
|
|
83
|
+
inheritedCa: resolvedInherited
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Commands to install the CA into the system trust store, per distribution family.
|
|
88
|
+
* Returned as text on purpose: jean-claude never runs `sudo` on its own.
|
|
89
|
+
*/
|
|
90
|
+
function systemTrustInstructions(certPath) {
|
|
91
|
+
return [
|
|
92
|
+
"# Debian / Ubuntu (the .crt extension is mandatory)",
|
|
93
|
+
`sudo cp ${certPath} /usr/local/share/ca-certificates/jean-claude.crt`,
|
|
94
|
+
"sudo update-ca-certificates",
|
|
95
|
+
"",
|
|
96
|
+
"# RHEL / Fedora / Rocky",
|
|
97
|
+
`sudo cp ${certPath} /etc/pki/ca-trust/source/anchors/jean-claude.crt`,
|
|
98
|
+
"sudo update-ca-trust extract",
|
|
99
|
+
"",
|
|
100
|
+
"# To uninstall: delete the copied file, then re-run the matching update command."
|
|
101
|
+
];
|
|
102
|
+
}
|
|
103
|
+
//#endregion
|
|
104
|
+
//#region src/config/paths.ts
|
|
105
|
+
/**
|
|
106
|
+
* Where jean-claude keeps everything: the config, its stubs, the CA and the
|
|
107
|
+
* session file of a running `start`.
|
|
108
|
+
*
|
|
109
|
+
* A single folder rather than a config directory plus a state directory: the
|
|
110
|
+
* primary use case is a *global* install ("freeze this API response everywhere"),
|
|
111
|
+
* and one path to remember beats XDG purity here. `--home` relocates the lot.
|
|
112
|
+
*
|
|
113
|
+
* <home>/jean-claude.yaml
|
|
114
|
+
* <home>/responses/
|
|
115
|
+
* <home>/ca/{ca.pem,ca.key,bundle.pem}
|
|
116
|
+
* <home>/session.json
|
|
117
|
+
*/
|
|
118
|
+
function jeanClaudeHome() {
|
|
119
|
+
const xdg = process.env.XDG_CONFIG_HOME;
|
|
120
|
+
const base = xdg !== void 0 && xdg.trim() !== "" ? xdg : path.join(os.homedir(), ".config");
|
|
121
|
+
return path.join(base, "jean-claude");
|
|
122
|
+
}
|
|
123
|
+
function caDirIn(home) {
|
|
124
|
+
return path.join(home, "ca");
|
|
125
|
+
}
|
|
126
|
+
function responsesDirIn(home) {
|
|
127
|
+
return path.join(home, "responses");
|
|
128
|
+
}
|
|
129
|
+
//#endregion
|
|
130
|
+
//#region src/env/upstream.ts
|
|
131
|
+
function firstDefined(env, names) {
|
|
132
|
+
for (const name of names) {
|
|
133
|
+
const value = env[name];
|
|
134
|
+
if (value !== void 0 && value.trim() !== "") return value.trim();
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
function parseNoProxy(env) {
|
|
138
|
+
const raw = firstDefined(env, ["NO_PROXY", "no_proxy"]);
|
|
139
|
+
if (raw === void 0) return void 0;
|
|
140
|
+
const entries = raw.split(",").map((entry) => entry.trim()).filter((entry) => entry !== "");
|
|
141
|
+
return entries.length > 0 ? entries : void 0;
|
|
142
|
+
}
|
|
143
|
+
/** The corporate CA that jean-claude itself must trust on outbound connections. */
|
|
144
|
+
function inheritedExtraCaCerts(env = process.env) {
|
|
145
|
+
const value = env.NODE_EXTRA_CA_CERTS;
|
|
146
|
+
return value !== void 0 && value.trim() !== "" ? value.trim() : void 0;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Resolve the upstream proxy. `upstream: off` forces a direct connection, `auto`
|
|
150
|
+
* inherits from the environment, and an explicit URL wins over the environment.
|
|
151
|
+
*/
|
|
152
|
+
function detectUpstream(config, env = process.env) {
|
|
153
|
+
if (config.upstream === "off") return void 0;
|
|
154
|
+
if (config.upstream !== "auto") return {
|
|
155
|
+
proxyUrl: config.upstream,
|
|
156
|
+
noProxy: parseNoProxy(env),
|
|
157
|
+
source: "config"
|
|
158
|
+
};
|
|
159
|
+
const proxyUrl = firstDefined(env, [
|
|
160
|
+
"HTTPS_PROXY",
|
|
161
|
+
"https_proxy",
|
|
162
|
+
"HTTP_PROXY",
|
|
163
|
+
"http_proxy"
|
|
164
|
+
]);
|
|
165
|
+
if (proxyUrl === void 0) return void 0;
|
|
166
|
+
return {
|
|
167
|
+
proxyUrl,
|
|
168
|
+
noProxy: parseNoProxy(env),
|
|
169
|
+
source: "env"
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
//#endregion
|
|
173
|
+
//#region src/commands/ca.ts
|
|
174
|
+
/**
|
|
175
|
+
* Inspects the certificate store. Never installs anything itself: trust changes
|
|
176
|
+
* need root, and are the user's call to make explicitly.
|
|
177
|
+
*/
|
|
178
|
+
async function caCommand(options) {
|
|
179
|
+
const ca = await ensureCa({
|
|
180
|
+
dir: caDirIn(options.home !== void 0 ? path.resolve(options.home) : jeanClaudeHome()),
|
|
181
|
+
inheritedCa: inheritedExtraCaCerts()
|
|
182
|
+
});
|
|
183
|
+
if (options.print) {
|
|
184
|
+
process.stdout.write(await readFile(ca.certPath, "utf8"));
|
|
185
|
+
return 0;
|
|
186
|
+
}
|
|
187
|
+
console.log(` ${pc.dim("cert ")}${ca.certPath}${ca.created ? pc.dim(" (just generated)") : ""}`);
|
|
188
|
+
console.log(` ${pc.dim("key ")}${ca.keyPath}`);
|
|
189
|
+
console.log(` ${pc.dim("bundle ")}${ca.bundlePath}`);
|
|
190
|
+
console.log(` ${pc.dim("system ")}${ca.systemBundle ?? "Node's built-in roots"}`);
|
|
191
|
+
if (ca.inheritedCa !== void 0) console.log(` ${pc.dim("corp ")}${ca.inheritedCa} ${pc.dim("(inherited from NODE_EXTRA_CA_CERTS)")}`);
|
|
192
|
+
if (options.install) {
|
|
193
|
+
console.log(`\n ${pc.bold("Installing into the system trust store")} ${pc.dim("(run these yourself)")}\n`);
|
|
194
|
+
for (const line of systemTrustInstructions(ca.certPath)) console.log(` ${line}`);
|
|
195
|
+
console.log("");
|
|
196
|
+
} else console.log(`\n ${pc.dim("`jean-claude run` injects this automatically. Use `--install` for the system trust store.")}\n`);
|
|
197
|
+
return 0;
|
|
198
|
+
}
|
|
199
|
+
//#endregion
|
|
200
|
+
//#region src/proxy/match.ts
|
|
201
|
+
const NO_MATCH = {
|
|
202
|
+
matched: false,
|
|
203
|
+
params: {}
|
|
204
|
+
};
|
|
205
|
+
/**
|
|
206
|
+
* `path-to-regexp` v8 requires wildcards to be named. We accept the natural
|
|
207
|
+
* `/api/*` form by naming them on the fly, so users need not learn `*splat`.
|
|
208
|
+
*/
|
|
209
|
+
function nameBareWildcards(pattern) {
|
|
210
|
+
let counter = 0;
|
|
211
|
+
return pattern.replaceAll(/\*(?![A-Za-z_])/g, () => `*wildcard${counter++}`);
|
|
212
|
+
}
|
|
213
|
+
function compilePathMatcher(pattern, ruleLabel) {
|
|
214
|
+
let matcher;
|
|
215
|
+
try {
|
|
216
|
+
matcher = match(nameBareWildcards(pattern), { decode: decodeURIComponent });
|
|
217
|
+
} catch (cause) {
|
|
218
|
+
throw new Error(`${ruleLabel}: invalid \`path\` pattern "${pattern}" — ${cause.message}`, { cause });
|
|
219
|
+
}
|
|
220
|
+
return (pathname) => {
|
|
221
|
+
const result = matcher(pathname);
|
|
222
|
+
if (!result) return void 0;
|
|
223
|
+
const params = {};
|
|
224
|
+
for (const [key, value] of Object.entries(result.params)) if (value !== void 0) params[key] = Array.isArray(value) ? value.join("/") : value;
|
|
225
|
+
return params;
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
/** `api.y.com` matches exactly; `*.y.com` matches any subdomain. */
|
|
229
|
+
function hostMatches(expected, actual) {
|
|
230
|
+
const wanted = expected.toLowerCase();
|
|
231
|
+
const got = actual.toLowerCase();
|
|
232
|
+
if (wanted.startsWith("*.")) {
|
|
233
|
+
const suffix = wanted.slice(1);
|
|
234
|
+
return got.endsWith(suffix) && got.length > suffix.length;
|
|
235
|
+
}
|
|
236
|
+
return wanted === got;
|
|
237
|
+
}
|
|
238
|
+
function describeRule(rule, index) {
|
|
239
|
+
return rule.name ?? `rule #${index + 1} (${rule.path ?? rule.pathRegex ?? "any URL"})`;
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Compile a rule into a predicate. Patterns are compiled once, at config load
|
|
243
|
+
* time, so broken patterns surface before the proxy starts.
|
|
244
|
+
*/
|
|
245
|
+
function compileMatcher(rule, config, index) {
|
|
246
|
+
const label = describeRule(rule, index);
|
|
247
|
+
const expectedHost = rule.host ?? config.host;
|
|
248
|
+
const expectedMethods = rule.method;
|
|
249
|
+
const matchPath = rule.path !== void 0 ? compilePathMatcher(rule.path, label) : void 0;
|
|
250
|
+
const pathRegex = rule.pathRegex !== void 0 ? new RegExp(rule.pathRegex) : void 0;
|
|
251
|
+
const expectedQuery = rule.query ? Object.entries(rule.query) : void 0;
|
|
252
|
+
return (facts) => {
|
|
253
|
+
if (expectedHost !== void 0 && !hostMatches(expectedHost, facts.hostname)) return NO_MATCH;
|
|
254
|
+
if (expectedMethods !== void 0 && !expectedMethods.includes(facts.method.toUpperCase())) return NO_MATCH;
|
|
255
|
+
if (pathRegex !== void 0 && !pathRegex.test(facts.pathname)) return NO_MATCH;
|
|
256
|
+
if (expectedQuery !== void 0 && !expectedQuery.every(([key, value]) => facts.query.get(key) === value)) return NO_MATCH;
|
|
257
|
+
if (matchPath !== void 0) {
|
|
258
|
+
const params = matchPath(facts.pathname);
|
|
259
|
+
if (params === void 0) return NO_MATCH;
|
|
260
|
+
return {
|
|
261
|
+
matched: true,
|
|
262
|
+
params
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
return {
|
|
266
|
+
matched: true,
|
|
267
|
+
params: {}
|
|
268
|
+
};
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
/** Derive the matching facts from an absolute URL. */
|
|
272
|
+
function factsFromUrl(url, method) {
|
|
273
|
+
const parsed = new URL(url);
|
|
274
|
+
return {
|
|
275
|
+
hostname: parsed.hostname,
|
|
276
|
+
method,
|
|
277
|
+
pathname: parsed.pathname,
|
|
278
|
+
query: parsed.searchParams
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
/** Interpolate captured parameters into a file path: `./stubs/{id}.json`. */
|
|
282
|
+
function interpolate(template, params) {
|
|
283
|
+
return template.replaceAll(/\{(\w+)\}/g, (whole, key) => params[key] ?? whole);
|
|
284
|
+
}
|
|
285
|
+
//#endregion
|
|
286
|
+
//#region src/config/schema.ts
|
|
287
|
+
/**
|
|
288
|
+
* Schema for the `jean-claude.yaml` file. This is the source of truth for the
|
|
289
|
+
* config format: the README and the `check` command both derive from it.
|
|
290
|
+
*/
|
|
291
|
+
const headersSchema = z.record(z.string(), z.string());
|
|
292
|
+
const statusSchema = z.number().int().min(100).max(599);
|
|
293
|
+
const delaySchema = z.number().int().nonnegative().max(6e5);
|
|
294
|
+
/** A single method, or a list of methods, normalised to upper case. */
|
|
295
|
+
const methodSchema = z.union([z.string(), z.array(z.string()).min(1)]).transform((value) => (Array.isArray(value) ? value : [value]).map((method) => method.toUpperCase()));
|
|
296
|
+
/** An RFC 6902 operation. `path` is a JSON Pointer: either `""` or `/a/0/b`. */
|
|
297
|
+
const jsonPatchOperationSchema = z.strictObject({
|
|
298
|
+
op: z.enum([
|
|
299
|
+
"add",
|
|
300
|
+
"remove",
|
|
301
|
+
"replace",
|
|
302
|
+
"move",
|
|
303
|
+
"copy",
|
|
304
|
+
"test"
|
|
305
|
+
]),
|
|
306
|
+
path: z.string().refine((p) => p === "" || p.startsWith("/"), "a JSON Pointer must be empty or start with \"/\""),
|
|
307
|
+
from: z.string().optional(),
|
|
308
|
+
value: z.unknown().optional()
|
|
309
|
+
});
|
|
310
|
+
/**
|
|
311
|
+
* Short circuit: reply without ever contacting the server.
|
|
312
|
+
* The short form `respond: ./file.json` is equivalent to `respond: { file: ./file.json }`.
|
|
313
|
+
*/
|
|
314
|
+
const respondSchema = z.union([z.string(), z.strictObject({
|
|
315
|
+
file: z.string().optional(),
|
|
316
|
+
body: z.unknown().optional(),
|
|
317
|
+
status: statusSchema.optional(),
|
|
318
|
+
headers: headersSchema.optional(),
|
|
319
|
+
delay: delaySchema.optional()
|
|
320
|
+
})]).transform((value) => typeof value === "string" ? { file: value } : value).refine((value) => !(value.file !== void 0 && value.body !== void 0), "`respond`: `file` and `body` are mutually exclusive");
|
|
321
|
+
/** Changes applied to the real response coming back from the server. */
|
|
322
|
+
const patchSchema = z.strictObject({
|
|
323
|
+
status: statusSchema.optional(),
|
|
324
|
+
headers: headersSchema.optional(),
|
|
325
|
+
replaceHeaders: headersSchema.optional(),
|
|
326
|
+
merge: z.record(z.string(), z.unknown()).optional(),
|
|
327
|
+
jsonPatch: z.array(jsonPatchOperationSchema).min(1).optional(),
|
|
328
|
+
body: z.unknown().optional(),
|
|
329
|
+
file: z.string().optional(),
|
|
330
|
+
delay: delaySchema.optional()
|
|
331
|
+
}).refine((value) => Object.keys(value).length > 0, "`patch` cannot be empty").refine((value) => !(value.file !== void 0 && value.body !== void 0), "`patch`: `file` and `body` are mutually exclusive").refine((value) => !(value.headers !== void 0 && value.replaceHeaders !== void 0), "`patch`: `headers` (merge) and `replaceHeaders` (replace) are mutually exclusive");
|
|
332
|
+
/** Rewrites applied to the request before it reaches the server. */
|
|
333
|
+
const requestSchema = z.strictObject({
|
|
334
|
+
host: z.string().optional(),
|
|
335
|
+
path: z.string().optional(),
|
|
336
|
+
method: z.string().optional(),
|
|
337
|
+
query: z.record(z.string(), z.string()).optional(),
|
|
338
|
+
headers: headersSchema.optional(),
|
|
339
|
+
removeHeaders: z.array(z.string()).min(1).optional(),
|
|
340
|
+
body: z.unknown().optional(),
|
|
341
|
+
merge: z.record(z.string(), z.unknown()).optional()
|
|
342
|
+
}).refine((value) => Object.keys(value).length > 0, "`request` cannot be empty").refine((value) => !(value.body !== void 0 && value.merge !== void 0), "`request`: `body` and `merge` are mutually exclusive");
|
|
343
|
+
const ruleSchema = z.strictObject({
|
|
344
|
+
name: z.string().optional(),
|
|
345
|
+
host: z.string().optional(),
|
|
346
|
+
method: methodSchema.optional(),
|
|
347
|
+
path: z.string().optional(),
|
|
348
|
+
pathRegex: z.string().optional(),
|
|
349
|
+
query: z.record(z.string(), z.string()).optional(),
|
|
350
|
+
respond: respondSchema.optional(),
|
|
351
|
+
patch: patchSchema.optional(),
|
|
352
|
+
request: requestSchema.optional()
|
|
353
|
+
}).refine((rule) => !(rule.respond && rule.patch), "`respond` and `patch` are mutually exclusive").refine((rule) => !(rule.respond && rule.request), "`respond` short circuits the request, so `request` would be a no-op").refine((rule) => rule.respond || rule.patch || rule.request, "a rule must define `respond`, `patch` or `request`").refine((rule) => !(rule.path && rule.pathRegex), "`path` and `pathRegex` are mutually exclusive");
|
|
354
|
+
const upstreamSchema = z.union([
|
|
355
|
+
z.literal("auto"),
|
|
356
|
+
z.literal("off"),
|
|
357
|
+
z.url()
|
|
358
|
+
]);
|
|
359
|
+
const configSchema = z.strictObject({
|
|
360
|
+
/** Default host for every rule that does not declare one. */
|
|
361
|
+
host: z.string().optional(),
|
|
362
|
+
port: z.number().int().min(1).max(65535).optional(),
|
|
363
|
+
/** `auto` inherits `HTTPS_PROXY` from the environment, `off` forces a direct connection. */
|
|
364
|
+
upstream: upstreamSchema.default("auto"),
|
|
365
|
+
/**
|
|
366
|
+
* Hosts the target tool should reach without going through jean-claude.
|
|
367
|
+
* Empty by default: excluding loopback would silently skip localhost targets.
|
|
368
|
+
*/
|
|
369
|
+
noProxy: z.array(z.string()).optional(),
|
|
370
|
+
/** Hosts tunnelled without interception, for clients that pin certificates. */
|
|
371
|
+
tlsPassthrough: z.array(z.string()).optional(),
|
|
372
|
+
rules: z.array(ruleSchema).default([])
|
|
373
|
+
});
|
|
374
|
+
//#endregion
|
|
375
|
+
//#region src/config/load.ts
|
|
376
|
+
/** File names searched for when `--config` is not given. */
|
|
377
|
+
const CONFIG_FILENAMES = ["jean-claude.yaml", "jean-claude.yml"];
|
|
378
|
+
/** The config `init` writes, and the fallback when no local one is found. */
|
|
379
|
+
function configPathIn(home) {
|
|
380
|
+
return path.join(home, CONFIG_FILENAMES[0]);
|
|
381
|
+
}
|
|
382
|
+
var ConfigError = class extends Error {
|
|
383
|
+
constructor(message, options) {
|
|
384
|
+
super(message, options);
|
|
385
|
+
this.name = "ConfigError";
|
|
386
|
+
}
|
|
387
|
+
};
|
|
388
|
+
async function readIfPresent(filePath) {
|
|
389
|
+
try {
|
|
390
|
+
return await readFile(filePath, "utf8");
|
|
391
|
+
} catch (cause) {
|
|
392
|
+
if (cause.code === "ENOENT") return void 0;
|
|
393
|
+
throw new ConfigError(`cannot read ${filePath}: ${cause.message}`, { cause });
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* Look for a config file in `cwd`, then walk up the parent directories, then
|
|
398
|
+
* fall back to the one `init` writes in the jean-claude home.
|
|
399
|
+
*
|
|
400
|
+
* The global fallback is what lets a single rule apply everywhere - the main
|
|
401
|
+
* reason to reach for this tool. A project-local file still wins, so a repo can
|
|
402
|
+
* override the global rules for its own traffic.
|
|
403
|
+
*/
|
|
404
|
+
async function findConfigFile(cwd, home = jeanClaudeHome()) {
|
|
405
|
+
let dir = path.resolve(cwd);
|
|
406
|
+
for (;;) {
|
|
407
|
+
for (const name of CONFIG_FILENAMES) {
|
|
408
|
+
const candidate = path.join(dir, name);
|
|
409
|
+
if (await readIfPresent(candidate) !== void 0) return candidate;
|
|
410
|
+
}
|
|
411
|
+
const parent = path.dirname(dir);
|
|
412
|
+
if (parent === dir) break;
|
|
413
|
+
dir = parent;
|
|
414
|
+
}
|
|
415
|
+
for (const name of CONFIG_FILENAMES) {
|
|
416
|
+
const candidate = path.join(home, name);
|
|
417
|
+
if (await readIfPresent(candidate) !== void 0) return candidate;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
/** Validate an already parsed object. Exposed for tests and for `check`. */
|
|
421
|
+
function compile(raw, baseDir, filePath) {
|
|
422
|
+
const parsed = configSchema.safeParse(raw);
|
|
423
|
+
if (!parsed.success) throw new ConfigError(`invalid configuration${filePath ? ` in ${filePath}` : ""}:\n${z.prettifyError(parsed.error)}`);
|
|
424
|
+
const config = parsed.data;
|
|
425
|
+
return {
|
|
426
|
+
config,
|
|
427
|
+
rules: config.rules.map((rule, index) => ({
|
|
428
|
+
rule,
|
|
429
|
+
label: describeRule(rule, index),
|
|
430
|
+
match: compileMatcher(rule, config, index)
|
|
431
|
+
})),
|
|
432
|
+
filePath,
|
|
433
|
+
baseDir
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
async function loadConfig(explicitPath, options = {}) {
|
|
437
|
+
const cwd = options.cwd ?? process.cwd();
|
|
438
|
+
const home = options.home ?? jeanClaudeHome();
|
|
439
|
+
const filePath = explicitPath ? path.resolve(cwd, explicitPath) : await findConfigFile(cwd, home);
|
|
440
|
+
if (filePath === void 0) throw new ConfigError(`no configuration file found (${CONFIG_FILENAMES.join(" or ")}).\n Searched from ${cwd} up to the root, then ${home}.\n Run \`jean-claude init\` to create one.`);
|
|
441
|
+
const source = await readIfPresent(filePath);
|
|
442
|
+
if (source === void 0) throw new ConfigError(`configuration file not found: ${filePath}`);
|
|
443
|
+
let raw;
|
|
444
|
+
try {
|
|
445
|
+
raw = parse(source);
|
|
446
|
+
} catch (cause) {
|
|
447
|
+
throw new ConfigError(`invalid YAML in ${filePath}:\n${cause instanceof YAMLParseError ? cause.message : cause.message}`, { cause });
|
|
448
|
+
}
|
|
449
|
+
if (raw === null || raw === void 0) raw = {};
|
|
450
|
+
return compile(raw, path.dirname(filePath), filePath);
|
|
451
|
+
}
|
|
452
|
+
//#endregion
|
|
453
|
+
//#region src/commands/check.ts
|
|
454
|
+
/** Describes what a rule does, in one word. */
|
|
455
|
+
function actionOf(rule) {
|
|
456
|
+
if (rule.respond !== void 0) return pc.magenta("stub");
|
|
457
|
+
if (rule.patch !== void 0 && rule.request !== void 0) return pc.cyan("rewrite+patch");
|
|
458
|
+
if (rule.patch !== void 0) return pc.cyan("patch");
|
|
459
|
+
return pc.blue("rewrite");
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* Validates the config and prints the rules as resolved, so the effective host
|
|
463
|
+
* and method of each rule can be checked without starting the proxy.
|
|
464
|
+
*/
|
|
465
|
+
async function checkCommand(configPath, homeOption) {
|
|
466
|
+
const loaded = await loadConfig(configPath, { home: homeOption !== void 0 ? path.resolve(homeOption) : jeanClaudeHome() });
|
|
467
|
+
const upstream = detectUpstream(loaded.config);
|
|
468
|
+
const upstreamLabel = upstream !== void 0 ? `${upstream.proxyUrl} ${pc.dim(`(from ${upstream.source})`)}` : pc.dim("direct");
|
|
469
|
+
console.log(` ${pc.dim("config ")}${loaded.filePath}`);
|
|
470
|
+
console.log(` ${pc.dim("upstream ")}${upstreamLabel}`);
|
|
471
|
+
console.log(` ${pc.dim("rules ")}${loaded.rules.length}\n`);
|
|
472
|
+
if (loaded.rules.length === 0) {
|
|
473
|
+
console.log(` ${pc.yellow("!")} no rules: every request will simply be logged and relayed.\n`);
|
|
474
|
+
return 0;
|
|
475
|
+
}
|
|
476
|
+
for (const [index, { rule, label }] of loaded.rules.entries()) {
|
|
477
|
+
const host = rule.host ?? loaded.config.host ?? pc.dim("any host");
|
|
478
|
+
const method = rule.method?.join("|") ?? pc.dim("any");
|
|
479
|
+
const target = rule.path ?? (rule.pathRegex !== void 0 ? `re:${rule.pathRegex}` : pc.dim("any path"));
|
|
480
|
+
console.log(` ${pc.dim(String(index + 1).padStart(2))} ${actionOf(rule)} ${label}`);
|
|
481
|
+
console.log(` ${pc.dim("match")} ${method} ${host}${target}`);
|
|
482
|
+
if (rule.query !== void 0) console.log(` ${pc.dim("query")} ${JSON.stringify(rule.query)}`);
|
|
483
|
+
}
|
|
484
|
+
console.log("");
|
|
485
|
+
return 0;
|
|
486
|
+
}
|
|
487
|
+
//#endregion
|
|
488
|
+
//#region src/env/child.ts
|
|
489
|
+
/** Variables added to, or overwritten in, the child environment. */
|
|
490
|
+
function proxyEnvVars({ proxyUrl, bundlePath, noProxy }) {
|
|
491
|
+
const exclusions = noProxy?.filter((entry) => entry.trim() !== "") ?? [];
|
|
492
|
+
return {
|
|
493
|
+
HTTP_PROXY: proxyUrl,
|
|
494
|
+
HTTPS_PROXY: proxyUrl,
|
|
495
|
+
http_proxy: proxyUrl,
|
|
496
|
+
https_proxy: proxyUrl,
|
|
497
|
+
...exclusions.length > 0 ? {
|
|
498
|
+
NO_PROXY: exclusions.join(","),
|
|
499
|
+
no_proxy: exclusions.join(",")
|
|
500
|
+
} : {},
|
|
501
|
+
NODE_USE_ENV_PROXY: "1",
|
|
502
|
+
NODE_EXTRA_CA_CERTS: bundlePath,
|
|
503
|
+
SSL_CERT_FILE: bundlePath,
|
|
504
|
+
CURL_CA_BUNDLE: bundlePath,
|
|
505
|
+
REQUESTS_CA_BUNDLE: bundlePath,
|
|
506
|
+
AWS_CA_BUNDLE: bundlePath,
|
|
507
|
+
GIT_SSL_CAINFO: bundlePath
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* Variables that have to be *removed* rather than set. An inherited `NO_PROXY`
|
|
512
|
+
* would otherwise punch a hole straight through the interception.
|
|
513
|
+
*/
|
|
514
|
+
function proxyEnvUnset({ noProxy }) {
|
|
515
|
+
return (noProxy?.filter((entry) => entry.trim() !== "") ?? []).length > 0 ? [] : ["NO_PROXY", "no_proxy"];
|
|
516
|
+
}
|
|
517
|
+
function buildChildEnv(base, options) {
|
|
518
|
+
const env = {
|
|
519
|
+
...base,
|
|
520
|
+
...proxyEnvVars(options)
|
|
521
|
+
};
|
|
522
|
+
for (const name of proxyEnvUnset(options)) delete env[name];
|
|
523
|
+
return env;
|
|
524
|
+
}
|
|
525
|
+
function parseVersion(version) {
|
|
526
|
+
const [major = 0, minor = 0, patch = 0] = version.split(".").map((part) => Number.parseInt(part, 10) || 0);
|
|
527
|
+
return [
|
|
528
|
+
major,
|
|
529
|
+
minor,
|
|
530
|
+
patch
|
|
531
|
+
];
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* `NODE_USE_ENV_PROXY` only exists from Node 22.21 / 24.5 onwards. Below that, a
|
|
535
|
+
* Node-based target will ignore the proxy and its traffic will bypass jean-claude.
|
|
536
|
+
*/
|
|
537
|
+
function nodeSupportsEnvProxy(version = process.versions.node) {
|
|
538
|
+
const [major, minor] = parseVersion(version);
|
|
539
|
+
if (major >= 25) return true;
|
|
540
|
+
if (major === 24) return minor >= 5;
|
|
541
|
+
if (major === 23) return false;
|
|
542
|
+
if (major === 22) return minor >= 21;
|
|
543
|
+
return false;
|
|
544
|
+
}
|
|
545
|
+
/** A shell-evaluable block, for `jean-claude start --export`. */
|
|
546
|
+
function formatShellExports(vars, unset = []) {
|
|
547
|
+
const lines = Object.entries(vars).map(([key, value]) => `export ${key}='${value.replaceAll("'", `'\\''`)}'`);
|
|
548
|
+
if (unset.length > 0) lines.push(`unset ${unset.join(" ")}`);
|
|
549
|
+
return lines.join("\n");
|
|
550
|
+
}
|
|
551
|
+
//#endregion
|
|
552
|
+
//#region src/env/session.ts
|
|
553
|
+
function sessionFilePath(home) {
|
|
554
|
+
return path.join(home, "session.json");
|
|
555
|
+
}
|
|
556
|
+
async function writeSessionFile(home, session) {
|
|
557
|
+
await writeFile(sessionFilePath(home), `${JSON.stringify(session, null, 2)}\n`, { mode: 384 });
|
|
558
|
+
}
|
|
559
|
+
async function removeSessionFile(home) {
|
|
560
|
+
await rm(sessionFilePath(home), { force: true });
|
|
561
|
+
}
|
|
562
|
+
/** Signal 0 probes for existence without actually signalling the process. */
|
|
563
|
+
function isProcessAlive(pid) {
|
|
564
|
+
try {
|
|
565
|
+
process.kill(pid, 0);
|
|
566
|
+
return true;
|
|
567
|
+
} catch (error) {
|
|
568
|
+
return error.code === "EPERM";
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
var NoSessionError = class extends Error {
|
|
572
|
+
constructor(message) {
|
|
573
|
+
super(message);
|
|
574
|
+
this.name = "NoSessionError";
|
|
575
|
+
}
|
|
576
|
+
};
|
|
577
|
+
/** Reads the session file, refusing anything stale so callers never get a dead port. */
|
|
578
|
+
async function readSessionFile(home) {
|
|
579
|
+
const filePath = sessionFilePath(home);
|
|
580
|
+
let raw;
|
|
581
|
+
try {
|
|
582
|
+
raw = await readFile(filePath, "utf8");
|
|
583
|
+
} catch (cause) {
|
|
584
|
+
if (cause.code === "ENOENT") throw new NoSessionError("no running session found.\n Start one in another terminal with `jean-claude start`, or pass `--port` explicitly.");
|
|
585
|
+
throw cause;
|
|
586
|
+
}
|
|
587
|
+
let session;
|
|
588
|
+
try {
|
|
589
|
+
session = JSON.parse(raw);
|
|
590
|
+
} catch {
|
|
591
|
+
throw new NoSessionError(`session file is unreadable: ${filePath}`);
|
|
592
|
+
}
|
|
593
|
+
if (!isProcessAlive(session.pid)) throw new NoSessionError(`the session recorded in ${filePath} is gone (pid ${session.pid} is not running).\n Start a new one with \`jean-claude start\`.`);
|
|
594
|
+
return session;
|
|
595
|
+
}
|
|
596
|
+
//#endregion
|
|
597
|
+
//#region src/commands/env.ts
|
|
598
|
+
/**
|
|
599
|
+
* Prints the environment for a proxy that is *already running*, without starting
|
|
600
|
+
* one. This is what makes the two-terminal workflow work:
|
|
601
|
+
*
|
|
602
|
+
* terminal 1: jean-claude start
|
|
603
|
+
* terminal 2: eval "$(jean-claude env)" && claude
|
|
604
|
+
*
|
|
605
|
+
* `jean-claude start --export` cannot be used for this - it runs in the
|
|
606
|
+
* foreground, so the command substitution would never return.
|
|
607
|
+
*/
|
|
608
|
+
async function envCommand(options) {
|
|
609
|
+
const home = options.home !== void 0 ? path.resolve(options.home) : jeanClaudeHome();
|
|
610
|
+
const { proxyUrl, bundlePath, noProxy } = options.port !== void 0 ? {
|
|
611
|
+
proxyUrl: `http://127.0.0.1:${options.port}`,
|
|
612
|
+
bundlePath: caPathsIn(caDirIn(home)).bundlePath,
|
|
613
|
+
noProxy: void 0
|
|
614
|
+
} : await (async () => {
|
|
615
|
+
const session = await readSessionFile(home);
|
|
616
|
+
return {
|
|
617
|
+
proxyUrl: session.proxy,
|
|
618
|
+
bundlePath: session.bundle,
|
|
619
|
+
noProxy: session.noProxy ?? void 0
|
|
620
|
+
};
|
|
621
|
+
})();
|
|
622
|
+
const childEnvOptions = {
|
|
623
|
+
proxyUrl,
|
|
624
|
+
bundlePath,
|
|
625
|
+
noProxy
|
|
626
|
+
};
|
|
627
|
+
const vars = proxyEnvVars(childEnvOptions);
|
|
628
|
+
const unset = proxyEnvUnset(childEnvOptions);
|
|
629
|
+
console.log(options.json ? JSON.stringify({
|
|
630
|
+
env: vars,
|
|
631
|
+
unset
|
|
632
|
+
}, null, 2) : formatShellExports(vars, unset));
|
|
633
|
+
return 0;
|
|
634
|
+
}
|
|
635
|
+
//#endregion
|
|
636
|
+
//#region src/config/template.ts
|
|
637
|
+
/** Scaffolding written by `jean-claude init`. */
|
|
638
|
+
/**
|
|
639
|
+
* The commented-out tour of the config format, shared by both templates so the
|
|
640
|
+
* Claude Code one does not become a dead end when you want a second rule.
|
|
641
|
+
*/
|
|
642
|
+
const EXAMPLES = ` # 2 - Patch the real response. It does reach the server; we edit what comes back.
|
|
643
|
+
# 'merge' is for object responses, 'jsonPatch' for arrays and precise edits.
|
|
644
|
+
# - path: /api/users/:id
|
|
645
|
+
# patch:
|
|
646
|
+
# merge: { verified: true }
|
|
647
|
+
|
|
648
|
+
# 3 - Force a status, headers or latency, to exercise error handling.
|
|
649
|
+
# - path: /api/flaky
|
|
650
|
+
# patch:
|
|
651
|
+
# status: 500
|
|
652
|
+
# body: { error: "boom" }
|
|
653
|
+
# delay: 2000
|
|
654
|
+
|
|
655
|
+
# 4 - Rewrite the outgoing request before it reaches the server.
|
|
656
|
+
# - host: auth.example.com
|
|
657
|
+
# path: /oauth/token
|
|
658
|
+
# request:
|
|
659
|
+
# host: staging-auth.example.com
|
|
660
|
+
# headers: { authorization: "Bearer TEST" }
|
|
661
|
+
|
|
662
|
+
# Captured path parameters are interpolable into the stub file name:
|
|
663
|
+
# - path: /api/users/:id
|
|
664
|
+
# respond: ./responses/users/{id}.json
|
|
665
|
+
`;
|
|
666
|
+
const CONFIG_TEMPLATE = `# jean-claude - HTTPS traffic rewriting rules.
|
|
667
|
+
#
|
|
668
|
+
# Every rule is tried in file order; the first one that matches wins.
|
|
669
|
+
# Anything that matches nothing is still decrypted, logged and relayed untouched.
|
|
670
|
+
|
|
671
|
+
# Default host for the rules below. Remove it to match any host.
|
|
672
|
+
host: api.example.com
|
|
673
|
+
|
|
674
|
+
# Upstream proxy for relayed traffic:
|
|
675
|
+
# auto inherit HTTPS_PROXY from the environment (default)
|
|
676
|
+
# off always connect directly
|
|
677
|
+
# <url> an explicit proxy
|
|
678
|
+
upstream: auto
|
|
679
|
+
|
|
680
|
+
rules:
|
|
681
|
+
# 1 - Replace the response with a file. The server is never contacted.
|
|
682
|
+
- name: frozen todos
|
|
683
|
+
method: GET
|
|
684
|
+
path: /api/todos
|
|
685
|
+
respond: ./responses/todos.json
|
|
686
|
+
|
|
687
|
+
${EXAMPLES}`;
|
|
688
|
+
const STUB_TEMPLATE = `${JSON.stringify([{
|
|
689
|
+
title: "Dining",
|
|
690
|
+
details: "bla-bla-bla"
|
|
691
|
+
}], null, 2)}\n`;
|
|
692
|
+
/**
|
|
693
|
+
* `init --claude-code`: pin Claude Code's managed settings to a local file, so
|
|
694
|
+
* the ones pushed by the account no longer apply.
|
|
695
|
+
*/
|
|
696
|
+
const CLAUDE_CODE_TEMPLATE = `# jean-claude - HTTPS traffic rewriting rules.
|
|
697
|
+
#
|
|
698
|
+
# Every rule is tried in file order; the first one that matches wins.
|
|
699
|
+
# Anything that matches nothing is still decrypted, logged and relayed untouched.
|
|
700
|
+
|
|
701
|
+
# Default host for the rules below. Remove it to match any host.
|
|
702
|
+
host: api.anthropic.com
|
|
703
|
+
|
|
704
|
+
# Upstream proxy for relayed traffic:
|
|
705
|
+
# auto inherit HTTPS_PROXY from the environment (default)
|
|
706
|
+
# off always connect directly
|
|
707
|
+
# <url> an explicit proxy
|
|
708
|
+
upstream: auto
|
|
709
|
+
|
|
710
|
+
rules:
|
|
711
|
+
# 1 - Freeze the settings Claude Code fetches at startup: it gets this file
|
|
712
|
+
# instead, and the server is never contacted for it. Edit the 'settings'
|
|
713
|
+
# object in the stub; it is re-read on every request.
|
|
714
|
+
#
|
|
715
|
+
# To start from what your account actually sends:
|
|
716
|
+
#
|
|
717
|
+
# jean-claude run --record ./captures -- claude
|
|
718
|
+
# cp ./captures/api.anthropic.com/api/claude_code/settings.GET.json \\
|
|
719
|
+
# ./responses/settings.GET.json
|
|
720
|
+
- name: frozen claude_code settings
|
|
721
|
+
method: GET
|
|
722
|
+
path: /api/claude_code/settings
|
|
723
|
+
respond: ./responses/settings.GET.json
|
|
724
|
+
|
|
725
|
+
${EXAMPLES}`;
|
|
726
|
+
const CLAUDE_CODE_STUB = `${JSON.stringify({
|
|
727
|
+
uuid: "1cb619ae-b7bd-495b-9f0d-741d32e4fad6",
|
|
728
|
+
checksum: "sha256:3081f0d1458367573f9a7cef56e490081218986bc8e20bc59257dfc10d930826",
|
|
729
|
+
settings: {
|
|
730
|
+
env: { CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1" },
|
|
731
|
+
permissions: { defaultMode: "plan" }
|
|
732
|
+
}
|
|
733
|
+
}, null, 2)}\n`;
|
|
734
|
+
//#endregion
|
|
735
|
+
//#region src/commands/init.ts
|
|
736
|
+
/**
|
|
737
|
+
* Sets up a jean-claude home: the config, its stub, and the CA. One command and
|
|
738
|
+
* `jean-claude run -- <tool>` works from anywhere.
|
|
739
|
+
*
|
|
740
|
+
* Existing files are never overwritten - re-running `init` is safe, and is the
|
|
741
|
+
* way to add the CA to a home scaffolded by an older version.
|
|
742
|
+
*/
|
|
743
|
+
async function initCommand(options) {
|
|
744
|
+
const home = options.home !== void 0 ? path.resolve(options.home) : jeanClaudeHome();
|
|
745
|
+
const stubName = options.claudeCode ? "settings.GET.json" : "todos.json";
|
|
746
|
+
const files = [[configPathIn(home), options.claudeCode ? CLAUDE_CODE_TEMPLATE : CONFIG_TEMPLATE], [path.join(responsesDirIn(home), stubName), options.claudeCode ? CLAUDE_CODE_STUB : STUB_TEMPLATE]];
|
|
747
|
+
const written = [];
|
|
748
|
+
const skipped = [];
|
|
749
|
+
for (const [target, contents] of files) {
|
|
750
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
751
|
+
try {
|
|
752
|
+
await writeFile(target, contents, { flag: "wx" });
|
|
753
|
+
written.push(target);
|
|
754
|
+
} catch (cause) {
|
|
755
|
+
if (cause.code !== "EEXIST") throw cause;
|
|
756
|
+
skipped.push(target);
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
const ca = await ensureCa({
|
|
760
|
+
dir: caDirIn(home),
|
|
761
|
+
inheritedCa: inheritedExtraCaCerts()
|
|
762
|
+
});
|
|
763
|
+
(ca.created ? written : skipped).push(ca.certPath);
|
|
764
|
+
written.push(ca.bundlePath);
|
|
765
|
+
for (const file of written) console.log(` ${pc.green("created")} ${file}`);
|
|
766
|
+
for (const file of skipped) console.log(` ${pc.yellow("kept")} ${file} ${pc.dim("(already existed)")}`);
|
|
767
|
+
const next = options.claudeCode ? "jean-claude run -- claude" : "jean-claude run -- <your command>";
|
|
768
|
+
console.log(`\n ${pc.dim(`Next: \`${next}\``)}`);
|
|
769
|
+
console.log(` ${pc.dim("`jean-claude ca --install` if a target cannot be pointed at the bundle.")}\n`);
|
|
770
|
+
return 0;
|
|
771
|
+
}
|
|
772
|
+
//#endregion
|
|
773
|
+
//#region src/config/watch.ts
|
|
774
|
+
/**
|
|
775
|
+
* Watches the config file and calls `onChange` after each save.
|
|
776
|
+
*
|
|
777
|
+
* Only the config file is watched: stub files are re-read on every request, so
|
|
778
|
+
* editing a response takes effect with no reload at all.
|
|
779
|
+
*/
|
|
780
|
+
function watchConfig(filePath, onChange) {
|
|
781
|
+
const watcher = watch(filePath, {
|
|
782
|
+
ignoreInitial: true,
|
|
783
|
+
awaitWriteFinish: {
|
|
784
|
+
stabilityThreshold: 150,
|
|
785
|
+
pollInterval: 30
|
|
786
|
+
}
|
|
787
|
+
});
|
|
788
|
+
watcher.on("change", () => void onChange());
|
|
789
|
+
watcher.on("add", () => void onChange());
|
|
790
|
+
return () => watcher.close();
|
|
791
|
+
}
|
|
792
|
+
//#endregion
|
|
793
|
+
//#region src/log/reporter.ts
|
|
794
|
+
const KIND_LABELS = {
|
|
795
|
+
stub: "stub",
|
|
796
|
+
patch: "patched",
|
|
797
|
+
rewrite: "rewritten",
|
|
798
|
+
passthrough: "passthrough"
|
|
799
|
+
};
|
|
800
|
+
function colorStatus(status) {
|
|
801
|
+
const text = String(status);
|
|
802
|
+
if (status >= 500) return pc.red(text);
|
|
803
|
+
if (status >= 400) return pc.yellow(text);
|
|
804
|
+
if (status >= 300) return pc.cyan(text);
|
|
805
|
+
return pc.green(text);
|
|
806
|
+
}
|
|
807
|
+
function shortTarget(url) {
|
|
808
|
+
if (url === void 0) return "(unknown url)";
|
|
809
|
+
try {
|
|
810
|
+
const parsed = new URL(url);
|
|
811
|
+
return `${parsed.host}${parsed.pathname}${parsed.search}`;
|
|
812
|
+
} catch {
|
|
813
|
+
return url;
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
function pad(text, width) {
|
|
817
|
+
return text.length >= width ? text : text + " ".repeat(width - text.length);
|
|
818
|
+
}
|
|
819
|
+
function truncate(text, width) {
|
|
820
|
+
return text.length <= width ? text : `${text.slice(0, width - 1)}…`;
|
|
821
|
+
}
|
|
822
|
+
/**
|
|
823
|
+
* Console log. One line per request, emitted on response so that the status
|
|
824
|
+
* actually delivered to the client can be shown.
|
|
825
|
+
*/
|
|
826
|
+
var Reporter = class {
|
|
827
|
+
options;
|
|
828
|
+
entries = /* @__PURE__ */ new Map();
|
|
829
|
+
constructor(options) {
|
|
830
|
+
this.options = options;
|
|
831
|
+
}
|
|
832
|
+
/** Merging upsert, so we do not depend on the order of the `request` event vs the rules. */
|
|
833
|
+
upsert(id, patch) {
|
|
834
|
+
this.entries.set(id, {
|
|
835
|
+
...this.entries.get(id),
|
|
836
|
+
...patch
|
|
837
|
+
});
|
|
838
|
+
}
|
|
839
|
+
seen(id, method, url) {
|
|
840
|
+
this.upsert(id, {
|
|
841
|
+
method,
|
|
842
|
+
url
|
|
843
|
+
});
|
|
844
|
+
}
|
|
845
|
+
action(id, kind, label, detail) {
|
|
846
|
+
this.upsert(id, {
|
|
847
|
+
kind,
|
|
848
|
+
label,
|
|
849
|
+
detail
|
|
850
|
+
});
|
|
851
|
+
}
|
|
852
|
+
recorded(id, filePath) {
|
|
853
|
+
this.upsert(id, { recorded: filePath });
|
|
854
|
+
}
|
|
855
|
+
response(id, statusCode) {
|
|
856
|
+
const entry = this.entries.get(id) ?? {};
|
|
857
|
+
this.entries.delete(id);
|
|
858
|
+
if (this.options.quiet) return;
|
|
859
|
+
if ((entry.kind ?? "passthrough") === "passthrough" && entry.recorded === void 0 && !this.options.verbose) return;
|
|
860
|
+
console.log(` ${pad(entry.method ?? "???", 6)}${pad(truncate(shortTarget(entry.url), 52), 54)}${colorStatus(statusCode)}${this.describeAction(entry)}`);
|
|
861
|
+
}
|
|
862
|
+
describeAction(entry) {
|
|
863
|
+
const parts = [];
|
|
864
|
+
if (entry.kind !== void 0 && entry.kind !== "passthrough") {
|
|
865
|
+
const detail = entry.detail !== void 0 ? ` ${pc.dim(entry.detail)}` : "";
|
|
866
|
+
parts.push(`${pc.magenta("→")} ${KIND_LABELS[entry.kind]}${detail}`);
|
|
867
|
+
}
|
|
868
|
+
if (entry.recorded !== void 0) parts.push(`${pc.blue("⇒")} ${pc.dim(entry.recorded)}`);
|
|
869
|
+
return parts.length > 0 ? ` ${parts.join(" ")}` : "";
|
|
870
|
+
}
|
|
871
|
+
aborted(id, reason) {
|
|
872
|
+
const entry = this.entries.get(id) ?? {};
|
|
873
|
+
this.entries.delete(id);
|
|
874
|
+
if (this.options.quiet) return;
|
|
875
|
+
const detail = reason !== void 0 ? ` ${pc.dim(`(${reason})`)}` : "";
|
|
876
|
+
console.log(` ${pad(entry.method ?? "???", 6)}${pad(truncate(shortTarget(entry.url), 52), 54)}${pc.red("aborted")}${detail}`);
|
|
877
|
+
}
|
|
878
|
+
/**
|
|
879
|
+
* A client-side TLS failure almost always means the target pins its
|
|
880
|
+
* certificates. Say so, rather than leaving the user to guess.
|
|
881
|
+
*/
|
|
882
|
+
tlsError(hostname) {
|
|
883
|
+
if (this.options.quiet) return;
|
|
884
|
+
const where = hostname ?? "unknown host";
|
|
885
|
+
this.warn(`TLS handshake with ${where} failed - the target rejected jean-claude's CA (certificate pinning?). Add "tlsPassthrough: [${where}]" to the config to tunnel it untouched.`);
|
|
886
|
+
}
|
|
887
|
+
banner(lines) {
|
|
888
|
+
if (this.options.quiet) return;
|
|
889
|
+
const width = Math.max(...lines.map(([key]) => key.length));
|
|
890
|
+
for (const [key, value] of lines) console.log(` ${pc.dim(pad(key, width + 2))}${value}`);
|
|
891
|
+
console.log("");
|
|
892
|
+
}
|
|
893
|
+
info(message) {
|
|
894
|
+
if (!this.options.quiet) console.log(` ${message}`);
|
|
895
|
+
}
|
|
896
|
+
warn(message) {
|
|
897
|
+
console.warn(` ${pc.yellow("!")} ${message}`);
|
|
898
|
+
}
|
|
899
|
+
};
|
|
900
|
+
//#endregion
|
|
901
|
+
//#region src/record/writer.ts
|
|
902
|
+
/**
|
|
903
|
+
* Writes real responses to disk, in a tree that mirrors the URL. Only the body
|
|
904
|
+
* is written, so the resulting file can be dropped straight into a `respond:` rule.
|
|
905
|
+
*/
|
|
906
|
+
const EXTENSION_BY_TYPE = [
|
|
907
|
+
[/json/, "json"],
|
|
908
|
+
[/html/, "html"],
|
|
909
|
+
[/xml/, "xml"],
|
|
910
|
+
[/javascript|ecmascript/, "js"],
|
|
911
|
+
[/css/, "css"],
|
|
912
|
+
[/plain/, "txt"]
|
|
913
|
+
];
|
|
914
|
+
function isJson(body) {
|
|
915
|
+
if (body.length === 0) return false;
|
|
916
|
+
try {
|
|
917
|
+
JSON.parse(body.toString("utf8"));
|
|
918
|
+
return true;
|
|
919
|
+
} catch {
|
|
920
|
+
return false;
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
function extensionFor(contentType, body) {
|
|
924
|
+
if (contentType !== void 0) {
|
|
925
|
+
const lower = contentType.toLowerCase();
|
|
926
|
+
for (const [pattern, extension] of EXTENSION_BY_TYPE) if (pattern.test(lower)) return extension;
|
|
927
|
+
}
|
|
928
|
+
return isJson(body) ? "json" : "bin";
|
|
929
|
+
}
|
|
930
|
+
/** Strip anything that has no business being in a file name. */
|
|
931
|
+
function sanitizeSegment(segment) {
|
|
932
|
+
const cleaned = segment.replaceAll(/[^A-Za-z0-9._@-]/g, "_");
|
|
933
|
+
return cleaned === "" || cleaned === "." || cleaned === ".." ? "_" : cleaned;
|
|
934
|
+
}
|
|
935
|
+
/**
|
|
936
|
+
* Build the capture path. The query string is reduced to a short digest, so two
|
|
937
|
+
* requests differing only by their parameters do not overwrite each other.
|
|
938
|
+
*/
|
|
939
|
+
function capturePathFor(dir, url, method, extension) {
|
|
940
|
+
const parsed = new URL(url);
|
|
941
|
+
const segments = parsed.pathname.split("/").filter((segment) => segment !== "");
|
|
942
|
+
const parents = segments.slice(0, -1).map(sanitizeSegment);
|
|
943
|
+
const fileName = `${sanitizeSegment(segments.at(-1) ?? "index")}${parsed.search === "" ? "" : `-${createHash("sha1").update(parsed.search).digest("hex").slice(0, 8)}`}.${method.toUpperCase()}.${extension}`;
|
|
944
|
+
return path.join(dir, sanitizeSegment(parsed.host), ...parents, fileName);
|
|
945
|
+
}
|
|
946
|
+
function reindentJson(body) {
|
|
947
|
+
try {
|
|
948
|
+
return `${JSON.stringify(JSON.parse(body.toString("utf8")), null, 2)}\n`;
|
|
949
|
+
} catch {
|
|
950
|
+
return body;
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
var Recorder = class {
|
|
954
|
+
dir;
|
|
955
|
+
constructor(dir) {
|
|
956
|
+
this.dir = dir;
|
|
957
|
+
}
|
|
958
|
+
/** Write the response body and return the path relative to the cwd, for logging. */
|
|
959
|
+
async record(url, method, body, contentType) {
|
|
960
|
+
const extension = extensionFor(contentType, body);
|
|
961
|
+
const target = capturePathFor(this.dir, url, method, extension);
|
|
962
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
963
|
+
await writeFile(target, extension === "json" ? reindentJson(body) : body);
|
|
964
|
+
return path.relative(process.cwd(), target);
|
|
965
|
+
}
|
|
966
|
+
};
|
|
967
|
+
//#endregion
|
|
968
|
+
//#region src/util/json.ts
|
|
969
|
+
/** A JSON Patch failure, carrying the index of the offending operation. */
|
|
970
|
+
var JsonPatchError = class extends Error {
|
|
971
|
+
index;
|
|
972
|
+
constructor(message, index) {
|
|
973
|
+
super(`jsonPatch[${index}]: ${message}`);
|
|
974
|
+
this.index = index;
|
|
975
|
+
this.name = "JsonPatchError";
|
|
976
|
+
}
|
|
977
|
+
};
|
|
978
|
+
function isPlainObject(value) {
|
|
979
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
980
|
+
}
|
|
981
|
+
/**
|
|
982
|
+
* Deep merge. Plain objects are merged recursively; arrays and scalars from
|
|
983
|
+
* `source` overwrite whatever `target` holds.
|
|
984
|
+
*/
|
|
985
|
+
function deepMerge(target, source) {
|
|
986
|
+
if (!isPlainObject(target)) return structuredClone(source);
|
|
987
|
+
const result = { ...target };
|
|
988
|
+
for (const [key, value] of Object.entries(source)) {
|
|
989
|
+
const existing = result[key];
|
|
990
|
+
result[key] = isPlainObject(value) && isPlainObject(existing) ? deepMerge(existing, value) : structuredClone(value);
|
|
991
|
+
}
|
|
992
|
+
return result;
|
|
993
|
+
}
|
|
994
|
+
/** Decode a JSON Pointer (RFC 6901) into its segments. */
|
|
995
|
+
function parseJsonPointer(pointer) {
|
|
996
|
+
if (pointer === "") return [];
|
|
997
|
+
return pointer.split("/").slice(1).map((segment) => segment.replaceAll("~1", "/").replaceAll("~0", "~"));
|
|
998
|
+
}
|
|
999
|
+
function isContainer(value) {
|
|
1000
|
+
return typeof value === "object" && value !== null;
|
|
1001
|
+
}
|
|
1002
|
+
/** Resolve every segment but the last, returning the parent container and the final key. */
|
|
1003
|
+
function resolveParent(root, segments, index) {
|
|
1004
|
+
let current = root;
|
|
1005
|
+
for (const segment of segments.slice(0, -1)) {
|
|
1006
|
+
if (!isContainer(current)) throw new JsonPatchError(`path crosses a non-container value ("${segment}")`, index);
|
|
1007
|
+
current = Array.isArray(current) ? current[Number(segment)] : current[segment];
|
|
1008
|
+
}
|
|
1009
|
+
if (!isContainer(current)) throw new JsonPatchError("the parent of the target path does not exist", index);
|
|
1010
|
+
return {
|
|
1011
|
+
parent: current,
|
|
1012
|
+
key: segments.at(-1)
|
|
1013
|
+
};
|
|
1014
|
+
}
|
|
1015
|
+
function arrayIndex(array, key, index, { allowAppend = false } = {}) {
|
|
1016
|
+
if (key === "-") {
|
|
1017
|
+
if (!allowAppend) throw new JsonPatchError("\"-\" is only usable with `add`", index);
|
|
1018
|
+
return array.length;
|
|
1019
|
+
}
|
|
1020
|
+
const position = Number(key);
|
|
1021
|
+
if (!Number.isInteger(position) || position < 0 || position > array.length - (allowAppend ? 0 : 1)) throw new JsonPatchError(`invalid array index "${key}"`, index);
|
|
1022
|
+
return position;
|
|
1023
|
+
}
|
|
1024
|
+
function readPointer(root, pointer, index) {
|
|
1025
|
+
const segments = parseJsonPointer(pointer);
|
|
1026
|
+
let current = root;
|
|
1027
|
+
for (const segment of segments) {
|
|
1028
|
+
if (!isContainer(current)) throw new JsonPatchError(`path "${pointer}" cannot be resolved`, index);
|
|
1029
|
+
current = Array.isArray(current) ? current[arrayIndex(current, segment, index)] : current[segment];
|
|
1030
|
+
}
|
|
1031
|
+
return current;
|
|
1032
|
+
}
|
|
1033
|
+
function writePointer(root, pointer, value, index, mode) {
|
|
1034
|
+
const segments = parseJsonPointer(pointer);
|
|
1035
|
+
if (segments.length === 0) return value;
|
|
1036
|
+
const { parent, key } = resolveParent(root, segments, index);
|
|
1037
|
+
if (Array.isArray(parent)) {
|
|
1038
|
+
const position = arrayIndex(parent, key, index, { allowAppend: mode === "add" });
|
|
1039
|
+
if (mode === "add") parent.splice(position, 0, value);
|
|
1040
|
+
else parent[position] = value;
|
|
1041
|
+
} else {
|
|
1042
|
+
if (mode === "replace" && !(key in parent)) throw new JsonPatchError(`key "${key}" does not exist`, index);
|
|
1043
|
+
parent[key] = value;
|
|
1044
|
+
}
|
|
1045
|
+
return root;
|
|
1046
|
+
}
|
|
1047
|
+
function removePointer(root, pointer, index) {
|
|
1048
|
+
const segments = parseJsonPointer(pointer);
|
|
1049
|
+
if (segments.length === 0) throw new JsonPatchError("`remove` on the document root is not allowed", index);
|
|
1050
|
+
const { parent, key } = resolveParent(root, segments, index);
|
|
1051
|
+
if (Array.isArray(parent)) parent.splice(arrayIndex(parent, key, index), 1);
|
|
1052
|
+
else {
|
|
1053
|
+
if (!(key in parent)) throw new JsonPatchError(`key "${key}" does not exist`, index);
|
|
1054
|
+
delete parent[key];
|
|
1055
|
+
}
|
|
1056
|
+
return root;
|
|
1057
|
+
}
|
|
1058
|
+
function deepEqual(a, b) {
|
|
1059
|
+
if (a === b) return true;
|
|
1060
|
+
if (Array.isArray(a) && Array.isArray(b)) return a.length === b.length && a.every((item, i) => deepEqual(item, b[i]));
|
|
1061
|
+
if (isPlainObject(a) && isPlainObject(b)) {
|
|
1062
|
+
const keys = Object.keys(a);
|
|
1063
|
+
return keys.length === Object.keys(b).length && keys.every((key) => deepEqual(a[key], b[key]));
|
|
1064
|
+
}
|
|
1065
|
+
return false;
|
|
1066
|
+
}
|
|
1067
|
+
/**
|
|
1068
|
+
* Apply a JSON Patch (RFC 6902) to a copy of the document.
|
|
1069
|
+
* The input document is never mutated.
|
|
1070
|
+
*/
|
|
1071
|
+
function applyJsonPatch(document, operations) {
|
|
1072
|
+
let result = structuredClone(document);
|
|
1073
|
+
for (const [index, operation] of operations.entries()) switch (operation.op) {
|
|
1074
|
+
case "add":
|
|
1075
|
+
result = writePointer(result, operation.path, structuredClone(operation.value), index, "add");
|
|
1076
|
+
break;
|
|
1077
|
+
case "replace":
|
|
1078
|
+
result = writePointer(result, operation.path, structuredClone(operation.value), index, "replace");
|
|
1079
|
+
break;
|
|
1080
|
+
case "remove":
|
|
1081
|
+
result = removePointer(result, operation.path, index);
|
|
1082
|
+
break;
|
|
1083
|
+
case "move": {
|
|
1084
|
+
if (operation.from === void 0) throw new JsonPatchError("`move` requires `from`", index);
|
|
1085
|
+
const moved = structuredClone(readPointer(result, operation.from, index));
|
|
1086
|
+
result = removePointer(result, operation.from, index);
|
|
1087
|
+
result = writePointer(result, operation.path, moved, index, "add");
|
|
1088
|
+
break;
|
|
1089
|
+
}
|
|
1090
|
+
case "copy": {
|
|
1091
|
+
if (operation.from === void 0) throw new JsonPatchError("`copy` requires `from`", index);
|
|
1092
|
+
const copied = structuredClone(readPointer(result, operation.from, index));
|
|
1093
|
+
result = writePointer(result, operation.path, copied, index, "add");
|
|
1094
|
+
break;
|
|
1095
|
+
}
|
|
1096
|
+
case "test": if (!deepEqual(readPointer(result, operation.path, index), operation.value)) throw new JsonPatchError(`\`test\` failed at "${operation.path}"`, index);
|
|
1097
|
+
}
|
|
1098
|
+
return result;
|
|
1099
|
+
}
|
|
1100
|
+
//#endregion
|
|
1101
|
+
//#region src/proxy/actions.ts
|
|
1102
|
+
const CONTENT_TYPE_BY_EXTENSION = {
|
|
1103
|
+
".json": "application/json",
|
|
1104
|
+
".html": "text/html; charset=utf-8",
|
|
1105
|
+
".xml": "application/xml",
|
|
1106
|
+
".txt": "text/plain; charset=utf-8",
|
|
1107
|
+
".js": "application/javascript",
|
|
1108
|
+
".css": "text/css",
|
|
1109
|
+
".csv": "text/csv"
|
|
1110
|
+
};
|
|
1111
|
+
/** Headers to drop as soon as we send back a decoded body of a different size. */
|
|
1112
|
+
const BODY_DEPENDENT_HEADERS = [
|
|
1113
|
+
"content-length",
|
|
1114
|
+
"content-encoding",
|
|
1115
|
+
"transfer-encoding"
|
|
1116
|
+
];
|
|
1117
|
+
function withoutBodyHeaders(headers) {
|
|
1118
|
+
const result = { ...headers };
|
|
1119
|
+
for (const name of BODY_DEPENDENT_HEADERS) delete result[name];
|
|
1120
|
+
return result;
|
|
1121
|
+
}
|
|
1122
|
+
function lowercaseKeys(headers) {
|
|
1123
|
+
if (headers === void 0) return {};
|
|
1124
|
+
return Object.fromEntries(Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]));
|
|
1125
|
+
}
|
|
1126
|
+
function headerValue(headers, name) {
|
|
1127
|
+
const value = headers[name];
|
|
1128
|
+
return Array.isArray(value) ? value[0] : value;
|
|
1129
|
+
}
|
|
1130
|
+
async function readStub(filePath) {
|
|
1131
|
+
return {
|
|
1132
|
+
body: await readFile(filePath),
|
|
1133
|
+
contentType: CONTENT_TYPE_BY_EXTENSION[path.extname(filePath).toLowerCase()]
|
|
1134
|
+
};
|
|
1135
|
+
}
|
|
1136
|
+
/** Split an inline body: a string is sent as-is, anything else as JSON. */
|
|
1137
|
+
function inlineBody(value) {
|
|
1138
|
+
if (typeof value === "string") return {
|
|
1139
|
+
body: value,
|
|
1140
|
+
contentType: "text/plain; charset=utf-8"
|
|
1141
|
+
};
|
|
1142
|
+
return {
|
|
1143
|
+
json: value,
|
|
1144
|
+
contentType: void 0
|
|
1145
|
+
};
|
|
1146
|
+
}
|
|
1147
|
+
async function safeJson(body) {
|
|
1148
|
+
try {
|
|
1149
|
+
return await body.getJson();
|
|
1150
|
+
} catch {
|
|
1151
|
+
return;
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
/**
|
|
1155
|
+
* A `respond` rule: reply directly, the server is never contacted. The stub file
|
|
1156
|
+
* is re-read on every request, so editing it takes effect without a restart.
|
|
1157
|
+
*/
|
|
1158
|
+
function makeRespondHandler(compiled, ctx) {
|
|
1159
|
+
const respond = compiled.rule.respond;
|
|
1160
|
+
return async (request) => {
|
|
1161
|
+
const { params } = compiled.match(factsFromUrl(request.url, request.method));
|
|
1162
|
+
const relative = respond.file !== void 0 ? interpolate(respond.file, params) : void 0;
|
|
1163
|
+
const filePath = relative !== void 0 ? path.resolve(ctx.baseDir, relative) : void 0;
|
|
1164
|
+
ctx.reporter.action(request.id, "stub", compiled.label, relative ?? "inline");
|
|
1165
|
+
if (respond.delay !== void 0) await setTimeout(respond.delay);
|
|
1166
|
+
const headers = lowercaseKeys(respond.headers);
|
|
1167
|
+
const status = respond.status ?? 200;
|
|
1168
|
+
if (filePath !== void 0) {
|
|
1169
|
+
let stub;
|
|
1170
|
+
try {
|
|
1171
|
+
stub = await readStub(filePath);
|
|
1172
|
+
} catch (cause) {
|
|
1173
|
+
ctx.reporter.warn(`${compiled.label}: cannot read stub (${filePath}) - ${cause.message}`);
|
|
1174
|
+
return {
|
|
1175
|
+
statusCode: 502,
|
|
1176
|
+
json: {
|
|
1177
|
+
error: "jean-claude: stub not found",
|
|
1178
|
+
file: filePath
|
|
1179
|
+
}
|
|
1180
|
+
};
|
|
1181
|
+
}
|
|
1182
|
+
if (stub.contentType !== void 0 && headers["content-type"] === void 0) headers["content-type"] = stub.contentType;
|
|
1183
|
+
return {
|
|
1184
|
+
statusCode: status,
|
|
1185
|
+
headers,
|
|
1186
|
+
body: stub.body
|
|
1187
|
+
};
|
|
1188
|
+
}
|
|
1189
|
+
if (respond.body !== void 0) {
|
|
1190
|
+
const { body, json, contentType } = inlineBody(respond.body);
|
|
1191
|
+
if (contentType !== void 0 && headers["content-type"] === void 0) headers["content-type"] = contentType;
|
|
1192
|
+
return json !== void 0 ? {
|
|
1193
|
+
statusCode: status,
|
|
1194
|
+
headers,
|
|
1195
|
+
json
|
|
1196
|
+
} : {
|
|
1197
|
+
statusCode: status,
|
|
1198
|
+
headers,
|
|
1199
|
+
body
|
|
1200
|
+
};
|
|
1201
|
+
}
|
|
1202
|
+
return {
|
|
1203
|
+
statusCode: status,
|
|
1204
|
+
headers
|
|
1205
|
+
};
|
|
1206
|
+
};
|
|
1207
|
+
}
|
|
1208
|
+
/**
|
|
1209
|
+
* Tags the request for the log and, when the rule asks for it, rewrites the
|
|
1210
|
+
* URL / method / headers / body before it goes out to the server.
|
|
1211
|
+
*/
|
|
1212
|
+
function makeBeforeRequest(compiled, ctx) {
|
|
1213
|
+
const rewrite = compiled?.rule.request;
|
|
1214
|
+
const kind = rewrite !== void 0 ? "rewrite" : compiled?.rule.patch !== void 0 ? "patch" : "passthrough";
|
|
1215
|
+
return async (request) => {
|
|
1216
|
+
ctx.reporter.action(request.id, kind, compiled?.label);
|
|
1217
|
+
if (rewrite === void 0) return void 0;
|
|
1218
|
+
const { params } = compiled.match(factsFromUrl(request.url, request.method));
|
|
1219
|
+
const url = new URL(request.url);
|
|
1220
|
+
if (rewrite.host !== void 0) url.host = rewrite.host;
|
|
1221
|
+
if (rewrite.path !== void 0) url.pathname = interpolate(rewrite.path, params);
|
|
1222
|
+
if (rewrite.query !== void 0) for (const [key, value] of Object.entries(rewrite.query)) url.searchParams.set(key, value);
|
|
1223
|
+
let headers = {
|
|
1224
|
+
...request.headers,
|
|
1225
|
+
...lowercaseKeys(rewrite.headers)
|
|
1226
|
+
};
|
|
1227
|
+
for (const name of rewrite.removeHeaders ?? []) delete headers[name.toLowerCase()];
|
|
1228
|
+
if (rewrite.host !== void 0) headers.host = url.host;
|
|
1229
|
+
const method = rewrite.method?.toUpperCase();
|
|
1230
|
+
if (rewrite.body === void 0 && rewrite.merge === void 0) return {
|
|
1231
|
+
url: url.toString(),
|
|
1232
|
+
method,
|
|
1233
|
+
headers
|
|
1234
|
+
};
|
|
1235
|
+
headers = withoutBodyHeaders(headers);
|
|
1236
|
+
if (rewrite.merge !== void 0) {
|
|
1237
|
+
const current = await safeJson(request.body);
|
|
1238
|
+
return {
|
|
1239
|
+
url: url.toString(),
|
|
1240
|
+
method,
|
|
1241
|
+
headers,
|
|
1242
|
+
json: deepMerge(current, rewrite.merge)
|
|
1243
|
+
};
|
|
1244
|
+
}
|
|
1245
|
+
const { body, json } = inlineBody(rewrite.body);
|
|
1246
|
+
return {
|
|
1247
|
+
url: url.toString(),
|
|
1248
|
+
method,
|
|
1249
|
+
headers,
|
|
1250
|
+
...json !== void 0 ? { json } : { body }
|
|
1251
|
+
};
|
|
1252
|
+
};
|
|
1253
|
+
}
|
|
1254
|
+
/**
|
|
1255
|
+
* Records the real response (before any change) and then applies `patch`.
|
|
1256
|
+
*
|
|
1257
|
+
* Returns `undefined` when there is neither recording nor patching to do: mockttp
|
|
1258
|
+
* then avoids buffering the body and the passthrough stays streamed.
|
|
1259
|
+
*/
|
|
1260
|
+
function makeBeforeResponse(compiled, ctx) {
|
|
1261
|
+
const patch = compiled?.rule.patch;
|
|
1262
|
+
if (patch === void 0 && ctx.recorder === void 0) return void 0;
|
|
1263
|
+
return async (response, request) => {
|
|
1264
|
+
const decoded = await response.body.getDecodedBuffer() ?? Buffer.alloc(0);
|
|
1265
|
+
if (ctx.recorder !== void 0) try {
|
|
1266
|
+
const written = await ctx.recorder.record(request.url, request.method, decoded, headerValue(response.headers, "content-type"));
|
|
1267
|
+
ctx.reporter.recorded(request.id, written);
|
|
1268
|
+
} catch (cause) {
|
|
1269
|
+
ctx.reporter.warn(`could not record ${request.url} - ${cause.message}`);
|
|
1270
|
+
}
|
|
1271
|
+
if (patch === void 0) return void 0;
|
|
1272
|
+
if (patch.delay !== void 0) await setTimeout(patch.delay);
|
|
1273
|
+
const statusCode = patch.status ?? response.statusCode;
|
|
1274
|
+
const headers = withoutBodyHeaders(patch.replaceHeaders !== void 0 ? lowercaseKeys(patch.replaceHeaders) : {
|
|
1275
|
+
...response.headers,
|
|
1276
|
+
...lowercaseKeys(patch.headers)
|
|
1277
|
+
});
|
|
1278
|
+
if (patch.file !== void 0) {
|
|
1279
|
+
const filePath = path.resolve(ctx.baseDir, patch.file);
|
|
1280
|
+
try {
|
|
1281
|
+
const stub = await readStub(filePath);
|
|
1282
|
+
if (stub.contentType !== void 0) headers["content-type"] = stub.contentType;
|
|
1283
|
+
return {
|
|
1284
|
+
statusCode,
|
|
1285
|
+
headers,
|
|
1286
|
+
body: stub.body
|
|
1287
|
+
};
|
|
1288
|
+
} catch (cause) {
|
|
1289
|
+
ctx.reporter.warn(`${compiled.label}: cannot read stub (${filePath}) - ${cause.message}`);
|
|
1290
|
+
return {
|
|
1291
|
+
statusCode: 502,
|
|
1292
|
+
headers,
|
|
1293
|
+
json: {
|
|
1294
|
+
error: "jean-claude: stub not found",
|
|
1295
|
+
file: filePath
|
|
1296
|
+
}
|
|
1297
|
+
};
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
if (patch.body !== void 0) {
|
|
1301
|
+
const { body, json, contentType } = inlineBody(patch.body);
|
|
1302
|
+
if (contentType !== void 0) headers["content-type"] = contentType;
|
|
1303
|
+
return json !== void 0 ? {
|
|
1304
|
+
statusCode,
|
|
1305
|
+
headers,
|
|
1306
|
+
json
|
|
1307
|
+
} : {
|
|
1308
|
+
statusCode,
|
|
1309
|
+
headers,
|
|
1310
|
+
body
|
|
1311
|
+
};
|
|
1312
|
+
}
|
|
1313
|
+
if (patch.merge !== void 0 || patch.jsonPatch !== void 0) {
|
|
1314
|
+
const current = await safeJson(response.body);
|
|
1315
|
+
if (current === void 0) {
|
|
1316
|
+
ctx.reporter.warn(`${compiled.label}: response is not JSON, \`merge\`/\`jsonPatch\` skipped`);
|
|
1317
|
+
return {
|
|
1318
|
+
statusCode,
|
|
1319
|
+
headers,
|
|
1320
|
+
body: decoded
|
|
1321
|
+
};
|
|
1322
|
+
}
|
|
1323
|
+
return {
|
|
1324
|
+
statusCode,
|
|
1325
|
+
headers,
|
|
1326
|
+
json: patch.merge !== void 0 ? deepMerge(current, patch.merge) : applyJsonPatch(current, patch.jsonPatch)
|
|
1327
|
+
};
|
|
1328
|
+
}
|
|
1329
|
+
return {
|
|
1330
|
+
statusCode,
|
|
1331
|
+
headers,
|
|
1332
|
+
body: decoded
|
|
1333
|
+
};
|
|
1334
|
+
};
|
|
1335
|
+
}
|
|
1336
|
+
//#endregion
|
|
1337
|
+
//#region src/proxy/server.ts
|
|
1338
|
+
/**
|
|
1339
|
+
* Options shared by every passthrough: where to send relayed traffic, and which
|
|
1340
|
+
* extra authorities to trust on the way out.
|
|
1341
|
+
*/
|
|
1342
|
+
function buildConnectionOptions({ upstream, inheritedCa }) {
|
|
1343
|
+
return {
|
|
1344
|
+
...upstream !== void 0 ? { proxyConfig: {
|
|
1345
|
+
proxyUrl: upstream.proxyUrl,
|
|
1346
|
+
...upstream.noProxy !== void 0 ? { noProxy: upstream.noProxy } : {}
|
|
1347
|
+
} } : {},
|
|
1348
|
+
...inheritedCa !== void 0 ? { additionalTrustedCAs: [{ certPath: inheritedCa }] } : {}
|
|
1349
|
+
};
|
|
1350
|
+
}
|
|
1351
|
+
async function startProxy(options) {
|
|
1352
|
+
const { ca, reporter } = options;
|
|
1353
|
+
let loaded = options.loaded;
|
|
1354
|
+
const initialTlsPassthrough = loaded.config.tlsPassthrough ?? [];
|
|
1355
|
+
const proxy = getLocal({
|
|
1356
|
+
https: {
|
|
1357
|
+
keyPath: ca.keyPath,
|
|
1358
|
+
certPath: ca.certPath,
|
|
1359
|
+
tlsPassthrough: initialTlsPassthrough.map((hostname) => ({ hostname }))
|
|
1360
|
+
},
|
|
1361
|
+
http2: "fallback",
|
|
1362
|
+
recordTraffic: false
|
|
1363
|
+
});
|
|
1364
|
+
const connection = buildConnectionOptions(options);
|
|
1365
|
+
async function attachListeners() {
|
|
1366
|
+
await proxy.on("request", (request) => reporter.seen(request.id, request.method, request.url));
|
|
1367
|
+
await proxy.on("response", (response) => reporter.response(response.id, response.statusCode));
|
|
1368
|
+
await proxy.on("abort", (request) => reporter.aborted(request.id, request.error?.message));
|
|
1369
|
+
await proxy.on("tls-client-error", (failure) => reporter.tlsError(failure.tlsMetadata.sniHostname ?? failure.destination?.hostname));
|
|
1370
|
+
}
|
|
1371
|
+
async function registerRules() {
|
|
1372
|
+
const ctx = {
|
|
1373
|
+
baseDir: loaded.baseDir,
|
|
1374
|
+
reporter,
|
|
1375
|
+
recorder: options.recorder
|
|
1376
|
+
};
|
|
1377
|
+
for (const compiled of loaded.rules) {
|
|
1378
|
+
const builder = proxy.forAnyRequest().matching((request) => compiled.match(factsFromUrl(request.url, request.method)).matched);
|
|
1379
|
+
if (compiled.rule.respond !== void 0) await builder.thenCallback(makeRespondHandler(compiled, ctx));
|
|
1380
|
+
else await builder.thenPassThrough({
|
|
1381
|
+
...connection,
|
|
1382
|
+
beforeRequest: makeBeforeRequest(compiled, ctx),
|
|
1383
|
+
beforeResponse: makeBeforeResponse(compiled, ctx)
|
|
1384
|
+
});
|
|
1385
|
+
}
|
|
1386
|
+
await proxy.forUnmatchedRequest().thenPassThrough({
|
|
1387
|
+
...connection,
|
|
1388
|
+
beforeRequest: makeBeforeRequest(void 0, ctx),
|
|
1389
|
+
beforeResponse: makeBeforeResponse(void 0, ctx)
|
|
1390
|
+
});
|
|
1391
|
+
}
|
|
1392
|
+
await proxy.start(options.port ?? loaded.config.port);
|
|
1393
|
+
await attachListeners();
|
|
1394
|
+
await registerRules();
|
|
1395
|
+
return {
|
|
1396
|
+
url: `http://127.0.0.1:${proxy.port}`,
|
|
1397
|
+
port: proxy.port,
|
|
1398
|
+
reload: async (next) => {
|
|
1399
|
+
loaded = next;
|
|
1400
|
+
if ((next.config.tlsPassthrough ?? []).join(",") !== initialTlsPassthrough.join(",")) reporter.warn("`tlsPassthrough` changed - restart jean-claude for it to take effect.");
|
|
1401
|
+
proxy.reset();
|
|
1402
|
+
await attachListeners();
|
|
1403
|
+
await registerRules();
|
|
1404
|
+
},
|
|
1405
|
+
stop: () => proxy.stop()
|
|
1406
|
+
};
|
|
1407
|
+
}
|
|
1408
|
+
//#endregion
|
|
1409
|
+
//#region src/commands/shared.ts
|
|
1410
|
+
/**
|
|
1411
|
+
* Brings up everything `run` and `start` need: config, CA, upstream detection,
|
|
1412
|
+
* the proxy itself, and optional config hot-reload.
|
|
1413
|
+
*/
|
|
1414
|
+
async function openSession(options) {
|
|
1415
|
+
const home = options.home !== void 0 ? path.resolve(options.home) : jeanClaudeHome();
|
|
1416
|
+
const loaded = await loadConfig(options.config, { home });
|
|
1417
|
+
const reporter = new Reporter({
|
|
1418
|
+
verbose: options.verbose,
|
|
1419
|
+
quiet: options.quiet
|
|
1420
|
+
});
|
|
1421
|
+
const inheritedCa = inheritedExtraCaCerts();
|
|
1422
|
+
const upstream = detectUpstream(loaded.config);
|
|
1423
|
+
const ca = await ensureCa({
|
|
1424
|
+
dir: caDirIn(home),
|
|
1425
|
+
inheritedCa
|
|
1426
|
+
});
|
|
1427
|
+
const proxy = await startProxy({
|
|
1428
|
+
loaded,
|
|
1429
|
+
ca,
|
|
1430
|
+
upstream,
|
|
1431
|
+
inheritedCa,
|
|
1432
|
+
reporter,
|
|
1433
|
+
recorder: options.record !== void 0 ? new Recorder(path.resolve(options.record)) : void 0,
|
|
1434
|
+
port: options.port
|
|
1435
|
+
});
|
|
1436
|
+
const childEnvOptions = {
|
|
1437
|
+
proxyUrl: proxy.url,
|
|
1438
|
+
bundlePath: ca.bundlePath,
|
|
1439
|
+
noProxy: loaded.config.noProxy
|
|
1440
|
+
};
|
|
1441
|
+
const env = proxyEnvVars(childEnvOptions);
|
|
1442
|
+
const unset = proxyEnvUnset(childEnvOptions);
|
|
1443
|
+
let unwatch;
|
|
1444
|
+
if (options.watch && loaded.filePath !== void 0) {
|
|
1445
|
+
const configPath = loaded.filePath;
|
|
1446
|
+
unwatch = watchConfig(configPath, async () => {
|
|
1447
|
+
try {
|
|
1448
|
+
const next = await loadConfig(configPath);
|
|
1449
|
+
await proxy.reload(next);
|
|
1450
|
+
reporter.info(pc.dim(`config reloaded - ${next.rules.length} rule(s)`));
|
|
1451
|
+
} catch (error) {
|
|
1452
|
+
reporter.warn(`config reload failed, keeping the previous rules:\n${error.message}`);
|
|
1453
|
+
}
|
|
1454
|
+
});
|
|
1455
|
+
}
|
|
1456
|
+
return {
|
|
1457
|
+
loaded,
|
|
1458
|
+
home,
|
|
1459
|
+
ca,
|
|
1460
|
+
upstream,
|
|
1461
|
+
reporter,
|
|
1462
|
+
proxy,
|
|
1463
|
+
env,
|
|
1464
|
+
unset,
|
|
1465
|
+
stop: async () => {
|
|
1466
|
+
await unwatch?.();
|
|
1467
|
+
await proxy.stop();
|
|
1468
|
+
}
|
|
1469
|
+
};
|
|
1470
|
+
}
|
|
1471
|
+
function printBanner(session, extra = []) {
|
|
1472
|
+
const { loaded, ca, upstream, proxy, reporter } = session;
|
|
1473
|
+
reporter.banner([
|
|
1474
|
+
["proxy", proxy.url],
|
|
1475
|
+
["ca", ca.certPath + (ca.created ? pc.dim(" (just generated)") : "")],
|
|
1476
|
+
["bundle", ca.bundlePath],
|
|
1477
|
+
["config", `${loaded.filePath ?? "(none)"} ${pc.dim(`${loaded.rules.length} rule(s)`)}`],
|
|
1478
|
+
["upstream", upstream !== void 0 ? `${upstream.proxyUrl} ${pc.dim(`(from ${upstream.source})`)}` : pc.dim("direct")],
|
|
1479
|
+
...loaded.config.noProxy?.length ? [["bypassed", loaded.config.noProxy.join(", ")]] : [],
|
|
1480
|
+
...extra
|
|
1481
|
+
]);
|
|
1482
|
+
if (ca.inheritedCa !== void 0) reporter.info(pc.dim(`Corporate CA folded into the bundle: ${ca.inheritedCa}`));
|
|
1483
|
+
if (!nodeSupportsEnvProxy()) reporter.warn(`Node ${process.versions.node} predates NODE_USE_ENV_PROXY (needs >=22.21 or >=24.5). Node-based targets will ignore the proxy - upgrade Node, or pass NODE_OPTIONS=--use-env-proxy yourself.`);
|
|
1484
|
+
}
|
|
1485
|
+
//#endregion
|
|
1486
|
+
//#region src/commands/run.ts
|
|
1487
|
+
/**
|
|
1488
|
+
* Starts the proxy, then runs the target command with the proxy and CA
|
|
1489
|
+
* variables already in its environment. Exits with the child's exit code.
|
|
1490
|
+
*/
|
|
1491
|
+
async function runCommand(command, options) {
|
|
1492
|
+
const [file, ...args] = command;
|
|
1493
|
+
if (file === void 0) throw new Error("nothing to run: pass the target command after `--`, e.g. `jean-claude run -- npx my-tool`.");
|
|
1494
|
+
const session = await openSession(options);
|
|
1495
|
+
printBanner(session, [["command", command.join(" ")]]);
|
|
1496
|
+
const swallow = () => {};
|
|
1497
|
+
process.on("SIGINT", swallow);
|
|
1498
|
+
process.on("SIGTERM", swallow);
|
|
1499
|
+
try {
|
|
1500
|
+
return (await execa(file, args, {
|
|
1501
|
+
env: buildChildEnv(process.env, {
|
|
1502
|
+
proxyUrl: session.proxy.url,
|
|
1503
|
+
bundlePath: session.ca.bundlePath,
|
|
1504
|
+
noProxy: session.loaded.config.noProxy
|
|
1505
|
+
}),
|
|
1506
|
+
stdio: "inherit",
|
|
1507
|
+
reject: false
|
|
1508
|
+
})).exitCode ?? 0;
|
|
1509
|
+
} finally {
|
|
1510
|
+
process.off("SIGINT", swallow);
|
|
1511
|
+
process.off("SIGTERM", swallow);
|
|
1512
|
+
await session.stop();
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
//#endregion
|
|
1516
|
+
//#region src/commands/start.ts
|
|
1517
|
+
/** Runs the proxy in the foreground until interrupted, for targets we cannot spawn. */
|
|
1518
|
+
async function startCommand(options) {
|
|
1519
|
+
const session = await openSession(options);
|
|
1520
|
+
await writeSessionFile(session.home, {
|
|
1521
|
+
proxy: session.proxy.url,
|
|
1522
|
+
port: session.proxy.port,
|
|
1523
|
+
bundle: session.ca.bundlePath,
|
|
1524
|
+
config: session.loaded.filePath ?? null,
|
|
1525
|
+
noProxy: session.loaded.config.noProxy ?? null,
|
|
1526
|
+
pid: process.pid
|
|
1527
|
+
});
|
|
1528
|
+
try {
|
|
1529
|
+
if (options.json) console.log(JSON.stringify({
|
|
1530
|
+
proxy: session.proxy.url,
|
|
1531
|
+
port: session.proxy.port,
|
|
1532
|
+
ca: session.ca.certPath,
|
|
1533
|
+
bundle: session.ca.bundlePath,
|
|
1534
|
+
config: session.loaded.filePath,
|
|
1535
|
+
rules: session.loaded.rules.length,
|
|
1536
|
+
upstream: session.upstream?.proxyUrl ?? null,
|
|
1537
|
+
env: session.env,
|
|
1538
|
+
unset: session.unset
|
|
1539
|
+
}, null, 2));
|
|
1540
|
+
else if (options.export) console.log(formatShellExports(session.env, session.unset));
|
|
1541
|
+
else {
|
|
1542
|
+
printBanner(session);
|
|
1543
|
+
console.log(` ${pc.dim("In the shell that runs your tool:")}\n`);
|
|
1544
|
+
console.log(` ${pc.bold("eval \"$(jean-claude env)\"")}\n`);
|
|
1545
|
+
console.log(` ${pc.dim("Ctrl-C to stop.")}\n`);
|
|
1546
|
+
}
|
|
1547
|
+
await waitForInterrupt();
|
|
1548
|
+
} finally {
|
|
1549
|
+
await removeSessionFile(session.home);
|
|
1550
|
+
await session.stop();
|
|
1551
|
+
}
|
|
1552
|
+
return 0;
|
|
1553
|
+
}
|
|
1554
|
+
function waitForInterrupt() {
|
|
1555
|
+
return new Promise((resolve) => {
|
|
1556
|
+
const finish = () => {
|
|
1557
|
+
process.off("SIGINT", finish);
|
|
1558
|
+
process.off("SIGTERM", finish);
|
|
1559
|
+
resolve();
|
|
1560
|
+
};
|
|
1561
|
+
process.on("SIGINT", finish);
|
|
1562
|
+
process.on("SIGTERM", finish);
|
|
1563
|
+
});
|
|
1564
|
+
}
|
|
1565
|
+
//#endregion
|
|
1566
|
+
//#region src/cli.ts
|
|
1567
|
+
function parsePort(value) {
|
|
1568
|
+
const port = Number.parseInt(value, 10);
|
|
1569
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new InvalidArgumentError("expected a port between 1 and 65535.");
|
|
1570
|
+
return port;
|
|
1571
|
+
}
|
|
1572
|
+
/** Shown wherever `--home` is offered, so the default is never a mystery. */
|
|
1573
|
+
const HOME_DESCRIPTION = "jean-claude directory: config, stubs, CA, session (default: ~/.config/jean-claude)";
|
|
1574
|
+
function toSessionOptions(flags) {
|
|
1575
|
+
return {
|
|
1576
|
+
config: flags.config,
|
|
1577
|
+
port: flags.port,
|
|
1578
|
+
record: flags.record,
|
|
1579
|
+
home: flags.home,
|
|
1580
|
+
verbose: flags.verbose ?? false,
|
|
1581
|
+
quiet: flags.quiet ?? false,
|
|
1582
|
+
watch: flags.watch ?? true
|
|
1583
|
+
};
|
|
1584
|
+
}
|
|
1585
|
+
/** Flags common to `run` and `start`. */
|
|
1586
|
+
function withSessionFlags(command) {
|
|
1587
|
+
return command.option("-c, --config <path>", "path to the config file (default: nearest jean-claude.yaml, then the home one)").option("-p, --port <port>", "port to listen on (default: a free port)", parsePort).option("-r, --record <dir>", "write real responses to this directory, ready to reuse as stubs").option("--home <dir>", HOME_DESCRIPTION).option("-v, --verbose", "also log traffic that matches no rule").option("-q, --quiet", "suppress the per-request log").option("--no-watch", "do not reload the config when it changes");
|
|
1588
|
+
}
|
|
1589
|
+
const program = new Command();
|
|
1590
|
+
program.name("jean-claude").description("MITM HTTPS proxy that rewrites another tool's API traffic, driven by a YAML file.").version("0.1.0");
|
|
1591
|
+
withSessionFlags(program.command("run", { isDefault: true }).description("run a command with its HTTPS traffic intercepted").argument("<command...>", "the command to run, after `--`")).action(async (command, flags) => {
|
|
1592
|
+
process.exitCode = await runCommand(command, toSessionOptions(flags));
|
|
1593
|
+
});
|
|
1594
|
+
withSessionFlags(program.command("start").description("run the proxy alone and print the variables to export")).option("--json", "print the settings as JSON").option("--export", "print only the shell export block").action(async (flags) => {
|
|
1595
|
+
process.exitCode = await startCommand({
|
|
1596
|
+
...toSessionOptions(flags),
|
|
1597
|
+
json: flags.json ?? false,
|
|
1598
|
+
export: flags.export ?? false
|
|
1599
|
+
});
|
|
1600
|
+
});
|
|
1601
|
+
program.command("env").description("print the environment for an already running `start`, for `eval \"$(jean-claude env)\"`").option("--home <dir>", HOME_DESCRIPTION).option("-p, --port <port>", "target this port instead of discovering the running session", parsePort).option("--json", "print the variables as JSON").action(async (flags) => {
|
|
1602
|
+
process.exitCode = await envCommand({
|
|
1603
|
+
home: flags.home,
|
|
1604
|
+
port: flags.port,
|
|
1605
|
+
json: flags.json ?? false
|
|
1606
|
+
});
|
|
1607
|
+
});
|
|
1608
|
+
program.command("ca").description("show the certificate store, and how to trust it").option("--home <dir>", HOME_DESCRIPTION).option("--print", "write the CA certificate to stdout").option("--install", "show the commands to add the CA to the system trust store").action(async (flags) => {
|
|
1609
|
+
process.exitCode = await caCommand({
|
|
1610
|
+
home: flags.home,
|
|
1611
|
+
print: flags.print ?? false,
|
|
1612
|
+
install: flags.install ?? false
|
|
1613
|
+
});
|
|
1614
|
+
});
|
|
1615
|
+
program.command("check").description("validate the config and print the rules as resolved").option("-c, --config <path>", "path to the config file").option("--home <dir>", HOME_DESCRIPTION).action(async (flags) => {
|
|
1616
|
+
process.exitCode = await checkCommand(flags.config, flags.home);
|
|
1617
|
+
});
|
|
1618
|
+
program.command("init").description("set up the jean-claude directory: config, sample stub and CA").option("--home <dir>", HOME_DESCRIPTION).option("--claude-code", "start from the rule that freezes Claude Code's managed settings").action(async (flags) => {
|
|
1619
|
+
process.exitCode = await initCommand({
|
|
1620
|
+
home: flags.home,
|
|
1621
|
+
claudeCode: flags.claudeCode ?? false
|
|
1622
|
+
});
|
|
1623
|
+
});
|
|
1624
|
+
try {
|
|
1625
|
+
await program.parseAsync();
|
|
1626
|
+
} catch (error) {
|
|
1627
|
+
console.error(`\n ${pc.red("error")} ${error.message}\n`);
|
|
1628
|
+
process.exitCode = 1;
|
|
1629
|
+
}
|
|
1630
|
+
//#endregion
|
|
1631
|
+
export {};
|