diff --git a/aesel/bin/pack.mjs b/aesel/bin/pack.mjs index 9867bf9eed..9a935b74a4 100755 --- a/aesel/bin/pack.mjs +++ b/aesel/bin/pack.mjs @@ -1,5 +1,5 @@ #!/usr/bin/env node -// pack — build the tarball easel.sh downloads. +// pack — build the tarball aesel.sh (and easel.sh) downloads. // // What travels: the source, the bin, the shell integration, the context bundle, // the licence, the README, and package.json. What does not: tests, the git @@ -8,18 +8,23 @@ // allowed to have, so the list is explicit rather than an exclusion glob that // quietly grows. // -// node easel/bin/pack.mjs → system/public/easel.tar.gz +// node aesel/bin/pack.mjs → system/public/aesel.tar.gz (+ easel.tar.gz) +// +// Every copy up to 0.8.3 polls easel.json and fetches easel.tar.gz, so the +// release goes out under both names, byte for byte, and bin/easel stays in it: +// that is the file an older updater checks for before it will install. import { execFileSync } from "node:child_process"; import { createHash } from "node:crypto"; -import { mkdirSync, readFileSync, statSync, writeFileSync, rmSync } from "node:fs"; +import { copyFileSync, mkdirSync, readFileSync, statSync, writeFileSync, rmSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; const HERE = dirname(fileURLToPath(import.meta.url)); const aesel = join(HERE, ".."); const REPO = join(aesel, ".."); -const OUT = join(REPO, "system", "public", "easel.tar.gz"); -const MANIFEST = join(REPO, "system", "public", "easel.json"); +const PUBLIC = join(REPO, "system", "public"); +const OUT = join(PUBLIC, "aesel.tar.gz"); +const NAMES = ["aesel", "easel"]; // 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(aesel, "install.json"); @@ -66,12 +71,14 @@ const sha256 = createHash("sha256").update(bytes).digest("hex"); // What a running aesel 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", -); +for (const name of NAMES) { + if (name !== "aesel") copyFileSync(OUT, join(PUBLIC, `${name}.tar.gz`)); + writeFileSync( + join(PUBLIC, `${name}.json`), + JSON.stringify({ version, sha256, bytes: bytes.length, tarball: `/${name}.tar.gz` }, null, 2) + "\n", + ); +} -console.log(`easel.tar.gz — v${version}, ${(bytes.length / 1024).toFixed(0)} KB`); +console.log(`aesel.tar.gz — v${version}, ${(bytes.length / 1024).toFixed(0)} KB`); console.log(` sha256 ${sha256.slice(0, 16)}…`); -console.log(` ${OUT}`); -console.log(` ${MANIFEST}`); +for (const name of NAMES) console.log(` ${join(PUBLIC, name)}.{tar.gz,json}`); diff --git a/aesel/docs/native-shell.md b/aesel/docs/native-shell.md index d282094a8a..bf00b56705 100644 --- a/aesel/docs/native-shell.md +++ b/aesel/docs/native-shell.md @@ -44,8 +44,8 @@ place at `tui.mjs:55` — `input`, `cursor`, `entries`, `approval`, `piece`, model/view seam already exists, and the view side is five hundred lines. The product ships as an 85 KB tarball behind `curl -fsSL -https://prompt.ac/easel.sh | sh`, which checks for Node 18 and unpacks to -`~/.local/share/easel`. `bin/easel` is bash, resolves its own symlink chain, and +https://prompt.ac/aesel.sh | sh` (or the older easel.sh), which checks for Node and unpacks to +`~/.local/share/aesel/app`. `bin/easel` is bash, resolves its own symlink chain, and refuses to start unless `[[ -t 0 && -t 1 ]]` — an interactive terminal is a hard precondition, not a preference. @@ -111,7 +111,7 @@ The assumptions are all in the shell around it: `"explorer"`. One of the two is a real bug, and it is three characters. 4. `updates.mjs:154` — `execFile("tar", ["-xzf", …])`. Windows 10 and later ship bsdtar as `tar.exe`, so this probably works. Unverified. -5. `bin/easel`, `install.sh` and `system/public/easel.sh` are shell scripts, and +5. `bin/easel`, `install.sh` and `system/public/aesel.sh` (and its twin `easel.sh`) are shell scripts, and `install.sh` uses BSD `/usr/bin/sed -i ''`, which is macOS-only even among POSIX systems. An app bundle makes these moot for the GUI; they still have no Windows story for the `ac` command line. diff --git a/aesel/src/updates.mjs b/aesel/src/updates.mjs index 1fefb66af9..d0933a0376 100644 --- a/aesel/src/updates.mjs +++ b/aesel/src/updates.mjs @@ -26,7 +26,7 @@ import { execFile } from "node:child_process"; import { createHash } from "node:crypto"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -34,7 +34,7 @@ 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"; +const SITE = process.env.AESEL_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 @@ -89,11 +89,18 @@ function noteCheck(now) { } catch {} } +// aesel.json first. easel.json is the same manifest under the old name, which +// every copy up to 0.8.3 polls, so it stays served; asking it second covers a +// site (AESEL_SITE) that has not published the new name. 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" }, - }); + let response; + for (const name of ["aesel.json", "easel.json"]) { + response = await fetch(`${site}/${name}`, { + signal: AbortSignal.timeout(TIMEOUT_MS), + headers: { "Cache-Control": "no-cache" }, + }); + if (response.status !== 404) break; + } 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"); @@ -130,24 +137,29 @@ export async function checkForUpdate({ // 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"}`, { +// +// Anything in the install folder that the release does not ship is carried +// into the new one before the old one goes. Installs made by easel.sh live in +// ~/.local/share/easel, which is also where history and transcripts were kept, +// and a swap that dropped the whole folder dropped them with it. +export async function applyUpdate({ fetch = globalThis.fetch, site = SITE, manifest, root = ROOT } = {}) { + if (!existsSync(join(root, "install.json"))) throw new Error("this aesel is a checkout, not an install — use git"); + const release = manifest || (await fetchManifest({ fetch, site })); + + const response = await fetch(`${site}${release.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)}…)`); + if (got !== release.sha256) { + throw new Error(`checksum mismatch — refusing to install (expected ${release.sha256.slice(0, 12)}…, got ${got.slice(0, 12)}…)`); } - const work = mkdtempSync(join(tmpdir(), "easel-update-")); + const work = mkdtempSync(join(tmpdir(), "aesel-update-")); try { - const archive = join(work, "easel.tar.gz"); + const archive = join(work, "aesel.tar.gz"); writeFileSync(archive, bytes); const unpacked = join(work, "unpacked"); mkdirSync(unpacked); @@ -157,17 +169,23 @@ export async function applyUpdate({ fetch = globalThis.fetch, site = SITE, manif } // Keep the previous install until the new one is in place, then drop it. - const previous = `${ROOT}.previous`; + // The real path, so a folder reached through a symlink is swapped rather + // than the link. + const target = realpathSync(root); + const previous = `${target}.previous`; rmSync(previous, { recursive: true, force: true }); - renameSync(ROOT, previous); + renameSync(target, previous); try { - renameSync(unpacked, ROOT); + renameSync(unpacked, target); } catch (error) { - renameSync(previous, ROOT); // put it back rather than leave nothing + renameSync(previous, target); // put it back rather than leave nothing throw error; } + for (const name of readdirSync(previous)) { + if (!existsSync(join(target, name))) renameSync(join(previous, name), join(target, name)); + } rmSync(previous, { recursive: true, force: true }); - return target.version; + return release.version; } finally { rmSync(work, { recursive: true, force: true }); } diff --git a/aesel/test/updates.test.mjs b/aesel/test/updates.test.mjs index 170e036760..30a24a95f8 100644 --- a/aesel/test/updates.test.mjs +++ b/aesel/test/updates.test.mjs @@ -1,6 +1,29 @@ import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; import test from "node:test"; -import { checkForUpdate, currentVersion, installed, isNewer } from "../src/updates.mjs"; +import { fileURLToPath } from "node:url"; +import { applyUpdate, checkForUpdate, currentVersion, fetchManifest, installed, isNewer } from "../src/updates.mjs"; + +const REPO = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); +const put = (file, text = "x") => { mkdirSync(dirname(file), { recursive: true }); writeFileSync(file, text); }; + +// A release as pack.mjs lays one out: the names an older updater and +// installer check for, and a version. +function release(t, version = "9.9.9") { + const dir = mkdtempSync(join(tmpdir(), "aesel-release-")); + t.after(() => rmSync(dir, { recursive: true, force: true })); + const src = join(dir, "src"); + for (const name of ["easel", "aesel", "easel-desktop", "a", "aes"]) put(join(src, "bin", name), "#!/bin/sh\n"); + put(join(src, "package.json"), JSON.stringify({ version })); + put(join(src, "install.json"), JSON.stringify({ version })); + put(join(src, "src/tui.mjs"), "// new"); + const bytes = execFileSync("tar", ["-czf", "-", "-C", src, "bin", "package.json", "install.json", "src"]); + return { dir, bytes, sha256: createHash("sha256").update(bytes).digest("hex"), version }; +} test("versions compare numerically, not alphabetically", () => { assert.equal(isNewer("0.10.0", "0.9.9"), true, "0.10 is newer than 0.9"); @@ -40,3 +63,72 @@ test("a failed check is silent rather than an error", async () => { test("the current version is readable", () => { assert.match(currentVersion(), /^\d+\.\d+\.\d+$/); }); + +test("aesel.json first, easel.json when a site has only the old name", async () => { + const asked = []; + const site = async (url) => { + asked.push(url.split("/").pop()); + return url.endsWith("/aesel.json") + ? { ok: false, status: 404 } + : { ok: true, status: 200, json: async () => ({ version: "1.0.0", sha256: "f".repeat(64), tarball: "/easel.tar.gz" }) }; + }; + assert.equal((await fetchManifest({ fetch: site, site: "https://example.test" })).tarball, "/easel.tar.gz"); + assert.deepEqual(asked, ["aesel.json", "easel.json"]); +}); + +// An easel.sh install shares its folder with history; the swap used to drop it. +test("an update carries what the release does not ship into the new install", async (t) => { + const r = release(t); + const home = mkdtempSync(join(tmpdir(), "aesel-update-home-")); + t.after(() => rmSync(home, { recursive: true, force: true })); + const root = join(home, "easel"); + put(join(root, "install.json"), "{}"); + put(join(root, "bin/easel"), "old"); + put(join(root, "src/tui.mjs"), "// old"); + put(join(root, "history/abc/v1.json"), "kept"); + put(join(root, ".update-check.json"), "{}"); + const fetch = async () => ({ ok: true, arrayBuffer: async () => r.bytes }); + const version = await applyUpdate({ fetch, site: "https://example.test", root, + manifest: { version: r.version, sha256: r.sha256, tarball: "/aesel.tar.gz" } }); + assert.equal(version, "9.9.9"); + assert.equal(readFileSync(join(root, "src/tui.mjs"), "utf8"), "// new"); + assert.equal(readFileSync(join(root, "history/abc/v1.json"), "utf8"), "kept"); + assert.ok(existsSync(join(root, ".update-check.json"))); + assert.ok(!existsSync(`${root}.previous`)); +}); + +test("an install reached through a symlink is swapped at its real path", async (t) => { + const r = release(t); + const home = mkdtempSync(join(tmpdir(), "aesel-update-link-")); + t.after(() => rmSync(home, { recursive: true, force: true })); + const real = join(home, "aesel"), link = join(home, "easel"); + put(join(real, "install.json"), "{}"); + put(join(real, "bin/easel"), "old"); + symlinkSync(real, link); + const fetch = async () => ({ ok: true, arrayBuffer: async () => r.bytes }); + await applyUpdate({ fetch, site: "https://example.test", root: link, + manifest: { version: r.version, sha256: r.sha256 } }); + assert.equal(readFileSync(join(link, "src/tui.mjs"), "utf8"), "// new"); + assert.equal(readFileSync(join(real, "src/tui.mjs"), "utf8"), "// new"); +}); + +// aesel.sh and easel.sh are one script under two names; each fetches its own +// tarball, installs beside history rather than over it, and carries anything +// an older install folder held. +test("the installer is the same script under both names and keeps what it finds", (t) => { + const aesel = readFileSync(join(REPO, "system/public/aesel.sh"), "utf8"); + const easel = readFileSync(join(REPO, "system/public/easel.sh"), "utf8"); + assert.equal(easel, aesel.replace("TARBALL=aesel.tar.gz", "TARBALL=easel.tar.gz")); + + const r = release(t, "1.2.3"); + const home = join(r.dir, "home"); + writeFileSync(join(r.dir, "aesel.tar.gz"), r.bytes); + const prefix = join(home, ".local/share/aesel/app"); + put(join(prefix, "bin/easel"), "old"); + put(join(prefix, "history/abc/v1.json"), "kept"); + const env = { PATH: process.env.PATH, HOME: home, AESEL_SITE: `file://${r.dir}` }; + execFileSync("sh", [join(REPO, "system/public/aesel.sh")], { env, stdio: "pipe" }); + assert.equal(readFileSync(join(prefix, "src/tui.mjs"), "utf8"), "// new"); + assert.equal(readFileSync(join(prefix, "history/abc/v1.json"), "utf8"), "kept"); + for (const name of ["ac", "a", "aes", "aesel", "easel"]) assert.ok(existsSync(join(home, ".local/bin", name)), name); +}); diff --git a/lith/Caddyfile b/lith/Caddyfile index fb47904a9c..901e5a294a 100644 --- a/lith/Caddyfile +++ b/lith/Caddyfile @@ -739,16 +739,21 @@ # The installer and its tarball, served as files from both hosts. Must sit # above @promptac, which otherwise hands every non-root path to the resolver - # and would turn `curl prompt.ac/easel.sh` into a redirect to a piece. + # and would turn `curl prompt.ac/aesel.sh` into a redirect to a piece. + # The easel.* names are the same files under the name they had first; + # installed copies poll easel.json, so both stay served. # # prompt.ac because it is nine bytes shorter than aesthetic.computer and an # install line is read aloud and typed by hand more often than it is copied. - @easel_install { + @aesel_install { host prompt.ac aesthetic.computer - path /easel.sh /easel.tar.gz /easel.json + path /aesel.sh /aesel.tar.gz /aesel.json /easel.sh /easel.tar.gz /easel.json } - handle @easel_install { + handle @aesel_install { root * /opt/ac/system/public + header /aesel.sh Content-Type "text/plain; charset=utf-8" + header /aesel.tar.gz Content-Type "application/gzip" + header /aesel.json Content-Type "application/json" header /easel.sh Content-Type "text/plain; charset=utf-8" header /easel.tar.gz Content-Type "application/gzip" header /easel.json Content-Type "application/json" diff --git a/lith/deploy.fish b/lith/deploy.fish index bfa7b425f4..c7d0d26f42 100644 --- a/lith/deploy.fish +++ b/lith/deploy.fish @@ -303,11 +303,11 @@ else if test -f $SPACES_ENV_GPG end end -# The tarball easel.sh downloads is built from easel/, not committed, so it is -# packed on the box after the pull. Building it here rather than locally means +# The tarball aesel.sh (and easel.sh) downloads is built from aesel/, not +# committed, so it is packed on the box after the pull. Building it here rather than locally means # the installer can never point at a version older than the source that shipped # with it. -echo -e "$GREEN-> Packing the Easel installer tarball...$NC" +echo -e "$GREEN-> Packing the Aesel installer tarball...$NC" ssh -i $SSH_KEY $LITH_USER@$TARGET_HOST "cd $REMOTE_DIR && node aesel/bin/pack.mjs" 2>&1 | tail -2 echo -e "$GREEN-> Refreshing notepat.com.amxd build stream...$NC" diff --git a/system/public/aesel.json b/system/public/aesel.json new file mode 100644 index 0000000000..b5d2c625b0 --- /dev/null +++ b/system/public/aesel.json @@ -0,0 +1,6 @@ +{ + "version": "0.8.0", + "sha256": "eddbecccf7f00e9de91a471408ee0727672d81991ab33ce4553959173ef123a4", + "bytes": 892527, + "tarball": "/aesel.tar.gz" +} diff --git a/system/public/aesel.sh b/system/public/aesel.sh index 6a952f4107..36cc1413de 100755 --- a/system/public/aesel.sh +++ b/system/public/aesel.sh @@ -1,14 +1,18 @@ #!/bin/sh # Aesel — the Aesthetic Computer terminal editor. # -# curl -fsSL https://prompt.ac/easel.sh | sh +# curl -fsSL https://prompt.ac/aesel.sh | sh +# +# (prompt.ac/easel.sh is this same script under the name it had first; the +# only difference is that it fetches easel.tar.gz, the same bytes as +# aesel.tar.gz under the old name.) # # What this does, in order, so that reading it takes less time than running it: # # 1. checks for node # 2. downloads one tarball from aesthetic.computer and unpacks it to -# ~/.local/share/easel -# 3. links `ac` and `easel` into ~/.local/bin +# ~/.local/share/aesel/app +# 3. links `ac`, `a`, `aes`, `aesel` and `easel` into ~/.local/bin # 4. offers to add ~/.local/bin to your PATH, and says how if you decline # # It writes nothing outside your home folder, asks for no privileges, and runs @@ -22,9 +26,13 @@ set -eu -SITE="${EASEL_SITE:-https://aesthetic.computer}" -PREFIX="${EASEL_PREFIX:-$HOME/.local/share/easel}" -BIN="${EASEL_BIN:-$HOME/.local/bin}" +# AESEL_* first; EASEL_* is the same setting under the old name. +SITE="${AESEL_SITE:-${EASEL_SITE:-https://aesthetic.computer}}" +# Its own folder, not ~/.local/share/aesel itself: that is where history and +# transcripts live, and an install replaces its folder whole. +PREFIX="${AESEL_PREFIX:-${EASEL_PREFIX:-$HOME/.local/share/aesel/app}}" +BIN="${AESEL_BIN:-${EASEL_BIN:-$HOME/.local/bin}}" +TARBALL=aesel.tar.gz # Match the runtime APIs used by the desktop harness, including util.parseEnv. MIN_NODE=22 @@ -53,28 +61,38 @@ else die "Need curl or wget to download Aesel." fi -TMP=$(mktemp -d 2>/dev/null || mktemp -d -t easel) +TMP=$(mktemp -d 2>/dev/null || mktemp -d -t aesel) # Leave nothing behind on any exit path, including a failed download. trap 'rm -rf "$TMP"' EXIT INT TERM say " downloading…" -fetch "$SITE/easel.tar.gz" "$TMP/easel.tar.gz" \ - || die "Could not download $SITE/easel.tar.gz" +fetch "$SITE/$TARBALL" "$TMP/aesel.tar.gz" \ + || die "Could not download $SITE/$TARBALL" # --- install --------------------------------------------------------------- # Unpack beside the target and swap, so an interrupted install cannot leave a # half-written Aesel where a working one used to be. mkdir -p "$TMP/unpack" -tar -xzf "$TMP/easel.tar.gz" -C "$TMP/unpack" || die "Could not unpack the download." +tar -xzf "$TMP/aesel.tar.gz" -C "$TMP/unpack" || die "Could not unpack the download." [ -f "$TMP/unpack/bin/easel" ] || die "That download does not look like Aesel." mkdir -p "$(dirname "$PREFIX")" "$BIN" rm -rf "$PREFIX.old" [ -d "$PREFIX" ] && mv "$PREFIX" "$PREFIX.old" mv "$TMP/unpack" "$PREFIX" +# Whatever the old folder held that a release does not ship comes along. An +# older easel.sh installed into the folder history was kept in, so a plain +# swap would take someone's history with it. +if [ -d "$PREFIX.old" ]; then + for entry in "$PREFIX.old"/* "$PREFIX.old"/.[!.]*; do + [ -e "$entry" ] || [ -L "$entry" ] || continue + name=$(basename "$entry") + [ -e "$PREFIX/$name" ] || [ -L "$PREFIX/$name" ] || mv "$entry" "$PREFIX/$name" + done +fi rm -rf "$PREFIX.old" -chmod +x "$PREFIX/bin/easel" "$PREFIX/bin/aesel" "$PREFIX/bin/easel-desktop" "$PREFIX/bin/a" +chmod +x "$PREFIX/bin/easel" "$PREFIX/bin/aesel" "$PREFIX/bin/easel-desktop" "$PREFIX/bin/a" "$PREFIX/bin/aes" ln -sfn "$PREFIX/bin/easel" "$BIN/easel" ln -sfn "$PREFIX/bin/aesel" "$BIN/aesel" ln -sfn "$PREFIX/bin/a" "$BIN/a" diff --git a/system/public/easel.sh b/system/public/easel.sh index 3fb9dcb2db..1b8f2b3cde 100644 --- a/system/public/easel.sh +++ b/system/public/easel.sh @@ -1,14 +1,18 @@ #!/bin/sh # Aesel — the Aesthetic Computer terminal editor. # -# curl -fsSL https://prompt.ac/easel.sh | sh +# curl -fsSL https://prompt.ac/aesel.sh | sh +# +# (prompt.ac/easel.sh is this same script under the name it had first; the +# only difference is that it fetches easel.tar.gz, the same bytes as +# aesel.tar.gz under the old name.) # # What this does, in order, so that reading it takes less time than running it: # # 1. checks for node # 2. downloads one tarball from aesthetic.computer and unpacks it to -# ~/.local/share/easel -# 3. links `ac` and `easel` into ~/.local/bin +# ~/.local/share/aesel/app +# 3. links `ac`, `a`, `aes`, `aesel` and `easel` into ~/.local/bin # 4. offers to add ~/.local/bin to your PATH, and says how if you decline # # It writes nothing outside your home folder, asks for no privileges, and runs @@ -22,9 +26,13 @@ set -eu -SITE="${EASEL_SITE:-https://aesthetic.computer}" -PREFIX="${EASEL_PREFIX:-$HOME/.local/share/easel}" -BIN="${EASEL_BIN:-$HOME/.local/bin}" +# AESEL_* first; EASEL_* is the same setting under the old name. +SITE="${AESEL_SITE:-${EASEL_SITE:-https://aesthetic.computer}}" +# Its own folder, not ~/.local/share/aesel itself: that is where history and +# transcripts live, and an install replaces its folder whole. +PREFIX="${AESEL_PREFIX:-${EASEL_PREFIX:-$HOME/.local/share/aesel/app}}" +BIN="${AESEL_BIN:-${EASEL_BIN:-$HOME/.local/bin}}" +TARBALL=easel.tar.gz # Match the runtime APIs used by the desktop harness, including util.parseEnv. MIN_NODE=22 @@ -53,34 +61,43 @@ else die "Need curl or wget to download Aesel." fi -TMP=$(mktemp -d 2>/dev/null || mktemp -d -t easel) +TMP=$(mktemp -d 2>/dev/null || mktemp -d -t aesel) # Leave nothing behind on any exit path, including a failed download. trap 'rm -rf "$TMP"' EXIT INT TERM say " downloading…" -fetch "$SITE/easel.tar.gz" "$TMP/easel.tar.gz" \ - || die "Could not download $SITE/easel.tar.gz" +fetch "$SITE/$TARBALL" "$TMP/aesel.tar.gz" \ + || die "Could not download $SITE/$TARBALL" # --- install --------------------------------------------------------------- # Unpack beside the target and swap, so an interrupted install cannot leave a # half-written Aesel where a working one used to be. mkdir -p "$TMP/unpack" -tar -xzf "$TMP/easel.tar.gz" -C "$TMP/unpack" || die "Could not unpack the download." +tar -xzf "$TMP/aesel.tar.gz" -C "$TMP/unpack" || die "Could not unpack the download." [ -f "$TMP/unpack/bin/easel" ] || die "That download does not look like Aesel." mkdir -p "$(dirname "$PREFIX")" "$BIN" rm -rf "$PREFIX.old" [ -d "$PREFIX" ] && mv "$PREFIX" "$PREFIX.old" mv "$TMP/unpack" "$PREFIX" +# Whatever the old folder held that a release does not ship comes along. An +# older easel.sh installed into the folder history was kept in, so a plain +# swap would take someone's history with it. +if [ -d "$PREFIX.old" ]; then + for entry in "$PREFIX.old"/* "$PREFIX.old"/.[!.]*; do + [ -e "$entry" ] || [ -L "$entry" ] || continue + name=$(basename "$entry") + [ -e "$PREFIX/$name" ] || [ -L "$PREFIX/$name" ] || mv "$entry" "$PREFIX/$name" + done +fi rm -rf "$PREFIX.old" -chmod +x "$PREFIX/bin/easel" "$PREFIX/bin/aesel" "$PREFIX/bin/easel-desktop" "$PREFIX/bin/a" +chmod +x "$PREFIX/bin/easel" "$PREFIX/bin/aesel" "$PREFIX/bin/easel-desktop" "$PREFIX/bin/a" "$PREFIX/bin/aes" ln -sfn "$PREFIX/bin/easel" "$BIN/easel" ln -sfn "$PREFIX/bin/aesel" "$BIN/aesel" ln -sfn "$PREFIX/bin/a" "$BIN/a" ln -sfn "$PREFIX/bin/aes" "$BIN/aes" ln -sfn "$PREFIX/bin/easel" "$BIN/ac" -ln -sfn "$PREFIX/bin/aes" "$BIN/aes" VERSION=$(node -p "require('$PREFIX/package.json').version" 2>/dev/null || echo "?") say " installed Aesel $VERSION to $PREFIX"