wslc-wip 0.4.1 → 0.4.3

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: 5808f27cf9f20fab582caacc33a35f4727d1b74764b8e541fe9f10a82ec8ceaa
4
- data.tar.gz: 79c031022523b9ea21dfc79d7b160bcc46ab71cebe9f98d89050758ba581d986
3
+ metadata.gz: be995c6781c440a7fd84dd3c3bbbddd6799154b59694edce5aa653d80c7d6e53
4
+ data.tar.gz: 7fb1638a125b2bce92212e8ff525a38e31e2d8aa4386e3e74e379420d4df9c91
5
5
  SHA512:
6
- metadata.gz: 4b64f0d01f0f54476b5acacc7094dc6c54f2ea5ed480272644b20839a9a1d3217d9c32748935eb20d75f5ee3672012d6c8799dfba8192b1fa656e16a7c65380f
7
- data.tar.gz: 5ab533ffee65252d885c20862c32450f3d4b7c5c4a8019cf2314e5075b9c3f5ea49efd551f08cf73c170663b90cebe420533be7041e5d20c6eff1988e6b3a758
6
+ metadata.gz: 539721780baa7f366f215581629a761c85e396823e05ff08a3da97c2d0de7808d1470c959240867a5a0ab762f4441ea8b2cb22c92f0063712a18c6b0f80dc4fe
7
+ data.tar.gz: fc00ceceeeb01cd63ac931ff2ffeea1f64848f38a1222b60b8c5de7a501e914a3820d61abbd7d437ebd412e2046958223edab7b46d582b5cb6ef693badf5cc3e
data/README.md CHANGED
@@ -5,6 +5,8 @@
5
5
  [![License: MIT](https://img.shields.io/github/license/slidict/wip.svg)](LICENSE)
6
6
  [![Ruby](https://img.shields.io/badge/ruby-%3E%3D%203.2-red.svg)](wslc-wip.gemspec)
7
7
 
8
+ Homepage: https://wslc-wip.slidict.com/
9
+
8
10
  `wip` is a Ruby-built OSS CLI wrapper that brings a [`dip`](https://github.com/bibendi/dip)-like
9
11
  workflow to Microsoft WSLC. It collects a project's container, image, environment variables, and
10
12
  commands into a single `wip.yml`, and forwards them to `wslc.exe` / `wslc` as safe argument arrays
@@ -127,8 +129,48 @@ dependencies down (the network itself is left in place). Each dependency entry a
127
129
  | `wip NAME ARGS...` | Run `commands.NAME`, appending any extra arguments |
128
130
 
129
131
  TTY allocation is decided by combining the command's config, the CLI option, and whether both
130
- stdin and stdout are real TTYs. Set `WIP_DEBUG=1` to print the `Shellwords`-joined command before
131
- running it.
132
+ stdin and stdout are real TTYs.
133
+
134
+ Pass `--debug` (or set `WIP_DEBUG=1`) to see where time is going: wip prints each step it takes —
135
+ checking for an existing network/container/dependency, and running the resolved `wslc`/`docker`
136
+ command — along with how long that step took, e.g.:
137
+
138
+ ```console
139
+ $ wip rails c --debug
140
+ wip: [debug] running: wslc.exe exec -it -w /app app bin/rails c
141
+ + wslc.exe exec -it -w /app app bin/rails c
142
+ ...
143
+ wip: [debug] done in 4.32s: running: wslc.exe exec -it -w /app app bin/rails c
144
+ ```
145
+
146
+ For long-running interactive commands (like `rails c`), the "done" line only prints after you
147
+ exit, but the timestamp of the `+ ...` line tells you when wip finished its own setup and handed
148
+ off to `wslc`/`docker` — useful for telling wip-side overhead apart from time spent booting inside
149
+ the container.
150
+
151
+ While a step is still running, wip also prints a host resource snapshot (load average, memory,
152
+ disk I/O, and the top CPU-consuming processes) every 5 seconds, so a hang is visible even before
153
+ the command has produced any output of its own:
154
+
155
+ ```console
156
+ 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
157
+ ```
158
+
159
+ The disk I/O figure is worth watching first if the host's CPU and memory look idle — a slow
160
+ `bundle`/`rails` boot is often WSL2's bind-mounted (`.:/app`-style) volumes doing a lot of small
161
+ reads, not the container starving for CPU.
162
+
163
+ For commands that hand the real terminal to the child (`-it`, e.g. `rails c`), these periodic
164
+ snapshots go to a log file instead of your terminal — wip prints the path once at the start —
165
+ since writing into a terminal the child controls in raw mode would garble both outputs. Commands
166
+ that don't need a TTY still get the snapshots printed live.
167
+
168
+ Override that choice with `--debug-log`:
169
+
170
+ - `--debug-log=-` forces snapshots inline even for `-it` commands (only useful if you know your
171
+ terminal/pager can tolerate the interleaving).
172
+ - `--debug-log=PATH` always writes snapshots to `PATH`, including for non-TTY commands, e.g. to
173
+ keep every run's snapshots in one place: `wip rails c --debug --debug-log=/tmp/wip-debug.log`.
132
174
 
133
175
  ## doctor
134
176
 
data/lib/wip/cli.rb CHANGED
@@ -9,6 +9,8 @@ module Wip
9
9
  # Thor-based command-line interface for wip.
10
10
  class CLI < Thor
11
11
  class_option :config, type: :string, desc: 'Path to wip.yml'
12
+ class_option :debug, type: :boolean, default: false, desc: 'Print progress and timing for each step'
13
+ class_option :debug_log, type: :string, desc: 'Where --debug snapshots go: a file path, or "-" for inline'
12
14
  default_task :dispatch
13
15
 
14
16
  def self.exit_on_failure? = true
@@ -119,19 +121,31 @@ module Wip
119
121
  def builder = CommandBuilder.new(wslc: resolver.resolve(load_config.wslc_command), config: load_config)
120
122
 
121
123
  def execute(command, interactive: false, exit_on_failure: true)
122
- code = CommandRunner.new.run(command, interactive: interactive)
124
+ runner = CommandRunner.new(debug: debug?)
125
+ code = reporter.step("running: #{CommandDisplay.for_debug(command)}", live: !interactive) do
126
+ runner.run(command, interactive: interactive)
127
+ end
123
128
  exit(code) if exit_on_failure && !code.zero?
124
129
  code
125
130
  end
126
131
 
127
132
  def resource_exists?(find_command)
128
- out = StringIO.new
129
- code = CommandRunner.new(stdout: out, stderr: StringIO.new).run(find_command)
130
- code.zero? && !JSON.parse(out.string).empty?
133
+ code, output = probe(find_command)
134
+ code.zero? && !JSON.parse(output).empty?
131
135
  rescue JSON::ParserError
132
136
  false
133
137
  end
134
138
 
139
+ def probe(command)
140
+ out = StringIO.new
141
+ runner = CommandRunner.new(stdout: out, stderr: StringIO.new, debug: debug?)
142
+ code = reporter.step("checking: #{CommandDisplay.for_debug(command)}") { runner.run(command) }
143
+ [code, out.string]
144
+ end
145
+
146
+ def debug? = options[:debug] || !ENV['WIP_DEBUG'].to_s.empty?
147
+ def reporter = @reporter ||= DebugReporter.new(enabled: debug?, log: options[:debug_log])
148
+
135
149
  def ensure_network
136
150
  network = load_config.network
137
151
  return unless network
@@ -142,11 +156,8 @@ module Wip
142
156
  end
143
157
 
144
158
  def network_exists?(network)
145
- out = StringIO.new
146
- code = CommandRunner.new(stdout: out, stderr: StringIO.new).run(builder.network_list)
147
- return false unless code.zero?
148
-
149
- JSON.parse(out.string).any? { |entry| entry['Name'] == network }
159
+ code, output = probe(builder.network_list)
160
+ code.zero? && JSON.parse(output).any? { |entry| entry['Name'] == network }
150
161
  rescue JSON::ParserError
151
162
  false
152
163
  end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'shellwords'
4
+
5
+ module Wip
6
+ # Renders a command array for debug output, masking `-e KEY=value` env values
7
+ # so secrets from wip.yml never reach logs.
8
+ module CommandDisplay
9
+ def self.for_debug(command)
10
+ masked = command.each_cons(2).with_index.each_with_object(command.dup) do |((flag, pair), index), result|
11
+ next unless flag == '-e'
12
+
13
+ key, = pair.split('=', 2)
14
+ result[index + 1] = "#{key}=***"
15
+ end
16
+ Shellwords.join(masked)
17
+ end
18
+ end
19
+ end
@@ -1,20 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'open3'
4
- require 'shellwords'
5
4
 
6
5
  module Wip
7
6
  # Executes a built command, pumping its I/O and returning the exit status.
8
7
  class CommandRunner
9
- def initialize(stdin: $stdin, stdout: $stdout, stderr: $stderr, interpreter: ErrorInterpreter.new)
8
+ def initialize(stdin: $stdin, stdout: $stdout, stderr: $stderr, interpreter: ErrorInterpreter.new,
9
+ debug: !ENV['WIP_DEBUG'].to_s.empty?)
10
10
  @stdin = stdin
11
11
  @stdout = stdout
12
12
  @stderr = stderr
13
13
  @interpreter = interpreter
14
+ @debug = debug
14
15
  end
15
16
 
16
17
  def run(command, env: {}, interactive: false)
17
- @stderr.puts "+ #{Shellwords.join(command)}" if ENV['WIP_DEBUG']
18
+ @stderr.puts "+ #{CommandDisplay.for_debug(command)}" if @debug
18
19
  return run_attached(command, env) if interactive
19
20
 
20
21
  captured = +''
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tmpdir'
4
+
5
+ module Wip
6
+ # Prints each step wip takes and how long it took, when debug mode is enabled.
7
+ class DebugReporter
8
+ # `log:` overrides where resource snapshots go, regardless of `live`:
9
+ # nil - automatic (see `step`'s `live:`)
10
+ # '-' - always print inline, even for interactive steps
11
+ # path - always write to this file, even for non-interactive steps
12
+ def initialize(enabled:, out: $stderr, log: nil)
13
+ @enabled = enabled
14
+ @out = out
15
+ @log = log
16
+ end
17
+
18
+ # `live: false` is for steps that hand the real TTY to the child process
19
+ # (e.g. `wslc exec -it`). The child owns raw-mode cursor control there, so
20
+ # writing periodic snapshots straight into that same terminal races with
21
+ # it and garbles the output; those snapshots go to a log file instead,
22
+ # unless overridden by `log:` in the constructor.
23
+ def step(label, live: true)
24
+ return yield unless @enabled
25
+
26
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
27
+ @out.puts "wip: [debug] #{label}"
28
+ file = log_file(live)
29
+ monitor = ResourceMonitor.new(out: file || @out)
30
+ monitor.start(label)
31
+ begin
32
+ yield
33
+ ensure
34
+ monitor.stop
35
+ file&.close
36
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
37
+ @out.puts format('wip: [debug] done in %<elapsed>.2fs: %<label>s', elapsed: elapsed, label: label)
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ def log_file(live)
44
+ return nil if @log == '-'
45
+ return open_log(@log, "streaming resource snapshots to #{@log}") if @log
46
+ return nil if live
47
+
48
+ path = File.join(Dir.tmpdir, "wip-debug-#{Process.pid}-#{Time.now.to_i}.log")
49
+ open_log(path, "command owns the terminal; streaming resource snapshots to #{path}")
50
+ end
51
+
52
+ def open_log(path, notice)
53
+ @out.puts "wip: [debug] #{notice}"
54
+ file = File.open(path, 'a') # rubocop:disable Style/FileOpen -- closed by `step`'s ensure block
55
+ file.sync = true
56
+ file
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wip
4
+ # Periodically prints host CPU/memory/disk-IO/process info while a debug step
5
+ # is still running, so a hung or slow step is visible even before it produces
6
+ # output of its own.
7
+ class ResourceMonitor
8
+ def initialize(interval: 5, out: $stderr)
9
+ @interval = interval
10
+ @out = out
11
+ @last_disk = disk_sectors
12
+ @last_sampled_at = monotonic
13
+ end
14
+
15
+ def start(label)
16
+ @thread = Thread.new do
17
+ loop do
18
+ sleep @interval
19
+ @out.puts "wip: [debug] still running (#{snapshot}): #{label}"
20
+ end
21
+ end
22
+ end
23
+
24
+ def stop
25
+ @thread&.kill
26
+ @thread&.join
27
+ end
28
+
29
+ private
30
+
31
+ def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
32
+
33
+ def snapshot
34
+ [load_average, memory, disk_io, top_processes].join(' | ')
35
+ end
36
+
37
+ def load_average
38
+ "load #{File.read('/proc/loadavg').split[0..2].join(' ')}"
39
+ rescue Errno::ENOENT, IOError
40
+ 'load n/a'
41
+ end
42
+
43
+ def memory
44
+ fields = File.read('/proc/meminfo').lines.to_h { |line| line.split(':', 2).then { |k, v| [k, v.to_i] } }
45
+ total_gb = fields.fetch('MemTotal', 0) / 1_048_576.0
46
+ used_gb = total_gb - (fields.fetch('MemAvailable', 0) / 1_048_576.0)
47
+ format('mem %<used>.1fG/%<total>.1fG', used: used_gb, total: total_gb)
48
+ rescue Errno::ENOENT, IOError
49
+ 'mem n/a'
50
+ end
51
+
52
+ # WSL2's bind-mounted (9p) volumes are often the real bottleneck behind a
53
+ # slow `bundle`/`rails` boot, and that shows up here rather than in CPU.
54
+ def disk_io
55
+ now = monotonic
56
+ elapsed = [now - @last_sampled_at, 0.001].max
57
+ current = disk_sectors
58
+ read_kbps = (current[:read] - @last_disk[:read]) * 0.5 / elapsed
59
+ write_kbps = (current[:write] - @last_disk[:write]) * 0.5 / elapsed
60
+ @last_disk = current
61
+ @last_sampled_at = now
62
+ format('io read %<read>.0fKB/s write %<write>.0fKB/s', read: read_kbps, write: write_kbps)
63
+ rescue Errno::ENOENT, IOError
64
+ 'io n/a'
65
+ end
66
+
67
+ def disk_sectors
68
+ totals = { read: 0, write: 0 }
69
+ File.readlines('/proc/diskstats').each do |line|
70
+ fields = line.split
71
+ next if fields[2].to_s.match?(/\A(loop|ram)/)
72
+
73
+ totals[:read] += fields[5].to_i
74
+ totals[:write] += fields[9].to_i
75
+ end
76
+ totals
77
+ rescue Errno::ENOENT, IOError
78
+ { read: 0, write: 0 }
79
+ end
80
+
81
+ def top_processes
82
+ lines = `ps -eo pid,pcpu,pmem,comm --sort=-pcpu 2>/dev/null`.lines.drop(1).first(3)
83
+ entries = lines.filter_map do |line|
84
+ pid, cpu, mem, comm = line.split(nil, 4)
85
+ next unless comm
86
+
87
+ "#{comm.strip}(#{pid}) cpu #{cpu}%/mem #{mem}%"
88
+ end
89
+ "top: #{entries.join(', ')}"
90
+ rescue StandardError
91
+ 'top: n/a'
92
+ end
93
+ end
94
+ 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.1'
4
+ VERSION = '0.4.3'
5
5
  end
data/lib/wip.rb CHANGED
@@ -8,6 +8,9 @@ require_relative 'wip/environment'
8
8
  require_relative 'wip/command_resolver'
9
9
  require_relative 'wip/error_interpreter'
10
10
  require_relative 'wip/command_builder'
11
+ require_relative 'wip/command_display'
11
12
  require_relative 'wip/command_runner'
13
+ require_relative 'wip/resource_monitor'
14
+ require_relative 'wip/debug_reporter'
12
15
  require_relative 'wip/doctor'
13
16
  require_relative 'wip/cli'
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.1
4
+ version: 0.4.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Wip contributors
@@ -35,20 +35,24 @@ files:
35
35
  - lib/wip.rb
36
36
  - lib/wip/cli.rb
37
37
  - lib/wip/command_builder.rb
38
+ - lib/wip/command_display.rb
38
39
  - lib/wip/command_resolver.rb
39
40
  - lib/wip/command_runner.rb
40
41
  - lib/wip/config.rb
41
42
  - lib/wip/config_loader.rb
43
+ - lib/wip/debug_reporter.rb
42
44
  - lib/wip/doctor.rb
43
45
  - lib/wip/environment.rb
44
46
  - lib/wip/error_interpreter.rb
45
47
  - lib/wip/errors.rb
48
+ - lib/wip/resource_monitor.rb
46
49
  - lib/wip/version.rb
47
- homepage: https://github.com/slidict/wip
50
+ homepage: https://wslc-wip.slidict.com/
48
51
  licenses:
49
52
  - MIT
50
53
  metadata:
51
54
  rubygems_mfa_required: 'true'
55
+ homepage_uri: https://wslc-wip.slidict.com/
52
56
  source_code_uri: https://github.com/slidict/wip
53
57
  changelog_uri: https://github.com/slidict/wip/releases
54
58
  bug_tracker_uri: https://github.com/slidict/wip/issues