jvm-upgrade 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Theofilos Chamalis
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,181 @@
1
+ # jvm-upgrade
2
+
3
+ [![npm](https://img.shields.io/npm/v/jvm-upgrade.svg)](https://www.npmjs.com/package/jvm-upgrade)
4
+ [![CI](https://github.com/Theofilos-Chamalis/jvm-upgrade/actions/workflows/ci.yml/badge.svg)](https://github.com/Theofilos-Chamalis/jvm-upgrade/actions/workflows/ci.yml)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
6
+
7
+ Find outdated libraries in **Java, Kotlin and Android** projects, read their changelogs, and upgrade them. Works with **Gradle** (Kotlin DSL, Groovy DSL, version catalogs) and **Maven**. Like `npm-upgrade`, but for the JVM.
8
+
9
+ ```sh
10
+ npx jvm-upgrade
11
+ ```
12
+
13
+ ```
14
+ Name Current Patch Minor Major
15
+ androidx.core:core-ktx 1.10.0 1.10.1 1.17.0
16
+ com.android.application 8.5.0 8.5.2 8.13.0
17
+ com.squareup.okhttp3:okhttp 4.9.0 4.9.3 4.12.0 5.1.0
18
+ org.jetbrains.kotlin:kotlin-stdlib 1.9.0 1.9.25 2.2.20
19
+
20
+ Run jvm-upgrade -u to apply the underlined versions (target: major), or jvm-upgrade -i to pick.
21
+ ```
22
+
23
+ ## Why
24
+
25
+ - **Fast.** It reads Maven repositories directly and in parallel. It does not start Gradle or Maven.
26
+ - **Safe.** It changes only the version text. Your comments and formatting stay. Files are written atomically, and nothing is written if any file changed under it.
27
+ - **Changelogs.** See the release notes of every version between your version and the new one, right in the terminal.
28
+ - **Every source.** Maven Central, Google Maven, Gradle Plugin Portal, JitPack, `mavenLocal()`, and any custom or private repository your build declares.
29
+ - **Supply chain aware.** A cooldown window skips versions that are too new to trust.
30
+ - **No trash.** No build output, no temp files left behind. The metadata cache lives in your OS cache folder and expires by itself.
31
+
32
+ ## Install
33
+
34
+ ```sh
35
+ npm install -g jvm-upgrade
36
+ ```
37
+
38
+ Or run it once with `npx jvm-upgrade`. Needs Node.js 20.12 or newer.
39
+
40
+ ## Usage
41
+
42
+ ```sh
43
+ jvm-upgrade # list available upgrades (nothing is changed)
44
+ jvm-upgrade -u # apply the latest major versions
45
+ jvm-upgrade -u -t minor # apply the latest minor versions only
46
+ jvm-upgrade -i # pick versions and read changelogs interactively
47
+ jvm-upgrade -l # print the changelogs of the selected upgrades
48
+ jvm-upgrade -c 7 # ignore versions published in the last 7 days
49
+ jvm-upgrade path/to/project
50
+ ```
51
+
52
+ ### Interactive mode
53
+
54
+ `jvm-upgrade -i` opens a picker:
55
+
56
+ | Key | Action |
57
+ |---|---|
58
+ | `↑` `↓` | Move |
59
+ | `←` `→` | Choose patch, minor, major or skip |
60
+ | `space` | Select or unselect |
61
+ | `a` | Select or unselect all |
62
+ | `c` | Show the changelog for the chosen version |
63
+ | `enter` | Apply |
64
+ | `q` | Quit without changes |
65
+
66
+ ## Options
67
+
68
+ | Option | Default | Description |
69
+ |---|---|---|
70
+ | `[directory]` | `.` | Project root to scan |
71
+ | `-u, --upgrade` | | Write the upgrades to the build files |
72
+ | `-i, --interactive` | | Pick upgrades in a terminal UI |
73
+ | `-l, --changelog` | | Print the changelog of each selected upgrade |
74
+ | `-t, --target <level>` | `major` | Highest jump allowed: `major`, `minor` or `patch` |
75
+ | `-c, --cooldown <days>` | `0` | Skip versions published less than N days ago |
76
+ | `--allow-downgrade` | | With `--cooldown`, roll back versions that are too new |
77
+ | `--pre` | | Allow prereleases (alpha, beta, rc, ...) |
78
+ | `--include <pattern>` | | Only check matching deps. Repeatable or comma separated |
79
+ | `--exclude <pattern>` | | Skip matching deps |
80
+ | `--repo <url>` | | Extra Maven repository to query. Repeatable |
81
+ | `--format <format>` | `text` | `text` or `json` (JSON goes to stdout, the rest to stderr) |
82
+ | `--error-on-outdated` | | Exit with code 1 when upgrades are available (for CI) |
83
+ | `--verbose` | | Also list dependencies that are up to date |
84
+ | `--no-progress` | | Hide progress bars |
85
+ | `--concurrency <n>` | `8` | Parallel HTTP requests |
86
+ | `--no-cache` | | Do not use the metadata cache |
87
+ | `--clear-cache` | | Delete the metadata cache first |
88
+
89
+ Patterns match `group` (`com.google.*`) or `group:artifact` (`org.jetbrains:*`, `junit:junit`). For plugins, the group is the plugin id.
90
+
91
+ ### Exit codes
92
+
93
+ | Code | Meaning |
94
+ |---|---|
95
+ | `0` | Success |
96
+ | `1` | Upgrades available (only with `--error-on-outdated`) |
97
+ | `2` | Bad flag or config |
98
+ | `3` | A build file could not be read |
99
+ | `4` | Network or repository error |
100
+ | `5` | A file changed while upgrading, so nothing was written |
101
+
102
+ ## What it reads
103
+
104
+ | File | What |
105
+ |---|---|
106
+ | `build.gradle`, `build.gradle.kts` | Dependencies, `plugins {}`, `buildscript` classpath, `ext` / `val` / `extra` variables, repositories |
107
+ | `settings.gradle(.kts)` | `pluginManagement`, `dependencyResolutionManagement`, extra version catalogs |
108
+ | `gradle/libs.versions.toml` | `[versions]`, `[libraries]`, `[plugins]` |
109
+ | `gradle.properties` | Version properties |
110
+ | `gradle/wrapper/gradle-wrapper.properties` | The Gradle version itself |
111
+ | `pom.xml` | Dependencies, dependency management, plugins, extensions, parent, `<properties>`, repositories |
112
+
113
+ When a version comes from a variable (a catalog `version.ref`, a property, `ext`), the upgrade lands where the variable is defined. All libraries that share it are upgraded together, to a version that exists for all of them.
114
+
115
+ Dynamic versions (`1.+`, `[1.0,2.0)`, `latest.release`) are shown but never rewritten.
116
+
117
+ ## Version rules
118
+
119
+ 1. Never go down (unless `--allow-downgrade`).
120
+ 2. Stable stays stable. If you use a prerelease, newer prereleases are allowed too. `--pre` allows all prereleases.
121
+ 3. Cooldown drops versions that are too new.
122
+ 4. `--target` sets the highest jump.
123
+ 5. The highest remaining version wins, using Gradle's version ordering.
124
+
125
+ ## Changelogs
126
+
127
+ For each upgrade, jvm-upgrade looks for the source repository in the POM (`<scm>`, `<url>`, parent POMs), or uses the GitHub repo directly for JitPack. It then reads the GitHub releases between your version and the new one. If there are none, it reads `CHANGELOG.md` (and similar files) and cuts out the right sections. If it finds nothing, it gives you the link.
128
+
129
+ Set `GITHUB_TOKEN` (or `GH_TOKEN`) to avoid GitHub rate limits.
130
+
131
+ ## Configuration
132
+
133
+ ### Project: `.jvm-upgrade.json`
134
+
135
+ Put it in the project root, or in a module folder to override settings for that module. The closest file wins, and it inherits the rest from the parent folders.
136
+
137
+ ```json
138
+ {
139
+ "target": "minor",
140
+ "cooldown": 7,
141
+ "pre": false,
142
+ "allowDowngrade": false,
143
+ "include": [],
144
+ "exclude": ["com.example:legacy-*"],
145
+ "repositories": ["https://repo.example.com/maven/"]
146
+ }
147
+ ```
148
+
149
+ ### User: `~/.jvm-upgrade/config.json`
150
+
151
+ Your defaults for every project. It takes all project keys, plus `cacheDir`, `noCache` and `concurrency`.
152
+
153
+ ### Private repositories: `~/.jvm-upgrade/credentials.json`
154
+
155
+ ```json
156
+ {
157
+ "repositories": [
158
+ { "url": "https://nexus.example.com/", "token": "$NEXUS_TOKEN" },
159
+ { "url": "https://artifactory.example.com/", "username": "$ART_USER", "password": "$ART_PASS" }
160
+ ]
161
+ }
162
+ ```
163
+
164
+ Values that start with `$` come from environment variables. The longest matching URL prefix wins. Credentials are only sent to that host, and authenticated responses are never written to the disk cache.
165
+
166
+ Set `JVM_UPGRADE_HOME` to use a different folder than `~/.jvm-upgrade`.
167
+
168
+ ## CI
169
+
170
+ ```sh
171
+ npx jvm-upgrade --error-on-outdated --cooldown 7
172
+ npx jvm-upgrade --format json > upgrades.json
173
+ ```
174
+
175
+ ## Contributing
176
+
177
+ See [CONTRIBUTING.md](CONTRIBUTING.md). Issues with a small build file snippet are the best way to get a new notation supported.
178
+
179
+ ## License
180
+
181
+ [MIT](LICENSE)
package/dist/apply.js ADDED
@@ -0,0 +1,55 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
3
+ export class EditConflictError extends Error {
4
+ }
5
+ /** Rewrites only the version literals. Every file is checked first, then written atomically. */
6
+ export async function applyEdits(edits) {
7
+ const byFile = new Map();
8
+ for (const edit of edits) {
9
+ const list = byFile.get(edit.location.file) ?? [];
10
+ list.push(edit);
11
+ byFile.set(edit.location.file, list);
12
+ }
13
+ const outputs = [];
14
+ for (const [file, list] of byFile) {
15
+ outputs.push({ file, content: rewrite(file, await readFile(file, 'utf8'), list) });
16
+ }
17
+ for (const out of outputs)
18
+ await writeAtomic(out.file, out.content);
19
+ }
20
+ function rewrite(file, content, edits) {
21
+ const unique = new Map();
22
+ for (const edit of edits) {
23
+ const prev = unique.get(edit.location.start);
24
+ if (prev && prev.to !== edit.to) {
25
+ throw new EditConflictError(`${file}: two different upgrades target the same version (${prev.to} and ${edit.to})`);
26
+ }
27
+ unique.set(edit.location.start, edit);
28
+ }
29
+ const sorted = [...unique.values()].sort((a, b) => b.location.start - a.location.start);
30
+ let out = content;
31
+ let lastStart = Infinity;
32
+ for (const { location, from, to } of sorted) {
33
+ if (location.end > lastStart)
34
+ throw new EditConflictError(`${file}: overlapping edits`);
35
+ const current = out.slice(location.start, location.end);
36
+ if (current !== from) {
37
+ throw new EditConflictError(`${file}: expected "${from}" at offset ${location.start} but found "${current}". Was the file changed?`);
38
+ }
39
+ out = out.slice(0, location.start) + to + out.slice(location.end);
40
+ lastStart = location.start;
41
+ }
42
+ return out;
43
+ }
44
+ async function writeAtomic(file, content) {
45
+ const tmp = `${file}.${randomBytes(4).toString('hex')}.tmp`;
46
+ const { mode } = await stat(file);
47
+ try {
48
+ await writeFile(tmp, content, { mode });
49
+ await rename(tmp, file);
50
+ }
51
+ catch (e) {
52
+ await rm(tmp, { force: true });
53
+ throw e;
54
+ }
55
+ }
@@ -0,0 +1,207 @@
1
+ import { fetchPom } from './repos.js';
2
+ const CHANGELOG_FILES = ['CHANGELOG.md', 'CHANGES.md', 'HISTORY.md', 'RELEASE_NOTES.md', 'docs/CHANGELOG.md'];
3
+ const MAX_RELEASE_PAGES = 5;
4
+ const MAX_PARENT_DEPTH = 3;
5
+ const POM_NOISE = /<(parent|scm|licenses|developers|contributors|organization|distributionManagement|issueManagement|ciManagement|mailingLists|repositories|pluginRepositories|build|reporting|profiles|dependencies|dependencyManagement)\b[\s\S]*?<\/\1>/g;
6
+ export function normalizeScmUrl(raw) {
7
+ let s = raw.trim();
8
+ if (!s || s.includes('${'))
9
+ return undefined;
10
+ s = s.replace(/^scm:(git:|svn:|hg:)?/i, '');
11
+ s = s.replace(/^git@([^:]+):/i, 'https://$1/');
12
+ s = s.replace(/^(ssh|git|http):\/\/(git@)?/i, 'https://');
13
+ if (!/^https:\/\//i.test(s))
14
+ return undefined;
15
+ let url;
16
+ try {
17
+ url = new URL(s);
18
+ }
19
+ catch {
20
+ return undefined;
21
+ }
22
+ if (url.hostname.toLowerCase() === 'github.com' || url.hostname.toLowerCase() === 'www.github.com') {
23
+ const [owner, repo] = url.pathname.split('/').filter(Boolean);
24
+ if (!owner || !repo)
25
+ return undefined;
26
+ const clean = repo.replace(/\.git$/i, '');
27
+ return { github: { owner, repo: clean }, url: `https://github.com/${owner}/${clean}` };
28
+ }
29
+ return { url: s.replace(/\.git$/i, '').replace(/\/$/, '') };
30
+ }
31
+ function tagValue(xml, tag) {
32
+ return new RegExp(`<${tag}>\\s*([^<]*?)\\s*</${tag}>`).exec(xml)?.[1];
33
+ }
34
+ export function pomCandidates(pom) {
35
+ const scm = /<scm>([\s\S]*?)<\/scm>/.exec(pom)?.[1] ?? '';
36
+ const scmValues = ['url', 'connection', 'developerConnection'].map((t) => tagValue(scm, t));
37
+ return [...scmValues, tagValue(pom.replace(POM_NOISE, ''), 'url')].filter((v) => !!v);
38
+ }
39
+ export function pomParent(pom) {
40
+ const block = /<parent>([\s\S]*?)<\/parent>/.exec(pom)?.[1];
41
+ if (!block)
42
+ return undefined;
43
+ const group = tagValue(block, 'groupId');
44
+ const artifact = tagValue(block, 'artifactId');
45
+ const version = tagValue(block, 'version');
46
+ return group && artifact && version ? { group, artifact, version } : undefined;
47
+ }
48
+ function pickSource(candidates) {
49
+ const normalized = candidates.map(normalizeScmUrl).filter((s) => !!s);
50
+ return normalized.find((s) => s.github) ?? normalized[0];
51
+ }
52
+ export function androidxReleasesUrl(group) {
53
+ const [root, first, second] = group.split('.');
54
+ if (root !== 'androidx' || !first)
55
+ return undefined;
56
+ const name = first === 'compose' && second ? `compose-${second}` : first;
57
+ return `https://developer.android.com/jetpack/androidx/releases/${name}`;
58
+ }
59
+ export async function findSourceRepo(http, dep, version, repo) {
60
+ if (dep.kind === 'gradle')
61
+ return { url: `https://docs.gradle.org/${version}/release-notes.html` };
62
+ const [root, , owner] = dep.group.split('.');
63
+ if (dep.group.startsWith('com.github.') && owner) {
64
+ return { github: { owner, repo: dep.artifact }, url: `https://github.com/${owner}/${dep.artifact}` };
65
+ }
66
+ if (dep.group === 'org.jetbrains.kotlin' || dep.group.startsWith('org.jetbrains.kotlin.')) {
67
+ return { github: { owner: 'JetBrains', repo: 'kotlin' }, url: 'https://github.com/JetBrains/kotlin' };
68
+ }
69
+ const androidx = root === 'androidx' ? androidxReleasesUrl(dep.group) : undefined;
70
+ if (androidx)
71
+ return { url: androidx };
72
+ if (!repo)
73
+ return {};
74
+ let coords = {
75
+ group: dep.group,
76
+ artifact: dep.artifact,
77
+ version,
78
+ };
79
+ for (let depth = 0; coords && depth <= MAX_PARENT_DEPTH; depth++) {
80
+ const pom = await fetchPom(http, coords.group, coords.artifact, coords.version, repo);
81
+ if (!pom)
82
+ break;
83
+ const found = pickSource(pomCandidates(pom));
84
+ if (found)
85
+ return found;
86
+ coords = depth === 0 && dep.artifact.endsWith('.gradle.plugin') ? (pomFirstDependency(pom) ?? pomParent(pom)) : pomParent(pom);
87
+ }
88
+ return {};
89
+ }
90
+ /** Gradle plugin marker POMs only declare the real plugin artifact as their single dependency. */
91
+ export function pomFirstDependency(pom) {
92
+ const block = /<dependency>([\s\S]*?)<\/dependency>/.exec(pom)?.[1];
93
+ const tag = (name) => block && new RegExp(`<${name}>\\s*([^<\\s]+)\\s*</${name}>`).exec(block)?.[1];
94
+ const group = tag('groupId');
95
+ const artifact = tag('artifactId');
96
+ const version = tag('version');
97
+ return group && artifact && version ? { group, artifact, version } : undefined;
98
+ }
99
+ export function normalizeTag(tag, artifact) {
100
+ let v = tag.trim();
101
+ const lower = () => v.toLowerCase();
102
+ const art = artifact.toLowerCase();
103
+ const slash = v.lastIndexOf('/');
104
+ if (slash !== -1) {
105
+ const scope = lower().slice(0, slash).split('/').pop();
106
+ if (!art.split(/[-_.]/).includes(scope) && scope !== art)
107
+ return v;
108
+ v = v.slice(slash + 1);
109
+ }
110
+ for (const prefix of [`${art}@`, `${art}-`, 'release-', 'v']) {
111
+ if (lower().startsWith(prefix))
112
+ v = v.slice(prefix.length);
113
+ }
114
+ return v;
115
+ }
116
+ const isVersionLike = (v) => /^\d+(\.\d+)*([-.+_][0-9A-Za-z.+_-]*)?$/.test(v);
117
+ function inRange(v, from, to, compare) {
118
+ return compare(v, from) > 0 && compare(v, to) <= 0;
119
+ }
120
+ const sortDesc = (entries, compare) => entries.sort((a, b) => compare(b.version, a.version));
121
+ async function releaseEntries(http, gh, artifact, from, to, compare) {
122
+ const entries = [];
123
+ for (let page = 1; page <= MAX_RELEASE_PAGES; page++) {
124
+ const res = await http
125
+ .get(`https://api.github.com/repos/${gh.owner}/${gh.repo}/releases?per_page=100&page=${page}`)
126
+ .catch(() => undefined);
127
+ if (res && (res.status === 403 || res.status === 429))
128
+ return { entries, rateLimited: true };
129
+ if (res?.status !== 200)
130
+ break;
131
+ const releases = JSON.parse(res.body);
132
+ const versions = [];
133
+ for (const r of releases) {
134
+ const version = normalizeTag(r.tag_name, artifact);
135
+ if (r.draft || !isVersionLike(version))
136
+ continue;
137
+ versions.push(version);
138
+ if (!inRange(version, from, to, compare))
139
+ continue;
140
+ entries.push({
141
+ version,
142
+ title: r.name || undefined,
143
+ body: r.body ?? '',
144
+ url: r.html_url,
145
+ date: r.published_at ?? undefined,
146
+ });
147
+ }
148
+ if (releases.length < 100 || versions.every((v) => compare(v, from) < 0))
149
+ break;
150
+ }
151
+ return { entries, rateLimited: false };
152
+ }
153
+ const HEADING = /^(#{1,3})\s+(.*?)\s*#*\s*$/;
154
+ const VERSION_TOKEN = /(?:^|[^\w.])v?(\d+\.\d+(?:\.\d+)*(?:-[0-9A-Za-z][0-9A-Za-z.]*)?)(?![\w.])/;
155
+ export function splitChangelog(markdown) {
156
+ const sections = [];
157
+ let current;
158
+ for (const line of markdown.split(/\r?\n/)) {
159
+ const heading = HEADING.exec(line);
160
+ if (heading) {
161
+ const level = heading[1].length;
162
+ const title = heading[2];
163
+ const version = VERSION_TOKEN.exec(title)?.[1];
164
+ if (version) {
165
+ current = { version, title, body: '', level, lines: [] };
166
+ sections.push(current);
167
+ continue;
168
+ }
169
+ if (current && level <= current.level)
170
+ current = undefined;
171
+ }
172
+ current?.lines.push(line);
173
+ }
174
+ return sections.map(({ version, title, lines }) => ({ version, title, body: lines.join('\n').trim() }));
175
+ }
176
+ async function changelogFileEntries(http, gh, from, to, compare) {
177
+ for (const file of CHANGELOG_FILES) {
178
+ const res = await http
179
+ .get(`https://raw.githubusercontent.com/${gh.owner}/${gh.repo}/HEAD/${file}`)
180
+ .catch(() => undefined);
181
+ if (res?.status !== 200)
182
+ continue;
183
+ const entries = splitChangelog(res.body).filter((e) => inRange(e.version, from, to, compare));
184
+ if (entries.length) {
185
+ const source = `https://github.com/${gh.owner}/${gh.repo}/blob/HEAD/${file}`;
186
+ return { entries: sortDesc(dedupe(entries), compare), source };
187
+ }
188
+ }
189
+ return undefined;
190
+ }
191
+ function dedupe(entries) {
192
+ const seen = new Set();
193
+ return entries.filter((e) => !seen.has(e.version) && !!seen.add(e.version));
194
+ }
195
+ export async function getChangelog(http, dep, from, to, repo, compare) {
196
+ const src = await findSourceRepo(http, dep, to, repo);
197
+ const gh = src.github;
198
+ if (!gh)
199
+ return { entries: [], source: src.url };
200
+ const releasesUrl = `https://github.com/${gh.owner}/${gh.repo}/releases`;
201
+ const releases = await releaseEntries(http, gh, dep.artifact, from, to, compare);
202
+ if (releases.entries.length)
203
+ return { entries: sortDesc(releases.entries, compare), source: releasesUrl };
204
+ if (releases.rateLimited)
205
+ return { entries: [], source: releasesUrl };
206
+ return (await changelogFileEntries(http, gh, from, to, compare)) ?? { entries: [], source: releasesUrl };
207
+ }
package/dist/cli.js ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ import { EXIT, fail, main } from './main.js';
3
+ main().then((code) => process.stdout.write('', () => process.exit(code)), (e) => fail(EXIT.network, e.message));
package/dist/config.js ADDED
@@ -0,0 +1,150 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import { dirname, join, relative, resolve, sep } from 'node:path';
4
+ import { TARGETS } from './types.js';
5
+ export const PROJECT_CONFIG = '.jvm-upgrade.json';
6
+ export const DEFAULTS = {
7
+ target: 'major',
8
+ pre: false,
9
+ cooldown: 0,
10
+ allowDowngrade: false,
11
+ include: [],
12
+ exclude: [],
13
+ repositories: [],
14
+ noCache: false,
15
+ concurrency: 8,
16
+ };
17
+ export class ConfigError extends Error {
18
+ }
19
+ export function userDir() {
20
+ return process.env.JVM_UPGRADE_HOME ?? join(homedir(), '.jvm-upgrade');
21
+ }
22
+ const PROJECT_KEYS = {
23
+ target: (v) => TARGETS.includes(v),
24
+ pre: (v) => typeof v === 'boolean',
25
+ cooldown: (v) => Number.isInteger(v) && v >= 0,
26
+ allowDowngrade: (v) => typeof v === 'boolean',
27
+ include: isStringArray,
28
+ exclude: isStringArray,
29
+ repositories: isStringArray,
30
+ };
31
+ const USER_KEYS = {
32
+ ...PROJECT_KEYS,
33
+ cacheDir: (v) => typeof v === 'string',
34
+ noCache: (v) => typeof v === 'boolean',
35
+ concurrency: (v) => Number.isInteger(v) && v > 0,
36
+ };
37
+ function isStringArray(v) {
38
+ return Array.isArray(v) && v.every((x) => typeof x === 'string');
39
+ }
40
+ async function readJson(file) {
41
+ let text;
42
+ try {
43
+ text = await readFile(file, 'utf8');
44
+ }
45
+ catch (e) {
46
+ if (e.code === 'ENOENT')
47
+ return undefined;
48
+ throw e;
49
+ }
50
+ try {
51
+ const data = JSON.parse(text);
52
+ if (data === null || typeof data !== 'object' || Array.isArray(data))
53
+ throw new Error('expected an object');
54
+ return data;
55
+ }
56
+ catch (e) {
57
+ throw new ConfigError(`${file}: invalid JSON (${e.message})`);
58
+ }
59
+ }
60
+ function validate(file, data, keys) {
61
+ for (const [key, value] of Object.entries(data)) {
62
+ const check = keys[key];
63
+ if (!check)
64
+ throw new ConfigError(`${file}: unknown option "${key}"`);
65
+ if (!check(value))
66
+ throw new ConfigError(`${file}: invalid value for "${key}"`);
67
+ }
68
+ return data;
69
+ }
70
+ export async function loadUserConfig() {
71
+ const file = join(userDir(), 'config.json');
72
+ const data = await readJson(file);
73
+ return { ...DEFAULTS, ...(data && validate(file, data, USER_KEYS)) };
74
+ }
75
+ /** Returns a resolver giving the merged project options for a build file ("closest config wins"). */
76
+ export function projectConfigResolver(root, base) {
77
+ const cache = new Map();
78
+ const absRoot = resolve(root);
79
+ const forDir = (dir) => {
80
+ let hit = cache.get(dir);
81
+ if (!hit) {
82
+ hit = (async () => {
83
+ const parent = dir === absRoot || !isInside(absRoot, dir) ? base : await forDir(dirname(dir));
84
+ const file = join(dir, PROJECT_CONFIG);
85
+ const data = await readJson(file);
86
+ return data ? { ...parent, ...validate(file, data, PROJECT_KEYS) } : parent;
87
+ })();
88
+ cache.set(dir, hit);
89
+ }
90
+ return hit;
91
+ };
92
+ return (file) => {
93
+ const dir = dirname(resolve(file));
94
+ return forDir(dir.endsWith(`${sep}gradle`) && dirname(dir) === absRoot ? absRoot : dir);
95
+ };
96
+ }
97
+ function isInside(root, dir) {
98
+ const rel = relative(root, dir);
99
+ return rel !== '' && !rel.startsWith('..') && !rel.startsWith(sep);
100
+ }
101
+ export async function loadCredentials() {
102
+ const file = join(userDir(), 'credentials.json');
103
+ const data = await readJson(file);
104
+ if (!data)
105
+ return [];
106
+ const repos = data.repositories;
107
+ if (!Array.isArray(repos))
108
+ throw new ConfigError(`${file}: "repositories" must be an array`);
109
+ return repos.map((entry, i) => {
110
+ const at = `${file}: repositories[${i}]`;
111
+ if (typeof entry?.url !== 'string')
112
+ throw new ConfigError(`${at}: "url" is required`);
113
+ const hasToken = entry.token !== undefined;
114
+ const hasBasic = entry.username !== undefined || entry.password !== undefined;
115
+ if (hasToken === hasBasic)
116
+ throw new ConfigError(`${at}: use either "token" or "username" + "password"`);
117
+ const cred = { url: entry.url };
118
+ for (const key of ['token', 'username', 'password']) {
119
+ if (entry[key] === undefined)
120
+ continue;
121
+ if (typeof entry[key] !== 'string')
122
+ throw new ConfigError(`${at}: "${key}" must be a string`);
123
+ cred[key] = expandEnv(entry[key], at);
124
+ }
125
+ if (hasBasic && (cred.username === undefined || cred.password === undefined)) {
126
+ throw new ConfigError(`${at}: "username" and "password" are both required`);
127
+ }
128
+ return cred;
129
+ });
130
+ }
131
+ function expandEnv(value, at) {
132
+ if (!value.startsWith('$'))
133
+ return value;
134
+ const name = value.slice(1);
135
+ const env = process.env[name];
136
+ if (env === undefined)
137
+ throw new ConfigError(`${at}: environment variable ${name} is not set`);
138
+ return env;
139
+ }
140
+ function globToRegExp(glob) {
141
+ const body = glob.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.');
142
+ return new RegExp(`^${body}$`, 'i');
143
+ }
144
+ /** Patterns without ":" match the group only; "group:artifact" patterns match both. */
145
+ export function matchesAny(patterns, group, artifact) {
146
+ return patterns.some((p) => globToRegExp(p).test(p.includes(':') ? `${group}:${artifact}` : group));
147
+ }
148
+ export function splitPatterns(values) {
149
+ return values.flatMap((v) => v.split(',')).map((v) => v.trim()).filter(Boolean);
150
+ }