diff --git a/README.md b/README.md index 0ad7765c5..75e18080d 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ Fork it. Improve it. Make it yours. And if you want to hate on free open source `./setup` took 31 seconds after the clone on a 4-vCPU Linux cloud machine (v1.91.13.0, clean HOME, including the binary build and the Chromium download). Slower networks and laptops take longer. -**Requirements:** [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Git](https://git-scm.com/), [Bun](https://bun.sh/) v1.0+, [Node.js](https://nodejs.org/) (Windows only). **Recommended on macOS:** the [Aside](https://aside.com) browser (macOS 15+) — browser skills, `/make-pdf`, and `/diagram` drive it first, with your real logged-in sessions. Without it, `./setup` builds gstack's own bundled browser and the same skills use that. `/cso` additionally needs a Bun release with all four `--no-compile-autoload-*` build flags plus a native toolchain: a static-capable C compiler on Linux, Xcode command-line tools on macOS, or Visual Studio 2022 Build Tools with Desktop development with C++ on Windows. If those are absent, setup installs everything else, removes stale CSO helpers, and `/cso` reports `not assessed` with the prerequisite. +**Requirements:** [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Git](https://git-scm.com/), [Bun](https://bun.sh/) v1.4.0+ (the tested version; setup refuses Bun older than 1.3.3, which would let compiled tools read a project's `.env`, and warns in between), [Node.js](https://nodejs.org/) (Windows only). **Recommended on macOS:** the [Aside](https://aside.com) browser (macOS 15+) — browser skills, `/make-pdf`, and `/diagram` drive it first, with your real logged-in sessions. Without it, `./setup` builds gstack's own bundled browser and the same skills use that. `/cso` additionally needs a Bun release with all four `--no-compile-autoload-*` build flags plus a native toolchain: a static-capable C compiler on Linux, Xcode command-line tools on macOS, or Visual Studio 2022 Build Tools with Desktop development with C++ on Windows. If those are absent, setup installs everything else, removes stale CSO helpers, and `/cso` reports `not assessed` with the prerequisite. When qualified CSO runtime images are published, setup gives each automatic preload a 30-second window plus a bounded setup allowance for the declared catalog. For slower registries, set an integer such as `GSTACK_CSO_IMAGE_PULL_TIMEOUT_SECONDS=120` (accepted range: 5–300 seconds). One image timing out does not consume the remaining images' windows; setup reports partial progress and a later run resumes from exact digests already present in local Docker. The complete preload is capped at one hour. diff --git a/bin/gstack-bun-version.sh b/bin/gstack-bun-version.sh new file mode 100644 index 000000000..eeb66e978 --- /dev/null +++ b/bin/gstack-bun-version.sh @@ -0,0 +1,58 @@ +# shellcheck shell=bash +# gstack-bun-version.sh — the one source for the Bun versions gstack needs. +# Sourced, never executed: ./setup refuses or warns from it before writing +# anything, and bin/gstack-session-update reads the INCOMING release's copy +# (git show) before advancing a live checkout. test/bun-version-drift.test.ts +# keeps it equal to package.json engines.bun, every CI pin, setup's install +# hint and README. Bash 3.2 builtins only. +# +# GSTACK_BUN_FLOOR is the security floor. Older Bun accepts +# --no-compile-autoload-dotenv/--no-compile-autoload-bunfig but ignores them, +# so gstack's compiled tools would read a project's .env. Verified by a +# compiled probe (a binary built with all four no-autoload flags, run beside a +# .env): Bun 1.3.2 loads it, 1.3.3 and 1.4.0 do not. +# GSTACK_BUN_TESTED is the supported minimum: the CI pin and engines.bun. +GSTACK_BUN_FLOOR="1.3.3" +GSTACK_BUN_TESTED="1.4.0" + +# _gstack_bun_parse — sets _gb_core ("MAJOR MINOR PATCH" as decimal +# integers) and _gb_pre (prerelease, may be empty); returns 1 when malformed. +# A leading "v" and "+build" metadata are ignored. +_gstack_bun_parse() { + _gb_v="${1#v}"; _gb_v="${_gb_v%%+*}"; _gb_pre="" + case "$_gb_v" in + *-*) _gb_pre="${_gb_v#*-}"; _gb_v="${_gb_v%%-*}"; [ -n "$_gb_pre" ] || return 1 ;; + esac + case "$_gb_v" in *.*.*) ;; *) return 1 ;; esac + _gb_maj="${_gb_v%%.*}"; _gb_rest="${_gb_v#*.}"; _gb_min="${_gb_rest%%.*}"; _gb_pat="${_gb_rest#*.}" + case "$_gb_maj" in ''|*[!0-9]*) return 1 ;; esac + case "$_gb_min" in ''|*[!0-9]*) return 1 ;; esac + case "$_gb_pat" in ''|*[!0-9]*) return 1 ;; esac + case "$_gb_pre" in *[!0-9A-Za-z.-]*) return 1 ;; esac + _gb_core="$((10#$_gb_maj)) $((10#$_gb_min)) $((10#$_gb_pat))" +} + +# _gstack_bun_below — true when version sorts below the +# release. Prerelease rule (semver): 1.3.3-canary.1 is below 1.3.3 and above +# 1.3.2, so a prerelease of the floor itself is refused. +_gstack_bun_below() { + _gstack_bun_parse "$2" || return 1 + set -- "$1" $_gb_core + _gstack_bun_parse "$1" || return 1 + set -- "$@" $_gb_core + [ "$5" -ne "$2" ] && { [ "$5" -lt "$2" ]; return; } + [ "$6" -ne "$3" ] && { [ "$6" -lt "$3" ]; return; } + [ "$7" -ne "$4" ] && { [ "$7" -lt "$4" ]; return; } + [ -n "$_gb_pre" ] +} + +# gstack_bun_status [floor] — prints too-old, untested, ok or +# malformed. The optional floor lets the auto-updater apply the incoming +# release's floor with this release's comparison rule. +gstack_bun_status() { + if ! _gstack_bun_parse "$1"; then echo malformed + elif _gstack_bun_below "$1" "${2:-$GSTACK_BUN_FLOOR}"; then echo too-old + elif _gstack_bun_below "$1" "$GSTACK_BUN_TESTED"; then echo untested + else echo ok + fi +} diff --git a/bin/gstack-session-update b/bin/gstack-session-update index 0fa33bb35..53d5d1fb2 100755 --- a/bin/gstack-session-update +++ b/bin/gstack-session-update @@ -5,6 +5,11 @@ # The entire update runs in background (forked). The hook itself exits # immediately so session startup is never delayed. # +# Before the live checkout moves, the updater fetches, reads the incoming +# release's Bun floor (bin/gstack-bun-version.sh) and checks the `bun` setup +# will run; below it the update is held with reason bun-too-old and nothing +# changes (E1). Updaters older than this check cannot protect their first pull. +# # Stages (B11): pull, setup and migrations are tracked separately. The # "just upgraded" marker is written only when every stage succeeded. A failed # stage is persisted in $STATE_DIR/session-update-pending and retried even @@ -25,6 +30,9 @@ STATE_DIR="$(gstack_state_root; printf x)"; STATE_DIR="${STATE_DIR%x}" # Egress receipt helpers (_receipted_git): fail-open — an update pull must # never block a session over a receipt hiccup. . "$(cd "$(dirname "$0")" && pwd)/gstack-egress-lib.sh" +# Bun floor comparison (E1): the incoming release's floor is applied before +# the live checkout moves. Missing helper fails open, like every error here. +. "$(cd "$(dirname "$0")" && pwd)/gstack-bun-version.sh" 2>/dev/null THROTTLE_FILE="$STATE_DIR/.last-session-update" LOCK_DIR="$STATE_DIR/.setup-lock" LOG_FILE="$STATE_DIR/analytics/session-update.log" @@ -63,7 +71,12 @@ if [ -f "$PENDING_FILE" ]; then case "$_P_REASON" in bun*) _P_FIX="install Bun (https://bun.sh), then $_P_FIX" ;; esac echo "gstack auto-update: ${_P_STAGES} did not finish (${_P_REASON:-unknown reason}); installed skills may be out of date. gstack retries automatically. Fix now: $_P_FIX" elif [ -n "$_P_PULL" ]; then - echo "gstack auto-update: git pull did not finish ($_P_PULL); gstack is not updating. Fix now: cd $GSTACK_DIR && git pull --ff-only && ./setup" + case "$_P_PULL" in + bun-too-old*) + echo "gstack auto-update: update held ($_P_PULL); nothing was installed or changed. Fix now: bun upgrade && cd $GSTACK_DIR && git pull --ff-only && ./setup" ;; + *) + echo "gstack auto-update: git pull did not finish ($_P_PULL); gstack is not updating. Fix now: cd $GSTACK_DIR && git pull --ff-only && ./setup" ;; + esac fi fi @@ -158,7 +171,7 @@ fi # heartbeat. trap 'kill "$HB_PID" 2>/dev/null; [ "$(cat "$LOCK_DIR/pid" 2>/dev/null)" = "$MYPID" ] && rm -rf "$LOCK_DIR" 2>/dev/null' EXIT - # ── Pull latest ── + # ── Fetch, check the incoming release's Bun floor, then fast-forward (E1) ── OLD_HEAD=$(git -C "$GSTACK_DIR" rev-parse HEAD 2>/dev/null) UPDATE_URL=$(git -C "$GSTACK_DIR" remote get-url origin 2>/dev/null || echo "") UPDATE_HOST="${UPDATE_URL#*://}"; UPDATE_HOST="${UPDATE_HOST#*@}"; UPDATE_HOST="${UPDATE_HOST%%[/:]*}" @@ -170,8 +183,27 @@ fi # Capture stderr: the log must carry WHY a pull failed, never just the code. PULL_ERR_FILE=$(mktemp "${TMPDIR:-/tmp}/gstack-session-pull-XXXXXX" 2>/dev/null || echo "") GSTACK_HOME="$STATE_DIR" _receipted_git open session-update "${UPDATE_HOST:-unknown}" gstack-self-update-pull "auto_upgrade=true" \ - bash -c 'git -C "$1" pull --ff-only --autostash -q 2>"${2:-/dev/null}"' _ "$GSTACK_DIR" "$PULL_ERR_FILE" + bash -c 'git -C "$1" fetch -q 2>"${2:-/dev/null}"' _ "$GSTACK_DIR" "$PULL_ERR_FILE" PULL_EXIT=$? + # The fast-forward targets the exact revision whose requirement was read. + # Setup runs `bun` from this PATH, so that is the binary checked; a + # malformed version passes here because setup only warns on it. + INCOMING="" BUN_HELD="" + if [ "$PULL_EXIT" -eq 0 ]; then + INCOMING=$(git -C "$GSTACK_DIR" rev-parse --verify '@{upstream}^{commit}' 2>"${PULL_ERR_FILE:-/dev/null}") || PULL_EXIT=1 + fi + if [ "$PULL_EXIT" -eq 0 ] && [ "$INCOMING" != "$OLD_HEAD" ] && command -v bun >/dev/null 2>&1; then + _IN_FLOOR=$(git -C "$GSTACK_DIR" show "$INCOMING:bin/gstack-bun-version.sh" 2>/dev/null | sed -n 's/^GSTACK_BUN_FLOOR="\([0-9A-Za-z.+-]*\)".*/\1/p' | head -1) + _BUN_FOUND=$(bun --version 2>/dev/null | head -1) + if [ -n "$_IN_FLOOR" ] && [ "$(gstack_bun_status "$_BUN_FOUND" "$_IN_FLOOR" 2>/dev/null)" = too-old ]; then + _IN_VERSION=$(git -C "$GSTACK_DIR" show "$INCOMING:VERSION" 2>/dev/null | head -1) + BUN_HELD="bun-too-old: found Bun $_BUN_FOUND at $(command -v bun); gstack ${_IN_VERSION:-update} needs $_IN_FLOOR or newer" + fi + fi + if [ "$PULL_EXIT" -eq 0 ] && [ -z "$BUN_HELD" ]; then + git -C "$GSTACK_DIR" merge --ff-only --autostash -q "$INCOMING" >/dev/null 2>"${PULL_ERR_FILE:-/dev/null}" + PULL_EXIT=$? + fi NEW_HEAD=$(git -C "$GSTACK_DIR" rev-parse HEAD 2>/dev/null) # Heartbeat: pull done — keep the TTL clock fresh for the setup step. @@ -185,6 +217,13 @@ fi case "$P_FAILURES" in ''|*[!0-9]*) P_FAILURES=0 ;; esac case "$P_NEXT" in ''|*[!0-9]*) P_NEXT=0 ;; esac + if [ -n "$BUN_HELD" ]; then + log_entry "HELD $BUN_HELD incoming=$INCOMING" + P_PULL="$BUN_HELD" + pending_save + rm -f "$PULL_ERR_FILE" 2>/dev/null + exit 0 + fi if [ "$PULL_EXIT" -ne 0 ]; then PULL_REASON=$(head -c 300 "$PULL_ERR_FILE" 2>/dev/null | tr '\n' ' ' | tr -s ' ') log_entry "PULL_FAILED exit=$PULL_EXIT reason=${PULL_REASON:-unknown}" diff --git a/setup b/setup index 70947d524..8eb4264b5 100755 --- a/setup +++ b/setup @@ -283,16 +283,28 @@ case "$HOST" in echo "Unknown --host value: $HOST — nothing was installed or changed. Expected one of: claude (default), codex, kiro, factory, opencode, cursor, copilot, slate, openclaw, hermes, gbrain, or auto. Tiers: ./setup --help" >&2; exit 1 ;; esac +# Bun versions come from one sourced file (floor and tested; E1). +. "$(dirname "$0")/bin/gstack-bun-version.sh" 2>/dev/null || { echo "$0: bin/gstack-bun-version.sh is missing; nothing was installed or changed. Fix: reinstall gstack (git clone https://github.com/garrytan/gstack)" >&2; exit 1; } if ! command -v bun >/dev/null 2>&1; then echo "Error: bun is required but not installed." >&2 echo "Install with checksum verification:" >&2 - echo ' BUN_VERSION="1.3.10"' >&2 + echo " BUN_VERSION=\"$GSTACK_BUN_TESTED\"" >&2 echo ' tmpfile=$(mktemp)' >&2 echo ' curl -fsSL "https://bun.sh/install" -o "$tmpfile"' >&2 echo ' echo "Verify checksum before running: sha256sum $tmpfile # or: shasum -a 256 $tmpfile"' >&2 echo ' BUN_VERSION="$BUN_VERSION" bash "$tmpfile" && rm "$tmpfile"' >&2 exit 1 fi +_BUN_FOUND="$(bun --version 2>/dev/null | head -1)" +case "$(gstack_bun_status "$_BUN_FOUND")" in + too-old) + echo "gstack needs Bun $GSTACK_BUN_FLOOR or newer ($GSTACK_BUN_TESTED recommended); found $_BUN_FOUND at $(command -v bun). Nothing was installed or changed." >&2 + echo "Why: older Bun ignores --no-compile-autoload-dotenv, so gstack's compiled tools could read a project's .env." >&2 + echo "Fix: bun upgrade, then re-run ./setup (docs/troubleshooting.md#bun-too-old)" >&2 + exit 1 ;; + untested) echo "warning: gstack is tested on Bun $GSTACK_BUN_TESTED (CI pin); found $_BUN_FOUND. Upgrade with: bun upgrade (docs/troubleshooting.md#bun-too-old)" >&2 ;; + malformed) echo "warning: could not read the Bun version (bun --version printed '$(printf '%s' "$_BUN_FOUND" | head -c 60)'); continuing. gstack is tested on Bun $GSTACK_BUN_TESTED (docs/troubleshooting.md#bun-too-old)" >&2 ;; +esac INSTALL_GSTACK_DIR="$(cd "$(dirname "$0")" && pwd)" SOURCE_GSTACK_DIR="$(cd "$(dirname "$0")" && pwd -P)" diff --git a/test/bun-version-drift.test.ts b/test/bun-version-drift.test.ts index 0a0d21578..c2f0a2e93 100644 --- a/test/bun-version-drift.test.ts +++ b/test/bun-version-drift.test.ts @@ -10,9 +10,16 @@ * parsing — a lane on a different Bun is testing a different product. * * Bumping Bun: change every surface in one commit; this test names each one. + * + * E1: bin/gstack-bun-version.sh is the one source setup and the auto-updater + * read. Its tested version is the CI pin, engines.bun's lower bound, setup's + * install hint and README's requirement; its floor (1.3.3) is the security + * boundary below which the no-autoload compile flags are silently ignored. */ import { describe, expect, test } from 'bun:test'; +import { spawnSync } from 'node:child_process'; import * as fs from 'node:fs'; +import * as os from 'node:os'; import * as path from 'node:path'; const ROOT = path.resolve(__dirname, '..'); @@ -88,3 +95,79 @@ describe('bun version pins', () => { } }); }); + +function bunVersionsFile(): { floor: string; tested: string } { + const src = fs.readFileSync(path.join(ROOT, 'bin', 'gstack-bun-version.sh'), 'utf-8'); + return { + floor: src.match(/^GSTACK_BUN_FLOOR="([^"]+)"$/m)?.[1] ?? '', + tested: src.match(/^GSTACK_BUN_TESTED="([^"]+)"$/m)?.[1] ?? '', + }; +} + +describe('Bun requirement surfaces agree (E1)', () => { + const { floor, tested } = bunVersionsFile(); + + test('tested version == every CI pin == engines.bun lower bound; floor sits below it', () => { + const ciVersions = [...new Set(collectPins().map((p) => p.version))]; + expect(ciVersions).toEqual([tested]); + const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf-8')); + expect(pkg.engines?.bun).toBe(`>=${tested}`); + expect(floor).toBe('1.3.3'); + expect(Bun.semver.order(floor, tested)).toBe(-1); + }); + + test('README states the tested version and the floor', () => { + const readme = fs.readFileSync(path.join(ROOT, 'README.md'), 'utf-8'); + expect(readme).toContain(`[Bun](https://bun.sh/) v${tested}+`); + expect(readme).toContain(`refuses Bun older than ${floor}`); + }); + + test.skipIf(process.platform === 'win32')('setup install hint pins the tested version', () => { + const base = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-bun-hint-')); + try { + const nobun = path.join(base, 'bin'); + fs.mkdirSync(nobun); + for (const tool of ['dirname', 'cat', 'uname', 'mkdir']) { + const found = spawnSync('bash', ['-c', `command -v ${tool}`], { encoding: 'utf-8', timeout: 10_000 }).stdout.trim(); + if (found) fs.symlinkSync(found, path.join(nobun, tool)); + } + const r = spawnSync('/bin/bash', [path.join(ROOT, 'setup')], { + encoding: 'utf-8', env: { PATH: nobun, HOME: base }, timeout: 30_000, + }); + expect(r.status).toBe(1); + expect(r.stderr).toContain('bun is required but not installed'); + expect(r.stderr).toContain(`BUN_VERSION="${tested}"`); + } finally { + fs.rmSync(base, { recursive: true, force: true }); + } + }); + + // Compiled probe of the security floor: a binary built with the four + // no-autoload flags must not read a .env beside it. Run by hand against + // released binaries on 2026-10-04: Bun 1.3.2 accepted the flags and still + // loaded .env; 1.3.3 and 1.4.0 did not. This keeps the probe honest on the + // Bun under test (the CI pin); the control build proves the probe can see a leak. + test.skipIf(process.platform === 'win32')('compiled probe: the no-autoload flags hold on the Bun under test', () => { + const base = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-bun-probe-')); + try { + fs.writeFileSync(path.join(base, 'probe.ts'), 'console.log(process.env.GSTACK_BUN_PROBE ?? "absent");\n'); + const run = path.join(base, 'run'); + fs.mkdirSync(run); + fs.writeFileSync(path.join(run, '.env'), 'GSTACK_BUN_PROBE=leaked\n'); + const build = (out: string, flags: string[]) => { + const r = spawnSync(process.execPath, ['build', '--compile', ...flags, 'probe.ts', '--outfile', out], { + cwd: base, encoding: 'utf-8', timeout: 120_000, + }); + expect(r.status, r.stderr).toBe(0); + return spawnSync(path.join(base, out), [], { cwd: run, encoding: 'utf-8', timeout: 30_000 }).stdout.trim(); + }; + expect(build('control', [])).toBe('leaked'); + expect(build('guarded', [ + '--no-compile-autoload-dotenv', '--no-compile-autoload-bunfig', + '--no-compile-autoload-tsconfig', '--no-compile-autoload-package-json', + ])).toBe('absent'); + } finally { + fs.rmSync(base, { recursive: true, force: true }); + } + }, 240_000); +}); diff --git a/test/session-update-autostash.test.ts b/test/session-update-autostash.test.ts index b61b659a2..5bc0098c6 100644 --- a/test/session-update-autostash.test.ts +++ b/test/session-update-autostash.test.ts @@ -138,7 +138,7 @@ describe('gstack-session-update lock identity + TTL (#2613)', () => { const realGit = execFileSync('bash', ['-c', 'command -v git'], { encoding: 'utf8' }).trim(); fs.writeFileSync( path.join(shimDir, 'git'), - `#!/usr/bin/env bash\ncase "$*" in *pull*) sleep ${sleepSecs};; esac\nexec "${realGit}" "$@"\n`, + `#!/usr/bin/env bash\ncase "$*" in *fetch*) sleep ${sleepSecs};; esac\nexec "${realGit}" "$@"\n`, { mode: 0o755 }, ); return shimDir; diff --git a/test/session-update-stages.test.ts b/test/session-update-stages.test.ts index 4107fe656..4f68c4cbc 100644 --- a/test/session-update-stages.test.ts +++ b/test/session-update-stages.test.ts @@ -78,8 +78,27 @@ function upstreamRelease(fx: Fx, version: string) { git(fx.seed, 'push', '-q', 'origin', 'main'); } -function runHook(fx: Fx, opts: { noBun?: boolean } = {}) { - const PATH = opts.noBun ? pathWithoutBun(fx.base) : `${path.dirname(process.execPath)}:${process.env.PATH}`; +/** Upstream release that ships the Bun floor file (E1). */ +function upstreamReleaseWithFloor(fx: Fx, version: string, floor: string) { + fs.copyFileSync(path.join(ROOT, 'bin', 'gstack-bun-version.sh'), path.join(fx.seed, 'bin', 'gstack-bun-version.sh')); + const file = path.join(fx.seed, 'bin', 'gstack-bun-version.sh'); + fs.writeFileSync(file, fs.readFileSync(file, 'utf8').replace(/^GSTACK_BUN_FLOOR="[^"]*"/m, `GSTACK_BUN_FLOOR="${floor}"`)); + git(fx.seed, 'add', '-A'); + upstreamRelease(fx, version); +} + +/** A `bun` first on PATH that reports `version` (the binary setup would run). */ +function bunStub(fx: Fx, version: string): string { + const dir = path.join(fx.base, 'bun-stub'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'bun'), `#!/usr/bin/env bash\nprintf '%s\\n' '${version}'\n`, { mode: 0o755 }); + return dir; +} + +function runHook(fx: Fx, opts: { noBun?: boolean; bun?: string } = {}) { + const PATH = opts.noBun ? pathWithoutBun(fx.base) + : opts.bun ? `${bunStub(fx, opts.bun)}:${pathWithoutBun(fx.base)}` + : `${path.dirname(process.execPath)}:${process.env.PATH}`; return spawnSync('bash', [SCRIPT], { encoding: 'utf8', env: { PATH, HOME: fx.home, GSTACK_DIR: fx.install, GSTACK_STATE_ROOT: fx.state, SETUP_CONTROL: fx.control, SETUP_CALLS: fx.calls, TMPDIR: fx.base }, @@ -198,3 +217,51 @@ describe.skipIf(process.platform === 'win32')('gstack-session-update stages (B11 expect(setupRuns(fx)).toBe(1); }); }); + +describe.skipIf(process.platform === 'win32')('gstack-session-update Bun floor (E1)', () => { + const head = (fx: Fx) => git(fx.install, 'rev-parse', 'HEAD'); + + test('below the incoming floor: the live checkout and installs stay untouched, with a stable bun-too-old reason', async () => { + const fx = makeFixture(); + const before = head(fx); + upstreamReleaseWithFloor(fx, '1.1.0', '1.3.3'); + const incoming = git(fx.seed, 'rev-parse', 'HEAD'); + + expect(runHook(fx, { bun: '1.3.2' }).stdout).toBe(''); + const stub = path.join(fx.base, 'bun-stub', 'bun'); + const log = await waitFor(fx, /HELD bun-too-old/); + expect(log).toContain(`HELD bun-too-old: found Bun 1.3.2 at ${stub}; gstack 1.1.0 needs 1.3.3 or newer incoming=${incoming}`); + expect(head(fx)).toBe(before); + expect(fs.readFileSync(path.join(fx.install, 'VERSION'), 'utf8')).toBe('1.0.0\n'); + expect(git(fx.install, 'status', '--porcelain')).toBe(''); + expect(setupRuns(fx)).toBe(0); + expect(fs.existsSync(marker(fx))).toBe(false); + expect(fs.readFileSync(pending(fx), 'utf8')).toContain(`pull=bun-too-old: found Bun 1.3.2 at ${stub}; gstack 1.1.0 needs 1.3.3 or newer\n`); + + expireThrottle(fx); + expect(runHook(fx, { bun: '1.3.2' }).stdout.trim()).toBe( + `gstack auto-update: update held (bun-too-old: found Bun 1.3.2 at ${stub}; gstack 1.1.0 needs 1.3.3 or newer); nothing was installed or changed. Fix now: bun upgrade && cd ${fx.install} && git pull --ff-only && ./setup`, + ); + await waitFor(fx, /HELD bun-too-old/, 2); + expect(head(fx)).toBe(before); + + // After `bun upgrade` the next session start resumes the update on its own. + expireThrottle(fx); + runHook(fx, { bun: '1.4.0' }); + await waitFor(fx, /UPDATED from=1\.0\.0 to=1\.1\.0/); + expect(head(fx)).toBe(incoming); + expect(setupRuns(fx)).toBe(1); + expect(fs.existsSync(pending(fx))).toBe(false); + }); + + for (const version of ['1.3.14', 'bun-dev (local build)']) { + test(`supported or unparseable Bun (${version}) proceeds: setup decides`, async () => { + const fx = makeFixture(); + upstreamReleaseWithFloor(fx, '1.1.0', '1.3.3'); + runHook(fx, { bun: version }); + const log = await waitFor(fx, /UPDATED from=1\.0\.0 to=1\.1\.0/); + expect(log).not.toContain('HELD'); + expect(head(fx)).toBe(git(fx.seed, 'rev-parse', 'HEAD')); + }); + } +}); diff --git a/test/setup-bun-floor.test.ts b/test/setup-bun-floor.test.ts new file mode 100644 index 000000000..f812aaaf7 --- /dev/null +++ b/test/setup-bun-floor.test.ts @@ -0,0 +1,139 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { spawnSync } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +// E1: setup refuses Bun below the security floor (1.3.3) before writing +// anything, warns from the floor up to the tested version (1.4.0), warns and +// continues on a version it cannot parse, and keeps --help/--status working +// without Bun. The auto-updater half lives in session-update-stages.test.ts. + +const ROOT = path.resolve(import.meta.dir, '..'); +const SETUP = path.join(ROOT, 'setup'); +const VERSIONS = path.join(ROOT, 'bin', 'gstack-bun-version.sh'); +const bases: string[] = []; +afterEach(() => { for (const b of bases.splice(0)) fs.rmSync(b, { recursive: true, force: true }); }); + +function tempBase(): string { + const base = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-bun-floor-')); + bases.push(base); + return base; +} + +/** PATH without any bun: directories holding bun are replaced by shims of everything else. */ +function pathWithoutBun(base: string): string { + return (process.env.PATH ?? '/usr/bin:/bin').split(':').filter(Boolean).map((dir, i) => { + if (!fs.existsSync(path.join(dir, 'bun'))) return dir; + const shim = path.join(base, `nobun-${i}`); + if (fs.existsSync(shim)) return shim; + fs.mkdirSync(shim, { recursive: true }); + for (const name of fs.readdirSync(dir)) if (name !== 'bun') fs.symlinkSync(path.join(dir, name), path.join(shim, name)); + return shim; + }).join(':'); +} + +/** A `bun` that reports `version`; any other call is recorded and fails, so setup stops right after the gate. */ +function runSetupWithBun(version: string) { + const base = tempBase(); + const stub = path.join(base, 'stub'); + const home = path.join(base, 'home'); + const state = path.join(base, 'state'); + const calls = path.join(base, 'bun-calls'); + fs.mkdirSync(stub, { recursive: true }); + fs.mkdirSync(home); + fs.mkdirSync(state); + fs.writeFileSync(path.join(stub, 'bun'), [ + '#!/usr/bin/env bash', + `if [ "$1" = "--version" ]; then printf '%s\\n' '${version}'; exit 0; fi`, + `echo "$*" >> '${calls}'`, + 'echo stubbed-bun-stop >&2', + 'exit 1', + ].join('\n') + '\n', { mode: 0o755 }); + const r = spawnSync('bash', [SETUP, '--claude-model', 'claude-opus-4-8'], { + encoding: 'utf8', + env: { PATH: `${stub}:${pathWithoutBun(base)}`, HOME: home, GSTACK_STATE_ROOT: state, TMPDIR: base }, + timeout: 30_000, + }); + const bunCalls = fs.existsSync(calls) ? fs.readFileSync(calls, 'utf8') : ''; + const written = [...fs.readdirSync(home), ...fs.readdirSync(state)]; + return { ...r, stub: path.join(stub, 'bun'), bunCalls, written }; +} + +function status(version: string, floor?: string): string { + const r = spawnSync('bash', ['-c', `. "$1"; gstack_bun_status "$2" ${floor ? '"$3"' : ''}`, '_', VERSIONS, version, floor ?? ''], { + encoding: 'utf8', + timeout: 10_000, + }); + return r.stdout.trim(); +} + +describe.skipIf(process.platform === 'win32')('Bun floor (E1)', () => { + test('comparison rule: numeric fields, build metadata ignored, a prerelease sorts below its release', () => { + const table: Record = { + '1.3.2': 'too-old', + '1.3.3-canary.1': 'too-old', + '1.3.3': 'untested', + '1.3.14': 'untested', + '1.4.0-canary.20261001+abc123': 'untested', + '1.4.0': 'ok', + 'v1.4.1': 'ok', + '1.10.0': 'ok', + '2.0.0': 'ok', + '': 'malformed', + '1.4': 'malformed', + '1.4.0.1': 'malformed', + '1.4.0-': 'malformed', + 'bun-dev (local build)': 'malformed', + }; + const got = Object.fromEntries(Object.keys(table).map((v) => [v, status(v)])); + expect(got).toEqual(table); + // The updater passes the incoming release's floor explicitly. + expect(status('1.3.5', '1.3.6')).toBe('too-old'); + expect(status('1.3.6', '1.3.6')).toBe('untested'); + }); + + test('below the floor: refused with cause and fix, before anything is written', () => { + for (const version of ['1.3.2', '1.3.3-canary.1']) { + const r = runSetupWithBun(version); + expect(r.status).toBe(1); + expect(r.stderr).toContain(`gstack needs Bun 1.3.3 or newer (1.4.0 recommended); found ${version} at ${r.stub}. Nothing was installed or changed.`); + expect(r.stderr).toContain('older Bun ignores --no-compile-autoload-dotenv'); + expect(r.stderr).toContain('Fix: bun upgrade, then re-run ./setup (docs/troubleshooting.md#bun-too-old)'); + expect(r.bunCalls).toBe(''); + expect(r.written).toEqual([]); + } + }); + + test('between the floor and the tested version: one warning, then setup continues', () => { + const r = runSetupWithBun('1.3.14'); + expect(r.stderr).toContain('warning: gstack is tested on Bun 1.4.0 (CI pin); found 1.3.14. Upgrade with: bun upgrade'); + expect(r.stderr).not.toContain('gstack needs Bun'); + expect(r.bunCalls).toContain('run scripts/models.ts claude-overlay claude-opus-4-8'); + }); + + test('the tested version passes silently', () => { + const r = runSetupWithBun('1.4.0'); + expect(r.stderr).not.toMatch(/warning: gstack is tested on Bun|gstack needs Bun|could not read the Bun version/); + expect(r.bunCalls).toContain('run scripts/models.ts claude-overlay claude-opus-4-8'); + }); + + test('a malformed version warns and continues', () => { + const r = runSetupWithBun('bun-dev (local build)'); + expect(r.stderr).toContain("warning: could not read the Bun version (bun --version printed 'bun-dev (local build)'); continuing. gstack is tested on Bun 1.4.0"); + expect(r.stderr).not.toContain('gstack needs Bun'); + expect(r.bunCalls).toContain('run scripts/models.ts claude-overlay claude-opus-4-8'); + }); + + test('--help and --status keep working without Bun', () => { + const base = tempBase(); + const env = { PATH: pathWithoutBun(base), HOME: base, GSTACK_STATE_ROOT: path.join(base, 'state') }; + expect(spawnSync('bash', ['-c', 'command -v bun'], { env, timeout: 10_000 }).status).not.toBe(0); + const help = spawnSync('bash', [SETUP, '--help'], { encoding: 'utf8', env, timeout: 30_000 }); + expect(help.status).toBe(0); + expect(help.stdout).toContain('Usage:'); + const st = spawnSync('bash', [SETUP, '--status'], { encoding: 'utf8', env, timeout: 30_000 }); + expect(st.status).toBe(0); + expect(st.stderr).not.toContain('bun is required'); + }); +}); diff --git a/test/setup-claude-code-migration.test.ts b/test/setup-claude-code-migration.test.ts index fe33e9910..a4bb2344b 100644 --- a/test/setup-claude-code-migration.test.ts +++ b/test/setup-claude-code-migration.test.ts @@ -30,7 +30,7 @@ function fixture(copy: boolean) { put(path.join(root, 'VERSION'), '2.0.0.0\n'); put(path.join(root, 'ETHOS.md'), 'Fixture ethos\n'); put(path.join(root, 'package.json'), JSON.stringify({ type: 'module', scripts: { 'gen:skill-docs': 'bun run scripts/gen-skill-docs.ts' } })); - for (const bin of ['gstack-config', 'gstack-patch-names', 'gstack-relink', 'gstack-migrate-claude-code', 'gstack-claude-code', 'gstack-state-root.sh', 'gstack-install-registry.sh', 'gstack-render-claude.sh']) { + for (const bin of ['gstack-config', 'gstack-patch-names', 'gstack-relink', 'gstack-migrate-claude-code', 'gstack-claude-code', 'gstack-state-root.sh', 'gstack-bun-version.sh', 'gstack-install-registry.sh', 'gstack-render-claude.sh']) { const target = path.join(root, 'bin', bin); fs.mkdirSync(path.dirname(target), { recursive: true }); fs.copyFileSync(path.join(ROOT, 'bin', bin), target); diff --git a/test/team-mode.test.ts b/test/team-mode.test.ts index f2705165f..cee5b095a 100644 --- a/test/team-mode.test.ts +++ b/test/team-mode.test.ts @@ -366,7 +366,7 @@ describe('setup --team / --no-team / -q', () => { fs.writeFileSync(file, content, { mode: 0o755 }); }; try { - for (const rel of ['setup', 'VERSION', 'SKILL.md', 'qa/SKILL.md', 'bin/gstack-config', 'bin/gstack-patch-names', 'bin/gstack-state-root.sh', 'bin/gstack-install-registry.sh', 'bin/gstack-render-claude.sh', 'scripts/resolve-codex-generation-model.ts', 'scripts/models.ts', 'scripts/preflight-codex-overlap.ts', 'scripts/discover-skills.ts', 'scripts/external-skill-names.ts', 'scripts/host-config.ts']) { + for (const rel of ['setup', 'VERSION', 'SKILL.md', 'qa/SKILL.md', 'bin/gstack-config', 'bin/gstack-patch-names', 'bin/gstack-state-root.sh', 'bin/gstack-bun-version.sh', 'bin/gstack-install-registry.sh', 'bin/gstack-render-claude.sh', 'scripts/resolve-codex-generation-model.ts', 'scripts/models.ts', 'scripts/preflight-codex-overlap.ts', 'scripts/discover-skills.ts', 'scripts/external-skill-names.ts', 'scripts/host-config.ts']) { const dest = path.join(cwd, rel); fs.mkdirSync(path.dirname(dest), { recursive: true }); fs.copyFileSync(path.join(ROOT, rel), dest);