From 3b4f54984e6a3b2ff1520be153d747161148b91c Mon Sep 17 00:00:00 2001 From: Joshua Bell Date: Thu, 29 Jan 2026 15:18:42 -0600 Subject: [PATCH] Add workspace CLI (list/add/remove) and registry hash/unregister --- cmd/qvm/main.go | 1 + cmd/qvm/workspace.go | 146 +++++++++++++++++++++++++++++++++ internal/workspace/registry.go | 40 +++++++++ 3 files changed, 187 insertions(+) create mode 100644 cmd/qvm/workspace.go diff --git a/cmd/qvm/main.go b/cmd/qvm/main.go index adf280c..5592c32 100644 --- a/cmd/qvm/main.go +++ b/cmd/qvm/main.go @@ -29,6 +29,7 @@ func init() { rootCmd.AddCommand(resetCmd) rootCmd.AddCommand(cleanCmd) rootCmd.AddCommand(doctorCmd) + rootCmd.AddCommand(workspaceCmd) } func main() { diff --git a/cmd/qvm/workspace.go b/cmd/qvm/workspace.go new file mode 100644 index 0000000..be8df92 --- /dev/null +++ b/cmd/qvm/workspace.go @@ -0,0 +1,146 @@ +package main + +import ( + "fmt" + "os" + "qvm/internal/config" + "qvm/internal/logging" + "qvm/internal/vm" + "qvm/internal/workspace" + + "github.com/spf13/cobra" +) + +var workspaceCmd = &cobra.Command{ + Use: "workspace", + Short: "Manage workspace mounts", + Long: `Manage workspace mounts for the VM. + +Workspaces are directories that get mounted into the VM at /workspace/{hash}-{dirname}. +Use subcommands to list, add, or remove workspace mounts.`, + Aliases: []string{"ws"}, +} + +var workspaceListCmd = &cobra.Command{ + Use: "list", + Short: "List registered workspaces", + Long: `List all registered workspaces with their hash, host path, and guest path.`, + Aliases: []string{"ls"}, + Run: func(cmd *cobra.Command, args []string) { + regResult := workspace.Load(config.WorkspacesFile) + if regResult.IsError() { + logging.Error(regResult.Error().Error()) + os.Exit(1) + } + reg := regResult.MustGet() + + workspaces := reg.List() + if len(workspaces) == 0 { + fmt.Println("No workspaces registered.") + return + } + + fmt.Printf("%-10s %-40s %s\n", "HASH", "HOST PATH", "GUEST PATH") + fmt.Printf("%-10s %-40s %s\n", "----", "---------", "----------") + for _, ws := range workspaces { + fmt.Printf("%-10s %-40s %s\n", ws.Hash, ws.HostPath, ws.GuestPath) + } + }, +} + +var workspaceRemoveCmd = &cobra.Command{ + Use: "remove ", + Short: "Remove a workspace mount", + Long: `Remove a workspace from the registry by its hash. + +The hash can be found using 'qvm workspace list' or 'qvm status'. +You can use a partial hash (minimum 3 characters) for convenience. + +Note: If the VM is running, you'll need to restart it for the change to take effect.`, + Aliases: []string{"rm", "unmount"}, + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + hash := args[0] + + regResult := workspace.Load(config.WorkspacesFile) + if regResult.IsError() { + logging.Error(regResult.Error().Error()) + os.Exit(1) + } + reg := regResult.MustGet() + + wsOpt := reg.UnregisterByHash(hash) + if wsOpt.IsAbsent() { + logging.Error(fmt.Sprintf("No workspace found with hash: %s", hash)) + os.Exit(1) + } + + ws := wsOpt.MustGet() + + saveResult := reg.Save() + if saveResult.IsError() { + logging.Error(saveResult.Error().Error()) + os.Exit(1) + } + + fmt.Printf("Removed workspace: %s -> %s\n", ws.Hash, ws.HostPath) + + // Check if VM is running and warn user + statusResult := vm.Status() + if statusResult.IsOk() && statusResult.MustGet().Running { + logging.Warn("VM is running. Restart it for changes to take effect: qvm stop && qvm start") + } + }, +} + +var workspaceAddCmd = &cobra.Command{ + Use: "add [path]", + Short: "Add a workspace mount", + Long: `Add a directory as a workspace mount. + +If no path is provided, the current directory is used. + +Note: If the VM is running, you'll need to restart it for the mount to be available.`, + Args: cobra.MaximumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + path := "." + if len(args) > 0 { + path = args[0] + } + + regResult := workspace.Load(config.WorkspacesFile) + if regResult.IsError() { + logging.Error(regResult.Error().Error()) + os.Exit(1) + } + reg := regResult.MustGet() + + wsResult := reg.Register(path) + if wsResult.IsError() { + logging.Error(wsResult.Error().Error()) + os.Exit(1) + } + ws := wsResult.MustGet() + + saveResult := reg.Save() + if saveResult.IsError() { + logging.Error(saveResult.Error().Error()) + os.Exit(1) + } + + fmt.Printf("Registered workspace: %s -> %s\n", ws.Hash, ws.HostPath) + fmt.Printf("Guest path: %s\n", ws.GuestPath) + + // Check if VM is running and warn user + statusResult := vm.Status() + if statusResult.IsOk() && statusResult.MustGet().Running { + logging.Warn("VM is running. Restart it for the new mount to be available: qvm stop && qvm start") + } + }, +} + +func init() { + workspaceCmd.AddCommand(workspaceListCmd) + workspaceCmd.AddCommand(workspaceRemoveCmd) + workspaceCmd.AddCommand(workspaceAddCmd) +} diff --git a/internal/workspace/registry.go b/internal/workspace/registry.go index 42a31e7..488277d 100644 --- a/internal/workspace/registry.go +++ b/internal/workspace/registry.go @@ -138,3 +138,43 @@ func (r *Registry) Find(hostPath string) mo.Option[Workspace] { } return mo.None[Workspace]() } + +// FindByHash looks up a workspace by its hash (or hash prefix) +func (r *Registry) FindByHash(hash string) mo.Option[Workspace] { + for _, ws := range r.workspaces { + if ws.Hash == hash { + return mo.Some(ws) + } + // Also support prefix matching for convenience + if len(hash) >= 3 && len(hash) < 8 && ws.Hash[:len(hash)] == hash { + return mo.Some(ws) + } + } + return mo.None[Workspace]() +} + +// Unregister removes a workspace from the registry by host path +func (r *Registry) Unregister(hostPath string) bool { + absPath, err := filepath.Abs(hostPath) + if err != nil { + absPath = hostPath + } + + if _, exists := r.workspaces[absPath]; exists { + delete(r.workspaces, absPath) + return true + } + return false +} + +// UnregisterByHash removes a workspace from the registry by its hash +func (r *Registry) UnregisterByHash(hash string) mo.Option[Workspace] { + wsOpt := r.FindByHash(hash) + if wsOpt.IsAbsent() { + return mo.None[Workspace]() + } + + ws := wsOpt.MustGet() + delete(r.workspaces, ws.HostPath) + return mo.Some(ws) +}