mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-21 04:10:47 +02:00
249 lines
8.2 KiB
JavaScript
Executable File
249 lines
8.2 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,
|
|
update_check: true,
|
|
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");
|
|
}
|