sidepage 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.
- sidepage-0.1.0/LICENSE +21 -0
- sidepage-0.1.0/PKG-INFO +306 -0
- sidepage-0.1.0/README.md +288 -0
- sidepage-0.1.0/pyproject.toml +46 -0
- sidepage-0.1.0/src/sidepage/__init__.py +9 -0
- sidepage-0.1.0/src/sidepage/cli.py +143 -0
- sidepage-0.1.0/src/sidepage/commands/__init__.py +7 -0
- sidepage-0.1.0/src/sidepage/commands/account.py +98 -0
- sidepage-0.1.0/src/sidepage/commands/app_registry.py +344 -0
- sidepage-0.1.0/src/sidepage/commands/directory.py +73 -0
- sidepage-0.1.0/src/sidepage/commands/inspect.py +39 -0
- sidepage-0.1.0/src/sidepage/commands/new.py +29 -0
- sidepage-0.1.0/src/sidepage/commands/scope.py +26 -0
- sidepage-0.1.0/src/sidepage/commands/secrets.py +68 -0
- sidepage-0.1.0/src/sidepage/commands/serve.py +260 -0
- sidepage-0.1.0/src/sidepage/commands/setup.py +59 -0
- sidepage-0.1.0/src/sidepage/commands/usage.py +37 -0
- sidepage-0.1.0/src/sidepage/config/__init__.py +4 -0
- sidepage-0.1.0/src/sidepage/config/settings.py +162 -0
- sidepage-0.1.0/src/sidepage/core/__init__.py +8 -0
- sidepage-0.1.0/src/sidepage/core/account.py +163 -0
- sidepage-0.1.0/src/sidepage/core/app_registry.py +200 -0
- sidepage-0.1.0/src/sidepage/core/auth.py +40 -0
- sidepage-0.1.0/src/sidepage/core/cloudflared_installer.py +281 -0
- sidepage-0.1.0/src/sidepage/core/directory_client.py +128 -0
- sidepage-0.1.0/src/sidepage/core/ecosystem.py +90 -0
- sidepage-0.1.0/src/sidepage/core/exceptions.py +140 -0
- sidepage-0.1.0/src/sidepage/core/guardrail.py +30 -0
- sidepage-0.1.0/src/sidepage/core/inspector.py +239 -0
- sidepage-0.1.0/src/sidepage/core/notebook.py +77 -0
- sidepage-0.1.0/src/sidepage/core/process.py +464 -0
- sidepage-0.1.0/src/sidepage/core/registry.py +171 -0
- sidepage-0.1.0/src/sidepage/core/reverse_proxy.py +495 -0
- sidepage-0.1.0/src/sidepage/core/scaffold.py +40 -0
- sidepage-0.1.0/src/sidepage/core/secrets_vault.py +117 -0
- sidepage-0.1.0/src/sidepage/core/static.py +32 -0
- sidepage-0.1.0/src/sidepage/core/target.py +235 -0
- sidepage-0.1.0/src/sidepage/core/token_runtime.py +89 -0
- sidepage-0.1.0/src/sidepage/core/tunnel_manager.py +686 -0
- sidepage-0.1.0/src/sidepage/core/usage_reporter.py +70 -0
- sidepage-0.1.0/src/sidepage/output.py +49 -0
sidepage-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kalpit B
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
sidepage-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sidepage
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Sidepage CLI — local-first hosting, tunneling, and directory for small apps and MCP servers
|
|
5
|
+
Author: Kalpit B
|
|
6
|
+
Author-email: Kalpit B <kalpit@dolphins.dev>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Dist: cryptography>=50.0.0,<51
|
|
10
|
+
Requires-Dist: httpx>=0.28.1,<0.29
|
|
11
|
+
Requires-Dist: rich>=15.0.0,<16
|
|
12
|
+
Requires-Dist: starlette>=1.3.1,<2
|
|
13
|
+
Requires-Dist: typer>=0.27.1,<0.28
|
|
14
|
+
Requires-Dist: uvicorn>=0.52.1,<0.53
|
|
15
|
+
Requires-Dist: websockets>=17.0.1,<18
|
|
16
|
+
Requires-Python: >=3.12
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# sidepage
|
|
20
|
+
|
|
21
|
+
Local-first hosting and tunneling for code, static sites, and notebooks.
|
|
22
|
+
`sidepage serve` wraps almost anything — a script, a static site, a
|
|
23
|
+
Streamlit or FastAPI app, a Python MCP server, a Jupyter notebook —
|
|
24
|
+
behind a local reverse proxy and hands you a URL. `sidepage new`
|
|
25
|
+
scaffolds a static site to get started.
|
|
26
|
+
|
|
27
|
+
**Status:** `serve`, `secrets`, `inspect`, and bring-your-own-domain
|
|
28
|
+
tunneling are real and tested end to end. Features that need a Sidepage
|
|
29
|
+
cloud backend that doesn't exist yet (brokered tunneling, account login,
|
|
30
|
+
the directory beyond this machine) print a clear "not implemented"
|
|
31
|
+
message instead of failing silently or being left out of the CLI. See
|
|
32
|
+
[Project status](#project-status) for the full breakdown.
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
Requires [uv](https://docs.astral.sh/uv/) (it fetches Python 3.12+ for you
|
|
37
|
+
if needed).
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
uv sync
|
|
41
|
+
uv run sidepage --help
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Or activate the venv and call it directly: `source .venv/bin/activate &&
|
|
45
|
+
sidepage --help`.
|
|
46
|
+
|
|
47
|
+
## Quickstart
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
# Serve a static site
|
|
51
|
+
uv run sidepage serve tests/fixtures/static-site --name demo
|
|
52
|
+
|
|
53
|
+
# Serve a Streamlit app, gated behind a token
|
|
54
|
+
uv run sidepage serve tests/fixtures/streamlit-app/app.py --name demo --auth token
|
|
55
|
+
|
|
56
|
+
# Serve a FastAPI app — /docs (Swagger UI) works automatically
|
|
57
|
+
uv run sidepage serve tests/fixtures/fastapi-app/app.py --name demo
|
|
58
|
+
|
|
59
|
+
# Serve a Python MCP server over real Streamable HTTP — even if its own
|
|
60
|
+
# __main__ only ever calls mcp.run() (stdio), sidepage never runs that
|
|
61
|
+
# entrypoint, so it's reachable at /mcp regardless
|
|
62
|
+
uv run sidepage serve tests/fixtures/mcp-app/app.py --name demo
|
|
63
|
+
|
|
64
|
+
# Serve a Jupyter notebook — a full, editable Lab instance with a live
|
|
65
|
+
# kernel, reachable through the proxy like anything else
|
|
66
|
+
uv run sidepage serve tests/fixtures/notebook-app/notebook.ipynb --name demo
|
|
67
|
+
|
|
68
|
+
# Inject a secret and expose it over a real public tunnel
|
|
69
|
+
uv run sidepage secrets set MY_KEY
|
|
70
|
+
uv run sidepage serve some_app.py --env MY_KEY --anon
|
|
71
|
+
|
|
72
|
+
# Auto-stop after 30 minutes of no traffic, and inject another running
|
|
73
|
+
# app's URL as SIDEPAGE_PEER_API_URL
|
|
74
|
+
uv run sidepage serve frontend.py --idle-timeout 1800 --peer api=backend
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Every `serve` call blocks the terminal until Ctrl+C (or `sidepage stop
|
|
78
|
+
<app-name>` from another terminal), tearing everything down immediately —
|
|
79
|
+
no background/daemon mode.
|
|
80
|
+
|
|
81
|
+
## How it works
|
|
82
|
+
|
|
83
|
+
Two things sit between "just run a script" and what `serve` does:
|
|
84
|
+
|
|
85
|
+
- **A local reverse proxy** runs on your machine in front of the app's
|
|
86
|
+
real port. It enforces `--auth`, counts usage, shows a holding page
|
|
87
|
+
while the app boots, and proxies HTTP + WebSockets — the wrapped app
|
|
88
|
+
itself needs zero Sidepage-specific code.
|
|
89
|
+
- **A tunnel**, chosen per call: `--anon` for a free, no-account
|
|
90
|
+
`*.trycloudflare.com` URL, or `--domain <domain>` for your own
|
|
91
|
+
Cloudflare domain (see [Bring your own domain](#bring-your-own-domain)
|
|
92
|
+
below). Without either flag, `serve` just listens on `127.0.0.1`.
|
|
93
|
+
|
|
94
|
+
## Commands
|
|
95
|
+
|
|
96
|
+
| Command | What it does |
|
|
97
|
+
|---|---|
|
|
98
|
+
| `sidepage serve <target>` | Wrap and host a static dir, script, or app — see flags below. |
|
|
99
|
+
| `sidepage stop <app-name>` | Tear down a running app. |
|
|
100
|
+
| `sidepage ls` / `sidepage status <app-name>` | List / check apps running on this machine. |
|
|
101
|
+
| `sidepage usage <app-name>` | Request and connection counts for an app. |
|
|
102
|
+
| `sidepage inspect [<app-name>]` | Interactive HTTP console against a running app. |
|
|
103
|
+
| `sidepage secrets set\|list\|remove` | Encrypted local vault for standing credentials. |
|
|
104
|
+
| `sidepage account domain set` | Provision a BYO Cloudflare domain — see below. |
|
|
105
|
+
| `sidepage new <name>` | Scaffold a static site. |
|
|
106
|
+
| `sidepage app register "<invocation>" <name>` | Save a `serve` invocation under a short name. |
|
|
107
|
+
| `sidepage app list` / `show <name>` / `unregister <name>` | Manage saved apps — see below. |
|
|
108
|
+
| `sidepage promote <app-name>` | Widen an app's discovery scope. Not yet meaningful — only `local` scope exists today. |
|
|
109
|
+
| `sidepage login` / `sidepage account status` | Not implemented — no Sidepage account backend to talk to yet. |
|
|
110
|
+
|
|
111
|
+
`serve`'s main flags:
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
sidepage serve <target> [--type auto|code|static|notebook] [--name <app-name>]
|
|
115
|
+
[--auth open|token] [--anon | --domain <domain>]
|
|
116
|
+
[--token <value>] [--env <SECRET_NAME>]...
|
|
117
|
+
[--timeout <seconds>] [--idle-timeout <seconds>]
|
|
118
|
+
[--peer <role>=<app-name>]...
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
- `--type` is usually inferred: `code` targets are auto-detected as
|
|
122
|
+
Streamlit, FastAPI, or a Python MCP server (official `mcp` SDK or the
|
|
123
|
+
third-party `fastmcp` package) and launched with their real launcher
|
|
124
|
+
(`streamlit run`, `uvicorn <module>:<app>`, or `uvicorn --factory
|
|
125
|
+
<module>:<mcp-var>.<app-method>`); anything else falls back to a
|
|
126
|
+
generic `$PORT`-reading launch. `notebook` (`.ipynb`) targets get a
|
|
127
|
+
full, editable Jupyter Lab instance with a live kernel.
|
|
128
|
+
MCP servers are launched by bypassing their own entrypoint entirely
|
|
129
|
+
(same trick as FastAPI) — a script whose `__main__` only calls
|
|
130
|
+
`mcp.run()` (stdio, the default) still ends up served over real
|
|
131
|
+
Streamable HTTP at `/mcp`, since that entrypoint is never executed.
|
|
132
|
+
- `--auth open|token` — `token` gates the app behind a header, query
|
|
133
|
+
param, or browser cookie set by a gate page. (`network`/`oauth` parse
|
|
134
|
+
but aren't built.)
|
|
135
|
+
- `--env <SECRET_NAME>` — repeatable; injects a named vault secret into
|
|
136
|
+
the wrapped process's environment. Fails loud if the name isn't stored.
|
|
137
|
+
- `--anon` / `--domain` are mutually exclusive — see [How it
|
|
138
|
+
works](#how-it-works).
|
|
139
|
+
- `--timeout <seconds>` / `--idle-timeout <seconds>` — auto-teardown; see
|
|
140
|
+
[Timeouts, lazy start, and peers](#timeouts-lazy-start-and-peers) below.
|
|
141
|
+
- `--peer <role>=<app-name>` — repeatable; wire one served app to
|
|
142
|
+
another's URL. Same section below.
|
|
143
|
+
|
|
144
|
+
Run `sidepage <command> --help` for the full flag list, including ones
|
|
145
|
+
that parse but aren't implemented yet (they report that clearly rather
|
|
146
|
+
than silently doing nothing).
|
|
147
|
+
|
|
148
|
+
## Timeouts, lazy start, and peers
|
|
149
|
+
|
|
150
|
+
**Auto-teardown.** `--timeout <seconds>` stops the app once its total
|
|
151
|
+
lifetime (from `serve` start) reaches the limit; `--idle-timeout
|
|
152
|
+
<seconds>` stops it once that many seconds pass with no proxied HTTP
|
|
153
|
+
request or WebSocket message — the timer resets on every one. Both are
|
|
154
|
+
composable with each other and checked in the same blocking loop Ctrl+C
|
|
155
|
+
already interrupts, so an auto-stop tears down exactly like `sidepage
|
|
156
|
+
stop` would: immediately, no drain window.
|
|
157
|
+
|
|
158
|
+
```bash
|
|
159
|
+
sidepage serve demo.py --idle-timeout 1800 # stop after 30 idle minutes
|
|
160
|
+
sidepage serve demo.py --timeout 3600 # stop after 1 hour no matter what
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
**Lazy start.** For `code`/`notebook` targets, the wrapped process isn't
|
|
164
|
+
launched at `serve` time — it launches on the *first* inbound request,
|
|
165
|
+
behind the same "starting…" holding page a slow boot already shows. A
|
|
166
|
+
`serve` call that nobody ever hits never spends the CPU/memory to boot
|
|
167
|
+
the wrapped app at all. (`static` targets are already in-process and
|
|
168
|
+
instant, so there's nothing to defer there.) This is automatic — no flag.
|
|
169
|
+
|
|
170
|
+
**Peers.** `--peer <role>=<app-name>` (repeatable) resolves another
|
|
171
|
+
*currently running* served app's URL and injects it as
|
|
172
|
+
`SIDEPAGE_PEER_<ROLE>_URL` in the wrapped process's environment — useful
|
|
173
|
+
for a frontend that needs to reach a backend whose tunnel URL doesn't
|
|
174
|
+
exist until it's actually served, and changes across `--anon` runs.
|
|
175
|
+
Resolution fails loud (nonzero exit, clear message) if the named peer
|
|
176
|
+
isn't running yet. The app can also re-resolve peers live, at any point,
|
|
177
|
+
via `GET /.sidepage/peers.json` — gated by the app's own `--auth` tier
|
|
178
|
+
like any other route — so a peer that restarts mid-session with a fresh
|
|
179
|
+
URL is never stale the way the boot-time env var would be. `code`/
|
|
180
|
+
`notebook` targets only; there's no subprocess to inject into for a
|
|
181
|
+
`static` target, so `--peer` on one is rejected up front.
|
|
182
|
+
|
|
183
|
+
```bash
|
|
184
|
+
sidepage serve backend.py --name backend
|
|
185
|
+
sidepage serve frontend.py --peer api=backend # $SIDEPAGE_PEER_API_URL in frontend's env
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
## Saved apps (the local registry)
|
|
189
|
+
|
|
190
|
+
Save a `serve` invocation under a short name and re-run it without
|
|
191
|
+
retyping flags:
|
|
192
|
+
|
|
193
|
+
```bash
|
|
194
|
+
sidepage app register "abc.py --auth token" abc-app
|
|
195
|
+
sidepage serve abc-app
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
Any flag passed at `serve` time overrides the registered one **for that
|
|
199
|
+
one run only** — the saved registration itself is never changed:
|
|
200
|
+
|
|
201
|
+
```bash
|
|
202
|
+
sidepage serve abc-app --scope web # runs with --auth token (registered)
|
|
203
|
+
# but --scope web for just this run
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
`sidepage app show abc-app` prints the saved config; add `--with "<flags>"`
|
|
207
|
+
to preview the effective merged config before actually running it, e.g.
|
|
208
|
+
`sidepage app show abc-app --with "--scope web"`.
|
|
209
|
+
|
|
210
|
+
A registered app's target is resolved once, at registration time — so
|
|
211
|
+
`--type` is stored as a concrete value (`code`, `static`, `notebook`),
|
|
212
|
+
never "auto." `sidepage app register` **refuses** a literal `--token
|
|
213
|
+
<value>`: auth tokens are per-process and regenerate on every `serve`
|
|
214
|
+
call, so storing one would defeat the point of them being ephemeral.
|
|
215
|
+
`--env <SECRET_NAME>` is fine to save — it's a reference to a vault entry,
|
|
216
|
+
never the secret value itself.
|
|
217
|
+
|
|
218
|
+
```bash
|
|
219
|
+
sidepage app list
|
|
220
|
+
sidepage app unregister abc-app
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
## Bring your own domain
|
|
224
|
+
|
|
225
|
+
Route apps through your own Cloudflare domain instead of
|
|
226
|
+
`*.trycloudflare.com`. One-time setup:
|
|
227
|
+
|
|
228
|
+
1. Create a Cloudflare API token (dashboard → My Profile → API Tokens)
|
|
229
|
+
scoped to:
|
|
230
|
+
- Account → Cloudflare Tunnel → Edit
|
|
231
|
+
- Zone → DNS → Edit
|
|
232
|
+
- Zone → Zone → Read
|
|
233
|
+
2. Store it in the vault, then provision the domain:
|
|
234
|
+
```bash
|
|
235
|
+
sidepage secrets set cf-api-token
|
|
236
|
+
sidepage account domain set example.com --api-token-name cf-api-token
|
|
237
|
+
```
|
|
238
|
+
This creates one Cloudflare Tunnel for the whole domain and stores its
|
|
239
|
+
run-token in the vault automatically — the CLI prints the vault name it
|
|
240
|
+
landed under (`cf-tunnel-token::example.com`), since it was never typed
|
|
241
|
+
by you.
|
|
242
|
+
3. Serve apps through it:
|
|
243
|
+
```bash
|
|
244
|
+
sidepage serve app.py --domain example.com
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
Every app served under the same domain shares that one tunnel — no new
|
|
248
|
+
Cloudflare resources or tokens per app. The shared `cloudflared` process
|
|
249
|
+
starts with the first app on a domain and stops with the last.
|
|
250
|
+
|
|
251
|
+
## Project layout
|
|
252
|
+
|
|
253
|
+
```
|
|
254
|
+
src/sidepage/
|
|
255
|
+
├── cli.py Root Typer app
|
|
256
|
+
├── commands/ Argument parsing & help text — one module per command group
|
|
257
|
+
├── core/ The SDK: serve/tunnel/proxy orchestration, secrets vault, running-app registry, saved-app registry
|
|
258
|
+
└── config/ Local config paths (XDG-style, overridable via SIDEPAGE_HOME)
|
|
259
|
+
|
|
260
|
+
tests/
|
|
261
|
+
├── fixtures/ Real apps used as test targets (static site, Streamlit, FastAPI, MCP, notebook)
|
|
262
|
+
└── test_*.py Unit and integration tests
|
|
263
|
+
|
|
264
|
+
docs/
|
|
265
|
+
├── CHECKLIST.md Build status for every command and core module
|
|
266
|
+
├── OPEN_QUESTIONS.md Design decisions — resolved and still-open
|
|
267
|
+
└── SPEC_V5_DRAFT.md v5 proposals — timeout/lazy-start/--peer (built, this doc) plus still-parked ideas
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
## Development
|
|
271
|
+
|
|
272
|
+
```bash
|
|
273
|
+
uv sync # install runtime + dev deps
|
|
274
|
+
uv run ruff check . # lint
|
|
275
|
+
uv run pytest # full suite (~4 min; mostly first-run dependency resolves)
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
Runtime dependencies are real, not stubs: Starlette, uvicorn, httpx, and
|
|
279
|
+
`websockets` back the reverse proxy; `cryptography` backs the secrets
|
|
280
|
+
vault. `cloudflared` and network access (for `uv run` to resolve wrapped
|
|
281
|
+
apps' dependencies) are expected to be available wherever tests run.
|
|
282
|
+
|
|
283
|
+
## Project status
|
|
284
|
+
|
|
285
|
+
**Real and tested end to end:** `serve` for static, code, and notebook
|
|
286
|
+
targets (Streamlit/FastAPI/Python-MCP auto-detected, generic `$PORT`
|
|
287
|
+
fallback, full Jupyter Lab for `.ipynb`), `open`/`token` auth, `--env`
|
|
288
|
+
secret injection, `--anon` tunneling, BYO-domain tunneling (`account
|
|
289
|
+
domain set` + `serve --domain`), `secrets`, `stop`/`ls`/`status`/`usage`,
|
|
290
|
+
`inspect` for HTTP/static targets, the local app registry (`app
|
|
291
|
+
register|list|show|unregister` + `serve <app-name>`, with real one-off
|
|
292
|
+
override merging), `--timeout`/`--idle-timeout` auto-teardown, lazy start
|
|
293
|
+
for code/notebook targets (subprocess deferred to the first request), and
|
|
294
|
+
`--peer <role>=<app-name>` (boot-time env injection plus a live
|
|
295
|
+
`GET /.sidepage/peers.json`).
|
|
296
|
+
|
|
297
|
+
**Not implemented, and reports that clearly rather than silently
|
|
298
|
+
no-op'ing:** brokered (default) tunneling, `login`/`account status`, the
|
|
299
|
+
discovery directory beyond this machine, `--guardrail`, `--auth
|
|
300
|
+
network`/`oauth`, MCP tool browsing in `inspect`, and the OS-keychain
|
|
301
|
+
backend for the secrets vault (encrypted-file only for
|
|
302
|
+
now).
|
|
303
|
+
|
|
304
|
+
See [`docs/CHECKLIST.md`](docs/CHECKLIST.md) for the full per-feature
|
|
305
|
+
breakdown, and [`docs/OPEN_QUESTIONS.md`](docs/OPEN_QUESTIONS.md) for
|
|
306
|
+
design rationale behind what's resolved and what's still open.
|
sidepage-0.1.0/README.md
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
# sidepage
|
|
2
|
+
|
|
3
|
+
Local-first hosting and tunneling for code, static sites, and notebooks.
|
|
4
|
+
`sidepage serve` wraps almost anything — a script, a static site, a
|
|
5
|
+
Streamlit or FastAPI app, a Python MCP server, a Jupyter notebook —
|
|
6
|
+
behind a local reverse proxy and hands you a URL. `sidepage new`
|
|
7
|
+
scaffolds a static site to get started.
|
|
8
|
+
|
|
9
|
+
**Status:** `serve`, `secrets`, `inspect`, and bring-your-own-domain
|
|
10
|
+
tunneling are real and tested end to end. Features that need a Sidepage
|
|
11
|
+
cloud backend that doesn't exist yet (brokered tunneling, account login,
|
|
12
|
+
the directory beyond this machine) print a clear "not implemented"
|
|
13
|
+
message instead of failing silently or being left out of the CLI. See
|
|
14
|
+
[Project status](#project-status) for the full breakdown.
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
Requires [uv](https://docs.astral.sh/uv/) (it fetches Python 3.12+ for you
|
|
19
|
+
if needed).
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
uv sync
|
|
23
|
+
uv run sidepage --help
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Or activate the venv and call it directly: `source .venv/bin/activate &&
|
|
27
|
+
sidepage --help`.
|
|
28
|
+
|
|
29
|
+
## Quickstart
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
# Serve a static site
|
|
33
|
+
uv run sidepage serve tests/fixtures/static-site --name demo
|
|
34
|
+
|
|
35
|
+
# Serve a Streamlit app, gated behind a token
|
|
36
|
+
uv run sidepage serve tests/fixtures/streamlit-app/app.py --name demo --auth token
|
|
37
|
+
|
|
38
|
+
# Serve a FastAPI app — /docs (Swagger UI) works automatically
|
|
39
|
+
uv run sidepage serve tests/fixtures/fastapi-app/app.py --name demo
|
|
40
|
+
|
|
41
|
+
# Serve a Python MCP server over real Streamable HTTP — even if its own
|
|
42
|
+
# __main__ only ever calls mcp.run() (stdio), sidepage never runs that
|
|
43
|
+
# entrypoint, so it's reachable at /mcp regardless
|
|
44
|
+
uv run sidepage serve tests/fixtures/mcp-app/app.py --name demo
|
|
45
|
+
|
|
46
|
+
# Serve a Jupyter notebook — a full, editable Lab instance with a live
|
|
47
|
+
# kernel, reachable through the proxy like anything else
|
|
48
|
+
uv run sidepage serve tests/fixtures/notebook-app/notebook.ipynb --name demo
|
|
49
|
+
|
|
50
|
+
# Inject a secret and expose it over a real public tunnel
|
|
51
|
+
uv run sidepage secrets set MY_KEY
|
|
52
|
+
uv run sidepage serve some_app.py --env MY_KEY --anon
|
|
53
|
+
|
|
54
|
+
# Auto-stop after 30 minutes of no traffic, and inject another running
|
|
55
|
+
# app's URL as SIDEPAGE_PEER_API_URL
|
|
56
|
+
uv run sidepage serve frontend.py --idle-timeout 1800 --peer api=backend
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Every `serve` call blocks the terminal until Ctrl+C (or `sidepage stop
|
|
60
|
+
<app-name>` from another terminal), tearing everything down immediately —
|
|
61
|
+
no background/daemon mode.
|
|
62
|
+
|
|
63
|
+
## How it works
|
|
64
|
+
|
|
65
|
+
Two things sit between "just run a script" and what `serve` does:
|
|
66
|
+
|
|
67
|
+
- **A local reverse proxy** runs on your machine in front of the app's
|
|
68
|
+
real port. It enforces `--auth`, counts usage, shows a holding page
|
|
69
|
+
while the app boots, and proxies HTTP + WebSockets — the wrapped app
|
|
70
|
+
itself needs zero Sidepage-specific code.
|
|
71
|
+
- **A tunnel**, chosen per call: `--anon` for a free, no-account
|
|
72
|
+
`*.trycloudflare.com` URL, or `--domain <domain>` for your own
|
|
73
|
+
Cloudflare domain (see [Bring your own domain](#bring-your-own-domain)
|
|
74
|
+
below). Without either flag, `serve` just listens on `127.0.0.1`.
|
|
75
|
+
|
|
76
|
+
## Commands
|
|
77
|
+
|
|
78
|
+
| Command | What it does |
|
|
79
|
+
|---|---|
|
|
80
|
+
| `sidepage serve <target>` | Wrap and host a static dir, script, or app — see flags below. |
|
|
81
|
+
| `sidepage stop <app-name>` | Tear down a running app. |
|
|
82
|
+
| `sidepage ls` / `sidepage status <app-name>` | List / check apps running on this machine. |
|
|
83
|
+
| `sidepage usage <app-name>` | Request and connection counts for an app. |
|
|
84
|
+
| `sidepage inspect [<app-name>]` | Interactive HTTP console against a running app. |
|
|
85
|
+
| `sidepage secrets set\|list\|remove` | Encrypted local vault for standing credentials. |
|
|
86
|
+
| `sidepage account domain set` | Provision a BYO Cloudflare domain — see below. |
|
|
87
|
+
| `sidepage new <name>` | Scaffold a static site. |
|
|
88
|
+
| `sidepage app register "<invocation>" <name>` | Save a `serve` invocation under a short name. |
|
|
89
|
+
| `sidepage app list` / `show <name>` / `unregister <name>` | Manage saved apps — see below. |
|
|
90
|
+
| `sidepage promote <app-name>` | Widen an app's discovery scope. Not yet meaningful — only `local` scope exists today. |
|
|
91
|
+
| `sidepage login` / `sidepage account status` | Not implemented — no Sidepage account backend to talk to yet. |
|
|
92
|
+
|
|
93
|
+
`serve`'s main flags:
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
sidepage serve <target> [--type auto|code|static|notebook] [--name <app-name>]
|
|
97
|
+
[--auth open|token] [--anon | --domain <domain>]
|
|
98
|
+
[--token <value>] [--env <SECRET_NAME>]...
|
|
99
|
+
[--timeout <seconds>] [--idle-timeout <seconds>]
|
|
100
|
+
[--peer <role>=<app-name>]...
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
- `--type` is usually inferred: `code` targets are auto-detected as
|
|
104
|
+
Streamlit, FastAPI, or a Python MCP server (official `mcp` SDK or the
|
|
105
|
+
third-party `fastmcp` package) and launched with their real launcher
|
|
106
|
+
(`streamlit run`, `uvicorn <module>:<app>`, or `uvicorn --factory
|
|
107
|
+
<module>:<mcp-var>.<app-method>`); anything else falls back to a
|
|
108
|
+
generic `$PORT`-reading launch. `notebook` (`.ipynb`) targets get a
|
|
109
|
+
full, editable Jupyter Lab instance with a live kernel.
|
|
110
|
+
MCP servers are launched by bypassing their own entrypoint entirely
|
|
111
|
+
(same trick as FastAPI) — a script whose `__main__` only calls
|
|
112
|
+
`mcp.run()` (stdio, the default) still ends up served over real
|
|
113
|
+
Streamable HTTP at `/mcp`, since that entrypoint is never executed.
|
|
114
|
+
- `--auth open|token` — `token` gates the app behind a header, query
|
|
115
|
+
param, or browser cookie set by a gate page. (`network`/`oauth` parse
|
|
116
|
+
but aren't built.)
|
|
117
|
+
- `--env <SECRET_NAME>` — repeatable; injects a named vault secret into
|
|
118
|
+
the wrapped process's environment. Fails loud if the name isn't stored.
|
|
119
|
+
- `--anon` / `--domain` are mutually exclusive — see [How it
|
|
120
|
+
works](#how-it-works).
|
|
121
|
+
- `--timeout <seconds>` / `--idle-timeout <seconds>` — auto-teardown; see
|
|
122
|
+
[Timeouts, lazy start, and peers](#timeouts-lazy-start-and-peers) below.
|
|
123
|
+
- `--peer <role>=<app-name>` — repeatable; wire one served app to
|
|
124
|
+
another's URL. Same section below.
|
|
125
|
+
|
|
126
|
+
Run `sidepage <command> --help` for the full flag list, including ones
|
|
127
|
+
that parse but aren't implemented yet (they report that clearly rather
|
|
128
|
+
than silently doing nothing).
|
|
129
|
+
|
|
130
|
+
## Timeouts, lazy start, and peers
|
|
131
|
+
|
|
132
|
+
**Auto-teardown.** `--timeout <seconds>` stops the app once its total
|
|
133
|
+
lifetime (from `serve` start) reaches the limit; `--idle-timeout
|
|
134
|
+
<seconds>` stops it once that many seconds pass with no proxied HTTP
|
|
135
|
+
request or WebSocket message — the timer resets on every one. Both are
|
|
136
|
+
composable with each other and checked in the same blocking loop Ctrl+C
|
|
137
|
+
already interrupts, so an auto-stop tears down exactly like `sidepage
|
|
138
|
+
stop` would: immediately, no drain window.
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
sidepage serve demo.py --idle-timeout 1800 # stop after 30 idle minutes
|
|
142
|
+
sidepage serve demo.py --timeout 3600 # stop after 1 hour no matter what
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
**Lazy start.** For `code`/`notebook` targets, the wrapped process isn't
|
|
146
|
+
launched at `serve` time — it launches on the *first* inbound request,
|
|
147
|
+
behind the same "starting…" holding page a slow boot already shows. A
|
|
148
|
+
`serve` call that nobody ever hits never spends the CPU/memory to boot
|
|
149
|
+
the wrapped app at all. (`static` targets are already in-process and
|
|
150
|
+
instant, so there's nothing to defer there.) This is automatic — no flag.
|
|
151
|
+
|
|
152
|
+
**Peers.** `--peer <role>=<app-name>` (repeatable) resolves another
|
|
153
|
+
*currently running* served app's URL and injects it as
|
|
154
|
+
`SIDEPAGE_PEER_<ROLE>_URL` in the wrapped process's environment — useful
|
|
155
|
+
for a frontend that needs to reach a backend whose tunnel URL doesn't
|
|
156
|
+
exist until it's actually served, and changes across `--anon` runs.
|
|
157
|
+
Resolution fails loud (nonzero exit, clear message) if the named peer
|
|
158
|
+
isn't running yet. The app can also re-resolve peers live, at any point,
|
|
159
|
+
via `GET /.sidepage/peers.json` — gated by the app's own `--auth` tier
|
|
160
|
+
like any other route — so a peer that restarts mid-session with a fresh
|
|
161
|
+
URL is never stale the way the boot-time env var would be. `code`/
|
|
162
|
+
`notebook` targets only; there's no subprocess to inject into for a
|
|
163
|
+
`static` target, so `--peer` on one is rejected up front.
|
|
164
|
+
|
|
165
|
+
```bash
|
|
166
|
+
sidepage serve backend.py --name backend
|
|
167
|
+
sidepage serve frontend.py --peer api=backend # $SIDEPAGE_PEER_API_URL in frontend's env
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
## Saved apps (the local registry)
|
|
171
|
+
|
|
172
|
+
Save a `serve` invocation under a short name and re-run it without
|
|
173
|
+
retyping flags:
|
|
174
|
+
|
|
175
|
+
```bash
|
|
176
|
+
sidepage app register "abc.py --auth token" abc-app
|
|
177
|
+
sidepage serve abc-app
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Any flag passed at `serve` time overrides the registered one **for that
|
|
181
|
+
one run only** — the saved registration itself is never changed:
|
|
182
|
+
|
|
183
|
+
```bash
|
|
184
|
+
sidepage serve abc-app --scope web # runs with --auth token (registered)
|
|
185
|
+
# but --scope web for just this run
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
`sidepage app show abc-app` prints the saved config; add `--with "<flags>"`
|
|
189
|
+
to preview the effective merged config before actually running it, e.g.
|
|
190
|
+
`sidepage app show abc-app --with "--scope web"`.
|
|
191
|
+
|
|
192
|
+
A registered app's target is resolved once, at registration time — so
|
|
193
|
+
`--type` is stored as a concrete value (`code`, `static`, `notebook`),
|
|
194
|
+
never "auto." `sidepage app register` **refuses** a literal `--token
|
|
195
|
+
<value>`: auth tokens are per-process and regenerate on every `serve`
|
|
196
|
+
call, so storing one would defeat the point of them being ephemeral.
|
|
197
|
+
`--env <SECRET_NAME>` is fine to save — it's a reference to a vault entry,
|
|
198
|
+
never the secret value itself.
|
|
199
|
+
|
|
200
|
+
```bash
|
|
201
|
+
sidepage app list
|
|
202
|
+
sidepage app unregister abc-app
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
## Bring your own domain
|
|
206
|
+
|
|
207
|
+
Route apps through your own Cloudflare domain instead of
|
|
208
|
+
`*.trycloudflare.com`. One-time setup:
|
|
209
|
+
|
|
210
|
+
1. Create a Cloudflare API token (dashboard → My Profile → API Tokens)
|
|
211
|
+
scoped to:
|
|
212
|
+
- Account → Cloudflare Tunnel → Edit
|
|
213
|
+
- Zone → DNS → Edit
|
|
214
|
+
- Zone → Zone → Read
|
|
215
|
+
2. Store it in the vault, then provision the domain:
|
|
216
|
+
```bash
|
|
217
|
+
sidepage secrets set cf-api-token
|
|
218
|
+
sidepage account domain set example.com --api-token-name cf-api-token
|
|
219
|
+
```
|
|
220
|
+
This creates one Cloudflare Tunnel for the whole domain and stores its
|
|
221
|
+
run-token in the vault automatically — the CLI prints the vault name it
|
|
222
|
+
landed under (`cf-tunnel-token::example.com`), since it was never typed
|
|
223
|
+
by you.
|
|
224
|
+
3. Serve apps through it:
|
|
225
|
+
```bash
|
|
226
|
+
sidepage serve app.py --domain example.com
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
Every app served under the same domain shares that one tunnel — no new
|
|
230
|
+
Cloudflare resources or tokens per app. The shared `cloudflared` process
|
|
231
|
+
starts with the first app on a domain and stops with the last.
|
|
232
|
+
|
|
233
|
+
## Project layout
|
|
234
|
+
|
|
235
|
+
```
|
|
236
|
+
src/sidepage/
|
|
237
|
+
├── cli.py Root Typer app
|
|
238
|
+
├── commands/ Argument parsing & help text — one module per command group
|
|
239
|
+
├── core/ The SDK: serve/tunnel/proxy orchestration, secrets vault, running-app registry, saved-app registry
|
|
240
|
+
└── config/ Local config paths (XDG-style, overridable via SIDEPAGE_HOME)
|
|
241
|
+
|
|
242
|
+
tests/
|
|
243
|
+
├── fixtures/ Real apps used as test targets (static site, Streamlit, FastAPI, MCP, notebook)
|
|
244
|
+
└── test_*.py Unit and integration tests
|
|
245
|
+
|
|
246
|
+
docs/
|
|
247
|
+
├── CHECKLIST.md Build status for every command and core module
|
|
248
|
+
├── OPEN_QUESTIONS.md Design decisions — resolved and still-open
|
|
249
|
+
└── SPEC_V5_DRAFT.md v5 proposals — timeout/lazy-start/--peer (built, this doc) plus still-parked ideas
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
## Development
|
|
253
|
+
|
|
254
|
+
```bash
|
|
255
|
+
uv sync # install runtime + dev deps
|
|
256
|
+
uv run ruff check . # lint
|
|
257
|
+
uv run pytest # full suite (~4 min; mostly first-run dependency resolves)
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
Runtime dependencies are real, not stubs: Starlette, uvicorn, httpx, and
|
|
261
|
+
`websockets` back the reverse proxy; `cryptography` backs the secrets
|
|
262
|
+
vault. `cloudflared` and network access (for `uv run` to resolve wrapped
|
|
263
|
+
apps' dependencies) are expected to be available wherever tests run.
|
|
264
|
+
|
|
265
|
+
## Project status
|
|
266
|
+
|
|
267
|
+
**Real and tested end to end:** `serve` for static, code, and notebook
|
|
268
|
+
targets (Streamlit/FastAPI/Python-MCP auto-detected, generic `$PORT`
|
|
269
|
+
fallback, full Jupyter Lab for `.ipynb`), `open`/`token` auth, `--env`
|
|
270
|
+
secret injection, `--anon` tunneling, BYO-domain tunneling (`account
|
|
271
|
+
domain set` + `serve --domain`), `secrets`, `stop`/`ls`/`status`/`usage`,
|
|
272
|
+
`inspect` for HTTP/static targets, the local app registry (`app
|
|
273
|
+
register|list|show|unregister` + `serve <app-name>`, with real one-off
|
|
274
|
+
override merging), `--timeout`/`--idle-timeout` auto-teardown, lazy start
|
|
275
|
+
for code/notebook targets (subprocess deferred to the first request), and
|
|
276
|
+
`--peer <role>=<app-name>` (boot-time env injection plus a live
|
|
277
|
+
`GET /.sidepage/peers.json`).
|
|
278
|
+
|
|
279
|
+
**Not implemented, and reports that clearly rather than silently
|
|
280
|
+
no-op'ing:** brokered (default) tunneling, `login`/`account status`, the
|
|
281
|
+
discovery directory beyond this machine, `--guardrail`, `--auth
|
|
282
|
+
network`/`oauth`, MCP tool browsing in `inspect`, and the OS-keychain
|
|
283
|
+
backend for the secrets vault (encrypted-file only for
|
|
284
|
+
now).
|
|
285
|
+
|
|
286
|
+
See [`docs/CHECKLIST.md`](docs/CHECKLIST.md) for the full per-feature
|
|
287
|
+
breakdown, and [`docs/OPEN_QUESTIONS.md`](docs/OPEN_QUESTIONS.md) for
|
|
288
|
+
design rationale behind what's resolved and what's still open.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "sidepage"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Sidepage CLI — local-first hosting, tunneling, and directory for small apps and MCP servers"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [{ name = "Kalpit B", email = "kalpit@dolphins.dev" }]
|
|
7
|
+
license = "MIT"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
requires-python = ">=3.12"
|
|
10
|
+
# Floors are versions actually tested against; ceilings block the next
|
|
11
|
+
# major (or, for pre-1.0 packages, the next minor — semver treats those as
|
|
12
|
+
# potentially breaking) so an untested future release can't silently pull
|
|
13
|
+
# in behavior changes on `uv sync`/`pip install`.
|
|
14
|
+
dependencies = [
|
|
15
|
+
"cryptography>=50.0.0,<51",
|
|
16
|
+
"httpx>=0.28.1,<0.29",
|
|
17
|
+
"rich>=15.0.0,<16",
|
|
18
|
+
"starlette>=1.3.1,<2",
|
|
19
|
+
"typer>=0.27.1,<0.28",
|
|
20
|
+
"uvicorn>=0.52.1,<0.53",
|
|
21
|
+
"websockets>=17.0.1,<18",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[project.scripts]
|
|
25
|
+
sidepage = "sidepage.cli:app"
|
|
26
|
+
|
|
27
|
+
[build-system]
|
|
28
|
+
requires = ["uv_build>=0.9.21,<0.10.0"]
|
|
29
|
+
build-backend = "uv_build"
|
|
30
|
+
|
|
31
|
+
[dependency-groups]
|
|
32
|
+
dev = [
|
|
33
|
+
"pytest>=8,<10",
|
|
34
|
+
"ruff>=0.6,<1",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
[tool.ruff]
|
|
38
|
+
line-length = 100
|
|
39
|
+
src = ["src", "tests"]
|
|
40
|
+
extend-exclude = ["tests/fixtures"]
|
|
41
|
+
|
|
42
|
+
[tool.ruff.lint]
|
|
43
|
+
select = ["E", "F", "I", "UP", "B"]
|
|
44
|
+
|
|
45
|
+
[tool.pytest.ini_options]
|
|
46
|
+
testpaths = ["tests"]
|
|
@@ -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"
|