Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

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

tangled: add knot deployment + migrate to aesthetic.computer/core

Self-hosted Tangled knot at knot.aesthetic.computer on the PDS droplet.
Dual-push configured — origin now pushes to both Tangled and GitHub.
SCORE.md updated to reflect new home.

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

+926 -1
+4 -1
SCORE.md
··· 35 35 **Recipes:** See [USER-GUIDE.md](USER-GUIDE.md) for making paintings, playing melodies, and joining the community. 36 36 37 37 **Links:** 38 - - **GitHub**: https://github.com/whistlegraph/aesthetic-computer 38 + - **Tangled** (new home): https://tangled.org/aesthetic.computer/core 39 + - **GitHub** (deprecating): https://github.com/whistlegraph/aesthetic-computer 39 40 - **No Paint (predecessor)**: https://nopaint.art ([HN 2020](https://news.ycombinator.com/item?id=23546706)) 40 41 - **Notepat on HN**: https://news.ycombinator.com/item?id=41526754 42 + 43 + > We are migrating from GitHub to [Tangled](https://tangled.org), a decentralized code hosting platform built on AT Protocol. Our repo now lives on a self-hosted knot at `knot.aesthetic.computer` under the same ATProto identity (`did:plc:k3k3wknzkcnekbnyde4dbatz`) that powers our PDS, user handles, and federated content. GitHub will be maintained as a read-only mirror during the transition. 41 44 42 45 --- 43 46
+86
at/knot/README.md
··· 1 + # Tangled Knot — knot.aesthetic.computer 2 + 3 + Self-hosted [Tangled](https://tangled.org) knot server co-located on the PDS 4 + droplet (`at.aesthetic.computer`). Provides decentralized git hosting under 5 + AC's ATProto identity. 6 + 7 + ## Prerequisites 8 + 9 + 1. PDS droplet running at `at.aesthetic.computer` (165.227.120.137) 10 + 2. Vault file `aesthetic-computer-vault/at/knot.env` with: 11 + ``` 12 + KNOT_OWNER_DID=did:plc:your-did-here 13 + ``` 14 + Find your DID at https://tangled.org/settings 15 + 3. Tangled account with SSH key added at https://tangled.org/settings/keys 16 + 17 + ## Deploy 18 + 19 + ```fish 20 + cd at/knot/deployment 21 + fish deploy.fish 22 + ``` 23 + 24 + This will: 25 + - Install Go + build deps on the droplet 26 + - Create `git` user with SSH AuthorizedKeysCommand 27 + - Build the knot binary from source 28 + - Deploy systemd service + environment 29 + - Configure Caddy reverse proxy (TLS auto via Let's Encrypt) 30 + - Create `knot.aesthetic.computer` DNS via Cloudflare 31 + 32 + ## After Deploy 33 + 34 + 1. Verify: `curl https://knot.aesthetic.computer/` 35 + 2. Register knot at https://tangled.org/settings/knots — click verify 36 + 3. Create repo on Tangled, selecting `knot.aesthetic.computer` as host 37 + 4. Push: `git remote add tangled git@knot.aesthetic.computer:aesthetic-computer.git && git push tangled main` 38 + 39 + ## Unify Repo History 40 + 41 + Stitch the four predecessor repos into one continuous timeline: 42 + 43 + ```bash 44 + # Dry run first 45 + ./unify-repo-history.sh --dry-run 46 + 47 + # For real 48 + ./unify-repo-history.sh 49 + ``` 50 + 51 + Requires `git-filter-repo` (`pip install git-filter-repo`). 52 + 53 + ## Architecture 54 + 55 + ``` 56 + knot.aesthetic.computer (Caddy :443) 57 + └─ reverse proxy → localhost:5555 (knot public API) 58 + └─ websocket → localhost:5555/events 59 + 60 + SSH :22 61 + └─ Match User git → knot keys (AuthorizedKeysCommand) 62 + └─ git push/pull via SSH 63 + 64 + /home/git/ 65 + ├─ .knot.env # config 66 + ├─ repositories/ # bare git repos 67 + ├─ database/ # knotserver.db (SQLite) 68 + └─ log/ # knot.log 69 + ``` 70 + 71 + Co-hosts with PDS — same droplet, separate subdomain. 72 + 73 + ## Files 74 + 75 + ``` 76 + at/knot/ 77 + ├─ README.md 78 + ├─ unify-repo-history.sh # graft 4 repos → one history 79 + ├─ deployment/ 80 + │ ├─ deploy.sh # main deployment script 81 + │ ├─ deploy.fish # fish wrapper (loads vault env) 82 + │ └─ configure-dns.mjs # Cloudflare A record setup 83 + └─ infra/ 84 + ├─ Caddyfile # reverse proxy config 85 + └─ knotserver.service # systemd unit 86 + ```
+1
at/knot/deployment/.droplet_ip
··· 1 + 165.227.120.137
+140
at/knot/deployment/configure-dns.mjs
··· 1 + #!/usr/bin/env node 2 + // Configure Cloudflare DNS for Tangled Knot deployment 3 + // Creates A record for knot.aesthetic.computer 4 + 5 + import { readFileSync } from "fs"; 6 + import { resolve } from "path"; 7 + 8 + const vaultEnvPath = resolve( 9 + process.env.HOME, 10 + "aesthetic-computer/aesthetic-computer-vault/.devcontainer/envs/devcontainer.env", 11 + ); 12 + 13 + const envContent = readFileSync(vaultEnvPath, "utf-8"); 14 + const env = {}; 15 + envContent.split("\n").forEach((line) => { 16 + const match = line.match(/^([^#=]+)=(.*)$/); 17 + if (match) { 18 + const key = match[1].trim(); 19 + let value = match[2].trim(); 20 + if ( 21 + (value.startsWith('"') && value.endsWith('"')) || 22 + (value.startsWith("'") && value.endsWith("'")) 23 + ) { 24 + value = value.slice(1, -1); 25 + } 26 + env[key] = value; 27 + } 28 + }); 29 + 30 + const CLOUDFLARE_EMAIL = env.CLOUDFLARE_EMAIL; 31 + const CLOUDFLARE_API_KEY = env.CLOUDFLARE_API_KEY; 32 + const CLOUDFLARE_BASE_URL = "https://api.cloudflare.com/client/v4"; 33 + 34 + if (!CLOUDFLARE_EMAIL || !CLOUDFLARE_API_KEY) { 35 + console.error("x Missing Cloudflare credentials in vault"); 36 + console.error(" Expected in:", vaultEnvPath); 37 + process.exit(1); 38 + } 39 + 40 + const headers = { 41 + "X-Auth-Email": CLOUDFLARE_EMAIL, 42 + "X-Auth-Key": CLOUDFLARE_API_KEY, 43 + "Content-Type": "application/json", 44 + }; 45 + 46 + const dropletIP = readFileSync( 47 + resolve(import.meta.dirname, ".droplet_ip"), 48 + "utf-8", 49 + ).trim(); 50 + 51 + console.log("Configuring DNS for Tangled Knot"); 52 + console.log(` Domain: knot.aesthetic.computer`); 53 + console.log(` IP: ${dropletIP}`); 54 + console.log(""); 55 + 56 + async function fetchZone(zoneName) { 57 + const response = await fetch(`${CLOUDFLARE_BASE_URL}/zones`, { headers }); 58 + const zones = await response.json(); 59 + return zones.result?.find((zone) => zone.name === zoneName); 60 + } 61 + 62 + async function fetchARecord(zoneId, recordName) { 63 + const response = await fetch( 64 + `${CLOUDFLARE_BASE_URL}/zones/${zoneId}/dns_records?type=A&name=${recordName}`, 65 + { headers }, 66 + ); 67 + return response.json(); 68 + } 69 + 70 + async function updateDNSRecord(zoneId, recordId, data) { 71 + const response = await fetch( 72 + `${CLOUDFLARE_BASE_URL}/zones/${zoneId}/dns_records/${recordId}`, 73 + { method: "PUT", headers, body: JSON.stringify(data) }, 74 + ); 75 + return response.json(); 76 + } 77 + 78 + async function createDNSRecord(zoneId, data) { 79 + const response = await fetch( 80 + `${CLOUDFLARE_BASE_URL}/zones/${zoneId}/dns_records`, 81 + { method: "POST", headers, body: JSON.stringify(data) }, 82 + ); 83 + return response.json(); 84 + } 85 + 86 + async function createOrUpdateARecord(subdomain, ip) { 87 + const rootDomain = "aesthetic.computer"; 88 + const zoneId = (await fetchZone(rootDomain))?.id; 89 + 90 + if (!zoneId) { 91 + console.error(`x Zone ID not found for ${rootDomain}`); 92 + return false; 93 + } 94 + 95 + const recordResponse = await fetchARecord(zoneId, subdomain); 96 + const record = recordResponse.result?.[0]; 97 + 98 + const data = { 99 + type: "A", 100 + name: subdomain, 101 + content: ip, 102 + ttl: 600, 103 + proxied: false, // Direct connection for git SSH 104 + }; 105 + 106 + if (record) { 107 + if (record.content === ip) { 108 + console.log(`✓ ${subdomain} already points to ${ip}`); 109 + return true; 110 + } 111 + console.log(`i Updating ${subdomain}: ${record.content} -> ${ip}`); 112 + const result = await updateDNSRecord(zoneId, record.id, data); 113 + if (result.success) { 114 + console.log(`✓ Updated ${subdomain} -> ${ip}`); 115 + return true; 116 + } else { 117 + console.error(`x Failed to update ${subdomain}:`, result.errors); 118 + return false; 119 + } 120 + } else { 121 + console.log(`i Creating A record: ${subdomain} -> ${ip}`); 122 + const result = await createDNSRecord(zoneId, data); 123 + if (result.success) { 124 + console.log(`✓ Created ${subdomain} -> ${ip}`); 125 + return true; 126 + } else { 127 + console.error(`x Failed to create ${subdomain}:`, result.errors); 128 + return false; 129 + } 130 + } 131 + } 132 + 133 + const success = await createOrUpdateARecord("knot.aesthetic.computer", dropletIP); 134 + if (success) { 135 + console.log(""); 136 + console.log("DNS configured: knot.aesthetic.computer -> " + dropletIP); 137 + } else { 138 + console.error("DNS configuration failed"); 139 + } 140 + process.exit(success ? 0 : 1);
+59
at/knot/deployment/deploy.fish
··· 1 + #!/usr/bin/env fish 2 + # Deploy Aesthetic Computer Tangled Knot 3 + # Wraps deploy.sh with vault environment loading 4 + 5 + set SCRIPT_DIR (dirname (status --current-filename)) 6 + set VAULT_DIR (cd "$SCRIPT_DIR/../../../aesthetic-computer-vault" 2>/dev/null && pwd; or echo "") 7 + 8 + echo "╔════════════════════════════════════════════════════════╗" 9 + echo "║ Aesthetic Computer Knot Deployment (Tangled) ║" 10 + echo "╚════════════════════════════════════════════════════════╝" 11 + echo "" 12 + 13 + # Load knot-specific vault env 14 + if test -f "$VAULT_DIR/at/knot.env" 15 + echo "✓ Loading knot config from vault..." 16 + for line in (cat "$VAULT_DIR/at/knot.env" | grep -v '^#' | grep -v '^$') 17 + set -l parts (string split '=' -- $line) 18 + if test (count $parts) -ge 2 19 + set -gx $parts[1] (string join '=' -- $parts[2..-1]) 20 + end 21 + end 22 + else 23 + echo "! Vault knot config not found: $VAULT_DIR/at/knot.env" 24 + echo "" 25 + echo " Create it with:" 26 + echo " KNOT_OWNER_DID=did:plc:your-did-here" 27 + echo "" 28 + echo " Find your DID at https://tangled.org/settings" 29 + echo "" 30 + 31 + # Default to @aesthetic.computer DID (jeffrey) 32 + if test -z "$KNOT_OWNER_DID" 33 + set -gx KNOT_OWNER_DID "did:plc:k3k3wknzkcnekbnyde4dbatz" 34 + echo " Using default DID: $KNOT_OWNER_DID" 35 + end 36 + end 37 + 38 + # Load shared deploy env (for Cloudflare creds, DO token, etc.) 39 + if test -f "$VAULT_DIR/at/deploy.env" 40 + echo "✓ Loading shared deploy config..." 41 + for line in (cat "$VAULT_DIR/at/deploy.env" | grep -v '^#' | grep -v '^$') 42 + set -l parts (string split '=' -- $line) 43 + if test (count $parts) -ge 2 44 + set -gx $parts[1] (string join '=' -- $parts[2..-1]) 45 + end 46 + end 47 + end 48 + 49 + # Export for bash script 50 + export KNOT_OWNER_DID 51 + export DROPLET_IP 52 + 53 + echo "" 54 + echo "→ Starting knot deployment..." 55 + echo "" 56 + 57 + set -x AUTO_CONFIRM yes 58 + 59 + bash "$SCRIPT_DIR/deploy.sh" $argv
+356
at/knot/deployment/deploy.sh
··· 1 + #!/usr/bin/env bash 2 + # Aesthetic Computer Knot Deployment Script 3 + # Installs a Tangled knot server on the existing PDS droplet (at.aesthetic.computer) 4 + # Target: knot.aesthetic.computer 5 + 6 + set -euo pipefail 7 + 8 + RED='\033[0;31m' 9 + GREEN='\033[0;32m' 10 + YELLOW='\033[1;33m' 11 + BLUE='\033[0;34m' 12 + NC='\033[0m' 13 + 14 + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 15 + PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" 16 + VAULT_DIR="$PROJECT_ROOT/../aesthetic-computer-vault" 17 + INFRA_DIR="$SCRIPT_DIR/../infra" 18 + 19 + # Knot configuration 20 + KNOT_DOMAIN="knot.aesthetic.computer" 21 + KNOT_PUBLIC_PORT=5555 22 + KNOT_INTERNAL_PORT=5444 23 + APPVIEW_ENDPOINT="https://tangled.org" 24 + 25 + # PDS droplet — knot co-hosts here 26 + PDS_DEPLOY_DIR="$SCRIPT_DIR/../../pds/deployment/digitalocean" 27 + DROPLET_IP_FILE="$PDS_DEPLOY_DIR/.droplet_ip" 28 + 29 + info() { echo -e "${BLUE}i${NC} $1"; } 30 + success() { echo -e "${GREEN}✓${NC} $1"; } 31 + warning() { echo -e "${YELLOW}!${NC} $1"; } 32 + error() { echo -e "${RED}x${NC} $1"; exit 1; } 33 + 34 + get_droplet_ip() { 35 + if [ -f "$DROPLET_IP_FILE" ]; then 36 + DROPLET_IP=$(cat "$DROPLET_IP_FILE") 37 + elif [ -n "${DROPLET_IP:-}" ]; then 38 + : # already set via env 39 + else 40 + error "Droplet IP not found. Set DROPLET_IP or ensure $DROPLET_IP_FILE exists." 41 + fi 42 + info "Target: $DROPLET_IP ($KNOT_DOMAIN)" 43 + } 44 + 45 + get_ssh_key() { 46 + # Try PDS key first, then default 47 + if [ -f ~/.ssh/aesthetic_pds ]; then 48 + SSH_KEY=~/.ssh/aesthetic_pds 49 + elif [ -f ~/.ssh/id_ed25519 ]; then 50 + SSH_KEY=~/.ssh/id_ed25519 51 + else 52 + error "No SSH key found. Expected ~/.ssh/aesthetic_pds or ~/.ssh/id_ed25519" 53 + fi 54 + info "SSH key: $SSH_KEY" 55 + } 56 + 57 + get_owner_did() { 58 + if [ -n "${KNOT_OWNER_DID:-}" ]; then 59 + info "Owner DID: $KNOT_OWNER_DID" 60 + return 61 + fi 62 + 63 + # Try to load from vault 64 + if [ -f "$VAULT_DIR/at/knot.env" ]; then 65 + source "$VAULT_DIR/at/knot.env" 66 + if [ -n "${KNOT_OWNER_DID:-}" ]; then 67 + info "Owner DID (from vault): $KNOT_OWNER_DID" 68 + return 69 + fi 70 + fi 71 + 72 + # Default: @aesthetic.computer DID (jeffrey) 73 + KNOT_OWNER_DID="did:plc:k3k3wknzkcnekbnyde4dbatz" 74 + info "Owner DID (aesthetic.computer): $KNOT_OWNER_DID" 75 + } 76 + 77 + wait_for_ssh() { 78 + info "Waiting for SSH..." 79 + for i in {1..20}; do 80 + if ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=no -i "$SSH_KEY" "root@$DROPLET_IP" "echo ok" &>/dev/null; then 81 + success "SSH ready" 82 + return 83 + fi 84 + sleep 3 85 + done 86 + error "SSH timeout" 87 + } 88 + 89 + remote() { 90 + ssh -i "$SSH_KEY" "root@$DROPLET_IP" "$@" 91 + } 92 + 93 + remote_script() { 94 + ssh -i "$SSH_KEY" "root@$DROPLET_IP" 'bash -s' < "$1" 95 + } 96 + 97 + install_go() { 98 + info "Checking Go on remote..." 99 + if remote "go version" &>/dev/null; then 100 + success "Go already installed" 101 + return 102 + fi 103 + 104 + info "Installing Go..." 105 + remote << 'ENDSSH' 106 + set -euo pipefail 107 + cd /tmp 108 + curl -sLO https://go.dev/dl/go1.24.1.linux-amd64.tar.gz 109 + rm -rf /usr/local/go 110 + tar -C /usr/local -xzf go1.24.1.linux-amd64.tar.gz 111 + rm go1.24.1.linux-amd64.tar.gz 112 + 113 + # Add to PATH for all users 114 + echo 'export PATH=$PATH:/usr/local/go/bin' > /etc/profile.d/go.sh 115 + export PATH=$PATH:/usr/local/go/bin 116 + go version 117 + ENDSSH 118 + success "Go installed" 119 + } 120 + 121 + install_build_deps() { 122 + info "Installing build dependencies..." 123 + remote << 'ENDSSH' 124 + set -euo pipefail 125 + apt-get update -qq 126 + apt-get install -y -qq gcc git make sqlite3 > /dev/null 127 + ENDSSH 128 + success "Build dependencies installed" 129 + } 130 + 131 + create_git_user() { 132 + info "Creating git user..." 133 + if remote "id git" &>/dev/null; then 134 + success "git user already exists" 135 + return 136 + fi 137 + 138 + remote << 'ENDSSH' 139 + set -euo pipefail 140 + adduser --disabled-password --gecos "" git 141 + mkdir -p /home/git/.ssh 142 + chown -R git:git /home/git 143 + ENDSSH 144 + success "git user created" 145 + } 146 + 147 + build_knot() { 148 + info "Building knot binary..." 149 + remote << 'ENDSSH' 150 + set -euo pipefail 151 + export PATH=$PATH:/usr/local/go/bin 152 + 153 + cd /tmp 154 + rm -rf tangled-core 155 + 156 + # Clone from Tangled's own hosting 157 + git clone https://tangled.org/@tangled.org/core tangled-core 158 + cd tangled-core 159 + 160 + CGO_ENABLED=1 go build -o knot ./cmd/knot 161 + 162 + mv knot /usr/local/bin/knot 163 + chown root:root /usr/local/bin/knot 164 + chmod 755 /usr/local/bin/knot 165 + 166 + rm -rf /tmp/tangled-core 167 + 168 + /usr/local/bin/knot --help || true 169 + ENDSSH 170 + success "Knot binary built and installed" 171 + } 172 + 173 + configure_ssh_knot() { 174 + info "Configuring SSH for knot..." 175 + remote << 'ENDSSH' 176 + set -euo pipefail 177 + 178 + # Configure SSH AuthorizedKeysCommand for git user 179 + cat > /etc/ssh/sshd_config.d/tangled-knot.conf << 'SSHEOF' 180 + Match User git 181 + AuthorizedKeysCommand /usr/local/bin/knot keys -o authorized-keys 182 + AuthorizedKeysCommandUser nobody 183 + SSHEOF 184 + 185 + # Reload SSH 186 + systemctl reload ssh || systemctl reload sshd 187 + 188 + echo "SSH configured for knot" 189 + ENDSSH 190 + success "SSH configured for git user" 191 + } 192 + 193 + deploy_knot_env() { 194 + info "Deploying knot environment..." 195 + remote "cat > /home/git/.knot.env" << EOF 196 + KNOT_REPO_SCAN_PATH=/home/git/repositories 197 + KNOT_SERVER_HOSTNAME=$KNOT_DOMAIN 198 + APPVIEW_ENDPOINT=$APPVIEW_ENDPOINT 199 + KNOT_SERVER_OWNER=$KNOT_OWNER_DID 200 + KNOT_SERVER_INTERNAL_LISTEN_ADDR=127.0.0.1:$KNOT_INTERNAL_PORT 201 + KNOT_SERVER_LISTEN_ADDR=127.0.0.1:$KNOT_PUBLIC_PORT 202 + KNOT_SERVER_DB_PATH=/home/git/database/knotserver.db 203 + EOF 204 + 205 + remote << 'ENDSSH' 206 + set -euo pipefail 207 + chown git:git /home/git/.knot.env 208 + chmod 600 /home/git/.knot.env 209 + mkdir -p /home/git/repositories /home/git/database /home/git/log 210 + chown -R git:git /home/git/repositories /home/git/database /home/git/log 211 + 212 + # Optional MOTD 213 + printf "aesthetic computer knot\n" > /home/git/motd 214 + chown git:git /home/git/motd 215 + ENDSSH 216 + success "Knot environment deployed" 217 + } 218 + 219 + deploy_systemd_service() { 220 + info "Deploying systemd service..." 221 + cat "$INFRA_DIR/knotserver.service" | remote "cat > /etc/systemd/system/knotserver.service" 222 + remote << 'ENDSSH' 223 + set -euo pipefail 224 + systemctl daemon-reload 225 + systemctl enable knotserver 226 + systemctl start knotserver 227 + sleep 2 228 + systemctl status knotserver --no-pager || true 229 + ENDSSH 230 + success "Knot service running" 231 + } 232 + 233 + configure_caddy() { 234 + info "Configuring Caddy reverse proxy..." 235 + 236 + # Check if Caddy is managing PDS 237 + if remote "systemctl is-active caddy" &>/dev/null; then 238 + info "Caddy already running (PDS), adding knot block..." 239 + 240 + # Append knot config to existing Caddyfile 241 + remote "cat >> /etc/caddy/Caddyfile" << EOF 242 + 243 + # Tangled Knot Server 244 + $KNOT_DOMAIN { 245 + reverse_proxy localhost:$KNOT_PUBLIC_PORT 246 + 247 + @websocket { 248 + header Connection *Upgrade* 249 + header Upgrade websocket 250 + } 251 + reverse_proxy @websocket localhost:$KNOT_PUBLIC_PORT 252 + } 253 + EOF 254 + remote "systemctl reload caddy" 255 + else 256 + # PDS might use its own TLS (common with bluesky PDS installer) 257 + # Install Caddy for knot only 258 + info "Installing Caddy..." 259 + remote << 'ENDSSH' 260 + set -euo pipefail 261 + apt-get install -y -qq debian-keyring debian-archive-keyring apt-transport-https curl > /dev/null 262 + curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg 2>/dev/null 263 + curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | tee /etc/apt/sources.list.d/caddy-stable.list > /dev/null 264 + apt-get update -qq 265 + apt-get install -y -qq caddy > /dev/null 266 + ENDSSH 267 + 268 + cat "$INFRA_DIR/Caddyfile" | remote "cat > /etc/caddy/Caddyfile" 269 + remote "systemctl enable caddy && systemctl restart caddy" 270 + fi 271 + 272 + success "Caddy configured for $KNOT_DOMAIN" 273 + } 274 + 275 + configure_dns() { 276 + info "Configuring DNS via Cloudflare..." 277 + 278 + # Write droplet IP for the DNS script 279 + echo "$DROPLET_IP" > "$SCRIPT_DIR/.droplet_ip" 280 + 281 + node "$SCRIPT_DIR/configure-dns.mjs" || error "DNS configuration failed" 282 + 283 + success "DNS configured" 284 + } 285 + 286 + verify_deployment() { 287 + info "Verifying deployment..." 288 + echo "" 289 + 290 + # Check systemd 291 + if remote "systemctl is-active knotserver" &>/dev/null; then 292 + success "knotserver service: active" 293 + else 294 + warning "knotserver service: not running" 295 + remote "journalctl -u knotserver --no-pager -n 20" || true 296 + fi 297 + 298 + # Check local port 299 + if remote "curl -sf http://localhost:$KNOT_PUBLIC_PORT/ > /dev/null 2>&1"; then 300 + success "Knot responding on localhost:$KNOT_PUBLIC_PORT" 301 + else 302 + warning "Knot not responding on localhost:$KNOT_PUBLIC_PORT yet" 303 + fi 304 + 305 + echo "" 306 + info "Next steps:" 307 + echo " 1. Verify DNS: dig $KNOT_DOMAIN" 308 + echo " 2. Check HTTPS: curl https://$KNOT_DOMAIN/" 309 + echo " 3. Register knot at https://tangled.org/settings/knots" 310 + echo " 4. Add SSH key at https://tangled.org/settings/keys" 311 + echo " 5. Push repos: git remote add tangled git@$KNOT_DOMAIN:aesthetic-computer.git" 312 + echo "" 313 + } 314 + 315 + main() { 316 + echo "" 317 + echo "╔════════════════════════════════════════════════════════╗" 318 + echo "║ Aesthetic Computer Knot Deployment (Tangled) ║" 319 + echo "╚════════════════════════════════════════════════════════╝" 320 + echo "" 321 + 322 + get_droplet_ip 323 + get_ssh_key 324 + get_owner_did 325 + echo "" 326 + 327 + if [[ "${AUTO_CONFIRM:-no}" != "yes" ]]; then 328 + read -p "Deploy knot to $DROPLET_IP as $KNOT_DOMAIN? (y/N) " -n 1 -r 329 + echo "" 330 + if [[ ! $REPLY =~ ^[Yy]$ ]]; then 331 + error "Cancelled" 332 + fi 333 + fi 334 + 335 + echo "" 336 + wait_for_ssh 337 + install_build_deps 338 + install_go 339 + create_git_user 340 + build_knot 341 + configure_ssh_knot 342 + deploy_knot_env 343 + deploy_systemd_service 344 + configure_dns 345 + configure_caddy 346 + echo "" 347 + verify_deployment 348 + 349 + success "Knot deployment complete!" 350 + } 351 + 352 + if [ $# -gt 0 ]; then 353 + "$@" 354 + else 355 + main 356 + fi
+13
at/knot/infra/Caddyfile
··· 1 + # Tangled Knot Server — knot.aesthetic.computer 2 + # Only used if Caddy is not already running for PDS. 3 + # If Caddy is already active, the deploy script appends to the existing Caddyfile. 4 + 5 + knot.aesthetic.computer { 6 + reverse_proxy localhost:5555 7 + 8 + @websocket { 9 + header Connection *Upgrade* 10 + header Upgrade websocket 11 + } 12 + reverse_proxy @websocket localhost:5555 13 + }
+23
at/knot/infra/knotserver.service
··· 1 + [Unit] 2 + Description=Tangled Knot Server (aesthetic computer) 3 + After=network.target 4 + 5 + [Service] 6 + Type=simple 7 + User=git 8 + Group=git 9 + WorkingDirectory=/home/git 10 + EnvironmentFile=/home/git/.knot.env 11 + ExecStart=/usr/local/bin/knot server 12 + Restart=on-failure 13 + RestartSec=5 14 + StandardOutput=append:/home/git/log/knot.log 15 + StandardError=append:/home/git/log/knot.log 16 + 17 + # Hardening 18 + NoNewPrivileges=true 19 + ProtectSystem=strict 20 + ReadWritePaths=/home/git 21 + 22 + [Install] 23 + WantedBy=multi-user.target
+244
at/knot/unify-repo-history.sh
··· 1 + #!/usr/bin/env bash 2 + # Unify Aesthetic Computer repo history 3 + # 4 + # Grafts the four successive repositories into one continuous git history: 5 + # 1. whistlegraph/system-ac (Aug-Dec 2021, 38 commits) 6 + # 2. whistlegraph/disks-ac (Oct-Dec 2021, companion) 7 + # 3. whistlegraph/2022.aesthetic.computer (Dec 2021-Dec 2022, ~500 commits) 8 + # 4. whistlegraph/aesthetic-computer (Dec 2022-present, 11k+ commits) 9 + # 10 + # After grafting, the unified history is pushed to the Tangled knot. 11 + # 12 + # Usage: 13 + # ./unify-repo-history.sh [--dry-run] [--work-dir /path/to/workdir] 14 + # 15 + # Requirements: 16 + # - git-filter-repo (pip install git-filter-repo) 17 + # - Access to all four GitHub repos 18 + 19 + set -euo pipefail 20 + 21 + RED='\033[0;31m' 22 + GREEN='\033[0;32m' 23 + YELLOW='\033[1;33m' 24 + BLUE='\033[0;34m' 25 + NC='\033[0m' 26 + 27 + info() { echo -e "${BLUE}i${NC} $1"; } 28 + success() { echo -e "${GREEN}✓${NC} $1"; } 29 + warning() { echo -e "${YELLOW}!${NC} $1"; } 30 + error() { echo -e "${RED}x${NC} $1"; exit 1; } 31 + 32 + # Defaults 33 + DRY_RUN=false 34 + WORK_DIR="/tmp/ac-unified-history" 35 + KNOT_REMOTE="git@knot.aesthetic.computer" 36 + 37 + # The four repos in chronological order 38 + REPO_1="https://github.com/whistlegraph/system-ac.git" 39 + REPO_2="https://github.com/whistlegraph/disks-ac.git" 40 + REPO_3="https://github.com/whistlegraph/2022.aesthetic.computer.git" 41 + REPO_4="https://github.com/whistlegraph/aesthetic-computer.git" 42 + 43 + # Parse args 44 + while [[ $# -gt 0 ]]; do 45 + case $1 in 46 + --dry-run) DRY_RUN=true; shift ;; 47 + --work-dir) WORK_DIR="$2"; shift 2 ;; 48 + --knot-remote) KNOT_REMOTE="$2"; shift 2 ;; 49 + *) error "Unknown option: $1" ;; 50 + esac 51 + done 52 + 53 + check_requirements() { 54 + if ! command -v git-filter-repo &>/dev/null; then 55 + error "git-filter-repo not found. Install: pip install git-filter-repo" 56 + fi 57 + info "Requirements OK" 58 + } 59 + 60 + clone_repos() { 61 + mkdir -p "$WORK_DIR" 62 + cd "$WORK_DIR" 63 + 64 + info "Cloning all four repositories..." 65 + 66 + if [ ! -d "system-ac" ]; then 67 + git clone "$REPO_1" system-ac 68 + success "Cloned system-ac" 69 + else 70 + warning "system-ac already cloned" 71 + fi 72 + 73 + if [ ! -d "disks-ac" ]; then 74 + git clone "$REPO_2" disks-ac 75 + success "Cloned disks-ac" 76 + else 77 + warning "disks-ac already cloned" 78 + fi 79 + 80 + if [ ! -d "2022.aesthetic.computer" ]; then 81 + git clone "$REPO_3" 2022.aesthetic.computer 82 + success "Cloned 2022.aesthetic.computer" 83 + else 84 + warning "2022.aesthetic.computer already cloned" 85 + fi 86 + 87 + if [ ! -d "aesthetic-computer" ]; then 88 + git clone --mirror "$REPO_4" aesthetic-computer.git 89 + # Also make a working copy for grafting 90 + git clone aesthetic-computer.git aesthetic-computer 91 + success "Cloned aesthetic-computer" 92 + else 93 + warning "aesthetic-computer already cloned" 94 + fi 95 + } 96 + 97 + find_boundary_commits() { 98 + info "Finding boundary commits..." 99 + echo "" 100 + 101 + # Last commit of each predecessor repo 102 + cd "$WORK_DIR/system-ac" 103 + SYSTEM_AC_LAST=$(git log --format="%H" -1) 104 + SYSTEM_AC_LAST_DATE=$(git log --format="%ci" -1) 105 + info "system-ac last: $SYSTEM_AC_LAST ($SYSTEM_AC_LAST_DATE)" 106 + 107 + cd "$WORK_DIR/disks-ac" 108 + DISKS_AC_LAST=$(git log --format="%H" -1) 109 + DISKS_AC_LAST_DATE=$(git log --format="%ci" -1) 110 + info "disks-ac last: $DISKS_AC_LAST ($DISKS_AC_LAST_DATE)" 111 + 112 + cd "$WORK_DIR/2022.aesthetic.computer" 113 + REPO_2022_LAST=$(git log --format="%H" -1) 114 + REPO_2022_LAST_DATE=$(git log --format="%ci" -1) 115 + info "2022.aesthetic.computer last: $REPO_2022_LAST ($REPO_2022_LAST_DATE)" 116 + 117 + # First (root) commit of each successor repo 118 + cd "$WORK_DIR/2022.aesthetic.computer" 119 + REPO_2022_FIRST=$(git rev-list --max-parents=0 HEAD | tail -1) 120 + REPO_2022_FIRST_DATE=$(git log --format="%ci" "$REPO_2022_FIRST" -1) 121 + info "2022.aesthetic.computer first: $REPO_2022_FIRST ($REPO_2022_FIRST_DATE)" 122 + 123 + cd "$WORK_DIR/aesthetic-computer" 124 + CURRENT_FIRST=$(git rev-list --max-parents=0 HEAD | tail -1) 125 + CURRENT_FIRST_DATE=$(git log --format="%ci" "$CURRENT_FIRST" -1) 126 + info "aesthetic-computer first: $CURRENT_FIRST ($CURRENT_FIRST_DATE)" 127 + 128 + echo "" 129 + info "Graft plan:" 130 + echo " system-ac ($SYSTEM_AC_LAST_DATE)" 131 + echo " + disks-ac ($DISKS_AC_LAST_DATE)" 132 + echo " -> 2022.aesthetic.computer root ($REPO_2022_FIRST_DATE)" 133 + echo " -> aesthetic-computer root ($CURRENT_FIRST_DATE)" 134 + echo "" 135 + } 136 + 137 + build_unified_repo() { 138 + info "Building unified repository..." 139 + 140 + cd "$WORK_DIR/aesthetic-computer" 141 + 142 + # Add predecessor repos as remotes 143 + git remote add system-ac "$WORK_DIR/system-ac" 2>/dev/null || true 144 + git remote add disks-ac "$WORK_DIR/disks-ac" 2>/dev/null || true 145 + git remote add repo-2022 "$WORK_DIR/2022.aesthetic.computer" 2>/dev/null || true 146 + 147 + git fetch system-ac 148 + git fetch disks-ac 149 + git fetch repo-2022 150 + 151 + success "Fetched all predecessor histories" 152 + 153 + # Graft: 2022 repo's root commit gets system-ac and disks-ac as parents 154 + info "Grafting 2022.aesthetic.computer onto system-ac + disks-ac..." 155 + git replace --graft "$REPO_2022_FIRST" "$SYSTEM_AC_LAST" "$DISKS_AC_LAST" 156 + 157 + # Graft: current repo's root commit gets 2022 repo's last commit as parent 158 + info "Grafting aesthetic-computer onto 2022.aesthetic.computer..." 159 + git replace --graft "$CURRENT_FIRST" "$REPO_2022_LAST" 160 + 161 + success "Grafts applied" 162 + 163 + # Verify the chain 164 + info "Verifying unified history..." 165 + TOTAL_COMMITS=$(git log --oneline | wc -l) 166 + EARLIEST=$(git log --format="%ci %s" --reverse | head -1) 167 + LATEST=$(git log --format="%ci %s" -1) 168 + echo " Total commits: $TOTAL_COMMITS" 169 + echo " Earliest: $EARLIEST" 170 + echo " Latest: $LATEST" 171 + echo "" 172 + 173 + if [ "$DRY_RUN" = true ]; then 174 + warning "Dry run — grafts are temporary (git replace refs only)" 175 + warning "Run without --dry-run to make permanent with filter-repo" 176 + return 177 + fi 178 + 179 + # Make grafts permanent 180 + info "Making grafts permanent with filter-repo..." 181 + git filter-repo --force 182 + 183 + success "Unified history is permanent" 184 + 185 + # Clean up remotes 186 + git remote remove system-ac 2>/dev/null || true 187 + git remote remove disks-ac 2>/dev/null || true 188 + git remote remove repo-2022 2>/dev/null || true 189 + } 190 + 191 + push_to_knot() { 192 + if [ "$DRY_RUN" = true ]; then 193 + info "[dry-run] Would push to $KNOT_REMOTE:aesthetic-computer.git" 194 + return 195 + fi 196 + 197 + cd "$WORK_DIR/aesthetic-computer" 198 + 199 + info "Adding Tangled knot remote..." 200 + git remote add tangled "$KNOT_REMOTE:aesthetic-computer.git" 2>/dev/null || \ 201 + git remote set-url tangled "$KNOT_REMOTE:aesthetic-computer.git" 202 + 203 + info "Pushing unified history to knot..." 204 + git push tangled --all --force 205 + git push tangled --tags 206 + 207 + success "Pushed to $KNOT_REMOTE" 208 + } 209 + 210 + main() { 211 + echo "" 212 + echo "╔════════════════════════════════════════════════════════╗" 213 + echo "║ Aesthetic Computer — Unify Repository History ║" 214 + echo "╚════════════════════════════════════════════════════════╝" 215 + echo "" 216 + 217 + if [ "$DRY_RUN" = true ]; then 218 + warning "DRY RUN MODE — no permanent changes" 219 + echo "" 220 + fi 221 + 222 + info "Work directory: $WORK_DIR" 223 + echo "" 224 + 225 + check_requirements 226 + clone_repos 227 + find_boundary_commits 228 + build_unified_repo 229 + push_to_knot 230 + 231 + echo "" 232 + success "Done!" 233 + echo "" 234 + info "Unified history at: $WORK_DIR/aesthetic-computer" 235 + echo "" 236 + echo "To use in your working copy:" 237 + echo " cd /workspaces/aesthetic-computer" 238 + echo " git remote add tangled $KNOT_REMOTE:aesthetic-computer.git" 239 + echo " git fetch tangled" 240 + echo " git push tangled main" 241 + echo "" 242 + } 243 + 244 + main