diff --git a/.simple-release.js b/.simple-release.js index 23003237..2aae4012 100644 --- a/.simple-release.js +++ b/.simple-release.js @@ -1,3 +1,4 @@ +import { execSync } from "node:child_process"; import { readFileSync, writeFileSync } from "node:fs"; import { NpmProject } from "@simple-release/npm"; @@ -21,6 +22,7 @@ class ArchgateProject extends NpmProject { if (changed) { writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n"); + execSync("bun install", { stdio: "inherit" }); } } diff --git a/bun.lock b/bun.lock index b6b8d83f..b050c71b 100644 --- a/bun.lock +++ b/bun.lock @@ -19,9 +19,9 @@ "zod": "4.3.6", }, "optionalDependencies": { - "archgate-darwin-arm64": "0.11.2", - "archgate-linux-x64": "0.11.2", - "archgate-win32-x64": "0.11.2", + "archgate-darwin-arm64": "0.12.0", + "archgate-linux-x64": "0.12.0", + "archgate-win32-x64": "0.12.0", }, "peerDependencies": { "typescript": "^5", @@ -185,10 +185,6 @@ "ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="], - "archgate-darwin-arm64": ["archgate-darwin-arm64@0.11.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-6zLgBves0TL4MbK7QgGUEw9yLVCTuDTVix3KiKpf8z2NQBK545+NIfp9WNgi66b3KAZRIyTbQYGglJ2D49uqrA=="], - - "archgate-linux-x64": ["archgate-linux-x64@0.11.2", "", { "os": "linux", "cpu": "x64" }, "sha512-MjdvBaN5W5buokND94o3L+kzO4+djq3boMDxoo1sUzBF9jD+rAQ55A4nMU8TPItGvM5d3+mB5Etlw+jJxBzlfg=="], - "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], "array-ify": ["array-ify@1.0.0", "", {}, "sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng=="], diff --git a/src/commands/login.ts b/src/commands/login.ts index 17056ffd..506a2b37 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -12,6 +12,7 @@ import { clearCredentials, } from "../helpers/auth"; import { logError, logInfo } from "../helpers/log"; +import { isTlsError, tlsHintMessage } from "../helpers/tls"; export function registerLoginCommand(program: Command) { const login = program @@ -32,6 +33,10 @@ export function registerLoginCommand(program: Command) { await runDeviceFlow(); } catch (err) { + if (isTlsError(err)) { + logError(tlsHintMessage()); + process.exit(1); + } logError(err instanceof Error ? err.message : String(err)); process.exit(1); } @@ -67,6 +72,10 @@ export function registerLoginCommand(program: Command) { await clearCredentials(); await runDeviceFlow(); } catch (err) { + if (isTlsError(err)) { + logError(tlsHintMessage()); + process.exit(1); + } logError(err instanceof Error ? err.message : String(err)); process.exit(1); } diff --git a/src/helpers/tls.ts b/src/helpers/tls.ts new file mode 100644 index 00000000..7ea9db5c --- /dev/null +++ b/src/helpers/tls.ts @@ -0,0 +1,40 @@ +/** + * tls.ts — Detect TLS/SSL interception errors common in corporate environments + * and provide actionable guidance to the user. + */ + +const TLS_ERROR_PATTERNS = [ + "self signed certificate", + "unable to get local issuer certificate", + "certificate has expired", + "unable to verify the first certificate", + "CERT_HAS_EXPIRED", + "DEPTH_ZERO_SELF_SIGNED_CERT", + "SELF_SIGNED_CERT_IN_CHAIN", + "UNABLE_TO_GET_ISSUER_CERT_LOCALLY", +]; + +/** + * Returns true when the error looks like a TLS certificate verification + * failure — typically caused by a corporate proxy performing SSL inspection. + */ +export function isTlsError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return TLS_ERROR_PATTERNS.some((pattern) => message.includes(pattern)); +} + +/** + * Human-readable hint explaining the TLS failure and how to fix it. + */ +export function tlsHintMessage(): string { + return [ + "TLS certificate verification failed.", + "This typically happens behind a corporate proxy that performs SSL inspection.", + "", + "To fix this, set the NODE_EXTRA_CA_CERTS environment variable to your corporate CA certificate:", + "", + ' export NODE_EXTRA_CA_CERTS="/path/to/corporate-ca.pem"', + "", + "Then retry the command.", + ].join("\n"); +} diff --git a/tests/helpers/tls.test.ts b/tests/helpers/tls.test.ts new file mode 100644 index 00000000..9ebdf326 --- /dev/null +++ b/tests/helpers/tls.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, test } from "bun:test"; + +import { isTlsError, tlsHintMessage } from "../../src/helpers/tls"; + +describe("isTlsError", () => { + test("detects 'self signed certificate in certificate chain'", () => { + const err = new Error("self signed certificate in certificate chain"); + expect(isTlsError(err)).toBe(true); + }); + + test("detects 'self signed certificate' (without chain suffix)", () => { + expect(isTlsError(new Error("self signed certificate"))).toBe(true); + }); + + test("detects SELF_SIGNED_CERT_IN_CHAIN code", () => { + expect(isTlsError(new Error("SELF_SIGNED_CERT_IN_CHAIN"))).toBe(true); + }); + + test("detects 'unable to verify the first certificate'", () => { + expect( + isTlsError(new Error("unable to verify the first certificate")) + ).toBe(true); + }); + + test("detects UNABLE_TO_GET_ISSUER_CERT_LOCALLY code", () => { + expect(isTlsError(new Error("UNABLE_TO_GET_ISSUER_CERT_LOCALLY"))).toBe( + true + ); + }); + + test("returns false for unrelated errors", () => { + expect(isTlsError(new Error("fetch failed"))).toBe(false); + expect(isTlsError(new Error("ECONNREFUSED"))).toBe(false); + }); + + test("handles non-Error values", () => { + expect(isTlsError("self signed certificate")).toBe(true); + expect(isTlsError(42)).toBe(false); + }); +}); + +describe("tlsHintMessage", () => { + test("mentions NODE_EXTRA_CA_CERTS", () => { + expect(tlsHintMessage()).toContain("NODE_EXTRA_CA_CERTS"); + }); + + test("mentions corporate proxy", () => { + expect(tlsHintMessage()).toContain("corporate proxy"); + }); +});