mirror of
https://github.com/89luca89/distrobox.git
synced 2026-08-19 01:14:49 -05:00
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:
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user