mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-03 23:37:53 +01:00
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:
1 parent
96764e80a6
commit
ab3b6f02e8
5 files changed
+561
-13
No files matched your search
+71
-13
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user