diff --git a/api.ts b/api.ts index f7ffb9f..e30c857 100755 --- a/api.ts +++ b/api.ts @@ -758,7 +758,7 @@ async function runUpdates(opts: UpdatesOptions): Promise { fileData[relPath] = {absPath: file, content, fileType: "workflow", workflowLines}; const yamlPath: Array<{indent: number, key: string}> = []; - for (const [lineNumber, line] of content.split("\n").entries()) { + for (const [lineNumber, line] of content.split(/\r?\n/).entries()) { if (actionsEnabled) { const parsed = parseUsesLine(line); const action = parsed && parseActionRef(parsed.value); diff --git a/cli.test.ts b/cli.test.ts index 92b7009..1ec6422 100644 --- a/cli.test.ts +++ b/cli.test.ts @@ -14,4 +14,15 @@ test("recovers swallowed short option clusters", () => { expect(args.update).toBe(true); expect(args.greatest).toEqual(["react"]); expect(positionals).toEqual(["package.json"]); + + const ordered = parseCliArgs([ + "-g", "-ulreact=*", "-l", "react=<19", + "-g", "-uT1000", "-T", "2000", + "-g", "-ufcluster.json", "-f", "explicit.json", + "package.json", + ]); + expect(ordered.args.pin).toEqual(["react=*", "react=<19"]); + expect(ordered.args.timeout).toBe("2000"); + expect(ordered.args.file).toEqual(["cluster.json", "explicit.json"]); + expect(ordered.positionals).toEqual(["package.json"]); }); diff --git a/cli.ts b/cli.ts index e5d11dc..8e54298 100644 --- a/cli.ts +++ b/cli.ts @@ -41,12 +41,21 @@ export function parseCliArgs(argv?: Array): {args: Record, ...(argv !== undefined && {args: argv}), }); - const values = result.values as Record; + const values = Object.create(null) as Record; const consumedPositionals = new Set(); let positionalsSeen = 0; for (const [index, token] of result.tokens.entries()) { if (token.kind === "positional") positionalsSeen++; - if (token.kind !== "option" || token.inlineValue || !token.value?.startsWith("-")) continue; + if (token.kind !== "option") continue; + if (token.inlineValue || !token.value?.startsWith("-")) { + if (options[token.name]?.multiple) { + const list = (values[token.name] ??= []) as Array; + list.push(token.value ?? true); + } else { + values[token.name] = token.value ?? true; + } + continue; + } const longOption = token.value.startsWith("--"); const next = result.tokens[index + 1]; const nextPositional = next?.kind === "positional" ? next.value : undefined; @@ -77,11 +86,17 @@ export function parseCliArgs(argv?: Array): {args: Record, } } } - if (!recoveredOptions.length) continue; - const swallowed = values[token.name]; - if (Array.isArray(swallowed)) { - const position = swallowed.indexOf(token.value); - if (position !== -1) swallowed.splice(position, 1); + if (!recoveredOptions.length) { + if (options[token.name]?.multiple) { + const list = (values[token.name] ??= []) as Array; + list.push(token.value); + } else { + values[token.name] = token.value; + } + continue; + } + if (options[token.name]?.multiple) { + values[token.name] ??= []; } else { values[token.name] = true; } diff --git a/index.test.ts b/index.test.ts index f04be9b..fc19bb7 100644 --- a/index.test.ts +++ b/index.test.ts @@ -60,6 +60,7 @@ const pnpmWorkspaceFile = fileURLToPath(new URL("fixtures/pnpm-workspace/pnpm-wo const testPkg = JSON.parse(readFileSync(testFile, "utf8")); const testDir = mkdtempSync(join(tmpdir(), "updates-")); +const sourceScript = fileURLToPath(new URL("index.ts", import.meta.url)); const script = fileURLToPath(new URL("dist/index.js", import.meta.url)); // An awaiting handler holds the request open, which the client sees as a response that never comes. @@ -457,6 +458,26 @@ test("version info fallback", async ({expect = globalExpect}: any = {}) => { expect(noty.age).toBeTruthy(); }); +test("version resolves the source and built package layouts", async ({expect = globalExpect}: any = {}) => { + const parentDir = join(testDir, "version-layouts"); + const sourceDir = join(parentDir, "updates"); + const distDir = join(sourceDir, "dist"); + mkdirSync(distDir, {recursive: true}); + const source = await readFile(sourceScript, "utf8"); + await Promise.all([ + writeFile(join(parentDir, "package.json"), JSON.stringify({version: "wrong"})), + writeFile(join(sourceDir, "package.json"), JSON.stringify({version: "1.2.3", type: "module"})), + writeFile(join(sourceDir, "index.ts"), source), + writeFile(join(distDir, "index.ts"), source), + ]); + + for (const entry of [join(sourceDir, "index.ts"), join(distDir, "index.ts")]) { + const {stdout, stderr} = await execFileAsync(execPath, [entry, "--version"]); + expect(stderr).toEqual(""); + expect(stdout).toBe("1.2.3\n"); + } +}); + test("empty", async ({expect = globalExpect}: any = {}) => { const {stdout, stderr} = await execFileAsync(execPath, [ script, "-n", ...apiArgs(), "-f", emptyFile, @@ -560,6 +581,7 @@ const latestRows: Array<[string, string, string, ReturnType]> = [ ["npm", "dependencies", "prismjs", dep("https://github.com/LeaVerou/prism", "1.17.1", "1.0.0")], ["npm", "dependencies", "react", dep("https://github.com/facebook/react/tree/HEAD/packages/react", "18.2", "18.0")], ["npm", "dependencies", "styled-components", dep("https://github.com/styled-components/styled-components", "4.4.1", "2.5.0-1")], + ["npm", "dependencies", "updates", dep("https://github.com/silverwind/updates", "537ccb7", "6941e05")], ["npm", "overrides", "noty", dep("https://github.com/needim/noty", "3.1.4", "3.1.0")], ["npm", "overrides", "prismjs:overrides.@babel/preset-env.prismjs", dep("https://github.com/LeaVerou/prism", "1.17.1", "1.0.0")], ["npm", "overrides", "prismjs:overrides.prismjs", dep("https://github.com/LeaVerou/prism", "1.17.1", "1.0.0")], @@ -584,6 +606,7 @@ test("prerelease", async ({expect = globalExpect}: any = {}) => { ["npm", "dependencies", "prismjs", dep("https://github.com/LeaVerou/prism", "1.17.1", "1.0.0")], ["npm", "dependencies", "react", dep("https://github.com/facebook/react/tree/HEAD/packages/react", "18.3.0-next-fecc288b7-20221025", "18.0")], ["npm", "dependencies", "styled-components", dep("https://github.com/styled-components/styled-components", "5.0.0-regexrehydrate", "2.5.0-1")], + ["npm", "dependencies", "updates", dep("https://github.com/silverwind/updates", "537ccb7", "6941e05")], ["npm", "overrides", "noty", dep("https://github.com/needim/noty", "3.2.0-beta", "3.1.0")], ["npm", "overrides", "prismjs:overrides.@babel/preset-env.prismjs", dep("https://github.com/LeaVerou/prism", "1.17.1", "1.0.0")], ["npm", "overrides", "prismjs:overrides.prismjs", dep("https://github.com/LeaVerou/prism", "1.17.1", "1.0.0")], @@ -616,6 +639,7 @@ test("patch", async ({expect = globalExpect}: any = {}) => { ["npm", "dependencies", "gulp-sourcemaps", dep("https://github.com/floridoo/gulp-sourcemaps", "2.0.1", "2.0.0")], ["npm", "dependencies", "html-webpack-plugin", dep("https://github.com/jantimon/html-webpack-plugin", "4.0.0-beta.11", "4.0.0-alpha.2")], ["npm", "dependencies", "noty", dep("https://github.com/needim/noty", "3.1.4", "3.1.0")], + ["npm", "dependencies", "updates", dep("https://github.com/silverwind/updates", "537ccb7", "6941e05")], ["npm", "overrides", "noty", dep("https://github.com/needim/noty", "3.1.4", "3.1.0")], ["npm", "packageManager", "npm", dep("https://github.com/npm/cli", "11.6.2", "11.6.0")], ["npm", "resolutions", "versions/updates", dep("https://github.com/silverwind/updates", "^1.0.6", "^1.0.0")], @@ -1438,11 +1462,22 @@ test.each([ ["workflow", dockerActionsDir, "ci.yaml", { node: {old: "18", new: "22"}, postgres: {old: "15", new: "17"}, redis: {old: "7", new: "8"}, }], -])("docker %s basic", async (_name, file, suffix, expected, {expect = globalExpect}: any = {}) => { +])("docker %s basic", async (name, file, suffix, expected, {expect = globalExpect}: any = {}) => { const {stdout, stderr} = await runCliExec(dockerArgs("-j", "-f", file)); expect(stderr).toEqual(""); const docker = JSON.parse(stdout).results.docker; - expect(docker[Object.keys(docker).find(key => key.endsWith(suffix))!]).toMatchObject(expected); + const dependencies = docker[Object.keys(docker).find(key => key.endsWith(suffix))!]; + expect(dependencies).toMatchObject(expected); + if (name === "workflow") { + const crlfDir = join(testDir, "docker-actions-crlf", ".github", "workflows"); + const crlfFile = join(crlfDir, "ci.yaml"); + mkdirSync(crlfDir, {recursive: true}); + await writeFile(crlfFile, (await readFile(join(file, suffix), "utf8")).replaceAll("\n", "\r\n")); + const crlfOutput = await runCliExec(dockerArgs("-j", "-f", crlfFile)); + expect(crlfOutput.stderr).toEqual(""); + const crlfDocker = JSON.parse(crlfOutput.stdout).results.docker; + expect(crlfDocker[Object.keys(crlfDocker).find(key => key.endsWith(suffix))!]).toEqual(dependencies); + } }); test("docker allowedVersions compares floating tags with Docker semantics", async ({expect = globalExpect}: any = {}) => { @@ -1612,7 +1647,7 @@ function configTest(config: string, args: string): Promise<{stdout: string, stde test("config exit-code options", async ({expect = globalExpect}: any = {}) => { for (const [config, args, output] of [ ["{ errorOnOutdated: true }", "-j -i noty", "noty"], - ["{ errorOnUnchanged: true }", "-j -i updates", "All dependencies are up to date."], + ["{ errorOnUnchanged: true }", "-j -i svgstore", "All dependencies are up to date."], ]) { try { await configTest(config, args); diff --git a/index.ts b/index.ts index dffae96..eed8c5e 100755 --- a/index.ts +++ b/index.ts @@ -151,8 +151,8 @@ async function main(): Promise { if (hasFlag(rawArgs, "version", "v")) { let packageJson: string; - try { packageJson = readFileSync(new URL("../package.json", import.meta.url), "utf8"); } catch { - packageJson = readFileSync(new URL("package.json", import.meta.url), "utf8"); + try { packageJson = readFileSync(new URL("package.json", import.meta.url), "utf8"); } catch { + packageJson = readFileSync(new URL("../package.json", import.meta.url), "utf8"); } console.info(JSON.parse(packageJson).version); await end(); diff --git a/modes/cargo.test.ts b/modes/cargo.test.ts index e2bbb9f..cbaa071 100644 --- a/modes/cargo.test.ts +++ b/modes/cargo.test.ts @@ -26,6 +26,8 @@ test.each([ `dependencies${fieldSep}serde`, {old: "1.0.0", new: "1.1.0"}, `[dependencies]\nserde = { version = "1.1.0", features = ["derive"] }\n`], [`extended table [dependencies.name]`, `[dependencies.serde]\nversion = "1.0.0"\nfeatures = ["derive"]\n`, `dependencies${fieldSep}serde`, {old: "1.0.0", new: "1.2.0"}, `[dependencies.serde]\nversion = "1.2.0"\nfeatures = ["derive"]\n`], + [`spaced dotted workspace table`, `[workspace . dependencies]\nserde = "1.0.0"\n`, + `workspace.dependencies${fieldSep}serde`, {old: "1.0.0", new: "1.0.1"}, `[workspace . dependencies]\nserde = "1.0.1"\n`], [`extended table skips comments and multiline strings`, `[dependencies.serde]\n# version = "1.0.0" was old\nnote = """\nversion = "1.0.0"\n"""\nversion = "1.0.0"\n`, `dependencies${fieldSep}serde`, {old: "1.0.0", new: "1.1.0"}, `[dependencies.serde]\n# version = "1.0.0" was old\nnote = """\nversion = "1.0.0"\n"""\nversion = "1.1.0"\n`], [`extended table beside a same-named dev entry`, `[dependencies.serde]\nversion = "1.0.0"\n\n[dev-dependencies]\nserde = "1.0.0"\n`, @@ -170,7 +172,7 @@ test("target sections", () => { `[target.'cfg(feature = "foo.bar")'.dependencies]`, `libc = "0.2.0"`, ``, - `[target.x86_64-pc-windows-msvc.dependencies.winapi]`, + `[target . x86_64-pc-windows-msvc . dependencies . winapi]`, `version = "0.3.0"`, ``, `[target.'cfg(windows)'.build-dependencies.cc]`, @@ -190,7 +192,7 @@ test("target sections", () => { }; const result = updateCargoToml(input, deps); expect(result).toContain(`libc = "0.2.1"`); - expect(result).toContain(`[target.x86_64-pc-windows-msvc.dependencies.winapi]\nversion = "0.3.9"`); + expect(result).toContain(`[target . x86_64-pc-windows-msvc . dependencies . winapi]\nversion = "0.3.9"`); expect(result).toContain(`[target.'cfg(windows)'.build-dependencies.cc]\nversion = "1.1.0"`); expect(result).toContain(`[dev-dependencies]\nlibc = "0.2.0"`); }); diff --git a/modes/cargo.ts b/modes/cargo.ts index 935291f..35c7a5b 100644 --- a/modes/cargo.ts +++ b/modes/cargo.ts @@ -189,8 +189,9 @@ export function updateCargoToml(pkgStr: string, deps: Deps): string { const oldEsc = esc(oldValue); const typePath: Array = typeKey.startsWith("[") ? JSON.parse(jsonStringArrayRe.exec(typeKey)![0]) : typeKey.split("|", 1)[0].split("."); - const sectionEsc = typePath.map(tomlKey).join("\\."); - const ownRe = new RegExp(`^${sectionEsc}\\.${nameEsc}$`); + const dottedSeparator = "[ \\t]*\\.[ \\t]*"; + const sectionEsc = typePath.map(tomlKey).join(dottedSeparator); + const ownRe = new RegExp(`^${sectionEsc}${dottedSeparator}${nameEsc}$`); const sectionRe = new RegExp(`^${sectionEsc}$`); const ownSpan = spans.find(entry => ownRe.test(entry.path)); const span = ownSpan ?? spans.find(entry => sectionRe.test(entry.path)); diff --git a/modes/docker.test.ts b/modes/docker.test.ts index 14a6edb..fe0adae 100644 --- a/modes/docker.test.ts +++ b/modes/docker.test.ts @@ -18,7 +18,7 @@ test.each([ ["docker.io", "docker.io/library/node:18", {registry: null, namespace: "library", repo: "node", tag: "18", fullImage: "docker.io/library/node"}], ["index.docker.io", "index.docker.io/myorg/myapp:1.0.0", {registry: null, namespace: "myorg", repo: "myapp", tag: "1.0.0", fullImage: "index.docker.io/myorg/myapp"}], ["registry-1.docker.io", "registry-1.docker.io/node:18", {registry: null, namespace: "library", repo: "node", tag: "18", fullImage: "registry-1.docker.io/node"}], - ["a deep Hub path", "org/team/image:1.2.3", {registry: null, namespace: "org/team", repo: "image", tag: "1.2.3", fullImage: "org/team/image"}], + ["a registry without a domain suffix", "org/team/image:1.2.3", {registry: "org", namespace: "team", repo: "image", tag: "1.2.3", fullImage: "org/team/image"}], ["localhost registry", "localhost/owner/image:1.2.3", {registry: "localhost", namespace: "owner", repo: "image", tag: "1.2.3", fullImage: "localhost/owner/image"}], ["a tag with suffix", "node:18-alpine", {registry: null, namespace: "library", repo: "node", tag: "18-alpine", fullImage: "node"}], ["full semver with suffix", "node:18.19.1-bookworm", {registry: null, namespace: "library", repo: "node", tag: "18.19.1-bookworm", fullImage: "node"}], @@ -41,6 +41,7 @@ test("dockerImageNames", () => { expect(dockerImageNames("index.docker.io/library/mysql")).toEqual(["index.docker.io/library/mysql", ...hub]); expect(dockerImageNames("grafana/grafana")).toEqual(["grafana/grafana", "docker.io/grafana/grafana"]); expect(dockerImageNames("ghcr.io/foo/bar")).toEqual(["ghcr.io/foo/bar"]); + expect(dockerImageNames("REGISTRY/team/image")).toEqual(["REGISTRY/team/image"]); }); test.each([ @@ -178,6 +179,7 @@ test("findDockerVersion ignores tags from another versioning scheme", () => { expect(findDockerVersion(tagMap, "3", allSemvers)).toBeNull(); expect(findDockerVersion(tagMap, "20251224", allSemvers)).toEqual({newTag: "20260127", date: "2026-01-28"}); expect(findDockerVersion({"9": "2020-01-01", "10": "2020-06-01"}, "9", allSemvers)).toEqual({newTag: "10", date: "2020-06-01"}); + expect(findDockerVersion({"20260127": "2026-01-27", "9999999999999999999": "2026-02-01"}, "20260127", allSemvers)).toBeNull(); }); test("findDockerVersion cooldown needs a timestamp", () => { @@ -358,8 +360,7 @@ test("fetchDockerTagDigest returns the registry digest and reports failures", as await expect(fetchDockerTagDigest("library", "node", "20", hubCtx(() => Promise.resolve({ ok: false, status: 429, statusText: "Too Many Requests", })))).rejects.toThrow("Received 429 Too Many Requests"); - await expect(fetchDockerTagDigest("library", "node", "20", hubCtx(hubBody({})))) - .rejects.toThrow("Malformed Docker Hub tag response"); + await expect(fetchDockerTagDigest("library", "node", "20", hubCtx(hubBody({})))).resolves.toBe(null); }); test("fetchDockerInfo library image", async () => { diff --git a/modes/docker.ts b/modes/docker.ts index 7792891..5f3583f 100644 --- a/modes/docker.ts +++ b/modes/docker.ts @@ -62,7 +62,8 @@ const hubRegistryRe = /^(?:(?:index|registry-1)\.)?docker\.io$/; function parseImageParts(imagePart: string): {registry: string | null, namespace: string, repo: string} { const parts = imagePart.split("/"); if (parts.length > 1 && hubRegistryRe.test(parts[0])) parts.shift(); - const registry = parts.length > 1 && (parts[0] === "localhost" || parts[0].includes(".") || parts[0].includes(":")) ? + const registry = parts.length > 2 || + parts.length > 1 && (parts[0] === "localhost" || parts[0].includes(".") || parts[0].includes(":")) ? parts.shift()! : null; return {registry, namespace: parts.length === 1 ? "library" : parts.slice(0, -1).join("/"), repo: parts.at(-1)!}; } @@ -199,9 +200,8 @@ export async function fetchDockerTagDigest( const result = await fetchWithEtag(url, ctx, {headers: {"accept-encoding": "gzip, deflate, br"}}, reduceJson(data => ({digest: data.digest}))); if ("body" in result) { - const data = JSON.parse(result.body); - if (typeof data?.digest !== "string") throw new Error(`Malformed Docker Hub tag response: ${namespace}/${repo}:${tag}`); - return data.digest; + const digest = JSON.parse(result.body)?.digest; // absent on tags pushed before Docker Hub recorded manifest digests + return typeof digest === "string" ? digest : null; } if (!noTagsStatus.has(result.res?.status as number)) throwFetchError(result.res, url, `${namespace}/${repo}:${tag}`, ctx.dockerApiUrl); return null; @@ -310,7 +310,8 @@ export function findDockerVersion( const coerced = coerceDockerVersion(parsed.version); if (!coerced) continue; - const candidate = parse(dockerSemver(coerced, parsed.prerelease))!; + const candidate = parse(dockerSemver(coerced, parsed.prerelease)); + if (!candidate) continue; if (parsed.prerelease && skipsPrerelease(candidate)) continue; if (pinnedRange && !satisfies(coerced, pinnedRange)) continue; diff --git a/modes/go.test.ts b/modes/go.test.ts index abf2704..31e60a5 100644 --- a/modes/go.test.ts +++ b/modes/go.test.ts @@ -245,6 +245,10 @@ test.each([ goMod("require foo v1.0.0", "exclude foo v1.0.0", "replace other => foo v1.0.0"), {[`deps${fieldSep}foo`]: {old: "1.0.0", new: "1.1.0"}}, goMod("require foo v1.1.0", "exclude foo v1.0.0", "replace other => foo v1.0.0"), {}], + ["a directive in a file with mixed line endings", + "module x\n\nrequire example.com/dep v1.0.0\r\n", + {[`deps${fieldSep}example.com/dep`]: {old: "1.0.0", new: "1.1.0"}}, + "module x\n\nrequire example.com/dep v1.1.0\r\n", {}], ])("updateGoMod rewrites %s", (_name, content, deps, expected, expectedRewrites) => { const [result, rewrites] = updateGoMod(content, deps); expect(result).toBe(expected); @@ -402,25 +406,30 @@ test.each([ expect(parseGoWork(lines.join("\n"))).toEqual(expected); }); -test("resolveGoWorkModule contains members after resolving symlinks", () => { +test("resolveGoWorkModule resolves an out-of-tree member", () => { const parent = mkdtempSync(resolve(tmpdir(), "updates-go-work-")); try { const root = resolve(parent, "project"); - const member = resolve(root, "member"); - const outside = resolve(parent, "trusted"); - mkdirSync(member, {recursive: true}); + const outside = resolve(parent, "shared"); + mkdirSync(root); mkdirSync(outside); - writeFileSync(resolve(member, "go.mod"), "module example.com/member\n"); - writeFileSync(resolve(outside, "go.mod"), "module example.com/trusted\n"); - symlinkSync(outside, resolve(root, "linked")); - expect(resolveGoWorkModule(root, "member")).toBe(realpathSync(resolve(member, "go.mod"))); - expect(resolveGoWorkModule(root, "../trusted")).toBeNull(); - expect(resolveGoWorkModule(root, "linked")).toBeNull(); + writeFileSync(resolve(outside, "go.mod"), "module example.com/shared\n"); + expect(resolveGoWorkModule(root, "../shared")).toBe(realpathSync(resolve(outside, "go.mod"))); } finally { rmSync(parent, {recursive: true}); } }); +test("resolveGoWorkModule skips a member with a resolution error", () => { + const root = mkdtempSync(resolve(tmpdir(), "updates-go-work-")); + try { + symlinkSync("loop", resolve(root, "loop")); + expect(resolveGoWorkModule(root, "loop")).toBeNull(); + } finally { + rmSync(root, {recursive: true}); + } +}); + test("rewriteGoImports rewrites matching imports and skips empty work", () => { rewriteGoImports(resolve("fixtures/go"), {}, () => { throw new Error("unexpected write"); }); rewriteGoImports(resolve("fixtures/cargo"), {"github.com/old": "github.com/new"}, () => { throw new Error("unexpected write"); }); diff --git a/modes/go.ts b/modes/go.ts index b9911da..9b2bb81 100644 --- a/modes/go.ts +++ b/modes/go.ts @@ -1,5 +1,5 @@ import {env} from "node:process"; -import {dirname, isAbsolute, join, relative, resolve, sep} from "node:path"; +import {dirname, join, resolve} from "node:path"; import {globSync, readFileSync, realpathSync} from "node:fs"; import { type Deps, type GoProxyEntry, type ModeContext, type PackageInfo, dedupe, fieldSep, stripv, getSubDir, normalizeUrl, @@ -407,8 +407,8 @@ export function updateGoMod(pkgStr: string, deps: Deps): [string, Record = {}; const entries = Object.entries(deps); if (!entries.length) return [pkgStr, majorVersionRewrites]; - const newline = pkgStr.includes("\r\n") ? "\r\n" : "\n"; - const lines = pkgStr.split(newline); + const lineEndings = pkgStr.match(/\r?\n/g) ?? []; + const lines = pkgStr.split(/\r?\n/); const rewriteLines = (lineNumbers: Array | undefined, pattern: RegExp, replacement: string): boolean => { let rewritten = false; for (const lineNumber of lineNumbers ?? []) { @@ -477,7 +477,7 @@ export function updateGoMod(pkgStr: string, deps: Deps): [string, Record `${line}${lineEndings[index] ?? ""}`).join(""), majorVersionRewrites]; } const goTokenRe = /\s+|\/\/[^\n]*(?:\n|$)|\/\*[\s\S]*?(?:\*\/|$)|[A-Za-z_][A-Za-z0-9_]*|"(?:\\[\s\S]|[^"\\])*(?:"|$)|`[^`]*(?:`|$)|'(?:\\[\s\S]|[^'\\])*(?:'|$)|./g; @@ -552,12 +552,9 @@ export function parseGoWork(content: string): {use: string[], replace: Record): string { const bySpec = new Map(rewrites.map(({oldSpec, newSpec}) => [oldSpec, newSpec])); if (!bySpec.size) return content; - const specRe = new RegExp(`(? code.replace(specRe, spec => bySpec.get(spec)!)); + const specs = Array.from(bySpec.keys()).sort((a, b) => b.length - a.length) + .map(spec => Array.from(spec, char => esc(char)).join(`["']*`)).join("|"); + const specRe = new RegExp(`(? code.replace(specRe, authoredSpec => { + const oldSpec = authoredSpec.replace(/["']/g, ""); + const newSpec = bySpec.get(oldSpec)!; + let newIndex = 0; + const result = authoredSpec.replace(/[^"']/g, () => newSpec[newIndex++] ?? ""); + return result + newSpec.slice(newIndex); + })); } diff --git a/modes/npm.test.ts b/modes/npm.test.ts index aace8d9..59c43aa 100644 --- a/modes/npm.test.ts +++ b/modes/npm.test.ts @@ -239,6 +239,28 @@ test.each([ } }); +test("fetchNpmInfo prefers a scoped npmrc registry over a native default", async () => { + const dir = mkdtempSync(join(tmpdir(), "updates-registry-specificity-")); + let fetchedUrl = ""; + let authorization: string | null = null; + const ctx = modeCtx({noCache: true, doFetch: (url: string, opts: RequestInit) => { + fetchedUrl = url; + authorization = new Headers(opts.headers).get("authorization"); + return textRes({}); + }}); + try { + writeFileSync(join(dir, "pnpm-workspace.yaml"), "registry: https://registry.npmjs.org\n"); + writeFileSync(join(dir, ".npmrc"), "@company:registry=https://npm.company.example\n//npm.company.example/:_authToken=secret\n"); + await fetchNpmInfo("@company/pkg", "dependencies", {}, {}, ctx, dir); + expect([fetchedUrl, authorization]).toEqual([ + "https://npm.company.example/@company%2fpkg", + "Bearer secret", + ]); + } finally { + rmSync(dir, {recursive: true}); + } +}); + test("fetchNpmInfo never sends unscoped _auth to a repository registry", async () => { const dir = mkdtempSync(join(tmpdir(), "updates-auth-")); const home = join(dir, "home"); @@ -314,7 +336,7 @@ test("checkUrlDep parses refs and refreshes hashes", async () => { fetches++; return textRes([{sha: "def5678901234", commit: {committer: {date: "2025-03-01"}}}]); }}); - const result = await checkUrlDep("key", {old: "github:user/repo#1234567", new: ""}, hashCtx); + const result = await checkUrlDep("key", {old: "github:user/repo#abc4567", new: ""}, hashCtx); expect(result).not.toBeNull(); expect(result!.newRange).toBe("github:user/repo#def5678"); expect(result!.newRef).toBe("def5678"); @@ -336,3 +358,13 @@ test.each([ const ctx = forgeCtx({noCache: true, doFetch: (url: string) => jsonRes(url.includes("/releases?") ? [] : tags)}); expect((await checkUrlDep("key", {old, new: ""}, ctx))?.newRange).toBe(expected); }); + +test("checkUrlDep updates GitHub path refs at their trailing occurrence", async () => { + const tags = [{name: "v1.2.3", commit: {sha: "abc"}}, {name: "v2.0.0", commit: {sha: "def"}}]; + const ctx = forgeCtx({noCache: true, doFetch: (url: string) => url.endsWith("/commits") ? + textRes([{sha: "def5678901234", commit: {}}]) : jsonRes(url.includes("/releases?") ? [] : tags)}); + expect((await checkUrlDep("key", {old: "https://github.com/user/repo-v1.2.3/tarball/v1.2.3", new: ""}, ctx))?.newRange) + .toBe("https://github.com/user/repo-v1.2.3/tarball/v2.0.0"); + expect((await checkUrlDep("key", {old: "https://github.com/user/repo/abc1234", new: ""}, ctx))?.newRange) + .toBe("https://github.com/user/repo/def5678"); +}); diff --git a/modes/npm.ts b/modes/npm.ts index b50db61..2518a07 100644 --- a/modes/npm.ts +++ b/modes/npm.ts @@ -58,13 +58,14 @@ function resolveNpmRegistry(name: string, config: Config, args: Record { + const nativeDefaultRegistry = scope && dir ? resolveNativeNpmRegistry("", dir) : null; + return getOrSet(authCache, `${dir ?? ""}${fieldSep}${scope}:${registry}:${nativeRegistry ?? ""}:${nativeDefaultRegistry ?? ""}`, () => { let resolvedRegistry = nativeRegistry ? normalizeUrl(nativeRegistry) : registry; - const scoped = !nativeRegistry && scope && npmrcConfig[`${scope}:registry`]; + const scoped = nativeRegistry === nativeDefaultRegistry && scope && npmrcConfig[`${scope}:registry`]; // Specificity wins across sources. if (scoped) { try { const url = normalizeUrl(scoped); - if (url !== registry) resolvedRegistry = url; + if (url !== resolvedRegistry) resolvedRegistry = url; } catch {} } return {auth: getRegistryAuthToken(resolvedRegistry, npmrcConfig), registry: resolvedRegistry}; @@ -344,9 +345,9 @@ type GitHubSpec = {user: string, repo: string, ref: string, selector: string | n function parseGitHubSpec(value: string): GitHubSpec | null { const hash = value.lastIndexOf("#"); - if (hash < 1 || hash === value.length - 1) return null; - let source = value.slice(0, hash).replace(/^git\+/i, ""); - const fragment = value.slice(hash + 1); + if (hash === value.length - 1) return null; + let source = value.slice(0, hash === -1 ? value.length : hash).replace(/^git\+/i, ""); + let fragment = hash === -1 ? "" : value.slice(hash + 1); if (/^github:/i.test(source)) source = source.slice(7); else if (/^git@github\.com:/i.test(source)) source = source.replace(/^git@github\.com:/i, ""); else if (/^(?:https?|git|ssh):/i.test(source)) { @@ -356,6 +357,12 @@ function parseGitHubSpec(value: string): GitHubSpec | null { } else if (source.includes(":")) { return null; } + if (!fragment) { + const match = /^([^/]+\/[^/]+)\/(?:.*\/)?([0-9a-f]+|v?[0-9]+\.[0-9]+\.[0-9]+)$/i.exec(source); + if (!match) return null; + source = match[1]; + fragment = match[2]; + } source = source.replace(/\.git$/i, ""); const parts = source.split("/"); if (parts.length !== 2 || !/^[a-z\d](?:-?[a-z\d]){0,38}$/i.test(parts[0]) || !/^[a-z\d._-]{1,100}$/i.test(parts[1])) return null; @@ -370,8 +377,8 @@ export async function checkUrlDep(key: string, dep: Dep, ctx: ModeContext): Prom const {user, repo, ref: oldRef, selector} = parsed; const replaceRef = (ref: string) => { - const index = dep.old.lastIndexOf("#") + 1; - return `${dep.old.slice(0, index)}${selector ? `semver:${ref}` : ref}`; + const index = dep.old.lastIndexOf(oldRef); + return `${dep.old.slice(0, index)}${ref}${dep.old.slice(index + oldRef.length)}`; }; if (hashRe.test(oldRef)) { diff --git a/modes/shared.test.ts b/modes/shared.test.ts index a5c7948..8b5f606 100644 --- a/modes/shared.test.ts +++ b/modes/shared.test.ts @@ -100,9 +100,9 @@ test("parseTags transforms tag data, commit or not", () => { ]); }); -test("hashRe only recognizes npm commit lengths", () => { - expect(["deadbee", "1234567", "a".repeat(40)].every(value => hashRe.test(value))).toBe(true); - expect(["abc123", "deadbeef", "a".repeat(39), "a".repeat(41), "b".repeat(64)].some(value => hashRe.test(value))).toBe(false); +test("hashRe recognizes plausible GitHub commit hashes", () => { + expect(["deadbee", "deadbeef", "a".repeat(40)].every(value => hashRe.test(value))).toBe(true); + expect(["abc123", "2024010", "a".repeat(41), "b".repeat(64)].some(value => hashRe.test(value))).toBe(false); }); test("throwFetchError names the status, or the package when there is none", () => { diff --git a/modes/shared.ts b/modes/shared.ts index 055edb6..dc8b3a2 100644 --- a/modes/shared.ts +++ b/modes/shared.ts @@ -561,7 +561,7 @@ export function resolvePackageJsonUrl(url: string): string { const commitHashPattern = "(?:[0-9a-f]{6,7}|[0-9a-f]{40}|[0-9a-f]{64})"; export const commitHashRe = new RegExp(`^${commitHashPattern}$`, "i"); -export const hashRe = /^(?:[0-9a-f]{7}|[0-9a-f]{40})$/i; +export const hashRe = /^(?=.*[a-f])[0-9a-f]{7,40}$/i; export function isVersionLikeRef(ref: string): boolean { return /^v?\d+(?:\.\d+)*(?:[-+][\w.-]+)?$/.test(ref); diff --git a/utils/semver.test.ts b/utils/semver.test.ts index c6d4a3c..2fe8d8c 100644 --- a/utils/semver.test.ts +++ b/utils/semver.test.ts @@ -65,6 +65,8 @@ test("ranges", () => { ["3.0.0", "1.0.0 - 2.0.0", false], ["0.9.9", "1.0.0 - 2.0.0", false], ["1.5.0", "1.x", true], ["1.0.0", "1.x", true], ["2.0.0", "1.x", false], ["1.5.0", "1.x.x", true], ["2.0.0", "1.x.x", false], ["999.0.0", "*", true], ["0.0.0", "*", true], + ["1.2.3", ">*", false], ["1.2.3", "<*", false], ["1.2.3", ">x", false], ["1.2.3", "<*.*.*", false], + ["1.2.3", "*", true], ["1.2.3", ">=*", true], ["1.2.3", "=*", true], ["1.2.3", "<=*", true], ["1.2.3-alpha", "*", false], ["1.2.3-alpha", "x", false], ["1.2.3-alpha", "*.*.*", false], ["1.2.3-alpha", "", false], ["1.2.3-alpha", "|| >2.0.0", false], ["1.2.3-alpha", ">2.0.0 ||", false], ["1.2.3-alpha", "|| >=1.2.3-alpha", true], @@ -100,7 +102,7 @@ test("ranges", () => { for (const range of [ "^1.0.0", ">=1.0.0 <2.0.0", "1.0.0 - 2.0.0", "*", "~>1.2.3", "1.2.3+build", "^1.2.3+build", "*.*.*", "1.2.3 - 2.3.x", "1.2.x", "^1.x", "^1.x.x", "^1.2.x", "^0.x", - "~1.x", "~1.2.x", ">=1.2.x", ">=1.2.x <2.0.0", + "~1.x", "~1.2.x", ">=1.2.x", ">=1.2.x <2.0.0", ">*", "<*", ">x", "<*.*.*", ">=*", "=*", "<=*", ]) expect(validRange(range)).toBe(range); expect(validRange("1.*.3")).toBeNull(); expect(validRange("not valid!!")).toBeNull(); diff --git a/utils/semver.ts b/utils/semver.ts index a8ea2e5..b67cc1f 100644 --- a/utils/semver.ts +++ b/utils/semver.ts @@ -195,7 +195,10 @@ function upperComparator(major: number, minor: number, patch: number): Comparato } function partialBounds(partial: PartialVersion, op: string): Array | null { - if (partial.major === null) return partial.minor === null && partial.patch === null ? [] : null; + if (partial.major === null) { + if (partial.minor !== null || partial.patch !== null) return null; + return op === ">" || op === "<" ? comparators(upperComparator(0, 0, 0)) : []; + } const major = partial.major; const minor = partial.minor ?? 0; const patch = partial.patch ?? 0; diff --git a/utils/utils.test.ts b/utils/utils.test.ts index 16cb170..ab6d6c8 100644 --- a/utils/utils.test.ts +++ b/utils/utils.test.ts @@ -113,7 +113,8 @@ test("default npm dependency types", () => { }); test.each([ - ["foo*", ["FOO", "foo.bar", "foo/bar"], [true, true, false]], + ["*", ["@scope/pkg"], [true]], + ["foo*", ["FOO", "foo.bar", "foo/bar"], [true, true, true]], ["foo/**/bar", ["foo/bar", "foo/a/bar", ".foo/bar"], [true, true, false]], ["file?.[jt]s", ["file1.js", "fileA.ts", "file10.js"], [true, true, false]], ["{foo,bar}/@(one|two)", ["foo/one", "bar/two", "baz/one"], [true, true, false]], diff --git a/utils/utils.ts b/utils/utils.ts index 34cff67..a2de086 100644 --- a/utils/utils.ts +++ b/utils/utils.ts @@ -338,8 +338,8 @@ function parseGlob(pattern: string): {source: string, tokens: Array} tokens.push({kind: "span", slash: true}); } } else { - source += "[^/]*"; - tokens.push({kind: "span", slash: false}); + source += ".*"; + tokens.push({kind: "span", slash: true}); } } else if (char === "?" && pattern[i + 1] !== "(") { addChar("[^/]"); diff --git a/utils/workspace.test.ts b/utils/workspace.test.ts index c203011..2da51d8 100644 --- a/utils/workspace.test.ts +++ b/utils/workspace.test.ts @@ -43,6 +43,19 @@ test("filterDepsForMember", () => { }); }); +test("filterDepsForMember preserves dependency identities", () => { + const directIdentity = JSON.stringify(["overrides", "prismjs"]); + const nestedIdentity = JSON.stringify(["overrides", "parent", "prismjs"]); + const allDeps = { + [`overrides|./app${fieldSep}prismjs${fieldSep}${directIdentity}`]: {old: "1.29.0", new: "1.30.0"}, + [`overrides|./app${fieldSep}prismjs${fieldSep}${nestedIdentity}`]: {old: "1.28.0", new: "1.30.0"}, + }; + expect(filterDepsForMember(allDeps, "./app")).toEqual({ + [`overrides${fieldSep}prismjs${fieldSep}${directIdentity}`]: allDeps[`overrides|./app${fieldSep}prismjs${fieldSep}${directIdentity}`], + [`overrides${fieldSep}prismjs${fieldSep}${nestedIdentity}`]: allDeps[`overrides|./app${fieldSep}prismjs${fieldSep}${nestedIdentity}`], + }); +}); + test("resolveWorkspaceMembers resolves literals, globs and exclusions", async () => { const literalDir = makeWorkspace({ "crate-a/Cargo.toml": "[package]\nname = \"a\"", @@ -68,8 +81,21 @@ test("resolveWorkspaceMembers resolves literals, globs and exclusions", async () test("resolveWorkspaceMembers skips missing", async () => { const dir = makeWorkspace(); expect(await resolveWorkspaceMembers(["nonexistent"], dir, "Cargo.toml")).toEqual([]); +}); + +test("resolveWorkspaceMembers skips unreadable manifests", async () => { + const dir = makeWorkspace(); mkdirSync(join(dir, "member")); - await expect(resolveWorkspaceMembers(["member"], dir, ".")).rejects.toMatchObject({code: "EISDIR"}); + expect(await resolveWorkspaceMembers(["member"], dir, ".")).toEqual([]); +}); + +test("resolveWorkspaceMembers resolves symlinked glob directories", async () => { + const dir = makeWorkspace({"internal/app/package.json": "{\"name\": \"app\"}"}); + mkdirSync(join(dir, "packages")); + symlinkSync("../internal/app", join(dir, "packages/app"), "dir"); + expect((await resolveWorkspaceMembers(["packages/*"], dir, "package.json")).map(({memberPath}) => memberPath)) + .toEqual(["./packages/app"]); + expect(await resolveWorkspaceMembers(["packages/*", "!packages/*"], dir, "package.json")).toEqual([]); }); test("resolveWorkspaceMembers rejects traversal and escaping symlinks", async () => { @@ -90,6 +116,11 @@ test("parsePnpmWorkspace", () => { expect(parsePnpmWorkspace("packages:\n # comment\n - libs/*\nnodeLinker: hoisted\n")).toEqual(["libs/*"]); }); +test("parsePnpmWorkspace parses quoted patterns with inline comments", () => { + expect(parsePnpmWorkspace("packages:\n - \"packages/*\" # app packages\n - 'libs/*' # libs\n - plain/*\n")) + .toEqual(["packages/*", "libs/*", "plain/*"]); +}); + test("parse pnpm registry config", () => { expect(parsePnpmRegistryConfig("registry: https://pnpm.test\nregistries: {'@foo': https://foo.pnpm.test, default: https://default.pnpm.test}\n")).toEqual({ registry: "https://pnpm.test", diff --git a/utils/workspace.ts b/utils/workspace.ts index 57d3cec..0494704 100644 --- a/utils/workspace.ts +++ b/utils/workspace.ts @@ -21,10 +21,10 @@ export function filterDepsForMember(allDeps: Deps, memberPath: string): Deps { const byMember = getOrSet(depsByMember, allDeps, () => { const result = new Map>(); for (const [key, dep] of Object.entries(allDeps)) { - const [type, name] = key.split(fieldSep); + const [type, ...parts] = key.split(fieldSep); const separator = type.indexOf("|"); const path = separator === -1 ? "." : type.slice(separator + 1); - pushTo(result, path, [`${baseType(type)}${fieldSep}${name}`, dep]); + pushTo(result, path, [[baseType(type), ...parts].join(fieldSep), dep]); } return result; }); @@ -35,7 +35,7 @@ const globChars = /[*?{[]/; function globDirectories(pattern: string, cwd: string): Array { return globSync(pattern, {cwd, withFileTypes: true}) - .filter(entry => entry.isDirectory()) + .filter(entry => entry.isDirectory() || entry.isSymbolicLink()) .map(entry => resolve(entry.parentPath, entry.name)); } @@ -65,9 +65,8 @@ export async function resolveWorkspaceMembers(patterns: string[], workspaceDir: const rel = relative(workspaceRoot, absPath); if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) return null; return {absPath, content: await readFile(absPath, "utf8"), memberPath}; - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; - throw error; + } catch { + return null; } }, {concurrency}); return reads.filter((m): m is WorkspaceMember => m !== null); @@ -143,8 +142,9 @@ function yamlScalar(content: string): {value: string, valueIndex: number} | null if (!trimmed) return null; const quote = trimmed[0]; if (quote === '"' || quote === "'") { - if (!trimmed.endsWith(quote)) return null; - return {value: trimmed.slice(1, -1), valueIndex: leading + 1}; + const end = trimmed.indexOf(quote, 1); // anything past the closing quote is a comment + if (end === -1) return null; + return {value: trimmed.slice(1, end), valueIndex: leading + 1}; } const commentIndex = trimmed.search(/\s#/); return {value: (commentIndex === -1 ? trimmed : trimmed.slice(0, commentIndex)).trimEnd(), valueIndex: leading};