From 90a5bb6bcea976d983cbf0fcb3b65c8670d2cfbd Mon Sep 17 00:00:00 2001 From: Yi-Ru Lin Date: Wed, 1 Jul 2026 07:52:42 -0500 Subject: [PATCH 01/12] feat(dew-win): dew up --with (podman-backed services) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- cmd/dew-win/main.go | 186 +++++++++++++++++++++++++++++++++++-- cmd/dew-win/main_test.go | 92 ++++++++++++++++++ cmd/dew-win/smoke-test.ps1 | 31 +++++++ 3 files changed, 300 insertions(+), 9 deletions(-) diff --git a/cmd/dew-win/main.go b/cmd/dew-win/main.go index 22f0a3f..aadfec8 100644 --- a/cmd/dew-win/main.go +++ b/cmd/dew-win/main.go @@ -31,11 +31,14 @@ import ( "net/http" "os" "os/exec" + "os/signal" "path/filepath" "runtime" "sort" "strings" "time" + + "github.com/solcreek/dew/internal/services" ) const ( @@ -95,6 +98,7 @@ func printUsage() { Usage: dew setup Install/update WSL2 distro dew up [dir] Detect a Node project + run its dev server in WSL2 + dew up --with Also start services (postgres,redis,mysql,mongo,minio) dew run [--] Run a one-shot command in the WSL2 distro dew exec Run a command inside the WSL2 distro dew vm start Ensure the WSL2 distro is running @@ -446,6 +450,143 @@ func cmdExec(args []string) error { return runPassthrough(cmd) } +// parseUpArgs extracts the project dir and any `--with ` / +// `--with=` services from `dew up` arguments. +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 +} + +// splitCSV splits a comma-separated list, dropping blanks. +func splitCSV(s string) []string { + var out []string + for _, p := range strings.Split(s, ",") { + if p = strings.TrimSpace(p); p != "" { + out = append(out, p) + } + } + return out +} + +// serviceContainer is the podman container name for a dew service. +func serviceContainer(name string) string { return "dew-svc-" + name } + +// ensurePodman installs podman in the distro on first --with use. The dew +// rootfs is minimal Alpine with no container runtime baked in, so apk-add it +// once (persisted on the distro's disk). +func ensurePodman() error { + if exec.Command("wsl", "-d", distroName, "--exec", "sh", "-c", "command -v podman").Run() == nil { + return nil + } + fmt.Println("dew: installing podman in the distro (first --with use)...") + add := exec.Command("wsl", "-d", distroName, "--exec", "sh", "-c", "apk add --no-cache podman") + add.Stdout, add.Stderr = os.Stdout, os.Stderr + if err := add.Run(); err != nil { + return fmt.Errorf("apk add podman: %w", err) + } + return nil +} + +// podmanRunArgs builds the wsl.exe argv that starts a service container on +// the distro's host network. Pure, so the exact flags — host networking, the +// -e env pairs, image, and trailing server args — are unit-testable. +func podmanRunArgs(s services.Service) []string { + args := []string{"-d", distroName, "--exec", "podman", "run", "-d", + "--name", serviceContainer(s.Name), "--network=host"} + for _, e := range s.Env { + args = append(args, "-e", e) + } + args = append(args, s.Image) + return append(args, s.Args...) +} + +// startService runs one service via podman on the distro's host network +// (required: rootful podman's bridge conflicts with WSL2 mirrored mode) and +// waits until its port is listening. Returns the client connection string. +func startService(s services.Service) (string, error) { + // Clear any stale container from a previous run. + exec.Command("wsl", "-d", distroName, "--exec", "podman", "rm", "-f", serviceContainer(s.Name)).Run() + + if out, err := exec.Command("wsl", podmanRunArgs(s)...).CombinedOutput(); err != nil { + return "", fmt.Errorf("podman run %s: %w: %s", s.Name, err, strings.TrimSpace(string(out))) + } + if err := waitForServicePort(s.Port); err != nil { + return "", fmt.Errorf("%s: %w", s.Name, err) + } + return services.ConnString(s, s.Port), nil +} + +// waitForServicePort polls ListenProbeCmd inside the distro until the port is +// listening. A host-network container binds the distro's netns, so the port +// shows up in the distro's own /proc/net/tcp. +func waitForServicePort(port int) error { + probe := services.ListenProbeCmd(port) + for i := 0; i < 120; i++ { // ~60s at 500ms + if exec.Command("wsl", "-d", distroName, "--exec", "sh", "-c", probe).Run() == nil { + return nil + } + time.Sleep(500 * time.Millisecond) + } + return fmt.Errorf("port %d not listening after 60s", port) +} + +// stopServices force-removes the service containers. Idempotent. +func stopServices(names []string) { + for _, n := range names { + exec.Command("wsl", "-d", distroName, "--exec", "podman", "rm", "-f", serviceContainer(n)).Run() + } +} + +// startWithServices starts the requested services and returns a cleanup func. +// It also installs a Ctrl+C handler that stops them and exits, since the dev +// server's os.Exit (or the services-only blocking wait) would otherwise skip +// cleanup. No-op when names is empty. +func startWithServices(names []string) (func(), error) { + stop := func() { stopServices(names) } + if len(names) == 0 { + return func() {}, nil + } + if err := ensurePodman(); err != nil { + return nil, err + } + sig := make(chan os.Signal, 1) + signal.Notify(sig, os.Interrupt) + go func() { + <-sig + fmt.Println("\ndew: stopping services...") + stop() + os.Exit(130) + }() + for _, name := range names { + s := *services.Lookup(name) + fmt.Printf("dew: starting %s (%s)...\n", name, s.Image) + conn, err := startService(s) + if err != nil { + stop() + return nil, err + } + fmt.Printf("dew: %s ready -> %s\n", name, conn) + } + return stop, nil +} + // cmdUp detects a Node-style project in dir (or cwd) and runs its // dev server inside the WSL2 distro. The project is reached via // the auto-mounted /mnt//... path; WSL2's mirrored @@ -460,26 +601,45 @@ func cmdExec(args []string) error { // Heavier project-aware behavior can land iteratively as Windows // users hit specific gaps. func cmdUp(args []string) error { - dir := "." - if len(args) > 0 && !strings.HasPrefix(args[0], "--") { - dir = args[0] + dir, withServices, err := parseUpArgs(args) + if err != nil { + return err + } + for _, name := range withServices { + if services.Lookup(name) == nil { + return fmt.Errorf("unknown service %q (available: %s)", name, strings.Join(services.Names(), ", ")) + } } absDir, err := filepath.Abs(dir) if err != nil { return fmt.Errorf("resolve %q: %w", dir, err) } - // Node-only fast check. If we ever support Python/Go/etc. - // projects on Windows, branch here per project type. + // The dev-server half is Node-only; with --with the project is optional + // (services can run on their own). pkgPath := filepath.Join(absDir, "package.json") - if _, err := os.Stat(pkgPath); err != nil { - return fmt.Errorf("no package.json in %s — dew up on Windows currently supports Node-style projects only", absDir) + _, 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 ", absDir) } if err := ensureDistro(); err != nil { return err } + stop, err := startWithServices(withServices) + if err != nil { + return err + } + + // Services-only: no project to run, so hold the distro open (which keeps + // the containers alive) until Ctrl+C, handled by startWithServices. + if !hasProject { + fmt.Println("dew: services running. Ctrl+C to stop.") + select {} + } + // Translate the Windows path to the WSL2 mount path // (e.g. C:\Users\foo\proj → /mnt/c/Users/foo/proj). Defer to // wslpath inside the distro so we don't have to mirror its @@ -524,7 +684,15 @@ func cmdUp(args []string) error { dev.Stdin = os.Stdin dev.Stdout = os.Stdout dev.Stderr = os.Stderr - return runPassthrough(dev) + runErr := dev.Run() + // Stop services after the dev server exits. runPassthrough's os.Exit + // can't be used here — it would skip this cleanup. + stop() + var ee *exec.ExitError + if errors.As(runErr, &ee) { + os.Exit(ee.ExitCode()) + } + return runErr } // winPathToWSL converts a Windows absolute path into its WSL2 @@ -573,7 +741,7 @@ func detectDevScript(pkgPath string) string { // 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: '...'\”...' . func shellQuote(s string) string { return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" } diff --git a/cmd/dew-win/main_test.go b/cmd/dew-win/main_test.go index 24ee7ca..075ffbc 100644 --- a/cmd/dew-win/main_test.go +++ b/cmd/dew-win/main_test.go @@ -11,6 +11,8 @@ import ( "slices" "strings" "testing" + + "github.com/solcreek/dew/internal/services" ) // withStubWSL replaces the wslQuery seam with a fake that answers the @@ -455,6 +457,96 @@ func TestDewDataDir(t *testing.T) { } } +func TestParseUpArgs(t *testing.T) { + cases := []struct { + name string + args []string + dir string + with []string + wantErr bool + }{ + {"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}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + dir, with, err := parseUpArgs(c.args) + if (err != nil) != c.wantErr { + t.Fatalf("err = %v, wantErr %v", err, c.wantErr) + } + if c.wantErr { + return + } + if dir != c.dir { + t.Errorf("dir = %q, want %q", dir, c.dir) + } + if !reflect.DeepEqual(with, c.with) { + t.Errorf("with = %v, want %v", with, c.with) + } + }) + } +} + +func TestSplitCSV(t *testing.T) { + cases := []struct { + in string + want []string + }{ + {"a,b,c", []string{"a", "b", "c"}}, + {"a, b ,,c", []string{"a", "b", "c"}}, + {"", nil}, + {" ", nil}, + {"solo", []string{"solo"}}, + } + for _, c := range cases { + if got := splitCSV(c.in); !reflect.DeepEqual(got, c.want) { + t.Errorf("splitCSV(%q) = %v, want %v", c.in, got, c.want) + } + } +} + +func TestServiceContainer(t *testing.T) { + if got := serviceContainer("redis"); got != "dew-svc-redis" { + t.Errorf("serviceContainer(redis) = %q, want dew-svc-redis", got) + } +} + +func TestPodmanRunArgs(t *testing.T) { + // No env, no server args: exact argv. + redis := services.Service{Name: "redis", Image: "docker.io/library/redis:7-alpine", Port: 6379} + want := []string{"-d", "dew", "--exec", "podman", "run", "-d", + "--name", "dew-svc-redis", "--network=host", "docker.io/library/redis:7-alpine"} + if got := podmanRunArgs(redis); !reflect.DeepEqual(got, want) { + t.Errorf("redis: got %v, want %v", got, want) + } + + // Env pairs must precede the image; server args must follow it. + mysql := services.Service{ + Name: "mysql", Image: "docker.io/library/mysql:8-oracle", Port: 3306, + Env: []string{"MYSQL_ROOT_PASSWORD=dew"}, + Args: []string{"--bind-address=0.0.0.0"}, + } + got := podmanRunArgs(mysql) + if !slices.Contains(got, "--network=host") { + t.Errorf("mysql: --network=host missing in %v", got) + } + env := slices.Index(got, "MYSQL_ROOT_PASSWORD=dew") + img := slices.Index(got, mysql.Image) + arg := slices.Index(got, "--bind-address=0.0.0.0") + if env < 0 || img < 0 || arg < 0 { + t.Fatalf("mysql: missing element in %v", got) + } + if !(env < img && img < arg) { + t.Errorf("mysql: order wrong (env=%d img=%d arg=%d) in %v", env, img, arg, got) + } +} + func TestHasMirroredNetworking(t *testing.T) { cases := []struct { name string diff --git a/cmd/dew-win/smoke-test.ps1 b/cmd/dew-win/smoke-test.ps1 index 7fe3fe5..c6f53d7 100644 --- a/cmd/dew-win/smoke-test.ps1 +++ b/cmd/dew-win/smoke-test.ps1 @@ -137,6 +137,37 @@ Check "dev server reachable at localhost:$port" { $body -eq 'smoke-ok' } if ($up -and -not $up.HasExited) { $up.Kill() } wsl --terminate dew 2>&1 | Out-Null +# --- up --with: service starts, is reachable, and is cleaned up ---- +Write-Host "== up --with: service lifecycle ==" -ForegroundColor Cyan +$svcProj = Join-Path $env:TEMP 'dew-smoke-svc' +New-Item -ItemType Directory -Force -Path $svcProj | Out-Null +# dev script that self-exits after ~15s so the cleanup path (dev exits -> +# stop()) runs on its own without needing a Ctrl+C we can't send here. +Set-Content -Path (Join-Path $svcProj 'package.json') -Encoding ascii -Value '{"name":"svc","private":true,"scripts":{"dev":"node -e \"setTimeout(()=>process.exit(0),15000)\""}}' +$svcLog = Join-Path $env:TEMP 'dew-smoke-svc.log' +Remove-Item $svcLog -ErrorAction SilentlyContinue +$svc = Start-Process $dew -ArgumentList "up --with redis `"$svcProj`"" -RedirectStandardOutput $svcLog -RedirectStandardError "$svcLog.err" -PassThru -NoNewWindow +$svcReady = $false +foreach ($i in 1..90) { + Start-Sleep -Seconds 1 + if ((Get-Content $svcLog -Raw -ErrorAction SilentlyContinue) -match 'redis ready') { $svcReady = $true; break } + if ($svc.HasExited) { break } +} +Check "up --with redis reports the service ready" { $svcReady } +Check "redis reachable on Windows localhost:6379" { + (Test-NetConnection -ComputerName 127.0.0.1 -Port 6379 -WarningAction SilentlyContinue).TcpTestSucceeded +} +Check "service container is running" { + (wsl -d dew -e podman ps --format '{{.Names}}' | Out-String) -match 'dew-svc-redis' +} +# Let the dev server self-exit so stop() removes the container, then verify. +if (-not $svc.HasExited) { $svc.WaitForExit(25000) | Out-Null } +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') +} +wsl --terminate dew 2>&1 | Out-Null + # --- Result -------------------------------------------------------- Write-Host "" if ($script:Failures -eq 0) { From 9090b58d4bd9195d050f92848885c01d4b311748 Mon Sep 17 00:00:00 2001 From: Yi-Ru Lin Date: Wed, 1 Jul 2026 08:06:18 -0500 Subject: [PATCH 02/12] fix(dew-win): reject multiple project dirs in dew up 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. --- cmd/dew-win/main.go | 8 +++++++- cmd/dew-win/main_test.go | 2 ++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/cmd/dew-win/main.go b/cmd/dew-win/main.go index aadfec8..6cde971 100644 --- a/cmd/dew-win/main.go +++ b/cmd/dew-win/main.go @@ -454,6 +454,7 @@ func cmdExec(args []string) error { // `--with=` services from `dew up` arguments. func parseUpArgs(args []string) (dir string, with []string, err error) { dir = "." + dirSet := false for i := 0; i < len(args); i++ { a := args[i] switch { @@ -468,7 +469,12 @@ func parseUpArgs(args []string) (dir string, with []string, err error) { case strings.HasPrefix(a, "--"): return "", nil, fmt.Errorf("unknown flag %q for dew up", a) default: - dir = a + // Reject a second positional rather than silently taking the + // last one (e.g. `dew up ./a ./b` is a user error, not "./b"). + if dirSet { + return "", nil, fmt.Errorf("dew up takes at most one project dir, got %q and %q", dir, a) + } + dir, dirSet = a, true } } return dir, with, nil diff --git a/cmd/dew-win/main_test.go b/cmd/dew-win/main_test.go index 075ffbc..25c3730 100644 --- a/cmd/dew-win/main_test.go +++ b/cmd/dew-win/main_test.go @@ -470,9 +470,11 @@ func TestParseUpArgs(t *testing.T) { {"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}, + {"with then dir (PR example)", []string{"--with", "redis", "./app"}, "./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}, + {"multiple dirs rejected", []string{"./a", "./b"}, "", nil, true}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { From bce3c7496b3ce56e16bfc778fedc647e0cb50d81 Mon Sep 17 00:00:00 2001 From: Yi-Ru Lin Date: Wed, 1 Jul 2026 08:06:18 -0500 Subject: [PATCH 03/12] docs(dew-win): fix the shellQuote POSIX-escape smart quote The escape example regressed to a curly quote; restore the literal '...'\''...' that shellQuote emits. --- cmd/dew-win/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/dew-win/main.go b/cmd/dew-win/main.go index 6cde971..4962aba 100644 --- a/cmd/dew-win/main.go +++ b/cmd/dew-win/main.go @@ -747,7 +747,7 @@ func detectDevScript(pkgPath string) string { // 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: '...'\''...' . func shellQuote(s string) string { return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" } From 93256dc01b2eea3ab6db44af5d514e7e318cfa13 Mon Sep 17 00:00:00 2001 From: Yi-Ru Lin Date: Wed, 1 Jul 2026 08:06:18 -0500 Subject: [PATCH 04/12] test(dew-win): kill the dev server if it overruns the --with smoke wait If the dev server doesn't self-exit within the wait, Kill() it so a running dew up doesn't leak into later checks. --- cmd/dew-win/smoke-test.ps1 | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cmd/dew-win/smoke-test.ps1 b/cmd/dew-win/smoke-test.ps1 index c6f53d7..63f81c4 100644 --- a/cmd/dew-win/smoke-test.ps1 +++ b/cmd/dew-win/smoke-test.ps1 @@ -161,7 +161,10 @@ Check "service container is running" { (wsl -d dew -e podman ps --format '{{.Names}}' | Out-String) -match 'dew-svc-redis' } # Let the dev server self-exit so stop() removes the container, then verify. +# If it overruns the wait, kill it so we don't leak a running dew up into +# later checks. 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') From b761fa396a4ed41836063bb2ea830066764520d2 Mon Sep 17 00:00:00 2001 From: Yi-Ru Lin Date: Wed, 1 Jul 2026 08:16:53 -0500 Subject: [PATCH 05/12] fix(dew-win): reliably clean up --with services on all exit paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- cmd/dew-win/main.go | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/cmd/dew-win/main.go b/cmd/dew-win/main.go index 4962aba..ba41584 100644 --- a/cmd/dew-win/main.go +++ b/cmd/dew-win/main.go @@ -578,6 +578,10 @@ func startWithServices(names []string) (func(), error) { <-sig fmt.Println("\ndew: stopping services...") stop() + // The dev server runs as a child wsl.exe; on Windows, exiting dew + // doesn't reliably kill it, so terminate the distro to bring down + // the dev server (and anything else) before we exit. + exec.Command("wsl", "--terminate", distroName).Run() os.Exit(130) }() for _, name := range names { @@ -638,6 +642,11 @@ func cmdUp(args []string) error { if err != nil { return err } + // Guarantee cleanup on every early error return below (winPathToWSL, + // npm install, missing script). The dev-server path calls stop() + // explicitly before its os.Exit (which would skip this defer), and the + // Ctrl+C handler stops them too; stopServices is idempotent. + defer stop() // Services-only: no project to run, so hold the distro open (which keeps // the containers alive) until Ctrl+C, handled by startWithServices. @@ -691,13 +700,13 @@ func cmdUp(args []string) error { dev.Stdout = os.Stdout dev.Stderr = os.Stderr runErr := dev.Run() - // Stop services after the dev server exits. runPassthrough's os.Exit - // can't be used here — it would skip this cleanup. - stop() var ee *exec.ExitError if errors.As(runErr, &ee) { + // os.Exit skips the deferred stop(), so clean up explicitly first. + stop() os.Exit(ee.ExitCode()) } + // Non-exit-code paths fall through to the deferred stop(). return runErr } From ed3da7df7a400604a942df7ac1044355fdbaa01b Mon Sep 17 00:00:00 2001 From: Yi-Ru Lin Date: Wed, 1 Jul 2026 08:22:32 -0500 Subject: [PATCH 06/12] fix(dew-win): harden --with argument parsing 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. --- cmd/dew-win/main.go | 17 ++++++++++++++--- cmd/dew-win/main_test.go | 2 ++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/cmd/dew-win/main.go b/cmd/dew-win/main.go index ba41584..b7191f2 100644 --- a/cmd/dew-win/main.go +++ b/cmd/dew-win/main.go @@ -462,10 +462,18 @@ func parseUpArgs(args []string) (dir string, with []string, err error) { if i+1 >= len(args) { return "", nil, fmt.Errorf("--with needs a comma-separated service list") } - with = append(with, splitCSV(args[i+1])...) + svcs := splitCSV(args[i+1]) + if len(svcs) == 0 { + return "", nil, fmt.Errorf("--with needs a comma-separated service list") + } + with = append(with, svcs...) i++ case strings.HasPrefix(a, "--with="): - with = append(with, splitCSV(strings.TrimPrefix(a, "--with="))...) + svcs := splitCSV(strings.TrimPrefix(a, "--with=")) + if len(svcs) == 0 { + return "", nil, fmt.Errorf("--with needs a comma-separated service list") + } + with = append(with, svcs...) case strings.HasPrefix(a, "--"): return "", nil, fmt.Errorf("unknown flag %q for dew up", a) default: @@ -617,7 +625,10 @@ func cmdUp(args []string) error { } for _, name := range withServices { if services.Lookup(name) == nil { - return fmt.Errorf("unknown service %q (available: %s)", name, strings.Join(services.Names(), ", ")) + // 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, ", ")) } } absDir, err := filepath.Abs(dir) diff --git a/cmd/dew-win/main_test.go b/cmd/dew-win/main_test.go index 25c3730..6a0adf1 100644 --- a/cmd/dew-win/main_test.go +++ b/cmd/dew-win/main_test.go @@ -473,6 +473,8 @@ func TestParseUpArgs(t *testing.T) { {"with then dir (PR example)", []string{"--with", "redis", "./app"}, "./app", []string{"redis"}, false}, {"csv trims blanks", []string{"--with", "a, b ,,c"}, ".", []string{"a", "b", "c"}, false}, {"with needs arg", []string{"--with"}, "", nil, true}, + {"with empty equals rejected", []string{"--with="}, "", nil, true}, + {"with blank value rejected", []string{"--with", " , "}, "", nil, true}, {"unknown flag", []string{"--bogus"}, "", nil, true}, {"multiple dirs rejected", []string{"./a", "./b"}, "", nil, true}, } From 1a1fc25e034edaa1a9ae92d6465ae2a00b1610b8 Mon Sep 17 00:00:00 2001 From: Yi-Ru Lin Date: Wed, 1 Jul 2026 08:29:00 -0500 Subject: [PATCH 07/12] fix(dew-win): surface non-not-exist package.json stat errors in dew up 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. --- cmd/dew-win/main.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/cmd/dew-win/main.go b/cmd/dew-win/main.go index b7191f2..c44d9b0 100644 --- a/cmd/dew-win/main.go +++ b/cmd/dew-win/main.go @@ -637,9 +637,14 @@ func cmdUp(args []string) error { } // The dev-server half is Node-only; with --with the project is optional - // (services can run on their own). + // (services can run on their own). Only a genuine "not found" means no + // project — surface permission/IO stat errors instead of silently + // treating them as a missing package.json. pkgPath := filepath.Join(absDir, "package.json") _, statErr := os.Stat(pkgPath) + if statErr != nil && !os.IsNotExist(statErr) { + return fmt.Errorf("stat %s: %w", pkgPath, statErr) + } 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 ", absDir) From f9697af291d52a901d084a91cc6630e23a241dba Mon Sep 17 00:00:00 2001 From: Yi-Ru Lin Date: Wed, 1 Jul 2026 08:32:43 -0500 Subject: [PATCH 08/12] docs(dew-win): show --with takes a service list in usage The usage placeholder implied a single service; --with accepts a comma-separated list, so show . --- cmd/dew-win/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/dew-win/main.go b/cmd/dew-win/main.go index c44d9b0..dbfae16 100644 --- a/cmd/dew-win/main.go +++ b/cmd/dew-win/main.go @@ -98,7 +98,7 @@ func printUsage() { Usage: dew setup Install/update WSL2 distro dew up [dir] Detect a Node project + run its dev server in WSL2 - dew up --with Also start services (postgres,redis,mysql,mongo,minio) + dew up --with Start services too (postgres,redis,mysql,mongo,minio) dew run [--] Run a one-shot command in the WSL2 distro dew exec Run a command inside the WSL2 distro dew vm start Ensure the WSL2 distro is running From 609079f2c441131c88eda1329f5213c02f5f00ac Mon Sep 17 00:00:00 2001 From: Yi-Ru Lin Date: Wed, 1 Jul 2026 08:40:17 -0500 Subject: [PATCH 09/12] fix(dew-win): deduplicate --with service names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `--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. --- cmd/dew-win/main.go | 17 ++++++++++++++++- cmd/dew-win/main_test.go | 1 + 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/cmd/dew-win/main.go b/cmd/dew-win/main.go index dbfae16..0d7260f 100644 --- a/cmd/dew-win/main.go +++ b/cmd/dew-win/main.go @@ -485,7 +485,22 @@ func parseUpArgs(args []string) (dir string, with []string, err error) { dir, dirSet = a, true } } - return dir, with, nil + return dir, dedupPreserveOrder(with), nil +} + +// dedupPreserveOrder removes duplicate names, keeping first-seen order, so +// `--with redis,redis` (or repeated --with flags) doesn't start the same +// container twice. +func dedupPreserveOrder(names []string) []string { + seen := map[string]bool{} + var out []string + for _, n := range names { + if !seen[n] { + seen[n] = true + out = append(out, n) + } + } + return out } // splitCSV splits a comma-separated list, dropping blanks. diff --git a/cmd/dew-win/main_test.go b/cmd/dew-win/main_test.go index 6a0adf1..32670ce 100644 --- a/cmd/dew-win/main_test.go +++ b/cmd/dew-win/main_test.go @@ -472,6 +472,7 @@ func TestParseUpArgs(t *testing.T) { {"dir and with", []string{"./app", "--with", "redis"}, "./app", []string{"redis"}, false}, {"with then dir (PR example)", []string{"--with", "redis", "./app"}, "./app", []string{"redis"}, false}, {"csv trims blanks", []string{"--with", "a, b ,,c"}, ".", []string{"a", "b", "c"}, false}, + {"dedup preserves order", []string{"--with", "redis,redis,postgres,redis"}, ".", []string{"redis", "postgres"}, false}, {"with needs arg", []string{"--with"}, "", nil, true}, {"with empty equals rejected", []string{"--with="}, "", nil, true}, {"with blank value rejected", []string{"--with", " , "}, "", nil, true}, From ef69c97200958e003c7618989f24203a3abf1bd2 Mon Sep 17 00:00:00 2001 From: Yi-Ru Lin Date: Wed, 1 Jul 2026 08:40:17 -0500 Subject: [PATCH 10/12] test(dew-win): make the --with cleanup smoke check robust 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. --- cmd/dew-win/smoke-test.ps1 | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/cmd/dew-win/smoke-test.ps1 b/cmd/dew-win/smoke-test.ps1 index 63f81c4..614b1a8 100644 --- a/cmd/dew-win/smoke-test.ps1 +++ b/cmd/dew-win/smoke-test.ps1 @@ -160,15 +160,21 @@ Check "redis reachable on Windows localhost:6379" { Check "service container is running" { (wsl -d dew -e podman ps --format '{{.Names}}' | Out-String) -match 'dew-svc-redis' } -# Let the dev server self-exit so stop() removes the container, then verify. -# If it overruns the wait, kill it so we don't leak a running dew up into -# later checks. +# Let the dev server self-exit so stop() removes the container. If it +# overruns the wait, Kill() it and rm the container ourselves -- a force +# kill skips stop(), so this prevents a leak into the rest of the run. 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') +if (-not $svc.HasExited) { + $svc.Kill() + wsl -d dew -e podman rm -f dew-svc-redis 2>&1 | Out-Null } +# Poll for removal instead of a fixed sleep (slower machines/CI). +$removed = $false +foreach ($i in 1..20) { + if (-not ((wsl -d dew -e podman ps -a --format '{{.Names}}' | Out-String) -match 'dew-svc-redis')) { $removed = $true; break } + Start-Sleep -Milliseconds 500 +} +Check "service container removed after dev server exits" { $removed } wsl --terminate dew 2>&1 | Out-Null # --- Result -------------------------------------------------------- From a32f6e7120c2537cb2b5bce7b5badf3e1d9bae93 Mon Sep 17 00:00:00 2001 From: Yi-Ru Lin Date: Wed, 1 Jul 2026 08:47:04 -0500 Subject: [PATCH 11/12] fix(dew-win): validate the dew up project dir exists 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. --- cmd/dew-win/main.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/cmd/dew-win/main.go b/cmd/dew-win/main.go index 0d7260f..ac54b56 100644 --- a/cmd/dew-win/main.go +++ b/cmd/dew-win/main.go @@ -650,6 +650,14 @@ func cmdUp(args []string) error { if err != nil { return fmt.Errorf("resolve %q: %w", dir, err) } + // Validate the dir itself so a typoed path (e.g. `dew up --with redis + // ./typo`) surfaces an error instead of silently falling into + // services-only mode. A real dir without package.json still works. + if fi, err := os.Stat(absDir); err != nil { + return fmt.Errorf("dew up %s: %w", dir, err) + } else if !fi.IsDir() { + return fmt.Errorf("dew up: %s is not a directory", dir) + } // The dev-server half is Node-only; with --with the project is optional // (services can run on their own). Only a genuine "not found" means no From d799f83a0efed1971d2eb81a2b1a2fc2ed08c6ed Mon Sep 17 00:00:00 2001 From: Yi-Ru Lin Date: Wed, 1 Jul 2026 08:56:17 -0500 Subject: [PATCH 12/12] fix(dew-win): return an error instead of panicking on unknown service 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. --- cmd/dew-win/main.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/cmd/dew-win/main.go b/cmd/dew-win/main.go index ac54b56..fefd475 100644 --- a/cmd/dew-win/main.go +++ b/cmd/dew-win/main.go @@ -608,7 +608,14 @@ func startWithServices(names []string) (func(), error) { os.Exit(130) }() for _, name := range names { - s := *services.Lookup(name) + svc := services.Lookup(name) + if svc == nil { + // cmdUp validates names first, but don't panic if a future + // caller doesn't — this function already returns an error. + stop() + return nil, fmt.Errorf("unknown service %q", name) + } + s := *svc fmt.Printf("dew: starting %s (%s)...\n", name, s.Image) conn, err := startService(s) if err != nil {