Skip to content

Commit 7b8fa42

Browse files
Merge branch 'main' into feat/task-runner
2 parents df0b4ac + 7175140 commit 7b8fa42

28 files changed

Lines changed: 529 additions & 108 deletions

docs/guide/command-reference.md

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,8 @@ covered in [global-flags.md](global-flags.md).
1212
> `queue`/`topic`/`stream rm`) **require `--yes`** when run under `--json` or
1313
> non-interactively.
1414
15-
> **Promotion note.** `shared expose` / `shared ports` are the current spelling;
16-
> a branch is promoting them to top-level `expose` / `ports` (mid-refactor). Use
17-
> the `shared`-prefixed forms today.
15+
> **Naming note.** `expose` / `ports` exist at the top level (canonical) and as
16+
> `shared expose` / `shared ports` (backward-compatible aliases) — same behavior.
1817
1918
## Lifecycle
2019

docs/guide/concepts.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,8 +87,8 @@ devstack shared ports # show the published ports + connection stri
8787
```
8888

8989
See [Shared services & host access](shared-services.md) for the details. (These
90-
two verbs are currently spelled `shared expose` / `shared ports`; a branch is
91-
promoting them to top-level `expose` / `ports`.)
90+
verbs work as top-level `expose` / `ports` and as `shared expose` / `shared ports`
91+
aliases.)
9292

9393
## Stateless CLI, no daemon
9494

docs/guide/shared-services.md

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -132,11 +132,10 @@ Key properties:
132132
- **Opt-in per service** — with no arguments every exposable engine is published;
133133
name engines to narrow it.
134134

135-
> **Naming note.** The current, shipping spelling is `devstack shared expose` and
136-
> `devstack shared ports` (documented here). These are being **promoted to
137-
> top-level** commands — `devstack expose` and `devstack ports` — on a branch
138-
> that is still mid-refactor. When that lands, the top-level forms become the
139-
> canonical spelling; the `shared`-prefixed forms are what you use today.
135+
> **Naming note.** These are available **both** as top-level `devstack expose` /
136+
> `devstack ports` (the canonical spelling) **and** as `devstack shared expose` /
137+
> `devstack shared ports` (backward-compatible aliases). Use whichever you prefer —
138+
> they run the same logic.
140139

141140
## Seeing the ports: `shared ports`
142141

docs/guide/templates.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,12 @@ what you need. A missing **required** param fails fast.
4646
| `php.nginx` | — (parent) | — | — | `phpVersion` ("8.3") |
4747
| `php.laravel.nginx` | — (extends `php.nginx`) | — | — | `appEnv` (local) |
4848
| `node.vite` | — | — | — | `nodeVersion` ("20"); runs `npm run dev` |
49+
| `node.express` | — | — | — | `nodeVersion` ("20"); `npm run dev`, hot reload |
50+
| `node.nestjs` | — | — | — | `nodeVersion` ("20"); `nest start --watch` |
51+
| `node.next` | — | — | — | `nodeVersion` ("20"); `next dev`, `WATCHPACK_POLLING` |
52+
| `react.vite` | — | — | — | `nodeVersion` ("20"); Vite HMR |
53+
| `bun.app` | — | — | — | `bunVersion` ("1"); `bun run dev` |
54+
| `turborepo` | — | — | — | `nodeVersion` ("20"); `turbo run dev` (monorepo) |
4955
| `kafka` (Redpanda) | kafka | host, port, adminPort | 9092 | `image` |
5056
| `nats` | nats | host, port, monitorPort | 4222 | `version` ("2") |
5157
| `rabbitmq` | amqp | host, port, mgmtPort | 5672 | `version` ("3"), `user` (devstack) |

docs/guide/whats-next.md

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -81,19 +81,20 @@ devstack run test lint # build+lint in parallel, then test
8181
devstack run test --dry-run
8282
```
8383

84-
### Framework dev servers with watch mode (Next.js, NestJS)
84+
### Framework dev servers with watch mode (Next.js, NestJS) — ✅ shipped
8585

86-
**Partially covered — the pattern already works, first-class templates don't ship
87-
yet.** The `node.vite` built-in builds from `build/Dockerfile` and runs
88-
`command: ["npm", "run", "dev"]` in watch mode. Next.js and NestJS are just **new
89-
templates you can author right now** with `template new`, bind-mounting source
90-
for hot reload:
86+
**Now built-in.** `node.express`, `node.nestjs`, `node.next`, `react.vite`,
87+
`bun.app`, and `turborepo` ship as templates with **dev-mode hot reload** — they
88+
bind-mount the project source (`..:/app`), keep an anonymous `node_modules`
89+
volume, run `install` + the dev server, and set the framework's file-watch polling
90+
env for WSL2. See [templates.md](templates.md). You can still author your own
91+
variant with `template new`; a minimal `node.next`-style template:
9192

9293
```bash
9394
devstack template new node.next --kind app
9495
```
9596

96-
A minimal `node.next` template that runs `next dev` with hot reload:
97+
A minimal template that runs `next dev` with hot reload:
9798

9899
```yaml
99100
# ~/.devstack/templates/node.next/template.yaml

install.sh

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,5 +133,18 @@ case ":${PATH}:" in
133133
printf " export PATH=\"%s:\$PATH\"\n" "$install_dir" >&2 ;;
134134
esac
135135

136+
# --- shell-integration hint ------------------------------------------------
137+
# The eval hook puts the install dir on PATH, loads completions, and (the point)
138+
# lets `${BINARY} use` switch your shell's workspace/project. Opt-in — we only
139+
# print the line for the detected shell; we never edit your rc. The \$(...) is an
140+
# escaped literal for the user to copy.
141+
_ds_shell="$(basename "${SHELL:-sh}")"
142+
case "$_ds_shell" in
143+
zsh) info "shell integration — add to ~/.zshrc: eval \"\$(${BINARY} shell-init zsh)\"" ;;
144+
bash) info "shell integration — add to ~/.bashrc: eval \"\$(${BINARY} shell-init bash)\"" ;;
145+
fish) info "shell integration — add to ~/.config/fish/config.fish: ${BINARY} shell-init fish | source" ;;
146+
*) info "shell integration (zsh/bash/fish): eval \"\$(${BINARY} shell-init <shell>)\" — enables '${BINARY} use' to switch your shell" ;;
147+
esac
148+
136149
printf '\n%s%s installed.%s run %s%s doctor%s to verify your environment.\n' \
137150
"$GREEN" "$BINARY" "$RESET" "$BOLD" "$BINARY" "$RESET"

internal/cli/context.go

Lines changed: 57 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,14 @@ package cli
22

33
import (
44
"fmt"
5+
"io"
6+
"os"
57
"strings"
68
"text/tabwriter"
79

810
"github.com/spf13/cobra"
911

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

82+
// renderPromptSegment prints a terse "workspace" or "workspace:project" segment
83+
// for a shell prompt (spec 30). It is deliberately cheap: it discovers the
84+
// workspace from config only (no Docker client, no ledger) and reads the project
85+
// from DEVSTACK_PROJECT (set by the `use` shell hook). Outside a workspace it
86+
// prints nothing, so the prompt segment simply disappears.
87+
func renderPromptSegment(cmd *cobra.Command) error {
88+
cwd, err := os.Getwd()
89+
if err != nil {
90+
return nil
91+
}
92+
m, err := config.Load(cwd)
93+
if err != nil {
94+
return nil
95+
}
96+
seg := m.Workspace.Name
97+
if p := os.Getenv("DEVSTACK_PROJECT"); p != "" {
98+
seg += ":" + p
99+
}
100+
fmt.Fprintln(cmd.OutOrStdout(), seg)
101+
return nil
102+
}
103+
79104
// newContextCmd wires the read-only `context` command: print the resolved active
80105
// workspace/project/role/docker-context/version. Lock-free.
81106
func newContextCmd(g *GlobalOpts) *cobra.Command {
82-
return &cobra.Command{
107+
var promptMode bool
108+
cmd := &cobra.Command{
83109
Use: "context",
84110
Short: "Show the active workspace, project, role and Docker context",
85111
Args: cobra.NoArgs,
86112
RunE: func(cmd *cobra.Command, _ []string) error {
113+
if promptMode {
114+
return renderPromptSegment(cmd)
115+
}
87116
mgr, closeFn, err := buildManager(cmd)
88117
if err != nil {
89118
return err
@@ -110,6 +139,8 @@ func newContextCmd(g *GlobalOpts) *cobra.Command {
110139
return tw.Flush()
111140
},
112141
}
142+
cmd.Flags().BoolVar(&promptMode, "prompt", false, "terse single-line output for a shell prompt segment (cheap; no Docker/ledger)")
143+
return cmd
113144
}
114145

115146
// newUseCmd wires `use [name]`: set the active project (or switch to a registered
@@ -119,6 +150,7 @@ func newContextCmd(g *GlobalOpts) *cobra.Command {
119150
func newUseCmd(g *GlobalOpts) *cobra.Command {
120151
var project string
121152
var printScript bool
153+
var shell string
122154
cmd := &cobra.Command{
123155
Use: "use [name]",
124156
Short: "Set the active project (or switch workspace); persists across terminals",
@@ -162,9 +194,13 @@ func newUseCmd(g *GlobalOpts) *cobra.Command {
162194
targetRoot = root
163195
}
164196
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)
197+
// Bare `use`: report current context + candidates. Under --print
198+
// the hint goes to stderr so stdout stays an eval-safe (empty) script.
199+
out := cmd.OutOrStdout()
200+
if printScript {
201+
out = cmd.ErrOrStderr()
202+
}
203+
return printUseHint(out, mgr, projects)
168204
}
169205

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

176212
if printScript {
177-
emitUseScript(cmd, targetRoot, targetProject)
213+
emitUseScript(cmd, targetRoot, targetProject, shell)
178214
return nil
179215
}
180216
if g.JSON {
@@ -194,6 +230,8 @@ func newUseCmd(g *GlobalOpts) *cobra.Command {
194230
}
195231
cmd.Flags().StringVar(&project, "project", "", "force-select a project in the current workspace")
196232
cmd.Flags().BoolVar(&printScript, "print", false, "emit an eval-able shell script (cd + export) instead of persisting only")
233+
cmd.Flags().StringVar(&shell, "shell", "", "syntax for --print output: fish (else POSIX sh/zsh/bash)")
234+
_ = cmd.Flags().MarkHidden("shell")
197235
return cmd
198236
}
199237

@@ -211,11 +249,21 @@ func lookupWorkspaceRoot(mgr *workspace.Manager, name string) (string, bool, err
211249
return "", false, nil
212250
}
213251

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) {
252+
// emitUseScript writes the eval script the shell wrapper runs: POSIX (sh/zsh/bash)
253+
// by default, fish syntax when shell=="fish". Single-quoted values are valid in
254+
// both. The `devstack` wrapper from `shell-init` eval's this to mutate the shell.
255+
func emitUseScript(cmd *cobra.Command, root, project, shell string) {
217256
w := cmd.OutOrStdout()
218257
fmt.Fprintf(w, "cd %s\n", shellQuote(root))
258+
if shell == "fish" {
259+
fmt.Fprintf(w, "set -gx DEVSTACK_WORKSPACE %s\n", shellQuote(root))
260+
if project != "" {
261+
fmt.Fprintf(w, "set -gx DEVSTACK_PROJECT %s\n", shellQuote(project))
262+
} else {
263+
fmt.Fprintln(w, "set -e DEVSTACK_PROJECT")
264+
}
265+
return
266+
}
219267
fmt.Fprintf(w, "export DEVSTACK_WORKSPACE=%s\n", shellQuote(root))
220268
if project != "" {
221269
fmt.Fprintf(w, "export DEVSTACK_PROJECT=%s\n", shellQuote(project))
@@ -226,8 +274,7 @@ func emitUseScript(cmd *cobra.Command, root, project string) {
226274

227275
// printUseHint reports the current active context and the selectable projects when
228276
// `use` is invoked with no target.
229-
func printUseHint(cmd *cobra.Command, mgr *workspace.Manager, projects []string) error {
230-
w := cmd.OutOrStdout()
277+
func printUseHint(w io.Writer, mgr *workspace.Manager, projects []string) error {
231278
active := resolveActiveProject(mgr.Model, mgr.DB)
232279
if active != "" {
233280
fmt.Fprintf(w, "active project: %s\n", active)

internal/cli/expose.go

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,12 @@ import (
99
"github.com/open-source-cloud/devstack/internal/orchestrate"
1010
)
1111

12-
// newSharedExposeCmd wires `shared expose [services...]` — publish the shared
13-
// engines on stable 127.0.0.1 host ports so GUI clients (DataGrip, a Redis/S3
14-
// browser, the RabbitMQ UI) can connect. Opt-in and loopback-only; it never
15-
// touches the deterministic generated compose (an up-time overlay). `--off`
16-
// removes the publish and returns the stack to DNS-only.
17-
func newSharedExposeCmd(g *GlobalOpts) *cobra.Command {
12+
// newExposeCmd wires the top-level `expose [services...]` (also `shared expose`) —
13+
// publish the shared engines on stable 127.0.0.1 host ports so GUI clients
14+
// (DataGrip, a Redis/S3 browser, the RabbitMQ UI) can connect. Opt-in and
15+
// loopback-only; it never touches the deterministic generated compose (an up-time
16+
// overlay). `--off` removes the publish and returns the stack to DNS-only.
17+
func newExposeCmd(g *GlobalOpts) *cobra.Command {
1818
var off bool
1919
cmd := &cobra.Command{
2020
Use: "expose [services...]",
@@ -53,9 +53,9 @@ func newSharedExposeCmd(g *GlobalOpts) *cobra.Command {
5353
return cmd
5454
}
5555

56-
// newSharedPortsCmd wires `shared ports` — the read-only projection of the
56+
// newPortsCmd wires `ports` (and `shared ports`) — the read-only projection of the
5757
// currently-published host ports + connection strings (lock-free snapshot).
58-
func newSharedPortsCmd(g *GlobalOpts) *cobra.Command {
58+
func newPortsCmd(g *GlobalOpts) *cobra.Command {
5959
return &cobra.Command{
6060
Use: "ports",
6161
Short: "Show the published 127.0.0.1 host ports for shared services (and connection strings)",

internal/cli/expose_test.go

Lines changed: 17 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -1,76 +1,23 @@
11
package cli
22

3-
import (
4-
"bytes"
5-
"strings"
6-
"testing"
7-
8-
"github.com/spf13/cobra"
9-
10-
"github.com/open-source-cloud/devstack/internal/orchestrate"
11-
)
12-
13-
func exposeFixture() []orchestrate.ExposedPort {
14-
return []orchestrate.ExposedPort{
15-
{Instance: "postgres", Engine: "postgres", Alias: "shared-postgres", Label: "postgres", Host: "127.0.0.1", Port: 55432, Container: 5432, Primary: true, URL: "postgres://devstack:devstack@127.0.0.1:55432/postgres?sslmode=disable"},
16-
{Instance: "minio", Engine: "minio", Alias: "shared-minio", Label: "console", Host: "127.0.0.1", Port: 59001, Container: 9001, Primary: false, URL: "http://127.0.0.1:59001"},
17-
}
18-
}
19-
20-
func TestRenderExposed_Table(t *testing.T) {
21-
var buf bytes.Buffer
22-
cmd := &cobra.Command{}
23-
cmd.SetOut(&buf)
24-
if err := renderExposed(cmd, &GlobalOpts{}, exposeFixture()); err != nil {
25-
t.Fatal(err)
26-
}
27-
out := buf.String()
28-
for _, want := range []string{"shared-postgres", "55432", "shared-minio (console)", "59001", "per-project database:"} {
29-
if !strings.Contains(out, want) {
30-
t.Errorf("table missing %q:\n%s", want, out)
3+
import "testing"
4+
5+
// TestExposePortsRegistered asserts expose/ports are available both at the top
6+
// level (the promotion) and under `shared` (backward-compatible aliases).
7+
func TestExposePortsRegistered(t *testing.T) {
8+
root := NewRootCmd(Options{})
9+
for _, path := range [][]string{
10+
{"expose"}, {"ports"},
11+
{"shared", "expose"}, {"shared", "ports"},
12+
} {
13+
c, _, err := root.Find(path)
14+
if err != nil || c.RunE == nil {
15+
t.Errorf("%v not registered as a real command: %v", path, err)
3116
}
3217
}
33-
}
34-
35-
func TestRenderExposed_JSON(t *testing.T) {
36-
var buf bytes.Buffer
37-
cmd := &cobra.Command{}
38-
cmd.SetOut(&buf)
39-
if err := renderExposed(cmd, &GlobalOpts{JSON: true}, exposeFixture()); err != nil {
40-
t.Fatal(err)
41-
}
42-
out := buf.String()
43-
if !strings.Contains(out, "\"exposed\"") || !strings.Contains(out, "\"port\": 55432") {
44-
t.Errorf("json missing fields:\n%s", out)
45-
}
46-
}
47-
48-
func TestRenderExposed_Quiet(t *testing.T) {
49-
var buf bytes.Buffer
50-
cmd := &cobra.Command{}
51-
cmd.SetOut(&buf)
52-
if err := renderExposed(cmd, &GlobalOpts{Quiet: true}, exposeFixture()); err != nil {
53-
t.Fatal(err)
54-
}
55-
out := strings.TrimSpace(buf.String())
56-
// Quiet emits only the connection URLs, one per line.
57-
lines := strings.Split(out, "\n")
58-
if len(lines) != 2 || !strings.HasPrefix(lines[0], "postgres://") {
59-
t.Errorf("quiet should print only URLs, got:\n%s", out)
60-
}
61-
}
62-
63-
// TestSharedExposeCommandsRegistered guards that `shared expose` and
64-
// `shared ports` are wired into the shared command tree.
65-
func TestSharedExposeCommandsRegistered(t *testing.T) {
66-
sh := newSharedCmd(&GlobalOpts{})
67-
have := map[string]bool{}
68-
for _, c := range sh.Commands() {
69-
have[c.Name()] = true
70-
}
71-
for _, want := range []string{"expose", "ports", "status", "gc", "doctor"} {
72-
if !have[want] {
73-
t.Errorf("shared subcommand %q not registered", want)
74-
}
18+
// The top-level expose carries its --off flag.
19+
c, _, _ := root.Find([]string{"expose"})
20+
if c.Flags().Lookup("off") == nil {
21+
t.Error("expose missing --off flag")
7522
}
7623
}

0 commit comments

Comments
 (0)