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/dist/format.js ADDED
@@ -0,0 +1,93 @@
1
+ import { relative } from 'node:path';
2
+ import { styleText } from 'node:util';
3
+ import { levelColor } from './rows.js';
4
+ const LEVELS = ['patch', 'minor', 'major'];
5
+ export function renderTable(rows, root) {
6
+ const header = ['Name', 'Current', 'Patch', 'Minor', 'Major'];
7
+ const cells = rows.map((row) => [
8
+ row.location ? row.name : `${row.name} (manual)`,
9
+ row.current,
10
+ ...LEVELS.map((level) => row.choices.find((c) => c.level === level)?.version ?? ''),
11
+ ]);
12
+ const widths = header.map((h, i) => Math.max(h.length, ...cells.map((c) => c[i].length)));
13
+ const lines = [header.map((h, i) => styleText('bold', h.padEnd(widths[i]))).join(' ')];
14
+ rows.forEach((row, r) => {
15
+ const selected = row.selected === undefined ? undefined : row.choices[row.selected];
16
+ const line = cells[r].map((text, i) => {
17
+ const padded = text.padEnd(widths[i]);
18
+ if (i === 0)
19
+ return row.location ? padded : styleText('gray', padded);
20
+ if (i === 1)
21
+ return padded;
22
+ const level = LEVELS[i - 2];
23
+ if (!text)
24
+ return padded;
25
+ const styles = selected?.version === text ? [levelColor(level), 'bold', 'underline'] : [levelColor(level)];
26
+ return styleText(styles, text) + padded.slice(text.length);
27
+ });
28
+ lines.push(line.join(' ').trimEnd());
29
+ const down = row.choices.find((c) => c.level === 'downgrade');
30
+ if (down)
31
+ lines.push(styleText('magenta', ` ↳ cooldown: roll back to ${down.version}`));
32
+ for (const error of row.errors)
33
+ lines.push(styleText('yellow', ` ↳ ${error}`));
34
+ });
35
+ return lines.join('\n');
36
+ }
37
+ export function toJson(rows, root) {
38
+ return rows.map((row) => {
39
+ const selected = row.selected === undefined ? undefined : row.choices[row.selected];
40
+ return {
41
+ name: row.name,
42
+ dependencies: row.deps.map((d) => ({ group: d.group, artifact: d.artifact, kind: d.kind, file: relative(root, d.file) })),
43
+ current: row.current,
44
+ patch: row.choices.find((c) => c.level === 'patch')?.version ?? null,
45
+ minor: row.choices.find((c) => c.level === 'minor')?.version ?? null,
46
+ major: row.choices.find((c) => c.level === 'major')?.version ?? null,
47
+ downgrade: row.choices.find((c) => c.level === 'downgrade')?.version ?? null,
48
+ selected: selected?.version ?? null,
49
+ rewritable: row.location !== undefined,
50
+ errors: row.errors,
51
+ };
52
+ });
53
+ }
54
+ export function formatChangelog(log, width) {
55
+ if (!log.entries.length) {
56
+ return log.source ? `No release notes found here. Read them at:\n${log.source}` : 'No release notes found.';
57
+ }
58
+ const out = [];
59
+ for (const entry of log.entries) {
60
+ const date = entry.date ? styleText('gray', ` ${entry.date.slice(0, 10)}`) : '';
61
+ out.push(styleText(['bold', 'cyan'], entry.title && entry.title !== entry.version ? `${entry.version} ${entry.title}` : entry.version) + date);
62
+ if (entry.url)
63
+ out.push(styleText('gray', entry.url));
64
+ out.push('');
65
+ for (const line of entry.body.replace(/\r/g, '').trim().split('\n'))
66
+ out.push(...wrap(line, width));
67
+ out.push('');
68
+ }
69
+ if (log.source)
70
+ out.push(styleText('gray', `Source: ${log.source}`));
71
+ return out.join('\n');
72
+ }
73
+ function wrap(line, width) {
74
+ if (line.length <= width || width < 20)
75
+ return [line];
76
+ const indent = /^\s*([-*+]\s+|\d+\.\s+)?/.exec(line)[0].length;
77
+ const pad = ' '.repeat(Math.min(indent, 8));
78
+ const words = line.split(' ');
79
+ const lines = [];
80
+ let current = '';
81
+ for (const word of words) {
82
+ if (current && current.length + word.length + 1 > width) {
83
+ lines.push(current);
84
+ current = pad + word;
85
+ }
86
+ else {
87
+ current = current ? `${current} ${word}` : word;
88
+ }
89
+ }
90
+ if (current)
91
+ lines.push(current);
92
+ return lines;
93
+ }
package/dist/http.js ADDED
@@ -0,0 +1,226 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises';
3
+ import { homedir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ const DEFAULT_TTL = 60 * 60 * 1000;
7
+ const MAX_REDIRECTS = 5;
8
+ export function defaultCacheDir() {
9
+ const env = process.env;
10
+ if (env.XDG_CACHE_HOME)
11
+ return join(env.XDG_CACHE_HOME, 'jvm-upgrade');
12
+ if (process.platform === 'darwin')
13
+ return join(homedir(), 'Library', 'Caches', 'jvm-upgrade');
14
+ if (process.platform === 'win32') {
15
+ return join(env.LOCALAPPDATA ?? join(homedir(), 'AppData', 'Local'), 'jvm-upgrade', 'cache');
16
+ }
17
+ return join(homedir(), '.cache', 'jvm-upgrade');
18
+ }
19
+ export async function clearCache(dir) {
20
+ await rm(dir, { recursive: true, force: true });
21
+ }
22
+ export function authHeader(url, credentials) {
23
+ const match = credentials
24
+ .filter((c) => url.startsWith(c.url) && sameOrigin(url, c.url))
25
+ .sort((a, b) => b.url.length - a.url.length)[0];
26
+ if (match?.token)
27
+ return `Bearer ${match.token}`;
28
+ if (match?.username !== undefined && match.password !== undefined) {
29
+ return `Basic ${Buffer.from(`${match.username}:${match.password}`).toString('base64')}`;
30
+ }
31
+ const gh = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
32
+ if (gh && hostOf(url) === 'api.github.com')
33
+ return `Bearer ${gh}`;
34
+ return undefined;
35
+ }
36
+ function hostOf(url) {
37
+ try {
38
+ return new URL(url).host;
39
+ }
40
+ catch {
41
+ return undefined;
42
+ }
43
+ }
44
+ function sameOrigin(a, b) {
45
+ try {
46
+ return new URL(a).origin === new URL(b).origin;
47
+ }
48
+ catch {
49
+ return false;
50
+ }
51
+ }
52
+ function limiter(max) {
53
+ let active = 0;
54
+ const waiting = [];
55
+ return async (fn) => {
56
+ if (active >= max)
57
+ await new Promise((r) => waiting.push(r));
58
+ else
59
+ active++;
60
+ try {
61
+ return await fn();
62
+ }
63
+ finally {
64
+ const next = waiting.shift();
65
+ if (next)
66
+ next();
67
+ else
68
+ active--;
69
+ }
70
+ };
71
+ }
72
+ async function readLocal(url) {
73
+ try {
74
+ const body = await readFile(fileURLToPath(url), 'utf8');
75
+ return { status: 200, headers: {}, body };
76
+ }
77
+ catch {
78
+ return { status: 404, headers: {}, body: '' };
79
+ }
80
+ }
81
+ async function atomicWrite(file, data) {
82
+ const tmp = `${file}.${randomBytes(6).toString('hex')}.tmp`;
83
+ try {
84
+ await writeFile(tmp, data);
85
+ await rename(tmp, file);
86
+ }
87
+ catch (err) {
88
+ await rm(tmp, { force: true });
89
+ throw err;
90
+ }
91
+ }
92
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
93
+ export function createHttp(opts) {
94
+ const ttl = opts.ttlMs ?? DEFAULT_TTL;
95
+ const timeoutMs = opts.timeoutMs ?? 15_000;
96
+ const cacheDir = opts.noCache ? undefined : opts.cacheDir;
97
+ const limit = limiter(Math.max(1, opts.concurrency));
98
+ const inflight = new Map();
99
+ let dirReady;
100
+ const cacheFile = (url) => join(cacheDir, `${createHash('sha256').update(url).digest('hex')}.json`);
101
+ async function readCache(url) {
102
+ if (!cacheDir)
103
+ return undefined;
104
+ const file = cacheFile(url);
105
+ try {
106
+ const entry = JSON.parse(await readFile(file, 'utf8'));
107
+ if (Date.now() - entry.time <= ttl) {
108
+ return { status: entry.status, headers: entry.headers, body: entry.body };
109
+ }
110
+ await rm(file, { force: true });
111
+ }
112
+ catch {
113
+ // missing or corrupt entry
114
+ }
115
+ return undefined;
116
+ }
117
+ async function writeCache(url, res) {
118
+ if (!cacheDir || (res.status !== 200 && res.status !== 404))
119
+ return;
120
+ try {
121
+ dirReady ??= mkdir(cacheDir, { recursive: true });
122
+ await dirReady;
123
+ await atomicWrite(cacheFile(url), JSON.stringify({ ...res, time: Date.now() }));
124
+ }
125
+ catch {
126
+ // cache is best effort
127
+ }
128
+ }
129
+ async function fetchOnce(method, url) {
130
+ const auth = authHeader(url, opts.credentials);
131
+ let current = url;
132
+ for (let i = 0; i <= MAX_REDIRECTS; i++) {
133
+ const headers = { 'user-agent': opts.userAgent ?? 'jvm-upgrade' };
134
+ if (auth && sameOrigin(current, url))
135
+ headers.authorization = auth;
136
+ const res = await fetch(current, {
137
+ method,
138
+ headers,
139
+ redirect: 'manual',
140
+ signal: AbortSignal.timeout(timeoutMs),
141
+ });
142
+ const location = res.headers.get('location');
143
+ if (res.status >= 300 && res.status < 400 && location) {
144
+ await res.body?.cancel();
145
+ current = new URL(location, current).toString();
146
+ continue;
147
+ }
148
+ return {
149
+ status: res.status,
150
+ headers: Object.fromEntries(res.headers),
151
+ body: method === 'HEAD' ? '' : await res.text(),
152
+ };
153
+ }
154
+ throw new Error(`Too many redirects: ${url}`);
155
+ }
156
+ async function network(method, url) {
157
+ return limit(async () => {
158
+ opts.onRequest?.();
159
+ try {
160
+ try {
161
+ const res = await fetchOnce(method, url);
162
+ if (res.status < 500 && res.status !== 429)
163
+ return res;
164
+ }
165
+ catch {
166
+ // retried below
167
+ }
168
+ await sleep(300);
169
+ return await fetchOnce(method, url);
170
+ }
171
+ finally {
172
+ opts.onResponse?.();
173
+ }
174
+ });
175
+ }
176
+ async function get(url) {
177
+ if (url.startsWith('file:'))
178
+ return readLocal(url);
179
+ const authed = authHeader(url, opts.credentials) !== undefined;
180
+ const cached = authed ? undefined : await readCache(url);
181
+ if (cached)
182
+ return cached;
183
+ const res = await network('GET', url);
184
+ if (!authed)
185
+ await writeCache(url, res);
186
+ return res;
187
+ }
188
+ return {
189
+ get(url) {
190
+ let p = inflight.get(url);
191
+ if (!p) {
192
+ p = get(url);
193
+ inflight.set(url, p);
194
+ p.catch(() => inflight.delete(url));
195
+ }
196
+ return p;
197
+ },
198
+ async head(url) {
199
+ if (url.startsWith('file:'))
200
+ return readLocal(url);
201
+ return network('HEAD', url);
202
+ },
203
+ async close() {
204
+ if (!cacheDir)
205
+ return;
206
+ const files = await readdir(cacheDir).catch(() => []);
207
+ const ours = files.filter((f) => f.endsWith('.json') || f.endsWith('.tmp'));
208
+ await Promise.all(ours.map((f) => pruneFile(join(cacheDir, f), ttl)));
209
+ },
210
+ };
211
+ }
212
+ async function pruneFile(file, ttl) {
213
+ try {
214
+ if (file.endsWith('.tmp')) {
215
+ if (Date.now() - (await stat(file)).mtimeMs > ttl)
216
+ await rm(file, { force: true });
217
+ return;
218
+ }
219
+ const entry = JSON.parse(await readFile(file, 'utf8'));
220
+ if (Date.now() - entry.time > ttl)
221
+ await rm(file, { force: true });
222
+ }
223
+ catch {
224
+ await rm(file, { force: true });
225
+ }
226
+ }
package/dist/main.js ADDED
@@ -0,0 +1,279 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { stat } from 'node:fs/promises';
3
+ import { relative, resolve } from 'node:path';
4
+ import { parseArgs, styleText } from 'node:util';
5
+ import { applyEdits, EditConflictError } from './apply.js';
6
+ import { getChangelog } from './changelog.js';
7
+ import { ConfigError, loadCredentials, loadUserConfig, projectConfigResolver, splitPatterns } from './config.js';
8
+ import { formatChangelog, renderTable, toJson } from './format.js';
9
+ import { clearCache, createHttp, defaultCacheDir } from './http.js';
10
+ import { createProgress } from './progress.js';
11
+ import { buildRows } from './rows.js';
12
+ import { scanProject } from './scan/index.js';
13
+ import { TARGETS } from './types.js';
14
+ import { compareVersions } from './version.js';
15
+ export const EXIT = { ok: 0, outdated: 1, usage: 2, parse: 3, network: 4, conflict: 5 };
16
+ const HELP = `Usage: jvm-upgrade [directory] [options]
17
+
18
+ Find outdated libraries in Gradle and Maven projects and upgrade them.
19
+
20
+ Actions
21
+ -u, --upgrade Write the selected upgrades to the build files
22
+ -i, --interactive Pick upgrades (and read changelogs) in a terminal UI
23
+ -l, --changelog Print the changelog of every selected upgrade
24
+
25
+ Policy
26
+ -t, --target <level> Highest jump allowed: major, minor or patch (default: major)
27
+ -c, --cooldown <days> Skip versions published less than <days> days ago
28
+ --allow-downgrade With --cooldown, roll back versions that are too new
29
+ --pre Allow prereleases (alpha, beta, rc, ...)
30
+ --include <pattern> Only check matching deps, e.g. "com.google.*,org.jetbrains:*"
31
+ --exclude <pattern> Skip matching deps
32
+ --repo <url> Extra Maven repository to query (repeatable)
33
+
34
+ Output
35
+ --format <format> text or json (default: text)
36
+ --error-on-outdated Exit with code 1 when upgrades are available
37
+ --verbose Also list dependencies that are up to date
38
+ --no-progress Hide progress bars
39
+
40
+ Network
41
+ --concurrency <n> Parallel HTTP requests (default: 8)
42
+ --no-cache Do not use the metadata cache
43
+ --clear-cache Delete the metadata cache first
44
+
45
+ -h, --help Show this help
46
+ -v, --version Show the version
47
+ `;
48
+ export function fail(code, message) {
49
+ process.stderr.write(`${styleText('red', 'error')} ${message}\n`);
50
+ process.exit(code);
51
+ }
52
+ function parseCli(argv) {
53
+ try {
54
+ return parseArgs({
55
+ args: argv,
56
+ allowPositionals: true,
57
+ allowNegative: true,
58
+ options: {
59
+ upgrade: { type: 'boolean', short: 'u' },
60
+ interactive: { type: 'boolean', short: 'i' },
61
+ changelog: { type: 'boolean', short: 'l' },
62
+ target: { type: 'string', short: 't' },
63
+ cooldown: { type: 'string', short: 'c' },
64
+ 'allow-downgrade': { type: 'boolean' },
65
+ pre: { type: 'boolean' },
66
+ include: { type: 'string', multiple: true },
67
+ exclude: { type: 'string', multiple: true },
68
+ repo: { type: 'string', multiple: true },
69
+ format: { type: 'string' },
70
+ 'error-on-outdated': { type: 'boolean' },
71
+ verbose: { type: 'boolean' },
72
+ progress: { type: 'boolean', default: true },
73
+ concurrency: { type: 'string' },
74
+ cache: { type: 'boolean', default: true },
75
+ 'clear-cache': { type: 'boolean' },
76
+ help: { type: 'boolean', short: 'h' },
77
+ version: { type: 'boolean', short: 'v' },
78
+ },
79
+ });
80
+ }
81
+ catch (e) {
82
+ fail(EXIT.usage, `${e.message}\nRun jvm-upgrade --help for usage.`);
83
+ }
84
+ }
85
+ function parseInteger(name, value, min) {
86
+ if (value === undefined)
87
+ return undefined;
88
+ const n = Number(value);
89
+ if (!Number.isInteger(n) || n < min)
90
+ fail(EXIT.usage, `--${name} must be an integer >= ${min}`);
91
+ return n;
92
+ }
93
+ export async function main(argv = process.argv.slice(2)) {
94
+ const { values: flags, positionals } = parseCli(argv);
95
+ if (flags.help) {
96
+ process.stdout.write(HELP);
97
+ return EXIT.ok;
98
+ }
99
+ if (flags.version) {
100
+ const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
101
+ process.stdout.write(`${pkg.version}\n`);
102
+ return EXIT.ok;
103
+ }
104
+ if (positionals.length > 1)
105
+ fail(EXIT.usage, 'only one directory can be given');
106
+ if (flags.target && !TARGETS.includes(flags.target))
107
+ fail(EXIT.usage, '--target must be major, minor or patch');
108
+ if (flags.format && flags.format !== 'text' && flags.format !== 'json')
109
+ fail(EXIT.usage, '--format must be text or json');
110
+ const json = flags.format === 'json';
111
+ if (json && flags.interactive)
112
+ fail(EXIT.usage, '--interactive cannot be used with --format json');
113
+ if (flags.interactive && !(process.stdin.isTTY && process.stdout.isTTY))
114
+ fail(EXIT.usage, '--interactive needs a terminal');
115
+ const root = resolve(positionals[0] ?? '.');
116
+ if (!(await stat(root).catch(() => undefined))?.isDirectory())
117
+ fail(EXIT.usage, `${root} is not a directory`);
118
+ let user;
119
+ let credentials;
120
+ try {
121
+ user = await loadUserConfig();
122
+ credentials = await loadCredentials();
123
+ }
124
+ catch (e) {
125
+ if (e instanceof ConfigError)
126
+ fail(EXIT.usage, e.message);
127
+ throw e;
128
+ }
129
+ const cooldown = parseInteger('cooldown', flags.cooldown, 0);
130
+ const concurrency = parseInteger('concurrency', flags.concurrency, 1) ?? user.concurrency;
131
+ const cli = {
132
+ ...(flags.target && { target: flags.target }),
133
+ ...(cooldown !== undefined && { cooldown }),
134
+ ...(flags['allow-downgrade'] !== undefined && { allowDowngrade: flags['allow-downgrade'] }),
135
+ ...(flags.pre !== undefined && { pre: flags.pre }),
136
+ ...(flags.include && { include: splitPatterns(flags.include) }),
137
+ ...(flags.exclude && { exclude: splitPatterns(flags.exclude) }),
138
+ };
139
+ const resolveBase = projectConfigResolver(root, user);
140
+ const optionsFor = async (file) => ({ ...(await resolveBase(file)), ...cli });
141
+ const rootOptions = await optionsFor(resolve(root, 'build.gradle')).catch((e) => {
142
+ if (e instanceof ConfigError)
143
+ fail(EXIT.usage, e.message);
144
+ throw e;
145
+ });
146
+ if (rootOptions.allowDowngrade && !rootOptions.cooldown)
147
+ fail(EXIT.usage, '--allow-downgrade needs --cooldown');
148
+ const cacheDir = user.cacheDir ?? defaultCacheDir();
149
+ if (flags['clear-cache'])
150
+ await clearCache(cacheDir);
151
+ const showProgress = flags.progress && process.stderr.isTTY;
152
+ const log = (text) => process.stderr.write(`${text}\n`);
153
+ let scan;
154
+ try {
155
+ scan = await scanProject(root);
156
+ }
157
+ catch (e) {
158
+ fail(EXIT.parse, `could not read the project: ${e.message}`);
159
+ }
160
+ for (const warning of scan.warnings)
161
+ log(styleText('yellow', `warning: ${warning}`));
162
+ if (!scan.dependencies.length) {
163
+ log('No Gradle or Maven dependencies found.');
164
+ if (json)
165
+ process.stdout.write('[]\n');
166
+ return EXIT.ok;
167
+ }
168
+ const progress = createProgress('Checking versions', process.stderr, showProgress);
169
+ const http = createHttp({ credentials, concurrency, cacheDir, noCache: !flags.cache || user.noCache });
170
+ try {
171
+ let rows;
172
+ try {
173
+ rows = await buildRows(scan.dependencies, {
174
+ http,
175
+ optionsFor,
176
+ extraRepositories: flags.repo ?? [],
177
+ progress,
178
+ now: new Date(),
179
+ });
180
+ }
181
+ catch (e) {
182
+ if (e instanceof ConfigError)
183
+ fail(EXIT.usage, e.message);
184
+ throw e;
185
+ }
186
+ finally {
187
+ progress.done();
188
+ }
189
+ rows.sort((a, b) => a.name.localeCompare(b.name));
190
+ const nameCount = new Map();
191
+ for (const row of rows)
192
+ nameCount.set(row.name, (nameCount.get(row.name) ?? 0) + 1);
193
+ for (const row of rows)
194
+ if (nameCount.get(row.name) > 1)
195
+ row.name += ` (${relative(root, row.deps[0].file)})`;
196
+ const outdated = rows.filter((r) => r.choices.length);
197
+ const failed = rows.filter((r) => !r.choices.length && r.errors.length);
198
+ const failedAll = rows.length > 0 && failed.length === rows.length;
199
+ const visible = flags.verbose ? rows : [...outdated, ...failed];
200
+ if (json) {
201
+ process.stdout.write(`${JSON.stringify(toJson(visible, root), null, 2)}\n`);
202
+ }
203
+ else if (!flags.interactive) {
204
+ if (visible.length)
205
+ log(renderTable(visible, root));
206
+ if (!outdated.length)
207
+ log(styleText('green', `All ${rows.length - failed.length} dependencies are up to date.`));
208
+ }
209
+ if (failedAll)
210
+ return EXIT.network;
211
+ if (!outdated.length)
212
+ return EXIT.ok;
213
+ let chosen;
214
+ if (flags.interactive) {
215
+ const picked = await (await import('./tui.js')).pick(outdated, (row, choice) => loadChangelog(http, row, choice));
216
+ if (!picked) {
217
+ log('Cancelled. Nothing was changed.');
218
+ return EXIT.ok;
219
+ }
220
+ chosen = picked;
221
+ }
222
+ else {
223
+ chosen = new Map();
224
+ for (const row of outdated) {
225
+ const choice = row.selected === undefined ? undefined : row.choices[row.selected];
226
+ if (choice)
227
+ chosen.set(row, choice);
228
+ }
229
+ }
230
+ if (flags.changelog && !json)
231
+ await printChangelogs(http, chosen, showProgress);
232
+ const writable = [...chosen].filter(([row]) => row.location);
233
+ if (flags.upgrade || flags.interactive) {
234
+ if (!writable.length) {
235
+ log('Nothing to upgrade.');
236
+ return EXIT.ok;
237
+ }
238
+ const edits = writable.map(([row, choice]) => ({ location: row.location, from: row.current, to: choice.version }));
239
+ try {
240
+ await applyEdits(edits);
241
+ }
242
+ catch (e) {
243
+ if (e instanceof EditConflictError)
244
+ fail(EXIT.conflict, e.message);
245
+ throw e;
246
+ }
247
+ for (const [row, choice] of writable)
248
+ log(`${styleText('green', '✔')} ${row.name} ${row.current} → ${choice.version}`);
249
+ log(styleText('gray', 'Build files updated. Run your build and tests to check the upgrades.'));
250
+ return EXIT.ok;
251
+ }
252
+ if (!json) {
253
+ const target = rootOptions.target;
254
+ log(styleText('gray', `\nRun ${styleText('cyan', 'jvm-upgrade -u')} to apply the underlined versions (target: ${target}), or ${styleText('cyan', 'jvm-upgrade -i')} to pick.`));
255
+ }
256
+ return flags['error-on-outdated'] ? EXIT.outdated : EXIT.ok;
257
+ }
258
+ finally {
259
+ await http.close();
260
+ }
261
+ }
262
+ function loadChangelog(http, row, choice) {
263
+ const dep = row.deps[0];
264
+ return getChangelog(http, dep, row.current, choice.version, row.repoOf.get(choice.version), compareVersions);
265
+ }
266
+ async function printChangelogs(http, chosen, showProgress) {
267
+ const entries = [...chosen];
268
+ const progress = createProgress('Loading changelogs', process.stderr, showProgress);
269
+ progress.total(entries.length);
270
+ const logs = await Promise.all(entries.map(([row, choice]) => loadChangelog(http, row, choice)
271
+ .catch((e) => ({ entries: [], source: `could not load: ${e.message}` }))
272
+ .finally(() => progress.tick())));
273
+ progress.done();
274
+ const width = Math.min(process.stdout.columns ?? 100, 120) - 2;
275
+ entries.forEach(([row, choice], i) => {
276
+ process.stdout.write(`\n${styleText(['bold', 'underline'], `${row.name} ${row.current} → ${choice.version}`)}\n\n`);
277
+ process.stdout.write(`${formatChangelog(logs[i], width)}\n`);
278
+ });
279
+ }
package/dist/policy.js ADDED
@@ -0,0 +1,58 @@
1
+ import { compareVersions, isPrerelease, isSnapshot, upgradeLevel } from './version.js';
2
+ const DAY_MS = 86_400_000;
3
+ const RANK = { patch: 0, minor: 1, major: 2 };
4
+ export async function selectCandidates(current, versions, opts, publishedAt) {
5
+ const allowSnapshot = isSnapshot(current);
6
+ const allowPre = opts.pre || isPrerelease(current);
7
+ const allowed = [...new Set(versions)]
8
+ .filter((v) => (allowSnapshot || !isSnapshot(v)) && (allowPre || !isPrerelease(v)))
9
+ .sort((a, b) => compareVersions(b, a));
10
+ const memo = new Map();
11
+ const cutoff = opts.now.getTime() - opts.cooldownDays * DAY_MS;
12
+ const passes = (v) => {
13
+ if (opts.cooldownDays <= 0)
14
+ return Promise.resolve(true);
15
+ let p = memo.get(v);
16
+ if (!p) {
17
+ p = publishedAt(v).then((d) => !d || d.getTime() <= cutoff);
18
+ memo.set(v, p);
19
+ }
20
+ return p;
21
+ };
22
+ const firstPassing = async (list) => {
23
+ for (const v of list)
24
+ if (await passes(v))
25
+ return v;
26
+ return undefined;
27
+ };
28
+ const upgrades = allowed
29
+ .map((v) => ({ v, level: upgradeLevel(current, v) }))
30
+ .filter((c) => c.level !== undefined);
31
+ const upTo = (t) => upgrades.filter((c) => RANK[c.level] <= RANK[t]).map((c) => c.v);
32
+ const result = {};
33
+ for (const t of ['major', 'minor', 'patch']) {
34
+ const v = await firstPassing(upTo(t));
35
+ if (v)
36
+ result[t] = v;
37
+ }
38
+ if (opts.allowDowngrade && opts.cooldownDays > 0 && !result.major && !(await passes(current))) {
39
+ const v = await firstPassing(allowed.filter((x) => compareVersions(x, current) < 0));
40
+ if (v)
41
+ result.downgrade = v;
42
+ }
43
+ return result;
44
+ }
45
+ export function pick(c, target) {
46
+ if (target === 'major')
47
+ return c.major ?? c.minor ?? c.patch;
48
+ if (target === 'minor')
49
+ return c.minor ?? c.patch;
50
+ return c.patch;
51
+ }
52
+ export function intersectVersions(lists) {
53
+ const [first, ...rest] = lists;
54
+ if (!first)
55
+ return [];
56
+ const sets = rest.map((l) => new Set(l));
57
+ return [...new Set(first)].filter((v) => sets.every((s) => s.has(v)));
58
+ }
@@ -0,0 +1,37 @@
1
+ import { styleText } from 'node:util';
2
+ const WIDTH = 28;
3
+ const FRAME_MS = 80;
4
+ export function createProgress(label, stream = process.stderr, enabled = stream.isTTY) {
5
+ if (!enabled)
6
+ return { total() { }, tick() { }, done() { } };
7
+ let total = 0;
8
+ let count = 0;
9
+ let last = 0;
10
+ let finished = false;
11
+ const draw = (force = false) => {
12
+ const now = Date.now();
13
+ if (!force && now - last < FRAME_MS)
14
+ return;
15
+ last = now;
16
+ const ratio = total ? Math.min(count / total, 1) : 0;
17
+ const filled = Math.round(ratio * WIDTH);
18
+ const bar = styleText('cyan', '█'.repeat(filled)) + styleText('gray', '░'.repeat(WIDTH - filled));
19
+ stream.write(`\r\x1b[2K${label} ${bar} ${count}/${total}`);
20
+ };
21
+ return {
22
+ total(n) {
23
+ total += n;
24
+ draw();
25
+ },
26
+ tick() {
27
+ count++;
28
+ draw(count === total);
29
+ },
30
+ done() {
31
+ if (finished)
32
+ return;
33
+ finished = true;
34
+ stream.write('\r\x1b[2K');
35
+ },
36
+ };
37
+ }