mirror of
https://github.com/morluto/rea.git
synced 2026-10-27 01:45:32 +00:00
Reuse supervised raw-byte capture with a 4 MiB aggregate budget. Drain package-manager output through physical close, reject oversized complete replies, and let verbose installs finish with explicitly bounded failure diagnostics. Preserve existing command resolution, selected environment/cwd, runtime prerequisites and signal policy.
Own human progress backpressure and final write acknowledgements without ending borrowed stderr. Continue draining after destination failures and observe outstanding write rejections through their error event.
Validation: 152 checks across 12 focused files, followed by a 44-check rerun including two additional no-deadline waiter controls; typecheck/lint, docs and discovery pass. The public oversized-metadata regression fails on 4f7b5fb60. Fixtures execute owned Node/fake npm/Bun commands; no actual package install or registry request. Native Windows/Linux and exhausting-output/OOM scenarios were not run.
Small completed CLI checks return identical replies and token estimates. Six sequential baseline/candidate tasks separate first-process and two warm repeats; no speedup, true-cold or large-install memory claim. Existing descendant cleanup and signal behavior is outside this output-bound fix.
Closes #4888.
428 lines
15 KiB
TypeScript
428 lines
15 KiB
TypeScript
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<string>;
|
|
globalRoot(): Promise<string>;
|
|
globalPrefix(): Promise<string>;
|
|
}
|
|
|
|
/** Filesystem queries used to identify a Bun global installation. */
|
|
export interface BunInstallationHost {
|
|
canonicalPath(path: string): Promise<string>;
|
|
isFile(path: string): Promise<boolean>;
|
|
}
|
|
|
|
/** 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<NodeJS.ProcessEnv>,
|
|
output: UpdateOutput = "structured",
|
|
workingDirectory: string | undefined = undefined,
|
|
): Promise<Result<string, string>> =>
|
|
runUpdateProcess(
|
|
command,
|
|
selectedEnvironment,
|
|
output,
|
|
workingDirectory,
|
|
"complete",
|
|
);
|
|
|
|
const runUpdateProcess = async (
|
|
command: readonly string[],
|
|
selectedEnvironment: Readonly<NodeJS.ProcessEnv>,
|
|
output: UpdateOutput,
|
|
workingDirectory: string | undefined,
|
|
retention: "complete" | "diagnostics",
|
|
): Promise<Result<string, string>> => {
|
|
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<NodeJS.ProcessEnv>,
|
|
): Promise<string> => {
|
|
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<NpmInstallation | undefined> => {
|
|
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<NodeJS.ProcessEnv>,
|
|
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<BunInstallation | undefined> => {
|
|
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<string, string> => {
|
|
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<string, string>,
|
|
): Result<string, ReleaseLookupFailure> => {
|
|
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<boolean> => {
|
|
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<NodeJS.ProcessEnv>,
|
|
): {
|
|
readonly environment: Readonly<NodeJS.ProcessEnv>;
|
|
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<NodeJS.ProcessEnv> = 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)}`;
|
|
}
|
|
},
|
|
};
|
|
};
|