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

Merged opened this

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

Merged

mason/forge:mason/7322-the-terminal-s-deploys-screen-lists-an-a → main 2 commits fork

  • Merged. mason/7322-the-terminal-s-deploys-screen-lists-an-a was merged into main as f8781d5.

Review

Sign in to review this change.
8 files changed +407 −48
docs/APPS.md +5 −3
@@ -971,7 +971,8 @@ browser.
971971 An app can answer at a name you own — `app.example.com`, or an apex like
972972 `example.com` — beside the hostname the forge gave it. Under **Settings →
973973 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
975976 shown the two records to publish in the zone:
976977
977978 - 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:
982983 provider's ALIAS or ANAME record instead, or A and AAAA records with the
983984 address the forge is served at.
984985
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;
987989 the domain is verified when some record there is exactly the value shown.
988990 Every check leaves one sentence a person can act on ("No TXT record at
989991 `_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
203203
204204 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.
205205
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
206208 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.
207209
208210 `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(
440440 };
441441 }
442442
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
443461 /** A one-field modal: a name to type, a username to add, a branch to create. */
444462 export function promptScreen(
445463 ctx: Ctx,
src/tui/screens/deploys.ts +215 −3
@@ -19,6 +19,14 @@
1919 * A writer — whom the MCP tool `probe` lets knock — gets `p`: the same
2020 * `probeApp` against the running production app, every door tried as
2121 * 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.
2230 */
2331
2432 import {
@@ -31,6 +39,22 @@ import {
3139 stopEnv,
3240 type AppPullLine,
3341 } 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";
3458 import { envLabel, envRefusalText, redeployedText, stoppedText } from "../../actions/env_words.ts";
3559 import { trendLine, visitorsLine } from "../../apps/vitals.ts";
3660 import { listRepoRuns } from "../../actions/runs.ts";
@@ -48,7 +72,16 @@ import { style } from "../term/style.ts";
4872 import { truncate } from "../term/width.ts";
4973 import { fill, len, split } from "../ui/layout.ts";
5074 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";
5285 import { RunList, openRunLog, runRows } from "./runlist.ts";
5386
5487 const LIMIT = 50;
@@ -99,6 +132,8 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
99132 let side: string[] = [];
100133 let pulls: AppPullLine[] = [];
101134 let production: Production = { state: "", redeploy: false, stop: false };
135 /** The app's domains, or undefined for anyone `listAppDomains` refuses. */
136 let domains: AppDomainView[] | undefined;
102137
103138 const read = (): void => {
104139 const runs = readDeploys(f, repo);
@@ -114,6 +149,8 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
114149 side = appSideLines(f.app, f.user, repo);
115150 pulls = appPullLines(f.app, f.user, repo);
116151 production = readProduction(f, repo);
152 const listed = listAppDomains(f.app, f.user, repo);
153 domains = listed.ok ? listed.value : undefined;
117154 loaded = true;
118155 ctx.invalidate();
119156 };
@@ -167,13 +204,22 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
167204 ...(production.redeploy ? [binding(["D"], "redeploy production")] : []),
168205 ...(production.stop ? [binding(["S"], "stop production")] : []),
169206 ...(mayProbe ? [binding(["p"], "probe the running app's doors")] : []),
207 ...(domains !== undefined ? [binding(["n"], "the app's own domains")] : []),
170208 ];
171209 },
172210 load: async () => read(),
173211 render(area, s) {
174212 const c = ctx.theme.c;
213 const named = domainsLine(domains);
175214 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 ),
177223 fill(1),
178224 len(pulls.length === 0 ? 0 : pulls.length + 1),
179225 ]);
@@ -199,7 +245,7 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
199245 style(c.mutedFg, c.bg),
200246 head!.x + head!.w,
201247 );
202 [visitors, trend, pace, ...side]
248 [visitors, trend, pace, ...side, named]
203249 .filter((line) => line !== "")
204250 .forEach((line, i) =>
205251 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 {
250296 ctx.push(probeScreen(f, ctx, repo));
251297 return true;
252298 }
299 if (e.key === "n" && domains !== undefined) {
300 ctx.push(domainsScreen(f, ctx, repo, read));
301 return true;
302 }
253303 return false;
254304 },
255305 };
@@ -378,3 +428,165 @@ function probeScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
378428 },
379429 };
380430 }
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";
1919 import { fill, len, split } from "../ui/layout.ts";
2020 import { emptyState } from "../ui/widgets.ts";
2121 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";
2323 import { pullScreen } from "./pull.ts";
2424 import { localRepo, sourceName, whereRows, whereStrip } from "./where.ts";
2525
@@ -220,18 +220,3 @@ export function wavesScreen(f: Session, ctx: Ctx, src: RepoSource): Screen {
220220 };
221221 return self;
222222 }
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 {
2525 removeAppDomain,
2626 type AppDomainView,
2727 } from "../../actions/domains.ts";
28 import {
29 domainAddedText,
30 domainRefusalText,
31 domainRemovedText,
32 NO_SUCH_DOMAIN,
33 } from "../../actions/domain_words.ts";
2834 import {
2935 egressView,
3036 grantAppEgress,
@@ -1213,27 +1219,6 @@ export async function appEnvDelete(ctx: Ctx): Promise<Response> {
12131219 );
12141220 }
12151221
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
12371222 // POST /:owner/:name/settings/app/domains — add one.
12381223 export async function appDomainAdd(ctx: Ctx): Promise<Response> {
12391224 const gate = await adminForm(ctx);
@@ -1246,12 +1231,12 @@ export async function appDomainAdd(ctx: Ctx): Promise<Response> {
12461231 `${repoBase(repo)}/settings/app`,
12471232 added.ok
12481233 ? {
1249 message: `${added.value.host} is added. Publish ${recordsInWords(added.value.records)}, then press Check now.`,
1234 message: domainAddedText(added.value),
12501235 }
12511236 : {
12521237 tone: "danger",
12531238 message:
1254 APP_DOMAIN_ERRORS[added.error] ?? "That domain could not be added.",
1239 domainRefusalText(added.error, "That domain could not be added."),
12551240 },
12561241 );
12571242 }
@@ -1279,7 +1264,7 @@ export async function appDomainCheck(ctx: Ctx): Promise<Response> {
12791264 : {
12801265 tone: "danger",
12811266 message:
1282 APP_DOMAIN_ERRORS[checked.error] ?? "No domain by that name.",
1267 domainRefusalText(checked.error, NO_SUCH_DOMAIN),
12831268 },
12841269 );
12851270 }
@@ -1296,8 +1281,8 @@ export async function appDomainDelete(ctx: Ctx): Promise<Response> {
12961281 ctx,
12971282 `${repoBase(repo)}/settings/app`,
12981283 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 },
13011286 );
13021287 }
13031288
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
205205 await runtime.stop();
206206 }
207207 });
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs