From 3628f24f39a0109711cbd6c4b94182cd2588dd49 Mon Sep 17 00:00:00 2001 From: silverwind Date: Mon, 4 May 2026 12:04:37 +0200 Subject: [PATCH 1/2] Resolve config per-file when -f is used Previously loadConfig was always called with cwd(), so running `updates -f ../other -M docker` from a different cwd would never see `../other/updates.config.ts` and silently ignore its pin/include/exclude. Changes: - loadConfig now walks up from its argument to find the nearest updates.config.*. Both the walk-up (findCache by startDir) and the final config (configCache by resolved configDir) are memoized so per-dep loadConfig calls in the docker/actions handlers don't rescan the filesystem. - index.ts derives a startDir from -f/positionals (first path's dir, or itself if it's a directory). Falls back to cwd when no path was given. - pin, include, and exclude are now resolved per-file: each docker and actions dep walks up from its own file's dir to find the nearest config and uses that config's pin/include/exclude. CLI/API-set values still act as cross-file overrides. - The candidate-loading logic (tryLoadInDir) uses Promise.all + outcome objects so a broken sibling updates.config.js next to a valid updates.config.ts no longer blocks the valid one. - utils/renovate.ts: loadRenovateConfig walks up to find the nearest renovate config, mirroring the updates.config behavior, and is cached by startDir. Behavior change worth calling out: when a workspace member has its own updates.config.ts, the member's pin is now authoritative for its deps instead of being merged with (and overridden by) the workspace root's pin. The closer config wins. Co-Authored-By: Claude (Opus 4.7) --- api.ts | 42 +++++++++++++------ config.ts | 101 ++++++++++++++++++++++++++++++---------------- index.ts | 23 +++++++++-- utils/renovate.ts | 42 ++++++++++++++----- 4 files changed, 148 insertions(+), 60 deletions(-) diff --git a/api.ts b/api.ts index cca57af..a3ae8a6 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 = {}; @@ -370,10 +370,10 @@ export async function updates(opts: UpdatesOptions = {}): Promise { 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,11 +387,11 @@ export async function updates(opts: UpdatesOptions = {}): Promise { return []; } - function collectDockerRefs(content: string, relPath: string, regexes: Array): void { + function collectDockerRefs(content: string, relPath: string, regexes: Array, fileInclude: Set, fileExclude: Set): 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", fileInclude, fileExclude, "docker")) continue; const key = `${relPath}${fieldSep}${ref.fullImage}`; if (deps.docker[key]) continue; const parsed = parseDockerTag(ref.tag); @@ -402,6 +402,13 @@ export async function updates(opts: UpdatesOptions = {}): Promise { } } + async function resolveFileFilters(fileDir: string): Promise<{include: Set, exclude: Set}> { + 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}; + } + for (const file of files) { if (isWorkflowFile(file)) { const actionsEnabled = enabledModes.has("actions"); @@ -411,12 +418,13 @@ export async function updates(opts: UpdatesOptions = {}): Promise { const content = fileContents.get(file)!; const relPath = toRelPath(file); wfData[relPath] = {absPath: file, content}; + const fileFilters = await resolveFileFilters(dirname(file)); 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", fileFilters.include, fileFilters.exclude, "actions")) continue; const key = `${relPath}${fieldSep}${action.name}`; if (deps.actions[key]) continue; deps.actions[key] = {old: action.ref} as Dep; @@ -426,7 +434,7 @@ export async function updates(opts: UpdatesOptions = {}): Promise { if (dockerEnabled) { dockerFileData[relPath] = {absPath: file, content, fileType: "workflow"}; - collectDockerRefs(content, relPath, [composeImageRe, workflowContainerRe, workflowDockerUsesRe]); + collectDockerRefs(content, relPath, [composeImageRe, workflowContainerRe, workflowDockerUsesRe], fileFilters.include, fileFilters.exclude); } continue; } @@ -439,7 +447,8 @@ export async function updates(opts: UpdatesOptions = {}): Promise { const relPath = toRelPath(file); const fileType = isDockerfile(filename) ? "dockerfile" : "compose"; dockerFileData[relPath] = {absPath: file, content, fileType}; - collectDockerRefs(content, relPath, [getExtractionRegex(filename)]); + const fileFilters = await resolveFileFilters(dirname(file)); + collectDockerRefs(content, relPath, [getExtractionRegex(filename)], fileFilters.include, fileFilters.exclude); continue; } @@ -904,12 +913,16 @@ export async function updates(opts: UpdatesOptions = {}): Promise { await pMap(infos, async ({key, host, ref, name: actionName, isHash}) => { const dep = deps.actions[key]; const infoUrl = `https://${host || "github.com"}/${owner}/${repo}`; + const relPath = key.split(fieldSep)[0]; + const fileDir = dirname(wfData[relPath].absPath); + const filePin = (await loadConfig(fileDir)).pin ?? {}; + 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 +950,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 +996,10 @@ 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 relPath = info.key.split(fieldSep)[0]; + const fileDir = dirname(dockerFileData[relPath].absPath); + const filePin = (await loadConfig(fileDir)).pin ?? {}; + const pinnedRange = globalPin[info.fullImage] ?? 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..62029e9 100644 --- a/config.ts +++ b/config.ts @@ -1,4 +1,4 @@ -import {join} from "node:path"; +import {join, dirname} from "node:path"; import {pathToFileURL} from "node:url"; import {access} from "node:fs/promises"; import type {ParseArgsOptionsConfig} from "node:util"; @@ -171,40 +171,73 @@ 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}; +const findCache = new Map>(); +const configCache = new Map>(); + +type LoadOutcome = + | {ok: true, value: FoundConfig} | + {ok: false, kind: "missing"} | + {ok: false, kind: "parse", filename: string, message: string}; + +// 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 (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 outcomes = await Promise.all(exts.map(async (ext): Promise => { + const filename = `updates.config.${ext}`; + const fullPath = join(dir, filename); + try { + await access(fullPath); + } catch { + return {ok: false, kind: "missing"}; + } + try { + const mod = await import(pathToFileURL(fullPath).href); + return {ok: true, value: {configDir: dir, filename, default: mod.default ?? {}}}; + } catch (err: any) { + return {ok: false, kind: "parse", filename, message: err?.message ?? String(err)}; + } + })); + for (const o of outcomes) if (o.ok) return o.value; + for (const o of outcomes) { + if (!o.ok && o.kind === "parse") { + throw new Error(`Unable to parse config file ${o.filename}: ${o.message}`); } } + return null; +} + +async function findConfigUp(startDir: string): Promise { + let cached = findCache.get(startDir); + if (cached) return cached; + cached = (async () => { + let dir = startDir; + while (true) { + const found = await tryLoadInDir(dir); + if (found) return found; + const parent = dirname(dir); + if (parent === dir) return null; + dir = parent; + } + })(); + findCache.set(startDir, cached); + return cached; +} - const {loadRenovateConfig} = await import("./utils/renovate.ts"); - const renovateConfig = await loadRenovateConfig(rootDir, config.inherit?.renovate); - return {...renovateConfig, ...config}; +export async function loadConfig(startDir: string): Promise { + const found = await findConfigUp(startDir); + const cacheKey = found ? `dir:${found.configDir}` : `none:${startDir}`; + let cached = configCache.get(cacheKey); + if (cached) return cached; + cached = (async () => { + const raw: Config = found?.default ?? {}; + const {loadRenovateConfig} = await import("./utils/renovate.ts"); + const renovateConfig = await loadRenovateConfig(found?.configDir ?? startDir, raw.inherit?.renovate); + return {...renovateConfig, ...raw}; + })(); + configCache.set(cacheKey, cached); + return cached; } diff --git a/index.ts b/index.ts index 1469a88..985d09f 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"; @@ -134,7 +136,21 @@ async function main(): Promise { await end(); } - const fileConfig = await loadConfig(cwd()); + const fileSet = parseMixedArg(args.file); + const filesList = [...(fileSet instanceof Set ? fileSet : []), ...positionals]; + + let startDir = cwd(); + if (filesList.length) { + const first = filesList[0]; + const abs = isAbsolute(first) ? first : resolve(cwd(), first); + try { + startDir = statSync(abs).isDirectory() ? abs : dirname(abs); + } catch { + startDir = dirname(abs); + } + } + + const fileConfig = await loadConfig(startDir); const useColor = resolveColor(fileConfig); if (useColor) { red = (text: string | number) => styleText("red", String(text)); @@ -142,6 +158,9 @@ async function main(): Promise { } const config: UpdatesOptions = {...fileConfig}; + // pin is resolved per-file in api.ts (walking up from each dep's file dir). + // Only CLI/API-set pin acts as a global override. + config.pin = undefined; if (args.json) config.json = true; if (args.verbose) config.verbose = true; if (args["no-cache"]) config.noCache = true; @@ -175,8 +194,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..17547f3 100644 --- a/utils/renovate.ts +++ b/utils/renovate.ts @@ -1,4 +1,4 @@ -import {join} from "node:path"; +import {join, dirname} from "node:path"; import {readFile} from "node:fs/promises"; import {parseJsonish} from "./json5.ts"; import {validRange} from "./semver.ts"; @@ -136,15 +136,37 @@ function normalize(raw: RenovateConfig, opts: RenovateImportOptions): Partial>(); + +async function findRenovateUp(startDir: string): Promise { + let cached = renovateCache.get(startDir); + if (cached) return cached; + cached = (async () => { + let dir = startDir; + while (true) { + const found = await readFirstExisting(dir); + if (found) { + 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 null; + return {parsed: raw as RenovateConfig, path: found.path}; + } + const parent = dirname(dir); + if (parent === dir) return null; + dir = parent; + } + })(); + renovateCache.set(startDir, cached); + return cached; +} + export async function loadRenovateConfig(rootDir: string, opts: RenovateImportOptions = {}): Promise> { - const found = await readFirstExisting(rootDir); + const found = await findRenovateUp(rootDir); if (!found) return {}; - 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); + return normalize(found.parsed, opts); } From f91c5a77cb796b0837081b11c0836971d817f59f Mon Sep 17 00:00:00 2001 From: silverwind Date: Mon, 4 May 2026 12:17:24 +0200 Subject: [PATCH 2/2] Simplify: extract walkUp + memoizeAsync, drop redundant cache, attach filePin to dep info - utils/utils.ts: add walkUp and memoizeAsync helpers. - config.ts: use both helpers; drop the second cache layer (configCache) since findCache + the renovate cache already cover the expensive work; replace the LoadOutcome union with FoundConfig | Error | null. - utils/renovate.ts: collapse findRenovateUp into a memoizeAsync(walkUp) one-liner. - api.ts: attach filePin to ActionDepInfo/DockerDepInfo at push time instead of fishing it back out of wfData/dockerFileData via relPath split. collectDockerRefs takes a single FileFilters object instead of two extra positionals. - index.ts: factor startDir derivation into deriveStartDir, drop the narrating comment. Co-Authored-By: Claude (Opus 4.7) --- api.ts | 38 +++++++++++++--------------- config.ts | 63 +++++++++++------------------------------------ index.ts | 22 +++++++---------- utils/renovate.ts | 43 +++++++++++--------------------- utils/utils.ts | 25 +++++++++++++++++++ 5 files changed, 81 insertions(+), 110 deletions(-) diff --git a/api.ts b/api.ts index a3ae8a6..de939a9 100755 --- a/api.ts +++ b/api.ts @@ -362,9 +362,9 @@ 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 = {}; @@ -387,26 +387,28 @@ export async function updates(opts: UpdatesOptions = {}): Promise { return []; } - function collectDockerRefs(content: string, relPath: string, regexes: Array, fileInclude: Set, fileExclude: Set): 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", fileInclude, fileExclude, "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 resolveFileFilters(fileDir: string): Promise<{include: Set, exclude: Set}> { + 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}; + return {include: inc, exclude: exc, pin: cfg.pin ?? {}}; } for (const file of files) { @@ -417,24 +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}; - const fileFilters = await resolveFileFilters(dirname(file)); 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", fileFilters.include, fileFilters.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], fileFilters.include, fileFilters.exclude); + collectDockerRefs(content, relPath, [composeImageRe, workflowContainerRe, workflowDockerUsesRe], filters); } continue; } @@ -446,9 +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}; - const fileFilters = await resolveFileFilters(dirname(file)); - collectDockerRefs(content, relPath, [getExtractionRegex(filename)], fileFilters.include, fileFilters.exclude); + collectDockerRefs(content, relPath, [getExtractionRegex(filename)], filters); continue; } @@ -910,12 +912,9 @@ 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 relPath = key.split(fieldSep)[0]; - const fileDir = dirname(wfData[relPath].absPath); - const filePin = (await loadConfig(fileDir)).pin ?? {}; const actionPin = globalPin[actionName] ?? filePin[actionName]; if (isHash) { @@ -996,10 +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 relPath = info.key.split(fieldSep)[0]; - const fileDir = dirname(dockerFileData[relPath].absPath); - const filePin = (await loadConfig(fileDir)).pin ?? {}; - const pinnedRange = globalPin[info.fullImage] ?? filePin[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 62029e9..67f29be 100644 --- a/config.ts +++ b/config.ts @@ -1,9 +1,9 @@ -import {join, dirname} from "node:path"; +import {join} from "node:path"; 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 */ @@ -172,72 +172,39 @@ export function configMixedToRegexes(val: boolean | Array | und } type FoundConfig = {configDir: string, filename: string, default: Config}; -const findCache = new Map>(); -const configCache = new Map>(); - -type LoadOutcome = - | {ok: true, value: FoundConfig} | - {ok: false, kind: "missing"} | - {ok: false, kind: "parse", filename: string, message: string}; // 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 (a broken sibling next to a valid one does not block -// the valid one). +// 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 outcomes = await Promise.all(exts.map(async (ext): Promise => { + 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 {ok: false, kind: "missing"}; + return null; } try { const mod = await import(pathToFileURL(fullPath).href); - return {ok: true, value: {configDir: dir, filename, default: mod.default ?? {}}}; + return {configDir: dir, filename, default: mod.default ?? {}}; } catch (err: any) { - return {ok: false, kind: "parse", filename, message: err?.message ?? String(err)}; + return new Error(`Unable to parse config file ${filename}: ${err?.message ?? err}`); } })); - for (const o of outcomes) if (o.ok) return o.value; - for (const o of outcomes) { - if (!o.ok && o.kind === "parse") { - throw new Error(`Unable to parse config file ${o.filename}: ${o.message}`); - } - } + 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; } -async function findConfigUp(startDir: string): Promise { - let cached = findCache.get(startDir); - if (cached) return cached; - cached = (async () => { - let dir = startDir; - while (true) { - const found = await tryLoadInDir(dir); - if (found) return found; - const parent = dirname(dir); - if (parent === dir) return null; - dir = parent; - } - })(); - findCache.set(startDir, cached); - return cached; -} +const findConfigUp = memoizeAsync((startDir: string) => walkUp(startDir, tryLoadInDir)); export async function loadConfig(startDir: string): Promise { const found = await findConfigUp(startDir); - const cacheKey = found ? `dir:${found.configDir}` : `none:${startDir}`; - let cached = configCache.get(cacheKey); - if (cached) return cached; - cached = (async () => { - const raw: Config = found?.default ?? {}; - const {loadRenovateConfig} = await import("./utils/renovate.ts"); - const renovateConfig = await loadRenovateConfig(found?.configDir ?? startDir, raw.inherit?.renovate); - return {...renovateConfig, ...raw}; - })(); - configCache.set(cacheKey, cached); - return cached; + const raw: Config = found?.default ?? {}; + const {loadRenovateConfig} = await import("./utils/renovate.ts"); + const renovateConfig = await loadRenovateConfig(found?.configDir ?? startDir, raw.inherit?.renovate); + return {...renovateConfig, ...raw}; } diff --git a/index.ts b/index.ts index 985d09f..d87943a 100755 --- a/index.ts +++ b/index.ts @@ -50,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; @@ -138,17 +146,7 @@ async function main(): Promise { const fileSet = parseMixedArg(args.file); const filesList = [...(fileSet instanceof Set ? fileSet : []), ...positionals]; - - let startDir = cwd(); - if (filesList.length) { - const first = filesList[0]; - const abs = isAbsolute(first) ? first : resolve(cwd(), first); - try { - startDir = statSync(abs).isDirectory() ? abs : dirname(abs); - } catch { - startDir = dirname(abs); - } - } + const startDir = deriveStartDir(filesList[0]); const fileConfig = await loadConfig(startDir); const useColor = resolveColor(fileConfig); @@ -158,8 +156,6 @@ async function main(): Promise { } const config: UpdatesOptions = {...fileConfig}; - // pin is resolved per-file in api.ts (walking up from each dep's file dir). - // Only CLI/API-set pin acts as a global override. config.pin = undefined; if (args.json) config.json = true; if (args.verbose) config.verbose = true; diff --git a/utils/renovate.ts b/utils/renovate.ts index 17547f3..95dd241 100644 --- a/utils/renovate.ts +++ b/utils/renovate.ts @@ -1,7 +1,8 @@ -import {join, dirname} from "node:path"; +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"]; @@ -137,33 +138,19 @@ function normalize(raw: RenovateConfig, opts: RenovateImportOptions): Partial>(); - -async function findRenovateUp(startDir: string): Promise { - let cached = renovateCache.get(startDir); - if (cached) return cached; - cached = (async () => { - let dir = startDir; - while (true) { - const found = await readFirstExisting(dir); - if (found) { - 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 null; - return {parsed: raw as RenovateConfig, path: found.path}; - } - const parent = dirname(dir); - if (parent === dir) return null; - dir = parent; - } - })(); - renovateCache.set(startDir, cached); - return cached; -} + +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 null; + return {parsed: raw as RenovateConfig, path: found.path}; +})); export async function loadRenovateConfig(rootDir: string, opts: RenovateImportOptions = {}): Promise> { const found = await findRenovateUp(rootDir); 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; + }; +}