ripencli 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +17 -12
  2. package/dist/cli.js +271 -276
  3. package/package.json +11 -11
package/README.md CHANGED
@@ -11,10 +11,12 @@
11
11
  ## Features
12
12
 
13
13
  - **Interactive TUI** — navigate packages with arrow keys, select with space
14
+ - **Clipboard-first** — copies the ready-to-run update command to your clipboard, nothing is executed for you
15
+ - **Publish age** — shows how long ago the latest version was published (yellow if < 1 day, useful for pnpm's `minimumReleaseAge`)
14
16
  - **Version picker** — choose any specific version from the npm registry, not just latest
15
17
  - **Changelog viewer** — see GitHub release notes before you update
16
18
  - **npm, pnpm, yarn & bun** — auto-detects your package manager
17
- - **Global packages** — check and update global installs across all* package managers
19
+ - **Global packages** — check global installs across all* package managers
18
20
  - **Show all packages** — `ripen --all` lists every dependency, not just outdated ones (great for checking changelogs or downgrading)
19
21
  - **Self-update** — notifies you when a new version of ripen is available
20
22
  - **Major bump warnings** — highlights potentially breaking updates
@@ -51,24 +53,27 @@ ripen --help
51
53
 
52
54
  ## Controls
53
55
 
54
- | Key | Action |
55
- | ------- | ------------------------------ |
56
- | `↑ ↓` | Navigate packages |
57
- | `space` | Toggle select |
58
- | `v` | Pick specific version |
59
- | `c` | View changelog / release notes |
60
- | `enter` | Update selected packages |
61
- | `s` | Open settings |
62
- | `esc` | Cancel / go back |
56
+ | Key | Action |
57
+ | ------------ | ----------------------------------- |
58
+ | `↑ ↓` | Navigate packages |
59
+ | `PgUp PgDn` | Scroll a full page |
60
+ | `Tab` | Jump between groups |
61
+ | `← →` | Collapse / expand scope groups |
62
+ | `space` | Toggle select |
63
+ | `v` | Pick specific version |
64
+ | `c` | View changelog / release notes |
65
+ | `enter` | Copy update command & exit |
66
+ | `s` | Open settings |
67
+ | `esc` | Cancel / go back |
63
68
 
64
69
  ## How it works
65
70
 
66
71
  1. Reads your `package.json` and checks each dependency against the npm registry directly
67
- 2. Detects your package manager from the lock file (`bun.lock`, `pnpm-lock.yaml`, `package-lock.json`, or `yarn.lock`) for running updates
72
+ 2. Detects your package manager from the lock file (`bun.lock`, `pnpm-lock.yaml`, `package-lock.json`, or `yarn.lock`)
68
73
  3. Shows outdated packages in a colorful interactive list (use `--all` to show every package, including up-to-date ones)
69
74
  4. Press `v` on any package to pick a specific version from the npm registry
70
75
  5. Press `c` to see GitHub release notes between your current and target version
71
- 6. Select the ones you want and press enter — ripen runs the update commands for you
76
+ 6. Select the ones you want and press `enter` — ripen copies the exact update command to your clipboard and exits
72
77
 
73
78
  ## Settings
74
79
 
package/dist/cli.js CHANGED
@@ -1,14 +1,14 @@
1
1
  #!/usr/bin/env node
2
- import { Box, Text, render, useApp, useInput, useStdout } from "ink";
2
+ import { Box, Text, render, useApp, useInput, useWindowSize } from "ink";
3
3
  import { createRequire } from "module";
4
4
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
5
5
  import { join } from "path";
6
6
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
7
7
  import { execa } from "execa";
8
8
  import { homedir } from "os";
9
+ import { exec, execSync } from "child_process";
9
10
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
10
11
  import { ScrollView } from "ink-scroll-view";
11
- import { exec } from "child_process";
12
12
  //#region src/detector.ts
13
13
  function detectPackageManager(cwd) {
14
14
  if (existsSync(join(cwd, "bun.lock"))) return "bun";
@@ -98,7 +98,7 @@ function parseBaseVersion(range) {
98
98
  const prefixMatch = v.match(/^([~^>=<]+)/);
99
99
  const prefix = prefixMatch ? prefixMatch[1] : "";
100
100
  v = v.replace(/^[~^>=<]+/, "").trim();
101
- if (/^\d+\.\d+\.\d+/.test(v)) return {
101
+ if (/^\d+(\.\d+(\.\d+)?)?/.test(v)) return {
102
102
  version: v,
103
103
  prefix
104
104
  };
@@ -141,8 +141,10 @@ async function fetchChangelog(packageName, fromVersion, toVersion) {
141
141
  const ghRes = await fetch(`https://api.github.com/repos/${repo}/releases?per_page=30`, { headers: { Accept: "application/vnd.github+json" } });
142
142
  if (!ghRes.ok) return [];
143
143
  const releases = await ghRes.json();
144
+ const toMajor = parseVersion(toVersion)[0];
144
145
  const filtered = releases.filter((r) => {
145
146
  if (r.draft || r.prerelease) return false;
147
+ if (fromVersion === "") return parseVersion(r.tag_name)[0] === toMajor && compareVersions(r.tag_name, toVersion) <= 0;
146
148
  return compareVersions(r.tag_name, fromVersion) > 0 && compareVersions(r.tag_name, toVersion) <= 0;
147
149
  }).map((r) => ({
148
150
  version: r.tag_name,
@@ -157,7 +159,7 @@ async function fetchChangelog(packageName, fromVersion, toVersion) {
157
159
  url: latest.html_url
158
160
  }];
159
161
  }
160
- return filtered;
162
+ return filtered.sort((a, b) => compareVersions(a.version, b.version));
161
163
  } catch {
162
164
  return [];
163
165
  }
@@ -211,14 +213,20 @@ function readPackageJsonDeps(cwd) {
211
213
  }
212
214
  return entries;
213
215
  }
214
- async function fetchLatestWithRetry(packageName) {
216
+ async function fetchRegistryInfoWithRetry(packageName) {
215
217
  for (let attempt = 0; attempt < 3; attempt++) try {
216
218
  const controller = new AbortController();
217
219
  const timeout = setTimeout(() => controller.abort(), 15e3);
218
- const res = await fetch(`https://registry.npmjs.org/${encodeURIComponent(packageName)}/latest`, { signal: controller.signal });
220
+ const res = await fetch(`https://registry.npmjs.org/${encodeURIComponent(packageName)}`, { signal: controller.signal });
219
221
  clearTimeout(timeout);
220
222
  if (!res.ok) return null;
221
- return (await res.json()).version ?? null;
223
+ const data = await res.json();
224
+ const version = data["dist-tags"]?.latest ?? null;
225
+ if (!version) return null;
226
+ return {
227
+ version,
228
+ publishedAt: data.time?.[version] ?? ""
229
+ };
222
230
  } catch {
223
231
  if (attempt === 2) return null;
224
232
  }
@@ -232,7 +240,7 @@ async function fetchAllLatest(names, concurrency, onLine) {
232
240
  while (index < names.length) {
233
241
  const name = names[index++];
234
242
  onLine?.(`Checking ${name} (${completed + 1}/${names.length})...`);
235
- results.set(name, await fetchLatestWithRetry(name));
243
+ results.set(name, await fetchRegistryInfoWithRetry(name));
236
244
  completed++;
237
245
  }
238
246
  }
@@ -261,8 +269,9 @@ async function getOutdatedPackages(manager, cwd, global = false, onLine, showAll
261
269
  };
262
270
  const packages = [];
263
271
  for (const dep of deps) {
264
- const latest = latestVersions.get(dep.name);
265
- if (!latest) continue;
272
+ const info = latestVersions.get(dep.name);
273
+ if (!info) continue;
274
+ const { version: latest, publishedAt } = info;
266
275
  if (!showAll && !isNewerVersion(dep.current, latest)) continue;
267
276
  packages.push({
268
277
  name: dep.name,
@@ -273,7 +282,8 @@ async function getOutdatedPackages(manager, cwd, global = false, onLine, showAll
273
282
  type: dep.type,
274
283
  selected: false,
275
284
  targetVersion: latest,
276
- rangePrefix: dep.prefix
285
+ rangePrefix: dep.prefix,
286
+ latestPublishedAt: publishedAt || void 0
277
287
  });
278
288
  }
279
289
  return {
@@ -458,22 +468,28 @@ function parseYarnOutdated(raw, global) {
458
468
  }
459
469
  //#endregion
460
470
  //#region src/executor.ts
461
- async function updatePackages(manager, packages, cwd, global = false, onLine) {
462
- const results = [];
471
+ function buildUpdateCommands(manager, packages, global = false) {
472
+ const commands = [];
463
473
  const deps = packages.filter((p) => !global && p.type === "dependencies");
464
474
  const devDeps = packages.filter((p) => !global && p.type === "devDependencies");
465
- const globalPkgs = packages.filter((p) => global);
466
- const batches = [];
467
- if (deps.length > 0) batches.push({
468
- mgr: manager,
469
- pkgs: deps,
470
- flags: []
471
- });
472
- if (devDeps.length > 0) batches.push({
473
- mgr: manager,
474
- pkgs: devDeps,
475
- flags: [manager === "bun" ? "-d" : "-D"]
476
- });
475
+ const globalPkgs = packages.filter((p) => global || p.type === "global");
476
+ const makeCmd = (mgr, pkgs, flags) => {
477
+ const pkgArgs = pkgs.map((pkg) => {
478
+ const version = pkg.targetVersion ?? pkg.latest;
479
+ return `${pkg.name}@${version}`;
480
+ });
481
+ return `${mgr} ${(mgr === "yarn" && global ? [
482
+ "global",
483
+ "add",
484
+ ...pkgArgs
485
+ ] : [
486
+ "add",
487
+ ...flags,
488
+ ...pkgArgs
489
+ ]).join(" ")}`;
490
+ };
491
+ if (deps.length > 0) commands.push(makeCmd(manager, deps, []));
492
+ if (devDeps.length > 0) commands.push(makeCmd(manager, devDeps, [manager === "bun" ? "-d" : "-D"]));
477
493
  if (globalPkgs.length > 0) {
478
494
  const byManager = /* @__PURE__ */ new Map();
479
495
  for (const pkg of globalPkgs) {
@@ -483,72 +499,10 @@ async function updatePackages(manager, packages, cwd, global = false, onLine) {
483
499
  }
484
500
  for (const [mgr, pkgs] of byManager) {
485
501
  const globalFlags = mgr === "yarn" ? [] : mgr === "bun" ? ["-g"] : ["--global"];
486
- batches.push({
487
- mgr,
488
- pkgs,
489
- flags: globalFlags
490
- });
502
+ commands.push(makeCmd(mgr, pkgs, globalFlags));
491
503
  }
492
504
  }
493
- for (const batch of batches) {
494
- const pkgArgs = batch.pkgs.map((pkg) => {
495
- const version = pkg.targetVersion ?? pkg.latest;
496
- const prefix = pkg.rangePrefix ?? "";
497
- return `${pkg.name}@${prefix}${version}`;
498
- });
499
- const args = batch.mgr === "yarn" && global ? [
500
- "global",
501
- "add",
502
- ...pkgArgs
503
- ] : [
504
- "add",
505
- ...batch.flags,
506
- ...pkgArgs
507
- ];
508
- onLine?.(`$ ${batch.mgr} ${args.join(" ")}`);
509
- try {
510
- const proc = execa(batch.mgr, args, {
511
- cwd,
512
- reject: false
513
- });
514
- if (onLine) {
515
- const forward = (chunk) => {
516
- const lines = chunk.toString().split("\n");
517
- for (const line of lines) {
518
- const trimmed = line.trim();
519
- if (trimmed) onLine(trimmed);
520
- }
521
- };
522
- proc.stderr?.on("data", forward);
523
- proc.stdout?.on("data", forward);
524
- }
525
- const result = await proc;
526
- if (result.exitCode !== 0) {
527
- const errMsg = result.stderr?.trim() || `exited with code ${result.exitCode}`;
528
- for (const pkg of batch.pkgs) results.push({
529
- name: pkg.name,
530
- fromVersion: pkg.current,
531
- version: pkg.targetVersion ?? pkg.latest,
532
- success: false,
533
- error: errMsg
534
- });
535
- } else for (const pkg of batch.pkgs) results.push({
536
- name: pkg.name,
537
- fromVersion: pkg.current,
538
- version: pkg.targetVersion ?? pkg.latest,
539
- success: true
540
- });
541
- } catch (err) {
542
- for (const pkg of batch.pkgs) results.push({
543
- name: pkg.name,
544
- fromVersion: pkg.current,
545
- version: pkg.targetVersion ?? pkg.latest,
546
- success: false,
547
- error: err.message ?? "Unknown error"
548
- });
549
- }
550
- }
551
- return results;
505
+ return commands;
552
506
  }
553
507
  //#endregion
554
508
  //#region src/config.ts
@@ -597,6 +551,36 @@ function incrementFrequency(packageNames) {
597
551
  } catch {}
598
552
  }
599
553
  //#endregion
554
+ //#region src/lib/utils.ts
555
+ /** Open a URL in the user's default browser (cross-platform). */
556
+ function openInBrowser(url) {
557
+ exec(process.platform === "win32" ? `start "" "${url}"` : process.platform === "darwin" ? `open "${url}"` : `xdg-open "${url}"`);
558
+ }
559
+ /** Copy text to the system clipboard (best-effort, silently fails if unavailable). */
560
+ function copyToClipboard(text) {
561
+ try {
562
+ if (process.platform === "win32") execSync("clip", { input: text });
563
+ else if (process.platform === "darwin") execSync("pbcopy", { input: text });
564
+ else try {
565
+ execSync("xclip -selection clipboard", { input: text });
566
+ } catch {
567
+ execSync("xsel --clipboard --input", { input: text });
568
+ }
569
+ } catch {}
570
+ }
571
+ /** Convert an ISO date string to a human-readable relative age like "21h", "3d", "1mo", "2y". */
572
+ function formatAge(dateStr) {
573
+ if (!dateStr) return "";
574
+ const ms = Date.now() - new Date(dateStr).getTime();
575
+ if (ms < 0) return "";
576
+ const min = 6e4, hour = 36e5, day = 864e5;
577
+ if (ms < hour) return `${Math.floor(ms / min)}m`;
578
+ if (ms < day) return `${Math.floor(ms / hour)}h`;
579
+ if (ms < 30 * day) return `${Math.floor(ms / day)}d`;
580
+ if (ms < 365 * day) return `${Math.floor(ms / (30 * day))}mo`;
581
+ return `${Math.floor(ms / (365 * day))}y`;
582
+ }
583
+ //#endregion
600
584
  //#region src/ui/package-list/types.ts
601
585
  const TYPE_COLORS = {
602
586
  dependencies: "cyan",
@@ -880,8 +864,7 @@ function PackageList({ packages, onToggle, onToggleMany, onSelectVersion, onView
880
864
  const effectiveCollapsed = !initialized && allScopeKeys.size > 0 ? allScopeKeys : collapsedScopes;
881
865
  const visibleRows = useMemo(() => filterCollapsed(allRows, effectiveCollapsed), [allRows, effectiveCollapsed]);
882
866
  const groups = useMemo(() => buildGroups(visibleRows), [visibleRows]);
883
- const { stdout } = useStdout();
884
- const terminalRows = stdout?.rows ?? 24;
867
+ const { rows: terminalRows } = useWindowSize();
885
868
  const maxVisible = useMemo(() => computeMaxPerGroup(terminalRows, groups.length), [terminalRows, groups.length]);
886
869
  const scrollOffsetsRef = useRef({});
887
870
  for (const group of groups) {
@@ -1018,7 +1001,7 @@ function PackageList({ packages, onToggle, onToggleMany, onSelectVersion, onView
1018
1001
  color: "white",
1019
1002
  children: "enter"
1020
1003
  }),
1021
- " update"
1004
+ " copy & exit"
1022
1005
  ]
1023
1006
  })
1024
1007
  })]
@@ -1062,7 +1045,7 @@ function PackageList({ packages, onToggle, onToggleMany, onSelectVersion, onView
1062
1045
  })
1063
1046
  ] }), selectedCount > 0 && /* @__PURE__ */ jsx(Text, {
1064
1047
  color: "greenBright",
1065
- children: " Press enter to update →"
1048
+ children: " Press enter to copy command →"
1066
1049
  })]
1067
1050
  })
1068
1051
  })
@@ -1154,6 +1137,13 @@ function PackageGroupBox({ group, focusedIndex, collapsedScopes, scrollOffset, m
1154
1137
  color: "gray",
1155
1138
  children: "latest"
1156
1139
  })
1140
+ }),
1141
+ /* @__PURE__ */ jsx(Box, {
1142
+ width: 5,
1143
+ children: /* @__PURE__ */ jsx(Text, {
1144
+ color: "gray",
1145
+ children: "age"
1146
+ })
1157
1147
  })
1158
1148
  ]
1159
1149
  }),
@@ -1244,6 +1234,16 @@ function PackageGroupBox({ group, focusedIndex, collapsedScopes, scrollOffset, m
1244
1234
  children: pkg.latest
1245
1235
  })
1246
1236
  }),
1237
+ /* @__PURE__ */ jsx(Box, {
1238
+ width: 5,
1239
+ children: pkg.latestPublishedAt ? /* @__PURE__ */ jsx(Text, {
1240
+ color: Date.now() - new Date(pkg.latestPublishedAt).getTime() < 864e5 ? "yellow" : "gray",
1241
+ children: formatAge(pkg.latestPublishedAt)
1242
+ }) : /* @__PURE__ */ jsx(Text, {
1243
+ color: "gray",
1244
+ children: " "
1245
+ })
1246
+ }),
1247
1247
  /* @__PURE__ */ jsx(Box, {
1248
1248
  width: 9,
1249
1249
  children: isMajorBump ? /* @__PURE__ */ jsx(Text, {
@@ -1269,7 +1269,8 @@ function VersionPicker({ pkg, onSelect, onCancel }) {
1269
1269
  const [loading, setLoading] = useState(true);
1270
1270
  const [cursor, setCursor] = useState(0);
1271
1271
  const [scroll, setScroll] = useState(0);
1272
- const PAGE = 12;
1272
+ const { rows } = useWindowSize();
1273
+ const PAGE = Math.max(1, rows - 11);
1273
1274
  useEffect(() => {
1274
1275
  fetchVersions(pkg.name).then((v) => {
1275
1276
  setVersions(v);
@@ -1338,13 +1339,17 @@ function VersionPicker({ pkg, onSelect, onCancel }) {
1338
1339
  })
1339
1340
  ]
1340
1341
  }),
1341
- loading ? /* @__PURE__ */ jsx(Text, {
1342
- color: "gray",
1343
- children: " fetching versions…"
1344
- }) : versions.length === 0 ? /* @__PURE__ */ jsx(Text, {
1345
- color: "red",
1346
- children: " Could not fetch versions."
1347
- }) : /* @__PURE__ */ jsxs(Fragment, { children: [visible.map((v, i) => {
1342
+ Array.from({ length: PAGE }, (_, i) => {
1343
+ if (loading) return /* @__PURE__ */ jsx(Text, {
1344
+ color: "gray",
1345
+ children: i === 0 ? " fetching versions…" : " "
1346
+ }, `_${i}`);
1347
+ if (versions.length === 0) return /* @__PURE__ */ jsx(Text, {
1348
+ color: i === 0 ? "red" : "gray",
1349
+ children: i === 0 ? " Could not fetch versions." : " "
1350
+ }, `_${i}`);
1351
+ const v = visible[i];
1352
+ if (!v) return /* @__PURE__ */ jsx(Text, { children: " " }, `_${i}`);
1348
1353
  const isFocused = scroll + i === cursor;
1349
1354
  const isCurrent = v.version === pkg.current;
1350
1355
  const isLatest = v.tag === "latest";
@@ -1364,10 +1369,10 @@ function VersionPicker({ pkg, onSelect, onCancel }) {
1364
1369
  })
1365
1370
  }),
1366
1371
  /* @__PURE__ */ jsx(Box, {
1367
- width: 10,
1372
+ width: 6,
1368
1373
  children: /* @__PURE__ */ jsx(Text, {
1369
- color: "gray",
1370
- children: v.date
1374
+ color: v.date && Date.now() - new Date(v.date).getTime() < 864e5 ? "yellow" : "gray",
1375
+ children: formatAge(v.date)
1371
1376
  })
1372
1377
  }),
1373
1378
  isLatest && /* @__PURE__ */ jsx(Text, {
@@ -1380,18 +1385,11 @@ function VersionPicker({ pkg, onSelect, onCancel }) {
1380
1385
  })
1381
1386
  ]
1382
1387
  }, v.version);
1383
- }), versions.length > PAGE && /* @__PURE__ */ jsxs(Text, {
1388
+ }),
1389
+ /* @__PURE__ */ jsx(Text, {
1384
1390
  color: "gray",
1385
- children: [
1386
- " ",
1387
- "showing ",
1388
- scroll + 1,
1389
- "–",
1390
- Math.min(scroll + PAGE, versions.length),
1391
- " of ",
1392
- versions.length
1393
- ]
1394
- })] }),
1391
+ children: !loading && versions.length > PAGE ? ` showing ${scroll + 1}–${Math.min(scroll + PAGE, versions.length)} of ${versions.length}` : " "
1392
+ }),
1395
1393
  /* @__PURE__ */ jsx(Box, {
1396
1394
  marginTop: 1,
1397
1395
  children: /* @__PURE__ */ jsx(Text, {
@@ -1425,16 +1423,14 @@ function VersionPicker({ pkg, onSelect, onCancel }) {
1425
1423
  });
1426
1424
  }
1427
1425
  //#endregion
1428
- //#region src/lib/utils.ts
1429
- /** Open a URL in the user's default browser (cross-platform). */
1430
- function openInBrowser(url) {
1431
- exec(process.platform === "win32" ? `start "" "${url}"` : process.platform === "darwin" ? `open "${url}"` : `xdg-open "${url}"`);
1432
- }
1433
- //#endregion
1434
1426
  //#region src/ui/MarkdownLine.tsx
1435
- function parseInline(raw) {
1427
+ /** Wrap text in an OSC 8 hyperlink so modern terminals make it clickable. */
1428
+ function osc8(text, url) {
1429
+ return `\x1b]8;;${url}\x07${text}\x1b]8;;\x07`;
1430
+ }
1431
+ function parseInline(raw, repoUrl) {
1436
1432
  const segments = [];
1437
- const re = /(\*\*(.+?)\*\*|`([^`]+)`|\[([^\]]+)\]\(([^)]+)\))/g;
1433
+ const re = /(\*\*(.+?)\*\*|\*([^*\n]+)\*|~~([^~\n]+)~~|`([^`]+)`|\[([^\]]*)\]\(([^)]+)\)|<(?:strong|b)>(.+?)<\/(?:strong|b)>|<(?:em|i)>(.+?)<\/(?:em|i)>|<(?:del|s)>(.+?)<\/(?:del|s)>|<(?:code|samp|kbd)>(.+?)<\/(?:code|samp|kbd)>|(?<![/\w&#])#(\d+)|(?<!\w)@([a-zA-Z0-9][a-zA-Z0-9-]*)|(https?:\/\/[^\s)>\]]+))/g;
1438
1434
  let last = 0;
1439
1435
  let m;
1440
1436
  while ((m = re.exec(raw)) !== null) {
@@ -1445,30 +1441,123 @@ function parseInline(raw) {
1445
1441
  });
1446
1442
  else if (m[3] !== void 0) segments.push({
1447
1443
  text: m[3],
1444
+ italic: true
1445
+ });
1446
+ else if (m[4] !== void 0) segments.push({
1447
+ text: m[4],
1448
+ strikethrough: true
1449
+ });
1450
+ else if (m[5] !== void 0) segments.push({
1451
+ text: m[5],
1448
1452
  code: true
1449
1453
  });
1450
- else if (m[4] !== void 0) {
1451
- segments.push({ text: m[4] });
1454
+ else if (m[6] !== void 0) {
1455
+ const linkText = m[6].trim() || m[7];
1452
1456
  segments.push({
1453
- text: ` (${m[5]})`,
1454
- dim: true
1457
+ text: linkText,
1458
+ link: m[7]
1455
1459
  });
1456
- }
1460
+ } else if (m[8] !== void 0) segments.push({
1461
+ text: m[8],
1462
+ bold: true
1463
+ });
1464
+ else if (m[9] !== void 0) segments.push({
1465
+ text: m[9],
1466
+ italic: true
1467
+ });
1468
+ else if (m[10] !== void 0) segments.push({
1469
+ text: m[10],
1470
+ strikethrough: true
1471
+ });
1472
+ else if (m[11] !== void 0) segments.push({
1473
+ text: m[11],
1474
+ code: true
1475
+ });
1476
+ else if (m[12] !== void 0) {
1477
+ const num = m[12];
1478
+ const url = repoUrl ? `${repoUrl}/issues/${num}` : void 0;
1479
+ segments.push(url ? {
1480
+ text: `#${num}`,
1481
+ link: url
1482
+ } : {
1483
+ text: `#${num}`,
1484
+ code: true
1485
+ });
1486
+ } else if (m[13] !== void 0) {
1487
+ const user = m[13];
1488
+ segments.push({
1489
+ text: `@${user}`,
1490
+ link: `https://github.com/${user}`
1491
+ });
1492
+ } else if (m[14] !== void 0) segments.push({
1493
+ text: m[14],
1494
+ link: m[14]
1495
+ });
1457
1496
  last = m.index + m[0].length;
1458
1497
  }
1459
1498
  if (last < raw.length) segments.push({ text: raw.slice(last) });
1460
1499
  return segments;
1461
1500
  }
1462
- function MarkdownLine({ line, baseColor = "white", baseDim = false }) {
1463
- const raw = line;
1464
- const headingMatch = raw.match(/^(#{1,3})\s+(.*)/);
1501
+ function Segment({ s, baseColor }) {
1502
+ if (s.link) return /* @__PURE__ */ jsx(Text, {
1503
+ color: "blueBright",
1504
+ underline: true,
1505
+ children: osc8(s.text, s.link)
1506
+ });
1507
+ if (s.code) return /* @__PURE__ */ jsx(Text, {
1508
+ color: "cyan",
1509
+ children: s.text
1510
+ });
1511
+ if (s.bold) return /* @__PURE__ */ jsx(Text, {
1512
+ bold: true,
1513
+ color: baseColor,
1514
+ children: s.text
1515
+ });
1516
+ if (s.italic) return /* @__PURE__ */ jsx(Text, {
1517
+ italic: true,
1518
+ color: baseColor,
1519
+ children: s.text
1520
+ });
1521
+ if (s.strikethrough) return /* @__PURE__ */ jsx(Text, {
1522
+ strikethrough: true,
1523
+ color: "gray",
1524
+ children: s.text
1525
+ });
1526
+ return /* @__PURE__ */ jsx(Text, {
1527
+ color: baseColor,
1528
+ children: s.text
1529
+ });
1530
+ }
1531
+ const HTML_ENTITIES = {
1532
+ "&nbsp;": " ",
1533
+ "&amp;": "&",
1534
+ "&lt;": "<",
1535
+ "&gt;": ">",
1536
+ "&quot;": "\"",
1537
+ "&apos;": "'",
1538
+ "&mdash;": "—",
1539
+ "&ndash;": "–",
1540
+ "&hellip;": "…",
1541
+ "&laquo;": "«",
1542
+ "&raquo;": "»"
1543
+ };
1544
+ function decodeEntities(s) {
1545
+ return s.replace(/&[a-z]+;/g, (e) => HTML_ENTITIES[e] ?? e).replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n))).replace(/&#x([0-9a-f]+);/gi, (_, h) => String.fromCodePoint(parseInt(h, 16)));
1546
+ }
1547
+ function MarkdownLine({ line, baseColor = "white", repoUrl }) {
1548
+ const raw = decodeEntities(line);
1549
+ const headingMatch = raw.match(/^(#{1,6})\s+(.*)/);
1465
1550
  if (headingMatch) {
1466
1551
  const level = headingMatch[1].length;
1467
1552
  const text = headingMatch[2];
1553
+ const color = level === 1 ? "whiteBright" : level === 2 ? "cyanBright" : level === 3 ? "cyan" : "gray";
1468
1554
  return /* @__PURE__ */ jsxs(Text, {
1469
- bold: true,
1470
- color: level === 1 ? "whiteBright" : level === 2 ? "white" : "gray",
1471
- children: [" ", text]
1555
+ bold: level <= 3,
1556
+ color,
1557
+ children: [" " + " ".repeat(Math.max(0, level - 3)), parseInline(text, repoUrl).map((s, i) => /* @__PURE__ */ jsx(Segment, {
1558
+ s,
1559
+ baseColor: color
1560
+ }, i))]
1472
1561
  });
1473
1562
  }
1474
1563
  if (raw.startsWith("> ")) return /* @__PURE__ */ jsxs(Text, {
@@ -1479,11 +1568,10 @@ function MarkdownLine({ line, baseColor = "white", baseDim = false }) {
1479
1568
  if (listMatch) {
1480
1569
  const indent = listMatch[1].match(/^\s*/)?.[0].length ?? 0;
1481
1570
  const content = listMatch[2];
1482
- const segments = parseInline(content);
1483
- return /* @__PURE__ */ jsxs(Text, { children: [" " + " ".repeat(indent) + "• ", segments.map((s, i) => /* @__PURE__ */ jsx(Text, {
1484
- bold: s.bold,
1485
- color: s.code ? "cyan" : s.dim ? "gray" : baseColor,
1486
- children: s.text
1571
+ const segments = parseInline(content, repoUrl);
1572
+ return /* @__PURE__ */ jsxs(Text, { children: [" " + " ".repeat(indent) + "• ", segments.map((s, i) => /* @__PURE__ */ jsx(Segment, {
1573
+ s,
1574
+ baseColor
1487
1575
  }, i))] });
1488
1576
  }
1489
1577
  if (/^[-*_]{3,}$/.test(raw.trim())) return /* @__PURE__ */ jsx(Text, {
@@ -1491,10 +1579,9 @@ function MarkdownLine({ line, baseColor = "white", baseDim = false }) {
1491
1579
  children: " ────────────────────"
1492
1580
  });
1493
1581
  if (!raw.trim()) return /* @__PURE__ */ jsx(Text, { children: " " });
1494
- return /* @__PURE__ */ jsxs(Text, { children: [" ", parseInline(raw).map((s, i) => /* @__PURE__ */ jsx(Text, {
1495
- bold: s.bold,
1496
- color: s.code ? "cyan" : s.dim ? "gray" : baseColor,
1497
- children: s.text
1582
+ return /* @__PURE__ */ jsxs(Text, { children: [" ", parseInline(raw, repoUrl).map((s, i) => /* @__PURE__ */ jsx(Segment, {
1583
+ s,
1584
+ baseColor
1498
1585
  }, i))] });
1499
1586
  }
1500
1587
  //#endregion
@@ -1506,22 +1593,19 @@ function ChangelogPanel({ pkg, onClose }) {
1506
1593
  const [opened, setOpened] = useState(false);
1507
1594
  const [activeEntry, setActiveEntry] = useState(0);
1508
1595
  const scrollRef = useRef(null);
1509
- const { stdout } = useStdout();
1596
+ const { columns, rows } = useWindowSize();
1597
+ const isUpToDate = pkg.current === (pkg.targetVersion ?? pkg.latest);
1510
1598
  useEffect(() => {
1511
- Promise.all([fetchChangelog(pkg.name, pkg.current, pkg.targetVersion ?? pkg.latest), fetchRepoUrl(pkg.name)]).then(([e, repo]) => {
1599
+ Promise.all([fetchChangelog(pkg.name, isUpToDate ? "" : pkg.current, pkg.targetVersion ?? pkg.latest), fetchRepoUrl(pkg.name)]).then(([e, repo]) => {
1512
1600
  setEntries(e);
1513
- setActiveEntry(0);
1601
+ setActiveEntry(isUpToDate ? Math.max(0, e.length - 1) : 0);
1514
1602
  setRepoUrl(repo);
1515
1603
  setLoading(false);
1516
1604
  });
1517
1605
  }, [pkg.name]);
1518
1606
  useEffect(() => {
1519
- const handleResize = () => scrollRef.current?.remeasure();
1520
- stdout?.on("resize", handleResize);
1521
- return () => {
1522
- stdout?.off("resize", handleResize);
1523
- };
1524
- }, [stdout]);
1607
+ scrollRef.current?.remeasure();
1608
+ }, [columns, rows]);
1525
1609
  const releasesPageUrl = repoUrl ? `${repoUrl}/releases` : "";
1526
1610
  const triggerOpen = (url) => {
1527
1611
  openInBrowser(url);
@@ -1597,14 +1681,6 @@ function ChangelogPanel({ pkg, onClose }) {
1597
1681
  })
1598
1682
  ]
1599
1683
  }),
1600
- repoUrl && /* @__PURE__ */ jsxs(Text, {
1601
- color: "gray",
1602
- children: [
1603
- " ",
1604
- repoUrl,
1605
- "/releases"
1606
- ]
1607
- }),
1608
1684
  /* @__PURE__ */ jsx(Text, {
1609
1685
  color: "gray",
1610
1686
  children: "────────────────────────────────────────────────────"
@@ -1674,7 +1750,10 @@ function ChangelogPanel({ pkg, onClose }) {
1674
1750
  ref: scrollRef,
1675
1751
  children: /* @__PURE__ */ jsx(Box, {
1676
1752
  flexDirection: "column",
1677
- children: currentEntry.body.split("\n").map((line, j) => /* @__PURE__ */ jsx(MarkdownLine, { line }, j))
1753
+ children: currentEntry.body.split("\n").map((line, j) => /* @__PURE__ */ jsx(MarkdownLine, {
1754
+ line,
1755
+ repoUrl
1756
+ }, j))
1678
1757
  })
1679
1758
  })
1680
1759
  }) : null,
@@ -1743,85 +1822,6 @@ function ChangelogPanel({ pkg, onClose }) {
1743
1822
  });
1744
1823
  }
1745
1824
  //#endregion
1746
- //#region src/ui/UpdateResults.tsx
1747
- function UpdateResults({ results, onDone }) {
1748
- useEffect(() => {
1749
- const timer = setTimeout(onDone, 1e3);
1750
- return () => clearTimeout(timer);
1751
- }, []);
1752
- const success = results.filter((r) => r.success);
1753
- const failed = results.filter((r) => !r.success);
1754
- return /* @__PURE__ */ jsxs(Box, {
1755
- flexDirection: "column",
1756
- children: [
1757
- /* @__PURE__ */ jsx(Box, {
1758
- marginBottom: 1,
1759
- children: /* @__PURE__ */ jsxs(Text, {
1760
- bold: true,
1761
- color: "greenBright",
1762
- children: [" ", "Update complete"]
1763
- })
1764
- }),
1765
- success.map((r) => /* @__PURE__ */ jsxs(Box, {
1766
- gap: 2,
1767
- children: [
1768
- /* @__PURE__ */ jsx(Text, {
1769
- color: "greenBright",
1770
- children: "✓"
1771
- }),
1772
- /* @__PURE__ */ jsx(Text, {
1773
- color: "white",
1774
- children: r.name
1775
- }),
1776
- /* @__PURE__ */ jsx(Text, {
1777
- color: "gray",
1778
- children: r.fromVersion
1779
- }),
1780
- /* @__PURE__ */ jsx(Text, {
1781
- color: "gray",
1782
- children: "→"
1783
- }),
1784
- /* @__PURE__ */ jsx(Text, {
1785
- color: "greenBright",
1786
- children: r.version
1787
- })
1788
- ]
1789
- }, r.name)),
1790
- failed.map((r) => /* @__PURE__ */ jsxs(Box, {
1791
- flexDirection: "column",
1792
- children: [/* @__PURE__ */ jsxs(Box, {
1793
- gap: 2,
1794
- children: [
1795
- /* @__PURE__ */ jsx(Text, {
1796
- color: "red",
1797
- children: "✗"
1798
- }),
1799
- /* @__PURE__ */ jsx(Text, {
1800
- color: "white",
1801
- children: r.name
1802
- }),
1803
- /* @__PURE__ */ jsx(Text, {
1804
- color: "gray",
1805
- children: r.fromVersion
1806
- }),
1807
- /* @__PURE__ */ jsx(Text, {
1808
- color: "gray",
1809
- children: "→"
1810
- }),
1811
- /* @__PURE__ */ jsx(Text, {
1812
- color: "red",
1813
- children: r.version
1814
- })
1815
- ]
1816
- }), r.error && /* @__PURE__ */ jsxs(Text, {
1817
- color: "red",
1818
- children: [" ", r.error.slice(0, 80)]
1819
- })]
1820
- }, r.name))
1821
- ]
1822
- });
1823
- }
1824
- //#endregion
1825
1825
  //#region src/ui/SettingsToggle.tsx
1826
1826
  function SettingsToggle({ label, description, checked, focused, disabled = false }) {
1827
1827
  return /* @__PURE__ */ jsxs(Box, {
@@ -2187,6 +2187,8 @@ function SelfUpdatePrompt({ currentVersion, latestVersion, updating, error, onUp
2187
2187
  //#endregion
2188
2188
  //#region src/ui/TerminalOutputBox.tsx
2189
2189
  function TerminalOutputBox({ message, command, outputLines, maxLines }) {
2190
+ const { columns } = useWindowSize();
2191
+ const boxWidth = Math.min(64, columns - 4);
2190
2192
  return /* @__PURE__ */ jsxs(Box, {
2191
2193
  flexDirection: "column",
2192
2194
  padding: 1,
@@ -2209,7 +2211,7 @@ function TerminalOutputBox({ message, command, outputLines, maxLines }) {
2209
2211
  borderStyle: "round",
2210
2212
  borderColor: "gray",
2211
2213
  paddingX: 1,
2212
- width: 64,
2214
+ width: boxWidth,
2213
2215
  height: maxLines + 3,
2214
2216
  overflow: "hidden",
2215
2217
  children: [command !== "" && /* @__PURE__ */ jsxs(Box, { children: [/* @__PURE__ */ jsx(Text, {
@@ -2352,7 +2354,7 @@ function useExitOnScreen(screen, targetScreens, exit, options) {
2352
2354
  }
2353
2355
  //#endregion
2354
2356
  //#region src/ui/App.tsx
2355
- function App({ project, global, showAll, version, installManager }) {
2357
+ function App({ project, global, showAll, version, installManager, onCancelled, onCopied }) {
2356
2358
  const { exit } = useApp();
2357
2359
  const [screen, setScreen] = useState("self-update-check");
2358
2360
  const [config, setConfig] = useState(() => loadConfig());
@@ -2368,7 +2370,7 @@ function App({ project, global, showAll, version, installManager }) {
2368
2370
  useExitOnScreen(screen, ["self-update-done", "empty"], exit);
2369
2371
  useExitOnScreen(screen, ["cancelled"], exit, {
2370
2372
  delay: 200,
2371
- beforeExit: () => console.log(" \x1B[32mCancelled.\x1B[0m\n")
2373
+ beforeExit: () => onCancelled?.()
2372
2374
  });
2373
2375
  useEffect(() => {
2374
2376
  if (!selfUpdate.checkComplete) return;
@@ -2403,21 +2405,15 @@ function App({ project, global, showAll, version, installManager }) {
2403
2405
  setConfig(newConfig);
2404
2406
  saveConfig(newConfig);
2405
2407
  };
2406
- const [results, setResults] = useState([]);
2407
- const handleConfirm = async () => {
2408
+ const handleConfirm = () => {
2408
2409
  const selected = packages.filter((p) => p.selected);
2409
2410
  if (selected.length === 0) return;
2410
- terminal.setLoadingMsg(`Updating ${selected.length} package${selected.length > 1 ? "s" : ""}…`);
2411
- terminal.setTerminalCmd("");
2412
- setScreen("updating");
2413
- const res = await updatePackages(project.manager, selected, project.cwd, global, terminal.onLine);
2414
- setResults(res);
2415
- setScreen("results");
2416
- const successNames = res.filter((r) => r.success).map((r) => r.name);
2417
- if (successNames.length > 0) {
2418
- incrementFrequency(successNames);
2419
- setFrequency(loadFrequency());
2420
- }
2411
+ const commands = buildUpdateCommands(project.manager, selected, global);
2412
+ copyToClipboard(commands.join(" && "));
2413
+ incrementFrequency(selected.map((p) => p.name));
2414
+ setFrequency(loadFrequency());
2415
+ onCopied?.(commands);
2416
+ exit();
2421
2417
  };
2422
2418
  if (screen === "self-update-check") return /* @__PURE__ */ jsxs(Box, {
2423
2419
  flexDirection: "column",
@@ -2518,22 +2514,6 @@ function App({ project, global, showAll, version, installManager }) {
2518
2514
  });
2519
2515
  const isListActive = screen === "list";
2520
2516
  return /* @__PURE__ */ jsxs(Fragment, { children: [
2521
- screen === "updating" && /* @__PURE__ */ jsx(TerminalOutputBox, {
2522
- message: terminal.loadingMsg,
2523
- command: terminal.terminalCmd,
2524
- outputLines: terminal.outputLines,
2525
- maxLines: terminal.maxLines
2526
- }),
2527
- screen === "results" && /* @__PURE__ */ jsx(Box, {
2528
- padding: 1,
2529
- children: /* @__PURE__ */ jsx(UpdateResults, {
2530
- results,
2531
- onDone: () => {
2532
- exit();
2533
- process.exit(0);
2534
- }
2535
- })
2536
- }),
2537
2517
  screen === "settings" && /* @__PURE__ */ jsx(Box, {
2538
2518
  padding: 1,
2539
2519
  children: /* @__PURE__ */ jsx(Settings, {
@@ -2616,7 +2596,7 @@ if (showHelp) {
2616
2596
  space toggle select
2617
2597
  v pick specific version
2618
2598
  c view changelog / release notes
2619
- enter update selected packages
2599
+ enter copy update command to clipboard & exit
2620
2600
  esc cancel / go back
2621
2601
  `);
2622
2602
  process.exit(0);
@@ -2627,13 +2607,28 @@ if (!isGlobal && !hasPackageJson(cwd)) {
2627
2607
  console.log(" Run ripen inside a Node.js project, or use ripen -g for global packages.\n");
2628
2608
  process.exit(1);
2629
2609
  }
2610
+ const project = getProjectInfo(cwd);
2611
+ const installManager = detectGlobalInstallManager();
2612
+ let wasCancelled = false;
2613
+ let copiedCommands = [];
2630
2614
  const { waitUntilExit } = render(/* @__PURE__ */ jsx(App, {
2631
- project: getProjectInfo(cwd),
2615
+ project,
2632
2616
  global: isGlobal,
2633
2617
  showAll,
2634
2618
  version: VERSION,
2635
- installManager: detectGlobalInstallManager()
2636
- }), { exitOnCtrlC: false });
2619
+ installManager,
2620
+ onCancelled: () => {
2621
+ wasCancelled = true;
2622
+ },
2623
+ onCopied: (cmds) => {
2624
+ copiedCommands = cmds;
2625
+ }
2626
+ }), {
2627
+ exitOnCtrlC: false,
2628
+ alternateScreen: true
2629
+ });
2637
2630
  await waitUntilExit();
2631
+ if (wasCancelled) process.stdout.write(" \x1B[32mCancelled.\x1B[0m\n");
2632
+ else if (copiedCommands.length > 0) process.stdout.write(" \x1B[32mCopied to clipboard.\x1B[0m\n");
2638
2633
  //#endregion
2639
2634
  export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ripencli",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Interactive dependency updater for npm, pnpm, yarn, and bun",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -35,7 +35,7 @@
35
35
  "dev": "tsx src/cli.tsx",
36
36
  "typecheck": "tsc --noEmit",
37
37
  "start": "node dist/cli.js",
38
- "checks": "node scripts/pr-checks.ts",
38
+ "check": "node scripts/pr-checks.ts",
39
39
  "docs:dev": "pnpm --prefix docs dev",
40
40
  "docs:build": "pnpm --prefix docs build",
41
41
  "docs:start": "pnpm --prefix docs start"
@@ -65,16 +65,16 @@
65
65
  "dist"
66
66
  ],
67
67
  "dependencies": {
68
- "execa": "^9.6.1",
69
- "ink": "^6.8.0",
70
- "ink-scroll-view": "^0.3.6",
71
- "react": "^19.2.4"
68
+ "execa": "9.6.1",
69
+ "ink": "7.0.3",
70
+ "ink-scroll-view": "0.3.7",
71
+ "react": "19.2.6"
72
72
  },
73
73
  "devDependencies": {
74
- "@types/node": "^25.5.0",
75
- "@types/react": "^19.2.14",
76
- "prettier": "^3.8.1",
77
- "tsdown": "^0.21.6",
78
- "typescript": "^6.0.2"
74
+ "@types/node": "24.12.2",
75
+ "@types/react": "19.2.14",
76
+ "prettier": "3.8.3",
77
+ "tsdown": "0.22.0",
78
+ "typescript": "6.0.3"
79
79
  }
80
80
  }