mirror of
https://github.com/garrytan/gstack.git
synced 2026-08-23 14:32:33 +02:00
* fix(settings-hook): KNOWN_HOOKS identity healer — per-item ownership, mutation lock, fail-closed parse
Claude Code strips the unknown _gstack_source key when it rewrites
settings.json, so tag-based dedupe degraded to exact-command equality and
every Conductor worktree's setup appended a fresh hook entry; deleted
worktrees left dead hooks erroring on every AskUserQuestion fire.
- KNOWN_HOOKS identity table (shared JS prelude, single source of truth):
ownership is intrinsic and PER HOOK ITEM — basename + relpath suffix +
event (+ matcher where defined). Tags never claim foreign items.
- New `prune-stale [--repoint <root>] [--all]`: prune dead gstack items,
re-point survivors at the stable install (tag restore from the table),
exact-duplicate collapse, uninstall/no-team identity sweep. Explicit
plan_tune_hooks:no is honored (dead pruned, live never re-pointed).
- add-event / remove-source become item-aware: replace/remove only the owned
item; a user's co-located hook in the same entry is never collateral.
- Mutation safety: mkdir lock with owner token, ownership-checked release,
atomic stale takeover; per-process-unique tmp + backup names;
backup-on-change everywhere; fail-closed on parse failure (a corrupt
settings.json is never overwritten — previously catch{} clobbered it);
locked atomic rollback.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(gstack-config): `has <key>` — key-presence provenance through STATE_DIR resolution
`get` returns the DEFAULTS value for absent keys, so callers that need to
know whether the USER decided something (vs inherited a default) had no
correct primitive — setup's consent logic was about to grep a hardcoded
~/.gstack/config.yaml, which misclassifies under GSTACK_STATE_ROOT /
GSTACK_HOME / GSTACK_STATE_DIR overrides. `has` exits 0 iff the key is
literally present in the resolved config file, with the same C-locale key
validation as get/set.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(setup): canonical-only hook registration, heal-first, PT_EXPLICIT consent provenance
Three root causes of the phantom-AskUserQuestion-hooks class, all in the
registration path:
- Bug A: the Conductor auto-opt-in upgraded PT_DECISION "prompt" -> "yes"
even when "prompt" was dev-setup's EXPLICIT --plan-tune-hooks=prompt pin,
so every new Conductor workspace installed hooks. PT_EXPLICIT (flag/env/
config-key-presence via `gstack-config has`) now gates the auto-opt-in to
the true silent fall-through.
- Bug B: hook commands were baked from $SOURCE_GSTACK_DIR (`pwd -P` of the
running tree — ephemeral for worktrees). Registration is now CANONICAL-ONLY
via _hook_command_path (${CLAUDE_CONFIG_DIR:-$HOME/.claude}/skills/gstack);
missing canonical hook = skip + log, never a baked tree path. SessionStart
moves to schema-aware add-event under its identity source; whitespace paths
are quoted.
- Bug C: nothing ever pruned, and dead tagged entries blocked the
"already installed" guards forever. Setup now heals FIRST on every run
(prune-stale --repoint at the stable install), surfaces a one-line summary
only when something changed, surfaces the plan_tune_hooks:no-vs-live-hooks
contradiction, and --no-team tears down all three sources plus an identity
sweep for untagged strays.
dev-setup's no-mutation guarantee gains its stated repair exception (prune
dead / re-point existing, never ADD).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(uninstall): run hook cleanup BEFORE install-root deletion + full identity sweep
SETTINGS_HOOK resolves via $(dirname "$0") INSIDE the install root, but the
cleanup ran after `rm -rf ~/.claude/skills/gstack` — a real global uninstall
(running the installed copy) silently no-op'd and orphaned every hook entry.
Tests masked it by running the uninstaller from the repo checkout.
The relocated block also removes the auq-error-fallback source (registered by
setup, previously never torn down) and finishes with a prune-stale --all
identity sweep so untagged strays (Claude Code strips _gstack_source) go too.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test: phantom-hooks heal coverage — incident facsimile, per-item safety, lock, canonical tripwires
- gstack-settings-hook-schema-aware: 16 new cases — identity re-point (tag
restore), foreign-basename rejection, mixed-entry per-item safety for
add-event/remove-source/--all, prune-stale modes incl. bash-prefix +
Windows-backslash + spaced-path idempotence, duplicate collapse preferring
the tagged twin, plan_tune_hooks:no split, backup-on-change no-churn,
fail-closed corrupt-JSON for every mutator, stale-lock takeover,
fresh-foreign-lock skip, two-writer concurrency smoke, and an INCIDENT
FACSIMILE replaying the exact 2026-08-17 production damage (6/3/2 entries,
mixed tags, live-ephemeral Stop) healing to 2/1/1 canonical.
- NEW setup-hook-canonical-paths: static tripwires — canonical-only resolver
(no $SOURCE_GSTACK_DIR anywhere in it), heal-before-guards ordering,
unsuppressed heal output, ${VAR:-0} counter idiom, shared-prelude
concatenation at every bun call site, KNOWN_HOOKS completeness vs setup's
registrations, uninstall cleanup-before-deletion ordering, defect-class
warning present.
- setup-plan-tune-hooks-noninteractive: PT_EXPLICIT pins + `gstack-config
has` provenance + has-subcommand behavior (env-resolution, malformed keys).
- auq-error-fallback-hook: registration + both-teardown wiring (previously
untested).
- uninstall: behavioral ordering test running the INSTALLED copy from inside
the root it deletes.
- setup-windows-fallback / gstack-config-key-locale: pins updated for the new
HOOK_CMD shape and the third C-locale validator.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(test): banner-tripwire exec used JSON.stringify as shell quoting — vacuous pass + stray artifact
JSON escaping is not shell escaping. Interpolating JSON.stringify(script)
into `bash -c ${...}` left every JSON "\n" as a literal backslash-n inside
shell double quotes, collapsing the extracted release-body tripwire block
onto one line: `then\n` parsed as the command word `thenn`, and
`>&2\nelse\n` parsed as the redirect `>&2nelsen` — so every full-suite run
littered a `2nelsen` file (containing "bash: thenn: command not found") in
the repo root, and the test's single not-contains assertion passed
VACUOUSLY because all output had been redirected into that file. The
"and it actually fires" functional check never verified anything.
Fix: pass the script as an argv element (spawnSync array form) and assert
both branches for real — ABORT case must print the leak message to stderr,
clean case must print "banner tripwire clean" to stdout.
Verified: `bun test test/binding-template-drift.test.ts` previously created
the artifact deterministically; the full free suite now runs artifact-free.
The other shell-interpolation sites (evidence, schema-aware concurrency,
empty-find-fallthrough, branch-slug-hygiene) already use correct quoting.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test: regression pin for legacy remove mixed-entry filtering + ownership negatives
Coverage-audit iron rule: the rewritten legacy `remove` action filters
per-item (pre-v1.67.2 it dropped the whole entry, destroying a user's
co-located SessionStart hook) — modified existing behavior, previously
untested. Also pins two ownership negatives: an owned basename+relpath under
the WRONG matcher stays foreign, and prune-stale on an absent settings file
exits 0 with removed 0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: pre-landing review fixes — review-army findings hardened
Specialist review (testing, maintainability, security, performance,
data-migration) findings, each verified against code before fixing:
- legacy remove: preserve malformed/foreign entries (hooks absent, non-array,
or pre-existing empty) — only entries THIS pass emptied are dropped
- add-event: never tag a mixed entry (old gstack versions in sibling
worktrees treat tags as entry-level ownership and would destroy the user's
co-located items); tag only single-item entries; prune-stale drops tags
from mixed entries for the same reason
- prune-stale: within-entry twin collapse (two dead copies of one hook
re-pointed to the same canonical command no longer double-fire); command
quoting hardened via gsQuoteCmd (escapes \\ " $ backtick; gsStripWrap
unescapes so identity round-trips); NUL bytes in the dedupe key replaced
with a JSON.stringify key (bash silently dropped the NULs, degrading the
separator; the file also read as binary to tooling)
- gsIsAlive: only provable absence (ENOENT/ENOTDIR) counts as dead —
EACCES/EIO/unmounted volumes no longer prune (one-way-ratchet guard)
- gsWriteIfChanged: preserves the live settings.json mode across rewrites
(a user-tightened 0600 carrying API keys was silently broadened to 0644);
fresh files start 0600; backups rotate (keep 10)
- remove-source: command-less items default to foreign (gstack only writes
type:command items); single-item stray claim requires a command
- rollback: pointer target must be a sibling settings.json.bak.* file
- uninstall + setup --no-team + SessionStart registration: stderr stays
attached — a lock give-up or fail-closed parse during TEARDOWN must be
visible ("the next setup retries" does not apply after uninstall)
- setup: team-mode banner no longer claims an auto-update hook when
registration was skipped; heal log documents the rollback-pointer caveat;
SESSION_UPDATE_CMD quoting mirrors gsQuoteCmd; lock constants named
- list-sources: corrupt settings.json reports to stderr instead of silently
printing nothing (setup guards must not misread corrupt as no-hooks)
- tests: 10 new pins (malformed-entry preservation, mixed no-tag, twin
collapse, 0600 mode, metachar escaping round-trip, backup rotation,
rollback pointer refusal, held-lock uninstall warning, matcher-drift
tripwire, ownership negatives)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: red-team findings — verify-gate identity, single quoting authority, Windows paths
Red-team pass over the hardened diff (several findings empirically verified
by the reviewer before reporting):
- KNOWN_HOOKS gains the sixth identity: gstack-verify-gate (README-documented
opt-in Stop hook). A tag-stripped verify-gate entry previously survived
prune-stale --all and errored at the end of EVERY turn after uninstall
deleted the install root — the exact phantom-hook class this branch fixes.
Uninstall also sweeps its tagged form.
- add-event is now the single quoting authority: every registered command is
normalized through the same gsQuoteCmd/gsStripWrap round-trip the healer
uses. Pre-fix, only SessionStart got caller-side quoting — a spaced/metachar
canonical root registered broken plan-tune/AUQ/timeline hooks that the very
next heal rewrote (the codebase disagreed with its own registrations).
- Windows: MSYS-form paths (/c/Users/...) are drive-translated for fs checks
only (gsWinPath) — native bun resolved them drive-relative, so the heal
judged every LIVE Windows hook dead and pruned it. The three AskUserQuestion
hooks and the Stop hook now also get the mandatory 'bash ' prefix on
Windows (previously only SessionStart did; extensionless bash shims
otherwise hit the file-association dialog).
- CANONICAL_GSTACK_ROOT falls back to $HOME/.claude/skills/gstack when a
CLAUDE_CONFIG_DIR-derived root was never installed (the installer hardcodes
the home path — split-brain left such users permanently hookless).
- prune-stale preserves foreign entries that STARTED empty (they were
silently deleted, uncounted, on every heal).
- The timeline Stop registration and its list-sources guard join the
zero-silent-mutations contract (stderr attached).
Tests: verify-gate tag-stripped heal+sweep, started-empty preservation,
add-event quoting-authority round-trip.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore: bump version and changelog (v1.68.1.0)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: update project documentation for v1.68.1.0
README: document canonical-only hook registration + the prune-stale
self-heal in the setup hooks section; expand the manual-uninstall note
to cover every gstack hook identity, not just timeline-stop-hook.
CONTRIBUTING: record PT_EXPLICIT provenance (Conductor auto-opt-in
fires only on the true silent fall-through) and the heal-first repair
exception in the dev-setup paragraph.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(settings-hook): fail-loud hardening — gsMain umbrella, lock exit 5, prototype-safe ownership
bun in -e mode swallows uncaught exceptions thrown after a require() and
exits 0 (verified on 1.3.13; uncaughtException handlers never fire either),
so any runtime throw in a mutator was a SILENT SUCCESS. Every script body
now runs inside a gsMain try/catch that prints "internal error ... refusing
to mutate" and exits 4.
Also: lock give-up now exits 5 instead of 0 (callers must not report a
skipped mutation as registered); basename lookup uses hasOwnProperty so a
foreign hook named "toString"/"constructor" can't resolve to an inherited
Object.prototype member and abort the sweep; ownership-checked release also
clears an empty/missing owner file; backup rotation sorts by mtime, not
name; Windows-only backslash normalization (a legal Unix path containing a
backslash is no longer rewritten); GSTACK_SWEEP_EXCLUDE_SOURCES lets a
sweep spare named sources; lock tradeoffs documented at the lock helper.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(setup): honest hook-registration reporting + verify-gate sweep exclusion
_install_plan_tune_hooks now propagates per-add-event failures (lock
contention exits 5, fail-closed settings errors exit 3) and both caller
sites branch on it: success logs the installed message, failure logs a
visible "NOT registered — re-run ./setup" warning instead of claiming
success for a mutation that never happened.
--no-team's identity sweep runs with GSTACK_SWEEP_EXCLUDE_SOURCES=
verify-gate: turning team mode off must not delete the user-registered
verify-gate opt-in whose binary still exists (uninstall still sweeps it,
correctly, because there the binary itself is being removed).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test: adversarial regression pins — wrong-shape fail-loud, prototype basename, sweep exclusion, lock exit 5
New pins for the fail-loud hardening: a wrong-shape hooks value (object
where an array belongs) exits 4 with "refusing to mutate" and leaves the
file byte-identical (pre-gsMain this was a silent exit-0 no-op); a foreign
hook whose basename collides with Object.prototype ("toString") survives
an --all sweep that still removes gstack rows; GSTACK_SWEEP_EXCLUDE_SOURCES
preserves the verify-gate row during --all; the fresh-foreign-lock test now
asserts the loud exit 5 instead of a quiet skip.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(verify-gate): allow the --no-team sweep exclusion, keep registration banned
setup now legitimately mentions verify-gate once: the --no-team identity
sweep excludes it via GSTACK_SWEEP_EXCLUDE_SOURCES so team-mode teardown
can't delete a user-registered gate. The opt-in pin tightens from a blanket
not-contains to: every mention must be a comment or that exclusion, and no
mention may sit on an add-event line.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(settings-hook): GNU-first stat in the lock stale check — Linux abort on held locks
On Linux, BSD-style `stat -f %m` prints a multi-line FILESYSTEM block to
stdout before exiting 1, so the BSD-first || chain captured that garbage
concatenated with the real `stat -c %Y` epoch. The non-numeric mtime made
`$(( now - mtime ))` a syntax error and set -e killed the binary with
exit 1 whenever a lock dir already existed — every contention path (stale
takeover, give-up, concurrent writers) broke on CI while staying green on
macOS, where BSD stat -f succeeds cleanly.
GNU `stat -c %Y` now goes first (BSD stat rejects -c with no stdout, so
macOS falls through cleanly), and a numeric guard blanks any residual
garbage so a future platform quirk degrades to the normal give-up path
instead of an arithmetic abort. Same defect class as gstack-repo-mode's
GNU-first ordering (#2195). Verified in an oven/bun Linux container:
the four CI-failing lock tests now pass (62/62 across both files).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(uninstall): 30s budgets for the two subprocess-heavy behavioral tests
Both tests spawn the copied uninstaller, which itself runs several
settings-hook bun -e children (the lock-contention one also waits out a
300ms give-up per call). On a loaded box those cold starts blow bun's
default 5s per-test timeout, and a timeout kill reports as a bare fail
with no assertion diff — observed at 5.6-8.5s under load avg 25+.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
193 lines
8.2 KiB
TypeScript
193 lines
8.2 KiB
TypeScript
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
|
import * as fs from 'fs';
|
|
import * as os from 'os';
|
|
import * as path from 'path';
|
|
import { execSync } from 'child_process';
|
|
|
|
// Regression guard for the conductor/workspace setup hang:
|
|
// `./setup` used a blocking `read -r` to ask "Install both hooks now? [y/N]".
|
|
// When setup runs under a forwarded/automated TTY (conductor workspace setup,
|
|
// CI with a pty) the read blocked forever. The fix moves the decision into
|
|
// flags + env + saved config with a non-blocking, time-bounded prompt fallback.
|
|
//
|
|
// These are static + binary-level assertions (free, <1s) — they lock in the
|
|
// contract without running the full (environment-mutating) setup script.
|
|
|
|
const ROOT = path.resolve(import.meta.dir, '..');
|
|
const SETUP = path.join(ROOT, 'setup');
|
|
const GSTACK_CONFIG = path.join(ROOT, 'bin', 'gstack-config');
|
|
|
|
const setupSrc = fs.readFileSync(SETUP, 'utf-8');
|
|
|
|
describe('setup: plan-tune hooks are non-interactive-safe', () => {
|
|
test('exposes --plan-tune-hooks / --no-plan-tune-hooks / =value flags', () => {
|
|
expect(setupSrc).toContain('--plan-tune-hooks)');
|
|
expect(setupSrc).toContain('--no-plan-tune-hooks)');
|
|
expect(setupSrc).toContain('--plan-tune-hooks=*)');
|
|
});
|
|
|
|
test('resolution falls through env then saved config', () => {
|
|
expect(setupSrc).toContain('GSTACK_PLAN_TUNE_HOOKS');
|
|
expect(setupSrc).toContain('get plan_tune_hooks');
|
|
});
|
|
|
|
test('explicit yes/no decisions never reach a prompt', () => {
|
|
// The yes/no branches must short-circuit before the interactive branch.
|
|
const yesIdx = setupSrc.indexOf('PT_DECISION" = "yes"');
|
|
const noIdx = setupSrc.indexOf('PT_DECISION" = "no"');
|
|
const promptIdx = setupSrc.indexOf('Install both hooks now?');
|
|
expect(yesIdx).toBeGreaterThan(-1);
|
|
expect(noIdx).toBeGreaterThan(-1);
|
|
expect(yesIdx).toBeLessThan(promptIdx);
|
|
expect(noIdx).toBeLessThan(promptIdx);
|
|
});
|
|
|
|
test('the interactive prompt is time-bounded (cannot hang)', () => {
|
|
// No bare blocking read for the plan-tune reply.
|
|
expect(setupSrc).not.toMatch(/read -r PLAN_TUNE_INSTALL_REPLY\b/);
|
|
// It must use a timed read from the controlling tty with an empty fallback.
|
|
// The timeout may be a literal or a named variable (e.g. "$_PT_PROMPT_TIMEOUT").
|
|
expect(setupSrc).toMatch(/read -t (?:\d+|"?\$\{?\w+\}?"?) -r PLAN_TUNE_INSTALL_REPLY <\/dev\/tty/);
|
|
});
|
|
|
|
test('interactive prompt is gated on a real TTY and non-quiet', () => {
|
|
// The prompt branch requires both stdin+stdout TTYs and not --quiet.
|
|
expect(setupSrc).toMatch(/\[ "\$QUIET" -ne 1 \] && \[ -t 0 \] && \[ -t 1 \]/);
|
|
});
|
|
|
|
test('decision input is normalized (lowercase + whitespace-stripped)', () => {
|
|
// "YES" / " yes" from a flag/env must not silently downgrade to skip.
|
|
expect(setupSrc).toMatch(/tr '\[:upper:\]' '\[:lower:\]'/);
|
|
expect(setupSrc).toMatch(/PT_DECISION=\$\(printf .* tr/);
|
|
});
|
|
});
|
|
|
|
describe('dev-setup: never silently mutates global settings.json', () => {
|
|
const DEV_SETUP = path.join(ROOT, 'bin', 'dev-setup');
|
|
const devSetupSrc = fs.readFileSync(DEV_SETUP, 'utf-8');
|
|
|
|
test('runs setup with stdin detached AND --plan-tune-hooks=prompt pin', () => {
|
|
// stdin alone only suppresses the prompt branch; the flag (highest
|
|
// precedence) is what stops a saved `plan_tune_hooks: yes` / env opt-in
|
|
// from rewriting global hooks to the ephemeral worktree path.
|
|
expect(devSetupSrc).toMatch(/setup" --plan-tune-hooks=prompt <\/dev\/null/);
|
|
});
|
|
});
|
|
|
|
describe('setup: PT_EXPLICIT provenance (Conductor auto-opt-in respects explicit decisions)', () => {
|
|
// The phantom-hooks root cause (Bug A): the Conductor auto-opt-in upgraded
|
|
// PT_DECISION "prompt" → "yes" even when "prompt" came from dev-setup's
|
|
// EXPLICIT --plan-tune-hooks=prompt flag, so every new Conductor workspace
|
|
// installed hooks pointing at its ephemeral worktree.
|
|
|
|
test('flag and env set PT_EXPLICIT=1', () => {
|
|
expect(setupSrc).toContain('PT_EXPLICIT=1');
|
|
const flagIdx = setupSrc.indexOf('PT_DECISION="$PLAN_TUNE_HOOKS_MODE"');
|
|
const explicitIdx = setupSrc.indexOf('PT_EXPLICIT=1', flagIdx);
|
|
expect(flagIdx).toBeGreaterThan(-1);
|
|
expect(explicitIdx).toBeGreaterThan(flagIdx);
|
|
});
|
|
|
|
test('the Conductor auto-opt-in fires only on the true silent fall-through', () => {
|
|
expect(setupSrc).toMatch(
|
|
/\[ "\$PT_DECISION" = "prompt" \] && \[ "\$PT_EXPLICIT" -eq 0 \] && \{ \[ -n "\$\{CONDUCTOR_WORKSPACE_PATH:-\}" \] \|\| \[ -n "\$\{CONDUCTOR_PORT:-\}" \]; \}/,
|
|
);
|
|
});
|
|
|
|
test('config provenance uses gstack-config has (env-resolution-safe), never a hardcoded config grep', () => {
|
|
// `gstack-config get` returns the default "prompt" for absent keys, so
|
|
// key PRESENCE must come from `has`, which resolves GSTACK_STATE_ROOT /
|
|
// GSTACK_HOME / GSTACK_STATE_DIR the same way `get` does. A hardcoded
|
|
// grep of ~/.gstack/config.yaml misclassifies under env overrides.
|
|
expect(setupSrc).toMatch(/"\$GSTACK_CONFIG" has plan_tune_hooks/);
|
|
expect(setupSrc).not.toMatch(/grep -q ["']\^plan_tune_hooks:/);
|
|
});
|
|
});
|
|
|
|
describe('gstack-config: has subcommand (key-presence provenance)', () => {
|
|
let tmpHome2: string;
|
|
let env2: NodeJS.ProcessEnv;
|
|
|
|
beforeAll(() => {
|
|
tmpHome2 = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-cfg-has-'));
|
|
env2 = { ...process.env, GSTACK_STATE_ROOT: tmpHome2 };
|
|
});
|
|
|
|
afterAll(() => {
|
|
fs.rmSync(tmpHome2, { recursive: true, force: true });
|
|
});
|
|
|
|
function has(key: string): number {
|
|
try {
|
|
execSync(`${GSTACK_CONFIG} has '${key}'`, { encoding: 'utf-8', env: env2 });
|
|
return 0;
|
|
} catch (e: any) {
|
|
return e.status ?? 1;
|
|
}
|
|
}
|
|
|
|
test('absent key exits nonzero even though get returns the default', () => {
|
|
expect(has('plan_tune_hooks')).not.toBe(0);
|
|
const got = execSync(`${GSTACK_CONFIG} get plan_tune_hooks`, { encoding: 'utf-8', env: env2 }).trim();
|
|
expect(got).toBe('prompt'); // default — indistinguishable from a saved value via get
|
|
});
|
|
|
|
test('present key exits 0 through the same STATE_DIR resolution as get', () => {
|
|
execSync(`${GSTACK_CONFIG} set plan_tune_hooks no`, { encoding: 'utf-8', env: env2 });
|
|
expect(has('plan_tune_hooks')).toBe(0);
|
|
// GSTACK_STATE_ROOT was the writer — a hardcoded ~/.gstack grep would miss it.
|
|
});
|
|
|
|
test('rejects malformed keys', () => {
|
|
expect(has('bad key$(touch /tmp/pwned)')).not.toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('gstack-config: plan_tune_hooks key', () => {
|
|
// Isolate state: gstack-config reads $GSTACK_HOME/config.yaml. Point it at a
|
|
// fresh temp dir so `get` returns the built-in default rather than whatever
|
|
// the host machine has in ~/.gstack/config.yaml (which would make the
|
|
// default-value assertion non-deterministic).
|
|
let tmpHome: string;
|
|
let env: NodeJS.ProcessEnv;
|
|
|
|
beforeAll(() => {
|
|
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-cfg-test-'));
|
|
env = { ...process.env, GSTACK_HOME: tmpHome };
|
|
});
|
|
|
|
afterAll(() => {
|
|
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
});
|
|
|
|
test('default is "prompt"', () => {
|
|
const out = execSync(`${GSTACK_CONFIG} get plan_tune_hooks`, {
|
|
encoding: 'utf-8',
|
|
env,
|
|
}).trim();
|
|
expect(out).toBe('prompt');
|
|
});
|
|
|
|
test('appears in defaults and list output', () => {
|
|
const defaults = execSync(`${GSTACK_CONFIG} defaults`, { encoding: 'utf-8', env });
|
|
expect(defaults).toContain('plan_tune_hooks');
|
|
const list = execSync(`${GSTACK_CONFIG} list`, { encoding: 'utf-8', env });
|
|
expect(list).toContain('plan_tune_hooks');
|
|
});
|
|
|
|
test('accepts valid values (round-trips yes/no/prompt)', () => {
|
|
for (const v of ['yes', 'no', 'prompt']) {
|
|
execSync(`${GSTACK_CONFIG} set plan_tune_hooks ${v}`, { encoding: 'utf-8', env });
|
|
const got = execSync(`${GSTACK_CONFIG} get plan_tune_hooks`, { encoding: 'utf-8', env }).trim();
|
|
expect(got).toBe(v);
|
|
}
|
|
});
|
|
|
|
test('rejects out-of-domain values (warns + falls back to prompt)', () => {
|
|
const res = execSync(`${GSTACK_CONFIG} set plan_tune_hooks maybe 2>&1`, { encoding: 'utf-8', env });
|
|
expect(res.toLowerCase()).toContain('not recognized');
|
|
const got = execSync(`${GSTACK_CONFIG} get plan_tune_hooks`, { encoding: 'utf-8', env }).trim();
|
|
expect(got).toBe('prompt');
|
|
});
|
|
});
|