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

forked from trevato/cs · proposes

The terminal boots headlessly: src/tty.ts takes its streams, and a session is under test #175

Merged opened this

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

Merged

torque/cs-engine:torque/109-the-terminal-boots-headlessly-src-tty-ts → main 1 commit fork

  • Merged. torque/109-the-terminal-boots-headlessly-src-tty-ts was merged into main as 1de62ea.

Review

Sign in to review this change.
5 files changed +490 −43
README.md +18
@@ -652,6 +652,24 @@ command: nothing, quit, or delete this match). Both are plain data in and out,
652652 so their tests press keys and read lines with no terminal and no database —
653653 with the escapes taken off by `plain()`, so an expectation is a sentence.
654654
655 The wiring is under test too. `run(io)` takes everything the session reaches
656 for — an input that can go raw, an output that takes strings, a resize source,
657 an exit, an open database and the environment — as one plain object, and the
658 last lines of `src/tty.ts` are the only place `process` and `openDatabase()`
659 are named, under `import.meta.main`. So `src/tty.test.ts` runs a whole session
660 headlessly against objects it made up and a real SQLite database under a
661 temporary directory: the first paint, <kbd>Tab</kbd> through all three tabs,
662 <kbd>j</kbd>/<kbd>k</kbd> stopping at both ends, <kbd>r</kbd> picking up a row
663 written behind its back, <kbd>x</kbd> deleting the match under the cursor, and
664 the terminal handed back exactly once — on <kbd>q</kbd>, on <kbd>ctrl-C</kbd>,
665 and when a draw throws, which ends the session with the tty restored and a line
666 saying what happened rather than a raw shell. No real stdin is ever listened
667 to. What a line of ANSI says is also an output to escape: every string the
668 screen did not write itself — a name, a timestamp, the note, the environment
669 pill, the ssh command — goes through `printable()` in `src/terminal/screen.ts`,
670 which turns each control character into a middle dot, one for one so the
671 columns still count. A player whose name carries `\x1b[2J` clears nobody's screen.
672
655673 The forge sets `HOST`, `PORT`, `FORGE_APP_DATA`, `FORGE_APP_ENV` (`production`
656674 or `preview`), `FORGE_APP_URL`, `FORGE_APP_SSH`, `FORGE_APP_COMMIT` and
657675 `FORGE_APP_IDENTITY_KEY` when it starts the app. Nothing else is read from the
src/terminal/screen.test.ts +14 −1
@@ -8,7 +8,7 @@
88 import { expect, test } from "bun:test";
99 import type { Card } from "../client/weights.ts";
1010 import type { MatchRow, Standing } from "../db.ts";
11 import { clamp, plain, rowsOf, screen, type Data, type View } from "./screen.ts";
11 import { clamp, plain, printable, rowsOf, screen, type Data, type View } from "./screen.ts";
1212
1313 const CARD: Card = {
1414 trainedAt: "2026-09-14",
@@ -161,6 +161,19 @@ test("the cursor never draws past the end of the list, and the policy card has n
161161 expect(plain(lines[6]!)).toStartWith("▸ bo");
162162 });
163163
164 test("a name out of the database is text, not a program: every control character is a dot", () => {
165 expect(printable("evil\x1b[2J")).toBe("evil·[2J");
166 expect(printable("two\r\nlines")).toBe("two··lines");
167 expect(printable("eight bit \x9b2J")).toBe("eight bit ·2J");
168 expect(printable("plain kestrel")).toBe("plain kestrel");
169 // One character in, one out, so a padded column is still the width it says.
170 expect(printable("\x1b[2J\x1b[H").length).toBe("\x1b[2J\x1b[H".length);
171 // And the row it lands in carries no escape of its own.
172 const row = screen(view(), data({ standings: [standing("\x1b[2Jace")] }))[5]!;
173 expect(row).not.toContain("\x1b[2J");
174 expect(plain(row)).toStartWith("▸ ·[2Jace");
175 });
176
164177 test("plain() takes off exactly the escapes and leaves the words", () => {
165178 expect(plain("\x1b[1mcs\x1b[0m \x1b[2mlocal\x1b[0m")).toBe("cs local");
166179 expect(plain("\x1b[33mnothing removed\x1b[0m")).toBe("nothing removed");
src/terminal/screen.ts +28 −7
@@ -6,6 +6,11 @@
66 * does all three around it, and a test calls `screen()` with neither.
77 *
88 * Column widths are the ones a person already knows; do not move them.
9 *
10 * Everything that came from outside this file — a name, a timestamp, the note,
11 * the environment pill and the ssh command — goes through `printable()` on the
12 * way into a line, because a line of ANSI is a program: a name carrying an
13 * escape would otherwise clear the screen or move the cursor out of its row.
914 */
1015
1116 import type { Card } from "../client/weights.ts";
@@ -56,11 +61,25 @@ export const clamp = (cursor: number, rows: number): number =>
5661 */
5762 export const plain = (s: string): string => s.replace(/\x1b\[[0-9;]*[A-Za-z]/g, "");
5863
64 /**
65 * Text that came from somewhere else — a player's name, a timestamp, the note
66 * under the table — made safe to put inside a line of ANSI. Every control
67 * character becomes a middle dot: the escape above all, but the 8-bit CSI, a
68 * carriage return and a newline too, because each of them is also a way to
69 * move the cursor out of the row it was given. One character in, one character
70 * out, so the columns below still count what they always counted.
71 *
72 * A name is checked on the way in (`cleanName` in `src/result.ts`), and it is
73 * checked again here on the way out: the screen does not trust the database to
74 * hold only what today's boundary would have let through.
75 */
76 export const printable = (s: string): string => s.replace(/[\u0000-\u001f\u007f-\u009f]/g, "·");
77
5978 const header = (tab: Tab): string =>
6079 TABS.map((t) => (t === tab ? `${esc}7m ${t} ${esc}0m` : `${esc}2m ${t} ${esc}0m`)).join("");
6180
6281 function policyLines(card: Card): string[] {
63 const line = (k: string, v: string): string => ` ${esc}2m${pad(k, 22)}${esc}0m${v}`;
82 const line = (k: string, v: string): string => ` ${esc}2m${pad(k, 22)}${esc}0m${printable(v)}`;
6483 return [
6584 ` ${esc}1mthe policy the bots fight with${esc}0m`,
6685 "",
@@ -82,7 +101,7 @@ function standingsLines(table: Standing[], cursor: number): string[] {
82101 ];
83102 if (table.length === 0) lines.push(`${esc}2m nothing played yet${esc}0m`);
84103 table.forEach((s, i) => {
85 const line = `${pad(displayName(s), 26)}${pad(String(s.matches), 9)}${pad(String(s.kills), 7)}` +
104 const line = `${pad(printable(displayName(s)), 26)}${pad(String(s.matches), 9)}${pad(String(s.kills), 7)}` +
86105 `${pad(ratio(s.kills, s.deaths), 7)}${pad(headshotShare(s.headshots, s.kills), 6)}${s.wins}`;
87106 lines.push(i === cursor ? `${esc}7m▸ ${line}${esc}0m` : ` ${line}`);
88107 });
@@ -95,8 +114,8 @@ function matchLines(matches: MatchRow[], cursor: number): string[] {
95114 ];
96115 if (matches.length === 0) lines.push(`${esc}2m no matches yet${esc}0m`);
97116 matches.forEach((m, i) => {
98 const line = `${pad(displayName(m), 22)}${pad(`${m.score_mine}-${m.score_theirs}`, 8)}` +
99 `${pad(`${m.kills}/${m.deaths}`, 8)}${pad(String(m.damage), 8)}${m.updated_at}` +
117 const line = `${pad(printable(displayName(m)), 22)}${pad(`${m.score_mine}-${m.score_theirs}`, 8)}` +
118 `${pad(`${m.kills}/${m.deaths}`, 8)}${pad(String(m.damage), 8)}${printable(m.updated_at)}` +
100119 `${m.finished === 1 ? "" : " (live)"}`;
101120 lines.push(i === cursor ? `${esc}7m▸ ${line}${esc}0m` : ` ${line}`);
102121 });
@@ -107,7 +126,9 @@ function matchLines(matches: MatchRow[], cursor: number): string[] {
107126 export function screen(view: View, data: Data): string[] {
108127 const cursor = clamp(view.cursor, rowsOf(view.tab, data));
109128 const lines: string[] = [];
110 lines.push(`${esc}1mcs${esc}0m ${esc}2m${view.where} · ${data.total} match${data.total === 1 ? "" : "es"}${esc}0m`);
129 lines.push(
130 `${esc}1mcs${esc}0m ${esc}2m${printable(view.where)} · ${data.total} match${data.total === 1 ? "" : "es"}${esc}0m`,
131 );
111132 lines.push("");
112133 lines.push(header(view.tab));
113134 lines.push("");
@@ -118,7 +139,7 @@ export function screen(view: View, data: Data): string[] {
118139 lines.push(`${esc}2mj/k move · tab switch · x remove a match · r refresh · q quit${esc}0m`);
119140 lines.push(view.ssh === undefined
120141 ? `${esc}2mthe app's terminal is off${esc}0m`
121 : `${esc}2mreach this terminal with: ${view.ssh}${esc}0m`);
122 if (view.note !== "") lines.push(`${esc}33m${view.note}${esc}0m`);
142 : `${esc}2mreach this terminal with: ${printable(view.ssh)}${esc}0m`);
143 if (view.note !== "") lines.push(`${esc}33m${printable(view.note)}${esc}0m`);
123144 return lines;
124145 }
src/tty.test.ts added +310
@@ -0,0 +1,310 @@
1 /**
2 * A whole ssh session, run headlessly. `run(io)` takes its terminal as plain
3 * data, so this file hands it a keyboard nobody is typing on, a screen that is
4 * an array of strings, an exit that records its code and a real SQLite
5 * database under a temporary directory — the queries are the part worth
6 * exercising, so nothing about the database is faked.
7 *
8 * Nothing here touches the real process: `src/tty.ts` names `process` only
9 * under `import.meta.main`, so importing it registers no listener on the real
10 * stdin, sets no raw mode and exits nothing.
11 *
12 * The frames are read with `plain()` off `screen.ts`, so an expectation is a
13 * sentence. A frame is one `write`: the clear, then the lines joined with CRLF.
14 */
15
16 import { expect, test } from "bun:test";
17 import type { Database } from "bun:sqlite";
18 import { mkdtempSync } from "node:fs";
19 import { tmpdir } from "node:os";
20 import { join } from "node:path";
21 import { matchCount, openDatabase, saveMatch, type Player } from "./db.ts";
22 import type { Result } from "./result.ts";
23 import { plain } from "./terminal/screen.ts";
24 import { run, type Io } from "./tty.ts";
25
26 /** What `src/tty.ts` writes to wipe the screen, at the top of every frame. */
27 const CLEAR = "\x1b[2J\x1b[H";
28
29 const result = (over: Partial<Result> = {}): Result => ({
30 matchId: crypto.randomUUID(),
31 name: "the player says the name",
32 rounds: 5, scoreMine: 3, scoreTheirs: 2,
33 kills: 9, deaths: 4, headshots: 3, damage: 1200, seconds: 420, finished: true,
34 ...over,
35 });
36
37 const player = (name: string): Player => ({ userId: `u-${name}`, session: "s", name });
38
39 /** One recorded match for a signed-in player, straight into the database. */
40 function play(db: Database, name: string, over: Partial<Result> = {}): string {
41 const match = result(over);
42 saveMatch(db, player(name), match);
43 return match.matchId;
44 }
45
46 type Session = {
47 db: Database;
48 /** Every string written to the screen, oldest first. One a frame. */
49 writes: string[];
50 /** What raw mode was set to, in order. A session sets it twice. */
51 raw: boolean[];
52 /** The codes the session exited with. */
53 exits: number[];
54 /** Whether the keyboard was resumed and what it was decoded as. */
55 resumed: number;
56 encoding: string;
57 press(key: string): void;
58 /** Fire a resize at this width. Nothing reads it; the terminal says it. */
59 resizeTo(columns: number): void;
60 /** The last frame drawn, escapes off, one string a line. */
61 frame(): string[];
62 /** The last frame as it was written, without the leading clear. */
63 body(): string;
64 /** The line the cursor is on, escapes off, or "" when no row is marked. */
65 marked(): string;
66 };
67
68 /**
69 * Boot a session on a fresh database. `seed` runs before the first paint, so a
70 * test can decide what is on the screen when the person arrives.
71 */
72 function session(
73 options: { env?: Record<string, string | undefined>; seed?: (db: Database) => void } = {},
74 ): Session {
75 const db = openDatabase(mkdtempSync(join(tmpdir(), "tty-")));
76 options.seed?.(db);
77
78 const writes: string[] = [];
79 const raw: boolean[] = [];
80 const exits: number[] = [];
81 const keys: ((key: string) => void)[] = [];
82 const resizes: (() => void)[] = [];
83 const state = { resumed: 0, encoding: "", columns: 80 };
84
85 const input = {
86 setRawMode: (on: boolean) => { raw.push(on); },
87 resume: () => { state.resumed++; },
88 setEncoding: (encoding: "utf8") => { state.encoding = encoding; },
89 on: (_event: "data", listener: (key: string) => void) => { keys.push(listener); },
90 };
91 // A real stdout carries its width, so this one does too — and, like the real
92 // session, nothing reads it: `screen.ts` owns the columns.
93 const output = { write: (text: string) => { writes.push(text); }, get columns() { return state.columns; } };
94 const resize = { on: (_event: "resize", listener: () => void) => { resizes.push(listener); } };
95 const io: Io = { input, output, resize, exit: (code) => { exits.push(code); }, db, env: options.env ?? {} };
96 run(io);
97
98 const frame = (): string[] => (writes.at(-1) ?? "").slice(CLEAR.length).split("\r\n").map(plain);
99 return {
100 db, writes, raw, exits,
101 get resumed() { return state.resumed; },
102 get encoding() { return state.encoding; },
103 press: (key: string) => { for (const listener of keys) listener(key); },
104 resizeTo: (columns: number) => { state.columns = columns; for (const fire of resizes) fire(); },
105 frame,
106 body: () => (writes.at(-1) ?? "").slice(CLEAR.length),
107 marked: () => frame().find((line) => line.startsWith("▸")) ?? "",
108 };
109 }
110
111 test("the first paint is the standings, under the environment and the match count", () => {
112 const tty = session({
113 env: { FORGE_APP_SSH: "ssh -p 2222 cs@localhost" },
114 seed: (db) => { play(db, "ace"); play(db, "bo", { kills: 3 }); },
115 });
116 // Raw, resumed and decoded before anything is drawn, and drawn exactly once.
117 expect(tty.raw).toEqual([true]);
118 expect(tty.resumed).toBe(1);
119 expect(tty.encoding).toBe("utf8");
120 expect(tty.writes.length).toBe(1);
121 expect(tty.writes[0]).toStartWith(CLEAR);
122
123 const lines = tty.frame();
124 expect(lines[0]).toBe("cs local · 2 matches");
125 expect(lines[2]).toBe(" standings matches policy ");
126 expect(tty.marked()).toStartWith("▸ ace");
127 expect(lines.some((line) => line.startsWith(" bo"))).toBe(true);
128 expect(tty.exits).toEqual([]);
129 });
130
131 test("the session prints FORGE_APP_SSH verbatim, and says the terminal is off when it is unset", () => {
132 const on = session({ env: { FORGE_APP_SSH: "ssh -p 2222 [email protected]" } });
133 expect(on.frame().at(-1)).toBe("reach this terminal with: ssh -p 2222 [email protected]");
134
135 const off = session();
136 expect(off.frame().at(-1)).toBe("the app's terminal is off");
137 // Nothing that looks like an address is made up in its place.
138 expect(off.frame().join("\n")).not.toContain("@");
139 expect(off.frame().join("\n")).not.toContain("ssh ");
140 });
141
142 test("a preview says which pull it is, and production says production", () => {
143 const preview = session({ env: { FORGE_APP_ENV: "preview", FORGE_APP_PULL: "109" } });
144 expect(preview.frame()[0]).toBe("cs preview of #109 · 0 matches");
145 expect(session({ env: { FORGE_APP_ENV: "production" } }).frame()[0]).toBe("cs production · 0 matches");
146 // A preview that was not told its number still says it is one.
147 expect(session({ env: { FORGE_APP_ENV: "preview" } }).frame()[0]).toBe("cs preview of #? · 0 matches");
148 });
149
150 test("tab cycles the three tabs, and every press draws exactly one frame", () => {
151 const tty = session({ seed: (db) => { play(db, "ace"); } });
152 expect(tty.frame()[4]).toStartWith(" player matches");
153 tty.press("\t");
154 expect(tty.frame()[2]).toBe(" standings matches policy ");
155 expect(tty.frame()[4]).toStartWith(" player score");
156 tty.press("\t");
157 expect(tty.frame()[4]).toBe(" the policy the bots fight with");
158 tty.press("\t");
159 expect(tty.frame()[4]).toStartWith(" player matches");
160 expect(tty.writes.length).toBe(4);
161 });
162
163 test("j and k move the cursor and stop at both ends of the list", () => {
164 const tty = session({
165 seed: (db) => { play(db, "ace"); play(db, "bo", { kills: 3 }); play(db, "cy", { kills: 1 }); },
166 });
167 expect(tty.marked()).toStartWith("▸ ace");
168 tty.press("j");
169 expect(tty.marked()).toStartWith("▸ bo");
170 tty.press("j");
171 tty.press("j");
172 tty.press("j");
173 // Three rows, so the bottom one is where it stays.
174 expect(tty.marked()).toStartWith("▸ cy");
175 tty.press("k");
176 expect(tty.marked()).toStartWith("▸ bo");
177 tty.press("k");
178 tty.press("k");
179 expect(tty.marked()).toStartWith("▸ ace");
180 // The arrow keys are the same keys.
181 tty.press("\x1b[B");
182 expect(tty.marked()).toStartWith("▸ bo");
183 tty.press("\x1b[A");
184 expect(tty.marked()).toStartWith("▸ ace");
185 // Only ever one row is marked.
186 expect(tty.frame().filter((line) => line.startsWith("▸")).length).toBe(1);
187 });
188
189 test("r picks up a match written behind the session's back", () => {
190 const tty = session({ seed: (db) => { play(db, "ace"); } });
191 expect(tty.frame()[0]).toBe("cs local · 1 match");
192
193 play(tty.db, "newcomer", { kills: 30 });
194 // Not on the screen until it is asked for: the session queries when it draws.
195 expect(tty.frame().some((line) => line.includes("newcomer"))).toBe(false);
196 tty.press("r");
197 expect(tty.frame()[0]).toBe("cs local · 2 matches");
198 expect(tty.marked()).toStartWith("▸ newcomer");
199 });
200
201 test("x on the matches tab removes the match under the cursor and says whose it was", () => {
202 const tty = session({ seed: (db) => { play(db, "ace"); play(db, "bo", { kills: 3 }); } });
203 tty.press("\t");
204 tty.press("j");
205 const doomed = tty.marked().slice(2).split(" ")[0];
206 tty.press("x");
207
208 expect(matchCount(tty.db)).toBe(1);
209 expect(tty.frame().at(-1)).toBe(`removed ${doomed}'s match`);
210 expect(tty.frame().some((line) => line.includes(`${doomed} `))).toBe(false);
211 // The cursor stayed inside the list it just shortened.
212 expect(tty.frame().filter((line) => line.startsWith("▸")).length).toBe(1);
213 // The next press clears the note: it described the press before it.
214 tty.press("r");
215 expect(tty.frame().at(-1)).toBe("the app's terminal is off");
216
217 // x on the standings tab is not a delete, and takes nothing with it.
218 tty.press("\t");
219 tty.press("\t");
220 tty.press("x");
221 expect(matchCount(tty.db)).toBe(1);
222 });
223
224 test("x with nothing under the cursor removes nothing and does not throw", () => {
225 const tty = session();
226 tty.press("\t");
227 tty.press("x");
228 expect(matchCount(tty.db)).toBe(0);
229 expect(tty.frame()[5]).toBe(" no matches yet");
230 expect(tty.exits).toEqual([]);
231 });
232
233 test("q gives the terminal back exactly once and leaves", () => {
234 const tty = session({ seed: (db) => { play(db, "ace"); } });
235 tty.press("q");
236 expect(tty.raw).toEqual([true, false]);
237 expect(tty.writes.at(-1)).toBe(CLEAR);
238 expect(tty.exits).toEqual([0]);
239
240 // A pty can deliver another key after the exit; the session is over.
241 const drawn = tty.writes.length;
242 tty.press("j");
243 tty.press("q");
244 tty.resizeTo(100);
245 expect(tty.writes.length).toBe(drawn);
246 expect(tty.raw).toEqual([true, false]);
247 expect(tty.exits).toEqual([0]);
248 });
249
250 test("ctrl-c leaves the same way q does", () => {
251 const tty = session();
252 tty.press("\x03");
253 expect(tty.raw).toEqual([true, false]);
254 expect(tty.writes.at(-1)).toBe(CLEAR);
255 expect(tty.exits).toEqual([0]);
256 });
257
258 test("a draw that throws still gives the terminal back, once, and says what happened", () => {
259 const tty = session({ seed: (db) => { play(db, "ace"); } });
260 // The database goes out from under the session: the next draw's query throws.
261 tty.db.close();
262 tty.press("j");
263
264 expect(tty.raw).toEqual([true, false]);
265 expect(tty.exits).toEqual([1]);
266 expect(tty.writes.at(-1)).toStartWith(CLEAR);
267 expect(tty.body()).toStartWith("the terminal stopped: ");
268 // And it is over: no further key redraws, and raw mode is not set again.
269 const drawn = tty.writes.length;
270 tty.press("q");
271 expect(tty.writes.length).toBe(drawn);
272 expect(tty.raw).toEqual([true, false]);
273 expect(tty.exits).toEqual([1]);
274 });
275
276 test("a resize redraws, and a 20-column terminal and a 200-column one both draw", () => {
277 const tty = session({ seed: (db) => { play(db, "ace"); } });
278 const first = tty.frame();
279
280 tty.resizeTo(20);
281 expect(tty.writes.length).toBe(2);
282 expect(tty.frame()).toEqual(first);
283
284 tty.resizeTo(200);
285 expect(tty.writes.length).toBe(3);
286 // The columns are `screen.ts`'s and do not move with the window: what this
287 // pins is that neither extreme throws and neither changes the layout.
288 expect(tty.frame()).toEqual(first);
289 expect(tty.exits).toEqual([]);
290 });
291
292 test("a name out of the database cannot move the cursor or clear the screen", () => {
293 // A row written before `cleanName` existed, or by anything that is not the
294 // web boundary: the screen is the last place this can be caught.
295 const attack = "evil\x1b[2J\x1b[Hpwn";
296 const tty = session({ seed: (db) => { play(db, attack); } });
297
298 // The only clear in the frame is the one the session wrote itself, first.
299 expect(tty.writes.at(-1)).toStartWith(CLEAR);
300 expect(tty.body()).not.toContain("\x1b[2J");
301 expect(tty.body()).not.toContain("\x1b[H");
302 expect(tty.marked()).toStartWith("▸ evil·[2J·[Hpwn");
303
304 // And the same name coming back as the note after a delete is no escape either.
305 tty.press("\t");
306 tty.press("x");
307 expect(tty.body()).not.toContain("\x1b[2J");
308 expect(tty.frame().at(-1)).toBe(`removed ${attack.replace(/\x1b/g, "·")}'s match`);
309 expect(matchCount(tty.db)).toBe(0);
310 });
src/tty.ts +120 −35
@@ -8,49 +8,134 @@
88 * This file is the wiring only — the database, raw stdin, stdout, resize and
99 * exit. What it draws is `src/terminal/screen.ts` and what it answers is
1010 * `src/terminal/keys.ts`, both plain data in and out, both under test.
11 *
12 * Nothing in here is a process. `run(io)` takes everything the session reaches
13 * for — an input that can go raw, an output that takes strings, a resize
14 * source, an exit, an open database and the environment — as one plain object,
15 * typed by the members it actually uses the way `src/client/main.ts` types its
16 * host. The last lines of this file are the only place `process` and
17 * `openDatabase()` are named, and they run only when this file is the program:
18 * `tty.test.ts` imports `run` and drives a whole session against plain objects
19 * and a database under a temporary directory, touching no real terminal.
1120 */
1221
22 import type { Database } from "bun:sqlite";
1323 import { matchCount, openDatabase, recentMatches, standings, deleteMatch } from "./db.ts";
1424 import { CARD } from "./client/weights.ts";
15 import { screen, type Data, type View } from "./terminal/screen.ts";
25 import { printable, screen, type Data, type View } from "./terminal/screen.ts";
1626 import { press, removed } from "./terminal/keys.ts";
1727
18 const db = openDatabase();
28 // --- the io: what a terminal has to be for this session to run on it -------
1929
20 let view: View = {
21 tab: "standings",
22 cursor: 0,
23 note: "",
24 where: process.env.FORGE_APP_ENV === "preview"
25 ? `preview of #${process.env.FORGE_APP_PULL ?? "?"}`
26 : (process.env.FORGE_APP_ENV ?? "local"),
27 ssh: process.env.FORGE_APP_SSH,
30 /** The keyboard: raw, decoded, and handing this file one key at a time. */
31 export type Keys = {
32 /** A pipe is not a tty and has none, which is why it is optional. */
33 setRawMode?(raw: boolean): void;
34 resume(): void;
35 setEncoding(encoding: "utf8"): void;
36 on(event: "data", listener: (key: string) => void): void;
2837 };
2938
30 const read = (): Data => ({
31 standings: standings(db, 20),
32 matches: recentMatches(db, 20),
33 total: matchCount(db),
34 card: CARD,
35 });
39 /** The screen: everything this file knows how to do to one. */
40 export type Writes = { write(text: string): void };
3641
37 const clear = "\x1b[2J\x1b[H";
38 const draw = (): void => { process.stdout.write(clear + screen(view, read()).join("\r\n")); };
42 /** Whatever says the window changed size. `process.stdout` is one. */
43 export type Resizes = { on(event: "resize", listener: () => void): void };
3944
40 process.stdin.setRawMode?.(true);
41 process.stdin.resume();
42 process.stdin.setEncoding("utf8");
43 draw();
44 process.stdin.on("data", (key: string) => {
45 const step = press(view, key, read());
46 view = step.view;
47 if (step.command.kind === "quit") {
48 process.stdout.write(clear);
49 process.exit(0);
50 }
51 if (step.command.kind === "delete") {
52 view = removed(view, step.command.name, deleteMatch(db, step.command.id));
53 }
54 draw();
55 });
56 process.stdout.on("resize", draw);
45 export type Io = {
46 input: Keys;
47 output: Writes;
48 resize: Resizes;
49 /** Ends the session. The real one never returns; a test's does. */
50 exit(code: number): void;
51 /** Already open: the session queries it and never decides where it lives. */
52 db: Database;
53 /** FORGE_APP_ENV, FORGE_APP_PULL and FORGE_APP_SSH are all that is read. */
54 env: Record<string, string | undefined>;
55 };
56
57 /** Wipe the screen and put the cursor home. Written on every paint. */
58 const CLEAR = "\x1b[2J\x1b[H";
59
60 /** The environment pill: `local`, `production`, `preview of #12`. */
61 const whereOf = (env: Io["env"]): string =>
62 env.FORGE_APP_ENV === "preview" ? `preview of #${env.FORGE_APP_PULL ?? "?"}` : (env.FORGE_APP_ENV ?? "local");
63
64 /**
65 * Run a session until it quits. Returns once the listeners are registered —
66 * every keypress after that is the input's to deliver.
67 */
68 export function run(io: Io): void {
69 let view: View = {
70 tab: "standings",
71 cursor: 0,
72 note: "",
73 where: whereOf(io.env),
74 // FORGE_APP_SSH verbatim, or undefined: the screen then says the terminal
75 // is off. No address is ever made up here.
76 ssh: io.env.FORGE_APP_SSH,
77 };
78
79 const read = (): Data => ({
80 standings: standings(io.db, 20),
81 matches: recentMatches(io.db, 20),
82 total: matchCount(io.db),
83 card: CARD,
84 });
85
86 const draw = (): void => { io.output.write(CLEAR + screen(view, read()).join("\r\n")); };
87
88 // The terminal is handed back exactly once, however the session ends: raw
89 // mode off first, so a write that fails still leaves a usable shell behind.
90 let over = false;
91 const finish = (code: number, note = ""): void => {
92 if (over) return;
93 over = true;
94 io.input.setRawMode?.(false);
95 io.output.write(CLEAR + note);
96 io.exit(code);
97 };
98
99 /** Anything that can throw runs in here: a broken draw gives the tty back. */
100 const guarded = (step: () => void): void => {
101 if (over) return;
102 try {
103 step();
104 } catch (error) {
105 const why = error instanceof Error ? error.message : String(error);
106 finish(1, `the terminal stopped: ${printable(why)}\r\n`);
107 }
108 };
109
110 guarded(() => {
111 io.input.setRawMode?.(true);
112 io.input.resume();
113 io.input.setEncoding("utf8");
114 draw();
115 });
116
117 io.input.on("data", (key) => guarded(() => {
118 // A pty hands over a string; anything else is read as the keypress it is
119 // not, which `press` answers by redrawing.
120 const step = press(view, typeof key === "string" ? key : String(key), read());
121 view = step.view;
122 if (step.command.kind === "quit") return finish(0);
123 if (step.command.kind === "delete") {
124 view = removed(view, step.command.name, deleteMatch(io.db, step.command.id));
125 }
126 draw();
127 }));
128
129 io.resize.on("resize", () => guarded(draw));
130 }
131
132 if (import.meta.main) {
133 run({
134 input: process.stdin,
135 output: process.stdout,
136 resize: process.stdout,
137 exit: (code) => process.exit(code),
138 db: openDatabase(),
139 env: process.env,
140 });
141 }

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs