Skip to content

Commit a329700

Browse files
committed
Merge ISS-51-plugin-system into main
2 parents c0b0718 + 77c7904 commit a329700

9 files changed

Lines changed: 1222 additions & 0 deletions

File tree

cmd/devbox/main.go

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ import (
1717
devboxerr "github.com/junixlabs/devbox/internal/errors"
1818
"github.com/junixlabs/devbox/internal/identity"
1919
"github.com/junixlabs/devbox/internal/metrics"
20+
"github.com/junixlabs/devbox/internal/plugin"
21+
"github.com/junixlabs/devbox/internal/plugin/docker"
2022
"github.com/junixlabs/devbox/internal/server"
2123
"github.com/junixlabs/devbox/internal/snapshot"
2224
devboxssh "github.com/junixlabs/devbox/internal/ssh"
@@ -72,6 +74,7 @@ func main() {
7274
rootCmd.AddCommand(tuiCmd(wm))
7375
rootCmd.AddCommand(snapshotCmd())
7476
rootCmd.AddCommand(restoreCmd())
77+
rootCmd.AddCommand(pluginCmd())
7578

7679
if err := rootCmd.Execute(); err != nil {
7780
printError(err)
@@ -1235,3 +1238,158 @@ func formatBytes(b int64) string {
12351238
}
12361239
return formatBytesShort(uint64(b))
12371240
}
1241+
1242+
func pluginCmd() *cobra.Command {
1243+
cmd := &cobra.Command{
1244+
Use: "plugin",
1245+
Short: "Manage plugins",
1246+
Long: "List, install, and remove devbox plugins.\nPlugins extend devbox with custom providers (Docker, Podman, LXC) and lifecycle hooks.",
1247+
}
1248+
cmd.AddCommand(pluginListCmd())
1249+
cmd.AddCommand(pluginInstallCmd())
1250+
cmd.AddCommand(pluginRemoveCmd())
1251+
return cmd
1252+
}
1253+
1254+
func pluginListCmd() *cobra.Command {
1255+
return &cobra.Command{
1256+
Use: "list",
1257+
Aliases: []string{"ls"},
1258+
Short: "List installed plugins",
1259+
RunE: func(cmd *cobra.Command, args []string) error {
1260+
registry := plugin.NewRegistry()
1261+
1262+
// Register built-in Docker provider.
1263+
dockerProvider := docker.New(nil)
1264+
dockerManifest := docker.Manifest()
1265+
_ = registry.RegisterProvider("docker", dockerProvider, dockerManifest)
1266+
1267+
// Discover external plugins.
1268+
pluginDir := plugin.DefaultPluginDir()
1269+
if err := registry.Discover(pluginDir); err != nil {
1270+
slog.Warn("plugin discovery failed", "error", err)
1271+
}
1272+
1273+
plugins := registry.ListPlugins()
1274+
if len(plugins) == 0 {
1275+
fmt.Println("No plugins installed")
1276+
return nil
1277+
}
1278+
1279+
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
1280+
fmt.Fprintln(w, "NAME\tVERSION\tTYPE\tDESCRIPTION")
1281+
for _, p := range plugins {
1282+
builtIn := ""
1283+
if p.BuiltIn {
1284+
builtIn = " (built-in)"
1285+
}
1286+
fmt.Fprintf(w, "%s\t%s\t%s\t%s%s\n", p.Name, p.Version, p.Type, p.Description, builtIn)
1287+
}
1288+
return w.Flush()
1289+
},
1290+
}
1291+
}
1292+
1293+
func pluginInstallCmd() *cobra.Command {
1294+
return &cobra.Command{
1295+
Use: "install <path>",
1296+
Short: "Install a plugin from a local directory",
1297+
Long: "Copy a plugin directory to the devbox plugins directory.\nThe source must contain a valid plugin.yaml manifest.",
1298+
Args: cobra.ExactArgs(1),
1299+
RunE: func(cmd *cobra.Command, args []string) error {
1300+
srcDir := args[0]
1301+
1302+
// Validate manifest in source directory.
1303+
manifestPath := filepath.Join(srcDir, "plugin.yaml")
1304+
m, err := plugin.LoadManifest(manifestPath)
1305+
if err != nil {
1306+
return fmt.Errorf("devbox plugin install: %w", err)
1307+
}
1308+
1309+
// Copy plugin to discovery directory.
1310+
destDir := filepath.Join(plugin.DefaultPluginDir(), m.Name)
1311+
if err := copyDir(srcDir, destDir); err != nil {
1312+
return fmt.Errorf("devbox plugin install: %w", err)
1313+
}
1314+
1315+
fmt.Printf("Plugin %q (%s) installed\n", m.Name, m.Version)
1316+
return nil
1317+
},
1318+
}
1319+
}
1320+
1321+
func pluginRemoveCmd() *cobra.Command {
1322+
return &cobra.Command{
1323+
Use: "remove <name>",
1324+
Aliases: []string{"rm"},
1325+
Short: "Remove an installed plugin",
1326+
Args: cobra.ExactArgs(1),
1327+
RunE: func(cmd *cobra.Command, args []string) error {
1328+
name := args[0]
1329+
1330+
// Prevent removing built-in plugins.
1331+
if name == "docker" {
1332+
return fmt.Errorf("devbox plugin remove: cannot remove built-in plugin %q", name)
1333+
}
1334+
1335+
// Validate name to prevent path traversal.
1336+
if strings.ContainsAny(name, `/\`) || strings.Contains(name, "..") || name == "." {
1337+
return fmt.Errorf("devbox plugin remove: invalid plugin name %q", name)
1338+
}
1339+
1340+
pluginDir := filepath.Join(plugin.DefaultPluginDir(), name)
1341+
if _, err := os.Stat(pluginDir); os.IsNotExist(err) {
1342+
return fmt.Errorf("devbox plugin remove: plugin %q not found", name)
1343+
}
1344+
1345+
if err := os.RemoveAll(pluginDir); err != nil {
1346+
return fmt.Errorf("devbox plugin remove: %w", err)
1347+
}
1348+
1349+
fmt.Printf("Plugin %q removed\n", name)
1350+
return nil
1351+
},
1352+
}
1353+
}
1354+
1355+
// copyDir recursively copies a directory tree.
1356+
func copyDir(src, dst string) error {
1357+
if err := os.MkdirAll(dst, 0755); err != nil {
1358+
return err
1359+
}
1360+
1361+
entries, err := os.ReadDir(src)
1362+
if err != nil {
1363+
return err
1364+
}
1365+
1366+
for _, entry := range entries {
1367+
srcPath := filepath.Join(src, entry.Name())
1368+
dstPath := filepath.Join(dst, entry.Name())
1369+
1370+
// Skip symlinks and special files to prevent path traversal.
1371+
if entry.Type()&os.ModeSymlink != 0 {
1372+
continue
1373+
}
1374+
1375+
if entry.IsDir() {
1376+
if err := copyDir(srcPath, dstPath); err != nil {
1377+
return err
1378+
}
1379+
} else if entry.Type().IsRegular() {
1380+
data, err := os.ReadFile(srcPath)
1381+
if err != nil {
1382+
return err
1383+
}
1384+
info, err := entry.Info()
1385+
if err != nil {
1386+
return err
1387+
}
1388+
if err := os.WriteFile(dstPath, data, info.Mode()); err != nil {
1389+
return err
1390+
}
1391+
}
1392+
// Skip non-regular, non-directory entries (pipes, sockets, devices).
1393+
}
1394+
return nil
1395+
}

0 commit comments

Comments
 (0)