jvm-upgrade 0.1.0 → 0.2.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/README.md +10 -2
- package/dist/apply.js +3 -2
- package/dist/changelog.js +49 -12
- package/dist/http.js +22 -11
- package/dist/main.js +11 -9
- package/dist/repos.js +14 -6
- package/dist/rows.js +4 -0
- package/dist/scan/gradle.js +49 -31
- package/dist/scan/index.js +15 -5
- package/dist/scan/maven.js +1 -2
- package/dist/version.js +0 -4
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -35,7 +35,7 @@ Run jvm-upgrade -u to apply the underlined versions (target: major), or jvm-upgr
|
|
|
35
35
|
npm install -g jvm-upgrade
|
|
36
36
|
```
|
|
37
37
|
|
|
38
|
-
Or run it once with `npx jvm-upgrade`. Needs Node.js
|
|
38
|
+
Or run it once with `npx jvm-upgrade`. Needs Node.js 22.13 or newer.
|
|
39
39
|
|
|
40
40
|
## Usage
|
|
41
41
|
|
|
@@ -124,7 +124,15 @@ Dynamic versions (`1.+`, `[1.0,2.0)`, `latest.release`) are shown but never rewr
|
|
|
124
124
|
|
|
125
125
|
## Changelogs
|
|
126
126
|
|
|
127
|
-
For each upgrade, jvm-upgrade
|
|
127
|
+
For each upgrade, jvm-upgrade finds the source repository:
|
|
128
|
+
|
|
129
|
+
- the POM's `<scm>` or `<url>`, following parent POMs and Gradle plugin markers,
|
|
130
|
+
- the GitHub repo itself for JitPack (`com.github.user:repo`),
|
|
131
|
+
- `gradle/gradle` for the Gradle wrapper.
|
|
132
|
+
|
|
133
|
+
It then reads the GitHub releases between your version and the new one. Prereleases are skipped unless you upgrade to one. If there are no releases, it reads `CHANGELOG.md` (and similar files) and cuts out the right sections. Tags without the major version (protobuf-java `4.36.2` is tag `v36.2`) are matched too.
|
|
134
|
+
|
|
135
|
+
If it finds nothing, it gives you a link: the release notes page for androidx, Google Play services, Firebase and Gradle, or the project's releases page.
|
|
128
136
|
|
|
129
137
|
Set `GITHUB_TOKEN` (or `GH_TOKEN`) to avoid GitHub rate limits.
|
|
130
138
|
|
package/dist/apply.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { randomBytes } from 'node:crypto';
|
|
2
|
-
import { readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { readFile, realpath, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
3
3
|
export class EditConflictError extends Error {
|
|
4
4
|
}
|
|
5
5
|
/** Rewrites only the version literals. Every file is checked first, then written atomically. */
|
|
@@ -41,7 +41,8 @@ function rewrite(file, content, edits) {
|
|
|
41
41
|
}
|
|
42
42
|
return out;
|
|
43
43
|
}
|
|
44
|
-
async function writeAtomic(
|
|
44
|
+
async function writeAtomic(path, content) {
|
|
45
|
+
const file = await realpath(path);
|
|
45
46
|
const tmp = `${file}.${randomBytes(4).toString('hex')}.tmp`;
|
|
46
47
|
const { mode } = await stat(file);
|
|
47
48
|
try {
|
package/dist/changelog.js
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import { fetchPom } from './repos.js';
|
|
2
|
+
import { isPrerelease } from './version.js';
|
|
2
3
|
const CHANGELOG_FILES = ['CHANGELOG.md', 'CHANGES.md', 'HISTORY.md', 'RELEASE_NOTES.md', 'docs/CHANGELOG.md'];
|
|
3
4
|
const MAX_RELEASE_PAGES = 5;
|
|
4
5
|
const MAX_PARENT_DEPTH = 3;
|
|
6
|
+
const GOOGLE_NOTES = [
|
|
7
|
+
['com.google.android.gms', 'https://developers.google.com/android/guides/releases'],
|
|
8
|
+
['com.google.gms', 'https://developers.google.com/android/guides/releases'],
|
|
9
|
+
['com.google.firebase', 'https://firebase.google.com/support/release-notes/android'],
|
|
10
|
+
];
|
|
5
11
|
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
12
|
export function normalizeScmUrl(raw) {
|
|
7
13
|
let s = raw.trim();
|
|
@@ -56,9 +62,21 @@ export function androidxReleasesUrl(group) {
|
|
|
56
62
|
const name = first === 'compose' && second ? `compose-${second}` : first;
|
|
57
63
|
return `https://developer.android.com/jetpack/androidx/releases/${name}`;
|
|
58
64
|
}
|
|
65
|
+
function googleNotesUrl(group) {
|
|
66
|
+
return GOOGLE_NOTES.find(([prefix]) => group === prefix || group.startsWith(`${prefix}.`))?.[1];
|
|
67
|
+
}
|
|
59
68
|
export async function findSourceRepo(http, dep, version, repo) {
|
|
60
|
-
if (dep.kind === 'gradle')
|
|
61
|
-
|
|
69
|
+
if (dep.kind === 'gradle') {
|
|
70
|
+
const notesUrl = `https://docs.gradle.org/${version}/release-notes.html`;
|
|
71
|
+
return { github: { owner: 'gradle', repo: 'gradle' }, url: notesUrl, notesUrl };
|
|
72
|
+
}
|
|
73
|
+
const fromPom = await sourceFromPom(http, dep, version, repo);
|
|
74
|
+
const notesUrl = googleNotesUrl(dep.group);
|
|
75
|
+
if (notesUrl)
|
|
76
|
+
return { ...fromPom, url: fromPom.url ?? notesUrl, notesUrl };
|
|
77
|
+
return fromPom;
|
|
78
|
+
}
|
|
79
|
+
async function sourceFromPom(http, dep, version, repo) {
|
|
62
80
|
const [root, , owner] = dep.group.split('.');
|
|
63
81
|
if (dep.group.startsWith('com.github.') && owner) {
|
|
64
82
|
return { github: { owner, repo: dep.artifact }, url: `https://github.com/${owner}/${dep.artifact}` };
|
|
@@ -114,11 +132,19 @@ export function normalizeTag(tag, artifact) {
|
|
|
114
132
|
return v;
|
|
115
133
|
}
|
|
116
134
|
const isVersionLike = (v) => /^\d+(\.\d+)*([-.+_][0-9A-Za-z.+_-]*)?$/.test(v);
|
|
135
|
+
const trimZeros = (v) => v.replace(/(\.0)+(?=$|-)/, '');
|
|
136
|
+
/** Trailing ".0" parts are ignored so a "v9.8.0" tag matches version "9.8". */
|
|
117
137
|
function inRange(v, from, to, compare) {
|
|
118
|
-
|
|
138
|
+
const x = trimZeros(v);
|
|
139
|
+
return compare(x, trimZeros(from)) > 0 && compare(x, trimZeros(to)) <= 0;
|
|
140
|
+
}
|
|
141
|
+
/** Some projects tag without the artifact's major, e.g. protobuf-java 4.36.2 is tag v36.2. */
|
|
142
|
+
function withoutMajor(v) {
|
|
143
|
+
const parts = v.split('.');
|
|
144
|
+
return parts.length >= 3 && /^\d+$/.test(parts[0]) ? parts.slice(1).join('.') : undefined;
|
|
119
145
|
}
|
|
120
146
|
const sortDesc = (entries, compare) => entries.sort((a, b) => compare(b.version, a.version));
|
|
121
|
-
async function releaseEntries(http, gh, artifact, from, to, compare) {
|
|
147
|
+
async function releaseEntries(http, gh, artifact, from, to, compare, includePre) {
|
|
122
148
|
const entries = [];
|
|
123
149
|
for (let page = 1; page <= MAX_RELEASE_PAGES; page++) {
|
|
124
150
|
const res = await http
|
|
@@ -135,7 +161,7 @@ async function releaseEntries(http, gh, artifact, from, to, compare) {
|
|
|
135
161
|
if (r.draft || !isVersionLike(version))
|
|
136
162
|
continue;
|
|
137
163
|
versions.push(version);
|
|
138
|
-
if (!inRange(version, from, to, compare))
|
|
164
|
+
if (!inRange(version, from, to, compare) || (r.prerelease && !includePre))
|
|
139
165
|
continue;
|
|
140
166
|
entries.push({
|
|
141
167
|
version,
|
|
@@ -196,12 +222,23 @@ export async function getChangelog(http, dep, from, to, repo, compare) {
|
|
|
196
222
|
const src = await findSourceRepo(http, dep, to, repo);
|
|
197
223
|
const gh = src.github;
|
|
198
224
|
if (!gh)
|
|
199
|
-
return { entries: [], source: src.url };
|
|
225
|
+
return { entries: [], source: src.notesUrl ?? src.url };
|
|
200
226
|
const releasesUrl = `https://github.com/${gh.owner}/${gh.repo}/releases`;
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
227
|
+
const fallback = { entries: [], source: src.notesUrl ?? releasesUrl };
|
|
228
|
+
const includePre = isPrerelease(to);
|
|
229
|
+
const ranges = [[from, to]];
|
|
230
|
+
const [shortFrom, shortTo] = [withoutMajor(from), withoutMajor(to)];
|
|
231
|
+
if (shortFrom && shortTo && compare(shortFrom, shortTo) < 0)
|
|
232
|
+
ranges.push([shortFrom, shortTo]);
|
|
233
|
+
for (const [a, b] of ranges) {
|
|
234
|
+
const releases = await releaseEntries(http, gh, dep.artifact, a, b, compare, includePre);
|
|
235
|
+
if (releases.entries.length)
|
|
236
|
+
return { entries: sortDesc(releases.entries, compare), source: releasesUrl };
|
|
237
|
+
if (releases.rateLimited)
|
|
238
|
+
return fallback;
|
|
239
|
+
const files = await changelogFileEntries(http, gh, a, b, compare);
|
|
240
|
+
if (files)
|
|
241
|
+
return files;
|
|
242
|
+
}
|
|
243
|
+
return fallback;
|
|
207
244
|
}
|
package/dist/http.js
CHANGED
|
@@ -21,7 +21,7 @@ export async function clearCache(dir) {
|
|
|
21
21
|
}
|
|
22
22
|
export function authHeader(url, credentials) {
|
|
23
23
|
const match = credentials
|
|
24
|
-
.filter((c) => url
|
|
24
|
+
.filter((c) => underPrefix(url, c.url) && sameOrigin(url, c.url))
|
|
25
25
|
.sort((a, b) => b.url.length - a.url.length)[0];
|
|
26
26
|
if (match?.token)
|
|
27
27
|
return `Bearer ${match.token}`;
|
|
@@ -33,6 +33,10 @@ export function authHeader(url, credentials) {
|
|
|
33
33
|
return `Bearer ${gh}`;
|
|
34
34
|
return undefined;
|
|
35
35
|
}
|
|
36
|
+
function underPrefix(url, prefix) {
|
|
37
|
+
const base = prefix.endsWith('/') ? prefix : `${prefix}/`;
|
|
38
|
+
return url === prefix || url.startsWith(base);
|
|
39
|
+
}
|
|
36
40
|
function hostOf(url) {
|
|
37
41
|
try {
|
|
38
42
|
return new URL(url).host;
|
|
@@ -69,10 +73,12 @@ function limiter(max) {
|
|
|
69
73
|
}
|
|
70
74
|
};
|
|
71
75
|
}
|
|
72
|
-
async function readLocal(url) {
|
|
76
|
+
async function readLocal(url, method) {
|
|
73
77
|
try {
|
|
74
|
-
const
|
|
75
|
-
|
|
78
|
+
const path = fileURLToPath(url);
|
|
79
|
+
const { mtime } = await stat(path);
|
|
80
|
+
const body = method === 'HEAD' ? '' : await readFile(path, 'utf8');
|
|
81
|
+
return { status: 200, headers: { 'last-modified': mtime.toUTCString() }, body };
|
|
76
82
|
}
|
|
77
83
|
catch {
|
|
78
84
|
return { status: 404, headers: {}, body: '' };
|
|
@@ -89,6 +95,7 @@ async function atomicWrite(file, data) {
|
|
|
89
95
|
throw err;
|
|
90
96
|
}
|
|
91
97
|
}
|
|
98
|
+
const cacheable = (res) => res.status === 200 || res.status === 404;
|
|
92
99
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
93
100
|
export function createHttp(opts) {
|
|
94
101
|
const ttl = opts.ttlMs ?? DEFAULT_TTL;
|
|
@@ -104,10 +111,11 @@ export function createHttp(opts) {
|
|
|
104
111
|
const file = cacheFile(url);
|
|
105
112
|
try {
|
|
106
113
|
const entry = JSON.parse(await readFile(file, 'utf8'));
|
|
107
|
-
|
|
108
|
-
|
|
114
|
+
const age = Date.now() - entry.time;
|
|
115
|
+
// Stale entries are not deleted here: that races fresh writes from other processes.
|
|
116
|
+
if (age >= 0 && age <= ttl && typeof entry.status === 'number' && typeof entry.body === 'string') {
|
|
117
|
+
return { status: entry.status, headers: entry.headers ?? {}, body: entry.body };
|
|
109
118
|
}
|
|
110
|
-
await rm(file, { force: true });
|
|
111
119
|
}
|
|
112
120
|
catch {
|
|
113
121
|
// missing or corrupt entry
|
|
@@ -115,7 +123,7 @@ export function createHttp(opts) {
|
|
|
115
123
|
return undefined;
|
|
116
124
|
}
|
|
117
125
|
async function writeCache(url, res) {
|
|
118
|
-
if (!cacheDir || (res
|
|
126
|
+
if (!cacheDir || !cacheable(res))
|
|
119
127
|
return;
|
|
120
128
|
try {
|
|
121
129
|
dirReady ??= mkdir(cacheDir, { recursive: true });
|
|
@@ -145,6 +153,8 @@ export function createHttp(opts) {
|
|
|
145
153
|
current = new URL(location, current).toString();
|
|
146
154
|
continue;
|
|
147
155
|
}
|
|
156
|
+
if (method === 'HEAD')
|
|
157
|
+
await res.body?.cancel();
|
|
148
158
|
return {
|
|
149
159
|
status: res.status,
|
|
150
160
|
headers: Object.fromEntries(res.headers),
|
|
@@ -175,7 +185,7 @@ export function createHttp(opts) {
|
|
|
175
185
|
}
|
|
176
186
|
async function get(url) {
|
|
177
187
|
if (url.startsWith('file:'))
|
|
178
|
-
return readLocal(url);
|
|
188
|
+
return readLocal(url, 'GET');
|
|
179
189
|
const authed = authHeader(url, opts.credentials) !== undefined;
|
|
180
190
|
const cached = authed ? undefined : await readCache(url);
|
|
181
191
|
if (cached)
|
|
@@ -191,13 +201,14 @@ export function createHttp(opts) {
|
|
|
191
201
|
if (!p) {
|
|
192
202
|
p = get(url);
|
|
193
203
|
inflight.set(url, p);
|
|
194
|
-
|
|
204
|
+
// Keep good answers for the whole run; let errors and 5xx/429 be retried later.
|
|
205
|
+
p.then((res) => cacheable(res) || inflight.delete(url), () => inflight.delete(url));
|
|
195
206
|
}
|
|
196
207
|
return p;
|
|
197
208
|
},
|
|
198
209
|
async head(url) {
|
|
199
210
|
if (url.startsWith('file:'))
|
|
200
|
-
return readLocal(url);
|
|
211
|
+
return readLocal(url, 'HEAD');
|
|
201
212
|
return network('HEAD', url);
|
|
202
213
|
},
|
|
203
214
|
async close() {
|
package/dist/main.js
CHANGED
|
@@ -150,6 +150,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
150
150
|
await clearCache(cacheDir);
|
|
151
151
|
const showProgress = flags.progress && process.stderr.isTTY;
|
|
152
152
|
const log = (text) => process.stderr.write(`${text}\n`);
|
|
153
|
+
const say = (text) => (json ? process.stderr : process.stdout).write(`${text}\n`);
|
|
153
154
|
let scan;
|
|
154
155
|
try {
|
|
155
156
|
scan = await scanProject(root);
|
|
@@ -160,7 +161,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
160
161
|
for (const warning of scan.warnings)
|
|
161
162
|
log(styleText('yellow', `warning: ${warning}`));
|
|
162
163
|
if (!scan.dependencies.length) {
|
|
163
|
-
|
|
164
|
+
say('No Gradle or Maven dependencies found.');
|
|
164
165
|
if (json)
|
|
165
166
|
process.stdout.write('[]\n');
|
|
166
167
|
return EXIT.ok;
|
|
@@ -194,6 +195,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
194
195
|
if (nameCount.get(row.name) > 1)
|
|
195
196
|
row.name += ` (${relative(root, row.deps[0].file)})`;
|
|
196
197
|
const outdated = rows.filter((r) => r.choices.length);
|
|
198
|
+
const selectable = outdated.filter((r) => r.selected !== undefined);
|
|
197
199
|
const failed = rows.filter((r) => !r.choices.length && r.errors.length);
|
|
198
200
|
const failedAll = rows.length > 0 && failed.length === rows.length;
|
|
199
201
|
const visible = flags.verbose ? rows : [...outdated, ...failed];
|
|
@@ -202,9 +204,9 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
202
204
|
}
|
|
203
205
|
else if (!flags.interactive) {
|
|
204
206
|
if (visible.length)
|
|
205
|
-
|
|
207
|
+
say(renderTable(visible, root));
|
|
206
208
|
if (!outdated.length)
|
|
207
|
-
|
|
209
|
+
say(styleText('green', `All ${rows.length - failed.length} dependencies are up to date.`));
|
|
208
210
|
}
|
|
209
211
|
if (failedAll)
|
|
210
212
|
return EXIT.network;
|
|
@@ -214,7 +216,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
214
216
|
if (flags.interactive) {
|
|
215
217
|
const picked = await (await import('./tui.js')).pick(outdated, (row, choice) => loadChangelog(http, row, choice));
|
|
216
218
|
if (!picked) {
|
|
217
|
-
|
|
219
|
+
say('Cancelled. Nothing was changed.');
|
|
218
220
|
return EXIT.ok;
|
|
219
221
|
}
|
|
220
222
|
chosen = picked;
|
|
@@ -232,7 +234,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
232
234
|
const writable = [...chosen].filter(([row]) => row.location);
|
|
233
235
|
if (flags.upgrade || flags.interactive) {
|
|
234
236
|
if (!writable.length) {
|
|
235
|
-
|
|
237
|
+
say('Nothing to upgrade.');
|
|
236
238
|
return EXIT.ok;
|
|
237
239
|
}
|
|
238
240
|
const edits = writable.map(([row, choice]) => ({ location: row.location, from: row.current, to: choice.version }));
|
|
@@ -245,15 +247,15 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
245
247
|
throw e;
|
|
246
248
|
}
|
|
247
249
|
for (const [row, choice] of writable)
|
|
248
|
-
|
|
249
|
-
|
|
250
|
+
say(`${styleText('green', '✔')} ${row.name} ${row.current} → ${choice.version}`);
|
|
251
|
+
say(styleText('gray', 'Build files updated. Run your build and tests to check the upgrades.'));
|
|
250
252
|
return EXIT.ok;
|
|
251
253
|
}
|
|
252
254
|
if (!json) {
|
|
253
255
|
const target = rootOptions.target;
|
|
254
|
-
|
|
256
|
+
say(styleText('gray', `\nRun ${styleText('cyan', 'jvm-upgrade -u')} to apply the underlined versions (target: ${target}), or ${styleText('cyan', 'jvm-upgrade -i')} to pick.`));
|
|
255
257
|
}
|
|
256
|
-
return flags['error-on-outdated'] ? EXIT.outdated : EXIT.ok;
|
|
258
|
+
return flags['error-on-outdated'] && selectable.length ? EXIT.outdated : EXIT.ok;
|
|
257
259
|
}
|
|
258
260
|
finally {
|
|
259
261
|
await http.close();
|
package/dist/repos.js
CHANGED
|
@@ -41,21 +41,29 @@ function loadGradle(http) {
|
|
|
41
41
|
return p;
|
|
42
42
|
}
|
|
43
43
|
async function fetchGithubTags(http, owner, repo) {
|
|
44
|
-
const
|
|
44
|
+
const url = `https://api.github.com/repos/${owner}/${repo}/tags?per_page=100`;
|
|
45
|
+
const res = await http.get(url);
|
|
46
|
+
if (res.status === 404)
|
|
47
|
+
return [];
|
|
45
48
|
if (res.status !== 200)
|
|
46
|
-
|
|
49
|
+
throw new Error(`${url}: HTTP ${res.status}`);
|
|
47
50
|
const tags = JSON.parse(res.body);
|
|
48
51
|
return tags.map((t) => t.name.replace(/^v(?=\d)/i, ''));
|
|
49
52
|
}
|
|
50
53
|
async function versionsFromRepo(http, dep, repo) {
|
|
51
54
|
const url = `${repo}${artifactPath(dep.group, dep.artifact)}/maven-metadata.xml`;
|
|
52
55
|
const res = await http.get(url);
|
|
53
|
-
if (res.status === 200)
|
|
56
|
+
if (res.status === 200) {
|
|
57
|
+
// Some proxies answer 200 with an HTML page instead of 404 or 401.
|
|
58
|
+
if (!res.body.includes('<metadata'))
|
|
59
|
+
throw new Error(`${url}: not a maven-metadata.xml response`);
|
|
54
60
|
return parseMetadata(res.body);
|
|
61
|
+
}
|
|
55
62
|
if (res.status === 404) {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
63
|
+
// JitPack: com.github.Owner:Repo, or com.github.Owner.Repo:Module for multi-module builds.
|
|
64
|
+
const [, , owner, ghRepo = dep.artifact] = dep.group.split('.');
|
|
65
|
+
if (repo.includes('jitpack.io') && dep.group.startsWith('com.github.') && owner) {
|
|
66
|
+
return fetchGithubTags(http, owner, ghRepo);
|
|
59
67
|
}
|
|
60
68
|
return [];
|
|
61
69
|
}
|
package/dist/rows.js
CHANGED
|
@@ -42,6 +42,10 @@ export async function buildRows(deps, opts) {
|
|
|
42
42
|
try {
|
|
43
43
|
return await buildRow(group, optionsOf.get(group[0]), opts);
|
|
44
44
|
}
|
|
45
|
+
catch (e) {
|
|
46
|
+
const first = group[0];
|
|
47
|
+
return { name: displayName(first), deps: group, current: first.version, location: first.location, choices: [], repoOf: new Map(), errors: [e.message] };
|
|
48
|
+
}
|
|
45
49
|
finally {
|
|
46
50
|
opts.progress.tick();
|
|
47
51
|
}
|
package/dist/scan/gradle.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
2
|
import { dirname, join, resolve } from 'node:path';
|
|
3
3
|
import { dedupeRepos, GOOGLE, GRADLE_PLUGIN_PORTAL, MAVEN_CENTRAL, NAMED_REPOS } from './repos.js';
|
|
4
|
-
const COORD_RE = /(["'])([\w.\-]
|
|
4
|
+
const COORD_RE = /(["'])([A-Za-z][\w.\-]*):([\w.\-]+):((?:\$\{[^}\n]*\}|[\w.\-+!$\[\](),])+)(?::[\w.\-]+)?(?:@\w+)?\1/dg;
|
|
5
|
+
// Rejects look-alikes such as "jdbc:h2:mem:test".
|
|
6
|
+
const VERSIONISH_RE = /\d|\$|^\+$|^latest\.|SNAPSHOT/;
|
|
5
7
|
const MAP_RE = /\bgroup\s*[:=]\s*(["'])([^"']+)\1\s*,\s*name\s*[:=]\s*(["'])([^"']+)\3\s*,\s*version\s*[:=]\s*(?:(["'])([^"']*)\5|([A-Za-z_][\w.]*))/dg;
|
|
6
|
-
const KOTLIN_DEP_RE = /\bkotlin\s*\(\s*"([\w\-]+)"\s*,\s*(?:version\s*=\s*)?"([^"]*)"\s*\)/dg;
|
|
8
|
+
const KOTLIN_DEP_RE = /\bkotlin\s*\(\s*"([\w\-]+)"\s*,\s*(?:version\s*=\s*)?(?:"([^"]*)"|([A-Za-z_][\w.]*))\s*\)/dg;
|
|
7
9
|
const PLUGIN_RE = /\b(id|kotlin)\s*\(?\s*(["'])([\w.\-]+)\2\s*\)?\s*\.?\s*version\s*\(?\s*(?:(["'])([^"']*)\4|([A-Za-z_][\w.]*(?:\(\))?))/dg;
|
|
8
10
|
const REPO_RE = /\b(mavenCentral|google|gradlePluginPortal|jcenter|mavenLocal)\s*[({]|\bmaven\s*\(\s*(?:url\s*=\s*)?(?:uri\s*\(\s*)?["']([^"'$]+)["']|\b(?:url|setUrl)\s*(?:=\s*)?\(?\s*(?:uri\s*\(\s*)?["']([^"'$]+)["']/g;
|
|
9
11
|
const CATALOG_RE = /(?:create\s*\(\s*["'](\w+)["']\s*\)|(\w+))\s*\{[^{}]*?\bfrom\s*\(\s*files\s*\(\s*["']([^"']+)["']/g;
|
|
@@ -17,6 +19,8 @@ const EXT_BLOCK_RES = [
|
|
|
17
19
|
/(?<![\w.])(\w+)\s*=\s*(["'])([^"'$\n]*)\2/dg,
|
|
18
20
|
/\bset\s*\(\s*["'](\w+)["']\s*,\s*(["'])([^"'$\n]*)\2/dg,
|
|
19
21
|
];
|
|
22
|
+
const MAP_VAR_RE = /(?<![\w.])(?:ext\.)?(\w+)\s*=\s*\[/g;
|
|
23
|
+
const MAP_ENTRY_RE = /(?<![\w.])(\w+)\s*:\s*(["'])([^"'$\n]*)\2/dg;
|
|
20
24
|
const PROPERTY_RE = /^[ \t]*([\w.\-]+)[ \t]*[=:][ \t]*(.*?)[ \t]*\r?$/dgm;
|
|
21
25
|
const WRAPPER_RE = /distributionUrl\s*[=:].*?gradle-([\w.\-]+?)-(?:bin|all)\.zip/d;
|
|
22
26
|
export function isDynamic(version) {
|
|
@@ -90,8 +94,7 @@ export function stripProperties(src) {
|
|
|
90
94
|
}
|
|
91
95
|
async function readSource(file, strip) {
|
|
92
96
|
try {
|
|
93
|
-
|
|
94
|
-
return { file, content, code: strip(content) };
|
|
97
|
+
return { file, code: strip(await readFile(file, 'utf8')) };
|
|
95
98
|
}
|
|
96
99
|
catch {
|
|
97
100
|
return undefined;
|
|
@@ -101,15 +104,17 @@ function findBlocks(code, name) {
|
|
|
101
104
|
const re = new RegExp(`\\b${name}\\s*(?:\\([^()]*\\))?\\s*\\{`, 'g');
|
|
102
105
|
return [...code.matchAll(re)].map((m) => {
|
|
103
106
|
const open = m.index + m[0].length - 1;
|
|
104
|
-
return { start: open + 1, end:
|
|
107
|
+
return { start: open + 1, end: matchClose(code, open) };
|
|
105
108
|
});
|
|
106
109
|
}
|
|
107
|
-
function
|
|
110
|
+
function matchClose(code, open) {
|
|
111
|
+
const opener = code[open];
|
|
112
|
+
const closer = opener === '[' ? ']' : '}';
|
|
108
113
|
let depth = 0;
|
|
109
114
|
for (let i = open; i < code.length; i++) {
|
|
110
|
-
if (code[i] ===
|
|
115
|
+
if (code[i] === opener)
|
|
111
116
|
depth++;
|
|
112
|
-
else if (code[i] ===
|
|
117
|
+
else if (code[i] === closer && --depth === 0)
|
|
113
118
|
return i;
|
|
114
119
|
}
|
|
115
120
|
return code.length;
|
|
@@ -150,6 +155,13 @@ function collectVars(src) {
|
|
|
150
155
|
].sort((a, b) => a.index - b.index);
|
|
151
156
|
for (const m of matches)
|
|
152
157
|
vars.set(m[1], { value: m[3], loc: locOf(src, m, 3) });
|
|
158
|
+
for (const m of src.code.matchAll(MAP_VAR_RE)) {
|
|
159
|
+
const open = m.index + m[0].length - 1;
|
|
160
|
+
for (const e of src.code.slice(open, matchClose(src.code, open)).matchAll(MAP_ENTRY_RE)) {
|
|
161
|
+
const [start, end] = e.indices[3];
|
|
162
|
+
vars.set(`${m[1]}.${e[1]}`, { value: e[3], loc: { file: src.file, start: open + start, end: open + end } });
|
|
163
|
+
}
|
|
164
|
+
}
|
|
153
165
|
return vars;
|
|
154
166
|
}
|
|
155
167
|
function propertyVars(src) {
|
|
@@ -168,8 +180,8 @@ function lookup(scope, expr) {
|
|
|
168
180
|
const catalog = /^(\w+)\.versions\.([\w.]+)$/.exec(e);
|
|
169
181
|
if (catalog)
|
|
170
182
|
return scope.catalogs.get(catalog[1])?.get(normKey(catalog[2]));
|
|
171
|
-
const key = /(?:extra|properties)\s*\[\s*["']([\w.\-]+)["']\s*\]$/.exec(e)?.[1] ??
|
|
172
|
-
/property\s*\(\s*["']([\w.\-]+)["']\s*\)$/.exec(e)?.[1] ??
|
|
183
|
+
const key = /(?:ext|extra|properties)\s*\[\s*["']([\w.\-]+)["']\s*\]$/.exec(e)?.[1] ??
|
|
184
|
+
/(?:findProperty|property)\s*\(\s*["']([\w.\-]+)["']\s*\)$/.exec(e)?.[1] ??
|
|
173
185
|
e.replace(/^(?:(?:rootProject|project|ext|extra|properties)\.)+/, '');
|
|
174
186
|
for (const vars of scope.vars) {
|
|
175
187
|
const value = vars.get(key);
|
|
@@ -211,30 +223,30 @@ function addResolved(ctx, src, kind, group, artifact, raw, loc) {
|
|
|
211
223
|
}
|
|
212
224
|
ctx.out.push(makeDep(kind, group, artifact, version, src.file, ctx.repositories));
|
|
213
225
|
}
|
|
226
|
+
/** Adds a match whose version is either a quoted literal in group `literal` or a bare reference in `literal + 1`. */
|
|
227
|
+
function addLiteralOrRef(ctx, src, kind, group, artifact, m, literal) {
|
|
228
|
+
if (m[literal] !== undefined)
|
|
229
|
+
addResolved(ctx, src, kind, group, artifact, m[literal], locOf(src, m, literal));
|
|
230
|
+
else
|
|
231
|
+
addResolved(ctx, src, kind, group, artifact, `\${${m[literal + 1]}}`);
|
|
232
|
+
}
|
|
214
233
|
function libraryDeps(src, ctx) {
|
|
215
234
|
for (const m of src.code.matchAll(COORD_RE)) {
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
for (const m of src.code.matchAll(MAP_RE)) {
|
|
219
|
-
if (m[6] !== undefined)
|
|
220
|
-
addResolved(ctx, src, 'library', m[2], m[4], m[6], locOf(src, m, 6));
|
|
221
|
-
else
|
|
222
|
-
addResolved(ctx, src, 'library', m[2], m[4], `\${${m[7]}}`);
|
|
235
|
+
if (VERSIONISH_RE.test(m[4]))
|
|
236
|
+
addResolved(ctx, src, 'library', m[2], m[3], m[4], locOf(src, m, 4));
|
|
223
237
|
}
|
|
238
|
+
for (const m of src.code.matchAll(MAP_RE))
|
|
239
|
+
addLiteralOrRef(ctx, src, 'library', m[2], m[4], m, 6);
|
|
224
240
|
const plugins = findBlocks(src.code, 'plugins');
|
|
225
241
|
for (const m of src.code.matchAll(KOTLIN_DEP_RE)) {
|
|
226
|
-
if (!within(m.index, plugins))
|
|
227
|
-
|
|
228
|
-
}
|
|
242
|
+
if (!within(m.index, plugins))
|
|
243
|
+
addLiteralOrRef(ctx, src, 'library', 'org.jetbrains.kotlin', `kotlin-${m[1]}`, m, 2);
|
|
229
244
|
}
|
|
230
245
|
}
|
|
231
246
|
function pluginDeps(src, ctx) {
|
|
232
247
|
for (const m of matchesIn(src.code, PLUGIN_RE, findBlocks(src.code, 'plugins'))) {
|
|
233
248
|
const id = m[1] === 'kotlin' ? `org.jetbrains.kotlin.${m[3]}` : m[3];
|
|
234
|
-
|
|
235
|
-
addResolved(ctx, src, 'plugin', id, `${id}.gradle.plugin`, m[5], locOf(src, m, 5));
|
|
236
|
-
else
|
|
237
|
-
addResolved(ctx, src, 'plugin', id, `${id}.gradle.plugin`, `\${${m[6]}}`);
|
|
249
|
+
addLiteralOrRef(ctx, src, 'plugin', id, `${id}.gradle.plugin`, m, 5);
|
|
238
250
|
}
|
|
239
251
|
}
|
|
240
252
|
export function parseToml(code) {
|
|
@@ -305,6 +317,8 @@ export function parseToml(code) {
|
|
|
305
317
|
break;
|
|
306
318
|
if (code[i] === '[') {
|
|
307
319
|
const end = code.indexOf(']', i);
|
|
320
|
+
if (end < 0)
|
|
321
|
+
break;
|
|
308
322
|
table = code.slice(i + 1, end).trim();
|
|
309
323
|
i = end + 1;
|
|
310
324
|
skipLine();
|
|
@@ -354,8 +368,7 @@ function pickVersion(src, fields, path) {
|
|
|
354
368
|
function splitNotation(src, s) {
|
|
355
369
|
const parts = s.value.split(':');
|
|
356
370
|
const last = parts[parts.length - 1];
|
|
357
|
-
|
|
358
|
-
return { parts, version: tomlValue(src, { value: last, start, end: s.end }) };
|
|
371
|
+
return { parts, version: tomlValue(src, { value: last, start: s.end - last.length, end: s.end }) };
|
|
359
372
|
}
|
|
360
373
|
function parseCatalog(src, libraryRepos, pluginRepos, warnings) {
|
|
361
374
|
const entries = parseToml(src.code);
|
|
@@ -379,7 +392,7 @@ function parseCatalog(src, libraryRepos, pluginRepos, warnings) {
|
|
|
379
392
|
const str = e.fields.get('');
|
|
380
393
|
if (str) {
|
|
381
394
|
const { parts, version } = splitNotation(src, str);
|
|
382
|
-
if (parts.length >= 3
|
|
395
|
+
if (parts.length >= 3)
|
|
383
396
|
deps.push(makeDep('library', parts[0], parts[1], version, src.file, libraryRepos));
|
|
384
397
|
continue;
|
|
385
398
|
}
|
|
@@ -437,12 +450,17 @@ export async function scanGradle(build) {
|
|
|
437
450
|
const scope = { vars: [collectVars(settings), rootProps], catalogs };
|
|
438
451
|
pluginDeps(settings, { scope, repositories: pluginRepos, warnings, out });
|
|
439
452
|
}
|
|
440
|
-
|
|
441
|
-
const
|
|
453
|
+
const modules = await Promise.all(build.buildFiles.map(async (file) => {
|
|
454
|
+
const dir = dirname(file);
|
|
455
|
+
const [src, props] = await Promise.all([
|
|
456
|
+
file === rootBuildFile ? rootBuild : readSource(file, stripCode),
|
|
457
|
+
dir === build.root ? undefined : readSource(join(dir, 'gradle.properties'), stripProperties),
|
|
458
|
+
]);
|
|
459
|
+
return { src, moduleProps: propertyVars(props) };
|
|
460
|
+
}));
|
|
461
|
+
for (const { src, moduleProps } of modules) {
|
|
442
462
|
if (!src)
|
|
443
463
|
continue;
|
|
444
|
-
const dir = dirname(file);
|
|
445
|
-
const moduleProps = dir === build.root ? new Map() : propertyVars(await readSource(join(dir, 'gradle.properties'), stripProperties));
|
|
446
464
|
const scope = { vars: [collectVars(src), rootVars, moduleProps, rootProps], catalogs };
|
|
447
465
|
libraryDeps(src, { scope, repositories: libraryRepos(buildFileRepos(src)), warnings, out });
|
|
448
466
|
pluginDeps(src, { scope, repositories: pluginRepos, warnings, out });
|
package/dist/scan/index.js
CHANGED
|
@@ -5,21 +5,31 @@ import { scanMaven } from './maven.js';
|
|
|
5
5
|
const SKIP_DIRS = new Set(['build', 'node_modules', 'target', 'out', 'bin', 'intermediates']);
|
|
6
6
|
const SETTINGS = new Set(['settings.gradle', 'settings.gradle.kts']);
|
|
7
7
|
const BUILD_FILES = new Set(['build.gradle', 'build.gradle.kts']);
|
|
8
|
+
const isBuildFile = (name) => SETTINGS.has(name) || BUILD_FILES.has(name) || name === 'pom.xml';
|
|
9
|
+
// Symlinked directories are not followed (Dirent.isDirectory() is false for them), so link loops cannot recurse.
|
|
8
10
|
async function walk(dir, files) {
|
|
9
11
|
const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
12
|
+
// A module's src/ holds sources and test fixtures (src/it, src/test/resources), never real modules.
|
|
13
|
+
const isModule = entries.some((e) => !e.isDirectory() && isBuildFile(e.name));
|
|
10
14
|
await Promise.all(entries.map(async (entry) => {
|
|
11
15
|
const path = join(dir, entry.name);
|
|
12
16
|
if (entry.isDirectory()) {
|
|
13
|
-
|
|
17
|
+
const skip = entry.name.startsWith('.') || SKIP_DIRS.has(entry.name) || (isModule && entry.name === 'src');
|
|
18
|
+
if (!skip)
|
|
14
19
|
await walk(path, files);
|
|
15
20
|
}
|
|
16
|
-
else if (
|
|
21
|
+
else if (isBuildFile(entry.name)) {
|
|
17
22
|
files.push(path);
|
|
18
23
|
}
|
|
19
24
|
}));
|
|
20
25
|
}
|
|
21
|
-
function
|
|
22
|
-
|
|
26
|
+
function nearestBuild(dir, builds) {
|
|
27
|
+
for (let d = dir;; d = dirname(d)) {
|
|
28
|
+
if (builds.has(d))
|
|
29
|
+
return d;
|
|
30
|
+
if (dirname(d) === d)
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
23
33
|
}
|
|
24
34
|
function depth(path) {
|
|
25
35
|
return path.split(sep).length;
|
|
@@ -32,7 +42,7 @@ export function groupGradleBuilds(files) {
|
|
|
32
42
|
const buildFiles = files.filter((f) => BUILD_FILES.has(basename(f))).sort((a, b) => depth(a) - depth(b));
|
|
33
43
|
for (const file of buildFiles) {
|
|
34
44
|
const dir = dirname(file);
|
|
35
|
-
const owner =
|
|
45
|
+
const owner = nearestBuild(dir, builds);
|
|
36
46
|
const root = owner && basename(dir) !== 'buildSrc' ? owner : dir;
|
|
37
47
|
if (!builds.has(root))
|
|
38
48
|
builds.set(root, { root, buildFiles: [] });
|
package/dist/scan/maven.js
CHANGED
|
@@ -72,7 +72,6 @@ async function loadPom(file) {
|
|
|
72
72
|
const pom = { file, code, project, props: new Map(), parent: child(project, 'parent') };
|
|
73
73
|
pom.artifactId = childText(pom, project, 'artifactId');
|
|
74
74
|
pom.groupId = childText(pom, project, 'groupId') ?? childText(pom, pom.parent, 'groupId');
|
|
75
|
-
pom.version = childText(pom, project, 'version') ?? childText(pom, pom.parent, 'version');
|
|
76
75
|
for (const prop of child(project, 'properties')?.children ?? []) {
|
|
77
76
|
const value = textOf(pom, prop);
|
|
78
77
|
if (value)
|
|
@@ -192,7 +191,7 @@ export async function scanMaven(pomFiles) {
|
|
|
192
191
|
kind,
|
|
193
192
|
version: version.value,
|
|
194
193
|
file: pom.file,
|
|
195
|
-
repositories: kind === 'plugin' ? pluginRepos : libraryRepos,
|
|
194
|
+
repositories: kind === 'plugin' || node.parent?.parent?.name === 'plugin' ? pluginRepos : libraryRepos,
|
|
196
195
|
...(location && { location }),
|
|
197
196
|
});
|
|
198
197
|
}
|
package/dist/version.js
CHANGED
|
@@ -44,10 +44,6 @@ export function isSnapshot(v) {
|
|
|
44
44
|
export function isPrerelease(v) {
|
|
45
45
|
return tokenize(v).some((t) => PRERELEASE.has(t));
|
|
46
46
|
}
|
|
47
|
-
export function isDynamic(v) {
|
|
48
|
-
const s = v.trim();
|
|
49
|
-
return /^[[(]/.test(s) || /[\])]$/.test(s) || s.endsWith('+') || /^latest\./i.test(s) || /^(LATEST|RELEASE)$/.test(s);
|
|
50
|
-
}
|
|
51
47
|
export function upgradeLevel(from, to) {
|
|
52
48
|
if (compareVersions(to, from) <= 0)
|
|
53
49
|
return undefined;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jvm-upgrade",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Find and apply library upgrades in Java, Kotlin and Android projects (Gradle and Maven), with changelogs.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"LICENSE"
|
|
13
13
|
],
|
|
14
14
|
"engines": {
|
|
15
|
-
"node": ">=
|
|
15
|
+
"node": ">=22.13"
|
|
16
16
|
},
|
|
17
17
|
"scripts": {
|
|
18
18
|
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|