mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-21 04:10:47 +02:00
implement six-skill gstack 2 runtime
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user