sillydev 1.0.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: 9102635d2e7ac06f66d36dd44ea361de787e1d8d7357a3cdb677327ecb26974c
4
+ data.tar.gz: 22131ee43b2a56dcaca660c0d7e45883055da548e5a5acd432c6c547770154e1
5
+ SHA512:
6
+ metadata.gz: 3df262ecb504008664795fc8780140886fea64729e4ebd6c29675c1c663db2b490188498a1eabb72def712654644cf3fa0047d011f586a5f22b45bbc5effc5ae
7
+ data.tar.gz: 22d8573315748b14286eb5af88c0d7930ff6ea24f332211b1070408f188dc0f25d689688387d679236c1150d8bb7cc7700bd79d3c5876b2184c8349401901456
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Silly Development
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
+ # sillydev
2
+
3
+ The Ruby client for the [Silly Development](https://sillydev.co.uk) panel API: start, stop and restart
4
+ your bots, follow their console live, manage files, backups, schedules and databases, and deploy from
5
+ Git, all from your own code. It also has a `sillydev` command line tool, an MCP server for AI
6
+ assistants and opt-in application monitoring.
7
+
8
+ Full API docs: <https://sillydev.co.uk/docs/panel>
9
+
10
+ ## Install
11
+
12
+ ```sh
13
+ gem install sillydev
14
+ ```
15
+
16
+ or add `gem "sillydev"` to your Gemfile. Ruby 3.1 or later. No dependencies outside Ruby's standard
17
+ library.
18
+
19
+ ## Quick start
20
+
21
+ Create an API key in the panel under **Account, API keys** and put it in the `SILLYDEV_API_KEY`
22
+ environment variable. Keys start with `sdevc_` (older `ptlc_` keys keep working).
23
+
24
+ ```ruby
25
+ require "sillydev"
26
+
27
+ client = SillyDev::Client.new # or SillyDev::Client.new("sdevc_...")
28
+
29
+ client.panel.servers.list.each do |server|
30
+ puts "#{server["identifier"]} #{server["name"]}"
31
+ end
32
+
33
+ client.panel.servers.restart("1a7ce997")
34
+ client.panel.servers.command("1a7ce997", "say Back in a minute")
35
+ ```
36
+
37
+ Each server has an 8-character identifier, shown in the panel and in the `identifier` field of every
38
+ server. Every method takes it (or the full UUID) as its first argument.
39
+
40
+ Answers are plain hashes and arrays with string keys, as the API sends them, without the
41
+ `{"object", "attributes"}` wrapping.
42
+
43
+ ## What it covers
44
+
45
+ Every endpoint of the client API, grouped as in the docs:
46
+
47
+ | `client.panel.` | For example |
48
+ | --- | --- |
49
+ | `servers` | `list`, `get`, `resources`, `wait_for_state`, `power`, `start`, `stop`, `restart`, `kill`, `command`, `activity` |
50
+ | `settings` | `rename`, `reinstall`, `set_docker_image`, `badge` |
51
+ | `startup` | `get`, `set_variable` |
52
+ | `files` | `list`, `read`, `write`, `rename`, `copy`, `compress`, `decompress`, `delete`, `create_folder`, `chmod`, `pull` |
53
+ | `backups` | `list`, `create`, `get`, `download_url`, `toggle_lock`, `restore`, `delete` |
54
+ | `schedules` | `list`, `create`, `update`, `run`, `create_task`, `update_task` |
55
+ | `network` | `list`, `create`, `set_notes`, `set_primary`, `delete` |
56
+ | `subusers` | `list`, `create`, `get`, `update`, `delete` |
57
+ | `git` | `get`, `link`, `deploy`, `cancel_deployment`, `sync_config` |
58
+ | `account` | `get`, `activity` |
59
+ | `server_creation` | `options`, `create` |
60
+ | `databases` | `list`, `create`, `rotate_password`, `move`, `delete` |
61
+ | `billing` | `get`, `checkout`, `database_checkout`, `credit`, `cancel_subscription` |
62
+ | `monitoring` | `list_projects`, `create_key`, `list_issues`, `list_events`, `usage` |
63
+
64
+ Each method's comment names the API operation it calls (`operationId: sendPowerSignal`), so the
65
+ [API docs](https://sillydev.co.uk/docs/panel) describe its fields.
66
+
67
+ ### Lists
68
+
69
+ `list` (and `activity`) on paginated endpoints returns an `Enumerator` that fetches the next page
70
+ only when it is reached, so `first(5)` makes one request. The `*_page` twins return one
71
+ `SillyDev::Page`, which also has the list's `meta`:
72
+
73
+ ```ruby
74
+ page = client.panel.backups.list_page("1a7ce997")
75
+ puts "#{page.meta["backup_count"]} backups, page #{page.current_page} of #{page.total_pages}"
76
+ ```
77
+
78
+ ### Waiting for a server
79
+
80
+ ```ruby
81
+ client.panel.servers.start("1a7ce997")
82
+ client.panel.servers.wait_for_state("1a7ce997", "running", timeout: 120)
83
+ ```
84
+
85
+ ### Files
86
+
87
+ ```ruby
88
+ puts client.panel.files.read("1a7ce997", "/main.rb")
89
+ client.panel.files.write("1a7ce997", "/config.json", JSON.generate(debug: false))
90
+ client.panel.files.write("1a7ce997", "/logo.png", File.binread("logo.png")) # sent as binary
91
+ ```
92
+
93
+ ### Deploying from Git
94
+
95
+ ```ruby
96
+ deployment = client.panel.git.deploy("1a7ce997")
97
+ puts deployment["status"]
98
+ ```
99
+
100
+ ## Errors
101
+
102
+ Every error is a `SillyDev::Error`. When the API answers with an error, the class says what kind:
103
+
104
+ | Class | Status |
105
+ | --- | --- |
106
+ | `SillyDev::BadRequestError` | 400 |
107
+ | `SillyDev::AuthenticationError` | 401 |
108
+ | `SillyDev::PermissionDeniedError` | 403 |
109
+ | `SillyDev::NotFoundError` | 404 |
110
+ | `SillyDev::ConflictError` | 409 |
111
+ | `SillyDev::ValidationError` | 422, with `fields` grouping the messages by field |
112
+ | `SillyDev::RateLimitError` | 429, with `retry_after` |
113
+ | `SillyDev::ServerError` | 5xx |
114
+ | `SillyDev::APIConnectionError` | the panel could not be reached |
115
+
116
+ Each has `status_code`, `code` and the full `errors` list from the response.
117
+
118
+ ```ruby
119
+ begin
120
+ client.panel.servers.get("nope")
121
+ rescue SillyDev::NotFoundError => e
122
+ puts e.message # "404: The requested resource could not be found on the server."
123
+ end
124
+ ```
125
+
126
+ ## Retries and options
127
+
128
+ Requests answered 429 are retried (and reads answered 502, 503 or 504), waiting as long as
129
+ `Retry-After` asks, up to `max_retries` times. Checkouts always send an `Idempotency-Key`, so a
130
+ retried checkout never buys twice.
131
+
132
+ ```ruby
133
+ SillyDev::Client.new(
134
+ "sdevc_...",
135
+ base_url: "https://sillydev.co.uk/panel/api/client", # or SILLYDEV_BASE_URL
136
+ timeout: 60,
137
+ max_retries: 2,
138
+ default_headers: { "X-Request-Source" => "my-bot" },
139
+ )
140
+ ```
141
+
142
+ `transport:` takes any object with a `call(request)` that returns a `SillyDev::Response`, which is
143
+ useful for tests.
144
+
145
+ ## Live console
146
+
147
+ `client.panel.console` follows a server's console live and sends commands to it. It connects over
148
+ the server's websocket, and falls back by itself to a streamed or polled session on the node and
149
+ then to the panel's relay, so it also works where websockets are blocked.
150
+
151
+ ```ruby
152
+ client.panel.console.connect("1a7ce997") do |console|
153
+ console.on("console output") { |message| puts message.args.first }
154
+ console.command("say hi")
155
+ sleep 10
156
+ end # closed when the block ends
157
+ ```
158
+
159
+ Without a block, `connect` returns the console: read it with `console.each(timeout: 1) { |message| ... }`
160
+ (each message has `event` and `args`), and call `console.close` when done. `console.power("restart")`,
161
+ `console.request_stats` and `console.on_close { |error| ... }` are there too. It reconnects after a
162
+ dropped connection and renews its token before it expires.
163
+
164
+ For the recent output without staying connected:
165
+
166
+ ```ruby
167
+ puts client.panel.console.history("1a7ce997").last(20)
168
+ ```
169
+
170
+ `transport:` picks one way to connect (`"websocket"`, `"stream"`, `"poll"` or `"relay"`) instead of
171
+ trying them in turn. A console that cannot connect raises `SillyDev::ConsoleError`, whose `reason` is
172
+ `"suspended"`, `"booting"`, `"token"`, `"network"` and so on.
173
+
174
+ ## Command line
175
+
176
+ The gem installs a `sillydev` command. It reads the key from `SILLYDEV_API_KEY` (or `--api-key`), and
177
+ `--json` gives machine-readable output.
178
+
179
+ ```sh
180
+ sillydev servers list
181
+ sillydev status 1a7ce997
182
+ sillydev power 1a7ce997 restart
183
+ sillydev command 1a7ce997 "say hi"
184
+ sillydev logs 1a7ce997
185
+ sillydev console 1a7ce997 # follow live; lines you type are sent as commands
186
+ sillydev deploy 1a7ce997
187
+ sillydev files ls 1a7ce997 /src
188
+ sillydev files cat 1a7ce997 /config.json
189
+ sillydev backups create 1a7ce997 --name before-update
190
+ ```
191
+
192
+ `sillydev --help` lists every command.
193
+
194
+ ## MCP server
195
+
196
+ `sillydev mcp` runs an [MCP](https://modelcontextprotocol.io) server on stdio, so an AI assistant can
197
+ look after your servers. Its tools list servers, read a server's status, send power actions and
198
+ console commands, read the recent console output, list and read files, deploy from Git and list
199
+ backups. Nothing in it spends money, deletes anything or changes who can reach a server. Give it a key
200
+ limited to the servers you want the assistant to reach.
201
+
202
+ ```json
203
+ {
204
+ "mcpServers": {
205
+ "sillydev": {
206
+ "command": "sillydev",
207
+ "args": ["mcp"],
208
+ "env": { "SILLYDEV_API_KEY": "sdevc_..." }
209
+ }
210
+ }
211
+ }
212
+ ```
213
+
214
+ ## Monitoring (opt-in)
215
+
216
+ `SillyDev::Monitoring` sends metrics, errors, events and slow operations to Silly Development
217
+ application monitoring. Nothing is loaded by `require "sillydev"`, and nothing is collected, sent or
218
+ started until you call `init` with a monitoring key. Create one in the panel under **Monitoring**
219
+ and put it in `SILLYDEV_MONITORING_KEY`.
220
+
221
+ ```ruby
222
+ require "sillydev/monitoring"
223
+
224
+ SillyDev::Monitoring.init(service: "web") # reads SILLYDEV_MONITORING_KEY; does nothing without one
225
+
226
+ SillyDev::Monitoring.increment("orders.created", tags: { plan: "free" })
227
+ SillyDev::Monitoring.gauge("queue.depth", queue.size)
228
+ SillyDev::Monitoring.time("report.build") { build_report }
229
+ SillyDev::Monitoring.event("user.signed_up", { "plan" => "free" })
230
+
231
+ begin
232
+ risky
233
+ rescue StandardError => e
234
+ SillyDev::Monitoring.capture_exception(e, tags: { job: "sync" })
235
+ end
236
+ ```
237
+
238
+ Data is sent in the background every 10 seconds and at exit, along with CPU, memory, GC and thread
239
+ counts (`runtime_metrics: false` turns those off). Exceptions that end the program are reported.
240
+ Values of keys that look secret (`password`, `token`, `api_key`, ...) are replaced with `[redacted]`.
241
+ `sample_rate:` and `before_send: ->(type, item) { item }` (return `nil` to drop) let you send less.
242
+ `SILLYDEV_MONITORING_DISABLED=1` turns it off. Forked workers (Puma, Unicorn) start their own sender.
243
+
244
+ ### Rack, Rails and Sinatra
245
+
246
+ ```ruby
247
+ # config.ru
248
+ require "sillydev/monitoring/rack"
249
+ SillyDev::Monitoring.init
250
+ use SillyDev::Monitoring::Rack
251
+ ```
252
+
253
+ Rails apps can instead load the railtie from the Gemfile,
254
+ `gem "sillydev", require: ["sillydev", "sillydev/monitoring/rails"]`, and call
255
+ `SillyDev::Monitoring.init` in an initializer: the middleware is added for you, and errors reported
256
+ through `Rails.error` are sent too. Each request records `http.server.duration` tagged with the route
257
+ (the router's template on Rails and Sinatra, otherwise the path with ids as `:id`), method and status
258
+ class, reports unhandled exceptions, and records requests slower than `slow_threshold_ms` (1000).
259
+
260
+ ### discordrb
261
+
262
+ ```ruby
263
+ require "discordrb"
264
+ require "sillydev/monitoring/discordrb"
265
+
266
+ SillyDev::Monitoring.init(service: "bot")
267
+ bot = Discordrb::Commands::CommandBot.new(token: ENV["TOKEN"], prefix: "!")
268
+ SillyDev::Monitoring::Discordrb.monitor(bot)
269
+ bot.run
270
+ ```
271
+
272
+ This times slash commands and prefix commands (`discord.command.duration`), reports errors raised in
273
+ handlers (they still reach discordrb's own logging) and sends the `discord.guilds` gauge.
274
+
275
+ ## License
276
+
277
+ MIT
data/exe/sillydev ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "sillydev/cli"
5
+
6
+ SillyDev::CLI.start(ARGV)
@@ -0,0 +1,295 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "optparse"
5
+ require_relative "../sillydev"
6
+
7
+ module SillyDev
8
+ # The sillydev command line.
9
+ #
10
+ # sillydev servers list
11
+ # sillydev power 1a7ce997 restart
12
+ # sillydev command 1a7ce997 "say hi"
13
+ # sillydev logs 1a7ce997
14
+ # sillydev console 1a7ce997
15
+ # sillydev deploy 1a7ce997
16
+ # sillydev files ls 1a7ce997 /src
17
+ # sillydev backups create 1a7ce997
18
+ # sillydev mcp
19
+ #
20
+ # The API key comes from --api-key or the SILLYDEV_API_KEY environment variable. Add --json to
21
+ # any command for machine-readable output.
22
+ class CLI
23
+ POWER_SIGNALS = %w[start stop restart kill].freeze
24
+ TRANSPORTS = (["auto"] + %w[websocket stream poll relay]).freeze
25
+ PRINTED = ["console output", "install output", "daemon message"].freeze
26
+
27
+ USAGE = <<~TEXT
28
+ Usage: sillydev [options] <command> [arguments]
29
+
30
+ Control your Silly Development servers from the command line.
31
+
32
+ Commands:
33
+ servers list [--search TEXT] [--owner] list the servers the key can reach
34
+ servers get <server> show one server
35
+ status <server> show a server's state and resource use
36
+ power <server> start|stop|restart|kill start, stop, restart or kill a server
37
+ command <server> <command...> send a console command
38
+ logs <server> [--transport WAY] print the server's recent console output
39
+ console <server> [--transport WAY] follow the console live; lines typed are sent as
40
+ commands (Ctrl+C to stop)
41
+ deploy <server> deploy the linked Git repository now
42
+ files ls <server> [directory] list a directory
43
+ files cat <server> <file> print a file
44
+ backups list <server> list a server's backups
45
+ backups create <server> [--name NAME] [--locked] [--ignore PATH ...]
46
+ start a backup
47
+ mcp run the MCP server on stdio
48
+
49
+ WAY is auto (the default: tries websocket, stream, poll and then relay), websocket, stream,
50
+ poll or relay. <server> is the 8-character identifier or the UUID.
51
+
52
+ Options:
53
+ TEXT
54
+
55
+ def self.start(argv = ARGV)
56
+ exit(new.run(argv))
57
+ end
58
+
59
+ def initialize(stdout: $stdout, stderr: $stderr, stdin: $stdin, client_factory: nil, stop: nil)
60
+ @out = stdout
61
+ @err = stderr
62
+ @in = stdin
63
+ @client_factory = client_factory || ->(**kwargs) { Client.new(kwargs[:api_key], base_url: kwargs[:base_url]) }
64
+ @stop = stop
65
+ end
66
+
67
+ # Runs one command and returns the exit status.
68
+ def run(argv)
69
+ options = { json: false, transport: "auto", ignore: [] }
70
+ parser = build_parser(options)
71
+ args = catch(:done) { parser.parse(argv) }
72
+ return 0 if args.nil? # --help or --version
73
+
74
+ command = args.shift
75
+ if command.nil?
76
+ @err.puts(parser.help)
77
+ return 2
78
+ end
79
+ return mcp(options) if command == "mcp"
80
+
81
+ client = @client_factory.call(api_key: options[:api_key], base_url: options[:base_url])
82
+ if command == "console"
83
+ follow(client, need(args, 0, "server"), options)
84
+ return 0
85
+ end
86
+ data, show = dispatch(client, command, args, options)
87
+ if options[:json]
88
+ @out.puts(JSON.pretty_generate(data))
89
+ else
90
+ show.call
91
+ end
92
+ 0
93
+ rescue OptionParser::ParseError, UsageError => e
94
+ @err.puts("sillydev: #{e.message}")
95
+ @err.puts("Run sillydev --help for the commands.")
96
+ 2
97
+ rescue Error => e
98
+ if options && options[:json]
99
+ @err.puts(JSON.generate({ error: e.message, status: e.status_code, errors: e.errors }))
100
+ else
101
+ @err.puts("sillydev: #{e.message}")
102
+ end
103
+ 1
104
+ end
105
+
106
+ private
107
+
108
+ class UsageError < StandardError; end
109
+
110
+ def build_parser(options)
111
+ OptionParser.new do |o|
112
+ o.banner = USAGE
113
+ o.on("--api-key KEY", "API key (default: $SILLYDEV_API_KEY)") { |v| options[:api_key] = v }
114
+ o.on("--base-url URL", "API base URL (default: $SILLYDEV_BASE_URL or the public API)") { |v| options[:base_url] = v }
115
+ o.on("--json", "print JSON") { options[:json] = true }
116
+ o.on("--search TEXT", "servers list: match the name, identifier, UUID or an ip:port") { |v| options[:search] = v }
117
+ o.on("--owner", "servers list: only servers you own") { options[:owner] = true }
118
+ o.on("--transport WAY", TRANSPORTS, "logs, console: how to connect") { |v| options[:transport] = v }
119
+ o.on("--name NAME", "backups create: the backup's name") { |v| options[:name] = v }
120
+ o.on("--locked", "backups create: lock it so it cannot be deleted") { options[:locked] = true }
121
+ o.on("--ignore PATH", "backups create: leave out a path (repeatable)") { |v| options[:ignore] << v }
122
+ o.on("-v", "--version", "print the version") do
123
+ @out.puts("sillydev #{VERSION}")
124
+ throw :done
125
+ end
126
+ o.on("-h", "--help", "print this help") do
127
+ @out.puts(o.help)
128
+ @out.puts("\nDocs: https://sillydev.co.uk/docs/panel")
129
+ throw :done
130
+ end
131
+ end
132
+ end
133
+
134
+ def need(args, index, name)
135
+ args[index] or raise UsageError, "missing <#{name}>"
136
+ end
137
+
138
+ def dispatch(client, command, args, options)
139
+ panel = client.panel
140
+ case [command, args.first]
141
+ in ["servers", "list"]
142
+ type = options[:owner] ? "owner" : nil
143
+ data = panel.servers.list(search: options[:search], type: type).to_a
144
+ [data, -> { table([%w[ID NAME NODE]] + data.map { |s| [s["identifier"], s["name"], s["node"]].map(&:to_s) }) }]
145
+ in ["servers", "get"]
146
+ data = panel.servers.get(need(args, 1, "server"))
147
+ [data, -> { @out.puts(JSON.pretty_generate(data)) }]
148
+ in ["status", _]
149
+ data = panel.servers.resources(need(args, 0, "server"))
150
+ [data, -> { show_status(data) }]
151
+ in ["power", _]
152
+ server = need(args, 0, "server")
153
+ signal = need(args, 1, "signal")
154
+ raise UsageError, "the signal must be start, stop, restart or kill" unless POWER_SIGNALS.include?(signal)
155
+
156
+ panel.servers.power(server, signal)
157
+ [{ ok: true, signal: signal }, -> { @out.puts("Sent #{signal} to #{server}") }]
158
+ in ["command", _]
159
+ server = need(args, 0, "server")
160
+ text = args[1..].join(" ")
161
+ raise UsageError, "missing <command>" if text.empty?
162
+
163
+ panel.servers.command(server, text)
164
+ [{ ok: true, command: text }, -> { @out.puts("Sent") }]
165
+ in ["logs", _]
166
+ lines = panel.console.history(need(args, 0, "server"), transport: options[:transport])
167
+ [lines, -> { lines.each { |line| @out.puts(line) } }]
168
+ in ["deploy", _]
169
+ data = panel.git.deploy(need(args, 0, "server"))
170
+ [data, -> { @out.puts("Deployment #{data['uuid']} #{data['status']} (#{data['branch']})") }]
171
+ in ["files", "ls"]
172
+ data = panel.files.list(need(args, 1, "server"), args[2] || "/")
173
+ [data, -> { show_files(data) }]
174
+ in ["files", "cat"]
175
+ file = need(args, 2, "file")
176
+ data = panel.files.read(need(args, 1, "server"), file)
177
+ [{ file: file, contents: data }, -> { @out.write(data) }]
178
+ in ["backups", "list"]
179
+ data = panel.backups.list(need(args, 1, "server")).to_a
180
+ [data, -> { show_backups(data) }]
181
+ in ["backups", "create"]
182
+ data = panel.backups.create(need(args, 1, "server"), name: options[:name], is_locked: options[:locked],
183
+ ignored: options[:ignore].empty? ? nil : options[:ignore])
184
+ [data, -> { @out.puts("Backup #{data['uuid']} started") }]
185
+ else
186
+ raise UsageError, "unknown command: #{[command, args.first].compact.join(' ')}"
187
+ end
188
+ end
189
+
190
+ def table(rows)
191
+ return if rows.empty?
192
+
193
+ widths = rows.first.each_index.map { |i| rows.map { |row| row[i].to_s.length }.max }
194
+ rows.each { |row| @out.puts(row.each_with_index.map { |cell, i| cell.to_s.ljust(widths[i]) }.join(" ").rstrip) }
195
+ end
196
+
197
+ def size(bytes)
198
+ value = Float(bytes)
199
+ %w[B KiB MiB GiB].each do |unit|
200
+ return unit == "B" ? "#{value.round} B" : format("%<value>.1f %<unit>s", value: value, unit: unit) if value < 1024 || unit == "GiB"
201
+
202
+ value /= 1024
203
+ end
204
+ rescue ArgumentError, TypeError
205
+ "-"
206
+ end
207
+
208
+ def show_status(data)
209
+ res = data["resources"] || {}
210
+ @out.puts("state: #{data['current_state']}")
211
+ @out.puts("cpu: #{res['cpu_absolute'] || 0}")
212
+ @out.puts("memory: #{size(res['memory_bytes'])}")
213
+ @out.puts("disk: #{size(res['disk_bytes'])}")
214
+ @out.puts("uptime: #{(res['uptime'] || 0).to_i / 1000}s")
215
+ end
216
+
217
+ def show_files(data)
218
+ table(data.map do |f|
219
+ file = f["is_file"]
220
+ [file ? "-" : "d", f["mode"].to_s, file ? size(f["size"]) : "-", f["modified_at"].to_s,
221
+ "#{f['name']}#{'/' unless file}"]
222
+ end)
223
+ end
224
+
225
+ def show_backups(data)
226
+ rows = [%w[UUID NAME SIZE STATUS CREATED]]
227
+ data.each do |b|
228
+ state = if b["completed_at"].nil? then "running"
229
+ elsif b["is_successful"] then "ok"
230
+ else "failed"
231
+ end
232
+ state += ",locked" if b["is_locked"]
233
+ rows << [b["uuid"].to_s, b["name"].to_s, size(b["bytes"]), state, b["created_at"].to_s]
234
+ end
235
+ table(rows)
236
+ end
237
+
238
+ # Follows the console until Ctrl+C (or the stop latch) or the console ends; lines from stdin
239
+ # become commands.
240
+ def follow(client, server, options)
241
+ console = client.panel.console.connect(server, transport: options[:transport])
242
+ @err.puts("Connected to #{server} (#{console.transport}). Press Ctrl+C to stop.")
243
+ @err.flush
244
+ input = Thread.new do
245
+ @in&.each_line do |line|
246
+ break if console.closed? || stopped?
247
+ next if line.strip.empty?
248
+
249
+ begin
250
+ console.command(line.chomp)
251
+ rescue Error => e
252
+ @err.puts("sillydev: #{e.message}")
253
+ end
254
+ end
255
+ end
256
+ begin
257
+ # Show the recent output first, as the panel does.
258
+ console.request_logs
259
+ until stopped? || console.closed?
260
+ console.each(timeout: 0.25) do |message|
261
+ print_message(message, options[:json])
262
+ break if stopped?
263
+ end
264
+ end
265
+ rescue Interrupt
266
+ nil
267
+ ensure
268
+ reason = console.close_reason
269
+ console.close
270
+ input.kill
271
+ end
272
+ raise reason if reason
273
+ end
274
+
275
+ def stopped?
276
+ @stop.respond_to?(:call) ? @stop.call : false
277
+ end
278
+
279
+ def print_message(message, json)
280
+ if json
281
+ @out.puts(JSON.generate({ event: message.event, args: message.args }))
282
+ elsif PRINTED.include?(message.event)
283
+ @out.puts(message.args.first.to_s)
284
+ elsif message.event == "status"
285
+ @err.puts("[#{message.args.first}]")
286
+ end
287
+ @out.flush
288
+ end
289
+
290
+ def mcp(options)
291
+ require_relative "mcp_server"
292
+ MCPServer.main(api_key: options[:api_key], base_url: options[:base_url], stdin: @in, stdout: @out, stderr: @err)
293
+ end
294
+ end
295
+ end