* feat(analytics): scaffold PostHog product-analytics integration
- Add @open-design/contracts/analytics subpath with the 17 P0 event
payload types, header constants, and code↔CSV enum mapping helpers.
- Add apps/daemon/src/analytics.ts with env-gated posthog-node client,
request-scoped analytics context reader, and artifact-id anonymizer.
- Expose GET /api/analytics/config so the web bundle never embeds the
PostHog key at build time; daemon owns POSTHOG_KEY / POSTHOG_HOST.
- Add apps/web/src/analytics module (identity + lazy posthog-js client
+ React provider) and mount it under <I18nProvider> in app/layout.
No event wiring yet — that lands in the next commit alongside trigger
points (App.tsx, EntryView, NewProjectPanel, SettingsDialog, FileViewer,
runs.ts).
* feat(analytics): wire app_launch, home_view, home_click, project_create_result
- App.tsx: fire app_launch once after first effect tick. handleCreateProject
now emits project_create_result on both success and failure paths.
- EntryView.tsx: home_view (page) gated on agents loading so
has_available_cli isn't transiently false; home_view (asset_panel) fires
per top-tab change with the right result_count.
- NewProjectPanel.tsx: home_click create_button fires before delegating to
the parent; a fresh request_id is generated here and threaded through
onCreate so the matching project_create_result stitches via $insert_id.
- contracts/analytics: tighten createTabToTracking and topTabToTracking
for the worktree branch's renamed tabs (live-artifact, templates).
* feat(analytics): wire settings_view + 3 settings_click events
- settings_view fires on dialog mount and on every section switch,
carrying the active section (mapped via settingsSectionToTracking
for the 16-section worktree layout), execution_mode, and the
selected CLI provider id when present.
- settings_click execution_mode_tab: setMode now emits before/after
values whenever the user toggles between Local CLI and BYOK.
- settings_click cli_provider_card: agent card onClick reports
cli_provider_id via agentIdToTracking (kiro → other).
- settings_click byok_field: onFocus added to api_key, model select,
and base_url inputs; provider_id widened to include google so the
worktree's Gemini protocol slot type-checks.
* feat(analytics): wire studio_view + studio_click chat, studio_view artifact
- packages/contracts/src/analytics/artifact-id.ts: FNV-1a 64-bit helper
produces a 16-hex anonymized id for (projectId, fileName). Stable
cross-platform so the daemon and the web bundle resolve the same id
without a Web Crypto round-trip; daemon now re-exports it.
- ChatComposer: studio_view chat_panel fires once per project mount,
studio_click chat_composer fires on attachment + send buttons with
estimated user_query_tokens (length/4) and has_attachment.
- FileViewer: studio_view artifact fires once per (project, file) at
the dispatcher level, before any sub-viewer renders, with
artifact_kind derived from the renderer registry / file.kind table.
- Widen TrackingExportFormat to include markdown and cloudflare_pages
so the worktree branch's full share menu can emit verbatim.
* feat(analytics): wire studio_click share_option + artifact_export_result
HtmlViewer's share menu now emits both events per click via a
fireShareExport helper:
- studio_click share_option fires immediately on click with the chosen
export_format and a fresh request_id.
- artifact_export_result fires when the export resolves — success for
sync exporters (html, markdown, template) the moment the call
returns, success/failed for async exporters (pdf, zip, deploy)
via .then/.catch. The same request_id threads both events so
PostHog stitches click → result via $insert_id.
DEPLOY_PROVIDER_OPTIONS maps to the CSV's vercel / cloudflare_pages
slots; markdown is now a first-class export_format value.
Also ignore .env.local so local POSTHOG_KEY / .env-style secrets
don't get committed.
* feat(analytics): emit run_created and run_finished from the daemon
POST /api/runs now reads the analytics context off the
x-od-analytics-* headers the web client sets on every fetch, then:
- Captures run_created with project_id, conversation_id, run_id,
model_id, agent_provider_id (mapped via agentIdToTracking),
skill_id, design_system_id, plus the token_count_source marker.
- Schedules a run_finished capture on runs.wait(run) resolution,
mapping succeeded/canceled/failed to success/cancelled/failed and
reporting total_duration_ms.
Both events use a stable insert_id derived from the same uuid so
PostHog dedupes the daemon-side mirror against any future
web-side capture without double-counting.
Token sub-fields (user_query_tokens/system_prompt_tokens/...) stay
omitted in v1 — the claude-stream parser only exposes input/output
totals today. See tracking-doc-issues.md §3.2.
* feat(analytics): emit settings_cli_test_result + settings_byok_test_result
The original BLOCKING-list assumed these CSV P0 events were not
implementable in this branch because main lacked Test buttons. The
worktree HEAD actually wires `handleTestAgent` and `handleTestProvider`
in SettingsDialog, so both events are now in scope.
- handleTestAgent emits settings_cli_test_result on success and
failure paths with cli_provider_id mapped via agentIdToTracking,
result drawn from result.ok / catch branch, error_code from
result.kind or the thrown error name, and duration_ms timed via
performance.now().
- handleTestProvider emits settings_byok_test_result analogously,
using apiProtocol (anthropic|openai|azure|ollama|google) directly
as provider_id — wider than the CSV's 5-value enum, documented in
tracking-doc-issues.md §2.5.
Contracts: add SettingsCliTestResultProps / SettingsByokTestResultProps
plus matching track* helpers. AnalyticsEventName union now covers all
14 P0 events this branch supports.
* feat(analytics): gate PostHog on the existing telemetry.metrics consent
The integration now reuses the same first-launch privacy banner +
Settings → Privacy toggle that gates Langfuse, so a single user
decision controls both telemetry sinks.
- /api/analytics/config now consults the persisted AppConfigPrefs:
it returns enabled=true only when POSTHOG_KEY is set AND the user
has chosen "Share usage data" (telemetry.metrics === true). The
response also echoes installationId so the web client uses the
same anonymous id Langfuse keys off of — one identity per install,
shared across both sinks.
- Web AnalyticsProvider:
- Bootstrap fetch resolves installationId and threads it through
the x-od-analytics-anonymous-id header on every /api/* fetch,
so daemon-side captures (run_created / run_finished /
project_create_result) land on the same person record.
- Exposes a setConsent(granted) method that calls posthog-js's
opt_in_capturing / opt_out_capturing, wired from App.tsx via a
useEffect watching config.telemetry?.metrics. Toggling Privacy
→ metrics now stops/resumes events immediately, no reload.
- app_launch additionally gates on telemetry.metrics so a freshly-
declined user fires nothing, and a freshly-opted-in user fires on
the next reload.
* feat(packaging): bake POSTHOG_KEY into packaged daemon spawn env
Wires PostHog product analytics through the same Langfuse-style build-
secret pipeline so official Open Design builds ship with the key while
fork builds compile without it (the integration short-circuits cleanly
when POSTHOG_KEY is absent).
tools/pack
- resolveToolPackConfig reads POSTHOG_KEY / POSTHOG_HOST from
process.env at packaging time, validates them (no whitespace in the
key, http(s) URL for host, trailing-slash strip), and stamps them on
ToolPackConfig. Fork builds without the env vars simply omit the
fields; the daemon-side gate keeps things off in that case.
- Mac, Windows, and Linux packaged-config writers each append the two
fields to open-design-config.json next to the existing
telemetryRelayUrl entry.
apps/packaged
- RawPackagedConfig / PackagedConfig surface posthogKey / posthogHost
so the Electron entry and headless entry both forward them to the
daemon sidecar.
- buildPackagedDaemonSpawnEnv emits POSTHOG_KEY / POSTHOG_HOST into
the daemon child env when present. The daemon's existing analytics
module reads these via process.env — no daemon-side changes needed.
- The headless packaged path falls back to process.env for fields the
builder hasn't injected, mirroring how OPEN_DESIGN_TELEMETRY_RELAY_URL
is read there.
CI
- release-beta.yml and release-stable.yml expose POSTHOG_KEY (secret)
and POSTHOG_HOST (var) at workflow-env scope so every packaging job
inherits them. PR / fork builds without these set simply skip the
bake step.
Tests
- tools/pack: config.test.ts covers bake-through, fork-build omission,
whitespace rejection, invalid-URL rejection, and trailing-slash
normalization.
- apps/packaged: sidecars.test.ts covers buildPackagedDaemonSpawnEnv
forwarding the keys when present and omitting them when null.
* feat(analytics): enable PostHog autocapture + perf + exceptions
Flip on the PostHog SDK's automatic diagnostic features so we capture
click paths, page transitions, web vitals, dead clicks, and browser
exceptions without scattering instrumentation through the codebase.
Privacy defense lives in one place — apps/web/src/analytics/scrub.ts —
wired in via posthog-js's `before_send` hook so every outgoing event
passes through the same audit point:
- $autocapture / $rageclick / $dead_click / $copy_autocapture:
strips $el_text and value/placeholder/aria-label attrs from any
input, textarea, password input, or contenteditable element. PostHog
autocapture does not capture input.value by default, but $el_text
on a <textarea> reflects the typed content — that's the prompt
body for us, so it has to be scrubbed every time.
- $pageview / $pageleave: drops query string and fragment from
$current_url / $referrer so any future ?q=… can't leak.
- $exception: rewrites file:// and absolute filesystem paths in
stack frames to app://apps/<repo-relative> so we don't ship the
user's home directory.
- Suppresses $opt_in entirely — duplicate of our explicit
setConsent toggle in App.tsx.
Element-level defense in depth is limited to the single most sensitive
surface: the chat composer textarea gets `ph-no-capture` so PostHog
never even generates an event for clicks inside that subtree. Every
other input relies on scrub.ts — sprinkling the class through every
form would be noisy and easy to forget on new surfaces.
The existing Privacy → "Share usage data" toggle continues to gate
every new feature: posthog-js's opt_out_capturing() halts autocapture,
$pageview, $exception, web vitals, and dead clicks alongside the
explicit capture() calls — one global switch.
11 unit tests pin the scrub rules in apps/web/tests/analytics-scrub.test.ts.
* ci(nix): bump pnpmDepsHash for posthog-js + posthog-node additions
Adding posthog-js to apps/web and posthog-node to apps/daemon changed
pnpm-lock.yaml, which Nix's fixed-output pnpmDeps derivation pins by
sha256. The CI nix flake check failed with:
specified: sha256-KF3Mld72/iau+pJmA7HvnanRx8VLtDP0N624SKrtrrc=
got: sha256-PGFgX4lYyeH2TRAXfUq52A3EOa6bb1gO59hPsXhEk3s=
Copy the new hash into both nix/package-web.nix and
nix/package-daemon.nix per the procedure documented in nix/README.md
§"First-build hash pinning".
* feat(analytics): unify PostHog identity with Langfuse installationId
PostHog's distinct_id is the installationId stamped by /api/analytics/
config; Langfuse already reads the same id off app-config.json to
populate trace.userId. With both sinks keying off the same anonymous
identity, dashboards can correlate user actions (PostHog events) with
LLM runs (Langfuse traces) without re-identifying.
Two gaps closed:
1. applyConsent(false) — clear posthog-js's persisted ph_*_posthog
localStorage entry on opt-out via posthog.reset(). Without this, a
user who opts out, then clicks Delete my data, then re-opts in
would see PostHog stitch their new session to the deleted identity
because bootstrap.distinctID only takes effect on first init.
2. applyIdentity(newInstallationId) — Delete my data rotates the
installationId in app-config; App.tsx now watches config.installationId
and calls posthog.reset() then identify(newId) so the next event
batch is fully decoupled from the deleted one. Idempotent on
same-id re-renders so benign config refreshes don't churn PostHog
identities.
The fetch wrapper's x-od-analytics-anonymous-id header also flips to
the new id on rotation so daemon-side captures (run_created /
run_finished) land on the same person record from the very next API
call, not after a reload.
The end-to-end rotation flow is verified against a live PostHog
project; these unit tests pin the safety guards (no-client paths, null
inputs) since stubbing posthog-js's init-loaded callback chain is
brittle.
* fix(langfuse): require both metrics AND content consent for trace reports
Tightens the Langfuse gate so a user who shares anonymous metrics but
NOT conversation content stops emitting Langfuse traces entirely —
Langfuse is used for turn-quality evals which only make sense with
prompt/output bodies. PostHog (product analytics, content-free) stays
gated on `metrics` alone and is unaffected.
i18n: "Conversation content" → "Conversation and tool content" with
hints expanded to mention tool inputs/outputs so the consent surface
matches what the trace actually carries (en + zh-CN).
Bundled here per PR scope — change originated outside this PostHog
PR but lands cleanly on the same files; gating Langfuse strictly
on `content` makes the dual-sink consent model (PostHog = metrics,
Langfuse = metrics + content) symmetric across both i18n locales and
the daemon-side gate.
* feat(analytics): wire byok_provider_option + fix PR review P1s
Adds the BYOK protocol-chip click event (5-value provider_id mirroring
the apiProtocol Settings UI) and resolves four P1 review threads on
PR #1428.
byok_provider_option:
- New SettingsClickByokProviderOptionProps in contracts (provider_id =
anthropic|openai|azure|google|ollama; maps to CSV's 5 values per
tracking-doc-issues.md §2.5).
- trackSettingsClickByokProviderOption helper in apps/web/src/analytics.
- SettingsDialog hooks it on the protocol-chip onClick alongside the
existing setApiProtocol call; is_selected reflects whether the chip
was already active.
Review fixes:
1. client.ts (Siri-Ray): clear `initPromise` when the resolution is
null so a Privacy → metrics opt-in after a previous decline triggers
a fresh /api/analytics/config fetch. Without this, the disabled
response was cached forever — first-session opt-in needed a reload
to start sending PostHog events.
2. provider.tsx (Siri-Ray): replace `url.includes('/api/')` with a
strict same-origin + /api/ pathname check (shared
`isSameOriginApiCall` helper). Outbound third-party URLs containing
`/api/` (e.g. provider.example.com/api/x) no longer receive our
x-od-analytics-* headers.
3. provider.tsx (codex-connector, lefarcen): gate header injection on
`resolvedAnonId` being non-null. When Privacy → metrics is off,
/api/analytics/config returns enabled=false → resolvedAnonId stays
null → wrapper never installs → daemon can't read consent-bearing
headers → no daemon-side PostHog event. setConsent now also clears
resolvedAnonId on opt-out and re-fetches on opt-in.
4. daemon/analytics.ts (defense in depth): createAnalyticsService now
takes dataDir and capture() re-reads app-config to check
telemetry.metrics inside the fire-and-forget wrapper. Even if a
stale header somehow reaches the daemon after opt-out, the capture
is dropped before posthog-node.capture is called.
* fix(web): place "Share usage data" on the right in privacy consent banner
Swap button order in PrivacyConsentModal and the in-settings ConsentCard
so the affirmative "Share usage data" lands on the right and "Not now"
on the left. Matches the OK-on-the-right pattern users expect for
primary actions.
Both buttons keep equal visual prominence (same .privacy-consent-action
styling) so the swap doesn't change the EDPB equal-prominence stance
called out in the original Langfuse telemetry spec.
* feat(analytics): populate run_finished token totals from claude-stream usage
Daemon's claude-stream parser already emits agent usage events with
input_tokens / output_tokens totals; the run service buffers them in
run.events and Langfuse reads them out the same way. The run_finished
PostHog event was leaving these fields empty.
Scan run.events for the most recent agent usage frame on terminal
transition and emit input_tokens / output_tokens / total_tokens when
present. token_count_source flips to 'provider_usage' only when at
least one count landed; runs without provider-side usage data keep
'unknown'.
Provider does not break the input down into the 7 sub-fields the
tracking doc lists (memory / context / attachment / system_prompt /
…); those stay omitted until a parser change exposes them.
* feat(analytics): estimate user_query_tokens from prompt length
The user_query_tokens field for run_created / run_finished was hardcoded
to 0. We can't tokenize without bundling a model-specific tokenizer, but
the character/4 heuristic is the industry-standard estimate when one
isn't available and is enough for funnel analysis (prompt-length cohorts,
short-vs-long-query conversion rates).
Extracted from req.body via the same telemetryPromptFromRunRequest
pattern the daemon already uses for langfuse-bridge (currentPrompt then
message fallback). Only the integer count goes to PostHog — the prompt
text itself never leaves the daemon.
token_count_source flips appropriately:
- run_created with a prompt: 'estimated' (was 'unknown')
- run_created with no prompt: 'unknown'
- run_finished with provider usage: 'provider_usage' (overrides
baseProps' 'estimated' value)
- run_finished without provider usage: inherits 'estimated' or 'unknown'
from baseProps so input/output absent doesn't mask the estimate.
|
||
|---|---|---|
| .. | ||
| home-manager.nix | ||
| module-common.nix | ||
| nixos.nix | ||
| package-daemon.nix | ||
| package-web.nix | ||
| README.md | ||
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
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:
{
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 --userunitsopen-design.serviceand (optionally)open-design-web.service.systemctl --user status open-design. - macOS:
launchdagentsio.nexu.open-designand (optionally)io.nexu.open-design-web.launchctl print gui/$UID/io.nexu.open-design. - Data lives in
$HOME/.od/by default — overridedataDirto relocate.
(2) NixOS — for shared/server installs
{
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:8080while 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 setservices.open-design.webFrontend.allowedOrigins = [ "<your-proxy-origin>" ](which feedsOD_ALLOWED_ORIGINS) or, for the loopback-only split-port case, setextraEnv.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:
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}(orpackages.<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 — age- or PGP-encrypted YAML, decrypted into runtime files at activation.
- 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:
services.open-design.environmentFile = "/run/secrets/open-design.env";
Never inline a secret with pkgs.writeText or home.file.
First-build hash pinning
Both nix/package-daemon.nix and nix/package-web.nix vendor the pnpm
store via a fixed-output derivation (pnpmDeps). The outputHash
defaults to lib.fakeSha256 so nix build will fail with the expected
hash printed. Copy that value into the matching pnpmDepsHash constant
at the top of each file and re-run. Bump the hash whenever
pnpm-lock.yaml changes.
CI
.github/workflows/nix-check.yml runs nix flake check followed by
separate nix build .#daemon and nix build .#web steps on each push
that touches the flake or the lockfile. Build artifacts are cached on
the nexu-open-design Cachix instance — PRs from forks read from the
cache without needing the auth token.