Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ Philosophy: "Create architecture that is performant and easy to test"
- Follow single responsibility principle
- Prefer public helper modules to lots of private methods
- Use dependency injection for external services (REST client, etc.)
- Do not eagerly validate network/proxy/environment configuration while constructing command dependencies when the command has local-only or no-network paths. Defer validation until the first network operation and add regression tests for malformed env values on local paths.
- For MCP/agent-facing tools, avoid coupled optional flags and default-true booleans. Design schemas for real agent calls, including empty strings, empty arrays, and explicit `false` values.
- For GraphQL/API-backed tools, treat minimal data fetching as part of the tool contract. Before adding or changing selected fields, compare the query against every consumer (text, verbose, JSON, MCP, CLI, and internal callers), use conditional fields or separate queries for mode-specific data, and add tests that assert the wire variables/selections for compact and detailed modes.

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -313,7 +313,7 @@ This repository contains the GitHits CLI and reusable MCP package:

Requirements:

- Node.js `^20.12.0 || >=22.13.0`
- Node.js `^20.18.1 || >=22.13.0`
- Bun

Common commands:
Expand Down
5 changes: 4 additions & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 10 additions & 0 deletions docs/implementation/cli-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,16 @@ githits doctor --json

Prints redacted diagnostics for comparing GitHits behavior across terminals or agents. The report includes CLI/runtime identity, selected environment variables, service URL sources, config file status, active and legacy auth storage locations, token/client/metadata presence and timestamps, and recommendations. Secret-bearing values such as tokens, client secrets, API tokens, and proxy credentials are never printed; presence is reported as `set` / `present` only. JSON output uses `schemaVersion: 1` for support tooling.

### Proxy Support

CLI-originated HTTP traffic uses `src/services/proxy-fetch.ts`. This includes OAuth discovery, client registration, token exchange/refresh, REST API calls, code/package service calls, local MCP tool calls started through `githits mcp start`, and npm update checks. The fetch factory supports `HTTP_PROXY`, `HTTPS_PROXY`, and `NO_PROXY` plus lowercase aliases; lowercase values win when both cases are set, matching undici's env proxy precedence.

Native Node env proxy support is used only when the user explicitly opted in and the running Node version is known to support that opt-in. `NODE_USE_ENV_PROXY=1` is treated as native on Node `22.21.0+` and `24.0.0+`; `--use-env-proxy` / `NODE_OPTIONS=--use-env-proxy` is treated as native on Node `22.21.0+` and `24.5.0+`. Older or unknown versions use the fallback so the Node `20.18.1` floor remains supported.

Fallback proxy support uses the installed `undici` runtime dependency and per-request `ProxyAgent` dispatchers rather than mutating global fetch or the global dispatcher. Proxy URL validation and runtime proxy failures are sanitized before surfacing to CLI/MCP callers: credentials, path, query, and fragments are never printed. `doctor` remains presence/status-only for proxy environment variables.

`bun run smoke:proxy-node` builds the proxy fetch module and runs a Node process against local target/proxy servers. It verifies that fallback mode really sends HTTP traffic through a local proxy, that `NO_PROXY` bypasses it, and that proxy URL redaction stays intact.

### `githits pkg info`

```
Expand Down
4 changes: 4 additions & 0 deletions docs/implementation/update-check.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,10 @@ The service accepts injected dependencies for the current version, fetcher,
clock, and file-system service. Tests should mock those dependencies rather than
patch global state.

The CLI injects the proxy-aware fetcher from `src/services/proxy-fetch.ts`, so
the direct npm registry calls honor `HTTP_PROXY`, `HTTPS_PROXY`, and `NO_PROXY`
without requiring package-manager configuration.

## Backend Compatibility Signals

All backend requests already include `x-githits-client-version` and a
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
"inspector": "npx @modelcontextprotocol/inspector bun run dev mcp",
"smoke:cli": "bun run scripts/cli-smoke.ts",
"smoke:mcp": "bun run scripts/mcp-smoke.ts",
"smoke:proxy-node": "bun run scripts/run-proxy-node-smoke.ts",
"validate:packages": "bun run scripts/validate-public-packages.ts",
"validate:packages:mcp-publish": "bun run scripts/validate-public-packages.ts --mcp-publish-dry-run",
"sync:claude-skills": "bun run scripts/sync-claude-skill-assets.ts",
Expand Down Expand Up @@ -79,7 +80,7 @@
"url": "https://github.com/githits-com/githits-cli/issues"
},
"engines": {
"node": "^20.12.0 || >=22.13.0"
"node": "^20.18.1 || >=22.13.0"
},
"publishConfig": {
"access": "public"
Expand All @@ -96,6 +97,7 @@
"open": "^11.0.0",
"semver": "7.8.5",
"smol-toml": "^1.6.1",
"undici": "7.28.0",
"yaml": "^2.9.0",
"zod": "^4.4.3"
},
Expand Down
4 changes: 2 additions & 2 deletions packages/mcp/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@githits/mcp",
"version": "0.6.0",
"version": "0.6.1",
"description": "Reusable MCP server APIs and tools for GitHits",
"type": "module",
"files": [
Expand Down Expand Up @@ -52,7 +52,7 @@
},
"homepage": "https://githits.com",
"engines": {
"node": "^20.12.0 || >=22.13.0"
"node": "^20.18.1 || >=22.13.0"
},
"publishConfig": {
"access": "public"
Expand Down
178 changes: 178 additions & 0 deletions scripts/proxy-node-smoke.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import { createServer } from "node:http";
import { createServer as createHttpsServer } from "node:https";
import { connect } from "node:net";
import { pathToFileURL } from "node:url";

const modulePath = process.env.GITHITS_PROXY_FETCH_MODULE;
if (!modulePath) {
throw new Error("GITHITS_PROXY_FETCH_MODULE is required");
}

const { createCliFetch, redactProxyUrl } = await import(
pathToFileURL(modulePath).href
);

async function main() {
const servers = [];
try {
const proxyRequests = [];
const connectRequests = [];
const secure = await listen(
createHttpsServer({ key: TEST_KEY, cert: TEST_CERT }, (_req, res) => {
res.writeHead(200, { "Content-Type": "text/plain" });
res.end("secure");
}),
);
servers.push(secure.server);

const proxy = await listen(
createServer((req, res) => {
proxyRequests.push(req.url ?? "");
res.writeHead(200, { "Content-Type": "text/plain" });
res.end("proxied");
}),
);
proxy.server.on("connect", (req, clientSocket, head) => {
connectRequests.push(req.url ?? "");
const upstream = connect(secure.port, "127.0.0.1", () => {
clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
if (head.length > 0) {
upstream.write(head);
}
upstream.pipe(clientSocket);
clientSocket.pipe(upstream);
});
upstream.on("error", () => clientSocket.destroy());
clientSocket.on("error", () => upstream.destroy());
});
servers.push(proxy.server);

const direct = await listen(
createServer((_req, res) => {
res.writeHead(200, { "Content-Type": "text/plain" });
res.end("direct");
}),
);
servers.push(direct.server);

const fetchFn = createCliFetch({
env: {
HTTP_PROXY: `http://user:pass@127.0.0.1:${proxy.port}`,
HTTPS_PROXY: `http://user:pass@127.0.0.1:${proxy.port}`,
NO_PROXY: `127.0.0.1:${direct.port}`,
NODE_USE_ENV_PROXY: "1",
},
execArgv: [],
nodeOptions: "",
nodeVersion: "20.18.1",
});

const proxiedResponse = await fetchFn("http://example.test/proxy-check");
assertEqual(await proxiedResponse.text(), "proxied", "proxy response body");
assertEqual(
proxyRequests[0],
"http://example.test/proxy-check",
"proxy receives absolute-form HTTP request",
);

const secureResponse = await fetchFn("https://example.test/secure-check");
assertEqual(
await secureResponse.text(),
"secure",
"HTTPS proxy response body",
);
assertEqual(
connectRequests[0],
"example.test:443",
"HTTPS proxy receives CONNECT request",
);

const directResponse = await fetchFn(
`http://127.0.0.1:${direct.port}/direct`,
);
assertEqual(await directResponse.text(), "direct", "NO_PROXY response body");
assertEqual(proxyRequests.length, 1, "NO_PROXY bypasses proxy");

const redacted = redactProxyUrl(
"http://user:pass@proxy.example:8080/p?q=1#x",
);
assertEqual(redacted, "http://proxy.example:8080/", "proxy URL redaction");

process.stdout.write("proxy-node-smoke passed\n");
} finally {
await Promise.all(servers.map((server) => closeServer(server)));
}
}

const TEST_KEY = `-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDT35OVlcqLp9Wl
OsdI3auhRvcZhRNeEMXZW4dCb++l6aieDIPpOEL88sQ6UzM2rQ9knrhJpwKDKeli
80S3h34zpt8ifOm4LqOjd5QaQgjLyl5lj7ewz6mjN50QSixN0/GOY113o4kn68/B
fyu3qnjMNtBoCIFSXXX+XsPVaxoaTxHtc/PIEjewisNetszvSWvVAHlv8I3TVbe4
2lljvpDgavBN2/NX2uJi+SUKrC3Nju5w4JdpGrqI/0XeHHbxSffiVGUb0mblsx4B
XSZBZZGZllF1HZH5Omf8fjo4hHyFjl3A2jVdsbmeAS4sZyAfyNP8SfwQjInMylb7
KcITOWxPAgMBAAECggEAPhc5Yw8Ayqim3cM5/8qmr57ib2ImaNy1fptqKjgvnQm6
1oaIaeKJDyP+CbG0QoO5DR3OmBcPj2zK7qqoKrjUbUKsBalhvQ49+nvitUdA2Kg3
vb++b1yMND7qEooKLcy876ODErqkSUS8H9Kq9ypIOGCf9rz3WTH2kFMpRPQcNDUL
UVRFfhAhzU5JSy5xeeTEMB0NI0gl+an95oD0bXyVzhB+8sU09bjU/yXoIuxH9enm
YXlW2jygDRK5DkPnoxZksCHLDFeGxU9MATg7NWC2StnzGPNWL/LcEzLldqJXAbzG
WWNFU7hcXPvBe+R7cUsSV+Yvl5CwZWge+5YiVMLZoQKBgQDw0+HzBVelQ95uGUw9
sv6nyjXr0C85wFKzBb16Q4GYsnlo+HtwcEa0SiN7jz6nkEW4lSvyCcOr8Pm/HFqu
+mbMv9S6nuxZkWHnSU8/wk7hWZXd/bzfAe2170+eSjwYha63L4rWbMXsI//R4c9n
h7MkhKGULfaekza/xiVkKd/K6QKBgQDhOLZ162oXnfaYYJWoQ2FDq6hjIE+qy9xz
FdEz65MPBFo6pbJNft4e5usNMy2c0QBt4DAff/oRoww+GYexo13MwdsWnL9oyHCn
LwAqZoK9NjcFdkdpbNlRYzjj8POD6JPA0uvYhD0+JLlFKBjjuvJr2VOxQI1xJ7Vj
Yx7/5v0KdwKBgDkkLRJ6jAc8iURaYEqrc9zgD9c5+FqdlYHAtOqTpeZTQpdzjeZp
3XzdsnmYzWb4xnI7gsfVJUZg0QFVevbVlxqx0YnON4oxAqfcLx+TvR+fH/4iPHQ1
gu+OLrgCKSwwW/o/H5QtDvEuwX5NM+b+vbTGe4grN778cxshqrGPdfgxAoGBAN7e
9UgphtoKGh1d7psM2nJRqxc0wUF97RABtf0QEH2azAMfNxuTARFJZ66vR2LYO/l/
EYAKb5cGZzYIo4v44vidmUV+Jbf2Kex3CU3sFVJSFQ6VpkNAUKlGa+S86u1MuPHm
hzbCXaxiQOibrk2lEQIClNxhydYA+nF4hBOuLBcvAoGAHc7ADCJuaJ8R730iD4TJ
daXCXcZmMPZiq/Zu55/IvcTJCghiERse8dXOjgJRDEzc47V96icW8emtV85xQt+z
gN46s3p7T7ny/nfdQACk0GpYTZy0i8ZyPV8LQeJyADVty5EFBWEA/r/PFuN5mreZ
0v2DFEPG2UGRBMe3Cnh5o2g=
-----END PRIVATE KEY-----`;

const TEST_CERT = `-----BEGIN CERTIFICATE-----
MIIDKDCCAhCgAwIBAgIUIgvE5JR9tgOP8zt+2Mc6zXIWRxYwDQYJKoZIhvcNAQEL
BQAwFzEVMBMGA1UEAwwMZXhhbXBsZS50ZXN0MB4XDTI2MDcwODA4MzAwNloXDTM2
MDcwNTA4MzAwNlowFzEVMBMGA1UEAwwMZXhhbXBsZS50ZXN0MIIBIjANBgkqhkiG
9w0BAQEFAAOCAQ8AMIIBCgKCAQEA09+TlZXKi6fVpTrHSN2roUb3GYUTXhDF2VuH
Qm/vpemongyD6ThC/PLEOlMzNq0PZJ64SacCgynpYvNEt4d+M6bfInzpuC6jo3eU
GkIIy8peZY+3sM+pozedEEosTdPxjmNdd6OJJ+vPwX8rt6p4zDbQaAiBUl11/l7D
1WsaGk8R7XPzyBI3sIrDXrbM70lr1QB5b/CN01W3uNpZY76Q4GrwTdvzV9riYvkl
CqwtzY7ucOCXaRq6iP9F3hx28Un34lRlG9Jm5bMeAV0mQWWRmZZRdR2R+Tpn/H46
OIR8hY5dwNo1XbG5ngEuLGcgH8jT/En8EIyJzMpW+ynCEzlsTwIDAQABo2wwajAd
BgNVHQ4EFgQUoKQ1P5wZFvKZ5p7kfdMODOgZ0BUwHwYDVR0jBBgwFoAUoKQ1P5wZ
FvKZ5p7kfdMODOgZ0BUwDwYDVR0TAQH/BAUwAwEB/zAXBgNVHREEEDAOggxleGFt
cGxlLnRlc3QwDQYJKoZIhvcNAQELBQADggEBADSU7zh+LwQS8Zr3/x7J8mk+lZ9S
s/X0kXjMGHhwyiHDg/mq2EkBvTmdRd6mu74RPrdAnkIQVxtOY8e4Te8aXniLXaAj
Kh3jViROU2H2PUsLIZPQdH6KE/M8EGRzkryPhdUS/KJ06s5Q3/5ebuQTkjAcyRGz
RQI6Bt1T1SdAULNCeSoqacU4CkODojkoMBEr2jAHrMS5JqVHlGsHrrrdxt10MLJX
wGtq+XtLvvZApLZ9OJNRX0dTCkNlFTCYrW+mqlm1SZ0HbzzYgcIYxLkKhNt0PN7I
sjHd8Ixk4fi6F8q+VezypajRKe+ZiXze7k+z9P8nBoHNI6YCXYRN3sbca0A=
-----END CERTIFICATE-----`;

await main();

async function listen(server) {
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
return { server, port: server.address().port };
}

async function closeServer(server) {
if (!server.listening) {
return;
}
await new Promise((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
}

function assertEqual(actual, expected, label) {
if (actual !== expected) {
throw new Error(
`${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`,
);
}
}
79 changes: 79 additions & 0 deletions scripts/run-proxy-node-smoke.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { spawnSync } from "node:child_process";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";

async function main(): Promise<void> {
const outdir = await mkdtemp(join(tmpdir(), "githits-proxy-smoke-"));
try {
await writeFile(join(outdir, "package.json"), '{"type":"module"}\n');
const build = await Bun.build({
entrypoints: ["src/services/proxy-fetch.ts"],
outdir,
target: "node",
format: "esm",
});

if (!build.success) {
for (const log of build.logs) {
console.error(log);
}
process.exit(1);
}

const modulePath = join(outdir, "proxy-fetch.js");
const caPath = join(outdir, "proxy-smoke-ca.pem");
await writeFile(caPath, TEST_CERT);
const result = spawnSync("node", ["scripts/proxy-node-smoke.mjs"], {
stdio: "inherit",
env: {
...childSmokeEnv(process.env),
GITHITS_PROXY_FETCH_MODULE: modulePath,
NODE_EXTRA_CA_CERTS: caPath,
},
});

process.exit(result.status ?? 1);
} finally {
await rm(outdir, { recursive: true, force: true });
}
}

function childSmokeEnv(
env: NodeJS.ProcessEnv,
): Record<string, string | undefined> {
const {
HTTP_PROXY,
HTTPS_PROXY,
NO_PROXY,
http_proxy,
https_proxy,
no_proxy,
NODE_OPTIONS,
NODE_USE_ENV_PROXY,
...rest
} = env;
return rest;
}

const TEST_CERT = `-----BEGIN CERTIFICATE-----
MIIDKDCCAhCgAwIBAgIUIgvE5JR9tgOP8zt+2Mc6zXIWRxYwDQYJKoZIhvcNAQEL
BQAwFzEVMBMGA1UEAwwMZXhhbXBsZS50ZXN0MB4XDTI2MDcwODA4MzAwNloXDTM2
MDcwNTA4MzAwNlowFzEVMBMGA1UEAwwMZXhhbXBsZS50ZXN0MIIBIjANBgkqhkiG
9w0BAQEFAAOCAQ8AMIIBCgKCAQEA09+TlZXKi6fVpTrHSN2roUb3GYUTXhDF2VuH
Qm/vpemongyD6ThC/PLEOlMzNq0PZJ64SacCgynpYvNEt4d+M6bfInzpuC6jo3eU
GkIIy8peZY+3sM+pozedEEosTdPxjmNdd6OJJ+vPwX8rt6p4zDbQaAiBUl11/l7D
1WsaGk8R7XPzyBI3sIrDXrbM70lr1QB5b/CN01W3uNpZY76Q4GrwTdvzV9riYvkl
CqwtzY7ucOCXaRq6iP9F3hx28Un34lRlG9Jm5bMeAV0mQWWRmZZRdR2R+Tpn/H46
OIR8hY5dwNo1XbG5ngEuLGcgH8jT/En8EIyJzMpW+ynCEzlsTwIDAQABo2wwajAd
BgNVHQ4EFgQUoKQ1P5wZFvKZ5p7kfdMODOgZ0BUwHwYDVR0jBBgwFoAUoKQ1P5wZ
FvKZ5p7kfdMODOgZ0BUwDwYDVR0TAQH/BAUwAwEB/zAXBgNVHREEEDAOggxleGFt
cGxlLnRlc3QwDQYJKoZIhvcNAQELBQADggEBADSU7zh+LwQS8Zr3/x7J8mk+lZ9S
s/X0kXjMGHhwyiHDg/mq2EkBvTmdRd6mu74RPrdAnkIQVxtOY8e4Te8aXniLXaAj
Kh3jViROU2H2PUsLIZPQdH6KE/M8EGRzkryPhdUS/KJ06s5Q3/5ebuQTkjAcyRGz
RQI6Bt1T1SdAULNCeSoqacU4CkODojkoMBEr2jAHrMS5JqVHlGsHrrrdxt10MLJX
wGtq+XtLvvZApLZ9OJNRX0dTCkNlFTCYrW+mqlm1SZ0HbzzYgcIYxLkKhNt0PN7I
sjHd8Ixk4fi6F8q+VezypajRKe+ZiXze7k+z9P8nBoHNI6YCXYRN3sbca0A=
-----END CERTIFICATE-----`;

await main();
2 changes: 2 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
loadAutoLoginAuthSessionMetadata,
} from "./container.js";
import { FileSystemServiceImpl } from "./services/filesystem-service.js";
import { createLazyCliFetch } from "./services/proxy-fetch.js";
import { NpmRegistryUpdateCheckService } from "./services/update-check-service.js";
import { createRootCliPreAction } from "./shared/root-cli-pre-action.js";

Expand All @@ -57,6 +58,7 @@ const createUpdateCheckService = () =>
new NpmRegistryUpdateCheckService({
currentVersion: version,
fileSystemService: new FileSystemServiceImpl(),
fetcher: createLazyCliFetch(),
});

await enforceCachedRequiredUpdateForInvocation({
Expand Down
Loading