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,162 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { assertPathInside, resolveRuntimePaths } from "./paths.js";
|
||||
import { pathExists } from "./storage.js";
|
||||
import { recoverPendingUpgradeUnlocked } from "./upgrade.js";
|
||||
import { assertManagedHome, withRuntimeLifecycleLock } from "./managed-home.js";
|
||||
|
||||
const HOME_ATOMIC_TARGETS = Object.freeze([
|
||||
".gstack-managed-home.json",
|
||||
"config.json",
|
||||
"migration.json",
|
||||
"runtime-install.json",
|
||||
"secrets.json",
|
||||
]);
|
||||
|
||||
const UUID_SUFFIX = "[0-9a-f-]{8,}";
|
||||
const TMP_ATOMIC_PATTERN = new RegExp(`^\\.[A-Za-z0-9._-]+\\.tmp-\\d+-${UUID_SUFFIX}$`, "i");
|
||||
const INSTALL_SCRATCH_PATTERN = new RegExp(`^(?:install|uninstall)-${UUID_SUFFIX}$`, "i");
|
||||
const STALE_LOCK_SCRATCH_PATTERN = new RegExp(`^[A-Za-z0-9._-]+\\.lock\\.stale-\\d+-${UUID_SUFFIX}$`, "i");
|
||||
const VERSION_STAGE_PATTERN = new RegExp(`^\\.stage-[0-9A-Za-z][0-9A-Za-z._-]{0,79}-${UUID_SUFFIX}$`, "i");
|
||||
|
||||
export async function cleanupRuntime(home, options = {}) {
|
||||
const paths = resolveRuntimePaths({ home });
|
||||
const olderThanMs = options.olderThanMs ?? 24 * 60 * 60 * 1000;
|
||||
const now = options.nowMs ?? Date.now();
|
||||
const dryRun = Boolean(options.dryRun);
|
||||
const removed = [];
|
||||
const skipped = [];
|
||||
if (!(await pathExists(paths.home))) return { removed, skipped, bytesReclaimed: 0, dryRun };
|
||||
|
||||
const homeStat = await fs.lstat(paths.home);
|
||||
if (!homeStat.isDirectory() || homeStat.isSymbolicLink()) {
|
||||
const error = new Error(`Refusing to clean an unsafe gstack home: ${paths.home}`);
|
||||
error.code = "CLEANUP_HOME_UNSAFE";
|
||||
throw error;
|
||||
}
|
||||
|
||||
return withRuntimeLifecycleLock(paths.home, async () => {
|
||||
await assertManagedHome(paths.home, options);
|
||||
return cleanupRuntimeUnlocked(paths, { olderThanMs, now, dryRun, removed, skipped });
|
||||
}, { lockOptions: options.lockOptions });
|
||||
}
|
||||
|
||||
async function cleanupRuntimeUnlocked(paths, options) {
|
||||
const { olderThanMs, now, dryRun, removed, skipped } = options;
|
||||
const pendingRecovery = dryRun ? null : await recoverPendingUpgradeUnlocked(paths);
|
||||
let bytesReclaimed = 0;
|
||||
|
||||
/**
|
||||
* Cleanup is intentionally shallow. In particular, never recurse through
|
||||
* projects, plans, or active immutable versions: those trees can contain
|
||||
* user-authored files whose names happen to look like runtime temporaries.
|
||||
*/
|
||||
const cleanDirectory = async (directory, classify) => {
|
||||
const directoryStat = await fs.lstat(directory).catch((error) => {
|
||||
if (error?.code === "ENOENT") return null;
|
||||
throw error;
|
||||
});
|
||||
if (!directoryStat) return;
|
||||
if (directoryStat.isSymbolicLink()) {
|
||||
skipped.push({ path: directory, reason: "symlink-directory" });
|
||||
return;
|
||||
}
|
||||
if (!directoryStat.isDirectory()) {
|
||||
skipped.push({ path: directory, reason: "unexpected-directory-type" });
|
||||
return;
|
||||
}
|
||||
let entries;
|
||||
try {
|
||||
entries = await fs.readdir(directory, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
if (error?.code === "ENOENT") return;
|
||||
throw error;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const candidate = assertPathInside(paths.home, path.join(directory, entry.name));
|
||||
const stat = await fs.lstat(candidate).catch((error) => {
|
||||
if (error?.code === "ENOENT") return null;
|
||||
throw error;
|
||||
});
|
||||
if (!stat) continue;
|
||||
if (stat.isSymbolicLink()) {
|
||||
skipped.push({ path: candidate, reason: "symlink" });
|
||||
continue;
|
||||
}
|
||||
const age = now - stat.mtimeMs;
|
||||
if (age < olderThanMs) continue;
|
||||
const reason = await classify(entry.name, stat, candidate);
|
||||
if (reason) {
|
||||
const size = stat.isDirectory() ? await directorySize(candidate) : stat.size;
|
||||
removed.push({ path: candidate, reason, bytes: size });
|
||||
bytesReclaimed += size;
|
||||
if (!dryRun) await fs.rm(candidate, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await cleanDirectory(paths.home, (name, stat) =>
|
||||
stat.isFile() && isAtomicSidecar(name, HOME_ATOMIC_TARGETS) ? "stale-temporary" : null);
|
||||
await cleanDirectory(paths.tmp, (name, stat) =>
|
||||
(stat.isFile() && TMP_ATOMIC_PATTERN.test(name)) ||
|
||||
(stat.isDirectory() && INSTALL_SCRATCH_PATTERN.test(name))
|
||||
? "stale-install-scratch"
|
||||
: null);
|
||||
await cleanDirectory(paths.locks, async (name, stat, candidate) => {
|
||||
if (stat.isDirectory() && STALE_LOCK_SCRATCH_PATTERN.test(name)) return "stale-lock-scratch";
|
||||
if (!stat.isDirectory() || !/^[A-Za-z0-9._-]+\.lock$/.test(name)) return null;
|
||||
return await lockOwnerIsAlive(candidate) ? null : "stale-lock";
|
||||
});
|
||||
await cleanDirectory(paths.versions, (name, stat) => {
|
||||
if (stat.isDirectory() && VERSION_STAGE_PATTERN.test(name)) return "stale-version-stage";
|
||||
return stat.isFile() && isAtomicSidecar(name, ["current.json"]) ? "stale-temporary" : null;
|
||||
});
|
||||
|
||||
return { removed, skipped, bytesReclaimed, dryRun, pendingRecovery };
|
||||
}
|
||||
|
||||
function isAtomicSidecar(name, targets) {
|
||||
return targets.some((target) => {
|
||||
const escaped = target.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
return new RegExp(`^\\.${escaped}\\.tmp-\\d+-${UUID_SUFFIX}$`, "i").test(name) ||
|
||||
new RegExp(`^${escaped}\\.replace-\\d+-${UUID_SUFFIX}$`, "i").test(name);
|
||||
});
|
||||
}
|
||||
|
||||
async function lockOwnerIsAlive(lockDirectory) {
|
||||
try {
|
||||
const owner = JSON.parse(await fs.readFile(path.join(lockDirectory, "owner.json"), "utf8"));
|
||||
if (!Number.isInteger(owner.pid) || owner.pid <= 0) return false;
|
||||
process.kill(owner.pid, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return error?.code === "EPERM";
|
||||
}
|
||||
}
|
||||
|
||||
async function directorySize(directory) {
|
||||
let total = 0;
|
||||
const rootStat = await fs.lstat(directory).catch((error) => {
|
||||
if (error?.code === "ENOENT") return null;
|
||||
throw error;
|
||||
});
|
||||
if (!rootStat || rootStat.isSymbolicLink() || !rootStat.isDirectory()) return 0;
|
||||
let entries;
|
||||
try {
|
||||
entries = await fs.readdir(directory, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
if (error?.code === "ENOENT") return 0;
|
||||
throw error;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const child = path.join(directory, entry.name);
|
||||
const stat = await fs.lstat(child).catch((error) => {
|
||||
if (error?.code === "ENOENT") return null;
|
||||
throw error;
|
||||
});
|
||||
if (!stat || stat.isSymbolicLink()) continue;
|
||||
if (stat.isDirectory()) total += await directorySize(child);
|
||||
else if (stat.isFile()) total += stat.size;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
+665
@@ -0,0 +1,665 @@
|
||||
import readline from "node:readline/promises";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
import { stdin as processStdin, stdout as processStdout, stderr as processStderr } from "node:process";
|
||||
import { assertPathInside, resolveGstackHome, resolveRuntimePaths, shellQuote } from "./paths.js";
|
||||
import { readJson } from "./storage.js";
|
||||
import { setupRuntime } from "./setup.js";
|
||||
import {
|
||||
configGet,
|
||||
configSet,
|
||||
configSetNetworkChoice,
|
||||
parseConfigValue,
|
||||
secretSet,
|
||||
} from "./config.js";
|
||||
import { discoverProjectIdentity } from "./identity.js";
|
||||
import {
|
||||
beginRun,
|
||||
completeRun,
|
||||
inspectProject,
|
||||
inspectRun,
|
||||
markEffectApplied,
|
||||
markEffectNotApplied,
|
||||
resumeRun,
|
||||
runExternalEffect,
|
||||
updateRunWorkflow,
|
||||
} from "./state.js";
|
||||
import { runDoctor, formatDoctor } from "./doctor.js";
|
||||
import { cleanupRuntime } from "./cleanup.js";
|
||||
import {
|
||||
ContextClient,
|
||||
contextStatus,
|
||||
readContextKey,
|
||||
redactSensitiveText,
|
||||
validateContextKey,
|
||||
} from "./context.js";
|
||||
import { rollbackUpgrade } from "./upgrade.js";
|
||||
import { installManagedRuntime, uninstallManagedRuntime } from "./install.js";
|
||||
import { assertManagedHome, withRuntimeLifecycleLock } from "./managed-home.js";
|
||||
|
||||
const RUNTIME_VERSION = "2.0.0";
|
||||
|
||||
export async function main(argv = process.argv.slice(2), options = {}) {
|
||||
const env = options.env ?? process.env;
|
||||
const cwd = options.cwd ?? process.cwd();
|
||||
const stdin = options.stdin ?? processStdin;
|
||||
const stdout = options.stdout ?? processStdout;
|
||||
const stderr = options.stderr ?? processStderr;
|
||||
const home = resolveGstackHome({ env, cwd, homeDir: options.homeDir });
|
||||
const [command, ...args] = argv;
|
||||
|
||||
if (!command || ["help", "--help", "-h"].includes(command)) {
|
||||
write(stdout, usage());
|
||||
return 0;
|
||||
}
|
||||
if (["--version", "version", "-v"].includes(command)) {
|
||||
write(stdout, `gstack runtime ${RUNTIME_VERSION}\n`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
switch (command) {
|
||||
case "setup":
|
||||
return await setupCommand({ args, home, cwd, stdout });
|
||||
case "doctor":
|
||||
return await doctorCommand({ args, home, cwd, stdout });
|
||||
case "paths":
|
||||
return await pathsCommand({ args, home, stdout });
|
||||
case "runtime":
|
||||
return await runtimeCommand({ args, home, stdout });
|
||||
case "config":
|
||||
return await configCommand({ args, home, cwd, stdout });
|
||||
case "state":
|
||||
return await stateCommand({ args, home, cwd, env, stdout, stderr });
|
||||
case "context":
|
||||
return await contextCommand({ args, home, cwd, env, stdin, stdout, stderr });
|
||||
case "cleanup":
|
||||
return await cleanupCommand({ args, home, stdout });
|
||||
case "upgrade":
|
||||
return await upgradeCommand({ args, home, stdout, installOptions: options.installOptions });
|
||||
case "uninstall":
|
||||
return await uninstallCommand({ args, home, stdout });
|
||||
default:
|
||||
throw cliError(`Unknown command: ${command}`, "USAGE");
|
||||
}
|
||||
} catch (error) {
|
||||
const json = args.includes("--json");
|
||||
const safeMessage = redactSecrets(error?.message ?? String(error));
|
||||
if (json) {
|
||||
write(stderr, `${JSON.stringify({ ok: false, error: error?.code ?? "ERROR", message: safeMessage })}\n`);
|
||||
} else {
|
||||
write(stderr, `gstack: ${safeMessage}\n`);
|
||||
}
|
||||
return exitCodeFor(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function runtimeCommand({ args, home, stdout }) {
|
||||
const [action, relative, ...rest] = args;
|
||||
if (action !== "path" || !relative || rest.length > 0) {
|
||||
throw cliError("Usage: gstack runtime path <bundle-relative-path>", "USAGE");
|
||||
}
|
||||
if (relative.includes("\0") || path.isAbsolute(relative) || relative.split(/[\\/]+/).some((part) => part === ".." || part === "")) {
|
||||
throw cliError("Runtime bundle path must be a safe relative path", "USAGE");
|
||||
}
|
||||
const paths = resolveRuntimePaths({ home });
|
||||
const pointer = await readJson(paths.versionPointer, null);
|
||||
const version = pointer?.current;
|
||||
if (!version) throw cliError("No active managed runtime; run `gstack upgrade --source <package> --version <version>`", "RUNTIME_NOT_INSTALLED");
|
||||
if (typeof version !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(version)) {
|
||||
throw cliError("Managed runtime pointer contains an invalid version", "RUNTIME_POINTER_INVALID");
|
||||
}
|
||||
const versionRoot = assertPathInside(paths.versions, path.join(paths.versions, version));
|
||||
const target = assertPathInside(versionRoot, path.join(versionRoot, relative));
|
||||
const stat = await fs.lstat(target).catch((error) => {
|
||||
if (error?.code === "ENOENT" || error?.code === "ENOTDIR") return null;
|
||||
throw error;
|
||||
});
|
||||
if (!stat || stat.isSymbolicLink()) throw cliError(`Managed runtime asset is unavailable: ${relative}`, "RUNTIME_ASSET_MISSING");
|
||||
write(stdout, `${target}\n`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function pathsCommand({ args, home, stdout }) {
|
||||
rejectUnknown(args, ["--json", "--shell"]);
|
||||
if (args.includes("--json") && args.includes("--shell")) {
|
||||
throw cliError("Choose either --json or --shell", "USAGE");
|
||||
}
|
||||
const paths = resolveRuntimePaths({ home });
|
||||
const result = {
|
||||
GSTACK_STATE_ROOT: paths.home,
|
||||
PLAN_ROOT: paths.plans,
|
||||
TMP_ROOT: paths.tmp,
|
||||
};
|
||||
if (args.includes("--shell")) {
|
||||
for (const [key, value] of Object.entries(result)) write(stdout, `${key}=${shellQuote(value)}\n`);
|
||||
} else {
|
||||
write(stdout, `${JSON.stringify(result, null, 2)}\n`);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function setupCommand({ args, home, cwd, stdout }) {
|
||||
rejectUnknown(args, []);
|
||||
const result = await setupRuntime({ home, cwd });
|
||||
write(stdout, `gstack is ready\nhome: ${result.paths.home}\nproject: ${result.identity.projectId}\nnetwork: off\nContext.dev key setup: https://www.context.dev/auth.md\n`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function doctorCommand({ args, home, cwd, stdout }) {
|
||||
rejectUnknown(args, ["--json"]);
|
||||
const report = await runDoctor({ home, cwd });
|
||||
write(stdout, args.includes("--json") ? `${JSON.stringify(report, null, 2)}\n` : formatDoctor(report));
|
||||
return report.ok ? 0 : 1;
|
||||
}
|
||||
|
||||
async function configCommand({ args, home, cwd, stdout }) {
|
||||
const [action, ...tail] = args;
|
||||
if (action === "get") {
|
||||
const key = tail.find((arg) => !arg.startsWith("--"));
|
||||
rejectUnknown(tail.filter((arg) => arg !== key), ["--json"]);
|
||||
const result = await configGet(home, key);
|
||||
if (result === undefined) throw cliError(`Config key not found: ${key}`, "CONFIG_KEY_NOT_FOUND");
|
||||
write(stdout, `${typeof result === "string" ? result : JSON.stringify(result, null, 2)}\n`);
|
||||
return 0;
|
||||
}
|
||||
if (action === "set") {
|
||||
const [key, value, ...rest] = tail;
|
||||
if (!key || value === undefined || rest.length) throw cliError("Usage: gstack config set <key> <value>", "USAGE");
|
||||
await setupRuntime({ home, cwd });
|
||||
const result = await withOwnedRuntimeMutation(home, () => configSet(home, key, parseConfigValue(value)));
|
||||
write(stdout, `${key} = ${typeof result === "string" ? result : JSON.stringify(result)}\n`);
|
||||
return 0;
|
||||
}
|
||||
throw cliError("Usage: gstack config get [key] | gstack config set <key> <value>", "USAGE");
|
||||
}
|
||||
|
||||
async function stateCommand({ args, home, cwd, env, stdout, stderr }) {
|
||||
const [action, ...rest] = args;
|
||||
const identity = await discoverProjectIdentity(cwd);
|
||||
if (action === "inspect") {
|
||||
const parsed = parseStateArguments(rest, { flags: ["--json"] });
|
||||
if (parsed.positionals.length > 1) throw cliError("Usage: gstack state inspect [run-id] [--json]", "USAGE");
|
||||
const runId = parsed.positionals[0];
|
||||
const result = runId
|
||||
? await inspectRun(home, identity.projectId, runId)
|
||||
: await inspectProject(home, identity);
|
||||
write(stdout, `${JSON.stringify(runId ? {
|
||||
projectId: identity.projectId,
|
||||
run: result.run,
|
||||
reconstruction: result.reconstruction,
|
||||
} : result.state, null, 2)}\n`);
|
||||
return 0;
|
||||
}
|
||||
if (action === "resume") {
|
||||
const parsed = parseStateArguments(rest, { flags: ["--json"] });
|
||||
if (parsed.positionals.length > 1) throw cliError("Usage: gstack state resume [run-id] [--json]", "USAGE");
|
||||
const runId = parsed.positionals[0];
|
||||
const result = await withOwnedRuntimeMutation(home, () => resumeRun(home, identity.projectId, runId));
|
||||
const output = { projectId: identity.projectId, run: result.run, reconstruction: result.reconstruction };
|
||||
write(stdout, `${JSON.stringify(output, null, 2)}\n`);
|
||||
return 0;
|
||||
}
|
||||
if (action === "begin") {
|
||||
const parsed = parseStateArguments(rest, {
|
||||
flags: ["--json"],
|
||||
values: ["--run-id", "--goal", "--plan", "--stage", "--depth", "--mutation", "--modules"],
|
||||
});
|
||||
if (parsed.positionals.length !== 1) {
|
||||
throw cliError("Usage: gstack state begin <workflow> [metadata options] [--json]", "USAGE");
|
||||
}
|
||||
const [workflow] = parsed.positionals;
|
||||
const options = {
|
||||
runId: parsed.values.get("--run-id"),
|
||||
originalGoal: parsed.values.get("--goal"),
|
||||
currentPlanPointer: parsed.values.get("--plan"),
|
||||
currentWorkflowStage: parsed.values.get("--stage"),
|
||||
selectedDepth: parsed.values.get("--depth"),
|
||||
mutationAuthority: parsed.values.get("--mutation"),
|
||||
activeModules: parsed.values.has("--modules") ? parseModuleList(parsed.values.get("--modules")) : undefined,
|
||||
};
|
||||
await setupRuntime({ home, cwd });
|
||||
const result = await withOwnedRuntimeMutation(home, () => beginRun(home, identity.projectId, workflow, options));
|
||||
const output = { projectId: identity.projectId, run: result.run, reconstruction: result.reconstruction };
|
||||
write(stdout, parsed.flags.has("--json") ? `${JSON.stringify(output, null, 2)}\n` : `${result.run.id}\n`);
|
||||
return 0;
|
||||
}
|
||||
if (action === "update") {
|
||||
const parsed = parseStateArguments(rest, {
|
||||
flags: ["--json", "--clear-plan", "--pop-detour"],
|
||||
values: [
|
||||
"--plan", "--stage", "--depth", "--mutation", "--modules", "--push-detour",
|
||||
"--evidence-freshness", "--evidence-source", "--evidence-reference", "--evidence-captured-at",
|
||||
"--add-approval", "--approval-summary", "--resolve-approval",
|
||||
],
|
||||
});
|
||||
if (parsed.positionals.length !== 1) {
|
||||
throw cliError("Usage: gstack state update <run-id> [workflow transition options] [--json]", "USAGE");
|
||||
}
|
||||
if (parsed.flags.has("--clear-plan") && parsed.values.has("--plan")) {
|
||||
throw cliError("Choose either --plan or --clear-plan", "USAGE");
|
||||
}
|
||||
const transition = {};
|
||||
if (parsed.values.has("--plan")) transition.currentPlanPointer = parsed.values.get("--plan");
|
||||
if (parsed.flags.has("--clear-plan")) transition.currentPlanPointer = null;
|
||||
if (parsed.values.has("--stage")) transition.currentWorkflowStage = parsed.values.get("--stage");
|
||||
if (parsed.values.has("--depth")) transition.selectedDepth = parsed.values.get("--depth");
|
||||
if (parsed.values.has("--mutation")) transition.mutationAuthority = parsed.values.get("--mutation");
|
||||
if (parsed.values.has("--modules")) transition.activeModules = parseModuleList(parsed.values.get("--modules"));
|
||||
if (parsed.values.has("--push-detour")) transition.pushDetour = parsed.values.get("--push-detour");
|
||||
if (parsed.flags.has("--pop-detour")) transition.popDetour = true;
|
||||
if (parsed.values.has("--evidence-freshness")) {
|
||||
transition.evidenceFreshness = parsed.values.get("--evidence-freshness");
|
||||
}
|
||||
const evidenceFields = ["--evidence-source", "--evidence-reference", "--evidence-captured-at"];
|
||||
const hasEvidence = evidenceFields.some((flag) => parsed.values.has(flag));
|
||||
if (hasEvidence) {
|
||||
if (!parsed.values.has("--evidence-source") || !parsed.values.has("--evidence-reference")) {
|
||||
throw cliError("Evidence provenance requires --evidence-source and --evidence-reference", "USAGE");
|
||||
}
|
||||
transition.addEvidenceProvenance = {
|
||||
source: parsed.values.get("--evidence-source"),
|
||||
reference: parsed.values.get("--evidence-reference"),
|
||||
capturedAt: parsed.values.get("--evidence-captured-at"),
|
||||
};
|
||||
if (transition.addEvidenceProvenance.capturedAt === undefined) {
|
||||
delete transition.addEvidenceProvenance.capturedAt;
|
||||
}
|
||||
}
|
||||
if (parsed.values.has("--approval-summary") && !parsed.values.has("--add-approval")) {
|
||||
throw cliError("--approval-summary requires --add-approval", "USAGE");
|
||||
}
|
||||
if (parsed.values.has("--add-approval")) {
|
||||
const summary = parsed.values.get("--approval-summary");
|
||||
if (!summary) throw cliError("--add-approval requires --approval-summary", "USAGE");
|
||||
transition.addApprovalGate = { id: parsed.values.get("--add-approval"), summary };
|
||||
}
|
||||
if (parsed.values.has("--resolve-approval")) {
|
||||
transition.resolveApprovalGate = parsed.values.get("--resolve-approval");
|
||||
}
|
||||
const [runId] = parsed.positionals;
|
||||
const result = await withOwnedRuntimeMutation(home, () =>
|
||||
updateRunWorkflow(home, identity.projectId, runId, transition));
|
||||
write(stdout, `${JSON.stringify({
|
||||
projectId: identity.projectId,
|
||||
run: result.run,
|
||||
reconstruction: result.reconstruction,
|
||||
}, null, 2)}\n`);
|
||||
return 0;
|
||||
}
|
||||
if (action === "effect") {
|
||||
const delimiter = rest.indexOf("--");
|
||||
if (delimiter !== 2 || rest.length < 4) {
|
||||
throw cliError("Usage: gstack state effect <run-id> <effect-key> -- <executable> [args...]", "USAGE");
|
||||
}
|
||||
const [runId, effectKey] = rest;
|
||||
const command = rest.slice(delimiter + 1);
|
||||
const result = await withOwnedRuntimeMutation(home, () => runExternalEffect(home, identity.projectId, runId, effectKey, async ({ idempotencyKey }) =>
|
||||
runExternalCommand(command, {
|
||||
cwd,
|
||||
env: { ...env, GSTACK_IDEMPOTENCY_KEY: idempotencyKey },
|
||||
stdout,
|
||||
stderr,
|
||||
})));
|
||||
if (result.status === "uncertain") {
|
||||
throw cliError(
|
||||
`External effect ${effectKey} was already claimed. Inspect the external system, then reconcile explicitly; it was not repeated.`,
|
||||
"EXTERNAL_EFFECT_UNCERTAIN",
|
||||
);
|
||||
}
|
||||
write(stdout, `${JSON.stringify({ status: result.status, effectKey, idempotencyKey: result.idempotencyKey ?? null, result: result.result })}\n`);
|
||||
return 0;
|
||||
}
|
||||
if (action === "reconcile-not-applied") {
|
||||
const [runId, effectKey, confirmation, ...tail] = rest;
|
||||
if (!runId || !effectKey || confirmation !== "--confirm-not-applied" || tail.length) {
|
||||
throw cliError("Usage: gstack state reconcile-not-applied <run-id> <effect-key> --confirm-not-applied", "USAGE");
|
||||
}
|
||||
const result = await withOwnedRuntimeMutation(home, () => markEffectNotApplied(home, identity.projectId, runId, effectKey));
|
||||
write(stdout, `${JSON.stringify(result.result)}\n`);
|
||||
return 0;
|
||||
}
|
||||
if (action === "reconcile-applied") {
|
||||
const [runId, effectKey, confirmation, evidenceFlag, evidence, ...tail] = rest;
|
||||
if (!runId || !effectKey || confirmation !== "--confirm-applied" || evidenceFlag !== "--evidence" || !evidence || tail.length) {
|
||||
throw cliError("Usage: gstack state reconcile-applied <run-id> <effect-key> --confirm-applied --evidence <reference>", "USAGE");
|
||||
}
|
||||
const result = await withOwnedRuntimeMutation(home, () => markEffectApplied(home, identity.projectId, runId, effectKey, evidence));
|
||||
write(stdout, `${JSON.stringify(result.result)}\n`);
|
||||
return 0;
|
||||
}
|
||||
if (action === "complete") {
|
||||
const [runId, ...tail] = rest;
|
||||
if (!runId || tail.length) throw cliError("Usage: gstack state complete <run-id>", "USAGE");
|
||||
const result = await withOwnedRuntimeMutation(home, () => completeRun(home, identity.projectId, runId));
|
||||
write(stdout, `${JSON.stringify({ projectId: identity.projectId, run: result.run })}\n`);
|
||||
return 0;
|
||||
}
|
||||
throw cliError("Usage: gstack state inspect|begin|update|effect|resume|reconcile-applied|reconcile-not-applied|complete", "USAGE");
|
||||
}
|
||||
|
||||
async function runExternalCommand(command, { cwd, env, stdout, stderr }) {
|
||||
const [executable, ...args] = command;
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(executable, args, {
|
||||
cwd,
|
||||
env,
|
||||
shell: false,
|
||||
stdio: ["inherit", "pipe", "pipe"],
|
||||
});
|
||||
child.stdout?.on("data", (chunk) => write(stdout, chunk));
|
||||
child.stderr?.on("data", (chunk) => write(stderr, chunk));
|
||||
child.once("error", reject);
|
||||
child.once("close", (code, signal) => {
|
||||
if (code === 0) {
|
||||
resolve({ exitCode: 0, executable: path.basename(executable) });
|
||||
return;
|
||||
}
|
||||
const error = cliError(
|
||||
`External command ${path.basename(executable)} ${signal ? `ended by ${signal}` : `exited ${code}`}`,
|
||||
"EXTERNAL_COMMAND_FAILED",
|
||||
);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function withOwnedRuntimeMutation(home, callback) {
|
||||
return withRuntimeLifecycleLock(home, async () => {
|
||||
await assertManagedHome(home);
|
||||
return callback();
|
||||
});
|
||||
}
|
||||
|
||||
async function contextCommand({ args, home, cwd, env, stdin, stdout, stderr }) {
|
||||
const [action, ...rest] = args;
|
||||
if (action === "status") {
|
||||
rejectUnknown(rest, ["--json"]);
|
||||
const status = await contextStatus(home, env);
|
||||
if (rest.includes("--json")) write(stdout, `${JSON.stringify(status, null, 2)}\n`);
|
||||
else {
|
||||
write(stdout, `Context.dev: ${status.contextReady ? "ready" : "not ready"}\nkey: ${status.configured ? `configured (${status.keySource})` : "missing"}\nweb context: ${status.selection ?? "not selected"}\nconsent: ${status.consent ? "yes" : "no"}\n`);
|
||||
}
|
||||
return status.ready ? 0 : 1;
|
||||
}
|
||||
if (action === "options") {
|
||||
rejectUnknown(rest, []);
|
||||
write(stdout, "GStack needs public web context.\n\nA) Set up Context.dev free (recommended)\nB) Use this host's built-in public web search, if available\nC) Use GStack's local browser\nD) Continue without web research\n\nNo URL or credential is sent until Context.dev is explicitly selected and consented.\n");
|
||||
return 0;
|
||||
}
|
||||
if (action === "select") {
|
||||
const [choice, ...tail] = rest;
|
||||
rejectUnknown(tail, []);
|
||||
const modes = { host: "host", browser: "local-browser", "local-browser": "local-browser", none: "off", off: "off" };
|
||||
const mode = modes[choice];
|
||||
if (!mode) throw cliError("Usage: gstack context select host|local-browser|none", "USAGE");
|
||||
await setupRuntime({ home, cwd });
|
||||
await withOwnedRuntimeMutation(home, () => configSetNetworkChoice(home, {
|
||||
mode,
|
||||
consent: false,
|
||||
selection: mode === "off" ? "none" : mode,
|
||||
}));
|
||||
write(stdout, `Web context mode set to ${mode}; Context.dev network export remains off.\n`);
|
||||
return 0;
|
||||
}
|
||||
if (action === "setup") {
|
||||
if (rest.some((arg) => /key|token|secret/i.test(arg) || /^ctxt_secret_/i.test(arg))) {
|
||||
throw cliError("API keys must be supplied through hidden stdin or CONTEXT_DEV_API_KEY, never argv", "KEY_ON_COMMAND_LINE");
|
||||
}
|
||||
rejectUnknown(rest, ["--consent"]);
|
||||
await setupRuntime({ home, cwd });
|
||||
let consent = rest.includes("--consent");
|
||||
if (!consent) {
|
||||
if (!stdin.isTTY) {
|
||||
throw cliError("Explicit consent is required; rerun with --consent when piping a key", "CONSENT_REQUIRED");
|
||||
}
|
||||
const answer = await askLine(stdin, stderr,
|
||||
"Enable Context.dev network requests? This may consume API credits. Type yes to continue: ");
|
||||
consent = answer.trim().toLowerCase() === "yes";
|
||||
}
|
||||
if (!consent) throw cliError("Context.dev setup cancelled; network remains off", "CONSENT_REQUIRED");
|
||||
|
||||
let key;
|
||||
try {
|
||||
key = (await readContextKey({ home, env })).key;
|
||||
} catch (error) {
|
||||
if (error?.code !== "CONTEXT_KEY_MISSING") throw error;
|
||||
key = stdin.isTTY
|
||||
? await readHidden(stdin, stderr, "Context.dev API key: ")
|
||||
: (await readStream(stdin)).trim();
|
||||
}
|
||||
validateContextKey(key);
|
||||
await withOwnedRuntimeMutation(home, async () => {
|
||||
await secretSet(home, "context.apiKey", key);
|
||||
await configSetNetworkChoice(home, {
|
||||
mode: "context",
|
||||
consent: true,
|
||||
selection: "context",
|
||||
});
|
||||
});
|
||||
write(stdout, "Context.dev configured. The key is stored privately; network mode is context.\nKey source: https://www.context.dev/auth.md\n");
|
||||
return 0;
|
||||
}
|
||||
if (action === "smoke") {
|
||||
const parsed = parseFlags(rest, new Set(["--url", "--json"]));
|
||||
const url = parsed.values.get("--url") ?? "https://www.context.dev";
|
||||
const client = new ContextClient({ home, env });
|
||||
const response = await client.scrapeMarkdown(url, { useMainContentOnly: true, maxAgeMs: 86_400_000 });
|
||||
const result = {
|
||||
ok: true,
|
||||
endpoint: "/web/scrape/markdown",
|
||||
url,
|
||||
creditsRemaining: response.key_metadata?.credits_remaining ?? null,
|
||||
};
|
||||
write(stdout, parsed.flags.has("--json") ? `${JSON.stringify(result, null, 2)}\n` :
|
||||
`Context.dev smoke test passed for ${url}${result.creditsRemaining == null ? "" : ` (${result.creditsRemaining} credits remaining)`}\n`);
|
||||
return 0;
|
||||
}
|
||||
throw cliError("Usage: gstack context status|options|select|setup|smoke", "USAGE");
|
||||
}
|
||||
|
||||
async function cleanupCommand({ args, home, stdout }) {
|
||||
const parsed = parseFlags(args, new Set(["--dry-run", "--older-than-hours", "--json"]));
|
||||
const hoursRaw = parsed.values.get("--older-than-hours");
|
||||
const hours = hoursRaw == null ? 24 : Number(hoursRaw);
|
||||
if (!Number.isFinite(hours) || hours < 0) throw cliError("--older-than-hours must be a non-negative number", "USAGE");
|
||||
const result = await cleanupRuntime(home, {
|
||||
dryRun: parsed.flags.has("--dry-run"),
|
||||
olderThanMs: hours * 60 * 60 * 1000,
|
||||
});
|
||||
if (parsed.flags.has("--json")) write(stdout, `${JSON.stringify(result, null, 2)}\n`);
|
||||
else write(stdout, `${result.dryRun ? "Would remove" : "Removed"} ${result.removed.length} stale item(s), ${result.bytesReclaimed} byte(s)\n`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function upgradeCommand({ args, home, stdout, installOptions = {} }) {
|
||||
const parsed = parseFlags(args, new Set(["--source", "--version", "--rollback", "--json"]));
|
||||
if (parsed.flags.has("--rollback")) {
|
||||
if (parsed.values.has("--source") || parsed.values.has("--version")) throw cliError("--rollback cannot be combined with staging options", "USAGE");
|
||||
const pointer = await rollbackUpgrade(home);
|
||||
write(stdout, parsed.flags.has("--json") ? `${JSON.stringify(pointer, null, 2)}\n` : `Rolled back to ${pointer.current}\n`);
|
||||
return 0;
|
||||
}
|
||||
const sourceDir = parsed.values.get("--source");
|
||||
const version = parsed.values.get("--version");
|
||||
if (!sourceDir || !version) {
|
||||
throw cliError("Usage: gstack upgrade --source <complete-gstack-package> --version <version> | --rollback", "USAGE");
|
||||
}
|
||||
const result = await installManagedRuntime({
|
||||
home,
|
||||
sourceDir,
|
||||
version,
|
||||
...installOptions,
|
||||
buildMissing: false,
|
||||
rejectSourceRootLink: true,
|
||||
requirePackageIdentity: true,
|
||||
});
|
||||
write(stdout, parsed.flags.has("--json") ? `${JSON.stringify(result, null, 2)}\n` : `Activated ${result.pointer.current}\n`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function uninstallCommand({ args, home, stdout }) {
|
||||
rejectUnknown(args, ["--purge", "--yes", "--json"]);
|
||||
const purge = args.includes("--purge");
|
||||
if (purge && !args.includes("--yes")) {
|
||||
throw cliError("Purging config, secrets, and project state requires both --purge and --yes", "CONFIRMATION_REQUIRED");
|
||||
}
|
||||
const result = await uninstallManagedRuntime(home, { purge });
|
||||
write(stdout, args.includes("--json") ? `${JSON.stringify(result, null, 2)}\n` :
|
||||
purge ? `Purged gstack state at ${home}\n` : "Removed managed runtime versions; config and project state were preserved.\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
function parseStateArguments(args, options = {}) {
|
||||
const valueFlags = new Set(options.values ?? []);
|
||||
const booleanFlags = new Set(options.flags ?? []);
|
||||
const values = new Map();
|
||||
const flags = new Set();
|
||||
const positionals = [];
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index];
|
||||
if (!arg.startsWith("--")) {
|
||||
positionals.push(arg);
|
||||
continue;
|
||||
}
|
||||
if (valueFlags.has(arg)) {
|
||||
if (values.has(arg)) throw cliError(`Duplicate option: ${arg}`, "USAGE");
|
||||
const value = args[++index];
|
||||
if (value == null || value.startsWith("--")) throw cliError(`${arg} requires a value`, "USAGE");
|
||||
values.set(arg, value);
|
||||
continue;
|
||||
}
|
||||
if (booleanFlags.has(arg)) {
|
||||
if (flags.has(arg)) throw cliError(`Duplicate option: ${arg}`, "USAGE");
|
||||
flags.add(arg);
|
||||
continue;
|
||||
}
|
||||
throw cliError(`Unknown option: ${arg}`, "USAGE");
|
||||
}
|
||||
return { flags, values, positionals };
|
||||
}
|
||||
|
||||
function parseModuleList(value) {
|
||||
if (value === "") return [];
|
||||
const modules = value.split(",").map((entry) => entry.trim());
|
||||
if (modules.some((entry) => !entry)) throw cliError("--modules must be a comma-separated list", "USAGE");
|
||||
return modules;
|
||||
}
|
||||
|
||||
function parseFlags(args, allowed) {
|
||||
const flags = new Set();
|
||||
const values = new Map();
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index];
|
||||
if (!allowed.has(arg)) throw cliError(`Unknown option: ${arg}`, "USAGE");
|
||||
if (["--source", "--version", "--url", "--older-than-hours"].includes(arg)) {
|
||||
const value = args[++index];
|
||||
if (value == null || value.startsWith("--")) throw cliError(`${arg} requires a value`, "USAGE");
|
||||
values.set(arg, value);
|
||||
} else flags.add(arg);
|
||||
}
|
||||
return { flags, values };
|
||||
}
|
||||
|
||||
function rejectUnknown(args, allowed) {
|
||||
for (const arg of args) if (!allowed.includes(arg)) throw cliError(`Unknown option: ${arg}`, "USAGE");
|
||||
}
|
||||
|
||||
async function askLine(input, output, prompt) {
|
||||
const interface_ = readline.createInterface({ input, output, terminal: true });
|
||||
try {
|
||||
return await interface_.question(prompt);
|
||||
} finally {
|
||||
interface_.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function readHidden(input, output, prompt) {
|
||||
if (!input.isTTY || typeof input.setRawMode !== "function") return (await readStream(input)).trim();
|
||||
write(output, prompt);
|
||||
input.setRawMode(true);
|
||||
input.resume();
|
||||
return new Promise((resolve, reject) => {
|
||||
let value = "";
|
||||
const cleanup = () => {
|
||||
input.off("data", onData);
|
||||
input.setRawMode(false);
|
||||
input.pause();
|
||||
write(output, "\n");
|
||||
};
|
||||
const onData = (chunk) => {
|
||||
const text = chunk.toString("utf8");
|
||||
for (const character of text) {
|
||||
if (character === "\u0003") {
|
||||
cleanup();
|
||||
reject(cliError("Context.dev setup cancelled", "CANCELLED"));
|
||||
return;
|
||||
}
|
||||
if (character === "\r" || character === "\n") {
|
||||
cleanup();
|
||||
resolve(value.trim());
|
||||
return;
|
||||
}
|
||||
if (character === "\u007f" || character === "\b") value = value.slice(0, -1);
|
||||
else value += character;
|
||||
}
|
||||
};
|
||||
input.on("data", onData);
|
||||
});
|
||||
}
|
||||
|
||||
async function readStream(stream) {
|
||||
let value = "";
|
||||
for await (const chunk of stream) value += chunk.toString("utf8");
|
||||
return value;
|
||||
}
|
||||
|
||||
function write(stream, value) {
|
||||
stream.write(value);
|
||||
}
|
||||
|
||||
function cliError(message, code) {
|
||||
const error = new Error(message);
|
||||
error.code = code;
|
||||
return error;
|
||||
}
|
||||
|
||||
function exitCodeFor(error) {
|
||||
if (error?.code === "USAGE") return 2;
|
||||
if (["CONTEXT_KEY_MISSING", "CONTEXT_KEY_INVALID", "CONTEXT_EMAIL_UNVERIFIED", "CONTEXT_CREDITS_EXHAUSTED", "CONTEXT_RATE_LIMITED", "CONTEXT_TIMEOUT", "CONTEXT_BLOCKED", "CONTEXT_BAD_RESPONSE"].includes(error?.code)) return 3;
|
||||
return 1;
|
||||
}
|
||||
|
||||
function redactSecrets(message) {
|
||||
return redactSensitiveText(message);
|
||||
}
|
||||
|
||||
function usage() {
|
||||
return `gstack ${RUNTIME_VERSION}\n\n` +
|
||||
"Usage:\n" +
|
||||
" gstack setup\n" +
|
||||
" gstack doctor [--json]\n" +
|
||||
" gstack paths [--json|--shell]\n" +
|
||||
" gstack runtime path <bundle-relative-path>\n" +
|
||||
" gstack config get [key]\n" +
|
||||
" gstack config set <key> <value>\n" +
|
||||
" gstack state inspect [run-id]\n" +
|
||||
" gstack state begin <workflow> [--run-id <id>] [--goal <goal>] [--plan <pointer>] [--stage <stage>] [--depth quick|standard|deep] [--mutation <authority>] [--modules <a,b>]\n" +
|
||||
" gstack state update <run-id> [--plan <pointer>|--clear-plan] [--stage <stage>] [--depth quick|standard|deep] [--mutation <authority>] [--modules <a,b>] [--push-detour <goal>|--pop-detour]\n" +
|
||||
" [--evidence-freshness unknown|fresh|stale] [--evidence-source <source> --evidence-reference <reference> [--evidence-captured-at <ISO>]]\n" +
|
||||
" [--add-approval <id> --approval-summary <summary>|--resolve-approval <id>]\n" +
|
||||
" gstack state effect <run-id> <effect-key> -- <executable> [args...]\n" +
|
||||
" gstack state resume [run-id]\n" +
|
||||
" gstack state reconcile-applied <run-id> <effect-key> --confirm-applied --evidence <reference>\n" +
|
||||
" gstack state reconcile-not-applied <run-id> <effect-key> --confirm-not-applied\n" +
|
||||
" gstack state complete <run-id>\n" +
|
||||
" gstack context status\n" +
|
||||
" gstack context options\n" +
|
||||
" gstack context select host|local-browser|none\n" +
|
||||
" gstack context setup [--consent] # key from hidden stdin or env\n" +
|
||||
" gstack context smoke [--url <public-url>]\n" +
|
||||
" gstack cleanup [--dry-run] [--older-than-hours N]\n" +
|
||||
" gstack upgrade --source <complete-gstack-package> --version <version> | --rollback\n" +
|
||||
" gstack uninstall [--purge --yes]\n";
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { atomicWriteJson, readJson, withLock } from "./storage.js";
|
||||
import { resolveRuntimePaths } from "./paths.js";
|
||||
|
||||
export const DEFAULT_CONFIG = Object.freeze({
|
||||
schemaVersion: 2,
|
||||
network: Object.freeze({ mode: "off", consent: false, selection: null }),
|
||||
context: Object.freeze({ baseUrl: "https://api.context.dev/v1" }),
|
||||
cleanup: Object.freeze({ retentionDays: 30 }),
|
||||
});
|
||||
|
||||
const FORBIDDEN_SEGMENTS = new Set(["__proto__", "prototype", "constructor"]);
|
||||
const COHERENT_NETWORK_CHOICES = new Set([
|
||||
"context:true:context",
|
||||
"host:false:host",
|
||||
"local-browser:false:local-browser",
|
||||
"off:false:none",
|
||||
]);
|
||||
|
||||
export async function ensureConfig(home) {
|
||||
const paths = resolveRuntimePaths({ home });
|
||||
await fs.mkdir(home, { recursive: true, mode: 0o700 });
|
||||
return withLock(path.join(paths.locks, "config.lock"), async () => {
|
||||
let config = await readJson(paths.config, null);
|
||||
if (!config) {
|
||||
config = mergeDefaults(await readLegacyConfig(home));
|
||||
validateConfig(config);
|
||||
await atomicWriteJson(paths.config, config, { mode: 0o644 });
|
||||
}
|
||||
let secrets = await readJson(paths.secrets, null);
|
||||
if (!secrets) {
|
||||
secrets = { schemaVersion: 2, context: {} };
|
||||
await atomicWriteJson(paths.secrets, secrets, { mode: 0o600 });
|
||||
} else {
|
||||
await fs.chmod(paths.secrets, 0o600);
|
||||
}
|
||||
return { config, secrets };
|
||||
});
|
||||
}
|
||||
|
||||
/** Read-only migration input. config.json remains the sole write authority. */
|
||||
export async function readLegacyConfig(home) {
|
||||
const legacyPath = path.join(home, "config.yaml");
|
||||
const content = await fs.readFile(legacyPath, "utf8").catch((error) => {
|
||||
if (error?.code === "ENOENT") return "";
|
||||
throw error;
|
||||
});
|
||||
const result = {};
|
||||
for (const line of content.split(/\r?\n/)) {
|
||||
const match = line.match(/^([A-Za-z0-9_]+(?:@[a-f0-9]+)?):\s*(.*?)\s*(?:#.*)?$/);
|
||||
if (!match) continue;
|
||||
const raw = unquoteLegacyScalar(match[2]);
|
||||
result[match[1]] = parseConfigValue(raw);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function unquoteLegacyScalar(value) {
|
||||
if (value.length >= 2 && ((value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'")))) return value.slice(1, -1);
|
||||
return value;
|
||||
}
|
||||
|
||||
export async function loadConfig(home) {
|
||||
const paths = resolveRuntimePaths({ home });
|
||||
const stored = await readJson(paths.config, null);
|
||||
return stored ? mergeDefaults(stored) : cloneDefaultConfig();
|
||||
}
|
||||
|
||||
export async function loadSecrets(home, options = {}) {
|
||||
const paths = resolveRuntimePaths({ home });
|
||||
try {
|
||||
const stat = await fs.stat(paths.secrets);
|
||||
if (process.platform !== "win32" && (stat.mode & 0o077) !== 0) {
|
||||
if (options.repairPermissions) await fs.chmod(paths.secrets, 0o600);
|
||||
else {
|
||||
const error = new Error(`Secrets file permissions must be 0600: ${paths.secrets}`);
|
||||
error.code = "INSECURE_SECRETS";
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
return await readJson(paths.secrets, { schemaVersion: 2, context: {} });
|
||||
} catch (error) {
|
||||
if (error?.code === "ENOENT") return { schemaVersion: 2, context: {} };
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function configGet(home, key) {
|
||||
const config = await loadConfig(home);
|
||||
if (!key) return config;
|
||||
return getPath(config, key);
|
||||
}
|
||||
|
||||
export async function configSet(home, key, value) {
|
||||
if (!key) throw new TypeError("A config key is required");
|
||||
if (looksLikeSecretKey(key)) {
|
||||
const error = new Error("Secrets cannot be stored in config.json; use `gstack context setup`");
|
||||
error.code = "SECRET_IN_CONFIG";
|
||||
throw error;
|
||||
}
|
||||
return updateConfig(home, (config) => {
|
||||
setPath(config, key, value);
|
||||
return getPath(config, key);
|
||||
});
|
||||
}
|
||||
|
||||
/** Persist the complete network choice in one locked atomic replacement. */
|
||||
export async function configSetNetworkChoice(home, choice) {
|
||||
const keys = Object.keys(choice ?? {}).sort();
|
||||
if (keys.join(",") !== "consent,mode,selection") {
|
||||
throw new TypeError("A network choice requires mode, consent, and selection");
|
||||
}
|
||||
const signature = `${choice.mode}:${choice.consent}:${choice.selection}`;
|
||||
if (!COHERENT_NETWORK_CHOICES.has(signature)) {
|
||||
throw new TypeError("Network mode, consent, and selection must describe one coherent choice");
|
||||
}
|
||||
return updateConfig(home, (config) => {
|
||||
config.network = { ...config.network, ...choice };
|
||||
return { ...config.network };
|
||||
});
|
||||
}
|
||||
|
||||
async function updateConfig(home, mutate) {
|
||||
const paths = resolveRuntimePaths({ home });
|
||||
return withLock(path.join(paths.locks, "config.lock"), async () => {
|
||||
const config = mergeDefaults(await readJson(paths.config, cloneDefaultConfig()));
|
||||
const result = mutate(config);
|
||||
validateConfig(config);
|
||||
await atomicWriteJson(paths.config, config, { mode: 0o644 });
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
function looksLikeSecretKey(key) {
|
||||
const normalized = String(key).replace(/([a-z0-9])([A-Z])/g, "$1.$2");
|
||||
return normalized.split(/[.\-_]/).some((segment) =>
|
||||
/^(key|apikey|api.?key|secret|token|jwt|session|cookie|access.?token|refresh.?token|password|passwd|credential|credentials|authorization|bearer)$/i.test(segment),
|
||||
) || /api[._-]?key|access[._-]?token|refresh[._-]?token/i.test(String(key));
|
||||
}
|
||||
|
||||
export async function secretSet(home, key, value) {
|
||||
if (typeof value !== "string" || value.length === 0) throw new TypeError("Secret value is required");
|
||||
const paths = resolveRuntimePaths({ home });
|
||||
return withLock(path.join(paths.locks, "config.lock"), async () => {
|
||||
const secrets = await readJson(paths.secrets, { schemaVersion: 2, context: {} });
|
||||
setPath(secrets, key, value);
|
||||
await atomicWriteJson(paths.secrets, secrets, { mode: 0o600 });
|
||||
});
|
||||
}
|
||||
|
||||
export function parseConfigValue(raw) {
|
||||
if (typeof raw !== "string") return raw;
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
export function getPath(object, dotted) {
|
||||
return splitKey(dotted).reduce((value, segment) => value?.[segment], object);
|
||||
}
|
||||
|
||||
export function setPath(object, dotted, value) {
|
||||
const parts = splitKey(dotted);
|
||||
let cursor = object;
|
||||
for (const segment of parts.slice(0, -1)) {
|
||||
if (!cursor[segment] || typeof cursor[segment] !== "object" || Array.isArray(cursor[segment])) {
|
||||
cursor[segment] = {};
|
||||
}
|
||||
cursor = cursor[segment];
|
||||
}
|
||||
cursor[parts.at(-1)] = value;
|
||||
}
|
||||
|
||||
function splitKey(dotted) {
|
||||
if (typeof dotted !== "string" || !dotted) throw new TypeError("Config key is required");
|
||||
const parts = dotted.split(".");
|
||||
if (parts.some((part) => !part || FORBIDDEN_SEGMENTS.has(part))) throw new TypeError("Invalid config key");
|
||||
return parts;
|
||||
}
|
||||
|
||||
function validateConfig(config) {
|
||||
if (config.network?.mode != null && !["off", "context", "host", "local-browser"].includes(config.network.mode)) {
|
||||
throw new TypeError("network.mode must be `off`, `context`, `host`, or `local-browser`");
|
||||
}
|
||||
if (config.network?.consent != null && typeof config.network.consent !== "boolean") {
|
||||
throw new TypeError("network.consent must be a boolean");
|
||||
}
|
||||
if (config.network?.selection != null && !["context", "host", "local-browser", "none"].includes(config.network.selection)) {
|
||||
throw new TypeError("network.selection must be `context`, `host`, `local-browser`, `none`, or null");
|
||||
}
|
||||
if (config.context?.baseUrl != null) {
|
||||
const url = new URL(config.context.baseUrl);
|
||||
if (url.origin !== "https://api.context.dev" || !["/v1", "/v1/"].includes(url.pathname) ||
|
||||
url.search || url.hash || url.username || url.password) {
|
||||
throw new TypeError("context.baseUrl must be the official credential-free Context.dev v1 HTTPS endpoint");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function cloneDefaultConfig() {
|
||||
return JSON.parse(JSON.stringify(DEFAULT_CONFIG));
|
||||
}
|
||||
|
||||
function mergeDefaults(stored) {
|
||||
return {
|
||||
...cloneDefaultConfig(),
|
||||
...stored,
|
||||
network: { ...DEFAULT_CONFIG.network, ...(stored.network ?? {}) },
|
||||
context: { ...DEFAULT_CONFIG.context, ...(stored.context ?? {}) },
|
||||
cleanup: { ...DEFAULT_CONFIG.cleanup, ...(stored.cleanup ?? {}) },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,830 @@
|
||||
import net from "node:net";
|
||||
import dns from "node:dns/promises";
|
||||
import { loadConfig, loadSecrets } from "./config.js";
|
||||
|
||||
export const CONTEXT_FAILURES = Object.freeze([
|
||||
"CONTEXT_KEY_MISSING",
|
||||
"CONTEXT_KEY_INVALID",
|
||||
"CONTEXT_EMAIL_UNVERIFIED",
|
||||
"CONTEXT_CREDITS_EXHAUSTED",
|
||||
"CONTEXT_RATE_LIMITED",
|
||||
"CONTEXT_TIMEOUT",
|
||||
"CONTEXT_BLOCKED",
|
||||
"CONTEXT_BAD_RESPONSE",
|
||||
]);
|
||||
|
||||
const CONTEXT_FAILURE_SET = new Set(CONTEXT_FAILURES);
|
||||
const OFFICIAL_BASE_URL = "https://api.context.dev/v1";
|
||||
const PREFIXED_CREDENTIAL = /(?:^|[^A-Za-z0-9])(?:AIza[0-9A-Za-z_-]{20,}|AKIA[0-9A-Z]{16}|(?:ctxt|github_pat|gh[pousr]|sk|pk|rk|xox[aboprs])[-_][A-Za-z0-9._~-]{10,}|eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,})(?:$|[^A-Za-z0-9])/i;
|
||||
// `/` is a public URL/path separator, not part of one opaque candidate. Each
|
||||
// path segment is still scanned independently, avoiding false positives where
|
||||
// a long mixed-case documentation path looked like one credential.
|
||||
const OPAQUE_TOKEN_CANDIDATE = /[A-Za-z0-9._~+=-]{32,}/g;
|
||||
const MAX_CREDENTIAL_DECODE_PASSES = 8;
|
||||
const MIN_OPAQUE_TOKEN_ENTROPY = 4.25;
|
||||
|
||||
const BOOLEAN_OPTION = "boolean";
|
||||
const INTEGER_OPTION = "integer";
|
||||
const STRING_OPTION = "string";
|
||||
const STRING_ARRAY_OPTION = "string-array";
|
||||
const PDF_OPTIONS = Object.freeze({
|
||||
shouldParse: BOOLEAN_OPTION,
|
||||
start: INTEGER_OPTION,
|
||||
end: INTEGER_OPTION,
|
||||
ocr: BOOLEAN_OPTION,
|
||||
});
|
||||
const VIEWPORT_OPTIONS = Object.freeze({
|
||||
width: INTEGER_OPTION,
|
||||
height: INTEGER_OPTION,
|
||||
});
|
||||
|
||||
// Context.dev also documents free-form outbound headers and request tags. They
|
||||
// are deliberately excluded: either can carry authentication or unrelated
|
||||
// private text. Only the public extraction controls below may cross the boundary.
|
||||
const PUBLIC_OPTION_SCHEMAS = Object.freeze({
|
||||
scrapeMarkdown: Object.freeze({
|
||||
includeLinks: BOOLEAN_OPTION,
|
||||
includeImages: BOOLEAN_OPTION,
|
||||
shortenBase64Images: BOOLEAN_OPTION,
|
||||
useMainContentOnly: BOOLEAN_OPTION,
|
||||
pdf: Object.freeze({ object: PDF_OPTIONS }),
|
||||
includeFrames: BOOLEAN_OPTION,
|
||||
includeSelectors: STRING_ARRAY_OPTION,
|
||||
excludeSelectors: STRING_ARRAY_OPTION,
|
||||
maxAgeMs: INTEGER_OPTION,
|
||||
waitForMs: INTEGER_OPTION,
|
||||
settleAnimations: BOOLEAN_OPTION,
|
||||
country: STRING_OPTION,
|
||||
timeoutMS: INTEGER_OPTION,
|
||||
}),
|
||||
scrapeHtml: Object.freeze({
|
||||
pdf: Object.freeze({ object: PDF_OPTIONS }),
|
||||
includeFrames: BOOLEAN_OPTION,
|
||||
useMainContentOnly: BOOLEAN_OPTION,
|
||||
includeSelectors: STRING_ARRAY_OPTION,
|
||||
excludeSelectors: STRING_ARRAY_OPTION,
|
||||
maxAgeMs: INTEGER_OPTION,
|
||||
waitForMs: INTEGER_OPTION,
|
||||
settleAnimations: BOOLEAN_OPTION,
|
||||
country: STRING_OPTION,
|
||||
timeoutMS: INTEGER_OPTION,
|
||||
}),
|
||||
crawl: Object.freeze({
|
||||
maxPages: INTEGER_OPTION,
|
||||
maxDepth: INTEGER_OPTION,
|
||||
urlRegex: STRING_OPTION,
|
||||
includeLinks: BOOLEAN_OPTION,
|
||||
includeImages: BOOLEAN_OPTION,
|
||||
shortenBase64Images: BOOLEAN_OPTION,
|
||||
useMainContentOnly: BOOLEAN_OPTION,
|
||||
followSubdomains: BOOLEAN_OPTION,
|
||||
pdf: Object.freeze({ object: PDF_OPTIONS }),
|
||||
includeFrames: BOOLEAN_OPTION,
|
||||
includeSelectors: STRING_ARRAY_OPTION,
|
||||
excludeSelectors: STRING_ARRAY_OPTION,
|
||||
maxAgeMs: INTEGER_OPTION,
|
||||
waitForMs: INTEGER_OPTION,
|
||||
settleAnimations: BOOLEAN_OPTION,
|
||||
stopAfterMs: INTEGER_OPTION,
|
||||
country: STRING_OPTION,
|
||||
timeoutMS: INTEGER_OPTION,
|
||||
}),
|
||||
sitemap: Object.freeze({
|
||||
maxLinks: INTEGER_OPTION,
|
||||
sitemapUrl: STRING_OPTION,
|
||||
urlRegex: STRING_OPTION,
|
||||
timeoutMS: INTEGER_OPTION,
|
||||
}),
|
||||
screenshot: Object.freeze({
|
||||
domain: STRING_OPTION,
|
||||
directUrl: STRING_OPTION,
|
||||
fullScreenshot: BOOLEAN_OPTION,
|
||||
page: Object.freeze({ enum: Object.freeze(["login", "signup", "blog", "careers", "pricing", "terms", "privacy", "contact"]) }),
|
||||
waitForMs: INTEGER_OPTION,
|
||||
viewport: Object.freeze({ object: VIEWPORT_OPTIONS }),
|
||||
handleCookiePopup: BOOLEAN_OPTION,
|
||||
colorScheme: Object.freeze({ enum: Object.freeze(["light", "dark"]) }),
|
||||
scrollOffset: INTEGER_OPTION,
|
||||
maxAgeMs: INTEGER_OPTION,
|
||||
country: STRING_OPTION,
|
||||
timeoutMS: INTEGER_OPTION,
|
||||
}),
|
||||
});
|
||||
|
||||
export class ContextError extends Error {
|
||||
constructor(code, message, options = {}) {
|
||||
if (!CONTEXT_FAILURE_SET.has(code)) throw new TypeError(`Unknown Context.dev failure code: ${code}`);
|
||||
const secrets = options.secrets ?? [];
|
||||
const safeCause = sanitizeErrorCause(options.cause, secrets);
|
||||
super(redactSensitiveText(message, secrets), safeCause ? { cause: safeCause } : undefined);
|
||||
this.name = "ContextError";
|
||||
this.code = code;
|
||||
this.status = options.status;
|
||||
this.retryAfter = options.retryAfter;
|
||||
this.details = redactSensitiveValue(options.details, secrets);
|
||||
this.unsupported = Boolean(options.unsupported);
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
name: this.name,
|
||||
code: this.code,
|
||||
message: this.message,
|
||||
...(this.status == null ? {} : { status: this.status }),
|
||||
...(this.retryAfter == null ? {} : { retryAfter: this.retryAfter }),
|
||||
...(this.unsupported ? { unsupported: true } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function readContextKey(options = {}) {
|
||||
const env = options.env ?? process.env;
|
||||
const fromEnv = env.CONTEXT_DEV_API_KEY || env.CONTEXT_API_KEY;
|
||||
if (fromEnv?.trim()) return { key: fromEnv.trim(), source: "environment" };
|
||||
|
||||
const home = options.home;
|
||||
if (!home) throw new ContextError("CONTEXT_KEY_MISSING", "Context.dev API key is not configured");
|
||||
const secrets = await loadSecrets(home);
|
||||
const key = secrets?.context?.apiKey;
|
||||
if (!key) throw new ContextError("CONTEXT_KEY_MISSING", "Context.dev API key is not configured");
|
||||
return { key: String(key).trim(), source: "secrets.json" };
|
||||
}
|
||||
|
||||
export function validateContextKey(key) {
|
||||
if (typeof key !== "string" || !key) {
|
||||
throw new ContextError("CONTEXT_KEY_MISSING", "Context.dev API key is missing");
|
||||
}
|
||||
// Do not bake in a provider prefix: Context.dev may rotate key formats.
|
||||
// Whitespace is never valid in a bearer token; a small length floor catches
|
||||
// accidental empty/placeholder values without rejecting a future prefix.
|
||||
if (key.length < 12 || /\s/.test(key)) {
|
||||
throw new ContextError("CONTEXT_KEY_INVALID", "Context.dev API key has an invalid format");
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lexically validate a URL before any DNS lookup or HTTP request occurs.
|
||||
*/
|
||||
export function assertPublicUrl(input) {
|
||||
let url;
|
||||
try {
|
||||
url = input instanceof URL ? new URL(input.href) : new URL(String(input));
|
||||
} catch (cause) {
|
||||
throw new ContextError("CONTEXT_BLOCKED", "Target must be an absolute public HTTP(S) URL", { cause });
|
||||
}
|
||||
if (!["http:", "https:"].includes(url.protocol)) {
|
||||
throw new ContextError("CONTEXT_BLOCKED", "Only HTTP and HTTPS target URLs are allowed");
|
||||
}
|
||||
if (url.username || url.password) {
|
||||
throw new ContextError("CONTEXT_BLOCKED", "Target URLs must not contain credentials");
|
||||
}
|
||||
assertNoCredentialMaterial(decodeUrlComponent(url.pathname), "URL path");
|
||||
for (const [key, value] of url.searchParams) {
|
||||
if (isSensitiveFieldName(key) || containsCredentialLabel(key)) {
|
||||
throw new ContextError("CONTEXT_BLOCKED", `Target URL contains credential-like query parameter: ${key}`);
|
||||
}
|
||||
assertNoCredentialMaterial(key, "URL query parameter name");
|
||||
assertNoCredentialMaterial(value, "URL query value");
|
||||
}
|
||||
const fragment = decodeUrlFragment(url.hash.slice(1));
|
||||
assertNoCredentialMaterial(fragment, "URL fragment");
|
||||
for (const part of fragment.split(/[?&;]/)) {
|
||||
const separator = part.indexOf("=");
|
||||
if (separator === -1) continue;
|
||||
const key = part.slice(0, separator).trim();
|
||||
if (isSensitiveFieldName(key) || containsCredentialLabel(key)) {
|
||||
throw new ContextError("CONTEXT_BLOCKED", `Target URL contains credential-like fragment parameter: ${key}`);
|
||||
}
|
||||
assertNoCredentialMaterial(part.slice(separator + 1), "URL fragment value");
|
||||
}
|
||||
assertPublicHostname(url.hostname);
|
||||
return url;
|
||||
}
|
||||
|
||||
export function assertPublicHostname(input) {
|
||||
const hostname = String(input).replace(/^\[|\]$/g, "").replace(/\.$/, "").toLowerCase();
|
||||
if (!hostname || hostname.includes("\0")) {
|
||||
throw new ContextError("CONTEXT_BLOCKED", "Target hostname is missing or invalid");
|
||||
}
|
||||
const ipVersion = net.isIP(hostname);
|
||||
if (ipVersion) {
|
||||
if (!isPublicIp(hostname)) throw new ContextError("CONTEXT_BLOCKED", "Target IP address is not public");
|
||||
return hostname;
|
||||
}
|
||||
if (hostname === "localhost" || hostname.endsWith(".localhost")) {
|
||||
throw new ContextError("CONTEXT_BLOCKED", "Localhost targets are not allowed");
|
||||
}
|
||||
const forbiddenSuffixes = [
|
||||
".local", ".internal", ".intranet", ".lan", ".home", ".home.arpa",
|
||||
".localdomain", ".corp", ".private", ".test", ".invalid", ".example",
|
||||
];
|
||||
if (!hostname.includes(".") || forbiddenSuffixes.some((suffix) => hostname.endsWith(suffix))) {
|
||||
throw new ContextError("CONTEXT_BLOCKED", "Private or non-public hostnames are not allowed");
|
||||
}
|
||||
if (/^(metadata|instance-data)(\.|$)/.test(hostname) || hostname === "metadata.google.internal") {
|
||||
throw new ContextError("CONTEXT_BLOCKED", "Cloud metadata hostnames are not allowed");
|
||||
}
|
||||
return hostname;
|
||||
}
|
||||
|
||||
export function isPublicIp(input) {
|
||||
const address = String(input).replace(/^\[|\]$/g, "");
|
||||
const version = net.isIP(address);
|
||||
if (version === 4) return isPublicIpv4(address);
|
||||
if (version === 6) return isPublicIpv6(address);
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function assertPublicUrlResolved(input, options = {}) {
|
||||
const url = assertPublicUrl(input);
|
||||
const hostname = url.hostname.replace(/^\[|\]$/g, "");
|
||||
if (net.isIP(hostname)) return url;
|
||||
const lookup = options.lookup ?? dns.lookup;
|
||||
let records;
|
||||
try {
|
||||
records = await lookup(hostname, { all: true, verbatim: true });
|
||||
} catch (cause) {
|
||||
throw new ContextError("CONTEXT_BLOCKED", "Target hostname could not be resolved publicly", { cause });
|
||||
}
|
||||
const list = Array.isArray(records) ? records : [records];
|
||||
if (!list.length || list.some((record) => !isPublicIp(record?.address ?? record))) {
|
||||
throw new ContextError("CONTEXT_BLOCKED", "Target hostname resolves to a non-public address");
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
export function mapContextFailure(status, payload = {}, cause, headers, options = {}) {
|
||||
const secrets = options.secrets ?? [];
|
||||
const safeCause = sanitizeErrorCause(cause, secrets);
|
||||
if (cause?.name === "AbortError" || cause?.code === "ABORT_ERR" || cause?.code === "ETIMEDOUT") {
|
||||
return new ContextError("CONTEXT_TIMEOUT", "Context.dev request timed out", { status, cause: safeCause, secrets });
|
||||
}
|
||||
const apiCode = String(payload?.error_code ?? payload?.code ?? "").toUpperCase();
|
||||
const rawMessage = String(payload?.message ?? payload?.error ?? "Context.dev request failed");
|
||||
const searchable = `${apiCode} ${rawMessage}`.toLowerCase();
|
||||
const message = redactSensitiveText(rawMessage, secrets);
|
||||
const details = payload && typeof payload === "object" ? redactSensitiveValue(payload, secrets) : undefined;
|
||||
|
||||
if (status === 429 || apiCode === "RATE_LIMITED") {
|
||||
const retryAfter = getHeader(headers, "retry-after");
|
||||
return new ContextError("CONTEXT_RATE_LIMITED", message, { status, retryAfter, cause: safeCause, details, secrets });
|
||||
}
|
||||
if (status === 408 || apiCode === "REQUEST_TIMEOUT" || apiCode === "TIMEOUT_EXCEEDS_MAXIMUM") {
|
||||
return new ContextError("CONTEXT_TIMEOUT", message, { status, cause: safeCause, details, secrets });
|
||||
}
|
||||
if (apiCode === "USAGE_EXCEEDED" || /credits?\s*(exhausted|exceeded|remaining\s*[:=]?\s*0)|usage\s*(limit|exceeded)|quota/.test(searchable)) {
|
||||
return new ContextError("CONTEXT_CREDITS_EXHAUSTED", message, { status, cause: safeCause, details, secrets });
|
||||
}
|
||||
if (/email.*(unverified|not verified|verify)|verify.*email/.test(searchable)) {
|
||||
return new ContextError("CONTEXT_EMAIL_UNVERIFIED", message, { status, cause: safeCause, details, secrets });
|
||||
}
|
||||
if (apiCode === "WEBSITE_ACCESS_ERROR" || apiCode === "EXTERNAL_PROVIDER_ERROR" ||
|
||||
/blocked|private address|localhost|link.local|website access|hostile waf/.test(searchable)) {
|
||||
return new ContextError("CONTEXT_BLOCKED", message, { status, cause: safeCause, details, secrets });
|
||||
}
|
||||
if (status === 401 || ["UNAUTHORIZED", "DISABLED", "INSUFFICIENT_PERMISSIONS", "FORBIDDEN"].includes(apiCode) || status === 403) {
|
||||
return new ContextError("CONTEXT_KEY_INVALID", message, { status, cause: safeCause, details, secrets });
|
||||
}
|
||||
return new ContextError("CONTEXT_BAD_RESPONSE", message, { status, cause: safeCause, details, secrets });
|
||||
}
|
||||
|
||||
export class ContextClient {
|
||||
constructor(options = {}) {
|
||||
this.home = options.home;
|
||||
this.env = options.env ?? process.env;
|
||||
this.fetch = options.fetch ?? globalThis.fetch;
|
||||
this.lookup = options.lookup ?? dns.lookup;
|
||||
this.resolveDns = options.resolveDns ?? true;
|
||||
this.config = options.config;
|
||||
this.key = options.key;
|
||||
this.timeoutMs = options.timeoutMs ?? 90_000;
|
||||
this.baseUrl = options.baseUrl;
|
||||
}
|
||||
|
||||
async scrapeMarkdown(url, options = {}) {
|
||||
const publicOptions = assertPublicRequestOptions("scrapeMarkdown", options);
|
||||
const target = String(url);
|
||||
await this.#gateTarget(target);
|
||||
return this.#request("GET", "/web/scrape/markdown", { query: { ...publicOptions, url: target } });
|
||||
}
|
||||
|
||||
async scrapeHtml(url, options = {}) {
|
||||
const publicOptions = assertPublicRequestOptions("scrapeHtml", options);
|
||||
const target = String(url);
|
||||
await this.#gateTarget(target);
|
||||
return this.#request("GET", "/web/scrape/html", { query: { ...publicOptions, url: target } });
|
||||
}
|
||||
|
||||
async crawl(url, options = {}) {
|
||||
const publicOptions = assertPublicRequestOptions("crawl", options);
|
||||
const target = String(url);
|
||||
await this.#gateTarget(target);
|
||||
return this.#request("POST", "/web/crawl", { body: { ...publicOptions, url: target } });
|
||||
}
|
||||
|
||||
async sitemap(domain, options = {}) {
|
||||
const publicOptions = assertPublicRequestOptions("sitemap", options);
|
||||
const normalized = normalizeDomain(domain);
|
||||
await this.#gateTarget(`https://${normalized}`);
|
||||
if (publicOptions.sitemapUrl) await this.#gateTarget(publicOptions.sitemapUrl);
|
||||
return this.#request("GET", "/web/scrape/sitemap", { query: { ...publicOptions, domain: normalized } });
|
||||
}
|
||||
|
||||
async screenshot(target, options = {}) {
|
||||
let query;
|
||||
if (target && typeof target === "object" && !(target instanceof URL)) {
|
||||
query = { ...target, ...options };
|
||||
} else {
|
||||
const serialized = String(target);
|
||||
query = /^https?:\/\//i.test(serialized)
|
||||
? { directUrl: serialized, ...options }
|
||||
: { domain: serialized, ...options };
|
||||
}
|
||||
query = assertPublicRequestOptions("screenshot", query);
|
||||
if (query.directUrl && query.domain) {
|
||||
throw new ContextError("CONTEXT_BAD_RESPONSE", "Screenshot accepts either domain or directUrl, not both");
|
||||
}
|
||||
if (query.directUrl) await this.#gateTarget(query.directUrl);
|
||||
else {
|
||||
query.domain = normalizeDomain(query.domain);
|
||||
await this.#gateTarget(`https://${query.domain}`);
|
||||
}
|
||||
return this.#request("GET", "/screenshot", { query });
|
||||
}
|
||||
|
||||
async search() {
|
||||
throw new ContextError(
|
||||
"CONTEXT_BAD_RESPONSE",
|
||||
"Context.dev Search API is deprecated and is intentionally unsupported",
|
||||
{ unsupported: true },
|
||||
);
|
||||
}
|
||||
|
||||
async #gateTarget(input) {
|
||||
// The lexical gate is intentionally first and performs no I/O. DNS is only
|
||||
// reached after #networkSettings confirms explicit persisted consent.
|
||||
const url = assertPublicUrl(input);
|
||||
const settings = await this.#networkSettings();
|
||||
if (!settings.enabled) {
|
||||
throw new ContextError("CONTEXT_BLOCKED", "Network access is off; run `gstack context setup --consent`");
|
||||
}
|
||||
if (this.resolveDns) {
|
||||
try {
|
||||
await withTimeout(assertPublicUrlResolved(url, { lookup: this.lookup }), this.timeoutMs);
|
||||
} catch (cause) {
|
||||
if (cause instanceof ContextError) throw cause;
|
||||
throw mapContextFailure(undefined, {}, cause);
|
||||
}
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
async #networkSettings() {
|
||||
const config = this.config ?? await loadConfig(this.home);
|
||||
return {
|
||||
enabled: hasContextNetworkConsent(config),
|
||||
config,
|
||||
};
|
||||
}
|
||||
|
||||
async #apiKey() {
|
||||
if (this.key) return validateContextKey(String(this.key));
|
||||
return validateContextKey((await readContextKey({ home: this.home, env: this.env })).key);
|
||||
}
|
||||
|
||||
async #request(method, endpoint, options = {}) {
|
||||
if (typeof this.fetch !== "function") {
|
||||
throw new ContextError("CONTEXT_BAD_RESPONSE", "This Node runtime does not provide fetch()");
|
||||
}
|
||||
const { config, enabled } = await this.#networkSettings();
|
||||
if (!enabled) {
|
||||
throw new ContextError("CONTEXT_BLOCKED", "Network access is off; explicit Context.dev consent is required");
|
||||
}
|
||||
const configuredBase = this.baseUrl ?? config?.context?.baseUrl ?? OFFICIAL_BASE_URL;
|
||||
const baseUrl = validateBaseUrl(configuredBase);
|
||||
const key = await this.#apiKey();
|
||||
if (!key) throw new ContextError("CONTEXT_KEY_MISSING", "Context.dev API key is not configured");
|
||||
const requestUrl = new URL(`${baseUrl.replace(/\/$/, "")}${endpoint}`);
|
||||
addQuery(requestUrl.searchParams, options.query ?? {});
|
||||
const controller = new AbortController();
|
||||
const timeoutMs = options.timeoutMs ?? this.timeoutMs;
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
timeout.unref?.();
|
||||
let response;
|
||||
try {
|
||||
try {
|
||||
response = await raceWithAbort(this.fetch(requestUrl, {
|
||||
method,
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
Authorization: `Bearer ${key}`,
|
||||
...(options.body ? { "Content-Type": "application/json" } : {}),
|
||||
},
|
||||
body: options.body ? JSON.stringify(options.body) : undefined,
|
||||
signal: controller.signal,
|
||||
redirect: "error",
|
||||
}), controller.signal);
|
||||
} catch (cause) {
|
||||
if (cause instanceof ContextError) throw cause;
|
||||
throw mapContextFailure(undefined, {}, cause, undefined, { secrets: [key] });
|
||||
}
|
||||
|
||||
let text;
|
||||
try {
|
||||
text = await raceWithAbort(response.text(), controller.signal);
|
||||
} catch (cause) {
|
||||
throw mapContextFailure(response.status, {}, cause, response.headers, { secrets: [key] });
|
||||
}
|
||||
let payload;
|
||||
try {
|
||||
payload = text ? JSON.parse(text) : null;
|
||||
} catch (cause) {
|
||||
throw new ContextError("CONTEXT_BAD_RESPONSE", "Context.dev returned malformed JSON", {
|
||||
status: response.status,
|
||||
cause,
|
||||
secrets: [key],
|
||||
});
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw mapContextFailure(response.status, payload, undefined, response.headers, { secrets: [key] });
|
||||
}
|
||||
if (!payload || typeof payload !== "object") {
|
||||
throw new ContextError("CONTEXT_BAD_RESPONSE", "Context.dev returned an empty or invalid response", {
|
||||
status: response.status,
|
||||
secrets: [key],
|
||||
});
|
||||
}
|
||||
return payload;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function assertPublicRequestOptions(endpoint, options) {
|
||||
const schema = PUBLIC_OPTION_SCHEMAS[endpoint];
|
||||
if (!schema) throw new TypeError(`Unknown Context.dev option schema: ${endpoint}`);
|
||||
return copyOptionObject(options, schema, endpoint);
|
||||
}
|
||||
|
||||
function copyOptionObject(value, schema, trail) {
|
||||
if (!isPlainObject(value)) {
|
||||
throw new ContextError("CONTEXT_BLOCKED", `Context.dev ${trail} options must be a plain public-data object`);
|
||||
}
|
||||
const copy = {};
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
if (!Object.hasOwn(schema, key)) {
|
||||
throw new ContextError("CONTEXT_BLOCKED", `Context.dev request option is not allowlisted: ${trail}.${key}`);
|
||||
}
|
||||
copy[key] = copyOptionValue(child, schema[key], `${trail}.${key}`);
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
function copyOptionValue(value, schema, trail) {
|
||||
if (value == null) return value;
|
||||
if (schema === BOOLEAN_OPTION) {
|
||||
if (typeof value === "boolean" || value === "true" || value === "false") return value;
|
||||
} else if (schema === INTEGER_OPTION) {
|
||||
if (Number.isSafeInteger(value)) return value;
|
||||
} else if (schema === STRING_OPTION) {
|
||||
if (typeof value === "string" && !value.includes("\0")) {
|
||||
assertNoCredentialMaterial(value, trail);
|
||||
return value;
|
||||
}
|
||||
} else if (schema === STRING_ARRAY_OPTION) {
|
||||
if (Array.isArray(value) && value.every((item) => typeof item === "string" && !item.includes("\0"))) {
|
||||
for (const item of value) assertNoCredentialMaterial(item, trail);
|
||||
return [...value];
|
||||
}
|
||||
} else if (schema?.object) {
|
||||
return copyOptionObject(value, schema.object, trail);
|
||||
} else if (schema?.enum) {
|
||||
if (schema.enum.includes(value)) return value;
|
||||
}
|
||||
throw new ContextError("CONTEXT_BLOCKED", `Context.dev request option has a disallowed shape: ${trail}`);
|
||||
}
|
||||
|
||||
function isPlainObject(value) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
return prototype === Object.prototype || prototype === null;
|
||||
}
|
||||
|
||||
function hasContextNetworkConsent(config) {
|
||||
return config?.network?.selection === "context" &&
|
||||
config?.network?.mode === "context" &&
|
||||
config?.network?.consent === true;
|
||||
}
|
||||
|
||||
export async function contextStatus(home, env = process.env) {
|
||||
const config = await loadConfig(home);
|
||||
let keySource = null;
|
||||
try {
|
||||
keySource = (await readContextKey({ home, env })).source;
|
||||
} catch (error) {
|
||||
if (error?.code !== "CONTEXT_KEY_MISSING") throw error;
|
||||
}
|
||||
const contextReady = Boolean(keySource) && hasContextNetworkConsent(config);
|
||||
return {
|
||||
configured: Boolean(keySource),
|
||||
keySource,
|
||||
networkMode: config.network.mode,
|
||||
selection: config.network.selection,
|
||||
consent: config.network.consent === true,
|
||||
contextReady,
|
||||
ready: config.network.selection === "context"
|
||||
? contextReady
|
||||
: ["host", "local-browser", "none"].includes(config.network.selection),
|
||||
needsChoice: config.network.selection == null,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeDomain(input) {
|
||||
const raw = String(input ?? "").trim();
|
||||
if (!raw) throw new ContextError("CONTEXT_BLOCKED", "A public domain is required");
|
||||
const url = assertPublicUrl(raw.includes("://") ? raw : `https://${raw}`);
|
||||
if (url.pathname !== "/" || url.search || url.hash || url.port) {
|
||||
throw new ContextError("CONTEXT_BLOCKED", "Expected a bare public domain");
|
||||
}
|
||||
return url.hostname.replace(/^\[|\]$/g, "");
|
||||
}
|
||||
|
||||
function decodeUrlFragment(fragment) {
|
||||
try {
|
||||
return decodeURIComponent(fragment);
|
||||
} catch {
|
||||
return fragment;
|
||||
}
|
||||
}
|
||||
|
||||
function validateBaseUrl(input) {
|
||||
let url;
|
||||
try {
|
||||
url = new URL(String(input));
|
||||
} catch (cause) {
|
||||
throw new ContextError("CONTEXT_BAD_RESPONSE", "Invalid Context.dev API base URL", { cause });
|
||||
}
|
||||
if (url.origin !== "https://api.context.dev" || url.username || url.password ||
|
||||
!["/v1", "/v1/"].includes(url.pathname) || url.search || url.hash) {
|
||||
throw new ContextError("CONTEXT_BAD_RESPONSE", "Refusing to send a Context.dev key to a non-official API host");
|
||||
}
|
||||
return OFFICIAL_BASE_URL;
|
||||
}
|
||||
|
||||
export function redactSensitiveText(message, knownSecrets = []) {
|
||||
let safe = String(message ?? "");
|
||||
const exactSecrets = new Set();
|
||||
for (const secret of knownSecrets) {
|
||||
const raw = String(secret ?? "");
|
||||
if (raw.length < 8) continue;
|
||||
exactSecrets.add(raw);
|
||||
try {
|
||||
exactSecrets.add(encodeURIComponent(raw));
|
||||
} catch {}
|
||||
}
|
||||
for (const secret of exactSecrets) safe = safe.split(secret).join("[REDACTED]");
|
||||
safe = safe.replace(/(\bAuthorization\s*[:=]\s*)[^\r\n,}]+/gi, "$1[REDACTED]");
|
||||
safe = safe.replace(/(\b(?:Bearer|Basic)\s+)[A-Za-z0-9._~+/=-]{8,}/gi, "$1[REDACTED]");
|
||||
safe = safe.replace(/((?:[?&#]|\b)(?:access_?token|api_?key|auth(?:orization)?|client_?secret|credential|id_?token|jwt|oauth_?token|password|refresh_?token|secret|session|signature|token)=)[^&#\s,}]+/gi, "$1[REDACTED]");
|
||||
safe = safe.replace(/(\b(?:access[_-]?token|api[_-]?key|auth(?:orization)?|client[_-]?secret|credential|id[_-]?token|jwt|oauth[_-]?token|password|refresh[_-]?token|secret|session|signature|token)\s*[:=]\s*["']?)[^\s,"'&}]+/gi, "$1[REDACTED]");
|
||||
safe = safe.replace(/[A-Za-z0-9._~+/=-]{32,}/g, (candidate) =>
|
||||
looksOpaqueCredential(candidate) ? "[REDACTED]" : candidate);
|
||||
return safe;
|
||||
}
|
||||
|
||||
function redactSensitiveValue(value, knownSecrets = [], seen = new WeakSet()) {
|
||||
if (typeof value === "string") return redactSensitiveText(value, knownSecrets);
|
||||
if (!value || typeof value !== "object") return value;
|
||||
if (seen.has(value)) return "[REDACTED CYCLE]";
|
||||
seen.add(value);
|
||||
if (Array.isArray(value)) return value.map((item) => redactSensitiveValue(item, knownSecrets, seen));
|
||||
const copy = {};
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
copy[key] = isSensitiveFieldName(key)
|
||||
? "[REDACTED]"
|
||||
: redactSensitiveValue(child, knownSecrets, seen);
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
function sanitizeErrorCause(cause, knownSecrets = []) {
|
||||
if (!cause) return undefined;
|
||||
const safe = new Error(redactSensitiveText(cause?.message ?? String(cause), knownSecrets));
|
||||
safe.name = String(cause?.name ?? "Error");
|
||||
if (cause?.code != null) safe.code = cause.code;
|
||||
return safe;
|
||||
}
|
||||
|
||||
function assertNoCredentialMaterial(value, location) {
|
||||
let candidate = String(value ?? "");
|
||||
for (let pass = 0; pass < MAX_CREDENTIAL_DECODE_PASSES; pass += 1) {
|
||||
if (containsCredentialMaterial(candidate)) {
|
||||
throw new ContextError("CONTEXT_BLOCKED", `Context.dev ${location} contains secret-shaped data`);
|
||||
}
|
||||
const decoded = decodeUrlComponent(candidate);
|
||||
if (decoded === candidate) return;
|
||||
candidate = decoded;
|
||||
}
|
||||
// A value that remains encoded after the inspection cap could hide a token
|
||||
// at arbitrary depth. Fail closed rather than forwarding residual encoding.
|
||||
throw new ContextError("CONTEXT_BLOCKED", `Context.dev ${location} uses excessive nested URL encoding`);
|
||||
}
|
||||
|
||||
function containsCredentialMaterial(value) {
|
||||
const text = String(value ?? "");
|
||||
if (!text) return false;
|
||||
if (PREFIXED_CREDENTIAL.test(text)) return true;
|
||||
for (const candidate of text.match(OPAQUE_TOKEN_CANDIDATE) ?? []) {
|
||||
if (looksOpaqueCredential(candidate)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function looksOpaqueCredential(candidate) {
|
||||
const token = candidate.replace(/[.,;:!?]+$/, "");
|
||||
if (token.length < 32 || /^[a-f0-9]{32,}$/i.test(token) || isUuid(token) || isReadablePublicSlug(token)) return false;
|
||||
const categories = [/[a-z]/, /[A-Z]/, /\d/, /[._~+/=-]/]
|
||||
.reduce((count, pattern) => count + Number(pattern.test(token)), 0);
|
||||
return categories >= 3 && shannonEntropy(token) >= MIN_OPAQUE_TOKEN_ENTROPY;
|
||||
}
|
||||
|
||||
function isUuid(value) {
|
||||
return /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(value);
|
||||
}
|
||||
|
||||
function isReadablePublicSlug(value) {
|
||||
const parts = value.split("-");
|
||||
if (parts.length < 3) return false;
|
||||
let wordParts = 0;
|
||||
for (const part of parts) {
|
||||
if (/^[A-Za-z]{1,24}$/.test(part)) {
|
||||
wordParts += 1;
|
||||
continue;
|
||||
}
|
||||
if (/^\d{1,8}$/.test(part)) continue;
|
||||
if (/^[A-Za-z]{2,20}\d{1,4}$/.test(part)) {
|
||||
wordParts += 1;
|
||||
continue;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return wordParts >= 2;
|
||||
}
|
||||
|
||||
function shannonEntropy(value) {
|
||||
const counts = new Map();
|
||||
for (const character of value) counts.set(character, (counts.get(character) ?? 0) + 1);
|
||||
let entropy = 0;
|
||||
for (const count of counts.values()) {
|
||||
const probability = count / value.length;
|
||||
entropy -= probability * Math.log2(probability);
|
||||
}
|
||||
return entropy;
|
||||
}
|
||||
|
||||
function isSensitiveFieldName(key) {
|
||||
const normalized = String(key).replace(/[^A-Za-z0-9]/g, "").toLowerCase();
|
||||
if (["authorization", "clientsecret", "code", "cookie", "credential", "idtoken", "jwt", "key", "password", "refreshtoken", "secret", "session", "setcookie", "signature", "token"].includes(normalized)) {
|
||||
return true;
|
||||
}
|
||||
return /(?:access|api|auth|client|oauth|refresh|private|security|session|xamz|xgoog)(?:credential|key|password|secret|signature|token)$/.test(normalized) ||
|
||||
/(?:credential|password|secret|signature|token)$/.test(normalized);
|
||||
}
|
||||
|
||||
function containsCredentialLabel(value) {
|
||||
let candidate = String(value ?? "");
|
||||
for (let pass = 0; pass < MAX_CREDENTIAL_DECODE_PASSES; pass += 1) {
|
||||
const parts = candidate.split(/[\s/?#&;=:[\](){},]+/).filter(Boolean);
|
||||
if (parts.some((part) => isSensitiveFieldName(part))) return true;
|
||||
const decoded = decodeUrlComponent(candidate);
|
||||
if (decoded === candidate) return false;
|
||||
candidate = decoded;
|
||||
}
|
||||
// Residual nested encoding after the shared cap is ambiguous and may conceal
|
||||
// a credential label. Match the value scanner's fail-closed behavior.
|
||||
return true;
|
||||
}
|
||||
|
||||
function raceWithAbort(promise, signal) {
|
||||
if (signal.aborted) return Promise.reject(abortError());
|
||||
return new Promise((resolve, reject) => {
|
||||
const onAbort = () => reject(abortError());
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
Promise.resolve(promise).then(resolve, reject).finally(() => {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function withTimeout(promise, timeoutMs) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
timeout.unref?.();
|
||||
try {
|
||||
return await raceWithAbort(promise, controller.signal);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
function abortError() {
|
||||
const error = new Error("The operation was aborted");
|
||||
error.name = "AbortError";
|
||||
error.code = "ABORT_ERR";
|
||||
return error;
|
||||
}
|
||||
|
||||
function decodeUrlComponent(value) {
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function addQuery(searchParams, values, prefix = "") {
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
if (value == null) continue;
|
||||
const name = prefix ? `${prefix}[${key}]` : key;
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) searchParams.append(name, String(item));
|
||||
} else if (typeof value === "object") {
|
||||
addQuery(searchParams, value, name);
|
||||
} else {
|
||||
searchParams.append(name, String(value));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getHeader(headers, name) {
|
||||
if (!headers) return undefined;
|
||||
if (typeof headers.get === "function") return headers.get(name) ?? undefined;
|
||||
return headers[name] ?? headers[name.toLowerCase()];
|
||||
}
|
||||
|
||||
function isPublicIpv4(address) {
|
||||
const parts = address.split(".").map(Number);
|
||||
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return false;
|
||||
const value = (((parts[0] * 256 + parts[1]) * 256 + parts[2]) * 256 + parts[3]) >>> 0;
|
||||
const blocked = [
|
||||
["0.0.0.0", 8], ["10.0.0.0", 8], ["100.64.0.0", 10], ["127.0.0.0", 8],
|
||||
["169.254.0.0", 16], ["172.16.0.0", 12], ["192.0.0.0", 24], ["192.0.2.0", 24],
|
||||
["192.168.0.0", 16], ["198.18.0.0", 15], ["198.51.100.0", 24], ["203.0.113.0", 24],
|
||||
["224.0.0.0", 4], ["240.0.0.0", 4],
|
||||
];
|
||||
return !blocked.some(([base, bits]) => inIpv4Cidr(value, ipv4Number(base), bits));
|
||||
}
|
||||
|
||||
function ipv4Number(address) {
|
||||
return address.split(".").map(Number).reduce((value, part) => value * 256 + part, 0) >>> 0;
|
||||
}
|
||||
|
||||
function inIpv4Cidr(value, base, bits) {
|
||||
const mask = bits === 0 ? 0 : (0xffffffff << (32 - bits)) >>> 0;
|
||||
return (value & mask) === (base & mask);
|
||||
}
|
||||
|
||||
function isPublicIpv6(address) {
|
||||
let value;
|
||||
try {
|
||||
value = ipv6BigInt(address);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if ((value >> 32n) === 0xffffn) {
|
||||
const ipv4 = Number(value & 0xffffffffn);
|
||||
return isPublicIpv4(`${ipv4 >>> 24}.${(ipv4 >>> 16) & 255}.${(ipv4 >>> 8) & 255}.${ipv4 & 255}`);
|
||||
}
|
||||
const ranges = [
|
||||
["::", 128], ["::1", 128], ["64:ff9b:1::", 48], ["100::", 64], ["2001:2::", 48],
|
||||
["2001:10::", 28], ["2001:db8::", 32], ["2002::", 16],
|
||||
["fc00::", 7], ["fec0::", 10], ["fe80::", 10], ["ff00::", 8],
|
||||
];
|
||||
return !ranges.some(([base, bits]) => inIpv6Cidr(value, ipv6BigInt(base), bits));
|
||||
}
|
||||
|
||||
function ipv6BigInt(address) {
|
||||
let source = address.toLowerCase().split("%")[0];
|
||||
if (source.includes(".")) {
|
||||
const lastColon = source.lastIndexOf(":");
|
||||
const ipv4 = source.slice(lastColon + 1).split(".").map(Number);
|
||||
if (ipv4.length !== 4 || ipv4.some((part) => part < 0 || part > 255)) throw new Error("bad IPv6");
|
||||
source = `${source.slice(0, lastColon)}:${((ipv4[0] << 8) | ipv4[1]).toString(16)}:${((ipv4[2] << 8) | ipv4[3]).toString(16)}`;
|
||||
}
|
||||
const halves = source.split("::");
|
||||
if (halves.length > 2) throw new Error("bad IPv6");
|
||||
const left = halves[0] ? halves[0].split(":") : [];
|
||||
const right = halves[1] ? halves[1].split(":") : [];
|
||||
const missing = 8 - left.length - right.length;
|
||||
if ((halves.length === 1 && missing !== 0) || missing < 0) throw new Error("bad IPv6");
|
||||
const groups = [...left, ...Array(missing).fill("0"), ...right];
|
||||
if (groups.length !== 8 || groups.some((group) => !/^[0-9a-f]{1,4}$/.test(group))) throw new Error("bad IPv6");
|
||||
return groups.reduce((total, group) => (total << 16n) + BigInt(`0x${group}`), 0n);
|
||||
}
|
||||
|
||||
function inIpv6Cidr(value, base, bits) {
|
||||
const shift = BigInt(128 - bits);
|
||||
return (value >> shift) === (base >> shift);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { constants as fsConstants } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { spawn as nodeSpawn } from "node:child_process";
|
||||
import { resolveRuntimePaths } from "./paths.js";
|
||||
import { readJson, pathExists } from "./storage.js";
|
||||
import { discoverProjectIdentity } from "./identity.js";
|
||||
import { RUNTIME_SCHEMA_VERSION, RUNTIME_MIGRATION_ID } from "./migrations.js";
|
||||
import { assertManagedHome } from "./managed-home.js";
|
||||
import { recoverPendingUpgrade } from "./upgrade.js";
|
||||
|
||||
export async function runDoctor(options = {}) {
|
||||
const paths = resolveRuntimePaths(options);
|
||||
const checks = [];
|
||||
const add = (id, status, message, details) => checks.push({ id, status, message, ...(details ? { details } : {}) });
|
||||
const now = options.now ? options.now() : new Date();
|
||||
|
||||
const node = await inspectLauncherNode(options.nodeCommand ?? process.env.GSTACK_NODE ?? "node");
|
||||
add("runtime", node.ok ? "pass" : "fail", node.message, node.details);
|
||||
|
||||
if (!(await pathExists(paths.home))) {
|
||||
add("home", "fail", `State home does not exist: ${paths.home}`, { remedy: "Run `gstack setup`." });
|
||||
} else {
|
||||
try {
|
||||
await fs.access(paths.home, fsConstants.R_OK | fsConstants.W_OK);
|
||||
add("home", "pass", `State home is readable and writable: ${paths.home}`);
|
||||
} catch (error) {
|
||||
add("home", "fail", `State home is not readable and writable: ${paths.home}`, { code: error.code });
|
||||
}
|
||||
try {
|
||||
await assertManagedHome(paths.home, options);
|
||||
add("ownership", "pass", "Managed home ownership sentinel is valid");
|
||||
} catch (error) {
|
||||
add("ownership", "fail", `Managed home ownership cannot be verified: ${error.message}`, {
|
||||
code: error.code,
|
||||
remedy: "Run `gstack setup` with the intended GSTACK_HOME.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const config = await readJson(paths.config);
|
||||
add("config", config?.schemaVersion <= RUNTIME_SCHEMA_VERSION ? "pass" : "fail",
|
||||
`Config schema ${config?.schemaVersion ?? "unknown"}`);
|
||||
const enabled = config?.network?.mode === "context" && config?.network?.consent === true;
|
||||
add("network", enabled ? "pass" : "warn",
|
||||
enabled ? "Context.dev network mode has explicit consent" : "Network access is off (safe default)");
|
||||
} catch (error) {
|
||||
add("config", "fail", `Config cannot be read: ${error.message}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const stat = await fs.stat(paths.secrets);
|
||||
const privateMode = process.platform === "win32" || (stat.mode & 0o077) === 0;
|
||||
await readJson(paths.secrets);
|
||||
add("secrets", privateMode ? "pass" : "fail",
|
||||
privateMode ? "Secrets file is private" : "Secrets file permissions are broader than 0600",
|
||||
process.platform === "win32" ? undefined : { mode: `0${(stat.mode & 0o777).toString(8)}` });
|
||||
} catch (error) {
|
||||
add("secrets", "fail", `Secrets file cannot be read: ${error.message}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const migration = await readJson(paths.migrations);
|
||||
const supported = migration.schemaVersion === RUNTIME_SCHEMA_VERSION &&
|
||||
migration.applied?.some((entry) => entry.id === RUNTIME_MIGRATION_ID);
|
||||
add("migration", supported ? "pass" : "fail",
|
||||
supported ? `Forward-only schema ${migration.schemaVersion} is current` : "Migration marker is absent or unsupported");
|
||||
} catch (error) {
|
||||
add("migration", "fail", `Migration marker cannot be read: ${error.message}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const identity = await discoverProjectIdentity(options.cwd ?? process.cwd());
|
||||
const stateFile = path.join(paths.projects, identity.projectId, "state.json");
|
||||
if (await pathExists(stateFile)) {
|
||||
const state = await readJson(stateFile);
|
||||
const valid = state.schemaVersion <= RUNTIME_SCHEMA_VERSION && state.project?.id === identity.projectId;
|
||||
add("project", valid ? "pass" : "fail",
|
||||
valid ? `Project state found for ${identity.projectId}` : "Project state identity/schema does not match");
|
||||
} else {
|
||||
add("project", "warn", `No state initialized for ${identity.projectId}`, { remedy: "Run `gstack setup`." });
|
||||
}
|
||||
add("git", identity.isGit ? "pass" : "warn",
|
||||
identity.isGit ? `Git worktree ${identity.worktreeId}` : "Current directory is not a Git worktree");
|
||||
} catch (error) {
|
||||
add("project", "fail", `Project identity failed: ${error.message}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const recovery = await recoverPendingUpgrade(paths.home, options);
|
||||
const pointer = recovery.pointer;
|
||||
if (!pointer) add("upgrade", "pass", "No managed version pointer (package-managed install)");
|
||||
else if (recovery.recovered) {
|
||||
add("upgrade", pointer.current ? "warn" : "fail", pointer.current
|
||||
? `Recovered interrupted upgrade to last-known-good version: ${pointer.current}`
|
||||
: "Interrupted upgrade had no valid last-known-good version");
|
||||
} else add("upgrade", "pass", pointer.current ? `Active managed version: ${pointer.current}` : "No active managed version");
|
||||
} catch (error) {
|
||||
add("upgrade", "fail", `Version pointer cannot be read: ${error.message}`);
|
||||
}
|
||||
|
||||
return {
|
||||
ok: !checks.some((check) => check.status === "fail"),
|
||||
home: paths.home,
|
||||
checkedAt: now.toISOString(),
|
||||
checks,
|
||||
};
|
||||
}
|
||||
|
||||
async function inspectLauncherNode(command) {
|
||||
try {
|
||||
const result = await captureCommand(command, ["--version"]);
|
||||
const raw = `${result.stdout}${result.stderr}`.trim();
|
||||
const major = Number(raw.match(/v?(\d+)\./)?.[1]);
|
||||
if (!Number.isInteger(major) || major < 18) {
|
||||
return { ok: false, message: `Node 18+ is required by launchers; ${command} reported ${raw || "an unknown version"}` };
|
||||
}
|
||||
return { ok: true, message: `Launcher Node ${raw.replace(/^v/, "")}`, details: { command } };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
message: `Node 18+ launcher runtime is unavailable: ${error.message}`,
|
||||
details: { command, code: error.code },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function captureCommand(command, args) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = nodeSpawn(command, args, {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
windowsHide: true,
|
||||
shell: false,
|
||||
});
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.setEncoding("utf8");
|
||||
child.stderr.setEncoding("utf8");
|
||||
child.stdout.on("data", (chunk) => { stdout += chunk; });
|
||||
child.stderr.on("data", (chunk) => { stderr += chunk; });
|
||||
child.once("error", reject);
|
||||
child.once("exit", (code) => {
|
||||
if (code === 0) resolve({ stdout, stderr });
|
||||
else {
|
||||
const error = new Error(`${command} --version exited with ${code}`);
|
||||
error.code = "NODE_UNAVAILABLE";
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function formatDoctor(report) {
|
||||
const symbol = { pass: "OK", warn: "WARN", fail: "FAIL" };
|
||||
const lines = [`gstack doctor: ${report.ok ? "healthy" : "needs attention"}`, `home: ${report.home}`];
|
||||
for (const check of report.checks) lines.push(`${symbol[check.status]} ${check.id}: ${check.message}`);
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { createHash } from "node:crypto";
|
||||
import { execFile as execFileCallback } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execFile = promisify(execFileCallback);
|
||||
|
||||
export function stableId(namespace, value, length = 20) {
|
||||
const digest = createHash("sha256")
|
||||
.update(`gstack:${namespace}:v2\0`, "utf8")
|
||||
.update(String(value), "utf8")
|
||||
.digest("hex")
|
||||
.slice(0, length);
|
||||
return `${namespace}_${digest}`;
|
||||
}
|
||||
|
||||
export async function discoverProjectIdentity(cwd = process.cwd(), options = {}) {
|
||||
const absoluteCwd = await canonicalPath(cwd);
|
||||
const git = options.git ?? runGit;
|
||||
try {
|
||||
const [worktreeRootRaw, commonDirRaw, gitDirRaw] = await Promise.all([
|
||||
git(["rev-parse", "--show-toplevel"], absoluteCwd),
|
||||
git(["rev-parse", "--git-common-dir"], absoluteCwd),
|
||||
git(["rev-parse", "--git-dir"], absoluteCwd),
|
||||
]);
|
||||
const worktreeRoot = await canonicalPath(resolveGitPath(worktreeRootRaw, absoluteCwd));
|
||||
const commonDir = await canonicalPath(resolveGitPath(commonDirRaw, worktreeRoot));
|
||||
const gitDir = await canonicalPath(resolveGitPath(gitDirRaw, worktreeRoot));
|
||||
return identityFromPaths({ worktreeRoot, commonDir, gitDir, isGit: true });
|
||||
} catch (error) {
|
||||
if (options.requireGit) throw error;
|
||||
if (!isNotGitRepository(error)) throw error;
|
||||
return identityFromPaths({
|
||||
worktreeRoot: absoluteCwd,
|
||||
commonDir: absoluteCwd,
|
||||
gitDir: absoluteCwd,
|
||||
isGit: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function identityFromPaths({ worktreeRoot, commonDir, gitDir, isGit = true }) {
|
||||
const resolvedRoot = path.resolve(worktreeRoot);
|
||||
const resolvedCommon = path.resolve(commonDir);
|
||||
const resolvedGitDir = path.resolve(gitDir);
|
||||
const normalizedRoot = normalizeIdentityPath(resolvedRoot);
|
||||
const normalizedCommon = normalizeIdentityPath(resolvedCommon);
|
||||
const repoId = stableId("repo", normalizedCommon);
|
||||
|
||||
// Linked worktrees have a durable git-dir slot under the common repository.
|
||||
// The Git slot is stable when a linked checkout moves and unique within the
|
||||
// common repository. Non-Git folders have no slot, so their canonical path
|
||||
// remains the identity boundary.
|
||||
const gitSlot = path.relative(resolvedCommon, resolvedGitDir) || ".";
|
||||
const worktreeId = stableId(
|
||||
"worktree",
|
||||
isGit ? `${repoId}\0${normalizeIdentityPath(gitSlot)}` : `${repoId}\0${normalizedRoot}`,
|
||||
);
|
||||
const projectId = stableId("project", `${repoId}\0${worktreeId}`, 24);
|
||||
return Object.freeze({
|
||||
projectId,
|
||||
repoId,
|
||||
worktreeId,
|
||||
worktreeRoot: resolvedRoot,
|
||||
repoCommonDir: resolvedCommon,
|
||||
gitDir: resolvedGitDir,
|
||||
isGit,
|
||||
});
|
||||
}
|
||||
|
||||
function isNotGitRepository(error) {
|
||||
if (error?.code === "NOT_GIT") return true;
|
||||
const detail = `${error?.stderr ?? ""}\n${error?.message ?? ""}`;
|
||||
return (error?.code === 128 || error?.exitCode === 128) && /not a git repository/i.test(detail);
|
||||
}
|
||||
|
||||
async function runGit(args, cwd) {
|
||||
const { stdout } = await execFile("git", args, {
|
||||
cwd,
|
||||
encoding: "utf8",
|
||||
timeout: 5_000,
|
||||
maxBuffer: 1024 * 1024,
|
||||
windowsHide: true,
|
||||
});
|
||||
return stdout.replace(/[\r\n]+$/, "");
|
||||
}
|
||||
|
||||
function resolveGitPath(value, cwd) {
|
||||
if (!value) throw new Error("git returned an empty path");
|
||||
return path.isAbsolute(value) ? value : path.resolve(cwd, value);
|
||||
}
|
||||
|
||||
async function canonicalPath(value) {
|
||||
const absolute = path.resolve(value);
|
||||
try {
|
||||
return await fs.realpath(absolute);
|
||||
} catch (error) {
|
||||
if (error?.code === "ENOENT") return absolute;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeIdentityPath(value) {
|
||||
let normalized = path.normalize(String(value)).replaceAll(path.sep, "/");
|
||||
if (process.platform === "win32") normalized = normalized.toLowerCase();
|
||||
return normalized;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export * from "./paths.js";
|
||||
export * from "./managed-home.js";
|
||||
export * from "./storage.js";
|
||||
export * from "./config.js";
|
||||
export * from "./identity.js";
|
||||
export * from "./migrations.js";
|
||||
export * from "./state.js";
|
||||
export * from "./setup.js";
|
||||
export * from "./context.js";
|
||||
export * from "./doctor.js";
|
||||
export * from "./cleanup.js";
|
||||
export * from "./upgrade.js";
|
||||
export * from "./install.js";
|
||||
+1266
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,314 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { assertPathInside, resolveRuntimePaths } from "./paths.js";
|
||||
import { atomicWriteFile, atomicWriteJson, readJson, withLock } from "./storage.js";
|
||||
|
||||
export const MANAGED_HOME_SCHEMA_VERSION = 1;
|
||||
export const MANAGED_HOME_SENTINEL = ".gstack-managed-home.json";
|
||||
export const RUNTIME_TRANSACTION_FILE = ".gstack-runtime-transaction.json";
|
||||
|
||||
/**
|
||||
* Runtime install/upgrade/uninstall use a sibling lock so a purge cannot
|
||||
* delete the lock that is protecting it. All destructive runtime lifecycle
|
||||
* operations must use this lock, not a command-specific lock under home.
|
||||
*/
|
||||
export function runtimeLifecycleLockPath(home) {
|
||||
const resolved = assertSafeManagedHomePath(home);
|
||||
return `${resolved}.runtime-lifecycle.lock`;
|
||||
}
|
||||
|
||||
export function assertSafeManagedHomePath(home, options = {}) {
|
||||
if (typeof home !== "string" || home.length === 0 || home.includes("\0")) {
|
||||
throw managedHomeError("A non-empty managed home path is required", "MANAGED_HOME_UNSAFE");
|
||||
}
|
||||
const resolved = path.resolve(home);
|
||||
const root = path.parse(resolved).root;
|
||||
const userHome = path.resolve(options.homeDir ?? os.homedir());
|
||||
const cwd = path.resolve(options.cwd ?? process.cwd());
|
||||
|
||||
if (resolved === root || resolved === userHome || path.dirname(resolved) === root) {
|
||||
throw managedHomeError(`Refusing unsafe managed home: ${resolved}`, "MANAGED_HOME_UNSAFE");
|
||||
}
|
||||
if (isSameOrAncestor(resolved, cwd)) {
|
||||
throw managedHomeError(
|
||||
`Refusing managed home that contains the current working directory: ${resolved}`,
|
||||
"MANAGED_HOME_UNSAFE",
|
||||
);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export async function ensureManagedHome(home, options = {}) {
|
||||
const resolved = assertSafeManagedHomePath(home, options);
|
||||
const existing = await fs.lstat(resolved).catch((error) => {
|
||||
if (error?.code === "ENOENT") return null;
|
||||
throw error;
|
||||
});
|
||||
if (existing?.isSymbolicLink() || (existing && !existing.isDirectory())) {
|
||||
throw managedHomeError(`Managed home must be a real directory, not a link or file: ${resolved}`, "MANAGED_HOME_UNSAFE");
|
||||
}
|
||||
if (!existing) await fs.mkdir(resolved, { recursive: true, mode: 0o700 });
|
||||
const sentinelPath = path.join(resolved, MANAGED_HOME_SENTINEL);
|
||||
const sentinelStat = await fs.lstat(sentinelPath).catch((error) => {
|
||||
if (error?.code === "ENOENT") return null;
|
||||
throw error;
|
||||
});
|
||||
if (sentinelStat?.isSymbolicLink() || (sentinelStat && !sentinelStat.isFile())) {
|
||||
throw managedHomeError(`Managed home sentinel is not a regular file: ${sentinelPath}`, "MANAGED_HOME_INVALID");
|
||||
}
|
||||
|
||||
if (!sentinelStat) {
|
||||
const entries = await fs.readdir(resolved);
|
||||
const legacy = entries.length > 0
|
||||
? await inspectRecognizedLegacyHome(resolved, entries)
|
||||
: null;
|
||||
if (entries.length > 0 && !legacy) {
|
||||
throw managedHomeError(
|
||||
`Refusing to claim a non-empty directory as managed home: ${resolved}`,
|
||||
"MANAGED_HOME_UNOWNED",
|
||||
);
|
||||
}
|
||||
const sentinel = {
|
||||
schemaVersion: MANAGED_HOME_SCHEMA_VERSION,
|
||||
kind: "gstack-managed-home",
|
||||
home: resolved,
|
||||
ownerId: randomUUID(),
|
||||
createdAt: isoNow(options.now),
|
||||
...(legacy ? {
|
||||
adoptedLegacy: true,
|
||||
preexistingTopLevel: [...entries].sort(),
|
||||
} : {}),
|
||||
};
|
||||
await createOwnershipSentinel(sentinelPath, sentinel);
|
||||
const claimedEntries = (await fs.readdir(resolved)).sort();
|
||||
const expectedEntries = [...entries, MANAGED_HOME_SENTINEL].sort();
|
||||
if (JSON.stringify(claimedEntries) !== JSON.stringify(expectedEntries)) {
|
||||
await removeSentinelIfOwned(sentinelPath, sentinel.ownerId);
|
||||
throw managedHomeError(
|
||||
`Managed home changed while ownership was being claimed: ${resolved}`,
|
||||
"MANAGED_HOME_UNOWNED",
|
||||
);
|
||||
}
|
||||
return { home: resolved, sentinel, created: true };
|
||||
}
|
||||
|
||||
const sentinel = await readAndValidateSentinel(sentinelPath, resolved);
|
||||
return { home: resolved, sentinel, created: false };
|
||||
}
|
||||
|
||||
async function inspectRecognizedLegacyHome(home, entries) {
|
||||
// Record all pre-existing top-level entries in the sentinel so purge can
|
||||
// never remove them, even if their names later overlap the managed runtime
|
||||
// allowlist. Reject links before inspecting either legacy fingerprint.
|
||||
for (const entry of entries) {
|
||||
const stat = await fs.lstat(path.join(home, entry));
|
||||
if (stat.isSymbolicLink()) return null;
|
||||
}
|
||||
|
||||
// A legacy config with a known GStack key is the original adoption proof.
|
||||
if (entries.includes("config.yaml")) {
|
||||
const configPath = path.join(home, "config.yaml");
|
||||
const configStat = await fs.lstat(configPath);
|
||||
if (configStat.isFile() && configStat.size <= 1024 * 1024) {
|
||||
const text = await fs.readFile(configPath, "utf8");
|
||||
const knownKey = /^(?:proactive|routing_declined|telemetry|auto_upgrade|update_check|skill_prefix|checkpoint_mode|checkpoint_push|explain_level|codex_reviews|gstack_contributor|skip_eng_review|workspace_root|cross_project_learnings|artifacts_sync_mode|plan_tune_hooks|redact_repo_visibility|redact_prepush_hook|brain_trust_policy(?:@[a-f0-9]+)?):\s*/m;
|
||||
if (knownKey.test(text)) return { kind: "legacy-config", configPath };
|
||||
}
|
||||
}
|
||||
|
||||
// gstack-artifacts-init historically ran before the first config write, so
|
||||
// an existing artifacts repo can have no config.yaml and no ownership
|
||||
// sentinel. Require the complete, content-bearing GStack fingerprint: a
|
||||
// bare .git directory (or one marker file) must never make an arbitrary
|
||||
// directory adoptable.
|
||||
const artifactFiles = [
|
||||
".gitignore",
|
||||
".brain-allowlist",
|
||||
".brain-privacy-map.json",
|
||||
".gitattributes",
|
||||
];
|
||||
if (!entries.includes(".git") || !artifactFiles.every((entry) => entries.includes(entry))) return null;
|
||||
const gitStat = await fs.lstat(path.join(home, ".git"));
|
||||
if (!gitStat.isDirectory()) return null;
|
||||
const stats = await Promise.all(artifactFiles.map((entry) => fs.lstat(path.join(home, entry))));
|
||||
if (stats.some((stat) => !stat.isFile() || stat.size > 1024 * 1024)) return null;
|
||||
|
||||
const [gitignore, allowlist, privacyText, attributes] = await Promise.all(
|
||||
artifactFiles.map((entry) => fs.readFile(path.join(home, entry), "utf8")),
|
||||
);
|
||||
let privacyMap;
|
||||
try {
|
||||
privacyMap = JSON.parse(privacyText);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const hasCanonicalPrivacyEntry = Array.isArray(privacyMap) && privacyMap.some((entry) =>
|
||||
entry?.pattern === "projects/*/learnings.jsonl" && entry?.class === "artifact",
|
||||
);
|
||||
const recognized = gitignore.includes("gstack-artifacts sync") &&
|
||||
gitignore.includes(".brain-allowlist") &&
|
||||
allowlist.split(/\r?\n/).includes("projects/*/learnings.jsonl") &&
|
||||
allowlist.split(/\r?\n/).includes("retros/*.md") &&
|
||||
attributes.split(/\r?\n/).includes("*.jsonl merge=jsonl-append") &&
|
||||
hasCanonicalPrivacyEntry;
|
||||
return recognized ? { kind: "legacy-artifacts-repo" } : null;
|
||||
}
|
||||
|
||||
async function createOwnershipSentinel(sentinelPath, sentinel) {
|
||||
let handle;
|
||||
try {
|
||||
handle = await fs.open(sentinelPath, "wx", 0o600);
|
||||
await handle.writeFile(`${JSON.stringify(sentinel, null, 2)}\n`, "utf8");
|
||||
await handle.sync();
|
||||
} catch (error) {
|
||||
if (error?.code === "EEXIST") {
|
||||
throw managedHomeError(`Managed home ownership changed concurrently: ${sentinelPath}`, "MANAGED_HOME_UNOWNED");
|
||||
}
|
||||
if (handle) await fs.rm(sentinelPath, { force: true }).catch(() => {});
|
||||
throw error;
|
||||
} finally {
|
||||
await handle?.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async function removeSentinelIfOwned(sentinelPath, ownerId) {
|
||||
const sentinel = await readJson(sentinelPath, null).catch(() => null);
|
||||
if (sentinel?.ownerId === ownerId) await fs.rm(sentinelPath, { force: true });
|
||||
}
|
||||
|
||||
export async function assertManagedHome(home, options = {}) {
|
||||
const resolved = assertSafeManagedHomePath(home, options);
|
||||
const stat = await fs.lstat(resolved).catch((error) => {
|
||||
if (error?.code === "ENOENT") return null;
|
||||
throw error;
|
||||
});
|
||||
if (!stat?.isDirectory() || stat.isSymbolicLink()) {
|
||||
throw managedHomeError(`Managed home does not exist or is not a real directory: ${resolved}`, "MANAGED_HOME_UNOWNED");
|
||||
}
|
||||
const sentinelPath = path.join(resolved, MANAGED_HOME_SENTINEL);
|
||||
const sentinelStat = await fs.lstat(sentinelPath).catch((error) => {
|
||||
if (error?.code === "ENOENT") return null;
|
||||
throw error;
|
||||
});
|
||||
if (!sentinelStat?.isFile() || sentinelStat.isSymbolicLink()) {
|
||||
throw managedHomeError(
|
||||
`Refusing runtime mutation because the ownership sentinel is missing or invalid: ${sentinelPath}`,
|
||||
"MANAGED_HOME_UNOWNED",
|
||||
);
|
||||
}
|
||||
const sentinel = await readAndValidateSentinel(sentinelPath, resolved);
|
||||
return { home: resolved, sentinel };
|
||||
}
|
||||
|
||||
export async function withRuntimeLifecycleLock(home, callback, options = {}) {
|
||||
const resolved = assertSafeManagedHomePath(home, options);
|
||||
return withLock(`${resolved}.runtime-lifecycle.lock`, () => callback(resolved), options.lockOptions);
|
||||
}
|
||||
|
||||
/** Restore a launcher/manifest/pointer snapshot left by a killed installer. */
|
||||
export async function recoverRuntimeTransactionUnlocked(home) {
|
||||
const resolved = path.resolve(home);
|
||||
const journalPath = path.join(resolved, RUNTIME_TRANSACTION_FILE);
|
||||
const journal = await readJson(journalPath, null);
|
||||
if (!journal) return { recovered: false };
|
||||
const journalHomeMatches = typeof journal?.home === "string" && await pathsReferToSameLocation(journal.home, resolved);
|
||||
const valid = journal.schemaVersion === 1 &&
|
||||
journal.kind === "gstack-runtime-install-transaction" &&
|
||||
journal.status === "prepared" &&
|
||||
journalHomeMatches &&
|
||||
Array.isArray(journal.files) &&
|
||||
typeof journal.previousPointerExists === "boolean";
|
||||
if (!valid) {
|
||||
throw managedHomeError(`Runtime transaction journal is invalid: ${journalPath}`, "RUNTIME_TRANSACTION_INVALID");
|
||||
}
|
||||
|
||||
for (const file of journal.files) {
|
||||
const relative = validateTransactionPath(file?.path);
|
||||
const absolute = assertPathInside(resolved, path.join(resolved, relative));
|
||||
if (file.existed === false) {
|
||||
const stat = await fs.lstat(absolute).catch((error) => error?.code === "ENOENT" ? null : Promise.reject(error));
|
||||
if (stat?.isDirectory() && !stat.isSymbolicLink()) {
|
||||
throw managedHomeError(`Refusing to remove transaction path directory: ${relative}`, "RUNTIME_TRANSACTION_INVALID");
|
||||
}
|
||||
await fs.rm(absolute, { force: true });
|
||||
continue;
|
||||
}
|
||||
if (file.existed !== true || !Number.isInteger(file.mode) || file.mode < 0 || file.mode > 0o777 ||
|
||||
typeof file.dataBase64 !== "string" || file.dataBase64.length > 16 * 1024 * 1024 || !validBase64(file.dataBase64)) {
|
||||
throw managedHomeError(`Runtime transaction snapshot is invalid: ${relative}`, "RUNTIME_TRANSACTION_INVALID");
|
||||
}
|
||||
await atomicWriteFile(absolute, Buffer.from(file.dataBase64, "base64"), { mode: file.mode });
|
||||
}
|
||||
|
||||
const pointerPath = resolveRuntimePaths({ home: resolved }).versionPointer;
|
||||
if (journal.previousPointerExists) {
|
||||
if (!journal.previousPointer || journal.previousPointer.schemaVersion !== 2) {
|
||||
throw managedHomeError("Runtime transaction pointer snapshot is invalid", "RUNTIME_TRANSACTION_INVALID");
|
||||
}
|
||||
await atomicWriteJson(pointerPath, journal.previousPointer, { mode: 0o600 });
|
||||
} else {
|
||||
await fs.rm(pointerPath, { force: true });
|
||||
}
|
||||
await fs.rm(journalPath, { force: true });
|
||||
await fs.rmdir(path.join(resolved, "bin")).catch((error) => {
|
||||
if (!["ENOENT", "ENOTEMPTY", "EEXIST"].includes(error?.code)) throw error;
|
||||
});
|
||||
return { recovered: true, version: journal.version ?? null };
|
||||
}
|
||||
|
||||
async function readAndValidateSentinel(sentinelPath, expectedHome) {
|
||||
const sentinel = await readJson(sentinelPath, null);
|
||||
const homeMatches = typeof sentinel?.home === "string" && await pathsReferToSameLocation(sentinel.home, expectedHome);
|
||||
const valid = sentinel?.schemaVersion === MANAGED_HOME_SCHEMA_VERSION &&
|
||||
sentinel?.kind === "gstack-managed-home" &&
|
||||
homeMatches &&
|
||||
typeof sentinel?.ownerId === "string" &&
|
||||
/^[0-9a-f-]{16,}$/i.test(sentinel.ownerId) &&
|
||||
(!sentinel.adoptedLegacy || (
|
||||
Array.isArray(sentinel.preexistingTopLevel) &&
|
||||
sentinel.preexistingTopLevel.every((entry) => typeof entry === "string" && entry.length > 0 && entry !== MANAGED_HOME_SENTINEL && path.basename(entry) === entry)
|
||||
));
|
||||
if (!valid) {
|
||||
throw managedHomeError(`Managed home sentinel is invalid or belongs to another path: ${sentinelPath}`, "MANAGED_HOME_INVALID");
|
||||
}
|
||||
return sentinel;
|
||||
}
|
||||
|
||||
async function pathsReferToSameLocation(left, right) {
|
||||
if (path.resolve(left) === path.resolve(right)) return true;
|
||||
const [physicalLeft, physicalRight] = await Promise.all([
|
||||
fs.realpath(left).catch(() => path.resolve(left)),
|
||||
fs.realpath(right).catch(() => path.resolve(right)),
|
||||
]);
|
||||
return physicalLeft === physicalRight;
|
||||
}
|
||||
|
||||
function validateTransactionPath(value) {
|
||||
if (typeof value !== "string" || value.includes("\0") || path.isAbsolute(value)) {
|
||||
throw managedHomeError("Invalid runtime transaction path", "RUNTIME_TRANSACTION_INVALID");
|
||||
}
|
||||
const normalized = value.replaceAll("\\", "/");
|
||||
if (normalized === "runtime-install.json" || /^bin\/[A-Za-z0-9._-]+$/.test(normalized)) return normalized;
|
||||
throw managedHomeError(`Invalid runtime transaction path: ${value}`, "RUNTIME_TRANSACTION_INVALID");
|
||||
}
|
||||
|
||||
function validBase64(value) {
|
||||
return value.length % 4 === 0 && /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value);
|
||||
}
|
||||
|
||||
function isSameOrAncestor(candidate, target) {
|
||||
const relative = path.relative(candidate, target);
|
||||
return relative === "" || relative === "." || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
function managedHomeError(message, code) {
|
||||
const error = new Error(message);
|
||||
error.code = code;
|
||||
return error;
|
||||
}
|
||||
|
||||
function isoNow(now) {
|
||||
return (now ? now() : new Date()).toISOString();
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import path from "node:path";
|
||||
import { atomicWriteJson, readJson, withLock } from "./storage.js";
|
||||
import { resolveRuntimePaths } from "./paths.js";
|
||||
|
||||
export const RUNTIME_SCHEMA_VERSION = 2;
|
||||
export const RUNTIME_MIGRATION_ID = "2.0.0-host-neutral-runtime";
|
||||
|
||||
export async function ensureMigrations(home, options = {}) {
|
||||
const paths = resolveRuntimePaths({ home });
|
||||
return withLock(path.join(paths.locks, "migration.lock"), async () => {
|
||||
const existing = await readJson(paths.migrations, null);
|
||||
if (existing?.schemaVersion > RUNTIME_SCHEMA_VERSION) {
|
||||
const error = new Error(
|
||||
`State schema ${existing.schemaVersion} is newer than this runtime supports (${RUNTIME_SCHEMA_VERSION})`,
|
||||
);
|
||||
error.code = "MIGRATION_NEWER_THAN_RUNTIME";
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (existing?.schemaVersion === RUNTIME_SCHEMA_VERSION &&
|
||||
existing.applied?.some((entry) => entry.id === RUNTIME_MIGRATION_ID)) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const now = (options.now ?? (() => new Date()))().toISOString();
|
||||
const marker = {
|
||||
format: "gstack-forward-migrations",
|
||||
schemaVersion: RUNTIME_SCHEMA_VERSION,
|
||||
direction: "forward-only",
|
||||
applied: [
|
||||
...(Array.isArray(existing?.applied) ? existing.applied : []),
|
||||
{ id: RUNTIME_MIGRATION_ID, appliedAt: now },
|
||||
].filter((entry, index, all) => all.findIndex((other) => other.id === entry.id) === index),
|
||||
updatedAt: now,
|
||||
};
|
||||
await atomicWriteJson(paths.migrations, marker, { mode: 0o600 });
|
||||
return marker;
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { resolveRuntimePaths } from "./paths.js";
|
||||
import { ensureConfig } from "./config.js";
|
||||
import { ensureMigrations } from "./migrations.js";
|
||||
import { discoverProjectIdentity } from "./identity.js";
|
||||
import { initializeProject } from "./state.js";
|
||||
import { ensureManagedHome, recoverRuntimeTransactionUnlocked, withRuntimeLifecycleLock } from "./managed-home.js";
|
||||
|
||||
export async function setupRuntime(options = {}) {
|
||||
const paths = resolveRuntimePaths(options);
|
||||
return withRuntimeLifecycleLock(paths.home, async () => {
|
||||
await ensureManagedHome(paths.home, options);
|
||||
await recoverRuntimeTransactionUnlocked(paths.home);
|
||||
await fs.chmod(paths.home, 0o700).catch((error) => {
|
||||
if (process.platform !== "win32") throw error;
|
||||
});
|
||||
await Promise.all([
|
||||
fs.mkdir(paths.projects, { recursive: true, mode: 0o700 }),
|
||||
fs.mkdir(paths.locks, { recursive: true, mode: 0o700 }),
|
||||
fs.mkdir(paths.tmp, { recursive: true, mode: 0o700 }),
|
||||
fs.mkdir(paths.versions, { recursive: true, mode: 0o700 }),
|
||||
]);
|
||||
const { config } = await ensureConfig(paths.home);
|
||||
const migration = await ensureMigrations(paths.home, options);
|
||||
const identity = await discoverProjectIdentity(options.cwd ?? process.cwd(), options);
|
||||
const project = await initializeProject(paths.home, identity, options);
|
||||
return { paths, config, migration, identity, project: project.state };
|
||||
}, options);
|
||||
}
|
||||
@@ -0,0 +1,970 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { appendJsonLine, atomicWriteJson, readJson, withLock } from "./storage.js";
|
||||
import { projectPaths } from "./paths.js";
|
||||
import { discoverProjectIdentity } from "./identity.js";
|
||||
import { RUNTIME_SCHEMA_VERSION } from "./migrations.js";
|
||||
|
||||
const PROJECT_DIRECTORIES = ["evidence", "artifacts", "reviews", "checkpoints"];
|
||||
export const WORKFLOW_STATE_SCHEMA_VERSION = 1;
|
||||
const WORKFLOW_DEPTHS = new Set(["quick", "standard", "deep"]);
|
||||
const EVIDENCE_FRESHNESS = new Set(["unknown", "fresh", "stale"]);
|
||||
const RUN_STATUSES = new Set(["running", "completed"]);
|
||||
const EFFECT_STATUSES = new Set(["ready", "in_progress", "uncertain", "completed"]);
|
||||
const MUTATION_AUTHORITIES = new Set([
|
||||
"source-defined",
|
||||
"report-only",
|
||||
"plan-only",
|
||||
"design-doc-only",
|
||||
"spec-only",
|
||||
"spec-and-issue",
|
||||
"design-artifacts",
|
||||
"fix-safe",
|
||||
"fix-safe-after-root-cause",
|
||||
"investigate-only",
|
||||
"code-generation",
|
||||
"commit-push-pr",
|
||||
"merge-deploy",
|
||||
"deploy",
|
||||
"docs-only",
|
||||
"profile-only",
|
||||
"configuration",
|
||||
"installation",
|
||||
"safety-policy",
|
||||
"state-only",
|
||||
"state-dependent",
|
||||
"approval-required",
|
||||
"none",
|
||||
]);
|
||||
const EXTERNAL_EFFECT_AUTHORITIES = new Set([
|
||||
// source-defined keeps state written by the pre-metadata GStack 2 runtime
|
||||
// resumable; new dispatchers persist their exact authority.
|
||||
"source-defined",
|
||||
"spec-and-issue",
|
||||
"commit-push-pr",
|
||||
"merge-deploy",
|
||||
"deploy",
|
||||
"state-dependent",
|
||||
"configuration",
|
||||
"installation",
|
||||
]);
|
||||
const WORKFLOW_KEYS = new Set([
|
||||
"schemaVersion",
|
||||
"currentPlanPointer",
|
||||
"originalGoal",
|
||||
"detourStack",
|
||||
"currentWorkflowStage",
|
||||
"selectedDepth",
|
||||
"mutationAuthority",
|
||||
"activeModules",
|
||||
"evidenceFreshness",
|
||||
"evidenceProvenance",
|
||||
"pendingApprovalGates",
|
||||
]);
|
||||
const WORKFLOW_TRANSITION_KEYS = new Set([
|
||||
"currentPlanPointer",
|
||||
"currentWorkflowStage",
|
||||
"selectedDepth",
|
||||
"mutationAuthority",
|
||||
"activeModules",
|
||||
"pushDetour",
|
||||
"popDetour",
|
||||
"evidenceFreshness",
|
||||
"addEvidenceProvenance",
|
||||
"addApprovalGate",
|
||||
"resolveApprovalGate",
|
||||
]);
|
||||
|
||||
export async function initializeProject(home, identity, options = {}) {
|
||||
const paths = projectPaths(home, identity.projectId);
|
||||
await fs.mkdir(paths.root, { recursive: true, mode: 0o700 });
|
||||
return withLock(paths.lock, async () => {
|
||||
for (const name of PROJECT_DIRECTORIES) {
|
||||
await fs.mkdir(paths[name], { recursive: true, mode: 0o700 });
|
||||
}
|
||||
const now = isoNow(options.now);
|
||||
let state = await readJson(paths.state, null);
|
||||
if (!state) {
|
||||
state = {
|
||||
schemaVersion: RUNTIME_SCHEMA_VERSION,
|
||||
revision: 0,
|
||||
project: {
|
||||
id: identity.projectId,
|
||||
repoId: identity.repoId,
|
||||
worktreeId: identity.worktreeId,
|
||||
worktreeRoot: identity.worktreeRoot,
|
||||
repoCommonDir: identity.repoCommonDir,
|
||||
isGit: identity.isGit,
|
||||
},
|
||||
activeRunId: null,
|
||||
currentPlan: null,
|
||||
runs: Object.create(null),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
await atomicWriteJson(paths.state, state, { mode: 0o600 });
|
||||
} else {
|
||||
assertSupportedState(state, paths.state);
|
||||
// A registered worktree can move. Its ID intentionally changes when it
|
||||
// does; within an existing project, keep display paths fresh.
|
||||
state.project = { ...state.project, ...identity, id: identity.projectId };
|
||||
await atomicWriteJson(paths.state, state, { mode: 0o600 });
|
||||
}
|
||||
await ensureJsonl(paths.timeline);
|
||||
await ensureJsonl(paths.decisions);
|
||||
return { paths, state };
|
||||
});
|
||||
}
|
||||
|
||||
export async function currentProject(home, cwd = process.cwd(), options = {}) {
|
||||
const identity = await discoverProjectIdentity(cwd, options);
|
||||
return initializeProject(home, identity, options);
|
||||
}
|
||||
|
||||
export async function inspectProject(home, identityOrId) {
|
||||
const id = typeof identityOrId === "string" ? identityOrId : identityOrId.projectId;
|
||||
const paths = projectPaths(home, id);
|
||||
const state = await readJson(paths.state, null);
|
||||
if (!state) {
|
||||
const error = new Error(`No state found for project ${id}`);
|
||||
error.code = "STATE_NOT_FOUND";
|
||||
throw error;
|
||||
}
|
||||
assertSupportedState(state, paths.state);
|
||||
return { paths, state };
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the complete durable reconstruction needed to continue one run.
|
||||
* This function is intentionally read-only: callers must use resumeRun before
|
||||
* changing a non-active run.
|
||||
*/
|
||||
export async function inspectRun(home, projectId, runId) {
|
||||
validateRunId(runId);
|
||||
const { paths, state } = await inspectProject(home, projectId);
|
||||
const run = Object.hasOwn(state.runs, runId) ? state.runs[runId] : null;
|
||||
if (!run) throw codedError("RUN_NOT_FOUND", `Run not found: ${runId}`);
|
||||
return {
|
||||
paths,
|
||||
state,
|
||||
run,
|
||||
reconstruction: workflowReconstruction(state, run),
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateProjectState(home, projectId, mutator, options = {}) {
|
||||
const paths = projectPaths(home, projectId);
|
||||
return withLock(paths.lock, async () => {
|
||||
const state = await readJson(paths.state);
|
||||
assertSupportedState(state, paths.state);
|
||||
const result = await mutator(state);
|
||||
assertSupportedState(state, paths.state);
|
||||
state.revision = Number(state.revision ?? 0) + 1;
|
||||
state.updatedAt = isoNow(options.now);
|
||||
await atomicWriteJson(paths.state, state, { mode: 0o600 });
|
||||
return { state, result };
|
||||
}, options.lock);
|
||||
}
|
||||
|
||||
export async function beginRun(home, projectId, command, options = {}) {
|
||||
const runId = options.runId ?? `run_${Date.now().toString(36)}_${randomUUID().slice(0, 12)}`;
|
||||
validateRunId(runId);
|
||||
const paths = projectPaths(home, projectId);
|
||||
const now = isoNow(options.now);
|
||||
const { state, result } = await updateWithEvent(paths, async (state) => {
|
||||
if (Object.hasOwn(state.runs, runId)) {
|
||||
const error = new Error(`Run already exists: ${runId}`);
|
||||
error.code = "RUN_EXISTS";
|
||||
throw error;
|
||||
}
|
||||
const workflow = createWorkflowState(command, options, now);
|
||||
state.runs[runId] = {
|
||||
id: runId,
|
||||
command: String(command ?? "unknown"),
|
||||
status: "running",
|
||||
workflow,
|
||||
effects: Object.create(null),
|
||||
startedAt: now,
|
||||
updatedAt: now,
|
||||
resumeCount: 0,
|
||||
};
|
||||
state.activeRunId = runId;
|
||||
state.currentPlan = currentPlanProjection(runId, workflow, now);
|
||||
return {
|
||||
result: state.runs[runId],
|
||||
event: { type: "run.started", runId, command: state.runs[runId].command, at: now },
|
||||
};
|
||||
}, options);
|
||||
return { state, run: result, reconstruction: workflowReconstruction(state, result) };
|
||||
}
|
||||
|
||||
export async function resumeRun(home, projectId, runId, options = {}) {
|
||||
const paths = projectPaths(home, projectId);
|
||||
const now = isoNow(options.now);
|
||||
const { state, result } = await updateWithEvent(paths, async (state) => {
|
||||
const selected = runId ?? state.activeRunId ?? newestIncompleteRun(state);
|
||||
if (selected) validateRunId(selected);
|
||||
const run = selected && Object.hasOwn(state.runs, selected) ? state.runs[selected] : null;
|
||||
if (!run) {
|
||||
const error = new Error(selected ? `Run not found: ${selected}` : "No resumable run found");
|
||||
error.code = "RUN_NOT_FOUND";
|
||||
throw error;
|
||||
}
|
||||
if (run.status === "completed") {
|
||||
const error = new Error(`Run is already complete: ${run.id}`);
|
||||
error.code = "RUN_COMPLETED";
|
||||
throw error;
|
||||
}
|
||||
for (const effect of Object.values(run.effects ?? {})) {
|
||||
if (effect.status === "in_progress") {
|
||||
effect.status = "uncertain";
|
||||
effect.uncertainAt = now;
|
||||
effect.reason = "runtime stopped after effect was claimed; reconcile before retrying";
|
||||
}
|
||||
}
|
||||
run.status = "running";
|
||||
run.resumeCount = Number(run.resumeCount ?? 0) + 1;
|
||||
run.resumedAt = now;
|
||||
run.updatedAt = now;
|
||||
state.activeRunId = run.id;
|
||||
state.currentPlan = currentPlanProjection(run.id, run.workflow, now);
|
||||
return {
|
||||
result: run,
|
||||
event: { type: "run.resumed", runId: run.id, resumeCount: run.resumeCount, at: now },
|
||||
};
|
||||
}, options);
|
||||
return { state, run: result, reconstruction: workflowReconstruction(state, result) };
|
||||
}
|
||||
|
||||
export async function completeRun(home, projectId, runId, options = {}) {
|
||||
validateRunId(runId);
|
||||
const paths = projectPaths(home, projectId);
|
||||
const now = isoNow(options.now);
|
||||
const { state, result } = await updateWithEvent(paths, async (state) => {
|
||||
const run = Object.hasOwn(state.runs, runId) ? state.runs[runId] : null;
|
||||
if (!run) throw codedError("RUN_NOT_FOUND", `Run not found: ${runId}`);
|
||||
const unresolved = Object.values(run.effects ?? {}).filter((effect) =>
|
||||
["ready", "in_progress", "uncertain"].includes(effect.status));
|
||||
if (unresolved.length && !options.allowUncertain) {
|
||||
throw codedError("EFFECTS_UNCERTAIN", "Run has unresolved external effects");
|
||||
}
|
||||
if (run.workflow.pendingApprovalGates.length) {
|
||||
throw codedError("APPROVAL_GATES_PENDING", "Run has pending approval gates");
|
||||
}
|
||||
run.status = "completed";
|
||||
run.completedAt = now;
|
||||
run.updatedAt = now;
|
||||
if (state.activeRunId === runId) {
|
||||
state.activeRunId = null;
|
||||
state.currentPlan = null;
|
||||
}
|
||||
return {
|
||||
result: run,
|
||||
event: { type: "run.completed", runId, at: now },
|
||||
};
|
||||
}, options);
|
||||
return { state, run: result, reconstruction: workflowReconstruction(state, result) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an explicit workflow transition under the same project lock used for
|
||||
* external-effect claims. The original goal is deliberately not patchable.
|
||||
*/
|
||||
export async function updateRunWorkflow(home, projectId, runId, transition, options = {}) {
|
||||
validateRunId(runId);
|
||||
validateWorkflowTransition(transition);
|
||||
const paths = projectPaths(home, projectId);
|
||||
const now = isoNow(options.now);
|
||||
const { state, result } = await updateWithEvent(paths, async (state) => {
|
||||
const run = Object.hasOwn(state.runs, runId) ? state.runs[runId] : null;
|
||||
if (!run) throw codedError("RUN_NOT_FOUND", `Run not found: ${runId}`);
|
||||
if (run.status === "completed") throw codedError("RUN_COMPLETED", `Run is already complete: ${runId}`);
|
||||
if (state.activeRunId !== runId) {
|
||||
throw codedError("RUN_NOT_ACTIVE", `Run is not active; resume it before updating: ${runId}`);
|
||||
}
|
||||
|
||||
const changes = applyWorkflowTransition(run.workflow, transition, now);
|
||||
run.updatedAt = now;
|
||||
state.currentPlan = currentPlanProjection(runId, run.workflow, now);
|
||||
return {
|
||||
result: run,
|
||||
event: { type: "run.workflow_updated", runId, changes, at: now },
|
||||
};
|
||||
}, options);
|
||||
return {
|
||||
state,
|
||||
run: result,
|
||||
reconstruction: workflowReconstruction(state, result),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute an external side effect at most once from gstack's perspective.
|
||||
*
|
||||
* A durable claim is written before execute() is called. If the process dies
|
||||
* after the external system accepts the action, resume marks that claim
|
||||
* uncertain and will not call execute() again. The stable idempotencyKey should
|
||||
* also be passed to APIs that support native idempotency.
|
||||
*/
|
||||
export async function runExternalEffect(home, projectId, runId, effectKey, execute, options = {}) {
|
||||
validateRunId(runId);
|
||||
validateEffectKey(effectKey);
|
||||
const paths = projectPaths(home, projectId);
|
||||
const now = isoNow(options.now);
|
||||
const claimed = await updateWithEvent(paths, async (state) => {
|
||||
const run = Object.hasOwn(state.runs, runId) ? state.runs[runId] : null;
|
||||
if (!run) throw codedError("RUN_NOT_FOUND", `Run not found: ${runId}`);
|
||||
if (run.status === "completed") throw codedError("RUN_COMPLETED", `Run is already complete: ${runId}`);
|
||||
if (state.activeRunId !== runId) {
|
||||
throw codedError("RUN_NOT_ACTIVE", `Run is not active; resume it before an external effect: ${runId}`);
|
||||
}
|
||||
if (run.workflow.pendingApprovalGates.length) {
|
||||
throw codedError("APPROVAL_REQUIRED", "Resolve pending approval gates before external effects");
|
||||
}
|
||||
if (!EXTERNAL_EFFECT_AUTHORITIES.has(run.workflow.mutationAuthority)) {
|
||||
throw codedError(
|
||||
"MUTATION_NOT_AUTHORIZED",
|
||||
`Mutation authority ${run.workflow.mutationAuthority} does not permit external effects`,
|
||||
);
|
||||
}
|
||||
run.effects = normalizeRecord(run.effects, validateEffectKey, "effects");
|
||||
const existing = Object.hasOwn(run.effects, effectKey) ? run.effects[effectKey] : null;
|
||||
if (existing?.status === "completed") {
|
||||
return { result: { action: "completed", effect: existing } };
|
||||
}
|
||||
if (existing && ["in_progress", "uncertain"].includes(existing.status)) {
|
||||
existing.status = "uncertain";
|
||||
existing.uncertainAt ??= now;
|
||||
return { result: { action: "uncertain", effect: existing } };
|
||||
}
|
||||
const effect = {
|
||||
key: effectKey,
|
||||
status: "in_progress",
|
||||
idempotencyKey: existing?.idempotencyKey ?? stableIdempotencyKey(projectId, runId, effectKey),
|
||||
attempts: Number(existing?.attempts ?? 0) + 1,
|
||||
claimedAt: now,
|
||||
};
|
||||
run.effects[effectKey] = effect;
|
||||
run.updatedAt = now;
|
||||
return {
|
||||
result: { action: "execute", effect },
|
||||
event: { type: "effect.claimed", runId, effectKey, idempotencyKey: effect.idempotencyKey, at: now },
|
||||
};
|
||||
}, options);
|
||||
|
||||
if (claimed.result.action === "completed") {
|
||||
return { status: "completed", repeated: true, result: claimed.result.effect.result, idempotencyKey: claimed.result.effect.idempotencyKey };
|
||||
}
|
||||
if (claimed.result.action === "uncertain") {
|
||||
return {
|
||||
status: "uncertain",
|
||||
repeated: false,
|
||||
idempotencyKey: claimed.result.effect.idempotencyKey,
|
||||
reason: "Effect was already claimed; reconcile it explicitly before retrying",
|
||||
};
|
||||
}
|
||||
|
||||
const effect = claimed.result.effect;
|
||||
try {
|
||||
const result = await execute({ idempotencyKey: effect.idempotencyKey, effectKey, runId });
|
||||
await completeExternalEffect(home, projectId, runId, effectKey, result, options);
|
||||
return { status: "completed", repeated: false, result, idempotencyKey: effect.idempotencyKey };
|
||||
} catch (cause) {
|
||||
await markEffectUncertain(home, projectId, runId, effectKey, cause, options).catch(() => {});
|
||||
const error = new Error(`External effect ${effectKey} may have occurred; refusing automatic retry`, { cause });
|
||||
error.code = "EXTERNAL_EFFECT_UNCERTAIN";
|
||||
error.idempotencyKey = effect.idempotencyKey;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function completeExternalEffect(home, projectId, runId, effectKey, result, options = {}) {
|
||||
validateRunId(runId);
|
||||
validateEffectKey(effectKey);
|
||||
const paths = projectPaths(home, projectId);
|
||||
const now = isoNow(options.now);
|
||||
return updateWithEvent(paths, async (state) => {
|
||||
const effect = ownedEffect(state, runId, effectKey);
|
||||
if (!effect) throw codedError("EFFECT_NOT_FOUND", `Effect not found: ${effectKey}`);
|
||||
if (effect.status !== "in_progress") {
|
||||
throw codedError("EFFECT_NOT_IN_PROGRESS", `Effect is not in progress: ${effectKey}`);
|
||||
}
|
||||
effect.status = "completed";
|
||||
effect.completedAt = now;
|
||||
effect.result = jsonSafe(result);
|
||||
delete effect.reason;
|
||||
return {
|
||||
result: effect,
|
||||
event: { type: "effect.completed", runId, effectKey, at: now },
|
||||
};
|
||||
}, options);
|
||||
}
|
||||
|
||||
export async function markEffectNotApplied(home, projectId, runId, effectKey, options = {}) {
|
||||
validateRunId(runId);
|
||||
validateEffectKey(effectKey);
|
||||
const paths = projectPaths(home, projectId);
|
||||
return updateWithEvent(paths, async (state) => {
|
||||
const effect = ownedEffect(state, runId, effectKey);
|
||||
if (!effect) throw codedError("EFFECT_NOT_FOUND", `Effect not found: ${effectKey}`);
|
||||
if (effect.status !== "uncertain") {
|
||||
throw codedError("EFFECT_NOT_UNCERTAIN", `Only an uncertain effect can be reconciled as not applied: ${effectKey}`);
|
||||
}
|
||||
effect.status = "ready";
|
||||
effect.reconciledAt = isoNow(options.now);
|
||||
effect.reason = "caller confirmed the external action did not occur";
|
||||
return {
|
||||
result: effect,
|
||||
event: { type: "effect.reconciled_not_applied", runId, effectKey, at: effect.reconciledAt },
|
||||
};
|
||||
}, options);
|
||||
}
|
||||
|
||||
export async function markEffectApplied(home, projectId, runId, effectKey, evidence, options = {}) {
|
||||
validateRunId(runId);
|
||||
validateEffectKey(effectKey);
|
||||
if (typeof evidence !== "string" || !evidence.trim() || evidence.length > 500 || /[\r\n\0]/.test(evidence)) {
|
||||
throw new TypeError("A compact, single-line external evidence reference is required");
|
||||
}
|
||||
const paths = projectPaths(home, projectId);
|
||||
const now = isoNow(options.now);
|
||||
return updateWithEvent(paths, async (state) => {
|
||||
const effect = ownedEffect(state, runId, effectKey);
|
||||
if (!effect) throw codedError("EFFECT_NOT_FOUND", `Effect not found: ${effectKey}`);
|
||||
if (effect.status !== "uncertain") {
|
||||
throw codedError("EFFECT_NOT_UNCERTAIN", `Only an uncertain effect can be reconciled as applied: ${effectKey}`);
|
||||
}
|
||||
effect.status = "completed";
|
||||
effect.completedAt = now;
|
||||
effect.reconciledAt = now;
|
||||
effect.result = { reconciled: true, evidence: evidence.trim() };
|
||||
effect.reason = "external inspection confirmed the action occurred";
|
||||
return {
|
||||
result: effect,
|
||||
event: { type: "effect.reconciled_applied", runId, effectKey, evidence: evidence.trim(), at: now },
|
||||
};
|
||||
}, options);
|
||||
}
|
||||
|
||||
export async function appendDecision(home, projectId, decision, options = {}) {
|
||||
const paths = projectPaths(home, projectId);
|
||||
return withLock(paths.lock, async () => {
|
||||
const record = { ...jsonSafe(decision), id: randomUUID(), at: isoNow(options.now) };
|
||||
await appendJsonLine(paths.decisions, record, { mode: 0o600 });
|
||||
return record;
|
||||
});
|
||||
}
|
||||
|
||||
async function markEffectUncertain(home, projectId, runId, effectKey, cause, options) {
|
||||
const paths = projectPaths(home, projectId);
|
||||
const now = isoNow(options.now);
|
||||
return updateWithEvent(paths, async (state) => {
|
||||
const effect = ownedEffect(state, runId, effectKey);
|
||||
if (!effect) throw codedError("EFFECT_NOT_FOUND", `Effect not found: ${effectKey}`);
|
||||
effect.status = "uncertain";
|
||||
effect.uncertainAt = now;
|
||||
effect.reason = String(cause?.message ?? cause ?? "unknown external error").slice(0, 500);
|
||||
return {
|
||||
result: effect,
|
||||
event: { type: "effect.uncertain", runId, effectKey, at: now },
|
||||
};
|
||||
}, options);
|
||||
}
|
||||
|
||||
async function updateWithEvent(paths, mutator, options = {}) {
|
||||
return withLock(paths.lock, async () => {
|
||||
const state = await readJson(paths.state);
|
||||
assertSupportedState(state, paths.state);
|
||||
const mutation = await mutator(state);
|
||||
assertSupportedState(state, paths.state);
|
||||
state.revision = Number(state.revision ?? 0) + 1;
|
||||
state.updatedAt = isoNow(options.now);
|
||||
await atomicWriteJson(paths.state, state, { mode: 0o600 });
|
||||
if (mutation.event) await appendJsonLine(paths.timeline, mutation.event, { mode: 0o600 });
|
||||
return { state, result: mutation.result };
|
||||
}, options.lock);
|
||||
}
|
||||
|
||||
async function ensureJsonl(file) {
|
||||
try {
|
||||
const handle = await fs.open(file, "wx", 0o600);
|
||||
await handle.close();
|
||||
} catch (error) {
|
||||
if (error?.code !== "EEXIST") throw error;
|
||||
}
|
||||
await fs.chmod(file, 0o600);
|
||||
}
|
||||
|
||||
function newestIncompleteRun(state) {
|
||||
return Object.values(state.runs ?? {})
|
||||
.filter((run) => run.status !== "completed")
|
||||
.sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)))[0]?.id ?? null;
|
||||
}
|
||||
|
||||
function assertSupportedState(state, file) {
|
||||
if (!state || typeof state !== "object" || Array.isArray(state)) throw new Error(`Invalid state file: ${file}`);
|
||||
if (!Number.isInteger(state.schemaVersion) || state.schemaVersion < 1) {
|
||||
throw new Error(`Invalid state schema in ${file}`);
|
||||
}
|
||||
if (state.schemaVersion > RUNTIME_SCHEMA_VERSION) {
|
||||
throw codedError("STATE_NEWER_THAN_RUNTIME", `State schema is newer than this runtime: ${file}`);
|
||||
}
|
||||
if (!Number.isInteger(state.revision) || state.revision < 0) throw new Error(`Invalid state revision in ${file}`);
|
||||
state.runs = normalizeRecord(state.runs, validateRunId, "runs");
|
||||
for (const [runId, run] of Object.entries(state.runs)) {
|
||||
if (!run || typeof run !== "object" || Array.isArray(run)) throw new Error(`Invalid run record in ${file}`);
|
||||
run.effects = normalizeRecord(run.effects, validateEffectKey, "effects");
|
||||
if (run.id !== runId) throw new Error(`Run key/id mismatch in ${file}`);
|
||||
if (!RUN_STATUSES.has(run.status)) throw new Error(`Invalid run status in ${file}`);
|
||||
validateCompactLine(run.command, "run command", 128);
|
||||
validateIso(run.startedAt, "run start timestamp");
|
||||
validateIso(run.updatedAt, "run update timestamp");
|
||||
if (!Number.isInteger(run.resumeCount) || run.resumeCount < 0) throw new Error(`Invalid resume count in ${file}`);
|
||||
if (run.status === "completed") validateIso(run.completedAt, "run completion timestamp");
|
||||
if (run.workflow == null) {
|
||||
// Runs written by the first GStack 2 runtime are upgraded in memory and
|
||||
// become durable on the next locked mutation. This preserves resume
|
||||
// without treating missing metadata as trustworthy caller input.
|
||||
run.workflow = createWorkflowState(run.command, {
|
||||
currentWorkflowStage: run.status === "completed" ? "completed" : "initialized",
|
||||
}, validIsoOrNow(run.updatedAt));
|
||||
}
|
||||
validateWorkflowState(run.workflow, `run ${runId} in ${file}`);
|
||||
for (const [effectKey, effect] of Object.entries(run.effects)) {
|
||||
if (!effect || typeof effect !== "object" || effect.key !== effectKey) {
|
||||
throw new Error(`Effect key/id mismatch in ${file}`);
|
||||
}
|
||||
if (!EFFECT_STATUSES.has(effect.status)) throw new Error(`Invalid effect status in ${file}`);
|
||||
if (typeof effect.idempotencyKey !== "string" || !/^gstack_[0-9a-f]{64}$/.test(effect.idempotencyKey)) {
|
||||
throw new Error(`Invalid effect idempotency key in ${file}`);
|
||||
}
|
||||
if (!Number.isInteger(effect.attempts) || effect.attempts < 1) {
|
||||
throw new Error(`Invalid effect attempt count in ${file}`);
|
||||
}
|
||||
validateIso(effect.claimedAt, "effect claim timestamp");
|
||||
if (effect.status === "completed") validateIso(effect.completedAt, "effect completion timestamp");
|
||||
if (effect.status === "uncertain") validateIso(effect.uncertainAt, "effect uncertainty timestamp");
|
||||
if (effect.status === "ready") validateIso(effect.reconciledAt, "effect reconciliation timestamp");
|
||||
}
|
||||
}
|
||||
if (state.activeRunId != null) {
|
||||
validateRunId(state.activeRunId);
|
||||
if (!Object.hasOwn(state.runs, state.activeRunId)) {
|
||||
throw new Error(`Active run does not exist in ${file}`);
|
||||
}
|
||||
if (state.runs[state.activeRunId].status !== "running") {
|
||||
throw new Error(`Active run is not running in ${file}`);
|
||||
}
|
||||
}
|
||||
if (state.currentPlan === undefined) {
|
||||
const active = state.activeRunId == null ? null : state.runs[state.activeRunId];
|
||||
state.currentPlan = active
|
||||
? currentPlanProjection(active.id, active.workflow, validIsoOrNow(active.updatedAt))
|
||||
: null;
|
||||
}
|
||||
validateCurrentPlanProjection(state, file);
|
||||
}
|
||||
|
||||
function createWorkflowState(command, options, now) {
|
||||
const requestedFreshness = typeof options.evidenceFreshness === "string"
|
||||
? options.evidenceFreshness
|
||||
: options.evidenceFreshness?.status ?? "unknown";
|
||||
const workflow = {
|
||||
schemaVersion: WORKFLOW_STATE_SCHEMA_VERSION,
|
||||
currentPlanPointer: options.currentPlanPointer == null ? null : options.currentPlanPointer.trim(),
|
||||
originalGoal: options.originalGoal ?? String(command ?? "unknown"),
|
||||
detourStack: options.detourStack ?? [],
|
||||
currentWorkflowStage: options.currentWorkflowStage ?? "initialized",
|
||||
selectedDepth: options.selectedDepth ?? "standard",
|
||||
mutationAuthority: options.mutationAuthority ?? "source-defined",
|
||||
activeModules: options.activeModules ?? [],
|
||||
evidenceFreshness: {
|
||||
status: requestedFreshness,
|
||||
assessedAt: requestedFreshness === "unknown" ? null : now,
|
||||
},
|
||||
evidenceProvenance: options.evidenceProvenance ?? [],
|
||||
pendingApprovalGates: options.pendingApprovalGates ?? [],
|
||||
};
|
||||
// Normalize generated timestamps for initial metadata before validation.
|
||||
workflow.detourStack = workflow.detourStack.map((detour) => ({
|
||||
...detour,
|
||||
fromStage: detour?.fromStage ?? workflow.currentWorkflowStage,
|
||||
enteredAt: now,
|
||||
}));
|
||||
workflow.evidenceProvenance = workflow.evidenceProvenance.map((entry) => ({
|
||||
...entry,
|
||||
capturedAt: entry?.capturedAt ?? now,
|
||||
recordedAt: now,
|
||||
}));
|
||||
workflow.pendingApprovalGates = workflow.pendingApprovalGates.map((gate) => ({
|
||||
...gate,
|
||||
requestedAt: now,
|
||||
}));
|
||||
validateWorkflowState(workflow, "new workflow");
|
||||
return workflow;
|
||||
}
|
||||
|
||||
function validateWorkflowState(workflow, label) {
|
||||
assertPlainRecord(workflow, label, WORKFLOW_KEYS);
|
||||
if (workflow.schemaVersion !== WORKFLOW_STATE_SCHEMA_VERSION) {
|
||||
throw codedError("WORKFLOW_SCHEMA_UNSUPPORTED", `Unsupported workflow schema in ${label}`);
|
||||
}
|
||||
validateOptionalPointer(workflow.currentPlanPointer);
|
||||
validateGoal(workflow.originalGoal, "original goal");
|
||||
if (!Array.isArray(workflow.detourStack) || workflow.detourStack.length > 64) {
|
||||
throw new TypeError(`Invalid detour stack in ${label}`);
|
||||
}
|
||||
for (const detour of workflow.detourStack) validateDetour(detour);
|
||||
validateWorkflowToken(workflow.currentWorkflowStage, "workflow stage");
|
||||
if (!WORKFLOW_DEPTHS.has(workflow.selectedDepth)) throw new TypeError("Invalid selected depth");
|
||||
validateMutationAuthority(workflow.mutationAuthority);
|
||||
validateModuleList(workflow.activeModules);
|
||||
validateEvidenceFreshness(workflow.evidenceFreshness);
|
||||
if (!Array.isArray(workflow.evidenceProvenance) || workflow.evidenceProvenance.length > 512) {
|
||||
throw new TypeError(`Invalid evidence provenance in ${label}`);
|
||||
}
|
||||
for (const entry of workflow.evidenceProvenance) validateEvidenceProvenance(entry);
|
||||
if (workflow.evidenceFreshness.status === "fresh" && workflow.evidenceProvenance.length === 0) {
|
||||
throw new TypeError("Fresh evidence requires provenance");
|
||||
}
|
||||
if (!Array.isArray(workflow.pendingApprovalGates) || workflow.pendingApprovalGates.length > 64) {
|
||||
throw new TypeError(`Invalid pending approval gates in ${label}`);
|
||||
}
|
||||
const gateIds = new Set();
|
||||
for (const gate of workflow.pendingApprovalGates) {
|
||||
validateApprovalGate(gate);
|
||||
if (gateIds.has(gate.id)) throw new TypeError(`Duplicate approval gate: ${gate.id}`);
|
||||
gateIds.add(gate.id);
|
||||
}
|
||||
}
|
||||
|
||||
function validateWorkflowTransition(transition) {
|
||||
assertPlainRecord(transition, "workflow transition", WORKFLOW_TRANSITION_KEYS);
|
||||
if (Object.keys(transition).length === 0) throw new TypeError("Workflow transition cannot be empty");
|
||||
if (Object.hasOwn(transition, "currentPlanPointer")) validateOptionalPointer(transition.currentPlanPointer);
|
||||
if (Object.hasOwn(transition, "currentWorkflowStage")) {
|
||||
validateWorkflowToken(transition.currentWorkflowStage, "workflow stage");
|
||||
}
|
||||
if (Object.hasOwn(transition, "selectedDepth") && !WORKFLOW_DEPTHS.has(transition.selectedDepth)) {
|
||||
throw new TypeError("Invalid selected depth");
|
||||
}
|
||||
if (Object.hasOwn(transition, "mutationAuthority")) {
|
||||
validateMutationAuthority(transition.mutationAuthority);
|
||||
}
|
||||
if (Object.hasOwn(transition, "activeModules")) validateModuleList(transition.activeModules);
|
||||
if (Object.hasOwn(transition, "pushDetour")) validateGoal(transition.pushDetour, "detour goal");
|
||||
if (Object.hasOwn(transition, "popDetour") && transition.popDetour !== true) {
|
||||
throw new TypeError("popDetour must be true");
|
||||
}
|
||||
if (Object.hasOwn(transition, "pushDetour") && Object.hasOwn(transition, "popDetour")) {
|
||||
throw new TypeError("Cannot push and pop a detour in one transition");
|
||||
}
|
||||
if (Object.hasOwn(transition, "evidenceFreshness") && !EVIDENCE_FRESHNESS.has(transition.evidenceFreshness)) {
|
||||
throw new TypeError("Invalid evidence freshness");
|
||||
}
|
||||
if (Object.hasOwn(transition, "addEvidenceProvenance")) {
|
||||
validateEvidenceProvenanceInput(transition.addEvidenceProvenance);
|
||||
}
|
||||
if (Object.hasOwn(transition, "addApprovalGate")) validateApprovalGateInput(transition.addApprovalGate);
|
||||
if (Object.hasOwn(transition, "resolveApprovalGate")) validateStateKey(transition.resolveApprovalGate, "approval gate id");
|
||||
if (Object.hasOwn(transition, "addApprovalGate") && Object.hasOwn(transition, "resolveApprovalGate") &&
|
||||
transition.addApprovalGate.id === transition.resolveApprovalGate) {
|
||||
throw new TypeError("Cannot add and resolve the same approval gate in one transition");
|
||||
}
|
||||
}
|
||||
|
||||
function applyWorkflowTransition(workflow, transition, now) {
|
||||
const changes = [];
|
||||
const previousStage = workflow.currentWorkflowStage;
|
||||
if (Object.hasOwn(transition, "currentPlanPointer")) {
|
||||
workflow.currentPlanPointer = transition.currentPlanPointer == null ? null : transition.currentPlanPointer.trim();
|
||||
changes.push("currentPlanPointer");
|
||||
}
|
||||
if (Object.hasOwn(transition, "currentWorkflowStage")) {
|
||||
workflow.currentWorkflowStage = transition.currentWorkflowStage;
|
||||
changes.push("currentWorkflowStage");
|
||||
}
|
||||
if (Object.hasOwn(transition, "selectedDepth")) {
|
||||
workflow.selectedDepth = transition.selectedDepth;
|
||||
changes.push("selectedDepth");
|
||||
}
|
||||
if (Object.hasOwn(transition, "mutationAuthority")) {
|
||||
workflow.mutationAuthority = transition.mutationAuthority;
|
||||
changes.push("mutationAuthority");
|
||||
}
|
||||
if (Object.hasOwn(transition, "activeModules")) {
|
||||
workflow.activeModules = [...new Set(transition.activeModules)];
|
||||
changes.push("activeModules");
|
||||
}
|
||||
if (Object.hasOwn(transition, "pushDetour")) {
|
||||
workflow.detourStack.push({
|
||||
goal: transition.pushDetour.trim(),
|
||||
fromStage: previousStage,
|
||||
enteredAt: now,
|
||||
});
|
||||
changes.push("detourStack.push");
|
||||
}
|
||||
if (transition.popDetour === true) {
|
||||
if (workflow.detourStack.length === 0) throw codedError("DETOUR_STACK_EMPTY", "No detour is available to pop");
|
||||
workflow.detourStack.pop();
|
||||
changes.push("detourStack.pop");
|
||||
}
|
||||
if (Object.hasOwn(transition, "addEvidenceProvenance")) {
|
||||
const input = transition.addEvidenceProvenance;
|
||||
workflow.evidenceProvenance.push({
|
||||
source: input.source,
|
||||
reference: input.reference.trim(),
|
||||
capturedAt: input.capturedAt ?? now,
|
||||
recordedAt: now,
|
||||
});
|
||||
if (!Object.hasOwn(transition, "evidenceFreshness")) {
|
||||
workflow.evidenceFreshness = { status: "unknown", assessedAt: null };
|
||||
}
|
||||
changes.push("evidenceProvenance.add");
|
||||
}
|
||||
if (Object.hasOwn(transition, "evidenceFreshness")) {
|
||||
if (transition.evidenceFreshness === "fresh" && workflow.evidenceProvenance.length === 0) {
|
||||
throw new TypeError("Fresh evidence requires provenance");
|
||||
}
|
||||
workflow.evidenceFreshness = { status: transition.evidenceFreshness, assessedAt: now };
|
||||
changes.push("evidenceFreshness");
|
||||
}
|
||||
if (Object.hasOwn(transition, "addApprovalGate")) {
|
||||
const input = transition.addApprovalGate;
|
||||
if (workflow.pendingApprovalGates.some((gate) => gate.id === input.id)) {
|
||||
throw codedError("APPROVAL_GATE_EXISTS", `Approval gate already exists: ${input.id}`);
|
||||
}
|
||||
workflow.pendingApprovalGates.push({
|
||||
id: input.id,
|
||||
summary: input.summary.trim(),
|
||||
requestedAt: now,
|
||||
});
|
||||
changes.push("pendingApprovalGates.add");
|
||||
}
|
||||
if (Object.hasOwn(transition, "resolveApprovalGate")) {
|
||||
const index = workflow.pendingApprovalGates.findIndex((gate) => gate.id === transition.resolveApprovalGate);
|
||||
if (index === -1) {
|
||||
throw codedError("APPROVAL_GATE_NOT_FOUND", `Approval gate not found: ${transition.resolveApprovalGate}`);
|
||||
}
|
||||
workflow.pendingApprovalGates.splice(index, 1);
|
||||
changes.push("pendingApprovalGates.resolve");
|
||||
}
|
||||
validateWorkflowState(workflow, "updated workflow");
|
||||
return changes;
|
||||
}
|
||||
|
||||
function workflowReconstruction(state, run) {
|
||||
const currentDetour = run.workflow.detourStack.at(-1);
|
||||
return {
|
||||
runId: run.id,
|
||||
status: run.status,
|
||||
isActive: state.activeRunId === run.id,
|
||||
currentPlan: state.activeRunId === run.id ? state.currentPlan : currentPlanProjection(run.id, run.workflow, run.updatedAt),
|
||||
currentPlanPointer: run.workflow.currentPlanPointer,
|
||||
originalGoal: run.workflow.originalGoal,
|
||||
currentGoal: currentDetour?.goal ?? run.workflow.originalGoal,
|
||||
detourStack: run.workflow.detourStack,
|
||||
currentWorkflowStage: run.workflow.currentWorkflowStage,
|
||||
selectedDepth: run.workflow.selectedDepth,
|
||||
mutationAuthority: run.workflow.mutationAuthority,
|
||||
activeModules: run.workflow.activeModules,
|
||||
evidenceFreshness: run.workflow.evidenceFreshness,
|
||||
evidenceProvenance: run.workflow.evidenceProvenance,
|
||||
pendingApprovalGates: run.workflow.pendingApprovalGates,
|
||||
effects: run.effects,
|
||||
};
|
||||
}
|
||||
|
||||
function currentPlanProjection(runId, workflow, at) {
|
||||
return workflow.currentPlanPointer == null
|
||||
? null
|
||||
: { runId, pointer: workflow.currentPlanPointer, updatedAt: validIsoOrNow(at) };
|
||||
}
|
||||
|
||||
function validateCurrentPlanProjection(state, file) {
|
||||
if (state.currentPlan == null) {
|
||||
const active = state.activeRunId == null ? null : state.runs[state.activeRunId];
|
||||
if (active?.workflow.currentPlanPointer != null) {
|
||||
throw new Error(`Current plan projection is missing in ${file}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
assertPlainRecord(state.currentPlan, `current plan in ${file}`, new Set(["runId", "pointer", "updatedAt"]));
|
||||
validateRunId(state.currentPlan.runId);
|
||||
validateOptionalPointer(state.currentPlan.pointer, false);
|
||||
validateIso(state.currentPlan.updatedAt, "current plan timestamp");
|
||||
if (state.activeRunId !== state.currentPlan.runId || !Object.hasOwn(state.runs, state.currentPlan.runId)) {
|
||||
throw new Error(`Current plan is not owned by the active run in ${file}`);
|
||||
}
|
||||
if (state.runs[state.currentPlan.runId].workflow.currentPlanPointer !== state.currentPlan.pointer) {
|
||||
throw new Error(`Current plan projection is inconsistent in ${file}`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateDetour(detour) {
|
||||
assertPlainRecord(detour, "detour", new Set(["goal", "fromStage", "enteredAt"]));
|
||||
validateGoal(detour.goal, "detour goal");
|
||||
validateWorkflowToken(detour.fromStage, "detour source stage");
|
||||
validateIso(detour.enteredAt, "detour timestamp");
|
||||
}
|
||||
|
||||
function validateEvidenceFreshness(value) {
|
||||
assertPlainRecord(value, "evidence freshness", new Set(["status", "assessedAt"]));
|
||||
if (!EVIDENCE_FRESHNESS.has(value.status)) throw new TypeError("Invalid evidence freshness");
|
||||
if (value.assessedAt != null) validateIso(value.assessedAt, "evidence assessment timestamp");
|
||||
if (value.status !== "unknown" && value.assessedAt == null) {
|
||||
throw new TypeError("Assessed evidence freshness requires a timestamp");
|
||||
}
|
||||
}
|
||||
|
||||
function validateEvidenceProvenanceInput(entry) {
|
||||
assertPlainRecord(entry, "evidence provenance input", new Set(["source", "reference", "capturedAt"]));
|
||||
validateWorkflowToken(entry.source, "evidence source");
|
||||
validateCompactLine(entry.reference, "evidence reference", 2_048);
|
||||
if (entry.capturedAt != null) validateIso(entry.capturedAt, "evidence capture timestamp");
|
||||
}
|
||||
|
||||
function validateEvidenceProvenance(entry) {
|
||||
assertPlainRecord(entry, "evidence provenance", new Set(["source", "reference", "capturedAt", "recordedAt"]));
|
||||
validateWorkflowToken(entry.source, "evidence source");
|
||||
validateCompactLine(entry.reference, "evidence reference", 2_048);
|
||||
validateIso(entry.capturedAt, "evidence capture timestamp");
|
||||
validateIso(entry.recordedAt, "evidence record timestamp");
|
||||
}
|
||||
|
||||
function validateApprovalGateInput(gate) {
|
||||
assertPlainRecord(gate, "approval gate input", new Set(["id", "summary"]));
|
||||
validateStateKey(gate.id, "approval gate id");
|
||||
validateGoal(gate.summary, "approval gate summary", 2_048);
|
||||
}
|
||||
|
||||
function validateApprovalGate(gate) {
|
||||
assertPlainRecord(gate, "approval gate", new Set(["id", "summary", "requestedAt"]));
|
||||
validateStateKey(gate.id, "approval gate id");
|
||||
validateGoal(gate.summary, "approval gate summary", 2_048);
|
||||
validateIso(gate.requestedAt, "approval gate timestamp");
|
||||
}
|
||||
|
||||
function validateModuleList(modules) {
|
||||
if (!Array.isArray(modules) || modules.length > 64) throw new TypeError("Invalid active modules");
|
||||
const seen = new Set();
|
||||
for (const moduleName of modules) {
|
||||
validateStateKey(moduleName, "active module");
|
||||
if (seen.has(moduleName)) throw new TypeError(`Duplicate active module: ${moduleName}`);
|
||||
seen.add(moduleName);
|
||||
}
|
||||
}
|
||||
|
||||
function validateOptionalPointer(value, nullable = true) {
|
||||
if (nullable && value == null) return;
|
||||
validateCompactLine(value, "current plan pointer", 2_048);
|
||||
}
|
||||
|
||||
function validateWorkflowToken(value, label) {
|
||||
validateStateKey(value, label);
|
||||
}
|
||||
|
||||
function validateMutationAuthority(value) {
|
||||
validateWorkflowToken(value, "mutation authority");
|
||||
if (!MUTATION_AUTHORITIES.has(value)) throw new TypeError("Unsupported mutation authority");
|
||||
}
|
||||
|
||||
function validateGoal(value, label, max = 20_000) {
|
||||
if (typeof value !== "string" || !value.trim() || value.length > max || value.includes("\0")) {
|
||||
throw new TypeError(`Invalid ${label}`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateCompactLine(value, label, max) {
|
||||
if (typeof value !== "string" || !value.trim() || value.length > max || /[\r\n\0]/.test(value)) {
|
||||
throw new TypeError(`Invalid ${label}`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateIso(value, label) {
|
||||
if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(value) ||
|
||||
Number.isNaN(Date.parse(value))) {
|
||||
throw new TypeError(`Invalid ${label}`);
|
||||
}
|
||||
}
|
||||
|
||||
function validIsoOrNow(value) {
|
||||
try {
|
||||
validateIso(value, "timestamp");
|
||||
return value;
|
||||
} catch {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
}
|
||||
|
||||
function assertPlainRecord(value, label, allowedKeys) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value) ||
|
||||
![Object.prototype, null].includes(Object.getPrototypeOf(value))) {
|
||||
throw new TypeError(`Invalid ${label}`);
|
||||
}
|
||||
for (const key of Object.keys(value)) {
|
||||
if (["__proto__", "prototype", "constructor"].includes(key) || !allowedKeys.has(key)) {
|
||||
throw new TypeError(`Unknown ${label} field: ${key}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRecord(value, validateKey, label) {
|
||||
if (value == null) return Object.create(null);
|
||||
if (typeof value !== "object" || Array.isArray(value)) throw new TypeError(`Invalid ${label} record`);
|
||||
const normalized = Object.create(null);
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
validateKey(key);
|
||||
normalized[key] = child;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function ownedEffect(state, runId, effectKey) {
|
||||
const run = Object.hasOwn(state.runs ?? {}, runId) ? state.runs[runId] : null;
|
||||
if (!run || !Object.hasOwn(run.effects ?? {}, effectKey)) return null;
|
||||
return run.effects[effectKey];
|
||||
}
|
||||
|
||||
function validateRunId(value) {
|
||||
validateStateKey(value, "run id");
|
||||
}
|
||||
|
||||
function validateEffectKey(value) {
|
||||
validateStateKey(value, "external effect key");
|
||||
}
|
||||
|
||||
function validateStateKey(value, label) {
|
||||
if (typeof value !== "string" || !/^[a-zA-Z0-9][a-zA-Z0-9_.:-]{0,127}$/.test(value) ||
|
||||
["__proto__", "prototype", "constructor"].includes(value)) {
|
||||
throw new TypeError(`Invalid ${label}`);
|
||||
}
|
||||
}
|
||||
|
||||
function stableIdempotencyKey(projectId, runId, effectKey) {
|
||||
const digest = createHash("sha256")
|
||||
.update(String(projectId)).update("\0")
|
||||
.update(runId).update("\0")
|
||||
.update(effectKey)
|
||||
.digest("hex");
|
||||
return `gstack_${digest}`;
|
||||
}
|
||||
|
||||
function isoNow(now) {
|
||||
return (now ? now() : new Date()).toISOString();
|
||||
}
|
||||
|
||||
function jsonSafe(value) {
|
||||
if (value === undefined) return null;
|
||||
try {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function codedError(code, message) {
|
||||
const error = new Error(message);
|
||||
error.code = code;
|
||||
return error;
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import { constants as fsConstants } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
export async function pathExists(file) {
|
||||
try {
|
||||
await fs.access(file, fsConstants.F_OK);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (["ENOENT", "ENOTDIR"].includes(error?.code)) return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function readJson(file, fallback) {
|
||||
try {
|
||||
const raw = await fs.readFile(file, "utf8");
|
||||
return JSON.parse(raw);
|
||||
} catch (error) {
|
||||
if (error?.code === "ENOENT" && arguments.length >= 2) return fallback;
|
||||
if (error instanceof SyntaxError) {
|
||||
error.message = `Invalid JSON in ${file}: ${error.message}`;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function atomicWriteFile(file, data, options = {}) {
|
||||
const directory = path.dirname(file);
|
||||
const mode = options.mode ?? 0o644;
|
||||
await fs.mkdir(directory, { recursive: true, mode: 0o700 });
|
||||
const temp = path.join(directory, `.${path.basename(file)}.tmp-${process.pid}-${randomUUID()}`);
|
||||
let handle;
|
||||
try {
|
||||
handle = await fs.open(temp, "wx", mode);
|
||||
await handle.writeFile(data, options.encoding ?? "utf8");
|
||||
await handle.sync();
|
||||
await handle.chmod(mode);
|
||||
await handle.close();
|
||||
handle = undefined;
|
||||
await replaceFile(temp, file);
|
||||
// umask does not get to weaken the privacy guarantee on secret files.
|
||||
await fs.chmod(file, mode);
|
||||
await syncDirectory(directory);
|
||||
} catch (error) {
|
||||
if (handle) await handle.close().catch(() => {});
|
||||
await fs.rm(temp, { force: true }).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function atomicWriteJson(file, value, options = {}) {
|
||||
const serialized = `${JSON.stringify(value, null, 2)}\n`;
|
||||
await atomicWriteFile(file, serialized, options);
|
||||
}
|
||||
|
||||
async function replaceFile(source, destination) {
|
||||
try {
|
||||
await fs.rename(source, destination);
|
||||
} catch (error) {
|
||||
// Windows cannot always rename over an existing file. Preserve the old
|
||||
// copy until the new name is in place so a failed activation is recoverable.
|
||||
if (!(["EEXIST", "EPERM", "EACCES"].includes(error?.code))) throw error;
|
||||
const backup = `${destination}.replace-${process.pid}-${randomUUID()}`;
|
||||
let backedUp = false;
|
||||
try {
|
||||
await fs.rename(destination, backup);
|
||||
backedUp = true;
|
||||
} catch (backupError) {
|
||||
if (backupError?.code !== "ENOENT") throw error;
|
||||
}
|
||||
try {
|
||||
await fs.rename(source, destination);
|
||||
if (backedUp) await fs.rm(backup, { force: true });
|
||||
} catch (replacementError) {
|
||||
if (backedUp) await fs.rename(backup, destination).catch(() => {});
|
||||
throw replacementError;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function syncDirectory(directory) {
|
||||
// Directory fsync is supported on Unix and not consistently on Windows.
|
||||
try {
|
||||
const handle = await fs.open(directory, "r");
|
||||
await handle.sync();
|
||||
await handle.close();
|
||||
} catch (error) {
|
||||
if (!(["EINVAL", "ENOTSUP", "EISDIR", "EPERM", "EACCES"].includes(error?.code))) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function acquireLock(lockPath, options = {}) {
|
||||
const timeoutMs = options.timeoutMs ?? 10_000;
|
||||
const staleMs = options.staleMs ?? 120_000;
|
||||
const started = Date.now();
|
||||
const token = randomUUID();
|
||||
await fs.mkdir(path.dirname(lockPath), { recursive: true, mode: 0o700 });
|
||||
|
||||
for (let attempt = 0; ; attempt += 1) {
|
||||
try {
|
||||
await fs.mkdir(lockPath, { mode: 0o700 });
|
||||
const owner = { token, pid: process.pid, hostname: os.hostname(), createdAt: new Date().toISOString() };
|
||||
await atomicWriteJson(path.join(lockPath, "owner.json"), owner, { mode: 0o600 });
|
||||
const heartbeatMs = Math.max(1_000, Math.min(30_000, Math.floor(staleMs / 3)));
|
||||
const heartbeat = setInterval(() => {
|
||||
const now = new Date();
|
||||
fs.utimes(lockPath, now, now).catch(() => {});
|
||||
}, heartbeatMs);
|
||||
heartbeat.unref?.();
|
||||
let released = false;
|
||||
return async () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
clearInterval(heartbeat);
|
||||
try {
|
||||
const current = await readJson(path.join(lockPath, "owner.json"), null);
|
||||
if (current?.token === token) await fs.rm(lockPath, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Locks are leases. A stale-lock reaper may already have removed it.
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
if (error?.code !== "EEXIST") throw error;
|
||||
await reapStaleLock(lockPath, staleMs);
|
||||
if (Date.now() - started >= timeoutMs) {
|
||||
const timeout = new Error(`Timed out waiting for lock ${lockPath}`);
|
||||
timeout.code = "LOCK_TIMEOUT";
|
||||
throw timeout;
|
||||
}
|
||||
const delay = Math.min(20, 2 + Math.floor(attempt / 3));
|
||||
await sleep(delay);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function reapStaleLock(lockPath, staleMs) {
|
||||
try {
|
||||
const stat = await fs.stat(lockPath);
|
||||
if (Date.now() - stat.mtimeMs <= staleMs) return false;
|
||||
const owner = await readJson(path.join(lockPath, "owner.json"), null).catch(() => null);
|
||||
if (owner?.hostname === os.hostname() && processIsAlive(owner.pid)) return false;
|
||||
const staleName = `${lockPath}.stale-${process.pid}-${randomUUID()}`;
|
||||
await fs.rename(lockPath, staleName);
|
||||
await fs.rm(staleName, { recursive: true, force: true });
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (["ENOENT", "EEXIST", "ENOTEMPTY"].includes(error?.code)) return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function processIsAlive(pid) {
|
||||
if (!Number.isInteger(pid) || pid <= 0) return false;
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return error?.code === "EPERM";
|
||||
}
|
||||
}
|
||||
|
||||
export async function withLock(lockPath, callback, options = {}) {
|
||||
const release = await acquireLock(lockPath, options);
|
||||
try {
|
||||
return await callback();
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
}
|
||||
|
||||
export async function appendJsonLine(file, value, options = {}) {
|
||||
await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
|
||||
const handle = await fs.open(file, "a", options.mode ?? 0o600);
|
||||
try {
|
||||
await handle.writeFile(`${JSON.stringify(value)}\n`, "utf8");
|
||||
await handle.sync();
|
||||
await handle.chmod(options.mode ?? 0o600);
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensurePrivateFile(file, initial = "") {
|
||||
await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
|
||||
try {
|
||||
const handle = await fs.open(file, "wx", 0o600);
|
||||
await handle.writeFile(initial, "utf8");
|
||||
await handle.sync();
|
||||
await handle.close();
|
||||
} catch (error) {
|
||||
if (error?.code !== "EEXIST") throw error;
|
||||
}
|
||||
await fs.chmod(file, 0o600);
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { assertPathInside, resolveRuntimePaths } from "./paths.js";
|
||||
import { atomicWriteJson, pathExists, readJson } from "./storage.js";
|
||||
import {
|
||||
assertManagedHome,
|
||||
ensureManagedHome,
|
||||
recoverRuntimeTransactionUnlocked,
|
||||
withRuntimeLifecycleLock,
|
||||
} from "./managed-home.js";
|
||||
|
||||
export async function stageUpgrade(options) {
|
||||
const home = path.resolve(options.home);
|
||||
return withRuntimeLifecycleLock(home, async () => {
|
||||
await ensureManagedHome(home, options);
|
||||
await recoverRuntimeTransactionUnlocked(home);
|
||||
return stageUpgradeUnlocked({ ...options, home });
|
||||
}, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal transaction primitive used by the managed installer while it holds
|
||||
* the one lifecycle lock. Callers may update stable launchers/manifest in
|
||||
* beforeActivate and restore their snapshot in onRollback.
|
||||
*/
|
||||
export async function stageUpgradeUnlocked(options) {
|
||||
const { home, sourceDir } = options;
|
||||
const version = validateVersion(options.version);
|
||||
const paths = resolveRuntimePaths({ home });
|
||||
const source = path.resolve(sourceDir);
|
||||
await validateStageSource(source);
|
||||
await fs.mkdir(paths.versions, { recursive: true, mode: 0o700 });
|
||||
|
||||
await recoverPendingUpgradeUnlocked(paths, options);
|
||||
const previousExists = await pathExists(paths.versionPointer);
|
||||
const previous = await readJson(paths.versionPointer, emptyPointer());
|
||||
validatePointer(previous);
|
||||
const destination = assertPathInside(paths.versions, path.join(paths.versions, version));
|
||||
let staged = false;
|
||||
|
||||
if (!(await pathExists(destination))) {
|
||||
const stage = assertPathInside(
|
||||
paths.versions,
|
||||
path.join(paths.versions, `.stage-${version}-${randomUUID()}`),
|
||||
);
|
||||
try {
|
||||
await copyDirectory(source, stage);
|
||||
await atomicWriteJson(path.join(stage, ".gstack-version.json"), {
|
||||
schemaVersion: 2,
|
||||
version,
|
||||
stagedAt: isoNow(options.now),
|
||||
}, { mode: 0o644 });
|
||||
await assertTreeContainsNoLinks(stage);
|
||||
if (options.verify) await options.verify(stage);
|
||||
await fs.rename(stage, destination);
|
||||
staged = true;
|
||||
} catch (error) {
|
||||
await fs.rm(stage, { recursive: true, force: true }).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
if (!(await isRealDirectory(destination))) {
|
||||
throw upgradeError(`Version destination is not a real directory: ${version}`, "UPGRADE_DESTINATION_INVALID");
|
||||
}
|
||||
await assertTreeContainsNoLinks(destination);
|
||||
if (options.verify) await options.verify(destination);
|
||||
}
|
||||
|
||||
const lastKnownGood = previous.status === "active" && previous.current && previous.current !== version
|
||||
? previous.current
|
||||
: previous.lastKnownGood ?? null;
|
||||
const active = {
|
||||
schemaVersion: 2,
|
||||
status: "active",
|
||||
current: version,
|
||||
lastKnownGood,
|
||||
activatedAt: isoNow(options.now),
|
||||
verifiedAt: isoNow(options.now),
|
||||
};
|
||||
|
||||
try {
|
||||
// Health runs while the old active pointer remains visible. A candidate is
|
||||
// never published as `pending`, so concurrent launchers cannot execute it.
|
||||
if (options.healthCheck) await options.healthCheck(destination);
|
||||
if (options.beforeActivate) await options.beforeActivate({
|
||||
active,
|
||||
previous,
|
||||
previousExists,
|
||||
destination,
|
||||
staged,
|
||||
paths,
|
||||
});
|
||||
await atomicWriteJson(paths.versionPointer, active, { mode: 0o600 });
|
||||
if (options.afterActivate) await options.afterActivate({
|
||||
active,
|
||||
previous,
|
||||
previousExists,
|
||||
destination,
|
||||
staged,
|
||||
paths,
|
||||
});
|
||||
return { pointer: active, path: destination, staged };
|
||||
} catch (cause) {
|
||||
const rollbackErrors = [];
|
||||
let pointerRollbackError = null;
|
||||
try {
|
||||
if (previousExists) {
|
||||
await atomicWriteJson(paths.versionPointer, previous, { mode: 0o600 });
|
||||
} else {
|
||||
await atomicWriteJson(paths.versionPointer, {
|
||||
...emptyPointer(),
|
||||
status: "rolled_back",
|
||||
failedVersion: version,
|
||||
rolledBackAt: isoNow(options.now),
|
||||
}, { mode: 0o600 });
|
||||
}
|
||||
} catch (error) {
|
||||
pointerRollbackError = error;
|
||||
rollbackErrors.push(error);
|
||||
}
|
||||
try {
|
||||
if (options.onRollback) await options.onRollback({
|
||||
active,
|
||||
previous,
|
||||
previousExists,
|
||||
destination,
|
||||
staged,
|
||||
paths,
|
||||
cause,
|
||||
pointerRollbackError,
|
||||
});
|
||||
} catch (error) {
|
||||
rollbackErrors.push(error);
|
||||
}
|
||||
const error = upgradeError(`Upgrade ${version} failed health checks and was rolled back`, "UPGRADE_ROLLED_BACK", cause);
|
||||
if (rollbackErrors.length === 1) error.rollbackError = rollbackErrors[0];
|
||||
else if (rollbackErrors.length > 1) error.rollbackError = new AggregateError(rollbackErrors, "Runtime rollback was incomplete");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function recoverPendingUpgrade(home, options = {}) {
|
||||
const resolved = path.resolve(home);
|
||||
return withRuntimeLifecycleLock(resolved, async () => {
|
||||
await assertManagedHome(resolved, options);
|
||||
await recoverRuntimeTransactionUnlocked(resolved);
|
||||
return recoverPendingUpgradeUnlocked(resolveRuntimePaths({ home: resolved }), options);
|
||||
}, options);
|
||||
}
|
||||
|
||||
export async function recoverPendingUpgradeUnlocked(paths, options = {}) {
|
||||
const pointer = await readJson(paths.versionPointer, emptyPointer());
|
||||
validatePointer(pointer);
|
||||
if (pointer.status !== "pending") return { recovered: false, pointer };
|
||||
const fallback = pointer.lastKnownGood;
|
||||
const fallbackPath = fallback
|
||||
? assertPathInside(paths.versions, path.join(paths.versions, validateVersion(fallback)))
|
||||
: null;
|
||||
const fallbackExists = fallbackPath && await isRealDirectory(fallbackPath);
|
||||
const recovered = fallbackExists
|
||||
? {
|
||||
schemaVersion: 2,
|
||||
status: "active",
|
||||
current: fallback,
|
||||
lastKnownGood: null,
|
||||
recoveredFrom: pointer.current,
|
||||
recoveredAt: isoNow(options.now),
|
||||
}
|
||||
: {
|
||||
...emptyPointer(),
|
||||
status: "rolled_back",
|
||||
failedVersion: pointer.current,
|
||||
recoveredAt: isoNow(options.now),
|
||||
};
|
||||
await atomicWriteJson(paths.versionPointer, recovered, { mode: 0o600 });
|
||||
return { recovered: true, pointer: recovered };
|
||||
}
|
||||
|
||||
export async function rollbackUpgrade(home, options = {}) {
|
||||
const resolved = path.resolve(home);
|
||||
return withRuntimeLifecycleLock(resolved, async () => {
|
||||
await assertManagedHome(resolved, options);
|
||||
await recoverRuntimeTransactionUnlocked(resolved);
|
||||
const paths = resolveRuntimePaths({ home: resolved });
|
||||
const recovered = await recoverPendingUpgradeUnlocked(paths, options);
|
||||
const pointer = recovered.pointer;
|
||||
if (!pointer.lastKnownGood) {
|
||||
throw upgradeError("No last-known-good version is available", "NO_ROLLBACK_VERSION");
|
||||
}
|
||||
const fallbackVersion = validateVersion(pointer.lastKnownGood);
|
||||
const fallbackPath = assertPathInside(paths.versions, path.join(paths.versions, fallbackVersion));
|
||||
if (!(await isRealDirectory(fallbackPath))) {
|
||||
throw upgradeError(`Last-known-good version is missing: ${fallbackVersion}`, "ROLLBACK_VERSION_MISSING");
|
||||
}
|
||||
await assertTreeContainsNoLinks(fallbackPath);
|
||||
if (options.healthCheck) await options.healthCheck(fallbackPath);
|
||||
const rolledBack = {
|
||||
schemaVersion: 2,
|
||||
status: "active",
|
||||
current: fallbackVersion,
|
||||
lastKnownGood: pointer.current ?? null,
|
||||
rolledBackFrom: pointer.current ?? null,
|
||||
rolledBackAt: isoNow(options.now),
|
||||
};
|
||||
await atomicWriteJson(paths.versionPointer, rolledBack, { mode: 0o600 });
|
||||
return rolledBack;
|
||||
}, options);
|
||||
}
|
||||
|
||||
export async function activeVersion(home, options = {}) {
|
||||
const recovered = await recoverPendingUpgrade(home, options);
|
||||
return recovered.pointer;
|
||||
}
|
||||
|
||||
export async function uninstallRuntime(home, options = {}) {
|
||||
const resolved = path.resolve(home);
|
||||
return withRuntimeLifecycleLock(resolved, async () => {
|
||||
await assertManagedHome(resolved, options);
|
||||
await recoverRuntimeTransactionUnlocked(resolved);
|
||||
if (options.purge) {
|
||||
return purgeManagedHomeUnlocked(resolved);
|
||||
}
|
||||
const paths = resolveRuntimePaths({ home: resolved });
|
||||
await fs.rm(paths.versions, { recursive: true, force: true });
|
||||
return { purged: false, preservedState: true, home: resolved };
|
||||
}, options);
|
||||
}
|
||||
|
||||
export async function purgeManagedHomeUnlocked(home) {
|
||||
const resolved = path.resolve(home);
|
||||
const ownership = await assertManagedHome(resolved);
|
||||
const preexisting = new Set(ownership.sentinel.preexistingTopLevel ?? []);
|
||||
const managedEntries = new Set([
|
||||
".gstack-managed-home.json",
|
||||
".gstack-runtime-transaction.json",
|
||||
"bin",
|
||||
"config.json",
|
||||
"locks",
|
||||
"migration.json",
|
||||
"plans",
|
||||
"projects",
|
||||
"runtime-install.json",
|
||||
"secrets.json",
|
||||
"tmp",
|
||||
"versions",
|
||||
]);
|
||||
const present = await fs.readdir(resolved);
|
||||
const preserved = present.filter((entry) => !managedEntries.has(entry) || preexisting.has(entry));
|
||||
const quarantine = `${resolved}.purge-${process.pid}-${randomUUID()}`;
|
||||
await fs.mkdir(quarantine, { mode: 0o700 });
|
||||
const moved = [];
|
||||
try {
|
||||
for (const entry of present.filter((name) => managedEntries.has(name) && !preexisting.has(name))) {
|
||||
const source = assertPathInside(resolved, path.join(resolved, entry));
|
||||
const destination = assertPathInside(quarantine, path.join(quarantine, entry));
|
||||
await fs.rename(source, destination);
|
||||
moved.push({ source, destination });
|
||||
}
|
||||
await fs.rm(quarantine, { recursive: true, force: true });
|
||||
await fs.rmdir(resolved).catch((error) => {
|
||||
if (error?.code !== "ENOTEMPTY" && error?.code !== "EEXIST") throw error;
|
||||
});
|
||||
return { purged: true, home: resolved, preserved };
|
||||
} catch (error) {
|
||||
for (const item of moved.reverse()) {
|
||||
await fs.rename(item.destination, item.source).catch(() => {});
|
||||
}
|
||||
await fs.rmdir(quarantine).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function validateVersion(value) {
|
||||
if (typeof value !== "string" || !/^[0-9A-Za-z][0-9A-Za-z._-]{0,79}$/.test(value)) {
|
||||
throw new TypeError("Version must contain only letters, numbers, dots, underscores, or hyphens");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function validatePointer(pointer) {
|
||||
const validStatuses = new Set(["inactive", "pending", "active", "rolled_back"]);
|
||||
if (!pointer || pointer.schemaVersion !== 2 || !validStatuses.has(pointer.status)) {
|
||||
throw upgradeError("Managed version pointer is missing or unsupported", "UPGRADE_POINTER_INVALID");
|
||||
}
|
||||
if (pointer.current != null) validateVersion(pointer.current);
|
||||
if (pointer.lastKnownGood != null) validateVersion(pointer.lastKnownGood);
|
||||
}
|
||||
|
||||
async function validateStageSource(source) {
|
||||
const stat = await fs.lstat(source).catch((error) => {
|
||||
if (error?.code === "ENOENT") throw upgradeError(`Upgrade source does not exist: ${source}`, "UPGRADE_SOURCE_INVALID", error);
|
||||
throw error;
|
||||
});
|
||||
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
||||
throw upgradeError("Upgrade source must be a real directory, not a symlink", "UPGRADE_SOURCE_INVALID");
|
||||
}
|
||||
if ((await fs.readdir(source)).length === 0) {
|
||||
throw upgradeError("Upgrade source must not be empty", "UPGRADE_SOURCE_INVALID");
|
||||
}
|
||||
await assertTreeContainsNoLinks(source);
|
||||
}
|
||||
|
||||
async function copyDirectory(source, destination) {
|
||||
await fs.cp(source, destination, {
|
||||
recursive: true,
|
||||
force: false,
|
||||
errorOnExist: true,
|
||||
preserveTimestamps: true,
|
||||
verbatimSymlinks: true,
|
||||
});
|
||||
}
|
||||
|
||||
async function assertTreeContainsNoLinks(root) {
|
||||
const pending = [root];
|
||||
while (pending.length > 0) {
|
||||
const current = pending.pop();
|
||||
const stat = await fs.lstat(current);
|
||||
if (stat.isSymbolicLink()) {
|
||||
throw upgradeError(`Upgrade source contains a symlink: ${current}`, "UPGRADE_SOURCE_INVALID");
|
||||
}
|
||||
if (stat.isDirectory()) {
|
||||
for (const child of await fs.readdir(current)) pending.push(path.join(current, child));
|
||||
} else if (!stat.isFile()) {
|
||||
throw upgradeError(`Upgrade source contains an unsupported entry: ${current}`, "UPGRADE_SOURCE_INVALID");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function isRealDirectory(directory) {
|
||||
const stat = await fs.lstat(directory).catch(() => null);
|
||||
return Boolean(stat?.isDirectory() && !stat.isSymbolicLink());
|
||||
}
|
||||
|
||||
function emptyPointer() {
|
||||
return { schemaVersion: 2, status: "inactive", current: null, lastKnownGood: null };
|
||||
}
|
||||
|
||||
function isoNow(now) {
|
||||
return (now ? now() : new Date()).toISOString();
|
||||
}
|
||||
|
||||
function upgradeError(message, code, cause) {
|
||||
const error = cause === undefined ? new Error(message) : new Error(message, { cause });
|
||||
error.code = code;
|
||||
return error;
|
||||
}
|
||||
Reference in New Issue
Block a user