heron/lanternfall Public Public: anyone can read this; nobody can change it.
workbenchthis machine
Start lanternfall
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 | # lanternfall | |
| 2 | ||
| 3 | One dungeon, many delvers. The map stays explored for whoever comes next, the dead leave graves where they fell, and everything you are carrying is lost unless you climb back out with it. | |
| 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": "lanternfall", | |
| 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 | * lanternfall — 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>lanternfall</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>lanternfall</h1> | |
| 131 | <p>One dungeon, many delvers. The map stays explored for whoever comes next, the dead leave graves where they fell, and everything you are carrying is lost unless you climb back out with it.</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(`lanternfall listening on http://${server.hostname}:${server.port} (${env})`); |
src/tty.ts added +74
| @@ -0,0 +1,74 @@ | ||
| 1 | /** | |
| 2 | * lanternfall, 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}1mlanternfall${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); |