diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml new file mode 100644 index 0000000..2d2ac2e --- /dev/null +++ b/.github/workflows/nix.yml @@ -0,0 +1,17 @@ +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 + # `nix flake check` rather than `nix build`: it builds the package *and* + # evaluates every `checks.*`, so the help smoke test and the completions + # contract actually gate the PR. + - run: nix flake check --print-build-logs diff --git a/.gitignore b/.gitignore index ff18ab3..6ecfea4 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,6 @@ notes/agent-onboarding.md # Nathan's rule: execution over planning-docs-in-PRs. Local working # reference for the smalltalk→st cutover — never committed. notes/rename-cutover.md + +# `nix build` output symlink +/result 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..eda9c0c --- /dev/null +++ b/flake.nix @@ -0,0 +1,203 @@ +{ + description = "smalltalk - file-folder coordination CLI and TypeScript API"; + + 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; }; + + # package.json is the single source of truth for the version, so a + # release bump does not need a matching edit here. Reconciling this + # with a richer build identity (git rev, dirty marker) is issue #103. + version = (builtins.fromJSON (builtins.readFile ./package.json)).version; + + # npm names of 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 key must match package.json / package-lock.json exactly, since + # that is the specifier Node resolves. + siblingLinks = { + "@compoundingtech/pty" = "${pty.packages.${system}.default}/lib/pty"; + }; + + # The shells `st completions ` can generate. The generator emits + # `complete -c st` etc. unconditionally, so every script is for `st` + # regardless of which bin name invoked it. + completionShells = [ + "bash" + "zsh" + "fish" + ]; + + smalltalk = pkgs.buildNpmPackage { + pname = "smalltalk"; + inherit version; + src = self; + + # 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-CfgSssHEdSjL9liKjaw9aYKMtCJdaxWKGbFM59PgNwg="; + + nodejs = pkgs.nodejs_24; + npmInstallFlags = [ "--omit=dev" ]; + # Sources ship as TypeScript and run through node's type stripping; + # there is no compile step to produce a dist/. + dontNpmBuild = true; + + nativeBuildInputs = [ pkgs.installShellFiles ]; + + installPhase = '' + runHook preInstall + + mkdir -p $out/lib/smalltalk + cp -r . $out/lib/smalltalk + + # TODO(rust): sibling linking exists only because npm cannot resolve + # `file:` deps in a sandbox; a Cargo build has no equivalent. + ${pkgs.lib.concatLines ( + pkgs.lib.mapAttrsToList (npmName: storePath: '' + mkdir -p "$out/lib/smalltalk/node_modules/$(dirname ${npmName})" + ln -s ${storePath} "$out/lib/smalltalk/node_modules/${npmName}" + '') siblingLinks + )} + + # package.json declares `st` and `smalltalk`; both reach the same + # cli.ts. `_ST_INVOKED_AS` is what bin/st exports so help banners and + # the MCP server report the name the user actually typed. + # TODO(rust): a compiled binary replaces these strip-types shims. + mkdir -p $out/bin + for _binName in st smalltalk; do + cat > $out/bin/$_binName < completions-${shell} + '') completionShells} + + installShellCompletion --cmd st \ + --bash completions-bash \ + --zsh completions-zsh \ + --fish completions-fish + ''; + + meta = { + description = "File-folder coordination CLI and embeddable TypeScript API"; + homepage = "https://github.com/compoundingtech/smalltalk"; + license = pkgs.lib.licenses.mit; + mainProgram = "st"; + }; + }; + # Base for checks that need devDependencies (tsc, vitest), which the + # shipped package deliberately omits. + # + # Two things make this awkward enough to be worth explaining. First, + # npm cannot resolve the `file:../pty` devDependency from a store + # symlink: it chmods the linked `bin/pty`, which is read-only in the + # store, and fails EPERM. So the sibling is *copied* to the exact + # relative path the lockfile names and made writable. Second, + # `--ignore-scripts` keeps node-pty's native build (`hasInstallScript`) + # out of the sandbox; nothing gated here needs the native addon. + # + # These run the check in the build dir and `touch $out` rather than + # installing anything, so the store-external `../pty` symlink npm + # leaves in node_modules never has to survive into the output. + mkDevCheck = + name: script: + smalltalk.overrideAttrs (old: { + pname = "smalltalk-${name}"; + npmInstallFlags = [ ]; + npmFlags = [ "--ignore-scripts" ]; + postPatch = '' + cp -r ${pty.packages.${system}.default}/lib/pty ../pty + chmod -R u+w ../pty + ''; + installPhase = '' + runHook preInstall + export HOME=$(mktemp -d) + ${script} + touch $out + runHook postInstall + ''; + postInstall = ""; + # Nothing is installed, so the fixup hooks (patchelf, + # noBrokenSymlinks) have nothing useful to do. + dontFixup = true; + }); + in + { + packages.smalltalk = smalltalk; + packages.default = smalltalk; + + checks.help = pkgs.runCommand "smalltalk-help-${version}" { } '' + export HOME=$(mktemp -d) + ${smalltalk}/bin/st --help > /dev/null + touch $out + ''; + + # Guards the completions contract: every installed shell still gets a + # non-empty script, and fish in particular still binds to `st` (the name + # the installed st.fish file claims). + checks.completions = pkgs.runCommand "smalltalk-completions-${version}" { } '' + export HOME=$(mktemp -d) + ${pkgs.lib.concatMapStringsSep "\n" (shell: '' + ${smalltalk}/bin/st completions ${shell} | grep -q . \ + || { echo "empty ${shell} completions" >&2; exit 1; } + '') completionShells} + + ${smalltalk}/bin/st completions fish | grep -q '^complete -c st ' \ + || { echo "fish completions do not bind to \`st\`" >&2; exit 1; } + + touch $out + ''; + + # `npm run build` is typecheck-only (both tsconfigs set `noEmit`), so + # this gates src/ and examples/ against the compiler. + checks.typecheck = mkDevCheck "typecheck" "npm run build"; + + # The vitest `unit` project is NOT gated here yet. It very nearly works + # (1322/1323 pass in the sandbox), but tests/unit/cli.test.ts asserts + # `st +`, and the SHA comes from shelling out to + # `git rev-parse` in the source dir — which a hermetic build has no + # `.git` for. That is the build-identity question tracked in #103, not + # something to paper over here. The `integration` project is a separate + # matter: it shells out to rsync and spawns real pty sessions, which the + # build sandbox has no business hosting. + + devShells.default = pkgs.mkShell { + packages = [ + pkgs.nodejs_24 + smalltalk + ]; + }; + } + ); +}