feat(compat): preserve v1 entry points after v2 single-binary split

v1 containers bind-mount distrobox-{init,export,host-exec} from host
$bindir, and v1 desktop entries launch via `distrobox-enter`. Both
would silently break under v2's single binary. argv[0] dispatch
routes distrobox-* symlinks to the right subcommand, and the helpers
ship at the v1 paths so v1 containers keep working.

BREAKING CHANGE: v2 rc1 and rc2 prerelease containers used ~/.local/share/distrobox/v2/
for their helpers and must be recreated.

Signed-off-by: Luca Di Maio <luca.dimaio1@gmail.com>
This commit is contained in:
Luca Di Maio
2026-06-29 15:09:53 +02:00
parent 94d62f5e36
commit 94ae7fa1ac
7 changed files with 237 additions and 35 deletions
+9 -1
View File
@@ -34,6 +34,8 @@ ICONDIR ?= $(PREFIX)/share/icons/hicolor
ICON_SIZES := 16 22 24 32 36 48 64 72 96 128 256
V1_SUBCOMMANDS := assemble create enter ephemeral generate-entry ls list rm stop upgrade
.PHONY: install
install: build
install -d $(DESTDIR)$(BINDIR) $(DESTDIR)$(MANDIR) $(DESTDIR)$(BASHCOMPDIR) $(DESTDIR)$(ZSHCOMPDIR)
@@ -41,6 +43,12 @@ install: build
install -m 0644 man/man1/*.1 $(DESTDIR)$(MANDIR)/
install -m 0644 completions/bash/distrobox $(DESTDIR)$(BASHCOMPDIR)/distrobox
install -m 0644 completions/zsh/_distrobox $(DESTDIR)$(ZSHCOMPDIR)/_distrobox
for sub in $(V1_SUBCOMMANDS); do \
ln -sf distrobox $(DESTDIR)$(BINDIR)/distrobox-$${sub}; \
done
install -m 0755 internal/inside-distrobox/assets/distrobox-init $(DESTDIR)$(BINDIR)/distrobox-init
install -m 0755 internal/inside-distrobox/assets/distrobox-export $(DESTDIR)$(BINDIR)/distrobox-export
install -m 0755 internal/inside-distrobox/assets/distrobox-host-exec $(DESTDIR)$(BINDIR)/distrobox-host-exec
install -d $(DESTDIR)$(ICONDIR)/scalable/apps
install -m 0644 icons/terminal-distrobox-icon.svg $(DESTDIR)$(ICONDIR)/scalable/apps/
for sz in $(ICON_SIZES); do \
@@ -51,7 +59,7 @@ install: build
.PHONY: uninstall
uninstall:
rm -f $(DESTDIR)$(BINDIR)/distrobox
rm -f $(DESTDIR)$(BINDIR)/distrobox $(DESTDIR)$(BINDIR)/distrobox-*
rm -f $(DESTDIR)$(MANDIR)/distrobox.1 $(DESTDIR)$(MANDIR)/distrobox-*.1
rm -f $(DESTDIR)$(BASHCOMPDIR)/distrobox
rm -f $(DESTDIR)$(ZSHCOMPDIR)/_distrobox
+1 -1
View File
@@ -31,5 +31,5 @@ func run() error {
cmd := cli.NewRootCommand(cfg)
//nolint:wrapcheck // main reports errors as-is
return cmd.Run(ctx, os.Args)
return cmd.Run(ctx, cli.ResolveArgs(os.Args))
}
+16
View File
@@ -172,6 +172,22 @@ install -m 0644 src/man/man1/*.1 "${mandir}/"
install -m 0644 src/completions/bash/distrobox "${bashdir}/distrobox"
install -m 0644 src/completions/zsh/_distrobox "${zshdir}/_distrobox"
# v1 subcommand compatibility: argv[0] dispatch via symlinks. Users (and
# desktop entries written by v1 distrobox-export) still invoke
# `distrobox-enter foo`; the binary routes by basename.
for sub in assemble create enter ephemeral generate-entry ls list rm stop upgrade; do
ln -sf distrobox "${bindir}/distrobox-${sub}"
done
# In-container helper scripts. The Go binary embeds these and would
# extract them at first use, but shipping them here puts them on PATH
# next to the binary so the runtime detection short-circuits and skips
# extraction entirely. Same paths v1 used, which also means containers
# created by v1 keep finding a working bind-mount source.
install -m 0755 src/internal/inside-distrobox/assets/distrobox-init "${bindir}/distrobox-init"
install -m 0755 src/internal/inside-distrobox/assets/distrobox-export "${bindir}/distrobox-export"
install -m 0755 src/internal/inside-distrobox/assets/distrobox-host-exec "${bindir}/distrobox-host-exec"
install -d "${icondir}/scalable/apps"
install -m 0644 src/icons/terminal-distrobox-icon.svg "${icondir}/scalable/apps/"
for sz in ${icon_sizes}; do
+26
View File
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/urfave/cli/v3"
@@ -21,6 +22,31 @@ type contextKey string
const containerManagerKey contextKey = "containerManager"
// ResolveArgs supports v1-style subcommand invocation by basename. v1 shipped
// separate scripts (distrobox-create, distrobox-enter, …); v2 ships one binary
// with subcommands.
//
// distrobox → no change
// distrobox-enter foo → distrobox enter foo
// distrobox-;s → distrobox ls
//
// argv[0] is normalised to "distrobox" so help/usage output reads naturally
// regardless of which symlink was invoked.
func ResolveArgs(args []string) []string {
if len(args) == 0 {
return args
}
sub, ok := strings.CutPrefix(filepath.Base(args[0]), "distrobox-")
if !ok || sub == "" {
return args
}
out := make([]string, 0, len(args)+1)
out = append(out, "distrobox", sub)
out = append(out, args[1:]...)
return out
}
func NewRootCommand(cfg *config.Values) *cli.Command {
subs := subcommands(cfg)
// Install flag-aware completion on every command, including nested
+65
View File
@@ -0,0 +1,65 @@
package cli
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestResolveArgs(t *testing.T) {
t.Parallel()
tests := []struct {
name string
in []string
want []string
}{
{
name: "empty",
in: nil,
want: nil,
},
{
name: "plain distrobox is untouched",
in: []string{"distrobox"},
want: []string{"distrobox"},
},
{
name: "plain distrobox with subcommand is untouched",
in: []string{"distrobox", "enter", "foo"},
want: []string{"distrobox", "enter", "foo"},
},
{
name: "distrobox-enter dispatches to enter",
in: []string{"distrobox-enter", "foo"},
want: []string{"distrobox", "enter", "foo"},
},
{
name: "absolute path is stripped to basename for dispatch",
in: []string{"/usr/local/bin/distrobox-enter", "--name", "foo"},
want: []string{"distrobox", "enter", "--name", "foo"},
},
{
name: "two-word subcommand survives intact",
in: []string{"distrobox-generate-entry", "foo"},
want: []string{"distrobox", "generate-entry", "foo"},
},
{
name: "distrobox- with empty suffix is left alone",
in: []string{"distrobox-"},
want: []string{"distrobox-"},
},
{
name: "unrelated names are left alone",
in: []string{"something-else", "arg"},
want: []string{"something-else", "arg"},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
assert.Equal(t, tc.want, ResolveArgs(tc.in))
})
}
}
+29 -3
View File
@@ -4,6 +4,7 @@ import (
_ "embed"
"fmt"
"os"
"os/exec"
"path/filepath"
)
@@ -35,6 +36,10 @@ func ProvisionScripts() (string, error) {
}
for _, script := range scripts {
if exists(script.name) {
continue
}
destFilePath := filepath.Join(dir, script.name)
//nolint:gosec // 0755 is the same as from distrobox v1, let's keep it for compatibility
if err := os.WriteFile(destFilePath, []byte(script.content), 0755); err != nil {
@@ -45,6 +50,23 @@ func ProvisionScripts() (string, error) {
return dir, nil
}
// exists reports whether a script with the given name is already
// available on the host.
func exists(name string) bool {
if exe, err := os.Executable(); err == nil {
dir := filepath.Dir(exe)
if _, err := os.Stat(filepath.Join(dir, name)); err == nil {
return true
}
}
if _, err := exec.LookPath(name); err == nil {
return true
}
return false
}
// hostDir returns the directory path where the scripts should be stored.
// Evaluates DBX_SCRIPTS_DIR env var first, then HOME env var, and falls back to default path.
func hostDir() string {
@@ -53,12 +75,16 @@ func hostDir() string {
return dir
}
// then check the path where main distrobox is installed
if exe, err := os.Executable(); err == nil {
return filepath.Dir(exe)
}
// Then, check HOME env var
// v2 is added to avoid collisions with v1 installations
if home := os.Getenv("HOME"); home != "" {
return filepath.Join(home, ".local", "share", "distrobox", "v2")
return filepath.Join(home, ".local", "bin")
}
// Fallback to default path
return "/var/lib/distrobox/v2"
return "/usr/bin"
}
+91 -30
View File
@@ -11,47 +11,108 @@ import (
insidedistrobox "github.com/89luca89/distrobox/internal/inside-distrobox"
)
// expectedScripts is the canonical triad of in-container helper scripts.
// ProvisionScripts must always end with all three present in the returned
// directory, regardless of which resolution branch produced it.
//
//nolint:gochecknoglobals // shared fixture across the suite, behaves like a constant
var expectedScripts = []string{
"distrobox-host-exec",
"distrobox-init",
"distrobox-export",
}
func assertAllScripts(t *testing.T, dir string) {
t.Helper()
for _, name := range expectedScripts {
assert.FileExists(t, filepath.Join(dir, name), "expected %s in %s", name, dir)
}
}
// isolatePath wipes PATH for the duration of the test so the PATH branch
// of exists() never finds a system-installed distrobox-init while we are
// trying to exercise other resolution branches.
func isolatePath(t *testing.T) {
t.Helper()
t.Setenv("PATH", "")
}
// TestProvisionScripts_CustomDir checks the DBX_SCRIPTS_DIR override:
// when set to an empty directory, ProvisionScripts writes all three
// scripts there and returns that directory.
func TestProvisionScripts_CustomDir(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("DBX_SCRIPTS_DIR", tmpDir)
isolatePath(t)
scriptsDir, err := insidedistrobox.ProvisionScripts()
require.NoError(t, err, "ProvisionScripts failed")
defer os.RemoveAll(scriptsDir)
dir, err := insidedistrobox.ProvisionScripts()
require.NoError(t, err)
require.Equal(t, tmpDir, dir)
assertAllScripts(t, dir)
}
require.Equal(t, tmpDir, scriptsDir)
expectedScripts := []string{
"distrobox-host-exec",
"distrobox-init",
"distrobox-export",
// TestProvisionScripts_DetectOnPath confirms the skip-write shortcut via
// the PATH branch of exists(): when the helper scripts already exist
// somewhere on PATH, ProvisionScripts leaves them byte-for-byte
// untouched rather than overwriting from the embedded copies.
func TestProvisionScripts_DetectOnPath(t *testing.T) {
scriptsDir := t.TempDir()
marker := "#!/bin/sh\n# pre-existing-marker\n"
for _, name := range expectedScripts {
require.NoError(t, os.WriteFile(filepath.Join(scriptsDir, name), []byte(marker), 0755))
}
for _, scriptName := range expectedScripts {
scriptPath := filepath.Join(scriptsDir, scriptName)
assert.FileExists(t, scriptPath, "Expected script %s to exist", scriptName)
t.Setenv("PATH", scriptsDir)
t.Setenv("DBX_SCRIPTS_DIR", t.TempDir())
_, err := insidedistrobox.ProvisionScripts()
require.NoError(t, err)
for _, name := range expectedScripts {
got, err := os.ReadFile(filepath.Join(scriptsDir, name))
require.NoError(t, err)
require.Equal(t, marker, string(got), "%s was overwritten despite existing on PATH", name)
}
}
func TestProvisionScripts_HomeDir(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("HOME", tmpDir)
// TestProvisionScripts_ExtractsAdjacentToBinary verifies the default
// resolution: with no DBX_SCRIPTS_DIR override and nothing on PATH,
// ProvisionScripts writes to the directory containing the running
// binary. That is the layout a fresh `go install` or curl-only deploy
// produces.
func TestProvisionScripts_ExtractsAdjacentToBinary(t *testing.T) {
t.Setenv("DBX_SCRIPTS_DIR", "")
isolatePath(t)
t.Setenv("HOME", t.TempDir())
scriptsDir, err := insidedistrobox.ProvisionScripts()
require.NoError(t, err, "ProvisionScripts failed")
defer os.RemoveAll(scriptsDir)
expected := filepath.Join(tmpDir, ".local", "share", "distrobox", "v2")
require.Equal(t, expected, scriptsDir)
expectedScripts := []string{
"distrobox-host-exec",
"distrobox-init",
"distrobox-export",
exe, err := os.Executable()
require.NoError(t, err)
exeDir := filepath.Dir(exe)
if !isDirWritable(exeDir) {
t.Skipf("binary-adjacent dir %s is not writable; cannot exercise this branch here", exeDir)
}
t.Cleanup(func() {
for _, name := range expectedScripts {
_ = os.Remove(filepath.Join(exeDir, name))
}
})
for _, scriptName := range expectedScripts {
scriptPath := filepath.Join(scriptsDir, scriptName)
assert.FileExists(t, scriptPath, "Expected script %s to exist", scriptName)
}
dir, err := insidedistrobox.ProvisionScripts()
require.NoError(t, err)
require.Equal(t, exeDir, dir)
assertAllScripts(t, dir)
}
// isDirWritable does a probing-write into dir to determine whether a
// non-root user can create files there. Used by the extraction-adjacent
// test as a defensive skip when the binary's directory isn't writable
// (e.g. read-only test harness, distros with hardened tmpfs).
func isDirWritable(dir string) bool {
probe, err := os.CreateTemp(dir, ".dbx-write-probe-")
if err != nil {
return false
}
defer os.Remove(probe.Name())
defer probe.Close()
return true
}