diff --git a/CHANGELOG.md b/CHANGELOG.md index 8aa2175..2e35d2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ top released section into the GitHub Release notes (scripts/release-notes.ts). ## Unreleased +- `scratchwork update` — the CLI updates itself in place from GitHub Releases + (checksum-verified; pin with `SCRATCHWORK_VERSION`), and the new + `scratchwork install` command owns everything after download + verification + in the install flow. `install.sh` is now a thin bootstrap that downloads, + verifies, extracts, and delegates to `scratchwork install`. + ## v0.3.0 - `npm create scratchwork-server` — new `create-scratchwork-server` package diff --git a/README.md b/README.md index f2de94f..b06f4e0 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,8 @@ scratchwork --version scratchwork dev [path] ``` +Later, update to the latest release with `scratchwork update`. + To publish to a running Scratchwork server: ```sh diff --git a/RELEASING.md b/RELEASING.md index a0b4993..cd4c348 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -28,6 +28,7 @@ with confirmation gates at the irreversible steps: production server credentials). Automating this in release.yml is a follow-up. 7. **Smoke:** on a machine (or clean container) without Scratchwork: `curl -fsSL https://scratchwork.dev/install.sh | bash` and check - `scratchwork --version` prints X.Y.Z. + `scratchwork --version` prints X.Y.Z. On a machine with the previous + release installed, `scratchwork update` should also land X.Y.Z. Fix this document in the same PR whenever reality disagrees with it. diff --git a/cli/src/commands/install.ts b/cli/src/commands/install.ts new file mode 100644 index 0000000..41ad18c --- /dev/null +++ b/cli/src/commands/install.ts @@ -0,0 +1,266 @@ +/* + * `scratchwork install` / `scratchwork update` - self-installation and + * self-update for the compiled release binary. + * + * install copies the running binary into an install directory, verifies it + * runs, and prints PATH advice. It is the tail of the curl|bash flow: + * scratchwork.dev/www/install.sh downloads, checksum-verifies, and extracts a + * release, then delegates everything after that to `scratchwork install`. + * + * update downloads the latest release (or SCRATCHWORK_VERSION) for this + * platform from GitHub Releases, verifies its checksum, and atomically + * replaces the running binary in place. + * + * Both refuse to run from source (`bun src/index.ts`): they operate on the + * running executable, which for a source run would be Bun itself. + */ +import * as Command from "@effect/platform/Command"; +import type * as CommandExecutor from "@effect/platform/CommandExecutor"; +import type { PlatformError } from "@effect/platform/Error"; +import * as FileSystem from "@effect/platform/FileSystem"; +import * as HttpClient from "@effect/platform/HttpClient"; +import * as Path from "@effect/platform/Path"; +import * as Console from "effect/Console"; +import * as Effect from "effect/Effect"; +import type * as Scope from "effect/Scope"; +import { sha256Hex } from "@scratchwork/shared/crypto/digest"; +import pkg from "../../package.json"; +import { CliError, errorMessage } from "../errors"; + +/** Release download base; SCRATCHWORK_DOWNLOAD_BASE overrides it (hermetic ci fixture). */ +const DOWNLOAD_BASE = "https://github.com/scratch/scratchwork/releases"; + +const downloadBase = () => process.env.SCRATCHWORK_DOWNLOAD_BASE ?? DOWNLOAD_BASE; + +type SelfCommand = "install" | "update"; + +const fail = (command: SelfCommand, message: string) => + new CliError({ code: 1, message: `scratchwork ${command}: ${message}` }); + +/** The running compiled binary's path. Fails for source runs, where the executable is Bun. */ +function selfBinary(command: SelfCommand): Effect.Effect { + return Bun.main.startsWith("/$bunfs/") + ? Effect.succeed(process.execPath) + : Effect.fail( + fail( + command, + "not running from a compiled release binary. Install one with: curl -fsSL https://scratchwork.dev/install.sh | bash", + ), + ); +} + +/** Maps the running process to a release asset target such as darwin-arm64. */ +function releaseTarget(command: SelfCommand): Effect.Effect { + const os = process.platform === "darwin" || process.platform === "linux" ? process.platform : null; + const arch = process.arch === "arm64" || process.arch === "x64" ? process.arch : null; + return os == null || arch == null + ? Effect.fail( + fail( + command, + `no prebuilt binaries for ${process.platform}-${process.arch}. Prebuilt binaries cover macOS and glibc Linux on arm64 and x64.`, + ), + ) + : Effect.succeed(`${os}-${arch}`); +} + +/** One parsed release: its version plus the expected digest of every asset. */ +interface ReleaseChecksums { + readonly version: string; + readonly digests: ReadonlyMap; +} + +/** Parses checksums.txt (` ` lines); the asset names carry the version. */ +function parseChecksums(text: string): ReleaseChecksums | null { + const digests = new Map(); + let version: string | null = null; + for (const line of text.split("\n")) { + const match = line.match(/^([0-9a-f]{64}) (scratchwork-v([^-]+)-.+\.tar\.gz)$/); + if (match == null) continue; + digests.set(match[2]!, match[1]!); + version ??= match[3]!; + } + return version == null ? null : { version, digests }; +} + +function fetchText(url: string): Effect.Effect { + return Effect.flatMap(HttpClient.HttpClient, (client) => + HttpClient.filterStatusOk(client) + .get(url) + .pipe( + Effect.flatMap((response) => response.text), + Effect.mapError((cause) => fail("update", `could not download ${url}: ${errorMessage(cause)}`)), + ), + ); +} + +function fetchBytes(url: string): Effect.Effect { + return Effect.flatMap(HttpClient.HttpClient, (client) => + HttpClient.filterStatusOk(client) + .get(url) + .pipe( + Effect.flatMap((response) => response.arrayBuffer), + Effect.map((buffer) => new Uint8Array(buffer)), + Effect.mapError((cause) => fail("update", `could not download ${url}: ${errorMessage(cause)}`)), + ), + ); +} + +/** Downloads and parses checksums.txt for SCRATCHWORK_VERSION or the latest release. */ +const fetchRelease: Effect.Effect = Effect.gen(function* () { + const pinned = process.env.SCRATCHWORK_VERSION; + const url = pinned + ? `${downloadBase()}/download/v${pinned}/checksums.txt` + : `${downloadBase()}/latest/download/checksums.txt`; + const release = parseChecksums(yield* fetchText(url)); + if (release == null) { + return yield* Effect.fail(fail("update", `could not read a release version from ${url}`)); + } + return release; +}); + +/** Unpacks the release tarball in a scoped temp dir and returns the extracted binary's path. */ +function extractBinary( + command: SelfCommand, + asset: string, + bytes: Uint8Array, +): Effect.Effect< + string, + CliError | PlatformError, + FileSystem.FileSystem | Path.Path | CommandExecutor.CommandExecutor | Scope.Scope +> { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const paths = yield* Path.Path; + const tmp = yield* fs.makeTempDirectoryScoped({ prefix: "scratchwork-release-" }); + const archive = paths.join(tmp, asset); + yield* fs.writeFile(archive, bytes); + const code = yield* Command.exitCode(Command.make("tar", "-xzf", archive, "-C", tmp)).pipe( + Effect.mapError((cause) => fail(command, `could not run tar: ${errorMessage(cause)}`)), + ); + if (code !== 0) { + return yield* Effect.fail(fail(command, `tar failed extracting ${asset}`)); + } + const binary = paths.join(tmp, "scratchwork"); + if (!(yield* fs.exists(binary))) { + return yield* Effect.fail(fail(command, `archive ${asset} did not contain a scratchwork binary`)); + } + return binary; + }); +} + +/** + * Replaces `destination` with `source` via a staging file in the destination + * directory, so the final step is an atomic same-filesystem rename — safe + * even when `destination` is the currently running binary. + */ +function placeBinary( + command: SelfCommand, + source: string, + destination: string, +): Effect.Effect { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const paths = yield* Path.Path; + const dir = paths.dirname(destination); + const staged = paths.join(dir, `.scratchwork-staged-${process.pid}`); + yield* fs.makeDirectory(dir, { recursive: true }); + yield* Effect.gen(function* () { + yield* fs.copyFile(source, staged); + yield* fs.chmod(staged, 0o755); + yield* fs.rename(staged, destination); + }).pipe(Effect.onError(() => fs.remove(staged).pipe(Effect.ignore))); + }).pipe(Effect.mapError((cause) => (cause instanceof CliError ? cause : fail(command, `could not write ${destination}: ${errorMessage(cause)}`)))); +} + +/** Runs ` version` to confirm the installed binary executes, returning its version. */ +function verifyRuns( + command: SelfCommand, + binary: string, +): Effect.Effect { + return Command.string(Command.make(binary, "version")).pipe( + Effect.mapError((cause) => fail(command, `the installed binary failed to run: ${errorMessage(cause)}`)), + Effect.map((output) => output.trim()), + Effect.filterOrFail( + (output) => output !== "", + () => fail(command, "the installed binary failed to run"), + ), + ); +} + +/** Advice printed when the install directory is missing from PATH, or null when it is on it. */ +function pathAdvice(dir: string): string | null { + if ((process.env.PATH ?? "").split(":").includes(dir)) return null; + return `\n${dir} is not on your PATH. Add it, e.g.:\n export PATH="${dir}:$PATH"`; +} + +/** + * Runs `scratchwork install`: copies the running binary into the install + * directory (--dir, SCRATCHWORK_INSTALL_DIR, or ~/.local/bin), verifies it + * runs, and prints PATH advice when the directory is not on PATH. + */ +export function runInstall(config: { + readonly dir?: string | undefined; +}): Effect.Effect { + return Effect.gen(function* () { + const paths = yield* Path.Path; + const self = yield* selfBinary("install"); + const home = process.env.HOME; + const requested = + config.dir ?? process.env.SCRATCHWORK_INSTALL_DIR ?? (home ? paths.join(home, ".local", "bin") : null); + if (requested == null) { + return yield* Effect.fail(fail("install", "HOME is not set; pass --dir or set SCRATCHWORK_INSTALL_DIR")); + } + const dir = paths.resolve(process.cwd(), requested); + const destination = paths.join(dir, "scratchwork"); + yield* placeBinary("install", self, destination); + const version = yield* verifyRuns("install", destination); + yield* Console.log(`Installed ${destination}`); + yield* Console.log(`scratchwork ${version} is ready.`); + const advice = pathAdvice(dir); + if (advice != null) yield* Console.log(advice); + }); +} + +/** + * Runs `scratchwork update`: replaces the running binary with the latest + * release (or SCRATCHWORK_VERSION) for this platform, after verifying the + * download against the release's checksums.txt. + */ +export function runUpdate(): Effect.Effect< + void, + CliError | PlatformError, + FileSystem.FileSystem | Path.Path | CommandExecutor.CommandExecutor | HttpClient.HttpClient +> { + return Effect.scoped( + Effect.gen(function* () { + const self = yield* selfBinary("update"); + const target = yield* releaseTarget("update"); + const release = yield* fetchRelease; + if (release.version === pkg.version) { + return yield* Console.log( + process.env.SCRATCHWORK_VERSION + ? `scratchwork is already version ${pkg.version}.` + : `scratchwork ${pkg.version} is already the latest version.`, + ); + } + const asset = `scratchwork-v${release.version}-${target}.tar.gz`; + const expected = release.digests.get(asset); + if (expected == null) { + return yield* Effect.fail(fail("update", `release v${release.version} has no prebuilt binary for ${target}`)); + } + yield* Console.log(`Downloading scratchwork v${release.version} (${target})...`); + const bytes = yield* fetchBytes(`${downloadBase()}/download/v${release.version}/${asset}`); + const actual = yield* Effect.tryPromise({ + try: () => sha256Hex(bytes), + catch: (cause) => fail("update", `could not hash ${asset}: ${errorMessage(cause)}`), + }); + if (actual !== expected) { + return yield* Effect.fail(fail("update", `checksum mismatch for ${asset}: expected ${expected}, got ${actual}`)); + } + const extracted = yield* extractBinary("update", asset, bytes); + yield* placeBinary("update", extracted, self); + yield* verifyRuns("update", self); + yield* Console.log(`Updated ${self}: ${pkg.version} -> ${release.version}`); + }), + ); +} diff --git a/cli/src/help.ts b/cli/src/help.ts index 68e2794..ae5e2c4 100644 --- a/cli/src/help.ts +++ b/cli/src/help.ts @@ -83,6 +83,27 @@ const EXTRAS: Readonly> = { "scratchwork publish --private", ], }, + install: { + notes: [ + "Installs the binary you are running, so it is normally invoked by the install script (curl -fsSL https://scratchwork.dev/install.sh | bash) or from a freshly extracted release archive.", + "Prints how to add the install directory to PATH when it is not already on it.", + ], + examples: [ + "./scratchwork install", + "./scratchwork install --dir ~/bin", + "SCRATCHWORK_INSTALL_DIR=~/bin ./scratchwork install", + ], + }, + update: { + notes: [ + "Downloads the latest GitHub release for this platform, verifies its checksum, and replaces the running binary in place.", + "Pin or downgrade with SCRATCHWORK_VERSION.", + ], + examples: [ + "scratchwork update", + "SCRATCHWORK_VERSION=0.2.0 scratchwork update", + ], + }, login: { notes: [ "Starts a loopback callback server and opens the browser for Google OAuth.", diff --git a/cli/src/index.ts b/cli/src/index.ts index f5c081d..c532252 100755 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -16,6 +16,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import pkg from "../package.json"; import { runExample } from "./commands/example"; +import { runInstall, runUpdate } from "./commands/install"; import { runLogin } from "./commands/login"; import { DEFAULT_PORT, runDev } from "./commands/dev"; import { runPublish } from "./commands/publish"; @@ -183,6 +184,18 @@ const streamCommand = Command.make( runStream, ).pipe(Command.withDescription("Publish once, then republish on local file changes")); +const installCommand = Command.make( + "install", + { + dir: textOption("dir", "path", "Install destination directory. Default: SCRATCHWORK_INSTALL_DIR or ~/.local/bin."), + }, + ({ dir }) => runInstall({ dir }), +).pipe(Command.withDescription("Install this scratchwork binary into a directory on your PATH")); + +const updateCommand = Command.make("update", {}, () => runUpdate()).pipe( + Command.withDescription("Update the scratchwork CLI to the latest release"), +); + const versionCommand = Command.make("version", {}, () => Console.log(pkg.version), ).pipe(Command.withDescription("Print the Scratchwork CLI version")); @@ -195,6 +208,7 @@ const scratchworkCommand = Command.make("scratchwork").pipe( devCommand, exampleCommand, infoCommand, + installCommand, loginCommand, meCommand, projectsCommand, @@ -204,6 +218,7 @@ const scratchworkCommand = Command.make("scratchwork").pipe( streamCommand, templateCommand, unpublishCommand, + updateCommand, versionCommand, ]), ); diff --git a/cli/test/help.test.js b/cli/test/help.test.js index 1c247ba..9e80208 100644 --- a/cli/test/help.test.js +++ b/cli/test/help.test.js @@ -7,6 +7,7 @@ const COMMANDS = [ "dev", "example", "info", + "install", "login", "me", "projects", @@ -14,6 +15,7 @@ const COMMANDS = [ "stream", "template", "unpublish", + "update", "version", ]; diff --git a/scratchwork.dev/www/install.md b/scratchwork.dev/www/install.md index a53fa6f..dfa71a6 100644 --- a/scratchwork.dev/www/install.md +++ b/scratchwork.dev/www/install.md @@ -13,11 +13,26 @@ curl -fsSL https://scratchwork.dev/install.sh | bash ``` Installs the latest release to `~/.local/bin/scratchwork`. No sudo, ever. -Re-running upgrades in place. +The script only downloads, verifies, and extracts the release; the binary's +own `scratchwork install` command does the rest (choosing the destination, +installing, verifying, and PATH advice). - Pin a version: `SCRATCHWORK_VERSION=0.2.0 curl -fsSL https://scratchwork.dev/install.sh | bash` - Change the destination: set `SCRATCHWORK_INSTALL_DIR` (default `~/.local/bin`) +## Updating + +An installed CLI updates itself — no need to re-run the install script: + +```sh +scratchwork update +``` + +Downloads the latest release for your platform, verifies its checksum, and +replaces the binary in place. Pin or downgrade with +`SCRATCHWORK_VERSION=0.2.0 scratchwork update`. Re-running the install +one-liner also upgrades in place. + ## Supported platforms Prebuilt binaries exist for exactly these targets: @@ -55,17 +70,20 @@ curl -fsSLO https://github.com/scratch/scratchwork/releases/download/v0.2.0/chec shasum -a 256 -c <(grep darwin-arm64 checksums.txt) # Linux: sha256sum -c ... tar -xzf scratchwork-v0.2.0-darwin-arm64.tar.gz # extracts one file: scratchwork -mkdir -p ~/.local/bin -mv scratchwork ~/.local/bin/scratchwork -chmod +x ~/.local/bin/scratchwork +./scratchwork install # installs to ~/.local/bin ``` -Make sure `~/.local/bin` is on your `PATH`: +`scratchwork install` copies the binary into `SCRATCHWORK_INSTALL_DIR` +(default `~/.local/bin`, or pass `--dir `), verifies it runs, and tells +you if the directory is missing from your `PATH`, e.g.: ```sh export PATH="$HOME/.local/bin:$PATH" ``` +Prefer fully manual placement? The extracted `scratchwork` file is the whole +install — move it anywhere on your `PATH` and make it executable. + ## Verify the install ```sh diff --git a/scratchwork.dev/www/install.sh b/scratchwork.dev/www/install.sh index 869ce3c..301f23c 100644 --- a/scratchwork.dev/www/install.sh +++ b/scratchwork.dev/www/install.sh @@ -1,21 +1,25 @@ #!/bin/sh -# Installs the scratchwork CLI from GitHub Releases. +# Bootstraps the scratchwork CLI from GitHub Releases: downloads the release +# archive for this platform, verifies its checksum, and hands off to the +# binary's own `scratchwork install` for everything after that (choosing the +# install directory, installing, and PATH advice). # # curl -fsSL https://scratchwork.dev/install.sh | bash # # Environment: # SCRATCHWORK_VERSION pin a version (e.g. 0.2.0); default: latest release -# SCRATCHWORK_INSTALL_DIR install destination; default: ~/.local/bin +# SCRATCHWORK_INSTALL_DIR install destination, read by `scratchwork install`; +# default: ~/.local/bin # SCRATCHWORK_DOWNLOAD_BASE override the release download base URL (used by # the hermetic ci test; default: GitHub Releases) # -# The script never escalates privileges. Re-running upgrades in place. +# The script never escalates privileges. Re-running upgrades in place, and an +# installed CLI can upgrade itself with `scratchwork update`. # Agent-readable manual steps: https://scratchwork.dev/install.md set -euf base="${SCRATCHWORK_DOWNLOAD_BASE:-https://github.com/scratch/scratchwork/releases}" -install_dir="${SCRATCHWORK_INSTALL_DIR:-$HOME/.local/bin}" version="${SCRATCHWORK_VERSION:-}" fail() { @@ -70,7 +74,7 @@ asset="scratchwork-v$version-$os-$arch.tar.gz" expected="$(grep -F " $asset" "$tmp/checksums.txt" | cut -d' ' -f1 || true)" [ -n "$expected" ] || fail "release v$version has no prebuilt binary for $os-$arch" -# ── Download, verify, extract, install ────────────────────────────────────── +# ── Download, verify, extract ─────────────────────────────────────────────── printf 'Downloading scratchwork v%s (%s-%s)...\n' "$version" "$os" "$arch" curl -fsSL "$base/download/v$version/$asset" -o "$tmp/$asset" || fail "could not download $base/download/v$version/$asset" actual="$(sha256 "$tmp/$asset")" @@ -78,18 +82,7 @@ actual="$(sha256 "$tmp/$asset")" tar -xzf "$tmp/$asset" -C "$tmp" [ -f "$tmp/scratchwork" ] || fail "archive $asset did not contain a scratchwork binary" -mkdir -p "$install_dir" chmod 755 "$tmp/scratchwork" -mv -f "$tmp/scratchwork" "$install_dir/scratchwork" -printf 'Installed %s\n' "$install_dir/scratchwork" -"$install_dir/scratchwork" --version >/dev/null || fail "the installed binary failed to run" -printf 'scratchwork %s is ready.\n' "$("$install_dir/scratchwork" --version)" - -case ":$PATH:" in - *":$install_dir:"*) ;; - *) - printf '\n%s is not on your PATH. Add it, e.g.:\n' "$install_dir" - printf ' export PATH="%s:$PATH"\n' "$install_dir" - ;; -esac +# ── Hand off to the verified binary for the actual install ────────────────── +"$tmp/scratchwork" install || fail "scratchwork install failed" diff --git a/scripts/check-install-sh.ts b/scripts/check-install-sh.ts index a99c7aa..8062be4 100644 --- a/scripts/check-install-sh.ts +++ b/scripts/check-install-sh.ts @@ -1,17 +1,22 @@ #!/usr/bin/env bun /* - * Mechanized checks for scratchwork.dev/www/install.sh (notes/distribution-plan.md - * Phase 3), run inside the root `bun run ci`: + * Mechanized checks for scratchwork.dev/www/install.sh and the CLI's + * self-install commands (`scratchwork install` / `scratchwork update`), run + * inside the root `bun run ci`: * * 1. `sh -n` syntax check. - * 2. Full install loop against a local HTTP fixture standing in for GitHub - * Releases (no network): latest install, pinned-version install, - * checksum-mismatch rejection, and uname→target platform mapping via a - * fake `uname` on PATH. The "binaries" are tiny sh scripts that answer - * `--version`, so the loop exercises download → verify → extract → - * install → run without real release assets. + * 2. A local HTTP fixture stands in for GitHub Releases (no network). + * install.sh downloads, checksum-verifies, and extracts, then delegates + * to the binary's own `install` command, so the end-to-end cases run the + * real compiled binary (cli/dist/scratchwork, built by the cli + * workspace's ci earlier in the gate; built here when missing): + * latest install via install.sh, then `scratchwork update` self-replace, + * already-up-to-date, and checksum-rejection. + * 3. sh-only behavior — pinned versions, checksum rejection, and + * uname→target platform mapping via a fake `uname` on PATH — uses tiny + * fake sh "binaries" that answer any invocation, keeping those cases fast. */ -import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { repoRoot } from "./workspaces"; @@ -28,32 +33,71 @@ if (!syntax.success) { // ── 2. Hermetic fixture ───────────────────────────────────────────────────── const work = mkdtempSync(join(tmpdir(), "install-sh-check-")); const TARGETS = ["darwin-arm64", "darwin-x64", "linux-x64", "linux-arm64"]; +const HOST_TARGET = `${process.platform}-${process.arch}`; -/** Builds a fake release: per-target tarballs of an sh "binary" + checksums.txt. */ -function makeRelease(version: string): { dir: string; checksums: string } { +// The real compiled binary: the end-to-end cases exercise its `install` and +// `update` commands, not a stand-in. +const realBinary = join(repoRoot, "cli", "dist", "scratchwork"); +if (!existsSync(realBinary)) { + const build = Bun.spawnSync(["bun", "build.js"], { cwd: join(repoRoot, "cli"), stdout: "inherit", stderr: "inherit" }); + if (!build.success) { + console.error("check-install-sh: failed to build cli/dist/scratchwork"); + process.exit(1); + } +} +const realVersion = Bun.spawnSync([realBinary, "version"], { stdout: "pipe" }).stdout.toString().trim(); +if (!/^\d+\.\d+\.\d+/.test(realVersion)) { + console.error(`check-install-sh: cli/dist/scratchwork did not report a version (got "${realVersion}")`); + process.exit(1); +} + +// Tar the real binary once; releases that carry it copy this archive. +const realTarball = join(work, "scratchwork-host.tar.gz"); +{ + const staging = join(work, "staging-host"); + mkdirSync(staging); + cpSync(realBinary, join(staging, "scratchwork")); + chmodSync(join(staging, "scratchwork"), 0o755); + const tar = Bun.spawnSync(["tar", "-czf", realTarball, "-C", staging, "scratchwork"]); + if (!tar.success) throw new Error("fixture tar failed for the real binary"); +} + +/** + * Builds a fake release: per-target tarballs plus checksums.txt. Non-host + * targets (and everything, without `realHost`) are tiny sh "binaries" that + * answer any invocation — enough for the sh-only cases. With `realHost`, the + * host target's asset is the real compiled binary. + */ +function makeRelease(version: string, options: { realHost?: boolean } = {}): { dir: string } { const dir = join(work, `release-v${version}`); mkdirSync(dir, { recursive: true }); const lines: string[] = []; for (const target of TARGETS) { - const staging = join(dir, `staging-${target}`); - mkdirSync(staging); - writeFileSync(join(staging, "scratchwork"), `#!/bin/sh\necho "${version} (${target})"\n`); - chmodSync(join(staging, "scratchwork"), 0o755); const archive = join(dir, `scratchwork-v${version}-${target}.tar.gz`); - const tar = Bun.spawnSync(["tar", "-czf", archive, "-C", staging, "scratchwork"]); - if (!tar.success) throw new Error(`fixture tar failed for ${archive}`); + if (options.realHost && target === HOST_TARGET) { + cpSync(realTarball, archive); + } else { + const staging = join(dir, `staging-${target}`); + mkdirSync(staging); + writeFileSync(join(staging, "scratchwork"), `#!/bin/sh\necho "${version} (${target})"\n`); + chmodSync(join(staging, "scratchwork"), 0o755); + const tar = Bun.spawnSync(["tar", "-czf", archive, "-C", staging, "scratchwork"]); + if (!tar.success) throw new Error(`fixture tar failed for ${archive}`); + } const digest = new Bun.CryptoHasher("sha256").update(readFileSync(archive)).digest("hex"); lines.push(`${digest} scratchwork-v${version}-${target}.tar.gz`); } - const checksums = lines.join("\n") + "\n"; - writeFileSync(join(dir, "checksums.txt"), checksums); - return { dir, checksums }; + writeFileSync(join(dir, "checksums.txt"), lines.join("\n") + "\n"); + return { dir }; } -const latest = makeRelease("9.9.9"); -const pinned = makeRelease("8.8.8"); -const releases: Record = { "9.9.9": latest, "8.8.8": pinned }; +const releases: Record = { + "9.9.9": makeRelease("9.9.9", { realHost: true }), + "8.8.8": makeRelease("8.8.8"), + [realVersion]: makeRelease(realVersion, { realHost: true }), +}; +let latestVersion = "9.9.9"; let tamperChecksums = false; const server = Bun.serve({ port: 0, @@ -62,7 +106,7 @@ const server = Bun.serve({ const path = new URL(request.url).pathname; const latestMatch = path === "/latest/download/checksums.txt"; const versioned = path.match(/^\/download\/v([^/]+)\/(.+)$/); - const version = latestMatch ? "9.9.9" : versioned?.[1]; + const version = latestMatch ? latestVersion : versioned?.[1]; const file = latestMatch ? "checksums.txt" : versioned?.[2]; const release = version != null ? releases[version] : undefined; if (release == null || file == null) return new Response("not found", { status: 404 }); @@ -90,19 +134,12 @@ interface RunResult { stderr: string; } -let caseNumber = 0; -// Async spawn is load-bearing: the install script curls the Bun.serve fixture in -// this same process, and spawnSync would block the event loop that serves it. -async function runInstall(env: Record): Promise { - const installDir = join(work, `install-${caseNumber++}`); - const proc = Bun.spawn(["sh", script], { - env: { - PATH: env.FAKE_UNAME_S ? `${fakeBin}:${process.env.PATH}` : process.env.PATH, - HOME: work, - SCRATCHWORK_DOWNLOAD_BASE: base, - SCRATCHWORK_INSTALL_DIR: installDir, - ...env, - }, +// Async spawn is load-bearing: the install script and the update command curl +// the Bun.serve fixture in this same process, and spawnSync would block the +// event loop that serves it. +async function run(cmd: string[], env: Record): Promise { + const proc = Bun.spawn(cmd, { + env: { HOME: work, SCRATCHWORK_DOWNLOAD_BASE: base, ...env }, stdout: "pipe", stderr: "pipe", }); @@ -114,21 +151,81 @@ async function runInstall(env: Record): Promise { return { code, stdout, stderr }; } +let caseNumber = 0; +async function runInstall(env: Record): Promise { + const installDir = join(work, `install-${caseNumber++}`); + const result = await run(["sh", script], { + PATH: env.FAKE_UNAME_S ? `${fakeBin}:${process.env.PATH}` : process.env.PATH, + SCRATCHWORK_INSTALL_DIR: installDir, + ...env, + }); + return { ...result, installDir }; +} + function expect(name: string, condition: boolean, detail: RunResult) { if (!condition) { failures.push(`${name}\n exit=${detail.code}\n stdout: ${detail.stdout.trim()}\n stderr: ${detail.stderr.trim()}`); } } -// Latest install on the real host platform. -let result = await runInstall({}); -expect("latest install should succeed and run the binary", result.code === 0 && result.stdout.includes("9.9.9"), result); +// ── End-to-end: install.sh → real binary's `scratchwork install` ──────────── +const latestRun = await runInstall({}); +expect( + "latest install should run the real binary's install command end-to-end", + latestRun.code === 0 && + latestRun.stdout.includes("Downloading scratchwork v9.9.9") && + latestRun.stdout.includes(`Installed ${join(latestRun.installDir, "scratchwork")}`) && + latestRun.stdout.includes(`scratchwork ${realVersion} is ready.`) && + latestRun.stdout.includes("is not on your PATH") && + existsSync(join(latestRun.installDir, "scratchwork")), + latestRun, +); + +// ── End-to-end: `scratchwork update` against the fixture ──────────────────── +const updateHome = join(work, "update-home"); +mkdirSync(updateHome); +const updateBinary = join(updateHome, "scratchwork"); +cpSync(realBinary, updateBinary); +chmodSync(updateBinary, 0o755); +// Success: self-replace with the fixture's latest (a fresh inode proves the swap). +const inodeBefore = statSync(updateBinary).ino; +let result = await run([updateBinary, "update"], { PATH: process.env.PATH }); +expect( + "update should download, verify, and replace the running binary", + result.code === 0 && + result.stdout.includes("Downloading scratchwork v9.9.9") && + result.stdout.includes(`-> 9.9.9`) && + statSync(updateBinary).ino !== inodeBefore, + result, +); + +// Already up to date: the fixture's latest matches the binary's own version. +latestVersion = realVersion; +result = await run([updateBinary, "update"], { PATH: process.env.PATH }); +expect( + "update should be a no-op when already on the latest version", + result.code === 0 && result.stdout.includes("already the latest version") && !result.stdout.includes("Downloading"), + result, +); +latestVersion = "9.9.9"; + +// Tampered checksums must be rejected by the update command. +tamperChecksums = true; +result = await run([updateBinary, "update"], { PATH: process.env.PATH }); +expect( + "update must reject tampered checksums", + result.code !== 0 && result.stderr.includes("checksum mismatch"), + result, +); +tamperChecksums = false; + +// ── sh-only behavior (fake binaries; nothing real gets executed) ──────────── // Pinned version install. result = await runInstall({ SCRATCHWORK_VERSION: "8.8.8" }); expect("pinned install should fetch v8.8.8", result.code === 0 && result.stdout.includes("8.8.8"), result); -// Checksum tampering must be rejected. +// Checksum tampering must be rejected before anything runs. tamperChecksums = true; result = await runInstall({}); expect("tampered checksums must fail with a mismatch", result.code !== 0 && result.stderr.includes("checksum mismatch"), result); @@ -140,10 +237,11 @@ expect("unsupported OS must fail before downloading", result.code !== 0 && resul result = await runInstall({ FAKE_UNAME_S: "Linux", FAKE_UNAME_M: "riscv64" }); expect("unsupported architecture must fail", result.code !== 0 && result.stderr.includes("unsupported architecture"), result); -// ...and supported uname spellings map to the right release asset. -result = await runInstall({ FAKE_UNAME_S: "Linux", FAKE_UNAME_M: "aarch64" }); +// ...and supported uname spellings map to the right release asset. Pinned to +// the all-fake release so a non-host asset never runs for real. +result = await runInstall({ SCRATCHWORK_VERSION: "8.8.8", FAKE_UNAME_S: "Linux", FAKE_UNAME_M: "aarch64" }); expect("Linux/aarch64 must map to the linux-arm64 asset", result.code === 0 && result.stdout.includes("(linux-arm64)"), result); -result = await runInstall({ FAKE_UNAME_S: "Darwin", FAKE_UNAME_M: "arm64" }); +result = await runInstall({ SCRATCHWORK_VERSION: "8.8.8", FAKE_UNAME_S: "Darwin", FAKE_UNAME_M: "arm64" }); expect("Darwin/arm64 must map to the darwin-arm64 asset", result.code === 0 && result.stdout.includes("(darwin-arm64)"), result); server.stop(true); @@ -154,4 +252,6 @@ if (failures.length > 0) { for (const failure of failures) console.error(failure + "\n"); process.exit(1); } -console.log("check-install-sh: syntax ok; latest, pinned, checksum-rejection, and platform-mapping cases pass against the local fixture"); +console.log( + "check-install-sh: syntax ok; end-to-end install + update (real binary) and pinned, checksum-rejection, platform-mapping cases pass against the local fixture", +);