diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml new file mode 100644 index 0000000..e02c2d2 --- /dev/null +++ b/.github/workflows/nix.yml @@ -0,0 +1,23 @@ +# convoy's CI gate. `nix flake check` builds the package and runs every check, which is what makes it +# a real gate: checks.help and checks.completions exercise the binary the flake just built, and +# checks.typecheck runs `tsc --noEmit` against it. +# +# This is a flake check rather than a Node workflow because convoy's deps on smalltalk and pty are +# `file:../sibling` paths — a plain `npm ci` needs those repos checked out next to convoy, which CI +# has no clean way to arrange. Flake inputs solve that: the sibling packages come from their own +# flakes, pinned in flake.lock. +name: Nix + +on: + pull_request: + push: + branches: [main] + +jobs: + check: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + - uses: DeterminateSystems/determinate-nix-action@v3 + - run: nix flake check diff --git a/.gitignore b/.gitignore index 3fa9dbb..a72ffe0 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,7 @@ DING-BUS.md # Session marker .claude-session-id + +# Nix build results +result +result-* diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..6642c7d --- /dev/null +++ b/flake.lock @@ -0,0 +1,109 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1784497964, + "narHash": "sha256-vlHUuqAcbcH2RKmHbPiuQzbv1pnzzavXnI62RD0bqCU=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "241313f4e8e508cb9b13278c2b0fa25b9ca27163", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "pty": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1784556029, + "narHash": "sha256-52SqV7ANR/r+gqGNo/e2JUmQImpmCgmMUScd0KZlTW0=", + "owner": "compoundingtech", + "repo": "pty", + "rev": "0a9be8c06bd9e3f0c1acbb34784762075ef53eb3", + "type": "github" + }, + "original": { + "owner": "compoundingtech", + "repo": "pty", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs", + "pty": "pty", + "smalltalk": "smalltalk" + } + }, + "smalltalk": { + "inputs": { + "flake-utils": [ + "flake-utils" + ], + "nixpkgs": [ + "nixpkgs" + ], + "pty": [ + "pty" + ] + }, + "locked": { + "lastModified": 1784582866, + "narHash": "sha256-ZJN8bZafY/ntKtvVYWxP9JQFUbeTYrhdzf3SA3hpwAs=", + "owner": "compoundingtech", + "repo": "smalltalk", + "rev": "850266f8551335771b2d922a7456e7246621c737", + "type": "github" + }, + "original": { + "owner": "compoundingtech", + "repo": "smalltalk", + "type": "github" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..b19d32f --- /dev/null +++ b/flake.nix @@ -0,0 +1,219 @@ +{ + description = "convoy - the single front door to a smalltalk agent network"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + + pty.url = "github:compoundingtech/pty"; + pty.inputs.nixpkgs.follows = "nixpkgs"; + + smalltalk.url = "github:compoundingtech/smalltalk"; + smalltalk.inputs.nixpkgs.follows = "nixpkgs"; + smalltalk.inputs.flake-utils.follows = "flake-utils"; + # One pty in the closure: convoy links the same build smalltalk was built against. + smalltalk.inputs.pty.follows = "pty"; + }; + + outputs = + { + self, + nixpkgs, + flake-utils, + pty, + smalltalk, + }: + flake-utils.lib.eachDefaultSystem ( + system: + let + pkgs = import nixpkgs { inherit system; }; + + # package.json is the single source of truth for the version, so a release bump does not need + # a matching edit here. + version = (builtins.fromJSON (builtins.readFile ./package.json)).version; + + # npm names of the local `file:` deps that resolve to sibling repos, mapped to the store path + # holding their package root. buildNpmPackage cannot fetch these, so they are linked into + # node_modules after the copy. The keys must match package.json / package-lock.json exactly, + # since that is the specifier Node resolves. + siblingLinks = { + "@compoundingtech/pty" = "${pty.packages.${system}.default}/lib/pty"; + "@compoundingtech/smalltalk" = "${smalltalk.packages.${system}.default}/lib/smalltalk"; + }; + + # TODO(rust): a Rust rewrite drops npm entirely, and this hash with it. + # Regenerate with: nix run nixpkgs#prefetch-npm-deps -- package-lock.json + npmDepsHash = "sha256-RCsVWvgt0853fww5A89GDDd9OR2aexf23dAnn7EDq1M="; + + completionShells = [ + "bash" + "zsh" + "fish" + ]; + + # The sibling `file:` deps as node_modules symlinks. Shared by the package install and the + # typecheck check, so both resolve `@compoundingtech/*` the same way. + linkSiblings = dir: pkgs.lib.concatLines ( + pkgs.lib.mapAttrsToList (npmName: storePath: '' + mkdir -p "${dir}/node_modules/$(dirname ${npmName})" + rm -rf "${dir}/node_modules/${npmName}" + ln -s ${storePath} "${dir}/node_modules/${npmName}" + '') siblingLinks + ); + + convoy = pkgs.buildNpmPackage { + pname = "convoy"; + inherit version; + src = self; + + inherit npmDepsHash; + + nodejs = pkgs.nodejs_24; + npmInstallFlags = [ "--omit=dev" ]; + # Sources ship as TypeScript and run through node's type stripping; there is no compile step. + dontNpmBuild = true; + + nativeBuildInputs = [ pkgs.installShellFiles ]; + + installPhase = '' + runHook preInstall + + mkdir -p $out/lib/convoy + cp -r . $out/lib/convoy + + # TODO(rust): sibling linking exists only because npm cannot resolve `file:` deps in a + # sandbox; a Cargo build has no equivalent. + ${linkSiblings "$out/lib/convoy"} + + # bin/convoy dynamically imports src/cli.ts, so node has to strip types at load. + # TODO(rust): a compiled binary replaces this shim. + mkdir -p $out/bin + cat > $out/bin/convoy < completions-${shell} + '') completionShells} + + installShellCompletion --cmd convoy \ + --bash completions-bash \ + --zsh completions-zsh \ + --fish completions-fish + ''; + + meta = { + description = "Stand up and run a crew of coding agents over smalltalk and pty"; + homepage = "https://github.com/compoundingtech/convoy"; + license = pkgs.lib.licenses.mit; + mainProgram = "convoy"; + }; + }; + in + { + packages.convoy = convoy; + packages.default = convoy; + + checks.help = pkgs.runCommand "convoy-help-${version}" { } '' + export HOME=$(mktemp -d) + ${convoy}/bin/convoy --help > /dev/null + touch $out + ''; + + # Guards the completions contract end-to-end on the REAL binary: every shell gets a non-empty + # script, fish still binds to `convoy`, and each script is syntactically valid for its shell. + # The syntax checks matter most for zsh, which is often absent from a dev machine. + checks.completions = + pkgs.runCommand "convoy-completions-${version}" + { + nativeBuildInputs = [ + pkgs.bash + pkgs.zsh + pkgs.fish + ]; + } + '' + export HOME=$(mktemp -d) + ${pkgs.lib.concatMapStringsSep "\n" (shell: '' + ${convoy}/bin/convoy completions ${shell} > ${shell}.completions + grep -q . ${shell}.completions || { echo "empty ${shell} completions" >&2; exit 1; } + ${shell} -n ${shell}.completions \ + || { echo "${shell} rejected its own generated completions" >&2; exit 1; } + '') completionShells} + + grep -q '^complete -c convoy ' fish.completions \ + || { echo "fish completions do not bind to \`convoy\`" >&2; exit 1; } + + touch $out + ''; + + # The completions parity suite — the tests that keep src/command-table.ts, argv dispatch, and + # the generated scripts from drifting. Sandbox-safe (it only spawns `convoy` and reads + # src/cli.ts), unlike the rest of the vitest suite, which shells out to `git worktree` and + # probes the host and so is deliberately NOT wired up here. + checks.parity = pkgs.buildNpmPackage { + pname = "convoy-completions-parity"; + inherit version npmDepsHash; + src = self; + nodejs = pkgs.nodejs_24; + dontNpmBuild = true; + + # The suite skips a shell's syntax check when that shell is absent; give it all three. + nativeBuildInputs = [ + pkgs.bash + pkgs.zsh + pkgs.fish + ]; + + preBuild = linkSiblings "."; + + buildPhase = '' + runHook preBuild + export HOME=$(mktemp -d) + npx vitest run src/completions.test.ts + runHook postBuild + ''; + + installPhase = "touch $out"; + }; + + # convoy's own `tsc --noEmit`, run against the same sibling deps the package links. + checks.typecheck = pkgs.buildNpmPackage { + pname = "convoy-typecheck"; + inherit version; + src = self; + inherit npmDepsHash; + nodejs = pkgs.nodejs_24; + dontNpmBuild = true; + + preBuild = linkSiblings "."; + + buildPhase = '' + runHook preBuild + npm run typecheck + runHook postBuild + ''; + + installPhase = "touch $out"; + }; + + devShells.default = pkgs.mkShell { + packages = [ + pkgs.nodejs_24 + convoy + ]; + }; + } + ); +}