wokku-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 3d6db99db4bf59f5b55f619cbd6abb12fc6b3f711fba103b327e6d0b01479114
4
+ data.tar.gz: 0b10a6ea71101885f3929f7f6055705b6a0d98ebcb2a21147070911c0522b24a
5
+ SHA512:
6
+ metadata.gz: 8b3e284c92712469309c2a3aab4fffa60e26b89727f8bf948e1bfaba87e70b572eca298ec69ee1449d6112656d478294c825f1d826b0a7f9d2f7a02b57820085
7
+ data.tar.gz: e3f998fd1ec992942da0319c4838cf664dbbda1ef49cb9a777a35da0acc3d1a29ec0486df464f5b45bd3d9cc3eb431098e23b7bb047462d1fc6a27d90f26772d
data/CHANGELOG.md ADDED
@@ -0,0 +1,33 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 — 2026-05-04
4
+
5
+ First public release on RubyGems.
6
+
7
+ The wokku CLI was previously distributed as a tarball from `wokku.cloud/cli/wokku.tar.gz`. This is a fresh start under the `0.x` line — pre-1.0 means the surface may evolve as we gather feedback. Reach 1.0.0 once the API stabilizes.
8
+
9
+ ### Features
10
+ - Apps: `apps`, `apps:create`, `apps:destroy`, `apps:info`
11
+ - Process: `ps:start/stop/restart/scale/rebuild`, `redeploy`
12
+ - Config: `config`, `config:set/get/unset`, `config:export` (.env-safe quoting)
13
+ - Domains: `domains`, `domains:add/remove/clear`, `certs:enable`
14
+ - Buildpacks: `buildpacks`, `buildpacks:add/remove/clear/set`
15
+ - Health checks: `checks`, `checks:set`
16
+ - Storage: `storage`, `storage:mount/unmount`
17
+ - Releases: `releases`, `rollback`
18
+ - Add-ons: `addons`, `addons:add`
19
+ - Templates: `templates`, `deploy`
20
+ - Activity: `activity`
21
+ - Logs: `logs` with `--follow` / `-f` streaming
22
+ - SSH keys: `ssh-keys`, `ssh-keys:add/remove`
23
+ - Servers: `servers`, `servers:info`, `servers:default`
24
+ - Databases: `databases`, `databases:create/destroy/info/link/unlink`
25
+ - One-off: `wokku run APP -- COMMAND` (in-container)
26
+ - Passthrough: `wokku do APP -- DOKKU_ARGS` (raw dokku command, audit-logged)
27
+ - Authentication: `auth:login`, `auth:logout`, `auth:whoami`
28
+ - Global flags: `--json` (machine-readable), `--quiet` / `-q`
29
+
30
+ ### Distribution
31
+ - Install: `gem install wokku-cli` or `brew install johannesdwicahyo/tap/wokku`
32
+ - Source: <https://github.com/johannesdwicahyo/wokku-cli>
33
+ - Old `curl install.sh` flow continues to work (delegates to `gem install wokku-cli`).
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Johannes Dwicahyo
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,48 @@
1
+ # wokku-cli
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.
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ gem install wokku-cli
9
+ ```
10
+
11
+ Or via Homebrew:
12
+
13
+ ```sh
14
+ brew install johannesdwicahyo/tap/wokku
15
+ ```
16
+
17
+ ## Quick start
18
+
19
+ ```sh
20
+ wokku auth:login
21
+ wokku apps
22
+ wokku apps:create myapp
23
+ wokku ps:restart myapp
24
+ wokku logs myapp -f
25
+ ```
26
+
27
+ ## Commands
28
+
29
+ Run `wokku --help` for the full list. Highlights:
30
+
31
+ - **Apps:** `apps`, `apps:create`, `apps:destroy`, `apps:info`
32
+ - **Process:** `ps:start/stop/restart/scale/rebuild`, `redeploy`
33
+ - **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
+ - **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`
40
+
41
+ ## Global flags
42
+
43
+ - `--json` — machine-readable JSON output (read commands)
44
+ - `--quiet` / `-q` — suppress success messages and hints
45
+
46
+ ## License
47
+
48
+ MIT — see [LICENSE](LICENSE).
data/exe/wokku ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+ require "wokku"
4
+ exit Wokku::CLI.dispatch(ARGV)
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+ require_relative "config"
7
+
8
+ module Wokku
9
+ class ApiClient
10
+ class Error < StandardError; end
11
+ class NotAuthenticated < Error; end
12
+ class Timeout < Error; end
13
+ class Unreachable < Error; end
14
+
15
+ OPEN_TIMEOUT = 10
16
+ READ_TIMEOUT = 30
17
+
18
+ def initialize(url: nil, token: nil, user_agent: "WokkuCLI")
19
+ @url = url
20
+ @token = token
21
+ @user_agent = user_agent
22
+ end
23
+
24
+ def get(path) = request(:get, path)
25
+ def post(path, body = nil) = request(:post, path, body)
26
+ def put(path, body = nil) = request(:put, path, body)
27
+ def patch(path, body = nil) = request(:patch, path, body)
28
+ def delete(path, body = nil) = request(:delete, path, body)
29
+
30
+ def request(method, path, body = nil)
31
+ @last_path = path
32
+ raise NotAuthenticated, "Not logged in. Run: wokku auth:login" unless token
33
+
34
+ uri = URI("#{url}#{path}")
35
+ http = Net::HTTP.new(uri.host, uri.port)
36
+ http.use_ssl = uri.scheme == "https"
37
+ http.open_timeout = OPEN_TIMEOUT
38
+ http.read_timeout = READ_TIMEOUT
39
+
40
+ req = build_request(method, uri)
41
+ req["Authorization"] = "Bearer #{token}"
42
+ req["Content-Type"] = "application/json"
43
+ req["User-Agent"] = @user_agent
44
+ req.body = body.to_json if body
45
+
46
+ resp = perform(http, req, uri)
47
+ parse_response(resp)
48
+ end
49
+
50
+ def stream(method, path, &block)
51
+ @last_path = path
52
+ raise NotAuthenticated, "Not logged in. Run: wokku auth:login" unless token
53
+
54
+ uri = URI("#{url}#{path}")
55
+ http = Net::HTTP.new(uri.host, uri.port)
56
+ http.use_ssl = uri.scheme == "https"
57
+ # Local Net::HTTP instance — disabling read_timeout doesn't leak to other calls.
58
+ http.read_timeout = nil
59
+
60
+ req = build_request(method, uri)
61
+ req["Authorization"] = "Bearer #{token}"
62
+ req["Accept"] = "text/plain"
63
+ req["User-Agent"] = @user_agent
64
+
65
+ http.request(req) do |resp|
66
+ raise Error, "Stream failed: HTTP #{resp.code}" unless resp.is_a?(Net::HTTPSuccess)
67
+ resp.read_body { |chunk| block.call(chunk) }
68
+ end
69
+ rescue Interrupt
70
+ # Ctrl-C — exit gracefully
71
+ end
72
+
73
+ private
74
+
75
+ def url
76
+ @url || Wokku::Config.api_url
77
+ end
78
+
79
+ def token
80
+ @token || Wokku::Config.api_token
81
+ end
82
+
83
+ def build_request(method, uri)
84
+ case method
85
+ when :get then Net::HTTP::Get.new(uri)
86
+ when :post then Net::HTTP::Post.new(uri)
87
+ when :put then Net::HTTP::Put.new(uri)
88
+ when :patch then Net::HTTP::Patch.new(uri)
89
+ when :delete then Net::HTTP::Delete.new(uri)
90
+ else raise ArgumentError, "Unsupported method: #{method}"
91
+ end
92
+ end
93
+
94
+ def perform(http, req, uri)
95
+ http.request(req)
96
+ rescue Net::ReadTimeout, Net::OpenTimeout
97
+ raise Timeout, "Request timed out — the server didn't respond in #{READ_TIMEOUT}s."
98
+ rescue SocketError => e
99
+ raise Unreachable, "Cannot reach #{uri.host}: #{e.message.lines.first&.strip}."
100
+ rescue Errno::ECONNREFUSED
101
+ raise Unreachable, "Connection refused by #{uri.host}:#{uri.port}."
102
+ rescue OpenSSL::SSL::SSLError => e
103
+ raise Unreachable, "TLS error talking to #{uri.host}: #{e.message.lines.first&.strip}."
104
+ end
105
+
106
+ def parse_response(resp)
107
+ data = JSON.parse(resp.body) rescue resp.body
108
+ return data if resp.is_a?(Net::HTTPSuccess)
109
+
110
+ raise Error, friendly_error(resp, data)
111
+ end
112
+
113
+ def friendly_error(resp, data)
114
+ if resp.code == "404" && (req_path = @last_path) && (m = req_path.match(%r{\A/apps/([^/?]+)}))
115
+ return "No app named '#{m[1]}'"
116
+ end
117
+ msg = data.is_a?(Hash) ? (data["error"] || data["errors"]&.join(", ") || resp.code) : resp.code
118
+ "Error: #{msg}"
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ # --- Activity ---
4
+ register "activity", "Show recent activity" do
5
+ data = api(:get, "/activities?limit=20")
6
+ Wokku::Output.render(data) do |d|
7
+ if d.is_a?(Array)
8
+ d.each { |a| puts "#{a['created_at'].to_s[0..18]} #{a['action']} #{a['target_name']}" }
9
+ else
10
+ puts_json d
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ # --- Add-ons ---
4
+ register "addons", "List add-ons (usage: wokku addons APP)" do
5
+ id = ARGV.shift || abort("Usage: wokku addons APP")
6
+ data = api(:get, "/apps/#{id}/addons")
7
+ Wokku::Output.render(data) do |d|
8
+ if d.is_a?(Array)
9
+ table(d.map { |a| { "name" => a["name"], "type" => a["service_type"], "status" => a["status"] } })
10
+ else
11
+ puts_json d
12
+ end
13
+ end
14
+ end
15
+
16
+ register "addons:add", "Add add-on (usage: wokku addons:add APP postgres)" do
17
+ id = ARGV.shift || abort("Usage: wokku addons:add APP SERVICE_TYPE")
18
+ service_type = ARGV.shift || abort("Missing service type (postgres, redis, mysql, etc)")
19
+ name = nil
20
+ while arg = ARGV.shift
21
+ name = ARGV.shift if arg == "--name"
22
+ end
23
+ body = { service_type: service_type }
24
+ body[:name] = name if name
25
+ data = api(:post, "/apps/#{id}/addons", body)
26
+ Wokku::Output.status "Added #{service_type}: #{data['name'] || data['id']}"
27
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ # --- Apps ---
4
+ register "apps", "List all apps" do
5
+ apps = api(:get, "/apps")
6
+ Wokku::Output.render(apps) do |list|
7
+ table(list.map { |a| { "name" => a["name"], "status" => a["status"], "server" => a["server_id"] } })
8
+ end
9
+ end
10
+
11
+ register "apps:info", "Show app details (usage: wokku apps:info APP)" do
12
+ id = ARGV.shift || abort("Usage: wokku apps:info APP")
13
+ data = api(:get, "/apps/#{id}")
14
+ Wokku::Output.render(data) { |d| puts_json d }
15
+ end
16
+
17
+ register "apps:create", "Create app (usage: wokku apps:create NAME [--server SERVER] [--branch BRANCH])" do
18
+ name = ARGV.shift || abort("Usage: wokku apps:create NAME [--server SERVER]")
19
+ server = nil
20
+ branch = "main"
21
+ while arg = ARGV.shift
22
+ case arg
23
+ when "--server" then server = ARGV.shift
24
+ when "--branch" then branch = ARGV.shift
25
+ end
26
+ end
27
+ server = resolve_server(explicit: server)
28
+ data = api(:post, "/apps", { name: name, server_id: server, deploy_branch: branch })
29
+ Wokku::Output.status "Created app: #{data['name']} (id: #{data['id']})"
30
+ end
31
+
32
+ register "apps:destroy", "Delete app (usage: wokku apps:destroy APP)" do
33
+ id = ARGV.shift || abort("Usage: wokku apps:destroy APP")
34
+ print "Are you sure you want to delete this app? (y/N): "
35
+ confirm = $stdin.gets.strip
36
+ abort "Cancelled." unless confirm.downcase == "y"
37
+ api(:delete, "/apps/#{id}")
38
+ Wokku::Output.status "App deleted."
39
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
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 {}
24
+
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}"
32
+ end
33
+ end
34
+
35
+ register "auth:logout", "Log out" do
36
+ path = Wokku::Config.file
37
+ if File.exist?(path)
38
+ File.delete(path)
39
+ Wokku::Output.status "Logged out."
40
+ else
41
+ Wokku::Output.status "Not logged in."
42
+ end
43
+ end
44
+
45
+ register "auth:whoami", "Show current user" do
46
+ data = api(:get, "/auth/whoami")
47
+ Wokku::Output.render(data) do |d|
48
+ puts "Email: #{d['email']}"
49
+ puts "Role: #{d['role']}"
50
+ end
51
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ # --- Buildpacks ---
4
+ register "buildpacks", "List buildpacks (usage: wokku buildpacks APP)" do
5
+ id = ARGV.shift || abort("Usage: wokku buildpacks APP")
6
+ data = api(:get, "/apps/#{id}/buildpacks")
7
+ Wokku::Output.render(data) do |d|
8
+ list = d.is_a?(Hash) ? Array(d["buildpacks"]) : Array(d)
9
+ if list.empty?
10
+ puts "(no buildpacks configured — auto-detected from source)"
11
+ else
12
+ list.each_with_index { |url, i| puts "#{i + 1}. #{url}" }
13
+ end
14
+ end
15
+ end
16
+
17
+ register "buildpacks:add", "Append a buildpack (usage: wokku buildpacks:add APP URL [--index N])" do
18
+ id = ARGV.shift || abort("Usage: wokku buildpacks:add APP URL [--index N]")
19
+ url = ARGV.shift || abort("Missing buildpack URL")
20
+ index = nil
21
+ while arg = ARGV.shift
22
+ index = ARGV.shift if arg == "--index"
23
+ end
24
+ body = { url: url }
25
+ body[:index] = index if index
26
+ api(:post, "/apps/#{id}/buildpacks", body)
27
+ Wokku::Output.status "Added buildpack: #{url}"
28
+ end
29
+
30
+ register "buildpacks:remove", "Remove a buildpack (usage: wokku buildpacks:remove APP URL)" do
31
+ id = ARGV.shift || abort("Usage: wokku buildpacks:remove APP URL")
32
+ url = ARGV.shift || abort("Missing buildpack URL")
33
+ api(:delete, "/apps/#{id}/buildpacks", { url: url })
34
+ Wokku::Output.status "Removed buildpack: #{url}"
35
+ end
36
+
37
+ register "buildpacks:clear", "Remove every buildpack (usage: wokku buildpacks:clear APP)" do
38
+ id = ARGV.shift || abort("Usage: wokku buildpacks:clear APP")
39
+ api(:delete, "/apps/#{id}/buildpacks")
40
+ Wokku::Output.status "All buildpacks removed."
41
+ end
42
+
43
+ register "buildpacks:set", "Replace the buildpack stack in order (usage: wokku buildpacks:set APP URL [URL...])" do
44
+ id = ARGV.shift || abort("Usage: wokku buildpacks:set APP URL [URL...]")
45
+ urls = ARGV.dup
46
+ ARGV.clear
47
+ abort "No buildpack URLs given." if urls.empty?
48
+ api(:put, "/apps/#{id}/buildpacks", { urls: urls })
49
+ Wokku::Output.status "Buildpacks set: #{urls.join(', ')}"
50
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ # --- TLS / Certs ---
4
+ register "certs:enable", "Enable Let's Encrypt SSL on a domain (usage: wokku certs:enable APP DOMAIN)" do
5
+ id = ARGV.shift || abort("Usage: wokku certs:enable APP DOMAIN")
6
+ domain = ARGV.shift || abort("Missing domain name")
7
+ row = find_domain(id, domain) || abort("No domain '#{domain}' on app #{id} — add it first with wokku domains:add")
8
+ api(:post, "/apps/#{id}/domains/#{row['id']}/ssl")
9
+ Wokku::Output.status "SSL enabled for #{domain}"
10
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ # --- Health checks ---
4
+ register "checks", "Show Dokku health check config (usage: wokku checks APP)" do
5
+ id = ARGV.shift || abort("Usage: wokku checks APP")
6
+ data = api(:get, "/apps/#{id}/checks")
7
+ Wokku::Output.render(data) { |d| puts_json d }
8
+ end
9
+
10
+ register "checks:set", "Update health check settings (usage: wokku checks:set APP [--enabled BOOL] [--wait N] [--timeout N] [--attempts N] [--path PATH])" do
11
+ id = ARGV.shift || abort("Usage: wokku checks:set APP [flags]")
12
+ body = {}
13
+ while arg = ARGV.shift
14
+ case arg
15
+ when "--enabled" then body[:enabled] = ARGV.shift
16
+ when "--wait" then body[:wait] = ARGV.shift
17
+ when "--timeout" then body[:timeout] = ARGV.shift
18
+ when "--attempts" then body[:attempts] = ARGV.shift
19
+ when "--path" then body[:path] = ARGV.shift
20
+ end
21
+ end
22
+ abort "No flags given. Try --enabled true, --path /healthz, --wait 5, etc." if body.empty?
23
+ api(:put, "/apps/#{id}/checks", body)
24
+ Wokku::Output.status "Health check settings updated."
25
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ # --- Config ---
4
+ register "config", "Show config vars (usage: wokku config APP)" do
5
+ id = ARGV.shift || abort("Usage: wokku config APP")
6
+ data = api(:get, "/apps/#{id}/config")
7
+ Wokku::Output.render(data) do |d|
8
+ if d.is_a?(Hash)
9
+ d.each { |k, v| puts "#{k}=#{v}" }
10
+ else
11
+ puts_json d
12
+ end
13
+ end
14
+ end
15
+
16
+ register "config:set", "Set config vars (usage: wokku config:set APP KEY=VAL ...)" do
17
+ id = ARGV.shift || abort("Usage: wokku config:set APP KEY=VALUE ...")
18
+ vars = {}
19
+ ARGV.each do |pair|
20
+ key, val = pair.split("=", 2)
21
+ vars[key] = val if key && val
22
+ end
23
+ ARGV.clear
24
+ abort "No KEY=VALUE pairs given." if vars.empty?
25
+ # Server expects { vars: { K: V } }, not the bare hash. Sending the bare
26
+ # hash worked previously because Rails was permissive; the controller
27
+ # actually reads params[:vars].
28
+ api(:put, "/apps/#{id}/config", { vars: vars })
29
+ Wokku::Output.status "Config updated: #{vars.keys.join(', ')}"
30
+ Wokku::Output.status "Restart the app to pick up changes: wokku ps:restart #{id}"
31
+ end
32
+
33
+ register "config:get", "Show one config var (usage: wokku config:get APP KEY)" do
34
+ id = ARGV.shift || abort("Usage: wokku config:get APP KEY")
35
+ key = ARGV.shift || abort("Missing KEY")
36
+ data = api(:get, "/apps/#{id}/config")
37
+ vars = data.is_a?(Hash) ? (data["config"] || data) : {}
38
+ abort "Key not set: #{key}" unless vars.key?(key)
39
+ Wokku::Output.render({ "key" => key, "value" => vars[key] }) { |_| puts vars[key] }
40
+ end
41
+
42
+ register "config:export", "Export config as .env (usage: wokku config:export APP)" do
43
+ id = ARGV.shift || abort("Usage: wokku config:export APP")
44
+ data = api(:get, "/apps/#{id}/config")
45
+ vars = data.is_a?(Hash) ? (data["config"] || data) : {}
46
+ Wokku::Output.render(vars) do |d|
47
+ d.each do |k, v|
48
+ escaped = v.to_s.gsub('\\') { '\\\\' }.gsub('"') { '\\"' }
49
+ puts %Q(#{k}="#{escaped}")
50
+ end
51
+ end
52
+ end
53
+
54
+ register "config:unset", "Remove config vars (usage: wokku config:unset APP KEY [KEY...])" do
55
+ id = ARGV.shift || abort("Usage: wokku config:unset APP KEY [KEY...]")
56
+ keys = ARGV.dup
57
+ ARGV.clear
58
+ abort "No KEYs given." if keys.empty?
59
+ api(:delete, "/apps/#{id}/config", { keys: keys })
60
+ Wokku::Output.status "Removed: #{keys.join(', ')}"
61
+ Wokku::Output.status "Restart the app to pick up changes: wokku ps:restart #{id}"
62
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ # --- Databases ---
4
+ register "databases", "List your databases" do
5
+ data = api(:get, "/databases")
6
+ Wokku::Output.render(data) do |list|
7
+ table(Array(list).map { |db| {
8
+ "name" => db["name"],
9
+ "type" => db["service_type"],
10
+ "status" => db["status"],
11
+ "server" => db["server_id"]
12
+ } })
13
+ end
14
+ end
15
+
16
+ register "databases:info", "Show database details (usage: wokku databases:info NAME)" do
17
+ name = ARGV.shift || abort("Usage: wokku databases:info NAME")
18
+ data = api(:get, "/databases/#{name}")
19
+ Wokku::Output.render(data) { |d| puts_json d }
20
+ end
21
+
22
+ register "databases:create", "Create a database (usage: wokku databases:create NAME --type TYPE [--server SERVER])" do
23
+ name = ARGV.shift || abort("Usage: wokku databases:create NAME --type TYPE [--server SERVER]")
24
+ type = nil
25
+ server = nil
26
+ while arg = ARGV.shift
27
+ case arg
28
+ when "--type" then type = ARGV.shift
29
+ when "--server" then server = ARGV.shift
30
+ end
31
+ end
32
+ abort "Missing --type (postgres, mysql, redis, mongo)" unless type
33
+ server = resolve_server(explicit: server)
34
+ data = api(:post, "/databases", { name: name, service_type: type, server_id: server })
35
+ Wokku::Output.status "Created #{data['service_type']} database: #{data['name']}"
36
+ end
37
+
38
+ register "databases:destroy", "Destroy a database (usage: wokku databases:destroy NAME)" do
39
+ name = ARGV.shift || abort("Usage: wokku databases:destroy NAME")
40
+ print "Are you sure you want to destroy database '#{name}'? (y/N): "
41
+ confirm = $stdin.gets.to_s.strip
42
+ abort "Cancelled." unless confirm.downcase == "y"
43
+ api(:delete, "/databases/#{name}")
44
+ Wokku::Output.status "Database '#{name}' destroyed."
45
+ end
46
+
47
+ register "databases:link", "Link a database to an app (usage: wokku databases:link DB APP [--alias ALIAS])" do
48
+ db_name = ARGV.shift || abort("Usage: wokku databases:link DB APP [--alias ALIAS]")
49
+ app = ARGV.shift || abort("Missing APP")
50
+ alias_name = nil
51
+ while arg = ARGV.shift
52
+ case arg
53
+ when "--alias" then alias_name = ARGV.shift
54
+ end
55
+ end
56
+ body = { app_id: app }
57
+ body[:alias_name] = alias_name if alias_name
58
+ api(:post, "/databases/#{db_name}/link", body)
59
+ Wokku::Output.status "Linked '#{db_name}' to '#{app}'#{alias_name ? " as #{alias_name}" : ""}."
60
+ end
61
+
62
+ register "databases:unlink", "Unlink a database from an app (usage: wokku databases:unlink DB APP)" do
63
+ db_name = ARGV.shift || abort("Usage: wokku databases:unlink DB APP")
64
+ app = ARGV.shift || abort("Missing APP")
65
+ api(:post, "/databases/#{db_name}/unlink", { app_id: app })
66
+ Wokku::Output.status "Unlinked '#{db_name}' from '#{app}'."
67
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ # --- Raw dokku passthrough ---
4
+ register "do", "Run any dokku command (usage: wokku do APP -- DOKKU_ARGS | wokku do --server N -- DOKKU_ARGS)" do
5
+ app = nil
6
+ server = nil
7
+ force = false
8
+ while ARGV.any? && ARGV.first != "--"
9
+ arg = ARGV.shift
10
+ case arg
11
+ when "--server"
12
+ server = ARGV.shift
13
+ abort "--server requires a server ID" if server.nil? || server == "--"
14
+ when "--force" then force = true
15
+ else app ||= arg
16
+ end
17
+ end
18
+
19
+ abort "Missing `--` separator. Example: wokku do myapp -- ps:list" unless ARGV.first == "--"
20
+ ARGV.shift # drop the --
21
+ args = ARGV.dup
22
+ ARGV.clear
23
+ abort "Missing dokku command. Example: wokku do myapp -- ps:list" if args.empty?
24
+ abort "Pass either APP or --server N, not both" if app && server
25
+ abort "Pass APP or --server N before --" if app.nil? && server.nil?
26
+
27
+ path = app ? "/apps/#{app}/dokku" : "/servers/#{server}/dokku"
28
+ data = api(:post, path, { args: args, force: force })
29
+ $stdout.write(data["stdout"]) if data["stdout"] && !data["stdout"].empty?
30
+ $stderr.write(data["stderr"]) if data["stderr"] && !data["stderr"].empty?
31
+ exit (data["exit_code"] || 0).to_i
32
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ # --- Domains ---
4
+ register "domains", "List domains (usage: wokku domains APP)" do
5
+ id = ARGV.shift || abort("Usage: wokku domains APP")
6
+ data = api(:get, "/apps/#{id}/domains")
7
+ Wokku::Output.render(data) do |d|
8
+ if d.is_a?(Array)
9
+ d.each { |row| puts row["hostname"] || row }
10
+ else
11
+ puts_json d
12
+ end
13
+ end
14
+ end
15
+
16
+ register "domains:add", "Add domain (usage: wokku domains:add APP DOMAIN)" do
17
+ id = ARGV.shift || abort("Usage: wokku domains:add APP DOMAIN")
18
+ domain = ARGV.shift || abort("Missing domain name")
19
+ api(:post, "/apps/#{id}/domains", { hostname: domain })
20
+ Wokku::Output.status "Added domain: #{domain}"
21
+ end
22
+
23
+ register "domains:remove", "Remove a domain (usage: wokku domains:remove APP DOMAIN)" do
24
+ id = ARGV.shift || abort("Usage: wokku domains:remove APP DOMAIN")
25
+ domain = ARGV.shift || abort("Missing domain name")
26
+ row = find_domain(id, domain) || abort("No domain '#{domain}' on app #{id}")
27
+ api(:delete, "/apps/#{id}/domains/#{row['id']}")
28
+ Wokku::Output.status "Removed domain: #{domain}"
29
+ end
30
+
31
+ register "domains:clear", "Remove every custom domain on an app (usage: wokku domains:clear APP)" do
32
+ id = ARGV.shift || abort("Usage: wokku domains:clear APP")
33
+ list = api(:get, "/apps/#{id}/domains")
34
+ abort "No domains on app #{id}" unless list.is_a?(Array) && list.any?
35
+ list.each do |d|
36
+ api(:delete, "/apps/#{id}/domains/#{d['id']}")
37
+ Wokku::Output.status "Removed: #{d['hostname']}"
38
+ end
39
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ # --- Logs ---
4
+ register "logs", "View logs (usage: wokku logs APP [--lines N] [--follow|-f])" do
5
+ id = ARGV.shift || abort("Usage: wokku logs APP")
6
+ lines = 100
7
+ follow = false
8
+ while arg = ARGV.shift
9
+ case arg
10
+ when "--lines" then lines = ARGV.shift.to_i
11
+ when "--follow", "-f" then follow = true
12
+ end
13
+ end
14
+
15
+ if follow
16
+ Wokku.api_client.stream(:get, "/apps/#{id}/logs?follow=1") do |chunk|
17
+ $stdout.write(chunk)
18
+ $stdout.flush
19
+ end
20
+ else
21
+ data = api(:get, "/apps/#{id}/logs?lines=#{lines}")
22
+ Wokku::Output.render(data) do |d|
23
+ if d.is_a?(Array)
24
+ d.each { |l| puts l }
25
+ elsif d.is_a?(Hash) && d["logs"]
26
+ puts d["logs"]
27
+ else
28
+ puts_json d
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ # --- Process management ---
4
+ register "ps:restart", "Restart app (usage: wokku ps:restart APP)" do
5
+ id = ARGV.shift || abort("Usage: wokku ps:restart APP")
6
+ api(:post, "/apps/#{id}/restart")
7
+ Wokku::Output.status "Restarting..."
8
+ end
9
+
10
+ register "ps:stop", "Stop app (usage: wokku ps:stop APP)" do
11
+ id = ARGV.shift || abort("Usage: wokku ps:stop APP")
12
+ api(:post, "/apps/#{id}/stop")
13
+ Wokku::Output.status "Stopped."
14
+ end
15
+
16
+ register "ps:start", "Start app (usage: wokku ps:start APP)" do
17
+ id = ARGV.shift || abort("Usage: wokku ps:start APP")
18
+ api(:post, "/apps/#{id}/start")
19
+ Wokku::Output.status "Started."
20
+ end
21
+
22
+ register "ps:rebuild", "Rebuild + redeploy app (usage: wokku ps:rebuild APP)" do
23
+ id = ARGV.shift || abort("Usage: wokku ps:rebuild APP")
24
+ data = api(:post, "/apps/#{id}/deploy")
25
+ Wokku::Output.status "Rebuild triggered. Deploy ##{data['deploy_id']} (release ##{data['release_id']})."
26
+ Wokku::Output.status "Tail logs with: wokku logs #{id} --lines 200"
27
+ end
28
+
29
+ register "redeploy", "Redeploy app from latest source (usage: wokku redeploy APP)" do
30
+ id = ARGV.shift || abort("Usage: wokku redeploy APP")
31
+ data = api(:post, "/apps/#{id}/deploy")
32
+ Wokku::Output.status "Redeploy triggered. Deploy ##{data['deploy_id']} (release ##{data['release_id']})."
33
+ end
34
+
35
+ register "ps:scale", "Scale processes (usage: wokku ps:scale APP web=2 worker=1)" do
36
+ id = ARGV.shift || abort("Usage: wokku ps:scale APP web=N worker=N")
37
+ scaling = {}
38
+ ARGV.each do |pair|
39
+ type, count = pair.split("=")
40
+ scaling[type] = count.to_i if type && count
41
+ end
42
+ ARGV.clear
43
+ abort "No scaling pairs given. Example: web=2 worker=1" if scaling.empty?
44
+ api(:patch, "/apps/#{id}/ps", { scaling: scaling })
45
+ Wokku::Output.status "Scaled: #{scaling.map { |t, c| "#{t}=#{c}" }.join(' ')}"
46
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ # --- Releases ---
4
+ register "releases", "List releases (usage: wokku releases APP)" do
5
+ id = ARGV.shift || abort("Usage: wokku releases APP")
6
+ data = api(:get, "/apps/#{id}/releases")
7
+ Wokku::Output.render(data) do |d|
8
+ if d.is_a?(Array)
9
+ table(d.map { |r| { "version" => "v#{r['version']}", "description" => r["description"].to_s[0..40], "created" => r["created_at"].to_s[0..18] } })
10
+ else
11
+ puts_json d
12
+ end
13
+ end
14
+ end
15
+
16
+ register "rollback", "Rollback to release (usage: wokku rollback APP RELEASE_ID)" do
17
+ id = ARGV.shift || abort("Usage: wokku rollback APP RELEASE_ID")
18
+ release_id = ARGV.shift || abort("Missing release ID")
19
+ api(:post, "/apps/#{id}/releases/#{release_id}/rollback")
20
+ Wokku::Output.status "Rolling back..."
21
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ # --- One-off command in an ephemeral container ---
4
+ register "run", "Run a one-off command (usage: wokku run APP -- COMMAND [ARGS...])" do
5
+ id = ARGV.shift || abort("Usage: wokku run APP -- COMMAND")
6
+ ARGV.shift if ARGV.first == "--"
7
+ cmd = ARGV.join(" ")
8
+ ARGV.clear
9
+ abort "Missing command. Example: wokku run APP -- bin/rails console" if cmd.strip.empty?
10
+ data = api(:post, "/apps/#{id}/run", { command: cmd })
11
+ puts data["output"] if data.is_a?(Hash) && data["output"]
12
+ exit (data["exit_code"] || 0).to_i if data.is_a?(Hash) && data.key?("exit_code")
13
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ # --- Servers ---
4
+ register "servers", "List available servers" do
5
+ data = api(:get, "/servers")
6
+ default_name = Wokku::Config.load["default_server"]
7
+ Wokku::Output.render(data) do |list|
8
+ table(list.map { |s| {
9
+ "name" => s["name"], "host" => s["host"], "status" => s["status"],
10
+ "default" => s["name"] == default_name ? "*" : ""
11
+ } })
12
+ end
13
+ end
14
+
15
+ register "servers:info", "Show server details (usage: wokku servers:info SERVER)" do
16
+ name = ARGV.shift || abort("Usage: wokku servers:info SERVER")
17
+ data = api(:get, "/servers/#{name}")
18
+ Wokku::Output.render(data) { |d| puts_json d }
19
+ end
20
+
21
+ register "servers:default", "Set the default server (usage: wokku servers:default SERVER | --clear)" do
22
+ arg = ARGV.shift || abort("Usage: wokku servers:default SERVER | --clear")
23
+ cfg = Wokku::Config.load
24
+ if arg == "--clear"
25
+ cfg.delete("default_server")
26
+ Wokku::Config.save(cfg)
27
+ Wokku::Output.status "Default server cleared."
28
+ else
29
+ api(:get, "/servers/#{arg}") # validates server exists; aborts on 404
30
+ cfg["default_server"] = arg
31
+ Wokku::Config.save(cfg)
32
+ Wokku::Output.status "Default server set to: #{arg}"
33
+ end
34
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ # --- SSH keys ---
4
+ register "ssh-keys", "List your SSH public keys" do
5
+ data = api(:get, "/ssh_keys")
6
+ Wokku::Output.render(data) do |list|
7
+ table(list.map { |k| {
8
+ "name" => k["name"],
9
+ "fingerprint" => "#{k['fingerprint'].to_s[0..30]}...",
10
+ "created" => k["created_at"].to_s[0..18]
11
+ } })
12
+ end
13
+ end
14
+
15
+ register "ssh-keys:add", "Upload an SSH public key (usage: wokku ssh-keys:add [PATH] --name LABEL)" do
16
+ path = nil
17
+ name = nil
18
+ while arg = ARGV.shift
19
+ case arg
20
+ when "--name" then name = ARGV.shift
21
+ else path ||= arg
22
+ end
23
+ end
24
+ path ||= File.expand_path("~/.ssh/id_ed25519.pub")
25
+ abort "Public key not found at #{path}. Pass an explicit path." unless File.exist?(path)
26
+ abort "Missing --name LABEL" unless name
27
+
28
+ public_key = File.read(path).strip
29
+ data = api(:post, "/ssh_keys", { name: name, public_key: public_key })
30
+ Wokku::Output.status "Added SSH key '#{data['name']}' (#{data['fingerprint'][0..20]}...)"
31
+ end
32
+
33
+ register "ssh-keys:remove", "Remove an SSH public key (usage: wokku ssh-keys:remove NAME | --fingerprint SHA — fingerprint takes precedence)" do
34
+ name = nil
35
+ fingerprint = nil
36
+ while arg = ARGV.shift
37
+ case arg
38
+ when "--fingerprint" then fingerprint = ARGV.shift
39
+ else name ||= arg
40
+ end
41
+ end
42
+ abort "Usage: wokku ssh-keys:remove NAME [--fingerprint SHA]" unless name || fingerprint
43
+
44
+ list = api(:get, "/ssh_keys")
45
+ match = if fingerprint
46
+ list.find { |k| k["fingerprint"] == fingerprint }
47
+ else
48
+ by_name = list.select { |k| k["name"] == name }
49
+ abort "No SSH key named '#{name}'" if by_name.empty?
50
+ abort "Multiple keys named '#{name}' — pass --fingerprint to disambiguate." if by_name.size > 1
51
+ by_name.first
52
+ end
53
+ abort "SSH key not found" unless match
54
+
55
+ api(:delete, "/ssh_keys/#{match['id']}")
56
+ Wokku::Output.status "Removed SSH key '#{match['name']}'"
57
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ # --- Storage (host-bind volumes) ---
4
+ register "storage", "List storage mounts (usage: wokku storage APP)" do
5
+ id = ARGV.shift || abort("Usage: wokku storage APP")
6
+ data = api(:get, "/apps/#{id}/storage")
7
+ Wokku::Output.render(data) do |d|
8
+ list = d.is_a?(Hash) ? Array(d["mounts"]) : Array(d)
9
+ if list.empty?
10
+ puts "(no mounts configured)"
11
+ else
12
+ list.each { |m| puts m }
13
+ end
14
+ end
15
+ end
16
+
17
+ register "storage:mount", "Add a host-bind mount (usage: wokku storage:mount APP HOST_PATH:CONTAINER_PATH)" do
18
+ id = ARGV.shift || abort("Usage: wokku storage:mount APP HOST_PATH:CONTAINER_PATH")
19
+ spec = ARGV.shift || abort("Missing HOST_PATH:CONTAINER_PATH")
20
+ abort "Mount must be HOST_PATH:CONTAINER_PATH" unless spec.include?(":")
21
+ api(:post, "/apps/#{id}/storage", { mount: spec })
22
+ Wokku::Output.status "Mounted: #{spec}"
23
+ Wokku::Output.status "Restart the app to pick up the mount: wokku ps:restart #{id}"
24
+ end
25
+
26
+ register "storage:unmount", "Remove a host-bind mount (usage: wokku storage:unmount APP HOST_PATH:CONTAINER_PATH)" do
27
+ id = ARGV.shift || abort("Usage: wokku storage:unmount APP HOST_PATH:CONTAINER_PATH")
28
+ spec = ARGV.shift || abort("Missing HOST_PATH:CONTAINER_PATH")
29
+ api(:delete, "/apps/#{id}/storage", { mount: spec })
30
+ Wokku::Output.status "Unmounted: #{spec}"
31
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ # --- Templates ---
4
+ register "templates", "List available templates" do
5
+ data = api(:get, "/templates")
6
+ Wokku::Output.render(data) do |d|
7
+ if d.is_a?(Array)
8
+ d.each { |t| puts "#{t['slug'] || t['name']} #{t['description'].to_s[0..60]}" }
9
+ else
10
+ puts_json d
11
+ end
12
+ end
13
+ end
14
+
15
+ register "deploy", "Deploy template (usage: wokku deploy TEMPLATE [--server SERVER] [--name NAME])" do
16
+ slug = ARGV.shift || abort("Usage: wokku deploy TEMPLATE_SLUG [--server SERVER]")
17
+ server = nil
18
+ name = nil
19
+ while arg = ARGV.shift
20
+ case arg
21
+ when "--server" then server = ARGV.shift
22
+ when "--name" then name = ARGV.shift
23
+ end
24
+ end
25
+ server = resolve_server(explicit: server)
26
+ body = { slug: slug, server_id: server }
27
+ body[:name] = name if name
28
+ data = api(:post, "/templates/deploy", body)
29
+ Wokku::Output.status "Deploying #{slug}..."
30
+ Wokku::Output.render(data) { |d| puts_json d }
31
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ register "version", "Show CLI version" do
4
+ Wokku::Output.render({ "version" => VERSION }) { |d| puts "wokku/#{d['version']}" }
5
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+
6
+ module Wokku
7
+ module Config
8
+ DEFAULT_DIR = File.expand_path("~/.wokku")
9
+
10
+ module_function
11
+
12
+ def dir
13
+ ENV["WOKKU_CONFIG_DIR"] || DEFAULT_DIR
14
+ end
15
+
16
+ def file
17
+ File.join(dir, "config.json")
18
+ end
19
+
20
+ def load
21
+ return {} unless File.exist?(file)
22
+ JSON.parse(File.read(file))
23
+ rescue StandardError
24
+ {}
25
+ end
26
+
27
+ def save(data)
28
+ FileUtils.mkdir_p(dir)
29
+ File.write(file, JSON.pretty_generate(data))
30
+ File.chmod(0600, file)
31
+ end
32
+
33
+ def api_url
34
+ load["api_url"] || ENV["WOKKU_API_URL"] || "https://wokku.cloud/api/v1"
35
+ end
36
+
37
+ def api_token
38
+ load["token"] || ENV["WOKKU_API_TOKEN"]
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Cross-command helpers. Defined as top-level methods (matching the
4
+ # api/table/puts_json pattern) so command blocks can call them without
5
+ # a namespace prefix. Loaded by cli/wokku at boot.
6
+
7
+ # Look up a domain row by hostname so users can pass the name they know
8
+ # rather than an internal id. Returns nil when not found or when the API
9
+ # returns a non-array (error envelope).
10
+ def find_domain(app_id, hostname)
11
+ list = api(:get, "/apps/#{app_id}/domains")
12
+ return nil unless list.is_a?(Array)
13
+ list.find { |d| d["hostname"] == hostname }
14
+ end
15
+
16
+ # Resolve which server to target for a deploy/create. Walks the
17
+ # precedence chain: explicit flag → env → config → auto-pick if
18
+ # only one server → abort with helpful error. Returns the server
19
+ # name or UUID (whatever was explicitly passed/configured).
20
+ def resolve_server(explicit: nil)
21
+ return explicit if explicit
22
+ env = ENV["WOKKU_DEFAULT_SERVER"]
23
+ return env if env && !env.empty?
24
+ cfg_default = Wokku::Config.load["default_server"]
25
+ return cfg_default if cfg_default
26
+
27
+ servers = api(:get, "/servers")
28
+ unless servers.is_a?(Array)
29
+ abort "Unexpected response from /servers: #{servers.inspect[0..200]}"
30
+ end
31
+ abort "No servers available. Contact wokku.cloud support." if servers.empty?
32
+ return servers.first["name"] if servers.size == 1
33
+
34
+ names = servers.map { |s| s["name"] }.join(", ")
35
+ abort "Multiple servers available (#{names}). Pass --server NAME or set a default: wokku servers:default NAME"
36
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Wokku
6
+ module Output
7
+ module_function
8
+
9
+ def table(rows, headers: nil)
10
+ return if rows.empty?
11
+ cols = (headers || rows.first.keys).map(&:to_s)
12
+ widths = cols.map(&:length)
13
+ rows.each { |r| cols.each_with_index { |c, i| widths[i] = [widths[i], r[c].to_s.length].max } }
14
+
15
+ fmt = widths.map { |w| "%-#{w}s" }.join(" ")
16
+ puts fmt % cols.map(&:upcase)
17
+ puts widths.map { |w| "-" * w }.join(" ")
18
+ rows.each { |r| puts fmt % cols.map { |c| r[c] } }
19
+ end
20
+
21
+ def puts_json(data)
22
+ puts JSON.pretty_generate(data)
23
+ end
24
+
25
+ # Smart printer for read commands: prints raw API response as JSON when
26
+ # the global --json flag is set; otherwise yields to the human formatter.
27
+ def render(data)
28
+ if Wokku.json?
29
+ puts JSON.pretty_generate(data)
30
+ else
31
+ yield data
32
+ end
33
+ end
34
+
35
+ # Suppressible status / hint message. Silenced by --quiet or --json.
36
+ # Errors should NOT use this — they go through `abort` which writes to
37
+ # stderr and is never silenced.
38
+ def status(message)
39
+ return if Wokku.quiet? || Wokku.json?
40
+ puts message
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wokku
4
+ module Registry
5
+ COMMANDS = {}
6
+
7
+ module_function
8
+
9
+ def register(name, desc, &block)
10
+ COMMANDS[name] = { desc: desc, handler: block }
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+ module Wokku
3
+ VERSION = "0.1.0"
4
+ end
data/lib/wokku.rb ADDED
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+ require "json"
3
+ require "net/http"
4
+ require "uri"
5
+ require "io/console"
6
+ require "fileutils"
7
+
8
+ require "wokku/version"
9
+ require "wokku/config"
10
+ require "wokku/output"
11
+ require "wokku/api_client"
12
+ require "wokku/registry"
13
+ require "wokku/helpers"
14
+
15
+ module Wokku
16
+ class << self
17
+ attr_writer :api_client
18
+ attr_accessor :json, :quiet
19
+
20
+ def api_client
21
+ @api_client ||= ApiClient.new(user_agent: "WokkuCLI/#{VERSION}")
22
+ end
23
+
24
+ def json? = !!@json
25
+ def quiet? = !!@quiet
26
+ end
27
+
28
+ module CLI
29
+ COMMANDS = Wokku::Registry::COMMANDS
30
+
31
+ module_function
32
+
33
+ def dispatch(args)
34
+ sep = args.index("--")
35
+ head = sep ? args[0...sep] : args.dup
36
+ tail = sep ? args[sep..] : []
37
+ Wokku.json = !!head.delete("--json")
38
+ Wokku.quiet = !!(head.delete("--quiet") || head.delete("-q"))
39
+ ARGV.replace(head + tail)
40
+ command = ARGV.shift
41
+
42
+ if command.nil? || command == "help" || command == "--help" || command == "-h"
43
+ show_help
44
+ return 0
45
+ elsif COMMANDS[command]
46
+ COMMANDS[command][:handler].call
47
+ return 0
48
+ else
49
+ puts "Unknown command: #{command}"
50
+ puts "Run 'wokku help' for available commands."
51
+ return 1
52
+ end
53
+ end
54
+
55
+ def show_help
56
+ puts "Wokku CLI v#{Wokku::VERSION}"
57
+ puts "Usage: wokku COMMAND [args] [--json] [--quiet]"
58
+ puts
59
+ puts "Commands:"
60
+ COMMANDS.sort_by { |k, _| k }.each do |name, cmd|
61
+ puts " %-20s %s" % [name, cmd[:desc]]
62
+ end
63
+ puts
64
+ puts "Global flags:"
65
+ puts " --json Machine-readable JSON output (read commands only)"
66
+ puts " --quiet Suppress success messages and hints (alias: -q)"
67
+ puts
68
+ puts "Run 'wokku auth:login' to get started."
69
+ end
70
+ end
71
+ end
72
+
73
+ # Top-level helper shims used by command blocks (preserved for compatibility
74
+ # with the existing register-DSL pattern).
75
+ def api(method, path, body = nil)
76
+ Wokku.api_client.request(method, path, body)
77
+ rescue Wokku::ApiClient::NotAuthenticated, Wokku::ApiClient::Timeout,
78
+ Wokku::ApiClient::Unreachable, Wokku::ApiClient::Error => e
79
+ abort e.message
80
+ end
81
+
82
+ def load_config = Wokku::Config.load
83
+ def save_config(data) = Wokku::Config.save(data)
84
+ def api_url = Wokku::Config.api_url
85
+ def api_token = Wokku::Config.api_token
86
+ def table(rows, headers: nil) = Wokku::Output.table(rows, headers: headers)
87
+ def puts_json(data) = Wokku::Output.puts_json(data)
88
+ def register(name, desc, &block) = Wokku::Registry.register(name, desc, &block)
89
+
90
+ CONFIG_DIR = Wokku::Config::DEFAULT_DIR
91
+ CONFIG_FILE = File.join(CONFIG_DIR, "config.json")
92
+ VERSION = Wokku::VERSION
93
+
94
+ # Load all command files (they register via the top-level `register` shim).
95
+ Dir[File.expand_path("wokku/commands/*.rb", __dir__)].sort.each { |f| require f }
metadata ADDED
@@ -0,0 +1,78 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: wokku-cli
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Johannes Dwicahyo
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: Deploy and manage apps, databases, domains, and SSH keys on Wokku.cloud
13
+ or self-hosted Wokku servers.
14
+ email:
15
+ - johannesdwicahyo@gmail.com
16
+ executables:
17
+ - wokku
18
+ extensions: []
19
+ extra_rdoc_files: []
20
+ files:
21
+ - CHANGELOG.md
22
+ - LICENSE
23
+ - README.md
24
+ - exe/wokku
25
+ - lib/wokku.rb
26
+ - lib/wokku/api_client.rb
27
+ - lib/wokku/commands/activity.rb
28
+ - lib/wokku/commands/addons.rb
29
+ - lib/wokku/commands/apps.rb
30
+ - lib/wokku/commands/auth.rb
31
+ - lib/wokku/commands/buildpacks.rb
32
+ - lib/wokku/commands/certs.rb
33
+ - lib/wokku/commands/checks.rb
34
+ - lib/wokku/commands/config.rb
35
+ - lib/wokku/commands/databases.rb
36
+ - lib/wokku/commands/do.rb
37
+ - lib/wokku/commands/domains.rb
38
+ - lib/wokku/commands/logs.rb
39
+ - lib/wokku/commands/ps.rb
40
+ - lib/wokku/commands/releases.rb
41
+ - lib/wokku/commands/run.rb
42
+ - lib/wokku/commands/servers.rb
43
+ - lib/wokku/commands/ssh_keys.rb
44
+ - lib/wokku/commands/storage.rb
45
+ - lib/wokku/commands/templates.rb
46
+ - lib/wokku/commands/version.rb
47
+ - lib/wokku/config.rb
48
+ - lib/wokku/helpers.rb
49
+ - lib/wokku/output.rb
50
+ - lib/wokku/registry.rb
51
+ - lib/wokku/version.rb
52
+ homepage: https://wokku.cloud
53
+ licenses:
54
+ - MIT
55
+ metadata:
56
+ homepage_uri: https://wokku.cloud
57
+ source_code_uri: https://github.com/johannesdwicahyo/wokku-cli
58
+ bug_tracker_uri: https://github.com/johannesdwicahyo/wokku-cli/issues
59
+ changelog_uri: https://github.com/johannesdwicahyo/wokku-cli/blob/main/CHANGELOG.md
60
+ rubygems_mfa_required: 'true'
61
+ rdoc_options: []
62
+ require_paths:
63
+ - lib
64
+ required_ruby_version: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: 3.2.0
69
+ required_rubygems_version: !ruby/object:Gem::Requirement
70
+ requirements:
71
+ - - ">="
72
+ - !ruby/object:Gem::Version
73
+ version: '0'
74
+ requirements: []
75
+ rubygems_version: 3.6.9
76
+ specification_version: 4
77
+ summary: Wokku CLI — manage your Wokku apps from the terminal
78
+ test_files: []