devlaunch 0.0.11__tar.gz → 0.0.13__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.
- {devlaunch-0.0.11 → devlaunch-0.0.13}/PKG-INFO +15 -2
- {devlaunch-0.0.11 → devlaunch-0.0.13}/README.md +14 -1
- devlaunch-0.0.13/devlaunch/devpod_ssh.py +143 -0
- {devlaunch-0.0.11 → devlaunch-0.0.13}/devlaunch/dl.py +144 -18
- {devlaunch-0.0.11 → devlaunch-0.0.13}/devlaunch/gh_auth.py +14 -0
- devlaunch-0.0.13/devlaunch/tty_session.py +144 -0
- {devlaunch-0.0.11 → devlaunch-0.0.13}/pyproject.toml +7 -2
- {devlaunch-0.0.11 → devlaunch-0.0.13}/.gitignore +0 -0
- {devlaunch-0.0.11 → devlaunch-0.0.13}/LICENSE +0 -0
- {devlaunch-0.0.11 → devlaunch-0.0.13}/devlaunch/__init__.py +0 -0
- {devlaunch-0.0.11 → devlaunch-0.0.13}/devlaunch/aid.py +0 -0
- {devlaunch-0.0.11 → devlaunch-0.0.13}/devlaunch/completion.py +0 -0
- {devlaunch-0.0.11 → devlaunch-0.0.13}/devlaunch/completion_loader.py +0 -0
- {devlaunch-0.0.11 → devlaunch-0.0.13}/devlaunch/completions/__init__.py +0 -0
- {devlaunch-0.0.11 → devlaunch-0.0.13}/devlaunch/completions/dl.bash +0 -0
- {devlaunch-0.0.11 → devlaunch-0.0.13}/devlaunch/workspace_id.py +0 -0
- {devlaunch-0.0.11 → devlaunch-0.0.13}/devlaunch/worktree/__init__.py +0 -0
- {devlaunch-0.0.11 → devlaunch-0.0.13}/devlaunch/worktree/branch_manager.py +0 -0
- {devlaunch-0.0.11 → devlaunch-0.0.13}/devlaunch/worktree/config.py +0 -0
- {devlaunch-0.0.11 → devlaunch-0.0.13}/devlaunch/worktree/migration.py +0 -0
- {devlaunch-0.0.11 → devlaunch-0.0.13}/devlaunch/worktree/models.py +0 -0
- {devlaunch-0.0.11 → devlaunch-0.0.13}/devlaunch/worktree/repo_manager.py +0 -0
- {devlaunch-0.0.11 → devlaunch-0.0.13}/devlaunch/worktree/storage.py +0 -0
- {devlaunch-0.0.11 → devlaunch-0.0.13}/devlaunch/worktree/workspace_clone.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: devlaunch
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.13
|
|
4
4
|
Summary: DevLaunch - A streamlined CLI for devpod workspaces
|
|
5
5
|
Project-URL: Source, https://github.com/blooop/devlaunch
|
|
6
6
|
Project-URL: Home, https://github.com/blooop/devlaunch
|
|
@@ -75,6 +75,18 @@ dl <user/repo> <cmd> # Run workspace command (stop, code, etc.)
|
|
|
75
75
|
dl <user/repo> -- <command> # Run shell command in workspace
|
|
76
76
|
```
|
|
77
77
|
|
|
78
|
+
### Commands that need a terminal
|
|
79
|
+
|
|
80
|
+
`dl <ws> -- <command>` gives the command a terminal whenever `dl` itself has one,
|
|
81
|
+
so interactive programs — a coding agent, `htop`, `git rebase -i`, a REPL — start
|
|
82
|
+
and stay up instead of exiting immediately. Redirect the output and the terminal
|
|
83
|
+
goes away again, so `dl <ws> -- ls > files.txt` stays free of escape sequences.
|
|
84
|
+
|
|
85
|
+
This needs the ssh host alias `devpod up` writes to `~/.ssh/config`. If a
|
|
86
|
+
workspace has none, `dl` says so and falls back to the plain `devpod ssh`
|
|
87
|
+
transport, which has no terminal; `dl <ws> restart` republishes the alias. Set
|
|
88
|
+
`DEVLAUNCH_NO_TTY=1` to force the fallback everywhere.
|
|
89
|
+
|
|
78
90
|
## aid: start a coding agent in a workspace
|
|
79
91
|
|
|
80
92
|
`aid` is `dl` with a coding agent started for you:
|
|
@@ -209,13 +221,14 @@ than the filesystem has actually done.
|
|
|
209
221
|
| `dl <user/repo> restart` | Stop and start (no rebuild) |
|
|
210
222
|
| `dl <user/repo> recreate` | Recreate container |
|
|
211
223
|
| `dl <user/repo> reset` | Clean slate (remove all, recreate) |
|
|
212
|
-
| `dl <user/repo> -- <command>` | Run shell command in workspace |
|
|
224
|
+
| `dl <user/repo> -- <command>` | Run shell command in workspace (with a terminal, when `dl` has one) |
|
|
213
225
|
|
|
214
226
|
## Options
|
|
215
227
|
|
|
216
228
|
| Option | Description |
|
|
217
229
|
|--------|-------------|
|
|
218
230
|
| `--devcontainer <variant\|path>` | Use a non-default `devcontainer.json`. A bare name means `.devcontainer/<name>/devcontainer.json`. Stored with the workspace, so pass it once. |
|
|
231
|
+
| `DEVLAUNCH_NO_TTY=1` | Never give a workspace command a terminal; always use the plain `devpod ssh` transport. |
|
|
219
232
|
|
|
220
233
|
Projects with demanding devcontainers — several variants, compose sidecars, or a
|
|
221
234
|
host-side `initializeCommand` that has to tell branch workspaces apart — are
|
|
@@ -53,6 +53,18 @@ dl <user/repo> <cmd> # Run workspace command (stop, code, etc.)
|
|
|
53
53
|
dl <user/repo> -- <command> # Run shell command in workspace
|
|
54
54
|
```
|
|
55
55
|
|
|
56
|
+
### Commands that need a terminal
|
|
57
|
+
|
|
58
|
+
`dl <ws> -- <command>` gives the command a terminal whenever `dl` itself has one,
|
|
59
|
+
so interactive programs — a coding agent, `htop`, `git rebase -i`, a REPL — start
|
|
60
|
+
and stay up instead of exiting immediately. Redirect the output and the terminal
|
|
61
|
+
goes away again, so `dl <ws> -- ls > files.txt` stays free of escape sequences.
|
|
62
|
+
|
|
63
|
+
This needs the ssh host alias `devpod up` writes to `~/.ssh/config`. If a
|
|
64
|
+
workspace has none, `dl` says so and falls back to the plain `devpod ssh`
|
|
65
|
+
transport, which has no terminal; `dl <ws> restart` republishes the alias. Set
|
|
66
|
+
`DEVLAUNCH_NO_TTY=1` to force the fallback everywhere.
|
|
67
|
+
|
|
56
68
|
## aid: start a coding agent in a workspace
|
|
57
69
|
|
|
58
70
|
`aid` is `dl` with a coding agent started for you:
|
|
@@ -187,13 +199,14 @@ than the filesystem has actually done.
|
|
|
187
199
|
| `dl <user/repo> restart` | Stop and start (no rebuild) |
|
|
188
200
|
| `dl <user/repo> recreate` | Recreate container |
|
|
189
201
|
| `dl <user/repo> reset` | Clean slate (remove all, recreate) |
|
|
190
|
-
| `dl <user/repo> -- <command>` | Run shell command in workspace |
|
|
202
|
+
| `dl <user/repo> -- <command>` | Run shell command in workspace (with a terminal, when `dl` has one) |
|
|
191
203
|
|
|
192
204
|
## Options
|
|
193
205
|
|
|
194
206
|
| Option | Description |
|
|
195
207
|
|--------|-------------|
|
|
196
208
|
| `--devcontainer <variant\|path>` | Use a non-default `devcontainer.json`. A bare name means `.devcontainer/<name>/devcontainer.json`. Stored with the workspace, so pass it once. |
|
|
209
|
+
| `DEVLAUNCH_NO_TTY=1` | Never give a workspace command a terminal; always use the plain `devpod ssh` transport. |
|
|
197
210
|
|
|
198
211
|
Projects with demanding devcontainers — several variants, compose sidecars, or a
|
|
199
212
|
host-side `initializeCommand` that has to tell branch workspaces apart — are
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""How a `devpod ssh` session ended, recovered from what devpod reports.
|
|
2
|
+
|
|
3
|
+
devpod means to pass a remote process's exit status through. Its top-level error
|
|
4
|
+
handler does:
|
|
5
|
+
|
|
6
|
+
if sshExitErr, ok := err.(*ssh.ExitError); ok {
|
|
7
|
+
os.Exit(sshExitErr.ExitStatus())
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
But by the time the error reaches there it has been wrapped three times —
|
|
11
|
+
`ssh session: %w` in cmd/machine/ssh.go, then "run in container", then "tunnel to
|
|
12
|
+
container" — and a bare type assertion does not see through `%w`. So every
|
|
13
|
+
nonzero remote exit misses that branch and lands on devpod's generic failure
|
|
14
|
+
path instead, which prints
|
|
15
|
+
|
|
16
|
+
error Try using the --debug flag to see a more verbose output root.go:106
|
|
17
|
+
fatal tunnel to container: run in container: ssh session: Process exited with status 130
|
|
18
|
+
|
|
19
|
+
and exits 1.
|
|
20
|
+
|
|
21
|
+
Nothing has gone wrong in that example. A login shell exits with the status of
|
|
22
|
+
its last command, so a single Ctrl-C before typing `exit` is enough to make a
|
|
23
|
+
perfectly ordinary session end 130. The session ran and it ended; devpod just has
|
|
24
|
+
no way left to say so.
|
|
25
|
+
|
|
26
|
+
Both of those lines are Error/Fatal level, which loft-sh/log sends to stderr
|
|
27
|
+
(Info-level progress goes to stdout, so reading stderr does not hold back the
|
|
28
|
+
"waiting for workspace" chatter). That makes the status recoverable: read
|
|
29
|
+
devpod's stderr, take the status out of the message it buried it in, and hold
|
|
30
|
+
back the two lines that only exist because devpod could not report it properly.
|
|
31
|
+
|
|
32
|
+
The distinction the rest of devlaunch needs is which process the resulting number
|
|
33
|
+
came from, so it is a type rather than a bare int — see SshOutcome.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
import re
|
|
37
|
+
from dataclasses import dataclass
|
|
38
|
+
from typing import Iterable, NoReturn, Optional, TextIO
|
|
39
|
+
|
|
40
|
+
# devpod prints this immediately before the fatal it belongs to, so it has to be
|
|
41
|
+
# held for one line to see which fatal that is.
|
|
42
|
+
DEBUG_HINT = "Try using the --debug flag to see a more verbose output"
|
|
43
|
+
|
|
44
|
+
# The status golang.org/x/crypto/ssh formatted into an *ssh.ExitError:
|
|
45
|
+
# "Process exited with status 130", optionally " from signal SIGINT" and
|
|
46
|
+
# ". Reason was: ...". Anchored on devpod's "fatal" tag as well so a remote
|
|
47
|
+
# program printing the same sentence on its own stderr (which reaches us only
|
|
48
|
+
# when there is no pty) cannot be mistaken for devpod's report.
|
|
49
|
+
#
|
|
50
|
+
# No \b before "fatal": devpod colours the tag, and the escape it emits ends in
|
|
51
|
+
# "m", so there is no word boundary in front of it.
|
|
52
|
+
REMOTE_EXIT_RE = re.compile(r"fatal\b.*\bssh session: Process exited with status (\d+)")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class RemoteExit:
|
|
57
|
+
"""devpod ran the remote program, and it exited with `status`.
|
|
58
|
+
|
|
59
|
+
Not a devlaunch failure, whatever `status` is: the shell or command the user
|
|
60
|
+
asked for ran to completion. `status` belongs to that program.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
status: int
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True)
|
|
67
|
+
class DevpodFailed:
|
|
68
|
+
"""devpod never ran the remote program, or lost it partway.
|
|
69
|
+
|
|
70
|
+
`exit_code` is devpod's own. devpod has already written its diagnostics to
|
|
71
|
+
stderr by the time this is constructed, so it carries no message of its own —
|
|
72
|
+
there is nothing devlaunch knows that the user has not already been told.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
exit_code: int
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
SshOutcome = RemoteExit | DevpodFailed
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def assert_never(value: NoReturn) -> NoReturn:
|
|
82
|
+
"""Fail loudly on an SshOutcome arm nobody handled.
|
|
83
|
+
|
|
84
|
+
A runtime backstop, not a compile-time one: `ty`, the checker this project
|
|
85
|
+
runs in CI, does not currently reject a `match` that drops an arm. It is
|
|
86
|
+
still worth having, because a `match` with no fallthrough returns None, and
|
|
87
|
+
`main` returning None makes `dl` exit 0 — a new outcome would otherwise go
|
|
88
|
+
out as success.
|
|
89
|
+
|
|
90
|
+
Stands in for typing.assert_never, which needs 3.11; this project is 3.10+.
|
|
91
|
+
"""
|
|
92
|
+
raise AssertionError(f"unhandled outcome: {value!r}")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def filter_devpod_stderr(lines: Iterable[str], out: TextIO) -> Optional[int]:
|
|
96
|
+
"""Forward devpod's stderr, holding back its report of a remote exit status.
|
|
97
|
+
|
|
98
|
+
Returns that status if devpod reported one. Everything else is passed through
|
|
99
|
+
verbatim and unbuffered, so a genuine devpod failure still reads exactly as
|
|
100
|
+
it does today — including the --debug hint, which is released ahead of the
|
|
101
|
+
fatal it precedes rather than after it.
|
|
102
|
+
"""
|
|
103
|
+
remote_status: Optional[int] = None
|
|
104
|
+
held_hint: Optional[str] = None
|
|
105
|
+
|
|
106
|
+
for line in lines:
|
|
107
|
+
match = REMOTE_EXIT_RE.search(line)
|
|
108
|
+
if match:
|
|
109
|
+
remote_status = int(match.group(1))
|
|
110
|
+
# The hint introduced this fatal, so it goes with it.
|
|
111
|
+
held_hint = None
|
|
112
|
+
continue
|
|
113
|
+
if DEBUG_HINT in line:
|
|
114
|
+
held_hint = line
|
|
115
|
+
continue
|
|
116
|
+
if held_hint is not None:
|
|
117
|
+
out.write(held_hint)
|
|
118
|
+
held_hint = None
|
|
119
|
+
out.write(line)
|
|
120
|
+
out.flush()
|
|
121
|
+
|
|
122
|
+
if held_hint is not None:
|
|
123
|
+
out.write(held_hint)
|
|
124
|
+
out.flush()
|
|
125
|
+
|
|
126
|
+
return remote_status
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def interpret(devpod_exit_code: int, remote_status: Optional[int]) -> SshOutcome:
|
|
130
|
+
"""Decide what a finished `devpod ssh` actually reported.
|
|
131
|
+
|
|
132
|
+
A recovered remote status wins over devpod's own exit code, because devpod
|
|
133
|
+
reports 1 alongside it regardless of what the remote program returned.
|
|
134
|
+
"""
|
|
135
|
+
if remote_status is not None:
|
|
136
|
+
return RemoteExit(remote_status)
|
|
137
|
+
if devpod_exit_code == 0:
|
|
138
|
+
return RemoteExit(0)
|
|
139
|
+
# No status to recover. Either devpod really did fail, or a future devpod
|
|
140
|
+
# unwraps the error properly and exits with the remote status itself — in
|
|
141
|
+
# which case this is still the right number to pass on, and devpod stayed
|
|
142
|
+
# quiet, so nothing spurious is printed either way.
|
|
143
|
+
return DevpodFailed(devpod_exit_code)
|
|
@@ -33,7 +33,7 @@ from dataclasses import dataclass
|
|
|
33
33
|
from urllib.parse import urlparse
|
|
34
34
|
from urllib.request import url2pathname
|
|
35
35
|
|
|
36
|
-
from . import gh_auth
|
|
36
|
+
from . import devpod_ssh, gh_auth, tty_session
|
|
37
37
|
from .completion import install_completions
|
|
38
38
|
from .workspace_id import TARGET_LENGTH, WorkspaceId, slug, source_workspace_id, validate_ref_name
|
|
39
39
|
from .worktree.config import get_worktree_config
|
|
@@ -41,15 +41,27 @@ from .worktree.migration import migrate_cache
|
|
|
41
41
|
from .worktree.workspace_clone import WorkspaceCloneManager
|
|
42
42
|
|
|
43
43
|
|
|
44
|
-
class
|
|
45
|
-
"""
|
|
44
|
+
class MissingBinary(Exception):
|
|
45
|
+
"""A binary dl shells out to is not on PATH.
|
|
46
46
|
|
|
47
47
|
Deliberately not an OSError (FileNotFoundError is one) and not a
|
|
48
48
|
RuntimeError: dl catches both broadly in a dozen places so that a flaky
|
|
49
49
|
command degrades to an empty list or a "failed to prepare workspace"
|
|
50
50
|
message. A missing binary reported through one of those handlers is
|
|
51
|
-
reported wrongly, so it travels as a type nothing between
|
|
52
|
-
main() catches, and main() is the only place that handles it.
|
|
51
|
+
reported wrongly, so it travels as a type nothing between the spawn helpers
|
|
52
|
+
and main() catches, and main() is the only place that handles it.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class DevpodNotInstalled(MissingBinary):
|
|
57
|
+
"""The devpod binary is not on PATH."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class SshNotInstalled(MissingBinary):
|
|
61
|
+
"""OpenSSH is not on PATH, so no command can be given a terminal.
|
|
62
|
+
|
|
63
|
+
Its own type rather than DevpodNotInstalled: telling someone to install
|
|
64
|
+
devpod when devpod is present and working would send them the wrong way.
|
|
53
65
|
"""
|
|
54
66
|
|
|
55
67
|
|
|
@@ -62,6 +74,14 @@ DEVPOD_MISSING_MESSAGE = (
|
|
|
62
74
|
"(pixi/conda installs of devlaunch include it; pip installs do not)."
|
|
63
75
|
)
|
|
64
76
|
|
|
77
|
+
# Same shape, and names the way out that does not need ssh at all: the devpod
|
|
78
|
+
# transport still runs commands, it just cannot give them a terminal.
|
|
79
|
+
SSH_MISSING_MESSAGE = (
|
|
80
|
+
"ssh not found on PATH: dl needs OpenSSH to give a workspace command a "
|
|
81
|
+
"terminal. Install it, or set DEVLAUNCH_NO_TTY=1 to run commands through "
|
|
82
|
+
"devpod instead (interactive programs will not work)."
|
|
83
|
+
)
|
|
84
|
+
|
|
65
85
|
# The shell's own "command not found" code, which says more than a bare 1 and
|
|
66
86
|
# cannot be confused with a devpod command that ran and failed.
|
|
67
87
|
DEVPOD_MISSING_EXIT_CODE = 127
|
|
@@ -832,6 +852,38 @@ def run_devpod(
|
|
|
832
852
|
raise DevpodNotInstalled(DEVPOD_MISSING_MESSAGE) from e
|
|
833
853
|
|
|
834
854
|
|
|
855
|
+
def run_devpod_session(
|
|
856
|
+
args: List[str], env: Optional[Dict[str, str]] = None
|
|
857
|
+
) -> devpod_ssh.SshOutcome:
|
|
858
|
+
"""Run a devpod command that hands its stdin/stdout to a terminal session.
|
|
859
|
+
|
|
860
|
+
stdin and stdout are inherited untouched — devpod puts the real terminal into
|
|
861
|
+
raw mode through them, and requests a pty on that basis. Only stderr is read,
|
|
862
|
+
which under a pty carries devpod's own warnings and errors and nothing else,
|
|
863
|
+
so that devpod's report of how the session ended can be interpreted rather
|
|
864
|
+
than dumped on the user. See devpod_ssh for why that is necessary.
|
|
865
|
+
"""
|
|
866
|
+
cmd = ["devpod"] + args
|
|
867
|
+
logging.debug("Running: %s", " ".join(cmd))
|
|
868
|
+
# nosec B603 - using list form, not shell=True; no command injection risk
|
|
869
|
+
with subprocess.Popen(
|
|
870
|
+
cmd,
|
|
871
|
+
stderr=subprocess.PIPE,
|
|
872
|
+
text=True,
|
|
873
|
+
encoding="utf-8",
|
|
874
|
+
errors="replace",
|
|
875
|
+
env=env,
|
|
876
|
+
) as proc:
|
|
877
|
+
# proc.stderr is a pipe because PIPE was asked for, but Popen's type
|
|
878
|
+
# cannot express that, so the narrowing happens here rather than by
|
|
879
|
+
# widening filter_devpod_stderr to a None it would have no answer for.
|
|
880
|
+
pipe = proc.stderr
|
|
881
|
+
remote_status = (
|
|
882
|
+
devpod_ssh.filter_devpod_stderr(pipe, sys.stderr) if pipe is not None else None
|
|
883
|
+
)
|
|
884
|
+
return devpod_ssh.interpret(proc.returncode, remote_status)
|
|
885
|
+
|
|
886
|
+
|
|
835
887
|
# The memoized `devpod list` snapshot. A dict rather than a module-level
|
|
836
888
|
# Optional so the accessors below need no `global`, and so "nothing read yet"
|
|
837
889
|
# (no key at all) stays distinguishable from "devpod has no workspaces" (an
|
|
@@ -1013,6 +1065,28 @@ def workspace_up(
|
|
|
1013
1065
|
return result
|
|
1014
1066
|
|
|
1015
1067
|
|
|
1068
|
+
def run_ssh(args: List[str], env: Optional[Dict[str, str]] = None) -> subprocess.CompletedProcess:
|
|
1069
|
+
"""Run OpenSSH, dl's other way into a workspace.
|
|
1070
|
+
|
|
1071
|
+
Separate from run_devpod because it is a different binary with a different
|
|
1072
|
+
failure mode, and because the pty transport has to be swappable in tests
|
|
1073
|
+
without stubbing out every devpod call as well.
|
|
1074
|
+
|
|
1075
|
+
Takes the whole argv, `ssh` included, because tty_session composes a
|
|
1076
|
+
complete command rather than a tail of flags -- unlike run_devpod, whose
|
|
1077
|
+
callers each build up their own subcommand.
|
|
1078
|
+
|
|
1079
|
+
Security note: list form, not shell=True, so nothing in the payload is
|
|
1080
|
+
interpreted by a host shell.
|
|
1081
|
+
"""
|
|
1082
|
+
logging.debug("Running: %s", " ".join(args))
|
|
1083
|
+
try:
|
|
1084
|
+
# nosec B603 B607 - list form, not shell=True; no command injection risk
|
|
1085
|
+
return subprocess.run(list(args), check=False, env=env)
|
|
1086
|
+
except FileNotFoundError as e:
|
|
1087
|
+
raise SshNotInstalled(SSH_MISSING_MESSAGE) from e
|
|
1088
|
+
|
|
1089
|
+
|
|
1016
1090
|
def workspace_ssh(
|
|
1017
1091
|
workspace: str,
|
|
1018
1092
|
command: Optional[str] = None,
|
|
@@ -1020,6 +1094,17 @@ def workspace_ssh(
|
|
|
1020
1094
|
) -> int:
|
|
1021
1095
|
"""SSH into a workspace, optionally running a command.
|
|
1022
1096
|
|
|
1097
|
+
A command runs through whichever of the two transports can give it what it
|
|
1098
|
+
needs. devpod's --command never requests a pty, which is fine for `make
|
|
1099
|
+
test` and fatal for anything interactive -- `claude` reads the pipe as a
|
|
1100
|
+
non-interactive invocation and exits instead of starting a session. So when
|
|
1101
|
+
dl is itself on a terminal, the command goes to OpenSSH through the host
|
|
1102
|
+
alias devpod published, with -t (see tty_session). Otherwise, and when no
|
|
1103
|
+
alias exists, it goes to devpod as before.
|
|
1104
|
+
|
|
1105
|
+
A bare attach is left on devpod: it already gets a pty, being the one case
|
|
1106
|
+
devpod requests one for.
|
|
1107
|
+
|
|
1023
1108
|
Args:
|
|
1024
1109
|
workspace: The workspace ID to SSH into
|
|
1025
1110
|
command: Optional command to run (if None, starts interactive shell)
|
|
@@ -1028,19 +1113,34 @@ def workspace_ssh(
|
|
|
1028
1113
|
if given a path that doesn't exist in the container, so never guess
|
|
1029
1114
|
one from the workspace id.
|
|
1030
1115
|
"""
|
|
1031
|
-
|
|
1116
|
+
# devpod runs --command under a non-login, non-interactive `bash -c`, which
|
|
1117
|
+
# sources neither ~/.profile nor ~/.bashrc -- so PATH entries the image adds
|
|
1118
|
+
# there (notably $HOME/.pixi/bin) are missing and the payload dies with
|
|
1119
|
+
# "command not found". An interactive attach gets a login shell, so wrap
|
|
1120
|
+
# here to give both paths the same PATH. dl launches arbitrary repos, so the
|
|
1121
|
+
# parity has to come from the invocation rather than from any particular
|
|
1122
|
+
# devcontainer.json.
|
|
1123
|
+
#
|
|
1124
|
+
# Built once and shared by both transports: two copies of this expression
|
|
1125
|
+
# would be two chances for the transports to drift, which is the whole
|
|
1126
|
+
# failure this function exists to have fixed.
|
|
1127
|
+
payload = f"bash -lc {shlex.quote(command)}" if command else None
|
|
1128
|
+
|
|
1129
|
+
if payload is not None and tty_session.have_terminal():
|
|
1130
|
+
if tty_session.devpod_host_configured(workspace):
|
|
1131
|
+
return _ssh_with_terminal(workspace, payload, workdir)
|
|
1132
|
+
logging.warning(
|
|
1133
|
+
"No devpod ssh host entry for %s, so this command gets no terminal; "
|
|
1134
|
+
"interactive programs may exit immediately. `dl %s restart` republishes it.",
|
|
1135
|
+
workspace,
|
|
1136
|
+
workspace,
|
|
1137
|
+
)
|
|
1032
1138
|
|
|
1139
|
+
args = ["ssh", workspace]
|
|
1033
1140
|
if workdir:
|
|
1034
1141
|
args.extend(["--workdir", workdir])
|
|
1035
|
-
if
|
|
1036
|
-
|
|
1037
|
-
# which sources neither ~/.profile nor ~/.bashrc -- so PATH entries the
|
|
1038
|
-
# image adds there (notably $HOME/.pixi/bin) are missing and the payload
|
|
1039
|
-
# dies with "command not found". An interactive attach gets a login
|
|
1040
|
-
# shell, so wrap here to give both paths the same PATH. dl launches
|
|
1041
|
-
# arbitrary repos, so the parity has to come from the invocation rather
|
|
1042
|
-
# than from any particular devcontainer.json.
|
|
1043
|
-
args.extend(["--command", f"bash -lc {shlex.quote(command)}"])
|
|
1142
|
+
if payload is not None:
|
|
1143
|
+
args.extend(["--command", payload])
|
|
1044
1144
|
|
|
1045
1145
|
# Attaching to a running workspace skips workspace_up, so the gh login has
|
|
1046
1146
|
# to be offered here too. Only the variable name lands in args; the token
|
|
@@ -1049,8 +1149,34 @@ def workspace_ssh(
|
|
|
1049
1149
|
args.extend(token_args)
|
|
1050
1150
|
|
|
1051
1151
|
logging.info(f"SSH command: devpod {' '.join(args)}")
|
|
1052
|
-
|
|
1053
|
-
|
|
1152
|
+
outcome = run_devpod_session(args, env=env)
|
|
1153
|
+
|
|
1154
|
+
# The two arms carry the same kind of number from different processes, which
|
|
1155
|
+
# is exactly the confusion this used to make: `dl` reported devpod's exit
|
|
1156
|
+
# code (always 1) for a session that had ended perfectly normally with, say,
|
|
1157
|
+
# 130. Whichever arm this is, the status returned is the session's.
|
|
1158
|
+
match outcome:
|
|
1159
|
+
case devpod_ssh.RemoteExit(status=status):
|
|
1160
|
+
return status
|
|
1161
|
+
case devpod_ssh.DevpodFailed(exit_code=exit_code):
|
|
1162
|
+
logging.debug("devpod ssh failed with exit code %s", exit_code)
|
|
1163
|
+
return exit_code
|
|
1164
|
+
case _ as unhandled:
|
|
1165
|
+
devpod_ssh.assert_never(unhandled)
|
|
1166
|
+
|
|
1167
|
+
|
|
1168
|
+
def _ssh_with_terminal(workspace: str, payload: str, workdir: Optional[str]) -> int:
|
|
1169
|
+
"""Run an already-wrapped payload under a pty via OpenSSH.
|
|
1170
|
+
|
|
1171
|
+
No devpod_ssh.SshOutcome here, and nothing to recover: OpenSSH exits with
|
|
1172
|
+
the remote program's own status, which is the thing devpod loses by wrapping
|
|
1173
|
+
its *ssh.ExitError three times before type-asserting on it. This transport
|
|
1174
|
+
never had that bug, so it needs none of the machinery that works around it.
|
|
1175
|
+
"""
|
|
1176
|
+
env_names, env = gh_auth.openssh_env_names_and_env()
|
|
1177
|
+
args = tty_session.ssh_command_args(workspace, payload, send_env=env_names, workdir=workdir)
|
|
1178
|
+
logging.info("SSH command: %s", " ".join(args))
|
|
1179
|
+
return run_ssh(args, env=env).returncode
|
|
1054
1180
|
|
|
1055
1181
|
|
|
1056
1182
|
def attach_workspace(workspace_id: str, shell_command: Optional[str] = None) -> int:
|
|
@@ -1255,7 +1381,7 @@ def main(argv: Optional[List[str]] = None) -> int:
|
|
|
1255
1381
|
invalidate_workspace_list_cache()
|
|
1256
1382
|
try:
|
|
1257
1383
|
return _run_cli(argv)
|
|
1258
|
-
except
|
|
1384
|
+
except MissingBinary as e:
|
|
1259
1385
|
print(e, file=sys.stderr)
|
|
1260
1386
|
return DEVPOD_MISSING_EXIT_CODE
|
|
1261
1387
|
|
|
@@ -169,3 +169,17 @@ def ssh_args_and_env() -> Tuple[List[str], Optional[Dict[str, str]]]:
|
|
|
169
169
|
if not token:
|
|
170
170
|
return [], None
|
|
171
171
|
return ["--send-env", TOKEN_VAR], {**os.environ, TOKEN_VAR: token}
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def openssh_env_names_and_env() -> Tuple[List[str], Optional[Dict[str, str]]]:
|
|
175
|
+
"""The same forwarding, for the OpenSSH transport that carries a terminal.
|
|
176
|
+
|
|
177
|
+
Interactive payloads reach the workspace through `ssh` rather than `devpod
|
|
178
|
+
ssh` (see tty_session), which spells the same idea `-o SendEnv=NAME`. Only
|
|
179
|
+
the names are returned here; tty_session turns them into flags, and the
|
|
180
|
+
values travel in the environment for the same reason as above.
|
|
181
|
+
"""
|
|
182
|
+
token = resolve_token()
|
|
183
|
+
if not token:
|
|
184
|
+
return [], None
|
|
185
|
+
return [TOKEN_VAR], {**os.environ, TOKEN_VAR: token}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Carry a terminal into the workspace for commands that need one.
|
|
2
|
+
|
|
3
|
+
`devpod ssh --command` never asks its ssh session for a pty. It requests one
|
|
4
|
+
only for a bare interactive attach, so anything started through --command runs
|
|
5
|
+
with stdin, stdout and stderr on pipes and TERM=dumb, and there is no devpod
|
|
6
|
+
flag that forces the matter.
|
|
7
|
+
|
|
8
|
+
One-shot commands don't care. Interactive ones do, and they don't fail in a way
|
|
9
|
+
that looks like a missing terminal: `claude` reads the pipe as "invoked
|
|
10
|
+
non-interactively", switches to --print mode, and exits -- so `aid <repo>`
|
|
11
|
+
returned to the shell instead of leaving a session behind, and `dl <ws> --
|
|
12
|
+
claude 'fix it'` printed one answer and stopped.
|
|
13
|
+
|
|
14
|
+
devpod already publishes the way out. Every `devpod up` writes an ssh host alias
|
|
15
|
+
`<workspace>.devpod` into ~/.ssh/config whose ProxyCommand tunnels through
|
|
16
|
+
`devpod ssh --stdio`, so OpenSSH can open the same session devpod would and, with
|
|
17
|
+
-t, ask it for a pty. That gives the payload a real terminal, and gives the user
|
|
18
|
+
OpenSSH's terminal handling -- raw mode, window size, SIGWINCH -- which is what a
|
|
19
|
+
TUI needs and what a hand-rolled pty proxy in dl would have to reimplement.
|
|
20
|
+
|
|
21
|
+
This module only decides and composes; dl.workspace_ssh does the spawning. The
|
|
22
|
+
decision is deliberately the one ssh itself makes: use a terminal when there is
|
|
23
|
+
a terminal to use. A redirected `dl <ws> -- ls > out.txt` keeps the old
|
|
24
|
+
transport and so keeps its output free of escape sequences.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import os
|
|
30
|
+
import pathlib
|
|
31
|
+
import shlex
|
|
32
|
+
import sys
|
|
33
|
+
from typing import Any, Iterable, List, Optional
|
|
34
|
+
|
|
35
|
+
# Where devpod writes its host aliases. A module-level path rather than a call
|
|
36
|
+
# to Path.home() per lookup so tests can point it at a fixture.
|
|
37
|
+
SSH_CONFIG_PATH = pathlib.Path.home() / ".ssh" / "config"
|
|
38
|
+
|
|
39
|
+
# devpod names each alias after the workspace, and brackets the block with
|
|
40
|
+
# markers it recognises again on the next `up`.
|
|
41
|
+
HOST_SUFFIX = ".devpod"
|
|
42
|
+
MARKER_PREFIX = "# DevPod Start "
|
|
43
|
+
|
|
44
|
+
# Set this to keep every command on the devpod transport, whatever the terminal
|
|
45
|
+
# says. An escape hatch for a machine where the ssh alias is stale or the
|
|
46
|
+
# tunnel misbehaves, matching DEVLAUNCH_NO_GH_TOKEN in spirit.
|
|
47
|
+
DISABLE_VAR = "DEVLAUNCH_NO_TTY"
|
|
48
|
+
|
|
49
|
+
_FALSEY = ("", "0", "false", "no")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def disabled() -> bool:
|
|
53
|
+
"""Whether the user opted this machine out of the pty transport."""
|
|
54
|
+
return os.environ.get(DISABLE_VAR, "").strip().lower() not in _FALSEY
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def host_alias(workspace_id: str) -> str:
|
|
58
|
+
"""The ssh host name devpod publishes for a workspace."""
|
|
59
|
+
return f"{workspace_id}{HOST_SUFFIX}"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def have_terminal(stdin: Any = None, stdout: Any = None) -> bool:
|
|
63
|
+
"""Whether dl was run from a terminal it can hand to the workspace.
|
|
64
|
+
|
|
65
|
+
Both directions have to be a terminal: a pty on stdout with stdin redirected
|
|
66
|
+
would give a TUI a screen it cannot receive keystrokes on, and a pty on
|
|
67
|
+
stdin with stdout redirected would fill the redirect with escape sequences.
|
|
68
|
+
|
|
69
|
+
Anything that isn't a real stream -- pytest's capture object, a closed file
|
|
70
|
+
-- counts as no terminal, because that is what it behaves like.
|
|
71
|
+
"""
|
|
72
|
+
if disabled():
|
|
73
|
+
return False
|
|
74
|
+
streams = (
|
|
75
|
+
sys.stdin if stdin is None else stdin,
|
|
76
|
+
sys.stdout if stdout is None else stdout,
|
|
77
|
+
)
|
|
78
|
+
for stream in streams:
|
|
79
|
+
try:
|
|
80
|
+
if not stream.isatty():
|
|
81
|
+
return False
|
|
82
|
+
except (AttributeError, ValueError, OSError):
|
|
83
|
+
return False
|
|
84
|
+
return True
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def devpod_host_configured(workspace_id: str, config_path: Optional[pathlib.Path] = None) -> bool:
|
|
88
|
+
"""Whether devpod has published an ssh alias for this workspace.
|
|
89
|
+
|
|
90
|
+
Matched on devpod's own start marker as a whole line, not as a substring:
|
|
91
|
+
workspace ids share prefixes by construction (`devlaunch-main-abcdefgh` and
|
|
92
|
+
`devlaunch-main-ijklmnop`), so a substring test would route a command at a
|
|
93
|
+
host alias belonging to a different container.
|
|
94
|
+
"""
|
|
95
|
+
path = SSH_CONFIG_PATH if config_path is None else config_path
|
|
96
|
+
try:
|
|
97
|
+
text = pathlib.Path(path).read_text(encoding="utf-8", errors="replace")
|
|
98
|
+
except OSError:
|
|
99
|
+
# No config, no permission, no alias -- all mean "fall back", never
|
|
100
|
+
# "fail the launch".
|
|
101
|
+
return False
|
|
102
|
+
marker = f"{MARKER_PREFIX}{host_alias(workspace_id)}"
|
|
103
|
+
return any(line.strip() == marker for line in text.splitlines())
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def ssh_command_args(
|
|
107
|
+
workspace_id: str,
|
|
108
|
+
command: str,
|
|
109
|
+
send_env: Iterable[str] = (),
|
|
110
|
+
workdir: Optional[str] = None,
|
|
111
|
+
) -> List[str]:
|
|
112
|
+
"""Build the OpenSSH invocation that runs `command` under a pty.
|
|
113
|
+
|
|
114
|
+
-t is what the whole module exists for: without it ssh runs a command with
|
|
115
|
+
no terminal, which is the situation being escaped.
|
|
116
|
+
|
|
117
|
+
send_env names variables only. OpenSSH reads their values from its own
|
|
118
|
+
environment, so a forwarded token never appears in argv where `ps` would
|
|
119
|
+
show it to every other user on the host -- the same discipline gh_auth
|
|
120
|
+
applies to the devpod transport.
|
|
121
|
+
|
|
122
|
+
Raises:
|
|
123
|
+
ValueError: if the workspace id would reach ssh as an option.
|
|
124
|
+
"""
|
|
125
|
+
# The alias goes in positionally, and ssh has no reliable `--`, so an id
|
|
126
|
+
# beginning with a dash would be read as a flag -- and `-o ProxyCommand=...`
|
|
127
|
+
# is arbitrary command execution on the host. devpod's own ids cannot look
|
|
128
|
+
# like that (workspace_id validates a leading word character) and neither
|
|
129
|
+
# can the ~/.ssh/config entry this is gated on, so reaching here means
|
|
130
|
+
# something upstream is already wrong: refuse rather than hand it to ssh.
|
|
131
|
+
if workspace_id.startswith("-"):
|
|
132
|
+
raise ValueError(
|
|
133
|
+
f"refusing to ssh to a workspace id that looks like an option: {workspace_id!r}"
|
|
134
|
+
)
|
|
135
|
+
args = ["ssh", "-t"]
|
|
136
|
+
for name in send_env:
|
|
137
|
+
args.extend(["-o", f"SendEnv={name}"])
|
|
138
|
+
args.append(host_alias(workspace_id))
|
|
139
|
+
# ssh has no --workdir, so a directory has to travel inside the command.
|
|
140
|
+
payload = command
|
|
141
|
+
if workdir:
|
|
142
|
+
payload = f"cd {shlex.quote(workdir)} && {command}"
|
|
143
|
+
args.append(payload)
|
|
144
|
+
return args
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "devlaunch"
|
|
3
|
-
version = "0.0.
|
|
3
|
+
version = "0.0.13"
|
|
4
4
|
authors = [{ name = "Austin Gregg-Smith", email = "blooop@gmail.com" }]
|
|
5
5
|
description = "DevLaunch - A streamlined CLI for devpod workspaces"
|
|
6
6
|
readme = "README.md"
|
|
@@ -22,7 +22,12 @@ platforms = ["linux-64"]
|
|
|
22
22
|
[tool.pixi.dependencies]
|
|
23
23
|
python = ">=3.10"
|
|
24
24
|
shellcheck = ">=0.10.0,<0.11"
|
|
25
|
-
devpod
|
|
25
|
+
# Match the devpod `dl` is actually installed alongside (pixi global ships
|
|
26
|
+
# 0.26.x). The pin used to be <0.9, which quietly meant the suite exercised a
|
|
27
|
+
# devpod five years of releases behind the one users run -- and it hid this
|
|
28
|
+
# module's bug outright: 0.8 requests a pty for `devpod ssh --command` whenever
|
|
29
|
+
# stdout is a terminal, and 0.26 never does. See devlaunch/tty_session.py.
|
|
30
|
+
devpod = ">=0.26.1,<0.27"
|
|
26
31
|
gh = ">=2.83.0,<3.0"
|
|
27
32
|
|
|
28
33
|
[tool.pixi.feature.py310.dependencies]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|