Skip to content

Commit efb1330

Browse files
committed
feat(inspector): let run scripts declare their Open menu URL
Helmor derives the Run tab's Open menu by regex-sniffing stdout for `http://{localhost,127.0.0.1,0.0.0.0}:PORT` banners. That breaks under a reverse proxy: with portless, Caddy, ngrok, or Tailscale Funnel the services behind the proxy print ephemeral ports that are neither reachable nor stable across boots, while the address users actually need is a named domain nobody prints in a recognizable banner. Run scripts can now declare it explicitly by printing a marker line: echo "helmor:url=https://${HELMOR_WORKSPACE_NAME}.localhost" Declared URLs accept any host (not just the three local forms), and take precedence over sniffed ones rather than joining them — a script that declares its address is telling us the sniffed ports are wrong, not that they are extra options worth offering. Repeating the marker declares multiple URLs, which surface in the existing 2+ picker. Detection waits for the terminating newline before committing a match, since PTY output splits on arbitrary 4096-byte boundaries and a marker truncated mid-URL still parses as a well-formed one. No backend change: HELMOR_WORKSPACE_NAME is already exported into the script environment, so per-workspace interpolation works with no schema column and no new CLI flag.
1 parent 4f3e13d commit efb1330

6 files changed

Lines changed: 277 additions & 7 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"helmor": patch
3+
---
4+
5+
Let run scripts declare their own URL for the Open menu by printing `helmor:url=<URL>` on a line of their own — useful for reverse-proxy dev setups (portless, Caddy, ngrok, Tailscale Funnel) where Helmor's sniffed `localhost:PORT` banners are ephemeral ports that aren't reachable through the proxy. Declared URLs accept any host and fully replace the sniffed ones.

src/features/inspector/detect-urls.test.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import { describe, expect, it } from "vitest";
22
import {
33
dedupUrlKey,
4+
extractDeclaredUrls,
45
extractLocalUrls,
56
extractPort,
67
stripAnsi,
8+
trailingPartialLine,
79
} from "./detect-urls";
810

911
describe("stripAnsi", () => {
@@ -169,3 +171,73 @@ describe("extractPort", () => {
169171
expect(extractPort("https://localhost")).toBeNull();
170172
});
171173
});
174+
175+
describe("extractDeclaredUrls", () => {
176+
it("extracts a marker URL with a named host and no port", () => {
177+
expect(
178+
extractDeclaredUrls("helmor:url=https://achernar.localhost"),
179+
).toEqual(["https://achernar.localhost"]);
180+
});
181+
182+
it("survives ANSI wrapping and CRLF line endings", () => {
183+
expect(
184+
extractDeclaredUrls(
185+
"\x1b[32mhelmor:url=https://achernar.localhost\x1b[0m\r\nbooting…\r\n",
186+
),
187+
).toEqual(["https://achernar.localhost"]);
188+
});
189+
190+
it("tolerates leading indentation", () => {
191+
expect(extractDeclaredUrls(" \thelmor:url=http://app.test\n")).toEqual([
192+
"http://app.test",
193+
]);
194+
});
195+
196+
it("collects multiple declarations in order", () => {
197+
expect(
198+
extractDeclaredUrls(
199+
"helmor:url=https://web.localhost\nnoise\nhelmor:url=https://api.localhost\n",
200+
),
201+
).toEqual(["https://web.localhost", "https://api.localhost"]);
202+
});
203+
204+
it("ignores markers that are not at the start of a line", () => {
205+
expect(
206+
extractDeclaredUrls("see helmor:url=https://evil.test for details"),
207+
).toEqual([]);
208+
});
209+
210+
it("ignores non-http schemes", () => {
211+
expect(extractDeclaredUrls("helmor:url=ftp://nope.test\n")).toEqual([]);
212+
expect(extractDeclaredUrls("helmor:url=nonsense\n")).toEqual([]);
213+
});
214+
215+
it("strips trailing sentence punctuation", () => {
216+
expect(extractDeclaredUrls("helmor:url=https://app.test.\n")).toEqual([
217+
"https://app.test",
218+
]);
219+
});
220+
221+
it("is stateless across calls despite the shared global regex", () => {
222+
const input = "helmor:url=https://app.test\n";
223+
expect(extractDeclaredUrls(input)).toEqual(extractDeclaredUrls(input));
224+
});
225+
});
226+
227+
describe("trailingPartialLine", () => {
228+
it("returns text after the last newline", () => {
229+
expect(trailingPartialLine("a\nb\nhelmor:url=htt")).toBe("helmor:url=htt");
230+
});
231+
232+
it("returns the whole input when there is no newline", () => {
233+
expect(trailingPartialLine("partial")).toBe("partial");
234+
});
235+
236+
it("returns empty when the chunk ends on a newline", () => {
237+
expect(trailingPartialLine("done\n")).toBe("");
238+
});
239+
240+
it("caps runaway single-line output", () => {
241+
expect(trailingPartialLine("x".repeat(5000), 2048)).toHaveLength(2048);
242+
});
243+
});

src/features/inspector/detect-urls.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,62 @@ const ANSI_RE = new RegExp(
3131
const LOCAL_URL_RE =
3232
/\bhttps?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0)(?::\d+)?(?:\/[^\s"'`<>)\]]*)?/gi;
3333

34+
// Marker a run script prints to declare the URL Helmor should offer in the
35+
// Open menu. Anchored to the start of a line (leading whitespace allowed) so
36+
// it can't be triggered by a URL appearing mid-prose. The host part is
37+
// deliberately unrestricted — unlike LOCAL_URL_RE this must accept named
38+
// domains, which is the entire point of the feature.
39+
const DECLARED_URL_RE = /^[ \t]*helmor:url=(https?:\/\/[^\s"'`<>]+)/gim;
40+
3441
export function stripAnsi(input: string): string {
3542
return input.replace(ANSI_RE, "");
3643
}
3744

45+
/**
46+
* Extract URLs a run script has explicitly declared via `helmor:url=<URL>`
47+
* lines in its output.
48+
*
49+
* Sniffing `http://localhost:PORT` out of stdout works for plain dev servers
50+
* but breaks under a reverse proxy: with portless, Caddy, ngrok, or Tailscale
51+
* Funnel the services behind the proxy print ephemeral ports that are neither
52+
* reachable nor stable, while the address users actually need is a named
53+
* domain nobody prints in a recognizable banner. Declaring it explicitly is
54+
* the escape hatch:
55+
*
56+
* echo "helmor:url=https://${HELMOR_WORKSPACE_NAME}.localhost"
57+
*
58+
* Repeating the marker declares multiple URLs (e.g. web + api in a monorepo),
59+
* which surface in the Open menu's picker. Declared URLs take precedence over
60+
* sniffed ones — see the script store.
61+
*/
62+
export function extractDeclaredUrls(input: string): string[] {
63+
const clean = stripAnsi(input);
64+
const out: string[] = [];
65+
// Shared regex object with the `g` flag carries `lastIndex` between calls.
66+
DECLARED_URL_RE.lastIndex = 0;
67+
let match = DECLARED_URL_RE.exec(clean);
68+
while (match !== null) {
69+
out.push(match[1].replace(/[.,;:!?]+$/, ""));
70+
match = DECLARED_URL_RE.exec(clean);
71+
}
72+
return out;
73+
}
74+
75+
/**
76+
* Return the trailing partial line of a chunk — everything after the last
77+
* newline — capped so a pathological single-line stream (a progress bar
78+
* redrawing with `\r`) can't grow the carry buffer without bound.
79+
*
80+
* PTY output is split on arbitrary 4096-byte boundaries, so a marker line can
81+
* straddle two chunks. Carrying the partial line forward and re-scanning it
82+
* with the next chunk makes detection boundary-proof.
83+
*/
84+
export function trailingPartialLine(input: string, cap = 2048): string {
85+
const idx = input.lastIndexOf("\n");
86+
const tail = idx === -1 ? input : input.slice(idx + 1);
87+
return tail.length > cap ? tail.slice(-cap) : tail;
88+
}
89+
3890
/**
3991
* Extract normalized dev-server URLs from a chunk of shell output. Returns
4092
* URLs in the order they appear. Caller is responsible for deduping across

src/features/inspector/script-store.test.ts

Lines changed: 78 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,14 @@ vi.mock("@/lib/api", async (importOriginal) => {
2828
});
2929

3030
// Dynamic import so vi.mock is applied before module evaluation.
31-
const { _resetForTesting, getScriptState, startScript, TRUNCATION_NOTICE } =
32-
await import("./script-store");
31+
const {
32+
_resetForTesting,
33+
attach,
34+
effectiveScriptUrls,
35+
getScriptState,
36+
startScript,
37+
TRUNCATION_NOTICE,
38+
} = await import("./script-store");
3339

3440
// ── Helpers ──────────────────────────────────────────────────────────────────
3541

@@ -132,3 +138,73 @@ describe("script-store ring buffer", () => {
132138
expect(TRUNCATION_NOTICE).toContain("\x1b[0m");
133139
});
134140
});
141+
142+
describe("script-store URL detection", () => {
143+
it("sniffs localhost banners when nothing is declared", () => {
144+
const emit = startAndCapture();
145+
emit({ type: "stdout", data: " Local: http://localhost:5173/\n" });
146+
147+
const entry = getScriptState("ws1", "run");
148+
expect(effectiveScriptUrls(entry!)).toEqual(["http://localhost:5173/"]);
149+
});
150+
151+
it("lets a declared URL replace already-sniffed ephemeral ports", () => {
152+
const emit = startAndCapture();
153+
// A portless-style boot: proxied services announce raw ephemeral ports
154+
// that are not reachable through the proxy…
155+
emit({ type: "stdout", data: "web ready http://localhost:4761\n" });
156+
emit({ type: "stdout", data: "api ready http://localhost:4786\n" });
157+
const entry = getScriptState("ws1", "run");
158+
expect(effectiveScriptUrls(entry!)).toHaveLength(2);
159+
160+
// …then the run script declares the address that actually works.
161+
emit({ type: "stdout", data: "helmor:url=https://achernar.localhost\n" });
162+
163+
expect(effectiveScriptUrls(entry!)).toEqual(["https://achernar.localhost"]);
164+
});
165+
166+
it("stops collecting sniffed URLs once one is declared", () => {
167+
const emit = startAndCapture();
168+
emit({ type: "stdout", data: "helmor:url=https://achernar.localhost\n" });
169+
emit({ type: "stdout", data: "listening on http://localhost:4761\n" });
170+
171+
const entry = getScriptState("ws1", "run");
172+
expect(effectiveScriptUrls(entry!)).toEqual(["https://achernar.localhost"]);
173+
});
174+
175+
it("detects a marker split across PTY chunk boundaries", () => {
176+
const emit = startAndCapture();
177+
emit({ type: "stdout", data: "booting\nhelmor:url=https://ach" });
178+
emit({ type: "stdout", data: "ernar.localhost\nready\n" });
179+
180+
const entry = getScriptState("ws1", "run");
181+
expect(effectiveScriptUrls(entry!)).toEqual(["https://achernar.localhost"]);
182+
});
183+
184+
it("keeps multiple declared URLs in first-seen order without duplicates", () => {
185+
const emit = startAndCapture();
186+
emit({ type: "stdout", data: "helmor:url=https://web.localhost\n" });
187+
emit({ type: "stdout", data: "helmor:url=https://api.localhost\n" });
188+
emit({ type: "stdout", data: "helmor:url=https://web.localhost\n" });
189+
190+
const entry = getScriptState("ws1", "run");
191+
expect(effectiveScriptUrls(entry!)).toEqual([
192+
"https://web.localhost",
193+
"https://api.localhost",
194+
]);
195+
});
196+
197+
it("notifies listeners when a declaration supersedes sniffed URLs", () => {
198+
const seen: string[][] = [];
199+
const emit = startAndCapture();
200+
attach("ws1", "run", {
201+
onChunk: () => {},
202+
onStatusChange: () => {},
203+
onUrlsChange: (urls) => seen.push(urls),
204+
});
205+
emit({ type: "stdout", data: "http://localhost:4761\n" });
206+
emit({ type: "stdout", data: "helmor:url=https://achernar.localhost\n" });
207+
208+
expect(seen.at(-1)).toEqual(["https://achernar.localhost"]);
209+
});
210+
});

src/features/inspector/script-store.ts

Lines changed: 68 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,12 @@ import {
55
stopRepoScript,
66
writeRepoScriptStdin,
77
} from "@/lib/api";
8-
import { dedupUrlKey, extractLocalUrls } from "./detect-urls";
8+
import {
9+
dedupUrlKey,
10+
extractDeclaredUrls,
11+
extractLocalUrls,
12+
trailingPartialLine,
13+
} from "./detect-urls";
914

1015
export type ScriptStatus = "idle" | "running" | "exited";
1116

@@ -52,8 +57,29 @@ export type ScriptEntry = {
5257
* as new chunks arrive. Empty when the script hasn't printed any banner.
5358
*/
5459
urls: string[];
60+
/**
61+
* URLs the script explicitly declared via `helmor:url=<URL>` lines. When
62+
* non-empty these fully replace {@link urls} in the Open menu — a script
63+
* that declares its address is telling us the sniffed localhost ports are
64+
* wrong (typically ephemeral ports behind a reverse proxy), not that they
65+
* are extra options worth offering.
66+
*/
67+
declaredUrls: string[];
68+
/**
69+
* Trailing partial line carried over from the previous chunk, so marker
70+
* lines split across PTY chunk boundaries are still detected.
71+
*/
72+
tail: string;
5573
};
5674

75+
/**
76+
* URLs to surface in the Open menu: explicitly declared ones win over sniffed
77+
* localhost banners.
78+
*/
79+
export function effectiveScriptUrls(entry: ScriptEntry): string[] {
80+
return entry.declaredUrls.length > 0 ? entry.declaredUrls : entry.urls;
81+
}
82+
5783
/** Append a chunk and evict from the head until under the byte cap. */
5884
function appendChunk(entry: ScriptEntry, data: string) {
5985
entry.chunks.push(data);
@@ -110,6 +136,8 @@ export function startScript(
110136
status: "running",
111137
exitCode: null,
112138
urls: [],
139+
declaredUrls: [],
140+
tail: "",
113141
};
114142
entries.set(k, entry);
115143

@@ -137,15 +165,51 @@ export function startScript(
137165
// no URL. Skip the regex work when the chunk can't possibly
138166
// contain one. `event.data.includes("http")` is a plain
139167
// substring scan — ~100x faster than the ANSI+URL regex
140-
// combo and totally safe (any real localhost URL has "http"
141-
// verbatim in bytes, even when wrapped in ANSI).
168+
// combo and totally safe (any real URL, sniffed or declared,
169+
// has "http" verbatim in bytes, even when wrapped in ANSI).
142170
//
143171
// We still run detection on every chunk until we've seen at
144172
// least one URL, so the initial banner is never missed.
145-
if (entry.urls.length > 0 && !event.data.includes("http")) {
173+
if (
174+
entry.urls.length + entry.declaredUrls.length > 0 &&
175+
!event.data.includes("http")
176+
) {
177+
entry.tail = trailingPartialLine(entry.tail + event.data);
146178
break;
147179
}
148180

181+
// Rejoin the previous chunk's trailing partial line with the new
182+
// data, then consider only the newline-terminated portion. A
183+
// marker split across a PTY chunk boundary would otherwise be
184+
// committed truncated — `helmor:url=https://ach` is a perfectly
185+
// well-formed URL as far as the regex is concerned, so waiting
186+
// for the terminating newline is the only safe signal that we
187+
// have the whole thing.
188+
const scan = entry.tail + event.data;
189+
const lastNewline = scan.lastIndexOf("\n");
190+
const completeLines =
191+
lastNewline === -1 ? "" : scan.slice(0, lastNewline + 1);
192+
entry.tail = trailingPartialLine(scan);
193+
194+
// Explicit `helmor:url=` declarations win outright: once a
195+
// script has told us its address, sniffed localhost banners are
196+
// noise (ephemeral ports behind a reverse proxy) and we stop
197+
// collecting them entirely.
198+
const declared = extractDeclaredUrls(completeLines);
199+
if (declared.length > 0) {
200+
let changed = false;
201+
for (const url of declared) {
202+
if (!entry.declaredUrls.includes(url)) {
203+
entry.declaredUrls.push(url);
204+
changed = true;
205+
}
206+
}
207+
if (changed) {
208+
listeners.get(k)?.onUrlsChange?.([...entry.declaredUrls]);
209+
}
210+
}
211+
if (entry.declaredUrls.length > 0) break;
212+
149213
// Scan the fresh chunk for dev-server URLs. We keep a deduped,
150214
// first-seen-ordered list on the entry and only fire the listener
151215
// when something actually changed.

src/features/inspector/sections/run.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { TABS_EASING, TABS_HOVER_TRANSITION_MS, useTabsZoom } from "../layout";
2020
import {
2121
attach,
2222
detach,
23+
effectiveScriptUrls,
2324
resizeScript,
2425
type ScriptStatus,
2526
startScript,
@@ -182,7 +183,7 @@ export function RunTab({
182183
setStatus(existing.status);
183184
// Replay URLs already detected on this entry so the parent's state
184185
// mirrors the store the moment the component mounts.
185-
onUrlsChange?.([...existing.urls]);
186+
onUrlsChange?.(effectiveScriptUrls(existing));
186187
const replay = () => {
187188
const t = termRef.current;
188189
if (!t) return;

0 commit comments

Comments
 (0)