From 7775e8f38960aee54d5aae6eaa6f0fc245bb6f58 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Mon, 20 Jul 2026 17:32:34 +0200 Subject: [PATCH 1/2] feat(nix): self-contained flake + `nix flake check` as CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit convoy had no flake and no CI. This adds both, and they are the same thing: `nix flake check` is the gate. The flake is thin and self-contained — nixpkgs, flake-utils, and the two sibling packages from their own flakes. It builds from `self`, so it packages whatever revision it ships with rather than a pinned fetchFromGitHub. Why a flake check and not a Node workflow: convoy depends on smalltalk and pty via `file:../sibling`, so a plain `npm ci` in CI needs those repos checked out next to convoy. Flake inputs solve that properly — the sibling packages come from their own flakes, pinned in flake.lock, and the sibling node_modules links are expressed declaratively as an npm-name -> store-path attrset. Checks (all run a REAL built binary, not a source tree): - help — `convoy --help` - completions — generates bash/zsh/fish, asserts each is non-empty, that fish still binds to `convoy`, and that each shell accepts its own generated script (`-n`). This is where zsh actually gets syntax-checked; it is often absent from a dev machine. - typecheck — convoy's own `tsc --noEmit`, against the same sibling deps. The vitest suite is deliberately not wired up: parts of it shell out to `git worktree` and probe the host, which the sandbox cannot provide. Completions are generated at build time from the just-built binary and installed via installShellCompletion, so what ships can never drift from src/command-table.ts. The smalltalk input points at the branch of compoundingtech/smalltalk#104 (the PR adding smalltalk's flake) because that flake does not exist on its main yet; there is a TODO to repoint once it merges. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01T1xLDkqUDCYUMMADdMqVgP agent-session-id: 312caff5-3274-4d97-baa4-8ff06ab03fc5 agent-tool: Claude Code agent-tool-version: 2.1.215 agent-model: claude-opus-4-8 agent-runtime-profile: /nix/store/acr8a3l2v366jgmwiq8xdrhgz1py0db5-coding-agent-runtime-profile/share/coding-agents/profile.json agent-skills-manifest: /nix/store/sj1v5j91h8v8d1w9lca4040302lwrd6v-agent-skills-corpus/share/agent-skills/manifest.json tooling-profile: dotfiles@unknown-dirty --- .github/workflows/nix.yml | 23 +++++ .gitignore | 4 + flake.lock | 110 ++++++++++++++++++++++ flake.nix | 193 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 330 insertions(+) create mode 100644 .github/workflows/nix.yml create mode 100644 flake.lock create mode 100644 flake.nix 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..3401aa0 --- /dev/null +++ b/flake.lock @@ -0,0 +1,110 @@ +{ + "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": 1784561255, + "narHash": "sha256-3B8EyQiGtem0wCbZfF+UE370CiCuGoK1kjvrnNlqB64=", + "owner": "compoundingtech", + "repo": "smalltalk", + "rev": "a598a5e86f33570e90623d5205b634ee96293ad7", + "type": "github" + }, + "original": { + "owner": "compoundingtech", + "ref": "schickling-assistant/2026-07-20-nix-flake", + "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..0d5aafa --- /dev/null +++ b/flake.nix @@ -0,0 +1,193 @@ +{ + 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"; + + # TODO: repoint to `github:compoundingtech/smalltalk` once compoundingtech/smalltalk#104 (which + # adds smalltalk's flake) merges — that branch is the only place the flake exists today. + smalltalk.url = "github:compoundingtech/smalltalk/schickling-assistant/2026-07-20-nix-flake"; + 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 + ''; + + # convoy's own `tsc --noEmit`, run against the same sibling deps the package links. The vitest + # suite is deliberately NOT wired up here: parts of it shell out to `git worktree` and probe + # the host, which the sandbox cannot provide. + 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 + ]; + }; + } + ); +} From ceb0c62d519a28f4685051ee04628224f1c0ea29 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Mon, 20 Jul 2026 17:39:48 +0200 Subject: [PATCH 2/2] feat(nix): run the completions parity suite as a flake check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The suite in src/completions.test.ts is what keeps the command table, argv dispatch, and the generated scripts from drifting — but nothing ran it automatically. It is 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. bash, zsh, and fish are in the check's inputs so its per-shell syntax assertions actually run instead of skipping. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01T1xLDkqUDCYUMMADdMqVgP agent-session-id: 312caff5-3274-4d97-baa4-8ff06ab03fc5 agent-tool: Claude Code agent-tool-version: 2.1.215 agent-model: claude-opus-4-8 agent-runtime-profile: /nix/store/acr8a3l2v366jgmwiq8xdrhgz1py0db5-coding-agent-runtime-profile/share/coding-agents/profile.json agent-skills-manifest: /nix/store/sj1v5j91h8v8d1w9lca4040302lwrd6v-agent-skills-corpus/share/agent-skills/manifest.json tooling-profile: dotfiles@unknown-dirty --- flake.nix | 34 +++++++++++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/flake.nix b/flake.nix index 0d5aafa..25e97ef 100644 --- a/flake.nix +++ b/flake.nix @@ -160,9 +160,37 @@ touch $out ''; - # convoy's own `tsc --noEmit`, run against the same sibling deps the package links. The vitest - # suite is deliberately NOT wired up here: parts of it shell out to `git worktree` and probe - # the host, which the sandbox cannot provide. + # 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;