Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.2.2] - 2026-08-20

### Fixed

- **The tools devlaunch installs no longer edit the container's own pixi
manifest.** `gh`, `claude` and `zellij` go into `~/.devlaunch/pixi` rather than
`~/.pixi`, because `pixi global install` is not only an install: it is an edit
to `$PIXI_HOME/manifests/pixi-global.toml`, a *declarative* file that in a
container already has an owner. Writing there cost something in both
directions. `pixi global sync` removes every environment the manifest does not
list, so a dotfiles apply that rewrote the manifest and synced **uninstalled**
the zellij devlaunch had just installed — invisibly, and the next launch
reinstalled it, forever. And the manifest is not always a file: a devcontainer
is free to symlink it onto a tracked file inside the checkout, and
`kinisi_ros`'s does, so the append landed in the work tree and every
`git status` in the workspace came up dirty. An install that dirties the tree
it was pointed at is the launch damaging the work it exists to serve, and
`dl` launches arbitrary repos. A home devlaunch created makes both
unrepresentable rather than handled: nothing syncs that manifest, and no repo
state can sit beneath that path.

Not `~/.local/share/devlaunch/pixi`, which is the conventional path and the
wrong one here — containers bind-mount `~/.cache`, `~/.config` and
`~/.local/share` straight from the host, so a prefix tree under one would be
shared by every container on the machine and written into your own home;
prefixes are baked with absolute paths, which is prefix-dev/pixi#5476, the
hazard the shared package cache already keeps `PIXI_HOME` away from.
`PIXI_HOME` is set only for devlaunch's own install scripts and never exported
into the login profile, so **your own `pixi global install` in a workspace
still goes to your own `~/.pixi`**; only the bin directories go on `PATH`.

Existing containers are untouched: their tools are already on the login `PATH`,
so the presence check passes and nothing is reinstalled. The devcontainer
feature's installer still writes `~/.pixi` deliberately — it runs at image
build time, where that path is the image's own and there is no checkout to
dirty.

## [0.2.1] - 2026-08-20

### Changed
Expand Down
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1290,6 +1290,40 @@ every launch after it. `1777` is what `/var/tmp` carries at the other end of the
same mount, and it makes the same trade: every uid can write, and the sticky bit
means none of them can unlink another's entries.

### Where the tools themselves land

The cache above is shared; the *environments* devlaunch installs into are not,
and they are not the container's `~/.pixi` either. `gh`, `claude` and `zellij` go
into **`~/.devlaunch/pixi`**, a pixi home of devlaunch's own, because
`pixi global install` is not only an install — it is an edit to
`$PIXI_HOME/manifests/pixi-global.toml`, a declarative file that in a container
already has an owner. Writing there made devlaunch a second author, and cost
something in both directions:

- `pixi global sync` removes every environment the manifest does not list, so a
dotfiles apply that rewrites the manifest and syncs **uninstalls** the zellij
devlaunch just installed — and the next launch reinstalls it, forever.
- The manifest is not always a file. A devcontainer is free to symlink
`~/.pixi/manifests/pixi-global.toml` onto a tracked file inside the checkout,
and one does; the append then landed in the work tree and every `git status`
in the workspace came up dirty.

Neither is expressible against a home devlaunch created: nothing syncs that
manifest, and no repo state can sit under that path. It costs a duplicate
extracted prefix in the one case where a tool is installed but unreachable from
a login shell — disk only, since `PIXI_HOME` does not move the download cache —
and that is the case where the old behaviour reinstalled on every launch anyway.

Not `~/.local/share/devlaunch/pixi`, which is the conventional path and the wrong
one here: containers bind-mount `~/.cache`, `~/.config` and `~/.local/share`
straight from the host, so a prefix tree under one of them would be shared by
every container on the machine and written into your own home — pixi#5476 again,
the hazard the cache mount is careful to keep `PIXI_HOME` away from.

`PIXI_HOME` is set only for devlaunch's own install scripts, never exported into
the login profile, so **your own `pixi global install` in a workspace still goes
to your own `~/.pixi`**. Only the bin directory goes on `PATH`.

### Existing containers, and what a recreate is for

**A mount lands only when a container is created.** devpod re-applies
Expand Down
90 changes: 78 additions & 12 deletions devlaunch/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@
holds, which turns the slowest part of a cold launch (minutes of in-container
downloads) into a local copy. Only when the host has nothing to lend, or the
lent binaries do not run there (a different arch or libc), does the old
network path run: bootstrap pixi, `pixi global install` each tool.
network path run: bootstrap pixi, `pixi global install` each tool -- into a
pixi home of devlaunch's own rather than the container's `~/.pixi`, for the
reasons PIXI_HOME_TARGET gives.

What "lacks them" means is the probe's job, and it has three answers rather
than two, because a `claude` on PATH is not necessarily a `claude` worth
Expand Down Expand Up @@ -114,6 +116,49 @@
# that reads someone else's artifact is only correct until they change it.
PROFILE_MARK = "# devlaunch:"

# The pixi home devlaunch installs its own tools into, and never `~/.pixi`.
#
# `pixi global install` is not just an install: it is an edit to
# `$PIXI_HOME/manifests/pixi-global.toml`, a *declarative* file whose owner then
# syncs it. `~/.pixi` is somebody else's -- an image's, or a dotfiles repo's --
# and writing there made devlaunch a second author of a file with one owner,
# which cost something in both directions:
#
# 1. `pixi global sync` removes every env the manifest does not list, so a
# dotfiles apply that rewrites the manifest and syncs *uninstalls* the
# zellij this module just installed -- on a schedule nothing here can see,
# and the next launch reinstalls it, forever.
# 2. The manifest is not always a file. kinisi_ros's devcontainer symlinks
# `~/.pixi/manifests/pixi-global.toml` onto a tracked file inside the
# checkout (its `.devcontainer/on_create.sh`), so the append landed in the
# user's work tree and every `git status` in the workspace came up dirty.
# Nothing about that is one repo's quirk: dl launches arbitrary repos, and
# an install that dirties the tree it was pointed at is the launch damaging
# the work it exists to serve.
#
# A home of devlaunch's own makes both unrepresentable rather than handled.
# Nothing syncs this manifest, so what is installed here stays installed; and
# every path under it is one devlaunch created, so no repo state can be beneath
# it. What it costs is a duplicate prefix in the case where a tool is installed
# but unreachable from a login shell -- disk only, since PIXI_HOME does not move
# the package cache (`PIXI_CACHE_TARGET` still shares the downloads) -- and that
# case is the one where the old behaviour reinstalled on every launch anyway.
#
# `$HOME/.devlaunch/pixi` and *not* `$HOME/.local/share/devlaunch/pixi`, which is
# the conventional answer and the wrong one here. Containers bind-mount the XDG
# trees -- `~/.cache`, `~/.config`, `~/.local/share` -- straight from the host,
# so a prefix tree under `.local/share` would be shared by every container on the
# machine and written into the host's own home. Prefixes are baked with absolute
# paths and two syncs sharing one prefix tree is prefix-dev/pixi#5476: exactly
# the hazard PIXI_CACHE_TARGET refuses to let PIXI_HOME near. A dotted directory
# in `$HOME` is nobody's convention to mount, and `transfer_script` already
# stages under `$HOME/.devlaunch-lend`.
#
# Deliberately *not* exported into the login profile. Only these scripts set it,
# so a user's own `pixi global install` still goes to their `~/.pixi` -- which is
# the separation this exists to keep. Only the bin directories go on PATH.
PIXI_HOME_TARGET = "$HOME/.devlaunch/pixi"


@dataclass(frozen=True)
class Tool:
Expand Down Expand Up @@ -293,8 +338,9 @@ def provision_script(tools: Sequence[Tool] = REQUIRED_TOOLS) -> str:
Idempotent and cheap on the common path: every tool already on PATH is
skipped, so a workspace that has been provisioned before does nothing but
answer. It runs under a login shell (see provision_tools), which is what puts
an earlier run's ~/.pixi/bin on PATH -- checked from a non-login shell every
tool would look missing and be reinstalled on every launch.
an earlier run's bin directory (PIXI_HOME_TARGET) on PATH -- checked from a
non-login shell every tool would look missing and be reinstalled on every
launch.

Exits 0 unless an install actually failed, so "nothing to do" and "all
installs worked" are the same answer to the caller.
Expand All @@ -308,7 +354,7 @@ def provision_script(tools: Sequence[Tool] = REQUIRED_TOOLS) -> str:
"""
all_present = _all_present(tools)
installs = "\n".join(_install_line(tool) for tool in tools)
# The trampoline pixi writes into ~/.pixi/bin does not work for packages
# The trampoline pixi writes into its bin directory does not work for packages
# that ship a shell script, which is why the env's own bin directory is
# added too -- the same workaround .devcontainer/claude-code/install.sh
# carries, for the same package.
Expand All @@ -321,10 +367,10 @@ def provision_script(tools: Sequence[Tool] = REQUIRED_TOOLS) -> str:
# (since the check above is `command -v`) reinstalled from scratch
# on every single launch.
_profile_resolution(),
_profile_prepend('export PATH="$HOME/.pixi/bin:$PATH"', on_failure="failed=1"),
_profile_prepend(f'export PATH="{PIXI_HOME_TARGET}/bin:$PATH"', on_failure="failed=1"),
_profile_prepend(
'[ -d "$HOME/.pixi/envs/claude-shim/bin" ] && '
'export PATH="$HOME/.pixi/envs/claude-shim/bin:$PATH"',
f'[ -d "{PIXI_HOME_TARGET}/envs/claude-shim/bin" ] && '
f'export PATH="{PIXI_HOME_TARGET}/envs/claude-shim/bin:$PATH"',
on_failure="failed=1",
),
]
Expand All @@ -342,6 +388,7 @@ def provision_script(tools: Sequence[Tool] = REQUIRED_TOOLS) -> str:
# Everything already there: leave without touching pixi, the
# profile, or the network. Every launch after the first takes this.
f"if {all_present}; then exit 0; fi",
_pixi_home_export(),
_pixi_bootstrap(),
installs,
profile_lines,
Expand All @@ -363,9 +410,9 @@ def zellij_script() -> str:
Idempotent and nearly free on the common path: a `zellij` already on PATH
exits before pixi, the profile or the network are touched, and that is the
answer on every launch after the first. The check works because this runs
under the pass's login shell, which is what puts an earlier run's
~/.pixi/bin on PATH -- from a non-login shell an installed zellij looks
missing and would be reinstalled on every single launch.
under the pass's login shell, which is what puts an earlier run's bin
directory (PIXI_HOME_TARGET) on PATH -- from a non-login shell an installed
zellij looks missing and would be reinstalled on every single launch.

The PATH prepend is written here rather than relied on from elsewhere,
because the container this most often lands in has not had it written. A
Expand Down Expand Up @@ -395,10 +442,11 @@ def zellij_script() -> str:
# Already there: nothing else in this script may run. The commonest
# answer by far, and the reason a stage on every pass is affordable.
f"if command -v {shlex.quote(ZELLIJ_TOOL.command)} >/dev/null 2>&1; then exit 0; fi",
_pixi_home_export(),
_pixi_bootstrap(),
_install_line(ZELLIJ_TOOL),
_profile_resolution(),
_profile_prepend('export PATH="$HOME/.pixi/bin:$PATH"', on_failure="failed=1"),
_profile_prepend(f'export PATH="{PIXI_HOME_TARGET}/bin:$PATH"', on_failure="failed=1"),
'exit "$failed"',
]
)
Expand Down Expand Up @@ -721,6 +769,24 @@ def _read_outcome(name: str, status: Optional[str]) -> StageOutcome:
return StageNotReached(name=name)


def _pixi_home_export() -> str:
"""Point pixi at devlaunch's own home (PIXI_HOME_TARGET) for what follows.

One line at the top of every script here that runs pixi, and it has to cover
more than the installs: pixi's own installer honours the variable too
(`PIXI_HOME="${PIXI_HOME:-$HOME/.pixi}"` in its install.sh), so exporting it
once puts the binary and the environments in the same place instead of
bootstrapping pixi into a home its installs do not use.

Exported rather than passed per command, so nothing pixi does on its own
behalf can miss it -- and set flatly rather than defaulted to an existing
value, because a PIXI_HOME already in the container's environment is the
image's answer to where *its* globals live, which is the answer this is here
not to use.
"""
return f'export PIXI_HOME="{PIXI_HOME_TARGET}"'


def _pixi_bootstrap() -> str:
"""Install pixi if the image has none, since every tool here comes from it.

Expand All @@ -734,7 +800,7 @@ def _pixi_bootstrap() -> str:
"if ! command -v pixi >/dev/null 2>&1; then",
' echo "devlaunch: installing pixi"',
" curl -fsSL https://pixi.sh/install.sh | bash >/dev/null 2>&1 || true",
' export PATH="$HOME/.pixi/bin:$PATH"',
f' export PATH="{PIXI_HOME_TARGET}/bin:$PATH"',
"fi",
]
)
Expand Down
10 changes: 5 additions & 5 deletions rust/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion rust/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ members = [
# The single source of the version (docs/rust-rewrite-plan.md: cutover ships
# 0.1.0, version read from Cargo.toml).
[workspace.package]
version = "0.2.1"
version = "0.2.2"
edition = "2024"
license = "MIT"
repository = "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/blooop/devlaunch"
Expand Down
Loading