Public: anyone can read this; the crew can change it.
Every app on a hosted forge says “The terminal is off.” and never why or what turns it on #318
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/283-every-app-on-a-hosted-forge-says-the-ter → main 1 commit fork
4 files
changed +56
−13
src/actions/apps.ts +13 −8
| @@ -44,6 +44,7 @@ import type { Run } from "../core/runs.ts"; | ||
| 44 | 44 | import type { User } from "../core/users.ts"; |
| 45 | 45 | import { resolveOid } from "../git/merge.ts"; |
| 46 | 46 | import { repoDir } from "../git/repos.ts"; |
| 47 | import { sshCommand } from "../ssh/command.ts"; | |
| 47 | 48 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 48 | 49 | import { |
| 49 | 50 | requireReader, |
| @@ -378,20 +379,24 @@ export function sshCommandFor( | ||
| 378 | 379 | } |
| 379 | 380 | |
| 380 | 381 | /** |
| 381 | * The one fact behind every "Terminal" line a page draws: the exact command, | |
| 382 | * or undefined when there is nothing to show — SSH is off on this forge, or | |
| 383 | * this environment's app declares no `tty`. A status that has never been | |
| 384 | * live does not know its `tty` yet, so the command still shows; the | |
| 385 | * environment's own state already says it is not running. | |
| 382 | * The one fact behind every "Terminal" line a page draws: the exact | |
| 383 | * command, or — when there is nothing to show — why: this environment's | |
| 384 | * app declares no `tty`, or this forge itself has no SSH listener | |
| 385 | * (`app.config.sshPort`, `0` on a forge hosted as an app of another — | |
| 386 | * issue #283). A status that has never been live does not know its `tty` | |
| 387 | * yet, so the command still shows when SSH is on; the environment's own | |
| 388 | * state already says it is not running. | |
| 386 | 389 | */ |
| 387 | 390 | export function terminalFor( |
| 388 | 391 | app: App, |
| 389 | 392 | record: AppRecord, |
| 390 | 393 | env: EnvName, |
| 391 | 394 | status: EnvStatus | undefined, |
| 392 | ): string | undefined { | |
| 393 | if (status?.tty === null) return undefined; | |
| 394 | return sshCommandFor(app, record, env); | |
| 395 | ): { readonly command?: string; readonly reason?: "no_tty" | "no_listener" } { | |
| 396 | if (status?.tty === null) return { reason: "no_tty" }; | |
| 397 | const command = sshCommandFor(app, record, env); | |
| 398 | if (command !== undefined) return { command }; | |
| 399 | return sshCommand(app) === undefined ? { reason: "no_listener" } : {}; | |
| 395 | 400 | } |
| 396 | 401 | |
| 397 | 402 | /** What is running, for anyone who can see the repository. */ |
src/web/pages/apps.ts +1 −1
| @@ -637,7 +637,7 @@ export function appPage(ctx: Ctx): Response { | ||
| 637 | 637 | const production = envs.find((env) => env.env === "production"); |
| 638 | 638 | const ssh = writer |
| 639 | 639 | ? terminalFor(ctx.app, record, "production", production) |
| 640 | : undefined; | |
| 640 | : {}; | |
| 641 | 641 | // The same rows the runs page draws, from the same two grouped reads: a |
| 642 | 642 | // table's first column folds into a sliver at phone width, a row does not. |
| 643 | 643 | const runsHead = html`${icon("activity")} |
src/web/ui.ts +20 −4
| @@ -418,6 +418,14 @@ export function previewDataNote( | ||
| 418 | 418 | * show. Every page that names a way in draws this one, never its own words |
| 419 | 419 | * for "off". |
| 420 | 420 | * |
| 421 | * "Off" has two causes and a reader deserves to know which: the app | |
| 422 | * declares no `tty` (nothing would answer that connection even if one were | |
| 423 | * open — the sentence stays exactly what it always said), or this forge has | |
| 424 | * no SSH listener (`FORGE_SSH_PORT`, `0` on a forge hosted as an app of | |
| 425 | * another — issue #283) and the app is otherwise willing. The second names | |
| 426 | * the variable that turns it on; neither ever invents a connection string — | |
| 427 | * a command is shown only once there is a listener to reach. | |
| 428 | * | |
| 421 | 429 | * The command has a `login@host` in it, which an email-scanning edge on the |
| 422 | 430 | * forge's own domain will otherwise rewrite into a `mailto:` link — turning |
| 423 | 431 | * the one line a reader must copy character for character into a link that |
| @@ -427,13 +435,21 @@ export function previewDataNote( | ||
| 427 | 435 | * copyable line. |
| 428 | 436 | */ |
| 429 | 437 | export function terminalLine( |
| 430 | command: string | undefined, | |
| 438 | terminal: { | |
| 439 | readonly command?: string; | |
| 440 | readonly reason?: "no_tty" | "no_listener"; | |
| 441 | }, | |
| 431 | 442 | fingerprint?: string | undefined, |
| 432 | 443 | ): Html { |
| 433 | if (command === undefined) | |
| 434 | return html`<p class="muted small">The terminal is off.</p>`; | |
| 444 | if (terminal.command === undefined) | |
| 445 | return terminal.reason === "no_listener" | |
| 446 | ? html`<p class="muted small"> | |
| 447 | The terminal is off — this forge has no SSH listener. Set | |
| 448 | <span class="mono">FORGE_SSH_PORT</span> to turn it on. | |
| 449 | </p>` | |
| 450 | : html`<p class="muted small">The terminal is off.</p>`; | |
| 435 | 451 | return html`<p class="terminal-line"> |
| 436 | <code class="mono"><!--email_off-->${command}<!--/email_off--></code> | |
| 452 | <code class="mono"><!--email_off-->${terminal.command}<!--/email_off--></code> | |
| 437 | 453 | ${when( |
| 438 | 454 | fingerprint, |
| 439 | 455 | (fp) => html`<span class="muted small">host key ${fp}</span>`, |
test/studio.test.ts +22
| @@ -2888,6 +2888,28 @@ describe("the studio app page's terminal", () => { | ||
| 2888 | 2888 | const theirs = await appPage(reader); |
| 2889 | 2889 | expect(theirs).not.toContain("The terminal is off"); |
| 2890 | 2890 | }); |
| 2891 | ||
| 2892 | // Issue #283: a hosted forge (an app of another forge) defaults | |
| 2893 | // FORGE_SSH_PORT to 0, so serve() never opens a listener — every app on | |
| 2894 | // it declares a tty but has nothing to connect it to. The generic "The | |
| 2895 | // terminal is off." leaves a reader with no idea why or what to do; this | |
| 2896 | // asserts the line names FORGE_SSH_PORT instead, composed from the same | |
| 2897 | // sshPort field the other tests here already pin (#77's ground) rather | |
| 2898 | // than by shutting a real listener down. | |
| 2899 | test("a hosted forge with no SSH listener says why, and names FORGE_SSH_PORT", async () => { | |
| 2900 | putLive("bun run src/tty.ts"); | |
| 2901 | app.config.sshPort = 0; | |
| 2902 | try { | |
| 2903 | const mine = await appPage(writer); | |
| 2904 | expect(mine).toContain("FORGE_SSH_PORT"); | |
| 2905 | expect(mine).not.toContain("ssh -p 2222 [email protected]"); | |
| 2906 | expect(mine).not.toContain( | |
| 2907 | "The terminal is off.</p>", | |
| 2908 | ); | |
| 2909 | } finally { | |
| 2910 | app.config.sshPort = 2222; | |
| 2911 | } | |
| 2912 | }); | |
| 2891 | 2913 | }); |
| 2892 | 2914 | |
| 2893 | 2915 | /** |
new above
offline