Files
gstack/bin/gstack-config
T

251 lines
8.3 KiB
JavaScript
Executable File

#!/usr/bin/env node
// Compatibility adapter for preserved specialist modules.
// config.json is the only writable config authority. A legacy config.yaml may
// be read as a migration fallback, but this command never writes YAML.
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import {
configGet,
configSet,
ensureConfig,
loadConfig,
parseConfigValue,
readLegacyConfig,
} from "../runtime/config.js";
import { ensureManagedHome, withRuntimeLifecycleLock } from "../runtime/managed-home.js";
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const env = process.env.GSTACK_HOME
? process.env
: process.env.GSTACK_STATE_ROOT
? { ...process.env, GSTACK_HOME: process.env.GSTACK_STATE_ROOT }
: process.env.GSTACK_STATE_DIR
? { ...process.env, GSTACK_HOME: process.env.GSTACK_STATE_DIR }
: process.env;
const home = path.resolve(env.GSTACK_HOME || path.join(os.homedir(), ".gstack"));
const legacyConfig = path.join(home, "config.yaml");
const defaults = Object.freeze({
proactive: true,
routing_declined: false,
telemetry: "off",
auto_upgrade: false,
// Skill installation and update discovery belong to the Agent Skills CLI.
// GStack never performs a passive network update check by default.
update_check: false,
skill_prefix: false,
checkpoint_mode: "explicit",
checkpoint_push: false,
explain_level: "default",
codex_reviews: "enabled",
gstack_contributor: false,
skip_eng_review: false,
workspace_root: path.join(os.homedir(), "conductor", "workspaces"),
cross_project_learnings: "",
artifacts_sync_mode: "off",
artifacts_sync_mode_prompted: false,
plan_tune_hooks: "prompt",
redact_repo_visibility: "",
redact_prepush_hook: false,
salience_allowlist: "",
});
const [command, ...args] = process.argv.slice(2);
try {
switch (command) {
case "get":
await getCommand(args);
break;
case "set":
await setCommand(args);
break;
case "list":
await listCommand();
break;
case "defaults":
printEntries(defaults);
break;
case "endpoint-hash":
process.stdout.write(endpointHash());
break;
case "resolve-user-slug":
await resolveUserSlug();
break;
case "gbrain-refresh":
await refreshGbrainDetection();
break;
default:
usage();
process.exitCode = 1;
}
} catch (error) {
process.stderr.write(`gstack-config: ${error?.message ?? error}\n`);
process.exitCode = 1;
}
async function getCommand(args) {
if (args.length !== 1) throw new Error("Usage: gstack-config get <key>");
const key = validateKey(args[0]);
let value;
if (await exists(path.join(home, "config.json"))) value = await configGet(home, key);
if (value === undefined && await exists(legacyConfig)) value = await readLegacyValue(key);
if (value === undefined) value = defaultFor(key);
process.stdout.write(formatValue(value));
}
async function setCommand(args) {
if (args.length !== 2) throw new Error("Usage: gstack-config set <key> <value>");
const key = validateKey(args[0]);
const raw = validateClosedValue(key, args[1]);
await mutateConfigHome(() => configSet(home, key, parseConfigValue(raw)));
}
async function listCommand() {
const stored = await exists(path.join(home, "config.json"))
? await loadConfig(home)
: await readLegacyConfig(home);
const flattened = { ...defaults, ...flatten(stored) };
printEntries(flattened);
}
function defaultFor(key) {
if (/^brain_trust_policy(?:@|$)/.test(key)) return "unset";
return Object.hasOwn(defaults, key) ? defaults[key] : "";
}
function validateKey(key) {
if (typeof key !== "string" || !/^[a-zA-Z0-9_]+(?:@[a-f0-9]+)?$/.test(key)) {
throw new Error("key must contain only alphanumeric characters, underscores, and an optional @<hex-hash> suffix");
}
return key;
}
function validateClosedValue(key, value) {
const domains = [
[/^brain_trust_policy(?:@|$)/, ["personal", "shared", "unset"], "unset"],
[/^explain_level$/, ["default", "terse"], "default"],
[/^artifacts_sync_mode$/, ["off", "artifacts-only", "full"], "off"],
[/^redact_repo_visibility$/, ["public", "private", "unknown"], "unknown"],
[/^redact_prepush_hook$/, ["true", "false"], "false"],
[/^plan_tune_hooks$/, ["prompt", "yes", "no"], "prompt"],
];
if (key === "codex_reviews" && !["enabled", "disabled"].includes(value)) {
throw new Error(`codex_reviews '${value}' not recognized. Valid values: enabled, disabled. Existing value left unchanged.`);
}
for (const [pattern, allowed, fallback] of domains) {
if (pattern.test(key) && !allowed.includes(value)) {
process.stderr.write(`Warning: ${key} '${value}' not recognized. Valid values: ${allowed.join(", ")}. Using ${fallback}.\n`);
return fallback;
}
}
return value;
}
async function readLegacyValue(key) {
const content = await fs.readFile(legacyConfig, "utf8");
let found;
for (const line of content.split(/\r?\n/)) {
const match = line.match(/^([A-Za-z0-9_]+(?:@[a-f0-9]+)?):\s*(.*?)\s*(?:#.*)?$/);
if (match?.[1] === key) found = parseConfigValue(unquote(match[2]));
}
return found;
}
function unquote(value) {
if (value.length >= 2 && ((value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'")))) return value.slice(1, -1);
return value;
}
function endpointHash() {
const endpoint = env.GSTACK_GBRAIN_ENDPOINT || env.GBRAIN_URL || "";
return endpoint ? createHash("sha256").update(endpoint).digest("hex").slice(0, 8) : "local";
}
async function resolveUserSlug() {
const key = `user_slug_at_${endpointHash()}`;
if (await exists(path.join(home, "config.json"))) {
const stored = await configGet(home, key);
if (typeof stored === "string" && stored) {
process.stdout.write(stored);
return;
}
}
const user = sanitizeSlug(env.USER || "");
const email = spawnSync("git", ["config", "user.email"], { encoding: "utf8" }).stdout?.trim();
const fallback = email
? `email-${sha8(email)}`
: `anonymous-${sha8(os.hostname() || "unknown")}`;
const slug = user || fallback;
await mutateConfigHome(() => configSet(home, key, slug));
process.stdout.write(slug);
}
async function refreshGbrainDetection() {
await mutateConfigHome(async () => {
const detector = path.join(scriptDir, "gstack-gbrain-detect");
const result = spawnSync(detector, [], { encoding: "utf8", env });
const payload = result.status === 0 && result.stdout.trim()
? result.stdout.trim()
: '{"gbrain_on_path":false,"gbrain_local_status":"no-cli"}';
JSON.parse(payload);
const target = path.join(home, "gbrain-detection.json");
const temporary = `${target}.tmp-${process.pid}`;
await fs.writeFile(temporary, `${payload}\n`, { mode: 0o600 });
await fs.rename(temporary, target);
});
process.stdout.write("GBrain detection refreshed. Re-run the standard Agent Skills installer if skill content must change.\n");
}
async function mutateConfigHome(callback) {
return withRuntimeLifecycleLock(home, async () => {
await ensureManagedHome(home);
await ensureConfig(home);
return callback();
});
}
function sanitizeSlug(value) {
return value.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "");
}
function sha8(value) {
return createHash("sha256").update(value).digest("hex").slice(0, 8);
}
function flatten(value, prefix = "", output = {}) {
for (const [key, child] of Object.entries(value ?? {})) {
const name = prefix ? `${prefix}.${key}` : key;
if (child && typeof child === "object" && !Array.isArray(child)) flatten(child, name, output);
else output[name] = child;
}
return output;
}
function printEntries(entries) {
for (const key of Object.keys(entries).sort()) {
process.stdout.write(`${key}: ${formatValue(entries[key])}\n`);
}
}
function formatValue(value) {
if (value === undefined || value === null) return "";
return typeof value === "string" ? value : JSON.stringify(value);
}
async function exists(target) {
return fs.lstat(target).then(() => true, (error) => {
if (error?.code === "ENOENT") return false;
throw error;
});
}
function usage() {
process.stderr.write("Usage: gstack-config {get|set|list|defaults|endpoint-hash|resolve-user-slug|gbrain-refresh} [key] [value]\n");
}