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/__init__.py +1 -0
- macsetup/_data/files/git/gitconfig.j2 +17 -0
- macsetup/_data/files/shell/zshrc-block.zsh +6 -0
- macsetup/_data/files/zsh/00-homebrew.zsh +10 -0
- macsetup/_data/files/zsh/10-completion.zsh +22 -0
- macsetup/_data/files/zsh/cli.zsh +18 -0
- macsetup/_data/files/zsh/node.zsh +8 -0
- macsetup/_data/files/zsh/python.zsh +12 -0
- macsetup/_data/manifests/modules/cli.yaml +16 -0
- macsetup/_data/manifests/modules/core.yaml +7 -0
- macsetup/_data/manifests/modules/editors.yaml +16 -0
- macsetup/_data/manifests/modules/fonts.yaml +5 -0
- macsetup/_data/manifests/modules/git-identity.yaml +15 -0
- macsetup/_data/manifests/modules/lang-node.yaml +8 -0
- macsetup/_data/manifests/modules/lang-python.yaml +8 -0
- macsetup/_data/manifests/modules/shell.yaml +18 -0
- macsetup/_data/manifests/profiles.yaml +26 -0
- macsetup/_data/manifests/vars.yaml +10 -0
- macsetup/brewfile.py +59 -0
- macsetup/cli.py +481 -0
- macsetup/detect.py +53 -0
- macsetup/files.py +150 -0
- macsetup/manifest.py +321 -0
- macsetup/planner.py +141 -0
- macsetup/runner.py +71 -0
- macsetup/state.py +102 -0
- macsetup/wizard.py +169 -0
- macsetup-0.1.0.dist-info/METADATA +255 -0
- macsetup-0.1.0.dist-info/RECORD +32 -0
- macsetup-0.1.0.dist-info/WHEEL +4 -0
- macsetup-0.1.0.dist-info/entry_points.txt +2 -0
- macsetup-0.1.0.dist-info/licenses/LICENSE +21 -0
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))
|