shelldeck 0.0.2__tar.gz → 0.0.4__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 (50) hide show
  1. {shelldeck-0.0.2 → shelldeck-0.0.4}/CHANGELOG.md +37 -0
  2. {shelldeck-0.0.2 → shelldeck-0.0.4}/PKG-INFO +14 -7
  3. {shelldeck-0.0.2 → shelldeck-0.0.4}/README.md +13 -6
  4. {shelldeck-0.0.2 → shelldeck-0.0.4}/pyproject.toml +1 -1
  5. {shelldeck-0.0.2 → shelldeck-0.0.4}/shelldeck/auth.py +29 -3
  6. {shelldeck-0.0.2 → shelldeck-0.0.4}/shelldeck/cli.py +54 -4
  7. {shelldeck-0.0.2 → shelldeck-0.0.4}/shelldeck/db.py +23 -4
  8. {shelldeck-0.0.2 → shelldeck-0.0.4}/shelldeck/server.py +244 -28
  9. {shelldeck-0.0.2 → shelldeck-0.0.4}/shelldeck/static/app.css +45 -0
  10. {shelldeck-0.0.2 → shelldeck-0.0.4}/shelldeck/static/app.js +14 -6
  11. shelldeck-0.0.4/shelldeck/static/devices.js +76 -0
  12. {shelldeck-0.0.2 → shelldeck-0.0.4}/shelldeck/static/index.html +2 -0
  13. {shelldeck-0.0.2 → shelldeck-0.0.4}/shelldeck/static/ui.js +2 -0
  14. {shelldeck-0.0.2 → shelldeck-0.0.4}/shelldeck/static/views.js +2 -1
  15. {shelldeck-0.0.2 → shelldeck-0.0.4}/tests/test_cli.py +12 -3
  16. {shelldeck-0.0.2 → shelldeck-0.0.4}/tests/test_shelldeck.py +127 -8
  17. {shelldeck-0.0.2 → shelldeck-0.0.4}/.gitignore +0 -0
  18. {shelldeck-0.0.2 → shelldeck-0.0.4}/LICENSE +0 -0
  19. {shelldeck-0.0.2 → shelldeck-0.0.4}/shelldeck/__init__.py +0 -0
  20. {shelldeck-0.0.2 → shelldeck-0.0.4}/shelldeck/__main__.py +0 -0
  21. {shelldeck-0.0.2 → shelldeck-0.0.4}/shelldeck/addons/__init__.py +0 -0
  22. {shelldeck-0.0.2 → shelldeck-0.0.4}/shelldeck/addons/fast_context.py +0 -0
  23. {shelldeck-0.0.2 → shelldeck-0.0.4}/shelldeck/gitgraph.py +0 -0
  24. {shelldeck-0.0.2 → shelldeck-0.0.4}/shelldeck/integration/bash.sh +0 -0
  25. {shelldeck-0.0.2 → shelldeck-0.0.4}/shelldeck/integration/shelldeck.fish +0 -0
  26. {shelldeck-0.0.2 → shelldeck-0.0.4}/shelldeck/integration/shelldeck.ps1 +0 -0
  27. {shelldeck-0.0.2 → shelldeck-0.0.4}/shelldeck/integration/zsh/.zshenv +0 -0
  28. {shelldeck-0.0.2 → shelldeck-0.0.4}/shelldeck/integration/zsh/.zshrc +0 -0
  29. {shelldeck-0.0.2 → shelldeck-0.0.4}/shelldeck/pty.py +0 -0
  30. {shelldeck-0.0.2 → shelldeck-0.0.4}/shelldeck/runner.py +0 -0
  31. {shelldeck-0.0.2 → shelldeck-0.0.4}/shelldeck/scheduler.py +0 -0
  32. {shelldeck-0.0.2 → shelldeck-0.0.4}/shelldeck/shells.py +0 -0
  33. {shelldeck-0.0.2 → shelldeck-0.0.4}/shelldeck/static/gitgraph.js +0 -0
  34. {shelldeck-0.0.2 → shelldeck-0.0.4}/shelldeck/static/history.js +0 -0
  35. {shelldeck-0.0.2 → shelldeck-0.0.4}/shelldeck/static/icon-192.png +0 -0
  36. {shelldeck-0.0.2 → shelldeck-0.0.4}/shelldeck/static/icon-32.png +0 -0
  37. {shelldeck-0.0.2 → shelldeck-0.0.4}/shelldeck/static/icon-512.png +0 -0
  38. {shelldeck-0.0.2 → shelldeck-0.0.4}/shelldeck/static/icon.svg +0 -0
  39. {shelldeck-0.0.2 → shelldeck-0.0.4}/shelldeck/static/manifest.webmanifest +0 -0
  40. {shelldeck-0.0.2 → shelldeck-0.0.4}/shelldeck/static/monitor.js +0 -0
  41. {shelldeck-0.0.2 → shelldeck-0.0.4}/shelldeck/static/vendor/LICENSE-xterm.txt +0 -0
  42. {shelldeck-0.0.2 → shelldeck-0.0.4}/shelldeck/static/vendor/addon-fit.js +0 -0
  43. {shelldeck-0.0.2 → shelldeck-0.0.4}/shelldeck/static/vendor/addon-search.js +0 -0
  44. {shelldeck-0.0.2 → shelldeck-0.0.4}/shelldeck/static/vendor/addon-serialize.js +0 -0
  45. {shelldeck-0.0.2 → shelldeck-0.0.4}/shelldeck/static/vendor/addon-web-links.js +0 -0
  46. {shelldeck-0.0.2 → shelldeck-0.0.4}/shelldeck/static/vendor/addon-webgl.js +0 -0
  47. {shelldeck-0.0.2 → shelldeck-0.0.4}/shelldeck/static/vendor/xterm.css +0 -0
  48. {shelldeck-0.0.2 → shelldeck-0.0.4}/shelldeck/static/vendor/xterm.js +0 -0
  49. {shelldeck-0.0.2 → shelldeck-0.0.4}/shelldeck/stats.py +0 -0
  50. {shelldeck-0.0.2 → shelldeck-0.0.4}/tests/test_gitgraph.py +0 -0
@@ -2,6 +2,41 @@
2
2
 
3
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
4
 
5
+ ## [0.0.4] - 2026-09-29
6
+
7
+ ### Added
8
+
9
+ - Share approval: opening a share link puts the device on a waiting page until you allow it in the `sd share` window (`Allow it? [y/N]`); then it still needs the password.
10
+ - `sd share --new-link` prints another one-use link for the running share.
11
+ - Security headers on every response (no framing, no MIME sniffing, no referrer) and `Cache-Control: no-store` on the API.
12
+
13
+ ### Changed
14
+
15
+ - Share links work once and expire after 10 minutes unopened. A new link voids the unused old one.
16
+ - Sharing needs a password of 12+ characters (an existing one that long counts after the next login).
17
+ - Revoking a shared device in Devices also voids its approval, so it needs a new link.
18
+ - README: `sd share` is encrypted in transit but not end to end; Cloudflare can see the traffic.
19
+
20
+ ### Fixed
21
+
22
+ - A share left open when `sd share` was killed or its window closed: the server now closes a share within a minute of its last heartbeat and signs its browsers out. Devices no longer shows "sharing via" a dead tunnel.
23
+
24
+ ## [0.0.3] - 2026-09-29
25
+
26
+ ### Added
27
+
28
+ - 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.
29
+ - 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.
30
+
31
+ ### Changed
32
+
33
+ - Logging in again in the same browser replaces its old login instead of adding one.
34
+ - Share logins are stored with their share and removed when it stops, is replaced, or the server restarts.
35
+
36
+ ### Fixed
37
+
38
+ - Re-running the one-line installer now upgrades an existing install to the latest release.
39
+
5
40
  ## [0.0.2] - 2026-09-29
6
41
 
7
42
  ### Added
@@ -32,5 +67,7 @@ First public release.
32
67
  - Mandatory password with per-browser logins, idle lock, and HTTPS or SSH-tunnel remote access.
33
68
  - `sd` CLI with a startup banner, `sd search` (LLM-free code search), and one-line installers for Windows and Linux.
34
69
 
70
+ [0.0.4]: https://github.com/codejunction/shelldeck/releases/tag/v0.0.4
71
+ [0.0.3]: https://github.com/codejunction/shelldeck/releases/tag/v0.0.3
35
72
  [0.0.2]: https://github.com/codejunction/shelldeck/releases/tag/v0.0.2
36
73
  [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.2
3
+ Version: 0.0.4
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
@@ -70,7 +70,7 @@ irm https://raw.githubusercontent.com/codejunction/shelldeck/main/install.ps1 |
70
70
  curl -fsSL https://raw.githubusercontent.com/codejunction/shelldeck/main/install.sh | sh
71
71
  ```
72
72
 
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+. 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:
74
74
 
75
75
  ```sh
76
76
  uv tool install shelldeck # or: pipx install shelldeck, or: pip install shelldeck
@@ -130,6 +130,7 @@ sd ~/code/api # add a folder as a project and open a terminal in it
130
130
  ### Secure by default
131
131
 
132
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.
133
134
  - **Local only.** shelldeck listens on `127.0.0.1` and refuses requests from other websites.
134
135
  - **Remote use** goes over an SSH tunnel or HTTPS. See [Remote access](#remote-access).
135
136
 
@@ -195,7 +196,7 @@ sd schedule list|add|run|toggle|delete|logs
195
196
  sd task list|add|move|delete|alarms
196
197
  sd serve [--host H] run the server in the foreground
197
198
  sd stop stop the background server (closes all terminals)
198
- sd share share over an HTTPS Cloudflare tunnel with a QR link (needs cloudflared)
199
+ sd share [--new-link] share over an HTTPS Cloudflare tunnel: one-use QR link, host approval (needs cloudflared)
199
200
  sd login-link emergency: one-time login URL (host only)
200
201
  sd reset-password emergency: forget the password (host only)
201
202
  ```
@@ -220,13 +221,16 @@ ssh -N -L 5455:127.0.0.1:5455 you@vm # on your machine, then open http://12
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.
221
222
 
222
223
  ```sh
223
- sd share # starts shelldeck if needed, prints a QR code and a link; Ctrl+C stops sharing
224
+ sd share # starts shelldeck if needed, prints a QR code and a link; Ctrl+C stops sharing
225
+ sd share --new-link # another one-use link for the running share (a second device)
224
226
  ```
225
227
 
226
- - **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.
227
- - **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.
228
+ - **Encrypted in transit, not end to end.** 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. Cloudflare ends the TLS connection, so it can see the traffic (terminal input and output, your password as you log in). For sensitive work use the SSH tunnel or Tailscale instead.
229
+ - **Three locks.** The random `trycloudflare.com` address alone is refused. The printed link works **once** and only for **10 minutes**; opening it makes the device wait until you allow it in the `sd share` window (`Allow it? [y/N]`). Then it still needs your password.
230
+ - **Long password.** Sharing needs a password of 12+ characters. An older password that long counts after your next login.
231
+ - **Self-closing.** `sd share` renews the share every few seconds. If it stops without Ctrl+C (window closed, process killed), the server closes the share within a minute and signs those browsers out.
228
232
  - **Phones.** On touch screens a key bar adds Esc, Tab, Ctrl (applies to the next letter), arrows and `| ~ / -`; dialogs open as bottom sheets.
229
- - **Stopping.** Ctrl+C closes the tunnel, voids the link and signs out every browser that logged in through it. Each `sd share` gets a new address and link.
233
+ - **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). Revoking a shared device in **Devices** also voids its approval, so it needs a new link. Each `sd share` gets a new address and link.
230
234
 
231
235
  **HTTPS on the VM's address,** with a real certificate (Tailscale `tailscale cert`, Let's Encrypt, your reverse proxy) or a self-signed one:
232
236
 
@@ -242,6 +246,9 @@ sd serve --host 0.0.0.0 --cert cert.pem --key key.pem
242
246
 
243
247
  - **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.
244
248
  - **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.
249
+ - **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.
250
+ - **Headers.** Every response forbids framing (`X-Frame-Options: DENY`, `frame-ancestors 'none'`), sniffing and referrers, and API responses are never cached.
251
+ - **Sharing** adds a one-use, 10-minute link, approval on the host and a 12+ character password; see [Remote access](#remote-access).
245
252
  - **The CLI** authenticates with a token file in the data folder, readable only by your account and rotated on every server start.
246
253
  - **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.
247
254
  - **Your data** lives in `~/.config/shelldeck/` (database, log and saved terminal history). Set `SHELLDECK_HOME` to move it.
@@ -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,7 +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)
163
- sd share share over an HTTPS Cloudflare tunnel with a QR link (needs cloudflared)
164
+ sd share [--new-link] share over an HTTPS Cloudflare tunnel: one-use QR link, host approval (needs cloudflared)
164
165
  sd login-link emergency: one-time login URL (host only)
165
166
  sd reset-password emergency: forget the password (host only)
166
167
  ```
@@ -185,13 +186,16 @@ ssh -N -L 5455:127.0.0.1:5455 you@vm # on your machine, then open http://12
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.
186
187
 
187
188
  ```sh
188
- sd share # starts shelldeck if needed, prints a QR code and a link; Ctrl+C stops sharing
189
+ sd share # starts shelldeck if needed, prints a QR code and a link; Ctrl+C stops sharing
190
+ sd share --new-link # another one-use link for the running share (a second device)
189
191
  ```
190
192
 
191
- - **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.
192
- - **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.
193
+ - **Encrypted in transit, not end to end.** 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. Cloudflare ends the TLS connection, so it can see the traffic (terminal input and output, your password as you log in). For sensitive work use the SSH tunnel or Tailscale instead.
194
+ - **Three locks.** The random `trycloudflare.com` address alone is refused. The printed link works **once** and only for **10 minutes**; opening it makes the device wait until you allow it in the `sd share` window (`Allow it? [y/N]`). Then it still needs your password.
195
+ - **Long password.** Sharing needs a password of 12+ characters. An older password that long counts after your next login.
196
+ - **Self-closing.** `sd share` renews the share every few seconds. If it stops without Ctrl+C (window closed, process killed), the server closes the share within a minute and signs those browsers out.
193
197
  - **Phones.** On touch screens a key bar adds Esc, Tab, Ctrl (applies to the next letter), arrows and `| ~ / -`; dialogs open as bottom sheets.
194
- - **Stopping.** Ctrl+C closes the tunnel, voids the link and signs out every browser that logged in through it. Each `sd share` gets a new address and link.
198
+ - **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). Revoking a shared device in **Devices** also voids its approval, so it needs a new link. Each `sd share` gets a new address and link.
195
199
 
196
200
  **HTTPS on the VM's address,** with a real certificate (Tailscale `tailscale cert`, Let's Encrypt, your reverse proxy) or a self-signed one:
197
201
 
@@ -207,6 +211,9 @@ sd serve --host 0.0.0.0 --cert cert.pem --key key.pem
207
211
 
208
212
  - **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.
209
213
  - **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.
214
+ - **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.
215
+ - **Headers.** Every response forbids framing (`X-Frame-Options: DENY`, `frame-ancestors 'none'`), sniffing and referrers, and API responses are never cached.
216
+ - **Sharing** adds a one-use, 10-minute link, approval on the host and a 12+ character password; see [Remote access](#remote-access).
210
217
  - **The CLI** authenticates with a token file in the data folder, readable only by your account and rotated on every server start.
211
218
  - **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.
212
219
  - **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.2"
3
+ version = "0.0.4"
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"
@@ -20,9 +20,11 @@ from . import db
20
20
 
21
21
  LOCK_TIMEOUT_SECONDS = 30 * 60
22
22
  MIN_PASSWORD = 8
23
+ SHARE_MIN_PASSWORD = 12 # `sd share` puts the login page on the internet
23
24
  COOKIE = "sd_session"
24
25
  TOKEN_HEADER = "x-shelldeck-token"
25
26
  _PASSWORD_KEY = "auth_password_hash"
27
+ _STRONG_KEY = "auth_password_strong" # "1" when the password is long enough to share
26
28
  _TOUCH_EVERY = 60 # seconds; last_seen writes are throttled
27
29
  _CACHE_TTL = 5 # seconds a session lookup is trusted before re-reading the db
28
30
 
@@ -57,7 +59,20 @@ def has_password() -> bool:
57
59
 
58
60
  def check_password(password: str) -> bool:
59
61
  stored = db.get_setting(_PASSWORD_KEY)
60
- return bool(stored) and _verify_password(password, stored)
62
+ ok = bool(stored) and _verify_password(password, stored)
63
+ if ok:
64
+ _note_strength(password) # passwords set before this flag existed get it at their next login
65
+ return ok
66
+
67
+
68
+ def _note_strength(password: str) -> None:
69
+ strong = "1" if len(password) >= SHARE_MIN_PASSWORD else ""
70
+ if db.get_setting(_STRONG_KEY, "") != strong:
71
+ db.set_setting(_STRONG_KEY, strong)
72
+
73
+
74
+ def strong_password() -> bool:
75
+ return db.get_setting(_STRONG_KEY, "") == "1"
61
76
 
62
77
 
63
78
  def validate_new(password: str, confirm: str) -> str | None:
@@ -71,6 +86,7 @@ def validate_new(password: str, confirm: str) -> str | None:
71
86
 
72
87
  def set_password(password: str) -> None:
73
88
  db.set_setting(_PASSWORD_KEY, _hash_password(password))
89
+ _note_strength(password)
74
90
  _file("setup-code").unlink(missing_ok=True)
75
91
 
76
92
 
@@ -128,6 +144,7 @@ def init_auth() -> str | None:
128
144
  def reset() -> str:
129
145
  """Emergency, host-only: forget the password and every login. Returns the new setup code."""
130
146
  db.delete_setting(_PASSWORD_KEY)
147
+ db.delete_setting(_STRONG_KEY)
131
148
  db.delete_auth_sessions()
132
149
  _file("setup-code").unlink(missing_ok=True)
133
150
  _cache.clear()
@@ -141,12 +158,21 @@ def _h(token: str) -> str:
141
158
  return hashlib.sha256(token.encode()).hexdigest()
142
159
 
143
160
 
144
- def new_session(client: str) -> str:
161
+ def new_session(client: str, via: str = "local") -> str:
145
162
  token = secrets.token_urlsafe(32)
146
- db.add_auth_session(_h(token), time.time(), client)
163
+ db.add_auth_session(_h(token), time.time(), client, via)
147
164
  return token
148
165
 
149
166
 
167
+ def alive(h: str | None) -> bool:
168
+ """A session (by hash) that still exists and hasn't idled out."""
169
+ if not h:
170
+ return False
171
+ hit = _cache.get(h)
172
+ row = hit[1] if hit and time.time() - hit[0] < _CACHE_TTL else db.get_auth_session(h)
173
+ return bool(row) and time.time() - row["last_seen"] <= LOCK_TIMEOUT_SECONDS
174
+
175
+
150
176
  def session_hash(token: str | None) -> str | None:
151
177
  """sha256 of a live session token, else None. Idle sessions are removed here."""
152
178
  if not token:
@@ -1,10 +1,12 @@
1
1
  import json
2
2
  import os
3
+ import queue
3
4
  import re
4
5
  import shutil
5
6
  import ssl
6
7
  import subprocess
7
8
  import sys
9
+ import threading
8
10
  import time
9
11
  import urllib.error
10
12
  import urllib.request
@@ -308,13 +310,53 @@ def _show_share(url: str) -> None:
308
310
  if sys.stdout.isatty():
309
311
  segno.make(url, error="l").terminal(compact=(sys.stdout.encoding or "").lower().startswith("utf"))
310
312
  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
+ typer.echo(" Open it on the other device within 10 minutes. It works once; allow the device here, then log in")
314
+ typer.echo(" with your password. Another device: `sd share --new-link`. Ctrl+C stops sharing and signs them out.")
315
+
316
+
317
+ def _watch_share() -> None:
318
+ """Renew the share's lease (the server closes it ~60s after this stops) and ask here before a
319
+ device that opened the link gets in."""
320
+ asks: queue.Queue = queue.Queue()
321
+
322
+ def poll() -> None:
323
+ asked: set[str] = set()
324
+ while True:
325
+ try:
326
+ for p in _api("/api/share").get("pending", []):
327
+ if p["id"] not in asked:
328
+ asked.add(p["id"])
329
+ asks.put(p)
330
+ except typer.Exit:
331
+ pass # server busy or restarting; try again
332
+ time.sleep(3)
333
+
334
+ def ask() -> None:
335
+ while True:
336
+ p = asks.get()
337
+ typer.echo(f"\n A device opened the link: {p['ip']}\n {p['agent'][:100]}")
338
+ allow = input(" Allow it? [y/N] ").strip().lower() in ("y", "yes")
339
+ try:
340
+ _api("/api/share/decide", "POST", {"id": p["id"], "allow": allow})
341
+ typer.echo(" allowed; it can log in with your password now" if allow else " denied")
342
+ except typer.Exit:
343
+ typer.echo(" that request expired; open a new link")
344
+
345
+ for target in (poll, ask):
346
+ threading.Thread(target=target, daemon=True).start()
313
347
 
314
348
 
315
349
  @app.command()
316
- def share():
317
- """Reach this shelldeck from another device: HTTPS Cloudflare quick tunnel, QR link, then your password."""
350
+ def share(
351
+ new_link: bool = typer.Option(False, "--new-link", help="Print a fresh one-use link for the running share."),
352
+ ):
353
+ """Reach this shelldeck from another device: HTTPS Cloudflare quick tunnel, one-use QR link, host approval, then your password."""
354
+ if new_link:
355
+ if not (_health() == "ok" and _api("/api/share").get("host")):
356
+ typer.echo("not sharing; start with `sd share`", err=True)
357
+ raise typer.Exit(1)
358
+ _show_share(f"https://{_api('/api/share')['host']}" + _api("/api/share/link", "POST", {})["path"])
359
+ return
318
360
  exe = shutil.which("cloudflared")
319
361
  if not exe:
320
362
  hint = "winget install Cloudflare.cloudflared" if sys.platform == "win32" else "see https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/"
@@ -324,6 +366,13 @@ def share():
324
366
  if not _api("/api/auth/status").get("has_password"):
325
367
  typer.echo("Set a password first: open shelldeck on this machine (`sd`), then run `sd share` again.", err=True)
326
368
  raise typer.Exit(1)
369
+ if not _api("/api/share").get("strong_password"):
370
+ typer.echo(
371
+ f"Sharing puts the login page on the internet, so it needs a password of {auth.SHARE_MIN_PASSWORD}+ characters.\n"
372
+ "Change it in Settings (or, if it is already that long, log in once), then run `sd share` again.",
373
+ err=True,
374
+ )
375
+ raise typer.Exit(1)
327
376
  # the tunnel ends at our own loopback server, whose certificate (if any) is self-signed
328
377
  cmd = [exe, "tunnel", "--no-autoupdate", "--url", _url()] + (["--no-tls-verify"] if _scheme() == "https" else [])
329
378
  proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True, errors="replace")
@@ -335,6 +384,7 @@ def share():
335
384
  elif host and not shared and "Registered tunnel connection" in line:
336
385
  shared = f"https://{host}" + _api("/api/share", "POST", {"host": host})["path"]
337
386
  _show_share(shared)
387
+ _watch_share()
338
388
  except KeyboardInterrupt:
339
389
  pass
340
390
  finally:
@@ -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