import { realpath, stat } from "node:fs/promises"; import { homeDirectoryFromEnvironment } from "../config/homeDirectory.js"; import { basename, dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import spawn from "cross-spawn"; import { ProviderProcessSupervisor } from "../process/ProviderProcess.js"; import { UpdateCommandProgress } from "./UpdateCommandProgress.js"; import { lt, valid } from "semver"; import { PRODUCT_IDENTITY } from "../identity.js"; import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; import { err, ok, type Result } from "../domain/result.js"; import { safeParseJson } from "../domain/safeJson.js"; import { updateInstallCommand, type BunInstallation, type NpmInstallation, type ReleaseLookupFailure, type UpdateHost, type UpdateInstallation, type UpdateOutput, } from "./Update.js"; import { planIntegrationMaintenance } from "./UpdateMaintenance.js"; import { withoutPackageRunnerIdentity } from "./ClientRegistrationIdentity.js"; /** npm filesystem queries used to identify the installation being updated. */ export interface NpmInstallationHost { canonicalPath(path: string): Promise; globalRoot(): Promise; globalPrefix(): Promise; } /** Filesystem queries used to identify a Bun global installation. */ export interface BunInstallationHost { canonicalPath(path: string): Promise; isFile(path: string): Promise; } /** Aggregate raw-byte retention policy for package-manager replies and diagnostics. */ export const UPDATE_COMMAND_OUTPUT_BYTES = 4 * 1024 * 1024; /** Complete metadata queries share launch and collection with diagnostic-only installs. */ export const runUpdateCommand = ( command: readonly string[], selectedEnvironment: Readonly, output: UpdateOutput = "structured", workingDirectory: string | undefined = undefined, ): Promise> => runUpdateProcess( command, selectedEnvironment, output, workingDirectory, "complete", ); const runUpdateProcess = async ( command: readonly string[], selectedEnvironment: Readonly, output: UpdateOutput, workingDirectory: string | undefined, retention: "complete" | "diagnostics", ): Promise> => { const [executable, ...args] = command; if (executable === undefined) return err("No executable supplied."); const environment = withoutPackageRunnerIdentity(selectedEnvironment); const child = spawn(executable, args, { stdio: ["ignore", "pipe", "pipe"], env: environment, windowsHide: true, ...(workingDirectory === undefined ? {} : { cwd: workingDirectory }), }); let processFailure: string | undefined; const supervisor = new ProviderProcessSupervisor( { process: child, ownsProcessLifetime: true }, { maxDiagnosticBytes: UPDATE_COMMAND_OUTPUT_BYTES, onDiagnostic: (event) => { if (event.type === "error") processFailure ??= event.message; }, }, ); const progress = output === "human" ? new UpdateCommandProgress(process.stderr) : undefined; if (progress !== undefined) { for (const stream of [child.stdout, child.stderr]) if (stream !== null) progress.pipe(stream); } try { // Drain even an oversized reply. Verbosity must not interrupt a package install. // Keep the existing launch/signal policy; this collector owns no process-tree authority. await supervisor.waitForOutputClose(); const progressFailure = await progress?.close(); const snapshot = supervisor.snapshot(); if (processFailure !== undefined) return err(processFailure); if (snapshot.outputIncomplete === true) return err("Update command output did not close completely."); if (retention === "complete" && snapshot.diagnosticTruncated === true) return err( `Update command output exceeded the ${UPDATE_COMMAND_OUTPUT_BYTES}-byte complete-output retention budget; reply was not accepted.`, ); if (snapshot.exitCode !== 0 || snapshot.signal !== null) { const detail = snapshot.stderr.text.trim() || snapshot.stdout.text.trim() || (typeof snapshot.signal === "string" ? `Process terminated by ${snapshot.signal}.` : typeof snapshot.exitCode === "number" ? `Process exited with code ${snapshot.exitCode}.` : "Update command failed without a recorded exit status."); return err( snapshot.diagnosticTruncated ? `${detail}\n[Update diagnostics truncated at ${UPDATE_COMMAND_OUTPUT_BYTES} bytes; later output omitted.]` : detail, ); } if ( retention === "complete" && (snapshot.stdout.invalidUtf8 || snapshot.stderr.invalidUtf8) ) return err( "Update command returned invalid UTF-8; complete output is unavailable.", ); if (progressFailure !== undefined) return err(`Update progress output failed: ${progressFailure.message}`); return ok(retention === "diagnostics" ? "" : snapshot.stdout.text.trim()); } finally { // Reached only after physical close; no child or inherited pipe remains pending. supervisor.dispose(); await progress?.close(); } }; const requireCommandOutput = async ( command: readonly string[], environment: Readonly, ): Promise => { const result = await runUpdateCommand(command, environment); if (!result.ok) throw new Error(result.error); return result.value; }; /** Resolve the global prefix only when it owns the running package. */ export const detectNpmInstallation = async ( packageRoot: string, host: NpmInstallationHost, ): Promise => { let canonicalPackageRoot: string; try { canonicalPackageRoot = await host.canonicalPath(packageRoot); } catch (cause: unknown) { // best-effort cleanup: optional install-location probing; unknown means not npm-managed. void cause; return undefined; } try { const npmRoot = await host.globalRoot(); const globalPackageRoot = await host.canonicalPath( resolve(npmRoot, PRODUCT_IDENTITY.packageName), ); if (canonicalPackageRoot === globalPackageRoot) { const prefix = await host.globalPrefix(); return prefix.length === 0 ? undefined : { manager: "npm", prefix, packageRoot: canonicalPackageRoot }; } } catch (cause: unknown) { // The curl installer uses a Unix prefix that may differ from npm's current default. void cause; } const nodeModules = dirname(canonicalPackageRoot); const library = dirname(nodeModules); if ( basename(canonicalPackageRoot) !== PRODUCT_IDENTITY.packageName || basename(nodeModules) !== "node_modules" || basename(library) !== "lib" ) return undefined; return { manager: "npm", prefix: dirname(library), packageRoot: canonicalPackageRoot, }; }; const nonemptyEnvironment = (value: string | undefined): string | undefined => value === undefined || value.length === 0 ? undefined : value; /** * Bun's global project directory for the selected environment, in Bun's precedence: * BUN_INSTALL_GLOBAL_DIR, then $BUN_INSTALL/install/global, then * $XDG_CACHE_HOME/.bun/install/global, then ~/.bun/install/global. * Resolve against the caller's cwd before reusing the path as a subprocess cwd. */ export const bunGlobalDirectory = ( environment: Readonly, platform: NodeJS.Platform, ): string => { const explicit = nonemptyEnvironment(environment.BUN_INSTALL_GLOBAL_DIR); if (explicit !== undefined) return resolve(explicit); const installRoot = nonemptyEnvironment(environment.BUN_INSTALL); if (installRoot !== undefined) return resolve(installRoot, "install", "global"); const cacheRoot = nonemptyEnvironment(environment.XDG_CACHE_HOME) ?? homeDirectoryFromEnvironment(environment, platform); return resolve(cacheRoot, ".bun", "install", "global"); }; /** * Identify the running package as Bun's global installation. * Bun walks up to an ancestor project when its global directory has no * package.json, so the global manifest must exist before `bun add --global` * can be trusted to update this package root. */ export const detectBunInstallation = async ( packageRoot: string, globalDirectory: string, host: BunInstallationHost, ): Promise => { try { const canonicalPackageRoot = await host.canonicalPath(packageRoot); const globalPackageRoot = await host.canonicalPath( join(globalDirectory, "node_modules", PRODUCT_IDENTITY.packageName), ); if ( canonicalPackageRoot !== globalPackageRoot || !(await host.isFile(join(globalDirectory, "package.json"))) ) return undefined; return { manager: "bun", globalDirectory, packageRoot: canonicalPackageRoot, }; } catch (cause: unknown) { // best-effort cleanup: optional install-location probing; unknown means not Bun-managed. void cause; return undefined; } }; const INVALID_NPM_RELEASE_METADATA = "Invalid npm release metadata: expected a version string or a single-element version array."; /** bun info arguments for dist-tags.latest; Bun resolves its configured registry. */ export const BUN_LATEST_VERSION_COMMAND: readonly string[] = [ "bun", "info", PRODUCT_IDENTITY.packageName, "dist-tags.latest", "--json", ]; /** npm view arguments for dist-tags.latest, scoped to the owning prefix when known. */ export const npmLatestVersionCommand = ( installation: NpmInstallation | undefined, ): readonly string[] => [ "npm", "view", "--global", ...(installation === undefined ? [] : ["--prefix", installation.prefix]), PRODUCT_IDENTITY.packageName, "dist-tags.latest", "--json", "--fetch-retries=0", "--fetch-timeout=10000", ]; /** * Read one latest version from `npm view --json`. * npm 11 emits a JSON string. npm 12 emits that string as a one-element array. */ export const parseNpmLatestVersion = ( metadata: string, ): Result => { const decoded = safeParseJson(metadata); if (!decoded.ok) return err(decoded.error); const version = readNpmLatestVersion(decoded.value); return version === undefined ? err(INVALID_NPM_RELEASE_METADATA) : ok(version); }; const readNpmLatestVersion = (value: unknown): string | undefined => { if (typeof value === "string") return nonemptyVersion(value); if (!Array.isArray(value) || value.length !== 1) return undefined; const only = value[0]; return typeof only === "string" ? nonemptyVersion(only) : undefined; }; const nonemptyVersion = (value: string): string | undefined => value.length === 0 ? undefined : value; /** Classify `npm view` or `bun info` output as a version or a lookup failure. */ export const npmReleaseLookup = ( response: Result, ): Result => { if (!response.ok) return err({ kind: "unavailable", detail: response.error }); const parsed = parseNpmLatestVersion(response.value); return parsed.ok ? ok(parsed.value) : err({ kind: "invalid-metadata", detail: parsed.error }); }; const isFile = async (path: string): Promise => { try { return (await stat(path)).isFile(); } catch (cause: unknown) { // best-effort cleanup: a missing or unreadable manifest is not an owned Bun global project. void cause; return false; } }; /** * Environment and working directory for package-manager subprocesses. * Bun commands run inside the identified global project with that directory * pinned, so the caller's current project and Bun's ancestor search are never * selected. npm keeps the caller's working directory; its prefix is explicit. */ const packageManagerContext = ( installation: UpdateInstallation | undefined, environment: Readonly, ): { readonly environment: Readonly; readonly workingDirectory: string | undefined; } => installation?.manager === "bun" ? { environment: { ...environment, BUN_INSTALL_GLOBAL_DIR: installation.globalDirectory, }, workingDirectory: installation.globalDirectory, } : { environment, workingDirectory: undefined }; /** Create package-manager, registry, verification, and read-only maintenance effects. */ export const systemUpdateHost = ( packageRoot = fileURLToPath(new URL("../..", import.meta.url)), home: string | undefined = undefined, environment: Readonly = process.env, ): UpdateHost => { const resources = new ArtifactResourceScope(); return { installation: async () => (await detectBunInstallation( packageRoot, bunGlobalDirectory(environment, process.platform), { canonicalPath: realpath, isFile }, )) ?? detectNpmInstallation(packageRoot, { canonicalPath: realpath, globalRoot: () => requireCommandOutput(["npm", "root", "--global"], environment), globalPrefix: () => requireCommandOutput(["npm", "prefix", "--global"], environment), }), latestVersion: async (installation) => { const context = packageManagerContext(installation, environment); if (installation?.manager === "bun") { const version = await runUpdateCommand( ["bun", "--version"], context.environment, "structured", context.workingDirectory, ); if (!version.ok) return err({ kind: "unavailable", detail: version.error }); const bunVersion = valid(version.value); // bun info was introduced in 1.2.17; older releases reserve the command. if (bunVersion === null || lt(bunVersion, "1.2.17")) return err({ kind: "unsupported-package-manager", detail: `REA updates for Bun global installations require Bun 1.2.17 or later; bun --version returned ${JSON.stringify(version.value)}. Upgrade Bun manually, then rerun rea update.`, }); } return npmReleaseLookup( await runUpdateCommand( installation?.manager === "bun" ? BUN_LATEST_VERSION_COMMAND : npmLatestVersionCommand(installation), context.environment, "structured", context.workingDirectory, ), ); }, installVersion: async (installation, version, output) => { const context = packageManagerContext(installation, environment); const result = await runUpdateProcess( updateInstallCommand(installation, version), context.environment, output, context.workingDirectory, "diagnostics", ); return result.ok ? ok(undefined) : result; }, installedVersion: (installation) => runUpdateCommand( [ process.execPath, join(installation.packageRoot, "scripts", "rea.mjs"), "--version", ], environment, ), planMaintenance: (installation) => planIntegrationMaintenance( home ?? homeDirectoryFromEnvironment(environment, process.platform), join(installation.packageRoot, "scripts", "rea.mjs"), environment, (command) => runUpdateCommand(command, environment), resources, ), close: async () => { try { await resources.close(); return undefined; } catch (cause: unknown) { return `Update resource cleanup failed: ${cause instanceof Error ? cause.message : String(cause)}`; } }, }; };