Files
renovate/lib/modules/manager/util.ts
Sebastian Poxhofer 4220c0fe36 feat(manager): share the .npmrc host-rule wrapper between bun, deno and npm (#46131)
* refactor(manager): share the .npmrc host-rule wrapper between bun, deno and npm

Adds `withNpmrcHostRules()` to the npm manager utils: it injects the host-rule lines into a directory's `.npmrc`, runs the callback, and restores the original `.npmrc` from a `finally` block. bun, deno, the corepack `updateArtifacts()` and the three lock file loops in `post-update/index.ts` now share it instead of repeating getNpmrcContent / updateNpmrcContent / resetNpmrcContent.

Adds `readUpdatedBinaryLockFile()` to the shared manager utils for the `Buffer.compare` plus file-addition block that bun and deno both carried.

Behaviour changes:

- bun and deno now restore `.npmrc` when `exec()` throws. The reset previously ran only after a successful install, so a failed lock file update left the injected registry credentials in the working tree. Covered by "restores .npmrc when the install fails" in `bun/artifacts.spec.ts` and `deno/artifacts.spec.ts`.
- The npm, yarn and pnpm loops in `post-update/index.ts` now restore `.npmrc` when the loop body throws an `ExternalHostError`, for the same reason.
- deno reads `config.isLockFileMaintenance` instead of deriving the flag from `config.updateType === 'lockFileMaintenance'`. Grouped branches overwrite `updateType` while `isLockFileMaintenance` is set explicitly, so the flag is the reliable source. The deno specs were updated to set it.

* refactor(manager): let updateLockFile() compare binary lock files

A Buffer as the existing content makes the skeleton read, compare and
return the new lock file as bytes, so bun and deno use it instead of a
separate readUpdatedBinaryLockFile() helper.

* refactor(manager/devbox): update the lock file through updateLockFile()

devbox kept the same Buffer compare as bun and deno; the trace log before
returning the lock file goes away with it.

* fix(manager/devbox): type the command list explicitly

The list is now only read inside the run() closure, where TypeScript
cannot resolve an evolving array type, so tsc reported an implicit any.
2026-09-18 09:30:27 +00:00

229 lines
6.7 KiB
TypeScript

import { isNonEmptyString } from '@sindresorhus/is';
import { logger } from '../../logger/index.ts';
import type { MaybePromise } from '../../types/index.ts';
import { detectPlatform } from '../../util/common.ts';
import type { ExecError } from '../../util/exec/exec-error.ts';
import type { ConstraintName } from '../../util/exec/types.ts';
import {
deleteLocalFile,
readLocalFile,
writeLocalFile,
} from '../../util/fs/index.ts';
import type { FileChange } from '../../util/git/types.ts';
import { parseGitUrl } from '../../util/git/url.ts';
import { GitRefsDatasource } from '../datasource/git-refs/index.ts';
import { GitTagsDatasource } from '../datasource/git-tags/index.ts';
import { GithubTagsDatasource } from '../datasource/github-tags/index.ts';
import { GitlabTagsDatasource } from '../datasource/gitlab-tags/index.ts';
import type {
PackageDependency,
ToolConstraintsConfig,
UpdateArtifactsResult,
UpdateLockFileConfig,
} from './types.ts';
export function applyGitSource(
dep: PackageDependency,
git: string,
rev: string | undefined,
tag: string | undefined,
branch: string | undefined,
): void {
if (tag) {
const platform = detectPlatform(git);
if (platform === 'github' || platform === 'gitlab') {
dep.datasource =
platform === 'github'
? GithubTagsDatasource.id
: GitlabTagsDatasource.id;
const { host, full_name } = parseGitUrl(git);
// Always use HTTPS for GitHub/GitLab API endpoints, even if the git URL protocol is SSH.
dep.registryUrls = [`https://${host}`];
dep.packageName = full_name;
} else {
dep.datasource = GitTagsDatasource.id;
dep.packageName = git;
}
dep.currentValue = tag;
dep.skipReason = undefined;
} else if (rev) {
dep.datasource = GitRefsDatasource.id;
dep.packageName = git;
dep.currentDigest = rev;
dep.replaceString = rev;
dep.skipReason = undefined;
} else {
dep.datasource = GitRefsDatasource.id;
dep.packageName = git;
dep.currentValue = branch;
dep.skipReason = branch ? 'git-dependency' : 'unspecified-version';
}
}
/**
* Given an {@link ExecError}, retrieve the message which will be used for an {@link ArtifactError}.
*
* An `ExecError` always carries a `stderr` property, so nullish coalescing would keep an empty string and render an artifact error with no message at all.
*
*/
export function artifactErrorMessageFromExecError(
err: Partial<ExecError>,
message: string,
): string {
if (err.stderr?.trim()) {
return err.stderr;
}
if (err.stdout?.trim()) {
return err.stdout;
}
return message;
}
/**
* Wraps {@link FileChange}s, e.g. the result of `collectFileChanges()`, into the
* result shape returned by `updateArtifacts()`.
*/
export function fileChangesToArtifactResults(
changes: FileChange[],
): UpdateArtifactsResult[] {
return changes.map((file) => ({ file }));
}
/**
* Resolve the constraint for a tool that `updateArtifacts()` has to run.
*
* The precedence is:
*
* 1. `constraints`, because the user asked for it explicitly
* 2. `derive`, because it reads the package files as the branch changed them,
* so it can be newer than what extraction saw
* 3. `extractedConstraints`, as collected while extracting the base branch
*
* Managers that have no way to derive the constraint at artifact time can omit
* `derive`. An empty string counts as "not set" at every step, so it never
* shadows a value further down the list.
*
* This is the only place that reads `constraints` and `extractedConstraints`
* from the config; the `renovate/prefer-resolve-tool-constraint` lint rule
* keeps managers from reading them directly.
*/
export async function resolveToolConstraint(
config: ToolConstraintsConfig,
toolName: ConstraintName,
derive?: () => MaybePromise<string | null | undefined>,
): Promise<string | undefined> {
const configured = config.constraints?.[toolName];
if (isNonEmptyString(configured)) {
return configured;
}
const derived = await derive?.();
if (isNonEmptyString(derived)) {
return derived;
}
const extracted = config.extractedConstraints?.[toolName];
return isNonEmptyString(extracted) ? extracted : undefined;
}
/**
* The result which reports `path` as created or updated.
*/
export function fileAddition(
path: string,
contents: string | Buffer | null,
): UpdateArtifactsResult {
return { file: { type: 'addition', path, contents } };
}
/**
* The result which reports a failed artifact update to the user.
*/
export function artifactError(
fileName: string | undefined,
stderr: string,
): UpdateArtifactsResult {
return { artifactError: { fileName, stderr } };
}
/**
* The result for an artifact update which threw, using the most informative
* output the error carries. Callers are expected to have rethrown
* `TEMPORARY_ERROR` and logged the error before calling this.
*/
export function artifactErrorResult(
fileName: string | undefined,
err: Error & Partial<ExecError>,
): UpdateArtifactsResult[] {
return [
artifactError(
fileName,
artifactErrorMessageFromExecError(err, err.message),
),
];
}
/**
* The skeleton shared by the managers which regenerate a single lock file:
* rewrite the package file, optionally drop the lock file, run the package
* manager and return the lock file when its content changed.
*
* Errors from `run()` are not handled here - callers keep their own logging and
* pass the error to {@link artifactErrorResult}.
*/
export async function updateLockFile({
lockFileName,
existingLockFileContent,
packageFile,
deleteLockFile,
run,
}: UpdateLockFileConfig): Promise<UpdateArtifactsResult[] | null> {
if (packageFile) {
await writeLocalFile(packageFile.path, packageFile.contents);
}
if (deleteLockFile) {
await deleteLocalFile(lockFileName);
}
await run();
// A Buffer as the existing content means the lock file may be binary, so the
// new content is read and compared as bytes as well.
const newLockFileContent = await readLockFile(
lockFileName,
Buffer.isBuffer(existingLockFileContent),
);
if (!newLockFileContent) {
logger.debug(`No ${lockFileName} found`);
return null;
}
if (isSameContent(existingLockFileContent, newLockFileContent)) {
logger.debug(`${lockFileName} is unchanged`);
return null;
}
return [fileAddition(lockFileName, newLockFileContent)];
}
function readLockFile(
lockFileName: string,
asBuffer: boolean,
): Promise<string | Buffer | null> {
return asBuffer
? readLocalFile(lockFileName)
: readLocalFile(lockFileName, 'utf8');
}
function isSameContent(
before: string | Buffer | null,
after: string | Buffer,
): boolean {
if (Buffer.isBuffer(before) && Buffer.isBuffer(after)) {
return before.equals(after);
}
return before === after;
}