diff --git a/ac-m4l/build-notepat.mjs b/ac-m4l/build-notepat.mjs index 2f30ae1933..78301e6b7f 100644 --- a/ac-m4l/build-notepat.mjs +++ b/ac-m4l/build-notepat.mjs @@ -25,6 +25,7 @@ import os from "node:os"; import { createHash } from "node:crypto"; import { execSync } from "node:child_process"; import { fileURLToPath } from "node:url"; +import { createRequire } from "node:module"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = path.resolve(__dirname, ".."); @@ -38,6 +39,26 @@ process.env.AC_SOURCE_DIR = process.env.AC_SOURCE_DIR || AC_SOURCE_DIR; const args = process.argv.slice(2); const WITH_DESKTOP = args.includes("--desktop"); const VERBOSE = args.includes("--log"); +const IF_STALE = args.includes("--if-stale"); +const SYNC_SPACES = args.includes("--sync-spaces"); + +// DO Spaces bucket where the versioned amxds live permanently (same +// bucket as the rest of AC's CDN assets). Each deploy uploads the new +// hash-qualified amxd here so lith's local /m4l/ mirror is just the +// latest two — S3 is the long-term archive. +const SPACES_BUCKET = "assets-aesthetic-computer"; +const SPACES_ENDPOINT = "https://sfo3.digitaloceanspaces.com"; +const SPACES_PREFIX = "m4l/notepat.com"; + +// Files that actually influence the amxd binary. Anything outside this +// set (other pieces, docs, infra, other lith routes) shouldn't trigger +// a rebuild when we're called with --if-stale. +const INPUT_PATHS = [ + "system/public/aesthetic.computer/disks/notepat-remote.mjs", + "system/public/aesthetic.computer/bios.mjs", + "system/public/aesthetic.computer/lib/", + "oven/bundler.mjs", +]; function gitHash() { try { @@ -60,14 +81,76 @@ function gitDirty() { } } +async function readExistingManifest() { + const manifestPath = path.join(DEVICE_DIR, "latest.json"); + try { + return JSON.parse(await fs.readFile(manifestPath, "utf8")); + } catch { + return null; + } +} + +function amxdInputsChangedSince(lastBuiltCommit) { + try { + const diff = execSync( + `git diff --name-only ${lastBuiltCommit} HEAD -- ${INPUT_PATHS.map((p) => `'${p}'`).join(" ")}`, + { cwd: REPO_ROOT, stdio: "pipe" }, + ).toString().trim(); + return diff ? diff.split("\n") : []; + } catch { + // git diff failed (invalid commit, etc.) — treat as needing rebuild. + return null; + } +} + +// Uncommitted input changes — captures both unstaged and staged edits +// under INPUT_PATHS. `git status --porcelain` is the right primitive +// here; with pathspecs it narrows to just the files we care about. +function amxdInputsUncommitted() { + try { + const out = execSync( + `git status --porcelain -- ${INPUT_PATHS.map((p) => `'${p}'`).join(" ")}`, + { cwd: REPO_ROOT, stdio: "pipe" }, + ).toString().trim(); + if (!out) return []; + // Each line is "XY filename" — strip the status columns. + return out.split("\n").map((l) => l.slice(3)); + } catch { + return []; + } +} + async function main() { const bundlerPath = path.join(REPO_ROOT, "oven/bundler.mjs"); - const { createM4DBundle } = await import(bundlerPath); const hash = gitHash(); const dirty = gitDirty(); const version = dirty ? `${hash}-dirty` : hash; + // --if-stale short-circuits when no amxd input has changed since the + // last successful build. Cheaper than always rebuilding on deploys + // that only touch unrelated files (docs, other pieces, infra). + if (IF_STALE) { + const prev = await readExistingManifest(); + if (prev?.piece_git) { + const committed = amxdInputsChangedSince(prev.piece_git) || []; + const uncommitted = dirty ? amxdInputsUncommitted() : []; + const combined = [...new Set([...committed, ...uncommitted])]; + if (combined.length === 0) { + console.log( + `✓ skip — no amxd-input changes since ${prev.piece_git.slice(0, 9)}`, + ); + return; + } + console.log(`→ rebuild needed — ${combined.length} input(s) changed:`); + for (const f of combined) console.log(` · ${f}`); + } else { + console.log("→ initial build — no prior manifest"); + } + } + + const { createM4DBundle } = await import(bundlerPath); + const onProgress = VERBOSE ? (p) => console.log(`[${p.stage}] ${p.message}`) : () => {}; @@ -122,6 +205,67 @@ async function main() { await fs.writeFile(desktopPath, binary); console.log(` ✓ ${desktopPath} (Desktop)`); } + + if (SYNC_SPACES) { + await syncToSpaces({ binary, versionedName, manifest }); + } +} + +// Upload the versioned amxd + latest.json to DO Spaces so each build +// has a durable permalink outside lith. Mirrors the ac-os OTA pattern: +// versioned artifacts are immutable; `latest.json` is the rolling +// pointer the piece's env-info fetch reads to detect stale installs. +async function syncToSpaces({ binary, versionedName, manifest }) { + const accessKeyId = + process.env.DO_SPACES_KEY || process.env.AWS_ACCESS_KEY_ID; + const secretAccessKey = + process.env.DO_SPACES_SECRET || process.env.AWS_SECRET_ACCESS_KEY; + if (!accessKeyId || !secretAccessKey) { + console.warn( + " ⚠ --sync-spaces set but no credentials (DO_SPACES_KEY/SECRET or AWS_ACCESS_KEY_ID/SECRET); skipping upload", + ); + return; + } + // Resolve @aws-sdk/client-s3 out of oven's node_modules — that's + // where the dep already lives (oven depends on it for existing + // S3 pipelines) and it saves root-level install duplication. + const ovenRequire = createRequire(path.join(REPO_ROOT, "oven/package.json")); + const { S3Client, PutObjectCommand } = ovenRequire("@aws-sdk/client-s3"); + const s3 = new S3Client({ + endpoint: SPACES_ENDPOINT, + region: "sfo3", + credentials: { accessKeyId, secretAccessKey }, + }); + const uploads = [ + { + key: `${SPACES_PREFIX}/${versionedName}`, + body: binary, + contentType: "application/octet-stream", + }, + { + key: `${SPACES_PREFIX}/latest.json`, + body: Buffer.from(JSON.stringify(manifest, null, 2) + "\n"), + contentType: "application/json", + }, + { + key: `${SPACES_PREFIX}.amxd`, // alias at m4l/notepat.com.amxd + body: binary, + contentType: "application/octet-stream", + }, + ]; + for (const u of uploads) { + await s3.send( + new PutObjectCommand({ + Bucket: SPACES_BUCKET, + Key: u.key, + Body: u.body, + ACL: "public-read", + ContentType: u.contentType, + CacheControl: u.key.endsWith("latest.json") ? "no-cache" : "public,max-age=31536000,immutable", + }), + ); + console.log(` ☁ s3://${SPACES_BUCKET}/${u.key}`); + } } main().catch((err) => { diff --git a/lith/deploy.fish b/lith/deploy.fish index 97d787417a..0c1c96727e 100644 --- a/lith/deploy.fish +++ b/lith/deploy.fish @@ -178,11 +178,14 @@ echo -e "$GREEN-> Installing dependencies...$NC" 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" # notepat.com amxd build stream. -# Modeled after `ac-os upload`'s "always rebuild first" pattern so -# notepat.com/amxd + /m4l/notepat.com/latest.json always reflect the -# commit we just deployed (no "dirty" hashes from dev machine state). -echo -e "$GREEN-> Building notepat.com.amxd from deployed commit...$NC" -ssh -i $SSH_KEY $LITH_USER@$TARGET_HOST "cd $REMOTE_DIR && node ac-m4l/build-notepat.mjs" +# Modeled after `ac-os upload`'s OTA flow: only rebuild + re-upload +# when an amxd input actually changed since the last successful build +# (via --if-stale), then push the versioned artifact + latest.json to +# DO Spaces (--sync-spaces) so each release has a durable CDN URL +# outside lith. Sourcing /opt/ac/system/.env before running picks up +# DO_SPACES_* / AWS_* creds that lith.service already has configured. +echo -e "$GREEN-> Refreshing notepat.com.amxd build stream...$NC" +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" # Install service file + Caddy config from the deployed checkout echo -e "$GREEN-> Updating service + Caddy config...$NC" diff --git a/package.json b/package.json index 3aa598d6a2..95df63b83e 100644 --- a/package.json +++ b/package.json @@ -80,6 +80,7 @@ "new": "node utilities/generate-new-piece.mjs", "notepat:build": "node ac-m4l/build-notepat.mjs", "notepat:build:desktop": "node ac-m4l/build-notepat.mjs --desktop --log", + "notepat:publish": "node ac-m4l/build-notepat.mjs --sync-spaces --log", "reload-piece": "curl -X POST -H \"Content-Type: application/json\" -d '{\"piece\": \"@digitpain/hello\"}' http://localhost:8082/reload", "server:socket": "cd socket-server; npm run server", "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)'",