@join-pantheon/cli 0.26.5 → 0.28.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.
@@ -2,7 +2,8 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync } from "node
2
2
  import { homedir } from "node:os";
3
3
  import { dirname, join } from "node:path";
4
4
  import { MAX_CONSECUTIVE_BLOCKS, isOurHookCommand } from "./notify.js";
5
- import { copilotHome } from "./agents.js";
5
+ import { copilotHome, kiroHome } from "./agents.js";
6
+ import { KIRO_AGENT_NAME, kiroAgentFile, kiroAgentDoc, readKiroAgent, writeKiroAgent, kiroDefaultIsPantheon } from "./kiro-files.js";
6
7
  function ours(cmd) {
7
8
  return isOurHookCommand(cmd);
8
9
  }
@@ -247,4 +248,48 @@ export function removeCopilotHooks(projectDir = process.cwd()) {
247
248
  }
248
249
  return out;
249
250
  }
251
+ export { KIRO_AGENT_NAME };
252
+ export function kiroAgentPath(scope, projectDir = process.cwd()) {
253
+ return kiroAgentFile(scope === "user" ? kiroHome() : join(projectDir, ".kiro"));
254
+ }
255
+ export function kiroHookState(scope, projectDir = process.cwd()) {
256
+ const file = kiroAgentPath(scope, projectDir);
257
+ const a = readKiroAgent(file);
258
+ return a?.ours
259
+ ? { agent: "kiro", label: "Kiro", installed: true, scope, file, events: a.events }
260
+ : { agent: "kiro", label: "Kiro", installed: false };
261
+ }
262
+ export function installKiroHooks(bin, scope, projectDir = process.cwd(), allowed = []) {
263
+ const file = kiroAgentPath(scope, projectDir);
264
+ const a = readKiroAgent(file);
265
+ if (a && !a.ours)
266
+ return [` skipped ${file} is an agent called "${KIRO_AGENT_NAME}" that pantheon did not write — left as it is`];
267
+ writeKiroAgent(file, kiroAgentDoc(bin, allowed));
268
+ return [
269
+ ` ${a ? "updated" : "installed"} ${file} (an agent: Kiro's default, plus pantheon's hooks)`,
270
+ ` runs ${bin} hook kiro-stop`,
271
+ ` ${bin} hook kiro-prompt`,
272
+ kiroDefaultIsPantheon()
273
+ ? ` ${""}(it is your default agent, so every kiro-cli chat uses it)`
274
+ : ` \x1b[33mKiro uses these only in the pantheon agent:\x1b[0m kiro-cli chat --agent pantheon`,
275
+ ...(kiroDefaultIsPantheon() ? [] : [` ${""}(or always: kiro-cli agent set-default pantheon — pantheon never changes your default)`]),
276
+ ];
277
+ }
278
+ export function removeKiroHooks(projectDir = process.cwd()) {
279
+ const out = [];
280
+ for (const scope of ["project", "user"]) {
281
+ const file = kiroAgentPath(scope, projectDir);
282
+ if (!kiroHookState(scope, projectDir).installed)
283
+ continue;
284
+ try {
285
+ rmSync(file, { force: true });
286
+ out.push(` removed ${file}`);
287
+ }
288
+ catch { }
289
+ }
290
+ if (out.length && kiroDefaultIsPantheon()) {
291
+ out.push(" Kiro's default was the pantheon agent; it now falls back to kiro_default and warns each session — kiro-cli agent set-default kiro_default");
292
+ }
293
+ return out;
294
+ }
250
295
  //# sourceMappingURL=hooks-install.js.map
package/dist/install.js CHANGED
@@ -4,11 +4,12 @@ import { homedir, platform } from "node:os";
4
4
  import { join, dirname, resolve } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { grantToolPermissions, claudeSettingsFile, cursorCliConfigFile } from "./grants.js";
7
- import { PANTHEON_TOOL_NAMES, WRITE_TOOL_NAMES } from "./tools.js";
7
+ import { PANTHEON_TOOL_NAMES, WRITE_TOOL_NAMES, toolsAllowed } from "./tools.js";
8
8
  import { load } from "./config.js";
9
9
  import { loadProfile } from "./profile.js";
10
10
  import { agentSpawn, onPathNamed, which } from "./which.js";
11
11
  import { FEEDBACK_ADDRESS } from "./feedback.js";
12
+ import { kiroDir, kiroAgentFile, refreshKiroAgentAllowed } from "./kiro-files.js";
12
13
  const HERE = dirname(fileURLToPath(import.meta.url));
13
14
  export function onPath() {
14
15
  return onPathNamed("pantheon");
@@ -40,7 +41,7 @@ function mergeMcpJson(file, entry, shape = {}) {
40
41
  const existed = existsSync(file);
41
42
  doc.mcpServers ??= {};
42
43
  const before = JSON.stringify(doc.mcpServers["pantheon"] ?? null);
43
- doc.mcpServers["pantheon"] = { ...shape, ...entry, env: {} };
44
+ doc.mcpServers["pantheon"] = { ...shape, ...entry, env: shape.env ?? {} };
44
45
  if (before === JSON.stringify(doc.mcpServers["pantheon"]))
45
46
  return "unchanged";
46
47
  mkdirSync(dirname(file), { recursive: true });
@@ -61,6 +62,12 @@ export function mergeVsCodeMcpJson(file, entry) {
61
62
  return existed ? "updated" : "created";
62
63
  }
63
64
  export const copilotMcpConfigFile = (home = homedir(), env = process.env) => join(env.COPILOT_HOME || join(home, ".copilot"), "mcp-config.json");
65
+ export { kiroDir };
66
+ export const kiroMcpConfigFile = (home = homedir(), env = process.env) => join(kiroDir(home, env), "settings", "mcp.json");
67
+ export const kiroEntryShape = (approval) => ({
68
+ autoApprove: toolsAllowed(approval),
69
+ env: { PANTHEON_AGENT: "kiro" },
70
+ });
64
71
  export function vsCodeUserDir(home = homedir(), os = platform(), env = process.env) {
65
72
  const dir = os === "darwin" ? join(home, "Library", "Application Support", "Code", "User")
66
73
  : os === "win32" ? join(env.APPDATA || join(home, "AppData", "Roaming"), "Code", "User")
@@ -131,6 +138,9 @@ export function install(projectDir, cfg, force, os = platform(), claudeLocal = c
131
138
  if (existsSync(p) || f === "AGENTS.md")
132
139
  out.push(` ${f.padEnd(25)} ${upsertBrief(p, cfg)} (the collaboration brief)`);
133
140
  }
141
+ if (existsSync(kiroDir()) || onPathNamed("kiro-cli")) {
142
+ out.push(` .kiro/settings/mcp.json ${"not used".padEnd(9)} Kiro reads ~/.kiro/settings/mcp.json, which pantheon join wrote — a repo copy would override each teammate's own (kiro)`);
143
+ }
134
144
  out.push("");
135
145
  out.push(" Copilot asks once to trust this folder before it reads the repo's MCP servers and hooks.");
136
146
  out.push(" Codex reads ~/.codex/config.toml, which `pantheon join` already wrote at user scope.");
@@ -251,6 +261,21 @@ function copilotUserScope(entry) {
251
261
  return { tool, where: file, status: `failed: ${e.message}` };
252
262
  }
253
263
  }
264
+ function kiroUserScope(entry, approval) {
265
+ const tool = "kiro";
266
+ const file = kiroMcpConfigFile();
267
+ if (!existsSync(kiroDir()) && !onPathNamed("kiro-cli"))
268
+ return { tool, where: "-", status: "skipped (kiro-cli not on PATH)" };
269
+ try {
270
+ const status = mergeMcpJson(file, entry, kiroEntryShape(approval));
271
+ const agent = refreshKiroAgentAllowed(kiroAgentFile(kiroDir()), toolsAllowed(approval));
272
+ const which = approval === "ask-before-sending" ? "reads run without asking; send, open_goal and claim prompt" : `the ${PANTHEON_TOOL_NAMES.length} pantheon tools run without asking`;
273
+ return { tool, where: file, status: `${status} — in the Kiro IDE, ${which}${agent === "absent" ? "; the CLI asks per call unless you use the pantheon agent (pantheon hook install)" : `; in the CLI's pantheon agent too (${agent})`}` };
274
+ }
275
+ catch (e) {
276
+ return { tool, where: file, status: `failed: ${e.message}` };
277
+ }
278
+ }
254
279
  function vsCodeUserScope(entry) {
255
280
  const tool = "vs code";
256
281
  const dir = vsCodeUserDir();
@@ -268,7 +293,7 @@ export const CODEX_APPROVAL_LINE = `default_tools_approval_mode = "approve"`;
268
293
  export function approvalSummary(approval) {
269
294
  return approval === "ask-before-sending"
270
295
  ? `your agent asks you before each send, goal or claim in your name; reading and waiting run without a prompt. Unattended runs keep their own per-run approvals — this governs the agent you sit in front of`
271
- : `pantheon's ${PANTHEON_TOOL_NAMES.length} tools run without a per-call prompt in Claude Code, Cursor and Codex — your agent coordinates for you without stopping to ask. Copilot has no allow-list file: it asks once per tool per folder and remembers your answer`;
296
+ : `pantheon's ${PANTHEON_TOOL_NAMES.length} tools run without a per-call prompt in Claude Code, Cursor, Codex, the Kiro IDE and Kiro's pantheon agent — your agent coordinates for you without stopping to ask. Copilot has no allow-list file: it asks once per tool per folder and remembers your answer; Kiro's default CLI agent asks per call`;
272
297
  }
273
298
  export function codexTomlBlock(entry, env = { PANTHEON_AGENT: "codex" }, approval = "approve") {
274
299
  const envToml = Object.entries(env)
@@ -466,7 +491,7 @@ export function installForUser(approval = chosenApproval()) {
466
491
  const entry = userEntry();
467
492
  return [
468
493
  claudeUserScope(entry), cursorUserScope(entry), codexUserScope(entry, approval),
469
- copilotUserScope(entry), vsCodeUserScope(entry),
494
+ copilotUserScope(entry), vsCodeUserScope(entry), kiroUserScope(entry, approval),
470
495
  ...grantToolPermissions(approval).map((g) => ({
471
496
  tool: g.tool,
472
497
  where: g.tool.startsWith("claude") ? claudeSettingsFile() : cursorCliConfigFile(),
@@ -0,0 +1,56 @@
1
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ export const kiroDir = (home = homedir(), env = process.env) => env.KIRO_HOME || join(home, ".kiro");
5
+ export const KIRO_AGENT_NAME = "pantheon";
6
+ export const kiroAgentFile = (kiroBase) => join(kiroBase, "agents", `${KIRO_AGENT_NAME}.json`);
7
+ export const isOurKiroHook = (cmd) => !!cmd && /\bhook\s+kiro-(?:prompt|stop)\b/.test(cmd);
8
+ export const kiroAllowed = (tools) => tools.map((t) => `@pantheon/${t}`);
9
+ export function kiroAgentDoc(bin, allowed) {
10
+ return {
11
+ name: KIRO_AGENT_NAME,
12
+ description: "Kiro's default agent, plus Pantheon: its tools run without asking, and a turn is held open while a teammate is waiting on you",
13
+ tools: ["*"],
14
+ allowedTools: kiroAllowed(allowed),
15
+ includeMcpJson: true,
16
+ hooks: {
17
+ userPromptSubmit: [{ command: `${bin} hook kiro-prompt >/dev/null 2>&1` }],
18
+ stop: [{ command: `${bin} hook kiro-stop` }],
19
+ },
20
+ };
21
+ }
22
+ export function readKiroAgent(file) {
23
+ if (!existsSync(file))
24
+ return null;
25
+ try {
26
+ const doc = JSON.parse(readFileSync(file, "utf8"));
27
+ const events = Object.entries(doc.hooks ?? {}).filter(([, es]) => (es ?? []).some((h) => isOurKiroHook(h.command))).map(([e]) => e);
28
+ return { doc, ours: events.length > 0, events };
29
+ }
30
+ catch {
31
+ return { doc: {}, ours: false, events: [] };
32
+ }
33
+ }
34
+ export function writeKiroAgent(file, doc) {
35
+ mkdirSync(dirname(file), { recursive: true });
36
+ writeFileSync(file, JSON.stringify(doc, null, 2) + "\n");
37
+ }
38
+ export function refreshKiroAgentAllowed(file, allowed) {
39
+ const a = readKiroAgent(file);
40
+ if (!a?.ours)
41
+ return "absent";
42
+ const want = kiroAllowed(allowed);
43
+ if (JSON.stringify(a.doc.allowedTools) === JSON.stringify(want))
44
+ return "unchanged";
45
+ writeKiroAgent(file, { ...a.doc, allowedTools: want });
46
+ return "updated";
47
+ }
48
+ export function kiroDefaultIsPantheon(base = kiroDir()) {
49
+ try {
50
+ return JSON.parse(readFileSync(join(base, "settings", "cli.json"), "utf8"))["chat.defaultAgent"] === KIRO_AGENT_NAME;
51
+ }
52
+ catch {
53
+ return false;
54
+ }
55
+ }
56
+ //# sourceMappingURL=kiro-files.js.map
package/dist/machine.js CHANGED
@@ -4,7 +4,7 @@ import { homedir, platform } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { CONFIG_DIR } from "./config.js";
6
6
  import { plistPath } from "./service.js";
7
- import { copilotMcpConfigFile, vsCodeUserDir } from "./install.js";
7
+ import { copilotMcpConfigFile, vsCodeUserDir, kiroMcpConfigFile, kiroDir } from "./install.js";
8
8
  const sh = (cmd) => {
9
9
  try {
10
10
  return execFileSync("sh", ["-lc", cmd], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || null;
@@ -120,6 +120,10 @@ export function wiring() {
120
120
  const f = join(vscodeDir, "mcp.json");
121
121
  out.push({ tool: "vs code", state: existsSync(f) && /"pantheon"/.test(readFileSync(f, "utf8")) ? "wired" : "not wired" });
122
122
  }
123
+ if (existsSync(kiroDir())) {
124
+ const f = kiroMcpConfigFile();
125
+ out.push({ tool: "kiro", state: existsSync(f) && /"pantheon"/.test(readFileSync(f, "utf8")) ? "wired" : "not wired" });
126
+ }
123
127
  return out;
124
128
  }
125
129
  export function logFileFor(kind, proc) {
package/dist/measure.js CHANGED
@@ -3,7 +3,7 @@ import { existsSync } from "node:fs";
3
3
  import { resolve, dirname, join } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { claudeGrantedEffective, cursorGrantedEffective, claudeAllow, cursorAllow, isServerWide } from "./grants.js";
6
- import { codexApprovalState, copilotMcpConfigFile } from "./install.js";
6
+ import { codexApprovalState, copilotMcpConfigFile, kiroMcpConfigFile } from "./install.js";
7
7
  import { existsSync as fileExists, readFileSync } from "node:fs";
8
8
  import { dirname as dirOf } from "node:path";
9
9
  import { PANTHEON_TOOL_NAMES, WRITE_TOOL_NAMES } from "./tools.js";
@@ -161,13 +161,41 @@ export function toolsReport(registered, seen, approval, opts = {}) {
161
161
  }
162
162
  const copilot = seen.copilot === undefined || seen.copilot === null ? null
163
163
  : seen.copilot ? "Copilot wired (it asks once per tool per folder and remembers)" : "Copilot has no pantheon entry";
164
- const detail = `${head}; ${claude.text}, ${cursor.text}, ${codex}${copilot ? `, ${copilot}` : ""}`;
164
+ let kiro = null;
165
+ if (seen.kiro === "absent")
166
+ kiro = "Kiro has no pantheon entry";
167
+ else if (Array.isArray(seen.kiro)) {
168
+ const want = [...(approval === "ask-before-sending" ? PANTHEON_TOOL_NAMES.filter((t) => !WRITE_TOOL_NAMES.includes(t)) : PANTHEON_TOOL_NAMES)].sort();
169
+ const have = [...new Set(seen.kiro)].filter((t) => t.startsWith("pantheon_")).sort();
170
+ const extra = have.filter((t) => !want.includes(t)), missing = want.filter((t) => !have.includes(t));
171
+ if (!extra.length && !missing.length)
172
+ kiro = `Kiro IDE allows ${have.length}`;
173
+ else {
174
+ kiro = `Kiro IDE allows ${have.length}${missing.length ? `, missing ${missing.map((t) => t.replace(/^pantheon_/, "")).join(", ")}` : ""}${extra.length ? `, and allows ${extra.map((t) => t.replace(/^pantheon_/, "")).join(", ")} you chose to be asked about` : ""}`;
175
+ problems.push("Kiro's autoApprove does not match");
176
+ }
177
+ }
178
+ const detail = `${head}; ${claude.text}, ${cursor.text}, ${codex}${copilot ? `, ${copilot}` : ""}${kiro ? `, ${kiro}` : ""}`;
165
179
  if (!problems.length)
166
180
  return { level: "pass", detail };
167
181
  return { level: "warn", detail, fix: `${problems.join("; ")} — pantheon install --user rewrites every grant in your own settings to match this build and your approval choice; a rule in this repo's .claude/settings*.json or .cursor/cli.json is yours to edit by hand` };
168
182
  }
169
183
  export function grantsSeen(projectDir = null) {
170
- return { claude: claudeGrantedEffective(projectDir), cursor: cursorGrantedEffective(projectDir), codex: codexApprovalState(), copilot: copilotWired() };
184
+ return { claude: claudeGrantedEffective(projectDir), cursor: cursorGrantedEffective(projectDir), codex: codexApprovalState(), copilot: copilotWired(), kiro: kiroApproved() };
185
+ }
186
+ export function kiroApproved(file = kiroMcpConfigFile()) {
187
+ if (!fileExists(dirOf(dirOf(file))))
188
+ return null;
189
+ try {
190
+ const doc = JSON.parse(readFileSync(file, "utf8"));
191
+ const entry = doc.mcpServers?.["pantheon"];
192
+ if (!entry)
193
+ return "absent";
194
+ return Array.isArray(entry.autoApprove) ? entry.autoApprove.filter((t) => typeof t === "string") : [];
195
+ }
196
+ catch {
197
+ return "absent";
198
+ }
171
199
  }
172
200
  export function copilotWired(file = copilotMcpConfigFile()) {
173
201
  if (!fileExists(dirOf(file)))
@@ -0,0 +1,394 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import http from "node:http";
4
+ import https from "node:https";
5
+ import net from "node:net";
6
+ import tls from "node:tls";
7
+ import { platform } from "node:os";
8
+ import { join } from "node:path";
9
+ const LOOPBACK = ["localhost", "127.0.0.1", "::1", "[::1]"];
10
+ const NONE = { https: null, http: null, noProxy: LOOPBACK, source: "none", fallbackOnly: false };
11
+ const pick = (env, ...names) => {
12
+ for (const n of names) {
13
+ const v = env[n]?.trim();
14
+ if (v)
15
+ return v;
16
+ }
17
+ return undefined;
18
+ };
19
+ export function normalizeProxy(raw) {
20
+ const v = raw.trim();
21
+ if (!v)
22
+ return null;
23
+ try {
24
+ const u = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(v) ? v : `http://${v}`);
25
+ if (u.protocol !== "http:" && u.protocol !== "https:")
26
+ return null;
27
+ if (!u.hostname)
28
+ return null;
29
+ return u.toString().replace(/\/$/, "");
30
+ }
31
+ catch {
32
+ return null;
33
+ }
34
+ }
35
+ export function redactProxy(url) {
36
+ if (!url)
37
+ return "direct";
38
+ try {
39
+ const u = new URL(url);
40
+ if (u.username || u.password) {
41
+ u.username = u.username ? "***" : "";
42
+ u.password = "";
43
+ }
44
+ return u.toString().replace(/\/$/, "");
45
+ }
46
+ catch {
47
+ return "(unparseable)";
48
+ }
49
+ }
50
+ export function splitNoProxy(raw) {
51
+ return [...new Set([...(raw ?? "").split(/[,\s]+/).map((s) => s.trim().toLowerCase()).filter(Boolean), ...LOOPBACK])];
52
+ }
53
+ export function bypassesProxy(host, port, noProxy) {
54
+ const h = host.toLowerCase().replace(/^\[|\]$/g, "");
55
+ for (const raw of noProxy) {
56
+ if (raw === "*")
57
+ return true;
58
+ let entry = raw.replace(/^\[|\]$/g, "");
59
+ let entryPort;
60
+ const m = /^(.*):(\d+)$/.exec(entry);
61
+ if (m && !entry.includes("::")) {
62
+ entry = m[1];
63
+ entryPort = m[2];
64
+ }
65
+ if (entryPort && entryPort !== port)
66
+ continue;
67
+ if (entry.includes("/") && net.isIP(h)) {
68
+ if (inCidr(h, entry))
69
+ return true;
70
+ continue;
71
+ }
72
+ const bare = entry.replace(/^\*?\./, "");
73
+ if (h === bare || h.endsWith(`.${bare}`))
74
+ return true;
75
+ }
76
+ return false;
77
+ }
78
+ function inCidr(ip, cidr) {
79
+ const [base, bitsRaw] = cidr.split("/");
80
+ const bits = Number(bitsRaw);
81
+ if (!base || net.isIPv4(ip) !== net.isIPv4(base) || !net.isIPv4(ip) || !(bits >= 0 && bits <= 32))
82
+ return false;
83
+ const n = (s) => s.split(".").reduce((a, o) => (a << 8) + Number(o), 0) >>> 0;
84
+ const mask = bits === 0 ? 0 : (~0 << (32 - bits)) >>> 0;
85
+ return (n(ip) & mask) === (n(base) & mask);
86
+ }
87
+ export function parseScutil(out) {
88
+ const val = (k) => new RegExp(`^\\s*${k}\\s*:\\s*(.+?)\\s*$`, "m").exec(out)?.[1];
89
+ const on = (k) => val(k) === "1";
90
+ const hp = (host, port) => (host ? normalizeProxy(`${host}${port ? `:${port}` : ""}`) : null);
91
+ const exceptions = [];
92
+ const block = /ExceptionsList\s*:\s*<array>\s*\{([\s\S]*?)\}/.exec(out)?.[1] ?? "";
93
+ for (const line of block.split("\n")) {
94
+ const m = /:\s*(\S+)\s*$/.exec(line);
95
+ if (m)
96
+ exceptions.push(m[1]);
97
+ }
98
+ const pac = on("ProxyAutoConfigEnable") ? val("ProxyAutoConfigURLString") : undefined;
99
+ return {
100
+ https: on("HTTPSEnable") ? hp(val("HTTPSProxy"), val("HTTPSPort")) : null,
101
+ http: on("HTTPEnable") ? hp(val("HTTPProxy"), val("HTTPPort")) : null,
102
+ noProxy: exceptions,
103
+ ...(pac ? { pac } : {}),
104
+ };
105
+ }
106
+ export function parseWindowsReg(out) {
107
+ const val = (k) => new RegExp(`^\\s*${k}\\s+REG_\\w+\\s+(.*?)\\s*$`, "mi").exec(out)?.[1];
108
+ const pac = val("AutoConfigURL") || undefined;
109
+ const enabled = /^0x0*1$/i.test(val("ProxyEnable") ?? "");
110
+ const server = enabled ? val("ProxyServer") ?? "" : "";
111
+ let httpsP = null, httpP = null;
112
+ if (server.includes("=")) {
113
+ for (const part of server.split(";")) {
114
+ const [k, v] = part.split("=");
115
+ if (k?.trim().toLowerCase() === "https" && v)
116
+ httpsP = normalizeProxy(v);
117
+ if (k?.trim().toLowerCase() === "http" && v)
118
+ httpP = normalizeProxy(v);
119
+ }
120
+ httpsP ??= httpP;
121
+ }
122
+ else if (server) {
123
+ httpsP = httpP = normalizeProxy(server);
124
+ }
125
+ const override = (val("ProxyOverride") ?? "").split(";").map((s) => s.trim()).filter((s) => s && s !== "<local>");
126
+ return { https: httpsP, http: httpP, noProxy: override, ...(pac ? { pac } : {}) };
127
+ }
128
+ const run = (cmd, args) => {
129
+ try {
130
+ return execFileSync(cmd, args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 3000 });
131
+ }
132
+ catch {
133
+ return null;
134
+ }
135
+ };
136
+ function savedFromConfig(env) {
137
+ try {
138
+ const home = env.PANTHEON_HOME ?? join(env.HOME ?? env.USERPROFILE ?? "", ".pantheon");
139
+ const f = join(home, "config.json");
140
+ if (!existsSync(f))
141
+ return null;
142
+ return JSON.parse(readFileSync(f, "utf8")).network ?? null;
143
+ }
144
+ catch {
145
+ return null;
146
+ }
147
+ }
148
+ export const defaultNetworkDeps = () => ({
149
+ env: process.env,
150
+ os: platform(),
151
+ saved: () => savedFromConfig(process.env),
152
+ scutil: () => run("/usr/sbin/scutil", ["--proxy"]),
153
+ winReg: () => run("reg", ["query", "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings"]),
154
+ });
155
+ export function resolveProxyPlan(d = defaultNetworkDeps()) {
156
+ const noProxy = splitNoProxy(pick(d.env, "no_proxy", "NO_PROXY"));
157
+ const forced = d.env.PANTHEON_PROXY?.trim();
158
+ if (forced) {
159
+ if (/^(none|direct|off|false|0)$/i.test(forced))
160
+ return { ...NONE, noProxy };
161
+ const p = normalizeProxy(forced);
162
+ if (p)
163
+ return { https: p, http: p, noProxy, source: "PANTHEON_PROXY", fallbackOnly: false };
164
+ }
165
+ const envHttps = pick(d.env, "https_proxy", "HTTPS_PROXY", "all_proxy", "ALL_PROXY");
166
+ const envHttp = pick(d.env, "http_proxy", "HTTP_PROXY", "all_proxy", "ALL_PROXY");
167
+ if (envHttps || envHttp) {
168
+ const hs = envHttps ? normalizeProxy(envHttps) : null;
169
+ const h = envHttp ? normalizeProxy(envHttp) : null;
170
+ if (hs || h)
171
+ return { https: hs, http: h, noProxy, source: "env", fallbackOnly: false };
172
+ }
173
+ const saved = d.saved();
174
+ if (saved && saved.proxy === "")
175
+ return { ...NONE, noProxy };
176
+ const sp = saved?.proxy ? normalizeProxy(saved.proxy) : null;
177
+ if (sp)
178
+ return { https: sp, http: sp, noProxy: splitNoProxy([saved?.no_proxy, ...noProxy].filter(Boolean).join(",")), source: "config", fallbackOnly: false };
179
+ const sys = d.os === "darwin" ? (() => { const o = d.scutil(); return o ? parseScutil(o) : null; })()
180
+ : d.os === "win32" ? (() => { const o = d.winReg(); return o ? parseWindowsReg(o) : null; })()
181
+ : null;
182
+ if (sys && (sys.https || sys.http)) {
183
+ return { https: sys.https ?? sys.http, http: sys.http ?? sys.https, noProxy: splitNoProxy([...sys.noProxy, ...noProxy].join(",")),
184
+ source: d.os === "darwin" ? "macos" : "windows", fallbackOnly: true, ...(sys.pac ? { pac: sys.pac } : {}) };
185
+ }
186
+ return { ...NONE, noProxy, ...(sys?.pac ? { pac: sys.pac } : {}) };
187
+ }
188
+ export function proxyFor(target, plan) {
189
+ let u;
190
+ try {
191
+ u = new URL(target);
192
+ }
193
+ catch {
194
+ return null;
195
+ }
196
+ const secure = u.protocol === "https:" || u.protocol === "wss:";
197
+ const port = u.port || (secure ? "443" : "80");
198
+ const p = secure ? plan.https : plan.http;
199
+ if (!p || bypassesProxy(u.hostname, port, plan.noProxy))
200
+ return null;
201
+ return p;
202
+ }
203
+ export function tunnelAgent(proxyUrl, secure) {
204
+ const proxy = new URL(proxyUrl);
205
+ const auth = proxy.username || proxy.password
206
+ ? `Basic ${Buffer.from(`${decodeURIComponent(proxy.username)}:${decodeURIComponent(proxy.password)}`).toString("base64")}`
207
+ : undefined;
208
+ const Base = secure ? https.Agent : http.Agent;
209
+ class Tunnel extends Base {
210
+ createConnection(options, callback) {
211
+ const cb = (err, s) => callback?.(err, s);
212
+ const host = String(options.hostname ?? options.host ?? "");
213
+ const port = Number(options.port ?? (secure ? 443 : 80));
214
+ const req = (proxy.protocol === "https:" ? https : http).request({
215
+ host: proxy.hostname, port: Number(proxy.port || (proxy.protocol === "https:" ? 443 : 80)),
216
+ method: "CONNECT", path: `${host.includes(":") ? `[${host}]` : host}:${port}`,
217
+ headers: { host: `${host}:${port}`, ...(auth ? { "proxy-authorization": auth } : {}) },
218
+ timeout: 10_000,
219
+ });
220
+ let done = false;
221
+ const fail = (e) => { if (!done) {
222
+ done = true;
223
+ cb(e);
224
+ } };
225
+ req.once("connect", (res, socket) => {
226
+ if (res.statusCode !== 200) {
227
+ socket.destroy();
228
+ const err = new Error(`the proxy ${redactProxy(proxyUrl)} answered ${res.statusCode} ${res.statusMessage ?? ""} to a tunnel request for ${host}:${port}`.trim());
229
+ err.code = res.statusCode === 407 ? "PROXY_AUTH_REQUIRED" : "PROXY_REFUSED";
230
+ return fail(err);
231
+ }
232
+ if (!secure) {
233
+ done = true;
234
+ return cb(null, socket);
235
+ }
236
+ const t = tls.connect({ ...options, socket, servername: net.isIP(host) ? undefined : host });
237
+ t.once("secureConnect", () => { if (!done) {
238
+ done = true;
239
+ cb(null, t);
240
+ } });
241
+ t.once("error", fail);
242
+ });
243
+ req.once("timeout", () => { req.destroy(Object.assign(new Error(`the proxy ${redactProxy(proxyUrl)} did not answer within 10s`), { code: "ETIMEDOUT" })); });
244
+ req.once("error", fail);
245
+ req.end();
246
+ return undefined;
247
+ }
248
+ }
249
+ return new Tunnel({ keepAlive: false });
250
+ }
251
+ let installed = null;
252
+ let fallbackUsed = false;
253
+ export function wsOptions(address) {
254
+ const plan = currentPlan();
255
+ if (plan.fallbackOnly && !fallbackUsed)
256
+ return {};
257
+ const p = proxyFor(address, plan);
258
+ if (!p)
259
+ return {};
260
+ return { agent: tunnelAgent(p, /^wss:/.test(address)) };
261
+ }
262
+ export function currentPlan() {
263
+ if (!installed)
264
+ installed = { plan: resolveProxyPlan(), active: null };
265
+ return installed.plan;
266
+ }
267
+ export async function installNetwork() {
268
+ const plan = currentPlan();
269
+ if (plan.https || plan.http) {
270
+ if (!plan.fallbackOnly)
271
+ await useProxyForFetch(plan);
272
+ else
273
+ wrapFetchWithFallback(plan);
274
+ }
275
+ return plan;
276
+ }
277
+ async function useProxyForFetch(plan) {
278
+ if (installed?.active)
279
+ return;
280
+ const { EnvHttpProxyAgent, setGlobalDispatcher } = await import("undici");
281
+ setGlobalDispatcher(new EnvHttpProxyAgent({
282
+ ...(plan.https ? { httpsProxy: plan.https } : {}),
283
+ ...(plan.http ? { httpProxy: plan.http } : {}),
284
+ noProxy: plan.noProxy.join(","),
285
+ }));
286
+ if (installed)
287
+ installed.active = plan.https ?? plan.http;
288
+ }
289
+ const BLOCKED = /ENOTFOUND|EAI_AGAIN|ECONNREFUSED|ETIMEDOUT|EHOSTUNREACH|ENETUNREACH|EPROTO|wrong version number|UND_ERR_CONNECT_TIMEOUT|UND_ERR_SOCKET|handshake has timed out|socket hang up/i;
290
+ export const isBlockedPath = (e) => {
291
+ const err = e;
292
+ return BLOCKED.test(`${err?.code ?? ""} ${err?.message ?? ""} ${err?.cause?.code ?? ""} ${err?.cause?.message ?? ""}`);
293
+ };
294
+ function wrapFetchWithFallback(plan) {
295
+ const g = globalThis;
296
+ if (g.__pantheonFetch)
297
+ return;
298
+ g.__pantheonFetch = true;
299
+ const direct = g.fetch.bind(globalThis);
300
+ g.fetch = (async (input, init) => {
301
+ if (fallbackUsed)
302
+ return direct(input, init);
303
+ try {
304
+ return await direct(input, init);
305
+ }
306
+ catch (e) {
307
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
308
+ if (!isBlockedPath(e) || !proxyFor(url, plan))
309
+ throw e;
310
+ await useProxyForFetch(plan);
311
+ fallbackUsed = true;
312
+ return direct(input, init);
313
+ }
314
+ });
315
+ }
316
+ export function trySystemFallback(address, e) {
317
+ const plan = currentPlan();
318
+ if (!plan.fallbackOnly || fallbackUsed || !isBlockedPath(e) || !proxyFor(address, plan))
319
+ return false;
320
+ fallbackUsed = true;
321
+ void useProxyForFetch(plan);
322
+ return true;
323
+ }
324
+ export const usedSystemFallback = () => fallbackUsed;
325
+ export function proxyEnvFor(plan = currentPlan()) {
326
+ if (!(plan.https || plan.http) || plan.source === "env")
327
+ return {};
328
+ if (plan.fallbackOnly && !fallbackUsed)
329
+ return {};
330
+ return {
331
+ ...(plan.https ? { HTTPS_PROXY: plan.https, https_proxy: plan.https } : {}),
332
+ ...(plan.http ? { HTTP_PROXY: plan.http, http_proxy: plan.http } : {}),
333
+ NO_PROXY: plan.noProxy.join(","), no_proxy: plan.noProxy.join(","),
334
+ };
335
+ }
336
+ export function resetNetworkForTests(plan) {
337
+ installed = plan ? { plan, active: null } : null;
338
+ fallbackUsed = false;
339
+ const g = globalThis;
340
+ delete g.__pantheonFetch;
341
+ }
342
+ export function explainNetError(e, plan = currentPlan()) {
343
+ const err = e;
344
+ const text = `${err?.code ?? ""} ${err?.message ?? ""} ${err?.cause?.code ?? ""} ${err?.cause?.message ?? ""}`;
345
+ const proxy = plan.https ?? plan.http;
346
+ const shown = proxy ? redactProxy(proxy) : "";
347
+ const orProxy = "Behind a company proxy? pantheon network --proxy http://host:port";
348
+ if (/PROXY_AUTH_REQUIRED|\b407\b/.test(text)) {
349
+ return { why: `the proxy ${shown} asked for a login`, fix: "put it in the proxy address: http://user:password@host:port", share: "proxy requires authentication" };
350
+ }
351
+ if (/PROXY_REFUSED/.test(text)) {
352
+ return { why: `the proxy ${shown} would not open a connection to the relay`, fix: "ask whoever runs it to allow HTTPS to relay.joinpantheon.network", share: "proxy refused the tunnel" };
353
+ }
354
+ if (/wrong version number|EPROTO|packet length too long|unknown protocol/i.test(text)) {
355
+ return proxy
356
+ ? { why: `the proxy ${shown} answered in plain text where a secure connection was expected`, fix: "check its address: company proxies are usually http://host:port, even for secure traffic", share: "TLS answered in plain text (via proxy)" }
357
+ : { why: "this network answered the relay's secure connection in plain text, which is what a network that requires a proxy does", fix: "set the proxy and run it again: export HTTPS_PROXY=http://host:port (or save it: pantheon network --proxy http://host:port)", share: "TLS answered in plain text (direct) — likely a required proxy" };
358
+ }
359
+ if (/SELF_SIGNED_CERT_IN_CHAIN|UNABLE_TO_GET_ISSUER_CERT_LOCALLY|UNABLE_TO_VERIFY_LEAF_SIGNATURE|self[- ]signed certificate|unable to get local issuer/i.test(text)) {
360
+ return { why: "the relay's certificate was swapped for one this machine does not trust, which happens on networks that inspect secure traffic", fix: "point Node at your company's root certificate: export NODE_EXTRA_CA_CERTS=/path/to/root.pem (or on Node 22.15+: NODE_USE_SYSTEM_CA=1)", share: "certificate not trusted — TLS inspection" };
361
+ }
362
+ if (/CERT_HAS_EXPIRED|certificate has expired|ERR_TLS_CERT_ALTNAME_INVALID|Hostname\/IP does not match/i.test(text)) {
363
+ return { why: "the relay's certificate looks expired or belongs to another name", fix: "check this machine's date and time, then try another network", share: "certificate expired or for another name" };
364
+ }
365
+ if (/ENOTFOUND|EAI_AGAIN|getaddrinfo/i.test(text)) {
366
+ return proxy
367
+ ? { why: `the proxy ${shown} could not find the relay's address`, fix: "check the relay address with pantheon doctor", share: "name did not resolve" }
368
+ : { why: "the relay's address did not resolve — this machine may be offline", fix: `check the connection and try again. ${orProxy}`, share: "name did not resolve" };
369
+ }
370
+ if (/ECONNREFUSED/i.test(text)) {
371
+ return proxy
372
+ ? { why: `nothing is accepting connections at the proxy ${shown}`, fix: "check the proxy address and port, or clear it: pantheon network --clear", share: "proxy refused the connection" }
373
+ : { why: "nothing is accepting connections at the relay's address — it may be down or restarting", fix: "try again in a minute; pantheon doctor shows more", share: "connection refused" };
374
+ }
375
+ if (/ETIMEDOUT|UND_ERR_CONNECT_TIMEOUT|handshake has timed out|timed out|EHOSTUNREACH|ENETUNREACH/i.test(text)) {
376
+ return proxy
377
+ ? { why: `the proxy ${shown} did not answer in time`, fix: "check it is reachable from this machine, or clear it: pantheon network --clear", share: "connection timed out" }
378
+ : { why: "the relay did not answer in time", fix: `check this machine is online and try again. ${orProxy}`, share: "connection timed out" };
379
+ }
380
+ if (/ECONNRESET|socket hang up/i.test(text)) {
381
+ return proxy
382
+ ? { why: `the connection through the proxy ${shown} was cut`, fix: "the proxy may not allow WebSockets; ask for relay.joinpantheon.network to be allowed", share: "connection reset" }
383
+ : { why: "the connection dropped before the relay answered", fix: "try again", share: "connection reset" };
384
+ }
385
+ return null;
386
+ }
387
+ export function describeNetError(e, plan) {
388
+ const raw = e?.message ?? String(e);
389
+ const x = explainNetError(e, plan);
390
+ const first = (raw.split("\n")[0] ?? "").slice(0, 120);
391
+ const fix = x ? x.fix.charAt(0).toUpperCase() + x.fix.slice(1) : "";
392
+ return x ? `${x.why}. ${fix}. (${first})`.replace(/\.\. \(/, ". (").replace(/\?\. \(/, "? (") : raw;
393
+ }
394
+ //# sourceMappingURL=network.js.map