macsetup 0.1.0__tar.gz

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.
Files changed (41) hide show
  1. macsetup-0.1.0/.github/workflows/ci.yml +131 -0
  2. macsetup-0.1.0/.github/workflows/release.yml +55 -0
  3. macsetup-0.1.0/.gitignore +6 -0
  4. macsetup-0.1.0/LICENSE +21 -0
  5. macsetup-0.1.0/PKG-INFO +255 -0
  6. macsetup-0.1.0/README.md +230 -0
  7. macsetup-0.1.0/files/git/gitconfig.j2 +17 -0
  8. macsetup-0.1.0/files/shell/zshrc-block.zsh +6 -0
  9. macsetup-0.1.0/files/zsh/00-homebrew.zsh +10 -0
  10. macsetup-0.1.0/files/zsh/10-completion.zsh +22 -0
  11. macsetup-0.1.0/files/zsh/cli.zsh +18 -0
  12. macsetup-0.1.0/files/zsh/node.zsh +8 -0
  13. macsetup-0.1.0/files/zsh/python.zsh +12 -0
  14. macsetup-0.1.0/install.sh +111 -0
  15. macsetup-0.1.0/manifests/modules/cli.yaml +16 -0
  16. macsetup-0.1.0/manifests/modules/core.yaml +7 -0
  17. macsetup-0.1.0/manifests/modules/editors.yaml +16 -0
  18. macsetup-0.1.0/manifests/modules/fonts.yaml +5 -0
  19. macsetup-0.1.0/manifests/modules/git-identity.yaml +15 -0
  20. macsetup-0.1.0/manifests/modules/lang-node.yaml +8 -0
  21. macsetup-0.1.0/manifests/modules/lang-python.yaml +8 -0
  22. macsetup-0.1.0/manifests/modules/shell.yaml +18 -0
  23. macsetup-0.1.0/manifests/profiles.yaml +26 -0
  24. macsetup-0.1.0/manifests/vars.yaml +10 -0
  25. macsetup-0.1.0/pyproject.toml +60 -0
  26. macsetup-0.1.0/src/macsetup/__init__.py +1 -0
  27. macsetup-0.1.0/src/macsetup/brewfile.py +59 -0
  28. macsetup-0.1.0/src/macsetup/cli.py +481 -0
  29. macsetup-0.1.0/src/macsetup/detect.py +53 -0
  30. macsetup-0.1.0/src/macsetup/files.py +150 -0
  31. macsetup-0.1.0/src/macsetup/manifest.py +321 -0
  32. macsetup-0.1.0/src/macsetup/planner.py +141 -0
  33. macsetup-0.1.0/src/macsetup/runner.py +71 -0
  34. macsetup-0.1.0/src/macsetup/state.py +102 -0
  35. macsetup-0.1.0/src/macsetup/wizard.py +169 -0
  36. macsetup-0.1.0/tests/test_files.py +209 -0
  37. macsetup-0.1.0/tests/test_manifest.py +176 -0
  38. macsetup-0.1.0/tests/test_planner.py +115 -0
  39. macsetup-0.1.0/tests/test_state.py +84 -0
  40. macsetup-0.1.0/tests/test_wizard.py +165 -0
  41. macsetup-0.1.0/uv.lock +584 -0
@@ -0,0 +1,131 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ workflow_dispatch:
8
+ workflow_call: # release.yml runs the full suite before publishing
9
+
10
+ concurrency:
11
+ group: ${{ github.workflow }}-${{ github.ref }}
12
+ cancel-in-progress: true
13
+
14
+ jobs:
15
+ # Everything that does not need a Mac. Fast, and the job that gates a PR.
16
+ lint-and-test:
17
+ runs-on: ubuntu-latest
18
+ steps:
19
+ - uses: actions/checkout@v7
20
+ # Pinned to an exact tag on purpose: setup-uv published floating major
21
+ # tags up to v6 and then stopped, so `@v10` is not a resolvable ref even
22
+ # though v10.3.2 is the current release. Bump this deliberately.
23
+ - uses: astral-sh/setup-uv@v10.3.2
24
+ with:
25
+ enable-cache: true
26
+
27
+ # --locked rather than plain sync: a pyproject edit that was never
28
+ # reflected in uv.lock should fail here, not drift silently.
29
+ - run: uv sync --locked
30
+
31
+ - run: uv run ruff check .
32
+ - run: uv run ruff format --check .
33
+ - run: uv run pytest -q
34
+ - run: shellcheck install.sh
35
+
36
+ # The suite loads the catalog and resolves every profile, so a YAML typo
37
+ # or a dangling `requires:` already fails above. This covers the other
38
+ # packaging invariant: manifests/ and files/ are force-included into the
39
+ # wheel as _data/, and without them `uv tool install macsetup` installs
40
+ # an engine with nothing to run against.
41
+ - name: the wheel carries the example catalog
42
+ run: |
43
+ uv build --wheel
44
+ for want in \
45
+ macsetup/_data/manifests/profiles.yaml \
46
+ macsetup/_data/manifests/vars.yaml \
47
+ macsetup/_data/manifests/modules/core.yaml \
48
+ macsetup/_data/files/shell/zshrc-block.zsh \
49
+ macsetup/_data/files/git/gitconfig.j2; do
50
+ if ! unzip -l dist/*.whl | grep -q "$want"; then
51
+ echo "::error file=pyproject.toml::$want is missing from the wheel"
52
+ exit 1
53
+ fi
54
+ done
55
+ echo "wheel contains the catalog"
56
+
57
+ # plan and apply shell out to `brew list`, so these need a runner that has
58
+ # Homebrew. This is the end-to-end pass: detect, plan, Brewfile generation
59
+ # and the file appliers, against a real brew rather than FakeRunner.
60
+ catalog:
61
+ runs-on: macos-latest
62
+ steps:
63
+ - uses: actions/checkout@v7
64
+ # Pinned to an exact tag on purpose: setup-uv published floating major
65
+ # tags up to v6 and then stopped, so `@v10` is not a resolvable ref even
66
+ # though v10.3.2 is the current release. Bump this deliberately.
67
+ - uses: astral-sh/setup-uv@v10.3.2
68
+ with:
69
+ enable-cache: true
70
+ - run: uv sync --locked
71
+
72
+ - name: the catalog loads
73
+ run: uv run macsetup modules
74
+
75
+ # No machine.toml exists on a runner, so every profile is exercised
76
+ # through the --profile path with picks falling back to their defaults.
77
+ - name: every profile plans and dry-runs
78
+ run: |
79
+ profiles=$(uv run python -c \
80
+ "import yaml;print(' '.join(yaml.safe_load(open('manifests/profiles.yaml'))['profiles']))")
81
+ echo "profiles: $profiles"
82
+ for p in $profiles; do
83
+ echo "::group::$p"
84
+ uv run macsetup plan --profile "$p"
85
+ uv run macsetup apply --profile "$p" --dry-run --yes
86
+ echo "::endgroup::"
87
+ done
88
+
89
+ - name: brewfiles generate
90
+ run: uv run macsetup plan --profile full --show-brewfile
91
+
92
+ - name: status and untracked run against a real machine
93
+ run: |
94
+ uv run macsetup status --profile minimal
95
+ uv run macsetup untracked --profile minimal
96
+
97
+ # The file appliers for real, against a throwaway HOME. `shell` has no
98
+ # packages, so brew is never invoked and nothing on the runner changes.
99
+ - name: file actions apply in a sandbox
100
+ run: |
101
+ sb=$(mktemp -d)
102
+ HOME="$sb" MACSETUP_CONFIG="$sb/.config" \
103
+ uv run macsetup apply --only shell --only git-identity --yes
104
+ test -f "$sb/.zshrc"
105
+ test -f "$sb/.config/macsetup/zsh/00-homebrew.zsh"
106
+ test -f "$sb/.config/macsetup/zsh/10-completion.zsh"
107
+ test -f "$sb/.config/macsetup/example-gitconfig"
108
+ # vars.yaml supplies the defaults now that the engine ships none
109
+ grep -q 'you@example.com' "$sb/.config/macsetup/example-gitconfig"
110
+ grep -q '>>> macsetup >>>' "$sb/.zshrc"
111
+ echo "file actions verified"
112
+
113
+ # The one-line installer, piped the way the README shows it, against a
114
+ # throwaway HOME. The runner already has brew and uv, so this covers the
115
+ # macsetup install and the hand-off; running it twice proves re-runs work.
116
+ - name: install.sh bootstraps and re-runs
117
+ run: |
118
+ sb=$(mktemp -d)
119
+ for run in 1 2; do
120
+ HOME="$sb" MACSETUP_SOURCE="$PWD" bash -s -- plan --profile minimal < install.sh
121
+ done
122
+ test -x "$sb/.local/bin/macsetup"
123
+
124
+ # Re-applying a converged machine must be a no-op, not a second write.
125
+ - name: applying twice is idempotent
126
+ run: |
127
+ sb=$(mktemp -d)
128
+ export HOME="$sb" MACSETUP_CONFIG="$sb/.config"
129
+ uv run macsetup apply --only shell --yes >/dev/null
130
+ uv run macsetup apply --only shell --yes | tee /tmp/second.txt
131
+ grep -q 'Nothing to do' /tmp/second.txt
@@ -0,0 +1,55 @@
1
+ name: Release
2
+
3
+ # Pushing a tag like v0.2.0 publishes that version to PyPI. Bump `version` in
4
+ # pyproject.toml first; the tag has to match it or nothing is published.
5
+ on:
6
+ push:
7
+ tags: ["v*"]
8
+
9
+ jobs:
10
+ # The full CI run, Linux and macOS, gates the upload: a tag on a commit that
11
+ # never passed CI should not reach PyPI.
12
+ ci:
13
+ uses: ./.github/workflows/ci.yml
14
+
15
+ build:
16
+ needs: ci
17
+ runs-on: ubuntu-latest
18
+ steps:
19
+ - uses: actions/checkout@v7
20
+ # Pinned to an exact tag on purpose; see the note in ci.yml.
21
+ - uses: astral-sh/setup-uv@v10.3.2
22
+
23
+ # PyPI versions are permanent -- a mismatch caught here costs a re-tag,
24
+ # one caught after upload costs a version number.
25
+ - name: tag matches pyproject version
26
+ run: |
27
+ version=$(uv version --short)
28
+ if [ "v$version" != "$GITHUB_REF_NAME" ]; then
29
+ echo "::error file=pyproject.toml::tag $GITHUB_REF_NAME but pyproject says $version"
30
+ exit 1
31
+ fi
32
+
33
+ - run: uv build
34
+ - uses: actions/upload-artifact@v7
35
+ with:
36
+ name: dist
37
+ path: dist/
38
+
39
+ # Separate from build so the OIDC token that can publish never shares a job
40
+ # with the build. Trusted publishing: no API token is stored anywhere, PyPI
41
+ # trusts this workflow + environment directly.
42
+ publish:
43
+ needs: build
44
+ runs-on: ubuntu-latest
45
+ environment:
46
+ name: pypi
47
+ url: https://pypi.org/project/macsetup/
48
+ permissions:
49
+ id-token: write
50
+ steps:
51
+ - uses: actions/download-artifact@v8
52
+ with:
53
+ name: dist
54
+ path: dist/
55
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,6 @@
1
+ .venv/
2
+ .pytest_cache/
3
+ .ruff_cache/
4
+ __pycache__/
5
+ *.pyc
6
+ dist/
macsetup-0.1.0/LICENSE ADDED
@@ -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.
@@ -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