|
| 1 | +package cli |
| 2 | + |
| 3 | +import ( |
| 4 | + "fmt" |
| 5 | + "strings" |
| 6 | + "text/tabwriter" |
| 7 | + |
| 8 | + "github.com/spf13/cobra" |
| 9 | + |
| 10 | + "github.com/open-source-cloud/devstack/internal/lock" |
| 11 | + "github.com/open-source-cloud/devstack/internal/version" |
| 12 | + "github.com/open-source-cloud/devstack/internal/workspace" |
| 13 | +) |
| 14 | + |
| 15 | +// contextInfo is the resolved active-context projection printed by `context` and |
| 16 | +// used for the console header + shell prompt segment (spec 30). |
| 17 | +type contextInfo struct { |
| 18 | + Workspace string `json:"workspace"` |
| 19 | + Root string `json:"root"` |
| 20 | + Project string `json:"project,omitempty"` |
| 21 | + Role string `json:"role,omitempty"` |
| 22 | + Docker string `json:"dockerContext"` |
| 23 | + Backend string `json:"backend"` |
| 24 | + Version string `json:"version"` |
| 25 | +} |
| 26 | + |
| 27 | +// resolveContext gathers the active-context projection from a manager. All fields |
| 28 | +// come from the already-loaded model + ledger; no new data sources. |
| 29 | +func resolveContext(mgr *workspace.Manager) contextInfo { |
| 30 | + proj := resolveActiveProject(mgr.Model, mgr.DB) |
| 31 | + ci := contextInfo{ |
| 32 | + Workspace: mgr.Model.Workspace.Name, |
| 33 | + Root: mgr.Model.Root, |
| 34 | + Project: proj, |
| 35 | + Docker: mgr.Docker.ContextName(), |
| 36 | + Backend: backendLabel(mgr), |
| 37 | + Version: version.Version, |
| 38 | + } |
| 39 | + if proj != "" { |
| 40 | + // The per-project Postgres role is the sanitized project name (hyphens → |
| 41 | + // underscores), matching provision.pgIdent. Display-only. |
| 42 | + ci.Role = strings.ReplaceAll(proj, "-", "_") |
| 43 | + } |
| 44 | + return ci |
| 45 | +} |
| 46 | + |
| 47 | +// backendLabel describes where the shared stack runs: "local", or the remote |
| 48 | +// docker context / host endpoint. |
| 49 | +func backendLabel(mgr *workspace.Manager) string { |
| 50 | + b := mgr.Model.Workspace.Backend |
| 51 | + if b == nil || !b.IsRemote() { |
| 52 | + return "local" |
| 53 | + } |
| 54 | + if b.Context != "" { |
| 55 | + return "remote:" + b.Context |
| 56 | + } |
| 57 | + return "remote:" + b.Host |
| 58 | +} |
| 59 | + |
| 60 | +// renderContextHeader prints a compact active-context line atop human command |
| 61 | +// output (status/up). Suppressed under --json/--quiet — the update-notice |
| 62 | +// precedent (root.go). Best-effort: a nil/empty manager prints nothing. |
| 63 | +func renderContextHeader(cmd *cobra.Command, mgr *workspace.Manager, g *GlobalOpts) { |
| 64 | + if g.JSON || g.Quiet || mgr == nil { |
| 65 | + return |
| 66 | + } |
| 67 | + ci := resolveContext(mgr) |
| 68 | + parts := []string{"workspace " + ci.Workspace} |
| 69 | + if ci.Project != "" { |
| 70 | + parts = append(parts, "project "+ci.Project) |
| 71 | + } |
| 72 | + if ci.Role != "" { |
| 73 | + parts = append(parts, "role "+ci.Role) |
| 74 | + } |
| 75 | + parts = append(parts, "docker "+ci.Docker, ci.Version) |
| 76 | + fmt.Fprintf(cmd.OutOrStdout(), "devstack · %s\n\n", strings.Join(parts, " · ")) |
| 77 | +} |
| 78 | + |
| 79 | +// newContextCmd wires the read-only `context` command: print the resolved active |
| 80 | +// workspace/project/role/docker-context/version. Lock-free. |
| 81 | +func newContextCmd(g *GlobalOpts) *cobra.Command { |
| 82 | + return &cobra.Command{ |
| 83 | + Use: "context", |
| 84 | + Short: "Show the active workspace, project, role and Docker context", |
| 85 | + Args: cobra.NoArgs, |
| 86 | + RunE: func(cmd *cobra.Command, _ []string) error { |
| 87 | + mgr, closeFn, err := buildManager(cmd) |
| 88 | + if err != nil { |
| 89 | + return err |
| 90 | + } |
| 91 | + defer closeFn() |
| 92 | + ci := resolveContext(mgr) |
| 93 | + if g.JSON { |
| 94 | + return writeJSON(cmd, ci) |
| 95 | + } |
| 96 | + tw := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0) |
| 97 | + fmt.Fprintf(tw, "workspace\t%s\n", ci.Workspace) |
| 98 | + fmt.Fprintf(tw, "root\t%s\n", ci.Root) |
| 99 | + if ci.Project != "" { |
| 100 | + fmt.Fprintf(tw, "project\t%s\n", ci.Project) |
| 101 | + } else { |
| 102 | + fmt.Fprintf(tw, "project\t(none — run `devstack use <project>`)\n") |
| 103 | + } |
| 104 | + if ci.Role != "" { |
| 105 | + fmt.Fprintf(tw, "db role\t%s\n", ci.Role) |
| 106 | + } |
| 107 | + fmt.Fprintf(tw, "docker context\t%s\n", ci.Docker) |
| 108 | + fmt.Fprintf(tw, "backend\t%s\n", ci.Backend) |
| 109 | + fmt.Fprintf(tw, "version\t%s\n", ci.Version) |
| 110 | + return tw.Flush() |
| 111 | + }, |
| 112 | + } |
| 113 | +} |
| 114 | + |
| 115 | +// newUseCmd wires `use [name]`: set the active project (or switch to a registered |
| 116 | +// workspace). It persists the active context to the ledger under the flock, and |
| 117 | +// with --print emits an eval-able script (cd + export) so a shell wrapper can make |
| 118 | +// the switch mutate the live shell (spec 30; the wrapper ships with `shell-init`). |
| 119 | +func newUseCmd(g *GlobalOpts) *cobra.Command { |
| 120 | + var project string |
| 121 | + var printScript bool |
| 122 | + cmd := &cobra.Command{ |
| 123 | + Use: "use [name]", |
| 124 | + Short: "Set the active project (or switch workspace); persists across terminals", |
| 125 | + Long: "Set the active workspace/project. Names a project in the current workspace, or a\n" + |
| 126 | + "registered workspace to switch to. The choice is persisted (per Docker context) and\n" + |
| 127 | + "becomes the default for db/s3/queue/... commands. A child process cannot change its\n" + |
| 128 | + "parent shell, so `--print` emits an eval-able script (cd + export DEVSTACK_*); the\n" + |
| 129 | + "`devstack` shell wrapper from `devstack shell-init` runs that for you.", |
| 130 | + Args: cobra.MaximumNArgs(1), |
| 131 | + RunE: func(cmd *cobra.Command, args []string) error { |
| 132 | + mgr, closeFn, err := buildManager(cmd) |
| 133 | + if err != nil { |
| 134 | + return err |
| 135 | + } |
| 136 | + defer closeFn() |
| 137 | + |
| 138 | + targetRoot := mgr.Model.Root |
| 139 | + targetProject := "" |
| 140 | + projects := sortedProjectNames(mgr.Model) |
| 141 | + |
| 142 | + switch { |
| 143 | + case project != "": |
| 144 | + if !contains(projects, project) { |
| 145 | + return fmt.Errorf("no project %q in workspace %q", project, mgr.Model.Workspace.Name) |
| 146 | + } |
| 147 | + targetProject = project |
| 148 | + case len(args) == 1: |
| 149 | + name := args[0] |
| 150 | + switch { |
| 151 | + case contains(projects, name): |
| 152 | + targetProject = name |
| 153 | + default: |
| 154 | + // Try a registered workspace by name. |
| 155 | + root, ok, err := lookupWorkspaceRoot(mgr, name) |
| 156 | + if err != nil { |
| 157 | + return err |
| 158 | + } |
| 159 | + if !ok { |
| 160 | + return fmt.Errorf("no project or registered workspace named %q", name) |
| 161 | + } |
| 162 | + targetRoot = root |
| 163 | + } |
| 164 | + default: |
| 165 | + // Bare `use`: report current context + candidates (the fuzzy picker |
| 166 | + // TUI lands in the shell-integration phase). |
| 167 | + return printUseHint(cmd, mgr, projects) |
| 168 | + } |
| 169 | + |
| 170 | + if err := lock.WithLock(cmd.Context(), mgr.LockPath, func() error { |
| 171 | + return mgr.DB.SetActiveContext(targetRoot, targetProject) |
| 172 | + }); err != nil { |
| 173 | + return err |
| 174 | + } |
| 175 | + |
| 176 | + if printScript { |
| 177 | + emitUseScript(cmd, targetRoot, targetProject) |
| 178 | + return nil |
| 179 | + } |
| 180 | + if g.JSON { |
| 181 | + return writeJSON(cmd, map[string]string{"workspace": targetRoot, "project": targetProject}) |
| 182 | + } |
| 183 | + if !g.Quiet { |
| 184 | + w := cmd.OutOrStdout() |
| 185 | + if targetProject != "" { |
| 186 | + fmt.Fprintf(w, "active project → %s\n", targetProject) |
| 187 | + } else { |
| 188 | + fmt.Fprintf(w, "active workspace → %s\n", targetRoot) |
| 189 | + } |
| 190 | + fmt.Fprintln(w, "tip: add `eval \"$(devstack shell-init zsh)\"` so `use` also cd's your shell") |
| 191 | + } |
| 192 | + return nil |
| 193 | + }, |
| 194 | + } |
| 195 | + cmd.Flags().StringVar(&project, "project", "", "force-select a project in the current workspace") |
| 196 | + cmd.Flags().BoolVar(&printScript, "print", false, "emit an eval-able shell script (cd + export) instead of persisting only") |
| 197 | + return cmd |
| 198 | +} |
| 199 | + |
| 200 | +// lookupWorkspaceRoot resolves a registered workspace name to its root (lock-free). |
| 201 | +func lookupWorkspaceRoot(mgr *workspace.Manager, name string) (string, bool, error) { |
| 202 | + rows, err := mgr.DB.ListWorkspaces() |
| 203 | + if err != nil { |
| 204 | + return "", false, err |
| 205 | + } |
| 206 | + for _, w := range rows { |
| 207 | + if w.Name == name { |
| 208 | + return w.Root, true, nil |
| 209 | + } |
| 210 | + } |
| 211 | + return "", false, nil |
| 212 | +} |
| 213 | + |
| 214 | +// emitUseScript writes the POSIX eval script the shell wrapper runs. Fish support |
| 215 | +// is handled by the `shell-init` wrapper (it re-emits in fish syntax). |
| 216 | +func emitUseScript(cmd *cobra.Command, root, project string) { |
| 217 | + w := cmd.OutOrStdout() |
| 218 | + fmt.Fprintf(w, "cd %s\n", shellQuote(root)) |
| 219 | + fmt.Fprintf(w, "export DEVSTACK_WORKSPACE=%s\n", shellQuote(root)) |
| 220 | + if project != "" { |
| 221 | + fmt.Fprintf(w, "export DEVSTACK_PROJECT=%s\n", shellQuote(project)) |
| 222 | + } else { |
| 223 | + fmt.Fprintln(w, "unset DEVSTACK_PROJECT") |
| 224 | + } |
| 225 | +} |
| 226 | + |
| 227 | +// printUseHint reports the current active context and the selectable projects when |
| 228 | +// `use` is invoked with no target. |
| 229 | +func printUseHint(cmd *cobra.Command, mgr *workspace.Manager, projects []string) error { |
| 230 | + w := cmd.OutOrStdout() |
| 231 | + active := resolveActiveProject(mgr.Model, mgr.DB) |
| 232 | + if active != "" { |
| 233 | + fmt.Fprintf(w, "active project: %s\n", active) |
| 234 | + } else { |
| 235 | + fmt.Fprintln(w, "active project: (none)") |
| 236 | + } |
| 237 | + if len(projects) == 0 { |
| 238 | + fmt.Fprintln(w, "no projects in this workspace") |
| 239 | + return nil |
| 240 | + } |
| 241 | + fmt.Fprintln(w, "projects:") |
| 242 | + for _, p := range projects { |
| 243 | + marker := " " |
| 244 | + if p == active { |
| 245 | + marker = "* " |
| 246 | + } |
| 247 | + fmt.Fprintf(w, "%s%s\n", marker, p) |
| 248 | + } |
| 249 | + fmt.Fprintln(w, "run `devstack use <project>` to select one") |
| 250 | + return nil |
| 251 | +} |
| 252 | + |
| 253 | +// shellQuote single-quotes a value for safe eval in POSIX shells. |
| 254 | +func shellQuote(s string) string { |
| 255 | + return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" |
| 256 | +} |
0 commit comments