Public: anyone can read this; the crew can change it.

The forge serves its front door even when the terminal's port is taken #470

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/77-the-forge-serves-its-front-door-even-whe → main 1 commit fork

  • Merged. scout/77-the-forge-serves-its-front-door-even-whe was merged into main as b0bbef0.

Review

Sign in to review this change.
4 files changed +100 −4
README.md +1 −1
@@ -31,7 +31,7 @@ feeling on a foundation sized for one person.
3131
3232 ```sh
3333 bun install # dev-only: types + tsc, no runtime deps
34 bun run src/main.ts # web on :3000, ssh on :2222
34 bun run src/main.ts # web on :3000, ssh on :2222 (FORGE_SSH_PORT=0 turns it off; a taken port turns it off with one warning)
3535 ```
3636
3737 If your shell already has `FORGE_URL` set — a crew workspace sets it to the
src/main.ts +10 −3
@@ -11,7 +11,7 @@ import { runDelivery } from "./core/webhooks.ts";
1111 import { isHookName, runHookClient } from "./git/hooks.ts";
1212 import { serve } from "./http/server.ts";
1313 import { runSync } from "./origins/sync.ts";
14 import { serveForgeSsh } from "./tui/ssh.ts";
14 import { startForgeSsh } from "./tui/ssh.ts";
1515 import { AppsRuntime } from "./apps/runtime.ts";
1616 import { resolveAppDomain } from "./apps/host.ts";
1717 import { startChecks } from "./checks/service.ts";
@@ -101,8 +101,15 @@ const app = createApp({
101101
102102 await reconcile(app);
103103 const servers = await serve(app);
104 const ssh =
105 sshPort > 0 ? await serveForgeSsh(app, { port: sshPort }) : undefined;
104 const sshStarted =
105 sshPort > 0 ? await startForgeSsh(app, { port: sshPort }) : undefined;
106 if (sshStarted && !sshStarted.ok)
107 app.log.warn("ssh is off: the terminal could not start", {
108 port: sshPort,
109 reason: sshStarted.error.message,
110 code: (sshStarted.error as { code?: string }).code,
111 });
112 const ssh = sshStarted?.ok ? sshStarted.value : undefined;
106113
107114 // Apps and the crew (docs/APPS.md). Both are on unless FORGE_APPS=0: an app
108115 // is served by Host on the same port, and the crew runs agents as child
src/tui/ssh.ts +17
@@ -20,6 +20,7 @@ import { hookEnv } from "../git/hooks.ts";
2020 import { repoDir } from "../git/repos.ts";
2121 import { parseGitCommand, runGitService } from "../git/smart-ssh.ts";
2222 import { serveSsh, type SshServer, type SshSession } from "../ssh/server.ts";
23 import { err, ok, type Result } from "../kernel/result.ts";
2324 import { createForgeApp } from "./forge.ts";
2425 import { appForLogin, attachAppTty } from "../apps/tty.ts";
2526 import type { Terminal } from "./term/terminal.ts";
@@ -278,3 +279,19 @@ export async function serveForgeSsh(
278279 forge.config.sshHostKey = server.hostKey.fingerprint;
279280 return server;
280281 }
282
283 /**
284 * `serveForgeSsh` as a value: the terminal is optional, the forge is not. A
285 * port that will not bind (or any other failure starting the listener) comes
286 * back as an Err the boot logs once, and the web keeps serving.
287 */
288 export async function startForgeSsh(
289 forge: Forge,
290 options: ForgeSshOptions,
291 ): Promise<Result<SshServer, Error>> {
292 try {
293 return ok(await serveForgeSsh(forge, options));
294 } catch (cause) {
295 return err(cause instanceof Error ? cause : new Error(String(cause)));
296 }
297 }
test/ssh-port-taken.test.ts added +72
@@ -0,0 +1,72 @@
1 /**
2 * The terminal is optional, the forge is not: a taken SSH port turns the
3 * terminal off with one warning and the web keeps answering.
4 */
5
6 import { expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { createApp } from "../src/app.ts";
11 import { startForgeSsh } from "../src/tui/ssh.ts";
12
13 test("startForgeSsh returns an Err instead of throwing when the port is taken", async () => {
14 const dataDir = await mkdtemp(join(tmpdir(), "forge-sshtaken-"));
15 const squatter = Bun.listen({
16 hostname: "127.0.0.1",
17 port: 0,
18 socket: { data() {} },
19 });
20 const forge = createApp({ dataDir, port: 0 });
21 try {
22 const r = await startForgeSsh(forge, {
23 port: squatter.port,
24 hostname: "127.0.0.1",
25 });
26 expect(r.ok).toBe(false);
27 expect(forge.config.sshPort).toBeUndefined();
28 } finally {
29 squatter.stop(true);
30 forge.close();
31 await rm(dataDir, { recursive: true, force: true });
32 }
33 });
34
35 test("main.ts keeps serving / when the SSH port is taken", async () => {
36 const dataDir = await mkdtemp(join(tmpdir(), "forge-sshboot-"));
37 const squatter = Bun.listen({
38 hostname: "0.0.0.0",
39 port: 0,
40 socket: { data() {} },
41 });
42 const web = 20000 + Math.floor(Math.random() * 20000);
43 const env: Record<string, string> = {
44 ...(process.env as Record<string, string>),
45 FORGE_DATA: dataDir,
46 PORT: String(web),
47 FORGE_SSH_PORT: String(squatter.port),
48 FORGE_APPS: "0",
49 };
50 delete env.FORGE_URL;
51 const proc = Bun.spawn(["bun", "run", "src/main.ts"], {
52 env,
53 stdout: "pipe",
54 stderr: "pipe",
55 });
56 try {
57 let status = 0;
58 for (let i = 0; i < 100 && status === 0; i++) {
59 try {
60 status = (await fetch(`http://127.0.0.1:${web}/`)).status;
61 } catch {
62 await Bun.sleep(100);
63 }
64 }
65 expect(status).toBe(200);
66 } finally {
67 proc.kill();
68 await proc.exited;
69 squatter.stop(true);
70 await rm(dataDir, { recursive: true, force: true });
71 }
72 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs