diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1bbb50b..d2c2d04 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,10 +68,18 @@ jobs: - run: test ! -d ~/.local/bin/openssl - run: if [[ $(~/.local/bin/node --version) != v20* ]]; then false; fi + # `@1.18` records a `~1.18` pin in the manifest, so `update` must respect + # it and NOT cross to 1.19… - run: ./pkgm.ts i hyperfine@1.18 - - run: ./pkgm.ts outdated | grep hyperfine - run: if pkgx semverator satisfies '>=1.19' "$(hyperfine --version | cut -f 2 -d ' ')"; then false; fi - run: ./pkgm.ts update + - run: if pkgx semverator satisfies '>=1.19' "$(hyperfine --version | cut -f 2 -d ' ')"; then false; fi + # …until the pin is widened to `*`, after which `update` tracks latest. + # `-i.bak` (not bare `-i`) so it's portable: GNU and BSD/macOS sed both + # take the suffix here; the leftover .bak file is harmless. + - run: sed -i.bak 's/= "~1.18"/= "*"/' "${XDG_CONFIG_HOME:-$HOME/.config}/pkgm/manifest.toml" + - run: ./pkgm.ts outdated | grep hyperfine + - run: ./pkgm.ts update - run: pkgx semverator satisfies '>=1.19' "$(hyperfine --version | cut -f 2 -d ' ')" # TODO pending: https://github.com/pkgxdev/pantry/issues/8487 @@ -124,6 +132,13 @@ jobs: - name: sudo install drops privileges and overrides HOME run: | set -eux + # The pkgm.ts shebang makes pkgx fetch a private deno (and unzip, to + # unpack deno’s .zip) the first time it runs. Under sudo that bootstrap + # is the kernel-invoked `pkgx … deno run` executing as root with + # HOME=/root, so it lands in /root/.pkgx before any pkgm code runs — + # pkgm can’t relocate it. Warm it before the marker so the checks below + # scope only to what the install itself creates. + sudo ./pkgm.ts --version # marker to scope ownership checks to files created by this install touch /tmp/pkgm-sudo-marker sudo ./pkgm.ts i hyperfine @@ -175,3 +190,90 @@ jobs: # crashing when only the sudo-only pkgx remains reachable. test $rc -eq 0 test -x /usr/local/bin/gum + + # Exercises the manifest behaviour added for pkgxdev/pkgm#88. macOS mirrors + # where the issue was reported and avoids the linux rpath caveats above. + manifest: + runs-on: macos-latest + env: + MANIFEST: ${{ github.workspace }}/.config/pkgm/manifest.toml + XDG_CONFIG_HOME: ${{ github.workspace }}/.config + steps: + - uses: actions/checkout@v4 + - uses: pkgxdev/setup@v4 + + # install records intent (requested → major-lock) and marks deps `dep`. + - name: install records intent and deps + run: | + set -eux + ./pkgm.ts i curl + test -f "$MANIFEST" + cat "$MANIFEST" + grep -qE '"curl.se" = "\^[0-9]' "$MANIFEST" # requested → ^major + grep -q '= "dep"' "$MANIFEST" # transitive deps → dep + # no blank lines between entries (only the one after the header) + test "$(grep -c '^$' "$MANIFEST")" -eq 1 + + # deleting the manifest then reinstalling recreates it. + - name: manifest is recreated + run: | + set -eux + rm -f "$MANIFEST" + ./pkgm.ts i curl + test -f "$MANIFEST" + grep -q '= "dep"' "$MANIFEST" + + # uninstall drops the package's entry. + - name: uninstall removes the entry + run: | + set -eux + ./pkgm.ts rm curl + if grep -q '"curl.se"' "$MANIFEST"; then + echo "curl.se was not removed"; cat "$MANIFEST"; exit 1 + fi + + # issue #88: conflicting transitive deps must never surface as an uncaught + # exception — in either install order. + - name: node then uv keeps outdated/update alive + run: | + set -eux + rm -rf "$HOME/.local" "$XDG_CONFIG_HOME/pkgm" + ./pkgm.ts i node + ./pkgm.ts i uv + out=$(./pkgm.ts outdated 2>&1 || true) + echo "$out" + if echo "$out" | grep -q "Uncaught"; then + echo "outdated raised an uncaught exception"; exit 1 + fi + - name: uv then node keeps outdated/update alive + run: | + set -eux + rm -rf "$HOME/.local" "$XDG_CONFIG_HOME/pkgm" + ./pkgm.ts i uv + ./pkgm.ts i node + out=$(./pkgm.ts outdated 2>&1 || true) + echo "$out" + if echo "$out" | grep -q "Uncaught"; then + echo "outdated raised an uncaught exception"; exit 1 + fi + + # #88 root cause: a later install must resolve against the whole requested + # set (via the manifest), so python's strict zlib pin (pulled in by uv) + # wins in either order — node, whose constraint is loose, must not relink + # zlib to whatever it picked in isolation. derived from uv, not hardcoded, + # so it survives the pantry moving the pin. + - name: shared dep settles on the strict pin in either order + run: | + set -eux + zver() { + basename "$(dirname "$(dirname "$(readlink "$HOME/.local/lib/libz.dylib")")")" + } + reset() { rm -rf "$HOME/.local" "$XDG_CONFIG_HOME/pkgm"; } + # the version uv (→ python) requires; this must win throughout. + reset; ./pkgm.ts i uv >/dev/null + pin=$(zver); echo "uv pins zlib to $pin" + ./pkgm.ts i node >/dev/null + test "$(zver)" = "$pin" # adding node must not relink zlib + # other order must converge to the same pin + reset; ./pkgm.ts i node >/dev/null; ./pkgm.ts i uv >/dev/null + test "$(zver)" = "$pin" diff --git a/deno.lock b/deno.lock new file mode 100644 index 0000000..cd19c00 --- /dev/null +++ b/deno.lock @@ -0,0 +1,109 @@ +{ + "version": "5", + "specifiers": { + "jsr:@std/bytes@^1.0.6": "1.0.6", + "jsr:@std/cli@1": "1.0.30", + "jsr:@std/collections@^1.1.3": "1.2.0", + "jsr:@std/crypto@1": "1.1.0", + "jsr:@std/encoding@1": "1.0.10", + "jsr:@std/fs@1": "1.0.24", + "jsr:@std/internal@^1.0.14": "1.0.14", + "jsr:@std/io@0.225": "0.225.3", + "jsr:@std/path@1": "1.1.5", + "jsr:@std/path@^1.1.5": "1.1.5", + "jsr:@std/toml@1": "1.0.11", + "jsr:@std/yaml@1": "1.1.1" + }, + "jsr": { + "@std/bytes@1.0.6": { + "integrity": "f6ac6adbd8ccd99314045f5703e23af0a68d7f7e58364b47d2c7f408aeb5820a" + }, + "@std/cli@1.0.30": { + "integrity": "769446536522d0417d7127ebcabcafac1ab0ce6766d0eb3fca1d36326fe98d13", + "dependencies": [ + "jsr:@std/internal" + ] + }, + "@std/collections@1.2.0": { + "integrity": "47627a21d3a13138b77fd0e4d790ba9d2e603c3510b686cde6b132fe9aa98a88" + }, + "@std/crypto@1.1.0": { + "integrity": "b8d6d0a6377a32b213af2661ed7bf1062d94feac0c57def5526a8e74a95c3ec8" + }, + "@std/encoding@1.0.10": { + "integrity": "8783c6384a2d13abd5e9e87a7ae0520a30e9f56aeeaa3bdf910a3eaaf5c811a1" + }, + "@std/fs@1.0.24": { + "integrity": "f3061b45b81673a2bece689da041df32d174be064c89eb6397fb5718d3fb7877", + "dependencies": [ + "jsr:@std/internal", + "jsr:@std/path@^1.1.5" + ] + }, + "@std/internal@1.0.14": { + "integrity": "291516b3d4c35024d6ffbc0a9df5bf4c64116e05b50012cf846710152d2ffdf7" + }, + "@std/io@0.225.3": { + "integrity": "27b07b591384d12d7b568f39e61dff966b8230559122df1e9fd11cc068f7ddd1", + "dependencies": [ + "jsr:@std/bytes" + ] + }, + "@std/path@1.1.5": { + "integrity": "ccea00982ea28c36becaf6e62f855406c76a8c32d462f66f415bbb7d83a271bc", + "dependencies": [ + "jsr:@std/internal" + ] + }, + "@std/toml@1.0.11": { + "integrity": "e084988b872ca4bad6aedfb7350f6eeed0e8ba88e9ee5e1590621c5b5bb8f715", + "dependencies": [ + "jsr:@std/collections" + ] + }, + "@std/yaml@1.1.1": { + "integrity": "a57665ecf3d17b926380593a56625d8a10cc7281802f1e993b5ebc94a48e71f8" + } + }, + "remote": { + "https://deno.land/x/is_what@v4.1.15/src/index.ts": "e55b975d532b71a0e32501ada85ae3c67993b75dc1047c6d1a2e00368b789af0", + "https://deno.land/x/libpkgx@v0.21.0/mod.ts": "14a69905ffad8064444c02d146008efeb6a0ddf0fe543483839af18e01684f5a", + "https://deno.land/x/libpkgx@v0.21.0/src/deps.ts": "6941cfc0b926d256c067a0ce3546ff7bac5a043c10d64f3ab06fef99d373f49d", + "https://deno.land/x/libpkgx@v0.21.0/src/hooks/useCache.ts": "9f3cc576fabae2caa6aedbf00ab12a59c732be1315471e5a475fef496c1e35ae", + "https://deno.land/x/libpkgx@v0.21.0/src/hooks/useCellar.ts": "c1e264fcb732423734f8c113fc7cb80c97befe8f13ed9d24906328bc5526c72d", + "https://deno.land/x/libpkgx@v0.21.0/src/hooks/useConfig.ts": "57ec8590b6d063a98932eb8f6ebb0e520c0be4e94c228f0d93ea87d01cb5c110", + "https://deno.land/x/libpkgx@v0.21.0/src/hooks/useDownload.ts": "3f9133486008146809508783b977e3480d0a43238ace27f78565fb9679aa9906", + "https://deno.land/x/libpkgx@v0.21.0/src/hooks/useFetch.ts": "ecf29342210b8eceed216e3bb73fcc7ea5b3ea5059686cf344ed190ca42ff682", + "https://deno.land/x/libpkgx@v0.21.0/src/hooks/useInventory.ts": "f459d819ab676a7e3786522d856b7670e994e4a755b0d1609b53c8b4ebe0c959", + "https://deno.land/x/libpkgx@v0.21.0/src/hooks/useMoustaches.ts": "e9166ddace759315782be0f570a4cd63c78e3b85592d59b75ddd33a0e401aa6b", + "https://deno.land/x/libpkgx@v0.21.0/src/hooks/useOffLicense.ts": "1c41ef6882512b67a47fcd1d1c0ce459906d6981a59f6be86d982594a7c26058", + "https://deno.land/x/libpkgx@v0.21.0/src/hooks/usePantry.ts": "113f3ac7cb6565425eebc7f1bd1ee52217f074865b46b452db79cc72d82e4d4a", + "https://deno.land/x/libpkgx@v0.21.0/src/hooks/useShellEnv.ts": "ae2388d3f15d2e03435df23a8392ace21d3d4f0c83b2575a9670ab7badc389c3", + "https://deno.land/x/libpkgx@v0.21.0/src/hooks/useSync.ts": "ea605a0eaa43ab9988d36dd6150e16dd911c4be45b7b0f2add6b236636bd517c", + "https://deno.land/x/libpkgx@v0.21.0/src/hooks/useSyncCache.ts": "30891e9d923f2c2b28f1ba220923221195b8261a4aeea18ef2676d93bd5da10d", + "https://deno.land/x/libpkgx@v0.21.0/src/plumbing/hydrate.ts": "c75f151ed307532ce9c2bf62c61e6478bb1132f95a11b848e02ea2dec08c2ff3", + "https://deno.land/x/libpkgx@v0.21.0/src/plumbing/install.ts": "2a4e19fae70fef7ba0be454fd5b7efed4d7d19a5141d26d3b26124ab792007ed", + "https://deno.land/x/libpkgx@v0.21.0/src/plumbing/link.ts": "0ed6198de737ebeab1704d375c732c9264fb0cfa7f2aedddb90f51d100174a73", + "https://deno.land/x/libpkgx@v0.21.0/src/plumbing/resolve.ts": "9425e0d201ee440a8dc011940046f0bb6d94aa29cd738e1a8c39ca86e55aad41", + "https://deno.land/x/libpkgx@v0.21.0/src/plumbing/which.ts": "f918211e561e56aabf6909e06fa10fa3be06ffebd9e7cc28ce57efef4faff27d", + "https://deno.land/x/libpkgx@v0.21.0/src/porcelain/install.ts": "85caffe3842ab63bf6d59c6c5c9fb93fbc95a0d5652488d93b95d865722b67b9", + "https://deno.land/x/libpkgx@v0.21.0/src/porcelain/run.ts": "55cc9124dca732e2f5557a8c451daebecb109c86b2f4347fa1e433aedf35ab5a", + "https://deno.land/x/libpkgx@v0.21.0/src/types.ts": "dc1a4e6458d11454282f832909838c56f786a26eed54fb8ab5675d6691ebf534", + "https://deno.land/x/libpkgx@v0.21.0/src/utils/Path.ts": "3ce5a1559219adeb65f7df18e2c29c26782a614bdaf635abe1d72a2ce92d2c94", + "https://deno.land/x/libpkgx@v0.21.0/src/utils/error.ts": "b0d3130f5cdfc0cc8ea10f93fea0e7e97d4473ddc9bc527156b0fcf24c7b939c", + "https://deno.land/x/libpkgx@v0.21.0/src/utils/flock.ts": "5fd77f6b53c3a90888cf20a7726e9047aad2c766e4ec2fbf7cf2f916b98d99a4", + "https://deno.land/x/libpkgx@v0.21.0/src/utils/host.ts": "3b9e0d4cb05f9bde0ee8bcb0f8557b0a339f6ef56dfb1f08b2cfa63b44db91ee", + "https://deno.land/x/libpkgx@v0.21.0/src/utils/misc.ts": "a4d7944da07066e5dd2ef289af436dc7f1032aed4272811e9b19ceeed60b8491", + "https://deno.land/x/libpkgx@v0.21.0/src/utils/pkg.ts": "e737cc9a98cd6a2797668c6ef856128692290256a521cc3906bd538410925451", + "https://deno.land/x/libpkgx@v0.21.0/src/utils/read-lines.ts": "6d947ccd5f8e48701ed9c5402b6ac5144df3fce60d666f19b6506edbc36c8367", + "https://deno.land/x/libpkgx@v0.21.0/src/utils/semver.ts": "84884902ec2dcc1d538960dc274a69931723d66252e0531759d2a43df2406b20", + "https://deno.land/x/libpkgx@v0.21.0/vendor/sqlite3@0.10.0/mod.ts": "7ce0a19f9cea3475cc94750ece61c20d857f1c3a279ad38cd029a3f8d9b7b03e", + "https://deno.land/x/libpkgx@v0.21.0/vendor/sqlite3@0.10.0/src/constants.ts": "85fd27aa6e199093f25f5f437052e16fd0e0870b96ca9b24a98e04ddc8b7d006", + "https://deno.land/x/libpkgx@v0.21.0/vendor/sqlite3@0.10.0/src/database.ts": "49569b0f279cfc3e42730002ae789a2694da74deb212e63a4b4e6640dc4d70ba", + "https://deno.land/x/libpkgx@v0.21.0/vendor/sqlite3@0.10.0/src/ffi.ts": "ddffcee178b3e72c45be385efd8b4434f7196cafe45a0046ae68df9af307c7f3", + "https://deno.land/x/libpkgx@v0.21.0/vendor/sqlite3@0.10.0/src/statement.ts": "2be7ffebbb72a031899dbf189972c5596aa73eabfc8a382a1bac9c5c111b0026", + "https://deno.land/x/libpkgx@v0.21.0/vendor/sqlite3@0.10.0/src/util.ts": "19815a492dd8f4c684587238dc20066de11782137de549cd4c9709d1b548247e", + "https://deno.land/x/outdent@v0.8.0/mod.ts": "72630e680dcc36d5ae556fbff6900b12706c81a6fd592345fc98bcc0878fb3ca", + "https://deno.land/x/outdent@v0.8.0/src/index.ts": "6dc3df4108d5d6fedcdb974844d321037ca81eaaa16be6073235ff3268841a22" + } +} diff --git a/pkgm.ts b/pkgm.ts index f3f08c3..749ea25 100755 --- a/pkgm.ts +++ b/pkgm.ts @@ -11,8 +11,26 @@ import { import { dirname, join } from "jsr:@std/path@^1"; import { ensureDir, existsSync, walk } from "jsr:@std/fs@^1"; import { parseArgs } from "jsr:@std/cli@^1"; +import { parse as parse_toml } from "jsr:@std/toml@^1"; const { hydrate } = plumbing; +// declared up here (not beside the other manifest helpers) because the +// top-level command dispatch below runs during module evaluation and can call +// record_install → apply_manifest before a `const` placed lower would be +// initialized (temporal dead zone). +const MANIFEST_HEADER = + `# pkgm manifest — the packages you’ve asked pkgm to manage. +# +# key = a package; value = either: +# a semver range — keep the package within this range; "*" means any (track latest) +# e.g. "*", "^20", ">=1.18", "=1.3.1" +# "dep" — a transitive dependency; let resolution pick the version +# +# pkgm records new packages here for you. edit a value and save to change how a +# package is tracked; on a version conflict pkgm will ask you to edit this file. + +`; + function standardPath() { let path = "/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"; @@ -64,14 +82,23 @@ if (parsedArgs.help || parsedArgs._[0] == "help") { case "install": case "i": { - const rv = await install(args, install_prefix().string); + assert_manifest_resolved(); + const prefix = install_prefix(); + const rv = await install( + await combined_specs(prefix, args), + prefix.string, + ); + await record_install(prefix, args); console.log(rv.join("\n")); } break; case "local-install": case "li": if (install_prefix().string != "/usr/local") { - await install(args, Path.home().join(".local").string); + const dst = Path.home().join(".local"); + assert_manifest_resolved(dst); + await install(await combined_specs(dst, args), dst.string); + await record_install(dst, args); } else { console.error("deprecated: use `pkgm install` without `sudo` instead"); } @@ -87,6 +114,7 @@ if (parsedArgs.help || parsedArgs._[0] == "help") { case "uninstall": case "rm": { + assert_manifest_resolved(); let all_success = true; for (const arg of args) { if (!(await uninstall(arg))) { @@ -105,6 +133,7 @@ if (parsedArgs.help || parsedArgs._[0] == "help") { case "up": case "update": case "upgrade": + assert_manifest_resolved(); await update(); break; @@ -113,6 +142,7 @@ if (parsedArgs.help || parsedArgs._[0] == "help") { Deno.exit(1); break; case "outdated": + assert_manifest_resolved(); await outdated(); break; default: @@ -300,7 +330,8 @@ async function query_pkgx( const needs_sudo_backwards = install_prefix().string == "/usr/local"; let cmd = needs_sudo_backwards ? "/usr/bin/sudo" : pkgx; if (needs_sudo_backwards) { - if (!Deno.env.get("SUDO_USER")) { + const sudo_user = Deno.env.get("SUDO_USER"); + if (!sudo_user) { if (Deno.uid() == 0) { console.error( "%cwarning", @@ -309,8 +340,18 @@ async function query_pkgx( ); } cmd = pkgx; + } else if (reachable_as(pkgx, sudo_user)) { + // drop privileges so pkgx writes its cache as the invoking user, and point + // HOME at their home so it doesn’t cache back under /root/ where they + // couldn’t reach it on the next invocation. + const home = user_home(sudo_user); + if (home) env.HOME = home; + args.unshift("-u", sudo_user, pkgx); } else { - args.unshift("-u", Deno.env.get("SUDO_USER")!, pkgx); + // pkgx lives somewhere the unprivileged user can’t execute it (e.g. only + // under /root/.pkgx). dropping privileges would abort with “Permission + // denied”, so run it as root instead (pkgxdev/pkgm#68). + cmd = pkgx; } } @@ -350,6 +391,34 @@ async function query_pkgx( ]; } +// home directory of the invoking (pre-sudo) user, so pkgx caches under their +// tree rather than /root/. getent is the portable lookup on Linux; on macOS it +// is absent, but the /root/.pkgx scenario this guards against doesn’t arise +// there in practice, so a missing home (→ no override) is fine. +function user_home(user: string): string | undefined { + try { + const out = new Deno.Command("/usr/bin/getent", { + args: ["passwd", user], + }).outputSync(); + if (!out.success) return undefined; + const fields = new TextDecoder().decode(out.stdout).trim().split(":"); + return fields[5] || undefined; + } catch { + return undefined; + } +} + +// can `user` execute the binary at `p`? private home dirs are typically mode +// 700, so a path under another user’s home is unreachable; system paths and the +// user’s own home are assumed reachable. used to decide whether dropping +// privileges to `user` would leave pkgx unrunnable (pkgxdev/pkgm#68). +function reachable_as(p: string, user: string): boolean { + if (p.startsWith("/root/")) return user === "root"; + const m = p.match(/^\/home\/([^/]+)\//); + if (m) return m[1] === user; + return true; +} + async function mirror_directory(dst: string, src: string, prefix: string) { let warned_copy_fallback = false; await processEntry(join(src, prefix), join(dst, prefix)); @@ -511,7 +580,16 @@ function expand_runtime_env(json: JsonResponse, basePath: string) { } function symlink_with_overwrite(src: string, dst: string) { - if (existsSync(dst) && Deno.lstatSync(dst).isSymlink) { + // existsSync follows symlinks, so a *broken* one reads as absent and the + // create below then fails with EEXIST (e.g. libpng’s `.la` symlinks during a + // re-mirror). lstat sees the link itself; remove any symlink, broken or not. + let stat; + try { + stat = Deno.lstatSync(dst); + } catch { + stat = undefined; + } + if (stat?.isSymlink) { Deno.removeSync(dst); } Deno.symlinkSync(src, dst); @@ -604,12 +682,23 @@ async function uninstall(arg: string) { pkg_dirs.push(dir); for await (const [pkgdir, { isDirectory }] of dir.ls()) { if (!isDirectory) continue; - for await (const { path, isDirectory } of walk(pkgdir.string)) { + for await (const { path } of walk(pkgdir.string)) { const leaf = new Path(path).relative({ to: pkgdir }); const resolved_path = root.join(leaf); + // a second installed version (e.g. zlib 1.3.1 alongside 1.3.2) yields the + // same resolved leaf; dedupe so we don’t try to remove it twice. if (set.has(resolved_path.string)) continue; - if (!resolved_path.exists()) continue; - if (isDirectory) { + set.add(resolved_path.string); + // lstat, not exists() — exists() follows links, so a symlink pointing + // into a sibling version dir we’re also removing reads as absent and gets + // orphaned. lstat sees the link itself. + let stat; + try { + stat = Deno.lstatSync(resolved_path.string); + } catch { + continue; + } + if (stat.isDirectory) { dirs.push(resolved_path); } else { files.push(resolved_path); @@ -625,8 +714,10 @@ async function uninstall(arg: string) { Deno.exit(1); } for (const path of files) { - if (!path.isDirectory()) { + try { Deno.removeSync(path.string); + } catch { + // already gone — tolerate (e.g. a leaf shared by two installed versions) } } for (const path of dirs) { @@ -642,6 +733,19 @@ async function uninstall(arg: string) { Deno.removeSync(path.string, { recursive: true }); } + try { + apply_manifest(root, {}, [found.project]); + } catch (err) { + // manifest upkeep is best-effort; never fail an uninstall over it + console.error( + "%c! warning:", + "color:yellow", + `could not drop ${found.project} from ${manifest_path(root)}: ${ + err instanceof Error ? err.message : err + }`, + ); + } + return true; } @@ -665,22 +769,15 @@ async function outdated() { pkgs.push(pkg); } - const { pkgs: raw_graph } = await hydrate( - pkgs.map((x) => ({ - project: x.pkg.project, - constraint: new semver.Range(`^${x.pkg.version}`), - })), - ); - const graph: Record = {}; - for (const { project, constraint } of raw_graph) { - graph[project] = constraint; - } + const graph = await hydrate_graph(pkgs); for (const { path, pkg } of pkgs) { + const range = graph[pkg.project]; + if (!range) continue; // orphan: not reachable from the requested set const versions = await hooks.useInventory().get(pkg); - // console.log(pkg, graph[pkg.project]); + // console.log(pkg, range); const constrained_versions = versions.filter( - (x) => graph[pkg.project].satisfies(x) && x.gt(pkg.version), + (x) => range.satisfies(x) && x.gt(pkg.version), ); if (constrained_versions.length) { console.log( @@ -719,23 +816,16 @@ async function update() { pkgs.push(pkg); } - const { pkgs: raw_graph } = await hydrate( - pkgs.map((x) => ({ - project: x.pkg.project, - constraint: new semver.Range(`^${x.pkg.version}`), - })), - ); - const graph: Record = {}; - for (const { project, constraint } of raw_graph) { - graph[project] = constraint; - } + const graph = await hydrate_graph(pkgs); const update_list = []; for (const { pkg } of pkgs) { + const range = graph[pkg.project]; + if (!range) continue; // orphan: not reachable from the requested set const versions = await hooks.useInventory().get(pkg); const constrained_versions = versions.filter( - (x) => graph[pkg.project].satisfies(x) && x.gt(pkg.version), + (x) => range.satisfies(x) && x.gt(pkg.version), ); if (constrained_versions.length) { @@ -744,6 +834,14 @@ async function update() { } } + // nothing satisfies a newer in-range version: everything’s current. return + // before calling install(), which treats an empty arg list as a usage error + // ("no packages specified") and exits non-zero. + if (update_list.length == 0) { + console.error("everything is up-to-date"); + return; + } + for (const pkgspec of update_list) { const pkg = utils.pkg.parse(pkgspec); console.log( @@ -766,6 +864,313 @@ function install_prefix() { } } +// where the manifest lives for a given install prefix. user installs follow XDG +// (config, since it’s hand-editable intent); the /usr/local prefix has no XDG +// home so it uses the FHS-conventional …/etc. +function manifest_path(prefix = install_prefix()): Path { + if (prefix.string == "/usr/local") { + return new Path("/usr/local/etc/pkgm/manifest.toml"); + } + const xdg = Deno.env.get("XDG_CONFIG_HOME"); + const base = xdg ? new Path(xdg) : Path.home().join(".config"); + return base.join("pkgm/manifest.toml"); +} + +function read_manifest( + prefix = install_prefix(), +): Record | undefined { + const path = manifest_path(prefix); + if (!existsSync(path.string)) return undefined; + const raw = parse_toml(Deno.readTextFileSync(path.string)); + const out: Record = {}; + for (const [key, value] of Object.entries(raw)) { + out[key] = `${value}`; + } + return out; +} + +type ManifestEntryKind = "dep" | "requested" | "unresolved"; + +function manifest_entry_kind(value: string): ManifestEntryKind { + if (value == "dep") return "dep"; + try { + new semver.Range(value == "any" ? "*" : value); + return "requested"; + } catch { + return "unresolved"; + } +} + +// "boot" check: refuse to operate when the manifest holds a value that is +// neither `dep` nor a valid semver range — i.e. a hand-edit that hasn’t been +// finished. no manifest at all → legacy behavior (see hydrate_graph), so +// existing installs keep working untouched. +function assert_manifest_resolved(prefix = install_prefix()) { + let manifest; + try { + manifest = read_manifest(prefix); + } catch (err) { + // invalid TOML (e.g. a partially-finished hand-edit) must block commands + // with a clear message, not crash with an uncaught parse stack trace. + console.error( + "%cerror", + "color:red", + `could not parse ${manifest_path(prefix)}: ${ + err instanceof Error ? err.message : err + }`, + ); + console.error("fix the TOML syntax and re-run"); + Deno.exit(1); + } + if (!manifest) return; + const unresolved = Object.entries(manifest) + .filter(([, value]) => manifest_entry_kind(value) == "unresolved") + .map(([project]) => project); + if (unresolved.length == 0) return; + console.error( + "%cerror", + "color:red", + `unresolved entries in ${manifest_path(prefix)}:`, + ); + for (const project of unresolved) { + console.error(` ${project}`); + } + console.error( + "edit the file, set each to `dep`, `*`, or a version, then re-run", + ); + Deno.exit(1); +} + +// resolve the constraint graph used by `outdated`/`update`. with a manifest we +// seed hydrate from the *requested* packages only and let it recompute the +// transitive deps coherently; without one we fall back to today’s behavior of +// assuming every installed package is pinned to its major version. an +// unreconcilable seed is dropped with a warning rather than aborting (#88). +async function hydrate_graph( + pkgs: Installation[], +): Promise> { + const manifest = read_manifest(); + const seeds = manifest + ? Object.entries(manifest) + .filter(([, value]) => value != "dep") + .map(([project, value]) => ({ + project, + constraint: new semver.Range(value == "any" ? "*" : value), + })) + : pkgs.map((x) => ({ + project: x.pkg.project, + constraint: new semver.Range(`^${x.pkg.version}`), + })); + + // installs can hold mutually-incompatible versions of a shared dep (e.g. + // python pins zlib=1.3.1 while node pulled zlib^1.3.2); hydrate models a + // single coherent universe and throws "cannot intersect". rather than let one + // unreconcilable seed take down the whole command (the #88 symptom), drop the + // seed that is one side of the conflict and re-resolve — the package is then + // covered by its dependents’ constraints instead of our pin — accumulating a + // warning for each so the user can fix or `dep` it. + const dropped: { project: string; constraint: string }[] = []; + let active = seeds; + let raw_graph: { project: string; constraint: semver.Range }[]; + for (;;) { + try { + raw_graph = (await hydrate(active)).pkgs; + break; + } catch (err) { + if ( + !(err instanceof Error) || !err.message.includes("cannot intersect") + ) { + throw err; + } + // err.message is "cannot intersect: && "; drop the seed whose + // constraint is one of the two sides. + const sides = err.message.match( + /cannot intersect:\s*(.+?)\s*&&\s*(.+?)\s*$/, + ); + const idx = sides + ? active.findIndex((s) => { + const t = `${s.constraint}`; + return t == sides[1] || t == sides[2]; + }) + : -1; + if (idx < 0) { + // can’t attribute the conflict to one of our seeds; surface it raw. + console.error("%cversion conflict", "color:red", err.message); + if (manifest) { + console.error( + ` edit ${manifest_path()}: set the offending package to \`dep\`, or to a compatible version.`, + ); + } + Deno.exit(1); + } + dropped.push({ + project: active[idx].project, + constraint: `${active[idx].constraint}`, + }); + active = active.filter((_, i) => i != idx); + } + } + + if (dropped.length) { + const where = manifest ? ` in ${manifest_path()}` : ""; + for (const { project, constraint } of dropped) { + console.error( + "%c! warning", + "color:yellow", + `\`${project}\` (${constraint}) can’t be reconciled with a dependency’s requirement; ignoring that pin — set \`${project}\` to \`dep\` or a compatible version${where} to silence this.`, + ); + } + } + + const graph: Record = {}; + for (const { project, constraint } of raw_graph) { + graph[project] = constraint; + } + return graph; +} + +// edit the on-disk manifest as text so a user’s comments and hand-edits to +// untouched lines survive. entries in `set` are overwritten in place (or +// appended); projects in `remove` are dropped. +function apply_manifest( + prefix: Path, + set: Record, + remove: string[] = [], +) { + const path = manifest_path(prefix); + Deno.mkdirSync(dirname(path.string), { recursive: true }); + const text = existsSync(path.string) + ? Deno.readTextFileSync(path.string) + : MANIFEST_HEADER; + + const pending = new Map(Object.entries(set)); + const removing = new Set(remove); + const key_re = /^\s*"([^"]+)"\s*=/; + + const out: string[] = []; + for (const line of text.split("\n")) { + const project = line.match(key_re)?.[1]; + if (project && removing.has(project)) continue; + if (project && pending.has(project)) { + out.push(`"${project}" = "${pending.get(project)}"`); + pending.delete(project); + } else { + out.push(line); + } + } + + // append new entries directly after the existing ones — no separator blank, + // or every install would add a gap. the single blank after the header comes + // from MANIFEST_HEADER itself. + let body = out.join("\n"); + if (!body.endsWith("\n")) body += "\n"; + for (const [project, value] of pending) { + body += `"${project}" = "${value}"\n`; + } + + Deno.writeTextFileSync(path.string, body); +} + +// fold the already-requested packages from the manifest into this install, so a +// shared dependency is resolved once across everything you’ve asked for rather +// than each install picking its own version and clobbering the link (#88: `i +// node` would relink zlib to 1.3.2, breaking python’s 1.3.1). new args win over +// a manifest entry for the same project. `dep`s are omitted — they’re recomputed +// from the requested set. +async function combined_specs(prefix: Path, args: string[]): Promise { + const manifest = read_manifest(prefix); + // no args → let install() emit "no packages specified" rather than re-syncing. + if (!manifest || args.length == 0) return args; + + const new_projects = new Set(); + for (const arg of args) { + try { + const found = await hooks.usePantry().find(arg); + if (found.length == 1) new_projects.add(found[0].project); + } catch { + // unresolved project name; the worst case is a duplicate spec, which + // pkgx reconciles against the identical project anyway. + } + } + + const specs = [...args]; + for (const [project, value] of Object.entries(manifest)) { + if (value == "dep" || new_projects.has(project)) continue; + specs.push(value == "*" || value == "any" ? project : `${project}${value}`); + } + return specs; +} + +// map each requested arg to its canonical project and the constraint you asked +// for. a bare name (`node`) yields `*` here, which record_install then +// major-locks to the installed version (`^`); a versioned spec +// (`node@22`) yields its range (`^22`) verbatim. pantry hiccups are non-fatal: +// the package will still be picked up as a pin by record_install’s tree walk. +async function requested_constraints( + args: string[], +): Promise> { + const out: Record = {}; + for (const arg of args) { + let project: string | undefined; + try { + const found = await hooks.usePantry().find(arg); + if (found.length == 1) project = found[0].project; + } catch { + continue; + } + if (!project) continue; + out[project] = `${utils.pkg.parse(arg).constraint}`; + } + return out; +} + +// keep the manifest in step with an install. requested packages record intent +// and overwrite: a bare name major-locks to what was installed (`^version`, +// matching pre-manifest `outdated`/`update` seeding), a versioned spec keeps the +// range you asked for (`node@22` → `^22`). every other package the install +// pulled in is recorded as `dep` — a transitive dependency with no constraint +// of its own, recomputed at resolution time. existing entries (e.g. a `dep` you +// promoted to a version by hand) are never clobbered. +async function record_install(prefix: Path, args: string[]) { + try { + const requested = await requested_constraints(args); + const existing = read_manifest(prefix) ?? {}; + + const greatest: Record = {}; + for await (const { pkg } of walk_pkgs(prefix.join("pkgs"))) { + const cur = greatest[pkg.project]; + if (!cur || pkg.version.gt(cur)) greatest[pkg.project] = pkg.version; + } + + const set: Record = {}; + for (const [project, constraint] of Object.entries(requested)) { + // a bare request parses to "*"; lock it to the installed major instead. + set[project] = constraint == "*" && greatest[project] + ? `^${greatest[project]}` + : constraint; + } + + // everything else now on disk is a transitive dependency: record it as + // `dep`, carrying no constraint of its own. at compute time we hydrate the + // requested (non-`dep`) entries, which pulls the whole installed graph back + // in for collision detection, so deps don't need a recorded range. + for (const project of Object.keys(greatest)) { + if (project in requested || project in existing) continue; + set[project] = "dep"; + } + + apply_manifest(prefix, set); + } catch (err) { + console.error( + "%c! warning:", + "color:yellow", + `could not update ${manifest_path(prefix)}: ${ + err instanceof Error ? err.message : err + }`, + ); + } +} + function dev_stub_text(selfpath: string, bin_prefix: string, name: string) { if (selfpath.startsWith("/usr/local") && selfpath != "/usr/local/bin/dev") { return `