Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

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

notepat build stream: --if-stale + --sync-spaces (DO Spaces archive)

--if-stale: skip rebuild when no amxd input changed since the last
successful build. Checks both committed diff (prev.piece_git…HEAD)
and uncommitted working-tree changes under INPUT_PATHS (piece,
bios, lib/, bundler.mjs). Deploy flow now passes this flag so infra-
only commits don't trigger a 60s rebuild + restart cycle.

--sync-spaces: after a successful build, PUTs the versioned amxd +
latest.json + root alias to s3://assets-aesthetic-computer/m4l/
notepat.com/ via DigitalOcean Spaces. Mirrors ac-os OTA: versioned
artifacts are immutable (long max-age + immutable cache-control),
latest.json is no-cache so the piece's staleness check always sees
fresh data. Credentials come from DO_SPACES_KEY/SECRET or AWS_*;
gracefully warns + skips when absent.

deploy.fish: sources /opt/ac/system/.env before running the build so
lith's configured DO_SPACES creds reach the script. Build command
becomes \`node ac-m4l/build-notepat.mjs --if-stale --sync-spaces\`.

Also adds npm run notepat:publish for local dev publish (build +
sync) without a full lith deploy.

+154 -6
+145 -1
ac-m4l/build-notepat.mjs
··· 25 25 import { createHash } from "node:crypto"; 26 26 import { execSync } from "node:child_process"; 27 27 import { fileURLToPath } from "node:url"; 28 + import { createRequire } from "node:module"; 28 29 29 30 const __dirname = path.dirname(fileURLToPath(import.meta.url)); 30 31 const REPO_ROOT = path.resolve(__dirname, ".."); ··· 38 39 const args = process.argv.slice(2); 39 40 const WITH_DESKTOP = args.includes("--desktop"); 40 41 const VERBOSE = args.includes("--log"); 42 + const IF_STALE = args.includes("--if-stale"); 43 + const SYNC_SPACES = args.includes("--sync-spaces"); 44 + 45 + // DO Spaces bucket where the versioned amxds live permanently (same 46 + // bucket as the rest of AC's CDN assets). Each deploy uploads the new 47 + // hash-qualified amxd here so lith's local /m4l/ mirror is just the 48 + // latest two — S3 is the long-term archive. 49 + const SPACES_BUCKET = "assets-aesthetic-computer"; 50 + const SPACES_ENDPOINT = "https://sfo3.digitaloceanspaces.com"; 51 + const SPACES_PREFIX = "m4l/notepat.com"; 52 + 53 + // Files that actually influence the amxd binary. Anything outside this 54 + // set (other pieces, docs, infra, other lith routes) shouldn't trigger 55 + // a rebuild when we're called with --if-stale. 56 + const INPUT_PATHS = [ 57 + "system/public/aesthetic.computer/disks/notepat-remote.mjs", 58 + "system/public/aesthetic.computer/bios.mjs", 59 + "system/public/aesthetic.computer/lib/", 60 + "oven/bundler.mjs", 61 + ]; 41 62 42 63 function gitHash() { 43 64 try { ··· 60 81 } 61 82 } 62 83 84 + async function readExistingManifest() { 85 + const manifestPath = path.join(DEVICE_DIR, "latest.json"); 86 + try { 87 + return JSON.parse(await fs.readFile(manifestPath, "utf8")); 88 + } catch { 89 + return null; 90 + } 91 + } 92 + 93 + function amxdInputsChangedSince(lastBuiltCommit) { 94 + try { 95 + const diff = execSync( 96 + `git diff --name-only ${lastBuiltCommit} HEAD -- ${INPUT_PATHS.map((p) => `'${p}'`).join(" ")}`, 97 + { cwd: REPO_ROOT, stdio: "pipe" }, 98 + ).toString().trim(); 99 + return diff ? diff.split("\n") : []; 100 + } catch { 101 + // git diff failed (invalid commit, etc.) — treat as needing rebuild. 102 + return null; 103 + } 104 + } 105 + 106 + // Uncommitted input changes — captures both unstaged and staged edits 107 + // under INPUT_PATHS. `git status --porcelain` is the right primitive 108 + // here; with pathspecs it narrows to just the files we care about. 109 + function amxdInputsUncommitted() { 110 + try { 111 + const out = execSync( 112 + `git status --porcelain -- ${INPUT_PATHS.map((p) => `'${p}'`).join(" ")}`, 113 + { cwd: REPO_ROOT, stdio: "pipe" }, 114 + ).toString().trim(); 115 + if (!out) return []; 116 + // Each line is "XY filename" — strip the status columns. 117 + return out.split("\n").map((l) => l.slice(3)); 118 + } catch { 119 + return []; 120 + } 121 + } 122 + 63 123 async function main() { 64 124 const bundlerPath = path.join(REPO_ROOT, "oven/bundler.mjs"); 65 - const { createM4DBundle } = await import(bundlerPath); 66 125 67 126 const hash = gitHash(); 68 127 const dirty = gitDirty(); 69 128 const version = dirty ? `${hash}-dirty` : hash; 70 129 130 + // --if-stale short-circuits when no amxd input has changed since the 131 + // last successful build. Cheaper than always rebuilding on deploys 132 + // that only touch unrelated files (docs, other pieces, infra). 133 + if (IF_STALE) { 134 + const prev = await readExistingManifest(); 135 + if (prev?.piece_git) { 136 + const committed = amxdInputsChangedSince(prev.piece_git) || []; 137 + const uncommitted = dirty ? amxdInputsUncommitted() : []; 138 + const combined = [...new Set([...committed, ...uncommitted])]; 139 + if (combined.length === 0) { 140 + console.log( 141 + `✓ skip — no amxd-input changes since ${prev.piece_git.slice(0, 9)}`, 142 + ); 143 + return; 144 + } 145 + console.log(`→ rebuild needed — ${combined.length} input(s) changed:`); 146 + for (const f of combined) console.log(` · ${f}`); 147 + } else { 148 + console.log("→ initial build — no prior manifest"); 149 + } 150 + } 151 + 152 + const { createM4DBundle } = await import(bundlerPath); 153 + 71 154 const onProgress = VERBOSE 72 155 ? (p) => console.log(`[${p.stage}] ${p.message}`) 73 156 : () => {}; ··· 121 204 const desktopPath = path.join(os.homedir(), "Desktop", filename); 122 205 await fs.writeFile(desktopPath, binary); 123 206 console.log(` ✓ ${desktopPath} (Desktop)`); 207 + } 208 + 209 + if (SYNC_SPACES) { 210 + await syncToSpaces({ binary, versionedName, manifest }); 211 + } 212 + } 213 + 214 + // Upload the versioned amxd + latest.json to DO Spaces so each build 215 + // has a durable permalink outside lith. Mirrors the ac-os OTA pattern: 216 + // versioned artifacts are immutable; `latest.json` is the rolling 217 + // pointer the piece's env-info fetch reads to detect stale installs. 218 + async function syncToSpaces({ binary, versionedName, manifest }) { 219 + const accessKeyId = 220 + process.env.DO_SPACES_KEY || process.env.AWS_ACCESS_KEY_ID; 221 + const secretAccessKey = 222 + process.env.DO_SPACES_SECRET || process.env.AWS_SECRET_ACCESS_KEY; 223 + if (!accessKeyId || !secretAccessKey) { 224 + console.warn( 225 + " ⚠ --sync-spaces set but no credentials (DO_SPACES_KEY/SECRET or AWS_ACCESS_KEY_ID/SECRET); skipping upload", 226 + ); 227 + return; 228 + } 229 + // Resolve @aws-sdk/client-s3 out of oven's node_modules — that's 230 + // where the dep already lives (oven depends on it for existing 231 + // S3 pipelines) and it saves root-level install duplication. 232 + const ovenRequire = createRequire(path.join(REPO_ROOT, "oven/package.json")); 233 + const { S3Client, PutObjectCommand } = ovenRequire("@aws-sdk/client-s3"); 234 + const s3 = new S3Client({ 235 + endpoint: SPACES_ENDPOINT, 236 + region: "sfo3", 237 + credentials: { accessKeyId, secretAccessKey }, 238 + }); 239 + const uploads = [ 240 + { 241 + key: `${SPACES_PREFIX}/${versionedName}`, 242 + body: binary, 243 + contentType: "application/octet-stream", 244 + }, 245 + { 246 + key: `${SPACES_PREFIX}/latest.json`, 247 + body: Buffer.from(JSON.stringify(manifest, null, 2) + "\n"), 248 + contentType: "application/json", 249 + }, 250 + { 251 + key: `${SPACES_PREFIX}.amxd`, // alias at m4l/notepat.com.amxd 252 + body: binary, 253 + contentType: "application/octet-stream", 254 + }, 255 + ]; 256 + for (const u of uploads) { 257 + await s3.send( 258 + new PutObjectCommand({ 259 + Bucket: SPACES_BUCKET, 260 + Key: u.key, 261 + Body: u.body, 262 + ACL: "public-read", 263 + ContentType: u.contentType, 264 + CacheControl: u.key.endsWith("latest.json") ? "no-cache" : "public,max-age=31536000,immutable", 265 + }), 266 + ); 267 + console.log(` ☁ s3://${SPACES_BUCKET}/${u.key}`); 124 268 } 125 269 } 126 270
+8 -5
lith/deploy.fish
··· 178 178 ssh -i $SSH_KEY $LITH_USER@$TARGET_HOST "cd $REMOTE_DIR/lith && npm install --omit=dev && cd $REMOTE_DIR/system && npm install --omit=dev && cd $REMOTE_DIR/oven && PUPPETEER_SKIP_DOWNLOAD=1 npm install --omit=dev" 179 179 180 180 # notepat.com amxd build stream. 181 - # Modeled after `ac-os upload`'s "always rebuild first" pattern so 182 - # notepat.com/amxd + /m4l/notepat.com/latest.json always reflect the 183 - # commit we just deployed (no "dirty" hashes from dev machine state). 184 - echo -e "$GREEN-> Building notepat.com.amxd from deployed commit...$NC" 185 - ssh -i $SSH_KEY $LITH_USER@$TARGET_HOST "cd $REMOTE_DIR && node ac-m4l/build-notepat.mjs" 181 + # Modeled after `ac-os upload`'s OTA flow: only rebuild + re-upload 182 + # when an amxd input actually changed since the last successful build 183 + # (via --if-stale), then push the versioned artifact + latest.json to 184 + # DO Spaces (--sync-spaces) so each release has a durable CDN URL 185 + # outside lith. Sourcing /opt/ac/system/.env before running picks up 186 + # DO_SPACES_* / AWS_* creds that lith.service already has configured. 187 + echo -e "$GREEN-> Refreshing notepat.com.amxd build stream...$NC" 188 + ssh -i $SSH_KEY $LITH_USER@$TARGET_HOST "cd $REMOTE_DIR && set -a && source system/.env 2>/dev/null || true; set +a; node ac-m4l/build-notepat.mjs --if-stale --sync-spaces" 186 189 187 190 # Install service file + Caddy config from the deployed checkout 188 191 echo -e "$GREEN-> Updating service + Caddy config...$NC"
+1
package.json
··· 80 80 "new": "node utilities/generate-new-piece.mjs", 81 81 "notepat:build": "node ac-m4l/build-notepat.mjs", 82 82 "notepat:build:desktop": "node ac-m4l/build-notepat.mjs --desktop --log", 83 + "notepat:publish": "node ac-m4l/build-notepat.mjs --sync-spaces --log", 83 84 "reload-piece": "curl -X POST -H \"Content-Type: application/json\" -d '{\"piece\": \"@digitpain/hello\"}' http://localhost:8082/reload", 84 85 "server:socket": "cd socket-server; npm run server", 85 86 "assets:sync:down": "aws s3 sync s3://assets-aesthetic-computer system/public/assets --endpoint-url https://sfo3.digitaloceanspaces.com --exclude 'false.work/spiderlily-*.zip*' || echo 'Sync completed with some directory conflicts (safe to ignore)'",