E1: one Bun version source; setup refuses below the 1.3.3 floor, updater checks before advancing

bin/gstack-bun-version.sh holds the security floor (1.3.3) and the tested
version (1.4.0, the CI pin and engines.bun). setup refuses Bun below the
floor before writing anything, warns between floor and tested, warns and
continues on an unparseable version, and prints a 1.4.0 install hint.
Prereleases sort below their release (semver).

gstack-session-update now fetches, reads the incoming release's floor and
checks the bun setup will run before fast-forwarding to that exact
revision; below it the update is held with reason bun-too-old and a
resume command, leaving the revision and installs untouched.

Fixture copies of setup (team-mode, claude-code migration) now include the
sourced file; the slow-git lock test sleeps on fetch, the updater's
network step.
This commit is contained in:
garrytan committed 2026-10-04 17:24:49 +00:00
1 parent e54923176f
commit 850ac83997
10 files changed
+408 -10

No files matched your search

+1 -1
View File
@@ -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.
+58
View File
@@ -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 <version> — 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 <version> <release> — 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 <version> [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
}
+42 -3
View File
@@ -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}"
+13 -1
View File
@@ -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)"
+83
View File
@@ -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] ?? '<no GSTACK_BUN_FLOOR>',
tested: src.match(/^GSTACK_BUN_TESTED="([^"]+)"$/m)?.[1] ?? '<no GSTACK_BUN_TESTED>',
};
}
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);
});
+1 -1
View File
@@ -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;
+69 -2
View File
@@ -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'));
});
}
});
+139
View File
@@ -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<string, string> = {
'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');
});
});
+1 -1
View File
@@ -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);
+1 -1
View File
@@ -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);