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 +7 -0
- data/CHANGELOG.md +13 -0
- data/LICENSE +21 -0
- data/README.md +277 -0
- data/bin/boxd-dev +7 -0
- data/boxd.gemspec +47 -0
- data/lib/boxd/box.rb +165 -0
- data/lib/boxd/box_service.rb +76 -0
- data/lib/boxd/cli_backend.rb +120 -0
- data/lib/boxd/compute.rb +50 -0
- data/lib/boxd/dev_command.rb +146 -0
- data/lib/boxd/errors.rb +25 -0
- data/lib/boxd/version.rb +5 -0
- data/lib/boxd.rb +22 -0
- metadata +96 -0
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
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
|
data/lib/boxd/compute.rb
ADDED
|
@@ -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
|
data/lib/boxd/errors.rb
ADDED
|
@@ -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
|
data/lib/boxd/version.rb
ADDED
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: []
|