From 6970e7fa82aa26dee9ed143e6ea52a10a1673f8a Mon Sep 17 00:00:00 2001 From: linus kendall Date: Thu, 7 May 2026 13:43:37 +0530 Subject: [PATCH 1/2] Add Coder workspace support to the applet --- README.md | 10 +- docs/config.md | 26 +- doctor_cmd.go | 35 ++- internal/config/config.go | 95 ++++-- internal/config/config_test.go | 46 +++ internal/daemon/daemon.go | 18 ++ internal/daemon/gui_flow.go | 133 +++++---- internal/daemon/gui_window.go | 387 ++++++++++++++++-------- internal/daemon/gui_window_cosmo.go | 312 ++++++++++++++++++-- internal/daemon/poller.go | 65 ++++- internal/daemon/tray.go | 129 ++++---- internal/doctor/doctor.go | 11 +- internal/provider/coder.go | 297 +++++++++++++++++++ internal/provider/github.go | 132 +++++++++ internal/provider/provider.go | 155 ++++++++++ internal/sshconfig/sshconfig.go | 73 ++++- internal/sshconfig/sshconfig_test.go | 37 +++ internal/tui/tui.go | 48 +-- main.go | 422 ++++++++++++++------------- modules/home-manager.nix | 60 ++++ 20 files changed, 1966 insertions(+), 525 deletions(-) create mode 100644 internal/provider/coder.go create mode 100644 internal/provider/github.go create mode 100644 internal/provider/provider.go diff --git a/README.md b/README.md index c619d53..219cd66 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ # Cosmonaut Launcher -CLI and menu bar applet for GitHub Codespaces + [Zed](https://zed.dev). +CLI and menu bar applet for GitHub Codespaces or Coder workspaces + [Zed](https://zed.dev). **[Documentation](https://linuskendall.github.io/cosmonaut/)** · **[Configuration](https://linuskendall.github.io/cosmonaut/config/)** · @@ -38,7 +38,7 @@ Or with [Home Manager](https://linuskendall.github.io/cosmonaut/install/#home-ma ## Quick start ```bash -# Launch interactively: pick a repo, select a codespace, open in Zed +# Launch interactively: pick a repo/workspace and open it in Zed cosmonaut # Or use a named target from your config @@ -50,6 +50,8 @@ cosmonaut applet ## Requirements -- [`gh`](https://cli.github.com/) authenticated (`gh auth login`) +- One workspace provider CLI: + - [`gh`](https://cli.github.com/) authenticated (`gh auth login`) for GitHub Codespaces + - [`coder`](https://coder.com/docs/install/cli) authenticated (`coder login`) for Coder - [`zed`](https://zed.dev) installed -- SSH server in your codespace image ([`ghcr.io/devcontainers/features/sshd:1`](https://github.com/devcontainers/features/tree/main/src/sshd)) +- SSH access enabled in your remote workspace image diff --git a/docs/config.md b/docs/config.md index 2efb1c7..2c592a8 100644 --- a/docs/config.md +++ b/docs/config.md @@ -11,9 +11,18 @@ The default path is `cosmonaut.config.json` in the current directory for the CLI // Default target to use when no target name is given. "defaultTarget": "work", + // Global workspace provider: "github" (default) or "coder". + "workspaceProvider": "coder", + // Editor to launch: "zed" (default) or "neovim". "editor": "zed", + "providers": { + "coder": { + "organization": "coder" + } + }, + // Applet settings (menu bar icon, hotkey, codespace lifecycle). "daemon": { "hotkey": "Cmd+Shift+S", @@ -23,11 +32,15 @@ The default path is `cosmonaut.config.json` in the current directory for the CLI "targets": { "work": { - "repository": "my-org/my-repo", - "branch": "main", "workspacePath": "/workspaces/my-repo", - "machine": "standardLinux32gb", - "preWarm": "08:00" + "coder": { + "template": "nomad-devcontainer", + "workspaceName": "my-repo", + "parameters": { + "repo": "my-org/my-repo" + }, + "stopAfter": "8h" + } } } } @@ -38,7 +51,9 @@ The default path is `cosmonaut.config.json` in the current directory for the CLI | Field | Type | Description | |---|---|---| | `defaultTarget` | string | Target name to use when no target is passed on the CLI | +| `workspaceProvider` | string | `github` (default) or `coder` | | `editor` | string | `zed` (default) or `neovim`; overridden by `--editor` | +| `providers` | object | Provider-specific defaults such as `providers.coder.organization` | | `targets` | object | Map of target name to target definition | | `daemon` | object | Applet settings (see [Daemon fields](#daemon-fields)) | @@ -46,7 +61,7 @@ The default path is `cosmonaut.config.json` in the current directory for the CLI | Field | Type | Required | Description | |---|---|---|---| -| `repository` | string | yes | GitHub repository in `owner/repo` form | +| `repository` | string | | GitHub repository in `owner/repo` form; optional for Coder targets | | `branch` | string | | Preferred branch when creating or matching a codespace | | `displayName` | string | | Exact display name to disambiguate codespace matches | | `codespaceName` | string | | Exact codespace name for strict reuse | @@ -60,6 +75,7 @@ The default path is `cosmonaut.config.json` in the current directory for the CLI | `zedNickname` | string | | Friendly name in Zed's remote project list | | `autoStop` | string | | Reserved for a future auto-stop feature; currently parsed but not acted on | | `preWarm` | string | | Time-of-day to pre-warm codespace (applet only, e.g. `08:00`) | +| `coder` | object | | Coder-specific target settings: `template`, `workspaceName`, `parameters`, `stopAfter`, `organization` | ## Daemon fields diff --git a/doctor_cmd.go b/doctor_cmd.go index cbabad1..4ea4ae9 100644 --- a/doctor_cmd.go +++ b/doctor_cmd.go @@ -3,11 +3,13 @@ package main import ( "fmt" "os" + "path/filepath" "github.com/spf13/cobra" - "github.com/linuskendall/cosmonaut/internal/codespace" + "github.com/linuskendall/cosmonaut/internal/config" "github.com/linuskendall/cosmonaut/internal/doctor" + "github.com/linuskendall/cosmonaut/internal/provider" ) func doctorCmd() *cobra.Command { @@ -19,36 +21,51 @@ func doctorCmd() *cobra.Command { and report which pass and which need attention. With --fix, programmatic fixes are applied directly. Fixes that need a -TTY (such as gh auth refresh) are printed as commands you can copy and + TTY (such as gh auth refresh) are printed as commands you can copy and run yourself.`, RunE: func(cmd *cobra.Command, args []string) error { - return runDoctor(fix) + configPath, err := cmd.Flags().GetString("config") + if err != nil { + return err + } + return runDoctor(configPath, fix) }, } cmd.Flags().BoolVar(&fix, "fix", false, "apply fixes for failing checks") return cmd } -func runDoctor(applyFixes bool) error { - if err := codespace.RequireCommand("gh"); err != nil { +func runDoctor(configPath string, applyFixes bool) error { + absConfigPath, err := filepath.Abs(configPath) + if err != nil { + return err + } + cfg, _ := config.LoadConfig(absConfigPath) + if cfg == nil { + cfg = &config.Config{} + } + manager, err := provider.NewManager(cfg) + if err != nil { + return err + } + if err := manager.EnsurePrereqs(); err != nil { return err } - runner := codespace.DefaultGHRunner{} - // Lazy: only call gh codespace list if a check actually needs it. + // Lazy: only call provider list if a check actually needs it. var ( listErrCalled bool listErrCache error ) listErr := func() error { if !listErrCalled { - _, listErrCache = codespace.ListAllCodespaces(runner) + _, listErrCache = manager.ListAllWorkspaces() listErrCalled = true } return listErrCache } - checks := doctor.Catalog(listErr) + checks := doctor.CatalogForProvider(manager.Name(), listErr) out := os.Stdout failures := 0 diff --git a/internal/config/config.go b/internal/config/config.go index b8a3309..be57909 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -12,42 +12,64 @@ import ( ) type Config struct { - DefaultTarget string `json:"defaultTarget,omitempty"` - Editor string `json:"editor,omitempty"` // "zed" (default) or "neovim" - Targets map[string]Target `json:"targets"` - Daemon *DaemonConfig `json:"daemon,omitempty"` + DefaultTarget string `json:"defaultTarget,omitempty"` + WorkspaceProvider string `json:"workspaceProvider,omitempty"` // "github" (default) or "coder" + Editor string `json:"editor,omitempty"` // "zed" (default) or "neovim" + Providers ProviderConfigs `json:"providers,omitempty"` + Targets map[string]Target `json:"targets"` + Daemon *DaemonConfig `json:"daemon,omitempty"` +} + +type ProviderConfigs struct { + GitHub GitHubProviderConfig `json:"github,omitempty"` + Coder CoderProviderConfig `json:"coder,omitempty"` +} + +type GitHubProviderConfig struct{} + +type CoderProviderConfig struct { + Organization string `json:"organization,omitempty"` } // DaemonConfig holds settings for the background daemon (tray, hotkey, poller). type DaemonConfig struct { Hotkey string `json:"hotkey,omitempty"` // e.g. "Cmd+Shift+S" (macOS) or "Ctrl+Shift+S" (Linux) HotkeyAction string `json:"hotkeyAction,omitempty"` // "picker" (default), "previous", or "default" - Terminal string `json:"terminal,omitempty"` // terminal app to launch picker in; "auto" to detect - PollInterval string `json:"pollInterval,omitempty"` // how often to poll codespace state (e.g. "5m") + Terminal string `json:"terminal,omitempty"` // terminal app to launch picker in; "auto" to detect + PollInterval string `json:"pollInterval,omitempty"` // how often to poll codespace state (e.g. "5m") InhibitSleep string `json:"inhibitSleep,omitempty"` // "off" (default), "sleep", or "sleep+shutdown" } type Target struct { - Repository string `json:"repository"` - Branch string `json:"branch,omitempty"` - DisplayName string `json:"displayName,omitempty"` - CodespaceName string `json:"codespaceName,omitempty"` - WorkspacePath string `json:"workspacePath"` - Machine string `json:"machine,omitempty"` - Location string `json:"location,omitempty"` - DevcontainerPath string `json:"devcontainerPath,omitempty"` - IdleTimeout string `json:"idleTimeout,omitempty"` - RetentionPeriod string `json:"retentionPeriod,omitempty"` - UploadBinaryOverSSH *bool `json:"uploadBinaryOverSsh,omitempty"` - ZedNickname string `json:"zedNickname,omitempty"` - AutoStop string `json:"autoStop,omitempty"` // auto-stop after idle duration (e.g. "30m") - PreWarm string `json:"preWarm,omitempty"` // time-of-day to pre-warm codespace (e.g. "08:00") + Repository string `json:"repository,omitempty"` + Branch string `json:"branch,omitempty"` + DisplayName string `json:"displayName,omitempty"` + CodespaceName string `json:"codespaceName,omitempty"` + WorkspacePath string `json:"workspacePath"` + Machine string `json:"machine,omitempty"` + Location string `json:"location,omitempty"` + DevcontainerPath string `json:"devcontainerPath,omitempty"` + IdleTimeout string `json:"idleTimeout,omitempty"` + RetentionPeriod string `json:"retentionPeriod,omitempty"` + UploadBinaryOverSSH *bool `json:"uploadBinaryOverSsh,omitempty"` + ZedNickname string `json:"zedNickname,omitempty"` + AutoStop string `json:"autoStop,omitempty"` // auto-stop after idle duration (e.g. "30m") + PreWarm string `json:"preWarm,omitempty"` // time-of-day to pre-warm codespace (e.g. "08:00") + Coder *CoderTargetConfig `json:"coder,omitempty"` +} + +type CoderTargetConfig struct { + Template string `json:"template,omitempty"` + WorkspaceName string `json:"workspaceName,omitempty"` + Parameters map[string]string `json:"parameters,omitempty"` + StopAfter string `json:"stopAfter,omitempty"` + Organization string `json:"organization,omitempty"` } var ( - blockCommentRe = regexp.MustCompile(`(?s)/\*.*?\*/`) - lineCommentRe = regexp.MustCompile(`(?m)^\s*//.*$`) - trailingCommaRe = regexp.MustCompile(`,\s*([}\]])`) + blockCommentRe = regexp.MustCompile(`(?s)/\*.*?\*/`) + lineCommentRe = regexp.MustCompile(`(?m)^\s*//.*$`) + trailingCommaRe = regexp.MustCompile(`,\s*([}\]])`) ) // ParseJSONC strips comments and trailing commas, then returns clean JSON bytes. @@ -75,6 +97,10 @@ func LoadConfig(path string) (*Config, error) { return nil, fmt.Errorf("parsing %s: %w", path, err) } + if cfg.WorkspaceProvider == "" { + cfg.WorkspaceProvider = "github" + } + return &cfg, nil } @@ -89,6 +115,26 @@ func SaveConfig(path string, cfg *Config) error { return os.WriteFile(path, data, 0644) } +func (c *Config) EffectiveWorkspaceProvider() string { + if c == nil || c.WorkspaceProvider == "" { + return "github" + } + return c.WorkspaceProvider +} + +func (t Target) ExplicitWorkspaceName(provider string) string { + if provider == "coder" { + if t.Coder != nil && t.Coder.WorkspaceName != "" { + return t.Coder.WorkspaceName + } + return "" + } + if t.CodespaceName != "" { + return t.CodespaceName + } + return "" +} + // FieldDoc describes a single config target field for generated documentation. type FieldDoc struct { JSON string // JSON key name @@ -99,7 +145,7 @@ type FieldDoc struct { // TargetFieldDocs is the authoritative documentation for every Target field. var TargetFieldDocs = []FieldDoc{ - {"repository", "string", true, "GitHub repository in owner/repo form"}, + {"repository", "string", false, "GitHub repository in owner/repo form; optional for Coder targets"}, {"branch", "string", false, "Preferred branch when creating or matching a codespace"}, {"displayName", "string", false, "Exact display name to disambiguate codespace matches"}, {"codespaceName", "string", false, "Exact codespace name for strict reuse"}, @@ -113,6 +159,7 @@ var TargetFieldDocs = []FieldDoc{ {"zedNickname", "string", false, "Friendly name shown in Zed's remote project list"}, {"autoStop", "string", false, "Auto-stop codespace after idle duration (e.g. 30m)"}, {"preWarm", "string", false, "Time-of-day to pre-warm codespace (e.g. 08:00)"}, + {"coder", "object", false, "Coder-specific target settings: template, workspaceName, parameters, stopAfter, organization"}, } // DaemonFieldDocs is the authoritative documentation for DaemonConfig fields. diff --git a/internal/config/config_test.go b/internal/config/config_test.go index e6eafb5..20764e5 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -58,7 +58,53 @@ func TestLoadConfig(t *testing.T) { if cfg.DefaultTarget != "demo" { t.Errorf("defaultTarget = %q, want demo", cfg.DefaultTarget) } + if cfg.WorkspaceProvider != "github" { + t.Errorf("workspaceProvider = %q, want github default", cfg.WorkspaceProvider) + } if _, ok := cfg.Targets["demo"]; !ok { t.Error("missing target 'demo'") } } + +func TestLoadCoderConfig(t *testing.T) { + content := `{ + "workspaceProvider": "coder", + "providers": { + "coder": { + "organization": "coder" + } + }, + "targets": { + "work": { + "workspacePath": "/workspaces/demo", + "coder": { + "template": "nomad-devcontainer", + "workspaceName": "demo", + "parameters": { + "repo": "acme/demo" + }, + "stopAfter": "8h" + } + } + } + }` + + dir := t.TempDir() + path := filepath.Join(dir, "config.json") + os.WriteFile(path, []byte(content), 0644) + + cfg, err := LoadConfig(path) + if err != nil { + t.Fatal(err) + } + if got := cfg.EffectiveWorkspaceProvider(); got != "coder" { + t.Fatalf("workspace provider = %q, want coder", got) + } + target := cfg.Targets["work"] + if target.Coder == nil || target.Coder.Template != "nomad-devcontainer" { + t.Fatalf("coder target not parsed: %+v", target.Coder) + } + if target.Coder.Parameters["repo"] != "acme/demo" { + t.Fatalf("coder parameters = %+v", target.Coder.Parameters) + } +} diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index ce38632..485b8e2 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -14,6 +14,7 @@ import ( "github.com/linuskendall/cosmonaut/internal/codespace" "github.com/linuskendall/cosmonaut/internal/config" + "github.com/linuskendall/cosmonaut/internal/provider" ) // Daemon is the long-running background process that hosts the system tray, @@ -27,6 +28,7 @@ type Daemon struct { mu sync.Mutex codespaces []codespace.Codespace + workspaces []provider.Workspace portCache map[string]portCacheEntry forwards *PortForwardManager listErr error @@ -167,6 +169,15 @@ func (d *Daemon) Codespaces() []codespace.Codespace { return result } +// Workspaces returns the last-polled combined workspace list. +func (d *Daemon) Workspaces() []provider.Workspace { + d.mu.Lock() + defer d.mu.Unlock() + result := make([]provider.Workspace, len(d.workspaces)) + copy(result, d.workspaces) + return result +} + // SetCodespaces updates the cached codespace list. func (d *Daemon) SetCodespaces(cs []codespace.Codespace) { d.mu.Lock() @@ -174,6 +185,13 @@ func (d *Daemon) SetCodespaces(cs []codespace.Codespace) { d.codespaces = cs } +// SetWorkspaces updates the cached combined workspace list. +func (d *Daemon) SetWorkspaces(ws []provider.Workspace) { + d.mu.Lock() + defer d.mu.Unlock() + d.workspaces = ws +} + // ListErr returns the error from the most recent codespace list attempt, // or nil on success. func (d *Daemon) ListErr() error { diff --git a/internal/daemon/gui_flow.go b/internal/daemon/gui_flow.go index 5cd103a..4355652 100644 --- a/internal/daemon/gui_flow.go +++ b/internal/daemon/gui_flow.go @@ -3,15 +3,14 @@ package daemon import ( "fmt" "log" - "os" "fyne.io/fyne/v2" "fyne.io/fyne/v2/dialog" - "github.com/linuskendall/cosmonaut/internal/codespace" "github.com/linuskendall/cosmonaut/internal/config" "github.com/linuskendall/cosmonaut/internal/editor" "github.com/linuskendall/cosmonaut/internal/history" + "github.com/linuskendall/cosmonaut/internal/provider" "github.com/linuskendall/cosmonaut/internal/sshconfig" ) @@ -19,7 +18,7 @@ import ( // Args determine initial state: // - no args: show the window with sidebar // - target name or owner/repo: open tree, expand that repo -// - "--codespace", csName, target: direct launch with progress +// - "--workspace", name, "--provider", provider, target: direct launch func (d *Daemon) showGUI(args ...string) { if d.app == nil { log.Println("gui: app not initialized") @@ -27,12 +26,16 @@ func (d *Daemon) showGUI(args ...string) { } // Parse args. - var targetArg, codespaceName string + var targetArg, workspaceName, providerName string for i := 0; i < len(args); i++ { - if args[i] == "--codespace" && i+1 < len(args) { - codespaceName = args[i+1] + switch { + case args[i] == "--workspace" && i+1 < len(args): + workspaceName = args[i+1] i++ - } else { + case args[i] == "--provider" && i+1 < len(args): + providerName = args[i+1] + i++ + default: targetArg = args[i] } } @@ -40,16 +43,25 @@ func (d *Daemon) showGUI(args ...string) { fyne.Do(func() { uw := d.newCosmoWindow() - if codespaceName != "" && targetArg != "" { - // Direct codespace launch: show progress immediately. + if workspaceName != "" && providerName != "" { target, resolvedName := d.resolveGUITarget(targetArg) - cs := &codespace.Codespace{Name: codespaceName, Repository: codespace.RepoField(target.Repository)} + manager, err := d.managerForProvider(providerName) + if err != nil { + showFlowError(uw.win, err) + return + } + ws, err := manager.ResolveWorkspace(workspaceName) + if err != nil { + showFlowError(uw.win, err) + return + } uw.win.Show() - d.runLaunchFlow(uw.win, target, resolvedName, cs) + d.runLaunchFlow(uw.win, target, resolvedName, ws) } else if targetArg != "" { - // Open with a specific repo expanded. target, _ := d.resolveGUITarget(targetArg) - uw.tree.OpenBranch(repoNodeID(target.Repository)) + if target.Repository != "" { + uw.tree.OpenBranch(repoNodeID(target.Repository)) + } uw.win.Show() } else { uw.win.Show() @@ -102,29 +114,37 @@ func showFlowError(win fyne.Window, err error) { }) } -// runCreateAndLaunch creates a codespace and then launches it. +// runCreateAndLaunch creates a workspace and then launches it. func (d *Daemon) runCreateAndLaunch(win fyne.Window, target config.Target, resolvedName string) { - progress := newProgressScreen("Creating codespace...") + manager, err := d.managerForTarget(target) + if err != nil { + showFlowError(win, err) + return + } + progress := newProgressScreen("Creating workspace...") win.SetContent(progress.canvas) go func() { - cs, err := codespace.CreateCodespace(d.Runner, target) + ws, err := manager.CreateWorkspace(target, false) if err != nil { progress.stop() - showFlowError(win, fmt.Errorf("creating codespace: %w", err)) + showFlowError(win, fmt.Errorf("creating workspace: %w", err)) return } - // runLaunchFlow installs its own progress screen; stop ours so the - // first animation goroutine doesn't leak. progress.stop() - d.runLaunchFlow(win, target, resolvedName, cs) + d.runLaunchFlow(win, target, resolvedName, ws) }() } // runLaunchFlow runs the SSH setup and editor launch sequence. -func (d *Daemon) runLaunchFlow(win fyne.Window, target config.Target, resolvedName string, selected *codespace.Codespace) { +func (d *Daemon) runLaunchFlow(win fyne.Window, target config.Target, resolvedName string, selected *provider.Workspace) { + manager, err := d.managerForTarget(target) + if err != nil { + showFlowError(win, err) + return + } ed := d.getEditor() - progress := newProgressScreen("Preparing codespace...") + progress := newProgressScreen("Preparing workspace...") fyne.Do(func() { win.SetContent(progress.canvas) }) go func() { @@ -135,15 +155,17 @@ func (d *Daemon) runLaunchFlow(win fyne.Window, target config.Target, resolvedNa // Record in history. hist := history.Load() - hist.Touch(target.Repository) - hist.Save() + if target.Repository != "" { + hist.Touch(target.Repository) + hist.Save() + } - // Fast path: if already Available with existing SSH config. - if selected.State == "Available" { + workspacePath := guessWorkspacePath(target, selected) + if isWorkspaceRunning(*selected) { paths := sshconfig.ResolvePaths() - if alias, ok := sshconfig.ReadExistingAlias(paths.IncludeDir, selected.Name); ok { + if alias, ok := sshconfig.ReadExistingWorkspaceAlias(paths, selected.Provider, selected.Name); ok { setStatus(fmt.Sprintf("Launching %s...", ed.Name())) - if err := ed.LaunchRemote(alias, target.WorkspacePath); err != nil { + if err := ed.LaunchRemote(alias, workspacePath); err != nil { showFlowError(win, err) return } @@ -155,54 +177,37 @@ func (d *Daemon) runLaunchFlow(win fyne.Window, target config.Target, resolvedNa } } - // Ensure SSH connectivity. - setStatus("Waiting for codespace SSH...") - if err := codespace.EnsureReachable(d.Runner, selected.Name); err != nil { - showFlowError(win, fmt.Errorf("SSH connectivity: %w", err)) - return - } - - // Get SSH config. - setStatus("Fetching SSH config...") - sshCfg, err := codespace.GetSSHConfig(d.Runner, selected.Name) + latest, err := manager.StartWorkspace(selected) if err != nil { showFlowError(win, err) return } + selected = latest - sshAlias, err := sshconfig.ParsePrimaryHostAlias(sshCfg) - if err != nil { - showFlowError(win, err) + setStatus("Waiting for workspace SSH...") + if err := manager.EnsureReachable(selected); err != nil { + showFlowError(win, fmt.Errorf("SSH connectivity: %w", err)) return } - // Write SSH config. paths := sshconfig.ResolvePaths() - if err := os.MkdirAll(paths.IncludeDir, 0700); err != nil { - showFlowError(win, err) - return - } - if err := sshconfig.EnsureConfigIncludesGenerated(paths.MainConfigPath); err != nil { - showFlowError(win, err) - return - } - if err := sshconfig.WriteCodespaceConfig(paths.IncludeDir, selected.Name, sshCfg); err != nil { + setStatus("Preparing SSH config...") + sshAlias, err := manager.PrepareSSH(paths, selected) + if err != nil { showFlowError(win, err) return } - // Configure editor-specific settings. nickname := editor.ResolveNickname( target.ZedNickname, target.DisplayName, selected.DisplayName, resolvedName, ) - if err := ed.ConfigureConnection(sshAlias, target.WorkspacePath, nickname, target.UploadBinaryOverSSH); err != nil { + if err := ed.ConfigureConnection(sshAlias, workspacePath, nickname, target.UploadBinaryOverSSH); err != nil { showFlowError(win, err) return } - // Launch editor. setStatus(fmt.Sprintf("Launching %s...", ed.Name())) - if err := ed.LaunchRemote(sshAlias, target.WorkspacePath); err != nil { + if err := ed.LaunchRemote(sshAlias, workspacePath); err != nil { showFlowError(win, err) return } @@ -214,3 +219,21 @@ func (d *Daemon) runLaunchFlow(win fyne.Window, target config.Target, resolvedNa d.rebuildTrayMenu() }() } + +func (d *Daemon) managerForTarget(target config.Target) (provider.Manager, error) { + if target.Coder != nil { + return provider.NewCoderManager(d.Cfg), nil + } + return provider.NewGitHubManager(d.Runner), nil +} + +func (d *Daemon) managerForProvider(providerName string) (provider.Manager, error) { + switch providerName { + case provider.NameGitHub: + return provider.NewGitHubManager(d.Runner), nil + case provider.NameCoder: + return provider.NewCoderManager(d.Cfg), nil + default: + return nil, fmt.Errorf("unknown provider %q", providerName) + } +} diff --git a/internal/daemon/gui_window.go b/internal/daemon/gui_window.go index ce2aa0e..8fadeec 100644 --- a/internal/daemon/gui_window.go +++ b/internal/daemon/gui_window.go @@ -10,9 +10,9 @@ import ( "fyne.io/fyne/v2/layout" "fyne.io/fyne/v2/widget" - "github.com/linuskendall/cosmonaut/internal/codespace" "github.com/linuskendall/cosmonaut/internal/config" "github.com/linuskendall/cosmonaut/internal/history" + "github.com/linuskendall/cosmonaut/internal/provider" ) const ( @@ -29,10 +29,11 @@ type unifiedWindow struct { tree *widget.Tree // Data for the tree. - allRepos []string - recentCount int - filter string - filtered []string // repos matching current filter + allRepos []string + recentCount int + filter string + filtered []string // repos matching current filter + coderTargets []string } func (d *Daemon) newUnifiedWindow() *unifiedWindow { @@ -51,7 +52,7 @@ func (d *Daemon) newUnifiedWindow() *unifiedWindow { // Fetch all user repos in background. go func() { - allUserRepos, err := codespace.ListAllRepos(d.Runner) + allUserRepos, err := provider.NewGitHubManager(d.Runner).ListRepositories() if err != nil { log.Printf("gui: fetch repos: %v", err) return @@ -92,12 +93,13 @@ func (d *Daemon) newUnifiedWindow() *unifiedWindow { } func (uw *unifiedWindow) loadRepos() { - repos := codespace.UniqueRepos(uw.daemon.Codespaces()) + repos := provider.UniqueRepos(filterWorkspacesByProvider(uw.daemon.Workspaces(), provider.NameGitHub)) repos = mergeRepos(repos, configRepos(uw.daemon.Cfg)) hist := history.Load() sorted := hist.SortRepos(repos) uw.recentCount = countRecentRepos(sorted, hist) uw.allRepos = sorted + uw.coderTargets = configuredCoderTargets(uw.daemon.Cfg) uw.applyFilter() } @@ -122,69 +124,95 @@ func (uw *unifiedWindow) setContent(obj fyne.CanvasObject) { } // --- Tree node ID scheme --- -// "repo:": branch node for a repo -// "cs::": leaf node for a codespace -// "new:": leaf node for "create new" +// "section:": branch node for a provider section +// "repo:": branch node for a GitHub repo +// "ws::": leaf node for a workspace +// "new::": leaf node for "create new" const ( - repoPrefix = "repo:" - csPrefix = "cs:" - newPrefix = "new:" + sectionPrefix = "section:" + repoPrefix = "repo:" + wsPrefix = "ws:" + newPrefix = "new:" ) -func repoNodeID(repo string) widget.TreeNodeID { return repoPrefix + repo } -func csNodeID(cs, repo string) widget.TreeNodeID { return csPrefix + cs + ":" + repo } -func newNodeID(repo string) widget.TreeNodeID { return newPrefix + repo } -func isRepoNode(id widget.TreeNodeID) bool { return strings.HasPrefix(id, repoPrefix) } -func isCsNode(id widget.TreeNodeID) bool { return strings.HasPrefix(id, csPrefix) } -func isNewNode(id widget.TreeNodeID) bool { return strings.HasPrefix(id, newPrefix) } -func repoFromNode(id widget.TreeNodeID) string { return strings.TrimPrefix(id, repoPrefix) } - -func csNameFromNode(id widget.TreeNodeID) string { - s := strings.TrimPrefix(id, csPrefix) - if i := strings.LastIndex(s, ":"); i >= 0 { - return s[:i] +func sectionNodeID(providerName string) widget.TreeNodeID { return sectionPrefix + providerName } +func repoNodeID(repo string) widget.TreeNodeID { return repoPrefix + repo } +func workspaceNodeID(providerName, name string) widget.TreeNodeID { + return wsPrefix + providerName + ":" + name +} +func newNodeID(providerName, context string) widget.TreeNodeID { + return newPrefix + providerName + ":" + context +} +func isSectionNode(id widget.TreeNodeID) bool { return strings.HasPrefix(id, sectionPrefix) } +func isRepoNode(id widget.TreeNodeID) bool { return strings.HasPrefix(id, repoPrefix) } +func isWorkspaceNode(id widget.TreeNodeID) bool { return strings.HasPrefix(id, wsPrefix) } +func isNewNode(id widget.TreeNodeID) bool { return strings.HasPrefix(id, newPrefix) } +func sectionFromNode(id widget.TreeNodeID) string { return strings.TrimPrefix(id, sectionPrefix) } +func repoFromNode(id widget.TreeNodeID) string { return strings.TrimPrefix(id, repoPrefix) } + +func providerAndNameFromWorkspaceNode(id widget.TreeNodeID) (string, string) { + s := strings.TrimPrefix(id, wsPrefix) + parts := strings.SplitN(s, ":", 2) + if len(parts) != 2 { + return "", s } - return s + return parts[0], parts[1] } -func repoFromCsNode(id widget.TreeNodeID) string { - s := strings.TrimPrefix(id, csPrefix) - if i := strings.LastIndex(s, ":"); i >= 0 { - return s[i+1:] +func providerAndContextFromNewNode(id widget.TreeNodeID) (string, string) { + s := strings.TrimPrefix(id, newPrefix) + parts := strings.SplitN(s, ":", 2) + if len(parts) != 2 { + return "", s } - return "" + return parts[0], parts[1] } -func repoFromNewNode(id widget.TreeNodeID) string { return strings.TrimPrefix(id, newPrefix) } - func (uw *unifiedWindow) buildTree() *widget.Tree { t := widget.NewTree( // childUIDs func(id widget.TreeNodeID) []widget.TreeNodeID { if id == "" { - ids := make([]widget.TreeNodeID, len(uw.filtered)) - for i, repo := range uw.filtered { - ids[i] = repoNodeID(repo) + return []widget.TreeNodeID{sectionNodeID(provider.NameGitHub), sectionNodeID(provider.NameCoder)} + } + if isSectionNode(id) { + switch sectionFromNode(id) { + case provider.NameGitHub: + ids := make([]widget.TreeNodeID, len(uw.filtered)) + for i, repo := range uw.filtered { + ids[i] = repoNodeID(repo) + } + return ids + case provider.NameCoder: + workspaces := filterWorkspacesByProvider(uw.daemon.Workspaces(), provider.NameCoder) + ids := make([]widget.TreeNodeID, 0, len(workspaces)+1) + for _, ws := range workspaces { + if uw.filter != "" && !workspaceMatchesFilter(ws, uw.filter) { + continue + } + ids = append(ids, workspaceNodeID(ws.Provider, ws.Name)) + } + ids = append(ids, newNodeID(provider.NameCoder, "")) + return ids } - return ids } if isRepoNode(id) { repo := repoFromNode(id) - all := uw.daemon.Codespaces() - repoCS := codespace.FilterByRepo(all, repo) - ids := make([]widget.TreeNodeID, 0, len(repoCS)+1) - for _, cs := range repoCS { - ids = append(ids, csNodeID(cs.Name, repo)) + all := filterWorkspacesByProvider(uw.daemon.Workspaces(), provider.NameGitHub) + repoWS := provider.FilterByRepo(all, repo) + ids := make([]widget.TreeNodeID, 0, len(repoWS)+1) + for _, ws := range repoWS { + ids = append(ids, workspaceNodeID(ws.Provider, ws.Name)) } - ids = append(ids, newNodeID(repo)) + ids = append(ids, newNodeID(provider.NameGitHub, repo)) return ids } return nil }, // isBranch func(id widget.TreeNodeID) bool { - return id == "" || isRepoNode(id) + return id == "" || isSectionNode(id) || isRepoNode(id) }, // create func(branch bool) fyne.CanvasObject { @@ -193,25 +221,38 @@ func (uw *unifiedWindow) buildTree() *widget.Tree { // update func(id widget.TreeNodeID, branch bool, obj fyne.CanvasObject) { label := obj.(*widget.Label) - if isRepoNode(id) { + switch { + case isSectionNode(id): + switch sectionFromNode(id) { + case provider.NameGitHub: + label.SetText("GitHub Codespaces") + case provider.NameCoder: + label.SetText("Coder Workspaces") + } + case isRepoNode(id): repo := repoFromNode(id) - count := len(codespace.FilterByRepo(uw.daemon.Codespaces(), repo)) + count := len(provider.FilterByRepo(filterWorkspacesByProvider(uw.daemon.Workspaces(), provider.NameGitHub), repo)) if count > 0 { label.SetText(fmt.Sprintf("%s (%d)", repo, count)) } else { label.SetText(repo) } - } else if isCsNode(id) { - csName := csNameFromNode(id) - for _, cs := range uw.daemon.Codespaces() { - if cs.Name == csName { - label.SetText(fmt.Sprintf(" %s %s", stateIcon(cs.State), csLabel(cs))) + case isWorkspaceNode(id): + providerName, name := providerAndNameFromWorkspaceNode(id) + for _, ws := range uw.daemon.Workspaces() { + if ws.Provider == providerName && ws.Name == name { + label.SetText(fmt.Sprintf(" %s %s", stateIcon(ws.State), workspaceLabel(ws))) return } } - label.SetText(" " + csName) - } else if isNewNode(id) { - label.SetText(" + Create new") + label.SetText(" " + name) + case isNewNode(id): + providerName, _ := providerAndContextFromNewNode(id) + if providerName == provider.NameCoder { + label.SetText(" + Create new Coder workspace") + } else { + label.SetText(" + Create new") + } } }, ) @@ -220,13 +261,18 @@ func (uw *unifiedWindow) buildTree() *widget.Tree { if isRepoNode(id) { repo := repoFromNode(id) uw.showRepoSummary(repo) - } else if isCsNode(id) { - csName := csNameFromNode(id) - repo := repoFromCsNode(id) - uw.showCodespaceDetail(csName, repo) + } else if isWorkspaceNode(id) { + providerName, name := providerAndNameFromWorkspaceNode(id) + uw.showWorkspaceDetail(providerName, name) } else if isNewNode(id) { - repo := repoFromNewNode(id) - uw.showCreateNew(repo) + providerName, context := providerAndContextFromNewNode(id) + uw.showCreateNewForProvider(providerName, context) + } else if isSectionNode(id) { + if sectionFromNode(id) == provider.NameCoder { + uw.showCoderSummary() + } else { + uw.showWelcome() + } } } @@ -236,22 +282,22 @@ func (uw *unifiedWindow) buildTree() *widget.Tree { // --- Content panel builders --- func (uw *unifiedWindow) showWelcome() { - msg := widget.NewLabel("Select a repository or codespace to get started.") + msg := widget.NewLabel("Select a repository or workspace to get started.") msg.Alignment = fyne.TextAlignCenter uw.setContent(container.NewCenter(msg)) } func (uw *unifiedWindow) showRepoSummary(repo string) { - all := uw.daemon.Codespaces() - repoCS := codespace.FilterByRepo(all, repo) + all := filterWorkspacesByProvider(uw.daemon.Workspaces(), provider.NameGitHub) + repoWS := provider.FilterByRepo(all, repo) title := widget.NewLabel(repo) title.TextStyle = fyne.TextStyle{Bold: true} - info := widget.NewLabel(fmt.Sprintf("%d codespace(s)", len(repoCS))) + info := widget.NewLabel(fmt.Sprintf("%d workspace(s)", len(repoWS))) - createBtn := widget.NewButton("Create new codespace", func() { - uw.showCreateNew(repo) + createBtn := widget.NewButton("Create new GitHub codespace", func() { + uw.showCreateNewForProvider(provider.NameGitHub, repo) }) uw.setContent(container.NewVBox( @@ -261,66 +307,43 @@ func (uw *unifiedWindow) showRepoSummary(repo string) { )) } -func (uw *unifiedWindow) showCodespaceDetail(csName, repo string) { - var cs *codespace.Codespace - for _, c := range uw.daemon.Codespaces() { - if c.Name == csName { - cs = &c - break +func (uw *unifiedWindow) showWorkspaceDetail(providerName, name string) { + for _, ws := range uw.daemon.Workspaces() { + if ws.Provider == providerName && ws.Name == name { + if providerName == provider.NameGitHub { + uw.showCosmoCodespaceDetail(name, ws.Repository) + return + } + uw.showCoderWorkspaceDetail(ws) + return } } - if cs == nil { - uw.showWelcome() - return - } + uw.showWelcome() +} - title := widget.NewLabel(csLabel(*cs)) +func (uw *unifiedWindow) showCoderSummary() { + all := filterWorkspacesByProvider(uw.daemon.Workspaces(), provider.NameCoder) + title := widget.NewLabel("Coder Workspaces") title.TextStyle = fyne.TextStyle{Bold: true} - - state := widget.NewLabel(fmt.Sprintf("State: %s %s", stateIcon(cs.State), cs.State)) - - branch := "" - if cs.GitStatus != nil { - ref := cs.GitStatus.Ref - if ref == "" { - ref = cs.GitStatus.Branch - } - branch = ref - } - branchLabel := widget.NewLabel(fmt.Sprintf("Branch: %s", branch)) - - target, resolvedName := guiTargetForRepo(uw.daemon.Cfg, repo) - matches := codespace.FindMatching([]codespace.Codespace{*cs}, &target) - matchLabel := widget.NewLabel("") - if len(matches) > 0 { - matchLabel.SetText("Matches config target") - } - - openBtn := widget.NewButton("Open", func() { - uw.daemon.runLaunchFlow(uw.win, target, resolvedName, cs) + info := widget.NewLabel(fmt.Sprintf("%d workspace(s)", len(all))) + createBtn := widget.NewButton("Create new Coder workspace", func() { + uw.showCreateNewForProvider(provider.NameCoder, "") }) - - deleteBtn := widget.NewButton("Delete", func() { - go func() { - _ = codespace.DeleteCodespace(uw.daemon.Runner, cs.Name) - fyne.Do(func() { - uw.tree.Refresh() - uw.showWelcome() - }) - }() - }) - uw.setContent(container.NewVBox( layout.NewSpacer(), - container.NewCenter(container.NewVBox( - title, state, branchLabel, matchLabel, - widget.NewSeparator(), - container.NewHBox(openBtn, deleteBtn), - )), + container.NewCenter(container.NewVBox(title, info, createBtn)), layout.NewSpacer(), )) } +func (uw *unifiedWindow) showCreateNewForProvider(providerName, context string) { + if providerName == provider.NameCoder { + uw.showCosmoCreateNewCoder() + return + } + uw.showCreateNew(context) +} + func (uw *unifiedWindow) showCreateNew(repo string) { target, resolvedName := guiTargetForRepo(uw.daemon.Cfg, repo) @@ -410,6 +433,140 @@ func guiTargetForRepo(cfg *config.Config, repo string) (config.Target, string) { }, repo } +func guiTargetForCoderWorkspace(cfg *config.Config, ws provider.Workspace) (config.Target, string) { + if cfg != nil { + for name, t := range cfg.Targets { + if t.Coder != nil && t.Coder.WorkspaceName == ws.Name { + return applyWorkspaceDefaults(t, ws), name + } + } + for name, t := range cfg.Targets { + if t.Coder != nil { + t = applyWorkspaceDefaults(t, ws) + if t.Coder.WorkspaceName == "" { + t.Coder.WorkspaceName = ws.Name + } + return t, name + } + } + } + return config.Target{ + WorkspacePath: "/workspaces/" + ws.Name, + Coder: &config.CoderTargetConfig{ + WorkspaceName: ws.Name, + }, + }, ws.Name +} + +func applyWorkspaceDefaults(target config.Target, ws provider.Workspace) config.Target { + if target.Repository == "" && ws.Repository != "" { + target.Repository = ws.Repository + } + if target.WorkspacePath == "" { + target.WorkspacePath = guessWorkspacePath(target, &ws) + } + return target +} + +func guessWorkspacePath(target config.Target, ws *provider.Workspace) string { + if target.WorkspacePath != "" { + return target.WorkspacePath + } + if ws != nil && ws.Provider == provider.NameCoder { + return "/workspaces/" + ws.Name + } + if target.Repository != "" { + parts := strings.SplitN(target.Repository, "/", 2) + return "/workspaces/" + parts[len(parts)-1] + } + if ws != nil && ws.Name != "" { + return "/workspaces/" + ws.Name + } + return "/workspaces" +} + +func isWorkspaceRunning(ws provider.Workspace) bool { + state := strings.ToLower(ws.State) + return state == "available" || state == "ready" || state == "running" || state == "connected" +} + +func filterWorkspacesByProvider(workspaces []provider.Workspace, providerName string) []provider.Workspace { + var result []provider.Workspace + for _, ws := range workspaces { + if ws.Provider == providerName { + result = append(result, ws) + } + } + return result +} + +func workspaceMatchesFilter(ws provider.Workspace, filter string) bool { + filter = strings.ToLower(strings.TrimSpace(filter)) + if filter == "" { + return true + } + fields := []string{ws.Name, ws.DisplayName, ws.Repository, ws.Branch, ws.Template} + for _, field := range fields { + if strings.Contains(strings.ToLower(field), filter) { + return true + } + } + return false +} + +func workspaceLabel(ws provider.Workspace) string { + name := ws.DisplayName + if name == "" { + name = ws.Name + } + if ws.Provider == provider.NameCoder && ws.Template != "" { + return fmt.Sprintf("%s (%s)", name, ws.Template) + } + if ws.Branch != "" { + return fmt.Sprintf("%s (%s)", name, ws.Branch) + } + return name +} + +func configuredCoderTargets(cfg *config.Config) []string { + if cfg == nil { + return nil + } + var names []string + for name, target := range cfg.Targets { + if target.Coder != nil && target.Coder.Template != "" { + names = append(names, name) + } + } + return names +} + +func coderWorkspaceNameFromInput(raw string) string { + raw = strings.ToLower(strings.TrimSpace(raw)) + if raw == "" { + return "" + } + var b strings.Builder + lastDash := false + for _, r := range raw { + switch { + case r >= 'a' && r <= 'z', r >= '0' && r <= '9': + b.WriteRune(r) + lastDash = false + case r == '-' || r == '_' || r == ' ' || r == '/': + if !lastDash && b.Len() > 0 { + b.WriteByte('-') + lastDash = true + } + } + } + name := strings.Trim(b.String(), "-") + if len(name) > 63 { + name = strings.Trim(name[:63], "-") + } + return name +} + func countRecentRepos(sorted []string, hist *history.History) int { n := 0 for _, repo := range sorted { diff --git a/internal/daemon/gui_window_cosmo.go b/internal/daemon/gui_window_cosmo.go index af9f15f..e6cb628 100644 --- a/internal/daemon/gui_window_cosmo.go +++ b/internal/daemon/gui_window_cosmo.go @@ -34,7 +34,9 @@ import ( "image/color" "github.com/linuskendall/cosmonaut/internal/codespace" + "github.com/linuskendall/cosmonaut/internal/config" "github.com/linuskendall/cosmonaut/internal/doctor" + "github.com/linuskendall/cosmonaut/internal/provider" ) const ( @@ -66,7 +68,7 @@ func (d *Daemon) newCosmoWindow() *unifiedWindow { // Background fetch of all user repos. go func() { - allUserRepos, err := codespace.ListAllRepos(d.Runner) + allUserRepos, err := provider.NewGitHubManager(d.Runner).ListRepositories() if err != nil { log.Printf("gui: fetch repos: %v", err) return @@ -173,7 +175,7 @@ func (uw *unifiedWindow) fixButton(c doctor.Check) *widget.Button { } // buildCosmoSidebar constructs the left pane with title row, search, -// repo tree, and account footer. Separator canvases give crisp 1px lines +// workspace tree, and account footer. Separator canvases give crisp 1px lines // that respect the theme's border color. func (uw *unifiedWindow) buildCosmoSidebar() fyne.CanvasObject { // Title row: mark + name + "+" action @@ -217,7 +219,7 @@ func (uw *unifiedWindow) buildCosmoSidebar() fyne.CanvasObject { // Search filterEntry := widget.NewEntry() - filterEntry.PlaceHolder = "Filter repositories…" + filterEntry.PlaceHolder = "Filter workspaces…" filterEntry.OnChanged = func(text string) { uw.filter = text uw.applyFilter() @@ -229,18 +231,40 @@ func (uw *unifiedWindow) buildCosmoSidebar() fyne.CanvasObject { uw.tree.OnSelected = func(id widget.TreeNodeID) { if isRepoNode(id) { repo := repoFromNode(id) - // Auto-expand repos that have codespaces. - if len(codespace.FilterByRepo(uw.daemon.Codespaces(), repo)) > 0 { + if len(provider.FilterByRepo(filterWorkspacesByProvider(uw.daemon.Workspaces(), provider.NameGitHub), repo)) > 0 { uw.tree.OpenBranch(id) } uw.showCosmoRepoSummary(repo) - } else if isCsNode(id) { - csName := csNameFromNode(id) - repo := repoFromCsNode(id) - uw.showCosmoCodespaceDetail(csName, repo) + } else if isWorkspaceNode(id) { + providerName, name := providerAndNameFromWorkspaceNode(id) + if providerName == provider.NameGitHub { + for _, ws := range uw.daemon.Workspaces() { + if ws.Provider == providerName && ws.Name == name { + uw.showCosmoCodespaceDetail(name, ws.Repository) + return + } + } + } else { + for _, ws := range uw.daemon.Workspaces() { + if ws.Provider == providerName && ws.Name == name { + uw.showCoderWorkspaceDetail(ws) + return + } + } + } } else if isNewNode(id) { - repo := repoFromNewNode(id) - uw.showCosmoCreateNew(repo) + providerName, context := providerAndContextFromNewNode(id) + if providerName == provider.NameCoder { + uw.showCosmoCreateNewCoder() + } else { + uw.showCosmoCreateNew(context) + } + } else if isSectionNode(id) { + if sectionFromNode(id) == provider.NameCoder { + uw.showCoderSummary() + } else { + uw.showCosmoWelcome() + } } } @@ -378,7 +402,23 @@ func (uw *unifiedWindow) showCosmoCodespaceDetail(csName, repo string) { openBtn := primaryButton("Open", func() { origEditor := uw.daemon.Cfg.Editor uw.daemon.Cfg.Editor = selectedEditor - uw.daemon.runLaunchFlow(uw.win, target, resolvedName, cs) + workspace := provider.Workspace{ + Provider: provider.NameGitHub, + Name: cs.Name, + DisplayName: cs.DisplayName, + Repository: repo, + State: cs.State, + MachineName: cs.MachineName, + CreatedAt: cs.CreatedAt, + LastUsedAt: cs.LastUsedAt, + } + if cs.GitStatus != nil { + workspace.Branch = cs.GitStatus.Ref + if workspace.Branch == "" { + workspace.Branch = cs.GitStatus.Branch + } + } + uw.daemon.runLaunchFlow(uw.win, target, resolvedName, &workspace) uw.daemon.Cfg.Editor = origEditor }) @@ -525,9 +565,9 @@ func (uw *unifiedWindow) portRow(csName, repo string, port codespace.Port) fyne. func stateColor(state string) color.Color { switch state { - case "Available": + case "Available", "Started", "ready", "running", "connected": return cLime - case "Starting": + case "Starting", "starting", "pending": return cOrange case "Error": return cRed @@ -547,7 +587,7 @@ func (uw *unifiedWindow) showCosmoWelcome() { h.TextStyle = fyne.TextStyle{Bold: true} h.Alignment = fyne.TextAlignCenter - sub := canvas.NewText("Select a repository or codespace to get started.", cTextMute) + sub := canvas.NewText("Select a GitHub repo or Coder workspace to get started.", cTextMute) sub.TextSize = 12 sub.Alignment = fyne.TextAlignCenter @@ -560,18 +600,18 @@ func (uw *unifiedWindow) showCosmoWelcome() { // ── REPO SUMMARY ─────────────────────────────────────────────────────── func (uw *unifiedWindow) showCosmoRepoSummary(repo string) { - all := uw.daemon.Codespaces() - repoCS := codespace.FilterByRepo(all, repo) + all := filterWorkspacesByProvider(uw.daemon.Workspaces(), provider.NameGitHub) + repoCS := provider.FilterByRepo(all, repo) title := canvas.NewText(repo, cText) title.TextSize = 18 title.TextStyle = fyne.TextStyle{Bold: true} - countText := fmt.Sprintf("%d codespace(s)", len(repoCS)) + countText := fmt.Sprintf("%d workspace(s)", len(repoCS)) info := canvas.NewText(countText, cTextDim) info.TextSize = 13 - createBtn := primaryButton("Create new codespace", func() { + createBtn := primaryButton("Create new GitHub codespace", func() { uw.showCosmoCreateNew(repo) }) @@ -585,7 +625,31 @@ func (uw *unifiedWindow) showCosmoRepoSummary(repo string) { // ── CREATE ────────────────────────────────────────────────────────────── func (uw *unifiedWindow) showCreateNewGeneric() { - uw.showCosmoCreateNew("") + title := canvas.NewText("Create a new workspace", cText) + title.TextSize = 18 + title.TextStyle = fyne.TextStyle{Bold: true} + + copy := widget.NewLabel("Choose a provider. GitHub creation is repo-based; Coder creation uses configured Coder targets.") + copy.Wrapping = fyne.TextWrapWord + + githubBtn := primaryButton("GitHub Codespace", func() { + uw.showCosmoWelcome() + }) + coderBtn := primaryButton("Coder Workspace", func() { + uw.showCosmoCreateNewCoder() + }) + + hint := widget.NewLabel("For GitHub, select a repository in the left pane and use its create action.") + hint.Wrapping = fyne.TextWrapWord + + body := container.NewPadded(container.NewVBox( + title, + widget.NewSeparator(), + copy, + container.NewHBox(githubBtn, coderBtn), + hint, + )) + uw.setContent(container.NewCenter(body)) } func (uw *unifiedWindow) showCosmoCreateNew(repo string) { @@ -661,6 +725,214 @@ func (uw *unifiedWindow) showCosmoCreateNew(repo string) { uw.setContent(container.NewScroll(body)) } +func (uw *unifiedWindow) showCoderWorkspaceDetail(ws provider.Workspace) { + target, resolvedName := guiTargetForCoderWorkspace(uw.daemon.Cfg, ws) + + stateLbl := canvas.NewText(strings.ToUpper(ws.State), stateColor(ws.State)) + stateLbl.TextSize = 10 + stateLbl.TextStyle = fyne.TextStyle{Monospace: true, Bold: true} + statusRow := container.NewHBox(stateDot(ws.State), stateLbl) + + title := ws.DisplayName + if title == "" { + title = ws.Name + } + heroTitle := canvas.NewText(title, cText) + heroTitle.TextSize = 16 + heroTitle.TextStyle = fyne.TextStyle{Bold: true} + + subtitle := canvas.NewText("coder", cTextMute) + subtitle.TextSize = 11 + subtitle.TextStyle = fyne.TextStyle{Monospace: true} + + selectedEditor := uw.daemon.getEditor().Name() + editorSel := widget.NewSelect([]string{"zed", "neovim"}, func(val string) { + selectedEditor = val + }) + editorSel.Selected = selectedEditor + + openBtn := primaryButton("Open", func() { + origEditor := uw.daemon.Cfg.Editor + uw.daemon.Cfg.Editor = selectedEditor + workspace := ws + uw.daemon.runLaunchFlow(uw.win, target, resolvedName, &workspace) + uw.daemon.Cfg.Editor = origEditor + }) + + nameVal := widget.NewLabel(ws.Name) + nameVal.TextStyle = fyne.TextStyle{Monospace: true} + stateVal := widget.NewLabel(ws.State) + templateVal := widget.NewLabel(ws.Template) + lastUsedVal := widget.NewLabel(formatTimeAgo(ws.LastUsedAt)) + sshHostVal := widget.NewLabel(fmt.Sprintf("%s.coder", ws.Name)) + sshHostVal.TextStyle = fyne.TextStyle{Monospace: true} + pathVal := widget.NewLabel(guessWorkspacePath(target, &ws)) + pathVal.TextStyle = fyne.TextStyle{Monospace: true} + + info := widget.NewForm( + widget.NewFormItem("Workspace", nameVal), + widget.NewFormItem("State", stateVal), + widget.NewFormItem("Template", templateVal), + widget.NewFormItem("Last used", lastUsedVal), + widget.NewFormItem("SSH host", sshHostVal), + widget.NewFormItem("Path", pathVal), + ) + + body := container.NewVBox( + statusRow, + heroTitle, + subtitle, + widget.NewSeparator(), + container.NewHBox(openBtn, editorSel), + widget.NewSeparator(), + info, + ) + uw.setContent(container.NewPadded(body)) +} + +func (uw *unifiedWindow) showCosmoCreateNewCoder() { + title := canvas.NewText("Create a new Coder workspace", cText) + title.TextSize = 18 + title.TextStyle = fyne.TextStyle{Bold: true} + + targetNames := configuredCoderTargets(uw.daemon.Cfg) + if len(targetNames) == 0 { + uw.showCosmoCreateNewCoderFromTemplates(title) + return + } + + targetSel := widget.NewSelect(targetNames, func(string) {}) + targetSel.SetSelected(targetNames[0]) + baseTarget := uw.daemon.Cfg.Targets[targetNames[0]] + + nameEntry := widget.NewEntry() + nameEntry.PlaceHolder = "e.g. my-repo-review" + if baseTarget.Coder != nil && baseTarget.Coder.WorkspaceName != "" { + nameEntry.SetText(baseTarget.Coder.WorkspaceName) + } + + pathEntry := widget.NewEntry() + pathEntry.PlaceHolder = "/workspaces/my-repo" + pathEntry.SetText(guessWorkspacePath(baseTarget, nil)) + + targetSel.OnChanged = func(name string) { + t := uw.daemon.Cfg.Targets[name] + if t.Coder != nil && t.Coder.WorkspaceName != "" { + nameEntry.SetText(t.Coder.WorkspaceName) + } + pathEntry.SetText(guessWorkspacePath(t, nil)) + } + + form := widget.NewForm( + widget.NewFormItem("Target", targetSel), + widget.NewFormItem("Workspace name", nameEntry), + widget.NewFormItem("Workspace path", pathEntry), + ) + + hint := widget.NewLabel("") + hint.Wrapping = fyne.TextWrapWord + + createBtn := primaryButton("Create and open", func() { + targetName := targetSel.Selected + target := uw.daemon.Cfg.Targets[targetName] + if target.Coder == nil { + hint.SetText("Selected target is missing coder settings.") + return + } + name := coderWorkspaceNameFromInput(nameEntry.Text) + if name == "" { + hint.SetText("Enter a workspace name.") + return + } + target.WorkspacePath = strings.TrimSpace(pathEntry.Text) + if target.WorkspacePath == "" { + target.WorkspacePath = "/workspaces/" + name + } + target.Coder.WorkspaceName = name + uw.daemon.runCreateAndLaunch(uw.win, target, targetName) + }) + cancelBtn := widget.NewButton("Cancel", func() { uw.showCosmoWelcome() }) + + actions := container.NewHBox(layout.NewSpacer(), cancelBtn, createBtn) + body := container.NewPadded(container.NewVBox( + title, + widget.NewSeparator(), + form, + hint, + actions, + )) + uw.setContent(container.NewScroll(body)) +} + +func (uw *unifiedWindow) showCosmoCreateNewCoderFromTemplates(title *canvas.Text) { + manager := provider.NewCoderManager(uw.daemon.Cfg) + templates, err := manager.ListTemplates() + if err != nil { + msg := widget.NewLabel(fmt.Sprintf("Could not load Coder templates automatically: %v", err)) + msg.Wrapping = fyne.TextWrapWord + uw.setContent(container.NewPadded(container.NewVBox(title, widget.NewSeparator(), msg))) + return + } + if len(templates) == 0 { + msg := widget.NewLabel("No Coder templates were found.") + msg.Wrapping = fyne.TextWrapWord + uw.setContent(container.NewPadded(container.NewVBox(title, widget.NewSeparator(), msg))) + return + } + + templateNames := make([]string, 0, len(templates)) + for _, tpl := range templates { + templateNames = append(templateNames, tpl.Name) + } + + templateSel := widget.NewSelect(templateNames, func(string) {}) + templateSel.SetSelected(templateNames[0]) + + nameEntry := widget.NewEntry() + nameEntry.PlaceHolder = "e.g. my-repo-review" + + pathEntry := widget.NewEntry() + pathEntry.PlaceHolder = "/workspaces/my-workspace" + + form := widget.NewForm( + widget.NewFormItem("Template", templateSel), + widget.NewFormItem("Workspace name", nameEntry), + widget.NewFormItem("Workspace path", pathEntry), + ) + + hint := widget.NewLabel("Using live Coder templates because no Coder target is configured.") + hint.Wrapping = fyne.TextWrapWord + + createBtn := primaryButton("Create and open", func() { + name := coderWorkspaceNameFromInput(nameEntry.Text) + if name == "" { + hint.SetText("Enter a workspace name.") + return + } + target := config.Target{ + WorkspacePath: strings.TrimSpace(pathEntry.Text), + Coder: &config.CoderTargetConfig{ + Template: templateSel.Selected, + WorkspaceName: name, + }, + } + if target.WorkspacePath == "" { + target.WorkspacePath = "/workspaces/" + name + } + uw.daemon.runCreateAndLaunch(uw.win, target, name) + }) + cancelBtn := widget.NewButton("Cancel", func() { uw.showCosmoWelcome() }) + + body := container.NewPadded(container.NewVBox( + title, + widget.NewSeparator(), + form, + hint, + container.NewHBox(layout.NewSpacer(), cancelBtn, createBtn), + )) + uw.setContent(container.NewScroll(body)) +} + // fetchBranches returns branch names for a repo, default branch first. func fetchBranches(runner codespace.GHRunner, repo string) []string { // Get default branch. diff --git a/internal/daemon/poller.go b/internal/daemon/poller.go index 3be19e4..6e28aad 100644 --- a/internal/daemon/poller.go +++ b/internal/daemon/poller.go @@ -8,6 +8,7 @@ import ( "fyne.io/fyne/v2/driver/desktop" "github.com/linuskendall/cosmonaut/internal/codespace" + "github.com/linuskendall/cosmonaut/internal/provider" ) func (d *Daemon) startPoller() { @@ -35,33 +36,75 @@ func (d *Daemon) poll() { codespaces, err := codespace.ListAllCodespaces(d.Runner) if err != nil { log.Printf("poll: %v", err) - d.SetListErr(err) - return + if d.Cfg == nil || d.Cfg.EffectiveWorkspaceProvider() == provider.NameGitHub { + d.SetListErr(err) + } + codespaces = nil + } + var workspaces []provider.Workspace + if len(codespaces) > 0 { + for _, cs := range codespaces { + ws := provider.Workspace{ + Provider: provider.NameGitHub, + Name: cs.Name, + DisplayName: cs.DisplayName, + Repository: string(cs.Repository), + State: cs.State, + MachineName: cs.MachineName, + CreatedAt: cs.CreatedAt, + LastUsedAt: cs.LastUsedAt, + } + if cs.GitStatus != nil { + ws.Branch = cs.GitStatus.Ref + if ws.Branch == "" { + ws.Branch = cs.GitStatus.Branch + } + } + workspaces = append(workspaces, ws) + } + } + + coderManager := provider.NewCoderManager(d.Cfg) + coderWorkspaces, coderErr := coderManager.ListAllWorkspaces() + if coderErr != nil { + log.Printf("poll(coder): %v", coderErr) + if d.Cfg != nil && d.Cfg.EffectiveWorkspaceProvider() == provider.NameCoder { + d.SetListErr(coderErr) + } + } else { + workspaces = append(workspaces, coderWorkspaces...) + } + + if (d.Cfg == nil || d.Cfg.EffectiveWorkspaceProvider() == provider.NameGitHub) && err == nil { + d.SetListErr(nil) + } + if d.Cfg != nil && d.Cfg.EffectiveWorkspaceProvider() == provider.NameCoder && coderErr == nil { + d.SetListErr(nil) } - d.SetListErr(nil) - log.Printf("poll: fetched %d codespaces", len(codespaces)) + log.Printf("poll: fetched %d github codespaces and %d total workspaces", len(codespaces), len(workspaces)) old := d.Codespaces() d.SetCodespaces(codespaces) + d.SetWorkspaces(workspaces) if len(old) > 0 { d.detectStateChanges(old, codespaces) } d.checkAutoStop(codespaces) - d.updateTrayIcon(codespaces) + d.updateTrayIcon(workspaces) d.rebuildTrayMenu() } -// updateTrayIcon switches tray icon based on aggregate codespace state. -func (d *Daemon) updateTrayIcon(codespaces []codespace.Codespace) { +// updateTrayIcon switches tray icon based on aggregate workspace state. +func (d *Daemon) updateTrayIcon(workspaces []provider.Workspace) { hasAvailable := false hasStarting := false - for _, cs := range codespaces { - switch cs.State { - case "Available": + for _, ws := range workspaces { + switch ws.State { + case "Available", "ready", "running", "connected": hasAvailable = true - case "Starting": + case "Starting", "starting", "pending": hasStarting = true } } diff --git a/internal/daemon/tray.go b/internal/daemon/tray.go index d10ca68..8a58b70 100644 --- a/internal/daemon/tray.go +++ b/internal/daemon/tray.go @@ -10,6 +10,7 @@ import ( "github.com/linuskendall/cosmonaut/internal/codespace" "github.com/linuskendall/cosmonaut/internal/history" + "github.com/linuskendall/cosmonaut/internal/provider" ) const maxSubmenuCodespaces = 5 @@ -18,58 +19,19 @@ const maxSubmenuCodespaces = 5 // and cached codespace state. func (d *Daemon) buildTrayMenu() *fyne.Menu { var items []*fyne.MenuItem - seen := make(map[string]bool) - - // ── Spaces heading ── - heading := fyne.NewMenuItem("Spaces", nil) - heading.Disabled = true - items = append(items, heading) - - // Default target. - if d.Cfg != nil && d.Cfg.DefaultTarget != "" { - if t, ok := d.Cfg.Targets[d.Cfg.DefaultTarget]; ok { - name := d.Cfg.DefaultTarget - item := fyne.NewMenuItem("Open "+name, func() { - go d.showGUI(name) - }) - if sub := d.codespaceSubmenu(t.Repository, name); sub != nil { - item.ChildMenu = sub - } - items = append(items, item) - seen[t.Repository] = true - } + + if githubItem := d.githubCodespacesMenu(); githubItem != nil { + items = append(items, githubItem) + } + if coderItem := d.coderWorkspaceMenu(); coderItem != nil { + items = append(items, coderItem) } - // Recent targets from history (de-duplicated against default). + // Open previous / launch. hist := history.Load() - if len(hist.Entries) > 0 { + if len(items) > 0 { items = append(items, fyne.NewMenuItemSeparator()) - limit := min(5, len(hist.Entries)) - for i := len(hist.Entries) - 1; i >= len(hist.Entries)-limit; i-- { - entry := hist.Entries[i] - if seen[entry.Repository] { - continue - } - seen[entry.Repository] = true - - targetName := d.targetNameForRepo(entry.Repository) - label := entry.Repository - args := targetName - if args == "" { - args = entry.Repository - } - item := fyne.NewMenuItem(label, func() { - go d.showGUI(args) - }) - if sub := d.codespaceSubmenu(entry.Repository, args); sub != nil { - item.ChildMenu = sub - } - items = append(items, item) - } } - - // Open previous / launch. - items = append(items, fyne.NewMenuItemSeparator()) if len(hist.Entries) > 0 { items = append(items, fyne.NewMenuItem("Open previous", func() { go d.hotkeyActionPrevious() @@ -92,6 +54,65 @@ func (d *Daemon) buildTrayMenu() *fyne.Menu { return fyne.NewMenu("cosmonaut", items...) } +func (d *Daemon) githubCodespacesMenu() *fyne.MenuItem { + all := d.Codespaces() + if len(all) == 0 { + return nil + } + + repos := codespace.UniqueRepos(all) + hist := history.Load() + repos = hist.SortRepos(repos) + + items := make([]*fyne.MenuItem, 0, len(repos)) + for _, repo := range repos { + repo := repo + args := d.targetNameForRepo(repo) + if args == "" { + args = repo + } + item := fyne.NewMenuItem(repo, func() { + go d.showGUI(args) + }) + if sub := d.codespaceSubmenu(repo, args); sub != nil { + item.ChildMenu = sub + } + items = append(items, item) + } + + root := fyne.NewMenuItem("Codespaces", nil) + root.ChildMenu = fyne.NewMenu("", items...) + return root +} + +func (d *Daemon) coderWorkspaceMenu() *fyne.MenuItem { + workspaces := filterWorkspacesByProvider(d.Workspaces(), provider.NameCoder) + if len(workspaces) == 0 { + return nil + } + + sort.Slice(workspaces, func(i, j int) bool { + oi, oj := stateOrder(workspaces[i].State), stateOrder(workspaces[j].State) + if oi != oj { + return oi < oj + } + return workspaceLabel(workspaces[i]) < workspaceLabel(workspaces[j]) + }) + + items := make([]*fyne.MenuItem, 0, len(workspaces)) + for _, ws := range workspaces { + ws := ws + label := fmt.Sprintf("%s %s", stateIcon(ws.State), ws.Name) + items = append(items, fyne.NewMenuItem(label, func() { + _, resolvedName := guiTargetForCoderWorkspace(d.Cfg, ws) + go d.showGUI("--workspace", ws.Name, "--provider", provider.NameCoder, resolvedName) + })) + } + item := fyne.NewMenuItem("Coder", nil) + item.ChildMenu = fyne.NewMenu("", items...) + return item +} + // codespaceSubmenu builds a submenu showing codespaces for a repo. // Returns nil if the repo has no codespaces. func (d *Daemon) codespaceSubmenu(repo, launchArgs string) *fyne.Menu { @@ -115,7 +136,7 @@ func (d *Daemon) codespaceSubmenu(repo, launchArgs string) *fyne.Menu { for _, cs := range repoCS[:limit] { label := fmt.Sprintf("%s %s", stateIcon(cs.State), csLabel(cs)) item := fyne.NewMenuItem(label, func() { - go d.showGUI("--codespace", cs.Name, launchArgs) + go d.showGUI("--workspace", cs.Name, "--provider", "github", launchArgs) }) item.ChildMenu = d.codespaceActionsMenu(cs, launchArgs) items = append(items, item) @@ -134,7 +155,7 @@ func (d *Daemon) codespaceSubmenu(repo, launchArgs string) *fyne.Menu { func (d *Daemon) codespaceActionsMenu(cs codespace.Codespace, launchArgs string) *fyne.Menu { items := []*fyne.MenuItem{ fyne.NewMenuItem("Open in editor", func() { - go d.showGUI("--codespace", cs.Name, launchArgs) + go d.showGUI("--workspace", cs.Name, "--provider", "github", launchArgs) }), fyne.NewMenuItem("Refresh ports", func() { d.refreshPortsAsync(cs.Name, nil) @@ -204,11 +225,11 @@ func disabledMenuItem(label string) *fyne.MenuItem { // stateOrder returns a sort key for codespace states (lower = first). func stateOrder(state string) int { switch state { - case "Available": + case "Available", "Started", "ready", "running", "connected": return 0 - case "Starting": + case "Starting", "starting", "pending": return 1 - case "Stopped": + case "Stopped", "stopped": return 2 default: return 3 @@ -218,9 +239,9 @@ func stateOrder(state string) int { // stateIcon returns a Unicode indicator for a codespace state. func stateIcon(state string) string { switch state { - case "Available": + case "Available", "Started", "ready", "running", "connected": return "●" - case "Starting": + case "Starting", "starting", "pending": return "◐" default: return "○" diff --git a/internal/doctor/doctor.go b/internal/doctor/doctor.go index c2265cf..1f996dc 100644 --- a/internal/doctor/doctor.go +++ b/internal/doctor/doctor.go @@ -72,10 +72,17 @@ const ( // `gh codespace list` attempt; the daemon supplies its cached value, the // CLI runs a fresh list at call time. func Catalog(listErr func() error) []Check { - return []Check{ - ghCodespaceScopeCheck(listErr), + return CatalogForProvider("github", listErr) +} + +func CatalogForProvider(providerName string, listErr func() error) []Check { + checks := []Check{ sshHostStarCheck(), } + if providerName == "github" { + checks = append([]Check{ghCodespaceScopeCheck(listErr)}, checks...) + } + return checks } // FindByID returns the check with the given ID, or nil. diff --git a/internal/provider/coder.go b/internal/provider/coder.go new file mode 100644 index 0000000..2b21ffd --- /dev/null +++ b/internal/provider/coder.go @@ -0,0 +1,297 @@ +package provider + +import ( + "bytes" + "encoding/json" + "fmt" + "os/exec" + "path/filepath" + "sort" + "strings" + + "github.com/linuskendall/cosmonaut/internal/config" + "github.com/linuskendall/cosmonaut/internal/sshconfig" +) + +type CoderManager struct { + Config *config.Config +} + +type CoderTemplate struct { + Name string + Organization string +} + +func NewCoderManager(cfg *config.Config) *CoderManager { + return &CoderManager{Config: cfg} +} + +func (m *CoderManager) Name() string { return NameCoder } + +func (m *CoderManager) EnsurePrereqs() error { return RequireCommand("coder") } + +func (m *CoderManager) EnsureAuth() error { + _, err := m.run("whoami", "-o", "json") + if err != nil { + return fmt.Errorf("Coder CLI is not authenticated. Run `coder login` first") + } + return nil +} + +func (m *CoderManager) ListAllWorkspaces() ([]Workspace, error) { + out, err := m.run("list", "-o", "json") + if err != nil { + return nil, err + } + var items []coderWorkspace + if err := json.Unmarshal([]byte(out), &items); err != nil { + return nil, fmt.Errorf("parsing coder workspace list: %w", err) + } + return coderWorkspaces(items), nil +} + +func (m *CoderManager) ListTemplates() ([]CoderTemplate, error) { + out, err := m.run("templates", "list", "-o", "json") + if err != nil { + return nil, err + } + var items []struct { + Template struct { + Name string `json:"name"` + OrganizationName string `json:"organization_name"` + } `json:"Template"` + } + if err := json.Unmarshal([]byte(out), &items); err != nil { + return nil, fmt.Errorf("parsing coder template list: %w", err) + } + result := make([]CoderTemplate, 0, len(items)) + for _, item := range items { + if item.Template.Name == "" { + continue + } + result = append(result, CoderTemplate{ + Name: item.Template.Name, + Organization: item.Template.OrganizationName, + }) + } + return result, nil +} + +func (m *CoderManager) ListRepositories() ([]string, error) { + return nil, nil +} + +func (m *CoderManager) ListWorkspacesForTarget(target config.Target) ([]Workspace, error) { + all, err := m.ListAllWorkspaces() + if err != nil { + return nil, err + } + if target.Coder != nil && target.Coder.WorkspaceName != "" { + var filtered []Workspace + for _, ws := range all { + if ws.Name == target.Coder.WorkspaceName { + filtered = append(filtered, ws) + } + } + return filtered, nil + } + if target.Repository != "" { + var filtered []Workspace + repoName := pathBase(target.Repository) + for _, ws := range all { + if ws.Repository == target.Repository || ws.Name == repoName || strings.Contains(ws.Name, repoName) { + filtered = append(filtered, ws) + } + } + if len(filtered) > 0 { + return filtered, nil + } + } + return all, nil +} + +func (m *CoderManager) ResolveWorkspace(name string) (*Workspace, error) { + all, err := m.ListAllWorkspaces() + if err != nil { + return nil, err + } + for _, ws := range all { + if ws.Name == name { + return &ws, nil + } + } + return nil, fmt.Errorf("workspace %q not found", name) +} + +func (m *CoderManager) CreateWorkspace(target config.Target, interactive bool) (*Workspace, error) { + if target.Coder == nil || target.Coder.Template == "" { + return nil, fmt.Errorf("coder target requires coder.template") + } + name := coderWorkspaceName(target) + if name == "" { + return nil, fmt.Errorf("coder target requires coder.workspaceName or a repository-derived default") + } + + args := []string{"create"} + if org := m.targetOrganization(target); org != "" { + args = append(args, "--org", org) + } + args = append(args, "--template", target.Coder.Template) + if target.Coder.StopAfter != "" { + args = append(args, "--stop-after", target.Coder.StopAfter) + } + keys := sortedKeys(target.Coder.Parameters) + for _, key := range keys { + args = append(args, "--parameter", fmt.Sprintf("%s=%s", key, target.Coder.Parameters[key])) + } + args = append(args, "--yes", name) + + if _, err := m.run(args...); err != nil { + return nil, err + } + return m.ResolveWorkspace(name) +} + +func (m *CoderManager) StartWorkspace(workspace *Workspace) (*Workspace, error) { + if workspace == nil { + return nil, fmt.Errorf("workspace is nil") + } + if strings.EqualFold(workspace.State, "running") || strings.EqualFold(workspace.State, "available") { + return workspace, nil + } + if _, err := m.run("start", "--yes", workspace.Name); err != nil { + return nil, err + } + return m.ResolveWorkspace(workspace.Name) +} + +func (m *CoderManager) DeleteWorkspace(name string) error { + return fmt.Errorf("deleting Coder workspaces is not yet supported from Cosmonaut") +} + +func (m *CoderManager) EnsureReachable(workspace *Workspace) error { + latest, err := m.ResolveWorkspace(workspace.Name) + if err != nil { + return err + } + if strings.EqualFold(latest.State, "ready") || strings.EqualFold(latest.State, "running") || strings.EqualFold(latest.State, "connected") { + return nil + } + return fmt.Errorf("coder workspace %q is not ready yet (state: %s)", workspace.Name, latest.State) +} + +func (m *CoderManager) PrepareSSH(paths sshconfig.SSHPaths, workspace *Workspace) (string, error) { + if err := sshconfig.EnsureMainConfigIncludesGenerated(paths.MainConfigPath); err != nil { + return "", err + } + configPath := filepath.Join(paths.IncludeDir, "coder.conf") + args := []string{"config-ssh", "--yes", "--ssh-config-file", configPath} + if _, err := m.run(args...); err != nil { + return "", err + } + return workspace.Name + ".coder", nil +} + +func (m *CoderManager) run(args ...string) (string, error) { + cmd := exec.Command("coder", args...) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + detail := strings.TrimSpace(stderr.String()) + if detail == "" { + detail = strings.TrimSpace(stdout.String()) + } + return "", fmt.Errorf("coder %s exited with code %d: %s", strings.Join(args, " "), cmd.ProcessState.ExitCode(), detail) + } + return stdout.String(), nil +} + +type coderWorkspace struct { + ID string `json:"id"` + Name string `json:"name"` + LastUsedAt string `json:"last_used_at"` + TemplateName string `json:"template_name"` + OwnerName string `json:"owner_name"` + LatestBuild struct { + Status string `json:"status"` + Transition string `json:"transition"` + Resources []struct { + Agents []struct { + Status string `json:"status"` + LifecycleState string `json:"lifecycle_state"` + } `json:"agents"` + } `json:"resources"` + } `json:"latest_build"` +} + +func coderWorkspaces(items []coderWorkspace) []Workspace { + result := make([]Workspace, 0, len(items)) + for _, item := range items { + state := coderWorkspaceState(item) + result = append(result, Workspace{ + Provider: NameCoder, + ID: item.ID, + Name: item.Name, + DisplayName: item.Name, + State: state, + LastUsedAt: item.LastUsedAt, + Template: item.TemplateName, + Metadata: map[string]string{ + "owner": item.OwnerName, + }, + }) + } + return result +} + +func coderWorkspaceState(item coderWorkspace) string { + for _, resource := range item.LatestBuild.Resources { + for _, agent := range resource.Agents { + if agent.LifecycleState != "" { + return agent.LifecycleState + } + if agent.Status != "" { + return agent.Status + } + } + } + if item.LatestBuild.Status != "" { + return item.LatestBuild.Status + } + return item.LatestBuild.Transition +} + +func coderWorkspaceName(target config.Target) string { + if target.Coder != nil && target.Coder.WorkspaceName != "" { + return target.Coder.WorkspaceName + } + if target.Repository == "" { + return "" + } + return pathBase(target.Repository) +} + +func pathBase(s string) string { + parts := strings.Split(s, "/") + return parts[len(parts)-1] +} + +func (m *CoderManager) targetOrganization(target config.Target) string { + if target.Coder != nil && target.Coder.Organization != "" { + return target.Coder.Organization + } + if m.Config != nil { + return m.Config.Providers.Coder.Organization + } + return "" +} + +func sortedKeys(maybe map[string]string) []string { + keys := make([]string, 0, len(maybe)) + for key := range maybe { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} diff --git a/internal/provider/github.go b/internal/provider/github.go new file mode 100644 index 0000000..e659835 --- /dev/null +++ b/internal/provider/github.go @@ -0,0 +1,132 @@ +package provider + +import ( + "encoding/json" + + "github.com/linuskendall/cosmonaut/internal/codespace" + "github.com/linuskendall/cosmonaut/internal/config" + "github.com/linuskendall/cosmonaut/internal/sshconfig" +) + +type GitHubManager struct { + Runner codespace.GHRunner +} + +func NewGitHubManager(runner codespace.GHRunner) *GitHubManager { + return &GitHubManager{Runner: runner} +} + +func (m *GitHubManager) Name() string { return NameGitHub } + +func (m *GitHubManager) EnsurePrereqs() error { return codespace.RequireCommand("gh") } + +func (m *GitHubManager) EnsureAuth() error { return codespace.EnsureGHAuth(m.Runner) } + +func (m *GitHubManager) ListAllWorkspaces() ([]Workspace, error) { + items, err := codespace.ListAllCodespaces(m.Runner) + if err != nil { + return nil, err + } + return githubWorkspaces(items), nil +} + +func (m *GitHubManager) ListRepositories() ([]string, error) { + return codespace.ListAllRepos(m.Runner) +} + +func (m *GitHubManager) ListWorkspacesForTarget(target config.Target) ([]Workspace, error) { + items, err := codespace.ListCodespaces(m.Runner, target.Repository) + if err != nil { + return nil, err + } + return githubWorkspaces(items), nil +} + +func (m *GitHubManager) ResolveWorkspace(name string) (*Workspace, error) { + out, err := m.Runner.Run([]string{ + "codespace", "view", + "--codespace", name, + "--json", "name,displayName,repository,state,gitStatus,machineName,createdAt,lastUsedAt", + }) + if err != nil { + return nil, err + } + var cs codespace.Codespace + if err := json.Unmarshal([]byte(out), &cs); err != nil { + return nil, err + } + ws := githubWorkspace(cs) + return &ws, nil +} + +func (m *GitHubManager) CreateWorkspace(target config.Target, interactive bool) (*Workspace, error) { + var ( + cs *codespace.Codespace + err error + ) + if interactive { + cs, err = codespace.CreateCodespaceInteractive(m.Runner, target) + } else { + cs, err = codespace.CreateCodespace(m.Runner, target) + } + if err != nil { + return nil, err + } + ws := githubWorkspace(*cs) + return &ws, nil +} + +func (m *GitHubManager) StartWorkspace(workspace *Workspace) (*Workspace, error) { + return workspace, nil +} + +func (m *GitHubManager) DeleteWorkspace(name string) error { + return codespace.DeleteCodespace(m.Runner, name) +} + +func (m *GitHubManager) EnsureReachable(workspace *Workspace) error { + return codespace.EnsureReachable(m.Runner, workspace.Name) +} + +func (m *GitHubManager) PrepareSSH(paths sshconfig.SSHPaths, workspace *Workspace) (string, error) { + sshCfg, err := codespace.GetSSHConfig(m.Runner, workspace.Name) + if err != nil { + return "", err + } + alias, err := sshconfig.ParsePrimaryHostAlias(sshCfg) + if err != nil { + return "", err + } + if err := sshconfig.EnsureWorkspaceConfig(paths, workspace.Provider, workspace.Name, sshCfg); err != nil { + return "", err + } + return alias, nil +} + +func githubWorkspaces(items []codespace.Codespace) []Workspace { + result := make([]Workspace, 0, len(items)) + for _, item := range items { + result = append(result, githubWorkspace(item)) + } + return result +} + +func githubWorkspace(item codespace.Codespace) Workspace { + ws := Workspace{ + Provider: NameGitHub, + Name: item.Name, + DisplayName: item.DisplayName, + Repository: string(item.Repository), + State: item.State, + MachineName: item.MachineName, + CreatedAt: item.CreatedAt, + LastUsedAt: item.LastUsedAt, + } + if item.GitStatus != nil { + ws.Branch = item.GitStatus.Ref + if ws.Branch == "" { + ws.Branch = item.GitStatus.Branch + } + } + return ws +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go new file mode 100644 index 0000000..8a5078a --- /dev/null +++ b/internal/provider/provider.go @@ -0,0 +1,155 @@ +package provider + +import ( + "fmt" + "os/exec" + "strings" + + "github.com/linuskendall/cosmonaut/internal/codespace" + "github.com/linuskendall/cosmonaut/internal/config" + "github.com/linuskendall/cosmonaut/internal/sshconfig" +) + +const ( + NameGitHub = "github" + NameCoder = "coder" +) + +type Workspace struct { + Provider string + ID string + Name string + DisplayName string + Repository string + Branch string + State string + MachineName string + CreatedAt string + LastUsedAt string + Template string + Metadata map[string]string +} + +type Manager interface { + Name() string + EnsurePrereqs() error + EnsureAuth() error + ListAllWorkspaces() ([]Workspace, error) + ListRepositories() ([]string, error) + ListWorkspacesForTarget(target config.Target) ([]Workspace, error) + ResolveWorkspace(name string) (*Workspace, error) + CreateWorkspace(target config.Target, interactive bool) (*Workspace, error) + StartWorkspace(workspace *Workspace) (*Workspace, error) + DeleteWorkspace(name string) error + EnsureReachable(workspace *Workspace) error + PrepareSSH(paths sshconfig.SSHPaths, workspace *Workspace) (string, error) +} + +func RequireCommand(name string) error { + if _, err := exec.LookPath(name); err != nil { + return fmt.Errorf("%q not found on PATH", name) + } + return nil +} + +func NewManager(cfg *config.Config) (Manager, error) { + switch cfg.EffectiveWorkspaceProvider() { + case "", NameGitHub: + return NewGitHubManager(codespace.DefaultGHRunner{}), nil + case NameCoder: + return NewCoderManager(cfg), nil + default: + return nil, fmt.Errorf("unknown workspaceProvider %q (supported: github, coder)", cfg.EffectiveWorkspaceProvider()) + } +} + +func MatchesTarget(ws *Workspace, t *config.Target) bool { + if ws == nil || t == nil { + return false + } + if t.Repository != "" && ws.Repository != "" && ws.Repository != t.Repository { + return false + } + explicitName := t.ExplicitWorkspaceName(ws.Provider) + if explicitName != "" && ws.Name != explicitName { + return false + } + if t.DisplayName != "" && ws.DisplayName != t.DisplayName { + return false + } + if t.Branch != "" && ws.Branch != "" && ws.Branch != t.Branch { + return false + } + return true +} + +func FindMatching(workspaces []Workspace, t *config.Target) []Workspace { + var matches []Workspace + for i := range workspaces { + if MatchesTarget(&workspaces[i], t) { + matches = append(matches, workspaces[i]) + } + } + return matches +} + +func ChooseWorkspace(workspaces []Workspace, t *config.Target) (*Workspace, error) { + matches := FindMatching(workspaces, t) + if len(matches) > 1 { + names := make([]string, len(matches)) + for i, m := range matches { + names[i] = m.Name + } + return nil, fmt.Errorf("ambiguous workspace match: %s", strings.Join(names, ", ")) + } + if len(matches) == 1 { + return &matches[0], nil + } + return nil, nil +} + +func DescribeWorkspace(ws *Workspace, recommended bool) string { + state := ws.State + if state == "" { + state = "unknown" + } + + label := ws.Name + if ws.DisplayName != "" { + label += fmt.Sprintf(" (%s)", ws.DisplayName) + } + label += fmt.Sprintf(", provider=%s, state=%s", ws.Provider, state) + if ws.Branch != "" { + label += fmt.Sprintf(", branch=%s", ws.Branch) + } + if ws.Template != "" { + label += fmt.Sprintf(", template=%s", ws.Template) + } + if recommended { + label += " [matches config]" + } + return label +} + +func UniqueRepos(workspaces []Workspace) []string { + seen := make(map[string]bool) + var repos []string + for _, ws := range workspaces { + if ws.Repository == "" || seen[ws.Repository] { + continue + } + seen[ws.Repository] = true + repos = append(repos, ws.Repository) + } + return repos +} + +func FilterByRepo(workspaces []Workspace, repo string) []Workspace { + var result []Workspace + for _, ws := range workspaces { + if ws.Repository == repo { + result = append(result, ws) + } + } + return result +} diff --git a/internal/sshconfig/sshconfig.go b/internal/sshconfig/sshconfig.go index a572f45..10180dd 100644 --- a/internal/sshconfig/sshconfig.go +++ b/internal/sshconfig/sshconfig.go @@ -12,6 +12,7 @@ import ( ) const SSHIncludeLine = "Include ~/.ssh/cosmonaut/*.conf" +const coderConfigFile = "coder.conf" // HostStarScopedLine is the form a bare `Host *` is rewritten to when the // user accepts the scoping fix. The negation patterns prevent the @@ -23,7 +24,7 @@ const HostStarScopedLine = "Host * !cs-* !cs.*" // one-shot backup written before ScopeHostStarBlocks first modifies it. const MainConfigBackupSuffix = ".cosmonaut.bak" -var hostAliasRe = regexp.MustCompile(`(?m)^\s*Host\s+([^\s*][^\s]*)\s*$`) +var hostAliasRe = regexp.MustCompile(`(?m)^\s*Host\s+([^\s]+)\s*$`) // hostStarLineRe matches lines that are exactly `Host *` (case-insensitive, // any leading whitespace, any trailing whitespace). More complex patterns @@ -33,11 +34,17 @@ var hostStarLineRe = regexp.MustCompile(`(?im)^([ \t]*)Host[ \t]+\*[ \t]*$`) // ParsePrimaryHostAlias extracts the first concrete Host entry from SSH config text. func ParsePrimaryHostAlias(sshConfig string) (string, error) { - match := hostAliasRe.FindStringSubmatch(sshConfig) - if match == nil { - return "", fmt.Errorf("could not find a concrete Host entry in gh codespace ssh --config output") + matches := hostAliasRe.FindAllStringSubmatch(sshConfig, -1) + for _, match := range matches { + if len(match) < 2 { + continue + } + alias := match[1] + if isConcreteHostAlias(alias) { + return alias, nil + } } - return match[1], nil + return "", fmt.Errorf("could not find a concrete Host entry in SSH config output") } // EnsureIncludeLine ensures the SSH include line is at the top of the config. @@ -77,6 +84,16 @@ func (p SSHPaths) CodespaceConfigPath(codespaceName string) string { return filepath.Join(p.IncludeDir, codespaceName+".conf") } +func (p SSHPaths) WorkspaceConfigPath(provider, workspaceName string) string { + if provider == "coder" { + return filepath.Join(p.IncludeDir, coderConfigFile) + } + if provider == "github" || provider == "" { + return filepath.Join(p.IncludeDir, workspaceName+".conf") + } + return filepath.Join(p.IncludeDir, provider+"-"+workspaceName+".conf") +} + // EnsureConfigIncludesGenerated ensures the main SSH config includes the generated configs. func EnsureConfigIncludesGenerated(mainConfigPath string) error { current, err := os.ReadFile(mainConfigPath) @@ -97,6 +114,10 @@ func EnsureConfigIncludesGenerated(mainConfigPath string) error { return os.WriteFile(mainConfigPath, []byte(updated), 0644) } +func EnsureMainConfigIncludesGenerated(mainConfigPath string) error { + return EnsureConfigIncludesGenerated(mainConfigPath) +} + // NeedsHostStarScoping reports whether mainConfigPath contains any bare // `Host *` lines that should be narrowed so the catch-all block doesn't // apply to codespace hosts. Returns false on read errors or missing file @@ -155,6 +176,29 @@ func ReadExistingAlias(includeDir, codespaceName string) (string, bool) { return alias, true } +func ReadExistingWorkspaceAlias(paths SSHPaths, provider, workspaceName string) (string, bool) { + path := paths.WorkspaceConfigPath(provider, workspaceName) + if provider == "coder" { + if _, err := os.Stat(path); err != nil { + return "", false + } + return workspaceName + ".coder", true + } + data, err := os.ReadFile(path) + if err != nil { + return "", false + } + alias, err := ParsePrimaryHostAlias(string(data)) + if err != nil { + return "", false + } + return alias, true +} + +func isConcreteHostAlias(alias string) bool { + return alias != "" && !strings.ContainsAny(alias, "*!?") +} + // managedExtrasVersion is bumped whenever managedExtrasBody changes, so // existing on-disk confs get rewritten by RefreshAllManagedExtras on the // next applet start. @@ -254,6 +298,25 @@ func WriteCodespaceConfig(includeDir, codespaceName, content string) error { return os.WriteFile(path, []byte(content), 0644) } +func WriteWorkspaceConfig(includeDir, provider, workspaceName, content string) error { + if err := os.MkdirAll(includeDir, 0700); err != nil { + return err + } + content = applyManagedExtras(content) + path := SSHPaths{IncludeDir: includeDir}.WorkspaceConfigPath(provider, workspaceName) + return os.WriteFile(path, []byte(content), 0644) +} + +func EnsureWorkspaceConfig(paths SSHPaths, provider, workspaceName, content string) error { + if err := os.MkdirAll(paths.IncludeDir, 0700); err != nil { + return err + } + if err := EnsureConfigIncludesGenerated(paths.MainConfigPath); err != nil { + return err + } + return WriteWorkspaceConfig(paths.IncludeDir, provider, workspaceName, content) +} + // RefreshManagedExtras rewrites the managed block in path to the current // version. Returns true if the file was changed. No-op if already current // or if the file doesn't exist. diff --git a/internal/sshconfig/sshconfig_test.go b/internal/sshconfig/sshconfig_test.go index dd37391..ff54b3b 100644 --- a/internal/sshconfig/sshconfig_test.go +++ b/internal/sshconfig/sshconfig_test.go @@ -22,6 +22,43 @@ Host cs-demo } } +func TestParsePrimaryHostAliasSkipsWildcardHosts(t *testing.T) { + sshConfig := ` +Host coder.* + ProxyCommand coder ssh --stdio %h + +Host *.coder + ProxyCommand coder ssh --stdio %h +` + _, err := ParsePrimaryHostAlias(sshConfig) + if err == nil { + t.Fatal("expected no concrete alias to be found") + } +} + +func TestReadExistingWorkspaceAliasForCoderUsesConcreteWorkspaceAlias(t *testing.T) { + dir := t.TempDir() + paths := SSHPaths{IncludeDir: dir} + path := filepath.Join(dir, "coder.conf") + body := ` +Host coder.* + ProxyCommand coder ssh --stdio %h + +Host *.coder + ProxyCommand coder ssh --stdio %h +` + if err := os.WriteFile(path, []byte(body), 0644); err != nil { + t.Fatal(err) + } + got, ok := ReadExistingWorkspaceAlias(paths, "coder", "my-workspace") + if !ok { + t.Fatal("expected coder alias to be detected") + } + if got != "my-workspace.coder" { + t.Fatalf("got %q, want %q", got, "my-workspace.coder") + } +} + func TestEnsureIncludeLineIsIdempotent(t *testing.T) { once := EnsureIncludeLine("Host example\n HostName example.com\n") twice := EnsureIncludeLine(once) diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 548439b..0c1077b 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -16,8 +16,8 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - "github.com/linuskendall/cosmonaut/internal/codespace" "github.com/linuskendall/cosmonaut/internal/config" + "github.com/linuskendall/cosmonaut/internal/provider" ) const numberTimeout = 500 * time.Millisecond @@ -325,19 +325,19 @@ func RunRepoSelection(repos []string, recentCount int) (string, error) { return result.Repo, nil } -// --- Codespace Selection Model --- +// --- Workspace Selection Model --- -// SelectResult holds the outcome of the codespace selection TUI. +// SelectResult holds the outcome of the workspace selection TUI. type SelectResult struct { - Selected *codespace.Codespace // nil means "create new" - Delete *codespace.Codespace // non-nil means user wants to delete this codespace + Selected *provider.Workspace // nil means "create new" + Delete *provider.Workspace // non-nil means user wants to delete this workspace Quit bool Back bool // user wants to go back to repo selection } -// SelectModel is the Bubbletea model for codespace selection. +// SelectModel is the Bubbletea model for workspace selection. type SelectModel struct { - codespaces []codespace.Codespace + workspaces []provider.Workspace target config.Target dryRun bool allowBack bool // whether esc means "back" instead of "quit" @@ -354,12 +354,12 @@ type SelectModel struct { // NewSelectModel creates a selection model. // If allowBack is true, esc/backspace signals "go back" instead of quit. -func NewSelectModel(codespaces []codespace.Codespace, target config.Target, dryRun, allowBack bool) SelectModel { - matches := codespace.FindMatching(codespaces, &target) +func NewSelectModel(workspaces []provider.Workspace, target config.Target, dryRun, allowBack bool) SelectModel { + matches := provider.FindMatching(workspaces, &target) recommended := -1 if len(matches) == 1 { - for i, cs := range codespaces { - if cs.Name == matches[0].Name { + for i, ws := range workspaces { + if ws.Name == matches[0].Name { recommended = i break } @@ -367,7 +367,7 @@ func NewSelectModel(codespaces []codespace.Codespace, target config.Target, dryR } return SelectModel{ - codespaces: codespaces, + workspaces: workspaces, target: target, dryRun: dryRun, allowBack: allowBack, @@ -380,7 +380,7 @@ func NewSelectModel(codespaces []codespace.Codespace, target config.Target, dryR func (m SelectModel) Init() tea.Cmd { return tea.EnableMouseCellMotion } func (m SelectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - totalChoices := len(m.codespaces) + 1 + totalChoices := len(m.workspaces) + 1 switch msg := msg.(type) { case numberTimeoutMsg: @@ -462,9 +462,9 @@ func (m SelectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.numberBuf = "" return m.selectCurrent() case "d", "x": - if m.cursor < len(m.codespaces) { - cs := m.codespaces[m.cursor] - m.result.Delete = &cs + if m.cursor < len(m.workspaces) { + ws := m.workspaces[m.cursor] + m.result.Delete = &ws m.done = true return m, tea.Quit } @@ -486,9 +486,9 @@ func (m SelectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (m SelectModel) selectCurrent() (tea.Model, tea.Cmd) { - if m.cursor < len(m.codespaces) { - cs := m.codespaces[m.cursor] - m.result.Selected = &cs + if m.cursor < len(m.workspaces) { + ws := m.workspaces[m.cursor] + m.result.Selected = &ws } else { m.result.Selected = nil // create new } @@ -502,11 +502,11 @@ func (m SelectModel) View() string { } var b strings.Builder - fmt.Fprintf(&b, "Existing codespaces found for %s:\n\n", m.repo) + fmt.Fprintf(&b, "Existing workspaces found for %s:\n\n", m.repo) - for i, cs := range m.codespaces { + for i, ws := range m.workspaces { recommended := i == m.recommendedIdx - desc := codespace.DescribeCodespace(&cs, recommended) + desc := provider.DescribeWorkspace(&ws, recommended) cursor := " " if i == m.cursor { @@ -526,13 +526,13 @@ func (m SelectModel) View() string { } // "Create new" option - createIdx := len(m.codespaces) + createIdx := len(m.workspaces) cursor := " " if m.cursor == createIdx { cursor = cursorStyle.Render("> ") } num := fmt.Sprintf("%d. ", createIdx+1) - label := "create a new codespace" + label := "create a new workspace" if m.dryRun { label += " (disabled by --dry-run)" } diff --git a/main.go b/main.go index 2ded85b..ab3e34f 100644 --- a/main.go +++ b/main.go @@ -1,11 +1,11 @@ -// cosmonaut starts or creates GitHub Codespaces and opens them in your -// editor (Zed or Neovim) via SSH remoting. +// cosmonaut starts or creates remote development workspaces and opens them in +// your editor (Zed or Neovim) via SSH remoting. // // The tool performs the following steps: -// 1. Authenticate with GitHub via the gh CLI -// 2. Resolve a target repository and codespace (interactive or from config) -// 3. Create a codespace if no match exists -// 4. Fetch the codespace's SSH config and write it to ~/.ssh/cosmonaut/ +// 1. Authenticate with the selected workspace provider CLI +// 2. Resolve a target repository or workspace (interactive or from config) +// 3. Create a workspace if no match exists +// 4. Fetch the workspace's SSH config and write it to ~/.ssh/cosmonaut/ // 5. Configure editor-specific settings (e.g. Zed's settings.json) // 6. Launch the editor with the SSH remote connection package main @@ -22,10 +22,10 @@ import ( "github.com/spf13/cobra" "golang.org/x/term" - "github.com/linuskendall/cosmonaut/internal/codespace" "github.com/linuskendall/cosmonaut/internal/config" "github.com/linuskendall/cosmonaut/internal/editor" "github.com/linuskendall/cosmonaut/internal/history" + "github.com/linuskendall/cosmonaut/internal/provider" "github.com/linuskendall/cosmonaut/internal/slug" "github.com/linuskendall/cosmonaut/internal/sshconfig" "github.com/linuskendall/cosmonaut/internal/tui" @@ -59,7 +59,6 @@ func isAppBundle() bool { return strings.Contains(exe, ".app/Contents/MacOS/") } - func rootCmd() *cobra.Command { var ( configPath string @@ -71,13 +70,13 @@ func rootCmd() *cobra.Command { cmd := &cobra.Command{ Use: "cosmonaut [target]", - Short: "Start or create GitHub Codespaces and open them in your editor", - Long: `cosmonaut connects GitHub Codespaces to your editor via SSH remoting. + Short: "Start or create remote workspaces and open them in your editor", + Long: `cosmonaut connects remote workspaces to your editor via SSH remoting. When a target name is given, its definition is read from the config file. Without a target, an interactive TUI lets you pick a repository (with -type-ahead filtering across all your GitHub repos) and select or create -a codespace. +type-ahead filtering across your provider's workspaces or repositories) +and select or create a workspace. Config file fields: ` + config.TargetFieldsHelp(), @@ -143,39 +142,43 @@ func run(configPath, targetName, codespaceName, editorFlag string, noOpen, dryRu } cfg, _ := config.LoadConfig(absConfigPath) - - if err := codespace.RequireCommand("gh"); err != nil { + if cfg == nil { + cfg = &config.Config{} + } + manager, err := provider.NewManager(cfg) + if err != nil { + return err + } + if err := manager.EnsurePrereqs(); err != nil { return err } - - runner := codespace.DefaultGHRunner{} interactive := term.IsTerminal(int(os.Stdin.Fd())) // Authenticate. if interactive { - if err := tui.RunWithSpinner("Checking GitHub auth", func() error { - return codespace.EnsureGHAuth(runner) + if err := tui.RunWithSpinner("Checking "+manager.Name()+" auth", func() error { + return manager.EnsureAuth() }); err != nil { return err } } else { - if err := codespace.EnsureGHAuth(runner); err != nil { + if err := manager.EnsureAuth(); err != nil { return err } } - // Resolve target + select codespace. - // Dynamic mode uses a loop so the user can go back from codespace selection to repo selection. + // Resolve target + select workspace. + // Dynamic mode uses a loop so the user can go back from workspace selection to repo selection. var target config.Target var resolvedTargetName string - var selected *codespace.Codespace + var selected *provider.Workspace dynamicMode := false if targetName != "" { // If the argument looks like owner/repo, treat it as a direct repo // name rather than a config target (used by the tray for history entries). if strings.Contains(targetName, "/") { - target, resolvedTargetName = targetForRepo(cfg, targetName) + target, resolvedTargetName = targetForRepo(cfg, targetName, manager.Name()) } else if cfg == nil { return fmt.Errorf("target %q specified but no config file found at %s", targetName, absConfigPath) } else if t, ok := cfg.Targets[targetName]; ok { @@ -199,120 +202,131 @@ func run(configPath, targetName, codespaceName, editorFlag string, noOpen, dryRu // Direct codespace launch: bypass all TUI selection. if codespaceName != "" { - if target.Repository == "" { + if target.Repository == "" && target.ExplicitWorkspaceName(manager.Name()) == "" { return fmt.Errorf("--codespace requires a target or repo argument to resolve workspace settings") } - // Fetch full codespace details so we have state for the fast path. - out, csErr := runner.Run([]string{ - "codespace", "view", - "--codespace", codespaceName, - "--json", "name,displayName,repository,state,gitStatus,machineName,createdAt,lastUsedAt", - }) - if csErr != nil { - return fmt.Errorf("looking up codespace %q: %w", codespaceName, csErr) - } - var cs codespace.Codespace - if csErr := json.Unmarshal([]byte(out), &cs); csErr != nil { - return fmt.Errorf("parsing codespace %q: %w", codespaceName, csErr) + selected, err = manager.ResolveWorkspace(codespaceName) + if err != nil { + return fmt.Errorf("looking up workspace %q: %w", codespaceName, err) } - selected = &cs } if selected != nil { // Already resolved (e.g. --codespace flag); skip selection. } else if dynamicMode { - // Fetch all codespaces and all user repos for the repo picker. - var allCodespaces []codespace.Codespace - var allUserRepos []string - allCodespaces, err = tui.RunWithSpinnerResult("Fetching your codespaces", func() ([]codespace.Codespace, error) { - return codespace.ListAllCodespaces(runner) - }) - if err != nil { - return err - } - allUserRepos, err = tui.RunWithSpinnerResult("Fetching your repositories", func() ([]string, error) { - return codespace.ListAllRepos(runner) - }) - if err != nil { - return err - } - - repos := codespace.UniqueRepos(allCodespaces) - repos = mergeRepos(repos, configRepos(cfg)) - repos = mergeRepos(repos, allUserRepos) - - hist := history.Load() - sorted := hist.SortRepos(repos) - recentCount := countRecent(sorted, hist) - - // Loop: repo selection → codespace selection (with back). - for { - repo, err := tui.RunRepoSelection(sorted, recentCount) + if manager.Name() == provider.NameCoder { + var allWorkspaces []provider.Workspace + allWorkspaces, err = tui.RunWithSpinnerResult("Fetching your coder workspaces", func() ([]provider.Workspace, error) { + return manager.ListAllWorkspaces() + }) if err != nil { return err } - - target, resolvedTargetName = targetForRepo(cfg, repo) - repoCodespaces := codespace.FilterByRepo(allCodespaces, repo) - - if len(repoCodespaces) == 0 { - // No existing codespaces: skip selection, go straight to creation. - selected = nil - break + if len(allWorkspaces) == 0 { + return fmt.Errorf("no coder workspaces found and no target was provided") } - - sel, back, del, err := runSelectionTUIWithBack(repoCodespaces, target, dryRun) + target = config.Target{WorkspacePath: guessWorkspacePath(target, nil)} + resolvedTargetName = allWorkspaces[0].Name + sel, del, selErr := runSelectionTUI(allWorkspaces, target, dryRun) + if selErr != nil { + return selErr + } + if del != nil { + return fmt.Errorf("workspace deletion is not supported for provider %q", manager.Name()) + } + selected = sel + if selected != nil { + target = applyWorkspaceDefaults(target, *selected) + resolvedTargetName = selected.Name + } + } else { + // Fetch all workspaces and all user repos for the repo picker. + var allWorkspaces []provider.Workspace + var allUserRepos []string + allWorkspaces, err = tui.RunWithSpinnerResult("Fetching your workspaces", func() ([]provider.Workspace, error) { + return manager.ListAllWorkspaces() + }) if err != nil { return err } - if back { - continue // go back to repo picker + allUserRepos, err = tui.RunWithSpinnerResult("Fetching your repositories", func() ([]string, error) { + return manager.ListRepositories() + }) + if err != nil { + return err } - if del != nil { - if err := deleteCodespaceWithSpinner(runner, del.Name); err != nil { + + repos := provider.UniqueRepos(allWorkspaces) + repos = mergeRepos(repos, configRepos(cfg)) + repos = mergeRepos(repos, allUserRepos) + + hist := history.Load() + sorted := hist.SortRepos(repos) + recentCount := countRecent(sorted, hist) + + // Loop: repo selection → workspace selection (with back). + for { + repo, err := tui.RunRepoSelection(sorted, recentCount) + if err != nil { return err } - // Remove from cached list and retry selection. - allCodespaces = removeCodespace(allCodespaces, del.Name) - repos = codespace.UniqueRepos(allCodespaces) - sorted = hist.SortRepos(repos) - recentCount = countRecent(sorted, hist) - if len(repos) == 0 { - return fmt.Errorf("no codespaces remain: create one with `gh codespace create` first") + + target, resolvedTargetName = targetForRepo(cfg, repo, manager.Name()) + repoWorkspaces := provider.FilterByRepo(allWorkspaces, repo) + + if len(repoWorkspaces) == 0 { + selected = nil + break } - continue + + sel, back, del, err := runSelectionTUIWithBack(repoWorkspaces, target, dryRun) + if err != nil { + return err + } + if back { + continue + } + if del != nil { + if err := deleteWorkspaceWithSpinner(manager, del.Name); err != nil { + return err + } + allWorkspaces = removeWorkspace(allWorkspaces, del.Name) + repos = provider.UniqueRepos(allWorkspaces) + sorted = hist.SortRepos(repos) + recentCount = countRecent(sorted, hist) + if len(repos) == 0 { + return fmt.Errorf("no workspaces remain") + } + continue + } + selected = sel + break } - selected = sel - break } } else { - // Static target: list codespaces for the specific repo. - // When using a default target interactively (no explicit target name), - // allow the user to go back to pick a different repo. + // Static target: list workspaces for the specific target. allowBack := interactive && targetName == "" - var codespaces []codespace.Codespace + var workspaces []provider.Workspace if interactive { - codespaces, err = tui.RunWithSpinnerResult("Listing codespaces for "+target.Repository, func() ([]codespace.Codespace, error) { - return codespace.ListCodespaces(runner, target.Repository) + workspaces, err = tui.RunWithSpinnerResult("Listing workspaces", func() ([]provider.Workspace, error) { + return manager.ListWorkspacesForTarget(target) }) } else { - codespaces, err = codespace.ListCodespaces(runner, target.Repository) + workspaces, err = manager.ListWorkspacesForTarget(target) } if err != nil { return err } wentBack := false - if len(codespaces) > 0 { - // Auto-select when there's only one codespace for this repo - // in non-interactive mode (e.g. when launched from the applet). - if len(codespaces) == 1 && !interactive { - selected = &codespaces[0] + if len(workspaces) > 0 { + if len(workspaces) == 1 && !interactive { + selected = &workspaces[0] } else if interactive { for { if allowBack { - sel, back, del, selErr := runSelectionTUIWithBack(codespaces, target, dryRun) + sel, back, del, selErr := runSelectionTUIWithBack(workspaces, target, dryRun) if selErr != nil { return selErr } @@ -321,11 +335,11 @@ func run(configPath, targetName, codespaceName, editorFlag string, noOpen, dryRu break } if del != nil { - if delErr := deleteCodespaceWithSpinner(runner, del.Name); delErr != nil { + if delErr := deleteWorkspaceWithSpinner(manager, del.Name); delErr != nil { return delErr } - codespaces = removeCodespace(codespaces, del.Name) - if len(codespaces) == 0 { + workspaces = removeWorkspace(workspaces, del.Name) + if len(workspaces) == 0 { break } continue @@ -333,16 +347,16 @@ func run(configPath, targetName, codespaceName, editorFlag string, noOpen, dryRu selected = sel break } else { - sel, del, selErr := runSelectionTUI(codespaces, target, dryRun) + sel, del, selErr := runSelectionTUI(workspaces, target, dryRun) if selErr != nil { return selErr } if del != nil { - if delErr := deleteCodespaceWithSpinner(runner, del.Name); delErr != nil { + if delErr := deleteWorkspaceWithSpinner(manager, del.Name); delErr != nil { return delErr } - codespaces = removeCodespace(codespaces, del.Name) - if len(codespaces) == 0 { + workspaces = removeWorkspace(workspaces, del.Name) + if len(workspaces) == 0 { break } continue @@ -352,34 +366,32 @@ func run(configPath, targetName, codespaceName, editorFlag string, noOpen, dryRu } } } else { - selected, err = codespace.ChooseCodespace(codespaces, &target) + selected, err = provider.ChooseWorkspace(workspaces, &target) if err != nil { return err } } } else if allowBack { - // No codespaces for default target: let user pick another repo. wentBack = true } - // If user went back from default target, fall into dynamic repo selection. if wentBack { - var allCodespaces []codespace.Codespace + var allWorkspaces []provider.Workspace var allUserRepos []string - allCodespaces, err = tui.RunWithSpinnerResult("Fetching your codespaces", func() ([]codespace.Codespace, error) { - return codespace.ListAllCodespaces(runner) + allWorkspaces, err = tui.RunWithSpinnerResult("Fetching your workspaces", func() ([]provider.Workspace, error) { + return manager.ListAllWorkspaces() }) if err != nil { return err } allUserRepos, err = tui.RunWithSpinnerResult("Fetching your repositories", func() ([]string, error) { - return codespace.ListAllRepos(runner) + return manager.ListRepositories() }) if err != nil { return err } - repos := codespace.UniqueRepos(allCodespaces) + repos := provider.UniqueRepos(allWorkspaces) repos = mergeRepos(repos, configRepos(cfg)) repos = mergeRepos(repos, allUserRepos) @@ -393,15 +405,15 @@ func run(configPath, targetName, codespaceName, editorFlag string, noOpen, dryRu return repoErr } - target, resolvedTargetName = targetForRepo(cfg, repo) - repoCodespaces := codespace.FilterByRepo(allCodespaces, repo) + target, resolvedTargetName = targetForRepo(cfg, repo, manager.Name()) + repoWorkspaces := provider.FilterByRepo(allWorkspaces, repo) - if len(repoCodespaces) == 0 { + if len(repoWorkspaces) == 0 { selected = nil break } - sel, back, del, selErr := runSelectionTUIWithBack(repoCodespaces, target, dryRun) + sel, back, del, selErr := runSelectionTUIWithBack(repoWorkspaces, target, dryRun) if selErr != nil { return selErr } @@ -409,15 +421,15 @@ func run(configPath, targetName, codespaceName, editorFlag string, noOpen, dryRu continue } if del != nil { - if delErr := deleteCodespaceWithSpinner(runner, del.Name); delErr != nil { + if delErr := deleteWorkspaceWithSpinner(manager, del.Name); delErr != nil { return delErr } - allCodespaces = removeCodespace(allCodespaces, del.Name) - repos = codespace.UniqueRepos(allCodespaces) + allWorkspaces = removeWorkspace(allWorkspaces, del.Name) + repos = provider.UniqueRepos(allWorkspaces) sorted = hist.SortRepos(repos) recentCount = countRecent(sorted, hist) if len(repos) == 0 { - return fmt.Errorf("no codespaces remain: create one with `gh codespace create` first") + return fmt.Errorf("no workspaces remain") } continue } @@ -427,14 +439,14 @@ func run(configPath, targetName, codespaceName, editorFlag string, noOpen, dryRu } } - // Create codespace if needed. + // Create workspace if needed. if selected == nil { if dryRun { - return fmt.Errorf("no matching codespace exists and --dry-run forbids creating one") + return fmt.Errorf("no matching workspace exists and --dry-run forbids creating one") } createTarget := target - if interactive { + if interactive && manager.Name() == provider.NameGitHub { workLabel, err := runWorkLabelTUI() if err != nil { return err @@ -449,23 +461,17 @@ func run(configPath, targetName, codespaceName, editorFlag string, noOpen, dryRu } } + if interactive && manager.Name() == provider.NameGitHub { + fmt.Fprintf(os.Stderr, " Creating workspace…\n") + } + ws, createErr := manager.CreateWorkspace(createTarget, interactive) + if createErr != nil { + return createErr + } if interactive { - // Run interactively (not inside a spinner) so gh can prompt - // the user if it needs to (e.g. machine type selection). - fmt.Fprintf(os.Stderr, " Creating codespace…\n") - cs, createErr := codespace.CreateCodespaceInteractive(runner, createTarget) - if createErr != nil { - return createErr - } - tui.Status("✓", "Codespace created") - selected = cs - } else { - cs, createErr := codespace.CreateCodespace(runner, createTarget) - if createErr != nil { - return createErr - } - selected = cs + tui.Status("✓", "Workspace created") } + selected = ws } // Record repo in history. @@ -483,23 +489,26 @@ func run(configPath, targetName, codespaceName, editorFlag string, noOpen, dryRu return err } - // Fast path: if the codespace is already Available and we have an - // SSH config on disk, skip the slow SSH wait + config fetch and + workspacePath := guessWorkspacePath(target, selected) + + // Fast path: if the workspace is already running and we have an SSH config + // on disk, skip the slow SSH wait + config fetch and // go straight to launching the editor. - if selected.State == "Available" { + if isWorkspaceRunning(*selected) { paths := sshconfig.ResolvePaths() - if alias, ok := sshconfig.ReadExistingAlias(paths.IncludeDir, selected.Name); ok { + if alias, ok := sshconfig.ReadExistingWorkspaceAlias(paths, selected.Provider, selected.Name); ok { if interactive { - tui.Status("⚡", fmt.Sprintf("Codespace already running, opening %s", ed.Name())) + tui.Status("⚡", fmt.Sprintf("Workspace already running, opening %s", ed.Name())) } if !dryRun && !noOpen { - return ed.LaunchRemote(alias, target.WorkspacePath) + return ed.LaunchRemote(alias, workspacePath) } if dryRun || noOpen { - remoteURL := fmt.Sprintf("ssh://%s/%s", alias, strings.TrimLeft(target.WorkspacePath, "/")) + remoteURL := fmt.Sprintf("ssh://%s/%s", alias, strings.TrimLeft(workspacePath, "/")) output := map[string]string{ "target": resolvedTargetName, - "codespace": selected.Name, + "workspace": selected.Name, + "provider": selected.Provider, "sshAlias": alias, "remoteUrl": remoteURL, } @@ -511,48 +520,34 @@ func run(configPath, targetName, codespaceName, editorFlag string, noOpen, dryRu } // Ensure SSH connectivity. + if selected, err = manager.StartWorkspace(selected); err != nil { + return err + } if interactive { - if err := tui.RunWithSpinner("Waiting for codespace SSH", func() error { - return codespace.EnsureReachable(runner, selected.Name) + if err := tui.RunWithSpinner("Waiting for workspace SSH", func() error { + return manager.EnsureReachable(selected) }); err != nil { return err } } else { - if err := codespace.EnsureReachable(runner, selected.Name); err != nil { + if err := manager.EnsureReachable(selected); err != nil { return err } } - // Get SSH config. - var sshCfg string + paths := sshconfig.ResolvePaths() + var sshAlias string if interactive { - sshCfg, err = tui.RunWithSpinnerResult("Fetching SSH config", func() (string, error) { - return codespace.GetSSHConfig(runner, selected.Name) + sshAlias, err = tui.RunWithSpinnerResult("Preparing SSH config", func() (string, error) { + return manager.PrepareSSH(paths, selected) }) } else { - sshCfg, err = codespace.GetSSHConfig(runner, selected.Name) + sshAlias, err = manager.PrepareSSH(paths, selected) } if err != nil { return err } - sshAlias, err := sshconfig.ParsePrimaryHostAlias(sshCfg) - if err != nil { - return err - } - - // Write SSH config. - paths := sshconfig.ResolvePaths() - if err := os.MkdirAll(paths.IncludeDir, 0700); err != nil { - return err - } - if err := sshconfig.EnsureConfigIncludesGenerated(paths.MainConfigPath); err != nil { - return err - } - if err := sshconfig.WriteCodespaceConfig(paths.IncludeDir, selected.Name, sshCfg); err != nil { - return err - } - // Configure editor-specific settings (e.g. Zed's settings.json). nickname := editor.ResolveNickname( target.ZedNickname, @@ -560,7 +555,7 @@ func run(configPath, targetName, codespaceName, editorFlag string, noOpen, dryRu selected.DisplayName, resolvedTargetName, ) - if err := ed.ConfigureConnection(sshAlias, target.WorkspacePath, nickname, target.UploadBinaryOverSSH); err != nil { + if err := ed.ConfigureConnection(sshAlias, workspacePath, nickname, target.UploadBinaryOverSSH); err != nil { return err } @@ -569,10 +564,11 @@ func run(configPath, targetName, codespaceName, editorFlag string, noOpen, dryRu } if dryRun || noOpen { - remoteURL := fmt.Sprintf("ssh://%s/%s", sshAlias, strings.TrimLeft(target.WorkspacePath, "/")) + remoteURL := fmt.Sprintf("ssh://%s/%s", sshAlias, strings.TrimLeft(workspacePath, "/")) output := map[string]string{ "target": resolvedTargetName, - "codespace": selected.Name, + "workspace": selected.Name, + "provider": selected.Provider, "sshAlias": sshAlias, "remoteUrl": remoteURL, "editor": ed.Name(), @@ -585,12 +581,12 @@ func run(configPath, targetName, codespaceName, editorFlag string, noOpen, dryRu // Launch editor. if interactive { if err := tui.RunWithSpinner(fmt.Sprintf("Launching %s", ed.Name()), func() error { - return ed.LaunchRemote(sshAlias, target.WorkspacePath) + return ed.LaunchRemote(sshAlias, workspacePath) }); err != nil { return err } } else { - if err := ed.LaunchRemote(sshAlias, target.WorkspacePath); err != nil { + if err := ed.LaunchRemote(sshAlias, workspacePath); err != nil { return err } } @@ -649,8 +645,40 @@ func mergeRepos(base, extra []string) []string { return result } +func applyWorkspaceDefaults(target config.Target, ws provider.Workspace) config.Target { + if target.Repository == "" && ws.Repository != "" { + target.Repository = ws.Repository + } + if target.WorkspacePath == "" { + target.WorkspacePath = guessWorkspacePath(target, &ws) + } + return target +} + +func guessWorkspacePath(target config.Target, ws *provider.Workspace) string { + if target.WorkspacePath != "" { + return target.WorkspacePath + } + if ws != nil && ws.Provider == provider.NameCoder { + return "/workspaces/" + ws.Name + } + if target.Repository != "" { + parts := strings.SplitN(target.Repository, "/", 2) + return "/workspaces/" + parts[len(parts)-1] + } + if ws != nil && ws.Name != "" { + return "/workspaces/" + ws.Name + } + return "/workspaces" +} + +func isWorkspaceRunning(ws provider.Workspace) bool { + state := strings.ToLower(ws.State) + return state == "available" || state == "ready" || state == "running" || state == "connected" +} + // targetForRepo finds a config target matching the repo, or builds a default. -func targetForRepo(cfg *config.Config, repo string) (config.Target, string) { +func targetForRepo(cfg *config.Config, repo, _ string) (config.Target, string) { if cfg != nil { for name, t := range cfg.Targets { if t.Repository == repo { @@ -667,9 +695,9 @@ func targetForRepo(cfg *config.Config, repo string) (config.Target, string) { }, repo } -// runSelectionTUI runs the codespace selector without back support (static target mode). -func runSelectionTUI(codespaces []codespace.Codespace, target config.Target, dryRun bool) (*codespace.Codespace, *codespace.Codespace, error) { - model := tui.NewSelectModel(codespaces, target, dryRun, false) +// runSelectionTUI runs the workspace selector without back support (static target mode). +func runSelectionTUI(workspaces []provider.Workspace, target config.Target, dryRun bool) (*provider.Workspace, *provider.Workspace, error) { + model := tui.NewSelectModel(workspaces, target, dryRun, false) p := tea.NewProgram(model, tea.WithMouseCellMotion()) finalModel, err := p.Run() if err != nil { @@ -685,15 +713,15 @@ func runSelectionTUI(codespaces []codespace.Codespace, target config.Target, dry } if result.Selected == nil && dryRun { - return nil, nil, fmt.Errorf("no matching codespace exists and --dry-run forbids creating one") + return nil, nil, fmt.Errorf("no matching workspace exists and --dry-run forbids creating one") } return result.Selected, nil, nil } -// runSelectionTUIWithBack runs the codespace selector with back support (dynamic mode). -func runSelectionTUIWithBack(codespaces []codespace.Codespace, target config.Target, dryRun bool) (*codespace.Codespace, bool, *codespace.Codespace, error) { - model := tui.NewSelectModel(codespaces, target, dryRun, true) +// runSelectionTUIWithBack runs the workspace selector with back support (dynamic mode). +func runSelectionTUIWithBack(workspaces []provider.Workspace, target config.Target, dryRun bool) (*provider.Workspace, bool, *provider.Workspace, error) { + model := tui.NewSelectModel(workspaces, target, dryRun, true) p := tea.NewProgram(model, tea.WithMouseCellMotion()) finalModel, err := p.Run() if err != nil { @@ -712,23 +740,23 @@ func runSelectionTUIWithBack(codespaces []codespace.Codespace, target config.Tar } if result.Selected == nil && dryRun { - return nil, false, nil, fmt.Errorf("no matching codespace exists and --dry-run forbids creating one") + return nil, false, nil, fmt.Errorf("no matching workspace exists and --dry-run forbids creating one") } return result.Selected, false, nil, nil } -func deleteCodespaceWithSpinner(runner codespace.GHRunner, name string) error { - return tui.RunWithSpinner("Deleting codespace "+name, func() error { - return codespace.DeleteCodespace(runner, name) +func deleteWorkspaceWithSpinner(manager provider.Manager, name string) error { + return tui.RunWithSpinner("Deleting workspace "+name, func() error { + return manager.DeleteWorkspace(name) }) } -func removeCodespace(codespaces []codespace.Codespace, name string) []codespace.Codespace { - var result []codespace.Codespace - for _, cs := range codespaces { - if cs.Name != name { - result = append(result, cs) +func removeWorkspace(workspaces []provider.Workspace, name string) []provider.Workspace { + var result []provider.Workspace + for _, ws := range workspaces { + if ws.Name != name { + result = append(result, ws) } } return result diff --git a/modules/home-manager.nix b/modules/home-manager.nix index 2dfa5ba..0fdad2a 100644 --- a/modules/home-manager.nix +++ b/modules/home-manager.nix @@ -90,6 +90,44 @@ let default = null; description = "Time-of-day to pre-warm codespace (e.g. 08:00)."; }; + + coder = lib.mkOption { + type = lib.types.nullOr (lib.types.submodule ({ ... }: { + options = { + template = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = "Coder template name used to create the workspace."; + }; + + workspaceName = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = "Exact Coder workspace name for reuse and creation."; + }; + + parameters = lib.mkOption { + type = lib.types.attrsOf lib.types.str; + default = { }; + description = "Coder template parameters passed as --parameter name=value."; + }; + + stopAfter = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = "Auto-stop duration passed to coder create (for example 8h)."; + }; + + organization = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = "Override the default Coder organization for this target."; + }; + }; + })); + default = null; + description = "Coder-specific target settings."; + }; }; }; @@ -97,12 +135,22 @@ let configJSON = builtins.toJSON (filterNulls { defaultTarget = cfg.defaultTarget; + workspaceProvider = cfg.workspaceProvider; editor = cfg.editor; + providers = filterNulls { + coder = lib.optionalAttrs (cfg.providers.coder.organization != null) { + organization = cfg.providers.coder.organization; + }; + }; targets = lib.mapAttrs (_: target: filterNulls { inherit (target) repository branch displayName codespaceName workspacePath machine location devcontainerPath idleTimeout retentionPeriod uploadBinaryOverSsh zedNickname autoStop preWarm; + coder = if target.coder == null then null else filterNulls { + inherit (target.coder) template workspaceName stopAfter organization; + parameters = if target.coder.parameters == { } then null else target.coder.parameters; + }; }) cfg.targets; daemon = lib.optionalAttrs cfg.daemon.enable (filterNulls { hotkey = cfg.daemon.hotkey; @@ -147,6 +195,18 @@ in description = "Editor to use for opening codespaces (zed or neovim). Defaults to zed."; }; + workspaceProvider = lib.mkOption { + type = lib.types.nullOr (lib.types.enum [ "github" "coder" ]); + default = null; + description = "Workspace provider to use globally. Defaults to github."; + }; + + providers.coder.organization = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = "Default Coder organization name or UUID."; + }; + targets = lib.mkOption { type = lib.types.attrsOf (lib.types.submodule targetModule); default = { }; From 3edcd03fbcc12b8852b83c5b359d0c086dbd93dd Mon Sep 17 00:00:00 2001 From: linus kendall Date: Thu, 7 May 2026 14:27:57 +0530 Subject: [PATCH 2/2] Add xdg-open forwarder plan --- docs/xdg-open-forwarder-plan.md | 253 ++++++++++++++++++++++++++++++++ 1 file changed, 253 insertions(+) create mode 100644 docs/xdg-open-forwarder-plan.md diff --git a/docs/xdg-open-forwarder-plan.md b/docs/xdg-open-forwarder-plan.md new file mode 100644 index 0000000..68a6a66 --- /dev/null +++ b/docs/xdg-open-forwarder-plan.md @@ -0,0 +1,253 @@ +# xdg-open Forwarder Plan + +This plan describes an opt-in `xdg-open`-style forwarder for remote workspaces +launched by Cosmonaut. The goal is that a command run inside a Coder workspace +or GitHub Codespace can open URLs and supported resources on the local macOS +machine that is running the Cosmonaut applet. + +## Goals + +- Make remote `xdg-open https://example.com` open on the local Mac. +- Keep the local open endpoint private to the active workspace session. +- Reuse Cosmonaut's existing workspace launch, SSH setup, daemon, and provider + abstractions. +- Start with a focused Coder-compatible MVP, then generalize to GitHub + Codespaces. + +## Non-Goals + +- Do not expose a network listener beyond localhost. +- Do not open arbitrary shell commands from the remote workspace. +- Do not require users to hand-edit remote shell startup files for the MVP. +- Do not make this feature mandatory for all launches. + +## Architecture + +1. Cosmonaut starts a local HTTP open server from the daemon. +2. When a workspace is launched, Cosmonaut starts a reverse SSH tunnel from the + remote workspace back to that local server. +3. Cosmonaut installs or updates a remote `xdg-open`-compatible shim. +4. The remote shim sends open requests to the tunneled localhost endpoint. +5. The local daemon validates the request and invokes macOS `/usr/bin/open`. + +The effective request path is: + +```text +remote xdg-open + -> http://127.0.0.1:/open + -> ssh -R tunnel + -> Cosmonaut daemon on 127.0.0.1: + -> /usr/bin/open +``` + +## Configuration + +Add an optional target-level config block: + +```jsonc +{ + "targets": { + "demo": { + "workspaceProvider": "coder", + "workspacePath": "/workspaces/demo", + "openForward": { + "enabled": true, + "remotePort": 17890, + "installShim": true + } + } + } +} +``` + +Suggested fields: + +- `enabled`: opt into the feature for this target. +- `remotePort`: remote loopback port that the shim calls. +- `installShim`: whether Cosmonaut should install `~/.local/bin/xdg-open`. +- `allowedSchemes`: optional list of URI schemes. Default to `http`, `https`, + and `mailto`. + +Implementation files likely touched: + +- `internal/config/config.go` +- `dist/cosmonaut.config.example.json` +- `docs/config.md` +- `modules/home-manager.nix` + +## Local Open Server + +Create a small package, probably `internal/openforward`, responsible for: + +- Starting a localhost-only HTTP server. +- Registering per-workspace sessions and tokens. +- Validating incoming requests. +- Calling the platform opener. + +Endpoint: + +```http +POST /open +Content-Type: application/json +``` + +Body: + +```json +{ + "target": "https://example.com", + "token": "session-token" +} +``` + +Validation rules: + +- Token must match an active workspace session. +- Target must be non-empty. +- Target scheme must be allowed. +- Requests with multiple targets are rejected. +- The server binds only to `127.0.0.1`. + +macOS opener: + +```sh +/usr/bin/open +``` + +Linux can be left as a later platform-specific implementation. + +## Reverse SSH Tunnel + +Add a daemon-managed tunnel component mirroring the shape of +`internal/daemon/port_forward.go`. + +For each active workspace: + +```sh +ssh -N -R 127.0.0.1::127.0.0.1: +``` + +Responsibilities: + +- Keep one tunnel per workspace. +- Reuse the provider-neutral SSH alias returned by `PrepareSSH`. +- Restart or surface errors if the tunnel exits unexpectedly. +- Stop the tunnel when the workspace session is no longer active. + +Likely new component: + +- `internal/daemon/open_forward.go` +- `internal/daemon/open_forward_test.go` + +## Remote Shim + +Install a small executable at `~/.local/bin/xdg-open` when configured. + +Initial shell-based shim: + +```sh +#!/bin/sh +set -eu + +target="${1:-}" +if [ -z "$target" ]; then + echo "xdg-open: missing target" >&2 + exit 2 +fi + +exec curl -fsS \ + -H "Content-Type: application/json" \ + -d "{\"target\":\"$target\",\"token\":\"$COSMONAUT_OPEN_TOKEN\"}" \ + "$COSMONAUT_OPEN_URL" +``` + +The production version should avoid fragile shell JSON escaping. Good options: + +- Install a tiny static `cosmonaut-open` helper. +- Render a shell shim that uses `python3 -c` or another structured encoder when + available. +- Keep `xdg-open` as a thin wrapper around that helper. + +Remote environment values: + +```sh +COSMONAUT_OPEN_URL=http://127.0.0.1:17890/open +COSMONAUT_OPEN_TOKEN= +``` + +Cosmonaut can also write: + +```text +~/.config/cosmonaut/open-forward.env +``` + +## Launch Flow Integration + +Hook into both launch paths after SSH is prepared and before the editor opens: + +- CLI path in `main.go` +- applet path in `internal/daemon/gui_flow.go` + +Sequence: + +1. Resolve or create workspace. +2. Start workspace. +3. Ensure SSH is reachable. +4. Prepare SSH config and get `sshAlias`. +5. If open forwarding is enabled: + - start or reuse local open server + - create session token + - start reverse SSH tunnel + - install or refresh remote shim +6. Configure editor connection. +7. Launch editor. +8. Track editor and tunnel lifecycle. + +## Provider Notes + +Coder is the best MVP target because Cosmonaut already has Coder workspace +support and SSH aliases from `coder config-ssh`. + +GitHub Codespaces can use the same tunnel and shim approach after SSH aliasing is +prepared. Codespaces may need extra care around devcontainer lifecycle and +whether `~/.local/bin` wins over the system `xdg-open` in PATH. + +## Security Defaults + +- Feature is disabled by default. +- Local server listens only on `127.0.0.1`. +- Remote tunnel binds only on remote `127.0.0.1`. +- Token is random per workspace session. +- Token is never logged. +- Allowed schemes default to `http`, `https`, and `mailto`. +- `file:` should require explicit opt-in. +- No shell command execution is accepted from the remote side. + +## Tests + +Unit tests: + +- Config parsing and defaults. +- Allowed and rejected URI schemes. +- Token validation. +- Tunnel command construction. +- Shim rendering. +- macOS opener command construction with a fake runner. + +Integration smoke test: + +1. Launch a Coder workspace with `openForward.enabled`. +2. Confirm the reverse tunnel starts. +3. Run remote `xdg-open https://example.com`. +4. In test mode, assert the local daemon receives the target and would call + `/usr/bin/open https://example.com`. + +## MVP Milestones + +1. Add config shape and docs. +2. Implement local open server with fakeable opener. +3. Implement daemon-managed reverse tunnel. +4. Install remote shim over SSH. +5. Wire Coder launch flow. +6. Add tests for validation, command construction, and shim install. +7. Extend to GitHub Codespaces once the Coder flow is stable.