Skip to content

Commit e313cd1

Browse files
committed
fix: handle browse install resolution and CDN diagnostics
1 parent 99ad71b commit e313cd1

5 files changed

Lines changed: 168 additions & 10 deletions

File tree

setup.sh

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -87,8 +87,10 @@ echo " ✓ dist/browse ($OS-$ARCH_LABEL)"
8787
echo ""
8888
echo "[5/5] Creating symlink..."
8989

90-
# Target: ~/.local/bin/browse
91-
SYMLINK_DIR="$HOME/.local/bin"
90+
# Target: ~/.local/bin/browse by default.
91+
# Override with INSTALL_DIR=/usr/local/bin ./setup.sh when a system PATH entry
92+
# should own the command name.
93+
SYMLINK_DIR="${INSTALL_DIR:-$HOME/.local/bin}"
9294
SYMLINK_PATH="$SYMLINK_DIR/browse"
9395

9496
mkdir -p "$SYMLINK_DIR"
@@ -101,11 +103,25 @@ ln -s "$SCRIPT_DIR/dist/browse" "$SYMLINK_PATH"
101103
echo "$SYMLINK_PATH$SCRIPT_DIR/dist/browse"
102104

103105
if ! echo "$PATH" | tr ':' '\n' | grep -q "$SYMLINK_DIR"; then
104-
echo ""
105-
echo "Note: ~/.local/bin is not on your PATH. Add it with:"
106-
echo " export PATH=\"\$HOME/.local/bin:\$PATH\""
107-
echo "Add this to your shell profile (~/.zshrc or ~/.bashrc) to make it permanent."
106+
echo ""
107+
echo "Warning: $SYMLINK_DIR is not on your PATH. Add it with:"
108+
echo " export PATH=\"$SYMLINK_DIR:\$PATH\""
109+
echo "Add this to your shell profile (~/.zshrc or ~/.bashrc) to make it permanent."
110+
fi
111+
112+
RESOLVED_BROWSE="$(command -v browse || true)"
113+
if [ -n "$RESOLVED_BROWSE" ] && [ "$RESOLVED_BROWSE" != "$SYMLINK_PATH" ]; then
114+
echo ""
115+
echo "Warning: 'browse' currently resolves to:"
116+
echo " $RESOLVED_BROWSE"
117+
echo "not the installed CLI:"
118+
echo " $SYMLINK_PATH"
119+
echo "Run '$SYMLINK_PATH ...', move $SYMLINK_DIR earlier in PATH, or reinstall with INSTALL_DIR set to an earlier PATH directory."
120+
elif [ -z "$RESOLVED_BROWSE" ]; then
121+
echo ""
122+
echo "Warning: 'browse' is not currently resolvable from PATH."
123+
echo "Run '$SYMLINK_PATH ...' or add $SYMLINK_DIR to PATH."
108124
fi
109125

110126
echo ""
111-
echo "Setup complete. Run 'browse goto https://example.com' to get started."
127+
echo "Setup complete. Run '$SYMLINK_PATH goto https://example.com' to get started."

src/cli.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,10 @@ function resolveBrowserFromFlag(flag?: string): BrowserName | undefined {
8787
return undefined;
8888
}
8989

90+
function looksLikeUrl(value: string): boolean {
91+
return /^(?:https?:\/\/|file:\/\/|about:)/i.test(value);
92+
}
93+
9094
export type ParsedArgs =
9195
| {
9296
cmd: string;
@@ -207,7 +211,10 @@ export function parseArgs(argv: string[]): ParsedArgs {
207211
if (remaining.length === 0)
208212
return { cmd: "help", args: [], timeout, session, json, config };
209213

210-
const [cmd, ...args] = remaining;
214+
const [rawCmd, ...rawArgs] = remaining;
215+
const [cmd, args] = looksLikeUrl(rawCmd)
216+
? ["goto", [rawCmd, ...rawArgs]]
217+
: [rawCmd, rawArgs];
211218

212219
return { cmd: cmd as string, args, timeout, session, json, config };
213220
}

src/commands/goto.ts

Lines changed: 93 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
import { devices, type Page } from "playwright";
1+
import {
2+
devices,
3+
type Page,
4+
type Response as PlaywrightResponse,
5+
} from "playwright";
26
import type { Response } from "../protocol.ts";
37
import { handleSnapshot } from "./snapshot.ts";
48
import { PRESETS, type ViewportParsedArgs } from "./viewport.ts";
@@ -102,6 +106,79 @@ function parseGotoArgs(args: string[]): {
102106
return { url, viewport: null };
103107
}
104108

109+
function trimSnippet(text: string): string {
110+
return text.replace(/\s+/g, " ").trim().slice(0, 500);
111+
}
112+
113+
function headerValue(
114+
headers: Record<string, string>,
115+
name: string,
116+
): string | undefined {
117+
return headers[name] ?? headers[name.toLowerCase()];
118+
}
119+
120+
async function getBodySnippet(page: Page): Promise<string> {
121+
try {
122+
return trimSnippet(
123+
await page.locator("body").innerText({ timeout: 2_000 }),
124+
);
125+
} catch {
126+
return "";
127+
}
128+
}
129+
130+
function looksLikeCdnAccessBlock(
131+
status: number,
132+
headers: Record<string, string>,
133+
title: string,
134+
bodySnippet: string,
135+
): boolean {
136+
if (status !== 403) return false;
137+
138+
const server = headerValue(headers, "server")?.toLowerCase() ?? "";
139+
const combined = `${title}\n${bodySnippet}`.toLowerCase();
140+
141+
return (
142+
server.includes("akamaighost") ||
143+
server.includes("akamai") ||
144+
(combined.includes("access denied") &&
145+
(combined.includes("edgesuite") || combined.includes("permission")))
146+
);
147+
}
148+
149+
async function cdnBlockDiagnostic(
150+
page: Page,
151+
response: PlaywrightResponse,
152+
title: string,
153+
): Promise<string | null> {
154+
const status = response.status();
155+
const headers = response.headers();
156+
const bodySnippet = await getBodySnippet(page);
157+
158+
if (!looksLikeCdnAccessBlock(status, headers, title, bodySnippet)) {
159+
return null;
160+
}
161+
162+
const server = headerValue(headers, "server") ?? "unknown";
163+
const finalUrl = response.url();
164+
const lines = [
165+
`Navigation blocked by CDN/access controls (${status}).`,
166+
`URL: ${finalUrl}`,
167+
`Server: ${server}`,
168+
`Title: ${title || "untitled"}`,
169+
];
170+
171+
if (bodySnippet) {
172+
lines.push(`Body: ${bodySnippet}`);
173+
}
174+
175+
lines.push(
176+
"Try a real browser session, configured proxy, or verify that the installed 'browse' binary is the project CLI with 'browse version'.",
177+
);
178+
179+
return lines.join("\n");
180+
}
181+
105182
export async function handleGoto(
106183
page: Page,
107184
args: string[],
@@ -126,7 +203,10 @@ export async function handleGoto(
126203
});
127204
}
128205

129-
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30_000 });
206+
const navigationResponse = await page.goto(url, {
207+
waitUntil: "domcontentloaded",
208+
timeout: 30_000,
209+
});
130210

131211
// Inject stealth patches to fix CreepJS detection.
132212
// This runs on every navigation since addInitScript only affects new pages.
@@ -169,6 +249,17 @@ export async function handleGoto(
169249

170250
const title = await page.title();
171251

252+
if (navigationResponse) {
253+
const blockDiagnostic = await cdnBlockDiagnostic(
254+
page,
255+
navigationResponse,
256+
title,
257+
);
258+
if (blockDiagnostic) {
259+
return { ok: false, error: blockDiagnostic };
260+
}
261+
}
262+
172263
let result: string;
173264
if (viewport && viewport.action === "set") {
174265
const suffix = viewport.label ? ` (${viewport.label})` : "";

test/cli.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,17 @@ describe("parseArgs", () => {
2020
});
2121
});
2222

23+
test("treats a bare URL as goto shorthand", () => {
24+
const result = parseArgs(["https://example.com", "--timeout", "60000"]);
25+
expect(result).toEqual({
26+
cmd: "goto",
27+
args: ["https://example.com"],
28+
timeout: 60000,
29+
session: undefined,
30+
json: false,
31+
});
32+
});
33+
2334
test("parses text command with no args", () => {
2435
const result = parseArgs(["text"]);
2536
expect(result).toEqual({

test/commands.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,39 @@ describe("handleGoto", () => {
4141
expect(result.error).toContain("net::ERR_NAME_NOT_RESOLVED");
4242
}
4343
});
44+
45+
test("returns diagnostic for Akamai access denied pages", async () => {
46+
const page = mockPage({
47+
goto: mock(() =>
48+
Promise.resolve({
49+
status: () => 403,
50+
headers: () => ({ server: "AkamaiGHost" }),
51+
url: () => "https://www.war.gov/UFO/",
52+
}),
53+
),
54+
title: mock(() => Promise.resolve("Access Denied")),
55+
locator: mock(() => ({
56+
innerText: mock(() =>
57+
Promise.resolve(
58+
'Access Denied You do not have permission to access "https://www.war.gov/UFO/" on this server.',
59+
),
60+
),
61+
})),
62+
});
63+
64+
const result = await handleGoto(page as never, [
65+
"https://www.war.gov/UFO/",
66+
]);
67+
68+
expect(result.ok).toBe(false);
69+
if (!result.ok) {
70+
expect(result.error).toContain(
71+
"Navigation blocked by CDN/access controls",
72+
);
73+
expect(result.error).toContain("Server: AkamaiGHost");
74+
expect(result.error).toContain("browse version");
75+
}
76+
});
4477
});
4578

4679
describe("handleText", () => {

0 commit comments

Comments
 (0)