mirror of
https://github.com/morluto/rea.git
synced 2026-10-27 18:05:45 +00:00
333 lines
12 KiB
TypeScript
333 lines
12 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 { 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,
|
|
} from "./Update.js";
|
|
import { planIntegrationMaintenance } from "./UpdateMaintenance.js";
|
|
import { UpdateCommandRunner } from "./UpdateCommandRunner.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>;
|
|
}
|
|
|
|
/** 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();
|
|
const commands = new UpdateCommandRunner();
|
|
const requireOutput = async (command: readonly string[]): Promise<string> => {
|
|
const result = await commands.runProbe(command, environment);
|
|
if (!result.ok) throw new Error(result.error);
|
|
return result.value;
|
|
};
|
|
return {
|
|
installation: async () =>
|
|
(await detectBunInstallation(
|
|
packageRoot,
|
|
bunGlobalDirectory(environment, process.platform),
|
|
{ canonicalPath: realpath, isFile },
|
|
)) ??
|
|
detectNpmInstallation(packageRoot, {
|
|
canonicalPath: realpath,
|
|
globalRoot: () => requireOutput(["npm", "root", "--global"]),
|
|
globalPrefix: () => requireOutput(["npm", "prefix", "--global"]),
|
|
}),
|
|
latestVersion: async (installation) => {
|
|
const context = packageManagerContext(installation, environment);
|
|
if (installation?.manager === "bun") {
|
|
const version = await commands.runProbe(
|
|
["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 commands.runProbe(
|
|
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 commands.runInstall(
|
|
updateInstallCommand(installation, version),
|
|
context.environment,
|
|
output,
|
|
context.workingDirectory,
|
|
);
|
|
return result.ok ? ok(undefined) : result;
|
|
},
|
|
installedVersion: (installation) =>
|
|
commands.runProbe(
|
|
[
|
|
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) => commands.runProbe(command, environment),
|
|
resources,
|
|
),
|
|
close: async () => {
|
|
const results = await Promise.allSettled([
|
|
commands.close(),
|
|
resources.close(),
|
|
]);
|
|
const failures = results.flatMap((result) =>
|
|
result.status === "rejected"
|
|
? [
|
|
result.reason instanceof Error
|
|
? result.reason.message
|
|
: String(result.reason),
|
|
]
|
|
: [],
|
|
);
|
|
return failures.length === 0
|
|
? undefined
|
|
: `Update resource cleanup failed: ${failures.join("; ")}`;
|
|
},
|
|
};
|
|
};
|