wslc-wip 0.4.3 → 0.6.0

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: be995c6781c440a7fd84dd3c3bbbddd6799154b59694edce5aa653d80c7d6e53
4
- data.tar.gz: 7fb1638a125b2bce92212e8ff525a38e31e2d8aa4386e3e74e379420d4df9c91
3
+ metadata.gz: c125a123c76462cbb6b78fe0ead5ea400b8fd0f7a8c825421985547190603d02
4
+ data.tar.gz: 0762dbbf6480a84d9ef58d9481d1398672b5d2ef7e8d644d39be655558704022
5
5
  SHA512:
6
- metadata.gz: 539721780baa7f366f215581629a761c85e396823e05ff08a3da97c2d0de7808d1470c959240867a5a0ab762f4441ea8b2cb22c92f0063712a18c6b0f80dc4fe
7
- data.tar.gz: fc00ceceeeb01cd63ac931ff2ffeea1f64848f38a1222b60b8c5de7a501e914a3820d61abbd7d437ebd412e2046958223edab7b46d582b5cb6ef693badf5cc3e
6
+ metadata.gz: b91868415f3069167ddd479616d9bd2937d3684af2e7c6228789b198cd7af952e9d2705fe363877ef05a6831a7f5a8e9a203e073623bb0c4b46102f2771429c5
7
+ data.tar.gz: 88b8d3fdec972776b0bdefedd5753ebcb4a025cb9bf54122ad7eb723e663f4cac64dff5a4ec201bd45b5959b9f44ba6801e8441419ff72f388631eb7eee80f67
data/README.md CHANGED
@@ -103,6 +103,22 @@ commands:
103
103
  credential, or auth. Keep real secrets out of the config file and in your runtime environment
104
104
  instead.
105
105
 
106
+ ### .env
107
+
108
+ Like `docker compose`, `wip` automatically loads a `.env` file next to `wip.yml` (one `KEY=VALUE`
109
+ per line; `#` comments, blank lines, `export` prefixes, and quoted values are all supported) and
110
+ passes its keys through as container environment variables on `build`, `up`, `run`, `exec`, and
111
+ custom commands. `.env` only fills in keys that aren't already set by `defaults.env` or a
112
+ command/dependency's own `env` — those always win on conflict. Pass `--env-file PATH` to load a
113
+ different file instead.
114
+
115
+ ### .dockerignore
116
+
117
+ `wip build` reads `.dockerignore` from the build context and stages a filtered copy of the
118
+ context (skipping anything it matches) before handing it to `wslc build`, since `wslc` sends the
119
+ context as-is otherwise. If there's no `.dockerignore`, the original context directory is used
120
+ directly with no copying.
121
+
106
122
  ### Dependency containers
107
123
 
108
124
  If your app needs sidecar services (a database, Redis, ...), declare them under `dependencies`
@@ -113,6 +129,47 @@ the same way Compose's service names resolve. `wip down` tears the main containe
113
129
  dependencies down (the network itself is left in place). Each dependency entry accepts `image`
114
130
  (required), `command`, `env`, `ports`, `volumes`, and `workdir` — the same shape as `defaults`.
115
131
 
132
+ ### Compose mode
133
+
134
+ If your project already has a real `compose.yml`, don't duplicate it in `dependencies:` — point
135
+ `wip` at it instead:
136
+
137
+ ```yaml
138
+ version: 1
139
+ compose:
140
+ service: app # required: which compose service wip run/exec/NAME target
141
+ command: wslc-compose # required: the compose-for-wslc binary/path you have installed
142
+ file: compose.yml # optional; auto-detected next to wip.yml otherwise
143
+ project: myapp # optional; omitted lets the compose tool pick its own default
144
+ ```
145
+
146
+ `compose:` is mutually exclusive with `dependencies:`/`defaults.network` — pick one orchestration
147
+ path per project. In compose mode, `wip` becomes a thin bridge to an external compose-for-`wslc`
148
+ CLI rather than reimplementing Compose itself.
149
+
150
+ `wslc` itself has no native Compose support yet (tracked upstream in
151
+ [microsoft/WSL#40948](https://github.com/microsoft/WSL/issues/40948)), and until it does,
152
+ independent third-party tools fill the gap — for example
153
+ [bacarndiaye/wslc-compose](https://github.com/bacarndiaye/wslc-compose) (Python) and
154
+ [inuyume/wslc-compose](https://github.com/inuyume/wslc-compose) (Go), among others. `wslc` is new
155
+ and still evolving, so expect more of these to show up (and existing ones to change) over time.
156
+ `wip` deliberately doesn't pick a winner or default to any of them (unlike `wslc.command`, which
157
+ defaults to `auto` and searches for `wslc.exe`/`wslc`): `compose.command` is required and treats
158
+ every implementation equally — set it to whichever binary name or absolute path you've installed.
159
+ Whichever one you use needs to understand `-f FILE [-p PROJECT] up|down|exec|logs`, the subset of
160
+ the Compose CLI vocabulary `wip` drives. `wip doctor` reports whether the configured command is
161
+ found, its version, and which compose file `wip` resolved.
162
+
163
+ - `wip up`/`wip down` delegate straight to `<compose command> up -d`/`down`.
164
+ - `wip exec`/`wip NAME` (custom `commands:`) run inside `compose.service`.
165
+ - `wip shell` also goes through the bridge: unless `commands.shell` is defined in `wip.yml`, it
166
+ `exec`s `bash` against `compose.service`, falling back to `sh`.
167
+ - `wip logs [-f] [SERVICE...]` is only available in compose mode.
168
+ - `wip run` has no ephemeral-container equivalent in this exec-only vocabulary, so it falls back
169
+ to `exec` against the already-running `compose.service` (wip warns when this happens).
170
+ - `commands:` entries with `type: run`/`type: build` aren't supported in compose mode — use your
171
+ compose tool's own `build`/`up --build` directly; compose owns builds for its own services.
172
+
116
173
  ## Commands
117
174
 
118
175
  | Command | Description |
@@ -124,8 +181,9 @@ dependencies down (the network itself is left in place). Each dependency entry a
124
181
  | `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 |
125
182
  | `wip down` | Stop and remove `defaults.container` and its `dependencies` |
126
183
  | `wip exec [--no-interactive] COMMAND...` | Run a command in the existing container |
127
- | `wip run [--no-interactive] COMMAND...` | Run a command in a new `--rm` container |
184
+ | `wip run [--no-interactive] COMMAND...` | Run a command in a new `--rm` container (compose mode: `exec`s into `compose.service` instead) |
128
185
  | `wip shell` | Open the configured shell, falling back to `bash` then `sh` |
186
+ | `wip logs [-f] [SERVICE...]` | Follow compose service logs (compose mode only) |
129
187
  | `wip NAME ARGS...` | Run `commands.NAME`, appending any extra arguments |
130
188
 
131
189
  TTY allocation is decided by combining the command's config, the CLI option, and whether both
@@ -193,6 +251,23 @@ When `pull access denied` (or similar) is detected, wip suggests how to log in:
193
251
  wslc registry login -u <username> docker.io
194
252
  ```
195
253
 
254
+ ### Slow boot when the app directory is bind-mounted
255
+
256
+ wslc containers run in their own VM, so a bind-mounted host directory (`.:/app`) is always shared
257
+ in over virtiofs, even when the host path is already on WSL's native filesystem. Frameworks that
258
+ scan large directory trees at startup (Ruby's Zeitwerk autoloader, for example) issue many small
259
+ per-file stat/open calls, and each one is a round trip through that layer — CPU on the Windows
260
+ side can look busy while almost no data is actually transferred, and the process can appear hung
261
+ for minutes with barely any resource usage to show for it.
262
+
263
+ If a debug log shows a boot-time command "stuck" with low CPU/mem/IO in `resource_monitor`'s
264
+ output, this is worth checking before assuming the app itself is broken. The workaround is to stop
265
+ bind-mounting the source live and instead mirror it into a named volume: mount the host path
266
+ read-only (`.:/host-src:ro`), add a named volume for `/app`, and have the container's entrypoint
267
+ `rsync` from the read-only mount into the volume before boot, then keep syncing in the background
268
+ (e.g. every couple of seconds) so edits still show up with a short delay. The app then only ever
269
+ touches fast native storage once it's running.
270
+
196
271
  ### CPU architecture mismatch
197
272
 
198
273
  Check the image and publish a multi-arch (amd64/arm64) image:
@@ -227,10 +302,42 @@ checklist.
227
302
 
228
303
  ## Not in the initial release
229
304
 
230
- Full Compose compatibility (multiple networks, `depends_on` ordering/health checks, profiles), a
231
- resident/daemon process, a GUI, PowerShell-specific tuning, direct registry API/manifest parsing,
232
- self-update, and plugins are all unimplemented. Likely future additions: a richer config schema,
233
- lifecycle hooks, platform selection, and more detailed diagnostics.
305
+ Full Compose compatibility isn't reimplemented in `wip` itself, but is available by delegating to
306
+ a third-party compose-for-`wslc` tool see [Compose mode](#compose-mode). A resident/daemon
307
+ process, a GUI, PowerShell-specific tuning, direct registry API/manifest parsing, self-update, and
308
+ plugins are all unimplemented.
309
+
310
+ ## Roadmap
311
+
312
+ `wip` already covers most of what [`dip`](https://github.com/bibendi/dip) adds on top of Compose —
313
+ named commands (`commands:`), `run`/`exec` hidden behind a single verb, `.env` passthrough, and
314
+ sidecar services via `dependencies:` + `defaults.network`. Fuller Compose semantics
315
+ (`depends_on` ordering/health checks, log aggregation, named volumes, profiles, scaling) are now
316
+ handled by delegating to a separate compose-for-`wslc` tool in [compose mode](#compose-mode)
317
+ rather than being reimplemented in `wip` itself. Which of those actually work is entirely up to
318
+ the external tool you point `compose.command` at — `wip` only forwards
319
+ `-f FILE [-p PROJECT] up|down|exec|logs`, so treat that list as what Compose offers, not as
320
+ something `wip` guarantees. See that section for what compose mode covers
321
+ and its current limitations (`run`, and `commands:` of type `run`/`build`). What's still planned
322
+ for `wip`, roughly in priority order:
323
+
324
+ 1. **`wip provision`** — a dip-style one-shot bootstrap hook (build → up deps → install deps →
325
+ create/migrate/seed DB) so a new contributor can go from `git clone` to a working environment
326
+ in two commands (`wip provision && wip up`).
327
+ 2. **Config file merging** — `--config` currently accepts one file; support layering
328
+ (`wip.yml` + `wip.override.yml`, or a `WIP_CONFIG` list) for dev/CI/debug variants without
329
+ duplicating the whole file, plus a `wip config --resolved` view of the merged result.
330
+ 3. **Bind-mount boot time (`rails c`, `bundle`, ...)** — commands like `wip rails c` still start
331
+ noticeably slower than the equivalent under `docker compose`, mostly from WSL2 bind-mounted
332
+ (`.:/app`-style) volumes doing many small reads for gems/`node_modules` (use `--debug` to
333
+ confirm it's disk I/O and not `wip`'s own overhead). This isn't really `wip`'s responsibility —
334
+ the fix has to come from either the mount layer or the project's own volume layout. We're also
335
+ hoping for improvements on the `wslc` side itself (faster bind-mount/cache behavior); `wip`
336
+ will pick those up for free as soon as they land.
337
+
338
+ Each of these should stay additive to the existing `wip.yml` shape — no breaking changes to
339
+ `defaults`, `commands`, `dependencies`, or `compose` are planned. A resident daemon and a GUI
340
+ remain out of scope; see "Not in the initial release" above.
234
341
 
235
342
  ## License
236
343
 
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'pathname'
5
+ require 'tmpdir'
6
+
7
+ module Wip
8
+ # Stages a build context into a scratch directory with anything matched by
9
+ # .dockerignore left out, since wslc build (unlike `docker build`) sends the
10
+ # context as-is instead of filtering it itself.
11
+ class BuildContext
12
+ def initialize(context, ignore: nil)
13
+ @root = Pathname(context).expand_path
14
+ @ignore = ignore || DockerIgnore.load(@root.join('.dockerignore'))
15
+ end
16
+
17
+ def stage
18
+ return yield @root.to_s if @ignore.empty?
19
+
20
+ Dir.mktmpdir('wip-build-context-') do |dir|
21
+ copy_included_files(Pathname(dir))
22
+ yield dir
23
+ end
24
+ end
25
+
26
+ private
27
+
28
+ def copy_included_files(destination)
29
+ each_included_file do |relative_path|
30
+ target = destination.join(relative_path)
31
+ FileUtils.mkdir_p(target.dirname)
32
+ FileUtils.cp(@root.join(relative_path), target)
33
+ end
34
+ end
35
+
36
+ def each_included_file
37
+ Dir.glob('**/*', File::FNM_DOTMATCH, base: @root.to_s).each do |entry|
38
+ next if %w[. ..].include?(entry)
39
+
40
+ path = @root.join(entry)
41
+ next unless path.file?
42
+ next if @ignore.ignored?(entry)
43
+
44
+ yield entry
45
+ end
46
+ end
47
+ end
48
+ end
data/lib/wip/cli.rb CHANGED
@@ -4,11 +4,13 @@ require 'thor'
4
4
  require 'yaml'
5
5
  require 'json'
6
6
  require 'stringio'
7
+ require 'shellwords'
7
8
 
8
9
  module Wip
9
10
  # Thor-based command-line interface for wip.
10
11
  class CLI < Thor
11
12
  class_option :config, type: :string, desc: 'Path to wip.yml'
13
+ class_option :env_file, type: :string, desc: 'Path to a dotenv file (default: .env next to wip.yml)'
12
14
  class_option :debug, type: :boolean, default: false, desc: 'Print progress and timing for each step'
13
15
  class_option :debug_log, type: :string, desc: 'Where --debug snapshots go: a file path, or "-" for inline'
14
16
  default_task :dispatch
@@ -52,12 +54,20 @@ module Wip
52
54
  desc 'build [OPTIONS]', 'Build the configured image'
53
55
  def build(*extra)
54
56
  extra.shift if extra.first == '--'
55
- execute(builder.build(settings: load_config.command('build') || {}, extra: extra))
57
+ settings = load_config.command('build') || {}
58
+ context = settings['context'] || load_config.defaults['context'] || '.'
59
+ BuildContext.new(context).stage do |staged_context|
60
+ execute(builder.build(settings: settings.merge('context' => staged_context), extra: extra))
61
+ end
56
62
  end
57
63
 
58
64
  desc 'up', 'Start the configured container and its dependencies, creating them if necessary'
59
65
  option :detach, type: :boolean, default: false, aliases: '-d'
60
66
  def up
67
+ if load_config.compose?
68
+ return execute(compose_bridge.up(detach: options[:detach]), interactive: tty?(!options[:detach]))
69
+ end
70
+
61
71
  ensure_network
62
72
  load_config.dependencies.each_key { |name| ensure_dependency(name) }
63
73
  ensure_container
@@ -65,6 +75,8 @@ module Wip
65
75
 
66
76
  desc 'down', 'Stop and remove the configured container and its dependencies'
67
77
  def down
78
+ return execute(compose_bridge.down, exit_on_failure: false) if load_config.compose?
79
+
68
80
  execute(builder.down, exit_on_failure: false)
69
81
  execute(builder.remove, exit_on_failure: false)
70
82
  load_config.dependencies.each_key do |name|
@@ -76,41 +88,51 @@ module Wip
76
88
  desc 'exec COMMAND...', 'Execute a command in the running container'
77
89
  option :interactive, type: :boolean, default: true
78
90
  def exec(*command)
79
- tty = builder.tty?(options[:interactive])
80
- execute(builder.exec(command, interactive: options[:interactive]), interactive: tty)
91
+ execute(exec_target(command, interactive: options[:interactive]), interactive: tty?(options[:interactive]))
81
92
  end
82
93
 
83
94
  desc 'run COMMAND...', 'Run a command in a new container'
84
95
  option :interactive, type: :boolean, default: true
85
96
  def run_command(*command)
86
- tty = builder.tty?(options[:interactive])
87
- execute(builder.run(command, interactive: options[:interactive]), interactive: tty)
97
+ if load_config.compose?
98
+ warn "wip: compose mode has no ephemeral 'run'; executing in the running " \
99
+ "'#{load_config.compose_service}' service instead"
100
+ return execute(exec_target(command, interactive: options[:interactive]),
101
+ interactive: tty?(options[:interactive]))
102
+ end
103
+
104
+ execute(builder.run(command, interactive: options[:interactive]), interactive: tty?(options[:interactive]))
88
105
  end
89
106
  map 'run' => :run_command
90
107
 
91
108
  desc 'shell', 'Open a shell in the configured container'
92
109
  def shell_command
93
110
  configured = load_config.command('shell')
94
- if configured
95
- tty = builder.tty?(configured.fetch('interactive', false))
96
- return execute(builder.custom('shell', []), interactive: tty)
97
- end
111
+ return dispatch('shell') if configured
98
112
 
99
- tty = builder.tty?(true)
100
- code = execute(builder.exec(['bash'], settings: { 'interactive' => true }, interactive: true),
101
- interactive: tty, exit_on_failure: false)
113
+ code = execute(exec_target(['bash'], interactive: true), interactive: tty?(true), exit_on_failure: false)
102
114
  return if code.zero?
103
115
 
104
- execute(builder.exec(['sh'], settings: { 'interactive' => true }, interactive: true), interactive: tty)
116
+ execute(exec_target(['sh'], interactive: true), interactive: tty?(true))
105
117
  end
106
118
  map 'shell' => :shell_command
107
119
 
120
+ desc 'logs [SERVICE...]', 'Follow logs from compose services (compose mode only)'
121
+ option :follow, type: :boolean, default: true, aliases: '-f'
122
+ def logs(*services)
123
+ raise ConfigError, '`wip logs` is only available in compose mode' unless load_config.compose?
124
+
125
+ execute(compose_bridge.logs(services: services, follow: options[:follow]), interactive: true)
126
+ end
127
+
108
128
  desc 'dispatch COMMAND [ARGS...]', 'Run a command defined in wip.yml'
109
129
  def dispatch(name = nil, *arguments)
110
130
  raise ConfigError, 'A command is required' unless name
111
131
 
112
132
  values = load_config.command(name) || raise(ConfigError, "Unknown command: #{name}")
113
- execute(builder.custom(name, arguments), interactive: builder.tty?(values.fetch('interactive', false)))
133
+ return dispatch_compose(name, values, arguments) if load_config.compose?
134
+
135
+ execute(builder.custom(name, arguments), interactive: tty?(values.fetch('interactive', false)))
114
136
  end
115
137
 
116
138
  private
@@ -118,7 +140,41 @@ module Wip
118
140
  def loader = ConfigLoader.new(path: options[:config])
119
141
  def load_config = (@load_config ||= loader.load)
120
142
  def resolver = CommandResolver.new
121
- def builder = CommandBuilder.new(wslc: resolver.resolve(load_config.wslc_command), config: load_config)
143
+
144
+ def dotenv_path
145
+ options[:env_file] ? Pathname(options[:env_file]).expand_path : Pathname(load_config.path).dirname.join('.env')
146
+ end
147
+
148
+ def dotenv = @dotenv ||= DotenvLoader.new(dotenv_path).load
149
+
150
+ def builder
151
+ CommandBuilder.new(wslc: resolver.resolve(load_config.wslc_command), config: load_config, dotenv: dotenv)
152
+ end
153
+
154
+ def compose_bridge = @compose_bridge ||= ComposeBridge.for(load_config)
155
+
156
+ def tty?(requested) = requested && Environment.new.interactive?
157
+
158
+ def exec_target(arguments, interactive:)
159
+ if load_config.compose?
160
+ return compose_bridge.exec(load_config.compose_service, arguments,
161
+ interactive: interactive)
162
+ end
163
+
164
+ builder.exec(arguments, interactive: interactive)
165
+ end
166
+
167
+ def dispatch_compose(name, values, arguments)
168
+ type = values['type'] || 'exec'
169
+ unless type == 'exec'
170
+ raise ConfigError, "commands.#{name}: type '#{type}' is not supported in compose mode " \
171
+ '(use `wslc-compose build`/`up --build` directly)'
172
+ end
173
+
174
+ command = Shellwords.split(values['command'].to_s) + arguments
175
+ interactive = values.fetch('interactive', false)
176
+ execute(exec_target(command, interactive: interactive), interactive: tty?(interactive))
177
+ end
122
178
 
123
179
  def execute(command, interactive: false, exit_on_failure: true)
124
180
  runner = CommandRunner.new(debug: debug?)
@@ -174,7 +230,7 @@ module Wip
174
230
 
175
231
  def ensure_container
176
232
  container = load_config.defaults['container']
177
- interactive = builder.tty?(!options[:detach])
233
+ interactive = tty?(!options[:detach])
178
234
  if resource_exists?(builder.find)
179
235
  warn "wip: starting existing container '#{container}'"
180
236
  execute(builder.start(detach: options[:detach]), interactive: interactive)
@@ -5,10 +5,11 @@ require 'shellwords'
5
5
  module Wip
6
6
  # Builds the argument arrays for wslc build/exec/run/custom invocations.
7
7
  class CommandBuilder
8
- def initialize(wslc:, config:, environment: Environment.new)
8
+ def initialize(wslc:, config:, environment: Environment.new, dotenv: {})
9
9
  @wslc = wslc
10
10
  @config = config
11
11
  @environment = environment
12
+ @dotenv = dotenv
12
13
  end
13
14
 
14
15
  def exec(arguments, settings: {}, interactive: true)
@@ -115,7 +116,7 @@ module Wip
115
116
  def options(values, include_container: false, include_publish: true)
116
117
  result = []
117
118
  result.push('-w', values['workdir']) unless values['workdir'].to_s.empty?
118
- values.fetch('env', {}).each { |key, value| result.push('-e', "#{key}=#{value}") }
119
+ merged_env(values).each { |key, value| result.push('-e', "#{key}=#{value}") }
119
120
  if include_publish
120
121
  Array(values['ports']).each { |port| result.push('-p', port.to_s) }
121
122
  Array(values['volumes']).each { |volume| result.push('-v', volume.to_s) }
@@ -124,6 +125,9 @@ module Wip
124
125
  result
125
126
  end
126
127
 
128
+ # .env supplies defaults; env set in wip.yml (defaults or per-command) wins on conflict.
129
+ def merged_env(values) = @dotenv.merge(values.fetch('env', {}))
130
+
127
131
  def required(values, key)
128
132
  value = values[key]
129
133
  raise ConfigError, "Configured #{key} must not be empty" if value.to_s.empty?
@@ -1,20 +1,29 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Wip
4
- # Locates the wslc/wslc.exe executable on the current system.
4
+ # Locates an executable on the current system, trying a list of candidates.
5
5
  class CommandResolver
6
6
  CANDIDATES = ['wslc.exe', 'wslc', '/mnt/c/Windows/System32/wslc.exe'].freeze
7
+ DEFAULT_INSTALL_HINT = <<~HINT.chomp
8
+ Install or update the WSL container tooling, then run:
7
9
 
8
- def initialize(path: ENV.fetch('PATH', ''), executable: nil)
10
+ wip doctor
11
+ HINT
12
+
13
+ def initialize(path: ENV.fetch('PATH', ''), executable: nil, candidates: CANDIDATES, label: 'WSLC',
14
+ install_hint: DEFAULT_INSTALL_HINT)
9
15
  @path = path
10
16
  @executable = executable || method(:executable?)
17
+ @candidates = candidates
18
+ @label = label
19
+ @install_hint = install_hint
11
20
  end
12
21
 
13
22
  def resolve(configured = 'auto')
14
23
  return configured if configured != 'auto' && @executable.call(configured)
15
- return raise_not_found if configured != 'auto'
24
+ return raise_not_found([configured]) if configured != 'auto'
16
25
 
17
- CANDIDATES.find { |candidate| @executable.call(candidate) } || raise_not_found
26
+ @candidates.find { |candidate| @executable.call(candidate) } || raise_not_found(@candidates)
18
27
  end
19
28
 
20
29
  private
@@ -25,16 +34,14 @@ module Wip
25
34
  @path.split(File::PATH_SEPARATOR).any? { |directory| File.executable?(File.join(directory, command)) }
26
35
  end
27
36
 
28
- def raise_not_found
37
+ def raise_not_found(attempted)
29
38
  raise CommandNotFoundError, <<~MESSAGE
30
- WSLC was not found.
39
+ #{@label} was not found.
31
40
 
32
41
  Checked:
33
- #{CANDIDATES.join("\n ")}
34
-
35
- Install or update the WSL container tooling, then run:
42
+ #{attempted.join("\n ")}
36
43
 
37
- wip doctor
44
+ #{@install_hint}
38
45
  MESSAGE
39
46
  end
40
47
  end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wip
4
+ # Builds argument arrays for wslc-compose invocations, delegating orchestration
5
+ # to a real compose.yml instead of wip's own dependencies:/network handling.
6
+ class ComposeBridge
7
+ FILENAMES = %w[compose.yml compose.yaml docker-compose.yml docker-compose.yaml].freeze
8
+ # No default candidates: wip doesn't favor any one compose-for-wslc implementation.
9
+ # compose.command must name the one you've installed.
10
+ INSTALL_HINT = <<~HINT.chomp
11
+ wip doesn't bundle or pin a compose-for-wslc implementation — install one and set
12
+ compose.command in wip.yml to its binary name or path, e.g.:
13
+
14
+ https://github.com/bacarndiaye/wslc-compose
15
+ https://github.com/inuyume/wslc-compose
16
+ HINT
17
+
18
+ def self.for(config, resolver: CommandResolver.new(candidates: [], label: 'compose command',
19
+ install_hint: INSTALL_HINT))
20
+ new(compose_command: resolver.resolve(config.compose_command), file: file_path(config),
21
+ project: config.compose_project)
22
+ end
23
+
24
+ def self.file_path(config)
25
+ base = Pathname(config.path).dirname
26
+ configured = config.compose_file
27
+ # Relative compose.file is resolved against wip.yml, not the current directory,
28
+ # so `wip` behaves the same from any subdirectory (matching auto-detection below).
29
+ return base.join(configured).expand_path if configured
30
+
31
+ FILENAMES.map { |name| base.join(name) }.find(&:file?) ||
32
+ raise(ConfigError, "compose mode: no compose file found next to #{config.path} " \
33
+ "(looked for #{FILENAMES.join(', ')})")
34
+ end
35
+
36
+ def initialize(compose_command:, file:, project: nil)
37
+ @compose_command = compose_command
38
+ @file = file
39
+ @project = project
40
+ end
41
+
42
+ def up(detach: true)
43
+ command = base.push('up')
44
+ command << '-d' if detach
45
+ command
46
+ end
47
+
48
+ def down = base.push('down')
49
+
50
+ def exec(service, arguments, interactive: true)
51
+ command = base.push('exec')
52
+ command << '-T' unless interactive
53
+ command.push(service.to_s).concat(arguments)
54
+ end
55
+
56
+ def logs(services: [], follow: true)
57
+ command = base.push('logs')
58
+ command << '-f' if follow
59
+ command.concat(services.map(&:to_s))
60
+ end
61
+
62
+ private
63
+
64
+ def base
65
+ command = [@compose_command, '-f', @file.to_s]
66
+ command.push('-p', @project) if @project
67
+ command
68
+ end
69
+ end
70
+ end
data/lib/wip/config.rb CHANGED
@@ -20,6 +20,12 @@ module Wip
20
20
  def up_command = @raw.dig('up', 'command')
21
21
  def dependencies = @raw['dependencies'] || {}
22
22
  def network = defaults['network']
23
+ def compose = @raw['compose']
24
+ def compose? = !!compose
25
+ def compose_service = compose && compose['service']
26
+ def compose_file = compose && compose['file']
27
+ def compose_project = compose && compose['project']
28
+ def compose_command = compose && compose['command']
23
29
 
24
30
  def command(name)
25
31
  entry = commands[name.to_s]
@@ -37,7 +43,7 @@ module Wip
37
43
 
38
44
  def to_h(redact: true)
39
45
  value = { 'version' => 1, 'wslc' => { 'command' => wslc_command }, 'defaults' => defaults,
40
- 'up' => { 'command' => up_command }, 'dependencies' => dependencies,
46
+ 'up' => { 'command' => up_command }, 'dependencies' => dependencies, 'compose' => compose,
41
47
  'commands' => commands.transform_values { |entry| defaults.merge('type' => 'exec').merge(entry) } }
42
48
  redact ? redact_secrets(value) : value
43
49
  end
@@ -50,6 +56,7 @@ module Wip
50
56
 
51
57
  validate_commands!
52
58
  validate_dependencies!
59
+ validate_compose!
53
60
  end
54
61
 
55
62
  def validate_commands!
@@ -64,6 +71,15 @@ module Wip
64
71
  dependencies.each { |name, entry| validate_dependency!(name, entry) }
65
72
  end
66
73
 
74
+ def validate_compose!
75
+ return unless @raw.key?('compose')
76
+ raise ConfigError, 'compose must be a mapping' unless compose.is_a?(Hash)
77
+ raise ConfigError, 'compose.service must not be empty' if compose_service.to_s.empty?
78
+ raise ConfigError, 'compose.command must not be empty' if compose_command.to_s.empty?
79
+ raise ConfigError, 'compose is mutually exclusive with dependencies' if dependencies.any?
80
+ raise ConfigError, 'compose is mutually exclusive with defaults.network' if network
81
+ end
82
+
67
83
  def validate_command!(name, entry)
68
84
  raise ConfigError, "commands.#{name} must be a mapping" unless entry.is_a?(Hash)
69
85
 
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pathname'
4
+
5
+ module Wip
6
+ # Parses a .dockerignore file and decides whether a build-context-relative
7
+ # path should be excluded, following the same pattern rules as the Docker
8
+ # CLI (gitignore-like globs, later rules override earlier ones, `!` negates).
9
+ class DockerIgnore
10
+ Rule = Struct.new(:pattern, :negate)
11
+
12
+ FNMATCH_FLAGS = File::FNM_PATHNAME | File::FNM_DOTMATCH | File::FNM_EXTGLOB
13
+
14
+ def self.load(path)
15
+ path = Pathname(path)
16
+ return new([]) unless path.file?
17
+
18
+ new(path.readlines)
19
+ end
20
+
21
+ def initialize(lines)
22
+ @rules = lines.filter_map { |line| parse(line) }
23
+ end
24
+
25
+ def empty? = @rules.empty?
26
+
27
+ def ignored?(relative_path)
28
+ @rules.reduce(false) do |ignored, rule|
29
+ matches?(rule.pattern, relative_path) ? !rule.negate : ignored
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ def parse(line)
36
+ line = line.strip
37
+ return nil if line.empty? || line.start_with?('#')
38
+
39
+ negate = line.start_with?('!')
40
+ pattern = negate ? line[1..] : line
41
+ pattern = pattern.delete_suffix('/')
42
+ anchored = pattern.start_with?('/')
43
+ pattern = pattern.delete_prefix('/')
44
+ pattern = "**/#{pattern}" if !anchored && !pattern.include?('/')
45
+ Rule.new(pattern, negate)
46
+ end
47
+
48
+ # A match on a directory component also excludes everything under it,
49
+ # so every prefix of the path (not just the full path) is tested.
50
+ def matches?(pattern, relative_path)
51
+ components = relative_path.split('/')
52
+ (1..components.size).any? do |i|
53
+ File.fnmatch(pattern, components[0...i].join('/'), FNMATCH_FLAGS)
54
+ end
55
+ end
56
+ end
57
+ end
data/lib/wip/doctor.rb CHANGED
@@ -7,10 +7,13 @@ module Wip
7
7
  class Doctor
8
8
  Result = Data.define(:level, :message)
9
9
 
10
- def initialize(loader:, resolver: CommandResolver.new, environment: Environment.new)
10
+ def initialize(loader:, resolver: CommandResolver.new, environment: Environment.new,
11
+ compose_resolver: CommandResolver.new(candidates: [], label: 'compose command',
12
+ install_hint: ComposeBridge::INSTALL_HINT))
11
13
  @loader = loader
12
14
  @resolver = resolver
13
15
  @environment = environment
16
+ @compose_resolver = compose_resolver
14
17
  end
15
18
 
16
19
  def call
@@ -19,8 +22,8 @@ module Wip
19
22
  results << interop_result unless @environment.windows?
20
23
  results << Result.new(:ok, "Architecture: #{@environment.architecture}")
21
24
  config = load_config(results)
22
- command = resolve(config, results) if config
23
- check_version(command, results) if command
25
+ check_wslc(config, results) if config
26
+ check_compose(config, results) if config&.compose?
24
27
  results << result(command_available?('git') ? :ok : :warn, 'Git is available',
25
28
  'Git is not available to the WSLC build environment')
26
29
  results
@@ -41,7 +44,7 @@ module Wip
41
44
 
42
45
  def load_config(results)
43
46
  config = @loader.load
44
- invalid = %w[container image].select { |key| config.defaults[key].to_s.empty? }
47
+ invalid = config.compose? ? [] : %w[container image].select { |key| config.defaults[key].to_s.empty? }
45
48
  results << Result.new(invalid.empty? ? :ok : :fail,
46
49
  invalid.empty? ? 'Loaded wip.yml' : "Empty defaults: #{invalid.join(', ')}")
47
50
  config
@@ -50,6 +53,11 @@ module Wip
50
53
  nil
51
54
  end
52
55
 
56
+ def check_wslc(config, results)
57
+ command = resolve(config, results)
58
+ check_version(command, results) if command
59
+ end
60
+
53
61
  def resolve(config, results)
54
62
  command = @resolver.resolve(config.wslc_command)
55
63
  results << Result.new(:ok, "Found #{command}")
@@ -59,12 +67,35 @@ module Wip
59
67
  nil
60
68
  end
61
69
 
62
- def check_version(command, results)
70
+ def check_version(command, results, label: 'WSLC')
63
71
  _output, status = Open3.capture2e(command, 'version')
64
72
  results << Result.new(status.success? ? :ok : :fail,
65
- status.success? ? 'WSLC is available' : 'WSLC version failed')
73
+ status.success? ? "#{label} is available" : "#{label} version failed")
66
74
  rescue Errno::ENOENT
67
- results << Result.new(:fail, 'WSLC version failed')
75
+ results << Result.new(:fail, "#{label} version failed")
76
+ end
77
+
78
+ def check_compose(config, results)
79
+ command = resolve_compose(config, results)
80
+ check_version(command, results, label: 'compose command') if command
81
+ check_compose_file(config, results)
82
+ end
83
+
84
+ def resolve_compose(config, results)
85
+ command = @compose_resolver.resolve(config.compose_command)
86
+ results << Result.new(:ok, "Found #{command}")
87
+ command
88
+ rescue CommandNotFoundError => e
89
+ results << Result.new(:fail, e.message)
90
+ nil
91
+ end
92
+
93
+ def check_compose_file(config, results)
94
+ path = ComposeBridge.file_path(config)
95
+ results << result(path.file? ? :ok : :fail, "Found compose file #{path}",
96
+ "Compose file not found: #{path}")
97
+ rescue ConfigError => e
98
+ results << Result.new(:fail, e.message)
68
99
  end
69
100
 
70
101
  def result(condition, ok_message, fail_message)
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pathname'
4
+
5
+ module Wip
6
+ # Parses a .env file the way `docker compose` does, so values don't have to
7
+ # be duplicated into wip.yml just to reach the container as -e flags.
8
+ class DotenvLoader
9
+ LINE_PATTERN = /\A(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)\z/
10
+
11
+ def initialize(path)
12
+ @path = Pathname(path)
13
+ end
14
+
15
+ def load
16
+ return {} unless @path.file?
17
+
18
+ @path.readlines.each_with_object({}) do |line, env|
19
+ line = line.strip
20
+ next if line.empty? || line.start_with?('#')
21
+
22
+ match = LINE_PATTERN.match(line)
23
+ next unless match
24
+
25
+ env[match[1]] = unquote(match[2])
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ def unquote(value)
32
+ value = value.strip
33
+ return value[1..-2] if value.start_with?('"') && value.end_with?('"') && value.length >= 2
34
+ return value[1..-2] if value.start_with?("'") && value.end_with?("'") && value.length >= 2
35
+
36
+ value.split(/\s+#/, 2).first.to_s.strip
37
+ end
38
+ end
39
+ end
data/lib/wip/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Wip
4
- VERSION = '0.4.3'
4
+ VERSION = '0.6.0'
5
5
  end
data/lib/wip.rb CHANGED
@@ -5,9 +5,13 @@ require_relative 'wip/errors'
5
5
  require_relative 'wip/config'
6
6
  require_relative 'wip/config_loader'
7
7
  require_relative 'wip/environment'
8
+ require_relative 'wip/dotenv_loader'
9
+ require_relative 'wip/docker_ignore'
10
+ require_relative 'wip/build_context'
8
11
  require_relative 'wip/command_resolver'
9
12
  require_relative 'wip/error_interpreter'
10
13
  require_relative 'wip/command_builder'
14
+ require_relative 'wip/compose_bridge'
11
15
  require_relative 'wip/command_display'
12
16
  require_relative 'wip/command_runner'
13
17
  require_relative 'wip/resource_monitor'
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: 0.4.3
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Wip contributors
@@ -33,15 +33,19 @@ files:
33
33
  - README.md
34
34
  - exe/wip
35
35
  - lib/wip.rb
36
+ - lib/wip/build_context.rb
36
37
  - lib/wip/cli.rb
37
38
  - lib/wip/command_builder.rb
38
39
  - lib/wip/command_display.rb
39
40
  - lib/wip/command_resolver.rb
40
41
  - lib/wip/command_runner.rb
42
+ - lib/wip/compose_bridge.rb
41
43
  - lib/wip/config.rb
42
44
  - lib/wip/config_loader.rb
43
45
  - lib/wip/debug_reporter.rb
46
+ - lib/wip/docker_ignore.rb
44
47
  - lib/wip/doctor.rb
48
+ - lib/wip/dotenv_loader.rb
45
49
  - lib/wip/environment.rb
46
50
  - lib/wip/error_interpreter.rb
47
51
  - lib/wip/errors.rb