355 lines
13 KiB
Nix
355 lines
13 KiB
Nix
{
|
|
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
|
|
'';
|
|
};
|
|
};
|
|
})
|
|
]);
|
|
}
|