spiriconfig 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 (85) hide show
  1. spiriconfig-0.1.0/.github/workflows/publish.yml +42 -0
  2. spiriconfig-0.1.0/.gitignore +28 -0
  3. spiriconfig-0.1.0/.python-version +1 -0
  4. spiriconfig-0.1.0/.readthedocs.yaml +16 -0
  5. spiriconfig-0.1.0/NOTES-out-of-process-plugins.md +292 -0
  6. spiriconfig-0.1.0/PKG-INFO +159 -0
  7. spiriconfig-0.1.0/README.md +146 -0
  8. spiriconfig-0.1.0/docs/advanced.md +197 -0
  9. spiriconfig-0.1.0/docs/api.md +72 -0
  10. spiriconfig-0.1.0/docs/appstore.md +302 -0
  11. spiriconfig-0.1.0/docs/conf.py +47 -0
  12. spiriconfig-0.1.0/docs/configuration.md +167 -0
  13. spiriconfig-0.1.0/docs/design.md +343 -0
  14. spiriconfig-0.1.0/docs/docker.md +161 -0
  15. spiriconfig-0.1.0/docs/index.md +76 -0
  16. spiriconfig-0.1.0/docs/install.md +137 -0
  17. spiriconfig-0.1.0/docs/plugins.md +158 -0
  18. spiriconfig-0.1.0/examples/store/README.md +20 -0
  19. spiriconfig-0.1.0/examples/store/docs/README.md +1 -0
  20. spiriconfig-0.1.0/examples/store/grafana/compose.yaml +75 -0
  21. spiriconfig-0.1.0/examples/store/nextcloud/compose.yaml +35 -0
  22. spiriconfig-0.1.0/examples/store/traefik/compose.yaml +22 -0
  23. spiriconfig-0.1.0/examples/store/whoami/compose.yaml +11 -0
  24. spiriconfig-0.1.0/pyproject.toml +59 -0
  25. spiriconfig-0.1.0/scripts/test-data.sh +54 -0
  26. spiriconfig-0.1.0/src/spiriconfig/__init__.py +15 -0
  27. spiriconfig-0.1.0/src/spiriconfig/__main__.py +8 -0
  28. spiriconfig-0.1.0/src/spiriconfig/advanced.py +146 -0
  29. spiriconfig-0.1.0/src/spiriconfig/auth.py +246 -0
  30. spiriconfig-0.1.0/src/spiriconfig/cli.py +197 -0
  31. spiriconfig-0.1.0/src/spiriconfig/commands.py +495 -0
  32. spiriconfig-0.1.0/src/spiriconfig/config.py +138 -0
  33. spiriconfig-0.1.0/src/spiriconfig/logging.py +45 -0
  34. spiriconfig-0.1.0/src/spiriconfig/plugins.py +133 -0
  35. spiriconfig-0.1.0/src/spiriconfig/preferences.py +96 -0
  36. spiriconfig-0.1.0/src/spiriconfig/service.py +345 -0
  37. spiriconfig-0.1.0/src/spiriconfig/terminal.py +211 -0
  38. spiriconfig-0.1.0/src/spiriconfig/theme.py +108 -0
  39. spiriconfig-0.1.0/src/spiriconfig/tls.py +247 -0
  40. spiriconfig-0.1.0/src/spiriconfig/web.py +311 -0
  41. spiriconfig-0.1.0/src/spiriconfig_appstore/__init__.py +55 -0
  42. spiriconfig-0.1.0/src/spiriconfig_appstore/cli.py +611 -0
  43. spiriconfig-0.1.0/src/spiriconfig_appstore/config.py +73 -0
  44. spiriconfig-0.1.0/src/spiriconfig_appstore/credentials.py +307 -0
  45. spiriconfig-0.1.0/src/spiriconfig_appstore/installs.py +175 -0
  46. spiriconfig-0.1.0/src/spiriconfig_appstore/stores.py +672 -0
  47. spiriconfig-0.1.0/src/spiriconfig_appstore/web.py +827 -0
  48. spiriconfig-0.1.0/src/spiriconfig_docker/__init__.py +41 -0
  49. spiriconfig-0.1.0/src/spiriconfig_docker/cli.py +424 -0
  50. spiriconfig-0.1.0/src/spiriconfig_docker/config.py +50 -0
  51. spiriconfig-0.1.0/src/spiriconfig_docker/env.py +276 -0
  52. spiriconfig-0.1.0/src/spiriconfig_docker/settings.py +630 -0
  53. spiriconfig-0.1.0/src/spiriconfig_docker/stacks.py +674 -0
  54. spiriconfig-0.1.0/src/spiriconfig_docker/web.py +796 -0
  55. spiriconfig-0.1.0/src/spiriconfig_docker/widgets.py +374 -0
  56. spiriconfig-0.1.0/src/spiriconfig_terminal/__init__.py +48 -0
  57. spiriconfig-0.1.0/src/spiriconfig_terminal/cli.py +53 -0
  58. spiriconfig-0.1.0/src/spiriconfig_terminal/config.py +40 -0
  59. spiriconfig-0.1.0/src/spiriconfig_terminal/shell.py +86 -0
  60. spiriconfig-0.1.0/src/spiriconfig_terminal/web.py +97 -0
  61. spiriconfig-0.1.0/src/spiriconfig_users/__init__.py +43 -0
  62. spiriconfig-0.1.0/src/spiriconfig_users/cli.py +211 -0
  63. spiriconfig-0.1.0/src/spiriconfig_users/config.py +52 -0
  64. spiriconfig-0.1.0/src/spiriconfig_users/users.py +331 -0
  65. spiriconfig-0.1.0/src/spiriconfig_users/web.py +425 -0
  66. spiriconfig-0.1.0/tests/__init__.py +0 -0
  67. spiriconfig-0.1.0/tests/conftest.py +186 -0
  68. spiriconfig-0.1.0/tests/test_advanced.py +279 -0
  69. spiriconfig-0.1.0/tests/test_appstore.py +891 -0
  70. spiriconfig-0.1.0/tests/test_auth.py +200 -0
  71. spiriconfig-0.1.0/tests/test_cli.py +187 -0
  72. spiriconfig-0.1.0/tests/test_commands.py +191 -0
  73. spiriconfig-0.1.0/tests/test_credentials.py +209 -0
  74. spiriconfig-0.1.0/tests/test_docker_web.py +939 -0
  75. spiriconfig-0.1.0/tests/test_plugins.py +135 -0
  76. spiriconfig-0.1.0/tests/test_service.py +197 -0
  77. spiriconfig-0.1.0/tests/test_settings.py +720 -0
  78. spiriconfig-0.1.0/tests/test_stacks.py +690 -0
  79. spiriconfig-0.1.0/tests/test_terminal.py +474 -0
  80. spiriconfig-0.1.0/tests/test_tls.py +123 -0
  81. spiriconfig-0.1.0/tests/test_users.py +216 -0
  82. spiriconfig-0.1.0/tests/test_users_web.py +61 -0
  83. spiriconfig-0.1.0/tests/test_web.py +132 -0
  84. spiriconfig-0.1.0/todo.md +37 -0
  85. spiriconfig-0.1.0/uv.lock +1704 -0
@@ -0,0 +1,42 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*"
7
+
8
+ jobs:
9
+ build:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: actions/checkout@v4
13
+
14
+ - uses: actions/setup-python@v5
15
+ with:
16
+ python-version: "3.13"
17
+
18
+ - name: Install build
19
+ run: pip install build
20
+
21
+ - name: Build package
22
+ run: python -m build
23
+
24
+ - uses: actions/upload-artifact@v4
25
+ with:
26
+ name: dist
27
+ path: dist/
28
+
29
+ publish:
30
+ needs: build
31
+ runs-on: ubuntu-latest
32
+ environment: pypi
33
+ permissions:
34
+ id-token: write
35
+ steps:
36
+ - uses: actions/download-artifact@v4
37
+ with:
38
+ name: dist
39
+ path: dist/
40
+
41
+ - name: Publish to PyPI
42
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,28 @@
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+
9
+ # Virtual environments
10
+ .venv
11
+
12
+ # Sphinx
13
+ docs/_build/
14
+
15
+ # Test artifacts
16
+ .pytest_cache/
17
+
18
+ # Built by ./scripts/test-data.sh: a throwaway compose directory and a git app
19
+ # store to install from. Disposable by design -- the defaults in
20
+ # spiriconfig_docker.config and spiriconfig_appstore.config point here so that a
21
+ # checkout never touches /srv/compose on a real machine.
22
+ /test_data/
23
+
24
+ # NiceGUI's on-disk storage for the running dev server.
25
+ .nicegui/
26
+
27
+ # aider local tooling state
28
+ .aider*
@@ -0,0 +1 @@
1
+ 3.13
@@ -0,0 +1,16 @@
1
+ version: 2
2
+
3
+ build:
4
+ os: ubuntu-24.04
5
+ tools:
6
+ python: "3.13"
7
+ jobs:
8
+ install:
9
+ - pip install uv
10
+ - uv sync --all-groups
11
+ build:
12
+ html:
13
+ - uv run sphinx-build -b html docs $READTHEDOCS_OUTPUT/html
14
+
15
+ sphinx:
16
+ configuration: docs/conf.py
@@ -0,0 +1,292 @@
1
+ # Notes: out-of-process plugins
2
+
3
+ Scratch. Not documentation. Nothing here is decided.
4
+
5
+ Working assumption: **a plugin is a container, and may be written in any language.**
6
+ Third pass; the first two chased the wrong motivation and are worth not repeating.
7
+
8
+ ## This is not a sandbox
9
+
10
+ Stating the premise first, because I derived a whole security architecture from its
11
+ absence and had to throw it away, and future-me will be tempted to do it again.
12
+
13
+ **You are assumed to have root on machines you own.** A plugin is trusted code that
14
+ the operator chose to install. We are not defending against a hostile plugin, and we
15
+ should not build anything that implies we are — a security boundary we half-mean is
16
+ worse than none, because people will lean on it.
17
+
18
+ Consequences, all of them freeing:
19
+
20
+ - A plugin bind-mounts whatever it needs. `/var/run/docker.sock`, `/srv/compose`,
21
+ `/etc`. That's not a hole in the design, it's the design.
22
+ - No capability API, no `Command`-over-RPC, no confirmation dialog as a security
23
+ mechanism. (Drafted all three. Deleted all three. They only existed to defend a
24
+ boundary we don't want.)
25
+ - Same-origin frontend "isolation" is not a goal. We couldn't have it anyway — CSRF
26
+ is unpreventable same-origin, and an iframe `sandbox` that made the child a foreign
27
+ origin would cost us URL sync to buy us protection from code we already trust.
28
+
29
+ So the same-origin question I flagged last pass as "the most important open question"
30
+ is **closed**: same origin, take the deep links, the plugin's JS can touch the shell
31
+ and that's fine.
32
+
33
+ ## The actual motivation: version isolation
34
+
35
+ The thing that hurts today, and the reason the original phrasing was *"a plugin means
36
+ tightly coupling third-party code to my project"*:
37
+
38
+ An entry-point plugin shares our interpreter, so it shares **our resolved dependency
39
+ set**. There is exactly one environment, and everything in it must agree.
40
+
41
+ - A plugin cannot depend on a nicegui, pydantic, or typer we didn't pick.
42
+ - Two plugins with conflicting pins cannot coexist. Neither can be installed second.
43
+ - **We cannot bump nicegui without potentially breaking every third-party plugin**,
44
+ and no plugin can upgrade independently of us. The whole ecosystem is welded to one
45
+ `uv lock`.
46
+ - A plugin cannot be written in Go. Obviously. But that's the same problem wearing a
47
+ bigger hat: we are dictating the plugin's entire runtime.
48
+
49
+ Containers dissolve all of it. Each plugin ships its own image with its own closure —
50
+ own language, own runtime, own deps — and the only thing shared is a wire contract.
51
+ We bump nicegui whenever we like. A plugin ships when it likes. Nobody's release
52
+ schedule is anybody else's problem.
53
+
54
+ That's the whole pitch. The rest of this doc is mechanism.
55
+
56
+ ### Bonus, not the reason
57
+
58
+ Falls out for free, worth having, but don't lead with it:
59
+
60
+ - A plugin cannot **block the event loop** — the thing we genuinely cannot defend
61
+ against in-process. One synchronous `subprocess.run` in a plugin's `page()` today
62
+ freezes every user's UI, websockets included, so it doesn't even fail visibly. No
63
+ `try`/`except` saves us. A container can't do it.
64
+ - Crashes, OOM, fd exhaustion, `sys.exit()`, a segfault in some C extension. cgroups
65
+ and the process boundary cover what our `except` blocks can't.
66
+
67
+ ## Shape
68
+
69
+ - plugin container serves HTTP on some port
70
+ - SpiriConfig reverse-proxies `/plugin/<name>/{path:path}` → the container,
71
+ injecting `X-Forwarded-Prefix: /plugin/<name>`
72
+ - shell renders `<iframe src="/plugin/<name>/">` filling the main area
73
+
74
+ Same origin (a path on us, not a port on localhost) keeps cookies working and lets
75
+ parent and child talk. That matters more than it looks — see "rejected: one port per
76
+ plugin".
77
+
78
+ An iframe is the only way to get a container's UI into our page at all. The
79
+ alternative is a data protocol where the plugin describes widgets and we render them,
80
+ which means inventing a cross-language UI toolkit. No.
81
+
82
+ ## A plugin is an app with labels on it
83
+
84
+ If a plugin is a container, a plugin is **a compose app with some labels**, and we
85
+ already built an entire subsystem for installing compose apps from a git repo.
86
+
87
+ ```yaml
88
+ services:
89
+ ui:
90
+ image: ghcr.io/someone/spiriconfig-netplan
91
+ labels:
92
+ spiriconfig.plugin.name: netplan
93
+ spiriconfig.plugin.title: Network
94
+ spiriconfig.plugin.icon: lan
95
+ spiriconfig.plugin.port: "8080"
96
+ volumes:
97
+ - /etc/netplan:/etc/netplan # trusted code; mounts what it needs
98
+ ```
99
+
100
+ Follow it through — almost everything falls out:
101
+
102
+ | question | answer | who does the work |
103
+ | --- | --- | --- |
104
+ | what plugins exist? | `docker ps --filter label=spiriconfig.plugin.name` | docker |
105
+ | install a plugin | install an app from a store | the app store, unchanged |
106
+ | uninstall | `rm` a symlink | the app store, unchanged |
107
+ | is it running? | is the container up? | docker |
108
+ | restart when it dies | `restart: unless-stopped` | docker |
109
+ | where did it come from? | `readlink` + `git` | the app store, unchanged |
110
+ | update it | `git merge` | the app store, unchanged |
111
+
112
+ **There is no state that is ours.** No plugin registry, no enabled/disabled, no
113
+ install manifest. Same argument design.md already makes three times, pointed at
114
+ plugins. The plugin system doesn't need building so much as *noticing* — it's the app
115
+ store plus a label convention.
116
+
117
+ Install instructions for a plugin author become "add it to your app store", which
118
+ users already know how to do.
119
+
120
+ ## The contract
121
+
122
+ Three things, and they're all boring on purpose. Boring is what makes them
123
+ implementable in Go.
124
+
125
+ 1. **Declare yourself** with labels (above).
126
+ 2. **Work behind a reverse proxy at a subpath**, honouring `X-Forwarded-Prefix`.
127
+ 3. **Include one script tag**, if you want deep links and our theme.
128
+
129
+ ### On (2): the adoption tax
130
+
131
+ This is the real cost of the design and it's worth being clear-eyed. "Work correctly
132
+ under a path prefix" is a standard ask, and plenty of apps get it wrong — Flask needs
133
+ `ProxyFix`, anything that hardcodes `/static/…` breaks. A plugin author's first bug
134
+ will be this bug.
135
+
136
+ NiceGUI happens to be *excellent* at it. Verified against the 3.14.0 in `.venv`
137
+ rather than from memory, since it decides viability:
138
+
139
+ | what | where |
140
+ | --- | --- |
141
+ | reads `X-Forwarded-Prefix`, threads it into the page | `client.py:198` |
142
+ | assets, importmap, components | `dependencies.py:235-274` |
143
+ | favicon | `favicon.py:26` |
144
+ | socket.io connection path | `static/nicegui.js:422` |
145
+ | `ui.navigate.to` / `ui.download` | `static/nicegui.js:542-546` |
146
+ | redirect `Location` headers | `middlewares.py:12` |
147
+
148
+ A NiceGUI plugin with *no idea* it's behind a prefix emits correct URLs anyway,
149
+ purely from the header. Our bundled two would port with no URL work.
150
+
151
+ Its one gap — `link.py:29` writes `href` straight to the DOM, bypassing the JS that
152
+ prepends the prefix:
153
+
154
+ ```python
155
+ ui.navigate.to('/routes') # -> /plugin/netplan/routes ✓
156
+ ui.link('Routes', target='/routes') # -> /routes ✗ escapes the iframe
157
+ ```
158
+
159
+ ### On (3): `shell.js`
160
+
161
+ The polyglot answer to the deep-link problem. We serve one small script at a
162
+ well-known URL; the plugin adds one tag:
163
+
164
+ ```html
165
+ <script src="/plugin-sdk/shell.js"></script>
166
+ ```
167
+
168
+ It does the history sync (below) and picks up the shell's theme, so a Go plugin
169
+ author gets both without writing either. Optional — a plugin that skips it still
170
+ works, it just sits at a URL that doesn't move and looks like itself.
171
+
172
+ Alternative was rewriting HTML responses in the proxy to inject it. That's a tarpit.
173
+ One script tag is a fair ask.
174
+
175
+ ## iframe URL mechanics — the real work
176
+
177
+ Unchanged by any of the above, and still the highest-risk piece.
178
+
179
+ An iframe is a separate browsing context: its own document, its own URL. Not a widget
180
+ that renders someone's HTML into our page — a whole browser tab that happens to be
181
+ rectangle-shaped. **Two URLs at all times**, moving independently.
182
+
183
+ Shell at `robot.local/netplan`, iframe src `/plugin/netplan/`, so the iframe document
184
+ is at `robot.local/plugin/netplan/`. Inside it:
185
+
186
+ | href | resolves to | |
187
+ | --- | --- | --- |
188
+ | `routes` | `/plugin/netplan/routes` | stays in |
189
+ | `./routes` | `/plugin/netplan/routes` | stays in |
190
+ | `/routes` | `/routes` | **escapes** |
191
+
192
+ Two consequences, complementary — which hints at fixing both at once:
193
+
194
+ **The address bar never moves.** User goes three levels deep, hits F5, lands back at
195
+ the iframe's original `src`. Position gone, and it could never have been bookmarked or
196
+ shared. As far as the browser is concerned they never left `/netplan`.
197
+
198
+ **But the back button does move.** Iframe navigations land on the tab's joint session
199
+ history — and for NiceGUI they're *real* navigations (`nicegui.js:542` does
200
+ `window.open(url, "_self")`, a full document load, not client-side routing). So the
201
+ user hits Back to leave the plugin and the *iframe* steps back a page while the
202
+ address bar sits there unchanged. We'd hit this on day one.
203
+
204
+ So: the URL doesn't track state that history *does* track. Make it track.
205
+
206
+ ### The fix (unproven)
207
+
208
+ Same origin, so the child reaches the parent's `history` directly — no `postMessage`
209
+ handshake, no cooperation needed beyond the script tag.
210
+
211
+ - shell route becomes `/netplan/{sub:path}`, rendering `<iframe src="/plugin/netplan/{sub}">`
212
+ - `shell.js` mirrors the child's path up on load, via `parent.history.replaceState`
213
+
214
+ Address bar tracks the plugin; deep links and reload work because the shell rebuilds
215
+ the iframe src from its own path; `replaceState` not `pushState` so we don't
216
+ double-stack history.
217
+
218
+ **Highest-uncertainty thing in the document.** Well-trodden pattern, does work, but
219
+ "sync two browsing contexts' histories" has more edge cases than that bullet list
220
+ admits — chiefly what Back *should* do once the parent URL tracks properly. Build it
221
+ and click around before believing it.
222
+
223
+ ## The CLI face
224
+
225
+ design.md: *"a plugin that offers a web page and no CLI is a bug in spirit."*
226
+
227
+ A container plugin can't add a Typer subcommand, and doesn't need to — the container's
228
+ entrypoint *is* its CLI:
229
+
230
+ ```console
231
+ $ docker exec spiriconfig-netplan netplan-cli show
232
+ ```
233
+
234
+ A command a human could have run, which is the whole test. `spiriconfig netplan ...`
235
+ can forward to it, and `--show` prints the `docker exec` line. The principle survives
236
+ unamended, which is a good sign we're not fighting the design.
237
+
238
+ ## Open questions
239
+
240
+ - **Asset cache is keyed per prefix.** Each plugin's framework assets live under its
241
+ own prefix, so a browser caches Vue/Quasar once *per plugin*. Over loopback the
242
+ bandwidth is free — it's memory and parse time, N runtimes for N plugins. Measure
243
+ before worrying; on a robot it might matter.
244
+ - **Do the bundled two become containers?** They'd need the docker socket, which is
245
+ now fine. But it means shipping SpiriConfig means shipping images, and the dev loop
246
+ gets a build step. Keeping them in-process means two plugin systems, which is a
247
+ smell. Suspect the honest answer is that first-party plugins are privileged and we
248
+ *say so* — but it's unresolved and it affects the dev experience most.
249
+ - **The websocket proxy is the only real code.** HTTP proxying is trivial; the upgrade
250
+ needs an ASGI relay pumping frames both ways. ~100 lines. Needed for any live UI,
251
+ not just NiceGUI ones.
252
+ - **Dead plugin card.** `docker ps` says it's down → shell renders a card instead of
253
+ an iframe. Cheap, because docker already knows.
254
+ - **Version-skew the contract.** The thing we just decoupled will re-couple here if
255
+ we're careless: labels + prefix + `shell.js` is now a public API. It should be tiny
256
+ and it should be versioned (`spiriconfig.plugin.api: "1"`), precisely so we never
257
+ end up where we are today.
258
+
259
+ ## Rejected
260
+
261
+ **One port per plugin.** Iframe `http://robot.local:9001/` directly; no prefix
262
+ contract, so the single biggest adoption tax disappears. Killed by the origin: a
263
+ different port is a different origin, so no shared cookies, no `window.parent` (history
264
+ sync needs `postMessage` and cooperation), a port to allocate and firewall per plugin,
265
+ and the plugin is reachable from the network directly, bypassing whatever auth the
266
+ shell grows. Isolation used to be the counter-argument *for* this; now that we don't
267
+ want isolation, its last advantage is gone. Stays rejected, more firmly than before.
268
+
269
+ **Entry points alongside containers.** Two contracts, two docs, and every author's
270
+ first question is "which kind do I write?". The container contract is a superset — a
271
+ Python plugin can be a container. If entry points survive it's as an explicitly
272
+ first-party, privileged mechanism, not a peer.
273
+
274
+ **A capability API (plugin POSTs us a `Command`, we run it).** Elegant — it reuses the
275
+ `Command`/`str(Command)` seam design.md already built — but its entire justification
276
+ was a security boundary. Without sandboxing it's a worse way for a plugin to run
277
+ `docker compose` than mounting the socket and running `docker compose`. Noted because
278
+ it's seductive and I want the reason it died to survive.
279
+
280
+ ## Phasing
281
+
282
+ 1. **Spike the iframe + history sync.** One hardcoded container serving anything,
283
+ proxied under a prefix, wired into the shell. Click around. Riskiest bit, cheapest
284
+ to test.
285
+ 2. Proxy for real: HTTP + websocket, prefix injection.
286
+ 3. Label discovery via `docker ps`. Should be small — the app store already installs.
287
+ 4. `shell.js`.
288
+ 5. Port a bundled plugin, or write a throwaway Go one to prove the polyglot claim
289
+ isn't theoretical. **This is the acceptance test for the whole idea** — if writing a
290
+ Go plugin isn't pleasant, none of the above was worth it.
291
+
292
+ Nothing below 2 matters if 1 is unpleasant.
@@ -0,0 +1,159 @@
1
+ Metadata-Version: 2.4
2
+ Name: spiriconfig
3
+ Version: 0.1.0
4
+ Summary: Plugin-based configuration and container management
5
+ Requires-Python: >=3.13
6
+ Requires-Dist: loguru>=0.7.3
7
+ Requires-Dist: nicegui>=3.14.0
8
+ Requires-Dist: pamela>=1.2.0
9
+ Requires-Dist: pydantic-settings>=2.14.2
10
+ Requires-Dist: pyyaml>=6.0.3
11
+ Requires-Dist: typer>=0.26.8
12
+ Description-Content-Type: text/markdown
13
+
14
+ # SpiriConfig
15
+
16
+ Plugin-based configuration and container management, built around one rule:
17
+
18
+ > **Anything SpiriConfig can do, you must be able to do without it.**
19
+
20
+ Press **Up** on a stack in the web UI and SpiriConfig runs this, showing you the
21
+ line as it goes, with a button to copy it:
22
+
23
+ ```console
24
+ $ cd /srv/compose/whoami && docker compose -p whoami -f compose.yaml up -d
25
+ ```
26
+
27
+ No database, no registry, no bespoke on-disk format. If SpiriConfig vanished
28
+ tomorrow, everything it manages would keep working -- and you would already know
29
+ the commands to manage it.
30
+
31
+ ## Installation
32
+
33
+ Every service SpiriConfig manages runs in a container; SpiriConfig itself cannot,
34
+ because the process that starts those containers on boot has to live on the host.
35
+ So it installs itself as a systemd service. You need
36
+ [uv](https://docs.astral.sh/uv/) on the machine, then:
37
+
38
+ ```console
39
+ $ uvx spiriconfig install
40
+ ```
41
+
42
+ That pulls the latest release from PyPI, writes a systemd unit and an environment
43
+ file, and enables and starts the service -- the web UI comes up on
44
+ http://localhost:8080 and returns on every boot. Add `--show` to print every
45
+ command it will run before it runs anything.
46
+
47
+ Run it as root for a system-wide service, or as a normal user for a
48
+ single-operator `systemctl --user` one; that choice *is* the security model, not
49
+ a preference. Update in place with `spiriconfig update`. See
50
+ [Installing SpiriConfig](docs/install.md) for the full walkthrough.
51
+
52
+ To track the latest development instead of a release, install from git -- the
53
+ positional argument is anything uv accepts:
54
+
55
+ ```console
56
+ $ spiriconfig install git+https://github.com/spiri-robotics/SpiriConfig.git
57
+ ```
58
+
59
+ A git-branch install pins to a commit, so `spiriconfig update --reinstall`
60
+ refetches the branch when it moves.
61
+
62
+ ## Usage
63
+
64
+ Point it at your compose directory and start the web UI:
65
+
66
+ ```console
67
+ $ export SPIRICONFIG_DOCKER_COMPOSE_DIR=/srv/compose
68
+ $ spiriconfig serve # web UI on http://localhost:8080
69
+ ```
70
+
71
+ Adding a service is making a directory. No CLI required -- that is the point:
72
+
73
+ ```console
74
+ $ mkdir -p /srv/compose/whoami
75
+ $ $EDITOR /srv/compose/whoami/compose.yaml
76
+ ```
77
+
78
+ From the shell:
79
+
80
+ ```console
81
+ $ spiriconfig docker list
82
+ whoami running
83
+ grafana stopped
84
+
85
+ $ spiriconfig docker up whoami
86
+ $ spiriconfig docker logs whoami -f
87
+ ```
88
+
89
+ Not sure what a command will do? Ask, without running it:
90
+
91
+ ```console
92
+ $ spiriconfig docker up whoami --show
93
+ cd /srv/compose/whoami && docker compose -p whoami -f compose.yaml up -d
94
+ ```
95
+
96
+ Because the compose project name is the directory name, these are the same
97
+ containers you get from running compose yourself -- SpiriConfig and a plain shell
98
+ can manage the same stack on the same afternoon without confusing each other.
99
+
100
+ ## Configuration
101
+
102
+ Environment variables only. See [the docs](docs/configuration.md) for the full
103
+ list; the one you need is:
104
+
105
+ | Variable | Default | Meaning |
106
+ | --- | --- | --- |
107
+ | `SPIRICONFIG_DOCKER_COMPOSE_DIR` | `test_data/compose` | One subdirectory per compose project. Set it to `/srv/compose` on a real machine. |
108
+
109
+ The defaults are relative on purpose: running out of a checkout should not start
110
+ managing the containers on your actual box. See [configuration](docs/configuration.md).
111
+
112
+ ## Plugins
113
+
114
+ The docker plugin is the only one that ships, and it is not special: it is
115
+ discovered through the `spiriconfig.plugins` entry point group exactly as yours
116
+ would be.
117
+
118
+ ```toml
119
+ [project.entry-points."spiriconfig.plugins"]
120
+ tailscale = "spiriconfig_tailscale:TailscalePlugin"
121
+ ```
122
+
123
+ Install the package and it appears in the CLI and the web UI. See
124
+ [docs/plugins.md](docs/plugins.md) for the interface and the rules, and
125
+ [docs/design.md](docs/design.md) for why the rules exist.
126
+
127
+ ## Development
128
+
129
+ Work from a checkout with [uv](https://docs.astral.sh/uv/):
130
+
131
+ ```console
132
+ $ uv sync
133
+ ```
134
+
135
+ You can drive the whole flow without touching your real machine. `test_data/` is
136
+ gitignored and disposable, and the default settings point at it, so trying
137
+ SpiriConfig out cannot start managing the containers on your box:
138
+
139
+ ```console
140
+ $ ./scripts/test-data.sh # a compose dir + an example app store
141
+ $ uv run spiriconfig appstore sync
142
+ $ uv run spiriconfig appstore install whoami
143
+ $ uv run spiriconfig docker up whoami
144
+ $ curl localhost:8080
145
+ Hostname: 2d5bcd6f2629
146
+ GET / HTTP/1.1
147
+ ```
148
+
149
+ Run the tests and build the docs:
150
+
151
+ ```console
152
+ $ uv run pytest # 92 tests
153
+ $ uv run sphinx-build -b html docs docs/_build # docs
154
+ ```
155
+
156
+ Tests that need a docker daemon are skipped when there is not one, so the suite
157
+ passes on a laptop with no docker and still means something on a machine that has
158
+ it. Most of them never need one anyway: the plugin *builds* commands and the tests
159
+ assert on the command line, which is the actual contract.
@@ -0,0 +1,146 @@
1
+ # SpiriConfig
2
+
3
+ Plugin-based configuration and container management, built around one rule:
4
+
5
+ > **Anything SpiriConfig can do, you must be able to do without it.**
6
+
7
+ Press **Up** on a stack in the web UI and SpiriConfig runs this, showing you the
8
+ line as it goes, with a button to copy it:
9
+
10
+ ```console
11
+ $ cd /srv/compose/whoami && docker compose -p whoami -f compose.yaml up -d
12
+ ```
13
+
14
+ No database, no registry, no bespoke on-disk format. If SpiriConfig vanished
15
+ tomorrow, everything it manages would keep working -- and you would already know
16
+ the commands to manage it.
17
+
18
+ ## Installation
19
+
20
+ Every service SpiriConfig manages runs in a container; SpiriConfig itself cannot,
21
+ because the process that starts those containers on boot has to live on the host.
22
+ So it installs itself as a systemd service. You need
23
+ [uv](https://docs.astral.sh/uv/) on the machine, then:
24
+
25
+ ```console
26
+ $ uvx spiriconfig install
27
+ ```
28
+
29
+ That pulls the latest release from PyPI, writes a systemd unit and an environment
30
+ file, and enables and starts the service -- the web UI comes up on
31
+ http://localhost:8080 and returns on every boot. Add `--show` to print every
32
+ command it will run before it runs anything.
33
+
34
+ Run it as root for a system-wide service, or as a normal user for a
35
+ single-operator `systemctl --user` one; that choice *is* the security model, not
36
+ a preference. Update in place with `spiriconfig update`. See
37
+ [Installing SpiriConfig](docs/install.md) for the full walkthrough.
38
+
39
+ To track the latest development instead of a release, install from git -- the
40
+ positional argument is anything uv accepts:
41
+
42
+ ```console
43
+ $ spiriconfig install git+https://github.com/spiri-robotics/SpiriConfig.git
44
+ ```
45
+
46
+ A git-branch install pins to a commit, so `spiriconfig update --reinstall`
47
+ refetches the branch when it moves.
48
+
49
+ ## Usage
50
+
51
+ Point it at your compose directory and start the web UI:
52
+
53
+ ```console
54
+ $ export SPIRICONFIG_DOCKER_COMPOSE_DIR=/srv/compose
55
+ $ spiriconfig serve # web UI on http://localhost:8080
56
+ ```
57
+
58
+ Adding a service is making a directory. No CLI required -- that is the point:
59
+
60
+ ```console
61
+ $ mkdir -p /srv/compose/whoami
62
+ $ $EDITOR /srv/compose/whoami/compose.yaml
63
+ ```
64
+
65
+ From the shell:
66
+
67
+ ```console
68
+ $ spiriconfig docker list
69
+ whoami running
70
+ grafana stopped
71
+
72
+ $ spiriconfig docker up whoami
73
+ $ spiriconfig docker logs whoami -f
74
+ ```
75
+
76
+ Not sure what a command will do? Ask, without running it:
77
+
78
+ ```console
79
+ $ spiriconfig docker up whoami --show
80
+ cd /srv/compose/whoami && docker compose -p whoami -f compose.yaml up -d
81
+ ```
82
+
83
+ Because the compose project name is the directory name, these are the same
84
+ containers you get from running compose yourself -- SpiriConfig and a plain shell
85
+ can manage the same stack on the same afternoon without confusing each other.
86
+
87
+ ## Configuration
88
+
89
+ Environment variables only. See [the docs](docs/configuration.md) for the full
90
+ list; the one you need is:
91
+
92
+ | Variable | Default | Meaning |
93
+ | --- | --- | --- |
94
+ | `SPIRICONFIG_DOCKER_COMPOSE_DIR` | `test_data/compose` | One subdirectory per compose project. Set it to `/srv/compose` on a real machine. |
95
+
96
+ The defaults are relative on purpose: running out of a checkout should not start
97
+ managing the containers on your actual box. See [configuration](docs/configuration.md).
98
+
99
+ ## Plugins
100
+
101
+ The docker plugin is the only one that ships, and it is not special: it is
102
+ discovered through the `spiriconfig.plugins` entry point group exactly as yours
103
+ would be.
104
+
105
+ ```toml
106
+ [project.entry-points."spiriconfig.plugins"]
107
+ tailscale = "spiriconfig_tailscale:TailscalePlugin"
108
+ ```
109
+
110
+ Install the package and it appears in the CLI and the web UI. See
111
+ [docs/plugins.md](docs/plugins.md) for the interface and the rules, and
112
+ [docs/design.md](docs/design.md) for why the rules exist.
113
+
114
+ ## Development
115
+
116
+ Work from a checkout with [uv](https://docs.astral.sh/uv/):
117
+
118
+ ```console
119
+ $ uv sync
120
+ ```
121
+
122
+ You can drive the whole flow without touching your real machine. `test_data/` is
123
+ gitignored and disposable, and the default settings point at it, so trying
124
+ SpiriConfig out cannot start managing the containers on your box:
125
+
126
+ ```console
127
+ $ ./scripts/test-data.sh # a compose dir + an example app store
128
+ $ uv run spiriconfig appstore sync
129
+ $ uv run spiriconfig appstore install whoami
130
+ $ uv run spiriconfig docker up whoami
131
+ $ curl localhost:8080
132
+ Hostname: 2d5bcd6f2629
133
+ GET / HTTP/1.1
134
+ ```
135
+
136
+ Run the tests and build the docs:
137
+
138
+ ```console
139
+ $ uv run pytest # 92 tests
140
+ $ uv run sphinx-build -b html docs docs/_build # docs
141
+ ```
142
+
143
+ Tests that need a docker daemon are skipped when there is not one, so the suite
144
+ passes on a laptop with no docker and still means something on a machine that has
145
+ it. Most of them never need one anyway: the plugin *builds* commands and the tests
146
+ assert on the command line, which is the actual contract.