Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
eaaa8fd
feat(mappings): add app_to_hm for home-manager program modules
wgordon17 Jul 20, 2026
a34ff9c
feat(mappings): add dotfile_to_hm mapping
wgordon17 Jul 20, 2026
bdec45d
feat(mappings): add app_config_registry for Application Support paths
wgordon17 Jul 20, 2026
c898cd5
feat(mappings): add app_to_package with nix-native alternative hints
wgordon17 Jul 20, 2026
ea6f20d
feat(mappings): add ephemeral state filter for scanned preferences
wgordon17 Jul 20, 2026
e9709a6
feat(mappings): add non-defaults nix-darwin option mappings
wgordon17 Jul 20, 2026
09788db
feat(mappings): add brew_to_nixpkgs equivalence mapping
wgordon17 Jul 20, 2026
0b89936
feat(mappings): add defaults_to_nix with all 22 system.defaults categ…
wgordon17 Jul 20, 2026
0374520
feat(mappings): add four-tier setting classifier
wgordon17 Jul 20, 2026
4970542
fix(mappings): correct ghostty nixpkgs attribute to ghostty-bin
wgordon17 Jul 20, 2026
64448dc
feat(mappings): add environment mappings, wire timezone and app confi…
wgordon17 Jul 20, 2026
77b752f
fix(mappings): freeze lookup-result dataclasses and immutabilize AppC…
wgordon17 Jul 20, 2026
afb517b
refactor(mappings): collapse repeated shell-env branches, freeze HMMo…
wgordon17 Jul 20, 2026
d4ef672
fix(mappings): redact sensitive shell alias/env-var/command values be…
wgordon17 Jul 20, 2026
408ca48
fix(mappings): narrow ephemeral timestamp-range check, patch sensitiv…
wgordon17 Jul 20, 2026
4b18c6e
fix(mappings): patch preference aliasing bug and broaden redaction
wgordon17 Jul 24, 2026
d1678cd
feat(cli): add scan_report module for per-scanner status and log capture
wgordon17 Jul 24, 2026
10fbaae
feat(cli): classify scanner outcomes as success/warning/error/skipped
wgordon17 Jul 24, 2026
b1b4fe7
feat(cli): render live per-scanner table with inline warnings and rem…
wgordon17 Jul 24, 2026
d05e3cf
fix(scanners): log non-zero exits in run_command, fix misleading home…
wgordon17 Jul 24, 2026
8b7ac33
fix(cli): attribute crash logs correctly, suppress npm's benign nonze…
wgordon17 Jul 24, 2026
ef67d2a
fix(scanners): suppress false-warning on expected nix-daemon check mi…
wgordon17 Jul 24, 2026
50b6e9f
refactor(cli): remove redundant tally dict, extract shared row-render…
wgordon17 Jul 25, 2026
14896c0
fix(cli): sanitize control characters from scanner warning/error text
wgordon17 Jul 25, 2026
fc7ade1
fix(scanners): suppress false warnings on 18 known-benign nonzero exits
wgordon17 Jul 25, 2026
fc336c3
refactor(cli): use Counter and enum iteration for the scan tally
wgordon17 Jul 25, 2026
a0ae76a
fix(scanners): propagate contextvars into parallel_walk_dirs workers
wgordon17 Jul 25, 2026
ac11c38
fix(scanners): propagate contextvars in package_managers_scanner pools
wgordon17 Jul 25, 2026
029cedb
fix(cli): correct misleading permission hints, wrap long rows, clarif…
wgordon17 Jul 27, 2026
b998de7
fix(scanners): readable command text, skip unreadable plists
wgordon17 Jul 29, 2026
29a3449
refactor(cli): decouple summary row width from warning text width
wgordon17 Jul 29, 2026
9af2454
fix(cli): nest stderr continuation under its warning line
wgordon17 Jul 29, 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
155 changes: 123 additions & 32 deletions src/mac2nix/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,106 @@
import asyncio
import time
import uuid
from collections import Counter
from collections.abc import Sequence
from pathlib import Path

import click
from rich.console import Console
from rich.progress import BarColumn, MofNCompleteColumn, Progress, SpinnerColumn, TextColumn, TimeElapsedColumn
from rich.console import Console, Group, RenderableType
from rich.live import Live
from rich.spinner import Spinner
from rich.table import Table
from rich.text import Text

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
from mac2nix.scanners import get_all_scanners
from mac2nix.vm.discovery import DiscoveryRunner
from mac2nix.vm.manager import TartVMManager
from mac2nix.vm.validator import Validator

_STATUS_ICONS: dict[ScannerStatus, tuple[str, str]] = {
ScannerStatus.SUCCESS: ("✓", "green"),
ScannerStatus.WARNING: ("⚠", "yellow"),
ScannerStatus.ERROR: ("✗", "red"),
ScannerStatus.SKIPPED: ("⊘", "dim"),
}

_TALLY_LABELS: dict[ScannerStatus, str] = {
ScannerStatus.SUCCESS: "success",
ScannerStatus.WARNING: "completed with warnings",
ScannerStatus.ERROR: "failed",
ScannerStatus.SKIPPED: "skipped",
}


def _status_icon(status: ScannerStatus) -> tuple[str, str]:
"""Return (icon, style) for a scanner's status."""
return _STATUS_ICONS[status]


def _message_lines(message: str) -> list[Text]:
"""Render a warning/error message plus its remediation hint (if any).

Returned as standalone Text objects (not table cells) so they wrap at the
full console width instead of being squeezed into a narrow shared column.
A message with embedded newlines (e.g. a "...\\nstderr: ..." continuation
from run_command()) gets each continuation line indented to nest under the
first, rather than resetting to the left margin.
"""
first, *continuation = message.split("\n")
lines = [Text(f" ↳ {first}", style="dim")]
lines.extend(Text(f" {line}", style="dim") for line in continuation)
hint = get_remediation_hint(message)
if hint is not None:
lines.append(Text(f" → {hint}", style="dim italic"))
return lines


def _build_scan_table(outcomes: dict[str, ScannerOutcome], order: Sequence[str]) -> Group:
"""Render one compact summary line per scanner, with full-width lines for warnings/errors.

Each scanner gets its own small grid for the summary line (status icon, name,
elapsed, detail) so that line's width is driven only by scanner names, never
by warning/error text -- that text is rendered separately, below, as
unconstrained lines so it wraps at the full console width instead of forcing
every summary line wide.
"""
name_width = max((len(name) for name in order), default=0)
renderables: list[RenderableType] = []

for name in order:
grid = Table.grid(padding=(0, 1))
grid.add_column(width=2, justify="center")
grid.add_column(width=name_width)
grid.add_column(width=6, justify="right")
grid.add_column()

outcome = outcomes.get(name)
if outcome is None:
grid.add_row(Spinner("dots"), name, "", "")
renderables.append(grid)
continue

icon, style = _status_icon(outcome.status)
detail = ""
if outcome.status is ScannerStatus.WARNING:
detail = f"{len(outcome.warnings)} warning(s)"
elif outcome.status is ScannerStatus.ERROR:
detail = "error"

grid.add_row(Text(icon, style=style), name, f"{outcome.elapsed:.1f}s", detail)
renderables.append(grid)

if outcome.status in (ScannerStatus.WARNING, ScannerStatus.ERROR):
for warning in outcome.warnings:
renderables.extend(_message_lines(warning))
if outcome.status is ScannerStatus.ERROR and outcome.error is not None:
renderables.extend(_message_lines(outcome.error))

return Group(*renderables)


@click.group()
@click.version_option()
Expand Down Expand Up @@ -54,52 +141,56 @@ def scan(output: Path | None, selected_scanners: tuple[str, ...]) -> None:
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)
scanner_names: list[str] = scanners if scanners is not None else all_names

completed: int = 0
outcomes: dict[str, ScannerOutcome] = {}
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")
with (
capture_scanner_logs() as log_handler,
Live(
_build_scan_table(outcomes, scanner_names),
console=Console(stderr=True),
refresh_per_second=8,
transient=False,
) as live,
):

def progress_callback(outcome: ScannerOutcome) -> None:
outcomes[outcome.name] = outcome
live.update(_build_scan_table(outcomes, scanner_names))

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

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

if log_handler.unattributed:
click.echo("General warnings:", err=True)
for warning in log_handler.unattributed:
click.echo(f" {warning}", err=True)
hint = get_remediation_hint(warning)
if hint is not None:
click.echo(f" → {hint}", err=True)

tally_counts = Counter(outcome.status for outcome in outcomes.values())
tally = ", ".join(
f"{tally_counts[status]} {_TALLY_LABELS[status]}" for status in ScannerStatus if tally_counts.get(status, 0) > 0
)

json_output = state.to_json()
summary = f"Scanned {len(outcomes)} scanner(s) in {elapsed:.1f}s ({tally})"

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,
)
click.echo(f"{summary} — wrote {output}", err=True)
else:
click.echo(
f"Scanned {scanner_count} scanner(s) in {elapsed:.1f}s",
err=True,
)
click.echo(summary, err=True)
click.echo(json_output)


Expand Down
112 changes: 112 additions & 0 deletions src/mac2nix/mappings/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
"""Mapping layer: macOS scan data to nix-darwin option paths.

Re-exports the public API of all nine mapping modules. `app_to_package`'s
`classify_app(name: str) -> AppClassification | None` (a raw name lookup) is
deliberately NOT re-exported at this level -- it would shadow this package's
own `classify_app(app: InstalledApp) -> ClassificationResult` (the
classifier's per-InstalledApp routing function). Reach it via
`mac2nix.mappings.app_to_package.classify_app` if needed.
"""

from __future__ import annotations

from mac2nix.mappings.app_config_registry import APP_CONFIG_REGISTRY, AppConfigInfo, get_app_config
from mac2nix.mappings.app_to_hm import APP_TO_HM_MODULE, HMModuleInfo, get_hm_module
from mac2nix.mappings.app_to_package import AppClassification, normalize_app_name
from mac2nix.mappings.brew_to_nixpkgs import (
BREW_TO_NIXPKGS,
VERSION_MANAGER_FORMULAE,
NixpkgsEquivalent,
get_nixpkgs_equivalent,
is_unnecessary_in_nix,
)
from mac2nix.mappings.classifier import (
ClassificationResult,
ClassificationTier,
classify_app,
classify_app_config,
classify_brew_formula,
classify_dotfile,
classify_font,
classify_launch_agent,
classify_network_setting,
classify_preference,
classify_security_setting,
classify_shell_setting,
classify_system_setting,
)
from mac2nix.mappings.defaults_to_nix import (
DEFAULTS_TO_NIX,
DOMAIN_ALIASES,
NixOption,
get_nix_option,
get_unmapped_keys,
)
from mac2nix.mappings.dotfile_to_hm import DOTFILE_TO_HM, get_hm_program
from mac2nix.mappings.ephemeral_filter import filter_ephemeral, is_ephemeral
from mac2nix.mappings.non_defaults_to_nix import (
FONT_TO_NIXPKGS,
LAUNCHD_KEYS_TO_DROP,
LAUNCHD_LABEL_TO_SERVICE,
NETWORKING_MAP,
POWER_SETTING_MAP,
SECURITY_MAP,
SHELL_PROGRAM_MAP,
TIMEZONE_NIX_OPTION,
get_font_nixpkgs,
get_launchd_service,
get_power_nix_option,
get_shell_program,
is_launchd_key_droppable,
)

__all__ = [
"APP_CONFIG_REGISTRY",
"APP_TO_HM_MODULE",
"BREW_TO_NIXPKGS",
"DEFAULTS_TO_NIX",
"DOMAIN_ALIASES",
"DOTFILE_TO_HM",
"FONT_TO_NIXPKGS",
"LAUNCHD_KEYS_TO_DROP",
"LAUNCHD_LABEL_TO_SERVICE",
"NETWORKING_MAP",
"POWER_SETTING_MAP",
"SECURITY_MAP",
"SHELL_PROGRAM_MAP",
"TIMEZONE_NIX_OPTION",
"VERSION_MANAGER_FORMULAE",
"AppClassification",
"AppConfigInfo",
"ClassificationResult",
"ClassificationTier",
"HMModuleInfo",
"NixOption",
"NixpkgsEquivalent",
"classify_app",
"classify_app_config",
"classify_brew_formula",
"classify_dotfile",
"classify_font",
"classify_launch_agent",
"classify_network_setting",
"classify_preference",
"classify_security_setting",
"classify_shell_setting",
"classify_system_setting",
"filter_ephemeral",
"get_app_config",
"get_font_nixpkgs",
"get_hm_module",
"get_hm_program",
"get_launchd_service",
"get_nix_option",
"get_nixpkgs_equivalent",
"get_power_nix_option",
"get_shell_program",
"get_unmapped_keys",
"is_ephemeral",
"is_launchd_key_droppable",
"is_unnecessary_in_nix",
"normalize_app_name",
]
Loading