@gjsify/cli 0.4.33 → 0.4.35

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.
@@ -76,6 +76,14 @@ export function renderDesktop(inputs) {
76
76
  const f = inputs.flatpak;
77
77
  const categoriesLine = (f.categories ?? ['Utility']).join(';') + ';';
78
78
  const keywordsLine = f.keywords?.length ? `Keywords=${f.keywords.join(';')};\n` : '';
79
+ // `MimeType=` is sourced from `gjsify.flatpak.provides.mimetypes` —
80
+ // the same field already populates `<mediatype>` entries in the
81
+ // MetaInfo XML, so callers configure both with one knob. Typical
82
+ // entries: `x-scheme-handler/<scheme>` for URL-scheme handlers
83
+ // (Flatpak portal: chats / browsers can launch the app via a
84
+ // custom URL), or `application/<mime>` for file-type handlers.
85
+ const mimetypes = f.provides?.mimetypes ?? [];
86
+ const mimetypesLine = mimetypes.length ? `MimeType=${mimetypes.join(';')};\n` : '';
79
87
  return substitute(loadDesktopTemplate(), {
80
88
  NAME: inputs.name,
81
89
  SUMMARY: f.summary ?? inputs.name,
@@ -83,6 +91,7 @@ export function renderDesktop(inputs) {
83
91
  APP_ID: inputs.appId,
84
92
  CATEGORIES_LINE: categoriesLine,
85
93
  KEYWORDS_LINE: keywordsLine,
94
+ MIMETYPES_LINE: mimetypesLine,
86
95
  });
87
96
  }
88
97
  /** Render the flathub.json policy file. */
@@ -4,6 +4,10 @@ interface UpgradeOptions {
4
4
  minor?: boolean;
5
5
  patch?: boolean;
6
6
  filter?: string;
7
+ workspace?: string;
8
+ excludeWorkspace?: string;
9
+ align?: boolean;
10
+ check?: boolean;
7
11
  dryRun?: boolean;
8
12
  cwd?: string;
9
13
  verbose?: boolean;
@@ -1,26 +1,45 @@
1
1
  // `gjsify upgrade` — drop-in replacement for `yarn upgrade-interactive`
2
- // and `npx npm-check-updates`. Two modes:
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
- // 1. Interactive (default): show outdated packages, prompt user to
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
- // 2. Non-interactive (`--latest` / `--minor` / `--patch` / `--filter`):
9
- // bump matching packages automatically without prompting.
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
- // Workspace-aware: `workspace:^` / `workspace:~` / `workspace:*` ranges
12
- // are skipped those are the gjsify monorepo internal links and `gjsify
13
- // install` resolves them locally. Only external npm specs get checked
14
- // against the registry.
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: 'Check the npm registry for newer versions of declared dependencies and update package.json. Interactive by default; `--latest` / `--minor` / `--patch` switch to non-interactive bulk-update mode.',
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 packages whose name matches this substring (case-insensitive). Repeatable; comma-separated values are split.',
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 pkgJsonPath = join(cwd, 'package.json');
69
- if (!existsSync(pkgJsonPath)) {
70
- throw new Error(`[gjsify upgrade] no package.json at ${pkgJsonPath}`);
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 rawPkg = readFileSync(pkgJsonPath, 'utf-8');
73
- const pkg = JSON.parse(rawPkg);
74
- const filters = args.filter
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 npmrc = await loadNpmrcLight(cwd);
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 ${entries.length} dependencies against ${npmrc.registry}…`);
94
- const candidates = await resolveCandidates(entries, npmrc, args.verbose ?? false, mode);
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; package.json unchanged.');
160
+ console.log('[gjsify upgrade] nothing selected; no files changed.');
113
161
  return;
114
162
  }
115
163
  if (args.dryRun) {
116
- console.log(`[gjsify upgrade] --dry-run: would update ${selected.length} dependencies (no write).`);
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
- writePackageJson(pkgJsonPath, rawPkg, pkg, selected);
120
- console.log(`✏️ updated ${selected.length} dependencies in ${pkgJsonPath}. Run \`gjsify install\` to apply.`);
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
- // ─── Resolution ─────────────────────────────────────────────────────────
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 collectExternalDeps(pkg, filters) {
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 (filters.length && !filters.some((f) => name.toLowerCase().includes(f))) {
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('npm:') || // e.g. `foo: npm:@scope/foo@^1`
145
- raw === '*' ||
146
- raw === 'latest') {
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; // strip range modifiers (`||`, ` - `, etc.)
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
- async function resolveCandidates(entries, npmrc, verbose, mode) {
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 >= entries.length)
354
+ if (i >= groups.length)
184
355
  return;
185
- const entry = entries[i];
356
+ const g = groups[i];
186
357
  try {
187
- const packument = await fetchPackument(entry.name, { npmrc });
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(` ${entry.name}: no dist-tags.latest, skipping`);
362
+ console.warn(` ${g.name}: no dist-tags.latest, skipping`);
192
363
  continue;
193
364
  }
194
- if (!entry.currentVersion) {
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(` ${entry.name}: unable to parse current range "${entry.currentRange}"`);
370
+ console.warn(` ${g.name}: unable to parse any current range`);
197
371
  continue;
198
372
  }
199
- const diff = classifyDiff(entry.currentVersion, latest);
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(` ${entry.name}: fetch failed (${err.message})`);
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 curW = Math.max(...candidates.map((c) => c.currentRange.length), 7);
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
- 'current'.padEnd(curW) +
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) + ANSI.dim + '─'.repeat(nameW + curW + newW + 12) + ANSI.reset);
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.currentRange.padEnd(curW) +
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 writePackageJson(path, rawText, parsed, selected) {
344
- // Mutate the parsed object then re-stringify with the original indent.
345
- for (const c of selected) {
346
- const map = parsed[c.field];
347
- if (!map)
348
- continue;
349
- map[c.name] = c.prefix + c.latestVersion;
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
- const indent = detectIndent(rawText);
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( +)/);
@@ -6,5 +6,5 @@ Icon={{APP_ID}}
6
6
  Terminal=false
7
7
  Type=Application
8
8
  Categories={{CATEGORIES_LINE}}
9
- {{KEYWORDS_LINE}}StartupNotify=true
9
+ {{KEYWORDS_LINE}}{{MIMETYPES_LINE}}StartupNotify=true
10
10
  StartupWMClass={{APP_ID}}
@@ -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[];