wokku-cli 0.1.0 → 0.2.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: 90dbd93905c3fc3dc7422cf4342def249b9ff04d3c5bbefd64f5d4a5ccc23cb6
4
+ data.tar.gz: 01b1043ff82e7ae509ddb36eed9fa6d9657b7321ae82502996c448eb4d755440
5
5
  SHA512:
6
- metadata.gz: 8b3e284c92712469309c2a3aab4fffa60e26b89727f8bf948e1bfaba87e70b572eca298ec69ee1449d6112656d478294c825f1d826b0a7f9d2f7a02b57820085
7
- data.tar.gz: e3f998fd1ec992942da0319c4838cf664dbbda1ef49cb9a777a35da0acc3d1a29ec0486df464f5b45bd3d9cc3eb431098e23b7bb047462d1fc6a27d90f26772d
6
+ metadata.gz: 7650db80c9d0608c8f879c86f556008ae47af5cd3827e029378e7327e455b9762ef6f4260a73f0d1da61c7c8a93b2bdfe03fd01fae1ca3212e60acbbfa61bc56
7
+ data.tar.gz: 33fbc61d1092de38e30737cedcb83750bc55fa8193f1cf3604599e5ecf7e6103bc16faa7775c8bab0217d44dc3ba2b33cae6804c921007888f9097c277e94f78
data/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0 — 2026-05-06
4
+
5
+ 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.
6
+
7
+ ### Added
8
+ - `wokku enter APP` — interactive shell in the app's running container.
9
+ - `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).
10
+ - `wokku databases:connect DB` — interactive client for the managed database (psql / redis-cli / mysql via dokku's `<plugin>:connect`).
11
+
12
+ ### Internals
13
+ - New `Wokku::CableClient` — thin `websocket-driver` wrapper, Bearer-token handshake on `/cable`.
14
+ - New `Wokku::PtySession` — local raw-mode PTY orchestration with SIGWINCH resize and guaranteed `cooked!` restore.
15
+ - Runtime dependency: `websocket-driver ~> 0.7`.
16
+
17
+ ### Requires
18
+ - wokku-cloud server-side support shipped 2026-05-06 (Bearer auth on `ApplicationCable::Connection`, new `TerminalChannel` wire protocol with modes).
19
+
3
20
  ## 0.1.0 — 2026-05-04
4
21
 
5
22
  First public release on RubyGems.
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
@@ -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,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
@@ -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.2.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.2.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
@@ -40,6 +56,7 @@ files:
40
56
  - lib/wokku/commands/releases.rb
41
57
  - lib/wokku/commands/run.rb
42
58
  - lib/wokku/commands/servers.rb
59
+ - lib/wokku/commands/shell.rb
43
60
  - lib/wokku/commands/ssh_keys.rb
44
61
  - lib/wokku/commands/storage.rb
45
62
  - lib/wokku/commands/templates.rb
@@ -47,6 +64,7 @@ files:
47
64
  - lib/wokku/config.rb
48
65
  - lib/wokku/helpers.rb
49
66
  - lib/wokku/output.rb
67
+ - lib/wokku/pty_session.rb
50
68
  - lib/wokku/registry.rb
51
69
  - lib/wokku/version.rb
52
70
  homepage: https://wokku.cloud