Skip to content

Commit ef795da

Browse files
Merge pull request #115 from open-source-cloud/feat/active-context-and-branding
feat(cli): active-context model, use/context, header & logo (spec 30 phase 1)
2 parents a31bc0b + 2c84c89 commit ef795da

12 files changed

Lines changed: 590 additions & 19 deletions

File tree

‎internal/branding/branding.go‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
// Package branding renders the devstack ASCII logo for help and version banners
2+
// (spec 30). It is decorative only; callers gate it off under --json/--quiet so
3+
// the machine-output contract (ARCHITECTURE §7.9) always holds.
4+
package branding
5+
6+
import (
7+
_ "embed"
8+
"strings"
9+
)
10+
11+
//go:embed logo.txt
12+
var logo string
13+
14+
// Tagline is the one-line description shown under the logo.
15+
const Tagline = "Docker dev environments with shared infrastructure"
16+
17+
// Logo returns the raw ASCII logo, trailing newlines trimmed.
18+
func Logo() string { return strings.TrimRight(logo, "\n") }
19+
20+
// Banner returns the logo, a tagline, and the version, ready to print above help.
21+
func Banner(version string) string {
22+
return Logo() + "\n\n " + Tagline + " · " + version + "\n"
23+
}

‎internal/branding/logo.txt‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
_ _ _
2+
__| | _____ _| |_ __ _ ___| | __
3+
/ _` |/ _ \ \ / / __/ _` |/ __| |/ /
4+
| (_| | __/\ V /| || (_| | (__| <
5+
\__,_|\___| \_/ \__\__,_|\___|_|\_\

‎internal/cli/activectx.go‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package cli
2+
3+
import (
4+
"os"
5+
6+
"github.com/open-source-cloud/devstack/internal/config"
7+
"github.com/open-source-cloud/devstack/internal/state"
8+
)
9+
10+
// resolveActiveProject picks the default project for a command when none was given
11+
// on the --project flag (spec 30 active-context resolution). Precedence:
12+
//
13+
// DEVSTACK_PROJECT env → the persisted active context → the single/first project.
14+
//
15+
// The persisted active project is only honored when its workspace_root matches the
16+
// workspace discovered at command time, so a stale pointer never leaks a project
17+
// into a different workspace. Any candidate must name a real project in the model.
18+
// db may be nil (callers without a ledger handle); persisted resolution is skipped.
19+
func resolveActiveProject(m *config.Model, db *state.DB) string {
20+
names := sortedProjectNames(m)
21+
inModel := func(p string) bool { return p != "" && contains(names, p) }
22+
23+
if env := os.Getenv("DEVSTACK_PROJECT"); inModel(env) {
24+
return env
25+
}
26+
if db != nil {
27+
if a, ok, err := db.ActiveContext(); err == nil && ok {
28+
if a.WorkspaceRoot == m.Root && inModel(a.Project) {
29+
return a.Project
30+
}
31+
}
32+
}
33+
if len(names) > 0 {
34+
return names[0]
35+
}
36+
return ""
37+
}

‎internal/cli/context.go‎

Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
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

Comments
 (0)