← Files ModRetro Chromatic PluginARCHIVED FILE

scripts/dependency-doctor.mjs

37.4 KB · Oct 2, 2026 · 00:37 UTC

↓ Download file

import { constants } from "node:fs";
import { access, lstat, open, opendir, realpath, stat } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";

import { DEPENDENCY_CATALOG, NODE_RELEASES, SUPPORTED_SETUP_PLATFORMS } from "./dependency-catalog.mjs";
import { runSetupCommand, setupSha256File } from "./setup-io.mjs";

export const DEPENDENCY_TASKS = Object.freeze(["authoring", "projectBuild", "cBuild", "play", "desktop"]);
export const DEPENDENCY_PROBES = Object.freeze(["cli-version", "gbdk-version", "emulator-import"]);
const DEFAULT_TASKS = ["projectBuild", "play"];
const COMPONENTS = ["node", "runtime", "cli", "gbdk", "python", "pyboy", "pillow", "desktop", "uv"];
const TASK_COMPONENTS = {
  authoring: ["node", "runtime"],
  projectBuild: ["node", "runtime", "cli", "gbdk"],
  cBuild: ["node", "runtime", "gbdk"],
  play: ["node", "runtime", "cli", "gbdk", "python", "pyboy", "pillow"],
  desktop: ["desktop"],
};
const contexts = new WeakMap();
const MAX_METADATA_BYTES = 1024 * 1024;
const MAX_PROBE_BYTES = 64 * 1024;
const UNSAFE_ENV = new Set([
  "NODE_OPTIONS", "NODE_PATH", "NODE_EXTRA_CA_CERTS", "NODE_REPL_HISTORY", "PYTHONPATH", "PYTHONHOME",
  "PYTHONSTARTUP", "PYTHONUSERBASE", "PYTHONWARNINGS", "PYTHONINSPECT", "PYTHONBREAKPOINT",
  "PYTHONEXECUTABLE", "__PYVENV_LAUNCHER__",
  "VIRTUAL_ENV", "CONDA_PREFIX", "BASH_ENV", "ENV", "LD_PRELOAD", "LD_LIBRARY_PATH",
  "DYLD_INSERT_LIBRARIES", "DYLD_LIBRARY_PATH", "DYLD_FRAMEWORK_PATH",
  "PSMODULEPATH", "PSMODULEANALYSISCACHEPATH", "PSEXECUTIONPOLICYPREFERENCE", "__PSLOCKDOWNPOLICY",
]);

function environmentValue(environment, name, platform) {
  if (platform !== "win32") return environment[name] || undefined;
  const values = Object.entries(environment).filter(([key, value]) => key.toUpperCase() === name.toUpperCase() && value !== undefined).map(([, value]) => value);
  if (new Set(values).size > 1) throw new TypeError(`Conflicting case-insensitive Windows environment values exist for ${name}.`);
  return values[0] || undefined;
}

function absolute(candidate, label) {
  if (typeof candidate !== "string" || candidate.length > 4096 || /[\u0000-\u001f]/u.test(candidate) || !path.isAbsolute(candidate)) {
    throw new TypeError(`${label} must be an absolute path without control characters.`);
  }
  if (process.platform === "win32" && (/^[\\/]{2}/u.test(candidate) || candidate.split(/[\\/]+/u).some((part, index) =>
    (part.includes(":") && !(index === 0 && /^[a-z]:$/iu.test(part))) || /[. ]$/u.test(part) ||
    /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/iu.test(part)))) {
    throw new TypeError(`${label} must not contain a Windows network, device, stream, or ambiguous alias.`);
  }
  return path.resolve(candidate);
}

function within(root, candidate) {
  const relative = path.relative(root, candidate);
  return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative));
}

function missing(error) { return error?.code === "ENOENT" || error?.code === "ENOTDIR"; }

async function directory(candidate, label) {
  const selected = absolute(candidate, label);
  try {
    const canonical = await realpath(selected);
    if (!(await stat(canonical)).isDirectory()) throw new TypeError(`${label} must name a directory.`);
    return canonical;
  } catch (error) {
    if (error?.code === "ENOENT") return selected;
    throw error;
  }
}

async function toolchainOwner(candidate) {
  const selected = await directory(candidate, "Toolchain root");
  const local = path.join(selected, ".local");
  try {
    if (!(await lstat(local)).isSymbolicLink()) return selected;
    const canonical = await realpath(local);
    if (path.basename(canonical) !== ".local" || !(await stat(canonical)).isDirectory()) {
      throw new TypeError("A linked toolchain must resolve to an actual .local directory.");
    }
    return path.dirname(canonical);
  } catch (error) {
    if (missing(error)) return selected;
    throw error;
  }
}

async function regular(candidate, root, executable = false, platform = process.platform) {
  const canonical = await realpath(candidate);
  if (root && !within(root, canonical)) throw new Error("A dependency file resolves outside its selected root.");
  const metadata = await stat(canonical);
  if (!metadata.isFile() || metadata.size === 0) throw new Error("A dependency file must be a nonempty regular file.");
  if (executable) await access(candidate, platform === "win32" ? constants.F_OK : constants.X_OK);
  return canonical;
}

async function boundedText(candidate, root, limit = MAX_METADATA_BYTES) {
  await regular(candidate, root);
  const handle = await open(candidate, "r");
  try {
    const metadata = await handle.stat();
    if (!metadata.isFile() || metadata.size > limit) throw new Error("Dependency metadata exceeds its regular-file size bound.");
    const buffer = Buffer.alloc(limit + 1);
    let count = 0;
    while (count < buffer.length) {
      const result = await handle.read(buffer, count, buffer.length - count, count);
      if (result.bytesRead === 0) break;
      count += result.bytesRead;
    }
    if (count > limit) throw new Error("Dependency metadata exceeds its size bound.");
    return buffer.toString("utf8", 0, count);
  } finally { await handle.close(); }
}

async function object(candidate, root) {
  const contents = await boundedText(candidate, root);
  let parsed;
  try { parsed = JSON.parse(contents); }
  catch (cause) { throw new Error(`Dependency metadata could not be read as JSON: ${candidate}`, { cause }); }
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error(`Dependency metadata must be an object: ${candidate}`);
  return parsed;
}

async function optionalObject(candidate, root) {
  try { return await object(candidate, root); } catch (error) { if (missing(error)) return undefined; throw error; }
}

async function executableHash(candidate, root) {
  return (await setupSha256File(await regular(candidate, root))).sha256;
}

function version(value) {
  if (typeof value !== "string") return undefined;
  const matched = value.trim().match(/^v?(\d+)\.(\d+)(?:\.(\d+))?$/u);
  return matched ? `${Number(matched[1])}.${Number(matched[2])}.${Number(matched[3] ?? 0)}` : undefined;
}

function compatible(id, detected, requiredVersion) {
  const normalized = version(detected);
  if (!normalized) return false;
  const parts = normalized.split(".").map(Number);
  const definition = DEPENDENCY_CATALOG[id];
  switch (definition.versionPolicy) {
    case "minimum": return parts[0] >= Number(definition.requiredVersion);
    case "minor": return `${parts[0]}.${parts[1]}` === definition.requiredVersion;
    case "range": return parts[0] === 11 || parts[0] === 12;
    default: return normalized === version(requiredVersion ?? definition.requiredVersion);
  }
}

function nextStep(id, roots, platform = process.platform) {
  if (id === "node") return `Use the packaged setup bootstrap to provision a compatible Node executable under ${roots.nodeRoot}; selecting or activating a different bound executable is separate.`;
  const group = ["python", "pyboy", "pillow", "uv"].includes(id) ? "emulator" : ["cli", "gbdk"].includes(id) ? "build" : id;
  const quote = (value) => "'" + (platform === "win32" ? value.replaceAll("'", "''") : value.replaceAll("'", "'\\''")) + "'";
  return `Run ${platform === "win32" ? "& " : ""}${quote(process.execPath)} ${quote(path.join(roots.packageRoot, "scripts", "setup.mjs"))} plan --root ${quote(roots.setupRoot)} --components ${group}. Review the plan before apply; existing bound roots are inspection-only.`;
}

function component(id, roots, provenance, platform) {
  return { id, status: "missing", requiredVersion: DEPENDENCY_CATALOG[id].requiredVersion, provenance,
    detectionEvidence: [], probeStatus: "not-run", nextSteps: [nextStep(id, roots, platform)],
    ...(DEPENDENCY_CATALOG[id].installerOnly ? { installerOnly: true } : {}) };
}

function detected(result, detectedVersion, evidence) {
  if (!version(detectedVersion)) throw new Error(`The ${result.id} version metadata is missing or malformed.`);
  result.detectedVersion = detectedVersion;
  result.detectionEvidence.push(evidence);
  result.status = compatible(result.id, detectedVersion, result.requiredVersion) ? "ready" : "incompatible";
  if (result.status === "ready") result.nextSteps = [];
  return result;
}

async function detectFile(result, inspect) {
  let primaryExists = false;
  try { if (result.path) { await lstat(result.path); primaryExists = true; } } catch { /* Classified by inspection below. */ }
  try { await inspect(); } catch (error) {
    result.status = missing(error) && !primaryExists ? "missing" : "broken";
    result.detectionEvidence.push(error instanceof Error ? error.message : String(error));
  }
  return result;
}

async function resolveRoots(options, environment, platform) {
  const supplied = options.resolvedRoots ?? {};
  const packageRoot = await directory(options.packageRoot ?? supplied.packageRoot ?? path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."), "Package root");
  const selectedSetup = options.setupRoot ?? supplied.setupRoot ?? environmentValue(environment, "GB_STUDIO_SETUP_ROOT", platform);
  const explicitSetup = selectedSetup !== undefined;
  const home = environmentValue(environment, platform === "win32" ? "USERPROFILE" : "HOME", platform) ?? os.homedir();
  const dataRoot = platform === "darwin" ? path.join(home, "Library", "Application Support")
    : platform === "win32" ? (environmentValue(environment, "LOCALAPPDATA", platform) ?? path.join(home, "AppData", "Local"))
    : (environmentValue(environment, "XDG_DATA_HOME", platform) ?? path.join(home, ".local", "share"));
  const setupRoot = await directory(selectedSetup ?? path.join(dataRoot, "modretro-chromatic"), "Setup root");
  let receipt;
  const receiptEvidence = [];
  if (!explicitSetup) {
    try {
      receipt = await optionalObject(path.join(packageRoot, ".codex-plugin", "local-payload.json"), packageRoot);
      if (receipt && (receipt.schemaVersion !== 1 || receipt.pluginName !== "modretro-chromatic")) throw new Error("Unsupported local-payload receipt.");
      if (receipt) for (const key of ["runtimeRoot", "toolchainRoot", "nodeExecutable"]) absolute(receipt[key], `Receipt ${key}`);
    } catch (error) { receipt = undefined; receiptEvidence.push(`Local payload inspection receipt was ignored: ${error.message}`); }
  }
  const bound = (name) => explicitSetup ? undefined : environmentValue(environment, name, platform);
  const selectedRuntime = options.runtimeRoot ?? supplied.runtimeRoot ?? bound("GB_STUDIO_RUNTIME_ROOT") ?? receipt?.runtimeRoot;
  const selectedToolchain = options.toolchainRoot ?? supplied.toolchainRoot ?? bound("GB_STUDIO_TOOLCHAIN_ROOT") ?? receipt?.toolchainRoot;
  const origin = (option, resolved, envName, fromReceipt, fallback) => option !== undefined ? "explicit option"
    : resolved !== undefined ? "explicit resolved root" : bound(envName) !== undefined ? `environment ${envName}`
      : fromReceipt !== undefined ? "local-payload inspection receipt (not activated or reverified)" : fallback;
  const bundled = (await optionalObject(path.join(packageRoot, "package.json"), packageRoot))?.bundledMcp === true;
  const roots = {
    packageRoot, setupRoot,
    runtimeRoot: await directory(selectedRuntime ?? (bundled ? packageRoot : path.join(setupRoot, "runtime")), "Runtime root"),
    toolchainRoot: await toolchainOwner(selectedToolchain ?? path.join(setupRoot, "toolchain")),
    nodeRoot: await directory(options.nodeRoot ?? supplied.nodeRoot ?? path.join(setupRoot, "node"), "Managed Node root"),
  };
  return { roots, receipt, receiptEvidence, explicitSetup,
    nodeExecutable: options.nodeExecutable ?? supplied.nodeExecutable ?? bound("GB_STUDIO_NODE") ?? receipt?.nodeExecutable,
    pythonPath: options.pythonPath ?? bound("GB_STUDIO_PYTHON"),
    provenance: {
      runtime: origin(options.runtimeRoot, supplied.runtimeRoot, "GB_STUDIO_RUNTIME_ROOT", receipt?.runtimeRoot, "external setup runtime sibling"),
      toolchain: origin(options.toolchainRoot, supplied.toolchainRoot, "GB_STUDIO_TOOLCHAIN_ROOT", receipt?.toolchainRoot, "external setup toolchain sibling"),
      node: origin(options.nodeExecutable, supplied.nodeExecutable, "GB_STUDIO_NODE", receipt?.nodeExecutable, "managed receipt or current Node process"),
      python: origin(options.pythonPath, undefined, "GB_STUDIO_PYTHON", undefined, "selected toolchain virtual environment"),
      setup: explicitSetup ? "explicit setup root" : "operating-system external data root",
    } };
}

async function detectNode(result, context) {
  const { roots, platform, arch } = context;
  const receiptPath = path.join(roots.nodeRoot, `${platform}-${arch}`, "receipt.json");
  let receipt;
  try {
    receipt = await optionalObject(receiptPath, roots.nodeRoot);
    const release = NODE_RELEASES[`${platform}-${arch}`];
    if (receipt && (receipt.schemaVersion !== 1 || receipt.owner !== "codex-gb-studio-setup" || receipt.platform !== `${platform}-${arch}` ||
      !release || receipt.version !== release.version || receipt.sourceUrl !== release.url || receipt.archiveSha256 !== release.sha256 ||
      !/^[a-f0-9]{64}$/u.test(receipt.executableSha256) || !within(path.dirname(receiptPath), absolute(receipt.executablePath, "Managed Node executable")))) {
      throw new Error("Managed Node receipt does not describe this platform and managed root.");
    }
    if (receipt && await executableHash(receipt.executablePath, roots.nodeRoot) !== receipt.executableSha256) throw new Error("Managed Node executable SHA-256 does not match its receipt.");
  } catch (error) {
    receipt = undefined;
    result.detectionEvidence.push(`Managed Node receipt: ${error.message}`);
    if (!context.nodeExecutable || within(roots.nodeRoot, context.nodeExecutable)) {
      result.path = context.nodeExecutable ?? receiptPath;
      result.status = "broken";
      return;
    }
  }
  const selected = context.nodeExecutable ?? receipt?.executablePath ?? process.execPath;
  result.path = absolute(selected, "Node executable");
  context.nodeExecutable = result.path;
  await detectFile(result, async () => {
    const canonical = await regular(result.path, undefined, true, platform);
    if (canonical === await realpath(process.execPath)) {
      detected(result, process.versions.node, "Current Node process version; no subprocess launched.");
    } else if (receipt?.executablePath === result.path) {
      detected(result, receipt.version, `Managed Node version from ${receiptPath}; catalog archive identity and executable SHA-256 match, but executable health has not been probed.`);
    } else {
      const header = path.resolve(path.dirname(result.path), "..", "include", "node", "node_version.h");
      const source = await boundedText(header, undefined, 64 * 1024);
      const parts = ["MAJOR", "MINOR", "PATCH"].map((key) => source.match(new RegExp(`^\\s*#\\s*define\\s+NODE_${key}_VERSION\\s+(\\d+)`, "mu"))?.[1]);
      if (parts.some((part) => part === undefined)) throw new Error("Node version metadata is missing or malformed.");
      detected(result, parts.join("."), `Node version header: ${header}; binary has not been executed.`);
    }
  });
}

async function detectRuntime(result, context) {
  const { packageRoot, runtimeRoot } = context.roots;
  result.path = runtimeRoot;
  await detectFile(result, async () => {
    const source = await optionalObject(path.join(packageRoot, "package.json"), packageRoot)
      ?? await optionalObject(path.join(packageRoot, ".codex-plugin", "plugin.json"), packageRoot);
    const required = source?.version?.split("+")[0] ?? context.receipt?.sourceVersion;
    if (!version(required)) throw new Error("Source runtime package version is not known.");
    result.requiredVersion = required;
    const installed = await object(path.join(runtimeRoot, "package.json"), runtimeRoot);
    if (installed.name !== "modretro-chromatic") throw new Error("Runtime package identity is not modretro-chromatic.");
    await regular(path.join(runtimeRoot, "dist", "server.js"), runtimeRoot);
    for (const name of ["emulator_worker.py", "dependency-doctor.mjs", "dependency-catalog.mjs", "node-releases.tsv", "setup-io.mjs"]) {
      await regular(path.join(runtimeRoot, "scripts", name), runtimeRoot);
    }
    const starter = await object(path.join(runtimeRoot, "examples", "starter", "project.gbsproj"), runtimeRoot);
    if (starter._resourceType !== "project" || typeof starter.name !== "string" || !starter.name.trim()) throw new Error("The packaged native game starter metadata is malformed.");
    if (installed.bundledMcp === true) {
      await regular(path.join(runtimeRoot, "scripts", "device-client-deps.mjs"), runtimeRoot);
      detected(result, installed.version, "Bundled MCP entrypoint, device client, worker and starter are present; no modules imported.");
      return;
    }
    const lock = await optionalObject(path.join(packageRoot, "scripts", "runtime-package-lock.json"), packageRoot)
      ?? await optionalObject(path.join(runtimeRoot, "package-lock.json"), runtimeRoot);
    for (const name of ["@modelcontextprotocol/sdk", "pngjs", "zod"]) {
      const dependency = await object(path.join(runtimeRoot, "node_modules", ...name.split("/"), "package.json"), runtimeRoot);
      const expected = lock?.packages?.[`node_modules/${name}`]?.version;
      if (!expected || dependency.name !== name || dependency.version !== expected) throw new Error(`Runtime dependency metadata does not match its reviewed lock: ${name}.`);
    }
    detected(result, installed.version, "Runtime entrypoint, worker, starter and direct production dependency metadata match the package and lock; no modules imported.");
  });
}

function cliCandidates(root, platform) {
  const vendor = path.join(root, ".local", "vendor", "gb-studio");
  const apps = path.join(root, ".local", "apps");
  const directories = platform === "win32" ? [vendor,
    path.join(apps, "GB Studio", "resources", "app"), path.join(apps, "GB Studio", "win-unpacked", "resources", "app")]
    : [vendor, path.join(apps, "GB Studio.app", "Contents", "Resources", "app")];
  return directories.map((directory) => ({ directory, executable: path.join(directory, "out", "cli", "gb-studio-cli.js") }));
}

async function firstPresent(candidates) {
  for (const candidate of candidates) {
    try { await lstat(candidate); return candidate; } catch (error) { if (!missing(error)) throw error; }
  }
  return candidates[0];
}

async function detectCli(result, context) {
  const root = context.roots.toolchainRoot;
  const candidates = cliCandidates(root, context.platform);
  result.path = await firstPresent(candidates.map((candidate) => candidate.executable));
  await detectFile(result, async () => {
    await regular(result.path, root);
    const packagePath = path.resolve(path.dirname(result.path), "..", "..", "package.json");
    const metadata = await object(packagePath, root);
    if (metadata.name !== "gb-studio") throw new Error("CLI package metadata is not the official gb-studio package.");
    const cliRoot = path.dirname(packagePath);
    await object(path.join(cliRoot, "appData", "engine", "engine.json"), root);
    await regular(path.join(cliRoot, "appData", "engine", "gbvm", "include", "vm.h"), root);
    const compilerRoot = path.join(cliRoot, "buildTools", `${context.platform}-${context.arch}`, "gbdk");
    await regular(path.join(compilerRoot, "bin", context.platform === "win32" ? "lcc.exe" : "lcc"), root, true, context.platform);
    const header = await boundedText(path.join(compilerRoot, "include", "gbdk", "version.h"), root, 64 * 1024);
    if (!/^\s*#\s*define\s+__GBDK_VERSION\s+450(?:\s|$)/mu.test(header)) throw new Error("The CLI's embedded GBDK version metadata is incompatible.");
    detected(result, metadata.version, `CLI artifact, package version, engine metadata, VM header and platform GBDK resources: ${packagePath}; --version was not run.`);
  });
}

async function detectGbdk(result, context) {
  const root = context.roots.toolchainRoot;
  result.path = path.join(root, ".local", "gbdk", "bin", context.platform === "win32" ? "lcc.exe" : "lcc");
  await detectFile(result, async () => {
    await regular(result.path, root, true, context.platform);
    const header = path.join(root, ".local", "gbdk", "include", "gbdk", "version.h");
    const source = await boundedText(header, root, 64 * 1024);
    const encoded = source.match(/^\s*#\s*define\s+__GBDK_VERSION\s+(\d{3})(?:\s|$)/mu)?.[1];
    if (!encoded) throw new Error("GBDK version header is missing its version definition.");
    detected(result, encoded.split("").join("."), `GBDK version header: ${header}; lcc was not executed.`);
  });
}

async function pythonMetadata(result, context) {
  const windows = context.platform === "win32";
  result.path = absolute(context.pythonPath ?? path.join(context.roots.toolchainRoot, ".local", "pyboy-venv", windows ? "Scripts" : "bin", windows ? "python.exe" : "python"), "Python executable");
  context.pythonPath = result.path;
  context.venvRoot = path.dirname(path.dirname(result.path));
  await detectFile(result, async () => {
    // A venv executable normally links to its base interpreter. Keep the venv's
    // invocation path: replacing it with realpath would change package selection.
    await regular(result.path, undefined, true, context.platform);
    const configPath = path.join(context.venvRoot, "pyvenv.cfg");
    const config = await boundedText(configPath, context.venvRoot, 64 * 1024);
    if (/^\s*include-system-site-packages\s*=\s*true\s*$/imu.test(config)) throw new Error("The configured Python environment includes system site packages.");
    // uv may record only major.minor; preserve that precision in the report.
    const detectedVersion = config.match(/^\s*version(?:_info)?\s*=\s*(\d+\.\d+(?:\.\d+)?)(?:\.(?:alpha|beta|candidate|final)\.\d+)?[\t ]*$/imu)?.[1];
    if (!detectedVersion) throw new Error("Python version is not recorded in pyvenv.cfg.");
    context.pythonVersion = detectedVersion;
    detected(result, detectedVersion, `Python virtual-environment version: ${configPath}; no interpreter launched.`);
  });
}

async function distributionMetadata(result, context) {
  const id = result.id;
  const minor = (context.pythonVersion ?? DEPENDENCY_CATALOG.python.requiredVersion).split(".").slice(0, 2).join(".");
  const site = context.platform === "win32" ? path.join(context.venvRoot, "Lib", "site-packages")
    : path.join(context.venvRoot, "lib", `python${minor}`, "site-packages");
  result.path = site;
  await detectFile(result, async () => {
    const matches = [];
    const entries = await opendir(site);
    let count = 0;
    for await (const entry of entries) {
      if (++count > 4096) throw new Error("Python metadata directory exceeds its entry bound.");
      if (entry.name.toLowerCase().startsWith(`${id}-`) && entry.name.endsWith(".dist-info")) matches.push(entry.name);
    }
    if (matches.length === 0) { result.status = "missing"; result.detectionEvidence.push(`No ${id} dist-info metadata in the selected environment.`); return; }
    if (matches.length !== 1) throw new Error(`Multiple ${id} distributions make package selection ambiguous.`);
    const metadataPath = path.join(site, matches[0], "METADATA");
    result.path = metadataPath;
    const source = await boundedText(metadataPath, context.venvRoot, 256 * 1024);
    if (source.match(/^Name:\s*(.+)$/imu)?.[1]?.trim().toLowerCase() !== id) throw new Error(`Unexpected ${id} distribution name.`);
    const detectedVersion = source.match(/^Version:\s*(.+)$/imu)?.[1]?.trim();
    await regular(path.join(site, id === "pillow" ? "PIL" : "pyboy", "__init__.py"), context.venvRoot);
    detected(result, detectedVersion, `Distribution METADATA and module entrypoint: ${metadataPath}; no imports or ABI checks performed.`);
  });
}

async function detectDesktop(result, context) {
  const root = context.roots.toolchainRoot;
  const apps = path.join(root, ".local", "apps");
  const windows = path.join(apps, "GB Studio");
  const candidates = context.platform === "darwin" ? [path.join(apps, "GB Studio.app")]
    : context.platform === "win32" ? [path.join(windows, "GB Studio-win32-x64", "gb-studio.exe"), path.join(windows, "gb-studio.exe"), path.join(windows, "GB Studio.exe"), path.join(windows, "win-unpacked", "GB Studio.exe"), path.join(windows, "app", "GB Studio.exe")]
      : [path.join(apps, "gb-studio-linux.AppImage"), path.join(apps, "gb-studio-linux-arm64.AppImage")];
  result.path = await firstPresent(candidates);
  await detectFile(result, async () => {
    if (context.platform === "darwin") {
      const plist = path.join(result.path, "Contents", "Info.plist");
      const contents = await boundedText(plist, root, 256 * 1024);
      const value = contents.match(/<key>CFBundleShortVersionString<\/key>\s*<string>([^<]+)<\/string>/u)?.[1];
      if (!value) throw new Error("Desktop version is not available as passive plist metadata.");
      detected(result, value, `Desktop Info.plist: ${plist}; application was not launched.`);
    } else {
      await regular(result.path, root);
      const metadata = context.platform === "win32" ? await optionalObject(path.join(path.dirname(result.path), "resources", "app", "package.json"), root) : undefined;
      if (metadata) { detected(result, metadata.version, "Extracted desktop package version; application was not launched."); return; }
      const receipt = await optionalObject(path.join(context.roots.setupRoot, "receipts", "desktop.json"), context.roots.setupRoot);
      const expectedDestination = context.platform === "win32" ? windows : result.path;
      if (!receipt || receipt.owner !== "codex-gb-studio-setup" || receipt.id !== "desktop" || receipt.destination !== expectedDestination || !["prepared", "ready"].includes(receipt.status)) throw new Error("Desktop exists but has no compatible passive version metadata; app.asar is not a CLI.");
      detected(result, receipt.version, "Managed desktop receipt version; application was not launched.");
    }
  });
}

async function detectUv(result, context) {
  result.path = path.join(context.roots.setupRoot, "uv", `${context.platform}-${context.arch}`, context.platform === "win32" ? "uv.exe" : "uv");
  await detectFile(result, async () => {
    await regular(result.path, context.roots.setupRoot, true, context.platform);
    const receipt = await object(path.join(context.roots.setupRoot, "receipts", "uv.json"), context.roots.setupRoot);
    if (receipt.owner !== "codex-gb-studio-setup" || receipt.id !== "uv" || receipt.destination !== path.dirname(result.path) || !["prepared", "ready"].includes(receipt.status)) throw new Error("uv receipt does not match the managed executable directory.");
    detected(result, receipt.version, "Declared version from the owned uv setup receipt; uv was not executed. Installer prerequisite only.");
  });
}

/** Inspect bounded local metadata only. Never launch/import a dependency or write files. */
export async function detectDependencies(options = {}) {
  const environment = { ...(options.environment ?? process.env) };
  const platform = options.platform ?? process.platform;
  const arch = options.arch ?? process.arch;
  const requestedTasks = [...new Set(options.tasks ?? options.requestedTasks ?? DEFAULT_TASKS)];
  if (requestedTasks.length === 0 || requestedTasks.some((task) => !DEPENDENCY_TASKS.includes(task))) throw new TypeError("Select at least one supported dependency task.");
  const resolved = await resolveRoots(options, environment, platform);
  const context = { ...resolved, environment, platform, arch };
  const components = COMPONENTS.map((id) => component(id, resolved.roots,
    `${resolved.provenance[id === "runtime" ? "runtime" : id === "node" ? "node" : ["python", "pyboy", "pillow"].includes(id) ? "python" : id === "uv" ? "setup" : "toolchain"]}; expected source: ${DEPENDENCY_CATALOG[id].source}`, platform));
  const byId = Object.fromEntries(components.map((item) => [item.id, item]));
  await detectNode(byId.node, context);
  await detectRuntime(byId.runtime, context);
  await detectCli(byId.cli, context);
  await detectGbdk(byId.gbdk, context);
  await pythonMetadata(byId.python, context);
  await distributionMetadata(byId.pyboy, context);
  await distributionMetadata(byId.pillow, context);
  await detectDesktop(byId.desktop, context);
  await detectUv(byId.uv, context);
  if (!SUPPORTED_SETUP_PLATFORMS.includes(`${platform}-${arch}`)) {
    for (const item of components) {
      item.status = "unsupported";
      item.detectionEvidence.push(`Managed setup does not support ${platform}-${arch}.`);
      item.nextSteps = [`Use a supported setup platform: ${SUPPORTED_SETUP_PLATFORMS.join(", ")}.`];
    }
  }
  const tasks = Object.fromEntries(DEPENDENCY_TASKS.map((name) => [name, {
    ready: TASK_COMPONENTS[name].every((id) => byId[id].status === "ready"), requiredComponents: [...TASK_COMPONENTS[name]],
  }]));
  const report = { schemaVersion: 1, mode: "detect", readinessBasis: "compatible-metadata", platform, arch,
    roots: resolved.roots, requestedTasks, components, tasks, ready: requestedTasks.every((name) => tasks[name].ready),
    nextSteps: [...resolved.receiptEvidence, ...new Set(requestedTasks.flatMap((name) => tasks[name].requiredComponents.flatMap((id) => byId[id].nextSteps)))],
  };
  context.components = Object.fromEntries(components.map((item) => [item.id, { ...item }]));
  context.roots = { ...resolved.roots };
  contexts.set(report, context);
  return report;
}

async function probeEnvironment(context) {
  const output = {};
  const aliases = new Map();
  for (const [key, value] of Object.entries(context.environment)) {
    if (value === undefined) continue;
    const normalized = key.toUpperCase();
    if (context.platform === "win32") {
      if (aliases.has(normalized) && aliases.get(normalized) !== value) throw new TypeError(`Conflicting case-insensitive Windows environment values exist for ${key}.`);
      aliases.set(normalized, value);
    }
    if (UNSAFE_ENV.has(normalized) || normalized.startsWith("UV_") || normalized.startsWith("DYLD_") || normalized === "COMSPEC") continue;
    output[key] = value;
  }
  if (context.platform === "win32") {
    const systemRoot = environmentValue(context.environment, "SystemRoot", "win32");
    if (!systemRoot) throw new TypeError("Windows probes require a trusted absolute SystemRoot.");
    const requested = absolute(systemRoot, "Windows SystemRoot");
    const canonical = await realpath(requested);
    if (canonical !== requested || (await lstat(requested)).isSymbolicLink()) throw new TypeError("Windows SystemRoot must not resolve through a redirected path.");
    const command = path.join(canonical, "System32", "cmd.exe");
    if (await regular(command, canonical) !== command || (await lstat(command)).isSymbolicLink()) throw new TypeError("Windows SystemRoot requires a real System32/cmd.exe.");
    for (const key of Object.keys(output)) if (key.toUpperCase() === "SYSTEMROOT") delete output[key];
    output.SystemRoot = canonical;
    output.COMSPEC = command;
  }
  return output;
}

function aborted(signal) {
  if (signal?.aborted) throw signal.reason instanceof Error ? signal.reason : new DOMException("Dependency probe was cancelled.", "AbortError");
}

async function executeProbe(command, args, options) {
  const capture = (value) => ({
    executed: Number.isSafeInteger(value.pid),
    exitObserved: value.exitObserved === true,
    cleanupErrors: (value.cleanupErrors ?? []).map((message) => String(message).slice(0, 512)).slice(0, 8),
    output: (String(value.stdout ?? "") + "\n" + String(value.stderr ?? "")).trim().slice(-4096),
  });
  try {
    // Only the import-safe command/hash helpers are shared with setup. In
    // particular, setupEnvironment (which prepares storage) is never called.
    const result = await runSetupCommand(command, args, { ...options, label: "Dependency probe",
      timeoutMs: 10_000, maxOutputBytes: MAX_PROBE_BYTES,
      platform: os.type() === "Windows_NT" ? "win32" : os.type() === "Darwin" ? "darwin" : "linux",
    });
    return { status: result.outputTruncated ? "fail" : "pass", ...capture(result),
      ...(result.outputTruncated ? { error: "Probe output exceeded its bounded capture." } : {}) };
  } catch (error) {
    const details = error.details ?? {};
    const captured = capture(details);
    if (options.signal?.aborted) {
      const reason = options.signal.reason instanceof Error ? options.signal.reason : new DOMException("Dependency probe was cancelled.", "AbortError");
      const cleanup = captured.cleanupErrors.length ? " Owned subprocess cleanup could not be fully confirmed." : "";
      throw Object.assign(new Error(reason.message + cleanup, { cause: error }), {
        name: reason.name, code: "ABORT_ERR", exitObserved: captured.exitObserved, cleanupErrors: captured.cleanupErrors,
      });
    }
    return { status: "fail", ...captured,
      error: String(details.exitCode ?? details.signal ?? error.code ?? error.message).slice(0, 512) };
  }
}

/** Execute only named, bounded health checks for a report produced in this process. */
export async function probeDependencies(report, { probes, signal } = {}) {
  const context = contexts.get(report);
  if (!context) throw new TypeError("Probe a fresh report returned by detectDependencies; serialized reports are inspection-only.");
  if (!Array.isArray(probes) || probes.length === 0 || probes.length > DEPENDENCY_PROBES.length || probes.some((name) => !DEPENDENCY_PROBES.includes(name)) || new Set(probes).size !== probes.length) {
    throw new TypeError("Select one or more distinct named dependency probes.");
  }
  aborted(signal);
  let environment;
  let environmentError;
  try { environment = await probeEnvironment(context); } catch (error) { aborted(signal); environmentError = error; }
  const results = [];
  const components = report.components.map((item) => ({ ...item }));
  const specs = {
    "cli-version": { ids: ["cli"], required: ["node", "cli"], command: context.nodeExecutable, args: [context.components.cli.path, "--version"] },
    "gbdk-version": { ids: ["gbdk"], required: ["gbdk"], command: context.components.gbdk.path, args: ["-v"],
      cwd: path.dirname(path.dirname(context.components.gbdk.path)) },
    "emulator-import": { ids: ["python", "pyboy", "pillow"], required: ["python", "pyboy", "pillow"], command: context.pythonPath,
      args: ["-I", "-B", "-c", "import pyboy, PIL; from importlib.metadata import version; print('PyBoy ' + version('pyboy') + ', Pillow ' + version('pillow'))"] },
  };
  for (const name of probes) {
    aborted(signal);
    const spec = specs[name];
    const unavailable = spec.required.filter((id) => context.components[id].status !== "ready");
    let result;
    if (unavailable.length) {
      result = { status: "fail", error: `Compatible metadata is required before execution: ${unavailable.join(", ")}.`, output: "", executed: false };
    } else if (environmentError) {
      result = { status: "fail", error: String(environmentError.message ?? environmentError).slice(0, 512), output: "", executed: false };
    } else {
      try {
        if (await realpath(context.roots.toolchainRoot) !== context.roots.toolchainRoot) throw new Error("The selected toolchain directory was redirected after detection.");
        await regular(spec.command, name === "gbdk-version" ? context.roots.toolchainRoot : undefined, true, context.platform);
        if (name === "cli-version") await regular(context.components.cli.path, context.roots.toolchainRoot);
        const env = { ...environment };
        const certificateAuthority = environmentValue(context.environment, "NODE_EXTRA_CA_CERTS", context.platform);
        if (name !== "emulator-import" && certificateAuthority !== undefined) env.NODE_EXTRA_CA_CERTS = certificateAuthority;
        // GBDK 4.5's version probe expands argv[0] into a small native prefix
        // buffer. A fixed relative GBDKDIR avoids path-length-dependent crashes;
        // this does not change the standalone C build invocation.
        if (name === "gbdk-version") {
          for (const key of Object.keys(env)) if (key.toUpperCase() === "GBDKDIR") delete env[key];
          env.GBDKDIR = "./";
        }
        const cwd = spec.cwd ?? context.roots.toolchainRoot;
        if (!within(context.roots.toolchainRoot, await realpath(cwd))) throw new Error("The probe working directory resolves outside its selected toolchain root.");
        result = await executeProbe(spec.command, spec.args, { cwd, env, signal });
      } catch (error) {
        if (signal?.aborted && (error.name === "AbortError" || error.code === "ABORT_ERR")) throw error;
        aborted(signal);
        result = { status: "fail", error: String(error.message ?? error).slice(0, 512), output: "", executed: false };
      }
      if (result.status === "pass" && name === "cli-version" && !result.output.split(/\s+/u).some((token) => version(token) === DEPENDENCY_CATALOG.cli.requiredVersion)) {
        result.status = "fail"; result.error = "CLI --version did not report the required version.";
      }
      if (result.status === "pass" && name === "emulator-import" && !/PyBoy 2\.7\.0, Pillow (?:11|12)\.\d+\.\d+/u.test(result.output)) {
        result.status = "fail"; result.error = "Emulator imports did not report compatible package versions.";
      }
    }
    results.push({ name, components: spec.ids, ...result });
    for (const item of components) if (spec.ids.includes(item.id)) {
      item.probeStatus = result.status;
      if (result.status === "fail") item.nextSteps = [...new Set([...item.nextSteps, nextStep(item.id, context.roots, context.platform)])];
    }
  }
  const probed = { ...report, mode: "probe", components, probeResults: results, probesPassed: results.every((result) => result.status === "pass"),
    nextSteps: [...new Set([...report.nextSteps, ...components.filter((item) => item.probeStatus === "fail").flatMap((item) => item.nextSteps)])] };
  contexts.set(probed, context);
  return probed;
}

SHA-256: 1a5e6e1f443f5ee3ca58000742f2bc6cee81eb2c60fa9c9cdfb780f85296ddeb