devlaunch 0.0.12__tar.gz → 0.0.14__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 (25) hide show
  1. {devlaunch-0.0.12 → devlaunch-0.0.14}/PKG-INFO +48 -4
  2. {devlaunch-0.0.12 → devlaunch-0.0.14}/README.md +47 -3
  3. {devlaunch-0.0.12 → devlaunch-0.0.14}/devlaunch/dl.py +104 -16
  4. {devlaunch-0.0.12 → devlaunch-0.0.14}/devlaunch/gh_auth.py +14 -0
  5. devlaunch-0.0.14/devlaunch/tools.py +219 -0
  6. devlaunch-0.0.14/devlaunch/tty_session.py +144 -0
  7. {devlaunch-0.0.12 → devlaunch-0.0.14}/pyproject.toml +7 -2
  8. {devlaunch-0.0.12 → devlaunch-0.0.14}/.gitignore +0 -0
  9. {devlaunch-0.0.12 → devlaunch-0.0.14}/LICENSE +0 -0
  10. {devlaunch-0.0.12 → devlaunch-0.0.14}/devlaunch/__init__.py +0 -0
  11. {devlaunch-0.0.12 → devlaunch-0.0.14}/devlaunch/aid.py +0 -0
  12. {devlaunch-0.0.12 → devlaunch-0.0.14}/devlaunch/completion.py +0 -0
  13. {devlaunch-0.0.12 → devlaunch-0.0.14}/devlaunch/completion_loader.py +0 -0
  14. {devlaunch-0.0.12 → devlaunch-0.0.14}/devlaunch/completions/__init__.py +0 -0
  15. {devlaunch-0.0.12 → devlaunch-0.0.14}/devlaunch/completions/dl.bash +0 -0
  16. {devlaunch-0.0.12 → devlaunch-0.0.14}/devlaunch/devpod_ssh.py +0 -0
  17. {devlaunch-0.0.12 → devlaunch-0.0.14}/devlaunch/workspace_id.py +0 -0
  18. {devlaunch-0.0.12 → devlaunch-0.0.14}/devlaunch/worktree/__init__.py +0 -0
  19. {devlaunch-0.0.12 → devlaunch-0.0.14}/devlaunch/worktree/branch_manager.py +0 -0
  20. {devlaunch-0.0.12 → devlaunch-0.0.14}/devlaunch/worktree/config.py +0 -0
  21. {devlaunch-0.0.12 → devlaunch-0.0.14}/devlaunch/worktree/migration.py +0 -0
  22. {devlaunch-0.0.12 → devlaunch-0.0.14}/devlaunch/worktree/models.py +0 -0
  23. {devlaunch-0.0.12 → devlaunch-0.0.14}/devlaunch/worktree/repo_manager.py +0 -0
  24. {devlaunch-0.0.12 → devlaunch-0.0.14}/devlaunch/worktree/storage.py +0 -0
  25. {devlaunch-0.0.12 → devlaunch-0.0.14}/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.12
3
+ Version: 0.0.14
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
@@ -233,8 +246,9 @@ whichever answers first, and hands it to the container as `GH_TOKEN`. That reach
233
246
  any image and any container user, unlike a bind-mount of `~/.config/gh`, and it
234
247
  works whether the host keeps its token in `hosts.yml` or in a keyring. The token
235
248
  is passed to devpod through a private file and through devpod's own environment,
236
- never on a command line, so it does not appear in `ps`. The container still needs
237
- `gh` installed for the login to be of any use. Check a workspace with:
249
+ never on a command line, so it does not appear in `ps`. `dl` installs `gh` itself
250
+ (see [Tools in every workspace](#tools-in-every-workspace)), so the login has
251
+ something to be spent on whatever the image ships. Check a workspace with:
238
252
 
239
253
  ```bash
240
254
  dl <workspace> -- gh auth status
@@ -265,6 +279,36 @@ that is *already running* skips that step, and the token it was given at startup
265
279
  stays in place — including one it was given before you set
266
280
  `DEVLAUNCH_NO_GH_TOKEN`. Run `dl <workspace> restart` to replace it.
267
281
 
282
+ ## Tools in every workspace
283
+
284
+ `gh` and `claude` are available in every workspace `dl` opens, in every kind of
285
+ session — an interactive `dl <workspace>`, a one-shot `dl <workspace> -- <command>`,
286
+ and `aid`. The repo's `devcontainer.json` does not have to provide them, and most
287
+ do not: `dl` launches arbitrary repos, so a guarantee that depended on the image
288
+ would not be a guarantee.
289
+
290
+ They are installed with `pixi global` on `devpod up`, and put on the PATH of a
291
+ login shell through whichever of `~/.bash_profile`, `~/.bash_login` or `~/.profile`
292
+ bash actually reads — it sources only the first of those that exists, so an image
293
+ shipping a `~/.bash_profile` never reads `~/.profile`. A workspace that already has both is left alone —
294
+ the check runs first, so the cost after the first launch is one round-trip and no
295
+ network. If `pixi` is missing from the image, `dl` installs that too.
296
+
297
+ An install that fails costs the workspace its tools, not its launch: `dl` logs a
298
+ warning and hands you the session anyway.
299
+
300
+ ```bash
301
+ DEVLAUNCH_NO_TOOLS=1 dl someone/repo
302
+ ```
303
+
304
+ | Variable | Description |
305
+ |----------|-------------|
306
+ | `DEVLAUNCH_NO_TOOLS=1` | Do not install `gh` or `claude` into workspaces |
307
+
308
+ Attaching to a workspace that is *already running* skips `devpod up`, and so skips
309
+ this too. A workspace started by something other than `dl` — or created before this
310
+ existed — picks the tools up on its next `dl <workspace> restart`.
311
+
268
312
  ## Global Commands
269
313
 
270
314
  | Command | Description |
@@ -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
@@ -211,8 +224,9 @@ whichever answers first, and hands it to the container as `GH_TOKEN`. That reach
211
224
  any image and any container user, unlike a bind-mount of `~/.config/gh`, and it
212
225
  works whether the host keeps its token in `hosts.yml` or in a keyring. The token
213
226
  is passed to devpod through a private file and through devpod's own environment,
214
- never on a command line, so it does not appear in `ps`. The container still needs
215
- `gh` installed for the login to be of any use. Check a workspace with:
227
+ never on a command line, so it does not appear in `ps`. `dl` installs `gh` itself
228
+ (see [Tools in every workspace](#tools-in-every-workspace)), so the login has
229
+ something to be spent on whatever the image ships. Check a workspace with:
216
230
 
217
231
  ```bash
218
232
  dl <workspace> -- gh auth status
@@ -243,6 +257,36 @@ that is *already running* skips that step, and the token it was given at startup
243
257
  stays in place — including one it was given before you set
244
258
  `DEVLAUNCH_NO_GH_TOKEN`. Run `dl <workspace> restart` to replace it.
245
259
 
260
+ ## Tools in every workspace
261
+
262
+ `gh` and `claude` are available in every workspace `dl` opens, in every kind of
263
+ session — an interactive `dl <workspace>`, a one-shot `dl <workspace> -- <command>`,
264
+ and `aid`. The repo's `devcontainer.json` does not have to provide them, and most
265
+ do not: `dl` launches arbitrary repos, so a guarantee that depended on the image
266
+ would not be a guarantee.
267
+
268
+ They are installed with `pixi global` on `devpod up`, and put on the PATH of a
269
+ login shell through whichever of `~/.bash_profile`, `~/.bash_login` or `~/.profile`
270
+ bash actually reads — it sources only the first of those that exists, so an image
271
+ shipping a `~/.bash_profile` never reads `~/.profile`. A workspace that already has both is left alone —
272
+ the check runs first, so the cost after the first launch is one round-trip and no
273
+ network. If `pixi` is missing from the image, `dl` installs that too.
274
+
275
+ An install that fails costs the workspace its tools, not its launch: `dl` logs a
276
+ warning and hands you the session anyway.
277
+
278
+ ```bash
279
+ DEVLAUNCH_NO_TOOLS=1 dl someone/repo
280
+ ```
281
+
282
+ | Variable | Description |
283
+ |----------|-------------|
284
+ | `DEVLAUNCH_NO_TOOLS=1` | Do not install `gh` or `claude` into workspaces |
285
+
286
+ Attaching to a workspace that is *already running* skips `devpod up`, and so skips
287
+ this too. A workspace started by something other than `dl` — or created before this
288
+ existed — picks the tools up on its next `dl <workspace> restart`.
289
+
246
290
  ## Global Commands
247
291
 
248
292
  | Command | Description |
@@ -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 devpod_ssh, gh_auth
36
+ from . import devpod_ssh, gh_auth, tools, 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 DevpodNotInstalled(Exception):
45
- """The devpod binary dl shells out to is not on PATH.
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 run_devpod and
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
@@ -1042,9 +1062,37 @@ def workspace_up(
1042
1062
  # `up` creates and starts workspaces, so any snapshot of `devpod list`
1043
1063
  # taken before it is now out of date.
1044
1064
  invalidate_workspace_list_cache()
1065
+ # The tools a session always needs, for whatever repo this is: `up` is the
1066
+ # one path that runs for every workspace dl opens and is already slow
1067
+ # enough to absorb a round-trip. Only after a successful `up` -- there is
1068
+ # no container to install into otherwise.
1069
+ if result.returncode == 0 and identity:
1070
+ tools.ensure_tools(identity, run_devpod)
1045
1071
  return result
1046
1072
 
1047
1073
 
1074
+ def run_ssh(args: List[str], env: Optional[Dict[str, str]] = None) -> subprocess.CompletedProcess:
1075
+ """Run OpenSSH, dl's other way into a workspace.
1076
+
1077
+ Separate from run_devpod because it is a different binary with a different
1078
+ failure mode, and because the pty transport has to be swappable in tests
1079
+ without stubbing out every devpod call as well.
1080
+
1081
+ Takes the whole argv, `ssh` included, because tty_session composes a
1082
+ complete command rather than a tail of flags -- unlike run_devpod, whose
1083
+ callers each build up their own subcommand.
1084
+
1085
+ Security note: list form, not shell=True, so nothing in the payload is
1086
+ interpreted by a host shell.
1087
+ """
1088
+ logging.debug("Running: %s", " ".join(args))
1089
+ try:
1090
+ # nosec B603 B607 - list form, not shell=True; no command injection risk
1091
+ return subprocess.run(list(args), check=False, env=env)
1092
+ except FileNotFoundError as e:
1093
+ raise SshNotInstalled(SSH_MISSING_MESSAGE) from e
1094
+
1095
+
1048
1096
  def workspace_ssh(
1049
1097
  workspace: str,
1050
1098
  command: Optional[str] = None,
@@ -1052,6 +1100,17 @@ def workspace_ssh(
1052
1100
  ) -> int:
1053
1101
  """SSH into a workspace, optionally running a command.
1054
1102
 
1103
+ A command runs through whichever of the two transports can give it what it
1104
+ needs. devpod's --command never requests a pty, which is fine for `make
1105
+ test` and fatal for anything interactive -- `claude` reads the pipe as a
1106
+ non-interactive invocation and exits instead of starting a session. So when
1107
+ dl is itself on a terminal, the command goes to OpenSSH through the host
1108
+ alias devpod published, with -t (see tty_session). Otherwise, and when no
1109
+ alias exists, it goes to devpod as before.
1110
+
1111
+ A bare attach is left on devpod: it already gets a pty, being the one case
1112
+ devpod requests one for.
1113
+
1055
1114
  Args:
1056
1115
  workspace: The workspace ID to SSH into
1057
1116
  command: Optional command to run (if None, starts interactive shell)
@@ -1060,19 +1119,34 @@ def workspace_ssh(
1060
1119
  if given a path that doesn't exist in the container, so never guess
1061
1120
  one from the workspace id.
1062
1121
  """
1063
- args = ["ssh", workspace]
1122
+ # devpod runs --command under a non-login, non-interactive `bash -c`, which
1123
+ # sources neither ~/.profile nor ~/.bashrc -- so PATH entries the image adds
1124
+ # there (notably $HOME/.pixi/bin) are missing and the payload dies with
1125
+ # "command not found". An interactive attach gets a login shell, so wrap
1126
+ # here to give both paths the same PATH. dl launches arbitrary repos, so the
1127
+ # parity has to come from the invocation rather than from any particular
1128
+ # devcontainer.json.
1129
+ #
1130
+ # Built once and shared by both transports: two copies of this expression
1131
+ # would be two chances for the transports to drift, which is the whole
1132
+ # failure this function exists to have fixed.
1133
+ payload = f"bash -lc {shlex.quote(command)}" if command else None
1134
+
1135
+ if payload is not None and tty_session.have_terminal():
1136
+ if tty_session.devpod_host_configured(workspace):
1137
+ return _ssh_with_terminal(workspace, payload, workdir)
1138
+ logging.warning(
1139
+ "No devpod ssh host entry for %s, so this command gets no terminal; "
1140
+ "interactive programs may exit immediately. `dl %s restart` republishes it.",
1141
+ workspace,
1142
+ workspace,
1143
+ )
1064
1144
 
1145
+ args = ["ssh", workspace]
1065
1146
  if workdir:
1066
1147
  args.extend(["--workdir", workdir])
1067
- if command:
1068
- # devpod runs --command under a non-login, non-interactive `bash -c`,
1069
- # which sources neither ~/.profile nor ~/.bashrc -- so PATH entries the
1070
- # image adds there (notably $HOME/.pixi/bin) are missing and the payload
1071
- # dies with "command not found". An interactive attach gets a login
1072
- # shell, so wrap here to give both paths the same PATH. dl launches
1073
- # arbitrary repos, so the parity has to come from the invocation rather
1074
- # than from any particular devcontainer.json.
1075
- args.extend(["--command", f"bash -lc {shlex.quote(command)}"])
1148
+ if payload is not None:
1149
+ args.extend(["--command", payload])
1076
1150
 
1077
1151
  # Attaching to a running workspace skips workspace_up, so the gh login has
1078
1152
  # to be offered here too. Only the variable name lands in args; the token
@@ -1097,6 +1171,20 @@ def workspace_ssh(
1097
1171
  devpod_ssh.assert_never(unhandled)
1098
1172
 
1099
1173
 
1174
+ def _ssh_with_terminal(workspace: str, payload: str, workdir: Optional[str]) -> int:
1175
+ """Run an already-wrapped payload under a pty via OpenSSH.
1176
+
1177
+ No devpod_ssh.SshOutcome here, and nothing to recover: OpenSSH exits with
1178
+ the remote program's own status, which is the thing devpod loses by wrapping
1179
+ its *ssh.ExitError three times before type-asserting on it. This transport
1180
+ never had that bug, so it needs none of the machinery that works around it.
1181
+ """
1182
+ env_names, env = gh_auth.openssh_env_names_and_env()
1183
+ args = tty_session.ssh_command_args(workspace, payload, send_env=env_names, workdir=workdir)
1184
+ logging.info("SSH command: %s", " ".join(args))
1185
+ return run_ssh(args, env=env).returncode
1186
+
1187
+
1100
1188
  def attach_workspace(workspace_id: str, shell_command: Optional[str] = None) -> int:
1101
1189
  """Hand the workspace to the user: name its prompt, then ssh in.
1102
1190
 
@@ -1299,7 +1387,7 @@ def main(argv: Optional[List[str]] = None) -> int:
1299
1387
  invalidate_workspace_list_cache()
1300
1388
  try:
1301
1389
  return _run_cli(argv)
1302
- except DevpodNotInstalled as e:
1390
+ except MissingBinary as e:
1303
1391
  print(e, file=sys.stderr)
1304
1392
  return DEVPOD_MISSING_EXIT_CODE
1305
1393
 
@@ -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,219 @@
1
+ """Put the tools a session always needs into every workspace devlaunch opens.
2
+
3
+ `gh` and `claude` are not optional extras for the way these workspaces get used:
4
+ `dl` already forwards the host's GitHub login into every container, which is
5
+ worth nothing when the container has no `gh` to spend it, and `aid` exists to
6
+ run `claude` in there. Both currently arrive only when the repo's own
7
+ devcontainer.json arranges them -- this repo does, through
8
+ `.devcontainer/claude-code/`, which is why `claude` is present in its workspaces
9
+ and `gh` (a project pixi dependency, reachable only as `pixi run gh`) is not.
10
+
11
+ A guarantee that depends on the repo is not a guarantee. `dl` launches arbitrary
12
+ repos, so the tools have to come from the invocation, the same argument
13
+ gh_auth makes for the token and workspace_ssh's login shell makes for PATH.
14
+
15
+ Installing them costs a devpod round-trip on `up`, which already runs for
16
+ seconds, and nothing at all on the attach paths -- the script exits before doing
17
+ any work when both tools are already there, and `up` is the only caller.
18
+
19
+ Two consequences worth knowing:
20
+
21
+ - A workspace that is already running when `dl` reaches it skips `up` entirely
22
+ (the fast-attach path), so it is not topped up. That covers workspaces started
23
+ by something other than `dl`, and ones created before this existed; both get
24
+ the tools on their next `dl <ws> restart` or `up`.
25
+ - Provisioning is a convenience, so a failed install costs the workspace its
26
+ tools and not its launch: an install that fails is logged and the session
27
+ starts anyway. The exception is a devpod that has gone missing between `up`
28
+ and here, which dl treats as fatal everywhere else and which this does not
29
+ make an exception of.
30
+ """
31
+
32
+ import logging
33
+ import os
34
+ import shlex
35
+ from dataclasses import dataclass
36
+ from typing import List, Optional, Sequence
37
+
38
+ # Set this to opt a machine out of installing tools into workspaces.
39
+ DISABLE_VAR = "DEVLAUNCH_NO_TOOLS"
40
+
41
+ _FALSEY = ("", "0", "false", "no")
42
+
43
+ # The claude package lives in a personal channel rather than conda-forge.
44
+ BLOOOP_CHANNEL = "https://prefix.dev/blooop"
45
+
46
+ # Which file a bash login shell will actually read. bash tries ~/.bash_profile,
47
+ # ~/.bash_login and ~/.profile in that order and sources only the first that
48
+ # exists, so appending to ~/.profile in an image that ships a ~/.bash_profile
49
+ # writes to a file nothing reads.
50
+ _PROFILE_RESOLUTION = "\n".join(
51
+ [
52
+ 'if [ -f "$HOME/.bash_profile" ]; then PROFILE="$HOME/.bash_profile"',
53
+ 'elif [ -f "$HOME/.bash_login" ]; then PROFILE="$HOME/.bash_login"',
54
+ 'else PROFILE="$HOME/.profile"',
55
+ "fi",
56
+ ]
57
+ )
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class Tool:
62
+ """A binary a session must be able to run, and the pixi package providing it.
63
+
64
+ `command` is what a shell has to find on PATH, which is not always the
65
+ package name -- `claude` ships in `claude-shim` -- so both are recorded
66
+ rather than one being derived from the other.
67
+ """
68
+
69
+ command: str
70
+ package: str
71
+ channel: Optional[str] = None
72
+
73
+ @property
74
+ def install_args(self) -> List[str]:
75
+ """The `pixi global install` arguments that provide this tool."""
76
+ if self.channel:
77
+ return ["--channel", self.channel, self.package]
78
+ return [self.package]
79
+
80
+
81
+ REQUIRED_TOOLS: Sequence[Tool] = (
82
+ Tool(command="gh", package="gh"),
83
+ Tool(command="claude", package="claude-shim", channel=BLOOOP_CHANNEL),
84
+ )
85
+
86
+
87
+ def provisioning_disabled() -> bool:
88
+ """Whether the user opted this machine out of installing tools."""
89
+ return os.environ.get(DISABLE_VAR, "").strip().lower() not in _FALSEY
90
+
91
+
92
+ def _install_line(tool: Tool) -> str:
93
+ args = " ".join(shlex.quote(arg) for arg in tool.install_args)
94
+ return (
95
+ f"if ! command -v {shlex.quote(tool.command)} >/dev/null 2>&1; then\n"
96
+ f' echo "devlaunch: installing {tool.command}"\n'
97
+ f" pixi global install {args} || failed=1\n"
98
+ f"fi"
99
+ )
100
+
101
+
102
+ def provision_script(tools: Sequence[Tool] = REQUIRED_TOOLS) -> str:
103
+ """The shell script that makes `tools` available in a workspace.
104
+
105
+ Idempotent and cheap on the common path: every tool already on PATH is
106
+ skipped, so a workspace that has been provisioned before does nothing but
107
+ answer. It runs under a login shell (see ensure_tools), which is what puts
108
+ an earlier run's ~/.pixi/bin on PATH -- checked from a non-login shell every
109
+ tool would look missing and be reinstalled on every launch.
110
+
111
+ Exits 0 unless an install actually failed, so "nothing to do" and "all
112
+ installs worked" are the same answer to the caller.
113
+ """
114
+ all_present = " && ".join(
115
+ f"command -v {shlex.quote(tool.command)} >/dev/null 2>&1" for tool in tools
116
+ )
117
+ installs = "\n".join(_install_line(tool) for tool in tools)
118
+ # The trampoline pixi writes into ~/.pixi/bin does not work for packages
119
+ # that ship a shell script, which is why the env's own bin directory is
120
+ # added too -- the same workaround .devcontainer/claude-code/install.sh
121
+ # carries, for the same package.
122
+ profile_lines = "\n".join(
123
+ [
124
+ # bash reads exactly one of these on login, in this order, and
125
+ # stops at the first that exists -- so an image shipping a
126
+ # ~/.bash_profile means ~/.profile is never sourced at all. Writing
127
+ # to the wrong one leaves the tools installed and unreachable, and
128
+ # (since the check above is `command -v`) reinstalled from scratch
129
+ # on every single launch.
130
+ _PROFILE_RESOLUTION,
131
+ 'grep -q "\\.pixi/bin" "$PROFILE" 2>/dev/null || '
132
+ 'echo \'export PATH="$HOME/.pixi/bin:$PATH"\' >> "$PROFILE" || failed=1',
133
+ 'grep -q "pixi/envs/claude-shim" "$PROFILE" 2>/dev/null || '
134
+ 'echo \'[ -d "$HOME/.pixi/envs/claude-shim/bin" ] && '
135
+ 'export PATH="$HOME/.pixi/envs/claude-shim/bin:$PATH"\' >> "$PROFILE" || failed=1',
136
+ ]
137
+ )
138
+ return "\n".join(
139
+ [
140
+ "set -u",
141
+ # Everything this script prints is progress, and progress is not
142
+ # the answer to anything: `dl <ws> -- cmd > file` on a workspace
143
+ # that needs provisioning must put the command's output in the
144
+ # file and nothing else. pixi writes to stdout too, so redirect
145
+ # once here rather than per line.
146
+ "exec >&2",
147
+ "failed=0",
148
+ # Everything already there: leave without touching pixi, the
149
+ # profile, or the network. Every launch after the first takes this.
150
+ f"if {all_present}; then exit 0; fi",
151
+ _pixi_bootstrap(),
152
+ installs,
153
+ profile_lines,
154
+ 'exit "$failed"',
155
+ ]
156
+ )
157
+
158
+
159
+ def _pixi_bootstrap() -> str:
160
+ """Install pixi if the image has none, since every tool here comes from it.
161
+
162
+ An arbitrary repo's container is not required to carry pixi, and without it
163
+ the guarantee this module makes would hold only for images that happen to
164
+ have it. Failure is left to the install steps to report: they will fail for
165
+ a reason the log can name.
166
+ """
167
+ return "\n".join(
168
+ [
169
+ "if ! command -v pixi >/dev/null 2>&1; then",
170
+ ' echo "devlaunch: installing pixi"',
171
+ " curl -fsSL https://pixi.sh/install.sh | bash >/dev/null 2>&1 || true",
172
+ ' export PATH="$HOME/.pixi/bin:$PATH"',
173
+ "fi",
174
+ ]
175
+ )
176
+
177
+
178
+ def ensure_tools(workspace: str, runner, tools: Sequence[Tool] = REQUIRED_TOOLS) -> bool:
179
+ """Make `tools` available in `workspace`. Returns whether they now are.
180
+
181
+ `runner` is dl.run_devpod, passed in rather than imported to keep this
182
+ module off dl's import cycle and testable without a devpod.
183
+
184
+ The payload goes through `bash -lc` for the same reason workspace_ssh wraps
185
+ its own: devpod runs --command under a shell that sources no profile, so
186
+ PATH would be missing the pixi directory this module itself installs into.
187
+
188
+ Output is not captured. A cold install downloads pixi and two packages and
189
+ takes tens of seconds, which with nothing on the terminal reads as a hung
190
+ `dl`; the script's own progress lines are the answer to that, and they are
191
+ worth nothing in a buffer. A workspace that needs no work stays silent
192
+ because the script prints nothing on that path.
193
+
194
+ Not every failure is swallowed: DevpodNotInstalled is deliberately not an
195
+ OSError (see dl.DevpodNotInstalled) so that it is never mistaken for a
196
+ failure of the thing being attempted, and it keeps that meaning here.
197
+ """
198
+ if provisioning_disabled():
199
+ logging.debug("%s is set; not installing tools into %s", DISABLE_VAR, workspace)
200
+ return False
201
+
202
+ script = provision_script(tools)
203
+ command = f"bash -lc {shlex.quote(script)}"
204
+ try:
205
+ result = runner(["ssh", workspace, "--command", command])
206
+ except OSError as e:
207
+ logging.debug("Could not install tools into %s: %s", workspace, e)
208
+ return False
209
+
210
+ if result.returncode != 0:
211
+ # Named, not raised: the workspace is up and the user asked for a
212
+ # session, not for an install.
213
+ logging.warning(
214
+ "Could not install %s into %s; the session will start without them.",
215
+ " and ".join(tool.command for tool in tools),
216
+ workspace,
217
+ )
218
+ return False
219
+ return True
@@ -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.12"
3
+ version = "0.0.14"
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 = ">=0.8.0,<0.9"
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