sidepage 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 (42) hide show
  1. sidepage/__init__.py +9 -0
  2. sidepage/cli.py +143 -0
  3. sidepage/commands/__init__.py +7 -0
  4. sidepage/commands/account.py +98 -0
  5. sidepage/commands/app_registry.py +344 -0
  6. sidepage/commands/directory.py +73 -0
  7. sidepage/commands/inspect.py +39 -0
  8. sidepage/commands/new.py +29 -0
  9. sidepage/commands/scope.py +26 -0
  10. sidepage/commands/secrets.py +68 -0
  11. sidepage/commands/serve.py +260 -0
  12. sidepage/commands/setup.py +59 -0
  13. sidepage/commands/usage.py +37 -0
  14. sidepage/config/__init__.py +4 -0
  15. sidepage/config/settings.py +162 -0
  16. sidepage/core/__init__.py +8 -0
  17. sidepage/core/account.py +163 -0
  18. sidepage/core/app_registry.py +200 -0
  19. sidepage/core/auth.py +40 -0
  20. sidepage/core/cloudflared_installer.py +281 -0
  21. sidepage/core/directory_client.py +128 -0
  22. sidepage/core/ecosystem.py +90 -0
  23. sidepage/core/exceptions.py +140 -0
  24. sidepage/core/guardrail.py +30 -0
  25. sidepage/core/inspector.py +239 -0
  26. sidepage/core/notebook.py +77 -0
  27. sidepage/core/process.py +464 -0
  28. sidepage/core/registry.py +171 -0
  29. sidepage/core/reverse_proxy.py +495 -0
  30. sidepage/core/scaffold.py +40 -0
  31. sidepage/core/secrets_vault.py +117 -0
  32. sidepage/core/static.py +32 -0
  33. sidepage/core/target.py +235 -0
  34. sidepage/core/token_runtime.py +89 -0
  35. sidepage/core/tunnel_manager.py +686 -0
  36. sidepage/core/usage_reporter.py +70 -0
  37. sidepage/output.py +49 -0
  38. sidepage-0.1.0.dist-info/METADATA +306 -0
  39. sidepage-0.1.0.dist-info/RECORD +42 -0
  40. sidepage-0.1.0.dist-info/WHEEL +4 -0
  41. sidepage-0.1.0.dist-info/entry_points.txt +3 -0
  42. sidepage-0.1.0.dist-info/licenses/LICENSE +21 -0
sidepage/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ """sidepage — local-first hosting, tunneling, and directory CLI for small apps and MCP servers.
2
+
3
+ See README.md for the full command reference and docs/OPEN_QUESTIONS.md for
4
+ decisions the spec left open. This package is currently CLI scaffolding only:
5
+ command wiring, help text, and options are real; the underlying behavior
6
+ lives in `sidepage.core` as commented placeholders until the SDK is built.
7
+ """
8
+
9
+ __version__ = "0.1.0"
sidepage/cli.py ADDED
@@ -0,0 +1,143 @@
1
+ """Root Typer app — assembles every `sidepage.commands` module into the
2
+ `sidepage` command tree described in the spec (v3). This module owns wiring
3
+ only; see each `sidepage.commands.*` module for the spec section it
4
+ implements.
5
+
6
+ Command tree:
7
+ sidepage setup (not in spec — see note below)
8
+ sidepage new <name> §1
9
+ sidepage serve <target> §2
10
+ sidepage stop <app-name> §2
11
+ sidepage promote <app-name> §5
12
+ sidepage login §13
13
+ sidepage account status §13
14
+ sidepage account domain set <domain> §13 (v4: + --api-token-name)
15
+ sidepage usage <app-name> §7
16
+ sidepage secrets set|list|remove v4 §9
17
+ sidepage inspect [<app-name-or-url>] §10
18
+ sidepage ls (no v3 section — see note below)
19
+ sidepage status <app-name> (no v3 section — see note below)
20
+ sidepage app register|list|show|unregister (registry spec v2, no v3 section)
21
+ sidepage serve <app-name> (registry spec v2 — serve's <target>
22
+ also accepts a registered app name)
23
+
24
+ Note: `sidepage setup` isn't a v1/v3/v4 spec command at all — it's a
25
+ `pip install sidepage` packaging concern (installing the `cloudflared`
26
+ binary tunnel functionality needs without making it a Python dependency
27
+ or vendoring it into the wheel). See `sidepage.core.cloudflared_installer`
28
+ and `sidepage.commands.setup`.
29
+
30
+ Note: v3 has no "Directory queries" section (v1's §10) — it doesn't
31
+ mention `ls`/`status` at all, jumping from §9 (local reverse proxy) to §10
32
+ (inspection) to §11 (static). Kept here on the same reasoning as
33
+ guardrails: the directory model itself is still very much alive in v3
34
+ (§3, §5), so this reads as "not re-stated" rather than "cut" — but flagged
35
+ since it wasn't a locked-in decision the way the identity/keys/tunnel drops
36
+ were.
37
+
38
+ Dropped from v1's tree, not carried forward: `whoami` / `name check`
39
+ (folded into `account status`, see sidepage.core.account), `keys
40
+ create|revoke|list` (replaced by per-serve `--token`, see
41
+ sidepage.core.token_runtime), `tunnel login|token set|status|revoke`
42
+ (replaced by `login` / `account domain set`, tunnel mechanics moved into
43
+ sidepage.core.tunnel_manager without a dedicated command group).
44
+
45
+ (§8, token handling, has no standalone command — it's `serve --token` /
46
+ `SIDEPAGE_TOKEN`. Guardrails, still parked though absent from v3, remain
47
+ `serve --guardrail`; see sidepage.commands.serve and sidepage.core.guardrail.)
48
+
49
+ v4 adds `sidepage secrets set|list|remove` — the secrets vault, cited by
50
+ the user's own summary as "v4 §9." v3 §9 was "local reverse proxy"; the
51
+ migration to v4 was done from a 4-point delta summary, not the full v4
52
+ document, so whether v4 actually renumbered the reverse proxy section is
53
+ unconfirmed — see `docs/OPEN_QUESTIONS.md` for the flagged collision.
54
+ `sidepage.core.secrets_vault` is genuinely new, not a replacement for
55
+ `sidepage.core.reverse_proxy`, regardless of how the numbering shakes out.
56
+ """
57
+
58
+ from __future__ import annotations
59
+
60
+ import typer
61
+
62
+ from sidepage import __version__
63
+ from sidepage.commands import (
64
+ account,
65
+ app_registry,
66
+ directory,
67
+ inspect,
68
+ new,
69
+ scope,
70
+ secrets,
71
+ serve,
72
+ setup,
73
+ usage,
74
+ )
75
+
76
+ app = typer.Typer(
77
+ name="sidepage",
78
+ help=(
79
+ "Local-first hosting, tunneling, and directory for small apps and MCP servers.\n\n"
80
+ "Scaffold with `sidepage new`, serve it with `sidepage serve`, and share it — "
81
+ "ephemeral by default, torn down when you Ctrl+C."
82
+ ),
83
+ no_args_is_help=True,
84
+ add_completion=True,
85
+ )
86
+
87
+
88
+ def _version_callback(value: bool) -> None:
89
+ if value:
90
+ typer.echo(f"sidepage {__version__}")
91
+ raise typer.Exit()
92
+
93
+
94
+ @app.callback()
95
+ def main(
96
+ version: bool = typer.Option(
97
+ False,
98
+ "--version",
99
+ help="Show the sidepage CLI version and exit.",
100
+ callback=_version_callback,
101
+ is_eager=True,
102
+ ),
103
+ ) -> None:
104
+ """sidepage — local-first hosting, tunneling, and directory."""
105
+
106
+
107
+ # Not a numbered spec section — `pip install sidepage` packaging concern,
108
+ # not a v1/v3/v4 feature. Installs `cloudflared` (see
109
+ # sidepage.core.cloudflared_installer) without making it a Python
110
+ # dependency or vendoring it into the wheel.
111
+ app.command("setup")(setup.setup)
112
+
113
+ # §1 — targets / scaffolding
114
+ app.command("new")(new.new)
115
+
116
+ # §2 — serving
117
+ app.command("serve")(serve.serve)
118
+ app.command("stop")(serve.stop)
119
+
120
+ # §5 — discovery & scope
121
+ app.command("promote")(scope.promote)
122
+
123
+ # §7 — metering
124
+ app.command("usage")(usage.usage)
125
+
126
+ # v4 §9 — secrets vault
127
+ app.add_typer(secrets.secrets_app)
128
+
129
+ # §10 — inspection & directory queries
130
+ app.command("inspect")(inspect.inspect)
131
+ app.command("ls")(directory.ls)
132
+ app.command("status")(directory.status)
133
+
134
+ # §13 — account & login
135
+ app.command("login")(account.login)
136
+ app.add_typer(account.account_app)
137
+
138
+ # Local app registry (registry spec v2) — sidepage app register|list|show|unregister
139
+ app.add_typer(app_registry.app_app)
140
+
141
+
142
+ if __name__ == "__main__":
143
+ app()
@@ -0,0 +1,7 @@
1
+ """CLI command modules — argument parsing and help text only.
2
+
3
+ Each module here maps 1:1 to a section of the spec and owns the Typer
4
+ wiring for it. None of them implement real behavior; they validate/parse
5
+ input, then call into `sidepage.core` (unimplemented) or print a
6
+ `sidepage.output.not_implemented` notice naming the future implementation.
7
+ """
@@ -0,0 +1,98 @@
1
+ """`sidepage login` / `sidepage account status` / `sidepage account domain
2
+ set` — spec v3 §13, account & login.
3
+
4
+ Deliberately separate from per-app `--auth` (`sidepage serve --auth`) —
5
+ signing in to Sidepage and gating one app's visitors are different
6
+ concerns, and sharing a name would collide confusingly.
7
+
8
+ `account status` absorbs v1's `sidepage whoami` — see
9
+ `sidepage.core.account` for why that command was folded in rather than kept
10
+ standalone. Still unimplemented: there's no Sidepage account backend to
11
+ authenticate against.
12
+
13
+ `domain set` is real. v4 delta: a **single** required flag,
14
+ `--api-token-name` — a vault secret name (v4 §9,
15
+ `sidepage.core.secrets_vault`), not a raw credential value. The earlier
16
+ two-token design (`--zone-token-name` / `--tunnel-token-name`, requiring a
17
+ tunnel already created out-of-band) is gone — this command now creates the
18
+ tunnel itself via `sidepage.core.account.configure_domain`, and stores its
19
+ run-token in the vault automatically. That storage always happens, but is
20
+ never silent: success logs the internal vault name it landed under, and
21
+ failure names it too (see `configure_domain`'s
22
+ `TunnelProvisioningError` — the run-token is a one-time Cloudflare API
23
+ response, so a failure to persist it leaves an orphaned tunnel behind that
24
+ the user needs to know the ID of).
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ from typing import Annotated
30
+
31
+ import typer
32
+
33
+ from sidepage.core import account as account_core
34
+ from sidepage.core.exceptions import SecretNotFoundError, TunnelProvisioningError
35
+ from sidepage.output import error, info, not_implemented, success
36
+
37
+ account_app = typer.Typer(
38
+ name="account",
39
+ help="Account status and BYO-domain configuration.",
40
+ no_args_is_help=True,
41
+ )
42
+
43
+ domain_app = typer.Typer(
44
+ name="domain",
45
+ help="Manage the persistent default BYO domain (premium).",
46
+ no_args_is_help=True,
47
+ )
48
+ account_app.add_typer(domain_app)
49
+
50
+
51
+ def login() -> None:
52
+ """Interactive login flow."""
53
+ not_implemented("sidepage login", implemented_by="sidepage.core.account.login")
54
+
55
+
56
+ @account_app.command("status")
57
+ def status() -> None:
58
+ """Show the identity/session/plan currently active on this machine."""
59
+ not_implemented(
60
+ "sidepage account status", implemented_by="sidepage.core.account.current_account"
61
+ )
62
+
63
+
64
+ @domain_app.command("set")
65
+ def domain_set(
66
+ domain: Annotated[str, typer.Argument(help="Zone apex to use as the persistent default.")],
67
+ api_token_name: Annotated[
68
+ str,
69
+ typer.Option(
70
+ "--api-token-name",
71
+ help="Vault secret name (see `sidepage secrets set`) holding a Cloudflare API "
72
+ "token scoped to Account -> Cloudflare Tunnel:Edit, Zone -> DNS:Edit, "
73
+ "Zone -> Zone:Read.",
74
+ ),
75
+ ],
76
+ ) -> None:
77
+ """Provision (or reuse) the persistent default BYO domain (premium):
78
+ creates one Cloudflare Tunnel meant to serve every app later run under
79
+ `domain` via `serve --domain`, and stores that tunnel's run-token in
80
+ the vault automatically — never typed by the user, but always
81
+ reported below by its vault name. `domain` should be the zone apex
82
+ (e.g. `example.com`), not a pre-built subdomain — served apps get
83
+ `<app-name>-<id>.<domain>`. Re-running for an already-configured
84
+ domain is a no-op, not a re-provision."""
85
+ try:
86
+ config = account_core.configure_domain(domain, api_token_name=api_token_name)
87
+ except SecretNotFoundError as exc:
88
+ error(str(exc))
89
+ raise typer.Exit(1) from exc
90
+ except TunnelProvisioningError as exc:
91
+ error(str(exc))
92
+ raise typer.Exit(1) from exc
93
+
94
+ success(f"default BYO domain set: {domain} (tunnel {config.tunnel_id})")
95
+ info(
96
+ f"tunnel run-token stored in vault as {config.tunnel_token_name!r} "
97
+ "(see `sidepage secrets list`)"
98
+ )
@@ -0,0 +1,344 @@
1
+ """`sidepage app register|list|show|unregister` plus `sidepage serve
2
+ <app-name>` — registry spec v2 (`sidepage-registry-spec.md`).
3
+
4
+ **The parser-reuse mechanism, and why it's implemented here rather than
5
+ in `sidepage.core.app_registry`:** the spec's core design goal is that a
6
+ new `serve` flag automatically becomes registerable with zero changes on
7
+ this side — achieved by parsing a registration invocation string with
8
+ `serve`'s own real Click command object (`serve_cmd.make_context(...)`),
9
+ not a hand-maintained second parser. That needs a runtime import of
10
+ `sidepage.cli` (to fetch the fully-assembled Click command tree) — which
11
+ would be circular at module-load time, since `cli.py` imports this module
12
+ to wire up `sidepage app ...` in the first place. Deferred (function-body,
13
+ not module-top) imports below exist specifically to break that cycle;
14
+ `sidepage.core.app_registry` itself stays free of any `typer`/`click`
15
+ import, consistent with every other `core` module never importing from
16
+ `commands` or `cli`.
17
+
18
+ **A wrinkle worth recording:** this installed Typer version (0.27.1) fully
19
+ vendors its own fork of Click (`typer._click`) rather than depending on
20
+ the real `click` package for its command/context machinery — confirmed by
21
+ observing that errors raised from `Command.make_context(...)` here are
22
+ `typer._click.exceptions.*` instances, not `click.exceptions.*` (the two
23
+ are unrelated classes, even though a real `click` distribution is also
24
+ separately installed and importable). That's why parsing failures below
25
+ are caught as a broad `Exception` rather than the specific/expected
26
+ `click.ClickException` a normal Click integration would use — there's no
27
+ public Typer symbol for that private base class to catch instead, and
28
+ reaching into the private `typer._click` module by name would be more
29
+ fragile than a broad catch scoped tightly around one parse call.
30
+
31
+ **`sidepage serve <app-name>` merge semantics** (`merge_with_registered`
32
+ below) are the same for a real invocation and for `sidepage app show
33
+ --with`'s preview — both ultimately answer "for each mergeable field, did
34
+ *this* invocation pass it explicitly (`ctx.get_parameter_source(...) is
35
+ COMMANDLINE`), or should the registered value apply?" `sidepage.commands.serve`
36
+ calls this directly with its own already-Typer-typed parameters; `show
37
+ --with` calls it with values coerced from a `make_context()`-parsed
38
+ string, since raw `ctx.params` values are always plain strings/primitives
39
+ regardless of the declared parameter type (confirmed live — Typer's
40
+ type-conversion happens when it calls the real command function, not
41
+ when populating `ctx.params`) — see `_coerce_raw_params`.
42
+ """
43
+
44
+ from __future__ import annotations
45
+
46
+ import shlex
47
+ from pathlib import Path
48
+ from typing import Annotated, Any
49
+
50
+ import typer
51
+
52
+ from sidepage.core import app_registry
53
+ from sidepage.core.app_registry import AppRegistration
54
+ from sidepage.core.auth import AuthTier
55
+ from sidepage.core.directory_client import Scope
56
+ from sidepage.core.exceptions import (
57
+ AppNotRegisteredError,
58
+ AppRegistrationError,
59
+ TargetDetectionError,
60
+ )
61
+ from sidepage.core.target import TargetKind, detect_target_kind
62
+ from sidepage.output import error, info, stdout, success
63
+
64
+ app_app = typer.Typer(
65
+ name="app",
66
+ help="Save a `serve` invocation under a short name and re-run it later.",
67
+ no_args_is_help=True,
68
+ )
69
+
70
+
71
+ def _serve_click_command():
72
+ """The real, live `serve` Click command object — fetched at call time,
73
+ not import time, to avoid the `cli.py` <-> this module import cycle
74
+ described in this module's docstring."""
75
+ import typer as typer_module
76
+
77
+ from sidepage.cli import app as cli_app
78
+
79
+ click_group = typer_module.main.get_command(cli_app)
80
+ return click_group.commands["serve"]
81
+
82
+
83
+ def _make_serve_context(invocation: str, *, placeholder_target: bool = False):
84
+ """Parse `invocation` (shell-quoted `serve` flags, e.g. `"abc.py
85
+ --auth token"`) using `serve`'s own Click command — the single source
86
+ of truth this whole module exists to reuse rather than duplicate. Set
87
+ `placeholder_target=True` for a preview string that omits the target
88
+ entirely (`sidepage app show --with`, where the target is implied by
89
+ the already-registered app, not part of what's being previewed) —
90
+ `serve`'s positional `target` argument is required, so a harmless
91
+ placeholder is prepended and never read back out.
92
+ """
93
+ args = shlex.split(invocation)
94
+ if placeholder_target:
95
+ args = ["__sidepage_placeholder_target__", *args]
96
+ try:
97
+ return _serve_click_command().make_context("serve", args)
98
+ except Exception as exc:
99
+ raise AppRegistrationError(f"could not parse {invocation!r} as serve flags: {exc}") from exc
100
+
101
+
102
+ def _coerce_raw_params(raw: dict) -> dict:
103
+ """`ctx.params` from `make_context()` holds plain strings/primitives
104
+ regardless of the real parameter type (confirmed live, not assumed —
105
+ see module docstring) — this turns them into the same typed values a
106
+ real `serve` invocation's function parameters would already have."""
107
+ from sidepage.commands.serve import ServeTargetType
108
+
109
+ return {
110
+ "target": Path(raw["target"]),
111
+ "target_type": ServeTargetType(raw["target_type"]),
112
+ "name": raw["name"],
113
+ "domain": raw["domain"],
114
+ "auth": AuthTier(raw["auth"]),
115
+ "scope": Scope(raw["scope"]),
116
+ "anon": raw["anon"],
117
+ "token": raw["token"],
118
+ "env": list(raw["env"] or ()),
119
+ "guardrail": Path(raw["guardrail"]) if raw["guardrail"] else None,
120
+ }
121
+
122
+
123
+ def _is_explicit(ctx, field: str) -> bool:
124
+ source = ctx.get_parameter_source(field)
125
+ return source is not None and source.name == "COMMANDLINE"
126
+
127
+
128
+ def merge_with_registered(
129
+ ctx,
130
+ registered: AppRegistration,
131
+ *,
132
+ target_type,
133
+ name: str | None,
134
+ domain: str | None,
135
+ auth: AuthTier,
136
+ scope: Scope,
137
+ anon: bool,
138
+ env: list[str],
139
+ guardrail: Path | None,
140
+ ) -> dict[str, Any]:
141
+ """For each mergeable field, an explicit command-line value (source
142
+ `COMMANDLINE` on `ctx`, per `ctx.get_parameter_source`) overrides the
143
+ registered one; otherwise the registered value applies — the spec's
144
+ resolved merge semantics (override-wins, base config never mutated by
145
+ a one-off override). `target`/`token` are deliberately excluded:
146
+ `target` is implied by which registered app you're serving, not a
147
+ mergeable flag, and `token` is never stored at all (process-scoped,
148
+ always whatever this invocation supplies, registered or not).
149
+
150
+ `env`'s merge is **replace, not append**: an explicit `--env` on this
151
+ invocation replaces the registered env list entirely rather than
152
+ accumulating with it — the spec's example only exercises a scalar
153
+ override (`--scope`), so this is a judgment call, made for consistency
154
+ with every other field's replace semantics rather than inventing a
155
+ different rule for the one list-valued flag.
156
+
157
+ Returns a dict shaped as `ServeConfig`'s own keyword arguments
158
+ (`target_kind`, `name`, `domain`, `auth`, `scope`, `anon`,
159
+ `env_secrets`, `guardrail`) — ready to splice into
160
+ `ServeConfig(target=..., token=..., **merged)`.
161
+ """
162
+ from sidepage.commands.serve import ServeTargetType
163
+
164
+ if _is_explicit(ctx, "target_type"):
165
+ target_kind = None if target_type is ServeTargetType.AUTO else TargetKind(target_type.value)
166
+ else:
167
+ target_kind = registered.target_kind
168
+
169
+ def use(field: str, live_value, registered_value):
170
+ return live_value if _is_explicit(ctx, field) else registered_value
171
+
172
+ return {
173
+ "target_kind": target_kind,
174
+ "name": use("name", name, registered.name),
175
+ "domain": use("domain", domain, registered.domain),
176
+ "auth": use("auth", auth, registered.auth),
177
+ "scope": use("scope", scope, registered.scope),
178
+ "anon": use("anon", anon, registered.anon),
179
+ "env_secrets": tuple(env) if _is_explicit(ctx, "env") else registered.env_secrets,
180
+ "guardrail": use("guardrail", guardrail, registered.guardrail),
181
+ }
182
+
183
+
184
+ def _or_none(value: object, label: str = "(none)") -> str:
185
+ return str(value) if value is not None else f"[dim]{label}[/dim]"
186
+
187
+
188
+ def _print_registration(app_name: str, r: AppRegistration) -> None:
189
+ stdout.print(f"[bold]{app_name}[/bold]")
190
+ stdout.print(f" target: {r.target}")
191
+ stdout.print(f" type: {r.target_kind.value}")
192
+ stdout.print(f" name: {_or_none(r.name, '(defaults to app-name)')}")
193
+ stdout.print(f" domain: {_or_none(r.domain)}")
194
+ stdout.print(f" auth: {r.auth.value}")
195
+ stdout.print(f" scope: {r.scope.value}")
196
+ stdout.print(f" anon: {r.anon}")
197
+ stdout.print(f" env: {', '.join(r.env_secrets) or '[dim](none)[/dim]'}")
198
+ stdout.print(f" guardrail: {_or_none(r.guardrail)}")
199
+ stdout.print(f" registered_at: {r.registered_at}")
200
+
201
+
202
+ @app_app.command("register")
203
+ def register(
204
+ invocation: Annotated[
205
+ str,
206
+ typer.Argument(
207
+ help="The target plus any serve flags, as one shell-quoted string, "
208
+ 'e.g. "abc.py --auth token".'
209
+ ),
210
+ ],
211
+ app_name: Annotated[str, typer.Argument(help="Short name to register this invocation under.")],
212
+ ) -> None:
213
+ """Save a `serve` invocation under `app_name` for later `sidepage
214
+ serve <app-name>`. Parsed with `serve`'s own flags — a `--type` isn't
215
+ stored as given, it's resolved (auto-detection runs now, at
216
+ registration time, not deferred to every future `serve` call).
217
+
218
+ Rejects a literal `--token <value>` outright: auth tokens are
219
+ process-scoped and regenerate on each `serve` call, so persisting one
220
+ here would quietly reintroduce a plaintext, indefinitely-stored secret
221
+ — the exact separation the vault (`sidepage secrets`) and the runtime
222
+ token file already establish. `--env <NAME>` is fine to store: it's a
223
+ vault reference, not a value.
224
+ """
225
+ ctx = _make_serve_context(invocation)
226
+ raw = ctx.params
227
+
228
+ if raw["token"] is not None:
229
+ error(
230
+ "cannot register an app with a literal --token value.\n"
231
+ "Auth tokens are process-scoped and regenerate on each serve — omit --token "
232
+ "and one will be issued fresh each time this app is served."
233
+ )
234
+ raise typer.Exit(1)
235
+
236
+ values = _coerce_raw_params(raw)
237
+
238
+ try:
239
+ target_kind = detect_target_kind(
240
+ values["target"],
241
+ override=None
242
+ if values["target_type"].value == "auto"
243
+ else TargetKind(values["target_type"].value),
244
+ )
245
+ registration = app_registry.register(
246
+ app_name,
247
+ target=values["target"].resolve(),
248
+ target_kind=target_kind,
249
+ name=values["name"],
250
+ domain=values["domain"],
251
+ auth=values["auth"],
252
+ scope=values["scope"],
253
+ anon=values["anon"],
254
+ env_secrets=tuple(values["env"]),
255
+ guardrail=values["guardrail"],
256
+ )
257
+ except (AppRegistrationError, TargetDetectionError) as exc:
258
+ error(str(exc))
259
+ raise typer.Exit(1) from exc
260
+
261
+ success(f"registered {app_name!r} -> {registration.target} ({registration.target_kind.value})")
262
+
263
+
264
+ @app_app.command("list")
265
+ def list_() -> None:
266
+ """List registered app names."""
267
+ names = app_registry.list_registered()
268
+ if not names:
269
+ stdout.print("[dim]no apps registered[/dim]")
270
+ return
271
+ for name in names:
272
+ stdout.print(name)
273
+
274
+
275
+ @app_app.command("show")
276
+ def show(
277
+ app_name: Annotated[str, typer.Argument(help="Registered app name.")],
278
+ with_: Annotated[
279
+ str | None,
280
+ typer.Option(
281
+ "--with",
282
+ help="Preview the effective config if these serve flags were also passed, "
283
+ 'e.g. --with "--scope web" — the same merge `serve <app-name>` would do, '
284
+ "without actually running it.",
285
+ ),
286
+ ] = None,
287
+ ) -> None:
288
+ """Show a registered app's saved config — or, with `--with`, the
289
+ effective merged config it would run with if those extra flags were
290
+ passed to `sidepage serve <app-name>` too. Inspectable before it
291
+ runs, so a one-off override is never a surprise."""
292
+ registered = app_registry.get(app_name)
293
+ if registered is None:
294
+ error(f"no app named {app_name!r} is registered")
295
+ raise typer.Exit(1)
296
+
297
+ if with_ is None:
298
+ _print_registration(app_name, registered)
299
+ return
300
+
301
+ ctx = _make_serve_context(with_, placeholder_target=True)
302
+ values = _coerce_raw_params(ctx.params)
303
+ merged = merge_with_registered(
304
+ ctx,
305
+ registered,
306
+ target_type=values["target_type"],
307
+ name=values["name"],
308
+ domain=values["domain"],
309
+ auth=values["auth"],
310
+ scope=values["scope"],
311
+ anon=values["anon"],
312
+ env=values["env"],
313
+ guardrail=values["guardrail"],
314
+ )
315
+ if merged["name"] is None:
316
+ merged["name"] = app_name
317
+
318
+ info(f"effective config for {app_name!r} with --with {with_!r}:")
319
+ preview = AppRegistration(
320
+ target=registered.target,
321
+ target_kind=merged["target_kind"],
322
+ name=merged["name"],
323
+ domain=merged["domain"],
324
+ auth=merged["auth"],
325
+ scope=merged["scope"],
326
+ anon=merged["anon"],
327
+ env_secrets=merged["env_secrets"],
328
+ guardrail=merged["guardrail"],
329
+ registered_at=registered.registered_at,
330
+ )
331
+ _print_registration(app_name, preview)
332
+
333
+
334
+ @app_app.command("unregister")
335
+ def unregister(
336
+ app_name: Annotated[str, typer.Argument(help="Registered app name to remove.")],
337
+ ) -> None:
338
+ """Delete a registered app's saved config."""
339
+ try:
340
+ app_registry.unregister(app_name)
341
+ except AppNotRegisteredError as exc:
342
+ error(str(exc))
343
+ raise typer.Exit(1) from exc
344
+ success(f"unregistered {app_name!r}")
@@ -0,0 +1,73 @@
1
+ """`sidepage ls` / `sidepage status` — directory queries.
2
+
3
+ Not a numbered section in the v3 spec (v1 had a "Directory queries" §10;
4
+ v3 goes straight from §9 local reverse proxy to §10 inspection with no
5
+ `ls`/`status` mention). Kept as-is since the directory model itself is
6
+ still central to v3 (§3, §5) — treated as not re-stated, not cut.
7
+
8
+ Real, but against `sidepage.core.registry` (this machine's running apps),
9
+ not a cloud directory — there isn't one to talk to (see
10
+ `sidepage.core.directory_client`, still unimplemented). `--scope`/`--mine`
11
+ have no real meaning against a single-machine registry; `ls` notes that
12
+ rather than pretending to filter. `status` does a live reachability check
13
+ against the registered local URL — the "reconciliation" the spec describes,
14
+ just against this machine's own record instead of a cloud directory's.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from typing import Annotated
20
+
21
+ import httpx
22
+ import typer
23
+
24
+ from sidepage.core import registry
25
+ from sidepage.core.directory_client import Scope
26
+ from sidepage.output import error, info, stdout
27
+
28
+
29
+ def ls(
30
+ scope: Annotated[
31
+ Scope | None, typer.Option("--scope", help="Filter to one scope.")
32
+ ] = None,
33
+ mine: Annotated[
34
+ bool, typer.Option("--mine", help="Limit to the current identity's own apps.")
35
+ ] = False,
36
+ ) -> None:
37
+ """List apps running on this machine."""
38
+ if scope is not None:
39
+ info("--scope filtering isn't implemented — no cloud directory to filter against yet")
40
+ apps = registry.list_running()
41
+ if not apps:
42
+ stdout.print("[dim]no apps running[/dim]")
43
+ return
44
+ for app in apps:
45
+ line = f"{app.name} [dim]{app.target_kind}[/dim] {app.url}"
46
+ if app.tunnel_url:
47
+ line += f" [cyan]{app.tunnel_url}[/cyan]"
48
+ stdout.print(line)
49
+
50
+
51
+ def status(
52
+ app_name: Annotated[str, typer.Argument(help="App to check.")],
53
+ ) -> None:
54
+ """Show reachability and connection info for a running app, reconciling
55
+ the local registry's record against a live check."""
56
+ app = registry.get(app_name)
57
+ if app is None:
58
+ error(f"no running app named {app_name!r}")
59
+ raise typer.Exit(1)
60
+
61
+ try:
62
+ httpx.get(app.url, timeout=2.0)
63
+ reachable = True
64
+ except httpx.TransportError:
65
+ reachable = False
66
+
67
+ stdout.print(f"name: {app.name}")
68
+ stdout.print(f"target: {app.target} ({app.target_kind})")
69
+ stdout.print(f"pid: {app.pid}")
70
+ stdout.print(f"url: {app.url}")
71
+ if app.tunnel_url:
72
+ stdout.print(f"public: {app.tunnel_url}")
73
+ stdout.print(f"reachable: {'[green]yes[/green]' if reachable else '[red]no[/red]'}")