Commit 6fa8639

HPCesia <me@hpcesia.com>
2026-09-03 16:10:16
ci: add update script to every maintained package
Assisted-by: opencode:deepseek-v4-flash
1 parent 3dd56e2
pkgs/elio/default.nix
@@ -2,6 +2,7 @@
   lib,
   rustPlatform,
   fetchFromGitHub,
+  nix-update-script,
 }:
 rustPlatform.buildRustPackage (finalAttrs: {
   pname = "elio";
@@ -17,6 +18,8 @@ rustPlatform.buildRustPackage (finalAttrs: {
   cargoHash = "sha256-gnUPukVYevg6JpPIVqDt/9LMtb3FmC8NNgJH6AU8fBE=";
   doCheck = false;
 
+  passthru.updateScript = nix-update-script {};
+
   meta = {
     description = "Snappy, batteries-included terminal file manager with rich previews, inline images, bulk actions, and trash support";
     homepage = "https://github.com/elio-fm/elio";
pkgs/gitmal/default.nix
@@ -2,6 +2,7 @@
   lib,
   buildGoModule,
   fetchFromGitHub,
+  nix-update-script,
 }:
 buildGoModule {
   pname = "gitmal";
@@ -16,6 +17,8 @@ buildGoModule {
 
   vendorHash = "sha256-12kkN1rh9OWG8YIr9KyHtm1TFJQPUtSpD6ub8zokAhQ=";
 
+  passthru.updateScript = nix-update-script {};
+
   meta = {
     description = " A static page generator for repos";
     homepage = "https://github.com/antonmedv/gitmal";
pkgs/harmonoid/default.nix
@@ -14,6 +14,8 @@ stdenvNoCC.mkDerivation rec {
   pname = "harmonoid";
   version = "0.3.32";
 
+  passthru.updateScript = [(toString ./update.sh)];
+
   src =
     if stdenvNoCC.isAarch64
     then
pkgs/harmonoid/update.sh
@@ -0,0 +1,30 @@
+#!/usr/bin/env nix-shell
+#!nix-shell -i bash -p curl -p jq -p nix
+# shellcheck shell=bash
+set -euo pipefail
+
+REPO_URL="https://github.com/alexmercerind2/harmonoid-releases"
+
+SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd)
+PKG_FILE="$SCRIPT_DIR/default.nix"
+PKG_NAME="harmonoid"
+
+OLD_VERSION=$(sed -n 's/.*version = "\([^"]*\)";.*/\1/p' "$PKG_FILE" | head -1)
+NEW_VERSION=$(curl -fsSL "${REPO_URL}/releases.atom" | sed -n 's#.*releases/tag/v\([^"<]*\).*#\1#p' | head -1)
+
+if [ "$NEW_VERSION" = "$OLD_VERSION" ]; then
+	exit 0
+fi
+
+X86_64_HASH=$(nix store prefetch-file --json "${REPO_URL}/releases/download/v${NEW_VERSION}/harmonoid-linux-x86_64.tar.gz" | jq -r .hash)
+AARCH64_HASH=$(nix store prefetch-file --json "${REPO_URL}/releases/download/v${NEW_VERSION}/harmonoid-linux-aarch64.tar.gz" | jq -r .hash)
+
+sed -i "s/version = \"$OLD_VERSION\";/version = \"$NEW_VERSION\";/" "$PKG_FILE"
+
+awk -v x86="$X86_64_HASH" -v arm="$AARCH64_HASH" '
+  /harmonoid-linux-aarch64/ { print; getline; sub(/hash = "[^"]*"/, "hash = \"" arm "\""); print; next }
+  /harmonoid-linux-x86_64/ { print; getline; sub(/hash = "[^"]*"/, "hash = \"" x86 "\""); print; next }
+  { print }
+' "$PKG_FILE" >"$PKG_FILE.tmp" && mv "$PKG_FILE.tmp" "$PKG_FILE"
+
+echo "$PKG_NAME: $OLD_VERSION -> $NEW_VERSION"
pkgs/helix-plugins/fcitx-focus.nix
@@ -2,6 +2,7 @@
   lib,
   buildHelixPluginWithNative,
   fetchFromGitHub,
+  nix-update-script,
 }:
 buildHelixPluginWithNative {
   pname = "helix-fcitx-focus";
@@ -16,6 +17,10 @@ buildHelixPluginWithNative {
 
   cargoHash = "sha256-LVNwdhn2h50un3r391JwPJiPhlTmRPl863rqB595RKo=";
 
+  passthru.updateScript = nix-update-script {
+    extraArgs = ["--version" "branch"];
+  };
+
   meta = {
     description = "Steel native module for Helix fcitx5 focus and mode switching";
     homepage = "https://github.com/mtul0729/helix-fcitx-focus";
pkgs/helix-plugins/smith.nix
@@ -2,6 +2,7 @@
   lib,
   buildHelixPlugin,
   fetchFromGitHub,
+  nix-update-script,
 }:
 buildHelixPlugin {
   pname = "smith.hx";
@@ -14,6 +15,10 @@ buildHelixPlugin {
     hash = "sha256-ccLoMI9E3v8BLPLKtrjg1c0UK5VgdBH6L/d59HaQ4yg=";
   };
 
+  passthru.updateScript = nix-update-script {
+    extraArgs = ["--version" "branch"];
+  };
+
   meta = {
     description = "A declarative Helix plugin manager";
     homepage = "https://github.com/kn66/smith.hx";
pkgs/kelivo/default.nix
@@ -8,6 +8,7 @@
   gst_all_1,
   keybinder3,
   libappindicator,
+  nix-update-script,
 }:
 flutter344.buildFlutterApplication (finalAttrs: {
   pname = "kelivo";
@@ -20,6 +21,8 @@ flutter344.buildFlutterApplication (finalAttrs: {
     hash = "sha256-SZ8EbO00zkQpTOzvsfsV8KskajgDERzNyeWspHo5LIs=";
   };
 
+  passthru.updateScript = nix-update-script {};
+
   pubspecLock = lib.importJSON ./pubspec.lock.json;
 
   nativeBuildInputs = [
pkgs/localbooru-bin/default.nix
@@ -8,6 +8,7 @@
   makeWrapper,
   wrapGAppsHook3,
   mpv-unwrapped,
+  nix-update-script,
   xdg-user-dirs,
   zenity,
 }:
@@ -15,6 +16,8 @@ stdenv.mkDerivation rec {
   pname = "localbooru-bin";
   version = "1.6.1";
 
+  passthru.updateScript = nix-update-script {};
+
   src = fetchurl {
     url = "https://github.com/resucutie/localbooru/releases/download/${version}/localbooru-linux.deb";
     hash = "sha256-N37XdomSJFu3UPYcTmtihFwwGnbb5JCSdRDMVdlsP+8=";
pkgs/miaomiaowu/default.nix
@@ -5,6 +5,8 @@
   callPackage,
   miaomiaowu-frontend ? callPackage ./frontend.nix {},
 }: let
+  updateScript = [(toString ./update.sh)];
+
   version = "0.8.3";
 
   src = fetchFromGitHub {
@@ -17,6 +19,7 @@ in
   buildGoModule (finalAttrs: {
     pname = "miaomiaowu";
     inherit version src;
+    passthru = {inherit updateScript;};
 
     vendorHash = "sha256-2w8sBHpRaSv2RqwNRxNE8Q2O1A0b96WdHsugrWrSixE=";
 
pkgs/miaomiaowu/update.sh
@@ -0,0 +1,43 @@
+#!/usr/bin/env nix-shell
+#!nix-shell -i bash -p nix-update -p jq -p nix
+# shellcheck shell=bash
+set -euo pipefail
+
+SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd)
+PKG_FILE="$SCRIPT_DIR/default.nix"
+FRONTEND_FILE="$SCRIPT_DIR/frontend.nix"
+PKG_NAME="miaomiaowu"
+
+OLD_VERSION=$(sed -n 's/.*version = "\([^"]*\)";.*/\1/p' "$PKG_FILE" | head -1)
+
+nix-update "$UPDATE_NIX_ATTR_PATH"
+
+NEW_VERSION=$(sed -n 's/.*version = "\([^"]*\)";.*/\1/p' "$PKG_FILE" | head -1)
+
+if [ "$NEW_VERSION" = "$OLD_VERSION" ]; then
+	exit 0
+fi
+
+NEW_SRC_HASH=$(sed -n 's/.*hash = "\(sha256-[^"]*\)";.*/\1/p' "$PKG_FILE" | head -1)
+
+sed -i \
+	-e "s/version = \"$OLD_VERSION\";/version = \"$NEW_VERSION\";/" \
+	-e "s|hash = \"sha256-[^\"]*\";|hash = \"$NEW_SRC_HASH\";|" \
+	"$FRONTEND_FILE"
+
+NPM_HASH=$(nix-build --no-out-link --expr "
+  let pkgs = import <nixpkgs> {};
+      frontend = import \"$FRONTEND_FILE\" {
+        inherit (pkgs) lib buildNpmPackage fetchFromGitHub jq;
+      };
+  in frontend.npmDeps.overrideAttrs (_: { outputHash = \"\"; outputHashAlgo = \"sha256\"; })
+" 2>&1 | grep -o 'got:.*sha256-[A-Za-z0-9+/=]*' | tail -1 | sed 's/got:.*\(sha256-[A-Za-z0-9+/=]*\)/\1/' || true)
+
+if [ -z "$NPM_HASH" ]; then
+	echo "failed to extract npmDepsHash" >&2
+	exit 1
+fi
+
+sed -i "s|npmDepsHash = \"sha256-[^\"]*\";|npmDepsHash = \"$NPM_HASH\";|" "$FRONTEND_FILE"
+
+echo "$PKG_NAME: $OLD_VERSION -> $NEW_VERSION"
pkgs/mo2-lint/default.nix
@@ -8,6 +8,7 @@
   wineWow64Packages,
   unzip,
   makeWrapper,
+  nix-update-script,
   protontricks,
 }: let
   version = "7.0.0-rc7";
@@ -146,6 +147,15 @@
     inherit version;
     src = mo2-lint-src;
 
+    passthru.updateScript = nix-update-script {
+      extraArgs = [
+        "--version"
+        "unstable"
+        "--version-regex"
+        "^([0-9.]+-rc[0-9]+)$"
+      ];
+    };
+
     nativeBuildInputs = [makeWrapper];
 
     postPatch = ''
pkgs/musly-player/default.nix
@@ -9,10 +9,15 @@
   copyDesktopItems,
   makeDesktopItem,
 }:
+let
+  updateScript = [(toString ./update.sh)];
+in
 flutter341.buildFlutterApplication rec {
   pname = "musly-player";
   version = "2.0.2";
 
+  passthru = {inherit updateScript;};
+
   src = fetchFromGitHub {
     owner = "dddevid";
     repo = "Musly";
pkgs/musly-player/update.sh
@@ -0,0 +1,58 @@
+#!/usr/bin/env nix-shell
+#!nix-shell -i bash -p curl -p jq -p nix -p flutter341 -p python3 -p python3Packages.pyyaml
+# shellcheck shell=bash
+set -euo pipefail
+
+REPO_URL="https://github.com/dddevid/Musly"
+
+SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd)
+PKG_FILE="$SCRIPT_DIR/default.nix"
+PKG_NAME="musly-player"
+
+OLD_VERSION=$(sed -n 's/.*version = "\([^"]*\)";.*/\1/p' "$PKG_FILE" | head -1)
+NEW_VERSION=$(curl -fsSL "${REPO_URL}/releases.atom" | sed -n 's#.*releases/tag/v\([^"<]*\).*#\1#p' | head -1)
+
+if [ "$NEW_VERSION" = "$OLD_VERSION" ]; then
+	exit 0
+fi
+
+NEW_HASH=$(nix store prefetch-file --json --unpack "${REPO_URL}/archive/refs/tags/v${NEW_VERSION}.tar.gz" | jq -r .hash)
+
+TMPDIR=$(mktemp -d)
+trap 'rm -rf "$TMPDIR"' EXIT
+
+curl -fsSL "${REPO_URL}/archive/refs/tags/v${NEW_VERSION}.tar.gz" | tar xz -C "$TMPDIR" --strip-components=1
+chmod -R +w "$TMPDIR"
+
+cp "$SCRIPT_DIR/pubspec.lock.json" "$TMPDIR/pubspec.lock.json.old"
+
+(
+	cd "$TMPDIR"
+	flutter pub get </dev/null >/dev/null
+)
+
+# flutter writes the YAML pubspec.lock; the package consumes a JSON one.
+python3 - "$TMPDIR/pubspec.lock" <<'PY'
+import json
+import sys
+import yaml
+
+with open(sys.argv[1]) as f:
+    data = yaml.safe_load(f)
+with open(sys.argv[1] + ".json", "w") as f:
+    json.dump(data, f, indent=2)
+PY
+
+if ! cmp -s "$TMPDIR/pubspec.lock.json.old" "$TMPDIR/pubspec.lock.json"; then
+	cp "$TMPDIR/pubspec.lock.json" "$SCRIPT_DIR/pubspec.lock.json"
+fi
+
+sed -i "s/version = \"$OLD_VERSION\";/version = \"$NEW_VERSION\";/" "$PKG_FILE"
+
+awk -v hash="$NEW_HASH" '
+  /src = fetchFromGitHub {/ { in_src = 1 }
+  in_src && /hash = "[^"]*"/ { sub(/hash = "[^"]*"/, "hash = \"" hash "\""); in_src = 0 }
+  { print }
+' "$PKG_FILE" >"$PKG_FILE.tmp" && mv "$PKG_FILE.tmp" "$PKG_FILE"
+
+echo "$PKG_NAME: $OLD_VERSION -> $NEW_VERSION"
pkgs/navibeat/default.nix
@@ -25,6 +25,8 @@ in
   appimageTools.wrapType2 {
     inherit pname version src;
 
+    passthru.updateScript = [(toString ./update.sh)];
+
     extraPkgs = pkgs: [
       pkgs.vlc
     ];
pkgs/navibeat/update.sh
@@ -0,0 +1,30 @@
+#!/usr/bin/env nix-shell
+#!nix-shell -i bash -p curl -p jq -p nix
+# shellcheck shell=bash
+set -euo pipefail
+
+REPO_URL="https://github.com/nenadjokic/navibeat-linux"
+
+SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd)
+PKG_FILE="$SCRIPT_DIR/default.nix"
+PKG_NAME="navibeat"
+
+OLD_VERSION=$(sed -n 's/.*version = "\([^"]*\)";.*/\1/p' "$PKG_FILE" | head -1)
+NEW_VERSION=$(curl -fsSL "${REPO_URL}/releases.atom" | sed -n 's#.*releases/tag/v\([^"<]*\).*#\1#p' | head -1)
+
+if [ "$NEW_VERSION" = "$OLD_VERSION" ]; then
+	exit 0
+fi
+
+X86_64_HASH=$(nix store prefetch-file --json "${REPO_URL}/releases/download/v${NEW_VERSION}/NaviBeat-linux-x86_64-slim.AppImage" | jq -r .hash)
+AARCH64_HASH=$(nix store prefetch-file --json "${REPO_URL}/releases/download/v${NEW_VERSION}/NaviBeat-linux-aarch64-slim.AppImage" | jq -r .hash)
+
+sed -i "s/version = \"$OLD_VERSION\";/version = \"$NEW_VERSION\";/" "$PKG_FILE"
+
+awk -v x86="$X86_64_HASH" -v arm="$AARCH64_HASH" '
+  /NaviBeat-linux-aarch64-slim.AppImage/ { print; getline; sub(/hash = "[^"]*"/, "hash = \"" arm "\""); print; next }
+  /NaviBeat-linux-x86_64-slim.AppImage/ { print; getline; sub(/hash = "[^"]*"/, "hash = \"" x86 "\""); print; next }
+  { print }
+' "$PKG_FILE" >"$PKG_FILE.tmp" && mv "$PKG_FILE.tmp" "$PKG_FILE"
+
+echo "$PKG_NAME: $OLD_VERSION -> $NEW_VERSION"
pkgs/realitlscanner/default.nix
@@ -2,6 +2,7 @@
   lib,
   buildGoModule,
   fetchFromGitHub,
+  nix-update-script,
 }:
 buildGoModule {
   pname = "realitlscanner";
@@ -16,6 +17,8 @@ buildGoModule {
 
   vendorHash = "sha256-qNAGWAWfin8KwlMwLdEFAzGuqkOYDHcHeTXp8hk3tfw=";
 
+  passthru.updateScript = nix-update-script {};
+
   meta = {
     description = "A TLS server certificate scanner for real IP discovery";
     homepage = "https://github.com/XTLS/RealiTLScanner";
pkgs/shimmie2/default.nix
@@ -6,9 +6,14 @@
   shimmie2-unwrapped ? callPackage ./unwrapped.nix {},
   defaultDataDir ? "$HOME/.shimmie2",
 }:
+let
+  updateScript = [(toString ./update.sh)];
+in
 writeShellApplication {
   name = "shimmie2";
 
+  passthru = {inherit updateScript;};
+
   derivationArgs = {
     inherit (shimmie2-unwrapped) meta version;
   };
pkgs/shimmie2/update.sh
@@ -0,0 +1,40 @@
+#!/usr/bin/env nix-shell
+#!nix-shell -i bash -p jq -p nix
+# shellcheck shell=bash
+set -euo pipefail
+
+REPO_URL="https://github.com/shish/shimmie2"
+
+SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd)
+PKG_FILE="$SCRIPT_DIR/unwrapped.nix"
+PKG_NAME="shimmie2"
+
+OLD_VERSION=$(sed -n 's/.*version = "\([^"]*\)";.*/\1/p' "$PKG_FILE" | head -1)
+NEW_VERSION=$(git ls-remote --tags "$REPO_URL" | grep -v '\^{}' | sed 's#.*refs/tags/v##' | grep -v 'refs/tags' | grep -E '^[0-9]' | sort -V | tail -1)
+
+if [ "$NEW_VERSION" = "$OLD_VERSION" ]; then
+	exit 0
+fi
+
+NEW_HASH=$(nix store prefetch-file --json --unpack "${REPO_URL}/archive/refs/tags/v${NEW_VERSION}.tar.gz" | jq -r .hash)
+
+COMPOSER_HASH=$(nix-build --no-out-link --expr "
+  let pkgs = import <nixpkgs> {};
+      unwrapped = import \"$PKG_FILE\" {
+        inherit (pkgs) lib php fetchFromGitHub;
+      };
+  in unwrapped.composerVendor.overrideAttrs (_: { outputHash = \"\"; outputHashAlgo = \"sha256\"; })
+" 2>&1 | grep -o 'got:.*sha256-[A-Za-z0-9+/=]*' | tail -1 | sed 's/got:.*\(sha256-[A-Za-z0-9+/=]*\)/\1/' || true)
+
+if [ -z "$COMPOSER_HASH" ]; then
+	echo "failed to extract composerVendor hash" >&2
+	exit 1
+fi
+
+sed -i \
+	-e "s/version = \"$OLD_VERSION\";/version = \"$NEW_VERSION\";/" \
+	-e "/src = fetchFromGitHub {/,/^    };/ s|hash = \"sha256-[^\"]*\";|hash = \"$NEW_HASH\";|" \
+	-e "s|vendorHash = \"sha256-[^\"]*\";|vendorHash = \"$COMPOSER_HASH\";|" \
+	"$PKG_FILE"
+
+echo "$PKG_NAME: $OLD_VERSION -> $NEW_VERSION"
pkgs/spritz-wine-bin/build-helper.nix
@@ -10,6 +10,7 @@
 stdenvNoCC.mkDerivation (args
   // {
     name = "${name}-bin-${version}";
+    passthru.updateScript = [(toString ./update.sh)];
     installPhase = "cp -r $src $out";
     meta = {
       description = "Spritz-Wine builds for some games ";
pkgs/spritz-wine-bin/update.sh
@@ -0,0 +1,62 @@
+#!/usr/bin/env nix-shell
+#!nix-shell -i bash -p jq -p nix
+# shellcheck shell=bash
+set -euo pipefail
+
+REPO_URL="https://github.com/NelloKudo/spritz-wine"
+
+SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd)
+JSON="$SCRIPT_DIR/version.json"
+
+# stream name -> tag prefix (before the version part)
+declare -A TAG_PATTERNS=(
+	[cachyos]="wine-cachyos-aagl-v"
+	[dwproton]="wine-dwproton-"
+	[tkg]="spritz-wine-"
+)
+
+# tkg additionally used the wine-tkg-aagl-v naming for older releases.
+declare -A EXTRA_TAG_PATTERNS=(
+	[tkg]="wine-tkg-aagl-v"
+)
+
+TMPDIR=$(mktemp -d)
+trap 'rm -rf "$TMPDIR"' EXIT
+
+git ls-remote --tags "$REPO_URL" | grep -v '\^{}' >"$TMPDIR/tags"
+
+UPDATED=""
+for STREAM in "${!TAG_PATTERNS[@]}"; do
+	OLD_VERSION=$(jq -r ".${STREAM} | keys | .[]" "$JSON" | sort -V | tail -1)
+	ASSET_PREFIX=$(jq -r ".${STREAM}[\"$OLD_VERSION\"].url" "$JSON" | sed 's#.*/##' | sed "s/-${OLD_VERSION}-x86_64.tar.xz$//")
+
+	# Gather (version, tag) pairs from all tag namings of this stream.
+	: >"$TMPDIR/versions-$STREAM"
+	for PAT in "${TAG_PATTERNS[$STREAM]}" ${EXTRA_TAG_PATTERNS[$STREAM]:-}; do
+		while read -r SHA TAG; do
+			VER=${TAG#"refs/tags/$PAT"}
+			if [ "$VER" != "$TAG" ]; then
+				printf '%s\t%s\n' "$VER" "${TAG#refs/tags/}" >>"$TMPDIR/versions-$STREAM"
+			fi
+		done <"$TMPDIR/tags"
+	done
+
+	# All versions strictly newer than the current latest, in version order.
+	NEW_VERSIONS=$(
+		cut -f1 "$TMPDIR/versions-$STREAM" | sort -u -V |
+			awk -v old="$OLD_VERSION" '!f { f = ($0 == old) } f && $0 != old'
+	)
+
+	for VER in $NEW_VERSIONS; do
+		TAG=$(awk -F'\t' -v v="$VER" '$1 == v {print $2; exit}' "$TMPDIR/versions-$STREAM")
+		URL="https://github.com/NelloKudo/spritz-wine/releases/download/$TAG/$ASSET_PREFIX-$VER-x86_64.tar.xz"
+		HASH=$(nix store prefetch-file --json --unpack "$URL" | jq -r .hash)
+		jq ".${STREAM}[\"$VER\"] = { url: \"$URL\", hash: \"$HASH\" }" "$JSON" >"$JSON.tmp"
+		mv "$JSON.tmp" "$JSON"
+		UPDATED="$UPDATED $STREAM:$VER"
+	done
+done
+
+if [ -n "$UPDATED" ]; then
+	echo "spritz-wine-bin:$UPDATED"
+fi
pkgs/steelix/default.nix
@@ -57,6 +57,12 @@
     pname = "steelix";
     strictDeps = true;
 
+    passthru =
+      (previousAttrs.passthru or {})
+      // {
+        updateScript = [(toString ./update.sh)];
+      };
+
     meta =
       previousAttrs.meta
       // {
pkgs/steelix/generate_grammars.py
@@ -0,0 +1,155 @@
+#!/usr/bin/env nix-shell
+#! nix-shell -i python3 -p python3 nurl
+"""
+Generate grammar information for Helix editor by parsing languages.toml
+and fetching source information using nurl in parallel.
+"""
+
+import argparse
+import asyncio
+import json
+import os
+import sys
+import tomllib
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any
+
+
+@dataclass
+class Grammar:
+    name: str
+    git_url: str
+    rev: str
+    subpath: str | None = None
+
+
+async def run_nurl(url: str, rev: str, semaphore: asyncio.Semaphore) -> dict[str, Any]:
+    """Run nurl command for a single grammar and return parsed JSON output."""
+    async with semaphore:
+        proc = await asyncio.create_subprocess_exec(
+            "nurl",
+            url,
+            rev,
+            "--json",
+            stdout=asyncio.subprocess.PIPE,
+            stderr=asyncio.subprocess.PIPE,
+        )
+        stdout, stderr = await proc.communicate()
+
+        if proc.returncode != 0:
+            raise RuntimeError(f"nurl failed for {url}@{rev}: {stderr.decode()}")
+
+        return json.loads(stdout.decode())
+
+
+def parse_languages_toml(toml_path: Path) -> list[Grammar]:
+    """Parse languages.toml and extract grammar information."""
+    with open(toml_path, "rb") as f:
+        config = tomllib.load(f)
+
+    grammars = []
+    for grammar in config.get("grammar", []):
+        if "source" not in grammar:
+            continue
+
+        source = grammar["source"]
+        if "git" not in source or "rev" not in source:
+            continue
+
+        grammars.append(
+            Grammar(
+                name=grammar["name"].replace("_", "-"),
+                git_url=source["git"],
+                rev=source["rev"],
+                subpath=source.get("subpath"),
+            )
+        )
+
+    return grammars
+
+
+async def fetch_all_grammars(
+    grammars: list[Grammar], max_parallel: int
+) -> dict[str, Any]:
+    """Fetch nurl information for all grammars in parallel."""
+    semaphore = asyncio.Semaphore(max_parallel)
+    results = {}
+    total = len(grammars)
+    completed = 0
+
+    tasks = []
+    for grammar in grammars:
+        task = run_nurl(grammar.git_url, grammar.rev, semaphore)
+        tasks.append((grammar, task))
+
+    for grammar, task in tasks:
+        try:
+            result = await task
+            results[grammar.name] = {
+                "nurl": result,
+                "subpath": grammar.subpath,
+            }
+            completed += 1
+            print(f"[{completed}/{total}] ✓ {grammar.name}", file=sys.stderr)
+        except Exception as e:
+            completed += 1
+            print(f"[{completed}/{total}] ✗ {grammar.name}: {e}", file=sys.stderr)
+            results[grammar.name] = {"error": str(e)}
+
+    return results
+
+
+async def main():
+    parser = argparse.ArgumentParser(
+        description="Generate grammar information for Helix editor"
+    )
+    parser.add_argument(
+        "languages_toml",
+        type=Path,
+        help="path to languages.toml from Helix repository",
+    )
+    parser.add_argument(
+        "-o",
+        "--output",
+        type=Path,
+        default=Path("grammars.json"),
+        help="output JSON file (default: grammars.json)",
+    )
+    parser.add_argument(
+        "-j",
+        "--jobs",
+        type=int,
+        default=os.cpu_count(),
+        help=f"number of parallel nurl instances (default: {os.cpu_count()})",
+    )
+
+    args = parser.parse_args()
+
+    if not args.languages_toml.exists():
+        print(f"Error: {args.languages_toml} not found", file=sys.stderr)
+        sys.exit(1)
+
+    print(f"Parsing {args.languages_toml}...", file=sys.stderr)
+    grammars = parse_languages_toml(args.languages_toml)
+    print(f"Found {len(grammars)} grammars", file=sys.stderr)
+
+    print(f"Fetching grammar information ({args.jobs} parallel jobs)...", file=sys.stderr)
+    results = await fetch_all_grammars(grammars, args.jobs)
+
+    errors = [name for name, data in results.items() if "error" in data]
+    if errors:
+        print(f"\nFailed grammars ({len(errors)}):", file=sys.stderr)
+        for name in errors:
+            print(f"  - {name}: {results[name]['error']}", file=sys.stderr)
+        sys.exit(1)
+
+    with open(args.output, "w") as f:
+        json.dump(results, f, indent=2)
+        f.write('\n')
+
+    print(f"\nResults written to {args.output}", file=sys.stderr)
+
+
+if __name__ == "__main__":
+    asyncio.run(main())
pkgs/steelix/update.sh
@@ -0,0 +1,56 @@
+#!/usr/bin/env nix-shell
+#!nix-shell -i bash -p git -p jq -p nix -p python3 -p nurl
+# shellcheck shell=bash
+set -euo pipefail
+
+REPO_URL="https://github.com/mattwparas/helix"
+BRANCH="steel-event-system"
+
+SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd)
+PKG_FILE="$SCRIPT_DIR/unwrapped.nix"
+OLD_VERSION=$(sed -n 's/.*version = "\([^"]*\)";.*/\1/p' "$PKG_FILE" | head -1)
+PKG_NAME="steelix"
+
+OLD_REV=$(sed -n 's/.*rev = "\([0-9a-f]\{40\}\)".*/\1/p' "$PKG_FILE")
+NEW_REV=$(git ls-remote "$REPO_URL" "refs/heads/$BRANCH" | awk '{print $1}')
+
+if [ "$NEW_REV" = "$OLD_REV" ]; then
+	exit 0
+fi
+
+TMPDIR=$(mktemp -d)
+trap 'rm -rf "$TMPDIR"' EXIT
+
+git clone --depth 1 --branch "$BRANCH" "$REPO_URL" "$TMPDIR/repo"
+NEW_DATE=$(git -C "$TMPDIR/repo" show -s --format=%cs HEAD)
+
+NEW_HASH=$(nix store prefetch-file --json --unpack "https://github.com/mattwparas/helix/archive/$NEW_REV.tar.gz" | jq -r .hash)
+NEW_VERSION="0-unstable-$NEW_DATE"
+
+sed -i \
+	-e "s/version = \"[^\"]*\";/version = \"$NEW_VERSION\";/" \
+	-e "s/rev = \"$OLD_REV\";/rev = \"$NEW_REV\";/" \
+	-e "/src = fetchFromGitHub {/,/^    };/ s|hash = \"sha256-[^\"]*\";|hash = \"$NEW_HASH\";|" \
+	"$PKG_FILE"
+
+CARGO_DEPS_HASH=$(nix-build --no-out-link --expr "
+  let pkgs = import <nixpkgs> {};
+      unwrapped = import \"$PKG_FILE\" {
+        inherit (pkgs) rustPlatform fetchFromGitHub fetchpatch helix-unwrapped;
+      };
+  in unwrapped.cargoDeps.vendorStaging.overrideAttrs (_: { outputHash = \"\"; outputHashAlgo = \"sha256\"; })
+" 2>&1 | grep -o 'got:.*sha256-[A-Za-z0-9+/=]*' | tail -1 | sed 's/got:.*\(sha256-[A-Za-z0-9+/=]*\)/\1/' || true)
+
+if [ -z "$CARGO_DEPS_HASH" ]; then
+	echo "failed to extract cargoDeps hash" >&2
+	exit 1
+fi
+
+awk -v hash="$CARGO_DEPS_HASH" '
+  /inherit \(finalAttrs\) src pname version;/ { print; getline; sub(/hash = "[^"]*"/, "hash = \"" hash "\""); print; next }
+  { print }
+' "$PKG_FILE" >"$PKG_FILE.tmp" && mv "$PKG_FILE.tmp" "$PKG_FILE"
+
+python3 "$SCRIPT_DIR/generate_grammars.py" "$TMPDIR/repo/languages.toml" -o "$SCRIPT_DIR/grammars.json"
+
+echo "$PKG_NAME: $OLD_VERSION -> $NEW_VERSION"
pkgs/stinkpot/default.nix
@@ -13,6 +13,8 @@ buildGoModule {
     hash = "sha256-HwBtYIwtj5HkUwAv5haoei0Be6dXdLKilbA/A76L46c=";
   };
 
+  passthru.updateScript = [(toString ./update.sh)];
+
   vendorHash = "sha256-IVPACl1oWnBKGzcXvG5gzev8MwhzIKNI7zwEKJjhFc8=";
 
   env.CGO_ENABLED = "0";
pkgs/stinkpot/update.sh
@@ -0,0 +1,36 @@
+#!/usr/bin/env nix-shell
+#!nix-shell -i bash -p git -p jq -p nix
+# shellcheck shell=bash
+set -euo pipefail
+
+REPO_URL="https://tangled.org/did:plc:wqstj3k5tslmm246baaf3tpa"
+ARCHIVE_URL="${REPO_URL}/archive"
+
+SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd)
+PKG_FILE="$SCRIPT_DIR/default.nix"
+OLD_VERSION=$(sed -n 's/.*version = "\([^"]*\)";.*/\1/p' "$PKG_FILE" | head -1)
+PKG_NAME="stinkpot"
+
+OLD_REV=$(sed -n 's/.*rev = "\([0-9a-f]\{40\}\)".*/\1/p' "$PKG_FILE")
+NEW_REV=$(git ls-remote "$REPO_URL" HEAD | awk '{print $1}')
+
+if [ "$NEW_REV" = "$OLD_REV" ]; then
+	exit 0
+fi
+
+TMPDIR=$(mktemp -d)
+trap 'rm -rf "$TMPDIR"' EXIT
+
+git clone --depth 1 "$REPO_URL" "$TMPDIR/repo"
+NEW_DATE=$(git -C "$TMPDIR/repo" show -s --format=%cs HEAD)
+
+NEW_HASH=$(nix store prefetch-file --json --unpack "$ARCHIVE_URL/$NEW_REV" | jq -r .hash)
+NEW_VERSION="0-unstable-$NEW_DATE"
+
+sed -i \
+	-e "s/version = \"[^\"]*\";/version = \"$NEW_VERSION\";/" \
+	-e "s/rev = \"$OLD_REV\";/rev = \"$NEW_REV\";/" \
+	-e "s|hash = \"sha256-[^\"]*\";|hash = \"$NEW_HASH\";|" \
+	"$PKG_FILE"
+
+echo "$PKG_NAME: $OLD_VERSION -> $NEW_VERSION"
pkgs/sylvakru/default.nix
@@ -10,10 +10,15 @@
   copyDesktopItems,
   makeDesktopItem,
 }:
+let
+  updateScript = [(toString ./update.sh)];
+in
 flutter344.buildFlutterApplication rec {
   pname = "sylvakru";
   version = "3.6.0";
 
+  passthru = {inherit updateScript;};
+
   src = fetchFromGitHub {
     owner = "AfalpHy";
     repo = "sylvakru";
pkgs/sylvakru/update.sh
@@ -0,0 +1,84 @@
+#!/usr/bin/env nix-shell
+#!nix-shell -i bash -p curl -p jq -p nix -p nix-prefetch-git -p flutter344
+# shellcheck shell=bash
+set -euo pipefail
+
+REPO_URL="https://github.com/AfalpHy/sylvakru"
+
+SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd)
+PKG_FILE="$SCRIPT_DIR/default.nix"
+PKG_NAME="sylvakru"
+
+OLD_VERSION=$(sed -n 's/.*version = "\([^"]*\)";.*/\1/p' "$PKG_FILE" | head -1)
+NEW_VERSION=$(curl -fsSL "${REPO_URL}/releases.atom" | sed -n 's#.*releases/tag/v\([^"<]*\).*#\1#p' | head -1)
+
+if [ "$NEW_VERSION" = "$OLD_VERSION" ]; then
+	exit 0
+fi
+
+NEW_HASH=$(nix store prefetch-file --json --unpack "${REPO_URL}/archive/refs/tags/v${NEW_VERSION}.tar.gz" | jq -r .hash)
+
+TMPDIR=$(mktemp -d)
+trap 'rm -rf "$TMPDIR"' EXIT
+
+curl -fsSL "${REPO_URL}/archive/refs/tags/v${NEW_VERSION}.tar.gz" | tar xz -C "$TMPDIR" --strip-components=1
+chmod -R +w "$TMPDIR"
+
+cp "$SCRIPT_DIR/pubspec.lock.json" "$TMPDIR/pubspec.lock.json.old"
+
+(
+	cd "$TMPDIR"
+	flutter pub get >/dev/null
+)
+
+LOCKFILE_CHANGED=false
+if ! cmp -s "$TMPDIR/pubspec.lock.json.old" "$TMPDIR/pubspec.lock.json"; then
+	LOCKFILE_CHANGED=true
+	cp "$TMPDIR/pubspec.lock.json" "$SCRIPT_DIR/pubspec.lock.json"
+fi
+
+sed -i "s/version = \"$OLD_VERSION\";/version = \"$NEW_VERSION\";/" "$PKG_FILE"
+
+awk -v hash="$NEW_HASH" '
+  /src = fetchFromGitHub {/ { in_src = 1 }
+  in_src && /hash = "[^"]*"/ { sub(/hash = "[^"]*"/, "hash = \"" hash "\""); in_src = 0 }
+  { print }
+' "$PKG_FILE" >"$PKG_FILE.tmp" && mv "$PKG_FILE.tmp" "$PKG_FILE"
+
+if [ "$LOCKFILE_CHANGED" = true ]; then
+	# media-kit family shares one hash via the media-kit-hash binding.
+	MEDIA_KIT_URL=$(jq -r '.packages.media_kit.description.url' "$TMPDIR/pubspec.lock.json")
+	MEDIA_KIT_REV=$(jq -r '.packages.media_kit.description."resolved-ref"' "$TMPDIR/pubspec.lock.json")
+	for FAM in media_kit_libs_android_audio media_kit_libs_ios_audio media_kit_libs_macos_audio media_kit_libs_windows_audio; do
+		FAM_URL=$(jq -r ".packages.$FAM.description.url" "$TMPDIR/pubspec.lock.json")
+		FAM_REV=$(jq -r ".packages.$FAM.description.\"resolved-ref\"" "$TMPDIR/pubspec.lock.json")
+		if [ "$FAM_URL" != "$MEDIA_KIT_URL" ] || [ "$FAM_REV" != "$MEDIA_KIT_REV" ]; then
+			echo "media-kit family diverged; manual update required" >&2
+			exit 1
+		fi
+	done
+	MEDIA_KIT_HASH=$(nix-prefetch-git --url "$MEDIA_KIT_URL" --rev "$MEDIA_KIT_REV" --quiet 2>/dev/null | jq -r .sha256 | nix hash convert --hash-algo sha256 --to sri)
+	sed -i "s|media-kit-hash = \"sha256-[^\"]*\";|media-kit-hash = \"$MEDIA_KIT_HASH\";|" "$PKG_FILE"
+
+	while IFS=$'\t' read -r KEY URL REV; do
+		case "$KEY" in
+		media_kit | media_kit_libs_*)
+			continue
+			;;
+		esac
+		HASH=$(nix-prefetch-git --url "$URL" --rev "$REV" --quiet 2>/dev/null | jq -r .sha256 | nix hash convert --hash-algo sha256 --to sri)
+		sed -i "s|\($KEY = \)\"sha256-[^\"]*\"|\1\"$HASH\"|" "$PKG_FILE"
+	done < <(jq -r '.packages | to_entries[] | select(.value.source == "git") | [.key, .value.description.url, .value.description."resolved-ref"] | @tsv' "$TMPDIR/pubspec.lock.json")
+
+	RIVE_VERSION=$(jq -r '.packages.rive_native.version // empty' "$TMPDIR/pubspec.lock.json")
+	if [ -n "$RIVE_VERSION" ]; then
+		RIVE_URL="https://rive-flutter-artifacts.rive.app/rive_native_versions/${RIVE_VERSION//+/%2B}/rive_native_artifacts_linux.zip"
+		RIVE_HASH=$(nix store prefetch-file --json --hash-algo sha512 "$RIVE_URL" | jq -r .hash)
+		sed -i \
+			-e "s|rive_native_versions/[^/]*/|rive_native_versions/${RIVE_VERSION//+/%2B}/|" \
+			-e "s|hash = \"sha512-[^\"]*\";|hash = \"$RIVE_HASH\";|" \
+			"$PKG_FILE"
+	fi
+fi
+
+echo "$PKG_NAME: $OLD_VERSION -> $NEW_VERSION"