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.
- {devlaunch-0.0.13 → devlaunch-0.0.15}/PKG-INFO +48 -4
- {devlaunch-0.0.13 → devlaunch-0.0.15}/README.md +47 -3
- {devlaunch-0.0.13 → devlaunch-0.0.15}/devlaunch/aid.py +43 -12
- devlaunch-0.0.15/devlaunch/devpod_provider.py +116 -0
- {devlaunch-0.0.13 → devlaunch-0.0.15}/devlaunch/dl.py +7 -1
- devlaunch-0.0.15/devlaunch/tools.py +219 -0
- {devlaunch-0.0.13 → devlaunch-0.0.15}/pyproject.toml +2 -2
- {devlaunch-0.0.13 → devlaunch-0.0.15}/.gitignore +0 -0
- {devlaunch-0.0.13 → devlaunch-0.0.15}/LICENSE +0 -0
- {devlaunch-0.0.13 → devlaunch-0.0.15}/devlaunch/__init__.py +0 -0
- {devlaunch-0.0.13 → devlaunch-0.0.15}/devlaunch/completion.py +0 -0
- {devlaunch-0.0.13 → devlaunch-0.0.15}/devlaunch/completion_loader.py +0 -0
- {devlaunch-0.0.13 → devlaunch-0.0.15}/devlaunch/completions/__init__.py +0 -0
- {devlaunch-0.0.13 → devlaunch-0.0.15}/devlaunch/completions/dl.bash +0 -0
- {devlaunch-0.0.13 → devlaunch-0.0.15}/devlaunch/devpod_ssh.py +0 -0
- {devlaunch-0.0.13 → devlaunch-0.0.15}/devlaunch/gh_auth.py +0 -0
- {devlaunch-0.0.13 → devlaunch-0.0.15}/devlaunch/tty_session.py +0 -0
- {devlaunch-0.0.13 → devlaunch-0.0.15}/devlaunch/workspace_id.py +0 -0
- {devlaunch-0.0.13 → devlaunch-0.0.15}/devlaunch/worktree/__init__.py +0 -0
- {devlaunch-0.0.13 → devlaunch-0.0.15}/devlaunch/worktree/branch_manager.py +0 -0
- {devlaunch-0.0.13 → devlaunch-0.0.15}/devlaunch/worktree/config.py +0 -0
- {devlaunch-0.0.13 → devlaunch-0.0.15}/devlaunch/worktree/migration.py +0 -0
- {devlaunch-0.0.13 → devlaunch-0.0.15}/devlaunch/worktree/models.py +0 -0
- {devlaunch-0.0.13 → devlaunch-0.0.15}/devlaunch/worktree/repo_manager.py +0 -0
- {devlaunch-0.0.13 → devlaunch-0.0.15}/devlaunch/worktree/storage.py +0 -0
- {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.
|
|
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`.
|
|
250
|
-
|
|
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`.
|
|
228
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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.
|
|
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 = "
|
|
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
|
|
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
|