feat(dew-win): dew up --with <service> (podman-backed services)#39
Conversation
Add `dew up --with postgres,redis,...` to the Windows wrapper, reusing the internal/services Registry so service definitions match macOS. Each service runs as a rootful podman container on the distro's host network (--network=host — required, since podman's netavark bridge conflicts with WSL2 mirrored networking), is health-gated via ListenProbeCmd, and its ConnString is printed. Services live alongside the dev server (which holds the distro open) and are removed when it exits or on Ctrl+C. podman is apk-installed into the distro on first --with use. Tests: unit (parseUpArgs, splitCSV, serviceContainer, podmanRunArgs) and a real-machine smoke check that starts redis, verifies it's reachable on Windows localhost:6379, and confirms the container is cleaned up after the dev server exits. Verified end-to-end on Windows 11 + WSL2.
There was a problem hiding this comment.
Pull request overview
Adds Windows/WSL2 parity with macOS “dev services” by introducing dew up --with <service> in dew-win, starting selected services as podman containers on host networking and cleaning them up when the dev server exits (or on Ctrl+C).
Changes:
- Implement
dew up --with <csv>parsing and service lifecycle management (podman install, run, readiness probe, cleanup) incmd/dew-win. - Add unit tests for new parsing / argv construction helpers.
- Extend the Windows smoke test to validate redis service readiness, localhost reachability, and cleanup behavior.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
| cmd/dew-win/main.go | Adds --with parsing, podman-backed service start/stop with readiness gating, and dev-server-exit cleanup. |
| cmd/dew-win/main_test.go | Adds unit tests for argument parsing and podman argv construction. |
| cmd/dew-win/smoke-test.ps1 | Adds an e2e WSL2 smoke test for dew up --with redis lifecycle and cleanup. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| @@ -573,7 +741,7 @@ | |||
| // when interpolating WSL paths into an inline `sh -c` string. The | |||
| // WSL path is alphanumeric + / + . in practice but a user's project | |||
| // dir could theoretically contain a single-quote in a parent dir | |||
| // name (rare), so escape per POSIX: '...'\''...' . | |||
| // name (rare), so escape per POSIX: '...'\”...' . | |||
There was a problem hiding this comment.
Adopted in bce3c74 — restored the literal POSIX escape '...'''...' (the curly quote had regressed); verified the committed blob is ASCII.
| func parseUpArgs(args []string) (dir string, with []string, err error) { | ||
| dir = "." | ||
| for i := 0; i < len(args); i++ { | ||
| a := args[i] | ||
| switch { | ||
| case a == "--with": | ||
| if i+1 >= len(args) { | ||
| return "", nil, fmt.Errorf("--with needs a comma-separated service list") | ||
| } | ||
| with = append(with, splitCSV(args[i+1])...) | ||
| i++ | ||
| case strings.HasPrefix(a, "--with="): | ||
| with = append(with, splitCSV(strings.TrimPrefix(a, "--with="))...) | ||
| case strings.HasPrefix(a, "--"): | ||
| return "", nil, fmt.Errorf("unknown flag %q for dew up", a) | ||
| default: | ||
| dir = a | ||
| } | ||
| } | ||
| return dir, with, nil | ||
| } |
There was a problem hiding this comment.
Adopted in 9090b58 — parseUpArgs now rejects a second positional dir (e.g. 'dew up ./a ./b') instead of silently taking the last one.
| }{ | ||
| {"empty", nil, ".", nil, false}, | ||
| {"dir only", []string{"./app"}, "./app", nil, false}, | ||
| {"with space form", []string{"--with", "postgres,redis"}, ".", []string{"postgres", "redis"}, false}, | ||
| {"with equals form", []string{"--with=postgres"}, ".", []string{"postgres"}, false}, | ||
| {"dir and with", []string{"./app", "--with", "redis"}, "./app", []string{"redis"}, false}, | ||
| {"csv trims blanks", []string{"--with", "a, b ,,c"}, ".", []string{"a", "b", "c"}, false}, | ||
| {"with needs arg", []string{"--with"}, "", nil, true}, | ||
| {"unknown flag", []string{"--bogus"}, "", nil, true}, | ||
| } |
There was a problem hiding this comment.
Adopted in 9090b58 — added the flag-then-dir ordering case ('--with redis ./app') and a multiple-dirs rejection case to TestParseUpArgs.
| if (-not $svc.HasExited) { $svc.WaitForExit(25000) | Out-Null } | ||
| Start-Sleep -Seconds 2 |
There was a problem hiding this comment.
Adopted in 93256dc — the --with smoke check now Kill()s the dev server if it overruns the wait, so a running dew up can't leak into later checks.
parseUpArgs overwrote dir on every positional arg, so `dew up ./a ./b` silently took ./b. Reject a second positional as a user error. Add test cases for the flag-then-dir ordering and the multiple-dir rejection.
The escape example regressed to a curly quote; restore the literal '...'\''...' that shellQuote emits.
If the dev server doesn't self-exit within the wait, Kill() it so a running dew up doesn't leak into later checks.
| stop, err := startWithServices(withServices) | ||
| if err != nil { | ||
| return err | ||
| } |
There was a problem hiding this comment.
Adopted in b761fa3 — added a defer stop() right after startWithServices so early error returns (winPathToWSL, npm install, missing script) no longer leak the containers; the os.Exit branch keeps its explicit stop() since defers don't run through os.Exit.
| go func() { | ||
| <-sig | ||
| fmt.Println("\ndew: stopping services...") | ||
| stop() | ||
| os.Exit(130) | ||
| }() |
There was a problem hiding this comment.
Adopted in b761fa3 — the Ctrl+C handler now terminates the distro after stopping the containers, so the dev server (a child wsl.exe that exiting dew won't reliably kill on Windows) comes down too.
Two leaks in the --with path: (1) cmdUp only called stop() after dev.Run(), so early error returns (winPathToWSL, npm install, missing script) left the podman containers running — add a defer stop() right after startWithServices and keep the explicit stop() only on the os.Exit branch that would skip the defer; (2) the Ctrl+C handler stopped the containers but not the dev server, which runs as a child wsl.exe that exiting dew doesn't reliably kill on Windows — terminate the distro in the handler so the dev server comes down too.
| for _, name := range withServices { | ||
| if services.Lookup(name) == nil { | ||
| return fmt.Errorf("unknown service %q (available: %s)", name, strings.Join(services.Names(), ", ")) | ||
| } | ||
| } |
There was a problem hiding this comment.
Adopted in ed3da7d — sort services.Names() before joining so the available-services list in the error is deterministic.
| case a == "--with": | ||
| if i+1 >= len(args) { | ||
| return "", nil, fmt.Errorf("--with needs a comma-separated service list") | ||
| } | ||
| with = append(with, splitCSV(args[i+1])...) | ||
| i++ | ||
| case strings.HasPrefix(a, "--with="): | ||
| with = append(with, splitCSV(strings.TrimPrefix(a, "--with="))...) |
There was a problem hiding this comment.
Adopted in ed3da7d — an explicitly-empty --with (--with= or a blank value) now errors with the same 'needs a comma-separated service list' message instead of silently starting nothing.
Reject an explicitly-empty --with (`--with=`, `--with ' '`) instead of silently starting no services, and sort services.Names() before joining it into the unknown-service error so the 'available:' list is stable (it iterates a map). Add test cases for both.
| _, statErr := os.Stat(pkgPath) | ||
| hasProject := statErr == nil | ||
| if !hasProject && len(withServices) == 0 { | ||
| return fmt.Errorf("no package.json in %s — dew up on Windows supports Node-style projects; or use --with <service>", absDir) | ||
| } |
There was a problem hiding this comment.
Adopted in 1a1fc25 — dew up now returns the stat error unless it's os.IsNotExist, so a permission/IO problem on package.json no longer silently falls into the services-only path.
A permission/IO stat error on package.json was treated as 'no project', which with --with silently fell into the services-only path and masked the real failure. Return the stat error unless it's a plain os.IsNotExist.
| Usage: | ||
| dew setup Install/update WSL2 distro | ||
| dew up [dir] Detect a Node project + run its dev server in WSL2 | ||
| dew up --with <svc> Also start services (postgres,redis,mysql,mongo,minio) |
There was a problem hiding this comment.
Adopted in f9697af — the usage line now shows --with <svc,...> to reflect that it accepts a comma-separated list.
The usage placeholder <svc> implied a single service; --with accepts a comma-separated list, so show <svc,...>.
| for _, name := range withServices { | ||
| if services.Lookup(name) == nil { | ||
| // services.Names() iterates a map; sort for a stable message. | ||
| avail := services.Names() | ||
| sort.Strings(avail) | ||
| return fmt.Errorf("unknown service %q (available: %s)", name, strings.Join(avail, ", ")) | ||
| } | ||
| } |
There was a problem hiding this comment.
Adopted in 609079f — parseUpArgs now dedups the service list preserving first-seen order, so '--with redis,redis' starts redis once. Added a test case.
| if (-not $svc.HasExited) { $svc.WaitForExit(25000) | Out-Null } | ||
| if (-not $svc.HasExited) { $svc.Kill() } | ||
| Start-Sleep -Seconds 2 | ||
| Check "service container removed after dev server exits" { | ||
| -not ((wsl -d dew -e podman ps -a --format '{{.Names}}' | Out-String) -match 'dew-svc-redis') | ||
| } |
There was a problem hiding this comment.
Adopted in ef69c97 — the smoke check now polls for removal instead of a fixed sleep, and force-kills fall back to an explicit podman rm -f so nothing leaks into later checks.
`--with redis,redis` started the same container name twice — the second startService rm -f'd and restarted the first. Dedup the service list in parseUpArgs, preserving first-seen order (as the macOS CLI does). Add a test case.
Poll for container removal instead of a fixed 2s sleep (flaky on slow machines), and if the dev server had to be force-killed (which skips stop()), podman rm -f the container so it doesn't leak into the rest of the smoke run.
| absDir, err := filepath.Abs(dir) | ||
| if err != nil { | ||
| return fmt.Errorf("resolve %q: %w", dir, err) | ||
| } | ||
|
|
There was a problem hiding this comment.
Adopted in a32f6e7 — dew up now stats the project dir and requires it to be a directory, so a typoed path errors instead of silently falling into services-only mode; a real dir without package.json still runs services-only.
A typoed path with --with (e.g. `dew up --with redis ./typo`) was treated as project-less and silently fell into services-only mode. Stat absDir and require it to be a directory, so a bad path errors while a real dir without package.json still runs services-only.
| s := *services.Lookup(name) | ||
| fmt.Printf("dew: starting %s (%s)...\n", name, s.Image) |
There was a problem hiding this comment.
Adopted in d799f83 — startWithServices now nil-checks services.Lookup and returns an error for an unknown name instead of panicking (cmdUp still validates up front, but the function no longer depends on that).
startWithServices dereferenced services.Lookup(name) without a nil check. cmdUp validates names first so it can't be nil today, but a future caller could; return a normal error (the function already returns one) rather than panicking.
Summary
Add
dew up --with <service>to the Windows/WSL2 wrapper — the headlinedev-services feature, matching macOS
dew up --with.How it works
internal/servicesRegistry, so servicedefinitions (image, port, env, connstring) are identical to macOS.
--network=host.Host networking is required: podman's netavark bridge conflicts with WSL2
mirrored networking (the container dies). With host net + mirrored mode the
service port lands directly on the Windows host's
localhost— no portmapping needed.
services.ListenProbeCmdbefore printing the connstring.apk added into the distro on first--withuse (the minimalAlpine rootfs has no runtime baked in yet).
open). They're removed when the dev server exits, and a Ctrl+C handler stops
them too (the dev server's os.Exit / a services-only blocking wait would
otherwise skip cleanup).
--withalso works project-less (services only).Testing (unit + integration + e2e)
main_test.go, no wsl/podman):parseUpArgs,splitCSV,serviceContainer, andpodmanRunArgs(exact argv incl.--network=host,env-before-image, args-after-image ordering).
smoke-test.ps1, real WSL2): starts redis viadew up --with redis, asserts it reports ready, is reachable on Windowslocalhost:6379, the container is running, and — after the dev serverself-exits — the container is removed.
Verified on real Windows 11 + WSL2
dew up --with redis <proj>→ redis+PONGfrom Windows localhost, devserver serving,
podman psshowsdew-svc-redis, cleanup on exit confirmed.Notes / follow-ups
model;
DataDirvolumes are a follow-up.--withskips the apk install) isa natural follow-up on top of PR build(release): tailor the WSL2 rootfs (drop VM-only files, add wsl.conf) #38's tailored rootfs.