feat(dev-env): backport matured dev-env implementation from /etc/nixos

Replace the stub dev-env with the real, working implementation that grew
in the reference machine config — de-identified for the public scaffold.

Nix layer:
- options.nix: full project schema (url/upstream/fork/category/
  worktreeRoot/worktrees{branch,path,remote}/isClone/deployFlakeInput),
  deploy.targets, github.forkUser, writeDirenvHints. Drops the
  forgejo-URL block + deploy-flake auto-derivation (incoherent in a
  scaffold that uses explicit per-project urls).
- lib.nix: mkProject + worktreePath/bareRepoPath/projectRemotes,
  generalized to the explicit-url model (origin falls back to upstream).
- config.nix: renders /etc/dev-env/{config.sh,projects.json,
  tmux-sessions.json}, installs helpers via writeShellScriptBin, loads
  shell functions into interactive shells, wires the git pre-commit hook.

Scripts (config-driven, read /etc/dev-env at runtime):
- bootstrap.sh, nav.sh, worktree.sh, pr-helpers.sh, rebase.sh,
  status.sh, deploy.sh, regtest.sh, tmux-launch.sh.
- Stripped aiolabs/forgejo/bitspire/lamassu/webapp hardcoding; the
  github-fork remote is renamed 'fork' to match git.remotes vocabulary.
- Removes the dev.sh stub (the matured impl uses discrete commands +
  shell functions, not a unified 'dev' CLI).

presets/example.nix: a worked, generic project list replacing the
identity-specific aiolabs preset. tests/smoke.nix + flake checks
exercise the schema; 'nix flake check' is green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-06-15 21:18:49 +02:00
commit e38d313db2
17 changed files with 2925 additions and 147 deletions

View file

@ -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;

112
modules/dev-env/README.md Normal file
View file

@ -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 .#<host>
# 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 <name> # → ~/dev/shared/extensions/<name>
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 <session>` | Launch a declarative tmux session. |
| `dev-deploy <host>` | `nixos-rebuild` against your deploy flake. |
| `rebase [status\|all\|<path>]` | 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.

View file

@ -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_<HOST>
# 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} =
{ ... }:

View file

@ -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.<name>`.
#
# 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}/<worktree>; otherwise
# ${root}/${worktreeRoot}/<worktree>.
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/<basename>.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: <root>/<project>/<worktree>.
worktreePath =
_project: _worktree: throw "dev-env.lib.worktreePath: not yet implemented";
inherit
mkProject
worktreePath
bareRepoPath
projectRemotes
;
};
in
{

View file

@ -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/<name>.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/<basename>.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}/<key>` (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 <name>` 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,22 +203,114 @@ 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}/<category>/<project>/`.
'';
};
gitHooks = {
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:<forkUser>/<basename>.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_<HOST>` 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 <name>` 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
'';
secrets and unencrypted sops files; false positives are
handled via `# pragma: allowlist secret` markers
''
// {
default = true;
};
};
claude = {
@ -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.
'';
};
};
};
}

View file

@ -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";
}
];
};
};
};
}

View file

@ -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}/<name>.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 <<EOF
dev-env-bootstrap — materialize bare repos + worktrees from /etc/dev-env/projects.json
USAGE:
dev-env-bootstrap [OPTIONS] [project-name]
OPTIONS:
-n, --dry-run show what would happen, do not change anything
-v, --verbose print every git command
-f, --force-remotes silently rewrite remote URLs that differ from spec
-h, --help show this help
EXAMPLES:
dev-env-bootstrap --dry-run # full preview
dev-env-bootstrap # bring everything up to spec
dev-env-bootstrap lnbits # only operate on the lnbits project
Reads from: $PROJECTS_JSON
Writes to: $DEV_ROOT
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
-n|--dry-run) DRY_RUN=true ;;
-v|--verbose) VERBOSE=true ;;
-f|--force-remotes) FORCE_REMOTES=true ;;
-h|--help) usage; exit 0 ;;
-*) echo "unknown option: $1"; usage; exit 1 ;;
*) ONLY_PROJECT="$1" ;;
esac
shift
done
# --- Output -----------------------------------------------------------
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
BLUE='\033[0;34m'
DIM='\033[2m'
NC='\033[0m'
info() { echo -e "${BLUE}[..]${NC} $*"; }
ok() { echo -e "${GREEN}[ok]${NC} $*"; }
warn() { echo -e "${YELLOW}[!!]${NC} $*"; }
err() { echo -e "${RED}[XX]${NC} $*" >&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-name<TAB>url" 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 "wtname<TAB>branch<TAB>path<TAB>remote" 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

View file

@ -0,0 +1,217 @@
#!/usr/bin/env bash
# dev-deploy: thin wrapper around your unified deploy flake
#
# Usage:
# dev-deploy <host> switch on host (uses locked deploy flake input)
# dev-deploy <host> test test build (no switch)
# dev-deploy <host> build local build only
# dev-deploy --local <host> 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[/<flakeInput>].
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 <<EOF
dev-deploy — wraps nixos-rebuild against your unified deploy flake
USAGE:
dev-deploy <host> deploy (switch)
dev-deploy <host> test test build (no switch)
dev-deploy <host> build local build only (no target)
dev-deploy --local <host> deploy with --override-input from local worktrees
dev-deploy --local <host> 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_<HOST>=<ssh> (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 <input> path:<worktree-path>
# Picks the first worktree by default; pass DEVENV_OVERRIDE_WORKTREE=<name>
# 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

View file

@ -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

View file

@ -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 <worktree>"
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 <category> [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 <category> [repo] [worktree]
g() {
_devenv_load_config
local category="${1:-}"
if [[ -z "$category" ]]; then
echo "Usage: g <category> [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 <host>"
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
}

View file

@ -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}/<repo>-<branch> 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}/<repo>-<branch>.
#
# Usage: git-pr-branch <repo-name> <branch-name>
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 <repo-name> <branch-name>"
echo "Example: git-pr-branch lnbits fix-invoice-bug"
echo ""
echo "Creates a worktree at $prs_dir/<repo>-<branch>"
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:-<user>}/${repo_name}.git"
fi
cat <<EOF
Ready! Your PR worktree is at:
cd $pr_path
Workflow:
1. cd $pr_path
2. Edit, test, commit
3. git push fork $branch_name
4. Open the PR on GitHub (against upstream/$base_branch)
5. After merge: git-pr-cleanup $repo_name $branch_name
EOF
}
# Remove a PR worktree after the PR is merged (or abandoned).
git-pr-cleanup() {
_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-cleanup <repo-name> <branch-name>"
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'

View file

@ -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 <<EOF
${BOLD}${YELLOW}Conflict Resolution Guide:${NC}
1. Check conflicts: ${CYAN}git status${NC}
2. Resolve markers <<<<<<< ======= >>>>>>>
3. Stage: ${CYAN}git add <file>${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 "<path>:<display-name>" 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 <<EOF
${BOLD}rebase${NC} — Safely rebase forks onto upstream
USAGE:
rebase Interactive mode
rebase single <path> 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 <name> 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 "$@"

View file

@ -0,0 +1,267 @@
#!/usr/bin/env bash
# dev-env: Bitcoin/Lightning regtest docker environment
#
# Provides:
# regtest-start [worktree|pr:<branch>] [--path <dir>] [--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:<branch>] [--path <dir>] [--seed|--keep]
#
# worktree an lnbits worktree name (under ~/dev/lnbits/<name>)
# pr:<branch> ~/dev/upstream-prs/lnbits-<branch>
# --path <dir> 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 <<USAGE
Unknown argument: $1
Usage: regtest-start [worktree|pr:<branch>] [--path <dir>] [--seed|--keep]
worktree an lnbits worktree name (under ~/dev/lnbits/<name>)
pr:<branch> ~/dev/upstream-prs/lnbits-<branch>
--path <dir> 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 <<EOF
Regtest running:
LNbits: http://localhost:5001/
Mempool: http://localhost:8080/
Boltz: http://localhost:9001/
EOF
echo "Commands: regtest-stop, regtest-logs, regtest-cli, regtest-status"
}
#-------------------------------------------------------------------------------
# Stop
#-------------------------------------------------------------------------------
regtest-stop() {
_devenv_load_config
if [[ ! -d "$REGTEST_DIR" ]]; then
echo "Regtest environment not found"
return 1
fi
echo "Stopping regtest environment..."
(cd "$REGTEST_DIR"
# shellcheck disable=SC1091
source ./docker-scripts.sh
docker compose down -v)
echo "Regtest stopped"
}
#-------------------------------------------------------------------------------
# Status / logs / CLI
#-------------------------------------------------------------------------------
regtest-status() {
_devenv_load_config
echo "=== Regtest Environment ==="
if _regtest_is_running; then
echo "Status: RUNNING"
echo ""
docker ps --filter "name=lnbits-" --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" | head -15
local balance
balance="$(docker exec "$REGTEST_LND" lncli --network=regtest walletbalance 2>/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 <<EOF
Available commands:
bitcoin-cli-sim Bitcoin Core CLI
lncli-sim <1-4> 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
}

View file

@ -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 <<EOF
${BLUE}Legend:${NC}
${GREEN}${NC} clean ${YELLOW}${NC} dirty ${GREEN}${NC} ahead origin
${RED}${NC} behind origin ${CYAN}${NC} behind upstream
${BLUE}Quick actions:${NC}
wts sync all worktrees with origin
wtu <repo> fetch upstream + show divergence
rebase status which forks need rebasing onto upstream
dev-env-bootstrap materialize missing worktrees
EOF

View file

@ -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 <<EOF
dev-tm — declarative tmux session launcher
USAGE:
dev-tm list available sessions
dev-tm <session> start or attach to <session>
dev-tm -k <session> 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 <session|all>"; 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

View file

@ -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: <path> <sha> [<branch>]
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 <repo-name>"
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 <repo> <new-branch> [base-branch]
#
# Locates the bare repo, creates <new-branch> from <base-branch> (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 <repo-name> <new-branch-name> [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/<branch>` 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'

View file

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