diff --git a/.gitignore b/.gitignore index ea832c14da..e5a3b079d7 100644 --- a/.gitignore +++ b/.gitignore @@ -581,3 +581,7 @@ tmp/nopaint-sheets/ # Built by `node easel/bin/pack.mjs` from easel/ — derived, not source. system/public/easel.tar.gz + +# Written beside a real install by the updater; a checkout never makes one. +easel/.update-check.json +easel/install.json diff --git a/easel/LICENSE b/easel/LICENSE index 6720b5d9f8..4b65286055 100644 --- a/easel/LICENSE +++ b/easel/LICENSE @@ -1,7 +1,21 @@ Copyright (c) 2026 Aesthetic Computer -All rights reserved. +Permission is granted, free of charge, to any person obtaining a copy of this +software to install it, run it, and use it for any purpose, including +commercially, and to modify it for their own use. -This software and its source code are proprietary and confidential. No right -to use, copy, modify, distribute, sublicense, or create derivative works is -granted without prior written permission from Aesthetic Computer. +Redistribution is not granted. You may not publish, sublicense, sell, or +otherwise distribute this software or a derivative of it, in source or compiled +form, whether alone or as part of another product. Aesthetic Computer +distributes it, from https://prompt.ac/easel.sh. + +Forking it to run your own copy is use, and is fine. Handing your copy to +someone else is distribution, and is not. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/easel/bin/easel b/easel/bin/easel index 0198215fd2..125871c5cb 100755 --- a/easel/bin/easel +++ b/easel/bin/easel @@ -2,7 +2,6 @@ set -euo pipefail -VERSION="0.4.0" DEFAULT_BACKEND="${EASEL_BACKEND:-claude}" DEFAULT_CLAUDE_MODEL="claude-opus-5" script_path="${BASH_SOURCE[0]}" @@ -17,6 +16,12 @@ while [[ -L "$script_path" ]]; do done PROJECT_DIR="$(cd "$(dirname "$script_path")/.." && pwd -P)" +# One source of truth. This used to be a literal, which meant that after a +# self-update the launcher went on reporting the version it was written with +# while package.json — the file the updater actually compares — had moved on. +# Two places to change a version is one place to forget. +VERSION="$(node -p "require('$PROJECT_DIR/package.json').version" 2>/dev/null || echo "?")" + usage() { cat <<'EOF' Usage: ac [directory] [--runtime mjs|lisp|processing] diff --git a/easel/bin/pack.mjs b/easel/bin/pack.mjs index 4bf4143c83..561144a479 100755 --- a/easel/bin/pack.mjs +++ b/easel/bin/pack.mjs @@ -10,7 +10,8 @@ // // node easel/bin/pack.mjs → system/public/easel.tar.gz import { execFileSync } from "node:child_process"; -import { mkdirSync, readFileSync, statSync } from "node:fs"; +import { createHash } from "node:crypto"; +import { mkdirSync, readFileSync, statSync, writeFileSync, rmSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -18,11 +19,19 @@ const HERE = dirname(fileURLToPath(import.meta.url)); const EASEL = join(HERE, ".."); const REPO = join(EASEL, ".."); const OUT = join(REPO, "system", "public", "easel.tar.gz"); +const MANIFEST = join(REPO, "system", "public", "easel.json"); +// Written into the tarball so an install can tell what it is. Its absence is +// how a git checkout knows never to overwrite itself with a release. +const STAMP = join(EASEL, "install.json"); -const INCLUDE = ["bin", "src", "shell", "context", "package.json", "README.md", "LICENSE"]; +const INCLUDE = ["bin", "src", "shell", "context", "package.json", "README.md", "LICENSE", "install.json"]; const version = JSON.parse(readFileSync(join(EASEL, "package.json"), "utf8")).version; +// The stamp is part of the archive, so it is written before tarring and removed +// after: a working checkout must not acquire one by having run this script. +writeFileSync(STAMP, JSON.stringify({ version, packedAt: new Date().toISOString() }, null, 2) + "\n"); + for (const entry of INCLUDE) { try { statSync(join(EASEL, entry)); @@ -43,6 +52,19 @@ execFileSync("tar", [ ...INCLUDE, ], { stdio: "inherit" }); -const size = statSync(OUT).size; -console.log(`easel.tar.gz — v${version}, ${(size / 1024).toFixed(0)} KB`); +rmSync(STAMP, { force: true }); + +const bytes = readFileSync(OUT); +const sha256 = createHash("sha256").update(bytes).digest("hex"); + +// What a running Easel fetches to decide whether it is behind. Kept to the four +// facts an updater needs, so it stays cheap enough to poll once a day. +writeFileSync( + MANIFEST, + JSON.stringify({ version, sha256, bytes: bytes.length, tarball: "/easel.tar.gz" }, null, 2) + "\n", +); + +console.log(`easel.tar.gz — v${version}, ${(bytes.length / 1024).toFixed(0)} KB`); +console.log(` sha256 ${sha256.slice(0, 16)}…`); console.log(` ${OUT}`); +console.log(` ${MANIFEST}`); diff --git a/easel/src/tui.mjs b/easel/src/tui.mjs index cdee8e0ae5..4e5fe3c2ed 100755 --- a/easel/src/tui.mjs +++ b/easel/src/tui.mjs @@ -857,10 +857,32 @@ async function submitInput() { state.entries = []; return redraw(); } + if (command === "/update") { + if (!installed()) { + addEntry("notice", `Easel ${currentVersion()} — running from a checkout, so there is nothing to update. Use git.`); + return redraw(); + } + addEntry("notice", "Checking for a newer Easel…"); + redraw(); + try { + const update = await checkForUpdate({ force: true }); + if (!update) { + addEntry("notice", `Easel ${currentVersion()} is the latest.`); + return redraw(); + } + addEntry("notice", `Installing Easel ${update.version}…`); + redraw(); + const version = await applyUpdate({ manifest: update }); + addEntry("notice", `Easel ${version} installed. Restart to run it.`); + } catch (error) { + addEntry("error", `Update failed: ${errorText(error)}`); + } + return redraw(); + } if (command === "/help") { addEntry( "notice", - "/login · /logout · /whoami · /publish [file] · /autopublish [on|off] · /ask [on|off] · /piece [name] · /runtime [id] · /backend [id] · /model [name] · /open · /qr · /live · /new · /clear · /quit ctrl-c interrupts a running turn", + "/login · /logout · /whoami · /publish [file] · /autopublish [on|off] · /ask [on|off] · /piece [name] · /runtime [id] · /backend [id] · /model [name] · /update · /open · /qr · /live · /new · /clear · /quit ctrl-c interrupts a running turn", ); return redraw(); } @@ -1155,6 +1177,20 @@ session.watch().on("change", () => { live.create(); live.watch(liveError); publishBlankOnce(); + +// 🆕 Ask once a day, in the background, and say nothing unless there is news. +// Deliberately not automatic: replacing the tool someone is mid-sentence with +// is the wrong kind of surprise, and a line they can ignore costs nothing. +checkForUpdate() + .then((update) => { + if (!update) return; + addEntry( + "notice", + `Easel ${update.version} is out — you have ${update.current}. Run /update to install it.`, + ); + redraw(); + }) + .catch(() => {}); // Every save that reaches the phone is a candidate for the public URL too, and // so is the blank. That reverses an earlier rule — an untouched session used to // leave nothing behind, out there or in the workspace — because the address on diff --git a/easel/src/updates.mjs b/easel/src/updates.mjs new file mode 100644 index 0000000000..48a1b0a09f --- /dev/null +++ b/easel/src/updates.mjs @@ -0,0 +1,174 @@ +// updates — notice that a newer Easel 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 Easel 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 Easel 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 Easel? 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 Easel 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 Easel 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 Easel"); + } + + // 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 }); + } +} diff --git a/easel/test/updates.test.mjs b/easel/test/updates.test.mjs new file mode 100644 index 0000000000..170e036760 --- /dev/null +++ b/easel/test/updates.test.mjs @@ -0,0 +1,42 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { checkForUpdate, currentVersion, installed, isNewer } from "../src/updates.mjs"; + +test("versions compare numerically, not alphabetically", () => { + assert.equal(isNewer("0.10.0", "0.9.9"), true, "0.10 is newer than 0.9"); + assert.equal(isNewer("1.0.0", "0.99.99"), true); + assert.equal(isNewer("0.4.0", "0.4.0"), false); + assert.equal(isNewer("0.3.9", "0.4.0"), false); +}); + +// Every unparseable comparison must fail toward not updating. A tool that +// overwrites itself because it could not read a version number is worse than +// one that never updates at all. +test("anything unreadable means no update", () => { + for (const [l, c] of [["", "0.4.0"], ["x.y.z", "0.4.0"], ["0.4.0", ""], [null, "0.4.0"], [undefined, undefined]]) { + assert.equal(isNewer(l, c), false, `${JSON.stringify(l)} vs ${JSON.stringify(c)}`); + } +}); + +// The rule that protects development: this repository is a checkout, so it must +// never see itself as updatable, whatever the server says. +test("a checkout is never an install, and never updates", async () => { + assert.equal(installed(), false, "the repo copy must not carry an install stamp"); + const served = async () => ({ + ok: true, + json: async () => ({ version: "99.0.0", sha256: "f".repeat(64), tarball: "/easel.tar.gz" }), + }); + const update = await checkForUpdate({ fetch: served, force: true }); + assert.equal(update, null, "a checkout must refuse an update even when one exists"); +}); + +test("a failed check is silent rather than an error", async () => { + const broken = async () => { throw new Error("offline"); }; + assert.equal(await checkForUpdate({ fetch: broken, force: true }), null); + const wrong = async () => ({ ok: true, json: async () => ({ nope: true }) }); + assert.equal(await checkForUpdate({ fetch: wrong, force: true }), null); +}); + +test("the current version is readable", () => { + assert.match(currentVersion(), /^\d+\.\d+\.\d+$/); +});