macsetup 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
macsetup/cli.py ADDED
@@ -0,0 +1,481 @@
1
+ """Command surface.
2
+
3
+ Every wizard answer has a flag equivalent, so any machine can be provisioned
4
+ non-interactively. The wizard is for *deciding*; apply is for *converging*.
5
+
6
+ Selection precedence: --only beats --profile beats saved machine state.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import sys
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+ from typing import Annotated
15
+
16
+ import typer
17
+ from rich.console import Console
18
+ from rich.table import Table
19
+
20
+ from . import brewfile, wizard
21
+ from . import files as files_mod
22
+ from . import state as state_mod
23
+ from .detect import System, detect
24
+ from .manifest import Catalog, ManifestError, Module, files_dir, load_catalog
25
+ from .planner import Plan, build_plan, untracked
26
+ from .runner import SubprocessRunner
27
+ from .state import MachineState, builtin_vars
28
+
29
+ app = typer.Typer(
30
+ add_completion=True,
31
+ no_args_is_help=True,
32
+ help="Provision a MacBook from declarative manifests.",
33
+ )
34
+ console = Console()
35
+
36
+ ProfileOpt = Annotated[
37
+ str | None,
38
+ typer.Option("--profile", "-p", help="Profile to resolve. Defaults to saved machine state."),
39
+ ]
40
+ OnlyOpt = Annotated[
41
+ list[str] | None,
42
+ typer.Option("--only", help="Limit to these modules (repeatable). Skips `always` modules."),
43
+ ]
44
+
45
+
46
+ @dataclass
47
+ class Selection:
48
+ modules: list[Module]
49
+ picks: dict[str, set[str]] = field(default_factory=dict)
50
+ source: str = ""
51
+ machine: MachineState | None = None
52
+
53
+ def template_vars(self) -> dict[str, str]:
54
+ if self.machine:
55
+ return self.machine.template_vars()
56
+ # No machine.toml yet (--profile on a fresh box): fall back to the
57
+ # hostname so `render` steps still work before `init` has been run.
58
+ return builtin_vars(wizard.default_machine_name(SubprocessRunner()))
59
+
60
+
61
+ def _build(selection: Selection, system: System) -> Plan:
62
+ try:
63
+ root = files_dir()
64
+ except ManifestError:
65
+ root = None
66
+ return build_plan(
67
+ selection.modules,
68
+ system,
69
+ selection.picks,
70
+ files_root=root,
71
+ variables=selection.template_vars(),
72
+ )
73
+
74
+
75
+ def _load() -> Catalog:
76
+ try:
77
+ return load_catalog()
78
+ except ManifestError as exc:
79
+ console.print(f"[red]manifest error:[/] {exc}")
80
+ raise typer.Exit(2) from exc
81
+
82
+
83
+ def _system(*, require_brew: bool = True) -> System:
84
+ system = detect(SubprocessRunner())
85
+ if require_brew and not system.brew:
86
+ console.print("[red]Homebrew not found.[/] Install it from https://brew.sh first.")
87
+ raise typer.Exit(1)
88
+ return system
89
+
90
+
91
+ def _select(catalog: Catalog, profile: str | None, only: list[str] | None) -> Selection:
92
+ machine = state_mod.load()
93
+ try:
94
+ if only:
95
+ picks = machine.picks_as_sets() if machine else {}
96
+ return Selection(catalog.resolve(only=only), picks, f"modules {', '.join(only)}")
97
+ if profile:
98
+ picks = machine.picks_as_sets() if machine and machine.profile == profile else {}
99
+ return Selection(catalog.resolve(profile), picks, f"profile {profile}")
100
+ if machine:
101
+ if not machine.modules:
102
+ console.print(
103
+ "[yellow]This machine selects no optional modules[/] — only the "
104
+ "always-on ones apply. Run [bold]macsetup configure[/] to pick some."
105
+ )
106
+ modules = catalog.resolve(only=machine.modules, include_always=True)
107
+ return Selection(
108
+ modules,
109
+ machine.picks_as_sets(),
110
+ f"machine {machine.machine} (profile {machine.profile})",
111
+ machine,
112
+ )
113
+ except ManifestError as exc:
114
+ console.print(f"[red]{exc}[/]")
115
+ raise typer.Exit(2) from exc
116
+
117
+ console.print(
118
+ "[red]No machine state and no --profile.[/] "
119
+ "Run [bold]macsetup init[/] once on this machine, or pass --profile."
120
+ )
121
+ raise typer.Exit(2)
122
+
123
+
124
+ def _plan_table(plan: Plan) -> Table:
125
+ table = Table(box=None, pad_edge=False)
126
+ table.add_column("", width=2)
127
+ table.add_column("module", style="bold")
128
+ table.add_column("package")
129
+ table.add_column("kind", style="dim")
130
+
131
+ for module_plan in plan.modules:
132
+ first = True
133
+ for package in module_plan.missing:
134
+ table.add_row(
135
+ "[green]+[/]",
136
+ module_plan.module.id if first else "",
137
+ package.name,
138
+ "cask" if package.cask else "formula",
139
+ )
140
+ first = False
141
+ for action in module_plan.pending_files:
142
+ verb = "[green]+[/]" if action.outcome.status is files_mod.FileStatus.CREATE else "~"
143
+ table.add_row(
144
+ verb,
145
+ module_plan.module.id if first else "",
146
+ str(action.outcome.dest).replace(str(Path.home()), "~"),
147
+ action.spec.kind,
148
+ )
149
+ first = False
150
+ if module_plan.pending:
151
+ continue
152
+ detail = f"{len(module_plan.satisfied)} package(s) already installed"
153
+ if module_plan.file_actions and not module_plan.satisfied:
154
+ detail = f"{len(module_plan.file_actions)} file(s) up to date"
155
+ table.add_row("[dim]=[/]", f"[dim]{module_plan.module.id}[/]", f"[dim]{detail}[/]", "")
156
+ return table
157
+
158
+
159
+ def _summarise(plan: Plan) -> None:
160
+ if plan.converged:
161
+ have = []
162
+ if plan.satisfied_count:
163
+ have.append(f"{plan.satisfied_count} package(s)")
164
+ file_count = sum(len(m.file_actions) for m in plan.modules)
165
+ if file_count:
166
+ have.append(f"{file_count} file(s)")
167
+ console.print(f"[green]Converged.[/] {', '.join(have) or 'Nothing selected'} up to date.")
168
+ return
169
+ bits = []
170
+ if plan.missing_count:
171
+ bits.append(f"[bold]{plan.missing_count}[/] package(s) to install")
172
+ if plan.file_change_count:
173
+ bits.append(f"[bold]{plan.file_change_count}[/] file(s) to write")
174
+ console.print(f"{', '.join(bits)}, across {len(plan.pending)} module(s).")
175
+
176
+
177
+ def _apply_files(module_plan, root: Path, variables: dict[str, str]) -> None:
178
+ for action in module_plan.pending_files:
179
+ spec = action.spec
180
+ dest = action.outcome.dest
181
+ source = root / spec.source
182
+ if spec.kind == "copy":
183
+ files_mod.apply_copy(source, dest, spec.mode)
184
+ elif spec.kind == "render":
185
+ files_mod.apply_render(source, dest, variables, spec.mode)
186
+ else:
187
+ files_mod.apply_block(
188
+ dest,
189
+ source.read_text(encoding="utf-8"),
190
+ comment=spec.comment,
191
+ position=spec.position,
192
+ mode=spec.mode,
193
+ )
194
+ console.print(f" [green]wrote[/] {str(dest).replace(str(Path.home()), '~')}")
195
+
196
+
197
+ def _install(plan: Plan, selection: Selection, *, upgrade: bool, jobs: int | None) -> None:
198
+ runner = SubprocessRunner()
199
+ failed: list[str] = []
200
+ targets = plan.modules if upgrade else plan.pending
201
+ variables = selection.template_vars()
202
+ root = files_dir()
203
+
204
+ for module_plan in targets:
205
+ console.rule(f"[bold]{module_plan.module.id}[/]")
206
+ packages = module_plan.selected
207
+ if packages and (module_plan.missing or upgrade):
208
+ path = brewfile.write(module_plan.module.id, packages)
209
+ if not brewfile.install(runner, path, upgrade=upgrade, jobs=jobs).ok:
210
+ failed.append(module_plan.module.id)
211
+ console.print(f"[red]{module_plan.module.id} failed[/] (Brewfile kept at {path})")
212
+ # Files often configure what the packages provide, so skip them
213
+ # rather than pointing a shell fragment at a missing binary.
214
+ continue
215
+ try:
216
+ _apply_files(module_plan, root, variables)
217
+ except OSError as exc:
218
+ failed.append(module_plan.module.id)
219
+ console.print(f"[red]{module_plan.module.id} file step failed:[/] {exc}")
220
+
221
+ console.rule()
222
+ if failed:
223
+ console.print(f"[red]Failed modules:[/] {', '.join(failed)}")
224
+ raise typer.Exit(1)
225
+ console.print("[green]Done.[/]")
226
+
227
+
228
+ def _run_wizard(catalog: Catalog, current: MachineState | None) -> MachineState:
229
+ # prompt_toolkit throws a bare OSError when there is no usable terminal
230
+ # (piped stdin, ssh without -t, CI). Catch it here so the flag-based path
231
+ # is discoverable instead of dumping a traceback.
232
+ if not sys.stdin.isatty():
233
+ console.print(
234
+ "[red]The wizard needs an interactive terminal.[/]\n"
235
+ "Non-interactively, every answer has a flag: "
236
+ "[bold]macsetup apply --profile work --yes[/]"
237
+ )
238
+ raise typer.Exit(2)
239
+
240
+ system = _system()
241
+ try:
242
+ chosen = wizard.run(catalog, system, SubprocessRunner(), current=current)
243
+ except wizard.WizardAborted:
244
+ console.print("[yellow]Cancelled.[/] Nothing was saved.")
245
+ raise typer.Exit(130) from None
246
+ except (OSError, EOFError) as exc:
247
+ console.print(f"[red]Could not read from the terminal:[/] {exc}")
248
+ raise typer.Exit(2) from exc
249
+
250
+ # Enter on an untouched checkbox selects nothing, which is a legitimate
251
+ # answer but almost never the intended one -- and it is what the caller is
252
+ # about to write over the existing selection.
253
+ if not chosen.modules and not typer.confirm(
254
+ "Nothing selected — only the always-on modules would apply. Save that?",
255
+ default=False,
256
+ ):
257
+ console.print("[yellow]Cancelled.[/] Nothing was saved.")
258
+ raise typer.Exit(130)
259
+ return chosen
260
+
261
+
262
+ # --------------------------------------------------------------------------- #
263
+
264
+
265
+ @app.command("modules")
266
+ def list_modules() -> None:
267
+ """List every module and profile in the catalog."""
268
+ catalog = _load()
269
+
270
+ table = Table(title="modules", box=None, pad_edge=False)
271
+ table.add_column("id", style="bold")
272
+ table.add_column("packages", justify="right")
273
+ table.add_column("select", style="dim")
274
+ table.add_column("description")
275
+ for module in catalog.modules.values():
276
+ flags = module.select + (" · always" if module.always else "")
277
+ table.add_row(module.id, str(len(module.packages)), flags, module.description)
278
+ console.print(table)
279
+
280
+ profiles = Table(title="profiles", box=None, pad_edge=False)
281
+ profiles.add_column("name", style="bold")
282
+ profiles.add_column("modules")
283
+ for name, entries in catalog.profiles.items():
284
+ profiles.add_row(name, "all" if entries == ["*"] else ", ".join(entries))
285
+ console.print()
286
+ console.print(profiles)
287
+
288
+
289
+ @app.command()
290
+ def init(
291
+ yes: Annotated[bool, typer.Option("--yes", "-y", help="Skip install confirmation.")] = False,
292
+ no_apply: Annotated[
293
+ bool, typer.Option("--no-apply", help="Save the selection without installing.")
294
+ ] = False,
295
+ ) -> None:
296
+ """First-run wizard: choose modules, save machine state, then install."""
297
+ catalog = _load()
298
+ current = state_mod.load()
299
+ if current:
300
+ console.print(
301
+ f"[yellow]This machine is already configured[/] as "
302
+ f"[bold]{current.machine}[/]; re-running with your current answers."
303
+ )
304
+
305
+ chosen = _run_wizard(catalog, current)
306
+ path = state_mod.save(chosen)
307
+ console.print(f"\nSaved [bold]{path}[/]")
308
+
309
+ if no_apply:
310
+ console.print("Run [bold]macsetup apply[/] when ready.")
311
+ return
312
+
313
+ modules = catalog.resolve(only=chosen.modules, include_always=True)
314
+ selection = Selection(modules, chosen.picks_as_sets(), "wizard", chosen)
315
+ plan_result = _build(selection, _system())
316
+ console.print()
317
+ console.print(_plan_table(plan_result))
318
+ console.print()
319
+ _summarise(plan_result)
320
+
321
+ if plan_result.converged:
322
+ return
323
+ if not yes and not typer.confirm(
324
+ f"Install {plan_result.missing_count} package(s)?", default=True
325
+ ):
326
+ raise typer.Abort()
327
+ _install(plan_result, selection, upgrade=False, jobs=None)
328
+
329
+
330
+ @app.command()
331
+ def configure() -> None:
332
+ """Re-run the wizard to change this machine's selection. Installs nothing."""
333
+ catalog = _load()
334
+ chosen = _run_wizard(catalog, state_mod.load())
335
+ path = state_mod.save(chosen)
336
+ console.print(f"\nSaved [bold]{path}[/]")
337
+ console.print("Run [bold]macsetup apply[/] to converge.")
338
+
339
+
340
+ @app.command()
341
+ def plan(
342
+ profile: ProfileOpt = None,
343
+ only: OnlyOpt = None,
344
+ show_brewfile: Annotated[
345
+ bool, typer.Option("--show-brewfile", help="Print the generated Brewfiles instead.")
346
+ ] = False,
347
+ ) -> None:
348
+ """Show what apply would install. Changes nothing."""
349
+ catalog = _load()
350
+ selection = _select(catalog, profile, only)
351
+ result = _build(selection, _system())
352
+
353
+ if show_brewfile:
354
+ for module_plan in result.modules:
355
+ if not module_plan.selected:
356
+ continue
357
+ console.print(f"[bold]# {module_plan.module.id}.Brewfile[/]")
358
+ console.print(brewfile.render(module_plan.selected))
359
+ return
360
+
361
+ console.print(f"[dim]{selection.source}[/]")
362
+ console.print(_plan_table(result))
363
+ console.print()
364
+ _summarise(result)
365
+
366
+
367
+ @app.command()
368
+ def apply(
369
+ profile: ProfileOpt = None,
370
+ only: OnlyOpt = None,
371
+ yes: Annotated[bool, typer.Option("--yes", "-y", help="Skip confirmation.")] = False,
372
+ dry_run: Annotated[bool, typer.Option("--dry-run", help="Stop after the preview.")] = False,
373
+ upgrade: Annotated[
374
+ bool, typer.Option("--upgrade", help="Also upgrade outdated packages.")
375
+ ] = False,
376
+ jobs: Annotated[int | None, typer.Option("--jobs", help="Parallel formula installs.")] = None,
377
+ ) -> None:
378
+ """Install everything the selected modules require."""
379
+ catalog = _load()
380
+ selection = _select(catalog, profile, only)
381
+ result = _build(selection, _system())
382
+
383
+ console.print(f"[dim]{selection.source}[/]")
384
+ console.print(_plan_table(result))
385
+ console.print()
386
+
387
+ if result.converged and not upgrade:
388
+ console.print("[green]Nothing to do.[/]")
389
+ return
390
+ if dry_run:
391
+ console.print(f"[yellow]Dry run.[/] {result.missing_count} package(s) would be installed.")
392
+ return
393
+ if not yes and not typer.confirm("Apply these changes?", default=True):
394
+ raise typer.Abort()
395
+
396
+ _install(result, selection, upgrade=upgrade, jobs=jobs)
397
+
398
+
399
+ @app.command()
400
+ def status(profile: ProfileOpt = None, only: OnlyOpt = None) -> None:
401
+ """Drift report: manifest versus what is actually on this machine."""
402
+ catalog = _load()
403
+ selection = _select(catalog, profile, only)
404
+ system = _system()
405
+ result = _build(selection, system)
406
+
407
+ console.print(
408
+ f"[bold]{system.arch}[/] · macOS {system.macos} · "
409
+ f"{system.installed_count} packages installed · {selection.source}"
410
+ )
411
+ console.print()
412
+
413
+ table = Table(box=None, pad_edge=False)
414
+ table.add_column("module", style="bold")
415
+ table.add_column("state")
416
+ table.add_column("detail", style="dim")
417
+ for module_plan in result.modules:
418
+ if module_plan.pending:
419
+ bits = []
420
+ if module_plan.missing:
421
+ bits.append("missing: " + ", ".join(p.name for p in module_plan.missing))
422
+ for action in module_plan.pending_files:
423
+ target = str(action.outcome.dest).replace(str(Path.home()), "~")
424
+ bits.append(f"{action.outcome.status.value} {target}")
425
+ table.add_row(module_plan.module.id, "[yellow]drifted[/]", "; ".join(bits))
426
+ else:
427
+ counts = [f"{len(module_plan.satisfied)} package(s)"]
428
+ if module_plan.file_actions:
429
+ counts.append(f"{len(module_plan.file_actions)} file(s)")
430
+ table.add_row(module_plan.module.id, "[green]ok[/]", ", ".join(counts))
431
+ console.print(table)
432
+
433
+ # Modules that were never adopted on this machine. Without this, a module
434
+ # added upstream stays invisible forever.
435
+ if selection.machine:
436
+ machine_profile = selection.machine.profile
437
+ unadopted = catalog.unadopted(machine_profile, (m.id for m in selection.modules))
438
+ known = machine_profile in catalog.profiles
439
+ scope = f"in profile '{machine_profile}'" if known else "available"
440
+ if unadopted:
441
+ console.print()
442
+ console.print(
443
+ f"[yellow]{len(unadopted)} module(s) {scope} "
444
+ f"not selected here:[/] {', '.join(unadopted)}"
445
+ )
446
+ console.print("[dim]Run `macsetup configure` to adopt them.[/]")
447
+
448
+ extra_formulae, extra_casks = untracked(result, system)
449
+ extras = len(extra_formulae) + len(extra_casks)
450
+ if extras:
451
+ console.print()
452
+ console.print(
453
+ f"[dim]{extras} installed package(s) not covered by this selection "
454
+ f"({len(extra_formulae)} formulae, {len(extra_casks)} casks). "
455
+ f"Run `macsetup untracked` to list them.[/]"
456
+ )
457
+
458
+
459
+ @app.command("untracked")
460
+ def untracked_cmd(profile: ProfileOpt = None, only: OnlyOpt = None) -> None:
461
+ """List installed packages that no selected module accounts for."""
462
+ catalog = _load()
463
+ selection = _select(catalog, profile, only)
464
+ system = _system()
465
+ formulae, casks = untracked(_build(selection, system), system)
466
+
467
+ if not formulae and not casks:
468
+ console.print("[green]Everything installed is accounted for.[/]")
469
+ return
470
+ if formulae:
471
+ console.print("[bold]formulae[/]")
472
+ console.print(" " + "\n ".join(formulae))
473
+ if casks:
474
+ console.print("[bold]casks[/]")
475
+ console.print(" " + "\n ".join(casks))
476
+ console.print()
477
+ console.print("[dim]Candidates to adopt into a module, or to remove once prune lands.[/]")
478
+
479
+
480
+ if __name__ == "__main__":
481
+ app()
macsetup/detect.py ADDED
@@ -0,0 +1,53 @@
1
+ """Machine inspection.
2
+
3
+ The whole point of this module is that `brew list` runs exactly twice per
4
+ invocation, no matter how many packages the catalog holds. Checking 60 packages
5
+ must not mean 60 subprocess calls.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import platform
11
+ from dataclasses import dataclass, field
12
+
13
+ from .runner import Runner
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class System:
18
+ arch: str
19
+ macos: str
20
+ brew: bool
21
+ formulae: frozenset[str] = field(default_factory=frozenset)
22
+ casks: frozenset[str] = field(default_factory=frozenset)
23
+
24
+ def has(self, name: str, *, cask: bool) -> bool:
25
+ return name in (self.casks if cask else self.formulae)
26
+
27
+ @property
28
+ def installed_count(self) -> int:
29
+ return len(self.formulae) + len(self.casks)
30
+
31
+
32
+ def detect(runner: Runner) -> System:
33
+ brew_present = runner.run(["command", "-v", "brew"]).ok or _brew_responds(runner)
34
+
35
+ formulae: frozenset[str] = frozenset()
36
+ casks: frozenset[str] = frozenset()
37
+ if brew_present:
38
+ formulae = frozenset(runner.run(["brew", "list", "--formula", "-1"]).lines())
39
+ casks = frozenset(runner.run(["brew", "list", "--cask", "-1"]).lines())
40
+
41
+ return System(
42
+ arch=platform.machine(),
43
+ macos=platform.mac_ver()[0] or "unknown",
44
+ brew=brew_present,
45
+ formulae=formulae,
46
+ casks=casks,
47
+ )
48
+
49
+
50
+ def _brew_responds(runner: Runner) -> bool:
51
+ # `command -v` is a shell builtin and will not exist as an executable, so
52
+ # fall back to asking brew directly.
53
+ return runner.run(["brew", "--prefix"]).ok
macsetup/files.py ADDED
@@ -0,0 +1,150 @@
1
+ """File steps: copy, render and managed blocks.
2
+
3
+ Deviation from plan 02, which called for symlinks: these **copy**. A symlink
4
+ into the installed package would point inside the uv tool directory and break
5
+ the next time the tool is upgraded. Copying works identically whether macsetup
6
+ runs from a repo checkout or an installed wheel, and it makes `status` able to
7
+ report a hand-edited file as drifted.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ from dataclasses import dataclass
14
+ from enum import StrEnum
15
+ from pathlib import Path
16
+
17
+ from jinja2 import StrictUndefined, Template
18
+
19
+ BEGIN = "# >>> macsetup >>>"
20
+ END = "# <<< macsetup <<<"
21
+
22
+
23
+ class FileStatus(StrEnum):
24
+ OK = "ok"
25
+ CREATE = "create"
26
+ UPDATE = "update"
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class Outcome:
31
+ status: FileStatus
32
+ dest: Path
33
+ detail: str = ""
34
+
35
+ @property
36
+ def pending(self) -> bool:
37
+ return self.status is not FileStatus.OK
38
+
39
+
40
+ def expand(path: str) -> Path:
41
+ return Path(os.path.expandvars(path)).expanduser()
42
+
43
+
44
+ def _diff_status(dest: Path, wanted: str) -> FileStatus:
45
+ if not dest.exists():
46
+ return FileStatus.CREATE
47
+ try:
48
+ return FileStatus.OK if dest.read_text(encoding="utf-8") == wanted else FileStatus.UPDATE
49
+ except (UnicodeDecodeError, OSError):
50
+ return FileStatus.UPDATE
51
+
52
+
53
+ def _write(dest: Path, content: str, mode: str | None) -> None:
54
+ dest.parent.mkdir(parents=True, exist_ok=True)
55
+ # A real file we did not write gets kept; losing a hand-made ~/.ssh config
56
+ # to a tool run is not an acceptable failure mode.
57
+ if dest.exists() and not dest.is_symlink():
58
+ backup = dest.with_suffix(dest.suffix + ".macsetup-bak")
59
+ if not backup.exists():
60
+ backup.write_bytes(dest.read_bytes())
61
+ dest.write_text(content, encoding="utf-8")
62
+ if mode:
63
+ dest.chmod(int(mode, 8))
64
+
65
+
66
+ def render_template(source: Path, variables: dict[str, str]) -> str:
67
+ # keep_trailing_newline: jinja drops the final newline by default, which
68
+ # leaves config files without one. StrictUndefined: a typo'd variable must
69
+ # fail loudly rather than render as an empty string.
70
+ template = Template(
71
+ source.read_text(encoding="utf-8"),
72
+ undefined=StrictUndefined,
73
+ keep_trailing_newline=True,
74
+ )
75
+ return template.render(**variables)
76
+
77
+
78
+ def plan_copy(source: Path, dest: Path) -> Outcome:
79
+ wanted = source.read_text(encoding="utf-8")
80
+ return Outcome(_diff_status(dest, wanted), dest)
81
+
82
+
83
+ def apply_copy(source: Path, dest: Path, mode: str | None = None) -> None:
84
+ _write(dest, source.read_text(encoding="utf-8"), mode)
85
+
86
+
87
+ def plan_render(source: Path, dest: Path, variables: dict[str, str]) -> Outcome:
88
+ return Outcome(_diff_status(dest, render_template(source, variables)), dest)
89
+
90
+
91
+ def apply_render(
92
+ source: Path, dest: Path, variables: dict[str, str], mode: str | None = None
93
+ ) -> None:
94
+ _write(dest, render_template(source, variables), mode)
95
+
96
+
97
+ # --------------------------------------------------------------------------- #
98
+ # Managed blocks
99
+ #
100
+ # One marker-delimited region in a file macsetup does not own. Everything
101
+ # outside the markers is preserved untouched, so hand edits survive re-runs.
102
+
103
+
104
+ def _block_text(body: str, comment: str) -> str:
105
+ begin = BEGIN if comment == "#" else BEGIN.replace("#", comment, 1)
106
+ end = END if comment == "#" else END.replace("#", comment, 1)
107
+ return f"{begin}\n{body.rstrip()}\n{end}\n"
108
+
109
+
110
+ def compose_block(existing: str, body: str, *, comment: str = "#", position: str = "bottom") -> str:
111
+ """Return `existing` with the managed block inserted or replaced in place."""
112
+ block = _block_text(body, comment)
113
+ begin = block.splitlines()[0]
114
+ end = block.splitlines()[-1]
115
+
116
+ if begin in existing and end in existing:
117
+ head, _, rest = existing.partition(begin)
118
+ _, _, tail = rest.partition(end)
119
+ return head + block.rstrip("\n") + tail
120
+
121
+ if not existing.strip():
122
+ return block
123
+ if position == "top":
124
+ # SSH cares about ordering: `Include` has to precede the Host blocks it
125
+ # is meant to win against, so this cannot simply append.
126
+ return block + "\n" + existing
127
+ return existing.rstrip("\n") + "\n\n" + block
128
+
129
+
130
+ def plan_block(dest: Path, body: str, *, comment: str = "#", position: str = "bottom") -> Outcome:
131
+ existing = dest.read_text(encoding="utf-8") if dest.exists() else ""
132
+ wanted = compose_block(existing, body, comment=comment, position=position)
133
+ if not dest.exists():
134
+ return Outcome(FileStatus.CREATE, dest, "new file")
135
+ if existing == wanted:
136
+ return Outcome(FileStatus.OK, dest)
137
+ detail = "update block" if BEGIN.replace("#", comment, 1) in existing else "insert block"
138
+ return Outcome(FileStatus.UPDATE, dest, detail)
139
+
140
+
141
+ def apply_block(
142
+ dest: Path, body: str, *, comment: str = "#", position: str = "bottom", mode: str | None = None
143
+ ) -> None:
144
+ existing = dest.read_text(encoding="utf-8") if dest.exists() else ""
145
+ dest.parent.mkdir(parents=True, exist_ok=True)
146
+ dest.write_text(
147
+ compose_block(existing, body, comment=comment, position=position), encoding="utf-8"
148
+ )
149
+ if mode:
150
+ dest.chmod(int(mode, 8))