Public: anyone can read this; the crew can change it.
The terminal's deploys screen lists an app's custom domains, adds, checks and removes one, as MCP `app_domains` does #7421
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/7322-the-terminal-s-deploys-screen-lists-an-a → main 2 commits fork
8 files
changed +407
−48
docs/APPS.md +5 −3
| @@ -971,7 +971,8 @@ browser. | ||
| 971 | 971 | An app can answer at a name you own — `app.example.com`, or an apex like |
| 972 | 972 | `example.com` — beside the hostname the forge gave it. Under **Settings → |
| 973 | 973 | App → Domains** an admin of the repository adds the name (or `POST |
| 974 | /api/v1/repos/:owner/:name/app/domains` with a `{ "host" }` body) and is | |
| 974 | /api/v1/repos/:owner/:name/app/domains` with a `{ "host" }` body, MCP | |
| 975 | `app_domains`, or `n` then `+` on the terminal's **Deploys** screen) and is | |
| 975 | 976 | shown the two records to publish in the zone: |
| 976 | 977 | |
| 977 | 978 | - a TXT record at `_forge-verify.<host>` whose value is `forge-verify=<token>` |
| @@ -982,8 +983,9 @@ shown the two records to publish in the zone: | ||
| 982 | 983 | provider's ALIAS or ANAME record instead, or A and AAAA records with the |
| 983 | 984 | address the forge is served at. |
| 984 | 985 | |
| 985 | The forge checks when asked — **Check now** on the page, or `POST | |
| 986 | …/app/domains/:host/check` — never on a timer. It looks the TXT record up; | |
| 986 | The forge checks when asked — **Check now** on the page, `c` on a domain in | |
| 987 | the terminal's domains screen, or `POST …/app/domains/:host/check` — never on | |
| 988 | a timer. It looks the TXT record up; | |
| 987 | 989 | the domain is verified when some record there is exactly the value shown. |
| 988 | 990 | Every check leaves one sentence a person can act on ("No TXT record at |
| 989 | 991 | `_forge-verify.app.example.com` yet.", "The TXT record is there but holds a |
docs/TOUR.md +2
| @@ -203,6 +203,8 @@ An app made from the template ships the same beacon as `static/beacon.js`, posti | ||
| 203 | 203 | |
| 204 | 204 | A writer of the app's repository redeploys production from the terminal's **Deploys** screen with `D` and stops it with `S`, each after a one-line confirm that names the app and production. They are the page's and MCP `app_deploy`'s actions (`redeployEnv`, `stopEnv`), answered in the page's words (`src/actions/env_words.ts`). The title line shows production's state, a stopped production offers `D` only, and a reader sees neither key. |
| 205 | 205 | |
| 206 | The app's admin also sees its own domains on that screen, one line with each domain's state (`live` or `waiting for DNS`), and presses `n` for the domains screen: `+` adds one, `c` checks the selected one's DNS proof now and says the record still needed, `-` removes it after `remove <host>? y/n`. They are the settings page's and MCP `app_domains`' actions (`src/actions/domains.ts`), answered in the page's words (`src/actions/domain_words.ts`); anyone `listAppDomains` refuses sees neither the domains nor `n`. | |
| 207 | ||
| 206 | 208 | The `app_vital` board source (`src/boards/sources.ts`) charts those reports: one row per app per day with `app` (the app's id), `day`, `load_p75`, `inp_p75` (ms) and `reports` (rows kept that day), the percentiles nearest-rank per app per day as `reach` does. `max` or `avg` over several days summarises daily percentiles, not the percentile of the whole range. Its rows belong to the app's repository, so a query sees only the repos in its scope. Today boards query with `repoIds: "all"` (`src/web/pages/boards.ts`) and only a site admin reaches them; no non-admin scope exists, so nothing yet applies the settings page's `canAdmin` gate per repository, and whoever builds one must. Days older than the 90-day trim are gone, not zero. The slug and the visitor hash are not columns. No dependency was added. Left out: a starting board for it; pick `app_vital` in the `/boards` source picker. |
| 207 | 209 | |
| 208 | 210 | `reach` is a boards source for an admin only (anyone else gets 403, an agent too): `visitors` per day, `lcp_p75` and `inp_p75` per route per day, `server_p50` and `server_p95` per route per day (the beacon's time to first byte, the only per-route server timing the forge keeps), and `accounts_active_1d`, `accounts_active_7d` and `accounts_total` as of now. Filter by `metric`, group by `route` or a time bucket. Percentiles are nearest-rank within one route and day, so a `max` or `avg` over several days is a summary of daily percentiles, not a percentile of the period. Account activity comes from a session's last-seen time, so it is a count as of now, not a history. No dependency was added. |
src/actions/domain_words.ts added +56
| @@ -0,0 +1,56 @@ | ||
| 1 | /** | |
| 2 | * What a person is told about an app's own domains (`listAppDomains`, | |
| 3 | * `addAppDomain`, `checkAppDomain`, `removeAppDomain`). Said the same on the | |
| 4 | * app's settings page and in the terminal's domains screen, from here. | |
| 5 | */ | |
| 6 | ||
| 7 | import type { DnsRecord } from "../apps/domains.ts"; | |
| 8 | import type { AppDomainView } from "./domains.ts"; | |
| 9 | ||
| 10 | const REFUSALS: Readonly<Record<string, string>> = { | |
| 11 | invalid_host: | |
| 12 | "A domain is a hostname: letters, digits and hyphens in at least two labels, like app.example.com.", | |
| 13 | reserved_host: "That name is the forge's own to hand out.", | |
| 14 | taken: "Another app already answers to that domain.", | |
| 15 | no_runtime: "Nothing is serving apps on this forge.", | |
| 16 | }; | |
| 17 | ||
| 18 | /** A refusal code as a sentence; `fallback` for a code with none of its own. */ | |
| 19 | export function domainRefusalText(code: string, fallback: string): string { | |
| 20 | return REFUSALS[code] ?? fallback; | |
| 21 | } | |
| 22 | ||
| 23 | export const NO_SUCH_DOMAIN = "No domain by that name."; | |
| 24 | ||
| 25 | /** `live`, or `waiting for DNS` until a check finds the proof. */ | |
| 26 | export function domainStateWord(d: AppDomainView): string { | |
| 27 | return d.verified ? "live" : "waiting for DNS"; | |
| 28 | } | |
| 29 | ||
| 30 | /** What the last check said, or that none has run. */ | |
| 31 | export function domainSaidText(d: AppDomainView): string { | |
| 32 | return d.said === "" ? "Not checked yet." : d.said; | |
| 33 | } | |
| 34 | ||
| 35 | /** What the records say, as one sentence: type, name and value, in words. */ | |
| 36 | export function recordsInWords(records: readonly DnsRecord[]): string { | |
| 37 | return records | |
| 38 | .map((r) => | |
| 39 | r.type === "TXT" | |
| 40 | ? `a TXT record at ${r.name} with the value ${r.value}` | |
| 41 | : r.type === "CNAME" | |
| 42 | ? `a CNAME from ${r.name} to ${r.value}` | |
| 43 | : `an ALIAS from ${r.name} to ${r.value}`, | |
| 44 | ) | |
| 45 | .join(" and "); | |
| 46 | } | |
| 47 | ||
| 48 | /** A domain was added: what to publish next. */ | |
| 49 | export function domainAddedText(d: AppDomainView): string { | |
| 50 | return `${d.host} is added. Publish ${recordsInWords(d.records)}, then press Check now.`; | |
| 51 | } | |
| 52 | ||
| 53 | /** A domain was removed. */ | |
| 54 | export function domainRemovedText(host: string): string { | |
| 55 | return `${host} is removed. The forge no longer answers there.`; | |
| 56 | } |
src/tui/screens/common.ts +18
| @@ -440,6 +440,24 @@ export function confirmScreen( | ||
| 440 | 440 | }; |
| 441 | 441 | } |
| 442 | 442 | |
| 443 | /** | |
| 444 | * A `… y/n` confirm: `y` chooses the first action and `n` cancels, whatever | |
| 445 | * the selection — each outright, rather than pressing Enter. | |
| 446 | */ | |
| 447 | export function yesNo(ctx: Ctx, dialog: Screen, choose: (choice: number) => void): Screen { | |
| 448 | return { | |
| 449 | ...dialog, | |
| 450 | key(e) { | |
| 451 | if (e.key === "y" || e.key === "n") { | |
| 452 | ctx.pop(); | |
| 453 | choose(e.key === "y" ? 0 : -1); | |
| 454 | return true; | |
| 455 | } | |
| 456 | return dialog.key(e); | |
| 457 | }, | |
| 458 | }; | |
| 459 | } | |
| 460 | ||
| 443 | 461 | /** A one-field modal: a name to type, a username to add, a branch to create. */ |
| 444 | 462 | export function promptScreen( |
| 445 | 463 | ctx: Ctx, |
src/tui/screens/deploys.ts +215 −3
| @@ -19,6 +19,14 @@ | ||
| 19 | 19 | * A writer — whom the MCP tool `probe` lets knock — gets `p`: the same |
| 20 | 20 | * `probeApp` against the running production app, every door tried as |
| 21 | 21 | * nobody, a user and an admin, read-only, one line per door. |
| 22 | * | |
| 23 | * The app's admin sees its own domains under the head, each with its state | |
| 24 | * in the settings page's words (`domain_words.ts`), and gets `n`: a domains | |
| 25 | * screen where `+` adds one, `c` checks the selected one's DNS proof now and | |
| 26 | * says the record still needed, and `-` removes it after `remove <host>? y/n` | |
| 27 | * — `addAppDomain`, `checkAppDomain` and `removeAppDomain`, the page's and | |
| 28 | * MCP `app_domains`' actions. `listAppDomains` is the admin gate: anyone it | |
| 29 | * refuses sees neither the domains nor the key. | |
| 22 | 30 | */ |
| 23 | 31 | |
| 24 | 32 | import { |
| @@ -31,6 +39,22 @@ import { | ||
| 31 | 39 | stopEnv, |
| 32 | 40 | type AppPullLine, |
| 33 | 41 | } from "../../actions/apps.ts"; |
| 42 | import { | |
| 43 | addAppDomain, | |
| 44 | checkAppDomain, | |
| 45 | listAppDomains, | |
| 46 | removeAppDomain, | |
| 47 | type AppDomainView, | |
| 48 | } from "../../actions/domains.ts"; | |
| 49 | import { | |
| 50 | NO_SUCH_DOMAIN, | |
| 51 | domainAddedText, | |
| 52 | domainRefusalText, | |
| 53 | domainRemovedText, | |
| 54 | domainSaidText, | |
| 55 | domainStateWord, | |
| 56 | recordsInWords, | |
| 57 | } from "../../actions/domain_words.ts"; | |
| 34 | 58 | import { envLabel, envRefusalText, redeployedText, stoppedText } from "../../actions/env_words.ts"; |
| 35 | 59 | import { trendLine, visitorsLine } from "../../apps/vitals.ts"; |
| 36 | 60 | import { listRepoRuns } from "../../actions/runs.ts"; |
| @@ -48,7 +72,16 @@ import { style } from "../term/style.ts"; | ||
| 48 | 72 | import { truncate } from "../term/width.ts"; |
| 49 | 73 | import { fill, len, split } from "../ui/layout.ts"; |
| 50 | 74 | import { emptyState } from "../ui/widgets.ts"; |
| 51 | import { ListState, confirmScreen, drawList, hitList, runAsync, type ListRow } from "./common.ts"; | |
| 75 | import { | |
| 76 | ListState, | |
| 77 | confirmScreen, | |
| 78 | drawList, | |
| 79 | hitList, | |
| 80 | promptScreen, | |
| 81 | runAsync, | |
| 82 | yesNo, | |
| 83 | type ListRow, | |
| 84 | } from "./common.ts"; | |
| 52 | 85 | import { RunList, openRunLog, runRows } from "./runlist.ts"; |
| 53 | 86 | |
| 54 | 87 | const LIMIT = 50; |
| @@ -99,6 +132,8 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 99 | 132 | let side: string[] = []; |
| 100 | 133 | let pulls: AppPullLine[] = []; |
| 101 | 134 | let production: Production = { state: "", redeploy: false, stop: false }; |
| 135 | /** The app's domains, or undefined for anyone `listAppDomains` refuses. */ | |
| 136 | let domains: AppDomainView[] | undefined; | |
| 102 | 137 | |
| 103 | 138 | const read = (): void => { |
| 104 | 139 | const runs = readDeploys(f, repo); |
| @@ -114,6 +149,8 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 114 | 149 | side = appSideLines(f.app, f.user, repo); |
| 115 | 150 | pulls = appPullLines(f.app, f.user, repo); |
| 116 | 151 | production = readProduction(f, repo); |
| 152 | const listed = listAppDomains(f.app, f.user, repo); | |
| 153 | domains = listed.ok ? listed.value : undefined; | |
| 117 | 154 | loaded = true; |
| 118 | 155 | ctx.invalidate(); |
| 119 | 156 | }; |
| @@ -167,13 +204,22 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 167 | 204 | ...(production.redeploy ? [binding(["D"], "redeploy production")] : []), |
| 168 | 205 | ...(production.stop ? [binding(["S"], "stop production")] : []), |
| 169 | 206 | ...(mayProbe ? [binding(["p"], "probe the running app's doors")] : []), |
| 207 | ...(domains !== undefined ? [binding(["n"], "the app's own domains")] : []), | |
| 170 | 208 | ]; |
| 171 | 209 | }, |
| 172 | 210 | load: async () => read(), |
| 173 | 211 | render(area, s) { |
| 174 | 212 | const c = ctx.theme.c; |
| 213 | const named = domainsLine(domains); | |
| 175 | 214 | const [head, upper, tail] = split(area, "v", [ |
| 176 | len(1 + (visitors === "" ? 0 : 1) + (trend === "" ? 0 : 1) + (pace === "" ? 0 : 1) + side.length), | |
| 215 | len( | |
| 216 | 1 + | |
| 217 | (visitors === "" ? 0 : 1) + | |
| 218 | (trend === "" ? 0 : 1) + | |
| 219 | (pace === "" ? 0 : 1) + | |
| 220 | side.length + | |
| 221 | (named === "" ? 0 : 1), | |
| 222 | ), | |
| 177 | 223 | fill(1), |
| 178 | 224 | len(pulls.length === 0 ? 0 : pulls.length + 1), |
| 179 | 225 | ]); |
| @@ -199,7 +245,7 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 199 | 245 | style(c.mutedFg, c.bg), |
| 200 | 246 | head!.x + head!.w, |
| 201 | 247 | ); |
| 202 | [visitors, trend, pace, ...side] | |
| 248 | [visitors, trend, pace, ...side, named] | |
| 203 | 249 | .filter((line) => line !== "") |
| 204 | 250 | .forEach((line, i) => |
| 205 | 251 | s.text(head!.x, head!.y + 1 + i, truncate(line, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w), |
| @@ -250,6 +296,10 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 250 | 296 | ctx.push(probeScreen(f, ctx, repo)); |
| 251 | 297 | return true; |
| 252 | 298 | } |
| 299 | if (e.key === "n" && domains !== undefined) { | |
| 300 | ctx.push(domainsScreen(f, ctx, repo, read)); | |
| 301 | return true; | |
| 302 | } | |
| 253 | 303 | return false; |
| 254 | 304 | }, |
| 255 | 305 | }; |
| @@ -378,3 +428,165 @@ function probeScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 378 | 428 | }, |
| 379 | 429 | }; |
| 380 | 430 | } |
| 431 | ||
| 432 | // ---- Domains: `n`, the MCP tool `app_domains` from the deploys screen ---- | |
| 433 | ||
| 434 | /** `Domains: app.example.com live · shop.example.com waiting for DNS`, or "" with none. */ | |
| 435 | function domainsLine(domains: readonly AppDomainView[] | undefined): string { | |
| 436 | if (domains === undefined || domains.length === 0) return ""; | |
| 437 | return `Domains: ${domains.map((d) => `${d.host} ${domainStateWord(d)}`).join(" · ")}`; | |
| 438 | } | |
| 439 | ||
| 440 | /** `requireRepoAdmin` answers `not_found` to anyone else, so nothing leaks; here it is said. */ | |
| 441 | const NOT_ADMIN = "Only the app's admin manages its domains."; | |
| 442 | ||
| 443 | const domainRow = (d: AppDomainView): ListRow => ({ | |
| 444 | title: `${d.host} · ${domainStateWord(d)}`, | |
| 445 | tone: d.verified ? "success" : "attn", | |
| 446 | secondary: clean(domainSaidText(d)), | |
| 447 | }); | |
| 448 | ||
| 449 | /** What the proof still needs, for an unverified domain; "" once it is live. */ | |
| 450 | function stillNeeded(d: AppDomainView): string { | |
| 451 | return d.verified ? "" : `Still needed: ${recordsInWords(d.records)}.`; | |
| 452 | } | |
| 453 | ||
| 454 | /** | |
| 455 | * The app's domains, for its admin. Every key goes through the action, which | |
| 456 | * gates again; a refusal is said in the page's words. `changed` re-reads the | |
| 457 | * deploys screen beneath, so its domains line follows. | |
| 458 | */ | |
| 459 | export function domainsScreen(f: Session, ctx: Ctx, repo: Repo, changed: () => void = () => {}): Screen { | |
| 460 | const state = new ListState(); | |
| 461 | let domains: AppDomainView[] = []; | |
| 462 | let refused: string | undefined; | |
| 463 | let loaded = false; | |
| 464 | ||
| 465 | const refusal = (code: string, fallback: string): string => | |
| 466 | code === "not_found" ? NOT_ADMIN : domainRefusalText(code, fallback); | |
| 467 | const read = (): void => { | |
| 468 | const listed = listAppDomains(f.app, f.user, repo); | |
| 469 | refused = listed.ok ? undefined : refusal(listed.error, NO_SUCH_DOMAIN); | |
| 470 | domains = listed.ok ? listed.value : []; | |
| 471 | loaded = true; | |
| 472 | ctx.invalidate(); | |
| 473 | }; | |
| 474 | const after = (): void => { | |
| 475 | read(); | |
| 476 | changed(); | |
| 477 | }; | |
| 478 | const add = (): void => { | |
| 479 | ctx.push( | |
| 480 | promptScreen(ctx, self, { | |
| 481 | title: "Add a domain", | |
| 482 | label: "Domain", | |
| 483 | placeholder: "app.example.com", | |
| 484 | hint: "Publish the two records it shows, then check it with c.", | |
| 485 | onSubmit: (host) => { | |
| 486 | const added = addAppDomain(f.app, f.user, repo, host); | |
| 487 | if (!added.ok) return refusal(added.error, "That domain could not be added."); | |
| 488 | ctx.toast(domainAddedText(added.value)); | |
| 489 | after(); | |
| 490 | return undefined; | |
| 491 | }, | |
| 492 | }), | |
| 493 | ); | |
| 494 | }; | |
| 495 | ||
| 496 | const check = (d: AppDomainView): void => | |
| 497 | runAsync(ctx, async () => { | |
| 498 | const checked = await checkAppDomain(f.app, f.user, repo, d.host); | |
| 499 | if (!checked.ok) ctx.toast(refusal(checked.error, NO_SUCH_DOMAIN), "danger"); | |
| 500 | else | |
| 501 | ctx.toast( | |
| 502 | [clean(checked.value.said), stillNeeded(checked.value)].filter((t) => t !== "").join(" "), | |
| 503 | checked.value.verified ? "success" : "attn", | |
| 504 | ); | |
| 505 | after(); | |
| 506 | }); | |
| 507 | ||
| 508 | const remove = (d: AppDomainView): void => { | |
| 509 | const choose = (choice: number): void => { | |
| 510 | if (choice !== 0) return; | |
| 511 | const removed = removeAppDomain(f.app, f.user, repo, d.host); | |
| 512 | ctx.toast(removed.ok ? domainRemovedText(d.host) : refusal(removed.error, NO_SUCH_DOMAIN), removed.ok ? undefined : "danger"); | |
| 513 | after(); | |
| 514 | }; | |
| 515 | ctx.push( | |
| 516 | yesNo( | |
| 517 | ctx, | |
| 518 | confirmScreen(ctx, self, { | |
| 519 | title: "Remove domain", | |
| 520 | body: [`remove ${d.host}? y/n`], | |
| 521 | actions: [{ label: "Remove" }, { label: "Cancel", variant: "ghost" }], | |
| 522 | selected: 1, | |
| 523 | onChoose: choose, | |
| 524 | }), | |
| 525 | choose, | |
| 526 | ), | |
| 527 | ); | |
| 528 | }; | |
| 529 | ||
| 530 | const self: Screen = { | |
| 531 | title: "Domains", | |
| 532 | get keys() { | |
| 533 | return refused !== undefined | |
| 534 | ? [binding(["esc"], "back")] | |
| 535 | : [ | |
| 536 | binding(["j", "k"], "move"), | |
| 537 | binding(["+"], "add a domain"), | |
| 538 | ...(domains.length > 0 ? [binding(["c"], "check its DNS now"), binding(["-"], "remove it")] : []), | |
| 539 | binding(["r"], "refresh"), | |
| 540 | binding(["esc"], "back"), | |
| 541 | ]; | |
| 542 | }, | |
| 543 | load: async () => read(), | |
| 544 | render(area, s) { | |
| 545 | const c = ctx.theme.c; | |
| 546 | const [head, body, foot] = split(area, "v", [len(1), fill(1), len(1)]); | |
| 547 | s.text( | |
| 548 | head!.x, | |
| 549 | head!.y, | |
| 550 | truncate(`${repo.ownerName}/${repo.name} · the app's own domains`, head!.w), | |
| 551 | style(c.mutedFg, c.bg), | |
| 552 | head!.x + head!.w, | |
| 553 | ); | |
| 554 | if (refused !== undefined || domains.length === 0) { | |
| 555 | emptyState(s, body!, c, { | |
| 556 | title: refused !== undefined ? "Not yours" : loaded ? "No domains" : "Reading…", | |
| 557 | body: refused ?? (loaded ? "Press + to serve the app at a name of your own." : "Reading the domains"), | |
| 558 | }); | |
| 559 | return; | |
| 560 | } | |
| 561 | const rows = domains.map(domainRow); | |
| 562 | drawList(s, body!, c, state, rows); | |
| 563 | hitList(ctx, body!, state, rows.length, {}); | |
| 564 | const selected = domains[state.index]; | |
| 565 | if (selected !== undefined) | |
| 566 | s.text(foot!.x, foot!.y, truncate(stillNeeded(selected), foot!.w), style(c.mutedFg, c.bg), foot!.x + foot!.w); | |
| 567 | }, | |
| 568 | key(e) { | |
| 569 | if (refused !== undefined) return false; | |
| 570 | if (state.key(e, domains.length)) return true; | |
| 571 | const selected = domains[state.index]; | |
| 572 | if (e.key === "+") { | |
| 573 | add(); | |
| 574 | return true; | |
| 575 | } | |
| 576 | if (e.key === "c" && selected !== undefined) { | |
| 577 | check(selected); | |
| 578 | return true; | |
| 579 | } | |
| 580 | if (e.key === "-" && selected !== undefined) { | |
| 581 | remove(selected); | |
| 582 | return true; | |
| 583 | } | |
| 584 | if (e.key === "r") { | |
| 585 | read(); | |
| 586 | return true; | |
| 587 | } | |
| 588 | return false; | |
| 589 | }, | |
| 590 | }; | |
| 591 | return self; | |
| 592 | } |
src/tui/screens/waves.ts +1 −16
| @@ -19,7 +19,7 @@ import { truncate } from "../term/width.ts"; | ||
| 19 | 19 | import { fill, len, split } from "../ui/layout.ts"; |
| 20 | 20 | import { emptyState } from "../ui/widgets.ts"; |
| 21 | 21 | import type { Session } from "../session.ts"; |
| 22 | import { ListState, Scroll, confirmScreen, drawList, drawLines, hitList, plural, runAsync } from "./common.ts"; | |
| 22 | import { ListState, Scroll, confirmScreen, drawList, drawLines, hitList, plural, runAsync, yesNo } from "./common.ts"; | |
| 23 | 23 | import { pullScreen } from "./pull.ts"; |
| 24 | 24 | import { localRepo, sourceName, whereRows, whereStrip } from "./where.ts"; |
| 25 | 25 | |
| @@ -220,18 +220,3 @@ export function wavesScreen(f: Session, ctx: Ctx, src: RepoSource): Screen { | ||
| 220 | 220 | }; |
| 221 | 221 | return self; |
| 222 | 222 | } |
| 223 | ||
| 224 | /** `y` cuts and `n` cancels, whatever the selection: each chooses outright rather than pressing Enter. */ | |
| 225 | function yesNo(ctx: Ctx, dialog: Screen, choose: (choice: number) => void): Screen { | |
| 226 | return { | |
| 227 | ...dialog, | |
| 228 | key(e) { | |
| 229 | if (e.key === "y" || e.key === "n") { | |
| 230 | ctx.pop(); | |
| 231 | choose(e.key === "y" ? 0 : -1); | |
| 232 | return true; | |
| 233 | } | |
| 234 | return dialog.key(e); | |
| 235 | }, | |
| 236 | }; | |
| 237 | } |
src/web/pages/settings.ts +11 −26
| @@ -25,6 +25,12 @@ import { | ||
| 25 | 25 | removeAppDomain, |
| 26 | 26 | type AppDomainView, |
| 27 | 27 | } from "../../actions/domains.ts"; |
| 28 | import { | |
| 29 | domainAddedText, | |
| 30 | domainRefusalText, | |
| 31 | domainRemovedText, | |
| 32 | NO_SUCH_DOMAIN, | |
| 33 | } from "../../actions/domain_words.ts"; | |
| 28 | 34 | import { |
| 29 | 35 | egressView, |
| 30 | 36 | grantAppEgress, |
| @@ -1213,27 +1219,6 @@ export async function appEnvDelete(ctx: Ctx): Promise<Response> { | ||
| 1213 | 1219 | ); |
| 1214 | 1220 | } |
| 1215 | 1221 | |
| 1216 | const APP_DOMAIN_ERRORS: Record<string, string> = { | |
| 1217 | invalid_host: | |
| 1218 | "A domain is a hostname: letters, digits and hyphens in at least two labels, like app.example.com.", | |
| 1219 | reserved_host: "That name is the forge's own to hand out.", | |
| 1220 | taken: "Another app already answers to that domain.", | |
| 1221 | no_runtime: "Nothing is serving apps on this forge.", | |
| 1222 | }; | |
| 1223 | ||
| 1224 | /** What the records say, as one flash: type, name and value, in words. */ | |
| 1225 | function recordsInWords(records: DnsRecord[]): string { | |
| 1226 | return records | |
| 1227 | .map((r) => | |
| 1228 | r.type === "TXT" | |
| 1229 | ? `a TXT record at ${r.name} with the value ${r.value}` | |
| 1230 | : r.type === "CNAME" | |
| 1231 | ? `a CNAME from ${r.name} to ${r.value}` | |
| 1232 | : `an ALIAS from ${r.name} to ${r.value}`, | |
| 1233 | ) | |
| 1234 | .join(" and "); | |
| 1235 | } | |
| 1236 | ||
| 1237 | 1222 | // POST /:owner/:name/settings/app/domains — add one. |
| 1238 | 1223 | export async function appDomainAdd(ctx: Ctx): Promise<Response> { |
| 1239 | 1224 | const gate = await adminForm(ctx); |
| @@ -1246,12 +1231,12 @@ export async function appDomainAdd(ctx: Ctx): Promise<Response> { | ||
| 1246 | 1231 | `${repoBase(repo)}/settings/app`, |
| 1247 | 1232 | added.ok |
| 1248 | 1233 | ? { |
| 1249 | message: `${added.value.host} is added. Publish ${recordsInWords(added.value.records)}, then press Check now.`, | |
| 1234 | message: domainAddedText(added.value), | |
| 1250 | 1235 | } |
| 1251 | 1236 | : { |
| 1252 | 1237 | tone: "danger", |
| 1253 | 1238 | message: |
| 1254 | APP_DOMAIN_ERRORS[added.error] ?? "That domain could not be added.", | |
| 1239 | domainRefusalText(added.error, "That domain could not be added."), | |
| 1255 | 1240 | }, |
| 1256 | 1241 | ); |
| 1257 | 1242 | } |
| @@ -1279,7 +1264,7 @@ export async function appDomainCheck(ctx: Ctx): Promise<Response> { | ||
| 1279 | 1264 | : { |
| 1280 | 1265 | tone: "danger", |
| 1281 | 1266 | message: |
| 1282 | APP_DOMAIN_ERRORS[checked.error] ?? "No domain by that name.", | |
| 1267 | domainRefusalText(checked.error, NO_SUCH_DOMAIN), | |
| 1283 | 1268 | }, |
| 1284 | 1269 | ); |
| 1285 | 1270 | } |
| @@ -1296,8 +1281,8 @@ export async function appDomainDelete(ctx: Ctx): Promise<Response> { | ||
| 1296 | 1281 | ctx, |
| 1297 | 1282 | `${repoBase(repo)}/settings/app`, |
| 1298 | 1283 | removed.ok |
| 1299 | ? { message: `${host} is removed. The forge no longer answers there.` } | |
| 1300 | : { tone: "danger", message: "No domain by that name." }, | |
| 1284 | ? { message: domainRemovedText(host) } | |
| 1285 | : { tone: "danger", message: NO_SUCH_DOMAIN }, | |
| 1301 | 1286 | ); |
| 1302 | 1287 | } |
| 1303 | 1288 |
test/tui-deploys.test.ts +99
| @@ -205,3 +205,102 @@ test("production's terminal names the newest open preview to a reader, and a gue | ||
| 205 | 205 | await runtime.stop(); |
| 206 | 206 | } |
| 207 | 207 | }); |
| 208 | ||
| 209 | test("the app's admin lists, adds, checks and removes a domain; a reader is offered none of it", async () => { | |
| 210 | const { AppsRuntime } = await import("../src/apps/runtime.ts"); | |
| 211 | const { listAppDomains } = await import("../src/actions/domains.ts"); | |
| 212 | const none = () => Promise.reject(Object.assign(new Error("x"), { code: "ENODATA" })); | |
| 213 | const runtime = new AppsRuntime(forge, { | |
| 214 | domain: "apps.test", | |
| 215 | scheme: "https", | |
| 216 | forgeUrl: "https://forge.test", | |
| 217 | concurrency: 1, | |
| 218 | dns: { resolveTxt: none, resolveCname: none }, | |
| 219 | }); | |
| 220 | const r = await createRepo(forge, alice, { name: "parlour", visibility: "public" }); | |
| 221 | if (!r.ok) throw new Error("repo failed"); | |
| 222 | const made = createAppRecord(forge.db, { repoId: r.value.id, slug: "parlour", access: "public" }); | |
| 223 | if (!made.ok) throw new Error(made.error); | |
| 224 | const drive = async (who: User, keys: string[], of: Repo = r.value): Promise<string> => { | |
| 225 | const terminal = headlessTerminal(140, 30); | |
| 226 | const { app, session } = createForgeApp( | |
| 227 | forge, | |
| 228 | { sshUsername: who.username, remoteAddress: "127.0.0.1", user: who }, | |
| 229 | { terminal, exitHooks: false }, | |
| 230 | ); | |
| 231 | app.start(); | |
| 232 | await settleApp(app); | |
| 233 | app.push(deploysScreen(session, app.ctx, of)); | |
| 234 | await settleApp(app); | |
| 235 | for (const k of keys) { | |
| 236 | terminal.feed(k === "enter" ? "\r" : k); | |
| 237 | await settleApp(app); | |
| 238 | } | |
| 239 | const text = app.screen.toText(); | |
| 240 | app.stop(); | |
| 241 | return text; | |
| 242 | }; | |
| 243 | const hosts = () => { | |
| 244 | const listed = listAppDomains(forge, alice, r.value); | |
| 245 | return listed.ok ? listed.value.map((d) => d.host) : []; | |
| 246 | }; | |
| 247 | try { | |
| 248 | // A bad name and a taken one are refused in the page's words, and nothing is added. | |
| 249 | expect(await drive(alice, ["n", "+", ..."not a host", "enter"])).toContain("A domain is a hostname"); | |
| 250 | expect(hosts()).toEqual([]); | |
| 251 | ||
| 252 | expect(await drive(alice, ["n", "+", ..."parlour.example.com", "enter"])).toContain("parlour.example.com is added."); | |
| 253 | expect(hosts()).toEqual(["parlour.example.com"]); | |
| 254 | const listed = await drive(alice, []); | |
| 255 | expect(listed).toContain("Domains: parlour.example.com waiting for DNS"); | |
| 256 | ||
| 257 | const other = await createRepo(forge, alice, { name: "salon", visibility: "public" }); | |
| 258 | if (!other.ok) throw new Error("repo failed"); | |
| 259 | const otherApp = createAppRecord(forge.db, { repoId: other.value.id, slug: "salon", access: "public" }); | |
| 260 | if (!otherApp.ok) throw new Error(otherApp.error); | |
| 261 | expect(await drive(alice, ["n", "+", ..."parlour.example.com", "enter"], other.value)).toContain( | |
| 262 | "Another app already answers to that domain.", | |
| 263 | ); | |
| 264 | ||
| 265 | const checked = await drive(alice, ["n", "c"]); | |
| 266 | expect(checked).toContain("No TXT record at _forge-verify.parlour.example.com yet."); | |
| 267 | expect(checked).toContain("Still needed: a TXT record at _forge-verify.parlour.example.com"); | |
| 268 | ||
| 269 | // A reader sees neither the domains nor the key, and the screen refuses them in words. | |
| 270 | const d = await signUp(forge, { | |
| 271 | username: "dana", | |
| 272 | email: "[email protected]", | |
| 273 | password: "a-long-passphrase", | |
| 274 | }, { slot: fixtureSlot(forge) }); | |
| 275 | if (!d.ok) throw new Error("signup failed"); | |
| 276 | const reader = await drive(d.value, ["n"]); | |
| 277 | expect(reader).not.toContain("parlour.example.com"); | |
| 278 | expect(reader).not.toContain("the app's own domains"); | |
| 279 | const { domainsScreen } = await import("../src/tui/screens/deploys.ts"); | |
| 280 | const terminal = headlessTerminal(140, 30); | |
| 281 | const { app, session } = createForgeApp( | |
| 282 | forge, | |
| 283 | { sshUsername: "dana", remoteAddress: "127.0.0.1", user: d.value }, | |
| 284 | { terminal, exitHooks: false }, | |
| 285 | ); | |
| 286 | app.start(); | |
| 287 | await settleApp(app); | |
| 288 | app.push(domainsScreen(session, app.ctx, r.value)); | |
| 289 | await settleApp(app); | |
| 290 | terminal.feed("+"); | |
| 291 | await settleApp(app); | |
| 292 | const refused = app.screen.toText(); | |
| 293 | app.stop(); | |
| 294 | expect(refused).toContain("Only the app's admin manages its domains."); | |
| 295 | expect(refused).not.toContain("Add a domain"); | |
| 296 | ||
| 297 | // `-` asks first: `n` keeps it, `y` removes it. | |
| 298 | expect(await drive(alice, ["n", "-"])).toContain("remove parlour.example.com? y/n"); | |
| 299 | await drive(alice, ["n", "-", "n"]); | |
| 300 | expect(hosts()).toEqual(["parlour.example.com"]); | |
| 301 | expect(await drive(alice, ["n", "-", "y"])).toContain("parlour.example.com is removed."); | |
| 302 | expect(hosts()).toEqual([]); | |
| 303 | } finally { | |
| 304 | await runtime.stop(); | |
| 305 | } | |
| 306 | }); |
new above
offline