fabulous 0.1.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,666 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+ require "tty-table"
5
+ require "tty-spinner"
6
+ require "tty-prompt"
7
+ require "pastel"
8
+ require "dotenv/load"
9
+ require "date"
10
+ require "fabulous"
11
+
12
+ module Fabulous
13
+ class Nameservers < Thor
14
+ def initialize(*args)
15
+ super
16
+ @pastel = Pastel.new
17
+ configure_client
18
+ end
19
+
20
+ desc "get DOMAIN", "Get nameservers for a domain"
21
+ def get(domain_name)
22
+ spinner = TTY::Spinner.new("#{@pastel.cyan('⚡')} Fetching nameservers... ", format: :dots)
23
+ spinner.auto_spin
24
+
25
+ begin
26
+ nameservers = client.domains.get_nameservers(domain_name)
27
+ spinner.success(@pastel.green("✓ Found nameservers"))
28
+
29
+ puts
30
+ puts @pastel.bold.cyan("Nameservers for #{domain_name}:")
31
+ if nameservers && nameservers.any?
32
+ nameservers.each_with_index do |ns, i|
33
+ puts " #{@pastel.dim("#{i + 1}.")} #{@pastel.white(ns)}"
34
+ end
35
+ else
36
+ puts @pastel.yellow(" No nameservers found")
37
+ end
38
+ rescue Fabulous::Error => e
39
+ spinner.error(@pastel.red("✗ Error: #{e.message}"))
40
+ exit 1
41
+ end
42
+ end
43
+
44
+ desc "set DOMAIN NS1 NS2 [NS3...]", "Set nameservers for a domain"
45
+ def set(domain_name, *nameservers)
46
+ if nameservers.length < 2
47
+ puts @pastel.red("✗ Error: At least 2 nameservers required")
48
+ exit 1
49
+ end
50
+
51
+ puts @pastel.cyan("Setting nameservers for #{domain_name}:")
52
+ nameservers.each_with_index do |ns, i|
53
+ puts " #{i + 1}. #{ns}"
54
+ end
55
+
56
+ spinner = TTY::Spinner.new("#{@pastel.cyan('⚡')} Updating... ", format: :dots)
57
+ spinner.auto_spin
58
+
59
+ begin
60
+ if client.domains.set_nameservers(domain_name, nameservers)
61
+ spinner.success(@pastel.green("✓ Nameservers updated successfully"))
62
+ else
63
+ spinner.error(@pastel.red("✗ Failed to update nameservers"))
64
+ end
65
+ rescue Fabulous::Error => e
66
+ spinner.error(@pastel.red("✗ Error: #{e.message}"))
67
+ exit 1
68
+ end
69
+ end
70
+
71
+ private
72
+
73
+ def configure_client
74
+ Fabulous.configure do |config|
75
+ config.username = ENV["FABULOUS_USERNAME"]
76
+ config.password = ENV["FABULOUS_PASSWORD"]
77
+ end
78
+ end
79
+
80
+ def client
81
+ @client ||= Fabulous.client
82
+ end
83
+ end
84
+
85
+ class DNS < Thor
86
+ def initialize(*args)
87
+ super
88
+ @pastel = Pastel.new
89
+ @prompt = TTY::Prompt.new
90
+ configure_client
91
+ end
92
+
93
+ desc "list DOMAIN", "List all DNS records for a domain"
94
+ option :type, type: :string, enum: ["A", "AAAA", "CNAME", "MX", "TXT"], desc: "Filter by record type"
95
+ def list(domain_name)
96
+ spinner = TTY::Spinner.new("#{@pastel.cyan('⚡')} Fetching DNS records... ", format: :dots)
97
+ spinner.auto_spin
98
+
99
+ begin
100
+ records = client.dns.list_records(domain_name, type: options[:type])
101
+ spinner.success(@pastel.green("✓ Found #{records.length} records"))
102
+
103
+ if records.empty?
104
+ puts @pastel.yellow("No DNS records found")
105
+ else
106
+ display_dns_records(records)
107
+ end
108
+ rescue Fabulous::Error => e
109
+ spinner.error(@pastel.red("✗ Error: #{e.message}"))
110
+ exit 1
111
+ end
112
+ end
113
+
114
+ desc "add DOMAIN", "Add a DNS record interactively"
115
+ def add(domain_name)
116
+ type = @prompt.select("Choose record type:", %w[A AAAA CNAME MX TXT])
117
+
118
+ case type
119
+ when "A"
120
+ hostname = @prompt.ask("Hostname (e.g., www or @ for root):")
121
+ ip = @prompt.ask("IP Address:")
122
+ ttl = @prompt.ask("TTL:", default: "3600").to_i
123
+
124
+ spinner = TTY::Spinner.new("#{@pastel.cyan('⚡')} Adding A record... ", format: :dots)
125
+ spinner.auto_spin
126
+
127
+ if client.dns.add_a_record(domain_name, hostname: hostname, ip_address: ip, ttl: ttl)
128
+ spinner.success(@pastel.green("✓ A record added"))
129
+ else
130
+ spinner.error(@pastel.red("✗ Failed to add record"))
131
+ end
132
+
133
+ when "MX"
134
+ hostname = @prompt.ask("Mail server hostname:")
135
+ priority = @prompt.ask("Priority:", default: "10").to_i
136
+ ttl = @prompt.ask("TTL:", default: "3600").to_i
137
+
138
+ spinner = TTY::Spinner.new("#{@pastel.cyan('⚡')} Adding MX record... ", format: :dots)
139
+ spinner.auto_spin
140
+
141
+ if client.dns.add_mx_record(domain_name, hostname: hostname, priority: priority, ttl: ttl)
142
+ spinner.success(@pastel.green("✓ MX record added"))
143
+ else
144
+ spinner.error(@pastel.red("✗ Failed to add record"))
145
+ end
146
+
147
+ when "CNAME"
148
+ alias_name = @prompt.ask("Alias (e.g., blog):")
149
+ target = @prompt.ask("Target domain:")
150
+ ttl = @prompt.ask("TTL:", default: "3600").to_i
151
+
152
+ spinner = TTY::Spinner.new("#{@pastel.cyan('⚡')} Adding CNAME record... ", format: :dots)
153
+ spinner.auto_spin
154
+
155
+ if client.dns.add_cname_record(domain_name, alias_name: alias_name, target: target, ttl: ttl)
156
+ spinner.success(@pastel.green("✓ CNAME record added"))
157
+ else
158
+ spinner.error(@pastel.red("✗ Failed to add record"))
159
+ end
160
+
161
+ when "TXT"
162
+ hostname = @prompt.ask("Hostname (@ for root):")
163
+ text = @prompt.ask("Text value:")
164
+ ttl = @prompt.ask("TTL:", default: "3600").to_i
165
+
166
+ spinner = TTY::Spinner.new("#{@pastel.cyan('⚡')} Adding TXT record... ", format: :dots)
167
+ spinner.auto_spin
168
+
169
+ if client.dns.add_txt_record(domain_name, hostname: hostname, text: text, ttl: ttl)
170
+ spinner.success(@pastel.green("✓ TXT record added"))
171
+ else
172
+ spinner.error(@pastel.red("✗ Failed to add record"))
173
+ end
174
+ end
175
+ rescue Fabulous::Error => e
176
+ puts @pastel.red("✗ Error: #{e.message}")
177
+ exit 1
178
+ end
179
+
180
+ private
181
+
182
+ def configure_client
183
+ Fabulous.configure do |config|
184
+ config.username = ENV["FABULOUS_USERNAME"]
185
+ config.password = ENV["FABULOUS_PASSWORD"]
186
+ end
187
+ end
188
+
189
+ def client
190
+ @client ||= Fabulous.client
191
+ end
192
+
193
+ def display_dns_records(records)
194
+ table = TTY::Table.new(
195
+ header: [
196
+ @pastel.bold("Type"),
197
+ @pastel.bold("Name"),
198
+ @pastel.bold("Value"),
199
+ @pastel.bold("TTL"),
200
+ @pastel.bold("Priority")
201
+ ]
202
+ )
203
+
204
+ records.each do |record|
205
+ table << [
206
+ dns_type_badge(record[:type]),
207
+ record[:name] || "-",
208
+ truncate(record[:value], 40),
209
+ record[:ttl] || "-",
210
+ record[:priority] || "-"
211
+ ]
212
+ end
213
+
214
+ puts
215
+ puts table.render(:unicode, padding: [0, 1], border: { style: :cyan })
216
+ end
217
+
218
+ def dns_type_badge(type)
219
+ colors = {
220
+ "A" => :green,
221
+ "AAAA" => :green,
222
+ "CNAME" => :yellow,
223
+ "MX" => :blue,
224
+ "TXT" => :magenta
225
+ }
226
+
227
+ color = colors[type] || :white
228
+ @pastel.send(color, type || "?")
229
+ end
230
+
231
+ def truncate(text, length)
232
+ return "-" unless text
233
+ text.length > length ? "#{text[0...length]}..." : text
234
+ end
235
+ end
236
+
237
+ class CLI < Thor
238
+ def self.exit_on_failure?
239
+ true
240
+ end
241
+
242
+ def initialize(*args)
243
+ super
244
+ @pastel = Pastel.new
245
+ @prompt = TTY::Prompt.new
246
+ configure_client
247
+ end
248
+
249
+ desc "list", "List all domains in your portfolio"
250
+ option :sort, type: :string, enum: ["name", "expiry", "status"], default: "name", desc: "Sort by field"
251
+ option :filter, type: :string, desc: "Filter domains by name (partial match)"
252
+ option :expiring, type: :numeric, desc: "Show domains expiring within N days"
253
+ option :page, type: :numeric, desc: "Show specific page (without pagination)"
254
+ option :limit, type: :numeric, default: 20, desc: "Number of domains per page"
255
+ option :interactive, type: :boolean, default: false, desc: "Enable interactive pagination"
256
+ def list
257
+ spinner = TTY::Spinner.new("#{@pastel.cyan('⚡')} Fetching domains... ", format: :dots)
258
+ spinner.auto_spin
259
+
260
+ begin
261
+ domains = if options[:page]
262
+ client.domains.list(page: options[:page])
263
+ else
264
+ client.domains.all
265
+ end
266
+ spinner.success(@pastel.green("✓ Found #{domains.length} domains"))
267
+
268
+ # Apply filters
269
+ if options[:filter]
270
+ domains = domains.select { |d| d[:name].include?(options[:filter]) }
271
+ puts @pastel.yellow("Filtered to #{domains.length} domains matching '#{options[:filter]}'")
272
+ end
273
+
274
+ if options[:expiring]
275
+ today = Date.today
276
+ domains = domains.select do |d|
277
+ next false unless d[:expiry_date]
278
+ begin
279
+ expiry = Date.parse(d[:expiry_date])
280
+ days = (expiry - today).to_i
281
+ days > 0 && days <= options[:expiring]
282
+ rescue ArgumentError
283
+ false
284
+ end
285
+ end
286
+ puts @pastel.yellow("Showing #{domains.length} domains expiring within #{options[:expiring]} days")
287
+ end
288
+
289
+ # Sort domains
290
+ domains = sort_domains(domains, options[:sort])
291
+
292
+ # Display domains
293
+ if domains.empty?
294
+ puts @pastel.yellow("No domains found matching your criteria")
295
+ else
296
+ display_domains_table(domains, options[:limit], options[:interactive])
297
+ end
298
+ rescue Fabulous::Error => e
299
+ spinner.error(@pastel.red("✗ Error: #{e.message}"))
300
+ exit 1
301
+ end
302
+ end
303
+
304
+ desc "info DOMAIN", "Show detailed information about a domain"
305
+ def info(domain_name)
306
+ spinner = TTY::Spinner.new("#{@pastel.cyan('⚡')} Fetching domain info... ", format: :dots)
307
+ spinner.auto_spin
308
+
309
+ begin
310
+ info = client.domains.info(domain_name)
311
+
312
+ if info.nil?
313
+ spinner.error(@pastel.red("✗ Domain not found or no information available"))
314
+ exit 1
315
+ end
316
+
317
+ spinner.success(@pastel.green("✓ Domain found"))
318
+
319
+ puts
320
+ puts @pastel.bold.cyan("═" * 60)
321
+ puts @pastel.bold.white(" Domain Information: #{domain_name}")
322
+ puts @pastel.bold.cyan("═" * 60)
323
+ puts
324
+
325
+ display_info_item("Status", info[:status] || "Active", status_color(info[:status]))
326
+ display_info_item("Created", info[:creation_date] || "-")
327
+ display_info_item("Expires", info[:expiry_date] || "-", expiry_color(info[:expiry_date]))
328
+ display_info_item("Auto-Renew", info[:auto_renew].nil? ? "-" : (info[:auto_renew] ? "Enabled" : "Disabled"),
329
+ info[:auto_renew] ? :green : nil)
330
+ display_info_item("Domain Lock", info[:locked].nil? ? "-" : (info[:locked] ? "Locked" : "Unlocked"),
331
+ info[:locked] ? :green : nil)
332
+ display_info_item("WHOIS Privacy", info[:whois_privacy].nil? ? "-" : (info[:whois_privacy] ? "Enabled" : "Disabled"),
333
+ info[:whois_privacy] ? :green : nil)
334
+
335
+ if info[:nameservers] && info[:nameservers].any?
336
+ puts
337
+ puts @pastel.bold.cyan("Nameservers:")
338
+ info[:nameservers].each_with_index do |ns, i|
339
+ puts " #{@pastel.dim("#{i + 1}.")} #{@pastel.white(ns)}"
340
+ end
341
+ end
342
+
343
+ puts
344
+ puts @pastel.cyan("─" * 60)
345
+ rescue Fabulous::Error => e
346
+ spinner.error(@pastel.red("✗ Error: #{e.message}"))
347
+ exit 1
348
+ end
349
+ end
350
+
351
+ desc "search QUERY", "Search for domains"
352
+ def search(query)
353
+ invoke :list, [], filter: query
354
+ end
355
+
356
+ desc "expiring [DAYS]", "Show domains expiring soon"
357
+ def expiring(days = 30)
358
+ invoke :list, [], expiring: days.to_i
359
+ end
360
+
361
+ desc "nameservers SUBCOMMAND ...ARGS", "Manage nameservers"
362
+ subcommand "nameservers", Nameservers
363
+
364
+ desc "dns SUBCOMMAND ...ARGS", "Manage DNS records"
365
+ subcommand "dns", DNS
366
+
367
+ desc "check DOMAIN", "Check if a domain is available"
368
+ def check(domain_name)
369
+ spinner = TTY::Spinner.new("#{@pastel.cyan('⚡')} Checking availability... ", format: :dots)
370
+ spinner.auto_spin
371
+
372
+ begin
373
+ available = client.domains.check(domain_name)
374
+
375
+ if available
376
+ spinner.success(@pastel.green("✓ #{domain_name} is available!"))
377
+ else
378
+ spinner.stop(@pastel.yellow("✗ #{domain_name} is not available"))
379
+ end
380
+ rescue Fabulous::Error => e
381
+ spinner.error(@pastel.red("✗ Error: #{e.message}"))
382
+ exit 1
383
+ end
384
+ end
385
+
386
+ desc "summary", "Show portfolio summary"
387
+ def summary
388
+ spinner = TTY::Spinner.new("#{@pastel.cyan('⚡')} Analyzing portfolio... ", format: :dots)
389
+ spinner.auto_spin
390
+
391
+ begin
392
+ domains = client.domains.all
393
+ spinner.success(@pastel.green("✓ Analysis complete"))
394
+
395
+ puts
396
+ puts @pastel.bold.cyan("═" * 60)
397
+ puts @pastel.bold.white(" Portfolio Summary")
398
+ puts @pastel.bold.cyan("═" * 60)
399
+ puts
400
+
401
+ # Total domains
402
+ puts "#{@pastel.bold('Total Domains:')} #{@pastel.cyan(domains.length.to_s)}"
403
+ puts
404
+
405
+ # Group by year
406
+ by_year = domains.group_by do |d|
407
+ Date.parse(d[:expiry_date]).year rescue "Unknown"
408
+ end
409
+
410
+ puts @pastel.bold("Domains by Expiry Year:")
411
+ by_year.sort.each do |year, year_domains|
412
+ bar_length = (year_domains.length.to_f / domains.length * 30).round
413
+ bar = "█" * bar_length
414
+ puts " #{year}: #{@pastel.cyan(bar)} #{year_domains.length}"
415
+ end
416
+
417
+ # Expiring soon
418
+ today = Date.today
419
+ expiring_30 = domains.count do |d|
420
+ next false unless d[:expiry_date]
421
+ begin
422
+ expiry = Date.parse(d[:expiry_date])
423
+ days = (expiry - today).to_i
424
+ days > 0 && days <= 30
425
+ rescue
426
+ false
427
+ end
428
+ end
429
+
430
+ expiring_90 = domains.count do |d|
431
+ next false unless d[:expiry_date]
432
+ begin
433
+ expiry = Date.parse(d[:expiry_date])
434
+ days = (expiry - today).to_i
435
+ days > 0 && days <= 90
436
+ rescue
437
+ false
438
+ end
439
+ end
440
+
441
+ puts
442
+ puts @pastel.bold("Expiring Soon:")
443
+ puts " Next 30 days: #{color_expiry_count(expiring_30)}"
444
+ puts " Next 90 days: #{color_expiry_count(expiring_90)}"
445
+
446
+ puts
447
+ puts @pastel.cyan("─" * 60)
448
+ rescue Fabulous::Error => e
449
+ spinner.error(@pastel.red("✗ Error: #{e.message}"))
450
+ exit 1
451
+ end
452
+ end
453
+
454
+ desc "version", "Show version"
455
+ def version
456
+ puts "Fabulous CLI v#{Fabulous::VERSION}"
457
+ end
458
+
459
+ private
460
+
461
+ def configure_client
462
+ Fabulous.configure do |config|
463
+ config.username = ENV["FABULOUS_USERNAME"]
464
+ config.password = ENV["FABULOUS_PASSWORD"]
465
+ end
466
+
467
+ unless Fabulous.configuration.valid?
468
+ puts @pastel.red("✗ Error: Missing credentials")
469
+ puts "Please set FABULOUS_USERNAME and FABULOUS_PASSWORD environment variables"
470
+ puts "You can create a .env file with:"
471
+ puts " FABULOUS_USERNAME=your_username"
472
+ puts " FABULOUS_PASSWORD=your_password"
473
+ exit 1
474
+ end
475
+ end
476
+
477
+ def client
478
+ @client ||= Fabulous.client
479
+ end
480
+
481
+ def sort_domains(domains, sort_by)
482
+ case sort_by
483
+ when "expiry"
484
+ domains.sort_by { |d| d[:expiry_date] || "9999-12-31" }
485
+ when "status"
486
+ domains.sort_by { |d| d[:status] || "Unknown" }
487
+ else # name
488
+ domains.sort_by { |d| d[:name] }
489
+ end
490
+ end
491
+
492
+ def display_domains_table(domains, limit, interactive = false)
493
+ if interactive
494
+ display_domains_interactive(domains, limit)
495
+ else
496
+ # Non-interactive display - show all or limited number
497
+ display_limit = limit || domains.length
498
+ domains_to_show = domains.first(display_limit)
499
+
500
+ table = TTY::Table.new(
501
+ header: [
502
+ @pastel.bold("Domain"),
503
+ @pastel.bold("Status"),
504
+ @pastel.bold("Expires"),
505
+ @pastel.bold("Days Left")
506
+ ]
507
+ )
508
+
509
+ today = Date.today
510
+ domains_to_show.each do |domain|
511
+ days_left = calculate_days_left(domain[:expiry_date])
512
+
513
+ table << [
514
+ @pastel.white(domain[:name]),
515
+ status_badge(domain[:status]),
516
+ domain[:expiry_date] || "-",
517
+ days_left_badge(days_left)
518
+ ]
519
+ end
520
+
521
+ puts
522
+ puts table.render(:unicode, padding: [0, 1], border: { style: :cyan })
523
+
524
+ if domains.length > display_limit
525
+ puts
526
+ puts @pastel.dim("Showing #{display_limit} of #{domains.length} domains (use --limit to show more)")
527
+ end
528
+ end
529
+ end
530
+
531
+ def display_domains_interactive(domains, limit)
532
+ pages = (domains.length.to_f / limit).ceil
533
+ current_page = 0
534
+
535
+ loop do
536
+ start_idx = current_page * limit
537
+ end_idx = start_idx + limit
538
+ page_domains = domains[start_idx...end_idx]
539
+
540
+ table = TTY::Table.new(
541
+ header: [
542
+ @pastel.bold("Domain"),
543
+ @pastel.bold("Status"),
544
+ @pastel.bold("Expires"),
545
+ @pastel.bold("Days Left")
546
+ ]
547
+ )
548
+
549
+ today = Date.today
550
+ page_domains.each do |domain|
551
+ days_left = calculate_days_left(domain[:expiry_date])
552
+
553
+ table << [
554
+ @pastel.white(domain[:name]),
555
+ status_badge(domain[:status]),
556
+ domain[:expiry_date] || "-",
557
+ days_left_badge(days_left)
558
+ ]
559
+ end
560
+
561
+ puts
562
+ puts table.render(:unicode, padding: [0, 1], border: { style: :cyan })
563
+
564
+ if pages > 1
565
+ puts
566
+ puts @pastel.dim("Page #{current_page + 1} of #{pages} (#{domains.length} total domains)")
567
+
568
+ choices = []
569
+ choices << "Next page" if current_page < pages - 1
570
+ choices << "Previous page" if current_page > 0
571
+ choices << "Exit"
572
+
573
+ choice = @prompt.select("Navigate:", choices, cycle: true)
574
+
575
+ case choice
576
+ when "Next page"
577
+ current_page += 1
578
+ when "Previous page"
579
+ current_page -= 1
580
+ when "Exit"
581
+ break
582
+ end
583
+ else
584
+ break
585
+ end
586
+ end
587
+ end
588
+
589
+ def calculate_days_left(expiry_date)
590
+ return nil unless expiry_date
591
+ begin
592
+ expiry = Date.parse(expiry_date)
593
+ (expiry - Date.today).to_i
594
+ rescue ArgumentError
595
+ nil
596
+ end
597
+ end
598
+
599
+ def days_left_badge(days)
600
+ return "-" if days.nil?
601
+
602
+ if days < 0
603
+ @pastel.red("Expired")
604
+ elsif days <= 30
605
+ @pastel.red("#{days}d")
606
+ elsif days <= 90
607
+ @pastel.yellow("#{days}d")
608
+ else
609
+ @pastel.green("#{days}d")
610
+ end
611
+ end
612
+
613
+ def status_badge(status)
614
+ case status&.downcase
615
+ when "active"
616
+ @pastel.green("● Active")
617
+ when "inactive"
618
+ @pastel.red("● Inactive")
619
+ when "pending"
620
+ @pastel.yellow("● Pending")
621
+ else
622
+ @pastel.dim("● #{status || 'Unknown'}")
623
+ end
624
+ end
625
+
626
+ def display_info_item(label, value, color = nil)
627
+ formatted_value = value || "-"
628
+ formatted_value = @pastel.send(color, formatted_value) if color
629
+ puts " #{@pastel.bold(label.ljust(15))} #{formatted_value}"
630
+ end
631
+
632
+ def status_color(status)
633
+ case status&.downcase
634
+ when "active" then :green
635
+ when "inactive" then :red
636
+ when "pending" then :yellow
637
+ else nil
638
+ end
639
+ end
640
+
641
+ def expiry_color(expiry_date)
642
+ days = calculate_days_left(expiry_date)
643
+ return nil unless days
644
+
645
+ if days < 0
646
+ :red
647
+ elsif days <= 30
648
+ :red
649
+ elsif days <= 90
650
+ :yellow
651
+ else
652
+ :green
653
+ end
654
+ end
655
+
656
+ def color_expiry_count(count)
657
+ if count == 0
658
+ @pastel.green(count.to_s)
659
+ elsif count <= 5
660
+ @pastel.yellow(count.to_s)
661
+ else
662
+ @pastel.red(count.to_s)
663
+ end
664
+ end
665
+ end
666
+ end