wokku-cli 0.6.0 → 0.8.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: 7ca5f8ef74b0d7c4991fde4d09e2fe741bd2925152b20a1337ca34b20f4a54cd
4
- data.tar.gz: 661e85e21edc58b048532701bc32f5e9ef062d5fc610898d9d18718d051b39b5
3
+ metadata.gz: cce452abf4b37774f8aef5230edb8e3a1b88150ed88f69f9409474db937af0e6
4
+ data.tar.gz: a4b224d6cbcfa18aa08b5cf427c2dcab36c0ba5b5cb161647ce94f5abc043cc9
5
5
  SHA512:
6
- metadata.gz: 73e67bb05dbbba4f756a0fc69312d8bebd626d826779a5496404363786b45121046a0cc4e669a635d51e527932d19bfb92aa70bdc376b1df7b864fc75a3f404b
7
- data.tar.gz: b08a864fce04de1fc2435c0aa071d4ce343aee48214b963f4cc3859c67e14fef3a08250631ab343aaee155ab1c5406613f990f2fc30fd5cc79f5cfe0454bd0d9
6
+ metadata.gz: 69815ec5eb9d7869c7214190df8423632c94ad0e0f2a953ed09f0d9b5936aea0b0c431cc4ea5c3b7f3dd2d46f8263ea43d4ded955239f1794c3cf4a91eed924a
7
+ data.tar.gz: b43e5e78b17056f2ca1e82cf9e6cf60dd1d8977478654260201f5bd043d7685ed9117b28029712e2d2f61fe82cffa6468590d356315c92cf7ab70783c98622db
data/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.0 — 2026-10-23
4
+
5
+ - `incidents [--all] [--app APP]`, `incidents:info ID` — what is down now and recent outages.
6
+ - `alerts:mutes`, `alerts:mute [--app APP] [--event EVENT] [--for 1h|8h|1d]`, `alerts:unmute ID` — per-person push mutes.
7
+ - Coverage gate no longer needs ignores for `dynos.update`/`dynos.replace`/`servers.dokku` (deprecated in the contract).
8
+
9
+ ## 0.7.0 — 2026-10-23
10
+
11
+ Full API parity for everything customers can do (CI now fails if the CLI falls behind the API contract).
12
+
13
+ - New: `deploys`, `deploys:info`, `deploy:tarball`, `releases:info`, `ps`, `ps:sizes`, `ps:dynos`, `ps:resize` (usage: `ps:resize APP TYPE --memory MB [--cpu N]`), `run:status`, `backups`, `backups:capture`, `backups:download`, `backups:restore`, `databases:import`, `addons:remove`, `apps:update`, `tokens`, `tokens:create`, `tokens:revoke`, `doctor`, `ai:diagnose`, `servers:status`, `templates:info`.
14
+ - Fixed: `wokku run` now waits for the command and exits with its exit code (it used to return immediately).
15
+ - Fixed: `wokku config` prints `KEY=value` (it printed the response wrapper); viewers see `(hidden — viewer)`.
16
+ - `auth:logout` also revokes the token on the server. It also signs out the MCP plugin (they share ~/.wokku/config.json).
17
+ - Retired: `billing:usage` (usage is per resource now; see `billing:plan`) and `addons:add` (use `addons:shared:enable` / `addons:dedicated:upgrade`).
18
+
3
19
  ## 0.6.0 — 2026-07-30
4
20
 
5
21
  ### Removed
@@ -47,6 +47,28 @@ module Wokku
47
47
  parse_response(resp)
48
48
  end
49
49
 
50
+ # Raw-body POST (databases.import). Streams the file — dumps can be GBs.
51
+ def upload(path, file_path)
52
+ @last_path = path
53
+ raise NotAuthenticated, "Not logged in. Run: wokku auth:login" unless token
54
+
55
+ uri = URI("#{url}#{path}")
56
+ http = Net::HTTP.new(uri.host, uri.port)
57
+ http.use_ssl = uri.scheme == "https"
58
+ http.open_timeout = OPEN_TIMEOUT
59
+ http.read_timeout = 900
60
+
61
+ req = Net::HTTP::Post.new(uri)
62
+ req["Authorization"] = "Bearer #{token}"
63
+ req["Content-Type"] = "application/octet-stream"
64
+ req["User-Agent"] = @user_agent
65
+ File.open(file_path, "rb") do |f|
66
+ req["Content-Length"] = f.size.to_s
67
+ req.body_stream = f
68
+ parse_response(perform(http, req, uri))
69
+ end
70
+ end
71
+
50
72
  def stream(method, path, &block)
51
73
  @last_path = path
52
74
  raise NotAuthenticated, "Not logged in. Run: wokku auth:login" unless token
@@ -20,17 +20,9 @@ register "addons", "List add-ons (usage: wokku addons APP)" do
20
20
  end
21
21
  end
22
22
 
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
24
- id = ARGV.shift || abort("Usage: wokku addons:add APP SERVICE_TYPE")
25
- service_type = ARGV.shift || abort("Missing service type (postgres, redis, mysql, etc)")
26
- name = nil
27
- while (arg = ARGV.shift)
28
- name = ARGV.shift if arg == "--name"
29
- end
30
- body = { service_type: service_type }
31
- body[:name] = name if name
32
- data = api(:post, "/apps/#{id}/addons", body)
33
- Wokku::Output.status "Added #{service_type}: #{data['name'] || data['id']}"
23
+ register "addons:add", "Retired — use addons:shared:enable or addons:dedicated:upgrade" do
24
+ ARGV.clear
25
+ abort "addons:add is retired — use addons:shared:enable APP ENGINE (free) or addons:dedicated:upgrade APP ENGINE"
34
26
  end
35
27
 
36
28
  # --- Bundle v2 — shared engines ---
@@ -78,3 +70,12 @@ register "addons:dedicated:upgrade", "Upgrade a box to a dedicated DB or Redis (
78
70
  data = api(:post, "/apps/#{id}/addons/dedicated", { engine: engine })
79
71
  Wokku::Output.status(data["message"] || "Dedicated #{engine} upgrade queued for #{id}")
80
72
  end
73
+
74
+ register "addons:remove", "Remove an add-on from an app (usage: wokku addons:remove APP ADDON_ID)" do
75
+ id = ARGV.shift || abort("Usage: wokku addons:remove APP ADDON_ID")
76
+ addon = ARGV.shift || abort("Missing ADDON_ID (see: wokku addons #{id})")
77
+ print "Are you sure you want to remove add-on #{addon} from #{id}? This destroys the database (a farewell snapshot is taken). (y/N): "
78
+ abort "Cancelled." unless $stdin.gets.to_s.strip.downcase == "y"
79
+ api(:delete, "/apps/#{id}/addons/#{addon}")
80
+ Wokku::Output.status "Add-on removed."
81
+ end
@@ -51,3 +51,16 @@ register "apps:destroy", "Delete app (usage: wokku apps:destroy APP)" do
51
51
  api(:delete, "/apps/#{id}")
52
52
  Wokku::Output.status "App deleted."
53
53
  end
54
+
55
+ register "apps:update", "Update app settings (usage: wokku apps:update APP [--branch BRANCH] [--name NAME])" do
56
+ id = ARGV.shift || abort("Usage: wokku apps:update APP [--branch BRANCH] [--name NAME]")
57
+ body = {}
58
+ { "--branch" => :deploy_branch, "--name" => :name }.each do |flag, key|
59
+ next unless (i = ARGV.index(flag))
60
+ body[key] = ARGV[i + 1] || abort("Missing value for #{flag}")
61
+ ARGV.slice!(i, 2)
62
+ end
63
+ abort "Nothing to update. Pass --branch and/or --name." if body.empty?
64
+ api(:patch, "/apps/#{id}", body)
65
+ Wokku::Output.status "App updated."
66
+ end
@@ -9,9 +9,17 @@ register "auth:login", "Authenticate with Wokku via browser device flow" do
9
9
  Wokku::Auth.login_with_device_flow!(Wokku::Config.api_url)
10
10
  end
11
11
 
12
- register "auth:logout", "Log out" do
12
+ register "auth:logout", "Log out (revokes this token on the server)" do
13
13
  path = Wokku::Config.file
14
14
  if File.exist?(path)
15
+ begin
16
+ # covers: api(:delete, "/auth/logout")
17
+ # only revoke a token this file holds, never an env-provided (CI) one
18
+ Wokku.api_client.request(:delete, "/auth/logout") if Wokku::Config.load["token"]
19
+ rescue Wokku::ApiClient::NotAuthenticated, Wokku::ApiClient::Timeout,
20
+ Wokku::ApiClient::Unreachable, Wokku::ApiClient::Error
21
+ nil # best effort: always clear the local login
22
+ end
15
23
  File.delete(path)
16
24
  Wokku::Output.status "Logged out."
17
25
  else
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ # --- Database backups ---
4
+ register "backups", "List backups (usage: wokku backups DB)" do
5
+ db = ARGV.shift || abort("Usage: wokku backups DB")
6
+ data = api(:get, "/databases/#{db}/backups")
7
+ Wokku::Output.render(data) do |d|
8
+ next puts_json(d) unless d.is_a?(Array)
9
+ table(d.map { |b| { "id" => b["id"], "status" => b["status"], "size" => b["size_bytes"], "created" => b["created_at"].to_s[0..18] } })
10
+ end
11
+ end
12
+
13
+ register "backups:capture", "Start a backup now (usage: wokku backups:capture DB)" do
14
+ db = ARGV.shift || abort("Usage: wokku backups:capture DB")
15
+ data = api(:post, "/databases/#{db}/backups")
16
+ Wokku::Output.status "Backup started#{data.is_a?(Hash) && data['id'] ? " (#{data['id']})" : ''}."
17
+ end
18
+
19
+ register "backups:download", "Download a backup, or print its URL (usage: wokku backups:download DB BACKUP_ID [FILE])" do
20
+ db = ARGV.shift || abort("Usage: wokku backups:download DB BACKUP_ID [FILE]")
21
+ backup_id = ARGV.shift || abort("Missing BACKUP_ID")
22
+ out = ARGV.shift
23
+ data = api(:get, "/databases/#{db}/backups/#{backup_id}/download")
24
+ url = data.is_a?(Hash) && data["url"]
25
+ abort "Unexpected response: #{data.inspect[0..200]}" unless url
26
+ next puts(url) unless out
27
+
28
+ uri = URI(url)
29
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
30
+ http.request(Net::HTTP::Get.new(uri)) do |resp|
31
+ abort "Download failed: HTTP #{resp.code}" unless resp.is_a?(Net::HTTPSuccess)
32
+ File.open(out, "wb") { |f| resp.read_body { |chunk| f.write(chunk) } }
33
+ end
34
+ end
35
+ Wokku::Output.status "Saved #{out}"
36
+ end
37
+
38
+ register "backups:restore", "Restore a backup — overwrites the database (usage: wokku backups:restore DB BACKUP_ID)" do
39
+ db = ARGV.shift || abort("Usage: wokku backups:restore DB BACKUP_ID")
40
+ backup_id = ARGV.shift || abort("Missing BACKUP_ID")
41
+ print "Are you sure you want to overwrite #{db} with backup #{backup_id}? (y/N): "
42
+ abort "Cancelled." unless $stdin.gets.to_s.strip.downcase == "y"
43
+ data = api(:post, "/databases/#{db}/backups/#{backup_id}/restore")
44
+ Wokku::Output.status((data.is_a?(Hash) && data["message"]) || "Restore requested.")
45
+ end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # --- Billing (plan + usage; checkout/portal stay in the dashboard) ---
3
+ # --- Billing (plan; checkout/portal stay in the dashboard) ---
4
4
 
5
5
  register "billing:plan", "Show your current Wokku plan + limits (usage: wokku billing:plan)" do
6
6
  data = api(:get, "/billing/current_plan")
@@ -10,21 +10,6 @@ register "billing:plan", "Show your current Wokku plan + limits (usage: wokku bi
10
10
  Wokku::Output.status "max databases: #{data['max_databases']}" if data.key?("max_databases")
11
11
  end
12
12
 
13
- register "billing:usage", "Show month-to-date usage (usage: wokku billing:usage)" do
14
- data = api(:get, "/billing/usage")
15
- data = {} unless data.is_a?(Hash)
16
-
17
- Wokku::Output.status "current: $#{format('%.2f', data['current_cost_dollars'] || 0.0)}"
18
- Wokku::Output.status "projected: $#{format('%.2f', data['projected_cost_dollars'] || 0.0)}"
19
- Wokku::Output.status "payment method: #{data['has_payment_method'] ? 'yes' : 'no'}"
20
-
21
- resources = Array(data["resources"])
22
- if resources.any?
23
- Wokku::Output.status ""
24
- Wokku::Output.status "resources:"
25
- resources.each do |r|
26
- cents = r["current_cost_cents"].to_i
27
- Wokku::Output.status " #{r['resource_type']} #{r['tier_name']} $#{format('%.2f', cents / 100.0)}"
28
- end
29
- end
13
+ register "billing:usage", "Retired — usage is now per resource (see billing:plan)" do
14
+ abort "billing:usage is retired — usage is now per resource; see wokku billing:plan or the dashboard"
30
15
  end
@@ -5,11 +5,9 @@ register "config", "Show config vars (usage: wokku config APP)" do
5
5
  id = ARGV.shift || abort("Usage: wokku config APP")
6
6
  data = api(:get, "/apps/#{id}/config")
7
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
8
+ next puts_json(d) unless d.is_a?(Hash)
9
+ vars = d["config"] || d
10
+ vars.each { |k, v| puts(d["masked"] && v.nil? ? "#{k}=(hidden — viewer)" : "#{k}=#{v}") }
13
11
  end
14
12
  end
15
13
 
@@ -35,6 +33,7 @@ register "config:get", "Show one config var (usage: wokku config:get APP KEY)" d
35
33
  key = ARGV.shift || abort("Missing KEY")
36
34
  data = api(:get, "/apps/#{id}/config")
37
35
  vars = data.is_a?(Hash) ? (data["config"] || data) : {}
36
+ abort "Values are hidden for viewers — ask a team member or admin." if data.is_a?(Hash) && data["masked"]
38
37
  abort "Key not set: #{key}" unless vars.key?(key)
39
38
  Wokku::Output.render({ "key" => key, "value" => vars[key] }) { |_| puts vars[key] }
40
39
  end
@@ -42,6 +41,7 @@ end
42
41
  register "config:export", "Export config as .env (usage: wokku config:export APP)" do
43
42
  id = ARGV.shift || abort("Usage: wokku config:export APP")
44
43
  data = api(:get, "/apps/#{id}/config")
44
+ abort "Values are hidden for viewers — ask a team member or admin." if data.is_a?(Hash) && data["masked"]
45
45
  vars = data.is_a?(Hash) ? (data["config"] || data) : {}
46
46
  Wokku::Output.render(vars) do |d|
47
47
  d.each do |k, v|
@@ -81,3 +81,13 @@ register "databases:unlink", "Unlink a database from an app (usage: wokku databa
81
81
  api(:post, "/databases/#{db_name}/unlink", { app_id: app })
82
82
  Wokku::Output.status "Unlinked '#{db_name}' from '#{app}'."
83
83
  end
84
+
85
+ register "databases:import", "Import a dump — overwrites the database (usage: wokku databases:import DB FILE)" do
86
+ db = ARGV.shift || abort("Usage: wokku databases:import DB FILE")
87
+ file = ARGV.shift || abort("Missing FILE (e.g. a pg_dump -Fc dump)")
88
+ abort "File not found: #{file}" unless File.file?(file)
89
+ print "Are you sure you want to overwrite #{db} with #{File.basename(file)}? (y/N): "
90
+ abort "Cancelled." unless $stdin.gets.to_s.strip.downcase == "y"
91
+ data = api_upload(:post, "/databases/#{db}/import", file)
92
+ Wokku::Output.status(data.is_a?(Hash) && data["message"] ? data["message"] : "Imported.")
93
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tmpdir"
4
+
5
+ # --- Deploys ---
6
+ register "deploys", "List deploys (usage: wokku deploys APP)" do
7
+ id = ARGV.shift || abort("Usage: wokku deploys APP")
8
+ data = api(:get, "/apps/#{id}/deploys")
9
+ Wokku::Output.render(data) do |d|
10
+ next puts_json(d) unless d.is_a?(Array)
11
+ table(d.map { |x| { "id" => x["id"], "status" => x["status"], "commit" => x["commit_sha"].to_s[0, 7], "created" => x["created_at"].to_s[0..18] } })
12
+ end
13
+ end
14
+
15
+ register "deploys:info", "Show a deploy and its build log (usage: wokku deploys:info APP DEPLOY_ID)" do
16
+ id = ARGV.shift || abort("Usage: wokku deploys:info APP DEPLOY_ID")
17
+ deploy_id = ARGV.shift || abort("Missing DEPLOY_ID")
18
+ data = api(:get, "/apps/#{id}/deploys/#{deploy_id}")
19
+ Wokku::Output.render(data) do |d|
20
+ next puts_json(d) unless d.is_a?(Hash)
21
+ puts "status: #{d['status']}"
22
+ puts d["log"] if d["log"]
23
+ end
24
+ end
25
+
26
+ TERMINAL_DEPLOY_STATUSES = %w[succeeded failed timed_out].freeze
27
+
28
+ register "deploy:tarball", "Deploy a local directory without git push (usage: wokku deploy:tarball APP [DIR])" do
29
+ id = ARGV.shift || abort("Usage: wokku deploy:tarball APP [DIR]")
30
+ dir = File.expand_path(ARGV.shift || ".")
31
+ abort "Not a directory: #{dir}" unless Dir.exist?(dir)
32
+ if Dir.exist?(File.join(dir, ".git")) && !IO.popen([ "git", "-C", dir, "status", "--porcelain" ], &:read).strip.empty?
33
+ $stderr.puts "warning: uncommitted changes are not deployed (git archive uses HEAD)"
34
+ end
35
+ Dir.mktmpdir do |tmp|
36
+ archive = File.join(tmp, "source.tar.gz")
37
+ build_tarball(dir, archive)
38
+ slot = api(:post, "/apps/#{id}/deploys/uploads")
39
+ upload_presigned(slot["upload_url"], archive)
40
+ dep = api(:post, "/apps/#{id}/deploys", { archive_key: slot["archive_key"] })
41
+ Wokku::Output.status "Build queued (#{dep['deploy_id']}). Following..."
42
+ final = poll_until("/apps/#{id}/deploys/#{dep['deploy_id']}", attempts: 320, interval: 3) { |d| d.is_a?(Hash) && TERMINAL_DEPLOY_STATUSES.include?(d["status"].to_s) }
43
+ abort "Still building after 16 minutes. Check: wokku deploys:info #{id} #{dep['deploy_id']}" unless final
44
+ Wokku::Output.status "Deploy #{final['status']}."
45
+ exit 1 unless final["status"] == "succeeded"
46
+ end
47
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ # --- Diagnostics ---
4
+ register "doctor", "Check auth, client version, host health and app state (usage: wokku doctor)" do
5
+ data = api(:get, "/doctor")
6
+ Wokku::Output.render(data) { |d| puts_json d }
7
+ end
8
+
9
+ register "ai:diagnose", "AI diagnosis of a failed deploy (usage: wokku ai:diagnose APP [DEPLOY_ID])" do
10
+ id = ARGV.shift || abort("Usage: wokku ai:diagnose APP [DEPLOY_ID]")
11
+ deploy_id = ARGV.shift
12
+ unless deploy_id
13
+ deploys = api(:get, "/apps/#{id}/deploys")
14
+ deploy_id = Array(deploys).first&.dig("id") || abort("No deploys for #{id}")
15
+ end
16
+ data = api(:post, "/ai/diagnose", { deploy_id: deploy_id })
17
+ Wokku::Output.render(data) { |d| d.is_a?(Hash) && d["diagnosis"] ? puts(d["diagnosis"]) : puts_json(d) }
18
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require "uri"
5
+
6
+ # --- Incidents & alert mutes ---
7
+ # Command files are required at top level, so helper names are prefixed to stay out of the global namespace.
8
+ def incidents_rows(list)
9
+ list.map do |x|
10
+ { "id" => x["id"], "subject" => x["app_name"] || "server #{x['server_id']}", "reason" => x["reason"],
11
+ "started" => x["started_at"].to_s[0..18], "status" => x["open"] ? "OPEN" : "resolved after #{x['duration_seconds']}s" }
12
+ end
13
+ end
14
+
15
+ def incidents_flag_value(name)
16
+ i = ARGV.index(name) or return nil
17
+ ARGV.delete_at(i)
18
+ ARGV.delete_at(i) || abort("Missing value for #{name}")
19
+ end
20
+
21
+ register "incidents", "List incidents (usage: wokku incidents [--all] [--app APP])" do
22
+ query = { state: ARGV.delete("--all") ? "all" : "open" }
23
+ app = incidents_flag_value("--app")
24
+ query[:app_id] = app if app
25
+ data = api(:get, "/incidents?#{URI.encode_www_form(query)}")
26
+ Wokku::Output.render(data) do |d|
27
+ next puts_json(d) unless d.is_a?(Array)
28
+ d.empty? ? puts("No incidents.") : table(incidents_rows(d))
29
+ end
30
+ end
31
+
32
+ register "incidents:info", "Show one incident (usage: wokku incidents:info ID)" do
33
+ id = ARGV.shift || abort("Usage: wokku incidents:info ID")
34
+ data = api(:get, "/incidents/#{id}")
35
+ Wokku::Output.render(data) { |d| puts_json d }
36
+ end
37
+
38
+ register "alerts:mutes", "List your push alert mutes (usage: wokku alerts:mutes)" do
39
+ data = api(:get, "/alert_mutes")
40
+ Wokku::Output.render(data) do |d|
41
+ next puts_json(d) unless d.is_a?(Array)
42
+ next puts("No mutes.") if d.empty?
43
+ table(d.map { |m| { "id" => m["id"], "app" => m["app_name"] || "(all apps)", "event" => m["event"] || "(all events)", "until" => m["until"].to_s[0..18].then { |u| u.empty? ? "(until unmuted)" : u } } })
44
+ end
45
+ end
46
+
47
+ MUTE_UNITS = { "m" => 60, "h" => 3600, "d" => 86_400 }.freeze
48
+
49
+ register "alerts:mute", "Mute push alerts on your phone (usage: wokku alerts:mute [--app APP] [--event EVENT] [--for 1h|8h|1d])" do
50
+ usage = "Usage: wokku alerts:mute [--app APP] [--event EVENT] [--for 1h|8h|1d]"
51
+ body = { app_id: incidents_flag_value("--app"), event: incidents_flag_value("--event") }.compact
52
+ abort "#{usage} (give --app and/or --event)" if body.empty?
53
+ if (span = incidents_flag_value("--for"))
54
+ m = span.match(/\A(\d+)([mhd])\z/) || abort("#{usage} (--for looks like 30m, 8h or 1d)")
55
+ body[:until] = (Time.now + m[1].to_i * MUTE_UNITS[m[2]]).utc.iso8601
56
+ end
57
+ data = api(:post, "/alert_mutes", body)
58
+ Wokku::Output.render(data) { |d| Wokku::Output.status "Muted (#{d['id']})." }
59
+ end
60
+
61
+ register "alerts:unmute", "Remove a push alert mute (usage: wokku alerts:unmute ID)" do
62
+ id = ARGV.shift || abort("Usage: wokku alerts:unmute ID")
63
+ api(:delete, "/alert_mutes/#{id}")
64
+ Wokku::Output.status "Unmuted."
65
+ end
@@ -44,3 +44,41 @@ register "ps:scale", "Scale processes (usage: wokku ps:scale APP web=2 worker=1)
44
44
  api(:patch, "/apps/#{id}/ps", { scaling: scaling })
45
45
  Wokku::Output.status "Scaled: #{scaling.map { |t, c| "#{t}=#{c}" }.join(' ')}"
46
46
  end
47
+
48
+ register "ps", "Show process formation (usage: wokku ps APP)" do
49
+ id = ARGV.shift || abort("Usage: wokku ps APP")
50
+ data = api(:get, "/apps/#{id}/ps")
51
+ Wokku::Output.render(data) { |d| d.is_a?(Hash) && d["processes"].is_a?(Hash) ? d["processes"].each { |k, v| puts "#{k}: #{v}" } : puts_json(d) }
52
+ end
53
+
54
+ register "ps:sizes", "List box sizes (usage: wokku ps:sizes)" do
55
+ data = api(:get, "/dyno_tiers")
56
+ Wokku::Output.render(data) { |d| d.is_a?(Array) ? table(d) : puts_json(d) }
57
+ end
58
+
59
+ register "ps:dynos", "Show process allocations and box sizes (usage: wokku ps:dynos APP)" do
60
+ id = ARGV.shift || abort("Usage: wokku ps:dynos APP")
61
+ data = api(:get, "/apps/#{id}/dynos")
62
+ Wokku::Output.render(data) do |d|
63
+ next puts_json(d) unless d.is_a?(Array)
64
+ table(d.map { |a| { "type" => a["process_type"], "count" => a["count"], "size" => a["size_label"], "monthly" => format("%.2f", a["monthly_cost_cents"].to_i / 100.0) } })
65
+ end
66
+ end
67
+
68
+ register "ps:resize", "Change a process type's size (usage: wokku ps:resize APP TYPE --memory MB [--cpu N])" do
69
+ usage = "Usage: wokku ps:resize APP TYPE --memory MB [--cpu N]"
70
+ shape = {}
71
+ if (i = ARGV.index("--memory"))
72
+ shape[:memory_mb] = Integer(ARGV[i + 1] || abort("Missing value for --memory"))
73
+ ARGV.slice!(i, 2)
74
+ end
75
+ if (i = ARGV.index("--cpu"))
76
+ shape[:cpu] = Float(ARGV[i + 1] || abort("Missing value for --cpu"))
77
+ ARGV.slice!(i, 2)
78
+ end
79
+ id = ARGV.shift || abort(usage)
80
+ type = ARGV.shift || abort(usage)
81
+ abort "#{usage} (give --memory and/or --cpu; see: wokku ps:sizes)" if shape.empty?
82
+ api(:patch, "/apps/#{id}/ps", { resources: { type => shape } })
83
+ Wokku::Output.status "Resizing #{type}: #{shape.map { |k, v| "#{k}=#{v}" }.join(' ')}; applied in the background."
84
+ end
@@ -19,3 +19,10 @@ register "rollback", "Rollback to release (usage: wokku rollback APP RELEASE_ID)
19
19
  api(:post, "/apps/#{id}/releases/#{release_id}/rollback")
20
20
  Wokku::Output.status "Rolling back..."
21
21
  end
22
+
23
+ register "releases:info", "Show one release (usage: wokku releases:info APP RELEASE_ID)" do
24
+ id = ARGV.shift || abort("Usage: wokku releases:info APP RELEASE_ID")
25
+ release_id = ARGV.shift || abort("Missing RELEASE_ID")
26
+ data = api(:get, "/apps/#{id}/releases/#{release_id}")
27
+ Wokku::Output.render(data) { |d| puts_json d }
28
+ end
@@ -1,13 +1,27 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # --- One-off command in an ephemeral container ---
4
- register "run", "Run a one-off command (usage: wokku run APP -- COMMAND [ARGS...])" do
4
+ # apps.run is async (202 {run_id}); poll runs.show to a terminal status.
5
+ register "run", "Run a one-off command (usage: wokku run APP -- COMMAND [ARGS...], e.g. wokku run APP -- bin/rails db:migrate)" do
5
6
  id = ARGV.shift || abort("Usage: wokku run APP -- COMMAND")
6
7
  ARGV.shift if ARGV.first == "--"
7
8
  cmd = ARGV.join(" ")
8
9
  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")
10
+ abort "Missing command. Example: wokku run APP -- bin/rails db:migrate" if cmd.strip.empty?
11
+ started = api(:post, "/apps/#{id}/run", { command: cmd })
12
+ run_id = started.is_a?(Hash) && started["run_id"]
13
+ abort "Unexpected response: #{started.inspect[0..200]}" unless run_id
14
+ $stderr.puts "Run #{run_id} started — check later with: wokku run:status #{id} #{run_id}"
15
+ final = poll_until("/apps/#{id}/runs/#{run_id}", attempts: 300, interval: 2) { |d| d.is_a?(Hash) && %w[succeeded failed].include?(d["status"].to_s) }
16
+ abort "Still running after 10 minutes. Check: wokku run:status #{id} #{run_id}" unless final
17
+ puts final["output"] if final["output"]
18
+ $stderr.puts final["error"] if final["error"]
19
+ exit (final["exit_code"] || (final["status"] == "succeeded" ? 0 : 1)).to_i
20
+ end
21
+
22
+ register "run:status", "Show a one-off run (usage: wokku run:status APP RUN_ID)" do
23
+ id = ARGV.shift || abort("Usage: wokku run:status APP RUN_ID")
24
+ run_id = ARGV.shift || abort("Missing RUN_ID")
25
+ data = api(:get, "/apps/#{id}/runs/#{run_id}")
26
+ Wokku::Output.render(data) { |d| puts_json d }
13
27
  end
@@ -32,3 +32,9 @@ register "servers:default", "Set the default server (usage: wokku servers:defaul
32
32
  Wokku::Output.status "Default server set to: #{arg}"
33
33
  end
34
34
  end
35
+
36
+ register "servers:status", "Show server health (usage: wokku servers:status SERVER)" do
37
+ server = ARGV.shift || abort("Usage: wokku servers:status SERVER")
38
+ data = api(:get, "/servers/#{server}/status")
39
+ Wokku::Output.render(data) { |d| puts_json d }
40
+ end
@@ -29,3 +29,9 @@ register "deploy", "Deploy template (usage: wokku deploy TEMPLATE [--server SERV
29
29
  Wokku::Output.status "Deploying #{slug}..."
30
30
  Wokku::Output.render(data) { |d| puts_json d }
31
31
  end
32
+
33
+ register "templates:info", "Show a template (usage: wokku templates:info SLUG)" do
34
+ slug = ARGV.shift || abort("Usage: wokku templates:info SLUG")
35
+ data = api(:get, "/templates/#{slug}")
36
+ Wokku::Output.render(data) { |d| puts_json d }
37
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ # --- API tokens ---
4
+ register "tokens", "List API tokens (usage: wokku tokens)" do
5
+ data = api(:get, "/auth/tokens")
6
+ Wokku::Output.render(data) do |d|
7
+ next puts_json(d) unless d.is_a?(Array)
8
+ table(d.map { |t| { "id" => t["id"], "name" => t["name"], "last_used" => t["last_used_at"].to_s[0..18], "expires" => t["expires_at"].to_s[0..18] } })
9
+ end
10
+ end
11
+
12
+ register "tokens:create", "Create an API token (usage: wokku tokens:create NAME)" do
13
+ name = ARGV.shift || abort("Usage: wokku tokens:create NAME")
14
+ data = api(:post, "/auth/tokens", { name: name })
15
+ Wokku::Output.render(data) do |d|
16
+ puts d["token"]
17
+ Wokku::Output.status "Copy it now — it won't be shown again."
18
+ end
19
+ end
20
+
21
+ register "tokens:revoke", "Revoke an API token (usage: wokku tokens:revoke TOKEN_ID)" do
22
+ token_id = ARGV.shift || abort("Usage: wokku tokens:revoke TOKEN_ID")
23
+ api(:delete, "/auth/tokens/#{token_id}")
24
+ Wokku::Output.status "Token revoked."
25
+ end
data/lib/wokku/helpers.rb CHANGED
@@ -34,3 +34,31 @@ def resolve_server(explicit: nil)
34
34
  names = servers.map { |s| s["name"] }.join(", ")
35
35
  abort "Multiple servers available (#{names}). Pass --server NAME or set a default: wokku servers:default NAME"
36
36
  end
37
+
38
+ # PUT a file to a presigned bucket URL. No Authorization header: the
39
+ # signature in the URL is the credential and an extra header breaks it.
40
+ def upload_presigned(url, file_path)
41
+ uri = URI(url)
42
+ http = Net::HTTP.new(uri.host, uri.port)
43
+ http.use_ssl = uri.scheme == "https"
44
+ http.read_timeout = 300
45
+ req = Net::HTTP::Put.new(uri)
46
+ req["Content-Type"] = "application/gzip"
47
+ req.body = File.binread(file_path)
48
+ resp = http.request(req)
49
+ abort "Upload failed: HTTP #{resp.code}" unless resp.is_a?(Net::HTTPSuccess)
50
+ true
51
+ end
52
+
53
+ # .tar.gz of dir: committed files only for git repos, otherwise everything
54
+ # except .git/node_modules/tmp/log. COPYFILE_DISABLE stops macOS tar from
55
+ # adding ._ AppleDouble files that break builds.
56
+ def build_tarball(dir, archive_path)
57
+ ok = if Dir.exist?(File.join(dir, ".git"))
58
+ system("git", "-C", dir, "archive", "--format=tar.gz", "--output=#{archive_path}", "HEAD")
59
+ else
60
+ system({ "COPYFILE_DISABLE" => "1" }, "tar", "-czf", archive_path,
61
+ "--exclude=.git", "--exclude=node_modules", "--exclude=tmp", "--exclude=log", "-C", dir, ".")
62
+ end
63
+ abort "Could not archive #{dir}" unless ok
64
+ end
data/lib/wokku/version.rb CHANGED
@@ -1,4 +1,4 @@
1
1
  # frozen_string_literal: true
2
2
  module Wokku
3
- VERSION = "0.6.0"
3
+ VERSION = "0.8.0"
4
4
  end
data/lib/wokku.rb CHANGED
@@ -80,6 +80,15 @@ rescue Wokku::ApiClient::NotAuthenticated, Wokku::ApiClient::Timeout,
80
80
  abort e.message
81
81
  end
82
82
 
83
+ def api_upload(method, path, file_path)
84
+ abort "uploads are POST only" unless method == :post
85
+ abort "File not found: #{file_path}" unless File.file?(file_path.to_s)
86
+ Wokku.api_client.upload(path, file_path)
87
+ rescue Wokku::ApiClient::NotAuthenticated, Wokku::ApiClient::Timeout,
88
+ Wokku::ApiClient::Unreachable, Wokku::ApiClient::Error => e
89
+ abort e.message
90
+ end
91
+
83
92
  # Poll GET <path> until the block returns truthy (returns that data) or the
84
93
  # attempt budget is exhausted (returns nil). interval is injectable so specs
85
94
  # can run with no real sleep.
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: wokku-cli
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Johannes Dwicahyo
@@ -45,6 +45,7 @@ files:
45
45
  - lib/wokku/commands/apps.rb
46
46
  - lib/wokku/commands/apps_lifecycle.rb
47
47
  - lib/wokku/commands/auth.rb
48
+ - lib/wokku/commands/backups.rb
48
49
  - lib/wokku/commands/billing.rb
49
50
  - lib/wokku/commands/cdn.rb
50
51
  - lib/wokku/commands/certs.rb
@@ -53,10 +54,13 @@ files:
53
54
  - lib/wokku/commands/cron.rb
54
55
  - lib/wokku/commands/databases.rb
55
56
  - lib/wokku/commands/db_monitor.rb
57
+ - lib/wokku/commands/deploys.rb
56
58
  - lib/wokku/commands/dev.rb
59
+ - lib/wokku/commands/doctor.rb
57
60
  - lib/wokku/commands/domains.rb
58
61
  - lib/wokku/commands/github.rb
59
62
  - lib/wokku/commands/https.rb
63
+ - lib/wokku/commands/incidents.rb
60
64
  - lib/wokku/commands/logs.rb
61
65
  - lib/wokku/commands/maintenance.rb
62
66
  - lib/wokku/commands/mcp.rb
@@ -72,6 +76,7 @@ files:
72
76
  - lib/wokku/commands/ssh_keys.rb
73
77
  - lib/wokku/commands/teams.rb
74
78
  - lib/wokku/commands/templates.rb
79
+ - lib/wokku/commands/tokens.rb
75
80
  - lib/wokku/commands/tunnel.rb
76
81
  - lib/wokku/commands/version.rb
77
82
  - lib/wokku/commands/vitals.rb