devlaunch 0.0.11__tar.gz → 0.0.12__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (23) hide show
  1. {devlaunch-0.0.11 → devlaunch-0.0.12}/PKG-INFO +1 -1
  2. devlaunch-0.0.12/devlaunch/devpod_ssh.py +143 -0
  3. {devlaunch-0.0.11 → devlaunch-0.0.12}/devlaunch/dl.py +47 -3
  4. {devlaunch-0.0.11 → devlaunch-0.0.12}/pyproject.toml +1 -1
  5. {devlaunch-0.0.11 → devlaunch-0.0.12}/.gitignore +0 -0
  6. {devlaunch-0.0.11 → devlaunch-0.0.12}/LICENSE +0 -0
  7. {devlaunch-0.0.11 → devlaunch-0.0.12}/README.md +0 -0
  8. {devlaunch-0.0.11 → devlaunch-0.0.12}/devlaunch/__init__.py +0 -0
  9. {devlaunch-0.0.11 → devlaunch-0.0.12}/devlaunch/aid.py +0 -0
  10. {devlaunch-0.0.11 → devlaunch-0.0.12}/devlaunch/completion.py +0 -0
  11. {devlaunch-0.0.11 → devlaunch-0.0.12}/devlaunch/completion_loader.py +0 -0
  12. {devlaunch-0.0.11 → devlaunch-0.0.12}/devlaunch/completions/__init__.py +0 -0
  13. {devlaunch-0.0.11 → devlaunch-0.0.12}/devlaunch/completions/dl.bash +0 -0
  14. {devlaunch-0.0.11 → devlaunch-0.0.12}/devlaunch/gh_auth.py +0 -0
  15. {devlaunch-0.0.11 → devlaunch-0.0.12}/devlaunch/workspace_id.py +0 -0
  16. {devlaunch-0.0.11 → devlaunch-0.0.12}/devlaunch/worktree/__init__.py +0 -0
  17. {devlaunch-0.0.11 → devlaunch-0.0.12}/devlaunch/worktree/branch_manager.py +0 -0
  18. {devlaunch-0.0.11 → devlaunch-0.0.12}/devlaunch/worktree/config.py +0 -0
  19. {devlaunch-0.0.11 → devlaunch-0.0.12}/devlaunch/worktree/migration.py +0 -0
  20. {devlaunch-0.0.11 → devlaunch-0.0.12}/devlaunch/worktree/models.py +0 -0
  21. {devlaunch-0.0.11 → devlaunch-0.0.12}/devlaunch/worktree/repo_manager.py +0 -0
  22. {devlaunch-0.0.11 → devlaunch-0.0.12}/devlaunch/worktree/storage.py +0 -0
  23. {devlaunch-0.0.11 → devlaunch-0.0.12}/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.11
3
+ Version: 0.0.12
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
@@ -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
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
@@ -832,6 +832,38 @@ def run_devpod(
832
832
  raise DevpodNotInstalled(DEVPOD_MISSING_MESSAGE) from e
833
833
 
834
834
 
835
+ def run_devpod_session(
836
+ args: List[str], env: Optional[Dict[str, str]] = None
837
+ ) -> devpod_ssh.SshOutcome:
838
+ """Run a devpod command that hands its stdin/stdout to a terminal session.
839
+
840
+ stdin and stdout are inherited untouched — devpod puts the real terminal into
841
+ raw mode through them, and requests a pty on that basis. Only stderr is read,
842
+ which under a pty carries devpod's own warnings and errors and nothing else,
843
+ so that devpod's report of how the session ended can be interpreted rather
844
+ than dumped on the user. See devpod_ssh for why that is necessary.
845
+ """
846
+ cmd = ["devpod"] + args
847
+ logging.debug("Running: %s", " ".join(cmd))
848
+ # nosec B603 - using list form, not shell=True; no command injection risk
849
+ with subprocess.Popen(
850
+ cmd,
851
+ stderr=subprocess.PIPE,
852
+ text=True,
853
+ encoding="utf-8",
854
+ errors="replace",
855
+ env=env,
856
+ ) as proc:
857
+ # proc.stderr is a pipe because PIPE was asked for, but Popen's type
858
+ # cannot express that, so the narrowing happens here rather than by
859
+ # widening filter_devpod_stderr to a None it would have no answer for.
860
+ pipe = proc.stderr
861
+ remote_status = (
862
+ devpod_ssh.filter_devpod_stderr(pipe, sys.stderr) if pipe is not None else None
863
+ )
864
+ return devpod_ssh.interpret(proc.returncode, remote_status)
865
+
866
+
835
867
  # The memoized `devpod list` snapshot. A dict rather than a module-level
836
868
  # Optional so the accessors below need no `global`, and so "nothing read yet"
837
869
  # (no key at all) stays distinguishable from "devpod has no workspaces" (an
@@ -1049,8 +1081,20 @@ def workspace_ssh(
1049
1081
  args.extend(token_args)
1050
1082
 
1051
1083
  logging.info(f"SSH command: devpod {' '.join(args)}")
1052
- result = run_devpod(args, env=env)
1053
- return result.returncode
1084
+ outcome = run_devpod_session(args, env=env)
1085
+
1086
+ # The two arms carry the same kind of number from different processes, which
1087
+ # is exactly the confusion this used to make: `dl` reported devpod's exit
1088
+ # code (always 1) for a session that had ended perfectly normally with, say,
1089
+ # 130. Whichever arm this is, the status returned is the session's.
1090
+ match outcome:
1091
+ case devpod_ssh.RemoteExit(status=status):
1092
+ return status
1093
+ case devpod_ssh.DevpodFailed(exit_code=exit_code):
1094
+ logging.debug("devpod ssh failed with exit code %s", exit_code)
1095
+ return exit_code
1096
+ case _ as unhandled:
1097
+ devpod_ssh.assert_never(unhandled)
1054
1098
 
1055
1099
 
1056
1100
  def attach_workspace(workspace_id: str, shell_command: Optional[str] = None) -> int:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "devlaunch"
3
- version = "0.0.11"
3
+ version = "0.0.12"
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"
File without changes
File without changes
File without changes
File without changes