mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
* chore(nix): streamline pnpm deps hash maintenance Generated-By: looper 0.9.0 (runner=worker, agent=opencode) * fix(ci): satisfy actionlint in nix hash autofix Generated-By: looper 0.9.0 (runner=fixer, agent=opencode) * fix(ci): allow nix hash autofix on fork PRs Generated-By: looper 0.9.0 (runner=fixer, agent=opencode) * fix(ci): follow up nix hash review Generated-By: looper 0.9.0 (runner=fixer, agent=opencode) * fix(ci): tolerate nix hash bot token failures Generated-By: looper 0.9.0 (runner=fixer, agent=opencode)
261 lines
13 KiB
Markdown
261 lines
13 KiB
Markdown
# Open Design — Nix flake
|
|
|
|
This flake exposes Open Design as a reproducible package, a `nix run` entry
|
|
point, a dev shell, and Home Manager / NixOS modules. The architecture
|
|
mirrors the runtime: the **daemon** (`od` CLI, Express API on `/api/*`)
|
|
and the **web frontend** (Next.js static SPA at `apps/web/out/`) are
|
|
**separate packages** and **separate services** — you can run either or
|
|
both.
|
|
|
|
## Outputs
|
|
|
|
| Output | What it is |
|
|
| ------------------------------------------ | -------------------------------------------------------------------------------------- |
|
|
| `packages.<system>.daemon` | The `@open-design/daemon` package — produces `bin/od`. Default output. |
|
|
| `packages.<system>.web` | The Next.js static export (`apps/web/out/`) ready to drop into any static file server. |
|
|
| `apps.<system>.default` | `nix run github:nexu-io/open-design` — boots the daemon. |
|
|
| `devShells.<system>.default` | Node 24 + Corepack-pinned pnpm 10.33 — reproduces `pnpm install` locally. |
|
|
| `homeManagerModules.{default,open-design}` | Home Manager module — primary individual-developer interface. |
|
|
| `nixosModules.{default,open-design}` | NixOS module — secondary, for shared/server installs. |
|
|
|
|
## Try it without installing
|
|
|
|
```bash
|
|
nix run github:nexu-io/open-design # boots the daemon on :7457
|
|
nix develop github:nexu-io/open-design # drop into the dev shell
|
|
```
|
|
|
|
## (1) Home Manager — the recommended path
|
|
|
|
For an individual workstation, add the flake as an input and import the
|
|
default module:
|
|
|
|
```nix
|
|
{
|
|
inputs.open-design.url = "github:nexu-io/open-design";
|
|
|
|
outputs = { self, home-manager, open-design, ... }: {
|
|
homeConfigurations.you = home-manager.lib.homeManagerConfiguration {
|
|
modules = [
|
|
open-design.homeManagerModules.default
|
|
{
|
|
services.open-design = {
|
|
enable = true;
|
|
autoStart = true; # systemd --user / launchd agent
|
|
webFrontend.enable = true; # also run the static SPA on :5174
|
|
};
|
|
}
|
|
];
|
|
};
|
|
};
|
|
}
|
|
```
|
|
|
|
What this wires up:
|
|
|
|
- Linux: `systemd --user` units `open-design.service` and (optionally)
|
|
`open-design-web.service`. `systemctl --user status open-design`.
|
|
- macOS: `launchd` agents `io.nexu.open-design` and (optionally)
|
|
`io.nexu.open-design-web`. `launchctl print gui/$UID/io.nexu.open-design`.
|
|
- Data lives in `$HOME/.od/` by default — override `dataDir` to relocate.
|
|
|
|
## (2) NixOS — for shared/server installs
|
|
|
|
```nix
|
|
{
|
|
imports = [ inputs.open-design.nixosModules.default ];
|
|
|
|
services.open-design = {
|
|
enable = true;
|
|
autoStart = true;
|
|
openFirewall = true;
|
|
webFrontend.enable = true;
|
|
user = "open-design";
|
|
group = "open-design";
|
|
};
|
|
}
|
|
```
|
|
|
|
This creates a system user, drops a tmpfiles rule for `/var/lib/open-design`,
|
|
and runs the daemon under hardened systemd (`ProtectSystem=strict`,
|
|
`PrivateTmp`, `ReadWritePaths` scoped to the data directory). Use this
|
|
when you want a single shared instance — for individual user
|
|
configuration prefer the Home Manager module.
|
|
|
|
## (3) `webFrontend` — when to use it, when to bring your own server
|
|
|
|
Open Design's frontend is a static SPA that issues relative `/api/*`,
|
|
`/artifacts/*`, and `/frames/*` requests. Three serving options:
|
|
|
|
| Option | When |
|
|
| -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
| `webFrontend.enable = true` | You want one-line setup. The module spawns a tiny Caddy file server on `webFrontend.port` (default `5174`) that serves the SPA and reverse-proxies the three path prefixes to the daemon. |
|
|
| `webFrontend.enable = false` (default) | You're running nginx / Caddy / Apache / Traefik yourself. Point your server's document root at `${pkgs.open-design.web}` (or the `packages.<system>.web` output) and replicate the proxy contract in section (4). |
|
|
| Skip the frontend entirely | You only need the daemon's API for headless agent dispatch. |
|
|
|
|
The two services are independent. `autoStart` controls the daemon;
|
|
`webFrontend.enable` controls the static server. Mix freely.
|
|
|
|
> **Bring-your-own-server gotcha:** if your proxy listens on any
|
|
> origin that differs from the daemon's bind (different host _or_
|
|
> different port — even loopback split-port like
|
|
> `http://127.0.0.1:8080` while the daemon stays on `:7457`), the
|
|
> daemon's same-origin gate will 403 the SPA's writes until you tell
|
|
> it about that origin. Either set
|
|
> `services.open-design.webFrontend.allowedOrigins = [ "<your-proxy-origin>" ]`
|
|
> (which feeds `OD_ALLOWED_ORIGINS`) or, for the loopback-only
|
|
> split-port case, set `extraEnv.OD_WEB_PORT = "<proxy-port>"`. See
|
|
> section (4) for the full decision tree.
|
|
|
|
### Exposing the bundled frontend on a non-loopback host
|
|
|
|
By default `webFrontend.host = "127.0.0.1"` so enabling the bundled
|
|
caddy does not publish anything beyond loopback. To intentionally
|
|
share with a LAN, two settings must be widened together — the
|
|
modules assert at eval time that the second is set whenever the
|
|
first is widened:
|
|
|
|
```nix
|
|
services.open-design.webFrontend = {
|
|
enable = true;
|
|
host = "0.0.0.0"; # caddy listener
|
|
# Every external origin browsers will load the SPA from. The daemon
|
|
# matches each entry against the browser's `Origin` header AND adds
|
|
# its host:port to the `Host`-header allowlist (Caddy v2 reverse_proxy
|
|
# preserves the original Host upstream by default), so list each
|
|
# scheme + hostname combo you actually use.
|
|
allowedOrigins = [
|
|
"http://laptop.local:5174"
|
|
"https://laptop.local:5174"
|
|
];
|
|
};
|
|
# On NixOS you also need:
|
|
services.open-design.openFirewall = true;
|
|
```
|
|
|
|
Under the hood `allowedOrigins` is forwarded to the daemon as the
|
|
`OD_ALLOWED_ORIGINS` environment variable (comma-separated). If you
|
|
run the daemon outside the modules — for example, behind your own
|
|
nginx/caddy — set `OD_ALLOWED_ORIGINS` directly in the daemon's
|
|
environment with the same shape:
|
|
|
|
```
|
|
OD_ALLOWED_ORIGINS=http://host1:port,https://host1:port,http://host2:port
|
|
```
|
|
|
|
Each entry must be a bare origin (`scheme://host[:port]`); only
|
|
`http://` and `https://` schemes are accepted, and the daemon refuses
|
|
to start if any entry fails to parse. The variable widens only the
|
|
general `/api/*` same-origin gate — connector-credential and
|
|
live-artifact preview/refresh routes stay strictly loopback-only by
|
|
design.
|
|
|
|
## (4) Same-origin proxying contract
|
|
|
|
The web package is built with `OD_DAEMON_URL = ""` so the bundled JS
|
|
issues **relative** requests — `/api/*`, `/artifacts/*`, `/frames/*` —
|
|
instead of baking a daemon URL into the export. There is no runtime
|
|
config endpoint; the SPA does not read `OD_DAEMON_URL` from the
|
|
serving environment.
|
|
|
|
The serving contract is therefore: **the static export must be served
|
|
same-origin with a reverse proxy to the daemon**. The bundled caddy
|
|
service does exactly this — `webFrontend` listens on
|
|
`webFrontend.port` and reverse-proxies the three path prefixes above
|
|
to `127.0.0.1:<cfg.port>`, with `flush_interval -1` and no `encode` on
|
|
`/api/*` so SSE streams flush immediately (gzip would buffer chunked
|
|
responses for ~80s and surface as `ERR_INCOMPLETE_CHUNKED_ENCODING`).
|
|
|
|
If you serve the static bundle yourself, replicate that shape:
|
|
|
|
- Document root → `${pkgs.open-design.web}` (or
|
|
`packages.<system>.web`).
|
|
- Reverse-proxy `/api/*`, `/artifacts/*`, `/frames/*` to the daemon's
|
|
bind address; `/api/*` must stream chunks immediately and skip
|
|
response compression.
|
|
- SPA fallback for unmatched paths → `index.html`.
|
|
|
|
The static-server's environment does not need any Open Design env
|
|
vars — but **the daemon's environment usually does**, because its
|
|
same-origin gate is built from `OD_BIND_HOST:port` (loopback hosts
|
|
included). The browser's `Origin` and `Host` are whatever your proxy
|
|
exposes, so unless that matches `127.0.0.1:<daemon-port>` exactly,
|
|
the daemon will 403 every PUT/POST until told otherwise:
|
|
|
|
| Your custom-server setup | What to set on the daemon |
|
|
| ------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
| Proxy at `http://127.0.0.1:<daemon-port>` (same host, same port — unusual) | Nothing. |
|
|
| Proxy at a loopback host but different port (e.g. `http://127.0.0.1:8080` while daemon is on `:7457`) | Either `extraEnv.OD_WEB_PORT = "8080"` (whitelists `8080` on every loopback host) or `services.open-design.webFrontend.allowedOrigins`. |
|
|
| Proxy on any non-loopback host (LAN IP, mDNS name, Tailscale name, public domain — `https://od.example.com`, `http://laptop.local:5174`, …) | `services.open-design.webFrontend.allowedOrigins = [ "<full origin>" ]`. List every scheme + host[:port] combo a browser might load the SPA from. |
|
|
|
|
`webFrontend.allowedOrigins` is forwarded to the daemon as
|
|
`OD_ALLOWED_ORIGINS`; if you run the daemon outside the modules,
|
|
export `OD_ALLOWED_ORIGINS` directly with the same shape (see
|
|
section (3)). The variable widens only the general `/api/*` gate —
|
|
connector-credential and live-artifact preview/refresh routes stay
|
|
strictly loopback-only by design.
|
|
|
|
## (5) Secrets — DO NOT put them in your Nix config
|
|
|
|
The `environmentFile` option takes a path to a `KEY=VALUE` file that the
|
|
service unit reads. Use it for BYOK API keys (Anthropic, OpenAI, Gemini),
|
|
provider tokens, and anything else you do not want world-readable in
|
|
`/nix/store`.
|
|
|
|
Recommended secret managers:
|
|
|
|
- [sops-nix](https://github.com/Mic92/sops-nix) — age- or PGP-encrypted
|
|
YAML, decrypted into runtime files at activation.
|
|
- [agenix](https://github.com/ryantm/agenix) — age-encrypted single
|
|
files, dropped into `/run/agenix/` at boot.
|
|
|
|
Either renders to a file like `/run/secrets/open-design.env`; pass that
|
|
path:
|
|
|
|
```nix
|
|
services.open-design.environmentFile = "/run/secrets/open-design.env";
|
|
```
|
|
|
|
Never inline a secret with `pkgs.writeText` or `home.file`.
|
|
|
|
## First-build hash pinning
|
|
|
|
`nix/pnpm-deps.nix` is the generated single source of truth for the
|
|
vendored pnpm store hash used by both `nix/package-daemon.nix` and
|
|
`nix/package-web.nix`. Treat it like a lock artifact, not a hand-edited
|
|
source file. If `pnpm-lock.yaml` changes and you are intentionally
|
|
maintaining the Nix packaging, run:
|
|
|
|
```bash
|
|
pnpm nix:update-hash
|
|
```
|
|
|
|
The script temporarily swaps one consumer to `lib.fakeHash`, runs the
|
|
matching `nix build .#<consumer> --print-build-logs`, extracts the
|
|
expected hash from the failure output, writes it back into
|
|
`nix/pnpm-deps.nix`, and restores the consumer file. The script runs via
|
|
`node --experimental-strip-types`, so CI can invoke it without first
|
|
installing the workspace.
|
|
|
|
## CI
|
|
|
|
`.github/workflows/nix-check.yml` runs `nix flake check` on pushes to
|
|
`main` and can also be started manually with `workflow_dispatch`.
|
|
|
|
Pull requests that touch Nix inputs, daemon/web Nix build closures, or the
|
|
generated hash maintenance workflows are validated earlier in
|
|
`.github/workflows/ci.yml` via the required `Validate workspace` gate.
|
|
That PR path runs `nix flake check` for `flake.*`, `nix/**`, root lock and
|
|
workspace manifests, and files that are actually in the daemon/web Nix
|
|
closures. The flake also filters each derivation down to only the workspace
|
|
packages it actually installs, so unrelated package/tool changes stay off the
|
|
slower Nix path and do not churn the other derivation's pnpm store hash.
|
|
|
|
When a PR run fails because `nix/pnpm-deps.nix` is stale, the CI job also
|
|
tries to regenerate a hash-only patch:
|
|
|
|
- same-repo PRs get a bot-authored commit pushed back to the PR branch when
|
|
the generated patch only touches `nix/pnpm-deps.nix`;
|
|
- fork PRs get a PR comment plus a workflow artifact containing the patch;
|
|
- the failing run still stays red until the generated patch lands and a
|
|
fresh validation run passes.
|