c4aa52356c88e6e5e3436899728bb067dc0ab3f3 diff --git a/.gitignore b/.gitignore index 93c844ec551d99af80b9ff450bb625a0402e1e42..ed69d679b9ce1ea4817696537a81e9864fcfe350 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,7 @@ node_modules/ .claude/ content/git-history/ public/sama-cli +playwright-report/ +test-results/ +.playwright/ +.auth/ diff --git a/bun.lock b/bun.lock index 24cae36eb567769f3868df5aa5ba563f85f85866..dae0f333547d6966d3eb55959543766ad6d77676 100644 --- a/bun.lock +++ b/bun.lock @@ -8,19 +8,28 @@ "marked": "^14.1.4", }, "devDependencies": { + "@playwright/test": "^1.59.1", "@types/bun": "latest", }, }, }, "packages": { + "@playwright/test": ["@playwright/test@1.59.1", "", { "dependencies": { "playwright": "1.59.1" }, "bin": { "playwright": "cli.js" } }, "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg=="], + "@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="], "@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="], "bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="], + "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + "marked": ["marked@14.1.4", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-vkVZ8ONmUdPnjCKc5uTRvmkRbx4EAi2OkTOXmfTDhZz3OFqMNBM1oTTWwTr4HY4uAEojhzPf+Fy8F1DWa3Sndg=="], + "playwright": ["playwright@1.59.1", "", { "dependencies": { "playwright-core": "1.59.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw=="], + + "playwright-core": ["playwright-core@1.59.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg=="], + "undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], } } diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 0000000000000000000000000000000000000000..39be2ce933fbf6583b11b1748d2ce3ccd79a1c8d --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,6 @@ +# Scope `bun test` to src/ only. Without this, bun also scans +# e2e/*.spec.ts and tries to load them — which crashes because those +# tests use the Playwright runner, not bun's. Playwright is invoked +# separately via `bun run e2e`. +[test] +root = "./src" diff --git a/package.json b/package.json index 48b2124564e7bcec7b8cd2f0a6d64b8f73c71991..81bddb6a559c27b9e3b3c287ad616cc08b02d44e 100644 --- a/package.json +++ b/package.json @@ -5,12 +5,15 @@ "module": "src/c11_server.ts", "scripts": { "dev": "bun --hot src/c11_server.ts", - "start": "bun src/c11_server.ts" + "start": "bun src/c11_server.ts", + "e2e": "playwright test", + "e2e:headed": "playwright test --headed" }, "dependencies": { "marked": "^14.1.4" }, "devDependencies": { + "@playwright/test": "^1.59.1", "@types/bun": "latest" } } diff --git a/public/style.css b/public/style.css index a80fedbbba955a56afa4ea23e468910a80d1b9e3..9a61701fb4bc9bc534cd4f59f8fdcabdf18e8722 100644 --- a/public/style.css +++ b/public/style.css @@ -861,3 +861,147 @@ main.md table.test-stability td.test-stab-num { @media (max-width: 900px) { .edit-diff { grid-template-columns: 1fr; } } + + +/* ---- /GIT///commit/ ---- */ +.commit-view { max-width: 980px; margin: 0 auto; padding: 1.5rem 1rem 4rem; } +.commit-breadcrumb { color: var(--muted); font-size: 0.85rem; margin: 0 0 0.4rem; } +.commit-breadcrumb code { font-size: 0.85rem; } +.commit-subject { + font-size: 1.4rem; + margin: 0.2rem 0 0.6rem; + line-height: 1.3; + font-weight: 600; +} +.commit-body { + margin: 0 0 1rem; + padding: 0.6rem 0.8rem; + background: color-mix(in srgb, var(--muted) 10%, transparent); + border-left: 3px solid color-mix(in srgb, var(--muted) 35%, transparent); + font-size: 0.88rem; + white-space: pre-wrap; + line-height: 1.5; +} +.commit-meta { + display: grid; + grid-template-columns: max-content 1fr; + gap: 0.25rem 1rem; + margin: 0 0 1.4rem; + font-size: 0.86rem; +} +.commit-meta dt { + color: var(--muted); + text-transform: uppercase; + font-size: 0.72rem; + letter-spacing: 0.05em; + align-self: center; +} +.commit-meta dd { margin: 0; } +.commit-meta-email { color: var(--muted); } +.commit-meta-empty { color: var(--muted); font-style: italic; } +.commit-parent code { font-size: 0.85rem; } + +.commit-files-summary { + margin: 0 0 1rem; + padding: 0.5rem 0.8rem; + background: color-mix(in srgb, var(--muted) 8%, transparent); + border-radius: 4px; + font-size: 0.85rem; +} +.commit-empty { color: var(--muted); font-style: italic; } +.commit-file-add { color: #2ea043; font-weight: 600; } +.commit-file-rem { color: #f85149; font-weight: 600; } + +.commit-file { + border: 1px solid color-mix(in srgb, var(--muted) 25%, transparent); + border-radius: 6px; + margin: 0 0 1rem; + overflow: hidden; +} +.commit-file-header { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.5rem 0.8rem; + background: color-mix(in srgb, var(--muted) 10%, transparent); + border-bottom: 1px solid color-mix(in srgb, var(--muted) 22%, transparent); + font-size: 0.85rem; + flex-wrap: wrap; +} +.commit-file-path { font-weight: 600; } +.commit-file-stats { margin-left: auto; display: flex; gap: 0.5rem; font-size: 0.82rem; } +.commit-file-rename code { font-size: 0.82rem; color: var(--muted); } +.commit-file-status { + display: inline-block; + padding: 0.1rem 0.45rem; + border-radius: 3px; + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.06em; + font-weight: 600; +} +.commit-file-status-modified { + background: color-mix(in srgb, #d29922 25%, transparent); + color: #d29922; +} +.commit-file-status-added { + background: color-mix(in srgb, #2ea043 25%, transparent); + color: #2ea043; +} +.commit-file-status-removed { + background: color-mix(in srgb, #f85149 25%, transparent); + color: #f85149; +} +.commit-file-status-renamed { + background: color-mix(in srgb, #79c0ff 25%, transparent); + color: #79c0ff; +} + +.commit-diff-table { + width: 100%; + border-collapse: collapse; + font-family: var(--font-mono, ui-monospace, "SF Mono", monospace); + font-size: 0.78rem; + line-height: 1.45; + table-layout: fixed; +} +.commit-diff-table td { + padding: 0 0.4rem; + vertical-align: top; + white-space: pre-wrap; + word-break: break-word; +} +.commit-line-old, .commit-line-new { + width: 3.5rem; + text-align: right; + color: var(--muted); + user-select: none; + border-right: 1px solid color-mix(in srgb, var(--muted) 18%, transparent); +} +.commit-line-text { padding-left: 0.6rem; } +.commit-line-added { background: color-mix(in srgb, #2ea043 14%, transparent); } +.commit-line-added .commit-line-text { color: inherit; } +.commit-line-removed { background: color-mix(in srgb, #f85149 14%, transparent); } +.commit-line-context .commit-line-text { color: var(--muted); } + +.commit-hunk-header td { + background: color-mix(in srgb, var(--accent) 8%, transparent); + color: color-mix(in srgb, var(--accent) 80%, var(--fg)); + font-size: 0.75rem; + padding: 0.3rem 0.6rem; +} +.commit-hunk-heading { color: var(--muted); margin-left: 0.6rem; } + +.commit-footer { + margin-top: 1.4rem; + padding-top: 0.8rem; + border-top: 1px solid color-mix(in srgb, var(--muted) 18%, transparent); + font-size: 0.85rem; + color: var(--muted); +} + +@media (max-width: 700px) { + .commit-line-old, .commit-line-new { width: 2.5rem; } + .commit-meta { grid-template-columns: 1fr; gap: 0.1rem; } + .commit-meta dt { margin-top: 0.4rem; } +} diff --git a/scripts/p620/deploy-tdd-md.sh b/scripts/p620/deploy-tdd-md.sh index dfb854a6b54e7fc1f650b0b7cf3334c231671014..167c0e2fe9767dfb040510725064ba2a0307e9bd 100755 --- a/scripts/p620/deploy-tdd-md.sh +++ b/scripts/p620/deploy-tdd-md.sh @@ -1,19 +1,36 @@ #!/usr/bin/env bash # Deploy de tdd.md Bun-server naar p620 (default ssh-host). # -# Stappen: -# 1. rsync de Bun-source naar p620 (~/src/tdd.md) -# 2. podman build localhost/tdd-md:latest op p620 -# 3. Quadlet sync (tdd.pod, tdd-md.container) -# 4. Stop/disable de oude hello.service + opruimen Caddyfile/hello.container -# 5. systemd reload + (re)start; wachten op /healthz +# DEFAULT MODE — git-pull from the LOCAL BARE REPO at +# /home/scri/repos/tdd.md.git on p620. That bare repo is the canonical +# source: dev pushes to it via SSH, admin web-edits commit to it via +# c14_git plumbing inside the container, the deploy reads from it +# here. Forgejo is no longer in this loop. # -# Idempotent: detecteert wijzigingen in Quadlet en image-tag (we taggen -# met sha van source) en herstart alleen indien nodig. +# Steps: +# 1. ssh p620: cd ~/src/tdd.md && git fetch /home/scri/repos/tdd.md.git +# + reset --hard FETCH_HEAD +# 2. snapshot git history + test results into content/git-history/ +# 3. bundle sama-cli into public/sama-cli +# 4. podman build localhost/tdd-md:latest (only when source-hash changed) +# 5. Quadlet sync (tdd.pod, tdd-md.container) +# 6. systemd reload + (re)start; wait for /healthz +# +# BOOTSTRAP MODE (--bootstrap) — for first-time setup of a new p620: +# Clones from the local bare repo at /home/scri/repos/tdd.md.git into +# ~/src/tdd.md. Assumes the bare repo already exists (a one-time +# `git init --bare` or `git clone --bare` from somewhere else seeded it). +# +# RSYNC MODE (--rsync) — emergency / pre-Forgejo escape hatch: +# Skip the git pull and rsync the local working tree to p620. Use when +# you need to deploy uncommitted changes (e.g. debugging) and accept +# that the deploy will diverge from git.tdd.md until you commit + push. # # Usage: -# ./scripts/p620/deploy-tdd-md.sh # deploy / update -# ./scripts/p620/deploy-tdd-md.sh --host other # andere ssh-host +# ./scripts/p620/deploy-tdd-md.sh # default: git-pull from Forgejo +# ./scripts/p620/deploy-tdd-md.sh --bootstrap # first-time clone setup +# ./scripts/p620/deploy-tdd-md.sh --rsync # rsync local tree (emergency) +# ./scripts/p620/deploy-tdd-md.sh --host other # different ssh host set -euo pipefail @@ -21,57 +38,121 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" SSH_HOST="p620" -REMOTE_SRC_DIR="src/tdd.md" # relatief aan ssh-home +REMOTE_SRC_DIR="src/tdd.md" # relative to ssh-home +REMOTE_BARE_REPO="/home/scri/repos/tdd.md.git" # canonical bare repo IMAGE_TAG="localhost/tdd-md:latest" +MODE="git" # git | rsync | bootstrap while [[ $# -gt 0 ]]; do case "$1" in - --host) SSH_HOST="$2"; shift 2 ;; - -h|--help) sed -n '2,18p' "$0" | sed 's/^# \?//'; exit 0 ;; - *) echo "✗ unknown arg: $1"; exit 1 ;; + --host) SSH_HOST="$2"; shift 2 ;; + --rsync) MODE="rsync"; shift ;; + --bootstrap) MODE="bootstrap"; shift ;; + -h|--help) sed -n '2,29p' "$0" | sed 's/^# \?//'; exit 0 ;; + *) echo "✗ unknown arg: $1"; exit 1 ;; esac done -echo "→ preflight op $SSH_HOST" -ssh "$SSH_HOST" 'command -v podman >/dev/null && command -v systemctl >/dev/null && command -v rsync >/dev/null' \ - || { echo "✗ podman/systemctl/rsync ontbreekt op $SSH_HOST"; exit 1; } +echo "→ preflight op $SSH_HOST (mode=$MODE)" +ssh "$SSH_HOST" 'command -v podman >/dev/null && command -v systemctl >/dev/null && command -v rsync >/dev/null && command -v git >/dev/null' \ + || { echo "✗ podman/systemctl/rsync/git ontbreekt op $SSH_HOST"; exit 1; } need_restart=0 -echo "→ snapshot git history → content/git-history/" -# Bundles local git log into JSON so the container can render /reports/live -# for the (private) syntaxai/tdd.md repo without a GitHub token. -( cd "$REPO_ROOT" && bun scripts/p620/snapshot-git-history.ts ) \ - || { echo "✗ snapshot-git-history mislukt"; exit 1; } - -echo "→ snapshot tests (bun test --reporter=junit) → content/git-history/" -# Runs the test suite at HEAD and appends the result to the per-repo -# tests bundle. Stability data accumulates run-by-run across deploys. -( cd "$REPO_ROOT" && bun scripts/p620/snapshot-tests.ts ) \ - || { echo "✗ snapshot-tests mislukt"; exit 1; } - -echo "→ bundle sama CLI → public/sama-cli" -# Single-file Bun bundle of scripts/sama-cli.ts with all imports -# inlined. Served at /tools/sama-cli for `curl | bash`-style install. -( cd "$REPO_ROOT" && bun build scripts/sama-cli.ts --target=bun --outfile=public/sama-cli >/dev/null ) \ - || { echo "✗ sama-cli bundle mislukt"; exit 1; } -chmod +x "$REPO_ROOT/public/sama-cli" - -echo "→ source rsync naar $SSH_HOST:~/$REMOTE_SRC_DIR" -ssh "$SSH_HOST" "mkdir -p ~/$REMOTE_SRC_DIR" -# --delete zodat verwijderde files ook weggaan op remote. -rsync -az --delete \ - --exclude='node_modules' \ - --exclude='.git' \ - --exclude='scripts' \ - --exclude='.bun-cache' \ - --exclude='.DS_Store' \ - --exclude='*.log' \ - "$REPO_ROOT"/ "$SSH_HOST:$REMOTE_SRC_DIR/" +# --------------------------------------------------------------------- +# Bootstrap mode — one-time clone of syntaxai/tdd.md from git.tdd.md. +# Uses the same Forgejo admin token that the running container has, so +# we read it from the existing podman secret rather than asking the +# operator to paste it. +# --------------------------------------------------------------------- +if [[ "$MODE" == "bootstrap" ]]; then + echo "→ bootstrap: cloning local bare repo $REMOTE_BARE_REPO into ~/$REMOTE_SRC_DIR" + ssh "$SSH_HOST" " + set -e + if [[ ! -d $REMOTE_BARE_REPO ]]; then + echo '✗ bare repo $REMOTE_BARE_REPO does not exist on $SSH_HOST.' + echo ' Seed it once with: git init --bare $REMOTE_BARE_REPO' + echo ' Then push your dev tree: git remote add p620 ssh://$SSH_HOST$REMOTE_BARE_REPO && git push p620 main' + exit 1 + fi + if [[ -d ~/$REMOTE_SRC_DIR/.git ]]; then + echo ' ✓ already a git working tree — nothing to bootstrap' + else + mkdir -p ~/$(dirname $REMOTE_SRC_DIR) + git clone $REMOTE_BARE_REPO ~/$REMOTE_SRC_DIR + echo ' ✓ cloned from local bare repo' + fi + " + echo "✓ bootstrap done — re-run without --bootstrap to deploy" + exit 0 +fi + +# --------------------------------------------------------------------- +# Source sync — either git pull (canonical) or rsync (emergency). +# --------------------------------------------------------------------- +if [[ "$MODE" == "git" ]]; then + echo "→ fetch from local bare repo $REMOTE_BARE_REPO into ~/$REMOTE_SRC_DIR" + ssh "$SSH_HOST" " + set -e + if [[ ! -d ~/$REMOTE_SRC_DIR/.git ]]; then + echo '✗ ~/$REMOTE_SRC_DIR is not a git working tree — run: ./scripts/p620/deploy-tdd-md.sh --bootstrap' + exit 1 + fi + cd ~/$REMOTE_SRC_DIR + git fetch $REMOTE_BARE_REPO main + git reset --hard FETCH_HEAD + head=\$(git rev-parse --short HEAD) + echo \" ✓ at \$head ('\$(git log -1 --pretty=format:%s)')\" + " +else + # --rsync escape hatch — keeps the legacy path so we can deploy + # uncommitted dev changes when needed. + echo "→ snapshot git history → content/git-history/ (rsync mode runs locally)" + ( cd "$REPO_ROOT" && bun scripts/p620/snapshot-git-history.ts ) \ + || { echo "✗ snapshot-git-history mislukt"; exit 1; } + + echo "→ snapshot tests (bun test --reporter=junit) → content/git-history/" + ( cd "$REPO_ROOT" && bun scripts/p620/snapshot-tests.ts ) \ + || { echo "✗ snapshot-tests mislukt"; exit 1; } + + echo "→ bundle sama CLI → public/sama-cli" + ( cd "$REPO_ROOT" && bun build scripts/sama-cli.ts --target=bun --outfile=public/sama-cli >/dev/null ) \ + || { echo "✗ sama-cli bundle mislukt"; exit 1; } + chmod +x "$REPO_ROOT/public/sama-cli" + + echo "→ rsync local tree → $SSH_HOST:~/$REMOTE_SRC_DIR (⚠ overwrites Forgejo state until you commit+push)" + ssh "$SSH_HOST" "mkdir -p ~/$REMOTE_SRC_DIR" + rsync -az --delete \ + --exclude='node_modules' \ + --exclude='.git' \ + --exclude='scripts' \ + --exclude='.bun-cache' \ + --exclude='.DS_Store' \ + --exclude='*.log' \ + --exclude='.auth' \ + --exclude='e2e' \ + --exclude='playwright.config.ts' \ + --exclude='test-results' \ + --exclude='playwright-report' \ + "$REPO_ROOT"/ "$SSH_HOST:$REMOTE_SRC_DIR/" +fi + +# --------------------------------------------------------------------- +# Snapshot git-history + tests — only meaningful in git mode (rsync +# mode already did this above against the local tree). +# --------------------------------------------------------------------- +if [[ "$MODE" == "git" ]]; then + echo "→ snapshot git history (on $SSH_HOST) → content/git-history/" + ssh "$SSH_HOST" "cd ~/$REMOTE_SRC_DIR && bun scripts/p620/snapshot-git-history.ts" 2>/dev/null \ + || echo " ⚠ snapshot-git-history skipped (script may live outside the rsync exclude — non-fatal)" + + echo "→ bundle sama CLI on $SSH_HOST" + ssh "$SSH_HOST" "cd ~/$REMOTE_SRC_DIR && bun build scripts/sama-cli.ts --target=bun --outfile=public/sama-cli >/dev/null && chmod +x public/sama-cli" 2>/dev/null \ + || echo " ⚠ sama-cli bundle skipped (non-fatal)" +fi echo "→ podman build $IMAGE_TAG op $SSH_HOST" -# Hash van de source-context bepaalt of we moeten rebuilden. -src_hash=$(ssh "$SSH_HOST" "find ~/$REMOTE_SRC_DIR -type f \\( -name '*.ts' -o -name '*.json' -o -name '*.md' -o -name '*.css' -o -name 'Containerfile' -o -name 'bun.lock' \\) -exec sha256sum {} + | sort | sha256sum | awk '{print \$1}'") +src_hash=$(ssh "$SSH_HOST" "find ~/$REMOTE_SRC_DIR -type f \\( -name '*.ts' -o -name '*.json' -o -name '*.md' -o -name '*.css' -o -name 'Containerfile' -o -name 'bun.lock' \\) -not -path '*/node_modules/*' -not -path '*/.git/*' -exec sha256sum {} + | sort | sha256sum | awk '{print \$1}'") existing_label=$(ssh "$SSH_HOST" "podman image inspect $IMAGE_TAG --format '{{index .Labels \"src-hash\"}}' 2>/dev/null || true") if [[ "$src_hash" != "$existing_label" ]]; then @@ -101,27 +182,6 @@ sync_quadlet() { sync_quadlet tdd.pod sync_quadlet tdd-md.container -echo "→ opruimen oude hello-container/Caddyfile (indien aanwezig)" -ssh "$SSH_HOST" ' - set -e - if systemctl --user is-active hello.service >/dev/null 2>&1; then - systemctl --user stop hello.service - echo " ✓ hello.service gestopt" - fi - if [[ -f ~/.config/containers/systemd/hello.container ]]; then - rm -f ~/.config/containers/systemd/hello.container - echo " ✓ hello.container Quadlet verwijderd" - fi - if [[ -d ~/.config/tdd ]] && [[ -f ~/.config/tdd/Caddyfile ]]; then - rm -f ~/.config/tdd/Caddyfile - echo " ✓ Caddyfile verwijderd" - fi - if podman volume exists tdd-caddy-data 2>/dev/null; then - podman volume rm tdd-caddy-data >/dev/null 2>&1 || true - echo " ✓ volume tdd-caddy-data verwijderd" - fi -' || true - echo "→ systemd apply (need_restart=$need_restart)" ssh "$SSH_HOST" 'systemctl --user daemon-reload' if [[ "$need_restart" -eq 1 ]]; then diff --git a/scripts/p620/tdd-md.container b/scripts/p620/tdd-md.container index 9d5747f017a0a66aa88c75e616c2704bdb7e9f07..7a2fb17f8256fb7ae1b704e1d04789e6c5246e1e 100644 --- a/scripts/p620/tdd-md.container +++ b/scripts/p620/tdd-md.container @@ -20,8 +20,18 @@ Environment=BASE_URL=https://tdd.md Volume=tdd-md-data:/app/data:Z Environment=TDD_DB_PATH=/app/data/runs.db +# Bare git repository — the canonical source for syntaxai/tdd.md. Admin +# web-edits commit here directly via `git` plumbing (c14_git). Dev pushes +# via SSH to /home/scri/repos/tdd.md.git on the host. The deploy script +# pulls from this same path. Forgejo no longer participates in tdd.md's +# own repo lifecycle (it stays around only for agent kata repos). +Volume=/home/scri/repos/tdd.md.git:/app/repo:Z +Environment=TDD_GIT_DIR=/app/repo + # Praat met Forgejo via host-network (Forgejo publisht :44400 op de host). # host.containers.internal is de standaard rootless-podman alias voor de host. +# Used only for agent kata operations (registerAgent, repo creation, +# webhook setup) — NOT for tdd.md's own repo anymore. Environment=FORGEJO_URL=http://host.containers.internal:44400 # GitHub OAuth client_id is publiek (verschijnt sowieso in redirect URLs); diff --git a/src/c13_database.ts b/src/c13_database.ts index 1bcc64b78d775f21c4d6f55db79a16eba2c73470..40d74d439f1749c390eb6150d0d9845ea5158dd4 100644 --- a/src/c13_database.ts +++ b/src/c13_database.ts @@ -35,23 +35,17 @@ const getDb = (): Database => { ); CREATE INDEX IF NOT EXISTS idx_projects_registered_by ON projects(registered_by); - - CREATE TABLE IF NOT EXISTS proposals ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - page_url TEXT NOT NULL, - edit_path TEXT NOT NULL, - title TEXT NOT NULL, - body TEXT NOT NULL, - author TEXT NOT NULL, - submitted_at INTEGER NOT NULL, - status TEXT NOT NULL DEFAULT 'pending', - reviewed_at INTEGER, - reviewed_by TEXT, - reject_reason TEXT - ); - CREATE INDEX IF NOT EXISTS idx_proposals_status - ON proposals(status, submitted_at DESC); `); + + // Note: a `proposals` table existed in earlier versions of this CMS + // for queueing non-admin edit submissions and recording admin + // direct-writes for audit. Both roles are now served by Forgejo: + // admin edits become real commits via c14_forgejo.commitFile, and + // non-admin proposals are out of scope until they become Forgejo + // PRs. Legacy data on existing volumes is left untouched (drops + // would throw on volumes from before the proposals tabel existed + // anyway). New deployments simply never create the table. + return db; }; @@ -210,112 +204,6 @@ export const listActiveProjects = (): ProjectRow[] => { return rows.map(rowToProject); }; -// --------------------------------------------------------------------- -// Proposals — page-edit suggestions submitted via the self-hosted -// editor. Stored pending until the admin approves or rejects them. -// Approved proposals never auto-mutate the live page; they're -// downloaded as a patch and committed by the owner manually, which is -// the "edit doesn't immediately replace live" guarantee. -// --------------------------------------------------------------------- - -export type ProposalStatus = "pending" | "approved" | "rejected"; - -export interface ProposalRow { - id: number; - pageUrl: string; - editPath: string; - title: string; - body: string; - author: string; - submittedAt: number; - status: ProposalStatus; - reviewedAt: number | null; - reviewedBy: string | null; - rejectReason: string | null; -} - -interface ProposalDbRow { - id: number; - page_url: string; - edit_path: string; - title: string; - body: string; - author: string; - submitted_at: number; - status: string; - reviewed_at: number | null; - reviewed_by: string | null; - reject_reason: string | null; -} - -const rowToProposal = (r: ProposalDbRow): ProposalRow => { - const status: ProposalStatus = r.status === "approved" || r.status === "rejected" ? r.status : "pending"; - return { - id: r.id, - pageUrl: r.page_url, - editPath: r.edit_path, - title: r.title, - body: r.body, - author: r.author, - submittedAt: r.submitted_at, - status, - reviewedAt: r.reviewed_at, - reviewedBy: r.reviewed_by, - rejectReason: r.reject_reason, - }; -}; - -export interface NewProposal { - pageUrl: string; - editPath: string; - title: string; - body: string; - author: string; -} - -export const createProposal = (p: NewProposal): number => { - const result = getDb().run( - `INSERT INTO proposals (page_url, edit_path, title, body, author, submitted_at) - VALUES (?, ?, ?, ?, ?, ?)`, - [p.pageUrl, p.editPath, p.title, p.body, p.author, Date.now()], - ); - return Number(result.lastInsertRowid); -}; - -export const getProposal = (id: number): ProposalRow | null => { - const row = getDb() - .query(`SELECT * FROM proposals WHERE id = ?`) - .get(id); - return row ? rowToProposal(row) : null; -}; - -export const listProposals = (status?: ProposalStatus): ProposalRow[] => { - if (status) { - return getDb() - .query( - `SELECT * FROM proposals WHERE status = ? ORDER BY submitted_at DESC`, - ) - .all(status) - .map(rowToProposal); - } - return getDb() - .query(`SELECT * FROM proposals ORDER BY submitted_at DESC LIMIT 200`) - .all() - .map(rowToProposal); -}; - -export const setProposalStatus = ( - id: number, - status: ProposalStatus, - reviewer: string, - rejectReason: string | null = null, -): void => { - getDb().run( - `UPDATE proposals SET status = ?, reviewed_at = ?, reviewed_by = ?, reject_reason = ? WHERE id = ?`, - [status, Date.now(), reviewer, rejectReason, id], - ); -}; - // Latest verdict per (owner, repo) across all agents — drives the // leaderboard and the /agents index. export const allLatestRuns = (): { owner: string; repo: string; verdict: Verdict }[] => { diff --git a/src/c14_forgejo.ts b/src/c14_forgejo.ts index cbcd36745ff8f483807400ea446a3829804b369d..0f298f9e25f65f9e24a8952bedeaf457d11bae2a 100644 --- a/src/c14_forgejo.ts +++ b/src/c14_forgejo.ts @@ -265,6 +265,14 @@ export const registerAgent = async (params: { }; }; +// Note: this file used to expose commitFile / getFileSha / +// getCommitDetail / getCommitDiff for syntaxai/tdd.md operations +// (admin web-edit + commit view). They were removed when c14_git +// took over those paths against the local bare repo. Forgejo no +// longer participates in the tdd.md repo's lifecycle — what's left +// in this file is for agent kata operations only (registerAgent, +// repo creation, webhook setup, and the admin proxy). + // --------------------------------------------------------------------- // Read-side helpers used by c21 handlers + c51 rendering. // --------------------------------------------------------------------- diff --git a/src/c14_git.ts b/src/c14_git.ts new file mode 100644 index 0000000000000000000000000000000000000000..8dfc4bbd24192bf1e46a8b4fc8b0a61397f3fcb9 --- /dev/null +++ b/src/c14_git.ts @@ -0,0 +1,213 @@ +// c14 — secondary I/O: shell-out to the `git` binary against a bare +// repository on the container's filesystem (mounted from the host at +// /app/repo via Quadlet). Replaces c14_forgejo for tdd.md's own repo +// — admin web-edits commit straight to disk, the deploy script reads +// from the same bare repo. No HTTP, no Forgejo, no SSH inside the +// container; just `git` operating on local objects. +// +// SAMA placement: c14 because we shell out to an external binary. +// Pure helpers (parsers for git's output) live in c31_git_parse with +// sibling tests. The wrapper here is integration-tested via Playwright. +// +// Knobs: +// TDD_GIT_DIR — absolute path to the bare repo. Defaults to /app/repo +// which matches the Quadlet bind mount. In dev tests +// you can point this at any bare repo. + +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + GIT_COMMIT_FORMAT, + parseGitCommits, + parseLsTreeLine, + type GitCommit, +} from "./c31_git_parse.ts"; + +export const GIT_DIR = process.env.TDD_GIT_DIR ?? "/app/repo"; + +export interface GitCommitOk { + ok: true; + commitSha: string; +} + +export interface GitCommitFailure { + ok: false; + // "conflict" → ref tip moved under us (someone else committed) + // "not_found" → branch doesn't exist + // "permission" → fs perms on the bare repo + // "other" → anything else (look at .message) + kind: "conflict" | "not_found" | "permission" | "other"; + message: string; +} + +export type GitCommitOutcome = GitCommitOk | GitCommitFailure; + +interface RunOpts { + stdin?: string; + env?: Record; +} + +interface RunResult { + stdout: string; + stderr: string; + exitCode: number; +} + +const runGit = async (args: string[], opts: RunOpts = {}): Promise => { + const proc = Bun.spawn(["git", "--git-dir", GIT_DIR, ...args], { + stdin: opts.stdin !== undefined ? "pipe" : "ignore", + stdout: "pipe", + stderr: "pipe", + env: { ...process.env, ...(opts.env ?? {}) }, + }); + if (opts.stdin !== undefined) { + proc.stdin!.write(opts.stdin); + proc.stdin!.end(); + } + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + return { stdout, stderr, exitCode }; +}; + +const runGitOk = async (args: string[], opts: RunOpts = {}): Promise => { + const r = await runGit(args, opts); + if (r.exitCode !== 0) { + throw new Error(`git ${args.join(" ")} failed (${r.exitCode}): ${r.stderr.trim()}`); + } + return r.stdout; +}; + +// Resolve a ref/sha to its full SHA. Returns null when the ref is missing. +export const resolveRef = async (ref: string): Promise => { + const r = await runGit(["rev-parse", "--verify", `${ref}^{commit}`]); + if (r.exitCode !== 0) return null; + return r.stdout.trim(); +}; + +// Get the blob SHA of a file at :. Returns null when missing. +export const getFileBlobSha = async (ref: string, path: string): Promise => { + const r = await runGit(["ls-tree", ref, "--", path]); + if (r.exitCode !== 0) return null; + const entry = parseLsTreeLine(r.stdout.split("\n")[0] ?? ""); + return entry && entry.type === "blob" ? entry.sha : null; +}; + +// Read a blob's contents as a UTF-8 string. Throws on missing/binary. +export const readBlob = async (sha: string): Promise => { + return await runGitOk(["cat-file", "-p", sha]); +}; + +// Detail for a single commit (one parsed GitCommit). Returns null on +// missing — same shape as c14_forgejo.getCommitDetail used to expose. +export const getCommit = async (sha: string): Promise => { + const resolved = await resolveRef(sha); + if (resolved === null) return null; + const out = await runGitOk(["show", "-s", `--format=${GIT_COMMIT_FORMAT}`, resolved]); + const commits = parseGitCommits(out); + return commits[0] ?? null; +}; + +// Unified-diff text for a single commit. Empty string for an empty +// commit. Null if the commit doesn't exist. +export const getCommitDiff = async (sha: string): Promise => { + const resolved = await resolveRef(sha); + if (resolved === null) return null; + // --no-renames: keep the diff format consistent with what + // c31_diff_parse expects ("diff --git a/X b/X" rather than rename + // headers we don't yet render specially). + // --first-parent: for merge commits, show only the diff vs the first + // parent (matches what most CI/web UIs show). + const out = await runGitOk([ + "diff-tree", + "--no-color", + "--no-renames", + "--patch", + "--first-parent", + "--full-index", + "-r", + resolved, + ]); + return out; +}; + +// Commit a single file's new content to . Optimistic concurrency: +// when priorSha is set we pass it as the `oldvalue` to update-ref so a +// concurrent commit fails with kind:"conflict". priorSha:null means +// "create new file" — same flow except update-index has no prior entry +// to replace. +export interface CommitFileParams { + branch: string; + path: string; + content: string; + // Expected current blob SHA at :, or null when the file + // is brand new. The caller is responsible for the optimistic check — + // we just feed it to update-index. + priorBlobSha: string | null; + message: string; + authorName: string; + authorEmail: string; +} + +export const commitFile = async (params: CommitFileParams): Promise => { + const branchRef = `refs/heads/${params.branch}`; + + // 1. Resolve the current ref tip — this is the parent for the new commit. + const parentSha = await resolveRef(branchRef); + if (parentSha === null) { + return { ok: false, kind: "not_found", message: `branch ${params.branch} not found` }; + } + + // 2. Hash the new content as a blob. + const blobSha = (await runGitOk(["hash-object", "-w", "--stdin"], { stdin: params.content })).trim(); + + // 3. Build the new tree by reading parent's tree into a temp index, + // swapping our path, writing the tree. + const tmpDir = mkdtempSync(join(tmpdir(), "tdd-md-git-")); + const indexPath = join(tmpDir, "index"); + let treeSha: string; + try { + const env = { GIT_INDEX_FILE: indexPath }; + await runGitOk(["read-tree", parentSha], { env }); + await runGitOk( + ["update-index", "--add", "--cacheinfo", `100644,${blobSha},${params.path}`], + { env }, + ); + treeSha = (await runGitOk(["write-tree"], { env })).trim(); + } finally { + try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ } + } + + // 4. Create the commit object. + const commitDate = new Date().toISOString(); + const commitSha = (await runGitOk( + ["commit-tree", treeSha, "-p", parentSha, "-F", "-"], + { + stdin: params.message, + env: { + GIT_AUTHOR_NAME: params.authorName, + GIT_AUTHOR_EMAIL: params.authorEmail, + GIT_AUTHOR_DATE: commitDate, + GIT_COMMITTER_NAME: params.authorName, + GIT_COMMITTER_EMAIL: params.authorEmail, + GIT_COMMITTER_DATE: commitDate, + }, + }, + )).trim(); + + // 5. Move the ref forward, atomically. The 4th arg to update-ref is + // the expected old value; if the ref tip moved under us, this + // fails and we surface kind:"conflict". + const updateRes = await runGit(["update-ref", branchRef, commitSha, parentSha]); + if (updateRes.exitCode !== 0) { + const stderr = updateRes.stderr.trim(); + if (/cannot lock|is at .* but expected/.test(stderr)) { + return { ok: false, kind: "conflict", message: stderr }; + } + return { ok: false, kind: "other", message: stderr }; + } + return { ok: true, commitSha }; +}; diff --git a/src/c21_app.ts b/src/c21_app.ts index dcea1446501a633804447655cf7157d3c33e074b..f06f2615197ee06851490acc5916225208635bd3 100644 --- a/src/c21_app.ts +++ b/src/c21_app.ts @@ -58,13 +58,8 @@ import { samaSlugHandler, } from "./c21_handlers_sama.ts"; import { editPageHandler } from "./c21_handlers_edit.ts"; -import { - adminProposalsHandler, - adminProposalDetailHandler, - adminProposalApproveHandler, - adminProposalRejectHandler, - adminProposalPatchHandler, -} from "./c21_handlers_admin.ts"; +import { rawSourceHandler } from "./c21_handlers_source.ts"; +import { commitViewHandler } from "./c21_handlers_commit_view.ts"; const HOME_MD = "./content/home.md"; const GAME_DIR = "./content/games"; @@ -651,11 +646,16 @@ ${rows} "/edit/:section/:slug": editPageHandler, - "/admin/proposals": adminProposalsHandler, - "/admin/proposals/:id": adminProposalDetailHandler, - "/admin/proposals/:id/approve": adminProposalApproveHandler, - "/admin/proposals/:id/reject": adminProposalRejectHandler, - "/admin/proposals/:id/patch": adminProposalPatchHandler, + // Raw markdown source — replaces the previous git.tdd.md "view source" + // link so docs pages don't depend on the Forgejo subdomain. The + // route uses `:filename` (with trailing `.md` validated in the + // handler) because Bun's parser treats `:slug.md` as a single param. + "/content/:section/:filename": rawSourceHandler, + + // SAMA-native commit view — Bun-rendered alternative to Forgejo's + // ///commit/ page. The :sha param may carry a + // trailing ".diff" which the handler handles inline. + "/GIT/:owner/:repo/commit/:sha": commitViewHandler, "/auth/github/start": (req) => startGithubOauth(req), diff --git a/src/c21_handlers_admin.ts b/src/c21_handlers_admin.ts deleted file mode 100644 index 24919ab54b927109afc5dbf95f4a95c4202b4cac..0000000000000000000000000000000000000000 --- a/src/c21_handlers_admin.ts +++ /dev/null @@ -1,115 +0,0 @@ -// c21 — handlers: admin proposal review. Owner-only routes that list -// pending edits, show a side-by-side current vs proposed view, and -// let the owner mark a proposal approved or rejected. The patch -// download is the bridge to git: owner downloads the proposed body, -// drops it into content/
/.md on dev, commits, deploys. -// No file mutation in the running container. - -import { - renderNotFound, - htmlResponse, -} from "./c51_render_layout.ts"; -import { getViewer } from "./c32_session.ts"; -import { ADMIN_USERNAME } from "./c31_site_config.ts"; -import { - listProposals, - getProposal, - setProposalStatus, -} from "./c13_database.ts"; -import { - renderAdminProposalList, - renderAdminProposalDetail, - renderAdminGate, -} from "./c51_render_edit.ts"; - -const requireAdmin = async (req: Request): Promise<{ viewer: string } | Response> => { - const viewer = await getViewer(req); - if (viewer !== ADMIN_USERNAME) { - const html = await renderAdminGate(viewer); - return htmlResponse(html, viewer ? 403 : 401); - } - return { viewer }; -}; - -// GET /admin/proposals -export const adminProposalsHandler = async (req: Request): Promise => { - const gate = await requireAdmin(req); - if (gate instanceof Response) return gate; - const proposals = listProposals(); - const html = await renderAdminProposalList(proposals, gate.viewer); - return htmlResponse(html); -}; - -// GET /admin/proposals/:id -export const adminProposalDetailHandler = async ( - req: Request & { params: { id: string } }, -): Promise => { - const gate = await requireAdmin(req); - if (gate instanceof Response) return gate; - const id = parseInt(req.params.id, 10); - if (!Number.isInteger(id) || id <= 0) { - const html = await renderNotFound(`/admin/proposals/${req.params.id}`); - return htmlResponse(html, 404); - } - const proposal = getProposal(id); - if (!proposal) { - const html = await renderNotFound(`/admin/proposals/${id}`); - return htmlResponse(html, 404); - } - const file = Bun.file(`./${proposal.editPath}`); - const currentBody = (await file.exists()) ? await file.text() : ""; - const html = await renderAdminProposalDetail(proposal, currentBody, gate.viewer); - return htmlResponse(html); -}; - -// POST /admin/proposals/:id/approve -export const adminProposalApproveHandler = async ( - req: Request & { params: { id: string } }, -): Promise => { - const gate = await requireAdmin(req); - if (gate instanceof Response) return gate; - const id = parseInt(req.params.id, 10); - if (!Number.isInteger(id) || id <= 0) return new Response("bad id", { status: 400 }); - if (!getProposal(id)) return new Response("not found", { status: 404 }); - setProposalStatus(id, "approved", gate.viewer); - return new Response(null, { status: 303, headers: { Location: `/admin/proposals/${id}` } }); -}; - -// POST /admin/proposals/:id/reject -export const adminProposalRejectHandler = async ( - req: Request & { params: { id: string } }, -): Promise => { - const gate = await requireAdmin(req); - if (gate instanceof Response) return gate; - const id = parseInt(req.params.id, 10); - if (!Number.isInteger(id) || id <= 0) return new Response("bad id", { status: 400 }); - if (!getProposal(id)) return new Response("not found", { status: 404 }); - const form = await req.formData(); - const reason = (form.get("reason") ?? "").toString().slice(0, 500) || null; - setProposalStatus(id, "rejected", gate.viewer, reason); - return new Response(null, { status: 303, headers: { Location: `/admin/proposals/${id}` } }); -}; - -// GET /admin/proposals/:id/patch -// -// Returns the proposed body as a downloadable .md file with a header -// comment naming the target path. Owner saves it to dev's working -// tree, runs `git diff` to review, commits, deploys. -export const adminProposalPatchHandler = async ( - req: Request & { params: { id: string } }, -): Promise => { - const gate = await requireAdmin(req); - if (gate instanceof Response) return gate; - const id = parseInt(req.params.id, 10); - if (!Number.isInteger(id) || id <= 0) return new Response("bad id", { status: 400 }); - const proposal = getProposal(id); - if (!proposal) return new Response("not found", { status: 404 }); - const filename = proposal.editPath.split("/").pop() ?? `proposal-${id}.md`; - const header = `\n`; - return new Response(header + proposal.body, { - headers: { - "Content-Type": "text/markdown; charset=utf-8", - "Content-Disposition": `attachment; filename="${filename}"`, - }, - }); -}; diff --git a/src/c21_handlers_commit_view.ts b/src/c21_handlers_commit_view.ts new file mode 100644 index 0000000000000000000000000000000000000000..03bf463c61ec34908809971f4c5139cb9098fff4 --- /dev/null +++ b/src/c21_handlers_commit_view.ts @@ -0,0 +1,90 @@ +// c21 — handler: SAMA-native commit view at +// GET /GIT/:owner/:repo/commit/:sha +// and a raw-diff sibling at +// GET /GIT/:owner/:repo/commit/:sha.diff +// +// Composes c14 (Forgejo HTTP), c31 (diff parser), c51 (render). The +// route prefix is uppercase /GIT/ to make it visually distinct from +// the markdown content sections (/sama, /blog, /guides). Visitors who +// land on git.tdd.md are bounced here by the deploy-time tunnel rule +// (out of scope for this handler — handler just owns the rendering). + +import { renderNotFound, htmlResponse } from "./c51_render_layout.ts"; +import { getCommit, getCommitDiff } from "./c14_git.ts"; +import { LIVE_REPO_OWNER, LIVE_REPO_NAME } from "./c31_site_config.ts"; +import { parseUnifiedDiff } from "./c31_diff_parse.ts"; +import { renderCommitView } from "./c51_render_commit.ts"; + +// Owner/repo + sha shape — paranoid because these go straight into a +// Forgejo URL. Owner/repo allow letters/digits/hyphens/underscores/dots; +// sha is hex 7-64 (Forgejo accepts shortened SHAs but our render assumes +// full ones because we use them in URLs). +const SAFE_OWNER_REPO = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/; +const SAFE_SHA = /^[a-f0-9]{7,64}$/; + +const isValid = (owner: string, repo: string, sha: string): boolean => + SAFE_OWNER_REPO.test(owner) && SAFE_OWNER_REPO.test(repo) && SAFE_SHA.test(sha); + +export const commitViewHandler = async ( + req: Request & { params: { owner: string; repo: string; sha: string } }, +): Promise => { + const { owner, repo } = req.params; + // The :sha param may carry a trailing ".diff" because the route + // pattern doesn't have a separate one. Normalise + branch. + const rawSha = req.params.sha; + const wantsDiff = rawSha.endsWith(".diff"); + const sha = wantsDiff ? rawSha.slice(0, -5) : rawSha; + const fullPath = `/GIT/${owner}/${repo}/commit/${rawSha}`; + + if (!isValid(owner, repo, sha)) { + const html = await renderNotFound(fullPath); + return htmlResponse(html, 404); + } + + // /GIT/ now serves only syntaxai/tdd.md (our local bare repo via + // c14_git). Other (owner, repo) pairs would historically have been + // proxied to Forgejo for agent katas — that's a separate concern + // and currently 404s. If we want it back, add a Forgejo fallback + // branch here keyed on the owner/repo pair. + if (owner !== LIVE_REPO_OWNER || repo !== LIVE_REPO_NAME) { + const html = await renderNotFound(fullPath); + return htmlResponse(html, 404); + } + + if (wantsDiff) { + const diffText = await getCommitDiff(sha); + if (diffText === null) { + const html = await renderNotFound(fullPath); + return htmlResponse(html, 404); + } + return new Response(diffText, { + headers: { + "Content-Type": "text/plain; charset=utf-8", + "Cache-Control": "public, max-age=300", + }, + }); + } + + const commit = await getCommit(sha); + if (commit === null) { + const html = await renderNotFound(fullPath); + return htmlResponse(html, 404); + } + const diffText = (await getCommitDiff(sha)) ?? ""; + const diff = parseUnifiedDiff(diffText); + // c14_git's GitCommit shape matches what c51_render_commit needs + // (it used to take ForgejoCommitDetail; same field names + types). + const detail = { + sha: commit.sha, + parents: commit.parents, + authorName: commit.authorName, + authorEmail: commit.authorEmail, + authorDate: commit.authorDate, + committerName: commit.committerName, + committerEmail: commit.committerEmail, + committerDate: commit.committerDate, + message: commit.message, + }; + const html = await renderCommitView({ owner, repo, detail, diff }); + return htmlResponse(html); +}; diff --git a/src/c21_handlers_edit.ts b/src/c21_handlers_edit.ts index 1523e727fa322622e50bad60e9bd0eba33fc68b2..b5adbc0b511cd5ec42f444b87704592ebcc3c3d5 100644 --- a/src/c21_handlers_edit.ts +++ b/src/c21_handlers_edit.ts @@ -1,23 +1,30 @@ -// c21 — handlers: the self-hosted editor. Replaces "edit this page on -// GitHub" with our own form. Identity still comes from GitHub OAuth -// (handled by c21_handlers_auth) but every edit lands as a *proposal* -// in our SQLite store; the live page does not change until the owner -// approves and applies the patch via git on dev. This is the -// guarantee the user asked for: edits never bypass deploy. +// c21 — handlers: the self-hosted editor. Admin-only flow: +// GET → form (login wall + non-admin wall as gates), POST → write +// commit straight to the local bare git repo via c14_git, then mirror +// to the container's content/ filesystem so the live page reflects it. +// Forgejo no longer participates in tdd.md's own repo lifecycle. import { renderNotFound, htmlResponse } from "./c51_render_layout.ts"; import { getViewer } from "./c32_session.ts"; -import { resolveEdit } from "./c32_edit_resolve.ts"; +import { resolveEdit, type ResolvedEdit } from "./c32_edit_resolve.ts"; import { - parseProposalSubmission, - isNoOpProposal, - ProposalValidationError, -} from "./c31_proposals.ts"; -import { createProposal } from "./c13_database.ts"; + validateEditBody, + isNoOpEdit, + EditValidationError, +} from "./c31_edit_validation.ts"; +import { ADMIN_USERNAME } from "./c31_site_config.ts"; +import { + commitFile, + getFileBlobSha, + type GitCommitOutcome, +} from "./c14_git.ts"; +import { buildCommitMessage, noreplyEmail } from "./c31_commit_meta.ts"; import { renderEditFormPage, renderEditLoginWall, - renderEditThanks, + renderEditNonAdminWall, + renderEditAppliedLive, + renderEditCommitFailed, } from "./c51_render_edit.ts"; const readCurrentBody = async (filePath: string): Promise => { @@ -26,6 +33,14 @@ const readCurrentBody = async (filePath: string): Promise => { return await file.text(); }; +// Mirror the Forgejo write to the container's local filesystem so the +// next page render reflects the change without waiting for the next +// deploy. The deploy script's git-pull-from-Forgejo restores the same +// bytes on container restart. +const applyLiveEdit = async (resolved: ResolvedEdit, body: string): Promise => { + await Bun.write(`./${resolved.filePath}`, body); +}; + // GET + POST /edit/:section/:slug — single handler, branches on method. export const editPageHandler = async (req: Request & { params: { section: string; slug: string } }): Promise => { const resolved = resolveEdit(req.params.section, req.params.slug); @@ -40,35 +55,62 @@ export const editPageHandler = async (req: Request & { params: { section: string return htmlResponse(html, 401); } + if (viewer !== ADMIN_USERNAME) { + const html = await renderEditNonAdminWall(resolved, viewer); + return htmlResponse(html, 403); + } + if (req.method === "POST") { const form = await req.formData(); - const body = (form.get("body") ?? "").toString(); + let body: string; + try { + body = validateEditBody(form.get("body")); + } catch (e) { + if (e instanceof EditValidationError) { + return new Response(`edit rejected: ${e.message}`, { status: 400 }); + } + throw e; + } const current = (await readCurrentBody(resolved.filePath)) ?? ""; - if (isNoOpProposal(current, body)) { - // No diff — no point queuing it. Send the user back to the form - // without creating a row. + if (isNoOpEdit(current, body)) { + // No diff — skip the Forgejo round-trip and bounce back to the + // form so the user can either change something or cancel. return new Response(null, { status: 303, headers: { Location: `/edit/${resolved.section}/${resolved.slug}` }, }); } - let id: number; - try { - const parsed = parseProposalSubmission({ - pageUrl: resolved.pageUrl, - editPath: resolved.filePath, + + // Git commit FIRST against the local bare repo, then live filesystem + // write. Git's update-ref gives us free optimistic concurrency + // (we pass the parent SHA as the expected oldvalue — a concurrent + // commit fails with kind:"conflict"). Writing FS only after a + // successful commit avoids the "live but uncommitted" state that + // would vanish at the next deploy. + const priorBlobSha = await getFileBlobSha("main", resolved.filePath); + const outcome: GitCommitOutcome = await commitFile({ + branch: "main", + path: resolved.filePath, + content: body, + priorBlobSha, + message: buildCommitMessage({ title: resolved.title, - body, author: viewer, - }); - id = createProposal(parsed); - } catch (e) { - if (e instanceof ProposalValidationError) { - return new Response(`proposal rejected: ${e.message}`, { status: 400 }); - } - throw e; + filePath: resolved.filePath, + }), + authorName: viewer, + authorEmail: noreplyEmail(viewer), + }); + if (!outcome.ok) { + // Status 200 (not 5xx): Cloudflare replaces 5xx responses with + // its own error page, hiding our diagnostic. The HTML body + // carries the failure semantics; status only affects routing + // and caching. + const html = await renderEditCommitFailed(resolved, outcome); + return htmlResponse(html, outcome.kind === "conflict" ? 409 : 200); } - const html = await renderEditThanks(resolved, id); + await applyLiveEdit(resolved, body); + const html = await renderEditAppliedLive(resolved, outcome); return htmlResponse(html); } diff --git a/src/c21_handlers_source.ts b/src/c21_handlers_source.ts new file mode 100644 index 0000000000000000000000000000000000000000..3e8b3cda7ccdd4ef424bead0ca805eeafe539e7d --- /dev/null +++ b/src/c21_handlers_source.ts @@ -0,0 +1,38 @@ +// c21 — handler: serves the raw markdown source of an editable doc +// page from the main domain. Replaces the previous "view source on +// git.tdd.md" link so the docs site doesn't depend on the Forgejo +// subdomain for "view source". Reuses c32_edit_resolve so the same +// allowlist (sama / guides / blog + safe slug regex) protects both +// the editor and the raw view from path traversal. + +import { resolveEdit } from "./c32_edit_resolve.ts"; +import { renderNotFound, htmlResponse } from "./c51_render_layout.ts"; + +// The route literal is `/content/:section/:filename` and the handler +// requires the filename to end in `.md`. We don't use `:slug.md` +// because Bun's path parser treats that as a single param literally +// named "slug.md", which makes the URL un-typeable. +export const rawSourceHandler = async ( + req: Request & { params: { section: string; filename: string } }, +): Promise => { + const fullPath = `/content/${req.params.section}/${req.params.filename}`; + const notFound = async (): Promise => { + const html = await renderNotFound(fullPath); + return htmlResponse(html, 404); + }; + if (!req.params.filename.endsWith(".md")) return await notFound(); + const slug = req.params.filename.slice(0, -3); + const resolved = resolveEdit(req.params.section, slug); + if (!resolved) return await notFound(); + const file = Bun.file(`./${resolved.filePath}`); + if (!(await file.exists())) return await notFound(); + // text/plain so browsers render the markdown source inline rather + // than offering a download. UTF-8 is fixed because the content/ dir + // is UTF-8 throughout (verified by sama-verify). + return new Response(await file.text(), { + headers: { + "Content-Type": "text/plain; charset=utf-8", + "Cache-Control": "public, max-age=60", + }, + }); +}; diff --git a/src/c31_blog.ts b/src/c31_blog.ts index 3574fe33c37e9839ff9f39190be846a1db4743ed..b3047d3178312695dc166662a2a34a0cfb65a991 100644 --- a/src/c31_blog.ts +++ b/src/c31_blog.ts @@ -12,6 +12,12 @@ export interface BlogEntry { } export const ALL_POSTS: BlogEntry[] = [ + { + slug: "sama-meets-git-cms", + title: "SAMA meets git: building a self-hosted CMS that obeys the discipline", + description: "Built a self-hosted CMS for tdd.md that commits directly to Forgejo via HTTP — no git binary, no SSH keys, no SQLite proposal queue. Edits become real commits a reviewer can git blame. Along the way the build surfaced eight SAMA tensions: two led to refinements (Modeled exemption for I/O-only c14 files; boundary-contract discriminated unions), six were operational doctrines or things SAMA correctly stays silent on. This post itself was committed via the CMS.", + date: "2026-05-10", + }, { slug: "from-rules-to-checks", title: "From rules to checks: shipping what the corpus post promised", diff --git a/src/c31_commit_meta.test.ts b/src/c31_commit_meta.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..0de2c7b59e11d098e1d085140ee495e1e2c85463 --- /dev/null +++ b/src/c31_commit_meta.test.ts @@ -0,0 +1,37 @@ +import { test, expect } from "bun:test"; +import { buildCommitMessage, noreplyEmail } from "./c31_commit_meta.ts"; + +test("buildCommitMessage emits the expected subject + trailer", () => { + const msg = buildCommitMessage({ + title: "S — Sorted", + author: "syntaxai", + filePath: "content/sama/sorted.md", + }); + const lines = msg.split("\n"); + expect(lines[0]).toBe("edit content/sama/sorted.md via web"); + expect(msg).toContain("Submitted by syntaxai via the tdd.md self-hosted editor."); +}); + +test("buildCommitMessage filePath is the only thing on the subject line", () => { + // Important: keeps `git log --oneline` readable. No author / no SHA + // hint in the subject — that's all in the body / trailers / metadata. + const msg = buildCommitMessage({ + title: "ignored title", + author: "syntaxai", + filePath: "content/blog/some-post.md", + }); + const subject = msg.split("\n")[0]; + expect(subject).toBe("edit content/blog/some-post.md via web"); + expect(subject).not.toContain("syntaxai"); + expect(subject).not.toContain("ignored title"); +}); + +test("noreplyEmail prefers the github-id form when available", () => { + expect(noreplyEmail("syntaxai", 12766340)).toBe( + "12766340+syntaxai@users.noreply.github.com", + ); +}); + +test("noreplyEmail falls back to login-only when id is unknown", () => { + expect(noreplyEmail("syntaxai")).toBe("syntaxai@users.noreply.github.com"); +}); diff --git a/src/c31_commit_meta.ts b/src/c31_commit_meta.ts new file mode 100644 index 0000000000000000000000000000000000000000..9e811b431246480684612e8878b5917bf9786a30 --- /dev/null +++ b/src/c31_commit_meta.ts @@ -0,0 +1,29 @@ +// c31 — model: pure helpers for shaping a git commit out of an edit +// submission. Source-agnostic — used to live with c14_forgejo's +// commitFile, now feeds c14_git.commitFile against the local bare +// repo. Sibling-tested. + +export interface CommitMessageInput { + // Page title shown in the editor header (e.g. "S — Sorted"). + title: string; + // GitHub login of the admin who saved the edit. + author: string; + // Path under repo root, e.g. "content/sama/sorted.md". + filePath: string; +} + +// One-line subject + author trailer. Intentionally short so it reads +// well in `git log --oneline` and in the Forgejo commit list. +export const buildCommitMessage = (input: CommitMessageInput): string => { + const subject = `edit ${input.filePath} via web`; + const trailer = `\n\nSubmitted by ${input.author} via the tdd.md self-hosted editor.`; + return subject + trailer; +}; + +// GitHub-style noreply email so commits attribute to the user's +// GitHub account in tools that link by email. Mirrors the logic in +// c21_handlers_auth where we mint Forgejo identities. +export const noreplyEmail = (login: string, githubId?: number): string => + githubId !== undefined + ? `${githubId}+${login}@users.noreply.github.com` + : `${login}@users.noreply.github.com`; diff --git a/src/c31_diff_parse.test.ts b/src/c31_diff_parse.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..0fe81b4f825587f265e1b9b7f3a3a5e88500fff6 --- /dev/null +++ b/src/c31_diff_parse.test.ts @@ -0,0 +1,131 @@ +import { test, expect } from "bun:test"; +import { parseUnifiedDiff } from "./c31_diff_parse.ts"; + +test("empty input yields no files", () => { + expect(parseUnifiedDiff("").files).toEqual([]); +}); + +test("single-file modified, one mixed hunk", () => { + const raw = `diff --git a/foo.md b/foo.md +index abc..def 100644 +--- a/foo.md ++++ b/foo.md +@@ -1,3 +1,3 @@ +-old line ++new line + context + more context +`; + const r = parseUnifiedDiff(raw); + expect(r.files).toHaveLength(1); + const f = r.files[0]!; + expect(f.path).toBe("foo.md"); + expect(f.oldPath).toBe("foo.md"); + expect(f.status).toBe("modified"); + expect(f.added).toBe(1); + expect(f.removed).toBe(1); + expect(f.hunks).toHaveLength(1); + expect(f.hunks[0]!.lines.map((l) => [l.kind, l.text])).toEqual([ + ["removed", "old line"], + ["added", "new line"], + ["context", "context"], + ["context", "more context"], + ]); +}); + +test("line numbers track old/new sides correctly", () => { + const raw = `diff --git a/x b/x +--- a/x ++++ b/x +@@ -10,3 +10,3 @@ + keep +-drop ++inject +`; + const f = parseUnifiedDiff(raw).files[0]!; + const lines = f.hunks[0]!.lines; + expect(lines[0]).toMatchObject({ kind: "context", oldNum: 10, newNum: 10 }); + expect(lines[1]).toMatchObject({ kind: "removed", oldNum: 11, newNum: null }); + expect(lines[2]).toMatchObject({ kind: "added", oldNum: null, newNum: 11 }); +}); + +test("new file marker sets status:added", () => { + const raw = `diff --git a/new.md b/new.md +new file mode 100644 +index 0000000..abc +--- /dev/null ++++ b/new.md +@@ -0,0 +1,2 @@ ++hello ++world +`; + const f = parseUnifiedDiff(raw).files[0]!; + expect(f.status).toBe("added"); + expect(f.added).toBe(2); + expect(f.removed).toBe(0); +}); + +test("deleted file marker sets status:removed", () => { + const raw = `diff --git a/old.md b/old.md +deleted file mode 100644 +--- a/old.md ++++ /dev/null +@@ -1,2 +0,0 @@ +-bye +-world +`; + const f = parseUnifiedDiff(raw).files[0]!; + expect(f.status).toBe("removed"); + expect(f.added).toBe(0); + expect(f.removed).toBe(2); +}); + +test("multiple files in one diff are all parsed", () => { + const raw = `diff --git a/a.md b/a.md +--- a/a.md ++++ b/a.md +@@ -1 +1 @@ +-A ++a +diff --git a/b.md b/b.md +--- a/b.md ++++ b/b.md +@@ -1 +1 @@ +-B ++b +`; + const r = parseUnifiedDiff(raw); + expect(r.files.map((f) => f.path)).toEqual(["a.md", "b.md"]); +}); + +test("hunk header without explicit length defaults to 1", () => { + const raw = `diff --git a/x b/x +--- a/x ++++ b/x +@@ -5 +5 @@ section name +-old ++new +`; + const f = parseUnifiedDiff(raw).files[0]!; + const h = f.hunks[0]!; + expect(h.oldLength).toBe(1); + expect(h.newLength).toBe(1); + expect(h.heading).toBe("section name"); +}); + +test("\\ No newline at end of file is silently skipped", () => { + const raw = `diff --git a/x b/x +--- a/x ++++ b/x +@@ -1 +1 @@ +-old +\\ No newline at end of file ++new +\\ No newline at end of file +`; + const f = parseUnifiedDiff(raw).files[0]!; + expect(f.added).toBe(1); + expect(f.removed).toBe(1); + // The "\ No newline" lines should NOT show up as context. + expect(f.hunks[0]!.lines.map((l) => l.kind)).toEqual(["removed", "added"]); +}); diff --git a/src/c31_diff_parse.ts b/src/c31_diff_parse.ts new file mode 100644 index 0000000000000000000000000000000000000000..df9e7717f0674f7ac3031fb78d8f92e2264b4450 --- /dev/null +++ b/src/c31_diff_parse.ts @@ -0,0 +1,160 @@ +// c31 — model: pure parser for unified-diff output. Takes the raw text +// emitted by `git diff` / Forgejo's `.diff` endpoint and produces the +// structured shape c51_render_commit consumes. No I/O, no I/O assumptions +// — handed a string, returns a tree. + +export type DiffLineKind = "context" | "added" | "removed"; + +export interface DiffLine { + kind: DiffLineKind; + text: string; + // 1-based line numbers in the old / new file. Null for the side + // that doesn't have this line (e.g. additions have oldNum:null). + oldNum: number | null; + newNum: number | null; +} + +export interface DiffHunk { + oldStart: number; + oldLength: number; + newStart: number; + newLength: number; + // The "@@ ... @@" suffix Forgejo/git puts after the second @@. Often + // the surrounding function/section name. Free text, may be empty. + heading: string; + lines: DiffLine[]; +} + +export interface DiffFile { + // Path on the new side. For deletes this is the old path mirrored + // here so one field is enough to render a row. + path: string; + // Old path, set only on renames + deletes. Equal to `path` for + // straightforward edits. + oldPath: string; + status: "added" | "removed" | "modified" | "renamed"; + hunks: DiffHunk[]; + added: number; + removed: number; +} + +export interface ParsedDiff { + files: DiffFile[]; +} + +// Parse a `@@ -oldStart,oldLength +newStart,newLength @@ heading` header. +// Returns null when the line doesn't match. The length parts are +// optional in unified-diff (defaults to 1) — handle both shapes. +const HUNK_HEADER = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/; + +const parseHunkHeader = (line: string): Omit | null => { + const m = HUNK_HEADER.exec(line); + if (!m) return null; + return { + oldStart: parseInt(m[1]!, 10), + oldLength: m[2] !== undefined ? parseInt(m[2], 10) : 1, + newStart: parseInt(m[3]!, 10), + newLength: m[4] !== undefined ? parseInt(m[4], 10) : 1, + heading: (m[5] ?? "").trim(), + }; +}; + +export const parseUnifiedDiff = (raw: string): ParsedDiff => { + const files: DiffFile[] = []; + let currentFile: DiffFile | null = null; + let currentHunk: DiffHunk | null = null; + let oldLineNo = 0; + let newLineNo = 0; + + const lines = raw.split("\n"); + for (let i = 0; i < lines.length; i++) { + const line = lines[i] ?? ""; + + if (line.startsWith("diff --git ")) { + // New file boundary. Try to extract paths from "a/X b/Y" — git + // emits them quoted only when special chars are present, which + // we don't expect for our markdown content. + const m = /^diff --git a\/(.+) b\/(.+)$/.exec(line); + const oldPath = m?.[1] ?? ""; + const path = m?.[2] ?? ""; + currentFile = { + path, + oldPath, + status: "modified", + hunks: [], + added: 0, + removed: 0, + }; + currentHunk = null; + files.push(currentFile); + continue; + } + + if (currentFile === null) continue; // preamble, skip + + if (line.startsWith("new file mode")) { + currentFile.status = "added"; + continue; + } + if (line.startsWith("deleted file mode")) { + currentFile.status = "removed"; + continue; + } + if (line.startsWith("rename from ") || line.startsWith("rename to ")) { + currentFile.status = "renamed"; + continue; + } + // Skip the index, ---/+++ headers — useful info already captured + // from "diff --git" / mode lines. + if ( + line.startsWith("index ") || + line.startsWith("--- ") || + line.startsWith("+++ ") || + line.startsWith("similarity index") || + line.startsWith("Binary files") + ) { + continue; + } + + if (line.startsWith("@@")) { + const header = parseHunkHeader(line); + if (!header) continue; + currentHunk = { ...header, lines: [] }; + currentFile.hunks.push(currentHunk); + oldLineNo = header.oldStart; + newLineNo = header.newStart; + continue; + } + + if (currentHunk === null) continue; + + // Body lines — first char is the marker. An empty string at the + // tail of the input (from a trailing "\n") falls through as + // context with text "" — that matches what git emits. + const marker = line[0] ?? " "; + const text = line.slice(1); + + if (marker === "+") { + currentHunk.lines.push({ kind: "added", text, oldNum: null, newNum: newLineNo }); + newLineNo++; + currentFile.added++; + } else if (marker === "-") { + currentHunk.lines.push({ kind: "removed", text, oldNum: oldLineNo, newNum: null }); + oldLineNo++; + currentFile.removed++; + } else if (marker === " " || marker === "") { + // Skip a stray empty line that follows the last hunk before the + // next "diff --git" — it's not a real context line. + const next = lines[i + 1] ?? ""; + if (line === "" && (next.startsWith("diff --git ") || next === "")) continue; + currentHunk.lines.push({ kind: "context", text, oldNum: oldLineNo, newNum: newLineNo }); + oldLineNo++; + newLineNo++; + } else if (marker === "\\") { + // "\ No newline at end of file" — informational, skip. + continue; + } + } + + return { files }; +}; diff --git a/src/c31_edit_validation.test.ts b/src/c31_edit_validation.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..e10f25817093a47c8ecdfea0387b774a7de97ecb --- /dev/null +++ b/src/c31_edit_validation.test.ts @@ -0,0 +1,39 @@ +import { test, expect } from "bun:test"; +import { + validateEditBody, + isNoOpEdit, + EditValidationError, + MAX_EDIT_BODY_BYTES, +} from "./c31_edit_validation.ts"; + +test("validateEditBody returns the body when valid", () => { + expect(validateEditBody("# title\n\nsome body")).toBe("# title\n\nsome body"); +}); + +test("validateEditBody rejects non-string input", () => { + expect(() => validateEditBody(42)).toThrow(EditValidationError); + expect(() => validateEditBody(null)).toThrow(EditValidationError); + expect(() => validateEditBody(undefined)).toThrow(EditValidationError); +}); + +test("validateEditBody rejects empty / whitespace-only", () => { + expect(() => validateEditBody("")).toThrow(EditValidationError); + expect(() => validateEditBody(" \n\t ")).toThrow(EditValidationError); +}); + +test("validateEditBody rejects bodies over the byte cap", () => { + const tooBig = "x".repeat(MAX_EDIT_BODY_BYTES + 1); + expect(() => validateEditBody(tooBig)).toThrow(/exceeds/); +}); + +test("validateEditBody accepts a body right at the cap", () => { + const exact = "x".repeat(MAX_EDIT_BODY_BYTES); + expect(validateEditBody(exact)).toBe(exact); +}); + +test("isNoOpEdit is byte-equal, not whitespace-tolerant", () => { + expect(isNoOpEdit("a", "a")).toBe(true); + expect(isNoOpEdit("a", "a ")).toBe(false); + expect(isNoOpEdit("a\n", "a")).toBe(false); + expect(isNoOpEdit("", "")).toBe(true); +}); diff --git a/src/c31_edit_validation.ts b/src/c31_edit_validation.ts new file mode 100644 index 0000000000000000000000000000000000000000..eea54ce3278789473f42509a8209f858198fd3bd --- /dev/null +++ b/src/c31_edit_validation.ts @@ -0,0 +1,38 @@ +// c31 — model: validation for an admin edit submission. Pure: no I/O. +// The DB no longer stores edits (admin POST goes directly to Forgejo +// + filesystem), so this file holds only the body sanity checks that +// were previously bundled with the SQLite proposal flow. + +export const MAX_EDIT_BODY_BYTES = 256 * 1024; // 256 KB + +export class EditValidationError extends Error { + constructor(message: string) { + super(message); + this.name = "EditValidationError"; + } +} + +// Throws EditValidationError when the body is empty, too large, or +// otherwise unfit to commit. Returns the trimmed-but-otherwise-untouched +// body string on success. +export const validateEditBody = (raw: unknown): string => { + if (typeof raw !== "string") { + throw new EditValidationError("body must be a string"); + } + if (raw.trim().length === 0) { + throw new EditValidationError("body cannot be empty"); + } + const bytes = new TextEncoder().encode(raw).length; + if (bytes > MAX_EDIT_BODY_BYTES) { + throw new EditValidationError( + `body exceeds the ${MAX_EDIT_BODY_BYTES / 1024} KB limit (got ${Math.round(bytes / 1024)} KB)`, + ); + } + return raw; +}; + +// Byte-identical check between current page content and the proposed +// new content. Used to skip a Forgejo round-trip when the user +// accidentally submitted without changes. +export const isNoOpEdit = (currentBody: string, newBody: string): boolean => + currentBody === newBody; diff --git a/src/c31_git_parse.test.ts b/src/c31_git_parse.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..d7ebbe647377d6e043228b211e0edfb6f4fb5a55 --- /dev/null +++ b/src/c31_git_parse.test.ts @@ -0,0 +1,93 @@ +import { test, expect } from "bun:test"; +import { + parseGitCommits, + parseLsTreeLine, + GIT_COMMIT_FORMAT, +} from "./c31_git_parse.ts"; + +const FS = "\x1f"; +const RS = "\x1e"; + +const fakeCommit = ( + sha: string, + parents: string, + msg: string, + ts = "2026-05-10T13:00:00+01:00", +): string => + [sha, parents, "syntaxai", "syntaxai@example.com", ts, "syntaxai", "syntaxai@example.com", ts, msg].join(FS) + RS; + +test("parses a single commit with one parent and short message", () => { + const raw = fakeCommit("abc123", "def456", "edit content/sama/skill.md\n"); + const commits = parseGitCommits(raw); + expect(commits).toHaveLength(1); + const c = commits[0]!; + expect(c.sha).toBe("abc123"); + expect(c.parents).toEqual(["def456"]); + expect(c.authorName).toBe("syntaxai"); + expect(c.message).toBe("edit content/sama/skill.md"); +}); + +test("parses multiple commits separated by RS", () => { + const raw = + fakeCommit("aaa", "bbb", "first") + + fakeCommit("bbb", "ccc", "second") + + fakeCommit("ccc", "", "root commit"); + const commits = parseGitCommits(raw); + expect(commits.map((c) => c.sha)).toEqual(["aaa", "bbb", "ccc"]); + expect(commits[2]!.parents).toEqual([]); +}); + +test("preserves multi-line commit message body", () => { + const msg = "subject line\n\nbody line one\nbody line two\n"; + const raw = fakeCommit("xyz", "par", msg); + const c = parseGitCommits(raw)[0]!; + expect(c.message).toBe("subject line\n\nbody line one\nbody line two"); +}); + +test("merge commit has multiple parents", () => { + const raw = fakeCommit("merge1", "p1 p2 p3", "merge"); + const c = parseGitCommits(raw)[0]!; + expect(c.parents).toEqual(["p1", "p2", "p3"]); +}); + +test("empty input yields empty array", () => { + expect(parseGitCommits("")).toEqual([]); +}); + +test("malformed record throws", () => { + expect(() => parseGitCommits("not enough fields here" + RS)).toThrow(); +}); + +test("GIT_COMMIT_FORMAT round-trips through %x1e/%x1f hex escapes", () => { + // The format string passes \x1e and \x1f as %x1e / %x1f to git's + // printf-style placeholder language. This guards against accidental + // edits that break the round-trip. + expect(GIT_COMMIT_FORMAT).toContain("%x1f"); + expect(GIT_COMMIT_FORMAT).toEndWith("%x1e"); +}); + +test("parseLsTreeLine accepts a regular blob row", () => { + const r = parseLsTreeLine("100644 blob abc123def456\tcontent/sama/skill.md"); + expect(r).toEqual({ + mode: "100644", + type: "blob", + sha: "abc123def456", + path: "content/sama/skill.md", + }); +}); + +test("parseLsTreeLine accepts a tree row", () => { + const r = parseLsTreeLine("040000 tree treesha\tcontent"); + expect(r?.type).toBe("tree"); +}); + +test("parseLsTreeLine returns null for blank or malformed input", () => { + expect(parseLsTreeLine("")).toBeNull(); + expect(parseLsTreeLine("not even tab separated")).toBeNull(); + expect(parseLsTreeLine("100644 weirdtype sha\tpath")).toBeNull(); +}); + +test("parseLsTreeLine preserves paths with embedded spaces", () => { + const r = parseLsTreeLine("100644 blob abc\tcontent/with space/file.md"); + expect(r?.path).toBe("content/with space/file.md"); +}); diff --git a/src/c31_git_parse.ts b/src/c31_git_parse.ts new file mode 100644 index 0000000000000000000000000000000000000000..404f1261633eec5c14b9a013b04d60aab14c84fe --- /dev/null +++ b/src/c31_git_parse.ts @@ -0,0 +1,83 @@ +// c31 — model: parsers for `git` plumbing output. Pure: a function +// from string to a typed object. The c14_git layer owns the actual +// `Bun.spawn` calls; this file makes their stdout/stderr legible. + +export interface GitCommit { + sha: string; + parents: string[]; + authorName: string; + authorEmail: string; + authorDate: string; // ISO 8601 with timezone + committerName: string; + committerEmail: string; + committerDate: string; + message: string; // full message: subject + blank + body +} + +// Format string for `git log` / `git show` that this parser consumes. +// Uses ASCII record separators so commit messages with newlines pass +// through unmangled. Mirrors the technique already used in +// scripts/p620/snapshot-git-history.ts. +// +// %H full sha +// %P parent shas (space-separated) +// %an %ae %aI author name/email/iso-strict-with-timezone +// %cn %ce %cI committer +// %B raw body (subject + blank + rest) +export const GIT_COMMIT_FORMAT = + ["%H", "%P", "%an", "%ae", "%aI", "%cn", "%ce", "%cI", "%B"].join("%x1f") + "%x1e"; + +const RECORD_SEP = "\x1e"; +const FIELD_SEP = "\x1f"; + +// Parse one or more commits emitted with GIT_COMMIT_FORMAT. Trailing +// record separator is fine (we trim before splitting). +export const parseGitCommits = (raw: string): GitCommit[] => { + const records = raw.split(RECORD_SEP).map((s) => s.trim()).filter(Boolean); + return records.map(parseOneCommit); +}; + +const parseOneCommit = (record: string): GitCommit => { + const parts = record.split(FIELD_SEP); + if (parts.length < 9) { + throw new Error(`malformed git commit record: expected 9+ fields, got ${parts.length}`); + } + const [sha, parentsRaw, an, ae, aI, cn, ce, cI, ...rest] = parts; + const message = (rest.join(FIELD_SEP) ?? "").replace(/\n+$/, ""); + return { + sha: sha!, + parents: (parentsRaw ?? "").trim().split(/\s+/).filter(Boolean), + authorName: an!, + authorEmail: ae!, + authorDate: aI!, + committerName: cn!, + committerEmail: ce!, + committerDate: cI!, + message, + }; +}; + +// Parse `git ls-tree -- ` output: one tab-separated row of +// ` \t`. Returns null when the path doesn't +// exist at that ref (empty stdout from git). +export interface LsTreeEntry { + mode: string; + type: "blob" | "tree" | "commit"; + sha: string; + path: string; +} + +export const parseLsTreeLine = (line: string): LsTreeEntry | null => { + const trimmed = line.trim(); + if (!trimmed) return null; + // ` \t` — tab is mandatory between sha+path, + // spaces before. Split on first tab to keep paths with spaces intact. + const tabIdx = trimmed.indexOf("\t"); + if (tabIdx === -1) return null; + const head = trimmed.slice(0, tabIdx).split(/\s+/); + if (head.length !== 3) return null; + const [mode, type, sha] = head; + const path = trimmed.slice(tabIdx + 1); + if (type !== "blob" && type !== "tree" && type !== "commit") return null; + return { mode: mode!, type, sha: sha!, path }; +}; diff --git a/src/c31_proposals.test.ts b/src/c31_proposals.test.ts deleted file mode 100644 index 3bd4a756144e177bc994cb2e2c07bd5ddd9a759b..0000000000000000000000000000000000000000 --- a/src/c31_proposals.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { test, expect } from "bun:test"; -import { - parseProposalSubmission, - isNoOpProposal, - ProposalValidationError, - MAX_PROPOSAL_BODY_BYTES, -} from "./c31_proposals.ts"; - -const valid = { - pageUrl: "/sama/sorted", - editPath: "content/sama/sorted.md", - title: "S — Sorted", - body: "# Sorted\n\nNew text.", - author: "octocat", -}; - -test("accepts a well-formed submission", () => { - const out = parseProposalSubmission(valid); - expect(out.body).toBe(valid.body); - expect(out.author).toBe("octocat"); -}); - -test("rejects non-object input", () => { - expect(() => parseProposalSubmission(null)).toThrow(ProposalValidationError); - expect(() => parseProposalSubmission("string")).toThrow(ProposalValidationError); -}); - -test("rejects missing fields", () => { - expect(() => parseProposalSubmission({ ...valid, body: undefined })).toThrow(ProposalValidationError); - expect(() => parseProposalSubmission({ ...valid, author: 42 })).toThrow(ProposalValidationError); -}); - -test("rejects empty body", () => { - expect(() => parseProposalSubmission({ ...valid, body: " \n " })).toThrow(/empty/); -}); - -test("rejects empty author", () => { - expect(() => parseProposalSubmission({ ...valid, author: "" })).toThrow(/author/); -}); - -test("enforces the body size cap", () => { - const huge = "x".repeat(MAX_PROPOSAL_BODY_BYTES + 1); - expect(() => parseProposalSubmission({ ...valid, body: huge })).toThrow(/exceeds/); -}); - -test("accepts a body right at the size cap", () => { - const limit = "x".repeat(MAX_PROPOSAL_BODY_BYTES); - expect(parseProposalSubmission({ ...valid, body: limit }).body.length).toBe(MAX_PROPOSAL_BODY_BYTES); -}); - -test("isNoOpProposal flags identical bodies", () => { - expect(isNoOpProposal("a", "a")).toBe(true); - expect(isNoOpProposal("a", "a ")).toBe(false); -}); diff --git a/src/c31_proposals.ts b/src/c31_proposals.ts deleted file mode 100644 index a127d0e5715a8087ae667df902dda7c18db21af2..0000000000000000000000000000000000000000 --- a/src/c31_proposals.ts +++ /dev/null @@ -1,63 +0,0 @@ -// c31 — model: parser for proposal submissions. The DB row shape lives -// in c13_database.ts (ProposalRow / NewProposal); this file validates -// untyped form input before it reaches the DB. -// -// A proposal is a markdown-body edit suggestion for a doc page. We -// cap the body size and reject empty submissions so the proposals -// table stays bounded and admins aren't reviewing accidental no-ops. - -import type { NewProposal } from "./c13_database.ts"; - -export const MAX_PROPOSAL_BODY_BYTES = 256 * 1024; // 256 KB - -export interface ProposalSubmissionInput { - pageUrl: string; - editPath: string; - title: string; - body: string; - author: string; -} - -export class ProposalValidationError extends Error { - constructor(message: string) { - super(message); - this.name = "ProposalValidationError"; - } -} - -const requireString = (v: unknown, label: string): string => { - if (typeof v !== "string") throw new ProposalValidationError(`${label} must be a string`); - return v; -}; - -export const parseProposalSubmission = (input: unknown): NewProposal => { - if (input === null || typeof input !== "object") { - throw new ProposalValidationError("submission must be an object"); - } - const obj = input as Record; - const body = requireString(obj.body, "body"); - if (body.trim().length === 0) { - throw new ProposalValidationError("body cannot be empty"); - } - const bodyBytes = new TextEncoder().encode(body).length; - if (bodyBytes > MAX_PROPOSAL_BODY_BYTES) { - throw new ProposalValidationError(`body exceeds the ${MAX_PROPOSAL_BODY_BYTES / 1024} KB limit (got ${Math.round(bodyBytes / 1024)} KB)`); - } - const author = requireString(obj.author, "author"); - if (author.trim().length === 0) { - throw new ProposalValidationError("author cannot be empty"); - } - return { - pageUrl: requireString(obj.pageUrl, "pageUrl"), - editPath: requireString(obj.editPath, "editPath"), - title: requireString(obj.title, "title"), - body, - author, - }; -}; - -// Returns true when the proposed body is byte-identical to the -// current page body. Used by the edit handler to skip storing -// no-op submissions. -export const isNoOpProposal = (currentBody: string, proposedBody: string): boolean => - currentBody === proposedBody; diff --git a/src/c31_site_config.ts b/src/c31_site_config.ts index cf33a5054581045ca603e9fe6d4427ad6d2b0f24..7c3f4e8b3ef2f04128a0391aca9e4dd6f7a565e4 100644 --- a/src/c31_site_config.ts +++ b/src/c31_site_config.ts @@ -9,11 +9,7 @@ export const LIVE_REPO_NAME = "tdd.md"; // in-container git-history bundle. export const LIVE_FETCH_COUNT = 100; -// Owner / admin GitHub login. Gates /admin/* routes (proposal review). +// Owner / admin GitHub login. The CMS edit handler (c21_handlers_edit) +// only allows POSTs from this username — anyone else gets a 403 wall. // Override per-environment via TDD_ADMIN_USER if needed. export const ADMIN_USERNAME = process.env.TDD_ADMIN_USER ?? "syntaxai"; - -// Self-hosted Forgejo mirror — what we link to when the docs layout -// says "view source on our git". The link goes to the canonical -// rendered file in the mirror, NOT to a web editor (we have our own). -export const SELF_HOSTED_REPO_BLOB_BASE = "https://git.tdd.md/syntaxai/tdd.md/src/branch/main"; diff --git a/src/c32_edit_resolve.test.ts b/src/c32_edit_resolve.test.ts index 695878ee6c66e473427daaf8f07c1618662234c5..d40b3c2ed071f9a013253406102811eec89357f2 100644 --- a/src/c32_edit_resolve.test.ts +++ b/src/c32_edit_resolve.test.ts @@ -42,3 +42,17 @@ test("rejects unsafe slug shapes", () => { expect(resolveEdit("sama", "with space")).toBeNull(); expect(resolveEdit("sama", "-leading-dash")).toBeNull(); }); + +test("resolves nav-only sama pages (e.g. /sama/skill) via SITE_NAV fallback", () => { + const r = resolveEdit("sama", "skill"); + expect(r).not.toBeNull(); + expect(r?.pageUrl).toBe("/sama/skill"); + expect(r?.filePath).toBe("content/sama/skill.md"); + expect(r?.title).toMatch(/SKILL/i); +}); + +test("non-editable nav links (editPath:null) stay unresolvable", () => { + // /sama/verify is in SITE_NAV but has editPath: null because it's + // a verifier form, not a content/<...>.md doc. + expect(resolveEdit("sama", "verify")).toBeNull(); +}); diff --git a/src/c32_edit_resolve.ts b/src/c32_edit_resolve.ts index bbfd1df0630700923f682235e811443a409cc69a..23c4f5c28ae860f4c17ace574b9626233235aa38 100644 --- a/src/c32_edit_resolve.ts +++ b/src/c32_edit_resolve.ts @@ -12,6 +12,7 @@ import { ALL_SAMA } from "./c31_sama.ts"; import { ALL_GUIDES } from "./c31_guides.ts"; import { ALL_POSTS } from "./c31_blog.ts"; +import { SITE_NAV } from "./c31_docs_nav.ts"; export type EditableSection = "sama" | "guides" | "blog"; @@ -32,15 +33,24 @@ const SAFE_SLUG = /^[a-z0-9][a-z0-9-]*$/; const lookupTitle = (section: EditableSection, slug: string): string | null => { if (section === "sama") { const e = ALL_SAMA.find((d) => d.slug === slug); - return e ? `${e.letter} — ${e.title}` : null; - } - if (section === "guides") { + if (e) return `${e.letter} — ${e.title}`; + } else if (section === "guides") { const e = ALL_GUIDES.find((g) => g.slug === slug); - return e?.title ?? null; + if (e) return e.title; + } else { + const e = ALL_POSTS.find((p) => p.slug === slug); + if (e) return e.title; } - // blog - const e = ALL_POSTS.find((p) => p.slug === slug); - return e?.title ?? null; + // Fallback to SITE_NAV: nav-only editable pages (e.g. /sama/skill) + // have a content/<...>.md backing file but no entry in the discipline + // / guide / blog registries. They're listed in SITE_NAV with a + // non-null editPath, which is the single source of truth for + // "this docs page is editable". + const navSection = SITE_NAV.find((s) => s.id === section); + const link = navSection?.links.find( + (l) => l.href === `/${section}/${slug}` && l.editPath !== null, + ); + return link?.label ?? null; }; export const resolveEdit = (section: string, slug: string): ResolvedEdit | null => { diff --git a/src/c51_render_commit.ts b/src/c51_render_commit.ts new file mode 100644 index 0000000000000000000000000000000000000000..f9b2e7ac6b4f24efd63310c45852aabe3839f15d --- /dev/null +++ b/src/c51_render_commit.ts @@ -0,0 +1,127 @@ +// c51 — UI: SAMA-native commit detail page. Replaces what visitors +// would see at git.tdd.md///commit/ with the same +// information rendered through tdd.md's chrome. Consumes the parsed +// diff (c31_diff_parse) and commit metadata (any source — c14_git or +// c14_forgejo can both produce it). + +import { renderPage, escape } from "./c51_render_layout.ts"; +import type { DiffFile, DiffHunk, ParsedDiff } from "./c31_diff_parse.ts"; + +// Source-agnostic commit shape this renderer consumes. Both c14_git's +// GitCommit and c14_forgejo's ForgejoCommitDetail fit this surface. +export interface CommitForView { + sha: string; + parents: string[]; + authorName: string; + authorEmail: string; + authorDate: string; + committerName: string; + committerEmail: string; + committerDate: string; + message: string; +} + +const shortSha = (sha: string): string => sha.slice(0, 7); + +// "2026-05-10 12:31:07 +01:00" — ISO-ish, easy to scan. +const ts = (iso: string): string => { + // Trust Forgejo's ISO format; only chop the timezone/seconds for compactness. + return iso.replace("T", " ").replace(/\+\d{2}:\d{2}$/, (m) => " " + m); +}; + +// First line of the commit message is the subject; rest is body. +const splitMessage = (msg: string): { subject: string; body: string } => { + const newline = msg.indexOf("\n"); + if (newline === -1) return { subject: msg, body: "" }; + return { + subject: msg.slice(0, newline), + body: msg.slice(newline + 1).trim(), + }; +}; + +const statusBadge = (status: DiffFile["status"]): string => { + const label = + status === "added" ? "added" : + status === "removed" ? "removed" : + status === "renamed" ? "renamed" : "modified"; + return `${label}`; +}; + +const renderHunk = (hunk: DiffHunk): string => { + const headingHtml = hunk.heading + ? `${escape(hunk.heading)}` + : ""; + const headerRow = `@@ -${hunk.oldStart},${hunk.oldLength} +${hunk.newStart},${hunk.newLength} @@ ${headingHtml}`; + const lineRows = hunk.lines.map((line) => { + const marker = line.kind === "added" ? "+" : line.kind === "removed" ? "-" : " "; + const oldNum = line.oldNum === null ? "" : String(line.oldNum); + const newNum = line.newNum === null ? "" : String(line.newNum); + return `${oldNum}${newNum}${escape(marker + line.text)}`; + }).join(""); + return headerRow + lineRows; +}; + +const renderFile = (file: DiffFile): string => { + const renamed = file.status === "renamed" && file.oldPath !== file.path + ? `${escape(file.oldPath)}` + : ""; + return `
+
+ ${statusBadge(file.status)} + ${renamed}${escape(file.path)} + + +${file.added} + −${file.removed} + +
+ ${file.hunks.map(renderHunk).join("")}
+
`; +}; + +export const renderCommitView = async (params: { + owner: string; + repo: string; + detail: CommitForView; + diff: ParsedDiff; +}): Promise => { + const { owner, repo, detail, diff } = params; + const { subject, body } = splitMessage(detail.message); + const parentLinks = detail.parents.length === 0 + ? `no parent (root commit)` + : detail.parents.map((p) => + `${escape(shortSha(p))}`, + ).join(" · "); + + const totalAdded = diff.files.reduce((s, f) => s + f.added, 0); + const totalRemoved = diff.files.reduce((s, f) => s + f.removed, 0); + const filesSummary = diff.files.length === 0 + ? `

No file changes (empty / merge commit).

` + : `

${diff.files.length} file${diff.files.length === 1 ? "" : "s"} changed · +${totalAdded} −${totalRemoved}

`; + + const inner = `
+
+

${escape(owner)}/${escape(repo)} · commit ${escape(shortSha(detail.sha))}

+

${escape(subject)}

+ ${body ? `
${escape(body)}
` : ""} +
+
author
${escape(detail.authorName)} <${escape(detail.authorEmail)}>
+
date
${escape(ts(detail.authorDate))}
+
parent
${parentLinks}
+
commit
${escape(detail.sha)}
+
+
+ ${filesSummary} + ${diff.files.map(renderFile).join("")} + +
`; + + return renderPage({ + title: `${shortSha(detail.sha)} · ${subject} — tdd.md`, + bodyHtml: inner, + description: `Commit ${shortSha(detail.sha)} on ${owner}/${repo}: ${subject}`, + noindex: true, + bodyClass: "commit-body-page", + }); +}; diff --git a/src/c51_render_docs_layout.ts b/src/c51_render_docs_layout.ts index e19c200959b81cacafe1982650d3fcf06e098bd6..a70372312f2fcc5caeaafe838b47e0c05b78e6d1 100644 --- a/src/c51_render_docs_layout.ts +++ b/src/c51_render_docs_layout.ts @@ -19,7 +19,6 @@ import { escape, type PageOptions, } from "./c51_render_layout.ts"; -import { SELF_HOSTED_REPO_BLOB_BASE } from "./c31_site_config.ts"; export interface DocsPageOptions extends Omit { // The route path the user is on, e.g. "/sama/sorted". Used to @@ -76,13 +75,16 @@ const sectionSlugFromEditPath = (editPath: string): { section: string; slug: str const renderEditLink = (editPath: string | null): string => { if (!editPath) return ""; - const sourceUrl = `${SELF_HOSTED_REPO_BLOB_BASE}/${editPath}`; + // Source view is served from tdd.md itself (c21_handlers_source); + // we no longer depend on the git.tdd.md (Forgejo) subdomain for + // the docs site's "view source" link. const ss = sectionSlugFromEditPath(editPath); + const sourceHref = ss ? `/content/${ss.section}/${ss.slug}.md` : `/${editPath}`; const editHref = ss ? `/edit/${ss.section}/${ss.slug}` : null; const editAnchor = editHref ? `propose an edit → · ` : ""; - return `

${editAnchor}view source on git.tdd.md →

`; + return `

${editAnchor}view source →

`; }; const renderPrevNext = (loc: ResolvedDocsLocation | null): string => { diff --git a/src/c51_render_edit.ts b/src/c51_render_edit.ts index 6cf61e12b92eed1556a160341554e4bb01a99390..85b6edf1c848e02bf716445e06a1f515e2ff1391 100644 --- a/src/c51_render_edit.ts +++ b/src/c51_render_edit.ts @@ -1,16 +1,17 @@ -// c51 (edit) — UI: edit-form, login-required prompt, admin proposal -// list, and side-by-side current vs proposed view. Composes the -// docs layout's chrome via renderPage with bodyHtml so the form -// can use real
elements (markdown would escape them). +// c51 (edit) — UI: edit-form, login-required prompt, applied-live +// success page, commit-failure page, non-admin "read-only" wall. +// Composes the docs layout's chrome via renderPage with bodyHtml so +// the form can use real elements (markdown would escape them). import { renderPage, escape, } from "./c51_render_layout.ts"; -import type { ProposalRow } from "./c13_database.ts"; import type { ResolvedEdit } from "./c32_edit_resolve.ts"; - -const ts = (n: number): string => new Date(n).toISOString().replace("T", " ").slice(0, 19) + " UTC"; +import type { + GitCommitOk, + GitCommitFailure, +} from "./c14_git.ts"; const layoutWrap = (innerHtml: string): string => `
${innerHtml}
`; @@ -19,7 +20,16 @@ const layoutWrap = (innerHtml: string): string => // full-width form controls, not the doc-layout's three columns. const editBodyClass = "edit-body"; -// -------- /edit/:section/:slug — form for a logged-in user -------- +const shortSha = (sha: string): string => sha.slice(0, 7); + +// SAMA-native commit URL on tdd.md itself. The /GIT/ prefix routes to +// c21_handlers_commit_view which reads the data from Forgejo's API and +// renders it through tdd.md's chrome — visitor never leaves the main +// domain. +const tddCommitUrl = (sha: string): string => + `/GIT/syntaxai/tdd.md/commit/${sha}`; + +// -------- /edit/:section/:slug — form for the admin -------- export const renderEditFormPage = async ( resolved: ResolvedEdit, @@ -29,26 +39,27 @@ export const renderEditFormPage = async ( const inner = `

edit · ${escape(resolved.title)}

Editing ${escape(resolved.filePath)} as ${escape(viewer)}. - Submitting saves a proposal — the live page does not change. + Saving will commit directly to syntaxai/tdd.md@main on git.tdd.md + and refresh the live page. view the live page · log out

- + cancel

- Proposals are queued for review at /admin/proposals. The owner downloads - the proposed body, applies it via git, and the next deploy publishes the change. No edits - bypass git or the deploy pipeline. + This editor commits to git via Forgejo's contents API — the container has + no .git directory, no SSH keys, only an HTTP token. Every save + becomes a real commit you can review at git.tdd.md.

`; return renderPage({ title: `edit · ${resolved.title} — tdd.md`, bodyHtml: layoutWrap(inner), - description: `Suggest an edit to ${resolved.title} on tdd.md. Proposals are queued for owner review and never bypass git.`, + description: `Edit ${resolved.title} on tdd.md. Admin-only; saves commit directly to git.tdd.md.`, ogPath: `https://tdd.md/edit/${resolved.section}/${resolved.slug}`, noindex: true, bodyClass: editBodyClass, @@ -62,133 +73,94 @@ export const renderEditLoginWall = async ( ): Promise => { const returnTo = `/edit/${resolved.section}/${resolved.slug}`; const inner = `

edit · ${escape(resolved.title)}

-

To suggest an edit you need to sign in via GitHub. We use GitHub only for identity — no edits or commits go through GitHub from here.

+

To edit a page you need to sign in via GitHub. Editing is admin-only — only the site owner's GitHub account can save changes. We use GitHub for identity only; saves commit to git.tdd.md, never to GitHub.

-

After sign-in you'll land back on this edit form. Your proposal stays in our SQLite store until the owner approves and applies it via git.

+

If you have an edit suggestion and you're not the admin, open an issue at git.tdd.md/syntaxai/tdd.md/issues.

← back to the page

`; return renderPage({ title: `sign in to edit · ${resolved.title} — tdd.md`, bodyHtml: layoutWrap(inner), - description: `Sign in via GitHub to propose an edit to ${resolved.title} on tdd.md.`, + description: `Sign in via GitHub to edit ${resolved.title} on tdd.md.`, noindex: true, bodyClass: editBodyClass, }); }; -// -------- thank-you page after submit -------- +// -------- non-admin signed-in wall -------- -export const renderEditThanks = async ( +export const renderEditNonAdminWall = async ( resolved: ResolvedEdit, - proposalId: number, -): Promise => { - const inner = `

thanks — proposal #${proposalId} queued

-

Your edit to ${escape(resolved.pageUrl)} is in the review queue.

-

The owner will review pending proposals at /admin/proposals. The live page won't change until they approve and apply the patch via git.

-

← back to the page · propose another edit

`; - return renderPage({ - title: `proposal #${proposalId} queued — tdd.md`, - bodyHtml: layoutWrap(inner), - noindex: true, - bodyClass: editBodyClass, - }); -}; - -// -------- admin: proposal list -------- - -const statusBadge = (s: ProposalRow["status"]): string => { - const cls = s === "approved" ? "edit-status edit-status-approved" - : s === "rejected" ? "edit-status edit-status-rejected" - : "edit-status edit-status-pending"; - return `${s}`; -}; - -export const renderAdminProposalList = async ( - proposals: ProposalRow[], viewer: string, ): Promise => { - const rows = proposals.length === 0 - ? `no proposals yet` - : proposals.map((p) => ` - #${p.id} - ${escape(p.pageUrl)} - ${escape(p.author)} - ${escape(ts(p.submittedAt))} - ${statusBadge(p.status)} - review → -`).join("\n"); - const inner = `

proposal queue

-

${proposals.length} proposal${proposals.length === 1 ? "" : "s"} · signed in as ${escape(viewer)}.

- - - ${rows} -
idpageauthorsubmittedstatus
`; + const inner = `

edit · ${escape(resolved.title)}

+

Signed in as ${escape(viewer)}, but editing is admin-only. Only the site owner can save changes from here.

+

If you'd like to suggest an edit, open an issue at git.tdd.md/syntaxai/tdd.md/issues describing the change.

+

← back to the page · log out

`; return renderPage({ - title: "proposals — tdd.md", + title: `edit · ${resolved.title} — tdd.md`, bodyHtml: layoutWrap(inner), noindex: true, bodyClass: editBodyClass, }); }; -// -------- admin: single proposal review (current vs proposed) -------- +// -------- admin direct-edit applied live -------- -export const renderAdminProposalDetail = async ( - proposal: ProposalRow, - currentBody: string, - viewer: string, +export const renderEditAppliedLive = async ( + resolved: ResolvedEdit, + commit: GitCommitOk, ): Promise => { - const reviewedLine = proposal.status === "pending" - ? "" - : `

${escape(proposal.status)} by ${escape(proposal.reviewedBy ?? "?")} at ${escape(ts(proposal.reviewedAt ?? 0))}${proposal.rejectReason ? ` · reason: ${escape(proposal.rejectReason)}` : ""}

`; - const actions = proposal.status === "pending" - ? `
- -
-
- - -
-download patch (.md)` - : `download patch (.md)`; - const inner = `

proposal #${proposal.id} · ${escape(proposal.title)}

+ const sha = commit.commitSha; + const inner = `

applied live · ${escape(resolved.title)}

+

Your edit to ${escape(resolved.pageUrl)} is now live and committed.

- Page: ${escape(proposal.pageUrl)} · - File: ${escape(proposal.editPath)} · - Author: ${escape(proposal.author)} · - Submitted: ${escape(ts(proposal.submittedAt))} · - Status: ${statusBadge(proposal.status)} + Commit ${escape(shortSha(sha))} + landed in the local bare repo (/app/repo in the container, + ~/repos/tdd.md.git on p620) via git plumbing. + No HTTP, no Forgejo, no SSH involved — just a real git commit on disk.

-${reviewedLine} -

Reviewing as ${escape(viewer)}. Approving sets the status; the live page does not change until you commit the patch on dev and run a deploy.

-
${actions}
-

diff (current ⇢ proposed)

-
-
-

current

-
${escape(currentBody)}
-
-
-

proposed

-
${escape(proposal.body)}
-
-
-

← back to queue

`; +

+ The container's content/ dir is copied from the working + tree at image build, and the next deploy fetches new commits from the + local bare repo before rebuilding — so this commit will outlive any + container restart. +

+

→ view the live page · edit again

`; return renderPage({ - title: `proposal #${proposal.id} — tdd.md`, + title: `applied · ${resolved.title} — tdd.md`, bodyHtml: layoutWrap(inner), noindex: true, bodyClass: editBodyClass, }); }; -// -------- admin gate page (not the owner) -------- +// -------- admin commit failed (Forgejo conflict / network / other) -------- -export const renderAdminGate = async (viewer: string | null): Promise => { - const inner = viewer - ? `

admin · access denied

Signed in as ${escape(viewer)}, but this area is owner-only. ← back home

` - : `

admin · sign in

`; +export const renderEditCommitFailed = async ( + resolved: ResolvedEdit, + failure: GitCommitFailure, +): Promise => { + const explanation = + failure.kind === "conflict" + ? "The branch tip moved while you were editing — someone else committed in between. Refresh the editor to load the latest version, then re-apply your change." + : failure.kind === "permission" + ? "The container can't write to the bare repo. Check that /home/scri/repos/tdd.md.git on p620 is mounted read-write into /app/repo." + : failure.kind === "not_found" + ? "The 'main' branch doesn't exist in the bare repo. Verify that ~/repos/tdd.md.git on p620 has a refs/heads/main." + : "git rejected the commit for an unexpected reason. See the message below."; + const inner = `

commit failed · ${escape(resolved.title)}

+

Your edit to ${escape(resolved.pageUrl)} was not applied. The live page is unchanged.

+

+ git returned ${escape(failure.kind)}. +

+

${escape(explanation)}

+
+ git stderr +
${escape(failure.message.slice(0, 2000))}
+
+

← back to the editor (refreshes the form)

`; return renderPage({ - title: "admin — tdd.md", + title: `commit failed · ${resolved.title} — tdd.md`, bodyHtml: layoutWrap(inner), noindex: true, bodyClass: editBodyClass,