gdkbox 0.1.1 → 0.1.5

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: 851b8b309a5e04c8e48d1090e45453b0f8464779a0d1cb30aaa69e5edf0829fe
4
+ data.tar.gz: 01af5d8bd6d09cca936bf801f22a82ff143e4357b86ce47dc6478659d19d3f9e
5
5
  SHA512:
6
- metadata.gz: 5a606c354092f3d0c16392174d68d589c87c348569defa631766eff4ca5169d1afcb3444c107aeab058186d9b5966e7ba439a1ab88e38fa62b2f4dadd233415f
7
- data.tar.gz: d15ae6669f61436afd12a90b5d4cabd72fa7441e04f1b69199dbd9b8a58c8a2e39bf5d7b522f8576116e0492f539a970f9d6b589142494ffb3fcf252e91a2fac
6
+ metadata.gz: 9a5201a873d0f54c7864f2e746e7077ea406f12f6ffce032280579ab1241d74fd743e6a4376204d89aa73bb75f30cdb697cc9bb99e3253e5f819d9e03d0adf17
7
+ data.tar.gz: 569e92b7c9d527932912790e6a0e2b48be01920d5bd29cd80788673827adc17a2230fcd66f212f006b704f5e8b8b7fcee4154d0d7d502ff87e470d7b10afca31
data/README.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # gdkbox
2
2
 
3
+ [![Gem Version](https://img.shields.io/gem/v/gdkbox)](https://rubygems.org/gems/gdkbox)
4
+ [![Gem Downloads](https://img.shields.io/gem/dt/gdkbox)](https://rubygems.org/gems/gdkbox)
5
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://opensource.org/licenses/MIT)
6
+
3
7
  Spin up a [GitLab Development Kit (GDK)](https://gitlab.com/gitlab-org/gitlab-development-kit)
4
8
  "in a box" with a single command, then connect to it from **VS Code** and run
5
9
  **Claude Code** agents against a real GitLab development environment.
@@ -51,14 +55,22 @@ gdkbox ls # list your boxes and their status
51
55
 
52
56
  ## Install
53
57
 
54
- From this directory:
58
+ `gdkbox` is published on [RubyGems](https://rubygems.org/gems/gdkbox):
59
+
60
+ ```sh
61
+ gem install gdkbox
62
+ ```
63
+
64
+ ### From source
65
+
66
+ To build and install from a checkout of this repository:
55
67
 
56
68
  ```sh
57
69
  gem build gdkbox.gemspec
58
- gem install ./gdkbox-0.1.0.gem
70
+ gem install ./gdkbox-*.gem
59
71
  ```
60
72
 
61
- Or run it straight from a checkout without installing:
73
+ Or run it straight from the checkout without installing:
62
74
 
63
75
  ```sh
64
76
  ./bin/gdkbox up myenv
@@ -68,7 +80,8 @@ Or run it straight from a checkout without installing:
68
80
 
69
81
  | Command | Description |
70
82
  | --- | --- |
71
- | `gdkbox up NAME` | Pull the GDK image, start a box, enable SSH, install Claude Code, register VS Code host. |
83
+ | `gdkbox up NAME` | Pull the GDK image (only if not already local), start a box, enable SSH, install Claude Code, register VS Code host. |
84
+ | `gdkbox update-image` | Pull the latest GDK-in-a-box image, with a progress bar and download speed (`--image` to override). |
72
85
  | `gdkbox ls` | List boxes and their container status (`--json` for orchestrators). |
73
86
  | `gdkbox status NAME` | Show container state and connection details (`--json`). |
74
87
  | `gdkbox dispatch NAME` | Run a Claude Code agent task headlessly in the box (`--task`/`--task-file`). |
data/lib/gdkbox/box.rb CHANGED
@@ -88,7 +88,10 @@ module GDKBox
88
88
  public_key = @ssh_key.ensure!
89
89
  cname = @config.container_name(name)
90
90
 
91
- @docker.pull(image)
91
+ # Only pull when the image is missing locally, so `gdkbox up` on a warm
92
+ # machine does not hit the registry; `gdkbox update-image` is the
93
+ # explicit way to refresh.
94
+ @docker.pull(image) unless @docker.image_exists?(image)
92
95
 
93
96
  # Reserve the host ports atomically. `next_port` reads the store, so
94
97
  # parallel `gdkbox up` runs would otherwise all pick the same "next free"
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,181 @@ module GDKBox
15
15
  !@shell.which("docker").nil?
16
16
  end
17
17
 
18
- def pull(image)
19
- @shell.run!("docker", "pull", image)
18
+ # Whether the image is already present locally.
19
+ def image_exists?(image)
20
+ @shell.run("docker", "image", "inspect", image).success?
21
+ end
22
+
23
+ # Pull an image. With no block the pull runs quietly and the (captured)
24
+ # Result is returned. With a block the pull is streamed and the block is
25
+ # called with a PullProgress after every line of `docker pull` output, so a
26
+ # caller can drive a progress bar. Raises CommandError on a non-zero exit.
27
+ def pull(image, &block)
28
+ return @shell.run!("docker", "pull", image) unless block
29
+
30
+ tracker = PullProgress.new
31
+ result = @shell.stream_tty("docker", "pull", image) do |line|
32
+ tracker.ingest(line)
33
+ block.call(tracker)
34
+ end
35
+ unless result.success?
36
+ raise CommandError.new(["docker", "pull", image], result.status, result.stderr)
37
+ end
38
+
39
+ result
40
+ end
41
+
42
+ # Parses the terminal output of `docker pull` (see Shell#stream_tty) into an
43
+ # overall completion fraction and a download rate. Docker repaints one line
44
+ # per layer, each shaped like "<layer-id>: <status> [==> ] 5MB/50MB". We
45
+ # track every layer we have seen and score its progress in 0.0..1.0: a
46
+ # download fills the first half of a layer and extraction the second, so the
47
+ # fraction rises smoothly rather than jumping only when a whole layer
48
+ # completes. Bytes reported on "Downloading" lines are summed across layers
49
+ # and sampled against a clock to derive the current download speed.
50
+ class PullProgress
51
+ # States that mark a layer as fully pulled.
52
+ DONE = ["Pull complete", "Already exists"].freeze
53
+
54
+ # Docker repaints many times a second across parallel layers; recomputing
55
+ # speed on every burst line would divide a small byte delta by a
56
+ # near-zero time and report absurd spikes. Instead accumulate bytes and
57
+ # only recompute the rate once this much time has elapsed.
58
+ SAMPLE_INTERVAL = 0.25
59
+
60
+ # SI byte units as docker prints them (base 1000).
61
+ UNITS = { "B" => 1, "kB" => 1000, "MB" => 1000**2,
62
+ "GB" => 1000**3, "TB" => 1000**4 }.freeze
63
+
64
+ # +clock+ returns a monotonically increasing time in seconds; it is
65
+ # injected in tests so speed is deterministic.
66
+ def initialize(clock: nil)
67
+ @layers = {}
68
+ @downloaded = Hash.new(0) # layer id => bytes pulled so far
69
+ @sizes = {} # layer id => total bytes (once known)
70
+ @clock = clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
71
+ @speed = nil # bytes/second, or nil until measurable
72
+ end
73
+
74
+ def ingest(line)
75
+ id, status = line.split(": ", 2)
76
+ return unless status
77
+ # The first line ("<tag>: Pulling from <repo>") is not a layer.
78
+ return if status.start_with?("Pulling from")
79
+
80
+ track_bytes(id, status)
81
+ @layers[id] = score(status)
82
+ end
83
+
84
+ # Number of layers fully pulled so far.
85
+ def completed
86
+ @layers.count { |_id, value| value >= 1.0 }
87
+ end
88
+
89
+ # Number of distinct layers seen so far.
90
+ def total
91
+ @layers.size
92
+ end
93
+
94
+ # Overall progress in 0.0..1.0 (0.0 before any layer is known).
95
+ def fraction
96
+ return 0.0 if @layers.empty?
97
+
98
+ @layers.values.sum / @layers.size
99
+ end
100
+
101
+ # Current download speed in bytes/second, or nil before it can be
102
+ # measured (needs two samples with elapsed time and new bytes).
103
+ def speed
104
+ @speed
105
+ end
106
+
107
+ # Current download speed as a human string (e.g. "12.3 MB/s"), or nil.
108
+ def human_speed
109
+ return nil unless @speed && @speed.positive?
110
+
111
+ "#{human_size(@speed)}/s"
112
+ end
113
+
114
+ private
115
+
116
+ # Update per-layer downloaded bytes from a status line and resample speed.
117
+ def track_bytes(id, status)
118
+ if status.start_with?("Downloading")
119
+ current, total_size = parse_sizes(status)
120
+ @sizes[id] = total_size if total_size
121
+ record_bytes(id, current) if current
122
+ elsif DONE.any? { |s| status.start_with?(s) } || status.start_with?("Download complete")
123
+ # Finalize the layer's byte count once its download is done.
124
+ record_bytes(id, @sizes[id]) if @sizes[id]
125
+ end
126
+ end
127
+
128
+ # Downloaded bytes only grow, so keep the max we have seen for the layer.
129
+ def record_bytes(id, bytes)
130
+ @downloaded[id] = [@downloaded[id], bytes].max
131
+ sample_speed
132
+ end
133
+
134
+ # Recompute the rate over the bytes accumulated since the last sample,
135
+ # but only once at least SAMPLE_INTERVAL has passed so bursty repaints do
136
+ # not produce spikes. A window that gained no bytes reports 0 (a stall or
137
+ # the extraction phase) so a stale figure is not left on screen.
138
+ def sample_speed
139
+ now = @clock.call
140
+ total_bytes = @downloaded.values.sum
141
+ if @prev_time.nil?
142
+ @prev_time = now
143
+ @prev_bytes = total_bytes
144
+ return
145
+ end
146
+
147
+ dt = now - @prev_time
148
+ return if dt < SAMPLE_INTERVAL
149
+
150
+ @speed = total_bytes > @prev_bytes ? (total_bytes - @prev_bytes) / dt : 0.0
151
+ @prev_time = now
152
+ @prev_bytes = total_bytes
153
+ end
154
+
155
+ # The "current/total" byte figures from a "Downloading ... 5MB/50MB" line,
156
+ # as [current_bytes, total_bytes]; either may be nil if not present.
157
+ def parse_sizes(status)
158
+ sizes = status.scan(/([\d.]+)\s*([kMGT]?B)\b/).map do |num, unit|
159
+ (num.to_f * UNITS.fetch(unit, 1)).to_i
160
+ end
161
+ [sizes[0], sizes[1]]
162
+ end
163
+
164
+ def human_size(bytes)
165
+ units = UNITS.keys
166
+ size = bytes.to_f
167
+ i = 0
168
+ while size >= 1000 && i < units.length - 1
169
+ size /= 1000
170
+ i += 1
171
+ end
172
+ format("%.1f %s", size, units[i])
173
+ end
174
+
175
+ def score(status)
176
+ return 1.0 if DONE.any? { |s| status.start_with?(s) }
177
+ return 0.5 + (0.5 * ratio(status)) if status.start_with?("Extracting")
178
+ return 0.5 * ratio(status) if status.start_with?("Downloading")
179
+ return 0.5 if status.start_with?("Verifying Checksum", "Download complete")
180
+
181
+ 0.0 # "Pulling fs layer", "Waiting", anything not yet started
182
+ end
183
+
184
+ # The fill ratio of a "[===> ]" progress meter, or 0.0 if absent.
185
+ def ratio(status)
186
+ return 0.0 unless (m = status.match(/\[([^\]]*)\]/))
187
+
188
+ inner = m[1]
189
+ return 0.0 if inner.empty?
190
+
191
+ (inner.count("=") + inner.count(">")).to_f / inner.length
192
+ end
20
193
  end
21
194
 
22
195
  # 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.5"
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.5
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
@@ -71,9 +72,14 @@ files:
71
72
  - lib/gdkbox/version.rb
72
73
  - lib/gdkbox/vscode.rb
73
74
  - skills/gdkbox-fleet/SKILL.md
75
+ homepage: https://gitlab.com/jotolo_gl/gdkbox
74
76
  licenses:
75
77
  - MIT
76
- metadata: {}
78
+ metadata:
79
+ source_code_uri: https://gitlab.com/jotolo_gl/gdkbox
80
+ bug_tracker_uri: https://gitlab.com/jotolo_gl/gdkbox/-/issues
81
+ documentation_uri: https://gitlab.com/jotolo_gl/gdkbox/-/blob/main/README.md
82
+ rubygems_mfa_required: 'true'
77
83
  rdoc_options: []
78
84
  require_paths:
79
85
  - lib