Skip to content
Open
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
38 changes: 38 additions & 0 deletions .github/workflows/nix.yml
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
24 changes: 23 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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).
Expand Down
24 changes: 24 additions & 0 deletions flake.lock

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

54 changes: 54 additions & 0 deletions flake.nix
Original file line number Diff line number Diff line change
@@ -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
'';
});
};
}
4 changes: 3 additions & 1 deletion internal/mcp/dispatch_http_routes_extras_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down
8 changes: 6 additions & 2 deletions internal/server/handlers_webhooks_secret_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
113 changes: 113 additions & 0 deletions nix/package.nix
Original file line number Diff line number Diff line change
@@ -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 = [ ];
};
}