← Files ModRetro Chromatic PluginARCHIVED FILE
scripts/setup-components.mjs
28.1 KB · Oct 3, 2026 · 06:38 UTC
/** Explicit, individually selected installers. The caller owns consent, locking and promotion. */
import { createHash, randomUUID } from "node:crypto";
import { lstat, readFile, readdir, realpath, rename, rmdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { DEPENDENCY_CATALOG, DESKTOP_RELEASES, GBDK_RELEASES, SOURCE_RELEASES, UV_RELEASES } from "./dependency-catalog.mjs";
import { patchWindowsCliConstants } from "./setup-compiler.mjs";
import {
SetupIoError, assertOwnedPath, checkSetupAbort, copySetupTree, downloadArchive,
ensureOwnedDirectory, extractArchive, makeSetupExecutable, runSetupCommand,
setupEnvironment, setupSha256File, writeSetupJson,
} from "./setup-io.mjs";
const hash = (bytes) => createHash("sha256").update(bytes).digest("hex");
const output = (result) => String(result?.stdout ?? "").trim();
const componentIds = new Set(["gbdk", "cli", "uv", "emulator", "desktop"]);
const BUILD_METADATA_PATH = "src/apps/shared/webpack.plugins.js";
async function present(filename) { try { return await lstat(filename); } catch (error) { if (error.code === "ENOENT") return undefined; throw error; } }
async function regular(context, filename, maximumBytes = 512 * 1024 * 1024) {
await assertOwnedPath(context.setupRoot, filename);
const entry = await lstat(filename);
if (!entry.isFile() || entry.size > maximumBytes) throw new SetupIoError("INVALID_COMPONENT", "A prepared dependency is missing a bounded regular file.", { path: filename });
return filename;
}
async function ownedText(context, filename, maximumBytes = 1024 * 1024) { await regular(context, filename, maximumBytes); return readFile(filename, "utf8"); }
async function emptyPlaceholder(context, filename) {
const entry = await present(filename);
if (!entry) return;
await assertOwnedPath(context.setupRoot, filename);
if (!entry.isDirectory() || (await readdir(filename)).length) throw new SetupIoError("INVALID_COMPONENT", "Pinned source has a nonempty dependency placeholder; it was not overwritten.", { path: filename });
await rmdir(filename);
}
function releaseEvidence(downloaded, release) {
return { source: release.url, sha256: release.sha256, archive: release.archive, bytes: downloaded.bytes, cacheReused: downloaded.reused === true, quarantined: downloaded.quarantined ?? [] };
}
async function command(context, executable, args, { label, cwd = context.stageRoot, env = context.env, timeoutMs } = {}) {
checkSetupAbort(context.signal);
return context.io.runSetupCommand(executable, args, { cwd, env, platform: context.platform, signal: context.signal, onProgress: context.onProgress, label, timeoutMs });
}
async function fetched(context, release, label) {
const downloaded = await context.io.downloadArchive(release, context);
const destination = path.join(context.stageRoot, label);
await context.io.extractArchive(downloaded.path, destination, context, { format: release.archive.endsWith(".zip") ? "zip" : "tar.gz" });
return { root: destination, downloaded, provenance: releaseEvidence(downloaded, release) };
}
async function soleDirectory(context, directory, expectedName) {
const entries = await readdir(directory, { withFileTypes: true });
if (entries.length !== 1 || !entries[0].isDirectory() || entries[0].isSymbolicLink() || (expectedName && entries[0].name !== expectedName)) throw new SetupIoError("INVALID_COMPONENT", "Pinned source archive does not contain its expected single directory.", { path: directory, expected: expectedName });
const selected = path.join(directory, entries[0].name);
await assertOwnedPath(context.setupRoot, selected);
return selected;
}
async function gbdk(context) {
const release = GBDK_RELEASES[context.nativePlatform];
const archive = await fetched(context, release, "gbdk-release");
const preparedPath = await soleDirectory(context, archive.root, "gbdk");
const executable = await regular(context, path.join(preparedPath, "bin", context.platform === "win32" ? "lcc.exe" : "lcc"));
const header = path.join(preparedPath, "include", "gbdk", "version.h");
if (!/^\s*#\s*define\s+__GBDK_VERSION\s+450(?:\s|$)/mu.test(await ownedText(context, header))) throw new SetupIoError("INCOMPATIBLE_COMPONENT", "The authenticated GBDK release does not identify version 4.5.0.");
if (context.platform !== "win32") await makeSetupExecutable(context.setupRoot, executable);
// GBDK 4.5 lcc expands its install prefix through a 128-byte token buffer.
// Probe from the compiler root with a trusted short prefix; keep the absolute
// executable and its bytes unchanged, without changing any project build path.
await command(context, executable, ["-v"], { label: "Verify the selected GBDK compiler", cwd: preparedPath, env: { ...context.env, GBDKDIR: "./" } });
return { preparedPath, version: release.version, provenance: { type: "official-release", ...archive.provenance, verification: ["SHA-256", "GBDK version header", "lcc -v"] }, files: [{ path: "bin/" + path.basename(executable), ...await context.io.setupSha256File(executable) }, { path: "include/gbdk/version.h", ...await context.io.setupSha256File(header) }] };
}
async function uv(context) {
const release = UV_RELEASES[context.nativePlatform];
const archive = await fetched(context, release, "uv-release");
const filename = context.platform === "win32" ? "uv.exe" : "uv";
let preparedPath = archive.root;
if (!(await present(path.join(preparedPath, filename)))) preparedPath = await soleDirectory(context, archive.root);
const executable = await regular(context, path.join(preparedPath, filename));
if (context.platform !== "win32") await makeSetupExecutable(context.setupRoot, executable);
const versionOutput = output(await command(context, executable, ["--version"], { label: "Verify the selected uv installer" }));
if (!new RegExp("^uv " + release.version.replaceAll(".", "\\.") + "(?:\\s|$)", "u").test(versionOutput)) throw new SetupIoError("INCOMPATIBLE_COMPONENT", "The uv installer did not report its pinned version.", { expected: release.version, detected: versionOutput });
return { preparedPath, version: release.version, provenance: { type: "official-release", ...archive.provenance, verification: ["SHA-256", "uv --version"] }, files: [{ path: filename, ...await context.io.setupSha256File(executable) }] };
}
/** Source archives have no .git; replace only the two build-metadata lookups. */
export function patchArchiveBuildMetadata(source) {
const importLine = 'const { GitRevisionPlugin } = require("git-revision-webpack-plugin");\n';
const creation = 'const gitRevisionPlugin = new GitRevisionPlugin({\n commithashCommand: "rev-list --max-count=1 --no-merges --abbrev-commit HEAD",\n});\n';
const versionCall = "gitRevisionPlugin.version()";
const commitCall = "gitRevisionPlugin.commithash()";
for (const token of [importLine, creation, versionCall, commitCall]) if (source.split(token).length !== 2) throw new SetupIoError("SOURCE_PATCH_MISMATCH", "Pinned GB Studio build metadata differs from the reviewed source-archive adaptation.");
const updated = source.replace(importLine, "").replace(creation, "").replace(versionCall, JSON.stringify("v" + DEPENDENCY_CATALOG.cli.installVersion)).replace(commitCall, JSON.stringify(DEPENDENCY_CATALOG.cli.commit.slice(0, 7)));
if (/GitRevisionPlugin|gitRevisionPlugin/u.test(updated)) throw new SetupIoError("SOURCE_PATCH_MISMATCH", "Unexpected Git revision plugin use remains after the bounded metadata adaptation.");
return { source: updated, receipt: { path: BUILD_METADATA_PATH, purpose: "Use pinned release/commit build metadata without a Git checkout; no engine change", beforeSha256: hash(source), afterSha256: hash(updated), version: "v" + DEPENDENCY_CATALOG.cli.installVersion, abbreviatedCommit: DEPENDENCY_CATALOG.cli.commit.slice(0, 7) } };
}
async function cli(context) {
const source = await fetched(context, SOURCE_RELEASES.cli, "cli-source");
const preparedPath = await soleDirectory(context, source.root, "gb-studio-" + DEPENDENCY_CATALOG.cli.commit);
const sourcePackage = JSON.parse(await ownedText(context, path.join(preparedPath, "package.json")));
if (sourcePackage.version !== DEPENDENCY_CATALOG.cli.installVersion || sourcePackage.packageManager !== "yarn@" + SOURCE_RELEASES.yarn.version + "+sha256." + SOURCE_RELEASES.yarn.executableSha256) throw new SetupIoError("SOURCE_PIN_MISMATCH", "Pinned GB Studio source version or package-manager integrity differs from the setup catalog.");
const gbvmSource = await fetched(context, SOURCE_RELEASES.gbvm, "gbvm-source");
const gbvmRoot = await soleDirectory(context, gbvmSource.root, "gbvm-" + DEPENDENCY_CATALOG.cli.gbvmCommit);
const engineDestination = path.join(preparedPath, "appData", "engine", "gbvm");
await emptyPlaceholder(context, engineDestination);
await context.io.copySetupTree(gbvmRoot, engineDestination, context);
const compilerRoot = path.join(context.toolchainRoot, ".local", "gbdk");
await assertOwnedPath(context.setupRoot, compilerRoot);
if (!/^\s*#\s*define\s+__GBDK_VERSION\s+450(?:\s|$)/mu.test(await ownedText(context, path.join(compilerRoot, "include", "gbdk", "version.h")))) throw new SetupIoError("MISSING_PREREQUISITE", "Install the selected GBDK 4.5.0 component before building the official CLI.");
const copiedCompiler = path.join(preparedPath, "buildTools", context.nativePlatform, "gbdk");
await emptyPlaceholder(context, copiedCompiler);
await context.io.copySetupTree(compilerRoot, copiedCompiler, context);
const metadataFile = path.join(preparedPath, ...BUILD_METADATA_PATH.split("/"));
const patched = patchArchiveBuildMetadata(await ownedText(context, metadataFile));
// This patch shortens the source. Publish a complete replacement atomically;
// an r+ write would leave stale trailing bytes behind the intended patch.
const replacement = metadataFile + ".codex-setup-" + randomUUID();
await assertOwnedPath(context.setupRoot, replacement, { allowMissing: true });
await writeFile(replacement, patched.source, { flag: "wx", mode: 0o644 });
checkSetupAbort(context.signal);
await assertOwnedPath(context.setupRoot, metadataFile);
if ((await context.io.setupSha256File(metadataFile)).sha256 !== patched.receipt.beforeSha256) throw new SetupIoError("SOURCE_PATCH_MISMATCH", "The pinned metadata source changed before its atomic replacement.");
await rename(replacement, metadataFile);
if ((await context.io.setupSha256File(metadataFile)).sha256 !== patched.receipt.afterSha256) throw new SetupIoError("SOURCE_PATCH_MISMATCH", "The written metadata patch does not match its recorded bytes.");
const patches = [patched.receipt];
if (context.platform === "win32") {
const constantsPath = path.join(preparedPath, "src", "consts.ts");
const before = await context.io.setupSha256File(await regular(context, constantsPath));
await patchWindowsCliConstants(constantsPath, { platform: context.platform });
patches.push({ path: "src/consts.ts", purpose: "Normalize Windows separators in the pinned CLI source root lookup", beforeSha256: before.sha256, afterSha256: (await context.io.setupSha256File(constantsPath)).sha256 });
}
const yarnSource = await fetched(context, SOURCE_RELEASES.yarn, "yarn-source");
const yarnRoot = await soleDirectory(context, yarnSource.root, "package");
const yarnExecutable = await regular(context, path.join(yarnRoot, "bin", "yarn.js"));
if ((await context.io.setupSha256File(yarnExecutable)).sha256 !== SOURCE_RELEASES.yarn.executableSha256) throw new SetupIoError("CHECKSUM_MISMATCH", "Yarn executable differs from the upstream packageManager integrity pin.");
await command(context, context.nodeExecutable, [yarnExecutable, "install", "--immutable"], { cwd: preparedPath, label: "Install the pinned official CLI build dependencies" });
const electronDirectory = path.join(preparedPath, "node_modules", "electron");
await assertOwnedPath(context.setupRoot, electronDirectory);
const electronPath = path.join(electronDirectory, "path.txt");
if (!(await present(electronPath))) await writeFile(electronPath, "electron", { flag: "wx", mode: 0o644 });
else if ((await ownedText(context, electronPath, 1024)).trim() !== "electron") throw new SetupIoError("INVALID_COMPONENT", "Unexpected Electron path metadata in the new headless build tree.");
const webpack = await regular(context, path.join(preparedPath, "node_modules", "webpack", "bin", "webpack.js"));
await command(context, context.nodeExecutable, [webpack, "--config", "src/apps/gb-studio-cli/webpack.cli.config.js"], { cwd: preparedPath, env: { ...context.env, NO_TYPE_CHECKING: "1" }, label: "Compile the official GB Studio CLI" });
const executable = await regular(context, path.join(preparedPath, "out", "cli", "gb-studio-cli.js"));
const versionOutput = output(await command(context, context.nodeExecutable, [executable, "--version"], { label: "Verify the compiled official CLI" }));
if (![DEPENDENCY_CATALOG.cli.installVersion, "v" + DEPENDENCY_CATALOG.cli.installVersion].includes(versionOutput)) throw new SetupIoError("INCOMPATIBLE_COMPONENT", "The compiled official CLI did not report the pinned version.", { detected: versionOutput });
const provenance = { type: "official-source-archives", sourceCommit: DEPENDENCY_CATALOG.cli.commit, gbvmCommit: DEPENDENCY_CATALOG.cli.gbvmCommit, archives: [source.provenance, gbvmSource.provenance, yarnSource.provenance], buildMetadataPatches: patches, packageManager: sourcePackage.packageManager, dependencyLock: await context.io.setupSha256File(path.join(preparedPath, "yarn.lock")), gbdkVersion: DEPENDENCY_CATALOG.gbdk.installVersion, verification: ["Pinned archive SHA-256 values", "Yarn --immutable", "Compiled CLI --version"], desktopRequired: false };
await writeSetupJson(context.setupRoot, path.join(preparedPath, ".codex-setup-source.json"), provenance);
return { preparedPath, version: DEPENDENCY_CATALOG.cli.installVersion, provenance, files: [{ path: "out/cli/gb-studio-cli.js", ...await context.io.setupSha256File(executable) }] };
}
function inside(root, filename) { const relative = path.relative(root, filename); return relative === "" || (relative !== ".." && !relative.startsWith(".." + path.sep) && !path.isAbsolute(relative)); }
/** Venv entrypoints may link to a managed Python; retain the venv invocation path. */
async function ownedPython(context, filename) {
if (!path.isAbsolute(filename)) throw new SetupIoError("UNSAFE_PYTHON", "Python discovery did not return an absolute owned executable.");
await assertOwnedPath(context.setupRoot, path.dirname(filename));
const canonical = await realpath(filename);
if (!inside(path.resolve(context.setupRoot), canonical)) throw new SetupIoError("UNSAFE_PYTHON", "Python discovery escaped the owned setup root.", { path: filename });
await regular(context, canonical);
return filename;
}
/** uv-generated hashes are retained per install, not misrepresented as catalog-wide pins. */
export function parseResolvedRequirements(source) {
if (source.length > 512 * 1024) throw new SetupIoError("INVALID_REQUIREMENTS", "Resolved Python requirements exceed their limit.");
const logical = source.replace(/\\\r?\n/gu, " ").split(/\r?\n/u).map((line) => line.trim()).filter((line) => line && !line.startsWith("#"));
const seen = new Set();
const packages = logical.map((line) => {
const match = /^([A-Za-z0-9][A-Za-z0-9._-]*)==([A-Za-z0-9.+!-]+)((?:\s+--hash=sha256:[a-f0-9]{64})+)$/u.exec(line);
if (!match) throw new SetupIoError("INVALID_REQUIREMENTS", "uv resolution contains a non-pinned, non-hashed, or non-registry requirement.");
const name = match[1].toLowerCase().replace(/[_.]+/gu, "-");
if (seen.has(name)) throw new SetupIoError("INVALID_REQUIREMENTS", "Resolved Python requirements contain duplicate distributions.");
seen.add(name);
return { name, version: match[2], allowedDistributionSha256: [...match[3].matchAll(/--hash=sha256:([a-f0-9]{64})/gu)].map((found) => found[1]) };
});
if (!packages.length || packages.length > 128) throw new SetupIoError("INVALID_REQUIREMENTS", "Resolved Python requirement count is invalid.");
for (const [name, version] of [["pyboy", DEPENDENCY_CATALOG.pyboy.installVersion], ["pillow", DEPENDENCY_CATALOG.pillow.installVersion]]) if (!packages.some((entry) => entry.name === name && entry.version === version)) throw new SetupIoError("INCOMPATIBLE_COMPONENT", "Resolved Python requirements changed a selected dependency pin.", { name, version });
return packages;
}
async function emulator(context) {
if (!context.finalPath || !path.isAbsolute(context.finalPath)) throw new SetupIoError("MISSING_FINAL_PATH", "Python environments require a fresh final owned path; they cannot be promoted by renaming.");
await assertOwnedPath(context.setupRoot, context.finalPath, { allowMissing: true });
if (await present(context.finalPath)) throw new SetupIoError("DESTINATION_EXISTS", "The Python environment destination already exists. The setup owner must quarantine its known-owned failed attempt before retrying.", { path: context.finalPath });
const uvExecutable = await regular(context, path.join(context.setupRoot, "uv", context.nativePlatform, context.platform === "win32" ? "uv.exe" : "uv"));
const uvVersion = output(await command(context, uvExecutable, ["--version"], { label: "Verify the owned uv installer" }));
if (!uvVersion.startsWith("uv " + DEPENDENCY_CATALOG.uv.installVersion + " ") && uvVersion !== "uv " + DEPENDENCY_CATALOG.uv.installVersion) throw new SetupIoError("INCOMPATIBLE_COMPONENT", "Install the pinned uv component before the direct-play emulator.");
const common = ["--no-config"];
const managedInstall = ["python", "install", "--no-bin", "--no-registry", DEPENDENCY_CATALOG.python.installVersion];
await command(context, uvExecutable, [...common, ...managedInstall], { label: "Install the selected managed CPython" });
// setupEnvironment already sets UV_PYTHON_PREFERENCE=only-managed. uv rejects
// combining that explicit preference with its redundant --managed-python flag.
const discovered = output(await command(context, uvExecutable, [...common, "python", "find", "--no-project", "--no-python-downloads", DEPENDENCY_CATALOG.python.installVersion], { label: "Locate the selected managed CPython" }));
if (discovered.includes("\n") || !inside(path.join(context.setupRoot, "python"), discovered)) throw new SetupIoError("UNSAFE_PYTHON", "uv did not select Python from the explicitly owned managed installation.");
const managedPython = await ownedPython(context, discovered);
const pythonVersion = output(await command(context, managedPython, ["-I", "-c", "import platform; print(platform.python_version())"], { label: "Verify the selected CPython version" }));
if (pythonVersion !== DEPENDENCY_CATALOG.python.installVersion) throw new SetupIoError("INCOMPATIBLE_COMPONENT", "The managed Python does not report its selected version.", { expected: DEPENDENCY_CATALOG.python.installVersion, detected: pythonVersion });
const input = path.join(context.stageRoot, "emulator-requirements.in");
const resolved = path.join(context.stageRoot, "emulator-requirements.txt");
await writeFile(input, `pyboy==${DEPENDENCY_CATALOG.pyboy.installVersion}\npillow==${DEPENDENCY_CATALOG.pillow.installVersion}\n`, { flag: "wx", mode: 0o600 });
const registry = ["--default-index", "https://pypi.org/simple", "--only-binary", ":all:", "--no-sources", "--no-python-downloads"];
await command(context, uvExecutable, [...common, "pip", "compile", input, "--python", managedPython, "--generate-hashes", "--no-header", "--no-annotate", "--output-file", resolved, ...registry], { label: "Resolve and hash the selected emulator wheels" });
const requirements = await ownedText(context, resolved, 512 * 1024);
const packages = parseResolvedRequirements(requirements);
await ensureOwnedDirectory(context.setupRoot, path.dirname(context.finalPath));
await command(context, uvExecutable, [...common, "venv", "--no-project", "--no-python-downloads", "--python", managedPython, context.finalPath], { label: "Create the final-path direct-play environment" });
await assertOwnedPath(context.setupRoot, context.finalPath);
const pythonExecutable = await ownedPython(context, path.join(context.finalPath, context.platform === "win32" ? "Scripts" : "bin", context.platform === "win32" ? "python.exe" : "python"));
await command(context, uvExecutable, [...common, "pip", "install", "--python", pythonExecutable, "--require-hashes", "--requirements", resolved, ...registry], { label: "Install the verified emulator wheels" });
const probe = "import importlib.metadata as m,json,platform,sys; import pyboy; from PIL import Image; print(json.dumps({'python':platform.python_version(),'pyboy':m.version('pyboy'),'pillow':m.version('pillow'),'prefix':sys.prefix,'basePrefix':sys.base_prefix,'distributions':sorted([{'name':d.metadata['Name'],'version':d.version} for d in m.distributions()],key=lambda d:d['name'].lower())}))";
let detected;
try { detected = JSON.parse(output(await command(context, pythonExecutable, ["-I", "-c", probe], { label: "Verify direct-play Python imports" }))); }
catch (error) {
// In particular, do not hide unconfirmed process cleanup from the lock owner.
if (error instanceof SetupIoError) throw error;
throw new SetupIoError("COMPONENT_PROBE_FAILED", "The new direct-play environment did not return its Python/import readiness report.", { path: context.finalPath }, error);
}
if (detected.python !== DEPENDENCY_CATALOG.python.installVersion || detected.pyboy !== DEPENDENCY_CATALOG.pyboy.installVersion || detected.pillow !== DEPENDENCY_CATALOG.pillow.installVersion || path.resolve(detected.prefix ?? "") !== path.resolve(context.finalPath) || detected.prefix === detected.basePrefix || !Array.isArray(detected.distributions)) throw new SetupIoError("INCOMPATIBLE_COMPONENT", "The new Python environment does not match its selected versions or final path.", { detected, finalPath: context.finalPath });
const installed = new Map(detected.distributions.map((entry) => [String(entry.name).toLowerCase().replace(/[_.]+/gu, "-"), entry.version]));
if (installed.size !== packages.length || packages.some((entry) => installed.get(entry.name) !== entry.version)) throw new SetupIoError("INCOMPATIBLE_COMPONENT", "Installed emulator distributions disagree with the verified requirements lock.");
const retainedRequirements = path.join(context.finalPath, ".codex-setup-requirements.txt");
await writeFile(retainedRequirements, requirements, { flag: "wx", mode: 0o600 });
const provenance = { type: "uv-managed-python-and-pypi-wheels", uvVersion: DEPENDENCY_CATALOG.uv.installVersion, uv: await context.io.setupSha256File(uvExecutable), pythonVersion, managedPython, pythonExecutableSha256: (await context.io.setupSha256File(await realpath(managedPython))).sha256, registry: "https://pypi.org/simple", pyboyVersion: detected.pyboy, pillowVersion: detected.pillow, requirementsSha256: hash(requirements), resolvedPackages: packages, wheelPolicy: "Prebuilt wheels only; per-install resolved distribution hashes, not globally pre-pinned transitives or proof of one selected wheel archive", verification: ["Pinned managed Python version", "Hash-required wheel installation", "Python/PyBoy/Pillow imports and environment prefix"], nonRelocatable: true };
await writeSetupJson(context.setupRoot, path.join(context.finalPath, ".codex-setup-emulator.json"), provenance);
return { preparedPath: context.finalPath, promotion: "already-final", version: DEPENDENCY_CATALOG.pyboy.installVersion, provenance, files: [{ path: ".codex-setup-requirements.txt", sha256: hash(requirements), bytes: Buffer.byteLength(requirements) }] };
}
async function findApplication(context, directory, name, depth = 0) {
if (depth > 4) return undefined;
for (const entry of await readdir(directory, { withFileTypes: true })) {
const candidate = path.join(directory, entry.name);
await assertOwnedPath(context.setupRoot, candidate);
if (name(entry)) return candidate;
if (entry.isDirectory()) { const nested = await findApplication(context, candidate, name, depth + 1); if (nested) return nested; }
}
return undefined;
}
async function desktop(context) {
const release = DESKTOP_RELEASES[context.nativePlatform];
const downloaded = await context.io.downloadArchive(release, context);
const provenance = { type: "optional-official-desktop-release", ...releaseEvidence(downloaded, release), launched: false, operatingSystemRegistered: false };
if (context.platform === "linux") {
const preparedPath = path.join(context.stageRoot, release.archive);
await context.io.copySetupTree(downloaded.path, preparedPath, context);
await makeSetupExecutable(context.setupRoot, preparedPath);
return { preparedPath, version: release.version, provenance, status: "prepared-not-launched", nextSteps: ["Launch this optional AppImage explicitly when needed; system FUSE/desktop integration was not installed or tested."] };
}
const extracted = path.join(context.stageRoot, "desktop-release");
try { await context.io.extractArchive(downloaded.path, extracted, context, { format: "zip" }); }
catch (error) {
if (context.platform !== "darwin" || error.code !== "UNSUPPORTED_ARCHIVE_LINK") throw error;
return { preparedPath: downloaded.path, promotion: "manual", status: "downloaded-not-installed", version: release.version, provenance, ready: false, nextSteps: ["The authenticated macOS desktop archive contains application framework links. Extract the verified ZIP with the operating system into an application location you explicitly choose; setup did not extract, register, or launch the desktop editor."] };
}
const application = context.platform === "darwin"
? await findApplication(context, extracted, (entry) => entry.isDirectory() && entry.name === "GB Studio.app")
: await findApplication(context, extracted, (entry) => entry.isFile() && ["gb-studio.exe", "gb studio.exe"].includes(entry.name.toLowerCase()));
if (!application) throw new SetupIoError("INVALID_COMPONENT", "The optional official archive does not contain its expected desktop application.");
return { preparedPath: context.platform === "darwin" ? application : path.dirname(application), version: release.version, provenance, status: "prepared-not-launched", nextSteps: ["The desktop editor is optional and has not been launched or registered with the operating system."] };
}
/** Parent supplies a fresh stageRoot under its marked, locked setupRoot. */
export async function installComponent(id, options) {
if (!componentIds.has(id)) throw new SetupIoError("UNKNOWN_COMPONENT", "Unknown setup component: " + id);
checkSetupAbort(options.signal);
const context = { ...options, platform: options.platform ?? process.platform, arch: options.arch ?? process.arch };
context.nativePlatform = context.platform + "-" + context.arch;
if (!GBDK_RELEASES[context.nativePlatform] || !UV_RELEASES[context.nativePlatform]) throw new SetupIoError("UNSUPPORTED_PLATFORM", "Packaged component setup does not support this platform/architecture.", { platform: context.nativePlatform });
for (const name of ["setupRoot", "stageRoot", "toolchainRoot", "nodeExecutable"]) if (typeof context[name] !== "string" || !path.isAbsolute(context[name])) throw new SetupIoError("INVALID_CONTEXT", "Component setup requires an explicit absolute " + name + ".");
await assertOwnedPath(context.setupRoot, context.stageRoot);
if (!(await lstat(context.stageRoot)).isDirectory() || (await readdir(context.stageRoot)).length) throw new SetupIoError("DESTINATION_EXISTS", "Component setup requires fresh empty caller-owned staging.", { path: context.stageRoot });
await assertOwnedPath(context.setupRoot, context.toolchainRoot, { allowMissing: true });
context.io = { downloadArchive, extractArchive, copySetupTree, runSetupCommand, setupSha256File, ...options.io };
context.env = await setupEnvironment(context);
context.onProgress?.({ phase: "component-prepare", component: id, platform: context.nativePlatform });
try { return await ({ gbdk, cli, uv, emulator, desktop })[id](context); }
catch (error) {
if (error instanceof SetupIoError) { error.details = { ...error.details, component: id, stageRoot: context.stageRoot, ...(id === "emulator" ? { finalPath: context.finalPath, nonRelocatable: true } : {}) }; throw error; }
throw new SetupIoError(error.code ?? "COMPONENT_INSTALL_FAILED", error.message ?? "Component setup failed.", { component: id, stageRoot: context.stageRoot, ...(id === "emulator" ? { finalPath: context.finalPath, nonRelocatable: true } : {}) }, error);
}
}
SHA-256: 3ce9c8ef86f71945b1767e98f61ad1a4c0dc0d6ae9dd79dc82c5056b9267e9fa