wokku-cli 0.4.0 → 0.5.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: 820e03562fff67b93f087227eb2da57e79e761c43ac5c108680699ce068a480e
4
- data.tar.gz: '077218e9886faadb334a615e225a3f61802688f29f6e9ac53625ca4f505f632d'
3
+ metadata.gz: 07765bcc128b05f4309dcba3b0b5ac7679a67ecb7db3da6bac86b6ebe947b44d
4
+ data.tar.gz: 466a009f4a186f7e621e16fbb74bae021b150a0a7bdef4f5f3e22eba417620f5
5
5
  SHA512:
6
- metadata.gz: 9518d6c33f111278305093a0d55f925772fe3363f4abe95df3d32b83e105d03f3e0bd2001add8f696c717d5e89352926fec2c1220fe7cb74baebc523a8c6812d
7
- data.tar.gz: 1326795840aae9837bb480a821e466ce8c64e90a0fc5ed2f01c8929b7adb16da27760aa7d3c459fc65cf60afd18ac5de006be0e790d639af8eb6e09296ae9dcd
6
+ metadata.gz: e3e6bbadba6e52c20b843542879100f0d4d40428c4b1a3f877c171c6f84452ff67ffc0bce4a15dfe182115bc32b1b7c5ea44000d8d88559c41b6e69c24bcdfde
7
+ data.tar.gz: '058f35efb1dd358b5183169fbdb35ef5ce7ee68ab708576cbed8ee9b03d01a896d9c465fed9cf53d655283bc4fa3621b944f22117825e7efe9fa87c61ed245e0'
data/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.0 — 2026-06-04
4
+
5
+ `wokku tunnel` — share a local port at `https://<sub>.wokku.dev` in one command. Pairs with wokku-cloud canonical backlog 3.4.
6
+
7
+ ### Added
8
+
9
+ - `wokku tunnel PORT [--subdomain S] [--app A]` — provisions a tunnel session via `POST /api/v1/tunnels`, downloads `frpc` v0.62.1 to `~/.wokku/bin/` on first run (~10 MB, one time), writes a per-process `frpc.toml`, execs frpc against the wokku-tunnel gateway, and closes the session on Ctrl-C.
10
+
11
+ Plan-tiered: Free gets 1 tunnel with a random subdomain and 2h timeout (watermarked); Solo+ unlocks custom subdomains and 3+ concurrent tunnels with no timeout. Server-side enforcement — the CLI just surfaces whichever the API returned.
12
+
3
13
  ## 0.4.0 — 2026-06-01
4
14
 
5
15
  MCP commands — wire the wokku-plugin Claude Code MCP server with one command. Pairs with wokku-cloud canonical backlog 1.3.
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ # --- App lifecycle (rename / clone / lock / unlock / transfer) ---
4
+
5
+ register "apps:rename", "Rename an app (usage: wokku apps:rename APP NEW_NAME [--force])" do
6
+ id = ARGV.shift || abort("Usage: wokku apps:rename APP NEW_NAME [--force]")
7
+ new_name = ARGV.shift || abort("Missing NEW_NAME. Example: wokku apps:rename myapp myapp-renamed")
8
+ force = ARGV.delete("--force")
9
+ unless force || !$stdout.tty?
10
+ print "Rename `#{id}` to `#{new_name}`? This breaks deployed bookmarks. [y/N] "
11
+ answer = $stdin.gets.to_s.strip.downcase
12
+ abort "Aborted." unless answer == "y"
13
+ end
14
+ data = api(:patch, "/apps/#{id}/rename", { name: new_name })
15
+ Wokku::Output.status "Renamed to #{(data.is_a?(Hash) ? data['name'] : new_name)}."
16
+ end
17
+
18
+ register "apps:clone", "Clone an app config (no env vars/data carried). Usage: wokku apps:clone APP NEW_NAME [--no-skip-deploy]" do
19
+ id = ARGV.shift || abort("Usage: wokku apps:clone APP NEW_NAME [--no-skip-deploy]")
20
+ new_name = ARGV.shift || abort("Missing NEW_NAME. Example: wokku apps:clone myapp myapp-copy")
21
+ skip_deploy = !ARGV.delete("--no-skip-deploy")
22
+ data = api(:post, "/apps/#{id}/clone", { name: new_name, skip_deploy: skip_deploy })
23
+ Wokku::Output.status "Cloned to #{(data.is_a?(Hash) ? data['name'] : new_name)}. Push code to its remote to deploy."
24
+ end
25
+
26
+ register "apps:lock", "Set the dokku deploy lock (blocks future git push). Usage: wokku apps:lock APP" do
27
+ id = ARGV.shift || abort("Usage: wokku apps:lock APP")
28
+ data = api(:post, "/apps/#{id}/lock")
29
+ Wokku::Output.status((data.is_a?(Hash) && data["locked"]) ? "locked" : "ok")
30
+ end
31
+
32
+ register "apps:unlock", "Release the dokku deploy lock. Usage: wokku apps:unlock APP" do
33
+ id = ARGV.shift || abort("Usage: wokku apps:unlock APP")
34
+ data = api(:post, "/apps/#{id}/unlock")
35
+ Wokku::Output.status((data.is_a?(Hash) && data["locked"] == false) ? "unlocked" : "ok")
36
+ end
37
+
38
+ register "apps:transfer", "Initiate an app transfer to another user (they accept via email). Usage: wokku apps:transfer APP RECIPIENT_EMAIL [--force]" do
39
+ id = ARGV.shift || abort("Usage: wokku apps:transfer APP RECIPIENT_EMAIL [--force]")
40
+ recipient = ARGV.shift || abort("Missing RECIPIENT_EMAIL")
41
+ force = ARGV.delete("--force")
42
+ unless force || !$stdout.tty?
43
+ print "Transfer `#{id}` to `#{recipient}`? They must accept via the email link to take ownership. [y/N] "
44
+ answer = $stdin.gets.to_s.strip.downcase
45
+ abort "Aborted." unless answer == "y"
46
+ end
47
+ data = api(:post, "/apps/#{id}/transfer", { recipient_email: recipient })
48
+ email = data.is_a?(Hash) ? data["recipient_email"] : recipient
49
+ Wokku::Output.status "Transfer requested. #{email} will receive an email."
50
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ # --- Billing (plan + usage; checkout/portal stay in the dashboard) ---
4
+
5
+ register "billing:plan", "Show your current Wokku plan + limits (usage: wokku billing:plan)" do
6
+ data = api(:get, "/billing/current_plan")
7
+ data = {} unless data.is_a?(Hash)
8
+ Wokku::Output.status "plan: #{data['plan'] || 'unknown'}"
9
+ Wokku::Output.status "max apps: #{data['max_apps']}" if data.key?("max_apps")
10
+ Wokku::Output.status "max databases: #{data['max_databases']}" if data.key?("max_databases")
11
+ end
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
30
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ # --- Cloudflare CDN proxy toggle ---
4
+
5
+ def print_cdn_state(enabled, note: nil)
6
+ Wokku::Output.status "cdn: #{enabled ? 'on' : 'off'}"
7
+ warn note if note
8
+ end
9
+
10
+ register "cdn", "Show Cloudflare CDN proxy state (usage: wokku cdn APP)" do
11
+ id = ARGV.shift || abort("Usage: wokku cdn APP")
12
+ data = api(:get, "/apps/#{id}")
13
+ print_cdn_state(data.is_a?(Hash) && data["cloudflare_proxied"])
14
+ end
15
+
16
+ register "cdn:on", "Enable Cloudflare CDN proxy (usage: wokku cdn:on APP)" do
17
+ id = ARGV.shift || abort("Usage: wokku cdn:on APP")
18
+ data = api(:patch, "/apps/#{id}/cdn", { enabled: true })
19
+ print_cdn_state(data.is_a?(Hash) ? data["enabled"] : true, note: "DNS propagation may take a minute.")
20
+ end
21
+
22
+ register "cdn:off", "Disable Cloudflare CDN proxy (usage: wokku cdn:off APP)" do
23
+ id = ARGV.shift || abort("Usage: wokku cdn:off APP")
24
+ data = api(:patch, "/apps/#{id}/cdn", { enabled: false })
25
+ print_cdn_state(data.is_a?(Hash) ? data["enabled"] : false)
26
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ # --- Scheduled tasks (cron) ---
4
+
5
+ register "cron", "List scheduled tasks for an app (usage: wokku cron APP)" do
6
+ id = ARGV.shift || abort("Usage: wokku cron APP")
7
+ data = api(:get, "/apps/#{id}/scheduled_tasks")
8
+ tasks = data.is_a?(Array) ? data : []
9
+ if tasks.empty?
10
+ Wokku::Output.status "no scheduled tasks"
11
+ next
12
+ end
13
+ tasks.each do |t|
14
+ Wokku::Output.status "#{t['id']} #{t['schedule']} #{t['command']}#{t['description'] ? " (#{t['description']})" : ''}"
15
+ end
16
+ end
17
+
18
+ register "cron:add", "Add a scheduled task. Usage: wokku cron:add APP --schedule '* * * * *' --command 'CMD' [--description 'DESC']" do
19
+ id = ARGV.shift || abort("Usage: wokku cron:add APP --schedule '...' --command '...' [--description '...']")
20
+ schedule = nil
21
+ command = nil
22
+ description = nil
23
+ while arg = ARGV.shift
24
+ case arg
25
+ when "--schedule" then schedule = ARGV.shift
26
+ when "--command" then command = ARGV.shift
27
+ when "--description" then description = ARGV.shift
28
+ end
29
+ end
30
+ abort "Missing --schedule" if schedule.nil? || schedule.empty?
31
+ abort "Missing --command" if command.nil? || command.empty?
32
+
33
+ data = api(:post, "/apps/#{id}/scheduled_tasks", { command: command, schedule: schedule, description: description })
34
+ task_id = data.is_a?(Hash) ? data["id"] : nil
35
+ Wokku::Output.status "Scheduled task added#{task_id ? " (id=#{task_id})" : ''}."
36
+ end
37
+
38
+ register "cron:remove", "Remove a scheduled task (usage: wokku cron:remove APP TASK_ID)" do
39
+ id = ARGV.shift || abort("Usage: wokku cron:remove APP TASK_ID")
40
+ task_id = ARGV.shift || abort("Missing TASK_ID")
41
+ api(:delete, "/apps/#{id}/scheduled_tasks/#{task_id}")
42
+ Wokku::Output.status "Scheduled task removed."
43
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ # --- Database monitor (connections, cache hit, slow queries) ---
4
+
5
+ register "db:monitor", "Show database connections, cache hit, size, and slow queries (usage: wokku db:monitor DB)" do
6
+ id = ARGV.shift || abort("Usage: wokku db:monitor DB")
7
+ data = api(:get, "/databases/#{id}/monitor")
8
+
9
+ if !data.is_a?(Hash) || data["recorded_at"].nil?
10
+ Wokku::Output.status "no stats collected yet"
11
+ next
12
+ end
13
+
14
+ size_mb = data["db_size_bytes"] ? (data["db_size_bytes"].to_f / (1024 * 1024)).round(2) : nil
15
+ Wokku::Output.status "conns: #{data['active_connections']} / #{data['max_connections']}"
16
+ Wokku::Output.status "size: #{size_mb} MB" if size_mb
17
+ Wokku::Output.status "cache hit: #{(data['cache_hit_ratio'].to_f * 100).round(2)}%"
18
+
19
+ queries = Array(data["top_queries"]).first(5)
20
+ if queries.any?
21
+ Wokku::Output.status ""
22
+ Wokku::Output.status "top queries:"
23
+ queries.each do |q|
24
+ qstr = q["query"].to_s.gsub(/\s+/, " ").strip
25
+ qstr = qstr[0, 80] + "…" if qstr.length > 80
26
+ Wokku::Output.status " #{qstr}"
27
+ Wokku::Output.status " calls=#{q['calls']} mean=#{q['mean_ms']}ms total=#{q['total_ms']}ms"
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require "uri"
5
+
6
+ # --- wokku dev: prod env + read-only DB tunnel for local dev ---
7
+
8
+ register "dev",
9
+ "Run a local command with prod env + read-only DB tunnel. Usage: wokku dev APP [--duration MIN] [--rw] [--local-port N] -- CMD [ARGS...]" do
10
+ args = ARGV.dup
11
+ ARGV.clear
12
+
13
+ id = args.shift || abort("Usage: wokku dev APP [flags] -- CMD [ARGS...]")
14
+ duration = 30
15
+ readwrite = false
16
+ local_port = 5433
17
+ user_cmd = nil
18
+
19
+ while (arg = args.shift)
20
+ case arg
21
+ when "--duration" then duration = args.shift.to_i
22
+ when "--rw" then readwrite = true
23
+ when "--local-port" then local_port = args.shift.to_i
24
+ when "--" then user_cmd = args.dup; break
25
+ end
26
+ end
27
+
28
+ abort "Missing user command. Add `-- bin/rails s` (or similar) after wokku flags." if user_cmd.nil? || user_cmd.empty?
29
+
30
+ session = api(:post, "/apps/#{id}/dev_sessions", { duration_min: duration.positive? ? duration : 30 })
31
+ session_id = session["session_id"]
32
+
33
+ tunnel_args = [
34
+ "ssh", "-N",
35
+ "-L", "#{local_port}:#{session['db_remote_host']}:#{session['db_remote_port']}",
36
+ "-p", session["ssh_port"].to_s,
37
+ "-o", "ExitOnForwardFailure=yes",
38
+ "-o", "ServerAliveInterval=30",
39
+ "-o", "StrictHostKeyChecking=accept-new",
40
+ "#{session['ssh_user']}@#{session['ssh_host']}"
41
+ ]
42
+ tunnel_pid = Process.spawn(*tunnel_args)
43
+
44
+ ready = false
45
+ 10.times do
46
+ begin
47
+ Socket.tcp("127.0.0.1", local_port, connect_timeout: 0.5) { |s| s.close rescue nil }
48
+ ready = true
49
+ rescue StandardError
50
+ sleep 0.5
51
+ end
52
+ break if ready
53
+ end
54
+
55
+ unless ready
56
+ Process.kill("TERM", tunnel_pid) rescue nil
57
+ api(:delete, "/apps/#{id}/dev_sessions/#{session_id}") rescue nil
58
+ abort "SSH tunnel failed to come up on port #{local_port}. Aborting."
59
+ end
60
+
61
+ env_data = api(:get, "/apps/#{id}/config")
62
+ env = env_data.is_a?(Hash) ? env_data.dup : {}
63
+
64
+ if env["DATABASE_URL"].to_s.start_with?("postgres")
65
+ db = URI.parse(env["DATABASE_URL"])
66
+ db.host = "localhost"
67
+ db.port = local_port
68
+
69
+ if readwrite
70
+ warn "⚠️ --rw MODE: writes will hit PROD database. 5s to Ctrl-C..."
71
+ sleep 5 unless ENV["WOKKU_DEV_SKIP_SLEEP"] == "1"
72
+ else
73
+ existing_query = db.query.to_s
74
+ ro = "options=-c%20default_transaction_read_only%3Don"
75
+ db.query = existing_query.empty? ? ro : "#{existing_query}&#{ro}"
76
+ end
77
+
78
+ env["DATABASE_URL"] = db.to_s
79
+ end
80
+
81
+ exit_status = nil
82
+ begin
83
+ user_pid = Process.spawn(env, *user_cmd)
84
+ _, status = Process.wait2(user_pid)
85
+ exit_status = status.exitstatus || 0
86
+ ensure
87
+ Process.kill("TERM", tunnel_pid) rescue nil
88
+ api(:delete, "/apps/#{id}/dev_sessions/#{session_id}") rescue nil
89
+ end
90
+
91
+ exit exit_status if exit_status
92
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ # --- GitHub repository connect / disconnect ---
4
+
5
+ register "github:connect", "Connect a GitHub repo to an app (usage: wokku github:connect APP REPO [--branch BRANCH])" do
6
+ id = ARGV.shift || abort("Usage: wokku github:connect APP REPO [--branch BRANCH]")
7
+ repo = ARGV.shift || abort("Missing REPO. Example: wokku github:connect myapp owner/repo")
8
+ branch = "main"
9
+ while arg = ARGV.shift
10
+ case arg
11
+ when "--branch" then branch = ARGV.shift || abort("--branch requires a value")
12
+ end
13
+ end
14
+
15
+ data = api(:post, "/apps/#{id}/github_connect", { repo: repo, branch: branch })
16
+ message = data.is_a?(Hash) ? data["message"] : nil
17
+ Wokku::Output.status(message || "Connected to #{repo} (#{branch}).")
18
+ end
19
+
20
+ register "github:disconnect", "Disconnect the GitHub repo from an app (usage: wokku github:disconnect APP)" do
21
+ id = ARGV.shift || abort("Usage: wokku github:disconnect APP")
22
+ data = api(:delete, "/apps/#{id}/github_disconnect")
23
+ message = data.is_a?(Hash) ? data["message"] : nil
24
+ Wokku::Output.status(message || "Disconnected.")
25
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ # --- HTTPS redirect toggle ---
4
+
5
+ def print_https_state(enabled)
6
+ Wokku::Output.status "https redirect: #{enabled ? 'on' : 'off'}"
7
+ end
8
+
9
+ register "https", "Show HTTPS redirect state (usage: wokku https APP)" do
10
+ id = ARGV.shift || abort("Usage: wokku https APP")
11
+ data = api(:get, "/apps/#{id}")
12
+ print_https_state(data.is_a?(Hash) && data["https_redirect"])
13
+ end
14
+
15
+ register "https:on", "Enable HTTPS redirect (usage: wokku https:on APP)" do
16
+ id = ARGV.shift || abort("Usage: wokku https:on APP")
17
+ data = api(:patch, "/apps/#{id}/https", { enabled: true })
18
+ print_https_state(data.is_a?(Hash) ? data["enabled"] : true)
19
+ end
20
+
21
+ register "https:off", "Disable HTTPS redirect (usage: wokku https:off APP)" do
22
+ id = ARGV.shift || abort("Usage: wokku https:off APP")
23
+ data = api(:patch, "/apps/#{id}/https", { enabled: false })
24
+ print_https_state(data.is_a?(Hash) ? data["enabled"] : false)
25
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ # --- Maintenance mode toggle ---
4
+
5
+ def print_maintenance_state(enabled)
6
+ Wokku::Output.status "maintenance: #{enabled ? 'on' : 'off'}"
7
+ end
8
+
9
+ register "maintenance", "Show maintenance mode state (usage: wokku maintenance APP)" do
10
+ id = ARGV.shift || abort("Usage: wokku maintenance APP")
11
+ data = api(:get, "/apps/#{id}")
12
+ print_maintenance_state(data.is_a?(Hash) && data["maintenance_enabled"])
13
+ end
14
+
15
+ register "maintenance:on", "Enable maintenance mode (usage: wokku maintenance:on APP)" do
16
+ id = ARGV.shift || abort("Usage: wokku maintenance:on APP")
17
+ data = api(:patch, "/apps/#{id}/maintenance", { enabled: true })
18
+ print_maintenance_state(data.is_a?(Hash) ? data["enabled"] : true)
19
+ end
20
+
21
+ register "maintenance:off", "Disable maintenance mode (usage: wokku maintenance:off APP)" do
22
+ id = ARGV.shift || abort("Usage: wokku maintenance:off APP")
23
+ data = api(:patch, "/apps/#{id}/maintenance", { enabled: false })
24
+ print_maintenance_state(data.is_a?(Hash) ? data["enabled"] : false)
25
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ # --- App runtime metrics (CPU / memory) ---
4
+
5
+ register "metrics", "Show latest CPU + memory for an app (usage: wokku metrics APP)" do
6
+ id = ARGV.shift || abort("Usage: wokku metrics APP")
7
+ data = api(:get, "/apps/#{id}/metrics")
8
+ minute = data.is_a?(Hash) ? Array(data["minute"]) : []
9
+
10
+ if minute.empty?
11
+ Wokku::Output.status "no metrics collected yet"
12
+ next
13
+ end
14
+
15
+ last = minute.last
16
+ cpu = last["cpu_pct"]
17
+ mem = last["memory_mb"]
18
+ limit = last["memory_limit_mb"]
19
+ Wokku::Output.status "cpu: #{cpu}%"
20
+ Wokku::Output.status "mem: #{mem} MB#{limit ? " / #{limit} MB" : ''}"
21
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ # --- App HTTP request monitor (req-rate, latency, error %) ---
4
+
5
+ register "monitor", "Show request totals and slow URLs (usage: wokku monitor APP)" do
6
+ id = ARGV.shift || abort("Usage: wokku monitor APP")
7
+ data = api(:get, "/apps/#{id}/monitor")
8
+ totals = data.is_a?(Hash) ? (data["totals"] || {}) : {}
9
+
10
+ if totals["req_count"].to_i.zero?
11
+ Wokku::Output.status "no requests in the last 24h"
12
+ next
13
+ end
14
+
15
+ Wokku::Output.status "req: #{totals['req_count']}"
16
+ Wokku::Output.status "rpm: #{totals['rpm']}"
17
+ Wokku::Output.status "p50: #{totals['p50_ms']}ms"
18
+ Wokku::Output.status "p95: #{totals['p95_ms']}ms"
19
+ Wokku::Output.status "error rate: #{((totals['error_rate'].to_f * 100).round(2))}%"
20
+
21
+ slow = Array(data["recent_slow"]).first(3)
22
+ if slow.any?
23
+ Wokku::Output.status ""
24
+ Wokku::Output.status "top slow:"
25
+ slow.each do |s|
26
+ Wokku::Output.status " #{s['method']} #{s['path']} #{s['response_time_ms']}ms (#{s['status']})"
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ # --- Notifications (team-scoped channels) ---
4
+
5
+ register "notifications", "List your notifications (usage: wokku notifications)" do
6
+ data = api(:get, "/notifications")
7
+ rows = data.is_a?(Array) ? data : []
8
+ if rows.empty?
9
+ Wokku::Output.status "no notifications"
10
+ next
11
+ end
12
+ rows.each do |n|
13
+ events = Array(n["events"]).join(",")
14
+ Wokku::Output.status "#{n['id']} #{n['channel']} #{events}"
15
+ end
16
+ end
17
+
18
+ register "notifications:add",
19
+ "Add a notification (email only in v1). Usage: wokku notifications:add --team TEAM_ID --channel email --events EVENT1,EVENT2" do
20
+ team_id = nil
21
+ channel = nil
22
+ events = nil
23
+ while arg = ARGV.shift
24
+ case arg
25
+ when "--team" then team_id = ARGV.shift
26
+ when "--channel" then channel = ARGV.shift
27
+ when "--events" then events = ARGV.shift
28
+ end
29
+ end
30
+ abort "Missing --team TEAM_ID" if team_id.nil? || team_id.empty?
31
+ abort "Missing --channel email" if channel.nil? || channel.empty?
32
+ abort "v1 only supports --channel email (use the dashboard for slack/webhook/etc.)" unless channel == "email"
33
+ abort "Missing --events. Example: --events deploy_succeeded,deploy_failed" if events.nil? || events.empty?
34
+
35
+ data = api(:post, "/notifications", {
36
+ team_id: team_id,
37
+ channel: channel,
38
+ events: events.split(",").map(&:strip)
39
+ })
40
+ id = data.is_a?(Hash) ? data["id"] : nil
41
+ Wokku::Output.status "Notification added#{id ? " (id=#{id})" : ''}."
42
+ end
43
+
44
+ register "notifications:remove", "Remove a notification (usage: wokku notifications:remove NOTIFICATION_ID)" do
45
+ id = ARGV.shift || abort("Usage: wokku notifications:remove NOTIFICATION_ID")
46
+ data = api(:delete, "/notifications/#{id}")
47
+ message = data.is_a?(Hash) ? data["message"] : nil
48
+ Wokku::Output.status(message || "Notification removed.")
49
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ # --- PR previews ---
4
+
5
+ register "previews", "List PR preview apps (usage: wokku previews APP)" do
6
+ id = ARGV.shift || abort("Usage: wokku previews APP")
7
+ data = api(:get, "/apps/#{id}/previews")
8
+ rows = data.is_a?(Array) ? data : []
9
+ if rows.empty?
10
+ Wokku::Output.status "no preview apps"
11
+ next
12
+ end
13
+ rows.each do |p|
14
+ line = "##{p['pr_number']} #{p['branch']} #{p['status']} #{p['name']}"
15
+ line += " #{p['url']}" if p["url"]
16
+ Wokku::Output.status line
17
+ end
18
+ end
19
+
20
+ register "previews:destroy", "Queue cleanup of a PR preview (usage: wokku previews:destroy APP PR_NUMBER)" do
21
+ id = ARGV.shift || abort("Usage: wokku previews:destroy APP PR_NUMBER")
22
+ pr = ARGV.shift || abort("Missing PR_NUMBER")
23
+ data = api(:delete, "/apps/#{id}/previews/#{pr}")
24
+ message = data.is_a?(Hash) ? data["message"] : nil
25
+ Wokku::Output.status(message || "Preview destroy queued.")
26
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ # --- Teams + team members ---
4
+
5
+ register "teams", "List the teams you have access to (usage: wokku teams)" do
6
+ data = api(:get, "/teams")
7
+ rows = data.is_a?(Array) ? data : []
8
+ if rows.empty?
9
+ Wokku::Output.status "no teams"
10
+ next
11
+ end
12
+ rows.each do |t|
13
+ Wokku::Output.status "#{t['id']} #{t['name']}"
14
+ end
15
+ end
16
+
17
+ register "teams:create", "Create a team (usage: wokku teams:create NAME)" do
18
+ name = ARGV.shift || abort("Usage: wokku teams:create NAME")
19
+ data = api(:post, "/teams", { name: name })
20
+ team_id = data.is_a?(Hash) ? data["id"] : nil
21
+ Wokku::Output.status "Team created#{team_id ? " (id=#{team_id})" : ''}."
22
+ end
23
+
24
+ register "teams:members", "List members of a team (usage: wokku teams:members TEAM_ID)" do
25
+ team_id = ARGV.shift || abort("Usage: wokku teams:members TEAM_ID")
26
+ data = api(:get, "/teams/#{team_id}/members")
27
+ rows = data.is_a?(Array) ? data : []
28
+ if rows.empty?
29
+ Wokku::Output.status "no members"
30
+ next
31
+ end
32
+ rows.each do |m|
33
+ Wokku::Output.status "#{m['id']} #{m['email']} #{m['role']}"
34
+ end
35
+ end
36
+
37
+ register "teams:members:add", "Add a member to a team. Usage: wokku teams:members:add TEAM_ID --email EMAIL [--role member|admin]" do
38
+ team_id = ARGV.shift || abort("Usage: wokku teams:members:add TEAM_ID --email EMAIL [--role member|admin]")
39
+ email = nil
40
+ role = "member"
41
+ while arg = ARGV.shift
42
+ case arg
43
+ when "--email" then email = ARGV.shift
44
+ when "--role" then role = ARGV.shift
45
+ end
46
+ end
47
+ abort "Missing --email" if email.nil? || email.empty?
48
+
49
+ data = api(:post, "/teams/#{team_id}/members", { email: email, role: role })
50
+ membership_id = data.is_a?(Hash) ? data["id"] : nil
51
+ Wokku::Output.status "Added member#{membership_id ? " (id=#{membership_id})" : ''}."
52
+ end
53
+
54
+ register "teams:members:remove", "Remove a member from a team (usage: wokku teams:members:remove TEAM_ID MEMBERSHIP_ID)" do
55
+ team_id = ARGV.shift || abort("Usage: wokku teams:members:remove TEAM_ID MEMBERSHIP_ID")
56
+ membership_id = ARGV.shift || abort("Missing MEMBERSHIP_ID. Get it from `wokku teams:members TEAM_ID`.")
57
+ api(:delete, "/teams/#{team_id}/members/#{membership_id}")
58
+ Wokku::Output.status "Member removed."
59
+ end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "open3"
5
+ require "rbconfig"
6
+ require "tempfile"
7
+
8
+ # --- wokku tunnel: share a local port at https://<sub>.wokku.dev ---
9
+ #
10
+ # Path B per project_wokku_tunnel_path_decision (memory): frp gateway.
11
+ # Wraps a downloaded `frpc` (one-time bootstrap, no platform-specific
12
+ # gem dependency). User runs `wokku tunnel 3000`; we POST to the API
13
+ # to provision a TunnelSession, write a per-process frpc.toml, exec
14
+ # frpc, and DELETE the session on exit.
15
+
16
+ FRPC_VERSION = "0.62.1"
17
+
18
+ def tunnel_frpc_path
19
+ File.join(Dir.home, ".wokku", "bin", "frpc")
20
+ end
21
+
22
+ def tunnel_frpc_platform
23
+ case RbConfig::CONFIG["host_os"]
24
+ when /darwin/ then "darwin_#{tunnel_frpc_arch}"
25
+ when /linux/ then "linux_#{tunnel_frpc_arch}"
26
+ when /mswin|mingw|cygwin/ then "windows_amd64"
27
+ else abort "Unsupported OS: #{RbConfig::CONFIG['host_os']}"
28
+ end
29
+ end
30
+
31
+ def tunnel_frpc_arch
32
+ case RbConfig::CONFIG["host_cpu"]
33
+ when /arm64|aarch64/ then "arm64"
34
+ when /x86_64|amd64/ then "amd64"
35
+ else abort "Unsupported CPU: #{RbConfig::CONFIG['host_cpu']}"
36
+ end
37
+ end
38
+
39
+ def tunnel_ensure_frpc!
40
+ return tunnel_frpc_path if File.executable?(tunnel_frpc_path)
41
+
42
+ warn "→ fetching tunnel helper (frpc #{FRPC_VERSION}, ~10 MB, one time)…"
43
+ platform = tunnel_frpc_platform
44
+ archive = "frp_#{FRPC_VERSION}_#{platform}.tar.gz"
45
+ url = "https://github.com/fatedier/frp/releases/download/v#{FRPC_VERSION}/#{archive}"
46
+ ext = platform.start_with?("windows") ? ".zip" : ".tar.gz"
47
+ archive = archive.sub(/\.tar\.gz\z/, ext)
48
+ url = url.sub(/\.tar\.gz\z/, ext)
49
+
50
+ Dir.mktmpdir do |tmp|
51
+ archive_path = File.join(tmp, archive)
52
+ unless system("curl", "-fL", url, "-o", archive_path)
53
+ abort "Failed to download frpc from #{url}"
54
+ end
55
+ if ext == ".zip"
56
+ system("unzip", "-q", archive_path, "-d", tmp) || abort("unzip failed")
57
+ else
58
+ system("tar", "-C", tmp, "-xzf", archive_path) || abort("tar failed")
59
+ end
60
+ frpc_dir = Dir[File.join(tmp, "frp_#{FRPC_VERSION}_*")].first || abort("frpc not found in archive")
61
+ src = File.join(frpc_dir, platform.start_with?("windows") ? "frpc.exe" : "frpc")
62
+ FileUtils.mkdir_p(File.dirname(tunnel_frpc_path))
63
+ FileUtils.cp(src, tunnel_frpc_path)
64
+ File.chmod(0o755, tunnel_frpc_path)
65
+ end
66
+
67
+ tunnel_frpc_path
68
+ end
69
+
70
+ register "tunnel",
71
+ "Share a local port at https://<sub>.wokku.dev. Usage: wokku tunnel PORT [--subdomain S] [--app APP]" do
72
+ args = ARGV.dup
73
+ ARGV.clear
74
+
75
+ local_port = args.shift&.to_i
76
+ abort "Usage: wokku tunnel PORT [--subdomain S] [--app APP]" unless local_port&.positive?
77
+
78
+ subdomain = nil
79
+ app_id = nil
80
+ while (arg = args.shift)
81
+ case arg
82
+ when "--subdomain" then subdomain = args.shift
83
+ when "--app" then app_id = args.shift
84
+ end
85
+ end
86
+
87
+ payload = {}
88
+ payload[:subdomain] = subdomain if subdomain
89
+ payload[:app_record_id] = app_id if app_id
90
+
91
+ session = api(:post, "/tunnels", payload)
92
+ if session.is_a?(Hash) && session["error"]
93
+ abort "Failed to create tunnel: #{session['error']}"
94
+ end
95
+
96
+ tunnel_id = session["tunnel_id"]
97
+ token = session["token"]
98
+ frps_host = session["frps_host"]
99
+ frps_port = session["frps_port"]
100
+ public_url = session["public_url"]
101
+ sub = session["subdomain"]
102
+
103
+ warn ""
104
+ warn " → tunnel: #{public_url} → localhost:#{local_port}"
105
+ warn " → press Ctrl-C to close"
106
+ warn ""
107
+
108
+ frpc = tunnel_ensure_frpc!
109
+
110
+ cfg = Tempfile.new([ "frpc-", ".toml" ])
111
+ cfg.write(<<~TOML)
112
+ serverAddr = "#{frps_host}"
113
+ serverPort = #{frps_port}
114
+
115
+ [[proxies]]
116
+ name = "wokku-#{sub}"
117
+ type = "http"
118
+ localPort = #{local_port}
119
+ subdomain = "#{sub}"
120
+
121
+ [proxies.metadatas]
122
+ token = "#{token}"
123
+ TOML
124
+ cfg.close
125
+
126
+ cleanup = lambda do
127
+ File.unlink(cfg.path) rescue nil
128
+ api(:delete, "/tunnels/#{tunnel_id}") rescue nil
129
+ end
130
+
131
+ %w[INT TERM].each do |sig|
132
+ Signal.trap(sig) do
133
+ cleanup.call
134
+ exit 0
135
+ end
136
+ end
137
+
138
+ status = system(frpc, "-c", cfg.path)
139
+ cleanup.call
140
+ exit(status ? 0 : 1)
141
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ # --- Core Web Vitals read (p75 per metric) ---
4
+
5
+ register "vitals", "Show p75 per Core Web Vital (usage: wokku vitals APP)" do
6
+ id = ARGV.shift || abort("Usage: wokku vitals APP")
7
+ data = api(:get, "/apps/#{id}/vitals")
8
+ latest = data.is_a?(Hash) ? (data["latest"] || {}) : {}
9
+
10
+ if latest.values.all?(&:nil?)
11
+ Wokku::Output.status "no vitals collected yet"
12
+ next
13
+ end
14
+
15
+ %w[CLS LCP INP FCP TTFB].each do |metric|
16
+ row = latest[metric]
17
+ if row.nil?
18
+ Wokku::Output.status "#{metric}: —"
19
+ else
20
+ Wokku::Output.status "#{metric}: #{row['p75']} (n=#{row['sample_count']})"
21
+ end
22
+ end
23
+ end
data/lib/wokku/version.rb CHANGED
@@ -1,4 +1,4 @@
1
1
  # frozen_string_literal: true
2
2
  module Wokku
3
- VERSION = "0.4.0"
3
+ VERSION = "0.5.0"
4
4
  end
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.4.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Johannes Dwicahyo
@@ -43,16 +43,29 @@ files:
43
43
  - lib/wokku/commands/activity.rb
44
44
  - lib/wokku/commands/addons.rb
45
45
  - lib/wokku/commands/apps.rb
46
+ - lib/wokku/commands/apps_lifecycle.rb
46
47
  - lib/wokku/commands/auth.rb
48
+ - lib/wokku/commands/billing.rb
47
49
  - lib/wokku/commands/buildpacks.rb
50
+ - lib/wokku/commands/cdn.rb
48
51
  - lib/wokku/commands/certs.rb
49
52
  - lib/wokku/commands/checks.rb
50
53
  - lib/wokku/commands/config.rb
54
+ - lib/wokku/commands/cron.rb
51
55
  - lib/wokku/commands/databases.rb
56
+ - lib/wokku/commands/db_monitor.rb
57
+ - lib/wokku/commands/dev.rb
52
58
  - lib/wokku/commands/do.rb
53
59
  - lib/wokku/commands/domains.rb
60
+ - lib/wokku/commands/github.rb
61
+ - lib/wokku/commands/https.rb
54
62
  - lib/wokku/commands/logs.rb
63
+ - lib/wokku/commands/maintenance.rb
55
64
  - lib/wokku/commands/mcp.rb
65
+ - lib/wokku/commands/metrics.rb
66
+ - lib/wokku/commands/monitor.rb
67
+ - lib/wokku/commands/notifications.rb
68
+ - lib/wokku/commands/previews.rb
56
69
  - lib/wokku/commands/ps.rb
57
70
  - lib/wokku/commands/releases.rb
58
71
  - lib/wokku/commands/run.rb
@@ -60,8 +73,11 @@ files:
60
73
  - lib/wokku/commands/shell.rb
61
74
  - lib/wokku/commands/ssh_keys.rb
62
75
  - lib/wokku/commands/storage.rb
76
+ - lib/wokku/commands/teams.rb
63
77
  - lib/wokku/commands/templates.rb
78
+ - lib/wokku/commands/tunnel.rb
64
79
  - lib/wokku/commands/version.rb
80
+ - lib/wokku/commands/vitals.rb
65
81
  - lib/wokku/config.rb
66
82
  - lib/wokku/helpers.rb
67
83
  - lib/wokku/output.rb