nullforge 0.1.0__tar.gz → 0.2.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.
- {nullforge-0.1.0 → nullforge-0.2.0}/.github/workflows/release-please.yaml +7 -7
- nullforge-0.2.0/.release-please-manifest.json +3 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/CHANGELOG.md +11 -0
- nullforge-0.2.0/PKG-INFO +68 -0
- nullforge-0.2.0/README.md +52 -0
- nullforge-0.2.0/nullforge/__init__.py +1 -0
- nullforge-0.2.0/nullforge/cli/__main__.py +42 -0
- nullforge-0.2.0/nullforge/cli/app.py +58 -0
- nullforge-0.2.0/nullforge/cli/components/completion/component.py +38 -0
- nullforge-0.2.0/nullforge/cli/components/completion/controller.py +99 -0
- nullforge-0.2.0/nullforge/cli/components/completion/errors.py +13 -0
- nullforge-0.2.0/nullforge/cli/components/completion/powershell.py +81 -0
- nullforge-0.2.0/nullforge/cli/components/foundry/__init__.py +0 -0
- nullforge-0.2.0/nullforge/cli/components/foundry/component.py +137 -0
- nullforge-0.2.0/nullforge/cli/components/foundry/controller.py +143 -0
- nullforge-0.2.0/nullforge/cli/components/foundry/errors.py +15 -0
- nullforge-0.2.0/nullforge/cli/components/runes/__init__.py +0 -0
- nullforge-0.2.0/nullforge/cli/components/runes/component.py +30 -0
- nullforge-0.2.0/nullforge/cli/components/runes/controller.py +19 -0
- nullforge-0.2.0/nullforge/cli/core/__init__.py +14 -0
- nullforge-0.2.0/nullforge/cli/core/application.py +64 -0
- nullforge-0.2.0/nullforge/cli/core/component.py +29 -0
- nullforge-0.2.0/nullforge/cli/core/controller.py +14 -0
- nullforge-0.2.0/nullforge/cli/core/errors.py +23 -0
- nullforge-0.2.0/nullforge/cli/core/types.py +13 -0
- nullforge-0.2.0/nullforge/cli/display.py +42 -0
- nullforge-0.2.0/nullforge/cli/types.py +122 -0
- nullforge-0.2.0/nullforge/foundry/README.md +0 -0
- nullforge-0.2.0/nullforge/foundry/_pyinfra.py +111 -0
- nullforge-0.2.0/nullforge/foundry/cast.py +25 -0
- nullforge-0.2.0/nullforge/inventories/README.md +0 -0
- nullforge-0.2.0/nullforge/smithy/monitoring/nezha/__init__.py +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/pyproject.toml +1 -0
- nullforge-0.1.0/.release-please-manifest.json +0 -3
- nullforge-0.1.0/PKG-INFO +0 -39
- nullforge-0.1.0/README.md +0 -23
- nullforge-0.1.0/nullforge/__init__.py +0 -1
- {nullforge-0.1.0 → nullforge-0.2.0}/.gitattributes +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/.github/CODEOWNERS +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/.github/CONTRIBUTING.md +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/.github/ISSUE_TEMPLATE/BUG-REPORT.yaml +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/.github/ISSUE_TEMPLATE/FEATURE-REQUEST.yaml +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/.github/ISSUE_TEMPLATE/config.yaml +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/.github/dependabot.yml +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/.github/labeler.yaml +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/.github/labels.yaml +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/.github/release.yaml +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/.github/workflows/check-commit-message-pr.yaml +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/.github/workflows/ci-code-quality.yaml +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/.github/workflows/ci-tests.yaml +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/.github/workflows/label-prs.yaml +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/.github/workflows/labels.yaml +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/.gitignore +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/.pre-commit-config.yaml +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/CODE_OF_CONDUCT.md +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/LICENSE +0 -0
- {nullforge-0.1.0/nullforge/smithy/monitoring/nezha → nullforge-0.2.0/nullforge/cli}/__init__.py +0 -0
- /nullforge-0.1.0/nullforge/foundry/README.md → /nullforge-0.2.0/nullforge/cli/components/__init__.py +0 -0
- /nullforge-0.1.0/nullforge/inventories/README.md → /nullforge-0.2.0/nullforge/cli/components/completion/__init__.py +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/foundry/__init__.py +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/foundry/full_cast.py +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/inventories/example.py +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/models/__init__.py +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/models/containers.py +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/models/dns.py +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/models/monitoring/__init__.py +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/models/monitoring/base.py +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/models/monitoring/nezha.py +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/models/netsec.py +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/models/profiles.py +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/models/system.py +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/models/users.py +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/models/warp.py +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/models/zerotrust.py +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/molds/__init__.py +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/molds/base_mold.py +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/molds/containers.py +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/molds/defaults.py +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/molds/dns.py +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/molds/features.py +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/molds/haproxy.py +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/molds/monitoring/__init__.py +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/molds/monitoring/nezha.py +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/molds/netsec.py +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/molds/profiles.py +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/molds/system.py +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/molds/telemt.py +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/molds/tor.py +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/molds/user.py +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/molds/utils.py +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/molds/warp.py +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/molds/xray.py +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/molds/zerotrust.py +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/runes/__init__.py +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/runes/base.py +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/runes/containers.py +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/runes/dns.py +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/runes/haproxy.py +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/runes/monitoring.py +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/runes/netsec.py +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/runes/prepare.py +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/runes/profiles.py +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/runes/telemt.py +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/runes/tor.py +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/runes/users.py +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/runes/warp.py +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/runes/xray.py +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/runes/zerotrust.py +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/smithy/__init__.py +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/smithy/admin.py +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/smithy/arch.py +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/smithy/blocky.py +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/smithy/cloudflare.py +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/smithy/github.py +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/smithy/http.py +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/smithy/install.py +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/smithy/monitoring/__init__.py +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/smithy/monitoring/nezha/agent.py +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/smithy/monitoring/nezha/dashboard.py +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/smithy/monitoring/nezha/deploy.py +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/smithy/network.py +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/smithy/packages.py +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/smithy/service.py +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/smithy/sni.py +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/smithy/swap.py +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/smithy/system.py +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/smithy/versions.py +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/templates/__init__.py +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/templates/cloudflared/tunnel.yml.j2 +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/templates/dns/blocky.yaml.j2 +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/templates/dns/dns.yaml.j2 +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/templates/dns/resolv.conf.j2 +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/templates/dns/resolved.conf.j2 +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/templates/etc/default/zramswap.j2 +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/templates/nvim/nvim_patch.lua.j2 +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/templates/profiles/direnv.toml +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/templates/profiles/starship.toml +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/templates/profiles/tmux.conf +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/templates/profiles/zshrc.j2 +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/templates/scripts/telemt-synfix.sh +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/templates/scripts/teleproxy-warp.sh +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/templates/scripts/warp-v6-policy.sh +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/templates/scripts/zt-tunnel-warp.sh +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/templates/systemd/blocky.service.j2 +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/templates/systemd/cloudflare-tunnel.service.j2 +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/templates/systemd/cloudflare-warp.service.j2 +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/templates/systemd/dns-internal.service.j2 +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/templates/systemd/telemt.service.j2 +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/templates/telemt/telemt.toml.j2 +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/templates/tor/torrc.j2 +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/release-please-config.json +0 -0
- {nullforge-0.1.0 → nullforge-0.2.0}/uv.lock +0 -0
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
name:
|
|
1
|
+
name: 🚀 Release
|
|
2
2
|
|
|
3
3
|
on:
|
|
4
4
|
push:
|
|
5
|
-
branches:
|
|
6
|
-
|
|
5
|
+
branches: [main]
|
|
6
|
+
workflow_dispatch:
|
|
7
7
|
|
|
8
8
|
permissions:
|
|
9
9
|
contents: read
|
|
@@ -14,7 +14,7 @@ concurrency:
|
|
|
14
14
|
|
|
15
15
|
jobs:
|
|
16
16
|
release-please:
|
|
17
|
-
name:
|
|
17
|
+
name: 🔖 Create release
|
|
18
18
|
runs-on: ubuntu-latest
|
|
19
19
|
timeout-minutes: 5
|
|
20
20
|
permissions:
|
|
@@ -78,7 +78,7 @@ jobs:
|
|
|
78
78
|
path: dist/
|
|
79
79
|
|
|
80
80
|
attest-artifacts:
|
|
81
|
-
name:
|
|
81
|
+
name: 🔏 Attest provenance
|
|
82
82
|
needs: [release-please, build]
|
|
83
83
|
if: needs.release-please.outputs.release_created == 'true'
|
|
84
84
|
runs-on: ubuntu-latest
|
|
@@ -123,7 +123,7 @@ jobs:
|
|
|
123
123
|
run: uv publish --trusted-publishing always
|
|
124
124
|
|
|
125
125
|
finalize-release:
|
|
126
|
-
name: 🚀 Attach assets and publish
|
|
126
|
+
name: 🚀 Attach assets and publish
|
|
127
127
|
needs: [release-please, build, attest-artifacts, publish-pypi]
|
|
128
128
|
if: needs.release-please.outputs.release_created == 'true'
|
|
129
129
|
runs-on: ubuntu-latest
|
|
@@ -173,7 +173,7 @@ jobs:
|
|
|
173
173
|
gh api --method PATCH "repos/${GITHUB_REPOSITORY}/releases/${release_id}" -F draft=false
|
|
174
174
|
|
|
175
175
|
release-pr:
|
|
176
|
-
name:
|
|
176
|
+
name: 🔁 Open next release PR
|
|
177
177
|
needs: [release-please, finalize-release]
|
|
178
178
|
if: |
|
|
179
179
|
always()
|
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.2.0](https://github.com/wlix13/NullForge/compare/v0.1.0...v0.2.0) (2026-07-25)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### ⚠ BREAKING CHANGES
|
|
7
|
+
|
|
8
|
+
* **cli:** `nullforge cast -i <inventory>` replaces `pyinfra <inventory> nullforge/foundry/full_cast.py` invocation.
|
|
9
|
+
|
|
10
|
+
### Features
|
|
11
|
+
|
|
12
|
+
* **cli:** add nullforge CLI ([1f842e3](https://github.com/wlix13/NullForge/commit/1f842e38e1cecffa9ea390a1142d38854c1bde9b))
|
|
13
|
+
|
|
3
14
|
## 0.1.0 (2026-07-25)
|
|
4
15
|
|
|
5
16
|
|
nullforge-0.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nullforge
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Forge the server's baseline from null
|
|
5
|
+
Author-email: wlix13 <github@wlix13.dev>
|
|
6
|
+
Maintainer-email: wlix13 <github@wlix13.dev>
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
10
|
+
Requires-Python: <3.14,>=3.13
|
|
11
|
+
Requires-Dist: click
|
|
12
|
+
Requires-Dist: pydantic
|
|
13
|
+
Requires-Dist: pyinfra
|
|
14
|
+
Requires-Dist: rich-click
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# NullForge
|
|
18
|
+
|
|
19
|
+
Forge the server's baseline from null — an infrastructure-as-code framework built on [pyinfra](https://pyinfra.com), themed around a blacksmith's forge: **inventories** define hosts, **molds** shape the configuration, **runes** are idempotent operation sets, the **foundry** casts them onto targets, and the **smithy** holds cross-distro helpers.
|
|
20
|
+
|
|
21
|
+
> [!WARNING]
|
|
22
|
+
> **NullForge is in active development.** Until the `v1.0.0` release, the CLI, mold schemas and deploy behaviour may change at any time — breaking changes can land in **any** release, including patch versions. Pin an exact version (e.g. `nullforge==0.1.0`) and check the [release notes](https://github.com/wlix13/NullForge/releases) before upgrading.
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
# Latest release from PyPI — puts `nullforge` on PATH
|
|
28
|
+
uv tool install nullforge
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
From a source checkout instead, when you want the Python API (`nullforge.molds`, `nullforge.smithy`, ...) or to hack on NullForge itself:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
uv sync
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Every command below then runs as `uv run nullforge ...`.
|
|
38
|
+
|
|
39
|
+
## CLI
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
# Full cast (all enabled features from the inventory)
|
|
43
|
+
nullforge cast -i nullforge/inventories/example.py
|
|
44
|
+
|
|
45
|
+
# Cast specific runes, in order (built-in names or paths to custom rune files)
|
|
46
|
+
nullforge cast -i nullforge/inventories/example.py -r warp -r dns
|
|
47
|
+
nullforge cast -i nullforge/inventories/example.py -r ./my_rune.py
|
|
48
|
+
|
|
49
|
+
# Fresh host without sudo: run the prepare rune as a separate first deploy
|
|
50
|
+
nullforge cast -i nullforge/inventories/example.py -r base --with-prepare
|
|
51
|
+
|
|
52
|
+
# Connection flags are proxied to pyinfra; unknown options pass through verbatim
|
|
53
|
+
nullforge cast -i nullforge/inventories/example.py -r dns --ssh-user root --ssh-port 2222 --dry -v
|
|
54
|
+
|
|
55
|
+
# List built-in runes
|
|
56
|
+
nullforge runes
|
|
57
|
+
|
|
58
|
+
# Shell completion (bash, zsh, fish, powershell) — print or install
|
|
59
|
+
nullforge completion powershell --install
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Custom rune files are plain pyinfra deploy modules and can use the full NullForge API, since they run in the same environment.
|
|
63
|
+
|
|
64
|
+
Note for PowerShell users: quote `@local`-style host specs (`-i "@local"`) — unquoted `@name` is PowerShell splatting syntax and is swallowed by the shell.
|
|
65
|
+
|
|
66
|
+
## Contributing
|
|
67
|
+
|
|
68
|
+
See [CONTRIBUTING.md](.github/CONTRIBUTING.md) for the full setup, commit conventions and pull request flow.
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# NullForge
|
|
2
|
+
|
|
3
|
+
Forge the server's baseline from null — an infrastructure-as-code framework built on [pyinfra](https://pyinfra.com), themed around a blacksmith's forge: **inventories** define hosts, **molds** shape the configuration, **runes** are idempotent operation sets, the **foundry** casts them onto targets, and the **smithy** holds cross-distro helpers.
|
|
4
|
+
|
|
5
|
+
> [!WARNING]
|
|
6
|
+
> **NullForge is in active development.** Until the `v1.0.0` release, the CLI, mold schemas and deploy behaviour may change at any time — breaking changes can land in **any** release, including patch versions. Pin an exact version (e.g. `nullforge==0.1.0`) and check the [release notes](https://github.com/wlix13/NullForge/releases) before upgrading.
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
# Latest release from PyPI — puts `nullforge` on PATH
|
|
12
|
+
uv tool install nullforge
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
From a source checkout instead, when you want the Python API (`nullforge.molds`, `nullforge.smithy`, ...) or to hack on NullForge itself:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
uv sync
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Every command below then runs as `uv run nullforge ...`.
|
|
22
|
+
|
|
23
|
+
## CLI
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
# Full cast (all enabled features from the inventory)
|
|
27
|
+
nullforge cast -i nullforge/inventories/example.py
|
|
28
|
+
|
|
29
|
+
# Cast specific runes, in order (built-in names or paths to custom rune files)
|
|
30
|
+
nullforge cast -i nullforge/inventories/example.py -r warp -r dns
|
|
31
|
+
nullforge cast -i nullforge/inventories/example.py -r ./my_rune.py
|
|
32
|
+
|
|
33
|
+
# Fresh host without sudo: run the prepare rune as a separate first deploy
|
|
34
|
+
nullforge cast -i nullforge/inventories/example.py -r base --with-prepare
|
|
35
|
+
|
|
36
|
+
# Connection flags are proxied to pyinfra; unknown options pass through verbatim
|
|
37
|
+
nullforge cast -i nullforge/inventories/example.py -r dns --ssh-user root --ssh-port 2222 --dry -v
|
|
38
|
+
|
|
39
|
+
# List built-in runes
|
|
40
|
+
nullforge runes
|
|
41
|
+
|
|
42
|
+
# Shell completion (bash, zsh, fish, powershell) — print or install
|
|
43
|
+
nullforge completion powershell --install
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Custom rune files are plain pyinfra deploy modules and can use the full NullForge API, since they run in the same environment.
|
|
47
|
+
|
|
48
|
+
Note for PowerShell users: quote `@local`-style host specs (`-i "@local"`) — unquoted `@name` is PowerShell splatting syntax and is swallowed by the shell.
|
|
49
|
+
|
|
50
|
+
## Contributing
|
|
51
|
+
|
|
52
|
+
See [CONTRIBUTING.md](.github/CONTRIBUTING.md) for the full setup, commit conventions and pull request flow.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.0" # x-release-please-version
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""NullForge CLI entry point."""
|
|
2
|
+
|
|
3
|
+
from click.exceptions import Abort, ClickException, Exit
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
|
|
6
|
+
from nullforge.cli.app import cli
|
|
7
|
+
from nullforge.cli.components.foundry.errors import PyinfraExecutionFailed
|
|
8
|
+
from nullforge.cli.core import Error
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
_err = Console(stderr=True)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def main() -> None:
|
|
15
|
+
try:
|
|
16
|
+
cli(standalone_mode=False, prog_name="nullforge")
|
|
17
|
+
except PyinfraExecutionFailed as e:
|
|
18
|
+
_err.print(f"[red bold]Error:[/red bold] {e}")
|
|
19
|
+
raise SystemExit(e.returncode)
|
|
20
|
+
except Error as e:
|
|
21
|
+
_err.print(f"[red bold]Error:[/red bold] {e}")
|
|
22
|
+
raise SystemExit(1)
|
|
23
|
+
except ClickException as e:
|
|
24
|
+
e.show()
|
|
25
|
+
raise SystemExit(e.exit_code)
|
|
26
|
+
except Exit as e:
|
|
27
|
+
raise SystemExit(e.exit_code)
|
|
28
|
+
except SystemExit:
|
|
29
|
+
raise
|
|
30
|
+
except Abort:
|
|
31
|
+
_err.print("\n[yellow]Aborted.[/yellow]")
|
|
32
|
+
raise SystemExit(130)
|
|
33
|
+
except KeyboardInterrupt:
|
|
34
|
+
_err.print("\n[yellow]Interrupted.[/yellow]")
|
|
35
|
+
raise SystemExit(130)
|
|
36
|
+
except Exception as e:
|
|
37
|
+
_err.print(f"[red bold]Unexpected error:[/red bold] {e}")
|
|
38
|
+
raise SystemExit(1)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
if __name__ == "__main__":
|
|
42
|
+
main()
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""NullForge CLI application and root command group."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, cast
|
|
6
|
+
|
|
7
|
+
import rich_click as click
|
|
8
|
+
|
|
9
|
+
from nullforge import __version__
|
|
10
|
+
from nullforge.cli.components.completion.component import CompletionComponent
|
|
11
|
+
from nullforge.cli.components.completion.powershell import register_powershell_completion
|
|
12
|
+
from nullforge.cli.components.foundry.component import FoundryComponent
|
|
13
|
+
from nullforge.cli.components.runes.component import RunesComponent
|
|
14
|
+
from nullforge.cli.core import BaseApplication
|
|
15
|
+
from nullforge.cli.display import Display
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from nullforge.cli.components.completion.controller import CompletionController
|
|
20
|
+
from nullforge.cli.components.foundry.controller import FoundryController
|
|
21
|
+
from nullforge.cli.components.runes.controller import RunesController
|
|
22
|
+
|
|
23
|
+
click.rich_click.USE_RICH_MARKUP = True
|
|
24
|
+
click.rich_click.SHOW_ARGUMENTS = True
|
|
25
|
+
click.rich_click.GROUP_ARGUMENTS_OPTIONS = True
|
|
26
|
+
click.rich_click.USE_MARKDOWN = False
|
|
27
|
+
click.rich_click.STYLE_ERRORS_SUGGESTION = "dim italic"
|
|
28
|
+
click.rich_click.MAX_WIDTH = 100
|
|
29
|
+
|
|
30
|
+
register_powershell_completion()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class NullForgeCli(BaseApplication["NullForgeCli"]):
|
|
34
|
+
"""Main application — component registry and dependency injector."""
|
|
35
|
+
|
|
36
|
+
default_components = [FoundryComponent, RunesComponent, CompletionComponent]
|
|
37
|
+
|
|
38
|
+
foundry: FoundryController
|
|
39
|
+
runes: RunesController
|
|
40
|
+
completion: CompletionController
|
|
41
|
+
|
|
42
|
+
def __init__(self) -> None:
|
|
43
|
+
self.display = Display(self)
|
|
44
|
+
super().__init__()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@click.group(name="nullforge")
|
|
48
|
+
@click.version_option(__version__, "-V", "--version")
|
|
49
|
+
@click.pass_context
|
|
50
|
+
def cli(ctx: click.Context) -> None:
|
|
51
|
+
"""NullForge — forge the server's baseline from null."""
|
|
52
|
+
|
|
53
|
+
app = NullForgeCli()
|
|
54
|
+
app.cli_root = cast("click.Group", ctx.command)
|
|
55
|
+
ctx.obj = app
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
NullForgeCli.register_cli(cli)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Completion component and CLI commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
import rich_click as click
|
|
8
|
+
|
|
9
|
+
from nullforge.cli.core import BaseComponent
|
|
10
|
+
from nullforge.cli.types import ShellType
|
|
11
|
+
|
|
12
|
+
from .controller import CompletionController
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from nullforge.cli.app import NullForgeCli
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class CompletionComponent(BaseComponent["NullForgeCli", CompletionController]):
|
|
20
|
+
name = "completion"
|
|
21
|
+
controller_class = CompletionController
|
|
22
|
+
expose_controller = True
|
|
23
|
+
|
|
24
|
+
@classmethod
|
|
25
|
+
def expose_cli(cls, base: click.Group) -> None:
|
|
26
|
+
@base.command()
|
|
27
|
+
@click.argument("shell", type=ShellType())
|
|
28
|
+
@click.option("--install", is_flag=True, help="Install the script into the shell profile.")
|
|
29
|
+
@click.pass_obj
|
|
30
|
+
def completion(app: NullForgeCli, shell: str, install: bool) -> None:
|
|
31
|
+
"""Print or install shell completion (bash, zsh, fish, powershell)."""
|
|
32
|
+
|
|
33
|
+
if install:
|
|
34
|
+
script_path, profile_path = app.completion.install(shell)
|
|
35
|
+
app.display.completion_installed(script_path, profile_path)
|
|
36
|
+
else:
|
|
37
|
+
# Raw stdout on purpose: the rich console would wrap lines and corrupt the script.
|
|
38
|
+
click.echo(app.completion.script(shell))
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Completion controller for script generation and installation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import TYPE_CHECKING, Final
|
|
9
|
+
|
|
10
|
+
from click.shell_completion import get_completion_class
|
|
11
|
+
|
|
12
|
+
from nullforge.cli.core import BaseController
|
|
13
|
+
from nullforge.cli.core.errors import Unreachable
|
|
14
|
+
|
|
15
|
+
from .errors import ProfileNotFound, UnsupportedShell
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from nullforge.cli.app import NullForgeCli # noqa: F401
|
|
20
|
+
|
|
21
|
+
COMPLETE_VAR: Final[str] = "_NULLFORGE_COMPLETE"
|
|
22
|
+
PROG_NAME: Final[str] = "nullforge"
|
|
23
|
+
|
|
24
|
+
MARKER_BEGIN: Final[str] = "# >>> nullforge completion >>>"
|
|
25
|
+
MARKER_END: Final[str] = "# <<< nullforge completion <<<"
|
|
26
|
+
|
|
27
|
+
SCRIPT_SUFFIXES: Final[dict[str, str]] = {
|
|
28
|
+
"bash": "bash",
|
|
29
|
+
"zsh": "zsh",
|
|
30
|
+
"fish": "fish",
|
|
31
|
+
"powershell": "ps1",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class CompletionController(BaseController["NullForgeCli"]):
|
|
36
|
+
def script(self, shell: str) -> str:
|
|
37
|
+
"""Generate the completion script for shell."""
|
|
38
|
+
|
|
39
|
+
completion_cls = get_completion_class(shell)
|
|
40
|
+
if completion_cls is None:
|
|
41
|
+
raise UnsupportedShell(shell)
|
|
42
|
+
if self.app.cli_root is None:
|
|
43
|
+
raise Unreachable()
|
|
44
|
+
return completion_cls(self.app.cli_root, {}, PROG_NAME, COMPLETE_VAR).source()
|
|
45
|
+
|
|
46
|
+
def install(self, shell: str) -> tuple[Path, Path]:
|
|
47
|
+
"""Write the completion script and wire it into the shell profile. Idempotent."""
|
|
48
|
+
|
|
49
|
+
script_path = self._script_path(shell)
|
|
50
|
+
script_path.parent.mkdir(parents=True, exist_ok=True)
|
|
51
|
+
script_path.write_text(self.script(shell), encoding="utf-8")
|
|
52
|
+
if shell == "fish":
|
|
53
|
+
return script_path, script_path # fish autoloads from completions/, no profile edit
|
|
54
|
+
|
|
55
|
+
profile_path = self._profile_path(shell)
|
|
56
|
+
existing = profile_path.read_text(encoding="utf-8") if profile_path.exists() else ""
|
|
57
|
+
if MARKER_BEGIN not in existing:
|
|
58
|
+
profile_path.parent.mkdir(parents=True, exist_ok=True)
|
|
59
|
+
block = f"\n{MARKER_BEGIN}\n{self._source_line(shell, script_path)}\n{MARKER_END}\n"
|
|
60
|
+
with profile_path.open("a", encoding="utf-8") as handle:
|
|
61
|
+
handle.write(block)
|
|
62
|
+
return script_path, profile_path
|
|
63
|
+
|
|
64
|
+
def _script_path(self, shell: str) -> Path:
|
|
65
|
+
if shell == "fish":
|
|
66
|
+
return Path.home() / ".config" / "fish" / "completions" / "nullforge.fish"
|
|
67
|
+
return Path.home() / ".nullforge" / f"completion.{SCRIPT_SUFFIXES[shell]}"
|
|
68
|
+
|
|
69
|
+
def _source_line(self, shell: str, script_path: Path) -> str:
|
|
70
|
+
if shell == "powershell":
|
|
71
|
+
return f'. "{script_path}"'
|
|
72
|
+
return f'source "{script_path}"'
|
|
73
|
+
|
|
74
|
+
def _profile_path(self, shell: str) -> Path:
|
|
75
|
+
if shell == "bash":
|
|
76
|
+
return Path.home() / ".bashrc"
|
|
77
|
+
if shell == "zsh":
|
|
78
|
+
return Path.home() / ".zshrc"
|
|
79
|
+
if shell == "powershell":
|
|
80
|
+
return self._powershell_profile()
|
|
81
|
+
raise Unreachable()
|
|
82
|
+
|
|
83
|
+
def _powershell_profile(self) -> Path:
|
|
84
|
+
executable = shutil.which("pwsh") or shutil.which("powershell")
|
|
85
|
+
if executable is None:
|
|
86
|
+
raise ProfileNotFound("powershell")
|
|
87
|
+
try:
|
|
88
|
+
completed = subprocess.run( # noqa: S603
|
|
89
|
+
[executable, "-NoProfile", "-Command", "$PROFILE"],
|
|
90
|
+
capture_output=True,
|
|
91
|
+
text=True,
|
|
92
|
+
check=False,
|
|
93
|
+
)
|
|
94
|
+
except OSError as exc:
|
|
95
|
+
raise ProfileNotFound("powershell") from exc
|
|
96
|
+
profile = completed.stdout.strip()
|
|
97
|
+
if completed.returncode != 0 or not profile:
|
|
98
|
+
raise ProfileNotFound("powershell")
|
|
99
|
+
return Path(profile)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Completion component errors."""
|
|
2
|
+
|
|
3
|
+
from nullforge.cli.core.errors import Error
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class UnsupportedShell(Error):
|
|
7
|
+
def __init__(self, shell: str) -> None:
|
|
8
|
+
super().__init__(f"Unsupported shell [bold]{shell}[/bold].")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ProfileNotFound(Error):
|
|
12
|
+
def __init__(self, shell: str) -> None:
|
|
13
|
+
super().__init__(f"Could not resolve [bold]{shell}[/bold] profile path.")
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""PowerShell shell-completion support."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
from typing import Final
|
|
6
|
+
|
|
7
|
+
from click.shell_completion import CompletionItem, ShellComplete, add_completion_class
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
SOURCE_POWERSHELL: Final[str] = """\
|
|
11
|
+
$%(complete_func)s = {
|
|
12
|
+
param($wordToComplete, $commandAst, $cursorPosition)
|
|
13
|
+
$env:%(complete_var)s = "powershell_complete"
|
|
14
|
+
$env:%(complete_var)s_WORDS = $commandAst.ToString()
|
|
15
|
+
$env:%(complete_var)s_INCOMPLETE = "$wordToComplete"
|
|
16
|
+
try {
|
|
17
|
+
& "%(prog_name)s" 2>$null | ForEach-Object {
|
|
18
|
+
$type, $value, $help = $_ -split "`t", 3
|
|
19
|
+
if ($type -eq "plain") {
|
|
20
|
+
$completionText = if ($value -match "\\s") { '"' + $value + '"' } else { $value }
|
|
21
|
+
$tooltip = if ($help) { $help } else { $value }
|
|
22
|
+
[System.Management.Automation.CompletionResult]::new(
|
|
23
|
+
$completionText, $value, "ParameterValue", $tooltip)
|
|
24
|
+
}
|
|
25
|
+
elseif (($type -eq "file") -or ($type -eq "dir")) {
|
|
26
|
+
Get-ChildItem -Path "$wordToComplete*" -ErrorAction SilentlyContinue | ForEach-Object {
|
|
27
|
+
[System.Management.Automation.CompletionResult]::new(
|
|
28
|
+
$_.FullName, $_.Name, "ProviderItem", $_.Name)
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
finally {
|
|
34
|
+
Remove-Item Env:%(complete_var)s, Env:%(complete_var)s_WORDS, Env:%(complete_var)s_INCOMPLETE `
|
|
35
|
+
-ErrorAction SilentlyContinue
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
Register-ArgumentCompleter -Native -CommandName "%(prog_name)s", "%(prog_name)s.exe" `
|
|
39
|
+
-ScriptBlock $%(complete_func)s
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
TOKEN_PATTERN: Final[re.Pattern[str]] = re.compile(r'"[^"]*"?|\'[^\']*\'?|\S+')
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def split_powershell_line(line: str) -> list[str]:
|
|
46
|
+
"""Split PowerShell command line into words, keeping backslashes literal."""
|
|
47
|
+
|
|
48
|
+
tokens: list[str] = []
|
|
49
|
+
for match in TOKEN_PATTERN.finditer(line):
|
|
50
|
+
token = match.group()
|
|
51
|
+
if len(token) >= 2 and token[0] == token[-1] and token[0] in {'"', "'"}:
|
|
52
|
+
token = token[1:-1]
|
|
53
|
+
elif token[:1] in {'"', "'"}:
|
|
54
|
+
token = token[1:]
|
|
55
|
+
tokens.append(token)
|
|
56
|
+
return tokens
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class PowerShellComplete(ShellComplete):
|
|
60
|
+
"""Completion for Windows PowerShell 5+ and pwsh."""
|
|
61
|
+
|
|
62
|
+
name = "powershell"
|
|
63
|
+
source_template = SOURCE_POWERSHELL
|
|
64
|
+
|
|
65
|
+
def get_completion_args(self) -> tuple[list[str], str]:
|
|
66
|
+
line = os.environ.get(f"{self.complete_var}_WORDS", "")
|
|
67
|
+
incomplete = os.environ.get(f"{self.complete_var}_INCOMPLETE", "")
|
|
68
|
+
args = split_powershell_line(line)[1:]
|
|
69
|
+
if incomplete and args and args[-1] == incomplete:
|
|
70
|
+
args.pop()
|
|
71
|
+
return args, incomplete
|
|
72
|
+
|
|
73
|
+
def format_completion(self, item: CompletionItem) -> str:
|
|
74
|
+
return f"{item.type}\t{item.value}\t{' '.join((item.help or '').split())}"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def register_powershell_completion() -> None:
|
|
78
|
+
"""Register PowerShell completer with click."""
|
|
79
|
+
|
|
80
|
+
add_completion_class(PowerShellComplete)
|
|
81
|
+
add_completion_class(PowerShellComplete, "pwsh")
|
|
File without changes
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Foundry component and CLI commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
import rich_click as click
|
|
8
|
+
|
|
9
|
+
from nullforge.cli.core import BaseComponent
|
|
10
|
+
from nullforge.cli.types import InventoryType, KeyValueType, RuneType
|
|
11
|
+
|
|
12
|
+
from .controller import CastOptions, FoundryController
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from nullforge.cli.app import NullForgeCli
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class FoundryComponent(BaseComponent["NullForgeCli", FoundryController]):
|
|
22
|
+
name = "foundry"
|
|
23
|
+
controller_class = FoundryController
|
|
24
|
+
expose_controller = True
|
|
25
|
+
|
|
26
|
+
@classmethod
|
|
27
|
+
def expose_cli(cls, base: click.Group) -> None:
|
|
28
|
+
@base.command("cast", context_settings={"ignore_unknown_options": True})
|
|
29
|
+
@click.option(
|
|
30
|
+
"-i",
|
|
31
|
+
"--inventory",
|
|
32
|
+
required=True,
|
|
33
|
+
type=InventoryType(),
|
|
34
|
+
help="Inventory .py file or raw host spec (e.g. @local, host1,host2).",
|
|
35
|
+
)
|
|
36
|
+
@click.option(
|
|
37
|
+
"-r",
|
|
38
|
+
"--rune",
|
|
39
|
+
"runes",
|
|
40
|
+
multiple=True,
|
|
41
|
+
type=RuneType(),
|
|
42
|
+
help="Built-in rune name or path to a custom rune file; repeatable, runs in order. "
|
|
43
|
+
"Omit to run the full cast.",
|
|
44
|
+
)
|
|
45
|
+
@click.option(
|
|
46
|
+
"--with-prepare",
|
|
47
|
+
is_flag=True,
|
|
48
|
+
help="Run the prepare rune as a separate first deploy (for fresh hosts without sudo).",
|
|
49
|
+
)
|
|
50
|
+
@click.option(
|
|
51
|
+
"--dry",
|
|
52
|
+
is_flag=True,
|
|
53
|
+
help="Don't execute operations on the target hosts.",
|
|
54
|
+
)
|
|
55
|
+
@click.option(
|
|
56
|
+
"--diff",
|
|
57
|
+
is_flag=True,
|
|
58
|
+
help="Show file and template differences.",
|
|
59
|
+
)
|
|
60
|
+
@click.option(
|
|
61
|
+
"-y",
|
|
62
|
+
"--yes",
|
|
63
|
+
is_flag=True,
|
|
64
|
+
help="Execute operations without prompting.",
|
|
65
|
+
)
|
|
66
|
+
@click.option(
|
|
67
|
+
"--debug",
|
|
68
|
+
is_flag=True,
|
|
69
|
+
help="Print Pyinfra debug logs.",
|
|
70
|
+
)
|
|
71
|
+
@click.option(
|
|
72
|
+
"-v",
|
|
73
|
+
"verbosity",
|
|
74
|
+
count=True,
|
|
75
|
+
help="Print meta (-v), input (-vv) and output (-vvv).",
|
|
76
|
+
)
|
|
77
|
+
@click.option(
|
|
78
|
+
"--ssh-user",
|
|
79
|
+
help="SSH user to connect as.",
|
|
80
|
+
)
|
|
81
|
+
@click.option(
|
|
82
|
+
"--ssh-port",
|
|
83
|
+
type=int,
|
|
84
|
+
help="SSH port to connect to.",
|
|
85
|
+
)
|
|
86
|
+
@click.option(
|
|
87
|
+
"--ssh-key",
|
|
88
|
+
help="SSH private key file.",
|
|
89
|
+
)
|
|
90
|
+
@click.option(
|
|
91
|
+
"--ssh-key-password",
|
|
92
|
+
help="SSH private key password.",
|
|
93
|
+
)
|
|
94
|
+
@click.option(
|
|
95
|
+
"--ssh-password",
|
|
96
|
+
help="SSH password.",
|
|
97
|
+
)
|
|
98
|
+
@click.option(
|
|
99
|
+
"--sudo-user",
|
|
100
|
+
help="User to sudo as.",
|
|
101
|
+
)
|
|
102
|
+
@click.option(
|
|
103
|
+
"--parallel",
|
|
104
|
+
type=int,
|
|
105
|
+
help="Number of hosts to run in parallel.",
|
|
106
|
+
)
|
|
107
|
+
@click.option(
|
|
108
|
+
"--limit",
|
|
109
|
+
multiple=True,
|
|
110
|
+
help="Restrict target hosts by name or group.",
|
|
111
|
+
)
|
|
112
|
+
@click.option(
|
|
113
|
+
"--data",
|
|
114
|
+
multiple=True,
|
|
115
|
+
type=KeyValueType(),
|
|
116
|
+
help="Override target data (key=value).",
|
|
117
|
+
)
|
|
118
|
+
@click.argument(
|
|
119
|
+
"pyinfra_args",
|
|
120
|
+
nargs=-1,
|
|
121
|
+
type=click.UNPROCESSED,
|
|
122
|
+
)
|
|
123
|
+
@click.pass_obj
|
|
124
|
+
def cast(
|
|
125
|
+
app: NullForgeCli,
|
|
126
|
+
inventory: str,
|
|
127
|
+
runes: tuple[Path, ...],
|
|
128
|
+
with_prepare: bool,
|
|
129
|
+
pyinfra_args: tuple[str, ...],
|
|
130
|
+
**proxied: Any,
|
|
131
|
+
) -> None:
|
|
132
|
+
"""Cast runes onto the inventory via Pyinfra.
|
|
133
|
+
|
|
134
|
+
Unknown options are passed through to Pyinfra.
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
app.foundry.cast(inventory, runes, with_prepare, CastOptions(extra=pyinfra_args, **proxied))
|