@builder.io/ai-utils 0.79.2 → 0.79.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@builder.io/ai-utils",
3
- "version": "0.79.2",
3
+ "version": "0.79.3",
4
4
  "description": "Builder.io AI utils",
5
5
  "files": [
6
6
  "src"
@@ -4,5 +4,11 @@ export interface DnsCheckOptions {
4
4
  source: Source;
5
5
  testId: TestId;
6
6
  timeout?: number;
7
+ /**
8
+ * Resolve against these DNS servers (each "ip" or "ip:port") instead of the
9
+ * system resolver — e.g. a VPC proxy's dnsmasq at "10.64.0.10:53" to test
10
+ * customer-internal name resolution.
11
+ */
12
+ servers?: string[];
7
13
  }
8
14
  export declare function dnsCheck(options: DnsCheckOptions): Promise<CheckResult>;
@@ -1,10 +1,23 @@
1
1
  import dns from "node:dns";
2
2
  import { mapNodeErrorToConnectivityCode } from "../error-codes.js";
3
- const { resolve4, resolve6 } = dns.promises;
4
3
  const DEFAULT_TIMEOUT_MS = 10000;
5
4
  export async function dnsCheck(options) {
6
- const { hostname, source, testId, timeout = DEFAULT_TIMEOUT_MS } = options;
5
+ const { hostname, source, testId, timeout = DEFAULT_TIMEOUT_MS, servers, } = options;
7
6
  const startTime = Date.now();
7
+ // Use a dedicated resolver when custom servers are provided; otherwise fall
8
+ // back to the process-wide resolver (system DNS). The dedicated resolver is
9
+ // cancelled on the way out (both paths) so a query left in flight by the
10
+ // timeout race doesn't keep its c-ares channel/handle alive (~25s) when the
11
+ // custom DNS server is unreachable.
12
+ let resolve4 = dns.promises.resolve4;
13
+ let resolve6 = dns.promises.resolve6;
14
+ let resolver;
15
+ if (servers && servers.length > 0) {
16
+ resolver = new dns.promises.Resolver();
17
+ resolver.setServers(servers);
18
+ resolve4 = resolver.resolve4.bind(resolver);
19
+ resolve6 = resolver.resolve6.bind(resolver);
20
+ }
8
21
  let timeoutId;
9
22
  const timeoutPromise = new Promise((_, reject) => {
10
23
  timeoutId = setTimeout(() => {
@@ -34,6 +47,7 @@ export async function dnsCheck(options) {
34
47
  }
35
48
  }
36
49
  clearTimeout(timeoutId);
50
+ resolver === null || resolver === void 0 ? void 0 : resolver.cancel();
37
51
  const durationMs = Date.now() - startTime;
38
52
  if (addresses.length === 0) {
39
53
  return {
@@ -63,6 +77,7 @@ export async function dnsCheck(options) {
63
77
  }
64
78
  catch (error) {
65
79
  clearTimeout(timeoutId);
80
+ resolver === null || resolver === void 0 ? void 0 : resolver.cancel();
66
81
  const durationMs = Date.now() - startTime;
67
82
  const err = error;
68
83
  if (err.message === "DNS resolution timed out") {
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,54 @@
1
+ import { describe, it, expect, vi, afterEach } from "vitest";
2
+ import dns from "node:dns";
3
+ import { dnsCheck } from "./dns-check.js";
4
+ describe("dnsCheck", () => {
5
+ afterEach(() => {
6
+ vi.restoreAllMocks();
7
+ });
8
+ it("uses the system resolver and does NOT construct a custom Resolver", async () => {
9
+ const resolve4 = vi
10
+ .spyOn(dns.promises, "resolve4")
11
+ .mockResolvedValue(["1.2.3.4"]);
12
+ const setServers = vi.spyOn(dns.promises.Resolver.prototype, "setServers");
13
+ const result = await dnsCheck({
14
+ hostname: "git.example.com",
15
+ source: "cloud",
16
+ testId: "git-host:dns",
17
+ });
18
+ expect(result.passed).toBe(true);
19
+ expect(resolve4).toHaveBeenCalledWith("git.example.com");
20
+ expect(setServers).not.toHaveBeenCalled();
21
+ });
22
+ it("resolves against the provided servers via a dedicated Resolver", async () => {
23
+ var _a;
24
+ const setServers = vi.spyOn(dns.promises.Resolver.prototype, "setServers");
25
+ const resolve4 = vi
26
+ .spyOn(dns.promises.Resolver.prototype, "resolve4")
27
+ .mockResolvedValue(["10.20.30.40"]);
28
+ const result = await dnsCheck({
29
+ hostname: "git.internal.customer",
30
+ source: "vpc",
31
+ testId: "git-host:dns",
32
+ servers: ["10.64.0.10:53"],
33
+ });
34
+ expect(result.passed).toBe(true);
35
+ expect(setServers).toHaveBeenCalledWith(["10.64.0.10:53"]);
36
+ expect(resolve4).toHaveBeenCalledWith("git.internal.customer");
37
+ expect((_a = result.metadata) === null || _a === void 0 ? void 0 : _a.addresses).toEqual(["10.20.30.40"]);
38
+ });
39
+ it("ignores an empty servers array (falls back to the system resolver)", async () => {
40
+ const resolve4 = vi
41
+ .spyOn(dns.promises, "resolve4")
42
+ .mockResolvedValue(["1.2.3.4"]);
43
+ const setServers = vi.spyOn(dns.promises.Resolver.prototype, "setServers");
44
+ const result = await dnsCheck({
45
+ hostname: "git.example.com",
46
+ source: "vpc",
47
+ testId: "git-host:dns",
48
+ servers: [],
49
+ });
50
+ expect(result.passed).toBe(true);
51
+ expect(resolve4).toHaveBeenCalled();
52
+ expect(setServers).not.toHaveBeenCalled();
53
+ });
54
+ });
@@ -1,3 +1,4 @@
1
+ import net from "net";
1
2
  import type { CheckResult, Source, TestId } from "../types.js";
2
3
  export interface SshCheckOptions {
3
4
  hostname: string;
@@ -5,5 +6,7 @@ export interface SshCheckOptions {
5
6
  source: Source;
6
7
  testId: TestId;
7
8
  timeout?: number;
9
+ /** Provide a pre-connected socket (e.g. tunneled through a SOCKS5 proxy). */
10
+ connectFn?: (hostname: string, port: number) => Promise<net.Socket>;
8
11
  }
9
12
  export declare function sshCheck(options: SshCheckOptions): Promise<CheckResult>;
@@ -5,52 +5,81 @@ const DEFAULT_TIMEOUT_MS = 5000;
5
5
  const SSH_BANNER_PREFIX = "SSH-";
6
6
  export async function sshCheck(options) {
7
7
  var _a, _b;
8
- const { hostname, port = DEFAULT_PORT, source, testId, timeout = DEFAULT_TIMEOUT_MS, } = options;
8
+ const { hostname, port = DEFAULT_PORT, source, testId, timeout = DEFAULT_TIMEOUT_MS, connectFn, } = options;
9
9
  const target = `${hostname}:${port}`;
10
10
  const startTime = Date.now();
11
11
  try {
12
12
  const result = await new Promise((resolve) => {
13
- const socket = new net.Socket();
14
13
  let banner = "";
15
- socket.setTimeout(timeout);
16
- socket.on("connect", () => {
17
- // Wait for SSH banner data before closing
18
- });
19
- socket.on("data", (data) => {
20
- banner += data.toString();
21
- if (banner.includes("\n") || banner.length > 256) {
22
- socket.destroy();
23
- banner = banner.split("\n")[0].trim();
24
- if (banner.startsWith(SSH_BANNER_PREFIX)) {
25
- resolve({ success: true, banner });
14
+ // Shared by the direct path (socket we create) and the proxied path
15
+ // (socket returned already-connected by connectFn).
16
+ const attachHandlers = (socket) => {
17
+ socket.setTimeout(timeout);
18
+ socket.on("data", (data) => {
19
+ banner += data.toString();
20
+ if (banner.includes("\n") || banner.length > 256) {
21
+ socket.destroy();
22
+ banner = banner.split("\n")[0].trim();
23
+ if (banner.startsWith(SSH_BANNER_PREFIX)) {
24
+ resolve({ success: true, banner });
25
+ }
26
+ else {
27
+ resolve({
28
+ success: false,
29
+ error: new Error(`Unexpected response: not an SSH server`),
30
+ banner,
31
+ });
32
+ }
26
33
  }
27
- else {
34
+ });
35
+ socket.on("timeout", () => {
36
+ socket.destroy();
37
+ const error = new Error("SSH connection timed out");
38
+ error.code = "ETIMEDOUT";
39
+ resolve({ success: false, error });
40
+ });
41
+ socket.on("error", (err) => {
42
+ socket.destroy();
43
+ resolve({ success: false, error: err });
44
+ });
45
+ socket.on("end", () => {
46
+ if (!banner) {
28
47
  resolve({
29
48
  success: false,
30
- error: new Error(`Unexpected response: not an SSH server`),
31
- banner,
49
+ error: new Error("Connection closed without receiving SSH banner"),
32
50
  });
33
51
  }
34
- }
35
- });
36
- socket.on("timeout", () => {
37
- socket.destroy();
38
- const error = new Error("SSH connection timed out");
39
- error.code = "ETIMEDOUT";
40
- resolve({ success: false, error });
41
- });
42
- socket.on("error", (err) => {
43
- socket.destroy();
44
- resolve({ success: false, error: err });
45
- });
46
- socket.on("end", () => {
47
- if (!banner) {
48
- resolve({
49
- success: false,
50
- error: new Error("Connection closed without receiving SSH banner"),
51
- });
52
- }
53
- });
52
+ });
53
+ };
54
+ if (connectFn) {
55
+ // Proxied path: connectFn returns an already-connected socket. Guard the
56
+ // connect itself with a timer since there's no "connect" event to await.
57
+ let timedOut = false;
58
+ const connectTimer = setTimeout(() => {
59
+ timedOut = true;
60
+ const error = new Error("SSH connection timed out");
61
+ error.code = "ETIMEDOUT";
62
+ resolve({ success: false, error });
63
+ }, timeout);
64
+ connectFn(hostname, port)
65
+ .then((socket) => {
66
+ clearTimeout(connectTimer);
67
+ // If the timer already fired, the check has resolved as failed —
68
+ // tear down the late socket instead of attaching, so it doesn't leak.
69
+ if (timedOut) {
70
+ socket.destroy();
71
+ return;
72
+ }
73
+ attachHandlers(socket);
74
+ })
75
+ .catch((err) => {
76
+ clearTimeout(connectTimer);
77
+ resolve({ success: false, error: err });
78
+ });
79
+ return;
80
+ }
81
+ const socket = new net.Socket();
82
+ attachHandlers(socket);
54
83
  socket.connect(port, hostname);
55
84
  });
56
85
  const durationMs = Date.now() - startTime;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,102 @@
1
+ import { describe, it, expect, afterEach, vi } from "vitest";
2
+ import net from "node:net";
3
+ import { sshCheck } from "./ssh-check.js";
4
+ /** Start a throwaway TCP server that writes the given banner on connect. */
5
+ function startServer(banner) {
6
+ return new Promise((resolve) => {
7
+ const server = net.createServer((socket) => {
8
+ socket.write(banner);
9
+ });
10
+ server.listen(0, "127.0.0.1", () => resolve(server));
11
+ });
12
+ }
13
+ function portOf(server) {
14
+ const addr = server.address();
15
+ if (addr && typeof addr === "object")
16
+ return addr.port;
17
+ throw new Error("no port");
18
+ }
19
+ describe("sshCheck", () => {
20
+ let server;
21
+ afterEach(() => {
22
+ server === null || server === void 0 ? void 0 : server.close();
23
+ server = undefined;
24
+ });
25
+ it("passes against a real SSH banner (direct connection)", async () => {
26
+ var _a;
27
+ server = await startServer("SSH-2.0-OpenSSH_9.0\r\n");
28
+ const result = await sshCheck({
29
+ hostname: "127.0.0.1",
30
+ port: portOf(server),
31
+ source: "local",
32
+ testId: "git-host:ssh",
33
+ });
34
+ expect(result.passed).toBe(true);
35
+ expect(String((_a = result.metadata) === null || _a === void 0 ? void 0 : _a.banner)).toMatch(/^SSH-/);
36
+ });
37
+ it("fails when the server is not an SSH server (direct connection)", async () => {
38
+ server = await startServer("HTTP/1.1 200 OK\r\n");
39
+ const result = await sshCheck({
40
+ hostname: "127.0.0.1",
41
+ port: portOf(server),
42
+ source: "local",
43
+ testId: "git-host:ssh",
44
+ });
45
+ expect(result.passed).toBe(false);
46
+ });
47
+ it("uses connectFn's pre-connected socket when provided", async () => {
48
+ var _a;
49
+ server = await startServer("SSH-2.0-Tunneled\r\n");
50
+ const port = portOf(server);
51
+ let called = false;
52
+ const connectFn = (hostname, p) => {
53
+ called = true;
54
+ return new Promise((resolve, reject) => {
55
+ const socket = net.connect(p, hostname);
56
+ socket.once("connect", () => resolve(socket));
57
+ socket.once("error", reject);
58
+ });
59
+ };
60
+ const result = await sshCheck({
61
+ hostname: "127.0.0.1",
62
+ port,
63
+ source: "vpc",
64
+ testId: "git-host:ssh",
65
+ connectFn,
66
+ });
67
+ expect(called).toBe(true);
68
+ expect(result.passed).toBe(true);
69
+ expect(String((_a = result.metadata) === null || _a === void 0 ? void 0 : _a.banner)).toMatch(/^SSH-/);
70
+ });
71
+ it("fails (does not throw) when connectFn rejects", async () => {
72
+ const connectFn = () => Promise.reject(new Error("tunnel refused"));
73
+ const result = await sshCheck({
74
+ hostname: "10.0.0.1",
75
+ port: 22,
76
+ source: "vpc",
77
+ testId: "git-host:ssh",
78
+ connectFn,
79
+ });
80
+ expect(result.passed).toBe(false);
81
+ });
82
+ it("destroys a connectFn socket that resolves after the timeout (no leak)", async () => {
83
+ const destroy = vi.fn();
84
+ // Resolves a fake socket well after the check's connect timeout fires.
85
+ const connectFn = () => new Promise((resolve) => {
86
+ setTimeout(() => resolve({ destroy }), 80);
87
+ });
88
+ const result = await sshCheck({
89
+ hostname: "10.0.0.1",
90
+ port: 22,
91
+ source: "vpc",
92
+ testId: "git-host:ssh",
93
+ timeout: 20,
94
+ connectFn,
95
+ });
96
+ expect(result.passed).toBe(false);
97
+ expect(result.errorCode).toBe("tcp_connection_timeout");
98
+ // Wait past the connectFn resolution to confirm the late socket is torn down.
99
+ await new Promise((r) => setTimeout(r, 100));
100
+ expect(destroy).toHaveBeenCalled();
101
+ });
102
+ });
@@ -0,0 +1,28 @@
1
+ import type { CheckResult, ConnectivityStatus } from "./types.js";
2
+ export type RowVerdict = "pass" | "informational" | "blocking";
3
+ /**
4
+ * Classify a single connectivity check row into pass / informational / blocking.
5
+ *
6
+ * This is the SINGLE source of truth shared by the verdict math
7
+ * (`computeDoctorTotals`) and both report renderers (the dev-tools clack
8
+ * renderer and the builder-code Ink renderer), so the recommendation, exit code,
9
+ * summary count, outro text, and per-row rendering can never disagree — the
10
+ * verdict counts exactly the rows the renderers draw red.
11
+ *
12
+ * A failed row is INFORMATIONAL (contributes to neither passed nor failed, and
13
+ * renders as a warning rather than a blocking failure) when:
14
+ *
15
+ * 1. it is an SSH probe (`git-host:ssh`) — SSH is informational everywhere; or
16
+ * 2. it is the VPC DNS probe (`source === "vpc"` && `git-host:dns`) — matching
17
+ * `analyzeConnectivity`'s `vpcCoreResults`, which excludes DNS from
18
+ * `vpcToGitHost`; or
19
+ * 3. it is a LOCAL `git-host:*` probe superseded by a PASSING VPC probe
20
+ * (`vpcToGitHost === "pass"`) — a private host is expected to be unreachable
21
+ * from the local machine, and the VPC proxy is the authoritative path. This
22
+ * mirrors `analyzeConnectivity` giving `vpcToGitHost === "pass"` precedence
23
+ * over `localToGitHost`. Keyed on `=== "pass"` (not merely "a VPC run was
24
+ * requested"), so a FAILED VPC run still surfaces the local failures.
25
+ */
26
+ export declare function classifyCheckRow(result: CheckResult, ctx: {
27
+ vpcToGitHost?: ConnectivityStatus;
28
+ }): RowVerdict;
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Classify a single connectivity check row into pass / informational / blocking.
3
+ *
4
+ * This is the SINGLE source of truth shared by the verdict math
5
+ * (`computeDoctorTotals`) and both report renderers (the dev-tools clack
6
+ * renderer and the builder-code Ink renderer), so the recommendation, exit code,
7
+ * summary count, outro text, and per-row rendering can never disagree — the
8
+ * verdict counts exactly the rows the renderers draw red.
9
+ *
10
+ * A failed row is INFORMATIONAL (contributes to neither passed nor failed, and
11
+ * renders as a warning rather than a blocking failure) when:
12
+ *
13
+ * 1. it is an SSH probe (`git-host:ssh`) — SSH is informational everywhere; or
14
+ * 2. it is the VPC DNS probe (`source === "vpc"` && `git-host:dns`) — matching
15
+ * `analyzeConnectivity`'s `vpcCoreResults`, which excludes DNS from
16
+ * `vpcToGitHost`; or
17
+ * 3. it is a LOCAL `git-host:*` probe superseded by a PASSING VPC probe
18
+ * (`vpcToGitHost === "pass"`) — a private host is expected to be unreachable
19
+ * from the local machine, and the VPC proxy is the authoritative path. This
20
+ * mirrors `analyzeConnectivity` giving `vpcToGitHost === "pass"` precedence
21
+ * over `localToGitHost`. Keyed on `=== "pass"` (not merely "a VPC run was
22
+ * requested"), so a FAILED VPC run still surfaces the local failures.
23
+ */
24
+ export function classifyCheckRow(result, ctx) {
25
+ if (result.passed)
26
+ return "pass";
27
+ if (result.testId === "git-host:ssh")
28
+ return "informational";
29
+ if (result.source === "vpc" && result.testId === "git-host:dns") {
30
+ return "informational";
31
+ }
32
+ if (result.source === "local" &&
33
+ result.testId.startsWith("git-host:") &&
34
+ ctx.vpcToGitHost === "pass") {
35
+ return "informational";
36
+ }
37
+ return "blocking";
38
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,43 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { classifyCheckRow } from "./classify.js";
3
+ function row(source, testId, passed) {
4
+ return { source, testId, target: "git", passed, durationMs: 1 };
5
+ }
6
+ describe("classifyCheckRow", () => {
7
+ it("passing rows are 'pass' regardless of source", () => {
8
+ expect(classifyCheckRow(row("local", "git-host:tcp", true), {})).toBe("pass");
9
+ expect(classifyCheckRow(row("vpc", "git-host:dns", true), {})).toBe("pass");
10
+ });
11
+ it("a failing SSH probe is informational for any source", () => {
12
+ expect(classifyCheckRow(row("local", "git-host:ssh", false), {})).toBe("informational");
13
+ expect(classifyCheckRow(row("vpc", "git-host:ssh", false), {
14
+ vpcToGitHost: "fail",
15
+ })).toBe("informational");
16
+ });
17
+ it("a failing VPC DNS probe is informational (matches vpcCoreResults)", () => {
18
+ expect(classifyCheckRow(row("vpc", "git-host:dns", false), {
19
+ vpcToGitHost: "fail",
20
+ })).toBe("informational");
21
+ });
22
+ it("VPC core failures (tcp/tls/http) are blocking", () => {
23
+ for (const t of [
24
+ "git-host:tcp",
25
+ "git-host:tls",
26
+ "git-host:http",
27
+ ]) {
28
+ expect(classifyCheckRow(row("vpc", t, false), { vpcToGitHost: "fail" })).toBe("blocking");
29
+ }
30
+ });
31
+ it("local git-host failures are superseded (informational) ONLY when VPC passed", () => {
32
+ const r = row("local", "git-host:tcp", false);
33
+ expect(classifyCheckRow(r, { vpcToGitHost: "pass" })).toBe("informational");
34
+ // Not superseded when VPC failed or no VPC run happened.
35
+ expect(classifyCheckRow(r, { vpcToGitHost: "fail" })).toBe("blocking");
36
+ expect(classifyCheckRow(r, {})).toBe("blocking");
37
+ });
38
+ it("non-git-host local failures are always blocking", () => {
39
+ expect(classifyCheckRow(row("local", "builder.io", false), {
40
+ vpcToGitHost: "pass",
41
+ })).toBe("blocking");
42
+ });
43
+ });
@@ -13,3 +13,5 @@ export { tlsCheck } from "./checks/tls-check.js";
13
13
  export type { TlsCheckOptions } from "./checks/tls-check.js";
14
14
  export { sshCheck } from "./checks/ssh-check.js";
15
15
  export type { SshCheckOptions } from "./checks/ssh-check.js";
16
+ export { classifyCheckRow } from "./classify.js";
17
+ export type { RowVerdict } from "./classify.js";
@@ -7,3 +7,4 @@ export { dnsCheck } from "./checks/dns-check.js";
7
7
  export { tcpCheck } from "./checks/tcp-check.js";
8
8
  export { tlsCheck } from "./checks/tls-check.js";
9
9
  export { sshCheck } from "./checks/ssh-check.js";
10
+ export { classifyCheckRow } from "./classify.js";
@@ -7,7 +7,7 @@ import { tcpCheck } from "./checks/tcp-check.js";
7
7
  import { tlsCheck } from "./checks/tls-check.js";
8
8
  import { sshCheck } from "./checks/ssh-check.js";
9
9
  export async function runChecks(input) {
10
- const { tests, gitHost, onProgress, fetchFn, dispatcher, connectFn } = input;
10
+ const { tests, gitHost, onProgress, fetchFn, dispatcher, connectFn, sshConnectFn, dnsResolver, } = input;
11
11
  const results = [];
12
12
  const total = tests.length;
13
13
  for (let index = 0; index < tests.length; index++) {
@@ -18,7 +18,7 @@ export async function runChecks(input) {
18
18
  index,
19
19
  total,
20
20
  });
21
- const result = await runSingleCheck(test, gitHost, fetchFn, dispatcher, connectFn);
21
+ const result = await runSingleCheck(test, gitHost, fetchFn, dispatcher, connectFn, sshConnectFn, dnsResolver);
22
22
  results.push(result);
23
23
  emitProgress(onProgress, {
24
24
  type: "test:complete",
@@ -37,7 +37,7 @@ export async function runChecks(input) {
37
37
  results,
38
38
  };
39
39
  }
40
- async function runSingleCheck(test, gitHost, fetchFn, dispatcher, connectFn) {
40
+ async function runSingleCheck(test, gitHost, fetchFn, dispatcher, connectFn, sshConnectFn, dnsResolver) {
41
41
  const { source, testId } = test;
42
42
  const checkType = getCheckTypeForTestId(testId);
43
43
  if (!isCheckAvailable(checkType)) {
@@ -89,6 +89,7 @@ async function runSingleCheck(test, gitHost, fetchFn, dispatcher, connectFn) {
89
89
  hostname: extractHostname(target),
90
90
  source,
91
91
  testId,
92
+ servers: dnsResolver === null || dnsResolver === void 0 ? void 0 : dnsResolver.servers,
92
93
  });
93
94
  case "tcp":
94
95
  return tcpCheck({
@@ -112,6 +113,7 @@ async function runSingleCheck(test, gitHost, fetchFn, dispatcher, connectFn) {
112
113
  port: extractExplicitPort(target, 22),
113
114
  source,
114
115
  testId,
116
+ connectFn: sshConnectFn,
115
117
  });
116
118
  default:
117
119
  return {
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,47 @@
1
+ import { describe, it, expect, vi, afterEach } from "vitest";
2
+ import dns from "node:dns";
3
+ import { runChecks } from "./run-checks.js";
4
+ const GIT_HOST = "https://git.example.com";
5
+ describe("runChecks — proxy seams are opt-in (never assumed)", () => {
6
+ afterEach(() => {
7
+ vi.restoreAllMocks();
8
+ });
9
+ it("does NOT use a custom DNS resolver when dnsResolver is absent", async () => {
10
+ const resolve4 = vi
11
+ .spyOn(dns.promises, "resolve4")
12
+ .mockResolvedValue(["1.2.3.4"]);
13
+ const setServers = vi.spyOn(dns.promises.Resolver.prototype, "setServers");
14
+ await runChecks({
15
+ tests: [{ source: "cloud", testId: "git-host:dns" }],
16
+ gitHost: GIT_HOST,
17
+ });
18
+ expect(resolve4).toHaveBeenCalled();
19
+ expect(setServers).not.toHaveBeenCalled();
20
+ });
21
+ it("threads dnsResolver.servers to the DNS check when provided", async () => {
22
+ const setServers = vi.spyOn(dns.promises.Resolver.prototype, "setServers");
23
+ vi.spyOn(dns.promises.Resolver.prototype, "resolve4").mockResolvedValue([
24
+ "10.0.0.5",
25
+ ]);
26
+ await runChecks({
27
+ tests: [{ source: "vpc", testId: "git-host:dns" }],
28
+ gitHost: GIT_HOST,
29
+ dnsResolver: { servers: ["10.64.0.10:53"] },
30
+ });
31
+ expect(setServers).toHaveBeenCalledWith(["10.64.0.10:53"]);
32
+ });
33
+ it("threads sshConnectFn to the SSH check when provided", async () => {
34
+ var _a;
35
+ const sshConnectFn = vi
36
+ .fn()
37
+ .mockRejectedValue(new Error("tunnel unavailable"));
38
+ const report = await runChecks({
39
+ tests: [{ source: "vpc", testId: "git-host:ssh" }],
40
+ gitHost: GIT_HOST,
41
+ sshConnectFn,
42
+ });
43
+ expect(sshConnectFn).toHaveBeenCalled();
44
+ // It still returns a (failed) result rather than throwing.
45
+ expect((_a = report.results[0]) === null || _a === void 0 ? void 0 : _a.passed).toBe(false);
46
+ });
47
+ });
@@ -1,4 +1,4 @@
1
- export type Source = "local" | "cloud" | "static-ip";
1
+ export type Source = "local" | "cloud" | "static-ip" | "vpc";
2
2
  export type TestId = "builder.io" | "builder.codes" | "api.builder.io" | "cdn.builder.io" | "builderio.xyz" | "builderio.xyz:ws" | "builderio.dev" | "builderio.dev:ws" | "fly.dev" | "git-host:http" | "git-host:dns" | "git-host:tcp" | "git-host:tls" | "git-host:ssh";
3
3
  export interface Test {
4
4
  source: Source;
@@ -26,11 +26,25 @@ export interface RunChecksInput {
26
26
  */
27
27
  dispatcher?: object;
28
28
  /**
29
- * Returns a connected socket tunneled through a proxy (via HTTP CONNECT).
30
- * Used by TCP and TLS checks for static IP routing. The returned socket
31
- * is already connected to hostname:port through the proxy.
29
+ * Returns a connected socket tunneled through a proxy (via HTTP CONNECT
30
+ * or SOCKS5). Used by TCP and TLS checks for static IP / VPC routing. The
31
+ * returned socket is already connected to hostname:port through the proxy.
32
32
  */
33
33
  connectFn?: (hostname: string, port: number) => Promise<unknown>;
34
+ /**
35
+ * Like `connectFn`, but used by the SSH check. Kept separate so SSH can be
36
+ * proxied independently of TCP/TLS (e.g. routed through a VPC SOCKS5 proxy).
37
+ */
38
+ sshConnectFn?: (hostname: string, port: number) => Promise<unknown>;
39
+ /**
40
+ * DNS servers (e.g. ["10.64.0.10:53"]) the DNS check should resolve against
41
+ * instead of the system resolver. Used to resolve customer-internal hostnames
42
+ * via a VPC proxy's DNS forwarder (dnsmasq), which the caller can reach
43
+ * directly even though SOCKS5 only does remote DNS on connect.
44
+ */
45
+ dnsResolver?: {
46
+ servers: string[];
47
+ };
34
48
  }
35
49
  export type ProgressEvent = {
36
50
  type: "test:start";
@@ -62,7 +76,7 @@ export interface CheckReport {
62
76
  }
63
77
  export type ConnectivityErrorCode = "dns_resolution_failed" | "dns_timeout" | "dns_wrong_ip" | "tcp_connection_refused" | "tcp_connection_timeout" | "tcp_connection_reset" | "tcp_host_unreachable" | "tcp_network_unreachable" | "tls_self_signed_cert" | "tls_cert_expired" | "tls_cert_not_yet_valid" | "tls_cert_invalid" | "tls_cert_hostname_mismatch" | "tls_handshake_failed" | "tls_protocol_error" | "proxy_auth_required" | "proxy_connection_failed" | "proxy_tunnel_failed" | "http_unauthorized" | "http_forbidden" | "http_not_found" | "http_server_error" | "http_service_unavailable" | "latency_high" | "check_unavailable" | "unknown_error";
64
78
  export type CheckType = "http" | "websocket" | "dns" | "tcp" | "tls" | "ssh";
65
- export type Recommendation = "ready_for_cloud_dev" | "enable_static_ip_proxy" | "whitelist_static_ip" | "fix_local_dns" | "fix_local_tls_certs" | "fix_local_firewall" | "use_local_development";
79
+ export type Recommendation = "ready_for_cloud_dev" | "enable_static_ip_proxy" | "whitelist_static_ip" | "fix_local_dns" | "fix_local_tls_certs" | "fix_local_firewall" | "fix_vpc_connectivity" | "use_local_development";
66
80
  export type LikelyCause = "ip_whitelisting_required" | "vpn_blocking" | "corporate_proxy_required" | "self_signed_certificate" | "dns_misconfiguration" | "firewall_blocking" | "server_unavailable";
67
81
  export type ConnectivityStatus = "pass" | "fail" | "unknown";
68
82
  export interface AnalysisResult {
@@ -76,6 +90,7 @@ export interface AnalysisResult {
76
90
  localToGitHost: ConnectivityStatus;
77
91
  cloudToGitHost: ConnectivityStatus;
78
92
  staticIpToGitHost: ConnectivityStatus;
93
+ vpcToGitHost: ConnectivityStatus;
79
94
  };
80
95
  allResults: CheckResult[];
81
96
  }
package/src/index.d.ts CHANGED
@@ -16,6 +16,7 @@ export * from "./vscode-tunnel.js";
16
16
  export * from "./claw.js";
17
17
  export * from "./kube-error.js";
18
18
  export * from "./connectivity/types.js";
19
+ export * from "./connectivity/classify.js";
19
20
  export * from "./single-tenancy.js";
20
21
  export * from "./design-systems.js";
21
22
  export { connectivityErrorCodeToLikelyCause, mapConnectivityErrorMessage, } from "./connectivity/error-codes.js";
package/src/index.js CHANGED
@@ -16,6 +16,7 @@ export * from "./vscode-tunnel.js";
16
16
  export * from "./claw.js";
17
17
  export * from "./kube-error.js";
18
18
  export * from "./connectivity/types.js";
19
+ export * from "./connectivity/classify.js";
19
20
  export * from "./single-tenancy.js";
20
21
  export * from "./design-systems.js";
21
22
  export { connectivityErrorCodeToLikelyCause, mapConnectivityErrorMessage, } from "./connectivity/error-codes.js";