shelldeck 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. shelldeck/__init__.py +0 -0
  2. shelldeck/__main__.py +4 -0
  3. shelldeck/addons/__init__.py +1 -0
  4. shelldeck/addons/fast_context.py +1346 -0
  5. shelldeck/auth.py +207 -0
  6. shelldeck/cli.py +587 -0
  7. shelldeck/db.py +794 -0
  8. shelldeck/gitgraph.py +150 -0
  9. shelldeck/integration/bash.sh +27 -0
  10. shelldeck/integration/shelldeck.fish +5 -0
  11. shelldeck/integration/shelldeck.ps1 +35 -0
  12. shelldeck/integration/zsh/.zshenv +6 -0
  13. shelldeck/integration/zsh/.zshrc +10 -0
  14. shelldeck/pty.py +352 -0
  15. shelldeck/runner.py +75 -0
  16. shelldeck/scheduler.py +283 -0
  17. shelldeck/server.py +1216 -0
  18. shelldeck/shells.py +174 -0
  19. shelldeck/static/app.css +2288 -0
  20. shelldeck/static/app.js +1863 -0
  21. shelldeck/static/gitgraph.js +142 -0
  22. shelldeck/static/history.js +172 -0
  23. shelldeck/static/icon-192.png +0 -0
  24. shelldeck/static/icon-32.png +0 -0
  25. shelldeck/static/icon-512.png +0 -0
  26. shelldeck/static/icon.svg +1 -0
  27. shelldeck/static/index.html +102 -0
  28. shelldeck/static/manifest.webmanifest +1 -0
  29. shelldeck/static/monitor.js +143 -0
  30. shelldeck/static/ui.js +316 -0
  31. shelldeck/static/vendor/LICENSE-xterm.txt +21 -0
  32. shelldeck/static/vendor/addon-fit.js +2 -0
  33. shelldeck/static/vendor/addon-search.js +2 -0
  34. shelldeck/static/vendor/addon-serialize.js +2 -0
  35. shelldeck/static/vendor/addon-web-links.js +2 -0
  36. shelldeck/static/vendor/addon-webgl.js +2 -0
  37. shelldeck/static/vendor/xterm.css +218 -0
  38. shelldeck/static/vendor/xterm.js +2 -0
  39. shelldeck/static/views.js +658 -0
  40. shelldeck/stats.py +128 -0
  41. shelldeck-0.0.1.dist-info/METADATA +254 -0
  42. shelldeck-0.0.1.dist-info/RECORD +45 -0
  43. shelldeck-0.0.1.dist-info/WHEEL +4 -0
  44. shelldeck-0.0.1.dist-info/entry_points.txt +3 -0
  45. shelldeck-0.0.1.dist-info/licenses/LICENSE +21 -0
shelldeck/gitgraph.py ADDED
@@ -0,0 +1,150 @@
1
+ """Git commit graph for a project: log parsing, lane layout, checkout.
2
+
3
+ The lane-assignment algorithm mirrors what tig/gitk/vscode-git-graph do
4
+ internally: walk commits newest-first, keep a list of "lanes" each
5
+ waiting for a specific next commit hash, and let a commit take over the
6
+ leftmost lane already waiting for it (or open a new one). Merge commits
7
+ add lanes for their extra parents, reusing freed columns to keep the
8
+ graph narrow.
9
+ """
10
+
11
+ import subprocess
12
+ import sys
13
+ from pathlib import Path
14
+
15
+ NO_WINDOW = subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0
16
+ FIELD_SEP = "\x1f"
17
+ RECORD_SEP = "\x1e"
18
+ FORMAT = FIELD_SEP.join(["%H", "%h", "%P", "%an", "%ad", "%s", "%D"]) + RECORD_SEP
19
+ # mirrors PROJECT_COLORS in app.js
20
+ PALETTE = ["#60a5fa", "#f472b6", "#34d399", "#fbbf24", "#a78bfa", "#f87171", "#22d3ee", "#fb923c"]
21
+
22
+
23
+ class GitError(Exception):
24
+ pass
25
+
26
+
27
+ def _run(path: str, args: list[str], timeout: int = 15) -> str:
28
+ try:
29
+ proc = subprocess.run(
30
+ ["git", *args],
31
+ cwd=path,
32
+ capture_output=True,
33
+ text=True,
34
+ errors="replace",
35
+ timeout=timeout,
36
+ check=False,
37
+ creationflags=NO_WINDOW,
38
+ )
39
+ except (OSError, subprocess.TimeoutExpired) as e:
40
+ raise GitError(str(e)) from e
41
+ if proc.returncode != 0:
42
+ raise GitError((proc.stderr or "").strip() or f"git {args[0]} failed")
43
+ return proc.stdout
44
+
45
+
46
+ def parse_log(text: str) -> list[dict]:
47
+ """Parse `git log --format=FORMAT` output into raw commit dicts."""
48
+ commits = []
49
+ for record in text.split(RECORD_SEP):
50
+ record = record.strip("\n")
51
+ if not record.strip():
52
+ continue
53
+ h, short, parents, author, date, subject, refs = record.split(FIELD_SEP)
54
+ commits.append({
55
+ "hash": h,
56
+ "short": short,
57
+ "parents": parents.split() if parents else [],
58
+ "author": author,
59
+ "date": date,
60
+ "subject": subject,
61
+ "refs": [r.strip() for r in refs.split(",") if r.strip()],
62
+ })
63
+ return commits
64
+
65
+
66
+ def build_graph(commits: list[dict]) -> None:
67
+ """Assign `column`, `color`, `enter`, `exit`, and `through` to each commit in place.
68
+
69
+ Each row is drawn in two halves, matching how vscode-git-graph/gitk render a row:
70
+ - `enter`: lanes converging into this commit's node from above (`{from_col, color}`,
71
+ curved unless from_col == column).
72
+ - `exit`: lanes diverging from this commit's node downward, one per parent
73
+ (`{to_col, color}`, curved unless to_col == column).
74
+ - `through`: unrelated lanes that just pass straight through this row (`{col, color}`).
75
+ """
76
+ lanes: dict[int, dict] = {} # column -> {"next": hash, "color": color}
77
+ next_color = 0
78
+
79
+ def new_color() -> str:
80
+ nonlocal next_color
81
+ c = PALETTE[next_color % len(PALETTE)]
82
+ next_color += 1
83
+ return c
84
+
85
+ def free_column() -> int:
86
+ return next((c for c in range(len(lanes) + 1) if c not in lanes), 0)
87
+
88
+ for commit in commits:
89
+ h = commit["hash"]
90
+ matches = sorted(col for col, lane in lanes.items() if lane["next"] == h)
91
+ if matches:
92
+ col, color = matches[0], lanes[matches[0]]["color"]
93
+ else:
94
+ col, color = free_column(), new_color()
95
+
96
+ enter, through = [], []
97
+ for other_col, lane in list(lanes.items()):
98
+ if other_col in matches:
99
+ enter.append({"from_col": other_col, "color": lane["color"]})
100
+ del lanes[other_col]
101
+ elif other_col != col:
102
+ through.append({"col": other_col, "color": lane["color"]})
103
+
104
+ exit_ = []
105
+ parents = commit["parents"]
106
+ if parents:
107
+ lanes[col] = {"next": parents[0], "color": color}
108
+ exit_.append({"to_col": col, "color": color})
109
+ for parent in parents[1:]:
110
+ free, pcolor = free_column(), new_color()
111
+ lanes[free] = {"next": parent, "color": pcolor}
112
+ exit_.append({"to_col": free, "color": pcolor})
113
+ else:
114
+ lanes.pop(col, None)
115
+
116
+ commit["column"] = col
117
+ commit["color"] = color
118
+ commit["enter"] = enter
119
+ commit["exit"] = exit_
120
+ commit["through"] = through
121
+
122
+
123
+ def get_log(path: str, max_commits: int = 300) -> dict:
124
+ """Commit graph for the repo at `path`: local branches + HEAD."""
125
+ try:
126
+ branch = _run(path, ["rev-parse", "--abbrev-ref", "HEAD"], timeout=5).strip()
127
+ except GitError: # unborn HEAD: a fresh `git init` with no commits
128
+ return {"commits": [], "current_branch": "", "has_more": False}
129
+ raw = _run(path, ["log", "HEAD", "--branches", "--date-order", f"--format={FORMAT}",
130
+ "--date=format:%Y-%m-%dT%H:%M:%S", "-n", str(max_commits + 1)])
131
+ commits = parse_log(raw)
132
+ has_more = len(commits) > max_commits
133
+ commits = commits[:max_commits]
134
+ build_graph(commits)
135
+ return {"commits": commits, "current_branch": branch, "has_more": has_more}
136
+
137
+
138
+ def checkout(path: str, ref: str) -> tuple[bool, str]:
139
+ """`git checkout <ref>`. git itself refuses an unsafe checkout on a dirty tree."""
140
+ if ref.startswith("-"): # would be parsed as an option (e.g. --force)
141
+ return False, "invalid ref"
142
+ try:
143
+ out = _run(path, ["checkout", ref])
144
+ return True, out.strip()
145
+ except GitError as e:
146
+ return False, str(e)
147
+
148
+
149
+ def has_git(path: str) -> bool:
150
+ return (Path(path) / ".git").exists()
@@ -0,0 +1,27 @@
1
+ # shelldeck shell integration for bash (used as --rcfile).
2
+ # Loads the user's normal startup files, then reports prompt/exit/cwd via OSC sequences.
3
+ if [ -n "$SHELLDECK_LOGIN" ]; then
4
+ unset SHELLDECK_LOGIN
5
+ [ -f /etc/profile ] && . /etc/profile
6
+ for __sd_f in ~/.bash_profile ~/.bash_login ~/.profile; do
7
+ if [ -f "$__sd_f" ]; then . "$__sd_f"; break; fi
8
+ done
9
+ unset __sd_f
10
+ else
11
+ [ -f ~/.bashrc ] && . ~/.bashrc
12
+ fi
13
+
14
+ __sd_prompt() {
15
+ local ec=$? d=$PWD
16
+ # Git Bash: report /c/x as C:\x so the server can reopen the folder
17
+ if [ -n "$MSYSTEM" ] && [[ $d =~ ^/([a-zA-Z])(/.*)?$ ]]; then
18
+ d="${BASH_REMATCH[1]^^}:${BASH_REMATCH[2]:-/}"
19
+ d=${d//\//\\}
20
+ fi
21
+ printf '\033]133;D;%s\007\033]633;P;Cwd=%s\007' "$ec" "$d"
22
+ return $ec
23
+ }
24
+ if [[ "$PROMPT_COMMAND" != *__sd_prompt* ]]; then
25
+ PROMPT_COMMAND="__sd_prompt${PROMPT_COMMAND:+;$PROMPT_COMMAND}"
26
+ fi
27
+ PS1="\[\033]133;A\007\]$PS1\[\033]133;B\007\]"
@@ -0,0 +1,5 @@
1
+ # shelldeck shell integration for fish: report prompt/exit/cwd via OSC sequences.
2
+ function __sd_prompt --on-event fish_prompt
3
+ set -l ec $status
4
+ printf '\e]133;D;%s\a\e]633;P;Cwd=%s\a\e]133;A\a' $ec $PWD
5
+ end
@@ -0,0 +1,35 @@
1
+ # shelldeck shell integration for pwsh / Windows PowerShell.
2
+ # Wraps the user's prompt (after their profile ran) to report, via OSC sequences:
3
+ # 633;E;<cmd> last command line 133;D;<code> command finished
4
+ # 633;P;Cwd= current directory 133;A / 133;B prompt start / end
5
+ if ($global:__sdLoaded) { return }
6
+ $global:__sdLoaded = $true
7
+ $global:__sdOrigPrompt = $function:prompt
8
+ $global:__sdLastId = -1
9
+ $global:__sdPrevCode = $global:LASTEXITCODE
10
+
11
+ function global:prompt {
12
+ $ok = $?
13
+ $code = $global:LASTEXITCODE
14
+ $e = [char]27
15
+ $b = [char]7
16
+ $out = ""
17
+ $h = Get-History -Count 1
18
+ if ($global:__sdLastId -eq -1) {
19
+ # first prompt: history holds this integration script (-EncodedCommand), not a user command
20
+ $global:__sdLastId = if ($h) { $h.Id } else { 0 }
21
+ } elseif ($h -and $h.Id -ne $global:__sdLastId) {
22
+ $global:__sdLastId = $h.Id
23
+ # cmdlet errors leave $LASTEXITCODE stale, so only trust it when the command changed it
24
+ $ec = if ($ok) { 0 } elseif ($code -and $code -ne $global:__sdPrevCode) { $code } else { 1 }
25
+ $cmd = $h.CommandLine -replace '[\x00-\x1f]', ' '
26
+ $out += "$e]633;E;$cmd$b$e]133;D;$ec$b"
27
+ }
28
+ $loc = Get-Location
29
+ if ($loc.Provider.Name -eq 'FileSystem') { $out += "$e]633;P;Cwd=$($loc.ProviderPath)$b" }
30
+ $out += "$e]133;A$b"
31
+ $global:__sdPrevCode = $code
32
+ $p = & $global:__sdOrigPrompt
33
+ $global:LASTEXITCODE = $code
34
+ "$out$p$e]133;B$b"
35
+ }
@@ -0,0 +1,6 @@
1
+ # shelldeck: zsh reads startup files from $ZDOTDIR; restore the user's and chain to theirs.
2
+ __sd_zdotdir="${ZDOTDIR}"
3
+ ZDOTDIR="${SHELLDECK_USER_ZDOTDIR:-$HOME}"
4
+ [ -f "$ZDOTDIR/.zshenv" ] && . "$ZDOTDIR/.zshenv"
5
+ __sd_user_zdotdir="$ZDOTDIR"
6
+ ZDOTDIR="$__sd_zdotdir"
@@ -0,0 +1,10 @@
1
+ # shelldeck shell integration for zsh: load the user's .zshrc, then report prompt/exit/cwd.
2
+ ZDOTDIR="$__sd_user_zdotdir"
3
+ [ -f "$ZDOTDIR/.zshrc" ] && . "$ZDOTDIR/.zshrc"
4
+
5
+ __sd_precmd() {
6
+ local ec=$?
7
+ printf '\033]133;D;%s\007\033]633;P;Cwd=%s\007\033]133;A\007' "$ec" "$PWD"
8
+ }
9
+ autoload -Uz add-zsh-hook
10
+ add-zsh-hook precmd __sd_precmd
shelldeck/pty.py ADDED
@@ -0,0 +1,352 @@
1
+ """Pseudo-terminals: ConPTY (pywinpty) on Windows, stdlib pty on Linux/macOS."""
2
+
3
+ import codecs
4
+ import logging
5
+ import os
6
+ import signal
7
+ import subprocess
8
+ import sys
9
+ import threading
10
+ import time
11
+ from collections import deque
12
+ from pathlib import Path
13
+
14
+ from . import shells
15
+
16
+ log = logging.getLogger(__name__)
17
+
18
+ SCROLLBACK_CHARS = 256 * 1024
19
+
20
+
21
+ class Scrollback:
22
+ """Last `limit` chars of output, so a reconnecting client can redraw the screen."""
23
+
24
+ def __init__(self, limit: int = SCROLLBACK_CHARS):
25
+ self.limit = limit
26
+ self.chunks: deque[str] = deque()
27
+ self.size = 0
28
+ self.dirty = False
29
+ self.total = 0 # chars ever added, to tell how stale a snapshot is
30
+ self.snapshot: tuple[int, str] | None = None # (total at snapshot, client-rendered screen)
31
+
32
+ def add(self, data: str) -> None:
33
+ self.dirty = True
34
+ self.total += len(data)
35
+ self.chunks.append(data)
36
+ self.size += len(data)
37
+ while self.size - len(self.chunks[0]) >= self.limit:
38
+ self.size -= len(self.chunks.popleft())
39
+ if (extra := self.size - self.limit) > 0:
40
+ self.chunks[0] = self.chunks[0][extra:]
41
+ self.size = self.limit
42
+
43
+ def text(self) -> str:
44
+ return "".join(self.chunks)
45
+
46
+ def persisted(self) -> str:
47
+ """What to save for a restart: the client's rendered snapshot while it is fresh.
48
+
49
+ Raw ConPTY output positions the cursor absolutely, so it replays garbled on a new screen.
50
+ ponytail: staleness = output since the snapshot; a server-side emulator would drop this."""
51
+ if self.snapshot and self.total - self.snapshot[0] < 4096:
52
+ return self.snapshot[1]
53
+ return self.text()
54
+
55
+
56
+ # --------------------------------------------------------------- backends
57
+ # Both expose: read() -> str (non-blocking), write(str), resize(rows, cols),
58
+ # isalive() -> bool, kill().
59
+
60
+
61
+ class WinProc:
62
+ def __init__(self, argv: list[str], cwd: str | None, env: dict[str, str], rows: int, cols: int):
63
+ import ctypes
64
+
65
+ from winpty import PTY, Backend
66
+
67
+ def short(path: str) -> str:
68
+ if " " not in path:
69
+ return path
70
+ buf = ctypes.create_unicode_buffer(512)
71
+ n = ctypes.windll.kernel32.GetShortPathNameW(path, buf, 512)
72
+ return buf.value if n and buf.value else path
73
+
74
+ # A detached server (CREATE_NEW_PROCESS_GROUP) ignores Ctrl+C and every shell inherits
75
+ # that, so ^C would never interrupt a running command. Re-enable it before spawning.
76
+ ctypes.windll.kernel32.SetConsoleCtrlHandler(None, False)
77
+ self.pty = PTY(cols, rows, backend=Backend.ConPTY)
78
+ cmdline = " " + subprocess.list2cmdline(argv[1:]) if len(argv) > 1 else None
79
+ env_str = "\0".join(f"{k}={v}" for k, v in env.items()) + "\0"
80
+ self.pty.spawn(short(argv[0]), cwd=cwd, env=env_str, cmdline=cmdline)
81
+ self.pid = self.pty.pid
82
+
83
+ def read(self) -> str:
84
+ try:
85
+ return self.pty.read(blocking=False)
86
+ except Exception: # noqa: BLE001 - winpty raises bare errors at EOF
87
+ return ""
88
+
89
+ def write(self, data: str) -> None:
90
+ self.pty.write(data)
91
+
92
+ def resize(self, rows: int, cols: int) -> None:
93
+ self.pty.set_size(cols, rows)
94
+
95
+ def isalive(self) -> bool:
96
+ return self.pty.isalive()
97
+
98
+ def kill(self) -> None:
99
+ pid = self.pty.pid
100
+ os.kill(pid, signal.SIGINT)
101
+ time.sleep(0.1)
102
+ if self.pty.isalive():
103
+ os.kill(pid, signal.SIGTERM)
104
+
105
+
106
+ class PosixProc:
107
+ def __init__(self, argv: list[str], cwd: str | None, env: dict[str, str], rows: int, cols: int):
108
+ import pty
109
+
110
+ self.master, slave = pty.openpty()
111
+ self._winsize(slave, rows, cols)
112
+ env.setdefault("TERM", "xterm-256color")
113
+ env.setdefault("COLORTERM", "truecolor")
114
+ self.proc = subprocess.Popen(
115
+ argv,
116
+ stdin=slave,
117
+ stdout=slave,
118
+ stderr=slave,
119
+ cwd=cwd,
120
+ env=env,
121
+ start_new_session=True, # own session + controlling tty
122
+ close_fds=True,
123
+ )
124
+ os.close(slave)
125
+ self.pid = self.proc.pid
126
+ os.set_blocking(self.master, False)
127
+ self.decoder = codecs.getincrementaldecoder("utf-8")("replace")
128
+ self.eof = False
129
+
130
+ @staticmethod
131
+ def _winsize(fd: int, rows: int, cols: int) -> None:
132
+ import fcntl
133
+ import struct
134
+ import termios
135
+
136
+ fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0))
137
+
138
+ def read(self) -> str:
139
+ if self.eof:
140
+ return ""
141
+ try:
142
+ data = os.read(self.master, 65536)
143
+ except BlockingIOError:
144
+ return ""
145
+ except OSError: # EIO: slave side closed
146
+ data = b""
147
+ if not data:
148
+ self.eof = True
149
+ return self.decoder.decode(data, final=not data)
150
+
151
+ def write(self, data: str) -> None:
152
+ os.write(self.master, data.encode())
153
+
154
+ def resize(self, rows: int, cols: int) -> None:
155
+ self._winsize(self.master, rows, cols)
156
+
157
+ def isalive(self) -> bool:
158
+ return not self.eof and self.proc.poll() is None
159
+
160
+ def kill(self) -> None:
161
+ try:
162
+ os.killpg(self.proc.pid, signal.SIGHUP)
163
+ except OSError:
164
+ pass
165
+ try:
166
+ self.proc.wait(timeout=0.5)
167
+ except subprocess.TimeoutExpired:
168
+ os.killpg(self.proc.pid, signal.SIGKILL)
169
+ os.close(self.master)
170
+
171
+
172
+ Proc = WinProc if sys.platform == "win32" else PosixProc
173
+
174
+
175
+ # ---------------------------------------------------------------- manager
176
+
177
+
178
+ # after replayed scrollback from a previous run: leave alt screen (1047: no cursor restore), reset style, show cursor
179
+ RESTORED = "\x1b[0m\x1b[?1047l\x1b[?25h\r\n\x1b[J\x1b[2m--- restored {when} ---\x1b[0m"
180
+
181
+
182
+ def _push_to_scrollback(rows: int) -> str:
183
+ """Scroll everything on screen into scrollback and home the cursor.
184
+
185
+ A new ConPTY paints at absolute rows of a screen it thinks is blank, which would
186
+ overwrite restored lines still in view."""
187
+ return "\x1b[999B" + "\r\n" * rows + "\x1b[H"
188
+
189
+
190
+ class PtyManager:
191
+ def __init__(self, store: Path | None = None):
192
+ self.procs: dict[str, Proc] = {}
193
+ self.scrollback: dict[str, Scrollback] = {}
194
+ self.wakes: dict[str, threading.Event] = {}
195
+ self.store = store # scrollback files, so a restart/reboot keeps terminal history
196
+
197
+ def _file(self, session_id: str) -> Path | None:
198
+ if not self.store or not session_id.replace("-", "").isalnum():
199
+ return None
200
+ return self.store / f"{session_id}.log"
201
+
202
+ def _restore(self, session_id: str, rows: int = 24) -> Scrollback:
203
+ sb = Scrollback()
204
+ f = self._file(session_id)
205
+ if f and f.exists():
206
+ old = f.read_bytes().decode("utf-8", errors="replace") # bytes: text mode drops the CR in CRLF
207
+ if old:
208
+ when = time.strftime("%Y-%m-%d %H:%M", time.localtime(f.stat().st_mtime))
209
+ sb.add(old + RESTORED.format(when=when) + _push_to_scrollback(rows))
210
+ return sb
211
+
212
+ def save(self) -> None:
213
+ """Write changed scrollbacks to disk."""
214
+ if not self.store:
215
+ return
216
+ self.store.mkdir(parents=True, exist_ok=True)
217
+ for sid, sb in list(self.scrollback.items()):
218
+ if sb.dirty and (f := self._file(sid)):
219
+ sb.dirty = False
220
+ f.write_bytes(sb.persisted().encode("utf-8"))
221
+
222
+ def snapshot(self, session_id: str, data: str) -> None:
223
+ """Store the client's serialized screen (xterm serialize addon) for the next restart."""
224
+ if sb := self.scrollback.get(session_id):
225
+ sb.snapshot = (sb.total, data[-SCROLLBACK_CHARS:])
226
+ sb.dirty = True
227
+
228
+ def searchable(self, session_id: str) -> str:
229
+ """Terminal output as saved for restore: from memory while running, else from disk."""
230
+ if sb := self.scrollback.get(session_id):
231
+ return sb.persisted()
232
+ f = self._file(session_id)
233
+ return f.read_bytes().decode("utf-8", errors="replace") if f and f.exists() else ""
234
+
235
+ def forget(self, session_id: str) -> None:
236
+ if f := self._file(session_id):
237
+ f.unlink(missing_ok=True)
238
+
239
+ def prune(self, keep: set[str]) -> None:
240
+ """Delete saved scrollback for sessions that no longer exist."""
241
+ if self.store and self.store.exists():
242
+ for f in self.store.glob("*.log"):
243
+ if f.stem not in keep:
244
+ f.unlink(missing_ok=True)
245
+
246
+ def create(
247
+ self,
248
+ session_id: str,
249
+ cwd: str | None = None,
250
+ shell: str | None = None,
251
+ rows: int = 24,
252
+ cols: int = 120,
253
+ distro: str | None = None,
254
+ ) -> Proc:
255
+ proc = self.get(session_id)
256
+ if proc:
257
+ return proc
258
+ start_dir = cwd if (cwd and Path(cwd).is_dir()) else None
259
+ argv, extra = shells.with_integration(shells.interactive_argv(shell or "", distro, start_dir))
260
+ env = os.environ.copy() | extra
261
+ env["SHELLDECK_SESSION_ID"] = session_id
262
+ proc = Proc(argv, start_dir, env, rows, cols)
263
+ self.procs[session_id] = proc
264
+ self.scrollback[session_id] = self._restore(session_id, rows)
265
+ log.info("spawned %s for session %s in %s", argv, session_id, start_dir)
266
+ return proc
267
+
268
+ def get(self, session_id: str) -> Proc | None:
269
+ proc = self.procs.get(session_id)
270
+ if proc and not proc.isalive():
271
+ return None
272
+ return proc
273
+
274
+ def history(self, session_id: str) -> str:
275
+ sb = self.scrollback.get(session_id)
276
+ return sb.text() if sb else ""
277
+
278
+ def stream(self, session_id: str, emit) -> None:
279
+ """Read the session's output on its own thread, calling emit(str) per chunk and emit(None) at exit.
280
+
281
+ The thread polls non-blocking reads: pywinpty's blocking read holds data back and never
282
+ returns at EOF. After output or a keystroke (write() wakes it) it polls with time.sleep,
283
+ which is ~1ms on Windows; asyncio sleeps and Event.wait round up to ~16ms there. Once idle
284
+ it waits on the event instead, so a quiet shell costs almost no CPU."""
285
+ proc = self.procs.get(session_id)
286
+ wake = self.wakes.setdefault(session_id, threading.Event())
287
+
288
+ def run() -> None:
289
+ idle = 0
290
+ while proc is not None:
291
+ try:
292
+ data = proc.read()
293
+ except OSError: # POSIX fd closed by kill()
294
+ break
295
+ if data:
296
+ idle = 0
297
+ emit(data)
298
+ continue
299
+ if not proc.isalive():
300
+ break
301
+ idle += 1
302
+ if idle <= 50: # just active or typed into: ~1ms polls for ~75ms
303
+ time.sleep(0.001)
304
+ elif wake.wait(0.02): # idle: Event.wait is ~16ms-grained on Windows, fine here
305
+ wake.clear()
306
+ idle = 0
307
+ emit(None)
308
+
309
+ threading.Thread(target=run, name=f"pty-{session_id}", daemon=True).start()
310
+
311
+ def record(self, session_id: str, data: str) -> None:
312
+ if sb := self.scrollback.get(session_id):
313
+ sb.add(data)
314
+
315
+ def write(self, session_id: str, data: str) -> bool:
316
+ proc = self.get(session_id)
317
+ if not proc:
318
+ return False
319
+ try:
320
+ proc.write(data)
321
+ if wake := self.wakes.get(session_id):
322
+ wake.set()
323
+ return True
324
+ except Exception: # noqa: BLE001
325
+ log.warning("write failed for session %s", session_id, exc_info=True)
326
+ return False
327
+
328
+ def resize(self, session_id: str, rows: int, cols: int) -> bool:
329
+ proc = self.get(session_id)
330
+ if not proc:
331
+ return False
332
+ try:
333
+ proc.resize(rows, cols)
334
+ return True
335
+ except Exception: # noqa: BLE001
336
+ log.warning("resize failed for session %s", session_id, exc_info=True)
337
+ return False
338
+
339
+ def list_alive(self) -> list[str]:
340
+ return [sid for sid in list(self.procs) if self.get(sid)]
341
+
342
+ def terminate(self, session_id: str) -> None:
343
+ self.scrollback.pop(session_id, None)
344
+ if wake := self.wakes.pop(session_id, None):
345
+ wake.set()
346
+ proc = self.procs.pop(session_id, None)
347
+ if not proc:
348
+ return
349
+ try:
350
+ proc.kill() # also releases the pty fd on POSIX, even if the shell already exited
351
+ except OSError:
352
+ log.warning("terminate failed for session %s", session_id, exc_info=True)
shelldeck/runner.py ADDED
@@ -0,0 +1,75 @@
1
+ import subprocess
2
+ import time
3
+ from pathlib import Path
4
+
5
+ from . import db, shells
6
+
7
+
8
+ def run_command(
9
+ project_id: str,
10
+ command: str,
11
+ shell: str | None = None,
12
+ timeout: int = 60,
13
+ ) -> dict:
14
+ started = time.perf_counter()
15
+ project = db.get_project(project_id)
16
+ if not project:
17
+ return {
18
+ "status": "failed",
19
+ "exit_code": None,
20
+ "output": "",
21
+ "error": "project not found",
22
+ "duration_ms": 0,
23
+ }
24
+
25
+ cwd = project.get("path")
26
+ if not cwd or not Path(cwd).exists():
27
+ return {
28
+ "status": "failed",
29
+ "exit_code": None,
30
+ "output": "",
31
+ "error": "project directory does not exist",
32
+ "duration_ms": 0,
33
+ }
34
+
35
+ shell = shell or db.get_setting("default_shell", shells.default_kind())
36
+ args = shells.command_argv(shell, command, db.get_setting("wsl_distro"), cwd)
37
+
38
+ try:
39
+ proc = subprocess.run(
40
+ args,
41
+ cwd=cwd,
42
+ capture_output=True,
43
+ text=True,
44
+ errors="replace",
45
+ timeout=timeout,
46
+ check=False,
47
+ )
48
+ output = (proc.stdout or "") + (proc.stderr or "")
49
+ exit_code = proc.returncode
50
+ status = "success" if exit_code == 0 else "failed"
51
+ error = None if status == "success" else f"exit code {exit_code}"
52
+ except subprocess.TimeoutExpired as exc:
53
+ # TimeoutExpired carries bytes even with text=True
54
+ output = "".join(
55
+ x.decode(errors="replace") if isinstance(x, bytes) else x
56
+ for x in (exc.stdout or "", exc.stderr or "")
57
+ )
58
+ exit_code = None
59
+ status = "failed"
60
+ error = f"timeout after {timeout}s"
61
+ except OSError as exc:
62
+ output = ""
63
+ exit_code = None
64
+ status = "failed"
65
+ error = str(exc)
66
+
67
+ duration_ms = int((time.perf_counter() - started) * 1000)
68
+
69
+ return {
70
+ "status": status,
71
+ "exit_code": exit_code,
72
+ "output": output,
73
+ "error": error,
74
+ "duration_ms": duration_ms,
75
+ }