gdkbox 0.1.1 → 0.1.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: 13ef02da003147624f1bf52ef134de61acba1cb43e950b3359686d3e916a1658
4
- data.tar.gz: c9b0697a87b799a54c90df07b36c112e56b4f0ad97da13ec02eac5bca7d25c38
3
+ metadata.gz: 2087c010d2c52bff4fddfd421d1ba713b9f0721c701674753813ecca7093054d
4
+ data.tar.gz: 9e697f3f33e5823296bc3e963f94025819b02b9dc340b9234d350b80b737498a
5
5
  SHA512:
6
- metadata.gz: 5a606c354092f3d0c16392174d68d589c87c348569defa631766eff4ca5169d1afcb3444c107aeab058186d9b5966e7ba439a1ab88e38fa62b2f4dadd233415f
7
- data.tar.gz: d15ae6669f61436afd12a90b5d4cabd72fa7441e04f1b69199dbd9b8a58c8a2e39bf5d7b522f8576116e0492f539a970f9d6b589142494ffb3fcf252e91a2fac
6
+ metadata.gz: d449dca72e949adb5cc497a7dedb8b707c6a0b03ee01b2bbae0ad2193f8e8287294f3281b95d5b2bf01241c014a2c9db4b4b7cea04ce348ddbe370a63df248b6
7
+ data.tar.gz: 67f02e778e1aa18e7a9463e2527c7f9eaed877ecb6d3b9c8a2b8b2f761efe3147083f7745155a98ff36453d1396447ef2d53370b15e96873608e85a9387c38c7
data/README.md CHANGED
@@ -69,6 +69,7 @@ Or run it straight from a checkout without installing:
69
69
  | Command | Description |
70
70
  | --- | --- |
71
71
  | `gdkbox up NAME` | Pull the GDK image, start a box, enable SSH, install Claude Code, register VS Code host. |
72
+ | `gdkbox update-image` | Pull the latest GDK-in-a-box image, with a progress bar and download speed (`--image` to override). |
72
73
  | `gdkbox ls` | List boxes and their container status (`--json` for orchestrators). |
73
74
  | `gdkbox status NAME` | Show container state and connection details (`--json`). |
74
75
  | `gdkbox dispatch NAME` | Run a Claude Code agent task headlessly in the box (`--task`/`--task-file`). |
data/lib/gdkbox/cli.rb CHANGED
@@ -69,6 +69,35 @@ module GDKBox
69
69
  end
70
70
  end
71
71
 
72
+ desc "update-image", "Pull the latest GDK-in-a-box image"
73
+ long_desc <<~DESC
74
+ Re-pulls the GDK image so new boxes start from the latest published
75
+ version. The image is large, so progress is shown as a bar while it
76
+ downloads. Defaults to the configured image (config.yml / $GDKBOX_IMAGE)
77
+ or the official one; override with --image.
78
+
79
+ Existing boxes keep the image they were created with; recreate a box
80
+ (`gdkbox rm NAME` then `gdkbox up NAME`) to adopt the freshly pulled one.
81
+ DESC
82
+ option :image, type: :string, desc: "Override the GDK image to pull"
83
+ def update_image
84
+ ensure_docker!
85
+ image = options[:image] || config.default_image
86
+ docker = Docker.new
87
+
88
+ say "Pulling #{image} ...", :green
89
+ bar = ProgressBar.new
90
+ docker.pull(image) do |progress|
91
+ label = "#{progress.completed}/#{progress.total} layers"
92
+ label += " · #{progress.human_speed}" if progress.human_speed
93
+ bar.update(progress.fraction, label)
94
+ end
95
+ bar.finish
96
+
97
+ say "Updated. New boxes will use this image; recreate existing boxes to adopt it.", :green
98
+ end
99
+ map "update-image" => :update_image
100
+
72
101
  desc "ls", "List all GDK boxes and their status"
73
102
  option :json, type: :boolean, default: false,
74
103
  desc: "Print the fleet as a JSON array (for orchestrators)"
data/lib/gdkbox/docker.rb CHANGED
@@ -15,8 +15,176 @@ module GDKBox
15
15
  !@shell.which("docker").nil?
16
16
  end
17
17
 
18
- def pull(image)
19
- @shell.run!("docker", "pull", image)
18
+ # Pull an image. With no block the pull runs quietly and the (captured)
19
+ # Result is returned. With a block the pull is streamed and the block is
20
+ # called with a PullProgress after every line of `docker pull` output, so a
21
+ # caller can drive a progress bar. Raises CommandError on a non-zero exit.
22
+ def pull(image, &block)
23
+ return @shell.run!("docker", "pull", image) unless block
24
+
25
+ tracker = PullProgress.new
26
+ result = @shell.stream_tty("docker", "pull", image) do |line|
27
+ tracker.ingest(line)
28
+ block.call(tracker)
29
+ end
30
+ unless result.success?
31
+ raise CommandError.new(["docker", "pull", image], result.status, result.stderr)
32
+ end
33
+
34
+ result
35
+ end
36
+
37
+ # Parses the terminal output of `docker pull` (see Shell#stream_tty) into an
38
+ # overall completion fraction and a download rate. Docker repaints one line
39
+ # per layer, each shaped like "<layer-id>: <status> [==> ] 5MB/50MB". We
40
+ # track every layer we have seen and score its progress in 0.0..1.0: a
41
+ # download fills the first half of a layer and extraction the second, so the
42
+ # fraction rises smoothly rather than jumping only when a whole layer
43
+ # completes. Bytes reported on "Downloading" lines are summed across layers
44
+ # and sampled against a clock to derive the current download speed.
45
+ class PullProgress
46
+ # States that mark a layer as fully pulled.
47
+ DONE = ["Pull complete", "Already exists"].freeze
48
+
49
+ # Docker repaints many times a second across parallel layers; recomputing
50
+ # speed on every burst line would divide a small byte delta by a
51
+ # near-zero time and report absurd spikes. Instead accumulate bytes and
52
+ # only recompute the rate once this much time has elapsed.
53
+ SAMPLE_INTERVAL = 0.25
54
+
55
+ # SI byte units as docker prints them (base 1000).
56
+ UNITS = { "B" => 1, "kB" => 1000, "MB" => 1000**2,
57
+ "GB" => 1000**3, "TB" => 1000**4 }.freeze
58
+
59
+ # +clock+ returns a monotonically increasing time in seconds; it is
60
+ # injected in tests so speed is deterministic.
61
+ def initialize(clock: nil)
62
+ @layers = {}
63
+ @downloaded = Hash.new(0) # layer id => bytes pulled so far
64
+ @sizes = {} # layer id => total bytes (once known)
65
+ @clock = clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
66
+ @speed = nil # bytes/second, or nil until measurable
67
+ end
68
+
69
+ def ingest(line)
70
+ id, status = line.split(": ", 2)
71
+ return unless status
72
+ # The first line ("<tag>: Pulling from <repo>") is not a layer.
73
+ return if status.start_with?("Pulling from")
74
+
75
+ track_bytes(id, status)
76
+ @layers[id] = score(status)
77
+ end
78
+
79
+ # Number of layers fully pulled so far.
80
+ def completed
81
+ @layers.count { |_id, value| value >= 1.0 }
82
+ end
83
+
84
+ # Number of distinct layers seen so far.
85
+ def total
86
+ @layers.size
87
+ end
88
+
89
+ # Overall progress in 0.0..1.0 (0.0 before any layer is known).
90
+ def fraction
91
+ return 0.0 if @layers.empty?
92
+
93
+ @layers.values.sum / @layers.size
94
+ end
95
+
96
+ # Current download speed in bytes/second, or nil before it can be
97
+ # measured (needs two samples with elapsed time and new bytes).
98
+ def speed
99
+ @speed
100
+ end
101
+
102
+ # Current download speed as a human string (e.g. "12.3 MB/s"), or nil.
103
+ def human_speed
104
+ return nil unless @speed && @speed.positive?
105
+
106
+ "#{human_size(@speed)}/s"
107
+ end
108
+
109
+ private
110
+
111
+ # Update per-layer downloaded bytes from a status line and resample speed.
112
+ def track_bytes(id, status)
113
+ if status.start_with?("Downloading")
114
+ current, total_size = parse_sizes(status)
115
+ @sizes[id] = total_size if total_size
116
+ record_bytes(id, current) if current
117
+ elsif DONE.any? { |s| status.start_with?(s) } || status.start_with?("Download complete")
118
+ # Finalize the layer's byte count once its download is done.
119
+ record_bytes(id, @sizes[id]) if @sizes[id]
120
+ end
121
+ end
122
+
123
+ # Downloaded bytes only grow, so keep the max we have seen for the layer.
124
+ def record_bytes(id, bytes)
125
+ @downloaded[id] = [@downloaded[id], bytes].max
126
+ sample_speed
127
+ end
128
+
129
+ # Recompute the rate over the bytes accumulated since the last sample,
130
+ # but only once at least SAMPLE_INTERVAL has passed so bursty repaints do
131
+ # not produce spikes. A window that gained no bytes reports 0 (a stall or
132
+ # the extraction phase) so a stale figure is not left on screen.
133
+ def sample_speed
134
+ now = @clock.call
135
+ total_bytes = @downloaded.values.sum
136
+ if @prev_time.nil?
137
+ @prev_time = now
138
+ @prev_bytes = total_bytes
139
+ return
140
+ end
141
+
142
+ dt = now - @prev_time
143
+ return if dt < SAMPLE_INTERVAL
144
+
145
+ @speed = total_bytes > @prev_bytes ? (total_bytes - @prev_bytes) / dt : 0.0
146
+ @prev_time = now
147
+ @prev_bytes = total_bytes
148
+ end
149
+
150
+ # The "current/total" byte figures from a "Downloading ... 5MB/50MB" line,
151
+ # as [current_bytes, total_bytes]; either may be nil if not present.
152
+ def parse_sizes(status)
153
+ sizes = status.scan(/([\d.]+)\s*([kMGT]?B)\b/).map do |num, unit|
154
+ (num.to_f * UNITS.fetch(unit, 1)).to_i
155
+ end
156
+ [sizes[0], sizes[1]]
157
+ end
158
+
159
+ def human_size(bytes)
160
+ units = UNITS.keys
161
+ size = bytes.to_f
162
+ i = 0
163
+ while size >= 1000 && i < units.length - 1
164
+ size /= 1000
165
+ i += 1
166
+ end
167
+ format("%.1f %s", size, units[i])
168
+ end
169
+
170
+ def score(status)
171
+ return 1.0 if DONE.any? { |s| status.start_with?(s) }
172
+ return 0.5 + (0.5 * ratio(status)) if status.start_with?("Extracting")
173
+ return 0.5 * ratio(status) if status.start_with?("Downloading")
174
+ return 0.5 if status.start_with?("Verifying Checksum", "Download complete")
175
+
176
+ 0.0 # "Pulling fs layer", "Waiting", anything not yet started
177
+ end
178
+
179
+ # The fill ratio of a "[===> ]" progress meter, or 0.0 if absent.
180
+ def ratio(status)
181
+ return 0.0 unless (m = status.match(/\[([^\]]*)\]/))
182
+
183
+ inner = m[1]
184
+ return 0.0 if inner.empty?
185
+
186
+ (inner.count("=") + inner.count(">")).to_f / inner.length
187
+ end
20
188
  end
21
189
 
22
190
  # Start a detached container, returning its id.
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GDKBox
4
+ # A single-line terminal progress bar.
5
+ #
6
+ # Rendering is only enabled on a TTY so piped or JSON output stays clean; on a
7
+ # non-TTY the bar is silent and the caller's regular `say` messages carry the
8
+ # story instead. The bar redraws in place with a carriage return until
9
+ # +finish+ moves the cursor to the next line.
10
+ class ProgressBar
11
+ def initialize(out: $stderr, width: 30, enabled: nil)
12
+ @out = out
13
+ @width = width
14
+ @enabled = enabled.nil? ? (out.respond_to?(:tty?) && out.tty?) : enabled
15
+ end
16
+
17
+ def enabled?
18
+ @enabled
19
+ end
20
+
21
+ # Redraw the bar at the given fraction (clamped to 0.0..1.0) with an
22
+ # optional trailing label (e.g. "12/40 layers").
23
+ def update(fraction, label = nil)
24
+ return unless @enabled
25
+
26
+ fraction = [[fraction.to_f, 0.0].max, 1.0].min
27
+ filled = (fraction * @width).round
28
+ bar = ("█" * filled) + ("░" * (@width - filled))
29
+ text = format("\r[%s] %3d%%", bar, (fraction * 100).round)
30
+ text += " #{label}" if label && !label.empty?
31
+ @out.print(text)
32
+ @out.flush
33
+ end
34
+
35
+ # Fill the bar and drop to a fresh line so later output is not overwritten.
36
+ def finish(label = nil)
37
+ return unless @enabled
38
+
39
+ update(1.0, label)
40
+ @out.print("\n")
41
+ @out.flush
42
+ end
43
+ end
44
+ end
data/lib/gdkbox/shell.rb CHANGED
@@ -48,10 +48,57 @@ module GDKBox
48
48
  run!(*args, input: input).stdout
49
49
  end
50
50
 
51
+ # Run a command attached to a pseudo-terminal, yielding each line of its
52
+ # output as it appears so callers can render live progress. A PTY is used
53
+ # (rather than a pipe) because some programs adapt their output to whether
54
+ # they are interactive: `docker pull`, for instance, only prints per-layer
55
+ # byte counts and progress bars when it believes it is on a terminal. ANSI
56
+ # escape sequences are stripped and carriage-return repaints are split into
57
+ # separate lines before yielding. Returns a Result with empty stdout/stderr
58
+ # (the output was streamed, not captured) and the child's exit status; never
59
+ # raises on a non-zero exit.
60
+ def stream_tty(*args, input: nil)
61
+ require "pty"
62
+ argv = args.map(&:to_s)
63
+ reader, writer, pid = PTY.spawn(*argv)
64
+ writer.write(input) if input
65
+ writer.close
66
+
67
+ buffer = +""
68
+ begin
69
+ loop do
70
+ buffer << reader.readpartial(4096)
71
+ while (idx = buffer.index(/[\r\n]/))
72
+ line = clean_tty(buffer.slice!(0..idx))
73
+ yield line if block_given? && line
74
+ end
75
+ end
76
+ rescue Errno::EIO, EOFError
77
+ # The child exited and closed its end of the PTY.
78
+ ensure
79
+ reader.close unless reader.closed?
80
+ end
81
+
82
+ trailing = clean_tty(buffer)
83
+ yield trailing if block_given? && trailing
84
+ _, status = Process.wait2(pid)
85
+ Result.new("", "", status&.exitstatus || 1)
86
+ end
87
+
51
88
  # Resolve an executable on PATH, returning its path or nil.
52
89
  def which(command)
53
90
  result = run("sh", "-c", "command -v #{command}")
54
91
  result.success? ? result.stdout.strip : nil
55
92
  end
93
+
94
+ private
95
+
96
+ # Strip ANSI escape sequences and CR/LF from a raw PTY segment, returning
97
+ # the trimmed text or nil when nothing meaningful remains (e.g. a bare
98
+ # cursor-movement escape used to repaint the display).
99
+ def clean_tty(raw)
100
+ text = raw.gsub(/\e\[[0-9;?]*[A-Za-z]/, "").tr("\r\n", "").strip
101
+ text.empty? ? nil : text
102
+ end
56
103
  end
57
104
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module GDKBox
4
- VERSION = "0.1.1"
4
+ VERSION = "0.1.3"
5
5
  end
data/lib/gdkbox.rb CHANGED
@@ -7,6 +7,7 @@ end
7
7
 
8
8
  require "gdkbox/version"
9
9
  require "gdkbox/shell"
10
+ require "gdkbox/progress_bar"
10
11
  require "gdkbox/config"
11
12
  require "gdkbox/docker"
12
13
  require "gdkbox/store"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: gdkbox
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.1.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - jotolo
@@ -62,6 +62,7 @@ files:
62
62
  - lib/gdkbox/config.rb
63
63
  - lib/gdkbox/docker.rb
64
64
  - lib/gdkbox/harness.rb
65
+ - lib/gdkbox/progress_bar.rb
65
66
  - lib/gdkbox/provisioner.rb
66
67
  - lib/gdkbox/shell.rb
67
68
  - lib/gdkbox/skills.rb