Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
3802099
feat(scanners): coverage gaps — 57 sub-tasks, 16 scanners, 492 tests
wgordon17 Mar 10, 2026
dc10724
fix(scanners): review findings — API naming, JSON, paths
wgordon17 Mar 10, 2026
87b2dd4
style: run ruff format on all scanner sources and tests
wgordon17 Mar 10, 2026
fb93925
feat(scanners): nix-state, version/package managers, containers
wgordon17 Mar 11, 2026
1fe8b87
fix(scanners): smoke-test bugs — redaction, daemon, version
wgordon17 Mar 11, 2026
51a7f98
fix(scanners): Nix 3.x profile dict format, Determinate daemon pgrep
wgordon17 Mar 11, 2026
1e5d31f
fix(scanners): nix config extra-* merging, version extraction
wgordon17 Mar 11, 2026
0f9d410
fix(scanners): registry parser matches all URL types, not just path:
wgordon17 Mar 11, 2026
4e803cd
fix(scanners): 13 bugs found by full live-system audit
wgordon17 Mar 12, 2026
d27d9e4
fix(scanners): 5 more bugs found on deeper live-system review
wgordon17 Mar 12, 2026
d9303e9
fix(scanners): iOS wrapper app detection, touch_id_sudo false positive
wgordon17 Mar 12, 2026
e4ceb9f
refactor(scanners): removes TCC summary field
wgordon17 Mar 12, 2026
a195dde
style(scanners): formats display.py
wgordon17 Mar 12, 2026
3bb12d2
fix(ci): prek files regex double-escaped, hooks never matched .py
wgordon17 Mar 12, 2026
512a813
feat(cli): adds scan command with orchestrator
wgordon17 Mar 14, 2026
40deff7
refactor(scanners): merges library_audit + app_config into library
wgordon17 Mar 14, 2026
ccd2d56
fix(scanners): review fixes — PII redaction, callback safety, naming
wgordon17 Mar 14, 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
4 changes: 2 additions & 2 deletions prek.toml
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ hooks = [
name = "Python lint (ruff check)",
entry = "uv run ruff check src/ tests/",
language = "system",
files = '\\.py$',
files = '\.py$',
pass_filenames = false,
priority = 0
},
Expand All @@ -72,7 +72,7 @@ hooks = [
name = "Python format check (ruff format)",
entry = "uv run ruff format --check src/ tests/",
language = "system",
files = '\\.py$',
files = '\.py$',
pass_filenames = false,
priority = 0
}
Expand Down
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ dependencies = [
"pydantic>=2.0",
"jinja2>=3.1",
"pyyaml>=6.0",
"rich>=13.0",
]

[project.scripts]
Expand Down Expand Up @@ -62,7 +63,7 @@ ignore = [
]

[tool.ruff.lint.per-file-ignores]
"**/tests/**/*.py" = ["S101", "SLF001"] # assert + private member access OK in tests
"**/tests/**/*.py" = ["S101", "S105", "S108", "SLF001"] # assert + test data + private access OK in tests

[tool.ruff.lint.isort]
known-first-party = ["mac2nix"]
Expand Down
87 changes: 85 additions & 2 deletions src/mac2nix/cli.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,17 @@
"""mac2nix CLI."""

from __future__ import annotations

import asyncio
import time
from pathlib import Path

import click
from rich.console import Console
from rich.progress import BarColumn, MofNCompleteColumn, Progress, SpinnerColumn, TextColumn, TimeElapsedColumn

from mac2nix.orchestrator import run_scan
from mac2nix.scanners import get_all_scanners


@click.group()
Expand All @@ -10,9 +21,81 @@ def main() -> None:


@main.command()
def scan() -> None:
@click.option(
"--output",
"-o",
type=click.Path(path_type=Path),
default=None,
help="Write JSON output to FILE instead of stdout.",
metavar="FILE",
)
@click.option(
"--scanner",
"-s",
"selected_scanners",
multiple=True,
help="Run only this scanner (repeatable). Defaults to all scanners.",
metavar="NAME",
)
def scan(output: Path | None, selected_scanners: tuple[str, ...]) -> None:
"""Scan the current macOS system state."""
click.echo("scan: not yet implemented")
all_names = list(get_all_scanners().keys())
scanners: list[str] | None = list(selected_scanners) if selected_scanners else None

# Validate any explicitly requested scanner names
if scanners is not None:
unknown = [s for s in scanners if s not in all_names]
if unknown:
available = ", ".join(sorted(all_names))
raise click.UsageError(f"Unknown scanner(s): {', '.join(unknown)}. Available: {available}")

total = len(scanners) if scanners is not None else len(all_names)

completed: int = 0
start = time.monotonic()

with Progress(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
BarColumn(),
MofNCompleteColumn(),
TimeElapsedColumn(),
console=Console(stderr=True),
transient=True,
redirect_stdout=False,
redirect_stderr=False,
) as progress:
task_id = progress.add_task("Scanning...", total=total)

def progress_callback(name: str) -> None:
nonlocal completed
completed += 1
progress.advance(task_id)
progress.update(task_id, description=f"[bold cyan]{name}[/] done")

try:
state = asyncio.run(run_scan(scanners=scanners, progress_callback=progress_callback))
except RuntimeError as e:
raise click.ClickException(str(e)) from e

elapsed = time.monotonic() - start
scanner_count = completed

json_output = state.to_json()

if output is not None:
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json_output)
click.echo(
f"Scanned {scanner_count} scanner(s) in {elapsed:.1f}s — wrote {output}",
err=True,
)
else:
click.echo(
f"Scanned {scanner_count} scanner(s) in {elapsed:.1f}s",
err=True,
)
click.echo(json_output)


@main.command()
Expand Down
110 changes: 106 additions & 4 deletions src/mac2nix/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,69 +3,171 @@
from mac2nix.models.application import (
ApplicationsResult,
AppSource,
BinarySource,
BrewCask,
BrewFormula,
BrewService,
HomebrewState,
InstalledApp,
MasApp,
PathBinary,
)
from mac2nix.models.files import (
AppConfigEntry,
AppConfigResult,
BundleEntry,
ConfigFileType,
DotfileEntry,
DotfileManager,
DotfilesResult,
FontCollection,
FontEntry,
FontSource,
FontsResult,
KeyBindingEntry,
LibraryDirEntry,
LibraryFileEntry,
LibraryResult,
WorkflowEntry,
)
from mac2nix.models.hardware import (
AudioConfig,
AudioDevice,
DisplayConfig,
Monitor,
NightShiftConfig,
)
from mac2nix.models.package_managers import (
CondaEnvironment,
CondaPackage,
CondaState,
ContainerRuntimeInfo,
ContainerRuntimeType,
ContainersResult,
DevboxProject,
DevenvProject,
HomeManagerState,
MacPortsPackage,
MacPortsState,
ManagedRuntime,
NixChannel,
NixConfig,
NixDarwinState,
NixDirenvConfig,
NixFlakeInput,
NixInstallation,
NixInstallType,
NixProfile,
NixProfilePackage,
NixRegistryEntry,
NixState,
PackageManagersResult,
VersionManagerInfo,
VersionManagersResult,
VersionManagerType,
)
from mac2nix.models.hardware import AudioConfig, AudioDevice, DisplayConfig, Monitor
from mac2nix.models.preferences import PreferencesDomain, PreferencesResult, PreferenceValue
from mac2nix.models.services import (
CronEntry,
LaunchAgentEntry,
LaunchAgentSource,
LaunchAgentsResult,
LaunchdScheduledJob,
ScheduledTasks,
ShellConfig,
ShellFramework,
)
from mac2nix.models.system import (
FirewallAppRule,
ICloudState,
NetworkConfig,
NetworkInterface,
PrinterInfo,
SecurityState,
SystemConfig,
SystemExtension,
TimeMachineConfig,
VpnProfile,
)
from mac2nix.models.system import NetworkConfig, NetworkInterface, SecurityState, SystemConfig
from mac2nix.models.system_state import SystemState

__all__ = [
"AppConfigEntry",
"AppConfigResult",
"AppSource",
"ApplicationsResult",
"AudioConfig",
"AudioDevice",
"BinarySource",
"BrewCask",
"BrewFormula",
"BrewService",
"BundleEntry",
"CondaEnvironment",
"CondaPackage",
"CondaState",
"ConfigFileType",
"ContainerRuntimeInfo",
"ContainerRuntimeType",
"ContainersResult",
"CronEntry",
"DevboxProject",
"DevenvProject",
"DisplayConfig",
"DotfileEntry",
"DotfileManager",
"DotfilesResult",
"FirewallAppRule",
"FontCollection",
"FontEntry",
"FontSource",
"FontsResult",
"HomeManagerState",
"HomebrewState",
"ICloudState",
"InstalledApp",
"KeyBindingEntry",
"LaunchAgentEntry",
"LaunchAgentSource",
"LaunchAgentsResult",
"LaunchdScheduledJob",
"LibraryDirEntry",
"LibraryFileEntry",
"LibraryResult",
"MacPortsPackage",
"MacPortsState",
"ManagedRuntime",
"MasApp",
"Monitor",
"NetworkConfig",
"NetworkInterface",
"NightShiftConfig",
"NixChannel",
"NixConfig",
"NixDarwinState",
"NixDirenvConfig",
"NixFlakeInput",
"NixInstallType",
"NixInstallation",
"NixProfile",
"NixProfilePackage",
"NixRegistryEntry",
"NixState",
"PackageManagersResult",
"PathBinary",
"PreferenceValue",
"PreferencesDomain",
"PreferencesResult",
"PrinterInfo",
"ScheduledTasks",
"SecurityState",
"ShellConfig",
"ShellFramework",
"SystemConfig",
"SystemExtension",
"SystemState",
"TimeMachineConfig",
"VersionManagerInfo",
"VersionManagerType",
"VersionManagersResult",
"VpnProfile",
"WorkflowEntry",
]
42 changes: 42 additions & 0 deletions src/mac2nix/models/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,27 @@ class AppSource(StrEnum):
MANUAL = "manual"


class BinarySource(StrEnum):
ASDF = "asdf"
BREW = "brew"
CARGO = "cargo"
CONDA = "conda"
GEM = "gem"
GO = "go"
JENV = "jenv"
MACPORTS = "macports"
MANUAL = "manual"
MISE = "mise"
NIX = "nix"
NPM = "npm"
NVM = "nvm"
PIPX = "pipx"
PYENV = "pyenv"
RBENV = "rbenv"
SDKMAN = "sdkman"
SYSTEM = "system"


class InstalledApp(BaseModel):
name: str
bundle_id: str | None = None
Expand All @@ -22,13 +43,25 @@ class InstalledApp(BaseModel):
source: AppSource


class PathBinary(BaseModel):
name: str
path: Path
source: BinarySource
version: str | None = None


class ApplicationsResult(BaseModel):
apps: list[InstalledApp]
path_binaries: list[PathBinary] = []
xcode_path: str | None = None
xcode_version: str | None = None
clt_version: str | None = None


class BrewFormula(BaseModel):
name: str
version: str | None = None
pinned: bool = False


class BrewCask(BaseModel):
Expand All @@ -42,8 +75,17 @@ class MasApp(BaseModel):
version: str | None = None


class BrewService(BaseModel):
name: str
status: str
user: str | None = None
plist_path: Path | None = None


class HomebrewState(BaseModel):
taps: list[str] = []
formulae: list[BrewFormula] = []
casks: list[BrewCask] = []
mas_apps: list[MasApp] = []
services: list[BrewService] = []
prefix: str | None = None
Loading