devlaunch 0.0.13__tar.gz → 0.0.15__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 (26) hide show
  1. {devlaunch-0.0.13 → devlaunch-0.0.15}/PKG-INFO +48 -4
  2. {devlaunch-0.0.13 → devlaunch-0.0.15}/README.md +47 -3
  3. {devlaunch-0.0.13 → devlaunch-0.0.15}/devlaunch/aid.py +43 -12
  4. devlaunch-0.0.15/devlaunch/devpod_provider.py +116 -0
  5. {devlaunch-0.0.13 → devlaunch-0.0.15}/devlaunch/dl.py +7 -1
  6. devlaunch-0.0.15/devlaunch/tools.py +219 -0
  7. {devlaunch-0.0.13 → devlaunch-0.0.15}/pyproject.toml +2 -2
  8. {devlaunch-0.0.13 → devlaunch-0.0.15}/.gitignore +0 -0
  9. {devlaunch-0.0.13 → devlaunch-0.0.15}/LICENSE +0 -0
  10. {devlaunch-0.0.13 → devlaunch-0.0.15}/devlaunch/__init__.py +0 -0
  11. {devlaunch-0.0.13 → devlaunch-0.0.15}/devlaunch/completion.py +0 -0
  12. {devlaunch-0.0.13 → devlaunch-0.0.15}/devlaunch/completion_loader.py +0 -0
  13. {devlaunch-0.0.13 → devlaunch-0.0.15}/devlaunch/completions/__init__.py +0 -0
  14. {devlaunch-0.0.13 → devlaunch-0.0.15}/devlaunch/completions/dl.bash +0 -0
  15. {devlaunch-0.0.13 → devlaunch-0.0.15}/devlaunch/devpod_ssh.py +0 -0
  16. {devlaunch-0.0.13 → devlaunch-0.0.15}/devlaunch/gh_auth.py +0 -0
  17. {devlaunch-0.0.13 → devlaunch-0.0.15}/devlaunch/tty_session.py +0 -0
  18. {devlaunch-0.0.13 → devlaunch-0.0.15}/devlaunch/workspace_id.py +0 -0
  19. {devlaunch-0.0.13 → devlaunch-0.0.15}/devlaunch/worktree/__init__.py +0 -0
  20. {devlaunch-0.0.13 → devlaunch-0.0.15}/devlaunch/worktree/branch_manager.py +0 -0
  21. {devlaunch-0.0.13 → devlaunch-0.0.15}/devlaunch/worktree/config.py +0 -0
  22. {devlaunch-0.0.13 → devlaunch-0.0.15}/devlaunch/worktree/migration.py +0 -0
  23. {devlaunch-0.0.13 → devlaunch-0.0.15}/devlaunch/worktree/models.py +0 -0
  24. {devlaunch-0.0.13 → devlaunch-0.0.15}/devlaunch/worktree/repo_manager.py +0 -0
  25. {devlaunch-0.0.13 → devlaunch-0.0.15}/devlaunch/worktree/storage.py +0 -0
  26. {devlaunch-0.0.13 → devlaunch-0.0.15}/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.13
3
+ Version: 0.0.15
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
@@ -105,13 +105,26 @@ aid blooop/devlaunch@fix/42 fix the flaky test
105
105
  is exactly
106
106
 
107
107
  ```bash
108
- dl blooop/devlaunch@fix/42 -- claude 'fix the flaky test'
108
+ dl blooop/devlaunch@fix/42 -- IS_SANDBOX=1 claude --dangerously-skip-permissions 'fix the flaky test'
109
109
  ```
110
110
 
111
111
  That means an `aid` workspace *is* the `dl` workspace: same clone, same workspace
112
112
  id, same container — started if stopped, attached to if already running, and never
113
113
  rebuilt just because `aid` asked for it. Anything `dl` learns, `aid` gets.
114
114
 
115
+ `claude` is started with `--dangerously-skip-permissions`. The agent is already
116
+ inside a disposable container holding only this repo, so the per-tool prompts it
117
+ would ask on the host protect nothing here and would stall an unattended run.
118
+ `IS_SANDBOX=1` rides along because `claude` otherwise refuses that flag outright
119
+ under `uid 0`, and devcontainers that run as root are ordinary. The variable is
120
+ scoped to the agent process, not exported into your shell.
121
+
122
+ The trade is worth stating plainly: an agent started this way edits, runs and
123
+ deletes inside the container without asking. It cannot reach your host, but it can
124
+ rewrite the checkout it is in, so review an `aid` workspace before pushing rather
125
+ than treating it as a sandbox that will stop it for you. `--codex` and `--gemini`
126
+ are unaffected, and `dl <ws> -- claude` still runs exactly what you typed.
127
+
115
128
  | Option | Description |
116
129
  |--------|-------------|
117
130
  | `--claude`, `--codex`, `--gemini` | Pick the agent (default: `claude`) |
@@ -246,8 +259,9 @@ whichever answers first, and hands it to the container as `GH_TOKEN`. That reach
246
259
  any image and any container user, unlike a bind-mount of `~/.config/gh`, and it
247
260
  works whether the host keeps its token in `hosts.yml` or in a keyring. The token
248
261
  is passed to devpod through a private file and through devpod's own environment,
249
- never on a command line, so it does not appear in `ps`. The container still needs
250
- `gh` installed for the login to be of any use. Check a workspace with:
262
+ never on a command line, so it does not appear in `ps`. `dl` installs `gh` itself
263
+ (see [Tools in every workspace](#tools-in-every-workspace)), so the login has
264
+ something to be spent on whatever the image ships. Check a workspace with:
251
265
 
252
266
  ```bash
253
267
  dl <workspace> -- gh auth status
@@ -278,6 +292,36 @@ that is *already running* skips that step, and the token it was given at startup
278
292
  stays in place — including one it was given before you set
279
293
  `DEVLAUNCH_NO_GH_TOKEN`. Run `dl <workspace> restart` to replace it.
280
294
 
295
+ ## Tools in every workspace
296
+
297
+ `gh` and `claude` are available in every workspace `dl` opens, in every kind of
298
+ session — an interactive `dl <workspace>`, a one-shot `dl <workspace> -- <command>`,
299
+ and `aid`. The repo's `devcontainer.json` does not have to provide them, and most
300
+ do not: `dl` launches arbitrary repos, so a guarantee that depended on the image
301
+ would not be a guarantee.
302
+
303
+ They are installed with `pixi global` on `devpod up`, and put on the PATH of a
304
+ login shell through whichever of `~/.bash_profile`, `~/.bash_login` or `~/.profile`
305
+ bash actually reads — it sources only the first of those that exists, so an image
306
+ shipping a `~/.bash_profile` never reads `~/.profile`. A workspace that already has both is left alone —
307
+ the check runs first, so the cost after the first launch is one round-trip and no
308
+ network. If `pixi` is missing from the image, `dl` installs that too.
309
+
310
+ An install that fails costs the workspace its tools, not its launch: `dl` logs a
311
+ warning and hands you the session anyway.
312
+
313
+ ```bash
314
+ DEVLAUNCH_NO_TOOLS=1 dl someone/repo
315
+ ```
316
+
317
+ | Variable | Description |
318
+ |----------|-------------|
319
+ | `DEVLAUNCH_NO_TOOLS=1` | Do not install `gh` or `claude` into workspaces |
320
+
321
+ Attaching to a workspace that is *already running* skips `devpod up`, and so skips
322
+ this too. A workspace started by something other than `dl` — or created before this
323
+ existed — picks the tools up on its next `dl <workspace> restart`.
324
+
281
325
  ## Global Commands
282
326
 
283
327
  | Command | Description |
@@ -83,13 +83,26 @@ aid blooop/devlaunch@fix/42 fix the flaky test
83
83
  is exactly
84
84
 
85
85
  ```bash
86
- dl blooop/devlaunch@fix/42 -- claude 'fix the flaky test'
86
+ dl blooop/devlaunch@fix/42 -- IS_SANDBOX=1 claude --dangerously-skip-permissions 'fix the flaky test'
87
87
  ```
88
88
 
89
89
  That means an `aid` workspace *is* the `dl` workspace: same clone, same workspace
90
90
  id, same container — started if stopped, attached to if already running, and never
91
91
  rebuilt just because `aid` asked for it. Anything `dl` learns, `aid` gets.
92
92
 
93
+ `claude` is started with `--dangerously-skip-permissions`. The agent is already
94
+ inside a disposable container holding only this repo, so the per-tool prompts it
95
+ would ask on the host protect nothing here and would stall an unattended run.
96
+ `IS_SANDBOX=1` rides along because `claude` otherwise refuses that flag outright
97
+ under `uid 0`, and devcontainers that run as root are ordinary. The variable is
98
+ scoped to the agent process, not exported into your shell.
99
+
100
+ The trade is worth stating plainly: an agent started this way edits, runs and
101
+ deletes inside the container without asking. It cannot reach your host, but it can
102
+ rewrite the checkout it is in, so review an `aid` workspace before pushing rather
103
+ than treating it as a sandbox that will stop it for you. `--codex` and `--gemini`
104
+ are unaffected, and `dl <ws> -- claude` still runs exactly what you typed.
105
+
93
106
  | Option | Description |
94
107
  |--------|-------------|
95
108
  | `--claude`, `--codex`, `--gemini` | Pick the agent (default: `claude`) |
@@ -224,8 +237,9 @@ whichever answers first, and hands it to the container as `GH_TOKEN`. That reach
224
237
  any image and any container user, unlike a bind-mount of `~/.config/gh`, and it
225
238
  works whether the host keeps its token in `hosts.yml` or in a keyring. The token
226
239
  is passed to devpod through a private file and through devpod's own environment,
227
- never on a command line, so it does not appear in `ps`. The container still needs
228
- `gh` installed for the login to be of any use. Check a workspace with:
240
+ never on a command line, so it does not appear in `ps`. `dl` installs `gh` itself
241
+ (see [Tools in every workspace](#tools-in-every-workspace)), so the login has
242
+ something to be spent on whatever the image ships. Check a workspace with:
229
243
 
230
244
  ```bash
231
245
  dl <workspace> -- gh auth status
@@ -256,6 +270,36 @@ that is *already running* skips that step, and the token it was given at startup
256
270
  stays in place — including one it was given before you set
257
271
  `DEVLAUNCH_NO_GH_TOKEN`. Run `dl <workspace> restart` to replace it.
258
272
 
273
+ ## Tools in every workspace
274
+
275
+ `gh` and `claude` are available in every workspace `dl` opens, in every kind of
276
+ session — an interactive `dl <workspace>`, a one-shot `dl <workspace> -- <command>`,
277
+ and `aid`. The repo's `devcontainer.json` does not have to provide them, and most
278
+ do not: `dl` launches arbitrary repos, so a guarantee that depended on the image
279
+ would not be a guarantee.
280
+
281
+ They are installed with `pixi global` on `devpod up`, and put on the PATH of a
282
+ login shell through whichever of `~/.bash_profile`, `~/.bash_login` or `~/.profile`
283
+ bash actually reads — it sources only the first of those that exists, so an image
284
+ shipping a `~/.bash_profile` never reads `~/.profile`. A workspace that already has both is left alone —
285
+ the check runs first, so the cost after the first launch is one round-trip and no
286
+ network. If `pixi` is missing from the image, `dl` installs that too.
287
+
288
+ An install that fails costs the workspace its tools, not its launch: `dl` logs a
289
+ warning and hands you the session anyway.
290
+
291
+ ```bash
292
+ DEVLAUNCH_NO_TOOLS=1 dl someone/repo
293
+ ```
294
+
295
+ | Variable | Description |
296
+ |----------|-------------|
297
+ | `DEVLAUNCH_NO_TOOLS=1` | Do not install `gh` or `claude` into workspaces |
298
+
299
+ Attaching to a workspace that is *already running* skips `devpod up`, and so skips
300
+ this too. A workspace started by something other than `dl` — or created before this
301
+ existed — picks the tools up on its next `dl <workspace> restart`.
302
+
259
303
  ## Global Commands
260
304
 
261
305
  | Command | Description |
@@ -7,7 +7,7 @@ a `dl` one and hands that to :func:`devlaunch.dl.main`, so:
7
7
 
8
8
  is exactly
9
9
 
10
- dl owner/repo@branch -- claude 'fix the flaky test'
10
+ dl owner/repo@branch -- claude --dangerously-skip-permissions 'fix the flaky test'
11
11
 
12
12
  Everything that decides how a workspace is obtained — the bare repo cache, the
13
13
  worktree clone, the workspace id, the devpod container, the fast attach to one
@@ -27,13 +27,41 @@ from typing import Dict, List, Optional
27
27
 
28
28
  from . import dl
29
29
 
30
+
31
+ @dataclass(frozen=True)
32
+ class Agent:
33
+ """How one coding agent is started inside the workspace.
34
+
35
+ Split three ways because not every part of the line belongs everywhere:
36
+ `env` and `command` always run, while `prompt_flags` only joins them when
37
+ there is a prompt — gemini's way of taking an initial prompt is a flag that
38
+ is a syntax error without one.
39
+ """
40
+
41
+ command: List[str]
42
+ prompt_flags: List[str] = field(default_factory=list)
43
+ env: Dict[str, str] = field(default_factory=dict)
44
+
45
+
30
46
  # Base command per agent. The prompt, when there is one, is appended as a single
31
47
  # quoted argument; each of these CLIs takes an initial prompt that way and then
32
48
  # drops into its interactive session.
33
- AGENT_COMMANDS: Dict[str, List[str]] = {
34
- "claude": ["claude"],
35
- "codex": ["codex"],
36
- "gemini": ["gemini", "--prompt-interactive"],
49
+ #
50
+ # claude is started with --dangerously-skip-permissions: the whole point of a dl
51
+ # workspace is that the agent is already inside a disposable container with only
52
+ # this repo in it, so the per-tool prompts it would otherwise ask on the host buy
53
+ # nothing and stop an unattended `aid owner/repo fix the bug` dead.
54
+ #
55
+ # IS_SANDBOX=1 is what makes that flag usable at all here. claude refuses it
56
+ # outright under uid 0 -- "cannot be used with root/sudo privileges", exit 1 --
57
+ # and plenty of devcontainers run as root, so without this aid would not start
58
+ # in them at all. The variable is claude's own way of being told the refusal is
59
+ # answering for a machine that isn't there, which is exactly a dl workspace: a
60
+ # disposable container holding one repo.
61
+ AGENT_COMMANDS: Dict[str, Agent] = {
62
+ "claude": Agent(["claude", "--dangerously-skip-permissions"], env={"IS_SANDBOX": "1"}),
63
+ "codex": Agent(["codex"]),
64
+ "gemini": Agent(["gemini"], prompt_flags=["--prompt-interactive"]),
37
65
  }
38
66
 
39
67
  # Flags that pick the agent, e.g. `aid --gemini owner/repo ...`.
@@ -122,18 +150,21 @@ def build_agent_command(agent: str, prompt: str = "") -> str:
122
150
  takes. The prompt is quoted here rather than reassembled by the caller, so
123
151
  the words the user typed reach the agent as the single argument they meant.
124
152
  """
153
+ # Named for the agent, not `spec`, which is the workspace everywhere else here.
125
154
  try:
126
- command = list(AGENT_COMMANDS[agent])
155
+ started = AGENT_COMMANDS[agent]
127
156
  except KeyError:
128
157
  raise UsageError(
129
158
  f"Unknown agent {agent!r}. Choose one of: {', '.join(sorted(AGENT_COMMANDS))}."
130
159
  ) from None
131
- if not prompt:
132
- # No prompt to be interactive about: start the agent's plain session.
133
- # gemini's --prompt-interactive would be a syntax error without one.
134
- return shlex.quote(command[0])
135
- command.append(prompt)
136
- return shlex.join(command)
160
+ # No prompt to be interactive about: start the agent's plain session, without
161
+ # the flags that only make sense alongside one.
162
+ words = [*started.command, *started.prompt_flags, prompt] if prompt else list(started.command)
163
+ # Assignments prefixing a command set the variables for that command only, so
164
+ # the agent is the one process that sees them and nothing in the login shell
165
+ # dl runs this under is changed.
166
+ assignments = [f"{name}={shlex.quote(value)}" for name, value in sorted(started.env.items())]
167
+ return " ".join([*assignments, shlex.join(words)])
137
168
 
138
169
 
139
170
  def build_dl_args(parsed: AidArgs) -> List[str]:
@@ -0,0 +1,116 @@
1
+ """Answer "is this devpod provider already registered?" from devpod itself.
2
+
3
+ The question used to be asked by grepping devpod's human-facing table:
4
+
5
+ devpod provider list | grep -qw docker || devpod provider add docker
6
+
7
+ devpod v0.26.1 colourises that table unconditionally and emits the reset
8
+ sequence ``ESC[m`` immediately before each cell, so the character preceding
9
+ ``docker`` is the letter ``m``, ``grep -w``'s left word boundary never matches,
10
+ and the guard reported "absent" on every machine that had already registered the
11
+ provider. ``NO_COLOR=1`` does not help.
12
+
13
+ Dropping ``-w`` would have fixed today's rendering and left us reading a table
14
+ devpod is free to re-render tomorrow. ``provider list --output json`` is the
15
+ same information in a form that has no rendering to change: an object keyed by
16
+ provider name.
17
+
18
+ The other half of the old guard's failure is the part worth keeping out on
19
+ purpose. Output it could not read came back as an empty set of providers, and an
20
+ empty set of providers means "go add one" -- so an unreadable answer turned into
21
+ an action. Here, unreadable is its own outcome: `UnreadableProviderList`. The
22
+ caller finds out, rather than being told something false.
23
+ """
24
+
25
+ import json
26
+ import subprocess
27
+ from typing import Callable, Optional, Sequence, Set
28
+
29
+
30
+ class UnreadableProviderList(RuntimeError):
31
+ """devpod's provider listing could not be read.
32
+
33
+ Distinct from "no providers are registered", which is a listing that reads
34
+ fine and is empty.
35
+ """
36
+
37
+
38
+ def parse_provider_names(listing: str) -> Set[str]:
39
+ """The names of every registered provider in a `--output json` listing."""
40
+ try:
41
+ parsed = json.loads(listing)
42
+ except json.JSONDecodeError as exc:
43
+ raise UnreadableProviderList(
44
+ f"devpod's provider listing is not JSON: {listing[:120]!r}"
45
+ ) from exc
46
+ if not isinstance(parsed, dict):
47
+ raise UnreadableProviderList(
48
+ f"expected devpod to list providers by name, got {type(parsed).__name__}"
49
+ )
50
+ return set(parsed)
51
+
52
+
53
+ def list_provider_names(
54
+ run: Callable[..., subprocess.CompletedProcess] = subprocess.run,
55
+ ) -> Set[str]:
56
+ """Ask devpod which providers are registered."""
57
+ result = run(
58
+ ["devpod", "provider", "list", "--output", "json"],
59
+ capture_output=True,
60
+ text=True,
61
+ check=False,
62
+ )
63
+ if result.returncode != 0:
64
+ raise UnreadableProviderList(
65
+ f"`devpod provider list` exited {result.returncode}: {(result.stderr or '').strip()[:200]}"
66
+ )
67
+ return parse_provider_names(result.stdout or "")
68
+
69
+
70
+ def ensure_provider(
71
+ name: str, run: Callable[..., subprocess.CompletedProcess] = subprocess.run
72
+ ) -> bool:
73
+ """Register `name` with devpod unless it is already registered.
74
+
75
+ Returns True if it had to be added, False if it was already there. Raises
76
+ `UnreadableProviderList` rather than guessing when devpod's answer cannot
77
+ be read.
78
+ """
79
+ if name in list_provider_names(run=run):
80
+ return False
81
+ result = run(["devpod", "provider", "add", name], check=False)
82
+ if result.returncode != 0:
83
+ raise RuntimeError(f"`devpod provider add {name}` exited {result.returncode}")
84
+ return True
85
+
86
+
87
+ def main(
88
+ argv: Optional[Sequence[str]] = None,
89
+ run: Callable[..., subprocess.CompletedProcess] = subprocess.run,
90
+ ) -> int:
91
+ """CLI entry point: `python -m devlaunch.devpod_provider <name>`.
92
+
93
+ This is what the `dev-add-docker` pixi task runs, so a devpod that cannot be
94
+ read has to stop the task rather than let it carry on as if the provider
95
+ were missing.
96
+ """
97
+ import argparse # pylint: disable=import-outside-toplevel
98
+
99
+ parser = argparse.ArgumentParser(
100
+ prog="python -m devlaunch.devpod_provider",
101
+ description="Register a devpod provider unless it is already registered.",
102
+ )
103
+ parser.add_argument("name", help="provider name, e.g. docker")
104
+ args = parser.parse_args(argv)
105
+
106
+ try:
107
+ added = ensure_provider(args.name, run=run)
108
+ except (UnreadableProviderList, RuntimeError) as exc:
109
+ print(f"error: {exc}")
110
+ return 1
111
+ print(f"devpod provider {args.name}: {'added' if added else 'already registered'}")
112
+ return 0
113
+
114
+
115
+ if __name__ == "__main__":
116
+ raise SystemExit(main())
@@ -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, tty_session
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
@@ -1062,6 +1062,12 @@ def workspace_up(
1062
1062
  # `up` creates and starts workspaces, so any snapshot of `devpod list`
1063
1063
  # taken before it is now out of date.
1064
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)
1065
1071
  return result
1066
1072
 
1067
1073
 
@@ -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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "devlaunch"
3
- version = "0.0.13"
3
+ version = "0.0.15"
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"
@@ -74,7 +74,7 @@ py312 = ["py312", "test"]
74
74
  py313 = ["py313", "test"]
75
75
 
76
76
  [tool.pixi.tasks]
77
- dev-add-docker = "devpod provider list | grep -qw docker || devpod provider add docker"
77
+ dev-add-docker = "python -m devlaunch.devpod_provider docker"
78
78
  dev = { cmd = "devpod up . --ide none && ssh pythontemplate.devpod", depends-on = ["dev-add-docker"] }
79
79
  dev-vs = { cmd = "devpod up . --ide vscode", depends-on = ["dev-add-docker"] }
80
80
  dev-restart = { cmd = "devpod up . --recreate --ide none && ssh pythontemplate.devpod", depends-on = ["dev-add-docker"] }
File without changes
File without changes