wip
Homepage: https://wslc-wip.slidict.com/
wip is a Ruby-built OSS CLI wrapper that brings a dip-like
workflow to Microsoft WSLC. It collects a project's container, image, environment variables, and
commands into a single wip.yml, and forwards them to wslc.exe / wslc as safe argument arrays
(no shell interpolation).

Status: early release. Expect to track WSLC's own interface as it evolves.
Requirements & installation
Ruby 3.2+, WSL2, and Microsoft WSLC.
gem install wslc-wip
From source: bundle install && bundle exec exe/wip version.
Quick start
gem install wslc-wip
cd my-project
wip init # writes a starter wip.yml; edit the TODOs, then:
wip doctor
wip build
wip up -d
wip rails console
Full configuration example
Put a wip.yml in your project root. Running from a subdirectory walks up to find it, or pass
--config PATH to point at one explicitly.
version: 1
mode: container # default. This example is container mode end-to-end — see "Compose mode" below
# for mode: compose, which replaces container:/network:/dependencies: with a
# compose: block instead; the two don't mix within one wip.yml.
wslc:
command: auto # tries wslc.exe, wslc, then System32; an absolute path also works
container: app # required once dependencies: has entries; which one `up`/`exec`/`run`/`build`/`commands:`
# target. No default — a project must say which entry is the primary one explicitly.
network: app-tier # optional; shared by every dependencies: entry so containers can resolve each other by name
dependencies:
app: # container: points here — the one container wip execs into and runs commands in
image: slidict/slidict:development
workdir: /app
interactive: false
remove: true
command: server # extra args appended when `wip up` creates the container
# (omit to use the image's default CMD)
env:
RAILS_ENV: development
PORT: "3000"
ports:
- "3000:3000"
volumes:
- ".:/app"
redis:
image: redis:latest
development.mysql:
image: mysql:8.0
command: --default-authentication-plugin=mysql_native_password
env:
MYSQL_ROOT_PASSWORD: password
MYSQL_DATABASE: development
commands:
rails:
type: exec
command: bin/rails
container: app
interactive: true
workdir: /app
env:
RAILS_ENV: development
bundle:
command: bundle
rspec:
command: bundle exec rspec
shell:
command: bash
interactive: true
migrate:
type: run
command: bundle exec rails db:migrate
image: slidict/slidict:development
remove: true
build:
type: build
context: .
tag: slidict/slidict:development
sync: # optional; mirror the source into a named volume instead of bind-mounting it live
exclude:
- .git
- tmp/
- node_modules/
env values are stringified. wip config masks any key matching token, password, secret,
credential, or auth. Keep real secrets out of the config file and in your runtime environment
instead.
.env
Like docker compose, wip automatically loads a .env file next to wip.yml (one KEY=VALUE
per line; # comments, blank lines, export prefixes, and quoted values are all supported) and
passes its keys through as container environment variables on build, up, run, exec, and
custom commands. .env only fills in keys that aren't already set by the primary container's
env or a command/dependency's own env — those always win on conflict. Pass --env-file PATH
to load a different file instead.
.dockerignore
wip build reads .dockerignore from the build context and stages a filtered copy of the
context (skipping anything it matches) before handing it to wslc build, since wslc sends the
context as-is otherwise. If there's no .dockerignore, the original context directory is used
directly with no copying.
Dependency containers
dependencies: holds every container uniformly — the primary one container: points at and any
sidecar services (a database, Redis, ...) alongside it. Each entry accepts image (required),
command, env, ports, volumes, and workdir; there's no separate, differently-shaped block
for "the one you exec into." container: has no default; once dependencies: has any entries,
wip needs to be told explicitly which one is primary rather than guessing a name.
What sets the primary entry apart is operational, not structural: wip up brings up every other
entry by name first (creating network: beforehand if it doesn't exist and set), then boots or
starts the primary one — so bin/rails c (or anything else run inside it) can reach
development.mysql/redis/etc. by their dependency name, the same way Compose's service names
resolve. wip down tears the primary container and all sidecars down (the network itself is left
in place). Only the primary container is a target for exec/run/build/commands: — sidecars
are only ever started and stopped, matching Compose's own service-vs-you-exec-into-one-of-them
split.
Source sync
Bind-mounting the app directory (.:/app) is what usually makes a container boot crawl under
wslc — see Slow boot when the app directory is
bind-mounted for why. A sync: block hands
that problem to wip: the source is mounted read-only, the app runs off a named volume, and wip
mirrors one into the other with rsync.
sync:
source: . # host path, relative to wip.yml (default: the wip.yml directory)
target: /app # container path served by the volume (default: the primary container's workdir, else /app)
volume: app-src # named volume holding the mirror (default: "<container>-src")
mount: /host-src # where the source is bind-mounted read-only (default: /host-src)
exclude: # rsync --exclude patterns
- .git
- tmp/
- node_modules/
delete: true # rsync --delete (default: true)
command: rsync # binary that does the mirroring (default: rsync)
options: [] # extra flags appended to the rsync invocation
interval: 2 # seconds between syncs for `wip sync --watch` (default: 2)
mode: exec # exec (mirror inside the running container) or run (a throwaway one);
# default: exec for `mode: container`, run for `mode: compose`
image: null # image for the mirror container; unused under mode: container unless set
# (falls back to the primary container's own image). Under mode: compose,
# one of image or build is required (there's no dependencies: entry to fall
# back to)
build: # optional; has wip build the mirror image itself instead of requiring one to
# already exist. build.tag wins over image if both are set.
dockerfile: |
FROM alpine:latest
RUN apk add --no-cache rsync
tag: null # optional (default: "wip-sync-<container>:latest")
Everything below sync: is optional — sync: {} alone already works. With it in place:
- Any
volumesentry on the primary container mountingtarget(the usual.:/app) is replaced by<source>:/host-src:roplusapp-src:/app, so the running app only ever touches the volume. Other volumes (bundle:/usr/local/bundle, ...) are passed through untouched, and sidecardependencies:entries keep mounting whatever they declare. wip upmirrors the source into the volume before the container boots;wip up --no-syncskips that step.wip syncmirrors on demand:sync.mode: exec(the default undermode: container) execs rsync inside the already-running container;sync.mode: runalways uses a throwaway container with the same mounts instead. Which one runs is fixed by config, not guessed at from whether a container happens to be up.wip sync --watch [--interval N]keeps re-syncing until Ctrl-C, so host edits reach the container with a short delay. Run it in a second terminal alongsidewip up -d.wip doctorreports the resolved source, volume, and target, and fails if the source is missing.- With
sync.buildconfigured,wip builds that image once perwip up/wip syncinvocation (including once before a--watchloop starts, not on every tick) before mirroring with it.
Like every built-in command, wip sync takes precedence over a commands: entry of the same
name; wip says so and points at wip dispatch sync, which still runs yours.
Two things to keep in mind. The mirror runs rsync inside the container, so the image needs it
(RUN apt-get update && apt-get install -y rsync) — or point sync.command at a copy tool the
image already has. And the mirror is one-way (host → volume): anything the app writes under
target is removed by the next --delete pass unless you exclude it, give it its own volume,
or set delete: false.
sync: works alongside mode: compose too, but two things change:
-
Compose still owns the volume layout, so wip doesn't rewrite any mounts for you: the compose service that runs your app must itself declare a named volume with the exact same name as
sync.volume(<container>-srcby default) mounted at the path your app expects, e.g.:# compose.yml services: app: volumes: - app-src:/app volumes: app-src:wip's mirror writes into that volume from a separate, disposable container; it never touches the compose service directly.
-
sync.modedefaults torunand can't be set toexec(only a container wip itself booted is guaranteed to have the read-only source mount attached, which compose services never do), andsync.imageorsync.buildbecomes required, since that disposable container needs an image from somewhere — undermode: containerit borrows the primarydependencies:entry's image, but compose mode has no such entry to borrow from.
wip up's pre-boot mirror (and the --no-sync flag that skips it) works the same way under
mode: compose as it does otherwise: the source is mirrored into the volume before
compose up starts the service that mounts it.
Since sync.mode: run boots a fresh container on every mirror, reusing your app's full image here
just adds startup overhead for something that only ever runs rsync. A dedicated, minimal image is
worth it — wip doesn't publish or default to one itself (same reasoning as compose.command:
picking a specific third-party image for you isn't its call to make), but sync.build covers it
without needing to manage a separate image yourself:
sync:
build:
dockerfile: |
FROM alpine:latest
RUN apk add --no-cache rsync
wip builds this once per wip up/wip sync invocation (not on every --watch tick — see above)
and uses the result, tagged wip-sync-<container>:latest by default (build.tag overrides it).
Prefer managing the image yourself instead? Build and tag it however you like, then set sync.image
to that tag directly — sync.build's tag wins if both are set, so don't configure both at once.
Compose mode
If your project already has a real compose.yml, don't duplicate it in dependencies: — point
wip at it instead:
version: 1
mode: compose # required to enable compose mode; a compose: block with no mode: compose is an error
compose:
service: app # required: which compose service wip run/exec/NAME target
command: wslc-compose # required: the compose-for-wslc binary/path you have installed
file: compose.yml # optional; auto-detected next to wip.yml otherwise
project: myapp # optional; omitted lets the compose tool pick its own default
compose: is mutually exclusive with dependencies:/network — pick one orchestration path per
project. In compose mode, wip becomes a thin bridge to an external compose-for-wslc CLI rather
than reimplementing Compose itself.
wslc itself has no native Compose support yet (tracked upstream in
microsoft/WSL#40948), and until it does,
independent third-party tools fill the gap — for example
bacarndiaye/wslc-compose (Python) and
inuyume/wslc-compose (Go), among others. wslc is new
and still evolving, so expect more of these to show up (and existing ones to change) over time.
wip deliberately doesn't pick a winner or default to any of them (unlike wslc.command, which
defaults to auto and searches for wslc.exe/wslc): compose.command is required and treats
every implementation equally — set it to whichever binary name or absolute path you've installed.
Whichever one you use needs to understand -f FILE [-p PROJECT] up|down|exec|logs, the subset of
the Compose CLI vocabulary wip drives. wip doctor reports whether the configured command is
found, its version, and which compose file wip resolved.
wip up/wip downdelegate straight to<compose command> up -d/down.wip exec/wip NAME(customcommands:) run insidecompose.service.wip shellalso goes through the bridge: unlesscommands.shellis defined inwip.yml, itexecsbashagainstcompose.service, falling back tosh.wip logs [-f] [SERVICE...]is only available in compose mode.wip runhas no ephemeral-container equivalent in this exec-only vocabulary, so it falls back toexecagainst the already-runningcompose.service(wip warns when this happens).commands:entries withtype: run/type: buildaren't supported in compose mode — use your compose tool's ownbuild/up --builddirectly; compose owns builds for its own services.
Commands
| Command | Description |
|---|---|
wip init [--force] |
Write a starter wip.yml: mode: compose if a compose.yml/docker-compose.yml is found next to it, mode: container otherwise. Refuses to overwrite an existing wip.yml unless --force |
wip version |
wip's version, plus WSLC's if it can be detected |
wip doctor |
Diagnose WSL2, interop, WSLC, config, architecture, and Git |
wip config |
Print the effective configuration (secrets masked) |
wip build -- --no-cache |
Build the image from the build definition |
wip up [-d] [--no-sync] |
Start the primary dependencies: entry (container: names which one) and its sidecars (creating any that are missing, on network: if set). -d runs the main container in the background; with sync: configured, the source is mirrored into the volume first unless --no-sync |
wip down |
Stop and remove the primary container and its sidecar dependencies: |
wip exec [--no-interactive] COMMAND... |
Run a command in the existing container |
wip run [--no-interactive] COMMAND... |
Run a command in a new --rm container (compose mode: execs into compose.service instead) |
wip shell |
Open the configured shell, falling back to bash then sh |
wip logs [-f] [SERVICE...] |
Follow compose service logs (compose mode only) |
wip sync [-w] [--interval N] |
Mirror the source into the sync volume once, or keep re-syncing with --watch (needs sync:) |
wip NAME ARGS... |
Run commands.NAME, appending any extra arguments |
TTY allocation is decided by combining the command's config, the CLI option, and whether both stdin and stdout are real TTYs.
Pass --debug (or set WIP_DEBUG=1) to see where time is going: wip prints each step it takes —
checking for an existing network/container/dependency, and running the resolved wslc/docker
command — along with how long that step took, e.g.:
$ wip rails c --debug
wip: [debug] running: wslc.exe exec -it -w /app app bin/rails c
+ wslc.exe exec -it -w /app app bin/rails c
...
wip: [debug] done in 4.32s: running: wslc.exe exec -it -w /app app bin/rails c
For long-running interactive commands (like rails c), the "done" line only prints after you
exit, but the timestamp of the + ... line tells you when wip finished its own setup and handed
off to wslc/docker — useful for telling wip-side overhead apart from time spent booting inside
the container.
While a step is still running, wip also prints a host resource snapshot (load average, memory, disk I/O, and the top CPU-consuming processes) every 5 seconds, so a hang is visible even before the command has produced any output of its own:
wip: [debug] still running (load 3.42 2.10 1.05 | mem 6.1G/15.6G | io read 12000KB/s write 400KB/s | top: wslc.exe(8842) cpu 61.0%/mem 3.2%, ...): running: wslc.exe exec -it -w /app app bin/rails c
The disk I/O figure is worth watching first if the host's CPU and memory look idle — a slow
bundle/rails boot is often WSL2's bind-mounted (.:/app-style) volumes doing a lot of small
reads, not the container starving for CPU.
For commands that hand the real terminal to the child (-it, e.g. rails c), these periodic
snapshots go to a log file instead of your terminal — wip prints the path once at the start —
since writing into a terminal the child controls in raw mode would garble both outputs. Commands
that don't need a TTY still get the snapshots printed live.
Override that choice with --debug-log:
--debug-log=-forces snapshots inline even for-itcommands (only useful if you know your terminal/pager can tolerate the interleaving).--debug-log=PATHalways writes snapshots toPATH, including for non-TTY commands, e.g. to keep every run's snapshots in one place:wip rails c --debug --debug-log=/tmp/wip-debug.log.
doctor
Each check prints as [OK], [WARN], or [FAIL]. Warnings alone exit 0; a WSL2, interop, WSLC,
or config problem that blocks execution exits 1. Git being unreachable from the real build
environment is only a warning.
Common errors
WSLC not found
Install or update the WSL container tooling, then run wip doctor. auto looks for wslc.exe,
wslc, then /mnt/c/Windows/System32/wslc.exe, in that order.
Docker Hub authentication
When pull access denied (or similar) is detected, wip suggests how to log in:
wslc registry login -u <username> docker.io
Slow boot when the app directory is bind-mounted
wslc containers run in their own VM, so a bind-mounted host directory (.:/app) is always shared
in over virtiofs, even when the host path is already on WSL's native filesystem. Frameworks that
scan large directory trees at startup (Ruby's Zeitwerk autoloader, for example) issue many small
per-file stat/open calls, and each one is a round trip through that layer — CPU on the Windows
side can look busy while almost no data is actually transferred, and the process can appear hung
for minutes with barely any resource usage to show for it.
If a debug log shows a boot-time command "stuck" with low CPU/mem/IO in resource_monitor's
output, this is worth checking before assuming the app itself is broken. The fix is to stop
bind-mounting the source live and mirror it into a named volume instead, so the app only ever
touches fast native storage once it's running. wip does that for you — add a sync: block and
it rewrites the mounts, mirrors before boot, and re-syncs on demand. See Source
sync.
CPU architecture mismatch
Check the image and publish a multi-arch (amd64/arm64) image:
docker buildx imagetools inspect <image>
docker buildx build \
--platform linux/amd64,linux/arm64 \
-t <image> \
--push .
Development
git clone https://github.com/slidict/wip.git
cd wip
bundle install
bundle exec rspec
bundle exec rubocop
bundle exec rake
The test suite doesn't need WSLC — the resolution, build, and execution layers are all swappable. GitHub Actions runs RSpec and RuboCop on Ruby 3.2, 3.3, 3.4, and 4.0.
Contributing
Bug reports and pull requests are welcome on GitHub. See CONTRIBUTING.md for commit conventions, versioning policy, and the PR checklist.
Not in the initial release
Full Compose compatibility isn't reimplemented in wip itself, but is available by delegating to
a third-party compose-for-wslc tool — see Compose mode. A resident/daemon
process, a GUI, PowerShell-specific tuning, direct registry API/manifest parsing, self-update, and
plugins are all unimplemented.
Roadmap
wip already covers most of what dip adds on top of Compose —
named commands (commands:), run/exec hidden behind a single verb, .env passthrough, and
sidecar services via dependencies: + network:. Fuller Compose semantics
(depends_on ordering/health checks, log aggregation, named volumes, profiles, scaling) are now
handled by delegating to a separate compose-for-wslc tool in compose mode
rather than being reimplemented in wip itself. Which of those actually work is entirely up to
the external tool you point compose.command at — wip only forwards
-f FILE [-p PROJECT] up|down|exec|logs, so treat that list as what Compose offers, not as
something wip guarantees. See that section for what compose mode covers
and its current limitations (run, and commands: of type run/build). What's still planned
for wip, roughly in priority order:
wip provision— a dip-style one-shot bootstrap hook (build → up deps → install deps → create/migrate/seed DB) so a new contributor can go fromgit cloneto a working environment in two commands (wip provision && wip up).- Config file merging —
--configcurrently accepts one file; support layering (wip.yml+wip.override.yml, or aWIP_CONFIGlist) for dev/CI/debug variants without duplicating the whole file, plus awip config --resolvedview of the merged result. - Bind-mount boot time (
rails c,bundle, ...) — commands likewip rails cstill start noticeably slower than the equivalent underdocker compose, mostly from WSL2 bind-mounted (.:/app-style) volumes doing many small reads for gems/node_modules(use--debugto confirm it's disk I/O and notwip's own overhead). Source sync works around this today by running the app off a named volume and mirroring the host tree into it, at the cost of a one-way sync with a short delay. A tighter loop (host-side file watching instead of interval polling, two-way sync) is the natural next step. We're also hoping for improvements on thewslcside itself (faster bind-mount/cache behavior);wipwill pick those up for free as soon as they land.
Each of these should stay additive to the existing wip.yml shape — no breaking changes to
container, network, commands, dependencies, or compose are planned. A resident daemon
and a GUI remain out of scope; see "Not in the initial release" above.