feat(dev-env): aiolabs dev environment — options, lib, config, presets

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-06-28 06:48:37 +02:00
commit 7960f82494
6 changed files with 1689 additions and 0 deletions

143
modules/dev-env/README.md Normal file
View file

@ -0,0 +1,143 @@
# modules/dev-env
Declarative NixOS module for managing a multi-project dev environment.
## Design principles
1. **Nix owns the configuration**, bash owns the runtime. Nix renders
`/etc/dev-env/config.sh` and `projects.json`; installed bash scripts
source those at call time. Navigation helpers walk the filesystem at
runtime — adding a new branch is `git worktree add`, never
`nixos-rebuild`.
2. **The deploy flake is the single source of truth** for deployed refs.
Set `dev-env.deploy.flakeInput = "deploy-flake"` and projects will be
derived from `inputs.deploy-flake.inputs` whose URLs live under the
configured forgejo host. Hand-authored extras go in
`dev-env.projects`.
3. **Bootstrap is user-invoked, not an activation hook.**
`dev-env-bootstrap` materializes bare repos + worktrees. It is never
run during `nixos-rebuild` — rebuilds stay fast and offline.
## Files
| File | Purpose |
|---|---|
| `default.nix` | Imports options, lib, config. Module entry point. |
| `options.nix` | `mkOption` declarations for every knob. |
| `lib.nix` | `mkProject` constructor + `deriveFromFlakeInput` parser. |
| `config.nix` | Renders config files, installs scripts, wires git hooks. |
| `presets/aiolabs.nix` | Hand-authored project list for the aiolabs ecosystem. |
| `scripts/*.sh` | Ported + new bash helpers loaded via `builtins.readFile`. |
| `scripts/git-hooks/pre-commit` | Shared secret-scanner hook (via `core.hooksPath`). |
| `docs/*.md` | Runbooks shipped to the user's `~/Documents/dev-env/`. |
## Using from omni
```nix
# omni/flake.nix
inputs.deploy-flake = {
url = "git+ssh://forgejo@git.atitlan.io/padreug/deploy-unified";
};
# omni/configuration.nix
imports = [
./modules/dev-env
./modules/dev-env/presets/aiolabs.nix # opt-in to aiolabs defaults
];
dev-env = {
enable = true;
forgejo.org = "aiolabs";
github.forkUser = "your-github-username";
deploy.flakeInput = "deploy-flake";
deploy.targets = {
host1 = "root@host1.example";
host2 = "root@host2.example";
host3 = "root@host3.example";
host4 = "root@host4.example";
host5 = "root@host5.example";
host6 = "root@host6.example";
host7 = "root@host7.example";
host8 = "root@host8.example";
};
};
```
## Bootstrap workflow
```bash
# 1. Rebuild with dev-env enabled
sudo nixos-rebuild switch --flake .#omni
# 2. Dry-run to see what will be created
dev-env-bootstrap --dry-run
# 3. Materialize bare repos + worktrees
dev-env-bootstrap
# 4. Navigate
lb dev # → ~/dev/lnbits/dev
bs # → ~/dev/bitspire/bitspire/dev
lam lightning-pub # → ~/dev/lamassu-next/lightning-pub/dev
prb lnbits fix-x # → ~/dev/upstream-prs/lnbits-fix-x on upstream/main
# 5. Deploy
dev-deploy host5 # uses locked deploy-flake input
dev-deploy --local host1 # overrides inputs with local worktrees
```
## Extending
Add a new project in `presets/aiolabs.nix` (or a new preset):
```nix
dev-env.projects.my-new-project = lib.mkProject {
category = "shared";
upstream = "https://github.com/example/my-project";
worktrees.main.branch = "main";
};
```
Then rebuild and `dev-env-bootstrap`. Existing worktrees are never
clobbered — the bootstrap script refuses to touch a worktree whose
current branch differs from the declared one.
## What this module does NOT do
- Manage bitSpire's internal `flake.nix`/`devenv.nix` — that project
has its own dev environment.
- Run `git fetch`/`git pull` automatically — use `wts`/`wtu` helpers or
a manual `git fetch --all`.
- Replace `direnv` per-worktree `.envrc` files — it just makes sure
`nix-direnv` is enabled (omni's home.nix already does this).
## Reference docs (`docs/`)
LNbits-domain guides that travel with this module — so the `lnbits` pack
is a self-contained dev environment whether used on omni or imported
standalone (`nixosModules.lnbits`). These were consolidated here from the
former standalone `lnbits-sensei` scaffold, which this pack supersedes.
- [`lnbits-workspace-notes.md`](docs/lnbits-workspace-notes.md) — practical
day-to-day gotchas: ports, `LNBITS_SRC` build-context traps,
extension-folder-upgrade wiping forks, settings precedence (`.env` vs
DB), Nostr keys, CLINK scope, fork versioning.
- [`lnbits-extension-dev.md`](docs/lnbits-extension-dev.md) — building
extensions: auth-decorator distinctions, FakeWallet vs regtest testing,
the `migrations_fork.py` pattern.
- [`lnbits-frontend-gotchas.md`](docs/lnbits-frontend-gotchas.md) —
Vue/Quasar UMD traps (no self-closing tags, CSS specificity vs Quasar
`!important`, cache busting, dark-mode discipline).
- [`lnbits-upstream-flow.md`](docs/lnbits-upstream-flow.md) — how
`lnbits/lnbits` itself moves (dev/main branch model, squash-merge,
release tagging).
- [`upstream-prs.md`](docs/upstream-prs.md) — sending a PR upstream via the
`~/dev/upstream-prs/` worktree flow (with a fork/PR primer).
- [`remotes.md`](docs/remotes.md) — the three remote-topology patterns
(upstream-only / github-fork / multi-remote with a private host).
- [`secrets-management.md`](docs/secrets-management.md) — getting secrets
out of `.env` into sops-encrypted YAML.
- [`stack-overview.md`](docs/stack-overview.md) — the aiolabs stack map
(which repo is what, how the pieces fit).

355
modules/dev-env/config.nix Normal file
View file

@ -0,0 +1,355 @@
{
config,
lib,
pkgs,
inputs ? { },
...
}:
let
inherit (lib)
mkIf
mkDefault
mkMerge
optional
;
cfg = config.dev-env;
helpers = cfg.lib;
# ---------------------------------------------------------------------
# Auto-derive projects from the deploy flake's inputs.
#
# Reads the deploy flake's flake.lock at eval time, walks every
# direct input declared by the deploy flake, filters to URLs under
# our configured forgejo host, and constructs project entries.
# Multiple inputs pointing at the same repo with different refs are
# collapsed into a single project with multiple worktrees via
# groupDerivedProjects (e.g., webapp + webapp-dev → one `webapp`
# project with two worktrees, main and dev).
#
# Returns {} when:
# - deriveProjectsFromInputs = false
# - flakeInput = null
# - the input isn't in scope (e.g. smoke test running without
# specialArgs.inputs)
#
# Reading the lock file directly is more robust than walking
# inputs.<flake>.inputs because the lock file has a stable,
# documented shape regardless of flake-compat quirks.
# ---------------------------------------------------------------------
derivedProjects =
if
cfg.deploy.deriveProjectsFromInputs
&& cfg.deploy.flakeInput != null
&& (inputs ? ${cfg.deploy.flakeInput})
then
let
deployFlake = inputs.${cfg.deploy.flakeInput};
lockData = builtins.fromJSON (builtins.readFile "${deployFlake}/flake.lock");
# Root node enumerates the deploy flake's direct inputs as
# { inputName = nodeName; ... }. A few inputs reference shared
# nodes via a list (inherited inputs); we drop those since
# they aren't the direct declarations we care about.
rootInputs = lib.filterAttrs (_: v: builtins.isString v) (lockData.nodes.root.inputs or { });
# Reconstruct a URL in the format parseForgejoUrl expects
# (git+<scheme>://host/org/repo[.git][?ref=branch]) from a
# flake.lock node. Lock entries store the URL without the
# `git+` prefix and put the ref in a separate field, so we
# normalize both.
#
# The `original` block records what the user declared. When
# they declared `?ref=main` explicitly, original.ref is set.
# When they declared no ref (implicit default branch), the
# `locked` block still has the resolved ref — but in the
# form `refs/heads/main`. We fall back to that and strip
# the prefix so the output matches the format parseForgejoUrl
# expects.
normalizeLockEntry =
nodeName:
let
node = lockData.nodes.${nodeName} or { };
src = node.original or node.locked or { };
url = src.url or "";
rawRef =
if src ? ref && src.ref != null then
src.ref
else if node ? locked && node.locked ? ref then
node.locked.ref
else
null;
ref = if rawRef == null then null else lib.removePrefix "refs/heads/" rawRef;
refSuffix = if ref != null then "?ref=${ref}" else "";
in
if url == "" then null else "git+${url}${refSuffix}";
parsed = lib.mapAttrsToList (
inputName: nodeName:
let
url = normalizeLockEntry nodeName;
in
if url == null then null else helpers.deriveFromFlakeInput { inherit inputName url; }
) rootInputs;
in
helpers.groupDerivedProjects parsed
else
{ };
# Final project set — hand-authored entries from cfg.projects
# completely override derived ones on key collision (shallow merge
# via //). Keys only in derivedProjects get auto-populated; keys in
# cfg.projects use the hand-authored version as-is.
#
# Rationale: deep-merge would give inconsistent behavior because
# cfg.projects entries already have every submodule default applied
# (e.g. `upstream = null`), and those defaults would silently
# replace derived values. Shallow merge forces the user to write
# complete entries when overriding — predictable and obvious.
mergedProjects = derivedProjects // cfg.projects;
# Resolve a project's complete shape (paths + remotes) once, so the
# JSON renderer and the bash scripts both see identical data.
resolveProject =
name: project:
let
bare = helpers.bareRepoPath name project;
remotes = helpers.projectRemotes project;
# Where a non-worktree clone would land
cloneCategoryDir = if project.category != null then "${cfg.root}/${project.category}" else cfg.root;
clonePath = "${cloneCategoryDir}/${project.worktreeRoot}";
resolvedWorktrees = lib.mapAttrs (wtName: wt: {
inherit (wt) branch remote;
path = helpers.worktreePath name project wtName;
}) project.worktrees;
in
{
inherit (project)
forgejoRepo
upstream
githubFork
category
worktreeRoot
isClone
deployFlakeInput
;
barePath = bare;
clonePath = clonePath;
remotes = remotes;
worktrees = resolvedWorktrees;
};
resolvedProjects = lib.mapAttrs resolveProject mergedProjects;
projectsJson = pkgs.writeText "dev-env-projects.json" (builtins.toJSON resolvedProjects);
tmuxSessionsJson = pkgs.writeText "dev-env-tmux-sessions.json" (builtins.toJSON cfg.tmux.sessions);
# Render /etc/dev-env/config.sh — the bash-readable runtime config.
# Provides DEV_ROOT, REPOS_DIR, etc. and per-host DEPLOY_TARGET_<HOST>
# env vars (the format dev-deploy looks for).
renderConfigSh = pkgs.writeText "dev-env-config.sh" ''
# Auto-generated by dev-env module — do not edit.
# Source from /etc/dev-env/config.sh
export DEV_ROOT="${cfg.root}"
export REPOS_DIR="${cfg.root}/repos"
export LNBITS_DIR="${cfg.root}/lnbits"
export WEBAPP_DIR="${cfg.root}/webapp"
export DEPLOY_DIR="${cfg.root}/deploy"
export SHARED_DIR="${cfg.root}/shared"
export LOCAL_DIR="${cfg.root}/local"
export DOCS_DIR="${cfg.root}/docs"
export UPSTREAM_PRS_DIR="${cfg.root}/upstream-prs"
export BITSPIRE_DIR="${cfg.root}/bitspire"
export LAMASSU_NEXT_DIR="${cfg.root}/lamassu-next"
export FORGEJO_HOST="${cfg.forgejo.host}"
export FORGEJO_SSH="${cfg.forgejo.sshUser}@${cfg.forgejo.host}"
export FORGEJO_ORG="${cfg.forgejo.org}"
export GITHUB_SSH="git@github.com"
${lib.optionalString (cfg.github.forkUser != null) ''
export GITHUB_FORK_USER="${cfg.github.forkUser}"
''}
export DEVENV_PROJECTS_JSON="/etc/dev-env/projects.json"
export DEVENV_WRITE_DIRENV_HINTS="${if cfg.writeDirenvHints then "1" else "0"}"
${lib.optionalString (cfg.deploy.flakeInput != null) ''
export DEVENV_DEPLOY_FLAKE_INPUT="${cfg.deploy.flakeInput}"
''}
# Deploy targets — one env var per host
${lib.concatStringsSep "\n" (
lib.mapAttrsToList (
host: target: ''export DEPLOY_TARGET_${lib.replaceStrings [ "-" ] [ "_" ] host}="${target}"''
) cfg.deploy.targets
)}
# List of all deploy hosts (bash array)
export DEPLOY_TARGETS=(${
lib.concatStringsSep " " (lib.mapAttrsToList (host: _: ''"${host}"'') cfg.deploy.targets)
})
'';
# Bash script wrappers — load source verbatim from ./scripts/*.sh.
# Using readFile keeps editor tooling/shellcheck working on the .sh files.
mkScriptBin = name: src: pkgs.writeShellScriptBin name (builtins.readFile src);
# Sourceable bash modules (functions only) that get loaded by
# /etc/profile.d/dev-env-functions.sh into every interactive shell.
shellFnSources = [
./scripts/nav.sh
./scripts/worktree.sh
./scripts/pr-helpers.sh
]
++ optional cfg.regtest.enable ./scripts/regtest.sh;
shellFnLoader = pkgs.writeText "dev-env-functions.sh" ''
# Auto-generated by dev-env module.
# Sources every dev-env shell-function module into the current shell.
${lib.concatMapStringsSep "\n" (src: ''
if [[ -r ${src} ]]; then
# shellcheck disable=SC1090
source ${src}
fi
'') shellFnSources}
'';
in
{
config = mkIf cfg.enable (mkMerge [
{
# 1) /etc/dev-env/* config files (machine-readable)
environment.etc = {
"dev-env/config.sh".source = renderConfigSh;
"dev-env/projects.json".source = projectsJson;
"dev-env/tmux-sessions.json".source = tmuxSessionsJson;
# Shared bash libraries sourced by the scripts at runtime —
# config loader (function modules) + colour palette (bins).
"dev-env/lib.sh".source = ./scripts/lib.sh;
"dev-env/lib-colors.sh".source = ./scripts/lib-colors.sh;
};
# 2) Loader so interactive shells (login OR non-login) get the
# functions. We can't rely on /etc/profile.d/*.sh alone
# because NixOS only sources that from /etc/profile (login
# shells). Hyprland-launched terminals (Alacritty, etc.)
# are interactive non-login shells, so they would never see
# these functions. `environment.interactiveShellInit` is
# sourced by both /etc/bashrc and /etc/zshrc on every
# interactive shell, which is what we want. We still install
# the file under /etc/profile.d for ssh-without-tty cases
# and for users who want to source it explicitly.
environment.etc."profile.d/dev-env-functions.sh" = {
source = shellFnLoader;
};
environment.interactiveShellInit = ''
if [[ -r /etc/profile.d/dev-env-functions.sh ]]; then
# shellcheck disable=SC1091
source /etc/profile.d/dev-env-functions.sh
fi
'';
# 3) System packages — every standalone helper.
environment.systemPackages = [
# core deps used by every script
pkgs.git
pkgs.jq
# standalone helpers
(mkScriptBin "dev-env-bootstrap" ./scripts/bootstrap.sh)
(mkScriptBin "dev-status" ./scripts/status.sh)
(mkScriptBin "dev-tm" ./scripts/tmux-launch.sh)
(mkScriptBin "dev-deploy" ./scripts/deploy.sh)
(mkScriptBin "rebase" ./scripts/rebase.sh)
]
++ lib.optionals cfg.regtest.enable [
(mkScriptBin "regtest-start" (
pkgs.writeShellScript "rs" ''
source ${./scripts/regtest.sh}
regtest-start "$@"
''
))
(mkScriptBin "regtest-stop" (
pkgs.writeShellScript "rs2" ''
source ${./scripts/regtest.sh}
regtest-stop "$@"
''
))
(mkScriptBin "regtest-status" (
pkgs.writeShellScript "rs3" ''
source ${./scripts/regtest.sh}
regtest-status "$@"
''
))
(mkScriptBin "regtest-lnbits-rebuild" (
pkgs.writeShellScript "rs5" ''
source ${./scripts/regtest.sh}
regtest-lnbits-rebuild "$@"
''
))
(mkScriptBin "regtest-lnbits-restart" (
pkgs.writeShellScript "rs6" ''
source ${./scripts/regtest.sh}
regtest-lnbits-restart "$@"
''
))
];
# 4) tmpfiles to ensure user dirs exist (only the leaf state dir;
# everything else is created by dev-env-bootstrap on demand).
systemd.tmpfiles.rules = lib.optional (config.dev-env.user or null != null) (
let
user = config.dev-env.user;
in
"d /home/${user}/.local/state/dev-env 0755 ${user} users -"
);
}
# 5) regtest implies docker. Set the standard option directly rather
# than toggling omni.features.containers, so this module is
# importable without omni. On omni the developer preset still
# enables features.containers (→ the fuller docker block in
# core.nix), and this mkDefault yields to it.
(mkIf cfg.regtest.enable {
virtualisation.docker.enable = mkDefault true;
})
# 6) Shared git pre-commit via core.hooksPath, applied per-user via
# home-manager so the user's git config picks it up.
(mkIf (cfg.gitHooks.enable && (config.dev-env.user or null) != null) {
home-manager.users.${config.dev-env.user} =
{ ... }:
{
home.file.".local/share/dev-env/git-hooks/pre-commit" = {
source = ./scripts/git-hooks/pre-commit;
executable = true;
};
programs.git.settings.core.hooksPath = "/home/${config.dev-env.user}/.local/share/dev-env/git-hooks";
};
})
# 7) Optional legacy compat — also write the old .devenv.conf so any
# loose bash scripts still reading it keep working during migration.
(mkIf (cfg.legacyConfigFile != null && (config.dev-env.user or null) != null) {
home-manager.users.${config.dev-env.user} =
{ ... }:
{
home.file.${
# home.file is keyed relative to $HOME, so strip the prefix
lib.removePrefix "/home/${config.dev-env.user}/" cfg.legacyConfigFile
} =
{
text = ''
# Legacy compat shim — sourced by old .devenv.d/*.sh scripts.
# Canonical config is /etc/dev-env/config.sh.
source /etc/dev-env/config.sh
'';
};
};
})
]);
}

View file

@ -0,0 +1,17 @@
# dev-env module entry point.
#
# This module declaratively manages a multi-project development environment
# (bare repos, worktrees, navigation helpers, tmux sessions, regtest docker
# env, upstream-PR workflow). It sits on top of omni and consumes the
# unified deploy flake as a source of truth for deployed refs.
#
# The module only configures tools; it does not materialize repos on disk.
# That is the job of the user-invoked `dev-env-bootstrap` script installed
# by config.nix.
{
imports = [
./options.nix
./lib.nix
./config.nix
];
}

241
modules/dev-env/lib.nix Normal file
View file

@ -0,0 +1,241 @@
{ config, lib, ... }:
let
inherit (lib) mkOption types;
cfg = config.dev-env;
# Parse a forgejo URL into { namespace, repo, ref }.
#
# Accepts:
# git+ssh://forgejo@host/org/repo.git?ref=branch
# git+ssh://forgejo@host/org/repo?ref=branch
# git+https://host/org/repo.git?ref=branch
# git+https://host/org/repo?ref=branch
#
# Returns null if the URL does not match a forgejo host.
#
# Implementation note: Nix's `builtins.match` uses POSIX ERE, which
# does NOT support non-greedy quantifiers (`+?`/`*?`). A naive regex
# like `([^?]+?)(\.git)?` lets the `+?` act greedy, producing a repo
# name of `webapp.git`. Instead we match everything before `?ref=`
# or end-of-string as the raw repo name, then strip `.git` after.
parseForgejoUrl =
url:
let
host = cfg.forgejo.host;
# One regex covering both schemes. Groups:
# 1: namespace
# 2: repo (possibly with .git suffix)
# 3: optional "?ref=..." (kept for completeness)
# 4: optional ref value
sshMatch = builtins.match "git\\+ssh://[^@]+@${host}/([^/]+)/([^?]+)(\\?ref=(.+))?" url;
httpsMatch = builtins.match "git\\+https://${host}/([^/]+)/([^?]+)(\\?ref=(.+))?" url;
m =
if sshMatch != null then
sshMatch
else if httpsMatch != null then
httpsMatch
else
null;
in
if m == null then
null
else
{
namespace = builtins.elemAt m 0;
repo = lib.removeSuffix ".git" (builtins.elemAt m 1);
ref = builtins.elemAt m 3;
};
# mkProject: shorthand constructor for `dev-env.projects.<name>`.
#
# Fills in conventional defaults so users write less boilerplate.
# The returned attrset matches the `projectType` submodule schema.
mkProject =
{
name ? null,
category ? null,
upstream ? null,
forgejoRepo ? null, # defaults to "${cfg.forgejo.org}/${name}"
originUrl ? null, # override origin URL (e.g. GitHub-only repos)
githubFork ? null, # defaults from github.forkUser
worktrees ? { },
isClone ? false,
deployFlakeInput ? null,
worktreeRoot ? null, # defaults to name
}:
{
forgejoRepo =
if forgejoRepo != null then
forgejoRepo
else if originUrl != null then
null
else if name != null then
"${cfg.forgejo.org}/${name}"
else
throw "dev-env.lib.mkProject: set forgejoRepo, originUrl, or name";
inherit
originUrl
upstream
category
isClone
deployFlakeInput
;
githubFork =
if githubFork != null then
githubFork
else if upstream != null && cfg.github.forkUser != null && name != null then
"git@github.com:${cfg.github.forkUser}/${name}.git"
else
null;
worktreeRoot =
if worktreeRoot != null then
worktreeRoot
else if name != null then
name
else
"";
inherit worktrees;
};
# deriveFromFlakeInput: parse one flake input url into a project entry
# (or null if the URL doesn't belong to our forgejo).
#
# Input: { name = "webapp-host5"; url = "git+ssh://forgejo@git.atitlan.io/aiolabs/webapp.git?ref=host5"; }
# Output: { namespace = "aiolabs"; repo = "webapp"; ref = "host5"; }
deriveFromFlakeInput =
{ inputName, url }:
let
parsed = parseForgejoUrl url;
in
if parsed == null then
null
else
{
inputName = inputName;
repo = parsed.repo;
namespace = parsed.namespace;
ref = parsed.ref;
};
# Collapse a list of `{ inputName, namespace, repo, ref }` into
# `{ <repo> = { forgejoRepo, worktrees = { <ref> = { branch = ref; }; ... }; }; }`.
#
# Multiple inputs can share the same repo with different refs; this is
# the common case for webapp + webapp-dev — both become worktrees of
# `webapp` (main and dev respectively).
groupDerivedProjects =
parsedList:
lib.foldl' (
acc: entry:
if entry == null || entry.ref == null then
acc
else
let
key = entry.repo;
existing =
acc.${key} or {
forgejoRepo = "${entry.namespace}/${entry.repo}";
originUrl = null;
worktreeRoot = entry.repo;
worktrees = { };
upstream = null;
githubFork = null;
category = null;
isClone = false;
deployFlakeInput = null;
};
newWorktree = {
branch = entry.ref;
path = null;
remote = "origin";
};
in
acc
// {
${key} = existing // {
worktrees = existing.worktrees // {
${entry.ref} = newWorktree;
};
# Record the LAST input name that contributed — good enough
# for --override-input wiring.
deployFlakeInput = entry.inputName;
};
}
) { } parsedList;
# Resolve worktree filesystem paths. Projects with category live under
# ${root}/${category}/${worktreeRoot}/<worktree>; otherwise
# ${root}/${worktreeRoot}/<worktree>.
worktreePath =
projectName: project: worktreeName:
let
root = cfg.root;
sub =
if project.category != null then
"${project.category}/${project.worktreeRoot}"
else
project.worktreeRoot;
leaf =
let
wt = project.worktrees.${worktreeName} or null;
in
if wt != null && wt.path != null then wt.path else worktreeName;
in
"${root}/${sub}/${leaf}";
# Bare repo path (always ${root}/repos/<basename>.git).
# Falls back to projectName when forgejoRepo is null (GitHub-only projects).
bareRepoPath =
projectName: project:
let
basename =
if project.forgejoRepo != null then
lib.last (lib.splitString "/" project.forgejoRepo)
else
projectName;
in
"${cfg.root}/repos/${basename}.git";
# Construct the remotes for a project. `origin` is `originUrl` if set,
# otherwise derived from the forgejo host + forgejoRepo. Projects with
# neither end up with no origin (filtered out below).
projectRemotes =
project:
lib.filterAttrs (_: v: v != null) {
origin =
if project.originUrl or null != null then
project.originUrl
else if project.forgejoRepo != null then
"${cfg.forgejo.sshUser}@${cfg.forgejo.host}:${project.forgejoRepo}.git"
else
null;
upstream = project.upstream;
github-fork = project.githubFork;
};
helpers = {
inherit
mkProject
deriveFromFlakeInput
parseForgejoUrl
groupDerivedProjects
worktreePath
bareRepoPath
projectRemotes
;
};
in
{
options.dev-env.lib = mkOption {
type = types.attrs;
internal = true;
description = "dev-env module helper functions (internal).";
};
config = {
dev-env.lib = helpers;
};
}

369
modules/dev-env/options.nix Normal file
View file

@ -0,0 +1,369 @@
{ config, lib, ... }:
let
inherit (lib) mkEnableOption mkOption types;
# One entry in dev-env.projects.
#
# A project is a git repo with one or more worktrees (or a single clone
# if `isClone = true`). Worktrees are derived from a bare repo at
# `${dev-env.root}/repos/${name}.git` by default; that path is managed
# by the bootstrap script.
projectType = types.submodule (
{ name, ... }:
{
options = {
forgejoRepo = mkOption {
type = types.nullOr types.str;
default = null;
description = ''
<org>/<repo> on the configured forgejo host. Used to construct
the `origin` remote. May be null for projects whose origin
lives outside forgejo in that case set `originUrl`.
'';
example = "aiolabs/lnbits";
};
originUrl = mkOption {
type = types.nullOr types.str;
default = null;
description = ''
Override the origin remote URL. By default, origin is derived
from the forgejo host + forgejoRepo. Set this to clone from
an arbitrary URL (e.g. a GitHub repo that has no forgejo mirror).
'';
example = "https://github.com/nostr-protocol/nips";
};
upstream = mkOption {
type = types.nullOr types.str;
default = null;
description = ''
HTTPS URL of the upstream OSS repo. Set to null for projects
with no upstream (divergent branches, original work).
When set, the `upstream` remote is added to the bare repo and
the `prb`/`prc`/rebase helpers know how to sync from it.
'';
example = "https://github.com/lnbits/lnbits";
};
githubFork = mkOption {
type = types.nullOr types.str;
default = null;
description = ''
Full SSH URL of the personal github fork used for upstream PRs
(the `github-fork` remote). If null, derived from the global
`dev-env.github.forkUser` + the basename of `forgejoRepo`.
'';
example = "git@github.com:your-github-username/lnbits.git";
};
category = mkOption {
type = types.nullOr types.str;
default = null;
description = ''
Sub-directory under `dev-env.root` that groups related
projects. e.g. "bitspire" groups the bitSpire frontend +
atm-tui. null puts the project at the top level.
'';
example = "bitspire";
};
worktreeRoot = mkOption {
type = types.str;
default = if name == null then "" else name;
defaultText = "project name";
description = ''
Directory name (relative to `category` or root) under which
worktrees live. Defaults to the project name.
'';
};
worktrees = mkOption {
type = types.attrsOf (
types.submodule {
options = {
branch = mkOption {
type = types.str;
description = "Branch to check out in this worktree.";
};
path = mkOption {
type = types.nullOr types.str;
default = null;
description = ''
Override the worktree directory name. Defaults to the
attribute key.
'';
};
remote = mkOption {
type = types.str;
default = "origin";
description = ''
Which remote the branch tracks. Usually origin (forgejo)
but could be "upstream" for read-only tracking worktrees.
'';
};
};
}
);
default = { };
description = ''
Worktrees to materialize for this project, keyed by worktree
name. Each worktree becomes a directory at
`''${dev-env.root}/''${category}/''${worktreeRoot}/<key>` (or under
root if category is null).
'';
example = lib.literalExpression ''
{
dev = { branch = "dev"; };
main = { branch = "main"; };
}
'';
};
isClone = mkEnableOption ''
Treat this project as a regular clone rather than a bare-repo
worktree set. Used for projects that won't have multiple
simultaneous branches checked out (e.g. atm-tui)
'';
deployFlakeInput = mkOption {
type = types.nullOr types.str;
default = null;
description = ''
Name of the flake input in the deploy flake that this project
corresponds to. Used by `dev-deploy --local` to know which
--override-input to pass.
'';
example = "lnbits";
};
};
}
);
# One entry in dev-env.tmux.sessions.
tmuxSessionType = types.submodule {
options = {
cwd = mkOption {
type = types.nullOr types.str;
default = null;
description = ''
Session-default cwd, relative to `dev-env.root`. Windows can
override with their own cwd.
'';
};
windows = mkOption {
type = types.listOf (
types.submodule {
options = {
name = mkOption { type = types.str; };
cwd = mkOption {
type = types.nullOr types.str;
default = null;
description = "Window cwd, relative to dev-env.root.";
};
cmd = mkOption {
type = types.nullOr types.str;
default = null;
description = "Command to run in the window on creation.";
};
};
}
);
default = [ ];
};
};
};
in
{
options.dev-env = {
enable = mkEnableOption "dev-env multi-project development environment";
user = mkOption {
type = types.str;
default = "user";
description = ''
Owner of the dev tree. Drives `root`'s default and the per-user
git-hooks / state-dir paths. Set this to your login user when
importing dev-env standalone; on omni the host wires it to
`settings.user`. Kept self-contained (a plain default, no
`config.omni.*` read) so this pack is importable without omni.
'';
};
root = mkOption {
type = types.str;
default = "/home/${config.dev-env.user}/dev";
defaultText = "/home/\${config.dev-env.user}/dev";
description = ''
Root directory for the dev environment. Bare repos live under
`''${root}/repos/`, worktrees under `''${root}/<category>/<project>/`.
'';
};
forgejo = {
host = mkOption {
type = types.str;
default = "git.atitlan.io";
description = "Forgejo server hostname.";
};
sshUser = mkOption {
type = types.str;
default = "forgejo";
description = ''
SSH user for git+ssh access to forgejo. Some installations use
"git", others use "forgejo". Defaults to "forgejo" because the
reference deployment uses that.
'';
};
org = mkOption {
type = types.str;
description = ''
Default forgejo org/user under which your forks live. Used as
the namespace when a project's `forgejoRepo` is a bare name.
'';
example = "aiolabs";
};
};
github = {
forkUser = mkOption {
type = types.nullOr types.str;
default = null;
description = ''
Personal github username used for upstream PR forks (the
`github-fork` remote). When set, projects that declare an
upstream automatically get a github-fork remote derived from
`git@github.com:<forkUser>/<basename>.git`.
'';
example = "your-github-username";
};
};
projects = mkOption {
type = types.attrsOf projectType;
default = { };
description = ''
Hand-authored project list. This is merged with projects derived
from the deploy flake (when `deploy.deriveProjectsFromInputs` is
true). Hand entries win on key collision.
'';
};
deploy = {
flakeInput = mkOption {
type = types.nullOr types.str;
default = null;
description = ''
Name of the omni flake input that points at the unified
deploy flake. When set, the bootstrap script clones it to
`''${root}/deploy/<flakeInput>` as a working copy, and
`dev-deploy` uses it as the --flake source by default.
'';
example = "deploy-flake";
};
targets = mkOption {
type = types.attrsOf types.str;
default = { };
description = ''
Map from hostname to SSH target used by `dev-deploy`. Mirrors
the TARGETS associative array in optimize-deploys/unified/
deploy.sh.
'';
example = lib.literalExpression ''
{
host1 = "root@host1.example";
host5 = "root@host5.example";
host7 = "root@host7.example";
}
'';
};
deriveProjectsFromInputs = mkOption {
type = types.bool;
default = true;
description = ''
When true and `flakeInput` is set, derive a default project
entry for every flake input of the deploy flake whose URL is
under the forgejo host. Hand-authored `projects` win on key
collision.
'';
};
};
regtest = {
enable = mkEnableOption ''
Unified Bitcoin/Lightning regtest docker environment. Enables
virtualisation.docker (via mkDefault)
'';
repoUrl = mkOption {
type = types.str;
default = "forgejo@git.atitlan.io:aiolabs/lnbits-regtest.git";
description = ''
Git URL of the regtest docker-compose repo. Cloned to
`''${root}/local/docker/regtest` by the bootstrap script.
'';
};
};
tmux = {
enable = mkEnableOption "declarative tmux session launcher (dev-tm)";
sessions = mkOption {
type = types.attrsOf tmuxSessionType;
default = { };
description = ''
Named tmux session layouts. The `dev-tm <name>` script reads
these from /etc/dev-env/tmux-sessions.json at runtime and
recreates the session.
'';
};
};
gitHooks = {
enable =
mkEnableOption ''
Shared pre-commit hook via core.hooksPath. Installs a single
hook that scans for secrets (prv keys, hardcoded passwords,
unencrypted sops files) in all repos under dev-env.root
''
// {
default = true;
};
path = mkOption {
type = types.str;
default = "${config.dev-env.root or "/tmp"}/.git-hooks";
defaultText = ''"''${root}/.git-hooks"'';
description = "Directory used for git core.hooksPath.";
};
};
writeDirenvHints = mkOption {
type = types.bool;
default = true;
description = ''
When true, the bootstrap script writes a default `.envrc`
containing `use flake` into each worktree that has a flake.nix
but no existing .envrc. Never clobbers existing files.
'';
};
legacyConfigFile = mkOption {
type = types.nullOr types.str;
default = "${config.dev-env.root or "/tmp"}/.devenv.conf";
defaultText = ''"''${root}/.devenv.conf"'';
description = ''
Path at which to also write a legacy `.devenv.conf` for any
loose bash scripts still reading it. The canonical config is at
/etc/dev-env/config.sh; this is a transitional aid. Set to null
to disable once migration is complete.
'';
};
};
}

View file

@ -0,0 +1,564 @@
# aiolabs preset — opinionated project list for the aiolabs ecosystem
#
# Reproduces the current ~/dev tree declaratively. Importing this file
# is opt-in; the dev-env module itself ships no projects by default.
#
# Includes the projects pinned by optimize-deploys/unified/flake.nix
# (which dev-env.deploy.deriveProjectsFromInputs would derive
# automatically when wired up) plus the dev-only worktrees that don't
# have a deploy artifact.
#
# Tmux sessions mirror the layouts in ~/dev/.devenv.d/20-tmux-sessions.sh
# but expressed as a Nix attrset rendered to JSON for dev-tm.
{ config, lib, ... }:
let
mk = config.dev-env.lib.mkProject;
in
{
# Gated: applies only when the aiolabs pack is enabled. Imported (inert)
# via modules/packs/aiolabs.nix. Enabling aiolabs implies lnbits.
config = lib.mkIf config.omni.packs.aiolabs.enable {
omni.packs.lnbits.enable = true;
dev-env = {
forgejo.org = lib.mkDefault "aiolabs";
github.forkUser = lib.mkDefault "your-github-username";
deploy.targets = lib.mkDefault {
host1 = "root@host1.example";
host2 = "root@host2.example";
host3 = "root@host3.example";
host4 = "root@host4.example";
host5 = "root@host5.example";
host6 = "root@host6.example";
host7 = "root@host7.example";
host8 = "root@host8.example";
};
projects = {
# ─── lnbits: shared codebase, dev/main branches ────────────────
lnbits = mk {
name = "lnbits";
category = null;
upstream = "https://github.com/lnbits/lnbits";
deployFlakeInput = "lnbits";
worktrees = {
dev = {
branch = "dev";
};
main = {
branch = "main";
};
};
};
# ─── nsecbunkerd: nostr remote signer daemon (NIP-46) ──────────
nsecbunkerd = mk {
name = "nsecbunkerd";
category = null;
upstream = "https://github.com/kind-0/nsecbunkerd";
worktrees = {
master = {
branch = "master";
};
};
};
# ─── webapp: divergent per-target branches ─────────────────────
webapp = mk {
name = "webapp";
category = null;
upstream = null; # no upstream — original work
deployFlakeInput = "webapp";
isClone = true; # single branch — per-target config lives
# in the deploy flake's services.webapp.*
# options, not in separate branches
};
# ─── boilerplate-website: vue+vite starter, spawn worktrees per site ─
boilerplate-website = mk {
name = "boilerplate-website";
category = null;
upstream = null; # original work
worktrees = {
main = {
branch = "main";
};
};
};
# ─── ATM stack ──────────────────────────────────────────────────
#
# bitspire (active) groups the bitSpire ATM frontend + atm-tui.
# lamassu-next (legacy, retained until the last machines migrate)
# groups the lamassu-next frontend + the lightning-pub fork it
# used. nostr-lightning-wallet is a single clone at top level.
bitspire = mk {
name = "bitspire";
category = "bitspire";
forgejoRepo = "aiolabs/bitspire";
upstream = null; # original
worktrees = {
main = {
branch = "main";
};
dev = {
branch = "dev";
};
};
};
lamassu-next = mk {
name = "lamassu-next";
category = "lamassu-next";
forgejoRepo = "aiolabs/lamassu-next";
upstream = null;
deployFlakeInput = "lamassu-next";
worktrees = {
main = {
branch = "main";
};
dev = {
branch = "dev";
};
};
};
lightning-pub = mk {
name = "lightning-pub";
category = "lamassu-next";
forgejoRepo = "aiolabs/lightning-pub";
upstream = "https://github.com/shocknet/Lightning.Pub";
worktrees = {
master = {
branch = "master";
};
dev = {
branch = "dev";
};
nip05 = {
branch = "feature/nip05";
};
marketplace = {
branch = "feature/marketplace";
};
withdraw = {
branch = "feature/withdraw";
};
extension-loader = {
branch = "feature/extension-loader";
};
};
};
nostr-lightning-wallet = mk {
name = "nostr-lightning-wallet";
category = null;
forgejoRepo = "aiolabs/shockwallet-vue";
upstream = null;
isClone = true; # single clone, has its own flake
deployFlakeInput = "shockwallet-vue";
};
atm-tui = mk {
name = "atm-tui";
category = "bitspire";
forgejoRepo = "aiolabs/atm-tui";
upstream = null; # original
isClone = true;
};
# ─── shared OSS forks ────────────────────────────────────────────
lamassu-server = mk {
name = "lamassu-server";
category = "shared";
forgejoRepo = "aiolabs/lamassu-server";
upstream = "https://github.com/lamassu/lamassu-server";
isClone = true;
};
lamassu-machine = mk {
name = "lamassu-machine";
category = "shared";
forgejoRepo = "aiolabs/lamassu-machine";
upstream = "https://github.com/lamassu/lamassu-machine";
isClone = true;
};
nix-bitcoin = mk {
name = "nix-bitcoin";
category = "shared";
forgejoRepo = "aiolabs/nix-bitcoin";
upstream = "https://github.com/fort-nix/nix-bitcoin";
isClone = true;
};
# ─── lnbits extensions (forked + original) ───────────────────────
ext-nostrrelay = mk {
name = "nostrrelay";
category = "shared/extensions";
forgejoRepo = "aiolabs/nostrrelay";
upstream = "https://github.com/lnbits/nostrrelay";
isClone = true;
};
ext-nostrclient = mk {
name = "nostrclient";
category = "shared/extensions";
forgejoRepo = "aiolabs/nostrclient";
upstream = "https://github.com/lnbits/nostrclient";
isClone = true;
};
ext-nostrmarket = mk {
name = "nostrmarket";
category = "shared/extensions";
forgejoRepo = "aiolabs/nostrmarket";
upstream = "https://github.com/lnbits/nostrmarket";
isClone = true;
};
ext-events = mk {
name = "events";
category = "shared/extensions";
forgejoRepo = "aiolabs/events";
upstream = "https://github.com/lnbits/events";
isClone = true;
};
ext-spirekeeper = mk {
name = "spirekeeper";
category = "shared/extensions";
forgejoRepo = "aiolabs/spirekeeper";
upstream = null; # original — bitSpire operator dashboard
isClone = true;
};
ext-libra = mk {
name = "libra";
category = "shared/extensions";
forgejoRepo = "aiolabs/libra";
upstream = null;
isClone = true;
};
ext-restaurant = mk {
name = "restaurant";
category = "shared/extensions";
forgejoRepo = "aiolabs/restaurant";
upstream = null; # original
isClone = true;
};
ext-withdraw = mk {
name = "withdraw";
category = "shared/extensions";
forgejoRepo = "aiolabs/withdraw";
upstream = "https://github.com/lnbits/withdraw";
isClone = true;
};
ext-lnurlp = mk {
name = "lnurlp";
category = "shared/extensions";
forgejoRepo = "aiolabs/lnurlp";
upstream = "https://github.com/lnbits/lnurlp";
isClone = true;
};
ext-splitpayments = mk {
name = "splitpayments";
category = "shared/extensions";
forgejoRepo = "aiolabs/splitpayments";
upstream = "https://github.com/lnbits/splitpayments";
isClone = true;
};
ext-tasks = mk {
name = "tasks";
category = "shared/extensions";
forgejoRepo = "aiolabs/tasks";
upstream = null; # original
isClone = true;
};
# ─── community-organizer surfaces ───────────────────────────────
#
# Matrix-side bot plugins + the foyer e-ink renderer. Both consume
# the NIP-52/NIP-72 community-organizer protocol defined in
# ~/dev/maubot-plugins/docs/community-organizer-spec.md and feed
# into the wider aiolabs Nostr-native stack (see /etc/nixos/docs/
# system-map.md for the end-to-end picture).
maubot-plugins = mk {
name = "maubot-plugins";
category = null;
forgejoRepo = "aiolabs/maubot-plugins";
upstream = null; # original
isClone = true;
};
inky-impression = mk {
name = "inky-impression";
category = null;
forgejoRepo = "aiolabs/inky-impression";
upstream = null; # original
isClone = true;
};
# ─── auxiliary ──────────────────────────────────────────────────
#
# Note: host5-home and atitlanio-pitch are both auto-derived from
# the server-deploy flake's inputs, so they don't need preset
# entries. See modules/dev-env/config.nix `derivedProjects`.
# The deploy flake itself (also a project so dev-env-bootstrap
# clones it for in-place editing alongside everything else).
# Not derived from the flake because a flake can't list itself
# as an input — this must be hand-authored.
server-deploy = mk {
name = "server-deploy";
category = "deploy";
forgejoRepo = "aiolabs/server-deploy";
upstream = null;
isClone = true;
};
# Regtest environment (Bitcoin/Lightning local dev)
regtest = mk {
name = "regtest";
category = "local/docker";
forgejoRepo = "aiolabs/regtest";
upstream = "https://github.com/lnbits/legend-regtest-enviroment";
isClone = true;
};
# Curated upstream-reference catalogue + weekly digests.
# Bootstrap clones the repo to ~/dev/refs/; bin/refresh
# then materialises repos/<group>/<owner>/<name> from refs.toml.
# The home-manager refs-refresh systemd timer runs that weekly;
# for first-bootstrap immediacy, run `~/dev/refs/bin/refresh`.
refs = mk {
name = "refs";
category = null;
forgejoRepo = "aiolabs/refs";
upstream = null;
isClone = true;
};
# Documentation (Quartz static site)
castle-docs = mk {
name = "castle-docs";
category = "docs";
forgejoRepo = "padreug/castle-docs";
upstream = null;
isClone = true;
};
# ─── reference material ─────────────────────────────────────────
nips = mk {
name = "nips";
category = "nostr-protocol";
originUrl = "https://github.com/nostr-protocol/nips";
upstream = null;
isClone = true;
};
quasar = mk {
name = "quasar";
category = "vue-framework";
originUrl = "https://github.com/quasarframework/quasar";
upstream = null;
isClone = true;
};
# Lightning.Pub + wallet2 upstreams now live as read-only mirrors
# under ~/dev/refs/ (see refs.toml), not as project worktrees.
extension-builder-stub = mk {
name = "extension_builder_stub";
category = "lnbits-reference";
originUrl = "https://github.com/lnbits/extension_builder_stub";
upstream = null;
isClone = true;
};
regtest-upstream = mk {
name = "regtest-upstream";
category = "lnbits-upstream";
originUrl = "https://github.com/lnbits/legend-regtest-enviroment";
upstream = null;
isClone = true;
};
};
# ─── tmux sessions (mirror the existing layouts) ──────────────────
tmux.enable = lib.mkDefault true;
tmux.sessions = {
lnbits = {
cwd = "lnbits";
windows = [
{
name = "dev";
cwd = "lnbits/dev";
cmd = "nvim .";
}
{
name = "main";
cwd = "lnbits/main";
cmd = null;
}
{
name = "extensions";
cwd = "shared/extensions";
cmd = null;
}
{
name = "term";
cwd = "lnbits/dev";
cmd = null;
}
{
name = "git";
cwd = "lnbits/dev";
cmd = "lazygit";
}
];
};
webapp = {
cwd = "webapp";
windows = [
{
name = "edit";
cwd = "webapp";
cmd = "nvim .";
}
{
name = "term";
cwd = "webapp";
cmd = null;
}
{
name = "git";
cwd = "webapp";
cmd = "lazygit";
}
];
};
shared = {
cwd = "shared";
windows = [
{
name = "extensions";
cwd = "shared/extensions";
cmd = null;
}
{
name = "lamassu";
cwd = "shared/lamassu-server";
cmd = null;
}
{
name = "nix-bitcoin";
cwd = "shared/nix-bitcoin";
cmd = null;
}
{
name = "git";
cwd = "shared";
cmd = "wts";
}
];
};
local = {
cwd = "local";
windows = [
{
name = "docker";
cwd = "local/docker";
cmd = null;
}
{
name = "lazydocker";
cwd = "local/docker";
cmd = "lazydocker";
}
{
name = "logs";
cwd = "local";
cmd = null;
}
];
};
bitspire = {
cwd = "bitspire/bitspire";
windows = [
{
name = "dev";
cwd = "bitspire/bitspire/dev";
cmd = "nvim .";
}
{
name = "main";
cwd = "bitspire/bitspire/main";
cmd = null;
}
{
name = "atm-tui";
cwd = "bitspire/atm-tui";
cmd = null;
}
{
name = "git";
cwd = "bitspire/bitspire/dev";
cmd = "lazygit";
}
];
};
lightning-pub = {
cwd = "lamassu-next/lightning-pub";
windows = [
{
name = "dev";
cwd = "lamassu-next/lightning-pub/dev";
cmd = "nvim .";
}
{
name = "master";
cwd = "lamassu-next/lightning-pub/master";
cmd = null;
}
{
name = "extensions";
cwd = "lamassu-next/lightning-pub/extension-loader";
cmd = null;
}
{
name = "git";
cwd = "lamassu-next/lightning-pub/dev";
cmd = "lazygit";
}
];
};
};
};
};
}