wslc-wip 1.0.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 91983f52ddbbc2b2ce8eff75cd94534f9e32525d006ff7388152ac24a199ee15
4
- data.tar.gz: c50b1311d33cbe16d94b1e050ce63f31c054bdd4850285728a3680d0c87e3390
3
+ metadata.gz: 49996dea1b8ae083cceb0caaafc0b8ddcc6de0a3ab8a7f5f4f856156948840d3
4
+ data.tar.gz: cca5758770d9f4364427c1ae7519ee8026c598b1002206fc9af8f22f5ffc0a64
5
5
  SHA512:
6
- metadata.gz: 1f5fd382e2c88dbee58c8835be090e6aefd885f4d938d1d4543ac821f1822e4fc55c8f0da384e69d4d874b40382dcfc9e1914aec4c330b0a8b1173ff1518cff1
7
- data.tar.gz: 9751c65c89c31c0d0f7e27ed28f27c4deece06ed15c780a900f608ba89df3cd9b16aae3c85faad058a4fb3363e5fe30ee30b29030fca6cdd22f828cad8bcc1a0
6
+ metadata.gz: df63da254bbbb1b6dedf5e56b5a78d8fbb09fd7d6dfa41ae0b3b58db600f07e8703ea3d729a4bb125e8d1a29e498ce8c3e4504e93d74ad51ce37b7d4a3bad0db
7
+ data.tar.gz: 8b8607bdda225610b7c7389210c16a04764aa95ba45748f9bcd3c384f9b6eb3ccb371342cea89a12749d7c4226f9fe6b94729c191513690b40168b339d21dd7b
data/README.md CHANGED
@@ -7,6 +7,15 @@
7
7
 
8
8
  Homepage: https://wslc-wip.slidict.com/
9
9
 
10
+ `wip` is a Ruby-built OSS CLI wrapper that brings a [`dip`](https://github.com/bibendi/dip)-like
11
+ workflow to Microsoft WSLC. It collects a project's container, image, environment variables, and
12
+ commands into a single `wip.yml`, and forwards them to `wslc.exe` / `wslc` as safe argument arrays
13
+ (no shell interpolation).
14
+
15
+ ![wip demo](https://raw.githubusercontent.com/slidict/wip/main/docs/demo.gif)
16
+
17
+ > **Status:** early release. Expect to track WSLC's own interface as it evolves.
18
+
10
19
  ## Contents
11
20
 
12
21
  - [Which mode should you use?](#which-mode-should-you-use)
@@ -22,20 +31,12 @@ Homepage: https://wslc-wip.slidict.com/
22
31
  - [Commands](#commands)
23
32
  - [doctor](#doctor)
24
33
  - [Common errors](#common-errors)
34
+ - [FAQ](#faq)
25
35
  - [Development](#development)
26
36
  - [Contributing](#contributing)
27
37
  - [Roadmap](#roadmap)
28
38
  - [License](#license)
29
39
 
30
- `wip` is a Ruby-built OSS CLI wrapper that brings a [`dip`](https://github.com/bibendi/dip)-like
31
- workflow to Microsoft WSLC. It collects a project's container, image, environment variables, and
32
- commands into a single `wip.yml`, and forwards them to `wslc.exe` / `wslc` as safe argument arrays
33
- (no shell interpolation).
34
-
35
- ![wip demo](https://raw.githubusercontent.com/slidict/wip/main/docs/demo.gif)
36
-
37
- > **Status:** early release. Expect to track WSLC's own interface as it evolves.
38
-
39
40
  ## Which mode should you use?
40
41
 
41
42
  `wip.yml` runs in one of three modes, set with `mode:`. Pick the one that matches your project:
@@ -86,7 +87,7 @@ version: 1
86
87
  mode: container # default
87
88
  wslc:
88
89
  command: auto # tries wslc.exe, wslc, then System32; an absolute path also works
89
- container: app # required once dependencies: has entries; which one `up`/`exec`/`run`/`build`/`commands:`
90
+ container: app # required once dependencies: has entries; which one `up`/`exec`/`run`/`build`/`interaction:`
90
91
  # target. No default — a project must say which entry is the primary one explicitly.
91
92
  network: app-tier # optional; shared by every dependencies: entry so containers can resolve each other by name
92
93
  dependencies:
@@ -112,7 +113,7 @@ dependencies:
112
113
  env:
113
114
  MYSQL_ROOT_PASSWORD: password
114
115
  MYSQL_DATABASE: development
115
- commands:
116
+ interaction:
116
117
  rails:
117
118
  type: exec
118
119
  command: bin/rails
@@ -137,6 +138,7 @@ commands:
137
138
  type: build
138
139
  context: .
139
140
  tag: slidict/slidict:development
141
+ shadow_context: /mnt/c/Users/me/AppData/Local/wip/build-contexts
140
142
  sync: # optional; mirror the source into a named volume instead of bind-mounting it live
141
143
  exclude:
142
144
  - .git
@@ -148,6 +150,11 @@ sync: # optional; mirror the source into a named volume instead of bind-mounting
148
150
  credential, or auth. Keep real secrets out of the config file and in your runtime environment
149
151
  instead.
150
152
 
153
+ `interaction:` can also be spelled `commands:` — the same block under a different name, e.g. for
154
+ projects that already use `commands:`. `wip config` prints the effective config using `commands:`.
155
+ The two are aliases for the same feature, not separate ones: pick whichever name you like, but declaring both
156
+ `commands:` and `interaction:` in the same `wip.yml` is a `ConfigError`.
157
+
151
158
  #### Dependency containers
152
159
 
153
160
  `dependencies:` holds every container uniformly — the primary one `container:` points at and any
@@ -161,7 +168,7 @@ entry by name first (creating `network:` beforehand if it doesn't exist and set)
161
168
  starts the primary one — so `bin/rails c` (or anything else run inside it) can reach
162
169
  `development.mysql`/`redis`/etc. by their dependency name, the same way Compose's service names
163
170
  resolve. `wip down` tears the primary container and all sidecars down (the network itself is left
164
- in place). Only the primary container is a target for `exec`/`run`/`build`/`commands:` — sidecars
171
+ in place). Only the primary container is a target for `exec`/`run`/`build`/`interaction:` — sidecars
165
172
  are only ever started and stopped, matching Compose's own service-vs-you-exec-into-one-of-them
166
173
  split.
167
174
 
@@ -198,13 +205,13 @@ the Compose CLI vocabulary `wip` drives. `wip doctor` reports whether the config
198
205
  found, its version, and which compose file `wip` resolved.
199
206
 
200
207
  - `wip up`/`wip down` delegate straight to `<compose command> up -d`/`down`.
201
- - `wip exec`/`wip NAME` (custom `commands:`) run inside `compose.service`.
202
- - `wip shell` also goes through the bridge: unless `commands.shell` is defined in `wip.yml`, it
208
+ - `wip exec`/`wip NAME` (custom `interaction:`) run inside `compose.service`.
209
+ - `wip shell` also goes through the bridge: unless `interaction.shell` is defined in `wip.yml`, it
203
210
  `exec`s `bash` against `compose.service`, falling back to `sh`.
204
211
  - `wip logs [-f] [SERVICE...]` is only available in compose mode.
205
212
  - `wip run` has no ephemeral-container equivalent in this exec-only vocabulary, so it falls back
206
213
  to `exec` against the already-running `compose.service` (wip warns when this happens).
207
- - `commands:` entries with `type: run`/`type: build` aren't supported in compose mode — use your
214
+ - `interaction:` entries with `type: run`/`type: build` aren't supported in compose mode — use your
208
215
  compose tool's own `build`/`up --build` directly; compose owns builds for its own services.
209
216
 
210
217
  ### Compose mode (native)
@@ -237,8 +244,10 @@ top level (`networks:`, `volumes:`, `configs:`, `secrets:`, ...) is the one exce
237
244
  real Compose tools, not by `wip`, so `wip` silently ignores it rather than rejecting an otherwise
238
245
  valid compose.yml over sections it doesn't need to look at:
239
246
 
240
- - Per service: `image`, `build` (string or `{context:, dockerfile:}`; resolved relative to
241
- `compose.yml`, not wherever `wip` is invoked from), `command` (shell or exec form), `environment`
247
+ - Per service: `image`, `build` (string or `{context:, dockerfile:, args:, shadow_context:}`;
248
+ `context` is resolved relative to `compose.yml`, not wherever `wip` is invoked from; `dockerfile`
249
+ stays relative to `context` itself — see [.dockerignore](#dockerignore) for what `shadow_context`
250
+ does), `command` (shell or exec form), `environment`
242
251
  (mapping or `KEY=VALUE` array — a mapping value must not be null; host environment pass-through
243
252
  isn't supported), `ports`/`volumes` (short syntax only — `"host:container"` strings, not
244
253
  long-syntax mappings), `working_dir`, `user`, `depends_on` (ordering only — a `condition:` other
@@ -263,10 +272,16 @@ to load a different file instead.
263
272
 
264
273
  ### .dockerignore
265
274
 
266
- `wip build` reads `.dockerignore` from the build context and stages a filtered copy of the
267
- context (skipping anything it matches) before handing it to `wslc build`, since `wslc` sends the
268
- context as-is otherwise. If there's no `.dockerignore`, the original context directory is used
269
- directly with no copying.
275
+ `wip build` reads `.dockerignore` from the build context and excludes anything it matches before
276
+ handing the context to `wslc build`, since `wslc` sends the context as-is otherwise. Set
277
+ `commands.build.shadow_context` in `wip.yml` (or `build.shadow_context` on a compose-native
278
+ `build:` service in `compose.yml`) to a directory on the Windows filesystem to enable a persistent
279
+ shadow context for projects outside `/mnt/<drive>`. The first build copies every included file;
280
+ later builds only copy added or changed files and remove deleted or newly ignored files. Without
281
+ this setting the optimization is disabled, and it only applies under WSL2 — on WSL1 (or anywhere
282
+ else) the context is handed to `wslc build` directly. Projects already on `/mnt/c` (or another
283
+ mounted Windows drive) also continue to build directly even when the setting is present. The path
284
+ must live outside the build context itself, or `wip build`/`wip up` refuses it.
270
285
 
271
286
  ### Source sync
272
287
 
@@ -344,7 +359,7 @@ Everything below `sync:` is optional — `sync: {}` alone already works. With it
344
359
  - With `sync.build` configured, `wip build`s that image once per `wip up`/`wip sync` invocation
345
360
  (including once before a `--watch` loop starts, not on every tick) before mirroring with it.
346
361
 
347
- Like every built-in command, `wip sync` takes precedence over a `commands:` entry of the same
362
+ Like every built-in command, `wip sync` takes precedence over an `interaction:` entry of the same
348
363
  name; wip says so and points at `wip dispatch sync`, which still runs yours.
349
364
 
350
365
  Two things to keep in mind. The mirror runs `rsync` *inside* the container, so the image needs it
@@ -415,7 +430,7 @@ to that tag directly — `sync.build`'s tag wins if both are set, so don't confi
415
430
  | `wip shell` | Open the configured shell, falling back to `bash` then `sh` |
416
431
  | `wip logs [-f] [SERVICE...]` | Follow compose service logs (compose modes only; mode: compose-native takes at most one `SERVICE`) |
417
432
  | `wip sync [-w] [--interval N]` | Mirror the source into the sync volume once, or keep re-syncing with `--watch` (needs `sync:`) |
418
- | `wip NAME ARGS...` | Run `commands.NAME`, appending any extra arguments |
433
+ | `wip NAME ARGS...` | Run `interaction.NAME`, appending any extra arguments |
419
434
 
420
435
  TTY allocation is decided by combining the command's config, the CLI option, and whether both
421
436
  stdin and stdout are real TTYs.
@@ -510,6 +525,74 @@ docker buildx build \
510
525
  --push .
511
526
  ```
512
527
 
528
+ ## FAQ
529
+
530
+ **Which mode should I start with?**
531
+ Pick whichever `mode:` fits your project — see [Which mode should you use?](#which-mode-should-you-use)
532
+ for the breakdown.
533
+
534
+ **Can I use `dependencies:` and `compose:` together?**
535
+ No — `compose:` is mutually exclusive with `dependencies:`/`network`. Pick one orchestration path
536
+ per project; see [Configuration](#configuration).
537
+
538
+ **What's the difference between `mode: compose` and `mode: compose-native`?**
539
+ `compose` delegates to a third-party compose-for-`wslc` binary you install yourself
540
+ (`compose.command`); `compose-native` parses `compose.yml` itself and drives `wslc` directly, no
541
+ external tool required. `compose-native`'s Compose coverage isn't frozen at whatever it handles
542
+ today — it keeps growing until `wslc` ships native Compose support of its own. See
543
+ [Compose mode](#compose-mode) and [Compose mode (native)](#compose-mode-native).
544
+
545
+ **`dependencies:` already gives me sidecar containers — why would I need `compose-native` too?**
546
+ `dependencies:` and `compose-native` aren't really alternatives to each other — they're for two
547
+ different starting points. No `compose.yml`? Declare containers directly in `wip.yml`'s own shape
548
+ with `dependencies:`. Already have a `compose.yml`? Reusing it is where `compose-native` and
549
+ `mode: compose` both come in, so the real comparison is between those two, not against
550
+ `dependencies:`: `mode: compose` reuses it too, but only by delegating to a third-party
551
+ compose-for-`wslc` binary you install yourself; `compose-native` reuses the same `compose.yml`
552
+ without installing anything external, parsing it and driving `wslc` directly — and gets a real
553
+ `wslc run --rm` for `wip run` instead of the `exec` fallback `mode: compose` falls back to.
554
+ `mode: compose`'s coverage is whatever the external tool you point it at supports; `compose-native`'s
555
+ is maintained in this repo and actively extended, not treated as a permanent ceiling. See
556
+ [Which mode should you use?](#which-mode-should-you-use) for the full picture.
557
+
558
+ **What happens to `compose-native` once `wslc` gets official Compose support?**
559
+ `compose-native` exists to close the gap for as long as `wslc` has no native Compose support of its
560
+ own (tracked upstream in [microsoft/WSL#40948](https://github.com/microsoft/WSL/issues/40948)), and
561
+ we intend to keep extending its Compose coverage until that lands — see
562
+ [Compose mode (native)](#compose-mode-native) and [Roadmap](#roadmap). `wip.yml`'s shape (`mode:`,
563
+ `compose:`) isn't planned to change for existing `container`/`compose`/`compose-native` setups, so
564
+ whatever we do once `wslc` catches up won't require rewriting your config.
565
+
566
+ **Is `sync:` required?**
567
+ No, it's entirely optional. Add it if boot times feel slow with a bind-mounted app directory — see
568
+ [Slow boot when the app directory is bind-mounted](#slow-boot-when-the-app-directory-is-bind-mounted).
569
+
570
+ **How does `wip` actually fix the slow bind-mount boot problem?**
571
+ The slowness comes from `.:/app`-style bind mounts going through virtiofs, where frameworks that
572
+ stat/open many small files at startup (e.g. Ruby's Zeitwerk) pay a round trip per file. A `sync:`
573
+ block moves the app off that path entirely: the host source is mounted read-only, the app itself
574
+ runs off a named volume (fast native storage inside the VM), and `wip` mirrors the read-only
575
+ source into that volume with `rsync` — once before boot, and on demand afterward via `wip sync`
576
+ (or continuously with `wip sync --watch`). Since the app never touches the bind mount directly, its
577
+ own file access is no longer paying the virtiofs cost; the trade-off is a one-way, slightly-delayed
578
+ mirror instead of an always-live view of host edits. See [Source sync](#source-sync) for the full
579
+ config and [Slow boot when the app directory is bind-mounted](#slow-boot-when-the-app-directory-is-bind-mounted)
580
+ for the root cause.
581
+
582
+ **Is it safe to put passwords/secrets in `wip.yml`?**
583
+ `wip config` masks any key matching token/password/secret/credential/auth when printing, but the
584
+ raw file itself is not encrypted. Keep real secrets in your runtime environment or `.env` instead
585
+ of committing them in `wip.yml` — but `.env` is only safe if it's actually untracked: add `.env` to
586
+ `.gitignore` (and confirm with `git check-ignore .env`) before putting anything sensitive in it.
587
+
588
+ **`wslc.exe`/`wslc` isn't found — what do I do?**
589
+ See [WSLC not found](#wslc-not-found) under Common errors.
590
+
591
+ **I'm migrating from `dip` — do I have to rename `interaction:`?**
592
+ No, `wip.yml` accepts `interaction:` as-is — it's the primary spelling, same as in `dip`. `commands:`
593
+ also works as an alias if you prefer it, but not both in the same file (that's a `ConfigError`). See
594
+ [Container mode](#container-mode).
595
+
513
596
  ## Development
514
597
 
515
598
  ```bash
@@ -522,7 +605,8 @@ bundle exec rake
522
605
  ```
523
606
 
524
607
  The test suite doesn't need WSLC — the resolution, build, and execution layers are all
525
- swappable. GitHub Actions runs RSpec and RuboCop on Ruby 3.2, 3.3, 3.4, and 4.0.
608
+ swappable. This project uses RuboCop for Ruby style and static analysis; `bundle exec rake` runs
609
+ both RSpec and RuboCop. GitHub Actions checks them on Ruby 3.2, 3.3, 3.4, and 4.0.
526
610
 
527
611
  ## Contributing
528
612
 
@@ -533,7 +617,7 @@ checklist.
533
617
  ## Roadmap
534
618
 
535
619
  `wip` already covers most of what [`dip`](https://github.com/bibendi/dip) adds on top of Compose —
536
- named commands (`commands:`), `run`/`exec` hidden behind a single verb, `.env` passthrough, and
620
+ named commands (`interaction:`), `run`/`exec` hidden behind a single verb, `.env` passthrough, and
537
621
  sidecar services via `dependencies:` + `network:`. Rather than waiting on `wslc`'s own Compose
538
622
  support ([microsoft/WSL#40948](https://github.com/microsoft/WSL/issues/40948)) or an external
539
623
  compose-for-`wslc` tool staying complete, `mode: compose-native` (see
@@ -547,7 +631,7 @@ compose-for-`wslc` tool in [compose mode](#compose-mode) — which of those actu
547
631
  entirely up to the external tool you point `compose.command` at; `wip` only forwards
548
632
  `-f FILE [-p PROJECT] up|down|exec|logs`, so treat that list as what Compose offers, not as
549
633
  something `wip` guarantees. See that section for what compose mode covers
550
- and its current limitations (`run`, and `commands:` of type `run`/`build`).
634
+ and its current limitations (`run`, and `interaction:` of type `run`/`build`).
551
635
 
552
636
  Beyond Compose parity, a resident/daemon process, a GUI, PowerShell-specific tuning, direct
553
637
  registry API/manifest parsing, self-update, and plugins are all unimplemented and not currently
@@ -1,33 +1,180 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'find'
3
+ require 'digest'
4
4
  require 'fileutils'
5
+ require 'find'
6
+ require 'json'
5
7
  require 'pathname'
6
8
  require 'tmpdir'
7
9
 
8
10
  module Wip
9
- # Stages a build context into a scratch directory with anything matched by
10
- # .dockerignore left out, since wslc build (unlike `docker build`) sends the
11
- # context as-is instead of filtering it itself.
11
+ # Filters build contexts for wslc and keeps WSL-hosted sources in a fast,
12
+ # persistent Windows-side shadow directory.
12
13
  class BuildContext
13
- def initialize(context, ignore: nil)
14
+ def initialize(context, ignore: nil, environment: Environment.new, shadow_root: nil)
14
15
  @root = Pathname(context).expand_path
15
16
  @ignore = ignore || DockerIgnore.load(@root.join('.dockerignore'))
17
+ @environment = environment
18
+ @shadow_root = validated_shadow_root(shadow_root) if shadow_root
16
19
  end
17
20
 
18
21
  # on_progress fires before copying and after each file, as `|count, total|`,
19
22
  # so a caller can report elapsed progress even while copying one large file.
20
- def stage(on_progress: nil)
21
- return yield @root.to_s if @ignore.empty?
23
+ def stage(on_progress: nil, &block)
24
+ return stage_shadow(on_progress, &block) if shadow_required?
25
+ return block.call(@root.to_s) if @ignore.empty?
22
26
 
23
27
  Dir.mktmpdir('wip-build-context-') do |dir|
24
28
  copy_included_files(Pathname(dir), on_progress)
25
- yield dir
29
+ block.call(dir)
26
30
  end
27
31
  end
28
32
 
33
+ # Whether the upcoming stage will use the Windows-side shadow directory
34
+ # (configured shadow_root, on WSL2, with a context outside /mnt) rather
35
+ # than staging in place or under a WSL-side tmpdir.
36
+ def shadow? = shadow_required?
37
+
29
38
  private
30
39
 
40
+ # A shadow root under the context would itself be walked by included_files
41
+ # on the next build and copied into itself at ever-deeper paths, so the
42
+ # cache would grow without bound and the build would eventually fail.
43
+ def validated_shadow_root(shadow_root)
44
+ root = Pathname(shadow_root).expand_path
45
+ if root == @root || root.to_s.start_with?("#{@root}/")
46
+ raise ConfigError, "shadow_context (#{root}) must not be inside the build context (#{@root})"
47
+ end
48
+
49
+ root
50
+ end
51
+
52
+ def shadow_required?
53
+ return false unless @shadow_root
54
+
55
+ @environment.wsl2? && !@root.to_s.match?(%r{\A/mnt/[a-z](?:/|\z)}i)
56
+ end
57
+
58
+ # Keep one stable Windows-side context per source path. Its manifest lives
59
+ # beside (rather than inside) the context so it is never sent to wslc.
60
+ def stage_shadow(on_progress)
61
+ key = Digest::SHA256.hexdigest(@root.to_s)
62
+ cache = @shadow_root.join(key)
63
+ context = cache.join('context')
64
+ FileUtils.mkdir_p(cache)
65
+
66
+ File.open(cache.join('lock'), File::RDWR | File::CREAT, 0o600) do |lock|
67
+ lock.flock(File::LOCK_EX)
68
+ synchronize_shadow(context, cache.join('manifest.json'), on_progress)
69
+ # Keep the shadow immutable until wslc has finished reading it.
70
+ yield context.to_s
71
+ end
72
+ end
73
+
74
+ def synchronize_shadow(context, manifest_path, on_progress)
75
+ current = included_files.to_h { |entry| [entry, fingerprint(@root.join(entry))] }
76
+ previous = previous_manifest(context, manifest_path)
77
+ changed = current.keys.reject { |entry| current[entry] == previous[entry] }
78
+ removed = previous.keys - current.keys
79
+
80
+ apply_shadow_changes(context, changed, removed, on_progress)
81
+ FileUtils.mkdir_p(context)
82
+ write_manifest(manifest_path, current)
83
+ end
84
+
85
+ # A context we can't describe is a context we can't update incrementally:
86
+ # with no manifest there is no way to tell which of its entries are stale,
87
+ # so it gets discarded and rebuilt rather than left holding deleted or
88
+ # newly ignored files.
89
+ def previous_manifest(context, manifest_path)
90
+ return {} unless context.directory?
91
+
92
+ manifest = load_manifest(manifest_path)
93
+ return manifest if manifest
94
+
95
+ FileUtils.rm_rf(context)
96
+ {}
97
+ end
98
+
99
+ def apply_shadow_changes(context, changed, removed, on_progress)
100
+ total = changed.size + removed.size
101
+ on_progress&.call(0, total)
102
+
103
+ removed.each_with_index do |entry, index|
104
+ FileUtils.rm_rf(context.join(entry))
105
+ prune_empty_parents(context.join(entry).dirname, context)
106
+ on_progress&.call(index + 1, total)
107
+ end
108
+ changed.each_with_index do |entry, index|
109
+ copy_entry_atomically(@root.join(entry), context.join(entry))
110
+ on_progress&.call(removed.size + index + 1, total)
111
+ end
112
+ end
113
+
114
+ # Returns nil — not an empty manifest — when the manifest is missing,
115
+ # unreadable, or not a manifest at all, so callers can tell "nothing was
116
+ # synced yet" apart from "we no longer know what was synced".
117
+ def load_manifest(path)
118
+ manifest = JSON.parse(path.read)
119
+ manifest if manifest.is_a?(Hash)
120
+ rescue JSON::ParserError, SystemCallError
121
+ nil
122
+ end
123
+
124
+ def write_manifest(path, contents)
125
+ temporary = Pathname("#{path}.tmp-#{Process.pid}")
126
+ temporary.write(JSON.generate(contents))
127
+ File.rename(temporary, path)
128
+ ensure
129
+ FileUtils.rm_f(temporary) if temporary
130
+ end
131
+
132
+ def fingerprint(path)
133
+ stat = path.lstat
134
+ if stat.symlink?
135
+ { 'type' => 'link', 'target' => path.readlink.to_s }
136
+ else
137
+ { 'type' => 'file', 'size' => stat.size, 'mtime_ns' => stat.mtime.nsec + (stat.mtime.to_i * 1_000_000_000),
138
+ 'mode' => stat.mode }
139
+ end
140
+ end
141
+
142
+ # preserve: true keeps the source mode, so an executable stays executable
143
+ # even when the shadow lives on a DrvFs mount whose fmask would otherwise
144
+ # strip the bit and break a `RUN ./script` in the image build.
145
+ def copy_entry_atomically(source, target)
146
+ FileUtils.mkdir_p(target.dirname)
147
+ temporary = target.dirname.join(".#{target.basename}.wip-#{Process.pid}")
148
+ FileUtils.rm_rf(temporary)
149
+ FileUtils.copy_entry(source, temporary, true, false, false)
150
+ replace_atomically(temporary, target)
151
+ ensure
152
+ FileUtils.rm_rf(temporary) if temporary
153
+ end
154
+
155
+ # rename replaces an existing entry in a single step, so an interrupted
156
+ # update leaves the previous copy in place instead of no copy at all. Only
157
+ # a target rename can't overwrite — a directory where a file now lives, or
158
+ # a filesystem without overwrite semantics — needs the unsafe fallback.
159
+ def replace_atomically(temporary, target)
160
+ File.rename(temporary, target)
161
+ rescue Errno::EISDIR, Errno::ENOTDIR, Errno::ENOTEMPTY, Errno::EEXIST, Errno::EPERM, Errno::EACCES
162
+ FileUtils.rm_rf(target)
163
+ File.rename(temporary, target)
164
+ end
165
+
166
+ def prune_empty_parents(directory, root)
167
+ while empty_descendant?(directory, root)
168
+ directory.rmdir
169
+ directory = directory.dirname
170
+ end
171
+ end
172
+
173
+ def empty_descendant?(directory, root)
174
+ directory != root && directory.to_s.start_with?("#{root}/") &&
175
+ directory.directory? && directory.children.empty?
176
+ end
177
+
31
178
  def copy_included_files(destination, on_progress)
32
179
  files = included_files
33
180
  on_progress&.call(0, files.size)
@@ -37,7 +184,7 @@ module Wip
37
184
  # Keep links as links. Dereferencing a link here could copy arbitrary
38
185
  # host files outside the build context (for example, ~/.ssh/id_rsa)
39
186
  # into the staged directory and expose them to the image build.
40
- FileUtils.copy_entry(@root.join(relative_path), target, false, false, false)
187
+ FileUtils.copy_entry(@root.join(relative_path), target, true, false, false)
41
188
  on_progress&.call(index + 1, files.size)
42
189
  end
43
190
  end
data/lib/wip/cli.rb CHANGED
@@ -73,10 +73,10 @@ module Wip
73
73
  context = Pathname(load_config.path).dirname.join(settings['context'] || '.').to_s
74
74
  warn "wip: staging build context (#{context})"
75
75
  progress = StagingProgress.new
76
- BuildContext.new(context).stage(on_progress: progress.method(:tick)) do |staged_context|
76
+ build_context = BuildContext.new(context, shadow_root: settings['shadow_context'])
77
+ build_context.stage(on_progress: progress.method(:tick)) do |staged_context|
77
78
  progress.finish
78
- built = builder.build(settings: settings.merge('context' => staged_context), extra: extra)
79
- execute(built, interactive: tty?(true))
79
+ run_staged_build(build_context, staged_context, settings, extra)
80
80
  end
81
81
  ensure
82
82
  progress&.finish
@@ -247,10 +247,12 @@ module Wip
247
247
  execute(exec_target(command, interactive: interactive), interactive: tty?(interactive))
248
248
  end
249
249
 
250
- def execute(command, interactive: false, exit_on_failure: true)
250
+ def execute(command, interactive: false, exit_on_failure: true, chdir: nil)
251
251
  runner = CommandRunner.new(debug: debug?)
252
+ run_options = { interactive: interactive }
253
+ run_options[:chdir] = chdir if chdir
252
254
  code = reporter.step("running: #{CommandDisplay.for_debug(command)}", live: !interactive) do
253
- runner.run(command, interactive: interactive)
255
+ runner.run(command, **run_options)
254
256
  end
255
257
  exit(code) if exit_on_failure && !code.zero?
256
258
  code
@@ -321,15 +323,23 @@ module Wip
321
323
  extra = compose_build_extra(spec)
322
324
  warn "wip: building service '#{name}' (tag: #{spec['tag']}) from #{spec['context']}"
323
325
  progress = StagingProgress.new
324
- BuildContext.new(spec['context']).stage(on_progress: progress.method(:tick)) do |staged_context|
326
+ build_context = BuildContext.new(spec['context'], shadow_root: spec['shadow_context'])
327
+ build_context.stage(on_progress: progress.method(:tick)) do |staged_context|
325
328
  progress.finish
326
- settings = { 'context' => staged_context, 'tag' => spec['tag'] }
327
- execute(builder.build(settings: settings, extra: extra), interactive: tty?(true))
329
+ run_staged_build(build_context, staged_context, { 'tag' => spec['tag'] }, extra)
328
330
  end
329
331
  ensure
330
332
  progress&.finish
331
333
  end
332
334
 
335
+ # wslc build crashes (ERROR_UNHANDLED_EXCEPTION) when handed an absolute
336
+ # context path; running from inside the context and passing "." avoids it.
337
+ def run_staged_build(build_context, staged_context, settings, extra)
338
+ warn "wip: using shadow build context at #{staged_context}" if build_context.shadow?
339
+ built = builder.build(settings: settings.merge('context' => '.'), extra: extra)
340
+ execute(built, interactive: tty?(true), chdir: staged_context)
341
+ end
342
+
333
343
  def build_extra_options(extra)
334
344
  extra = extra.dup
335
345
  extra.shift if extra.first == '--'
@@ -355,7 +365,9 @@ module Wip
355
365
 
356
366
  Dir.mktmpdir('wip-sync-build-') do |dir|
357
367
  File.write(File.join(dir, 'Dockerfile'), settings.build['dockerfile'])
358
- execute(builder.sync_build(dir))
368
+ # wslc build crashes (ERROR_UNHANDLED_EXCEPTION) when handed an absolute
369
+ # context path; running from inside the context and passing "." avoids it.
370
+ execute(builder.sync_build('.'), chdir: dir)
359
371
  end
360
372
  end
361
373
 
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'open3'
4
+ require 'pty'
5
+ require 'io/console'
4
6
 
5
7
  module Wip
6
8
  # Executes a built command, pumping its I/O and returning the exit status.
@@ -14,13 +16,14 @@ module Wip
14
16
  @debug = debug
15
17
  end
16
18
 
17
- def run(command, env: {}, interactive: false)
19
+ def run(command, env: {}, interactive: false, chdir: nil)
18
20
  @stderr.puts "+ #{CommandDisplay.for_debug(command)}" if @debug
19
- return run_attached(command, env) if interactive
21
+ return run_attached(command, env, chdir) if interactive
20
22
 
23
+ opts = chdir ? { chdir: chdir } : {}
21
24
  captured = +''
22
25
  status = nil
23
- Open3.popen3(env, *command) do |input, output, error, wait|
26
+ Open3.popen3(env, *command, opts) do |input, output, error, wait|
24
27
  input.close
25
28
  threads = [pump(output, @stdout, captured), pump(error, @stderr, captured)]
26
29
  threads.each(&:join)
@@ -52,15 +55,84 @@ module Wip
52
55
 
53
56
  # Piping stdin/stdout/stderr (as `run` does above) closes the child's
54
57
  # stdin immediately, which breaks anything that reads from the terminal
55
- # (a shell, `rails console`, ...). Inherit the real file descriptors
56
- # instead so the child gets a genuine TTY.
57
- def run_attached(command, env)
58
- pid = Process.spawn(env, *command)
59
- _, status = Process.wait2(pid)
58
+ # (a shell, `rails console`, ...). Run it behind a pseudo-terminal instead
59
+ # of inheriting wip's real fds directly: a pty still gives the child a
60
+ # genuine controlling terminal (job control, Ctrl-C -> SIGINT, isatty-gated
61
+ # rendering all work the same as direct inheritance), but routes output
62
+ # through wip first, so report_hint can still see it — inherited fds go
63
+ # straight to the terminal and wip never would. wip's own terminal is
64
+ # switched to raw mode for the duration so only the pty's line discipline
65
+ # echoes input; without that, every keystroke would echo twice.
66
+ def run_attached(command, env, chdir)
67
+ opts = chdir ? { chdir: chdir } : {}
68
+ captured = +''
69
+ status = nil
70
+ PTY.spawn(env, *command, opts) do |output, input, pid|
71
+ sync_winsize(output)
72
+ with_winsize_sync(output) { with_raw_stdin { pump_attached(output, input, captured) } }
73
+ _, status = Process.wait2(pid)
74
+ end
75
+ report_hint(captured) unless status.success?
60
76
  exitstatus(status)
61
77
  rescue Errno::ENOENT => e
62
78
  @stderr.puts e.message
63
79
  127
80
+ rescue Interrupt
81
+ @stderr.puts "\nwip: interrupted"
82
+ 130
83
+ end
84
+
85
+ def pump_attached(output, input, captured)
86
+ stdin_thread = forward_stdin(input)
87
+ loop do
88
+ chunk = output.readpartial(4096)
89
+ @stdout.write(chunk)
90
+ captured << chunk
91
+ end
92
+ rescue Errno::EIO, IOError
93
+ # the pty's slave side closed when the child exited
94
+ ensure
95
+ stdin_thread.kill
96
+ end
97
+
98
+ # Keeps the child's pty sized to wip's real terminal so full-screen
99
+ # programs (an editor, `less`, ...) render correctly. A non-tty @stdout
100
+ # (piped output, tests) has no size to read, so the pty keeps its default.
101
+ def sync_winsize(output)
102
+ output.winsize = @stdout.winsize if @stdout.respond_to?(:winsize) && @stdout.tty?
103
+ end
104
+
105
+ # The child's pty only gets the terminal size wip had at spawn time
106
+ # (sync_winsize, above) — it's a separate pty from wip's own real one, so
107
+ # later resizes of wip's terminal don't reach it on their own. Trap
108
+ # SIGWINCH for the duration to re-sync it live, restoring whatever handler
109
+ # was already installed (if any) once the command finishes.
110
+ def with_winsize_sync(output)
111
+ return yield unless @stdout.respond_to?(:winsize) && @stdout.tty?
112
+
113
+ previous = Signal.trap('WINCH') { sync_winsize(output) }
114
+ yield
115
+ ensure
116
+ Signal.trap('WINCH', previous) if previous
117
+ end
118
+
119
+ # A non-tty @stdin (piped input, tests) has no raw mode to switch to, so
120
+ # it's forwarded to the child as-is.
121
+ def with_raw_stdin(&)
122
+ return yield unless @stdin.respond_to?(:raw) && @stdin.tty?
123
+
124
+ @stdin.raw(&)
125
+ end
126
+
127
+ def forward_stdin(input)
128
+ Thread.new do
129
+ loop do
130
+ chunk = @stdin.readpartial(4096)
131
+ input.write(chunk)
132
+ end
133
+ rescue IOError, Errno::EIO, Errno::EBADF
134
+ nil
135
+ end
64
136
  end
65
137
 
66
138
  # An Interrupt during the main thread's `join` closes these pipes out from
@@ -24,7 +24,7 @@ module Wip
24
24
  # service already shares one project network (config.rb); and `wslc run`/`exec` has no
25
25
  # restart-policy or capability flag to forward `restart:`/`cap_add:` to.
26
26
  IGNORED_SERVICE_KEYS = %w[tty stdin_open networks restart cap_add].freeze
27
- BUILD_KEYS = %w[context dockerfile args].freeze
27
+ BUILD_KEYS = %w[context dockerfile args shadow_context].freeze
28
28
  SUPPORTED_CONDITIONS = %w[service_started].freeze
29
29
  LIST_HINT = 'only supports short syntax ("host:container"), not long-syntax mappings'
30
30
 
@@ -140,9 +140,11 @@ module Wip
140
140
  unless unknown.empty?
141
141
 
142
142
  context = resolve_context(presence(value['context']) || '.')
143
- dockerfile = presence(value['dockerfile'])
144
- { 'context' => context, 'dockerfile' => dockerfile && Pathname(context).join(dockerfile).to_s,
145
- 'args' => normalize_kv(name, value['args'], 'build.args') }.compact
143
+ # Kept relative to context (not resolved against it) so `-f` still finds it once
144
+ # `wip up`/`wip build` chdir into a staged or shadowed copy of that context.
145
+ { 'context' => context, 'dockerfile' => presence(value['dockerfile']),
146
+ 'args' => normalize_kv(name, value['args'], 'build.args'),
147
+ 'shadow_context' => presence(value['shadow_context']) }.compact
146
148
  end
147
149
 
148
150
  # build.context is relative to compose.yml's own directory (Compose's own rule),
data/lib/wip/config.rb CHANGED
@@ -27,7 +27,18 @@ module Wip
27
27
  end
28
28
 
29
29
  def wslc_command = @raw.dig('wslc', 'command') || 'auto'
30
- def commands = @raw['commands'] || {}
30
+
31
+ # interaction: is dip's name for the same concept — accepted as an alias so a
32
+ # dip.yml can be renamed to wip.yml with fewer edits. The two are mutually
33
+ # exclusive (validate_commands!) rather than merged, so a project doesn't end up
34
+ # with the same command split across both keys.
35
+ def commands
36
+ return @raw['commands'] if @raw.key?('commands')
37
+ return @raw['interaction'] if @raw.key?('interaction')
38
+
39
+ {}
40
+ end
41
+
31
42
  # Raw dependencies: block as written in wip.yml. Under compose-native mode this stays
32
43
  # empty by construction (validate_compose! forbids combining the two) — #dependencies
33
44
  # below is what callers actually want, since it's synthesized from compose.yml there.
@@ -126,6 +137,8 @@ module Wip
126
137
  end
127
138
 
128
139
  def validate_commands!
140
+ raise ConfigError, 'commands is mutually exclusive with interaction — pick one' \
141
+ if @raw.key?('commands') && @raw.key?('interaction')
129
142
  raise ConfigError, 'commands must be a mapping' unless commands.is_a?(Hash)
130
143
 
131
144
  commands.each { |name, entry| validate_command!(name, entry) }
@@ -175,9 +188,22 @@ module Wip
175
188
  type = entry['type'] || (name == 'build' ? 'build' : 'exec')
176
189
  raise ConfigError, "Invalid command type for #{name}: #{type}" unless %w[exec run build].include?(type)
177
190
 
191
+ validate_shadow_context!(name, entry, type)
192
+
178
193
  entry['env']&.transform_values!(&:to_s)
179
194
  end
180
195
 
196
+ # shadow_context names a Windows-side mirror of the build context, so it only
197
+ # means anything on a build command — and only as a real path.
198
+ def validate_shadow_context!(name, entry, type)
199
+ return unless entry.key?('shadow_context')
200
+
201
+ shadow_context = entry['shadow_context']
202
+ return if type == 'build' && shadow_context.is_a?(String) && !shadow_context.empty?
203
+
204
+ raise ConfigError, "commands.#{name}.shadow_context must be a non-empty path for a build command"
205
+ end
206
+
181
207
  def validate_dependency!(name, entry)
182
208
  raise ConfigError, "dependencies.#{name} must be a mapping" unless entry.is_a?(Hash)
183
209
  raise ConfigError, "dependencies.#{name} must set image" if entry['image'].to_s.empty?
@@ -31,17 +31,18 @@ module Wip
31
31
  # example uses.
32
32
  FALLBACK_EXCLUDES = %w[.git tmp/ node_modules/].freeze
33
33
 
34
- # Appended to both templates after dependencies/compose. commands: itself is left
35
- # commented since an empty commands: {} is indistinguishable from omitting the key.
34
+ # Appended to both templates after dependencies/compose. interaction: itself is left
35
+ # commented since an empty interaction: {} is indistinguishable from omitting the key.
36
36
  COMMANDS_EXAMPLE = <<~YAML.chomp
37
37
  # optional; custom subcommands, e.g. `wip test` — see README
38
- # commands:
38
+ # (interaction: is the primary key; commands: is accepted as an alias — the two are
39
+ # mutually exclusive, so pick one)
40
+ # interaction:
39
41
  # test:
40
42
  # optional; exec (default, runs in the running container) | run (fresh container) | build
41
43
  # type: exec
42
44
 
43
- # TODO
44
- # command: bundle exec rspec
45
+ # command: ls
45
46
  YAML
46
47
 
47
48
  def initialize(dir: Dir.pwd, template: nil)
data/lib/wip/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Wip
4
- VERSION = '1.0.0'
4
+ VERSION = '1.1.1'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: wslc-wip
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Wip contributors