deploio-cli 0.1.1 → 0.3.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.
@@ -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,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Deploio
4
+ # Backups for the dedicated tier (kind: Postgres), where we own the whole
5
+ # database server and reach it over SSH
6
+ # The naming is confusing, but this is how Nine names them and how the resources appear, so prefer to stay
7
+ # consistent with that
8
+ class PostgresBackupService
9
+ DEFAULT_EXTENSION = ".zst"
10
+
11
+ # @param name [String] the name the user typed, used for hints in messages
12
+ def initialize(data:, name: nil, dry_run: false)
13
+ @data = data || {}
14
+ @name = name
15
+ @dry_run = dry_run
16
+ end
17
+
18
+ def default_destination = "./#{@name}-latest-backup#{DEFAULT_EXTENSION}"
19
+
20
+ def backups
21
+ raise Deploio::UnsupportedBackupOperationError,
22
+ "Listing backups is not yet supported for dedicated PostgreSQL instances; Feel free to implement it!\n" \
23
+ "Use 'deploio pg backups download #{@name}' to fetch it."
24
+ end
25
+
26
+ def capture
27
+ cmd = ["ssh", "dbadmin@#{fqdn}", "sudo nine-postgresql-backup"]
28
+ Output.command(cmd.join(" "))
29
+ system(*cmd) unless @dry_run
30
+ end
31
+
32
+ def download(destination:, db_name: nil)
33
+ name = resolve_db_name(db_name)
34
+
35
+ cmd = ["rsync", "-av", "dbadmin@#{fqdn}:~/backup/postgresql/latest/customer/#{name}/#{name}.zst", destination]
36
+ Output.command(cmd.join(" "))
37
+ system(*cmd) unless @dry_run
38
+
39
+ nil
40
+ end
41
+
42
+ private
43
+
44
+ def resolve_db_name(db_name)
45
+ if databases.empty?
46
+ raise Deploio::Error, "No databases found in PostgreSQL instance; cannot download backup."
47
+ elsif databases.size > 1 && db_name.nil?
48
+ raise Deploio::Error,
49
+ "Multiple databases found in PostgreSQL instance\n" \
50
+ "Databases: #{databases.join(", ")}\n" \
51
+ "Please specify the database name using the --db_name option."
52
+ end
53
+
54
+ db_name || databases.first
55
+ end
56
+
57
+ def databases
58
+ @databases ||= (@data.dig("status", "atProvider", "databases")&.keys || []).reject { |db| db.strip.empty? }
59
+ end
60
+
61
+ def fqdn
62
+ value = @data.dig("status", "atProvider", "fqdn")
63
+ raise Deploio::Error, "Database FQDN not found; cannot reach the database server." if value.nil? || value.empty?
64
+
65
+ value
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Deploio
4
+ # Backups for the economy tier (kind: PostgresDatabase), where the database
5
+ # lives on a shared server we have no access to.
6
+ # The naming is confusing, but this is how Nine names them and how the resources appear, so prefer to stay
7
+ # consistent with that
8
+ class PostgresDatabaseBackupService
9
+ DEFAULT_EXTENSION = ".sql.zst"
10
+
11
+ BACKUP_SCHEDULE_LABEL = "DatabaseBackupSchedule"
12
+
13
+ def initialize(db_ref:, data:, nctl_client:, name: nil, rclone_client_factory: nil)
14
+ @db_ref = db_ref
15
+ @data = data || {}
16
+ @nctl = nctl_client
17
+ @name = name || db_ref.full_name
18
+ @rclone_client_factory = rclone_client_factory || method(:build_rclone_client)
19
+ end
20
+
21
+ def default_destination
22
+ "./#{@name}-latest-backup#{DEFAULT_EXTENSION}"
23
+ end
24
+
25
+ # Nine takes these backups on a schedule
26
+ # (you can even see them using `kubectl get databasebackupschedules.storage.nine.ch -n renuo-chess-tracker -o json`)
27
+ # => there is no way to trigger one.
28
+ def capture
29
+ raise Deploio::UnsupportedBackupOperationError,
30
+ "'#{@db_ref.full_name}' is an economy-tier database. Those are backed up automatically " \
31
+ "on their configured schedule and cannot be captured manually.\n" \
32
+ "Use 'deploio pg backups list #{@name}' to see the available backups."
33
+ end
34
+
35
+ def backups
36
+ @backups ||= begin
37
+ entries = rclone.list(bucket_name)
38
+ entries = entries.select { |e| own_backup?(e["Name"].to_s) }
39
+ entries.sort_by { |e| e["ModTime"].to_s }.reverse
40
+ end
41
+ end
42
+
43
+ def download(destination:, db_name: nil)
44
+ backup = backups.first
45
+ unless backup
46
+ raise Deploio::Error, "No backups found for '#{@db_ref.full_name}' in bucket '#{bucket_name}'."
47
+ end
48
+
49
+ rclone.download(bucket_name, backup["Name"], destination)
50
+ backup
51
+ end
52
+
53
+ private
54
+
55
+ def own_backup?(object_name)
56
+ return true if instance_name.empty?
57
+
58
+ object_name.start_with?("PostgresDatabase-#{instance_name}-")
59
+ end
60
+
61
+ def instance_name
62
+ @instance_name ||= @data.dig("status", "atProvider", "name").to_s
63
+ end
64
+
65
+ def bucket_name
66
+ bucket.dig("metadata", "name")
67
+ end
68
+
69
+ # The PostgresDatabase resource holds no reference to its backup bucket
70
+ # Changes with service connections (I assume), but we're not there yet as we still have projects with the old setup.
71
+ # Therefore, we match by name
72
+ def bucket
73
+ @bucket ||= begin
74
+ candidates = @nctl.get_services_by_type("bucket", project: @db_ref.project_name).select do |bucket|
75
+ backup_bucket_for_database?(bucket)
76
+ end
77
+
78
+ if candidates.empty?
79
+ raise Deploio::Error,
80
+ "No backup bucket found for '#{@db_ref.full_name}'. " \
81
+ "Check that backups are enabled for this database (spec.forProvider.backupSchedule)."
82
+ end
83
+
84
+ candidates.first
85
+ end
86
+ end
87
+
88
+ def backup_bucket_for_database?(bucket)
89
+ metadata = bucket["metadata"] || {}
90
+ labels = metadata["labels"] || {}
91
+ return false unless labels["nine.ch/controllerKind"] == BACKUP_SCHEDULE_LABEL
92
+
93
+ metadata["name"].to_s.match?(/\Apostgresdatabase-#{Regexp.escape(@db_ref.database_name)}-[0-9a-f]{7}\z/)
94
+ end
95
+
96
+ def rclone
97
+ @rclone ||= @rclone_client_factory.call
98
+ end
99
+
100
+ def build_rclone_client
101
+ endpoint = bucket.dig("status", "atProvider", "endpoint")
102
+ if endpoint.nil? || endpoint.to_s.empty?
103
+ raise Deploio::Error, "Backup bucket '#{bucket_name}' has no endpoint; cannot access backups."
104
+ end
105
+
106
+ client = RcloneClient.new(
107
+ endpoint: "https://#{endpoint}",
108
+ access_key: @nctl.get_bucket_user_access_key(bucket_name, project: @db_ref.project_name),
109
+ secret_key: @nctl.get_bucket_user_secret_key(bucket_name, project: @db_ref.project_name),
110
+ dry_run: @nctl.dry_run
111
+ )
112
+ client.check_requirements unless @nctl.dry_run
113
+ client
114
+ end
115
+ end
116
+ 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
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "open3"
5
+
6
+ module Deploio
7
+ class RcloneClient
8
+ REMOTE = "DEPLOIO"
9
+
10
+ attr_reader :dry_run
11
+
12
+ def initialize(endpoint:, access_key:, secret_key:, dry_run: false)
13
+ @endpoint = endpoint
14
+ @access_key = access_key
15
+ @secret_key = secret_key
16
+ @dry_run = dry_run
17
+ end
18
+
19
+ def check_requirements
20
+ check_rclone_installed
21
+ end
22
+
23
+ def list(bucket)
24
+ output = capture("lsjson", remote_path(bucket))
25
+ return [] if output.nil? || output.empty?
26
+
27
+ data = JSON.parse(output)
28
+ data.is_a?(Array) ? data : []
29
+ rescue JSON::ParserError
30
+ []
31
+ end
32
+
33
+ def download(bucket, object, destination)
34
+ run("copyto", remote_path(bucket, object), destination, "--progress", "--stats-one-line")
35
+ end
36
+
37
+ private
38
+
39
+ # --s3-no-check-bucket skips the HeadBucket call, which the read-only bucket
40
+ # user is not permitted to make.
41
+ # See also https://docs.nine.ch/docs/object-storage/object-storage-client-tools#rclone
42
+ def build_command(args)
43
+ ["rclone", *args.map(&:to_s), "--s3-no-check-bucket"]
44
+ end
45
+
46
+ def remote_path(bucket, object = nil)
47
+ object ? "#{REMOTE}:#{bucket}/#{object}" : "#{REMOTE}:#{bucket}"
48
+ end
49
+
50
+ def env
51
+ {
52
+ "RCLONE_CONFIG_#{REMOTE}_TYPE" => "s3",
53
+ "RCLONE_CONFIG_#{REMOTE}_PROVIDER" => "Other",
54
+ "RCLONE_CONFIG_#{REMOTE}_ENDPOINT" => @endpoint,
55
+ "RCLONE_CONFIG_#{REMOTE}_ACCESS_KEY_ID" => @access_key,
56
+ "RCLONE_CONFIG_#{REMOTE}_SECRET_ACCESS_KEY" => @secret_key
57
+ }
58
+ end
59
+
60
+ def capture(*args)
61
+ cmd = build_command(args)
62
+ if dry_run
63
+ Output.command(cmd.join(" "))
64
+ return ""
65
+ end
66
+
67
+ puts "> #{cmd.join(" ")}" if ENV["DEPLOIO_DEBUG"]
68
+ stdout, stderr, status = Open3.capture3(env, *cmd)
69
+ unless status.success?
70
+ raise Deploio::RcloneError, "rclone command failed: #{stderr}"
71
+ end
72
+
73
+ stdout
74
+ end
75
+
76
+ def run(*args)
77
+ cmd = build_command(args)
78
+ Output.command(cmd.join(" "))
79
+ return true if dry_run
80
+
81
+ unless system(env, *cmd)
82
+ raise Deploio::RcloneError, "rclone command failed: #{cmd.join(" ")}"
83
+ end
84
+
85
+ true
86
+ end
87
+
88
+ def check_rclone_installed
89
+ _stdout, _stderr, status = Open3.capture3("rclone", "version")
90
+ return if status.success?
91
+
92
+ raise Deploio::RcloneError,
93
+ "rclone not found. Please install it: brew install rclone"
94
+ rescue Errno::ENOENT
95
+ raise Deploio::RcloneError,
96
+ "rclone not found. Please install it: brew install rclone"
97
+ end
98
+ end
99
+ end
@@ -7,8 +7,8 @@ module Deploio
7
7
  def self.included(base)
8
8
  base.class_option :app, aliases: "-a", type: :string, desc: "App in <project>-<app> format"
9
9
  base.class_option :org, aliases: "-o", type: :string, desc: "Organization"
10
- base.class_option :dry_run, type: :boolean, default: false, desc: "Print commands without executing"
11
- base.class_option :no_color, type: :boolean, default: false, desc: "Disable colored output"
10
+ base.class_option :dry_run, type: :boolean, desc: "Print commands without executing"
11
+ base.class_option :no_color, type: :boolean, desc: "Disable colored output"
12
12
 
13
13
  base.define_singleton_method(:exit_on_failure?) { true }
14
14
  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