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 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
wslc:
command: auto # tries wslc.exe, wslc, then System32; an absolute path also works
defaults:
container: app
image: slidict/slidict:development
workdir: /app
interactive: false
remove: true
network: app-tier # optional; shared with `dependencies` so containers can resolve each other by name
env:
RAILS_ENV: development
PORT: "3000"
ports:
- "3000:3000"
volumes:
- ".:/app"
up:
command: server # command `wip up` passes to the image when it creates the container
# (omit to use the image's default CMD)
dependencies:
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
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 defaults.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
If your app needs sidecar services (a database, Redis, ...), declare them under dependencies
and set defaults.network. wip up creates the network first (if it doesn't exist), then brings
up each dependency by name before the main container — so bin/rails c (or anything else run
inside the main container) can reach development.mysql/redis/etc. by their dependency name,
the same way Compose's service names resolve. wip down tears the main container and all
dependencies down (the network itself is left in place). Each dependency entry accepts image
(required), command, env, ports, volumes, and workdir — the same shape as defaults.
Compose mode
If your project already has a real compose.yml, don't duplicate it in dependencies: — point
wip at it instead:
version: 1
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:/defaults.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 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] |
Start defaults.container and its dependencies (creating any that are missing, on defaults.network if set). -d runs the main container in the background |
wip down |
Stop and remove defaults.container and its 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 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 workaround is to stop
bind-mounting the source live and instead mirror it into a named volume: mount the host path
read-only (.:/host-src:ro), add a named volume for /app, and have the container's entrypoint
rsync from the read-only mount into the volume before boot, then keep syncing in the background
(e.g. every couple of seconds) so edits still show up with a short delay. The app then only ever
touches fast native storage once it's running.
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: + defaults.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). This isn't reallywip's responsibility — the fix has to come from either the mount layer or the project's own volume layout. 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
defaults, commands, dependencies, or compose are planned. A resident daemon and a GUI
remain out of scope; see "Not in the initial release" above.