import logging from collections.abc import Callable from dataclasses import dataclass from pathlib import Path from clan_lib.api import API from clan_lib.cmd import Log, RunOpts, run from clan_lib.dirs import clan_templates from clan_lib.errors import ClanError from clan_lib.flake import Flake from clan_lib.nix import nix_command, nix_metadata, nix_shell from clan_lib.nix_models.clan import InventoryMeta from clan_lib.persist.inventory_store import InventoryStore from clan_lib.persist.patch_engine import merge_objects from clan_lib.persist.path_utils import set_value_by_path from clan_lib.templates.handler import clan_template from clan_lib.validator.hostname import hostname log = logging.getLogger(__name__) @dataclass class CreateOptions: dest: Path template: str src_flake: Flake | None = None setup_git: bool = True initial: InventoryMeta | None = None update_clan: bool = True # -- Internal use only -- # # Post-processing hook to make flakes offline testable _postprocess_flake_hook: Callable[[Path], None] | None = None def validate(self) -> None: if self.initial and "name" in self.initial: try: hostname(self.initial["name"]) except ClanError as e: msg = "must be a valid hostname." raise ClanError( msg, location="name", description="The 'name' field must be a valid hostname.", ) from e def git_command(directory: Path, *args: str) -> list[str]: return nix_shell(["git"], ["git", "-C", str(directory), *args]) @API.register def create_clan(opts: CreateOptions) -> None: """Create a new clan repository with the specified template. Args: opts: CreateOptions containing the destination path, template name, source flake, and other options. Raises: ClanError: If the source flake is not a valid flake or if the destination directory already exists. """ opts.validate() dest = opts.dest.resolve() if opts.src_flake is not None: try: nix_metadata(str(opts.src_flake)) except ClanError: log.exception( f"Found a repository, but it is not a valid flake: {opts.src_flake}", ) log.warning("Setting src_flake to None") opts.src_flake = None if opts.src_flake is None: opts.src_flake = Flake(str(clan_templates())) with clan_template( opts.src_flake, template_ident=opts.template, dst_dir=opts.dest, # _postprocess_flake_hook must be private to avoid leaking it to the public API post_process=opts._postprocess_flake_hook, # noqa: SLF001 ) as _clan_dir: flake = Flake(str(Path(opts.dest).absolute())) if opts.setup_git: run(git_command(dest, "init")) run(git_command(dest, "add", ".")) # check if username is set has_username = run( git_command(dest, "config", "user.name"), RunOpts(check=False), ) if has_username.returncode != 0: run(git_command(dest, "config", "user.name", "clan-tool")) has_username = run( git_command(dest, "config", "user.email"), RunOpts(check=False), ) if has_username.returncode != 0: run(git_command(dest, "config", "user.email", "clan@example.com")) if opts.update_clan: log.info("Updating flake.lock file...") debug_logging = Log.STDERR if log.isEnabledFor(logging.DEBUG) else Log.NONE run( nix_command(["flake", "update"]), RunOpts(cwd=dest, log=debug_logging), ) flake.invalidate_cache() if opts.setup_git: run(git_command(dest, "add", ".")) run(git_command(dest, "commit", "-m", "Initial commit")) if opts.initial: inventory_store = InventoryStore(flake) inventory = inventory_store.read() curr_meta = inventory.get("meta", {}) new_meta = merge_objects(curr_meta, opts.initial) set_value_by_path(inventory, "meta", new_meta) inventory_store.write(inventory, message="Init inventory")