Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
9dbe83d
feat(generators): adds curated macOS preferences generator
wgordon17 Aug 13, 2026
7ba0f9a
feat(cli): wires generate command with generate_all() foundation
wgordon17 Aug 13, 2026
68b2f0f
fix(vm): packages mac2nix as a runnable flake for VM-based re-scans
wgordon17 Aug 13, 2026
d3d557d
refactor(tests): extracts shared generate test helper
wgordon17 Aug 14, 2026
fdda7b9
docs(vm): documents the nix run entry point in README
wgordon17 Aug 14, 2026
0deb5cd
docs(generators): notes generate_all()'s concurrency limitation
wgordon17 Aug 14, 2026
517f390
fix(cli): guards generate's filesystem checks against OSError
wgordon17 Aug 14, 2026
5ba3a0d
docs(generators): flags future-PR extension points inline
wgordon17 Aug 14, 2026
c4371cd
fix(generators): adds nix_comment filter, prevents comment injection
wgordon17 Aug 14, 2026
45b8511
fix(templates): passes wallpaper path as osascript argv
wgordon17 Aug 14, 2026
f096a2c
fix(mappings): redacts sensitive key name in classifier destination
wgordon17 Aug 14, 2026
6aba95b
fix(cli): rejects path-traversal hostnames in generate command
wgordon17 Aug 14, 2026
2346b8f
perf(scanners): closes sqlite3 wallpaper-db connection handle
wgordon17 Aug 14, 2026
c6bffad
fix(generators): raises clear error on missing generate sentinels
wgordon17 Aug 14, 2026
ace2e93
perf(vm): reuses stable venv cache path instead of mktemp per run
wgordon17 Aug 14, 2026
075715a
test(vm): adds real nix build/run coverage for mac2nix's own flake
wgordon17 Aug 14, 2026
98c5f6b
fix(generators): rejects non-finite floats in python_to_nix
wgordon17 Aug 14, 2026
40725ae
fix(generators): hardens power bool coercion and wallpaper activation
wgordon17 Aug 14, 2026
42662c5
fix(generators): prevents hardware-dependent power settings from abor…
wgordon17 Aug 16, 2026
ade051a
fix(scanners): percent-encodes wallpaper db path before building sqli…
wgordon17 Aug 16, 2026
9208f14
fix(vm): excludes dev-only content from copied user flakes, documents…
wgordon17 Aug 16, 2026
ea1bafd
refactor(cli): extracts has_hosts_sentinels() instead of importing pr…
wgordon17 Aug 16, 2026
d89a295
test(generators): adds coverage for generate_all()'s error paths and …
wgordon17 Aug 16, 2026
2b8f83d
docs(contributing): adds tests scope to conventional commit types
wgordon17 Aug 16, 2026
03d22e8
fix(generators): guards generate_all against missing config
wgordon17 Aug 19, 2026
578d4f9
fix(vm): records SHA-pin decision, tests unpinned-source warning
wgordon17 Aug 19, 2026
701bc93
test(cli): covers generate's skipped and unrecognized domain output
wgordon17 Aug 19, 2026
a1e0f3a
docs(contributing): adds contributing as a valid commit scope
wgordon17 Aug 19, 2026
4ba1ed9
feat(generators): combines scripts into one postActivation hook
wgordon17 Aug 19, 2026
94a07d8
feat(scanners): distinguishes wallpaper scan failure from no-wallpaper
wgordon17 Aug 19, 2026
9647dac
feat(mappings): tags manual-report reasons by category
wgordon17 Aug 19, 2026
9e6341c
chore(tests): exempts ARG002 for tests, matching ARG001
wgordon17 Aug 19, 2026
b75531a
feat(generators): bundles wallpaper images with self-guarding scripts
wgordon17 Aug 19, 2026
f6f9a2a
test(vm): verifies hardware-dependent scripts on real hardware
wgordon17 Aug 19, 2026
c42f38e
docs(generators): documents wallpaper bundling and report tags
wgordon17 Aug 19, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 15 additions & 13 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,19 +26,21 @@ This project follows [Conventional Commits](https://www.conventionalcommits.org/

### Scopes

| Scope | Description |
|--------------|------------------------------------|
| `cli` | CLI commands and options |
| `scanners` | macOS system scanners |
| `generators` | Nix configuration generators |
| `mappings` | macOS-to-nix mapping tables |
| `models` | Data models |
| `reports` | Scan reports and diffs |
| `templates` | Jinja2 nix templates |
| `vm` | Tart VM integration |
| `drift` | Configuration drift detection |
| `deps` | Dependency updates |
| `ci` | CI/CD pipeline |
| Scope | Description |
|----------------|--------------------------------------|
| `cli` | CLI commands and options |
| `scanners` | macOS system scanners |
| `generators` | Nix configuration generators |
| `mappings` | macOS-to-nix mapping tables |
| `models` | Data models |
| `reports` | Scan reports and diffs |
| `templates` | Jinja2 nix templates |
| `vm` | Tart VM integration |
| `drift` | Configuration drift detection |
| `deps` | Dependency updates |
| `ci` | CI/CD pipeline |
| `tests` | Test-only infrastructure/helpers |
| `contributing` | Contributing guide and conventions |

### Examples

Expand Down
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,49 @@ Generate nix-darwin configurations from macOS system scans.
uv sync
```

mac2nix also ships a minimal `flake.nix`, so it can be run via Nix without a
local `uv` install (used internally to re-scan a VM during `mac2nix validate`):

```sh
nix run github:gordon-code/mac2nix -- --help
```

## Usage

```sh
uv run mac2nix --help
```

### Migrating a Mac

Scaffold a reusable, host-less nix-darwin + home-manager + sops-nix framework,
register a machine, scan it, and generate that host's configuration:

```sh
uv run mac2nix init ~/my-nix-config
uv run mac2nix add-host ~/my-nix-config --hostname my-mac --username myuser
uv run mac2nix generate ~/my-nix-config --hostname my-mac
```

`init` runs once per framework (it scaffolds `flake.nix`, shared `modules/`,
and sops-nix wiring with zero hosts registered). `add-host` registers one
machine at a time — including the first — generating that host's own
sops-nix age key behind a mandatory backup-confirmation prompt. `generate`
scans the current machine (or replays a `mac2nix scan` JSON file via
`--scan-file`) and writes that host's curated `preferences.nix` — bundling a
personal desktop wallpaper image under that host's `assets/` directory when
one is set — updating `configuration.nix`'s generated-imports section. It's
safely re-runnable and supports `--domains` to select which domains to
generate (currently just `preferences`; more are added incrementally).

Anything `generate` can't apply automatically shows up as a `# not automated:`
comment in the generated file, prefixed with why: `[sensitive]` (a value
that's deliberately never captured), `[coverage gap]` (no nix-darwin option
exists yet), `[hardware-dependent]` (the target machine's support for the
setting can't be known until activation time), or `[out of scope]` (outside
this generator's current scope). Review these and apply them manually where
needed.

## License

MIT
27 changes: 27 additions & 0 deletions flake.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

52 changes: 52 additions & 0 deletions flake.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
{
description = "mac2nix -- scan macOS system state and generate nix-darwin configuration";

# Minimal packaging for `nix run` -- delegates to `uv` for the actual Python
# dependency resolution/venv management (this project's own established
# convention, per CLAUDE.md's "use uv for all Python work"), rather than
# reimplementing that as a native Nix Python closure (uv2nix, etc.). This
# exists specifically so `Validator._scan_vm()` (src/mac2nix/vm/validator.py)
# can `nix run <this-repo-or-a-copy-of-it> -- scan` from inside a VM that
# only has Nix bootstrapped, not `uv` itself -- `uv` is fetched from
# nixpkgs as part of this flake's own closure.
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";

outputs =
{ self, nixpkgs }:
let
systems = [
"aarch64-darwin"
"x86_64-darwin"
];
forEachSystem = nixpkgs.lib.genAttrs systems;
in
{
apps = forEachSystem (
system:
let
pkgs = nixpkgs.legacyPackages.${system};
in
{
default = {
type = "app";
# `--project ${self}` points uv at a read-only Nix store path for
# source/dependency resolution -- uv must never try to create its
# venv there (its own default, and any ambient
# $UV_PROJECT_ENVIRONMENT override, both assume a writable
# project root). Force the venv into a stable, writable cache
# location instead of a fresh `mktemp -d` per invocation -- `uv`
# creates any missing parent directories itself, and reusing the
# same venv makes repeat invocations a fast no-op sync instead of
# a full dependency install, and never leaves orphaned venv
# directories behind in $TMPDIR.
program = toString (
pkgs.writeShellScript "mac2nix" ''
export UV_PROJECT_ENVIRONMENT="''${XDG_CACHE_HOME:-$HOME/.cache}/mac2nix/nix-run-venv"
exec ${pkgs.uv}/bin/uv run --project ${self} mac2nix "$@"
''
);
};
}
);
};
}
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ ignore = [
]

[tool.ruff.lint.per-file-ignores]
"**/tests/**/*.py" = ["S101", "S105", "S106", "S107", "S108", "S314", "SLF001", "ARG001"] # assert + test data + credentials + XML parsing + private access + unused mock args OK in tests
"**/tests/**/*.py" = ["S101", "S105", "S106", "S107", "S108", "S314", "SLF001", "ARG001", "ARG002"] # assert + test data + credentials + XML parsing + private access + unused mock/fixture args (functions and methods) OK in tests

[tool.ruff.lint.isort]
known-first-party = ["mac2nix"]
Expand Down
98 changes: 94 additions & 4 deletions src/mac2nix/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@
from rich.text import Text

from mac2nix import onepassword
from mac2nix.generators.scaffold import add_host, age_key_path, init_framework
from mac2nix.generators import generate_all
from mac2nix.generators.scaffold import add_host, age_key_path, has_hosts_sentinels, init_framework
from mac2nix.models.system_state import SystemState
from mac2nix.orchestrator import run_scan
from mac2nix.scan_report import ScannerOutcome, ScannerStatus, capture_scanner_logs, get_remediation_hint
Expand Down Expand Up @@ -384,10 +385,99 @@ def _register_one(current_hostname: str, current_username: str, current_system:
)


# Extend this tuple (never restructure) as each domain generator lands --
# Task 7 (shell) adds "shell", then Task 6 (homebrew) adds "homebrew".
_ALLOWED_DOMAINS = ("preferences",)


def _check_scaffolded_framework(output_dir: Path) -> None:
"""Matches add_host()'s own scaffolded-framework check in scaffold.py
(shared `has_hosts_sentinels()` predicate) -- kept as a second,
independent check (not a shared function) since this one must raise
click.ClickException while add_host() raises ScaffoldError, but reusing
the shared predicate avoids the two checks silently drifting apart.
"""
flake_path = output_dir / "flake.nix"
try:
flake_content = flake_path.read_text() if flake_path.is_file() else ""
except OSError as exc:
raise click.ClickException(f"Failed to read {flake_path}: {exc}") from exc
if not has_hosts_sentinels(flake_content):
raise click.ClickException(f"{output_dir} is not a mac2nix-scaffolded framework — run `mac2nix init` first")


def _check_host_registered(output_dir: Path, hostname: str) -> None:
host_dir = output_dir / "hosts" / "darwin" / hostname
try:
host_registered = host_dir.exists()
except OSError as exc:
raise click.ClickException(f"Failed to check {host_dir}: {exc}") from exc
if not host_registered:
msg = f"host {hostname!r} is not registered under {output_dir} — run `mac2nix add-host` first"
raise click.ClickException(msg)


@main.command()
def generate() -> None:
"""Generate nix-darwin configuration from a scan snapshot."""
click.echo("generate: not yet implemented")
@click.argument("output_dir", type=click.Path(exists=True, file_okay=False, path_type=Path))
@click.option(
"--hostname",
required=True,
callback=_validate_hostname,
help="Host to populate (must already be registered via add-host).",
)
@click.option(
"--scan-file",
type=click.Path(exists=True, dir_okay=False, path_type=Path),
default=None,
help="Source SystemState JSON produced by 'mac2nix scan'. Omit to scan the current machine inline.",
)
@click.option(
"--domains",
default="preferences",
show_default=True,
help=f"Comma-separated domains to generate. Allowed: {', '.join(_ALLOWED_DOMAINS)}.",
)
def generate(output_dir: Path, hostname: str, scan_file: Path | None, domains: str) -> None:
"""Generate nix-darwin configuration for one host from a scan.

Never invokes `nix flake lock`, `nix build`, `darwin-rebuild`, or `git`
-- it only writes files and (for an inline scan) runs the existing
read-only scanners.
"""
_check_scaffolded_framework(output_dir)

requested_domains = {token.strip() for token in domains.split(",") if token.strip()}
unknown = requested_domains - set(_ALLOWED_DOMAINS)
if unknown:
msg = f"unknown domain(s): {', '.join(sorted(unknown))}. Allowed: {', '.join(_ALLOWED_DOMAINS)}"
raise click.BadParameter(msg, param_hint="--domains")

_check_host_registered(output_dir, hostname)

if scan_file is not None:
try:
system_state = SystemState.from_json(scan_file)
except Exception as exc:
raise click.ClickException(f"Failed to load scan file: {exc}") from exc
else:
try:
system_state = asyncio.run(run_scan())
except RuntimeError as exc:
raise click.ClickException(str(exc)) from exc

try:
result = generate_all(system_state, output_dir, hostname, requested_domains)
except click.ClickException:
raise
except Exception as exc:
raise click.ClickException(str(exc)) from exc

if result.ran:
click.echo(f"Generated: {', '.join(sorted(result.ran))}")
for domain, reason in sorted(result.skipped.items()):
click.echo(f"Skipped {domain}: {reason}")
if result.unrecognized:
click.echo(f"Unrecognized (not generated): {', '.join(sorted(result.unrecognized))}")


@main.command()
Expand Down
Loading
Loading