shelldeck 0.0.1__tar.gz → 0.0.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. shelldeck-0.0.3/CHANGELOG.md +53 -0
  2. {shelldeck-0.0.1 → shelldeck-0.0.3}/PKG-INFO +18 -3
  3. {shelldeck-0.0.1 → shelldeck-0.0.3}/README.md +16 -2
  4. {shelldeck-0.0.1 → shelldeck-0.0.3}/pyproject.toml +2 -1
  5. {shelldeck-0.0.1 → shelldeck-0.0.3}/shelldeck/auth.py +11 -2
  6. {shelldeck-0.0.1 → shelldeck-0.0.3}/shelldeck/cli.py +46 -0
  7. {shelldeck-0.0.1 → shelldeck-0.0.3}/shelldeck/db.py +23 -4
  8. {shelldeck-0.0.1 → shelldeck-0.0.3}/shelldeck/server.py +151 -15
  9. {shelldeck-0.0.1 → shelldeck-0.0.3}/shelldeck/static/app.css +114 -0
  10. {shelldeck-0.0.1 → shelldeck-0.0.3}/shelldeck/static/app.js +50 -7
  11. shelldeck-0.0.3/shelldeck/static/devices.js +76 -0
  12. {shelldeck-0.0.1 → shelldeck-0.0.3}/shelldeck/static/index.html +8 -1
  13. {shelldeck-0.0.1 → shelldeck-0.0.3}/shelldeck/static/ui.js +2 -0
  14. {shelldeck-0.0.1 → shelldeck-0.0.3}/shelldeck/static/views.js +2 -1
  15. {shelldeck-0.0.1 → shelldeck-0.0.3}/tests/test_cli.py +24 -0
  16. {shelldeck-0.0.1 → shelldeck-0.0.3}/tests/test_shelldeck.py +75 -0
  17. shelldeck-0.0.1/CHANGELOG.md +0 -21
  18. {shelldeck-0.0.1 → shelldeck-0.0.3}/.gitignore +0 -0
  19. {shelldeck-0.0.1 → shelldeck-0.0.3}/LICENSE +0 -0
  20. {shelldeck-0.0.1 → shelldeck-0.0.3}/shelldeck/__init__.py +0 -0
  21. {shelldeck-0.0.1 → shelldeck-0.0.3}/shelldeck/__main__.py +0 -0
  22. {shelldeck-0.0.1 → shelldeck-0.0.3}/shelldeck/addons/__init__.py +0 -0
  23. {shelldeck-0.0.1 → shelldeck-0.0.3}/shelldeck/addons/fast_context.py +0 -0
  24. {shelldeck-0.0.1 → shelldeck-0.0.3}/shelldeck/gitgraph.py +0 -0
  25. {shelldeck-0.0.1 → shelldeck-0.0.3}/shelldeck/integration/bash.sh +0 -0
  26. {shelldeck-0.0.1 → shelldeck-0.0.3}/shelldeck/integration/shelldeck.fish +0 -0
  27. {shelldeck-0.0.1 → shelldeck-0.0.3}/shelldeck/integration/shelldeck.ps1 +0 -0
  28. {shelldeck-0.0.1 → shelldeck-0.0.3}/shelldeck/integration/zsh/.zshenv +0 -0
  29. {shelldeck-0.0.1 → shelldeck-0.0.3}/shelldeck/integration/zsh/.zshrc +0 -0
  30. {shelldeck-0.0.1 → shelldeck-0.0.3}/shelldeck/pty.py +0 -0
  31. {shelldeck-0.0.1 → shelldeck-0.0.3}/shelldeck/runner.py +0 -0
  32. {shelldeck-0.0.1 → shelldeck-0.0.3}/shelldeck/scheduler.py +0 -0
  33. {shelldeck-0.0.1 → shelldeck-0.0.3}/shelldeck/shells.py +0 -0
  34. {shelldeck-0.0.1 → shelldeck-0.0.3}/shelldeck/static/gitgraph.js +0 -0
  35. {shelldeck-0.0.1 → shelldeck-0.0.3}/shelldeck/static/history.js +0 -0
  36. {shelldeck-0.0.1 → shelldeck-0.0.3}/shelldeck/static/icon-192.png +0 -0
  37. {shelldeck-0.0.1 → shelldeck-0.0.3}/shelldeck/static/icon-32.png +0 -0
  38. {shelldeck-0.0.1 → shelldeck-0.0.3}/shelldeck/static/icon-512.png +0 -0
  39. {shelldeck-0.0.1 → shelldeck-0.0.3}/shelldeck/static/icon.svg +0 -0
  40. {shelldeck-0.0.1 → shelldeck-0.0.3}/shelldeck/static/manifest.webmanifest +0 -0
  41. {shelldeck-0.0.1 → shelldeck-0.0.3}/shelldeck/static/monitor.js +0 -0
  42. {shelldeck-0.0.1 → shelldeck-0.0.3}/shelldeck/static/vendor/LICENSE-xterm.txt +0 -0
  43. {shelldeck-0.0.1 → shelldeck-0.0.3}/shelldeck/static/vendor/addon-fit.js +0 -0
  44. {shelldeck-0.0.1 → shelldeck-0.0.3}/shelldeck/static/vendor/addon-search.js +0 -0
  45. {shelldeck-0.0.1 → shelldeck-0.0.3}/shelldeck/static/vendor/addon-serialize.js +0 -0
  46. {shelldeck-0.0.1 → shelldeck-0.0.3}/shelldeck/static/vendor/addon-web-links.js +0 -0
  47. {shelldeck-0.0.1 → shelldeck-0.0.3}/shelldeck/static/vendor/addon-webgl.js +0 -0
  48. {shelldeck-0.0.1 → shelldeck-0.0.3}/shelldeck/static/vendor/xterm.css +0 -0
  49. {shelldeck-0.0.1 → shelldeck-0.0.3}/shelldeck/static/vendor/xterm.js +0 -0
  50. {shelldeck-0.0.1 → shelldeck-0.0.3}/shelldeck/stats.py +0 -0
  51. {shelldeck-0.0.1 → shelldeck-0.0.3}/tests/test_gitgraph.py +0 -0
@@ -0,0 +1,53 @@
1
+ # Changelog
2
+
3
+ All notable changes to shelldeck are listed here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow [Semantic Versioning](https://semver.org/).
4
+
5
+ ## [0.0.3] - 2026-09-29
6
+
7
+ ### Added
8
+
9
+ - Devices page: every signed-in browser with where it came from (this machine, network, or a share), when it signed in, last activity, and whether it is in use; revoke one or sign out all others.
10
+ - One device at a time: other browsers stay signed in but idle; logging in on one takes over and shows the others an "In use on another device" screen.
11
+
12
+ ### Changed
13
+
14
+ - Logging in again in the same browser replaces its old login instead of adding one.
15
+ - Share logins are stored with their share and removed when it stops, is replaced, or the server restarts.
16
+
17
+ ### Fixed
18
+
19
+ - Re-running the one-line installer now upgrades an existing install to the latest release.
20
+
21
+ ## [0.0.2] - 2026-09-29
22
+
23
+ ### Added
24
+
25
+ - `sd share`: reach shelldeck from another device over an HTTPS Cloudflare quick tunnel (needs `cloudflared`, no account). It prints a QR code and a one-per-share link; the tunnel address is refused without that link, and your password is still required. Ctrl+C stops sharing and signs those browsers out.
26
+ - Phone key bar on touch screens: Esc, Tab, a sticky Ctrl, arrows and `| ~ / -`.
27
+
28
+ ### Changed
29
+
30
+ - Touch devices use xterm's DOM renderer (WebGL came up blank on high-DPI phones).
31
+ - History rows and the Settings dialog stack into one column on narrow screens.
32
+ - Requests through a proxy that sets `CF-Connecting-IP` count as remote.
33
+ - CI actions updated: checkout v7, setup-uv v7, upload-artifact v7, download-artifact v8.
34
+
35
+ ## [0.0.1] - 2026-09-28
36
+
37
+ First public release.
38
+
39
+ ### Added
40
+
41
+ - Projects sidebar with real shells per project: pwsh, PowerShell, cmd, Git Bash and WSL on Windows; every shell in `/etc/shells` on Linux.
42
+ - Tiled split layout and a free-floating window layout, both persisted.
43
+ - Shell integration (prompts, exit codes, current folder) for pwsh, PowerShell, cmd, bash, zsh and fish.
44
+ - Session restore across restarts, reconnect with scrollback replay, and search across every terminal's output.
45
+ - Git graph per project with branch and commit checkout.
46
+ - Command history, bookmarks, cron scheduler, task board with reminders, and a task manager with CPU, RAM and GPU usage.
47
+ - Clickable file paths, listening-port chips, finished-command alerts and a command palette.
48
+ - Mandatory password with per-browser logins, idle lock, and HTTPS or SSH-tunnel remote access.
49
+ - `sd` CLI with a startup banner, `sd search` (LLM-free code search), and one-line installers for Windows and Linux.
50
+
51
+ [0.0.3]: https://github.com/codejunction/shelldeck/releases/tag/v0.0.3
52
+ [0.0.2]: https://github.com/codejunction/shelldeck/releases/tag/v0.0.2
53
+ [0.0.1]: https://github.com/codejunction/shelldeck/releases/tag/v0.0.1
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: shelldeck
3
- Version: 0.0.1
3
+ Version: 0.0.3
4
4
  Summary: Project-first multi-terminal for Windows and Linux: real shells, grouped by project, in your browser
5
5
  Project-URL: Homepage, https://github.com/codejunction/shelldeck
6
6
  Project-URL: Source, https://github.com/codejunction/shelldeck
@@ -28,6 +28,7 @@ Requires-Dist: fastapi>=0.110
28
28
  Requires-Dist: psutil>=5.9
29
29
  Requires-Dist: pywinpty>=2.0; sys_platform == 'win32'
30
30
  Requires-Dist: rich>=13
31
+ Requires-Dist: segno>=1.6
31
32
  Requires-Dist: typer>=0.12
32
33
  Requires-Dist: uvicorn[standard]>=0.29
33
34
  Description-Content-Type: text/markdown
@@ -43,7 +44,7 @@ Description-Content-Type: text/markdown
43
44
  Real shells (PowerShell, cmd, Git Bash, WSL, bash, zsh, fish) grouped by project,<br>
44
45
  in split or free-floating panes that survive restarts.
45
46
 
46
- [![PyPI](https://img.shields.io/pypi/v/shelldeck?color=8b5cf6&label=pypi)](https://pypi.org/project/shelldeck/)
47
+ [![PyPI](https://img.shields.io/pypi/v/shelldeck?color=8b5cf6&cacheSeconds=3600)](https://pypi.org/project/shelldeck/)
47
48
  [![Python](https://img.shields.io/pypi/pyversions/shelldeck?color=8b5cf6)](https://pypi.org/project/shelldeck/)
48
49
  [![CI](https://github.com/codejunction/shelldeck/actions/workflows/ci.yml/badge.svg)](https://github.com/codejunction/shelldeck/actions/workflows/ci.yml)
49
50
  [![Platforms](https://img.shields.io/badge/platform-Windows%20%7C%20Linux-8b5cf6)](#install)
@@ -69,7 +70,7 @@ irm https://raw.githubusercontent.com/codejunction/shelldeck/main/install.ps1 |
69
70
  curl -fsSL https://raw.githubusercontent.com/codejunction/shelldeck/main/install.sh | sh
70
71
  ```
71
72
 
72
- The installer sets up [uv](https://docs.astral.sh/uv/) if you don't have it, then installs shelldeck with its own Python 3.12+. Already use Python tooling? Any of these work too:
73
+ The installer sets up [uv](https://docs.astral.sh/uv/) if you don't have it, then installs shelldeck with its own Python 3.12+. Run it again to update to the latest release (or `uv tool upgrade shelldeck`); stop the server first with `sd stop` so Windows can replace `sd.exe`. Already use Python tooling? Any of these work too:
73
74
 
74
75
  ```sh
75
76
  uv tool install shelldeck # or: pipx install shelldeck, or: pip install shelldeck
@@ -129,6 +130,7 @@ sd ~/code/api # add a folder as a project and open a terminal in it
129
130
  ### Secure by default
130
131
 
131
132
  - **Password required.** Every browser logs in separately and locks after 30 minutes idle.
133
+ - **One device at a time.** Any number of browsers can stay signed in, but only one uses shelldeck; logging in on another takes over and idles the rest. The **Devices** page lists every signed-in browser (where from, last active, in use or idle) and revokes any of them.
132
134
  - **Local only.** shelldeck listens on `127.0.0.1` and refuses requests from other websites.
133
135
  - **Remote use** goes over an SSH tunnel or HTTPS. See [Remote access](#remote-access).
134
136
 
@@ -194,6 +196,7 @@ sd schedule list|add|run|toggle|delete|logs
194
196
  sd task list|add|move|delete|alarms
195
197
  sd serve [--host H] run the server in the foreground
196
198
  sd stop stop the background server (closes all terminals)
199
+ sd share share over an HTTPS Cloudflare tunnel with a QR link (needs cloudflared)
197
200
  sd login-link emergency: one-time login URL (host only)
198
201
  sd reset-password emergency: forget the password (host only)
199
202
  ```
@@ -215,6 +218,17 @@ sd serve # on the VM: stays on 127.0.0.1:5455
215
218
  ssh -N -L 5455:127.0.0.1:5455 you@vm # on your machine, then open http://127.0.0.1:5455
216
219
  ```
217
220
 
221
+ **`sd share` (any device, no setup).** Needs [`cloudflared`](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/) (`winget install Cloudflare.cloudflared`); no Cloudflare account.
222
+
223
+ ```sh
224
+ sd share # starts shelldeck if needed, prints a QR code and a link; Ctrl+C stops sharing
225
+ ```
226
+
227
+ - **Encrypted.** The other device talks HTTPS to Cloudflare, which relays it through cloudflared's outbound encrypted tunnel to shelldeck on `127.0.0.1`. No ports are opened.
228
+ - **Two locks.** The random `trycloudflare.com` address alone is refused; only the printed link (a one-per-share token made on the host) lets a browser in, and then it still needs your password. A password must exist before sharing.
229
+ - **Phones.** On touch screens a key bar adds Esc, Tab, Ctrl (applies to the next letter), arrows and `| ~ / -`; dialogs open as bottom sheets.
230
+ - **Stopping.** Ctrl+C closes the tunnel, voids the link and signs out every browser that logged in through it (they also vanish from **Devices**; a server restart clears them too). Each `sd share` gets a new address and link.
231
+
218
232
  **HTTPS on the VM's address,** with a real certificate (Tailscale `tailscale cert`, Let's Encrypt, your reverse proxy) or a self-signed one:
219
233
 
220
234
  ```sh
@@ -229,6 +243,7 @@ sd serve --host 0.0.0.0 --cert cert.pem --key key.pem
229
243
 
230
244
  - **Local only.** The server binds to `127.0.0.1` and rejects HTTP and WebSocket requests whose `Host` or `Origin` isn't the app itself, so other websites can't reach your shells.
231
245
  - **Passwords** are stored as PBKDF2-SHA256 (600k iterations). A login is a random token in an `HttpOnly`, `SameSite=Strict` cookie, and only its SHA-256 is stored. After 5 wrong passwords, each further try waits longer.
246
+ - **Devices.** Each login records where it came from (this machine, the network, or a share). Only one login is in use at a time; the others get `423` until they enter the password again. Revoke any login from the Devices page.
232
247
  - **The CLI** authenticates with a token file in the data folder, readable only by your account and rotated on every server start.
233
248
  - **Forgot the password?** On the host machine, `sd login-link` prints a one-time login URL valid for 5 minutes, and `sd reset-password` removes the password. There is deliberately no way to do either from the browser.
234
249
  - **Your data** lives in `~/.config/shelldeck/` (database, log and saved terminal history). Set `SHELLDECK_HOME` to move it.
@@ -9,7 +9,7 @@
9
9
  Real shells (PowerShell, cmd, Git Bash, WSL, bash, zsh, fish) grouped by project,<br>
10
10
  in split or free-floating panes that survive restarts.
11
11
 
12
- [![PyPI](https://img.shields.io/pypi/v/shelldeck?color=8b5cf6&label=pypi)](https://pypi.org/project/shelldeck/)
12
+ [![PyPI](https://img.shields.io/pypi/v/shelldeck?color=8b5cf6&cacheSeconds=3600)](https://pypi.org/project/shelldeck/)
13
13
  [![Python](https://img.shields.io/pypi/pyversions/shelldeck?color=8b5cf6)](https://pypi.org/project/shelldeck/)
14
14
  [![CI](https://github.com/codejunction/shelldeck/actions/workflows/ci.yml/badge.svg)](https://github.com/codejunction/shelldeck/actions/workflows/ci.yml)
15
15
  [![Platforms](https://img.shields.io/badge/platform-Windows%20%7C%20Linux-8b5cf6)](#install)
@@ -35,7 +35,7 @@ irm https://raw.githubusercontent.com/codejunction/shelldeck/main/install.ps1 |
35
35
  curl -fsSL https://raw.githubusercontent.com/codejunction/shelldeck/main/install.sh | sh
36
36
  ```
37
37
 
38
- The installer sets up [uv](https://docs.astral.sh/uv/) if you don't have it, then installs shelldeck with its own Python 3.12+. Already use Python tooling? Any of these work too:
38
+ The installer sets up [uv](https://docs.astral.sh/uv/) if you don't have it, then installs shelldeck with its own Python 3.12+. Run it again to update to the latest release (or `uv tool upgrade shelldeck`); stop the server first with `sd stop` so Windows can replace `sd.exe`. Already use Python tooling? Any of these work too:
39
39
 
40
40
  ```sh
41
41
  uv tool install shelldeck # or: pipx install shelldeck, or: pip install shelldeck
@@ -95,6 +95,7 @@ sd ~/code/api # add a folder as a project and open a terminal in it
95
95
  ### Secure by default
96
96
 
97
97
  - **Password required.** Every browser logs in separately and locks after 30 minutes idle.
98
+ - **One device at a time.** Any number of browsers can stay signed in, but only one uses shelldeck; logging in on another takes over and idles the rest. The **Devices** page lists every signed-in browser (where from, last active, in use or idle) and revokes any of them.
98
99
  - **Local only.** shelldeck listens on `127.0.0.1` and refuses requests from other websites.
99
100
  - **Remote use** goes over an SSH tunnel or HTTPS. See [Remote access](#remote-access).
100
101
 
@@ -160,6 +161,7 @@ sd schedule list|add|run|toggle|delete|logs
160
161
  sd task list|add|move|delete|alarms
161
162
  sd serve [--host H] run the server in the foreground
162
163
  sd stop stop the background server (closes all terminals)
164
+ sd share share over an HTTPS Cloudflare tunnel with a QR link (needs cloudflared)
163
165
  sd login-link emergency: one-time login URL (host only)
164
166
  sd reset-password emergency: forget the password (host only)
165
167
  ```
@@ -181,6 +183,17 @@ sd serve # on the VM: stays on 127.0.0.1:5455
181
183
  ssh -N -L 5455:127.0.0.1:5455 you@vm # on your machine, then open http://127.0.0.1:5455
182
184
  ```
183
185
 
186
+ **`sd share` (any device, no setup).** Needs [`cloudflared`](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/) (`winget install Cloudflare.cloudflared`); no Cloudflare account.
187
+
188
+ ```sh
189
+ sd share # starts shelldeck if needed, prints a QR code and a link; Ctrl+C stops sharing
190
+ ```
191
+
192
+ - **Encrypted.** The other device talks HTTPS to Cloudflare, which relays it through cloudflared's outbound encrypted tunnel to shelldeck on `127.0.0.1`. No ports are opened.
193
+ - **Two locks.** The random `trycloudflare.com` address alone is refused; only the printed link (a one-per-share token made on the host) lets a browser in, and then it still needs your password. A password must exist before sharing.
194
+ - **Phones.** On touch screens a key bar adds Esc, Tab, Ctrl (applies to the next letter), arrows and `| ~ / -`; dialogs open as bottom sheets.
195
+ - **Stopping.** Ctrl+C closes the tunnel, voids the link and signs out every browser that logged in through it (they also vanish from **Devices**; a server restart clears them too). Each `sd share` gets a new address and link.
196
+
184
197
  **HTTPS on the VM's address,** with a real certificate (Tailscale `tailscale cert`, Let's Encrypt, your reverse proxy) or a self-signed one:
185
198
 
186
199
  ```sh
@@ -195,6 +208,7 @@ sd serve --host 0.0.0.0 --cert cert.pem --key key.pem
195
208
 
196
209
  - **Local only.** The server binds to `127.0.0.1` and rejects HTTP and WebSocket requests whose `Host` or `Origin` isn't the app itself, so other websites can't reach your shells.
197
210
  - **Passwords** are stored as PBKDF2-SHA256 (600k iterations). A login is a random token in an `HttpOnly`, `SameSite=Strict` cookie, and only its SHA-256 is stored. After 5 wrong passwords, each further try waits longer.
211
+ - **Devices.** Each login records where it came from (this machine, the network, or a share). Only one login is in use at a time; the others get `423` until they enter the password again. Revoke any login from the Devices page.
198
212
  - **The CLI** authenticates with a token file in the data folder, readable only by your account and rotated on every server start.
199
213
  - **Forgot the password?** On the host machine, `sd login-link` prints a one-time login URL valid for 5 minutes, and `sd reset-password` removes the password. There is deliberately no way to do either from the browser.
200
214
  - **Your data** lives in `~/.config/shelldeck/` (database, log and saved terminal history). Set `SHELLDECK_HOME` to move it.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "shelldeck"
3
- version = "0.0.1"
3
+ version = "0.0.3"
4
4
  description = "Project-first multi-terminal for Windows and Linux: real shells, grouped by project, in your browser"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -29,6 +29,7 @@ dependencies = [
29
29
  "rich>=13",
30
30
  "apscheduler>=3.10,<4",
31
31
  "psutil>=5.9",
32
+ "segno>=1.6",
32
33
  ]
33
34
 
34
35
  [project.urls]
@@ -141,12 +141,21 @@ def _h(token: str) -> str:
141
141
  return hashlib.sha256(token.encode()).hexdigest()
142
142
 
143
143
 
144
- def new_session(client: str) -> str:
144
+ def new_session(client: str, via: str = "local") -> str:
145
145
  token = secrets.token_urlsafe(32)
146
- db.add_auth_session(_h(token), time.time(), client)
146
+ db.add_auth_session(_h(token), time.time(), client, via)
147
147
  return token
148
148
 
149
149
 
150
+ def alive(h: str | None) -> bool:
151
+ """A session (by hash) that still exists and hasn't idled out."""
152
+ if not h:
153
+ return False
154
+ hit = _cache.get(h)
155
+ row = hit[1] if hit and time.time() - hit[0] < _CACHE_TTL else db.get_auth_session(h)
156
+ return bool(row) and time.time() - row["last_seen"] <= LOCK_TIMEOUT_SECONDS
157
+
158
+
150
159
  def session_hash(token: str | None) -> str | None:
151
160
  """sha256 of a live session token, else None. Idle sessions are removed here."""
152
161
  if not token:
@@ -1,5 +1,6 @@
1
1
  import json
2
2
  import os
3
+ import re
3
4
  import shutil
4
5
  import ssl
5
6
  import subprocess
@@ -301,6 +302,51 @@ def login_link():
301
302
  typer.echo("One use, valid for 5 minutes. Through an SSH tunnel, open it on your machine as-is.")
302
303
 
303
304
 
305
+ def _show_share(url: str) -> None:
306
+ import segno
307
+
308
+ if sys.stdout.isatty():
309
+ segno.make(url, error="l").terminal(compact=(sys.stdout.encoding or "").lower().startswith("utf"))
310
+ typer.echo(f"\n {url}\n")
311
+ typer.echo(" Scan or open it on the other device, then log in with your shelldeck password.")
312
+ typer.echo(" Without this link the tunnel URL is refused. Ctrl+C stops sharing and signs those browsers out.")
313
+
314
+
315
+ @app.command()
316
+ def share():
317
+ """Reach this shelldeck from another device: HTTPS Cloudflare quick tunnel, QR link, then your password."""
318
+ exe = shutil.which("cloudflared")
319
+ if not exe:
320
+ hint = "winget install Cloudflare.cloudflared" if sys.platform == "win32" else "see https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/"
321
+ typer.echo(f"cloudflared is not installed ({hint})", err=True)
322
+ raise typer.Exit(1)
323
+ ensure_server()
324
+ if not _api("/api/auth/status").get("has_password"):
325
+ typer.echo("Set a password first: open shelldeck on this machine (`sd`), then run `sd share` again.", err=True)
326
+ raise typer.Exit(1)
327
+ # the tunnel ends at our own loopback server, whose certificate (if any) is self-signed
328
+ cmd = [exe, "tunnel", "--no-autoupdate", "--url", _url()] + (["--no-tls-verify"] if _scheme() == "https" else [])
329
+ proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True, errors="replace")
330
+ host = shared = None
331
+ try:
332
+ for line in proc.stderr: # cloudflared logs to stderr: the URL first, then each edge connection
333
+ if not host and (m := re.search(r"https://([a-z0-9-]+\.trycloudflare\.com)", line)):
334
+ host = m.group(1)
335
+ elif host and not shared and "Registered tunnel connection" in line:
336
+ shared = f"https://{host}" + _api("/api/share", "POST", {"host": host})["path"]
337
+ _show_share(shared)
338
+ except KeyboardInterrupt:
339
+ pass
340
+ finally:
341
+ proc.terminate()
342
+ if shared and _health() == "ok":
343
+ _api("/api/share", "DELETE")
344
+ if not shared:
345
+ typer.echo("cloudflared exited without a tunnel URL; run it by hand to see why.", err=True)
346
+ raise typer.Exit(1)
347
+ typer.echo("sharing stopped")
348
+
349
+
304
350
  @app.command()
305
351
  def stop():
306
352
  """Stop the background server (closes all terminals)."""
@@ -136,7 +136,8 @@ def init_db() -> None:
136
136
  token_hash TEXT PRIMARY KEY,
137
137
  created_at REAL NOT NULL,
138
138
  last_seen REAL NOT NULL,
139
- client TEXT
139
+ client TEXT,
140
+ via TEXT
140
141
  );
141
142
 
142
143
  -- command history (sessions/projects may be deleted later; keep the text)
@@ -162,6 +163,9 @@ def init_db() -> None:
162
163
  cols = {r[1] for r in conn.execute("PRAGMA table_info(sessions)")}
163
164
  if "name" not in cols:
164
165
  conn.execute("ALTER TABLE sessions ADD COLUMN name TEXT")
166
+ if "via" not in {r[1] for r in conn.execute("PRAGMA table_info(auth_sessions)")}:
167
+ conn.execute("ALTER TABLE auth_sessions ADD COLUMN via TEXT")
168
+ conn.execute("DELETE FROM auth_sessions WHERE via LIKE 'share:%'") # shares end with the server
165
169
  cols = {r[1] for r in conn.execute("PRAGMA table_info(projects)")}
166
170
  if "color" not in cols:
167
171
  conn.execute("ALTER TABLE projects ADD COLUMN color INTEGER")
@@ -317,15 +321,30 @@ def list_sessions_with_project() -> list[dict]:
317
321
  return [dict(r) for r in rows]
318
322
 
319
323
 
320
- def add_auth_session(token_hash: str, now: float, client: str) -> None:
324
+ def add_auth_session(token_hash: str, now: float, client: str, via: str = "local") -> None:
321
325
  with _connect() as conn:
322
326
  conn.execute(
323
- "INSERT INTO auth_sessions (token_hash, created_at, last_seen, client) VALUES (?, ?, ?, ?)",
324
- (token_hash, now, now, client[:200]),
327
+ "INSERT INTO auth_sessions (token_hash, created_at, last_seen, client, via) VALUES (?, ?, ?, ?, ?)",
328
+ (token_hash, now, now, client[:200], via),
325
329
  )
326
330
  conn.commit()
327
331
 
328
332
 
333
+ def list_auth_sessions() -> list[dict]:
334
+ with _connect() as conn:
335
+ conn.row_factory = sqlite3.Row
336
+ return [dict(r) for r in conn.execute("SELECT * FROM auth_sessions ORDER BY last_seen DESC")]
337
+
338
+
339
+ def delete_auth_sessions_via(via: str) -> list[str]:
340
+ """Delete the logins made through `via` (e.g. one share); returns their hashes."""
341
+ with _connect() as conn:
342
+ gone = [r[0] for r in conn.execute("SELECT token_hash FROM auth_sessions WHERE via = ?", (via,))]
343
+ conn.execute("DELETE FROM auth_sessions WHERE via = ?", (via,))
344
+ conn.commit()
345
+ return gone
346
+
347
+
329
348
  def get_auth_session(token_hash: str) -> dict | None:
330
349
  with _connect() as conn:
331
350
  conn.row_factory = sqlite3.Row
@@ -1,4 +1,5 @@
1
1
  import asyncio
2
+ import hashlib
2
3
  import hmac
3
4
  import json
4
5
  import logging
@@ -67,10 +68,28 @@ def err(code: str, status: int = 400) -> JSONResponse:
67
68
  return JSONResponse({"error": code}, status_code=status)
68
69
 
69
70
 
70
- def _trusted(headers) -> bool:
71
- """Same-origin check. Browsers always send Origin cross-site; the CLI sends none."""
71
+ # `sd share`: the tunnel's public hostname, sha256 of its link token, and the logins made through it
72
+ _share: dict = {}
73
+ SHARE_COOKIE = "sd_share"
74
+
75
+
76
+ def _share_key(token: str | None) -> bool:
77
+ return bool(token and _share) and hmac.compare_digest(hashlib.sha256(token.encode()).hexdigest(), _share["key"])
78
+
79
+
80
+ def _via_share(conn) -> bool:
81
+ return bool(_share) and conn.headers.get("host", "").rsplit(":", 1)[0] == _share["host"]
82
+
83
+
84
+ def _trusted(conn) -> bool:
85
+ """Same-origin check. Browsers always send Origin cross-site; the CLI sends none.
86
+ The share tunnel's host also needs the cookie its link sets (or to be opening that link)."""
87
+ headers = conn.headers
72
88
  host = headers.get("host", "")
73
- if ALLOWED_HOSTS is not None and host.rsplit(":", 1)[0] not in ALLOWED_HOSTS:
89
+ if _via_share(conn):
90
+ if not (conn.url.path.startswith("/share/") or _share_key(conn.cookies.get(SHARE_COOKIE))):
91
+ return False
92
+ elif ALLOWED_HOSTS is not None and host.rsplit(":", 1)[0] not in ALLOWED_HOSTS:
74
93
  return False
75
94
  origin = headers.get("origin")
76
95
  return not origin or urlsplit(origin).netloc == host
@@ -152,7 +171,8 @@ def _client(conn) -> str:
152
171
 
153
172
  def _is_local(conn) -> bool:
154
173
  """A request from the host itself (not through a reverse proxy)."""
155
- return _client(conn) in LOOPBACK and "x-forwarded-for" not in conn.headers
174
+ proxied = "x-forwarded-for" in conn.headers or "cf-connecting-ip" in conn.headers # e.g. `sd share`
175
+ return _client(conn) in LOOPBACK and not proxied
156
176
 
157
177
 
158
178
  def _who(conn) -> tuple[str, str | None] | None:
@@ -163,13 +183,25 @@ def _who(conn) -> tuple[str, str | None] | None:
163
183
  return ("browser", h) if h else None
164
184
 
165
185
 
186
+ # One device uses shelldeck at a time; the others stay signed in but idle until they log in again.
187
+ _active: dict[str, str | None] = {"h": None}
188
+
189
+
190
+ def _claim(h: str) -> bool:
191
+ """True when browser session `h` may act: it is the active one, or no live session is."""
192
+ if _active["h"] != h and auth.alive(_active["h"]):
193
+ return False
194
+ _active["h"] = h
195
+ return True
196
+
197
+
166
198
  def _denied() -> JSONResponse:
167
199
  return err("locked" if auth.has_password() else "setup_required", 401)
168
200
 
169
201
 
170
202
  @app.middleware("http")
171
203
  async def guard(request: Request, call_next):
172
- if not _trusted(request.headers):
204
+ if not _trusted(request):
173
205
  return err("forbidden_origin", 403)
174
206
  path = request.url.path
175
207
  request.state.session = None
@@ -178,6 +210,8 @@ async def guard(request: Request, call_next):
178
210
  if not who:
179
211
  return _denied()
180
212
  request.state.session = who[1]
213
+ if who[1] and not _claim(who[1]):
214
+ return err("in_use", 423)
181
215
  if who[1] and request.method != "GET": # polling GETs are not activity
182
216
  auth.touch(who[1])
183
217
  return await call_next(request)
@@ -214,9 +248,21 @@ async def shutdown():
214
248
  # ---------------------------------------------------------------------------
215
249
 
216
250
 
217
- def _login_response(request: Request, body: dict, res=None):
218
- """Start a login session: cookie on `res` (JSON body by default)."""
219
- token = auth.new_session(f"{_client(request)} {request.headers.get('user-agent', '')}")
251
+ def _via(request: Request) -> str:
252
+ if _via_share(request):
253
+ return f"share:{_share['host']}"
254
+ return "local" if _is_local(request) else "network"
255
+
256
+
257
+ async def _login_response(request: Request, body: dict, res=None):
258
+ """Start a login session (cookie on `res`, JSON body by default). It becomes the active device:
259
+ this browser's previous login is replaced and every other device goes idle."""
260
+ if old := auth.session_hash(request.cookies.get(auth.COOKIE)):
261
+ auth.end_session(old)
262
+ token = auth.new_session(f"{_client(request)} {request.headers.get('user-agent', '')}", _via(request))
263
+ h = auth.session_hash(token)
264
+ _active["h"] = h
265
+ await _close_session_sockets(lambda owner: owner != h, IN_USE)
220
266
  res = res or JSONResponse(body)
221
267
  res.set_cookie(
222
268
  auth.COOKIE, token, httponly=True, samesite="strict", path="/",
@@ -241,6 +287,7 @@ async def auth_status(request: Request):
241
287
  "has_password": auth.has_password(),
242
288
  "setup_code_required": not auth.has_password() and not _is_local(request),
243
289
  "authenticated": bool(who),
290
+ "in_use_elsewhere": bool(who and who[1] and who[1] != _active["h"] and auth.alive(_active["h"])),
244
291
  "idle_timeout": auth.LOCK_TIMEOUT_SECONDS,
245
292
  "min_length": auth.MIN_PASSWORD,
246
293
  }
@@ -263,7 +310,7 @@ async def auth_setup(request: Request, payload: dict):
263
310
  return err(problem)
264
311
  auth.set_password(password)
265
312
  log.info("password created from %s", _client(request))
266
- return _login_response(request, {"status": "ok"})
313
+ return await _login_response(request, {"status": "ok"})
267
314
 
268
315
 
269
316
  @app.post("/api/auth/login")
@@ -277,7 +324,7 @@ async def auth_login(request: Request, payload: dict):
277
324
  if not ok:
278
325
  log.warning("failed login from %s", _client(request))
279
326
  return err("invalid_password", 401)
280
- return _login_response(request, {"status": "ok"})
327
+ return await _login_response(request, {"status": "ok"})
281
328
 
282
329
 
283
330
  _login_links: dict[str, float] = {} # one-time code -> expiry
@@ -293,12 +340,54 @@ async def auth_login_link(request: Request):
293
340
  return {"path": f"/api/auth/link/{code}"}
294
341
 
295
342
 
343
+ @app.post("/api/share")
344
+ async def share_start(request: Request, payload: dict):
345
+ """Host CLI only: open the gate for an `sd share` tunnel host; returns its one link."""
346
+ if not auth.cli_ok(request.headers.get(auth.TOKEN_HEADER)):
347
+ return err("host_cli_only", 403)
348
+ host = str(payload.get("host", "")).lower()
349
+ if not re.fullmatch(r"[a-z0-9-]+(\.[a-z0-9-]+)+", host):
350
+ return err("invalid_host")
351
+ await _share_stop()
352
+ token = secrets.token_urlsafe(32)
353
+ _share.update(host=host, key=hashlib.sha256(token.encode()).hexdigest())
354
+ log.info("sharing via %s", host)
355
+ return {"path": f"/share/{token}"}
356
+
357
+
358
+ @app.delete("/api/share")
359
+ async def share_stop(request: Request):
360
+ if not auth.cli_ok(request.headers.get(auth.TOKEN_HEADER)):
361
+ return err("host_cli_only", 403)
362
+ await _share_stop()
363
+ return {"status": "ok"}
364
+
365
+
366
+ async def _share_stop() -> None:
367
+ """Close the gate and sign out every browser that logged in through the tunnel."""
368
+ if not _share:
369
+ return
370
+ gone = db.delete_auth_sessions_via(f"share:{_share['host']}")
371
+ _share.clear()
372
+ await _revoke(gone)
373
+
374
+
375
+ @app.get("/share/{token}")
376
+ async def share_open(request: Request, token: str):
377
+ """The QR link: sets the tunnel cookie, then the usual password login takes over."""
378
+ if not (_via_share(request) and _share_key(token)):
379
+ return err("link_expired", 403)
380
+ res = RedirectResponse("/", status_code=303) # drops the token from the address bar
381
+ res.set_cookie(SHARE_COOKIE, token, httponly=True, samesite="lax", secure=True, path="/", max_age=24 * 3600)
382
+ return res
383
+
384
+
296
385
  @app.get("/api/auth/link/{code}")
297
386
  async def auth_use_link(request: Request, code: str):
298
387
  expiry = _login_links.pop(code, 0)
299
388
  if expiry < time.time() or not auth.has_password():
300
389
  return err("link_expired", 403)
301
- return _login_response(request, {}, RedirectResponse("/", status_code=303))
390
+ return await _login_response(request, {}, RedirectResponse("/", status_code=303))
302
391
 
303
392
 
304
393
  @app.post("/api/auth/logout")
@@ -313,6 +402,44 @@ async def auth_logout(request: Request):
313
402
  return res
314
403
 
315
404
 
405
+ @app.get("/api/devices")
406
+ async def devices(request: Request):
407
+ """Browsers signed in: where from, when, and which one is in use."""
408
+ online: dict[str, int] = {}
409
+ for owner in socket_owner.values():
410
+ if owner:
411
+ online[owner] = online.get(owner, 0) + 1
412
+ rows = []
413
+ for r in db.list_auth_sessions():
414
+ h = r["token_hash"]
415
+ if not auth.alive(h):
416
+ continue
417
+ ip, _, agent = (r["client"] or "").partition(" ")
418
+ rows.append({
419
+ "id": h, "ip": ip, "agent": agent, "via": r["via"] or "local",
420
+ "created_at": r["created_at"], "last_seen": r["last_seen"],
421
+ "active": h == _active["h"], "current": h == request.state.session, "sockets": online.get(h, 0),
422
+ })
423
+ return {"devices": rows, "share": _share.get("host")}
424
+
425
+
426
+ @app.delete("/api/devices/{device_id}")
427
+ async def revoke_device(request: Request, device_id: str):
428
+ """Sign one browser out, or with id "others" every browser but this one."""
429
+ if device_id == "others":
430
+ return await _revoke([d["token_hash"] for d in db.list_auth_sessions() if d["token_hash"] != request.state.session])
431
+ return await _revoke([device_id])
432
+
433
+
434
+ async def _revoke(hashes: list[str]) -> dict:
435
+ for h in hashes:
436
+ auth.end_session(h)
437
+ if _active["h"] == h:
438
+ _active["h"] = None
439
+ await _close_session_sockets(lambda owner: owner in hashes)
440
+ return {"revoked": len(hashes)}
441
+
442
+
316
443
  @app.put("/api/auth/password")
317
444
  async def auth_password(request: Request, payload: dict):
318
445
  """Change the password (logged-in browser only). Signs out every other browser."""
@@ -679,11 +806,14 @@ async def _close(ws: WebSocket, code: int = 1000) -> None:
679
806
  pass
680
807
 
681
808
 
682
- async def _close_session_sockets(match) -> None:
809
+ IN_USE = 4423 # socket close code: another device took over
810
+
811
+
812
+ async def _close_session_sockets(match, code: int = 1008) -> None:
683
813
  """Close sockets whose login (session hash; None for the CLI) matches."""
684
814
  for ws, owner in list(socket_owner.items()):
685
815
  if owner and match(owner):
686
- await _close(ws, 1008)
816
+ await _close(ws, code)
687
817
 
688
818
 
689
819
  # ---------------------------------------------------------------------------
@@ -825,10 +955,13 @@ def _attach(session: dict, rows: int, cols: int) -> None:
825
955
 
826
956
  @app.websocket("/ws/alarms")
827
957
  async def alarms_ws(ws: WebSocket):
828
- who = _who(ws) if _trusted(ws.headers) else None
958
+ who = _who(ws) if _trusted(ws) else None
829
959
  if not who:
830
960
  await ws.close(code=1008)
831
961
  return
962
+ if who[1] and not _claim(who[1]):
963
+ await ws.close(code=IN_USE)
964
+ return
832
965
  await ws.accept()
833
966
  alarm_sockets.add(ws)
834
967
  socket_owner[ws] = who[1]
@@ -846,10 +979,13 @@ async def alarms_ws(ws: WebSocket):
846
979
 
847
980
  @app.websocket("/ws/{session_id}")
848
981
  async def terminal_ws(ws: WebSocket, session_id: str):
849
- who = _who(ws) if _trusted(ws.headers) else None
982
+ who = _who(ws) if _trusted(ws) else None
850
983
  if not who:
851
984
  await ws.close(code=1008)
852
985
  return
986
+ if who[1] and not _claim(who[1]):
987
+ await ws.close(code=IN_USE)
988
+ return
853
989
  login = who[1]
854
990
  session = db.get_session(session_id)
855
991
  if not session: