diff --git a/modules/dev-env/README.md b/modules/dev-env/README.md new file mode 100644 index 0000000..353c8d1 --- /dev/null +++ b/modules/dev-env/README.md @@ -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). diff --git a/modules/dev-env/config.nix b/modules/dev-env/config.nix new file mode 100644 index 0000000..c61fe78 --- /dev/null +++ b/modules/dev-env/config.nix @@ -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..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+://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_ + # 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 + ''; + }; + }; + }) + ]); +} diff --git a/modules/dev-env/default.nix b/modules/dev-env/default.nix new file mode 100644 index 0000000..ee8a57d --- /dev/null +++ b/modules/dev-env/default.nix @@ -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 + ]; +} diff --git a/modules/dev-env/lib.nix b/modules/dev-env/lib.nix new file mode 100644 index 0000000..8246273 --- /dev/null +++ b/modules/dev-env/lib.nix @@ -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.`. + # + # 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 + # `{ = { forgejoRepo, worktrees = { = { 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}/; otherwise + # ${root}/${worktreeRoot}/. + 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/.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; + }; +} diff --git a/modules/dev-env/options.nix b/modules/dev-env/options.nix new file mode 100644 index 0000000..efc8ace --- /dev/null +++ b/modules/dev-env/options.nix @@ -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 = '' + / 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}/` (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}///`. + ''; + }; + + 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:/.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/` 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 ` 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. + ''; + }; + }; +} diff --git a/modules/dev-env/presets/aiolabs.nix b/modules/dev-env/presets/aiolabs.nix new file mode 100644 index 0000000..1d47146 --- /dev/null +++ b/modules/dev-env/presets/aiolabs.nix @@ -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/// 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"; + } + ]; + }; + }; + }; + }; +}