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.
Files changed (153) hide show
  1. {nullforge-0.1.0 → nullforge-0.2.0}/.github/workflows/release-please.yaml +7 -7
  2. nullforge-0.2.0/.release-please-manifest.json +3 -0
  3. {nullforge-0.1.0 → nullforge-0.2.0}/CHANGELOG.md +11 -0
  4. nullforge-0.2.0/PKG-INFO +68 -0
  5. nullforge-0.2.0/README.md +52 -0
  6. nullforge-0.2.0/nullforge/__init__.py +1 -0
  7. nullforge-0.2.0/nullforge/cli/__main__.py +42 -0
  8. nullforge-0.2.0/nullforge/cli/app.py +58 -0
  9. nullforge-0.2.0/nullforge/cli/components/completion/component.py +38 -0
  10. nullforge-0.2.0/nullforge/cli/components/completion/controller.py +99 -0
  11. nullforge-0.2.0/nullforge/cli/components/completion/errors.py +13 -0
  12. nullforge-0.2.0/nullforge/cli/components/completion/powershell.py +81 -0
  13. nullforge-0.2.0/nullforge/cli/components/foundry/__init__.py +0 -0
  14. nullforge-0.2.0/nullforge/cli/components/foundry/component.py +137 -0
  15. nullforge-0.2.0/nullforge/cli/components/foundry/controller.py +143 -0
  16. nullforge-0.2.0/nullforge/cli/components/foundry/errors.py +15 -0
  17. nullforge-0.2.0/nullforge/cli/components/runes/__init__.py +0 -0
  18. nullforge-0.2.0/nullforge/cli/components/runes/component.py +30 -0
  19. nullforge-0.2.0/nullforge/cli/components/runes/controller.py +19 -0
  20. nullforge-0.2.0/nullforge/cli/core/__init__.py +14 -0
  21. nullforge-0.2.0/nullforge/cli/core/application.py +64 -0
  22. nullforge-0.2.0/nullforge/cli/core/component.py +29 -0
  23. nullforge-0.2.0/nullforge/cli/core/controller.py +14 -0
  24. nullforge-0.2.0/nullforge/cli/core/errors.py +23 -0
  25. nullforge-0.2.0/nullforge/cli/core/types.py +13 -0
  26. nullforge-0.2.0/nullforge/cli/display.py +42 -0
  27. nullforge-0.2.0/nullforge/cli/types.py +122 -0
  28. nullforge-0.2.0/nullforge/foundry/README.md +0 -0
  29. nullforge-0.2.0/nullforge/foundry/_pyinfra.py +111 -0
  30. nullforge-0.2.0/nullforge/foundry/cast.py +25 -0
  31. nullforge-0.2.0/nullforge/inventories/README.md +0 -0
  32. nullforge-0.2.0/nullforge/smithy/monitoring/nezha/__init__.py +0 -0
  33. {nullforge-0.1.0 → nullforge-0.2.0}/pyproject.toml +1 -0
  34. nullforge-0.1.0/.release-please-manifest.json +0 -3
  35. nullforge-0.1.0/PKG-INFO +0 -39
  36. nullforge-0.1.0/README.md +0 -23
  37. nullforge-0.1.0/nullforge/__init__.py +0 -1
  38. {nullforge-0.1.0 → nullforge-0.2.0}/.gitattributes +0 -0
  39. {nullforge-0.1.0 → nullforge-0.2.0}/.github/CODEOWNERS +0 -0
  40. {nullforge-0.1.0 → nullforge-0.2.0}/.github/CONTRIBUTING.md +0 -0
  41. {nullforge-0.1.0 → nullforge-0.2.0}/.github/ISSUE_TEMPLATE/BUG-REPORT.yaml +0 -0
  42. {nullforge-0.1.0 → nullforge-0.2.0}/.github/ISSUE_TEMPLATE/FEATURE-REQUEST.yaml +0 -0
  43. {nullforge-0.1.0 → nullforge-0.2.0}/.github/ISSUE_TEMPLATE/config.yaml +0 -0
  44. {nullforge-0.1.0 → nullforge-0.2.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  45. {nullforge-0.1.0 → nullforge-0.2.0}/.github/dependabot.yml +0 -0
  46. {nullforge-0.1.0 → nullforge-0.2.0}/.github/labeler.yaml +0 -0
  47. {nullforge-0.1.0 → nullforge-0.2.0}/.github/labels.yaml +0 -0
  48. {nullforge-0.1.0 → nullforge-0.2.0}/.github/release.yaml +0 -0
  49. {nullforge-0.1.0 → nullforge-0.2.0}/.github/workflows/check-commit-message-pr.yaml +0 -0
  50. {nullforge-0.1.0 → nullforge-0.2.0}/.github/workflows/ci-code-quality.yaml +0 -0
  51. {nullforge-0.1.0 → nullforge-0.2.0}/.github/workflows/ci-tests.yaml +0 -0
  52. {nullforge-0.1.0 → nullforge-0.2.0}/.github/workflows/label-prs.yaml +0 -0
  53. {nullforge-0.1.0 → nullforge-0.2.0}/.github/workflows/labels.yaml +0 -0
  54. {nullforge-0.1.0 → nullforge-0.2.0}/.gitignore +0 -0
  55. {nullforge-0.1.0 → nullforge-0.2.0}/.pre-commit-config.yaml +0 -0
  56. {nullforge-0.1.0 → nullforge-0.2.0}/CODE_OF_CONDUCT.md +0 -0
  57. {nullforge-0.1.0 → nullforge-0.2.0}/LICENSE +0 -0
  58. {nullforge-0.1.0/nullforge/smithy/monitoring/nezha → nullforge-0.2.0/nullforge/cli}/__init__.py +0 -0
  59. /nullforge-0.1.0/nullforge/foundry/README.md → /nullforge-0.2.0/nullforge/cli/components/__init__.py +0 -0
  60. /nullforge-0.1.0/nullforge/inventories/README.md → /nullforge-0.2.0/nullforge/cli/components/completion/__init__.py +0 -0
  61. {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/foundry/__init__.py +0 -0
  62. {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/foundry/full_cast.py +0 -0
  63. {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/inventories/example.py +0 -0
  64. {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/models/__init__.py +0 -0
  65. {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/models/containers.py +0 -0
  66. {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/models/dns.py +0 -0
  67. {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/models/monitoring/__init__.py +0 -0
  68. {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/models/monitoring/base.py +0 -0
  69. {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/models/monitoring/nezha.py +0 -0
  70. {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/models/netsec.py +0 -0
  71. {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/models/profiles.py +0 -0
  72. {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/models/system.py +0 -0
  73. {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/models/users.py +0 -0
  74. {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/models/warp.py +0 -0
  75. {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/models/zerotrust.py +0 -0
  76. {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/molds/__init__.py +0 -0
  77. {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/molds/base_mold.py +0 -0
  78. {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/molds/containers.py +0 -0
  79. {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/molds/defaults.py +0 -0
  80. {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/molds/dns.py +0 -0
  81. {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/molds/features.py +0 -0
  82. {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/molds/haproxy.py +0 -0
  83. {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/molds/monitoring/__init__.py +0 -0
  84. {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/molds/monitoring/nezha.py +0 -0
  85. {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/molds/netsec.py +0 -0
  86. {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/molds/profiles.py +0 -0
  87. {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/molds/system.py +0 -0
  88. {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/molds/telemt.py +0 -0
  89. {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/molds/tor.py +0 -0
  90. {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/molds/user.py +0 -0
  91. {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/molds/utils.py +0 -0
  92. {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/molds/warp.py +0 -0
  93. {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/molds/xray.py +0 -0
  94. {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/molds/zerotrust.py +0 -0
  95. {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/runes/__init__.py +0 -0
  96. {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/runes/base.py +0 -0
  97. {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/runes/containers.py +0 -0
  98. {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/runes/dns.py +0 -0
  99. {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/runes/haproxy.py +0 -0
  100. {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/runes/monitoring.py +0 -0
  101. {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/runes/netsec.py +0 -0
  102. {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/runes/prepare.py +0 -0
  103. {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/runes/profiles.py +0 -0
  104. {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/runes/telemt.py +0 -0
  105. {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/runes/tor.py +0 -0
  106. {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/runes/users.py +0 -0
  107. {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/runes/warp.py +0 -0
  108. {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/runes/xray.py +0 -0
  109. {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/runes/zerotrust.py +0 -0
  110. {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/smithy/__init__.py +0 -0
  111. {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/smithy/admin.py +0 -0
  112. {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/smithy/arch.py +0 -0
  113. {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/smithy/blocky.py +0 -0
  114. {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/smithy/cloudflare.py +0 -0
  115. {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/smithy/github.py +0 -0
  116. {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/smithy/http.py +0 -0
  117. {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/smithy/install.py +0 -0
  118. {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/smithy/monitoring/__init__.py +0 -0
  119. {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/smithy/monitoring/nezha/agent.py +0 -0
  120. {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/smithy/monitoring/nezha/dashboard.py +0 -0
  121. {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/smithy/monitoring/nezha/deploy.py +0 -0
  122. {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/smithy/network.py +0 -0
  123. {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/smithy/packages.py +0 -0
  124. {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/smithy/service.py +0 -0
  125. {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/smithy/sni.py +0 -0
  126. {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/smithy/swap.py +0 -0
  127. {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/smithy/system.py +0 -0
  128. {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/smithy/versions.py +0 -0
  129. {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/templates/__init__.py +0 -0
  130. {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/templates/cloudflared/tunnel.yml.j2 +0 -0
  131. {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/templates/dns/blocky.yaml.j2 +0 -0
  132. {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/templates/dns/dns.yaml.j2 +0 -0
  133. {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/templates/dns/resolv.conf.j2 +0 -0
  134. {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/templates/dns/resolved.conf.j2 +0 -0
  135. {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/templates/etc/default/zramswap.j2 +0 -0
  136. {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/templates/nvim/nvim_patch.lua.j2 +0 -0
  137. {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/templates/profiles/direnv.toml +0 -0
  138. {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/templates/profiles/starship.toml +0 -0
  139. {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/templates/profiles/tmux.conf +0 -0
  140. {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/templates/profiles/zshrc.j2 +0 -0
  141. {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/templates/scripts/telemt-synfix.sh +0 -0
  142. {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/templates/scripts/teleproxy-warp.sh +0 -0
  143. {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/templates/scripts/warp-v6-policy.sh +0 -0
  144. {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/templates/scripts/zt-tunnel-warp.sh +0 -0
  145. {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/templates/systemd/blocky.service.j2 +0 -0
  146. {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/templates/systemd/cloudflare-tunnel.service.j2 +0 -0
  147. {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/templates/systemd/cloudflare-warp.service.j2 +0 -0
  148. {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/templates/systemd/dns-internal.service.j2 +0 -0
  149. {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/templates/systemd/telemt.service.j2 +0 -0
  150. {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/templates/telemt/telemt.toml.j2 +0 -0
  151. {nullforge-0.1.0 → nullforge-0.2.0}/nullforge/templates/tor/torrc.j2 +0 -0
  152. {nullforge-0.1.0 → nullforge-0.2.0}/release-please-config.json +0 -0
  153. {nullforge-0.1.0 → nullforge-0.2.0}/uv.lock +0 -0
@@ -1,9 +1,9 @@
1
- name: 🤖 Release Please
1
+ name: 🚀 Release
2
2
 
3
3
  on:
4
4
  push:
5
- branches:
6
- - main
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: 🤖 Release Please
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: 🔐 Generate artifact attestations
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 release
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: 🤖 Open release PR
176
+ name: 🔁 Open next release PR
177
177
  needs: [release-please, finalize-release]
178
178
  if: |
179
179
  always()
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "0.2.0"
3
+ }
@@ -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
 
@@ -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")
@@ -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))