Something went wrong. Try again.
Monorepo for Aesthetic.Computer aesthetic.computer
Something went wrong. Try again.
JavaScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175// updates — notice that a newer aesel exists, and become it.//// A tool installed by a shell script has no package manager behind it, so if it// does not look after its own version nobody else will: the copy someone// installed in September keeps telling a model about a piece API that moved in// October, and the first symptom is bad advice rather than an error.//// Two rules shape everything here.//// It never updates a checkout. `install.json` is written into the tarball by// bin/pack.mjs and exists nowhere else, so its absence means this aesel is a// working copy of the repository — where overwriting src/ with a release would// destroy someone's afternoon. Development is the case that must never be// guessed wrong, so it is detected by a file that only a release can have,// rather than by sniffing for .git and hoping.//// And it never installs bytes it did not verify. The manifest carries the// tarball's sha256; the download is hashed before anything is unpacked, and a// mismatch is refused rather than reported. This is code that will execute as// the user on their next launch.//// The check is a courtesy, not a gate. Every failure path here is silent: no// network, a wrong shape, a server error, a missing manifest — all of them mean// "no update today" and none of them mean an error in front of someone trying// to draw something.
import { execFile } from "node:child_process";import { createHash } from "node:crypto";import { existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";import { tmpdir } from "node:os";import { dirname, join } from "node:path";import { fileURLToPath } from "node:url";import { promisify } from "node:util";
const run = promisify(execFile);const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");const SITE = process.env.EASEL_SITE || "https://aesthetic.computer";
// Once a day. The version changes far less often than aesel opens, and a tool// that phones home on every launch is a tool that is slow to start on a bad// connection for no benefit.const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;// Short enough that a hung endpoint never delays a session.const TIMEOUT_MS = 4000;
const stampPath = join(ROOT, "install.json");const statePath = join(ROOT, ".update-check.json");
// A release install, or a working checkout? Only the former may be replaced.export function installed() { return existsSync(stampPath);}
export function currentVersion() { try { return JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8")).version || ""; } catch { return ""; }}
// Semantic-ish: compare dotted integers left to right, so 0.10.0 is newer than// 0.9.9 rather than alphabetically older. Anything unparseable compares equal,// which fails toward not updating.export function isNewer(latest, current) { const parse = (v) => String(v || "").split(".").map((n) => parseInt(n, 10)); const a = parse(latest); const b = parse(current); if (a.some(Number.isNaN) || b.some(Number.isNaN) || !a.length || !b.length) return false; for (let i = 0; i < Math.max(a.length, b.length); i += 1) { const x = a[i] ?? 0; const y = b[i] ?? 0; if (x !== y) return x > y; } return false;}
function lastCheckedAt() { try { return Number(JSON.parse(readFileSync(statePath, "utf8")).at) || 0; } catch { return 0; }}
function noteCheck(now) { try { writeFileSync(statePath, JSON.stringify({ at: now }) + "\n"); } catch {}}
export async function fetchManifest({ fetch = globalThis.fetch, site = SITE } = {}) { const response = await fetch(`${site}/easel.json`, { signal: AbortSignal.timeout(TIMEOUT_MS), headers: { "Cache-Control": "no-cache" }, }); if (!response.ok) throw new Error(`manifest HTTP ${response.status}`); const manifest = await response.json(); if (!manifest?.version || !manifest?.sha256) throw new Error("manifest is missing version or sha256"); return manifest;}
// Is there a newer aesel? Resolves null for every reason there might not be —// including "not an install" and "asked recently" — so a caller can treat any// non-null as news worth showing.export async function checkForUpdate({ fetch = globalThis.fetch, site = SITE, now = Date.now(), force = false,} = {}) { if (!installed()) return null; if (!force && now - lastCheckedAt() < CHECK_INTERVAL_MS) return null; try { const manifest = await fetchManifest({ fetch, site }); noteCheck(now); const current = currentVersion(); if (!isNewer(manifest.version, current)) return null; return { current, ...manifest }; } catch { // A failed check still counts, so a machine that is offline all week does // not retry on every single launch. noteCheck(now); return null; }}
// Download, verify, and swap. Returns the version now installed.//// The swap is a rename of a fully unpacked directory, which is as close to// atomic as this gets: at no point is there a half-written aesel at the path a// terminal is about to launch.export async function applyUpdate({ fetch = globalThis.fetch, site = SITE, manifest } = {}) { if (!installed()) throw new Error("this aesel is a checkout, not an install — use git"); const target = manifest || (await fetchManifest({ fetch, site }));
const response = await fetch(`${site}${target.tarball || "/easel.tar.gz"}`, { signal: AbortSignal.timeout(60_000), }); if (!response.ok) throw new Error(`download HTTP ${response.status}`); const bytes = Buffer.from(await response.arrayBuffer());
const got = createHash("sha256").update(bytes).digest("hex"); if (got !== target.sha256) { throw new Error(`checksum mismatch — refusing to install (expected ${target.sha256.slice(0, 12)}…, got ${got.slice(0, 12)}…)`); }
const work = mkdtempSync(join(tmpdir(), "easel-update-")); try { const archive = join(work, "easel.tar.gz"); writeFileSync(archive, bytes); const unpacked = join(work, "unpacked"); mkdirSync(unpacked); await run("tar", ["-xzf", archive, "-C", unpacked]); if (!existsSync(join(unpacked, "bin", "easel"))) { throw new Error("that archive does not look like aesel"); }
// Keep the previous install until the new one is in place, then drop it. const previous = `${ROOT}.previous`; rmSync(previous, { recursive: true, force: true }); renameSync(ROOT, previous); try { renameSync(unpacked, ROOT); } catch (error) { renameSync(previous, ROOT); // put it back rather than leave nothing throw error; } rmSync(previous, { recursive: true, force: true }); return target.version; } finally { rmSync(work, { recursive: true, force: true }); }}