Public: anyone can read this; the crew can change it.
The terminal app browses the forge like a file system: repos → apps → environments → .db files → tables → rows, with a SQL prompt and vim keys #10319
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7581-the-terminal-app-browses-the-forge-like → main 7 commits fork
15 files
changed +1017
−242
docs/CLI.md +19
| @@ -474,3 +474,22 @@ Flags: none. | ||
| 474 | 474 | $ forge talk "what is waiting on me?" |
| 475 | 475 | Two pulls are green and wait for your merge: #12 and #15. |
| 476 | 476 | |
| 477 | ||
| 478 | ## forge data | |
| 479 | ||
| 480 | The SQLite files you may read, from a shell (so nvim and other editors can use | |
| 481 | them). The forge decides what you see (an admin: its own `forge.db`; a writer | |
| 482 | of an app: that app's databases per environment) and masks secrets, exactly as | |
| 483 | the Data screen and the MCP `data` tool do. | |
| 484 | ||
| 485 | - `forge data` lists the databases. | |
| 486 | - `forge data <app> [env]` lists that app's tables (`env` defaults to production). | |
| 487 | - `forge data <app> [env] --table name [--page n]` prints 50 rows. | |
| 488 | - `forge data <app> [env] "SELECT …"` runs one read-only SELECT (5 s, 1,000 rows); a write is refused with the forge's words and exit 1. SQL is 20 runs a minute per person, shared with the web and terminal; past that it prints "try again in N s" and exits 1. | |
| 489 | ||
| 490 | `--file x.db` picks among several databases of one environment (`app.db` is the default). `--json` prints an array of objects (rows) or of tables. An unknown or unreadable app exits 2; not logged in exits 3. | |
| 491 | ||
| 492 | console | |
| 493 | $ forge data notes "select name from users" --json | |
| 494 | [{"name":"ann"}] | |
| 495 |
docs/ON-YOUR-MACHINE.md +1
| @@ -31,6 +31,7 @@ forge open owner/name --web # the page, in a browser | ||
| 31 | 31 | forge ship 12 # land pull #12 after a y/N, as Merge does |
| 32 | 32 | forge pulls # open pulls of this clone, newest first |
| 33 | 33 | forge runs # the crew's last 10 runs here: state, agent, #N, title, age, cost |
| 34 | forge data notes "select * from users" --json # read an app's SQLite, read-only, secrets masked | |
| 34 | 35 | forge asks # what waits on you, most important first |
| 35 | 36 | forge asks --repo owner/name # only that repository's; --json for the API's answer |
| 36 | 37 | forge asks --ship 1 3 # decide by the line numbers just printed: planned, then Apply? [y/N] |
docs/features/data-browser.md +3 −1
| @@ -12,7 +12,9 @@ Views are masked whole when their text mentions a masked column (an alias cannot | ||
| 12 | 12 | |
| 13 | 13 | Limits on work: `query` runs at most 2 workers at once (a third is told it is busy), each with a 128 MB heap cap (`hard_heap_limit`), a 5 s timer, 1,000 rows and about 250 KB of result; every cell is cut to 1,000 characters inside SQLite and a blob shows as its size. `rows()` cuts cells the same way. |
| 14 | 14 | |
| 15 | Terminal: `src/tui/screens/data.ts` (files → tables → rows, `n`/`p` for pages, `s` sort, `f` filter, `c` clear, `x` read-only SQL), offered by `maySeeData`; the forge's own database is among an admin's files. SQL on either door is 20 runs a minute per person (`src/data/sql_window.ts`); beyond that it says when to retry. | |
| 15 | Terminal: `src/tui/screens/data.ts` is a tree (repos → apps → environments → files → tables → first 50 rows; `src/data/tree.ts` builds it), offered by `maySeeData`. Keys: `j`/`k` move, `l`/Enter open, `h` close or go up, `/` filter the open lines, `:` a read-only SQL prompt against the file under the cursor (same caps; a write is refused), `y` yank a row as JSON (OSC 52), `n`/`p` next/previous page of 50 rows on a table or row, `r` read the files afresh. The tree is built once and rebuilt only when a node opens, a page turns or `r` is pressed; the files are read once per open node. Shell: `forge data <app> [env] [sql] [--json]` (docs/CLI.md) calls the MCP tool. | |
| 16 | ||
| 17 | Not done: a filter that reaches into closed lines, SQL on the web page. SQL on any door (web, terminal, `forge data` and the MCP `data` tool) is 20 runs a minute per person (`src/data/sql_window.ts`); beyond that it says when to retry. | |
| 16 | 18 | |
| 17 | 19 | API and MCP by repository (`data/repo_scope.ts`, `http/api/data.ts`): `GET /api/v1/repos/:o/:n/data` (databases as `{ env, file }`), `…/data/:env/:file/tables`, `…/rows?table=&page=`, `POST …/query {sql}`; MCP `data` with `{ repo, env?, file?, table?, sql? }` answers the same. Only writers of the repository (and admins) see anything; everyone else gets a 404 (an error from the tool). Same masking, read-only SQL and 1,000-row cap as the web; SQL shares the per-person 20 a minute (`sql_window.ts`), and past it the API answers 429 with `retry-after`, the tool an error. `page` is a whole number from 0 to 1000. |
| 18 | 20 |
src/cli/api.ts +24
| @@ -187,6 +187,30 @@ export class ForgeApi { | ||
| 187 | 187 | return ok(parsed as T); |
| 188 | 188 | } |
| 189 | 189 | |
| 190 | /** | |
| 191 | * One MCP tool call (`POST /mcp`, tools/call): the tool's JSON answer, or | |
| 192 | * the forge's words when it refused. | |
| 193 | */ | |
| 194 | async tool(name: string, args: Record<string, unknown>): Promise<ApiResult<unknown>> { | |
| 195 | const got = await this.call<{ result?: { content?: { text?: unknown }[]; isError?: boolean }; error?: { message?: unknown } }>( | |
| 196 | "/mcp", | |
| 197 | { | |
| 198 | method: "POST", | |
| 199 | body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }), | |
| 200 | }, | |
| 201 | ); | |
| 202 | if (!got.ok) return got; | |
| 203 | const text = got.value.result?.content?.[0]?.text; | |
| 204 | if (typeof text !== "string") | |
| 205 | return err({ code: "bad_response", message: String(got.value.error?.message ?? "unexpected answer"), status: 200 }); | |
| 206 | if (got.value.result?.isError === true) return err({ code: "tool", message: text, status: 400 }); | |
| 207 | try { | |
| 208 | return ok(JSON.parse(text) as unknown); | |
| 209 | } catch { | |
| 210 | return err({ code: "bad_response", message: "the answer was not JSON", status: 200 }); | |
| 211 | } | |
| 212 | } | |
| 213 | ||
| 190 | 214 | async whoami(): Promise<ApiResult<Whoami>> { |
| 191 | 215 | const out = await this.call<{ username?: string; displayName?: string }>( |
| 192 | 216 | "/api/v1/user", |
src/cli/commands/data.ts added +108
| @@ -0,0 +1,108 @@ | ||
| 1 | import { ForgeApi } from "../api.ts"; | |
| 2 | import { requireConfig } from "../context.ts"; | |
| 3 | import { EXIT } from "../exit.ts"; | |
| 4 | import { cell, inert, type Out } from "../out.ts"; | |
| 5 | import { cellLine, rowObject, type Cell } from "../../data/shape.ts"; | |
| 6 | import type { Command } from "./types.ts"; | |
| 7 | ||
| 8 | interface Listed { | |
| 9 | source: string; | |
| 10 | label: string; | |
| 11 | } | |
| 12 | ||
| 13 | const isList = (v: unknown): v is Listed[] => | |
| 14 | Array.isArray(v) && v.every((x) => typeof x === "object" && x !== null && typeof (x as Listed).source === "string" && typeof (x as Listed).label === "string"); | |
| 15 | ||
| 16 | const ENV = /^(production|pull-[1-9]\d*|build-[0-9a-f]{7})$/; | |
| 17 | ||
| 18 | /** The file `forge data <app> [env]` means, among those the forge listed; an error message when it is none or many. */ | |
| 19 | export function pick(list: readonly Listed[], app: string, env: string | undefined, file: string | undefined): Listed | string { | |
| 20 | const parts = (l: Listed) => l.label.split(" · "); | |
| 21 | const named = list.filter((l) => { | |
| 22 | const [slug, e, f] = parts(l); | |
| 23 | return slug === app && (env === undefined || e === env) && (file === undefined || f === file); | |
| 24 | }); | |
| 25 | if (named.length === 0) return `no database for ${[app, env, file].filter(Boolean).join(" ")} that you may read`; | |
| 26 | const prod = named.filter((l) => parts(l)[1] === "production"); | |
| 27 | const pool = env === undefined && prod.length > 0 ? prod : named; | |
| 28 | const preferred = pool.filter((l) => parts(l)[2] === "app.db"); | |
| 29 | const chosen = file === undefined && preferred.length > 0 ? preferred : pool; | |
| 30 | if (chosen.length === 1) return chosen[0]!; | |
| 31 | return `several databases match: ${chosen.map((l) => l.label.replaceAll(" · ", " ")).join(", ")} — name the env, or pass --file`; | |
| 32 | } | |
| 33 | ||
| 34 | function show(out: Out, columns: readonly string[], rows: readonly (readonly Cell[])[], json: boolean): void { | |
| 35 | if (json) { | |
| 36 | out.line(JSON.stringify(rows.map((r) => rowObject(columns, r)))); | |
| 37 | return; | |
| 38 | } | |
| 39 | if (rows.length === 0) { | |
| 40 | out.line("No rows."); | |
| 41 | return; | |
| 42 | } | |
| 43 | out.table( | |
| 44 | columns.map((header) => ({ header, max: 40 })), | |
| 45 | rows.map((r) => r.map((v) => cell(cellLine(v)))), | |
| 46 | ); | |
| 47 | } | |
| 48 | ||
| 49 | /** | |
| 50 | * `forge data` lists the databases you may read; `forge data <app> [env]` | |
| 51 | * its tables; with `--table` a page of rows; with SQL the answer to a | |
| 52 | * read-only SELECT. The forge decides what you may see and masks secrets. | |
| 53 | */ | |
| 54 | async function data(out: Out, args: Parameters<Command["run"]>[1]): Promise<number> { | |
| 55 | const loaded = await requireConfig(out); | |
| 56 | if (typeof loaded === "number") return loaded; | |
| 57 | const json = args.flag("json"); | |
| 58 | const api = new ForgeApi(loaded.url, loaded.token); | |
| 59 | const fail = (m: string, code: number = EXIT.failed): number => (out.err(`forge data: ${inert(m)}`), code); | |
| 60 | ||
| 61 | const listed = await api.tool("data", {}); | |
| 62 | if (!listed.ok) return fail(listed.error.message, listed.error.status === 401 ? EXIT.notConfigured : EXIT.failed); | |
| 63 | if (!isList(listed.value)) return fail("unexpected answer"); | |
| 64 | ||
| 65 | const [app, ...rest] = args.positional; | |
| 66 | if (app === undefined) { | |
| 67 | if (json) out.line(JSON.stringify(listed.value)); | |
| 68 | else if (listed.value.length === 0) out.line("No databases you may read."); | |
| 69 | else for (const l of listed.value) out.line(inert(l.label.replaceAll(" · ", " "))); | |
| 70 | return EXIT.ok; | |
| 71 | } | |
| 72 | // `[env] [sql]`: a lone word that names an environment is the env; anything else is SQL. | |
| 73 | const env = rest[0] !== undefined && ENV.test(rest[0]) ? rest.shift() : undefined; | |
| 74 | const sql = rest.join(" ").trim(); | |
| 75 | if (sql === "" && rest.length > 0) return fail("empty SQL", EXIT.usage); | |
| 76 | ||
| 77 | const chosen = pick(listed.value, app, env, args.value("file")); | |
| 78 | if (typeof chosen === "string") return fail(chosen, EXIT.usage); | |
| 79 | ||
| 80 | const table = args.value("table"); | |
| 81 | if (table !== undefined && sql !== "") return fail("--table and SQL together: pick one", EXIT.usage); | |
| 82 | const rawPage = args.value("page"); | |
| 83 | if (args.flag("page") && (rawPage === undefined || !/^\d{1,4}$/.test(rawPage))) return fail("--page is a whole number, 0 or more", EXIT.usage); | |
| 84 | const answer = await api.tool("data", { | |
| 85 | source: chosen.source, | |
| 86 | ...(sql !== "" ? { sql } : table !== undefined ? { table } : {}), | |
| 87 | ...(rawPage !== undefined ? { page: Number(rawPage) } : {}), | |
| 88 | }); | |
| 89 | if (!answer.ok) return fail(answer.error.message); | |
| 90 | const v = answer.value as { columns?: string[]; rows?: Cell[][]; truncated?: boolean }; | |
| 91 | if (Array.isArray(answer.value)) { | |
| 92 | const tables = answer.value as { name: string; rows: number }[]; | |
| 93 | if (json) out.line(JSON.stringify(tables)); | |
| 94 | else if (tables.length === 0) out.line("No tables."); | |
| 95 | else out.table([{ header: "TABLE" }, { header: "ROWS", align: "right" }], tables.map((t) => [cell(t.name), cell(String(t.rows))])); | |
| 96 | return EXIT.ok; | |
| 97 | } | |
| 98 | if (!Array.isArray(v.columns) || !Array.isArray(v.rows)) return fail("unexpected answer"); | |
| 99 | show(out, v.columns, v.rows, json); | |
| 100 | if (v.truncated === true) out.err("forge data: the answer was cut at the forge's cap"); | |
| 101 | return EXIT.ok; | |
| 102 | } | |
| 103 | ||
| 104 | export const command: Command = { | |
| 105 | name: "data", | |
| 106 | usage: { synopsis: "forge data [app] [env] [sql]", flags: "[--table name] [--file x.db] [--page n] [--json]" }, | |
| 107 | run: (out, args) => data(out, args), | |
| 108 | }; |
src/cli/commands/index.ts +2
| @@ -21,6 +21,7 @@ import { command as tell } from "./tell.ts"; | ||
| 21 | 21 | import { command as talk } from "./talk.ts"; |
| 22 | 22 | import { command as newApp } from "./new.ts"; |
| 23 | 23 | import { command as ship } from "./ship.ts"; |
| 24 | import { command as data } from "./data.ts"; | |
| 24 | 25 | import type { Command } from "./types.ts"; |
| 25 | 26 | |
| 26 | 27 | export const commands: readonly Command[] = [ |
| @@ -43,4 +44,5 @@ export const commands: readonly Command[] = [ | ||
| 43 | 44 | talk, |
| 44 | 45 | newApp, |
| 45 | 46 | ship, |
| 47 | data, | |
| 46 | 48 | ]; |
src/cli/main.ts +1 −1
| @@ -36,7 +36,7 @@ export { EXIT }; | ||
| 36 | 36 | /** Every command by name, built once from `commands/index.ts`. */ |
| 37 | 37 | const TABLE = new Map(commands.map((c) => [c.name, c])); |
| 38 | 38 | |
| 39 | const VALUE_FLAGS = ["token", "interval", "root", "url", "limit", "repo", "kind", "reason", "owner", "variants", "from"]; | |
| 39 | const VALUE_FLAGS = ["token", "interval", "root", "url", "limit", "repo", "kind", "reason", "owner", "variants", "from", "table", "file", "page"]; | |
| 40 | 40 | |
| 41 | 41 | export async function main( |
| 42 | 42 | argv: readonly string[], |
src/data/shape.ts added +14
| @@ -0,0 +1,14 @@ | ||
| 1 | /** A row as the object an editor wants: column → value. Plain data in, plain data out. */ | |
| 2 | export type Cell = string | number | null; | |
| 3 | ||
| 4 | export function rowObject(columns: readonly string[], row: readonly Cell[]): Record<string, Cell> { | |
| 5 | const out: Record<string, Cell> = {}; | |
| 6 | columns.forEach((c, i) => { | |
| 7 | out[c] = row[i] ?? null; | |
| 8 | }); | |
| 9 | return out; | |
| 10 | } | |
| 11 | ||
| 12 | /** One line of a row, for a list: no newline or control byte can reach the terminal. */ | |
| 13 | export const cellLine = (v: Cell): string => | |
| 14 | v === null ? "" : String(v).replace(/[\u0000-\u001f\u007f]+/g, " "); |
src/data/sources.ts +5 −2
| @@ -19,6 +19,8 @@ export interface Source { | ||
| 19 | 19 | readonly label: string; |
| 20 | 20 | readonly kind: "forge" | "app"; |
| 21 | 21 | readonly path: string; |
| 22 | /** For an app's file: where it sits, so a tree can group by it. */ | |
| 23 | readonly where?: { readonly repo: string; readonly app: string; readonly env: string; readonly file: string }; | |
| 22 | 24 | } |
| 23 | 25 | |
| 24 | 26 | const DB_FILE = /^[\w.-]+\.db$/; |
| @@ -42,8 +44,8 @@ export function listSources( | ||
| 42 | 44 | apps.map((a) => a.repoId), |
| 43 | 45 | ); |
| 44 | 46 | for (const app of apps) { |
| 45 | if (!writable.has(app.repoId) || getRepoById(db, app.repoId) === undefined) | |
| 46 | continue; | |
| 47 | const repo = writable.has(app.repoId) ? getRepoById(db, app.repoId) : undefined; | |
| 48 | if (repo === undefined) continue; | |
| 47 | 49 | let envs: string[]; |
| 48 | 50 | try { |
| 49 | 51 | envs = readdirSync(join(dataDir, "apps", app.id)).filter(isEnvName); |
| @@ -65,6 +67,7 @@ export function listSources( | ||
| 65 | 67 | label: `${app.slug} · ${env} · ${file}`, |
| 66 | 68 | kind: "app", |
| 67 | 69 | path: join(dir, file), |
| 70 | where: { repo: `${repo.ownerName}/${repo.name}`, app: app.slug, env, file }, | |
| 68 | 71 | }); |
| 69 | 72 | } |
| 70 | 73 | } |
src/data/tree.ts added +143
| @@ -0,0 +1,143 @@ | ||
| 1 | /** | |
| 2 | * The forge's data as a file tree: repos → apps → environments → files → | |
| 3 | * tables → rows. Pure: which nodes are open and what to filter by come in, | |
| 4 | * the visible lines go out. Reads happen only for an open file or table. | |
| 5 | */ | |
| 6 | import { PAGE_SIZE, rows as readRows, tables as readTables, type RowsResult, type TableInfo } from "./browse.ts"; | |
| 7 | import { rowObject, cellLine, type Cell } from "./shape.ts"; | |
| 8 | import type { Source } from "./sources.ts"; | |
| 9 | ||
| 10 | export type Kind = "repo" | "app" | "env" | "file" | "table" | "row"; | |
| 11 | ||
| 12 | export interface Node { | |
| 13 | /** Unique path; a child's key starts with its parent's. */ | |
| 14 | readonly key: string; | |
| 15 | readonly kind: Kind; | |
| 16 | readonly depth: number; | |
| 17 | readonly label: string; | |
| 18 | /** The file this node is in or is (not on a repo, app or env). */ | |
| 19 | readonly source?: Source; | |
| 20 | readonly table?: string; | |
| 21 | /** A table's row count, for paging; the label is only for display. */ | |
| 22 | readonly total?: number; | |
| 23 | /** A row's JSON, masked as every read is. */ | |
| 24 | readonly json?: Record<string, Cell>; | |
| 25 | } | |
| 26 | ||
| 27 | const SEP = "\u0001"; | |
| 28 | const join = (parent: string, part: string): string => (parent === "" ? part : parent + SEP + part); | |
| 29 | const isUnder = (key: string, parent: string): boolean => key.startsWith(parent + SEP); | |
| 30 | ||
| 31 | /** | |
| 32 | * What was read from the files, kept so a rebuild of the tree reads nothing | |
| 33 | * it has read: each open file's tables once, each open table's page once. | |
| 34 | * Drop it to read afresh. | |
| 35 | */ | |
| 36 | export interface Reads { | |
| 37 | readonly tables: Map<string, TableInfo[]>; | |
| 38 | readonly pages: Map<string, RowsResult | undefined>; | |
| 39 | } | |
| 40 | export const newReads = (): Reads => ({ tables: new Map(), pages: new Map() }); | |
| 41 | ||
| 42 | /** The pages of a table of `total` rows. */ | |
| 43 | export const pageCount = (total: number): number => Math.max(1, Math.ceil(total / PAGE_SIZE)); | |
| 44 | ||
| 45 | /** | |
| 46 | * Every node that is visible with `open` expanded, in order. `page` is the | |
| 47 | * zero-based page of each open table, by its key. Reads happen only for an | |
| 48 | * open file or table, and once each while `reads` is kept. | |
| 49 | */ | |
| 50 | export function visible( | |
| 51 | sources: readonly Source[], | |
| 52 | open: ReadonlySet<string>, | |
| 53 | reads: Reads = newReads(), | |
| 54 | page: ReadonlyMap<string, number> = new Map(), | |
| 55 | ): Node[] { | |
| 56 | const out: Node[] = []; | |
| 57 | const tableNodes = (file: Node, depth: number) => { | |
| 58 | const source = file.source!; | |
| 59 | let list = reads.tables.get(file.key); | |
| 60 | if (list === undefined) { | |
| 61 | try { | |
| 62 | list = readTables(source); | |
| 63 | } catch { | |
| 64 | list = []; | |
| 65 | } | |
| 66 | reads.tables.set(file.key, list); | |
| 67 | } | |
| 68 | for (const t of list) { | |
| 69 | const key = join(file.key, t.name); | |
| 70 | const at = page.get(key) ?? 0; | |
| 71 | const paged = t.rows > PAGE_SIZE ? ` page ${at + 1}/${pageCount(t.rows)}` : ""; | |
| 72 | const node: Node = { key, kind: "table", depth, label: `${t.name} ${t.rows} rows${open.has(key) ? paged : ""}`, source, table: t.name, total: t.rows }; | |
| 73 | out.push(node); | |
| 74 | if (!open.has(node.key)) continue; | |
| 75 | const pk = `${key}${SEP}${at}`; | |
| 76 | if (!reads.pages.has(pk)) { | |
| 77 | try { | |
| 78 | reads.pages.set(pk, readRows(source, t.name, { page: at })); | |
| 79 | } catch { | |
| 80 | reads.pages.set(pk, undefined); | |
| 81 | } | |
| 82 | } | |
| 83 | const got = reads.pages.get(pk); | |
| 84 | for (const [i, r] of (got?.rows ?? []).entries()) | |
| 85 | out.push({ | |
| 86 | key: join(node.key, String(i)), | |
| 87 | kind: "row", | |
| 88 | depth: depth + 1, | |
| 89 | label: r.map(cellLine).join(" | "), | |
| 90 | source, | |
| 91 | table: t.name, | |
| 92 | json: rowObject(got!.columns, r), | |
| 93 | }); | |
| 94 | } | |
| 95 | }; | |
| 96 | const fileNode = (source: Source, key: string, depth: number, label: string) => { | |
| 97 | const node: Node = { key, kind: "file", depth, label, source }; | |
| 98 | out.push(node); | |
| 99 | if (open.has(key)) tableNodes(node, depth + 1); | |
| 100 | }; | |
| 101 | for (const s of sources.filter((x) => x.where === undefined)) fileNode(s, join("", `file:${s.id}`), 0, `${s.label}.db`); | |
| 102 | const byRepo = Map.groupBy( | |
| 103 | sources.filter((x) => x.where !== undefined), | |
| 104 | (s) => s.where!.repo, | |
| 105 | ); | |
| 106 | for (const [repo, ofRepo] of byRepo) { | |
| 107 | const rk = join("", `repo:${repo}`); | |
| 108 | out.push({ key: rk, kind: "repo", depth: 0, label: repo }); | |
| 109 | if (!open.has(rk)) continue; | |
| 110 | for (const [app, ofApp] of Map.groupBy(ofRepo, (s) => s.where!.app)) { | |
| 111 | const ak = join(rk, `app:${app}`); | |
| 112 | out.push({ key: ak, kind: "app", depth: 1, label: app }); | |
| 113 | if (!open.has(ak)) continue; | |
| 114 | for (const [env, ofEnv] of Map.groupBy(ofApp, (s) => s.where!.env)) { | |
| 115 | const ek = join(ak, `env:${env}`); | |
| 116 | out.push({ key: ek, kind: "env", depth: 2, label: env }); | |
| 117 | if (!open.has(ek)) continue; | |
| 118 | for (const s of ofEnv) fileNode(s, join(ek, `file:${s.id}`), 3, s.where!.file); | |
| 119 | } | |
| 120 | } | |
| 121 | } | |
| 122 | return out; | |
| 123 | } | |
| 124 | ||
| 125 | /** Whether a node opens at all (a row is a leaf). */ | |
| 126 | export const opens = (n: Node): boolean => n.kind !== "row"; | |
| 127 | ||
| 128 | /** | |
| 129 | * `text` keeps the lines that contain it (any case) and the lines above | |
| 130 | * them that lead there; an empty `text` keeps everything. | |
| 131 | */ | |
| 132 | export function filtered(nodes: readonly Node[], text: string): Node[] { | |
| 133 | const want = text.trim().toLowerCase(); | |
| 134 | if (want === "") return [...nodes]; | |
| 135 | const hits = nodes.filter((n) => n.label.toLowerCase().includes(want)); | |
| 136 | return nodes.filter((n) => hits.some((h) => h.key === n.key || isUnder(h.key, n.key))); | |
| 137 | } | |
| 138 | ||
| 139 | /** The parent line's key, or undefined at the top. */ | |
| 140 | export function parentKey(key: string): string | undefined { | |
| 141 | const at = key.lastIndexOf(SEP); | |
| 142 | return at < 0 ? undefined : key.slice(0, at); | |
| 143 | } |
src/tui/screens/data.ts +216 −170
| @@ -1,200 +1,246 @@ | ||
| 1 | 1 | /** |
| 2 | * `/data` in the terminal: the SQLite files the viewer may see, a file's | |
| 3 | * tables, a table's rows 50 a page, secrets masked. The same reads, and the | |
| 4 | * same gate (`maySeeData`, `listSources`), as the web page and MCP `data`. | |
| 5 | * `s` sorts a table by a column (`-name` for descending), `f` filters it | |
| 6 | * (`text`, or `column=text`), `c` clears both; `x` on a file's tables runs one | |
| 7 | * read-only SELECT, on the page's per-person budget (`sqlWindow`). | |
| 2 | * `/data` in the terminal, as a file tree: repos → apps → environments → | |
| 3 | * files → tables → rows, over the same reads and the same gate | |
| 4 | * (`maySeeData`, `listSources`) as the web page and the MCP `data` tool. | |
| 5 | * | |
| 6 | * Keys: j/k move, l or Enter opens, h closes (or goes up), `/` filters the | |
| 7 | * lines shown, `:` asks the file under the cursor a read-only question (the | |
| 8 | * same SELECT-only caps as the tool), `y` yanks a row as JSON. Secrets stay | |
| 9 | * masked: rows come from the masked reads. | |
| 8 | 10 | */ |
| 9 | 11 | |
| 10 | import { PAGE_SIZE, rows, tables, type RowsOptions } from "../../data/browse.ts"; | |
| 11 | import { query } from "../../data/query.ts"; | |
| 12 | import { listSources, maySeeData, type Source } from "../../data/sources.ts"; | |
| 12 | import { query, type QueryResult } from "../../data/query.ts"; | |
| 13 | import { cellLine, rowObject, type Cell } from "../../data/shape.ts"; | |
| 14 | import { listSources, maySeeData } from "../../data/sources.ts"; | |
| 13 | 15 | import { sqlWindow } from "../../data/sql_window.ts"; |
| 16 | import { PAGE_SIZE } from "../../data/browse.ts"; | |
| 17 | import { filtered, newReads, opens, pageCount, parentKey, visible, type Node } from "../../data/tree.ts"; | |
| 14 | 18 | import type { Ctx, Screen } from "../app/app.ts"; |
| 15 | 19 | import { binding } from "../app/keys.ts"; |
| 20 | import type { KeyEvent } from "../term/input.ts"; | |
| 16 | 21 | import type { Session } from "../session.ts"; |
| 17 | 22 | import { style } from "../term/style.ts"; |
| 18 | 23 | import { truncate } from "../term/width.ts"; |
| 19 | import { later, promptScreen } from "./common.ts"; | |
| 20 | 24 | |
| 21 | /** A list of rows to move through; `open` is what Enter does on one. */ | |
| 22 | function listScreen( | |
| 23 | ctx: Ctx, | |
| 24 | title: string, | |
| 25 | intro: string, | |
| 26 | items: () => readonly string[], | |
| 27 | open: (index: number) => void, | |
| 28 | extra?: (key: string) => boolean, | |
| 29 | keys: ReturnType<typeof binding>[] = [], | |
| 30 | ): Screen { | |
| 25 | /** The longest SQL the prompt takes, and the longest filter. */ | |
| 26 | export const SQL_MAX = 4000; | |
| 27 | export const FILTER_MAX = 200; | |
| 28 | ||
| 29 | /** A one-line editor: what a prompt types into, cut at `max` characters however it arrives. */ | |
| 30 | export function typing(max: number, initial = "") { | |
| 31 | const cap = (t: string) => [...t].slice(0, max).join(""); | |
| 32 | let text = cap(initial); | |
| 33 | return { | |
| 34 | get text() { | |
| 35 | return text; | |
| 36 | }, | |
| 37 | /** True when the key was text (or an edit) and is spent. */ | |
| 38 | key(e: KeyEvent): boolean { | |
| 39 | if (e.ctrl || e.alt) return false; | |
| 40 | if (e.key === "backspace") text = [...text].slice(0, -1).join(""); | |
| 41 | else if (e.key === "space") text = cap(text + " "); | |
| 42 | else if ([...e.key].length === 1) text = cap(text + e.key); | |
| 43 | else return false; | |
| 44 | return true; | |
| 45 | }, | |
| 46 | paste(t: string) { | |
| 47 | text = cap(text + t.replace(/\s+/g, " ")); | |
| 48 | }, | |
| 49 | }; | |
| 50 | } | |
| 51 | ||
| 52 | /** The lines of a SQL answer; `y` yanks the highlighted row. */ | |
| 53 | function resultScreen(ctx: Ctx, sql: string, r: Extract<QueryResult, { ok: true }>): Screen { | |
| 31 | 54 | let at = 0; |
| 32 | 55 | return { |
| 33 | title, | |
| 34 | keys: [binding(["j", "k"], "move"), binding(["enter"], "open"), ...keys, binding(["esc"], "back")], | |
| 56 | title: "Result", | |
| 57 | keys: [binding(["j", "k"], "move"), binding("y", "yank row as JSON"), binding("esc", "back")], | |
| 35 | 58 | render(area, s) { |
| 36 | 59 | const c = ctx.theme.c; |
| 37 | s.text(area.x + 1, area.y, truncate(intro, area.w - 2), style(c.mutedFg, c.bg), area.x + area.w); | |
| 38 | const list = items(); | |
| 39 | if (list.length === 0) s.text(area.x + 1, area.y + 2, "Nothing here yet.", style(c.mutedFg, c.bg), area.x + area.w); | |
| 40 | const h = Math.max(1, area.h - 2); | |
| 41 | const from = Math.max(0, Math.min(at - h + 1, list.length - h)); | |
| 42 | list.slice(from, from + h).forEach((line, i) => { | |
| 60 | const note = `${r.rows.length} rows${r.truncated ? " (cut)" : ""} · ${sql}`; | |
| 61 | s.text(area.x + 1, area.y, truncate(note, area.w - 2), style(c.mutedFg, c.bg), area.x + area.w); | |
| 62 | s.text(area.x + 1, area.y + 1, truncate(` ${r.columns.join(" | ")}`, area.w - 2), style(c.mutedFg, c.bg), area.x + area.w); | |
| 63 | const h = Math.max(1, area.h - 3); | |
| 64 | const from = Math.max(0, Math.min(at - h + 1, r.rows.length - h)); | |
| 65 | r.rows.slice(from, from + h).forEach((row, i) => { | |
| 43 | 66 | const on = from + i === at; |
| 44 | s.text(area.x + 1, area.y + 2 + i, truncate(`${on ? "> " : " "}${line}`, area.w - 2), on ? style(c.accent, c.bg) : style(c.fg, c.bg), area.x + area.w); | |
| 67 | s.text(area.x + 1, area.y + 2 + i, truncate(`${on ? "> " : " "}${row.map(cellLine).join(" | ")}`, area.w - 2), style(on ? c.accent : c.fg, c.bg), area.x + area.w); | |
| 45 | 68 | }); |
| 46 | 69 | }, |
| 47 | 70 | key(e) { |
| 48 | 71 | if (e.ctrl || e.alt) return false; |
| 49 | const n = items().length; | |
| 50 | 72 | if (e.key === "esc" || e.key === "q") ctx.pop(); |
| 51 | else if (e.key === "j" || e.key === "down") at = Math.min(Math.max(0, n - 1), at + 1); | |
| 73 | else if (e.key === "j" || e.key === "down") at = Math.min(Math.max(0, r.rows.length - 1), at + 1); | |
| 52 | 74 | else if (e.key === "k" || e.key === "up") at = Math.max(0, at - 1); |
| 53 | else if (e.key === "enter") { | |
| 54 | if (n > 0) open(at); | |
| 55 | } else return extra?.(e.key) ?? false; | |
| 56 | return true; | |
| 57 | }, | |
| 58 | }; | |
| 59 | } | |
| 60 | ||
| 61 | const cellText = (v: string | number | null): string => (v === null ? "" : String(v).replace(/[\r\n\t]+/g, " ").replace(/[\u0000-\u001f\u007f]/g, " ")); | |
| 62 | ||
| 63 | /** Splits `-name` / `name` into a sort; `undefined` for nothing. */ | |
| 64 | const parseSort = (text: string): RowsOptions["order"] | undefined => { | |
| 65 | const t = text.trim(); | |
| 66 | if (t === "") return undefined; | |
| 67 | return t.startsWith("-") ? { column: t.slice(1), dir: "desc" } : { column: t, dir: "asc" }; | |
| 68 | }; | |
| 69 | ||
| 70 | /** `text` or `column=text`; an empty string is no filter. */ | |
| 71 | const parseFilter = (text: string, columns: readonly string[]): RowsOptions["filter"] | undefined => { | |
| 72 | const eq = text.indexOf("="); | |
| 73 | const head = eq > 0 ? text.slice(0, eq).trim() : ""; | |
| 74 | if (head !== "" && columns.includes(head)) return { column: head, text: text.slice(eq + 1) }; | |
| 75 | return text === "" ? undefined : { text }; | |
| 76 | }; | |
| 77 | ||
| 78 | function rowsScreen(ctx: Ctx, source: Source, table: string): Screen { | |
| 79 | let page = 0; | |
| 80 | let order: RowsOptions["order"] | undefined; | |
| 81 | let filter: RowsOptions["filter"] | undefined; | |
| 82 | let note = ""; | |
| 83 | const read = () => { | |
| 84 | const wanted = { page, ...(order ? { order } : {}), ...(filter ? { filter } : {}) }; | |
| 85 | const r = rows(source, table, wanted); | |
| 86 | if (r || (!order && !filter)) return r; | |
| 87 | // An unknown or sealed column cannot be sorted or searched: show the table, say so. | |
| 88 | note = " · that column cannot be sorted or searched"; | |
| 89 | return rows(source, table, { page }); | |
| 90 | }; | |
| 91 | const screen: Screen = listScreen( | |
| 92 | ctx, | |
| 93 | `${table} · ${source.label}`, | |
| 94 | "", | |
| 95 | () => { | |
| 96 | note = ""; | |
| 97 | const r = read(); | |
| 98 | if (!r) return []; | |
| 99 | const how = `${order ? ` · by ${order.column} ${order.dir}` : ""}${filter ? ` · filter ${filter.column ? `${filter.column}=` : ""}${filter.text}` : ""}`; | |
| 100 | return [ | |
| 101 | `${r.total} rows, page ${r.page + 1}${how}${note} · ${r.columns.join(" | ")}`, | |
| 102 | ...r.rows.map((row) => row.map(cellText).join(" | ")), | |
| 103 | ]; | |
| 104 | }, | |
| 105 | () => undefined, | |
| 106 | (key) => { | |
| 107 | const r = read(); | |
| 108 | if (!r) return false; | |
| 109 | if (key === "n" && (r.page + 1) * PAGE_SIZE < r.total) page = r.page + 1; | |
| 110 | else if (key === "p" && r.page > 0) page = r.page - 1; | |
| 111 | else if (key === "c") { | |
| 112 | order = undefined; | |
| 113 | filter = undefined; | |
| 114 | page = 0; | |
| 115 | } else if (key === "s" || key === "f") { | |
| 116 | const sorting = key === "s"; | |
| 117 | ctx.push( | |
| 118 | promptScreen(ctx, screen, { | |
| 119 | title: sorting ? "Sort by" : "Filter", | |
| 120 | label: sorting ? "Column" : "Text", | |
| 121 | placeholder: sorting ? "name, or -name for descending" : "text, or column=text", | |
| 122 | onSubmit: (v) => { | |
| 123 | if (sorting) order = parseSort(v); | |
| 124 | else filter = parseFilter(v.trim().slice(0, 200), r.columns); | |
| 125 | page = 0; | |
| 126 | return undefined; | |
| 127 | }, | |
| 128 | }), | |
| 129 | ); | |
| 75 | else if (e.key === "y") { | |
| 76 | const row = r.rows[at]; | |
| 77 | if (!row) return true; | |
| 78 | ctx.copy(JSON.stringify(rowObject(r.columns, row as readonly Cell[]))); | |
| 79 | ctx.toast("Row sent to your clipboard as JSON."); | |
| 130 | 80 | } else return false; |
| 131 | 81 | return true; |
| 132 | 82 | }, |
| 133 | [binding(["n", "p"], "next / previous page"), binding(["s", "f", "c"], "sort / filter / clear")], | |
| 134 | ); | |
| 135 | return screen; | |
| 136 | } | |
| 137 | ||
| 138 | /** One SELECT's rows as lines, or the reason it was refused. */ | |
| 139 | async function sqlLines(source: Source, sql: string): Promise<string[]> { | |
| 140 | const t0 = performance.now(); | |
| 141 | const r = await query(source, sql); | |
| 142 | const ms = Math.max(1, Math.round(performance.now() - t0)); | |
| 143 | if (!r.ok) return [`${r.message} · ${ms} ms`]; | |
| 144 | return [ | |
| 145 | `${r.rows.length} rows${r.truncated ? " (cut)" : ""} · ${ms} ms · ${r.columns.join(" | ")}`, | |
| 146 | ...r.rows.map((row) => row.map(cellText).join(" | ")), | |
| 147 | ]; | |
| 148 | } | |
| 149 | ||
| 150 | function tablesScreen(ctx: Ctx, session: Session, source: Source): Screen { | |
| 151 | const list = tables(source); | |
| 152 | const screen: Screen = listScreen( | |
| 153 | ctx, | |
| 154 | source.label, | |
| 155 | "Tables; secrets are masked.", | |
| 156 | () => list.map((t) => `${t.name} ${t.rows} rows`), | |
| 157 | (i) => { | |
| 158 | const t = list[i]; | |
| 159 | if (t) ctx.push(rowsScreen(ctx, source, t.name)); | |
| 160 | }, | |
| 161 | (key) => { | |
| 162 | if (key !== "x") return false; | |
| 163 | ctx.push( | |
| 164 | promptScreen(ctx, screen, { | |
| 165 | title: "SQL", | |
| 166 | label: "SELECT", | |
| 167 | placeholder: "SELECT * FROM … (read-only, one statement)", | |
| 168 | onSubmit: (sql) => { | |
| 169 | const text = sql.trim().slice(0, 4000); | |
| 170 | if (text === "") return "Type one SELECT."; | |
| 171 | const wait = session.user ? sqlWindow(session.app, session.user.id) : 1; | |
| 172 | if (wait !== undefined) return `Too many queries; try again in ${wait} s.`; | |
| 173 | return sqlLines(source, text).then((lines) => { | |
| 174 | void later(ctx, () => listScreen(ctx, "SQL", "", () => lines, () => undefined)); | |
| 175 | }); | |
| 176 | }, | |
| 177 | }), | |
| 178 | ); | |
| 179 | return true; | |
| 180 | }, | |
| 181 | [binding(["x"], "run SQL")], | |
| 182 | ); | |
| 183 | return screen; | |
| 83 | }; | |
| 184 | 84 | } |
| 185 | 85 | |
| 186 | 86 | export function dataScreen(session: Session, ctx: Ctx): Screen { |
| 187 | const sources = () => listSources(session.app.db, session.app.config.dataDir, session.user); | |
| 188 | return listScreen( | |
| 189 | ctx, | |
| 190 | "Data", | |
| 191 | "The SQLite files you may read.", | |
| 192 | () => sources().map((s) => s.label), | |
| 193 | (i) => { | |
| 194 | const s = sources()[i]; | |
| 195 | if (s) ctx.push(tablesScreen(ctx, session, s)); | |
| 87 | const open = new Set<string>(); | |
| 88 | let at = 0; | |
| 89 | let filter = ""; | |
| 90 | let mode: "filter" | "sql" | undefined; | |
| 91 | let line = typing(FILTER_MAX); | |
| 92 | ||
| 93 | // Built once and rebuilt only when `open`, a page, the filter or a reload changes it. | |
| 94 | const pages = new Map<string, number>(); | |
| 95 | let reads = newReads(); | |
| 96 | let files: ReturnType<typeof listSources> | undefined; | |
| 97 | let built: Node[] | undefined; | |
| 98 | let shown: Node[] | undefined; | |
| 99 | const dirty = (all = false) => { | |
| 100 | shown = undefined; | |
| 101 | if (all) built = undefined; | |
| 102 | }; | |
| 103 | const lines = (): Node[] => { | |
| 104 | if (shown) return shown; | |
| 105 | files ??= listSources(session.app.db, session.app.config.dataDir, session.user); | |
| 106 | built ??= visible(files, open, reads, pages); | |
| 107 | return (shown = filtered(built, filter)); | |
| 108 | }; | |
| 109 | const reload = () => { | |
| 110 | files = undefined; | |
| 111 | reads = newReads(); | |
| 112 | dirty(true); | |
| 113 | }; | |
| 114 | /** The table key a node belongs to, for paging. */ | |
| 115 | const tableKey = (n: Node) => (n.kind === "table" ? n.key : n.kind === "row" ? parentKey(n.key) : undefined); | |
| 116 | function turn(n: Node | undefined, by: number) { | |
| 117 | const tk = n && tableKey(n); | |
| 118 | if (tk === undefined) return; | |
| 119 | const total = built?.find((x) => x.key === tk)?.total ?? 0; | |
| 120 | const next = Math.max(0, Math.min(pageCount(total) - 1, (pages.get(tk) ?? 0) + by)); | |
| 121 | if (next === (pages.get(tk) ?? 0)) return ctx.toast(by > 0 ? "That is the last page." : "That is the first page."); | |
| 122 | pages.set(tk, next); | |
| 123 | dirty(true); | |
| 124 | at = Math.max(0, lines().findIndex((x) => x.key === tk)); | |
| 125 | } | |
| 126 | const current = (): Node | undefined => lines()[at]; | |
| 127 | const clamp = () => { | |
| 128 | at = Math.max(0, Math.min(at, lines().length - 1)); | |
| 129 | }; | |
| 130 | ||
| 131 | function ask(text: string) { | |
| 132 | const sql = text.trim(); | |
| 133 | if (sql === "") return ctx.toast("Type one SELECT.", "danger"); | |
| 134 | const source = current()?.source; | |
| 135 | if (!source) return ctx.toast("Move onto a file, table or row first: the question goes to that file.", "danger"); | |
| 136 | const wait = session.user ? sqlWindow(session.app, session.user.id) : 1; | |
| 137 | if (wait !== undefined) return ctx.toast(`Too many queries; try again in ${wait} s.`, "danger"); | |
| 138 | ctx.track( | |
| 139 | query(source, sql).then((r) => { | |
| 140 | if (r.ok) ctx.push(resultScreen(ctx, sql, r)); | |
| 141 | else ctx.toast(r.message, "danger"); | |
| 142 | }), | |
| 143 | ); | |
| 144 | } | |
| 145 | ||
| 146 | return { | |
| 147 | title: "Data", | |
| 148 | get keys() { | |
| 149 | return mode | |
| 150 | ? [binding("enter", mode === "sql" ? "run" : "keep"), binding("esc", "cancel")] | |
| 151 | : [ | |
| 152 | binding(["j", "k"], "move"), | |
| 153 | binding(["h", "l"], "close / open"), | |
| 154 | binding("/", "filter"), | |
| 155 | binding(":", "SQL"), | |
| 156 | binding("y", "yank row as JSON"), | |
| 157 | binding(["n", "p"], `page of ${PAGE_SIZE}`), | |
| 158 | binding("r", "reload"), | |
| 159 | binding("esc", "back"), | |
| 160 | ]; | |
| 196 | 161 | }, |
| 197 | ); | |
| 162 | render(area, s) { | |
| 163 | const c = ctx.theme.c; | |
| 164 | const top = filter === "" ? "The SQLite files you may read; secrets are masked." : `Filter: ${filter}`; | |
| 165 | s.text(area.x + 1, area.y, truncate(top, area.w - 2), style(c.mutedFg, c.bg), area.x + area.w); | |
| 166 | const list = lines(); | |
| 167 | if (list.length === 0) s.text(area.x + 1, area.y + 2, filter === "" ? "Nothing here yet." : "No open line matches.", style(c.mutedFg, c.bg), area.x + area.w); | |
| 168 | const h = Math.max(1, area.h - 3); | |
| 169 | const from = Math.max(0, Math.min(at - h + 1, list.length - h)); | |
| 170 | list.slice(from, from + h).forEach((n, i) => { | |
| 171 | const on = from + i === at; | |
| 172 | const mark = opens(n) ? (open.has(n.key) ? "v " : "> ") : " "; | |
| 173 | s.text(area.x + 1, area.y + 2 + i, truncate(`${" ".repeat(n.depth)}${mark}${n.label}`, area.w - 2), style(on ? c.accent : c.fg, c.bg), area.x + area.w); | |
| 174 | }); | |
| 175 | if (mode) { | |
| 176 | s.text(area.x + 1, area.y + area.h - 1, truncate(`${mode === "sql" ? ":" : "/"}${line.text}`, area.w - 2), style(c.fg, c.card), area.x + area.w); | |
| 177 | } | |
| 178 | }, | |
| 179 | key(e) { | |
| 180 | if (mode) { | |
| 181 | if (e.key === "esc") { | |
| 182 | if (mode === "filter") (filter = ""), dirty(); | |
| 183 | mode = undefined; | |
| 184 | } else if (e.key === "enter") { | |
| 185 | const done = mode; | |
| 186 | mode = undefined; | |
| 187 | if (done === "filter") { | |
| 188 | filter = line.text; | |
| 189 | dirty(); | |
| 190 | at = 0; | |
| 191 | } else ask(line.text); | |
| 192 | } else if (!line.key(e)) return true; | |
| 193 | else if (mode === "filter") { | |
| 194 | filter = line.text; | |
| 195 | dirty(); | |
| 196 | at = 0; | |
| 197 | } | |
| 198 | return true; | |
| 199 | } | |
| 200 | if (e.ctrl || e.alt) return false; | |
| 201 | const n = current(); | |
| 202 | if (e.key === "esc" || e.key === "q") { | |
| 203 | if (filter !== "") (filter = ""), dirty(); | |
| 204 | else ctx.pop(); | |
| 205 | } else if (e.key === "j" || e.key === "down") at = Math.min(Math.max(0, lines().length - 1), at + 1); | |
| 206 | else if (e.key === "k" || e.key === "up") at = Math.max(0, at - 1); | |
| 207 | else if ((e.key === "l" || e.key === "right" || e.key === "enter") && n && opens(n)) (open.add(n.key), dirty(true)); | |
| 208 | else if (e.key === "h" || e.key === "left") { | |
| 209 | if (n && open.has(n.key)) (open.delete(n.key), dirty(true)); | |
| 210 | else if (n) { | |
| 211 | const up = parentKey(n.key); | |
| 212 | if (up !== undefined) { | |
| 213 | open.delete(up); | |
| 214 | dirty(true); | |
| 215 | at = Math.max(0, lines().findIndex((x) => x.key === up)); | |
| 216 | } | |
| 217 | } | |
| 218 | } else if (e.key === "/") { | |
| 219 | mode = "filter"; | |
| 220 | line = typing(FILTER_MAX, filter); | |
| 221 | } else if (e.key === ":") { | |
| 222 | mode = "sql"; | |
| 223 | line = typing(SQL_MAX, n?.table ? `SELECT * FROM "${n.table.replaceAll('"', '""')}" LIMIT 20` : ""); | |
| 224 | } else if (e.key === "n" || e.key === "p") turn(n, e.key === "n" ? 1 : -1); | |
| 225 | else if (e.key === "r") { | |
| 226 | reload(); | |
| 227 | ctx.toast("Reloaded."); | |
| 228 | } else if (e.key === "y") { | |
| 229 | if (n?.json) { | |
| 230 | ctx.copy(JSON.stringify(n.json)); | |
| 231 | ctx.toast("Row sent to your clipboard as JSON."); | |
| 232 | } else ctx.toast("Move onto a row to yank it."); | |
| 233 | } else return false; | |
| 234 | clamp(); | |
| 235 | return true; | |
| 236 | }, | |
| 237 | paste(text) { | |
| 238 | if (!mode) return false; | |
| 239 | line.paste(text); | |
| 240 | if (mode === "filter") (filter = line.text), dirty(); | |
| 241 | return true; | |
| 242 | }, | |
| 243 | }; | |
| 198 | 244 | } |
| 199 | 245 | |
| 200 | 246 | /** Offered to an admin and to an app's writers; a reader and a guest see nothing. */ |
| @@ -204,7 +250,7 @@ export const dataCommand = (session: Session, ctx: Ctx) => | ||
| 204 | 250 | { |
| 205 | 251 | id: "data", |
| 206 | 252 | title: "Data", |
| 207 | hint: "the SQLite files you may read, secrets masked", | |
| 253 | hint: "the SQLite files you may read as a tree; / filters, : asks in SQL, y yanks a row", | |
| 208 | 254 | run: () => ctx.push(dataScreen(session, ctx)), |
| 209 | 255 | }, |
| 210 | 256 | ] |
test/cli-data.test.ts added +185
| @@ -0,0 +1,185 @@ | ||
| 1 | /** `forge data`: lists, tables, rows and read-only SQL, from a shell, over the MCP tool. */ | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { Database } from "bun:sqlite"; | |
| 4 | import { mkdirSync } from "node:fs"; | |
| 5 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 6 | import { tmpdir } from "node:os"; | |
| 7 | import { join } from "node:path"; | |
| 8 | import { createApp, type App } from "../src/app.ts"; | |
| 9 | import { envDataDir } from "../src/apps/data.ts"; | |
| 10 | import { EXIT, main } from "../src/cli/main.ts"; | |
| 11 | import { pick } from "../src/cli/commands/data.ts"; | |
| 12 | import { createApp as createAppRecord } from "../src/core/apps.ts"; | |
| 13 | import { createRepo } from "../src/core/repos.ts"; | |
| 14 | import { SQL_PER_MINUTE } from "../src/data/sql_window.ts"; | |
| 15 | import { createToken } from "../src/core/tokens.ts"; | |
| 16 | import { createUser } from "../src/core/users.ts"; | |
| 17 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 18 | import { quietLogs } from "./helpers/globals.ts"; | |
| 19 | ||
| 20 | quietLogs(); | |
| 21 | ||
| 22 | let dir: string; | |
| 23 | let app: App; | |
| 24 | let servers: Servers; | |
| 25 | const token: Record<string, string> = {}; | |
| 26 | const env = { url: process.env["FORGE_URL"], token: process.env["FORGE_TOKEN"] }; | |
| 27 | ||
| 28 | beforeAll(async () => { | |
| 29 | dir = await mkdtemp(join(tmpdir(), "forge-cli-data-")); | |
| 30 | app = createApp({ dataDir: dir, port: 0, host: "forge.test" }); | |
| 31 | servers = await serve(app); | |
| 32 | const ids: Record<string, string> = {}; | |
| 33 | for (const name of ["owner1", "alice", "bob"]) { | |
| 34 | const made = await createUser(app.db, { origin: "seed", trusted: true, username: name, email: `${name}@forge.local`, password: "a-good-passphrase", adminIfFirst: name === "owner1" }); | |
| 35 | if (!made.ok) throw new Error(made.error); | |
| 36 | ids[name] = made.value.id; | |
| 37 | token[name] = createToken(app.db, made.value.id, "t", "admin").token; | |
| 38 | } | |
| 39 | const repo = await createRepo(app.db, app.reposRoot, { ownerId: ids["alice"]!, name: "notes", visibility: "public" }); | |
| 40 | if (!repo.ok) throw new Error(repo.error); | |
| 41 | const rec = createAppRecord(app.db, { repoId: repo.value.id, slug: "notes", access: "public" }); | |
| 42 | if (!rec.ok) throw new Error(rec.error); | |
| 43 | const where = envDataDir(dir, rec.value.id, "production"); | |
| 44 | mkdirSync(where, { recursive: true }); | |
| 45 | const d = new Database(join(where, "app.db")); | |
| 46 | d.exec("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, api_token TEXT)"); | |
| 47 | d.exec("INSERT INTO users VALUES (1, 'ann-visible', 'tok-secret-1')"); | |
| 48 | d.exec("CREATE TABLE big (id INTEGER PRIMARY KEY)"); | |
| 49 | d.exec("WITH RECURSIVE c(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM c WHERE i < 60) INSERT INTO big SELECT i FROM c"); | |
| 50 | d.close(); | |
| 51 | const o = new Database(join(where, "other.db")); | |
| 52 | o.exec("CREATE TABLE extra (v TEXT)"); | |
| 53 | o.exec("INSERT INTO extra VALUES ('from-other')"); | |
| 54 | o.close(); | |
| 55 | }, 60_000); | |
| 56 | ||
| 57 | afterAll(async () => { | |
| 58 | servers.stop(); | |
| 59 | app.close(); | |
| 60 | for (const [k, v] of [["FORGE_URL", env.url], ["FORGE_TOKEN", env.token]] as const) | |
| 61 | if (v === undefined) delete process.env[k]; | |
| 62 | else process.env[k] = v; | |
| 63 | await rm(dir, { recursive: true, force: true }); | |
| 64 | }); | |
| 65 | ||
| 66 | async function run(who: string, argv: string[]) { | |
| 67 | process.env["FORGE_URL"] = servers.url; | |
| 68 | process.env["FORGE_TOKEN"] = token[who]!; | |
| 69 | const lines: string[] = []; | |
| 70 | const errs: string[] = []; | |
| 71 | const write = process.stdout.write.bind(process.stdout); | |
| 72 | const werr = process.stderr.write.bind(process.stderr); | |
| 73 | process.stdout.write = ((s: string) => (lines.push(String(s)), true)) as never; | |
| 74 | process.stderr.write = ((s: string) => (errs.push(String(s)), true)) as never; | |
| 75 | try { | |
| 76 | const code = await main(argv); | |
| 77 | return { code, text: lines.join(""), err: errs.join("") }; | |
| 78 | } finally { | |
| 79 | process.stdout.write = write; | |
| 80 | process.stderr.write = werr; | |
| 81 | } | |
| 82 | } | |
| 83 | ||
| 84 | test("lists the databases a writer may read", async () => { | |
| 85 | const r = await run("alice", ["data"]); | |
| 86 | expect(r.code).toBe(EXIT.ok); | |
| 87 | expect(r.text).toContain("notes production app.db"); | |
| 88 | expect((await run("bob", ["data", "--json"])).text.trim()).toBe("[]"); | |
| 89 | }); | |
| 90 | ||
| 91 | test("a table list, then rows with secrets masked", async () => { | |
| 92 | const t = await run("alice", ["data", "notes", "--json"]); | |
| 93 | expect(JSON.parse(t.text)).toEqual([{ name: "big", rows: 60 }, { name: "users", rows: 1 }]); | |
| 94 | const rows = await run("alice", ["data", "notes", "production", "--table", "users"]); | |
| 95 | expect(rows.text).toContain("ann-visible"); | |
| 96 | expect(rows.text).not.toContain("tok-secret-1"); | |
| 97 | }); | |
| 98 | ||
| 99 | test("SQL prints JSON with --json, as objects", async () => { | |
| 100 | const r = await run("alice", ["data", "notes", "select id, name from users", "--json"]); | |
| 101 | expect(r.code).toBe(EXIT.ok); | |
| 102 | expect(JSON.parse(r.text)).toEqual([{ id: 1, name: "ann-visible" }]); | |
| 103 | }); | |
| 104 | ||
| 105 | test("a write is refused, and a reader finds no such app", async () => { | |
| 106 | const w = await run("alice", ["data", "notes", "delete from users"]); | |
| 107 | expect(w.code).toBe(EXIT.failed); | |
| 108 | expect(w.err).toContain("Only SELECT and WITH statements are allowed."); | |
| 109 | const b = await run("bob", ["data", "notes", "select 1"]); | |
| 110 | expect(b.code).toBe(EXIT.usage); | |
| 111 | }); | |
| 112 | ||
| 113 | test("--file picks another database of the environment; an unknown one is a usage error", async () => { | |
| 114 | const r = await run("alice", ["data", "notes", "--file", "other.db", "--table", "extra"]); | |
| 115 | expect(r.text).toContain("from-other"); | |
| 116 | const none = await run("alice", ["data", "notes", "--file", "nope.db"]); | |
| 117 | expect(none.code).toBe(EXIT.usage); | |
| 118 | }); | |
| 119 | ||
| 120 | test("an env argument names the environment; one that has no files is a usage error", async () => { | |
| 121 | expect((await run("alice", ["data", "notes", "production", "--json"])).code).toBe(EXIT.ok); | |
| 122 | expect((await run("alice", ["data", "notes", "pull-9"])).code).toBe(EXIT.usage); | |
| 123 | }); | |
| 124 | ||
| 125 | test("--page turns the page; a bad one exits 2 before asking the forge", async () => { | |
| 126 | const one = JSON.parse((await run("alice", ["data", "notes", "--table", "big", "--page", "1", "--json"])).text); | |
| 127 | expect(one.length).toBe(10); | |
| 128 | expect(one[0]).toEqual({ id: 51 }); | |
| 129 | for (const bad of [["--page", "x"], ["--page", "-1"], ["--page=-1"], ["--page", "1.5"], ["--page="], ["--page"]]) { | |
| 130 | const r = await run("alice", ["data", "notes", "--table", "big", ...bad]); | |
| 131 | expect(r.code).toBe(EXIT.usage); | |
| 132 | expect(r.err).toContain("--page"); | |
| 133 | } | |
| 134 | }); | |
| 135 | ||
| 136 | test("--table with SQL is refused, not half-obeyed", async () => { | |
| 137 | const r = await run("alice", ["data", "notes", "select 1", "--table", "users"]); | |
| 138 | expect(r.code).toBe(EXIT.usage); | |
| 139 | expect(r.err).toContain("--table"); | |
| 140 | }); | |
| 141 | ||
| 142 | test("an unknown table is the forge's words, exit 1", async () => { | |
| 143 | const r = await run("alice", ["data", "notes", "--table", "nope"]); | |
| 144 | expect(r.code).toBe(EXIT.failed); | |
| 145 | expect(r.err).toContain("no such table"); | |
| 146 | }); | |
| 147 | ||
| 148 | test("SQL from the shell keeps the per-person budget: the 21st run in a minute is refused, exit 1", async () => { | |
| 149 | // owner1 runs no other SQL in this file, so its window starts empty. | |
| 150 | for (let i = 0; i < SQL_PER_MINUTE; i++) expect((await run("owner1", ["data", "notes", "select 1", "--json"])).code).toBe(EXIT.ok); | |
| 151 | const r = await run("owner1", ["data", "notes", "select 1"]); | |
| 152 | expect(r.code).toBe(EXIT.failed); | |
| 153 | expect(r.err).toMatch(/try again in \d+ s/); | |
| 154 | // Browsing a table is not SQL and stays open. | |
| 155 | expect((await run("owner1", ["data", "notes", "--table", "users"])).code).toBe(EXIT.ok); | |
| 156 | }); | |
| 157 | ||
| 158 | test("not signed in exits 3", async () => { | |
| 159 | process.env["FORGE_URL"] = ""; | |
| 160 | process.env["FORGE_TOKEN"] = ""; | |
| 161 | const prev = process.env["FORGE_CONFIG_DIR"]; | |
| 162 | process.env["FORGE_CONFIG_DIR"] = join(dir, "no-config"); | |
| 163 | const errs: string[] = []; | |
| 164 | const werr = process.stderr.write.bind(process.stderr); | |
| 165 | process.stderr.write = ((x: string) => (errs.push(String(x)), true)) as never; | |
| 166 | try { | |
| 167 | expect(await main(["data"])).toBe(EXIT.notConfigured); | |
| 168 | } finally { | |
| 169 | process.stderr.write = werr; | |
| 170 | if (prev === undefined) delete process.env["FORGE_CONFIG_DIR"]; | |
| 171 | else process.env["FORGE_CONFIG_DIR"] = prev; | |
| 172 | } | |
| 173 | expect(errs.join("")).toContain("forge login"); | |
| 174 | }); | |
| 175 | ||
| 176 | test("pick: several matches are named, not guessed", () => { | |
| 177 | const list = [ | |
| 178 | { source: "1", label: "notes · production · a.db" }, | |
| 179 | { source: "2", label: "notes · production · b.db" }, | |
| 180 | ]; | |
| 181 | const r = pick(list, "notes", undefined, undefined); | |
| 182 | expect(typeof r).toBe("string"); | |
| 183 | expect(r).toContain("several databases match"); | |
| 184 | expect(pick(list, "notes", undefined, "b.db")).toEqual(list[1]!); | |
| 185 | }); |
test/data-tree.test.ts added +119
| @@ -0,0 +1,119 @@ | ||
| 1 | /** The data tree: grouping, filter, parent, paging, one read per open node, an unreadable file. */ | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { Database } from "bun:sqlite"; | |
| 4 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 5 | import { tmpdir } from "node:os"; | |
| 6 | import { join } from "node:path"; | |
| 7 | import { copyFileSync, rmSync } from "node:fs"; | |
| 8 | import type { Source } from "../src/data/sources.ts"; | |
| 9 | import { filtered, newReads, opens, parentKey, visible } from "../src/data/tree.ts"; | |
| 10 | ||
| 11 | let dir: string; | |
| 12 | let good: Source; | |
| 13 | let bad: Source; | |
| 14 | let forgeDb: Source; | |
| 15 | ||
| 16 | beforeAll(async () => { | |
| 17 | dir = await mkdtemp(join(tmpdir(), "forge-tree-")); | |
| 18 | const p = join(dir, "app.db"); | |
| 19 | const d = new Database(p); | |
| 20 | d.exec("CREATE TABLE t (id INTEGER PRIMARY KEY, note TEXT)"); | |
| 21 | d.exec("WITH RECURSIVE c(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM c WHERE i < 60) INSERT INTO t SELECT i, 'n' || i FROM c"); | |
| 22 | d.exec("CREATE TABLE ctl (v TEXT)"); | |
| 23 | d.run("INSERT INTO ctl VALUES (?)", ["a\u001b[31mb\u0007\nc"]); | |
| 24 | d.close(); | |
| 25 | const where = { repo: "alice/notes", app: "notes", env: "production", file: "app.db" }; | |
| 26 | good = { id: "g", label: "notes · production · app.db", kind: "app", path: p, where }; | |
| 27 | bad = { id: "b", label: "notes · production · bad.db", kind: "app", path: join(dir, "bad.db"), where: { ...where, file: "bad.db" } }; | |
| 28 | await Bun.write(bad.path, "this is not a database, only text".repeat(40)); | |
| 29 | forgeDb = { id: "forge", label: "forge", kind: "forge", path: p }; | |
| 30 | }); | |
| 31 | afterAll(() => rm(dir, { recursive: true, force: true })); | |
| 32 | ||
| 33 | const all = (open: string[]) => visible([good, bad, forgeDb], new Set(open)); | |
| 34 | ||
| 35 | test("files outside any app come first, then repos group apps, envs and files", () => { | |
| 36 | const nodes = visible([good, bad, forgeDb], new Set()); | |
| 37 | expect(nodes.map((n) => [n.kind, n.label])).toEqual([["file", "forge.db"], ["repo", "alice/notes"]]); | |
| 38 | const repo = nodes[1]!; | |
| 39 | const deeper = visible([good, bad, forgeDb], new Set([repo.key])); | |
| 40 | expect(deeper.map((n) => n.kind)).toEqual(["file", "repo", "app"]); | |
| 41 | expect(opens(deeper[2]!)).toBe(true); | |
| 42 | }); | |
| 43 | ||
| 44 | test("parentKey names the line above, undefined at the top", () => { | |
| 45 | const nodes = all([]); | |
| 46 | const repo = nodes.find((n) => n.kind === "repo")!; | |
| 47 | const app = visible([good], new Set([repo.key])).find((n) => n.kind === "app")!; | |
| 48 | expect(parentKey(app.key)).toBe(repo.key); | |
| 49 | expect(parentKey(repo.key)).toBeUndefined(); | |
| 50 | }); | |
| 51 | ||
| 52 | test("filtered keeps hits and the lines that lead to them; empty keeps all", () => { | |
| 53 | const repo = all([]).find((n) => n.kind === "repo")!; | |
| 54 | const open = new Set<string>([repo.key]); | |
| 55 | let nodes = visible([good], open); | |
| 56 | const app = nodes.find((n) => n.kind === "app")!; | |
| 57 | open.add(app.key); | |
| 58 | nodes = visible([good], open); | |
| 59 | expect(filtered(nodes, "").length).toBe(nodes.length); | |
| 60 | expect(filtered(nodes, " ").length).toBe(nodes.length); | |
| 61 | expect(filtered(nodes, "PRODUCTION").map((n) => n.kind)).toEqual(["repo", "app", "env"]); | |
| 62 | expect(filtered(nodes, "zzz")).toEqual([]); | |
| 63 | }); | |
| 64 | ||
| 65 | function openAll(sources: Source[], extra: (k: string) => boolean = () => false) { | |
| 66 | const open = new Set<string>(); | |
| 67 | const reads = newReads(); | |
| 68 | const pages = new Map<string, number>(); | |
| 69 | for (let i = 0; i < 6; i++) | |
| 70 | for (const n of visible(sources, open, reads, pages)) if (n.kind !== "row" && (n.kind !== "table" || extra(n.label))) open.add(n.key); | |
| 71 | return { open, reads, pages }; | |
| 72 | } | |
| 73 | ||
| 74 | test("an unreadable file shows as a file with no tables, and the rest still shows", () => { | |
| 75 | const { open } = openAll([good, bad]); | |
| 76 | const nodes = visible([good, bad], open); | |
| 77 | const labels = nodes.map((n) => n.label); | |
| 78 | expect(labels).toContain("bad.db"); | |
| 79 | expect(labels).toContain("app.db"); | |
| 80 | const at = nodes.findIndex((n) => n.label === "bad.db"); | |
| 81 | expect(nodes[at + 1]?.kind).not.toBe("table"); | |
| 82 | }); | |
| 83 | ||
| 84 | test("a table pages 50 rows at a time, and a row carries its JSON", () => { | |
| 85 | const { open, reads, pages } = openAll([good], (l) => l.startsWith("t ")); | |
| 86 | const rows = (page: number) => { | |
| 87 | const t = visible([good], open, reads, pages).find((n) => n.kind === "table" && n.table === "t")!; | |
| 88 | pages.set(t.key, page); | |
| 89 | return visible([good], open, reads, pages).filter((n) => n.kind === "row" && n.table === "t"); | |
| 90 | }; | |
| 91 | expect(rows(0).length).toBe(50); | |
| 92 | expect(rows(1).length).toBe(10); | |
| 93 | expect(rows(1)[0]!.json).toEqual({ id: 51, note: "n51" }); | |
| 94 | }); | |
| 95 | ||
| 96 | test("a rebuild reads nothing again while the reads are kept", () => { | |
| 97 | const copy = join(dir, "gone.db"); | |
| 98 | copyFileSync(good.path, copy); | |
| 99 | const src: Source = { ...good, id: "x", path: copy }; | |
| 100 | const { open, reads, pages } = openAll([src], (l) => l.startsWith("t ")); | |
| 101 | const first = visible([src], open, reads, pages).map((n) => n.label); | |
| 102 | rmSync(copy); | |
| 103 | expect(visible([src], open, reads, pages).map((n) => n.label)).toEqual(first); | |
| 104 | expect(visible([src], open, newReads(), pages).filter((n) => n.kind === "table")).toEqual([]); | |
| 105 | }); | |
| 106 | ||
| 107 | test("a table carries its row count as data, not only in its label", () => { | |
| 108 | const { open } = openAll([good], () => false); | |
| 109 | const t = visible([good], open).find((n) => n.kind === "table" && n.table === "t")!; | |
| 110 | expect(typeof t.total).toBe("number"); | |
| 111 | expect(t.label).toContain(`${t.total} rows`); | |
| 112 | }); | |
| 113 | ||
| 114 | test("a control byte or newline in a cell never reaches a row's line", () => { | |
| 115 | const { open } = openAll([good], (l) => l.startsWith("ctl")); | |
| 116 | const row = visible([good], open).find((n) => n.kind === "row" && n.table === "ctl")!; | |
| 117 | expect(row.label).toBe("a [31mb c"); | |
| 118 | expect(row.label).not.toMatch(/[\u0000-\u001f\u007f]/); | |
| 119 | }); |
test/palette-roster.test.ts +2 −2
| @@ -48,7 +48,7 @@ test("a site-admin person is offered the whole roster", () => { | ||
| 48 | 48 | ["home", "Home", "your repositories"], |
| 49 | 49 | ["explore", "Explore", "on the web"], |
| 50 | 50 | ["packs", "Forge packs", "premade forges to deploy, and how to help build their apps"], |
| 51 | ["data", "Data", "the SQLite files you may read, secrets masked"], | |
| 51 | ["data", "Data", "the SQLite files you may read as a tree; / filters, : asks in SQL, y yanks a row"], | |
| 52 | 52 | ["fleet", "Fleet rows", "the rows a fleet query line selects"], |
| 53 | 53 | ["profile", "Open a profile", "username or org"], |
| 54 | 54 | ["repos", "Repositories", "the repositories you may write"], |
| @@ -165,7 +165,7 @@ test("a site-admin agent is offered the roster without the person-only entries", | ||
| 165 | 165 | ["home", "Home", "your repositories"], |
| 166 | 166 | ["explore", "Explore", "on the web"], |
| 167 | 167 | ["packs", "Forge packs", "premade forges to deploy, and how to help build their apps"], |
| 168 | ["data", "Data", "the SQLite files you may read, secrets masked"], | |
| 168 | ["data", "Data", "the SQLite files you may read as a tree; / filters, : asks in SQL, y yanks a row"], | |
| 169 | 169 | ["fleet", "Fleet rows", "the rows a fleet query line selects"], |
| 170 | 170 | ["profile", "Open a profile", "username or org"], |
| 171 | 171 | ["repos", "Repositories", "the repositories you may write"], |
test/tui-data.test.ts +175 −66
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | /** The terminal's Data screen: gated as the page is, reads the same masked rows. */ |
| 2 | import { sqlWindow } from "../src/data/sql_window.ts"; | |
| 2 | 3 | import { afterAll, beforeAll, expect, test } from "bun:test"; |
| 3 | 4 | import { Database } from "bun:sqlite"; |
| 4 | 5 | import { mkdirSync } from "node:fs"; |
| @@ -12,9 +13,7 @@ import { createRepo } from "../src/core/repos.ts"; | ||
| 12 | 13 | import { createUser, getUserByName } from "../src/core/users.ts"; |
| 13 | 14 | import { forgeCommands } from "../src/tui/forge.ts"; |
| 14 | 15 | import { createForgeApp } from "../src/tui/forge.ts"; |
| 15 | import { query } from "../src/data/query.ts"; | |
| 16 | import { listSources } from "../src/data/sources.ts"; | |
| 17 | import { dataScreen } from "../src/tui/screens/data.ts"; | |
| 16 | import { dataScreen, FILTER_MAX, SQL_MAX, typing } from "../src/tui/screens/data.ts"; | |
| 18 | 17 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; |
| 19 | 18 | import { quietLogs } from "./helpers/globals.ts"; |
| 20 | 19 | import { settleApp } from "./helpers/tui.ts"; |
| @@ -23,8 +22,6 @@ quietLogs(); | ||
| 23 | 22 | |
| 24 | 23 | let dir: string; |
| 25 | 24 | let app: App; |
| 26 | let appRecordId = ""; | |
| 27 | const appIdOf = () => appRecordId; | |
| 28 | 25 | |
| 29 | 26 | beforeAll(async () => { |
| 30 | 27 | dir = await mkdtemp(join(tmpdir(), "forge-tui-data-")); |
| @@ -39,12 +36,13 @@ beforeAll(async () => { | ||
| 39 | 36 | if (!repo.ok) throw new Error(repo.error); |
| 40 | 37 | const rec = createAppRecord(app.db, { repoId: repo.value.id, slug: "notes", access: "public" }); |
| 41 | 38 | if (!rec.ok) throw new Error(rec.error); |
| 42 | appRecordId = rec.value.id; | |
| 43 | 39 | const where = envDataDir(dir, rec.value.id, "production"); |
| 44 | 40 | mkdirSync(where, { recursive: true }); |
| 45 | 41 | const d = new Database(join(where, "app.db")); |
| 46 | 42 | d.exec("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, api_token TEXT)"); |
| 47 | 43 | d.exec("INSERT INTO users VALUES (1, 'ann-visible', 'tok-secret-1')"); |
| 44 | d.exec("CREATE TABLE zbig (id INTEGER PRIMARY KEY)"); | |
| 45 | d.exec("WITH RECURSIVE c(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM c WHERE i < 60) INSERT INTO zbig SELECT i FROM c"); | |
| 48 | 46 | d.close(); |
| 49 | 47 | }); |
| 50 | 48 | afterAll(async () => { |
| @@ -72,23 +70,6 @@ test("an admin and an app's writer are offered it; a reader and a guest are not" | ||
| 72 | 70 | expect(offered()).toBe(false); |
| 73 | 71 | }); |
| 74 | 72 | |
| 75 | test("a writer walks from the files to the tables to masked rows", async () => { | |
| 76 | const { app: tui, session } = open("alice"); | |
| 77 | tui.start(); | |
| 78 | tui.ctx.push(dataScreen(session, tui.ctx)); | |
| 79 | await settleApp(tui); | |
| 80 | expect(tui.screen.toText()).toContain("notes · production · app.db"); | |
| 81 | tui.dispatch({ type: "key", key: "enter", ctrl: false, alt: false, shift: false }); | |
| 82 | await settleApp(tui); | |
| 83 | expect(tui.screen.toText()).toContain("users 1 rows"); | |
| 84 | tui.dispatch({ type: "key", key: "enter", ctrl: false, alt: false, shift: false }); | |
| 85 | await settleApp(tui); | |
| 86 | const text = tui.screen.toText(); | |
| 87 | expect(text).toContain("ann-visible"); | |
| 88 | expect(text).not.toContain("tok-secret-1"); | |
| 89 | tui.stop?.(); | |
| 90 | }); | |
| 91 | ||
| 92 | 73 | test("an admin sees forge.db among the files", async () => { |
| 93 | 74 | const { app: tui, session } = open("owner1"); |
| 94 | 75 | tui.start(); |
| @@ -98,60 +79,188 @@ test("an admin sees forge.db among the files", async () => { | ||
| 98 | 79 | tui.stop?.(); |
| 99 | 80 | }); |
| 100 | 81 | |
| 101 | const press = async (tui: ReturnType<typeof open>["app"], ...keys: string[]) => { | |
| 102 | for (const key of keys) { | |
| 103 | tui.dispatch({ type: "key", key, ctrl: false, alt: false, shift: false }); | |
| 82 | const press = (tui: ReturnType<typeof open>["app"], key: string) => | |
| 83 | tui.dispatch({ type: "key", key, ctrl: false, alt: false, shift: false }); | |
| 84 | const open_ = async (tui: ReturnType<typeof open>["app"], times: number) => { | |
| 85 | for (let i = 0; i < times; i++) { | |
| 86 | press(tui, "l"); | |
| 104 | 87 | await settleApp(tui); |
| 88 | press(tui, "j"); | |
| 105 | 89 | } |
| 90 | press(tui, "l"); | |
| 91 | await settleApp(tui); | |
| 92 | }; | |
| 93 | const typeText = (tui: ReturnType<typeof open>["app"], text: string) => { | |
| 94 | for (const ch of text) press(tui, ch === " " ? "space" : ch); | |
| 95 | }; | |
| 96 | /** `:` with the prompt's prefill erased, `sql` typed, and enter. */ | |
| 97 | const askSql = async (tui: ReturnType<typeof open>["app"], sql: string) => { | |
| 98 | press(tui, ":"); | |
| 99 | for (let i = 0; i < 80; i++) press(tui, "backspace"); | |
| 100 | typeText(tui, sql); | |
| 101 | press(tui, "enter"); | |
| 102 | await settleApp(tui); | |
| 106 | 103 | }; |
| 107 | const type = (tui: ReturnType<typeof open>["app"], text: string) => press(tui, ...text.split("")); | |
| 108 | 104 | |
| 109 | test("a writer sorts and filters a table, and runs read-only SQL with secrets masked", async () => { | |
| 110 | const d = new Database(join(envDataDir(dir, appIdOf(), "production"), "app.db")); | |
| 111 | d.exec("INSERT INTO users VALUES (2, 'zed-visible', 'tok-secret-2')"); | |
| 112 | d.close(); | |
| 105 | test("the tree lists a writer's repo, app, environment, file, table, then rows", async () => { | |
| 113 | 106 | const { app: tui, session } = open("alice"); |
| 114 | 107 | tui.start(); |
| 115 | 108 | tui.ctx.push(dataScreen(session, tui.ctx)); |
| 116 | 109 | await settleApp(tui); |
| 117 | await press(tui, "enter"); | |
| 118 | await press(tui, "x"); | |
| 119 | await type(tui, "SELECT api_token, name FROM users"); | |
| 120 | await press(tui, "enter"); | |
| 121 | await new Promise((r) => setTimeout(r, 300)); | |
| 122 | await settleApp(tui); | |
| 123 | let text = tui.screen.toText(); | |
| 124 | expect(text).toContain("zed-visible"); | |
| 125 | expect(text).not.toContain("tok-secret"); | |
| 126 | await press(tui, "esc", "enter"); | |
| 127 | await press(tui, "s"); | |
| 128 | await type(tui, "-name"); | |
| 129 | await press(tui, "enter"); | |
| 130 | text = tui.screen.toText(); | |
| 131 | expect(text.indexOf("zed-visible")).toBeLessThan(text.indexOf("ann-visible")); | |
| 132 | await press(tui, "f"); | |
| 133 | await type(tui, "name=ann"); | |
| 134 | await press(tui, "enter"); | |
| 135 | text = tui.screen.toText(); | |
| 136 | expect(text).toContain("ann-visible"); | |
| 137 | expect(text).not.toContain("zed-visible"); | |
| 110 | expect(tui.screen.toText()).toContain("alice/notes"); | |
| 111 | await open_(tui, 4); | |
| 112 | const text = tui.screen.toText(); | |
| 113 | for (const word of ["alice/notes", "notes", "production", "app.db", "users 1 rows", "ann-visible"]) expect(text).toContain(word); | |
| 114 | expect(text).not.toContain("tok-secret-1"); | |
| 138 | 115 | tui.stop?.(); |
| 139 | 116 | }); |
| 140 | 117 | |
| 141 | test("a reader and a guest get no files, so no SQL either", () => { | |
| 142 | for (const who of ["bob", undefined]) { | |
| 143 | const { app: tui, session } = open(who); | |
| 144 | expect(listSources(app.db, dir, session.user)).toEqual([]); | |
| 145 | tui.stop?.(); | |
| 146 | } | |
| 118 | test("/ filters the lines and h closes", async () => { | |
| 119 | const { app: tui, session } = open("alice"); | |
| 120 | tui.start(); | |
| 121 | tui.ctx.push(dataScreen(session, tui.ctx)); | |
| 122 | await settleApp(tui); | |
| 123 | await open_(tui, 1); | |
| 124 | press(tui, "/"); | |
| 125 | typeText(tui, "zzz"); | |
| 126 | await settleApp(tui); | |
| 127 | expect(tui.screen.toText()).toContain("No open line matches."); | |
| 128 | press(tui, "esc"); | |
| 129 | await settleApp(tui); | |
| 130 | expect(tui.screen.toText()).toContain("production"); | |
| 131 | press(tui, "h"); | |
| 132 | press(tui, "h"); | |
| 133 | await settleApp(tui); | |
| 134 | expect(tui.screen.toText()).not.toContain("production"); | |
| 135 | tui.stop?.(); | |
| 147 | 136 | }); |
| 148 | 137 | |
| 149 | test("the forge database is among an admin's files, and SQL on it is masked", async () => { | |
| 150 | const forge = listSources(app.db, dir, getUserByName(app.db, "owner1")!).find((s) => s.kind === "forge")!; | |
| 151 | const r = await query(forge, "SELECT password_hash FROM user"); | |
| 152 | expect(r.ok).toBe(true); | |
| 153 | const hash = app.db.query<{ h: string }, []>("SELECT password_hash AS h FROM user LIMIT 1").get()!.h; | |
| 154 | expect(hash.length).toBeGreaterThan(10); | |
| 155 | expect(JSON.stringify(r)).not.toContain(hash); | |
| 156 | expect(JSON.stringify(r)).toContain("••••••"); | |
| 138 | test(": runs a read-only SELECT and refuses a write; y yanks a row as JSON", async () => { | |
| 139 | const { app: tui, session } = open("alice"); | |
| 140 | const copied: string[] = []; | |
| 141 | tui.ctx.copy = (t: string) => void copied.push(t); | |
| 142 | tui.start(); | |
| 143 | tui.ctx.push(dataScreen(session, tui.ctx)); | |
| 144 | await settleApp(tui); | |
| 145 | await open_(tui, 4); | |
| 146 | await askSql(tui, "DELETE FROM users"); | |
| 147 | expect(tui.screen.toText()).toContain("Only SELECT and WITH statements are allowed."); | |
| 148 | await askSql(tui, "SELECT id, name FROM users"); | |
| 149 | expect(tui.screen.toText()).toContain("1 | ann-visible"); | |
| 150 | press(tui, "y"); | |
| 151 | expect(JSON.parse(copied[0]!)).toEqual({ id: 1, name: "ann-visible" }); | |
| 152 | tui.stop?.(); | |
| 153 | }); | |
| 154 | ||
| 155 | const fresh = async (who: string) => { | |
| 156 | const { app: tui, session } = open(who); | |
| 157 | const copied: string[] = []; | |
| 158 | tui.ctx.copy = (t: string) => void copied.push(t); | |
| 159 | tui.start(); | |
| 160 | tui.ctx.push(dataScreen(session, tui.ctx)); | |
| 161 | await settleApp(tui); | |
| 162 | return { tui, copied }; | |
| 163 | }; | |
| 164 | ||
| 165 | test("a reader opening the screen sees no file, not the forge's, not an app's", async () => { | |
| 166 | const { tui } = await fresh("bob"); | |
| 167 | const text = tui.screen.toText(); | |
| 168 | expect(text).toContain("Nothing here yet."); | |
| 169 | expect(text).not.toContain("alice/notes"); | |
| 170 | expect(text).not.toContain("forge.db"); | |
| 171 | tui.stop?.(); | |
| 172 | }); | |
| 173 | ||
| 174 | test("h on a closed line goes up to its parent and closes it", async () => { | |
| 175 | const { tui } = await fresh("alice"); | |
| 176 | await open_(tui, 3); // file open, cursor on the first table | |
| 177 | expect(tui.screen.toText()).toContain("users 1 rows"); | |
| 178 | press(tui, "h"); // table is closed already: up to the file, which closes | |
| 179 | await settleApp(tui); | |
| 180 | const text = tui.screen.toText(); | |
| 181 | expect(text).toContain("app.db"); | |
| 182 | expect(text).not.toContain("users 1 rows"); | |
| 183 | tui.stop?.(); | |
| 184 | }); | |
| 185 | ||
| 186 | test("y on a row in the tree yanks it as JSON, masked; y elsewhere yanks nothing", async () => { | |
| 187 | const { tui, copied } = await fresh("alice"); | |
| 188 | await open_(tui, 4); | |
| 189 | press(tui, "y"); // on the table | |
| 190 | expect(copied).toEqual([]); | |
| 191 | press(tui, "j"); | |
| 192 | press(tui, "y"); | |
| 193 | expect(JSON.parse(copied[0]!)).toEqual({ id: 1, name: "ann-visible", api_token: expect.not.stringContaining("tok-secret") }); | |
| 194 | tui.stop?.(); | |
| 195 | }); | |
| 196 | ||
| 197 | test("n and p turn the pages of a table past 50 rows", async () => { | |
| 198 | const { tui } = await fresh("alice"); | |
| 199 | await open_(tui, 3); | |
| 200 | press(tui, "j"); // users | |
| 201 | press(tui, "j"); // zbig | |
| 202 | press(tui, "l"); | |
| 203 | await settleApp(tui); | |
| 204 | expect(tui.screen.toText()).toContain("zbig 60 rows page 1/2"); | |
| 205 | press(tui, "n"); | |
| 206 | await settleApp(tui); | |
| 207 | const text = tui.screen.toText(); | |
| 208 | expect(text).toContain("page 2/2"); | |
| 209 | expect(text).toContain("51"); | |
| 210 | press(tui, "n"); | |
| 211 | await settleApp(tui); | |
| 212 | expect(tui.screen.toText()).toContain("That is the last page."); | |
| 213 | press(tui, "p"); | |
| 214 | await settleApp(tui); | |
| 215 | expect(tui.screen.toText()).toContain("page 1/2"); | |
| 216 | tui.stop?.(); | |
| 217 | }); | |
| 218 | ||
| 219 | test("a control byte in a cell cannot reach the terminal from a result, and y on a result yanks the row", async () => { | |
| 220 | const { tui, copied } = await fresh("alice"); | |
| 221 | await open_(tui, 4); | |
| 222 | await askSql(tui, "SELECT char(27) || '[31mred' || char(7) AS v, 2 AS n"); | |
| 223 | const text = tui.screen.toText(); | |
| 224 | expect(text).toContain("[31mred"); | |
| 225 | expect(text).not.toMatch(/[\u0000-\u0008\u000b-\u001f\u007f]/); | |
| 226 | press(tui, "y"); | |
| 227 | expect(JSON.parse(copied[0]!)).toEqual({ v: "\u001b[31mred\u0007", n: 2 }); | |
| 228 | tui.stop?.(); | |
| 229 | }); | |
| 230 | ||
| 231 | test(": is refused once the person's SQL budget is spent", async () => { | |
| 232 | const { tui, session } = await (async () => { | |
| 233 | const { app: tui, session } = open("alice"); | |
| 234 | tui.start(); | |
| 235 | tui.ctx.push(dataScreen(session, tui.ctx)); | |
| 236 | await settleApp(tui); | |
| 237 | return { tui, session }; | |
| 238 | })(); | |
| 239 | await open_(tui, 4); | |
| 240 | for (let i = 0; i < 25; i++) sqlWindow(session.app, session.user!.id); | |
| 241 | press(tui, ":"); | |
| 242 | press(tui, "enter"); | |
| 243 | await settleApp(tui); | |
| 244 | expect(tui.screen.toText()).toContain("Too many queries"); | |
| 245 | tui.stop?.(); | |
| 246 | }); | |
| 247 | ||
| 248 | test("an empty SELECT asks for one and runs nothing", async () => { | |
| 249 | const { tui } = await fresh("alice"); | |
| 250 | await open_(tui, 4); | |
| 251 | await askSql(tui, " "); | |
| 252 | expect(tui.screen.toText()).toContain("Type one SELECT."); | |
| 253 | tui.stop?.(); | |
| 254 | }); | |
| 255 | ||
| 256 | test("the prompts cut SQL at 4000 characters and the filter at 200, typed or pasted", () => { | |
| 257 | const sql = typing(SQL_MAX, "x".repeat(SQL_MAX + 10)); | |
| 258 | expect(sql.text.length).toBe(SQL_MAX); | |
| 259 | sql.paste("y".repeat(50)); | |
| 260 | sql.key({ type: "key", key: "z", ctrl: false, alt: false, shift: false }); | |
| 261 | expect(sql.text).toBe("x".repeat(SQL_MAX)); | |
| 262 | const filter = typing(FILTER_MAX); | |
| 263 | filter.paste("a".repeat(FILTER_MAX - 1)); | |
| 264 | filter.paste("bc"); | |
| 265 | expect(filter.text).toBe(`${"a".repeat(FILTER_MAX - 1)}b`); | |
| 157 | 266 | }); |