Public: anyone can read this; the crew can change it.

A `.env.example` in a repository opens as the variables an app needs: name, example value and its comment per row #11130

Merged opened this

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

Merged

scout/forge:scout/11109-a-env-example-in-a-repository-opens-as-t → main 4 commits fork

  • Merged. scout/11109-a-env-example-in-a-repository-opens-as-t was merged into main as 406764f.

Review

Sign in to review this change.
8 files changed +274 −1
docs/design/areas/render.md +4
@@ -40,6 +40,10 @@ A .zip, .tar or .tar.gz is drawn by `src/web/render/archive-view.ts` as the exis
4040
4141 `src/web/render/junit.ts` draws the totals as a plain line, opened by one sentence of what the file is, then per suite an `h3`, one existing `.table` (`data-dense`) of its failing cases with each body in a `pre` below, and the passing cases inside one native `details`. `.junit-view` is only a hook on the wrapper and has no rule. The XML reader is its own: no DOCTYPE, no entity but the five predefined, at most 2,000 cases drawn (failing first). How it works: `docs/features/file-junit.md`.
4242
43 ## Environment example (.env.example)
44
45 `src/web/render/env.ts` draws one existing `.table` (`data-dense`): name, example, comment, with group headings as full-width `th` rows. `.env-view` is only a hook on the wrapper and has no rule. How it works: `docs/features/file-env.md`.
46
4347 ## OpenAPI operations (openapi.json, *.openapi.yaml)
4448
4549 `src/web/render/openapi.ts` draws one `h3` per tag and under it one existing `.table` (`data-dense`) of method, path, summary and a `deprecated` mark; the cap line is a `muted small` paragraph. `.openapi-view` is only a hook on the wrapper and has no rule. How it works: `docs/features/file-openapi.md`.
docs/features/file-env.md added +13
@@ -0,0 +1,13 @@
1 # A `.env.example` opens as the variables an app needs
2
3 A file named exactly `.env.example`, `.env.sample` or `.env.template` opens as a table instead of raw text: **name**, **example value** and the **comment** written above the line. Source is one tap away (`?view=source`). A plain `.env` (or `.env.local`, `.env.production`) is never drawn this way and keeps the source view.
4
5 - **Value:** shown as written, quotes removed; `—` when empty. A name ending `_KEY`, `_SECRET` or `_TOKEN`, or containing `PASSWORD`, shows `•••` instead of its value, even in an example file.
6 - **Comment:** the `#` lines directly above a variable, plus an inline ` # note`. `export ` is allowed; `#` inside quotes or a URL fragment is part of the value.
7 - **Groups:** a `#` block followed by a blank line is a group heading; the variables below it sit under it.
8 - **Cap:** at most 500 variables, then one line `N more not shown`. Files over 1 MB keep Source. A file with no variable shows Source with the reason.
9 - Every cell is escaped and the table is drawn on the server. No script, no new CSS (`.env-view` is a hook with no rule).
10
11 **Terminal:** the workspace opens the same table (`envTable` in `src/tui/screens/workspace.ts`, over the same `parseEnv`); `s` flips to the source.
12
13 Code: `src/web/render/env.ts`, `src/web/render/kinds.ts`. Tests: `test/render-env.test.ts`, `test/tui-workspace.test.ts`.
src/tui/screens/workspace.ts +20 −1
@@ -50,7 +50,7 @@ import { canAdmin, canWrite, DECISIONS_BY, getRepoById, type Repo } from "../../
5050 import type { TreeEntry } from "../../git/read.ts";
5151 import { parseUnifiedDiff, type Diff } from "../../git/diff.ts";
5252 import { renderDiff as renderPatch } from "../ui/diff.ts";
53 import { fileKind, kindByName, NO_BYTES } from "../../web/render/kinds.ts";
53 import { fileKind, isEnvExample, kindByName, NO_BYTES } from "../../web/render/kinds.ts";
5454 import { downloadHref } from "../../web/pages/media.ts";
5555 import { isModelPath, modelFacts } from "../../web/render/model-facts.ts";
5656 import type { RepoFacts, RepoSource } from "../../view/source.ts";
@@ -66,6 +66,7 @@ import { RECORD_CAP, parseLcov, worstFirst, type Ratio } from "../../web/render/
6666 import { EVENT_CAP, parseIcs } from "../../web/render/ics.ts";
6767 import { fileSize } from "../../web/ui.ts";
6868 import { parseEml } from "../../web/render/eml-parse.ts";
69 import { parseEnv, ROW_CAP as ENV_ROW_CAP } from "../../web/render/env.ts";
6970 import { CARD_CAP, parseVcf } from "../../web/render/vcf-parse.ts";
7071 import { CUE_CAP, parseSubtitles } from "../../web/render/subtitles.ts";
7172 import { isJsonlPath, kindView, type KindLines } from "./workspace_kinds.ts";
@@ -371,6 +372,21 @@ function vcfTable(source: string): Table | null {
371372 };
372373 }
373374
375 /** The variables of a `.env.example` as a table; null when it holds none. Shares the web's parser. */
376 function envTable(source: string): Table | null {
377 const doc = parseEnv(source);
378 if (doc.total === 0) return null;
379 const dash = (v: string): string => (v === "" ? "—" : v);
380 const rows: string[][] = [];
381 let last = "";
382 for (const r of doc.rows.slice(0, ENV_ROW_CAP)) {
383 if (r.group !== "" && r.group !== last) rows.push([`# ${r.group}`, "", ""]);
384 last = r.group;
385 rows.push([r.name, dash(r.value), dash(r.comment)]);
386 }
387 return { header: ["Name", "Example", "Comment"], rows, more: doc.total - doc.rows.length, delimiter: ",", columns: 3 };
388 }
389
374390 /** `.eml`: a message, drawn as rows of its headers, body and attachments. */
375391 function isEmlPath(path: string): boolean {
376392 return /\.eml$/i.test(path);
@@ -683,6 +699,9 @@ class Tab {
683699 } else if (isVcfPath(this.path)) {
684700 this.table = vcfTable(new TextDecoder().decode(source));
685701 if (this.table !== null) this.grid = layoutTable(this.table);
702 } else if (isEnvExample(this.path)) {
703 this.table = envTable(new TextDecoder().decode(source));
704 if (this.table !== null) this.grid = layoutTable(this.table);
686705 } else if (isEmlPath(this.path)) {
687706 this.table = emlTable(source);
688707 if (this.table !== null) this.grid = layoutTable(this.table);
src/web/render/env.ts added +118
@@ -0,0 +1,118 @@
1 /**
2 * A `.env.example`, `.env.sample` or `.env.template` as the variables an app
3 * needs: name, example value and the comment written above it, one row each.
4 * A comment block followed by a blank line is a group heading; one directly
5 * above a variable is that variable's comment (an inline ` # note` joins it).
6 * A value whose name looks secret is shown as `•••`, even in an example file.
7 * A plain `.env` is never this kind (`isEnvExample` in `kinds.ts`, matched by exact name).
8 * Every cell is escaped by the template; at most `ROW_CAP` variables are drawn.
9 */
10
11 import { html, type Html } from "../html.ts";
12 import { fileSize } from "../ui.ts";
13 import type { Rendered, RenderInput } from "./renderers.ts";
14
15 export const ROW_CAP = 500;
16 export const MASK = "•••";
17
18 export interface EnvRow {
19 readonly name: string;
20 /** The example value as written (quotes removed); `•••` when the name looks secret. */
21 readonly value: string;
22 readonly comment: string;
23 /** The heading of the group this variable sits under; empty before any. */
24 readonly group: string;
25 }
26
27 export interface EnvDoc {
28 readonly rows: readonly EnvRow[];
29 /** Variables in the file, drawn or not. */
30 readonly total: number;
31 }
32
33 /** `*_KEY`, `*_SECRET`, `*_TOKEN`, `*PASSWORD*` (and the bare words). */
34 export const looksSecret = (name: string): boolean => /(^|_)(KEY|SECRET|TOKEN)$|PASSWORD/i.test(name);
35
36 const ENTRY = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_.-]*)\s*=(.*)$/;
37
38 /** The value and any inline comment of what follows `=`. */
39 function value(rest: string): { value: string; comment: string } {
40 const s = rest.trimStart();
41 const q = s[0];
42 if (q === '"' || q === "'") {
43 let out = "";
44 for (let i = 1; i < s.length; i++) {
45 const ch = s[i]!;
46 if (q === '"' && ch === "\\" && i + 1 < s.length) {
47 const n = s[++i]!;
48 out += n === "n" ? "\n" : n;
49 } else if (ch === q) {
50 const tail = s.slice(i + 1).trim();
51 return { value: out, comment: tail.startsWith("#") ? tail.slice(1).trim() : "" };
52 } else out += ch;
53 }
54 return { value: out, comment: "" }; // unterminated: the line's rest
55 }
56 const hash = s.search(/(^|\s)#/);
57 if (hash === -1) return { value: s.trim(), comment: "" };
58 return { value: s.slice(0, hash).trim(), comment: s.slice(s.indexOf("#", hash) + 1).trim() };
59 }
60
61 /** `# ---- Database ----` is `Database`. */
62 const commentText = (line: string): string =>
63 line.trim().replace(/^#+\s?/, "").replace(/\s*[-=*#_~]{2,}\s*$/, "").replace(/^[-=*#_~]{2,}\s*/, "").trim();
64
65 /** Read a dotenv example. Pure; never throws; reads at most `ROW_CAP` rows but counts all. */
66 export function parseEnv(source: string): EnvDoc {
67 const rows: EnvRow[] = [];
68 let total = 0;
69 let group = "";
70 let pending: string[] = [];
71 for (const raw of source.replace(/^/, "").split(/\r?\n/)) {
72 const line = raw.trim();
73 if (line === "") {
74 // A comment block set off by a blank line names the group that follows.
75 const heading = pending.filter((p) => p !== "").join(" ");
76 if (heading !== "") group = heading;
77 pending = [];
78 } else if (line.startsWith("#")) pending.push(commentText(line));
79 else {
80 const m = ENTRY.exec(raw);
81 if (m !== null) {
82 total++;
83 if (rows.length < ROW_CAP) {
84 const v = value(m[2]!);
85 const comment = [...pending.filter((p) => p !== ""), v.comment].filter((p) => p !== "").join(" ");
86 rows.push({
87 name: m[1]!,
88 value: v.value !== "" && looksSecret(m[1]!) ? MASK : v.value,
89 comment,
90 group,
91 });
92 }
93 }
94 pending = [];
95 }
96 }
97 return { rows, total };
98 }
99
100 const cell = (v: string): string => (v === "" ? "—" : v);
101
102 export function envView(input: RenderInput): Rendered {
103 const doc = parseEnv(new TextDecoder().decode(input.body));
104 if (doc.total === 0) return { content: html``, lines: 0, fallback: "This file holds no variables to draw." };
105 let last: string | undefined;
106 const body: Html[] = [];
107 for (const r of doc.rows) {
108 if (r.group !== "" && r.group !== last) body.push(html`<tr><th scope="rowgroup" colspan="3">${r.group}</th></tr>`);
109 last = r.group;
110 body.push(html`<tr><th scope="row"><code>${r.name}</code></th><td>${cell(r.value)}</td><td>${cell(r.comment)}</td></tr>`);
111 }
112 const more = doc.total - doc.rows.length;
113 const foot = more > 0 ? html`<p class="muted small">${more.toLocaleString()} more not shown</p>` : html``;
114 const name = input.path.slice(input.path.lastIndexOf("/") + 1);
115 const content = html`<div class="env-view"><table class="table" data-dense aria-label="${name} variables"><thead><tr><th scope="col">Name</th><th scope="col">Example</th><th scope="col">Comment</th></tr></thead><tbody>${body}</tbody></table>${foot}</div>`;
116 const n = doc.total;
117 return { content, lines: 0, summary: `${n.toLocaleString()} ${n === 1 ? "variable" : "variables"} · ${fileSize(input.size)}` };
118 }
src/web/render/kinds.ts +8
@@ -40,6 +40,7 @@ export type FileKind =
4040 | "sarif"
4141 | "lcov"
4242 | "junit"
43 | "env"
4344 | "openapi"
4445 | "text"
4546 | "binary";
@@ -79,6 +80,7 @@ export const RENDER_CAP: Readonly<Record<FileKind, number>> = {
7980 sarif: 5 * MB,
8081 lcov: 5 * MB,
8182 junit: 5 * MB,
83 env: 1 * MB,
8284 openapi: 1 * MB,
8385 text: 1 * MB,
8486 binary: 0,
@@ -205,6 +207,10 @@ const TEXTUAL: Readonly<Record<string, FileKind>> = {
205207 lcov: "lcov",
206208 };
207209
210 /** The three example names; a plain `.env` is never one. */
211 export const isEnvExample = (path: string): boolean =>
212 /^\.env\.(example|sample|template)$/i.test(path.slice(path.lastIndexOf("/") + 1));
213
208214 /** `openapi.json|yaml|yml` and `*.openapi.*`: the name of an OpenAPI document; its content decides whether it is drawn as one. */
209215 const isOpenApiName = (path: string): boolean => /(^|\/|\.)openapi\.(json|ya?ml)$/i.test(path);
210216
@@ -277,6 +283,7 @@ export function fileKind(path: string, head: Uint8Array): FileKind {
277283 if (isArchive(path, head)) return "archive";
278284 if (looksBinary(head)) return "binary";
279285 if (isLcovName(path)) return "lcov";
286 if (isEnvExample(path)) return "env";
280287 if (isJunit(path, head)) return "junit";
281288 if (isOpenApiName(path)) return "openapi";
282289 return TEXTUAL[extension(path)] ?? "text";
@@ -293,6 +300,7 @@ export const needsPaths = (kind: FileKind): boolean => kind === "sarif" || kind
293300 export function kindByName(path: string): FileKind {
294301 const ext = extension(path);
295302 if (isLcovName(path)) return "lcov";
303 if (isEnvExample(path)) return "env";
296304 if (isOpenApiName(path)) return "openapi";
297305 return MEDIA[ext]?.kind ?? TEXTUAL[ext] ?? "text";
298306 }
src/web/render/renderers.ts +2
@@ -10,6 +10,7 @@ import { archive } from "./archive-view.ts";
1010 import { audio } from "./audio.ts";
1111 import { config } from "./config.ts";
1212 import { isFormConfig } from "./config-format.ts";
13 import { envView } from "./env.ts";
1314 import { eml } from "./eml.ts";
1415 import { csv } from "./csv.ts";
1516 import { geojson } from "./geojson.ts";
@@ -98,6 +99,7 @@ export const RENDERERS: Partial<Readonly<Record<FileKind, Renderer>>> = {
9899 sarif,
99100 lcov,
100101 junit,
102 env: envView,
101103 openapi,
102104 mail: eml,
103105 archive,
test/render-env.test.ts added +90
@@ -0,0 +1,90 @@
1 import { describe, expect, test } from "bun:test";
2 import { envView, looksSecret, parseEnv, ROW_CAP } from "../src/web/render/env.ts";
3 import { fileKind, isEnvExample, NO_BYTES } from "../src/web/render/kinds.ts";
4 import { rendererFor } from "../src/web/render/renderers.ts";
5
6 const draw = (text: string, path = ".env.example"): { html: string; summary: string | undefined; fallback: string | undefined } => {
7 const body = new TextEncoder().encode(text);
8 const r = envView({ path, size: body.length, body, media: "", download: "" });
9 return { html: String(r.content), summary: r.summary, fallback: r.fallback };
10 };
11
12 describe("parseEnv", () => {
13 test("quotes, export and inline comments", () => {
14 const { rows } = parseEnv(
15 ['A="hello world" # quoted', "export B='x # not a comment'", "C=plain # note", 'D="a\\"b"', "E=", "F=http://x/#frag"].join("\n"),
16 );
17 expect(rows.map((r) => [r.name, r.value, r.comment])).toEqual([
18 ["A", "hello world", "quoted"],
19 ["B", "x # not a comment", ""],
20 ["C", "plain", "note"],
21 ["D", 'a"b', ""],
22 ["E", "", ""],
23 ["F", "http://x/#frag", ""],
24 ]);
25 });
26 test("comment above a line, and blank-separated groups", () => {
27 const { rows } = parseEnv("# Database\n\n# The url\n# of it\nDB=pg\n\n# Mail\n\nSMTP=x\n");
28 expect(rows[0]).toMatchObject({ name: "DB", comment: "The url of it", group: "Database" });
29 expect(rows[1]).toMatchObject({ name: "SMTP", group: "Mail" });
30 });
31 });
32
33 describe("masking", () => {
34 test("by name, even in an example", () => {
35 expect(looksSecret("API_KEY")).toBe(true);
36 expect(looksSecret("CLIENT_SECRET")).toBe(true);
37 expect(looksSecret("GH_TOKEN")).toBe(true);
38 expect(looksSecret("DB_PASSWORD_HASH")).toBe(true);
39 expect(looksSecret("PORT")).toBe(false);
40 const out = draw("API_KEY=sk-live-123\nDB_PASSWORD=hunter2\nPORT=3000\nEMPTY_TOKEN=\n").html;
41 expect(out).toContain("•••");
42 expect(out).not.toContain("sk-live-123");
43 expect(out).not.toContain("hunter2");
44 expect(out).toContain("3000");
45 });
46 });
47
48 describe("drawing", () => {
49 test("empty cells are a dash; groups are headings", () => {
50 const out = draw("# Web\n\nPORT=\n").html;
51 expect(out).toContain('<th scope="rowgroup" colspan="3">Web</th>');
52 expect(out).toContain("<td>—</td><td>—</td>");
53 });
54 test("every cell is escaped", () => {
55 const out = draw('# <b>x</b>\n\nA="<script>alert(1)</script>" # <img src=x onerror=1>\n').html;
56 expect(out).not.toContain("<script>");
57 expect(out).not.toContain("<img");
58 expect(out).not.toContain("<b>");
59 expect(out).toContain("&lt;script&gt;");
60 });
61 test("the cap says how many are left", () => {
62 const text = Array.from({ length: ROW_CAP + 7 }, (_, i) => `V${i}=1`).join("\n");
63 const r = draw(text);
64 expect(r.html).toContain("7 more not shown");
65 expect(r.html).not.toContain("V510");
66 expect(r.summary).toContain("507 variables");
67 });
68 test("a file with no variables falls back to source", () => {
69 expect(draw("# nothing\n").fallback).toBeDefined();
70 });
71 });
72
73 describe("which files", () => {
74 test("only the three example names", () => {
75 for (const n of [".env.example", ".env.sample", ".env.template", "app/.env.example"]) {
76 expect(isEnvExample(n)).toBe(true);
77 expect(fileKind(n, NO_BYTES)).toBe("env");
78 }
79 for (const n of [".env", ".env.local", "env.example", ".env.production", "a.env.example"]) {
80 expect(isEnvExample(n)).toBe(false);
81 expect(fileKind(n, NO_BYTES)).not.toBe("env");
82 }
83 });
84 test("a plain .env keeps the source view; an example gets a renderer", () => {
85 const body = new TextEncoder().encode("A=1\n");
86 const blob = { kind: "text", body, size: body.length } as never;
87 expect(rendererFor(".env", blob)).toBeUndefined();
88 expect(rendererFor(".env.example", blob)).toBeDefined();
89 });
90 });
test/tui-workspace.test.ts +19
@@ -635,6 +635,25 @@ describe("the terminal workspace", () => {
635635 }
636636 });
637637
638 test("a .env.example opens as a table with secrets masked, and s flips to the source", async () => {
639 const made = await createRepo(forge, alice, { name: "envrepo", visibility: "public", description: "tests" });
640 if (!made.ok) throw new Error("repo failed");
641 await commitToBranch(forge, alice, made.value, { message: "seed", files: [{ path: ".env.example", content: "# Port to listen on\nPORT=3000\nAPI_KEY=sk-live-abc\n" }] });
642 const h = boot();
643 try {
644 await h.settle();
645 await open(h, "envrepo");
646 await openFile(h, ".env.example");
647 expect(h.text()).toContain("Port to listen on");
648 expect(h.text()).toContain("•••");
649 expect(h.text()).not.toContain("sk-live-abc");
650 await h.press("s");
651 expect(h.text()).toContain("API_KEY=sk-live-abc");
652 } finally {
653 h.stop();
654 }
655 });
656
638657 test("a JUnit-named file that is not a report opens as source and says why", async () => {
639658 const made = await createRepo(forge, alice, { name: "badjunit", visibility: "public", description: "tests" });
640659 if (!made.ok) throw new Error("repo failed");

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs