mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-21 04:10:47 +02:00
feat: componentize GStack 2 runtime and release integrity
This commit is contained in:
+99
-17
@@ -61,7 +61,8 @@ export async function main(argv = process.argv.slice(2), options = {}) {
|
||||
try {
|
||||
switch (command) {
|
||||
case "setup":
|
||||
return await setupCommand({ args, home, cwd, stdout });
|
||||
case "init":
|
||||
return await initCommand({ args, home, cwd, stdout, legacyAlias: command === "setup" });
|
||||
case "doctor":
|
||||
return await doctorCommand({ args, home, cwd, stdout });
|
||||
case "paths":
|
||||
@@ -73,7 +74,10 @@ export async function main(argv = process.argv.slice(2), options = {}) {
|
||||
case "state":
|
||||
return await stateCommand({ args, home, cwd, env, stdout, stderr });
|
||||
case "context":
|
||||
return await contextCommand({ args, home, cwd, env, stdin, stdout, stderr });
|
||||
return await contextCommand({
|
||||
args, home, cwd, env, stdin, stdout, stderr,
|
||||
clientFactory: options.contextClientFactory ?? ((clientOptions) => new ContextClient(clientOptions)),
|
||||
});
|
||||
case "cleanup":
|
||||
return await cleanupCommand({ args, home, stdout });
|
||||
case "upgrade":
|
||||
@@ -140,17 +144,18 @@ async function pathsCommand({ args, home, stdout }) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function setupCommand({ args, home, cwd, stdout }) {
|
||||
async function initCommand({ args, home, cwd, stdout, legacyAlias = false }) {
|
||||
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`);
|
||||
write(stdout, `${legacyAlias ? "Note: `gstack setup` initializes state only; use `gstack init` for this operation.\n" : ""}GStack state initialized\nhome: ${result.paths.home}\nproject: ${result.identity.projectId}\nnetwork: ${result.config.network.mode}\noptional runtime: unchanged (run \`gstack doctor\` for capability readiness)\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));
|
||||
const parsed = parseFlags(args, new Set(["--json", "--skill-api"]));
|
||||
if (parsed.positionals.length) throw cliError("Doctor accepts only named options", "USAGE");
|
||||
const report = await runDoctor({ home, cwd, expectedSkillApi: parsed.values.get("--skill-api") });
|
||||
write(stdout, parsed.flags.has("--json") ? `${JSON.stringify(report, null, 2)}\n` : formatDoctor(report));
|
||||
return report.ok ? 0 : 1;
|
||||
}
|
||||
|
||||
@@ -372,14 +377,14 @@ async function withOwnedRuntimeMutation(home, callback) {
|
||||
});
|
||||
}
|
||||
|
||||
async function contextCommand({ args, home, cwd, env, stdin, stdout, stderr }) {
|
||||
async function contextCommand({ args, home, cwd, env, stdin, stdout, stderr, clientFactory }) {
|
||||
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`);
|
||||
write(stdout, `Context.dev: ${status.contextReady ? "ready" : "not ready"}\nkey: ${status.configured ? `configured (${status.keySource})` : "missing"}\nvalidation: ${status.validation}\nweb context: ${status.selection ?? "not selected"}\nconsent: ${status.consent ? "yes" : "no"}\n`);
|
||||
}
|
||||
return status.ready ? 0 : 1;
|
||||
}
|
||||
@@ -407,7 +412,7 @@ async function contextCommand({ args, home, cwd, env, stdin, stdout, stderr }) {
|
||||
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"]);
|
||||
rejectUnknown(rest, ["--consent", "--offline"]);
|
||||
await setupRuntime({ home, cwd });
|
||||
let consent = rest.includes("--consent");
|
||||
if (!consent) {
|
||||
@@ -430,6 +435,24 @@ async function contextCommand({ args, home, cwd, env, stdin, stdout, stderr }) {
|
||||
: (await readStream(stdin)).trim();
|
||||
}
|
||||
validateContextKey(key);
|
||||
const offline = rest.includes("--offline");
|
||||
let verification = { status: "unverified", checkedAt: null };
|
||||
if (!offline) {
|
||||
const client = clientFactory({
|
||||
home,
|
||||
env,
|
||||
key,
|
||||
config: {
|
||||
network: { mode: "context", consent: true, selection: "context" },
|
||||
context: { baseUrl: "https://api.context.dev/v1", validation: { status: "verified" } },
|
||||
},
|
||||
});
|
||||
await client.scrapeMarkdown("https://www.context.dev", {
|
||||
useMainContentOnly: true,
|
||||
maxAgeMs: 86_400_000,
|
||||
});
|
||||
verification = { status: "verified", checkedAt: new Date().toISOString() };
|
||||
}
|
||||
await withOwnedRuntimeMutation(home, async () => {
|
||||
await secretSet(home, "context.apiKey", key);
|
||||
await configSetNetworkChoice(home, {
|
||||
@@ -437,14 +460,18 @@ async function contextCommand({ args, home, cwd, env, stdin, stdout, stderr }) {
|
||||
consent: true,
|
||||
selection: "context",
|
||||
});
|
||||
await configSet(home, "context.validation", verification);
|
||||
});
|
||||
write(stdout, "Context.dev configured. The key is stored privately; network mode is context.\nKey source: https://www.context.dev/auth.md\n");
|
||||
write(stdout, offline
|
||||
? "Context.dev key saved but unverified. Provider operations remain blocked until `gstack context setup --consent` verifies it online.\n"
|
||||
: "Context.dev configured and verified. 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"]));
|
||||
if (parsed.positionals.length) throw cliError("Context smoke accepts only --url and --json", "USAGE");
|
||||
const url = parsed.values.get("--url") ?? "https://www.context.dev";
|
||||
const client = new ContextClient({ home, env });
|
||||
const client = clientFactory({ home, env });
|
||||
const response = await client.scrapeMarkdown(url, { useMainContentOnly: true, maxAgeMs: 86_400_000 });
|
||||
const result = {
|
||||
ok: true,
|
||||
@@ -456,11 +483,55 @@ async function contextCommand({ args, home, cwd, env, stdin, stdout, stderr }) {
|
||||
`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");
|
||||
if (["scrape-markdown", "scrape-html", "crawl", "sitemap", "screenshot"].includes(action)) {
|
||||
return contextOperation({ action, args: rest, home, env, stdout, clientFactory });
|
||||
}
|
||||
throw cliError("Usage: gstack context status|options|select|setup|smoke|scrape-markdown|scrape-html|crawl|sitemap|screenshot", "USAGE");
|
||||
}
|
||||
|
||||
async function contextOperation({ action, args, home, env, stdout, clientFactory }) {
|
||||
const parsed = parseFlags(args, new Set([
|
||||
"--json", "--main-content", "--full-page", "--max-pages", "--max-depth", "--max-links",
|
||||
]));
|
||||
const target = parsed.positionals?.[0];
|
||||
if (!target || parsed.positionals.length !== 1) {
|
||||
throw cliError(`Usage: gstack context ${action} <public-url-or-domain> [options]`, "USAGE");
|
||||
}
|
||||
const client = clientFactory({ home, env });
|
||||
let response;
|
||||
if (action === "scrape-markdown") {
|
||||
response = await client.scrapeMarkdown(target, { useMainContentOnly: parsed.flags.has("--main-content") });
|
||||
} else if (action === "scrape-html") {
|
||||
response = await client.scrapeHtml(target, { useMainContentOnly: parsed.flags.has("--main-content") });
|
||||
} else if (action === "crawl") {
|
||||
response = await client.crawl(target, numericContextOptions(parsed.values, [["--max-pages", "maxPages"], ["--max-depth", "maxDepth"]]));
|
||||
} else if (action === "sitemap") {
|
||||
response = await client.sitemap(target, numericContextOptions(parsed.values, [["--max-links", "maxLinks"]]));
|
||||
} else {
|
||||
response = await client.screenshot(target, { fullScreenshot: parsed.flags.has("--full-page") });
|
||||
}
|
||||
if (parsed.flags.has("--json")) write(stdout, `${JSON.stringify(response, null, 2)}\n`);
|
||||
else {
|
||||
const preferred = action === "scrape-markdown" ? response.markdown : action === "scrape-html" ? response.html : null;
|
||||
write(stdout, typeof preferred === "string" ? `${preferred}\n` : `${JSON.stringify(response, null, 2)}\n`);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function numericContextOptions(values, mappings) {
|
||||
const result = {};
|
||||
for (const [flag, key] of mappings) {
|
||||
if (!values.has(flag)) continue;
|
||||
const value = Number(values.get(flag));
|
||||
if (!Number.isSafeInteger(value) || value < 1) throw cliError(`${flag} must be a positive integer`, "USAGE");
|
||||
result[key] = value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function cleanupCommand({ args, home, stdout }) {
|
||||
const parsed = parseFlags(args, new Set(["--dry-run", "--older-than-hours", "--json"]));
|
||||
if (parsed.positionals.length) throw cliError("Cleanup accepts only named options", "USAGE");
|
||||
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");
|
||||
@@ -475,6 +546,7 @@ async function cleanupCommand({ args, home, stdout }) {
|
||||
|
||||
async function upgradeCommand({ args, home, stdout, installOptions = {} }) {
|
||||
const parsed = parseFlags(args, new Set(["--source", "--version", "--rollback", "--json"]));
|
||||
if (parsed.positionals.length) throw cliError("Upgrade accepts only named options", "USAGE");
|
||||
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);
|
||||
@@ -550,16 +622,21 @@ function parseModuleList(value) {
|
||||
function parseFlags(args, allowed) {
|
||||
const flags = new Set();
|
||||
const values = new Map();
|
||||
const positionals = [];
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index];
|
||||
if (!arg.startsWith("--")) {
|
||||
positionals.push(arg);
|
||||
continue;
|
||||
}
|
||||
if (!allowed.has(arg)) throw cliError(`Unknown option: ${arg}`, "USAGE");
|
||||
if (["--source", "--version", "--url", "--older-than-hours"].includes(arg)) {
|
||||
if (["--source", "--version", "--url", "--older-than-hours", "--max-pages", "--max-depth", "--max-links", "--skill-api"].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 };
|
||||
return { flags, values, positionals };
|
||||
}
|
||||
|
||||
function rejectUnknown(args, allowed) {
|
||||
@@ -628,8 +705,9 @@ function exitCodeFor(error) {
|
||||
function usage() {
|
||||
return `gstack ${RUNTIME_VERSION}\n\n` +
|
||||
"Usage:\n" +
|
||||
" gstack setup\n" +
|
||||
" gstack doctor [--json]\n" +
|
||||
" gstack init # initialize state only\n" +
|
||||
" gstack setup # compatibility alias for init\n" +
|
||||
" gstack doctor [--skill-api <version>] [--json]\n" +
|
||||
" gstack paths [--json|--shell]\n" +
|
||||
" gstack runtime path <bundle-relative-path>\n" +
|
||||
" gstack config get [key]\n" +
|
||||
@@ -649,6 +727,10 @@ function usage() {
|
||||
" 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 context scrape-markdown|scrape-html <public-url> [--main-content] [--json]\n" +
|
||||
" gstack context crawl <public-url> [--max-pages N] [--max-depth N] [--json]\n" +
|
||||
" gstack context sitemap <public-domain> [--max-links N] [--json]\n" +
|
||||
" gstack context screenshot <public-url-or-domain> [--full-page] [--json]\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";
|
||||
|
||||
+13
-1
@@ -6,7 +6,10 @@ 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" }),
|
||||
context: Object.freeze({
|
||||
baseUrl: "https://api.context.dev/v1",
|
||||
validation: Object.freeze({ status: "unverified", checkedAt: null }),
|
||||
}),
|
||||
cleanup: Object.freeze({ retentionDays: 30 }),
|
||||
});
|
||||
|
||||
@@ -199,6 +202,15 @@ function validateConfig(config) {
|
||||
throw new TypeError("context.baseUrl must be the official credential-free Context.dev v1 HTTPS endpoint");
|
||||
}
|
||||
}
|
||||
if (config.context?.validation != null) {
|
||||
if (!["verified", "unverified"].includes(config.context.validation.status)) {
|
||||
throw new TypeError("context.validation.status must be `verified` or `unverified`");
|
||||
}
|
||||
if (config.context.validation.checkedAt != null &&
|
||||
!Number.isFinite(Date.parse(config.context.validation.checkedAt))) {
|
||||
throw new TypeError("context.validation.checkedAt must be an ISO timestamp or null");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function cloneDefaultConfig() {
|
||||
|
||||
+5
-2
@@ -522,7 +522,8 @@ function isPlainObject(value) {
|
||||
function hasContextNetworkConsent(config) {
|
||||
return config?.network?.selection === "context" &&
|
||||
config?.network?.mode === "context" &&
|
||||
config?.network?.consent === true;
|
||||
config?.network?.consent === true &&
|
||||
config?.context?.validation?.status !== "unverified";
|
||||
}
|
||||
|
||||
export async function contextStatus(home, env = process.env) {
|
||||
@@ -533,13 +534,15 @@ export async function contextStatus(home, env = process.env) {
|
||||
} catch (error) {
|
||||
if (error?.code !== "CONTEXT_KEY_MISSING") throw error;
|
||||
}
|
||||
const contextReady = Boolean(keySource) && hasContextNetworkConsent(config);
|
||||
const validation = config.context?.validation?.status ?? "unverified";
|
||||
const contextReady = Boolean(keySource) && hasContextNetworkConsent(config) && validation === "verified";
|
||||
return {
|
||||
configured: Boolean(keySource),
|
||||
keySource,
|
||||
networkMode: config.network.mode,
|
||||
selection: config.network.selection,
|
||||
consent: config.network.consent === true,
|
||||
validation,
|
||||
contextReady,
|
||||
ready: config.network.selection === "context"
|
||||
? contextReady
|
||||
|
||||
+185
-12
@@ -1,6 +1,7 @@
|
||||
import { constants as fsConstants } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { spawn as nodeSpawn } from "node:child_process";
|
||||
import { resolveRuntimePaths } from "./paths.js";
|
||||
import { readJson, pathExists } from "./storage.js";
|
||||
@@ -8,18 +9,29 @@ 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";
|
||||
import { bashCandidates } from "./tooling.js";
|
||||
import {
|
||||
OPTIONAL_RUNTIME_CAPABILITIES,
|
||||
RUNTIME_CAPABILITY_DEPENDENCIES,
|
||||
RUNTIME_COMPATIBILITY,
|
||||
managedBunRelativePath,
|
||||
} from "./install.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 expectedSkillApi = options.expectedSkillApi ?? RUNTIME_COMPATIBILITY.skillApi;
|
||||
if (typeof expectedSkillApi !== "string" || !/^[0-9A-Za-z][0-9A-Za-z._-]{0,31}$/.test(expectedSkillApi)) {
|
||||
throw new TypeError("Expected skill API must be a short version identifier");
|
||||
}
|
||||
|
||||
const node = await inspectLauncherNode(options.nodeCommand ?? process.env.GSTACK_NODE ?? "node");
|
||||
add("runtime", node.ok ? "pass" : "fail", node.message, node.details);
|
||||
add("launcher-node", 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`." });
|
||||
add("home", "fail", `State home does not exist: ${paths.home}`, { remedy: "Run `gstack init`." });
|
||||
} else {
|
||||
try {
|
||||
await fs.access(paths.home, fsConstants.R_OK | fsConstants.W_OK);
|
||||
@@ -33,7 +45,7 @@ export async function runDoctor(options = {}) {
|
||||
} 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.",
|
||||
remedy: "Run `gstack init` with the intended GSTACK_HOME.",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -79,7 +91,7 @@ export async function runDoctor(options = {}) {
|
||||
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("project", "warn", `No state initialized for ${identity.projectId}`, { remedy: "Run `gstack init`." });
|
||||
}
|
||||
add("git", identity.isGit ? "pass" : "warn",
|
||||
identity.isGit ? `Git worktree ${identity.worktreeId}` : "Current directory is not a Git worktree");
|
||||
@@ -90,14 +102,77 @@ export async function runDoctor(options = {}) {
|
||||
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");
|
||||
add("upgrade", recovery.recovered ? "warn" : "pass", recovery.recovered
|
||||
? `Recovered interrupted upgrade${pointer?.current ? ` to ${pointer.current}` : " without a last-known-good version"}`
|
||||
: pointer?.current ? `Version pointer is active: ${pointer.current}` : "No pending managed runtime transaction");
|
||||
if (!pointer?.current) {
|
||||
add("managed-runtime", "fail", "No active managed runtime", {
|
||||
remedy: "Install the optional runtime explicitly from the GStack bootstrap package; judgment-only skills remain available.",
|
||||
});
|
||||
for (const capability of OPTIONAL_RUNTIME_CAPABILITIES) {
|
||||
add(`capability:${capability}`, "warn", "not installed");
|
||||
}
|
||||
} else {
|
||||
const activeRoot = path.join(paths.versions, pointer.current);
|
||||
const stat = await fs.lstat(activeRoot).catch(() => null);
|
||||
if (!stat?.isDirectory() || stat.isSymbolicLink()) {
|
||||
add("managed-runtime", "fail", `Active managed runtime is missing or unsafe: ${pointer.current}`);
|
||||
for (const capability of OPTIONAL_RUNTIME_CAPABILITIES) add(`capability:${capability}`, "warn", "runtime unavailable");
|
||||
} else {
|
||||
const manifest = await readJson(path.join(activeRoot, ".gstack-bundle.json"), null);
|
||||
const compatible = manifest?.compatibility?.skillApi === expectedSkillApi;
|
||||
add("managed-runtime", compatible ? (recovery.recovered ? "warn" : "pass") : "fail",
|
||||
compatible
|
||||
? `${recovery.recovered ? "Recovered" : "Active"} managed runtime ${pointer.current} (skill API ${manifest.compatibility.skillApi})`
|
||||
: `Managed runtime ${pointer.current} is missing compatible skill API metadata`,
|
||||
compatible ? { skillApi: manifest.compatibility.skillApi } : {
|
||||
expectedSkillApi,
|
||||
remedy: "Upgrade the GStack runtime to match the installed skills.",
|
||||
});
|
||||
const bun = await inspectRuntimeBun(activeRoot, manifest, options.nodeCommand ?? process.env.GSTACK_NODE ?? "node");
|
||||
add("runtime-tool:bun", bun.ok ? "pass" : "fail", bun.message, bun.details);
|
||||
const shell = await inspectBash(options.env ?? process.env);
|
||||
add("helper-shell:bash", shell.ok ? "pass" : "fail", shell.message, shell.details);
|
||||
const python = await inspectPython(options.env ?? process.env);
|
||||
add("specialist-tool:python", python.ok ? "pass" : "warn", python.message, python.details);
|
||||
const selected = new Set(Array.isArray(manifest?.selectedCapabilities) ? manifest.selectedCapabilities : []);
|
||||
const launchers = manifest?.capabilities ?? {};
|
||||
for (const capability of OPTIONAL_RUNTIME_CAPABILITIES) {
|
||||
if (!selected.has(capability)) {
|
||||
add(`capability:${capability}`, "warn", "not selected");
|
||||
continue;
|
||||
}
|
||||
const missingDependencies = (RUNTIME_CAPABILITY_DEPENDENCIES[capability] ?? [])
|
||||
.filter((dependency) => !selected.has(dependency));
|
||||
if (missingDependencies.length) {
|
||||
add(`capability:${capability}`, "fail", `installed without required dependencies: ${missingDependencies.join(", ")}`);
|
||||
continue;
|
||||
}
|
||||
if (!capabilityLaunchersReady(capability, launchers)) {
|
||||
add(`capability:${capability}`, "fail", "selected but required launcher metadata is missing");
|
||||
continue;
|
||||
}
|
||||
if (capability === "browser") {
|
||||
const browser = await inspectManagedChromium(activeRoot, options.nodeCommand ?? process.env.GSTACK_NODE ?? "node");
|
||||
add(`capability:${capability}`, browser.ok ? "pass" : "fail", browser.message, browser.details);
|
||||
continue;
|
||||
}
|
||||
if (capability === "ios") {
|
||||
const ios = await inspectXcrun();
|
||||
add(`capability:${capability}`, ios.ok ? "pass" : "fail", ios.message, ios.details);
|
||||
continue;
|
||||
}
|
||||
add(`capability:${capability}`, "pass", "installed with required dependencies and launcher metadata");
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
add("upgrade", "fail", `Version pointer cannot be read: ${error.message}`);
|
||||
add("managed-runtime", "fail", `Managed runtime cannot be inspected: ${error.message}`);
|
||||
for (const capability of OPTIONAL_RUNTIME_CAPABILITIES) {
|
||||
if (!checks.some((check) => check.id === `capability:${capability}`)) {
|
||||
add(`capability:${capability}`, "warn", "readiness unknown because runtime inspection failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -108,6 +183,102 @@ export async function runDoctor(options = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
async function inspectRuntimeBun(activeRoot, manifest) {
|
||||
const relative = managedBunRelativePath();
|
||||
const declared = manifest?.tools?.bun;
|
||||
if (declared?.path !== relative || typeof declared?.version !== "string") {
|
||||
return { ok: false, message: "managed Bun metadata is missing or incompatible" };
|
||||
}
|
||||
const executable = path.join(activeRoot, relative);
|
||||
const stat = await fs.lstat(executable).catch(() => null);
|
||||
if (!stat?.isFile() || stat.isSymbolicLink()) return { ok: false, message: "managed Bun executable is missing or unsafe" };
|
||||
try {
|
||||
if (process.platform !== "win32") await fs.access(executable, fsConstants.X_OK);
|
||||
const result = await captureCommand(executable, ["--version"]);
|
||||
const version = result.stdout.trim();
|
||||
if (version !== declared.version) return { ok: false, message: "managed Bun version does not match its bundle metadata" };
|
||||
return { ok: true, message: `managed Bun ${version} is runnable`, details: { executable, version } };
|
||||
} catch (error) {
|
||||
return { ok: false, message: `managed Bun is not runnable: ${error.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
async function inspectBash(env) {
|
||||
for (const command of bashCandidates(env)) {
|
||||
try {
|
||||
const result = await captureCommand(command, ["--version"]);
|
||||
return { ok: true, message: "retained shell-helper Bash is available", details: { command, version: result.stdout.split(/\r?\n/, 1)[0] } };
|
||||
} catch { /* try the next explicit candidate */ }
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
message: process.platform === "win32"
|
||||
? "retained shell helpers require Git for Windows Bash (set GSTACK_BASH or install Git for Windows)"
|
||||
: "retained shell helpers require Bash (set GSTACK_BASH)",
|
||||
};
|
||||
}
|
||||
|
||||
async function inspectPython(env) {
|
||||
const candidates = [env.GSTACK_PYTHON, ...(process.platform === "win32" ? ["python", "python3"] : ["python3", "python"])]
|
||||
.filter((entry, index, list) => entry && list.indexOf(entry) === index);
|
||||
for (const command of candidates) {
|
||||
try {
|
||||
const result = await captureCommand(command, ["--version"]);
|
||||
return { ok: true, message: "optional specialist Python is available", details: { command, version: `${result.stdout}${result.stderr}`.trim() } };
|
||||
} catch { /* optional candidate */ }
|
||||
}
|
||||
return { ok: false, message: "Python 3 is absent; only specialist flows that explicitly request it are unavailable" };
|
||||
}
|
||||
|
||||
async function inspectManagedChromium(activeRoot, nodeCommand) {
|
||||
const browserRoot = path.join(activeRoot, ".gstack-runtime-browsers");
|
||||
const modulePath = path.join(activeRoot, "node_modules", "playwright", "index.mjs");
|
||||
const [browserStat, moduleStat] = await Promise.all([
|
||||
fs.lstat(browserRoot).catch(() => null),
|
||||
fs.lstat(modulePath).catch(() => null),
|
||||
]);
|
||||
if (!browserStat?.isDirectory() || browserStat.isSymbolicLink() || !moduleStat?.isFile() || moduleStat.isSymbolicLink()) {
|
||||
return { ok: false, message: "managed Chromium or Playwright module is missing/unsafe" };
|
||||
}
|
||||
try {
|
||||
const moduleUrl = pathToFileURL(modulePath).href;
|
||||
const result = await captureCommand(nodeCommand, [
|
||||
"--input-type=module",
|
||||
"--eval",
|
||||
`const { chromium } = await import(${JSON.stringify(moduleUrl)}); process.stdout.write(chromium.executablePath());`,
|
||||
], { env: { ...process.env, PLAYWRIGHT_BROWSERS_PATH: browserRoot } });
|
||||
const executable = result.stdout.trim();
|
||||
const stat = await fs.lstat(executable).catch(() => null);
|
||||
if (!stat?.isFile() || stat.isSymbolicLink()) return { ok: false, message: "Playwright could not resolve a safe managed Chromium executable" };
|
||||
if (process.platform !== "win32") await fs.access(executable, fsConstants.X_OK);
|
||||
return { ok: true, message: "managed Chromium executable is present", details: { executable } };
|
||||
} catch (error) {
|
||||
return { ok: false, message: `managed Chromium is not runnable: ${error.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
async function inspectXcrun() {
|
||||
if (process.platform !== "darwin") return { ok: false, message: "physical-iOS capability requires macOS" };
|
||||
try {
|
||||
const result = await captureCommand("xcrun", ["--find", "devicectl"]);
|
||||
return { ok: true, message: "CoreDevice tooling is present", details: { devicectl: result.stdout.trim() } };
|
||||
} catch (error) {
|
||||
return { ok: false, message: `CoreDevice tooling is unavailable: ${error.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
function capabilityLaunchersReady(capability, launchers) {
|
||||
if (capability === "browser") return typeof launchers.browse === "string";
|
||||
if (capability === "design") return typeof launchers["gstack-design"] === "string";
|
||||
if (capability === "pdf") return typeof launchers["make-pdf"] === "string";
|
||||
if (capability === "diagram") return true;
|
||||
if (capability === "ios") {
|
||||
return typeof launchers["gstack-ios-qa-daemon"] === "string" &&
|
||||
typeof launchers["gstack-ios-qa-mint"] === "string";
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function inspectLauncherNode(command) {
|
||||
try {
|
||||
const result = await captureCommand(command, ["--version"]);
|
||||
@@ -126,12 +297,14 @@ async function inspectLauncherNode(command) {
|
||||
}
|
||||
}
|
||||
|
||||
function captureCommand(command, args) {
|
||||
function captureCommand(command, args, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = nodeSpawn(command, args, {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
windowsHide: true,
|
||||
shell: false,
|
||||
cwd: options.cwd,
|
||||
env: options.env ?? process.env,
|
||||
});
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
|
||||
+946
-63
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,82 @@
|
||||
Bun itself is MIT-licensed.
|
||||
|
||||
## JavaScriptCore
|
||||
|
||||
Bun statically links JavaScriptCore (and WebKit) which is LGPL-2 licensed. WebCore files from WebKit are also licensed under LGPL2. Per LGPL2:
|
||||
|
||||
> (1) If you statically link against an LGPL’d library, you must also provide your application in an object (not necessarily source) format, so that a user has the opportunity to modify the library and relink the application.
|
||||
|
||||
You can find the patched version of WebKit used by Bun here: <https://github.com/oven-sh/webkit>. If you would like to relink Bun with changes:
|
||||
|
||||
- `git submodule update --init --recursive`
|
||||
- `make jsc`
|
||||
- `zig build`
|
||||
|
||||
This compiles JavaScriptCore, compiles Bun’s `.cpp` bindings for JavaScriptCore (which are the object files using JavaScriptCore) and outputs a new `bun` binary with your changes.
|
||||
|
||||
## Linked libraries
|
||||
|
||||
Bun statically links these libraries:
|
||||
|
||||
| Library | License |
|
||||
|---------|---------|
|
||||
| [`boringssl`](https://boringssl.googlesource.com/boringssl/) | [several licenses](https://boringssl.googlesource.com/boringssl/+/refs/heads/master/LICENSE) |
|
||||
| [`brotli`](https://github.com/google/brotli) | MIT |
|
||||
| [`libarchive`](https://github.com/libarchive/libarchive) | [several licenses](https://github.com/libarchive/libarchive/blob/master/COPYING) |
|
||||
| [`lol-html`](https://github.com/cloudflare/lol-html/tree/master/c-api) | BSD 3-Clause |
|
||||
| [`ls-hpack`](https://github.com/litespeedtech/ls-hpack) | MIT |
|
||||
| [`ls-qpack`](https://github.com/litespeedtech/ls-qpack) | MIT |
|
||||
| [`lsquic`](https://github.com/litespeedtech/lsquic) | MIT (portions derived from [Chromium proto-quic](https://github.com/litespeedtech/lsquic/blob/master/LICENSE.chrome), BSD 3-Clause) |
|
||||
| [`mimalloc`](https://github.com/microsoft/mimalloc) | MIT |
|
||||
| [`picohttp`](https://github.com/h2o/picohttpparser) | dual-licensed under the Perl License or the MIT License |
|
||||
| [`zstd`](https://github.com/facebook/zstd) | dual-licensed under the BSD License or GPLv2 license |
|
||||
| [`simdutf`](https://github.com/simdutf/simdutf) | Apache 2.0 |
|
||||
| [`tinycc`](https://github.com/tinycc/tinycc) | LGPL v2.1 |
|
||||
| [`uSockets`](https://github.com/uNetworking/uSockets) | Apache 2.0 |
|
||||
| [`zlib-ng`](https://github.com/zlib-ng/zlib-ng) | zlib |
|
||||
| [`c-ares`](https://github.com/c-ares/c-ares) | MIT licensed |
|
||||
| [`libicu`](https://github.com/unicode-org/icu) 72 | [license here](https://github.com/unicode-org/icu/blob/main/icu4c/LICENSE) |
|
||||
| [`libbase64`](https://github.com/aklomp/base64/blob/master/LICENSE) | BSD 2-Clause |
|
||||
| [`libuv`](https://github.com/libuv/libuv) (on Windows) | MIT |
|
||||
| [`libdeflate`](https://github.com/ebiggers/libdeflate) | MIT |
|
||||
| [`libjpeg-turbo`](https://github.com/libjpeg-turbo/libjpeg-turbo) | [BSD 3-Clause / IJG / zlib](https://github.com/libjpeg-turbo/libjpeg-turbo/blob/main/LICENSE.md) |
|
||||
| [`libspng`](https://github.com/randy408/libspng) | BSD 2-Clause |
|
||||
| [`libwebp`](https://github.com/webmproject/libwebp) | BSD 3-Clause |
|
||||
| [`highway`](https://github.com/google/highway) | Apache 2.0 |
|
||||
| [`uucode`](https://github.com/jacobsandlund/uucode) | MIT |
|
||||
| A fork of [`uWebsockets`](https://github.com/jarred-sumner/uwebsockets) | Apache 2.0 licensed |
|
||||
| Parts of [Tigerbeetle's IO code](https://github.com/tigerbeetle/tigerbeetle/blob/532c8b70b9142c17e07737ab6d3da68d7500cbca/src/io/windows.zig#L1) | Apache 2.0 licensed |
|
||||
| `__cxa_thread_atexit` fallback from [LLVM libc++abi](https://github.com/llvm/llvm-project/blob/llvmorg-19.1.0/libcxxabi/src/cxa_thread_atexit.cpp) | Apache 2.0 with LLVM exception |
|
||||
|
||||
## Polyfills
|
||||
|
||||
For compatibility reasons, the following packages are embedded into Bun's binary and injected if imported.
|
||||
|
||||
| Package | License |
|
||||
|---------|---------|
|
||||
| [`assert`](https://npmjs.com/package/assert) | MIT |
|
||||
| [`browserify-zlib`](https://npmjs.com/package/browserify-zlib) | MIT |
|
||||
| [`buffer`](https://npmjs.com/package/buffer) | MIT |
|
||||
| [`constants-browserify`](https://npmjs.com/package/constants-browserify) | MIT |
|
||||
| [`crypto-browserify`](https://npmjs.com/package/crypto-browserify) | MIT |
|
||||
| [`domain-browser`](https://npmjs.com/package/domain-browser) | MIT |
|
||||
| [`events`](https://npmjs.com/package/events) | MIT |
|
||||
| [`https-browserify`](https://npmjs.com/package/https-browserify) | MIT |
|
||||
| [`os-browserify`](https://npmjs.com/package/os-browserify) | MIT |
|
||||
| [`path-browserify`](https://npmjs.com/package/path-browserify) | MIT |
|
||||
| [`process`](https://npmjs.com/package/process) | MIT |
|
||||
| [`punycode`](https://npmjs.com/package/punycode) | MIT |
|
||||
| [`querystring-es3`](https://npmjs.com/package/querystring-es3) | MIT |
|
||||
| [`stream-browserify`](https://npmjs.com/package/stream-browserify) | MIT |
|
||||
| [`stream-http`](https://npmjs.com/package/stream-http) | MIT |
|
||||
| [`string_decoder`](https://npmjs.com/package/string_decoder) | MIT |
|
||||
| [`timers-browserify`](https://npmjs.com/package/timers-browserify) | MIT |
|
||||
| [`tty-browserify`](https://npmjs.com/package/tty-browserify) | MIT |
|
||||
| [`url`](https://npmjs.com/package/url) | MIT |
|
||||
| [`util`](https://npmjs.com/package/util) | MIT |
|
||||
| [`vm-browserify`](https://npmjs.com/package/vm-browserify) | MIT |
|
||||
|
||||
## Additional credits
|
||||
|
||||
- Bun's JS transpiler, CSS lexer, and Node.js module resolver source code is a Zig port of [@evanw](https://github.com/evanw)’s [esbuild](https://github.com/evanw/esbuild) project.
|
||||
- Credit to [@kipply](https://github.com/kipply) for the name "Bun"!
|
||||
@@ -0,0 +1,21 @@
|
||||
# Bun redistribution source and version record
|
||||
|
||||
Official GStack runtime 2.0.0 artifacts redistribute the unmodified Bun 1.3.14
|
||||
executable installed by the pinned `oven-sh/setup-bun` release workflow step.
|
||||
The exact upstream license inventory is vendored beside this file as
|
||||
`BUN-LICENSE-1.3.14.md`. The tagged raw file's SHA-256 is
|
||||
`2c6160ec8fb853f7e8f97d9b249e756c9b0ac44860a68b6bf4f1b0bcbc5c3741`;
|
||||
the vendored copy differs only by its final newline and has SHA-256
|
||||
`2cb858b2db8fc793bca2093489c5bc8eee615d002cc4924254904044c27a0afa`.
|
||||
|
||||
Authoritative tagged sources, including the object/source material and relink
|
||||
instructions referenced by the upstream license inventory:
|
||||
|
||||
- https://github.com/oven-sh/bun/tree/bun-v1.3.14
|
||||
- https://github.com/oven-sh/bun/blob/bun-v1.3.14/LICENSE.md
|
||||
- https://github.com/oven-sh/webkit
|
||||
|
||||
Reviewed-source installs may capture a different user-selected Bun executable.
|
||||
Its exact version is recorded in the active runtime's `.gstack-bundle.json`; the
|
||||
redistributor of such a custom bundle is responsible for retaining the matching
|
||||
upstream notices and source/relink offer.
|
||||
@@ -100,6 +100,32 @@ export async function ensureManagedHome(home, options = {}) {
|
||||
return { home: resolved, sentinel, created: false };
|
||||
}
|
||||
|
||||
/** Create/verify one direct runtime-owned directory without following links. */
|
||||
export async function ensureManagedRuntimeDirectory(home, directory) {
|
||||
const resolvedHome = path.resolve(home);
|
||||
const resolvedDirectory = path.resolve(directory);
|
||||
if (path.dirname(resolvedDirectory) !== resolvedHome) {
|
||||
throw managedHomeError("Managed runtime directory must be a direct child of GSTACK_HOME", "MANAGED_HOME_SUBDIRECTORY_UNSAFE");
|
||||
}
|
||||
try {
|
||||
await fs.mkdir(resolvedDirectory, { mode: 0o700 });
|
||||
} catch (error) {
|
||||
if (error?.code !== "EEXIST") throw error;
|
||||
}
|
||||
const stat = await fs.lstat(resolvedDirectory).catch(() => null);
|
||||
if (!stat?.isDirectory() || stat.isSymbolicLink()) {
|
||||
throw managedHomeError(`Managed runtime directory is missing or unsafe: ${resolvedDirectory}`, "MANAGED_HOME_SUBDIRECTORY_UNSAFE");
|
||||
}
|
||||
const [physicalHome, physicalDirectory] = await Promise.all([
|
||||
fs.realpath(resolvedHome),
|
||||
fs.realpath(resolvedDirectory),
|
||||
]);
|
||||
if (path.dirname(physicalDirectory) !== physicalHome) {
|
||||
throw managedHomeError(`Managed runtime directory escaped GSTACK_HOME: ${resolvedDirectory}`, "MANAGED_HOME_SUBDIRECTORY_UNSAFE");
|
||||
}
|
||||
return resolvedDirectory;
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -0,0 +1,422 @@
|
||||
#!/usr/bin/env node
|
||||
// Dependency-free bootstrap copied into each standards-installed GStack skill.
|
||||
// It installs only the optional local runtime; host skill placement remains the
|
||||
// responsibility of the Agent Skills installer.
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { createReadStream } from "node:fs";
|
||||
import { spawn } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
export const BOOTSTRAP_SCHEMA_VERSION = 2;
|
||||
export const BOOTSTRAP_RUNTIME_VERSION = "2.0.0";
|
||||
export const OFFICIAL_MANIFEST_URL =
|
||||
`https://github.com/time-attack/gstack/releases/download/v${BOOTSTRAP_RUNTIME_VERSION}/gstack-runtime-manifest.json`;
|
||||
const CAPABILITIES = new Set(["browser", "browser-visible", "design", "pdf", "diagram", "ios"]);
|
||||
const CAPABILITY_DEPENDENCIES = Object.freeze({
|
||||
browser: Object.freeze([]),
|
||||
"browser-visible": Object.freeze([]),
|
||||
design: Object.freeze([]),
|
||||
pdf: Object.freeze(["browser", "diagram"]),
|
||||
diagram: Object.freeze(["browser"]),
|
||||
ios: Object.freeze([]),
|
||||
});
|
||||
export const COMPONENT_DEPENDENCIES = Object.freeze({
|
||||
core: Object.freeze([]),
|
||||
"browser-code": Object.freeze(["core"]),
|
||||
"browser-headless": Object.freeze(["browser-code"]),
|
||||
"browser-visible": Object.freeze(["browser-code"]),
|
||||
design: Object.freeze(["core"]),
|
||||
diagram: Object.freeze(["browser-headless"]),
|
||||
pdf: Object.freeze(["diagram"]),
|
||||
ios: Object.freeze(["core"]),
|
||||
});
|
||||
export const CAPABILITY_COMPONENTS = Object.freeze({
|
||||
browser: Object.freeze(["browser-code", "browser-headless"]),
|
||||
"browser-visible": Object.freeze(["browser-code", "browser-visible"]),
|
||||
design: Object.freeze(["design"]),
|
||||
diagram: Object.freeze(["diagram"]),
|
||||
pdf: Object.freeze(["pdf"]),
|
||||
ios: Object.freeze(["ios"]),
|
||||
});
|
||||
const ALLOWED_DOWNLOAD_HOSTS = new Set([
|
||||
"github.com",
|
||||
"objects.githubusercontent.com",
|
||||
"release-assets.githubusercontent.com",
|
||||
]);
|
||||
const OFFICIAL_RELEASE_PREFIX = `/time-attack/gstack/releases/download/v${BOOTSTRAP_RUNTIME_VERSION}/`;
|
||||
const OFFICIAL_CERTIFICATE_IDENTITY =
|
||||
`https://github.com/time-attack/gstack/.github/workflows/release-artifacts.yml@refs/tags/v${BOOTSTRAP_RUNTIME_VERSION}`;
|
||||
const GITHUB_OIDC_ISSUER = "https://token.actions.githubusercontent.com";
|
||||
|
||||
export async function main(argv = process.argv.slice(2), options = {}) {
|
||||
const io = {
|
||||
stdout: options.stdout ?? process.stdout,
|
||||
stderr: options.stderr ?? process.stderr,
|
||||
};
|
||||
try {
|
||||
const parsed = parseArgs(argv);
|
||||
if (parsed.help) {
|
||||
io.stdout.write(usage());
|
||||
return 0;
|
||||
}
|
||||
if (!["preview", "install"].includes(parsed.action)) {
|
||||
throw bootstrapError("Expected `preview` or `install`", "BOOTSTRAP_USAGE");
|
||||
}
|
||||
|
||||
const platform = options.platform ?? process.platform;
|
||||
if (parsed.capabilities.includes("ios") && platform !== "darwin") {
|
||||
throw bootstrapError("The physical-iOS capability is available only on macOS", "BOOTSTRAP_PLATFORM_UNSUPPORTED");
|
||||
}
|
||||
if (parsed.source) {
|
||||
if (parsed.action === "preview") {
|
||||
io.stdout.write("Reviewed-source fallback has no signed compressed-byte manifest; the local installer can provide an on-disk preview only.\n");
|
||||
return 0;
|
||||
}
|
||||
if (!parsed.yes) throw bootstrapError("Installation requires explicit --yes after review", "BOOTSTRAP_CONSENT_REQUIRED");
|
||||
io.stderr.write("Developer-only source install: only continue with a checkout you reviewed and trust.\n");
|
||||
return await installFromSource(parsed.source, parsed, { ...options, ...io, prepared: false });
|
||||
}
|
||||
|
||||
const fetch_ = options.fetch ?? globalThis.fetch;
|
||||
if (typeof fetch_ !== "function") throw bootstrapError("Node 18+ with fetch is required", "BOOTSTRAP_NODE_UNSUPPORTED");
|
||||
const target = platformTarget(
|
||||
platform,
|
||||
options.arch ?? process.arch,
|
||||
options.libc ?? detectLinuxLibc(platform),
|
||||
);
|
||||
const manifestUrl = options.manifestUrl ?? OFFICIAL_MANIFEST_URL;
|
||||
assertOfficialUrl(manifestUrl, { manifest: true });
|
||||
const manifest = await fetchJson(fetch_, manifestUrl);
|
||||
validateManifest(manifest, target);
|
||||
const home = path.resolve(parsed.home ?? process.env.GSTACK_HOME ?? path.join(os.homedir(), ".gstack"));
|
||||
const reusable = await inspectReusableRuntime(home, manifest.version).catch(() => null);
|
||||
const plan = buildComponentPlan(manifest, target, parsed.capabilities, reusable);
|
||||
if (parsed.json) io.stdout.write(`${JSON.stringify({ ok: true, action: parsed.action, ...plan }, null, 2)}\n`);
|
||||
else printComponentPlan(io.stdout, plan);
|
||||
if (parsed.action === "preview") return 0;
|
||||
if (!parsed.yes) throw bootstrapError("Installation requires explicit --yes after reviewing this exact component plan", "BOOTSTRAP_CONSENT_REQUIRED");
|
||||
const temporary = await fs.mkdtemp(path.join(options.tmpDir ?? os.tmpdir(), "gstack-bootstrap-"));
|
||||
try {
|
||||
const root = path.join(temporary, "merged", "gstack");
|
||||
await fs.mkdir(root, { recursive: true, mode: 0o700 });
|
||||
const claimedFiles = new Set();
|
||||
if (reusable) await seedReusableRuntime(reusable.root, root, claimedFiles);
|
||||
for (const item of plan.downloads) {
|
||||
const archive = path.join(temporary, `${item.component}.tar.gz`);
|
||||
await downloadVerified(fetch_, item.artifact.url, archive, item.artifact.sha256, item.artifact.bytes);
|
||||
io.stdout.write(`Verified SHA-256 for ${item.component} (${target}).\n`);
|
||||
await verifyCosignWhenAvailable(archive, item.artifact, path.join(temporary, item.component), { ...options, fetch: fetch_, ...io });
|
||||
const extracted = path.join(temporary, "extracted", item.component);
|
||||
await fs.mkdir(extracted, { recursive: true, mode: 0o700 });
|
||||
await extractTarSafely(archive, extracted, options);
|
||||
const componentRoot = safeArtifactRoot(extracted, item.artifact.root ?? "gstack");
|
||||
await assertNoLinks(componentRoot);
|
||||
await mergeComponentRoot(componentRoot, root, claimedFiles, item.component);
|
||||
}
|
||||
return await installFromSource(root, parsed, { ...options, ...io, prepared: true, version: manifest.version });
|
||||
} finally {
|
||||
await fs.rm(temporary, { recursive: true, force: true });
|
||||
}
|
||||
} catch (error) {
|
||||
io.stderr.write(`gstack bootstrap: ${error?.message ?? error}\n`);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const result = { action: null, capabilities: [], source: null, home: null, yes: false, json: false, help: false };
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (["-h", "--help"].includes(arg)) result.help = true;
|
||||
else if (arg === "--yes") result.yes = true;
|
||||
else if (arg === "--json") result.json = true;
|
||||
else if (!result.action && !arg.startsWith("-")) result.action = arg;
|
||||
else if (["--capability", "--source", "--home"].includes(arg)) {
|
||||
const value = argv[++index];
|
||||
if (!value || value.startsWith("--")) throw bootstrapError(`${arg} requires a value`, "BOOTSTRAP_USAGE");
|
||||
if (arg === "--capability") result.capabilities.push(value);
|
||||
else if (arg === "--source") result.source = value;
|
||||
else result.home = value;
|
||||
} else throw bootstrapError(`Unknown option: ${arg}`, "BOOTSTRAP_USAGE");
|
||||
}
|
||||
if (result.help) return result;
|
||||
if (result.action === "preview" && result.yes) throw bootstrapError("preview cannot be combined with --yes", "BOOTSTRAP_USAGE");
|
||||
if (!result.capabilities.length) throw bootstrapError("At least one --capability is required", "BOOTSTRAP_USAGE");
|
||||
result.capabilities = [...new Set(result.capabilities)].sort();
|
||||
for (const capability of result.capabilities) {
|
||||
if (!CAPABILITIES.has(capability)) throw bootstrapError(`Unknown capability: ${capability}`, "BOOTSTRAP_USAGE");
|
||||
}
|
||||
const expanded = new Set(result.capabilities);
|
||||
const pending = [...expanded];
|
||||
while (pending.length) {
|
||||
for (const dependency of CAPABILITY_DEPENDENCIES[pending.pop()] ?? []) {
|
||||
if (!expanded.has(dependency)) {
|
||||
expanded.add(dependency);
|
||||
pending.push(dependency);
|
||||
}
|
||||
}
|
||||
}
|
||||
result.capabilities = [...expanded].sort();
|
||||
return result;
|
||||
}
|
||||
|
||||
function validateManifest(manifest, target) {
|
||||
if (manifest?.schemaVersion !== BOOTSTRAP_SCHEMA_VERSION || manifest?.version !== BOOTSTRAP_RUNTIME_VERSION ||
|
||||
manifest?.skillApi !== "2.0" || typeof manifest?.targets !== "object" ||
|
||||
!sameGraph(manifest.capabilityComponents, CAPABILITY_COMPONENTS) ||
|
||||
!sameGraph(manifest.componentDependencies, COMPONENT_DEPENDENCIES)) {
|
||||
throw bootstrapError("Official runtime manifest is incompatible", "BOOTSTRAP_MANIFEST_INVALID");
|
||||
}
|
||||
const targetRecord = manifest.targets[target];
|
||||
const expected = Object.keys(COMPONENT_DEPENDENCIES)
|
||||
.filter((component) => component !== "ios" || target.startsWith("darwin-"))
|
||||
.sort();
|
||||
if (!targetRecord || typeof targetRecord.components !== "object" ||
|
||||
JSON.stringify(Object.keys(targetRecord.components).sort()) !== JSON.stringify(expected)) {
|
||||
throw bootstrapError(`No valid official runtime artifact for ${target}`, "BOOTSTRAP_ARTIFACT_UNAVAILABLE");
|
||||
}
|
||||
for (const [component, artifact] of Object.entries(targetRecord.components)) {
|
||||
if (!artifact || artifact.format !== "tar.gz" || !/^[a-f0-9]{64}$/.test(artifact.sha256) ||
|
||||
!Number.isSafeInteger(artifact.bytes) || artifact.bytes < 1 || artifact.bytes > 2 * 1024 * 1024 * 1024) {
|
||||
throw bootstrapError(`Invalid ${component} artifact for ${target}`, "BOOTSTRAP_ARTIFACT_UNAVAILABLE");
|
||||
}
|
||||
assertOfficialReleaseAssetUrl(artifact.url);
|
||||
if (artifact.cosignBundleUrl) {
|
||||
assertOfficialReleaseAssetUrl(artifact.cosignBundleUrl);
|
||||
if (artifact.certificateIdentity !== OFFICIAL_CERTIFICATE_IDENTITY ||
|
||||
artifact.certificateOidcIssuer !== GITHUB_OIDC_ISSUER) {
|
||||
throw bootstrapError("Cosign metadata does not bind the official GStack release workflow", "BOOTSTRAP_MANIFEST_INVALID");
|
||||
}
|
||||
} else if (artifact.certificateIdentity || artifact.certificateOidcIssuer) {
|
||||
throw bootstrapError("Cosign certificate metadata requires a bundle URL", "BOOTSTRAP_MANIFEST_INVALID");
|
||||
}
|
||||
}
|
||||
return targetRecord;
|
||||
}
|
||||
|
||||
function sameGraph(actual, expected) {
|
||||
if (!actual || typeof actual !== "object" || Array.isArray(actual)) return false;
|
||||
const normalize = (graph) => Object.fromEntries(Object.entries(graph)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, values]) => [key, Array.isArray(values) ? [...values].sort() : values]));
|
||||
return JSON.stringify(normalize(actual)) === JSON.stringify(normalize(expected));
|
||||
}
|
||||
|
||||
async function fetchJson(fetch_, url) {
|
||||
const response = await fetch_(url, { headers: { Accept: "application/json" }, redirect: "follow" });
|
||||
assertFinalDownloadUrl(response.url || url);
|
||||
if (!response.ok) throw bootstrapError(`Download failed with HTTP ${response.status}`, "BOOTSTRAP_DOWNLOAD_FAILED");
|
||||
const value = await response.json();
|
||||
if (!value || typeof value !== "object") throw bootstrapError("Manifest returned invalid JSON", "BOOTSTRAP_MANIFEST_INVALID");
|
||||
return value;
|
||||
}
|
||||
|
||||
async function downloadVerified(fetch_, url, destination, expectedSha256, expectedBytes) {
|
||||
const response = await fetch_(url, { redirect: "follow" });
|
||||
assertFinalDownloadUrl(response.url || url);
|
||||
if (!response.ok) throw bootstrapError(`Artifact download failed with HTTP ${response.status}`, "BOOTSTRAP_DOWNLOAD_FAILED");
|
||||
const hash = createHash("sha256");
|
||||
const file = await fs.open(destination, "wx", 0o600);
|
||||
let total = 0;
|
||||
try {
|
||||
if (response.body?.getReader) {
|
||||
const reader = response.body.getReader();
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
total += value.byteLength;
|
||||
if (total > 2 * 1024 * 1024 * 1024) throw bootstrapError("Runtime artifact exceeds the 2 GiB safety limit", "BOOTSTRAP_DOWNLOAD_FAILED");
|
||||
hash.update(value);
|
||||
await file.write(value);
|
||||
}
|
||||
} else {
|
||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||
total = bytes.byteLength;
|
||||
hash.update(bytes);
|
||||
await file.write(bytes);
|
||||
}
|
||||
} catch (error) {
|
||||
await file.close();
|
||||
await fs.rm(destination, { force: true });
|
||||
throw error;
|
||||
}
|
||||
await file.close();
|
||||
if (total !== expectedBytes) {
|
||||
await fs.rm(destination, { force: true });
|
||||
throw bootstrapError(`Runtime artifact size mismatch (expected ${expectedBytes}, received ${total})`, "BOOTSTRAP_INTEGRITY_FAILED");
|
||||
}
|
||||
const actual = hash.digest("hex");
|
||||
if (actual !== expectedSha256) {
|
||||
await fs.rm(destination, { force: true });
|
||||
throw bootstrapError("Runtime artifact SHA-256 mismatch", "BOOTSTRAP_INTEGRITY_FAILED");
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyCosignWhenAvailable(archive, artifact, temporary, options) {
|
||||
if (!artifact.cosignBundleUrl) {
|
||||
options.stdout.write("No Cosign bundle declared; continuing with verified release-manifest SHA-256.\n");
|
||||
return;
|
||||
}
|
||||
const available = await run(options.cosignCommand ?? "cosign", ["version"], { capture: true }).then(() => true, () => false);
|
||||
if (!available) {
|
||||
options.stdout.write("Cosign metadata is available but Cosign is not installed; SHA-256 verification succeeded.\n");
|
||||
return;
|
||||
}
|
||||
const bundle = path.join(temporary, "cosign.bundle");
|
||||
const response = await options.fetch(artifact.cosignBundleUrl, { redirect: "follow" });
|
||||
assertFinalDownloadUrl(response.url || artifact.cosignBundleUrl);
|
||||
if (!response.ok) throw bootstrapError("Cosign bundle download failed", "BOOTSTRAP_ATTESTATION_FAILED");
|
||||
await fs.writeFile(bundle, new Uint8Array(await response.arrayBuffer()), { mode: 0o600, flag: "wx" });
|
||||
const args = ["verify-blob", "--bundle", bundle];
|
||||
if (artifact.certificateIdentity) args.push("--certificate-identity", artifact.certificateIdentity);
|
||||
if (artifact.certificateOidcIssuer) args.push("--certificate-oidc-issuer", artifact.certificateOidcIssuer);
|
||||
args.push(archive);
|
||||
await run(options.cosignCommand ?? "cosign", args);
|
||||
options.stdout.write("Verified Cosign release attestation.\n");
|
||||
}
|
||||
|
||||
async function extractTarSafely(archive, destination, options) {
|
||||
const tar = options.tarCommand ?? "tar";
|
||||
const listing = await run(tar, ["-tzf", archive], { capture: true });
|
||||
for (const name of listing.stdout.split(/\r?\n/).filter(Boolean)) {
|
||||
const normalized = name.replaceAll("\\", "/");
|
||||
if (normalized.includes("\0") || normalized.startsWith("/") || /^[A-Za-z]:\//.test(normalized) ||
|
||||
normalized.split("/").includes("..")) {
|
||||
throw bootstrapError("Runtime archive contains an unsafe path", "BOOTSTRAP_ARCHIVE_UNSAFE");
|
||||
}
|
||||
}
|
||||
const verbose = await run(tar, ["-tvzf", archive], { capture: true });
|
||||
for (const line of verbose.stdout.split(/\r?\n/).filter(Boolean)) {
|
||||
if (!/^[-d]/.test(line)) {
|
||||
throw bootstrapError("Runtime archive contains a link or special-file entry", "BOOTSTRAP_ARCHIVE_UNSAFE");
|
||||
}
|
||||
}
|
||||
await run(tar, ["-xzf", archive, "-C", destination]);
|
||||
}
|
||||
|
||||
async function installFromSource(source, parsed, options) {
|
||||
const physical = await fs.realpath(path.resolve(source));
|
||||
const installer = path.join(physical, "runtime", "install.js");
|
||||
const stat = await fs.lstat(installer).catch(() => null);
|
||||
if (!stat?.isFile() || stat.isSymbolicLink()) throw bootstrapError("Source does not contain a safe runtime installer", "BOOTSTRAP_SOURCE_INVALID");
|
||||
const args = [installer, "--source", physical, "--install-now", "--yes", "--capabilities", parsed.capabilities.join(",")];
|
||||
if (parsed.home) args.push("--home", path.resolve(parsed.home));
|
||||
if (options.version) args.push("--version", options.version);
|
||||
if (options.prepared) args.push("--prepared");
|
||||
await run(options.nodeCommand ?? process.execPath, args);
|
||||
options.stdout.write(`Installed optional capabilities: ${parsed.capabilities.join(", ")}. No coding host was enrolled.\n`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
function platformTarget(platform, arch, libc) {
|
||||
if (!["darwin", "linux", "win32"].includes(platform) || !["arm64", "x64"].includes(arch)) {
|
||||
throw bootstrapError(`Unsupported platform: ${platform}-${arch}`, "BOOTSTRAP_PLATFORM_UNSUPPORTED");
|
||||
}
|
||||
if (platform === "linux" && libc !== "glibc") {
|
||||
throw bootstrapError(
|
||||
"Official GStack runtime artifacts currently require glibc Linux; pure Agent Skills remain portable and the reviewed-source fallback may be used explicitly",
|
||||
"BOOTSTRAP_PLATFORM_UNSUPPORTED",
|
||||
);
|
||||
}
|
||||
return `${platform === "win32" ? "windows" : platform}-${arch}`;
|
||||
}
|
||||
|
||||
function detectLinuxLibc(platform) {
|
||||
if (platform !== "linux") return null;
|
||||
const report = typeof process.report?.getReport === "function" ? process.report.getReport() : null;
|
||||
return report?.header?.glibcVersionRuntime ? "glibc" : "musl";
|
||||
}
|
||||
|
||||
function safeArtifactRoot(extracted, relative) {
|
||||
if (typeof relative !== "string" || !relative || path.isAbsolute(relative) || relative.split(/[\\/]/).includes("..")) {
|
||||
throw bootstrapError("Manifest contains an unsafe artifact root", "BOOTSTRAP_MANIFEST_INVALID");
|
||||
}
|
||||
const target = path.resolve(extracted, relative);
|
||||
if (path.relative(extracted, target).startsWith(`..${path.sep}`)) throw bootstrapError("Artifact root escaped extraction", "BOOTSTRAP_ARCHIVE_UNSAFE");
|
||||
return target;
|
||||
}
|
||||
|
||||
async function assertNoLinks(root) {
|
||||
const pending = [root];
|
||||
while (pending.length) {
|
||||
const target = pending.pop();
|
||||
const stat = await fs.lstat(target);
|
||||
if (stat.isSymbolicLink()) throw bootstrapError("Runtime archive contains a symbolic link", "BOOTSTRAP_ARCHIVE_UNSAFE");
|
||||
if (stat.isDirectory()) for (const child of await fs.readdir(target)) pending.push(path.join(target, child));
|
||||
}
|
||||
}
|
||||
|
||||
function assertOfficialUrl(value, options = {}) {
|
||||
const url = new URL(value);
|
||||
if (url.protocol !== "https:" || url.username || url.password || !ALLOWED_DOWNLOAD_HOSTS.has(url.hostname)) {
|
||||
throw bootstrapError("Bootstrap downloads are restricted to official GitHub release hosts", "BOOTSTRAP_URL_BLOCKED");
|
||||
}
|
||||
if (options.manifest && url.hostname !== "github.com") throw bootstrapError("Manifest must come from the official GitHub release", "BOOTSTRAP_URL_BLOCKED");
|
||||
}
|
||||
|
||||
function assertOfficialReleaseAssetUrl(value) {
|
||||
assertOfficialUrl(value);
|
||||
const url = new URL(value);
|
||||
if (url.hostname !== "github.com" || !url.pathname.startsWith(OFFICIAL_RELEASE_PREFIX)) {
|
||||
throw bootstrapError("Runtime assets must come from the official versioned GStack release", "BOOTSTRAP_URL_BLOCKED");
|
||||
}
|
||||
}
|
||||
|
||||
function assertFinalDownloadUrl(value) {
|
||||
assertOfficialUrl(value);
|
||||
}
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, { shell: false, windowsHide: true, stdio: options.capture ? ["ignore", "pipe", "pipe"] : "inherit" });
|
||||
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("close", (code) => code === 0 ? resolve({ stdout, stderr }) : reject(bootstrapError(`${command} failed (${code})`, "BOOTSTRAP_COMMAND_FAILED")));
|
||||
});
|
||||
}
|
||||
|
||||
function bootstrapError(message, code) {
|
||||
const error = new Error(message);
|
||||
error.code = code;
|
||||
return error;
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
const units = ["B", "KiB", "MiB", "GiB"];
|
||||
let value = bytes;
|
||||
let index = 0;
|
||||
while (value >= 1024 && index < units.length - 1) {
|
||||
value /= 1024;
|
||||
index += 1;
|
||||
}
|
||||
return `${value.toFixed(index === 0 ? 0 : value >= 10 ? 1 : 2)} ${units[index]} (${bytes} bytes)`;
|
||||
}
|
||||
|
||||
function usage() {
|
||||
return "Usage: node runtime-bootstrap.mjs install --capability <name> [--capability <name>...]\n" +
|
||||
" node runtime-bootstrap.mjs install --source <reviewed-checkout> --capability <name>\n\n" +
|
||||
"Downloads only a versioned official GStack runtime release and never enrolls a coding host.\n" +
|
||||
"--source is a developer-only fallback for a checkout you have reviewed and trust.\n";
|
||||
}
|
||||
|
||||
async function isDirectExecution() {
|
||||
if (!process.argv[1]) return false;
|
||||
const [modulePath, invokedPath] = await Promise.all([
|
||||
fs.realpath(fileURLToPath(import.meta.url)),
|
||||
fs.realpath(path.resolve(process.argv[1])).catch(() => path.resolve(process.argv[1])),
|
||||
]);
|
||||
return modulePath === invokedPath;
|
||||
}
|
||||
|
||||
if (await isDirectExecution()) {
|
||||
process.exitCode = await main();
|
||||
}
|
||||
+9
-2
@@ -193,9 +193,16 @@ export async function acquireLock(lockPath, options = {}) {
|
||||
async function reapStaleLock(lockPath, staleMs, platform = process.platform) {
|
||||
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 sameHostPid = owner?.hostname === os.hostname() && Number.isInteger(owner?.pid) && owner.pid > 0;
|
||||
if (sameHostPid) {
|
||||
// A dead same-host PID is conclusive enough to recover immediately.
|
||||
// A live/reused PID remains protected; remote or malformed ownership
|
||||
// falls back to the heartbeat age lease below.
|
||||
if (processIsAlive(owner.pid)) return false;
|
||||
} else if (Date.now() - stat.mtimeMs <= staleMs) {
|
||||
return false;
|
||||
}
|
||||
const staleName = `${lockPath}.stale-${process.pid}-${randomUUID()}`;
|
||||
await renameWithRetry(lockPath, staleName, { platform });
|
||||
await fs.rm(staleName, { recursive: true, force: true });
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
export function bashCandidates(env = process.env, platform = process.platform) {
|
||||
const values = [env.GSTACK_BASH];
|
||||
if (platform === "win32") {
|
||||
for (const root of [
|
||||
env.ProgramFiles,
|
||||
env["ProgramFiles(x86)"],
|
||||
env.LOCALAPPDATA && path.join(env.LOCALAPPDATA, "Programs"),
|
||||
]) {
|
||||
if (!root) continue;
|
||||
values.push(
|
||||
path.join(root, "Git", "bin", "bash.exe"),
|
||||
path.join(root, "Git", "usr", "bin", "bash.exe"),
|
||||
);
|
||||
}
|
||||
}
|
||||
values.push("bash");
|
||||
return values.filter((entry, index, list) => entry && list.indexOf(entry) === index);
|
||||
}
|
||||
|
||||
export async function resolveBashCommand(env = process.env, platform = process.platform) {
|
||||
for (const candidate of bashCandidates(env, platform)) {
|
||||
if (!path.isAbsolute(candidate)) return candidate;
|
||||
const stat = await fs.lstat(candidate).catch(() => null);
|
||||
if (stat?.isFile() && !stat.isSymbolicLink()) return candidate;
|
||||
}
|
||||
return "bash";
|
||||
}
|
||||
+28
-4
@@ -6,6 +6,7 @@ import { atomicWriteJson, pathExists, readJson, renameWithRetry } from "./storag
|
||||
import {
|
||||
assertManagedHome,
|
||||
ensureManagedHome,
|
||||
ensureManagedRuntimeDirectory,
|
||||
recoverRuntimeTransactionUnlocked,
|
||||
withRuntimeLifecycleLock,
|
||||
} from "./managed-home.js";
|
||||
@@ -17,7 +18,10 @@ export async function stageUpgrade(options) {
|
||||
return withRuntimeLifecycleLock(home, async () => {
|
||||
await ensureManagedHome(home, options);
|
||||
await recoverRuntimeTransactionUnlocked(home);
|
||||
return stageUpgradeUnlocked({ ...options, home });
|
||||
// Consuming input is reserved for the installer's validated scratch.
|
||||
// Public upgrades always preserve and copy caller-owned sources.
|
||||
const { consumeInstallerScratch: _ignored, ...publicOptions } = options;
|
||||
return stageUpgradeUnlocked({ ...publicOptions, home });
|
||||
}, options);
|
||||
}
|
||||
|
||||
@@ -32,7 +36,12 @@ export async function stageUpgradeUnlocked(options) {
|
||||
const paths = resolveRuntimePaths({ home });
|
||||
const source = path.resolve(sourceDir);
|
||||
await validateStageSource(source);
|
||||
await fs.mkdir(paths.versions, { recursive: true, mode: 0o700 });
|
||||
const consumeSource = options.consumeInstallerScratch === true;
|
||||
if (consumeSource) {
|
||||
await ensureManagedRuntimeDirectory(home, paths.tmp);
|
||||
await assertInstallerScratchSource(source, paths.tmp);
|
||||
}
|
||||
await ensureManagedRuntimeDirectory(home, paths.versions);
|
||||
|
||||
await recoverPendingUpgradeUnlocked(paths, options);
|
||||
const previousExists = await pathExists(paths.versionPointer);
|
||||
@@ -47,7 +56,8 @@ export async function stageUpgradeUnlocked(options) {
|
||||
path.join(paths.versions, `.stage-${version}-${randomUUID()}`),
|
||||
);
|
||||
try {
|
||||
await copyDirectory(source, stage);
|
||||
if (consumeSource) await renameWithRetry(source, stage);
|
||||
else await copyDirectory(source, stage);
|
||||
await atomicWriteJson(path.join(stage, ".gstack-version.json"), {
|
||||
schemaVersion: 2,
|
||||
version,
|
||||
@@ -102,7 +112,7 @@ export async function stageUpgradeUnlocked(options) {
|
||||
staged,
|
||||
paths,
|
||||
});
|
||||
return { pointer: active, path: destination, staged };
|
||||
return { pointer: active, path: destination, staged, consumedSource: staged && consumeSource };
|
||||
} catch (cause) {
|
||||
const rollbackErrors = [];
|
||||
let pointerRollbackError = null;
|
||||
@@ -320,6 +330,20 @@ async function copyDirectory(source, destination) {
|
||||
});
|
||||
}
|
||||
|
||||
async function assertInstallerScratchSource(source, tmpRoot) {
|
||||
const [physicalSource, physicalTmp] = await Promise.all([
|
||||
fs.realpath(source),
|
||||
fs.realpath(tmpRoot),
|
||||
]);
|
||||
if (path.dirname(physicalSource) !== physicalTmp || !path.basename(physicalSource).startsWith("install-")) {
|
||||
throw upgradeError("Only a direct installer-owned scratch may be consumed", "UPGRADE_SOURCE_CONSUME_INVALID");
|
||||
}
|
||||
const stat = await fs.lstat(physicalSource);
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
||||
throw upgradeError("Installer scratch must be a real directory", "UPGRADE_SOURCE_CONSUME_INVALID");
|
||||
}
|
||||
}
|
||||
|
||||
async function assertTreeContainsNoLinks(root) {
|
||||
const pending = [root];
|
||||
while (pending.length > 0) {
|
||||
|
||||
Reference in New Issue
Block a user