upgrade-interactive 1.0.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 Pablo
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,104 @@
1
+ # upgrade-interactive
2
+
3
+ A clone of `yarn upgrade-interactive` (the Yarn Berry / Yarn 4 version, built into
4
+ Yarn since v4) for npm projects. It was built by reading Yarn's actual source
5
+ (`@yarnpkg/plugin-interactive-tools`) rather than guessing at the UI, so the
6
+ keybindings, columns, and version-suggestion logic mirror it closely.
7
+
8
+ ## Install / run
9
+
10
+ ```sh
11
+ npx upgrade-interactive
12
+ # or, once published/linked:
13
+ npm install -g upgrade-interactive
14
+ nui
15
+ ```
16
+
17
+ Requires Node 18+ and an interactive terminal.
18
+
19
+ ### Using it inside a project (`npm run`)
20
+
21
+ npm has no plugin system for adding real subcommands the way yarn does, so
22
+ `npm upgrade-interactive` (no `run`) isn't possible. The closest equivalent,
23
+ and the standard way to wire up any custom npm command, is a script entry:
24
+
25
+ ```json
26
+ "scripts": {
27
+ "upgrade-interactive": "upgrade-interactive"
28
+ }
29
+ ```
30
+
31
+ Then `npm run upgrade-interactive` works from that project, every time.
32
+
33
+ ## What it does
34
+
35
+ 1. Reads `dependencies` and `devDependencies` from `package.json`.
36
+ 2. For each package, looks up two candidate upgrades from the npm registry:
37
+ - **Range** — the highest version that still satisfies your current
38
+ semver range (what `npm outdated` calls "Wanted").
39
+ - **Latest** — the version tagged `latest` on the registry, even if it's
40
+ outside your current range (a major bump, for example).
41
+ Both are re-formatted using your existing range modifier (`^`, `~`, or
42
+ exact), and packages with nothing new to offer are left out of the list
43
+ entirely — same as yarn.
44
+ 3. Lets you pick, per package, whether to stay on **Current**, take the
45
+ **Range** upgrade, or take the **Latest** upgrade.
46
+ 4. Writes your choices back into `package.json` and runs `npm install`.
47
+
48
+ ## Controls
49
+
50
+ | Key | Action |
51
+ | ------------------ | ---------------------------------------------------- |
52
+ | `↑` / `↓` | Move between packages |
53
+ | `←` / `→` | Move between Current / Range / Latest for that package |
54
+ | `c` / `r` / `l` | Select **c**urrent / **r**ange / **l**atest for *every* package at once |
55
+ | `Enter` | Apply the selected upgrades and run `npm install` |
56
+ | `Ctrl+C` / `Esc` | Abort — nothing is written |
57
+
58
+ Version numbers are colorized by the size of the bump (yellow-ish for
59
+ minor, red for major), with only the part of the version that actually
60
+ changed highlighted — same idea as yarn's diff highlighting.
61
+
62
+ ## Flags
63
+
64
+ - `--no-install` — update `package.json` only, skip the `npm install` step
65
+ - `-h, --help`, `-v, --version`
66
+
67
+ ## How closely does this match yarn?
68
+
69
+ Matched exactly:
70
+ - The three-column Current/Range/Latest layout and the help text wording
71
+ - The up/down/left/right navigation model (selection = which column is
72
+ highlighted per row, not a separate checkbox)
73
+ - The `c`/`r`/`l` bulk-select shortcuts, including `l`'s fallback to the
74
+ Range value when a package has no separate Latest suggestion
75
+ - Packages with no available upgrade never appear in the list
76
+ - The version-diff coloring algorithm (segment-by-segment: modifier → major
77
+ → minor → patch)
78
+
79
+ Intentional differences:
80
+ - Only plain semver ranges are resolved (git/file/link/workspace ranges,
81
+ and compound ranges like `>=1.0.0 <2.0.0`, are skipped — yarn handles
82
+ these through its pluggable resolvers, which is out of scope here).
83
+ - Only `dependencies`/`devDependencies` are scanned (matches yarn's own
84
+ scope for this command — it skips `peerDependencies`/`optionalDependencies` too).
85
+ - The list stays alphabetically sorted the whole time it's loading. Yarn's
86
+ actual implementation fills rows in whatever order each network request
87
+ finishes, which can make rows jump around while loading — this clone
88
+ avoids that instead of reproducing it.
89
+ - No monorepo/workspace support (single `package.json` only).
90
+
91
+ ## Project layout
92
+
93
+ ```
94
+ src/
95
+ cli.js entry point, arg parsing, apply + npm install
96
+ registry.js npm registry client
97
+ semver-suggest.js Current/Range/Latest suggestion + diff coloring
98
+ package-file.js package.json read/write
99
+ components/
100
+ App.js state machine + keybindings
101
+ Header.js, Prompt.js, Row.js presentation
102
+ test/
103
+ app.test.mjs simulated-keypress smoke tests (ink-testing-library)
104
+ ```
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "upgrade-interactive",
3
+ "version": "1.0.0",
4
+ "description": "A faithful clone of `yarn upgrade-interactive` (Yarn Berry / Yarn 4) for npm projects.",
5
+ "type": "module",
6
+ "bin": {
7
+ "upgrade-interactive": "./src/cli.js",
8
+ "nui": "./src/cli.js"
9
+ },
10
+ "main": "./src/cli.js",
11
+ "scripts": {
12
+ "test": "node test/app.test.mjs"
13
+ },
14
+ "engines": {
15
+ "node": ">=18"
16
+ },
17
+ "files": [
18
+ "src"
19
+ ],
20
+ "keywords": [
21
+ "npm",
22
+ "yarn",
23
+ "upgrade-interactive",
24
+ "dependencies",
25
+ "cli",
26
+ "interactive"
27
+ ],
28
+ "license": "MIT",
29
+ "dependencies": {
30
+ "ink": "^5.2.1",
31
+ "react": "^18.3.1",
32
+ "semver": "^7.8.5"
33
+ },
34
+ "devDependencies": {
35
+ "ink-testing-library": "^4.0.0"
36
+ }
37
+ }
package/src/cli.js ADDED
@@ -0,0 +1,133 @@
1
+ #!/usr/bin/env node
2
+ import React from 'react';
3
+ import { render } from 'ink';
4
+ import { spawn } from 'node:child_process';
5
+ import { readFile } from 'node:fs/promises';
6
+ import { fileURLToPath } from 'node:url';
7
+ import path from 'node:path';
8
+
9
+ import { App } from './components/App.js';
10
+ import { loadManifest, applyUpgrades } from './package-file.js';
11
+
12
+ const e = React.createElement;
13
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
14
+
15
+ const HELP = `
16
+ upgrade-interactive (nui)
17
+
18
+ A faithful clone of "yarn upgrade-interactive" (Yarn Berry / Yarn 4) for npm projects.
19
+
20
+ Usage
21
+ $ npx upgrade-interactive [options]
22
+
23
+ Options
24
+ --no-install Update package.json only, skip running "npm install" afterwards
25
+ -h, --help Show this help message
26
+ -v, --version Show the version number
27
+
28
+ Controls (inside the interactive UI)
29
+ <up>/<down> select a package
30
+ <left>/<right> select which version to apply (Current / Range / Latest)
31
+ c / r / l select all packages' Current / Range / Latest column at once
32
+ <enter> apply the selected upgrades (and run npm install)
33
+ <ctrl+c> / esc abort without changing anything
34
+ `;
35
+
36
+ async function main() {
37
+ const args = process.argv.slice(2);
38
+
39
+ if (args.includes('-h') || args.includes('--help')) {
40
+ process.stdout.write(HELP + '\n');
41
+ return;
42
+ }
43
+
44
+ if (args.includes('-v') || args.includes('--version')) {
45
+ const pkgRaw = await readFile(path.join(__dirname, '..', 'package.json'), 'utf8');
46
+ process.stdout.write(JSON.parse(pkgRaw).version + '\n');
47
+ return;
48
+ }
49
+
50
+ const skipInstall = args.includes('--no-install');
51
+
52
+ if (!process.stdin.isTTY) {
53
+ process.stderr.write('upgrade-interactive requires an interactive terminal (TTY).\n');
54
+ process.exitCode = 1;
55
+ return;
56
+ }
57
+
58
+ const cwd = process.cwd();
59
+ let manifest;
60
+ try {
61
+ manifest = await loadManifest(cwd);
62
+ } catch (err) {
63
+ process.stderr.write(`${err.message}\n`);
64
+ process.exitCode = 1;
65
+ return;
66
+ }
67
+
68
+ const result = await new Promise((resolve) => {
69
+ const { waitUntilExit } = render(
70
+ e(App, {
71
+ descriptors: manifest.descriptors,
72
+ onSubmit: (selections) => resolve({ type: 'submit', selections }),
73
+ onAbort: () => resolve({ type: 'abort' }),
74
+ }),
75
+ { exitOnCtrlC: false }
76
+ );
77
+ waitUntilExit().catch(() => resolve({ type: 'abort' }));
78
+ });
79
+
80
+ if (result.type === 'abort') {
81
+ process.stdout.write('\nAborted. No changes were made.\n');
82
+ process.exitCode = 1;
83
+ return;
84
+ }
85
+
86
+ if (result.selections.size === 0) {
87
+ process.stdout.write('\nNo changes selected.\n');
88
+ return;
89
+ }
90
+
91
+ const applied = await applyUpgrades(manifest, result.selections);
92
+
93
+ process.stdout.write('\n');
94
+ const byField = { dependencies: [], devDependencies: [] };
95
+ for (const change of applied) byField[change.field].push(change);
96
+
97
+ for (const field of ['dependencies', 'devDependencies']) {
98
+ if (byField[field].length === 0) continue;
99
+ process.stdout.write(`${field}\n`);
100
+ for (const change of byField[field]) {
101
+ process.stdout.write(` ${change.name} ${change.from} \u2192 ${change.to}\n`);
102
+ }
103
+ }
104
+
105
+ if (skipInstall) {
106
+ process.stdout.write('\nUpdated package.json. Run npm install to apply.\n');
107
+ return;
108
+ }
109
+
110
+ process.stdout.write('\nRunning npm install...\n');
111
+ await runNpmInstall(cwd);
112
+ }
113
+
114
+ function runNpmInstall(cwd) {
115
+ return new Promise((resolve) => {
116
+ const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm';
117
+ const child = spawn(npmCmd, ['install'], { cwd, stdio: 'inherit' });
118
+ child.on('exit', (code) => {
119
+ process.exitCode = code ?? 0;
120
+ resolve();
121
+ });
122
+ child.on('error', () => {
123
+ process.stderr.write('Failed to run npm install. Run it manually to finish updating your lockfile.\n');
124
+ process.exitCode = 1;
125
+ resolve();
126
+ });
127
+ });
128
+ }
129
+
130
+ main().catch((err) => {
131
+ process.stderr.write(`${err?.stack || err}\n`);
132
+ process.exitCode = 1;
133
+ });
@@ -0,0 +1,207 @@
1
+ import React, { useEffect, useRef, useState, useCallback } from 'react';
2
+ import { Box, Text, useInput, useApp } from 'ink';
3
+ import { Prompt } from './Prompt.js';
4
+ import { Header } from './Header.js';
5
+ import { Row, LoadingRow } from './Row.js';
6
+ import { fetchSuggestions } from '../semver-suggest.js';
7
+ import { mapWithConcurrency } from '../registry.js';
8
+
9
+ const e = React.createElement;
10
+ const CONCURRENCY = 8;
11
+
12
+ function clamp(n, min, max) {
13
+ return Math.max(min, Math.min(max, n));
14
+ }
15
+
16
+ function findNavigable(entries, from, direction) {
17
+ let i = from;
18
+ for (let step = 0; step < entries.length; step++) {
19
+ i += direction;
20
+ if (i < 0 || i >= entries.length) return from;
21
+ if (entries[i] && typeof entries[i] === 'object') return i;
22
+ }
23
+ return from;
24
+ }
25
+
26
+ function firstNavigable(entries) {
27
+ for (let i = 0; i < entries.length; i++) {
28
+ if (entries[i] && typeof entries[i] === 'object') return i;
29
+ }
30
+ return -1;
31
+ }
32
+
33
+ export function App({ descriptors, onSubmit, onAbort }) {
34
+ const { exit } = useApp();
35
+ const [entries, setEntries] = useState(() => descriptors.map(() => null));
36
+ const [allLoaded, setAllLoaded] = useState(descriptors.length === 0);
37
+ const [focusedIndex, setFocusedIndex] = useState(-1);
38
+ const [selectedColumns, setSelectedColumns] = useState({});
39
+ const mountedRef = useRef(true);
40
+
41
+ useEffect(() => {
42
+ return () => {
43
+ mountedRef.current = false;
44
+ };
45
+ }, []);
46
+
47
+ useEffect(() => {
48
+ if (descriptors.length === 0) return;
49
+ let cancelled = false;
50
+
51
+ mapWithConcurrency(
52
+ descriptors,
53
+ CONCURRENCY,
54
+ async (descriptor) => {
55
+ const suggestions = await fetchSuggestions(descriptor);
56
+ return suggestions ? { descriptor, suggestions } : null;
57
+ },
58
+ (result, _descriptor, index) => {
59
+ if (cancelled || !mountedRef.current) return;
60
+ setEntries((prev) => {
61
+ const next = [...prev];
62
+ next[index] = result;
63
+ return next;
64
+ });
65
+ }
66
+ ).then(() => {
67
+ if (cancelled || !mountedRef.current) return;
68
+ setAllLoaded(true);
69
+ });
70
+
71
+ return () => {
72
+ cancelled = true;
73
+ };
74
+ }, [descriptors]);
75
+
76
+ // Keep focus pinned to a navigable (loaded, upgradeable) row as things load in.
77
+ useEffect(() => {
78
+ if (focusedIndex !== -1 && entries[focusedIndex] && typeof entries[focusedIndex] === 'object') return;
79
+ const next = firstNavigable(entries);
80
+ if (next !== focusedIndex) setFocusedIndex(next);
81
+ }, [entries, focusedIndex]);
82
+
83
+ const cycleColumn = useCallback(
84
+ (direction) => {
85
+ if (focusedIndex === -1) return;
86
+ const entry = entries[focusedIndex];
87
+ if (!entry) return;
88
+ const name = entry.descriptor.name;
89
+ const current = selectedColumns[name] ?? 0;
90
+ let next = current;
91
+ for (let step = 0; step < entry.suggestions.length; step++) {
92
+ next = clamp(next + direction, 0, entry.suggestions.length - 1);
93
+ if (entry.suggestions[next].spans.length > 0 || next === 0) break;
94
+ if (next === current) break;
95
+ }
96
+ setSelectedColumns((prev) => ({ ...prev, [name]: next }));
97
+ },
98
+ [focusedIndex, entries, selectedColumns]
99
+ );
100
+
101
+ const bulkSelect = useCallback(
102
+ (which) => {
103
+ setSelectedColumns((prev) => {
104
+ const next = { ...prev };
105
+ for (const entry of entries) {
106
+ if (!entry) continue;
107
+ const { name } = entry.descriptor;
108
+ if (which === 'c') {
109
+ next[name] = 0;
110
+ } else if (which === 'r') {
111
+ next[name] = 1;
112
+ } else if (which === 'l') {
113
+ next[name] = entry.suggestions[2].value != null ? 2 : 1;
114
+ }
115
+ }
116
+ return next;
117
+ });
118
+ },
119
+ [entries]
120
+ );
121
+
122
+ useInput((input, key) => {
123
+ if (key.ctrl && input === 'c') {
124
+ onAbort();
125
+ exit();
126
+ return;
127
+ }
128
+ if (key.escape) {
129
+ onAbort();
130
+ exit();
131
+ return;
132
+ }
133
+ if (key.upArrow) {
134
+ setFocusedIndex((idx) => findNavigable(entries, idx, -1));
135
+ return;
136
+ }
137
+ if (key.downArrow) {
138
+ setFocusedIndex((idx) => findNavigable(entries, idx, 1));
139
+ return;
140
+ }
141
+ if (key.leftArrow) {
142
+ cycleColumn(-1);
143
+ return;
144
+ }
145
+ if (key.rightArrow) {
146
+ cycleColumn(1);
147
+ return;
148
+ }
149
+ if (input === 'c' || input === 'r' || input === 'l') {
150
+ bulkSelect(input);
151
+ return;
152
+ }
153
+ if (key.return) {
154
+ const selections = new Map();
155
+ for (const entry of entries) {
156
+ if (!entry) continue;
157
+ const col = selectedColumns[entry.descriptor.name] ?? 0;
158
+ const value = entry.suggestions[col]?.value ?? null;
159
+ if (value) selections.set(entry.descriptor.name, value);
160
+ }
161
+ onSubmit(selections);
162
+ exit();
163
+ }
164
+ });
165
+
166
+ const displayIndices = allLoaded
167
+ ? entries.map((_, i) => i).filter((i) => entries[i] !== null)
168
+ : entries.map((_, i) => i);
169
+
170
+ if (allLoaded && displayIndices.length === 0) {
171
+ return e(
172
+ Box,
173
+ { flexDirection: 'column' },
174
+ e(Prompt, null),
175
+ e(Header, null),
176
+ e(Text, { dimColor: true }, 'No upgrades found.')
177
+ );
178
+ }
179
+
180
+ const termRows = (process.stdout && process.stdout.rows) || 24;
181
+ const maxRows = Math.max(5, termRows - 11);
182
+ const posInDisplay = Math.max(0, displayIndices.indexOf(focusedIndex));
183
+ let windowStart = clamp(posInDisplay - Math.floor(maxRows / 2), 0, Math.max(0, displayIndices.length - maxRows));
184
+ const windowEnd = Math.min(displayIndices.length, windowStart + maxRows);
185
+ const visible = displayIndices.slice(windowStart, windowEnd);
186
+
187
+ return e(
188
+ Box,
189
+ { flexDirection: 'column' },
190
+ e(Prompt, null),
191
+ e(Header, null),
192
+ windowStart > 0 ? e(Text, { dimColor: true }, ` \u2191 ${windowStart} more above`) : null,
193
+ ...visible.map((i) => {
194
+ const entry = entries[i];
195
+ if (!entry) return e(LoadingRow, { key: i });
196
+ const col = selectedColumns[entry.descriptor.name] ?? 0;
197
+ return e(Row, {
198
+ key: i,
199
+ name: entry.descriptor.name,
200
+ active: i === focusedIndex,
201
+ suggestions: entry.suggestions,
202
+ selectedColumn: col,
203
+ });
204
+ }),
205
+ windowEnd < displayIndices.length ? e(Text, { dimColor: true }, ` \u2193 ${displayIndices.length - windowEnd} more below`) : null
206
+ );
207
+ }
@@ -0,0 +1,19 @@
1
+ import React from 'react';
2
+ import { Box, Text } from 'ink';
3
+
4
+ const e = React.createElement;
5
+
6
+ export function Header() {
7
+ return e(
8
+ Box,
9
+ { flexDirection: 'row', paddingTop: 1, paddingBottom: 1 },
10
+ e(
11
+ Box,
12
+ { width: 50 },
13
+ e(Text, { bold: true }, e(Text, { color: 'greenBright' }, '?'), ' Pick the packages you want to upgrade.')
14
+ ),
15
+ e(Box, { width: 17 }, e(Text, { bold: true, underline: true, color: 'gray' }, 'Current')),
16
+ e(Box, { width: 17 }, e(Text, { bold: true, underline: true, color: 'gray' }, 'Range')),
17
+ e(Box, { width: 17 }, e(Text, { bold: true, underline: true, color: 'gray' }, 'Latest'))
18
+ );
19
+ }
@@ -0,0 +1,56 @@
1
+ import React from 'react';
2
+ import { Box, Text } from 'ink';
3
+
4
+ const e = React.createElement;
5
+
6
+ function Key({ children }) {
7
+ return e(Text, { bold: true, color: 'cyanBright' }, children);
8
+ }
9
+
10
+ export function Prompt() {
11
+ return e(
12
+ Box,
13
+ { flexDirection: 'row' },
14
+ e(
15
+ Box,
16
+ { flexDirection: 'column', width: 49 },
17
+ e(
18
+ Box,
19
+ { marginLeft: 1 },
20
+ e(Text, null, 'Press ', e(Key, null, '<up>'), '/', e(Key, null, '<down>'), ' to select packages.')
21
+ ),
22
+ e(
23
+ Box,
24
+ { marginLeft: 1 },
25
+ e(Text, null, 'Press ', e(Key, null, '<left>'), '/', e(Key, null, '<right>'), ' to select versions.')
26
+ ),
27
+ e(
28
+ Box,
29
+ { marginLeft: 1 },
30
+ e(
31
+ Text,
32
+ null,
33
+ 'Press ',
34
+ e(Key, null, 'c'),
35
+ '/',
36
+ e(Key, null, 'r'),
37
+ '/',
38
+ e(Key, null, 'l'),
39
+ ' to select all ',
40
+ e(Key, null, 'current'),
41
+ '/',
42
+ e(Key, null, 'range'),
43
+ '/',
44
+ e(Key, null, 'latest'),
45
+ '.'
46
+ )
47
+ )
48
+ ),
49
+ e(
50
+ Box,
51
+ { flexDirection: 'column' },
52
+ e(Box, { marginLeft: 1 }, e(Text, null, 'Press ', e(Key, null, '<enter>'), ' to install.')),
53
+ e(Box, { marginLeft: 1 }, e(Text, null, 'Press ', e(Key, null, '<ctrl+c>'), ' to abort.'))
54
+ )
55
+ );
56
+ }
@@ -0,0 +1,51 @@
1
+ import React from 'react';
2
+ import { Box, Text } from 'ink';
3
+
4
+ const e = React.createElement;
5
+
6
+ function Spans({ spans, inverse }) {
7
+ if (!spans || spans.length === 0) return e(Text, null, '');
8
+ return e(
9
+ Text,
10
+ { inverse },
11
+ ...spans.map((span, i) => e(Text, { key: i, color: span.color || undefined }, span.text))
12
+ );
13
+ }
14
+
15
+ function Column({ suggestion, selected }) {
16
+ const hasContent = suggestion && suggestion.spans.length > 0;
17
+ return e(
18
+ Box,
19
+ { width: 17 },
20
+ hasContent
21
+ ? e(Text, null, selected ? '\u25CF ' : '\u25CB ', e(Spans, { spans: suggestion.spans, inverse: selected }))
22
+ : e(Text, { dimColor: true }, selected ? '\u25CF' : '')
23
+ );
24
+ }
25
+
26
+ export function Row({ name, active, suggestions, selectedColumn }) {
27
+ const padLength = Math.max(1, 45 - name.length);
28
+ return e(
29
+ Box,
30
+ { flexDirection: 'row' },
31
+ e(Box, { width: 2 }, e(Text, { color: 'cyanBright', bold: true }, active ? '\u276F ' : ' ')),
32
+ e(
33
+ Box,
34
+ { width: 45 },
35
+ e(Text, { bold: true }, name),
36
+ e(Text, null, ' '.repeat(padLength))
37
+ ),
38
+ e(Column, { suggestion: suggestions[0], selected: selectedColumn === 0 }),
39
+ e(Column, { suggestion: suggestions[1], selected: selectedColumn === 1 }),
40
+ e(Column, { suggestion: suggestions[2], selected: selectedColumn === 2 })
41
+ );
42
+ }
43
+
44
+ export function LoadingRow() {
45
+ return e(
46
+ Box,
47
+ { flexDirection: 'row' },
48
+ e(Box, { width: 2 }),
49
+ e(Box, { width: 45 }, e(Text, { dimColor: true }, 'Loading...'))
50
+ );
51
+ }
@@ -0,0 +1,61 @@
1
+ import { readFile, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ const DEPENDENCY_FIELDS = ['dependencies', 'devDependencies'];
5
+
6
+ export async function loadManifest(cwd) {
7
+ const filePath = path.join(cwd, 'package.json');
8
+ let raw;
9
+ try {
10
+ raw = await readFile(filePath, 'utf8');
11
+ } catch {
12
+ throw new Error(`No package.json found in ${cwd}`);
13
+ }
14
+
15
+ let json;
16
+ try {
17
+ json = JSON.parse(raw);
18
+ } catch (err) {
19
+ throw new Error(`Could not parse package.json: ${err.message}`);
20
+ }
21
+
22
+ const indentMatch = raw.match(/^[ \t]+/m);
23
+ const indent = indentMatch ? indentMatch[0] : ' ';
24
+ const trailingNewline = raw.endsWith('\n');
25
+
26
+ const descriptors = [];
27
+ for (const field of DEPENDENCY_FIELDS) {
28
+ const section = json[field];
29
+ if (!section || typeof section !== 'object') continue;
30
+ for (const [name, range] of Object.entries(section)) {
31
+ descriptors.push({ name, range, field });
32
+ }
33
+ }
34
+
35
+ descriptors.sort((a, b) => a.name.localeCompare(b.name));
36
+
37
+ return { filePath, json, raw, indent, trailingNewline, descriptors };
38
+ }
39
+
40
+ /**
41
+ * Apply a Map<name, newRange> of accepted upgrades to the manifest and
42
+ * write it back to disk. Returns the list of { name, field, from, to } applied.
43
+ */
44
+ export async function applyUpgrades(manifest, selections) {
45
+ const applied = [];
46
+
47
+ for (const descriptor of manifest.descriptors) {
48
+ const newRange = selections.get(descriptor.name);
49
+ if (!newRange || newRange === descriptor.range) continue;
50
+
51
+ manifest.json[descriptor.field][descriptor.name] = newRange;
52
+ applied.push({ name: descriptor.name, field: descriptor.field, from: descriptor.range, to: newRange });
53
+ }
54
+
55
+ if (applied.length === 0) return applied;
56
+
57
+ const serialized = JSON.stringify(manifest.json, null, manifest.indent) + (manifest.trailingNewline ? '\n' : '');
58
+ await writeFile(manifest.filePath, serialized, 'utf8');
59
+
60
+ return applied;
61
+ }
@@ -0,0 +1,62 @@
1
+ // Minimal npm registry client.
2
+ // Uses the "abbreviated" metadata format (much smaller / faster than full docs).
3
+
4
+ const REGISTRY = 'https://registry.npmjs.org';
5
+ const cache = new Map();
6
+
7
+ function encodePackageName(name) {
8
+ // Scoped packages (@scope/name) keep the slash un-encoded for the
9
+ // registry.npmjs.org convention, everything else is encoded normally.
10
+ if (name.startsWith('@')) {
11
+ const [scope, pkg] = name.split('/');
12
+ return `${encodeURIComponent(scope)}/${encodeURIComponent(pkg)}`;
13
+ }
14
+ return encodeURIComponent(name);
15
+ }
16
+
17
+ /**
18
+ * Fetch { versions: string[], distTags: Record<string,string> } for a package.
19
+ * Returns null if the package can't be resolved (404, network error, etc).
20
+ */
21
+ export async function fetchPackageMeta(name) {
22
+ if (cache.has(name)) return cache.get(name);
23
+
24
+ const promise = (async () => {
25
+ try {
26
+ const res = await fetch(`${REGISTRY}/${encodePackageName(name)}`, {
27
+ headers: {
28
+ Accept: 'application/vnd.npm.install-v1+json, application/json',
29
+ 'User-Agent': 'upgrade-interactive',
30
+ },
31
+ });
32
+ if (!res.ok) return null;
33
+ const json = await res.json();
34
+ const versions = Object.keys(json.versions || {});
35
+ const distTags = json['dist-tags'] || {};
36
+ if (versions.length === 0) return null;
37
+ return { versions, distTags };
38
+ } catch {
39
+ return null;
40
+ }
41
+ })();
42
+
43
+ cache.set(name, promise);
44
+ return promise;
45
+ }
46
+
47
+ /**
48
+ * Run `worker` over `items` with at most `limit` in flight at once.
49
+ * Calls `onEach(result, item, index)` as each one resolves (out of order).
50
+ */
51
+ export async function mapWithConcurrency(items, limit, worker, onEach) {
52
+ let cursor = 0;
53
+ const runners = new Array(Math.min(limit, items.length)).fill(null).map(async () => {
54
+ while (cursor < items.length) {
55
+ const index = cursor++;
56
+ const item = items[index];
57
+ const result = await worker(item, index);
58
+ if (onEach) onEach(result, item, index);
59
+ }
60
+ });
61
+ await Promise.all(runners);
62
+ }
@@ -0,0 +1,128 @@
1
+ import semver from 'semver';
2
+ import { fetchPackageMeta } from './registry.js';
3
+
4
+ // Same regex yarn uses to split a "simple" semver range into
5
+ // [modifier, major, .minor, .patch, -prerelease] groups.
6
+ const SIMPLE_SEMVER = /^((?:[\^~]|>=?)?)([0-9]+)(\.[0-9]+)(\.[0-9]+)((?:-\S+)?)$/;
7
+
8
+ // Ranges we can't safely resolve against the registry (git/file/link/workspace
9
+ // protocols, npm aliases, complex boolean ranges, dist-tags other than a bare
10
+ // tag name, etc). Yarn handles these through pluggable resolvers; we just skip them.
11
+ function isSimpleRange(range) {
12
+ if (!range) return false;
13
+ if (/^(git|github|gitlab|bitbucket|file|link|workspace|http|https|npm):/.test(range)) return false;
14
+ // Reject compound ranges ("1.x || 2.x", "1.0.0 - 2.0.0", etc) and anything
15
+ // with internal whitespace; those aren't resolvable by our simple scheme.
16
+ if (/\s/.test(range)) return false;
17
+ return true;
18
+ }
19
+
20
+ function getModifier(range) {
21
+ const match = range.match(SIMPLE_SEMVER);
22
+ if (match) return match[1];
23
+ if (range.startsWith('^')) return '^';
24
+ if (range.startsWith('~')) return '~';
25
+ return '';
26
+ }
27
+
28
+ /** Re-apply the original range's modifier (^, ~, or exact) to a resolved version. */
29
+ function applyModifier(modifier, version) {
30
+ return `${modifier}${version}`;
31
+ }
32
+
33
+ /**
34
+ * Resolve the highest published version matching `targetRange` (a semver
35
+ * range OR a dist-tag name like "latest"), then re-format it using the
36
+ * original descriptor's modifier style. Mirrors yarn's fetchUpdatedDescriptor.
37
+ */
38
+ function resolveAgainstMeta(meta, originalRange, targetRange) {
39
+ const modifier = getModifier(originalRange);
40
+
41
+ if (Object.prototype.hasOwnProperty.call(meta.distTags, targetRange)) {
42
+ const version = meta.distTags[targetRange];
43
+ if (!version) return null;
44
+ return applyModifier(modifier, version);
45
+ }
46
+
47
+ const best = semver.maxSatisfying(meta.versions, targetRange, { includePrerelease: false });
48
+ if (!best) return null;
49
+ return applyModifier(modifier, best);
50
+ }
51
+
52
+ /**
53
+ * Colorize the part of `to` that differs from `from`, segment by segment
54
+ * (modifier / major / minor / patch / prerelease), exactly like yarn's
55
+ * colorizeVersionDiff. Returns an array of { text, color } spans ready to
56
+ * be rendered as a sequence of <Text color=...> chunks.
57
+ */
58
+ export function colorizeVersionDiff(from, to) {
59
+ if (from === to) return [{ text: to, color: null }];
60
+
61
+ const matchedFrom = from.match(SIMPLE_SEMVER);
62
+ const matchedTo = to.match(SIMPLE_SEMVER);
63
+ if (!matchedFrom || !matchedTo) return [{ text: to, color: null }];
64
+
65
+ const SEMVER_COLORS = ['gray', 'red', 'yellow', 'green', 'magenta'];
66
+ let color = null;
67
+ const spans = [];
68
+
69
+ for (let t = 1; t < matchedTo.length; ++t) {
70
+ const differs = matchedFrom[t] !== matchedTo[t];
71
+ if (color !== null || differs) {
72
+ if (color === null) color = SEMVER_COLORS[Math.min(t - 1, SEMVER_COLORS.length - 1)];
73
+ spans.push({ text: matchedTo[t], color });
74
+ } else {
75
+ spans.push({ text: matchedTo[t], color: null });
76
+ }
77
+ }
78
+
79
+ return spans;
80
+ }
81
+
82
+ /**
83
+ * Compute the { label: 'current'|'range'|'latest', value, spans }[] suggestion
84
+ * set for a single descriptor, or null if there's nothing to upgrade to
85
+ * (mirrors yarn: a package with no viable Range/Latest suggestion is
86
+ * dropped from the list entirely).
87
+ */
88
+ export async function fetchSuggestions(descriptor) {
89
+ const { name, range } = descriptor;
90
+ if (!isSimpleRange(range)) return null;
91
+
92
+ const meta = await fetchPackageMeta(name);
93
+ if (!meta) return null;
94
+
95
+ const referenceRange = semver.valid(range) ? `^${range}` : range;
96
+
97
+ let rangeResolution = null;
98
+ let latestResolution = null;
99
+ try {
100
+ rangeResolution = resolveAgainstMeta(meta, range, referenceRange);
101
+ } catch {
102
+ rangeResolution = null;
103
+ }
104
+ try {
105
+ latestResolution = resolveAgainstMeta(meta, range, 'latest');
106
+ } catch {
107
+ latestResolution = null;
108
+ }
109
+
110
+ const suggestions = [
111
+ { key: 'current', value: null, spans: [{ text: range, color: null }] },
112
+ { key: 'range', value: null, spans: [] },
113
+ { key: 'latest', value: null, spans: [] },
114
+ ];
115
+
116
+ if (rangeResolution && rangeResolution !== range) {
117
+ suggestions[1] = { key: 'range', value: rangeResolution, spans: colorizeVersionDiff(range, rangeResolution) };
118
+ }
119
+
120
+ if (latestResolution && latestResolution !== rangeResolution && latestResolution !== range) {
121
+ suggestions[2] = { key: 'latest', value: latestResolution, spans: colorizeVersionDiff(range, latestResolution) };
122
+ }
123
+
124
+ const usableCount = suggestions.filter((s) => s.spans.length > 0).length;
125
+ if (usableCount <= 1) return null;
126
+
127
+ return suggestions;
128
+ }