rook/val Public Public: anyone can read this; nobody can change it.
forked from benjivers/val
workbenchthis machine
Start val
8 files
changed +426
−0
.gitignore added +4
| @@ -0,0 +1,4 @@ | ||
| 1 | node_modules/ | |
| 2 | *.db | |
| 3 | *.db-wal | |
| 4 | *.db-shm |
AGENTS.md added +52
| @@ -0,0 +1,52 @@ | ||
| 1 | # Working on this app | |
| 2 | ||
| 3 | You are one of the crew on this repository. The forge runs the app for you: | |
| 4 | production follows the default branch, and every pull you open gets a | |
| 5 | preview at its own address, deployed from your branch as you push. | |
| 6 | ||
| 7 | ## The rules | |
| 8 | ||
| 9 | - Work on the branch you were given. You cannot move the default branch; | |
| 10 | a person merges your pull once it is reviewed. | |
| 11 | - Keep state under `FORGE_APP_DATA`. Never write elsewhere at runtime. | |
| 12 | - Run `bun test` before you push. It is the check the pull shows, and every | |
| 13 | behaviour you add gets a test that would fail without it. | |
| 14 | - `src/tty.ts` is the app's terminal interface. The exact command that | |
| 15 | reaches it is `FORGE_APP_SSH` at runtime; print that verbatim wherever the | |
| 16 | app tells people how to connect. Keep it working and keep it useful: what | |
| 17 | a person can do on the web page they should be able to do here too. | |
| 18 | - Keep it dependency-free unless a dependency is clearly worth it, and say | |
| 19 | why in the pull. A dependency is code you now maintain. | |
| 20 | - When you are done, push and stop. Your last message becomes the pull's | |
| 21 | description: say what changed, what you chose not to do, and what to look | |
| 22 | at in the preview. | |
| 23 | ||
| 24 | ## The bar | |
| 25 | ||
| 26 | - Shape: small modules named after one concept, plain data in and out. When | |
| 27 | `src/server.ts` stops fitting in one read, split it — `src/db.ts` for | |
| 28 | the schema and queries, `src/pages/<page>.ts` for rendering, | |
| 29 | `src/routes.ts` for the table of paths — and keep the pattern you find. | |
| 30 | - Security: every input is validated at the boundary and every output is | |
| 31 | escaped; every response carries the security floor in `src/server.ts` | |
| 32 | (keep it, extend it); a POST that is not same-origin is refused; secrets | |
| 33 | come from the environment, never the repository; no `eval`, no shell | |
| 34 | built from user text; a private thing needs a session in an HttpOnly cookie. | |
| 35 | Every request body is capped by the bytes actually read (`readBody` in | |
| 36 | `src/server.ts`) — never by a Content-Length, which a chunked request does | |
| 37 | not have and a lie does not honour. `HEAD` answers like `GET`. | |
| 38 | An app with an owner or admin is never claimed by whoever visits first: | |
| 39 | the first sign-in needs a secret only the operator holds — a password from | |
| 40 | the environment, or a one-time token printed to the run log and the app's | |
| 41 | terminal — and a generated password is shown there once, never on a page. | |
| 42 | - Secrets come from the environment: the app's Settings → Environment | |
| 43 | (write-only), read at start-up; never the repository. | |
| 44 | - Speed: the first paint is server-rendered HTML; JavaScript is small, in | |
| 45 | files under `static/` named by content and cached for a year; no layout | |
| 46 | shift; the common request answers in milliseconds; work that can wait is | |
| 47 | done after the response. | |
| 48 | - A phone is a first-class client: the page works at 360px wide with no | |
| 49 | horizontal scroll, every control is at least 44px on a side, and the | |
| 50 | keyboard reaches everything a mouse does. | |
| 51 | - Finish: the app boots, `/` answers, the README says how to use it and | |
| 52 | what is real versus simulated, `forge.json` says how to run it. |
README.md added +23
| @@ -0,0 +1,23 @@ | ||
| 1 | # val | |
| 2 | ||
| 3 | Five attackers with the spike, five defenders, and abilities on cooldown. First to 13. | |
| 4 | ||
| 5 | This is an app: the forge runs it. Production follows the default branch; | |
| 6 | every open pull gets a preview at its own address with its own copy of the | |
| 7 | data. Everything the app keeps lives under `FORGE_APP_DATA`, which is what | |
| 8 | makes "production data" and "preview data" two different things. | |
| 9 | ||
| 10 | sh | |
| 11 | PORT=8080 FORGE_APP_DATA=./data bun run src/server.ts # run it here | |
| 12 | bun test # the check every pull runs | |
| 13 | FORGE_APP_DATA=./data bun run src/tty.ts # the terminal interface | |
| 14 | ||
| 15 | ||
| 16 | It also has a terminal: the exact command is in `FORGE_APP_SSH` when the app | |
| 17 | runs (`ssh -p 2222 <name>@<forge>` — the app is the login name, the forge is | |
| 18 | the host; `<name>--<pull>@<forge>` for a preview's). It opens `src/tty.ts` on | |
| 19 | a real pty over the same data. | |
| 20 | ||
| 21 | The forge sets `PORT`, `FORGE_APP_DATA`, `FORGE_APP_ENV` | |
| 22 | (`production` or `preview`), `FORGE_APP_URL` and `FORGE_APP_COMMIT` | |
| 23 | when it starts the app. `forge.json` says how. |
forge.json added +7
| @@ -0,0 +1,7 @@ | ||
| 1 | { | |
| 2 | "app": { | |
| 3 | "run": "bun run src/server.ts", | |
| 4 | "test": "bun test", | |
| 5 | "tty": "bun run src/tty.ts" | |
| 6 | } | |
| 7 | } |
package.json added +9
| @@ -0,0 +1,9 @@ | ||
| 1 | { | |
| 2 | "name": "val", | |
| 3 | "private": true, | |
| 4 | "type": "module", | |
| 5 | "scripts": { | |
| 6 | "start": "bun run src/server.ts", | |
| 7 | "test": "bun test" | |
| 8 | } | |
| 9 | } |
src/server.test.ts added +70
| @@ -0,0 +1,70 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { mkdtempSync } from "node:fs"; | |
| 3 | import { tmpdir } from "node:os"; | |
| 4 | import { join } from "node:path"; | |
| 5 | ||
| 6 | process.env.FORGE_APP_DATA = mkdtempSync(join(tmpdir(), "app-")); | |
| 7 | process.env.PORT = "0"; | |
| 8 | const { server } = await import("./server.ts"); | |
| 9 | const base = `http://${server.hostname}:${server.port}`; | |
| 10 | ||
| 11 | test("serves the page", async () => { | |
| 12 | const response = await fetch(base + "/"); | |
| 13 | expect(response.status).toBe(200); | |
| 14 | expect(await response.text()).toContain("<h1>"); | |
| 15 | }); | |
| 16 | ||
| 17 | test("keeps a note", async () => { | |
| 18 | const form = new FormData(); | |
| 19 | form.set("body", "hello from a test"); | |
| 20 | const posted = await fetch(base + "/notes", { method: "POST", body: form, redirect: "manual" }); | |
| 21 | expect(posted.status).toBe(303); | |
| 22 | expect(await (await fetch(base + "/")).text()).toContain("hello from a test"); | |
| 23 | }); | |
| 24 | ||
| 25 | test("stands on the security floor", async () => { | |
| 26 | const response = await fetch(base + "/"); | |
| 27 | expect(response.headers.get("content-security-policy")).toContain("default-src 'self'"); | |
| 28 | expect(response.headers.get("x-content-type-options")).toBe("nosniff"); | |
| 29 | }); | |
| 30 | ||
| 31 | test("a bug answers with the floor, not the runtime's page", async () => { | |
| 32 | // A percent-escape that is not one makes the search params throw. | |
| 33 | const response = await fetch(base + "/notes", { | |
| 34 | method: "POST", | |
| 35 | headers: { "content-type": "application/x-www-form-urlencoded" }, | |
| 36 | body: "body=%E0%A4%A", | |
| 37 | }); | |
| 38 | expect(response.status).toBe(200); | |
| 39 | expect(response.headers.get("content-security-policy")).toContain("default-src 'self'"); | |
| 40 | }); | |
| 41 | ||
| 42 | test("a body is capped by the bytes read, whatever it claims", async () => { | |
| 43 | // The cap counts bytes as they arrive and never reads Content-Length, so | |
| 44 | // a chunked body with no length is refused the same way this one is. | |
| 45 | const response = await fetch(base + "/notes", { | |
| 46 | method: "POST", | |
| 47 | headers: { "content-type": "application/x-www-form-urlencoded" }, | |
| 48 | body: "body=" + "x".repeat(1024 * 1024), | |
| 49 | }); | |
| 50 | expect(response.status).toBe(413); | |
| 51 | }); | |
| 52 | ||
| 53 | test("HEAD sees the app up", async () => { | |
| 54 | const response = await server.fetch(new Request(base + "/", { method: "HEAD" })); | |
| 55 | expect(response.status).toBe(200); | |
| 56 | expect(await response.text()).toBe(""); | |
| 57 | }); | |
| 58 | ||
| 59 | test("refuses a cross-site post", async () => { | |
| 60 | const form = new FormData(); | |
| 61 | form.set("body", "from another site"); | |
| 62 | const posted = await fetch(base + "/notes", { | |
| 63 | method: "POST", | |
| 64 | body: form, | |
| 65 | redirect: "manual", | |
| 66 | headers: { origin: "https://evil.example", "sec-fetch-site": "cross-site" }, | |
| 67 | }); | |
| 68 | expect(posted.status).toBe(403); | |
| 69 | expect(await (await fetch(base + "/")).text()).not.toContain("from another site"); | |
| 70 | }); |
src/server.ts added +187
| @@ -0,0 +1,187 @@ | ||
| 1 | /** | |
| 2 | * val — a small app with real data. | |
| 3 | * | |
| 4 | * Notes live in a SQLite file under FORGE_APP_DATA. Production and each | |
| 5 | * preview get their own directory, so a preview starts with production's | |
| 6 | * notes and never writes back to them. | |
| 7 | */ | |
| 8 | ||
| 9 | import { Database } from "bun:sqlite"; | |
| 10 | import { mkdirSync } from "node:fs"; | |
| 11 | import { join } from "node:path"; | |
| 12 | ||
| 13 | const dataDir = process.env.FORGE_APP_DATA ?? "./data"; | |
| 14 | mkdirSync(dataDir, { recursive: true }); | |
| 15 | const db = new Database(join(dataDir, "app.db")); | |
| 16 | db.exec("PRAGMA journal_mode = WAL"); | |
| 17 | db.exec( | |
| 18 | "CREATE TABLE IF NOT EXISTS note (id INTEGER PRIMARY KEY, body TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')))", | |
| 19 | ); | |
| 20 | ||
| 21 | const env = process.env.FORGE_APP_ENV ?? "local"; | |
| 22 | const pull = process.env.FORGE_APP_PULL; | |
| 23 | const commit = (process.env.FORGE_APP_COMMIT ?? "").slice(0, 7); | |
| 24 | ||
| 25 | const escape = (s: string): string => | |
| 26 | s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] ?? c); | |
| 27 | ||
| 28 | // The security floor every response stands on. Scripts and styles come from | |
| 29 | // this origin (inline styles allowed, inline scripts not), nothing frames us, | |
| 30 | // browsers never guess a content type, and referrers stay on this site. | |
| 31 | // The app's public address is what the forge told us, never the socket's. | |
| 32 | const publicOrigin = new URL(process.env.FORGE_APP_URL ?? "http://localhost").origin; | |
| 33 | ||
| 34 | const SECURITY = { | |
| 35 | "content-security-policy": | |
| 36 | "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none'; base-uri 'none'; form-action 'self'", | |
| 37 | "x-content-type-options": "nosniff", | |
| 38 | "referrer-policy": "strict-origin-when-cross-origin", | |
| 39 | "permissions-policy": "camera=(), microphone=(), geolocation=()", | |
| 40 | // Once served over https, always: browsers remember for a year. | |
| 41 | ...(publicOrigin.startsWith("https:") | |
| 42 | ? { "strict-transport-security": "max-age=31536000" } | |
| 43 | : {}), | |
| 44 | }; | |
| 45 | const html = (body: string, status = 200): Response => | |
| 46 | new Response(body, { status, headers: { "content-type": "text/html; charset=utf-8", ...SECURITY } }); | |
| 47 | ||
| 48 | // A browser on another site can make a person's browser POST here; it cannot | |
| 49 | // make that request look same-origin. Browsers say where a request came from | |
| 50 | // (Sec-Fetch-Site, or Origin); a request that names another site is refused. | |
| 51 | const sameOrigin = (request: Request): boolean => { | |
| 52 | const site = request.headers.get("sec-fetch-site"); | |
| 53 | if (site !== null) return site === "same-origin" || site === "none"; | |
| 54 | const origin = request.headers.get("origin"); | |
| 55 | return origin === null || origin === publicOrigin; | |
| 56 | }; | |
| 57 | ||
| 58 | // A request body is read up to a cap and no further, counting the bytes as | |
| 59 | // they arrive. A declared Content-Length is a claim: a chunked request has | |
| 60 | // none, and a lie is free. Undefined means "too large" — refuse it. Past the | |
| 61 | // cap the rest is drained and dropped so the connection stays usable for | |
| 62 | // the next request; past eight times the cap it is cut off instead. | |
| 63 | const BODY_MAX = 64 * 1024; | |
| 64 | async function readBody(request: Request, max = BODY_MAX): Promise<Uint8Array | undefined> { | |
| 65 | const reader = request.body?.getReader(); | |
| 66 | if (reader === undefined) return new Uint8Array(); | |
| 67 | const parts: Uint8Array[] = []; | |
| 68 | let size = 0; | |
| 69 | for (;;) { | |
| 70 | const { done, value } = await reader.read(); | |
| 71 | if (done) break; | |
| 72 | size += value.byteLength; | |
| 73 | if (size > max) { | |
| 74 | for (;;) { | |
| 75 | const next = await reader.read(); | |
| 76 | if (next.done) return undefined; | |
| 77 | size += next.value.byteLength; | |
| 78 | if (size > max * 8) { | |
| 79 | await reader.cancel(); | |
| 80 | return undefined; | |
| 81 | } | |
| 82 | } | |
| 83 | } | |
| 84 | parts.push(value); | |
| 85 | } | |
| 86 | const body = new Uint8Array(size); | |
| 87 | let at = 0; | |
| 88 | for (const part of parts) { | |
| 89 | body.set(part, at); | |
| 90 | at += part.byteLength; | |
| 91 | } | |
| 92 | return body; | |
| 93 | } | |
| 94 | ||
| 95 | // The form fields of a capped body, decoded the way the browser sent them | |
| 96 | // (a plain form or a multipart one), by handing the bytes back to a Request. | |
| 97 | async function readForm(request: Request): Promise<FormData | undefined> { | |
| 98 | const body = await readBody(request); | |
| 99 | if (body === undefined) return undefined; | |
| 100 | return new Request(request.url, { | |
| 101 | method: "POST", | |
| 102 | headers: { "content-type": request.headers.get("content-type") ?? "" }, | |
| 103 | body, | |
| 104 | }).formData(); | |
| 105 | } | |
| 106 | ||
| 107 | function page(): string { | |
| 108 | const notes = db | |
| 109 | .query<{ id: number; body: string; created_at: string }, []>( | |
| 110 | "SELECT id, body, created_at FROM note ORDER BY id DESC LIMIT 100", | |
| 111 | ) | |
| 112 | .all(); | |
| 113 | const where = | |
| 114 | env === "preview" ? `preview of pull #${pull ?? "?"}` : env; | |
| 115 | return `<!doctype html> | |
| 116 | <meta charset="utf-8"> | |
| 117 | <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| 118 | <title>val</title> | |
| 119 | <style> | |
| 120 | :root { color-scheme: light dark; font: 16px/1.5 system-ui, sans-serif; } | |
| 121 | body { max-width: 40rem; margin: 3rem auto; padding: 0 1rem; } | |
| 122 | .env { display: inline-block; padding: .1rem .5rem; border-radius: 999px; font-size: .8rem; | |
| 123 | background: ${env === "production" ? "#16a34a" : "#2563eb"}; color: white; } | |
| 124 | form { display: flex; gap: .5rem; margin: 1.5rem 0; } | |
| 125 | input { flex: 1; padding: .6rem .8rem; font: inherit; border: 1px solid #8884; border-radius: .5rem; } | |
| 126 | button { padding: .6rem 1rem; font: inherit; border: 0; border-radius: .5rem; background: #111; color: white; } | |
| 127 | li { padding: .5rem 0; border-top: 1px solid #8883; list-style: none; } | |
| 128 | small { opacity: .6; } | |
| 129 | </style> | |
| 130 | <h1>val</h1> | |
| 131 | <p>Five attackers with the spike, five defenders, and abilities on cooldown. First to 13.</p> | |
| 132 | <p><span class="env">${escape(where)}</span> <small>${commit ? "@ " + commit : ""}</small></p> | |
| 133 | <form method="post" action="/notes"> | |
| 134 | <input name="body" placeholder="Write a note" required maxlength="500" autofocus> | |
| 135 | <button>Add</button> | |
| 136 | </form> | |
| 137 | <ul>${notes | |
| 138 | .map((n) => `<li>${escape(n.body)} <small>${n.created_at}</small></li>`) | |
| 139 | .join("")}</ul> | |
| 140 | <p><small>${notes.length === 0 ? "No notes yet. Notes written here stay in this environment's data." : `${notes.length} note${notes.length === 1 ? "" : "s"} in ${escape(where)} data.`}</small></p> | |
| 141 | `; | |
| 142 | } | |
| 143 | ||
| 144 | export const server = Bun.serve({ | |
| 145 | port: Number(process.env.PORT ?? 8080), | |
| 146 | hostname: process.env.HOST ?? "127.0.0.1", | |
| 147 | async fetch(request) { | |
| 148 | const url = new URL(request.url); | |
| 149 | if (request.method === "POST" && !sameOrigin(request)) | |
| 150 | return new Response("forbidden", { status: 403 }); | |
| 151 | if (request.method === "POST" && url.pathname === "/notes") { | |
| 152 | const form = await readForm(request); | |
| 153 | if (form === undefined) | |
| 154 | return new Response("too large", { status: 413, headers: SECURITY }); | |
| 155 | const body = String(form.get("body") ?? "").trim().slice(0, 500); | |
| 156 | if (body) db.query("INSERT INTO note (body) VALUES (?1)").run(body); | |
| 157 | return Response.redirect("/", 303); | |
| 158 | } | |
| 159 | // HEAD is GET without the body: a health check sees the app up. | |
| 160 | if (url.pathname === "/" && (request.method === "GET" || request.method === "HEAD")) { | |
| 161 | const full = html(page()); | |
| 162 | return request.method === "HEAD" | |
| 163 | ? new Response(null, { status: full.status, headers: full.headers }) | |
| 164 | : full; | |
| 165 | } | |
| 166 | // Files under ./static are served as they are, cached for a year: name | |
| 167 | // them by content (app.3f2a9c.js) and a change is a new name, never a | |
| 168 | // stale cache. The path is confined to the directory. | |
| 169 | if (url.pathname.startsWith("/static/") && !url.pathname.includes("..")) { | |
| 170 | const file = Bun.file(join(import.meta.dir, "..", url.pathname.slice(1))); | |
| 171 | if (await file.exists()) | |
| 172 | return new Response(file, { | |
| 173 | headers: { "cache-control": "public, max-age=31536000, immutable", ...SECURITY }, | |
| 174 | }); | |
| 175 | } | |
| 176 | return new Response("not found", { status: 404, headers: SECURITY }); | |
| 177 | }, | |
| 178 | // A bug answers with the floor and nothing else: never the runtime's own | |
| 179 | // error page, which carries source and paths, and never without the | |
| 180 | // headers. The detail goes to the log, where it belongs. | |
| 181 | error(error) { | |
| 182 | console.error(error); | |
| 183 | return html("<h1>Something went wrong</h1>", 500); | |
| 184 | }, | |
| 185 | }); | |
| 186 | ||
| 187 | console.log(`val listening on http://${server.hostname}:${server.port} (${env})`); |
src/tty.ts added +74
| @@ -0,0 +1,74 @@ | ||
| 1 | /** | |
| 2 | * val, in a terminal. The forge runs this on a pty when someone | |
| 3 | * `ssh`es to the app, with FORGE_APP_DATA pointing at the same data the | |
| 4 | * web page uses. j/k move, a adds a note, d deletes the one under the | |
| 5 | * cursor, q leaves. Plain ANSI, no dependencies. | |
| 6 | */ | |
| 7 | ||
| 8 | import { Database } from "bun:sqlite"; | |
| 9 | import { mkdirSync } from "node:fs"; | |
| 10 | import { join } from "node:path"; | |
| 11 | ||
| 12 | const dataDir = process.env.FORGE_APP_DATA ?? "./data"; | |
| 13 | mkdirSync(dataDir, { recursive: true }); | |
| 14 | const db = new Database(join(dataDir, "app.db")); | |
| 15 | db.exec("PRAGMA journal_mode = WAL"); | |
| 16 | db.exec( | |
| 17 | "CREATE TABLE IF NOT EXISTS note (id INTEGER PRIMARY KEY, body TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')))", | |
| 18 | ); | |
| 19 | const where = process.env.FORGE_APP_ENV === "preview" ? `preview of #${process.env.FORGE_APP_PULL ?? "?"}` : (process.env.FORGE_APP_ENV ?? "local"); | |
| 20 | ||
| 21 | type Note = { id: number; body: string; created_at: string }; | |
| 22 | const notes = (): Note[] => db.query<Note, []>("SELECT id, body, created_at FROM note ORDER BY id DESC").all(); | |
| 23 | ||
| 24 | let cursor = 0; | |
| 25 | let typing: string | undefined; | |
| 26 | const out = (s: string): void => { process.stdout.write(s); }; | |
| 27 | const esc = "\x1b["; | |
| 28 | ||
| 29 | function draw(): void { | |
| 30 | const rows = notes(); | |
| 31 | const lines: string[] = []; | |
| 32 | lines.push(`${esc}1mval${esc}0m ${esc}2m${where}${esc}0m`); | |
| 33 | lines.push(""); | |
| 34 | if (rows.length === 0) lines.push(`${esc}2mNo notes yet. Press a to add one.${esc}0m`); | |
| 35 | rows.forEach((n, i) => { | |
| 36 | const mark = i === cursor ? "▸" : " "; | |
| 37 | const body = i === cursor ? `${esc}1m${n.body}${esc}0m` : n.body; | |
| 38 | lines.push(`${mark} ${body} ${esc}2m${n.created_at}${esc}0m`); | |
| 39 | }); | |
| 40 | lines.push(""); | |
| 41 | lines.push(typing === undefined ? `${esc}2mj/k move · a add · d delete · q quit${esc}0m` : `New note: ${typing}${esc}7m ${esc}0m`); | |
| 42 | out(`${esc}2J${esc}H` + lines.join("\r\n")); | |
| 43 | } | |
| 44 | ||
| 45 | process.stdin.setRawMode?.(true); | |
| 46 | process.stdin.resume(); | |
| 47 | process.stdin.setEncoding("utf8"); | |
| 48 | draw(); | |
| 49 | process.stdin.on("data", (key: string) => { | |
| 50 | if (typing !== undefined) { | |
| 51 | if (key === "\r") { | |
| 52 | const body = typing.trim(); | |
| 53 | if (body) db.query("INSERT INTO note (body) VALUES (?1)").run(body.slice(0, 500)); | |
| 54 | typing = undefined; | |
| 55 | cursor = 0; | |
| 56 | } else if (key === "\x1b") typing = undefined; | |
| 57 | else if (key === "\x7f") typing = typing.slice(0, -1); | |
| 58 | else if (key >= " ") typing += key; | |
| 59 | draw(); | |
| 60 | return; | |
| 61 | } | |
| 62 | if (key === "q" || key === "\x03") { out(`${esc}2J${esc}H`); process.exit(0); } | |
| 63 | const count = notes().length; | |
| 64 | if (key === "j" || key === "\x1b[B") cursor = Math.min(cursor + 1, Math.max(0, count - 1)); | |
| 65 | if (key === "k" || key === "\x1b[A") cursor = Math.max(cursor - 1, 0); | |
| 66 | if (key === "a") typing = ""; | |
| 67 | if (key === "d") { | |
| 68 | const n = notes()[cursor]; | |
| 69 | if (n) db.query("DELETE FROM note WHERE id = ?1").run(n.id); | |
| 70 | cursor = Math.min(cursor, Math.max(0, notes().length - 1)); | |
| 71 | } | |
| 72 | draw(); | |
| 73 | }); | |
| 74 | process.stdout.on("resize", draw); |