spiriconfig 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. spiriconfig/__init__.py +15 -0
  2. spiriconfig/__main__.py +8 -0
  3. spiriconfig/advanced.py +146 -0
  4. spiriconfig/auth.py +246 -0
  5. spiriconfig/cli.py +197 -0
  6. spiriconfig/commands.py +495 -0
  7. spiriconfig/config.py +138 -0
  8. spiriconfig/logging.py +45 -0
  9. spiriconfig/plugins.py +133 -0
  10. spiriconfig/preferences.py +96 -0
  11. spiriconfig/service.py +345 -0
  12. spiriconfig/terminal.py +211 -0
  13. spiriconfig/theme.py +108 -0
  14. spiriconfig/tls.py +247 -0
  15. spiriconfig/web.py +311 -0
  16. spiriconfig-0.1.0.dist-info/METADATA +159 -0
  17. spiriconfig-0.1.0.dist-info/RECORD +44 -0
  18. spiriconfig-0.1.0.dist-info/WHEEL +4 -0
  19. spiriconfig-0.1.0.dist-info/entry_points.txt +8 -0
  20. spiriconfig_appstore/__init__.py +55 -0
  21. spiriconfig_appstore/cli.py +611 -0
  22. spiriconfig_appstore/config.py +73 -0
  23. spiriconfig_appstore/credentials.py +307 -0
  24. spiriconfig_appstore/installs.py +175 -0
  25. spiriconfig_appstore/stores.py +672 -0
  26. spiriconfig_appstore/web.py +827 -0
  27. spiriconfig_docker/__init__.py +41 -0
  28. spiriconfig_docker/cli.py +424 -0
  29. spiriconfig_docker/config.py +50 -0
  30. spiriconfig_docker/env.py +276 -0
  31. spiriconfig_docker/settings.py +630 -0
  32. spiriconfig_docker/stacks.py +674 -0
  33. spiriconfig_docker/web.py +796 -0
  34. spiriconfig_docker/widgets.py +374 -0
  35. spiriconfig_terminal/__init__.py +48 -0
  36. spiriconfig_terminal/cli.py +53 -0
  37. spiriconfig_terminal/config.py +40 -0
  38. spiriconfig_terminal/shell.py +86 -0
  39. spiriconfig_terminal/web.py +97 -0
  40. spiriconfig_users/__init__.py +43 -0
  41. spiriconfig_users/cli.py +211 -0
  42. spiriconfig_users/config.py +52 -0
  43. spiriconfig_users/users.py +331 -0
  44. spiriconfig_users/web.py +425 -0
@@ -0,0 +1,15 @@
1
+ """SpiriConfig: plugin-based configuration and container management.
2
+
3
+ The core is deliberately small. It discovers plugins, gives them a CLI to hang
4
+ subcommands off and a web UI to render pages into, and runs commands on their
5
+ behalf. Everything a user would actually call a feature lives in a plugin.
6
+
7
+ The rule the whole project is built around: **anything the web UI can do, the
8
+ user must be able to do without it.** Concretely, that means we drive the
9
+ system by running the same commands a human would run -- see
10
+ :mod:`spiriconfig.commands`.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ __version__ = "0.1.0"
@@ -0,0 +1,8 @@
1
+ """Allow ``python -m spiriconfig`` as well as the ``spiriconfig`` script."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from spiriconfig.cli import app
6
+
7
+ if __name__ == "__main__":
8
+ app()
@@ -0,0 +1,146 @@
1
+ """Advanced mode: a filter on what the web UI *shows*.
2
+
3
+ Advanced mode hides clutter. It is not a permission system, and it must never be
4
+ used as one -- a hidden button is still a reachable capability, and the CLI does
5
+ everything regardless of what the UI is currently showing. That is deliberate:
6
+ the CLI is the escape hatch that makes progressive enhancement true, so gating it
7
+ would undercut the whole project. If you ever need "this person may not restart
8
+ containers", that is authorisation, and it belongs in front of the *command*, not
9
+ in front of the button.
10
+
11
+ So: advanced mode decides what a page renders. Nothing else.
12
+
13
+ Plugins use it like this::
14
+
15
+ from spiriconfig import advanced
16
+
17
+ ui.button("Up", on_click=...) # everyone sees this
18
+
19
+ with advanced.only():
20
+ ui.button("Edit", on_click=...) # only developers see this
21
+
22
+ Elements inside :func:`only` are *bound* to the setting rather than conditionally
23
+ created, so flipping the toggle shows and hides them instantly, with no page
24
+ rebuild and no lost state in whatever the user was doing.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ from collections.abc import Iterator
30
+ from contextlib import contextmanager
31
+
32
+ from loguru import logger
33
+ from nicegui import app, binding, context, ui
34
+ from nicegui.element import Element
35
+
36
+ from spiriconfig import theme
37
+ from spiriconfig.config import settings
38
+ from spiriconfig.preferences import preferences
39
+
40
+ #: Key this setting is stored under, in whatever store `preferences()` resolves to.
41
+ PREFERENCE_KEY = "advanced"
42
+
43
+ #: Key for the per-connection state object in NiceGUI's client storage.
44
+ _STATE_KEY = "advanced_state"
45
+
46
+
47
+ @binding.bindable_dataclass
48
+ class AdvancedState:
49
+ """The live setting for one connected client.
50
+
51
+ A bindable dataclass, so that assigning to :attr:`enabled` immediately
52
+ propagates to every element bound to it.
53
+ """
54
+
55
+ enabled: bool = False
56
+
57
+
58
+ def state() -> AdvancedState:
59
+ """Return the advanced-mode state for the client being served right now.
60
+
61
+ Seeded on first access from the person's stored preference, falling back to
62
+ ``SPIRICONFIG_ADVANCED`` -- so a developer image can ship with advanced mode
63
+ on by default, and a customer image with it off, from the same code.
64
+
65
+ The live object is held per connection; the *durable* value lives in the
66
+ preference store, which is the thing that will become per-user.
67
+ """
68
+ client_storage = app.storage.client
69
+ if _STATE_KEY not in client_storage:
70
+ default = settings().advanced
71
+ try:
72
+ stored = preferences().get(PREFERENCE_KEY, default)
73
+ except Exception: # noqa: BLE001 - a broken store must not break the page
74
+ logger.exception("could not read the advanced-mode preference")
75
+ stored = default
76
+ client_storage[_STATE_KEY] = AdvancedState(enabled=bool(stored))
77
+ return client_storage[_STATE_KEY]
78
+
79
+
80
+ def enabled() -> bool:
81
+ """Whether advanced mode is on for the client being served right now."""
82
+ return state().enabled
83
+
84
+
85
+ def set_enabled(value: bool) -> None:
86
+ """Turn advanced mode on or off, and remember the choice.
87
+
88
+ The live state updates first so the UI responds even if persistence fails --
89
+ a preference we could not save is a much smaller problem than a toggle that
90
+ appears not to work.
91
+ """
92
+ state().enabled = value
93
+ try:
94
+ preferences().set(PREFERENCE_KEY, value)
95
+ except Exception: # noqa: BLE001
96
+ logger.exception("could not save the advanced-mode preference")
97
+
98
+
99
+ def mark(element: Element) -> Element:
100
+ """Show ``element`` only in advanced mode. Returns it, so it chains.
101
+
102
+ Also makes it *look* advanced, which is the same act: an element cannot be
103
+ advanced-only and yet fail to say so, because this is the only way to make it
104
+ advanced-only in the first place.
105
+
106
+ How it says so depends on what it is. A button takes the purple as its Quasar
107
+ ``color`` prop -- purple lettering on a flat button, a purple face on a solid
108
+ one -- because Quasar's own stylesheet sets ``outline: 0`` on ``.q-btn``, and
109
+ a ring drawn on a button is a fight with the framework that we would be
110
+ re-fighting at every upgrade. Everything else gets the dashed ring.
111
+ """
112
+ element.classes(add=theme.ADVANCED_CLASS)
113
+ if isinstance(element, ui.button):
114
+ element.props(f"color={theme.ADVANCED}")
115
+ return element.bind_visibility_from(state(), "enabled")
116
+
117
+
118
+ @contextmanager
119
+ def only() -> Iterator[None]:
120
+ """Show everything created inside this block only in advanced mode.
121
+
122
+ Binds the elements that appear in the current slot, rather than wrapping them
123
+ in a container: a wrapper element would sit inside the parent's flex or grid
124
+ layout and quietly change how the visible siblings are arranged.
125
+ """
126
+ slot = context.slot
127
+ before = len(slot.children)
128
+ try:
129
+ yield
130
+ finally:
131
+ for element in slot.children[before:]:
132
+ mark(element)
133
+
134
+
135
+ def toggle() -> ui.switch:
136
+ """A switch for advanced mode. Always visible -- it is the way back.
137
+
138
+ Purple when it is on, and grey when it is off, wearing the same colour as the
139
+ ring around everything it reveals: the switch is the legend for the marks.
140
+ """
141
+ switch = ui.switch(
142
+ "Advanced",
143
+ value=enabled(),
144
+ on_change=lambda event: set_enabled(event.value),
145
+ ).props(f"color={theme.ADVANCED}")
146
+ return switch.tooltip("Show developer features: raw commands, file editing")
spiriconfig/auth.py ADDED
@@ -0,0 +1,246 @@
1
+ """The optional PAM login in front of the web UI.
2
+
3
+ Off unless ``SPIRICONFIG_AUTH=pam`` (see :class:`~spiriconfig.config.Settings`).
4
+ When on, every page redirects to ``/login`` until the browser has authenticated
5
+ against the host's PAM stack, exactly as ``login`` or ``sshd`` would.
6
+
7
+ This is only a login gate -- authentication, not authorization (see
8
+ :doc:`design </design>`). Once past it everyone shares the one process, which runs
9
+ as whoever launched it, so every authenticated user has the same access. It answers
10
+ "is this a person the machine trusts?", not "what may this particular person do?";
11
+ the second question has no answer here, because per-user permissions are not part
12
+ of the model.
13
+
14
+ Why the login rule below is shaped the way it is: only root can read
15
+ ``/etc/shadow``, so only a root process can verify *another* user's password. A
16
+ non-root process can verify *its own* user's password and no one else's (PAM's
17
+ setuid ``unix_chkpwd`` helper is what lets it do even that). :func:`authenticate`
18
+ enforces exactly that boundary rather than pretending to a power the kernel will
19
+ not grant it.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import grp
25
+ import os
26
+ import pwd
27
+ from dataclasses import dataclass
28
+
29
+ from loguru import logger
30
+ from nicegui import app, ui
31
+ from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
32
+ from starlette.requests import Request
33
+ from starlette.responses import RedirectResponse, Response
34
+
35
+ from spiriconfig import theme
36
+ from spiriconfig.config import Settings
37
+
38
+ log = logger.bind(component="auth")
39
+
40
+
41
+ def is_root() -> bool:
42
+ """Whether the process can verify any account's password, not just its own.
43
+
44
+ A function, not a module constant, so a test can mock it -- and so the answer
45
+ is read now rather than frozen at import.
46
+ """
47
+ return os.geteuid() == 0
48
+
49
+
50
+ def running_user() -> str:
51
+ """The account this process runs as: the only login possible when not root."""
52
+ return pwd.getpwuid(os.geteuid()).pw_name
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class AuthResult:
57
+ """The outcome of one login attempt.
58
+
59
+ ``error`` is written for a person to read on the login page, so it names what
60
+ is wrong without leaking whether it was the username or the password that
61
+ failed the PAM check itself -- that distinction is a gift to someone guessing.
62
+ """
63
+
64
+ ok: bool
65
+ username: str | None = None
66
+ error: str | None = None
67
+
68
+
69
+ def _in_group(username: str, group: str) -> bool:
70
+ """Whether ``username`` belongs to ``group``, by membership or primary gid.
71
+
72
+ A user's primary group does not list them in ``gr_mem``, so checking only the
73
+ member list would miss someone whose *primary* group is the admin one. A group
74
+ that does not exist is a misconfiguration, not a member: logged loudly, because
75
+ the visible symptom is otherwise "nobody can log in" with no reason given.
76
+ """
77
+ try:
78
+ entry = grp.getgrnam(group)
79
+ except KeyError:
80
+ log.error(
81
+ "auth group {!r} does not exist; no one can log in until it does or "
82
+ "SPIRICONFIG_AUTH_GROUP names a group that does",
83
+ group,
84
+ )
85
+ return False
86
+ if username in entry.gr_mem:
87
+ return True
88
+ try:
89
+ return pwd.getpwnam(username).pw_gid == entry.gr_gid
90
+ except KeyError:
91
+ return False
92
+
93
+
94
+ def authenticate(username: str, password: str, config: Settings) -> AuthResult:
95
+ """Decide a login, applying the who-may-I-even-check rule before touching PAM.
96
+
97
+ The policy, and the only part of this module with logic worth testing on its
98
+ own. PAM is imported lazily and last: a box where ``auth`` is ``none`` never
99
+ loads libpam at all, and a box where libpam will not load fails one login with
100
+ a clear message instead of crashing the process at import.
101
+ """
102
+ username = username.strip()
103
+ if not username or not password:
104
+ return AuthResult(False, error="Enter a username and a password.")
105
+
106
+ if not is_root():
107
+ # We can only verify our own account's password, so no other name could
108
+ # succeed even if we tried it. Say so plainly rather than failing the PAM
109
+ # check for a reason the user cannot act on.
110
+ me = running_user()
111
+ if username != me:
112
+ return AuthResult(
113
+ False,
114
+ error=(
115
+ f"SpiriConfig is running as {me!r}, and a non-root process can "
116
+ f"only log in that one account. Log in as {me!r}."
117
+ ),
118
+ )
119
+ elif not _in_group(username, config.auth_group):
120
+ # Root can verify anyone, so without this gate every system account --
121
+ # nobody, service users -- would be an admin login.
122
+ return AuthResult(
123
+ False,
124
+ error=f"{username!r} is not a member of the {config.auth_group!r} group.",
125
+ )
126
+
127
+ try:
128
+ import pamela
129
+ except Exception as exc: # noqa: BLE001 - libpam may be missing/unloadable
130
+ log.error("PAM is unavailable, cannot authenticate: {}", exc)
131
+ return AuthResult(False, error="PAM is unavailable on this host; cannot log in.")
132
+
133
+ try:
134
+ pamela.authenticate(username, password, service=config.auth_service)
135
+ except pamela.PAMError as exc:
136
+ # INFO, not WARNING: a failed login is a normal event, and the reason
137
+ # (bad password vs. expired account) belongs in the log, not on the page.
138
+ log.info("PAM rejected {!r} via service {!r}: {}", username, config.auth_service, exc)
139
+ return AuthResult(False, error="Incorrect username or password.")
140
+
141
+ log.info("{!r} logged in", username)
142
+ return AuthResult(True, username=username)
143
+
144
+
145
+ # Routes reachable without a session. The login page has to be, or there is no
146
+ # way to get one; NiceGUI's own traffic is handled by prefix in the middleware.
147
+ unrestricted_page_routes = {"/login"}
148
+
149
+
150
+ class AuthMiddleware(BaseHTTPMiddleware):
151
+ """Send an unauthenticated request to ``/login``, remembering where it meant to go.
152
+
153
+ Everything under ``/_nicegui`` is let through unconditionally: it is the
154
+ framework's own assets and websocket, and the login page itself cannot render
155
+ without them. Only whole-page navigations are gated -- which is all that needs
156
+ to be, since a page is where a person actually arrives.
157
+ """
158
+
159
+ async def dispatch(
160
+ self, request: Request, call_next: RequestResponseEndpoint
161
+ ) -> Response:
162
+ if not app.storage.user.get("authenticated", False):
163
+ path = request.url.path
164
+ if path not in unrestricted_page_routes and not path.startswith("/_nicegui"):
165
+ app.storage.user["referrer_path"] = path
166
+ return RedirectResponse("/login")
167
+ return await call_next(request)
168
+
169
+
170
+ def logout() -> None:
171
+ """Drop the session and return to the login page."""
172
+ username = app.storage.user.get("username")
173
+ app.storage.user.clear()
174
+ if username:
175
+ log.info("{!r} logged out", username)
176
+ ui.navigate.to("/login")
177
+
178
+
179
+ def header_account() -> None:
180
+ """The 'you are X / log out' control for the shared header.
181
+
182
+ Renders nothing when no one is authenticated, so the header can call it
183
+ unconditionally: with ``auth`` off no session ever carries a username, so this
184
+ simply draws nothing and the header is unchanged.
185
+ """
186
+ username = app.storage.user.get("username")
187
+ if not username:
188
+ return
189
+ ui.space()
190
+ ui.label(username).classes("text-white text-sm").mark("account-user")
191
+ ui.button(icon="logout", on_click=logout).props(
192
+ "flat round dense color=white"
193
+ ).mark("logout").tooltip("Log out")
194
+
195
+
196
+ def login_page(config: Settings) -> None:
197
+ """Register ``/login``. Called only when :attr:`~spiriconfig.config.Settings.auth` is on."""
198
+
199
+ @ui.page("/login")
200
+ def _login() -> None:
201
+ theme.apply()
202
+
203
+ # Already in? The middleware lets /login through, so a logged-in visitor
204
+ # would otherwise sit staring at a login form. Send them on.
205
+ if app.storage.user.get("authenticated", False):
206
+ ui.navigate.to("/")
207
+ return
208
+
209
+ # Not root -> only running_user() can ever succeed, so name it and lock the
210
+ # field. Making someone guess the one account that works would be a small
211
+ # cruelty the PAM rule lets us avoid.
212
+ fixed_user = None if is_root() else running_user()
213
+
214
+ with ui.card().classes("absolute-center w-80 gap-3"):
215
+ ui.label("SpiriConfig").classes("text-xl font-bold")
216
+ username = ui.input("Username", value=fixed_user or "").classes("w-full")
217
+ username.mark("username")
218
+ if fixed_user is not None:
219
+ username.props("readonly")
220
+ password = ui.input(
221
+ "Password", password=True, password_toggle_button=True
222
+ ).classes("w-full")
223
+ password.mark("password")
224
+
225
+ def _submit() -> None:
226
+ result = authenticate(username.value, password.value, config)
227
+ if result.ok:
228
+ app.storage.user.update(authenticated=True, username=result.username)
229
+ ui.navigate.to(app.storage.user.pop("referrer_path", "/"))
230
+ else:
231
+ ui.notify(result.error, type="negative")
232
+
233
+ password.on("keydown.enter", _submit)
234
+ ui.button("Log in", on_click=_submit).classes("w-full").mark("login")
235
+
236
+
237
+ __all__ = [
238
+ "AuthMiddleware",
239
+ "AuthResult",
240
+ "authenticate",
241
+ "header_account",
242
+ "is_root",
243
+ "login_page",
244
+ "logout",
245
+ "running_user",
246
+ ]
spiriconfig/cli.py ADDED
@@ -0,0 +1,197 @@
1
+ """The ``spiriconfig`` command.
2
+
3
+ The root CLI owns almost nothing. It configures logging, discovers plugins, and
4
+ mounts each plugin's Typer app as a subcommand, so ``spiriconfig docker up foo``
5
+ is the docker plugin's own code. The only thing the core adds is ``serve``, which
6
+ starts the web UI, and ``plugins``, which tells you what got loaded.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import getpass
12
+ import secrets
13
+ from pathlib import Path
14
+ from typing import Annotated
15
+
16
+ import typer
17
+
18
+ from spiriconfig import logging
19
+ from spiriconfig.config import settings
20
+ from spiriconfig.plugins import Plugin, discover
21
+
22
+ app = typer.Typer(
23
+ name="spiriconfig",
24
+ help="Plugin-based configuration and container management.",
25
+ no_args_is_help=True,
26
+ )
27
+
28
+
29
+ def _mount(plugins: list[Plugin]) -> None:
30
+ """Mount each plugin's Typer app as ``spiriconfig <name>``."""
31
+ for plugin in plugins:
32
+ sub = plugin.cli()
33
+ if sub is None:
34
+ continue
35
+ app.add_typer(sub, name=plugin.name, help=plugin.description or None)
36
+
37
+
38
+ @app.command()
39
+ def serve() -> None:
40
+ """Start the web UI."""
41
+ from spiriconfig import web
42
+
43
+ config = settings()
44
+ web.serve(config)
45
+
46
+
47
+ @app.command("plugins")
48
+ def list_plugins() -> None:
49
+ """List the installed plugins."""
50
+ plugins = discover()
51
+ if not plugins:
52
+ typer.echo("No plugins installed.")
53
+ return
54
+ width = max(len(p.name) for p in plugins)
55
+ for plugin in plugins:
56
+ faces = []
57
+ if plugin.cli() is not None:
58
+ faces.append("cli")
59
+ if plugin.has_page:
60
+ faces.append("web")
61
+ typer.echo(f"{plugin.name:<{width}} {','.join(faces):<8} {plugin.description}")
62
+
63
+
64
+ def _write_file(path: Path, content: str) -> None:
65
+ """Create parent directories and write a file, for the install's two files."""
66
+ path.parent.mkdir(parents=True, exist_ok=True)
67
+ path.write_text(content)
68
+
69
+
70
+ @app.command()
71
+ def install(
72
+ source: Annotated[
73
+ str,
74
+ typer.Argument(
75
+ help="What to install: a PyPI spec, a git+ URL, or '.' with --editable."
76
+ ),
77
+ ] = "spiriconfig",
78
+ compose_dir: Annotated[
79
+ Path | None,
80
+ typer.Option(help="Where the docker plugin looks for apps. [system: /srv/compose]"),
81
+ ] = None,
82
+ auth: Annotated[
83
+ str, typer.Option(help="Login gate: 'pam' (the default) or 'none'.")
84
+ ] = "pam",
85
+ host: Annotated[str, typer.Option(help="Address to bind.")] = "127.0.0.1",
86
+ port: Annotated[int, typer.Option(help="Port to bind.")] = 8080,
87
+ auth_group: Annotated[
88
+ str, typer.Option(help="Group whose members may log in, when run as root.")
89
+ ] = "wheel",
90
+ editable: Annotated[
91
+ bool, typer.Option("--editable", "-e", help="Install the source in editable mode.")
92
+ ] = False,
93
+ show: Annotated[
94
+ bool, typer.Option("--show", help="Print everything install would do, and stop."),
95
+ ] = False,
96
+ ) -> None:
97
+ """Install SpiriConfig as a systemd service on this machine.
98
+
99
+ As root it becomes a system service that runs as root -- the only install
100
+ where the PAM login is multi-user and the users plugin can manage accounts. As
101
+ a normal user it becomes a `systemctl --user` service running as you, so the
102
+ login only ever authenticates your own account.
103
+
104
+ `--show` prints the exact `uv tool install`, the unit file, the env file, and
105
+ the `systemctl` commands, so you can do the whole thing by hand instead.
106
+ """
107
+ from spiriconfig import service
108
+
109
+ scope = service.Scope.detect()
110
+ config = service.ServiceConfig(
111
+ compose_dir=compose_dir
112
+ or (Path("/srv/compose") if scope.system else Path.home() / "compose"),
113
+ storage_secret=secrets.token_urlsafe(32),
114
+ auth=auth,
115
+ auth_group=auth_group,
116
+ host=host,
117
+ port=port,
118
+ )
119
+ try:
120
+ service.check_exposure(config)
121
+ except service.ServiceError as exc:
122
+ typer.secho(str(exc), fg=typer.colors.RED, err=True)
123
+ raise typer.Exit(1) from exc
124
+
125
+ exec_path = service.executable_path()
126
+ unit = service.render_unit_file(scope, exec_path)
127
+ env_file = service.render_env_file(config)
128
+
129
+ tool = service.install_tool_command(source, editable=editable)
130
+ steps = [tool, service.daemon_reload_command(scope), service.enable_command(scope)]
131
+ if not scope.system:
132
+ steps.append(service.linger_command(getpass.getuser()))
133
+
134
+ if show:
135
+ typer.echo(f"# {scope.name} install\n")
136
+ typer.echo(str(tool))
137
+ typer.echo(f"\n# write {scope.env_path}\n{env_file}")
138
+ typer.echo(f"# write {scope.unit_path}\n{unit}")
139
+ for step in steps[1:]:
140
+ typer.echo(str(step))
141
+ return
142
+
143
+ from spiriconfig.commands import run
144
+
145
+ run(tool, timeout=None).check()
146
+ _write_file(scope.env_path, env_file)
147
+ _write_file(scope.unit_path, unit)
148
+ for step in steps[1:]:
149
+ run(step).check()
150
+ typer.echo(f"Installed and started {service.SERVICE_NAME} ({scope.name} service).")
151
+
152
+
153
+ @app.command()
154
+ def update(
155
+ reinstall: Annotated[
156
+ bool,
157
+ typer.Option("--reinstall", help="Force a refetch -- needed for a git branch."),
158
+ ] = False,
159
+ show: Annotated[
160
+ bool, typer.Option("--show", help="Print the upgrade and restart, and stop."),
161
+ ] = False,
162
+ ) -> None:
163
+ """Update SpiriConfig in place, then restart the service.
164
+
165
+ The restart is handed to systemd to do a moment later rather than run inline,
166
+ because this very process is what gets restarted -- an inline restart would cut
167
+ the command off before it could report back.
168
+ """
169
+ from spiriconfig import service
170
+
171
+ scope = service.Scope.detect()
172
+ upgrade = service.upgrade_tool_command(reinstall=reinstall)
173
+ restart = service.restart_command(scope)
174
+
175
+ if show:
176
+ typer.echo(str(upgrade))
177
+ typer.echo(str(restart))
178
+ return
179
+
180
+ from spiriconfig.commands import run
181
+
182
+ run(upgrade, timeout=None).check()
183
+ run(restart).check()
184
+ typer.echo("Update applied; the service is restarting.")
185
+
186
+
187
+ # Logging is configured, and plugins mounted, at import time: Typer needs every
188
+ # subcommand registered before it can route `spiriconfig docker ...` or list them
189
+ # in `--help`, and discovery logs as it goes, so the sinks have to exist first.
190
+ # Discovery failures are logged and skipped inside discover(), so one broken
191
+ # plugin cannot stop the CLI from starting.
192
+ logging.configure(settings())
193
+ _mount(discover())
194
+
195
+
196
+ if __name__ == "__main__":
197
+ app()