@gjsify/cli 0.4.32 → 0.4.34
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/cli.gjs.mjs +11 -10
- package/lib/commands/upgrade.d.ts +4 -0
- package/lib/commands/upgrade.js +318 -79
- package/lib/utils/dep-aggregation.d.ts +43 -0
- package/lib/utils/dep-aggregation.js +86 -0
- package/package.json +15 -15
package/lib/commands/upgrade.js
CHANGED
|
@@ -1,26 +1,45 @@
|
|
|
1
1
|
// `gjsify upgrade` — drop-in replacement for `yarn upgrade-interactive`
|
|
2
|
-
// and `npx npm-check-updates`.
|
|
2
|
+
// and `npx npm-check-updates`. Workspace-aware: walks every package.json
|
|
3
|
+
// declared by the monorepo, groups by dep name, surfaces inconsistencies.
|
|
3
4
|
//
|
|
4
|
-
//
|
|
5
|
-
// select which ones to update (space-separated indices or `a` for
|
|
6
|
-
// all), then write the new ranges to `package.json`.
|
|
5
|
+
// Modes:
|
|
7
6
|
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
7
|
+
// 1. Interactive (default): show outdated packages aggregated across all
|
|
8
|
+
// workspaces, prompt user to select, then write the new ranges to every
|
|
9
|
+
// affected `package.json` (with optional per-workspace override).
|
|
10
10
|
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
11
|
+
// 2. Non-interactive bulk (`--latest` / `--minor` / `--patch`): bump
|
|
12
|
+
// matching packages automatically without prompting; same selection
|
|
13
|
+
// logic as above but no UI loop.
|
|
14
|
+
//
|
|
15
|
+
// 3. `--align`: offline consistency-only mode. Finds deps declared at
|
|
16
|
+
// multiple ranges across the workspace and proposes a single range
|
|
17
|
+
// (the highest declared) — no registry calls.
|
|
18
|
+
//
|
|
19
|
+
// 4. `--check`: CI gate. Exits non-zero when any inconsistency exists.
|
|
20
|
+
//
|
|
21
|
+
// Filters:
|
|
22
|
+
//
|
|
23
|
+
// --filter <name> match against dep name
|
|
24
|
+
// --workspace <pat> restrict to a subset of workspaces (`-p` shorthand;
|
|
25
|
+
// repeatable; comma-separated; glob-like — see
|
|
26
|
+
// `filterWorkspaces` in `@gjsify/workspace`).
|
|
27
|
+
//
|
|
28
|
+
// Workspace-protocol ranges (`workspace:*`, `workspace:^`, …) are always
|
|
29
|
+
// skipped — those are internal links and `gjsify install` resolves them
|
|
30
|
+
// locally.
|
|
15
31
|
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
16
32
|
import { join, resolve } from 'node:path';
|
|
17
33
|
import { homedir } from 'node:os';
|
|
18
34
|
import { createInterface } from 'node:readline/promises';
|
|
19
35
|
import { parse } from '@gjsify/semver';
|
|
20
36
|
import { DEFAULT_REGISTRY, fetchPackument, parseNpmrc } from '@gjsify/npm-registry';
|
|
37
|
+
import { discoverWorkspaces, filterWorkspaces } from '@gjsify/workspace';
|
|
38
|
+
import { findWorkspaceRoot } from '../utils/workspace-root.js';
|
|
39
|
+
import { groupByDependency, findInconsistencies, } from '../utils/dep-aggregation.js';
|
|
21
40
|
export const upgradeCommand = {
|
|
22
41
|
command: 'upgrade',
|
|
23
|
-
description: '
|
|
42
|
+
description: 'Workspace-aware dependency upgrades. Walks every package.json in the monorepo, groups by dep, flags inconsistencies. `--latest` / `--minor` / `--patch` for bulk-mode, `--align` for offline consistency-only mode, `--check` for CI drift detection.',
|
|
24
43
|
builder: (yargs) => {
|
|
25
44
|
return yargs
|
|
26
45
|
.option('latest', {
|
|
@@ -39,16 +58,35 @@ export const upgradeCommand = {
|
|
|
39
58
|
default: false,
|
|
40
59
|
})
|
|
41
60
|
.option('filter', {
|
|
42
|
-
description: 'Only consider
|
|
61
|
+
description: 'Only consider deps whose name matches this substring (case-insensitive). Repeatable; comma-separated values are split.',
|
|
62
|
+
type: 'string',
|
|
63
|
+
})
|
|
64
|
+
.option('workspace', {
|
|
65
|
+
alias: 'p',
|
|
66
|
+
description: 'Restrict to a subset of workspaces. Patterns are matched against the workspace package name AND its directory path (substring + glob). Repeatable; comma-separated values are split.',
|
|
67
|
+
type: 'string',
|
|
68
|
+
})
|
|
69
|
+
.option('exclude-workspace', {
|
|
70
|
+
description: 'Skip workspaces matching this pattern (glob-shaped; matched against workspace package name AND directory path). Repeatable; comma-separated values are split. Use for workspaces with intentional dependency drift — e.g. integration tests pinned to specific upstream versions.',
|
|
43
71
|
type: 'string',
|
|
72
|
+
})
|
|
73
|
+
.option('align', {
|
|
74
|
+
description: 'Offline consistency mode: find deps declared at multiple ranges across the workspace and align them to the highest. No registry calls.',
|
|
75
|
+
type: 'boolean',
|
|
76
|
+
default: false,
|
|
77
|
+
})
|
|
78
|
+
.option('check', {
|
|
79
|
+
description: 'CI gate: exit non-zero when any dep is declared inconsistently across workspaces. Implies offline (no registry calls). Pairs with `--align` as the fix.',
|
|
80
|
+
type: 'boolean',
|
|
81
|
+
default: false,
|
|
44
82
|
})
|
|
45
83
|
.option('dry-run', {
|
|
46
|
-
description: 'Print the upgrade plan without writing package.json.',
|
|
84
|
+
description: 'Print the upgrade plan without writing package.json files.',
|
|
47
85
|
type: 'boolean',
|
|
48
86
|
default: false,
|
|
49
87
|
})
|
|
50
88
|
.option('cwd', {
|
|
51
|
-
description: 'Project directory. Default: process.cwd().',
|
|
89
|
+
description: 'Project directory. Default: process.cwd(). When inside a workspace, walks up to the monorepo root.',
|
|
52
90
|
type: 'string',
|
|
53
91
|
})
|
|
54
92
|
.option('yes', {
|
|
@@ -65,24 +103,29 @@ export const upgradeCommand = {
|
|
|
65
103
|
},
|
|
66
104
|
handler: async (args) => {
|
|
67
105
|
const cwd = resolve(args.cwd ?? process.cwd());
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
106
|
+
const ctx = discoverContext(cwd);
|
|
107
|
+
const targetWorkspaces = applyWorkspaceFilter(ctx.workspaces, args.workspace, args.excludeWorkspace);
|
|
108
|
+
if (targetWorkspaces.length === 0) {
|
|
109
|
+
console.log('[gjsify upgrade] no matching workspaces.');
|
|
110
|
+
return;
|
|
71
111
|
}
|
|
72
|
-
const
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
? args.filter
|
|
76
|
-
.split(',')
|
|
77
|
-
.map((s) => s.trim().toLowerCase())
|
|
78
|
-
.filter(Boolean)
|
|
79
|
-
: [];
|
|
80
|
-
const entries = collectExternalDeps(pkg, filters);
|
|
81
|
-
if (entries.length === 0) {
|
|
112
|
+
const depFilters = parseFilterList(args.filter);
|
|
113
|
+
const decls = collectAllDeclarations(targetWorkspaces, depFilters);
|
|
114
|
+
if (decls.length === 0) {
|
|
82
115
|
console.log('[gjsify upgrade] no external npm dependencies to check.');
|
|
83
116
|
return;
|
|
84
117
|
}
|
|
85
|
-
const
|
|
118
|
+
const groups = groupByDependency(decls);
|
|
119
|
+
// --check: exit non-zero if any inconsistency.
|
|
120
|
+
if (args.check) {
|
|
121
|
+
return runCheckMode(groups);
|
|
122
|
+
}
|
|
123
|
+
// --align: offline consistency-only, no registry calls.
|
|
124
|
+
if (args.align) {
|
|
125
|
+
return runAlignMode(groups, args);
|
|
126
|
+
}
|
|
127
|
+
// Normal flow: hit the registry, build upgrade table.
|
|
128
|
+
const npmrc = await loadNpmrcLight(ctx.root);
|
|
86
129
|
const mode = args.latest
|
|
87
130
|
? 'latest'
|
|
88
131
|
: args.minor
|
|
@@ -90,10 +133,15 @@ export const upgradeCommand = {
|
|
|
90
133
|
: args.patch
|
|
91
134
|
? 'patch'
|
|
92
135
|
: 'interactive';
|
|
93
|
-
console.log(`[gjsify upgrade] checking ${
|
|
94
|
-
const candidates = await
|
|
136
|
+
console.log(`[gjsify upgrade] checking ${groups.length} unique deps across ${targetWorkspaces.length} workspace(s) against ${npmrc.registry}…`);
|
|
137
|
+
const candidates = await resolveCandidateGroups(groups, npmrc, args.verbose ?? false, mode);
|
|
95
138
|
if (candidates.length === 0) {
|
|
96
139
|
console.log('✅ all dependencies are up to date');
|
|
140
|
+
// Even with no upgrades, surface inconsistencies as a courtesy.
|
|
141
|
+
const inconsistencies = findInconsistencies(groups);
|
|
142
|
+
if (inconsistencies.length > 0) {
|
|
143
|
+
console.log(`\n⚠ ${inconsistencies.length} dep(s) declared at inconsistent ranges across workspaces. Run \`gjsify upgrade --align\` to fix.`);
|
|
144
|
+
}
|
|
97
145
|
return;
|
|
98
146
|
}
|
|
99
147
|
printTable(candidates);
|
|
@@ -109,21 +157,85 @@ export const upgradeCommand = {
|
|
|
109
157
|
selected = candidates;
|
|
110
158
|
}
|
|
111
159
|
if (selected.length === 0) {
|
|
112
|
-
console.log('[gjsify upgrade] nothing selected;
|
|
160
|
+
console.log('[gjsify upgrade] nothing selected; no files changed.');
|
|
113
161
|
return;
|
|
114
162
|
}
|
|
115
163
|
if (args.dryRun) {
|
|
116
|
-
|
|
164
|
+
const fileCount = uniqueLocations(selected).length;
|
|
165
|
+
console.log(`[gjsify upgrade] --dry-run: would update ${selected.length} deps across ${fileCount} package.json file(s).`);
|
|
166
|
+
printChangePlan(selected);
|
|
117
167
|
return;
|
|
118
168
|
}
|
|
119
|
-
|
|
120
|
-
console.log(`✏️ updated ${selected.length}
|
|
169
|
+
const files = applyToFiles(selected);
|
|
170
|
+
console.log(`✏️ updated ${selected.length} dep(s) across ${files} package.json file(s). Run \`gjsify install\` to apply.`);
|
|
121
171
|
},
|
|
122
172
|
};
|
|
123
|
-
|
|
173
|
+
function discoverContext(cwd) {
|
|
174
|
+
const root = findWorkspaceRoot(cwd);
|
|
175
|
+
if (root) {
|
|
176
|
+
const ws = discoverWorkspaces(root, { includeRoot: true });
|
|
177
|
+
return { root, workspaces: ws, isMonorepo: true };
|
|
178
|
+
}
|
|
179
|
+
// Single-package fallback — keep legacy behavior.
|
|
180
|
+
const pkgPath = join(cwd, 'package.json');
|
|
181
|
+
if (!existsSync(pkgPath)) {
|
|
182
|
+
throw new Error(`[gjsify upgrade] no package.json at ${pkgPath}`);
|
|
183
|
+
}
|
|
184
|
+
const manifest = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
|
185
|
+
return {
|
|
186
|
+
root: cwd,
|
|
187
|
+
workspaces: [
|
|
188
|
+
{
|
|
189
|
+
name: manifest.name ?? '<root>',
|
|
190
|
+
location: cwd,
|
|
191
|
+
relativeLocation: '.',
|
|
192
|
+
version: manifest.version ?? '0.0.0',
|
|
193
|
+
manifest: manifest,
|
|
194
|
+
private: manifest.private === true,
|
|
195
|
+
},
|
|
196
|
+
],
|
|
197
|
+
isMonorepo: false,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
function applyWorkspaceFilter(workspaces, include, exclude) {
|
|
201
|
+
const includePatterns = include
|
|
202
|
+
?.split(',')
|
|
203
|
+
.map((s) => s.trim())
|
|
204
|
+
.filter(Boolean);
|
|
205
|
+
const excludePatterns = exclude
|
|
206
|
+
?.split(',')
|
|
207
|
+
.map((s) => s.trim())
|
|
208
|
+
.filter(Boolean);
|
|
209
|
+
if ((!includePatterns || includePatterns.length === 0) && (!excludePatterns || excludePatterns.length === 0)) {
|
|
210
|
+
return workspaces;
|
|
211
|
+
}
|
|
212
|
+
return filterWorkspaces(workspaces, {
|
|
213
|
+
include: includePatterns,
|
|
214
|
+
exclude: excludePatterns,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
function parseFilterList(raw) {
|
|
218
|
+
if (!raw)
|
|
219
|
+
return [];
|
|
220
|
+
return raw
|
|
221
|
+
.split(',')
|
|
222
|
+
.map((s) => s.trim().toLowerCase())
|
|
223
|
+
.filter(Boolean);
|
|
224
|
+
}
|
|
225
|
+
// ─── Per-workspace dep collection ──────────────────────────────────────
|
|
124
226
|
const DEP_FIELDS = ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies'];
|
|
125
|
-
function
|
|
227
|
+
function collectAllDeclarations(workspaces, depFilters) {
|
|
228
|
+
const out = [];
|
|
229
|
+
for (const w of workspaces) {
|
|
230
|
+
for (const decl of collectFromWorkspace(w, depFilters)) {
|
|
231
|
+
out.push(decl);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return out;
|
|
235
|
+
}
|
|
236
|
+
function collectFromWorkspace(workspace, depFilters) {
|
|
126
237
|
const out = [];
|
|
238
|
+
const pkg = workspace.manifest;
|
|
127
239
|
for (const field of DEP_FIELDS) {
|
|
128
240
|
const map = pkg[field];
|
|
129
241
|
if (!map || typeof map !== 'object')
|
|
@@ -131,23 +243,24 @@ function collectExternalDeps(pkg, filters) {
|
|
|
131
243
|
for (const [name, raw] of Object.entries(map)) {
|
|
132
244
|
if (typeof raw !== 'string')
|
|
133
245
|
continue;
|
|
134
|
-
if (
|
|
246
|
+
if (depFilters.length && !depFilters.some((f) => name.toLowerCase().includes(f)))
|
|
135
247
|
continue;
|
|
136
|
-
}
|
|
137
248
|
// Skip workspace-protocol + file: + link: + git: + http(s): specs.
|
|
138
249
|
if (raw.startsWith('workspace:') ||
|
|
139
250
|
raw.startsWith('file:') ||
|
|
140
251
|
raw.startsWith('link:') ||
|
|
141
252
|
raw.startsWith('git+') ||
|
|
142
253
|
raw.startsWith('git:') ||
|
|
143
|
-
raw.startsWith('http') ||
|
|
144
|
-
raw.startsWith('
|
|
145
|
-
raw
|
|
146
|
-
raw
|
|
254
|
+
raw.startsWith('http:') ||
|
|
255
|
+
raw.startsWith('https:') ||
|
|
256
|
+
raw.startsWith('npm:') ||
|
|
257
|
+
raw.startsWith('portal:')) {
|
|
147
258
|
continue;
|
|
148
259
|
}
|
|
149
260
|
const { prefix, version } = splitRange(raw);
|
|
150
261
|
out.push({
|
|
262
|
+
workspace: workspace.name,
|
|
263
|
+
workspaceLocation: workspace.location,
|
|
151
264
|
name,
|
|
152
265
|
field,
|
|
153
266
|
currentRange: raw,
|
|
@@ -158,60 +271,117 @@ function collectExternalDeps(pkg, filters) {
|
|
|
158
271
|
}
|
|
159
272
|
return out;
|
|
160
273
|
}
|
|
161
|
-
/**
|
|
162
|
-
* Split `^1.2.3` → { prefix: "^", version: "1.2.3" }. Honors `~`, `>=`,
|
|
163
|
-
* `>`, `<=`, `<`, `=`. Defaults to "" prefix when the range is just a
|
|
164
|
-
* literal version.
|
|
165
|
-
*/
|
|
166
274
|
function splitRange(range) {
|
|
167
275
|
const m = range.match(/^(\^|~|>=|<=|>|<|=)?\s*([0-9].*)$/);
|
|
168
276
|
if (!m)
|
|
169
277
|
return { prefix: '', version: null };
|
|
170
278
|
const prefix = m[1] ?? '';
|
|
171
|
-
const version = m[2]?.split(/\s|[|&,]/)[0] ?? null;
|
|
279
|
+
const version = m[2]?.split(/\s|[|&,]/)[0] ?? null;
|
|
172
280
|
const parsed = version ? parse(version) : null;
|
|
173
281
|
return { prefix, version: parsed?.version ?? null };
|
|
174
282
|
}
|
|
175
|
-
|
|
283
|
+
// ─── Modes: --check / --align ──────────────────────────────────────────
|
|
284
|
+
function runCheckMode(groups) {
|
|
285
|
+
const inconsistencies = findInconsistencies(groups);
|
|
286
|
+
if (inconsistencies.length === 0) {
|
|
287
|
+
console.log(`gjsify upgrade --check: OK. ${groups.length} dep(s) consistently declared across workspaces.`);
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
console.error(`gjsify upgrade --check: FAIL. ${inconsistencies.length} dep(s) declared at inconsistent ranges:\n`);
|
|
291
|
+
for (const g of inconsistencies) {
|
|
292
|
+
const byRange = new Map();
|
|
293
|
+
for (const occ of g.occurrences) {
|
|
294
|
+
const list = byRange.get(occ.currentRange) ?? [];
|
|
295
|
+
list.push(occ.workspace);
|
|
296
|
+
byRange.set(occ.currentRange, list);
|
|
297
|
+
}
|
|
298
|
+
console.error(` ${ANSI.bold}${g.name}${ANSI.reset}`);
|
|
299
|
+
for (const [range, holders] of byRange.entries()) {
|
|
300
|
+
console.error(` ${range.padEnd(16)} — ${holders.join(', ')}`);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
console.error(`\nFix: run \`gjsify upgrade --align\` (offline; aligns each dep to its highest declared range).`);
|
|
304
|
+
process.exit(1);
|
|
305
|
+
}
|
|
306
|
+
function runAlignMode(groups, args) {
|
|
307
|
+
const inconsistencies = findInconsistencies(groups);
|
|
308
|
+
if (inconsistencies.length === 0) {
|
|
309
|
+
console.log(`gjsify upgrade --align: nothing to do. ${groups.length} dep(s) already consistent.`);
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
// For each inconsistency, the alignment target is the highest declared version
|
|
313
|
+
// — preserve the prefix of the dominant occurrence so the range shape doesn't
|
|
314
|
+
// mutate (caret stays caret, tilde stays tilde).
|
|
315
|
+
const updates = [];
|
|
316
|
+
for (const g of inconsistencies) {
|
|
317
|
+
if (!g.highestVersion)
|
|
318
|
+
continue;
|
|
319
|
+
// Pick a prefix: use the prefix of the dominant range; if dominant has
|
|
320
|
+
// no prefix, default to "^" (the npm-cli default).
|
|
321
|
+
const dominantOcc = g.occurrences.find((o) => o.currentRange === g.dominantRange);
|
|
322
|
+
const prefix = dominantOcc?.prefix || '^';
|
|
323
|
+
updates.push({
|
|
324
|
+
...g,
|
|
325
|
+
latestVersion: g.highestVersion,
|
|
326
|
+
diff: 'none',
|
|
327
|
+
occurrences: g.occurrences.map((o) => ({ ...o, prefix })),
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
if (updates.length === 0) {
|
|
331
|
+
console.log('gjsify upgrade --align: inconsistencies present but no parseable target version. No-op.');
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
console.log(`gjsify upgrade --align: aligning ${updates.length} inconsistent dep(s) to their highest declared version:\n`);
|
|
335
|
+
for (const u of updates) {
|
|
336
|
+
const newPrefix = u.occurrences[0]?.prefix ?? '^';
|
|
337
|
+
console.log(` ${u.name.padEnd(28)} ranges: ${[...u.declaredRanges].join(', ')} → ${newPrefix}${u.latestVersion}`);
|
|
338
|
+
}
|
|
339
|
+
if (args.dryRun) {
|
|
340
|
+
console.log('\n[gjsify upgrade --align] --dry-run: no files changed.');
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
const files = applyToFiles(updates);
|
|
344
|
+
console.log(`\n✏️ updated ${updates.length} dep(s) across ${files} package.json file(s).`);
|
|
345
|
+
}
|
|
346
|
+
// ─── Registry resolution (group-aware) ─────────────────────────────────
|
|
347
|
+
async function resolveCandidateGroups(groups, npmrc, verbose, mode) {
|
|
176
348
|
const results = [];
|
|
177
|
-
// Parallel fetch with a small concurrency cap.
|
|
178
349
|
const cap = 8;
|
|
179
350
|
let cursor = 0;
|
|
180
351
|
async function worker() {
|
|
181
352
|
for (;;) {
|
|
182
353
|
const i = cursor++;
|
|
183
|
-
if (i >=
|
|
354
|
+
if (i >= groups.length)
|
|
184
355
|
return;
|
|
185
|
-
const
|
|
356
|
+
const g = groups[i];
|
|
186
357
|
try {
|
|
187
|
-
const packument = await fetchPackument(
|
|
358
|
+
const packument = await fetchPackument(g.name, { npmrc });
|
|
188
359
|
const latest = packument['dist-tags']?.latest;
|
|
189
360
|
if (!latest) {
|
|
190
361
|
if (verbose)
|
|
191
|
-
console.warn(` ${
|
|
362
|
+
console.warn(` ${g.name}: no dist-tags.latest, skipping`);
|
|
192
363
|
continue;
|
|
193
364
|
}
|
|
194
|
-
|
|
365
|
+
// Diff is computed against the HIGHEST currently-declared version
|
|
366
|
+
// (so a workspace stuck on an old range still shows the same target).
|
|
367
|
+
const current = g.highestVersion;
|
|
368
|
+
if (!current) {
|
|
195
369
|
if (verbose)
|
|
196
|
-
console.warn(` ${
|
|
370
|
+
console.warn(` ${g.name}: unable to parse any current range`);
|
|
197
371
|
continue;
|
|
198
372
|
}
|
|
199
|
-
const diff = classifyDiff(
|
|
373
|
+
const diff = classifyDiff(current, latest);
|
|
200
374
|
if (diff === 'none')
|
|
201
375
|
continue;
|
|
202
376
|
if (mode === 'minor' && diff === 'major')
|
|
203
377
|
continue;
|
|
204
378
|
if (mode === 'patch' && (diff === 'major' || diff === 'minor'))
|
|
205
379
|
continue;
|
|
206
|
-
results.push({
|
|
207
|
-
...entry,
|
|
208
|
-
latestVersion: latest,
|
|
209
|
-
diff,
|
|
210
|
-
});
|
|
380
|
+
results.push({ ...g, latestVersion: latest, diff });
|
|
211
381
|
}
|
|
212
382
|
catch (err) {
|
|
213
383
|
if (verbose)
|
|
214
|
-
console.warn(` ${
|
|
384
|
+
console.warn(` ${g.name}: fetch failed (${err.message})`);
|
|
215
385
|
}
|
|
216
386
|
}
|
|
217
387
|
}
|
|
@@ -243,6 +413,7 @@ const ANSI = {
|
|
|
243
413
|
yellow: '\x1b[33m',
|
|
244
414
|
green: '\x1b[32m',
|
|
245
415
|
cyan: '\x1b[36m',
|
|
416
|
+
magenta: '\x1b[35m',
|
|
246
417
|
};
|
|
247
418
|
function colorForDiff(diff) {
|
|
248
419
|
switch (diff) {
|
|
@@ -260,30 +431,42 @@ function colorForDiff(diff) {
|
|
|
260
431
|
}
|
|
261
432
|
function printTable(candidates) {
|
|
262
433
|
const nameW = Math.max(...candidates.map((c) => c.name.length), 4);
|
|
263
|
-
const
|
|
434
|
+
const fanW = Math.max(...candidates.map((c) => `${c.occurrences.length}`.length + 2), 3);
|
|
435
|
+
const curW = Math.max(...candidates.map((c) => declaredCellWidth(c)), 7);
|
|
264
436
|
const newW = Math.max(...candidates.map((c) => c.latestVersion.length), 6);
|
|
265
437
|
const idxW = String(candidates.length).length + 2;
|
|
266
|
-
const head = ' '.repeat(idxW) +
|
|
438
|
+
const head = ' '.repeat(idxW + 2) +
|
|
267
439
|
ANSI.bold +
|
|
268
440
|
'name'.padEnd(nameW) +
|
|
269
441
|
' ' +
|
|
270
|
-
'
|
|
442
|
+
'fan'.padEnd(fanW) +
|
|
443
|
+
' ' +
|
|
444
|
+
'declared'.padEnd(curW) +
|
|
271
445
|
' ' +
|
|
272
446
|
'latest'.padEnd(newW) +
|
|
273
447
|
' ' +
|
|
274
448
|
'kind' +
|
|
275
449
|
ANSI.reset;
|
|
276
450
|
console.log(head);
|
|
277
|
-
console.log(' '.repeat(idxW
|
|
451
|
+
console.log(' '.repeat(idxW + 2) +
|
|
452
|
+
ANSI.dim +
|
|
453
|
+
'─'.repeat(nameW + fanW + curW + newW + 16) +
|
|
454
|
+
ANSI.reset);
|
|
278
455
|
for (let i = 0; i < candidates.length; i++) {
|
|
279
456
|
const c = candidates[i];
|
|
280
457
|
const idx = `${i + 1}.`.padEnd(idxW);
|
|
458
|
+
const badge = c.declaredRanges.size > 1 ? `${ANSI.magenta}⚠ ${ANSI.reset}` : ' ';
|
|
281
459
|
const color = colorForDiff(c.diff);
|
|
282
460
|
console.log(idx +
|
|
461
|
+
badge +
|
|
283
462
|
c.name.padEnd(nameW) +
|
|
284
463
|
' ' +
|
|
285
464
|
ANSI.dim +
|
|
286
|
-
c.
|
|
465
|
+
`${c.occurrences.length}`.padEnd(fanW) +
|
|
466
|
+
ANSI.reset +
|
|
467
|
+
' ' +
|
|
468
|
+
ANSI.dim +
|
|
469
|
+
renderDeclaredCell(c).padEnd(curW) +
|
|
287
470
|
ANSI.reset +
|
|
288
471
|
' ' +
|
|
289
472
|
color +
|
|
@@ -294,6 +477,29 @@ function printTable(candidates) {
|
|
|
294
477
|
c.diff +
|
|
295
478
|
ANSI.reset);
|
|
296
479
|
}
|
|
480
|
+
const inconsistentCount = candidates.filter((c) => c.declaredRanges.size > 1).length;
|
|
481
|
+
if (inconsistentCount > 0) {
|
|
482
|
+
console.log(`\n${ANSI.magenta}⚠${ANSI.reset} ${inconsistentCount} dep(s) declared at inconsistent ranges across workspaces.`);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
function declaredCellWidth(c) {
|
|
486
|
+
return renderDeclaredCell(c).length;
|
|
487
|
+
}
|
|
488
|
+
function renderDeclaredCell(c) {
|
|
489
|
+
if (c.declaredRanges.size === 1)
|
|
490
|
+
return [...c.declaredRanges][0];
|
|
491
|
+
// "^1.0, ^2.0" — truncate at 32 chars
|
|
492
|
+
const joined = [...c.declaredRanges].sort().join(', ');
|
|
493
|
+
if (joined.length <= 32)
|
|
494
|
+
return joined;
|
|
495
|
+
return joined.slice(0, 29) + '…';
|
|
496
|
+
}
|
|
497
|
+
function printChangePlan(selected) {
|
|
498
|
+
for (const u of selected) {
|
|
499
|
+
for (const occ of u.occurrences) {
|
|
500
|
+
console.log(` ${occ.workspace.padEnd(38)} ${u.name.padEnd(28)} ${occ.currentRange.padEnd(12)} → ${occ.prefix}${u.latestVersion}`);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
297
503
|
}
|
|
298
504
|
async function promptSelection(candidates) {
|
|
299
505
|
if (!process.stdin.isTTY) {
|
|
@@ -319,7 +525,7 @@ async function promptSelection(candidates) {
|
|
|
319
525
|
if (!answer)
|
|
320
526
|
return [];
|
|
321
527
|
if (answer.toLowerCase() === 'a' || answer.toLowerCase() === 'all')
|
|
322
|
-
return candidates;
|
|
528
|
+
return [...candidates];
|
|
323
529
|
const picked = new Set();
|
|
324
530
|
for (const token of answer.split(/[\s,]+/).filter(Boolean)) {
|
|
325
531
|
const range = token.match(/^(\d+)-(\d+)$/);
|
|
@@ -339,17 +545,50 @@ async function promptSelection(candidates) {
|
|
|
339
545
|
rl.close();
|
|
340
546
|
}
|
|
341
547
|
}
|
|
342
|
-
// ─── Write-back
|
|
343
|
-
function
|
|
344
|
-
//
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
548
|
+
// ─── Write-back across all affected workspaces ─────────────────────────
|
|
549
|
+
function applyToFiles(updates) {
|
|
550
|
+
// Group by workspace.location so each package.json is read + written once.
|
|
551
|
+
const byLocation = new Map();
|
|
552
|
+
for (const u of updates) {
|
|
553
|
+
for (const occ of u.occurrences) {
|
|
554
|
+
const list = byLocation.get(occ.workspaceLocation) ?? [];
|
|
555
|
+
list.push({ ...u, occurrences: [occ] });
|
|
556
|
+
byLocation.set(occ.workspaceLocation, list);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
let touched = 0;
|
|
560
|
+
for (const [location, groups] of byLocation.entries()) {
|
|
561
|
+
const pkgJsonPath = join(location, 'package.json');
|
|
562
|
+
const raw = readFileSync(pkgJsonPath, 'utf-8');
|
|
563
|
+
const pkg = JSON.parse(raw);
|
|
564
|
+
let changed = false;
|
|
565
|
+
for (const g of groups) {
|
|
566
|
+
const occ = g.occurrences[0];
|
|
567
|
+
const map = pkg[occ.field];
|
|
568
|
+
if (!map)
|
|
569
|
+
continue;
|
|
570
|
+
const next = `${occ.prefix}${g.latestVersion}`;
|
|
571
|
+
if (map[g.name] !== next) {
|
|
572
|
+
map[g.name] = next;
|
|
573
|
+
changed = true;
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
if (changed) {
|
|
577
|
+
const indent = detectIndent(raw);
|
|
578
|
+
writeFileSync(pkgJsonPath, JSON.stringify(pkg, null, indent) + (raw.endsWith('\n') ? '\n' : ''), 'utf-8');
|
|
579
|
+
touched++;
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
return touched;
|
|
583
|
+
}
|
|
584
|
+
function uniqueLocations(updates) {
|
|
585
|
+
const set = new Set();
|
|
586
|
+
for (const u of updates) {
|
|
587
|
+
for (const occ of u.occurrences) {
|
|
588
|
+
set.add(occ.workspaceLocation);
|
|
589
|
+
}
|
|
350
590
|
}
|
|
351
|
-
|
|
352
|
-
writeFileSync(path, JSON.stringify(parsed, null, indent) + (rawText.endsWith('\n') ? '\n' : ''), 'utf-8');
|
|
591
|
+
return [...set];
|
|
353
592
|
}
|
|
354
593
|
function detectIndent(json) {
|
|
355
594
|
const m = json.match(/^\{\n( +)/);
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/** One declaration of an external npm dep inside one workspace's package.json. */
|
|
2
|
+
export interface DepDeclaration {
|
|
3
|
+
/** Workspace package name (e.g. `@gjsify/cli`). */
|
|
4
|
+
workspace: string;
|
|
5
|
+
/** Absolute path to the workspace's `package.json`. */
|
|
6
|
+
workspaceLocation: string;
|
|
7
|
+
/** Dependency name (e.g. `rolldown`). */
|
|
8
|
+
name: string;
|
|
9
|
+
/** Which manifest field this declaration lives in. */
|
|
10
|
+
field: 'dependencies' | 'devDependencies' | 'optionalDependencies' | 'peerDependencies';
|
|
11
|
+
/** The original range string (e.g. `^1.0.4`). */
|
|
12
|
+
currentRange: string;
|
|
13
|
+
/** The parsed version (max-satisfying numeric) inside the range, or `null`. */
|
|
14
|
+
currentVersion: string | null;
|
|
15
|
+
/** Range prefix preserved on write-back (`^`, `~`, `>=`, …; `""` for literal). */
|
|
16
|
+
prefix: string;
|
|
17
|
+
}
|
|
18
|
+
/** Aggregated view of one external dep across all workspaces that declare it. */
|
|
19
|
+
export interface DependencyGroup {
|
|
20
|
+
/** Dependency name. */
|
|
21
|
+
name: string;
|
|
22
|
+
/** Every declaration of this dep across the workspace. */
|
|
23
|
+
occurrences: DepDeclaration[];
|
|
24
|
+
/** Set of all unique declared ranges (size > 1 → inconsistency). */
|
|
25
|
+
declaredRanges: Set<string>;
|
|
26
|
+
/** Range string declared by the most workspaces (the de-facto consensus). */
|
|
27
|
+
dominantRange: string;
|
|
28
|
+
/** When ranges disagree, the highest declared semver version across them. */
|
|
29
|
+
highestVersion: string | null;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Group a flat list of `DepDeclaration`s by dep name. Each group has the full
|
|
33
|
+
* occurrence list plus pre-computed aggregates (consensus range, highest
|
|
34
|
+
* declared version) so callers don't have to re-scan.
|
|
35
|
+
*/
|
|
36
|
+
export declare function groupByDependency(decls: readonly DepDeclaration[]): DependencyGroup[];
|
|
37
|
+
/** `true` when the group's workspaces declare the dep at >1 distinct range. */
|
|
38
|
+
export declare function isInconsistent(group: DependencyGroup): boolean;
|
|
39
|
+
/**
|
|
40
|
+
* Filter to just the inconsistent groups (>1 distinct range across workspaces).
|
|
41
|
+
* Convenience wrapper for `--align` and `--check` modes.
|
|
42
|
+
*/
|
|
43
|
+
export declare function findInconsistencies(groups: readonly DependencyGroup[]): DependencyGroup[];
|