feat: generate-entry command (#7)

* add GenerateEntryCommand package

The package mimics the actual distrobox-generate-entry shell command
that installs a desktop entry file so that the host's desktop
environment can render the appropriate application icon.
This first implementantion scaffolds the command package and implements
the simplest scenario for a single container.

* delete entry

If the `delete` flag is provided, the command remove the desktop entry
if present.
Deleting a non-existing item does not raise an error.

* generate entries for all containers

With `opts.All` we implement an abstraction over the
`GenerateEntryCommand` that iterates across all the containers to
generate or delete the relative desktop entries.
`ListCommand` is used to fetch the list of containers.

* mount the generate-entry cli command

Mount the command so that is reachable from the CLI. Define parameters
with type and basic validation.
Depending on the flag `--all`, the appropriate set of options is provided
to the `GenerateEntryCommand.Execute()` method.
If `--all` is set, `container-name` and `icon` arguments will be ignored.
This commit is contained in:
Emanuele De Cupis
2026-06-11 18:24:39 +02:00
committed by Alessio Biancalana
parent fe1f60b074
commit e78ce996b6
5 changed files with 545 additions and 0 deletions
+95
View File
@@ -0,0 +1,95 @@
package cli
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"github.com/urfave/cli/v3"
"github.com/89luca89/distrobox/pkg/commands"
"github.com/89luca89/distrobox/pkg/containermanager"
)
func newGenerateEntryCommand() *cli.Command {
return &cli.Command{
Name: "generate-entry",
Usage: "Generate or delete distrobox entries",
Version: "1.0.0",
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "delete",
Aliases: []string{"d"},
Usage: "delete the entry",
},
&cli.StringFlag{
Name: "icon",
Aliases: []string{"i"},
Usage: "specify a custom icon (default auto)",
Value: "auto",
},
&cli.BoolFlag{
Name: "all",
Aliases: []string{"a"},
Usage: "perform for all distroboxes",
},
&cli.BoolFlag{
Name: "root",
Aliases: []string{"r"},
Usage: "perform on rootful distroboxes",
},
},
ArgsUsage: "container-name",
Action: generateEntryAction,
}
}
func generateEntryAction(ctx context.Context, cmd *cli.Command) error {
// The current executable is used as distrobox path
distroboxPath, err := os.Executable()
if err != nil {
return fmt.Errorf("failed to get distrobox executable path: %w", err)
}
containerManager, ok := ctx.Value(containerManagerKey).(containermanager.ContainerManager)
if !ok {
return errors.New("container manager not found in context")
}
listCmd := commands.NewListCommand(containerManager)
opts := &commands.GenerateEntryOptions{
Verbose: cmd.Bool("verbose"),
Delete: cmd.Bool("delete"),
Root: cmd.Bool("root"),
DesktopEntryBaseDir: getDesktopEntryDir(),
DistroboxPath: distroboxPath,
}
if cmd.Bool("all") {
opts.All = true
} else {
opts.ContainerName = cmd.Args().First()
opts.Icon = cmd.String("icon")
}
genEntryCmd := commands.NewGenerateEntryCommand(listCmd)
err = genEntryCmd.Execute(ctx, opts)
if err != nil {
return fmt.Errorf("failed to execute generate entry command: %w", err)
}
return nil
}
// getDesktopEntryDir resolves the system path for the desktop entry file
func getDesktopEntryDir() string {
xdgDataHome := os.Getenv("XDG_DATA_HOME")
if xdgDataHome == "" {
home := os.Getenv("HOME")
return filepath.Join(home, ".local", "share")
}
return xdgDataHome
}
+1
View File
@@ -51,6 +51,7 @@ func NewRootCommand() *cli.Command {
Before: beforeAction,
Commands: []*cli.Command{
newListCommand(),
newGenerateEntryCommand(),
},
}
}
@@ -0,0 +1,17 @@
[Desktop Entry]
Name={{.entry_name}}
GenericName=Terminal entering {{.entry_name}}
Comment=Terminal entering {{.entry_name}}
Categories=Distrobox;System;Utility
Exec={{.distrobox_path}} enter {{.extra_flags}} {{.container_name}}
Icon={{.icon}}
Keywords=distrobox;
NoDisplay=false
Terminal=true
TryExec={{.distrobox_path}}
Type=Application
Actions=Remove;
[Desktop Action Remove]
Name=Remove {{.entry_name}} from system
Exec={{.distrobox_path}} rm {{.extra_flags}} {{.container_name}}
+208
View File
@@ -0,0 +1,208 @@
package commands
import (
"context"
_ "embed"
"fmt"
"html/template"
"os"
"path/filepath"
"strings"
)
//go:embed assets/desktop_entry.toml.tmpl
var desktopEntryTmpl string
const (
defaultContainerName = "my-distrobox"
defaultEntryIcon = "https://raw.githubusercontent.com/89luca89/distrobox/main/icons/terminal-distrobox-icon.svg"
defaultContainerDistro = "terminal-distrobox-icon"
)
type GenerateEntryOptions struct {
Verbose bool
Delete bool
Root bool
DesktopEntryBaseDir string
DistroboxPath string
All bool
Icon string // ignored when All=true
ContainerName string // ignored when All=true
}
type GenerateEntryCommand struct {
listCommand *ListCommand
}
func NewGenerateEntryCommand(listCommand *ListCommand) *GenerateEntryCommand {
return &GenerateEntryCommand{
listCommand: listCommand,
}
}
func (c *GenerateEntryCommand) Execute(
ctx context.Context,
opts *GenerateEntryOptions) error {
// Determine whether is a single or all entries generation
// If all is set, fetch the list of all containers
// If not, use the provided container name or the default one
var containerNames []string
var icon string
switch {
case opts.All:
// Generate entries for all containers
listResult, err := c.listCommand.Execute(ctx)
if err != nil {
return fmt.Errorf("failed to list containers: %w", err)
}
containerNames = make([]string, 0, len(listResult.Containers))
for _, container := range listResult.Containers {
containerNames = append(containerNames, container.Name)
}
// Set icon to auto for all entries
icon = "auto"
case opts.ContainerName != "":
containerNames = []string{opts.ContainerName}
icon = opts.Icon
default:
containerNames = []string{defaultContainerName}
icon = opts.Icon
}
if opts.Delete {
// Delete the desktop entries for all the containers
for _, containerName := range containerNames {
if err := c.deleteEntry(containerName, opts.DesktopEntryBaseDir); err != nil {
return fmt.Errorf("failed to delete desktop entry for container %s: %w", containerName, err)
}
}
} else {
// Create the desktop entries for all the containers
for _, containerName := range containerNames {
if err := c.createEntry(containerName, icon, opts.DesktopEntryBaseDir, opts.DistroboxPath, opts.Root); err != nil {
return fmt.Errorf("failed to create desktop entry for container %s: %w", containerName, err)
}
}
}
return nil
}
func (c *GenerateEntryCommand) deleteEntry(containerName string, desktopEntryBaseDir string) error {
desktopEntryAppsDir := filepath.Join(desktopEntryBaseDir, "applications")
entryFilePath := c.getEntryFilePath(desktopEntryAppsDir, containerName)
if _, err := os.Stat(entryFilePath); os.IsNotExist(err) {
return nil
}
if err := os.Remove(entryFilePath); err != nil {
return fmt.Errorf("failed to delete desktop entry for container %s: %w", containerName, err)
}
return nil
}
func (c *GenerateEntryCommand) createEntry(
containerName string,
icon string,
desktopEntryBaseDir string,
distroboxPath string,
root bool,
) error {
desktopEntryAppsDir, _, err := c.ensureDesktopEntryDirExists(desktopEntryBaseDir)
if err != nil {
return fmt.Errorf("failed to ensure desktop entry directories exist: %w", err)
}
entryFilePath := c.getEntryFilePath(desktopEntryAppsDir, containerName)
data := c.composeDesktopEntryData(containerName, icon, distroboxPath, root)
if err := c.writeDesktopEntryFile(entryFilePath, data); err != nil {
return fmt.Errorf("failed to write desktop entry file for container %s: %w", containerName, err)
}
return nil
}
func (c *GenerateEntryCommand) ensureDesktopEntryDirExists(desktopEntryBaseDir string) (string, string, error) {
// Ensure the needed targets directories exist
desktopEntryAppsDir := filepath.Join(desktopEntryBaseDir, "applications")
if err := os.MkdirAll(desktopEntryAppsDir, 0750); err != nil {
return "", "", fmt.Errorf("failed to create desktop entry applications directory: %w", err)
}
desktopEntryIconsDir := filepath.Join(desktopEntryBaseDir, "icons")
if err := os.MkdirAll(desktopEntryIconsDir, 0750); err != nil {
return "", "", fmt.Errorf("failed to create desktop entry icons directory: %w", err)
}
return desktopEntryAppsDir, desktopEntryIconsDir, nil
}
// composeDesktopEntry generates the desktop entry for a single container
func (c *GenerateEntryCommand) composeDesktopEntryData(
containerName string,
icon string,
distroboxPath string,
root bool,
) map[string]string {
extraFlags := ""
if root {
extraFlags += "--root"
}
return map[string]string{
"entry_name": getEntryName(containerName),
"container_name": containerName,
"distrobox_path": distroboxPath,
"icon": c.getDesktopIcon(icon),
"extra_flags": extraFlags,
}
}
// getEntryName returns the formatted entry name for the desktop entry
// based on the container name, capitalizing the first letter.
func getEntryName(containerName string) string {
if containerName == "" {
return ""
}
first := strings.ToUpper(containerName[:1])
if len(containerName) > 1 {
return first + containerName[1:]
}
return first
}
func (c *GenerateEntryCommand) writeDesktopEntryFile(
entryFilePath string,
data map[string]string,
) error {
//nolint:gosec // 644 is common permission for desktop entry files
destFileWriter, err := os.OpenFile(entryFilePath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
if err != nil {
return fmt.Errorf("failed to create desktop entry file: %w", err)
}
defer destFileWriter.Close()
t, err := template.New("desktopEntry").Parse(desktopEntryTmpl)
if err != nil {
return fmt.Errorf("failed to parse desktop entry template: %w", err)
}
err = t.Execute(destFileWriter, data)
if err != nil {
return fmt.Errorf("failed to execute desktop entry template: %w", err)
}
return nil
}
func (c *GenerateEntryCommand) getEntryFilePath(desktopEntryDir, containerName string) string {
return filepath.Join(desktopEntryDir, containerName+".desktop")
}
func (c *GenerateEntryCommand) getDesktopIcon(
icon string,
) string {
if icon == "auto" {
// TODO: detect the icon for the current container's distro
return defaultEntryIcon
}
return icon
}
+224
View File
@@ -0,0 +1,224 @@
package commands_test
import (
"context"
"fmt"
"os"
"testing"
"github.com/89luca89/distrobox/pkg/commands"
"github.com/89luca89/distrobox/pkg/containermanager/providers"
)
func TestGenerateEntryCommand_Execute(t *testing.T) {
ctx := context.Background()
tempDir := t.TempDir()
defer os.RemoveAll(tempDir)
// create the list command
containerManager := providers.NewDocker(false)
listCmd := commands.NewListCommand(containerManager)
//
// Generate the entry
//
generateEntryCmd := commands.NewGenerateEntryCommand(listCmd)
opts := &commands.GenerateEntryOptions{
ContainerName: "test-container",
Verbose: true,
Delete: false,
Icon: "https://raw.githubusercontent.com/89luca89/distrobox/main/icons/terminal-distrobox-icon.svg",
Root: false,
DesktopEntryBaseDir: fmt.Sprintf("%s/.local/share/", tempDir),
DistroboxPath: "/usr/bin/distrobox",
}
err := generateEntryCmd.Execute(ctx, opts)
if err != nil {
t.Errorf("GenerateEntryCommand.Execute() error = %v", err)
}
expectedEntryPath := fmt.Sprintf("%s/.local/share/applications/test-container.desktop", tempDir)
if _, err := os.Stat(expectedEntryPath); os.IsNotExist(err) {
t.Errorf("Expected desktop entry file %s does not exist, %s", expectedEntryPath, tempDir)
}
expectedContent := `[Desktop Entry]
Name=Test-container
GenericName=Terminal entering Test-container
Comment=Terminal entering Test-container
Categories=Distrobox;System;Utility
Exec=/usr/bin/distrobox enter test-container
Icon=https://raw.githubusercontent.com/89luca89/distrobox/main/icons/terminal-distrobox-icon.svg
Keywords=distrobox;
NoDisplay=false
Terminal=true
TryExec=/usr/bin/distrobox
Type=Application
Actions=Remove;
[Desktop Action Remove]
Name=Remove Test-container from system
Exec=/usr/bin/distrobox rm test-container`
content, err := os.ReadFile(expectedEntryPath)
if err != nil {
t.Errorf("Failed to read desktop entry file: %v", err)
}
if string(content) != expectedContent {
t.Errorf(
"Desktop entry content does not match expected.\nGot:\n'%s'\nExpected:\n'%s'",
string(content),
expectedContent,
)
}
// Delete the entry
opts.Delete = true
err = generateEntryCmd.Execute(ctx, opts)
if err != nil {
t.Errorf("GenerateEntryCommand.Execute() error on delete = %v", err)
}
if _, err := os.Stat(expectedEntryPath); !os.IsNotExist(err) {
t.Errorf("Expected desktop entry file %s to be deleted, %s", expectedEntryPath, tempDir)
}
// Try deleting a non-existing entry
err = generateEntryCmd.Execute(ctx, opts)
if err != nil {
t.Errorf("GenerateEntryCommand.Execute() error on delete non-existing = %v", err)
}
}
func TestGenerateEntryCommand_Execute_Root(t *testing.T) {
ctx := context.Background()
tempDir := t.TempDir()
defer os.RemoveAll(tempDir)
containerManager := providers.NewDocker(false)
listCmd := commands.NewListCommand(containerManager)
generateEntryCmd := commands.NewGenerateEntryCommand(listCmd)
opts := &commands.GenerateEntryOptions{
ContainerName: "test-container",
Verbose: true,
Delete: false,
Icon: "https://raw.githubusercontent.com/89luca89/distrobox/main/icons/terminal-distrobox-icon.svg",
Root: true,
DesktopEntryBaseDir: fmt.Sprintf("%s/.local/share/", tempDir),
DistroboxPath: "/usr/bin/distrobox",
}
err := generateEntryCmd.Execute(ctx, opts)
if err != nil {
t.Errorf("GenerateEntryCommand.Execute() error = %v", err)
}
expectedEntryPath := fmt.Sprintf("%s/.local/share/applications/test-container.desktop", tempDir)
if _, err := os.Stat(expectedEntryPath); os.IsNotExist(err) {
t.Errorf("Expected desktop entry file %s does not exist, %s", expectedEntryPath, tempDir)
}
expectedContent := `[Desktop Entry]
Name=Test-container
GenericName=Terminal entering Test-container
Comment=Terminal entering Test-container
Categories=Distrobox;System;Utility
Exec=/usr/bin/distrobox enter --root test-container
Icon=https://raw.githubusercontent.com/89luca89/distrobox/main/icons/terminal-distrobox-icon.svg
Keywords=distrobox;
NoDisplay=false
Terminal=true
TryExec=/usr/bin/distrobox
Type=Application
Actions=Remove;
[Desktop Action Remove]
Name=Remove Test-container from system
Exec=/usr/bin/distrobox rm --root test-container`
content, err := os.ReadFile(expectedEntryPath)
if err != nil {
t.Errorf("Failed to read desktop entry file: %v", err)
}
if string(content) != expectedContent {
t.Errorf(
"Desktop entry content does not match expected.\nGot:\n'%s'\nExpected:\n'%s'",
string(content),
expectedContent,
)
}
}
func TestGenerateAllEntriesCommand_Execute(t *testing.T) {
ctx := context.Background()
// tempDir is the test directory where we expect to create desktop entries
tempDir := t.TempDir()
defer os.RemoveAll(tempDir)
// create the list command
containerManager := providers.NewDocker(false)
listCmd := commands.NewListCommand(containerManager)
// create the generate all entries command
genAllEntriesCmd := commands.NewGenerateEntryCommand(listCmd)
//
// Generate the entries
//
opts := &commands.GenerateEntryOptions{
All: true,
Verbose: false,
Delete: false,
DesktopEntryBaseDir: fmt.Sprintf("%s/.local/share/", tempDir),
DistroboxPath: "/usr/bin/distrobox",
}
err := genAllEntriesCmd.Execute(ctx, opts)
if err != nil {
t.Errorf("GenerateAllEntriesCommand.Execute() error = %v", err)
}
// retrieve the list of containers to verify entries were created
listResult, err := listCmd.Execute(ctx)
if err != nil {
t.Errorf("ListCommand.Execute() error = %v", err)
}
// verify that each container has a corresponding desktop entry
for _, container := range listResult.Containers {
expectedEntryPath := fmt.Sprintf("%s/.local/share/applications/%s.desktop", tempDir, container.Name)
if _, err := os.Stat(expectedEntryPath); os.IsNotExist(err) {
t.Errorf("Expected desktop entry file %s does not exist", expectedEntryPath)
}
}
//
// Delete the entries
//
opts.Delete = true
err = genAllEntriesCmd.Execute(ctx, opts)
if err != nil {
t.Errorf("GenerateAllEntriesCommand.Execute() error = %v", err)
}
// verify that each container's desktop entry has been deleted
for _, container := range listResult.Containers {
expectedEntryPath := fmt.Sprintf("%s/.local/share/applications/%s.desktop", tempDir, container.Name)
if _, err := os.Stat(expectedEntryPath); !os.IsNotExist(err) {
t.Errorf("Expected desktop entry file %s to be deleted", expectedEntryPath)
}
}
}