mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-01 06:24:41 +01:00
feat(install): marked per-directory bun and bunx shims for fresh Capy shells (A4, A-e)
gstack-capy-install (install mode, Capy machines only, GSTACK_BUN_SHIM=0 opts out) writes ~/.local/bin/bun and bunx under ~/.gstack-runtime.lock. The shim bakes the store, verdict directory, checkout and install default, walks up to .git without spawning git, runs the Bun the (checkout, project) verdict recorded, else a store Bun matching the .bun-version/package.json subset (stderr names the --check to run when none matches), else the install default, else the next unmarked bun on PATH. A foreign file or dangling link is never touched. GSTACK_BUN_SHIM_EXPLAIN=1 prints project, record, choice and rule; the doctor's runtime row shows the shim and its target. Files: bin/gstack-capy-install, test/gstack-capy-install.test.ts, test/gstack-doctor.test.ts Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
9b96457dde
commit
cafb39dd12
3 files changed
+191
-6
No files matched your search
+89
-6
@@ -10,14 +10,21 @@
|
||||
# Steps: clone or reuse the checkout under a lock (never reset, never pulled
|
||||
# here; the Capy skill bootstrapper fast-forwards below-minimum checkouts and
|
||||
# hands its held lock over with GSTACK_CAPY_LOCK_HELD=1); preflight git, curl,
|
||||
# node, jq and Bun (a supported Bun already in $BUN_INSTALL/bin is reused; the
|
||||
# exact release archive, SHA-256 verified, is installed there only when
|
||||
# gstack's supported minimum and the project's pins agree on it); ./setup
|
||||
# node, jq and Bun through gstack_bun_resolve (an installed Bun that satisfies
|
||||
# gstack's minimum and the project's pins is reused; otherwise the exact
|
||||
# release archive, SHA-256 verified, is published in the runtime store
|
||||
# <state root>/runtimes/bun/<version>/bun, GSTACK_BUN_INSTALL_VERSION
|
||||
# overriding the default); ./setup
|
||||
# --host claude --no-prefix; ./setup --host codex --model gpt-6-astra (both
|
||||
# skipped when this HEAD was already set up and the check passes);
|
||||
# gstack-doctor --check with the launch PATH; the install verdict
|
||||
# (bin/gstack-install-verdict.sh). --check certifies only: no clone, no Bun
|
||||
# install, no setup. Chromium is lazy by default (bin/gstack-browser-ensure
|
||||
# (bin/gstack-install-verdict.sh), keyed by checkout and project and holding
|
||||
# the resolved Bun. --check certifies only: no clone, no Bun install, no
|
||||
# setup. On a Capy machine (CAPY_MACHINE_CONFIG or ~/.capy/system) install also writes the marked
|
||||
# per-directory shims ~/.local/bin/bun and ~/.local/bin/bunx, which run the Bun
|
||||
# recorded for the project around the working directory in any fresh shell;
|
||||
# GSTACK_BUN_SHIM=0 opts out, a foreign ~/.local/bin/bun is never touched, and
|
||||
# deleting the files removes them. Chromium is lazy by default (bin/gstack-browser-ensure
|
||||
# installs it on first use); --with-browser installs it now. The last lines
|
||||
# are the GSTACK_LAUNCH line the parent supplies to every gstack command it
|
||||
# launches and the doctor's trailer (`gstack: ok ...` or `gstack: fail ...`;
|
||||
@@ -31,7 +38,7 @@ ROOT="${GSTACK_CAPY_ROOT:-$SELF}"
|
||||
REPO="${GSTACK_CAPY_REPO:-https://github.com/garrytan/gstack.git}"
|
||||
DOCS="https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md"
|
||||
FOR="" REQUIRE="" WITH_BROWSER=0 REVISION="" PROJECT="" CHECK_ONLY=0 MIN_REVISION="${GSTACK_CAPY_REMOTE_MIN:-}"
|
||||
usage() { sed -n '2,26p' "$0" | sed 's/^# \{0,1\}//'; }
|
||||
usage() { sed -n '2,32p' "$0" | sed 's/^# \{0,1\}//'; }
|
||||
need() { [ -n "${2:-}" ] || { echo "gstack-capy-install: $1 needs a value" >&2; usage >&2; exit 2; }; }
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
@@ -309,6 +316,81 @@ if [ "$CHECK_ONLY" -eq 0 ] && [ "$SKIPPED" -eq 0 ]; then
|
||||
fi
|
||||
[ "$CHECK_ONLY" -eq 1 ] && certify "$@"
|
||||
|
||||
# write_shim — the per-directory bun and bunx shims (Capy only, install mode).
|
||||
# The store, verdict directory, checkout and install default are baked in, so
|
||||
# a fresh shell needs no environment; rewritten when they change. Only a
|
||||
# missing file or one carrying the marker is replaced.
|
||||
write_shim() {
|
||||
local dir="$HOME/.local/bin" name tmp
|
||||
[ "${GSTACK_BUN_SHIM:-1}" != 0 ] || return 0
|
||||
[ -n "${CAPY_MACHINE_CONFIG:-}" ] || [ -d "$HOME/.capy/system" ] || return 0
|
||||
mkdir -p "$dir" || { say "could not create $dir; no bun shim written"; return 0; }
|
||||
exec 8>"$HOME/.gstack-runtime.lock"
|
||||
command -v flock >/dev/null 2>&1 && flock -w 60 8
|
||||
for name in bun bunx; do
|
||||
if { [ -e "$dir/$name" ] || [ -L "$dir/$name" ]; } && ! { [ -f "$dir/$name" ] && [ ! -L "$dir/$name" ] && _gstack_bun_is_shim "$dir/$name"; }; then
|
||||
say "$dir/$name is not gstack's shim (no marker); left untouched, so keep the GSTACK_LAUNCH prefix"
|
||||
continue
|
||||
fi
|
||||
tmp="$dir/.$name.gstack.$$"
|
||||
if [ "$name" = bunx ]; then
|
||||
printf '#!/bin/sh\n%s\n# The Capy bunx shim: bun x through the bun shim beside it (docs/capy-install.md).\nexec %s x "$@"\n' "$GSTACK_BUN_SHIM_MARKER" "'$dir/bun'" > "$tmp"
|
||||
else
|
||||
cat > "$tmp" <<EOF_SHIM
|
||||
#!/bin/sh
|
||||
$GSTACK_BUN_SHIM_MARKER
|
||||
# The Capy bun shim, written by $ROOT/bin/gstack-capy-install: runs the Bun
|
||||
# recorded for the project around the working directory (docs/capy-install.md).
|
||||
# GSTACK_BUN_SHIM_EXPLAIN=1 prints the project root, record, choice and rule.
|
||||
store='$STORE'
|
||||
verdicts='$(dirname "$(gstack_install_verdict_path "$GSTACK_STATE_ROOT" "$ROOT_REAL" "")")'
|
||||
checkout='$ROOT_REAL'
|
||||
default='$INSTALL_VERSION'
|
||||
marker='$GSTACK_BUN_SHIM_MARKER'
|
||||
sha() { if command -v sha256sum >/dev/null 2>&1; then printf '%s' "\$1" | sha256sum | cut -d' ' -f1; else printf '%s' "\$1" | shasum -a 256 | cut -d' ' -f1; fi; }
|
||||
root="" record="" chose="" rule=""
|
||||
dir=\$(pwd -P 2>/dev/null) || dir=""
|
||||
while [ -n "\$dir" ]; do
|
||||
[ -e "\$dir/.git" ] && { root=\$dir; break; }
|
||||
[ "\$dir" = / ] && break
|
||||
dir=\${dir%/*}; [ -n "\$dir" ] || dir=/
|
||||
done
|
||||
if [ -n "\$root" ]; then
|
||||
key=\$root; [ "\$root" = "\$checkout" ] && key=none
|
||||
record="\$verdicts/\$(sha "\$key").json"
|
||||
if [ -f "\$record" ]; then
|
||||
b=\$(sed -n 's/^ "bun_path": "\(.*\)",\$/\1/p' "\$record" | head -1)
|
||||
[ -n "\$b" ] && [ -x "\$b" ] && chose=\$b rule=record
|
||||
else
|
||||
record="" pin=""
|
||||
[ -f "\$root/.bun-version" ] && pin=\$(head -1 "\$root/.bun-version" | tr -d ' v\r')
|
||||
[ -n "\$pin" ] || pin=\$(sed -n 's/.*"packageManager"[[:space:]]*:[[:space:]]*"bun@\([0-9][0-9.]*\)".*/\1/p' "\$root/package.json" 2>/dev/null | head -1)
|
||||
[ -n "\$pin" ] || pin=\$(sed -n 's/.*"bun"[[:space:]]*:[[:space:]]*"v\{0,1\}\([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\)".*/\1/p' "\$root/package.json" 2>/dev/null | head -1)
|
||||
if [ -n "\$pin" ] && [ -x "\$store/\$pin/bun" ]; then chose="\$store/\$pin/bun" rule=pin
|
||||
else echo "gstack bun shim: \$root has no recorded Bun; run ${GSTACK_CAPY_WRAPPER_PATH:+bash }${GSTACK_CAPY_WRAPPER_PATH:-$ROOT/bin/gstack-capy-install} --check here" >&2
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
[ -n "\$chose" ] || { [ -x "\$store/\$default/bun" ] && chose="\$store/\$default/bun" rule=default; }
|
||||
if [ -z "\$chose" ]; then
|
||||
set -f; ifs=\$IFS; IFS=:
|
||||
for d in \$PATH; do
|
||||
[ -n "\$d" ] && [ -f "\$d/bun" ] && [ -x "\$d/bun" ] || continue
|
||||
head -c 512 "\$d/bun" 2>/dev/null | grep -aqF "\$marker" && continue
|
||||
chose="\$d/bun" rule=PATH; break
|
||||
done
|
||||
IFS=\$ifs; set +f
|
||||
fi
|
||||
[ "\${GSTACK_BUN_SHIM_EXPLAIN:-}" = 1 ] && echo "gstack bun shim: project=\${root:-none} record=\${record:-none} chose=\${chose:-none} rule=\${rule:-none}" >&2
|
||||
[ -n "\$chose" ] || { echo "gstack bun shim: no bun found (store \$store, then PATH)" >&2; exit 127; }
|
||||
exec "\$chose" "\$@"
|
||||
EOF_SHIM
|
||||
fi
|
||||
chmod 0755 "$tmp" && if cmp -s "$tmp" "$dir/$name"; then rm -f "$tmp"; else mv -f "$tmp" "$dir/$name"; fi
|
||||
done
|
||||
exec 8>&-
|
||||
}
|
||||
|
||||
# 4. Certify. The trailer is the contract; `(requested <ref>; ...)` when the
|
||||
# existing checkout is not at --revision (it is never moved here).
|
||||
INSTALLED=$(head -1 "$ROOT/VERSION")
|
||||
@@ -319,6 +401,7 @@ if [ -n "$REVISION" ] && [ "$REVISION" != "$INSTALLED" ] && [ "$REVISION" != "v$
|
||||
fi
|
||||
TRAILER="$(printf '%s\n' "$OUT" | grep '^gstack: ' | tail -1)$REV_NOTE"
|
||||
record_verdict "$TRAILER" "$([ "$RC" -eq 0 ] && echo 1 || echo 0)"
|
||||
[ "$CHECK_ONLY" -eq 1 ] || write_shim
|
||||
printf '%s\n' "$OUT" | grep -v '^gstack: '
|
||||
say "total: $(( $(now) - T0 ))s"
|
||||
echo "GSTACK_LAUNCH: ${LAUNCH_DIR:+PATH=$LAUNCH_DIR:\$PATH }GSTACK_SESSION_KIND=unattended GSTACK_STATE_ROOT=$GSTACK_STATE_ROOT GSTACK_EPHEMERAL=${GSTACK_EPHEMERAL:-1} GSTACK_CAPY=1 GSTACK_INSTALL_VERDICT=$VERDICT"
|
||||
|
||||
@@ -499,6 +499,98 @@ describe.skipIf(process.platform !== 'linux')('gstack-capy-install: one resoluti
|
||||
});
|
||||
});
|
||||
|
||||
// Plan A4/A-e: on Capy the installer writes marked ~/.local/bin/bun and bunx
|
||||
// shims that run the Bun recorded for the project around the working
|
||||
// directory, in a fresh shell with Capy's PATH and no gstack environment.
|
||||
describe.skipIf(process.platform !== 'linux')('gstack-capy-install: the per-directory bun shim', () => {
|
||||
const realBun = (file: string, version: string) => script(file, [`[ "$1" = --version ] && { echo ${version}; exit 0; }`, `exec "${process.execPath}" "$@"`]);
|
||||
const repo = (f: ReturnType<typeof fixture>, name: string, pin?: string) => {
|
||||
const dir = path.join(f.base, name);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
if (pin) write(path.join(dir, '.bun-version'), `${pin}\n`);
|
||||
sh('git init -q', dir);
|
||||
return dir;
|
||||
};
|
||||
/** A fresh non-login shell: Capy's PATH (~/.local/bin first), HOME, nothing from gstack. */
|
||||
const fresh = (f: ReturnType<typeof fixture>, cwd: string, cmd: string, extra: Record<string, string> = {}) =>
|
||||
spawnSync('bash', ['-c', cmd], { cwd, encoding: 'utf8', timeout: 30_000, env: { HOME: f.home, PATH: `${f.home}/.local/bin:${f.env.PATH}`, ...extra } });
|
||||
|
||||
test('fresh shells alternate between two exact pins, an unpinned project and a directory outside any project; custom state root, no env', () => {
|
||||
const f = fixture({ bun: '1.3.14' });
|
||||
fs.mkdirSync(path.join(f.home, '.capy/system'), { recursive: true });
|
||||
const state = path.join(f.base, 'custom-state');
|
||||
const storeDir = path.join(state, 'runtimes/bun');
|
||||
realBun(path.join(storeDir, '1.4.2/bun'), '1.4.2');
|
||||
realBun(path.join(storeDir, '1.4.3/bun'), '1.4.3');
|
||||
const a = repo(f, 'a', '1.4.2'), b = repo(f, 'b', '1.4.3'), c = repo(f, 'c');
|
||||
for (const dir of [a, b, c]) {
|
||||
const r = run(f, ['--for', 'review'], { GSTACK_STATE_ROOT: state }, dir);
|
||||
expect(r.status, r.stdout + r.stderr).toBe(0);
|
||||
}
|
||||
const shim = fs.readFileSync(path.join(f.home, '.local/bin/bun'), 'utf8');
|
||||
expect(shim.split('\n')[1]).toBe('# gstack-bun-shim: gstack-owned; delete this file to remove it');
|
||||
expect(shim).toContain(`store='${storeDir}'`);
|
||||
const outside = path.join(f.base, 'outside');
|
||||
fs.mkdirSync(outside);
|
||||
for (const [dir, want] of [[a, '1.4.2'], [b, '1.4.3'], [a, '1.4.2'], [c, '1.4.3'], [outside, '1.4.3'], [b, '1.4.3']] as const) {
|
||||
const r = fresh(f, dir, 'bun --version');
|
||||
expect(r.stdout.trim(), `${dir}\n${r.stderr}`).toBe(want);
|
||||
expect(r.stderr).toBe('');
|
||||
}
|
||||
const explained = fresh(f, path.join(a), 'mkdir -p sub && cd sub && bun --version', { GSTACK_BUN_SHIM_EXPLAIN: '1' });
|
||||
expect(explained.stderr).toMatch(new RegExp(`^gstack bun shim: project=${a} record=${state}/install-verdicts/[0-9a-f]{64}/[0-9a-f]{64}\\.json chose=${storeDir}/1\\.4\\.2/bun rule=record\\n$`));
|
||||
expect(fresh(f, outside, 'bun --version', { GSTACK_BUN_SHIM_EXPLAIN: '1' }).stderr).toBe(`gstack bun shim: project=none record=none chose=${storeDir}/1.4.3/bun rule=default\n`);
|
||||
const bunx = fresh(f, a, 'bunx --version', { GSTACK_BUN_SHIM_EXPLAIN: '1' });
|
||||
expect(bunx.stderr).toContain(`chose=${storeDir}/1.4.2/bun rule=record`);
|
||||
const unrecordedPinned = repo(f, 'd', '1.4.2');
|
||||
const d = fresh(f, unrecordedPinned, 'bun --version');
|
||||
expect([d.stdout.trim(), d.stderr]).toEqual(['1.4.2', '']);
|
||||
const unrecorded = repo(f, 'e');
|
||||
const e = fresh(f, unrecorded, 'bun --version');
|
||||
expect(e.stdout.trim()).toBe('1.4.3');
|
||||
expect(e.stderr).toBe(`gstack bun shim: ${unrecorded} has no recorded Bun; run ${f.root}/bin/gstack-capy-install --check here\n`);
|
||||
});
|
||||
|
||||
test('a foreign ~/.local/bin/bun or a dangling link is never touched; GSTACK_BUN_SHIM=0 writes nothing; a rerun rewrites only gstack\'s own file', () => {
|
||||
const f = fixture();
|
||||
fs.mkdirSync(path.join(f.home, '.capy/system'), { recursive: true });
|
||||
write(path.join(f.home, '.local/bin/bun'), '#!/bin/sh\necho mine\n', 0o755);
|
||||
fs.symlinkSync(path.join(f.base, 'missing'), path.join(f.home, '.local/bin/bunx'));
|
||||
const r = run(f, ['--for', 'autoplan']);
|
||||
expect(r.status, r.stdout + r.stderr).toBe(0);
|
||||
expect(fs.readFileSync(path.join(f.home, '.local/bin/bun'), 'utf8')).toBe('#!/bin/sh\necho mine\n');
|
||||
expect(fs.readlinkSync(path.join(f.home, '.local/bin/bunx'))).toBe(path.join(f.base, 'missing'));
|
||||
expect(r.stdout).toContain(`${f.home}/.local/bin/bun is not gstack's shim (no marker); left untouched, so keep the GSTACK_LAUNCH prefix`);
|
||||
const g = fixture();
|
||||
fs.mkdirSync(path.join(g.home, '.capy/system'), { recursive: true });
|
||||
expect(run(g, [], { GSTACK_BUN_SHIM: '0' }).status).toBe(0);
|
||||
expect(fs.existsSync(path.join(g.home, '.local/bin/bun'))).toBe(false);
|
||||
expect(run(g).status).toBe(0);
|
||||
const first = fs.readFileSync(path.join(g.home, '.local/bin/bun'), 'utf8');
|
||||
expect(run(g, [], { GSTACK_STATE_ROOT: path.join(g.base, 'moved') }).status).toBe(0);
|
||||
const second = fs.readFileSync(path.join(g.home, '.local/bin/bun'), 'utf8');
|
||||
expect(second).not.toBe(first);
|
||||
expect(second).toContain(`store='${path.join(g.base, 'moved')}/runtimes/bun'`);
|
||||
const plain = fixture();
|
||||
expect(run(plain).status).toBe(0);
|
||||
expect(fs.existsSync(path.join(plain.home, '.local/bin/bun'))).toBe(false);
|
||||
});
|
||||
|
||||
test('the shim adds little over the Bun it runs (fixture timing bound)', () => {
|
||||
const f = fixture();
|
||||
fs.mkdirSync(path.join(f.home, '.capy/system'), { recursive: true });
|
||||
const storeBun = path.join(f.home, '.gstack-state/runtimes/bun/1.4.3/bun');
|
||||
realBun(storeBun, '1.4.3');
|
||||
expect(run(f).status).toBe(0);
|
||||
const time = (cmd: string) => {
|
||||
const samples = Array.from({ length: 7 }, () => { const t = performance.now(); fresh(f, f.base, cmd); return performance.now() - t; }).sort((x, y) => x - y);
|
||||
return samples[3];
|
||||
};
|
||||
const overhead = time('bun --version') - time(`"${storeBun}" --version`);
|
||||
expect(overhead).toBeLessThan(250);
|
||||
});
|
||||
});
|
||||
|
||||
function run(f: ReturnType<typeof fixture>, args: string[] = [], extra: Record<string, string> = {}, cwd?: string) {
|
||||
return f.run(args, extra, cwd);
|
||||
}
|
||||
@@ -853,4 +853,14 @@ describe('gstack-doctor --check: wrapper and runtime drift', () => {
|
||||
expect(r.trailer).toBe('gstack: fail runtime');
|
||||
expect(r.line('runtime')).toBe('FAIL runtime — bun resolution drifted: recorded bun 1.4.3 at /s/bun (home), now no eligible bun; changed: the recorded binary is gone; fix: bash /d/install.sh --for review (BUN_RESOLUTION_DRIFT; https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#bun-resolution-drift)');
|
||||
});
|
||||
|
||||
test('the runtime row shows gstack\'s bun shim and where it resolves from here, or names a foreign ~/.local/bin/bun it left alone', () => {
|
||||
const f = makeFixture();
|
||||
const shim = path.join(f.home, '.local/bin/bun');
|
||||
write(shim, '#!/bin/sh\n# gstack-bun-shim: gstack-owned; delete this file to remove it\n[ "$GSTACK_BUN_SHIM_EXPLAIN" = 1 ] && echo "gstack bun shim: project=none record=none chose=/store/1.4.3/bun rule=default" >&2\necho 1.4.3\n', 0o755);
|
||||
expect(parse(f, []).line('runtime')).toContain(`; shim ${shim} -> /store/1.4.3/bun`);
|
||||
write(shim, '#!/bin/sh\necho mine\n', 0o755);
|
||||
expect(parse(f, []).line('runtime')).toContain(`; ${shim} is not gstack's shim (no marker), left untouched: launch with the GSTACK_LAUNCH line`);
|
||||
expect(parse(f, [], { GSTACK_BUN_INSTALL_VERSION: '1.4.2' }).line('runtime')).toContain('; install version 1.4.2 from GSTACK_BUN_INSTALL_VERSION');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user