@builder.io/ai-utils 0.82.0 → 0.83.0-dev.202607211718.99c5f9cf8

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.82.0",
3
+ "version": "0.83.0-dev.202607211718.99c5f9cf8",
4
4
  "description": "Builder.io AI utils",
5
5
  "files": [
6
6
  "src"
@@ -0,0 +1,88 @@
1
+ import { z } from "zod";
2
+ export declare const PermissionSchema: z.ZodEnum<{
3
+ list: "list";
4
+ read: "read";
5
+ write: "write";
6
+ }>;
7
+ export type Permission = z.infer<typeof PermissionSchema>;
8
+ export declare const AclEntrySchema: z.ZodObject<{
9
+ action: z.ZodEnum<{
10
+ allow: "allow";
11
+ deny: "deny";
12
+ }>;
13
+ resource: z.ZodString;
14
+ permissions: z.ZodArray<z.ZodEnum<{
15
+ list: "list";
16
+ read: "read";
17
+ write: "write";
18
+ }>>;
19
+ description: z.ZodOptional<z.ZodString>;
20
+ principals: z.ZodOptional<z.ZodArray<z.ZodString>>;
21
+ }, z.core.$strip>;
22
+ export type AclEntry = z.infer<typeof AclEntrySchema>;
23
+ export declare const AclPolicySchema: z.ZodObject<{
24
+ secrets: z.ZodOptional<z.ZodArray<z.ZodString>>;
25
+ entries: z.ZodOptional<z.ZodArray<z.ZodObject<{
26
+ action: z.ZodEnum<{
27
+ allow: "allow";
28
+ deny: "deny";
29
+ }>;
30
+ resource: z.ZodString;
31
+ permissions: z.ZodArray<z.ZodEnum<{
32
+ list: "list";
33
+ read: "read";
34
+ write: "write";
35
+ }>>;
36
+ description: z.ZodOptional<z.ZodString>;
37
+ principals: z.ZodOptional<z.ZodArray<z.ZodString>>;
38
+ }, z.core.$strip>>>;
39
+ denyDescription: z.ZodOptional<z.ZodString>;
40
+ }, z.core.$strip>;
41
+ export type AclPolicy = z.infer<typeof AclPolicySchema>;
42
+ export declare const AclDenialSchema: z.ZodObject<{
43
+ kind: z.ZodEnum<{
44
+ "command-allowlist": "command-allowlist";
45
+ "command-security": "command-security";
46
+ "file-access": "file-access";
47
+ }>;
48
+ reason: z.ZodEnum<{
49
+ "deny-pattern-matched": "deny-pattern-matched";
50
+ "no-allow-match": "no-allow-match";
51
+ "security-policy": "security-policy";
52
+ "shell-metacharacter": "shell-metacharacter";
53
+ }>;
54
+ resource: z.ZodString;
55
+ command: z.ZodOptional<z.ZodString>;
56
+ permission: z.ZodOptional<z.ZodEnum<{
57
+ list: "list";
58
+ read: "read";
59
+ write: "write";
60
+ }>>;
61
+ policy: z.ZodOptional<z.ZodString>;
62
+ matchedPattern: z.ZodOptional<z.ZodString>;
63
+ matchedEntry: z.ZodOptional<z.ZodObject<{
64
+ action: z.ZodEnum<{
65
+ allow: "allow";
66
+ deny: "deny";
67
+ }>;
68
+ resource: z.ZodString;
69
+ permissions: z.ZodArray<z.ZodEnum<{
70
+ list: "list";
71
+ read: "read";
72
+ write: "write";
73
+ }>>;
74
+ description: z.ZodOptional<z.ZodString>;
75
+ principals: z.ZodOptional<z.ZodArray<z.ZodString>>;
76
+ }, z.core.$strip>>;
77
+ message: z.ZodString;
78
+ }, z.core.$strip>;
79
+ export type AclDenial = z.infer<typeof AclDenialSchema>;
80
+ export interface AccessResult {
81
+ allowed: boolean;
82
+ message: string;
83
+ matchedEntry?: AclEntry;
84
+ matchedPattern?: string;
85
+ reason?: "deny-pattern-matched" | "no-allow-match";
86
+ requestedResource?: string;
87
+ requestedPermission?: Permission;
88
+ }
@@ -0,0 +1,71 @@
1
+ import { z } from "zod";
2
+ export const PermissionSchema = z
3
+ .enum(["read", "write", "list"])
4
+ .meta({ title: "Permission" });
5
+ // One ACL rule
6
+ export const AclEntrySchema = z
7
+ .object({
8
+ action: z
9
+ .enum(["allow", "deny"])
10
+ .meta({ description: "whether this rule allows or denies access" }),
11
+ resource: z
12
+ .string()
13
+ .meta({ description: "what — supports glob patterns like /files/*.txt" }),
14
+ permissions: z
15
+ .array(PermissionSchema)
16
+ .meta({ description: "actions this rule applies to" }),
17
+ description: z.string().optional().meta({
18
+ description: "custom message, in deny case, this is the error message. This will override denyDescription on AclPolicy if defined.",
19
+ }),
20
+ principals: z.array(z.string()).optional().meta({
21
+ description: "array of teams/roles this rule applies to (e.g., ['developer', 'admin'])",
22
+ }),
23
+ })
24
+ .meta({ title: "AclEntry" });
25
+ // A full ACL policy is just a list of rules
26
+ export const AclPolicySchema = z
27
+ .object({
28
+ secrets: z.array(z.string()).optional(),
29
+ entries: z.array(AclEntrySchema).optional(),
30
+ denyDescription: z.string().optional().meta({
31
+ description: "Default message to use when a resource is denied access",
32
+ }),
33
+ })
34
+ .meta({ title: "AclPolicy" });
35
+ // Structured description of an ACL/policy denial. Travels with the tool result
36
+ // so internal tools can show admins exactly which rule blocked a command/file,
37
+ // and both UIs can render a distinct "blocked, did not run" treatment.
38
+ export const AclDenialSchema = z
39
+ .object({
40
+ kind: z
41
+ .enum(["command-security", "command-allowlist", "file-access"])
42
+ .meta({ description: "which gate produced the denial" }),
43
+ reason: z
44
+ .enum([
45
+ "security-policy",
46
+ "deny-pattern-matched",
47
+ "no-allow-match",
48
+ "shell-metacharacter",
49
+ ])
50
+ .meta({ description: "why the denial happened" }),
51
+ resource: z.string().meta({
52
+ description: "the file path or command that was blocked",
53
+ }),
54
+ command: z.string().optional().meta({
55
+ description: "the full command, when the denial is command-related",
56
+ }),
57
+ permission: PermissionSchema.optional().meta({
58
+ description: "the requested permission, for file-access denials",
59
+ }),
60
+ policy: z.string().optional().meta({
61
+ description: "named security policy that matched, when applicable",
62
+ }),
63
+ matchedPattern: z.string().optional().meta({
64
+ description: "the glob/pattern that matched the resource or command",
65
+ }),
66
+ matchedEntry: AclEntrySchema.optional().meta({
67
+ description: "the full ACL entry that matched, for file-access denials",
68
+ }),
69
+ message: z.string().meta({ description: "human-readable explanation" }),
70
+ })
71
+ .meta({ title: "AclDenial" });
package/src/claw.d.ts CHANGED
@@ -101,6 +101,7 @@ export type ChannelType<P extends KnownPlatform> = (typeof CHANNEL_TYPES)[P][num
101
101
  /** Union of every channel sub-type across all platforms. */
102
102
  export type AnyChannelType = ChannelType<KnownPlatform>;
103
103
  export declare function isChannelType<P extends KnownPlatform>(platform: P, type: string): type is ChannelType<P>;
104
+ export declare function isBuilderBranchChannelId(channelId: string): boolean;
104
105
  /** Platform recorded on a logged message; "unknown" when the channelId fails to parse. */
105
106
  export type ChannelSource = KnownPlatform | "unknown";
106
107
  /** Channel sub-type recorded on a logged message; "unknown" when parsing fails. */
package/src/claw.js CHANGED
@@ -81,6 +81,15 @@ export const CHANNEL_TYPES = {
81
81
  export function isChannelType(platform, type) {
82
82
  return CHANNEL_TYPES[platform].includes(type);
83
83
  }
84
+ export function isBuilderBranchChannelId(channelId) {
85
+ try {
86
+ const channel = parseChannelId(channelId);
87
+ return channel.platform === "builder" && channel.type === "branch";
88
+ }
89
+ catch (_a) {
90
+ return false;
91
+ }
92
+ }
84
93
  /**
85
94
  * Converts a Builder channel_id URI to a clickable URL for the
86
95
  * corresponding platform (Slack, Jira, etc.).
package/src/claw.spec.js CHANGED
@@ -1,5 +1,15 @@
1
1
  import { describe, it, expect } from "vitest";
2
- import { convertChannelIdToUrl, formatIncomingMessage, formatWorkerMessage, formatWorkerReport, } from "./claw";
2
+ import { convertChannelIdToUrl, formatIncomingMessage, formatWorkerMessage, formatWorkerReport, isBuilderBranchChannelId, } from "./claw";
3
+ describe("isBuilderBranchChannelId", () => {
4
+ it("recognizes Builder branch channel IDs", () => {
5
+ expect(isBuilderBranchChannelId("builder/branch/proj-id/my-branch")).toBe(true);
6
+ });
7
+ it("rejects other and invalid channel IDs", () => {
8
+ expect(isBuilderBranchChannelId("slack/channel/team/channel")).toBe(false);
9
+ expect(isBuilderBranchChannelId("builder/project/proj-id")).toBe(false);
10
+ expect(isBuilderBranchChannelId("invalid")).toBe(false);
11
+ });
12
+ });
3
13
  describe("convertChannelIdToUrl", () => {
4
14
  describe("slack/thread format", () => {
5
15
  it("converts a thread channel ID to a Slack app_redirect URL", () => {
package/src/codegen.d.ts CHANGED
@@ -3297,6 +3297,7 @@ export interface GenerateCompletionStepGit {
3297
3297
  */
3298
3298
  diagnostics?: GitStatusDiagnostics;
3299
3299
  }
3300
+ export declare const FUSION_GENERATION_APP_PLACEHOLDER_COMMENT = "{/* TODO: FUSION_GENERATION_APP_PLACEHOLDER replace everything here with the actual app! */}";
3300
3301
  /**
3301
3302
  * A typed comment added to the session via the generic `AddComment` tool. The
3302
3303
  * `commentType` mirrors the tool's `type` field (e.g. `"code-review"`) and
package/src/codegen.js CHANGED
@@ -1229,7 +1229,7 @@ export const SendMessageToolInputSchema = z
1229
1229
  description: "When true, send the response as a voice message using text-to-speech. Only supported for Telegram channels. Only set to true when the user's original message was a voice/audio message (look for '[Voice message transcription]' or '[Audio' markers), the channel is Telegram, and the response is short and conversational with no URLs, code, lists, or other content that doesn't translate to audio. Default to false (text) for all text-originated messages.",
1230
1230
  }),
1231
1231
  from_user_id: z.string().optional().meta({
1232
- description: "Builder.io user ID this message is from / should be attributed to. Only allowed when channel_id is 'builder/branch/{project_id}/{branch_name}'. When set, the message is delivered to the target branch as coming from this user (role 'user') instead of from the agent. Use whenever the message represents user feedback/intent that should be assigned to someone — even if it was composed, summarized, or merged from multiple people.",
1232
+ description: "Builder.io user ID this message is from / should be attributed to. Only allowed when channel_id is 'builder/branch/{project_id}/{branch_name}'. When set, the message is delivered to the target branch as coming from this user (role 'user') instead of from the agent. Defaults to the current requester when omitted. Set this explicitly when relaying a message on behalf of someone other than the person who sent the current message — even if it was composed, summarized, or merged from multiple people.",
1233
1233
  }),
1234
1234
  })
1235
1235
  .meta({ title: "SendMessageToolInput" });
@@ -1918,6 +1918,7 @@ export const CodeGenInputOptionsSchema = z
1918
1918
  systemReminderPrompt: z.string().optional(),
1919
1919
  })
1920
1920
  .meta({ title: "CodeGenInputOptions" });
1921
+ export const FUSION_GENERATION_APP_PLACEHOLDER_COMMENT = "{/* TODO: FUSION_GENERATION_APP_PLACEHOLDER replace everything here with the actual app! */}";
1921
1922
  export const AutoPushModeSchema = z
1922
1923
  .enum(["force-push", "merge-push", "ff-push", "safe-push", "none"])
1923
1924
  .meta({ title: "AutoPushMode" });
@@ -7,5 +7,12 @@ export interface HttpCheckOptions {
7
7
  fetchFn?: ConnectivityFetchFn;
8
8
  /** Fetch dispatcher for proxy routing (e.g. undici ProxyAgent). */
9
9
  dispatcher?: object;
10
+ /**
11
+ * Treat any 4xx as a failure (not just 5xx). Used by `doctor --browser`: a
12
+ * TLS-inspecting proxy (Zscaler) that blocks browser-shaped traffic typically
13
+ * answers with a 403/407/451 block page, which a plain reachability check
14
+ * would otherwise count as a pass.
15
+ */
16
+ strictHttpStatus?: boolean;
10
17
  }
11
18
  export declare function httpCheck(options: HttpCheckOptions): Promise<CheckResult>;
@@ -1,9 +1,10 @@
1
- import { mapFetchErrorToConnectivityCode } from "../error-codes.js";
1
+ import { mapFetchErrorToConnectivityCode, mapHttpStatusToErrorCode, } from "../error-codes.js";
2
2
  import { isBrowser } from "../environment.js";
3
3
  const DEFAULT_TIMEOUT_MS = 30000;
4
4
  const LATENCY_THRESHOLD_MS = 5000;
5
5
  export async function httpCheck(options) {
6
- const { target, source, testId, timeout = DEFAULT_TIMEOUT_MS, fetchFn = fetch, dispatcher, } = options;
6
+ var _a;
7
+ const { target, source, testId, timeout = DEFAULT_TIMEOUT_MS, fetchFn = fetch, dispatcher, strictHttpStatus = false, } = options;
7
8
  const startTime = Date.now();
8
9
  const controller = new AbortController();
9
10
  const timeoutId = setTimeout(() => controller.abort(), timeout);
@@ -46,14 +47,21 @@ export async function httpCheck(options) {
46
47
  clearTimeout(timeoutId);
47
48
  const durationMs = Date.now() - startTime;
48
49
  const hasHighLatency = durationMs > LATENCY_THRESHOLD_MS;
49
- const isServerError = response.status >= 500;
50
- if (isServerError) {
50
+ // Standard mode only fails on 5xx (server reachable is what matters).
51
+ // Browser (strict) mode also fails on 4xx to surface proxy block pages.
52
+ const isFailingStatus = strictHttpStatus
53
+ ? response.status >= 400
54
+ : response.status >= 500;
55
+ if (isFailingStatus) {
51
56
  return {
52
57
  source,
53
58
  testId,
54
59
  target,
55
60
  passed: false,
56
- errorCode: "http_server_error",
61
+ // Canonical status->code mapping: 401/403/404/407/503/5xx get specific
62
+ // codes; other 4xx (400/405/429/...) map to "unknown_error" instead of
63
+ // being mislabeled as a specific auth failure.
64
+ errorCode: (_a = mapHttpStatusToErrorCode(response.status)) !== null && _a !== void 0 ? _a : "unknown_error",
57
65
  durationMs,
58
66
  metadata: {
59
67
  statusCode: response.status,
@@ -24,3 +24,61 @@ describe("httpCheck", () => {
24
24
  expect(result.metadata).toMatchObject({ reachabilityOnly: true });
25
25
  });
26
26
  });
27
+ describe("httpCheck strictHttpStatus", () => {
28
+ const target = "https://api.builder.io/codegen/health";
29
+ it("treats 403 as a pass by default (server reachable)", async () => {
30
+ const fetchFn = vi
31
+ .fn()
32
+ .mockResolvedValue({ ok: false, status: 403, statusText: "Forbidden" });
33
+ const result = await httpCheck({
34
+ target,
35
+ source: "local",
36
+ testId: "api.builder.io",
37
+ fetchFn,
38
+ });
39
+ expect(result.passed).toBe(true);
40
+ expect(result.metadata).toMatchObject({ statusCode: 403 });
41
+ });
42
+ it("treats 403 as a failure in strict mode (proxy block page)", async () => {
43
+ const fetchFn = vi
44
+ .fn()
45
+ .mockResolvedValue({ ok: false, status: 403, statusText: "Forbidden" });
46
+ const result = await httpCheck({
47
+ target,
48
+ source: "local",
49
+ testId: "api.builder.io",
50
+ fetchFn,
51
+ strictHttpStatus: true,
52
+ });
53
+ expect(result.passed).toBe(false);
54
+ expect(result.errorCode).toBe("http_forbidden");
55
+ expect(result.metadata).toMatchObject({ statusCode: 403 });
56
+ });
57
+ it("maps an unhandled 4xx (429) to unknown_error, not http_forbidden", async () => {
58
+ const fetchFn = vi
59
+ .fn()
60
+ .mockResolvedValue({ ok: false, status: 429, statusText: "Too Many" });
61
+ const result = await httpCheck({
62
+ target,
63
+ source: "local",
64
+ testId: "api.builder.io",
65
+ fetchFn,
66
+ strictHttpStatus: true,
67
+ });
68
+ expect(result.passed).toBe(false);
69
+ expect(result.errorCode).toBe("unknown_error");
70
+ });
71
+ it("still passes on 200 in strict mode", async () => {
72
+ const fetchFn = vi
73
+ .fn()
74
+ .mockResolvedValue({ ok: true, status: 200, statusText: "OK" });
75
+ const result = await httpCheck({
76
+ target,
77
+ source: "local",
78
+ testId: "api.builder.io",
79
+ fetchFn,
80
+ strictHttpStatus: true,
81
+ });
82
+ expect(result.passed).toBe(true);
83
+ });
84
+ });
@@ -13,6 +13,12 @@ export function getCheckTypeForTestId(testId) {
13
13
  if (testId.startsWith("git-host:")) {
14
14
  return testId.replace("git-host:", "");
15
15
  }
16
+ if (testId.startsWith("project-health:")) {
17
+ return testId.replace("project-health:", "");
18
+ }
19
+ if (testId.startsWith("project:")) {
20
+ return testId.replace("project:", "");
21
+ }
16
22
  return "http";
17
23
  }
18
24
  export function isCheckAvailable(checkType) {
@@ -1,7 +1,7 @@
1
1
  export type { Source, TestId, Test, RunChecksInput, ProgressEvent, CheckResult, CheckReport, ConnectivityErrorCode, CheckType, Recommendation, LikelyCause, ConnectivityStatus, AnalysisResult, AnalyzeConnectivityInput, ConnectivityFetchFn, } from "./types.js";
2
2
  export { runChecks } from "./run-checks.js";
3
3
  export { mapNodeErrorToConnectivityCode, mapHttpStatusToErrorCode, mapFetchErrorToConnectivityCode, connectivityErrorCodeToLikelyCause, mapConnectivityErrorMessage, SELF_SIGNED_CERT_ERRORS, CERT_EXPIRED_ERRORS, CERT_NOT_YET_VALID_ERRORS, CERT_INVALID_ERRORS, CERT_HOSTNAME_MISMATCH_ERRORS, SSL_PROTOCOL_ERRORS, SSL_HANDSHAKE_ERRORS, NETWORK_UNREACHABLE_ERRORS, TIMEOUT_ERRORS, PROXY_ERRORS, DNS_ERRORS, } from "./error-codes.js";
4
- export { BUILDER_TARGETS, DEFAULT_PORTS, DEFAULT_LOCAL_BUILDER_TESTS, BUILDER_TEST_DISPLAY_NAMES, resolveTarget, extractHostname, extractPort, } from "./targets.js";
4
+ export { BUILDER_TARGETS, DEFAULT_PORTS, DEFAULT_LOCAL_BUILDER_TESTS, BUILDER_TEST_DISPLAY_NAMES, resolveTarget, getProjectHealthUrl, extractHostname, extractPort, } from "./targets.js";
5
5
  export { isBrowser, isNode, getCheckTypeForTestId, isCheckAvailable, getUnavailabilityReason, } from "./environment.js";
6
6
  export { httpCheck } from "./checks/http-check.js";
7
7
  export type { HttpCheckOptions } from "./checks/http-check.js";
@@ -1,6 +1,6 @@
1
1
  export { runChecks } from "./run-checks.js";
2
2
  export { mapNodeErrorToConnectivityCode, mapHttpStatusToErrorCode, mapFetchErrorToConnectivityCode, connectivityErrorCodeToLikelyCause, mapConnectivityErrorMessage, SELF_SIGNED_CERT_ERRORS, CERT_EXPIRED_ERRORS, CERT_NOT_YET_VALID_ERRORS, CERT_INVALID_ERRORS, CERT_HOSTNAME_MISMATCH_ERRORS, SSL_PROTOCOL_ERRORS, SSL_HANDSHAKE_ERRORS, NETWORK_UNREACHABLE_ERRORS, TIMEOUT_ERRORS, PROXY_ERRORS, DNS_ERRORS, } from "./error-codes.js";
3
- export { BUILDER_TARGETS, DEFAULT_PORTS, DEFAULT_LOCAL_BUILDER_TESTS, BUILDER_TEST_DISPLAY_NAMES, resolveTarget, extractHostname, extractPort, } from "./targets.js";
3
+ export { BUILDER_TARGETS, DEFAULT_PORTS, DEFAULT_LOCAL_BUILDER_TESTS, BUILDER_TEST_DISPLAY_NAMES, resolveTarget, getProjectHealthUrl, extractHostname, extractPort, } from "./targets.js";
4
4
  export { isBrowser, isNode, getCheckTypeForTestId, isCheckAvailable, getUnavailabilityReason, } from "./environment.js";
5
5
  export { httpCheck } from "./checks/http-check.js";
6
6
  export { dnsCheck } from "./checks/dns-check.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, sshConnectFn, dnsResolver, } = input;
10
+ const { tests, gitHost, onProgress, fetchFn, dispatcher, connectFn, sshConnectFn, dnsResolver, projectUrl, projectHealthUrl, strictHttpStatus, } = 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, sshConnectFn, dnsResolver);
21
+ const result = await runSingleCheck(test, gitHost, fetchFn, dispatcher, connectFn, sshConnectFn, dnsResolver, { projectUrl, projectHealthUrl, strictHttpStatus });
22
22
  results.push(result);
23
23
  emitProgress(onProgress, {
24
24
  type: "test:complete",
@@ -37,14 +37,14 @@ export async function runChecks(input) {
37
37
  results,
38
38
  };
39
39
  }
40
- async function runSingleCheck(test, gitHost, fetchFn, dispatcher, connectFn, sshConnectFn, dnsResolver) {
40
+ async function runSingleCheck(test, gitHost, fetchFn, dispatcher, connectFn, sshConnectFn, dnsResolver, extras) {
41
41
  const { source, testId } = test;
42
42
  const checkType = getCheckTypeForTestId(testId);
43
43
  if (!isCheckAvailable(checkType)) {
44
44
  const startTime = Date.now();
45
45
  let target;
46
46
  try {
47
- target = resolveTarget(testId, gitHost);
47
+ target = resolveTarget(testId, gitHost, extras);
48
48
  }
49
49
  catch (_a) {
50
50
  target = gitHost || testId;
@@ -64,7 +64,7 @@ async function runSingleCheck(test, gitHost, fetchFn, dispatcher, connectFn, ssh
64
64
  }
65
65
  let target;
66
66
  try {
67
- target = resolveTarget(testId, gitHost);
67
+ target = resolveTarget(testId, gitHost, extras);
68
68
  }
69
69
  catch (error) {
70
70
  return {
@@ -81,7 +81,14 @@ async function runSingleCheck(test, gitHost, fetchFn, dispatcher, connectFn, ssh
81
81
  }
82
82
  switch (checkType) {
83
83
  case "http":
84
- return httpCheck({ target, source, testId, fetchFn, dispatcher });
84
+ return httpCheck({
85
+ target,
86
+ source,
87
+ testId,
88
+ fetchFn,
89
+ dispatcher,
90
+ strictHttpStatus: extras === null || extras === void 0 ? void 0 : extras.strictHttpStatus,
91
+ });
85
92
  case "websocket":
86
93
  return websocketCheck({ target, source, testId });
87
94
  case "dns":
@@ -12,8 +12,18 @@ export declare const DEFAULT_LOCAL_BUILDER_TESTS: Test[];
12
12
  * UI surfaces to show friendly labels instead of raw testId strings.
13
13
  */
14
14
  export declare const BUILDER_TEST_DISPLAY_NAMES: Partial<Record<TestId, string>>;
15
+ /**
16
+ * Given a full project URL (e.g. https://<id>-<branch>.builderio.dev/... ), return
17
+ * the matching health.builderio.* URL the project is routed through, or null when
18
+ * the host is not a known Builder kube domain.
19
+ */
20
+ export declare function getProjectHealthUrl(projectUrl: string): string | null;
15
21
  export declare const DEFAULT_PORTS: Record<string, number>;
16
- export declare function resolveTarget(testId: TestId, gitHost?: string): string;
22
+ export interface ResolveTargetExtras {
23
+ projectUrl?: string;
24
+ projectHealthUrl?: string;
25
+ }
26
+ export declare function resolveTarget(testId: TestId, gitHost?: string, extras?: ResolveTargetExtras): string;
17
27
  export declare function extractHostname(target: string): string;
18
28
  /**
19
29
  * Extract only an explicitly specified port from a URL, ignoring protocol-specific defaults.
@@ -37,7 +37,28 @@ export const BUILDER_TEST_DISPLAY_NAMES = {
37
37
  "builderio.xyz:ws": "*.builderio.xyz (WebSocket)",
38
38
  "builderio.dev": "*.builderio.dev",
39
39
  "builderio.dev:ws": "*.builderio.dev (WebSocket)",
40
+ "project:dns": "Project URL (DNS)",
41
+ "project:tcp": "Project URL (TCP)",
42
+ "project:tls": "Project URL (TLS)",
43
+ "project:http": "Project URL (HTTP)",
44
+ "project-health:dns": "Health domain (DNS)",
45
+ "project-health:tcp": "Health domain (TCP)",
46
+ "project-health:tls": "Health domain (TLS)",
47
+ "project-health:http": "Health domain (HTTP)",
40
48
  };
49
+ /**
50
+ * Given a full project URL (e.g. https://<id>-<branch>.builderio.dev/... ), return
51
+ * the matching health.builderio.* URL the project is routed through, or null when
52
+ * the host is not a known Builder kube domain.
53
+ */
54
+ export function getProjectHealthUrl(projectUrl) {
55
+ const host = extractHostname(projectUrl);
56
+ if (host.endsWith("builderio.xyz"))
57
+ return BUILDER_TARGETS["builderio.xyz"];
58
+ if (host.endsWith("builderio.dev"))
59
+ return BUILDER_TARGETS["builderio.dev"];
60
+ return null;
61
+ }
41
62
  export const DEFAULT_PORTS = {
42
63
  http: 443,
43
64
  https: 443,
@@ -45,13 +66,25 @@ export const DEFAULT_PORTS = {
45
66
  tcp: 443,
46
67
  tls: 443,
47
68
  };
48
- export function resolveTarget(testId, gitHost) {
69
+ export function resolveTarget(testId, gitHost, extras) {
49
70
  if (testId.startsWith("git-host:")) {
50
71
  if (!gitHost) {
51
72
  throw new Error(`gitHost parameter is required for test "${testId}"`);
52
73
  }
53
74
  return gitHost;
54
75
  }
76
+ if (testId.startsWith("project-health:")) {
77
+ if (!(extras === null || extras === void 0 ? void 0 : extras.projectHealthUrl)) {
78
+ throw new Error(`projectHealthUrl is required for test "${testId}"`);
79
+ }
80
+ return extras.projectHealthUrl;
81
+ }
82
+ if (testId.startsWith("project:")) {
83
+ if (!(extras === null || extras === void 0 ? void 0 : extras.projectUrl)) {
84
+ throw new Error(`projectUrl is required for test "${testId}"`);
85
+ }
86
+ return extras.projectUrl;
87
+ }
55
88
  const target = BUILDER_TARGETS[testId];
56
89
  if (!target) {
57
90
  throw new Error(`Unknown testId: ${testId}`);
@@ -1,5 +1,6 @@
1
1
  import { describe, it, expect } from "vitest";
2
- import { extractPort, extractExplicitPort, BUILDER_TARGETS, DEFAULT_LOCAL_BUILDER_TESTS, BUILDER_TEST_DISPLAY_NAMES, } from "./targets";
2
+ import { extractPort, extractExplicitPort, BUILDER_TARGETS, DEFAULT_LOCAL_BUILDER_TESTS, BUILDER_TEST_DISPLAY_NAMES, resolveTarget, getProjectHealthUrl, } from "./targets";
3
+ import { getCheckTypeForTestId } from "./environment";
3
4
  describe("extractPort", () => {
4
5
  it("returns explicit port from URL", () => {
5
6
  expect(extractPort("https://git.amazon.com:2222", 443)).toBe(2222);
@@ -76,3 +77,52 @@ describe("BUILDER_TEST_DISPLAY_NAMES", () => {
76
77
  expect(BUILDER_TEST_DISPLAY_NAMES["builderio.dev:ws"]).toBe("*.builderio.dev (WebSocket)");
77
78
  });
78
79
  });
80
+ describe("resolveTarget — per-project tests", () => {
81
+ const PROJECT_URL = "https://9739e085f64844e09c9918ce06fa57f5-main.builderio.dev/_builder.io/api/status-v2";
82
+ const HEALTH_URL = "https://health.builderio.dev/health";
83
+ it("resolves project:* testIds to the provided projectUrl", () => {
84
+ for (const id of [
85
+ "project:dns",
86
+ "project:tcp",
87
+ "project:tls",
88
+ "project:http",
89
+ ]) {
90
+ expect(resolveTarget(id, undefined, { projectUrl: PROJECT_URL })).toBe(PROJECT_URL);
91
+ }
92
+ });
93
+ it("resolves project-health:* testIds to the provided projectHealthUrl", () => {
94
+ for (const id of [
95
+ "project-health:dns",
96
+ "project-health:tcp",
97
+ "project-health:tls",
98
+ "project-health:http",
99
+ ]) {
100
+ expect(resolveTarget(id, undefined, { projectHealthUrl: HEALTH_URL })).toBe(HEALTH_URL);
101
+ }
102
+ });
103
+ it("throws when the required project URL is missing", () => {
104
+ expect(() => resolveTarget("project:dns")).toThrow();
105
+ expect(() => resolveTarget("project-health:dns")).toThrow();
106
+ });
107
+ });
108
+ describe("getProjectHealthUrl", () => {
109
+ it("maps a .dev project URL to the .dev health endpoint", () => {
110
+ expect(getProjectHealthUrl("https://abc-main.builderio.dev/_builder.io/api/status-v2")).toBe("https://health.builderio.dev/health");
111
+ });
112
+ it("maps a .xyz project URL to the .xyz health endpoint", () => {
113
+ expect(getProjectHealthUrl("https://abc-main.builderio.xyz")).toBe("https://health.builderio.xyz/health");
114
+ });
115
+ it("returns null for a non-kube domain", () => {
116
+ expect(getProjectHealthUrl("https://example.com/foo")).toBeNull();
117
+ });
118
+ });
119
+ describe("getCheckTypeForTestId — per-project tests", () => {
120
+ it("derives the check type from the project testId suffix", () => {
121
+ expect(getCheckTypeForTestId("project:dns")).toBe("dns");
122
+ expect(getCheckTypeForTestId("project:tcp")).toBe("tcp");
123
+ expect(getCheckTypeForTestId("project:tls")).toBe("tls");
124
+ expect(getCheckTypeForTestId("project:http")).toBe("http");
125
+ expect(getCheckTypeForTestId("project-health:dns")).toBe("dns");
126
+ expect(getCheckTypeForTestId("project-health:tls")).toBe("tls");
127
+ });
128
+ });
@@ -1,5 +1,5 @@
1
1
  export type Source = "local" | "cloud" | "static-ip" | "vpc";
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";
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" | "project:dns" | "project:tcp" | "project:tls" | "project:http" | "project-health:dns" | "project-health:tcp" | "project-health:tls" | "project-health:http";
3
3
  export interface Test {
4
4
  source: Source;
5
5
  testId: TestId;
@@ -25,6 +25,12 @@ export interface RunChecksInput {
25
25
  * Typically only needed server-side for static IP routing.
26
26
  */
27
27
  dispatcher?: object;
28
+ /**
29
+ * Treat 4xx HTTP responses as failures (not just 5xx). Used by
30
+ * `doctor --browser` so a proxy block page (e.g. a 403 from Zscaler) counts
31
+ * as a failure instead of a reachable-server pass.
32
+ */
33
+ strictHttpStatus?: boolean;
28
34
  /**
29
35
  * Returns a connected socket tunneled through a proxy (via HTTP CONNECT
30
36
  * or SOCKS5). Used by TCP and TLS checks for static IP / VPC routing. The
@@ -45,6 +51,16 @@ export interface RunChecksInput {
45
51
  dnsResolver?: {
46
52
  servers: string[];
47
53
  };
54
+ /**
55
+ * Full URL of a specific project (e.g. a project's status-v2 endpoint) that the
56
+ * `project:*` tests probe. Resolved by resolveTarget for those testIds.
57
+ */
58
+ projectUrl?: string;
59
+ /**
60
+ * URL of the health.builderio.* domain the project is routed through, probed by
61
+ * the `project-health:*` tests. Derived from projectUrl's kube domain.
62
+ */
63
+ projectHealthUrl?: string;
48
64
  }
49
65
  export type ProgressEvent = {
50
66
  type: "test:start";