dnsmadeeasy 0.4.0 → 1.0.1

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.
Files changed (63) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ruby.yml +5 -10
  3. data/.gitignore +2 -0
  4. data/.rubocop.yml +5 -4
  5. data/.rubocop_todo.yml +251 -143
  6. data/CLAUDE.md +67 -0
  7. data/Gemfile +9 -1
  8. data/README.md +858 -0
  9. data/Rakefile +4 -4
  10. data/dnsmadeeasy.gemspec +42 -26
  11. data/docs/badges/coverage_badge.svg +21 -0
  12. data/docs/dry-cli-based-tools.webloc +8 -0
  13. data/docs/plan-zone-management.md +756 -0
  14. data/docs/plans/01-plan-ruby4-baseline.md +38 -0
  15. data/docs/plans/02-plan-dry-cli-launcher.md +44 -0
  16. data/docs/plans/03-plan-account-command.md +43 -0
  17. data/docs/plans/04-plan-zone-record-model.md +48 -0
  18. data/docs/plans/05-plan-zone-parser.md +41 -0
  19. data/docs/plans/06-plan-zone-formatter.md +43 -0
  20. data/docs/plans/07-plan-zone-export.md +58 -0
  21. data/docs/plans/08-plan-zone-diff.md +42 -0
  22. data/docs/plans/09-plan-zone-apply.md +69 -0
  23. data/docs/plans/10-plan-docs-cleanup.md +55 -0
  24. data/docs/spec-zone-management.md +242 -0
  25. data/exe/dme +13 -2
  26. data/exe/dmez +6 -0
  27. data/lib/dme.rb +7 -2
  28. data/lib/dnsmadeeasy/api/client.rb +32 -26
  29. data/lib/dnsmadeeasy/cli/box_output.rb +9 -0
  30. data/lib/dnsmadeeasy/cli/commands/account.rb +222 -0
  31. data/lib/dnsmadeeasy/cli/commands/base.rb +119 -0
  32. data/lib/dnsmadeeasy/cli/commands/legacy_operation.rb +30 -0
  33. data/lib/dnsmadeeasy/cli/commands/version.rb +22 -0
  34. data/lib/dnsmadeeasy/cli/commands/zone.rb +326 -0
  35. data/lib/dnsmadeeasy/cli/commands.rb +19 -0
  36. data/lib/dnsmadeeasy/cli/input.rb +14 -0
  37. data/lib/dnsmadeeasy/cli/launcher.rb +53 -0
  38. data/lib/dnsmadeeasy/cli/message_helpers.rb +81 -0
  39. data/lib/dnsmadeeasy/cli/reported_error.rb +10 -0
  40. data/lib/dnsmadeeasy/credentials/api_keys.rb +11 -9
  41. data/lib/dnsmadeeasy/credentials.rb +0 -1
  42. data/lib/dnsmadeeasy/runner.rb +24 -24
  43. data/lib/dnsmadeeasy/types.rb +19 -0
  44. data/lib/dnsmadeeasy/version.rb +2 -1
  45. data/lib/dnsmadeeasy/zone/aname_flattener.rb +63 -0
  46. data/lib/dnsmadeeasy/zone/apply_executor.rb +189 -0
  47. data/lib/dnsmadeeasy/zone/apply_result.rb +22 -0
  48. data/lib/dnsmadeeasy/zone/diff.rb +152 -0
  49. data/lib/dnsmadeeasy/zone/file.rb +26 -0
  50. data/lib/dnsmadeeasy/zone/parser.rb +172 -0
  51. data/lib/dnsmadeeasy/zone/plan.rb +28 -0
  52. data/lib/dnsmadeeasy/zone/plan_action.rb +29 -0
  53. data/lib/dnsmadeeasy/zone/plan_renderer.rb +91 -0
  54. data/lib/dnsmadeeasy/zone/provider_record.rb +18 -0
  55. data/lib/dnsmadeeasy/zone/record.rb +44 -0
  56. data/lib/dnsmadeeasy/zone/record_set.rb +23 -0
  57. data/lib/dnsmadeeasy/zone/remote_adapter.rb +94 -0
  58. data/lib/dnsmadeeasy/zone/remote_records.rb +26 -0
  59. data/lib/dnsmadeeasy/zone/serializer.rb +115 -0
  60. data/lib/dnsmadeeasy.rb +61 -27
  61. metadata +184 -25
  62. data/.dme-help.png +0 -0
  63. data/README.adoc +0 -690
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry/cli'
4
+ require 'awesome_print'
5
+ require 'json'
6
+ require 'yaml'
7
+ require 'dnsmadeeasy/cli/message_helpers'
8
+
9
+ module DnsMadeEasy
10
+ module CLI
11
+ module Commands
12
+ # Base class for dry-cli commands.
13
+ class Base < Dry::CLI::Command
14
+ include MessageHelpers
15
+
16
+ SUPPORTED_FORMATS = %w[json json_pretty yaml pp].freeze
17
+ DEFAULT_CREDENTIAL_PATHS = [
18
+ Pathname.new('~/.dnsmadeeasy/credentials.ini').expand_path,
19
+ Pathname.new('./.dnsmadeeasy/credential.ini').expand_path
20
+ ].freeze
21
+
22
+ option :format, values: SUPPORTED_FORMATS, required: false, desc: 'Output format'
23
+ option :credentials, required: false, desc: 'Path to credentials INI file'
24
+ option :api_key, required: false, desc: 'DNS Made Easy API key'
25
+ option :api_secret, required: false, desc: 'DNS Made Easy API secret'
26
+
27
+ private
28
+
29
+ def puts(*)
30
+ @out.puts(*)
31
+ end
32
+
33
+ def warn(*)
34
+ @err.puts(*)
35
+ end
36
+
37
+ def print_formatted(result, format = nil)
38
+ case format&.to_sym
39
+ when :json
40
+ puts JSON.generate(result)
41
+ when :json_pretty
42
+ puts JSON.pretty_generate(result)
43
+ when :yaml
44
+ puts result.to_yaml
45
+ when :pp
46
+ require 'pp'
47
+ puts PP.pp(result, +'')
48
+ else
49
+ @out.puts(result.ai(indent: 10))
50
+ end
51
+ end
52
+
53
+ def configure_authentication(credentials: nil, api_key: nil, api_secret: nil)
54
+ resolved_api_key, resolved_api_secret = resolve_credentials(
55
+ credentials: credentials,
56
+ api_key: api_key,
57
+ api_secret: api_secret
58
+ )
59
+
60
+ DnsMadeEasy.api_key = resolved_api_key
61
+ DnsMadeEasy.api_secret = resolved_api_secret
62
+ end
63
+
64
+ def resolve_credentials(credentials: nil, api_key: nil, api_secret: nil)
65
+ return explicit_api_credentials(api_key, api_secret) if api_key || api_secret
66
+ return credentials_from_ini(Pathname.new(credentials).expand_path) if credentials
67
+ return [ENV.fetch('DNSMADEEASY_API_KEY'), ENV.fetch('DNSMADEEASY_API_SECRET')] if env_credentials?
68
+
69
+ default_credentials_path = DEFAULT_CREDENTIAL_PATHS.find(&:exist?)
70
+ return credentials_from_ini(default_credentials_path) if default_credentials_path
71
+
72
+ raise DnsMadeEasy::APIKeyAndSecretMissingError, 'DNS Made Easy credentials were not found'
73
+ end
74
+
75
+ def explicit_api_credentials(api_key, api_secret)
76
+ raise DnsMadeEasy::APIKeyAndSecretMissingError, '--api-key and --api-secret must be provided together' unless api_key && api_secret
77
+
78
+ [api_key, api_secret]
79
+ end
80
+
81
+ def env_credentials?
82
+ ENV.fetch('DNSMADEEASY_API_KEY', nil) && ENV.fetch('DNSMADEEASY_API_SECRET', nil)
83
+ end
84
+
85
+ def credentials_from_ini(path)
86
+ raise DnsMadeEasy::APIKeyAndSecretMissingError, "Credentials file #{path} does not exist" unless path.exist?
87
+
88
+ credentials = parse_ini_credentials(path)
89
+ api_key = credentials['api_key'] || credentials['dns_dnsmadeeasy_api_key']
90
+ api_secret = credentials['api_secret'] || credentials['dns_dnsmadeeasy_secret_key']
91
+ explicit_api_credentials(api_key, api_secret)
92
+ end
93
+
94
+ def parse_ini_credentials(path)
95
+ path.each_line.with_object({}) do |line, credentials|
96
+ key, value = parse_ini_line(line)
97
+ credentials[key] = value if key && value
98
+ end
99
+ end
100
+
101
+ def parse_ini_line(line)
102
+ stripped_line = line.strip
103
+ return nil if ignored_ini_line?(stripped_line)
104
+
105
+ key, value = stripped_line.split('=', 2).map(&:strip)
106
+ [key, unquote_ini_value(value)]
107
+ end
108
+
109
+ def ignored_ini_line?(stripped_line)
110
+ stripped_line.empty? || stripped_line.start_with?('#', ';', '[')
111
+ end
112
+
113
+ def unquote_ini_value(value)
114
+ value&.delete_prefix('"')&.delete_suffix('"')
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dnsmadeeasy/api/client'
4
+ require 'dnsmadeeasy/cli/commands/base'
5
+
6
+ module DnsMadeEasy
7
+ module CLI
8
+ # Registered dry-cli command classes.
9
+ module Commands
10
+ # Provides a migration hint for pre-1.0 root-level API operations.
11
+ class LegacyOperation < Base
12
+ desc 'Show migration hint for legacy root-level API operation'
13
+
14
+ def initialize(operation_name)
15
+ super()
16
+ @operation_name = operation_name
17
+ end
18
+
19
+ def call(**)
20
+ warn "Use `dme account #{@operation_name}` for this API operation."
21
+ raise ArgumentError, "legacy root operation #{@operation_name.inspect} moved under account"
22
+ end
23
+ end
24
+
25
+ DnsMadeEasy::Api::Client.public_operations.each do |operation_name|
26
+ register operation_name, LegacyOperation.new(operation_name), hidden: true
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dnsmadeeasy/version'
4
+ require 'dnsmadeeasy/cli/commands/base'
5
+
6
+ module DnsMadeEasy
7
+ module CLI
8
+ # Registered dry-cli command classes.
9
+ module Commands
10
+ # Prints the gem version.
11
+ class Version < Base
12
+ desc 'Print version'
13
+
14
+ def call(*)
15
+ puts DnsMadeEasy::VERSION
16
+ end
17
+ end
18
+
19
+ register 'version', Version, aliases: %w[v -v --version]
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,326 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dnsmadeeasy/cli/commands/base'
4
+ require 'dnsmadeeasy/cli/input'
5
+ require 'dnsmadeeasy/cli/message_helpers'
6
+ require 'dnsmadeeasy/zone/aname_flattener'
7
+ require 'dnsmadeeasy/zone/apply_executor'
8
+ require 'dnsmadeeasy/zone/diff'
9
+ require 'json'
10
+ require 'dnsmadeeasy/zone/parser'
11
+ require 'dnsmadeeasy/zone/plan_renderer'
12
+ require 'dnsmadeeasy/zone/remote_adapter'
13
+ require 'dnsmadeeasy/zone/serializer'
14
+ require 'yaml'
15
+
16
+ module DnsMadeEasy
17
+ module CLI
18
+ # Registered dry-cli command classes.
19
+ module Commands
20
+ # Zone-file management commands.
21
+ module Zone
22
+ # Validates a standard DNS zone file.
23
+ class Validate < Base
24
+ desc 'Validate a DNS zone file'
25
+
26
+ argument :file, required: true, desc: 'Zone file path'
27
+
28
+ def call(file:, **)
29
+ result = DnsMadeEasy::Zone::Parser.new(::File.read(file)).call
30
+
31
+ if result.success?
32
+ record_count = result.value!.records.length
33
+ success("Zone file is valid.\nRecords: #{record_count}")
34
+ else
35
+ warning("Zone file is invalid.\n#{result.failure.join("\n")}")
36
+ raise ReportedError, 'zone file is invalid'
37
+ end
38
+ end
39
+ end
40
+
41
+ # Formats a standard DNS zone file into canonical output.
42
+ class Format < Base
43
+ desc 'Format a DNS zone file'
44
+
45
+ argument :file, required: true, desc: 'Zone file path'
46
+
47
+ def call(file:, **)
48
+ result = DnsMadeEasy::Zone::Parser.new(::File.read(file)).call
49
+
50
+ if result.success?
51
+ puts DnsMadeEasy::Zone::Serializer.new(result.value!)
52
+ success("Zone file formatted.\nRecords: #{result.value!.records.length}")
53
+ else
54
+ error("Zone file is invalid.\n#{result.failure.join("\n")}")
55
+ raise ReportedError, 'zone file is invalid'
56
+ end
57
+ end
58
+ end
59
+
60
+ # Exports DNS Made Easy records as canonical zone-file text.
61
+ class Export < Base
62
+ desc 'Export DNS Made Easy records as a canonical zone file'
63
+
64
+ argument :domain, required: true, desc: 'Domain name'
65
+
66
+ option :format, aliases: ['f'], values: %w[rfc json yaml], required: false,
67
+ desc: 'Export format: rfc, json, or yaml'
68
+ option :output, required: false, desc: 'Output file path'
69
+ option :ttl, required: false, desc: 'Default TTL for records missing provider TTL'
70
+ option :include_apex_ns, type: :boolean, default: false, desc: 'Include apex NS records'
71
+ option :strict_rfc, type: :boolean, default: false,
72
+ desc: 'Flatten ANAME records into resolved A records (RFC-portable output)'
73
+
74
+ def call(**options)
75
+ configure_authentication(credentials: options[:credentials], api_key: options[:api_key],
76
+ api_secret: options[:api_secret])
77
+
78
+ domain = options.fetch(:domain)
79
+ export_ttl = options[:ttl] || 300
80
+ result = DnsMadeEasy::Zone::RemoteAdapter.new(
81
+ DnsMadeEasy.client.records_for(domain),
82
+ domain: domain,
83
+ default_ttl: export_ttl
84
+ ).call
85
+ fail_export(result.failure) if result.failure?
86
+
87
+ records_result = export_ready_records(result.value!, strict_rfc: options[:strict_rfc])
88
+ fail_export(records_result.failure) if records_result.failure?
89
+
90
+ records, warnings = records_result.value!
91
+ export_records(
92
+ domain,
93
+ records,
94
+ warnings: warnings,
95
+ format: options[:format] || 'rfc',
96
+ output: options[:output],
97
+ ttl: export_ttl,
98
+ include_apex_ns: options[:include_apex_ns]
99
+ )
100
+ success(
101
+ "Zone export complete.\nDomain: #{domain}\nRecords: #{records.length}\n" \
102
+ "Destination: #{options[:output] || 'STDOUT'}"
103
+ )
104
+ end
105
+
106
+ private
107
+
108
+ def fail_export(errors)
109
+ error("Zone export failed.\n#{errors.join("\n")}")
110
+ raise ReportedError, 'zone export failed'
111
+ end
112
+
113
+ def export_ready_records(remote_records, strict_rfc:)
114
+ return Dry::Monads::Success([remote_records.records, remote_records.warnings]) unless strict_rfc
115
+
116
+ DnsMadeEasy::Zone::AnameFlattener.new(remote_records.records).call.fmap do |(records, notices)|
117
+ [records, remote_records.warnings + notices]
118
+ end
119
+ end
120
+
121
+ def export_records(domain, records, warnings:, format:, output:, ttl:, include_apex_ns:)
122
+ warnings.each { |warning| warn warning }
123
+ zone_text = export_text(domain, records, format: format, ttl: ttl, include_apex_ns: include_apex_ns)
124
+
125
+ output ? ::File.write(output, zone_text) : puts(zone_text)
126
+ end
127
+
128
+ def export_text(domain, records, format:, ttl:, include_apex_ns:)
129
+ zone_file = zone_file(domain, records, ttl: ttl)
130
+
131
+ case format
132
+ when 'json'
133
+ JSON.pretty_generate(export_hash(zone_file))
134
+ when 'yaml'
135
+ export_hash(zone_file).to_yaml
136
+ else
137
+ DnsMadeEasy::Zone::Serializer.new(zone_file, omit_apex_ns: !include_apex_ns).to_s
138
+ end
139
+ end
140
+
141
+ def zone_file(domain, records, ttl:)
142
+ DnsMadeEasy::Zone::File.new(
143
+ origin: "#{domain.delete_suffix('.')}.",
144
+ ttl: dominant_ttl(records, fallback: ttl),
145
+ record_set: DnsMadeEasy::Zone::RecordSet.new(records: records)
146
+ )
147
+ end
148
+
149
+ # $TTL is the most common record TTL so the export stays faithful
150
+ # while keeping explicit per-record TTLs to a minimum.
151
+ def dominant_ttl(records, fallback:)
152
+ records.map(&:ttl).tally.max_by { |ttl, count| [count, -ttl] }&.first || fallback
153
+ end
154
+
155
+ def export_hash(zone_file)
156
+ {
157
+ 'origin' => zone_file.origin,
158
+ 'ttl' => zone_file.ttl,
159
+ 'records' => zone_file.sorted.map { |record| record_hash(record) }
160
+ }
161
+ end
162
+
163
+ def record_hash(record)
164
+ {
165
+ 'owner' => record.owner,
166
+ 'type' => record.type,
167
+ 'value' => record.value,
168
+ 'ttl' => record.ttl,
169
+ 'priority' => record.priority,
170
+ 'weight' => record.weight,
171
+ 'port' => record.port
172
+ }.compact
173
+ end
174
+ end
175
+
176
+ # Produces a non-destructive plan comparing a zone file to remote records.
177
+ class Plan < Base
178
+ desc 'Plan DNS changes for a zone file'
179
+
180
+ argument :file, required: true, desc: 'Zone file path'
181
+
182
+ option :domain, required: false, desc: 'Domain name'
183
+ option :format, values: %w[text json], default: 'text', required: false, desc: 'Plan output format'
184
+ option :diff_ttl, type: :boolean, default: false, desc: 'Treat TTL-only differences as updates'
185
+
186
+ def call(file:, domain: nil, format: 'text', diff_ttl: false, credentials: nil, api_key: nil, api_secret: nil, **)
187
+ configure_authentication(credentials: credentials, api_key: api_key, api_secret: api_secret)
188
+
189
+ desired_result = DnsMadeEasy::Zone::Parser.new(::File.read(file)).call
190
+ return fail_with('Zone file is invalid', desired_result.failure) if desired_result.failure?
191
+
192
+ plan_domain = domain || desired_result.value!.origin
193
+ remote_result = remote_records(plan_domain)
194
+ return fail_with('Remote records are invalid', remote_result.failure) if remote_result.failure?
195
+
196
+ plan = DnsMadeEasy::Zone::Diff.new(
197
+ desired_records: desired_result.value!.records,
198
+ remote_records: remote_result.value!.records,
199
+ compare_ttl: diff_ttl
200
+ ).call
201
+ renderer = DnsMadeEasy::Zone::PlanRenderer.new(plan)
202
+
203
+ puts(format == 'json' ? renderer.to_json : renderer.to_text)
204
+ success(plan_summary(plan_domain, plan))
205
+ end
206
+
207
+ private
208
+
209
+ def plan_summary(domain, plan)
210
+ [
211
+ "Zone plan complete for #{domain}.",
212
+ "Creates: #{plan.creates.length}, Updates: #{plan.updates.length}",
213
+ "Skipped creates: #{plan.skipped_creates.length}, Skipped deletes: #{plan.skipped_deletes.length}",
214
+ "Manual review: #{plan.ambiguous.length}"
215
+ ].join("\n")
216
+ end
217
+
218
+ def remote_records(domain)
219
+ DnsMadeEasy::Zone::RemoteAdapter.new(DnsMadeEasy.client.records_for(domain), domain: domain).call
220
+ end
221
+
222
+ def fail_with(message, errors)
223
+ error("#{message}.\n#{errors.join("\n")}")
224
+ raise ReportedError, message.downcase
225
+ end
226
+ end
227
+
228
+ # Applies a reviewed zone plan safely.
229
+ class Apply < Base
230
+ desc 'Apply DNS changes for a zone file'
231
+
232
+ argument :file, required: true, desc: 'Zone file path'
233
+
234
+ option :domain, required: false, desc: 'Domain name'
235
+ option :yes, aliases: ['y'], type: :boolean, default: false, desc: 'Apply without confirmation prompt'
236
+ option :add_only, aliases: ['a'], type: :boolean, default: false, desc: 'Only add missing records'
237
+ option :delete_only, aliases: ['d'], type: :boolean, default: false, desc: 'Only apply deletions'
238
+ option :merge, aliases: ['m'], type: :boolean, default: true, desc: 'Merge creates and updates'
239
+ option :diff_ttl, type: :boolean, default: false, desc: 'Treat TTL-only differences as updates'
240
+
241
+ def call(**options)
242
+ configure_authentication(credentials: options[:credentials], api_key: options[:api_key],
243
+ api_secret: options[:api_secret])
244
+
245
+ plan_context = build_plan_context(options.fetch(:file), options[:domain], compare_ttl: options[:diff_ttl])
246
+ return fail_with('Zone apply failed', plan_context.failure) if plan_context.failure?
247
+
248
+ mode = apply_mode(options)
249
+ executor = DnsMadeEasy::Zone::ApplyExecutor.new(
250
+ client: DnsMadeEasy.client,
251
+ domain: plan_context.value!.fetch(:domain),
252
+ plan: plan_context.value!.fetch(:plan),
253
+ remote_records: plan_context.value!.fetch(:remote_records),
254
+ mode: mode,
255
+ spinner_output: @err
256
+ )
257
+ executable_count = executor.executable_action_count
258
+ confirm!(executable_count) unless options[:yes]
259
+
260
+ result = executor.call
261
+ return fail_with('Zone apply failed', result.failure) if result.failure?
262
+
263
+ print_apply_summary(plan_context.value!.fetch(:domain), result.value!)
264
+ end
265
+
266
+ private
267
+
268
+ def build_plan_context(file, domain, compare_ttl: false)
269
+ desired_result = DnsMadeEasy::Zone::Parser.new(::File.read(file)).call
270
+ return desired_result if desired_result.failure?
271
+
272
+ plan_domain = domain || desired_result.value!.origin
273
+ remote_result = DnsMadeEasy::Zone::RemoteAdapter.new(DnsMadeEasy.client.records_for(plan_domain),
274
+ domain: plan_domain).call
275
+ return remote_result if remote_result.failure?
276
+
277
+ plan = DnsMadeEasy::Zone::Diff.new(
278
+ desired_records: desired_result.value!.records,
279
+ remote_records: remote_result.value!.records,
280
+ compare_ttl: compare_ttl
281
+ ).call
282
+
283
+ Dry::Monads::Success(domain: plan_domain, plan: plan, remote_records: remote_result.value!)
284
+ end
285
+
286
+ def apply_mode(options)
287
+ return :add_only if options[:add_only]
288
+ return :delete_only if options[:delete_only]
289
+
290
+ :merge
291
+ end
292
+
293
+ def confirm!(executable_count)
294
+ warn "Apply #{executable_count} action(s)? Type yes to continue:"
295
+ response = DnsMadeEasy::CLI::Input.stdin.gets.to_s.strip
296
+ return if response == 'yes'
297
+
298
+ raise ArgumentError, 'zone apply cancelled'
299
+ end
300
+
301
+ def print_apply_summary(domain, result)
302
+ summary = "Zone apply complete for #{domain}.\n" \
303
+ "Applied: #{result.applied_actions.length}\n" \
304
+ "Failed: #{result.failed_actions.length}\n" \
305
+ "Skipped: #{result.skipped_actions.length}"
306
+
307
+ result.failed_actions.empty? ? success(summary) : warning(summary)
308
+ end
309
+
310
+ def fail_with(message, errors)
311
+ error("#{message}.\n#{errors.join("\n")}")
312
+ raise ReportedError, message.downcase
313
+ end
314
+ end
315
+ end
316
+
317
+ register 'zone' do |prefix|
318
+ prefix.register 'validate', Zone::Validate
319
+ prefix.register 'fmt', Zone::Format, aliases: ['format']
320
+ prefix.register 'export', Zone::Export
321
+ prefix.register 'plan', Zone::Plan
322
+ prefix.register 'apply', Zone::Apply
323
+ end
324
+ end
325
+ end
326
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry/cli'
4
+
5
+ module DnsMadeEasy
6
+ module CLI
7
+ module Commands
8
+ extend Dry::CLI::Registry
9
+ end
10
+ end
11
+ end
12
+
13
+ require 'dnsmadeeasy/cli/message_helpers'
14
+ require 'dnsmadeeasy/cli/reported_error'
15
+ require 'dnsmadeeasy/cli/commands/base'
16
+ require 'dnsmadeeasy/cli/commands/version'
17
+ require 'dnsmadeeasy/cli/commands/account'
18
+ require 'dnsmadeeasy/cli/commands/zone'
19
+ require 'dnsmadeeasy/cli/commands/legacy_operation'
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DnsMadeEasy
4
+ module CLI
5
+ # Shared input stream for commands that need confirmation.
6
+ module Input
7
+ class << self
8
+ attr_accessor :stdin
9
+ end
10
+
11
+ self.stdin = $stdin
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry/cli'
4
+ require 'dnsmadeeasy/version'
5
+ require 'dnsmadeeasy/cli/commands'
6
+ require 'dnsmadeeasy/cli/input'
7
+
8
+ module DnsMadeEasy
9
+ module CLI
10
+ # Entrypoint used by the executable and in-process CLI specs.
11
+ class Launcher
12
+ attr_reader :argv, :stdin, :stdout, :stderr, :kernel
13
+
14
+ def initialize(argv, stdin = $stdin, stdout = $stdout, stderr = $stderr, kernel = Kernel)
15
+ @argv = argv
16
+ @stdin = stdin
17
+ @stdout = stdout
18
+ @stderr = stderr
19
+ @kernel = kernel
20
+ end
21
+
22
+ def execute!
23
+ return print_account_operation_help if account_operation_help?
24
+
25
+ Input.stdin = stdin
26
+ command.call(arguments: argv, out: stdout, err: stderr)
27
+ 0
28
+ rescue SystemExit => e
29
+ e.status
30
+ rescue ReportedError
31
+ 1
32
+ rescue StandardError => e
33
+ stderr.puts(e.message)
34
+ 1
35
+ end
36
+
37
+ private
38
+
39
+ def account_operation_help?
40
+ argv.first == 'account' && argv[1] && !argv[1].start_with?('-') && argv.intersect?(%w[--help -h])
41
+ end
42
+
43
+ def print_account_operation_help
44
+ stdout.puts Commands::Account.operation_help(argv[1])
45
+ 0
46
+ end
47
+
48
+ def command
49
+ @command ||= Dry::CLI.new(Commands)
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tty-box'
4
+
5
+ module DnsMadeEasy
6
+ module CLI
7
+ # Colorful boxed CLI status messages. All boxes print to stderr so that
8
+ # stdout carries only command payload (zone files, plan output) and
9
+ # stays safe to pipe or redirect.
10
+ #
11
+ # Include the module to call info/success/warning/error directly from a
12
+ # command; boxes then print to the includer's @err stream when present.
13
+ # The module-level methods (MessageHelpers.error etc.) remain available
14
+ # for callers outside the command classes.
15
+ module MessageHelpers
16
+ BOX_OPTIONS = {
17
+ border: { type: :thick },
18
+ width: 85
19
+ }.freeze
20
+
21
+ class << self
22
+ attr_accessor :stdout, :stderr
23
+
24
+ def included(base)
25
+ base.include(InstanceMethods)
26
+ end
27
+
28
+ def info(message)
29
+ print_box(:info, message, stderr)
30
+ end
31
+
32
+ def warn(message)
33
+ print_box(:warn, message, stderr)
34
+ end
35
+
36
+ def error(message)
37
+ print_box(:error, message, stderr)
38
+ end
39
+
40
+ def success(message)
41
+ print_box(:success, message, stderr)
42
+ end
43
+
44
+ def print_box(box_type, message, output)
45
+ box = TTY::Box.public_send(box_type, message, **BOX_OPTIONS)
46
+ output.puts(box)
47
+ box
48
+ end
49
+ end
50
+
51
+ # Boxed helpers for command classes. The warn box is exposed as
52
+ # #warning because commands already use Kernel-style plain #warn.
53
+ module InstanceMethods
54
+ def info(message)
55
+ MessageHelpers.print_box(:info, message, message_output)
56
+ end
57
+
58
+ def warning(message)
59
+ MessageHelpers.print_box(:warn, message, message_output)
60
+ end
61
+
62
+ def error(message)
63
+ MessageHelpers.print_box(:error, message, message_output)
64
+ end
65
+
66
+ def success(message)
67
+ MessageHelpers.print_box(:success, message, message_output)
68
+ end
69
+
70
+ private
71
+
72
+ def message_output
73
+ (defined?(@err) && @err) || MessageHelpers.stderr
74
+ end
75
+ end
76
+
77
+ self.stdout = $stdout
78
+ self.stderr = $stderr
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DnsMadeEasy
4
+ module CLI
5
+ # Raised after an error has already been rendered to the user;
6
+ # the Launcher exits non-zero without printing it again.
7
+ class ReportedError < StandardError
8
+ end
9
+ end
10
+ end