← Files ModRetro Chromatic PluginARCHIVED FILE
scripts/setup-windows.mjs
29.8 KB · Oct 2, 2026 · 00:37 UTC
#!/usr/bin/env node
/** A small Windows front door; the existing setup scripts own installation. */
import { execFile, spawn } from "node:child_process";
import { lstat, readFile, realpath } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import {
assertLegacySetupMutableRoot,
preflightWindowsGbStudio,
sanitizeWindowsBootstrapEnvironment,
} from "./setup-compiler.mjs";
const execFileAsync = promisify(execFile);
const SCRIPT_PATH = fileURLToPath(import.meta.url);
const SOURCE_ROOT = path.resolve(path.dirname(SCRIPT_PATH), "..");
const OUTPUT_LIMIT = 1024 * 1024;
const PROGRESS_INTERVAL = 30_000;
const PYTHON_VERSION_PROGRAM = "import sys; print('.'.join(map(str, sys.version_info[:3])))";
const EMULATOR_VERSION_PROGRAM = [
"import sys, pyboy, PIL",
"from importlib.metadata import version",
"assert sys.version_info[:2] == (3, 13)",
"assert version('pyboy') == '2.7.0'",
"assert int(version('Pillow').split('.')[0]) in (11, 12)",
"print('.'.join(map(str, sys.version_info[:3])))",
].join("; ");
export const WINDOWS_SETUP_TIMEOUTS = Object.freeze({
prerequisite: 15_000,
dependencies: 15 * 60_000,
build: 5 * 60_000,
toolchain: 60 * 60_000,
emulator: 20 * 60_000,
doctor: 2 * 60_000,
payload: 5 * 60_000,
registration: 5 * 60_000,
});
const VALUE_OPTIONS = new Map([
["--toolchain-root", "toolchainRoot"],
["--payload-root", "payloadRoot"],
["--marketplace-root", "marketplaceRoot"],
["--marketplace-name", "marketplaceName"],
["--python", "python"],
]);
const FLAG_OPTIONS = new Map([
["--with-desktop", "withDesktop"],
["--skip-emulator", "skipEmulator"],
["--use-existing", "useExisting"],
["--replace-link", "replaceLink"],
["--dry-run", "dryRun"],
["--json", "json"],
["--help", "help"],
["-h", "help"],
]);
class SetupCommandError extends Error {}
export function parseWindowsFrontDoorArguments(arguments_) {
const selected = {
withDesktop: false,
skipEmulator: false,
useExisting: false,
replaceLink: false,
dryRun: false,
json: false,
help: false,
};
for (let index = 0; index < arguments_.length; index += 1) {
const argument = arguments_[index];
const flag = FLAG_OPTIONS.get(argument);
if (flag !== undefined) {
selected[flag] = true;
continue;
}
const key = VALUE_OPTIONS.get(argument);
if (key === undefined) throw new Error(`Unknown Windows setup option: ${argument}`);
const value = arguments_[++index];
if (!value || value.startsWith("--") || value.includes("\0")) {
throw new Error(`${argument} requires a nonempty value.`);
}
if (selected[key] !== undefined) throw new Error(`${argument} may only be supplied once.`);
selected[key] = value;
}
if (selected.marketplaceName !== undefined && selected.marketplaceRoot === undefined) {
throw new Error("--marketplace-name requires an explicitly selected --marketplace-root.");
}
return selected;
}
function environmentValue(environment, requested) {
const matches = Object.entries(environment).filter(([name, value]) =>
value !== undefined && name.toUpperCase() === requested.toUpperCase());
if (new Set(matches.map(([, value]) => value)).size > 1) {
throw new Error(`Conflicting case-insensitive Windows values for ${requested}.`);
}
return matches[0]?.[1];
}
function setEnvironmentValue(environment, name, value) {
for (const key of Object.keys(environment)) {
if (key.toUpperCase() === name.toUpperCase()) delete environment[key];
}
environment[name] = value;
}
function samePath(left, right) {
return path.resolve(left).toLowerCase() === path.resolve(right).toLowerCase();
}
function withinRoot(root, candidate) {
const relative = path.relative(path.resolve(root).toLowerCase(), path.resolve(candidate).toLowerCase());
return relative === "" ||
(relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
}
async function metadataIfPresent(candidate) {
try {
return await lstat(candidate);
} catch (error) {
if (error?.code === "ENOENT" || error?.code === "ENOTDIR") return undefined;
throw error;
}
}
async function realEntry(candidate, kind, label) {
if (!path.isAbsolute(candidate)) throw new Error(`${label} must use an absolute path.`);
const metadata = await metadataIfPresent(candidate);
if (!metadata || metadata.isSymbolicLink() ||
(kind === "file" ? !metadata.isFile() : !metadata.isDirectory()) ||
!samePath(await realpath(candidate), candidate)) {
throw new Error(`${label} must be a real ${kind}, not a link or reparse point: ${candidate}`);
}
return await realpath(candidate);
}
async function trustedFile(candidate, rejectedRoots, label) {
const canonical = await realEntry(candidate, "file", label);
if (rejectedRoots.some((root) => root && withinRoot(root, canonical))) {
throw new Error(`${label} must be installed outside the selected checkout and project.`);
}
return canonical;
}
async function trustedPathDirectories(environment, rejectedRoots) {
const directories = [];
for (const raw of (environmentValue(environment, "PATH") ?? "").split(";")) {
const entry = raw.trim().replace(/^"(.*)"$/, "$1");
if (!entry || !path.isAbsolute(entry)) continue;
try {
const canonical = await realpath(entry);
if (!(await lstat(canonical)).isDirectory() ||
rejectedRoots.some((root) => root && withinRoot(root, canonical))) continue;
if (!directories.some((existing) => samePath(existing, canonical))) directories.push(canonical);
} catch {
// Missing, relative, or checkout-controlled PATH entries are not executable authorities.
}
}
return directories;
}
async function findTrustedExecutable(names, environment, rejectedRoots) {
for (const directory of await trustedPathDirectories(environment, rejectedRoots)) {
for (const name of names) {
try {
return await trustedFile(path.join(directory, name), rejectedRoots, `The trusted ${name}`);
} catch {
// Keep looking; do not fall back to .cmd, aliases, the working directory, or a shell.
}
}
}
return undefined;
}
async function resolveNpmScript(nodeExecutable, environment, rejectedRoots) {
const directories = [path.dirname(nodeExecutable), ...await trustedPathDirectories(environment, rejectedRoots)];
for (const directory of directories) {
for (const candidate of [
path.join(directory, "node_modules", "npm", "bin", "npm-cli.js"),
path.resolve(directory, "..", "lib", "node_modules", "npm", "bin", "npm-cli.js"),
]) {
try {
return await trustedFile(candidate, rejectedRoots, "The trusted npm CLI");
} catch {
// A full Node installation or a trusted absolute npm-global prefix may supply npm.
}
}
}
throw new Error(
"This Node installation has no usable npm CLI. Install a current supported Node.js 22 or 24 LTS " +
"distribution with npm, reopen PowerShell, and rerun setup. Do not copy a bundled node.exe by itself.",
);
}
function checkedVersion(output, name, minimumMajor) {
const version = String(output ?? "").trim();
const match = /^(?:v)?(\d+)\.(\d+)\.(\d+)(?:[-+][0-9A-Za-z.-]+)?$/u.exec(version);
if (!match || Number(match[1]) < minimumMajor) {
throw new Error(`${name} did not report a supported version (requires ${minimumMajor} or newer).`);
}
return version;
}
/** PowerShell's single-quoted arguments are literal, including spaces, $, and backticks. */
export function formatPowerShellCommand(arguments_) {
return `& ${arguments_.map((argument) => `'${String(argument).replaceAll("'", "''")}'`).join(" ")}`;
}
async function terminateChildTree(child, options) {
if (!child.pid) return true;
if ((options.platform ?? process.platform) === "win32") {
try {
const systemRoot = environmentValue(options.env ?? {}, "SystemRoot");
if (!systemRoot) throw new Error("No trusted SystemRoot");
const canonicalRoot = await realEntry(systemRoot, "directory", "The trusted Windows SystemRoot");
const taskkill = await realEntry(path.join(canonicalRoot, "System32", "taskkill.exe"), "file", "The Windows process-tree terminator");
await execFileAsync(taskkill, ["/PID", String(child.pid), "/T", "/F"], {
cwd: canonicalRoot, env: options.env, shell: false, windowsHide: true,
timeout: 5_000, maxBuffer: 64 * 1024,
});
return true;
} catch {
child.kill("SIGKILL");
return false;
}
}
try { process.kill(-child.pid, "SIGKILL"); } catch { child.kill("SIGKILL"); }
return true;
}
/** Bounded subprocesses never use a shell or echo raw command output and credentials. */
export async function runWindowsSetupCommand(command, arguments_, options = {}) {
const timeout = options.timeout ?? WINDOWS_SETUP_TIMEOUTS.prerequisite;
if (!Number.isSafeInteger(timeout) || timeout < 1 || timeout > WINDOWS_SETUP_TIMEOUTS.toolchain) {
throw new Error("Windows setup command timeout is outside its supported limits.");
}
if (options.signal?.aborted) throw new SetupCommandError(`${options.label ?? "Windows setup command"} was cancelled.`);
const label = options.label ?? "Windows setup command";
const logger = options.logger ?? (() => {});
const platform = options.platform ?? process.platform;
const started = Date.now();
const child = (options.spawn ?? spawn)(command, arguments_, {
cwd: options.cwd, env: options.env, shell: false, windowsHide: true,
detached: platform !== "win32", stdio: ["ignore", "pipe", "pipe"],
});
const output = { stdout: [], stderr: [] };
const sizes = { stdout: 0, stderr: 0 };
let stopReason;
let terminating;
let cleanupTimer;
let rejectClose;
let closed = false;
const stop = (reason) => {
if (stopReason !== undefined || closed) return;
stopReason = reason;
terminating = Promise.resolve().then(() => (options.terminateTree ?? terminateChildTree)(child, { ...options, platform })).catch(() => false);
cleanupTimer = setTimeout(() => {
child.kill("SIGKILL");
rejectClose?.(new SetupCommandError(`${reason} Process cleanup did not finish within 10s.`));
}, 10_000);
};
const collect = (stream, chunk) => {
const buffer = Buffer.from(chunk);
sizes[stream] += buffer.length;
if (sizes[stream] > OUTPUT_LIMIT) {
stop(`${label} exceeded its bounded output limit.`);
return;
}
output[stream].push(buffer);
};
child.stdout?.on("data", (chunk) => collect("stdout", chunk));
child.stderr?.on("data", (chunk) => collect("stderr", chunk));
const onAbort = () => stop(`${label} was cancelled.`);
options.signal?.addEventListener("abort", onAbort, { once: true });
if (options.signal?.aborted) onAbort();
const timer = setTimeout(() => stop(`${label} timed out after ${Math.round(timeout / 1000)}s.`), timeout);
const progress = options.progress === false ? undefined : setInterval(() => {
logger(`${label}: still running (${Math.round((Date.now() - started) / 1000)}s).`);
}, options.progressInterval ?? PROGRESS_INTERVAL);
progress?.unref?.();
try {
const result = await new Promise((resolve, reject) => {
rejectClose = reject;
child.once("error", () => reject(new SetupCommandError(`${label} could not start. Check the executable and access permissions.`)));
child.once("close", (code, signal) => {
closed = true;
resolve({ code, signal });
});
});
const cleaned = terminating ? await terminating : true;
if (stopReason !== undefined) throw new SetupCommandError(`${stopReason}${cleaned === false ? " Descendant-process cleanup could not be confirmed." : ""}`);
if (result.code !== 0 || result.signal !== null) {
throw new SetupCommandError(`${label} failed (${result.signal ?? `exit code ${result.code ?? "unknown"}`}).`);
}
return {
stdout: Buffer.concat(output.stdout).toString("utf8"),
stderr: Buffer.concat(output.stderr).toString("utf8"),
};
} finally {
clearTimeout(timer);
if (cleanupTimer !== undefined) clearTimeout(cleanupTimer);
if (progress !== undefined) clearInterval(progress);
options.signal?.removeEventListener("abort", onAbort);
}
}
async function pythonVersion(executable, context, emulator = false) {
try {
const result = await context.run(executable, ["-I", "-c", emulator ? EMULATOR_VERSION_PROGRAM : PYTHON_VERSION_PROGRAM], {
...context.commandOptions, label: emulator ? "Existing PyBoy prerequisite" : "Python prerequisite",
timeout: WINDOWS_SETUP_TIMEOUTS.prerequisite, progress: false,
});
const version = checkedVersion(result.stdout, "Python", 3);
const [major, minor] = version.split(".").map(Number);
if (major < 3 || (major === 3 && minor < 10)) return undefined;
return version;
} catch {
return undefined;
}
}
async function resolvePython(selected, context, localRoot) {
const managed = path.join(localRoot, "pyboy-venv", "Scripts", "python.exe");
const managedMetadata = await metadataIfPresent(managed);
let managedPython;
if (managedMetadata) managedPython = await realEntry(managed, "file", "The managed PyBoy interpreter");
let registrationPython;
if (selected.python !== undefined) {
if (!path.isAbsolute(selected.python)) throw new Error("--python requires an absolute interpreter path.");
registrationPython = samePath(selected.python, managed)
? await realEntry(selected.python, "file", "The managed registration Python interpreter")
: await trustedFile(selected.python, context.rejectedRoots, "The selected registration Python interpreter");
if (!await pythonVersion(registrationPython, context)) {
throw new Error("The selected registration interpreter must be a working Python 3.10 or newer.");
}
} else if (managedPython && await pythonVersion(managedPython, context)) {
registrationPython = managedPython;
}
const needsRegistration = selected.marketplaceRoot !== undefined;
if (!selected.skipEmulator) {
const healthy = managedPython && await pythonVersion(managedPython, context, true);
if (!healthy) {
const uv = await findTrustedExecutable(["uv.exe"], context.environment, context.rejectedRoots);
if (!uv) {
throw new Error(
"Playtesting setup needs a trusted uv.exe on an absolute PATH entry to install its managed Python 3.13 runtime. " +
"Install uv from https://docs.astral.sh/uv/, reopen PowerShell, or use --skip-emulator for authoring only.",
);
}
await context.run(uv, ["--version"], {
...context.commandOptions, label: "uv prerequisite", timeout: WINDOWS_SETUP_TIMEOUTS.prerequisite, progress: false,
});
}
return { registrationPython: registrationPython ?? managed, managedPython: managed, installsManagedPython: !healthy };
}
if (!registrationPython && needsRegistration) {
const candidate = await findTrustedExecutable(["python.exe", "python3.exe"], context.environment, context.rejectedRoots);
if (candidate && await pythonVersion(candidate, context)) registrationPython = candidate;
if (!registrationPython) {
throw new Error(
"Workspace marketplace registration needs Python 3.10 or newer. Install Python, pass --python with its " +
"absolute python.exe path, or omit --skip-emulator to provision the managed playtesting interpreter.",
);
}
}
return { registrationPython, managedPython: managed, installsManagedPython: false };
}
function parseObjectResult(result, label) {
let parsed;
try { parsed = JSON.parse(result.stdout); } catch { throw new Error(`${label} did not return valid JSON.`); }
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error(`${label} did not return a JSON object.`);
return parsed;
}
export const WINDOWS_SETUP_USAGE = [
"Usage: node scripts/setup-windows.mjs [--toolchain-root PATH] [--with-desktop] [--skip-emulator]",
" [--use-existing] [--payload-root PATH] [--marketplace-root PATH] [--marketplace-name NAME]",
" [--python PATH] [--replace-link] [--dry-run] [--json]",
"Prepares a Windows x64 checkout, official compiler, optional emulator, and lean plugin payload.",
"Only an explicit --marketplace-root registers a workspace marketplace. Codex installation is a separate desktop-user step.",
"--dry-run prints an unverified plan without running prerequisite probes or changing files.",
"Installed plugins should use packaged setup.mjs (or setup.sh/setup.ps1) with an external dependency root.",
].join("\n");
function selectedSetupStages(selected) {
const stages = ["Install JavaScript dependencies", "Build the MCP runtime", "Prepare the official game toolchain"];
if (!selected.skipEmulator) stages.push("Prepare the PyBoy playtesting runtime");
stages.push("Check the prepared runtime", "Prepare the lean plugin payload");
if (selected.marketplaceRoot !== undefined) stages.push("Register the selected workspace marketplace");
return stages;
}
/** Preflight, then delegate to the existing supported setup/build/registration paths. */
export async function runWindowsSetup(options = {}) {
const selected = parseWindowsFrontDoorArguments(options.args ?? []);
const logger = options.logger ?? ((message) => process.stderr.write(`${message}\n`));
if (selected.help) {
logger(WINDOWS_SETUP_USAGE);
return { help: true, installed: false };
}
const platform = options.platform ?? process.platform;
const architecture = options.arch ?? process.arch;
if (platform !== "win32" || architecture !== "x64") {
throw new Error(`The Windows front door supports Windows x64 only; received ${platform} ${architecture}. Use the existing setup:compiler and setup:emulator commands on macOS/Linux.`);
}
if (selected.python !== undefined && !path.isAbsolute(selected.python)) {
throw new Error("--python requires an absolute interpreter path.");
}
if (selected.dryRun) {
// A plan must work before npm, Corepack, uv, Python, or a dependency root exists.
// These are lexical requested destinations, not verified physical bindings.
const sourceRoot = path.resolve(options.sourceRoot ?? SOURCE_ROOT);
const currentDirectory = options.cwd ?? process.cwd();
const toolchainRoot = path.resolve(currentDirectory, selected.toolchainRoot ??
environmentValue(options.environment ?? process.env, "GB_STUDIO_TOOLCHAIN_ROOT") ?? sourceRoot);
const payloadRoot = path.resolve(currentDirectory, selected.payloadRoot ?? path.join(sourceRoot, "artifacts", "plugin-payloads"));
const marketplaceRoot = selected.marketplaceRoot === undefined ? undefined : path.resolve(currentDirectory, selected.marketplaceRoot);
const stages = selectedSetupStages(selected);
logger("[dry-run] Plan only; no tools were run. Paths and prerequisite health are unverified.");
logger(`[dry-run] Requested source ${sourceRoot}; toolchain ${toolchainRoot}; payload output ${payloadRoot}.`);
logger("[dry-run] Apply requires Node.js >=22 with npm, official GB Studio 4.3.2 and GBDK 4.5.0; missing compiler setup also needs Git and Corepack.");
if (!selected.skipEmulator) logger("[dry-run] Playtesting uses Python 3.13, PyBoy 2.7.0, Pillow >=11,<13; uv is needed for installation or repair.");
if (selected.useExisting) logger("[dry-run] --use-existing verifies the existing official toolchain; it does not skip npm/runtime/payload stages or optional PyBoy repair.");
for (const stage of stages) logger(`[dry-run] ${stage}`);
return { schemaVersion: 1, platform: "win32-x64", sourceRoot, toolchainRoot, payloadRoot, marketplaceRoot, stages,
dryRun: true, verified: false, installed: false };
}
checkedVersion(options.nodeVersion ?? process.versions.node, "Node.js", 22);
const sourceRoot = await realEntry(await realpath(path.resolve(options.sourceRoot ?? SOURCE_ROOT)), "directory", "The plugin source checkout");
await assertLegacySetupMutableRoot(sourceRoot);
for (const relative of ["package.json", "package-lock.json", "scripts/prepare.mjs", "scripts/setup-compiler.mjs", "scripts/setup-emulator.mjs", "scripts/doctor.mjs", "scripts/prepare-plugin-payload.mjs", "scripts/register-personal-plugin.py"]) {
await realEntry(path.join(sourceRoot, relative), "file", "The plugin setup source");
}
const manifest = JSON.parse(await readFile(path.join(sourceRoot, "package.json"), "utf8"));
if (manifest.name !== "modretro-chromatic") throw new Error("The setup source is not a modretro-chromatic checkout.");
const parentEnvironment = options.environment ?? process.env;
const environment = sanitizeWindowsBootstrapEnvironment(parentEnvironment);
const currentDirectory = options.cwd ?? process.cwd();
const requestedToolchainRoot = await realEntry(
await realpath(path.resolve(currentDirectory, selected.toolchainRoot ?? environmentValue(parentEnvironment, "GB_STUDIO_TOOLCHAIN_ROOT") ?? sourceRoot)),
"directory", "The selected game toolchain root",
);
const requestedLocalRoot = path.join(requestedToolchainRoot, ".local");
let physicalLocalRoot = requestedLocalRoot;
if (await metadataIfPresent(requestedLocalRoot)) {
physicalLocalRoot = await realEntry(await realpath(requestedLocalRoot), "directory", "The selected toolchain .local directory");
if (path.basename(physicalLocalRoot).toLowerCase() !== ".local") {
throw new Error("A linked game toolchain must resolve to an actual .local directory.");
}
}
const rejectedRoots = [sourceRoot, currentDirectory, requestedToolchainRoot, physicalLocalRoot,
path.dirname(physicalLocalRoot), environmentValue(parentEnvironment, "GB_STUDIO_PROJECT_ROOT")].filter(Boolean);
const nodeExecutable = await trustedFile(await realpath(options.nodeExecutable ?? process.execPath), rejectedRoots, "The trusted Node.js executable");
const run = options.runCommand ?? runWindowsSetupCommand;
const commandOptions = { cwd: sourceRoot, env: environment, platform, signal: options.signal, logger };
const npmScript = await resolveNpmScript(nodeExecutable, environment, rejectedRoots);
logger("Checking Windows setup prerequisites...");
let npmVersion;
try {
const result = await run(nodeExecutable, [npmScript, "--version"], {
...commandOptions, label: "npm prerequisite", timeout: WINDOWS_SETUP_TIMEOUTS.prerequisite, progress: false,
});
npmVersion = checkedVersion(result.stdout, "npm", 8);
} catch {
throw new Error("The installed npm CLI could not run with this Node executable. Install a current supported Node.js 22 or 24 LTS distribution with npm, reopen PowerShell, and retry.");
}
const toolchainArguments = [selected.withDesktop ? "--with-desktop" : "--headless", "--with-cli"];
if (selected.useExisting) toolchainArguments.push("--use-existing");
const toolchain = await (options.preflightGbStudio ?? preflightWindowsGbStudio)({
platform, arch: architecture, args: toolchainArguments, pluginRoot: sourceRoot,
toolchainRoot: requestedToolchainRoot, workingDirectory: currentDirectory,
nodeExecutable, environment, logger,
runCommand: (command, arguments_, settings) => run(command, arguments_, {
...commandOptions, ...settings, label: "Game compiler prerequisite",
timeout: settings.timeout ?? WINDOWS_SETUP_TIMEOUTS.prerequisite, progress: false,
}),
});
// A linked checkout .local is allowed by the existing installer; use its physical owner downstream.
const toolchainRoot = path.dirname(toolchain.localRoot);
setEnvironmentValue(environment, "GB_STUDIO_TOOLCHAIN_ROOT", toolchainRoot);
const context = { run, environment, commandOptions, rejectedRoots };
const python = await resolvePython(selected, context, toolchain.localRoot);
const payloadRoot = path.resolve(currentDirectory, selected.payloadRoot ?? path.join(sourceRoot, "artifacts", "plugin-payloads"));
const marketplaceRoot = selected.marketplaceRoot === undefined ? undefined : path.resolve(currentDirectory, selected.marketplaceRoot);
const stages = selectedSetupStages(selected);
logger(`Prerequisites ready: Node ${options.nodeVersion ?? process.versions.node}, npm ${npmVersion}.`);
logger("Codex will not run from this setup process; installation belongs in the desktop user's terminal.");
if (options.signal?.aborted) throw new Error("Windows setup was cancelled before installation.");
async function stage(label, command, arguments_, timeout) {
if (options.signal?.aborted) throw new SetupCommandError(`${label} was cancelled before it started.`);
logger(`${label}...`);
try {
const result = await run(command, arguments_, { ...commandOptions, label, timeout });
logger(`${label}: completed.`);
return result;
} catch (error) {
logger(`${label}: failed.`);
const diagnostic = error instanceof SetupCommandError ? error.message : `${label} failed.`;
throw new Error(`${diagnostic} Rerun this supported command for its detailed diagnostic: ${formatPowerShellCommand([command, ...arguments_])}`);
}
}
await stage(stages[0], nodeExecutable, [npmScript, "ci", "--include=dev", "--ignore-scripts", "--no-audit", "--no-fund"], WINDOWS_SETUP_TIMEOUTS.dependencies);
await stage(stages[1], nodeExecutable, [path.join(sourceRoot, "scripts", "prepare.mjs")], WINDOWS_SETUP_TIMEOUTS.build);
await stage(stages[2], nodeExecutable, [path.join(sourceRoot, "scripts", "setup-compiler.mjs"), ...toolchainArguments], WINDOWS_SETUP_TIMEOUTS.toolchain);
if (!selected.skipEmulator) {
await stage("Prepare the PyBoy playtesting runtime", nodeExecutable, [path.join(sourceRoot, "scripts", "setup-emulator.mjs")], WINDOWS_SETUP_TIMEOUTS.emulator);
}
const doctorArguments = [path.join(sourceRoot, "scripts", "doctor.mjs"),
"--runtime-root", sourceRoot, "--toolchain-root", toolchainRoot, "--node", nodeExecutable,
"--task", "projectBuild", "--probe", "cli-version", "--probe", "gbdk-version"];
if (!selected.skipEmulator) doctorArguments.push("--task", "play", "--probe", "emulator-import");
await stage("Check the prepared runtime", nodeExecutable, doctorArguments, WINDOWS_SETUP_TIMEOUTS.doctor);
const payloadArguments = [path.join(sourceRoot, "scripts", "prepare-plugin-payload.mjs"), "--source", sourceRoot,
"--runtime-root", sourceRoot, "--toolchain-root", toolchainRoot, "--output-root", payloadRoot, "--json"];
const payload = parseObjectResult(await stage("Prepare the lean plugin payload", nodeExecutable, payloadArguments, WINDOWS_SETUP_TIMEOUTS.payload), "Plugin payload preparation");
if (payload.pluginName !== "modretro-chromatic" || typeof payload.payloadPath !== "string" || typeof payload.version !== "string" || payload.dryRun) {
throw new Error("Plugin payload preparation did not confirm a real modretro-chromatic payload.");
}
logger(`Prepared plugin payload ${payload.version} (${payload.fileCount ?? "unknown"} files, ${payload.totalBytes ?? "unknown"} bytes).`);
const registrationArguments = ["-I", path.join(sourceRoot, "scripts", "register-personal-plugin.py"), "--source", sourceRoot,
"--runtime-root", sourceRoot, "--toolchain-root", toolchainRoot, "--payload-root", payloadRoot];
if (selected.replaceLink) registrationArguments.push("--replace-link");
let marketplace;
if (marketplaceRoot !== undefined) {
const executable = await realEntry(python.registrationPython, "file", "The registration Python interpreter");
marketplace = parseObjectResult(await stage("Register the selected workspace marketplace", executable, [
...registrationArguments, "--marketplace-root", marketplaceRoot,
...(selected.marketplaceName === undefined ? [] : ["--marketplace-name", selected.marketplaceName]), "--json",
], WINDOWS_SETUP_TIMEOUTS.registration), "Workspace marketplace registration");
if (marketplace.registered !== true || marketplace.installed !== false || !Array.isArray(marketplace.handoff)) {
throw new Error("Workspace marketplace registration did not confirm a registered, not-yet-installed marketplace.");
}
logger("The selected workspace marketplace is prepared. In a normal desktop-user PowerShell terminal, run:");
for (const command of marketplace.handoff) {
if (!Array.isArray(command) || !command.every((argument) => typeof argument === "string")) {
throw new Error("Workspace marketplace registration returned an invalid handoff command.");
}
logger(` ${formatPowerShellCommand(command)}`);
}
} else {
const executable = python.registrationPython ?? "python";
logger("No marketplace or personal profile was changed. To register for the current desktop user, open a normal PowerShell terminal and run:");
logger(` ${formatPowerShellCommand([executable, ...registrationArguments, "--confirm-user-profile"])} $env:USERPROFILE`);
if (python.registrationPython === undefined) logger(" Use Python 3.10 or newer for that registration command.");
logger("Then run the exact codex plugin add command printed by registration, followed by codex plugin list --json. Start a new Codex task after installation.");
logger("Alternatively, rerun with an explicitly selected --marketplace-root to prepare a workspace marketplace.");
}
logger("Setup preparation is complete. Plugin installation has not been performed or verified.");
return { schemaVersion: 1, platform: "win32-x64", sourceRoot, toolchainRoot, payloadRoot, payload, marketplace, dryRun: false, installed: false };
}
if (process.argv[1] && path.resolve(process.argv[1]) === SCRIPT_PATH) {
const controller = new AbortController();
const cancel = () => controller.abort();
process.once("SIGINT", cancel);
process.once("SIGTERM", cancel);
try {
const selected = parseWindowsFrontDoorArguments(process.argv.slice(2));
const result = await runWindowsSetup({ args: process.argv.slice(2), signal: controller.signal });
if (selected.json) process.stdout.write(`${JSON.stringify(result)}\n`);
} catch (error) {
process.stderr.write(`Windows setup failed: ${error instanceof Error ? error.message : String(error)}\n`);
process.exitCode = controller.signal.aborted ? 130 : 1;
} finally {
process.removeListener("SIGINT", cancel);
process.removeListener("SIGTERM", cancel);
}
}
SHA-256: 7d24c4c28ec4f4d6faff5cf6761a8d076f9bd2b8c2fd70e22f9fe306d407cc3e