diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml new file mode 100644 index 00000000..4c2f8fec --- /dev/null +++ b/.github/workflows/nix.yml @@ -0,0 +1,38 @@ +name: Nix + +on: + push: + branches: [main] + pull_request: + branches: [main] + +concurrency: + group: nix-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +# All third-party Actions are pinned to a 40-char commit SHA with a trailing +# '# vX.Y.Z' comment so a compromised maintainer or moved tag cannot silently +# execute attacker code in CI. Bump the SHA + comment together when updating. + +jobs: + nix: + name: Nix build & check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - uses: DeterminateSystems/nix-installer-action@21a544727d0c62386e78b4befe52d19ad12692e3 # v22 + + - name: nix flake check + run: nix flake check --print-build-logs + + - name: nix build + run: nix build .#default --print-build-logs + + - name: Smoke test binary + run: | + ./result/bin/pad --version + ./result/bin/pad --help diff --git a/.gitignore b/.gitignore index c99b0579..7bde9835 100644 --- a/.gitignore +++ b/.gitignore @@ -52,6 +52,10 @@ coverage.txt # Distribution dist/ +# Nix build output +/result +/result-* + # Playwright e2e artifacts (scoped to the test fixture, never the # developer's real ~/.pad) .pad-e2e/ diff --git a/README.md b/README.md index 8450394f..29c0b6db 100644 --- a/README.md +++ b/README.md @@ -171,7 +171,7 @@ make build cp pad ~/.local/bin/ # or /usr/local/bin/ ``` -Requires Go 1.26+ and Node.js 22+. +Requires Go 1.26+ and Node.js 22+. Alternatively, `nix develop` provides a shell with the exact Go and Node versions pinned — see the [Nix](#nix) section below. The `go install github.com/PerpetualSoftware/pad/cmd/pad@latest` path is not supported for the full Pad binary, because the web UI must be built and embedded during the source build. @@ -200,6 +200,28 @@ docker run -p 7777:7777 -v pad-data:/data ghcr.io/perpetualsoftware/pad For multi-instance deployments, Pad supports Postgres + Redis via `docker-compose.yml` — see [docs/deployment.md](docs/deployment.md) for the full setup. +### Nix + +Run without installing: + +```bash +nix run github:PerpetualSoftware/pad +``` + +Or install into your profile: + +```bash +nix profile install github:PerpetualSoftware/pad +``` + +A flake devShell (Go, Node, and friends, pinned to the same versions CI uses) is also available for contributors: + +```bash +nix develop +``` + +> A `nixpkgs` package (`nix-shell -p pad` / `environment.systemPackages`) is planned but not yet merged upstream. Until then, use the `github:PerpetualSoftware/pad` flake reference above. + ### Binary Download Pre-built binaries for macOS, Linux, and Windows are available on the [releases page](https://github.com/PerpetualSoftware/pad/releases). diff --git a/flake.lock b/flake.lock new file mode 100644 index 00000000..a10f001a --- /dev/null +++ b/flake.lock @@ -0,0 +1,24 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 315532800, + "narHash": "sha256-/HjbyQqfFs3cG7o/zJ25RPaLZqS9a88z6vOGhMoZwRs=", + "rev": "597283ad8aa0b331c788e97c4c262d58877074ef", + "type": "tarball", + "url": "https://releases.nixos.org/nixos/26.05/nixos-26.05.5984.597283ad8aa0/nixexprs.tar.xz?lastModified=1784856561" + }, + "original": { + "type": "tarball", + "url": "https://channels.nixos.org/nixos-26.05/nixexprs.tar.xz" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 00000000..258cf273 --- /dev/null +++ b/flake.nix @@ -0,0 +1,54 @@ +{ + description = "Pad — local-first project management for developers and AI agents"; + + inputs.nixpkgs.url = "https://channels.nixos.org/nixos-26.05/nixexprs.tar.xz"; + + outputs = + { self, nixpkgs }: + let + systems = [ + "x86_64-linux" + "aarch64-linux" + "x86_64-darwin" + "aarch64-darwin" + ]; + forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system}); + in + { + packages = forAllSystems (pkgs: { + default = pkgs.callPackage ./nix/package.nix { }; + pad = pkgs.callPackage ./nix/package.nix { }; + }); + + apps = forAllSystems (pkgs: { + default = { + type = "app"; + program = "${self.packages.${pkgs.system}.default}/bin/pad"; + }; + }); + + devShells = forAllSystems (pkgs: { + default = pkgs.mkShell { + packages = with pkgs; [ + go_1_26 + nodejs_24 + golangci-lint + sqlite + gopls + ]; + + shellHook = '' + echo "pad dev shell: $(go version), node $(node --version)" + ''; + }; + }); + + checks = forAllSystems (pkgs: { + default = self.packages.${pkgs.system}.default; + version-smoke = pkgs.runCommand "pad-version-smoke" { } '' + ${self.packages.${pkgs.system}.default}/bin/pad --version + touch $out + ''; + }); + }; +} diff --git a/internal/mcp/dispatch_http_routes_extras_test.go b/internal/mcp/dispatch_http_routes_extras_test.go index be752311..71c58be6 100644 --- a/internal/mcp/dispatch_http_routes_extras_test.go +++ b/internal/mcp/dispatch_http_routes_extras_test.go @@ -1039,10 +1039,12 @@ func TestHTTPHandlerDispatcher_Integration_StarsRolesWebhooksWhoami(t *testing.T t.Errorf("expected no webhooks initially; got %v", hooks) } + // Literal IP (not a hostname) so ValidateWebhookURL passes without a + // DNS lookup — matches handlers_webhook_token_idor_test.go. createHookRes, err := d.Dispatch( WithDispatchInput(context.Background(), map[string]any{ "workspace": ws.Slug, - "url": "https://example.com/hook", + "url": "https://8.8.8.8/hook", "events": "item.created", }), []string{"webhook", "create"}, diff --git a/internal/server/handlers_webhooks_secret_test.go b/internal/server/handlers_webhooks_secret_test.go index dd09f1be..9c0fb8a5 100644 --- a/internal/server/handlers_webhooks_secret_test.go +++ b/internal/server/handlers_webhooks_secret_test.go @@ -35,8 +35,10 @@ func TestWebhookSecret_MaskedExceptOnCreate(t *testing.T) { base := "/api/v1/workspaces/" + ws.Slug + "/webhooks" // CREATE — the raw secret MUST be echoed back exactly once. + // Literal IP (not a hostname) so ValidateWebhookURL passes without a + // DNS lookup — matches handlers_webhook_token_idor_test.go. rr := doRequestWithCookie(srv, "POST", base, map[string]any{ - "url": "https://example.com/hook", + "url": "https://8.8.8.8/hook", "secret": secret, }, token) if rr.Code != http.StatusCreated { @@ -91,8 +93,10 @@ func TestWebhookSecret_RejectsReservedPrefix(t *testing.T) { t.Fatalf("CreateWorkspace: %v", err) } + // Literal IP (not a hostname) so ValidateWebhookURL passes without a + // DNS lookup — matches handlers_webhook_token_idor_test.go. rr := doRequestWithCookie(srv, "POST", "/api/v1/workspaces/"+ws.Slug+"/webhooks", map[string]any{ - "url": "https://example.com/hook", + "url": "https://8.8.8.8/hook", "secret": "enc:sneaky", }, token) if rr.Code != http.StatusBadRequest { diff --git a/nix/package.nix b/nix/package.nix new file mode 100644 index 00000000..bcbd8916 --- /dev/null +++ b/nix/package.nix @@ -0,0 +1,113 @@ +{ + lib, + stdenv, + buildGoModule, + go_1_26, + nodejs_24, + importNpmLock, +}: + +let + version = "0.11.0"; + + src = lib.fileset.toSource { + root = ../.; + fileset = lib.fileset.gitTracked ../.; + }; + + # SvelteKit static build (web/build) that gets embedded into the Go + # binary via `//go:embed all:web/build` in embed.go. Built separately + # so the Go derivation only needs a file copy, not a Node toolchain. + # Uses importNpmLock (per-package fetchurl against web/package-lock.json's + # own integrity hashes) rather than buildNpmPackage's npmDepsHash, so npm + # dependency updates never require discovering/updating a separate hash. + webUI = stdenv.mkDerivation { + pname = "pad-web"; + inherit version src; + sourceRoot = "source/web"; + + nativeBuildInputs = [ + nodejs_24 + importNpmLock.hooks.linkNodeModulesHook + ]; + + npmDeps = importNpmLock.buildNodeModules { + npmRoot = ../web; + nodejs = nodejs_24; + }; + + buildPhase = '' + runHook preBuild + # Without sandboxing, npm would otherwise write its cache to the + # real $HOME Nix sets for purity-checking (which must stay absent + # between derivations); keep it inside this build's own tmpdir. + export HOME="$TMPDIR" + npm run build + runHook postBuild + ''; + + installPhase = '' + runHook preInstall + cp -r build $out + runHook postInstall + ''; + }; +in +buildGoModule { + pname = "pad"; + inherit version src; + + go = go_1_26; + + # Update alongside go.sum. Regenerate via: + # nix build .#default 2>&1 | grep -A2 'got:' + vendorHash = "sha256-lfC5x/sS1KaC2AhGAJG1ozrbjJTHPTvqAq6ufGr+3Gk="; + + subPackages = [ "cmd/pad" ]; + + # subPackages also narrows buildGoModule's default checkPhase to just + # cmd/pad; override it to run the full `go test ./...` (matching CI), + # since cmd/loadtest-collab is an unrelated dev tool we don't ship. + checkPhase = '' + runHook preCheck + # Match buildGoModule's default checkPhase: don't trim source paths + # for tests, since some (e.g. invocation_framing_test.go) locate the + # repo root via runtime.Caller. + export GOFLAGS=''${GOFLAGS//-trimpath/} + # ValidateWebhookURL does a real net.LookupIP as an SSRF guard; + # these four subtests exercise that path against example.com, which + # needs DNS/network the Nix build sandbox deliberately doesn't have. + # Everything else in the package (invalid schemes, private-IP + # rejection, etc.) needs no network and still runs. + go test -skip 'TestValidateWebhookURL/valid_(https|http|with_port|with_path)$' ./... + runHook postCheck + ''; + + # Populate web/build with the real SvelteKit output before `go build` + # runs, so `//go:embed all:web/build` in embed.go has real files to + # embed. postPatch runs after patchPhase, before configure/build. + postPatch = '' + rm -rf web/build + cp -r ${webUI} web/build + ''; + + env.CGO_ENABLED = 0; + + ldflags = [ + "-s" + "-w" + "-X main.version=${version}" + ]; + + doCheck = true; + + meta = { + description = "Local-first project management for developers and AI agents"; + homepage = "https://github.com/PerpetualSoftware/pad"; + changelog = "https://github.com/PerpetualSoftware/pad/releases/tag/v${version}"; + license = lib.licenses.asl20; + mainProgram = "pad"; + platforms = lib.platforms.unix; + maintainers = [ ]; + }; +}