feat(dew-win): run as grove's services engine (dew.toml, --services-only, services/exec --json)#40
Conversation
grove drives dew as a services engine: it renders a union dew.toml (one [[service]] per installed app), sets cwd to that dir, spawns `dew up --services-only` detached, then polls ~/.local/state/dew/default.sock to decide the engine is up. dew-win now honours that contract. `up --services-only` loads the dew.toml via the platform-neutral internal/dewfile, starts each service via podman on the distro's host network (reusing the --with path), drops the readiness marker grove stats, and stays foreground holding the distro (and its containers) alive until `dew vm stop` terminates it or Ctrl+C. `vm stop` clears the marker. grove never dials the marker — VMRunning() only os.Stats it — so a plain file satisfies the contract without a real daemon socket. Data (volume persistence) and Ports (extra forwards) are not yet mapped; --network=host already puts every container port on Windows localhost. Unit tests cover the new --services-only flag and the dewfile->services mapping.
The services engine wrote its readiness marker only after every service was pulled and health-gated, so grove's engine-readiness poll had to wait out a slow first image pull — which can far exceed its window — even though the VM itself was up in a second. Write the marker as soon as the distro boots, matching macOS dew (its daemon socket appears when the VM comes up, before images are pulled). grove health-checks each app on its own generous timeout, so surfacing engine-ready early is correct and independent of pull time. Moving the marker ahead of the service loop also drops the data race between the appended started-slice and the Ctrl+C goroutine (which now just terminates the distro to kill the containers).
grove's dewbridge.Services() drives `dew services --json` to learn each
service's running state and forwarded host port: it polls this for install
health (waitServiceRunning waits for running==true) and reads it for
`grove list` / `stats`.
dew-win now serves that contract. `dew services` reads the union dew.toml from
the working dir (grove sets cwd to its engine dir) and reports each service as
{name, running, host_port, guest_port} inside the standard ok-envelope
({"ok":true,"schema_version":"1.0","data":{...}}). Services run on the
distro's host network, so host_port == guest_port == the primary port. Running
state is probed passively via `podman ps` — if the distro isn't up the query
reports every service stopped rather than starting it (grove polls while the
engine is still booting). A bare `dew services` prints a human table.
Adds the emitEnvelope helper (reused by later --json commands) and a unit test
asserting the envelope shape and stopped-state mapping.
grove's dewbridge runs `dew exec --json sh -c CMD` (uninstall's data-dir
cleanup, and its load-bearing guest-failure check) with --json placed right
after exec, since a trailing --json would be handed to the guest sh.
dew-win now honours a leading --json on exec: it captures the guest command's
stdout/stderr and exit code and emits {"ok":true,...,"data":{
"guest_exit_code":N,"stdout":...,"stderr":...}}. dew is 'ok' whenever it
dispatched the command, so this exits 0 even when the guest exits non-zero — the
real code rides in guest_exit_code, which grove turns into a GuestExitError.
Only a genuine dispatch failure (wsl missing) surfaces as an error. The
passthrough (non-JSON) exec path is unchanged.
There was a problem hiding this comment.
Pull request overview
Adds the missing “services engine” surface to dew-win so it can satisfy grove’s dew CLI contract on Windows/WSL2 (readiness marker, up --services-only, services --json, and exec --json).
Changes:
- Adds
dew up --services-onlyto run a uniondew.tomlas a foreground “services engine” and manage a grove readiness marker. - Adds
dew services [--json]to report service list + running state derived fromdew.tomland passivepodman ps. - Adds
dew exec --jsonto emit a grove-compatible JSON envelope containing guest exit code and captured stdout/stderr.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| cmd/dew-win/main.go | Implements services command, services-engine mode for up, readiness marker lifecycle, and exec --json envelope output. |
| cmd/dew-win/main_test.go | Updates parseUpArgs tests and adds unit coverage for dewfile→service mapping and dew services --json envelope shape. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| return filepath.Join(home, ".local", "state", "dew", "default.sock"), nil | ||
| } | ||
|
|
||
| // writeEngineMarker creates the readiness marker after services are up. |
There was a problem hiding this comment.
Adopted in 12b478c — corrected the writeEngineMarker comment to say the marker is written as soon as the distro is up (before pulling images), matching the marker-early ordering the engine relies on.
| jsonOut := false | ||
| dir := "." | ||
| for _, a := range args { | ||
| switch { | ||
| case a == "--json": | ||
| jsonOut = true | ||
| case strings.HasPrefix(a, "-"): | ||
| return fmt.Errorf("unknown flag %q for dew services", a) | ||
| default: | ||
| dir = a | ||
| } | ||
| } |
There was a problem hiding this comment.
Adopted in 12b478c — dew services now rejects a second positional dir with a clear error ("takes at most one dir"), matching dew up. Covered by TestCmdServicesRejectsMultipleDirs.
| // grove drives `dew exec --json sh -c CMD`, placing --json right after exec | ||
| // (a trailing --json would be handed to the guest sh). So only a LEADING | ||
| // --json is dew's own flag; strip it and switch to the captured-envelope | ||
| // path. Any later --json stays part of the guest argv. | ||
| jsonOut := false |
There was a problem hiding this comment.
Adopted in 12b478c — extracted splitExecJSONFlag and added TestSplitExecJSONFlag covering (1) a leading --json triggers the envelope and (2) a non-leading --json stays part of the guest argv. The ok:true + guest_exit_code path on a non-zero guest exit is exercised by the real-machine check (dew exec --json sh -c 'echo hi; exit 7' -> guest_exit_code:7).
Three review fixes in cmd/dew-win (all in main.go): - writeEngineMarker's comment said 'after services are up'; correct it to before (the marker is written as soon as the distro is up, ahead of image pulls — the point of the marker-early ordering). - dew services silently took the last positional dir when given several (`dew services a b`); reject a second positional with a clear error, matching dew up. - Extract splitExecJSONFlag as a pure helper so the contract-critical leading-vs-trailing --json parsing is unit-testable. Refs #40
| if running, err := distroRunningNow(); err != nil || !running { | ||
| return set | ||
| } |
There was a problem hiding this comment.
Adopted in 8039826 — runningServiceContainers now returns (map, error): a stopped distro stays an empty set with no error, but a distroRunningNow failure propagates through dew services so callers distinguish 'stopped' from 'couldn't determine'. Covered by TestCmdServicesPropagatesProbeError.
| out, err := exec.Command("wsl", "-d", distroName, "--exec", | ||
| "podman", "ps", "--format", "{{.Names}}").Output() | ||
| if err != nil { | ||
| return set | ||
| } |
There was a problem hiding this comment.
Adopted in 8039826 — the podman ps probe now uses CombinedOutput and returns an error (with stderr in the message) when the distro is running but the probe fails, so 'unable to determine' no longer masquerades as running:false.
| // Hold the distro open — an active wsl session keeps WSL2 (and the | ||
| // host-network containers) alive. Returns when `dew vm stop` runs | ||
| // `wsl --terminate`, at which point we clear the marker and exit. | ||
| exec.Command("wsl", "-d", distroName, "--exec", "sh", "-c", "while true; do sleep 86400; done").Run() | ||
| removeEngineMarker() | ||
| return nil |
There was a problem hiding this comment.
Adopted in 8039826 — a non-ExitError from the hold-distro Run() (wsl couldn't start the session) is now returned. A normal terminate via wsl --terminate is an ExitError and stays the expected stop path.
| jsonOut, args := splitExecJSONFlag(args) | ||
| if len(args) == 0 { | ||
| return fmt.Errorf("usage: dew exec <cmd> [args...]") | ||
| return fmt.Errorf("usage: dew exec [--json] <cmd> [args...]") | ||
| } |
There was a problem hiding this comment.
Adopted in 8039826 — printUsage now lists dew services and dew up --services-only, and shows dew exec [--json].
Address Copilot review on #40: - runningServiceContainers now returns an error. A stopped distro stays a normal empty-set result (grove polls while the engine boots), but a genuine probe failure — distroRunningNow erroring, or podman ps failing (now via CombinedOutput so stderr is in the message) — propagates through dew services so callers can tell 'stopped' from 'couldn't determine' instead of misreporting every service as stopped. - cmdUpServicesEngine's hold-distro wsl session: a non-ExitError from Run() (wsl couldn't start the session at all) is now returned, so the engine doesn't report success after 'services engine ready' when it never actually held the distro. A normal terminate (ExitError from wsl --terminate) stays the expected stop path. - printUsage now lists dew services and dew up --services-only, and shows dew exec [--json]. Refs #40
TestCmdServicesPropagatesProbeError asserts cmdServices returns an error (not a silent all-stopped report) when the running-state probe fails. Refs #40
| // services.Service. Data (volume persistence) and Ports (extra forwards) are | ||
| // not yet wired on Windows: with --network=host every container port already | ||
| // lands on the distro's netns (hence Windows localhost), and volume | ||
| // persistence is a follow-up. |
Makes
dew-winsatisfy the dew CLI contract grove drives, so grove can use WSL2 as its container engine on Windows the same way it uses Apple Virtualization dew on macOS. grove is macOS-only today; the documented hard blocker is "dew shipping a Windows build" — this is that build's engine surface.What grove drives (and what this adds)
grove renders a union
dew.toml(one[[service]]per installed app), spawnsdew up --services-onlydetached with cwd = its engine dir, polls a readiness marker, then readsdew services --jsonfor health/list/stats anddew exec --jsonfor guest commands.dew up --services-only— loads the uniondew.tomlvia the platform-neutralinternal/dewfile, starts each service via podman on the distro's host network (reusing the--withpath), drops the readiness marker~/.local/state/dew/default.sock, and stays foreground holding the distro alive untildew vm stopor Ctrl+C. The marker is written before pulling images (matching macOS dew's socket-on-boot timing) so a slow first pull can't blow grove's readiness window.vm stopclears the marker.dew services --json— reads the uniondew.tomland emits{"ok":true,"schema_version":"1.0","data":{"services":[{name,running,host_port,guest_port}]}}. Running state is probed passively viapodman ps(never starts the distro); host_port == guest_port == the primary port (host networking).dew exec --json— honours a leading--json(grove places it right afterexec) and emits{...,"data":{guest_exit_code,stdout,stderr}}, exiting 0 even when the guest exits non-zero (grove turnsguest_exit_codeinto a GuestExitError).Verified end-to-end on real Windows 11 / WSL2
grove's rendered pocketbase
dew.toml→ engine →podman ps: dew-svc-pocketbase-pocketbase Up→dew services --jsonreportsrunning:true(host/guest 8090) → PocketBase answers HTTP 200 on Windowslocalhost:8090via mirrored networking.dew exec --json sh -c 'echo hi; exit 7'→{guest_exit_code:7,stdout:"hi\n"}.Notes / deferred
vm forward add(host-net + mirrored networking makes it unnecessary), anddew vm stopexiting 0 already satisfies grove's VMStop (it bypasses envelope parsing) — so neither is needed here.Unit tests cover the
--services-onlyflag, the dewfile→services mapping, and thedew services --jsonenvelope shape. Windows CI (go test ./cmd/dew-win) is green.