Skip to content
This repository was archived by the owner on Jul 24, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .github/workflows/nix.yml
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,7 @@ DING-BUS.md

# Session marker
.claude-session-id

# Nix build results
result
result-*
110 changes: 110 additions & 0 deletions flake.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

221 changes: 221 additions & 0 deletions flake.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
{
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 <<EOF
#!${pkgs.runtimeShell}
exec ${pkgs.nodejs_24}/bin/node --experimental-strip-types \\
$out/lib/convoy/bin/convoy "\$@"
EOF
chmod +x $out/bin/convoy

runHook postInstall
'';

# Completions are generated by the binary we just built rather than committed, so they cannot
# drift from the command table in src/command-table.ts. The CLI touches $HOME on startup,
# which is not writable in the sandbox.
postInstall = ''
export HOME=$(mktemp -d)
${pkgs.lib.concatMapStringsSep "\n" (shell: ''
$out/bin/convoy completions ${shell} > 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
];
};
}
);
}
Loading