| 1/3 | A home page of profiles, what happened lately, and topics | +567 | -27 | ✓ |
| 2/3 | An admin sets the home page's motd over ssh | +139 | -6 | ✓ |
| 3/3 | /login/'s steps in panels, named "forge" in lowercase | +58 | -26 | ✓ |
No comments yet: forge comment 21 adds one.
1/3 A home page of profiles, what happened lately, and topics
The home page was the shelves of every repo, which each owner's page has
too. Now it says who is here and what they have been doing, in two
columns (one on phones: profiles, recent, topics):
- a quiet line under the header, .forge/motd ("a personal forge on plain
git" without the file, nothing when it is empty);
- profiles: each owner with public repos, a letter on a colour seeded
from the name (the accent for FORGE_OWNER, new --avatar-* tokens the
same in both modes), how many repos and when they were last active;
- recent: the last eight landed reviews, pushes to a default branch,
releases and site deploys of public repos, newest first, each linking
to what it names, with "insights" at the end of its head strip;
- topics: the shelves across every public repo, in tiles. One owner's
links to that shelf on their page (/cat/#tools, which ui.ts now opens on
its tab); one several owners share to /topics/#tools, a new page of
every public repo by shelf, each naming its owner.
lib/recent.ts makes the list. Landed reviews come from the review logs;
pushes, releases and site deploys from the events log, whose push lines
now carry the default branch's new tip as head, so a push can be named
by its subject, and a push whose head is a review's tip shows once, as
that review landing. Every build redoes the home page and live.ts swaps
it in, so it is as fresh as the last push. Private repos are never read,
and owners with only private repos have no profile.| M | DESIGN.md | 6 | +- |
| M | README.md | 22 | ++++- |
| M | bin/ | 15 | ++- |
| M | lib/ | 47 | ++++++++- |
| A | lib/ | 49 | ++++++++ |
| A | lib/ | 175 | ++++++++++++++++++++++++++++++ |
| M | lib/ | 5 | +- |
| M | lib/ | 5 | +- |
| M | site/ | 2 | +- |
| M | site/ | 7 | + |
| M | site/ | 6 | +- |
| M | site/ | 95 | ++++++++++++++++- |
| A | site/ | 16 | +++ |
| A | test/ | 35 | ++++++ |
| A | test/ | 58 | ++++++++++ |
| A | test/ | 37 | ++++++ |
| M | test/ | 2 | +- |
| M | test/ | 4 | +- |
| M | test/ | 4 | +- |
| M | test/ | 4 | +- |
@@ -60,7 +60,9 @@ it starts a group: the style guide titles groups by those comments).
6060 One accent, `#3148F6`: `--link` for links (lighter in dark mode),6161 `--accent` for the current tab's bar, focus, primary fills, `--deep` on6262 hover. Meaning: `--add-*`, `--del-*`, `--pass`, `--fail`, `--warn`,63- `--status-*` fills.63+ `--status-*` fills. The home page's letter avatars are white on64+ `--avatar-1` to `--avatar-5`, seeded from the name, or the accent for65+ the forge's own owner.6466 - Type: `--text-xs` 12, `--text-code` 12.5, `--text-sm` 13.5, `--text-md`6567 15 (body), `--text-prose` 16, `--text-lg` 18, `--text-xl` 22,6668 `--text-2xl` 28. Weights `--fw-regular` 400 (body), `--fw-medium` 500@@ -101,7 +103,7 @@ Self-hosted variable woff2 in `site/assets/fonts/` (how they were cut:
101103 Words for where you are, symbols for how you look at it.102104 103105 - Tabs say where you are (`ui.tabs`): the repo's sections, the shelves on104- the index (with an "all" tab).106+ an owner's page and `/topics/` (with an "all" tab).105107 - A checkbox turns one thing on or off (`ui.checkbox`): wrap lines,106108 viewed.107109 - An icon toggle is a checkbox drawn as a symbol (`ui.iconToggle`): a@@ -1326,7 +1326,27 @@ first build takes minutes.
13261326 13271327 ## Reading pages13281328 1329-The index and each owner's page show repos as rows on shelves by category,1329+The home page has a quiet line under its header, `.forge/motd` (no file1330+is "a personal forge on plain git", an empty one no line), then three panels. **profiles**: each1331+owner with public repos, how many and when they were last active.1332+**recent**: the last eight things that happened on public repos, newest1333+first: landed reviews, pushes to a default branch, releases (a tag or1334+release pushed) and site deploys, each linking to what it names1335+(`lib/recent.ts`). Landed reviews come from the review logs; the rest1336+from `.forge/events.jsonl`, whose push lines carry the default branch's new1337+tip as `head`. A push whose head is a review's tip is that review landing,1338+and shows once, as landed. Every build redoes the home page, and open1339+copies of it swap in the new one when the build is done, so the list is1340+as fresh as the last push; its times stay relative in the browser. The1341+events log is cut and left out of backups, so after a restore the list has1342+only landed reviews until the next pushes. **topics**: the shelves (below)1343+across every public repo; one owner's links to that shelf on their page1344+(`/dami/#games`, which opens on its tab), and one that several owners1345+share to `/topics/#games`, every public repo on its shelf, each naming its1346+owner. Private repos, and owners with only private ones, are never on1347+any of it.1348+1349+Each owner's page, and `/topics/`, show repos as rows on shelves by category,13301350 newest first. A repo's category is one line in the bare repo's `category`13311351 file, next to `description` (`printf 'games\n' > /srv/git/dami/greet.git/category`).13321352 A repo without one goes on the shelf for what Jev says it is (a web app, a@@ -1020,7 +1020,7 @@ load_config() {
10201020 10211021 valid_name() {10221022 case $1 in1023- '' | .* | *[!A-Za-z0-9._-]* | *.git | assets | sites | previews | hosts | insights | design | git-shell-commands) return 1 ;;1023+ '' | .* | *[!A-Za-z0-9._-]* | *.git | assets | sites | previews | hosts | insights | design | topics | git-shell-commands) return 1 ;;10241024 esac10251025 }10261026 @@ -2636,13 +2636,13 @@ noreview
26362636 DEFAULT=$first26372637 fi26382638 fi2639- PUSHED= rel=02639+ PUSHED= rel=0 head=26402640 while read -r old new ref; do26412641 case $ref in refs/heads/* | refs/tags/* | refs/sites/* | refs/releases/*) PUSHED="$PUSHED $ref" ;; esac26422642 case $ref in refs/tags/* | refs/releases/*) rel=1 ;; esac26432643 [ "$new" != "$ZERO" ] || case $ref in refs/heads/*) drop_previews "${ref#refs/heads/}" ;; esac26442644 case $ref in2645- "refs/heads/$DEFAULT") ;;2645+ "refs/heads/$DEFAULT") [ "$new" = "$ZERO" ] || head=$new ;;26462646 refs/heads/*) [ "$noreview" = 1 ] || record_review "${ref#refs/heads/}" "$new" ;;26472647 refs/sites/*) push_site "sites/$OWNER/$NAME/${ref#refs/sites/}" "$new" ;;26482648 refs/site-previews/*)@@ -2658,7 +2658,10 @@ noreview
26582658 if [ "$rel" = 1 ]; then release_latest "$(pages_root "$GIT_DIR")"; fi26592659 git update-server-info26602660 refs=$(printf '%s' "$PUSHED" | tr -d '"\\')2661- printf '{"t":%s000,"repo":"%s","type":"push","user":"%s","refs":"%s"%s}\n' "${FORGE_NOW:-$(date +%s)}" "$OWNER/$NAME" "${USER_:-admin}" "$refs" "$(ev_private "$GIT_DIR")" >>"$FORGE_STATE/events.jsonl"2661+ # head: the default branch's new tip, for the index's recent list (lib/recent.ts).2662+ hf=2663+ [ -z "$head" ] || hf=",\"head\":\"$head\""2664+ printf '{"t":%s000,"repo":"%s","type":"push","user":"%s"%s,"refs":"%s"%s}\n' "${FORGE_NOW:-$(date +%s)}" "$OWNER/$NAME" "${USER_:-admin}" "$hf" "$refs" "$(ev_private "$GIT_DIR")" >>"$FORGE_STATE/events.jsonl"26622665 rebuild "$OWNER/$NAME" push26632666 }26642667 @@ -2695,7 +2698,7 @@ build_all() {
26952698 [ -d "$w" ] || continue26962699 slug=${w#"$FORGE_WWW"/}26972700 slug=${slug%/}2698- case $slug in assets/* | sites/* | previews/* | hosts/* | insights/* | design/*) continue ;; esac2701+ case $slug in assets/* | sites/* | previews/* | hosts/* | insights/* | design/* | topics/*) continue ;; esac26992702 [ -f "$FORGE_ROOT/$slug.git/HEAD" ] && [ -f "$FORGE_ROOT/$slug.git/git-daemon-export-ok" ] || rm -rf "$w"27002703 done27012704 for w in "$FORGE_WWW"/sites/*/*/ "$FORGE_WWW"/previews/*/*/; do@@ -2706,7 +2709,7 @@ build_all() {
27062709 done27072710 for w in "$FORGE_WWW"/*/; do27082711 b=$(basename "$w")2709- case $b in assets | sites | previews | hosts | insights | design) continue ;; esac2712+ case $b in assets | sites | previews | hosts | insights | design | topics) continue ;; esac27102713 [ -d "$FORGE_ROOT/$b" ] || rm -rf "$w"27112714 done27122715 if [ -d "$FORGE_WWW/hosts" ]; then node "$FORGE_HOME/lib/hosts.ts" prune; fi@@ -22,6 +22,8 @@ import { labelCommits, labelKey, releases, repoTopic } from './jev-commits.ts';
2222 import { annotateReviews, answersStamp, isMechanical, pairChanges } from './jev-reviews.ts';2323 import { HighlightCache } from './highlight.ts';2424 import { assets } from './assets.ts';25+import { recent, type RecentItem } from './recent.ts';26+import { motd } from './motd.ts';2527 import type { CiStatus, Commit, Config, DiffFile, Repo, RepoCounts, RepoSummary, RepoUrls, Review, ReviewComment, SiteLink } from './types.ts';2628 2729 export function config(env: NodeJS.ProcessEnv = process.env): Config {@@ -52,7 +54,7 @@ export function config(env: NodeJS.ProcessEnv = process.env): Config {
5254 const caseless = (a: string, b: string): number => a.localeCompare(b, 'en', { sensitivity: 'base' }) || (a < b ? -1 : a > b ? 1 : 0);5355 5456 // Top-level names used by the forge itself; never owners.55-const RESERVED = new Set(['assets', 'sites', 'previews', 'hosts', 'insights', 'index.html', 'git-shell-commands', 'lost+found', 'login', 'private']);57+const RESERVED = new Set(['assets', 'sites', 'previews', 'hosts', 'insights', 'topics', 'index.html', 'git-shell-commands', 'lost+found', 'login', 'private']);5658 5759 // Every repo, public or not (RepoSummary.private); builds filter.5860 export function discover(cfg: Config): { repos: RepoSummary[]; owners: string[] } {@@ -117,6 +119,12 @@ export interface Forge {
117119 config: Config;118120 repos: RepoSummary[];119121 owners: { owner: string; repos: RepoSummary[] }[];122+ // The index (site/index.njk): its line under the header (null: none),123+ // the public owners, the shelves across them, and what happened lately.124+ motd: string | null;125+ profiles: Profile[];126+ topics: Topic[];127+ recent: RecentItem[];120128 pages: Record<string, Page[]>;121129 raw: RawFile[];122130 // Incremental builds: the live site, and the pages taken from it unchanged.@@ -126,6 +134,32 @@ export interface Forge {
126134 pageCaches: { cache: RepoCache; fps: Record<string, string> }[];127135 }128136 137+export interface Profile {138+ owner: string;139+ repos: number;140+ // When they last did something public (lib/recent.ts), ISO.141+ active: string;142+ // Their letter avatar's colour: avatar-1 to avatar-5, seeded from the143+ // name, or the accent for the forge's own owner (FORGE_OWNER).144+ colour: string;145+}146+147+export interface Topic {148+ id: string;149+ title: string;150+ repos: number;151+ // One owner's: that shelf on their page; several owners': on /topics/.152+ href: string;153+}154+155+const AVATARS = 5;156+function colour(owner: string, env: NodeJS.ProcessEnv): string {157+ if (owner === env.FORGE_OWNER) return 'accent';158+ let h = 0;159+ for (const ch of owner) h = (h * 31 + ch.codePointAt(0)!) >>> 0;160+ return `avatar-${(h % AVATARS) + 1}`;161+}162+129163 let last: Forge | null = null;130164 export const current = (): Forge | null => last;131165 @@ -385,10 +419,17 @@ export function loadForge(env: NodeJS.ProcessEnv = process.env): Forge {
385419 fs.writeFileSync(summaryCache, JSON.stringify(Object.fromEntries(summaries.map((s) => [s.slug, cached[s.slug] ?? { counts: {}, sites: [] }]))));386420 387421 const owners = ownerNames.map((owner) => ({ owner, repos: summaries.filter((s) => s.owner === owner) }));422+ // The index's panels; a private build has no index.423+ const lately = priv ? { items: [], active: {} } : recent(cfg, summaries);424+ const profiles = owners.filter((o) => o.repos.length).map((o) => ({ owner: o.owner, repos: o.repos.length, active: lately.active[o.owner] ?? '', colour: colour(o.owner, env) }));425+ const topics = filters.shelves(summaries).map((sh) => {426+ const who = [...new Set(sh.repos.map((r) => r.owner))];427+ return { id: sh.id, title: sh.title, repos: sh.repos.length, href: who.length === 1 ? `/${who[0]}/#${sh.id}` : `/topics/#${sh.id}` };428+ });388429 // Repo search on the index and owner pages.389430 raw.push({ path: '/repos.json', data: Buffer.from(reposIndex(summaries)) });390431 if (!cfg.only || cfg.only === '-') raw.push(...insightFiles(cfg, 'insights', '/insights/'));391- last = { config: cfg, repos: summaries, owners, pages, raw: raw.filter((r) => r.data || r.file), live, code, skipped, pageCaches };432+ last = { config: cfg, repos: summaries, owners, motd: motd(cfg.state), profiles, topics, recent: lately.items, pages, raw: raw.filter((r) => r.data || r.file), live, code, skipped, pageCaches };392433 return last;393434 }394435 @@ -529,7 +570,7 @@ export const filters = {
529570 return html.ci(status, { long });530571 },531572 date: html.date,532- // The index and owner pages: repos on shelves, newest first. A shelf is573+ // Owner pages, /topics/ and the home page's topics: repos on shelves, newest first. A shelf is533574 // the category the repo's owner gave it, else Jev's topic, else "other".534575 // Fuller shelves come first, "other" last.535576 shelves(repos: RepoSummary[]): { id: string; title: string; repos: RepoSummary[] }[] {@@ -0,0 +1,49 @@
1+// The motd: the quiet line under the header on the home page, which an2+// admin sets (bin/forge motd). It is kept in .forge/motd: no file is the3+// default, an empty one no line at all.4+//5+// node lib/motd.ts default the default line6+// node lib/motd.ts check <text> the text as stored (trimmed), or why not7+import fs from 'node:fs';8+import path from 'node:path';9+import { fileURLToPath } from 'node:url';10+11+export const MOTD_DEFAULT = 'a personal forge on plain git';12+export const MOTD_MAX = 72;13+14+// What the index shows: null for no line.15+export function motd(state: string): string | null {16+ let text: string;17+ try {18+ text = fs.readFileSync(path.join(state, 'motd'), 'utf8');19+ } catch {20+ return MOTD_DEFAULT;21+ }22+ return text.split('\n')[0].trim() || null;23+}24+25+// Plain text on one line, counted in characters; the page escapes it.26+export function check(text: string): { text: string } | { error: string } {27+ const s = text.trim();28+ const n = [...s].length;29+ if (/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/.test(s)) return { error: 'one line of plain text, without control characters' };30+ if (!n) return { error: 'empty; motd --clear shows no line' };31+ if (n > MOTD_MAX) return { error: `${n} characters; it can have ${MOTD_MAX} at most` };32+ return { text: s };33+}34+35+if (process.argv[1] && fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {36+ const [cmd, text = ''] = process.argv.slice(2);37+ if (cmd === 'default') console.log(MOTD_DEFAULT);38+ else if (cmd === 'check') {39+ const r = check(text);40+ if ('error' in r) {41+ console.error(`forge: motd: ${r.error}`);42+ process.exit(1);43+ }44+ console.log(r.text);45+ } else {46+ console.error('usage: node lib/motd.ts default | check <text>');47+ process.exit(2);48+ }49+}@@ -0,0 +1,175 @@
1+// What happened lately on the forge's public repos, for the index's recent2+// list and its profiles: landed reviews, pushes to a default branch,3+// releases and site deploys, newest first.4+//5+// Pushes, releases and site deploys come from the events log6+// (.forge/events.jsonl): post-receive writes a line for each push with the7+// refs it moved, and the default branch's new tip as head. Landed reviews8+// come from the review logs, which last: a review whose branch was deleted9+// with its tip in the default branch, at the time it was deleted. A push10+// whose head is a review's tip is that landing, and shows as landed. The11+// events log is cut to its last 1000 lines and left out of backups, so12+// after a restore only landed reviews show until the next pushes.13+//14+// Private repos are never read: the list is made from the public repos the15+// build was given, and their events, which carry no "private" mark.16+import fs from 'node:fs';17+import path from 'node:path';18+import { isAncestor, tryRun } from './git.ts';19+import { readReviewLogs } from './reviews.ts';20+import type { Config, RepoSummary } from './types.ts';21+22+export type RecentKind = 'landed' | 'pushed' | 'release' | 'site';23+24+export interface RecentItem {25+ kind: RecentKind;26+ // ISO time, for <time datetime>.27+ at: string;28+ repo: { slug: string; name: string; owner: string };29+ href: string;30+ // The review's #id or the tag, before the text (a release's is its tag only).31+ ref: string;32+ text: string;33+}34+35+// When each owner last did something public: their newest event, landed36+// review or commit. ISO, '' for never.37+export type Active = Record<string, string>;38+39+interface Candidate {40+ kind: RecentKind;41+ t: number;42+ repo: RepoSummary;43+ // pushed: the new head; landed: the review's tip; release, site: the name.44+ key: string;45+ // landed: the review.46+ id?: number;47+ title?: string;48+ base?: string | null;49+}50+51+interface Event {52+ t?: number;53+ repo?: string;54+ type?: string;55+ refs?: string;56+ head?: string;57+ private?: boolean;58+}59+60+function events(state: string): Event[] {61+ let text = '';62+ try {63+ text = fs.readFileSync(path.join(state, 'events.jsonl'), 'utf8');64+ } catch {65+ return [];66+ }67+ const out: Event[] = [];68+ for (const line of text.split('\n')) {69+ if (!line.includes('"type":"push"')) continue;70+ try {71+ const e = JSON.parse(line);72+ if (e && e.type === 'push' && !e.private && typeof e.repo === 'string' && typeof e.t === 'number') out.push(e);73+ } catch {74+ // A line cut short by a crash.75+ }76+ }77+ return out;78+}79+80+const iso = (ms: number): string => new Date(ms).toISOString().replace(/\.\d+Z$/, 'Z');81+82+export function recent(cfg: Config, repos: RepoSummary[], n = 8): { items: RecentItem[]; active: Active } {83+ const bySlug = new Map(repos.map((r) => [r.slug, r]));84+ const latest: Record<string, number> = {};85+ const seen = (owner: string, t: number): void => {86+ if (t > (latest[owner] ?? 0)) latest[owner] = t;87+ };88+ for (const r of repos) if (r.lastCommit) seen(r.owner, Date.parse(r.lastCommit));89+90+ const list: Candidate[] = [];91+ for (const e of events(cfg.state)) {92+ const repo = bySlug.get(e.repo!);93+ if (!repo) continue;94+ seen(repo.owner, e.t!);95+ const named = new Set<string>();96+ for (const ref of (e.refs ?? '').split(/\s+/).filter(Boolean)) {97+ if (ref === `refs/heads/${repo.defaultBranch}`) {98+ if (e.head && /^[0-9a-f]{40,64}$/.test(e.head)) list.push({ kind: 'pushed', t: e.t!, repo, key: e.head });99+ } else {100+ const m = /^refs\/(tags|releases|sites)\/(.+)$/.exec(ref);101+ if (!m) continue;102+ const kind = m[1] === 'sites' ? 'site' : 'release';103+ // A tag and its release in one push are one release.104+ if (named.has(kind + ' ' + m[2])) continue;105+ named.add(kind + ' ' + m[2]);106+ list.push({ kind, t: e.t!, repo, key: m[2] });107+ }108+ }109+ }110+111+ // Every review's revisions, by repo: a push of one to the default branch landed it.112+ const revs = new Map<string, Set<string>>();113+ for (const repo of repos) {114+ const shas = new Set<string>();115+ for (const r of readReviewLogs(repo.dir, false)) {116+ for (const v of r.revs) shas.add(v.sha);117+ const tip = r.revs.at(-1);118+ if (!r.deleted || !tip) continue;119+ seen(repo.owner, r.deleted.ts * 1000);120+ list.push({ kind: 'landed', t: r.deleted.ts * 1000, repo, key: tip.sha, id: r.id, title: r.opts.title, base: tip.base });121+ }122+ revs.set(repo.slug, shas);123+ }124+125+ // The refs each repo has now: a deleted tag or site isn't news.126+ const refs = new Map<string, Set<string>>();127+ const refsOf = (repo: RepoSummary): Set<string> => {128+ let s = refs.get(repo.slug);129+ if (!s) {130+ s = new Set((tryRun(repo.dir, ['for-each-ref', '--format=%(refname)', 'refs/tags', 'refs/releases', 'refs/sites']) ?? '').split('\n').filter(Boolean));131+ refs.set(repo.slug, s);132+ }133+ return s;134+ };135+ const subject = (repo: RepoSummary, rev: string): string | null => tryRun(repo.dir, ['log', '-1', '--format=%s', rev, '--'])?.trim() ?? null;136+137+ // Newest first; checked one by one (git), until there are n.138+ list.sort((a, b) => b.t - a.t || Number(b.kind === 'landed') - Number(a.kind === 'landed'));139+ const items: RecentItem[] = [];140+ const done = new Set<string>();141+ for (const c of list) {142+ if (items.length >= n) break;143+ const { repo } = c;144+ const once = `${c.kind} ${repo.slug} ${c.key}`;145+ if (done.has(once)) continue;146+ let href = '', ref = '', text = '';147+ if (c.kind === 'pushed') {148+ if (revs.get(repo.slug)?.has(c.key)) continue;149+ const s = subject(repo, c.key);150+ if (s == null) continue;151+ href = repo.urls.commit(c.key).href;152+ text = s;153+ } else if (c.kind === 'landed') {154+ if (!isAncestor(repo.dir, c.key, `refs/heads/${repo.defaultBranch}`)) continue;155+ // Untitled, a review is named for its first commit (as lib/repo.ts names it).156+ const first = c.title ? null : tryRun(repo.dir, ['log', '--reverse', '--format=%s', c.base ? `${c.base}..${c.key}` : c.key, '--'])?.split('\n')[0];157+ href = repo.urls.review(c.id!).href;158+ ref = `#${c.id}`;159+ text = c.title || first || '';160+ } else if (c.kind === 'release') {161+ const have = refsOf(repo);162+ if (!have.has(`refs/tags/${c.key}`) && !have.has(`refs/releases/${c.key}`)) continue;163+ href = `${repo.urls.releases.href}#${encodeURIComponent(c.key)}`;164+ ref = c.key;165+ } else {166+ if (!refsOf(repo).has(`refs/sites/${c.key}`)) continue;167+ const sites = repo.sites ?? [];168+ href = sites.find((s) => s.name === c.key)?.url ?? repo.urls.site(c.key);169+ text = sites.length > 1 ? `deployed its site ${c.key}` : 'deployed its site';170+ }171+ done.add(once);172+ items.push({ kind: c.kind, at: iso(c.t), repo: { slug: repo.slug, name: repo.name, owner: repo.owner }, href, ref, text });173+ }174+ return { items, active: Object.fromEntries(Object.entries(latest).map(([o, t]) => [o, iso(t)])) };175+}@@ -161,12 +161,13 @@ if (process.argv[1] && fs.realpathSync(process.argv[1]) === fileURLToPath(import
161161 const forge = loadForge();162162 const render = pageRenderer();163163 const templates = listTemplates();164- // Every page, unless lists are named: the index, owners, the forge's insights, the style guide, and forge.pages.<list>.164+ // Every page, unless lists are named: the index, owners, the forge's insights, the style guide, topics, and forge.pages.<list>.165165 const jobs: { file: string; template: string; data: Record<string, unknown> }[] = [];166- for (const list of lists.length ? lists : ['index', 'owners', 'forgeInsights', 'design', ...templates.keys()]) {166+ for (const list of lists.length ? lists : ['index', 'owners', 'forgeInsights', 'design', 'topics', ...templates.keys()]) {167167 if (list === 'index') jobs.push({ file: '/index.html', template: 'index.njk', data: { forge } });168168 else if (list === 'forgeInsights') jobs.push({ file: '/insights/index.html', template: 'insights.njk', data: { forge } });169169 else if (list === 'design') jobs.push({ file: '/design/index.html', template: 'design.njk', data: { forge } });170+ else if (list === 'topics') jobs.push({ file: '/topics/index.html', template: 'topics.njk', data: { forge } });170171 else if (list === 'owners') for (const o of forge.owners) jobs.push({ file: `/${o.owner}/index.html`, template: 'owner.njk', data: { forge, o } });171172 else {172173 const template = templates.get(list);@@ -7,11 +7,12 @@ import { revList, isAncestor, tryRun } from './git.ts';
77 import { readComments, parseApproval } from './discussion.ts';88 import type { Review, ReviewLog, Revision } from './types.ts';99 10-export function readReviewLogs(gitDir: string): ReviewLog[] {10+// comments: false leaves them out (the index's recent list, lib/recent.ts).11+export function readReviewLogs(gitDir: string, comments = true): ReviewLog[] {1112 const dir = path.join(gitDir, 'forge', 'reviews');1213 if (!fs.existsSync(dir)) return [];1314 return fs.readdirSync(dir).filter((f) => /^\d+$/.test(f)).map((f): ReviewLog => {14- const r: ReviewLog = { id: Number(f), branch: '', created: null, opts: {}, draft: false, revs: [], deleted: null, approvals: [], comments: readComments(gitDir, Number(f)) };15+ const r: ReviewLog = { id: Number(f), branch: '', created: null, opts: {}, draft: false, revs: [], deleted: null, approvals: [], comments: comments ? readComments(gitDir, Number(f)) : [] };1516 for (const line of fs.readFileSync(path.join(dir, f), 'utf8').split('\n')) {1617 const sp = line.indexOf(' ');1718 const k = sp < 0 ? line : line.slice(0, sp);@@ -138,7 +138,7 @@
138138 </table>139139 {% endmacro %}140140 141-{# The index and owner pages: repos as rows on shelves by kind. Tabs above141+{# Owner pages and /topics/: repos as rows on shelves by kind. Tabs above142142 them name the shelves, "all" first: without JS they jump to a shelf,143143 with it (ui.ts) one shows that shelf alone. Beside them, how to look at144144 the list, which needs JS and shows with it: the globe keeps only repos@@ -55,6 +55,13 @@
5555 --status-merged: #3148f6;5656 --status-closed: #c7162b;5757 --status-other: #5b6169;58+ /* letter avatars on the home page: white on a fill seeded from the59+ name (the forge's owner has the accent), the same in both modes */60+ --avatar-1: #00796b;61+ --avatar-2: #7a3fc4;62+ --avatar-3: #a8461d;63+ --avatar-4: #b02a66;64+ --avatar-5: #2f7a1f;5865 /* a linked line, floating panels, dialogs' backdrop, masks */5966 --target-bg: rgba(255, 213, 79, 0.25);6067 --target-gut: rgba(255, 213, 79, 0.4);@@ -72,7 +72,7 @@
7272 if (over > 0) tabs.scrollLeft += over + 24;7373 }7474 75- // The index and owner pages' shelves (shelves in macros.njk). Without JS75+ // The owner pages' and /topics/' shelves (shelves in macros.njk). Without JS7676 // the tabs jump to a shelf; here one shows that shelf alone ("all" shows7777 // every one), the globe hides repos without a site, and the sort is7878 // newest first or by name. The choices outlive live.ts swapping in a@@ -124,6 +124,10 @@
124124 else b.setAttribute('aria-pressed', String(b.getAttribute('aria-pressed') !== 'true'));125125 });126126 127+ // A link to one shelf (/cat/#games, from the home page's topics) opens on it.128+ const at = decodeURIComponent(location.hash.slice(1));129+ if (at && document.querySelector(`[data-shelves] a[data-shelf="${CSS.escape(at)}"]`)) pick.shelf = at;130+127131 const content = (): void => {128132 wrap(wrapped());129133 times();@@ -1,10 +1,101 @@
11 ---22 permalink: /index.html33 ---4+{#- The home page: the motd an admin sets (bin/forge motd), then who is5+ here (profiles), the shelves across them (topics) and what happened6+ lately on public repos (recent, lib/recent.ts). Every build redoes it,7+ so it is as fresh as the last push; ui.ts keeps its times relative, and8+ live.ts swaps it in when a build finishes. Each owner's page has their9+ repos on shelves. -#}410 {% extends "base.njk" %}511 {% import "macros.njk" as m with context %}612 {% import "ui.njk" as ui %}713 {% block content %}8-{{ m.shelves(forge.repos, forge.owners.length > 1) }}9-<p class="desc"><a href="/insights/">Insights</a>: builds, pushes, reviews and CI across these repos, over time.</p>14+{% css %}15+main.wrap > p.motd { margin: calc(-1 * var(--space-5)) 0 0; padding: var(--space-3) 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--text-sm); color: var(--muted); }16+/* Two columns: profiles and topics, then what happened; on phones one,17+ recent after profiles. */18+.home { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.15fr); grid-template-areas: "profiles recent" "topics recent"; grid-template-rows: auto 1fr; gap: var(--space-4); align-items: start; }19+.home > .panel { margin: 0; }20+.home .profiles { grid-area: profiles; }21+.home .topics { grid-area: topics; }22+.home .recent { grid-area: recent; }23+@media (max-width: 720px) {24+ .home { grid-template-columns: minmax(0, 1fr); grid-template-areas: "profiles" "recent" "topics"; grid-template-rows: none; }25+}26+.home .panel-head .end a { font-weight: var(--fw-medium); }27+/* A profile: a letter on a colour, the name, how many repos and when. */28+.profile { position: relative; display: flex; align-items: center; gap: var(--space-3); padding: var(--space-2) var(--space-3); }29+.profile:hover, .profile:focus-within { background: var(--hover); }30+.avatar { display: grid; place-items: center; flex: none; width: 36px; height: 36px; font: var(--fw-strong) var(--text-lg)/1 var(--mono); color: var(--on-accent); background: var(--avatar-1); }31+.avatar.accent { background: var(--accent); }32+.avatar.avatar-2 { background: var(--avatar-2); }33+.avatar.avatar-3 { background: var(--avatar-3); }34+.avatar.avatar-4 { background: var(--avatar-4); }35+.avatar.avatar-5 { background: var(--avatar-5); }36+.profile-text { display: flex; flex-direction: column; min-width: 0; }37+.profile-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--text-prose); font-weight: var(--fw-strong); color: var(--fg); }38+.profile-name:hover { color: var(--fg); }39+.profile-name::after { content: ''; position: absolute; inset: 0; }40+.profile-meta { font-size: var(--text-sm); color: var(--muted); }41+.profile-meta time { font: inherit; }42+/* Topics: tiles with hairlines between them; the panel's edge is the rule43+ round them, so the last column's and row's own rules are cut off. */44+.topics { overflow: hidden; }45+.topic-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); margin: 0 calc(-1 * var(--hairline)) calc(-1 * var(--hairline)) 0; padding: 0; list-style: none; }46+.topic-grid > li { position: relative; border-right: var(--hairline) solid var(--border); border-bottom: var(--hairline) solid var(--border); }47+.topic-grid > li:hover, .topic-grid > li:focus-within { background: var(--hover); }48+.topic-grid a { display: flex; flex-direction: column; padding: var(--space-2) var(--space-3); color: var(--fg); font-weight: var(--fw-medium); overflow-wrap: anywhere; }49+.topic-grid a:hover { color: var(--fg); }50+.topic-grid .n { font: var(--fw-regular) var(--text-xs) var(--mono); color: var(--faint); }51+@media (max-width: 720px) {52+ .topic-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }53+}54+/* What happened: its kind, the repo and what, when. */55+.ev { position: relative; display: grid; grid-template-columns: 3.6em minmax(0, 1fr) auto; column-gap: var(--space-3); align-items: baseline; padding: var(--space-2) var(--space-3); }56+.ev:hover, .ev:focus-within { background: var(--hover); }57+.ev-kind { font: var(--fw-regular) var(--text-xs) var(--mono); color: var(--muted); }58+.ev-what { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--fg); }59+.ev-what:hover { color: var(--fg); }60+.ev-what::after { content: ''; position: absolute; inset: 0; }61+.ev-what b { font-weight: var(--fw-strong); }62+.ev-what .tag { font-family: var(--mono); font-size: var(--text-inline-mono); color: var(--link); }63+.ev time { font-size: var(--text-xs); color: var(--faint); white-space: nowrap; }64+{% endcss %}65+{% if forge.motd %}<p class="motd">{{ forge.motd }}</p>66+{% endif %}67+<div class="home">68+{% set head %}<h2>profiles</h2><span class="n">{{ forge.profiles.length }}</span>{% endset %}69+{% call ui.panel(head, 'profiles') %}70+{% if forge.profiles.length %}71+<ul class="rows">72+{% for pr in forge.profiles %}73+<li class="profile"><span class="avatar {{ pr.colour }}" aria-hidden="true">{{ pr.owner | first }}</span><span class="profile-text"><a class="profile-name" href="/{{ pr.owner }}/">{{ pr.owner }}</a><span class="profile-meta">{{ pr.repos | plural("repo") }}{% if pr.active %}, active {{ pr.active | date }}{% endif %}</span></span></li>74+{% endfor %}75+</ul>76+{% else %}<p class="empty">No public repos here yet.</p>77+{% endif %}78+{% endcall %}79+{% set head %}<h2>recent</h2><span class="end"><a href="/insights/">insights</a></span>{% endset %}80+{% call ui.panel(head, 'recent') %}81+{% if forge.recent.length %}82+<ul class="rows">83+{% for e in forge.recent %}84+<li class="ev"><span class="ev-kind">{{ e.kind }}</span><a class="ev-what" href="{{ e.href }}"><b>{{ e.repo.name }}</b> {% if e.kind == 'release' %}<span class="tag">{{ e.ref }}</span>{% else %}{{ (e.ref + ' ') if e.ref }}{{ e.text }}{% endif %}</a>{{ e.at | date }}</li>85+{% endfor %}86+</ul>87+{% else %}<p class="empty">Nothing has happened here yet.</p>88+{% endif %}89+{% endcall %}90+{% if forge.topics.length %}91+{% set head %}<h2>topics</h2>{% endset %}92+{% call ui.panel(head, 'topics') %}93+<ul class="topic-grid">94+{% for t in forge.topics %}95+<li><a href="{{ t.href }}">{{ t.title }}<span class="n">{{ t.repos | plural('repo') }}</span></a></li>96+{% endfor %}97+</ul>98+{% endcall %}99+{% endif %}100+</div>10101 {% endblock %}@@ -0,0 +1,16 @@
1+---2+permalink: /topics/index.html3+---4+{#- Every public repo on its shelf, across owners: where the home page's5+ topics link when more than one owner has repos on a shelf (/topics/#<id>,6+ which ui.ts opens on that shelf). One owner's go to their own page. -#}7+{% extends "base.njk" %}8+{% import "macros.njk" as m with context %}9+{% import "ui.njk" as ui %}10+{% block title %}Topics - {{ forge.config.name }}{% endblock %}11+{% block head %}{{ m.meta('Topics - ' + forge.config.name, 'Every repo on ' + forge.config.name + ', by topic.', '/topics/') }}{% endblock %}12+{% block heading %}<a href="/">{{ forge.config.name }}</a> / <b>topics</b>13+{% endblock %}14+{% block content %}15+{{ m.shelves(forge.repos, true) }}16+{% endblock %}@@ -0,0 +1,35 @@
1+# The home page has a profile for each owner with public repos: their2+# letter, their name linking to their page, how many public repos they3+# have and when they were last active. No shelves of repos: those are on4+# each owner's page. The header says the forge's name.5+. "$(dirname "$0")/../lib.sh"6+user dami7+user ana8+user bob9+for r in dami/chess dami/go ana/notes; do10+ repo "$r"11+ clone "${r%%/*}" "$r" "$T/w/${r#*/}"12+ tick 360013+ at "$T/w/${r#*/}" commit "first"14+ push_as "${r%%/*}" "$T/w/${r#*/}" origin main15+done16+repo --private dami/secret17+repo --private bob/hidden18+export FORGE_STRICT=119+build20+i=$FORGE_WWW/index.html21+has "$i" '<div class="title">22+<b>forge</b></div>'23+has "$i" '<section class="panel profiles"><header class="panel-head"><h2>profiles</h2><span class="n">2</span></header>'24+has "$i" '<a class="profile-name" href="/ana/">ana</a><span class="profile-meta">1 repo, active <time datetime="2026-09-01T03:01:00Z">'25+has "$i" '<a class="profile-name" href="/dami/">dami</a><span class="profile-meta">2 repos, active <time datetime="2026-09-01T02:01:00Z">'26+has "$i" '<span class="avatar avatar-[1-5]" aria-hidden="true">d</span>'27+# bob has only a private repo: no profile.28+lacks "$i" 'bob'29+lacks "$i" 'data-shelves|class="card'30+lacks "$i" 'Insights</a>:'31+# The forge's owner has the accent.32+FORGE_OWNER=dami build33+has "$i" '<span class="avatar accent" aria-hidden="true">d</span>'34+# The owner page keeps its shelves.35+has "$FORGE_WWW/dami/index.html" 'data-shelves'@@ -0,0 +1,58 @@
1+# The home page's recent list: landed reviews, pushes to a default branch,2+# releases and site deploys of public repos, newest first, each linking to3+# what it names, with its time for ui.ts to say relative. A landing's push4+# shows once, as landed. Nothing of a private repo is in it.5+. "$(dirname "$0")/../lib.sh"6+user dami7+user ana8+repo dami/greet9+repo --private dami/secret10+clone dami dami/greet "$T/w/d"11+at "$T/w/d" commit "first"12+push_as dami "$T/w/d" origin main13+first=$(git -C "$T/w/d" rev-parse HEAD)14+tick15+clone ana dami/greet "$T/w/a"16+at "$T/w/a" commit "ana's change"17+push_as ana "$T/w/a" -o 'title=Say it louder' origin HEAD:ana/change18+tick19+ok ssh_as dami land dami/greet 120+tick21+at "$T/w/d" git pull -q origin main22+at "$T/w/d" git tag v123+push_as dami "$T/w/d" origin v124+tick25+push_as dami "$T/w/d" origin "$(site_commit "$T/w/d" docs):refs/sites/docs"26+tick27+at "$T/w/d" commit "polish <b>the</b> greeting"28+push_as dami "$T/w/d" origin main29+last=$(git -C "$T/w/d" rev-parse HEAD)30+# Newest of all, but private.31+tick32+clone dami dami/secret "$T/w/s"33+at "$T/w/s" commit "hidden thing"34+at "$T/w/s" git tag v935+push_as dami "$T/w/s" origin main v9 "$(site_commit "$T/w/s" docs):refs/sites/docs"36+has "$FORGE_ROOT/.forge/events.jsonl" "\"head\":\"$last\",\"refs\":\" refs/heads/main\"}$"37+38+export FORGE_STRICT=139+build40+i=$FORGE_WWW/index.html41+has "$i" '<section class="panel recent"><header class="panel-head"><h2>recent</h2><span class="end"><a href="/insights/">insights</a></span></header>'42+eq "$(grep -o 'class="ev-kind">[a-z]*' "$i" | sed 's/.*>//' | tr '\n' ' ')" "pushed site release landed pushed "43+has "$i" "<span class=\"ev-kind\">pushed</span><a class=\"ev-what\" href=\"/dami/greet/commit/$last/\"><b>greet</b> polish <b>the</b> greeting</a><time datetime=\"2026-09-01T00:05:00Z\">"44+has "$i" '<span class="ev-kind">site</span><a class="ev-what" href="/sites/dami/greet/docs/"><b>greet</b> deployed its site</a><time datetime="2026-09-01T00:04:00Z">'45+has "$i" '<span class="ev-kind">release</span><a class="ev-what" href="/dami/greet/releases/#v1"><b>greet</b> <span class="tag">v1</span></a><time datetime="2026-09-01T00:03:00Z">'46+has "$i" '<span class="ev-kind">landed</span><a class="ev-what" href="/dami/greet/reviews/1/"><b>greet</b> #1 Say it louder</a><time datetime="2026-09-01T00:02:00Z">'47+has "$i" "<span class=\"ev-kind\">pushed</span><a class=\"ev-what\" href=\"/dami/greet/commit/$first/\"><b>greet</b> first</a>"48+lacks "$i" 'secret|hidden thing|v9'49+# The links go somewhere.50+exists "$FORGE_WWW/dami/greet/commit/$last/index.html"51+exists "$FORGE_WWW/dami/greet/reviews/1/index.html"52+has "$FORGE_WWW/dami/greet/releases/index.html" 'id="v1"'53+exists "$FORGE_WWW/sites/dami/greet/docs/index.html"54+55+# Made private, the repo leaves the list.56+ok ssh_as dami private dami/greet57+build58+has "$i" '<p class="empty">Nothing has happened here yet.</p>'@@ -0,0 +1,37 @@
1+# The home page's topics are the shelves across the public repos, each2+# with its count. One owner's shelf links to it on their page, where ui.ts3+# opens the tab the fragment names; a shelf several owners share links to4+# it on /topics/, every public repo by shelf, each naming its owner.5+. "$(dirname "$0")/../lib.sh"6+user dami7+user ana8+for r in dami/chess dami/go dami/hammer ana/cards ana/notes; do9+ repo "$r"10+ clone "${r%%/*}" "$r" "$T/w/${r#*/}"11+ at "$T/w/${r#*/}" commit "first"12+ push_as "${r%%/*}" "$T/w/${r#*/}" origin main13+done14+repo --private ana/secret15+for r in dami/chess dami/go ana/cards ana/secret; do printf 'games\n' >"$FORGE_ROOT/$r.git/category"; done16+printf 'tools\n' >"$FORGE_ROOT/dami/hammer.git/category"17+export FORGE_STRICT=118+build19+i=$FORGE_WWW/index.html20+has "$i" '<li><a href="/topics/#games">games<span class="n">3 repos</span></a></li>'21+has "$i" '<li><a href="/dami/#tools">tools<span class="n">1 repo</span></a></li>'22+has "$i" '<li><a href="/ana/#other">other<span class="n">1 repo</span></a></li>'23+# Where they go.24+has "$FORGE_WWW/dami/index.html" '<a href="#tools" data-shelf="tools">tools<span class="n">1</span></a>'25+has "$FORGE_WWW/dami/index.html" 'data-shelf="tools" id="tools"'26+t=$FORGE_WWW/topics/index.html27+has "$t" '<section class="panel shelf" data-shelf="games" id="games"><header class="panel-head"><h2>games</h2><span class="n">3</span></header>'28+has "$t" '<a href="#games" data-shelf="games">games<span class="n">3</span></a>'29+has "$t" '<span class="desc">ana / </span>cards</a>'30+lacks "$t" 'secret'31+# ui.ts opens the shelf a link names.32+has site/assets/ui.ts 'location.hash'33+# A single repo's build redoes /topics/ too.34+printf 'tools\n' >"$FORGE_ROOT/ana/notes.git/category"35+build ana/notes36+has "$t" 'data-shelf="tools" id="tools"><header class="panel-head"><h2>tools</h2><span class="n">2</span>'37+has "$i" '<li><a href="/topics/#tools">tools<span class="n">2 repos</span></a></li>'@@ -18,7 +18,7 @@ has "$w/dami/greet/insights/index.html" 'data-insights="/dami/greet/insights/ins
1818 has "$w/dami/greet/insights/index.html" '<script src="/assets/insights\.[A-Za-z0-9_-]{10}\.js" defer>'1919 has "$w/dami/greet/index.html" '<a href="/dami/greet/insights/" data-key="i">insights</a>'2020 has "$w/insights/index.html" 'data-insights="/insights/insights.json" data-forge="1"'21-has "$w/index.html" '<a href="/insights/">Insights</a>'21+has "$w/index.html" '<span class="end"><a href="/insights/">insights</a></span>'2222 # The charts' script is only on insights pages.2323 lacks "$w/dami/greet/index.html" 'assets/insights\.'2424 # No lake yet: no data, and the page says so when it can't fetch it.@@ -1,4 +1,4 @@
1-# The index's shelves are panels: each one's head strip names the shelf1+# The shelves (owner pages, /topics/) are panels: each one's head strip names the shelf22 # and counts its repos, and the repos are rows inside it.33 . "$(dirname "$0")/../lib.sh"44 user dami@@ -12,7 +12,7 @@ printf 'games\n' >"$FORGE_ROOT/dami/chess.git/category"
1212 printf 'games\n' >"$FORGE_ROOT/dami/go.git/category"1313 export FORGE_STRICT=11414 build15-i=$FORGE_WWW/index.html15+i=$FORGE_WWW/topics/index.html1616 has "$i" '<section class="panel shelf" data-shelf="games" id="games"><header class="panel-head"><h2>games</h2><span class="n">2</span></header>'1717 has "$i" '<section class="panel shelf" data-shelf="other" id="other"><header class="panel-head"><h2>other</h2><span class="n">1</span></header>'1818 has "$i" '<ul class="rows cards nav-cards">'@@ -1,4 +1,4 @@
1-# The index's "has a site" filter is a control, not words: a globe icon1+# The shelves' "has a site" filter is a control, not words: a globe icon22 # toggle (the globe repo rows show beside a site) with aria-pressed, a33 # label and a title; the sort is a segmented control of two icon toggles;44 # shelves are tabs, "all" first and current. ui.ts makes them work.@@ -13,7 +13,7 @@ done
1313 printf 'games\n' >"$FORGE_ROOT/dami/chess.git/category"1414 export FORGE_STRICT=11515 build16-i=$FORGE_WWW/index.html16+i=$FORGE_WWW/dami/index.html1717 has "$i" '<button type="button" class="icon-btn" aria-pressed="false" aria-label="only repos with a site" title="only repos with a site" data-has-site><svg class="icon"'1818 has "$i" '<span class="segmented" role="group" aria-label="sort">'1919 has "$i" 'aria-label="newest first" title="newest first" aria-pressed="true" data-sort="date"'@@ -1,4 +1,4 @@
1-# The index and owner pages show repos as cards on shelves: the category1+# Owner pages and /topics/ show repos as cards on shelves: the category22 # its owner wrote in the bare repo's category file, else "other" (Jev's33 # topic is off in scenarios); fuller shelves first, "other" last, newest44 # first on each. Private repos have no card, and backups keep the file.@@ -17,7 +17,7 @@ printf 'Games\n' >"$FORGE_ROOT/dami/chess.git/category"
1717 printf 'games\n' >"$FORGE_ROOT/dami/go.git/category"1818 export FORGE_STRICT=11919 build20-i=$FORGE_WWW/index.html20+i=$FORGE_WWW/topics/index.html2121 has "$i" '<section class="panel shelf" data-shelf="games" id="games">'2222 has "$i" '<section class="panel shelf" data-shelf="other" id="other">'2323 # games (two repos) before other (one); go was pushed last, so it leads.2/3 An admin sets the home page's motd over ssh
ssh motd sets the line under the home page's header for admins
(forge.admins), and forge motd does the same on the server:
ssh git@src.rest motd "notes, tools and games"
ssh git@src.rest motd # print it
ssh git@src.rest motd --clear # no line at all
ssh git@src.rest motd --default # "a personal forge on plain git"
It is one line of plain text, trimmed, 72 characters at most, without
control characters (lib/motd.ts checks it; the page escapes it). It goes
in .forge/motd, which backups carry, and the index pages are rebuilt.
motd is one of the admins' commands, so to anyone else it is git-shell's
unknown command, byte for byte, and their help doesn't list it.| M | README.md | 16 | +++++++++- |
| M | bin/ | 47 | +++++++++++++++++++++++++++--- |
| A | test/ | 36 | +++++++++++++++++++++++ |
| A | test/ | 29 | +++++++++++++++++++ |
| A | test/ | 17 | +++++++++++ |
@@ -197,6 +197,19 @@ ssh git@git.example.com invites [rm fox]
197197 ssh git@git.example.com users198198 ```199199 200+They also set the home page's motd, the quiet line under its header201+(the server's form is `forge motd`):202+203+```sh204+ssh git@git.example.com motd "notes, tools and games" # one line of plain text, 72 characters at most205+ssh git@git.example.com motd # print it206+ssh git@git.example.com motd --clear # no line at all207+ssh git@git.example.com motd --default # back to "a personal forge on plain git"208+```209+210+It is kept in `.forge/motd` (no file is the default, an empty one no line),211+which backups carry, and setting it rebuilds the home page.212+200213 An admin's ssh session finds them in a `git-shell-commands` of their own,201214 `.forge/admin/git-shell-commands` (`forge server init` writes it), so to202215 anyone else they don't exist: git-shell answers `fatal: unrecognized@@ -1326,8 +1339,7 @@ first build takes minutes.
13261339 13271340 ## Reading pages13281341 1329-The home page has a quiet line under its header, `.forge/motd` (no file1330-is "a personal forge on plain git", an empty one no line), then three panels. **profiles**: each1342+The home page has the motd (above), then three panels. **profiles**: each13311343 owner with public repos, how many and when they were last active.13321344 **recent**: the last eight things that happened on public repos, newest13331345 first: landed reviews, pushes to a default branch, releases (a tag or@@ -24,6 +24,7 @@
2424 # forge user invite <name> [--ttl 7d] [--repo <owner>/<repo> --as reader|collaborator] a one-time link to join2525 # forge user invites [rm <name>] invites not taken up yet2626 # forge users everyone: keys, passkeys; and who is invited27+# forge motd [<text>|--clear|--default] the line under the header on /2728 # forge readers|collaborators <owner>/<repo> [add|rm <user>] who may read or push a repo2829 # forge keys sync2930 # forge repo new [--private] <owner>/<name> [description]@@ -57,8 +58,8 @@ NS=forge-ci
5758 RNS=forge-release5859 ZERO=00000000000000000000000000000000000000005960 # ssh commands for the forge's admins only (is_admin): the operator's60-# forge user invite, user invites and users.61-ADMIN_CMDS='invite invites users'61+# forge user invite, user invites and users, and motd.62+ADMIN_CMDS='invite invites users motd'6263 CI_SCRIPT=.forge/ci6364 6465 die() { printf 'forge: %s\n' "$*" >&2; exit 1; }@@ -1508,6 +1509,42 @@ can_read() {
15081509 return 115091510 }15101511 1512+# forge motd [<text>|--clear|--default] (and ssh motd, for admins): the1513+# quiet line under the header on the home page. Without arguments it prints1514+# it. Text is one line of plain text, at most 72 characters, trimmed; the1515+# page escapes it (lib/motd.ts checks it). --clear shows no line, --default1516+# the default one. It is kept in .forge/motd, which backups carry: no file1517+# is the default, an empty one no line. A change rebuilds the index pages.1518+cmd_motd() {1519+ if [ -n "${FORGE_USER:-}" ]; then mu="usage: motd"; else mu="usage: forge motd"; fi1520+ f=$FORGE_STATE/motd1521+ if [ $# -eq 0 ]; then1522+ if [ ! -f "$f" ]; then1523+ node "$FORGE_HOME/lib/motd.ts" default1524+ say "(the default; motd <text> sets another)"1525+ elif [ -s "$f" ]; then cat "$f"1526+ else say "(none: the home page shows no line; motd <text> sets one)"; fi1527+ return 01528+ fi1529+ case $1 in1530+ --clear | --default)1531+ [ $# -eq 1 ] || die "$mu [<text>|--clear|--default]"1532+ if [ "$1" = --clear ]; then : >"$f"; else rm -f "$f"; fi1533+ ;;1534+ -*) die "$mu [<text>|--clear|--default]" ;;1535+ *)1536+ text=$(node "$FORGE_HOME/lib/motd.ts" check "$*") || exit 11537+ printf '%s\n' "$text" >"$f.tmp" && mv "$f.tmp" "$f"1538+ ;;1539+ esac1540+ rebuild - motd1541+ case $1 in1542+ --clear) say "the home page shows no line now" ;;1543+ --default) say "the home page shows the default line now" ;;1544+ *) say "the home page says: $text" ;;1545+ esac1546+}1547+15111548 # is_admin <user>: is user one of the forge's admins? They are the values15121549 # of forge.admins in the server's git config, $FORGE_ROOT/.gitconfig (the15131550 # git user's own; several values, or names split by spaces or commas).@@ -2184,10 +2221,11 @@ cmd_shell_cmd() {
21842221 release) sc_release "$@" ;;21852222 rm) sc_rm "$@" ;;21862223 passkey | passkeys | sessions | tokens) credentials "$c" "$@" ;;2187- invite | invites | users)2224+ invite | invites | users | motd)21882225 # Reached only through the admins' git-shell-commands (cmd_shell).21892226 is_admin "$user" || die "unknown command $c"21902227 case $c in2228+ motd) cmd_motd "$@" ;;21912229 invite) user_invite "$@" ;;21922230 invites) node "$FORGE_HOME/lib/identity.ts" invites "$@" ;;21932231 users) [ $# -eq 0 ] || die "usage: users"; node "$FORGE_HOME/lib/identity.ts" users ;;@@ -2206,7 +2244,7 @@ cmd_shell_cmd() {
22062244 ;;22072245 *) printf 'commands:\n new [--private] <name> [description] create %s/<name>\n ls list your repos, and private ones you may read\n signers keys that may sign CI results (for forge verify)\n site-url <owner>/<repo> <site> [branch] where a site deploy is served\n reviews <owner>/<repo> open and draft reviews (forge reviews)\n land <owner>/<repo> <id|branch> land a review here, with the same gates as a push\n comment, approve, approvals used by forge comment, approve and land\n private <owner>/<repo> only its owner, collaborators and readers can see it\n public <owner>/<repo> [--force] anyone can, once its whole history passes the secret check\n readers <owner>/<repo> [add|rm <user>] who may read it while it is private\n collaborators <owner>/<repo> [add|rm <user>] who may push anything to it; only its owner changes the list\n who <owner>/<repo> its owner, collaborators and readers\n release ls <owner>/<repo> its releases: tag, file, size, sha256\n release get <owner>/<repo> <tag> <file> a release file, on stdout\n rm <owner>/<repo> --yes remove a repo of yours (the admin can restore it for 14 days)\n passkey [name] a one-time link that adds a passkey, to sign in on the web\n passkeys [rm <id>] your passkeys\n sessions [rm <id>|--all] where you are signed in on the web; rm signs one out, --all all\n token [--repo <owner>/<repo>] [--ttl 30d] [name] a token for curl and HTTPS clones, shown once\n tokens [rm <id>] your tokens\na push to %s/<name>.git creates that repo when it is missing\n' "$user" "$user"22082246 if is_admin "$user"; then2209- printf 'for admins of the forge:\n invite <name> [--ttl 7d] [--repo <owner>/<repo> --as reader|collaborator] a one-time link that makes someone a user\n invites [rm <name>] invites not taken up yet; rm makes a link dead\n users everyone here: keys, passkeys, tokens; then the invites\n'2247+ printf 'for admins of the forge:\n invite <name> [--ttl 7d] [--repo <owner>/<repo> --as reader|collaborator] a one-time link that makes someone a user\n invites [rm <name>] invites not taken up yet; rm makes a link dead\n users everyone here: keys, passkeys, tokens; then the invites\n motd [<text>|--clear|--default] the line under the header on the home page: print it, set it (72 characters at most), hide it, or go back to the default\n'22102248 fi22112249 ;;22122250 esac@@ -3769,6 +3807,7 @@ server)
37693807 ;;37703808 user) cmd_user "$@" ;;37713809 users) [ $# -eq 0 ] || die "usage: forge users"; load_config; node "$FORGE_HOME/lib/identity.ts" users ;;3810+motd) load_config; cmd_motd "$@" ;;37723811 readers | collaborators) cmd_names "$cmd" "$@" ;;37733812 keys) [ "${1:-}" = sync ] || die "usage: forge keys sync"; cmd_keys_sync ;;37743813 repo) cmd_repo "$@" ;;@@ -0,0 +1,36 @@
1+# An admin sets the home page's motd over ssh: motd <text> sets it (trimmed,2+# escaped on the page) and rebuilds the index, motd prints it, --clear3+# hides the line, --default brings the default back. The operator's forge4+# motd does the same, and backups carry it.5+. "$(dirname "$0")/../lib.sh"6+user cat7+repo cat/notes8+git config --file "$FORGE_ROOT/.gitconfig" forge.admins cat9+export FORGE_STRICT=110+build11+i=$FORGE_WWW/index.html12+has "$i" '<p class="motd">a personal forge on plain git</p>'13+ok ssh_as cat motd14+eq "$(output)" "a personal forge on plain git15+(the default; motd <text> sets another)"16+17+sync_builds18+ok ssh_as cat motd ' notes <b>&</b> things '19+has "$T_LOG" '^the home page says: notes <b>&</b> things$'20+has "$i" '<p class="motd">notes <b>&</b> things</p>'21+ok ssh_as cat motd22+eq "$(output)" 'notes <b>&</b> things'23+ok forge backup --dry-run24+has "$FORGE_ROOT/.forge/backup/stage/.forge/motd" '^notes <b>&</b> things$'25+26+ok ssh_as cat motd --clear27+lacks "$i" 'class="motd"'28+ok ssh_as cat motd29+eq "$(output)" "(none: the home page shows no line; motd <text> sets one)"30+31+ok forge motd "on the server"32+has "$i" '<p class="motd">on the server</p>'33+ok forge motd --default34+has "$i" '<p class="motd">a personal forge on plain git</p>'35+missing "$FORGE_ROOT/.forge/motd"36+fails_with '^forge: usage: motd \[<text>\|--clear\|--default\]$' ssh_as cat motd --clear now@@ -0,0 +1,29 @@
1+# To anyone who isn't an admin, ssh motd is an unknown command: the same2+# output and exit status as one that doesn't exist, nothing changed, and3+# not in their help. An admin's help lists it.4+. "$(dirname "$0")/../lib.sh"5+user cat6+user bob7+repo bob/stuff8+git config --file "$FORGE_ROOT/.gitconfig" forge.admins cat9+# as user word args...: output and exit status, the word written WORD.10+as() {11+ u=$1 w=$212+ shift 213+ rc=014+ ssh_as "$u" "$w" "$@" >"$T/out.$w" 2>&1 || rc=$?15+ printf 'exit %s\n' "$rc" >>"$T/out.$w"16+ sed "s/$w/WORD/g" "$T/out.$w"17+}18+for args in "motd" "motd hello there" "motd --clear" "motd --default"; do19+ set -- $args20+ shift21+ eq "$(as bob motd "$@")" "$(as bob nosuch "$@")"22+done23+eq "$(as bob motd)" "fatal: unrecognized command 'WORD'24+exit 128"25+missing "$FORGE_ROOT/.forge/motd"26+ok ssh_as bob help27+lacks "$T_LOG" 'motd'28+ok ssh_as cat help29+has "$T_LOG" '^ motd \[<text>\|--clear\|--default\] '@@ -0,0 +1,17 @@
1+# A motd is one line of plain text, 72 characters at most (characters, not2+# bytes): a longer one, an empty one or one with a control character is3+# refused, saying why, and the page keeps the one it had.4+. "$(dirname "$0")/../lib.sh"5+user cat6+git config --file "$FORGE_ROOT/.gitconfig" forge.admins cat7+long=$(printf 'x%.0s' $(seq 73))8+fails_with '^forge: motd: 73 characters; it can have 72 at most$' ssh_as cat motd "$long"9+missing "$FORGE_ROOT/.forge/motd"10+ok ssh_as cat motd "$(printf 'x%.0s' $(seq 72))"11+ok ssh_as cat motd "$(printf 'é%.0s' $(seq 72))"12+fails_with '72 at most' ssh_as cat motd "$(printf 'é%.0s' $(seq 73))"13+has "$FORGE_ROOT/.forge/motd" "^$(printf 'é%.0s' $(seq 72))\$"14+fails_with 'without control characters' forge motd "$(printf 'one\ntwo')"15+fails_with 'without control characters' forge motd "$(printf 'a\tb')"16+fails_with 'empty; motd --clear shows no line' ssh_as cat motd " "17+has "$FORGE_ROOT/.forge/motd" "^$(printf 'é%.0s' $(seq 72))\$"3/3 /login/'s steps in panels, named "forge" in lowercase
The sign-in page's headings were sentence case and named the domain: "Join src.rest", "Add a passkey", then "You are fox on src.rest" and "For fox on src.rest" under it. Now each step is a ui.panel whose head strip names it in lowercase chrome, with the forge's name: sign in, join forge, add a passkey for fox, signed in as fox. The passkey step says who it is for once, in its head strip. ssh commands on the page still show the real host, to copy.
| M | lib/ | 2 | +- |
| M | site/ | 44 | +++++++++++++++--------------- |
| M | site/ | 3 | +- |
| M | test/ | 2 | +- |
| A | test/ | 33 | +++++++++++++++++++++++ |
@@ -280,7 +280,7 @@ export function privateHandler(cfg: PrivateConfig): http.RequestListener {
280280 const who = gate.bySession(cookieValue(req.headers.cookie));281281 const html = render('login.njk', {282282 p: { title: `Sign in - ${cfg.name ?? 'forge'}` }, user: who?.user ?? '', csrf: who?.secret ? csrfToken(who.secret) : '',283- signal: who ? signalJson(who.user) : '', next: safeNext(url.searchParams.get('next')), ready: !!login, host: rp?.id ?? '',283+ signal: who ? signalJson(who.user) : '', next: safeNext(url.searchParams.get('next')), ready: !!login,284284 });285285 res.writeHead(200, { ...PAGE_HEADERS, ...cookie(who) }).end(html);286286 }@@ -2,8 +2,10 @@
22 sign in with a passkey, add one from the link that ssh <host> passkey33 prints (/login/#enroll=<code>; login.ts does both), join from the link44 forge user invite prints (/login/#invite=<code>: an SSH key, then a5- passkey), and, signed in, sign out. Data: forge, p (title), user (or ''), csrf, signal (JSON or ''),6- next, ready (sign-in is set up: FORGE_BASE_URL), host. -#}5+ passkey), and, signed in, sign out. Each step is a panel named in6+ lowercase for the forge (FORGE_NAME), not its domain; ssh commands show7+ the real host, to copy. Data: forge, p (title), user (or ''), csrf,8+ signal (JSON or ''), next, ready (sign-in is set up: FORGE_BASE_URL). -#}79 {% extends "base.njk" %}810 {% import "ui.njk" as ui %}911 {% block scripts %}@@ -14,44 +16,42 @@
1416 {% endblock %}1517 {% block content %}1618 {% css %}17-/* the sign-in page */19+/* the sign-in page: a panel for each step, the status and the help under them */1820 .login { max-width: 40em; }19-.login h1 { font-size: var(--text-xl); margin: 0 0 var(--space-3); }20-.login-status { min-height: 1.4em; }21+.login-status { min-height: 1.4em; margin: var(--space-3) 0 0; }2122 .login-status.bad { color: var(--fail); }2223 .login form { display: inline; }2324 .login .input { max-width: 36em; }2425 {% endcss %}25-<div class="panel login" id="login" data-next="{{ next }}"{% if signal %} data-signal="{{ signal }}"{% endif %}><div class="panel-body">26+<div class="login" id="login" data-next="{{ next }}"{% if signal %} data-signal="{{ signal }}"{% endif %}>2627 {% if user %}27-<h1>Signed in as {{ user }}</h1>28+{% set head %}<h2>signed in as <b>{{ user }}</b></h2>{% endset %}29+{% call ui.panel(head) %}<div class="panel-body">2830 <p>You see the pages of the private repos you can read: <a href="/private/">your private repos</a>.</p>2931 <form method="post" action="/login/logout"><input type="hidden" name="csrf" value="{{ csrf }}">{{ ui.button('sign out', false, 'id="signout"', 'submit') }}</form>32+</div>{% endcall %}3033 {% elif not ready %}31-<h1>Sign in</h1>34+{% call ui.panel('<h2>sign in</h2>') %}<div class="panel-body">3235 <p>Sign-in isn't set up here: the forge needs <code>FORGE_BASE_URL</code>, the origin passkeys belong to.</p>36+</div>{% endcall %}3337 {% else %}34-<div id="signin-part">35-<h1>Sign in</h1>38+{% call ui.panel('<h2>sign in</h2>', '', 'id="signin-part"') %}<div class="panel-body">3639 <p>Private repos show their pages to their readers. Sign in with a passkey you have added to see them.</p>3740 <p>{{ ui.button('sign in with a passkey', true, 'id="signin"') }}</p>38-</div>39-<div id="invite-part" hidden>40-<h1>Join {{ host }}</h1>41-<div id="invite-form">41+</div>{% endcall %}42+{% set head %}<h2>join {{ forge.config.name }}</h2>{% endset %}43+{% call ui.panel(head, '', 'id="invite-part" hidden') %}<div class="panel-body" id="invite-form">4244 <p>You are invited as <b id="invite-user">you</b><span id="invite-grant"></span>. Your SSH key is how you get in: paste the public half, the one line of a <code>.pub</code> file such as <code>~/.ssh/id_ed25519.pub</code>. None yet? <code>ssh-keygen -t ed25519</code> makes one.</p>4345 <p>{{ ui.textarea('ssh public key', 'ssh-ed25519 AAAA... you@laptop', 'id="invite-key" rows="3" spellcheck="false" autocomplete="off" autocapitalize="off" maxlength="8192"', true) }}</p>4446 <p>{{ ui.button('join', true, 'id="invite-join"') }}</p>45-</div>46-</div>47-<div id="enroll-part" hidden>48-<h1>Add a passkey</h1>49-<p id="joined" hidden>You are <b id="joined-user">in</b> on {{ host }}: <code>ssh {{ forge.config.ssh }} help</code> says what you can do over ssh. A passkey signs you in on the web as well, to see the pages of private repos you may read. Your browser, phone or password manager keeps it. Not now? <code>ssh {{ forge.config.ssh }} passkey</code> prints a link for one later.</p>50-<p id="enroll-for">For <b id="enroll-user">you</b> on {{ host }}. Your browser, phone or password manager keeps it, and signs you in with it from now on.</p>47+</div>{% endcall %}48+{% call ui.panel('<h2>add a passkey for <b id="enroll-user">you</b></h2>', '', 'id="enroll-part" hidden') %}<div class="panel-body">49+<p id="joined" hidden>You are in: <code>ssh {{ forge.config.ssh }} help</code> says what you can do over ssh. A passkey signs you in on the web as well, to see the pages of private repos you may read. Not now? <code>ssh {{ forge.config.ssh }} passkey</code> prints a link for one later.</p>50+<p>Your browser, phone or password manager keeps it, and signs you in with it from now on.</p>5151 <p>{{ ui.button('add a passkey', true, 'id="enroll"') }}</p>52-</div>52+</div>{% endcall %}5353 <p class="login-status" id="login-status" role="status"></p>5454 <p class="desc" id="login-help">No passkey yet, or lost them all? <code>ssh {{ forge.config.ssh }} passkey</code> prints a link that adds one: your ssh key is how you get in.</p>5555 {% endif %}56-</div></div>56+</div>5757 {% endblock %}@@ -141,9 +141,8 @@ interface WebAuthn {
141141 history.replaceState(null, '', '/login/');142142 say('');143143 document.getElementById('invite-part')!.hidden = true;144- document.getElementById('joined-user')!.textContent = String(done.user);144+ document.getElementById('enroll-user')!.textContent = String(done.user);145145 document.getElementById('joined')!.hidden = false;146- document.getElementById('enroll-for')!.hidden = true;147146 if (wa.browserSupportsWebAuthn()) startEnroll(String(done.enroll));148147 else {149148 document.getElementById('enroll-part')!.hidden = false;@@ -26,7 +26,7 @@ http -s -D "$T/h" -o "$T/login" -H "Cookie: __Host-forge=$c" "$PRIV/login/"
2626 lacks "$T/h" '^Set-Cookie'2727 has "$T/h" "^Content-Security-Policy: default-src 'self'"2828 has "$T/h" '^Cache-Control: private, no-store'29-has "$T/login" 'Signed in as dami'29+has "$T/login" '<h2>signed in as <b>dami</b></h2>'3030 signal=$(sed -n 's/.*data-signal="\([^"]*\)".*/\1/p' "$T/login" | sed 's/"/"/g')3131 for k in one two; do3232 id=$(node -e 'console.log(JSON.parse(require("fs").readFileSync(process.argv[1])).id)' "$T/$k.key")@@ -0,0 +1,33 @@
1+# /login/'s steps (sign in, join from an invite, add a passkey, signed in)2+# are panels whose head strips are lowercase chrome naming the forge by its3+# name, never its domain; ssh commands on the page show the real host, to4+# copy. The passkey step names who it is for once, in its head strip.5+. "$(dirname "$0")/../lib.sh"6+user dami7+export FORGE_BASE_URL=https://forge.test FORGE_SSH=git@forge.test8+private_server9+# heads file: the head strips of the page, one per line.10+heads() { grep -o '<header class="panel-head">.*</header>' "$1" | sed 's/<[^>]*>//g'; }11+http -s "$PRIV/login/" >"$T/page"12+eq "$(heads "$T/page")" "sign in13+join forge14+add a passkey for you"15+has "$T/page" '<section class="panel" id="invite-part" hidden><header class="panel-head"><h2>join forge</h2></header>'16+has "$T/page" '<section class="panel" id="enroll-part" hidden><header class="panel-head"><h2>add a passkey for <b id="enroll-user">you</b></h2></header>'17+has "$T/page" '<code>ssh git@forge\.test passkey</code>'18+lacks "$T/page" 'For <b|on forge\.test'19+heads "$T/page" | grep -q 'forge\.test' && fail "a heading names the domain"20+heads "$T/page" | grep -q '[A-Z]' && fail "a heading has capitals"21+22+code=$(ssh_as dami passkey | sed -n 's/^ .*#enroll=//p')23+ok node "$T_HOME/test/authenticator.ts" enroll "$PRIV" "$code" "$T/dami.key"24+c=$(node "$T_HOME/test/authenticator.ts" login "$PRIV" "$T/dami.key")25+http -s -H "Cookie: __Host-forge=$c" "$PRIV/login/" >"$T/in"26+eq "$(heads "$T/in")" "signed in as dami"27+28+# Without sign-in set up, still a panel.29+stop_servers30+unset FORGE_BASE_URL31+private_server32+http -s "$PRIV/login/" >"$T/off"33+eq "$(heads "$T/off")" "sign in"