diff --git a/.github/workflows/nix-build.yml b/.github/workflows/nix-build.yml new file mode 100644 index 0000000..4f70254 --- /dev/null +++ b/.github/workflows/nix-build.yml @@ -0,0 +1,67 @@ +name: CI — Nix Builds + +on: + pull_request: + push: + branches: + - main + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + nix-build-x86_64-linux: + name: Nix Build (x86_64-linux) + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Nix + uses: DeterminateSystems/nix-installer-action@main + + - name: Nix flake check + run: nix flake check + + - name: Check formatting + run: nix fmt -- --fail-on-change + + nix-build-aarch64-darwin: + name: Nix Build (aarch64-darwin) + runs-on: macos-latest + timeout-minutes: 60 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Nix + uses: DeterminateSystems/nix-installer-action@main + + - name: Nix flake check + run: nix flake check + + nix-build-aarch64-linux: + name: Nix Build (aarch64-linux) + runs-on: ubuntu-24.04-arm + timeout-minutes: 60 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Nix + uses: DeterminateSystems/nix-installer-action@main + + # KVM is not available on GitHub ARM runners, so the NixOS VM + # integration test cannot run here. Build packages and run linters + # only; the full VM test runs in the x86_64-linux job. + - name: Build packages + run: | + nix build .#packages.aarch64-linux.happier-server + nix build .#packages.aarch64-linux.happier-cli + + - name: Lint + run: | + nix build .#checks.aarch64-linux.deadnix + nix build .#checks.aarch64-linux.statix diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..4cc2563 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,88 @@ +# nix-happier — Agent Guidelines + +This file provides guidance for AI coding agents (Claude Code, Copilot, Codex, etc.) working in this repo. + +## What this repo is + +A Nix flake that builds and deploys [Happier](https://github.com/happier-dev/happier) Server and CLI. It does **not** contain application source code — the happier monorepo is fetched as a flake input. This repo only has Nix expressions, NixOS modules, and supporting config. + +## Repo layout + +``` +flake.nix # Flake entrypoint (inputs, systems, imports) +packages.nix # happier-server + happier-cli derivations +checks.nix # deadnix, statix, NixOS VM integration test +devshell.nix # Dev shell (fmt, update commands) +modules/nixos/happier-server.nix # NixOS service module +packages/prisma-engines-prebuilt.nix # Prebuilt Prisma engine binaries +examples/happier-server-tailscale.nix # Recommended production setup (Tailscale + Caddy) +examples/happier-server-light.nix # Minimal config (used by CI VM test) +``` + +## Language and tooling + +- **All code is Nix.** There is no TypeScript, Python, or other source code here. +- **Formatter**: `nixfmt-tree` (the flake's `formatter`). Run `nix fmt` to format. +- **Linters**: `deadnix` (unused bindings) and `statix` (anti-patterns). Both run via `nix flake check`. +- **statix config**: `statix.toml` disables `repeated_keys` (W20) — NixOS modules idiomatically use separate top-level assignments with `lib.mkIf` guards. + +## Nix style conventions + +- Use `nixfmt-tree` style (the RFC-style formatter). Do not manually reformat — run `nix fmt`. +- Prefer `lib.mkIf` / `lib.mkOption` / `lib.optional` over raw `if-then-else` in NixOS modules. +- Use `let ... in` for local bindings. Keep `let` blocks close to where they're used. +- Avoid `with pkgs;` at module scope — use it only in narrow scopes (e.g. inside `buildInputs` lists). +- `flake-parts` is used for per-system logic. New per-system outputs go in dedicated `.nix` files imported by `flake.nix`. +- Comments should explain *why*, not *what*. Nix expressions are usually self-documenting. + +## Key patterns + +### Flake structure + +The flake uses [flake-parts](https://github.com/hercules-ci/flake-parts). Per-system outputs (packages, checks, devshell, apps) are split into separate files and imported in `flake.nix`. Flake-level outputs (`nixosModules`) are defined in the `flake = { ... }` attrset. + +### NixOS module + +`modules/nixos/happier-server.nix` defines `services.happier-server` options. It supports two modes: +- **full**: PostgreSQL + Redis + MinIO (production stack) +- **light**: SQLite-only, no external deps + +The module provisions supporting services (PostgreSQL, Redis, MinIO) when `createLocally = true` and handles migrations, WAL mode, and secret loading via `systemd` `LoadCredential`. + +Both modes require an `environmentFile` containing `HANDY_MASTER_SECRET` for production use. The only exception is the CI VM test, which omits it. + +### Package builds + +Both `happier-server` and `happier-cli` are built from the happier monorepo source (`inputs.happier`). Prisma engines come from `packages/prisma-engines-prebuilt.nix` which fetches prebuilt binaries by hash. + +### Updating dependencies + +When the happier monorepo updates `@prisma/client`, the engine hashes here must also be updated: + +```sh +nix run .#update # updates flake inputs + Prisma hashes +nix run .#update-prisma-hashes # updates only Prisma hashes +``` + +## Verification + +Always verify changes with: + +```sh +nix fmt # format +nix flake check # lint (deadnix, statix) + VM integration test (Linux) +nix build .#happier-server # build the server package +``` + +`nix flake check` runs the NixOS VM integration test on Linux — it boots a VM with the light-mode example and verifies the server starts and responds on port 3005. + +## Do's and don'ts + +- **Do** run `nix fmt` after every change. +- **Do** run `nix flake check` before considering work done (or at minimum `nix build`). +- **Do** keep the NixOS module options in sync with the README's "Module options" table. +- **Do** add examples to `examples/` for new configurations and reference them in checks if testable. +- **Don't** modify `flake.lock` by hand — use `nix flake update` or `nix run .#update`. +- **Don't** add packages, commands, or dependencies that belong in the happier monorepo, not here. +- **Don't** introduce `with pkgs;` at module or file scope. +- **Don't** create migrations or modify Prisma schemas — that happens in the happier monorepo. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/README.md b/README.md index 8b13789..a492e18 100644 --- a/README.md +++ b/README.md @@ -1 +1,195 @@ +# nix-happier +Nix flake for building and deploying [Happier](https://github.com/happier-dev/happier) Server and CLI. + +> **Pre-release notice:** The `happier` flake input currently tracks the `main` branch. This will be pinned to tagged releases once Happier reaches a stable version. Updates to this flake are made manually for now — run `nix run .#update` to pull the latest. + +## Flake outputs + +| Output | Description | +|--------|-------------| +| `packages.happier-server` | Happier Server (full + light mode binaries) | +| `packages.happier-cli` | Happier CLI | +| `nixosModules.happier-server` | NixOS module for running Happier Server as a systemd service | +| `checks.deadnix` | Unused binding detection | +| `checks.statix` | Nix anti-pattern linting | +| `checks.nixos-happier-server-light` | NixOS VM integration test (Linux only) | +| `apps.update-prisma-hashes` | Update Prisma engine binary hashes | +| `apps.update` | Update all flake inputs + Prisma hashes | +| `devShells.default` | Dev shell with git and nixfmt | + +Supported systems: `aarch64-darwin`, `aarch64-linux`, `x86_64-linux` + +## Secrets + +All deployment modes require an **environment file** containing secrets. At minimum: + +```sh +HANDY_MASTER_SECRET= +``` + +Pass it to the module via `environmentFile`. Use [agenix](https://github.com/ryantm/agenix), [sops-nix](https://github.com/Mic92/sops-nix), or a plain file with restricted permissions — whatever fits your secrets workflow. + +## Quick start + +Add the flake input and import the NixOS module: + +```nix +{ + inputs = { + nixpkgs.url = "github:nixos/nixpkgs/nixos-25.11"; + nix-happier.url = "github:happier-dev/nix-happier"; + }; + + outputs = { nixpkgs, nix-happier, ... }: { + nixosConfigurations.myhost = nixpkgs.lib.nixosSystem { + system = "x86_64-linux"; + modules = [ + nix-happier.nixosModules.happier-server + { + services.happier-server = { + enable = true; + package = nix-happier.packages.x86_64-linux.happier-server; + mode = "light"; + environmentFile = "/run/secrets/happier-env"; + }; + } + ]; + }; + }; +} +``` + +### Recommended: Tailscale + Caddy + +The most common setup serves Happier over your Tailscale network with automatic TLS via Caddy. See [`examples/happier-server-tailscale.nix`](examples/happier-server-tailscale.nix) for a complete configuration that includes: + +- Happier Server in light mode on `localhost:3005` +- Tailscale for private networking +- Caddy reverse proxy with automatic TLS cert provisioning + +### Light mode (minimal) + +See [`examples/happier-server-light.nix`](examples/happier-server-light.nix) for the bare minimum. This is what the CI integration test uses — it omits `environmentFile` and networking, so it's useful as a starting point but not production-ready on its own. + +### Full mode (PostgreSQL + Redis + MinIO) + +With `createLocally = true` (the default), PostgreSQL, Redis, and MinIO are all provisioned on the same host as single-node instances. This is convenient for small deployments but comes with no built-in replication or backups — **you are responsible for setting up your own backup strategy** (e.g. `pgBackRest`, `restic`, or filesystem snapshots). + +```nix +{ + services.happier-server = { + enable = true; + package = nix-happier.packages.x86_64-linux.happier-server; + mode = "full"; + port = 3005; + environmentFile = "/run/secrets/happier-env"; + + database = { + name = "happier"; + user = "happier"; + createLocally = true; # provisions PostgreSQL 15 + }; + + redis.createLocally = true; + + minio = { + createLocally = true; + bucket = "happier"; + rootCredentialsFile = "/run/secrets/minio-creds"; # MINIO_ROOT_USER + MINIO_ROOT_PASSWORD + }; + }; +} +``` + +## Module options + +### Core + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `enable` | bool | `false` | Enable Happier Server | +| `package` | package | — | The `happier-server` package to use | +| `port` | port | `3005` | Port to listen on | +| `mode` | `"full"` \| `"light"` | `"full"` | `full` = PostgreSQL + Redis + MinIO; `light` = SQLite only | +| `environmentFile` | path \| null | `null` | **Required for production.** Secrets file (`KEY=value`) — must contain `HANDY_MASTER_SECRET` | + +### Database (full mode) + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `database.name` | string | `"happier"` | PostgreSQL database name | +| `database.user` | string | `"happier"` | PostgreSQL user | +| `database.createLocally` | bool | `true` | Provision PostgreSQL locally | + +### Redis (full mode) + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `redis.createLocally` | bool | `true` | Provision Redis locally | + +### MinIO (full mode) + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `minio.createLocally` | bool | `true` | Provision MinIO locally for S3-compatible storage | +| `minio.bucket` | string | `"happier"` | MinIO bucket name | +| `minio.rootCredentialsFile` | path \| null | `null` | File with `MINIO_ROOT_USER` and `MINIO_ROOT_PASSWORD` | + +## Examples + +The [`examples/`](examples/) directory contains NixOS configurations: + +| Example | Description | +|---------|-------------| +| [`happier-server-tailscale.nix`](examples/happier-server-tailscale.nix) | Recommended production setup — light mode + Tailscale + Caddy TLS | +| [`happier-server-light.nix`](examples/happier-server-light.nix) | Bare minimum for CI — tested via `nix flake check` VM integration test | + +## Development + +Enter the dev shell: + +```sh +nix develop +``` + +Available commands inside the shell: + +| Command | Description | +|---------|-------------| +| `fmt` | Format Nix files | +| `update` | Update flake inputs + Prisma hashes | + +Run linting and integration tests: + +```sh +nix flake check +``` + +Update all inputs and refresh Prisma engine hashes: + +```sh +nix run .#update +``` + +## Repo structure + +``` +. +├── flake.nix # Flake entrypoint +├── flake.lock +├── packages.nix # happier-server + happier-cli derivations +├── checks.nix # deadnix, statix, NixOS VM test +├── devshell.nix # Dev shell with commands +├── modules/ +│ └── nixos/ +│ └── happier-server.nix # NixOS module +├── packages/ +│ └── prisma-engines-prebuilt.nix # Prebuilt Prisma engine binaries +├── examples/ +│ ├── happier-server-tailscale.nix # Production setup with Tailscale + Caddy +│ └── happier-server-light.nix # Minimal config (used by CI) +└── .github/ + └── workflows/ + └── nix-build.yml # CI workflow +``` diff --git a/checks.nix b/checks.nix new file mode 100644 index 0000000..d3d5444 --- /dev/null +++ b/checks.nix @@ -0,0 +1,56 @@ +# Flake checks: linting (deadnix, statix) and NixOS VM integration test +{ + self, + ... +}: +{ + perSystem = + { + pkgs, + lib, + config, + ... + }: + { + checks = { + # Detect unused Nix bindings + deadnix = pkgs.runCommand "deadnix" { nativeBuildInputs = [ pkgs.deadnix ]; } '' + deadnix --fail ${self} + touch $out + ''; + + # Detect Nix anti-patterns + statix = pkgs.runCommand "statix" { nativeBuildInputs = [ pkgs.statix ]; } '' + cd ${self} && statix check . + touch $out + ''; + } + // lib.optionalAttrs pkgs.stdenv.hostPlatform.isLinux { + # NixOS VM integration test — light mode (SQLite-only, no external deps) + nixos-happier-server-light = pkgs.testers.runNixOSTest { + name = "happier-server-light"; + + nodes.server = + { ... }: + { + imports = [ + self.nixosModules.happier-server + ./examples/happier-server-light.nix + ]; + services.happier-server.package = config.packages.happier-server; + virtualisation.memorySize = 2048; + }; + + testScript = '' + server.wait_for_unit("happier-server-migrate.service") + server.wait_for_unit("happier-server-sqlite-wal.service") + server.wait_for_unit("happier-server.service") + server.wait_for_open_port(3005) + + # Verify server responds (any HTTP response = service is up) + server.succeed("curl -sf http://localhost:3005/ || curl -sf -o /dev/null -w '%{http_code}' http://localhost:3005/ | grep -qE '^[0-9]'") + ''; + }; + }; + }; +} diff --git a/devshell.nix b/devshell.nix new file mode 100644 index 0000000..12bc991 --- /dev/null +++ b/devshell.nix @@ -0,0 +1,43 @@ +_: + +{ + perSystem = + { + pkgs, + ... + }: + { + devshells = { + default = { + packages = [ + pkgs.git + pkgs.nixfmt-rfc-style + ]; + + env = [ + { + name = "LANG"; + value = "en_US.UTF-8"; + } + ]; + + commands = [ + { + name = "fmt"; + help = "Format Nix files"; + command = '' + find . -name "*.nix" -type f -print0 | xargs -0 nixfmt + ''; + } + { + name = "update"; + help = "Update all flake inputs and refresh Prisma engine hashes"; + command = '' + nix run .#update + ''; + } + ]; + }; + }; + }; +} diff --git a/examples/happier-server-full.nix b/examples/happier-server-full.nix new file mode 100644 index 0000000..3f7ce65 --- /dev/null +++ b/examples/happier-server-full.nix @@ -0,0 +1,58 @@ +# Example: Happier Server in full mode (PostgreSQL + Redis + MinIO). +# +# This is the recommended production setup. It: +# - Runs happier-server on localhost:3005 with the full backing stack +# - Provisions PostgreSQL, Redis, and MinIO locally (createLocally = true) +# - Uses Tailscale for private networking (not exposed to the public internet) +# - Terminates TLS via Caddy, which auto-provisions certs from Tailscale +# - Loads secrets via agenix (environmentFile + MinIO credentials) +# +# Prerequisites: +# - A Tailscale account with HTTPS enabled (admin console → DNS → Enable HTTPS) +# - An environment file containing at minimum: +# HANDY_MASTER_SECRET= +# - A MinIO credentials file containing: +# MINIO_ROOT_USER= +# MINIO_ROOT_PASSWORD= +# +# Replace "happier.example.ts.net" with your machine's Tailscale FQDN. + +{ config, ... }: +{ + # -- Happier Server (full mode) ---------------------------------------------- + + services.happier-server = { + enable = true; + port = 3005; + mode = "full"; + # Required — must contain HANDY_MASTER_SECRET at minimum. + # Use agenix, sops-nix, or a plain file with restricted permissions. + environmentFile = config.age.secrets.happier-env.path; + minio.rootCredentialsFile = config.age.secrets.minio-credentials.path; + }; + + # -- Secrets (agenix) -------------------------------------------------------- + + age.secrets.happier-env.file = ../secrets/happier-env.age; + age.secrets.minio-credentials.file = ../secrets/minio-credentials.age; + + # -- Tailscale ---------------------------------------------------------------- + + services.tailscale.enable = true; + # Allow Caddy to provision TLS certs via Tailscale + services.tailscale.permitCertUid = "caddy"; + + networking.firewall = { + trustedInterfaces = [ "tailscale0" ]; + allowedUDPPorts = [ 41641 ]; # Tailscale + }; + + # -- Caddy reverse proxy (auto TLS via Tailscale) ----------------------------- + + services.caddy = { + enable = true; + virtualHosts."happier.example.ts.net".extraConfig = '' + reverse_proxy localhost:3005 + ''; + }; +} diff --git a/examples/happier-server-light.nix b/examples/happier-server-light.nix new file mode 100644 index 0000000..92b9683 --- /dev/null +++ b/examples/happier-server-light.nix @@ -0,0 +1,13 @@ +# Minimal Happier Server config — light mode (SQLite, no external dependencies). +# +# Used by the CI integration test (nix flake check). The consumer must set +# services.happier-server.package externally. +# +# For a production-ready setup with TLS and Tailscale, see: +# examples/happier-server-tailscale.nix +{ + services.happier-server = { + enable = true; + mode = "light"; + }; +} diff --git a/examples/happier-server-tailscale.nix b/examples/happier-server-tailscale.nix new file mode 100644 index 0000000..5c87e07 --- /dev/null +++ b/examples/happier-server-tailscale.nix @@ -0,0 +1,47 @@ +# Example: Happier Server behind Tailscale with Caddy TLS reverse proxy. +# +# This is the recommended production setup for light mode. It: +# - Runs happier-server on localhost:3005 (SQLite, no external deps) +# - Uses Tailscale for private networking (not exposed to the public internet) +# - Terminates TLS via Caddy, which auto-provisions certs from Tailscale +# - Loads secrets (HANDY_MASTER_SECRET) from an environment file +# +# Prerequisites: +# - A Tailscale account with HTTPS enabled (admin console → DNS → Enable HTTPS) +# - An environment file containing at minimum: +# HANDY_MASTER_SECRET= +# +# Replace "happier.example.ts.net" with your machine's Tailscale FQDN. + +{ + # -- Happier Server (light mode) ------------------------------------------- + + services.happier-server = { + enable = true; + mode = "light"; + port = 3005; + # Required — must contain HANDY_MASTER_SECRET at minimum. + # Use agenix, sops-nix, or a plain file with restricted permissions. + environmentFile = "/run/secrets/happier-env"; + }; + + # -- Tailscale -------------------------------------------------------------- + + services.tailscale.enable = true; + # Allow Caddy to provision TLS certs via Tailscale + services.tailscale.permitCertUid = "caddy"; + + networking.firewall = { + trustedInterfaces = [ "tailscale0" ]; + allowedUDPPorts = [ 41641 ]; # Tailscale + }; + + # -- Caddy reverse proxy (auto TLS via Tailscale) --------------------------- + + services.caddy = { + enable = true; + virtualHosts."happier.example.ts.net".extraConfig = '' + reverse_proxy localhost:3005 + ''; + }; +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..270a6e4 --- /dev/null +++ b/flake.lock @@ -0,0 +1,100 @@ +{ + "nodes": { + "devshell": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1768818222, + "narHash": "sha256-460jc0+CZfyaO8+w8JNtlClB2n4ui1RbHfPTLkpwhU8=", + "owner": "numtide", + "repo": "devshell", + "rev": "255a2b1725a20d060f566e4755dbf571bbbb5f76", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "devshell", + "type": "github" + } + }, + "flake-parts": { + "inputs": { + "nixpkgs-lib": "nixpkgs-lib" + }, + "locked": { + "lastModified": 1775087534, + "narHash": "sha256-91qqW8lhL7TLwgQWijoGBbiD4t7/q75KTi8NxjVmSmA=", + "owner": "hercules-ci", + "repo": "flake-parts", + "rev": "3107b77cd68437b9a76194f0f7f9c55f2329ca5b", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "flake-parts", + "type": "github" + } + }, + "happier": { + "flake": false, + "locked": { + "lastModified": 1776627034, + "narHash": "sha256-43/AthzVHSeuNqoqfRu3qwdaOIwYSlu3johoRDa2vSU=", + "owner": "happier-dev", + "repo": "happier", + "rev": "326b10887227087c54d7d9088351ba45897343e5", + "type": "github" + }, + "original": { + "owner": "happier-dev", + "ref": "dev", + "repo": "happier", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1775305101, + "narHash": "sha256-/74n1oQPtKG52Yw41cbToxspxHbYz6O3vi+XEw16Qe8=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "36a601196c4ebf49e035270e10b2d103fe39076b", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "nixos-25.11", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs-lib": { + "locked": { + "lastModified": 1774748309, + "narHash": "sha256-+U7gF3qxzwD5TZuANzZPeJTZRHS29OFQgkQ2kiTJBIQ=", + "owner": "nix-community", + "repo": "nixpkgs.lib", + "rev": "333c4e0545a6da976206c74db8773a1645b5870a", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "nixpkgs.lib", + "type": "github" + } + }, + "root": { + "inputs": { + "devshell": "devshell", + "flake-parts": "flake-parts", + "happier": "happier", + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..4fe697b --- /dev/null +++ b/flake.nix @@ -0,0 +1,181 @@ +{ + inputs = { + nixpkgs.url = "github:nixos/nixpkgs/nixos-25.11"; + + devshell = { + url = "github:numtide/devshell"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + + flake-parts.url = "github:hercules-ci/flake-parts"; + + # The happier monorepo source (fetched as a plain source tree, not evaluated as a flake) + happier = { + url = "github:happier-dev/happier/dev"; + flake = false; + }; + }; + + outputs = + inputs@{ self, flake-parts, ... }: + flake-parts.lib.mkFlake { inherit inputs; } { + + systems = [ + "aarch64-darwin" + "aarch64-linux" + "x86_64-linux" + ]; + + imports = [ + inputs.devshell.flakeModule + ./devshell.nix + ./packages.nix + ./checks.nix + ]; + + flake = { + nixosModules.happier-server = ./modules/nixos/happier-server.nix; + nixosModules.default = self.nixosModules.happier-server; + }; + + perSystem = + { + pkgs, + lib, + system, + ... + }: + { + formatter = pkgs.nixfmt-tree; + + apps.update-prisma-hashes = { + type = "app"; + meta.description = "Update Prisma engine binary hashes in prisma-engines-prebuilt.nix"; + program = lib.getExe ( + pkgs.writeShellApplication { + name = "update-prisma-hashes"; + runtimeInputs = with pkgs; [ + jq + gnugrep + gnused + coreutils + ]; + text = '' + NIX_FILE="./packages/prisma-engines-prebuilt.nix" + YARN_LOCK="${inputs.happier}/yarn.lock" + + if [ ! -f "$NIX_FILE" ]; then + echo "ERROR: $NIX_FILE not found — run from the nix-happier repo root" >&2 + exit 1 + fi + + # Extract engine hash from yarn.lock + ENGINE_HASH=$(grep '@prisma/engines-version' "$YARN_LOCK" | head -1 | grep -oE '[a-f0-9]{40}') + + if [ -z "$ENGINE_HASH" ]; then + echo "ERROR: Could not find @prisma/engines-version in yarn.lock" >&2 + exit 1 + fi + + echo "Engine commit hash: $ENGINE_HASH" + + BASE_URL="https://binaries.prisma.sh/all_commits/$ENGINE_HASH" + + # Platform configs: system prisma-platform queryEngineFile schemaEngineFile + PLATFORMS=( + "aarch64-linux linux-arm64-openssl-3.0.x libquery_engine.so.node.gz schema-engine.gz" + "aarch64-darwin darwin-arm64 libquery_engine.dylib.node.gz schema-engine.gz" + "x86_64-linux debian-openssl-3.0.x libquery_engine.so.node.gz schema-engine.gz" + ) + + declare -A NEW_HASHES + + for entry in "''${PLATFORMS[@]}"; do + read -r sys plat qe_file se_file <<< "$entry" + echo "" + echo "Prefetching $sys binaries..." + + qe_url="$BASE_URL/$plat/$qe_file" + se_url="$BASE_URL/$plat/$se_file" + + echo " Fetching: $qe_url" + qe_hash=$(nix store prefetch-file --json --hash-type sha256 "$qe_url" | jq -r .hash) + echo " -> $qe_hash" + + echo " Fetching: $se_url" + se_hash=$(nix store prefetch-file --json --hash-type sha256 "$se_url" | jq -r .hash) + echo " -> $se_hash" + + NEW_HASHES["''${sys}_qe"]="$qe_hash" + NEW_HASHES["''${sys}_se"]="$se_hash" + done + + echo "" + echo "Updating hashes in $NIX_FILE..." + + # Read current hashes in order of appearance + mapfile -t CURRENT_HASHES < <(grep -oP 'Hash = "\K[^"]+' "$NIX_FILE") + + # Order must match nix file: aarch64-linux QE, SE, aarch64-darwin QE, SE, x86_64-linux QE, SE + ORDERED_NEW=( + "''${NEW_HASHES[aarch64-linux_qe]}" + "''${NEW_HASHES[aarch64-linux_se]}" + "''${NEW_HASHES[aarch64-darwin_qe]}" + "''${NEW_HASHES[aarch64-darwin_se]}" + "''${NEW_HASHES[x86_64-linux_qe]}" + "''${NEW_HASHES[x86_64-linux_se]}" + ) + + for i in "''${!CURRENT_HASHES[@]}"; do + old="''${CURRENT_HASHES[$i]}" + new="''${ORDERED_NEW[$i]}" + if [ "$old" != "$new" ]; then + sed -i "s|$old|$new|g" "$NIX_FILE" + echo " Updated: $old -> $new" + else + echo " Unchanged: $old" + fi + done + + echo "" + echo "Done! Verify with: nix build .#happier-server" + ''; + } + ); + }; + + apps.update = { + type = "app"; + meta.description = "Update all flake inputs and refresh Prisma engine hashes"; + program = lib.getExe ( + pkgs.writeShellApplication { + name = "update"; + text = '' + echo "Updating flake inputs..." + nix flake update + + echo "" + echo "Updating Prisma engine hashes..." + nix run .#update-prisma-hashes + ''; + } + ); + }; + + _module.args.pkgs = import self.inputs.nixpkgs { + inherit system; + overlays = [ + # Prebuilt Prisma engines — version auto-derived from yarn.lock. + # When @prisma/client is bumped, run: ./scripts/update-prisma-hashes.sh + (final: _prev: { + prisma-engines = import ./packages/prisma-engines-prebuilt.nix { + pkgs = final; + inherit (final) lib; + yarnLock = "${inputs.happier}/yarn.lock"; + }; + }) + ]; + }; + }; + }; +} diff --git a/modules/nixos/happier-server.nix b/modules/nixos/happier-server.nix new file mode 100644 index 0000000..5bcd2eb --- /dev/null +++ b/modules/nixos/happier-server.nix @@ -0,0 +1,303 @@ +# Happier Server NixOS module +# Provides happier-server as a native systemd service with PostgreSQL, Redis, and MinIO +{ + config, + lib, + pkgs, + ... +}: +let + cfg = config.services.happier-server; + isFullMode = cfg.mode == "full"; +in +{ + options.services.happier-server = { + enable = lib.mkEnableOption "Happier Server"; + + package = lib.mkOption { + type = lib.types.package; + description = "The happier-server package to use"; + }; + + port = lib.mkOption { + type = lib.types.port; + default = 3005; + description = "Port to listen on"; + }; + + mode = lib.mkOption { + type = lib.types.enum [ + "full" + "light" + ]; + default = "full"; + description = '' + Server mode. + - "full": PostgreSQL + Redis + MinIO (production stack) + - "light": SQLite-only, no external service dependencies + ''; + }; + + environmentFile = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + description = '' + Environment file with secrets in KEY=value format. + Should contain at minimum HANDY_MASTER_SECRET for encryption. + ''; + }; + + database = { + name = lib.mkOption { + type = lib.types.str; + default = "happier"; + description = "PostgreSQL database name (full mode only)"; + }; + + user = lib.mkOption { + type = lib.types.str; + default = "happier"; + description = "PostgreSQL user (full mode only)"; + }; + + createLocally = lib.mkOption { + type = lib.types.bool; + default = true; + description = "Create database and user locally (full mode only)"; + }; + }; + + redis = { + createLocally = lib.mkOption { + type = lib.types.bool; + default = true; + description = "Create Redis instance locally (full mode only)"; + }; + }; + + minio = { + createLocally = lib.mkOption { + type = lib.types.bool; + default = true; + description = "Create MinIO instance locally for S3-compatible storage (full mode only)"; + }; + + bucket = lib.mkOption { + type = lib.types.str; + default = "happier"; + description = "MinIO bucket name"; + }; + + rootCredentialsFile = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + description = "Path to file containing MINIO_ROOT_USER and MINIO_ROOT_PASSWORD"; + }; + }; + }; + + config = lib.mkIf cfg.enable { + # PostgreSQL configuration (full mode only) + services.postgresql = lib.mkIf (isFullMode && cfg.database.createLocally) { + enable = true; + package = pkgs.postgresql_15; + ensureDatabases = [ cfg.database.name ]; + ensureUsers = [ + { + name = cfg.database.user; + ensureDBOwnership = true; + } + ]; + authentication = lib.mkForce '' + local all all trust + host all all 127.0.0.1/32 trust + host all all ::1/128 trust + ''; + }; + + # Redis configuration (full mode only) + services.redis.servers.happier = lib.mkIf (isFullMode && cfg.redis.createLocally) { + enable = true; + port = 6379; + bind = "127.0.0.1"; + }; + + # MinIO configuration for S3-compatible storage (full mode only) + services.minio = lib.mkIf (isFullMode && cfg.minio.createLocally) { + enable = true; + listenAddress = "127.0.0.1:9000"; + consoleAddress = "127.0.0.1:9001"; + dataDir = [ "/var/lib/minio/data" ]; + inherit (cfg.minio) rootCredentialsFile; + }; + + # Create MinIO bucket after service starts (full mode only) + systemd.services.minio-bucket-init = lib.mkIf (isFullMode && cfg.minio.createLocally) { + description = "Create MinIO bucket for happier-server"; + wantedBy = [ "multi-user.target" ]; + after = [ "minio.service" ]; + requires = [ "minio.service" ]; + path = [ + pkgs.minio-client + pkgs.getent + ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + EnvironmentFile = cfg.minio.rootCredentialsFile; + }; + script = '' + # Wait for MinIO to be ready + for i in $(seq 1 30); do + mc alias set local http://127.0.0.1:9000 "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD" && break + sleep 1 + done + + # Create bucket if it doesn't exist + mc mb --ignore-existing local/${cfg.minio.bucket} + ''; + }; + + # Run database migrations before server starts + systemd.services.happier-server-migrate = { + description = "Run happier-server database migrations"; + wantedBy = [ "happier-server.service" ]; + before = [ "happier-server.service" ]; + after = lib.optional (isFullMode && cfg.database.createLocally) "postgresql.service"; + requires = lib.optional (isFullMode && cfg.database.createLocally) "postgresql.service"; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + # Share the same user and state directory as happier-server + # so migrations write to the same database the server reads + DynamicUser = true; + StateDirectory = "happier-server"; + } + // ( + if isFullMode then + { + ExecStart = "${cfg.package}/bin/happier-server-migrate"; + Environment = [ + "DATABASE_URL=postgresql://${cfg.database.user}@localhost/${cfg.database.name}" + "NODE_ENV=production" + "HOME=%S/happier-server" + ]; + } + else + { + ExecStart = "${cfg.package}/bin/happier-server-migrate-light"; + Environment = [ + "NODE_ENV=production" + "HOME=%S/happier-server" + ]; + } + ); + }; + + # Enable WAL mode on the SQLite database (light mode only). + # WAL allows concurrent readers + one writer, eliminating the lock contention + # that causes "Socket timeout" and "Transaction already closed" Prisma errors. + systemd.services.happier-server-sqlite-wal = lib.mkIf (!isFullMode) { + description = "Enable WAL mode on happier-server SQLite database"; + wantedBy = [ "happier-server.service" ]; + before = [ "happier-server.service" ]; + after = [ "happier-server-migrate.service" ]; + requires = [ "happier-server-migrate.service" ]; + path = [ pkgs.sqlite ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + DynamicUser = true; + StateDirectory = "happier-server"; + }; + script = '' + DB="%S/happier-server/.happy/server-light/happier-server-light.sqlite" + if [ -f "$DB" ]; then + sqlite3 "$DB" "PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000;" + fi + ''; + }; + + # Main happier-server service + systemd.services.happier-server = { + description = "Happier Server"; + wantedBy = [ "multi-user.target" ]; + after = [ + "network.target" + "happier-server-migrate.service" + ] + ++ lib.optional (!isFullMode) "happier-server-sqlite-wal.service" + ++ lib.optionals isFullMode ( + lib.optional cfg.database.createLocally "postgresql.service" + ++ lib.optional cfg.redis.createLocally "redis-happier.service" + ++ lib.optional cfg.minio.createLocally "minio-bucket-init.service" + ); + requires = [ + "happier-server-migrate.service" + ] + ++ lib.optional (!isFullMode) "happier-server-sqlite-wal.service" + ++ lib.optionals isFullMode ( + lib.optional cfg.database.createLocally "postgresql.service" + ++ lib.optional cfg.redis.createLocally "redis-happier.service" + ++ lib.optional cfg.minio.createLocally "minio.service" + ); + script = '' + ${lib.optionalString (cfg.environmentFile != null) '' + set -a + source "$CREDENTIALS_DIRECTORY/happier-env" + set +a + ''} + ${lib.optionalString + (isFullMode && cfg.minio.createLocally && cfg.minio.rootCredentialsFile != null) + '' + source "$CREDENTIALS_DIRECTORY/minio-creds" + export S3_ACCESS_KEY="$MINIO_ROOT_USER" + export S3_SECRET_KEY="$MINIO_ROOT_PASSWORD" + '' + } + exec ${cfg.package}/bin/${if isFullMode then "happier-server" else "happier-server-light"} + ''; + serviceConfig = { + Type = "simple"; + Restart = "on-failure"; + RestartSec = 5; + + Environment = [ + "NODE_ENV=production" + "PORT=${toString cfg.port}" + "HOME=%S/happier-server" + ] + ++ lib.optionals isFullMode ( + [ + "DATABASE_URL=postgresql://${cfg.database.user}@localhost/${cfg.database.name}" + ] + ++ lib.optional cfg.redis.createLocally "REDIS_URL=redis://localhost:6379" + ++ lib.optionals cfg.minio.createLocally [ + "S3_HOST=127.0.0.1" + "S3_PORT=9000" + "S3_USE_SSL=false" + "S3_BUCKET=${cfg.minio.bucket}" + "S3_PUBLIC_URL=http://127.0.0.1:9000/${cfg.minio.bucket}" + ] + ); + + # Use LoadCredential to make secret files readable by DynamicUser. + # systemd copies them to a private $CREDENTIALS_DIRECTORY. + LoadCredential = + lib.optional (cfg.environmentFile != null) "happier-env:${cfg.environmentFile}" + ++ lib.optional ( + isFullMode && cfg.minio.createLocally && cfg.minio.rootCredentialsFile != null + ) "minio-creds:${cfg.minio.rootCredentialsFile}"; + + # Hardening + DynamicUser = true; + ProtectSystem = "strict"; + ProtectHome = true; + PrivateTmp = true; + NoNewPrivileges = true; + StateDirectory = "happier-server"; + WorkingDirectory = "${cfg.package}/lib/happier-server/apps/server"; + }; + }; + }; +} diff --git a/packages.nix b/packages.nix new file mode 100644 index 0000000..0efc4ea --- /dev/null +++ b/packages.nix @@ -0,0 +1,531 @@ +# Nix packages for happier-cli and happier-server +{ inputs, ... }: + +{ + perSystem = + { + pkgs, + lib, + ... + }: + let + happierSrc = inputs.happier; + + # Source filter: exclude packages/dirs not needed for building CLI or server + filteredSrc = lib.cleanSourceWith { + src = happierSrc; + filter = + path: _type: + let + relPath = lib.removePrefix (toString happierSrc + "/") (toString path); + in + !( + lib.hasPrefix "apps/ui" relPath + || lib.hasPrefix "apps/stack" relPath + || lib.hasPrefix "apps/website" relPath + || lib.hasPrefix "apps/docs" relPath + || lib.hasPrefix "packages/audio-stream-native" relPath + || lib.hasPrefix "packages/sherpa-native" relPath + || lib.hasPrefix "packages/relay-server" relPath + || lib.hasPrefix "packages/tests" relPath + || lib.hasPrefix ".git" relPath + || relPath == "node_modules" + || lib.hasPrefix "node_modules/" relPath + || relPath == "dist" + || lib.hasPrefix ".pgdata" relPath + || lib.hasPrefix ".minio" relPath + || lib.hasPrefix ".logs" relPath + || lib.hasPrefix "result" relPath + || lib.hasPrefix ".project" relPath + ); + }; + + # Source filter for the web UI: include apps/ui, exclude server/CLI apps + uiFilteredSrc = lib.cleanSourceWith { + src = happierSrc; + filter = + path: _type: + let + relPath = lib.removePrefix (toString happierSrc + "/") (toString path); + in + !( + lib.hasPrefix "apps/server" relPath + || lib.hasPrefix "apps/cli" relPath + || lib.hasPrefix "apps/stack" relPath + || lib.hasPrefix "apps/website" relPath + || lib.hasPrefix "apps/docs" relPath + || lib.hasPrefix "packages/relay-server" relPath + || lib.hasPrefix "packages/tests" relPath + || lib.hasPrefix ".git" relPath + || relPath == "node_modules" + || lib.hasPrefix "node_modules/" relPath + || relPath == "dist" + || lib.hasPrefix ".pgdata" relPath + || lib.hasPrefix ".minio" relPath + || lib.hasPrefix ".logs" relPath + || lib.hasPrefix "result" relPath + || lib.hasPrefix ".project" relPath + ); + }; + + # Offline yarn cache from the root yarn.lock + yarnOfflineCache = pkgs.fetchYarnDeps { + yarnLock = "${happierSrc}/yarn.lock"; + hash = "sha256-kph4Y7WtP7lXLWwg3NJu4ifHCCvCQA+sKORSj7v6PFE="; + }; + + # Pre-built web UI bundle (Expo static export) + happier-ui-web = pkgs.stdenv.mkDerivation { + pname = "happier-ui-web"; + version = "0.1.0"; + + src = uiFilteredSrc; + + nativeBuildInputs = with pkgs; [ + nodejs_22 + yarn + yarnConfigHook + ]; + + inherit yarnOfflineCache; + + preConfigure = '' + export HAPPIER_INSTALL_SCOPE=ui,protocol,agents,connection-supervisor,transfers,release-runtime,cli-common + export HOME=$(mktemp -d) + export APP_ENV=production + export EXPO_NO_GIT_STATUS=1 + ''; + + buildPhase = '' + runHook preBuild + + # Build shared workspace packages in dependency order + node packages/protocol/scripts/generate-embedded-feature-policies.mjs + node node_modules/typescript/bin/tsc -p packages/protocol/tsconfig.json + node node_modules/typescript/bin/tsc -p packages/agents/tsconfig.json + node node_modules/typescript/bin/tsc -p packages/release-runtime/tsconfig.json + node node_modules/typescript/bin/tsc -p packages/transfers/tsconfig.json + node node_modules/typescript/bin/tsc -p packages/connection-supervisor/tsconfig.json + node node_modules/typescript/bin/tsc -p packages/cli-common/tsconfig.json + + # Export static web bundle (invoke via node to bypass shebang issues on linux builders) + (cd apps/ui && node node_modules/expo/bin/cli export --platform web --output-dir dist) + + runHook postBuild + ''; + + installPhase = '' + runHook preInstall + cp -r apps/ui/dist $out + runHook postInstall + ''; + + meta = { + description = "Happier Web UI - static Expo web bundle"; + homepage = "https://github.com/happier-dev/happier"; + license = lib.licenses.mit; + }; + }; + + # Builder function for happier-cli — allows overriding the default server URL. + # CLI args (--server-url) still take precedence over these env vars. + # + # Example — point CLI at a self-hosted server: + # + # environment.systemPackages = [ + # (nix-happier.packages.${system}.happier-cli.override { + # serverUrl = "https://happier.myhost.com"; + # webappUrl = "https://happier.myhost.com"; + # }) + # ]; + mkHappierCli = + { + serverUrl ? null, + webappUrl ? null, + }: + pkgs.stdenv.mkDerivation { + pname = "happier-cli"; + version = "0.1.0"; + + src = filteredSrc; + + nativeBuildInputs = with pkgs; [ + nodejs_22 + yarn + yarnConfigHook + makeWrapper + python3 + ]; + + inherit yarnOfflineCache; + + preConfigure = '' + # Skip server postinstall (only need CLI scope) + export HAPPIER_INSTALL_SCOPE=cli + export HOME=$(mktemp -d) + ''; + + buildPhase = '' + runHook preBuild + + # Build shared workspace packages in dependency order: + # protocol (no deps) -> agents, release-runtime, transfers, connection-supervisor (need protocol at most) -> cli-common (needs agents + release-runtime) + # Protocol needs its codegen step first + node packages/protocol/scripts/generate-embedded-feature-policies.mjs + node node_modules/typescript/bin/tsc -p packages/protocol/tsconfig.json + node node_modules/typescript/bin/tsc -p packages/agents/tsconfig.json + node node_modules/typescript/bin/tsc -p packages/release-runtime/tsconfig.json + node node_modules/typescript/bin/tsc -p packages/transfers/tsconfig.json + node node_modules/typescript/bin/tsc -p packages/connection-supervisor/tsconfig.json + node node_modules/typescript/bin/tsc -p packages/cli-common/tsconfig.json + + # Sync bundled workspace dist into CLI's node_modules so tsc/pkgroll can resolve them + node -e " + const { syncBundledWorkspaceDist } = await import('./apps/cli/scripts/buildSharedDeps.mjs'); + syncBundledWorkspaceDist({ repoRoot: process.cwd() }); + " + + # Build the CLI: clean dist, typecheck, then bundle with pkgroll + # Using subshells to avoid cd state leaking on errors + node apps/cli/scripts/rmDist.mjs + (cd apps/cli && node ../../node_modules/typescript/bin/tsc --noEmit) + (cd apps/cli && node ../../node_modules/.bin/pkgroll) + + runHook postBuild + ''; + + installPhase = '' + runHook preInstall + + # Replicate monorepo layout so Node.js module resolution works + mkdir -p $out/lib/happier-cli/apps/cli + mkdir -p $out/lib/happier-cli/packages/protocol + mkdir -p $out/lib/happier-cli/packages/agents + mkdir -p $out/lib/happier-cli/packages/cli-common + mkdir -p $out/lib/happier-cli/packages/release-runtime + mkdir -p $out/lib/happier-cli/packages/transfers + mkdir -p $out/lib/happier-cli/packages/connection-supervisor + + # Root node_modules (hoisted dependencies) + cp -r node_modules $out/lib/happier-cli/ + + # Remove broken symlinks (workspace cross-references we don't ship) + find $out/lib/happier-cli/node_modules -xtype l -delete + + # -- apps/cli artifacts -- + cp -r apps/cli/dist $out/lib/happier-cli/apps/cli/ + cp -r apps/cli/bin $out/lib/happier-cli/apps/cli/ + cp -r apps/cli/scripts $out/lib/happier-cli/apps/cli/ + cp apps/cli/package.json $out/lib/happier-cli/apps/cli/ + if [ -d apps/cli/node_modules ]; then + cp -r apps/cli/node_modules $out/lib/happier-cli/apps/cli/ + fi + + # -- packages/protocol -- + cp -r packages/protocol/dist $out/lib/happier-cli/packages/protocol/ + cp packages/protocol/package.json $out/lib/happier-cli/packages/protocol/ + if [ -d packages/protocol/node_modules ]; then + cp -r packages/protocol/node_modules $out/lib/happier-cli/packages/protocol/ + fi + + # -- packages/agents -- + cp -r packages/agents/dist $out/lib/happier-cli/packages/agents/ + cp packages/agents/package.json $out/lib/happier-cli/packages/agents/ + if [ -d packages/agents/node_modules ]; then + cp -r packages/agents/node_modules $out/lib/happier-cli/packages/agents/ + fi + + # -- packages/cli-common -- + cp -r packages/cli-common/dist $out/lib/happier-cli/packages/cli-common/ + cp packages/cli-common/package.json $out/lib/happier-cli/packages/cli-common/ + if [ -d packages/cli-common/node_modules ]; then + cp -r packages/cli-common/node_modules $out/lib/happier-cli/packages/cli-common/ + fi + + # -- packages/release-runtime -- + cp -r packages/release-runtime/dist $out/lib/happier-cli/packages/release-runtime/ + cp packages/release-runtime/package.json $out/lib/happier-cli/packages/release-runtime/ + if [ -d packages/release-runtime/node_modules ]; then + cp -r packages/release-runtime/node_modules $out/lib/happier-cli/packages/release-runtime/ + fi + + # -- packages/transfers -- + cp -r packages/transfers/dist $out/lib/happier-cli/packages/transfers/ + cp packages/transfers/package.json $out/lib/happier-cli/packages/transfers/ + if [ -d packages/transfers/node_modules ]; then + cp -r packages/transfers/node_modules $out/lib/happier-cli/packages/transfers/ + fi + + # -- packages/connection-supervisor -- + cp -r packages/connection-supervisor/dist $out/lib/happier-cli/packages/connection-supervisor/ + cp packages/connection-supervisor/package.json $out/lib/happier-cli/packages/connection-supervisor/ + if [ -d packages/connection-supervisor/node_modules ]; then + cp -r packages/connection-supervisor/node_modules $out/lib/happier-cli/packages/connection-supervisor/ + fi + + # Create wrapper scripts + mkdir -p $out/bin + + makeWrapper ${pkgs.nodejs_22}/bin/node $out/bin/happier \ + --add-flags "--no-warnings" \ + --add-flags "--no-deprecation" \ + --add-flags "$out/lib/happier-cli/apps/cli/dist/index.mjs" \ + ${lib.optionalString (serverUrl != null) ''--set HAPPIER_SERVER_URL "${serverUrl}"''} \ + ${lib.optionalString (webappUrl != null) ''--set HAPPIER_WEBAPP_URL "${webappUrl}"''} \ + --prefix PATH : ${ + lib.makeBinPath [ + pkgs.nodejs_22 + pkgs.difftastic + pkgs.ripgrep + ] + } + + makeWrapper ${pkgs.nodejs_22}/bin/node $out/bin/happier-mcp \ + --add-flags "--no-warnings" \ + --add-flags "--no-deprecation" \ + --add-flags "$out/lib/happier-cli/apps/cli/dist/backends/codex/happyMcpStdioBridge.mjs" \ + ${lib.optionalString (serverUrl != null) ''--set HAPPIER_SERVER_URL "${serverUrl}"''} \ + ${lib.optionalString (webappUrl != null) ''--set HAPPIER_WEBAPP_URL "${webappUrl}"''} \ + --prefix PATH : ${ + lib.makeBinPath [ + pkgs.nodejs_22 + pkgs.difftastic + pkgs.ripgrep + ] + } + + runHook postInstall + ''; + + passthru.override = mkHappierCli; + + meta = { + description = "Happier CLI - mobile and web client for Claude Code"; + homepage = "https://github.com/happier-dev/happier"; + license = lib.licenses.mit; + mainProgram = "happier"; + }; + }; + in + { + packages = { + # Default CLI (no server URL override — uses upstream defaults). + # Use happier-cli.override to set a default server URL: + # happier-cli.override { serverUrl = "https://happier.myhost.com"; } + happier-cli = mkHappierCli { }; + + # -- happier-server ---------------------------------------------------- + happier-server = pkgs.stdenv.mkDerivation { + pname = "happier-server"; + version = "0.1.2"; + + src = filteredSrc; + + nativeBuildInputs = with pkgs; [ + nodejs_22 + yarn + yarnConfigHook + makeWrapper + python3 + ]; + + buildInputs = with pkgs; [ + prisma-engines + # sharp bundles its own libvips via @img/sharp-* prebuilts — no system vips needed + ]; + + inherit yarnOfflineCache; + + preConfigure = '' + # Skip CLI postinstall (only need server scope) + export HAPPIER_INSTALL_SCOPE=server,cli-common,release-runtime + export HOME=$(mktemp -d) + + # Point Prisma at nixpkgs engines + export PRISMA_QUERY_ENGINE_LIBRARY="${pkgs.prisma-engines}/lib/libquery_engine.node" + export PRISMA_SCHEMA_ENGINE_BINARY="${pkgs.prisma-engines}/bin/schema-engine" + export PRISMA_SKIP_POSTINSTALL_GENERATE=true + ''; + + buildPhase = '' + runHook preBuild + + # Build shared workspace packages in dependency order: + # protocol (no deps) -> agents (needs protocol) -> release-runtime, cli-common (needs agents + release-runtime) + node packages/protocol/scripts/generate-embedded-feature-policies.mjs + node node_modules/typescript/bin/tsc -p packages/protocol/tsconfig.json + node node_modules/typescript/bin/tsc -p packages/agents/tsconfig.json + node node_modules/typescript/bin/tsc -p packages/release-runtime/tsconfig.json + node node_modules/typescript/bin/tsc -p packages/cli-common/tsconfig.json + + # Generate Prisma clients for all providers (postgres, mysql, sqlite) + # generate:providers handles schema:sync internally and generates all three + yarn workspace @happier-dev/server generate:providers + + # Typecheck directly to avoid prebuild re-running buildSharedDeps. + # Note: prisma-json-types-generator patches @prisma/client types in-place; + # if the patch silently fails in the sandbox, PrismaJson types won't resolve. + # The server runs via tsx at runtime so this is a validation-only step. + (cd apps/server && node ../../node_modules/typescript/bin/tsc --noEmit) || echo "WARN: tsc --noEmit had errors (non-fatal for tsx runtime)" + + runHook postBuild + ''; + + installPhase = '' + runHook preInstall + + # Replicate monorepo layout so Node.js module resolution works + mkdir -p $out/lib/happier-server/apps/server + mkdir -p $out/lib/happier-server/packages/protocol + mkdir -p $out/lib/happier-server/packages/agents + mkdir -p $out/lib/happier-server/packages/release-runtime + mkdir -p $out/lib/happier-server/packages/cli-common + + # Root node_modules (hoisted dependencies) + cp -r node_modules $out/lib/happier-server/ + + # Remove broken symlinks (workspace cross-references we don't ship) + find $out/lib/happier-server/node_modules -xtype l -delete + + # -- packages/protocol -- + cp -r packages/protocol/dist $out/lib/happier-server/packages/protocol/ + cp packages/protocol/package.json $out/lib/happier-server/packages/protocol/ + if [ -d packages/protocol/node_modules ]; then + cp -r packages/protocol/node_modules $out/lib/happier-server/packages/protocol/ + fi + + # -- packages/agents -- + cp -r packages/agents/dist $out/lib/happier-server/packages/agents/ + cp packages/agents/package.json $out/lib/happier-server/packages/agents/ + if [ -d packages/agents/node_modules ]; then + cp -r packages/agents/node_modules $out/lib/happier-server/packages/agents/ + fi + + # -- packages/release-runtime -- + cp -r packages/release-runtime/dist $out/lib/happier-server/packages/release-runtime/ + cp packages/release-runtime/package.json $out/lib/happier-server/packages/release-runtime/ + if [ -d packages/release-runtime/node_modules ]; then + cp -r packages/release-runtime/node_modules $out/lib/happier-server/packages/release-runtime/ + fi + + # -- packages/cli-common -- + cp -r packages/cli-common/dist $out/lib/happier-server/packages/cli-common/ + cp packages/cli-common/package.json $out/lib/happier-server/packages/cli-common/ + if [ -d packages/cli-common/node_modules ]; then + cp -r packages/cli-common/node_modules $out/lib/happier-server/packages/cli-common/ + fi + + # -- apps/server sources and config -- + cp -r apps/server/sources $out/lib/happier-server/apps/server/ + cp -r apps/server/prisma $out/lib/happier-server/apps/server/ + cp -r apps/server/scripts $out/lib/happier-server/apps/server/ + cp apps/server/tsconfig.json $out/lib/happier-server/apps/server/ + cp apps/server/package.json $out/lib/happier-server/apps/server/ + + # Generated Prisma clients for sqlite and mysql (relative to apps/server/) + if [ -d apps/server/generated ]; then + cp -r apps/server/generated $out/lib/happier-server/apps/server/ + fi + + # Workspace node_modules (including generated Prisma client) + if [ -d apps/server/node_modules ]; then + cp -r apps/server/node_modules $out/lib/happier-server/apps/server/ + fi + + # Generated Prisma client (.prisma at root) — dereference symlinks + # since engine binaries are nix store paths (read-only in the store) + if [ -d node_modules/.prisma ]; then + rm -rf $out/lib/happier-server/node_modules/.prisma + cp -rL node_modules/.prisma $out/lib/happier-server/node_modules/ + fi + + # Create wrapper scripts + mkdir -p $out/bin + + # Main server binary (full mode): run via tsx + makeWrapper ${pkgs.nodejs_22}/bin/node $out/bin/happier-server \ + --add-flags "--import" \ + --add-flags "tsx" \ + --add-flags "$out/lib/happier-server/apps/server/sources/main.ts" \ + --set PRISMA_QUERY_ENGINE_LIBRARY "${pkgs.prisma-engines}/lib/libquery_engine.node" \ + --set PRISMA_SCHEMA_ENGINE_BINARY "${pkgs.prisma-engines}/bin/schema-engine" \ + --set PRISMA_ENGINES_CHECKSUM_IGNORE_MISSING "1" \ + --set HAPPIER_SERVER_UI_DIR "${happier-ui-web}" \ + --prefix LD_LIBRARY_PATH : "${lib.makeLibraryPath [ pkgs.openssl ]}" \ + --chdir "$out/lib/happier-server/apps/server" \ + --prefix PATH : ${ + lib.makeBinPath [ + pkgs.nodejs_22 + pkgs.ffmpeg + pkgs.python3 + ] + } + + # Light/SQLite server binary + makeWrapper ${pkgs.nodejs_22}/bin/node $out/bin/happier-server-light \ + --add-flags "--import" \ + --add-flags "tsx" \ + --add-flags "$out/lib/happier-server/apps/server/sources/main.light.ts" \ + --set PRISMA_QUERY_ENGINE_LIBRARY "${pkgs.prisma-engines}/lib/libquery_engine.node" \ + --set PRISMA_SCHEMA_ENGINE_BINARY "${pkgs.prisma-engines}/bin/schema-engine" \ + --set PRISMA_ENGINES_CHECKSUM_IGNORE_MISSING "1" \ + --set HAPPIER_SERVER_UI_DIR "${happier-ui-web}" \ + --prefix LD_LIBRARY_PATH : "${lib.makeLibraryPath [ pkgs.openssl ]}" \ + --chdir "$out/lib/happier-server/apps/server" \ + --prefix PATH : ${ + lib.makeBinPath [ + pkgs.nodejs_22 + pkgs.ffmpeg + pkgs.python3 + ] + } + + # Migration binary (full mode — Prisma migrate deploy) + makeWrapper ${pkgs.nodejs_22}/bin/node $out/bin/happier-server-migrate \ + --add-flags "$out/lib/happier-server/node_modules/.bin/prisma" \ + --add-flags "migrate" \ + --add-flags "deploy" \ + --set PRISMA_QUERY_ENGINE_LIBRARY "${pkgs.prisma-engines}/lib/libquery_engine.node" \ + --set PRISMA_SCHEMA_ENGINE_BINARY "${pkgs.prisma-engines}/bin/schema-engine" \ + --set PRISMA_ENGINES_CHECKSUM_IGNORE_MISSING "1" \ + --prefix LD_LIBRARY_PATH : "${lib.makeLibraryPath [ pkgs.openssl ]}" \ + --chdir "$out/lib/happier-server/apps/server" \ + --prefix PATH : ${ + lib.makeBinPath [ + pkgs.nodejs_22 + pkgs.yarn + ] + } + + # Light migration binary (SQLite deploy script) + makeWrapper ${pkgs.nodejs_22}/bin/node $out/bin/happier-server-migrate-light \ + --add-flags "--import" \ + --add-flags "tsx" \ + --add-flags "$out/lib/happier-server/apps/server/scripts/migrate.sqlite.deploy.ts" \ + --set PRISMA_QUERY_ENGINE_LIBRARY "${pkgs.prisma-engines}/lib/libquery_engine.node" \ + --set PRISMA_SCHEMA_ENGINE_BINARY "${pkgs.prisma-engines}/bin/schema-engine" \ + --set PRISMA_ENGINES_CHECKSUM_IGNORE_MISSING "1" \ + --prefix LD_LIBRARY_PATH : "${lib.makeLibraryPath [ pkgs.openssl ]}" \ + --chdir "$out/lib/happier-server/apps/server" \ + --prefix PATH : ${ + lib.makeBinPath [ + pkgs.nodejs_22 + pkgs.yarn + ] + } + + runHook postInstall + ''; + + passthru.web = happier-ui-web; + + meta = { + description = "Happier Server - backend for Happier mobile and CLI clients"; + homepage = "https://github.com/happier-dev/happier"; + license = lib.licenses.mit; + mainProgram = "happier-server"; + }; + }; + }; + }; +} diff --git a/packages/prisma-engines-prebuilt.nix b/packages/prisma-engines-prebuilt.nix new file mode 100644 index 0000000..402c889 --- /dev/null +++ b/packages/prisma-engines-prebuilt.nix @@ -0,0 +1,108 @@ +# Fetch prebuilt Prisma engine binaries instead of compiling from source. +# +# The engine commit hash is auto-derived from @prisma/engines-version in yarn.lock. +# Only the binary download hashes below need manual updating when Prisma is bumped. +# +# When @prisma/client is bumped and yarn.lock changes: +# 1. nix build will fail with a hash mismatch (the engine hash changed → new URL) +# 2. Run: ./scripts/update-prisma-hashes.sh +# 3. Commit the updated hashes +{ + pkgs, + lib, + yarnLock, +}: +let + # Auto-derive engine commit hash and version from yarn.lock + yarnLockContent = builtins.readFile yarnLock; + lines = builtins.filter builtins.isString (builtins.split "\n" yarnLockContent); + + engineVersionLines = builtins.filter ( + l: builtins.match ".*@prisma/engines-version.*" l != null + ) lines; + engineHash = builtins.elemAt (builtins.match ".*\\.([a-f0-9]+).*" (builtins.head engineVersionLines)) 0; + + prismaVersionLines = builtins.filter (l: builtins.match "\"@prisma/engines@.*" l != null) lines; + version = builtins.elemAt (builtins.match "\"@prisma/engines@([^\"]+)\".*" (builtins.head prismaVersionLines)) 0; + + baseUrl = "https://binaries.prisma.sh/all_commits/${engineHash}"; + + # Platform-specific binary config + # Update these hashes after bumping Prisma: ./scripts/update-prisma-hashes.sh + platformConfig = { + "aarch64-linux" = { + platform = "linux-arm64-openssl-3.0.x"; + queryEngineFile = "libquery_engine.so.node.gz"; + schemaEngineFile = "schema-engine.gz"; + queryEngineHash = "sha256-9GxcSfrXQC/TzsW8fNgn5YcolXKUMKoHFbhKVIsqd64="; + schemaEngineHash = "sha256-LxtS3kmqbWOm+uSIvhigXuFDBcAk195SvBmyKFvK/SY="; + }; + "aarch64-darwin" = { + platform = "darwin-arm64"; + queryEngineFile = "libquery_engine.dylib.node.gz"; + schemaEngineFile = "schema-engine.gz"; + queryEngineHash = "sha256-hBS1HLFhLDtLDxW5ilXafX7JUgxZNgJpnsJjgjx5gE4="; + schemaEngineHash = "sha256-qVG2ANgyFOFFv3giqB7jHl1UiKRdOJDmjxxf2RL313M="; + }; + "x86_64-linux" = { + platform = "debian-openssl-3.0.x"; + queryEngineFile = "libquery_engine.so.node.gz"; + schemaEngineFile = "schema-engine.gz"; + queryEngineHash = "sha256-04CHLMihcxDG67JJ3AAPT8v7vVHdw2vrV7HtDFTG1hA="; + schemaEngineHash = "sha256-bB39/jRThIewiP0hsxUhR+G9PJ0MALT8bsrkDOuoPYo="; + }; + }; + + cfg = + platformConfig.${pkgs.system} + or (throw "prisma-engines-prebuilt: unsupported system ${pkgs.system}"); + + queryEngineSrc = pkgs.fetchurl { + url = "${baseUrl}/${cfg.platform}/${cfg.queryEngineFile}"; + sha256 = cfg.queryEngineHash; + }; + + schemaEngineSrc = pkgs.fetchurl { + url = "${baseUrl}/${cfg.platform}/${cfg.schemaEngineFile}"; + sha256 = cfg.schemaEngineHash; + }; +in +pkgs.stdenv.mkDerivation { + pname = "prisma-engines"; + inherit version; + + dontUnpack = true; + + nativeBuildInputs = [ + pkgs.gzip + ] + ++ lib.optionals pkgs.stdenv.hostPlatform.isLinux [ + pkgs.autoPatchelfHook + ]; + + # Runtime libraries needed by the prebuilt Linux binaries. + # On darwin the Mach-O binaries are self-contained. + buildInputs = lib.optionals pkgs.stdenv.hostPlatform.isLinux [ + pkgs.openssl + pkgs.stdenv.cc.cc.lib # libstdc++/libgcc + pkgs.zlib + ]; + + installPhase = '' + mkdir -p $out/lib $out/bin + + # Query engine (shared library loaded by @prisma/client) + gzip -dc ${queryEngineSrc} > $out/lib/libquery_engine.node + chmod 755 $out/lib/libquery_engine.node + + # Schema engine (binary used by prisma migrate) + gzip -dc ${schemaEngineSrc} > $out/bin/schema-engine + chmod 755 $out/bin/schema-engine + ''; + + meta = { + description = "Prisma engines (prebuilt binaries)"; + homepage = "https://github.com/prisma/prisma-engines"; + license = lib.licenses.asl20; + }; +} diff --git a/statix.toml b/statix.toml new file mode 100644 index 0000000..ca3407f --- /dev/null +++ b/statix.toml @@ -0,0 +1,9 @@ +# statix configuration +# W20 (repeated keys) is suppressed because NixOS modules idiomatically +# use separate top-level assignments with lib.mkIf guards per service. +disabled = ["repeated_keys"] + +ignore = [ + ".direnv", + "result", +]