@spunto/build 0.4.1 → 0.5.1
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.
- package/README.md +23 -0
- package/package.json +6 -2
- package/src/index.ts +1 -0
- package/src/script/setup-script.ts +56 -19
- package/src/terminal/dtach.ts +302 -0
- package/src/terminal/index.ts +22 -0
package/README.md
CHANGED
|
@@ -148,6 +148,29 @@ time a vendor reshuffles a field.
|
|
|
148
148
|
Here for the reason that inverts the rest of the package: **nothing has forked this yet.** Putting
|
|
149
149
|
it in the shared package now costs nothing and means the second product never writes its own.
|
|
150
150
|
|
|
151
|
+
### `@spunto/build/terminal` — the half of a persistent terminal that is a string
|
|
152
|
+
|
|
153
|
+
A worker's terminal is an exec attached to a session that outlives it, so closing a browser tab
|
|
154
|
+
doesn't kill a build. The attaching is I/O and stays with whoever owns the Docker socket; the shell
|
|
155
|
+
that gets attached, the layout it writes into, and the parsing of what it prints back are here.
|
|
156
|
+
|
|
157
|
+
```ts
|
|
158
|
+
import { sanitizeSessionName, buildDtachCommand, parseSessionList } from "@spunto/build/terminal"
|
|
159
|
+
|
|
160
|
+
const name = sanitizeSessionName(fromQuery) // every name MUST come through this
|
|
161
|
+
await exec(container, ["/bin/sh", "-c", buildDtachCommand()], { env: { MP_TERM_SESSION: name } })
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
**dtach, not tmux.** tmux's only knob for "the wheel scrolls history" is `mouse on`, which also
|
|
165
|
+
makes it swallow every drag — the frontend then has to forge Shift-drags to get a native selection
|
|
166
|
+
back. dtach does only persistence, so the emulator keeps the mouse, its scrollback and its search.
|
|
167
|
+
What dtach doesn't do is remember the screen, so the session is recorded inside the container with
|
|
168
|
+
`script(1)` and the tail replayed on attach.
|
|
169
|
+
|
|
170
|
+
`sanitizeSessionName` is a security boundary, not tidiness: names are interpolated into the shell
|
|
171
|
+
scripts this module builds, so anything that doesn't come through it is a command injection into
|
|
172
|
+
someone's container.
|
|
173
|
+
|
|
151
174
|
## The rule
|
|
152
175
|
|
|
153
176
|
A module belongs in this package only if it imports **no** ORM schema, **no** HTTP framework, **no**
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spunto/build",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Spunto's shared Build engine
|
|
3
|
+
"version": "0.5.1",
|
|
4
|
+
"description": "Spunto's shared Build engine — the devcontainer image protocol and VS Code extension registry clients, with no database, no HTTP framework and no UI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"homepage": "https://spunto.net",
|
|
@@ -55,6 +55,10 @@
|
|
|
55
55
|
"types": "./src/script/index.ts",
|
|
56
56
|
"import": "./src/script/index.ts"
|
|
57
57
|
},
|
|
58
|
+
"./terminal": {
|
|
59
|
+
"types": "./src/terminal/index.ts",
|
|
60
|
+
"import": "./src/terminal/index.ts"
|
|
61
|
+
},
|
|
58
62
|
"./agent-stream": {
|
|
59
63
|
"types": "./src/agent-stream/index.ts",
|
|
60
64
|
"import": "./src/agent-stream/index.ts"
|
package/src/index.ts
CHANGED
|
@@ -35,6 +35,37 @@ function shQuote(v: string): string {
|
|
|
35
35
|
return `'${v.replace(/'/g, `'\\''`)}'`
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Runs a command as the workspace user instead of root.
|
|
40
|
+
*
|
|
41
|
+
* The setup script itself is the container's CMD, so it runs as root — it has to, to chown
|
|
42
|
+
* /workspace and write under /etc. Everything it *clones*, though, belongs to the user, and
|
|
43
|
+
* cloning as root has cost us twice: once as `npm install` failing with EACCES on a root-owned
|
|
44
|
+
* node_modules (hence the re-chown after the repo clones), and once as a private dotfiles repo
|
|
45
|
+
* that authenticated with no key at all, because root has no `~/.ssh` and the identity was only
|
|
46
|
+
* named on the repo clones.
|
|
47
|
+
*
|
|
48
|
+
* `su` gives the command `HOME=/home/vscode`, which is what makes ssh pick up the `~/.ssh/config`
|
|
49
|
+
* written in the credentials step — so a clone over SSH needs nothing else to find the user's key.
|
|
50
|
+
*/
|
|
51
|
+
function asUser(username: string, cmd: string): string {
|
|
52
|
+
return `su ${username} -c ${shQuote(cmd)}`
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A dotfiles setting, as a clonable URL.
|
|
57
|
+
*
|
|
58
|
+
* Anything already naming a transport is left alone; only a bare `owner/repo` shorthand is
|
|
59
|
+
* expanded against GitHub. The previous test — `startsWith("http") || startsWith("git@")` — turned
|
|
60
|
+
* `ssh://git@host/me/dotfiles.git` into `https://github.com/ssh://git@host/me/dotfiles.git`.
|
|
61
|
+
*/
|
|
62
|
+
function normalizeDotfilesUrl(raw: string): string {
|
|
63
|
+
const v = raw.trim()
|
|
64
|
+
return /^(https?:\/\/|ssh:\/\/|git:\/\/|file:\/\/|[^/\s]+@[^/\s]+:)/.test(v)
|
|
65
|
+
? v
|
|
66
|
+
: `https://github.com/${v.replace(/^\/+|\/+$/g, "")}`
|
|
67
|
+
}
|
|
68
|
+
|
|
38
69
|
/**
|
|
39
70
|
* `export EXTENSIONS_GALLERY=…`, or nothing when the org is on the default (Open VSX) registry.
|
|
40
71
|
*
|
|
@@ -105,11 +136,13 @@ function cloneRepoBlock(params: {
|
|
|
105
136
|
index: number
|
|
106
137
|
total: number
|
|
107
138
|
homeDir: string
|
|
139
|
+
/** Who the checkout belongs to — the clone runs as them. */
|
|
140
|
+
username: string
|
|
108
141
|
branch?: string
|
|
109
142
|
githubInstallationTokens?: Record<string, string>
|
|
110
143
|
userSshPrivateKey?: string
|
|
111
144
|
}): { header: string; lines: string[] } {
|
|
112
|
-
const { repo: r, index, total, homeDir, branch, githubInstallationTokens, userSshPrivateKey } = params
|
|
145
|
+
const { repo: r, index, total, homeDir, username, branch, githubInstallationTokens, userSshPrivateKey } = params
|
|
113
146
|
// Pick the installation token for this repo's owner. A GitHub App installation only grants
|
|
114
147
|
// access to repos owned by its account, so the owner login (== installation accountLogin)
|
|
115
148
|
// uniquely selects the right token among the org's connected installations. Covers legacy
|
|
@@ -117,13 +150,16 @@ function cloneRepoBlock(params: {
|
|
|
117
150
|
const owner = (r.project.split("/")[0] ?? "").toLowerCase()
|
|
118
151
|
const repoToken = githubInstallationTokens?.[owner]
|
|
119
152
|
const b = branch ? ` --branch ${shQuote(branch)}` : ""
|
|
120
|
-
const
|
|
153
|
+
const rawCloneCmd = r.provider === "git" && r.cloneUrl
|
|
121
154
|
? `GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i ${homeDir}/.ssh/mp_deploy_key" git clone${b} ${shQuote(r.cloneUrl)} /workspace/${r.workspacePath}`
|
|
122
155
|
: r.provider === "github" && repoToken
|
|
123
156
|
? `git clone${b} https://x-access-token:${repoToken}@github.com/${r.project}.git /workspace/${r.workspacePath} && git -C /workspace/${r.workspacePath} remote set-url origin git@github.com:${r.project}.git`
|
|
124
157
|
: r.provider === "github" && userSshPrivateKey
|
|
125
158
|
? `GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i ${homeDir}/.ssh/mp_user_key" git clone${b} git@github.com:${r.project}.git /workspace/${r.workspacePath}`
|
|
126
159
|
: `git clone${b} https://github.com/${r.project} /workspace/${r.workspacePath}`
|
|
160
|
+
// Cloned as the user, not as root: /workspace is already theirs (the ownership step runs
|
|
161
|
+
// first), so the checkout lands with the right owner and needs no chown afterwards.
|
|
162
|
+
const cloneCmd = asUser(username, rawCloneCmd)
|
|
127
163
|
|
|
128
164
|
// Single-quoted only when a (user-provided) branch is interpolated; the plain form is kept
|
|
129
165
|
// verbatim for the no-branch case so the generated script is unchanged there.
|
|
@@ -851,23 +887,23 @@ export function buildSetupScript(params: SetupScriptParams): { script: string }
|
|
|
851
887
|
|
|
852
888
|
// ── 4. Dotfiles ───────────────────────────────────────────────────────────
|
|
853
889
|
if (dotfilesRepo) {
|
|
854
|
-
const dotfilesUrl =
|
|
855
|
-
? dotfilesRepo
|
|
856
|
-
: `https://github.com/${dotfilesRepo}`
|
|
890
|
+
const dotfilesUrl = normalizeDotfilesUrl(dotfilesRepo)
|
|
857
891
|
push(...banner("SETUP: DOTFILES"))
|
|
858
892
|
mpAt(mkStatus("dotfiles", allReposPending, pc0, null), "dotfiles")
|
|
859
893
|
push(
|
|
860
894
|
`echo "Cloning dotfiles from ${dotfilesUrl}..."`,
|
|
861
895
|
`set +e`,
|
|
862
|
-
|
|
896
|
+
// As the user, like every other clone: root has no `~/.ssh`, so a private dotfiles repo
|
|
897
|
+
// over SSH would authenticate with no key at all.
|
|
898
|
+
`${asUser(username, `git clone ${shQuote(dotfilesUrl)} ${homeDir}/dotfiles`)} 2>&1`,
|
|
863
899
|
`_DOTS_EXIT=$?`,
|
|
864
900
|
`set -e`,
|
|
865
901
|
`if [ $_DOTS_EXIT -ne 0 ]; then`,
|
|
866
902
|
` echo "Dotfiles clone failed (exit $_DOTS_EXIT) — continuing without dotfiles"`,
|
|
867
903
|
`else`,
|
|
868
904
|
` echo "Dotfiles cloned"`,
|
|
869
|
-
//
|
|
870
|
-
//
|
|
905
|
+
// The clone above already lands as the user. Kept for a worker whose ~/dotfiles was left
|
|
906
|
+
// root-owned by an earlier release, where the install script below would fail on it.
|
|
871
907
|
` chown -R ${username}:${username} ${homeDir}/dotfiles`,
|
|
872
908
|
` _INSTALL_SCRIPT=""`,
|
|
873
909
|
` for _candidate in install.sh bootstrap.sh setup.sh script/setup; do`,
|
|
@@ -911,6 +947,7 @@ export function buildSetupScript(params: SetupScriptParams): { script: string }
|
|
|
911
947
|
index: i,
|
|
912
948
|
total: project.repositories.length,
|
|
913
949
|
homeDir,
|
|
950
|
+
username,
|
|
914
951
|
branch: resolveRepoBranch(r, branch),
|
|
915
952
|
githubInstallationTokens,
|
|
916
953
|
userSshPrivateKey,
|
|
@@ -924,11 +961,10 @@ export function buildSetupScript(params: SetupScriptParams): { script: string }
|
|
|
924
961
|
})
|
|
925
962
|
|
|
926
963
|
// ── 5b. Re-own /workspace after cloning ───────────────────────────────────
|
|
927
|
-
//
|
|
928
|
-
//
|
|
929
|
-
//
|
|
930
|
-
//
|
|
931
|
-
// node_modules). Must run before postCreate, not just in the final step.
|
|
964
|
+
// The clones above run as the user, so their checkouts already belong to them. This stays as a
|
|
965
|
+
// net: a repo left root-owned by an earlier release (when cloning *was* done as root) would
|
|
966
|
+
// otherwise keep failing postCreate with EACCES — `npm install` on a root-owned node_modules —
|
|
967
|
+
// and the final chown in step 8 comes too late for that.
|
|
932
968
|
if (project.repositories.length > 0) {
|
|
933
969
|
push("", `chown -R ${username}:${username} /workspace`)
|
|
934
970
|
}
|
|
@@ -1774,21 +1810,21 @@ export function buildSetupPlan(params: SetupScriptParams): {
|
|
|
1774
1810
|
|
|
1775
1811
|
// ── dotfiles ──────────────────────────────────────────────────────────────
|
|
1776
1812
|
if (dotfilesRepo) {
|
|
1777
|
-
const dotfilesUrl =
|
|
1778
|
-
? dotfilesRepo
|
|
1779
|
-
: `https://github.com/${dotfilesRepo}`
|
|
1813
|
+
const dotfilesUrl = normalizeDotfilesUrl(dotfilesRepo)
|
|
1780
1814
|
const l: string[] = ["set -e", ...banner("SETUP: DOTFILES"),
|
|
1781
1815
|
`echo "Cloning dotfiles from ${dotfilesUrl}..."`,
|
|
1782
1816
|
`set +e`,
|
|
1783
|
-
|
|
1817
|
+
// As the user, like every other clone: root has no `~/.ssh`, so a private dotfiles repo
|
|
1818
|
+
// over SSH would authenticate with no key at all.
|
|
1819
|
+
`${asUser(username, `git clone ${shQuote(dotfilesUrl)} ${homeDir}/dotfiles`)} 2>&1`,
|
|
1784
1820
|
`_DOTS_EXIT=$?`,
|
|
1785
1821
|
`set -e`,
|
|
1786
1822
|
`if [ $_DOTS_EXIT -ne 0 ]; then`,
|
|
1787
1823
|
` echo "Dotfiles clone failed (exit $_DOTS_EXIT) — continuing without dotfiles"`,
|
|
1788
1824
|
`else`,
|
|
1789
1825
|
` echo "Dotfiles cloned"`,
|
|
1790
|
-
//
|
|
1791
|
-
//
|
|
1826
|
+
// The clone above already lands as the user. Kept for a worker whose ~/dotfiles was left
|
|
1827
|
+
// root-owned by an earlier release, where the install script below would fail on it.
|
|
1792
1828
|
` chown -R ${username}:${username} ${homeDir}/dotfiles`,
|
|
1793
1829
|
` _INSTALL_SCRIPT=""`,
|
|
1794
1830
|
` for _candidate in install.sh bootstrap.sh setup.sh script/setup; do`,
|
|
@@ -1833,6 +1869,7 @@ export function buildSetupPlan(params: SetupScriptParams): {
|
|
|
1833
1869
|
index: i,
|
|
1834
1870
|
total: project.repositories.length,
|
|
1835
1871
|
homeDir,
|
|
1872
|
+
username,
|
|
1836
1873
|
branch: resolveRepoBranch(r, branch),
|
|
1837
1874
|
githubInstallationTokens,
|
|
1838
1875
|
userSshPrivateKey,
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
// The half of a persistent terminal that is a string, not a socket.
|
|
2
|
+
//
|
|
3
|
+
// A worker's terminal is a `docker exec` attached to a session that outlives it — so that closing a
|
|
4
|
+
// browser tab does not kill a build. The *attaching* is I/O and stays with whoever owns the Docker
|
|
5
|
+
// socket; everything here is the shell that gets attached, the layout it writes into, and the
|
|
6
|
+
// parsing of what it prints back.
|
|
7
|
+
//
|
|
8
|
+
// **dtach, not tmux** (RFC 0019). tmux is a full multiplexer, and its only knob for "the wheel
|
|
9
|
+
// scrolls history" is `mouse on` — which also makes tmux swallow every drag, so the frontend has to
|
|
10
|
+
// forge Shift-drags to get a native selection back. dtach does only persistence: it owns a pty,
|
|
11
|
+
// lets clients attach and detach, and forwards every byte, mouse included. The terminal emulator
|
|
12
|
+
// keeps the mouse, its own scrollback and its own search.
|
|
13
|
+
//
|
|
14
|
+
// What dtach does not do is remember what was on screen. `-r winch` makes the running program
|
|
15
|
+
// redraw on attach, but the history is gone. So the session is recorded inside the container with
|
|
16
|
+
// script(1) and the tail of that recording is replayed just before attaching:
|
|
17
|
+
//
|
|
18
|
+
// docker exec ──> sh ──> [replay: tail of the log] ──> exec dtach -A ──> script -f log ──> $SHELL
|
|
19
|
+
// │
|
|
20
|
+
// persistent master
|
|
21
|
+
//
|
|
22
|
+
// Recording inside the container rather than in an agent-side ring buffer is what makes the buffer
|
|
23
|
+
// survive a control-plane restart with nothing permanently attached.
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Where a session's socket and recording live. One directory, two files per session.
|
|
27
|
+
*
|
|
28
|
+
* The name deliberately avoids the substring "dtach": the listing and kill scripts match processes
|
|
29
|
+
* by the socket path, and a path containing "dtach" would make every helper's own shell look like a
|
|
30
|
+
* dtach process to its own grep.
|
|
31
|
+
*/
|
|
32
|
+
export const TERMINAL_DIR = "/tmp/spunto-term"
|
|
33
|
+
|
|
34
|
+
/** Bytes of recording replayed on attach. ~64 lines of 120 cols is 8 KB; 256 KB is a few screens of a chatty build. */
|
|
35
|
+
export const REPLAY_BYTES = 262144
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Below this there is nothing worth replaying: a session created from a "+" button has already
|
|
39
|
+
* recorded its prompt, and replaying that would greet the user with a "replay" banner on a terminal
|
|
40
|
+
* they just opened. The attach redraw paints the prompt anyway.
|
|
41
|
+
*/
|
|
42
|
+
export const REPLAY_MIN_BYTES = 512
|
|
43
|
+
|
|
44
|
+
/** Above this, the recording is trimmed to `TRIM_TO` bytes on the next attach. */
|
|
45
|
+
export const MAX_LOG_BYTES = 4194304
|
|
46
|
+
export const TRIM_TO = 1048576
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Session name a control plane may safely interpolate into the shell scripts below.
|
|
50
|
+
*
|
|
51
|
+
* Restricted to `[A-Za-z0-9_-]` — which is both what a socket filename can hold without quoting and
|
|
52
|
+
* what the listing script's `awk` can match on. **Every name reaching the builders below must come
|
|
53
|
+
* through here**: they interpolate it into shell, so anything else is an injection.
|
|
54
|
+
*/
|
|
55
|
+
export function sanitizeSessionName(name: string | undefined): string {
|
|
56
|
+
const cleaned = (name ?? "main").replace(/[^A-Za-z0-9_-]/g, "-").replace(/^-+|-+$/g, "").slice(0, 40)
|
|
57
|
+
return cleaned || "main"
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function buildDtachCommand(): string {
|
|
61
|
+
return [
|
|
62
|
+
`D=${TERMINAL_DIR}`,
|
|
63
|
+
'N="$MP_TERM_SESSION"',
|
|
64
|
+
'SOCK="$D/$N.sock"',
|
|
65
|
+
'LOG="$D/$N.log"',
|
|
66
|
+
'mkdir -p "$D" 2>/dev/null; chmod 700 "$D" 2>/dev/null',
|
|
67
|
+
'_SH=$(getent passwd "$(id -un)" 2>/dev/null | cut -d: -f7); _SH=${_SH:-/bin/bash}',
|
|
68
|
+
// No dtach → say so plainly and degrade to a non-persistent shell.
|
|
69
|
+
"if ! command -v dtach >/dev/null 2>&1; then",
|
|
70
|
+
` printf '\\033[33m[spunto] dtach absent de ce conteneur — shell simple, sans persistance.\\033[0m\\r\\n'`,
|
|
71
|
+
' exec "$_SH" -l',
|
|
72
|
+
"fi",
|
|
73
|
+
// A session is live only if some process still holds its socket: a leftover
|
|
74
|
+
// socket file (container restarted, program exited) means the recorded log
|
|
75
|
+
// belongs to a session that no longer exists, so it is dropped rather than
|
|
76
|
+
// replayed into a brand new shell.
|
|
77
|
+
'LIVE=0',
|
|
78
|
+
'if [ -S "$SOCK" ] && ps -eo args= 2>/dev/null | grep -qF "$SOCK"; then LIVE=1; fi',
|
|
79
|
+
'if [ "$LIVE" = 0 ]; then rm -f "$SOCK" 2>/dev/null; : > "$LOG" 2>/dev/null; fi',
|
|
80
|
+
// ── replay buffer ────────────────────────────────────────────────────────
|
|
81
|
+
// Trim first (the log is append-only and would otherwise grow forever), then
|
|
82
|
+
// dump its tail. The dump is preceded by a reset because the tail can start
|
|
83
|
+
// mid-escape-sequence or while the alternate screen / mouse tracking was on;
|
|
84
|
+
// `tail -n +2` drops the (possibly truncated) first line.
|
|
85
|
+
'if [ "$LIVE" = 1 ] && [ -f "$LOG" ]; then',
|
|
86
|
+
' SZ=$(wc -c < "$LOG" 2>/dev/null || echo 0)',
|
|
87
|
+
` if [ "$SZ" -gt ${MAX_LOG_BYTES} ] 2>/dev/null; then`,
|
|
88
|
+
` tail -c ${TRIM_TO} "$LOG" > "$LOG.trim" 2>/dev/null && cat "$LOG.trim" > "$LOG" 2>/dev/null; rm -f "$LOG.trim"`,
|
|
89
|
+
" fi",
|
|
90
|
+
` if [ "$SZ" -gt ${REPLAY_MIN_BYTES} ] 2>/dev/null; then`,
|
|
91
|
+
` printf '\\033[?1049l\\033[?1000l\\033[?1002l\\033[?1003l\\033[?1006l\\033c'`,
|
|
92
|
+
` printf '\\033[2m── replay ──\\033[0m\\r\\n'`,
|
|
93
|
+
` tail -c ${REPLAY_BYTES} "$LOG" 2>/dev/null | tail -n +2`,
|
|
94
|
+
" fi",
|
|
95
|
+
"fi",
|
|
96
|
+
// ── attach ───────────────────────────────────────────────────────────────
|
|
97
|
+
// script(1) records the session's raw output for the replay above. Without it
|
|
98
|
+
// the session still works, it just comes back blank.
|
|
99
|
+
"if command -v script >/dev/null 2>&1; then",
|
|
100
|
+
' exec dtach -A "$SOCK" -r winch -E -z script -q -a -f "$LOG" -c "$_SH -l"',
|
|
101
|
+
"else",
|
|
102
|
+
' exec dtach -A "$SOCK" -r winch -E -z "$_SH" -l',
|
|
103
|
+
"fi",
|
|
104
|
+
].join("\n")
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// A dtach session is a socket file, nothing more — no server to ask. Name and
|
|
108
|
+
// creation date come from the socket itself; the foreground command has to be
|
|
109
|
+
// recovered from the container's process table (this is the metadata tmux gives
|
|
110
|
+
// away for free, see the RFC): walk the process tree down from the dtach master
|
|
111
|
+
// and keep the deepest descendant that sits in its pty's foreground process
|
|
112
|
+
// group (the `+` in ps's STAT), which is exactly what `pane_current_command` means.
|
|
113
|
+
export const LIST_SESSIONS_SCRIPT = [
|
|
114
|
+
`D=${TERMINAL_DIR}`,
|
|
115
|
+
'[ -d "$D" ] || exit 0',
|
|
116
|
+
'PSOUT=$(ps -eo pid=,ppid=,stat=,args= 2>/dev/null || true)',
|
|
117
|
+
'ESC=$(printf "\\033"); BEL=$(printf "\\007")',
|
|
118
|
+
'for s in "$D"/*.sock; do',
|
|
119
|
+
' [ -S "$s" ] || continue',
|
|
120
|
+
' n=$(basename "$s" .sock)',
|
|
121
|
+
' created=$(stat -c %Y "$s" 2>/dev/null || echo 0)',
|
|
122
|
+
' cmd=$(printf "%s\\n" "$PSOUT" | awk -v sock="$s" \'',
|
|
123
|
+
" {",
|
|
124
|
+
" pid=$1; ppid=$2; st=$3;",
|
|
125
|
+
' args=""; for (i=4; i<=NF; i++) args = args (i>4 ? " " : "") $i;',
|
|
126
|
+
" n++; order[n]=pid; P[pid]=ppid; S[pid]=st; A[pid]=args;",
|
|
127
|
+
' if (index(args, sock) > 0) d[pid]=0;',
|
|
128
|
+
" }",
|
|
129
|
+
" END {",
|
|
130
|
+
" for (iter=0; iter<12; iter++)",
|
|
131
|
+
" for (i=1; i<=n; i++) { p=order[i]; if (!(p in d) && (P[p] in d)) d[p]=d[P[p]]+1 }",
|
|
132
|
+
' best=""; bestd=0;',
|
|
133
|
+
" for (i=1; i<=n; i++) {",
|
|
134
|
+
" p=order[i];",
|
|
135
|
+
" if (!(p in d) || d[p]==0) continue;",
|
|
136
|
+
' if (S[p] !~ /\\+/) continue;',
|
|
137
|
+
' c=A[p]; sub(/ .*/, "", c); sub(/.*\\//, "", c); sub(/^-/, "", c);',
|
|
138
|
+
' if (c=="script" || c=="dtach") continue;',
|
|
139
|
+
" if (d[p] >= bestd) { bestd=d[p]; best=c }",
|
|
140
|
+
" }",
|
|
141
|
+
" print best",
|
|
142
|
+
" }')",
|
|
143
|
+
// Reported title (OSC 0/2, see terminal-title.ts). tmux keeps it in
|
|
144
|
+
// `pane_title`; dtach forwards every byte to the terminal and remembers
|
|
145
|
+
// nothing, so it is dug out of the replay log: the last `ESC ] 0|2 ; … BEL`
|
|
146
|
+
// written is the title currently on screen. Best effort — a container whose
|
|
147
|
+
// grep lacks `-a` simply yields no title and the UI falls back to the name.
|
|
148
|
+
' t=$(tail -c 65536 "$D/$n.log" 2>/dev/null | LC_ALL=C grep -ao "$ESC][02];[^$BEL$ESC]*" 2>/dev/null | tail -n 1 | LC_ALL=C cut -c5-)',
|
|
149
|
+
' echo "$n|1|0|$created|$cmd|$t"',
|
|
150
|
+
"done",
|
|
151
|
+
].join("\n")
|
|
152
|
+
|
|
153
|
+
/** Longest title kept — anything past that is a program dumping data, not naming itself. */
|
|
154
|
+
const MAX_TITLE_LENGTH = 120
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Normalize a reported title, or return "" when there is nothing worth showing.
|
|
158
|
+
*
|
|
159
|
+
* `placeholders` are the values that mean "no one reported anything": tmux seeds
|
|
160
|
+
* `pane_title` with the container hostname, so a title still equal to it says
|
|
161
|
+
* only that the session never set one.
|
|
162
|
+
*/
|
|
163
|
+
export function sanitizeReportedTitle(raw: string | undefined, placeholders: string[] = []): string {
|
|
164
|
+
const title = (raw ?? "")
|
|
165
|
+
// Control characters (a stray BEL/ESC from a truncated read, a newline) would
|
|
166
|
+
// land as-is in a JSON payload and then in a React tree.
|
|
167
|
+
.replace(/[\u0000-\u001f\u007f]/g, " ")
|
|
168
|
+
.replace(/\s+/g, " ")
|
|
169
|
+
.trim()
|
|
170
|
+
.slice(0, MAX_TITLE_LENGTH)
|
|
171
|
+
if (!title) return ""
|
|
172
|
+
if (placeholders.some((p) => p && p.trim() === title)) return ""
|
|
173
|
+
return title
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* dtach clears the screen as soon as a client attaches — a bare `ESC[H ESC[J` ("Clear the screen.
|
|
178
|
+
* This assumes VT100." in dtach's own source). That write lands *after* the replay the attach
|
|
179
|
+
* script just printed, and wipes exactly what the replay exists to show.
|
|
180
|
+
*
|
|
181
|
+
* So it is dropped: once, and only in the first seconds of an attach — long enough to cover
|
|
182
|
+
* dtach's write, short enough that a program legitimately clearing its screen later is never
|
|
183
|
+
* touched. (`clear` emits `ESC[H ESC[2J ESC[3J`, different bytes, so it would not match anyway.)
|
|
184
|
+
*
|
|
185
|
+
* `Uint8Array` in and out rather than Node's Buffer: a Buffer is one, so a Node caller passes its
|
|
186
|
+
* chunks straight through, and the package stays runnable anywhere.
|
|
187
|
+
*/
|
|
188
|
+
export function makeAttachClearStripper(
|
|
189
|
+
windowMs = 5000,
|
|
190
|
+
now: () => number = Date.now,
|
|
191
|
+
): (chunk: Uint8Array) => Uint8Array {
|
|
192
|
+
const CLEAR = new TextEncoder().encode("\x1b[H\x1b[J")
|
|
193
|
+
const deadline = now() + windowMs
|
|
194
|
+
let done = false
|
|
195
|
+
return (chunk) => {
|
|
196
|
+
if (done) return chunk
|
|
197
|
+
if (now() > deadline) {
|
|
198
|
+
done = true
|
|
199
|
+
return chunk
|
|
200
|
+
}
|
|
201
|
+
const i = indexOfBytes(chunk, CLEAR)
|
|
202
|
+
if (i === -1) return chunk
|
|
203
|
+
done = true
|
|
204
|
+
const out = new Uint8Array(chunk.length - CLEAR.length)
|
|
205
|
+
out.set(chunk.subarray(0, i), 0)
|
|
206
|
+
out.set(chunk.subarray(i + CLEAR.length), i)
|
|
207
|
+
return out
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** `Buffer.indexOf` for a plain Uint8Array — the needle is 6 bytes, so the naive scan is the right one. */
|
|
212
|
+
function indexOfBytes(haystack: Uint8Array, needle: Uint8Array): number {
|
|
213
|
+
outer: for (let i = 0; i + needle.length <= haystack.length; i++) {
|
|
214
|
+
for (let j = 0; j < needle.length; j++) if (haystack[i + j] !== needle[j]) continue outer
|
|
215
|
+
return i
|
|
216
|
+
}
|
|
217
|
+
return -1
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** One persistent session of a worker, as a control plane reports it. */
|
|
221
|
+
export type TerminalSession = {
|
|
222
|
+
name: string
|
|
223
|
+
/** Always 1 — dtach has no windows. Kept so a UI written against a multiplexer still renders. */
|
|
224
|
+
windows: number
|
|
225
|
+
/**
|
|
226
|
+
* Decided by the caller, not by this parse: a socket says nothing about who is connected, and the
|
|
227
|
+
* process that attaches is the one that knows.
|
|
228
|
+
*/
|
|
229
|
+
attached: boolean
|
|
230
|
+
createdAt: number
|
|
231
|
+
command: string
|
|
232
|
+
/** Title the running program reported over OSC 0/2 ("" when it reported none). */
|
|
233
|
+
title: string
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Parse what `LIST_SESSIONS_SCRIPT` printed into sessions.
|
|
238
|
+
*
|
|
239
|
+
* Separate from running it: executing a command inside a container is the caller's business, and
|
|
240
|
+
* keeping the parse here is what lets it be tested without a container.
|
|
241
|
+
*/
|
|
242
|
+
export function parseSessionList(output: string, isAttached: (session: string) => boolean): TerminalSession[] {
|
|
243
|
+
return output
|
|
244
|
+
.trim()
|
|
245
|
+
.split("\n")
|
|
246
|
+
.filter(Boolean)
|
|
247
|
+
.map((line) => {
|
|
248
|
+
// The title is free-form and last, so it keeps any "|" it contains.
|
|
249
|
+
const parts = line.split("|")
|
|
250
|
+
const [name = "", , , created = "0", command = ""] = parts
|
|
251
|
+
return {
|
|
252
|
+
name: name.trim(),
|
|
253
|
+
windows: 1,
|
|
254
|
+
attached: isAttached(name.trim()),
|
|
255
|
+
createdAt: (parseInt(created) || 0) * 1000,
|
|
256
|
+
command: command.trim(),
|
|
257
|
+
title: sanitizeReportedTitle(parts.slice(5).join("|")),
|
|
258
|
+
}
|
|
259
|
+
})
|
|
260
|
+
.filter((s) => s.name)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Shell that creates a session without attaching to it — `dtach -n` daemonizes the master.
|
|
265
|
+
*
|
|
266
|
+
* `session` must already have been through `sanitizeSessionName`: it is interpolated into the
|
|
267
|
+
* script.
|
|
268
|
+
*/
|
|
269
|
+
export function buildCreateSessionCommand(session: string): string {
|
|
270
|
+
return [
|
|
271
|
+
`D=${TERMINAL_DIR}`,
|
|
272
|
+
`N="${session}"`,
|
|
273
|
+
'mkdir -p "$D" 2>/dev/null; chmod 700 "$D" 2>/dev/null',
|
|
274
|
+
"command -v dtach >/dev/null 2>&1 || exit 0",
|
|
275
|
+
'[ -S "$D/$N.sock" ] && exit 0',
|
|
276
|
+
'_SH=$(getent passwd "$(id -un)" 2>/dev/null | cut -d: -f7); _SH=${_SH:-/bin/bash}',
|
|
277
|
+
// stdio redirected: the daemonized master inherits the exec's fds and would otherwise hold its
|
|
278
|
+
// stdout open long after the session exists.
|
|
279
|
+
"if command -v script >/dev/null 2>&1; then",
|
|
280
|
+
' dtach -n "$D/$N.sock" -r winch -E -z script -q -a -f "$D/$N.log" -c "$_SH -l" </dev/null >/dev/null 2>&1',
|
|
281
|
+
"else",
|
|
282
|
+
' dtach -n "$D/$N.sock" -r winch -E -z "$_SH" -l </dev/null >/dev/null 2>&1',
|
|
283
|
+
"fi",
|
|
284
|
+
].join("\n")
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Shell that kills a session: SIGTERM every process holding its socket (master and any client),
|
|
289
|
+
* then drop the socket and the recording.
|
|
290
|
+
*
|
|
291
|
+
* `session` must already have been through `sanitizeSessionName`.
|
|
292
|
+
*/
|
|
293
|
+
export function buildKillSessionCommand(session: string): string {
|
|
294
|
+
return [
|
|
295
|
+
`SOCK=${TERMINAL_DIR}/${session}.sock`,
|
|
296
|
+
'for p in $(ps -eo pid=,args= 2>/dev/null | awk -v sock="$SOCK" -v me=$$ \'$1 != me && index($0, sock) > 0 && index($0, "dtach") > 0 { print $1 }\'); do',
|
|
297
|
+
' kill "$p" 2>/dev/null',
|
|
298
|
+
"done",
|
|
299
|
+
`rm -f "$SOCK" ${TERMINAL_DIR}/${session}.log 2>/dev/null`,
|
|
300
|
+
"true",
|
|
301
|
+
].join("\n")
|
|
302
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// The half of a persistent terminal that is a string, not a socket: the shell that gets attached,
|
|
2
|
+
// the layout it writes into, and the parsing of what it prints back. Attaching is I/O and stays
|
|
3
|
+
// with whoever owns the Docker socket.
|
|
4
|
+
//
|
|
5
|
+
// See `./dtach` for why dtach and not tmux (RFC 0019).
|
|
6
|
+
|
|
7
|
+
export {
|
|
8
|
+
buildCreateSessionCommand,
|
|
9
|
+
buildDtachCommand,
|
|
10
|
+
buildKillSessionCommand,
|
|
11
|
+
LIST_SESSIONS_SCRIPT,
|
|
12
|
+
makeAttachClearStripper,
|
|
13
|
+
MAX_LOG_BYTES,
|
|
14
|
+
parseSessionList,
|
|
15
|
+
REPLAY_BYTES,
|
|
16
|
+
REPLAY_MIN_BYTES,
|
|
17
|
+
sanitizeReportedTitle,
|
|
18
|
+
sanitizeSessionName,
|
|
19
|
+
TERMINAL_DIR,
|
|
20
|
+
TRIM_TO,
|
|
21
|
+
} from "./dtach"
|
|
22
|
+
export type { TerminalSession } from "./dtach"
|