diff --git a/bin/gstack-config b/bin/gstack-config new file mode 100755 index 00000000..e99a940b --- /dev/null +++ b/bin/gstack-config @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# gstack-config — read/write ~/.gstack/config.yaml +# +# Usage: +# gstack-config get — read a config value +# gstack-config set — write a config value +# gstack-config list — show all config +# +# Env overrides (for testing): +# GSTACK_STATE_DIR — override ~/.gstack state directory +set -euo pipefail + +STATE_DIR="${GSTACK_STATE_DIR:-$HOME/.gstack}" +CONFIG_FILE="$STATE_DIR/config.yaml" + +case "${1:-}" in + get) + KEY="${2:?Usage: gstack-config get }" + grep -E "^${KEY}:" "$CONFIG_FILE" 2>/dev/null | tail -1 | awk '{print $2}' | tr -d '[:space:]' || true + ;; + set) + KEY="${2:?Usage: gstack-config set }" + VALUE="${3:?Usage: gstack-config set }" + mkdir -p "$STATE_DIR" + if grep -qE "^${KEY}:" "$CONFIG_FILE" 2>/dev/null; then + sed -i '' "s/^${KEY}:.*/${KEY}: ${VALUE}/" "$CONFIG_FILE" + else + echo "${KEY}: ${VALUE}" >> "$CONFIG_FILE" + fi + ;; + list) + cat "$CONFIG_FILE" 2>/dev/null || true + ;; + *) + echo "Usage: gstack-config {get|set|list} [key] [value]" + exit 1 + ;; +esac diff --git a/browse/test/gstack-config.test.ts b/browse/test/gstack-config.test.ts new file mode 100644 index 00000000..8a7b6dea --- /dev/null +++ b/browse/test/gstack-config.test.ts @@ -0,0 +1,125 @@ +/** + * Tests for bin/gstack-config bash script. + * + * Uses Bun.spawnSync to invoke the script with temp dirs and + * GSTACK_STATE_DIR env override for full isolation. + */ + +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import { mkdtempSync, writeFileSync, rmSync, readFileSync, existsSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; + +const SCRIPT = join(import.meta.dir, '..', '..', 'bin', 'gstack-config'); + +let stateDir: string; + +function run(args: string[] = [], extraEnv: Record = {}) { + const result = Bun.spawnSync(['bash', SCRIPT, ...args], { + env: { + ...process.env, + GSTACK_STATE_DIR: stateDir, + ...extraEnv, + }, + stdout: 'pipe', + stderr: 'pipe', + }); + return { + exitCode: result.exitCode, + stdout: result.stdout.toString().trim(), + stderr: result.stderr.toString().trim(), + }; +} + +beforeEach(() => { + stateDir = mkdtempSync(join(tmpdir(), 'gstack-config-test-')); +}); + +afterEach(() => { + rmSync(stateDir, { recursive: true, force: true }); +}); + +describe('gstack-config', () => { + // ─── get ────────────────────────────────────────────────── + test('get on missing file returns empty, exit 0', () => { + const { exitCode, stdout } = run(['get', 'auto_upgrade']); + expect(exitCode).toBe(0); + expect(stdout).toBe(''); + }); + + test('get existing key returns value', () => { + writeFileSync(join(stateDir, 'config.yaml'), 'auto_upgrade: true\n'); + const { exitCode, stdout } = run(['get', 'auto_upgrade']); + expect(exitCode).toBe(0); + expect(stdout).toBe('true'); + }); + + test('get missing key returns empty', () => { + writeFileSync(join(stateDir, 'config.yaml'), 'auto_upgrade: true\n'); + const { exitCode, stdout } = run(['get', 'nonexistent']); + expect(exitCode).toBe(0); + expect(stdout).toBe(''); + }); + + test('get returns last value when key appears multiple times', () => { + writeFileSync(join(stateDir, 'config.yaml'), 'foo: bar\nfoo: baz\n'); + const { exitCode, stdout } = run(['get', 'foo']); + expect(exitCode).toBe(0); + expect(stdout).toBe('baz'); + }); + + // ─── set ────────────────────────────────────────────────── + test('set creates file and writes key on missing file', () => { + const { exitCode } = run(['set', 'auto_upgrade', 'true']); + expect(exitCode).toBe(0); + const content = readFileSync(join(stateDir, 'config.yaml'), 'utf-8'); + expect(content).toContain('auto_upgrade: true'); + }); + + test('set appends new key to existing file', () => { + writeFileSync(join(stateDir, 'config.yaml'), 'foo: bar\n'); + const { exitCode } = run(['set', 'auto_upgrade', 'true']); + expect(exitCode).toBe(0); + const content = readFileSync(join(stateDir, 'config.yaml'), 'utf-8'); + expect(content).toContain('foo: bar'); + expect(content).toContain('auto_upgrade: true'); + }); + + test('set replaces existing key in-place', () => { + writeFileSync(join(stateDir, 'config.yaml'), 'auto_upgrade: false\n'); + const { exitCode } = run(['set', 'auto_upgrade', 'true']); + expect(exitCode).toBe(0); + const content = readFileSync(join(stateDir, 'config.yaml'), 'utf-8'); + expect(content).toContain('auto_upgrade: true'); + expect(content).not.toContain('auto_upgrade: false'); + }); + + test('set creates state dir if missing', () => { + const nestedDir = join(stateDir, 'nested', 'dir'); + const { exitCode } = run(['set', 'foo', 'bar'], { GSTACK_STATE_DIR: nestedDir }); + expect(exitCode).toBe(0); + expect(existsSync(join(nestedDir, 'config.yaml'))).toBe(true); + }); + + // ─── list ───────────────────────────────────────────────── + test('list shows all keys', () => { + writeFileSync(join(stateDir, 'config.yaml'), 'auto_upgrade: true\nupdate_check: false\n'); + const { exitCode, stdout } = run(['list']); + expect(exitCode).toBe(0); + expect(stdout).toContain('auto_upgrade: true'); + expect(stdout).toContain('update_check: false'); + }); + + test('list on missing file returns empty, exit 0', () => { + const { exitCode, stdout } = run(['list']); + expect(exitCode).toBe(0); + expect(stdout).toBe(''); + }); + + // ─── usage ──────────────────────────────────────────────── + test('no args shows usage and exits 1', () => { + const { exitCode, stdout } = run([]); + expect(exitCode).toBe(1); + expect(stdout).toContain('Usage'); + }); +});