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.
- shelldeck/__init__.py +0 -0
- shelldeck/__main__.py +4 -0
- shelldeck/addons/__init__.py +1 -0
- shelldeck/addons/fast_context.py +1346 -0
- shelldeck/auth.py +207 -0
- shelldeck/cli.py +587 -0
- shelldeck/db.py +794 -0
- shelldeck/gitgraph.py +150 -0
- shelldeck/integration/bash.sh +27 -0
- shelldeck/integration/shelldeck.fish +5 -0
- shelldeck/integration/shelldeck.ps1 +35 -0
- shelldeck/integration/zsh/.zshenv +6 -0
- shelldeck/integration/zsh/.zshrc +10 -0
- shelldeck/pty.py +352 -0
- shelldeck/runner.py +75 -0
- shelldeck/scheduler.py +283 -0
- shelldeck/server.py +1216 -0
- shelldeck/shells.py +174 -0
- shelldeck/static/app.css +2288 -0
- shelldeck/static/app.js +1863 -0
- shelldeck/static/gitgraph.js +142 -0
- shelldeck/static/history.js +172 -0
- shelldeck/static/icon-192.png +0 -0
- shelldeck/static/icon-32.png +0 -0
- shelldeck/static/icon-512.png +0 -0
- shelldeck/static/icon.svg +1 -0
- shelldeck/static/index.html +102 -0
- shelldeck/static/manifest.webmanifest +1 -0
- shelldeck/static/monitor.js +143 -0
- shelldeck/static/ui.js +316 -0
- shelldeck/static/vendor/LICENSE-xterm.txt +21 -0
- shelldeck/static/vendor/addon-fit.js +2 -0
- shelldeck/static/vendor/addon-search.js +2 -0
- shelldeck/static/vendor/addon-serialize.js +2 -0
- shelldeck/static/vendor/addon-web-links.js +2 -0
- shelldeck/static/vendor/addon-webgl.js +2 -0
- shelldeck/static/vendor/xterm.css +218 -0
- shelldeck/static/vendor/xterm.js +2 -0
- shelldeck/static/views.js +658 -0
- shelldeck/stats.py +128 -0
- shelldeck-0.0.1.dist-info/METADATA +254 -0
- shelldeck-0.0.1.dist-info/RECORD +45 -0
- shelldeck-0.0.1.dist-info/WHEEL +4 -0
- shelldeck-0.0.1.dist-info/entry_points.txt +3 -0
- 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,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
|
+
}
|