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
13 changes: 13 additions & 0 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -133,5 +133,18 @@ case ":${PATH}:" in
printf " export PATH=\"%s:\$PATH\"\n" "$install_dir" >&2 ;;
esac

# --- shell-integration hint ------------------------------------------------
# The eval hook puts the install dir on PATH, loads completions, and (the point)
# lets `${BINARY} use` switch your shell's workspace/project. Opt-in — we only
# print the line for the detected shell; we never edit your rc. The \$(...) is an
# escaped literal for the user to copy.
_ds_shell="$(basename "${SHELL:-sh}")"
case "$_ds_shell" in
zsh) info "shell integration — add to ~/.zshrc: eval \"\$(${BINARY} shell-init zsh)\"" ;;
bash) info "shell integration — add to ~/.bashrc: eval \"\$(${BINARY} shell-init bash)\"" ;;
fish) info "shell integration — add to ~/.config/fish/config.fish: ${BINARY} shell-init fish | source" ;;
*) info "shell integration (zsh/bash/fish): eval \"\$(${BINARY} shell-init <shell>)\" — enables '${BINARY} use' to switch your shell" ;;
esac

printf '\n%s%s installed.%s run %s%s doctor%s to verify your environment.\n' \
"$GREEN" "$BINARY" "$RESET" "$BOLD" "$BINARY" "$RESET"
67 changes: 57 additions & 10 deletions internal/cli/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,14 @@ package cli

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

"github.com/spf13/cobra"

"github.com/open-source-cloud/devstack/internal/config"
"github.com/open-source-cloud/devstack/internal/lock"
"github.com/open-source-cloud/devstack/internal/version"
"github.com/open-source-cloud/devstack/internal/workspace"
Expand Down Expand Up @@ -76,14 +79,40 @@ func renderContextHeader(cmd *cobra.Command, mgr *workspace.Manager, g *GlobalOp
fmt.Fprintf(cmd.OutOrStdout(), "devstack · %s\n\n", strings.Join(parts, " · "))
}

// renderPromptSegment prints a terse "workspace" or "workspace:project" segment
// for a shell prompt (spec 30). It is deliberately cheap: it discovers the
// workspace from config only (no Docker client, no ledger) and reads the project
// from DEVSTACK_PROJECT (set by the `use` shell hook). Outside a workspace it
// prints nothing, so the prompt segment simply disappears.
func renderPromptSegment(cmd *cobra.Command) error {
cwd, err := os.Getwd()
if err != nil {
return nil
}
m, err := config.Load(cwd)
if err != nil {
return nil
}
seg := m.Workspace.Name
if p := os.Getenv("DEVSTACK_PROJECT"); p != "" {
seg += ":" + p
}
fmt.Fprintln(cmd.OutOrStdout(), seg)
return nil
}

// 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{
var promptMode bool
cmd := &cobra.Command{
Use: "context",
Short: "Show the active workspace, project, role and Docker context",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
if promptMode {
return renderPromptSegment(cmd)
}
mgr, closeFn, err := buildManager(cmd)
if err != nil {
return err
Expand All @@ -110,6 +139,8 @@ func newContextCmd(g *GlobalOpts) *cobra.Command {
return tw.Flush()
},
}
cmd.Flags().BoolVar(&promptMode, "prompt", false, "terse single-line output for a shell prompt segment (cheap; no Docker/ledger)")
return cmd
}

// newUseCmd wires `use [name]`: set the active project (or switch to a registered
Expand All @@ -119,6 +150,7 @@ func newContextCmd(g *GlobalOpts) *cobra.Command {
func newUseCmd(g *GlobalOpts) *cobra.Command {
var project string
var printScript bool
var shell string
cmd := &cobra.Command{
Use: "use [name]",
Short: "Set the active project (or switch workspace); persists across terminals",
Expand Down Expand Up @@ -162,9 +194,13 @@ func newUseCmd(g *GlobalOpts) *cobra.Command {
targetRoot = root
}
default:
// Bare `use`: report current context + candidates (the fuzzy picker
// TUI lands in the shell-integration phase).
return printUseHint(cmd, mgr, projects)
// Bare `use`: report current context + candidates. Under --print
// the hint goes to stderr so stdout stays an eval-safe (empty) script.
out := cmd.OutOrStdout()
if printScript {
out = cmd.ErrOrStderr()
}
return printUseHint(out, mgr, projects)
}

if err := lock.WithLock(cmd.Context(), mgr.LockPath, func() error {
Expand All @@ -174,7 +210,7 @@ func newUseCmd(g *GlobalOpts) *cobra.Command {
}

if printScript {
emitUseScript(cmd, targetRoot, targetProject)
emitUseScript(cmd, targetRoot, targetProject, shell)
return nil
}
if g.JSON {
Expand All @@ -194,6 +230,8 @@ func newUseCmd(g *GlobalOpts) *cobra.Command {
}
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")
cmd.Flags().StringVar(&shell, "shell", "", "syntax for --print output: fish (else POSIX sh/zsh/bash)")
_ = cmd.Flags().MarkHidden("shell")
return cmd
}

Expand All @@ -211,11 +249,21 @@ func lookupWorkspaceRoot(mgr *workspace.Manager, name string) (string, bool, err
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) {
// emitUseScript writes the eval script the shell wrapper runs: POSIX (sh/zsh/bash)
// by default, fish syntax when shell=="fish". Single-quoted values are valid in
// both. The `devstack` wrapper from `shell-init` eval's this to mutate the shell.
func emitUseScript(cmd *cobra.Command, root, project, shell string) {
w := cmd.OutOrStdout()
fmt.Fprintf(w, "cd %s\n", shellQuote(root))
if shell == "fish" {
fmt.Fprintf(w, "set -gx DEVSTACK_WORKSPACE %s\n", shellQuote(root))
if project != "" {
fmt.Fprintf(w, "set -gx DEVSTACK_PROJECT %s\n", shellQuote(project))
} else {
fmt.Fprintln(w, "set -e DEVSTACK_PROJECT")
}
return
}
fmt.Fprintf(w, "export DEVSTACK_WORKSPACE=%s\n", shellQuote(root))
if project != "" {
fmt.Fprintf(w, "export DEVSTACK_PROJECT=%s\n", shellQuote(project))
Expand All @@ -226,8 +274,7 @@ func emitUseScript(cmd *cobra.Command, root, project string) {

// 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()
func printUseHint(w io.Writer, mgr *workspace.Manager, projects []string) error {
active := resolveActiveProject(mgr.Model, mgr.DB)
if active != "" {
fmt.Fprintf(w, "active project: %s\n", active)
Expand Down
1 change: 1 addition & 0 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ func NewRootCmd(opts Options) *cobra.Command {
newStatusCmd(g),
newUseCmd(g),
newContextCmd(g),
newShellInitCmd(g),
newLogsCmd(g),
newDashboardCmd(g),
newDnsCmd(g),
Expand Down
92 changes: 92 additions & 0 deletions internal/cli/shell_init.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package cli

import (
"fmt"
"os"
"path/filepath"

"github.com/spf13/cobra"
)

// newShellInitCmd wires `shell-init <shell>` (spec 30): print the shell code a
// user eval's from their rc to get (a) the install dir on PATH, (b) a `devstack`
// wrapper function that eval's `use`'s output so switching mutates the live shell
// — the "execute, don't print" fix — (c) completion loading, and (d) an opt-in
// prompt-segment helper. The wrapper is named after the invoked binary, so aliases
// (`rq shell-init zsh`) generate a matching `rq()` wrapper.
func newShellInitCmd(g *GlobalOpts) *cobra.Command {
return &cobra.Command{
Use: "shell-init <zsh|bash|fish>",
Short: "Print shell integration to eval (PATH, `use` wrapper, completions, prompt)",
Long: "Print shell integration code for the given shell. Add it to your shell rc:\n\n" +
" zsh/bash: eval \"$(devstack shell-init zsh)\"\n" +
" fish: devstack shell-init fish | source\n\n" +
"It puts the install dir on PATH, defines a `devstack` wrapper so `devstack use`\n" +
"changes your current shell's directory + DEVSTACK_* env, loads completions, and\n" +
"defines a `devstack_prompt` helper you can splice into your prompt.",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
name := cmd.Root().Name()
bindir := ""
if exe, err := os.Executable(); err == nil {
bindir = filepath.Dir(exe)
}
script, err := shellInitScript(name, args[0], bindir)
if err != nil {
return err
}
fmt.Fprint(cmd.OutOrStdout(), script)
return nil
},
}
}

const posixShellInit = `# devstack shell integration — add to your rc: eval "$(%[1]s shell-init %[3]s)"
case ":$PATH:" in
*":%[2]s:"*) ;;
*) export PATH="%[2]s:$PATH" ;;
esac
%[1]s() {
if [ "$1" = use ]; then
local _ds_out
_ds_out="$(command %[1]s "$@" --print --shell %[3]s)" || return $?
eval "$_ds_out"
else
command %[1]s "$@"
fi
}
if command -v %[1]s >/dev/null 2>&1; then
source <(command %[1]s completion %[3]s) 2>/dev/null || true
fi
%[1]s_prompt() { command %[1]s context --prompt 2>/dev/null; }
`

const fishShellInit = `# devstack shell integration — add to config.fish: %[1]s shell-init fish | source
if not contains %[2]s $PATH
set -gx PATH %[2]s $PATH
end
function %[1]s
if test "$argv[1]" = use
command %[1]s $argv --print --shell fish | source
else
command %[1]s $argv
end
end
command %[1]s completion fish | source
function %[1]s_prompt
command %[1]s context --prompt 2>/dev/null
end
`

// shellInitScript renders the integration for one shell. name is the wrapper
// function name (the invoked binary), bindir the install dir to add to PATH.
func shellInitScript(name, shell, bindir string) (string, error) {
switch shell {
case "zsh", "bash":
return fmt.Sprintf(posixShellInit, name, bindir, shell), nil
case "fish":
return fmt.Sprintf(fishShellInit, name, bindir), nil
default:
return "", fmt.Errorf("unsupported shell %q (want zsh, bash or fish)", shell)
}
}
88 changes: 88 additions & 0 deletions internal/cli/shell_init_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package cli

import (
"bytes"
"strings"
"testing"
)

func TestShellInitRegistered(t *testing.T) {
if !findCmd(t, "shell-init") {
t.Fatal("shell-init must be a real RunE command")
}
}

func TestShellInitScript(t *testing.T) {
cases := map[string][]string{
"zsh": {
"devstack() {",
"--print --shell zsh",
"completion zsh",
"devstack_prompt()",
`export PATH="/opt/bin:$PATH"`,
},
"bash": {"devstack() {", "--print --shell bash", "completion bash"},
"fish": {
"function devstack",
"--print --shell fish | source",
"completion fish | source",
"function devstack_prompt",
"set -gx PATH /opt/bin",
},
}
for shell, wants := range cases {
got, err := shellInitScript("devstack", shell, "/opt/bin")
if err != nil {
t.Fatalf("%s: %v", shell, err)
}
for _, w := range wants {
if !strings.Contains(got, w) {
t.Errorf("%s script missing %q\n---\n%s", shell, w, got)
}
}
}
if _, err := shellInitScript("devstack", "tcsh", "/opt/bin"); err == nil {
t.Error("unsupported shell should error")
}
}

// TestShellInitUsesInvokedName verifies the wrapper is named after the binary, so
// an alias (rq) gets an rq() wrapper.
func TestShellInitUsesInvokedName(t *testing.T) {
got, err := shellInitScript("rq", "zsh", "/opt/bin")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(got, "rq() {") || !strings.Contains(got, "command rq") {
t.Errorf("alias wrapper not named rq:\n%s", got)
}
}

func TestContextPromptSegment(t *testing.T) {
root := writeWS(t,
"apiVersion: devstack/v1\nkind: Workspace\nname: smoke\n"+
"projects:\n - { name: api, path: api }\n",
map[string]string{"api": "apiVersion: devstack/v1\nkind: Project\nname: api\nservices:\n app: { template: node.vite }\n"},
)
t.Chdir(root)

run := func() string {
c := NewRootCmd(Options{})
var buf bytes.Buffer
c.SetOut(&buf)
c.SetErr(&buf)
c.SetArgs([]string{"context", "--prompt"})
if err := c.Execute(); err != nil {
t.Fatalf("execute: %v", err)
}
return strings.TrimSpace(buf.String())
}

if got := run(); got != "smoke" {
t.Errorf("prompt without project = %q, want smoke", got)
}
t.Setenv("DEVSTACK_PROJECT", "api")
if got := run(); got != "smoke:api" {
t.Errorf("prompt with project = %q, want smoke:api", got)
}
}
Loading