porkbun 0.5.1 → 2.0.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3cf1f7fe28b5a9b974cf289160cd9f7c8b12bd4547e2814ba6d50e99a6d1feb3
4
- data.tar.gz: 5e3514fa7eecd74e9572d34c123af1953d6daaba34359184c2934adc62e20f22
3
+ metadata.gz: 0d5e8faabfe0bc6179bdb965bb3d2150e40a8ef4be37d70e16ea625b6ed1d653
4
+ data.tar.gz: 1044c9005c5ebbe24ac2c2478e70d87638ff68d034c05b78baa26913de8b15a8
5
5
  SHA512:
6
- metadata.gz: a11ad716a955f544a22c59f3c2bcad9bdb44bd310aee0d7c53427457c051ee5fb48ae6867e41db2e82c85abe2ee16e1a8a7f8659d23fa541db8a0ca920b024e7
7
- data.tar.gz: 802ccbc80c9082e90f357e83cd54da76e90aac3cffb28eb09857875c2bad71081f9b43d0815a547b003abec470c30fd43c476841c685f2c611bd543f68e57c76
6
+ metadata.gz: 352e4fe4edb99376d3965018027d4b06e6881d0bb9f20b2e34f0d41c210b2239fa79807692d3a554ebcab03f24d223299da1cf1acd140c64ff0dfdfbfdb56b43
7
+ data.tar.gz: bae37bcc7ee6640c7e3bab4576abfa1d97b092dea3041677cb70414e47b472ec193db9033c85dd1a5d4151a7785c391c07e3aa26c9310fbe6014237cf156885a
data/AGENTS.md ADDED
@@ -0,0 +1,41 @@
1
+ # Porkbun Ruby Agent Guide
2
+
3
+ This repository contains a Ruby gem and CLI for the Porkbun API. The project is a partial implementation focused on the author's needs.
4
+
5
+ ## Rules
6
+
7
+ - Read the relevant Ruby source and tests before changing behavior.
8
+ - Implement behavior in the local library API (`lib/`), not in the CLI. Keep CLI commands as thin argument and output adapters.
9
+ - Keep changes focused. Do not add speculative features or refactors.
10
+ - Add or update RSpec coverage for behavior changes.
11
+ - Run the test suite after every code modification and before every commit. Report the result.
12
+ - Update `README.md` every time you change code. Keep it in sync with the current API, CLI, and usage after every code modification; do not defer documentation updates. Update `CHANGELOG.md` with every user-facing change.
13
+ - Before bumping any dependency or project version, actually check the current latest release in an authoritative package registry. Do not rely on memory, an old lockfile, or an assumed version.
14
+ - Treat API and CLI renames as breaking changes. Update the major version when they remove or rename public behavior.
15
+ - Absolutely never commit, push, or change shared Git state unless the user explicitly instructs you to do so.
16
+
17
+ ## Structure
18
+
19
+ - `lib/` — gem implementation.
20
+ - `bin/` — executable scripts, including the CLI.
21
+ - `spec/` — RSpec tests.
22
+ - `README.md` — usage, API, CLI, and development documentation.
23
+ - `.github/workflows/` — CI configuration.
24
+
25
+ ## Commands
26
+
27
+ - Install dependencies: `bin/setup`
28
+ - Run tests: `bundle exec rake` or `bundle exec rspec`
29
+ - Open a console: `bin/console`
30
+ - Install the gem locally: `bundle exec rake install`
31
+ - Release the gem: `bundle exec rake release`
32
+
33
+ ## Documentation routing
34
+
35
+ | Topic | Document |
36
+ | --- | --- |
37
+ | Usage, API, CLI, development, and contributing | [README.md](README.md) |
38
+
39
+ ## Documentation and changelog
40
+
41
+ Update the relevant documentation and `CHANGELOG.md` with each user-facing change. Keep entries short and factual. If a change needs a design note, explain the decision and its impact in the relevant documentation.
data/CHANGELOG.md ADDED
@@ -0,0 +1,20 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here.
4
+
5
+ ## [2.0.0] - 2026-09-18
6
+
7
+ - Removed the CLI `list` and `retrieve` commands and replaced them with the single `ls` command for listing domains or retrieving records for a domain and optional record ID.
8
+ - Renamed the CLI `create` command to `add` and `delete_all` to `rm_rf`.
9
+ - Added the `rm` CLI command for deleting one unambiguous record by hostname, with or without a trailing dot.
10
+ - Added the `update` CLI command, with `up` as an alias, to change record content or TTL.
11
+ - Replaced the public `Porkbun::DNS.retrieve` API with `Porkbun::Domain.list`.
12
+ - Added `Porkbun.new(domain)` domain objects and `Porkbun::Record` objects for record retrieval, creation, update, and deletion.
13
+ - Added `Porkbun::Domain#zone_file`, `Porkbun::Domain.import`, and explicit `get_record` naming.
14
+ - Added RSpec coverage for `Porkbun::Domain.list`.
15
+ - Updated Thor to `1.5.0`.
16
+ - Fixed `--help` handling for CLI commands.
17
+ - Added project guidance in `AGENTS.md` and `CLAUDE.md`.
18
+ - Removed the external `http` dependency in favor of Ruby's standard library.
19
+ - Raised the minimum supported Ruby version to `3.2.0`.
20
+ - Updated development dependencies to their latest compatible releases.
data/CLAUDE.md ADDED
@@ -0,0 +1 @@
1
+ @AGENTS.md
data/Gemfile CHANGED
@@ -5,11 +5,9 @@ source 'https://rubygems.org'
5
5
  # Specify your gem's dependencies in porkbun.gemspec
6
6
  gemspec
7
7
 
8
- group :development do
9
- # gem "rake", "~> 13.0", :group => :test
10
- gem 'pry', '~> 0.14.2', group: :test
11
- gem 'rspec', '~> 3.12', group: :test
12
- gem 'webmock', '~> 3.19', group: :test
8
+ group :development, :test do
9
+ gem 'rake', '~> 13.4'
10
+ gem 'pry', '~> 0.16'
11
+ gem 'rspec', '~> 3.13'
12
+ gem 'webmock', '~> 3.26'
13
13
  end
14
-
15
- gem 'thor', '~> 1.2'
data/Gemfile.lock CHANGED
@@ -1,76 +1,60 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- porkbun (0.4.0)
5
- http (~> 5.1.1)
6
- thor (~> 1.2)
4
+ porkbun (2.0.0)
5
+ thor (~> 1.5)
7
6
 
8
7
  GEM
9
8
  remote: https://rubygems.org/
10
9
  specs:
11
- addressable (2.8.5)
12
- public_suffix (>= 2.0.2, < 6.0)
10
+ addressable (2.9.0)
11
+ public_suffix (>= 2.0.2, < 8.0)
12
+ bigdecimal (4.1.3)
13
13
  coderay (1.1.3)
14
- crack (0.4.5)
14
+ crack (1.0.1)
15
+ bigdecimal
15
16
  rexml
16
- diff-lcs (1.5.0)
17
- domain_name (0.5.20190701)
18
- unf (>= 0.0.5, < 1.0.0)
19
- ffi (1.15.5)
20
- ffi-compiler (1.0.1)
21
- ffi (>= 1.0.0)
22
- rake
23
- hashdiff (1.0.1)
24
- http (5.1.1)
25
- addressable (~> 2.8)
26
- http-cookie (~> 1.0)
27
- http-form_data (~> 2.2)
28
- llhttp-ffi (~> 0.4.0)
29
- http-cookie (1.0.5)
30
- domain_name (~> 0.5)
31
- http-form_data (2.3.0)
32
- llhttp-ffi (0.4.0)
33
- ffi-compiler (~> 1.0)
34
- rake (~> 13.0)
35
- method_source (1.0.0)
36
- pry (0.14.2)
17
+ diff-lcs (1.6.2)
18
+ hashdiff (1.2.1)
19
+ io-console (0.9.4)
20
+ method_source (1.1.0)
21
+ pry (0.16.0)
37
22
  coderay (~> 1.1)
38
23
  method_source (~> 1.0)
39
- public_suffix (5.0.3)
40
- rake (13.0.6)
41
- rexml (3.2.6)
42
- rspec (3.12.0)
43
- rspec-core (~> 3.12.0)
44
- rspec-expectations (~> 3.12.0)
45
- rspec-mocks (~> 3.12.0)
46
- rspec-core (3.12.2)
47
- rspec-support (~> 3.12.0)
48
- rspec-expectations (3.12.3)
24
+ reline (>= 0.6.0)
25
+ public_suffix (7.0.5)
26
+ rake (13.4.2)
27
+ reline (0.7.0)
28
+ io-console (~> 0.5)
29
+ rexml (3.4.4)
30
+ rspec (3.13.2)
31
+ rspec-core (~> 3.13.0)
32
+ rspec-expectations (~> 3.13.0)
33
+ rspec-mocks (~> 3.13.0)
34
+ rspec-core (3.13.6)
35
+ rspec-support (~> 3.13.0)
36
+ rspec-expectations (3.13.5)
49
37
  diff-lcs (>= 1.2.0, < 2.0)
50
- rspec-support (~> 3.12.0)
51
- rspec-mocks (3.12.6)
38
+ rspec-support (~> 3.13.0)
39
+ rspec-mocks (3.13.8)
52
40
  diff-lcs (>= 1.2.0, < 2.0)
53
- rspec-support (~> 3.12.0)
54
- rspec-support (3.12.1)
55
- thor (1.2.2)
56
- unf (0.1.4)
57
- unf_ext
58
- unf_ext (0.0.8.2)
59
- webmock (3.19.1)
41
+ rspec-support (~> 3.13.0)
42
+ rspec-support (3.13.7)
43
+ thor (1.5.0)
44
+ webmock (3.26.4)
60
45
  addressable (>= 2.8.0)
61
46
  crack (>= 0.3.2)
62
47
  hashdiff (>= 0.4.0, < 2.0.0)
63
48
 
64
49
  PLATFORMS
65
- arm64-darwin-22
66
- arm64-darwin-23
50
+ ruby
67
51
 
68
52
  DEPENDENCIES
69
53
  porkbun!
70
- pry (~> 0.14.2)
71
- rspec (~> 3.12)
72
- thor (~> 1.2)
73
- webmock (~> 3.19)
54
+ pry (~> 0.16)
55
+ rake (~> 13.4)
56
+ rspec (~> 3.13)
57
+ webmock (~> 3.26)
74
58
 
75
59
  BUNDLED WITH
76
60
  2.4.12
data/README.md CHANGED
@@ -14,11 +14,18 @@ need a specific call to be implemented, let me know, or submit a PR
14
14
  ```ruby
15
15
  ENV['PORKBUN_API_KEY'] = 'YOUR_API_KEY'
16
16
  ENV['PORKBUN_SECRET_API_KEY'] = 'YOUR_SECRET_API_KEY'
17
- record = Porkbun::DNS.create(name: 'test',
17
+ domain = Porkbun.new('domain.org')
18
+
19
+ domain.records.each { |record| puts record.to_s }
20
+
21
+ record = domain.create_record('test',
18
22
  type: 'A',
19
23
  content: '1.1.1.1',
20
24
  ttl: 300
21
25
  )
26
+ record.update(content: '8.8.8.8')
27
+ record.delete
28
+
22
29
  ```
23
30
 
24
31
  ## API
@@ -27,22 +34,60 @@ record = Porkbun::DNS.create(name: 'test',
27
34
 
28
35
  Make sure your keys are good.
29
36
 
30
- ### `Porkbun::DNS.retrieve(domain, id)`
37
+ ## Classes
38
+
39
+ ### `Porkbun::Domain`
40
+
41
+ #### class methods
31
42
 
32
- Retrive all or specific record for a domain
43
+ - `all` - returns an array of `Porkbun::Domain` objects for each domain in the account.
44
+ - `create_record(hostname, options)` - returns a `Porkbun::Record` created from a full hostname.
45
+ - `get_record(hostname)` - returns one unambiguous `Porkbun::Record`.
46
+ - `import(file)` - returns an array of `Porkbun::Record` objects created from a zone file.
47
+ - `update_dynamic(hostname, ip)` - returns a hash describing the created or updated record.
33
48
 
34
- ### `Porkbun::DNS.create(options)`
49
+ ```ruby
50
+ Porkbun::Domain.all.each { |domain| puts domain }
51
+ record = Porkbun::Domain.get_record('www.domain.org')
52
+ record.update(content: '8.8.8.8')
53
+ ```
54
+
55
+ #### instance methods
35
56
 
36
- Create record for a domain
57
+ `Porkbun.new('domain.org')` returns a `Porkbun::Domain` object.
37
58
 
38
- options:
39
- - `name` - name of record. example `www`
40
- - `type` - record type. example: CNAME
41
- - `content` - content of record. example '1.1.1.1',
42
- - `ttl` - time to live. example: 600. porkbun seems to have this as a minimum
43
- - `prio` - record priority. mainly for MX records. example 10.
59
+ - `records` - returns an array of `Porkbun::Record` objects for the domain.
60
+ - `get_record(name)` - returns one unambiguous `Porkbun::Record`; `name` can be relative or fully qualified.
61
+ - `create_record(name, options)` - returns a new `Porkbun::Record`.
62
+ - `delete_all_records` - returns an array of `Porkbun::Record` objects after deleting all non-NS records.
63
+ - `zone_file` - returns a string that can be passed to `import`.
64
+
65
+ ```ruby
66
+ domain = Porkbun.new('domain.org')
67
+ record = domain.get_record('www')
68
+ record.update(content: '8.8.8.8')
69
+ record.delete
70
+
71
+ domain.delete_all_records
72
+ ```
73
+
74
+ ### `Porkbun::Record`
75
+
76
+ A `Porkbun::Record` represents one DNS record and owns its mutations.
77
+
78
+ #### instance methods
79
+
80
+ - `update(content:, ttl:)` - returns the updated `Porkbun::Record`.
81
+ - `delete` - returns the deleted `Porkbun::Record`.
82
+ - `to_s` - returns a string containing the BIND zone entry.
83
+
84
+ ```ruby
85
+ record = domain.get_record('www')
86
+ puts record.to_s
87
+ record.update(ttl: 700)
88
+ record.delete
89
+ ```
44
90
 
45
- returns instance of DNS which can be used to delete
46
91
 
47
92
  ## CLI
48
93
 
@@ -50,12 +95,15 @@ The gem also comes with a CLI
50
95
 
51
96
  $ porkbun
52
97
  Commands:
53
- porkbun delete_all <domain> # deletes all records for a domain. this is destructive. use with caution
54
- porkbun dyndns <hostname.domain> [<ip>] # Update a dynamic dns record. example: porkbun dyndns home.example.com
55
- porkbun help [COMMAND] # Describe available commands or one specific command
56
- porkbun import <file> # Import BIND zone file
57
- porkbun list # List all domains
58
- porkbun retrieve <domain> [<id>] # List all records for a domain
98
+ porkbun add RECORD --content=CONTENT --type=TYPE # Add a new record
99
+ porkbun update RECORD [--content=CONTENT] [--ttl=TTL] # Update a record (alias: up)
100
+ porkbun rm_rf DOMAIN # deletes all records for a domain. this is destructive. use with caution
101
+ porkbun rm RECORD # Delete one record for a hostname
102
+ porkbun dyndns HOSTNAME [IP] # Update a dynamic dns record. example: porkbun dyndns home.example.com
103
+ porkbun env # Print environment variables
104
+ porkbun help [COMMAND] # Describe available commands or one specific command
105
+ porkbun import FILE # Import BIND zone file
106
+ porkbun ls [DOMAIN] [ID] # List all domains or records for a domain
59
107
 
60
108
 
61
109
  be sure to set the environmental variables for it to work
@@ -67,7 +115,7 @@ export PORKBUN_SECRET_API_KEY = YOUR_SECRET_API_KEY
67
115
 
68
116
  After checking out the repo, run `bin/setup` to install dependencies. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
69
117
 
70
- To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
118
+ Run the test suite with `just test`. To publish a release, update the version number, then run `just publish`. This runs the tests and then calls the supported release task, which creates a git tag, pushes git commits and the tag, and publishes the `.gem` file to [rubygems.org](https://rubygems.org).
71
119
 
72
120
  ## Contributing
73
121
 
data/Rakefile CHANGED
@@ -1,4 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "bundler/gem_tasks"
4
- task default: %i[]
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+ task default: %i[spec]
data/bin/porkbun CHANGED
@@ -5,109 +5,101 @@ require 'porkbun'
5
5
  require 'thor'
6
6
 
7
7
  class CLI < Thor
8
- desc 'import <file>', 'Import BIND zone file'
8
+ remove_command :tree
9
+ map 'up' => 'update'
10
+
11
+ def self.basename
12
+ 'porkbun'
13
+ end
14
+
15
+ desc 'import FILE', 'Import BIND zone file'
9
16
  def import(file)
10
- record_regex = /^(?<hostname>[^\s]+)\.\s+(?<ttl>\d+)\s+IN\s+(?<type>[^\s]+)\s*(?<priority>\d+)?\s+(?<content>.+)$/
11
- IO.readlines(file).each do |line|
12
- match_data = line.match(record_regex)
13
- next unless match_data
14
-
15
- domain = match_data[:hostname].split('.')[-2..].join('.').chomp '.'
16
- name = match_data[:hostname].split('.')[0..-3].join('.')
17
-
18
- options = {
19
- domain:,
20
- name:,
21
- ttl: match_data[:ttl],
22
- type: match_data[:type],
23
- prio: match_data[:priority],
24
- content: match_data[:content].chomp('.').gsub(/^"|"$/, '')
25
- }.compact
26
-
27
- record = Porkbun::DNS.create options
28
- pp record
29
- end
17
+ Porkbun::Domain.import(file).each { |record| puts record }
30
18
  end
31
19
 
32
- desc 'list', 'List all domains'
33
- def list
34
- Porkbun::Domain.list_all[:domains].each do |domain|
35
- puts domain[:domain]
20
+ desc 'ls [DOMAIN] [ID]', 'List all domains or records for a domain'
21
+ def ls(domain = nil, id = '')
22
+ if domain
23
+ puts Porkbun.new(domain).records
24
+ else
25
+ Porkbun::Domain.all.each do |domain|
26
+ puts domain
27
+ end
36
28
  end
37
29
  end
38
30
 
31
+
39
32
  desc 'env', 'Print environment variables'
40
33
  def env
41
34
  puts 'PORKBUN_API_KEY: ' + ENV['PORKBUN_API_KEY'].to_s
42
- puts 'BUN_SECRET_API_KEY: ' + ENV['PORKBUN_SECRET_API_KEY'].to_s
35
+ puts 'PORKBUN_SECRET_API_KEY: ' + ENV['PORKBUN_SECRET_API_KEY'].to_s
43
36
  end
44
37
 
45
- desc 'delete_all <domain>', 'deletes all records for a domain. this is destructive. use with caution'
46
- def delete_all(domain, id = '')
47
- records = get_all(domain, id)
48
- records.each do |record|
49
- next if record.type == 'NS'
50
-
38
+ desc 'rm_rf DOMAIN', 'deletes all records for a domain. this is destructive. use with caution'
39
+ def rm_rf(domain, id = '')
40
+ Porkbun.new(domain).delete_all_records.each do |record|
51
41
  puts "DELETE #{record}"
52
- record.delete
53
42
  end
54
43
  end
55
44
 
56
- desc 'create', 'Create a new record'
45
+ desc 'rm RECORD', 'Delete one record for a hostname'
46
+ def rm(hostname)
47
+ record = Porkbun::Domain.get_record(hostname)
48
+ record.delete
49
+ puts "DELETE #{record}"
50
+ end
51
+
52
+ desc 'update RECORD', 'Update a record (alias: up)'
53
+ option :content, desc: 'The new record content'
54
+ option :ttl, type: :numeric, desc: 'The new TTL in seconds'
55
+ def update(hostname)
56
+ raise Thor::Error, 'Specify --content or --ttl' unless options[:content] || options[:ttl]
57
+
58
+ record = Porkbun::Domain.get_record(hostname)
59
+ puts "UPDATE #{record}"
60
+ record.update(options)
61
+ puts record.message
62
+ end
63
+
64
+
65
+ desc 'add RECORD', 'Add a new record'
57
66
  option :type, required: true, desc: 'The record type: A, AAAA, CNAME, MX, TXT, SRV, NS, SOA'
58
- option :domain, required: true, desc: 'The domain name: google.com'
59
- option :name, required: true, desc: 'The hostname: www'
60
67
  option :content, required: true, desc: 'The content: hostname, or IP address'
61
68
  option :ttl, default: 600, type: :numeric, desc: 'The TTL in seconds'
62
69
  option :prio, default: 0, type: :numeric, desc: 'The priority for MX records'
63
- def create
64
- record = Porkbun::DNS.create(options)
70
+ def add(hostname)
71
+ record = Porkbun::Domain.create_record(hostname, options)
65
72
  puts record
66
73
  puts record.message
67
74
  end
68
75
 
69
- desc 'dyndns <hostname.domain> [<ip>]', 'Update a dynamic dns record. example: porkbun dyndns home.example.com'
76
+ desc 'dyndns HOSTNAME [IP]', 'Update a dynamic dns record. example: porkbun dyndns home.example.com'
70
77
  long_desc 'If no IP is provided, the current public IP is used.'
71
78
  def dyndns(hostname, ip = nil)
72
- ip ||= HTTP.get('https://canhazip.com').to_s.chomp
73
- domain = hostname.split('.')[-2..].join('.').chomp '.'
74
- name = hostname.split('.')[0..-3].join('.')
75
- records = get_all(domain)
76
- record = records.find { |r| r.name == hostname }
77
- if record
78
- record.content = ip
79
- record.name = name
80
- puts "UPDATE #{record}"
81
- record.save
82
- puts record.message
83
- else
79
+ ip ||= Porkbun::Domain.public_ip
80
+ result = Porkbun::Domain.update_dynamic(hostname, ip)
81
+ if result[:created]
84
82
  print 'CREATE '
85
- record = Porkbun::DNS.create(domain:, name:, type: 'A', content: ip)
86
- puts record
87
- puts record.message
83
+ puts result[:record]
84
+ puts result[:record].message
85
+ else
86
+ puts "UPDATE #{result[:display]}"
87
+ puts result[:record].message
88
88
  end
89
89
  end
90
90
 
91
- desc 'retrieve <domain> [<id>]', 'List all records for a domain'
92
- def retrieve(domain, id = '')
93
- records = get_all(domain, id)
94
- puts records
95
- end
96
91
 
97
92
  def self.exit_on_failure?
98
93
  exit 1
99
94
  end
100
95
 
101
- private
96
+ end
102
97
 
103
- def get_all(domain, id = '')
104
- records = Porkbun::DNS.retrieve(domain, id)
105
- if records.instance_of?(Porkbun::Error)
106
- puts records.message
107
- exit 1
108
- end
109
- records
98
+ if $PROGRAM_NAME == __FILE__
99
+ if ARGV.include?('--help')
100
+ command = ARGV.find { |arg| !arg.start_with?('-') }
101
+ CLI.start(command ? ['help', command] : ['help'])
102
+ else
103
+ CLI.start(ARGV)
110
104
  end
111
105
  end
112
-
113
- CLI.start(ARGV)
data/justfile ADDED
@@ -0,0 +1,8 @@
1
+ _default:
2
+ @just -l
3
+
4
+ test:
5
+ mise exec -- env BUNDLE_PATH=.bundle bundle exec rake
6
+
7
+ publish: test
8
+ mise exec -- env BUNDLE_PATH=.bundle bundle exec rake release
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Porkbun
4
- VERSION = "0.5.1"
4
+ VERSION = "2.0.0"
5
5
  end
data/lib/porkbun.rb CHANGED
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'http'
3
+ require 'json'
4
+ require 'net/http'
5
+ require 'uri'
4
6
  require_relative 'porkbun/version'
5
7
 
6
8
  module Porkbun
@@ -10,10 +12,16 @@ module Porkbun
10
12
  if ENV.fetch('PORKBUN_API_KEY', nil).nil? || ENV.fetch('PORKBUN_SECRET_API_KEY', nil).nil?
11
13
  abort 'PORKBUN_API_KEY and PORKBUN_SECRET_API_KEY must be set'
12
14
  end
13
- res = HTTP.post File.join('https://porkbun.com/api/json/v3', path), json: {
15
+ uri = URI(File.join('https://api.porkbun.com/api/json/v3', path))
16
+ request = Net::HTTP::Post.new(uri)
17
+ request['Content-Type'] = 'application/json'
18
+ request.body = {
14
19
  secretapikey: ENV.fetch('PORKBUN_SECRET_API_KEY', nil),
15
20
  apikey: ENV.fetch('PORKBUN_API_KEY', nil)
16
- }.merge(options)
21
+ }.merge(options).to_json
22
+ res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do |http|
23
+ http.request(request)
24
+ end
17
25
 
18
26
  JSON.parse(res.body, symbolize_names: true)
19
27
  end
@@ -41,7 +49,11 @@ module Porkbun
41
49
  porkbun 'ping'
42
50
  end
43
51
 
44
- class DNS < Abstract
52
+ def self.new(domain)
53
+ Domain.new(domain)
54
+ end
55
+
56
+ class Record < Abstract
45
57
  attr_accessor :name, :content, :type, :ttl, :prio, :domain, :id, :notes
46
58
 
47
59
  def initialize(options)
@@ -55,7 +67,7 @@ module Porkbun
55
67
  end
56
68
 
57
69
  def self.create(options)
58
- record = DNS.new options
70
+ record = Record.new options
59
71
  record.create
60
72
  end
61
73
 
@@ -72,17 +84,124 @@ module Porkbun
72
84
  edit
73
85
  end
74
86
 
75
- def self.retrieve(domain, id = nil)
87
+ def update(options = {})
88
+ self.name = Record.relative_record_name(self)
89
+ self.content = options[:content] if options.key?(:content)
90
+ self.ttl = options[:ttl] if options.key?(:ttl)
91
+ save
92
+ end
93
+
94
+ def self.list(domain, id = nil)
76
95
  raise Error, 'need domain' unless domain
77
96
 
78
97
  res = Porkbun.porkbun File.join('dns/retrieve', domain, id || '').chomp('/')
79
98
  return Error.new(res[:message]) if res[:status] == 'ERROR'
80
99
 
81
100
  res[:records].map do |record|
82
- DNS.new record.merge(domain:)
101
+ Record.new record.merge(domain:)
102
+ end
103
+ end
104
+
105
+ def self.domain_for(hostname)
106
+ fqdn = hostname.to_s.chomp('.')
107
+ domains = Porkbun::Domain.list_all[:domains].map { |item| item[:domain] }
108
+ domains.sort_by(&:length).reverse.find do |domain|
109
+ fqdn == domain || fqdn.end_with?(".#{domain}")
110
+ end
111
+ end
112
+
113
+ def self.records_for(target, id = nil)
114
+ requested = target.to_s.chomp('.')
115
+ owner = domain_for(requested) || requested.split('.').last(2).join('.')
116
+ records = list(owner, id)
117
+ raise records if records.is_a?(Error)
118
+ return records if owner == requested
119
+
120
+ records.select { |record| record_hostname(record, owner) == requested }
121
+ end
122
+
123
+ def self.find_record(hostname)
124
+ records = records_for(hostname)
125
+ raise Error, 'No record found for hostname' if records.empty?
126
+ raise Error, 'Multiple records found for hostname' if records.length > 1
127
+
128
+ records.first
129
+ end
130
+
131
+ def self.create_record(record, options)
132
+ fqdn = record.chomp('.')
133
+ domain = domain_for(fqdn) || fqdn.split('.').last(2).join('.')
134
+ name = fqdn == domain ? '' : fqdn.delete_suffix(".#{domain}")
135
+ create(options.merge(domain:, name:))
136
+ end
137
+
138
+ def self.update_record(record, options)
139
+ record.name = relative_record_name(record)
140
+ record.content = options[:content] if options[:content]
141
+ record.ttl = options[:ttl] if options[:ttl]
142
+ record.save
143
+ record
144
+ end
145
+
146
+ def self.delete_record(hostname)
147
+ record = find_record(hostname)
148
+ record.delete
149
+ record
150
+ end
151
+
152
+ def self.delete_all(domain, id = '')
153
+ list(domain, id).reject { |record| record.type == 'NS' }.each(&:delete)
154
+ end
155
+
156
+ def self.import_zone(file)
157
+ record_regex = /^(?<hostname>[^\s]+)\.\s+(?<ttl>\d+)\s+IN\s+(?<type>[^\s]+)\s*(?<priority>\d+)?\s+(?<content>.+)$/
158
+ IO.readlines(file).filter_map do |line|
159
+ match_data = line.match(record_regex)
160
+ next unless match_data
161
+
162
+ labels = match_data[:hostname].split('.')
163
+ options = {
164
+ domain: labels[-2..].join('.'),
165
+ name: labels[0..-3].join('.'),
166
+ ttl: match_data[:ttl],
167
+ type: match_data[:type],
168
+ prio: match_data[:priority],
169
+ content: match_data[:content].chomp('.').gsub(/^"|"$/, '')
170
+ }.compact
171
+ create(options)
83
172
  end
84
173
  end
85
174
 
175
+ def self.public_ip
176
+ Net::HTTP.get(URI('https://canhazip.com')).chomp
177
+ end
178
+
179
+ def self.update_dynamic(hostname, ip)
180
+ records = records_for(hostname)
181
+ return { created: true, record: create_record(hostname, type: 'A', content: ip) } if records.empty?
182
+
183
+ record = records.first
184
+ display = record.to_s
185
+ update_record(record, content: ip)
186
+ { created: false, display:, record: }
187
+ end
188
+
189
+ def self.record_hostname(record, domain)
190
+ name = record.name.to_s.chomp('.')
191
+ return domain if name.empty? || name == '@'
192
+
193
+ name.end_with?(".#{domain}") ? name : "#{name}.#{domain}"
194
+ end
195
+
196
+ def self.relative_record_name(record)
197
+ name = record.name.to_s.chomp('.')
198
+ domain = record.domain.to_s.chomp('.')
199
+ return '' if name == domain
200
+ return name.delete_suffix(".#{domain}") if name.end_with?(".#{domain}")
201
+
202
+ name
203
+ end
204
+
86
205
  def delete
87
206
  raise Error, 'Need ID to delete record' unless id
88
207
 
@@ -105,6 +224,7 @@ module Porkbun
105
224
  "#{name}. #{ttl} IN #{type} #{prio_str} #{content_str}".tr_s(' ', ' ')
106
225
  end
107
226
 
227
+
108
228
  def to_h
109
229
  {
110
230
  name: name,
@@ -137,4 +257,111 @@ module Porkbun
137
257
  options
138
258
  end
139
259
  end
260
+
261
+
262
+ class Domain
263
+ attr_reader :domain
264
+
265
+ def initialize(domain)
266
+ @domain = domain.to_s.chomp('.')
267
+ end
268
+
269
+ def self.all
270
+ list_all[:domains].map { |item| new(item[:domain]) }
271
+ end
272
+
273
+ def to_s
274
+ domain
275
+ end
276
+
277
+ def records
278
+ Record.list(domain)
279
+ end
280
+
281
+ def records_for(target)
282
+ requested = normalize_hostname(target)
283
+ records = Record.list(domain)
284
+ raise records if records.is_a?(Error)
285
+ return records if requested == domain
286
+
287
+ records.select { |record| Record.record_hostname(record, domain) == requested }
288
+ end
289
+
290
+ def get_record(name)
291
+ matches = records_for(name)
292
+ raise Error, 'No record found for hostname' if matches.empty?
293
+ raise Error, 'Multiple records found for hostname' if matches.length > 1
294
+
295
+ matches.first
296
+ end
297
+
298
+ def create_record(name, options)
299
+ hostname = normalize_hostname(name)
300
+ relative_name = hostname == domain ? '' : hostname.delete_suffix(".#{domain}")
301
+ Record.create(options.merge(domain:, name: relative_name))
302
+ end
303
+
304
+
305
+ def delete_all_records
306
+ Record.delete_all(domain)
307
+ end
308
+
309
+ def zone_file
310
+ records.map(&:to_s).join("\n") + "\n"
311
+ end
312
+
313
+ def self.get_record(hostname)
314
+ Record.find_record(hostname)
315
+ end
316
+
317
+
318
+ def self.update_dynamic(hostname, ip)
319
+ Record.update_dynamic(hostname, ip)
320
+ end
321
+
322
+ def update_dynamic(name, ip)
323
+ matches = records_for(name)
324
+ return { created: true, record: create_record(name, type: 'A', content: ip) } if matches.empty?
325
+
326
+ record = get_record(name)
327
+ display = record.to_s
328
+ record.update(content: ip)
329
+ { created: false, display:, record: }
330
+ end
331
+
332
+ private
333
+
334
+ def normalize_hostname(name)
335
+ hostname = name.to_s.chomp('.')
336
+ return hostname if hostname == domain || hostname.end_with?(".#{domain}")
337
+
338
+ "#{hostname}.#{domain}"
339
+ end
340
+
341
+ class << self
342
+ def get_record(hostname)
343
+ Record.find_record(hostname)
344
+ end
345
+
346
+
347
+
348
+
349
+ def create_record(hostname, options)
350
+ Record.create_record(hostname, options)
351
+ end
352
+
353
+
354
+ def import(file)
355
+ Record.import_zone(file)
356
+ end
357
+
358
+ def public_ip
359
+ Record.public_ip
360
+ end
361
+
362
+ def update_dynamic(hostname, ip)
363
+ Record.update_dynamic(hostname, ip)
364
+ end
365
+ end
366
+ end
140
367
  end
data/mise.toml ADDED
@@ -0,0 +1,2 @@
1
+ [tools]
2
+ ruby = { version = "4.0.4", postinstall = "gem install pry amazing_print slop http sqlite3 thor colorize" }
metadata CHANGED
@@ -1,43 +1,28 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: porkbun
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.1
4
+ version: 2.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Daniel Bretoi
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2024-07-10 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
- - !ruby/object:Gem::Dependency
14
- name: http
15
- requirement: !ruby/object:Gem::Requirement
16
- requirements:
17
- - - "~>"
18
- - !ruby/object:Gem::Version
19
- version: 5.1.1
20
- type: :runtime
21
- prerelease: false
22
- version_requirements: !ruby/object:Gem::Requirement
23
- requirements:
24
- - - "~>"
25
- - !ruby/object:Gem::Version
26
- version: 5.1.1
27
12
  - !ruby/object:Gem::Dependency
28
13
  name: thor
29
14
  requirement: !ruby/object:Gem::Requirement
30
15
  requirements:
31
16
  - - "~>"
32
17
  - !ruby/object:Gem::Version
33
- version: '1.2'
18
+ version: '1.5'
34
19
  type: :runtime
35
20
  prerelease: false
36
21
  version_requirements: !ruby/object:Gem::Requirement
37
22
  requirements:
38
23
  - - "~>"
39
24
  - !ruby/object:Gem::Version
40
- version: '1.2'
25
+ version: '1.5'
41
26
  description: Porkbun API wrapper for Ruby.
42
27
  email:
43
28
  - daniel@otherware.org
@@ -47,19 +32,23 @@ extensions: []
47
32
  extra_rdoc_files: []
48
33
  files:
49
34
  - ".rspec"
35
+ - AGENTS.md
36
+ - CHANGELOG.md
37
+ - CLAUDE.md
50
38
  - Gemfile
51
39
  - Gemfile.lock
52
40
  - README.md
53
41
  - Rakefile
54
42
  - bin/porkbun
43
+ - justfile
55
44
  - lib/porkbun.rb
56
45
  - lib/porkbun/version.rb
46
+ - mise.toml
57
47
  homepage: https://github.com/danielb2/porkbun-ruby
58
48
  licenses: []
59
49
  metadata:
60
50
  homepage_uri: https://github.com/danielb2/porkbun-ruby
61
51
  source_code_uri: https://github.com/danielb2/porkbun-ruby
62
- post_install_message:
63
52
  rdoc_options: []
64
53
  require_paths:
65
54
  - lib
@@ -67,15 +56,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
67
56
  requirements:
68
57
  - - ">="
69
58
  - !ruby/object:Gem::Version
70
- version: 2.6.0
59
+ version: 3.2.0
71
60
  required_rubygems_version: !ruby/object:Gem::Requirement
72
61
  requirements:
73
62
  - - ">="
74
63
  - !ruby/object:Gem::Version
75
64
  version: '0'
76
65
  requirements: []
77
- rubygems_version: 3.5.3
78
- signing_key:
66
+ rubygems_version: 4.0.10
79
67
  specification_version: 4
80
68
  summary: Porkbun API wrapper for Ruby.
81
69
  test_files: []