mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-21 12:20:48 +02:00
87 lines
3.0 KiB
JavaScript
87 lines
3.0 KiB
JavaScript
import os from "node:os";
|
|
import path from "node:path";
|
|
|
|
/**
|
|
* Resolve the one and only gstack state root.
|
|
*
|
|
* This deliberately does not consult host-specific variables such as
|
|
* CLAUDE_PLUGIN_DATA. GSTACK_HOME wins; otherwise state lives in ~/.gstack.
|
|
* Values are handled as paths, never evaluated by a shell.
|
|
*/
|
|
export function resolveGstackHome(options = {}) {
|
|
const env = options.env ?? process.env;
|
|
const homeDir = options.homeDir ?? os.homedir();
|
|
const cwd = options.cwd ?? process.cwd();
|
|
let configured = env.GSTACK_HOME;
|
|
|
|
if (configured != null && configured.includes("\0")) {
|
|
throw new TypeError("GSTACK_HOME must not contain a NUL byte");
|
|
}
|
|
|
|
if (configured == null || configured === "") {
|
|
if (!homeDir) throw new Error("Unable to resolve a home directory for ~/.gstack");
|
|
configured = path.join(homeDir, ".gstack");
|
|
} else if (configured === "~" || configured.startsWith(`~${path.sep}`)) {
|
|
if (!homeDir) throw new Error("Unable to expand ~ in GSTACK_HOME");
|
|
configured = path.join(homeDir, configured.slice(2));
|
|
}
|
|
|
|
return path.normalize(path.resolve(cwd, configured));
|
|
}
|
|
|
|
export function resolveRuntimePaths(options = {}) {
|
|
const home = options.home ?? resolveGstackHome(options);
|
|
return Object.freeze({
|
|
home,
|
|
config: path.join(home, "config.json"),
|
|
secrets: path.join(home, "secrets.json"),
|
|
migrations: path.join(home, "migration.json"),
|
|
projects: path.join(home, "projects"),
|
|
locks: path.join(home, "locks"),
|
|
tmp: path.join(home, "tmp"),
|
|
plans: path.join(home, "plans"),
|
|
versions: path.join(home, "versions"),
|
|
versionPointer: path.join(home, "versions", "current.json"),
|
|
});
|
|
}
|
|
|
|
/** POSIX-shell literal used only by the legacy gstack-paths adapter. */
|
|
export function shellQuote(value) {
|
|
return `'${String(value).replaceAll("'", `'\\''`)}'`;
|
|
}
|
|
|
|
export function projectPaths(home, projectId) {
|
|
assertSafeId(projectId, "project id");
|
|
const root = path.join(home, "projects", projectId);
|
|
return Object.freeze({
|
|
root,
|
|
state: path.join(root, "state.json"),
|
|
timeline: path.join(root, "timeline.jsonl"),
|
|
decisions: path.join(root, "decisions.jsonl"),
|
|
evidence: path.join(root, "evidence"),
|
|
artifacts: path.join(root, "artifacts"),
|
|
reviews: path.join(root, "reviews"),
|
|
checkpoints: path.join(root, "checkpoints"),
|
|
lock: path.join(root, ".state.lock"),
|
|
});
|
|
}
|
|
|
|
export function assertSafeId(value, label = "id") {
|
|
if (typeof value !== "string" || !/^[a-z0-9][a-z0-9_-]{0,127}$/i.test(value)) {
|
|
throw new TypeError(`Invalid ${label}`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
/** Return candidate only when it is strictly inside root. */
|
|
export function assertPathInside(root, candidate) {
|
|
const base = path.resolve(root);
|
|
const target = path.resolve(candidate);
|
|
const relative = path.relative(base, target);
|
|
if (relative === "" || relative === ".") return target;
|
|
if (relative.startsWith(".." + path.sep) || relative === ".." || path.isAbsolute(relative)) {
|
|
throw new Error(`Path escapes gstack home: ${target}`);
|
|
}
|
|
return target;
|
|
}
|