From 193a8135cbdfc637628cfaa197bf2d4808791b6f Mon Sep 17 00:00:00 2001 From: Evan Date: Tue, 20 Oct 2026 10:10:19 -0400 Subject: [PATCH 1/3] Date the cooldown from each release for pip and Homebrew too pip and Homebrew had no release-date lookup, so their cooldown counted from when a scan first saw a version: everything showed the full period left, even releases that were months old. - pip: upload date from PyPI, shared with uv tools (pypi_released) - Homebrew formulae: the build date of the version's first bottle on ghcr.io (each scan looks up the outdated formulae's ghcr repos with one `brew info`; the manifest is read with ghcr's anonymous token). Casks and tap formulae have no bottles there and still count from first seen - No cooldown shown or counted for managers pkgdash doesn't update (pip): the dashboard and Outdated page list them as "to update by hand" instead of "ready to update" Existing installs pick up the real dates at the next scan. --- README.md | 4 +++- index.html | 16 ++++++++++------ server.py | 52 ++++++++++++++++++++++++++++++++++++++++++++-------- 3 files changed, 57 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index e57dfc3..0d6a1d7 100644 --- a/README.md +++ b/README.md @@ -126,7 +126,9 @@ followed by a re-scan. The server only accepts names its last scan found. Updates wait until a release is that old, since hijacked releases are usually caught and pulled within days. uv uses `--exclude-newer` and npm `--before`, which also cover their dependencies; brew/go/cargo skip packages still cooling (cargo is pinned to the scanned version). Release dates come from PyPI, the npm registry, the -Go proxy and crates.io; Homebrew has none, so its clock starts when a scan first sees the version. +Go proxy and crates.io; Homebrew has none, so formulae use the build time of the version's first bottle on +ghcr.io, and casks (no bottles) start the clock when a scan first sees the version. pip packages show no +cooldown, since pkgdash doesn't update them.
diff --git a/index.html b/index.html index 3065485..abf4a43 100644 --- a/index.html +++ b/index.html @@ -353,10 +353,12 @@ const more = queued.length ? ` · +${queued.length} queued` : ""; return `${bar}
${VERB[run.action]}${run.pkg ? " " + esc(run.pkg) : ""} ${secs(el)}${eta}${more}${run.step ? " — " + esc(run.step) : ""}
`; } +// whether pkgdash installs updates for this package's manager (pip's are left to Homebrew / macOS) +const canUpdate = p => { const m = mgr(p.mid); return !!m && m.actions.includes("update") && !m.hold; }; // seconds until a package's new version is past its cooldown period (matches cooldown_left in server.py) const coolLeft = p => { const days = Number(S.settings.cooldown_days) || 0; - return days > 0 && p.outdated && p.latest && p.released ? Math.max(0, p.released + days * 86400 - S.now) : 0; + return days > 0 && canUpdate(p) && p.outdated && p.latest && p.released ? Math.max(0, p.released + days * 86400 - S.now) : 0; }; const leftText = s => s >= 86400 ? `${Math.ceil(s / 86400)} day${Math.ceil(s / 86400) === 1 ? "" : "s"}` : `${Math.max(1, Math.ceil(s / 3600))} hour${Math.ceil(s / 3600) <= 1 ? "" : "s"}`; // matches flag_text in server.py @@ -541,6 +543,8 @@ const t = S.totals; const lastScan = Math.max(0, ...S.managers.map(m => m.scanned_at || 0)); const cooling = S.managers.reduce((a, m) => a + (m.cooling || 0), 0), blocked = S.managers.reduce((a, m) => a + (m.blocked || 0), 0); + // outdated in managers pkgdash doesn't update (pip): not "ready", they're updated by hand + const manual = S.managers.filter(m => !m.separate && (m.hold || !m.actions.includes("update"))).reduce((a, m) => a + (m.outdated || 0), 0); $("#view").innerHTML = `

SYSTEM_OVERVIEW

Status: ONLINE | Uptime: ${uptime(S.uptime)} | Last scan: ${ago(lastScan || null)} | Next: scan ${until(S.next.scan)} · audit ${until(S.next.audit)}
@@ -552,7 +556,7 @@
${statCard("", "PACKAGES_TRACKED", "box", fmt(t.packages), `${icon("plus").replace("`, "new packages / 7d", "#/packages")} ${statCard(t.high ? "red" : t.vulns ? "amber" : "", "KNOWN_VULNS", "shield", fmt(t.vulns), `${t.high} high/crit`, "across all audits", "#/vulns")} - ${statCard("amber", "OUTDATED", "up", fmt(t.outdated), `${cooling} in cooldown${blocked ? ` ${blocked} blocked` : ""}`, `${Math.max(0, t.outdated - cooling - blocked)} ready to update`, "#/outdated")} + ${statCard("amber", "OUTDATED", "up", fmt(t.outdated), `${cooling} in cooldown${blocked ? ` ${blocked} blocked` : ""}`, `${Math.max(0, t.outdated - cooling - blocked - manual)} ready to update${manual ? ` · ${manual} by hand` : ""}`, "#/outdated")} ${statCard("blue", "JOBS_24H", "pulse", fmt(t.jobs24), `${t.failed24} failed`, "scan · audit · update", "#/jobs")}
${S.managers.filter(m => m.available).map(mgrCard).join("")}
@@ -654,8 +658,7 @@ /* ---------- package tables (manager pages, PACKAGES, OUTDATED) ---------- */ // rows are inventory packages with their manager id added as `mid` function updBtn(p) { - const m = mgr(p.mid); - if (!m || !m.actions.includes("update") || m.hold) return ""; + if (!canUpdate(p)) return ""; if (pkgBusy(p.mid, p)) return ' '; const attrs = `data-upd="${esc(p.mid)}" data-kind="${esc(p.kind)}" data-pkg="${esc(p.name)}"`; return coolLeft(p) ? ` ` : ` `; @@ -736,8 +739,9 @@ function viewOutdated() { const rows = allPkgs().filter(p => p.outdated) .sort((a, b) => (!!a.blocked - !!b.blocked) || (coolLeft(a) - coolLeft(b)) || a.mid.localeCompare(b.mid) || a.name.localeCompare(b.name)); - const ready = rows.filter(p => !p.blocked && !coolLeft(p)).length, cooling = rows.filter(p => !p.blocked && coolLeft(p)).length; - $("#osub").innerHTML = `${ready} ready to update · ${cooling} in cooldown period${rows.length - ready - cooling ? ` · ${rows.length - ready - cooling} blocked` : ""} · cooldown is ${Number(S.settings.cooldown_days) || 0} days (change)`; + const blocked = rows.filter(p => p.blocked).length, manual = rows.filter(p => !p.blocked && !canUpdate(p)).length; + const cooling = rows.filter(p => !p.blocked && coolLeft(p)).length, ready = rows.length - blocked - manual - cooling; + $("#osub").innerHTML = `${ready} ready to update · ${cooling} in cooldown period${blocked ? ` · ${blocked} blocked` : ""}${manual ? ` · ${manual} to update by hand` : ""} · cooldown is ${Number(S.settings.cooldown_days) || 0} days (change)`; $("#obody").innerHTML = rows.length ? pkgTable(rows, true) : `
everything is up to date
`; } diff --git a/server.py b/server.py index 392404c..34b97fa 100644 --- a/server.py +++ b/server.py @@ -167,17 +167,24 @@ def loads(text, default): return default -def http_json(url, body=None, timeout=30): +def http_json(url, body=None, timeout=30, headers=None): if not url.startswith("https://"): # registries and OSV only: never file: or plain http raise ValueError("not an https URL: " + url[:80]) req = urllib.request.Request( # noqa: S310 - https enforced above url, data=json.dumps(body).encode() if body is not None else None, headers={"User-Agent": "pkgdash/1.0 (local dashboard)", - "Content-Type": "application/json"}) + "Content-Type": "application/json", **(headers or {})}) with urllib.request.urlopen(req, timeout=timeout) as r: # noqa: S310 return json.loads(r.read()) +def pypi_released(name, version): + """Upload time of a PyPI release (its first file), if PyPI has it.""" + data = http_json("https://pypi.org/pypi/%s/%s/json" % (quote(name), quote(version))) + times = [t for t in (parse_iso(u.get("upload_time_iso_8601")) for u in data.get("urls", [])) if t] + return min(times) if times else None + + # ---------------------------------------------------------------- store class Store: @@ -798,9 +805,7 @@ def dep_tree(self, job): return [(*l.split("==", 1), tool) for tool, reqs in self.freeze(job).items() for l in reqs] def released(self, job, p): - data = http_json("https://pypi.org/pypi/%s/%s/json" % (p["name"], p["latest"])) - times = [t for t in (parse_iso(u.get("upload_time_iso_8601")) for u in data.get("urls", [])) if t] - return min(times) if times else None + return pypi_released(p["name"], p["latest"]) def receipt(self, tool): """Requirements from uv-receipt.toml (no tomllib on 3.9): [{name, specifier|directory|git|...}].""" @@ -1018,6 +1023,7 @@ def update(self, job, only=None): class Brew(Manager): id, label, icon, color = "brew", "homebrew", "beer", "amber" desc = "Homebrew formulae and casks (vulns via brew-vulns / OSV)" + bottle_repo = {} # formula name → its ghcr.io repo, for release dates (filled by each scan) def available(self): return bool(which("brew")) @@ -1035,6 +1041,7 @@ def scan(self, job): o = self.outdated(job) lf = {x["name"]: x["current_version"] for x in o.get("formulae", [])} lc = {x["name"]: x["current_version"] for x in o.get("casks", [])} + self.bottle_repos(job, sorted(lf)) res = [] for line in f.splitlines(): parts = line.split() @@ -1047,6 +1054,31 @@ def scan(self, job): res.append(pkg(parts[0], parts[-1], lc.get(parts[0]), "cask")) return res + def bottle_repos(self, job, names): + """Where each outdated homebrew/core formula's bottles live on ghcr.io ("homebrew/core/python/3.13").""" + self.bottle_repo = {} + if not names: + return + _, out = job.run(["brew", "info", "--json=v2", "--formula"] + names, quiet=True, timeout=300) + for x in loads(out, {}).get("formulae", []): + files = ((x.get("bottle") or {}).get("stable") or {}).get("files") or {} + url = next(iter(files.values()), {}).get("url", "") + m = re.match(r"https://ghcr\.io/v2/(homebrew/core/[\w.+@/-]+)/blobs/", url) + if m: + self.bottle_repo[x["name"]] = m.group(1) + + def released(self, job, p): + """Homebrew has no release dates, but a core bottle's manifest says when it was built. The plain version + tag is the first build (rebuilds are tagged -1, -2, ...), which comes soon after upstream's release. + Casks and tap formulae have none, so their cooldown counts from when a scan first saw them.""" + repo = self.bottle_repo.get(p["name"]) if p["kind"] == "formula" else None + if not repo: + return None + # "QQ==" is ghcr.io's anonymous token, enough for public images like Homebrew's bottles + data = http_json("https://ghcr.io/v2/%s/manifests/%s" % (repo, quote(p["latest"])), + headers={"Authorization": "Bearer QQ==", "Accept": "application/vnd.oci.image.index.v1+json"}) + return parse_iso((data.get("annotations") or {}).get("org.opencontainers.image.created")) + def audit(self, job): _, out = job.run(["brew", "vulns", "--json", "-m", "0"], quiet=True, timeout=1800) data = loads(out, None) @@ -1080,8 +1112,8 @@ def update(self, job, only=None): raise RuntimeError("%s not updated: %s" % (only[1], gate[only])) return self.update_one(job, *only) job.stream(["brew", "update"], timeout=900) - # Homebrew has no release dates, so the cooldown counts from when a scan first saw each - # version. Only upgrade what the scan saw (a version that appeared since then waits). + # the cooldown only knows about versions a scan has dated (bottle build time, or when the scan + # first saw it), so only upgrade what the scan saw: a version that appeared since then waits. inv, o = inv_pkgs(self.id), self.outdated(job) def ready(kind, items): @@ -1144,6 +1176,9 @@ def pythons(self): def available(self): return bool(self.pythons()) + def released(self, job, p): + return pypi_released(p["name"], p["latest"]) + def scan(self, job): res = [] for py, kind, extra in self.pythons(): @@ -2007,9 +2042,10 @@ def state_payload(): ign = m.ignored() fs = [f for f in fs if f["context"] not in ign] mal = [h for h in mal if h["context"] not in ign] + updatable = "update" in m.actions and not m.hold # pip: shown, never updated, so no cooldown either d.update(malware=[h for h in mal if h["where"] == "installed"], osv=bool(m.osv_eco), malware_checked_at=(store["malware"].get(m.id) or {}).get("checked_at"), - cooling=sum(cooldown_left(p) > 0 and not p.get("blocked") for p in pk), + cooling=sum(updatable and cooldown_left(p) > 0 and not p.get("blocked") for p in pk), blocked=sum(bool(p.get("blocked")) for p in pk)) totals["malware"] += len(d["malware"]) d.update(count=len(pk), outdated=sum(p["outdated"] for p in pk), vulns=len(fs), From c006c5622fb7d37ced8345d8599164a477ce64d5 Mon Sep 17 00:00:00 2001 From: Evan Date: Tue, 20 Oct 2026 10:10:19 -0400 Subject: [PATCH 2/3] Say which Socket.dev token scope pkgdash needs The setup page and README now say the token only needs packages:list: pkgdash only calls organizations, quota and the batch PURL lookup, all read-only. The setup page's test only calls organizations, so it can't check scopes; a missing one shows up as an HTTP 403 in the next scan's log. --- README.md | 3 ++- index.html | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 0d6a1d7..cc135c8 100644 --- a/README.md +++ b/README.md @@ -147,7 +147,8 @@ covered (OSV has no Homebrew malware data). The malware check also asks Socket's API. Its blocking verdicts (malware, typosquats, protestware…) block updates the same way, and each pending version gets a note: `socket ✓`, `not analysed yet`, or what it starts doing that the installed version didn't (`new: install script · network access`). Add the token on the setup -page: it's tested against Socket, then stored in the login Keychain (item `pkgdash-socket-token`) through +page; it only needs the `packages:list` scope (pkgdash calls `organizations`, `quota` and the batch PURL +lookup, read-only). It's tested against Socket, then stored in the login Keychain (item `pkgdash-socket-token`) through `security -i` on stdin, so it never appears on a command line, and it's never written to pkgdash's own files. Answers are cached in state.json (installed versions for 3 days, pending ones for a scan) because the free plan's quota is small (500, 100 per lookup); the settings page shows what's left. diff --git a/index.html b/index.html index abf4a43..f9cc101 100644 --- a/index.html +++ b/index.html @@ -856,7 +856,7 @@ function setupSocket() { return `
-
API token
free at socket.dev → Settings → API Tokens. Kept in your login Keychain (item pkgdash-socket-token), never in pkgdash's own files
+
API token
free at socket.dev → Settings → API Tokens. It only needs the packages:list scope (the test below can't check scopes: a missing one shows up as an HTTP 403 in the next scan's log). Kept in your login Keychain (item pkgdash-socket-token), never in pkgdash's own files
checking the Keychain…
${settingRows(["socket_check"])} From c48a1117cc3bd19333531fc9c19a84fa7fc390ed Mon Sep 17 00:00:00 2001 From: Evan Date: Tue, 20 Oct 2026 10:10:19 -0400 Subject: [PATCH 3/3] Show install.sh's progress and fail with a clear message - A "==>" line before each step, dots while waiting for the old service to stop, and a note when the menu bar app is being built (swiftc can take a minute) or left as it is - Check for Apple Silicon, the Xcode Command Line Tools and swiftc before stopping anything, so a failed install leaves a running pkgdash alone - codesign errors are printed instead of exiting silently under set -e, and the unsigned binary is removed so the next run rebuilds it - uninstall says where the kept state is --- install.sh | 49 ++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 42 insertions(+), 7 deletions(-) diff --git a/install.sh b/install.sh index 8040280..7e972dc 100755 --- a/install.sh +++ b/install.sh @@ -10,25 +10,54 @@ DIR=$(cd "$(dirname "$0")" && pwd) AGENTS="$HOME/Library/LaunchAgents" STATE="$HOME/.local/share/pkgdash" APP="$HOME/Applications/pkgdash.app" +BIN="$APP/Contents/MacOS/pkgdash" GUI="gui/$(id -u)" +step() { printf '==> %s\n' "$*"; } +fail() { printf 'pkgdash: %s\n' "$*" >&2; exit 1; } + +# The menu bar app is only rebuilt when its source (or this script) changes: it's ad-hoc signed, so every +# rebuild looks like a new app to macOS and it would ask for access to the projects folder again. +NEED_BUILD=0 +if [ ! -x "$BIN" ] || [ "$DIR/menubar/PkgdashMenu.swift" -nt "$BIN" ] || [ "$0" -nt "$BIN" ]; then + NEED_BUILD=1 +fi + +# check what's needed before stopping anything, so a failed install leaves a running pkgdash alone +if [ "${1:-}" != "uninstall" ]; then + [ "$(uname -m)" = arm64 ] || fail "needs a Mac with Apple Silicon (this one is $(uname -m))" + if ! xcode-select -p >/dev/null 2>&1 || ! /usr/bin/python3 -c "" >/dev/null 2>&1; then + fail "needs the Xcode Command Line Tools for /usr/bin/python3 and swiftc: run xcode-select --install, then try again" + fi + if [ "$NEED_BUILD" = 1 ] && ! xcrun --find swiftc >/dev/null 2>&1; then + fail "can't find swiftc to build the menu bar app: run xcode-select --install, then try again" + fi +fi + +step "Stopping the running service and menu bar app (if any)" launchctl bootout "$GUI/$LABEL" 2>/dev/null || true launchctl bootout "$GUI/$MENU_LABEL" 2>/dev/null || true # bootout returns before the jobs are gone, and loading them again too soon fails ("Bootstrap failed: 5") +waited=0 for _ in $(seq 30); do { launchctl print "$GUI/$LABEL" || launchctl print "$GUI/$MENU_LABEL"; } >/dev/null 2>&1 || break + printf '.' + waited=1 sleep 1 done +if [ "$waited" = 1 ]; then echo; fi if [ "${1:-}" = "uninstall" ]; then + step "Removing the LaunchAgents and $APP" rm -f "$AGENTS/$LABEL.plist" "$AGENTS/$MENU_LABEL.plist" rm -rf "$APP" - echo "pkgdash removed" + echo "pkgdash removed (state in $STATE is kept)" exit 0 fi # --- dashboard service ------------------------------------------------------- # launchd agents can't read protected folders like ~/Documents (TCC), so run from a copy in the state dir +step "Installing the dashboard service into $STATE/app" mkdir -p "$STATE/app" cp "$DIR/server.py" "$DIR/index.html" "$STATE/app/" cat > "$AGENTS/$LABEL.plist" < "$AGENTS/$LABEL.plist" < EOF +step "Starting the dashboard service" launchctl bootstrap "$GUI" "$AGENTS/$LABEL.plist" # --- menu bar icon ----------------------------------------------------------- -# Only rebuilt when its source (or this script) changes: it's ad-hoc signed, so every rebuild -# looks like a new app to macOS and it would ask for access to the projects folder again. -BIN="$APP/Contents/MacOS/pkgdash" -if [ ! -x "$BIN" ] || [ "$DIR/menubar/PkgdashMenu.swift" -nt "$BIN" ] || [ "$0" -nt "$BIN" ]; then +if [ "$NEED_BUILD" = 1 ]; then +step "Building the menu bar app (swiftc, can take a minute)" mkdir -p "$APP/Contents/MacOS" swiftc -O -target arm64-apple-macos13 -framework AppKit \ - -o "$BIN" "$DIR/menubar/PkgdashMenu.swift" + -o "$BIN" "$DIR/menubar/PkgdashMenu.swift" || fail "swiftc couldn't build menubar/PkgdashMenu.swift (see above)" cat > "$APP/Contents/Info.plist" < @@ -82,7 +110,13 @@ cat > "$APP/Contents/Info.plist" </dev/null 2>&1 +step "Signing the menu bar app (ad-hoc, hardened runtime)" +if ! out=$(codesign --force --options runtime --sign - "$APP" 2>&1); then + rm -f "$BIN" # so the next run rebuilds and signs it again + fail "codesign failed: $out" +fi +else +step "Menu bar app unchanged, not rebuilding it" fi # KeepAlive only on crash, so "Quit Menu Icon" stays quit until next login cat > "$AGENTS/$MENU_LABEL.plist" < "$AGENTS/$MENU_LABEL.plist" < EOF +step "Starting the menu bar app" launchctl bootstrap "$GUI" "$AGENTS/$MENU_LABEL.plist" echo "pkgdash running → http://127.0.0.1:7878 (menu bar icon: left-click opens, right-click for menu)"