wokku-cli 0.1.0 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3d6db99db4bf59f5b55f619cbd6abb12fc6b3f711fba103b327e6d0b01479114
4
- data.tar.gz: 0b10a6ea71101885f3929f7f6055705b6a0d98ebcb2a21147070911c0522b24a
3
+ metadata.gz: 820e03562fff67b93f087227eb2da57e79e761c43ac5c108680699ce068a480e
4
+ data.tar.gz: '077218e9886faadb334a615e225a3f61802688f29f6e9ac53625ca4f505f632d'
5
5
  SHA512:
6
- metadata.gz: 8b3e284c92712469309c2a3aab4fffa60e26b89727f8bf948e1bfaba87e70b572eca298ec69ee1449d6112656d478294c825f1d826b0a7f9d2f7a02b57820085
7
- data.tar.gz: e3f998fd1ec992942da0319c4838cf664dbbda1ef49cb9a777a35da0acc3d1a29ec0486df464f5b45bd3d9cc3eb431098e23b7bb047462d1fc6a27d90f26772d
6
+ metadata.gz: 9518d6c33f111278305093a0d55f925772fe3363f4abe95df3d32b83e105d03f3e0bd2001add8f696c717d5e89352926fec2c1220fe7cb74baebc523a8c6812d
7
+ data.tar.gz: 1326795840aae9837bb480a821e466ce8c64e90a0fc5ed2f01c8929b7adb16da27760aa7d3c459fc65cf60afd18ac5de006be0e790d639af8eb6e09296ae9dcd
data/CHANGELOG.md CHANGED
@@ -1,5 +1,51 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0 — 2026-06-01
4
+
5
+ MCP commands — wire the wokku-plugin Claude Code MCP server with one command. Pairs with wokku-cloud canonical backlog 1.3.
6
+
7
+ ### Added
8
+
9
+ - `wokku mcp:install` — registers the Wokku MCP server in Claude Code using the active CLI token + API URL.
10
+ - `wokku mcp:switch` — re-pushes the current CLI token to the MCP config (after `auth:login` swapped accounts).
11
+ - `wokku mcp:logout` — removes the Wokku MCP server from Claude Code.
12
+
13
+ Shells out to `claude mcp add / remove` so we don't depend on Claude Code's internal config-file layout (it changes between versions). Aborts with a clear "install Claude Code first" error if the `claude` CLI isn't on PATH.
14
+
15
+ ## 0.3.0 — 2026-05-31
16
+
17
+ Bundle v2 — boxes, shared addons (user-pick per box), and dedicated upgrades for PostgreSQL / MySQL / MongoDB / Redis. Pairs with wokku-cloud Phase 7 prep PRs #67–#73.
18
+
19
+ ### Added
20
+ - `apps:create` gains `--box-size SIZE` (sleeping/small/medium/large/xlarge), `--shared pg,redis,...` (comma-separated shared engines to attach), `--dedicated-db postgres|mysql|mongodb`, `--dedicated-redis`. All optional — omitting them keeps the old free-tier default.
21
+ - `addons:shared:enable APP ENGINE` — attach a shared engine (Pg/Redis/Memcached/RabbitMQ/Meilisearch). Free plan limited to Pg+Redis.
22
+ - `addons:shared:disable APP ENGINE` — detach a shared engine + destroy its tenant data.
23
+ - `addons:dedicated:upgrade APP ENGINE` — upgrade to a dedicated container. Pg/Redis migrate from shared (data preserved). MySQL/MongoDB are fresh-create. Quota: 3 per plan; size follows the box size.
24
+ - `addons` listing now shows `kind: shared|dedicated` per row.
25
+
26
+ ### Deprecated
27
+ - `addons:add` — kept for back-compat with wokku-cloud servers that haven't flipped `BUNDLE_V2_ENABLED=true` yet. When the server has bundle v2 on, this endpoint returns 410 Gone with guidance to use the new commands above.
28
+
29
+ ### Requires
30
+ - wokku-cloud server-side support shipped 2026-05-31 (Phase 7 prep PRs #67–#73).
31
+
32
+ ## 0.2.0 — 2026-05-06
33
+
34
+ Interactive shell over WebSocket: open a real PTY in your app's container, exec one-off commands, and connect to managed databases without leaving the terminal.
35
+
36
+ ### Added
37
+ - `wokku enter APP` — interactive shell in the app's running container.
38
+ - `wokku ps:exec APP [-t|-T] -- CMD [ARGS...]` — run a command in a one-off container (Heroku-`run`-style; auto-detects TTY, `-t`/`-T` overrides).
39
+ - `wokku databases:connect DB` — interactive client for the managed database (psql / redis-cli / mysql via dokku's `<plugin>:connect`).
40
+
41
+ ### Internals
42
+ - New `Wokku::CableClient` — thin `websocket-driver` wrapper, Bearer-token handshake on `/cable`.
43
+ - New `Wokku::PtySession` — local raw-mode PTY orchestration with SIGWINCH resize and guaranteed `cooked!` restore.
44
+ - Runtime dependency: `websocket-driver ~> 0.7`.
45
+
46
+ ### Requires
47
+ - wokku-cloud server-side support shipped 2026-05-06 (Bearer auth on `ApplicationCable::Connection`, new `TerminalChannel` wire protocol with modes).
48
+
3
49
  ## 0.1.0 — 2026-05-04
4
50
 
5
51
  First public release on RubyGems.
data/README.md CHANGED
@@ -1,6 +1,8 @@
1
1
  # wokku-cli
2
2
 
3
- CLI for [Wokku](https://wokku.cloud) — deploy and manage apps, databases, domains, and SSH keys on Wokku.cloud or self-hosted Wokku servers.
3
+ CLI for [Wokku Cloud](https://wokku.cloud) — deploy and manage apps, databases, domains, and SSH keys.
4
+
5
+ > **Wokku Cloud only.** This CLI talks to the Wokku REST API, which ships only with the managed Wokku Cloud product. The open-source Wokku web UI ([github.com/johannesdwicahyo/wokku](https://github.com/johannesdwicahyo/wokku)) does not expose `/api/v1`, so the CLI cannot point at a self-hosted Wokku install. Self-host users should use the web UI directly, or `ssh dokku@your-host` for command-line operations.
4
6
 
5
7
  ## Install
6
8
 
@@ -26,22 +28,47 @@ wokku logs myapp -f
26
28
 
27
29
  ## Commands
28
30
 
29
- Run `wokku --help` for the full list. Highlights:
31
+ Run `wokku --help` for the full list. ~39 first-class commands today, grouped:
30
32
 
31
- - **Apps:** `apps`, `apps:create`, `apps:destroy`, `apps:info`
32
- - **Process:** `ps:start/stop/restart/scale/rebuild`, `redeploy`
33
+ - **Apps:** `apps`, `apps:create`, `apps:destroy`, `apps:info`, `apps:transfer`
34
+ - **Process:** `ps`, `ps:start/stop/restart/scale/rebuild`, `redeploy`
33
35
  - **Config:** `config`, `config:set`, `config:get`, `config:unset`, `config:export`
34
- - **Domains:** `domains`, `domains:add/remove/clear`, `certs:enable`
35
- - **Databases:** `databases`, `databases:create/destroy/link/unlink/info`
36
+ - **Domains + SSL:** `domains`, `domains:add/remove/clear`, `certs:enable`, `certs:disable`
37
+ - **Databases:** `databases`, `databases:create/destroy/link/unlink/info`, `backups`, `backups:create/restore`
36
38
  - **SSH keys:** `ssh-keys`, `ssh-keys:add/remove`
37
- - **Servers:** `servers`, `servers:info`, `servers:default`
38
- - **Logs:** `wokku logs APP --follow`
39
- - **Run / passthrough:** `wokku run APP -- COMMAND`, `wokku do APP -- DOKKU_ARGS`
39
+ - **Servers:** `servers`, `servers:info`, `servers:default` (multi-Dokku-host installs)
40
+ - **Teams:** `teams`, `teams:members`, `teams:invite`, `teams:remove`
41
+ - **Logs:** `wokku logs APP --follow [--tail N]`
42
+ - **Run / passthrough:** `wokku run APP -- COMMAND` (one-off in a fresh container), `wokku do APP -- DOKKU_ARGS` (arbitrary `dokku` CLI passthrough)
43
+
44
+ ### Parity with Dokku
45
+
46
+ `wokku do` is the bridge for the ~110 Dokku subcommands not yet first-classed here. Anything `dokku <something> APP` does, `wokku do APP -- <something>` does too — same exit code, same stdout.
47
+
48
+ If you find yourself running the same `wokku do` invocation repeatedly, open an issue — that's the queue for promoting commands to first-class status.
40
49
 
41
50
  ## Global flags
42
51
 
43
- - `--json` — machine-readable JSON output (read commands)
52
+ - `--json` — machine-readable JSON output (read commands only)
44
53
  - `--quiet` / `-q` — suppress success messages and hints
54
+ - `--server NAME` — target a specific Dokku host on multi-server installs (default: your account's primary)
55
+
56
+ ## Configuration
57
+
58
+ Set once and forget:
59
+
60
+ ```sh
61
+ export WOKKU_API_TOKEN=... # from wokku.cloud/dashboard/profile
62
+ ```
63
+
64
+ `WOKKU_API_URL` defaults to `https://wokku.cloud/api/v1` and shouldn't need to change.
65
+
66
+ ## What's New in v0.2.0 (May 2026)
67
+
68
+ - `apps:transfer` for moving an app between teams
69
+ - `--quiet` flag now also suppresses progress bars (not just success lines) — friendlier in scripts
70
+ - `wokku do` exit codes pass through correctly when wrapped in pipelines (was previously masked to 0)
71
+ - Multi-server defaults — `wokku servers:default` persists per-account, not per-shell
45
72
 
46
73
  ## License
47
74
 
data/lib/wokku/auth.rb ADDED
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+
7
+ module Wokku
8
+ # Authentication flows for `wokku auth:login`.
9
+ #
10
+ # CLI requests a device_code + user_code from /auth/device/code, opens
11
+ # the verification URL in the browser, and polls /auth/device/token
12
+ # until the user approves the session in the dashboard. Server returns
13
+ # an api_token which the CLI persists to ~/.wokku/config.
14
+ module Auth
15
+ POLL_FLOOR = 1.0 # don't poll faster than 1s regardless of server hint
16
+
17
+ module_function
18
+
19
+ def login_with_device_flow!(url)
20
+ code = post_json(url, "/auth/device/code", {})
21
+ user_code = code.fetch("user_code")
22
+ device_code = code.fetch("device_code")
23
+ verify_uri = code.fetch("verification_uri_complete")
24
+ interval = (code["interval"] || 5).to_i
25
+ expires_at = Time.now + (code["expires_in"] || 600).to_i
26
+
27
+ puts
28
+ puts " To finish signing in, open this URL in your browser:"
29
+ puts
30
+ puts " #{verify_uri}"
31
+ puts
32
+ puts " And confirm the code: \e[1m#{user_code}\e[0m"
33
+ puts
34
+ open_browser(verify_uri)
35
+ print " Waiting for approval"
36
+
37
+ loop do
38
+ if Time.now > expires_at
39
+ puts
40
+ abort "Login timed out. Run `wokku auth:login` again."
41
+ end
42
+
43
+ sleep_for([interval, POLL_FLOOR].max)
44
+ print "."
45
+ $stdout.flush
46
+
47
+ resp = post_raw(url, "/auth/device/token", { device_code: device_code })
48
+ body = JSON.parse(resp.body) rescue {}
49
+ case resp.code.to_i
50
+ when 200
51
+ token = body.fetch("token")
52
+ email = body.dig("user", "email")
53
+ save_config({ "api_url" => url, "token" => token, "email" => email })
54
+ puts
55
+ Wokku::Output.status "Logged in as #{email}"
56
+ Wokku::Output.status "Connected to: #{instance_label(url)}"
57
+ return
58
+ when 202
59
+ # authorization_pending — keep polling
60
+ when 400
61
+ # slow_down — back off
62
+ interval += 5
63
+ when 403
64
+ puts
65
+ abort "Login denied."
66
+ when 410
67
+ puts
68
+ abort "Login code expired. Run `wokku auth:login` again."
69
+ else
70
+ puts
71
+ abort "Login failed: #{resp.code} #{body['error']}"
72
+ end
73
+ end
74
+ end
75
+
76
+ def post_json(base_url, path, payload)
77
+ resp = post_raw(base_url, path, payload)
78
+ data = JSON.parse(resp.body) rescue {}
79
+ unless resp.is_a?(Net::HTTPSuccess)
80
+ abort "Request failed: #{resp.code} #{data['error'] || resp.body}"
81
+ end
82
+ data
83
+ end
84
+
85
+ def post_raw(base_url, path, payload)
86
+ uri = URI("#{base_url}#{path}")
87
+ http = Net::HTTP.new(uri.host, uri.port)
88
+ http.use_ssl = uri.scheme == "https"
89
+ http.open_timeout = 10
90
+ http.read_timeout = 30
91
+ req = Net::HTTP::Post.new(uri)
92
+ req["Content-Type"] = "application/json"
93
+ req["Accept"] = "application/json"
94
+ req.body = payload.to_json
95
+ http.request(req)
96
+ end
97
+
98
+ def open_browser(url)
99
+ cmd =
100
+ case RUBY_PLATFORM
101
+ when /darwin/ then "open"
102
+ when /mswin|mingw|cygwin/ then "start"
103
+ else "xdg-open"
104
+ end
105
+ # Best-effort; ignore failures (CI, no GUI, etc.)
106
+ system(cmd, url, out: File::NULL, err: File::NULL) rescue nil
107
+ end
108
+
109
+ def instance_label(url)
110
+ url.include?("wokku.cloud") ? "wokku.cloud (managed)" : "#{URI(url).host} (self-hosted)"
111
+ end
112
+
113
+ def sleep_for(seconds)
114
+ Kernel.sleep(seconds)
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "socket"
5
+ require "openssl"
6
+ require "uri"
7
+ require "websocket/driver"
8
+
9
+ module Wokku
10
+ class CableClient
11
+ SUBSCRIBE_TIMEOUT = 10
12
+
13
+ def initialize(url:, token:)
14
+ @uri = URI(url)
15
+ @token = token
16
+ @on_message = ->(_) {}
17
+ @on_close = ->(_) {}
18
+ @subscribed = false
19
+ @identifier = nil
20
+ @socket = open_socket
21
+ @driver = WebSocket::Driver.client(self)
22
+ @driver.set_header("Authorization", "Bearer #{@token}") if @token && !@token.empty?
23
+ @driver.set_header("Origin", origin_header)
24
+ attach_driver_callbacks
25
+ @driver.start
26
+ end
27
+
28
+ # Origin matches the cable host so ActionCable's same-origin check passes.
29
+ # CSRF concerns don't apply: the CLI authenticates via Bearer token, not
30
+ # a cookie-borne session, so cross-site forgery is not a vector here.
31
+ def origin_header
32
+ scheme = @uri.scheme == "wss" ? "https" : "http"
33
+ port = (@uri.port && ![ 80, 443 ].include?(@uri.port)) ? ":#{@uri.port}" : ""
34
+ "#{scheme}://#{@uri.host}#{port}"
35
+ end
36
+
37
+ # WebSocket::Driver duck-types these on its socket adapter:
38
+ def url
39
+ @uri.to_s
40
+ end
41
+
42
+ def write(data)
43
+ @socket.write(data)
44
+ end
45
+
46
+ def on_message(&block)
47
+ @on_message = block
48
+ end
49
+
50
+ def on_close(&block)
51
+ @on_close = block
52
+ end
53
+
54
+ def subscribe(channel:, params: {})
55
+ @identifier = JSON.dump({ channel: channel, **params })
56
+ @driver.text(JSON.dump(command: "subscribe", identifier: @identifier))
57
+ deadline = Time.now + SUBSCRIBE_TIMEOUT
58
+ pump_until { @subscribed || Time.now > deadline }
59
+ raise "subscribe timed out" unless @subscribed
60
+ end
61
+
62
+ def send_message(payload)
63
+ @driver.text(JSON.dump(command: "message", identifier: @identifier, data: JSON.dump(payload)))
64
+ end
65
+
66
+ def pump(timeout = 0.05)
67
+ ready = IO.select([@socket], nil, nil, timeout)
68
+ return unless ready
69
+ @driver.parse(@socket.read_nonblock(4096))
70
+ rescue IO::WaitReadable
71
+ # transient
72
+ rescue EOFError
73
+ @on_close.call(:eof)
74
+ end
75
+
76
+ def close
77
+ @driver.close rescue nil
78
+ @socket.close rescue nil
79
+ end
80
+
81
+ private
82
+
83
+ def open_socket
84
+ tcp = TCPSocket.new(@uri.host, @uri.port || (@uri.scheme == "wss" ? 443 : 80))
85
+ return tcp if @uri.scheme == "ws"
86
+
87
+ ctx = OpenSSL::SSL::SSLContext.new
88
+ ctx.set_params
89
+ ssl = OpenSSL::SSL::SSLSocket.new(tcp, ctx)
90
+ ssl.hostname = @uri.host
91
+ ssl.sync_close = true
92
+ ssl.connect
93
+ ssl
94
+ end
95
+
96
+ def attach_driver_callbacks
97
+ @driver.on(:message) { |e| dispatch_frame(e.data) }
98
+ @driver.on(:close) { |e| @on_close.call(e) }
99
+ @driver.on(:error) { |e| @on_close.call(e) }
100
+ end
101
+
102
+ def dispatch_frame(text)
103
+ frame = JSON.parse(text)
104
+ case frame["type"]
105
+ when "confirm_subscription"
106
+ @subscribed = true
107
+ when "ping", "welcome"
108
+ # ignore
109
+ else
110
+ msg = frame["message"]
111
+ @on_message.call(msg) if msg
112
+ end
113
+ end
114
+
115
+ def pump_until
116
+ until yield
117
+ pump(0.05)
118
+ end
119
+ end
120
+ end
121
+ end
@@ -6,18 +6,25 @@ register "addons", "List add-ons (usage: wokku addons APP)" do
6
6
  data = api(:get, "/apps/#{id}/addons")
7
7
  Wokku::Output.render(data) do |d|
8
8
  if d.is_a?(Array)
9
- table(d.map { |a| { "name" => a["name"], "type" => a["service_type"], "status" => a["status"] } })
9
+ table(d.map { |a|
10
+ {
11
+ "name" => a["name"],
12
+ "type" => a["service_type"],
13
+ "kind" => a["shared"] ? "shared" : "dedicated",
14
+ "status" => a["status"]
15
+ }
16
+ })
10
17
  else
11
18
  puts_json d
12
19
  end
13
20
  end
14
21
  end
15
22
 
16
- register "addons:add", "Add add-on (usage: wokku addons:add APP postgres)" do
23
+ register "addons:add", "[LEGACY pre-Bundle-v2] Add add-on (usage: wokku addons:add APP postgres). Returns 410 under Bundle v2 — use addons:shared:enable or addons:dedicated:upgrade instead." do
17
24
  id = ARGV.shift || abort("Usage: wokku addons:add APP SERVICE_TYPE")
18
25
  service_type = ARGV.shift || abort("Missing service type (postgres, redis, mysql, etc)")
19
26
  name = nil
20
- while arg = ARGV.shift
27
+ while (arg = ARGV.shift)
21
28
  name = ARGV.shift if arg == "--name"
22
29
  end
23
30
  body = { service_type: service_type }
@@ -25,3 +32,31 @@ register "addons:add", "Add add-on (usage: wokku addons:add APP postgres)" do
25
32
  data = api(:post, "/apps/#{id}/addons", body)
26
33
  Wokku::Output.status "Added #{service_type}: #{data['name'] || data['id']}"
27
34
  end
35
+
36
+ # --- Bundle v2 — shared engines ---
37
+ # User-pick at box creation OR via these commands later. Free plan limited
38
+ # to postgres + redis; other plans get all 5 (memcached, rabbitmq, meilisearch
39
+ # also available).
40
+ register "addons:shared:enable", "Enable a shared engine on an app (usage: wokku addons:shared:enable APP ENGINE). ENGINE: postgres|redis|memcached|rabbitmq|meilisearch" do
41
+ id = ARGV.shift || abort("Usage: wokku addons:shared:enable APP ENGINE")
42
+ engine = ARGV.shift || abort("Missing engine")
43
+ data = api(:post, "/apps/#{id}/addons/shared", { engine: engine })
44
+ Wokku::Output.status(data["message"] || "Enabled shared #{engine} on #{id}")
45
+ end
46
+
47
+ register "addons:shared:disable", "Disable a shared engine on an app (usage: wokku addons:shared:disable APP ENGINE)" do
48
+ id = ARGV.shift || abort("Usage: wokku addons:shared:disable APP ENGINE")
49
+ engine = ARGV.shift || abort("Missing engine")
50
+ data = api(:delete, "/apps/#{id}/addons/shared/#{engine}")
51
+ Wokku::Output.status(data["message"] || "Disabled shared #{engine} on #{id}")
52
+ end
53
+
54
+ # --- Bundle v2 — dedicated upgrade ---
55
+ # Pg/Redis migrate from shared (existing data preserved). MySQL/MongoDB
56
+ # are fresh-create. Quota: 3 per plan, size follows the box size.
57
+ register "addons:dedicated:upgrade", "Upgrade a box to a dedicated DB or Redis (usage: wokku addons:dedicated:upgrade APP ENGINE). ENGINE: postgres|mysql|mongodb|redis" do
58
+ id = ARGV.shift || abort("Usage: wokku addons:dedicated:upgrade APP ENGINE")
59
+ engine = ARGV.shift || abort("Missing engine (postgres|mysql|mongodb|redis)")
60
+ data = api(:post, "/apps/#{id}/addons/dedicated", { engine: engine })
61
+ Wokku::Output.status(data["message"] || "Dedicated #{engine} upgrade queued for #{id}")
62
+ end
@@ -14,19 +14,33 @@ register "apps:info", "Show app details (usage: wokku apps:info APP)" do
14
14
  Wokku::Output.render(data) { |d| puts_json d }
15
15
  end
16
16
 
17
- register "apps:create", "Create app (usage: wokku apps:create NAME [--server SERVER] [--branch BRANCH])" do
17
+ register "apps:create", "Create app (usage: wokku apps:create NAME [--server SERVER] [--branch BRANCH] [--box-size SIZE] [--shared pg,redis,...] [--dedicated-db postgres|mysql|mongodb] [--dedicated-redis])" do
18
18
  name = ARGV.shift || abort("Usage: wokku apps:create NAME [--server SERVER]")
19
19
  server = nil
20
20
  branch = "main"
21
- while arg = ARGV.shift
21
+ box_size = nil
22
+ shared = nil
23
+ dedicated_db = nil
24
+ dedicated_redis = false
25
+ while (arg = ARGV.shift)
22
26
  case arg
23
- when "--server" then server = ARGV.shift
24
- when "--branch" then branch = ARGV.shift
27
+ when "--server" then server = ARGV.shift
28
+ when "--branch" then branch = ARGV.shift
29
+ when "--box-size" then box_size = ARGV.shift
30
+ when "--shared" then shared = ARGV.shift # comma-separated engine names
31
+ when "--dedicated-db" then dedicated_db = ARGV.shift
32
+ when "--dedicated-redis" then dedicated_redis = true
25
33
  end
26
34
  end
27
35
  server = resolve_server(explicit: server)
28
- data = api(:post, "/apps", { name: name, server_id: server, deploy_branch: branch })
36
+ body = { name: name, server_id: server, deploy_branch: branch }
37
+ body[:box_size] = box_size if box_size
38
+ body[:enabled_shared_engines] = shared.split(",").map(&:strip) if shared
39
+ body[:dedicated_db_engine] = dedicated_db if dedicated_db
40
+ body[:add_dedicated_redis] = true if dedicated_redis
41
+ data = api(:post, "/apps", body)
29
42
  Wokku::Output.status "Created app: #{data['name']} (id: #{data['id']})"
43
+ Array(data["warnings"]).each { |w| Wokku::Output.warn(w) }
30
44
  end
31
45
 
32
46
  register "apps:destroy", "Delete app (usage: wokku apps:destroy APP)" do
@@ -1,35 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # --- Auth ---
4
- register "auth:login", "Authenticate with Wokku" do
5
- print "Wokku API URL [https://wokku.cloud/api/v1]: "
6
- url = $stdin.gets.strip
7
- url = "https://wokku.cloud/api/v1" if url.empty?
8
-
9
- print "Email: "
10
- email = $stdin.gets.strip
11
- print "Password: "
12
- password = ($stdin.respond_to?(:noecho) ? $stdin.noecho(&:gets) : $stdin.gets).strip
13
- puts
14
-
15
- uri = URI("#{url}/auth/login")
16
- http = Net::HTTP.new(uri.host, uri.port)
17
- http.use_ssl = uri.scheme == "https"
18
- req = Net::HTTP::Post.new(uri)
19
- req["Content-Type"] = "application/json"
20
- req.body = { email: email, password: password }.to_json
21
-
22
- resp = http.request(req)
23
- data = JSON.parse(resp.body) rescue {}
3
+ DEFAULT_API_URL = "https://wokku.cloud/api/v1"
24
4
 
25
- if resp.is_a?(Net::HTTPSuccess) && data["token"]
26
- save_config({ "api_url" => url, "token" => data["token"], "email" => email })
27
- instance = url.include?("wokku.cloud") ? "wokku.cloud (managed)" : "#{URI(url).host} (self-hosted)"
28
- Wokku::Output.status "Logged in as #{email}"
29
- Wokku::Output.status "Connected to: #{instance}"
30
- else
31
- abort "Login failed: #{data['error'] || resp.code}"
5
+ # --- Auth ---
6
+ register "auth:login", "Authenticate with Wokku via browser device flow. Flag: --url URL (for self-hosted)" do
7
+ url = DEFAULT_API_URL
8
+ while (arg = ARGV.shift)
9
+ case arg
10
+ when "--url" then url = ARGV.shift or abort "--url requires a value"
11
+ else abort "Unknown argument: #{arg}"
12
+ end
32
13
  end
14
+
15
+ Wokku::Auth.login_with_device_flow!(url)
33
16
  end
34
17
 
35
18
  register "auth:logout", "Log out" do
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Wokku MCP commands — install / switch / logout the Wokku MCP server
4
+ # in Claude Code so the user's CLI token gets passed to the plugin.
5
+ # Shells out to `claude mcp` (Claude Code CLI) so we don't need to know
6
+ # Claude Code's exact config-file layout — it changes between versions.
7
+
8
+ MCP_SERVER_NAME = "wokku"
9
+
10
+ def claude_cli_available?
11
+ system("command -v claude >/dev/null 2>&1")
12
+ end
13
+
14
+ def require_claude_cli!
15
+ return if claude_cli_available?
16
+ abort <<~MSG
17
+ The `claude` CLI is not installed.
18
+ Wokku MCP commands shell out to it to write your Claude Code config.
19
+
20
+ Install Claude Code (https://claude.com/claude-code) and retry.
21
+ MSG
22
+ end
23
+
24
+ def require_logged_in!
25
+ return if Wokku::Config.api_token
26
+ abort "Not logged in. Run: wokku auth:login"
27
+ end
28
+
29
+ def install_mcp_entry!
30
+ url = Wokku::Config.api_url
31
+ token = Wokku::Config.api_token
32
+
33
+ # Idempotent — silently no-ops if the entry doesn't exist yet.
34
+ system("claude mcp remove #{Shellwords.escape(MCP_SERVER_NAME)} > /dev/null 2>&1")
35
+
36
+ ok = system(
37
+ "claude", "mcp", "add", MCP_SERVER_NAME,
38
+ "--env", "WOKKU_API_URL=#{url}",
39
+ "--env", "WOKKU_API_TOKEN=#{token}",
40
+ "--", "npx", "-y", "@johannesdwicahyo/wokku-plugin"
41
+ )
42
+ ok or abort "claude mcp add failed — check `claude mcp list` and retry."
43
+ end
44
+
45
+ register "mcp:install", "Add the Wokku MCP server to Claude Code with your CLI token" do
46
+ require_claude_cli!
47
+ require_logged_in!
48
+ require "shellwords"
49
+
50
+ install_mcp_entry!
51
+ Wokku::Output.status "MCP server '#{MCP_SERVER_NAME}' installed in Claude Code."
52
+ Wokku::Output.status "Restart Claude Code, then ask: \"Deploy this project to Wokku\""
53
+ end
54
+
55
+ register "mcp:switch", "Re-push the current CLI token to the Wokku MCP server (after auth:login)" do
56
+ require_claude_cli!
57
+ require_logged_in!
58
+ require "shellwords"
59
+
60
+ install_mcp_entry!
61
+ Wokku::Output.status "MCP server '#{MCP_SERVER_NAME}' now points at the current CLI session."
62
+ Wokku::Output.status "Restart Claude Code for the new token to take effect."
63
+ end
64
+
65
+ register "mcp:logout", "Remove the Wokku MCP server from Claude Code" do
66
+ require_claude_cli!
67
+ require "shellwords"
68
+
69
+ ok = system("claude", "mcp", "remove", MCP_SERVER_NAME)
70
+ if ok
71
+ Wokku::Output.status "MCP server '#{MCP_SERVER_NAME}' removed from Claude Code."
72
+ else
73
+ Wokku::Output.status "No MCP entry named '#{MCP_SERVER_NAME}' to remove."
74
+ end
75
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+ require "wokku/cable_client"
5
+ require "wokku/pty_session"
6
+
7
+ # --- wokku enter APP ---
8
+ register "enter", "Open an interactive shell in APP's running container (usage: wokku enter APP)" do
9
+ app_name = ARGV.shift or abort "Usage: wokku enter APP"
10
+ Wokku::Commands::Shell.run!(mode: "enter", target_kind: :app, target: app_name, argv: [], force_tty: true)
11
+ end
12
+
13
+ # --- wokku ps:exec APP -- CMD ARGS... ---
14
+ register "ps:exec", "Run CMD in a one-off container (like `heroku run`; usage: wokku ps:exec APP [-t|-T] -- CMD [ARGS...])" do
15
+ force = nil
16
+ app_name = nil
17
+ argv = []
18
+ while (arg = ARGV.shift)
19
+ case arg
20
+ when "-t", "--tty" then force = true
21
+ when "-T", "--no-tty" then force = false
22
+ when "--" then argv = ARGV.shift(ARGV.length); break
23
+ else app_name ||= arg
24
+ end
25
+ end
26
+ abort "Usage: wokku ps:exec APP [-t|-T] -- CMD [ARGS...]" unless app_name && !argv.empty?
27
+ Wokku::Commands::Shell.run!(mode: "exec", target_kind: :app, target: app_name, argv: argv, force_tty: force)
28
+ end
29
+
30
+ # --- wokku databases:connect DB ---
31
+ register "databases:connect", "Open an interactive client for DB (usage: wokku databases:connect DB)" do
32
+ db_name = ARGV.shift or abort "Usage: wokku databases:connect DB"
33
+ Wokku::Commands::Shell.run!(mode: "db_connect", target_kind: :database, target: db_name, argv: [], force_tty: true)
34
+ end
35
+
36
+ module Wokku
37
+ module Commands
38
+ module Shell
39
+ module_function
40
+
41
+ def run!(mode:, target_kind:, target:, argv:, force_tty:)
42
+ resource_path = target_kind == :app ? "/apps/#{target}" : "/databases/#{target}"
43
+ resource = api(:get, resource_path)
44
+ server_id = resource["server_id"] or abort "could not resolve server for #{target}"
45
+
46
+ token = Wokku::Config.api_token or abort "not logged in — run `wokku auth:login`"
47
+ cable_url = derive_cable_url(Wokku::Config.api_url)
48
+
49
+ cable = Wokku::CableClient.new(url: cable_url, token: token)
50
+ cable.subscribe(channel: "TerminalChannel", params: { server_id: server_id })
51
+
52
+ tty = force_tty.nil? ? $stdout.tty? : force_tty
53
+ session = Wokku::PtySession.new(cable: cable, tty: tty)
54
+ session.start!(mode: mode, target: target, argv: argv)
55
+ session.run
56
+ cable.close
57
+ exit(session.exit_code || 0)
58
+ end
59
+
60
+ def derive_cable_url(api_url)
61
+ u = URI(api_url)
62
+ scheme = u.scheme == "https" ? "wss" : "ws"
63
+ port = (u.port && ![80, 443].include?(u.port)) ? ":#{u.port}" : ""
64
+ "#{scheme}://#{u.host}#{port}/cable"
65
+ end
66
+ end
67
+ end
68
+ end
data/lib/wokku/output.rb CHANGED
@@ -39,5 +39,14 @@ module Wokku
39
39
  return if Wokku.quiet? || Wokku.json?
40
40
  puts message
41
41
  end
42
+
43
+ # Non-fatal warning (e.g. partial-success cases like "app created
44
+ # but the dedicated DB upgrade failed to enqueue"). Stderr so it
45
+ # doesn't pollute pipeable stdout; visible even with --quiet but
46
+ # silenced under --json (the JSON response carries the warning).
47
+ def warn(message)
48
+ return if Wokku.json?
49
+ $stderr.puts "WARNING: #{message}"
50
+ end
42
51
  end
43
52
  end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "io/console"
4
+ require "base64"
5
+
6
+ module Wokku
7
+ class PtySession
8
+ attr_reader :exit_code
9
+
10
+ def initialize(cable:, tty:)
11
+ @cable = cable
12
+ @tty = tty
13
+ @exit_code = nil
14
+ @done = false
15
+ end
16
+
17
+ def start!(mode:, target:, argv: [])
18
+ rows, cols = current_winsize
19
+ @cable.send_message(type: "start", mode: mode, target: target, argv: argv, cols: cols, rows: rows)
20
+ end
21
+
22
+ # Runs until @done is set by an "exit" or "error" message.
23
+ # Optional block (used in specs) is called once per pump tick and may return :stop.
24
+ def run
25
+ install_message_handler
26
+ install_signal_handlers if @tty
27
+
28
+ IO.console.raw! if @tty
29
+ begin
30
+ loop do
31
+ break if @done
32
+ forward_stdin if @tty
33
+ @cable.pump(0.05)
34
+ break if block_given? && yield == :stop
35
+ end
36
+ ensure
37
+ IO.console.cooked! if @tty
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ def install_signal_handlers
44
+ Signal.trap("WINCH") do
45
+ rows, cols = current_winsize
46
+ @cable.send_message(type: "resize", cols: cols, rows: rows)
47
+ end
48
+ end
49
+
50
+ def install_message_handler
51
+ @cable.on_message do |msg|
52
+ case msg["type"]
53
+ when "stdout"
54
+ $stdout.write(Base64.strict_decode64(msg["data"].to_s))
55
+ $stdout.flush
56
+ when "exit"
57
+ @exit_code = msg["code"].to_i
58
+ @done = true
59
+ when "error"
60
+ warn(msg["message"].to_s)
61
+ @exit_code = 1
62
+ @done = true
63
+ end
64
+ end
65
+ end
66
+
67
+ def forward_stdin
68
+ ready = IO.select([$stdin], nil, nil, 0)
69
+ return unless ready
70
+ data = $stdin.read_nonblock(4096)
71
+ @cable.send_message(type: "stdin", data: Base64.strict_encode64(data))
72
+ rescue IO::WaitReadable, EOFError, TypeError
73
+ # nothing to read (TypeError: StringIO in tests, or non-IO stdin)
74
+ end
75
+
76
+ def current_winsize
77
+ IO.console.winsize
78
+ rescue
79
+ [24, 80]
80
+ end
81
+ end
82
+ end
data/lib/wokku/version.rb CHANGED
@@ -1,4 +1,4 @@
1
1
  # frozen_string_literal: true
2
2
  module Wokku
3
- VERSION = "0.1.0"
3
+ VERSION = "0.4.0"
4
4
  end
data/lib/wokku.rb CHANGED
@@ -11,6 +11,7 @@ require "wokku/output"
11
11
  require "wokku/api_client"
12
12
  require "wokku/registry"
13
13
  require "wokku/helpers"
14
+ require "wokku/auth"
14
15
 
15
16
  module Wokku
16
17
  class << self
metadata CHANGED
@@ -1,14 +1,28 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: wokku-cli
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Johannes Dwicahyo
8
8
  bindir: exe
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
- dependencies: []
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: websocket-driver
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.7'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.7'
12
26
  description: Deploy and manage apps, databases, domains, and SSH keys on Wokku.cloud
13
27
  or self-hosted Wokku servers.
14
28
  email:
@@ -24,6 +38,8 @@ files:
24
38
  - exe/wokku
25
39
  - lib/wokku.rb
26
40
  - lib/wokku/api_client.rb
41
+ - lib/wokku/auth.rb
42
+ - lib/wokku/cable_client.rb
27
43
  - lib/wokku/commands/activity.rb
28
44
  - lib/wokku/commands/addons.rb
29
45
  - lib/wokku/commands/apps.rb
@@ -36,10 +52,12 @@ files:
36
52
  - lib/wokku/commands/do.rb
37
53
  - lib/wokku/commands/domains.rb
38
54
  - lib/wokku/commands/logs.rb
55
+ - lib/wokku/commands/mcp.rb
39
56
  - lib/wokku/commands/ps.rb
40
57
  - lib/wokku/commands/releases.rb
41
58
  - lib/wokku/commands/run.rb
42
59
  - lib/wokku/commands/servers.rb
60
+ - lib/wokku/commands/shell.rb
43
61
  - lib/wokku/commands/ssh_keys.rb
44
62
  - lib/wokku/commands/storage.rb
45
63
  - lib/wokku/commands/templates.rb
@@ -47,6 +65,7 @@ files:
47
65
  - lib/wokku/config.rb
48
66
  - lib/wokku/helpers.rb
49
67
  - lib/wokku/output.rb
68
+ - lib/wokku/pty_session.rb
50
69
  - lib/wokku/registry.rb
51
70
  - lib/wokku/version.rb
52
71
  homepage: https://wokku.cloud