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 +21 -0
- package/README.md +104 -0
- package/package.json +37 -0
- package/src/cli.js +133 -0
- package/src/components/App.js +207 -0
- package/src/components/Header.js +19 -0
- package/src/components/Prompt.js +56 -0
- package/src/components/Row.js +51 -0
- package/src/package-file.js +61 -0
- package/src/registry.js +62 -0
- package/src/semver-suggest.js +128 -0
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
|
+
}
|
package/src/registry.js
ADDED
|
@@ -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
|
+
}
|