mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-01 06:24:41 +01:00
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:
1 parent
e54923176f
commit
850ac83997
10 files changed
+408
-10
No files matched your search
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}"
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user