wip

Tests Gem Version License: MIT Ruby

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).

wip demo

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 volumes entry on the primary container mounting target (the usual .:/app) is replaced by <source>:/host-src:ro plus app-src:/app, so the running app only ever touches the volume. Other volumes (bundle:/usr/local/bundle, ...) are passed through untouched, and sidecar dependencies: entries keep mounting whatever they declare.
  • wip up mirrors the source into the volume before the container boots; wip up --no-sync skips that step.
  • wip sync mirrors on demand: sync.mode: exec (the default under mode: container) execs rsync inside the already-running container; sync.mode: run always 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 alongside wip up -d.
  • wip doctor reports the resolved source, volume, and target, and fails if the source is missing.
  • With sync.build configured, wip builds that image once per wip up/wip sync invocation (including once before a --watch loop 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>-src by 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.mode defaults to run and can't be set to exec (only a container wip itself booted is guaranteed to have the read-only source mount attached, which compose services never do), and sync.image or sync.build becomes required, since that disposable container needs an image from somewhere — under mode: container it borrows the primary dependencies: 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 down delegate straight to <compose command> up -d/down.
  • wip exec/wip NAME (custom commands:) run inside compose.service.
  • wip shell also goes through the bridge: unless commands.shell is defined in wip.yml, it execs bash against compose.service, falling back to sh.
  • wip logs [-f] [SERVICE...] is only available in compose mode.
  • wip run has no ephemeral-container equivalent in this exec-only vocabulary, so it falls back to exec against the already-running compose.service (wip warns when this happens).
  • commands: entries with type: run/type: build aren't supported in compose mode — use your compose tool's own build/up --build directly; 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 -it commands (only useful if you know your terminal/pager can tolerate the interleaving).
  • --debug-log=PATH always writes snapshots to PATH, 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:

  1. wip provision — a dip-style one-shot bootstrap hook (build → up deps → install deps → create/migrate/seed DB) so a new contributor can go from git clone to a working environment in two commands (wip provision && wip up).
  2. Config file merging--config currently accepts one file; support layering (wip.yml + wip.override.yml, or a WIP_CONFIG list) for dev/CI/debug variants without duplicating the whole file, plus a wip config --resolved view of the merged result.
  3. Bind-mount boot time (rails c, bundle, ...) — commands like wip rails c still start noticeably slower than the equivalent under docker compose, mostly from WSL2 bind-mounted (.:/app-style) volumes doing many small reads for gems/node_modules (use --debug to confirm it's disk I/O and not wip'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 the wslc side itself (faster bind-mount/cache behavior); wip will 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.

License

MIT License