diff --git a/bin/gstack-config b/bin/gstack-config index 3521825f4..46f82bfd0 100755 --- a/bin/gstack-config +++ b/bin/gstack-config @@ -403,6 +403,14 @@ case "${1:-}" in echo "Error: codex_reviews '$VALUE' not recognized. Valid values: enabled, disabled. Existing value left unchanged." >&2 exit 1 fi + # cross_project_learnings: empty get is the first-run prompt sentinel. + # Skills enable only on the literal "true". A typo must not persist — that + # keeps the feature off and suppresses the prompt. Reject, like + # codex_reviews; do not coerce (a stored default still kills the sentinel). + if [ "$KEY" = "cross_project_learnings" ] && [ "$VALUE" != "true" ] && [ "$VALUE" != "false" ]; then + echo "Error: cross_project_learnings '$VALUE' not recognized. Valid values: true, false. Existing value left unchanged." >&2 + exit 1 + fi mkdir -p "$STATE_DIR" # Write annotated header on first creation if [ ! -f "$CONFIG_FILE" ]; then diff --git a/test/gstack-config-cross-project.test.ts b/test/gstack-config-cross-project.test.ts new file mode 100644 index 000000000..93c323948 --- /dev/null +++ b/test/gstack-config-cross-project.test.ts @@ -0,0 +1,67 @@ +/** + * #2673: gstack-config set must reject malformed cross_project_learnings. + * + * Empty get is the first-run prompt sentinel (pinned in + * gstack-config-defaults.test.ts). Skills only enable on the literal "true". + * A typo used to store verbatim and exit 0, so the feature stayed off and + * the prompt never returned. Unlike pair_agent / redact_prepush_hook, do + * not coerce to a default — that would still persist a value and still + * kill the sentinel. Follow codex_reviews: reject, leave existing. + */ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { spawnSync } from "child_process"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; + +const CONFIG = path.resolve(import.meta.dir, "..", "bin", "gstack-config"); +let stateRoot: string; + +function cfg(args: string[]): { code: number; out: string; err: string } { + const r = spawnSync(CONFIG, args, { + encoding: "utf8", + env: { ...process.env, GSTACK_STATE_ROOT: stateRoot }, + }); + return { code: r.status ?? 0, out: r.stdout ?? "", err: r.stderr ?? "" }; +} + +beforeEach(() => { + stateRoot = fs.mkdtempSync(path.join(os.tmpdir(), "gstack-config-xproj-")); +}); + +afterEach(() => { + fs.rmSync(stateRoot, { recursive: true, force: true }); +}); + +describe("cross_project_learnings set domain (#2673)", () => { + test("empty get is still the first-run sentinel", () => { + const r = cfg(["get", "cross_project_learnings"]); + expect(r.code).toBe(0); + expect(r.out).toBe(""); + }); + + test("true and false round-trip", () => { + expect(cfg(["set", "cross_project_learnings", "true"]).code).toBe(0); + expect(cfg(["get", "cross_project_learnings"]).out).toBe("true"); + expect(cfg(["set", "cross_project_learnings", "false"]).code).toBe(0); + expect(cfg(["get", "cross_project_learnings"]).out).toBe("false"); + }); + + test("typo is rejected and does not write", () => { + const r = cfg(["set", "cross_project_learnings", "ture"]); + expect(r.code).toBe(1); + expect(r.err).toContain("not recognized"); + expect(r.err).toContain("cross_project_learnings"); + const got = cfg(["get", "cross_project_learnings"]); + expect(got.code).toBe(0); + expect(got.out).toBe(""); + }); + + test("typo leaves an existing valid value unchanged", () => { + expect(cfg(["set", "cross_project_learnings", "true"]).code).toBe(0); + const r = cfg(["set", "cross_project_learnings", "yes"]); + expect(r.code).toBe(1); + expect(r.err).toContain("Existing value left unchanged"); + expect(cfg(["get", "cross_project_learnings"]).out).toBe("true"); + }); +});