Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions internal/branding/branding.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// Package branding renders the devstack ASCII logo for help and version banners
// (spec 30). It is decorative only; callers gate it off under --json/--quiet so
// the machine-output contract (ARCHITECTURE §7.9) always holds.
package branding

import (
_ "embed"
"strings"
)

//go:embed logo.txt
var logo string

// Tagline is the one-line description shown under the logo.
const Tagline = "Docker dev environments with shared infrastructure"

// Logo returns the raw ASCII logo, trailing newlines trimmed.
func Logo() string { return strings.TrimRight(logo, "\n") }

// Banner returns the logo, a tagline, and the version, ready to print above help.
func Banner(version string) string {
return Logo() + "\n\n " + Tagline + " · " + version + "\n"
}
5 changes: 5 additions & 0 deletions internal/branding/logo.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
_ _ _
__| | _____ _| |_ __ _ ___| | __
/ _` |/ _ \ \ / / __/ _` |/ __| |/ /
| (_| | __/\ V /| || (_| | (__| <
\__,_|\___| \_/ \__\__,_|\___|_|\_\
37 changes: 37 additions & 0 deletions internal/cli/activectx.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package cli

import (
"os"

"github.com/open-source-cloud/devstack/internal/config"
"github.com/open-source-cloud/devstack/internal/state"
)

// resolveActiveProject picks the default project for a command when none was given
// on the --project flag (spec 30 active-context resolution). Precedence:
//
// DEVSTACK_PROJECT env → the persisted active context → the single/first project.
//
// The persisted active project is only honored when its workspace_root matches the
// workspace discovered at command time, so a stale pointer never leaks a project
// into a different workspace. Any candidate must name a real project in the model.
// db may be nil (callers without a ledger handle); persisted resolution is skipped.
func resolveActiveProject(m *config.Model, db *state.DB) string {
names := sortedProjectNames(m)
inModel := func(p string) bool { return p != "" && contains(names, p) }

if env := os.Getenv("DEVSTACK_PROJECT"); inModel(env) {
return env
}
if db != nil {
if a, ok, err := db.ActiveContext(); err == nil && ok {
if a.WorkspaceRoot == m.Root && inModel(a.Project) {
return a.Project
}
}
}
if len(names) > 0 {
return names[0]
}
return ""
}
256 changes: 256 additions & 0 deletions internal/cli/context.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,256 @@
package cli

import (
"fmt"
"strings"
"text/tabwriter"

"github.com/spf13/cobra"

"github.com/open-source-cloud/devstack/internal/lock"
"github.com/open-source-cloud/devstack/internal/version"
"github.com/open-source-cloud/devstack/internal/workspace"
)

// contextInfo is the resolved active-context projection printed by `context` and
// used for the console header + shell prompt segment (spec 30).
type contextInfo struct {
Workspace string `json:"workspace"`
Root string `json:"root"`
Project string `json:"project,omitempty"`
Role string `json:"role,omitempty"`
Docker string `json:"dockerContext"`
Backend string `json:"backend"`
Version string `json:"version"`
}

// resolveContext gathers the active-context projection from a manager. All fields
// come from the already-loaded model + ledger; no new data sources.
func resolveContext(mgr *workspace.Manager) contextInfo {
proj := resolveActiveProject(mgr.Model, mgr.DB)
ci := contextInfo{
Workspace: mgr.Model.Workspace.Name,
Root: mgr.Model.Root,
Project: proj,
Docker: mgr.Docker.ContextName(),
Backend: backendLabel(mgr),
Version: version.Version,
}
if proj != "" {
// The per-project Postgres role is the sanitized project name (hyphens →
// underscores), matching provision.pgIdent. Display-only.
ci.Role = strings.ReplaceAll(proj, "-", "_")
}
return ci
}

// backendLabel describes where the shared stack runs: "local", or the remote
// docker context / host endpoint.
func backendLabel(mgr *workspace.Manager) string {
b := mgr.Model.Workspace.Backend
if b == nil || !b.IsRemote() {
return "local"
}
if b.Context != "" {
return "remote:" + b.Context
}
return "remote:" + b.Host
}

// renderContextHeader prints a compact active-context line atop human command
// output (status/up). Suppressed under --json/--quiet — the update-notice
// precedent (root.go). Best-effort: a nil/empty manager prints nothing.
func renderContextHeader(cmd *cobra.Command, mgr *workspace.Manager, g *GlobalOpts) {
if g.JSON || g.Quiet || mgr == nil {
return
}
ci := resolveContext(mgr)
parts := []string{"workspace " + ci.Workspace}
if ci.Project != "" {
parts = append(parts, "project "+ci.Project)
}
if ci.Role != "" {
parts = append(parts, "role "+ci.Role)
}
parts = append(parts, "docker "+ci.Docker, ci.Version)
fmt.Fprintf(cmd.OutOrStdout(), "devstack · %s\n\n", strings.Join(parts, " · "))
}

// newContextCmd wires the read-only `context` command: print the resolved active
// workspace/project/role/docker-context/version. Lock-free.
func newContextCmd(g *GlobalOpts) *cobra.Command {
return &cobra.Command{
Use: "context",
Short: "Show the active workspace, project, role and Docker context",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
mgr, closeFn, err := buildManager(cmd)
if err != nil {
return err
}
defer closeFn()
ci := resolveContext(mgr)
if g.JSON {
return writeJSON(cmd, ci)
}
tw := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0)
fmt.Fprintf(tw, "workspace\t%s\n", ci.Workspace)
fmt.Fprintf(tw, "root\t%s\n", ci.Root)
if ci.Project != "" {
fmt.Fprintf(tw, "project\t%s\n", ci.Project)
} else {
fmt.Fprintf(tw, "project\t(none — run `devstack use <project>`)\n")
}
if ci.Role != "" {
fmt.Fprintf(tw, "db role\t%s\n", ci.Role)
}
fmt.Fprintf(tw, "docker context\t%s\n", ci.Docker)
fmt.Fprintf(tw, "backend\t%s\n", ci.Backend)
fmt.Fprintf(tw, "version\t%s\n", ci.Version)
return tw.Flush()
},
}
}

// newUseCmd wires `use [name]`: set the active project (or switch to a registered
// workspace). It persists the active context to the ledger under the flock, and
// with --print emits an eval-able script (cd + export) so a shell wrapper can make
// the switch mutate the live shell (spec 30; the wrapper ships with `shell-init`).
func newUseCmd(g *GlobalOpts) *cobra.Command {
var project string
var printScript bool
cmd := &cobra.Command{
Use: "use [name]",
Short: "Set the active project (or switch workspace); persists across terminals",
Long: "Set the active workspace/project. Names a project in the current workspace, or a\n" +
"registered workspace to switch to. The choice is persisted (per Docker context) and\n" +
"becomes the default for db/s3/queue/... commands. A child process cannot change its\n" +
"parent shell, so `--print` emits an eval-able script (cd + export DEVSTACK_*); the\n" +
"`devstack` shell wrapper from `devstack shell-init` runs that for you.",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
mgr, closeFn, err := buildManager(cmd)
if err != nil {
return err
}
defer closeFn()

targetRoot := mgr.Model.Root
targetProject := ""
projects := sortedProjectNames(mgr.Model)

switch {
case project != "":
if !contains(projects, project) {
return fmt.Errorf("no project %q in workspace %q", project, mgr.Model.Workspace.Name)
}
targetProject = project
case len(args) == 1:
name := args[0]
switch {
case contains(projects, name):
targetProject = name
default:
// Try a registered workspace by name.
root, ok, err := lookupWorkspaceRoot(mgr, name)
if err != nil {
return err
}
if !ok {
return fmt.Errorf("no project or registered workspace named %q", name)
}
targetRoot = root
}
default:
// Bare `use`: report current context + candidates (the fuzzy picker
// TUI lands in the shell-integration phase).
return printUseHint(cmd, mgr, projects)
}

if err := lock.WithLock(cmd.Context(), mgr.LockPath, func() error {
return mgr.DB.SetActiveContext(targetRoot, targetProject)
}); err != nil {
return err
}

if printScript {
emitUseScript(cmd, targetRoot, targetProject)
return nil
}
if g.JSON {
return writeJSON(cmd, map[string]string{"workspace": targetRoot, "project": targetProject})
}
if !g.Quiet {
w := cmd.OutOrStdout()
if targetProject != "" {
fmt.Fprintf(w, "active project → %s\n", targetProject)
} else {
fmt.Fprintf(w, "active workspace → %s\n", targetRoot)
}
fmt.Fprintln(w, "tip: add `eval \"$(devstack shell-init zsh)\"` so `use` also cd's your shell")
}
return nil
},
}
cmd.Flags().StringVar(&project, "project", "", "force-select a project in the current workspace")
cmd.Flags().BoolVar(&printScript, "print", false, "emit an eval-able shell script (cd + export) instead of persisting only")
return cmd
}

// lookupWorkspaceRoot resolves a registered workspace name to its root (lock-free).
func lookupWorkspaceRoot(mgr *workspace.Manager, name string) (string, bool, error) {
rows, err := mgr.DB.ListWorkspaces()
if err != nil {
return "", false, err
}
for _, w := range rows {
if w.Name == name {
return w.Root, true, nil
}
}
return "", false, nil
}

// emitUseScript writes the POSIX eval script the shell wrapper runs. Fish support
// is handled by the `shell-init` wrapper (it re-emits in fish syntax).
func emitUseScript(cmd *cobra.Command, root, project string) {
w := cmd.OutOrStdout()
fmt.Fprintf(w, "cd %s\n", shellQuote(root))
fmt.Fprintf(w, "export DEVSTACK_WORKSPACE=%s\n", shellQuote(root))
if project != "" {
fmt.Fprintf(w, "export DEVSTACK_PROJECT=%s\n", shellQuote(project))
} else {
fmt.Fprintln(w, "unset DEVSTACK_PROJECT")
}
}

// printUseHint reports the current active context and the selectable projects when
// `use` is invoked with no target.
func printUseHint(cmd *cobra.Command, mgr *workspace.Manager, projects []string) error {
w := cmd.OutOrStdout()
active := resolveActiveProject(mgr.Model, mgr.DB)
if active != "" {
fmt.Fprintf(w, "active project: %s\n", active)
} else {
fmt.Fprintln(w, "active project: (none)")
}
if len(projects) == 0 {
fmt.Fprintln(w, "no projects in this workspace")
return nil
}
fmt.Fprintln(w, "projects:")
for _, p := range projects {
marker := " "
if p == active {
marker = "* "
}
fmt.Fprintf(w, "%s%s\n", marker, p)
}
fmt.Fprintln(w, "run `devstack use <project>` to select one")
return nil
}

// shellQuote single-quotes a value for safe eval in POSIX shells.
func shellQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}
Loading
Loading