@@ -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.\n Plugins 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\t VERSION\t TYPE\t DESCRIPTION" )
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.\n The 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