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

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
'';
};
};
})
]);
}