diff --git a/api.ts b/api.ts index cca57af..de939a9 100755 --- a/api.ts +++ b/api.ts @@ -325,7 +325,7 @@ export async function updates(opts: UpdatesOptions = {}): Promise { const include = patternsToRegexSet(config.include ?? []); const exclude = patternsToRegexSet(config.exclude ?? []); - const configPin: Record = config.pin ?? {}; + const globalPin: Record = config.pin ?? {}; const deps: DepsByMode = {}; const maybeUrlDeps: Deps = {}; @@ -362,18 +362,18 @@ export async function updates(opts: UpdatesOptions = {}): Promise { const pnpmMemberFiles: WorkspaceMember[] = []; let pnpmWorkspaceActive = false; - type ActionDepInfo = ActionRef & {key: string, apiUrl: string}; + type ActionDepInfo = ActionRef & {key: string, apiUrl: string, filePin: Record}; const actionDepInfos: Array = []; - type DockerDepInfo = {key: string, fullImage: string, ref: DockerImageRef}; + type DockerDepInfo = {key: string, fullImage: string, ref: DockerImageRef, filePin: Record}; const dockerDepInfos: Array = []; type ModeCtx = {modeConfig: Config, projectDir: string, pin: Record}; const modeConfigs: Record = {}; async function resolveModeFilters(projectDir: string) { - const modeConfig = projectDir === cwd() ? config : await loadConfig(projectDir); - const modeInclude = modeConfig !== config && modeConfig?.include ? patternsToRegexSet([...(config.include ?? []), ...modeConfig.include]) : include; - const modeExclude = modeConfig !== config && modeConfig?.exclude ? patternsToRegexSet([...(config.exclude ?? []), ...modeConfig.exclude]) : exclude; - const pin: Record = {...modeConfig?.pin, ...configPin}; + const modeConfig = await loadConfig(projectDir); + const modeInclude = modeConfig.include?.length ? patternsToRegexSet([...(config.include ?? []), ...modeConfig.include]) : include; + const modeExclude = modeConfig.exclude?.length ? patternsToRegexSet([...(config.exclude ?? []), ...modeConfig.exclude]) : exclude; + const pin: Record = {...modeConfig.pin, ...globalPin}; return {modeConfig, modeInclude, modeExclude, pin}; } @@ -387,21 +387,30 @@ export async function updates(opts: UpdatesOptions = {}): Promise { return []; } - function collectDockerRefs(content: string, relPath: string, regexes: Array): void { + type FileFilters = {include: Set, exclude: Set, pin: Record}; + + function collectDockerRefs(content: string, relPath: string, regexes: Array, filters: FileFilters): void { deps.docker ??= {}; for (const regex of regexes) { for (const {ref} of extractDockerRefs(content, regex)) { - if (!canInclude(ref.fullImage, "docker", include, exclude, "docker")) continue; + if (!canInclude(ref.fullImage, "docker", filters.include, filters.exclude, "docker")) continue; const key = `${relPath}${fieldSep}${ref.fullImage}`; if (deps.docker[key]) continue; const parsed = parseDockerTag(ref.tag); if (!parsed) continue; deps.docker[key] = {old: parsed.version, oldOrig: ref.tag} as Dep; - dockerDepInfos.push({key, fullImage: ref.fullImage, ref}); + dockerDepInfos.push({key, fullImage: ref.fullImage, ref, filePin: filters.pin}); } } } + async function resolveFileConfig(fileDir: string): Promise { + const cfg = await loadConfig(fileDir); + const inc = cfg.include?.length ? patternsToRegexSet([...(config.include ?? []), ...cfg.include]) : include; + const exc = cfg.exclude?.length ? patternsToRegexSet([...(config.exclude ?? []), ...cfg.exclude]) : exclude; + return {include: inc, exclude: exc, pin: cfg.pin ?? {}}; + } + for (const file of files) { if (isWorkflowFile(file)) { const actionsEnabled = enabledModes.has("actions"); @@ -410,23 +419,24 @@ export async function updates(opts: UpdatesOptions = {}): Promise { const content = fileContents.get(file)!; const relPath = toRelPath(file); + const filters = await resolveFileConfig(dirname(file)); wfData[relPath] = {absPath: file, content}; if (actionsEnabled) { deps.actions ??= {}; const actions = Array.from(content.matchAll(actionsUsesRe), m => parseActionRef(m[1])).filter(a => a !== null); for (const action of actions) { - if (!canInclude(action.name, "actions", include, exclude, "actions")) continue; + if (!canInclude(action.name, "actions", filters.include, filters.exclude, "actions")) continue; const key = `${relPath}${fieldSep}${action.name}`; if (deps.actions[key]) continue; deps.actions[key] = {old: action.ref} as Dep; - actionDepInfos.push({...action, key, apiUrl: getForgeApiBaseUrl(action.host, forgeApiUrl)}); + actionDepInfos.push({...action, key, apiUrl: getForgeApiBaseUrl(action.host, forgeApiUrl), filePin: filters.pin}); } } if (dockerEnabled) { dockerFileData[relPath] = {absPath: file, content, fileType: "workflow"}; - collectDockerRefs(content, relPath, [composeImageRe, workflowContainerRe, workflowDockerUsesRe]); + collectDockerRefs(content, relPath, [composeImageRe, workflowContainerRe, workflowDockerUsesRe], filters); } continue; } @@ -438,8 +448,9 @@ export async function updates(opts: UpdatesOptions = {}): Promise { const content = fileContents.get(file)!; const relPath = toRelPath(file); const fileType = isDockerfile(filename) ? "dockerfile" : "compose"; + const filters = await resolveFileConfig(dirname(file)); dockerFileData[relPath] = {absPath: file, content, fileType}; - collectDockerRefs(content, relPath, [getExtractionRegex(filename)]); + collectDockerRefs(content, relPath, [getExtractionRegex(filename)], filters); continue; } @@ -901,15 +912,16 @@ export async function updates(opts: UpdatesOptions = {}): Promise { return null; } - await pMap(infos, async ({key, host, ref, name: actionName, isHash}) => { + await pMap(infos, async ({key, host, ref, name: actionName, isHash, filePin}) => { const dep = deps.actions[key]; const infoUrl = `https://${host || "github.com"}/${owner}/${repo}`; + const actionPin = globalPin[actionName] ?? filePin[actionName]; if (isHash) { const {usePre, useRel} = getVersionOpts(actionName); const result = await pickVersion({ range: "0.0.0", semvers: new Set(["patch", "minor", "major"]), usePre, useRel, - useGreatest: true, pinnedRange: configPin[actionName], + useGreatest: true, pinnedRange: actionPin, }); if (!result) { delete deps.actions[key]; return; } @@ -937,7 +949,7 @@ export async function updates(opts: UpdatesOptions = {}): Promise { const {usePre, useRel, semvers} = getVersionOpts(actionName); const result = await pickVersion({ range: coerced, semvers, usePre, useRel, - useGreatest: true, pinnedRange: configPin[actionName], + useGreatest: true, pinnedRange: actionPin, }); if (!result) { delete deps.actions[key]; return; } @@ -983,7 +995,7 @@ export async function updates(opts: UpdatesOptions = {}): Promise { const dep = deps.docker[info.key]; const oldTag = dep.oldOrig || dep.old; const {semvers} = getVersionOpts(info.fullImage); - const pinnedRange = configPin[info.fullImage]; + const pinnedRange = globalPin[info.fullImage] ?? info.filePin[info.fullImage]; const result = findDockerVersion( data.tags, oldTag, semvers, cooldownDays || undefined, cooldownDays ? now : undefined, diff --git a/config.ts b/config.ts index 08a37a0..67f29be 100644 --- a/config.ts +++ b/config.ts @@ -3,7 +3,7 @@ import {pathToFileURL} from "node:url"; import {access} from "node:fs/promises"; import type {ParseArgsOptionsConfig} from "node:util"; import {validRange} from "./utils/semver.ts"; -import {commaSeparatedToArray, esc} from "./utils/utils.ts"; +import {commaSeparatedToArray, esc, walkUp, memoizeAsync} from "./utils/utils.ts"; export type Config = { /** Array of dependencies to include */ @@ -171,40 +171,40 @@ export function configMixedToRegexes(val: boolean | Array | und return ret; } -export async function loadConfig(rootDir: string): Promise { - const filenames: Array = []; - for (const ext of ["js", "ts", "mjs", "mts"]) { - filenames.push(`updates.config.${ext}`); - } - let config: Config = {}; - - try { - ({default: config} = await Promise.any(filenames.map(async (filename) => { - const fullPath = join(rootDir, ...filename.split("/")); - const fileUrl = pathToFileURL(fullPath); - - try { - await access(fullPath); - } catch { - throw new Error(`File not found: ${filename}`); - } - - try { - return await import(fileUrl.href); - } catch (err: any) { - throw new Error(`Unable to parse config file ${filename}: ${err.message}`); - } - }))); - } catch (err) { - if (err instanceof AggregateError) { - const parseErrors = err.errors.filter((e: Error) => e.message.startsWith("Unable to parse")); - if (parseErrors.length > 0) { - throw parseErrors[0]; - } +type FoundConfig = {configDir: string, filename: string, default: Config}; + +// Try to load any updates.config.* in dir. Returns the first that imports +// successfully. If none imports but at least one parsed-and-failed, throws +// the first parse error so a broken sibling next to a valid one does not +// block the valid one. +async function tryLoadInDir(dir: string): Promise { + const exts = ["js", "ts", "mjs", "mts"]; + const results = await Promise.all(exts.map(async (ext): Promise => { + const filename = `updates.config.${ext}`; + const fullPath = join(dir, filename); + try { + await access(fullPath); + } catch { + return null; } - } + try { + const mod = await import(pathToFileURL(fullPath).href); + return {configDir: dir, filename, default: mod.default ?? {}}; + } catch (err: any) { + return new Error(`Unable to parse config file ${filename}: ${err?.message ?? err}`); + } + })); + for (const r of results) if (r && !(r instanceof Error)) return r; + for (const r of results) if (r instanceof Error) throw r; + return null; +} + +const findConfigUp = memoizeAsync((startDir: string) => walkUp(startDir, tryLoadInDir)); +export async function loadConfig(startDir: string): Promise { + const found = await findConfigUp(startDir); + const raw: Config = found?.default ?? {}; const {loadRenovateConfig} = await import("./utils/renovate.ts"); - const renovateConfig = await loadRenovateConfig(rootDir, config.inherit?.renovate); - return {...renovateConfig, ...config}; + const renovateConfig = await loadRenovateConfig(found?.configDir ?? startDir, raw.inherit?.renovate); + return {...renovateConfig, ...raw}; } diff --git a/index.ts b/index.ts index 1469a88..d87943a 100755 --- a/index.ts +++ b/index.ts @@ -1,6 +1,8 @@ #!/usr/bin/env node import {argv, cwd, stdout, stderr, exit, platform, versions} from "node:process"; import {stripVTControlCharacters, styleText, parseArgs} from "node:util"; +import {dirname, isAbsolute, resolve} from "node:path"; +import {statSync} from "node:fs"; import {updates} from "./api.ts"; import {options, parseMixedArg, getOptionKey, parseArgList, parsePinArg, loadConfig} from "./config.ts"; import {packageVersion, fetchTimeout} from "./modes/shared.ts"; @@ -48,6 +50,14 @@ function argToConfigMixed(arg: Arg): boolean | Array | undefine let red: (text: string | number) => string = String; let green: (text: string | number) => string = String; +function deriveStartDir(first: string | undefined): string { + if (!first) return cwd(); + const abs = isAbsolute(first) ? first : resolve(cwd(), first); + let isDir = false; + try { isDir = statSync(abs).isDirectory(); } catch {} + return isDir ? abs : dirname(abs); +} + function resolveColor(fileConfig: UpdatesOptions): boolean { if (args["no-color"] === true) return false; if (args.color === true) return true; @@ -134,7 +144,11 @@ async function main(): Promise { await end(); } - const fileConfig = await loadConfig(cwd()); + const fileSet = parseMixedArg(args.file); + const filesList = [...(fileSet instanceof Set ? fileSet : []), ...positionals]; + const startDir = deriveStartDir(filesList[0]); + + const fileConfig = await loadConfig(startDir); const useColor = resolveColor(fileConfig); if (useColor) { red = (text: string | number) => styleText("red", String(text)); @@ -142,6 +156,7 @@ async function main(): Promise { } const config: UpdatesOptions = {...fileConfig}; + config.pin = undefined; if (args.json) config.json = true; if (args.verbose) config.verbose = true; if (args["no-cache"]) config.noCache = true; @@ -175,8 +190,6 @@ async function main(): Promise { const allowDowngrade = argToConfigMixed(args["allow-downgrade"]); if (allowDowngrade !== undefined) config.allowDowngrade = allowDowngrade; - const fileSet = parseMixedArg(args.file); - const filesList = [...(fileSet instanceof Set ? fileSet : []), ...positionals]; if (filesList.length) config.files = filesList; for (const key of ["forgeapi", "pypiapi", "jsrapi", "goproxy", "cargoapi", "dockerapi"] as const) { diff --git a/utils/renovate.ts b/utils/renovate.ts index 1e2ddfc..95dd241 100644 --- a/utils/renovate.ts +++ b/utils/renovate.ts @@ -2,6 +2,7 @@ import {join} from "node:path"; import {readFile} from "node:fs/promises"; import {parseJsonish} from "./json5.ts"; import {validRange} from "./semver.ts"; +import {walkUp, memoizeAsync} from "./utils.ts"; import type {Config} from "../config.ts"; const forgeDirs = [".github", ".gitea", ".forgejo", ".gitlab"]; @@ -136,15 +137,23 @@ function normalize(raw: RenovateConfig, opts: RenovateImportOptions): Partial> { - const found = await readFirstExisting(rootDir); - if (!found) return {}; +type RenovateRaw = {parsed: RenovateConfig, path: string}; + +const findRenovateUp = memoizeAsync((startDir: string) => walkUp(startDir, async (dir): Promise => { + const found = await readFirstExisting(dir); + if (!found) return null; let raw: unknown; try { raw = parseJsonish(found.text); } catch (err: any) { throw new Error(`Unable to parse renovate config ${found.path}: ${err.message}`); } - if (!raw || typeof raw !== "object") return {}; - return normalize(raw as RenovateConfig, opts); + if (!raw || typeof raw !== "object") return null; + return {parsed: raw as RenovateConfig, path: found.path}; +})); + +export async function loadRenovateConfig(rootDir: string, opts: RenovateImportOptions = {}): Promise> { + const found = await findRenovateUp(rootDir); + if (!found) return {}; + return normalize(found.parsed, opts); } diff --git a/utils/utils.ts b/utils/utils.ts index bcca453..2efc15b 100644 --- a/utils/utils.ts +++ b/utils/utils.ts @@ -1,3 +1,5 @@ +import {dirname} from "node:path"; + export function highlightDiff(a: string, b: string, colorFn: (str: string) => string): string { if (a === b) return a; let i = 0; @@ -155,3 +157,26 @@ export async function pMap(iterable: Iterable, mapper: (item: T) => Pro } export const esc = (str: string) => str.replace(/[|\\{}()[\]^$+*?.-]/g, "\\$&"); + +export async function walkUp(startDir: string, probe: (dir: string) => Promise): Promise { + let dir = startDir; + while (true) { + const found = await probe(dir); + if (found) return found; + const parent = dirname(dir); + if (parent === dir) return null; + dir = parent; + } +} + +export function memoizeAsync(fn: (k: K) => Promise): (k: K) => Promise { + const cache = new Map>(); + return (k) => { + let p = cache.get(k); + if (!p) { + p = fn(k); + cache.set(k, p); + } + return p; + }; +}