From 789083d32fa48cb2a8277e39b6337073719d5bdc Mon Sep 17 00:00:00 2001 From: jackjohn7 <70782491+jackjohn7@users.noreply.github.com> Date: Thu, 9 Apr 2026 02:57:23 -0500 Subject: [PATCH] add boilerplate generation CLI --- .envrc | 1 + .gitignore | 2 + modules/packages/boilerplate/boilerplate.py | 233 ++++++++++++++++++++ modules/packages/boilerplate/default.nix | 32 +++ modules/shell.nix | 25 +++ 5 files changed, 293 insertions(+) create mode 100644 .envrc create mode 100644 .gitignore create mode 100644 modules/packages/boilerplate/boilerplate.py create mode 100644 modules/packages/boilerplate/default.nix create mode 100644 modules/shell.nix diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..00aeac2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +**/__pycache__ +.direnv/ diff --git a/modules/packages/boilerplate/boilerplate.py b/modules/packages/boilerplate/boilerplate.py new file mode 100644 index 0000000..97966a1 --- /dev/null +++ b/modules/packages/boilerplate/boilerplate.py @@ -0,0 +1,233 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import argparse +import json +import re +import subprocess +import sys +import textwrap +from pathlib import Path + + +KIND_LAYOUTS = { + "layer": Path("modules/features/layers"), + "feature": Path("modules/features"), + "host": Path("modules/hosts"), + "package": Path("modules/packages"), +} + + +NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]*$") + + +def nix_string(value: str) -> str: + return json.dumps(value) + + +def git_root() -> Path | None: + result = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + check=False, + capture_output=True, + text=True, + ) + if result.returncode != 0: + return None + return Path(result.stdout.strip()) + + +def host_templates(name: str) -> dict[Path, str]: + return { + Path("default.nix"): + textwrap.dedent( + f"""\ + {{ self, inputs, ... }}: + {{ + flake.nixosConfigurations.{name} = inputs.nixpkgs.lib.nixosSystem {{ + modules = [ + self.nixosModules.{name}Configuration + ]; + }}; + }} + """ + ), + Path("configuration.nix"): + textwrap.dedent( + f"""\ + {{ self, inputs, ... }}: + {{ + flake.nixosModules.{name}Configuration = + {{ pkgs, lib, ... }}: + {{ + imports = [ + self.nixosModules.{name}Hardware + ]; + }}; + }} + """ + ), + Path("hardware-configuration.nix"): + textwrap.dedent( + f"""\ + {{ self, inputs, ... }}: + {{ + flake.nixosModules.{name}Hardware = + {{ lib, pkgs, ... }}: + {{ + config = {{ + # Copy the generated hardware configuration here. + }}; + }}; + }} + """ + ), + } + + +def template(kind: str, name: str) -> str: + if kind in {"layer", "feature"}: + return textwrap.dedent( + f"""\ + {{ self, inputs, ... }}: + {{ + flake.nixosModules.{name} = {{ pkgs, lib, ... }}: + {{ + config = {{ + }}; + }}; + }} + """ + ) + + if kind == "host": + quoted_name = nix_string(name) + return textwrap.dedent( + f"""\ + {{ self, inputs, ... }}: + {{ + flake.nixosModules.{name} = {{ pkgs, lib, ... }}: + {{ + config = {{ + networking.hostName = {quoted_name}; + # system.stateVersion = "25.11"; + }}; + }}; + + flake.nixosConfigurations.{name} = inputs.nixpkgs.lib.nixosSystem {{ + modules = [ + self.nixosModules.{name} + ]; + }}; + }} + """ + ) + + if kind == "package": + quoted_name = nix_string(name) + return textwrap.dedent( + f"""\ + {{ self, inputs, ... }}: + {{ + perSystem = + {{ pkgs, ... }}: + {{ + packages.{name} = pkgs.writeShellApplication {{ + name = {quoted_name}; + runtimeInputs = [ ]; + text = '' + echo "TODO: implement {name}" + ''; + }}; + }}; + }} + """ + ) + + raise ValueError(f"Unsupported kind: {kind}") + + +def planned_files(kind: str, name: str, multifile: bool) -> dict[Path, str]: + if kind == "host": + return host_templates(name) + + target = Path("default.nix") if multifile else Path(f"{name}.nix") + return {target: template(kind, name)} + + +def write_file(path: Path, content: str) -> None: + if path.exists(): + raise FileExistsError(f"Refusing to overwrite existing path: {path}") + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content.rstrip() + "\n", encoding="utf-8") + + +def stage(paths: list[Path], root: Path) -> None: + subprocess.run( + ["git", "add", "--", *[str(path.relative_to(root)) for path in paths]], + check=True, + cwd=root, + ) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Generate Nix boilerplate modules") + parser.add_argument("kind", choices=sorted(KIND_LAYOUTS)) + parser.add_argument("name") + parser.add_argument("--no-git-stage", action="store_true", help="Do not stage created files") + parser.add_argument("--dry-run", action="store_true", help="Print planned files without creating them") + parser.add_argument( + "--multifile", + action="store_true", + help="Create /default.nix instead of .nix", + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + + if not NAME_RE.fullmatch(args.name): + print( + "name must match ^[A-Za-z0-9][A-Za-z0-9_.-]*$", + file=sys.stderr, + ) + return 2 + + root = git_root() + if root is None: + if args.dry_run or args.no_git_stage: + root = Path.cwd() + else: + print("not inside a git repository; use --no-git-stage", file=sys.stderr) + return 2 + + files = planned_files(args.kind, args.name, args.multifile) + base = root / KIND_LAYOUTS[args.kind] + target_base = base / args.name if args.kind == "host" else base + targets = [target_base / rel for rel in files] + + if args.dry_run: + print("dry run: no files will be created") + for target in targets: + print(target.relative_to(root)) + return 0 + + try: + for rel_path, content in files.items(): + write_file(target_base / rel_path, content) + except FileExistsError as err: + print(err, file=sys.stderr) + return 1 + + if not args.no_git_stage: + stage(targets, root) + + for target in targets: + print(target.relative_to(root)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/modules/packages/boilerplate/default.nix b/modules/packages/boilerplate/default.nix new file mode 100644 index 0000000..9498389 --- /dev/null +++ b/modules/packages/boilerplate/default.nix @@ -0,0 +1,32 @@ +{ self, inputs, ... }: +{ + perSystem = + { pkgs, lib, ... }: + { + packages.boilerplate = pkgs.stdenvNoCC.mkDerivation { + pname = "boilerplate"; + version = "0.1.0"; + + src = ./.; + + dontConfigure = true; + dontBuild = true; + + installPhase = '' + runHook preInstall + + substituteInPlace boilerplate.py \ + --replace-fail '#!/usr/bin/env python3' '#!${pkgs.python3}/bin/python3' + install -Dm755 boilerplate.py "$out/bin/boilerplate" + + runHook postInstall + ''; + + meta = { + description = "Generate boilerplate Nix modules"; + license = lib.licenses.mit; + mainProgram = "boilerplate"; + }; + }; + }; +} diff --git a/modules/shell.nix b/modules/shell.nix new file mode 100644 index 0000000..fd0e49c --- /dev/null +++ b/modules/shell.nix @@ -0,0 +1,25 @@ +{ ... }: +{ + perSystem = + { pkgs, self', ... }: + { + devShells.default = pkgs.mkShell { + buildInputs = with pkgs; [ + bacon + cargo + rust-analyzer + rustc + rustfmt + clippy + glibc + sea-orm-cli + nixfmt + nil + alejandra + self'.packages.boilerplate + ]; + nativeBuildInputs = [ pkgs.pkg-config ]; + env.RUST_SRC_PATH = "${pkgs.rust.packages.stable.rustPlatform.rustLibSrc}"; + }; + }; +}