W1: one state-root owner (lib/state-root.ts + bin/gstack-state-root.sh), gstack-paths --explain and fail-stop, parity tests

This commit is contained in:
garrytan committed 2026-09-30 06:38:50 +00:00
1 parent 96764e80a6
commit ab3b6f02e8
5 files changed
+561 -13

No files matched your search

+71 -13
View File
@@ -2,6 +2,7 @@
# gstack-paths — output portable state-root paths for skill bash blocks
# Usage: eval "$(gstack-paths)" → sets GSTACK_STATE_ROOT, PLAN_ROOT, TMP_ROOT
# Or: gstack-paths → prints GSTACK_STATE_ROOT=... etc.
# gstack-paths --explain → which variable selected the state root, and why
#
# Resolves three roots with explicit fallback chains so skills work the same
# whether installed as a Claude Code plugin (CLAUDE_PLUGIN_DATA / CLAUDE_PLANS_DIR
@@ -9,9 +10,18 @@
# CI / container env where HOME may be unset.
#
# Chains:
# GSTACK_STATE_ROOT: GSTACK_HOME -> CLAUDE_PLUGIN_DATA (only when CLAUDE_PLUGIN_ROOT=*gstack*) -> $HOME/.gstack -> .gstack
# GSTACK_STATE_ROOT: GSTACK_STATE_ROOT -> GSTACK_HOME -> GSTACK_STATE_DIR
# -> CLAUDE_PLUGIN_DATA (only when CLAUDE_PLUGIN_ROOT=*gstack*)
# -> $HOME/.gstack -> .gstack
# (one rule, owned by bin/gstack-state-root.sh; set GSTACK_HOME
# to relocate state; GSTACK_STATE_ROOT is this script's output,
# also honored as input; GSTACK_STATE_DIR is a legacy alias)
# PLAN_ROOT: GSTACK_PLAN_DIR -> CLAUDE_PLANS_DIR -> $HOME/.claude/plans -> .claude/plans
# TMP_ROOT: TMPDIR -> TMP -> .gstack/tmp (and mkdir -p, best-effort)
# Docs: https://github.com/garrytan/gstack/blob/main/docs/state-root.md
#
# Callers guard the eval, because eval "$(f)" succeeds even when f fails:
# eval "$(gstack-paths)"; : "${GSTACK_STATE_ROOT:?gstack-paths failed; reinstall with ./setup or /gstack-upgrade}"
#
# Output: values are emitted shell-quoted (printf %q) so `eval` round-trips them
# byte-for-byte. This matters on Windows, where $TMP is a backslash path like
@@ -24,19 +34,67 @@
# reason any path variable needs quoting.
set -u
_GSTACK_DOC="https://github.com/garrytan/gstack/blob/main/docs/state-root.md"
_GSTACK_TWIN="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)/gstack-state-root.sh"
# Fail stop: never print a payload that would set an empty GSTACK_STATE_ROOT.
_gstack_paths_fail() {
echo "gstack-paths: cannot resolve the gstack state root: $1" >&2
echo "fix: reinstall gstack with ./setup (in the gstack checkout) or /gstack-upgrade. Docs: $_GSTACK_DOC" >&2
exit 1
}
if [ ! -f "$_GSTACK_TWIN" ] || ! . "$_GSTACK_TWIN" 2>/dev/null || ! command -v gstack_state_root_select >/dev/null 2>&1; then
_gstack_paths_fail "missing or broken $_GSTACK_TWIN"
fi
# State root: where gstack writes projects/, sessions/, analytics/.
if [ -n "${GSTACK_HOME:-}" ]; then
_state_root="$GSTACK_HOME"
elif [ -n "${CLAUDE_PLUGIN_DATA:-}" ] && echo "${CLAUDE_PLUGIN_ROOT:-}" | grep -qi "gstack"; then
# Guard: only trust CLAUDE_PLUGIN_DATA when CLAUDE_PLUGIN_ROOT confirms we are
# running as the gstack plugin. Without this, a CLAUDE_PLUGIN_DATA from another
# plugin (e.g. codex) that leaked into the session env via CLAUDE_ENV_FILE would
# be picked up, writing all gstack state into the wrong directory.
_state_root="$CLAUDE_PLUGIN_DATA"
elif [ -n "${HOME:-}" ]; then
_state_root="$HOME/.gstack"
else
_state_root=".gstack"
gstack_state_root_select
_state_root="$_gstack_sr_root"
[ -n "$_state_root" ] || _gstack_paths_fail "the resolver in $_GSTACK_TWIN returned an empty root"
if [ "${1:-}" = "--explain" ]; then
echo "state root: $_state_root (selected by $_gstack_sr_var)"
echo "chain (first non-empty wins):"
_gstack_sel_seen=0
for _v in GSTACK_STATE_ROOT GSTACK_HOME GSTACK_STATE_DIR CLAUDE_PLUGIN_DATA; do
eval "_val=\${$_v:-}"
if [ -z "$_val" ]; then
printf ' %-20s unset\n' "$_v"
elif [ "$_v" = "$_gstack_sr_var" ]; then
printf ' %-20s %s selected\n' "$_v" "$_val"
elif [ "$_v" = CLAUDE_PLUGIN_DATA ] && [ "$_gstack_sr_var" = default ]; then
printf ' %-20s %s ignored (CLAUDE_PLUGIN_ROOT does not contain "gstack")\n' "$_v" "$_val"
else
printf ' %-20s %s ignored\n' "$_v" "$_val"
fi
done
_gstack_user_home
if [ -n "$_gstack_home_val" ]; then
_gstack_default="$_gstack_home_val/.gstack"
else
_gstack_default=".gstack"
fi
if [ "$_gstack_sr_var" = default ]; then
printf ' %-20s %s selected\n' "default" "$_gstack_default"
else
printf ' %-20s %s ignored\n' "default" "$_gstack_default"
_gstack_has_state=no
for _e in "$_gstack_default"/* "$_gstack_default"/.[!.]*; do
[ -e "$_e" ] && { _gstack_has_state=yes; break; }
done
echo "default root $_gstack_default also holds gstack state: $_gstack_has_state"
fi
echo "merged privacy keys (most restrictive value across roots wins):"
for _k in telemetry memorable_recall codex_reviews update_check; do
gstack_config_select "$_k"
if [ -n "$_gstack_cfg_value" ]; then
printf ' %-17s %s (from %s/config.yaml)\n' "$_k:" "$_gstack_cfg_value" "$_gstack_cfg_root"
else
printf ' %-17s not set (default applies)\n' "$_k:"
fi
done
echo "docs: $_GSTACK_DOC"
exit 0
fi
# Plan root: where /context-save and /codex consult write plan files.
+125
View File
@@ -0,0 +1,125 @@
# shellcheck shell=bash
# gstack-state-root.sh — the one bash owner of where gstack keeps its state
# (twin of lib/state-root.ts; test/state-root-parity.test.ts keeps them equal).
# Sourced, never executed: bin/gstack-paths and the careful/freeze hooks source
# it; other executables run `eval "$(<their dir>/gstack-paths)"` plus the
# `: "${GSTACK_STATE_ROOT:?...}"` guard. Chain: GSTACK_STATE_ROOT → GSTACK_HOME →
# GSTACK_STATE_DIR → CLAUDE_PLUGIN_DATA (only when CLAUDE_PLUGIN_ROOT contains
# "gstack") → $HOME/.gstack → .gstack. Bash builtins only (bash 3.2, no
# subprocess): hooks run it on every tool call. Enforced by
# test/state-root-ratchet.test.ts. Generalizes careful/bin/hook-extract.sh's
# former gstack_hook_state_root. Docs: docs/state-root.md.
# _gstack_user_home — HOME, or USERPROFILE on Windows shells when HOME is unset.
_gstack_user_home() {
_gstack_home_val="${HOME:-}"
if [ -z "$_gstack_home_val" ]; then
case "${OSTYPE:-}" in
msys*|cygwin*|win32*) _gstack_home_val="${USERPROFILE:-}" ;;
esac
fi
}
# gstack_state_root_select — sets _gstack_sr_root and _gstack_sr_var (the
# variable that selected it, or "default") without printing.
gstack_state_root_select() {
_gstack_user_home
if [ -n "${GSTACK_STATE_ROOT:-}" ]; then
_gstack_sr_root="$GSTACK_STATE_ROOT"; _gstack_sr_var="GSTACK_STATE_ROOT"
elif [ -n "${GSTACK_HOME:-}" ]; then
_gstack_sr_root="$GSTACK_HOME"; _gstack_sr_var="GSTACK_HOME"
elif [ -n "${GSTACK_STATE_DIR:-}" ]; then
_gstack_sr_root="$GSTACK_STATE_DIR"; _gstack_sr_var="GSTACK_STATE_DIR"
else
_gstack_sr_root=""
if [ -n "${CLAUDE_PLUGIN_DATA:-}" ]; then
case "${CLAUDE_PLUGIN_ROOT:-}" in
*[gG][sS][tT][aA][cC][kK]*) _gstack_sr_root="$CLAUDE_PLUGIN_DATA"; _gstack_sr_var="CLAUDE_PLUGIN_DATA" ;;
esac
fi
if [ -z "$_gstack_sr_root" ]; then
if [ -n "$_gstack_home_val" ]; then
_gstack_sr_root="$_gstack_home_val/.gstack"; _gstack_sr_var="default"
else
_gstack_sr_root=".gstack"; _gstack_sr_var="default"
fi
fi
fi
}
# gstack_state_root — print the state root WITHOUT a trailing newline. Capture
# with a sentinel so a root ending in a newline round-trips exactly:
# r="$(gstack_state_root; printf x)"; r="${r%x}"
gstack_state_root() {
gstack_state_root_select
printf '%s' "$_gstack_sr_root"
}
# _gstack_config_from_root ROOT KEY — sets _gstack_cfg_one to the value of the
# last `KEY:` line in ROOT/config.yaml, trimmed ("" when absent). Same parse as
# `gstack-config get`.
_gstack_config_from_root() {
_gstack_cfg_one=""
[ -f "$1/config.yaml" ] || return 0
while IFS= read -r _gstack_cfg_line || [ -n "$_gstack_cfg_line" ]; do
case "$_gstack_cfg_line" in
"$2":*)
_gstack_cfg_one="${_gstack_cfg_line#"$2":}"
_gstack_cfg_one="${_gstack_cfg_one#"${_gstack_cfg_one%%[![:space:]]*}"}"
_gstack_cfg_one="${_gstack_cfg_one%"${_gstack_cfg_one##*[![:space:]]}"}"
;;
esac
done < "$1/config.yaml"
}
# _gstack_config_rank KEY VALUE — sets _gstack_cfg_rank: position in the key's
# most-restrictive-first order, -1 for an unrecognized value, "" when KEY is
# not a merged key.
_gstack_config_rank() {
case "$1" in
telemetry) case "$2" in off) _gstack_cfg_rank=0 ;; anonymous) _gstack_cfg_rank=1 ;; community) _gstack_cfg_rank=2 ;; *) _gstack_cfg_rank=-1 ;; esac ;;
memorable_recall) case "$2" in off) _gstack_cfg_rank=0 ;; on) _gstack_cfg_rank=1 ;; *) _gstack_cfg_rank=-1 ;; esac ;;
codex_reviews) case "$2" in disabled) _gstack_cfg_rank=0 ;; enabled) _gstack_cfg_rank=1 ;; *) _gstack_cfg_rank=-1 ;; esac ;;
update_check) case "$2" in false) _gstack_cfg_rank=0 ;; true) _gstack_cfg_rank=1 ;; *) _gstack_cfg_rank=-1 ;; esac ;;
*) _gstack_cfg_rank="" ;;
esac
}
# gstack_config_select KEY — sets _gstack_cfg_value and _gstack_cfg_root (the
# root that supplied it; both "" when unset). Merged privacy keys (telemetry,
# memorable_recall, codex_reviews, update_check) take the most restrictive
# value across the resolved root and $HOME/.gstack; every other key reads the
# resolved root only. GSTACK_TEST_LEGACY_ROOT replaces $HOME/.gstack in tests.
gstack_config_select() {
gstack_state_root_select
_gstack_config_from_root "$_gstack_sr_root" "$1"
_gstack_cfg_value="$_gstack_cfg_one"; _gstack_cfg_root=""
[ -n "$_gstack_cfg_value" ] && _gstack_cfg_root="$_gstack_sr_root"
_gstack_config_rank "$1" "x"
[ -n "$_gstack_cfg_rank" ] || return 0
if [ -n "${GSTACK_TEST_LEGACY_ROOT:-}" ]; then
_gstack_cfg_legacy="$GSTACK_TEST_LEGACY_ROOT"
elif [ -n "$_gstack_home_val" ]; then
_gstack_cfg_legacy="$_gstack_home_val/.gstack"
else
return 0
fi
[ "$_gstack_cfg_legacy" = "$_gstack_sr_root" ] && return 0
_gstack_config_from_root "$_gstack_cfg_legacy" "$1"
[ -n "$_gstack_cfg_one" ] || return 0
if [ -z "$_gstack_cfg_value" ]; then
_gstack_cfg_value="$_gstack_cfg_one"; _gstack_cfg_root="$_gstack_cfg_legacy"
return 0
fi
_gstack_config_rank "$1" "$_gstack_cfg_value"; _gstack_cfg_best="$_gstack_cfg_rank"
_gstack_config_rank "$1" "$_gstack_cfg_one"
if [ "$_gstack_cfg_rank" -lt "$_gstack_cfg_best" ]; then
_gstack_cfg_value="$_gstack_cfg_one"; _gstack_cfg_root="$_gstack_cfg_legacy"
fi
}
# gstack_read_config_key KEY — print the (merged) value, no trailing newline.
gstack_read_config_key() {
gstack_config_select "$1"
printf '%s' "$_gstack_cfg_value"
}
+99
View File
@@ -0,0 +1,99 @@
/**
* state-root — the one owner of where gstack keeps its state (TS twin of
* bin/gstack-state-root.sh; test/state-root-parity.test.ts keeps them equal).
* Add a caller: `join(resolveStateRoot(), 'analytics')` instead of joining
* homedir() with '.gstack'; read a config key with `readConfigKey('telemetry')`.
* Chain: GSTACK_STATE_ROOT → GSTACK_HOME → GSTACK_STATE_DIR → CLAUDE_PLUGIN_DATA
* (only when CLAUDE_PLUGIN_ROOT contains "gstack") → $HOME/.gstack → .gstack.
* Enforced by test/state-root-ratchet.test.ts. Replaces the hand-rolled chains
* formerly in browse/src/config.ts and lib/cso/state.ts. Docs: docs/state-root.md.
*/
import * as fs from 'node:fs';
export type StateRootEnv = Record<string, string | undefined>;
export const STATE_ROOT_VARS = ['GSTACK_STATE_ROOT', 'GSTACK_HOME', 'GSTACK_STATE_DIR'] as const;
function userHome(env: StateRootEnv, platform: NodeJS.Platform): string {
return env.HOME || (platform === 'win32' ? env.USERPROFILE || '' : '');
}
/** The raw chain value. Pure: never prints, never touches the filesystem. */
export function resolveStateRoot(env: StateRootEnv = process.env, platform: NodeJS.Platform = process.platform): string {
for (const name of STATE_ROOT_VARS) {
if (env[name]) return env[name] as string;
}
if (env.CLAUDE_PLUGIN_DATA && (env.CLAUDE_PLUGIN_ROOT || '').toLowerCase().includes('gstack')) return env.CLAUDE_PLUGIN_DATA;
const home = userHome(env, platform);
return home ? `${home}/.gstack` : '.gstack';
}
/**
* The default root a merged privacy key is also read from. Tests point
* GSTACK_TEST_LEGACY_ROOT at an empty dir (test-setup.ts) so a developer's
* real ~/.gstack never leaks into a run.
*/
function legacyStateRoot(env: StateRootEnv, platform: NodeJS.Platform): string | null {
if (env.GSTACK_TEST_LEGACY_ROOT) return env.GSTACK_TEST_LEGACY_ROOT;
const home = userHome(env, platform);
return home ? `${home}/.gstack` : null;
}
/** Privacy and egress opt-outs: most restrictive first. Read from every candidate root. */
export const MERGED_CONFIG_KEYS: Record<string, readonly string[]> = {
telemetry: ['off', 'anonymous', 'community'],
memorable_recall: ['off', 'on'],
codex_reviews: ['disabled', 'enabled'],
update_check: ['false', 'true'],
};
/** Same parse as `gstack-config get`: last `^key:` line wins, value trimmed, empty = unset. */
function readKeyFromRoot(root: string, key: string): string | null {
let yaml: string;
try {
yaml = fs.readFileSync(`${root}/config.yaml`, 'utf-8');
} catch {
return null;
}
let value: string | null = null;
for (const line of yaml.split('\n')) {
if (line.startsWith(`${key}:`)) value = line.slice(key.length + 1).trim();
}
return value ? value : null;
}
export interface ConfigKeyReading {
value: string | null;
/** Root whose config.yaml supplied the value (null when no root sets it). */
root: string | null;
}
/**
* Read one config key. Merged keys (MERGED_CONFIG_KEYS) take the most
* restrictive value across the resolved root and $HOME/.gstack; an
* unrecognized value ranks as most restrictive. Every other key reads the
* resolved root only.
*/
export function readConfigKeyWithRoot(key: string, env: StateRootEnv = process.env, platform: NodeJS.Platform = process.platform): ConfigKeyReading {
const resolved = resolveStateRoot(env, platform);
const order = MERGED_CONFIG_KEYS[key];
const candidates = [resolved];
const legacy = order ? legacyStateRoot(env, platform) : null;
if (legacy && legacy !== resolved) candidates.push(legacy);
let best: ConfigKeyReading = { value: null, root: null };
let bestRank = Infinity;
for (const root of candidates) {
const value = readKeyFromRoot(root, key);
if (value === null) continue;
const rank = order ? order.indexOf(value) : 0;
if (rank < bestRank) {
best = { value, root };
bestRank = rank;
}
}
return best;
}
export function readConfigKey(key: string, env: StateRootEnv = process.env, platform: NodeJS.Platform = process.platform): string | null {
return readConfigKeyWithRoot(key, env, platform).value;
}
+116
View File
@@ -271,3 +271,119 @@ describe('CEO plan persistence uses the selected state root', () => {
}
}
});
describe('gstack-paths state-root contract (W1)', () => {
const DOC = 'https://github.com/garrytan/gstack/blob/main/docs/state-root.md';
function runRaw(args: string[], env: Record<string, string>, bin = BIN) {
return spawnSync('bash', [bin, ...args], {
env: { PATH: process.env.PATH, USERPROFILE: '', TMPDIR: os.tmpdir(), ...env } as Record<string, string>,
encoding: 'utf-8',
timeout: 30_000,
});
}
test('GSTACK_STATE_ROOT and GSTACK_STATE_DIR join the chain in declared precedence', () => {
expect(run({ HOME: '/tmp/home', GSTACK_STATE_ROOT: '/tmp/a', GSTACK_HOME: '/tmp/b' }).GSTACK_STATE_ROOT).toBe('/tmp/a');
expect(run({ HOME: '/tmp/home', GSTACK_STATE_DIR: '/tmp/c' }).GSTACK_STATE_ROOT).toBe('/tmp/c');
expect(run({ HOME: '/tmp/home', GSTACK_HOME: '/tmp/b', GSTACK_STATE_DIR: '/tmp/c' }).GSTACK_STATE_ROOT).toBe('/tmp/b');
expect(run({ HOME: '/tmp/home', GSTACK_STATE_DIR: '/tmp/c', CLAUDE_PLUGIN_DATA: '/tmp/p', CLAUDE_PLUGIN_ROOT: '/x/gstack' }).GSTACK_STATE_ROOT).toBe('/tmp/c');
});
test('reading its own output back as input returns the same root', () => {
const env = { HOME: '/tmp/home', GSTACK_HOME: '/tmp/b', GSTACK_STATE_DIR: '/tmp/c' };
const first = run(env).GSTACK_STATE_ROOT;
expect(run({ ...env, GSTACK_STATE_ROOT: first }).GSTACK_STATE_ROOT).toBe(first);
});
test('stderr is empty on success, even with disagreeing root variables', () => {
const r = runRaw([], { HOME: '/tmp/home', GSTACK_STATE_ROOT: '/tmp/a', GSTACK_HOME: '/tmp/b', GSTACK_STATE_DIR: '/tmp/c' });
expect(r.status).toBe(0);
expect(r.stderr).toBe('');
});
test('--explain golden: default environment', () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-explain-'));
try {
const r = runRaw(['--explain'], { HOME: home, GSTACK_TEST_LEGACY_ROOT: '' });
expect(r.status).toBe(0);
expect(r.stderr).toBe('');
expect(r.stdout).toBe([
`state root: ${home}/.gstack (selected by default)`,
'chain (first non-empty wins):',
' GSTACK_STATE_ROOT unset',
' GSTACK_HOME unset',
' GSTACK_STATE_DIR unset',
' CLAUDE_PLUGIN_DATA unset',
` default ${home}/.gstack selected`,
'merged privacy keys (most restrictive value across roots wins):',
' telemetry: not set (default applies)',
' memorable_recall: not set (default applies)',
' codex_reviews: not set (default applies)',
' update_check: not set (default applies)',
`docs: ${DOC}`,
'',
].join('\n'));
expect(fs.readdirSync(home)).toEqual([]);
} finally {
fs.rmSync(home, { recursive: true, force: true });
}
});
test('--explain golden: disagreeing environment with state in $HOME/.gstack', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-explain-'));
const home = path.join(tmp, 'home');
const a = path.join(tmp, 'a');
try {
fs.mkdirSync(path.join(home, '.gstack'), { recursive: true });
fs.writeFileSync(path.join(home, '.gstack', 'config.yaml'), 'telemetry: off\ncodex_reviews: disabled\n');
fs.mkdirSync(a);
fs.writeFileSync(path.join(a, 'config.yaml'), 'telemetry: community\nupdate_check: true\n');
const r = runRaw(['--explain'], {
HOME: home, GSTACK_TEST_LEGACY_ROOT: '', GSTACK_STATE_ROOT: a, GSTACK_HOME: '/tmp/b',
CLAUDE_PLUGIN_DATA: '/tmp/p', CLAUDE_PLUGIN_ROOT: '/plugins/codex',
});
expect(r.status).toBe(0);
expect(r.stderr).toBe('');
expect(r.stdout).toBe([
`state root: ${a} (selected by GSTACK_STATE_ROOT)`,
'chain (first non-empty wins):',
` GSTACK_STATE_ROOT ${a} selected`,
' GSTACK_HOME /tmp/b ignored',
' GSTACK_STATE_DIR unset',
' CLAUDE_PLUGIN_DATA /tmp/p ignored',
` default ${home}/.gstack ignored`,
`default root ${home}/.gstack also holds gstack state: yes`,
'merged privacy keys (most restrictive value across roots wins):',
` telemetry: off (from ${home}/.gstack/config.yaml)`,
' memorable_recall: not set (default applies)',
` codex_reviews: disabled (from ${home}/.gstack/config.yaml)`,
` update_check: true (from ${a}/config.yaml)`,
`docs: ${DOC}`,
'',
].join('\n'));
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test('a missing bin/gstack-state-root.sh fails stop: nonzero, empty stdout, message, no writes', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-paths-broken-'));
try {
const bin = path.join(tmp, 'bin');
fs.mkdirSync(bin);
fs.copyFileSync(BIN, path.join(bin, 'gstack-paths'));
const tmpRoot = path.join(tmp, 'tmproot');
const r = runRaw([], { HOME: path.join(tmp, 'home'), TMPDIR: tmpRoot }, path.join(bin, 'gstack-paths'));
expect(r.status).not.toBe(0);
expect(r.stdout).toBe('');
expect(r.stderr).toContain('gstack-paths: cannot resolve the gstack state root');
expect(r.stderr).toContain(path.join(bin, 'gstack-state-root.sh'));
expect(r.stderr).toContain('./setup');
expect(r.stderr).toContain('/gstack-upgrade');
expect(r.stderr).toContain(DOC);
expect(fs.readdirSync(tmp).sort()).toEqual(['bin']);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
});
+150
View File
@@ -0,0 +1,150 @@
/**
* Parity: lib/state-root.ts (TS) and bin/gstack-state-root.sh (bash) are the
* two owners of the one state-root chain. Every row runs through both with an
* empty PATH (the bash twin must use builtins only — hooks call it on every
* tool use) and must produce byte-identical roots and config readings.
*/
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { spawnSync } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { resolveStateRoot, readConfigKey } from '../lib/state-root';
const ROOT = path.resolve(import.meta.dir, '..');
const TWIN = path.join(ROOT, 'bin', 'gstack-state-root.sh').replace(/\\/g, '/');
const BASH = Bun.which('bash') ?? '/bin/bash';
type Row = { name: string; env: Record<string, string>; win?: boolean };
function bashEval(row: Row, body: string): string {
const ostype = row.win ? 'msys' : 'linux-gnu';
const r = spawnSync(BASH, ['-c', `OSTYPE=${ostype}; . "${TWIN}" && ${body}`], {
env: { USERPROFILE: '', ...row.env, PATH: '' },
encoding: 'utf-8',
timeout: 10_000,
});
if (r.status !== 0) throw new Error(`bash twin failed for ${row.name}: ${r.stderr}`);
return r.stdout;
}
function tsEnv(row: Row): Record<string, string> {
return { USERPROFILE: '', ...row.env };
}
const A = '/state/a', B = '/state/b', C = '/state/c';
const rootRows: Row[] = [
{ name: 'nothing set, HOME set', env: { HOME: '/home/u' } },
{ name: 'GSTACK_STATE_ROOT alone', env: { HOME: '/home/u', GSTACK_STATE_ROOT: A } },
{ name: 'GSTACK_HOME alone', env: { HOME: '/home/u', GSTACK_HOME: B } },
{ name: 'GSTACK_STATE_DIR alone', env: { HOME: '/home/u', GSTACK_STATE_DIR: C } },
{ name: 'STATE_ROOT + HOME differ', env: { HOME: '/home/u', GSTACK_STATE_ROOT: A, GSTACK_HOME: B } },
{ name: 'STATE_ROOT + STATE_DIR differ', env: { HOME: '/home/u', GSTACK_STATE_ROOT: A, GSTACK_STATE_DIR: C } },
{ name: 'GSTACK_HOME + STATE_DIR differ', env: { HOME: '/home/u', GSTACK_HOME: B, GSTACK_STATE_DIR: C } },
{ name: 'all three differ', env: { HOME: '/home/u', GSTACK_STATE_ROOT: A, GSTACK_HOME: B, GSTACK_STATE_DIR: C } },
{ name: 'plugin data with gstack plugin root', env: { HOME: '/home/u', CLAUDE_PLUGIN_DATA: '/plug', CLAUDE_PLUGIN_ROOT: '/plugins/gstack' } },
{ name: 'plugin data with mixed-case gstack root', env: { HOME: '/home/u', CLAUDE_PLUGIN_DATA: '/plug', CLAUDE_PLUGIN_ROOT: '/plugins/GStack-1.2' } },
{ name: 'plugin data with foreign plugin root', env: { HOME: '/home/u', CLAUDE_PLUGIN_DATA: '/plug', CLAUDE_PLUGIN_ROOT: '/plugins/codex' } },
{ name: 'plugin data without plugin root', env: { HOME: '/home/u', CLAUDE_PLUGIN_DATA: '/plug' } },
{ name: 'GSTACK_STATE_DIR beats plugin data', env: { HOME: '/home/u', GSTACK_STATE_DIR: C, CLAUDE_PLUGIN_DATA: '/plug', CLAUDE_PLUGIN_ROOT: '/plugins/gstack' } },
{ name: 'empty strings count as unset', env: { HOME: '/home/u', GSTACK_STATE_ROOT: '', GSTACK_HOME: '', GSTACK_STATE_DIR: '', CLAUDE_PLUGIN_DATA: '', CLAUDE_PLUGIN_ROOT: '' } },
{ name: 'empty STATE_ROOT falls to GSTACK_HOME', env: { HOME: '/home/u', GSTACK_STATE_ROOT: '', GSTACK_HOME: B } },
{ name: 'empty plugin data with gstack root', env: { HOME: '/home/u', CLAUDE_PLUGIN_DATA: '', CLAUDE_PLUGIN_ROOT: '/plugins/gstack' } },
{ name: 'unset HOME', env: {} },
{ name: 'empty HOME', env: { HOME: '' } },
{ name: 'unset HOME ignores USERPROFILE off Windows', env: { USERPROFILE: 'C:/Users/u' } },
{ name: 'path with spaces and newline', env: { HOME: '/home/u', GSTACK_HOME: '/state/with space\n' } },
{ name: 'Windows: HOME unset uses USERPROFILE', env: { USERPROFILE: 'C:/Users/u' }, win: true },
{ name: 'Windows: HOME wins over USERPROFILE', env: { HOME: '/c/Users/h', USERPROFILE: 'C:/Users/u' }, win: true },
{ name: 'Windows: nothing set', env: {}, win: true },
{ name: 'Windows: GSTACK_HOME backslash path', env: { GSTACK_HOME: 'C:\\gstack\\state', USERPROFILE: 'C:/Users/u' }, win: true },
];
describe('state root parity (bash twin vs lib/state-root.ts)', () => {
for (const row of rootRows) {
test(row.name, () => {
const bash = bashEval(row, 'gstack_state_root; printf x').replace(/x$/, '');
expect(bash).toBe(resolveStateRoot(tsEnv(row), row.win ? 'win32' : 'linux'));
});
}
test('precedence is GSTACK_STATE_ROOT, GSTACK_HOME, GSTACK_STATE_DIR, gstack plugin data, $HOME/.gstack, .gstack', () => {
const base = { HOME: '/home/u', CLAUDE_PLUGIN_DATA: '/plug', CLAUDE_PLUGIN_ROOT: '/x/gstack' };
expect(resolveStateRoot({ ...base, GSTACK_STATE_ROOT: A, GSTACK_HOME: B, GSTACK_STATE_DIR: C }, 'linux')).toBe(A);
expect(resolveStateRoot({ ...base, GSTACK_HOME: B, GSTACK_STATE_DIR: C }, 'linux')).toBe(B);
expect(resolveStateRoot({ ...base, GSTACK_STATE_DIR: C }, 'linux')).toBe(C);
expect(resolveStateRoot(base, 'linux')).toBe('/plug');
expect(resolveStateRoot({ HOME: '/home/u' }, 'linux')).toBe('/home/u/.gstack');
expect(resolveStateRoot({}, 'linux')).toBe('.gstack');
});
});
describe('config key parity (gstack_read_config_key vs readConfigKey)', () => {
let tmp: string;
let resolved: string, home: string, legacyOverride: string;
beforeAll(() => {
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-cfg-parity-'));
resolved = path.join(tmp, 'resolved');
home = path.join(tmp, 'home');
legacyOverride = path.join(tmp, 'legacy');
fs.mkdirSync(resolved, { recursive: true });
fs.mkdirSync(path.join(home, '.gstack'), { recursive: true });
fs.mkdirSync(legacyOverride, { recursive: true });
fs.writeFileSync(path.join(resolved, 'config.yaml'), [
'# telemetry: off (comment lines never match)',
'telemetry: community',
'codex_reviews: enabled',
'update_check: true ',
'memorable_recall: on\r',
'proactive: true',
'proactive: false',
'founder_resources: true',
'empty_key:',
].join('\n'));
fs.writeFileSync(path.join(home, '.gstack', 'config.yaml'), [
'telemetry: anonymous',
'codex_reviews: disabled',
'memorable_recall: bogus',
'proactive: true',
'founder_resources: false',
'only_legacy: yes',
].join('\n'));
fs.writeFileSync(path.join(legacyOverride, 'config.yaml'), 'telemetry: off\nupdate_check: false');
});
afterAll(() => fs.rmSync(tmp, { recursive: true, force: true }));
const keys = ['telemetry', 'codex_reviews', 'update_check', 'memorable_recall', 'proactive', 'founder_resources', 'empty_key', 'only_legacy', 'missing'];
test('every key reads identically through both twins, with and without the test legacy override', () => {
const envs: Row[] = [
{ name: 'resolved + real home', env: { HOME: home, GSTACK_HOME: resolved } },
{ name: 'legacy override', env: { HOME: home, GSTACK_HOME: resolved, GSTACK_TEST_LEGACY_ROOT: legacyOverride } },
{ name: 'resolved is the default root', env: { HOME: home } },
{ name: 'no HOME', env: { GSTACK_HOME: resolved } },
{ name: 'Windows USERPROFILE home', env: { USERPROFILE: home, GSTACK_HOME: resolved }, win: true },
];
for (const row of envs) {
for (const key of keys) {
const bash = bashEval(row, `gstack_read_config_key ${key}; printf x`).replace(/x$/, '');
const ts = readConfigKey(key, tsEnv(row), row.win ? 'win32' : 'linux') ?? '';
expect(`${row.name}/${key}=${bash}`).toBe(`${row.name}/${key}=${ts}`);
}
}
});
test('merged keys take the most restrictive value; other keys read the resolved root only', () => {
const env = { HOME: home, GSTACK_HOME: resolved };
expect(readConfigKey('telemetry', env, 'linux')).toBe('anonymous');
expect(readConfigKey('codex_reviews', env, 'linux')).toBe('disabled');
expect(readConfigKey('update_check', env, 'linux')).toBe('true');
expect(readConfigKey('memorable_recall', env, 'linux')).toBe('bogus');
expect(readConfigKey('proactive', env, 'linux')).toBe('false');
expect(readConfigKey('founder_resources', env, 'linux')).toBe('true');
expect(readConfigKey('only_legacy', env, 'linux')).toBeNull();
expect(readConfigKey('empty_key', env, 'linux')).toBeNull();
const overridden = { ...env, GSTACK_TEST_LEGACY_ROOT: legacyOverride };
expect(readConfigKey('telemetry', overridden, 'linux')).toBe('off');
expect(readConfigKey('codex_reviews', overridden, 'linux')).toBe('enabled');
});
});