diff --git a/CLAUDE.md b/CLAUDE.md index 8ca3632..db0b8fd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,6 +27,8 @@ Unfree vendor binaries are the exception: they live in `pkgs/default.nix`'s `unf **`pkgs/images/`** — container images built with `nix2container.buildImage`. Images are not standalone packages; they attach to an existing nixpkgs package via `overrideAttrs` + `passthru.image` (see `hercules-ci-agent` and `github-runner` in `pkgs/default.nix`). +**`lib/salesforce/`** — `mkSfPlugin` builds an oclif plugin from its registry tarball into `$out/lib/node_modules/`; `sfWithPlugins` backs `salesforce-cli.withPlugins`, which relinks the CLI with those plugins as core plugins. oclif loads a core plugin only when it appears in both `oclif.plugins` and `dependencies` of the CLI's `package.json`, and node resolves a module's dependencies from its realpath, so the CLI's own files are copied while its `node_modules` stay symlinks. A plugin that shells out to another program declares it in `runtimeInputs`; `withPlugins` puts those on the CLI wrapper's PATH. + **`lib/maintainers.nix`** — extends `pkgs.lib.maintainers` with the local `UnstoppableMango` entry. Referenced in every `meta.maintainers` block. **`lib/packages.nix`** — pure Nix function that generates the README table from `config.packages`. Called via `legacyPackages.packagesTable`; the actual README markers are updated by `scripts/gen-packages-table.sh`. @@ -41,6 +43,7 @@ Unfree vendor binaries are the exception: they live in `pkgs/default.nix`'s `unf | Rust | `rustPlatform.buildRustPackage` | `cargoHash` in derivation | | OCaml | `ocamlPackages.buildDunePackage` | — | | Container image | `nix2container.buildImage` | `manifest.json` for pulled base images | +| oclif plugin | `mkSfPlugin` (`lib/salesforce`) | — | ## Adding a package diff --git a/README.md b/README.md index 52d5ded..9f17400 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![Cachix](https://img.shields.io/badge/cachix-unstoppablemango-blue)](https://unstoppablemango.cachix.org) [![Last Commit](https://img.shields.io/github/last-commit/unmango/pkgs)](https://github.com/unmango/pkgs/commits/main) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) -[![packages](https://img.shields.io/badge/packages-42-blue)](#packages) +[![packages](https://img.shields.io/badge/packages-44-blue)](#packages)

@@ -57,6 +57,8 @@ Mini-nixpkgs of dubious quality. | `pulumi-yaml` | Pulumi language host for YAML programs | | `rust-analyzer-mcp` | Model Context Protocol (MCP) server that provides integration with rust-analyzer | | `salesforce-cli` | CLI for developing against the Salesforce Platform | +| `sf-plugin-code-analyzer` | Salesforce Code Analyzer, a Salesforce CLI plugin for static analysis | +| `sfdx-git-delta` | Salesforce CLI plugin that generates a delta package from a git diff | | `skopeo-nix2container` | Command line utility for various operations on container images and image repositories, with nix2container's nix: transport | | `slackdump` | Save or export your private and public Slack messages, threads, files, and users locally without admin privileges | | `terraform-plugin-codegen-framework` | Terraform Plugin Framework Code Generation | @@ -82,6 +84,19 @@ nix run github:unmango/pkgs#kubectl-slice nix shell github:unmango/pkgs#kube-vip ``` +### Salesforce CLI plugins + +`salesforce-cli.withPlugins` links plugins in as core plugins, so they come from +the store instead of `sf plugins install`'s mutable copy under +`$XDG_DATA_HOME/sf`. + +```nix +pkgs.salesforce-cli.withPlugins [ + pkgs.sfdx-git-delta + pkgs.sf-plugin-code-analyzer +] +``` + ### Overlay ```nix diff --git a/lib/salesforce/default.nix b/lib/salesforce/default.nix new file mode 100644 index 0000000..724b3d3 --- /dev/null +++ b/lib/salesforce/default.nix @@ -0,0 +1,10 @@ +{ pkgs }: +let + callPackage = pkgs.lib.callPackageWith (packages // pkgs); + + packages = { + mkSfPlugin = callPackage ./plugin.nix { }; + sfWithPlugins = callPackage ./with-plugins.nix { }; + }; +in +packages diff --git a/lib/salesforce/plugin.nix b/lib/salesforce/plugin.nix new file mode 100644 index 0000000..5be7d7b --- /dev/null +++ b/lib/salesforce/plugin.nix @@ -0,0 +1,93 @@ +{ + lib, + buildNpmPackage, + fetchurl, + jq, +}: + +# Builds an oclif plugin published to the npm registry into +# $out/lib/node_modules/, ready for `salesforce-cli.withPlugins`. +{ + pname, + version, + # Registry name, e.g. "@salesforce/plugin-code-analyzer". + npmName, + # Hash of the registry tarball. + hash, + npmDepsHash, + # Packages a command of this plugin shells out to. `withPlugins` puts them on + # the PATH of the CLI it builds; a plugin derivation on its own has no bin to + # wrap, so this is the only place they can be attached. + runtimeInputs ? [ ], + passthru ? { }, + ... +}@args: +let + # Scoped names live under the scope on the registry but drop it from the + # tarball filename: @salesforce/plugin-x/-/plugin-x-1.0.0.tgz. + tarballName = lib.last (lib.splitString "/" npmName); +in +buildNpmPackage ( + removeAttrs args [ + "npmName" + "hash" + "runtimeInputs" + "passthru" + ] + // { + inherit pname version npmDepsHash; + + src = fetchurl { + url = "https://registry.npmjs.org/${npmName}/-/${tarballName}-${version}.tgz"; + inherit hash; + }; + + npmDepsFetcherVersion = 2; + + # Salesforce publishes plugins with an npm-shrinkwrap.json (plugin-trust + # signs it) whose dev subtree is incomplete, so `npm ci` falls back to the + # registry for the missing entries and fails in the sandbox. dist/ ships + # prebuilt, so drop the dev half of the lockfile and of package.json with + # it. jq is called by store path because buildNpmPackage replays postPatch + # inside fetchNpmDeps, whose build environment it does not control. + postPatch = '' + if [ -f npm-shrinkwrap.json ]; then + lockfile=npm-shrinkwrap.json + elif [ -f package-lock.json ]; then + lockfile=package-lock.json + else + echo "mkSfPlugin: ${npmName} ships no npm-shrinkwrap.json or package-lock.json" >&2 + exit 1 + fi + + # The prune below walks .packages, which only exists from lockfileVersion + # 2 on. npm has written 2 or 3 since npm 7, so a version 1 lockfile is + # rejected rather than given a second pruning path. + lockfileVersion=$(${lib.getExe jq} '.lockfileVersion // 0' "$lockfile") + if [ "$lockfileVersion" -lt 2 ]; then + echo "mkSfPlugin: ${npmName} ships $lockfile at lockfileVersion $lockfileVersion, which has no .packages tree" >&2 + exit 1 + fi + + ${lib.getExe jq} 'del(.devDependencies)' package.json >patched.json + mv patched.json package.json + + ${lib.getExe jq} ' + (.packages |= with_entries(select(.value.dev != true))) + | del(.packages[""].devDependencies) + ' "$lockfile" >patched.json + mv patched.json "$lockfile" + ''; + + # dist/ ships prebuilt, and the install scripts are husky and telemetry. + npmFlags = [ + "--ignore-scripts" + "--legacy-peer-deps" + ]; + dontNpmBuild = true; + + passthru = passthru // { + inherit npmName runtimeInputs; + }; + } +) diff --git a/lib/salesforce/with-plugins.nix b/lib/salesforce/with-plugins.nix new file mode 100644 index 0000000..45dc074 --- /dev/null +++ b/lib/salesforce/with-plugins.nix @@ -0,0 +1,113 @@ +{ + lib, + jq, + makeWrapper, + nodejs, + runCommand, +}: + +# Rebuilds the Salesforce CLI with `plugins` linked in as core plugins, so they +# resolve from the store instead of from `sf plugins install`'s mutable copy +# under $XDG_DATA_HOME/sf. +let + sfWithPlugins = + { salesforce-cli, plugins }: + let + runtimeInputs = lib.concatMap (plugin: plugin.runtimeInputs or [ ]) plugins; + + wrapperArgs = + lib.mapAttrsToList ( + name: value: "--set ${name} ${lib.escapeShellArg value}" + ) salesforce-cli.runtimeEnv + ++ lib.optional (runtimeInputs != [ ]) "--prefix PATH : ${lib.makeBinPath runtimeInputs}"; + in + runCommand "salesforce-cli-${salesforce-cli.version}" + { + nativeBuildInputs = [ + jq + makeWrapper + ]; + + inherit plugins; + pluginNames = builtins.toJSON (map (plugin: plugin.npmName) plugins); + pluginDeps = builtins.toJSON ( + lib.listToAttrs (lib.map (plugin: lib.nameValuePair plugin.npmName plugin.version) plugins) + ); + + inherit (salesforce-cli) pname version meta; + + # Updates target pkgs/salesforce-cli/default.nix, so the base package's + # updateScript does not carry over to the composition. + passthru = removeAttrs salesforce-cli.passthru [ "updateScript" ] // { + inherit plugins; + # Stack onto what is already linked in rather than starting from the + # bare CLI again. + withPlugins = + extra: + sfWithPlugins { + inherit salesforce-cli; + plugins = plugins ++ extra; + }; + }; + } + '' + upstream=${salesforce-cli}/lib/node_modules/@salesforce/cli + root=$out/lib/node_modules/@salesforce/cli + mkdir -p "$root/node_modules" + + # The CLI's own ~2M of files are copied rather than symlinked: node resolves + # a module's dependencies from its realpath, so a symlinked entry point + # would look for node_modules beside the upstream copy and never see the + # plugins linked in below. + for entry in $(ls -A "$upstream"); do + if [ "$entry" != node_modules ]; then + cp -r --no-preserve=mode,ownership "$upstream/$entry" "$root/$entry" + fi + done + + # Its 270M of dependencies stay symlinks. Scope directories are recreated as + # real directories so a scoped plugin can be linked in beside them. + link_into() { + local from="$1" + for entry in $(ls -A "$from"); do + case "$entry" in + @*) + mkdir -p "$root/node_modules/$entry" + for scoped in $(ls -A "$from/$entry"); do + ln -sfn "$from/$entry/$scoped" "$root/node_modules/$entry/$scoped" + done + ;; + *) + ln -sfn "$from/$entry" "$root/node_modules/$entry" + ;; + esac + done + } + + link_into "$upstream/node_modules" + for plugin in $plugins; do + link_into "$plugin/lib/node_modules" + done + + # oclif loads a core plugin only when it appears in both oclif.plugins and + # dependencies (see loadCorePlugins in @oclif/core), and a plugin that is + # also declared under oclif.jitPlugins would otherwise still be reported as + # not installed. + jq --argjson names "$pluginNames" --argjson deps "$pluginDeps" ' + .oclif.plugins += $names + | .dependencies += $deps + | if .oclif.jitPlugins + then .oclif.jitPlugins |= with_entries(select(.key as $k | $names | index($k) | not)) + else . end + ' "$root/package.json" >package.json + mv package.json "$root/package.json" + + mkdir -p $out/bin + for bin in sf sfdx; do + makeWrapper ${lib.getExe nodejs} "$out/bin/$bin" \ + --add-flags "--no-deprecation $root/bin/run.js" \ + ${lib.concatStringsSep " " wrapperArgs} + done + ''; +in +sfWithPlugins diff --git a/pkgs/default.nix b/pkgs/default.nix index 4620728..8a4904d 100644 --- a/pkgs/default.nix +++ b/pkgs/default.nix @@ -13,6 +13,7 @@ inherit (inputs'.nix2container.packages) nix2container; inherit (inputs'.gomod2nix.legacyPackages) buildGoApplication; inherit (pkgs.callPackage ../lib/go { }) mkUpdateDeps; + inherit (pkgs.callPackage ../lib/salesforce { }) mkSfPlugin sfWithPlugins; }; callPackage = lib.callPackageWith (tools // pkgs); @@ -68,6 +69,8 @@ pulumi-yaml = callPackage ./pulumi-yaml { }; rust-analyzer-mcp = callPackage ./rust-analyzer-mcp { }; salesforce-cli = callPackage ./salesforce-cli { }; + sf-plugin-code-analyzer = callPackage ./sf-plugin-code-analyzer { }; + sfdx-git-delta = callPackage ./sfdx-git-delta { }; skopeo-nix2container = callPackage ./skopeo-nix2container { inherit (inputs'.nix2container.packages) skopeo-nix2container; }; diff --git a/pkgs/salesforce-cli/default.nix b/pkgs/salesforce-cli/default.nix index 4a7a24d..ed64f71 100644 --- a/pkgs/salesforce-cli/default.nix +++ b/pkgs/salesforce-cli/default.nix @@ -6,6 +6,7 @@ makeWrapper, nix-update-script, runCommand, + sfWithPlugins, writeText, }: let @@ -83,26 +84,9 @@ let ' $out/npm-shrinkwrap.json >shrinkwrap.json mv shrinkwrap.json $out/npm-shrinkwrap.json ''; -in -buildNpmPackage { - pname = "salesforce-cli"; - inherit version src; - - npmDepsFetcherVersion = 2; - npmDepsHash = "sha256-YiebUV18yxWX+mF02QMFVvpEi0pnLajzmGcYULvMHts="; - - nativeBuildInputs = [ makeWrapper ]; - - # dist/ ships prebuilt, and the preinstall script shells out to - # `sfdx --version` to detect a conflicting v1 install. - npmFlags = [ - "--ignore-scripts" - "--legacy-peer-deps" - ]; - dontNpmBuild = true; - # The environment below pins the CLI to this store path. Updating happens by - # bumping `version` and the hashes above. + # Pins the CLI to its store path. `withPlugins` builds its own wrappers, so + # this is passthru rather than inline in postInstall. # # SF_REDIRECTED short-circuits the launcher's first branch, which otherwise # hands execution to $XDG_DATA_HOME/sf/client/bin/sf whenever a self-updated @@ -112,24 +96,57 @@ buildNpmPackage { # notice points at the environment instead of telling the user to run # `npm update --global`. The CLI accepts either spelling of the autoupdate # flag, so both are set. - postInstall = '' - for bin in sf sfdx; do - wrapProgram $out/bin/$bin \ - --set SF_REDIRECTED 1 \ - --set SF_INSTALLER true \ - --set SF_AUTOUPDATE_DISABLE true \ - --set SF_DISABLE_AUTOUPDATE true - done - ''; + runtimeEnv = { + SF_REDIRECTED = "1"; + SF_INSTALLER = "true"; + SF_AUTOUPDATE_DISABLE = "true"; + SF_DISABLE_AUTOUPDATE = "true"; + }; + + setArgs = lib.mapAttrsToList (name: value: "--set ${name} ${lib.escapeShellArg value}") runtimeEnv; + + package = buildNpmPackage { + pname = "salesforce-cli"; + inherit version src; - passthru.updateScript = nix-update-script { }; + npmDepsFetcherVersion = 2; + npmDepsHash = "sha256-YiebUV18yxWX+mF02QMFVvpEi0pnLajzmGcYULvMHts="; - meta = with lib; { - description = "CLI for developing against the Salesforce Platform"; - homepage = "https://developer.salesforce.com/tools/salesforcecli"; - downloadPage = "https://github.com/salesforcecli/cli/releases"; - license = licenses.asl20; - maintainers = with maintainers; [ UnstoppableMango ]; - mainProgram = "sf"; + nativeBuildInputs = [ makeWrapper ]; + + # dist/ ships prebuilt, and the preinstall script shells out to + # `sfdx --version` to detect a conflicting v1 install. + npmFlags = [ + "--ignore-scripts" + "--legacy-peer-deps" + ]; + dontNpmBuild = true; + + postInstall = '' + for bin in sf sfdx; do + wrapProgram $out/bin/$bin ${lib.concatStringsSep " " setArgs} + done + ''; + + passthru = { + inherit runtimeEnv; + updateScript = nix-update-script { }; + withPlugins = + plugins: + sfWithPlugins { + inherit plugins; + salesforce-cli = package; + }; + }; + + meta = with lib; { + description = "CLI for developing against the Salesforce Platform"; + homepage = "https://developer.salesforce.com/tools/salesforcecli"; + downloadPage = "https://github.com/salesforcecli/cli/releases"; + license = licenses.asl20; + maintainers = with maintainers; [ UnstoppableMango ]; + mainProgram = "sf"; + }; }; -} +in +package diff --git a/pkgs/sf-plugin-code-analyzer/default.nix b/pkgs/sf-plugin-code-analyzer/default.nix new file mode 100644 index 0000000..199a17e --- /dev/null +++ b/pkgs/sf-plugin-code-analyzer/default.nix @@ -0,0 +1,31 @@ +{ + lib, + jdk21, + mkSfPlugin, + nix-update-script, + python3, +}: +mkSfPlugin { + pname = "sf-plugin-code-analyzer"; + version = "5.16.0"; + npmName = "@salesforce/plugin-code-analyzer"; + + hash = "sha256-1rKVldKQ0mEJm/8V+t3EYKTKwXMMSiOPM6/TVAN1npE="; + npmDepsHash = "sha256-6qZ2LGYEJ95CgWq+vrUla0NnJHJCbAE0ShQBVkpe+wM="; + + # The PMD, SFGE and ApexGuru engines run bundled jars, and the Flow engine + # runs a bundled Python module. Both are looked up on PATH. + runtimeInputs = [ + jdk21 + python3 + ]; + + passthru.updateScript = nix-update-script { }; + + meta = with lib; { + description = "Salesforce Code Analyzer, a Salesforce CLI plugin for static analysis"; + homepage = "https://developer.salesforce.com/docs/platform/salesforce-code-analyzer/overview"; + license = licenses.bsd3; + maintainers = with maintainers; [ UnstoppableMango ]; + }; +} diff --git a/pkgs/sfdx-git-delta/default.nix b/pkgs/sfdx-git-delta/default.nix new file mode 100644 index 0000000..fcb6c65 --- /dev/null +++ b/pkgs/sfdx-git-delta/default.nix @@ -0,0 +1,22 @@ +{ + lib, + mkSfPlugin, + nix-update-script, +}: +mkSfPlugin { + pname = "sfdx-git-delta"; + version = "6.45.1"; + npmName = "sfdx-git-delta"; + + hash = "sha256-XKtzSp/j5Oqu8gkFvh1y+IAhel3vzuMCEoTYYPD3pUg="; + npmDepsHash = "sha256-8scoP6imZJAt3qB3O+Mb5DykYNjC6FY/1dNdUXJY/OE="; + + passthru.updateScript = nix-update-script { }; + + meta = with lib; { + description = "Salesforce CLI plugin that generates a delta package from a git diff"; + homepage = "https://github.com/scolladon/sfdx-git-delta"; + license = licenses.mit; + maintainers = with maintainers; [ UnstoppableMango ]; + }; +}