deploio-cli 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +64 -0
- data/deploio-cli.gemspec +7 -0
- data/lib/deploio/cli.rb +17 -0
- data/lib/deploio/commands/apps.rb +29 -7
- data/lib/deploio/commands/builds.rb +139 -0
- data/lib/deploio/commands/postgresql.rb +131 -0
- data/lib/deploio/commands/postgresql_backups.rb +75 -0
- data/lib/deploio/commands/projects.rb +185 -0
- data/lib/deploio/commands/services.rb +186 -0
- data/lib/deploio/completion_generator.rb +21 -3
- data/lib/deploio/nctl_client.rb +197 -18
- data/lib/deploio/output.rb +40 -0
- data/lib/deploio/pg_database_ref.rb +66 -0
- data/lib/deploio/pg_database_resolver.rb +55 -0
- data/lib/deploio/price_fetcher.rb +201 -0
- data/lib/deploio/shared_options.rb +16 -0
- data/lib/deploio/templates/completion.zsh.erb +134 -1
- data/lib/deploio/version.rb +1 -1
- data/lib/deploio.rb +4 -0
- metadata +16 -2
data/lib/deploio/output.rb
CHANGED
|
@@ -42,6 +42,15 @@ module Deploio
|
|
|
42
42
|
puts pastel.magenta.bold(text)
|
|
43
43
|
end
|
|
44
44
|
|
|
45
|
+
# Create a clickable hyperlink using OSC 8 escape sequences
|
|
46
|
+
# Supported by most modern terminal emulators (iTerm2, GNOME Terminal, Windows Terminal, etc.)
|
|
47
|
+
def link(text, url = nil)
|
|
48
|
+
return text unless color_enabled
|
|
49
|
+
|
|
50
|
+
url ||= text.start_with?("http") ? text : "https://#{text}"
|
|
51
|
+
"\e]8;;#{url}\e\\#{text}\e]8;;\e\\"
|
|
52
|
+
end
|
|
53
|
+
|
|
45
54
|
def table(rows, headers: nil)
|
|
46
55
|
return if rows.empty?
|
|
47
56
|
|
|
@@ -49,6 +58,37 @@ module Deploio
|
|
|
49
58
|
puts tty_table.render(:unicode, padding: [0, 2, 0, 1], width: 10_000)
|
|
50
59
|
end
|
|
51
60
|
|
|
61
|
+
def list(items)
|
|
62
|
+
items.each { |item| puts " • #{item}" }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def grouped_table(groups, headers: nil)
|
|
66
|
+
return if groups.empty?
|
|
67
|
+
|
|
68
|
+
all_rows = groups.values.flatten(1)
|
|
69
|
+
return if all_rows.empty?
|
|
70
|
+
|
|
71
|
+
# Track which row indices should have separators after them (0-indexed)
|
|
72
|
+
# The separator lambda receives the row index (0 = first data row)
|
|
73
|
+
separator_after = []
|
|
74
|
+
current_idx = 0
|
|
75
|
+
groups.each_with_index do |(_, group_rows), group_idx|
|
|
76
|
+
current_idx += group_rows.size
|
|
77
|
+
# Add separator after last row of each group (except the last group)
|
|
78
|
+
separator_after << (current_idx - 1) if group_idx < groups.size - 1
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
rows = groups.values.flatten(1)
|
|
82
|
+
tty_table = headers ? TTY::Table.new(header: headers, rows: rows) : TTY::Table.new(rows: rows)
|
|
83
|
+
|
|
84
|
+
output = tty_table.render(:unicode, padding: [0, 2, 0, 1], width: 10_000) do |renderer|
|
|
85
|
+
# row_idx 0 = separator after header, then data row indices
|
|
86
|
+
renderer.border.separator = ->(row_idx) { row_idx == 0 || separator_after.include?(row_idx - 1) }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
puts output
|
|
90
|
+
end
|
|
91
|
+
|
|
52
92
|
private
|
|
53
93
|
|
|
54
94
|
def pastel
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "did_you_mean"
|
|
4
|
+
|
|
5
|
+
module Deploio
|
|
6
|
+
class PgDatabaseRef
|
|
7
|
+
attr_reader :project_name, :database_name
|
|
8
|
+
|
|
9
|
+
# The input is given in the format "<project>-<database>"
|
|
10
|
+
def initialize(input, available_databases: {})
|
|
11
|
+
@input = input.to_s
|
|
12
|
+
parse_from_available_databases(available_databases)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def full_name
|
|
16
|
+
"#{project_name}-#{database_name}"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def to_s
|
|
20
|
+
full_name
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def ==(other)
|
|
24
|
+
return false unless other.is_a?(PgDatabaseRef)
|
|
25
|
+
|
|
26
|
+
project_name == other.project_name && database_name == other.database_name
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def parse_from_available_databases(available_databases)
|
|
32
|
+
if available_databases.key?(@input)
|
|
33
|
+
match = available_databases[@input]
|
|
34
|
+
@project_name = match[:project_name]
|
|
35
|
+
@database_name = match[:database_name]
|
|
36
|
+
return
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# If available_databases provided but no match, raise error with suggestions
|
|
40
|
+
raise_not_found_error(@input, available_databases.keys) unless available_databases.empty?
|
|
41
|
+
|
|
42
|
+
raise_not_found_error(@input, [])
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def raise_not_found_error(input, available_database_names)
|
|
46
|
+
message = "Database not found: '#{input}'"
|
|
47
|
+
|
|
48
|
+
suggestions = suggest_similar(input, available_database_names)
|
|
49
|
+
unless suggestions.empty?
|
|
50
|
+
message += "\n\nDid you mean?"
|
|
51
|
+
suggestions.each { |s| message += "\n #{s}" }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
message += "\n\nRun 'deploio pg' to see available Postgres databases."
|
|
55
|
+
|
|
56
|
+
raise Deploio::PgDatabaseNotFoundError, message
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def suggest_similar(input, dictionary)
|
|
60
|
+
return [] if dictionary.empty?
|
|
61
|
+
|
|
62
|
+
spell_checker = DidYouMean::SpellChecker.new(dictionary: dictionary)
|
|
63
|
+
spell_checker.correct(input)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Deploio
|
|
4
|
+
class PgDatabaseResolver
|
|
5
|
+
attr_reader :nctl, :current_org
|
|
6
|
+
|
|
7
|
+
def initialize(nctl_client:)
|
|
8
|
+
@nctl = nctl_client
|
|
9
|
+
@current_org = @nctl.current_org
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def resolve(database_name: nil)
|
|
13
|
+
if database_name
|
|
14
|
+
return PgDatabaseRef.new(database_name, available_databases: available_databases_hash)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
raise Deploio::Error, "No database specified"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Returns hash mapping database names -> {project_name:, app_name:}
|
|
21
|
+
def available_databases_hash
|
|
22
|
+
@available_apps_hash ||= begin
|
|
23
|
+
hash = {}
|
|
24
|
+
current_org = @nctl.current_org
|
|
25
|
+
@nctl.get_all_pg_databases.each do |database|
|
|
26
|
+
metadata = database["metadata"] || {}
|
|
27
|
+
project_name = metadata["namespace"] || ""
|
|
28
|
+
database_name = metadata["name"]
|
|
29
|
+
full_name = "#{project_name}-#{database_name}"
|
|
30
|
+
hash[full_name] = {project_name: project_name, database_name: database_name}
|
|
31
|
+
|
|
32
|
+
# Also index by short name (without org prefix) for convenience
|
|
33
|
+
if current_org && project_name.start_with?("#{current_org}-")
|
|
34
|
+
project = project_name.delete_prefix("#{current_org}-")
|
|
35
|
+
short_name = "#{project}-#{database_name}"
|
|
36
|
+
hash[short_name] ||= {project_name: project_name, database_name: database_name}
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
hash
|
|
40
|
+
end
|
|
41
|
+
rescue
|
|
42
|
+
{}
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def short_name_for(namespace, database_name)
|
|
46
|
+
org = current_org
|
|
47
|
+
if org && namespace.start_with?("#{org}-")
|
|
48
|
+
project = namespace.delete_prefix("#{org}-")
|
|
49
|
+
"#{project}-#{database_name}"
|
|
50
|
+
else
|
|
51
|
+
"#{namespace}-#{database_name}"
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
require "fileutils"
|
|
7
|
+
|
|
8
|
+
module Deploio
|
|
9
|
+
class PriceFetcher
|
|
10
|
+
CACHE_DIR = File.expand_path("~/.deploio")
|
|
11
|
+
CACHE_FILE = File.join(CACHE_DIR, "prices.json")
|
|
12
|
+
CACHE_TTL = 24 * 60 * 60 # 24 hours in seconds
|
|
13
|
+
API_URL = "https://calculator-api-production.2deb129.deploio.app/product"
|
|
14
|
+
|
|
15
|
+
def initialize
|
|
16
|
+
@prices = nil
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def fetch
|
|
20
|
+
@prices ||= load_cached_prices || fetch_and_cache_prices
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def price_for_service(type, spec)
|
|
24
|
+
fetch
|
|
25
|
+
return nil unless @prices
|
|
26
|
+
|
|
27
|
+
case type
|
|
28
|
+
when "postgres", "mysql"
|
|
29
|
+
price_for_database(type, spec)
|
|
30
|
+
when "postgresdatabases", "mysqldatabases"
|
|
31
|
+
price_for_single_database
|
|
32
|
+
when "keyvaluestore"
|
|
33
|
+
price_for_keyvaluestore(spec)
|
|
34
|
+
when "opensearch"
|
|
35
|
+
price_for_opensearch
|
|
36
|
+
when "bucket"
|
|
37
|
+
price_for_bucket
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def price_for_app(app_data)
|
|
42
|
+
fetch
|
|
43
|
+
return nil unless @prices
|
|
44
|
+
|
|
45
|
+
spec = app_data["spec"] || app_data
|
|
46
|
+
status = app_data["status"] || {}
|
|
47
|
+
for_provider = spec["forProvider"] || {}
|
|
48
|
+
at_provider = status["atProvider"] || {}
|
|
49
|
+
config = for_provider["config"] || {}
|
|
50
|
+
|
|
51
|
+
size = (config["size"] || "micro").downcase
|
|
52
|
+
# Use status replicas (actual running) if available, otherwise spec replicas, default to 1
|
|
53
|
+
replicas = at_provider["replicas"] || for_provider["replicas"] || 1
|
|
54
|
+
replicas = replicas.to_i
|
|
55
|
+
|
|
56
|
+
size_price = @prices.dig("app", size)
|
|
57
|
+
return nil if size_price.nil?
|
|
58
|
+
|
|
59
|
+
size_price * replicas
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def format_price(price)
|
|
63
|
+
return "-" if price.nil?
|
|
64
|
+
|
|
65
|
+
"CHF #{price}/mo"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def load_cached_prices
|
|
71
|
+
return nil unless File.exist?(CACHE_FILE)
|
|
72
|
+
|
|
73
|
+
cache_data = JSON.parse(File.read(CACHE_FILE))
|
|
74
|
+
cached_at = cache_data["cached_at"]
|
|
75
|
+
return nil if cached_at.nil? || Time.now.to_i - cached_at > CACHE_TTL
|
|
76
|
+
|
|
77
|
+
cache_data["prices"]
|
|
78
|
+
rescue JSON::ParserError, Errno::ENOENT
|
|
79
|
+
nil
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def fetch_and_cache_prices
|
|
83
|
+
prices = fetch_prices_from_api
|
|
84
|
+
return nil if prices.nil?
|
|
85
|
+
|
|
86
|
+
cache_prices(prices)
|
|
87
|
+
prices
|
|
88
|
+
rescue
|
|
89
|
+
nil
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def fetch_prices_from_api
|
|
93
|
+
uri = URI.parse(API_URL)
|
|
94
|
+
response = Net::HTTP.get_response(uri)
|
|
95
|
+
return nil unless response.is_a?(Net::HTTPSuccess)
|
|
96
|
+
|
|
97
|
+
products = JSON.parse(response.body)
|
|
98
|
+
build_price_map(products)
|
|
99
|
+
rescue JSON::ParserError, Net::OpenTimeout, Net::ReadTimeout, SocketError
|
|
100
|
+
nil
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def build_price_map(products)
|
|
104
|
+
prices = {
|
|
105
|
+
"postgres" => {},
|
|
106
|
+
"mysql" => {},
|
|
107
|
+
"keyvaluestore" => {"base" => 15},
|
|
108
|
+
"opensearch" => {"base" => 60},
|
|
109
|
+
"single_database" => {"base" => 5},
|
|
110
|
+
"bucket" => {"base" => 0},
|
|
111
|
+
"app" => {"micro" => 8, "mini" => 16, "standard-1" => 32, "standard-2" => 58},
|
|
112
|
+
"ram_per_gib" => 5
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
products.each do |product|
|
|
116
|
+
name = product["name"]
|
|
117
|
+
list_price = product["list_price"]
|
|
118
|
+
categ_path = (product["categ_path"] || []).join("/")
|
|
119
|
+
|
|
120
|
+
case name
|
|
121
|
+
when /^PostgreSQL - (nine-(?:db|single-db)-\S+)/
|
|
122
|
+
machine_type = normalize_machine_type($1)
|
|
123
|
+
prices["postgres"][machine_type] = list_price
|
|
124
|
+
when /^MySQL - (nine-(?:db|single-db)-\S+)/
|
|
125
|
+
machine_type = normalize_machine_type($1)
|
|
126
|
+
prices["mysql"][machine_type] = list_price
|
|
127
|
+
when "Managed Service: Key-Value Store (Redis compatible)"
|
|
128
|
+
prices["keyvaluestore"]["base"] = list_price
|
|
129
|
+
when "Managed Service: OpenSearch (Elasticsearch compatible)"
|
|
130
|
+
prices["opensearch"]["base"] = list_price
|
|
131
|
+
when "Micro", "Mini", "Standard-1", "Standard-2"
|
|
132
|
+
# Only use deplo.io app sizes
|
|
133
|
+
prices["app"][name.downcase] = list_price if categ_path.include?("deplo")
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
prices
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def normalize_machine_type(raw)
|
|
141
|
+
# "nine-single-db-l - 10GB" -> "nine-single-db-l"
|
|
142
|
+
raw.split(" ").first
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def cache_prices(prices)
|
|
146
|
+
FileUtils.mkdir_p(CACHE_DIR)
|
|
147
|
+
cache_data = {
|
|
148
|
+
"cached_at" => Time.now.to_i,
|
|
149
|
+
"prices" => prices
|
|
150
|
+
}
|
|
151
|
+
File.write(CACHE_FILE, JSON.pretty_generate(cache_data))
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def price_for_database(type, spec)
|
|
155
|
+
for_provider = spec.dig("forProvider") || {}
|
|
156
|
+
machine_type = for_provider["machineType"] || for_provider["singleDBMachineType"]
|
|
157
|
+
return nil if machine_type.nil?
|
|
158
|
+
|
|
159
|
+
@prices.dig(type, machine_type)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def price_for_keyvaluestore(spec)
|
|
163
|
+
base_price = @prices.dig("keyvaluestore", "base") || 15
|
|
164
|
+
ram_price = @prices["ram_per_gib"] || 5
|
|
165
|
+
|
|
166
|
+
memory_size = spec.dig("forProvider", "memorySize")
|
|
167
|
+
return base_price if memory_size.nil?
|
|
168
|
+
|
|
169
|
+
# Parse memory size (e.g., "256Mi", "1Gi", "512Mi")
|
|
170
|
+
gib = parse_memory_to_gib(memory_size)
|
|
171
|
+
(base_price + (gib * ram_price)).round
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def price_for_opensearch
|
|
175
|
+
@prices.dig("opensearch", "base") || 60
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def price_for_single_database
|
|
179
|
+
# Single databases on shared instances - base price for smallest tier
|
|
180
|
+
@prices.dig("single_database", "base") || 5
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def price_for_bucket
|
|
184
|
+
# Buckets are usage-based, show base/minimum price
|
|
185
|
+
@prices.dig("bucket", "base") || 0
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def parse_memory_to_gib(memory_str)
|
|
189
|
+
case memory_str
|
|
190
|
+
when /^(\d+(?:\.\d+)?)Gi$/
|
|
191
|
+
$1.to_f
|
|
192
|
+
when /^(\d+(?:\.\d+)?)Mi$/
|
|
193
|
+
$1.to_f / 1024
|
|
194
|
+
when /^(\d+(?:\.\d+)?)Ki$/
|
|
195
|
+
$1.to_f / (1024 * 1024)
|
|
196
|
+
else
|
|
197
|
+
0
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
@@ -30,6 +30,7 @@ module Deploio
|
|
|
30
30
|
@nctl.check_requirements unless merged_options[:dry_run]
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
+
# @return [Deploio::AppRef]
|
|
33
34
|
def resolve_app
|
|
34
35
|
resolver = AppResolver.new(nctl_client: @nctl)
|
|
35
36
|
resolver.resolve(app_name: merged_options[:app])
|
|
@@ -37,5 +38,20 @@ module Deploio
|
|
|
37
38
|
Output.error(e.message)
|
|
38
39
|
exit 1
|
|
39
40
|
end
|
|
41
|
+
|
|
42
|
+
# Resolves a project name to its fully qualified form (org-project).
|
|
43
|
+
# Users can type short names like "myproject" and this will prepend the org.
|
|
44
|
+
# @param project [String] Project name (short or fully qualified)
|
|
45
|
+
# @return [String] Fully qualified project name
|
|
46
|
+
def resolve_project(project)
|
|
47
|
+
current_org = @nctl.current_org
|
|
48
|
+
return project unless current_org
|
|
49
|
+
|
|
50
|
+
# Special case: project equals org name (default project)
|
|
51
|
+
return project if project == current_org
|
|
52
|
+
|
|
53
|
+
# Always prepend org to get fully qualified name
|
|
54
|
+
"#{current_org}-#{project}"
|
|
55
|
+
end
|
|
40
56
|
end
|
|
41
57
|
end
|
|
@@ -23,6 +23,58 @@ _<%= program_name %>_orgs_list() {
|
|
|
23
23
|
fi
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
# Dynamic completion for services
|
|
27
|
+
_<%= program_name %>_services_list() {
|
|
28
|
+
local -a services
|
|
29
|
+
services=(${(f)"$(<%= program_name %> services --json 2>/dev/null | ruby -rjson -e '
|
|
30
|
+
data = JSON.parse(STDIN.read) rescue []
|
|
31
|
+
orgs_json = `<%= program_name %> orgs --json 2>/dev/null` rescue "[]"
|
|
32
|
+
orgs = JSON.parse(orgs_json) rescue []
|
|
33
|
+
current_org = orgs.find { |o| o["current"] }&.fetch("name", nil)
|
|
34
|
+
|
|
35
|
+
data.each do |s|
|
|
36
|
+
ns = s.dig("metadata", "namespace") || ""
|
|
37
|
+
name = s.dig("metadata", "name") || ""
|
|
38
|
+
type = s["_type"] || "unknown"
|
|
39
|
+
project = current_org && ns.start_with?("#{current_org}-") ? ns.delete_prefix("#{current_org}-") : ns
|
|
40
|
+
short_name = "#{project}-#{name}"
|
|
41
|
+
puts "#{short_name}:#{name} (#{type})"
|
|
42
|
+
end
|
|
43
|
+
' 2>/dev/null)"})
|
|
44
|
+
|
|
45
|
+
if [[ ${#services[@]} -gt 0 ]]; then
|
|
46
|
+
_describe -t services 'available services' services
|
|
47
|
+
else
|
|
48
|
+
_message 'service (format: project-servicename)'
|
|
49
|
+
fi
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
# Dynamic completion for projects
|
|
53
|
+
_<%= program_name %>_projects_list() {
|
|
54
|
+
local -a projects
|
|
55
|
+
projects=(${(f)"$(<%= program_name %> projects --json 2>/dev/null | ruby -rjson -e '
|
|
56
|
+
data = JSON.parse(STDIN.read) rescue []
|
|
57
|
+
orgs_json = `<%= program_name %> orgs --json 2>/dev/null` rescue "[]"
|
|
58
|
+
orgs = JSON.parse(orgs_json) rescue []
|
|
59
|
+
current_org = orgs.find { |o| o["current"] }&.fetch("name", nil)
|
|
60
|
+
|
|
61
|
+
data.each do |p|
|
|
62
|
+
name = p.dig("metadata", "name") || ""
|
|
63
|
+
display_name = p.dig("spec", "displayName").to_s
|
|
64
|
+
# Strip org prefix from project name for short name
|
|
65
|
+
short_name = current_org && name.start_with?("#{current_org}-") ? name.delete_prefix("#{current_org}-") : name
|
|
66
|
+
label = display_name.length > 0 ? display_name : short_name
|
|
67
|
+
puts "#{short_name}:#{label}"
|
|
68
|
+
end
|
|
69
|
+
' 2>/dev/null)"})
|
|
70
|
+
|
|
71
|
+
if [[ ${#projects[@]} -gt 0 ]]; then
|
|
72
|
+
_describe -t projects 'available projects' projects
|
|
73
|
+
else
|
|
74
|
+
_message 'project name'
|
|
75
|
+
fi
|
|
76
|
+
}
|
|
77
|
+
|
|
26
78
|
# Dynamic completion for apps
|
|
27
79
|
_<%= program_name %>_apps_list() {
|
|
28
80
|
local -a apps
|
|
@@ -48,7 +100,37 @@ _<%= program_name %>_apps_list() {
|
|
|
48
100
|
fi
|
|
49
101
|
}
|
|
50
102
|
|
|
51
|
-
|
|
103
|
+
# Dynamic completion for PostgreSQL databases
|
|
104
|
+
_<%= program_name %>_pg_databases_list() {
|
|
105
|
+
local -a databases
|
|
106
|
+
databases=(${(f)"$(<%= program_name %> pg list --json 2>/dev/null | ruby -rjson -e '
|
|
107
|
+
data = JSON.parse(STDIN.read) rescue []
|
|
108
|
+
orgs_json = `<%= program_name %> orgs --json 2>/dev/null` rescue "[]"
|
|
109
|
+
orgs = JSON.parse(orgs_json) rescue []
|
|
110
|
+
current_org = orgs.find { |o| o["current"] }&.fetch("name", nil)
|
|
111
|
+
|
|
112
|
+
data.each do |db|
|
|
113
|
+
metadata = db["metadata"] || {}
|
|
114
|
+
spec = db["spec"] || {}
|
|
115
|
+
for_provider = spec["forProvider"] || {}
|
|
116
|
+
ns = metadata["namespace"] || ""
|
|
117
|
+
name = metadata["name"] || ""
|
|
118
|
+
version = for_provider["version"] || "?"
|
|
119
|
+
kind = db["kind"] || ""
|
|
120
|
+
project = current_org && ns.start_with?("#{current_org}-") ? ns.delete_prefix("#{current_org}-") : ns
|
|
121
|
+
short_name = "#{project}-#{name}"
|
|
122
|
+
puts "#{short_name}:#{name} (#{kind}, v#{version})"
|
|
123
|
+
end
|
|
124
|
+
' 2>/dev/null)"})
|
|
125
|
+
|
|
126
|
+
if [[ ${#databases[@]} -gt 0 ]]; then
|
|
127
|
+
_describe -t databases 'available PostgreSQL databases' databases
|
|
128
|
+
else
|
|
129
|
+
_message 'database (format: project-dbname)'
|
|
130
|
+
fi
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
<% subcommands.each do |name, commands, class_options, default_task, klass| %>
|
|
52
134
|
# <%= name %> subcommand
|
|
53
135
|
_<%= program_name %>_<%= name %>() {
|
|
54
136
|
local -a <%= name %>_commands
|
|
@@ -58,6 +140,20 @@ _<%= program_name %>_<%= name %>() {
|
|
|
58
140
|
<% end -%>
|
|
59
141
|
)
|
|
60
142
|
|
|
143
|
+
<% default_cmd = commands.find { |cmd_name, _, _| cmd_name == default_task } -%>
|
|
144
|
+
<% if default_cmd -%>
|
|
145
|
+
# Handle options directly (for default task) or subcommand
|
|
146
|
+
# Check if current word or next word after subcommand starts with -
|
|
147
|
+
local first_arg="${words[2]}"
|
|
148
|
+
local current_word="${words[CURRENT]}"
|
|
149
|
+
if [[ "$first_arg" == -* ]] || [[ "$current_word" == -* ]] || [[ -z "$first_arg" && "$current_word" == -* ]]; then
|
|
150
|
+
# Options provided directly - use default task (<%= default_task %>)
|
|
151
|
+
_arguments -s \
|
|
152
|
+
<%= format_options(default_cmd[2], class_options, positional_arg(name, default_task)) %>
|
|
153
|
+
return
|
|
154
|
+
fi
|
|
155
|
+
|
|
156
|
+
<% end -%>
|
|
61
157
|
_arguments -s \
|
|
62
158
|
'1:<%= name %> command:-><%= name %>_cmd' \
|
|
63
159
|
'*::<%= name %> args:-><%= name %>_args'
|
|
@@ -70,8 +166,45 @@ _<%= program_name %>_<%= name %>() {
|
|
|
70
166
|
case "$words[1]" in
|
|
71
167
|
<% commands.each do |cmd_name, _, options| -%>
|
|
72
168
|
<%= cmd_name %>)
|
|
169
|
+
<% if klass.subcommand_classes.key?(cmd_name) -%>
|
|
170
|
+
_<%= program_name %>_<%= name %>_<%= cmd_name %>
|
|
171
|
+
<% else -%>
|
|
73
172
|
_arguments -s \
|
|
74
173
|
<%= format_options(options, class_options, positional_arg(name, cmd_name)) %>
|
|
174
|
+
<% end -%>
|
|
175
|
+
;;
|
|
176
|
+
<% end -%>
|
|
177
|
+
esac
|
|
178
|
+
;;
|
|
179
|
+
esac
|
|
180
|
+
}
|
|
181
|
+
<% end %>
|
|
182
|
+
|
|
183
|
+
<% nested_subcommands.each do |full_name, commands, class_options| %>
|
|
184
|
+
<% parent_name, nested_name = full_name.split(':') %>
|
|
185
|
+
# <%= full_name %> nested subcommand
|
|
186
|
+
_<%= program_name %>_<%= parent_name %>_<%= nested_name %>() {
|
|
187
|
+
local -a <%= nested_name %>_commands
|
|
188
|
+
<%= nested_name %>_commands=(
|
|
189
|
+
<% commands.each do |cmd_name, desc| -%>
|
|
190
|
+
'<%= cmd_name %>:<%= escape(desc) %>'
|
|
191
|
+
<% end -%>
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
_arguments -s \
|
|
195
|
+
'1:<%= nested_name %> command:-><%= nested_name %>_cmd' \
|
|
196
|
+
'*::<%= nested_name %> args:-><%= nested_name %>_args'
|
|
197
|
+
|
|
198
|
+
case "$state" in
|
|
199
|
+
<%= nested_name %>_cmd)
|
|
200
|
+
_describe -t commands '<%= nested_name %> commands' <%= nested_name %>_commands
|
|
201
|
+
;;
|
|
202
|
+
<%= nested_name %>_args)
|
|
203
|
+
case "$words[1]" in
|
|
204
|
+
<% commands.each do |cmd_name, _, options| -%>
|
|
205
|
+
<%= cmd_name %>)
|
|
206
|
+
_arguments -s \
|
|
207
|
+
<%= format_options(options, class_options, positional_arg(full_name, cmd_name)) %>
|
|
75
208
|
;;
|
|
76
209
|
<% end -%>
|
|
77
210
|
esac
|
data/lib/deploio/version.rb
CHANGED
data/lib/deploio.rb
CHANGED
|
@@ -6,13 +6,17 @@ require_relative "deploio/version"
|
|
|
6
6
|
require_relative "deploio/utils"
|
|
7
7
|
require_relative "deploio/output"
|
|
8
8
|
require_relative "deploio/app_ref"
|
|
9
|
+
require_relative "deploio/pg_database_ref"
|
|
9
10
|
require_relative "deploio/nctl_client"
|
|
10
11
|
require_relative "deploio/app_resolver"
|
|
12
|
+
require_relative "deploio/pg_database_resolver"
|
|
13
|
+
require_relative "deploio/price_fetcher"
|
|
11
14
|
require_relative "deploio/shared_options"
|
|
12
15
|
require_relative "deploio/cli"
|
|
13
16
|
|
|
14
17
|
module Deploio
|
|
15
18
|
class Error < StandardError; end
|
|
16
19
|
class AppNotFoundError < Error; end
|
|
20
|
+
class PgDatabaseNotFoundError < Error; end
|
|
17
21
|
class NctlError < Error; end
|
|
18
22
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: deploio-cli
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Renuo AG
|
|
@@ -56,10 +56,18 @@ files:
|
|
|
56
56
|
- lib/deploio/cli.rb
|
|
57
57
|
- lib/deploio/commands/apps.rb
|
|
58
58
|
- lib/deploio/commands/auth.rb
|
|
59
|
+
- lib/deploio/commands/builds.rb
|
|
59
60
|
- lib/deploio/commands/orgs.rb
|
|
61
|
+
- lib/deploio/commands/postgresql.rb
|
|
62
|
+
- lib/deploio/commands/postgresql_backups.rb
|
|
63
|
+
- lib/deploio/commands/projects.rb
|
|
64
|
+
- lib/deploio/commands/services.rb
|
|
60
65
|
- lib/deploio/completion_generator.rb
|
|
61
66
|
- lib/deploio/nctl_client.rb
|
|
62
67
|
- lib/deploio/output.rb
|
|
68
|
+
- lib/deploio/pg_database_ref.rb
|
|
69
|
+
- lib/deploio/pg_database_resolver.rb
|
|
70
|
+
- lib/deploio/price_fetcher.rb
|
|
63
71
|
- lib/deploio/shared_options.rb
|
|
64
72
|
- lib/deploio/templates/completion.zsh.erb
|
|
65
73
|
- lib/deploio/utils.rb
|
|
@@ -72,6 +80,11 @@ metadata:
|
|
|
72
80
|
homepage_uri: https://github.com/renuo/deploio-cli
|
|
73
81
|
source_code_uri: https://github.com/renuo/deploio-cli
|
|
74
82
|
changelog_uri: https://github.com/renuo/deploio-cli/blob/main/CHANGELOG.md
|
|
83
|
+
post_install_message: |+
|
|
84
|
+
To enable shell autocompletion for deploio, add this to your ~/.zshrc:
|
|
85
|
+
|
|
86
|
+
eval "$(deploio completion)"
|
|
87
|
+
|
|
75
88
|
rdoc_options: []
|
|
76
89
|
require_paths:
|
|
77
90
|
- lib
|
|
@@ -86,7 +99,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
86
99
|
- !ruby/object:Gem::Version
|
|
87
100
|
version: '0'
|
|
88
101
|
requirements: []
|
|
89
|
-
rubygems_version:
|
|
102
|
+
rubygems_version: 3.6.9
|
|
90
103
|
specification_version: 4
|
|
91
104
|
summary: CLI for Deploio
|
|
92
105
|
test_files: []
|
|
106
|
+
...
|