gdkbox 0.1.3 → 0.1.8

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: 2087c010d2c52bff4fddfd421d1ba713b9f0721c701674753813ecca7093054d
4
- data.tar.gz: 9e697f3f33e5823296bc3e963f94025819b02b9dc340b9234d350b80b737498a
3
+ metadata.gz: e1c7b818ffd457d8f32f3d80073585222be0140ba3ae5c32a76b587bae5a98f5
4
+ data.tar.gz: 35b56df278015bbf0ad44cf175665c481cddd3939bc2130a5b72d193aec44c90
5
5
  SHA512:
6
- metadata.gz: d449dca72e949adb5cc497a7dedb8b707c6a0b03ee01b2bbae0ad2193f8e8287294f3281b95d5b2bf01241c014a2c9db4b4b7cea04ce348ddbe370a63df248b6
7
- data.tar.gz: 67f02e778e1aa18e7a9463e2527c7f9eaed877ecb6d3b9c8a2b8b2f761efe3147083f7745155a98ff36453d1396447ef2d53370b15e96873608e85a9387c38c7
6
+ metadata.gz: d0fd71a1460e658cd975298666fc7afaa1110a2b154a0da005bc80c7c4e9bdb21545ab544091c5a2f2773eab48e2e9520311e24b39c16962a7ae2416b9024bcf
7
+ data.tar.gz: 44638a681108d58ddafdb1a9d4703f5a1f84cd6da764dae608d25ead66a3ebba8f4bb9057d57c38176ecd4f3c49e5c6809636c159c5c8ebbd0803c4b0ff2415d
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.
@@ -22,7 +26,7 @@ gdkbox ls # list your boxes and their status
22
26
  ┌──────────────┼─────────────────────────────────────────────┐
23
27
  │ ▼ │
24
28
  │ 1. docker pull <official GDK image> │
25
- │ 2. docker run -p 127.0.0.1:2222:22 -p 127.0.0.1:3000:3000
29
+ │ 2. docker run --hostname gdk.local -p 127.0.0.1:2222:22 -p 127.0.0.1:3000:3000
26
30
  │ 3. provision install + start sshd, authorize gdkbox key │
27
31
  │ 4. provision npm install -g @anthropic-ai/claude-code │
28
32
  │ 5. register Host gdkbox-demo -> ~/.gdkbox/ssh_config │
@@ -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,7 @@ 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. |
72
84
  | `gdkbox update-image` | Pull the latest GDK-in-a-box image, with a progress bar and download speed (`--image` to override). |
73
85
  | `gdkbox ls` | List boxes and their container status (`--json` for orchestrators). |
74
86
  | `gdkbox status NAME` | Show container state and connection details (`--json`). |
@@ -77,6 +89,7 @@ Or run it straight from a checkout without installing:
77
89
  | `gdkbox ssh NAME` | Open an interactive SSH session into the box. |
78
90
  | `gdkbox claude NAME` | Install Claude Code inside the box. |
79
91
  | `gdkbox set-key NAME` | Seed/rotate the Anthropic API key in the box for unattended dispatch. |
92
+ | `gdkbox set-host` | Add the `gdk.local` entry to your `/etc/hosts` so box web UIs resolve (`--remove` to undo). Also runs during `up`. |
80
93
  | `gdkbox start NAME` | Start a stopped box (and re-enable SSH). |
81
94
  | `gdkbox stop NAME` | Stop a running box. |
82
95
  | `gdkbox rm NAME` | Remove a box: container, metadata, and SSH entry. |
@@ -92,8 +105,8 @@ Or run it straight from a checkout without installing:
92
105
  | Option | Default | Description |
93
106
  | --- | --- | --- |
94
107
  | `--image` | official GDK image | Override the container image. |
95
- | `--ssh-port` | next free from 2222 | Host port to publish SSH on. |
96
- | `--web-port` | next free from 3000 | Host port to publish the GDK web UI on. |
108
+ | `--ssh-port` | next free from 2222 | Host port to publish SSH on. Auto-allocation skips ports in use on the host (e.g. a local GDK). |
109
+ | `--web-port` | next free from 3000 | Host port to publish the GDK web UI on. Auto-allocation skips ports in use on the host. |
97
110
  | `--harness ID` | `claude` (or config.yml) | Agent harness to install: claude, codex, opencode, pi. |
98
111
  | `--no-agent` | (agent installed) | Skip installing the agent harness. |
99
112
  | `--skill NAME [NAME...]` | (none) | Seed skill(s) into the box at creation for dispatched agents. |
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"
@@ -101,6 +104,10 @@ module GDKBox
101
104
  @docker.run_container(
102
105
  name: cname,
103
106
  image: image,
107
+ # GDK's services resolve and redirect to this hostname; without it
108
+ # set inside the container, workhorse and the http-router crash-loop
109
+ # on DNS timeouts.
110
+ hostname: Config::GDK_HOSTNAME,
104
111
  publish: [
105
112
  "127.0.0.1:#{ssh_port}:#{Config::SSH_CONTAINER_PORT}",
106
113
  "127.0.0.1:#{web_port}:#{Config::GDK_WEB_CONTAINER_PORT}"
@@ -263,8 +270,16 @@ module GDKBox
263
270
  # Choose free host ports and persist a preliminary record claiming them,
264
271
  # all while holding an exclusive lock so concurrent `create!` calls in
265
272
  # separate processes serialize and cannot pick the same ports. Returns the
266
- # chosen [ssh_port, web_port]. Caller-supplied ports are honored as-is.
273
+ # chosen [ssh_port, web_port]. Caller-supplied ports are honored, but fail
274
+ # fast with a clear message when something already holds them — better
275
+ # than the cryptic bind error `docker run` would produce later.
267
276
  def reserve_ports!(cname, ssh_port, web_port)
277
+ { "--ssh-port" => ssh_port, "--web-port" => web_port }.each do |flag, port|
278
+ if port && Ports.bound?(port)
279
+ raise Error, "Port #{port} (#{flag}) is already in use on 127.0.0.1."
280
+ end
281
+ end
282
+
268
283
  @config.ensure_dirs!
269
284
  File.open(@config.lock_path, File::RDWR | File::CREAT, 0o644) do |lock|
270
285
  lock.flock(File::LOCK_EX)
@@ -283,10 +298,14 @@ module GDKBox
283
298
  end
284
299
  end
285
300
 
301
+ # The next port at or above base that is neither recorded by gdkbox nor
302
+ # actually bound on the host. The store only covers gdkbox's own boxes;
303
+ # a locally running GDK (typically on 2222/3000) is invisible to it, so
304
+ # each candidate is also probed with a real bind.
286
305
  def next_port(base, exclude: [])
287
306
  used = @store.used_ports + exclude
288
307
  port = base
289
- port += 1 while used.include?(port)
308
+ port += 1 while used.include?(port) || Ports.bound?(port)
290
309
  port
291
310
  end
292
311
  end
data/lib/gdkbox/cli.rb CHANGED
@@ -61,6 +61,8 @@ module GDKBox
61
61
  return
62
62
  end
63
63
 
64
+ setup_host
65
+
64
66
  say "\nBox '#{name}' is up.", :green
65
67
  print_connection_details(box)
66
68
  unless api_key
@@ -234,6 +236,46 @@ module GDKBox
234
236
  end
235
237
  map "set-key" => :set_key
236
238
 
239
+ desc "set-host", "Add (default) or remove the gdk.local entry in /etc/hosts"
240
+ long_desc <<~DESC
241
+ GDK generates URLs and redirects that use the `gdk.local` hostname, so
242
+ your browser needs it mapped to loopback to reach a box's web UI. This
243
+ adds `#{HostsFile::ENTRY}` to /etc/hosts when no gdk.local entry exists
244
+ yet (writing needs sudo, so you may be prompted). `gdkbox up` runs the
245
+ same check automatically.
246
+
247
+ With --remove, the gdkbox-managed entry is removed again; lines you
248
+ added yourself are never touched.
249
+ DESC
250
+ option :add, type: :boolean, default: true, desc: "Add the entry (the default action)"
251
+ option :remove, type: :boolean, default: false,
252
+ desc: "Remove the gdkbox-managed entry instead"
253
+ def set_host
254
+ hosts = HostsFile.new
255
+ if options[:remove]
256
+ case hosts.remove
257
+ when :absent
258
+ say "No gdkbox-managed '#{HostsFile::HOSTNAME}' entry in #{hosts.path}. Nothing to do."
259
+ when :removed
260
+ say "Removed the '#{HostsFile::HOSTNAME}' entry from #{hosts.path}.", :green
261
+ when :manual
262
+ say "Could not write #{hosts.path}. Remove the line tagged " \
263
+ "'#{HostsFile::MARKER}' manually (e.g. with `sudoedit #{hosts.path}`).", :yellow
264
+ end
265
+ else
266
+ case hosts.add
267
+ when :present
268
+ say "'#{HostsFile::HOSTNAME}' already resolves via #{hosts.path}. Nothing to do."
269
+ when :added
270
+ say "Added '#{HostsFile::ENTRY}' to #{hosts.path}.", :green
271
+ when :manual
272
+ say "Could not write #{hosts.path}. Add the entry manually:", :yellow
273
+ say " echo '#{HostsFile::ENTRY}' | sudo tee -a #{hosts.path}", :yellow
274
+ end
275
+ end
276
+ end
277
+ map "set-host" => :set_host
278
+
237
279
  desc "start NAME", "Start a stopped box (and re-enable SSH)"
238
280
  def start(name)
239
281
  box = load_box!(name)
@@ -370,6 +412,28 @@ module GDKBox
370
412
  end
371
413
  end
372
414
 
415
+ # Ensure gdk.local resolves on the host so the box's web UI is reachable
416
+ # (GDK redirects to that hostname). Adding the entry needs sudo, so warn
417
+ # before any password prompt appears. Failures are non-fatal: the box is
418
+ # already up, and the user can run `gdkbox set-host` later. Skipped in
419
+ # --json mode, where prompting would break unattended orchestrators.
420
+ def setup_host
421
+ hosts = HostsFile.new
422
+ return if hosts.entry?
423
+
424
+ say "\nAdding '#{HostsFile::HOSTNAME}' to #{hosts.path} so the web UI resolves " \
425
+ "(may prompt for sudo)...", :green
426
+ case hosts.add
427
+ when :added
428
+ say "Added '#{HostsFile::ENTRY}' to #{hosts.path}.", :green
429
+ when :manual
430
+ say "Could not write #{hosts.path}. Run `gdkbox set-host`, or add it manually:", :yellow
431
+ say " echo '#{HostsFile::ENTRY}' | sudo tee -a #{hosts.path}", :yellow
432
+ end
433
+ rescue StandardError => e
434
+ say "Skipped #{hosts.path} setup: #{e.message}", :yellow
435
+ end
436
+
373
437
  # Show the discovered skills and let the user pick by number (or "all").
374
438
  def prompt_for_skills
375
439
  found = Skills.discover
@@ -432,7 +496,7 @@ module GDKBox
432
496
  say ""
433
497
  say " SSH: ssh #{box.ssh_host_alias}", :cyan
434
498
  say " VS Code: gdkbox code #{box.name}", :cyan
435
- say " Web UI: #{box.web_url}", :cyan
499
+ say " Web UI: #{box.web_url} (http://#{HostsFile::HOSTNAME}:#{box.web_port})", :cyan
436
500
  say " Agent: ssh #{box.ssh_host_alias} -t #{box.harness.bin}", :cyan
437
501
  end
438
502
  end
data/lib/gdkbox/config.rb CHANGED
@@ -22,6 +22,12 @@ module GDKBox
22
22
  DEFAULT_IMAGE =
23
23
  "registry.gitlab.com/gitlab-org/gitlab-development-kit/gitlab-gdk-in-a-box:main"
24
24
 
25
+ # The hostname GDK is configured for. It is set as the container's own
26
+ # hostname (so services inside the box can resolve it) and mapped to
27
+ # loopback in the host's /etc/hosts by HostsFile (so the host browser can
28
+ # follow GDK's redirects to it).
29
+ GDK_HOSTNAME = "gdk.local"
30
+
25
31
  CONTAINER_PREFIX = "gdkbox-"
26
32
  HOST_ALIAS_PREFIX = "gdkbox-"
27
33
 
data/lib/gdkbox/docker.rb CHANGED
@@ -15,6 +15,11 @@ module GDKBox
15
15
  !@shell.which("docker").nil?
16
16
  end
17
17
 
18
+ # Whether the image is already present locally.
19
+ def image_exists?(image)
20
+ @shell.run("docker", "image", "inspect", image).success?
21
+ end
22
+
18
23
  # Pull an image. With no block the pull runs quietly and the (captured)
19
24
  # Result is returned. With a block the pull is streamed and the block is
20
25
  # called with a PullProgress after every line of `docker pull` output, so a
@@ -187,9 +192,13 @@ module GDKBox
187
192
  end
188
193
  end
189
194
 
190
- # Start a detached container, returning its id.
191
- def run_container(name:, image:, publish: [], labels: {}, env: {}, args: [])
195
+ # Start a detached container, returning its id. When a hostname is given,
196
+ # Docker also maps it to the container's IP in the container's /etc/hosts
197
+ # and re-applies that mapping on every start (a manual edit would be lost:
198
+ # Docker regenerates the file when the container starts).
199
+ def run_container(name:, image:, publish: [], labels: {}, env: {}, args: [], hostname: nil)
192
200
  cmd = ["docker", "run", "-d", "--name", name]
201
+ cmd.push("--hostname", hostname) if hostname
193
202
  labels.each { |key, value| cmd.push("--label", "#{key}=#{value}") }
194
203
  env.each { |key, value| cmd.push("-e", "#{key}=#{value}") }
195
204
  publish.each { |mapping| cmd.push("-p", mapping) }
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GDKBox
4
+ # Manages the gdk.local entry in the host's /etc/hosts.
5
+ #
6
+ # GDK generates URLs and redirects that use the `gdk.local` hostname, so the
7
+ # host's browser needs it mapped to loopback to reach a box's web UI. The
8
+ # line gdkbox adds is tagged with a marker comment so it can be recognized
9
+ # and removed later without touching entries the user wrote themselves.
10
+ #
11
+ # /etc/hosts is root-owned: writes are attempted directly first (covers
12
+ # tests and unusual setups), then via `sudo tee`. Callers get a symbol
13
+ # describing what happened — :present/:added/:removed/:absent/:manual —
14
+ # and decide how to talk to the user; :manual means nothing was changed
15
+ # and the user has to edit the file themselves.
16
+ class HostsFile
17
+ HOSTNAME = Config::GDK_HOSTNAME
18
+ MARKER = "# added by gdkbox"
19
+ ENTRY = "127.0.0.1 #{HOSTNAME} #{MARKER}"
20
+
21
+ attr_reader :path
22
+
23
+ def initialize(path: "/etc/hosts", shell: Shell.new)
24
+ @path = path
25
+ @shell = shell
26
+ end
27
+
28
+ # Whether any active (non-comment) line already maps HOSTNAME, regardless
29
+ # of who added it — a user's own entry counts, so we never duplicate it.
30
+ def entry?
31
+ return false unless File.readable?(path)
32
+
33
+ File.foreach(path).any? { |line| maps_hostname?(line) }
34
+ end
35
+
36
+ # Ensure the entry exists. Returns :present when it already does, :added
37
+ # on success, or :manual when the file could not be written (with sudo
38
+ # declined/failed or disabled).
39
+ def add(sudo: true)
40
+ return :present if entry?
41
+
42
+ File.open(path, "a") { |f| f.puts(ENTRY) }
43
+ :added
44
+ rescue Errno::EACCES
45
+ return :manual unless sudo
46
+
47
+ result = @shell.run("sudo", "tee", "-a", path, input: "#{ENTRY}\n")
48
+ result.success? ? :added : :manual
49
+ end
50
+
51
+ # Remove the gdkbox-managed entry (matched by MARKER; user-written lines
52
+ # are never touched). Returns :absent when there is nothing to remove,
53
+ # :removed on success, or :manual when the file could not be written.
54
+ def remove(sudo: true)
55
+ lines = File.readable?(path) ? File.readlines(path) : []
56
+ kept = lines.reject { |line| managed?(line) }
57
+ return :absent if kept.size == lines.size
58
+
59
+ content = kept.join
60
+ begin
61
+ File.write(path, content)
62
+ :removed
63
+ rescue Errno::EACCES
64
+ return :manual unless sudo
65
+
66
+ result = @shell.run("sudo", "tee", path, input: content)
67
+ result.success? ? :removed : :manual
68
+ end
69
+ end
70
+
71
+ private
72
+
73
+ # Does this line actively map HOSTNAME? Comments are stripped first, so a
74
+ # commented-out entry does not count.
75
+ def maps_hostname?(line)
76
+ fields = line.sub(/#.*/, "").split
77
+ fields.length >= 2 && fields[1..].include?(HOSTNAME)
78
+ end
79
+
80
+ def managed?(line)
81
+ line.include?(MARKER) && line.include?(HOSTNAME)
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+
5
+ module GDKBox
6
+ # Answers whether a host port is actually usable.
7
+ #
8
+ # gdkbox's metadata store only knows about ports gdkbox itself allocated;
9
+ # anything else on the machine — typically a locally running GDK on 2222 and
10
+ # 3000 — is invisible to it. Binding is the authoritative test: gdkbox
11
+ # publishes container ports on 127.0.0.1, so try to bind exactly there.
12
+ module Ports
13
+ module_function
14
+
15
+ # True when something on the host already holds the port (or the bind is
16
+ # not permitted, which is just as unusable for publishing).
17
+ def bound?(port)
18
+ TCPServer.new("127.0.0.1", port).close
19
+ false
20
+ rescue SystemCallError
21
+ true
22
+ end
23
+ end
24
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module GDKBox
4
- VERSION = "0.1.3"
4
+ VERSION = "0.1.8"
5
5
  end
data/lib/gdkbox.rb CHANGED
@@ -10,6 +10,8 @@ require "gdkbox/shell"
10
10
  require "gdkbox/progress_bar"
11
11
  require "gdkbox/config"
12
12
  require "gdkbox/docker"
13
+ require "gdkbox/hosts_file"
14
+ require "gdkbox/ports"
13
15
  require "gdkbox/store"
14
16
  require "gdkbox/ssh_key"
15
17
  require "gdkbox/ssh_config"
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.3
4
+ version: 0.1.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - jotolo
@@ -62,6 +62,8 @@ files:
62
62
  - lib/gdkbox/config.rb
63
63
  - lib/gdkbox/docker.rb
64
64
  - lib/gdkbox/harness.rb
65
+ - lib/gdkbox/hosts_file.rb
66
+ - lib/gdkbox/ports.rb
65
67
  - lib/gdkbox/progress_bar.rb
66
68
  - lib/gdkbox/provisioner.rb
67
69
  - lib/gdkbox/shell.rb
@@ -72,9 +74,14 @@ files:
72
74
  - lib/gdkbox/version.rb
73
75
  - lib/gdkbox/vscode.rb
74
76
  - skills/gdkbox-fleet/SKILL.md
77
+ homepage: https://gitlab.com/jotolo_gl/gdkbox
75
78
  licenses:
76
79
  - MIT
77
- metadata: {}
80
+ metadata:
81
+ source_code_uri: https://gitlab.com/jotolo_gl/gdkbox
82
+ bug_tracker_uri: https://gitlab.com/jotolo_gl/gdkbox/-/issues
83
+ documentation_uri: https://gitlab.com/jotolo_gl/gdkbox/-/blob/main/README.md
84
+ rubygems_mfa_required: 'true'
78
85
  rdoc_options: []
79
86
  require_paths:
80
87
  - lib