boxd 0.1.0

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: f49524201855c87e4519d024cb44bea9aa8e1025e447063abb6bc54d051ed45d
4
+ data.tar.gz: 1477bd7f3e17f61fc33774ba39ee6556b410a6d1b61fcf93cd6f97caf50374e4
5
+ SHA512:
6
+ metadata.gz: 726612934833830a0280c892c10edc75a0dc3ce7d9cfc88f3d83dc4177bd32335e6558c25336b35bef226c783eca578c92e17665b476ae3d194d22b9d5949fb0
7
+ data.tar.gz: 9650055697701481d8e7cea406db62c7cd37604b0a8c1e45ef8b1a5b9f95a3ebc494ab504edee7df24e384008cad5a4b144f9ccbe4e483ed1760f062c60b74a9
data/CHANGELOG.md ADDED
@@ -0,0 +1,13 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 — 2026-06-04
4
+
5
+ Initial release. CLI-backed v0.
6
+
7
+ - `Boxd::Compute` entry point with `#boxes` / `#whoami`
8
+ - `Boxd::BoxService` with `#list`, `#get`, `#find`, `#create`, `#fork`, `#fork_or_get`, `#ephemeral`
9
+ - `Boxd::Box` with `#exec`, `#exec!`, `#write_file`, `#read_file`, `#suspend`, `#resume`, `#reboot`, `#destroy`, `#refresh!`, `#wait_for`
10
+ - Block-form streaming exec yielding `(stream, line)` tuples
11
+ - Typed errors: `AuthenticationError`, `NotFoundError`, `ExecError`, `TimeoutError`, `QuotaExceededError`, `CLIMissingError`, ...
12
+ - 5 example recipes under `examples/`
13
+ - Live smoke test in `spec/smoke_test.rb`
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 boxd contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,277 @@
1
+ # boxd
2
+
3
+ **Ruby SDK for [boxd.sh](https://boxd.sh) — fork a running Linux VM in ~160 milliseconds.**
4
+
5
+ ```ruby
6
+ require "boxd"
7
+
8
+ box = Boxd::Compute.new.boxes.fork("dev-golden", name: "my-fork")
9
+ box.url # => "https://my-fork.boxd.sh"
10
+ box.exec!(["uptime"])
11
+ box.suspend # idle is ~free; resume is sub-millisecond
12
+ ```
13
+
14
+ That's the whole pitch. The VM is real Linux on a real KVM hypervisor — sudo, kernel modules, Docker, persistent disk. The fork inherits live memory, open sockets, the running process tree. The URL is HTTPS at boot. Idle is sub-microsecond resume away.
15
+
16
+ Anything you'd build with a container, a serverless function, a CDE, or a code-interpreter sandbox — try it on a boxd VM and see if you stop reaching for the workaround.
17
+
18
+ ---
19
+
20
+ ## Why this gem exists
21
+
22
+ Modal, Daytona, E2B, Vercel Sandbox, Cloudflare Sandbox, Fly Sprites — every AI-sandbox platform of 2026 ships a Python or TypeScript SDK and stops there. Ruby is left in the cold.
23
+
24
+ But the Ruby world hasn't gone anywhere. Rails is still the front door to a generation of startups, Heroku-shaped operators still want a real shell on a real machine, and a whole community of CLI-tool authors lives in `bin/` directories Ruby still owns.
25
+
26
+ This gem is the boxd primitive in the idiom Ruby developers expect: snake_case, blocks for ephemeral resources, typed exceptions, `frozen_string_literal: true` on every file. Pair it with `ruby-openai`, `anthropic`, `langchain-ruby`, or whatever else you already trust — and you have a working agent loop in twenty lines.
27
+
28
+ ---
29
+
30
+ ## Install
31
+
32
+ ```bash
33
+ gem install boxd
34
+ ```
35
+
36
+ Or in a Gemfile:
37
+
38
+ ```ruby
39
+ gem "boxd"
40
+ ```
41
+
42
+ You'll need the `boxd` CLI on `$PATH` (this gem wraps it in v0 — see [Status](#status)):
43
+
44
+ ```bash
45
+ curl -fsSL https://boxd.sh/install.sh | sh
46
+ boxd login
47
+ ```
48
+
49
+ That's the whole setup. The gem reads your CLI credentials automatically; no API keys to wrangle unless you want to pin one explicitly (`Boxd::Compute.new(api_key: "bxd_…")`).
50
+
51
+ <a id="boxd-dev"></a>
52
+
53
+ ## boxd-dev — a one-liner dev box for any GitHub or GitLab repo
54
+
55
+ Installing the gem also drops a `boxd-dev` binary on your `$PATH`. Give it a GitHub repo and you're in a tmux session with the repo cloned, deps installed, and Claude one keystroke away:
56
+
57
+ ```bash
58
+ boxd-dev chad/phoenix # `owner/repo` shorthand → GitHub
59
+ boxd-dev https://github.com/chad/phoenix.git # any HTTPS clone URL
60
+ boxd-dev git@github.com:chad/phoenix.git # SSH-style remotes
61
+ boxd-dev https://gitlab.com/group/proj # other forges work too
62
+ boxd-dev # no arg → connects to your default dev VM
63
+ ```
64
+
65
+ What it actually does, end to end:
66
+
67
+ 1. Confirms the repo exists (via `gh repo view` for GitHub).
68
+ 2. Forks your golden VM into a per-repo box (`dev-<repo>`), in ~160 ms — or resumes the existing one if you've used it before.
69
+ 3. Ensures a persistent tmux session named `dev` is running.
70
+ 4. If the repo isn't cloned yet, kicks off `/usr/local/bin/dev-bootstrap` inside that tmux session — clones the repo and runs the project's setup script (or falls back to ecosystem defaults: `npm install`, `cargo fetch`, `mix deps.get`, `bundle install`, `uv sync`, etc.).
71
+ 5. SSHes you straight into the tmux session so you see the bootstrap happen live, then land at a shell in the repo directory.
72
+
73
+ Subsequent runs against the same repo skip steps 1–4 and just attach. Suspended VMs cost ~$0 and resume in sub-millisecond; you can keep one per project indefinitely.
74
+
75
+ Want it as `boxd dev <repo>` instead of `boxd-dev`? When boxd's CLI grows git/`gh`-style plugin autoload (it doesn't today), no code change here — same binary becomes the subcommand.
76
+
77
+ ---
78
+
79
+ ## A 15-minute tutorial, in five steps
80
+
81
+ The [`examples/`](examples) directory is the tutorial. Each file is a self-contained, runnable Ruby program that introduces one more capability. Read them in order and you've seen the whole surface.
82
+
83
+ ### Lesson 1 — Run untrusted code in a disposable VM
84
+
85
+ → [`examples/01_run_untrusted_llm_code.rb`](examples/01_run_untrusted_llm_code.rb)
86
+
87
+ The classic "code interpreter" pattern. An LLM hands you a Ruby snippet. You don't trust it. So you fork a fresh box, write the file, run it with a timeout, and throw the VM away. The whole pattern is one block:
88
+
89
+ ```ruby
90
+ result = compute.boxes.ephemeral(fork: "dev-golden") do |box|
91
+ box.write_file("/tmp/snippet.rb", llm_generated_code)
92
+ box.exec(["ruby", "/tmp/snippet.rb"], timeout: 10)
93
+ end
94
+ ```
95
+
96
+ `ephemeral` destroys the box at end of block — even if the code raises, even if you `Ctrl-C` out. The agent's malicious `rm -rf /` happens to a VM that's about to disappear anyway.
97
+
98
+ ### Lesson 2 — Fork an agent mid-thought
99
+
100
+ → [`examples/02_fork_during_thinking.rb`](examples/02_fork_during_thinking.rb)
101
+
102
+ Your agent is mid-run inside a box. It's holding state in memory — a parsed AST, a populated cache, a half-completed compile. You want to branch its execution and try two different next steps without losing the warm state.
103
+
104
+ ```ruby
105
+ parent = compute.boxes.fork("dev-golden", name: "parent")
106
+ parent.exec!(["bash", "-lc", "warm-up-stuff-here"])
107
+
108
+ branch_a = compute.boxes.fork(parent.name, name: "branch-a")
109
+ branch_b = compute.boxes.fork(parent.name, name: "branch-b")
110
+ # Both children inherit the parent's filesystem, memory, and open sockets.
111
+ ```
112
+
113
+ This is the primitive nothing else ships. Vercel and Cloudflare snapshot the filesystem; Modal restores from a deploy-time image. **boxd forks the running process tree.**
114
+
115
+ ### Lesson 3 — Race three agents at the same prompt
116
+
117
+ → [`examples/03_parallel_agent_bake_off.rb`](examples/03_parallel_agent_bake_off.rb)
118
+
119
+ Three agent personalities — architect, designer, hacker — each get their own VM, the same prompt, and a different system message. They build in parallel. You collect the URLs and pick the winner.
120
+
121
+ ```ruby
122
+ %w[architect designer hacker].map do |voice|
123
+ Thread.new do
124
+ box = compute.boxes.fork("dev-golden", name: "bake-#{voice}")
125
+ box.write_file("/tmp/prompt.txt", system_prompt_for(voice) + user_task)
126
+ box.exec(["bash", "-lc", 'claude -p "$(cat /tmp/prompt.txt)" --dangerously-skip-permissions'])
127
+ puts " #{voice} → #{box.url}"
128
+ end
129
+ end.each(&:join)
130
+ ```
131
+
132
+ Every VM ships Claude Code pre-authenticated to your account, so there's no API key wrangling. Three boxes, three live URLs, three running apps to compare side by side.
133
+
134
+ ### Lesson 4 — Replace your CI runner with a forked golden
135
+
136
+ → [`examples/04_ephemeral_ci_runner.rb`](examples/04_ephemeral_ci_runner.rb)
137
+
138
+ Your CI job needs Docker-in-Docker, sudo, and a warm Cargo cache. GitHub Actions runners are slow and don't trust you with that. Fork a golden that's already loaded with your toolchain instead:
139
+
140
+ ```ruby
141
+ compute.boxes.ephemeral(fork: "ci-golden", name: "ci-#{sha}") do |box|
142
+ box.exec!(["bash", "-lc", "git checkout #{sha} && ./script/test"]) do |stream, line|
143
+ print "[#{stream}] #{line}"
144
+ end
145
+ end
146
+ ```
147
+
148
+ The fork inherits the warm cache. The job runs in ~the time it takes to actually compile. Box auto-suspends when idle. You pay for the CPU you actually used.
149
+
150
+ ### Lesson 5 — A multi-tenant code interpreter
151
+
152
+ → [`examples/05_multi_tenant_code_interpreter.rb`](examples/05_multi_tenant_code_interpreter.rb)
153
+
154
+ You ship a Jupyter-style product where every customer session is its own isolated VM. You want cold-start in under a second, state that persists across sessions, and zero-ish cost between them. With `fork_or_get` plus auto-suspend, the whole pool fits in a class:
155
+
156
+ ```ruby
157
+ def session_for(customer_id)
158
+ box = compute.boxes.fork_or_get("interpreter-golden",
159
+ name: "ipy-#{customer_id}",
160
+ auto_suspend_timeout: 60)
161
+ box.resume if box.suspended?
162
+ box
163
+ end
164
+ ```
165
+
166
+ First call forks the golden in ~160ms. Subsequent calls find the existing VM and resume it in sub-ms. Sixty seconds of network idle and it auto-suspends — disk persists, billing stops.
167
+
168
+ ---
169
+
170
+ ## API reference
171
+
172
+ ### `Boxd::Compute`
173
+
174
+ ```ruby
175
+ compute = Boxd::Compute.new(api_key: "...", environment: "production")
176
+ compute.boxes # → BoxService
177
+ compute.whoami # → { user:, keys: [...] }
178
+ ```
179
+
180
+ Process-wide config (Rails-friendly):
181
+
182
+ ```ruby
183
+ Boxd.configure do |c|
184
+ c.api_key = ENV["BOXD_API_KEY"]
185
+ c.environment = "production" # or "staging"
186
+ end
187
+ ```
188
+
189
+ ### `Boxd::BoxService` — `compute.boxes`
190
+
191
+ | Method | Description |
192
+ |---|---|
193
+ | `#list` | Every box on the account |
194
+ | `#get(name)` | Fetch by name; raises `NotFoundError` |
195
+ | `#find(name)` | Fetch by name; returns `nil` if missing |
196
+ | `#create(name:, auto_suspend_timeout:, restart:)` | New empty VM |
197
+ | `#fork(source, name:, auto_suspend_timeout:)` | Fork an existing VM with full live state |
198
+ | `#fork_or_get(source, name:, **opts)` | Idempotent fork — returns existing box by name, or forks |
199
+ | `#ephemeral(fork: \| create:, name:, **opts) { \|box\| ... }` | Block-scoped VM; destroyed on exit |
200
+
201
+ ### `Boxd::Box`
202
+
203
+ | Method | Description |
204
+ |---|---|
205
+ | `#exec(cmd, env:, tty:, timeout:, raise_on_error:) { \|stream, line\| ... }` | Run a command; streams via block |
206
+ | `#exec!(cmd, **opts)` | Same; returns stdout `String`, raises on non-zero |
207
+ | `#write_file(path, content_or_io)` | Upload a file (string or anything `#read`-able) |
208
+ | `#read_file(remote, local=nil)` | Download a file; returns local path |
209
+ | `#suspend` / `#pause` | Pause the VM (sub-ms resume) |
210
+ | `#resume` | Wake from suspend |
211
+ | `#reboot` | Cold reboot |
212
+ | `#destroy` | Permanent — disk goes away |
213
+ | `#refresh!` | Refetch status from the API |
214
+ | `#wait_for(status, timeout:)` | Block until status matches |
215
+ | `#url` / `#name` / `#vm_id` / `#status` / `#image` | Cached attrs |
216
+ | `#running?` / `#suspended?` | Status helpers |
217
+
218
+ ### Exceptions
219
+
220
+ ```
221
+ Boxd::Error
222
+ ├── Boxd::AuthenticationError # bad token / not logged in
223
+ ├── Boxd::NotFoundError # VM doesn't exist
224
+ ├── Boxd::InvalidArgumentError # bad request to the API
225
+ ├── Boxd::QuotaExceededError # account limit hit
226
+ ├── Boxd::TimeoutError # exec or operation exceeded its budget
227
+ ├── Boxd::ConnectionError # network/transport issue
228
+ ├── Boxd::InternalError # CLI returned non-JSON / unexpected output
229
+ ├── Boxd::CLIMissingError # `boxd` not on $PATH
230
+ └── Boxd::ExecError # exec exited non-zero (carries :exit_code, :stdout, :stderr)
231
+ ```
232
+
233
+ ---
234
+
235
+ ## Recipes summary
236
+
237
+ | File | What you learn |
238
+ |---|---|
239
+ | [01_run_untrusted_llm_code.rb](examples/01_run_untrusted_llm_code.rb) | `ephemeral`, `write_file`, `exec` with timeout — the code-interpreter pattern |
240
+ | [02_fork_during_thinking.rb](examples/02_fork_during_thinking.rb) | Forking a *running* VM; preserving live state across branches |
241
+ | [03_parallel_agent_bake_off.rb](examples/03_parallel_agent_bake_off.rb) | Parallel forks; pre-auth'd Claude Code; comparing strategies |
242
+ | [04_ephemeral_ci_runner.rb](examples/04_ephemeral_ci_runner.rb) | Streaming exec; warm-cache forking; replacing your CI runner |
243
+ | [05_multi_tenant_code_interpreter.rb](examples/05_multi_tenant_code_interpreter.rb) | `fork_or_get`; long-lived per-customer VMs; auto-suspend economics |
244
+
245
+ ---
246
+
247
+ ## Status
248
+
249
+ **v0 (this release)** — wraps the [`boxd` CLI](https://docs.boxd.sh/cli). Works today; production-acceptable for back-end and DevOps use cases where shelling is fine. The public Ruby API is stable across the v1 transition.
250
+
251
+ **v1 (planned)** — native gRPC client via the `grpc` gem and the `boxd.api.v1` proto. Same public surface; faster, no CLI dependency, first-class streaming.
252
+
253
+ If you're reaching for something the CLI doesn't expose yet (templates, disks, networks, domains, token management), let us know what you need — open an issue and we'll lift it into the gem.
254
+
255
+ ---
256
+
257
+ ## Contributing
258
+
259
+ The codebase is small (~500 lines) and meant to stay that way. PRs welcome — especially for:
260
+
261
+ - Recipes showing real-world patterns
262
+ - Coverage of additional CLI surfaces
263
+ - A native gRPC backend for v1
264
+
265
+ Run the smoke test against your own boxd account:
266
+
267
+ ```bash
268
+ export BOXD_API_KEY=...
269
+ export BOXD_GOLDEN=dev-golden # or any box you have
270
+ ruby -Ilib spec/smoke_test.rb
271
+ ```
272
+
273
+ ---
274
+
275
+ ## License
276
+
277
+ MIT — see [LICENSE](LICENSE).
data/bin/boxd-dev ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "boxd"
5
+ require "boxd/dev_command"
6
+
7
+ Boxd::DevCommand.new(ARGV).run
data/boxd.gemspec ADDED
@@ -0,0 +1,47 @@
1
+ lib = File.expand_path("lib", __dir__)
2
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
3
+ require "boxd/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "boxd"
7
+ spec.version = Boxd::VERSION
8
+ spec.authors = ["boxd contributors"]
9
+ spec.email = ["hello@boxd.sh"]
10
+
11
+ spec.summary = "Ruby SDK for boxd.sh — forkable KVM microVMs"
12
+ spec.description = <<~DESC
13
+ Idiomatic Ruby client for boxd.sh. Fork a running VM with full state in
14
+ ~160ms, suspend and resume in sub-ms, run anything inside it, get an
15
+ HTTPS URL at name.boxd.sh.
16
+
17
+ v0 wraps the `boxd` CLI; v1 will speak gRPC natively. The public API
18
+ is stable across that change.
19
+ DESC
20
+ spec.homepage = "https://boxd.sh"
21
+ spec.license = "MIT"
22
+
23
+ spec.required_ruby_version = ">= 3.2.0"
24
+
25
+ spec.metadata = {
26
+ "homepage_uri" => "https://boxd.sh",
27
+ "documentation_uri" => "https://docs.boxd.sh",
28
+ "source_code_uri" => "https://github.com/boxd-sh/boxd-ruby",
29
+ "bug_tracker_uri" => "https://github.com/boxd-sh/boxd-ruby/issues",
30
+ "changelog_uri" => "https://github.com/boxd-sh/boxd-ruby/blob/main/CHANGELOG.md",
31
+ }
32
+
33
+ spec.files = Dir[
34
+ "lib/**/*.rb",
35
+ "bin/boxd-dev",
36
+ "README.md",
37
+ "LICENSE",
38
+ "CHANGELOG.md",
39
+ "boxd.gemspec",
40
+ ]
41
+ spec.bindir = "bin"
42
+ spec.executables = ["boxd-dev"]
43
+ spec.require_paths = ["lib"]
44
+
45
+ spec.add_development_dependency "rspec", "~> 3.13"
46
+ spec.add_development_dependency "rake", "~> 13.0"
47
+ end
data/lib/boxd/box.rb ADDED
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tempfile"
4
+
5
+ module Boxd
6
+ # A Box is the handle to a single VM. Instances are returned from
7
+ # Compute#boxes operations; you don't construct them directly.
8
+ class Box
9
+ attr_reader :name, :vm_id, :url, :status, :image
10
+
11
+ def initialize(attrs, backend:)
12
+ @backend = backend
13
+ refresh_from(attrs)
14
+ end
15
+
16
+ # Fetch the latest state from the API and update this Box in place.
17
+ def refresh!
18
+ refresh_from(@backend.call_json("info", @name))
19
+ self
20
+ end
21
+
22
+ # Convenience: hash representation of the current cached state.
23
+ def to_h
24
+ {
25
+ name: @name,
26
+ vm_id: @vm_id,
27
+ url: @url,
28
+ status: @status,
29
+ image: @image,
30
+ }
31
+ end
32
+
33
+ def running?
34
+ status == "running"
35
+ end
36
+
37
+ def suspended?
38
+ %w[hibernated standby paused].include?(status)
39
+ end
40
+
41
+ # Run a command inside the VM.
42
+ #
43
+ # box.exec(["ls", "-la"]) # sync, blocking
44
+ # box.exec(["sleep", "3"], timeout: 1) # raises TimeoutError
45
+ # box.exec(["bash", "-lc", "for i in 1 2 3; ...]) do |stream, chunk|
46
+ # # stream is :stdout or :stderr
47
+ # end
48
+ #
49
+ # Returns Hash {stdout:, stderr:, exit_code:}. Raises ExecError if
50
+ # `raise_on_error: true` and exit_code != 0.
51
+ def exec(cmd, env: nil, tty: false, timeout: nil, raise_on_error: false, &block)
52
+ result =
53
+ if timeout
54
+ run_with_timeout(timeout) { @backend.exec_stream(@name, cmd, env: env, tty: tty, &block) }
55
+ else
56
+ @backend.exec_stream(@name, cmd, env: env, tty: tty, &block)
57
+ end
58
+
59
+ if raise_on_error && result[:exit_code] != 0
60
+ raise ExecError.new(
61
+ "exec exited #{result[:exit_code]}: #{result[:stderr].strip.lines.last}",
62
+ **result,
63
+ )
64
+ end
65
+ result
66
+ end
67
+
68
+ # Run a command and return stdout as a String, raising on any non-zero
69
+ # exit. Convenience for one-liners.
70
+ def exec!(cmd, **opts)
71
+ r = exec(cmd, **opts, raise_on_error: true)
72
+ r[:stdout]
73
+ end
74
+
75
+ # Write content to a path inside the VM.
76
+ #
77
+ # box.write_file("/tmp/hello.txt", "hello world")
78
+ # box.write_file("/etc/conf", File.read("./conf"))
79
+ # box.write_file("/tmp/blob", io_object) # responds to :read
80
+ def write_file(path, content)
81
+ data =
82
+ if content.respond_to?(:read)
83
+ content.read
84
+ else
85
+ content.to_s
86
+ end
87
+
88
+ Tempfile.create(["boxd-write", File.extname(path)]) do |tmp|
89
+ tmp.binmode
90
+ tmp.write(data)
91
+ tmp.flush
92
+ @backend.call_raw("cp", tmp.path, "#{@name}:#{path}")
93
+ end
94
+ path
95
+ end
96
+
97
+ # Copy a file FROM the VM to the local filesystem. Returns the local path.
98
+ def read_file(remote_path, local_path = nil)
99
+ local_path ||= Tempfile.new(File.basename(remote_path)).path
100
+ @backend.call_raw("cp", "#{@name}:#{remote_path}", local_path)
101
+ local_path
102
+ end
103
+
104
+ def suspend
105
+ @backend.call_raw("pause", @name)
106
+ refresh!
107
+ end
108
+ alias pause suspend
109
+
110
+ def resume
111
+ @backend.call_raw("resume", @name)
112
+ refresh!
113
+ end
114
+
115
+ def reboot
116
+ @backend.call_raw("reboot", @name)
117
+ refresh!
118
+ end
119
+
120
+ def destroy
121
+ @backend.call_raw("destroy", @name, "--confirm")
122
+ @status = "destroyed"
123
+ self
124
+ end
125
+
126
+ # Wait until status matches `target` (String or Array). Polls `info`.
127
+ # Returns self on match; raises TimeoutError after `timeout` seconds.
128
+ def wait_for(target, timeout: 60, interval: 1)
129
+ targets = Array(target).map(&:to_s)
130
+ deadline = Time.now + timeout
131
+ until targets.include?(refresh!.status)
132
+ raise TimeoutError, "wait_for(#{targets.inspect}) timed out after #{timeout}s; status=#{status}" \
133
+ if Time.now >= deadline
134
+ sleep interval
135
+ end
136
+ self
137
+ end
138
+
139
+ def inspect
140
+ "#<Boxd::Box name=#{@name.inspect} status=#{@status.inspect} url=#{@url.inspect}>"
141
+ end
142
+
143
+ private
144
+
145
+ def refresh_from(attrs)
146
+ attrs ||= {}
147
+ @name = attrs[:name] || @name
148
+ @vm_id = attrs[:vm_id] || @vm_id
149
+ @status = attrs[:status] || @status
150
+ @image = attrs[:image] || @image
151
+ raw_url = attrs[:url] || @url
152
+ @url = raw_url && (raw_url.start_with?("http") ? raw_url : "https://#{raw_url}")
153
+ end
154
+
155
+ def run_with_timeout(seconds)
156
+ result = nil
157
+ thread = Thread.new { result = yield }
158
+ unless thread.join(seconds)
159
+ thread.kill
160
+ raise TimeoutError, "operation exceeded #{seconds}s"
161
+ end
162
+ result
163
+ end
164
+ end
165
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Boxd
4
+ # Collection accessor for boxes. Exposed as `compute.boxes`.
5
+ class BoxService
6
+ def initialize(backend)
7
+ @backend = backend
8
+ end
9
+
10
+ # List all boxes on the account.
11
+ def list
12
+ Array(@backend.call_json("list")).map { |attrs| Box.new(attrs, backend: @backend) }
13
+ end
14
+
15
+ # Get a single box by name. Raises NotFoundError if missing.
16
+ def get(name)
17
+ Box.new(@backend.call_json("info", name), backend: @backend)
18
+ end
19
+
20
+ # Like #get, but returns nil if the box doesn't exist.
21
+ def find(name)
22
+ get(name)
23
+ rescue NotFoundError
24
+ nil
25
+ end
26
+
27
+ # Create a fresh box.
28
+ #
29
+ # compute.boxes.create(name: "my-box", auto_suspend_timeout: 0)
30
+ def create(name: nil, auto_suspend_timeout: nil, restart: nil)
31
+ args = ["new"]
32
+ args.push("--name", name) if name
33
+ args.push("--auto-suspend-timeout", auto_suspend_timeout.to_s) if auto_suspend_timeout
34
+ args.push("--restart", restart.to_s) if restart
35
+ Box.new(@backend.call_json(*args), backend: @backend)
36
+ end
37
+
38
+ # Fork an existing box. `source` is the source box's name or ID.
39
+ #
40
+ # compute.boxes.fork("dev-golden", name: "preview-pr-42")
41
+ def fork(source, name: nil, auto_suspend_timeout: nil)
42
+ args = ["fork", source.to_s]
43
+ args.push("--name", name) if name
44
+ args.push("--auto-suspend-timeout", auto_suspend_timeout.to_s) if auto_suspend_timeout
45
+ Box.new(@backend.call_json(*args), backend: @backend)
46
+ end
47
+
48
+ # Fork-or-get: if `name` already exists, return that box; otherwise fork
49
+ # from `source`. Handy for idempotent provisioning.
50
+ def fork_or_get(source, name:, **opts)
51
+ existing = find(name)
52
+ return existing if existing
53
+
54
+ fork(source, name: name, **opts)
55
+ end
56
+
57
+ # Ephemeral: fork a source box, yield it, and destroy when the block
58
+ # exits (even on exception). Use for one-shot agent tasks where the
59
+ # fork should disappear afterwards.
60
+ #
61
+ # compute.boxes.ephemeral(fork: "dev-golden") do |box|
62
+ # box.exec!(["bash", "-lc", "cargo test"])
63
+ # end
64
+ def ephemeral(fork: nil, create: nil, name: nil, **opts)
65
+ box =
66
+ if fork
67
+ self.fork(fork, name: name, **opts)
68
+ else
69
+ create(name: name, **(create || {}), **opts)
70
+ end
71
+ yield box
72
+ ensure
73
+ box&.destroy if box && box.status != "destroyed"
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "open3"
5
+ require "shellwords"
6
+ require "tempfile"
7
+
8
+ module Boxd
9
+ # CLIBackend shells out to the `boxd` CLI for every operation. v0 of this
10
+ # gem is built on this; v1 will speak gRPC directly. The public API does
11
+ # not change between the two — only this file does.
12
+ class CLIBackend
13
+ DEFAULT_BIN = "boxd"
14
+
15
+ attr_reader :bin, :env
16
+
17
+ def initialize(api_key: nil, bin: nil, environment: nil)
18
+ @bin = bin || ENV["BOXD_BIN"] || DEFAULT_BIN
19
+ @env = {}
20
+ @env["BOXD_TOKEN"] = api_key if api_key
21
+ @env["BOXD_ENVIRONMENT"] = environment if environment
22
+
23
+ assert_cli_present!
24
+ end
25
+
26
+ # Run `boxd <args>` and parse `--json` output. Raises on non-zero exit
27
+ # with a typed Boxd::Error subclass when we recognise the failure.
28
+ def call_json(*args)
29
+ out, err, status = Open3.capture3(env, bin, "--json", *args.map(&:to_s))
30
+ unless status.success?
31
+ raise classify_error(err.empty? ? out : err, args)
32
+ end
33
+
34
+ return nil if out.strip.empty?
35
+
36
+ JSON.parse(out, symbolize_names: true)
37
+ rescue JSON::ParserError => e
38
+ raise InternalError, "boxd CLI returned non-JSON output for `#{args.join(' ')}`: #{e.message}\n#{out}"
39
+ end
40
+
41
+ # Run `boxd <args>` and return [stdout, stderr] as strings. Used for
42
+ # commands without --json support.
43
+ def call_raw(*args)
44
+ out, err, status = Open3.capture3(env, bin, *args.map(&:to_s))
45
+ raise classify_error(err.empty? ? out : err, args) unless status.success?
46
+
47
+ [out, err]
48
+ end
49
+
50
+ # Stream `boxd exec` for a single command, yielding chunks. Returns
51
+ # the exit code captured from CLI exit status.
52
+ #
53
+ # The CLI does not stream stdout/stderr separately by default; we
54
+ # capture both via pipes and yield each line as it arrives, tagging
55
+ # the stream.
56
+ def exec_stream(vm, cmd, env: nil, tty: false, &block)
57
+ cli_args = ["exec"]
58
+ cli_args << "--tty" if tty
59
+ Array(env).each do |k, v|
60
+ cli_args.push("-e", "#{k}=#{v}")
61
+ end
62
+ cli_args << vm.to_s
63
+ cli_args << "--"
64
+ cli_args.concat(Array(cmd).map(&:to_s))
65
+
66
+ stdout_buf = +""
67
+ stderr_buf = +""
68
+ exit_code = nil
69
+
70
+ Open3.popen3(self.env, bin, *cli_args) do |_stdin, stdout, stderr, wait_thr|
71
+ threads = []
72
+ threads << Thread.new do
73
+ stdout.each_line do |line|
74
+ stdout_buf << line
75
+ block&.call(:stdout, line)
76
+ end
77
+ end
78
+ threads << Thread.new do
79
+ stderr.each_line do |line|
80
+ stderr_buf << line
81
+ block&.call(:stderr, line)
82
+ end
83
+ end
84
+ threads.each(&:join)
85
+ exit_code = wait_thr.value.exitstatus
86
+ end
87
+
88
+ { stdout: stdout_buf, stderr: stderr_buf, exit_code: exit_code }
89
+ end
90
+
91
+ private
92
+
93
+ def assert_cli_present!
94
+ return if system("which #{Shellwords.escape(@bin)} > /dev/null 2>&1")
95
+
96
+ raise CLIMissingError, <<~MSG
97
+ boxd CLI not found on PATH (looked for `#{@bin}`).
98
+ Install it: curl -fsSL https://boxd.sh/install.sh | sh
99
+ MSG
100
+ end
101
+
102
+ def classify_error(message, args)
103
+ msg = message.to_s
104
+ case msg
105
+ when /InvalidToken|unauthenticated/i
106
+ AuthenticationError.new("boxd auth failed: #{msg}")
107
+ when /not found/i
108
+ NotFoundError.new("boxd #{args.first}: #{msg}")
109
+ when /quota|limit/i
110
+ QuotaExceededError.new(msg)
111
+ when /timeout/i
112
+ TimeoutError.new(msg)
113
+ when /connection|network/i
114
+ ConnectionError.new(msg)
115
+ else
116
+ Error.new("boxd #{args.join(' ')}: #{msg.strip}")
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Boxd
4
+ # Compute is the entry point. It holds the credentials + CLI backend
5
+ # and exposes service accessors (currently just #boxes).
6
+ #
7
+ # compute = Boxd::Compute.new(api_key: ENV["BOXD_API_KEY"])
8
+ # compute.boxes.list
9
+ # compute.whoami
10
+ #
11
+ # If api_key: is omitted, the boxd CLI's stored login credentials are
12
+ # used (i.e. whatever `boxd login` set up locally).
13
+ class Compute
14
+ attr_reader :backend
15
+
16
+ def initialize(api_key: nil, bin: nil, environment: nil)
17
+ api_key ||= Boxd.config.api_key
18
+ environment ||= Boxd.config.environment
19
+ @backend = CLIBackend.new(api_key: api_key, bin: bin, environment: environment)
20
+ end
21
+
22
+ def boxes
23
+ @boxes ||= BoxService.new(@backend)
24
+ end
25
+
26
+ def whoami
27
+ @backend.call_json("whoami")
28
+ end
29
+ end
30
+
31
+ # Process-wide defaults. Useful for Rails initializers and scripts.
32
+ #
33
+ # Boxd.configure do |c|
34
+ # c.api_key = ENV["BOXD_API_KEY"]
35
+ # c.environment = "production" # or "staging"
36
+ # end
37
+ class Configuration
38
+ attr_accessor :api_key, :environment
39
+ end
40
+
41
+ class << self
42
+ def config
43
+ @config ||= Configuration.new
44
+ end
45
+
46
+ def configure
47
+ yield config
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+ require "shellwords"
5
+ require "open3"
6
+ require "json"
7
+
8
+ module Boxd
9
+ # Implements the `boxd-dev` command. Given a repo reference, ensures a
10
+ # dev VM exists for it (forked from a golden), the repo is cloned and
11
+ # set up, a persistent tmux session is running, then drops you into it
12
+ # over SSH.
13
+ #
14
+ # Repo references accepted:
15
+ # chad/phoenix # GitHub (by convention)
16
+ # github.com/chad/phoenix
17
+ # https://github.com/chad/phoenix(.git)?
18
+ # git@github.com:chad/phoenix.git
19
+ # https://gitlab.com/group/proj
20
+ # <bare VM name> # connect to existing
21
+ class DevCommand
22
+ GOLDEN_DEFAULT = "dev-golden"
23
+ BOOTSTRAP_PATH = "/usr/local/bin/dev-bootstrap"
24
+
25
+ def initialize(argv)
26
+ @argv = argv
27
+ end
28
+
29
+ def run
30
+ arg = @argv.first || ENV["BOXD_DEV_VM"] || "dev-chad"
31
+
32
+ repo = parse_repo(arg)
33
+ vm = repo ? "dev-#{repo[:slug]}" : arg
34
+ golden = ENV.fetch("BOXD_GOLDEN", GOLDEN_DEFAULT)
35
+
36
+ preflight_repo!(repo) if repo
37
+
38
+ compute = Boxd::Compute.new
39
+ box = ensure_vm(compute, vm, golden, repo)
40
+ prep_tmux(box, repo)
41
+ exec_attach(box)
42
+ end
43
+
44
+ private
45
+
46
+ # ---- repo reference parsing ------------------------------------------
47
+
48
+ def parse_repo(arg)
49
+ case arg
50
+ when %r{\A([\w.-]+)/([\w.-]+)\z}
51
+ github("github.com", $1, $2)
52
+ when %r{\Ahttps?://([\w.-]+\.\w+)/([\w.-]+)/([\w.-]+?)(?:\.git)?/?\z}
53
+ github($1, $2, $3)
54
+ when %r{\Agit@([\w.-]+):([\w.-]+)/([\w.-]+?)(?:\.git)?\z}
55
+ github($1, $2, $3)
56
+ when %r{\A([\w.-]+\.\w+)/([\w.-]+)/([\w.-]+?)(?:\.git)?\z}
57
+ github($1, $2, $3)
58
+ end
59
+ end
60
+
61
+ def github(host, owner, repo)
62
+ slug = repo.downcase.gsub(/[^a-z0-9-]+/, "-").gsub(/-+/, "-").sub(/\A-/, "").sub(/-\z/, "")
63
+ {
64
+ host: host,
65
+ owner: owner,
66
+ repo: repo,
67
+ slug: slug,
68
+ display: "#{owner}/#{repo}",
69
+ clone_url: "https://#{host}/#{owner}/#{repo}.git",
70
+ }
71
+ end
72
+
73
+ # ---- preflight: refuse to fork on a bad repo -------------------------
74
+
75
+ def preflight_repo!(repo)
76
+ return unless repo[:host] == "github.com"
77
+
78
+ unless system("command -v gh > /dev/null 2>&1")
79
+ warn "boxd-dev: gh CLI not found; skipping repo existence check"
80
+ return
81
+ end
82
+
83
+ return if system("gh repo view #{Shellwords.escape(repo[:display])} > /dev/null 2>&1")
84
+
85
+ warn "boxd-dev: #{repo[:display]} not found or inaccessible to your gh account"
86
+ exit 1
87
+ end
88
+
89
+ # ---- VM lifecycle ----------------------------------------------------
90
+
91
+ def ensure_vm(compute, vm, golden, repo)
92
+ existing = compute.boxes.find(vm)
93
+ if existing
94
+ existing.resume if existing.suspended?
95
+ return existing
96
+ end
97
+
98
+ unless repo
99
+ warn "boxd-dev: no VM named '#{vm}'"
100
+ exit 1
101
+ end
102
+
103
+ unless compute.boxes.find(golden)
104
+ warn "boxd-dev: golden VM '#{golden}' is missing (set BOXD_GOLDEN to override)"
105
+ exit 1
106
+ end
107
+
108
+ warn "boxd-dev: forking #{golden} → #{vm} (#{repo[:display]})..."
109
+ compute.boxes.fork(golden, name: vm, auto_suspend_timeout: 0)
110
+ end
111
+
112
+ # ---- tmux + bootstrap ------------------------------------------------
113
+
114
+ # Ensure the persistent tmux session "dev" exists on the box. If a
115
+ # repo arg was given and the clone isn't there yet, kick off the
116
+ # bootstrap script in pane 0 so the attaching shell sees the build live.
117
+ def prep_tmux(box, repo)
118
+ script = +<<~SH
119
+ set -e
120
+ if ! tmux has-session -t dev 2>/dev/null; then
121
+ tmux new-session -d -s dev
122
+ SH
123
+
124
+ if repo
125
+ script << <<~SH
126
+ if [ ! -d "$HOME/#{repo[:repo]}" ]; then
127
+ tmux send-keys -t dev "REPO_URL='#{repo[:clone_url]}' REPO_NAME='#{repo[:repo]}' #{BOOTSTRAP_PATH} && cd ~/#{repo[:repo]}" Enter
128
+ else
129
+ tmux send-keys -t dev "cd ~/#{repo[:repo]}" Enter
130
+ fi
131
+ SH
132
+ end
133
+
134
+ script << " fi\n"
135
+
136
+ box.exec(["bash", "-c", script])
137
+ end
138
+
139
+ # ---- final SSH attach ------------------------------------------------
140
+
141
+ def exec_attach(box)
142
+ ssh_host = URI(box.url).host
143
+ exec("ssh", "-t", ssh_host, "--", "tmux new-session -A -s dev")
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Boxd
4
+ class Error < StandardError; end
5
+
6
+ class AuthenticationError < Error; end
7
+ class NotFoundError < Error; end
8
+ class InvalidArgumentError < Error; end
9
+ class QuotaExceededError < Error; end
10
+ class TimeoutError < Error; end
11
+ class ConnectionError < Error; end
12
+ class InternalError < Error; end
13
+ class CLIMissingError < Error; end
14
+
15
+ class ExecError < Error
16
+ attr_reader :exit_code, :stdout, :stderr
17
+
18
+ def initialize(message, exit_code:, stdout:, stderr:)
19
+ super(message)
20
+ @exit_code = exit_code
21
+ @stdout = stdout
22
+ @stderr = stderr
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Boxd
4
+ VERSION = "0.1.0"
5
+ end
data/lib/boxd.rb ADDED
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ # boxd — Ruby SDK for boxd.sh
4
+ #
5
+ # require "boxd"
6
+ #
7
+ # compute = Boxd::Compute.new(api_key: ENV["BOXD_API_KEY"])
8
+ # box = compute.boxes.fork("dev-golden", name: "my-fork")
9
+ # box.exec!(["bash", "-lc", "echo hello from $(hostname)"])
10
+ # box.suspend
11
+ #
12
+ # See README.md or examples/ for more.
13
+
14
+ require "boxd/version"
15
+ require "boxd/errors"
16
+ require "boxd/cli_backend"
17
+ require "boxd/box"
18
+ require "boxd/box_service"
19
+ require "boxd/compute"
20
+
21
+ module Boxd
22
+ end
metadata ADDED
@@ -0,0 +1,96 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: boxd
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - boxd contributors
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-06-04 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rspec
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '3.13'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '3.13'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rake
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '13.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '13.0'
41
+ description: |
42
+ Idiomatic Ruby client for boxd.sh. Fork a running VM with full state in
43
+ ~160ms, suspend and resume in sub-ms, run anything inside it, get an
44
+ HTTPS URL at name.boxd.sh.
45
+
46
+ v0 wraps the `boxd` CLI; v1 will speak gRPC natively. The public API
47
+ is stable across that change.
48
+ email:
49
+ - hello@boxd.sh
50
+ executables:
51
+ - boxd-dev
52
+ extensions: []
53
+ extra_rdoc_files: []
54
+ files:
55
+ - CHANGELOG.md
56
+ - LICENSE
57
+ - README.md
58
+ - bin/boxd-dev
59
+ - boxd.gemspec
60
+ - lib/boxd.rb
61
+ - lib/boxd/box.rb
62
+ - lib/boxd/box_service.rb
63
+ - lib/boxd/cli_backend.rb
64
+ - lib/boxd/compute.rb
65
+ - lib/boxd/dev_command.rb
66
+ - lib/boxd/errors.rb
67
+ - lib/boxd/version.rb
68
+ homepage: https://boxd.sh
69
+ licenses:
70
+ - MIT
71
+ metadata:
72
+ homepage_uri: https://boxd.sh
73
+ documentation_uri: https://docs.boxd.sh
74
+ source_code_uri: https://github.com/boxd-sh/boxd-ruby
75
+ bug_tracker_uri: https://github.com/boxd-sh/boxd-ruby/issues
76
+ changelog_uri: https://github.com/boxd-sh/boxd-ruby/blob/main/CHANGELOG.md
77
+ post_install_message:
78
+ rdoc_options: []
79
+ require_paths:
80
+ - lib
81
+ required_ruby_version: !ruby/object:Gem::Requirement
82
+ requirements:
83
+ - - ">="
84
+ - !ruby/object:Gem::Version
85
+ version: 3.2.0
86
+ required_rubygems_version: !ruby/object:Gem::Requirement
87
+ requirements:
88
+ - - ">="
89
+ - !ruby/object:Gem::Version
90
+ version: '0'
91
+ requirements: []
92
+ rubygems_version: 3.0.3.1
93
+ signing_key:
94
+ specification_version: 4
95
+ summary: Ruby SDK for boxd.sh — forkable KVM microVMs
96
+ test_files: []