@gjermundgaraba/upup 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,7 +8,7 @@ Understand, audit and update system tools and project dependencies from one TUI.
8
8
  npm install -g @gjermundgaraba/upup
9
9
  ```
10
10
 
11
- Requires Node 26.12 or newer. The TUI is built on [OpenTUI](https://github.com/anomalyco/opentui), which runs through Node's FFI.
11
+ Requires Node 26.13 or newer. The TUI is built on [OpenTUI](https://github.com/anomalyco/opentui), which runs through Node's FFI.
12
12
 
13
13
  ## Usage
14
14
 
@@ -18,19 +18,21 @@ upup system # tools installed on this machine
18
18
  upup project [dir] # dependencies of the project containing dir
19
19
  ```
20
20
 
21
- **System** lists operating-system updates from macOS Software Update and apt, macOS apps that update themselves, and tools from Homebrew (formulae and casks), npm, pnpm and Vite+ globals, Vite+ itself, `cargo install`, `go install` and `uv tool`. Only the package managers on your `PATH` are asked.
21
+ **System** lists operating-system updates from macOS Software Update and apt, macOS apps that update themselves, and tools from Homebrew (formulae and casks), npm, pnpm and Vite+ globals, Vite+ itself and its environment defaults, `cargo install`, `go install` and `uv tool`. Only the package managers on your `PATH` are asked.
22
22
 
23
- **Project** lists the registry dependencies declared across the workspace that contains the directory: Go modules, Cargo workspaces, and npm or pnpm projects including pnpm catalogs. Path, Git and workspace references are not listed. Yarn and Bun projects are reported as unsupported.
23
+ Vite+ environment rows show configured global defaults for Node.js, npm, pnpm, Yarn and Bun. Exact Node.js defaults update within their current major. Yarn Classic stays on Yarn 1, since modern Yarn is a different published package; migrating to it is left to you. Other package managers update to their latest stable release. Floating defaults such as `latest` and `lts` show their configured selector and stay under Vite+’s automatic version selection. Updating installs the selected version and changes that tool’s default, preserving project pins and cached versions. Each default is checked immediately before updating, checked again after downloading and before switching, and verified afterward.
24
24
 
25
- In a git repository it also lists every project below the directory that a `package.json`, `go.mod` or `Cargo.toml` git tracks or doesn't ignore belongs to, each once, its provider named with where it is (`npm clients/voice`). A `package.json` with a lockfile beside it is a project of its own, and so is a Go module its `go.work` doesn't use. Folders under `node_modules`, subtrees vendored in with `git subtree`, and Go modules in the folders the go command skips (`testdata`, and those starting with `_` or `.`) are left out. Node projects below the directory that have no lockfile yet, or use Yarn or Bun, are named together in one notice.
25
+ **Project** lists the registry dependencies declared across the workspace that contains the directory: Go modules, Cargo workspaces, and npm or pnpm projects including pnpm catalogs, among them a catalog entry only an override uses when it names an exact version. A Cargo dependency is listed whatever features turn it on. Path, Git and workspace references are not listed, nor is one module of a `go.work` that another requires, or a module its `replace` covers. Yarn and Bun projects are reported as unsupported.
26
+
27
+ In a git repository it also lists every project below the directory that a `package.json`, `go.mod` or `Cargo.toml` git tracks or doesn't ignore belongs to, each once, its provider named with where it is (`npm clients/voice`). A `package.json` with a lockfile beside it is a project of its own, and so is a Go module its `go.work` doesn't use. Folders under `node_modules`, subtrees vendored in with `git subtree`, and Go modules in the folders the go command skips (`testdata`, and those starting with `_` or `.`) are left out. Node projects below the directory that have no lockfile yet, or use Yarn or Bun, are named together in one notice; a workspace names the folders in it that its lockfile doesn't list. A Cargo workspace with no `Cargo.lock` yet is named in a notice too. A Go workspace whose member module paths cannot be read is reported as unavailable; unrelated projects remain listed. upup runs cargo with `RUSTUP_AUTO_INSTALL=0`, so rustup doesn't install the toolchain a Cargo workspace pins (releases of rustup too old to know the setting still do); a missing one fails with rustup's message.
26
28
 
27
29
  It also lists the GitHub Actions and reusable workflows that the repository's workflows (`.github/workflows`), its own `action.yml` and its composite actions (under `.github/actions`, and any local action a workflow uses) use, once per action and release however many files say it. Their tags are read with `git ls-remote`: first from the server the repository's `origin` is on, when that isn't github.com, since a GitHub Enterprise Server runs its own copy of an action before github.com's; then from github.com, a private repository's with your own git config. An update moves to the newest release tag written like the current one: as precise, and with or without the `v`. So `@v4` moves to `@v5` once the action tags `v5`, and `@v4.1.2` to the newest full release. A commit pin is read as the release tag that points at it (the one its comment names, such as `# v4.1.2`, else the most precise) and moves to the commit of the newest tag like that one, its comment's version moving with it; upup says when a comment names a tag that points elsewhere. Local (`./`) actions are read rather than listed, Docker actions are not listed, and branches such as `@main` are listed without an update. Removing an action is left to you.
28
30
 
29
31
  It also lists the Node.js versions pinned exactly, once per version however many files pin it: `node-version` in the `actions/setup-node` steps of every workflow and action upup reads for GitHub Actions, and the first line that isn't blank or a comment in the `.node-version` and `.nvmrc` files of the directory, the folders below it and those between it and the repository's top that git tracks or doesn't ignore. An update moves to the newest release on the same major line from nodejs.org, keeping each file's `v` or quotes, since moving to another major line is a migration rather than an update. `22` and `lts/*` already follow their line and aren't listed; matrix entries, expressions and Dockerfile `FROM node:` images aren't read yet. Like OS updates, these aren't audited.
30
32
 
31
- The package manager a Node project declares, in `packageManager` (`pnpm@12.5.1`) or `devEngines.packageManager`, is listed too, each field as a row of its own, and moves like a dependency to its newest stable release that the project's minimum release age lets through. Its current version is the one pnpm locked, or else the declared one; a range with no locked version isn't listed. Corepack's sha512 hash moves with the version, worked out from the registry's integrity for the release; a pin with another kind of hash is listed without an offer. Neither field can be removed from upup.
33
+ The package manager a Node project declares, in `packageManager` (`pnpm@12.5.1`) or `devEngines.packageManager`, is listed too, each field as a row of its own, and moves like a dependency. Its current version is the one pnpm locked, or else the declared one; a range with no locked version isn't listed. Corepack's sha512 hash moves with the version, worked out from the registry's integrity for the release; a pin with another kind of hash is listed without an offer. Neither field can be removed from upup.
32
34
 
33
- Current versions come from the package manager or lockfile. An offered update is the newest stable release. Updating a declared range keeps its style (`^1.2.0` becomes `^1.4.0`), and the lockfile is reconciled and checked afterwards. Removing a project dependency edits the manifest and reconciles the same way.
35
+ Current versions come from the package manager or lockfile. An offered update is the newest stable release; in a Node project, no newer than the one the registry tags `latest` and older than the project's minimum release age. When the registry can't be reached, the project's dependencies are listed without offers and one notice says why. Updating a declared range keeps its style (`^1.2.0` becomes `^1.4.0`), and the lockfile is reconciled and checked afterwards. Removing a project dependency edits the manifest and reconciles the same way.
34
36
 
35
37
  ## OS updates
36
38
 
@@ -113,7 +115,7 @@ Each update run is also logged to `updates/` in the user cache directory, one JS
113
115
 
114
116
  ## Development
115
117
 
116
- Requires Node 26.12 or newer and [Vite+](https://viteplus.dev).
118
+ Requires Node 26.13 or newer and [Vite+](https://viteplus.dev).
117
119
 
118
120
  Run `mise install` once to get the Go toolchain `mise.toml` pins; a binary-audit test reads its build info and is skipped without Go.
119
121
 
@@ -3,7 +3,7 @@ import { Deferred, Effect, FiberSet, Stream } from "effect";
3
3
  import { spawn } from "node:child_process";
4
4
  import { homedir } from "node:os";
5
5
  import semver from "semver";
6
- import { createComponent, createElement, createTextNode, effect, insert, insertNode, memo, render, setProp, use, useKeyboard, usePaste, useTerminalDimensions } from "@opentui/solid";
6
+ import { createComponent, createElement, createTextNode, effect, insert, insertNode, memo, onAnimationTick, render, setProp, use, useKeyboard, usePaste, useTerminalDimensions } from "@opentui/solid";
7
7
  import { TextAttributes, createCliRenderer } from "@opentui/core";
8
8
  import { For, Match, Show, Switch, createEffect, createMemo, createRenderEffect, createSignal, on, onCleanup, untrack } from "solid-js/dist/solid.js";
9
9
  // src/tui/text.ts
@@ -614,11 +614,8 @@ const frames = [
614
614
  ];
615
615
  const Spinner = (props) => {
616
616
  const [frame, setFrame] = createSignal(0);
617
- const timer = setInterval(() => {
617
+ onAnimationTick(12.5, () => {
618
618
  setFrame((at) => (at + 1) % frames.length);
619
- }, 80);
620
- onCleanup(() => {
621
- clearInterval(timer);
622
619
  });
623
620
  return (() => {
624
621
  var _el$ = createElement("text");
@@ -1281,6 +1278,7 @@ const providerLabels = /* @__PURE__ */ new Map([
1281
1278
  ["pnpm-global", "pnpm -g"],
1282
1279
  ["vp-global", "Vite+ global"],
1283
1280
  ["vp-self", "Vite+"],
1281
+ ["vp-env", "Vite+ environment"],
1284
1282
  ["cargo-install", "cargo install"],
1285
1283
  ["go-install", "go install"],
1286
1284
  ["uv-tool", "uv tool"],
@@ -1352,7 +1350,7 @@ const lines = (grouped) => {
1352
1350
  const reachable = (grouped) => grouped.flatMap(visibleRows);
1353
1351
  const unauditedLabels = {
1354
1352
  app: "app update",
1355
- runtime: "runtime pin",
1353
+ runtime: "Node.js runtime",
1356
1354
  os: "OS update"
1357
1355
  };
1358
1356
  const parts = (version) => version.replace(/^v/u, "").split(/(?<separator>[._+-])/u);
@@ -1776,7 +1774,7 @@ const ConfirmPopup = (props) => {
1776
1774
  get children() {
1777
1775
  var _el$6 = createElement("text");
1778
1776
  setProp(_el$6, "wrapMode", "word");
1779
- insert(_el$6, () => pending().rows.some((row) => unauditedKind(row.pkg) === "runtime") ? "upup does not audit the runtime versions a project pins: confirming moves them as offered." : "upup does not audit operating-system or app updates: confirming installs them as the vendor offers them, and upup asks for your password if sudo needs it.");
1777
+ insert(_el$6, () => pending().rows.some((row) => unauditedKind(row.pkg) === "runtime") ? "upup does not audit Node.js runtimes: confirming updates the selected versions as offered." : "upup does not audit operating-system or app updates: confirming installs them as the vendor offers them, and upup asks for your password if sudo needs it.");
1780
1778
  effect((_$p) => setProp(_el$6, "fg", theme.muted, _$p));
1781
1779
  return _el$6;
1782
1780
  }
@@ -4008,6 +4006,9 @@ const SettingsPopup = (props) => {
4008
4006
  const [entry, setEntry] = createSignal("");
4009
4007
  const [picked, setPicked] = createSignal(0);
4010
4008
  props.run(props.session.loadCatalogue);
4009
+ onCleanup(() => {
4010
+ if (props.state.signIn.value === "Prompted") props.run(props.session.closeSignIn);
4011
+ });
4011
4012
  const catalogue = () => props.state.catalogue?._tag === "Ready" ? props.state.catalogue.value : [];
4012
4013
  const setup = () => props.state.preferences.setup;
4013
4014
  const current = createMemo(() => catalogue().find((choice) => choice.provider === setup()?.provider && choice.modelId === setup()?.modelId));
@@ -4038,7 +4039,8 @@ const SettingsPopup = (props) => {
4038
4039
  };
4039
4040
  createEffect(() => {
4040
4041
  const now = mode();
4041
- if (now._tag !== "SigningIn" || props.state.signIn !== void 0) return;
4042
+ const { signIn } = props.state;
4043
+ if (now._tag !== "SigningIn" || signIn.value !== "SignedIn" || signIn.context.provider !== now.choice.provider) return;
4042
4044
  const signedIn = catalogue().find((choice) => choice.provider === now.choice.provider && choice.modelId === now.choice.modelId);
4043
4045
  if (signedIn?.signedIn === true) choose(signedIn);
4044
4046
  });
@@ -4110,7 +4112,7 @@ const SettingsPopup = (props) => {
4110
4112
  _tag: "SigningIn",
4111
4113
  choice
4112
4114
  });
4113
- props.run(props.session.signIn(choice.provider));
4115
+ props.run(props.session.closeSignIn.pipe(Effect.andThen(props.session.signIn(choice.provider))));
4114
4116
  }
4115
4117
  };
4116
4118
  const onEntry = (key, submit, cancel) => {
@@ -4127,11 +4129,11 @@ const SettingsPopup = (props) => {
4127
4129
  setMode({ _tag: "Picker" });
4128
4130
  };
4129
4131
  const onSignIn = (key, signIn) => {
4130
- if (signIn?._tag !== "Working" || signIn.prompt === void 0) {
4132
+ if (signIn.value !== "Prompted") {
4131
4133
  if (key.name === "escape") cancel();
4132
4134
  return;
4133
4135
  }
4134
- const { prompt } = signIn;
4136
+ const { prompt } = signIn.context;
4135
4137
  if (prompt.type === "select") {
4136
4138
  if (key.name === "escape") cancel();
4137
4139
  else if (key.name === "down") setPicked((index) => Math.min(prompt.options.length - 1, index + 1));
@@ -4162,7 +4164,7 @@ const SettingsPopup = (props) => {
4162
4164
  case "Key":
4163
4165
  onEntry(key, (text) => {
4164
4166
  if (text.trim() === "") return;
4165
- props.run(props.session.saveKey(now.choice.provider, text.trim()));
4167
+ props.run(props.session.closeSignIn.pipe(Effect.andThen(props.session.saveKey(now.choice.provider, text.trim()))));
4166
4168
  setEntry("");
4167
4169
  setMode({
4168
4170
  _tag: "SigningIn",
@@ -4236,9 +4238,12 @@ const SettingsPopup = (props) => {
4236
4238
  const now = mode();
4237
4239
  return now._tag === "SigningIn" ? now : void 0;
4238
4240
  };
4239
- const working = () => props.state.signIn?._tag === "Working" ? props.state.signIn : void 0;
4240
- const failure = () => props.state.signIn?._tag === "Failed" ? props.state.signIn : void 0;
4241
- const prompt = createMemo(() => working()?.prompt);
4241
+ const notices = () => props.state.signIn.context.notices ?? [];
4242
+ const failure = () => {
4243
+ const { signIn } = props.state;
4244
+ return signIn.value === "Failed" ? signIn.context : void 0;
4245
+ };
4246
+ const prompt = createMemo(() => props.state.signIn.context.prompt);
4242
4247
  const selecting = () => {
4243
4248
  const asked = prompt();
4244
4249
  return asked?.type === "select" ? asked : void 0;
@@ -4444,7 +4449,7 @@ const SettingsPopup = (props) => {
4444
4449
  setProp(_el$10, "flexDirection", "column");
4445
4450
  insert(_el$10, createComponent(Show, {
4446
4451
  get when() {
4447
- return memo(() => (working()?.notices.length ?? 0) === 0)() && prompt() === void 0;
4452
+ return memo(() => notices().length === 0)() && prompt() === void 0;
4448
4453
  },
4449
4454
  get children() {
4450
4455
  var _el$11 = createElement("text");
@@ -4455,7 +4460,7 @@ const SettingsPopup = (props) => {
4455
4460
  }), null);
4456
4461
  insert(_el$10, createComponent(For, {
4457
4462
  get each() {
4458
- return working()?.notices ?? [];
4463
+ return notices();
4459
4464
  },
4460
4465
  children: (notice) => (() => {
4461
4466
  var _el$13 = createElement("text");
@@ -4657,6 +4662,7 @@ const App = (props) => {
4657
4662
  createEffect(() => {
4658
4663
  if (anchor() !== void 0) anchorAt(cursor());
4659
4664
  });
4665
+ const operating = () => props.state.operation.value !== "Idle";
4660
4666
  const question = () => questionOf(props.state);
4661
4667
  const deciding = () => question() !== void 0;
4662
4668
  const finding = () => overlay()._tag === "Find" && !deciding();
@@ -4670,7 +4676,7 @@ const App = (props) => {
4670
4676
  const selectedRows = createMemo(() => everyRow().filter((row) => props.state.selected.includes(row.key)));
4671
4677
  const notices = createMemo(() => {
4672
4678
  const discovery = props.state.discoveryError;
4673
- return [
4679
+ const found = [
4674
4680
  ...discovery === void 0 ? [] : [{
4675
4681
  color: theme.danger,
4676
4682
  message: `✗ ${describeError(discovery)}`
@@ -4683,7 +4689,12 @@ const App = (props) => {
4683
4689
  color: theme.warn,
4684
4690
  message: `! ${providerLabel(entry.name)}: ${notice.message}`
4685
4691
  })))
4686
- ].slice(0, noticeLimit);
4692
+ ];
4693
+ if (found.length <= noticeLimit) return found;
4694
+ return [...found.slice(0, 3), {
4695
+ color: theme.muted,
4696
+ message: `+${found.length - noticeLimit + 1} more notices`
4697
+ }];
4687
4698
  });
4688
4699
  const natural = createMemo(() => naturalColumns(everyRow(), props.state));
4689
4700
  const fullList = () => Math.max(tableWidth(natural()) + 4, listMin);
@@ -4786,16 +4797,12 @@ const App = (props) => {
4786
4797
  readTo.clear();
4787
4798
  } else if (before !== void 0 && scroller !== void 0) readTo.set(before[1], scroller.scrollTop);
4788
4799
  if (now === "transcript" || scroller === void 0) return;
4789
- scroller.scrollTop = 0;
4790
- const saved = readTo.get(now);
4791
- if (saved !== void 0 && saved > 0) setTimeout(() => {
4792
- if (scroller !== void 0 && tab() === now) scroller.scrollTop = saved;
4793
- }, 30);
4800
+ scroller.scrollTo(readTo.get(now) ?? 0);
4794
4801
  }));
4795
4802
  createEffect(on(transcript, (file) => {
4796
4803
  if (file !== void 0) fire(props.session.readTranscript(file));
4797
4804
  }));
4798
- createEffect(on(() => props.state.running, (now, before) => {
4805
+ createEffect(on(operating, (now, before) => {
4799
4806
  if (before !== true || now) return;
4800
4807
  const last = untrack(run);
4801
4808
  if (last === void 0) return;
@@ -4848,6 +4855,7 @@ const App = (props) => {
4848
4855
  });
4849
4856
  const scrollDetails = (direction) => scroller?.scrollBy(direction * Math.max(1, Math.floor(contentHeight() / 2)));
4850
4857
  const confirm = (action) => {
4858
+ if (action !== "audit" && operating()) return;
4851
4859
  if (action === "audit" || action === "update" && props.state.preferences.auditFirst) fire(props.session.loadCatalogue);
4852
4860
  const focusedRow = focused();
4853
4861
  if (selectedRows().length === 0 && focusedRow !== void 0 && props.state.removed[focusedRow.key] !== void 0) {
@@ -4917,7 +4925,7 @@ const App = (props) => {
4917
4925
  }
4918
4926
  };
4919
4927
  const quit = () => {
4920
- if (!props.state.running) {
4928
+ if (!operating()) {
4921
4929
  props.quit();
4922
4930
  return;
4923
4931
  }
@@ -4967,10 +4975,7 @@ const App = (props) => {
4967
4975
  const count = changedFiles();
4968
4976
  if (count === 0) return;
4969
4977
  setFileAt((at) => (Math.min(at, count - 1) + by + count) % count);
4970
- setTimeout(() => {
4971
- const head = scroller?.content.findDescendantById(diffHead);
4972
- if (scroller !== void 0 && head !== void 0) scroller.scrollBy(head.y - scroller.viewport.y);
4973
- }, 30);
4978
+ scroller?.scrollChildIntoView(diffHead, { align: "start" });
4974
4979
  };
4975
4980
  const browse = () => {
4976
4981
  const row = focused();
@@ -5040,7 +5045,7 @@ const App = (props) => {
5040
5045
  else if (matches(keys.browse, key)) browse();
5041
5046
  else if (matches(keys.stop, key)) fire(props.session.stop);
5042
5047
  else if (matches(keys.quit, key)) quit();
5043
- else if (matches(keys.switchMode, key) && !props.state.running) {
5048
+ else if (matches(keys.switchMode, key) && !operating()) {
5044
5049
  const mode = otherMode(props.state.mode);
5045
5050
  places.set(props.state.mode, {
5046
5051
  key: focused()?.key,
@@ -5102,8 +5107,8 @@ const App = (props) => {
5102
5107
  const auditing = () => Object.values(props.state.audits).filter((audit) => audit._tag === "Running").length;
5103
5108
  const summary = () => {
5104
5109
  if (quitting()) return "Stopping; upup quits once the running update finishes";
5105
- if (props.state.stopping) return "Stopping after the targets already started";
5106
- if (props.state.running) return progress();
5110
+ if (props.state.operation.context.stopping) return "Stopping after the targets already started";
5111
+ if (operating()) return progress();
5107
5112
  const all = rows();
5108
5113
  const selected = selectedRows().length;
5109
5114
  const hidden = selectedRows().filter((row) => !all.some((visible) => visible.key === row.key)).length;
@@ -5124,7 +5129,7 @@ const App = (props) => {
5124
5129
  };
5125
5130
  const hints = () => {
5126
5131
  if (finding()) return [[keys.keepFilter, 0], [keys.clearFilter, 0]];
5127
- if (props.state.running) return [
5132
+ if (operating()) return [
5128
5133
  [keys.stop, 0],
5129
5134
  [keys.up, 3],
5130
5135
  [keys.quit, 2]
@@ -5192,7 +5197,7 @@ const App = (props) => {
5192
5197
  const latest = toast();
5193
5198
  return latest === void 0 ? summary() : `${summary()} ${latest.text}`;
5194
5199
  };
5195
- const busy = () => props.state.running || auditing() > 0;
5200
+ const busy = () => operating() || auditing() > 0;
5196
5201
  const footerRoom = () => dimensions().width - 2 - (busy() ? 2 : 0);
5197
5202
  const fitted = () => overlay()._tag !== "None" && !finding() ? [] : fitFooterHints(hints(), footerRoom(), graphemes(`${footnote()} `).length);
5198
5203
  const statusRoom = () => footerRoom() - hintsWidth(fitted());
@@ -5435,7 +5440,7 @@ const App = (props) => {
5435
5440
  setProp(_el$21, "paddingRight", 1);
5436
5441
  insert(_el$21, createComponent(Show, {
5437
5442
  get when() {
5438
- return props.state.running || auditing() > 0;
5443
+ return busy();
5439
5444
  },
5440
5445
  get children() {
5441
5446
  return [createComponent(Spinner, { get color() {
@@ -5725,7 +5730,7 @@ const App = (props) => {
5725
5730
  })
5726
5731
  }), null);
5727
5732
  effect((_p$) => {
5728
- var _v$3 = theme.onAccent, _v$4 = theme.accent, _v$5 = TextAttributes.BOLD, _v$6 = theme.muted, _v$7 = props.state.running ? theme.text : theme.muted;
5733
+ var _v$3 = theme.onAccent, _v$4 = theme.accent, _v$5 = TextAttributes.BOLD, _v$6 = theme.muted, _v$7 = operating() ? theme.text : theme.muted;
5729
5734
  _v$3 !== _p$.e && (_p$.e = setProp(_el$3, "fg", _v$3, _p$.e));
5730
5735
  _v$4 !== _p$.t && (_p$.t = setProp(_el$3, "bg", _v$4, _p$.t));
5731
5736
  _v$5 !== _p$.a && (_p$.a = setProp(_el$3, "attributes", _v$5, _p$.a));