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

Merged opened this in answer to

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

Merged

scout/forge:scout/7581-the-terminal-app-browses-the-forge-like → main 7 commits fork

  • Merged. scout/7581-the-terminal-app-browses-the-forge-like was merged into main as f261dc1.

Review

Sign in to review this change.
15 files changed +1017 −242
docs/CLI.md +19
@@ -474,3 +474,22 @@ Flags: none.
474474 $ forge talk "what is waiting on me?"
475475 Two pulls are green and wait for your merge: #12 and #15.
476476 ```
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
3131 forge ship 12 # land pull #12 after a y/N, as Merge does
3232 forge pulls # open pulls of this clone, newest first
3333 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
3435 forge asks # what waits on you, most important first
3536 forge asks --repo owner/name # only that repository's; --json for the API's answer
3637 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
1212
1313 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.
1414
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.
1618
1719 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.
1820
src/cli/api.ts +24
@@ -187,6 +187,30 @@ export class ForgeApi {
187187 return ok(parsed as T);
188188 }
189189
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
190214 async whoami(): Promise<ApiResult<Whoami>> {
191215 const out = await this.call<{ username?: string; displayName?: string }>(
192216 "/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";
2121 import { command as talk } from "./talk.ts";
2222 import { command as newApp } from "./new.ts";
2323 import { command as ship } from "./ship.ts";
24 import { command as data } from "./data.ts";
2425 import type { Command } from "./types.ts";
2526
2627 export const commands: readonly Command[] = [
@@ -43,4 +44,5 @@ export const commands: readonly Command[] = [
4344 talk,
4445 newApp,
4546 ship,
47 data,
4648 ];
src/cli/main.ts +1 −1
@@ -36,7 +36,7 @@ export { EXIT };
3636 /** Every command by name, built once from `commands/index.ts`. */
3737 const TABLE = new Map(commands.map((c) => [c.name, c]));
3838
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"];
4040
4141 export async function main(
4242 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 {
1919 readonly label: string;
2020 readonly kind: "forge" | "app";
2121 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 };
2224 }
2325
2426 const DB_FILE = /^[\w.-]+\.db$/;
@@ -42,8 +44,8 @@ export function listSources(
4244 apps.map((a) => a.repoId),
4345 );
4446 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;
4749 let envs: string[];
4850 try {
4951 envs = readdirSync(join(dataDir, "apps", app.id)).filter(isEnvName);
@@ -65,6 +67,7 @@ export function listSources(
6567 label: `${app.slug} · ${env} · ${file}`,
6668 kind: "app",
6769 path: join(dir, file),
70 where: { repo: `${repo.ownerName}/${repo.name}`, app: app.slug, env, file },
6871 });
6972 }
7073 }
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 @@
11 /**
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.
810 */
911
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";
1315 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";
1418 import type { Ctx, Screen } from "../app/app.ts";
1519 import { binding } from "../app/keys.ts";
20 import type { KeyEvent } from "../term/input.ts";
1621 import type { Session } from "../session.ts";
1722 import { style } from "../term/style.ts";
1823 import { truncate } from "../term/width.ts";
19 import { later, promptScreen } from "./common.ts";
2024
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 {
3154 let at = 0;
3255 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")],
3558 render(area, s) {
3659 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) => {
4366 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);
4568 });
4669 },
4770 key(e) {
4871 if (e.ctrl || e.alt) return false;
49 const n = items().length;
5072 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);
5274 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.");
13080 } else return false;
13181 return true;
13282 },
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 };
18484 }
18585
18686 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 ];
196161 },
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 };
198244 }
199245
200246 /** 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) =>
204250 {
205251 id: "data",
206252 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",
208254 run: () => ctx.push(dataScreen(session, ctx)),
209255 },
210256 ]
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", () => {
4848 ["home", "Home", "your repositories"],
4949 ["explore", "Explore", "on the web"],
5050 ["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"],
5252 ["fleet", "Fleet rows", "the rows a fleet query line selects"],
5353 ["profile", "Open a profile", "username or org"],
5454 ["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",
165165 ["home", "Home", "your repositories"],
166166 ["explore", "Explore", "on the web"],
167167 ["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"],
169169 ["fleet", "Fleet rows", "the rows a fleet query line selects"],
170170 ["profile", "Open a profile", "username or org"],
171171 ["repos", "Repositories", "the repositories you may write"],
test/tui-data.test.ts +175 −66
@@ -1,4 +1,5 @@
11 /** The terminal's Data screen: gated as the page is, reads the same masked rows. */
2 import { sqlWindow } from "../src/data/sql_window.ts";
23 import { afterAll, beforeAll, expect, test } from "bun:test";
34 import { Database } from "bun:sqlite";
45 import { mkdirSync } from "node:fs";
@@ -12,9 +13,7 @@ import { createRepo } from "../src/core/repos.ts";
1213 import { createUser, getUserByName } from "../src/core/users.ts";
1314 import { forgeCommands } from "../src/tui/forge.ts";
1415 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";
1817 import { headlessTerminal } from "../src/tui/term/terminal.ts";
1918 import { quietLogs } from "./helpers/globals.ts";
2019 import { settleApp } from "./helpers/tui.ts";
@@ -23,8 +22,6 @@ quietLogs();
2322
2423 let dir: string;
2524 let app: App;
26 let appRecordId = "";
27 const appIdOf = () => appRecordId;
2825
2926 beforeAll(async () => {
3027 dir = await mkdtemp(join(tmpdir(), "forge-tui-data-"));
@@ -39,12 +36,13 @@ beforeAll(async () => {
3936 if (!repo.ok) throw new Error(repo.error);
4037 const rec = createAppRecord(app.db, { repoId: repo.value.id, slug: "notes", access: "public" });
4138 if (!rec.ok) throw new Error(rec.error);
42 appRecordId = rec.value.id;
4339 const where = envDataDir(dir, rec.value.id, "production");
4440 mkdirSync(where, { recursive: true });
4541 const d = new Database(join(where, "app.db"));
4642 d.exec("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, api_token TEXT)");
4743 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");
4846 d.close();
4947 });
5048 afterAll(async () => {
@@ -72,23 +70,6 @@ test("an admin and an app's writer are offered it; a reader and a guest are not"
7270 expect(offered()).toBe(false);
7371 });
7472
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
9273 test("an admin sees forge.db among the files", async () => {
9374 const { app: tui, session } = open("owner1");
9475 tui.start();
@@ -98,60 +79,188 @@ test("an admin sees forge.db among the files", async () => {
9879 tui.stop?.();
9980 });
10081
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");
10487 await settleApp(tui);
88 press(tui, "j");
10589 }
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);
106103 };
107 const type = (tui: ReturnType<typeof open>["app"], text: string) => press(tui, ...text.split(""));
108104
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 () => {
113106 const { app: tui, session } = open("alice");
114107 tui.start();
115108 tui.ctx.push(dataScreen(session, tui.ctx));
116109 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");
138115 tui.stop?.();
139116 });
140117
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?.();
147136 });
148137
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`);
157266 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs