Public: anyone can read this; the crew can change it.
The studio says how to get into the running app: the terminal, verbatim #99
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/57-the-studio-says-how-to-get-into-the-runn → main 1 commit fork
8 files
changed +228
−7
docs/STUDIO.md +11
| @@ -70,6 +70,17 @@ coming up — but by "the crew" rather than an agent's own name, and never | ||
| 70 | 70 | with a run's cost, turns or steer box. A private repository is unaffected: |
| 71 | 71 | it already answers only to people who can read it at all. |
| 72 | 72 | |
| 73 | **The terminal** is the same operator's door, so it sits beside the same | |
| 74 | facts: a writer sees the exact `ssh …` command production answers to, | |
| 75 | copyable, with the forge's host key fingerprint beside it, or one line | |
| 76 | saying the terminal is off — no SSH listener on this forge, or this | |
| 77 | app's `forge.json` declares no `tty` (`docs/APPS.md`, _A terminal for | |
| 78 | every app_). A pull's own preview card shows its own command the same | |
| 79 | way, since a preview answers to a different login than production's. A | |
| 80 | reader sees none of this. Both surfaces call the one function that knows | |
| 81 | the fact, `terminalFor` (`src/actions/apps.ts`), so the dev App page and | |
| 82 | the studio page never carry two ideas of what the command is. | |
| 83 | ||
| 73 | 84 | **The box** is the front door. A sentence becomes an issue — the first line |
| 74 | 85 | its title, the rest its body — and the routing decision hands it to a |
| 75 | 86 | builder: the builder with the least in flight, then by name. That is one |
src/actions/apps.ts +17 −3
| @@ -361,8 +361,6 @@ export interface AppView { | ||
| 361 | 361 | readonly environments: EnvStatus[]; |
| 362 | 362 | /** Whether a runtime is serving at all (`FORGE_APPS=0` means no). */ |
| 363 | 363 | readonly serving: boolean; |
| 364 | /** `ssh -p 2222 notes@host` — how to reach production's terminal, when SSH is on. */ | |
| 365 | readonly ssh: string | undefined; | |
| 366 | 364 | } |
| 367 | 365 | |
| 368 | 366 | /** |
| @@ -379,6 +377,23 @@ export function sshCommandFor( | ||
| 379 | 377 | return appsRuntime(app)?.sshFor(record, env); |
| 380 | 378 | } |
| 381 | 379 | |
| 380 | /** | |
| 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. | |
| 386 | */ | |
| 387 | export function terminalFor( | |
| 388 | app: App, | |
| 389 | record: AppRecord, | |
| 390 | env: EnvName, | |
| 391 | status: EnvStatus | undefined, | |
| 392 | ): string | undefined { | |
| 393 | if (status?.tty === null) return undefined; | |
| 394 | return sshCommandFor(app, record, env); | |
| 395 | } | |
| 396 | ||
| 382 | 397 | /** What is running, for anyone who can see the repository. */ |
| 383 | 398 | export function appView( |
| 384 | 399 | app: App, |
| @@ -394,7 +409,6 @@ export function appView( | ||
| 394 | 409 | app: record, |
| 395 | 410 | environments: runtime === undefined ? [] : runtime.status(record), |
| 396 | 411 | serving: runtime !== undefined, |
| 397 | ssh: sshCommandFor(app, record, "production"), | |
| 398 | 412 | }); |
| 399 | 413 | } |
| 400 | 414 |
src/apps/runtime.ts +8
| @@ -110,6 +110,12 @@ export interface EnvStatus { | ||
| 110 | 110 | * already answers. |
| 111 | 111 | */ |
| 112 | 112 | readonly note?: string | undefined; |
| 113 | /** | |
| 114 | * The app's own `tty` command, once known live — `null` when its | |
| 115 | * `forge.json` declares none. Unset while never live, since a checkout | |
| 116 | * that has not started has not said either way. | |
| 117 | */ | |
| 118 | readonly tty?: string | null | undefined; | |
| 113 | 119 | } |
| 114 | 120 | |
| 115 | 121 | interface Live { |
| @@ -284,6 +290,7 @@ export class AppsRuntime { | ||
| 284 | 290 | since: state.live.since, |
| 285 | 291 | servingAt: state.live.servingAt, |
| 286 | 292 | createdAt: state.live.createdAt, |
| 293 | tty: state.live.manifest.tty, | |
| 287 | 294 | ...(state.live.deployStartedAt === null |
| 288 | 295 | ? {} |
| 289 | 296 | : { deployStartedAt: state.live.deployStartedAt }), |
| @@ -302,6 +309,7 @@ export class AppsRuntime { | ||
| 302 | 309 | since: state.live.since, |
| 303 | 310 | servingAt: state.live.servingAt, |
| 304 | 311 | createdAt: state.live.createdAt, |
| 312 | tty: state.live.manifest.tty, | |
| 305 | 313 | ...(state.live.deployStartedAt === null |
| 306 | 314 | ? {} |
| 307 | 315 | : { deployStartedAt: state.live.deployStartedAt }), |
src/web/pages/apps.ts +17 −4
| @@ -16,8 +16,8 @@ import { | ||
| 16 | 16 | newApp, |
| 17 | 17 | openEnvUrl, |
| 18 | 18 | redeployEnv, |
| 19 | sshCommandFor, | |
| 20 | 19 | stopEnv, |
| 20 | terminalFor, | |
| 21 | 21 | } from "../../actions/apps.ts"; |
| 22 | 22 | import { listAllAgents, listCrew } from "../../actions/crew.ts"; |
| 23 | 23 | import { planningAvailable } from "../../crew/plan.ts"; |
| @@ -69,6 +69,7 @@ import { | ||
| 69 | 69 | row, |
| 70 | 70 | selectField, |
| 71 | 71 | sha, |
| 72 | terminalLine, | |
| 72 | 73 | time, |
| 73 | 74 | userLink, |
| 74 | 75 | field as formField, |
| @@ -557,7 +558,10 @@ export function appPage(ctx: Ctx): Response { | ||
| 557 | 558 | const runs = listed.ok ? listed.value : []; |
| 558 | 559 | const crew = listCrew(ctx.app, ctx.auth.user, repo); |
| 559 | 560 | const url = appsRuntime(ctx.app)?.urlFor(record, "production"); |
| 560 | const ssh = sshCommandFor(ctx.app, record, "production"); | |
| 561 | const production = envs.find((env) => env.env === "production"); | |
| 562 | const ssh = writer | |
| 563 | ? terminalFor(ctx.app, record, "production", production) | |
| 564 | : undefined; | |
| 561 | 565 | // The same rows the runs page draws, from the same two grouped reads: a |
| 562 | 566 | // table's first column folds into a sliver at phone width, a row does not. |
| 563 | 567 | const runsHead = html`${icon("activity")} |
| @@ -616,10 +620,10 @@ export function appPage(ctx: Ctx): Response { | ||
| 616 | 620 | : html`<a href="${base}/app/production/open" class="mono">${url}</a>`} |
| 617 | 621 | </dd> |
| 618 | 622 | ${when( |
| 619 | ssh !== undefined, | |
| 623 | writer, | |
| 620 | 624 | () => |
| 621 | 625 | html`<dt>Terminal</dt> |
| 622 | <dd><code class="mono">${ssh ?? ""}</code></dd>`, | |
| 626 | <dd>${terminalLine(ssh, ctx.app.config.sshHostKey)}</dd>`, | |
| 623 | 627 | )} |
| 624 | 628 | <dt>Access</dt> |
| 625 | 629 | <dd> |
| @@ -718,8 +722,17 @@ export function previewCard(ctx: Ctx, repo: Repo, pull: Pull): Html { | ||
| 718 | 722 | : "No preview yet — one starts when this pull request's head deploys."; |
| 719 | 723 | return card(html`<p class="muted small">${why}</p>`, { head }); |
| 720 | 724 | } |
| 725 | // A preview's terminal is a different login from production's — its own | |
| 726 | // command, shown only to a writer, same as the app page's. | |
| 727 | const writer = canWrite(ctx.app.db, repo, ctx.auth.user); | |
| 721 | 728 | return card( |
| 722 | 729 | html`${envFacts(base, env)} |
| 730 | ${when(writer, () => | |
| 731 | terminalLine( | |
| 732 | terminalFor(ctx.app, view.value.app, env.env, env), | |
| 733 | ctx.app.config.sshHostKey, | |
| 734 | ), | |
| 735 | )} | |
| 723 | 736 | <div class="bar mt"> |
| 724 | 737 | ${btn("Open preview", { |
| 725 | 738 | href: `${base}/app/${env.env}/open`, |
src/web/pages/studio.ts +8
| @@ -23,6 +23,7 @@ import { | ||
| 23 | 23 | setIssueStateThrough, |
| 24 | 24 | } from "../../actions/issues.ts"; |
| 25 | 25 | import { mergePull } from "../../actions/pulls.ts"; |
| 26 | import { terminalFor } from "../../actions/apps.ts"; | |
| 26 | 27 | import { tellCrew } from "../../actions/crew.ts"; |
| 27 | 28 | import { envPull, previewEnv } from "../../apps/data.ts"; |
| 28 | 29 | import { |
| @@ -112,6 +113,7 @@ import { | ||
| 112 | 113 | seg, |
| 113 | 114 | sha, |
| 114 | 115 | stateBadge, |
| 116 | terminalLine, | |
| 115 | 117 | time, |
| 116 | 118 | userLink, |
| 117 | 119 | userTag, |
| @@ -885,6 +887,12 @@ function appPanels( | ||
| 885 | 887 | >`} |
| 886 | 888 | </p> |
| 887 | 889 | ${when(production, (env) => envFacts(base, env))} |
| 890 | ${when(writer, () => | |
| 891 | terminalLine( | |
| 892 | terminalFor(ctx.app, record, "production", production), | |
| 893 | ctx.app.config.sshHostKey, | |
| 894 | ), | |
| 895 | )} | |
| 888 | 896 | </div> |
| 889 | 897 | <span class="spacer"></span> |
| 890 | 898 | ${lookNow(ctx, repo, record)} |
src/web/ui.ts +22
| @@ -376,6 +376,28 @@ export function envFacts( | ||
| 376 | 376 | ${when(env.error, (error) => html`<p class="env-error">${error}</p>`)}`; |
| 377 | 377 | } |
| 378 | 378 | |
| 379 | /** | |
| 380 | * An environment's terminal, as copyable monospace text with the host key | |
| 381 | * fingerprint beside it — so a first connection can be trusted deliberately | |
| 382 | * — or one line saying the terminal is off, when there is no command to | |
| 383 | * show. Every page that names a way in draws this one, never its own words | |
| 384 | * for "off". | |
| 385 | */ | |
| 386 | export function terminalLine( | |
| 387 | command: string | undefined, | |
| 388 | fingerprint?: string | undefined, | |
| 389 | ): Html { | |
| 390 | if (command === undefined) | |
| 391 | return html`<p class="muted small">The terminal is off.</p>`; | |
| 392 | return html`<p class="terminal-line"> | |
| 393 | <code class="mono">${command}</code> | |
| 394 | ${when( | |
| 395 | fingerprint, | |
| 396 | (fp) => html`<span class="muted small">host key ${fp}</span>`, | |
| 397 | )} | |
| 398 | </p>`; | |
| 399 | } | |
| 400 | ||
| 379 | 401 | /** |
| 380 | 402 | * The two things a writer can do to an environment. Both are POST forms — |
| 381 | 403 | * the routes they post to make the same-origin check — and a reader is |
test/studio-words.test.ts +2
| @@ -264,6 +264,7 @@ describe("dev's vocabulary never reaches a studio page", () => { | ||
| 264 | 264 | handle: { port: 4321 }, |
| 265 | 265 | runId: check.id, |
| 266 | 266 | since: new Date().toISOString(), |
| 267 | manifest: { tty: null }, | |
| 267 | 268 | }); |
| 268 | 269 | |
| 269 | 270 | // A merged change, production caught up to it — the studio's *shipped*. |
| @@ -314,6 +315,7 @@ describe("dev's vocabulary never reaches a studio page", () => { | ||
| 314 | 315 | handle: { port: 4321 }, |
| 315 | 316 | runId: deploy.id, |
| 316 | 317 | since: new Date().toISOString(), |
| 318 | manifest: { tty: null }, | |
| 317 | 319 | }); |
| 318 | 320 | }); |
| 319 | 321 |
test/studio.test.ts +143
| @@ -1440,6 +1440,7 @@ describe("the studio head says why the app is not up", () => { | ||
| 1440 | 1440 | runId: deploy, |
| 1441 | 1441 | since: new Date(Date.now() - 60_000).toISOString(), |
| 1442 | 1442 | testsFailed: "1 of 3 tests failed", |
| 1443 | manifest: { tty: null }, | |
| 1443 | 1444 | }, |
| 1444 | 1445 | }); |
| 1445 | 1446 | const mine = await head(writer); |
| @@ -1449,6 +1450,143 @@ describe("the studio head says why the app is not up", () => { | ||
| 1449 | 1450 | }); |
| 1450 | 1451 | }); |
| 1451 | 1452 | |
| 1453 | /** | |
| 1454 | * The studio app page's Terminal — the one door an operator has into what | |
| 1455 | * is running. `terminalFor` (`src/actions/apps.ts`) is the single fact both | |
| 1456 | * this page and the dev App page read; this only checks it is drawn (or | |
| 1457 | * withheld) correctly, not recomputed. | |
| 1458 | */ | |
| 1459 | describe("the studio app page's terminal", () => { | |
| 1460 | let dataDir: string; | |
| 1461 | let app: App; | |
| 1462 | let servers: Servers; | |
| 1463 | let base: string; | |
| 1464 | let repo: Repo; | |
| 1465 | let record: AppRecord; | |
| 1466 | let runtime: AppsRuntime; | |
| 1467 | let writer: string; | |
| 1468 | let reader: string; | |
| 1469 | ||
| 1470 | interface Slot { | |
| 1471 | readonly app: AppRecord; | |
| 1472 | readonly env: EnvName; | |
| 1473 | readonly chain: Promise<void>; | |
| 1474 | readonly restarts: number[]; | |
| 1475 | readonly live?: unknown; | |
| 1476 | } | |
| 1477 | const putLive = (tty: string | null): void => { | |
| 1478 | (runtime as unknown as { envs: Map<string, Slot> }).envs.set( | |
| 1479 | `${record.id}/production`, | |
| 1480 | { | |
| 1481 | app: record, | |
| 1482 | env: "production", | |
| 1483 | chain: Promise.resolve(), | |
| 1484 | restarts: [], | |
| 1485 | live: { | |
| 1486 | commitOid: "a".repeat(40), | |
| 1487 | handle: { port: 4321 }, | |
| 1488 | runId: "fake", | |
| 1489 | since: new Date().toISOString(), | |
| 1490 | manifest: { tty }, | |
| 1491 | }, | |
| 1492 | }, | |
| 1493 | ); | |
| 1494 | }; | |
| 1495 | ||
| 1496 | const signIn = async (username: string): Promise<string> => | |
| 1497 | ( | |
| 1498 | ( | |
| 1499 | await fetch(`${base}/signin`, { | |
| 1500 | method: "POST", | |
| 1501 | body: new URLSearchParams({ | |
| 1502 | username, | |
| 1503 | password: "a-good-passphrase", | |
| 1504 | }), | |
| 1505 | redirect: "manual", | |
| 1506 | }) | |
| 1507 | ).headers.get("set-cookie") ?? "" | |
| 1508 | ).split(";")[0]!; | |
| 1509 | ||
| 1510 | const appPage = (cookie: string) => | |
| 1511 | fetch(`${base}/alice/beacon`, { | |
| 1512 | headers: { cookie: `${cookie}; forge_mode=studio` }, | |
| 1513 | redirect: "manual", | |
| 1514 | }).then((r) => r.text()); | |
| 1515 | ||
| 1516 | beforeAll(async () => { | |
| 1517 | dataDir = await mkdtemp(join(tmpdir(), "forge-studio-terminal-")); | |
| 1518 | app = createApp({ dataDir, port: 0, host: "forge.example" }); | |
| 1519 | app.config.sshPort = 2222; | |
| 1520 | app.config.sshHostKey = "SHA256:abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG"; | |
| 1521 | servers = await serve(app); | |
| 1522 | base = servers.url; | |
| 1523 | runtime = new AppsRuntime(app, { | |
| 1524 | domain: "apps.test", | |
| 1525 | scheme: "http", | |
| 1526 | publicPort: servers.port, | |
| 1527 | forgeUrl: servers.url, | |
| 1528 | concurrency: 1, | |
| 1529 | }); | |
| 1530 | const a = await createUser(app.db, { | |
| 1531 | username: "alice", | |
| 1532 | email: "[email protected]", | |
| 1533 | password: "a-good-passphrase", | |
| 1534 | adminIfFirst: true, | |
| 1535 | }); | |
| 1536 | if (!a.ok) throw new Error(a.error); | |
| 1537 | const b = await createUser(app.db, { | |
| 1538 | username: "bob", | |
| 1539 | email: "[email protected]", | |
| 1540 | password: "a-good-passphrase", | |
| 1541 | }); | |
| 1542 | if (!b.ok) throw new Error(b.error); | |
| 1543 | writer = await signIn("alice"); | |
| 1544 | reader = await signIn("bob"); | |
| 1545 | const made = await createRepo(app, a.value, { | |
| 1546 | name: "beacon", | |
| 1547 | visibility: "public", | |
| 1548 | description: "a light that stays on", | |
| 1549 | }); | |
| 1550 | if (!made.ok) throw new Error(String(made.error)); | |
| 1551 | repo = made.value; | |
| 1552 | const seeded = await commitToBranch(app, a.value, repo, { | |
| 1553 | message: "Start", | |
| 1554 | files: [{ path: "README.md", content: "beacon\n" }], | |
| 1555 | }); | |
| 1556 | if (!seeded.ok) throw new Error(JSON.stringify(seeded.error)); | |
| 1557 | const enabled = await enableApp(app, a.value, repo, { slug: "beacon" }); | |
| 1558 | if (!enabled.ok) throw new Error(String(enabled.error)); | |
| 1559 | record = getAppByRepo(app.db, repo.id)!; | |
| 1560 | }); | |
| 1561 | ||
| 1562 | afterAll(async () => { | |
| 1563 | servers.stop(); | |
| 1564 | app.close(); | |
| 1565 | await rm(dataDir, { recursive: true, force: true }); | |
| 1566 | }); | |
| 1567 | ||
| 1568 | test("a writer sees the exact command and the host key; a reader sees neither", async () => { | |
| 1569 | putLive("bun run src/tty.ts"); | |
| 1570 | const mine = await appPage(writer); | |
| 1571 | expect(mine).toContain("ssh -p 2222 [email protected]"); | |
| 1572 | expect(mine).toContain( | |
| 1573 | "host key SHA256:abcdefghijklmnopqrstuvwxyz", | |
| 1574 | ); | |
| 1575 | const theirs = await appPage(reader); | |
| 1576 | expect(theirs).not.toContain("ssh -p 2222 [email protected]"); | |
| 1577 | expect(theirs).not.toContain("The terminal is off"); | |
| 1578 | }); | |
| 1579 | ||
| 1580 | test("an app with no tty says the terminal is off, to a writer only", async () => { | |
| 1581 | putLive(null); | |
| 1582 | const mine = await appPage(writer); | |
| 1583 | expect(mine).toContain("The terminal is off"); | |
| 1584 | expect(mine).not.toContain("ssh -p 2222 [email protected]"); | |
| 1585 | const theirs = await appPage(reader); | |
| 1586 | expect(theirs).not.toContain("The terminal is off"); | |
| 1587 | }); | |
| 1588 | }); | |
| 1589 | ||
| 1452 | 1590 | /** |
| 1453 | 1591 | * GET /:owner/:name/app/state — the fragment the studio page's live client |
| 1454 | 1592 | * fetches on a deploy run event, and swaps into `#app-head`/`#app-previews` |
| @@ -1621,6 +1759,7 @@ describe("the app's live fragment: /:owner/:name/app/state", () => { | ||
| 1621 | 1759 | handle: { port: 4321 }, |
| 1622 | 1760 | runId: deployRun.id, |
| 1623 | 1761 | since: new Date().toISOString(), |
| 1762 | manifest: { tty: null }, | |
| 1624 | 1763 | }, |
| 1625 | 1764 | }); |
| 1626 | 1765 | const live = await (await get("/alice/vault/app/state", owner)).text(); |
| @@ -1651,6 +1790,7 @@ describe("the app's live fragment: /:owner/:name/app/state", () => { | ||
| 1651 | 1790 | servingAt, |
| 1652 | 1791 | createdAt, |
| 1653 | 1792 | deployStartedAt, |
| 1793 | manifest: { tty: null }, | |
| 1654 | 1794 | }, |
| 1655 | 1795 | }); |
| 1656 | 1796 | |
| @@ -1669,6 +1809,7 @@ describe("the app's live fragment: /:owner/:name/app/state", () => { | ||
| 1669 | 1809 | servingAt, |
| 1670 | 1810 | createdAt: deployStartedAt, |
| 1671 | 1811 | deployStartedAt, |
| 1812 | manifest: { tty: null }, | |
| 1672 | 1813 | }, |
| 1673 | 1814 | }); |
| 1674 | 1815 | const prompt = await (await get("/alice/vault/app/state", owner)).text(); |
| @@ -1685,6 +1826,7 @@ describe("the app's live fragment: /:owner/:name/app/state", () => { | ||
| 1685 | 1826 | since: servingAt, |
| 1686 | 1827 | servingAt, |
| 1687 | 1828 | deployStartedAt, |
| 1829 | manifest: { tty: null }, | |
| 1688 | 1830 | }, |
| 1689 | 1831 | }); |
| 1690 | 1832 | const pressed = await (await get("/alice/vault/app/state", owner)).text(); |
| @@ -1895,6 +2037,7 @@ describe("words come from facts, not from run.summary", () => { | ||
| 1895 | 2037 | handle: { port: 4321 }, |
| 1896 | 2038 | runId: "fake", |
| 1897 | 2039 | since: new Date().toISOString(), |
| 2040 | manifest: { tty: null }, | |
| 1898 | 2041 | }, |
| 1899 | 2042 | } |
| 1900 | 2043 | : {}), |
new above
offline