From a31fbef20ad295e839921d8f7571319f98de656c Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Fri, 11 Sep 2026 20:52:09 -0400 Subject: [PATCH] easel: a licence that permits the install, and a tool that keeps itself current MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The licence said all rights reserved, inside a tarball we were about to serve from prompt.ac. Now it grants use — install it, run it commercially, modify your copy — and withholds redistribution: forking it to run your own is use and is fine, handing your copy to someone else is distribution and is not. Aesthetic Computer distributes it, from one URL. A tool installed by a shell script has no package manager behind it, so if it does not look after its own version nobody will. The copy someone installs today keeps telling a model about a piece API that moves next month, and the first symptom is bad advice rather than an error — which is the worst shape a stale install can take, because nothing looks broken. So Easel asks once a day, in the background, and says nothing unless there is news. Not automatic: replacing the tool someone is mid-sentence with is the wrong kind of surprise. `/update` is the verb, and every failure path in the check is silent — no network, a bad shape, a server error all mean "no update today" rather than an error in front of someone trying to draw something. Two rules shape the updater. It never touches a checkout: `install.json` is written into the tarball by pack.mjs and exists nowhere else, so its absence means this is a working copy where overwriting src/ would destroy an afternoon. Development is the case that must never be guessed wrong, so it is detected by a file 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, because this is code that will run as the user on their next launch. The swap renames a fully unpacked directory and puts the old one back if that fails, so there is no moment where half an Easel sits at the path a terminal is about to launch. Version comparison is numeric, so 0.10 is newer than 0.9 rather than alphabetically older, and anything unparseable compares equal — every unreadable case fails toward not updating. Found while testing the round trip: bin/easel carried VERSION as a literal, so after a successful self-update the launcher went on reporting the version it was written with while package.json, which is what the updater actually compares, had moved on. It reads package.json now. Two places to change a version is one place to forget. Verified end to end in a throwaway HOME against a local server: install 0.4.0, publish 0.5.0, notice it, download it, verify the checksum, swap, and report 0.5.0 from the launcher — with the repository still on 0.4.0 afterwards. Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 4 + easel/LICENSE | 22 ++++- easel/bin/easel | 7 +- easel/bin/pack.mjs | 30 ++++++- easel/src/tui.mjs | 38 +++++++- easel/src/updates.mjs | 174 ++++++++++++++++++++++++++++++++++++ easel/test/updates.test.mjs | 42 +++++++++ 7 files changed, 307 insertions(+), 10 deletions(-) create mode 100644 easel/src/updates.mjs create mode 100644 easel/test/updates.test.mjs 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+$/); +}); -- 2.51.2