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.
- sidepage/__init__.py +9 -0
- sidepage/cli.py +143 -0
- sidepage/commands/__init__.py +7 -0
- sidepage/commands/account.py +98 -0
- sidepage/commands/app_registry.py +344 -0
- sidepage/commands/directory.py +73 -0
- sidepage/commands/inspect.py +39 -0
- sidepage/commands/new.py +29 -0
- sidepage/commands/scope.py +26 -0
- sidepage/commands/secrets.py +68 -0
- sidepage/commands/serve.py +260 -0
- sidepage/commands/setup.py +59 -0
- sidepage/commands/usage.py +37 -0
- sidepage/config/__init__.py +4 -0
- sidepage/config/settings.py +162 -0
- sidepage/core/__init__.py +8 -0
- sidepage/core/account.py +163 -0
- sidepage/core/app_registry.py +200 -0
- sidepage/core/auth.py +40 -0
- sidepage/core/cloudflared_installer.py +281 -0
- sidepage/core/directory_client.py +128 -0
- sidepage/core/ecosystem.py +90 -0
- sidepage/core/exceptions.py +140 -0
- sidepage/core/guardrail.py +30 -0
- sidepage/core/inspector.py +239 -0
- sidepage/core/notebook.py +77 -0
- sidepage/core/process.py +464 -0
- sidepage/core/registry.py +171 -0
- sidepage/core/reverse_proxy.py +495 -0
- sidepage/core/scaffold.py +40 -0
- sidepage/core/secrets_vault.py +117 -0
- sidepage/core/static.py +32 -0
- sidepage/core/target.py +235 -0
- sidepage/core/token_runtime.py +89 -0
- sidepage/core/tunnel_manager.py +686 -0
- sidepage/core/usage_reporter.py +70 -0
- sidepage/output.py +49 -0
- sidepage-0.1.0.dist-info/METADATA +306 -0
- sidepage-0.1.0.dist-info/RECORD +42 -0
- sidepage-0.1.0.dist-info/WHEEL +4 -0
- sidepage-0.1.0.dist-info/entry_points.txt +3 -0
- 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]'}")
|