W1: guarded migration of every executable state-root site; uninstall deletes only ~/.gstack

Bins, careful/freeze hooks, setup, upgrade migrations, browse/src, design,
ios-qa daemon, lib and scripts resolve the state root through
bin/gstack-state-root.sh (bash) or lib/state-root.ts (TS). Bins source the
twin and stop with a reinstall message when it is missing; hooks source it
and never spawn gstack-paths. browse/src/config.ts and lib/cso/state.ts
delegate to resolveStateRoot. Analytics writers and readers move together
so the usage log stays one file. gstack-uninstall deletes state only at
~/.gstack, refuses (exit 2) when it resolves to /, $HOME or an ancestor,
the checkout or the git root, and leaves any other resolved root in place
with the removal command. Fixtures that copy single bins now copy the twin.
This commit is contained in:
garrytan committed 2026-09-30 06:38:50 +00:00
1 parent ab3b6f02e8
commit cd7485b0b4
98 files changed
+546 -268

No files matched your search

+4 -2
View File
@@ -8,10 +8,12 @@
# gstack-analytics all # all time
#
# Env overrides (for testing):
# GSTACK_STATE_DIR — override ~/.gstack state directory
# GSTACK_HOME — relocate the state root (chain: bin/gstack-state-root.sh)
set -uo pipefail
STATE_DIR="${GSTACK_STATE_DIR:-$HOME/.gstack}"
. "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
gstack_state_root_select
STATE_DIR="$_gstack_sr_root"
JSONL_FILE="$STATE_DIR/analytics/skill-usage.jsonl"
# ─── Parse time window ───────────────────────────────────────
+3 -1
View File
@@ -46,7 +46,9 @@ BASH_COMPAT=50
set -euo pipefail
GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}"
. "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
gstack_state_root_select
GSTACK_HOME="$_gstack_sr_root"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
URL_BIN="$SCRIPT_DIR/gstack-artifacts-url"
REMOTE_FILE="$HOME/.gstack-artifacts-remote.txt"
+2 -1
View File
@@ -38,6 +38,7 @@ import { existsSync, readFileSync, statSync, readdirSync, accessSync, constants
import { join, dirname, basename, resolve, delimiter } from "path";
import { spawnSync } from "child_process";
import { homedir } from "os";
import { resolveStateRoot } from "../lib/state-root";
import { parseSkillManifest, type GbrainManifest, type GbrainManifestQuery, withErrorContext } from "../lib/gstack-memory-helpers";
@@ -67,7 +68,7 @@ interface QueryResult {
// ── Constants ──────────────────────────────────────────────────────────────
const HOME = homedir();
const GSTACK_HOME = process.env.GSTACK_HOME || join(HOME, ".gstack");
const GSTACK_HOME = resolveStateRoot();
// 500ms hard cap per Section 1C; overridable for slow/loaded environments
// (test harnesses under CI load, cold CLI starts).
const MCP_TIMEOUT_MS = Math.max(1, parseInt(process.env.GSTACK_BRAIN_TIMEOUT_MS || "", 10) || 500);
+3 -1
View File
@@ -31,7 +31,9 @@ set -uo pipefail
FILE="${1:-}"
[ -z "$FILE" ] && exit 0
GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}"
. "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
gstack_state_root_select
GSTACK_HOME="$_gstack_sr_root"
SPOOL="$GSTACK_HOME/.brain-queue.d"
SKIP_FILE="$GSTACK_HOME/.brain-skip.txt"
+3 -1
View File
@@ -37,7 +37,9 @@ BASH_COMPAT=50
set -euo pipefail
GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}"
. "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
gstack_state_root_select
GSTACK_HOME="$_gstack_sr_root"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
CONFIG_BIN="$SCRIPT_DIR/gstack-config"
+3 -1
View File
@@ -29,7 +29,9 @@ BASH_COMPAT=50
set -uo pipefail
GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}"
. "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
gstack_state_root_select
GSTACK_HOME="$_gstack_sr_root"
# Maildir-style spool: one FILE per record, <epoch>-<pid>-<uniq>.json.
# Writers (gstack-brain-enqueue, --discover-new) create records via tmp-file
# + atomic rename; the drain deletes exactly the files it snapshotted. No
+3 -1
View File
@@ -41,7 +41,9 @@
set -euo pipefail
GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}"
. "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
gstack_state_root_select
GSTACK_HOME="$_gstack_sr_root"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
CONFIG_BIN="$SCRIPT_DIR/gstack-config"
# v1.27.0.0+ canonical name; brain-remote is the legacy fallback during migration.
+13 -9
View File
@@ -63,8 +63,11 @@ _gstack_codex_model_probe() {
# Only call this AFTER _gstack_codex_auth_probe passes — probing without
# auth just measures the auth failure again.
local _codex_home="${CODEX_HOME:-$HOME/.codex}"
local _gstack_home="${GSTACK_HOME:-$HOME/.gstack}"
local _cache="$_gstack_home/.codex-model-probe"
# State root from the shared twin (sourced in a subshell, so the caller's
# shell is untouched). A missing twin only disables the cache.
local _gstack_home _cache=""
_gstack_home="$(. "${BASH_SOURCE[0]%/*}/gstack-state-root.sh" 2>/dev/null && gstack_state_root)" || _gstack_home=""
[ -n "$_gstack_home" ] && _cache="$_gstack_home/.codex-model-probe"
local _model="${GSTACK_CODEX_MODEL:-gpt-6-astra}"
# Cache signature: config.toml + auth.json mtimes + gstack model selection.
# Editing the model env/config or re-logging-in invalidates the cached result
@@ -83,7 +86,7 @@ _gstack_codex_model_probe() {
_sig="${_cfg_m}-${_auth_m}-${_model_sig}"
local _now
_now=$(date +%s 2>/dev/null || echo 0)
if [ -f "$_cache" ]; then
if [ -n "$_cache" ] && [ -f "$_cache" ]; then
local _c_line _c_status _c_ts _c_sig
_c_line=$(head -1 "$_cache" 2>/dev/null)
_c_status=$(printf '%s' "$_c_line" | cut -d' ' -f1)
@@ -105,14 +108,12 @@ _gstack_codex_model_probe() {
_out=$(_gstack_codex_timeout_wrapper 30 codex exec --skip-git-repo-check -s read-only -c "model=\"$_model\"" "reply OK" </dev/null 2>&1)
_code=$?
if [ "$_code" -eq 0 ]; then
mkdir -p "$_gstack_home" 2>/dev/null || true
printf 'MODEL_OK %s %s\n' "$_now" "$_sig" > "$_cache" 2>/dev/null || true
[ -n "$_cache" ] && { mkdir -p "$_gstack_home" 2>/dev/null; printf 'MODEL_OK %s %s\n' "$_now" "$_sig" > "$_cache" 2>/dev/null; }
echo "MODEL_OK"
return 0
fi
if printf '%s' "$_out" | grep -qiE 'model.{0,40}is not supported|"status":[[:space:]]*400'; then
mkdir -p "$_gstack_home" 2>/dev/null || true
printf 'MODEL_UNUSABLE %s %s\n' "$_now" "$_sig" > "$_cache" 2>/dev/null || true
[ -n "$_cache" ] && { mkdir -p "$_gstack_home" 2>/dev/null; printf 'MODEL_UNUSABLE %s %s\n' "$_now" "$_sig" > "$_cache" 2>/dev/null; }
echo "MODEL_UNUSABLE"
printf '%s\n' "$_out" | grep -i "model" | head -3
echo "HINT: gstack requested model '$_model'."
@@ -221,12 +222,15 @@ _gstack_codex_log_event() {
local _event="$1"
local _duration="${2:-0}"
[ "${_TEL:-off}" = "off" ] && return 0
mkdir -p "$HOME/.gstack/analytics" 2>/dev/null || return 0
local _root
_root="$(. "${BASH_SOURCE[0]%/*}/gstack-state-root.sh" 2>/dev/null && gstack_state_root)" || return 0
[ -n "$_root" ] || return 0
mkdir -p "$_root/analytics" 2>/dev/null || return 0
local _ts
_ts=$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || echo unknown)
printf '{"skill":"codex","event":"%s","duration_s":"%s","ts":"%s"}\n' \
"$_event" "$_duration" "$_ts" \
>> "$HOME/.gstack/analytics/skill-usage.jsonl" 2>/dev/null || true
>> "$_root/analytics/skill-usage.jsonl" 2>/dev/null || true
}
# --- Learnings log on hang --------------------------------------------------
+3 -1
View File
@@ -21,7 +21,9 @@
# derive all apply uniformly.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
GSTACK_HOME="${GSTACK_STATE_ROOT:-${GSTACK_HOME:-$HOME/.gstack}}"
. "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
gstack_state_root_select
GSTACK_HOME="$_gstack_sr_root"
CODEX_SESSIONS_ROOT="${CODEX_SESSIONS_ROOT:-$HOME/.codex/sessions}"
MODE="latest"
+9 -31
View File
@@ -57,7 +57,7 @@
*
* Scan hardening: every target, explicit or derived from `--changed`, must be an
* existing regular file or directory whose realpath lies under the repo root (or
* cwd) or under ${GSTACK_HOME:-~/.gstack}/projects/<slug>/designs/ (where design-
* cwd) or under <state root>/projects/<slug>/designs/ (where design-
* review keeps rendered-DOM dumps: `designs/<audit>/dom/**` are page dumps and
* scan with --no-inline-ignores, because an `impeccable-disable` comment there is
* page-controlled; other designs/ files are gstack-authored artifacts and keep
@@ -77,7 +77,7 @@
* whose realpath lies inside the repo or cwd are ignored (IMPECCABLE_ENV_IGNORED).
*
* Observability: one content-free JSON line per probe/scan appended to
* ${GSTACK_HOME:-~/.gstack}/analytics/design-detector.jsonl (local file, no egress).
* <state root>/analytics/design-detector.jsonl (local file, no egress).
*
* Non-sink: this spawns a third-party binary the user installed over local
* paths; gstack does not audit that engine's network behavior (NOTICE.md).
@@ -94,6 +94,7 @@ import {
ENGINE_RELEASE_BASE, ENGINE_ASSETS, ENGINE_PINS,
} from '../lib/design-detect-contract';
import { writeReceipt, writeOutcome } from '../lib/egress-receipt';
import { readConfigKey, resolveStateRoot } from '../lib/state-root';
import { DESIGN_SLOP_CATALOG, entryForImpeccableId } from '../lib/design-catalog';
import { isFrontendPath } from '../lib/frontend-scope';
@@ -104,14 +105,9 @@ const HOME = os.homedir();
const REAL_HOME = realpathOrNull(HOME) ?? HOME;
const ENV = process.env;
/** Where config.yaml lives: the same precedence bin/gstack-config uses. */
function gstackStateDir(): string {
return ENV.GSTACK_STATE_ROOT || ENV.GSTACK_HOME || ENV.GSTACK_STATE_DIR || path.join(HOME, '.gstack');
}
/** Existing local analytics location; design artifacts resolve separately below. */
/** Config, analytics and design artifacts share the one state root (lib/state-root.ts). */
function gstackHome(): string {
return ENV.GSTACK_HOME || path.join(HOME, '.gstack');
return resolveStateRoot(ENV);
}
function realpathOrNull(p: string): string | null {
@@ -130,23 +126,10 @@ function gitTopLevel(cwd: string): string | null {
return top ? realpathOrNull(top) : null;
}
/** One flat key from config.yaml, read the way bin/gstack-config resolves it (same STATE_DIR precedence); '' when unset. */
/** One flat key from config.yaml via readConfigKey (the bin/gstack-config reader); '' when unset. */
function configValue(key: string): string {
const file = path.join(gstackStateDir(), 'config.yaml');
try {
const text = fs.readFileSync(file, 'utf-8');
let value = '';
const re = new RegExp(`^${key}:\\s*(.*?)\\s*$`);
for (const line of text.split('\n')) {
const m = line.match(re);
if (!m) continue;
// flat YAML: drop a trailing comment and surrounding quotes
value = m[1].replace(/\s+#.*$/, '').trim().replace(/^["'](.*)["']$/, '$1');
}
return value;
} catch {
return '';
}
// flat YAML: drop a trailing comment and surrounding quotes
return (readConfigKey(key, ENV) ?? '').replace(/\s+#.*$/, '').trim().replace(/^["'](.*)["']$/, '$1');
}
/** design_detector: `Off` by hand must not silently re-enable a third-party binary. */
@@ -785,12 +768,7 @@ function refuse(target: string, why: string) {
}
function designsRoot(): string {
// Match the artifact producer's bin/gstack-paths precedence without changing
// config or analytics roots. Plugin data belongs to gstack only with its marker.
const stateRoot = ENV.GSTACK_HOME
|| (ENV.CLAUDE_PLUGIN_DATA && /gstack/i.test(ENV.CLAUDE_PLUGIN_ROOT || '') ? ENV.CLAUDE_PLUGIN_DATA : '')
|| (ENV.HOME ? path.join(ENV.HOME, '.gstack') : '.gstack');
return path.join(stateRoot, 'projects');
return path.join(resolveStateRoot(ENV), 'projects');
}
type TargetClass = 'project' | 'artifact' | 'dom-dump';
+3 -1
View File
@@ -29,7 +29,9 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
# GSTACK_STATE_ROOT takes precedence over GSTACK_HOME (test isolation per D16).
GSTACK_HOME="${GSTACK_STATE_ROOT:-${GSTACK_HOME:-$HOME/.gstack}}"
. "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
gstack_state_root_select
GSTACK_HOME="$_gstack_sr_root"
# Windows git-bash: GSTACK_HOME resolves to an MSYS path (/c/Users/...), which
# Bun on Windows cannot open as a filesystem path (bites --derive). Normalize
# once, here, before PROFILE_FILE/LEGACY_FILE are derived from it below —
+3 -1
View File
@@ -24,7 +24,9 @@
# gstack-distill-apply --list # show pending proposals
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
GSTACK_HOME="${GSTACK_STATE_ROOT:-${GSTACK_HOME:-$HOME/.gstack}}"
. "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
gstack_state_root_select
GSTACK_HOME="$_gstack_sr_root"
eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null || true)"
SLUG="${SLUG:-unknown}"
PROJECT_DIR="$GSTACK_HOME/projects/$SLUG"
+3 -1
View File
@@ -32,7 +32,9 @@ set -euo pipefail
BASH_COMPAT=50
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
GSTACK_HOME="${GSTACK_STATE_ROOT:-${GSTACK_HOME:-$HOME/.gstack}}"
. "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
gstack_state_root_select
GSTACK_HOME="$_gstack_sr_root"
eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null || true)"
SLUG="${SLUG:-unknown}"
PROJECT_DIR="$GSTACK_HOME/projects/$SLUG"
+5 -5
View File
@@ -43,13 +43,13 @@ case "${BASH_SOURCE[0]}" in
*) _gstack_egress_lib_dir="$(pwd)" ;;
esac
# State root from the shared twin (bin/gstack-state-root.sh, builtins only),
# sourced lazily on first use.
_gstack_egress_home() {
if [ -n "${GSTACK_HOME:-}" ]; then
printf '%s' "$GSTACK_HOME"
elif [ -n "${GSTACK_STATE_DIR:-}" ]; then
printf '%s' "$GSTACK_STATE_DIR"
if command -v gstack_state_root >/dev/null 2>&1 || { [ -f "$_gstack_egress_lib_dir/gstack-state-root.sh" ] && . "$_gstack_egress_lib_dir/gstack-state-root.sh"; }; then
gstack_state_root
else
printf '%s' "$HOME/.gstack"
printf '%s' "<gstack state root: $_gstack_egress_lib_dir/gstack-state-root.sh is missing; reinstall with ./setup or /gstack-upgrade>"
fi
}
+2 -2
View File
@@ -1,10 +1,10 @@
#!/usr/bin/env bun
import { readFileSync, realpathSync, statSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { spawnSync } from 'node:child_process';
import { gbrainInvocation, buildGbrainEnv } from '../lib/gbrain-exec';
import { parseSourcesList } from '../lib/gbrain-sources';
import { resolveStateRoot } from '../lib/state-root';
type Verdict = { status: 'ready' | 'unknown' | 'skipped' | 'source'; reason: string; source_id?: string; page_count?: number };
@@ -20,7 +20,7 @@ function readCapability(): Verdict {
try {
root = realpathSync(repo.stdout.trim());
const pinPath = join(root, '.gbrain-source');
const statePath = join(process.env.GSTACK_HOME || join(homedir(), '.gstack'), '.gbrain-sync-state.json');
const statePath = join(resolveStateRoot(), '.gbrain-sync-state.json');
if (statSync(pinPath).size > 512 || statSync(statePath).size > 64 * 1024)
return unknown('sync state or source pin exceeds the read limit');
pin = readFileSync(pinPath, 'utf8').trim();
+3 -1
View File
@@ -43,7 +43,9 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
CONFIG_BIN="$SCRIPT_DIR/gstack-config"
GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}"
. "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
gstack_state_root_select
GSTACK_HOME="$_gstack_sr_root"
WORKTREE="${GSTACK_BRAIN_WORKTREE:-$HOME/.gstack-brain-worktree}"
# v1.27.0.0+ canonical name; brain-remote is the legacy fallback during migration.
if [ -f "$HOME/.gstack-artifacts-remote.txt" ]; then
+3 -2
View File
@@ -44,6 +44,7 @@ import { localEngineStatus, type LocalEngineStatus } from "../lib/gbrain-local-s
import { buildGbrainEnv, spawnGbrain, execGbrainJson, NEEDS_SHELL_ON_WINDOWS, bashScriptInvocation } from "../lib/gbrain-exec";
import { repoPolicyTier as sharedRepoPolicyTier } from "../lib/gbrain-repo-policy-client";
import { checkOwnedStagingDir } from "../lib/staging-guard";
import { resolveStateRoot } from "../lib/state-root";
// ── Types ──────────────────────────────────────────────────────────────────
@@ -98,7 +99,7 @@ interface StageResult {
// ── Constants ──────────────────────────────────────────────────────────────
const HOME = homedir();
const GSTACK_HOME = process.env.GSTACK_HOME || join(HOME, ".gstack");
const GSTACK_HOME = resolveStateRoot();
const STATE_PATH = join(GSTACK_HOME, ".gbrain-sync-state.json");
const LOCK_PATH = join(GSTACK_HOME, ".sync-gbrain.lock");
const STALE_LOCK_MS = 5 * 60 * 1000;
@@ -118,7 +119,7 @@ const DREAM_MARKER_STALE_MS = DEFAULT_DREAM_TIMEOUT_MS;
* module-load-time const captures the real ~/.gstack before a test can redirect.
*/
export function dreamMarkerPath(): string {
return join(process.env.GSTACK_HOME || join(homedir(), ".gstack"), ".dream-in-progress");
return join(resolveStateRoot(), ".dream-in-progress");
}
// Default 35-minute timeout for code-walk + memory-ingest stages. Override via
+3 -1
View File
@@ -14,7 +14,9 @@ case "$(uname -s)" in
MINGW*|MSYS*|CYGWIN*) command -v cygpath >/dev/null 2>&1 && SCRIPT_DIR="$(cygpath -m "$SCRIPT_DIR")" ;;
esac
eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null)"
GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}"
. "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
gstack_state_root_select
GSTACK_HOME="$_gstack_sr_root"
mkdir -p "$GSTACK_HOME/projects/$SLUG"
INPUT="$1"
+3 -1
View File
@@ -8,7 +8,9 @@
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null)"
GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}"
. "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
gstack_state_root_select
GSTACK_HOME="$_gstack_sr_root"
TYPE=""
QUERY=""
+5 -3
View File
@@ -36,7 +36,9 @@ set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
GSTACK_CONFIG="$SCRIPT_DIR/gstack-config"
STATE_DIR="${GSTACK_STATE_ROOT:-${GSTACK_HOME:-${GSTACK_STATE_DIR:-$HOME/.gstack}}}"
. "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
gstack_state_root_select
STATE_DIR="$_gstack_sr_root"
SETTINGS_FILE="${GSTACK_SETTINGS_FILE:-${CLAUDE_CONFIG_DIR:-$HOME/.claude}/settings.json}"
HOOK_SOURCE="gstack-memorable"
@@ -380,9 +382,9 @@ status_bridge() {
n="unknown (gstack-egress list failed; run it yourself)"
fi
echo "receipts: $n for sink $SINK (gstack-egress list --sink $SINK)"
# Same resolution as lib/egress-receipt.ts resolveEgressHome: GSTACK_HOME, GSTACK_STATE_DIR, ~/.gstack.
# Same root lib/egress-receipt.ts resolveEgressHome uses (the shared state-root chain).
local ledger size
ledger="${GSTACK_HOME:-${GSTACK_STATE_DIR:-$HOME/.gstack}}/security/egress.jsonl"
ledger="$STATE_DIR/security/egress.jsonl"
if [ -f "$ledger" ]; then
size="$(wc -c < "$ledger" | tr -d ' ')"
if [ "${size:-0}" -gt 26214400 ]; then
+2 -1
View File
@@ -72,6 +72,7 @@ import { execGbrainText, spawnGbrainAsync } from "../lib/gbrain-exec";
import { writeReceipt } from "../lib/egress-receipt";
import { checkOwnedStagingDir, STAGING_MARKER } from "../lib/staging-guard";
import { hasRepoPolicyStore, repoPolicyTierBatch } from "../lib/gbrain-repo-policy-client";
import { resolveStateRoot } from "../lib/state-root";
// ── Types ──────────────────────────────────────────────────────────────────
@@ -186,7 +187,7 @@ interface BulkResult {
// ── Constants ──────────────────────────────────────────────────────────────
const HOME = homedir();
const GSTACK_HOME = process.env.GSTACK_HOME || join(HOME, ".gstack");
const GSTACK_HOME = resolveStateRoot();
const STATE_PATH = join(GSTACK_HOME, ".transcript-ingest-state.json");
const DEFAULT_INCREMENTAL_BUDGET_MS = 50;
+3 -1
View File
@@ -35,7 +35,9 @@ case "$(uname -s)" in
esac
eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null)"
# GSTACK_STATE_ROOT takes precedence over GSTACK_HOME (test isolation per D16).
GSTACK_HOME="${GSTACK_STATE_ROOT:-${GSTACK_HOME:-$HOME/.gstack}}"
. "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
gstack_state_root_select
GSTACK_HOME="$_gstack_sr_root"
mkdir -p "$GSTACK_HOME/projects/$SLUG"
INPUT="$1"
+3 -1
View File
@@ -32,7 +32,9 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
# GSTACK_STATE_ROOT takes precedence over GSTACK_HOME (test isolation per D16).
GSTACK_HOME="${GSTACK_STATE_ROOT:-${GSTACK_HOME:-$HOME/.gstack}}"
. "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
gstack_state_root_select
GSTACK_HOME="$_gstack_sr_root"
eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null || true)"
SLUG="${SLUG:-unknown}"
PREF_FILE="$GSTACK_HOME/projects/$SLUG/question-preferences.json"
+6 -4
View File
@@ -5,7 +5,7 @@
# gstack-relink
#
# Env overrides (for testing):
# GSTACK_STATE_DIR — override ~/.gstack state directory
# GSTACK_HOME — relocate the state root (chain: bin/gstack-state-root.sh)
# GSTACK_INSTALL_DIR — override gstack install directory
# GSTACK_SKILLS_DIR — override target skills directory
set -euo pipefail
@@ -40,7 +40,9 @@ PREFIX=$("$GSTACK_CONFIG" get skill_prefix 2>/dev/null || echo "false")
# out-dir instead of the tracked install checkout. When a render exists for a
# skill, relink serves it — otherwise a config change would silently flip
# every skill back to the canonical (blockless) source.
RENDER_DIR="${GSTACK_USER_RENDER_DIR:-${GSTACK_HOME:-$HOME/.gstack}/render/claude}"
. "$SCRIPT_DIR/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $SCRIPT_DIR/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
gstack_state_root_select; GSTACK_STATE_ROOT="$_gstack_sr_root"
RENDER_DIR="${GSTACK_USER_RENDER_DIR:-$GSTACK_STATE_ROOT/render/claude}"
# ─── Ownership gate ───────────────────────────────────────────────────────────
# relink runs on every ./setup and used to `rm -rf` any same-name entry with a
@@ -58,7 +60,7 @@ RENDER_DIR="${GSTACK_USER_RENDER_DIR:-${GSTACK_HOME:-$HOME/.gstack}/render/claud
# refreshed in place. WEAK (byte-identity with our source, or gen-skill-docs'
# two-line banner on a real file) proves only that the SKILL.md came from us:
# it authorizes touching that one file, never deleting the directory, and a
# differing file is moved to ${GSTACK_HOME:-~/.gstack}/backups/skills/<ts>/
# differing file is moved to $GSTACK_STATE_ROOT/backups/skills/<ts>/
# before we link over it (a user who started their own skill from a gstack
# SKILL.md looks exactly like a pre-marker legacy copy).
#
@@ -183,7 +185,7 @@ _report_foreign() {
# Weakly-proven real files we would otherwise overwrite go here, one summary
# line at the end. mv, not cp: the link that follows needs the path free.
BACKUP_ROOT="${GSTACK_HOME:-$HOME/.gstack}/backups/skills/$(date +%Y%m%dT%H%M%S)"
BACKUP_ROOT="$GSTACK_STATE_ROOT/backups/skills/$(date +%Y%m%dT%H%M%S)"
BACKED_UP=()
_backup_skill_md() {
# Non-zero when the file could NOT be moved: the caller leaves the entry alone.
+3 -1
View File
@@ -41,7 +41,9 @@ if [ -n "$OVERRIDE" ] && [ "$OVERRIDE" != "null" ]; then
fi
# Check cache (7-day TTL)
CACHE_DIR="$HOME/.gstack/projects/$SLUG"
. "$SCRIPT_DIR/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $SCRIPT_DIR/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
gstack_state_root_select; GSTACK_STATE_ROOT="$_gstack_sr_root"
CACHE_DIR="$GSTACK_STATE_ROOT/projects/$SLUG"
CACHE_FILE="$CACHE_DIR/repo-mode.json"
if [ -f "$CACHE_FILE" ]; then
# GNU first (#2195): on GNU coreutils `stat -f` SUCCEEDS with filesystem
+4 -2
View File
@@ -12,7 +12,7 @@
#
# Conventions mirror bin/gstack-skill-start:
# - Paths resolve $0-relative (works for every host + install layout).
# - State paths honor ${GSTACK_HOME:-$HOME/.gstack}.
# - State paths honor the shared state root (bin/gstack-paths).
# - Error style: per-line `|| true`, never `set -e` — a mid-script failure
# must not drop later METRIC lines.
# - No heredocs (nothing to BASH_COMPAT-guard; see
@@ -45,7 +45,9 @@ done
# sibling-bin call must go through $_BIN, never bare PATH lookup).
_SCRIPT_DIR=$(cd "$(dirname "$0")" 2>/dev/null && pwd)
_BIN="$_SCRIPT_DIR"
_GH="${GSTACK_HOME:-$HOME/.gstack}"
. "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
gstack_state_root_select
_GH="$_gstack_sr_root"
echo "RETRO_METRICS_PROTO: 1"
+3 -1
View File
@@ -13,7 +13,9 @@ export GIT_OPTIONAL_LOCKS=0
export GIT_CONFIG_PARAMETERS="${GIT_CONFIG_PARAMETERS:+$GIT_CONFIG_PARAMETERS }'core.fsmonitor=false' 'core.untrackedCache=false'"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null)"
GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}"
. "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
gstack_state_root_select
GSTACK_HOME="$_gstack_sr_root"
mkdir -p "$GSTACK_HOME/projects/$SLUG"
INPUT="${1:-}"
+3 -1
View File
@@ -14,7 +14,9 @@ case "$(uname -s)" in
MINGW*|MSYS*|CYGWIN*) command -v cygpath >/dev/null 2>&1 && SCRIPT_DIR="$(cygpath -m "$SCRIPT_DIR")" ;;
esac
eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null)"
GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}"
. "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
gstack_state_root_select
GSTACK_HOME="$_gstack_sr_root"
WTREE=$("$SCRIPT_DIR/gstack-wtree" 2>/dev/null || echo "unknown")
if [ -f "$GSTACK_HOME/projects/$SLUG/$BRANCH-reviews.jsonl" ]; then
GSTACK_REVIEW_LIB="$SCRIPT_DIR/../lib/review-evidence.ts" GSTACK_REVIEW_WTREE="$WTREE" bun -e '
+4 -1
View File
@@ -10,7 +10,10 @@
set +e
GSTACK_DIR="${GSTACK_DIR:-$HOME/.claude/skills/gstack}"
STATE_DIR="${GSTACK_STATE_DIR:-$HOME/.gstack}"
# Hook: source the state-root twin (never spawn gstack-paths); a broken
# install exits 0 silently, like every other error here.
. "$(cd "$(dirname "$0")" && pwd)/gstack-state-root.sh" 2>/dev/null || exit 0
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.
+3 -1
View File
@@ -30,7 +30,9 @@ done
_SCRIPT_DIR=$(cd "$(dirname "$0")" 2>/dev/null && pwd)
_BIN="$_SCRIPT_DIR"
_GH="${GSTACK_HOME:-$HOME/.gstack}"
. "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
gstack_state_root_select
_GH="$_gstack_sr_root"
_TEL=$("$_BIN/gstack-config" get telemetry 2>/dev/null || echo off)
_TEL_END=$(date +%s)
+3 -1
View File
@@ -51,7 +51,9 @@ done
_SCRIPT_DIR=$(cd "$(dirname "$0")" 2>/dev/null && pwd)
_BIN="$_SCRIPT_DIR"
_GH="${GSTACK_HOME:-$HOME/.gstack}"
. "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
gstack_state_root_select
_GH="$_gstack_sr_root"
# OV4: strip instruction markers from any passthrough text before echoing it
# into the blessed tool result. Prior-session/repo content must not be able to
+4 -2
View File
@@ -46,11 +46,13 @@
# injection when consumed via source or eval.
set -euo pipefail
# GSTACK_HOME-aware, matching lib/bin-context.ts's native port (#2561): the
# State-root aware (bin/gstack-state-root.sh), matching lib/bin-context.ts (#2561): the
# bash writer and the TS reader must key the SAME cache, and a test running
# with GSTACK_HOME=<temp> must write its cache junk there, not into the real
# home (observed: 2,528 stale temp-cwd entries accumulated in ~/.gstack).
CACHE_DIR="${GSTACK_HOME:-$HOME/.gstack}/slug-cache"
. "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
gstack_state_root_select; GSTACK_STATE_ROOT="$_gstack_sr_root"
CACHE_DIR="$GSTACK_STATE_ROOT/slug-cache"
PROJECT_DIR="$(pwd)"
# Encode absolute path as cache key: /Users/j/foo → _Users_j_foo
CACHE_KEY=$(printf '%s' "$PROJECT_DIR" | tr '/' '_')
+3 -1
View File
@@ -8,7 +8,9 @@
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null)"
GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}"
. "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
gstack_state_root_select
GSTACK_HOME="$_gstack_sr_root"
PROJECT_DIR="$GSTACK_HOME/projects/$SLUG"
if [ ! -d "$PROJECT_DIR" ]; then
+18 -8
View File
@@ -85,6 +85,21 @@ _gstack_config_rank() {
esac
}
# gstack_legacy_root_select — sets _gstack_legacy_root to the second candidate
# root merged privacy settings are also read from: $HOME/.gstack
# (GSTACK_TEST_LEGACY_ROOT in tests), or "" when there is none or it is the
# resolved root. Call after gstack_state_root_select.
gstack_legacy_root_select() {
_gstack_legacy_root=""
if [ -n "${GSTACK_TEST_LEGACY_ROOT:-}" ]; then
_gstack_legacy_root="$GSTACK_TEST_LEGACY_ROOT"
elif [ -n "$_gstack_home_val" ]; then
_gstack_legacy_root="$_gstack_home_val/.gstack"
fi
[ "$_gstack_legacy_root" = "$_gstack_sr_root" ] && _gstack_legacy_root=""
return 0
}
# 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
@@ -97,14 +112,9 @@ gstack_config_select() {
[ -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_legacy_root_select
[ -n "$_gstack_legacy_root" ] || return 0
_gstack_cfg_legacy="$_gstack_legacy_root"
_gstack_config_from_root "$_gstack_cfg_legacy" "$1"
[ -n "$_gstack_cfg_one" ] || return 0
if [ -z "$_gstack_cfg_value" ]; then
+5 -3
View File
@@ -16,7 +16,7 @@
# epilogue may sweep.
#
# Env overrides (for testing):
# GSTACK_STATE_DIR — override ~/.gstack state directory
# GSTACK_HOME — relocate the state root (chain: bin/gstack-state-root.sh)
# GSTACK_DIR — override auto-detected gstack root
#
# NOTE: Uses set -uo pipefail (no -e) — telemetry must never exit non-zero
@@ -29,7 +29,9 @@ SCRIPT_DIR="$GSTACK_DIR/bin"
case "$(uname -s)" in
MINGW*|MSYS*|CYGWIN*) command -v cygpath >/dev/null 2>&1 && SCRIPT_DIR="$(cygpath -m "$SCRIPT_DIR")" ;;
esac
STATE_DIR="${GSTACK_STATE_DIR:-$HOME/.gstack}"
. "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
gstack_state_root_select
STATE_DIR="$_gstack_sr_root"
ANALYTICS_DIR="$STATE_DIR/analytics"
JSONL_FILE="$ANALYTICS_DIR/skill-usage.jsonl"
PENDING_DIR="$ANALYTICS_DIR" # .pending-* files live here
@@ -148,7 +150,7 @@ fi
# can't be guessed or correlated by someone who knows your machine identity.
INSTALL_ID=""
if [ "$TIER" = "community" ]; then
ID_FILE="$HOME/.gstack/installation-id"
ID_FILE="$STATE_DIR/installation-id"
if [ -f "$ID_FILE" ]; then
INSTALL_ID="$(cat "$ID_FILE" 2>/dev/null)"
fi
+4 -2
View File
@@ -6,13 +6,15 @@
# Posts to the telemetry-ingest edge function (not PostgREST directly).
#
# Env overrides (for testing):
# GSTACK_STATE_DIR — override ~/.gstack state directory
# GSTACK_HOME — relocate the state root (chain: bin/gstack-state-root.sh)
# GSTACK_DIR — override auto-detected gstack root
# GSTACK_SUPABASE_URL — override Supabase project URL
set -uo pipefail
GSTACK_DIR="${GSTACK_DIR:-$(cd "$(dirname "$0")/.." && pwd)}"
STATE_DIR="${GSTACK_STATE_DIR:-$HOME/.gstack}"
. "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
gstack_state_root_select
STATE_DIR="$_gstack_sr_root"
# Egress receipt helpers (_receipted_curl): receipt-before-send, fail-closed.
. "$GSTACK_DIR/bin/gstack-egress-lib.sh"
+3 -1
View File
@@ -12,7 +12,9 @@
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null)"
GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}"
. "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
gstack_state_root_select
GSTACK_HOME="$_gstack_sr_root"
mkdir -p "$GSTACK_HOME/projects/$SLUG"
INPUT="$1"
+3 -1
View File
@@ -8,7 +8,9 @@
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null)"
GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}"
. "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
gstack_state_root_select
GSTACK_HOME="$_gstack_sr_root"
SINCE=""
LIMIT=20
+75 -7
View File
@@ -4,7 +4,15 @@
# Usage:
# gstack-uninstall — interactive uninstall (prompts before removing)
# gstack-uninstall --force — remove everything without prompting
# gstack-uninstall --keep-state — remove skills but keep ~/.gstack/ data
# gstack-uninstall --keep-state — remove skills but keep all gstack state
#
# State: only the default root ~/.gstack/ is ever deleted. A state root
# selected elsewhere (GSTACK_HOME, GSTACK_STATE_ROOT, GSTACK_STATE_DIR or
# plugin data; chain in bin/gstack-state-root.sh) is left in place and the
# exact removal command is printed. Uninstall refuses (exit 2) when ~/.gstack
# resolves to /, $HOME or an ancestor, the gstack checkout, the current git
# repository, or an ancestor of either.
# Docs: https://github.com/garrytan/gstack/blob/main/docs/state-root.md
#
# What gets REMOVED:
# ~/.claude/skills/gstack — global Claude skill install (git clone or vendored)
@@ -28,7 +36,6 @@
#
# Env overrides (for testing):
# GSTACK_DIR — override auto-detected gstack root
# GSTACK_STATE_DIR — override ~/.gstack state directory
#
# NOTE: Uses set -uo pipefail (no -e) — uninstall must never abort partway.
set -uo pipefail
@@ -39,7 +46,14 @@ if [ -z "${HOME:-}" ]; then
fi
GSTACK_DIR="${GSTACK_DIR:-$(cd "$(dirname "$0")/.." && pwd)}"
STATE_DIR="${GSTACK_STATE_DIR:-$HOME/.gstack}"
# Only the default root is ever deleted; RESOLVED_ROOT (the full chain) is
# used read-only (daemon discovery) and reported when it is elsewhere.
STATE_DIR="$HOME/.gstack"
_STATE_DOC="https://github.com/garrytan/gstack/blob/main/docs/state-root.md"
. "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade ($_STATE_DOC)" >&2; exit 1; }
gstack_state_root_select
RESOLVED_ROOT="$_gstack_sr_root"
RESOLVED_VAR="$_gstack_sr_var"
_GIT_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || true)"
# ─── Parse flags ─────────────────────────────────────────────
@@ -61,6 +75,53 @@ while [ $# -gt 0 ]; do
esac
done
# ─── State-root safety ───────────────────────────────────────
_real() { (cd -P -- "$1" 2>/dev/null && pwd -P); }
# _is_at_or_above TARGET PATH — TARGET is PATH or an ancestor of it.
_is_at_or_above() {
[ -n "$2" ] || return 1
[ "$1" = "$2" ] && return 0
case "$2/" in "${1%/}/"*) return 0 ;; esac
return 1
}
_refuse() {
echo "gstack-uninstall: refusing to delete $STATE_DIR: $1" >&2
echo "fix: $2 (docs: $_STATE_DOC)" >&2
exit 2
}
if [ "$KEEP_STATE" -eq 0 ] && [ -d "$STATE_DIR" ]; then
_STATE_REAL="$(_real "$STATE_DIR")"
_HOME_REAL="$(_real "$HOME")"
_CHECKOUT_REAL="$(_real "$GSTACK_DIR")"
_GIT_REAL=""
[ -n "$_GIT_ROOT" ] && _GIT_REAL="$(_real "$_GIT_ROOT")"
if [ -z "$_STATE_REAL" ] || [ "$_STATE_REAL" = "/" ]; then
_refuse "it resolves to the filesystem root /" "remove the link with rm -- '$STATE_DIR', or re-run with --keep-state"
elif _is_at_or_above "$_STATE_REAL" "$_HOME_REAL"; then
_refuse "it resolves to $_STATE_REAL, which is your home directory or an ancestor of it" "remove the link with rm -- '$STATE_DIR', or re-run with --keep-state"
elif _is_at_or_above "$_STATE_REAL" "$_CHECKOUT_REAL"; then
_refuse "it resolves to $_STATE_REAL, which is the gstack checkout $_CHECKOUT_REAL or an ancestor of it" "remove the link with rm -- '$STATE_DIR', or re-run with --keep-state"
elif _is_at_or_above "$_STATE_REAL" "$_GIT_REAL"; then
_refuse "it resolves to $_STATE_REAL, which is the current git repository $_GIT_REAL or an ancestor of it" "run gstack-uninstall from outside $_GIT_REAL, or re-run with --keep-state"
fi
fi
# The resolved root is left in place when it is not the default root.
_LEAVE_ROOT=""
if [ "$KEEP_STATE" -eq 0 ] && [ -e "$RESOLVED_ROOT" ]; then
_RESOLVED_REAL="$(_real "$RESOLVED_ROOT")"
_DEFAULT_REAL="$(_real "$STATE_DIR")"
if [ "$RESOLVED_ROOT" != "$STATE_DIR" ] && { [ -z "$_RESOLVED_REAL" ] || [ "$_RESOLVED_REAL" != "$_DEFAULT_REAL" ]; }; then
_LEAVE_ROOT="$RESOLVED_ROOT"
fi
fi
_report_left_root() {
[ -n "$_LEAVE_ROOT" ] || return 0
local _val
eval "_val=\${$RESOLVED_VAR:-}"
echo "left in place: $_LEAVE_ROOT (selected by $RESOLVED_VAR=$_val)" >&2
echo "fix: after checking it, remove it with rm -rf -- '$_LEAVE_ROOT' (docs: $_STATE_DOC)" >&2
}
# ─── Confirmation ────────────────────────────────────────────
if [ "$FORCE" -eq 0 ]; then
echo "This will remove gstack from your system:"
@@ -70,6 +131,7 @@ if [ "$FORCE" -eq 0 ]; then
[ -d "$HOME/.kiro/skills" ] && echo " ~/.kiro/skills/gstack*"
[ -d "$HOME/.cursor/skills" ] && echo " ~/.cursor/skills/gstack*"
[ "$KEEP_STATE" -eq 0 ] && [ -d "$STATE_DIR" ] && echo " $STATE_DIR"
[ -n "$_LEAVE_ROOT" ] && echo " (kept) $_LEAVE_ROOT: state root selected by $RESOLVED_VAR; left in place"
if [ -n "$_GIT_ROOT" ]; then
[ -d "$_GIT_ROOT/.claude/skills/gstack" ] && echo " $_GIT_ROOT/.claude/skills/gstack (project-local)"
@@ -125,12 +187,16 @@ if [ -n "$_GIT_ROOT" ] && [ -f "$_GIT_ROOT/.gstack/browse.json" ]; then
stop_browse_daemon "$_GIT_ROOT/.gstack/browse.json"
fi
# Stop daemons tracked in global projects directory
if [ -d "$STATE_DIR/projects" ]; then
# Stop daemons tracked in global projects directories (the resolved root is
# only read here, never deleted).
_PROJ_ROOTS=("$STATE_DIR")
[ "$RESOLVED_ROOT" != "$STATE_DIR" ] && _PROJ_ROOTS+=("$RESOLVED_ROOT")
for _PROJ_ROOT in "${_PROJ_ROOTS[@]}"; do
[ -d "$_PROJ_ROOT/projects" ] || continue
while IFS= read -r _BJ; do
stop_browse_daemon "$_BJ"
done < <(find "$STATE_DIR/projects" -name browse.json -path '*/.gstack/*' 2>/dev/null || true)
fi
done < <(find "$_PROJ_ROOT/projects" -name browse.json -path '*/.gstack/*' 2>/dev/null || true)
done
# ─── Remove gstack hooks from Claude Code settings ──────────
# MUST run BEFORE any install-root deletion: SETTINGS_HOOK resolves inside the
@@ -400,6 +466,8 @@ for _TMP in /tmp/gstack-latest-version /tmp/gstack-sketch-*.html /tmp/gstack-ske
fi
done
_report_left_root
# ─── Skipped-entry report ───────────────────────────────────
# Everything any provenance gate refused to delete (Claude shapes 2/3,
# cursor real dirs) — listed once, at the end, so nothing is silent.
+4 -2
View File
@@ -10,7 +10,7 @@
# GSTACK_DIR — override auto-detected gstack root
# GSTACK_REMOTE_URL — override remote VERSION URL (branch-pinned fallback)
# GSTACK_REMOTE_REPO — override remote git URL for ls-remote SHA resolution
# GSTACK_STATE_DIR — override ~/.gstack state directory
# GSTACK_HOME — relocate the state root (chain: bin/gstack-state-root.sh)
set -euo pipefail
# A crash must not read as "up to date" (#1974). With set -e, any unguarded
@@ -22,7 +22,9 @@ set -E
trap 'rc=$?; echo "CHECK_FAILED gstack-update-check crashed (line $LINENO, rc=$rc) — update status UNKNOWN, not up-to-date"; exit 0' ERR
GSTACK_DIR="${GSTACK_DIR:-$(cd "$(dirname "$0")/.." && pwd)}"
STATE_DIR="${GSTACK_STATE_DIR:-$HOME/.gstack}"
. "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
gstack_state_root_select
STATE_DIR="$_gstack_sr_root"
# Egress receipt helpers (_receipted_curl / _receipted_git). Update checks
# are fail-OPEN: a receipt hiccup warns but never blocks the version check.
+12 -4
View File
@@ -12,7 +12,7 @@
# NEVER runs until the user records it in the per-repo trust store:
# gstack-verify-gate --trust (run from inside the repo)
# The store maps realpath(repo root) -> sha256(command) at
# ${GSTACK_HOME:-$HOME/.gstack}/verify-gate-trust (flat "path<TAB>hash",
# $GSTACK_STATE_ROOT/verify-gate-trust (flat "path<TAB>hash",
# 0600, atomic rewrite). Any edit to the declared command invalidates trust
# until --trust is run again. Untrusted commands never block the turn.
#
@@ -23,7 +23,15 @@
set -uo pipefail
TAB="$(printf '\t')"
STORE="${GSTACK_HOME:-$HOME/.gstack}/verify-gate-trust"
# Hook: source the state-root twin (never spawn gstack-paths). A broken
# install fails open, like every other absence.
_VG_TWIN="$(cd "$(dirname "$0")" && pwd)/gstack-state-root.sh"
if [ ! -f "$_VG_TWIN" ] || ! . "$_VG_TWIN" 2>/dev/null; then
echo "verify-gate: $_VG_TWIN missing; gate skipped (reinstall with ./setup or /gstack-upgrade)"
exit 0
fi
GSTACK_STATE_ROOT="$(gstack_state_root; printf x)"; GSTACK_STATE_ROOT="${GSTACK_STATE_ROOT%x}"
STORE="$GSTACK_STATE_ROOT/verify-gate-trust"
_sha256() {
if command -v shasum >/dev/null 2>&1; then
@@ -100,7 +108,7 @@ _json_escape() {
# Args: $1 = root key, $2 = cmd sha256, $3 = cmd verbatim.
_log_trust_grant() {
local sec_dir log tty ts
sec_dir="${GSTACK_HOME:-$HOME/.gstack}/security"
sec_dir="$GSTACK_STATE_ROOT/security"
log="$sec_dir/verify-gate-trust-grants.jsonl"
tty=false
[ -t 0 ] && tty=true
@@ -175,7 +183,7 @@ _session_key() {
[ -n "$sid" ] || sid="ppid-$PPID"
_sha256 "$sid|$(_trust_key)"
}
ATTEMPTS_DIR="${GSTACK_HOME:-$HOME/.gstack}/verify-gate-attempts"
ATTEMPTS_DIR="$GSTACK_STATE_ROOT/verify-gate-attempts"
COUNTER="$ATTEMPTS_DIR/$(_session_key)"
# A first entry (stop_hook_active=false) starts a fresh blocking episode.
+2 -2
View File
@@ -18,11 +18,11 @@
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { mkdirSecure } from './file-permissions';
import { isPathWithin } from './platform';
import type { TierPaths } from './browser-skills';
import { defaultTierPaths } from './browser-skills';
import { resolveStateRoot } from '../../lib/state-root';
// ─── Naming validation ──────────────────────────────────────────
@@ -71,7 +71,7 @@ export function stageSkill(opts: StageSkillOptions): string {
}
const spawnId = opts.spawnId ?? generateSpawnId();
const tmpRoot = opts.tmpRoot ?? path.join(os.homedir(), '.gstack', '.tmp');
const tmpRoot = opts.tmpRoot ?? path.join(resolveStateRoot(), '.tmp');
const wrapperDir = path.join(tmpRoot, `skillify-${spawnId}`);
const stagedDir = path.join(wrapperDir, opts.name);
+3 -3
View File
@@ -22,8 +22,8 @@
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import * as cp from 'child_process';
import { resolveStateRoot } from '../../lib/state-root';
// ─── Types ──────────────────────────────────────────────────────
@@ -85,13 +85,13 @@ export interface TierPaths {
* Project tier requires git or a project hint; returns null when neither resolves.
*/
export function defaultTierPaths(opts: { projectRoot?: string; home?: string; bundledRoot?: string } = {}): TierPaths {
const home = opts.home ?? os.homedir();
const stateRoot = opts.home !== undefined ? path.join(opts.home, '.gstack') : resolveStateRoot();
const projectRoot = opts.projectRoot ?? detectProjectRoot();
const bundledRoot = opts.bundledRoot ?? detectBundledRoot();
return {
project: projectRoot ? path.join(projectRoot, '.gstack', 'browser-skills') : null,
global: path.join(home, '.gstack', 'browser-skills'),
global: path.join(stateRoot, 'browser-skills'),
bundled: path.join(bundledRoot, 'browser-skills'),
};
}
+11 -24
View File
@@ -11,10 +11,10 @@
*/
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { mkdirSecure } from './file-permissions';
import { safeUnlinkQuiet } from './error-handling';
import { readConfigKey, resolveStateRoot } from '../../lib/state-root';
export interface BrowseConfig {
projectDir: string;
@@ -193,39 +193,26 @@ export function readVersionHash(execPath: string = process.execPath): string | n
}
}
/**
* Resolve the gstack home directory.
*
* Honors the existing convention used by telemetry.ts and domain-skills.ts:
* 1. GSTACK_HOME env (explicit override)
* 2. $HOME/.gstack (default)
*/
/** The gstack state root: delegates to the shared chain in lib/state-root.ts. */
export function resolveGstackHome(): string {
return process.env.GSTACK_HOME || path.join(os.homedir(), '.gstack');
return resolveStateRoot();
}
/**
* Read one key from the flat-YAML config store at <gstack home>/config.yaml
* (the shape bin/gstack-config writes: `key: value` lines). Tolerates
* optional single/double quotes around the value and a trailing `# comment`.
* Returns the unquoted value string, or null when the file is missing or
* unreadable or the key is absent.
* Read one key from the flat-YAML config store via readConfigKey (the same
* reader bin/gstack-config uses, including the most-restrictive merge for
* privacy keys such as telemetry). Tolerates optional single/double quotes
* around the value and a trailing `# comment`. Returns the unquoted value
* string, or null when the file is missing or unreadable or the key is absent.
*
* Single source of truth for flat-YAML key reads — isPairAgentEnabled
* (pair_agent) and telemetry.ts (telemetry tier) both route through it so
* the two consent gates can never drift on parsing semantics.
*/
export function readGstackConfigYamlKey(key: string): string | null {
const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
try {
const yaml = fs.readFileSync(path.join(resolveGstackHome(), 'config.yaml'), 'utf-8');
// Last match wins: bin/gstack-config's `get` reads duplicates with
// `tail -1`, and both surfaces must agree on the same line.
const all = [...yaml.matchAll(new RegExp(`^\\s*${escaped}\\s*:\\s*['"]?([^'"#\\n]*?)['"]?\\s*(?:#.*)?$`, 'gm'))];
return all.length > 0 ? all[all.length - 1][1] : null;
} catch {
return null;
}
const raw = readConfigKey(key);
if (raw === null) return null;
return raw.replace(/\s*#.*$/, '').trim().replace(/^(['"])(.*)\1$/, '$2');
}
/**
+2 -2
View File
@@ -35,9 +35,9 @@
import { promises as fs } from 'fs';
import { open as fsOpen, constants as fsConstants } from 'fs';
import * as path from 'path';
import * as os from 'os';
import { createHash } from 'crypto';
import type { Page } from 'playwright';
import { resolveStateRoot } from '../../lib/state-root';
export type SkillState = 'quarantined' | 'active' | 'global';
export type SkillScope = 'project' | 'global';
@@ -63,7 +63,7 @@ export interface DomainSkillRow {
const PROMOTE_THRESHOLD = 3;
function gstackHome(): string {
return process.env.GSTACK_HOME || path.join(os.homedir(), '.gstack');
return resolveStateRoot();
}
function globalFile(): string {
+2 -2
View File
@@ -24,9 +24,9 @@
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { mkdirSecure } from './file-permissions';
import { type LayerSignal } from './security';
import { resolveStateRoot } from '../../lib/state-root';
// ─── Model location + packaging ──────────────────────────────
@@ -45,7 +45,7 @@ import { type LayerSignal } from './security';
* vocab.txt
* onnx/model.onnx (~112MB)
*/
const MODELS_DIR = path.join(os.homedir(), '.gstack', 'models');
const MODELS_DIR = path.join(resolveStateRoot(), 'models');
const TESTSAVANT_DIR = path.join(MODELS_DIR, 'testsavant-small');
const TESTSAVANT_HF_URL = 'https://huggingface.co/testsavantai/prompt-injection-defender-small-v0-onnx/resolve/main';
const TESTSAVANT_FILES = [
+2 -2
View File
@@ -20,11 +20,11 @@
import { promises as fs } from 'fs';
import * as path from 'path';
import * as os from 'os';
import { readGstackConfigYamlKey } from './config';
import { resolveStateRoot } from '../../lib/state-root';
function gstackHome(): string {
return process.env.GSTACK_HOME || path.join(os.homedir(), '.gstack');
return resolveStateRoot();
}
function analyticsDir(): string {
+2 -2
View File
@@ -17,10 +17,10 @@
*/
import { promises as fsp } from 'fs';
import * as path from 'path';
import * as os from 'os';
import { mkdirSecure } from './file-permissions';
import { resolveStateRoot } from '../../lib/state-root';
const LOG_DIR = path.join(os.homedir(), '.gstack', 'security');
const LOG_DIR = path.join(resolveStateRoot(), 'security');
const LOG_PATH = path.join(LOG_DIR, 'attempts.jsonl');
const RATE_CAP = 60; // writes per minute
const WINDOW_MS = 60_000;
+5
View File
@@ -58,6 +58,11 @@ beforeEach(() => {
join(import.meta.dir, '..', '..', 'bin', 'gstack-egress-lib.sh'),
join(binDir, 'gstack-egress-lib.sh'),
);
// Same for the state-root twin every migrated bin sources (docs/state-root.md).
symlinkSync(
join(import.meta.dir, '..', '..', 'bin', 'gstack-state-root.sh'),
join(binDir, 'gstack-state-root.sh'),
);
});
afterEach(() => {
+21 -25
View File
@@ -64,40 +64,36 @@ gstack_hook_decision() {
}
# gstack_hook_state_root
# Print the gstack state root, resolved with EXACTLY the chain bin/gstack-paths
# uses (GSTACK_STATE_ROOT): GSTACK_HOME, then CLAUDE_PLUGIN_DATA only when
# CLAUDE_PLUGIN_ROOT names gstack (a CLAUDE_PLUGIN_DATA leaked from another
# plugin via CLAUDE_ENV_FILE must not redirect our state), then $HOME/.gstack,
# then a project-local .gstack. Hooks run on every Edit/Bash call, so this is
# pure bash — never spawn gstack-paths from a hook. The writers (/freeze,
# /guard, /unfreeze, /investigate) resolve through gstack-paths; a reader that
# used a different chain failed OPEN whenever GSTACK_HOME was set (#1459).
# test/hook-scripts.test.ts pins parity against gstack-paths.
# Print the gstack state root: a thin wrapper over gstack_state_root from
# bin/gstack-state-root.sh, the one bash implementation of the chain
# bin/gstack-paths uses (docs/state-root.md). Hooks run on every Edit/Bash
# call, so the twin is pure bash — never spawn gstack-paths from a hook. The
# writers (/freeze, /guard, /unfreeze, /investigate) resolve through
# gstack-paths; a reader that used a different chain failed OPEN whenever
# GSTACK_HOME was set (#1459). test/hook-scripts.test.ts pins parity.
# Printed WITHOUT a trailing newline: callers capture with a sentinel
# (`r="$(gstack_hook_state_root; printf x)"; r="${r%x}"`) so a root that
# itself ends in a newline round-trips exactly as gstack-paths' %q does —
# otherwise writer and reader would again disagree on the directory.
# itself ends in a newline round-trips exactly as gstack-paths' %q does.
# When the twin is missing (partial upgrade) the wrapper is removed, so
# callers take their own fallback: careful asks, freeze fails closed.
gstack_hook_state_root() {
if [ -n "${GSTACK_HOME:-}" ]; then
printf '%s' "$GSTACK_HOME"
elif [ -n "${CLAUDE_PLUGIN_DATA:-}" ] && printf '%s' "${CLAUDE_PLUGIN_ROOT:-}" | grep -qi "gstack"; then
printf '%s' "$CLAUDE_PLUGIN_DATA"
elif [ -n "${HOME:-}" ]; then
printf '%s' "$HOME/.gstack"
else
printf '%s' ".gstack"
fi
gstack_state_root
}
_ghsr_twin="${BASH_SOURCE[0]%/*}/../../bin/gstack-state-root.sh"
if ! { [ -f "$_ghsr_twin" ] && . "$_ghsr_twin" 2>/dev/null; } || ! command -v gstack_state_root >/dev/null 2>&1; then
unset -f gstack_hook_state_root
fi
# gstack_hook_log_fire SKILL PATTERN
# Append a hook_fire analytics record (pattern name only, never command
# content). Respects GSTACK_HOME so tests never pollute the operator's real
# analytics file. Deliberately NOT gstack_hook_state_root: every other
# content) under the resolved state root, the same root every other
# analytics writer and reader (gstack-skill-start, gstack-retro-metrics,
# gstack-analytics) uses this two-step chain, and the usage log must stay one
# file. Best-effort: failures never affect the hook decision.
# gstack-analytics) uses, so the usage log stays one file and tests never
# pollute the operator's real analytics file. Best-effort: failures (or a
# missing twin) never affect the hook decision.
gstack_hook_log_fire() {
_ghlf_dir="${GSTACK_HOME:-$HOME/.gstack}/analytics"
command -v gstack_state_root >/dev/null 2>&1 || return 0
_ghlf_dir="$(gstack_state_root; printf x)"; _ghlf_dir="${_ghlf_dir%x}/analytics"
mkdir -p "$_ghlf_dir" 2>/dev/null || true
# Fields are JSON-encoded (a repo basename can carry quotes/backslashes) —
# same rule this file states for decisions: never raw-interpolate into JSON.
+3 -3
View File
@@ -10,8 +10,8 @@
import { execFileSync } from "child_process";
import fs from "fs";
import os from "os";
import path from "path";
import { resolveStateRoot } from "../../lib/state-root";
export interface DaemonState {
pid: number;
@@ -48,11 +48,11 @@ export function resolveLockFilePath(stateFile: string = resolveStateFilePath()):
}
export function resolveDaemonLogPath(): string {
return path.join(os.homedir(), ".gstack", "design-daemon.log");
return path.join(resolveStateRoot(), "design-daemon.log");
}
export function resolveStartupLogPath(): string {
return path.join(os.homedir(), ".gstack", "design-daemon-startup.log");
return path.join(resolveStateRoot(), "design-daemon-startup.log");
}
/**
+4
View File
@@ -3,6 +3,10 @@ set -euo pipefail
_here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
. "$_here/../../careful/bin/hook-extract.sh"
if ! command -v gstack_hook_state_root >/dev/null 2>&1; then
echo "FREEZE_ERROR: cannot resolve the gstack state root: $_here/../../bin/gstack-state-root.sh is missing. No boundary changed. Fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)." >&2
exit 1
fi
STATE_DIR="$(gstack_hook_state_root; printf x)"; STATE_DIR="${STATE_DIR%x}"
mkdir -p "$STATE_DIR"
STATE_DIR="$(cd "$STATE_DIR" && pwd -P && printf x)"; STATE_DIR="${STATE_DIR%$'\nx'}"
+2 -1
View File
@@ -11,7 +11,8 @@
# Affected: users who ran /office-hours before this version
set -euo pipefail
GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}"
. "${BASH_SOURCE[0]%/*}/../../bin/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: ${BASH_SOURCE[0]%/*}/../../bin/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
gstack_state_root_select; GSTACK_HOME="$_gstack_sr_root"
PROFILE_FILE="$GSTACK_HOME/builder-profile.jsonl"
# Find all per-project resource logs
+2 -1
View File
@@ -13,7 +13,8 @@
# Affected: every user on v0.19.x and below who upgrades to v1.x
set -euo pipefail
GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}"
. "${BASH_SOURCE[0]%/*}/../../bin/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: ${BASH_SOURCE[0]%/*}/../../bin/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
gstack_state_root_select; GSTACK_HOME="$_gstack_sr_root"
PROMPTED_FLAG="$GSTACK_HOME/.writing-style-prompted"
PENDING_FLAG="$GSTACK_HOME/.writing-style-prompt-pending"
+3 -1
View File
@@ -38,7 +38,9 @@ if [ "$SYNC_MODE" = "off" ] || [ -z "$SYNC_MODE" ]; then
fi
# Skip if no brain-sync git repo exists.
if [ ! -d "${HOME}/.gstack/.git" ]; then
. "${BASH_SOURCE[0]%/*}/../../bin/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: ${BASH_SOURCE[0]%/*}/../../bin/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
gstack_state_root_select
if [ ! -d "$_gstack_sr_root/.git" ]; then
exit 0
fi
+2 -1
View File
@@ -44,7 +44,8 @@ fi
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
GSTACK_HOME="${HOME}/.gstack"
. "${BASH_SOURCE[0]%/*}/../../bin/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: ${BASH_SOURCE[0]%/*}/../../bin/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
gstack_state_root_select; GSTACK_HOME="$_gstack_sr_root"
SKILLS_DIR="${HOME}/.claude/skills"
BIN_DIR="${SKILLS_DIR}/gstack/bin"
CONFIG_BIN="${BIN_DIR}/gstack-config"
+2 -1
View File
@@ -38,7 +38,8 @@ if [ -z "${HOME:-}" ]; then
exit 0
fi
GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}"
. "${BASH_SOURCE[0]%/*}/../../bin/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: ${BASH_SOURCE[0]%/*}/../../bin/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
gstack_state_root_select; GSTACK_HOME="$_gstack_sr_root"
MIGRATIONS_DIR="$GSTACK_HOME/.migrations"
DONE_TOUCH="$MIGRATIONS_DIR/v1.37.0.0.done"
CONFIG_BIN="$HOME/.claude/skills/gstack/bin/gstack-config"
+2 -1
View File
@@ -15,7 +15,8 @@
# still run. `set -u` is fine.
set -u
GSTACK_HOME="${HOME}/.gstack"
. "${BASH_SOURCE[0]%/*}/../../bin/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: ${BASH_SOURCE[0]%/*}/../../bin/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
gstack_state_root_select; GSTACK_HOME="$_gstack_sr_root"
ALLOWLIST="${GSTACK_HOME}/.brain-allowlist"
PRIVACY="${GSTACK_HOME}/.brain-privacy-map.json"
GITATTRS="${GSTACK_HOME}/.gitattributes"
+2 -1
View File
@@ -22,7 +22,8 @@
set -u
GSTACK_HOME="${HOME}/.gstack"
. "${BASH_SOURCE[0]%/*}/../../bin/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: ${BASH_SOURCE[0]%/*}/../../bin/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
gstack_state_root_select; GSTACK_HOME="$_gstack_sr_root"
ALLOWLIST="${GSTACK_HOME}/.brain-allowlist"
PRIVACY="${GSTACK_HOME}/.brain-privacy-map.json"
GITATTRS="${GSTACK_HOME}/.gitattributes"
+2 -1
View File
@@ -20,7 +20,8 @@
set -u
GSTACK_HOME="${HOME}/.gstack"
. "${BASH_SOURCE[0]%/*}/../../bin/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: ${BASH_SOURCE[0]%/*}/../../bin/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
gstack_state_root_select; GSTACK_HOME="$_gstack_sr_root"
MIGRATION_DIR="${GSTACK_HOME}/.migrations"
DONE="${MIGRATION_DIR}/v1.58.0.0.done"
mkdir -p "${MIGRATION_DIR}" 2>/dev/null || true
+2 -1
View File
@@ -34,7 +34,8 @@
set -u
GSTACK_HOME="${GSTACK_HOME:-${HOME}/.gstack}"
. "${BASH_SOURCE[0]%/*}/../../bin/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: ${BASH_SOURCE[0]%/*}/../../bin/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
gstack_state_root_select; GSTACK_HOME="$_gstack_sr_root"
MIGRATION_DIR="${GSTACK_HOME}/.migrations"
DONE="${MIGRATION_DIR}/v1.65.0.0.done"
# Written when a removal happened but the verified end state (a Chromium
+2 -1
View File
@@ -15,7 +15,8 @@
set -u
INSTALL_DIR="${GSTACK_INSTALL_DIR:-$HOME/.claude/skills/gstack}"
GH="${GSTACK_HOME:-$HOME/.gstack}"
. "${BASH_SOURCE[0]%/*}/../../bin/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: ${BASH_SOURCE[0]%/*}/../../bin/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
gstack_state_root_select; GH="$_gstack_sr_root"
mkdir -p "$GH" 2>/dev/null || exit 0
+2 -2
View File
@@ -7,13 +7,13 @@
import { readFile, writeFile, mkdir } from 'fs/promises';
import { join, dirname } from 'path';
import { homedir } from 'os';
import type { Allowlist, AllowlistEntry, Capability } from './types';
import { capabilityCovers } from './types';
import { resolveStateRoot } from '../../../lib/state-root';
export function defaultAllowlistPath(): string {
return process.env.GSTACK_IOS_ALLOWLIST_PATH
?? join(homedir(), '.gstack', 'ios-qa-allowlist.json');
?? join(resolveStateRoot(), 'ios-qa-allowlist.json');
}
export async function loadAllowlist(path: string = defaultAllowlistPath()): Promise<Allowlist> {
+4 -4
View File
@@ -3,28 +3,28 @@
import { mkdir, appendFile, stat, rename, readFile } from 'fs/promises';
import { join, dirname } from 'path';
import { homedir } from 'os';
import { createHash } from 'crypto';
import type { AuditRow, AttemptRow } from './types';
import { resolveStateRoot } from '../../../lib/state-root';
const MAX_BYTES = 10 * 1024 * 1024;
const MAX_GENS = 5;
export function defaultAuditPath(): string {
return process.env.GSTACK_IOS_AUDIT_PATH
?? join(homedir(), '.gstack', 'security', 'ios-qa-audit.jsonl');
?? join(resolveStateRoot(), 'security', 'ios-qa-audit.jsonl');
}
export function defaultAttemptsPath(): string {
return process.env.GSTACK_IOS_ATTEMPTS_PATH
?? join(homedir(), '.gstack', 'security', 'attempts.jsonl');
?? join(resolveStateRoot(), 'security', 'attempts.jsonl');
}
let _saltCache: string | null = null;
async function loadDeviceSalt(): Promise<string> {
if (_saltCache) return _saltCache;
const path = join(homedir(), '.gstack', 'security', 'device-salt');
const path = join(resolveStateRoot(), 'security', 'device-salt');
try {
_saltCache = (await readFile(path, 'utf-8')).trim();
} catch {
+2 -2
View File
@@ -8,8 +8,8 @@
import { readFile, mkdir, unlink } from 'fs/promises';
import { existsSync, openSync, writeSync, closeSync, unlinkSync } from 'fs';
import { join, dirname } from 'path';
import { homedir } from 'os';
import { spawn } from 'child_process';
import { resolveStateRoot } from '../../../lib/state-root';
export interface PidfileContents {
pid: number;
@@ -19,7 +19,7 @@ export interface PidfileContents {
export function defaultPidfilePath(): string {
return process.env.GSTACK_IOS_DAEMON_PIDFILE
?? join(homedir(), '.gstack', 'ios-qa-daemon.pid');
?? join(resolveStateRoot(), 'ios-qa-daemon.pid');
}
/**
+2 -2
View File
@@ -22,9 +22,9 @@
import { readFileSync, readdirSync, statSync, writeFileSync, mkdirSync, existsSync, copyFileSync, rmSync } from 'fs';
import { join, resolve, dirname } from 'path';
import { homedir } from 'os';
import { createHash } from 'crypto';
import { execSync } from 'child_process';
import { resolveStateRoot } from '../../lib/state-root';
export interface AccessorField {
name: string;
@@ -771,7 +771,7 @@ function detectBuildId(): string {
}
export function defaultCacheRoot(): string {
return process.env.GSTACK_IOS_CACHE_ROOT ?? join(homedir(), '.gstack', 'cache', 'gen-accessors');
return process.env.GSTACK_IOS_CACHE_ROOT ?? join(resolveStateRoot(), 'cache', 'gen-accessors');
}
export function generate(inputs: GenInputs): GenResult {
+2 -2
View File
@@ -7,8 +7,8 @@
import { spawnSync } from "child_process";
import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "fs";
import { homedir } from "os";
import { basename, dirname, join } from "path";
import { resolveStateRoot } from "./state-root";
/** Keep the slug inside the [a-zA-Z0-9._-] alphabet gstack-slug promises (`tr -cd`). */
function sanitizeSlug(s: string): string {
@@ -148,7 +148,7 @@ export function outermostRemoteRepo(startDir: string): { root: string; url: stri
* 4. Project root's basename; else basename(cwd) for plain non-project folders.
*/
export function slugFromEnvironment(gstackHome?: string, cwd: string = process.cwd()): string {
const home = gstackHome || process.env.GSTACK_HOME || join(homedir(), ".gstack");
const home = gstackHome || resolveStateRoot();
const cacheDir = join(home, "slug-cache");
const cacheFile = join(cacheDir, toMsysPath(cwd).replace(/\//g, "_"));
+2 -2
View File
@@ -12,11 +12,11 @@
*/
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
import { homedir } from "os";
import { dirname, join, resolve } from "path";
import { execFileSync } from "child_process";
import { hasRepoPolicyStore, repoPolicyTier } from "../gbrain-repo-policy-client";
import type { CodeProviderId, OpClass } from "./contract";
import { resolveStateRoot } from "../state-root";
export interface Selection {
provider: CodeProviderId | null;
@@ -31,7 +31,7 @@ export interface Selection {
const EMPTY: Selection = { provider: null, consents: {}, roots: {}, declined: false };
function storePath(env: NodeJS.ProcessEnv = process.env): string {
const home = env.GSTACK_HOME || join(env.HOME || homedir(), ".gstack");
const home = resolveStateRoot(env);
return join(home, "code-intelligence.json");
}
+3 -3
View File
@@ -4,6 +4,7 @@ import { randomBytes } from 'node:crypto';
import { atomicWriteSync } from '../fs-atomic';
import { CsoError, RunReportV3, canonical, completeness, fingerprint, renderReport, sha256 } from './contracts';
import { redact, sanitizeForJson, sanitizeHelperForJson } from './process';
import { resolveStateRoot } from '../state-root';
const MAX_STATE_FILE=1024*1024;
type ExactStats=Pick<fs.BigIntStats,'dev'|'ino'|'mtimeNs'|'ctimeNs'|'isFile'|'isSymbolicLink'|'isDirectory'> & Pick<fs.Stats,'nlink'|'size'|'mode'|'uid'>;
@@ -179,9 +180,8 @@ export function discardAtomicNoReplaceTemp(path:string,publisherPid:number,optio
}
export function stateRoot(env: Record<string,string|undefined> = process.env): string {
// Mirrors bin/gstack-paths. Security artifacts are intentionally outside every sync allowlist.
const userHome=env.HOME||(process.platform==='win32'?env.USERPROFILE:'');
return resolve(env.GSTACK_HOME || (env.CLAUDE_PLUGIN_ROOT?.toLowerCase().includes('gstack') ? env.CLAUDE_PLUGIN_DATA : '') || join(userHome || '.', '.gstack'));
// The shared chain (lib/state-root.ts), made absolute. Security artifacts are intentionally outside every sync allowlist.
return resolve(resolveStateRoot(env));
}
function ensureDirectory(path:string,hardenExistingLeaf:boolean):string{
const p=resolve(path),root=parse(p).root;
+3 -8
View File
@@ -26,7 +26,7 @@
import { createHash } from 'node:crypto';
import fs from 'node:fs';
import os from 'node:os';
import { resolveStateRoot } from './state-root';
import path from 'node:path';
export const EGRESS_RECEIPT_FAILED = 'EGRESS_RECEIPT_FAILED';
@@ -109,14 +109,9 @@ export interface VerifyResult {
sizeWarning: string | null;
}
/**
* Same resolution order as the rest of gstack (shell sinks, selection code):
* GSTACK_HOME, legacy GSTACK_STATE_DIR, then $HOME/.gstack.
*/
/** The shared state root (lib/state-root.ts), made absolute. */
export function resolveEgressHome(env: Env = process.env): string {
const configured = env.GSTACK_HOME || env.GSTACK_STATE_DIR;
if (configured) return path.resolve(configured);
return path.join(env.HOME || os.homedir(), '.gstack');
return path.resolve(resolveStateRoot(env));
}
export function egressLedgerPath(home: string): string {
+2 -4
View File
@@ -49,6 +49,7 @@ import { atomicWriteSync } from "./fs-atomic";
import { homedir } from "os";
import { dirname, join } from "path";
import { buildGbrainEnv, gbrainConfigDir, isExecTimeout, NEEDS_SHELL_ON_WINDOWS } from "./gbrain-exec";
import { resolveStateRoot } from "./state-root";
export type LocalEngineStatus =
| "ok"
@@ -116,10 +117,7 @@ function userHome(env?: NodeJS.ProcessEnv): string {
/** Cache path computed fresh on each call so tests can mutate GSTACK_HOME per case. */
export function cacheFilePath(): string {
return join(
process.env.GSTACK_HOME || join(userHome(), ".gstack"),
".gbrain-local-status-cache.json",
);
return join(resolveStateRoot(), ".gbrain-local-status-cache.json");
}
/**
+2 -2
View File
@@ -14,12 +14,12 @@
*/
import { join } from "path";
import { homedir } from "os";
import { randomUUID } from "crypto";
import { existsSync, readFileSync, appendFileSync, statSync, openSync, closeSync, unlinkSync } from "fs";
import { atomicWriteSync } from "./fs-atomic";
import { appendJsonl, readJsonl, hasInjection } from "./jsonl-store";
import { scan } from "./redact-engine";
import { resolveStateRoot } from "./state-root";
export type DecisionKind = "decide" | "supersede" | "redact";
export type DecisionScope = "repo" | "branch" | "issue";
@@ -57,7 +57,7 @@ export interface DecisionPaths {
/** Resolve the per-project decision store paths. Bins pass slug + GSTACK_HOME. */
export function decisionPaths(slug: string, gstackHome?: string): DecisionPaths {
const home = gstackHome || process.env.GSTACK_HOME || join(homedir(), ".gstack");
const home = gstackHome || resolveStateRoot();
const dir = join(home, "projects", slug || "unknown");
return {
log: join(dir, "decisions.jsonl"),
+3 -2
View File
@@ -24,7 +24,8 @@ import { appendJsonl } from "./jsonl-store";
import { gbrainConfigDir, isExecTimeout } from "./gbrain-exec";
import { dirname, join } from "path";
import { execFileSync } from "child_process";
import { homedir, tmpdir } from "os";
import { tmpdir } from "os";
import { resolveStateRoot } from "./state-root";
// ── Types ──────────────────────────────────────────────────────────────────
@@ -326,7 +327,7 @@ function redactMatch(s: string): string {
const ENGINE_CACHE_TTL_MS = 60 * 1000;
function gstackHome(): string {
return process.env.GSTACK_HOME || join(homedir(), ".gstack");
return resolveStateRoot();
}
function engineCachePath(): string {
+2 -1
View File
@@ -19,6 +19,7 @@ import * as os from "os";
import * as path from "path";
import { createHash } from "crypto";
import { appendJsonl } from "./jsonl-store";
import { resolveStateRoot } from "./state-root";
export interface SemanticReviewEntry {
ts: string;
@@ -30,7 +31,7 @@ export interface SemanticReviewEntry {
}
function securityDir(): string {
const home = process.env.GSTACK_HOME || path.join(os.homedir(), ".gstack");
const home = resolveStateRoot();
return path.join(home, "security");
}
+8 -4
View File
@@ -39,6 +39,13 @@ function legacyStateRoot(env: StateRootEnv, platform: NodeJS.Platform): string |
return home ? `${home}/.gstack` : null;
}
/** Roots merged privacy settings are read from: the resolved root, then $HOME/.gstack when different. */
export function mergedStateRoots(env: StateRootEnv = process.env, platform: NodeJS.Platform = process.platform): string[] {
const resolved = resolveStateRoot(env, platform);
const legacy = legacyStateRoot(env, platform);
return legacy && legacy !== resolved ? [resolved, legacy] : [resolved];
}
/** 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'],
@@ -75,11 +82,8 @@ export interface ConfigKeyReading {
* 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);
const candidates = order ? mergedStateRoots(env, platform) : [resolveStateRoot(env, platform)];
let best: ConfigKeyReading = { value: null, root: null };
let bestRank = Infinity;
for (const root of candidates) {
+2 -2
View File
@@ -13,7 +13,7 @@
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { resolveStateRoot } from '../lib/state-root';
export interface AnalyticsEvent {
skill: string;
@@ -23,7 +23,7 @@ export interface AnalyticsEvent {
pattern?: string;
}
const ANALYTICS_FILE = path.join(os.homedir(), '.gstack', 'analytics', 'skill-usage.jsonl');
const ANALYTICS_FILE = path.join(resolveStateRoot(), 'analytics', 'skill-usage.jsonl');
/**
* Parse JSONL content into AnalyticsEvent[], skipping malformed lines.
+2 -6
View File
@@ -22,9 +22,9 @@
*/
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { SIGNAL_MAP, type Dimension, ALL_DIMENSIONS } from './psychographic-signals';
import { resolveStateRoot } from '../lib/state-root';
const STRONG_HIGH = 0.7;
const STRONG_LOW = 0.3;
@@ -61,11 +61,7 @@ interface DeveloperProfile {
}
function stateRoot(): string {
return (
process.env.GSTACK_STATE_ROOT ||
process.env.GSTACK_HOME ||
path.join(os.homedir(), '.gstack')
);
return resolveStateRoot();
}
function readProfile(): DeveloperProfile | null {
+3 -2
View File
@@ -27,6 +27,7 @@ import type { HostConfig } from './host-config';
const ROOT = path.resolve(import.meta.dir, '..');
import { ALL_MODEL_NAMES, resolveModel, type Model } from './models';
import { resolveStateRoot } from '../lib/state-root';
type HostArg = Host | 'all';
@@ -75,7 +76,7 @@ export interface GenerationResult {
/** Canonical generation never reads local detection state unless opted in. */
function loadGbrainOverride(respectDetection: boolean): boolean {
if (!respectDetection) return false;
const stateDir = process.env.GSTACK_HOME || path.join(process.env.HOME || '', '.gstack');
const stateDir = resolveStateRoot();
try {
const json = JSON.parse(fs.readFileSync(path.join(stateDir, 'gbrain-detection.json'), 'utf-8'));
// Slow, remote, and locked engines are still usable (#1964/#2051/#2456).
@@ -1087,7 +1088,7 @@ export async function main(args = process.argv.slice(2)): Promise<number> {
}
if (!settings.dryRun) {
try {
const config = fs.readFileSync(path.join(process.env.HOME || '', '.gstack', 'config.yaml'), 'utf-8');
const config = fs.readFileSync(path.join(resolveStateRoot(), 'config.yaml'), 'utf-8');
if (/^skill_prefix:\s*true/m.test(config)) {
console.log('\nNote: skill_prefix is true. Run gstack-relink to re-apply name: patches (it patches both the install and any active gbrain render).');
}
+13 -8
View File
@@ -167,6 +167,9 @@ fi
INSTALL_GSTACK_DIR="$(cd "$(dirname "$0")" && pwd)"
SOURCE_GSTACK_DIR="$(cd "$(dirname "$0")" && pwd -P)"
# One state root for everything setup writes (bin/gstack-state-root.sh, docs/state-root.md).
. "$SOURCE_GSTACK_DIR/bin/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $SOURCE_GSTACK_DIR/bin/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
gstack_state_root_select; GSTACK_STATE_ROOT="$_gstack_sr_root"
INSTALL_SKILLS_DIR="$(dirname "$INSTALL_GSTACK_DIR")"
BROWSE_BIN="$SOURCE_GSTACK_DIR/browse/dist/browse"
CODEX_SKILLS="${CODEX_HOME:-$HOME/.codex}/skills"
@@ -308,7 +311,7 @@ _link_or_copy() {
# marker) means we created the entry and may delete or refresh it whole; WEAK
# (byte-identity with our source, or the two-line generated banner on a real
# file) covers only that SKILL.md — never the directory — and a differing
# weakly-proven file is moved to ${GSTACK_HOME:-~/.gstack}/backups/skills/<ts>/
# weakly-proven file is moved to $GSTACK_STATE_ROOT/backups/skills/<ts>/
# before we install over it. An entry is OURS
# when: it is a symlink resolving into the gstack payload / render dir (or any
# path with a `gstack` segment, the convention cleanup and gstack-uninstall
@@ -332,7 +335,7 @@ _gstack_target_is_ours() {
# $1 = absolute target path, $2 = gstack payload dir
local t="$1" g="$2" g_real render render_real
g_real="$(cd "$g" 2>/dev/null && pwd -P || printf '%s' "$g")"
render="${GSTACK_USER_RENDER_DIR:-${GSTACK_HOME:-$HOME/.gstack}/render/claude}"
render="${GSTACK_USER_RENDER_DIR:-$GSTACK_STATE_ROOT/render/claude}"
render_real="$(cd "$render" 2>/dev/null && pwd -P || printf '%s' "$render")"
case "$t" in
"$g"/*|"$g_real"/*|"$render"/*|"$render_real"/*|gstack/*|*/gstack/*|*/.gstack/render/claude/*) return 0 ;;
@@ -363,7 +366,7 @@ _claude_entry_is_ours() {
# A gbrain install serves the RENDERED file (link_claude_skill_dirs prefers
# it), so an exact copy of that render is ours too.
if [ -n "$src_md" ]; then
render_md="${GSTACK_USER_RENDER_DIR:-${GSTACK_HOME:-$HOME/.gstack}/render/claude}/$(basename "$(dirname "$src_md")")/SKILL.md"
render_md="${GSTACK_USER_RENDER_DIR:-$GSTACK_STATE_ROOT/render/claude}/$(basename "$(dirname "$src_md")")/SKILL.md"
[ -f "$render_md" ] && cmp -s "$entry/SKILL.md" "$render_md" && return 0
fi
_gstack_generated_header "$entry/SKILL.md" && return 0
@@ -389,7 +392,7 @@ _claude_entry_owned_strongly() {
}
# Weakly-proven real files we would otherwise overwrite are moved here (mv, so
# the path is free for the link); the final summary prints one line.
_SKILL_BACKUP_ROOT="${GSTACK_HOME:-$HOME/.gstack}/backups/skills/$(date +%Y%m%dT%H%M%S)"
_SKILL_BACKUP_ROOT="$GSTACK_STATE_ROOT/backups/skills/$(date +%Y%m%dT%H%M%S)"
_BACKED_UP_SKILL_MDS=()
_backup_skill_md() {
# Returns non-zero when the file could NOT be moved: the caller must then
@@ -1620,7 +1623,7 @@ link_claude_skill_dirs() {
# file's section-base paths point into the render dir, so section reads
# resolve there too.
_skill_md_src="$gstack_dir/$dir_name/SKILL.md"
_render_dir="${GSTACK_USER_RENDER_DIR:-${GSTACK_HOME:-$HOME/.gstack}/render/claude}"
_render_dir="${GSTACK_USER_RENDER_DIR:-$GSTACK_STATE_ROOT/render/claude}"
if [ -f "$_render_dir/$dir_name/SKILL.md" ]; then
_skill_md_src="$_render_dir/$dir_name/SKILL.md"
fi
@@ -2868,7 +2871,7 @@ fi
# Users who install gbrain after running ./setup should re-run setup OR
# call `gstack-config gbrain-refresh`.
DETECT_BIN="$SOURCE_GSTACK_DIR/bin/gstack-gbrain-detect"
GBRAIN_STATE_DIR="${GSTACK_HOME:-$HOME/.gstack}"
GBRAIN_STATE_DIR="$GSTACK_STATE_ROOT"
DETECTION_FILE="$GBRAIN_STATE_DIR/gbrain-detection.json"
_GSTACK_RENDER_DIR="${GSTACK_USER_RENDER_DIR:-$GBRAIN_STATE_DIR/render/claude}"
# PID-unique tmp so concurrent setups (parallel Conductor workspaces) can't
@@ -3290,8 +3293,10 @@ fi
# posture). An explicit answer is persisted and never re-asked; an explicit
# "false" is a recorded decline (adversarial review finding 11).
# `gstack-config get` defaults absent keys to "false", which is
# indistinguishable from a decline — test key presence in the config file.
_GSTACK_CFG_FILE="${GSTACK_HOME:-$HOME/.gstack}/config.yaml"
# indistinguishable from a decline — test key presence in the config file
# of the resolved state root (the twin beside gstack-config).
. "$(dirname "$GSTACK_CONFIG")/gstack-state-root.sh" 2>/dev/null && gstack_state_root_select
_GSTACK_CFG_FILE="${_gstack_sr_root:-$GSTACK_STATE_ROOT}/config.yaml"
if ! grep -q '^redact_prepush_hook:' "$_GSTACK_CFG_FILE" 2>/dev/null; then
if [ "$QUIET" -ne 1 ] && [ -t 0 ] && [ -t 1 ]; then
_REDACT_PROMPT_TIMEOUT=10
+1 -1
View File
@@ -50,7 +50,7 @@ beforeEach(() => {
env = { ...process.env, HOME: join(root, 'home'), GSTACK_HOME: join(root, 'state'), CLAUDE_PLUGIN_DATA: '', CLAUDE_PLUGIN_ROOT: '' };
mkdirSync(env.GSTACK_HOME!);
state = join(env.GSTACK_HOME!, 'freeze-dir.txt');
for (const file of ['freeze/bin/check-freeze.sh', 'freeze/bin/freeze-state.sh', 'careful/bin/hook-extract.sh', 'bin/gstack-paths']) {
for (const file of ['freeze/bin/check-freeze.sh', 'freeze/bin/freeze-state.sh', 'careful/bin/hook-extract.sh', 'bin/gstack-paths', 'bin/gstack-state-root.sh']) {
if (!existsSync(join(ROOT, file))) continue;
const dest = join(env.HOME!, '.claude/skills/gstack', file);
mkdirSync(dirname(dest), { recursive: true });
+1
View File
@@ -1935,6 +1935,7 @@ describe('BENEFITS_FROM resolver', () => {
fs.mkdirSync(cwd);
fs.copyFileSync(path.join(ROOT, 'bin/gstack-slug'), helper);
fs.chmodSync(helper, 0o755);
fs.copyFileSync(path.join(ROOT, 'bin/gstack-state-root.sh'), path.join(path.dirname(helper), 'gstack-state-root.sh'));
const expected = path.join(home, '.gstack/projects/canonical-override/session-unknown-design-current.md');
const wrong = path.join(home, '.gstack/projects/project/session-unknown-design-wrong.md');
for (const file of [expected, wrong]) {
+1
View File
@@ -168,6 +168,7 @@ describe('gstack-skill-start behavior', () => {
// Shadow the real bin dir by copying the script next to the poisoned tool.
fs.copyFileSync(START, path.join(fakeBin, 'gstack-skill-start'));
fs.chmodSync(path.join(fakeBin, 'gstack-skill-start'), 0o755);
fs.copyFileSync(path.join(path.dirname(START), 'gstack-state-root.sh'), path.join(fakeBin, 'gstack-state-root.sh'));
const out = execFileSync(path.join(fakeBin, 'gstack-skill-start'), ['--skill', 't'], {
timeout: 30_000,
encoding: 'utf-8',
+1
View File
@@ -52,6 +52,7 @@ export function createHealthEvalFixture(dir: string, repoRoot: string): HealthEv
fs.mkdirSync(path.join(dir, 'bin'), { recursive: true });
fs.copyFileSync(path.join(repoRoot, 'bin', 'gstack-slug'), path.join(dir, 'bin', 'gstack-slug'));
fs.chmodSync(path.join(dir, 'bin', 'gstack-slug'), 0o755);
fs.copyFileSync(path.join(repoRoot, 'bin', 'gstack-state-root.sh'), path.join(dir, 'bin', 'gstack-state-root.sh'));
// Only redirect installed paths. The workflow and score/history rules come
// from the real generated skill, without its unrelated shared preamble.
+8 -8
View File
@@ -1082,11 +1082,11 @@ describe('check-freeze.sh state-root resolution (#1459 / #1509)', () => {
// ============================================================
// gstack_hook_log_fire analytics sink follows the same state root (#1459)
// ============================================================
// The hook_fire record lands under ${GSTACK_HOME:-$HOME/.gstack}/analytics —
// the SAME two-step chain every other analytics writer and reader uses
// (gstack-skill-start, gstack-retro-metrics, gstack-analytics) — deliberately
// NOT the plugin-aware state root the freeze FILE uses, so the usage log stays
// one file. Logging is best-effort: an unwritable sink never changes the decision.
// The hook_fire record lands under the resolved state root (bin/gstack-state-root.sh)
// — the SAME root the freeze FILE and every other analytics writer and reader
// (gstack-skill-start, gstack-retro-metrics, gstack-analytics) use, so the usage
// log stays one file. Logging is best-effort: an unwritable sink never changes
// the decision.
describe('gstack_hook_log_fire writes under the resolved state root', () => {
const BOUNDARY = '/Users/dev/project/src/';
const OUTSIDE = '/Users/dev/other-project/index.ts';
@@ -1114,16 +1114,16 @@ describe('gstack_hook_log_fire writes under the resolved state root', () => {
});
});
test('plugin install: the freeze FILE is read from CLAUDE_PLUGIN_DATA but hook_fire still lands under $HOME/.gstack/analytics (one usage log)', () => {
test('plugin install: the freeze FILE and hook_fire both use CLAUDE_PLUGIN_DATA (one state root, one usage log)', () => {
withFreezeDir(BOUNDARY, (pluginData) => {
withEmptyDir((fakeHome) => {
const { output } = runHook(FREEZE_SCRIPT, freezeInput(OUTSIDE),
freezeEnv(pluginData, { HOME: fakeHome, CLAUDE_PLUGIN_ROOT: '/Plugins/GSTACK' }));
expect(output.hookSpecificOutput?.permissionDecision).toBe('deny');
const rec = lastRecord(path.join(fakeHome, '.gstack', 'analytics', 'skill-usage.jsonl'));
const rec = lastRecord(path.join(pluginData, 'analytics', 'skill-usage.jsonl'));
expect(rec.event).toBe('hook_fire');
expect(rec.skill).toBe('freeze');
expect(fs.existsSync(path.join(pluginData, 'analytics'))).toBe(false);
expect(fs.existsSync(path.join(fakeHome, '.gstack', 'analytics'))).toBe(false);
});
});
});
+2
View File
@@ -48,6 +48,8 @@ function copyIntoFakeInstall(workDir: string): { root: string; launcher: string
copyFileSync(join(ROOT, 'bin', 'gstack-ios-qa-regen'), launcher);
chmodSync(launcher, 0o755);
copyFileSync(join(ROOT, 'ios-qa', 'scripts', 'gen-accessors.ts'), join(scriptsDir, 'gen-accessors.ts'));
mkdirSync(join(root, 'lib'), { recursive: true });
copyFileSync(join(ROOT, 'lib', 'state-root.ts'), join(root, 'lib', 'state-root.ts'));
for (const [template] of SAFE_TEMPLATE_MAP) {
copyFileSync(join(ROOT, 'ios-qa', 'templates', template), join(templatesDir, template));
}
+3 -2
View File
@@ -43,6 +43,7 @@ function scaffoldFixture(workDir: string): { workDir: string; stateRoot: string;
'gstack-codex-session-import',
'gstack-distill-free-text',
'gstack-distill-apply',
'gstack-state-root.sh',
]) {
const src = path.join(ROOT, 'bin', script);
if (fs.existsSync(src)) {
@@ -65,10 +66,10 @@ function scaffoldFixture(workDir: string): { workDir: string; stateRoot: string;
if (fs.existsSync(src)) fs.copyFileSync(src, path.join(scriptsDir, f));
}
// Both imports are resolved relative to the copied install, not the source checkout.
// These imports are resolved relative to the copied install, not the source checkout.
const libDir = path.join(workDir, 'lib');
fs.mkdirSync(libDir, { recursive: true });
for (const file of ['jsonl-store.ts', 'is-conductor.ts']) {
for (const file of ['jsonl-store.ts', 'is-conductor.ts', 'state-root.ts']) {
fs.copyFileSync(path.join(ROOT, 'lib', file), path.join(libDir, file));
}
+2
View File
@@ -56,6 +56,8 @@ function setupMockInstall(skills: string[]): void {
fs.mkdirSync(mockBin, { recursive: true });
fs.copyFileSync(path.join(BIN, 'gstack-config'), path.join(mockBin, 'gstack-config'));
fs.chmodSync(path.join(mockBin, 'gstack-config'), 0o755);
// A real install always ships the state-root twin beside the bins that source it.
fs.copyFileSync(path.join(BIN, 'gstack-state-root.sh'), path.join(mockBin, 'gstack-state-root.sh'));
if (fs.existsSync(path.join(BIN, 'gstack-relink'))) {
fs.copyFileSync(path.join(BIN, 'gstack-relink'), path.join(mockBin, 'gstack-relink'));
fs.chmodSync(path.join(mockBin, 'gstack-relink'), 0o755);
+1 -1
View File
@@ -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']) {
for (const bin of ['gstack-config', 'gstack-patch-names', 'gstack-relink', 'gstack-migrate-claude-code', 'gstack-claude-code', 'gstack-state-root.sh']) {
const target = path.join(root, 'bin', bin);
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.copyFileSync(path.join(ROOT, 'bin', bin), target);
+1 -1
View File
@@ -219,7 +219,7 @@ Report the exact output — either "READY: <path>" or "NEEDS_SETUP".`,
// identically on main).
const binDir = path.join(opDir, 'bin');
fs.mkdirSync(binDir, { recursive: true });
for (const script of ['gstack-learnings-log', 'gstack-slug']) {
for (const script of ['gstack-learnings-log', 'gstack-slug', 'gstack-state-root.sh']) {
fs.copyFileSync(path.join(ROOT, 'bin', script), path.join(binDir, script));
fs.chmodSync(path.join(binDir, script), 0o755);
}
+1 -1
View File
@@ -59,7 +59,7 @@ function setupWorkdir(suffix: string): { workDir: string; gstackHome: string; sl
const binDir = path.join(workDir, 'bin');
fs.mkdirSync(binDir, { recursive: true });
for (const script of [
'gstack-timeline-log', 'gstack-timeline-read', 'gstack-slug',
'gstack-timeline-log', 'gstack-timeline-read', 'gstack-slug', 'gstack-state-root.sh',
'gstack-learnings-log', 'gstack-learnings-search',
'gstack-update-check', 'gstack-config', 'gstack-repo-mode',
]) {
+1 -1
View File
@@ -40,7 +40,7 @@ describeIfSelected('Learnings E2E', ['learnings-show'], () => {
// Copy bin scripts needed by /learn
const binDir = path.join(workDir, 'bin');
fs.mkdirSync(binDir, { recursive: true });
for (const script of ['gstack-learnings-search', 'gstack-learnings-log', 'gstack-slug']) {
for (const script of ['gstack-learnings-search', 'gstack-learnings-log', 'gstack-slug', 'gstack-state-root.sh']) {
fs.copyFileSync(path.join(ROOT, 'bin', script), path.join(binDir, script));
fs.chmodSync(path.join(binDir, script), 0o755);
}
+1
View File
@@ -50,6 +50,7 @@ describeIfSelected('PlanTune E2E', ['plan-tune-inspect'], () => {
for (const script of [
'gstack-slug',
'gstack-config',
'gstack-state-root.sh',
'gstack-question-log',
'gstack-question-preference',
'gstack-developer-profile',
+1 -1
View File
@@ -44,7 +44,7 @@ describeIfSelected('Session Intelligence E2E', [
const binDir = path.join(workDir, 'bin');
fs.mkdirSync(binDir, { recursive: true });
for (const script of [
'gstack-timeline-log', 'gstack-timeline-read', 'gstack-slug',
'gstack-timeline-log', 'gstack-timeline-read', 'gstack-slug', 'gstack-state-root.sh',
'gstack-learnings-log', 'gstack-learnings-search',
]) {
const src = path.join(ROOT, 'bin', script);
+1 -1
View File
@@ -96,7 +96,7 @@ function setupSkillifyWorkdir(suffix: string, installSkills: string[] = ['scrape
const binDir = path.join(workDir, 'bin');
fs.mkdirSync(binDir, { recursive: true });
for (const script of [
'gstack-timeline-log', 'gstack-slug', 'gstack-config',
'gstack-timeline-log', 'gstack-slug', 'gstack-config', 'gstack-state-root.sh',
'gstack-update-check', 'gstack-repo-mode',
'gstack-learnings-log', 'gstack-learnings-search',
]) {
+68
View File
@@ -0,0 +1,68 @@
/**
* One variable moves every store (W1). With only GSTACK_HOME, only
* GSTACK_STATE_ROOT or only GSTACK_STATE_DIR pointing at a temp dir, the
* config, egress ledger, trust-policy store, telemetry and analytics log, hook
* error log, and the root uninstall reports all resolve under that directory,
* and nothing lands in $HOME/.gstack.
*/
import { describe, test, expect } 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 } from '../lib/state-root';
const ROOT = path.resolve(import.meta.dir, '..');
const BIN = path.join(ROOT, 'bin');
for (const variable of ['GSTACK_HOME', 'GSTACK_STATE_ROOT', 'GSTACK_STATE_DIR'] as const) {
describe(`only ${variable} set`, () => {
test('every store resolves under it', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-one-var-'));
try {
const root = path.join(tmp, 'root');
const home = path.join(tmp, 'home');
const cwd = path.join(tmp, 'cwd');
for (const d of [root, home, cwd]) fs.mkdirSync(d, { recursive: true });
const env: Record<string, string> = {
...(process.env as Record<string, string>),
HOME: home, GSTACK_HOME: '', GSTACK_STATE_ROOT: '', GSTACK_STATE_DIR: '', CLAUDE_PLUGIN_DATA: '',
GSTACK_TEST_LEGACY_ROOT: '', [variable]: root,
};
expect(resolveStateRoot(env)).toBe(root);
const run = (cmd: string, args: string[], input?: string) =>
spawnSync(cmd, args, { cwd, env, input, encoding: 'utf-8', timeout: 30_000 });
// config
expect(run('bash', [path.join(BIN, 'gstack-config'), 'set', 'telemetry', 'anonymous']).status).toBe(0);
expect(fs.readFileSync(path.join(root, 'config.yaml'), 'utf-8')).toContain('telemetry: anonymous');
// telemetry + analytics log
expect(run('bash', [path.join(BIN, 'gstack-telemetry-log'), '--skill', 'qa', '--duration', '1', '--outcome', 'success', '--session-id', 'one-var-1', '--no-sweep']).status).toBe(0);
expect(fs.readFileSync(path.join(root, 'analytics', 'skill-usage.jsonl'), 'utf-8')).toContain('"skill":"qa"');
// egress ledger
const receipt = run('bun', [path.join(BIN, 'gstack-egress-receipt'), 'write', '--sink', 'test', '--host', 'example.invalid', '--class', 'test', '--no-payload', '--consent', 'test']);
expect(receipt.status).toBe(0);
expect(fs.existsSync(path.join(root, 'security', 'egress.jsonl'))).toBe(true);
// trust-policy store
expect(run('bash', [path.join(BIN, 'gstack-gbrain-repo-policy'), 'set', 'https://github.com/a/b', 'deny']).status).toBe(0);
expect(fs.existsSync(path.join(root, 'gbrain-repo-policy.json'))).toBe(true);
// hook error log
const hook = run('bash', [path.join(ROOT, 'hosts', 'claude', 'hooks', 'question-log-hook')], '{not json');
expect(hook.status).toBe(0);
expect(fs.readFileSync(path.join(root, 'hook-errors.log'), 'utf-8')).toContain('question-log-hook');
// uninstall target: a non-default root is reported, never deleted
const skillsGstack = path.join(home, '.claude', 'skills', 'gstack');
fs.mkdirSync(path.dirname(skillsGstack), { recursive: true });
const un = run('bash', [path.join(BIN, 'gstack-uninstall'), '--force']);
expect(un.status).toBe(0);
expect(un.stderr).toContain(`left in place: ${root} (selected by ${variable}=${root})`);
expect(un.stderr).toContain(`fix: after checking it, remove it with rm -rf -- '${root}'`);
expect(fs.existsSync(path.join(root, 'config.yaml'))).toBe(true);
expect(fs.existsSync(path.join(home, '.gstack'))).toBe(false);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
});
}
+1 -1
View File
@@ -351,7 +351,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', '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', '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);
+68 -6
View File
@@ -258,7 +258,7 @@ describe('hook cleanup runs before the install root is deleted', () => {
fs.mkdirSync(installBin, { recursive: true });
// The installed copies — the uninstaller under test IS the one inside
// the root it deletes.
for (const b of ['gstack-uninstall', 'gstack-settings-hook', 'gstack-session-update', 'gstack-config']) {
for (const b of ['gstack-uninstall', 'gstack-settings-hook', 'gstack-session-update', 'gstack-config', 'gstack-state-root.sh']) {
const src = path.join(ROOT, 'bin', b);
const dst = path.join(installBin, b);
fs.copyFileSync(src, dst);
@@ -317,7 +317,7 @@ describe('the Memorable bridge hook is removed by name and the kept config is le
const installRoot = path.join(mockHome, '.claude', 'skills', 'gstack');
const installBin = path.join(installRoot, 'bin');
fs.mkdirSync(installBin, { recursive: true });
for (const b of ['gstack-uninstall', 'gstack-settings-hook', 'gstack-session-update', 'gstack-config']) {
for (const b of ['gstack-uninstall', 'gstack-settings-hook', 'gstack-session-update', 'gstack-config', 'gstack-state-root.sh']) {
const dst = path.join(installBin, b);
fs.copyFileSync(path.join(ROOT, 'bin', b), dst);
fs.chmodSync(dst, 0o755);
@@ -360,7 +360,7 @@ describe('the Memorable arm stays quiet when nothing of its is registered', () =
const installRoot = path.join(mockHome, '.claude', 'skills', 'gstack');
const installBin = path.join(installRoot, 'bin');
fs.mkdirSync(installBin, { recursive: true });
for (const b of ['gstack-uninstall', 'gstack-settings-hook', 'gstack-session-update', 'gstack-config']) {
for (const b of ['gstack-uninstall', 'gstack-settings-hook', 'gstack-session-update', 'gstack-config', 'gstack-state-root.sh']) {
const dst = path.join(installBin, b);
fs.copyFileSync(path.join(ROOT, 'bin', b), dst);
fs.chmodSync(dst, 0o755);
@@ -392,7 +392,7 @@ describe('hook cleanup under lock contention is loud, never silent (review-army)
const installRoot = path.join(mockHome, '.claude', 'skills', 'gstack');
const installBin = path.join(installRoot, 'bin');
fs.mkdirSync(installBin, { recursive: true });
for (const b of ['gstack-uninstall', 'gstack-settings-hook', 'gstack-session-update', 'gstack-config']) {
for (const b of ['gstack-uninstall', 'gstack-settings-hook', 'gstack-session-update', 'gstack-config', 'gstack-state-root.sh']) {
const dst = path.join(installBin, b);
fs.copyFileSync(path.join(ROOT, 'bin', b), dst);
fs.chmodSync(dst, 0o755);
@@ -449,7 +449,7 @@ describe('the consent key never outlives the hook, even when the config lives ou
const installBin = path.join(installRoot, 'bin');
fs.mkdirSync(installBin, { recursive: true });
fs.mkdirSync(otherRoot, { recursive: true });
for (const b of ['gstack-uninstall', 'gstack-settings-hook', 'gstack-session-update', 'gstack-config']) {
for (const b of ['gstack-uninstall', 'gstack-settings-hook', 'gstack-session-update', 'gstack-config', 'gstack-state-root.sh']) {
const dst = path.join(installBin, b);
fs.copyFileSync(path.join(ROOT, 'bin', b), dst);
fs.chmodSync(dst, 0o755);
@@ -480,7 +480,7 @@ describe('the consent flip does not depend on the hook manager being present', (
const installRoot = path.join(mockHome, '.claude', 'skills', 'gstack');
const installBin = path.join(installRoot, 'bin');
fs.mkdirSync(installBin, { recursive: true });
for (const b of ['gstack-uninstall', 'gstack-config']) { // no settings hook, no session-update
for (const b of ['gstack-uninstall', 'gstack-config', 'gstack-state-root.sh']) { // no settings hook, no session-update
const dst = path.join(installBin, b);
fs.copyFileSync(path.join(ROOT, 'bin', b), dst);
fs.chmodSync(dst, 0o755);
@@ -499,3 +499,65 @@ describe('the consent flip does not depend on the hook manager being present', (
}
});
});
describe('state-root safety: only the default root is deleted (docs/state-root.md)', () => {
let tmp: string, mockHome: string, installBin: string, installRoot: string;
beforeEach(() => {
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-uninstall-root-'));
mockHome = path.join(tmp, 'home');
installRoot = path.join(mockHome, '.claude', 'skills', 'gstack');
installBin = path.join(installRoot, 'bin');
fs.mkdirSync(installBin, { recursive: true });
for (const b of ['gstack-uninstall', 'gstack-config', 'gstack-state-root.sh']) {
fs.copyFileSync(path.join(ROOT, 'bin', b), path.join(installBin, b));
fs.chmodSync(path.join(installBin, b), 0o755);
}
});
afterEach(() => fs.rmSync(tmp, { recursive: true, force: true }));
function uninstall(args: string[], env: Record<string, string>, input?: string) {
return spawnSync('bash', [path.join(installBin, 'gstack-uninstall'), ...args], {
cwd: tmp, input, encoding: 'utf-8', timeout: 30_000,
env: { ...process.env, HOME: mockHome, GSTACK_SETTINGS_FILE: path.join(mockHome, 'settings.json'),
GSTACK_HOME: '', GSTACK_STATE_ROOT: '', GSTACK_STATE_DIR: '', CLAUDE_PLUGIN_DATA: '', ...env } as Record<string, string>,
});
}
for (const mode of ['--force', 'interactive'] as const) {
test(`a custom GSTACK_HOME root is left in place with the removal command (${mode}); $HOME/.gstack goes`, () => {
const custom = path.join(tmp, 'custom-state');
fs.mkdirSync(path.join(custom, 'projects'), { recursive: true });
fs.mkdirSync(path.join(mockHome, '.gstack'), { recursive: true });
const r = mode === '--force' ? uninstall(['--force'], { GSTACK_HOME: custom }) : uninstall([], { GSTACK_HOME: custom }, 'y\n');
expect(r.status).toBe(0);
expect(r.stderr).toContain(`left in place: ${custom} (selected by GSTACK_HOME=${custom})`);
expect(r.stderr).toContain(`fix: after checking it, remove it with rm -rf -- '${custom}'`);
expect(r.stderr).toContain('docs/state-root.md');
expect(fs.existsSync(path.join(custom, 'projects'))).toBe(true);
expect(fs.existsSync(path.join(mockHome, '.gstack'))).toBe(false);
if (mode === 'interactive') expect(r.stdout).toContain(`(kept) ${custom}`);
});
}
for (const [label, target] of [['$HOME', () => mockHome], ['/', () => '/'], ['the gstack checkout', () => installRoot]] as const) {
test(`$HOME/.gstack symlinked to ${label} is refused with exit 2 and a fix line`, () => {
fs.symlinkSync(target(), path.join(mockHome, '.gstack'));
const r = uninstall(['--force'], {});
expect(r.status).toBe(2);
expect(r.stderr).toContain(`gstack-uninstall: refusing to delete ${path.join(mockHome, '.gstack')}: `);
expect(r.stderr).toMatch(/^fix: .*docs\/state-root\.md/m);
expect(fs.existsSync(path.join(installBin, 'gstack-uninstall'))).toBe(true);
expect(fs.lstatSync(path.join(mockHome, '.gstack')).isSymbolicLink()).toBe(true);
});
}
test('--keep-state deletes nothing and never refuses', () => {
const custom = path.join(tmp, 'custom-state');
fs.mkdirSync(custom, { recursive: true });
fs.symlinkSync(mockHome, path.join(mockHome, '.gstack'));
const r = uninstall(['--force', '--keep-state'], { GSTACK_HOME: custom });
expect(r.status).toBe(0);
expect(r.stderr).not.toContain('left in place:');
expect(fs.existsSync(custom)).toBe(true);
expect(fs.lstatSync(path.join(mockHome, '.gstack')).isSymbolicLink()).toBe(true);
});
});
+1
View File
@@ -173,6 +173,7 @@ describe('link_claude_skill_dirs prefers rendered SKILL.md (behavior)', () => {
'SKILL_PREFIX=0',
'_WINDOWS_COPY_NOTE_PRINTED=1',
`GSTACK_HOME="${home}"`,
`GSTACK_STATE_ROOT="${home}"`,
extractFn(SETUP_SRC, '_link_or_copy'),
extractFn(SETUP_SRC, '_print_windows_copy_note_once'),
extractFn(SETUP_SRC, '_link_skill_runtime_assets'),