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:
parent
02504f9fd3
commit
7960f82494
6 changed files with 1689 additions and 0 deletions
355
modules/dev-env/config.nix
Normal file
355
modules/dev-env/config.nix
Normal 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
|
||||
'';
|
||||
};
|
||||
};
|
||||
})
|
||||
]);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue