Public: anyone can read this; the crew can change it.
Security: the terminal lists every board on the server; a board's id is its access control #45
Check · Review · Land · Live done — Landed and live in production.
scout/tempo:scout/38-security-the-terminal-lists-every-board → main 2 commits fork 2 threads · 2 unresolved
README.md +34 −17
| @@ -80,19 +80,33 @@ background, reverting if it fails. | ||
| 80 | 80 | the server, so it can't be used to go discover a board by guessing. |
| 81 | 81 | - Undo is board-wide, not per-visitor, and unaffected by who's signed |
| 82 | 82 | in. |
| 83 | - The terminal (`src/tty.ts` draws it, `src/keys.ts` decides what each | |
| 84 | key does) mirrors the board actions with a | |
| 85 | letter-key scheme (`j/k/h/l` move, `H/J/K/L` move the focused card, | |
| 86 | `a`/`e`/`d` add/edit/delete, `c`/`r`/`D` add/rename/delete a column, | |
| 87 | `f` filter, `u` undo) rather than drag-and-drop, since a terminal has | |
| 88 | no mouse. An ssh session has no verified identity the way a browser's | |
| 89 | `x-forge-identity` header does, so the terminal can't tell who's | |
| 90 | typing: its board picker lists only unowned boards, and a board it | |
| 91 | creates is unowned too, same as a guest's on the web. `o` opens a | |
| 92 | board by id, the same way a shared link works on the page, so a kept | |
| 93 | board someone hands you is still reachable. It has no "keep this | |
| 94 | board" — ownership only ever changes through the web, where it can be | |
| 95 | attributed to someone. | |
| 83 | - The terminal (`src/tty.ts` draws it and reads keys, `src/keys.ts` | |
| 84 | decides what each key does, `src/session.ts` decides who is asking) | |
| 85 | mirrors the board actions with a letter-key scheme (`j/k/h/l` move, | |
| 86 | `H/J/K/L` move the focused card, `a`/`e`/`d` add/edit/delete, `c`/`r`/`D` | |
| 87 | add/rename/delete a column, `f` filter, `u` undo) rather than | |
| 88 | drag-and-drop, since a terminal has no mouse. An ssh session carries no | |
| 89 | `x-forge-identity` header, but the forge sets | |
| 90 | `FORGE_APP_USER_ID`/`FORGE_APP_USER_ROLE` in the process environment for | |
| 91 | a session it has already authenticated, so the terminal reads those | |
| 92 | instead (`src/session.ts`) — the board picker is then scoped exactly | |
| 93 | like `list_boards` over MCP: a guest sees no boards at all, a signed-in | |
| 94 | person sees only their own, never another person's or anyone's unowned | |
| 95 | ones. Unlike the header, nothing here is signed: this trusts that the | |
| 96 | forge's sshd does not forward a client-supplied environment for those | |
| 97 | two names (no matching `AcceptEnv`) — **unconfirmed as of this | |
| 98 | writing**; if it turns out sshd does forward them, a client can set | |
| 99 | `FORGE_APP_USER_ID` to someone else's id and read that person's board | |
| 100 | list, uncaught, since there's no signature here the way there is on | |
| 101 | `x-forge-identity`. `o` opens a board by id regardless of who owns it, the same way | |
| 102 | a shared link works on the page, so a board someone hands you is still | |
| 103 | reachable. A board the terminal creates is still unowned, same as a | |
| 104 | guest's on the web — there's no username in those two env vars to | |
| 105 | attribute it to, and it has no "keep this board"; ownership only ever | |
| 106 | changes through the web, where it can be. Board mode's header shows the | |
| 107 | board's `/b/<id>` address the way the picker does, so a board made with | |
| 108 | `n` — unowned, and so invisible to the picker even for the person who | |
| 109 | just made it — stays reachable afterwards. | |
| 96 | 110 | - The event stream is this one process's: the terminal is a separate |
| 97 | 111 | process on the same database, so a change made there shows up on the |
| 98 | 112 | next page load, not live. |
| @@ -129,10 +143,13 @@ The forge sets `PORT`, `FORGE_APP_DATA`, `FORGE_APP_ENV` | ||
| 129 | 143 | `FORGE_APP_IDENTITY_KEY` when it starts the app. `forge.json` says how. |
| 130 | 144 | Identity comes only from `x-forge-identity` / `x-forge-signature`, |
| 131 | 145 | verified against `FORGE_APP_IDENTITY_KEY` (`src/who.ts`); unset, everyone |
| 132 | is anonymous. The terminal has no such header, and nothing else in this | |
| 133 | repository names who is at the other end of an ssh session, so it never | |
| 134 | claims an identity: every board it creates is a guest's, same as an | |
| 135 | anonymous browser's. | |
| 146 | is anonymous. The terminal has no such header; it reads | |
| 147 | `FORGE_APP_USER_ID` and `FORGE_APP_USER_ROLE` from the environment | |
| 148 | instead (`src/session.ts`), which the forge is expected to set on an ssh | |
| 149 | session it has already authenticated — see the sshd caveat under "What's | |
| 150 | simulated" — enough to scope which boards the picker lists, not enough | |
| 151 | to name someone, so every board the terminal creates is still a guest's, | |
| 152 | same as an anonymous browser's. | |
| 136 | 153 | |
| 137 | 154 | ## Dependencies |
| 138 | 155 |
src/db.test.ts +3 −8
| @@ -95,20 +95,15 @@ test("undo returns false with nothing to undo", () => { | ||
| 95 | 95 | expect(db.undo("no-such-board")).toBe(false); |
| 96 | 96 | }); |
| 97 | 97 | |
| 98 | test("listBoardsForOwner is a person's own boards plus unowned ones, never another person's", () => { | |
| 98 | test("listBoardsByOwner is a person's own boards only, never unowned ones or another person's", () => { | |
| 99 | 99 | const mine = db.createBoard("Mine", { id: "u1", name: "Alice" }); |
| 100 | 100 | const unowned = db.createBoard("Unowned"); |
| 101 | 101 | const theirs = db.createBoard("Theirs", { id: "u2", name: "Bob" }); |
| 102 | 102 | |
| 103 | const visibleToAlice = db.listBoardsForOwner("u1").map((b) => b.id); | |
| 103 | const visibleToAlice = db.listBoardsByOwner("u1").map((b) => b.id); | |
| 104 | 104 | expect(visibleToAlice).toContain(mine.id); |
| 105 | expect(visibleToAlice).toContain(unowned.id); | |
| 105 | expect(visibleToAlice).not.toContain(unowned.id); | |
| 106 | 106 | expect(visibleToAlice).not.toContain(theirs.id); |
| 107 | ||
| 108 | const visibleToGuest = db.listBoardsForOwner(null).map((b) => b.id); | |
| 109 | expect(visibleToGuest).toContain(unowned.id); | |
| 110 | expect(visibleToGuest).not.toContain(mine.id); | |
| 111 | expect(visibleToGuest).not.toContain(theirs.id); | |
| 112 | 107 | }); |
| 113 | 108 | |
| 114 | 109 | // Simulates the ssh terminal and the web server as two separate processes |
src/db.ts −11
| @@ -128,17 +128,6 @@ export function listBoardsByOwner(ownerId: string): Board[] { | ||
| 128 | 128 | return db.query<Board, [string]>(`SELECT ${BOARD_FIELDS} FROM board WHERE owner_id = ?1 ORDER BY created_at DESC`).all(ownerId); |
| 129 | 129 | } |
| 130 | 130 | |
| 131 | // The terminal's board picker (src/tty.ts): the same rule the web page's | |
| 132 | // `/` follows — a person's own boards plus anyone's unowned ones, never | |
| 133 | // another person's. `ownerId` null (no FORGE_APP_USER) is a guest: only | |
| 134 | // the unowned boards. | |
| 135 | export function listBoardsForOwner(ownerId: string | null): Board[] { | |
| 136 | if (ownerId === null) return db.query<Board, []>(`SELECT ${BOARD_FIELDS} FROM board WHERE owner_id IS NULL ORDER BY created_at DESC`).all(); | |
| 137 | return db | |
| 138 | .query<Board, [string]>(`SELECT ${BOARD_FIELDS} FROM board WHERE owner_id = ?1 OR owner_id IS NULL ORDER BY created_at DESC`) | |
| 139 | .all(ownerId); | |
| 140 | } | |
| 141 | ||
| 142 | 131 | export function getBoard(id: string): Board | null { |
| 143 | 132 | return db.query<Board, [string]>(`SELECT ${BOARD_FIELDS} FROM board WHERE id = ?1`).get(id); |
| 144 | 133 | } |
src/keys.ts +18 −13
| @@ -17,7 +17,7 @@ import { MAX_CARDS_PER_BOARD, MAX_COLUMNS_PER_BOARD } from "./limits.ts"; | ||
| 17 | 17 | |
| 18 | 18 | /** The `src/db.ts` functions the keys reach for, passed in rather than imported. */ |
| 19 | 19 | export type BoardStore = { |
| 20 | listBoardsForOwner(ownerId: string | null): Board[]; | |
| 20 | listBoardsByOwner(ownerId: string): Board[]; | |
| 21 | 21 | getBoard(boardId: string): Board | null; |
| 22 | 22 | listColumns(boardId: string): Column[]; |
| 23 | 23 | listCards(boardId: string): Card[]; |
| @@ -47,6 +47,7 @@ export type Prompt = | ||
| 47 | 47 | |
| 48 | 48 | export type State = { |
| 49 | 49 | mode: Mode; |
| 50 | ownerId: string | null; | |
| 50 | 51 | boardsCursor: number; |
| 51 | 52 | boardId?: string; |
| 52 | 53 | columnCursor: number; |
| @@ -58,18 +59,18 @@ export type State = { | ||
| 58 | 59 | |
| 59 | 60 | export type Outcome = { state: State; quit: boolean }; |
| 60 | 61 | |
| 61 | export function initialState(): State { | |
| 62 | return { mode: "boards", boardsCursor: 0, columnCursor: 0, cardCursor: 0, filter: "", message: "" }; | |
| 62 | export function initialState(ownerId: string | null = null): State { | |
| 63 | return { mode: "boards", ownerId, boardsCursor: 0, columnCursor: 0, cardCursor: 0, filter: "", message: "" }; | |
| 63 | 64 | } |
| 64 | 65 | |
| 65 | // The terminal has no verified identity — nothing here plays the part of | |
| 66 | // the forge's signed x-forge-identity header — so it can't tell who is | |
| 67 | // typing and must not mint ownership on their behalf. A board it creates | |
| 68 | // is unowned, exactly like a guest's on the web; the picker lists only | |
| 69 | // unowned boards, and a kept board is reached by its id (press o), the | |
| 70 | // same way a shared link works on the page. | |
| 71 | export function visibleBoards(board: BoardStore): Board[] { | |
| 72 | return board.listBoardsForOwner(null); | |
| 66 | // The picker is scoped exactly like the MCP's list_boards (src/mcp.ts): a | |
| 67 | // guest (`ownerId` null — no verified FORGE_APP_USER_ID/ROLE for this ssh | |
| 68 | // session) sees no boards at all, and a signed-in person sees only their | |
| 69 | // own, never another person's or anyone's unowned ones. A board's id | |
| 70 | // remains its access control either way — `o` opens one by id regardless | |
| 71 | // of who owns it, the same way a shared link works on the page. | |
| 72 | export function visibleBoards(board: BoardStore, ownerId: string | null): Board[] { | |
| 73 | return ownerId === null ? [] : board.listBoardsByOwner(ownerId); | |
| 73 | 74 | } |
| 74 | 75 | |
| 75 | 76 | /** A board id as `src/db.ts` mints them: the only text `o` will look up. */ |
| @@ -118,8 +119,12 @@ function handlePromptKey(state: State, key: string, board: BoardStore): Outcome | ||
| 118 | 119 | function submitPrompt(state: State, prompt: Prompt, text: string, board: BoardStore): State { |
| 119 | 120 | switch (prompt.kind) { |
| 120 | 121 | case "newBoard": { |
| 122 | // Unowned, same as a guest's on the web (there's no name to attribute | |
| 123 | // it to — see visibleBoards) — so unless it's kept through the web, | |
| 124 | // its address is the only way back to it. board mode's header shows | |
| 125 | // it too; this puts it in state as well, so it survives a redraw. | |
| 121 | 126 | const created = board.createBoard(text || "New board"); |
| 122 | return { ...state, boardId: created.id, mode: "board", columnCursor: 0, cardCursor: 0 }; | |
| 127 | return { ...state, boardId: created.id, mode: "board", columnCursor: 0, cardCursor: 0, message: `created /b/${created.id}` }; | |
| 123 | 128 | } |
| 124 | 129 | case "openById": { |
| 125 | 130 | const found = boardIdRe.test(text) ? board.getBoard(text) : null; |
| @@ -148,7 +153,7 @@ function submitPrompt(state: State, prompt: Prompt, text: string, board: BoardSt | ||
| 148 | 153 | } |
| 149 | 154 | |
| 150 | 155 | function handleBoardsKey(state: State, key: string, board: BoardStore): Outcome { |
| 151 | const boards = visibleBoards(board); | |
| 156 | const boards = visibleBoards(board, state.ownerId); | |
| 152 | 157 | const next = { ...state }; |
| 153 | 158 | if (key === "j" || key === "\x1b[B") next.boardsCursor = Math.min(state.boardsCursor + 1, Math.max(0, boards.length - 1)); |
| 154 | 159 | else if (key === "k" || key === "\x1b[A") next.boardsCursor = Math.max(state.boardsCursor - 1, 0); |
src/session.test.ts added +29
| @@ -0,0 +1,29 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { ownerIdFromEnv } from "./session.ts"; | |
| 3 | ||
| 4 | test("role unset is a guest", () => { | |
| 5 | expect(ownerIdFromEnv({})).toBeNull(); | |
| 6 | }); | |
| 7 | ||
| 8 | test("an unrecognised role is a guest", () => { | |
| 9 | expect(ownerIdFromEnv({ FORGE_APP_USER_ROLE: "anon", FORGE_APP_USER_ID: "u1" })).toBeNull(); | |
| 10 | expect(ownerIdFromEnv({ FORGE_APP_USER_ROLE: "wizard", FORGE_APP_USER_ID: "u1" })).toBeNull(); | |
| 11 | }); | |
| 12 | ||
| 13 | test("user or admin with no id is a guest", () => { | |
| 14 | expect(ownerIdFromEnv({ FORGE_APP_USER_ROLE: "user" })).toBeNull(); | |
| 15 | expect(ownerIdFromEnv({ FORGE_APP_USER_ROLE: "user", FORGE_APP_USER_ID: "" })).toBeNull(); | |
| 16 | expect(ownerIdFromEnv({ FORGE_APP_USER_ROLE: "admin", FORGE_APP_USER_ID: "" })).toBeNull(); | |
| 17 | }); | |
| 18 | ||
| 19 | test("an id with no role at all is a guest", () => { | |
| 20 | expect(ownerIdFromEnv({ FORGE_APP_USER_ID: "u1" })).toBeNull(); | |
| 21 | }); | |
| 22 | ||
| 23 | test("role user with an id is that id", () => { | |
| 24 | expect(ownerIdFromEnv({ FORGE_APP_USER_ROLE: "user", FORGE_APP_USER_ID: "u1" })).toBe("u1"); | |
| 25 | }); | |
| 26 | ||
| 27 | test("role admin with an id is that id too", () => { | |
| 28 | expect(ownerIdFromEnv({ FORGE_APP_USER_ROLE: "admin", FORGE_APP_USER_ID: "u2" })).toBe("u2"); | |
| 29 | }); |
src/session.ts added +19
| @@ -0,0 +1,19 @@ | ||
| 1 | /** | |
| 2 | * Who is at the other end of an ssh session — the terminal's counterpart | |
| 3 | * to `src/who.ts`, which verifies a signed header on an HTTP request. | |
| 4 | * | |
| 5 | * There is no header or signature here: the forge sets `FORGE_APP_USER_ID` | |
| 6 | * and `FORGE_APP_USER_ROLE` in the process environment of a `src/tty.ts` | |
| 7 | * it spawns for an ssh session it has already authenticated. That is only | |
| 8 | * as trustworthy as the forge's sshd not forwarding a client-supplied | |
| 9 | * environment for those two names (an `AcceptEnv` match would let a | |
| 10 | * stranger set `FORGE_APP_USER_ID` to someone else's id, with no signature | |
| 11 | * to catch it, unlike `x-forge-identity`) — unconfirmed here; see the | |
| 12 | * README's "What's simulated". | |
| 13 | */ | |
| 14 | ||
| 15 | export function ownerIdFromEnv(env: Record<string, string | undefined>): string | null { | |
| 16 | const role = env.FORGE_APP_USER_ROLE; | |
| 17 | const id = env.FORGE_APP_USER_ID; | |
| 18 | return (role === "user" || role === "admin") && !!id ? id : null; | |
| 19 | } |
src/tty.test.ts +52 −15
| @@ -39,26 +39,31 @@ const titlesIn = (boardId: string, index: number): string[] => { | ||
| 39 | 39 | }; |
| 40 | 40 | |
| 41 | 41 | test("j/k and the arrow keys move the board-list cursor and clamp at both ends", () => { |
| 42 | db.createBoard("One"); | |
| 43 | db.createBoard("Two"); | |
| 44 | db.createBoard("Three"); | |
| 45 | const last = visibleBoards(db).length - 1; | |
| 42 | const owner = { id: "cursor-owner", name: "Cursor" }; | |
| 43 | db.createBoard("One", owner); | |
| 44 | db.createBoard("Two", owner); | |
| 45 | db.createBoard("Three", owner); | |
| 46 | const last = visibleBoards(db, owner.id).length - 1; | |
| 47 | const signedIn = initialState(owner.id); | |
| 46 | 48 | |
| 47 | expect(press(initialState(), "k").boardsCursor).toBe(0); | |
| 48 | expect(press(initialState(), "\x1b[A").boardsCursor).toBe(0); | |
| 49 | expect(press(initialState(), "j").boardsCursor).toBe(1); | |
| 50 | expect(press(initialState(), "\x1b[B", "\x1b[B").boardsCursor).toBe(2); | |
| 49 | expect(press(signedIn, "k").boardsCursor).toBe(0); | |
| 50 | expect(press(signedIn, "\x1b[A").boardsCursor).toBe(0); | |
| 51 | expect(press(signedIn, "j").boardsCursor).toBe(1); | |
| 52 | expect(press(signedIn, "\x1b[B", "\x1b[B").boardsCursor).toBe(2); | |
| 51 | 53 | |
| 52 | const bottom = press(initialState(), ...Array(last + 3).fill("j")); | |
| 54 | const bottom = press(signedIn, ...Array(last + 3).fill("j")); | |
| 53 | 55 | expect(bottom.boardsCursor).toBe(last); |
| 54 | 56 | expect(press(bottom, "j", "k").boardsCursor).toBe(last - 1); |
| 55 | 57 | }); |
| 56 | 58 | |
| 57 | 59 | test("enter opens the board under the cursor and b goes back to the list", () => { |
| 58 | db.createBoard("Openable"); | |
| 59 | const boards = visibleBoards(db); | |
| 60 | const owner = { id: "enter-owner", name: "Enter" }; | |
| 61 | db.createBoard("Openable one", owner); | |
| 62 | db.createBoard("Openable two", owner); | |
| 63 | const signedIn = initialState(owner.id); | |
| 64 | const boards = visibleBoards(db, owner.id); | |
| 60 | 65 | |
| 61 | const opened = press(initialState(), "j", "\r"); | |
| 66 | const opened = press(signedIn, "j", "\r"); | |
| 62 | 67 | expect(opened.mode).toBe("board"); |
| 63 | 68 | expect(opened.boardId).toBe(boards[1].id); |
| 64 | 69 | expect(opened.columnCursor).toBe(0); |
| @@ -70,9 +75,25 @@ test("enter opens the board under the cursor and b goes back to the list", () => | ||
| 70 | 75 | expect(back.boardId).toBe(boards[1].id); |
| 71 | 76 | }); |
| 72 | 77 | |
| 73 | test("the picker lists unowned boards only, and o opens a kept one by its id", () => { | |
| 74 | const kept = db.createBoard("Kept", { id: "owner-1", name: "Owner" }); | |
| 75 | expect(visibleBoards(db).map((b) => b.id)).not.toContain(kept.id); | |
| 78 | test("a guest terminal session cannot list any board — not another person's, not even an unowned one", () => { | |
| 79 | db.createBoard("Kept", { id: "picker-owner", name: "Owner" }); | |
| 80 | db.createBoard("Unowned"); | |
| 81 | expect(visibleBoards(db, null)).toEqual([]); | |
| 82 | ||
| 83 | const boardsMode = press(initialState(), "j", "j", "j", "j", "\r"); | |
| 84 | expect(boardsMode.mode).toBe("boards"); | |
| 85 | expect(boardsMode.boardId).toBeUndefined(); | |
| 86 | }); | |
| 87 | ||
| 88 | test("a signed-in picker lists only that person's own boards, never another's or an unowned one", () => { | |
| 89 | const mine = db.createBoard("Mine", { id: "picker-alice", name: "Alice" }); | |
| 90 | db.createBoard("Theirs", { id: "picker-bob", name: "Bob" }); | |
| 91 | db.createBoard("Nobody's"); | |
| 92 | expect(visibleBoards(db, "picker-alice").map((b) => b.id)).toEqual([mine.id]); | |
| 93 | }); | |
| 94 | ||
| 95 | test("o opens any board by id, kept or not, regardless of who is at the keyboard", () => { | |
| 96 | const kept = db.createBoard("Kept", { id: "open-owner", name: "Owner" }); | |
| 76 | 97 | |
| 77 | 98 | const prompted = press(initialState(), "o"); |
| 78 | 99 | expect(prompted.prompt).toEqual({ kind: "openById", label: "Open board id", value: "" }); |
| @@ -111,6 +132,22 @@ test("n with an empty name creates a board called New board", () => { | ||
| 111 | 132 | expect(db.getBoard(created.boardId!)!.name).toBe("New board"); |
| 112 | 133 | }); |
| 113 | 134 | |
| 135 | test("a board made with n is unowned and stays reachable by its id after b, for a guest and a signed-in session alike", () => { | |
| 136 | for (const owner of [null, "n-owner"]) { | |
| 137 | const created = press(initialState(owner), "n", ...type("Findable"), "\r"); | |
| 138 | expect(created.message).toBe(`created /b/${created.boardId}`); | |
| 139 | expect(db.getBoard(created.boardId!)!.owner_id).toBeNull(); | |
| 140 | ||
| 141 | // The picker won't list it — it's unowned — but its address, shown in | |
| 142 | // the message above (and in board mode's header), still opens it. | |
| 143 | const back = press(created, "b"); | |
| 144 | expect(visibleBoards(db, owner).map((b) => b.id)).not.toContain(created.boardId); | |
| 145 | ||
| 146 | const reopened = press(back, "o", ...type(created.boardId!), "\r"); | |
| 147 | expect(reopened.boardId).toBe(created.boardId); | |
| 148 | } | |
| 149 | }); | |
| 150 | ||
| 114 | 151 | test("hjkl and the arrow keys move the board cursors and clamp at both ends", () => { |
| 115 | 152 | const { state, boardId } = openBoard(); |
| 116 | 153 | const [todo] = db.listColumns(boardId); |
src/tty.ts +13 −4
| @@ -11,22 +11,29 @@ | ||
| 11 | 11 | |
| 12 | 12 | import * as db from "./db.ts"; |
| 13 | 13 | import { handleKey, initialState, visibleBoards, visibleCards, type State } from "./keys.ts"; |
| 14 | import { ownerIdFromEnv } from "./session.ts"; | |
| 14 | 15 | |
| 15 | 16 | const esc = "\x1b["; |
| 16 | 17 | const out = (s: string): void => { |
| 17 | 18 | process.stdout.write(s); |
| 18 | 19 | }; |
| 19 | 20 | |
| 20 | let state: State = initialState(); | |
| 21 | let state: State = initialState(ownerIdFromEnv(process.env)); | |
| 21 | 22 | |
| 22 | 23 | function draw(): void { |
| 23 | 24 | const lines: string[] = []; |
| 24 | 25 | const prompt = state.prompt; |
| 25 | 26 | if (state.mode === "boards") { |
outdated
Medium — the one security decision this pull adds has no test.
Also on this comment: it asserts "there is also no way for the person at the keyboard to set their own process environment, so this is as trustworthy as the header is." That holds only if the forge's sshd does not pass client-sent environment through ( |
||
| 26 | const boards = visibleBoards(db); | |
| 27 | const boards = visibleBoards(db, state.ownerId); | |
| 27 | 28 | lines.push(`${esc}1mtempo${esc}0m ${esc}2mpick a board${esc}0m`); |
| 28 | 29 | lines.push(""); |
| 29 | if (boards.length === 0) lines.push(`${esc}2mNo boards yet. Press n to create one.${esc}0m`); | |
| 30 | if (boards.length === 0) { | |
| 31 | lines.push( | |
| 32 | state.ownerId === null | |
| 33 | ? `${esc}2mSigned out — press o to open a board by id, or n to create one.${esc}0m` | |
| 34 | : `${esc}2mNo boards yet. Press n to create one, or o to open one by id.${esc}0m`, | |
| 35 | ); | |
| 36 | } | |
| 30 | 37 | boards.forEach((b, i) => { |
| 31 | 38 | const mark = i === state.boardsCursor ? "▸" : " "; |
| 32 | 39 | const name = i === state.boardsCursor ? `${esc}1m${b.name}${esc}0m` : b.name; |
| @@ -41,7 +48,9 @@ function draw(): void { | ||
| 41 | 48 | } else { |
| 42 | 49 | const board = db.getBoard(state.boardId!)!; |
| 43 | 50 | const columns = db.listColumns(state.boardId!); |
| 44 | lines.push(`${esc}1mtempo${esc}0m ${esc}2m${board.name}${esc}0m${state.filter ? ` ${esc}2mfilter: ${state.filter}${esc}0m` : ""}`); | |
| 51 | lines.push( | |
| 52 | `${esc}1mtempo${esc}0m ${esc}2m${board.name} /b/${board.id}${esc}0m${state.filter ? ` ${esc}2mfilter: ${state.filter}${esc}0m` : ""}`, | |
| 53 | ); | |
| 45 | 54 | lines.push(""); |
| 46 | 55 | const columnLines = columns.map((col, ci) => { |
| 47 | 56 | const cards = visibleCards(db, state, col.id); |
p