Public: anyone can read this; the crew can change it.
The flows view shows every line in and out: dependencies with advisories, origins, followed sources, forks, followers, mirrors and forges that follow #9603
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/9590-the-flows-view-shows-every-line-in-and-o → main 4 commits fork 1 thread · 1 unresolved
9 files
changed +216
−27
docs/features/flows.md +5 −1
| @@ -14,4 +14,8 @@ | ||
| 14 | 14 | - **Terminal.** The Flows screen shows the same lines with health tones and the same gates: `c` compares heads (admin), `p` pulls in what's new (writer, `takeFromOrigin`), `o` names the page that offers a pull to the parent. A guest sees only what they may read. |
| 15 | 15 | - **Actions.** A writer of a mirror with news sees *Pull in what's new* (the existing `sync/take`, which refuses a diverged mirror); a writer of a fork sees *Offer what's useful* (the existing pull-to-parent page). |
| 16 | 16 | |
| 17 | Left out: the CHANGELOG sections between pin and newest (dropped until a lookup can read one within a byte bound); a schedule and the issue the crew files from a report; advisories and code scanning (and the release block on a new high finding); per-line buttons (actions are listed under the columns); counting a fork's commits ahead of its parent; GitHub release tags; the all-repositories admin view; "last moved" for forks. Each is its own pull on top of `dep_latest` and `readFlows`. | |
| 17 | - **Advisories.** The same *Read upstream releases now* also asks the npm registry's bulk advisory endpoint about each exact pin and stores a one-line answer (severity, title, `(+N more)` when there are several) in `dep_latest.advisory`. A package with one shows an *advisory* badge and the text on the page and in the terminal (`depState`, `src/flows/radar.ts`, words both). They refresh only on that button: the page never calls the registry. The advisory ask is a second request per package inside the same 30 s run deadline. | |
| 18 | - **Forks.** Each fork out reads *fork, takes updates* or *fork, told of updates* (its `fork_follow` mode), and its last-moved time is its newest push (its creation until it has one). A fork following its parent is the only "follower" the forge records; there is no table of people following a repository. | |
| 19 | - **Forges that follow.** A child forge holding a proposal token for the repository is a *forge follows* line (its origin without userinfo or query; last use of the token, else when it was minted; *token ended* once expired). Only people who can write to the repository see these lines, page and terminal alike. | |
| 20 | ||
| 21 | Left out: the CHANGELOG sections between pin and newest (dropped until a lookup can read one within a byte bound); a schedule and the issue the crew files from a report; code scanning (and the release block on a new high finding); per-line buttons (actions are listed under the columns); counting a fork's commits ahead of its parent; GitHub release tags; the all-repositories admin view. Each is its own pull on top of `dep_latest` and `readFlows`. |
src/core/migration-pins/20261108160000-dep-advisory.pin added +1
| @@ -0,0 +1,1 @@ | ||
| 1 | 7eed33d02cd876fd1a5ce737a30bf8284931f122025d142fca415416a5c9b0cf |
src/core/migrations/20261108160000-dep-advisory.ts added +6
| @@ -0,0 +1,6 @@ | ||
| 1 | // A known security advisory for the version a repository pins (#9590): the | |
| 2 | // radar's last read keeps the advisory's one-line summary beside the newest | |
| 3 | // release, "" when none is known. Additive. | |
| 4 | export default ` | |
| 5 | ALTER TABLE dep_latest ADD COLUMN advisory TEXT NOT NULL DEFAULT ''; | |
| 6 | `; |
src/flows/radar.ts +46 −12
| @@ -12,10 +12,12 @@ import { gapOf, parseSemver, type Gap } from "./semver.ts"; | ||
| 12 | 12 | |
| 13 | 13 | export interface Upstream { |
| 14 | 14 | readonly latest: string; |
| 15 | /** One line about a known advisory for the pinned version, or absent. */ | |
| 16 | readonly advisory?: string; | |
| 15 | 17 | } |
| 16 | 18 | |
| 17 | 19 | /** Asks upstream about one package: its newest version, or why it could not say. */ |
| 18 | export type Lookup = (name: string) => Promise<Upstream | string>; | |
| 20 | export type Lookup = (name: string, version: string) => Promise<Upstream | string>; | |
| 19 | 21 | |
| 20 | 22 | export interface DepLine { |
| 21 | 23 | readonly name: string; |
| @@ -24,6 +26,7 @@ export interface DepLine { | ||
| 24 | 26 | readonly latest: string; |
| 25 | 27 | readonly gap: Gap; |
| 26 | 28 | readonly dev: boolean; |
| 29 | readonly advisory: string; | |
| 27 | 30 | readonly error: string; |
| 28 | 31 | readonly checkedAt: string | null; |
| 29 | 32 | } |
| @@ -34,7 +37,7 @@ export const CONCURRENCY = 4; | ||
| 34 | 37 | export const RUN_DEADLINE_MS = 30_000; |
| 35 | 38 | |
| 36 | 39 | /** The npm registry's answer for one package; the name is validated before it is put in a URL. */ |
| 37 | export const npmLookup: Lookup = async (name) => { | |
| 40 | export const npmLookup: Lookup = async (name, version) => { | |
| 38 | 41 | if (!NPM_NAME.test(name)) return "not a package name"; |
| 39 | 42 | try { |
| 40 | 43 | const res = await fetch(`https://registry.npmjs.org/${name.replace("/", "%2F")}/latest`, { |
| @@ -43,13 +46,36 @@ export const npmLookup: Lookup = async (name) => { | ||
| 43 | 46 | }); |
| 44 | 47 | if (!res.ok) return `registry answered ${res.status}`; |
| 45 | 48 | const body: unknown = await res.json(); |
| 46 | const version = typeof body === "object" && body !== null ? (body as { version?: unknown }).version : undefined; | |
| 47 | return typeof version === "string" && parseSemver(version) ? { latest: version } : "no version in the answer"; | |
| 49 | const latest = typeof body === "object" && body !== null ? (body as { version?: unknown }).version : undefined; | |
| 50 | if (typeof latest !== "string" || !parseSemver(latest)) return "no version in the answer"; | |
| 51 | return { latest, advisory: await npmAdvisory(name, version) }; | |
| 48 | 52 | } catch { |
| 49 | 53 | return "registry not reachable"; |
| 50 | 54 | } |
| 51 | 55 | }; |
| 52 | 56 | |
| 57 | /** The registry's advisory for one package at one version: its first title, or "" (none, no exact version, or not reachable). */ | |
| 58 | async function npmAdvisory(name: string, version: string): Promise<string> { | |
| 59 | if (version === "" || !NPM_NAME.test(name)) return ""; | |
| 60 | try { | |
| 61 | const res = await fetch("https://registry.npmjs.org/-/npm/v1/security/advisories/bulk", { | |
| 62 | method: "POST", | |
| 63 | signal: AbortSignal.timeout(5000), | |
| 64 | headers: { accept: "application/json", "content-type": "application/json" }, | |
| 65 | body: JSON.stringify({ [name]: [version] }), | |
| 66 | }); | |
| 67 | if (!res.ok) return ""; | |
| 68 | const body: unknown = await res.json(); | |
| 69 | const list = typeof body === "object" && body !== null ? (body as Record<string, unknown>)[name] : undefined; | |
| 70 | const first = Array.isArray(list) ? (list[0] as { title?: unknown; severity?: unknown } | undefined) : undefined; | |
| 71 | if (typeof first?.title !== "string") return ""; | |
| 72 | const more = (list as unknown[]).length - 1; | |
| 73 | return `${typeof first.severity === "string" ? `${first.severity.slice(0, 20)}: ` : ""}${first.title.slice(0, 130)}${more > 0 ? ` (+${more} more)` : ""}`; | |
| 74 | } catch { | |
| 75 | return ""; | |
| 76 | } | |
| 77 | } | |
| 78 | ||
| 53 | 79 | /** |
| 54 | 80 | * Read upstream for every exact pin and store the answers, a few at a time and |
| 55 | 81 | * within a total deadline; a pin not reached keeps its last row. Returns how |
| @@ -64,9 +90,9 @@ export async function refreshRadar( | ||
| 64 | 90 | deadlineMs = RUN_DEADLINE_MS, |
| 65 | 91 | ): Promise<number> { |
| 66 | 92 | const put = db.query( |
| 67 | `INSERT INTO dep_latest (repo_id, name, latest, error, checked_at) VALUES (?1, ?2, ?3, ?4, ?5) | |
| 93 | `INSERT INTO dep_latest (repo_id, name, latest, error, checked_at, advisory) VALUES (?1, ?2, ?3, ?4, ?5, ?6) | |
| 68 | 94 | ON CONFLICT (repo_id, name) DO UPDATE SET latest = excluded.latest, |
| 69 | error = excluded.error, checked_at = excluded.checked_at`, | |
| 95 | error = excluded.error, checked_at = excluded.checked_at, advisory = excluded.advisory`, | |
| 70 | 96 | ); |
| 71 | 97 | const todo = pins.slice(0, MAX_PINS).filter((p) => p.version !== ""); |
| 72 | 98 | const stop = Date.now() + deadlineMs; |
| @@ -75,10 +101,10 @@ export async function refreshRadar( | ||
| 75 | 101 | const worker = async (): Promise<void> => { |
| 76 | 102 | while (next < todo.length && Date.now() < stop) { |
| 77 | 103 | const pin = todo[next++]!; |
| 78 | const answer = await lookup(pin.name); | |
| 104 | const answer = await lookup(pin.name, pin.version); | |
| 79 | 105 | const at = now.toISOString(); |
| 80 | if (typeof answer === "string") put.run(repoId, pin.name, "", answer.slice(0, 200), at); | |
| 81 | else put.run(repoId, pin.name, answer.latest, "", at); | |
| 106 | if (typeof answer === "string") put.run(repoId, pin.name, "", answer.slice(0, 200), at, ""); | |
| 107 | else put.run(repoId, pin.name, answer.latest, "", at, (answer.advisory ?? "").slice(0, 200)); | |
| 82 | 108 | written++; |
| 83 | 109 | } |
| 84 | 110 | }; |
| @@ -86,12 +112,12 @@ export async function refreshRadar( | ||
| 86 | 112 | return written; |
| 87 | 113 | } |
| 88 | 114 | |
| 89 | interface Row { name: string; latest: string; error: string; checked_at: string } | |
| 115 | interface Row { name: string; latest: string; error: string; checked_at: string; advisory: string } | |
| 90 | 116 | |
| 91 | 117 | /** Each pin with what the radar last read, outdated first. */ |
| 92 | 118 | export function readRadar(db: Database, repoId: string, pins: readonly Pin[]): DepLine[] { |
| 93 | 119 | const rows = new Map( |
| 94 | db.query<Row, [string]>("SELECT name, latest, error, checked_at FROM dep_latest WHERE repo_id = ?1").all(repoId).map((r) => [r.name, r]), | |
| 120 | db.query<Row, [string]>("SELECT name, latest, error, checked_at, advisory FROM dep_latest WHERE repo_id = ?1").all(repoId).map((r) => [r.name, r]), | |
| 95 | 121 | ); |
| 96 | 122 | const rank: Record<Gap, number> = { major: 0, minor: 1, patch: 2, none: 3 }; |
| 97 | 123 | return pins |
| @@ -101,8 +127,16 @@ export function readRadar(db: Database, repoId: string, pins: readonly Pin[]): D | ||
| 101 | 127 | const b = parseSemver(r?.latest ?? ""); |
| 102 | 128 | return { |
| 103 | 129 | name: p.name, range: p.range, pinned: p.version, latest: r?.latest ?? "", dev: p.dev, |
| 104 | gap: a && b ? gapOf(a, b) : "none", error: r?.error ?? "", checkedAt: r?.checked_at ?? null, | |
| 130 | gap: a && b ? gapOf(a, b) : "none", advisory: r?.advisory ?? "", error: r?.error ?? "", checkedAt: r?.checked_at ?? null, | |
| 105 | 131 | }; |
| 106 | 132 | }) |
| 107 | 133 | .sort((x, y) => rank[x.gap] - rank[y.gap] || x.name.localeCompare(y.name)); |
| 108 | 134 | } |
| 135 | ||
| 136 | /** How a package stands, worded once for the page and the terminal: an advisory outranks a read error, which outranks the gap. */ | |
| 137 | export function depState(d: DepLine): { label: string; tone: "danger" | "attn" | "default" } { | |
| 138 | if (d.advisory !== "") return { label: "advisory", tone: "danger" }; | |
| 139 | if (d.error !== "") return { label: "not read", tone: "danger" }; | |
| 140 | if (d.gap === "none") return { label: d.checkedAt === null ? "not read yet" : "current", tone: "default" }; | |
| 141 | return { label: `${d.gap} update`, tone: d.gap === "major" ? "danger" : "attn" }; | |
| 142 | } |
src/flows/view.ts +27 −4
| @@ -7,7 +7,8 @@ | ||
| 7 | 7 | |
| 8 | 8 | import type { Database } from "bun:sqlite"; |
| 9 | 9 | import { getCursor } from "../core/origins.ts"; |
| 10 | import { canRead, canWrite, getRepoById, visibleForks, type Repo } from "../core/repos.ts"; | |
| 10 | import { forkFollowMode } from "../core/fork_follow.ts"; | |
| 11 | import { canRead, canWrite, getRepoById, repoDatesFor, visibleForks, type Repo } from "../core/repos.ts"; | |
| 11 | 12 | import type { User } from "../core/users.ts"; |
| 12 | 13 | import { aheadWords, parseAhead } from "../origins/ahead.ts"; |
| 13 | 14 | |
| @@ -25,7 +26,7 @@ export interface FlowAction { | ||
| 25 | 26 | } |
| 26 | 27 | |
| 27 | 28 | export interface FlowLine { |
| 28 | readonly kind: "origin" | "source" | "upstream" | "fork" | "mirror"; | |
| 29 | readonly kind: "origin" | "source" | "upstream" | "fork" | "mirror" | "forge"; | |
| 29 | 30 | readonly label: string; |
| 30 | 31 | /** In-forge link, when the line is a repository here. */ |
| 31 | 32 | readonly href: string; |
| @@ -123,8 +124,15 @@ export function readFlows(db: Database, repo: Repo, viewer: User | undefined, no | ||
| 123 | 124 | ...(writer && health === "behind" ? { action: { label: "Pull in what's new", href: `${base(repo)}/sync/take`, post: true } } : {}), |
| 124 | 125 | }); |
| 125 | 126 | } |
| 126 | for (const f of visibleForks(db, repo.id, viewer)) { | |
| 127 | outbound.push({ kind: "fork", label: `${f.ownerName}/${f.name}`, href: base(f), state: "fork", at: null, problem: false, health: "none" }); | |
| 127 | const forks = visibleForks(db, repo.id, viewer); | |
| 128 | const dates = repoDatesFor(db, forks.map((f) => f.id)); | |
| 129 | for (const f of forks) { | |
| 130 | const d = dates.get(f.id); | |
| 131 | const mode = forkFollowMode(db, f.id); | |
| 132 | outbound.push({ | |
| 133 | kind: "fork", label: `${f.ownerName}/${f.name}`, href: base(f), at: d?.pushedAt ?? d?.createdAt ?? null, problem: false, health: "none", | |
| 134 | state: mode === "take-updates" ? "fork, takes updates" : "fork, told of updates", | |
| 135 | }); | |
| 128 | 136 | } |
| 129 | 137 | const mirror = db |
| 130 | 138 | .query<{ full_name: string; pushed_at: string | null; last_error: string }, [string]>( |
| @@ -138,5 +146,20 @@ export function readFlows(db: Database, repo: Repo, viewer: User | undefined, no | ||
| 138 | 146 | health: mirror.last_error !== "" ? "unread" : "ok", |
| 139 | 147 | }); |
| 140 | 148 | } |
| 149 | // Forges that follow this one: a child forge holds a proposal token for it. Their names are for those who write here. | |
| 150 | if (writer) { | |
| 151 | for (const c of db | |
| 152 | .query<{ child: string; origin: string; created_at: string; last_used_at: string | null; expires_at: string | null }, [string]>( | |
| 153 | `SELECT p.child, p.origin, t.created_at, t.last_used_at, t.expires_at | |
| 154 | FROM proposal_token p JOIN token t ON t.id = p.token_id WHERE p.repo_id = ?1 ORDER BY t.created_at LIMIT 50`, | |
| 155 | ) | |
| 156 | .all(repo.id)) { | |
| 157 | const ended = c.expires_at !== null && Date.parse(c.expires_at) <= now; | |
| 158 | outbound.push({ | |
| 159 | kind: "forge", label: `${c.child} (${plainUrl(c.origin)})`, href: "", at: c.last_used_at ?? c.created_at, problem: false, | |
| 160 | health: ended ? "stale" : "none", state: ended ? "forge follows, token ended" : "forge follows", | |
| 161 | }); | |
| 162 | } | |
| 163 | } | |
| 141 | 164 | return { inbound, outbound }; |
| 142 | 165 | } |
src/tui/screens/flows.ts +4 −4
| @@ -7,7 +7,7 @@ | ||
| 7 | 7 | |
| 8 | 8 | import { takeFromOrigin } from "../../actions/origins.ts"; |
| 9 | 9 | import { canRead, type Repo } from "../../core/repos.ts"; |
| 10 | import { readRadar, type DepLine } from "../../flows/radar.ts"; | |
| 10 | import { depState, readRadar, type DepLine } from "../../flows/radar.ts"; | |
| 11 | 11 | import { pinsOf } from "../../flows/run.ts"; |
| 12 | 12 | import { probeLinks } from "../../flows/probe.ts"; |
| 13 | 13 | import { mayStartRadar, startRadar } from "../../flows/start.ts"; |
| @@ -28,9 +28,9 @@ const flowRow = (side: string) => (l: FlowLine): ListRow => ({ | ||
| 28 | 28 | |
| 29 | 29 | const depRow = (d: DepLine): ListRow => ({ |
| 30 | 30 | title: `pkg ${d.name}`, |
| 31 | secondary: d.error !== "" ? "not read" : d.gap === "none" ? (d.checkedAt === null ? "not read yet" : "current") : `${d.gap} update`, | |
| 32 | ...(d.error !== "" || d.gap === "major" ? { tone: "danger" as const } : {}), | |
| 33 | meta: `${d.pinned === "" ? d.range : `pinned ${d.pinned}`}${d.latest === "" ? "" : ` · newest ${d.latest}`}`, | |
| 31 | secondary: depState(d).label, | |
| 32 | ...(depState(d).tone === "default" ? {} : { tone: depState(d).tone }), | |
| 33 | meta: `${d.pinned === "" ? d.range : `pinned ${d.pinned}`}${d.latest === "" ? "" : ` · newest ${d.latest}`}${d.advisory === "" ? "" : ` · advisory: ${d.advisory}`}`, | |
| 34 | 34 | }); |
| 35 | 35 | |
| 36 | 36 | export function flowsScreen(f: Session, ctx: Ctx, repo: Repo): Screen { |
src/web/pages/flows.ts +4 −6
| @@ -8,7 +8,7 @@ | ||
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | 10 | import { notePhase, takeSessionTime } from "../../core/route_timing.ts"; |
| 11 | import { readRadar } from "../../flows/radar.ts"; | |
| 11 | import { depState, readRadar } from "../../flows/radar.ts"; | |
| 12 | 12 | import { pinsOf } from "../../flows/run.ts"; |
| 13 | 13 | import { probeLinks } from "../../flows/probe.ts"; |
| 14 | 14 | import { mayStartRadar, startRadar } from "../../flows/start.ts"; |
| @@ -55,10 +55,8 @@ export async function flowsPage(ctx: Ctx): Promise<Response> { | ||
| 55 | 55 | const depRows = deps.map((d) => |
| 56 | 56 | row({ |
| 57 | 57 | title: html`${d.name}`, |
| 58 | meta: html`${d.pinned === "" ? d.range : `pinned ${d.pinned}`}${d.latest === "" ? "" : ` · newest ${d.latest}`}${d.checkedAt === null ? "" : html` · read ${time(d.checkedAt)}`}`, | |
| 59 | trail: badge(d.error !== "" ? "not read" : d.gap === "none" ? (d.checkedAt === null ? "not read yet" : "current") : `${d.gap} update`, { | |
| 60 | tone: d.error !== "" || d.gap === "major" ? "danger" : d.gap === "none" ? "default" : "attn", | |
| 61 | }), | |
| 58 | meta: html`${d.pinned === "" ? d.range : `pinned ${d.pinned}`}${d.latest === "" ? "" : ` · newest ${d.latest}`}${d.checkedAt === null ? "" : html` · read ${time(d.checkedAt)}`}${d.advisory === "" ? "" : html` · advisory: ${d.advisory}`}`, | |
| 59 | trail: badge(depState(d).label, { tone: depState(d).tone }), | |
| 62 | 60 | }), |
| 63 | 61 | ); |
| 64 | 62 | const body = html`${repoHeader(ctx, repo, "flows")} |
| @@ -69,7 +67,7 @@ export async function flowsPage(ctx: Ctx): Promise<Response> { | ||
| 69 | 67 | ${actions} |
| 70 | 68 | ${side("Packages", depRows, "No pinned packages.")} |
| 71 | 69 | </div> |
| 72 | <div>${side("Flows out", flows.outbound.map(lineRow), "Nothing flows out: no forks and no mirror.")}</div> | |
| 70 | <div>${side("Flows out", flows.outbound.map(lineRow), "Nothing flows out: no forks, no mirror and no forge follows.")}</div> | |
| 73 | 71 | </div> |
| 74 | 72 | ${admin |
| 75 | 73 | ? html`<form method="post" action="${repoBase(repo)}/flows/probe" class="mt">${btn("Compare heads now", { variant: "secondary" })}</form> |
test/flows-lines.test.ts added +95
| @@ -0,0 +1,95 @@ | ||
| 1 | /** Flows (#9590): one line of every kind in and out is on the page, read from stored state, fast. */ | |
| 2 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 3 | import { createHmac } from "node:crypto"; | |
| 4 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 5 | import { tmpdir } from "node:os"; | |
| 6 | import { join } from "node:path"; | |
| 7 | import { createApp, type App } from "../src/app.ts"; | |
| 8 | import { createRepo } from "../src/actions/repos.ts"; | |
| 9 | import { getRepo } from "../src/core/repos.ts"; | |
| 10 | import { getUserById } from "../src/core/users.ts"; | |
| 11 | import { setForkFollowMode } from "../src/core/fork_follow.ts"; | |
| 12 | import { commitFiles } from "../src/git/write.ts"; | |
| 13 | import { repoDir } from "../src/git/repos.ts"; | |
| 14 | import { refreshRadar, readRadar } from "../src/flows/radar.ts"; | |
| 15 | import { readPins } from "../src/flows/pins.ts"; | |
| 16 | import { readFlows } from "../src/flows/view.ts"; | |
| 17 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 18 | import { PREVIEW_ADMIN, PREVIEW_REPO } from "../src/spawn/preview-looks.ts"; | |
| 19 | import { expectWork, requestStatements } from "./helpers/work.ts"; | |
| 20 | import { seedPreview } from "../src/spawn/preview-seed.ts"; | |
| 21 | ||
| 22 | const KEY = "flows-lines-key"; | |
| 23 | let dir: string; | |
| 24 | let app: App; | |
| 25 | let servers: Servers; | |
| 26 | const headers = () => { | |
| 27 | const said = `u1|trevato|admin|${Date.now() + 60_000}`; | |
| 28 | return { "x-forge-identity": said, "x-forge-signature": createHmac("sha256", KEY).update(said).digest("base64url") }; | |
| 29 | }; | |
| 30 | const url = () => `${servers.url}/${PREVIEW_ADMIN}/${PREVIEW_REPO}/flows`; | |
| 31 | ||
| 32 | beforeAll(async () => { | |
| 33 | dir = await mkdtemp(join(tmpdir(), "forge-flows-lines-")); | |
| 34 | app = createApp({ dataDir: dir, port: 0, previewIdentityKey: KEY }); | |
| 35 | const seeded = await seedPreview(app, { FORGE_APP_ENV: "preview" }); | |
| 36 | if (!seeded.ok) throw new Error(seeded.error); | |
| 37 | servers = await serve(app); | |
| 38 | const repo = getRepo(app.db, PREVIEW_ADMIN, PREVIEW_REPO)!; | |
| 39 | const pkg = JSON.stringify({ dependencies: { leftish: "1.0.0" } }); | |
| 40 | const c = await commitFiles(repoDir(app.reposRoot, repo.ownerName, repo.name), { | |
| 41 | branch: repo.defaultBranch, message: "pins", files: [{ path: "package.json", content: pkg }], author: { name: "T", email: "[email protected]" }, | |
| 42 | }); | |
| 43 | if (!c.ok) throw new Error(JSON.stringify(c.error)); | |
| 44 | await refreshRadar(app.db, repo.id, readPins(pkg), async () => ({ latest: "1.4.0", advisory: "high: prototype <script>pollution" })); | |
| 45 | app.db.query("INSERT INTO repo_source (id, repo_id, url, branch, by_id, created_at, checked_at) VALUES ('s1', ?1, 'https://src.example/x.git', 'main', 'u1', '2026-11-01T00:00:00Z', '2026-11-02T00:00:00Z')").run(repo.id); | |
| 46 | app.db.exec("PRAGMA foreign_keys = OFF"); | |
| 47 | app.db.query("INSERT INTO migrate_mirror (repo_id, origin_id, full_name, clone_url, pushed_at) VALUES (?1, 'o1', 'octo/mirrored-on-github', 'https://github.com/octo/m.git', '2026-11-03T00:00:00Z')").run(repo.id); | |
| 48 | const admin = getUserById(app.db, app.db.query<{ id: string }, [string]>("SELECT id FROM user WHERE username = ?").get(PREVIEW_ADMIN)!.id)!; | |
| 49 | const kid = await createRepo(app, admin, { name: "kid-fork", owner: PREVIEW_ADMIN, visibility: "public", description: "" }); | |
| 50 | if (!kid.ok) throw new Error(JSON.stringify(kid.error)); | |
| 51 | app.db.query("UPDATE repo SET fork_parent_id = ?1 WHERE id = ?2").run(repo.id, kid.value.id); | |
| 52 | setForkFollowMode(app.db, kid.value.id, "take-updates"); | |
| 53 | app.db.query("INSERT INTO token (id, token_hash, user_id, name, scope, created_at) VALUES ('t1', 'h1', ?1, 'proposal:child-forge', 'rw', '2026-11-04T00:00:00Z')").run(admin.id); | |
| 54 | app.db.query("INSERT INTO proposal_token (token_id, repo_id, child, origin) VALUES ('t1', ?1, 'child-forge', 'https://child.example')").run(repo.id); | |
| 55 | app.db.exec("PRAGMA foreign_keys = ON"); | |
| 56 | }, 30_000); | |
| 57 | afterAll(async () => { | |
| 58 | servers.stop(); | |
| 59 | app.close(); | |
| 60 | await rm(dir, { recursive: true, force: true }); | |
| 61 | }); | |
| 62 | ||
| 63 | describe("every line in and out", () => { | |
| 64 | test("the radar keeps an advisory with the newest release", () => { | |
| 65 | const repo = getRepo(app.db, PREVIEW_ADMIN, PREVIEW_REPO)!; | |
| 66 | const [dep] = readRadar(app.db, repo.id, readPins(JSON.stringify({ dependencies: { leftish: "1.0.0" } }))); | |
| 67 | expect(dep).toMatchObject({ latest: "1.4.0", advisory: "high: prototype <script>pollution" }); | |
| 68 | }); | |
| 69 | ||
| 70 | test("the view names a forge that follows to a writer only, with when it last moved", () => { | |
| 71 | const repo = getRepo(app.db, PREVIEW_ADMIN, PREVIEW_REPO)!; | |
| 72 | const admin = getUserById(app.db, app.db.query<{ id: string }, [string]>("SELECT id FROM user WHERE username = ?").get(PREVIEW_ADMIN)!.id)!; | |
| 73 | const mine = readFlows(app.db, repo, admin).outbound.find((l) => l.kind === "forge")!; | |
| 74 | expect(mine).toMatchObject({ label: "child-forge (https://child.example/)", at: "2026-11-04T00:00:00Z" }); | |
| 75 | expect(readFlows(app.db, repo, undefined).outbound.some((l) => l.kind === "forge")).toBe(false); | |
| 76 | const fork = readFlows(app.db, repo, undefined).outbound.find((l) => l.label.endsWith("/kid-fork"))!; | |
| 77 | expect(fork.state).toBe("fork, takes updates"); | |
| 78 | expect(fork.at).not.toBeNull(); | |
| 79 | }); | |
| 80 | ||
| 81 | test("the page shows one of each kind, escaped, and costs a bounded number of reads", async () => { | |
| 82 | await (await fetch(url(), { headers: headers() })).text(); | |
| 83 | const res = await fetch(url(), { headers: headers() }); | |
| 84 | const text = await res.text(); | |
| 85 | const cost = await requestStatements(app.db, url(), { headers: headers() }); | |
| 86 | expect(res.status).toBe(200); | |
| 87 | for (const want of [ | |
| 88 | "leftish", "newest 1.4.0", "advisory: high: prototype", "src.example/x.git", "octo/mirrored-on-github", | |
| 89 | `${PREVIEW_ADMIN}/kid-fork`, "fork, takes updates", "child-forge", "forge follows", | |
| 90 | ]) expect(text).toContain(want); | |
| 91 | expect(text).not.toContain("<script>pollution"); | |
| 92 | // Stored state only: the page chrome plus a few reads (50 measured), no per-line query, no network. | |
| 93 | expectWork(cost, { max: 55 }); | |
| 94 | }); | |
| 95 | }); |
test/flows-tui.test.ts +28
| @@ -5,6 +5,10 @@ import { tmpdir } from "node:os"; | ||
| 5 | 5 | import { join } from "node:path"; |
| 6 | 6 | import { createRepo } from "../src/actions/repos.ts"; |
| 7 | 7 | import { createApp, type App as Forge } from "../src/app.ts"; |
| 8 | import { commitFiles } from "../src/git/write.ts"; | |
| 9 | import { repoDir } from "../src/git/repos.ts"; | |
| 10 | import { refreshRadar } from "../src/flows/radar.ts"; | |
| 11 | import { readPins } from "../src/flows/pins.ts"; | |
| 8 | 12 | import type { Repo } from "../src/core/repos.ts"; |
| 9 | 13 | import { createUser, type User } from "../src/core/users.ts"; |
| 10 | 14 | import { createForgeApp } from "../src/tui/forge.ts"; |
| @@ -58,6 +62,10 @@ describe("flows in the terminal", () => { | ||
| 58 | 62 | if (!made.ok) throw new Error(JSON.stringify(made.error)); |
| 59 | 63 | repo = made.value; |
| 60 | 64 | forge.db.query("INSERT INTO repo_source (id, repo_id, url, branch, by_id, created_at) VALUES ('s1', ?1, 'https://u:[email protected]/x.git', 'main', ?2, '2026-11-01T00:00:00Z')").run(repo.id, ada.id); |
| 65 | const pkg = JSON.stringify({ dependencies: { leftish: "1.0.0" } }); | |
| 66 | const c = await commitFiles(repoDir(forge.reposRoot, "ada", "arm"), { branch: repo.defaultBranch, message: "pins", files: [{ path: "package.json", content: pkg }], author: { name: "T", email: "[email protected]" } }); | |
| 67 | if (!c.ok) throw new Error(JSON.stringify(c.error)); | |
| 68 | await refreshRadar(forge.db, repo.id, readPins(pkg), async () => ({ latest: "1.4.0", advisory: "high: prototype pollution (+1 more)" })); | |
| 61 | 69 | }, 60_000); |
| 62 | 70 | afterAll(async () => { |
| 63 | 71 | forge?.close(); |
| @@ -78,6 +86,26 @@ describe("flows in the terminal", () => { | ||
| 78 | 86 | reader.stop(); |
| 79 | 87 | }); |
| 80 | 88 | |
| 89 | test("a package with an advisory says so, with its text; forks and following forges read as on the page", async () => { | |
| 90 | const kid = await createRepo(forge, ada, { name: "kid", owner: "ada", visibility: "public", description: "" }); | |
| 91 | if (!kid.ok) throw new Error(JSON.stringify(kid.error)); | |
| 92 | forge.db.query("UPDATE repo SET fork_parent_id = ?1 WHERE id = ?2").run(repo.id, kid.value.id); | |
| 93 | forge.db.exec("PRAGMA foreign_keys = OFF"); | |
| 94 | forge.db.query("INSERT INTO token (id, token_hash, user_id, name, scope, created_at) VALUES ('t1', 'h1', ?1, 'proposal:child-forge', 'rw', '2026-11-04T00:00:00Z')").run(ada.id); | |
| 95 | forge.db.query("INSERT INTO proposal_token (token_id, repo_id, child, origin) VALUES ('t1', ?1, 'child-forge', 'https://child.example')").run(repo.id); | |
| 96 | forge.db.exec("PRAGMA foreign_keys = ON"); | |
| 97 | const admin = boot(ada); | |
| 98 | await admin.open(); | |
| 99 | for (const want of ["leftish", "advisory", "high: prototype pollution (+1 more)", "ada/kid", "fork, told of updates", "child-forge", "forge follows"]) | |
| 100 | expect(admin.text()).toContain(want); | |
| 101 | admin.stop(); | |
| 102 | const reader = boot(eve); | |
| 103 | await reader.open(); | |
| 104 | expect(reader.text()).toContain("advisory"); | |
| 105 | expect(reader.text()).not.toContain("child-forge"); | |
| 106 | reader.stop(); | |
| 107 | }); | |
| 108 | ||
| 81 | 109 | test("compare heads is offered to an admin and not to a reader or a guest", async () => { |
| 82 | 110 | const admin = boot(ada); |
| 83 | 111 | await admin.open(); |
The terminal screen has no counterpart for this advisory text and badge. Add it to src/tui/screens/flows.ts.