clone

a personal forge on plain git: static pages, stacked-diff reviews, signed local ci, repo sites and releases

A home page of profiles, recent and topics; motd for admins; /login/ in panels #21

28 files changed, +762 -57
MDESIGN.md6+-
MREADME.md34++++++-
Mbin/forge62+++++++++--
Mlib/forge.ts47++++++++-
Alib/motd.ts49++++++++
Mlib/private-server.ts2+-
Alib/recent.ts175++++++++++++++++++++++++++++++
Mlib/render-page.ts5+-
Mlib/reviews.ts5+-
Msite/_includes/login.njk44++++----
Msite/_includes/macros.njk2+-
Msite/assets/login.ts3+-
Msite/assets/style.css7+
Msite/assets/ui.ts6+-
Msite/index.njk95++++++++++++++++-
Asite/topics.njk16+++
Atest/scenarios/home-profiles.sh35++++++
Atest/scenarios/home-recent.sh58++++++++++
Atest/scenarios/home-topics.sh37++++++
Mtest/scenarios/identity-sessions.sh2+-
Mtest/scenarios/insights-pages.sh2+-
Atest/scenarios/login-headings.sh33++++++
Atest/scenarios/motd-admin.sh36++++++
Atest/scenarios/motd-hidden.sh29+++++
Atest/scenarios/motd-limits.sh17+++
Mtest/scenarios/panels-shelves.sh4+-
Mtest/scenarios/shelves-site-toggle.sh4+-
Mtest/scenarios/shelves.sh4+-
M DESIGN.md +4 -2
M README.md +33 -1
@@ -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,7 +1339,26 @@ first build takes minutes.
13261339 13271340 ## Reading pages13281341 1329-The index and each owner's page show repos as rows on shelves by category,1342+The home page has the motd (above), then three panels. **profiles**: each1343+owner with public repos, how many and when they were last active.1344+**recent**: the last eight things that happened on public repos, newest1345+first: landed reviews, pushes to a default branch, releases (a tag or1346+release pushed) and site deploys, each linking to what it names1347+(`lib/recent.ts`). Landed reviews come from the review logs; the rest1348+from `.forge/events.jsonl`, whose push lines carry the default branch's new1349+tip as `head`. A push whose head is a review's tip is that review landing,1350+and shows once, as landed. Every build redoes the home page, and open1351+copies of it swap in the new one when the build is done, so the list is1352+as fresh as the last push; its times stay relative in the browser. The1353+events log is cut and left out of backups, so after a restore the list has1354+only landed reviews until the next pushes. **topics**: the shelves (below)1355+across every public repo; one owner's links to that shelf on their page1356+(`/dami/#games`, which opens on its tab), and one that several owners1357+share to `/topics/#games`, every public repo on its shelf, each naming its1358+owner. Private repos, and owners with only private ones, are never on1359+any of it.1360+1361+Each owner's page, and `/topics/`, show repos as rows on shelves by category,13301362 newest first. A repo's category is one line in the bare repo's `category`13311363 file, next to `description` (`printf 'games\n' > /srv/git/dami/greet.git/category`).13321364 A repo without one goes on the shelf for what Jev says it is (a web app, a
M bin/forge +52 -10
@@ -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; }
@@ -1020,7 +1021,7 @@ load_config() {
10201021 10211022 valid_name() {10221023 case $1 in1023- '' | .* | *[!A-Za-z0-9._-]* | *.git | assets | sites | previews | hosts | insights | design | git-shell-commands) return 1 ;;1024+ '' | .* | *[!A-Za-z0-9._-]* | *.git | assets | sites | previews | hosts | insights | design | topics | git-shell-commands) return 1 ;;10241025 esac10251026 }10261027
@@ -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
@@ -2636,13 +2674,13 @@ noreview
26362674 DEFAULT=$first26372675 fi26382676 fi2639- PUSHED= rel=02677+ PUSHED= rel=0 head=26402678 while read -r old new ref; do26412679 case $ref in refs/heads/* | refs/tags/* | refs/sites/* | refs/releases/*) PUSHED="$PUSHED $ref" ;; esac26422680 case $ref in refs/tags/* | refs/releases/*) rel=1 ;; esac26432681 [ "$new" != "$ZERO" ] || case $ref in refs/heads/*) drop_previews "${ref#refs/heads/}" ;; esac26442682 case $ref in2645- "refs/heads/$DEFAULT") ;;2683+ "refs/heads/$DEFAULT") [ "$new" = "$ZERO" ] || head=$new ;;26462684 refs/heads/*) [ "$noreview" = 1 ] || record_review "${ref#refs/heads/}" "$new" ;;26472685 refs/sites/*) push_site "sites/$OWNER/$NAME/${ref#refs/sites/}" "$new" ;;26482686 refs/site-previews/*)
@@ -2658,7 +2696,10 @@ noreview
26582696 if [ "$rel" = 1 ]; then release_latest "$(pages_root "$GIT_DIR")"; fi26592697 git update-server-info26602698 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"2699+ # head: the default branch's new tip, for the index's recent list (lib/recent.ts).2700+ hf=2701+ [ -z "$head" ] || hf=",\"head\":\"$head\""2702+ 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"26622703 rebuild "$OWNER/$NAME" push26632704 }26642705
@@ -2695,7 +2736,7 @@ build_all() {
26952736 [ -d "$w" ] || continue26962737 slug=${w#"$FORGE_WWW"/}26972738 slug=${slug%/}2698- case $slug in assets/* | sites/* | previews/* | hosts/* | insights/* | design/*) continue ;; esac2739+ case $slug in assets/* | sites/* | previews/* | hosts/* | insights/* | design/* | topics/*) continue ;; esac26992740 [ -f "$FORGE_ROOT/$slug.git/HEAD" ] && [ -f "$FORGE_ROOT/$slug.git/git-daemon-export-ok" ] || rm -rf "$w"27002741 done27012742 for w in "$FORGE_WWW"/sites/*/*/ "$FORGE_WWW"/previews/*/*/; do
@@ -2706,7 +2747,7 @@ build_all() {
27062747 done27072748 for w in "$FORGE_WWW"/*/; do27082749 b=$(basename "$w")2709- case $b in assets | sites | previews | hosts | insights | design) continue ;; esac2750+ case $b in assets | sites | previews | hosts | insights | design | topics) continue ;; esac27102751 [ -d "$FORGE_ROOT/$b" ] || rm -rf "$w"27112752 done27122753 if [ -d "$FORGE_WWW/hosts" ]; then node "$FORGE_HOME/lib/hosts.ts" prune; fi
@@ -3766,6 +3807,7 @@ server)
37663807 ;;37673808 user) cmd_user "$@" ;;37683809 users) [ $# -eq 0 ] || die "usage: forge users"; load_config; node "$FORGE_HOME/lib/identity.ts" users ;;3810+motd) load_config; cmd_motd "$@" ;;37693811 readers | collaborators) cmd_names "$cmd" "$@" ;;37703812 keys) [ "${1:-}" = sync ] || die "usage: forge keys sync"; cmd_keys_sync ;;37713813 repo) cmd_repo "$@" ;;
M lib/forge.ts +44 -3
@@ -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[] }[] {
A lib/motd.ts +49 -0
@@ -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+}
M lib/private-server.ts +1 -1
A lib/recent.ts +175 -0
@@ -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+}
M lib/render-page.ts +3 -2
M lib/reviews.ts +3 -2
M site/_includes/login.njk +22 -22
@@ -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 %}
M site/_includes/macros.njk +1 -1
M site/assets/login.ts +1 -2
M site/assets/style.css +7 -0
M site/assets/ui.ts +5 -1
M site/index.njk +93 -2
@@ -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 %}
A site/topics.njk +16 -0
A test/scenarios/home-profiles.sh +35 -0
@@ -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'
A test/scenarios/home-recent.sh +58 -0
@@ -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 &lt;b&gt;the&lt;/b&gt; 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>'
A test/scenarios/home-topics.sh +37 -0
@@ -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>'
M test/scenarios/identity-sessions.sh +1 -1 mechanical
M test/scenarios/insights-pages.sh +1 -1
A test/scenarios/login-headings.sh +33 -0
@@ -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"
A test/scenarios/motd-admin.sh +36 -0
A test/scenarios/motd-hidden.sh +29 -0
A test/scenarios/motd-limits.sh +17 -0
M test/scenarios/panels-shelves.sh +2 -2
M test/scenarios/shelves-site-toggle.sh +2 -2
M test/scenarios/shelves.sh +2 -2