diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml new file mode 100644 index 0000000..a0aed28 --- /dev/null +++ b/.github/workflows/nix.yml @@ -0,0 +1,15 @@ +name: Nix +on: + pull_request: + push: + branches: [main] + +jobs: + check: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + - uses: DeterminateSystems/determinate-nix-action@v3 + # `nix flake check` rather than `nix build`, so the checks.* gate too. + - run: nix flake check --print-build-logs diff --git a/.gitignore b/.gitignore index 44d7e4a..edf660d 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,8 @@ dist/ !browser/dist/ *.tsbuildinfo test-results/ +result +result-* .env* .claude/ pty.toml diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..f1ba415 --- /dev/null +++ b/flake.lock @@ -0,0 +1,82 @@ +{ + "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" + } + }, + "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..452d3cb --- /dev/null +++ b/flake.nix @@ -0,0 +1,155 @@ +{ + description = "pty-relay — remote access to pty sessions over an end-to-end encrypted WebSocket tunnel"; + + 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"; + }; + + outputs = + { + self, + nixpkgs, + flake-utils, + pty, + }: + flake-utils.lib.eachDefaultSystem ( + system: + let + pkgs = import nixpkgs { inherit system; }; + + # Node runtime for both the derivation's npm steps and the bin shim, + # so a build and a run can never disagree on the interpreter. + nodejs = pkgs.nodejs_24; + + # Unpublished sibling packages, resolved from their own flakes and + # linked into node_modules at install time. npm records + # `@compoundingtech/pty` as `file:../pty`, which is a dangling link + # inside the sandbox; these store paths are what actually resolve. + # TODO(rust): the Rust rewrite links this natively — drop the map. + siblingPackages = { + "@compoundingtech/pty" = "${pty.packages.${system}.default}/lib/pty"; + }; + + linkSiblings = pkgs.lib.concatStringsSep "\n" ( + pkgs.lib.mapAttrsToList (name: path: '' + mkdir -p "$out/lib/pty-relay/node_modules/${builtins.dirOf name}" + rm -rf "$out/lib/pty-relay/node_modules/${name}" + ln -s ${path} "$out/lib/pty-relay/node_modules/${name}" + '') siblingPackages + ); + + # Single source of truth: package.json. Build identity beyond the + # semver (commit rev, build date) is the org's shared build-identity + # contract, not something this flake invents. + version = (builtins.fromJSON (builtins.readFile ./package.json)).version; + + pty-relay = pkgs.buildNpmPackage { + pname = "pty-relay"; + inherit version nodejs; + + src = self; + + # TODO(rust): cargo's lockfile is content-addressed; this vendoring + # hash disappears with the npm dependency tree. + # Regenerate with: nix run nixpkgs#prefetch-npm-deps -- package-lock.json + npmDepsHash = "sha256-wDKiIRJivnTFd0dXCdKw+GoLJA6T53a/5sDCsbxvkUU="; + + # pty-relay ships as raw TypeScript executed by Node with native + # type stripping — no compile step. Only the browser bundle has one, + # and the daemon/CLI don't need it. + dontNpmBuild = true; + + nativeBuildInputs = [ pkgs.installShellFiles ]; + + # Installed outside node_modules so Node's type-stripping works on + # src/cli.ts (Node refuses to strip types inside node_modules). + installPhase = '' + runHook preInstall + + mkdir -p $out/lib/pty-relay + cp -r . $out/lib/pty-relay + + # TODO(rust): sibling linking is an npm-workspace workaround. + ${linkSiblings} + + # TODO(rust): a compiled binary needs no interpreter shim. + mkdir -p $out/bin + cat > $out/bin/pty-relay < /dev/null + touch $out + ''; + + completions = pkgs.runCommand "pty-relay-completions" { } '' + export HOME=$(mktemp -d) + for shell in bash zsh fish; do + ${pty-relay}/bin/pty-relay completions $shell > script + test -s script || { echo "empty $shell completions"; exit 1; } + done + touch $out + ''; + }; + + devShells.default = pkgs.mkShell { + packages = [ + nodejs + pty.packages.${system}.default + ]; + }; + } + ); +} diff --git a/vitest.config.ts b/vitest.config.ts index b08afcb..8f9c51c 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,7 +3,9 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { globals: true, - exclude: ["integration/**", "node_modules/**"], + // `result` is the nix build symlink; it contains a full copy of the + // source tree, so without this every test would be collected twice. + exclude: ["integration/**", "node_modules/**", "result/**"], setupFiles: ["./test/setup.ts"], globalSetup: ["./test/setup/vitest-global.ts"], },