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.- commit
- 38873d01038d33e362cc24c6ba0c4dbf0733a257
- parent
- a92a26928f539747da142a4a61470cc01ea3182a
- author
- cat <admin@dev.site>
- ci
- ✓ passed in 86s, signed by cat
- kind
- feat risk score 0.8 of 3, Jev's estimate
- review
- #21 A home page of profiles, recent and topics; motd for admins; /login/ in panels
ci attestation
forge-attestation 1 type ci commit 38873d01038d33e362cc24c6ba0c4dbf0733a257 tree d9cc1852fe205797e1e81a0dfc58465c0d220aa9 script .forge/ci 2dbcda6e044dc4b05503abde8369e2528413d3c7 result pass exit 0 started 2026-10-19T04:43:51Z duration 86s runner (not shown here; refs/notes/ci has it) system Darwin 25.5.0 arm64 env host log sha256:f613a97fec5ceca5393c792d0b7458ead71b3a3a65bc488bfb9283b0dc8cb752 282 lines |pushed wip/i18n |de82f46 running .forge/ci: greet.sh: say what it is | | sh: /bin/sh | | ok greet |de82f46 pass in 0s, signed |pushed main |remote: review #4: /dami/greet/reviews/4/ |pushed wip/i18n |63c302e running .forge/ci: wave at the world | | sh: /bin/sh | | FAIL greet : got 'hello, world o/', want 'hello, world' |63c302e fail in 0s, signed |forge: 1 commit(s) did not pass |remote: review #6: /dami/greet/reviews/6/ |pushed ana/wave |290b5e8 running .forge/ci: update the test for the wave | | sh: /bin/sh | | ok greet |290b5e8 pass in 0s, signed |remote: review #6: /dami/greet/reviews/6/ |pushed ana/wave |warning: You appear to have cloned an empty repository. |site home: deployed ebc4321 -> /sites/ana/dotfiles/home/ |created ana/notes |remote: review #1: /ana/notes/reviews/1/ |pushed ana/todo |landing #1 A todo list: ana/todo -> main |b211f9f running .forge/ci: web, server and Python versions of greet | | sh: /bin/sh | | ok greet |b211f9f pass in 0s, signed |pushed main |f34d561 running .forge/ci: messages: notes for the later regions | | sh: /bin/sh | | ok greet |f34d561 pass in 0s, signed |remote: review #7: /dami/greet/reviews/7/ |pushed dami/regions |created dami/forge |warning: You appear to have cloned an empty repository. |site guide: mkdir -p guide/fonts && cp site/assets/fonts/*.woff2 guide/fonts/ |site guide: deployed 607dcb7 -> /sites/dami/forge/guide/ |(node:82000) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time |(Use `node --trace-warnings ...` to show where the warning was created) |built ana/dotfiles in 1s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.lyM7Pj/wt/demo/root/.forge/www |(node:82155) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time |(Use `node --trace-warnings ...` to show where the warning was created) |built ana/notes in 0s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.lyM7Pj/wt/demo/root/.forge/www |(node:82325) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time |(Use `node --trace-warnings ...` to show where the warning was created) |built dami/forge in 1s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.lyM7Pj/wt/demo/root/.forge/www |(node:82485) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time |(Use `node --trace-warnings ...` to show where the warning was created) |built dami/greet in 2s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.lyM7Pj/wt/demo/root/.forge/www |(node:83205) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time |(Use `node --trace-warnings ...` to show where the warning was created) |built - in 0s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.lyM7Pj/wt/demo/root/.forge/www |235 of 235 pages render the same without 11ty |guide: 8 files, links ok |ok
Signature: good, cat
| 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))\$"