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 +4 -4
- data/README.md +1 -0
- data/lib/gdkbox/cli.rb +29 -0
- data/lib/gdkbox/docker.rb +170 -2
- data/lib/gdkbox/progress_bar.rb +44 -0
- data/lib/gdkbox/shell.rb +47 -0
- data/lib/gdkbox/version.rb +1 -1
- data/lib/gdkbox.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2087c010d2c52bff4fddfd421d1ba713b9f0721c701674753813ecca7093054d
|
|
4
|
+
data.tar.gz: 9e697f3f33e5823296bc3e963f94025819b02b9dc340b9234d350b80b737498a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
19
|
-
|
|
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
|
data/lib/gdkbox/version.rb
CHANGED
data/lib/gdkbox.rb
CHANGED
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.
|
|
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
|