deploio-cli 0.2.0 → 0.4.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.
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Deploio
4
+ class MysqlBackupService
5
+ DEFAULT_EXTENSION = ".zst"
6
+
7
+ # @param name [String] the name the user typed, used for hints in messages
8
+ def initialize(data:, name: nil, dry_run: false)
9
+ @data = data || {}
10
+ @name = name
11
+ @dry_run = dry_run
12
+ end
13
+
14
+ def default_destination = "./#{@name}-latest-backup"
15
+
16
+ def backups
17
+ raise Deploio::UnsupportedBackupOperationError,
18
+ "Listing backups is not yet supported for dedicated MySQL instances; Feel free to implement it!\n" \
19
+ "Use 'deploio mysql backups download #{@name}' to fetch it."
20
+ end
21
+
22
+ def capture
23
+ cmd = ["ssh", "dbadmin@#{fqdn}", "sudo", "nine-mysql-backup"]
24
+ Output.command(cmd.join(" "))
25
+ system(*cmd) unless @dry_run
26
+ end
27
+
28
+ def download(destination:, db_name: nil)
29
+ name = resolve_db_name(db_name)
30
+
31
+ cmd = [
32
+ "rsync",
33
+ "-av",
34
+ "dbadmin@#{fqdn}:~/backup/mysql/latest/customer/#{name}/",
35
+ destination
36
+ ]
37
+
38
+ Output.command(cmd.join(" "))
39
+ unless @dry_run || system(*cmd)
40
+ raise Deploio::Error, "Failed to download MySQL backup for database '#{name}'."
41
+ end
42
+
43
+ nil
44
+ end
45
+
46
+ private
47
+
48
+ def resolve_db_name(db_name)
49
+ if databases.empty?
50
+ raise Deploio::Error,
51
+ "No databases found in MySQL instance; cannot download backup."
52
+ elsif databases.size > 1 && db_name.nil?
53
+ raise Deploio::Error,
54
+ "Multiple databases found in MySQL instance\n" \
55
+ "Databases: #{databases.join(", ")}\n" \
56
+ "Please specify the database name using the --db_name option."
57
+ end
58
+
59
+ db_name || databases.first
60
+ end
61
+
62
+ def databases
63
+ @databases ||= (
64
+ @data.dig("status", "atProvider", "databases")&.keys || []
65
+ ).reject { |db| db.strip.empty? }
66
+ end
67
+
68
+ def fqdn
69
+ value = @data.dig("status", "atProvider", "fqdn")
70
+
71
+ if value.nil? || value.empty?
72
+ raise Deploio::Error,
73
+ "Database FQDN not found; cannot reach the database server."
74
+ end
75
+
76
+ value
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Deploio
4
+ class MysqlDatabaseBackupService
5
+ DEFAULT_EXTENSION = ".sql.zst"
6
+ BACKUP_SCHEDULE_LABEL = "DatabaseBackupSchedule"
7
+
8
+ def initialize(
9
+ db_ref:,
10
+ data:,
11
+ nctl_client:,
12
+ name: nil,
13
+ rclone_client_factory: nil
14
+ )
15
+ @db_ref = db_ref
16
+ @data = data || {}
17
+ @nctl = nctl_client
18
+ @name = name || db_ref.full_name
19
+ @rclone_client_factory = rclone_client_factory || method(:build_rclone_client)
20
+ end
21
+
22
+ def default_destination
23
+ "./#{@name}-latest-backup#{DEFAULT_EXTENSION}"
24
+ end
25
+
26
+ # Nine takes these backups on a schedule and there is no way to trigger one.
27
+ def capture
28
+ raise Deploio::UnsupportedBackupOperationError,
29
+ "'#{@db_ref.full_name}' is an economy-tier database. Those are backed up automatically " \
30
+ "on their configured schedule and cannot be captured manually.\n" \
31
+ "Use 'deploio mysql backups list #{@name}' to see the available backups."
32
+ end
33
+
34
+ def backups
35
+ @backups ||= begin
36
+ entries = rclone.list(bucket_name)
37
+ entries = entries.select { |entry| own_backup?(entry["Name"].to_s) }
38
+ entries.sort_by { |entry| entry["ModTime"].to_s }.reverse
39
+ end
40
+ end
41
+
42
+ def download(destination:, db_name: nil)
43
+ backup = backups.first
44
+
45
+ unless backup
46
+ raise Deploio::Error,
47
+ "No backups found for '#{@db_ref.full_name}' in bucket '#{bucket_name}'."
48
+ end
49
+
50
+ rclone.download(bucket_name, backup["Name"], destination)
51
+ backup
52
+ end
53
+
54
+ private
55
+
56
+ def own_backup?(object_name)
57
+ return true if instance_name.empty?
58
+
59
+ object_name.start_with?("MySQLDatabase-#{instance_name}-")
60
+ end
61
+
62
+ def instance_name
63
+ @instance_name ||= @data.dig("status", "atProvider", "name").to_s
64
+ end
65
+
66
+ def bucket_name
67
+ bucket.dig("metadata", "name")
68
+ end
69
+
70
+ # The database resource holds no reference to its backup bucket.
71
+ # Therefore, match the bucket by name for now.
72
+ def bucket
73
+ @bucket ||= begin
74
+ candidates = @nctl
75
+ .get_services_by_type("bucket", project: @db_ref.project_name)
76
+ .select { |candidate| backup_bucket_for_database?(candidate) }
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 " \
82
+ "(spec.forProvider.backupSchedule)."
83
+ end
84
+
85
+ candidates.first
86
+ end
87
+ end
88
+
89
+ def backup_bucket_for_database?(bucket)
90
+ metadata = bucket["metadata"] || {}
91
+ labels = metadata["labels"] || {}
92
+
93
+ return false unless labels["nine.ch/controllerKind"] == BACKUP_SCHEDULE_LABEL
94
+
95
+ metadata["name"].to_s.match?(
96
+ /\Amysqldatabase-#{Regexp.escape(@db_ref.database_name)}-[0-9a-f]{7}\z/
97
+ )
98
+ end
99
+
100
+ def rclone
101
+ @rclone ||= @rclone_client_factory.call
102
+ end
103
+
104
+ def build_rclone_client
105
+ endpoint = bucket.dig("status", "atProvider", "endpoint")
106
+
107
+ if endpoint.nil? || endpoint.to_s.empty?
108
+ raise Deploio::Error,
109
+ "Backup bucket '#{bucket_name}' has no endpoint; cannot access backups."
110
+ end
111
+
112
+ client = RcloneClient.new(
113
+ endpoint: "https://#{endpoint}",
114
+ access_key: @nctl.get_bucket_user_access_key(
115
+ bucket_name,
116
+ project: @db_ref.project_name
117
+ ),
118
+ secret_key: @nctl.get_bucket_user_secret_key(
119
+ bucket_name,
120
+ project: @db_ref.project_name
121
+ ),
122
+ dry_run: @nctl.dry_run
123
+ )
124
+
125
+ client.check_requirements unless @nctl.dry_run
126
+ client
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "did_you_mean"
4
+
5
+ module Deploio
6
+ class MysqlDatabaseRef
7
+ attr_reader :project_name, :database_name
8
+
9
+ def initialize(input, available_databases: {})
10
+ @input = input.to_s
11
+ if available_databases.key?(@input)
12
+ match = available_databases[@input]
13
+ @project_name = match[:project_name]
14
+ @database_name = match[:database_name]
15
+ else
16
+ raise_not_found_error(@input, available_databases.keys)
17
+ end
18
+ end
19
+
20
+ def full_name = "#{project_name}-#{database_name}"
21
+ def to_s = full_name
22
+
23
+ def ==(other)
24
+ other.is_a?(self.class) && project_name == other.project_name && database_name == other.database_name
25
+ end
26
+
27
+ private
28
+
29
+ def raise_not_found_error(input, available_database_names)
30
+ message = "Database not found: '#{input}'"
31
+ suggestions = DidYouMean::SpellChecker.new(dictionary: available_database_names).correct(input) unless available_database_names.empty?
32
+ unless suggestions.nil? || suggestions.empty?
33
+ message += "\n\nDid you mean?"
34
+ suggestions.each { |suggestion| message += "\n #{suggestion}" }
35
+ end
36
+ message += "\n\nRun 'deploio mysql' to see available MySQL databases."
37
+ raise Deploio::MysqlDatabaseNotFoundError, message
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Deploio
4
+ class MysqlDatabaseResolver
5
+ attr_reader :nctl, :current_org
6
+
7
+ def initialize(nctl_client:)
8
+ @nctl = nctl_client
9
+ @database_ref = MysqlDatabaseRef
10
+ @fetch_databases = -> { @nctl.get_all_mysql_databases }
11
+ @current_org = @nctl.current_org
12
+ end
13
+
14
+ def resolve(database_name: nil)
15
+ if database_name
16
+ return @database_ref.new(
17
+ database_name,
18
+ available_databases: available_databases_hash
19
+ )
20
+ end
21
+
22
+ raise Deploio::Error, "No database specified"
23
+ end
24
+
25
+ # Returns hash mapping database names -> { project_name:, database_name: }
26
+ def available_databases_hash
27
+ @available_databases_hash ||= begin
28
+ hash = {}
29
+ current_org = @nctl.current_org
30
+
31
+ @fetch_databases.call.each do |database|
32
+ metadata = database["metadata"] || {}
33
+ project_name = metadata["namespace"] || ""
34
+ database_name = metadata["name"]
35
+ full_name = "#{project_name}-#{database_name}"
36
+
37
+ hash[full_name] = {
38
+ project_name: project_name,
39
+ database_name: database_name
40
+ }
41
+
42
+ # Also index by short name (without org prefix) for convenience
43
+ if current_org && project_name.start_with?("#{current_org}-")
44
+ project = project_name.delete_prefix("#{current_org}-")
45
+ short_name = "#{project}-#{database_name}"
46
+
47
+ hash[short_name] ||= {
48
+ project_name: project_name,
49
+ database_name: database_name
50
+ }
51
+ end
52
+ end
53
+
54
+ hash
55
+ end
56
+ rescue
57
+ {}
58
+ end
59
+
60
+ def short_name_for(namespace, database_name)
61
+ org = current_org
62
+
63
+ if org && namespace.start_with?("#{org}-")
64
+ project = namespace.delete_prefix("#{org}-")
65
+ "#{project}-#{database_name}"
66
+ else
67
+ "#{namespace}-#{database_name}"
68
+ end
69
+ end
70
+ end
71
+ end
@@ -100,6 +100,42 @@ module Deploio
100
100
  nil
101
101
  end
102
102
 
103
+ def get_all_mysql_databases
104
+ output_dedicated_dbs = capture("get", "mysql", "-A", "-o", "json")
105
+ output_shared_dbs = capture("get", "mysqldatabase", "-A", "-o", "json")
106
+ if (output_dedicated_dbs.nil? || output_dedicated_dbs.empty?) &&
107
+ (output_shared_dbs.nil? || output_shared_dbs.empty?)
108
+ return []
109
+ end
110
+
111
+ [
112
+ *JSON.parse(output_dedicated_dbs),
113
+ *JSON.parse(output_shared_dbs)
114
+ ]
115
+ rescue JSON::ParserError
116
+ []
117
+ end
118
+
119
+ def get_mysql_database(db_ref)
120
+ output = begin
121
+ capture("get", "mysql", db_ref.database_name,
122
+ "--project", db_ref.project_name, "-o", "json")
123
+ rescue Deploio::NctlError
124
+ nil
125
+ end
126
+
127
+ if output.nil? || output.empty?
128
+ output = capture("get", "mysqldatabase", db_ref.database_name,
129
+ "--project", db_ref.project_name, "-o", "json")
130
+ end
131
+
132
+ return nil if output.nil? || output.empty?
133
+
134
+ JSON.parse(output)
135
+ rescue JSON::ParserError
136
+ nil
137
+ end
138
+
103
139
  def get_apps_by_project(project)
104
140
  output = capture("get", "apps", "--project", project, "-o", "json")
105
141
  return [] if output.nil? || output.empty?
@@ -197,6 +233,14 @@ module Deploio
197
233
  nil
198
234
  end
199
235
 
236
+ def get_bucket_user_access_key(name, project:)
237
+ capture("get", "bucketuser", name, "--project", project, "--print-access-key").strip
238
+ end
239
+
240
+ def get_bucket_user_secret_key(name, project:)
241
+ capture("get", "bucketuser", name, "--project", project, "--print-secret-key").strip
242
+ end
243
+
200
244
  def get_projects
201
245
  output = capture("get", "projects", "-o", "json")
202
246
  return [] if output.nil? || output.empty?
@@ -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,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