fix(capy-install): the launch file appends the checkout's bin/ to PATH (GSTA-167)

A /ship child that sourced only the launch file got 'gstack-ship-budget: command
not found' (exit 127). The file now appends <checkout>/bin once, after everything
already on PATH, so it shadows nothing.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-29 23:28:12 +00:00
1 parent 9fa5e97c96
commit 4b623a490d
3 files changed
+18 -9

No files matched your search

+5 -1
View File
@@ -656,13 +656,16 @@ WARN behind — installed $INSTALLED is ${GSTACK_CAPY_BEHIND:-?} commits behind
[ -n "$NOTES" ] && TRAILER="$TRAILER ($NOTES)" ;;
esac
# The launch file: the same assignments as a sourceable script, mode 0600,
# beside the verdict. `. <file>` applies them without ever printing them; the
# beside the verdict. It also appends the checkout's bin directory to PATH
# (after everything already there, so it shadows nothing): a skill's bare
# `gstack-ship-budget` resolves in a shell that sourced only this file. `. <file>` applies them without ever printing them; the
# line carries no secret, but a caller that re-typed it as `export $(...)`
# ran a bare `export` when the substitution came back empty and dumped the
# whole environment, API keys included, into its tool output (GSTA-96). The
# file is part of the ok verdict: a passing run that cannot write it fails,
# so no caller is left with only the line and the re-typing path.
LAUNCH_FILE="${VERDICT%.json}.env"
BIN_DIR=$(cd "$ROOT/bin" 2>/dev/null && pwd -P)
write_launch_file() {
local tmp
mkdir -p "$(dirname "$LAUNCH_FILE")" || return 1
@@ -672,6 +675,7 @@ write_launch_file() {
echo "# Apply it with: . <this file>"
echo "# Never print it or the environment: no \`export \$(...)\`, \`env\`, \`printenv\` or bare \`set\` in a tool fence."
[ -n "$LAUNCH_DIR" ] && echo "case \":\$PATH:\" in *:$(shq "$LAUNCH_DIR"):*) ;; *) PATH=$(shq "$LAUNCH_DIR"):\"\$PATH\" ;; esac; export PATH"
[ -n "$BIN_DIR" ] && echo "case \":\$PATH:\" in *:$(shq "$BIN_DIR"):*) ;; *) PATH=\"\${PATH:+\$PATH:}\"$(shq "$BIN_DIR") ;; esac; export PATH"
echo "export GSTACK_SESSION_KIND=unattended"
echo "export GSTACK_STATE_ROOT=$(shq "$GSTACK_STATE_ROOT")"
echo "export GSTACK_EPHEMERAL=$(shq "${GSTACK_EPHEMERAL:-1}")"
+3
View File
@@ -423,6 +423,9 @@ GSTACK_LAUNCH_FILE: <verdict file without .json>.env
`GSTACK_LAUNCH_FILE` names the same assignments as a sourceable script (mode
0600, beside the verdict under the state root; the PATH prepend is idempotent).
The file also appends the checkout's `bin/` directory to PATH, after everything
already there and only once, so a shell that sourced only this file runs a bare
`gstack-ship-budget` (GSTA-167: a /ship child got `command not found`, exit 127).
The file is part of the ok verdict: a passing run that cannot write it exits 1
with `gstack: fail launch-file — could not write the launch file <path>; fix:
make <dir> writable, or set GSTACK_STATE_ROOT to a writable directory
+10 -8
View File
@@ -524,15 +524,17 @@ describe('gstack-capy-install: launch PATH, Bun reuse, CI pins, setup skip, verd
expect(r.stdout).toContain(`GSTACK_LAUNCH_FILE: ${files[0].replace(/\.json$/, '.env')}`);
expect(fs.readFileSync(files[0].replace(/\.json$/, '.env'), 'utf8')).toContain(`export GSTACK_INSTALL_VERDICT='${files[0]}'`);
// /ship Step 7 value card. protects: with the certified Bun first on PATH (no GSTACK_LAUNCH_PATH)
// the launch file carries no PATH line, sources under POSIX sh, and leaves PATH exactly as it was;
// --check rewrites and prints it too. fails_when: the PATH guard is dropped (an empty prepend
// leaves ":"-led PATH), the file uses a bashism, or --check stops printing GSTACK_LAUNCH_FILE.
// why_existing_misses: the PATH-bearing case above is sourced by bash only, and the --check run
// below asserted only the GSTACK_LAUNCH line. seam=none.
const sourced = spawnSync('sh', ['-c', '. "$1" && printf "%s\\n" "$PATH" "$GSTACK_SESSION_KIND" "$GSTACK_CAPY" "$GSTACK_INSTALL_VERDICT"', '_', files[0].replace(/\.json$/, '.env')],
// the launch file prepends nothing, sources under POSIX sh, and only appends the checkout's bin
// directory once (GSTA-167: a /ship child that sourced only the launch file got
// `gstack-ship-budget: command not found`, exit 127); --check rewrites and prints it too.
// fails_when: the PATH guard is dropped (an empty prepend leaves ":"-led PATH, a second source
// appends the bin directory twice), the file uses a bashism, the bin directory is not on PATH,
// or --check stops printing GSTACK_LAUNCH_FILE. why_existing_misses: the PATH-bearing case above
// is sourced by bash only, and the --check run below asserted only the GSTACK_LAUNCH line. seam=none.
const sourced = spawnSync('sh', ['-c', '. "$1" && . "$1" && printf "%s\\n" "$PATH" "$GSTACK_SESSION_KIND" "$GSTACK_CAPY" "$GSTACK_INSTALL_VERDICT" "$(command -v gstack-paths)"', '_', files[0].replace(/\.json$/, '.env')],
{ encoding: 'utf8', timeout: 30_000, env: { PATH: '/usr/bin:/bin' } });
expect(sourced.status, sourced.stderr).toBe(0);
expect(sourced.stdout.trimEnd().split('\n')).toEqual(['/usr/bin:/bin', 'unattended', '1', files[0]]);
expect(sourced.stdout.trimEnd().split('\n')).toEqual([`/usr/bin:/bin:${f.root}/bin`, 'unattended', '1', files[0], path.join(f.root, 'bin', 'gstack-paths')]);
const v = JSON.parse(fs.readFileSync(files[0], 'utf8'));
expect(v).toMatchObject({ schema_version: 2, checkout: f.root, head: sh('git rev-parse HEAD', f.root).stdout.trim(), for: 'autoplan', ok: true, trailer: 'gstack: ok 1.2.3 for=autoplan',
project: '', bun_path: path.join(f.stub, 'bun'), bun_version: '1.4.2', bun_rule: 'PATH' });
@@ -975,7 +977,7 @@ describe.skipIf(process.platform !== 'linux')('gstack-capy-install: the per-dire
const sourced = spawnSync('sh', ['-c', '. "$1" && . "$1" && printf "%s\\n" "$PATH" "$GSTACK_STATE_ROOT" "$GSTACK_EPHEMERAL" "$GSTACK_INSTALL_VERDICT"', '_', launchFile],
{ encoding: 'utf8', timeout: 30_000, env: { PATH: '/usr/bin:/bin' } });
expect(sourced.status, sourced.stderr).toBe(0);
expect(sourced.stdout.trimEnd().split('\n')).toEqual([`${state}/runtimes/bun/${BUN_DEFAULT}:/usr/bin:/bin`, state, '0', launchFile.replace(/\.env$/, '.json')]);
expect(sourced.stdout.trimEnd().split('\n')).toEqual([`${state}/runtimes/bun/${BUN_DEFAULT}:/usr/bin:/bin:${f.root}/bin`, state, '0', launchFile.replace(/\.env$/, '.json')]);
const r = fresh(f, f.base, 'bun --version && bunx --version', { GSTACK_BUN_SHIM_EXPLAIN: '1' });
expect(r.status, r.stderr).toBe(0);
expect(r.stdout.trim().split('\n')[0]).toBe(BUN_DEFAULT);