diff --git a/flake.nix b/flake.nix index 5d3144f..e08296e 100644 --- a/flake.nix +++ b/flake.nix @@ -49,8 +49,23 @@ # `settings` (single source of truth — user, host, identity, # remote topology) is threaded into every NixOS module and into # the home-manager module set, so neither has to re-import it. + + # dev-env schema smoke test — a minimal nixosSystem that imports + # only core.nix + the dev-env module and exercises the option + # surface. `nix flake check` builds the rendered config artifacts, + # which forces full module evaluation (catching schema breakage) + # without building a whole system. + smoke = import ./modules/dev-env/tests/smoke.nix { + inherit nixpkgs home-manager; + }; in { + checks.${system} = { + dev-env-projects-json = smoke.config.environment.etc."dev-env/projects.json".source; + dev-env-config-sh = smoke.config.environment.etc."dev-env/config.sh".source; + dev-env-tmux-sessions = smoke.config.environment.etc."dev-env/tmux-sessions.json".source; + }; + nixosConfigurations.${settings.hostName} = nixpkgs.lib.nixosSystem { inherit system; diff --git a/modules/dev-env/README.md b/modules/dev-env/README.md new file mode 100644 index 0000000..8ac0d09 --- /dev/null +++ b/modules/dev-env/README.md @@ -0,0 +1,112 @@ +# modules/dev-env + +Declarative NixOS module for managing a multi-project dev environment +around an LNbits stack — bare repos, worktrees, navigation helpers, tmux +sessions, the regtest docker env, and the upstream-PR workflow. + +## Design principles + +1. **Nix owns the configuration, bash owns the runtime.** Nix renders + `/etc/dev-env/config.sh` + `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. **Projects are explicit, git-host-agnostic.** Each project declares + its `origin` `url` directly (any forgejo / gitea / codeberg / github + URL). The only github-specific knob is `github.forkUser`, used to + derive a personal `fork` remote for upstream PRs. + +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 + path/remote helpers. | +| `config.nix` | Renders config files, installs scripts, wires git hooks. | +| `presets/example.nix` | A worked, generic project list — copy and edit. | +| `scripts/*.sh` | Bash helpers loaded via `builtins.readFile`. | +| `scripts/git-hooks/pre-commit` | Shared secret-scanner hook (via `core.hooksPath`). | +| `tests/smoke.nix` | `nix flake check` evaluation test for the schema. | + +## Using it + +```nix +# configuration.nix +imports = [ + ./modules/dev-env + ./modules/dev-env/presets/example.nix # opt-in; copy and edit +]; + +lnbits-sensei.devEnv = { + enable = true; + scaffoldPath = "/home/you/dev/lnbits-sensei"; + github.forkUser = "octocat"; + deploy.targets = { + prod = "root@prod-host"; + staging = "root@staging-host"; + }; +}; +``` + +## Bootstrap workflow + +```bash +# 1. Rebuild with dev-env enabled +sudo nixos-rebuild switch --flake .# + +# 2. Dry-run to see what will be created +dev-env-bootstrap --dry-run + +# 3. Materialize bare repos + worktrees +dev-env-bootstrap + +# 4. Navigate (shell functions, sourced into interactive shells) +lb dev # → ~/dev/lnbits/dev +g extensions myext # → ~/dev/extensions/myext +ext # → ~/dev/shared/extensions/ +prb lnbits fix-x # → ~/dev/upstream-prs/lnbits-fix-x on upstream/main + +# 5. Inspect / sync +dev-status # dirty + ahead/behind for every worktree +wts # fetch all bare repos, summarize worktree status +rebase status # which forks need rebasing onto upstream +lnbits-status # lnbits dev/main divergence vs upstream + +# 6. Regtest (when devEnv.regtest.enable = true; needs docker) +regtest-start dev # build lnbits from ~/dev/lnbits/dev, bring stack up +regtest-stop + +# 7. Deploy +dev-deploy prod # uses the locked deploy-flake input +dev-deploy --local staging # overrides inputs with local worktrees +``` + +## Command summary + +| Command | What | +|---|---| +| `dev-env-bootstrap` | Materialize bare repos + worktrees from `projects.json`. | +| `dev-status` | Dirty/ahead/behind report across all worktrees. | +| `dev-tm ` | Launch a declarative tmux session. | +| `dev-deploy ` | `nixos-rebuild` against your deploy flake. | +| `rebase [status\|all\|]` | Safe fork-onto-upstream rebase with backups. | +| `regtest-start` / `-stop` / `-status` | Bitcoin/Lightning regtest stack. | +| `lb` / `g` / `ext` / `dep` / `prs` / `shared` / `repos` | Navigation (shell functions). | +| `wt` / `wts` / `wtu` / `wtn` | Worktree list / sync / upstream-fetch / spawn. | +| `prb` / `prc` / `prl` | Upstream-PR worktree branch / cleanup / list. | +| `lnbits-status` / `lnbits-sync-dev` / `lnbits-sync-main` | lnbits fork workflow. | + +## What this module does NOT do + +- Run `git fetch`/`git pull` automatically — use `wts`/`wtu` or a manual + `git fetch --all`. +- Manage per-worktree `.envrc` beyond writing a default `use flake` hint + on bootstrap (never clobbers an existing file). +- Install docker for you — `devEnv.regtest.enable` installs the + `regtest-*` helpers, but you must provide the container engine. diff --git a/modules/dev-env/config.nix b/modules/dev-env/config.nix index 50b05b8..8a0e21c 100644 --- a/modules/dev-env/config.nix +++ b/modules/dev-env/config.nix @@ -1,14 +1,11 @@ # lnbits-sensei dev-env — wire-up. # -# Skeleton-only. The substantive pass will: -# - install the regtest.sh / fakewallet.sh wrappers on PATH -# - render a /etc/dev-env/config.sh consumed by the loose bash -# helpers (worktree nav, upstream-PR helper) -# - emit systemd.user units for any long-running pieces -# - hook into config.lnbits-sensei.git.remotes to drive the -# bootstrap script's remote reconciliation -# -# Empty body for now so the module composes cleanly. +# Nix owns the configuration; bash owns the runtime. This file renders +# the machine-readable config (/etc/dev-env/config.sh + projects.json + +# tmux-sessions.json), installs the helper scripts on PATH, loads the +# shell-function modules into interactive shells, and wires the shared +# git pre-commit hook. It never materializes repos on disk — that is the +# job of the user-invoked `dev-env-bootstrap` script. { config, lib, @@ -20,17 +17,186 @@ let inherit (lib) mkIf mkMerge; cfg = config.lnbits-sensei.devEnv; user = config.lnbits-sensei.user; + helpers = cfg.lib; + + # 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; + + 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) + upstream + fork + category + worktreeRoot + isClone + deployFlakeInput + ; + barePath = bare; + clonePath = clonePath; + remotes = remotes; + worktrees = resolvedWorktrees; + }; + + resolvedProjects = lib.mapAttrs resolveProject cfg.projects; + + 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 the lnbits-sensei 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 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 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 + )} + ''; + + # 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) loaded by + # /etc/profile.d/dev-env-functions.sh into every interactive shell. + shellFnSources = [ + ./scripts/nav.sh + ./scripts/worktree.sh + ./scripts/pr-helpers.sh + ] + ++ lib.optional cfg.regtest.enable ./scripts/regtest.sh; + + shellFnLoader = pkgs.writeText "dev-env-functions.sh" '' + # Auto-generated by the lnbits-sensei 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 [ - # TODO(skeleton): wire scripts, systemd units, and the - # /etc/dev-env/config.sh render here. See omnixy - # modules/dev-env/config.nix for the reference shape. + { + # 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 pre-commit hook via core.hooksPath. Installs the - # secret-scanner under ~/.local/share/lnbits-sensei/git-hooks/ - # and points the consumer's git config at that directory, so - # every repo on the machine picks it up automatically. + # 2) Loader so interactive shells (login OR non-login) get the + # functions. NixOS only sources /etc/profile.d/*.sh from + # /etc/profile (login shells); GUI-launched terminals are + # interactive non-login shells. `interactiveShellInit` is + # sourced by both /etc/bashrc and /etc/zshrc on every + # interactive shell, which is what we want. + 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 the rebase-log state dir exists. Everything + # else is created by dev-env-bootstrap on demand. + systemd.tmpfiles.rules = [ + "d /home/${user}/.local/state/dev-env 0755 ${user} ${user} -" + ]; + } + + # 5) 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 { home-manager.users.${user} = { ... }: diff --git a/modules/dev-env/lib.nix b/modules/dev-env/lib.nix index 565278d..1693dfc 100644 --- a/modules/dev-env/lib.nix +++ b/modules/dev-env/lib.nix @@ -1,22 +1,111 @@ # lnbits-sensei dev-env — helpers. # -# Skeleton-only. Place dev-env-internal helpers here (project path -# resolution, worktree-path expansion, remote-URL canonicalisation) -# rather than in the global `lnbits-sensei.lib` so they're scoped to -# the dev-env module and don't pollute the public helper namespace. +# dev-env-internal helpers scoped to this module (project path +# resolution, worktree-path expansion, remote construction) rather than +# the global `lnbits-sensei.lib` namespace. Exposed as +# `config.lnbits-sensei.devEnv.lib` so config.nix and the example preset +# can build project entries with less boilerplate. { config, lib, ... }: let inherit (lib) mkOption types; + cfg = config.lnbits-sensei.devEnv; + + # mkProject: shorthand constructor for `devEnv.projects.`. + # + # Fills in conventional defaults so consumers write less boilerplate. + # The returned attrset matches the `projectType` submodule schema. + # + # mkProject { + # name = "lnbits"; + # url = "git@git.example.com:you/lnbits.git"; + # upstream = "https://github.com/lnbits/lnbits"; + # worktrees = { main.branch = "main"; dev.branch = "dev"; }; + # } + mkProject = + { + name ? null, + category ? null, + url ? null, # origin remote URL + upstream ? null, + fork ? null, # defaults from github.forkUser when upstream is set + worktrees ? { }, + isClone ? false, + deployFlakeInput ? null, + worktreeRoot ? null, # defaults to name + }: + { + inherit + url + upstream + category + isClone + deployFlakeInput + ; + fork = + if fork != null then + fork + 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; + }; + + # Resolve worktree filesystem paths. Projects with a 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). The basename + # is derived from the project name; the bare object DB is shared by + # every worktree of the project. + bareRepoPath = projectName: _project: "${cfg.root}/repos/${projectName}.git"; + + # Construct the remotes for a project. `origin` is `url` if set, + # otherwise falls back to `upstream` (pure-upstream tracking repo). + # Projects with neither end up with no origin (filtered out). + projectRemotes = + project: + lib.filterAttrs (_: v: v != null) { + origin = + if project.url != null then + project.url + else + project.upstream; + upstream = project.upstream; + fork = project.fork; + }; helpers = { - # Resolve a project's on-disk root given a project name. - # projectRoot = name: "${config.lnbits-sensei.devEnv.root}/${name}"; - projectRoot = _name: throw "dev-env.lib.projectRoot: not yet implemented"; - - # Resolve a worktree path: //. - worktreePath = - _project: _worktree: throw "dev-env.lib.worktreePath: not yet implemented"; + inherit + mkProject + worktreePath + bareRepoPath + projectRemotes + ; }; in { diff --git a/modules/dev-env/options.nix b/modules/dev-env/options.nix index e8aa95b..7aecbf9 100644 --- a/modules/dev-env/options.nix +++ b/modules/dev-env/options.nix @@ -1,17 +1,26 @@ # lnbits-sensei dev-env — option schema. # -# Skeleton-only. Declares the option surface so consumers can wire -# values today and the substantive implementation can land later -# without churning the public API. +# Declares the full option surface for the dev environment: projects +# (bare repos + worktrees), the regtest stack, declarative tmux +# sessions, deploy targets, and the shared git hooks. Modules in this +# directory consume these via `config.lnbits-sensei.devEnv.*`. +# +# Identity model: this scaffold is git-host-agnostic. A project's +# `origin` is the explicit `url` you give it (any forgejo / gitea / +# codeberg / github URL) — there is no hardcoded host. The only +# github-specific knob is `github.forkUser`, used to derive a personal +# fork remote for the upstream-PR workflow. { 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/.git` - # by default. + # One entry in devEnv.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 `${devEnv.root}/repos/.git` by default; that path is + # materialized by the bootstrap script, never by `nixos-rebuild`. projectType = types.submodule ( { name, ... }: { @@ -20,14 +29,59 @@ let type = types.nullOr types.str; default = null; description = '' - Origin URL for this project. May be an upstream URL, a - personal fork, or a private mirror — the dev-env bootstrap - script reconciles the rest of the remote topology from - `lnbits-sensei.git.remotes`. + Origin remote URL (ssh or https). This is the repo the + bootstrap script clones/fetches as `origin`. May be null for + a pure-upstream tracking project — in that case `origin` + falls back to `upstream`. + ''; + example = "git@git.example.com:you/lnbits.git"; + }; + + 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`/`rebase` helpers know how to sync from it. ''; example = "https://github.com/lnbits/lnbits"; }; + fork = mkOption { + type = types.nullOr types.str; + default = null; + description = '' + Full SSH URL of the personal fork used for upstream PRs + (the `fork` remote). If null and the project has an + `upstream`, it is derived from `devEnv.github.forkUser` + + the project basename. + ''; + example = "git@github.com:you/lnbits.git"; + }; + + category = mkOption { + type = types.nullOr types.str; + default = null; + description = '' + Sub-directory under `devEnv.root` that groups related + projects. e.g. "extensions" could group several LNbits + extension repos. null puts the project at the top level. + ''; + example = "extensions"; + }; + + 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 { @@ -36,14 +90,31 @@ let 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, but + could be "upstream" for a read-only tracking worktree. + ''; + }; }; } ); default = { }; description = '' - Worktrees to materialize for this project, keyed by - worktree name. Each becomes a directory under the project - root. + Worktrees to materialize for this project, keyed by worktree + name. Each becomes a directory at + `''${devEnv.root}/''${category}/''${worktreeRoot}/` (or + under root if category is null). ''; example = lib.literalExpression '' { @@ -54,22 +125,36 @@ let }; isClone = mkEnableOption '' - Treat as a plain clone rather than a bare-repo + worktrees - set. Use for projects you won't have multiple simultaneous - branches checked out for + Treat this project as a regular clone rather than a bare-repo + worktree set. Use for projects you won't have multiple + simultaneous branches checked out for ''; + + deployFlakeInput = mkOption { + type = types.nullOr types.str; + default = null; + description = '' + Name of the flake input in your deploy flake that this + project corresponds to. Used by `dev-deploy --local` to know + which `--override-input` to pass so a deploy builds against + your in-progress worktree. + ''; + example = "lnbits"; + }; }; } ); - # One entry in dev-env.tmux.sessions. Mirrors the omnixy shape so - # the `dev-tm ` launcher can ship later with a familiar API. + # One entry in devEnv.tmux.sessions. tmuxSessionType = types.submodule { options = { cwd = mkOption { type = types.nullOr types.str; default = null; - description = "Session-default cwd, relative to dev-env.root."; + description = '' + Session-default cwd, relative to `devEnv.root`. Windows can + override with their own cwd. + ''; }; windows = mkOption { type = types.listOf ( @@ -79,6 +164,7 @@ let cwd = mkOption { type = types.nullOr types.str; default = null; + description = "Window cwd, relative to devEnv.root."; }; cmd = mkOption { type = types.nullOr types.str; @@ -101,9 +187,9 @@ in type = types.str; description = '' Absolute path to your lnbits-sensei checkout on this machine. - Used to source the seedable CLAUDE.md files (and, later, the - dev-env scripts) via `mkOutOfStoreSymlink` so edits in your - checkout take effect without a rebuild. + Used to source the seedable CLAUDE.md files via + `mkOutOfStoreSymlink` so edits in your checkout take effect + without a rebuild. Required when any `claude.*` integration is enabled. Type is `str` (not `path`) intentionally — `path` would copy the file @@ -117,24 +203,116 @@ in default = "/home/${config.lnbits-sensei.user or "user"}/dev"; defaultText = "/home/\${config.lnbits-sensei.user}/dev"; description = '' - Root directory for the dev environment. Worktrees and project - clones live under this prefix; bare repos under - `''${root}/repos/`. + Root directory for the dev environment. Bare repos live under + `''${root}/repos/`, worktrees under + `''${root}///`. ''; }; - gitHooks = { - enable = mkEnableOption '' - Shared pre-commit hook via `core.hooksPath`. Installs a single - secret-scanner hook under - `~/.local/share/lnbits-sensei/git-hooks/pre-commit` and points - the consumer's git config there — so every repo on the machine - picks it up without per-repo wiring. Refuses to commit obvious - secrets and unencrypted sops files; false positives are handled - via `# pragma: allowlist secret` markers + github = { + forkUser = mkOption { + type = types.nullOr types.str; + default = null; + description = '' + Personal github username used for upstream PR forks (the + `fork` remote). When set, projects that declare an `upstream` + but no explicit `fork` automatically get a fork remote derived + from `git@github.com:/.git`. + ''; + example = "octocat"; + }; + }; + + projects = mkOption { + type = types.attrsOf projectType; + default = { }; + description = '' + Hand-authored project list. Consumed by `dev-env-bootstrap` to + materialize bare repos + worktrees on disk. Use the `devEnv.lib. + mkProject` helper to cut boilerplate, or write raw submodule + attrs. See `presets/example.nix` for a worked example. ''; }; + deploy = { + flakeInput = mkOption { + type = types.nullOr types.str; + default = null; + description = '' + Sub-directory under `''${root}/deploy/` holding your unified + deploy flake working copy. `dev-deploy` uses it as the + `--flake` source. null means `''${root}/deploy` itself. + ''; + example = "unified"; + }; + + targets = mkOption { + type = types.attrsOf types.str; + default = { }; + description = '' + Map from hostname to SSH target used by `dev-deploy`. Each + entry is rendered to a `DEPLOY_TARGET_` env var in + /etc/dev-env/config.sh. + ''; + example = lib.literalExpression '' + { + prod = "root@prod-host"; + staging = "root@staging-host"; + } + ''; + }; + }; + + regtest = { + enable = mkEnableOption '' + Bitcoin/Lightning regtest docker stack. Wraps an upstream fork + of `lnbits/legend-regtest-enviroment` (LND + CLN + Eclair + + bitcoind + electrs). Installs `regtest-*` helpers. Requires a + container engine (docker) — install it separately; this flag + does not pull docker in for you + ''; + + repoUrl = mkOption { + type = types.str; + default = "https://github.com/lnbits/legend-regtest-enviroment"; + description = '' + Git URL of the regtest docker-compose repo. Cloned to + `''${root}/local/docker/regtest` by the bootstrap script. + Point at your own fork if you've customised the stack. + ''; + }; + }; + + tmux = { + enable = mkEnableOption "declarative tmux session launcher (dev-tm)"; + + sessions = mkOption { + type = types.attrsOf tmuxSessionType; + default = { }; + description = '' + Named tmux session layouts. The `dev-tm ` launcher 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 + secret-scanner hook under + `~/.local/share/lnbits-sensei/git-hooks/pre-commit` and points + the consumer's git config there — so every repo on the machine + picks it up without per-repo wiring. Refuses to commit obvious + secrets and unencrypted sops files; false positives are + handled via `# pragma: allowlist secret` markers + '' + // { + default = true; + }; + }; + claude = { enable = mkEnableOption '' Seed `~/dev/lnbits/CLAUDE.md` from @@ -152,46 +330,14 @@ in ''; }; - projects = mkOption { - type = types.attrsOf projectType; - default = { }; + writeDirenvHints = mkOption { + type = types.bool; + default = true; description = '' - Hand-authored project list. Consumed by the bootstrap script - (later pass) to materialize repos and worktrees on disk. + 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. ''; }; - - regtest = { - enable = mkEnableOption '' - Bitcoin/Lightning regtest docker stack. Wraps an upstream - fork of `lnbits/legend-regtest-enviroment` (LND + CLN + - Eclair + bitcoind + electrs). Brought up via - `dev up --regtest`. Implies a container engine — the - substantive pass will gate this on a containers feature - ''; - - repoUrl = mkOption { - type = types.str; - default = "https://github.com/lnbits/legend-regtest-enviroment"; - description = '' - Git URL of the regtest docker-compose repo. Cloned by the - dev-env bootstrap script. Point at your own fork if you've - customised the stack. - ''; - }; - }; - - tmux = { - enable = mkEnableOption "declarative tmux session launcher"; - - sessions = mkOption { - type = types.attrsOf tmuxSessionType; - default = { }; - description = '' - Named tmux session layouts. The launcher script (later pass) - reads these at runtime and recreates the session. - ''; - }; - }; }; } diff --git a/modules/dev-env/presets/example.nix b/modules/dev-env/presets/example.nix new file mode 100644 index 0000000..7c519f5 --- /dev/null +++ b/modules/dev-env/presets/example.nix @@ -0,0 +1,104 @@ +# Example dev-env preset — a worked, generic project list. +# +# Importing this file is opt-in and illustrative. Copy it, rename it, +# and edit the URLs / worktrees / targets for your own stack. The +# dev-env module ships **no** projects by default — you declare them +# either here or inline in your host config. +# +# Wire it up in configuration.nix: +# +# imports = [ +# ./modules/dev-env +# ./modules/dev-env/presets/example.nix +# ]; +# +# Then `nixos-rebuild switch` and `dev-env-bootstrap` to materialize the +# bare repos + worktrees on disk. +{ config, lib, ... }: + +let + # `mk` cuts boilerplate: derives the `fork` remote from + # github.forkUser, defaults worktreeRoot to the project name, etc. + mk = config.lnbits-sensei.devEnv.lib.mkProject; +in +{ + lnbits-sensei.devEnv = { + # Personal github username — used to derive `fork` remotes for any + # project that declares an upstream. Leave null if you never send + # upstream PRs. + github.forkUser = lib.mkDefault "octocat"; + + # Hostname → SSH target map for `dev-deploy`. Empty by default. + deploy.targets = lib.mkDefault { + # prod = "root@prod-host"; + # staging = "root@staging-host"; + }; + + projects = { + + # ─── lnbits: your fork, dev + main worktrees tracking upstream ── + # + # `url` is your origin (your fork on whatever git host you use). + # `upstream` adds the lnbits/lnbits remote so the rebase + sync + # helpers (lnbits-status, lnbits-sync-dev, rebase) work. + lnbits = mk { + name = "lnbits"; + url = "git@github.com:octocat/lnbits.git"; + upstream = "https://github.com/lnbits/lnbits"; + deployFlakeInput = "lnbits"; + worktrees = { + dev.branch = "dev"; + main.branch = "main"; + }; + }; + + # ─── an LNbits extension, grouped under an `extensions` category ─ + # + # Single clone (one branch checked out at a time). Lands at + # ${root}/extensions/myext. + myext = mk { + name = "myext"; + category = "extensions"; + url = "git@github.com:octocat/myext.git"; + upstream = "https://github.com/lnbits/myext"; + isClone = true; + }; + + # ─── a pure-upstream reference checkout (no fork, read-only) ───── + # + # No `url` → origin falls back to `upstream`. Useful for grepping + # a reference codebase alongside your own work. + nostr-tools = mk { + name = "nostr-tools"; + category = "refs"; + upstream = "https://github.com/nbd-wtf/nostr-tools"; + worktrees.master.branch = "master"; + }; + }; + + # ─── a declarative tmux session for the lnbits dev loop ─────────── + tmux = { + enable = lib.mkDefault true; + sessions.lnbits = { + cwd = "lnbits/dev"; + windows = [ + { + name = "edit"; + cwd = "lnbits/dev"; + cmd = "nvim ."; + } + { + name = "run"; + cwd = "lnbits/dev"; + cmd = null; + } + { + name = "git"; + cwd = "lnbits/dev"; + cmd = "lazygit"; + } + ]; + }; + }; + }; +} diff --git a/modules/dev-env/scripts/bootstrap.sh b/modules/dev-env/scripts/bootstrap.sh new file mode 100644 index 0000000..97ba832 --- /dev/null +++ b/modules/dev-env/scripts/bootstrap.sh @@ -0,0 +1,290 @@ +#!/usr/bin/env bash +# dev-env-bootstrap: idempotent materialization of bare repos and worktrees +# +# Reads /etc/dev-env/projects.json (rendered by config.nix) and ensures +# every declared project has: +# +# 1. A bare repo at ${REPOS_DIR}/.git with the declared remotes +# (origin, optional upstream, optional fork). +# 2. A worktree at the declared path for each entry in `worktrees`. +# 3. (Optional) A `.envrc` containing `use flake` if the worktree has +# a flake.nix and DEVENV_WRITE_DIRENV_HINTS=1. +# +# Safety: +# - Never clobbers an existing worktree whose current branch differs +# from the declared one — prints a warning and skips. +# - Never silently rewrites a remote URL — prints the diff and asks. +# - --dry-run shows everything that would happen without touching anything. +# - Re-running is a no-op when the tree already matches the spec. + +set -euo pipefail + +# --- Config ----------------------------------------------------------- + +if [[ -r /etc/dev-env/config.sh ]]; then + # shellcheck disable=SC1091 + source /etc/dev-env/config.sh +fi + +DEV_ROOT="${DEV_ROOT:-$HOME/dev}" +REPOS_DIR="${REPOS_DIR:-$DEV_ROOT/repos}" +PROJECTS_JSON="${DEVENV_PROJECTS_JSON:-/etc/dev-env/projects.json}" +WRITE_DIRENV="${DEVENV_WRITE_DIRENV_HINTS:-1}" + +# --- Args ------------------------------------------------------------- + +DRY_RUN=false +VERBOSE=false +FORCE_REMOTES=false +ONLY_PROJECT="" + +usage() { + cat <&2; } +trace() { if $VERBOSE; then echo -e "${DIM}\$ $*${NC}"; fi; } + +run() { + trace "$*" + if $DRY_RUN; then + echo " (dry-run) $*" + else + "$@" + fi +} + +# --- Preflight -------------------------------------------------------- + +if [[ ! -r "$PROJECTS_JSON" ]]; then + err "projects.json not found at $PROJECTS_JSON" + err "is the dev-env module enabled in your nixos config?" + exit 1 +fi + +if ! command -v jq >/dev/null; then + err "jq is required (should be in environment.systemPackages)" + exit 1 +fi + +if ! command -v git >/dev/null; then + err "git is required" + exit 1 +fi + +run mkdir -p "$REPOS_DIR" "$DEV_ROOT" + +# --- Per-project work ------------------------------------------------- + +# Returns "remote-nameurl" for the given project, one line per remote. +project_remotes_jq() { + local proj="$1" + jq -r --arg p "$proj" ' + .[$p].remotes + | to_entries[] + | "\(.key)\t\(.value)" + ' "$PROJECTS_JSON" +} + +# Returns "wtnamebranchpathremote" for each declared worktree. +project_worktrees_jq() { + local proj="$1" + jq -r --arg p "$proj" ' + .[$p].worktrees + | to_entries[] + | "\(.key)\t\(.value.branch)\t\(.value.path)\t\(.value.remote)" + ' "$PROJECTS_JSON" +} + +ensure_bare_repo() { + local proj="$1" + local bare_path + bare_path="$(jq -r --arg p "$proj" '.[$p].barePath' "$PROJECTS_JSON")" + + if [[ ! -d "$bare_path" ]]; then + info "creating bare repo $bare_path" + run git init --bare "$bare_path" + fi + + while IFS=$'\t' read -r remote_name url; do + [[ -z "$remote_name" ]] && continue + local current="" + current="$(git -C "$bare_path" remote get-url "$remote_name" 2>/dev/null || echo "")" + if [[ -z "$current" ]]; then + info " + remote $remote_name → $url" + run git -C "$bare_path" remote add "$remote_name" "$url" + elif [[ "$current" != "$url" ]]; then + warn " ! remote $remote_name URL differs:" + warn " have: $current" + warn " want: $url" + if $FORCE_REMOTES; then + info " (--force-remotes) updating" + run git -C "$bare_path" remote set-url "$remote_name" "$url" + else + warn " (skipped — pass --force-remotes to overwrite)" + fi + fi + done < <(project_remotes_jq "$proj") + + # Initial fetch (one-shot, only if origin has never been fetched) + if [[ ! -d "$bare_path/refs/remotes/origin" ]]; then + info " fetching origin (first time)" + run git -C "$bare_path" fetch origin 2>&1 | sed 's/^/ /' || \ + warn " fetch failed — check ssh access to origin" + fi + if git -C "$bare_path" remote get-url upstream &>/dev/null \ + && [[ ! -d "$bare_path/refs/remotes/upstream" ]]; then + info " fetching upstream (first time)" + run git -C "$bare_path" fetch upstream 2>&1 | sed 's/^/ /' || \ + warn " upstream fetch failed" + fi +} + +ensure_worktrees() { + local proj="$1" + local bare_path is_clone + bare_path="$(jq -r --arg p "$proj" '.[$p].barePath' "$PROJECTS_JSON")" + is_clone="$(jq -r --arg p "$proj" '.[$p].isClone' "$PROJECTS_JSON")" + + # Single-clone projects: clone to category/projectname instead of using worktrees + if [[ "$is_clone" == "true" ]]; then + local clone_path + clone_path="$(jq -r --arg p "$proj" '.[$p].clonePath' "$PROJECTS_JSON")" + if [[ -d "$clone_path/.git" ]]; then + ok " clone exists: $clone_path" + return 0 + fi + local origin_url + origin_url="$(jq -r --arg p "$proj" '.[$p].remotes.origin' "$PROJECTS_JSON")" + info " cloning $origin_url → $clone_path" + run mkdir -p "$(dirname "$clone_path")" + run git clone "$origin_url" "$clone_path" || warn " clone failed" + return 0 + fi + + while IFS=$'\t' read -r wt_name branch wt_path remote; do + [[ -z "$wt_name" ]] && continue + [[ "$wt_path" == "null" ]] && wt_path="" + + if [[ -z "$wt_path" ]]; then + warn " worktree $wt_name has no path; skipping" + continue + fi + + if [[ -d "$wt_path/.git" ]] || [[ -f "$wt_path/.git" ]]; then + local current_branch + current_branch="$(git -C "$wt_path" branch --show-current 2>/dev/null || echo '?')" + if [[ "$current_branch" == "$branch" ]]; then + ok " worktree $wt_name @ $branch (exists)" + else + warn " worktree $wt_name @ $current_branch (declared: $branch) — leaving alone" + fi + maybe_write_envrc "$wt_path" + continue + fi + + if [[ -e "$wt_path" ]]; then + warn " $wt_path exists but is not a git worktree; skipping" + continue + fi + + # Need to create the worktree. First make sure the local branch exists. + if ! git -C "$bare_path" show-ref --verify --quiet "refs/heads/$branch"; then + if git -C "$bare_path" show-ref --verify --quiet "refs/remotes/$remote/$branch"; then + info " creating local branch $branch from $remote/$branch" + run git -C "$bare_path" branch "$branch" "$remote/$branch" + else + warn " branch $branch not found on $remote — fetch and retry" + continue + fi + fi + + info " + worktree $wt_name → $wt_path ($branch)" + run mkdir -p "$(dirname "$wt_path")" + run git -C "$bare_path" worktree add "$wt_path" "$branch" + maybe_write_envrc "$wt_path" + done < <(project_worktrees_jq "$proj") +} + +maybe_write_envrc() { + local wt="$1" + [[ "$WRITE_DIRENV" != "1" ]] && return 0 + [[ -f "$wt/flake.nix" ]] || return 0 + [[ -e "$wt/.envrc" ]] && return 0 + info " + .envrc (use flake) → $wt/.envrc" + if ! $DRY_RUN; then + echo "use flake" > "$wt/.envrc" + fi +} + +# --- Main loop -------------------------------------------------------- + +mapfile -t PROJECTS < <(jq -r 'keys[]' "$PROJECTS_JSON") + +if [[ -n "$ONLY_PROJECT" ]]; then + if ! printf '%s\n' "${PROJECTS[@]}" | grep -qx "$ONLY_PROJECT"; then + err "project '$ONLY_PROJECT' not in $PROJECTS_JSON" + exit 1 + fi + PROJECTS=("$ONLY_PROJECT") +fi + +echo "" +echo "dev-env bootstrap" +echo " root: $DEV_ROOT" +echo " projects: ${#PROJECTS[@]}" +if $DRY_RUN; then echo " mode: dry-run"; fi +echo "" + +for proj in "${PROJECTS[@]}"; do + echo "─── $proj ───" + ensure_bare_repo "$proj" + ensure_worktrees "$proj" + echo "" +done + +ok "bootstrap complete" +if $DRY_RUN; then echo "(no changes were made)"; fi diff --git a/modules/dev-env/scripts/deploy.sh b/modules/dev-env/scripts/deploy.sh new file mode 100644 index 0000000..c6eaa62 --- /dev/null +++ b/modules/dev-env/scripts/deploy.sh @@ -0,0 +1,217 @@ +#!/usr/bin/env bash +# dev-deploy: thin wrapper around your unified deploy flake +# +# Usage: +# dev-deploy switch on host (uses locked deploy flake input) +# dev-deploy test test build (no switch) +# dev-deploy build local build only +# dev-deploy --local same as switch but with --override-input +# wired up from local worktrees so the deploy +# builds against your in-progress changes +# dev-deploy all [test|build] every host in parallel +# dev-deploy update nix flake update +# +# Deploy host → SSH target mapping comes from /etc/dev-env/config.sh +# (devEnv.deploy.targets). The flake working copy lives at +# ${DEV_ROOT}/deploy[/]. + +set -euo pipefail + +if [[ -r /etc/dev-env/config.sh ]]; then + # shellcheck disable=SC1091 + source /etc/dev-env/config.sh +fi + +DEPLOY_BASE="${DEPLOY_DIR:-$DEV_ROOT/deploy}" +if [[ -n "${DEVENV_DEPLOY_FLAKE_INPUT:-}" ]]; then + DEPLOY_DIR="$DEPLOY_BASE/$DEVENV_DEPLOY_FLAKE_INPUT" +else + DEPLOY_DIR="$DEPLOY_BASE" +fi +PROJECTS_JSON="${DEVENV_PROJECTS_JSON:-/etc/dev-env/projects.json}" + +usage() { + cat < deploy (switch) + dev-deploy test test build (no switch) + dev-deploy build local build only (no target) + dev-deploy --local deploy with --override-input from local worktrees + dev-deploy --local test test with overrides + dev-deploy all [test|build] every host in parallel + dev-deploy update [args...] nix flake update + +Configured targets: +EOF + env | grep '^DEPLOY_TARGET_' | sed 's/^DEPLOY_TARGET_/ /' | sed 's/=/ → /' || true + if [[ -r "$DEPLOY_DIR/flake.nix" ]]; then + echo "" + echo "Backing flake: $DEPLOY_DIR" + fi + exit 1 +} + +[[ $# -lt 1 ]] && usage + +# Pull the host → target map. We accept either: +# 1. DEPLOY_TARGET_= (rendered by config.nix as env vars) +# 2. ${DEPLOY_DIR}/deploy.sh's TARGETS associative array (fallback) +target_for() { + local host="$1" + local var="DEPLOY_TARGET_${host//-/_}" + if [[ -n "${!var:-}" ]]; then + echo "${!var}" + return + fi + # Fallback: parse a deploy.sh TARGETS array (simple regex; brittle + # but enough as a fallback) + if [[ -r "$DEPLOY_DIR/deploy.sh" ]]; then + awk -v h="$host" ' + /^[[:space:]]*\[/ { + if (match($0, /\[([^]]+)\]="([^"]*)"/, m)) { + if (m[1] == h) { print m[2]; exit } + } + } + ' "$DEPLOY_DIR/deploy.sh" + fi +} + +# --- subcommands --- + +cmd_update() { + shift + cd "$DEPLOY_DIR" + nix flake update "$@" +} + +# Build the --override-input flags for a host using projects.json's +# `deployFlakeInput` mapping. For each project that has deployFlakeInput +# set AND a worktree resolved to a real on-disk path, we emit +# --override-input path: +# Picks the first worktree by default; pass DEVENV_OVERRIDE_WORKTREE= +# to choose a specific one. +build_overrides() { + local args=() + local prefer="${DEVENV_OVERRIDE_WORKTREE:-}" + [[ -r "$PROJECTS_JSON" ]] || { printf '%s\n' ""; return; } + + while IFS=$'\t' read -r input wt_path; do + [[ -z "$input" || "$input" == "null" ]] && continue + [[ -d "$wt_path" ]] || continue + args+=(--override-input "$input" "path:$wt_path") + done < <(jq -r --arg prefer "$prefer" ' + to_entries[] + | select(.value.deployFlakeInput != null) + | .value as $v + | (if ($prefer | length) > 0 and ($v.worktrees | has($prefer)) + then $v.worktrees[$prefer].path + else + ($v.worktrees | to_entries | first.value.path // null) + end) as $path + | "\($v.deployFlakeInput)\t\($path // "")" + ' "$PROJECTS_JSON") + + printf '%s\0' "${args[@]}" +} + +run_host() { + local host="$1" action="$2" use_local="$3" + local target + target="$(target_for "$host")" + + if [[ -z "$target" ]] && [[ "$action" != "build" ]]; then + echo "no SSH target for host '$host' (set DEPLOY_TARGET_${host//-/_})" >&2 + return 1 + fi + + cd "$DEPLOY_DIR" + + local overrides=() + if $use_local; then + # Read the null-delimited overrides into an array + while IFS= read -r -d '' arg; do + [[ -n "$arg" ]] && overrides+=("$arg") + done < <(build_overrides) + if (( ${#overrides[@]} > 0 )); then + echo "[$host] using ${#overrides[@]} local override(s):" + local i=0 + while (( i < ${#overrides[@]} )); do + echo " ${overrides[$((i+1))]} -> ${overrides[$((i+2))]}" + i=$((i + 3)) + done + fi + fi + + case "$action" in + deploy) + echo "[$host] switch on $target..." + nixos-rebuild switch \ + --flake ".#$host" \ + --target-host "$target" \ + "${overrides[@]}" + ;; + test) + echo "[$host] test on $target..." + nixos-rebuild test \ + --flake ".#$host" \ + --target-host "$target" \ + "${overrides[@]}" + ;; + build) + echo "[$host] local build..." + nix build \ + ".#nixosConfigurations.$host.config.system.build.toplevel" \ + "${overrides[@]}" + ;; + *) + echo "unknown action: $action" >&2 + return 1 + ;; + esac +} + +USE_LOCAL=false +if [[ "$1" == "--local" ]]; then + USE_LOCAL=true + shift +fi + +case "$1" in + update) + cmd_update "$@" + ;; + all) + action="${2:-deploy}" + # iterate every DEPLOY_TARGET_* env var + pids=() + hosts=() + for var in $(env | grep -o '^DEPLOY_TARGET_[A-Za-z0-9_]*' || true); do + host="${var#DEPLOY_TARGET_}" + host="${host//_/-}" + run_host "$host" "$action" "$USE_LOCAL" & + pids+=($!) + hosts+=("$host") + done + failed=() + for i in "${!pids[@]}"; do + if ! wait "${pids[$i]}"; then + failed+=("${hosts[$i]}") + fi + done + if (( ${#failed[@]} > 0 )); then + echo "FAILED: ${failed[*]}" + exit 1 + fi + echo "All hosts deployed successfully." + ;; + -h|--help) + usage + ;; + *) + host="$1" + action="${2:-deploy}" + run_host "$host" "$action" "$USE_LOCAL" + ;; +esac diff --git a/modules/dev-env/scripts/dev.sh b/modules/dev-env/scripts/dev.sh deleted file mode 100755 index 2c98e17..0000000 --- a/modules/dev-env/scripts/dev.sh +++ /dev/null @@ -1,53 +0,0 @@ -#!/usr/bin/env bash -# dev — single entry point for spinning up an LNbits dev environment. -# -# dev up [--fakewallet|--regtest] start lnbits (default: --fakewallet) -# dev down tear down whatever's running -# dev logs follow lnbits (and docker, if regtest) logs -# dev shell drop into the lnbits venv (or regtest container) -# -# Modes: -# --fakewallet (default) LNBITS_BACKEND_WALLET_CLASS=FakeWallet — no docker, -# no chains, instant. Good for extension / UI work. -# --regtest docker-compose up the multi-node regtest stack -# (LND + CLN + Eclair + bitcoind + electrs), then -# start lnbits pointed at the LND-rest endpoint. -# Wraps lnbits/legend-regtest-enviroment. -# -# Skeleton — no real wiring yet. The substantive pass will: -# - locate the lnbits checkout via inputs.lnbits-src (or a configurable path) -# - locate the regtest docker repo via config.lnbits-sensei.devEnv.regtest.repoUrl -# - bring up containers, wait-for-it, populate LND credentials, exec lnbits - -set -euo pipefail - -cmd="${1:-up}" -shift || true - -mode="fakewallet" -while [[ $# -gt 0 ]]; do - case "$1" in - --fakewallet) mode="fakewallet"; shift ;; - --regtest) mode="regtest"; shift ;; - *) echo "dev: unknown flag: $1" >&2; exit 1 ;; - esac -done - -case "$cmd" in - up) - echo "dev up --$mode: TODO — wire substantive startup in next pass." - ;; - down) - echo "dev down: TODO — wire teardown in next pass." - ;; - logs) - echo "dev logs: TODO — tail lnbits + (docker logs if regtest) here." - ;; - shell) - echo "dev shell: TODO — drop into venv / regtest container here." - ;; - *) - echo "Usage: dev {up|down|logs|shell} [--fakewallet|--regtest]" >&2 - exit 1 - ;; -esac diff --git a/modules/dev-env/scripts/nav.sh b/modules/dev-env/scripts/nav.sh new file mode 100644 index 0000000..c910927 --- /dev/null +++ b/modules/dev-env/scripts/nav.sh @@ -0,0 +1,168 @@ +#!/usr/bin/env bash +# dev-env: navigation helpers +# +# Sourced by user shells (not invoked as a script) via the loader in +# /etc/profile.d/dev-env-functions.sh. Every function reads +# /etc/dev-env/config.sh at call time, so adding a new worktree on disk +# is immediately visible without a nixos-rebuild. + +# Load runtime config (idempotent). +_devenv_load_config() { + if [[ -r /etc/dev-env/config.sh ]]; then + # shellcheck disable=SC1091 + source /etc/dev-env/config.sh + fi +} + +# Navigate to the dev root. +dev() { + _devenv_load_config + cd "${DEV_ROOT:-$HOME/dev}" || return 1 +} + +# Navigate to an lnbits worktree. +# Usage: lb [worktree] (worktree ∈ whatever the filesystem shows, e.g. dev/main) +lb() { + _devenv_load_config + local env="${1:-}" + local lnbits_dir="${LNBITS_DIR:-$DEV_ROOT/lnbits}" + + if [[ -z "$env" ]]; then + echo "Usage: lb " + echo "" + echo "Current lnbits worktrees:" + if [[ -d "$lnbits_dir" ]]; then + for d in "$lnbits_dir"/*/; do + [[ -d "$d" ]] || continue + local name branch + name="$(basename "$d")" + branch="$(git -C "$d" branch --show-current 2>/dev/null || echo '?')" + printf " %-12s (%s)\n" "$name" "$branch" + done + fi + return 0 + fi + + if [[ -d "$lnbits_dir/$env" ]]; then + cd "$lnbits_dir/$env" || return 1 + else + echo "Unknown lnbits worktree: $env" + return 1 + fi +} + +# Navigate within a project-group folder (a `category` directory holding +# one or more sub-repos). Sub-repos may be single clones or bare-repo +# worktree sets; worktree sets accept an optional worktree name. +# Usage: g [repo] [worktree] +_dev_group_nav() { + local group_dir="$1" repo="${2:-}" worktree="${3:-dev}" + + if [[ -z "$repo" ]]; then + echo "repos under $(basename "$group_dir")/:" + if [[ -d "$group_dir" ]]; then + for d in "$group_dir"/*/; do + [[ -d "$d" ]] && echo " $(basename "$d")" + done + fi + return 0 + fi + + # Single-clone sub-repo (has its own .git at the top level) + if [[ -d "$group_dir/$repo/.git" ]]; then + cd "$group_dir/$repo" || return 1 + return 0 + fi + + # Worktree-based sub-repo + if [[ -d "$group_dir/$repo/$worktree" ]]; then + cd "$group_dir/$repo/$worktree" || return 1 + elif [[ -d "$group_dir/$repo" ]]; then + cd "$group_dir/$repo" || return 1 + else + echo "Unknown repo under $(basename "$group_dir")/: $repo" + return 1 + fi +} + +# Navigate into a project category directory. +# Usage: g [repo] [worktree] +g() { + _devenv_load_config + local category="${1:-}" + if [[ -z "$category" ]]; then + echo "Usage: g [repo] [worktree]" + echo "" + echo "Categories under $DEV_ROOT:" + for d in "$DEV_ROOT"/*/; do + [[ -d "$d" ]] && echo " $(basename "$d")" + done + return 0 + fi + _dev_group_nav "$DEV_ROOT/$category" "${2:-}" "${3:-dev}" +} + +# Navigate to an extension under shared/extensions. +ext() { + _devenv_load_config + local extension="${1:-}" + local ext_root="${SHARED_DIR:-$DEV_ROOT/shared}/extensions" + + if [[ -z "$extension" ]]; then + echo "Available extensions:" + [[ -d "$ext_root" ]] && ls -1 "$ext_root" + return 0 + fi + if [[ -d "$ext_root/$extension" ]]; then + cd "$ext_root/$extension" || return 1 + else + echo "Unknown extension: $extension" + [[ -d "$ext_root" ]] && ls -1 "$ext_root" + return 1 + fi +} + +# Navigate to a deploy host config inside the deploy flake working copy. +deploy_nav() { + _devenv_load_config + local host="${1:-}" + local deploy_root="${DEPLOY_DIR:-$DEV_ROOT/deploy}" + + if [[ -z "$host" ]]; then + echo "Usage: dep " + echo "" + echo "Deploy targets (from /etc/dev-env/config.sh):" + env | grep '^DEPLOY_TARGET_' | sed 's/^DEPLOY_TARGET_/ /' | sed 's/=/ → /' || true + return 1 + fi + + if [[ -d "$deploy_root/hosts/$host" ]]; then + cd "$deploy_root/hosts/$host" || return 1 + elif [[ -d "$deploy_root/$host" ]]; then + cd "$deploy_root/$host" || return 1 + elif [[ -d "$deploy_root" ]]; then + cd "$deploy_root" || return 1 + else + echo "Deploy path not found: $deploy_root" + return 1 + fi +} +alias dep=deploy_nav + +# Navigate to the shared repos directory. +shared() { + _devenv_load_config + cd "${SHARED_DIR:-$DEV_ROOT/shared}" || return 1 +} + +# Navigate to the bare-repos directory. +repos() { + _devenv_load_config + cd "${REPOS_DIR:-$DEV_ROOT/repos}" || return 1 +} + +# Navigate to the upstream-prs directory. +prs() { + _devenv_load_config + cd "${UPSTREAM_PRS_DIR:-$DEV_ROOT/upstream-prs}" || return 1 +} diff --git a/modules/dev-env/scripts/pr-helpers.sh b/modules/dev-env/scripts/pr-helpers.sh new file mode 100644 index 0000000..46763d5 --- /dev/null +++ b/modules/dev-env/scripts/pr-helpers.sh @@ -0,0 +1,144 @@ +#!/usr/bin/env bash +# dev-env: upstream PR worktree helpers +# +# Sourced into interactive shells. Provides prb / prc / prl. Each PR gets +# a throwaway worktree at ${UPSTREAM_PRS_DIR}/- based on +# upstream/main (or master), ready to push to the `fork` remote and open +# a PR. Reads paths from /etc/dev-env/config.sh. + +_devenv_load_config() { + if [[ -r /etc/dev-env/config.sh ]]; then + # shellcheck disable=SC1091 + source /etc/dev-env/config.sh + fi +} + +# Create a PR worktree at ${UPSTREAM_PRS_DIR}/-. +# +# Usage: git-pr-branch +git-pr-branch() { + _devenv_load_config + local repo_name="${1:-}" + local branch_name="${2:-}" + local repos_dir="${REPOS_DIR:-$DEV_ROOT/repos}" + local prs_dir="${UPSTREAM_PRS_DIR:-$DEV_ROOT/upstream-prs}" + + if [[ -z "$repo_name" || -z "$branch_name" ]]; then + echo "Usage: git-pr-branch " + echo "Example: git-pr-branch lnbits fix-invoice-bug" + echo "" + echo "Creates a worktree at $prs_dir/-" + echo "based on upstream/main, ready for an upstream PR." + return 1 + fi + + local bare_repo="$repos_dir/${repo_name}.git" + local pr_path="$prs_dir/${repo_name}-${branch_name}" + + if [[ ! -d "$bare_repo" ]]; then + echo "Repo not found: $bare_repo" + echo "Available repos:" + ls -1 "$repos_dir"/*.git 2>/dev/null | xargs -n1 basename | sed 's/\.git$//' + return 1 + fi + + if [[ -d "$pr_path" ]]; then + echo "PR worktree already exists: $pr_path" + echo "To remove it: git-pr-cleanup $repo_name $branch_name" + return 1 + fi + + # Fetch upstream + echo "Fetching upstream..." + git -C "$bare_repo" fetch upstream 2>/dev/null || { + echo "No 'upstream' remote on $repo_name — cannot create PR branch" + return 1 + } + + # Determine base branch (main or master) + local base_branch="main" + git -C "$bare_repo" show-ref --verify --quiet "refs/remotes/upstream/main" \ + || base_branch="master" + + echo "Creating branch '$branch_name' from upstream/$base_branch..." + git -C "$bare_repo" branch "$branch_name" "upstream/$base_branch" 2>/dev/null \ + || git -C "$bare_repo" branch -f "$branch_name" "upstream/$base_branch" + + mkdir -p "$prs_dir" + git -C "$bare_repo" worktree add "$pr_path" "$branch_name" + + if ! git -C "$bare_repo" remote get-url fork &>/dev/null; then + echo "" + echo "Note: 'fork' remote not configured for $repo_name." + echo "Add it with:" + echo " git -C $bare_repo remote add fork git@github.com:${GITHUB_FORK_USER:-}/${repo_name}.git" + fi + + cat < " + echo "" + echo "Active PR worktrees:" + ls -1 "$prs_dir" 2>/dev/null || echo " (none)" + return 1 + fi + + local bare_repo="$repos_dir/${repo_name}.git" + local pr_path="$prs_dir/${repo_name}-${branch_name}" + + [[ -d "$pr_path" ]] || { echo "PR worktree not found: $pr_path"; return 1; } + + echo "Removing worktree: $pr_path" + git -C "$bare_repo" worktree remove "$pr_path" + + echo "Deleting branch: $branch_name" + git -C "$bare_repo" branch -d "$branch_name" 2>/dev/null \ + || git -C "$bare_repo" branch -D "$branch_name" + + echo "Done." +} + +# List all active PR worktrees. +git-pr-list() { + _devenv_load_config + local prs_dir="${UPSTREAM_PRS_DIR:-$DEV_ROOT/upstream-prs}" + + echo "=== Active PR Worktrees ===" + if [[ -d "$prs_dir" && -n "$(ls -A "$prs_dir" 2>/dev/null)" ]]; then + for pr_dir in "$prs_dir"/*; do + [[ -d "$pr_dir" ]] || continue + local name branch status + name="$(basename "$pr_dir")" + branch="$(git -C "$pr_dir" branch --show-current 2>/dev/null || echo '?')" + status="$(git -C "$pr_dir" status -sb 2>/dev/null | head -1 || echo '?')" + printf " %-40s %s %s\n" "$name" "$branch" "$status" + done + else + echo " (none)" + fi +} + +alias prb='git-pr-branch' +alias prc='git-pr-cleanup' +alias prl='git-pr-list' diff --git a/modules/dev-env/scripts/rebase.sh b/modules/dev-env/scripts/rebase.sh new file mode 100644 index 0000000..60078ca --- /dev/null +++ b/modules/dev-env/scripts/rebase.sh @@ -0,0 +1,409 @@ +#!/usr/bin/env bash +# dev-env: safe fork-onto-upstream rebase helper +# +# Walks every worktree under $DEV_ROOT that has an `upstream` remote and +# rebases your fork commits onto upstream, with backups and a +# force-with-lease push gated on confirmation. Reads paths from +# /etc/dev-env/config.sh; the rebase log lives under the user's XDG +# state dir. +# +# Workflow for a single rebase: +# 1. Safety checks (no uncommitted changes, upstream remote exists) +# 2. Create backup branch `backup/pre-rebase-YYYYMMDD-HHMMSS` +# 3. Show incoming + outgoing commits +# 4. Rebase and force-with-lease push (with confirmation) +# 5. On conflict, print resolution guide and preserve backup + +set -e + +# Load runtime config (sets DEV_ROOT, REPOS_DIR, SHARED_DIR, …) +if [[ -r /etc/dev-env/config.sh ]]; then + # shellcheck disable=SC1091 + source /etc/dev-env/config.sh +else + echo "Error: /etc/dev-env/config.sh not found (is the dev-env module enabled?)" >&2 + exit 1 +fi + +DEV_ROOT="${DEV_ROOT:-$HOME/dev}" +REPOS_DIR="${REPOS_DIR:-$DEV_ROOT/repos}" +SHARED_DIR="${SHARED_DIR:-$DEV_ROOT/shared}" + +BACKUP_PREFIX="backup/pre-rebase" +LOG_FILE="${XDG_STATE_HOME:-$HOME/.local/state}/dev-env/rebase.log" +mkdir -p "$(dirname "$LOG_FILE")" + +#------------------------------------------------------------------------------- +# Colors +#------------------------------------------------------------------------------- +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +BOLD='\033[1m' +NC='\033[0m' + +info() { echo -e "${BLUE}[INFO]${NC} $1"; } +success() { echo -e "${GREEN}[OK]${NC} $1"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } +error() { echo -e "${RED}[ERROR]${NC} $1"; } +header() { echo -e "\n${BOLD}${CYAN}═══ $1 ═══${NC}\n"; } + +log_action() { + echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" >> "$LOG_FILE" +} + +confirm() { + local prompt="${1:-Continue?}" + read -r -p "$prompt (y/N) " -n 1 REPLY + echo + [[ $REPLY =~ ^[Yy]$ ]] +} + +#------------------------------------------------------------------------------- +# Generic repo helpers +#------------------------------------------------------------------------------- +get_upstream_branch() { + local repo_path="$1" + (cd "$repo_path" + for branch in main master; do + if git rev-parse "upstream/$branch" &>/dev/null; then + echo "$branch" + return 0 + fi + done + echo "main") +} + +check_repo_clean() { + local repo_path="$1" + [[ -z "$(git -C "$repo_path" status --porcelain)" ]] +} + +has_upstream() { + git -C "$1" remote get-url upstream &>/dev/null +} + +create_backup() { + local repo_path="$1" + local branch + branch="$(git -C "$repo_path" branch --show-current)" + local backup_name="${BACKUP_PREFIX}-$(date +%Y%m%d-%H%M%S)" + git -C "$repo_path" branch -f "$backup_name" "$branch" + echo "$backup_name" +} + +show_divergence() { + local repo_path="$1" upstream_branch="$2" + local behind ahead + behind=$(git -C "$repo_path" rev-list --count "HEAD..upstream/$upstream_branch" 2>/dev/null || echo 0) + ahead=$(git -C "$repo_path" rev-list --count "upstream/$upstream_branch..HEAD" 2>/dev/null || echo 0) + echo -e " ${CYAN}Behind upstream:${NC} $behind commits" + echo -e " ${GREEN}Ahead (your work):${NC} $ahead commits" +} + +#------------------------------------------------------------------------------- +# Core rebase +#------------------------------------------------------------------------------- +rebase_single_repo() { + local repo_path="$1" repo_name="$2" + local upstream_branch="${3:-}" + local auto_mode="${4:-false}" + + header "Rebasing: $repo_name" + + if [[ ! -d "$repo_path/.git" ]] && [[ ! -f "$repo_path/.git" ]]; then + error "Not a git repository: $repo_path" + return 1 + fi + + if ! has_upstream "$repo_path"; then + warn "No upstream remote configured. Skipping." + return 0 + fi + + if ! check_repo_clean "$repo_path"; then + error "Uncommitted changes detected!" + echo " Stash or commit first: git stash / git commit" + return 1 + fi + + [[ -z "$upstream_branch" ]] && upstream_branch="$(get_upstream_branch "$repo_path")" + + local current_branch + current_branch="$(git -C "$repo_path" branch --show-current)" + info "Current branch: $current_branch" + info "Upstream branch: upstream/$upstream_branch" + + info "Fetching upstream..." + git -C "$repo_path" fetch upstream + + show_divergence "$repo_path" "$upstream_branch" + + local behind + behind=$(git -C "$repo_path" rev-list --count "HEAD..upstream/$upstream_branch" 2>/dev/null || echo 0) + if [[ "$behind" -eq 0 ]]; then + success "Already up to date with upstream!" + return 0 + fi + + echo "" + echo -e "${BOLD}New commits from upstream:${NC}" + git -C "$repo_path" log --oneline --graph "HEAD..upstream/$upstream_branch" | head -15 + local total_upstream + total_upstream="$(git -C "$repo_path" rev-list --count "HEAD..upstream/$upstream_branch")" + (( total_upstream > 15 )) && echo " ... and $((total_upstream - 15)) more commits" + + echo "" + echo -e "${BOLD}Your commits to replay:${NC}" + git -C "$repo_path" log --oneline "upstream/$upstream_branch..HEAD" | head -15 + local total_yours + total_yours="$(git -C "$repo_path" rev-list --count "upstream/$upstream_branch..HEAD")" + (( total_yours > 15 )) && echo " ... and $((total_yours - 15)) more commits" + + echo "" + if [[ "$auto_mode" != "true" ]] && ! confirm "Proceed with rebase?"; then + warn "Skipped." + return 0 + fi + + local backup_branch + backup_branch="$(create_backup "$repo_path")" + success "Backup created: $backup_branch" + + info "Rebasing onto upstream/$upstream_branch..." + if git -C "$repo_path" rebase "upstream/$upstream_branch"; then + success "Rebase completed successfully!" + echo "" + if confirm "Push to origin with --force-with-lease?"; then + git -C "$repo_path" push origin "$current_branch" --force-with-lease + success "Pushed to origin!" + log_action "REBASE SUCCESS: $repo_name onto upstream/$upstream_branch" + else + warn "Not pushed. When ready: git push origin $current_branch --force-with-lease" + fi + echo "" + info "Backup branch '$backup_branch' preserved." + echo " Delete later: git branch -D $backup_branch" + return 0 + else + error "Rebase encountered conflicts!" + cat <>>>>>> + 3. Stage: ${CYAN}git add ${NC} + 4. Continue: ${CYAN}git rebase --continue${NC} + 5. Skip empty: ${CYAN}git rebase --skip${NC} + 6. Abort: ${CYAN}git rebase --abort${NC} + 7. After success: ${CYAN}git push origin $current_branch --force-with-lease${NC} + + Backup: ${GREEN}$backup_branch${NC} +EOF + log_action "REBASE CONFLICT: $repo_name - manual resolution required" + return 1 + fi +} + +#------------------------------------------------------------------------------- +# Discovery: walk actual worktrees under $DEV_ROOT instead of the old +# $PROJECTS_DIR layout that no longer exists. +#------------------------------------------------------------------------------- +find_forked_repos() { + # Print one ":" per rebase-eligible repo (has + # upstream remote). We look at every git dir (.git dir or .git file + # for worktrees) under $DEV_ROOT, excluding $REPOS_DIR bare repos. + find "$DEV_ROOT" -type d -name '.git' -prune 2>/dev/null | while read -r gitdir; do + local repo_path + repo_path="$(dirname "$gitdir")" + # Skip bare repos under REPOS_DIR + [[ "$repo_path" == "$REPOS_DIR"* ]] && continue + # Skip node_modules + [[ "$repo_path" == *"/node_modules/"* ]] && continue + if has_upstream "$repo_path"; then + local display + display="${repo_path#$DEV_ROOT/}" + echo "$repo_path:$display" + fi + done + # Also bare repos under REPOS_DIR (the .git file case) + find "$DEV_ROOT" -type f -name '.git' 2>/dev/null | while read -r gitfile; do + local repo_path + repo_path="$(dirname "$gitfile")" + [[ "$repo_path" == *"/node_modules/"* ]] && continue + if has_upstream "$repo_path"; then + local display + display="${repo_path#$DEV_ROOT/}" + echo "$repo_path:$display" + fi + done +} + +#------------------------------------------------------------------------------- +# Batch modes +#------------------------------------------------------------------------------- +rebase_all() { + header "Rebasing ALL Forks with Upstreams" + warn "This walks $DEV_ROOT for every worktree with an 'upstream' remote." + echo "" + if ! confirm "Continue?"; then info "Aborted."; return 0; fi + + local failed=() + while IFS=: read -r path name; do + if ! rebase_single_repo "$path" "$name"; then + failed+=("$name") + fi + done < <(find_forked_repos) + + echo "" + header "Summary" + if (( ${#failed[@]} == 0 )); then + success "All repositories rebased successfully!" + else + error "Failed (${#failed[@]}):" + printf ' - %s\n' "${failed[@]}" + return 1 + fi +} + +show_all_status() { + header "Repository Rebase Status" + while IFS=: read -r path name; do + local upstream_branch behind ahead + upstream_branch="$(get_upstream_branch "$path")" + git -C "$path" fetch upstream --quiet 2>/dev/null || true + behind=$(git -C "$path" rev-list --count "HEAD..upstream/$upstream_branch" 2>/dev/null || echo ?) + ahead=$(git -C "$path" rev-list --count "upstream/$upstream_branch..HEAD" 2>/dev/null || echo ?) + printf " %-40s ↓%-3s ↑%-3s\n" "$name" "$behind" "$ahead" + done < <(find_forked_repos) + echo "" + echo -e "${CYAN}Legend:${NC} ↓=behind upstream ↑=ahead (your commits)" +} + +view_log() { + header "Rebase Log" + if [[ -f "$LOG_FILE" ]]; then tail -50 "$LOG_FILE" + else info "No rebase log yet at $LOG_FILE"; fi +} + +cleanup_backups() { + header "Cleanup Backup Branches" + echo "Options:" + echo " 1) Delete backups older than 7 days" + echo " 2) Delete backups older than 30 days" + echo " 3) Delete ALL backup branches" + echo " 4) Cancel" + read -r -p "Select option: " -n 1 REPLY; echo "" + + local days=0 + case $REPLY in + 1) days=7 ;; + 2) days=30 ;; + 3) days=0 ;; + *) info "Cancelled."; return 0 ;; + esac + + local cutoff="" + (( days > 0 )) && cutoff=$(date -d "$days days ago" +%Y%m%d 2>/dev/null || date -v-"${days}d" +%Y%m%d) + + local deleted=0 + while IFS=: read -r path _; do + cd "$path" || continue + git branch --list "backup/*" 2>/dev/null | while read -r branch; do + branch="$(echo "$branch" | tr -d ' *')" + local bdate + bdate="$(echo "$branch" | grep -oE '[0-9]{8}' | head -1)" + if [[ $days -eq 0 ]] \ + || { [[ -n "$bdate" ]] && [[ "$bdate" < "$cutoff" ]]; }; then + git branch -D "$branch" 2>/dev/null && deleted=$((deleted + 1)) || true + fi + done + done < <(find_forked_repos) + success "Deleted $deleted backup branch(es)." +} + +#------------------------------------------------------------------------------- +# Interactive / CLI +#------------------------------------------------------------------------------- +interactive_menu() { + while true; do + header "Rebase Helper" + echo " 1) Rebase a single repository (by path)" + echo " 2) Rebase ALL forks with upstream" + echo " s) Status of all repos" + echo " l) View rebase log" + echo " c) Clean up old backup branches" + echo " q) Quit" + echo "" + read -r -p "Select: " -n 1 REPLY; echo "" + case $REPLY in + 1) read -r -p "Repo path: " p + [[ -d "$p/.git" || -f "$p/.git" ]] && rebase_single_repo "$p" "$(basename "$p")" || warn "Not a repo: $p" ;; + 2) rebase_all ;; + s) show_all_status ;; + l) view_log ;; + c) cleanup_backups ;; + q) echo "Bye!"; exit 0 ;; + *) warn "Invalid option" ;; + esac + echo "" + read -r -p "Press Enter to continue..." + done +} + +usage() { + cat < Rebase a single repository + rebase all Rebase all forks with upstream remote + rebase status Show status of all forks + rebase log Show rebase log + rebase cleanup Clean up old backup branches + +OPTIONS: + -b, --branch Upstream branch to rebase onto + -y, --yes Auto-confirm prompts + -h, --help Show this help + +Backups are branched automatically as backup/pre-rebase-YYYYMMDD-HHMMSS +and never deleted without explicit cleanup. +EOF +} + +main() { + local command="" repo_path="" upstream_branch="" auto_yes=false + while [[ $# -gt 0 ]]; do + case $1 in + -h|--help) usage; exit 0 ;; + -b|--branch) upstream_branch="$2"; shift 2 ;; + -y|--yes) auto_yes=true; shift ;; + single|all|status|log|cleanup) command="$1"; shift ;; + *) + if [[ -z "$command" ]] && [[ -d "$1/.git" || -f "$1/.git" ]]; then + command="single"; repo_path="$1" + else + repo_path="$1" + fi + shift + ;; + esac + done + + case $command in + "") interactive_menu ;; + single) [[ -z "$repo_path" ]] && { error "Need path"; exit 1; } + rebase_single_repo "$repo_path" "$(basename "$repo_path")" "$upstream_branch" "$auto_yes" ;; + all) rebase_all ;; + status) show_all_status ;; + log) view_log ;; + cleanup) cleanup_backups ;; + *) error "Unknown command: $command"; usage; exit 1 ;; + esac +} + +main "$@" diff --git a/modules/dev-env/scripts/regtest.sh b/modules/dev-env/scripts/regtest.sh new file mode 100644 index 0000000..16c94e9 --- /dev/null +++ b/modules/dev-env/scripts/regtest.sh @@ -0,0 +1,267 @@ +#!/usr/bin/env bash +# dev-env: Bitcoin/Lightning regtest docker environment +# +# Provides: +# regtest-start [worktree|pr:] [--path ] [--seed|--keep] +# regtest-stop +# regtest-status +# regtest-logs [service] +# regtest-cli # source CLI helpers +# regtest # cd to regtest dir +# +# Reads paths from /etc/dev-env/config.sh. Wraps the +# `lnbits/legend-regtest-enviroment` compose stack (or your fork of it, +# set via devEnv.regtest.repoUrl). Container names and ports below assume +# that stack's layout — adjust if you've customised it. +# +# This file is sourced into interactive shells (function definitions) AND +# dropped into the system path as standalone wrapper scripts so +# `regtest-start` works from a fresh non-interactive shell. + +_devenv_load_config() { + if [[ -r /etc/dev-env/config.sh ]]; then + # shellcheck disable=SC1091 + source /etc/dev-env/config.sh + fi + REGTEST_DIR="${LOCAL_DIR:-$DEV_ROOT/local}/docker/regtest" + LNBITS_DIR="${LNBITS_DIR:-$DEV_ROOT/lnbits}" + UPSTREAM_PRS_DIR="${UPSTREAM_PRS_DIR:-$DEV_ROOT/upstream-prs}" + # Node used for readiness/balance checks. Override if your stack + # names containers differently. + REGTEST_LND="${REGTEST_LND:-lnbits-lnd-4-1}" +} + +#------------------------------------------------------------------------------- +# Status helpers +#------------------------------------------------------------------------------- + +_regtest_is_running() { + docker ps --filter "name=$REGTEST_LND" --format '{{.Names}}' 2>/dev/null \ + | grep -q "$REGTEST_LND" +} + +#------------------------------------------------------------------------------- +# Start +#------------------------------------------------------------------------------- +# Usage: regtest-start [worktree|pr:] [--path ] [--seed|--keep] +# +# worktree an lnbits worktree name (under ~/dev/lnbits/) +# pr: ~/dev/upstream-prs/lnbits- +# --path arbitrary lnbits directory +# --seed copy lnbits-seed/ to lnbits/ before starting +# --keep keep existing data +regtest-start() { + _devenv_load_config + + local env="" data_mode="fresh" custom_path="" + + while [[ $# -gt 0 ]]; do + case "$1" in + --seed) data_mode="seed" ;; + --keep) data_mode="keep" ;; + --path) shift; custom_path="$1" ;; + pr:*) + local pr_branch="${1#pr:}" + custom_path="$UPSTREAM_PRS_DIR/lnbits-$pr_branch" + ;; + -*) + cat <] [--path ] [--seed|--keep] + + worktree an lnbits worktree name (under ~/dev/lnbits/) + pr: ~/dev/upstream-prs/lnbits- + --path arbitrary lnbits directory +USAGE + return 1 + ;; + *) env="$1" ;; + esac + shift + done + + if [[ ! -d "$REGTEST_DIR" ]]; then + echo "Regtest environment not found at $REGTEST_DIR" + echo "Run dev-env-bootstrap to clone it." + return 1 + fi + + # Determine lnbits source dir + local lnbits_path="" + if [[ -n "$custom_path" ]]; then + lnbits_path="$custom_path" + [[ -d "$lnbits_path" ]] || { echo "lnbits dir not found: $lnbits_path"; return 1; } + else + [[ -z "$env" ]] && env="dev" + lnbits_path="$LNBITS_DIR/$env" + [[ -d "$lnbits_path" ]] || { echo "lnbits worktree not found: $env"; return 1; } + fi + + local lnbits_data="$REGTEST_DIR/data/lnbits" + local lnbits_seed="$REGTEST_DIR/data/lnbits-seed" + + case "$data_mode" in + fresh) + if [[ -d "$lnbits_data" ]] && [[ -n "$(ls -A "$lnbits_data" 2>/dev/null)" ]]; then + echo "Existing lnbits data at $lnbits_data" + read -r -p "Wipe it? [y/N] " -n 1 REPLY; echo + [[ $REPLY =~ ^[Yy]$ ]] || { echo "Aborted."; return 1; } + docker run --rm -v "$lnbits_data":/data alpine sh -c "rm -rf /data/* /data/.*" 2>/dev/null || true + rmdir "$lnbits_data" 2>/dev/null || true + fi + mkdir -p "$lnbits_data" + ;; + seed) + [[ -d "$lnbits_seed" ]] || { echo "Seed data not found at $lnbits_seed"; return 1; } + if [[ -d "$lnbits_data" ]] && [[ -n "$(ls -A "$lnbits_data" 2>/dev/null)" ]]; then + read -r -p "Overwrite with seed? [y/N] " -n 1 REPLY; echo + [[ $REPLY =~ ^[Yy]$ ]] || { echo "Aborted."; return 1; } + docker run --rm -v "$lnbits_data":/data alpine sh -c "rm -rf /data/* /data/.*" 2>/dev/null || true + rmdir "$lnbits_data" 2>/dev/null || true + fi + cp -r "$lnbits_seed" "$lnbits_data" + ;; + keep) + mkdir -p "$lnbits_data" + ;; + esac + + echo "" + echo "Starting regtest..." + echo " lnbits path: $lnbits_path" + echo " data mode: $data_mode" + echo "" + + echo "Building lnbits Docker image from $lnbits_path..." + docker build -t lnbits/lnbits "$lnbits_path" + + (cd "$REGTEST_DIR" && ./start-regtest) + + cat </dev/null \ + | grep -oP '"total_balance":\s*"\K[0-9]+' || echo 0)" + echo "" + echo "$REGTEST_LND balance: $balance sats" + else + echo "Status: STOPPED" + echo "Start with: regtest-start" + fi +} + +regtest-logs() { + _devenv_load_config + local service="${1:-}" + [[ -d "$REGTEST_DIR" ]] || { echo "Regtest not found"; return 1; } + if [[ -n "$service" ]]; then + (cd "$REGTEST_DIR" && docker compose logs -f "$service") + else + (cd "$REGTEST_DIR" && docker compose logs -f) + fi +} + +regtest-cli() { + _devenv_load_config + [[ -f "$REGTEST_DIR/docker-scripts.sh" ]] || { echo "docker-scripts.sh missing"; return 1; } + echo "Sourcing regtest CLI helpers..." + # shellcheck disable=SC1091 + source "$REGTEST_DIR/docker-scripts.sh" + cat < LND node CLI + lightning-cli-sim <1-3> C-Lightning node CLI + boltzcli-sim Boltz client CLI + elements-cli-sim Elements/Liquid CLI + +Examples: + bitcoin-cli-sim -generate 1 + lncli-sim 1 getinfo +EOF +} + +regtest() { + _devenv_load_config + [[ -d "$REGTEST_DIR" ]] && cd "$REGTEST_DIR" || { echo "Regtest not found at $REGTEST_DIR"; return 1; } +} + +#------------------------------------------------------------------------------- +# Rebuild / restart the lnbits service (docker-compose.dev.yml workflow) +#------------------------------------------------------------------------------- +# regtest-lnbits-rebuild build (cached) + recreate the lnbits container +# regtest-lnbits-rebuild --clean build --no-cache + recreate (truly fresh image) +# regtest-lnbits-restart restart the container without rebuilding +# +# The cached build is the happy path: docker invalidates the source COPY layer +# when LNBITS_SRC content changes, so most rebuilds are fast. Use --clean when +# you've been mucking with the image itself. + +_regtest_lnbits_compose_file() { + echo "$REGTEST_DIR/docker-compose.dev.yml" +} + +regtest-lnbits-rebuild() { + _devenv_load_config + local compose_file build_args=() + compose_file="$(_regtest_lnbits_compose_file)" + [[ -f "$compose_file" ]] || { echo "Compose file not found: $compose_file"; return 1; } + + if [[ "${1:-}" == "--clean" ]]; then + build_args+=(--no-cache) + fi + + (cd "$REGTEST_DIR" \ + && docker compose -f "$compose_file" build "${build_args[@]}" lnbits \ + && docker compose -f "$compose_file" up -d --force-recreate lnbits) +} + +regtest-lnbits-restart() { + _devenv_load_config + local compose_file + compose_file="$(_regtest_lnbits_compose_file)" + [[ -f "$compose_file" ]] || { echo "Compose file not found: $compose_file"; return 1; } + docker compose -f "$compose_file" restart lnbits +} diff --git a/modules/dev-env/scripts/status.sh b/modules/dev-env/scripts/status.sh new file mode 100644 index 0000000..2a7341f --- /dev/null +++ b/modules/dev-env/scripts/status.sh @@ -0,0 +1,145 @@ +#!/usr/bin/env bash +# dev-status: show divergence and dirty state of every dev-env worktree +# +# Reads /etc/dev-env/projects.json and walks every declared worktree, +# reporting dirty state and ahead/behind counts vs origin (and upstream, +# for projects that declare one). + +set -euo pipefail + +if [[ -r /etc/dev-env/config.sh ]]; then + # shellcheck disable=SC1091 + source /etc/dev-env/config.sh +fi + +PROJECTS_JSON="${DEVENV_PROJECTS_JSON:-/etc/dev-env/projects.json}" + +if [[ ! -r "$PROJECTS_JSON" ]]; then + echo "projects.json not found at $PROJECTS_JSON" >&2 + exit 1 +fi + +if ! command -v jq >/dev/null; then + echo "jq required" >&2 + exit 1 +fi + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +BOLD='\033[1m' +NC='\033[0m' + +issues=() + +header() { + echo "" + echo -e "${BOLD}${CYAN}═══════════════════════════════════════════════════════${NC}" + echo -e "${BOLD}${CYAN} $1${NC}" + echo -e "${BOLD}${CYAN}═══════════════════════════════════════════════════════${NC}" +} + +check_one() { + local label="$1" path="$2" has_upstream="${3:-false}" + + if [[ ! -d "$path/.git" ]] && [[ ! -f "$path/.git" ]]; then + printf " ${YELLOW}⚠${NC} %s: not present\n" "$label" + issues+=("$label: not present") + return + fi + + git -C "$path" fetch --all --quiet 2>/dev/null || true + + local branch icons="" status_str="" + branch="$(git -C "$path" branch --show-current 2>/dev/null || echo '?')" + + [[ -n "$(git -C "$path" status --porcelain)" ]] && { + icons+="${YELLOW}●${NC} " + status_str+="dirty " + } + + local behind ahead + behind=$(git -C "$path" rev-list --count "HEAD..origin/$branch" 2>/dev/null || echo 0) + ahead=$(git -C "$path" rev-list --count "origin/$branch..HEAD" 2>/dev/null || echo 0) + (( behind > 0 )) && icons+="${RED}↓${NC}$behind " + (( ahead > 0 )) && icons+="${GREEN}↑${NC}$ahead " + + if [[ "$has_upstream" == "true" ]] && git -C "$path" remote get-url upstream &>/dev/null; then + local ub="main" + git -C "$path" rev-parse upstream/main &>/dev/null || ub="master" + local ub_behind + ub_behind=$(git -C "$path" rev-list --count "HEAD..upstream/$ub" 2>/dev/null || echo 0) + if (( ub_behind > 0 )); then + icons+="${CYAN}⇣${NC}$ub_behind " + issues+=("$label: $ub_behind behind upstream/$ub") + fi + fi + + [[ -z "$icons" ]] && icons="${GREEN}✓${NC}" + printf " %-45s %s (%s)\n" "$label" "$icons" "$branch" +} + +header "Development Environment Status" +echo -e " ${BLUE}Date:${NC} $(date '+%Y-%m-%d %H:%M')" +echo -e " ${BLUE}Host:${NC} $(hostname)" +echo -e " ${BLUE}Root:${NC} ${DEV_ROOT:-?}" + +# Walk every project and worktree from the JSON. +mapfile -t PROJECTS < <(jq -r 'keys[]' "$PROJECTS_JSON") + +for proj in "${PROJECTS[@]}"; do + echo "" + echo -e "${BLUE}─── $proj ───${NC}" + + is_clone="$(jq -r --arg p "$proj" '.[$p].isClone' "$PROJECTS_JSON")" + has_upstream_decl="$(jq -r --arg p "$proj" '.[$p].remotes.upstream != null' "$PROJECTS_JSON")" + + if [[ "$is_clone" == "true" ]]; then + clone_path="$(jq -r --arg p "$proj" '.[$p].clonePath' "$PROJECTS_JSON")" + check_one "$proj" "$clone_path" "$has_upstream_decl" + else + while IFS=$'\t' read -r wt_name wt_path; do + [[ -z "$wt_name" || "$wt_path" == "null" ]] && continue + check_one "$proj/$wt_name" "$wt_path" "$has_upstream_decl" + done < <(jq -r --arg p "$proj" ' + .[$p].worktrees + | to_entries[] + | "\(.key)\t\(.value.path)" + ' "$PROJECTS_JSON") + fi +done + +# Docker +echo "" +echo -e "${BLUE}─── Docker ───${NC}" +if command -v docker &>/dev/null; then + running="$(docker ps --format '{{.Names}}' 2>/dev/null | wc -l)" + echo -e " ${BLUE}Containers running:${NC} $running" +fi + +header "Summary" +if (( ${#issues[@]} == 0 )); then + echo -e " ${GREEN}✓ All worktrees are clean and in sync!${NC}" +else + echo -e " ${YELLOW}Issues found:${NC}" + for issue in "${issues[@]}"; do + echo -e " ${YELLOW}•${NC} $issue" + done +fi + +cat < fetch upstream + show divergence + rebase status which forks need rebasing onto upstream + dev-env-bootstrap materialize missing worktrees + +EOF diff --git a/modules/dev-env/scripts/tmux-launch.sh b/modules/dev-env/scripts/tmux-launch.sh new file mode 100644 index 0000000..031b468 --- /dev/null +++ b/modules/dev-env/scripts/tmux-launch.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash +# dev-tm: launch a declared tmux session +# +# Reads /etc/dev-env/tmux-sessions.json (rendered by config.nix) and +# either attaches to an existing session or creates one with the +# declared windows. Window cwds are resolved relative to $DEV_ROOT. +# +# This is a single generic launcher; the window layouts come from Nix +# (or a runtime override at ~/.config/dev-env/tmux-sessions.json). + +set -euo pipefail + +if [[ -r /etc/dev-env/config.sh ]]; then + # shellcheck disable=SC1091 + source /etc/dev-env/config.sh +fi + +DEV_ROOT="${DEV_ROOT:-$HOME/dev}" + +# Prefer a user override if present +SESSIONS_JSON="" +if [[ -r "$HOME/.config/dev-env/tmux-sessions.json" ]]; then + SESSIONS_JSON="$HOME/.config/dev-env/tmux-sessions.json" +elif [[ -r /etc/dev-env/tmux-sessions.json ]]; then + SESSIONS_JSON=/etc/dev-env/tmux-sessions.json +else + echo "No tmux-sessions.json found" >&2 + exit 1 +fi + +usage() { + cat < start or attach to + dev-tm -k kill session + dev-tm -k all kill all dev sessions + dev-tm -l list available sessions +EOF +} + +list_sessions() { + echo "Defined sessions (from $SESSIONS_JSON):" + jq -r 'keys[]' "$SESSIONS_JSON" | sed 's/^/ /' + echo "" + echo "Active tmux sessions:" + tmux list-sessions 2>/dev/null | sed 's/^/ /' || echo " (none)" +} + +resolve_path() { + local p="$1" + [[ -z "$p" || "$p" == "null" ]] && { echo "$DEV_ROOT"; return; } + [[ "$p" = /* ]] && { echo "$p"; return; } + echo "$DEV_ROOT/$p" +} + +start_session() { + local name="$1" + local session_name="dev-$name" + + if tmux has-session -t "$session_name" 2>/dev/null; then + tmux attach-session -t "$session_name" + return + fi + + local session_cwd + session_cwd="$(resolve_path "$(jq -r --arg s "$name" '.[$s].cwd // empty' "$SESSIONS_JSON")")" + + local nwindows + nwindows="$(jq --arg s "$name" '.[$s].windows | length' "$SESSIONS_JSON")" + if (( nwindows == 0 )); then + echo "Session '$name' has no windows defined" >&2 + exit 1 + fi + + # First window + local first_name first_cwd first_cmd + first_name="$(jq -r --arg s "$name" '.[$s].windows[0].name' "$SESSIONS_JSON")" + first_cwd="$(resolve_path "$(jq -r --arg s "$name" '.[$s].windows[0].cwd // empty' "$SESSIONS_JSON")")" + first_cmd="$(jq -r --arg s "$name" '.[$s].windows[0].cmd // empty' "$SESSIONS_JSON")" + + tmux new-session -d -s "$session_name" -n "$first_name" -c "$first_cwd" + [[ -n "$first_cmd" && "$first_cmd" != "null" ]] && \ + tmux send-keys -t "$session_name:0" "$first_cmd" C-m + + # Remaining windows + for ((i = 1; i < nwindows; i++)); do + local wn wcwd wcmd + wn="$(jq -r --arg s "$name" --argjson i "$i" '.[$s].windows[$i].name' "$SESSIONS_JSON")" + wcwd="$(resolve_path "$(jq -r --arg s "$name" --argjson i "$i" '.[$s].windows[$i].cwd // empty' "$SESSIONS_JSON")")" + wcmd="$(jq -r --arg s "$name" --argjson i "$i" '.[$s].windows[$i].cmd // empty' "$SESSIONS_JSON")" + tmux new-window -t "$session_name" -n "$wn" -c "$wcwd" + [[ -n "$wcmd" && "$wcmd" != "null" ]] && \ + tmux send-keys -t "$session_name:$i" "$wcmd" C-m + done + + tmux select-window -t "$session_name:0" + tmux attach-session -t "$session_name" +} + +kill_session() { + local target="$1" + if [[ "$target" == "all" ]]; then + tmux list-sessions -F '#{session_name}' 2>/dev/null \ + | grep '^dev-' \ + | xargs -r -n1 tmux kill-session -t \ + || echo "No dev sessions to kill" + return + fi + tmux kill-session -t "dev-$target" 2>/dev/null || echo "Session 'dev-$target' not found" +} + +# --- entry --- +case "${1:-}" in + -h|--help) usage ;; + -l|--list|"") list_sessions ;; + -k|--kill) + [[ -z "${2:-}" ]] && { echo "Usage: dev-tm -k "; exit 1; } + kill_session "$2" + ;; + *) + if ! jq -e --arg s "$1" 'has($s)' "$SESSIONS_JSON" >/dev/null; then + echo "Unknown session: $1" >&2 + list_sessions + exit 1 + fi + start_session "$1" + ;; +esac diff --git a/modules/dev-env/scripts/worktree.sh b/modules/dev-env/scripts/worktree.sh new file mode 100644 index 0000000..69ad317 --- /dev/null +++ b/modules/dev-env/scripts/worktree.sh @@ -0,0 +1,275 @@ +#!/usr/bin/env bash +# dev-env: git worktree helpers +# +# Sourced into interactive shells. Provides: +# - wt / wts / wtu generic worktree listing / sync / upstream-fetch +# - wtn / worktree-spawn create a new branch + worktree from any project +# - lnbits-status show dev/main divergence vs upstream +# - lnbits-sync-dev merge upstream/dev into the dev worktree +# - lnbits-sync-main merge upstream/main into the main worktree +# +# The bare-repo path comes from /etc/dev-env/config.sh. + +_devenv_load_config() { + if [[ -r /etc/dev-env/config.sh ]]; then + # shellcheck disable=SC1091 + source /etc/dev-env/config.sh + fi +} + +#------------------------------------------------------------------------------- +# Generic worktree helpers +#------------------------------------------------------------------------------- + +worktree-list() { + _devenv_load_config + local repo_name="${1:-}" + local repos_dir="${REPOS_DIR:-$DEV_ROOT/repos}" + + if [[ -n "$repo_name" ]]; then + local bare_repo="$repos_dir/${repo_name}.git" + if [[ -d "$bare_repo" ]]; then + git -C "$bare_repo" worktree list + else + echo "Repo not found: $repo_name" + return 1 + fi + else + echo "=== All Worktrees ===" + for repo in "$repos_dir"/*.git; do + [[ -d "$repo" ]] || continue + echo "" + echo "--- $(basename "$repo" .git) ---" + git -C "$repo" worktree list + done + fi +} + +worktree-sync() { + _devenv_load_config + local repos_dir="${REPOS_DIR:-$DEV_ROOT/repos}" + echo "=== Syncing Worktrees ===" + for repo in "$repos_dir"/*.git; do + [[ -d "$repo" ]] || continue + local name + name="$(basename "$repo" .git)" + echo "" + echo "--- $name ---" + git -C "$repo" fetch --all --quiet 2>/dev/null || true + git -C "$repo" worktree list | while read -r line; do + # format: [] + local path branch status + path="$(awk '{print $1}' <<<"$line")" + branch="$(awk '{print $3}' <<<"$line" | tr -d '[]')" + if [[ -d "$path" ]]; then + status="$(git -C "$path" status -sb 2>/dev/null | head -1)" + printf " %-20s %s\n" "${branch:-?}" "$status" + fi + done + done +} + +worktree-update-upstream() { + _devenv_load_config + local repo_name="${1:-}" + local repos_dir="${REPOS_DIR:-$DEV_ROOT/repos}" + + if [[ -z "$repo_name" ]]; then + echo "Usage: wtu " + return 1 + fi + + local bare_repo="$repos_dir/${repo_name}.git" + if [[ ! -d "$bare_repo" ]]; then + echo "Repo not found: $bare_repo" + return 1 + fi + + if ! git -C "$bare_repo" remote get-url upstream &>/dev/null; then + echo "No upstream remote configured for $repo_name" + return 1 + fi + + echo "Fetching upstream..." + git -C "$bare_repo" fetch upstream + + echo "" + echo "=== Upstream Status: $repo_name ===" + + # Determine upstream base branch (main or master). + local base_branch="main" + git -C "$bare_repo" show-ref --verify --quiet refs/remotes/upstream/main || base_branch="master" + + # Show each local branch's divergence vs the upstream base. + git -C "$bare_repo" for-each-ref --format='%(refname:short)' refs/heads | while read -r br; do + local behind ahead + behind=$(git -C "$bare_repo" rev-list --count "$br..upstream/$base_branch" 2>/dev/null || echo ?) + ahead=$(git -C "$bare_repo" rev-list --count "upstream/$base_branch..$br" 2>/dev/null || echo ?) + printf " %-24s behind:%s ahead:%s\n" "$br" "$behind" "$ahead" + done +} + +#------------------------------------------------------------------------------- +# Spawn a new worktree (== branch) from an existing project. +#------------------------------------------------------------------------------- +# Usage: worktree-spawn [base-branch] +# +# Locates the bare repo, creates from (default +# main, else master), and adds a worktree at the project root next to the +# base worktree. + +worktree-spawn() { + _devenv_load_config + local repo_name="${1:-}" + local new_branch="${2:-}" + local base_branch="${3:-}" + + if [[ -z "$repo_name" || -z "$new_branch" ]]; then + echo "Usage: worktree-spawn [base-branch]" + return 1 + fi + + local repos_dir="${REPOS_DIR:-$DEV_ROOT/repos}" + local bare_repo="$repos_dir/${repo_name}.git" + + [[ -d "$bare_repo" ]] || { echo "Repo not found: $bare_repo"; return 1; } + + if [[ -z "$base_branch" ]]; then + if git -C "$bare_repo" show-ref --verify --quiet refs/heads/main; then + base_branch="main" + elif git -C "$bare_repo" show-ref --verify --quiet refs/heads/master; then + base_branch="master" + else + echo "No main/master in $repo_name; pass base branch explicitly." + return 1 + fi + fi + + local base_path + base_path="$(git -C "$bare_repo" worktree list --porcelain | awk -v t="refs/heads/$base_branch" ' + /^worktree / { wt = $2 } + /^branch / && $2 == t { print wt; exit } + ')" + [[ -n "$base_path" ]] || { echo "No worktree for $repo_name@$base_branch"; return 1; } + + local project_root new_path + project_root="$(dirname "$base_path")" + new_path="$project_root/$new_branch" + + [[ -e "$new_path" ]] && { echo "Path already exists: $new_path"; return 1; } + + echo "Creating branch '$new_branch' from '$base_branch' in $repo_name..." + git -C "$bare_repo" branch "$new_branch" "$base_branch" || return 1 + + echo "Adding worktree at $new_path..." + git -C "$bare_repo" worktree add "$new_path" "$new_branch" || return 1 + + echo "" + echo "Ready: $new_path" + [[ -f "$new_path/package.json" ]] && echo "Next: cd $new_path && pnpm install" +} + +#------------------------------------------------------------------------------- +# lnbits fork workflow helpers +#------------------------------------------------------------------------------- +# Mental model: +# Your `dev` and `main` worktrees track a personal fork of lnbits that +# diverges from upstream and carries your own commits on top. The sync +# helpers below `merge upstream/` to vendor upstream changes — +# they don't fast-forward. `lnbits-status` reports divergence vs +# upstream so you know how stale the vendor base is. +# +# Adjust the `dev`/`main` worktree names to whatever your lnbits +# project declares in devEnv.projects.lnbits.worktrees. + +_lnbits_paths() { + _devenv_load_config + BARE="${REPOS_DIR:-$DEV_ROOT/repos}/lnbits.git" + LNBITS_ROOT="${LNBITS_DIR:-$DEV_ROOT/lnbits}" +} + +lnbits-status() { + _lnbits_paths + if [[ ! -d "$BARE" ]]; then + echo "lnbits bare repo not found: $BARE" + return 1 + fi + + git -C "$BARE" fetch upstream --quiet 2>/dev/null || true + git -C "$BARE" fetch origin --quiet 2>/dev/null || true + + echo "=== lnbits Branch Status ===" + echo "" + echo "Branch layout (your branches diverge from upstream; sync via merge):" + echo " dev = your staging (vendored from upstream/dev)" + echo " main = your stable (vendored from upstream/main)" + echo "" + + for env in dev main; do + local wt="$LNBITS_ROOT/$env" + if [[ ! -d "$wt" ]]; then + printf " %-6s: (worktree not created)\n" "$env" + continue + fi + local branch behind_u ahead_u + branch="$(git -C "$wt" branch --show-current)" + case "$env" in + dev) + behind_u=$(git -C "$BARE" rev-list --count "$branch..upstream/dev" 2>/dev/null || echo ?) + ahead_u=$(git -C "$BARE" rev-list --count "upstream/dev..$branch" 2>/dev/null || echo ?) + printf " dev: %-3s behind upstream/dev %-3s ahead\n" "$behind_u" "$ahead_u" + ;; + main) + behind_u=$(git -C "$BARE" rev-list --count "$branch..upstream/main" 2>/dev/null || echo ?) + ahead_u=$(git -C "$BARE" rev-list --count "upstream/main..$branch" 2>/dev/null || echo ?) + printf " main: %-3s behind upstream/main %-3s ahead\n" "$behind_u" "$ahead_u" + ;; + esac + done + + echo "" + echo "Commands:" + echo " lnbits-sync-dev # merge upstream/dev into dev" + echo " lnbits-sync-main # merge upstream/main into main worktree" +} + +lnbits-sync-dev() { + _lnbits_paths + local dev_dir="$LNBITS_ROOT/dev" + [[ -d "$BARE" ]] || { echo "bare repo missing: $BARE"; return 1; } + [[ -d "$dev_dir" ]] || { echo "dev worktree missing: $dev_dir"; return 1; } + + echo "Fetching upstream..." + git -C "$BARE" fetch upstream + + echo "Merging upstream/dev into dev..." + git -C "$dev_dir" merge upstream/dev + + echo "" + echo "Dev synced. Review, then: lb dev && git push" +} + +lnbits-sync-main() { + _lnbits_paths + local main_dir="$LNBITS_ROOT/main" + [[ -d "$BARE" ]] || { echo "bare repo missing: $BARE"; return 1; } + [[ -d "$main_dir" ]] || { echo "main worktree missing: $main_dir"; return 1; } + + echo "Fetching upstream..." + git -C "$BARE" fetch upstream + + echo "Merging upstream/main into main worktree..." + git -C "$main_dir" merge upstream/main + + echo "" + echo "Main synced. Review, then: lb main && git push" +} + +# Aliases keep the short two-letter shortcuts. +alias wt='worktree-list' +alias wts='worktree-sync' +alias wtu='worktree-update-upstream' +alias wtn='worktree-spawn' +alias lbs='lnbits-status' +alias lbsd='lnbits-sync-dev' +alias lbsm='lnbits-sync-main' diff --git a/modules/dev-env/tests/smoke.nix b/modules/dev-env/tests/smoke.nix new file mode 100644 index 0000000..6c07270 --- /dev/null +++ b/modules/dev-env/tests/smoke.nix @@ -0,0 +1,153 @@ +# Standalone smoke test for the dev-env module. +# +# Builds a minimal nixosConfiguration that imports the dev-env module +# (plus core.nix for the `lnbits-sensei.*` option namespace and a +# minimum-bootable stub) and exercises a representative slice of the +# option schema: +# +# - worktree-based project with upstream + fork (3 remotes) +# - worktree-based project without upstream (origin only) +# - isClone project with upstream +# - pure-upstream project (origin falls back to upstream) +# - minimal project (mkProject defaults) +# - tmux session schema +# - deploy targets +# +# Consumed by the top-level flake.nix as `checks.${system}.dev-env-smoke` +# so `nix flake check` catches schema regressions without building a +# full system. +# +# This is NOT bootable as a real system — the fileSystem/bootloader +# stubs only exist to satisfy module evaluation. Do not try to deploy. +{ nixpkgs, home-manager }: + +nixpkgs.lib.nixosSystem { + system = "x86_64-linux"; + modules = [ + home-manager.nixosModules.home-manager + + # The option namespace (lnbits-sensei.*) and the dev-env module. + ../../core.nix + ../default.nix + + # Minimal host stub + dev-env exercise. + ( + { config, ... }: + { + # ---- Minimum bootable stubs (never actually booted) ---- + + fileSystems."/" = { + device = "none"; + fsType = "tmpfs"; + }; + boot.loader.systemd-boot.enable = true; + boot.loader.efi.canTouchEfiVariables = true; + nixpkgs.hostPlatform = "x86_64-linux"; + system.stateVersion = "24.11"; + + # home-manager wants these set even with zero real users. + home-manager.useGlobalPkgs = true; + home-manager.useUserPackages = true; + + # ---- Exercise the dev-env schema ---- + + lnbits-sensei = { + enable = true; + user = "smoke"; + + devEnv = { + enable = true; + scaffoldPath = "/home/smoke/dev/lnbits-sensei"; + + # Explicit root — don't depend on the user-derived default. + root = "/tmp/dev-env-smoke"; + + github.forkUser = "testuser"; + + deploy.targets = { + host-a = "root@10.0.0.1"; + host-b = "root@10.0.0.2"; + }; + + # Disable regtest to avoid dragging docker into the eval. + regtest.enable = false; + + tmux = { + enable = true; + sessions.dev = { + cwd = "lnbits"; + windows = [ + { + name = "dev"; + cwd = "lnbits/dev"; + cmd = "nvim ."; + } + { + name = "term"; + cwd = "lnbits/dev"; + cmd = null; + } + ]; + }; + }; + + # Use mkProject so we exercise the lib helper in addition to + # the raw submodule schema. + projects = + let + mk = config.lnbits-sensei.devEnv.lib.mkProject; + in + { + # Worktree-based with upstream + fork (3 remotes). + worktree-with-upstream = mk { + name = "worktree-with-upstream"; + category = "shared"; + url = "git@github.com:testuser/worktree-with-upstream.git"; + upstream = "https://github.com/upstream-org/worktree-with-upstream"; + worktrees = { + main.branch = "main"; + dev.branch = "dev"; + feature.branch = "feature/x"; + }; + }; + + # Worktree-based, no upstream (origin only). + worktree-no-upstream = mk { + name = "worktree-no-upstream"; + category = "shared"; + url = "git@github.com:testuser/worktree-no-upstream.git"; + worktrees = { + alpha.branch = "alpha"; + beta.branch = "beta"; + }; + }; + + # Single-clone with upstream (rebase helper target). + clone-with-upstream = mk { + name = "clone-with-upstream"; + category = "test"; + url = "git@github.com:testuser/clone-with-upstream.git"; + upstream = "https://github.com/upstream-org/clone-with-upstream"; + isClone = true; + }; + + # Pure-upstream — origin falls back to upstream. + pure-upstream = mk { + name = "pure-upstream"; + category = "refs"; + upstream = "https://github.com/upstream-org/pure-upstream"; + worktrees.master.branch = "master"; + }; + + # Minimal project — mkProject defaults fill everything. + minimal = mk { + name = "minimal"; + url = "git@github.com:testuser/minimal.git"; + }; + }; + }; + }; + } + ) + ]; +}