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 +7 -0
- data/LICENSE +21 -0
- data/README.md +277 -0
- data/exe/sillydev +6 -0
- data/lib/sillydev/cli.rb +295 -0
- data/lib/sillydev/client.rb +199 -0
- data/lib/sillydev/console.rb +1131 -0
- data/lib/sillydev/envelope.rb +84 -0
- data/lib/sillydev/errors.rb +160 -0
- data/lib/sillydev/mcp_server.rb +193 -0
- data/lib/sillydev/monitoring/aggregator.rb +107 -0
- data/lib/sillydev/monitoring/client.rb +674 -0
- data/lib/sillydev/monitoring/discordrb.rb +131 -0
- data/lib/sillydev/monitoring/http.rb +31 -0
- data/lib/sillydev/monitoring/rack.rb +114 -0
- data/lib/sillydev/monitoring/rails.rb +41 -0
- data/lib/sillydev/monitoring/runtime.rb +96 -0
- data/lib/sillydev/monitoring/serialize.rb +168 -0
- data/lib/sillydev/monitoring.rb +227 -0
- data/lib/sillydev/resources.rb +844 -0
- data/lib/sillydev/transport.rb +71 -0
- data/lib/sillydev/version.rb +5 -0
- data/lib/sillydev/websocket.rb +248 -0
- data/lib/sillydev.rb +20 -0
- metadata +67 -0
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
data/lib/sillydev/cli.rb
ADDED
|
@@ -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
|