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/wizard.py ADDED
@@ -0,0 +1,169 @@
1
+ """The interactive selection flow.
2
+
3
+ Only ever *decides* things -- it returns a MachineState and touches nothing
4
+ else. Installing is apply's job, planning is planner's. Keeping the wizard
5
+ free of side effects is what lets every answer also be expressible as a flag.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import socket
11
+
12
+ import questionary
13
+ from questionary import Choice
14
+
15
+ from .detect import System
16
+ from .manifest import Catalog, Module
17
+ from .runner import Runner
18
+ from .state import MachineState
19
+
20
+ CUSTOM = "custom"
21
+
22
+ _STYLE = questionary.Style(
23
+ [
24
+ ("qmark", "fg:cyan bold"),
25
+ ("question", "bold"),
26
+ ("answer", "fg:cyan"),
27
+ ("pointer", "fg:cyan bold"),
28
+ ("highlighted", "fg:cyan bold"),
29
+ ("selected", "fg:green"),
30
+ ("instruction", "fg:#888888"),
31
+ ]
32
+ )
33
+
34
+
35
+ class WizardAborted(RuntimeError):
36
+ pass
37
+
38
+
39
+ def _ask(question):
40
+ answer = question.ask()
41
+ if answer is None:
42
+ raise WizardAborted("cancelled")
43
+ return answer
44
+
45
+
46
+ def default_machine_name(runner: Runner) -> str:
47
+ result = runner.run(["scutil", "--get", "ComputerName"])
48
+ if result.ok and result.stdout.strip():
49
+ return result.stdout.strip()
50
+ return socket.gethostname().removesuffix(".local")
51
+
52
+
53
+ def _module_label(module: Module, system: System) -> str:
54
+ total = len(module.packages)
55
+ installed = sum(1 for p in module.packages if system.has(p.name, cask=p.cask))
56
+ suffix = f"{installed}/{total} installed" if installed else f"{total} pkg"
57
+ detail = f" — {module.description}" if module.description else ""
58
+ return f"{module.id:<14} [{suffix}]{detail}"
59
+
60
+
61
+ def run(
62
+ catalog: Catalog,
63
+ system: System,
64
+ runner: Runner,
65
+ *,
66
+ current: MachineState | None = None,
67
+ ) -> MachineState:
68
+ """Ask the questions. Returns the chosen state; saving is the caller's job."""
69
+
70
+ machine = _ask(
71
+ questionary.text(
72
+ "Name this machine",
73
+ default=current.machine if current else default_machine_name(runner),
74
+ style=_STYLE,
75
+ )
76
+ ).strip()
77
+
78
+ profile_choices = [
79
+ Choice(
80
+ title=f"{name:<10} {'everything' if entries == ['*'] else f'{len(entries)} modules'}",
81
+ value=name,
82
+ )
83
+ for name, entries in catalog.profiles.items()
84
+ ]
85
+ profile_choices.append(Choice(title="custom pick modules yourself", value=CUSTOM))
86
+
87
+ profile = _ask(
88
+ questionary.select(
89
+ "Profile",
90
+ choices=profile_choices,
91
+ default=next(
92
+ (c for c in profile_choices if c.value == (current.profile if current else "work")),
93
+ None,
94
+ ),
95
+ style=_STYLE,
96
+ )
97
+ )
98
+
99
+ # Pre-tick from the current machine if reconfiguring, otherwise the profile.
100
+ # An empty saved selection falls through to the profile on purpose: pre-ticking
101
+ # nothing makes plain Enter save nothing, so an empty machine.toml would
102
+ # otherwise re-empty itself every time the wizard runs.
103
+ if current and current.modules and profile == current.profile:
104
+ preselected = set(current.modules)
105
+ elif profile == CUSTOM:
106
+ preselected = set()
107
+ else:
108
+ preselected = {m.id for m in catalog.resolve(profile) if not m.always}
109
+
110
+ optional = [m for m in catalog.modules.values() if not m.always]
111
+ always = [m for m in catalog.modules.values() if m.always]
112
+ if always:
113
+ names = ", ".join(m.id for m in always)
114
+ questionary.print(f" always included: {names}", style="fg:#888888")
115
+
116
+ chosen = _ask(
117
+ questionary.checkbox(
118
+ "Modules for this machine",
119
+ choices=[
120
+ Choice(
121
+ title=_module_label(m, system),
122
+ value=m.id,
123
+ checked=m.id in preselected,
124
+ )
125
+ for m in optional
126
+ ],
127
+ style=_STYLE,
128
+ )
129
+ )
130
+
131
+ # requires may pull in modules the user did not tick; surface that rather
132
+ # than silently installing something they did not choose.
133
+ resolved = catalog.resolve(only=chosen)
134
+ pulled = [m.id for m in resolved if m.id not in chosen and not m.always]
135
+ if pulled:
136
+ questionary.print(f" + required by your selection: {', '.join(pulled)}", style="fg:yellow")
137
+
138
+ selected_ids = [m.id for m in resolved if not m.always]
139
+
140
+ picks: dict[str, list[str]] = {}
141
+ for module in resolved:
142
+ if module.select != "pick":
143
+ continue
144
+ if current and module.id in current.picks:
145
+ checked = set(current.picks[module.id])
146
+ else:
147
+ checked = set(module.default)
148
+ choices = []
149
+ for package in module.packages:
150
+ mark = " (installed)" if system.has(package.name, cask=package.cask) else ""
151
+ choices.append(
152
+ Choice(
153
+ title=f"{package.name}{mark}",
154
+ value=package.name,
155
+ checked=package.name in checked,
156
+ )
157
+ )
158
+ answer = _ask(
159
+ questionary.checkbox(f"{module.name} — choose packages", choices=choices, style=_STYLE)
160
+ )
161
+ picks[module.id] = list(answer)
162
+
163
+ return MachineState(
164
+ machine=machine,
165
+ profile=profile,
166
+ modules=selected_ids,
167
+ picks=picks,
168
+ vars=dict(current.vars) if current else {},
169
+ )
@@ -0,0 +1,255 @@
1
+ Metadata-Version: 2.5
2
+ Name: macsetup
3
+ Version: 0.1.0
4
+ Summary: Declarative provisioning for macOS: packages and dotfiles, with a preview
5
+ Project-URL: Homepage, https://github.com/neosergio/macsetup
6
+ Project-URL: Issues, https://github.com/neosergio/macsetup/issues
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Keywords: brew-bundle,dotfiles,homebrew,macos,provisioning
10
+ Classifier: Environment :: Console
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: MacOS :: MacOS X
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Topic :: System :: Installation/Setup
16
+ Requires-Python: >=3.12
17
+ Requires-Dist: jinja2>=3.1.6
18
+ Requires-Dist: pydantic>=2.9
19
+ Requires-Dist: pyyaml>=6.0
20
+ Requires-Dist: questionary>=2.1.1
21
+ Requires-Dist: rich>=13.7
22
+ Requires-Dist: tomli-w>=1.0
23
+ Requires-Dist: typer>=0.15
24
+ Description-Content-Type: text/markdown
25
+
26
+ # macsetup
27
+
28
+ [![CI](https://github.com/neosergio/macsetup/actions/workflows/ci.yml/badge.svg)](https://github.com/neosergio/macsetup/actions/workflows/ci.yml)
29
+
30
+ Declarative provisioning for macOS. Describe what a machine should have in
31
+ YAML; `macsetup plan` shows you the difference against reality, and
32
+ `macsetup apply` closes it.
33
+
34
+ ```
35
+ $ macsetup plan
36
+ ┏━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━┓
37
+ ┃ Module ┃ Package ┃ State ┃
38
+ ┡━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━┩
39
+ │ core │ git, gh │ satisfied │
40
+ │ cli │ ripgrep │ install │
41
+ │ cli │ ~/.config/macsetup/zsh/90-… │ update │
42
+ │ lang-python │ uv │ satisfied │
43
+ └──────────────┴──────────────────────────────┴───────────┘
44
+ 1 to install, 1 file to update, 6 satisfied
45
+ ```
46
+
47
+ Adding software is a YAML edit, never a code change. Re-running on a converged
48
+ machine is a no-op, and `plan` and `apply` share the same planner, so the
49
+ preview is exactly what apply acts on.
50
+
51
+ Built for the case where you have **several** Macs that should share an
52
+ environment without every machine getting every package.
53
+
54
+ ## Install
55
+
56
+ One line, on a Mac with nothing on it yet:
57
+
58
+ ```sh
59
+ /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/neosergio/macsetup/main/install.sh)"
60
+ ```
61
+
62
+ [`install.sh`](install.sh) installs Homebrew if it is missing, then uv, then
63
+ macsetup, and ends in the `init` wizard. Each step checks first, so it is safe
64
+ to re-run. Arguments replace the wizard, for unattended machines:
65
+
66
+ ```sh
67
+ curl -fsSL https://raw.githubusercontent.com/neosergio/macsetup/main/install.sh \
68
+ | bash -s -- apply --profile work --yes
69
+ ```
70
+
71
+ If you already have Homebrew, the manual route is three commands:
72
+
73
+ ```sh
74
+ brew install uv
75
+ uv tool install macsetup
76
+ macsetup init
77
+ ```
78
+
79
+ `init` runs a wizard (machine name → profile → per-module choices), saves the
80
+ result to `~/.config/macsetup/machine.toml`, and applies it. After that no
81
+ flags are needed.
82
+
83
+ ```sh
84
+ macsetup plan # what apply would do; changes nothing
85
+ macsetup apply # do it
86
+ macsetup status # drift: manifest vs this machine
87
+ macsetup untracked # installed packages no selected module accounts for
88
+ macsetup modules # the whole catalog and its profiles
89
+ ```
90
+
91
+ Every wizard answer has a flag equivalent, so a machine can be provisioned
92
+ unattended: `macsetup apply --profile work --yes`.
93
+
94
+ ## Your own catalog
95
+
96
+ macsetup ships a small example catalog so a fresh install has something to run
97
+ against. It is an example — replace it with yours:
98
+
99
+ ```sh
100
+ export MACSETUP_MANIFESTS=~/dotfiles/manifests
101
+ export MACSETUP_FILES=~/dotfiles/files
102
+ ```
103
+
104
+ Both are resolved in that order: the environment variable, then a `manifests/`
105
+ or `files/` directory in the current working directory, then the copy baked
106
+ into the wheel. Keeping your catalog in a private repo and the engine public
107
+ is the intended arrangement.
108
+
109
+ ### Modules
110
+
111
+ One file per module in `manifests/modules/`, where `id` matches the filename
112
+ stem:
113
+
114
+ ```yaml
115
+ id: cli
116
+ name: CLI tools
117
+ requires: [fonts] # dependency-closed at resolve time
118
+ packages:
119
+ - ripgrep # formula
120
+ - {name: ghostty, cask: true} # cask
121
+ files:
122
+ - copy: zsh/cli.zsh
123
+ to: ~/.config/macsetup/zsh/90-cli.zsh
124
+ ```
125
+
126
+ **Casks must be marked explicitly.** Several names exist in both namespaces —
127
+ `docker` is the CLI formula *and* Docker Desktop the cask — and a bare
128
+ `brew install docker` silently picks the formula. Marking casks is the single
129
+ biggest class of bug the manifest format exists to prevent.
130
+
131
+ `always: true` puts a module on every machine regardless of profile.
132
+ `select: pick` turns a module into a per-package menu in the wizard, with
133
+ `default:` pre-ticked. Pydantic forbids unknown keys, so a typo fails at load
134
+ rather than halfway through an install.
135
+
136
+ ### Profiles
137
+
138
+ `manifests/profiles.yaml` bundles modules into named sets; `"*"` means
139
+ everything.
140
+
141
+ ```yaml
142
+ profiles:
143
+ work: [cli, lang-python, editors]
144
+ minimal: [cli]
145
+ full: ["*"]
146
+ ```
147
+
148
+ `machine.toml` stores the **resolved module list**, not just the profile name,
149
+ so a module added to a profile upstream cannot silently install itself on
150
+ every machine the next time they sync. `status` reports unadopted modules;
151
+ `configure` adopts them.
152
+
153
+ ### Files
154
+
155
+ Three actions:
156
+
157
+ ```yaml
158
+ files:
159
+ - copy: zsh/python.zsh # verbatim
160
+ to: ~/.config/macsetup/zsh/20-python.zsh
161
+ - render: git/gitconfig.j2 # Jinja2 + machine vars
162
+ to: ~/.gitconfig
163
+ mode: "600"
164
+ - block: shell/zshrc-block.zsh # region in a file you don't own
165
+ into: ~/.zshrc
166
+ position: top # default bottom
167
+ ```
168
+
169
+ `block:` maintains a marker-delimited region. Lines outside the markers are
170
+ preserved; re-applying rewrites only what's between them. `position: top`
171
+ exists because some formats are order-sensitive — an `Include` in `ssh_config`
172
+ is ignored if it lands after a matching block.
173
+
174
+ `render:` substitutes variables from `manifests/vars.yaml`, overridden per
175
+ machine by the `[vars]` table in `machine.toml`, plus `machine` and `home`
176
+ which are always supplied. Rendering uses `StrictUndefined`: a template
177
+ referencing a variable with no value is a hard error, not a silently empty
178
+ setting.
179
+
180
+ Before overwriting a file it did not create, `copy`/`render` leave a
181
+ `.macsetup-bak` copy alongside it.
182
+
183
+ **Nothing is symlinked.** A symlink would point into the installed wheel and
184
+ break on the next upgrade, so files are copied and you re-run `apply` after
185
+ editing a fragment.
186
+
187
+ ## Design notes
188
+
189
+ A few decisions that are load-bearing, in case you're reading the source:
190
+
191
+ **Data is separate from code.** `manifests/` says what to install;
192
+ `src/macsetup/` only knows how. If adding software requires touching Python,
193
+ something is wrong.
194
+
195
+ **`brew list` runs exactly twice per invocation**, cached in `detect.py` —
196
+ never once per package. Checking 60 packages costs two subprocess calls, so a
197
+ converged machine plans in well under a second regardless of catalog size.
198
+ There is a test asserting the call count.
199
+
200
+ **Installs delegate to `brew bundle`** against a generated Brewfile rather than
201
+ shelling out per package. Homebrew already knows about retries, cask
202
+ quarantine and sudo prompts. Generated Brewfiles stay in
203
+ `~/.cache/macsetup/brewfiles/` so a failure can be inspected.
204
+
205
+ **`apply` never upgrades.** `brew bundle install` upgrades by default; macsetup
206
+ passes `--no-upgrade` so converging a machine cannot turn into a 40-minute
207
+ upgrade run. `--upgrade` is opt-in.
208
+
209
+ **Subprocess calls go through `runner.py`.** That protocol is the test seam —
210
+ the suite runs without touching the machine.
211
+
212
+ ## Development
213
+
214
+ ```sh
215
+ uv sync
216
+ uv run macsetup plan -p work
217
+ uv run pytest # no machine access required
218
+ uv run ruff check . && uv run ruff format .
219
+ uv tool install -e . # put `macsetup` on PATH, tracking this checkout
220
+ ```
221
+
222
+ Tests never mutate the machine: `FakeRunner` stands in for subprocesses, and
223
+ file steps run against a sandboxed `HOME`. To try the file actions safely:
224
+
225
+ ```sh
226
+ SB=/tmp/ms-try; mkdir -p $SB
227
+ HOME=$SB MACSETUP_CONFIG=$SB/.config uv run macsetup apply --only shell --yes
228
+ ```
229
+
230
+ `shell` has no packages, so that never invokes brew.
231
+
232
+ CI runs the same two things on every push. Lint, the suite and a check that
233
+ the wheel still carries the catalog go on Linux; `plan` and `apply` shell out
234
+ to `brew list`, so every profile is planned and dry-run on a macOS runner,
235
+ along with the file appliers against a throwaway `HOME` and a second apply to
236
+ prove re-running converges to nothing.
237
+
238
+ ## Not built yet
239
+
240
+ - `sync` (git pull + apply), `add`, `prune`, `upgrade`, `doctor`
241
+ - macOS `defaults write` tweaks as a step type
242
+ - Mac App Store apps (`brew bundle` handles `mas` entries natively; no manifest
243
+ uses them yet)
244
+
245
+ ## Prior art
246
+
247
+ [chezmoi](https://www.chezmoi.io/) does dotfiles thoroughly but not packages.
248
+ `brew bundle` does packages but has no preview, no drift report and no notion
249
+ of per-machine subsets. [nix-darwin](https://github.com/LnL7/nix-darwin) does
250
+ all of it and more, if you want to learn Nix. macsetup is the middle: plain
251
+ YAML, Homebrew underneath, a plan you read before anything happens.
252
+
253
+ ## License
254
+
255
+ MIT
@@ -0,0 +1,32 @@
1
+ macsetup/__init__.py,sha256=XaUMTRQDMeOi9KFq2M2WfVSq87BbjxQ88uF3kl6t5Gs,81
2
+ macsetup/brewfile.py,sha256=BXg17LSbtfDHW0o5hArYpxxD-ZZx_p2JmWUtBtsbuWo,2322
3
+ macsetup/cli.py,sha256=JRGKk8aEOdmgqLocj_fFgbFmwYtNNHfwNh9K9liDDP8,17699
4
+ macsetup/detect.py,sha256=tNids4Cv-9C77c1CGEC2m8_9Bbzu-9ZKns-o9ln11ww,1563
5
+ macsetup/files.py,sha256=S0oOVbCl2Ns-SkxwRgVU1xS_Evq0RqnzgVU4k8awZqw,5194
6
+ macsetup/manifest.py,sha256=zkoNYoBE23gKcYPHMc0uAUtjbc9YmAiRlySHvndyY-w,12036
7
+ macsetup/planner.py,sha256=473VxyYYeTtzmdaZspbmrbdQ1a4ZhtbNW3LoM0t_VfQ,4241
8
+ macsetup/runner.py,sha256=Cq7_W1sit5zg6MguLvBkFM2Px54c90fVFr89Tzdle8c,2233
9
+ macsetup/state.py,sha256=jlKiOxxBOpNcMBtekZTpq_3himlFzojXCEJcpi1LOXY,3422
10
+ macsetup/wizard.py,sha256=oOl8h485T4XPqMbAvLB9O5Bu0b85LhDVKZU5SaS5MTw,5351
11
+ macsetup/_data/files/git/gitconfig.j2,sha256=gpeUG8-sNpehJ9EXntoLwdlsJNCrIiPHvvG5zrOmeeY,500
12
+ macsetup/_data/files/shell/zshrc-block.zsh,sha256=yHXw5U9Vyp3DB9zma59k4Oo3Dj90zxU6qKARcKBfE1g,234
13
+ macsetup/_data/files/zsh/00-homebrew.zsh,sha256=PBJkPfefy40uB2415t4DXeGuXyTHdjMqOrxgS-9u4w8,474
14
+ macsetup/_data/files/zsh/10-completion.zsh,sha256=VnPCobzCCFwtpvDqe-xfrWRTuQTa-xxyQmxMBdlm2Z0,938
15
+ macsetup/_data/files/zsh/cli.zsh,sha256=zq-UujtIOiacPX40pvtJzkeWMqRVx9Qw6zUoEaHk-7Q,916
16
+ macsetup/_data/files/zsh/node.zsh,sha256=XISalK4-90fBmNYSlgzsMaGkFCyKFL4XwAkGv92QzjE,377
17
+ macsetup/_data/files/zsh/python.zsh,sha256=W8v5_bYgYCT5TEIr4p8g2zTqWIupLXYjhrDaDKShJ8g,489
18
+ macsetup/_data/manifests/profiles.yaml,sha256=hfiJ3_KCRh7PvApdzCl7xHDgjeFn0-A_1cs8bO76mNg,596
19
+ macsetup/_data/manifests/vars.yaml,sha256=ltXckqGmtD7HoYCicl5KXdCCCscdwvek-LSIdqhXu5Q,439
20
+ macsetup/_data/manifests/modules/cli.yaml,sha256=WaC8bF-tcCJ5CceAidDV6hJiEYW2yvghRhmw50ixJgo,469
21
+ macsetup/_data/manifests/modules/core.yaml,sha256=OeLHEYB5nZ7gXEJPK31P3RDGgslqJXqya1j0rZkGXo0,118
22
+ macsetup/_data/manifests/modules/editors.yaml,sha256=vnzxbWIz1X29L5NlRef_Wi3ntyOjT-SCuRLcWPmFmQQ,621
23
+ macsetup/_data/manifests/modules/fonts.yaml,sha256=AIBIIuPz0Y1zELGIdbaeCecyBy1zYJMEfgq4k3sDfz8,147
24
+ macsetup/_data/manifests/modules/git-identity.yaml,sha256=BjUHAXIyVJC-a5RIBo4Eh0YQIluGuWgYHuMSfze2L1Y,741
25
+ macsetup/_data/manifests/modules/lang-node.yaml,sha256=g64UiC0F025RxlIkrFoVCFR-tp2anCNetvcSck9lQ0g,171
26
+ macsetup/_data/manifests/modules/lang-python.yaml,sha256=2PJXLw4qibmEnriBABwTt-8de4ZhSlOJ96pwbNaxiDI,193
27
+ macsetup/_data/manifests/modules/shell.yaml,sha256=i-ccXMNyF1-JNG3BXM--m045_Aq3UeEOScV_hi8k5q8,766
28
+ macsetup-0.1.0.dist-info/METADATA,sha256=oc-NKvHcBLFUhU0aM8MG_LgyKmabQIyrGXznvIWd3tc,9597
29
+ macsetup-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
30
+ macsetup-0.1.0.dist-info/entry_points.txt,sha256=aqEEFnIkOEgSnrbNChMkdVQf37CJ5NaCh6N53OMPNG8,46
31
+ macsetup-0.1.0.dist-info/licenses/LICENSE,sha256=fC6gbDhxVx-OgjDtdsNFmtniABmXidQ3-nhlXcPqs0w,1071
32
+ macsetup-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ macsetup = macsetup.cli:app
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sergio Infante
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.