Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

lith: version-control the knot ↔ GitHub mirror setup

Source-controls what lith already runs at /opt/ac-mirror/ so the
whole setup is reproducible if the host is rebuilt.

- lith/mirror/mirror.sh: the 37-line bidirectional sync script
(fetches both sides with "+" refspec, pushes whichever is behind,
exits 2 on true divergence).
- lith/mirror/ac-mirror.service + .timer: systemd oneshot + 60s
periodic trigger.
- lith/mirror/README.md: first-time setup walkthrough, operations,
and rationale for why this exists instead of a GitHub Action.

Verified live this session by forcing divergence in both directions
and confirming the mirror pushed the ahead tip back to the behind
side within seconds.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

+162
+101
lith/mirror/README.md
··· 1 + # lith mirror — knot ↔ GitHub bidirectional sync 2 + 3 + Keeps `main` in lockstep between the two remotes for 4 + `aesthetic.computer/core`: 5 + 6 + - **knot** (`knot.aesthetic.computer:aesthetic.computer/core`, Tangled) — 7 + what `lith` deploy pulls from. 8 + - **GitHub** (`whistlegraph/aesthetic-computer`) — what `session-server` 9 + deploy pulls from, plus Claude/tools usage. 10 + 11 + Runs as a systemd timer on `lith.aesthetic.computer`, every 60 seconds. 12 + Idempotent: exits 0 when the tips match, pushes the ahead side to the 13 + behind side otherwise, and exits 2 with a warning when the tips truly 14 + diverged (requires manual merge). 15 + 16 + ## Files in this directory 17 + 18 + - [`mirror.sh`](./mirror.sh) — the bidirectional sync script. 19 + - [`ac-mirror.service`](./ac-mirror.service) — systemd oneshot unit. 20 + - [`ac-mirror.timer`](./ac-mirror.timer) — every-60s trigger. 21 + 22 + ## First-time setup 23 + 24 + On the lith host: 25 + 26 + ```sh 27 + # 1. Bare clone (fetched over anon HTTPS; push goes via SSH keys below). 28 + mkdir -p /opt/ac-mirror 29 + git clone --bare https://knot.aesthetic.computer/aesthetic.computer/core \ 30 + /opt/ac-mirror/core 31 + cd /opt/ac-mirror/core 32 + git remote rename origin knot 33 + git remote set-url --push knot git@knot.aesthetic.computer:aesthetic.computer/core 34 + git remote add github https://github.com/whistlegraph/aesthetic-computer.git 35 + git remote set-url --push github git@github.com:whistlegraph/aesthetic-computer.git 36 + 37 + # 2. SSH keys (ed25519). 38 + # /root/.ssh/knot_push ← copy of the vault's home/.ssh/tangled 39 + # (the key registered on @jeffrey's 40 + # Tangled account, allowed to push). 41 + # /root/.ssh/github_mirror ← fresh ed25519 keypair generated on lith; 42 + # public half registered as a repo deploy key 43 + # with write access on 44 + # whistlegraph/aesthetic-computer, 45 + # encrypted private copy in 46 + # aesthetic-computer-vault/lith/mirror/. 47 + # Both files must be mode 600. 48 + 49 + # 3. Pin host keys to avoid interactive prompts. 50 + ssh-keyscan -t ed25519,rsa knot.aesthetic.computer >> /root/.ssh/known_hosts 51 + ssh-keyscan -t ed25519,rsa github.com >> /root/.ssh/known_hosts 52 + sort -u /root/.ssh/known_hosts -o /root/.ssh/known_hosts 53 + 54 + # 4. Install the script + units and enable the timer. 55 + install -m 755 mirror.sh /opt/ac-mirror/mirror.sh 56 + install -m 644 ac-mirror.service /etc/systemd/system/ac-mirror.service 57 + install -m 644 ac-mirror.timer /etc/systemd/system/ac-mirror.timer 58 + systemctl daemon-reload 59 + systemctl enable --now ac-mirror.timer 60 + ``` 61 + 62 + ## Observe / debug 63 + 64 + ```sh 65 + systemctl list-timers ac-mirror.timer 66 + journalctl -u ac-mirror -n 50 67 + # In sync = no output per run. A sync push logs one line: 68 + # 2026-04-20T22:31:27+00:00 → knot behind; pushing <sha> to knot. 69 + ``` 70 + 71 + ## Manual force 72 + 73 + ```sh 74 + # Run immediately (the timer fires hourly-ish otherwise on boot). 75 + systemctl start ac-mirror.service 76 + ``` 77 + 78 + ## Divergent heads 79 + 80 + If both sides received independent commits (true fork), the script exits 81 + `2` and logs: 82 + 83 + ``` 84 + ⚠️ divergent heads: knot=<sha> github=<sha> — skipping (manual resolution required) 85 + ``` 86 + 87 + Resolve by pulling both locally, merging with `git merge`, and pushing 88 + the merge commit. The mirror will then see both sides equal the merge 89 + tip and go back to green. 90 + 91 + ## Why this instead of GitHub Actions? 92 + 93 + A `.github/workflows/mirror-to-knot.yml` was tried first but: 94 + 95 + 1. GitHub Actions is billing-locked on the repo at the moment — 96 + the workflow never fires. 97 + 2. Tangled knot *also* reads `.github/workflows/*.yml` as pipelines, 98 + and sent failure emails about them. 99 + 100 + The systemd timer on lith is free, runs even when GitHub is unavailable, 101 + and keeps all credentials on a host we already control.
+13
lith/mirror/ac-mirror.service
··· 1 + [Unit] 2 + Description=Mirror main between knot and GitHub for aesthetic-computer/core 3 + After=network-online.target 4 + Wants=network-online.target 5 + 6 + [Service] 7 + Type=oneshot 8 + ExecStart=/opt/ac-mirror/mirror.sh 9 + # Don't flood logs with normal exits (code 0 = in sync, 1 = error, 2 = divergent). 10 + SuccessExitStatus=0 11 + 12 + [Install] 13 + WantedBy=multi-user.target
+11
lith/mirror/ac-mirror.timer
··· 1 + [Unit] 2 + Description=Run ac-mirror every 60 seconds 3 + 4 + [Timer] 5 + OnBootSec=60 6 + OnUnitActiveSec=60 7 + AccuracySec=10 8 + Unit=ac-mirror.service 9 + 10 + [Install] 11 + WantedBy=timers.target
+37
lith/mirror/mirror.sh
··· 1 + #!/usr/bin/env bash 2 + # ac-mirror.sh — bidirectional knot ↔ github mirror for the core repo. 3 + # Runs via systemd timer every 60s. Idempotent, exits fast when in sync. 4 + set -euo pipefail 5 + 6 + REPO=/opt/ac-mirror/core 7 + KNOT_KEY=/root/.ssh/knot_push 8 + GH_KEY=/root/.ssh/github_mirror 9 + BRANCH=main 10 + 11 + cd "$REPO" 12 + 13 + export GIT_SSH_COMMAND="ssh -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new \ 14 + -i $KNOT_KEY -i $GH_KEY" 15 + 16 + # "+" prefix → allow non-fast-forward fetches. A mirror must always track 17 + # wherever the remote actually is, even after force-pushes or rewinds. 18 + git fetch --quiet knot "+$BRANCH":refs/remotes/knot/$BRANCH 19 + git fetch --quiet github "+$BRANCH":refs/remotes/github/$BRANCH 20 + 21 + knot_head=$(git rev-parse "knot/$BRANCH") 22 + gh_head=$(git rev-parse "github/$BRANCH") 23 + 24 + if [ "$knot_head" = "$gh_head" ]; then 25 + exit 0 26 + fi 27 + 28 + if git merge-base --is-ancestor "$gh_head" "$knot_head"; then 29 + echo "$(date -Iseconds) → github behind; pushing $knot_head to github." 30 + git push --quiet github "$knot_head:refs/heads/$BRANCH" 31 + elif git merge-base --is-ancestor "$knot_head" "$gh_head"; then 32 + echo "$(date -Iseconds) → knot behind; pushing $gh_head to knot." 33 + git push --quiet knot "$gh_head:refs/heads/$BRANCH" 34 + else 35 + echo "$(date -Iseconds) ⚠️ divergent heads: knot=$knot_head github=$gh_head — skipping (manual resolution required)" >&2 36 + exit 2 37 + fi