consist 0.1.0 → 0.1.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/LICENSE.txt +256 -21
- data/README.md +624 -9
- data/lib/consist/cli.rb +38 -1
- data/lib/consist/commands/check.rb +36 -0
- data/lib/consist/commands/exec.rb +20 -0
- data/lib/consist/commands/includes/erbable.rb +24 -0
- data/lib/consist/commands/includes/stream_logger.rb +21 -0
- data/lib/consist/commands/mutate.rb +27 -0
- data/lib/consist/commands/upload.rb +37 -0
- data/lib/consist/consistfile.rb +71 -0
- data/lib/consist/recipe.rb +42 -0
- data/lib/consist/recipes.rb +28 -0
- data/lib/consist/step.rb +74 -0
- data/lib/consist/thor_ext.rb +2 -2
- data/lib/consist/version.rb +1 -1
- data/lib/recipes/kamal_single_server.rb +8 -0
- data/lib/steps/install_apt_packages/step.rb +19 -0
- data/lib/steps/update_apt_packages/apt_auto_upgrades +3 -0
- data/lib/steps/update_apt_packages/step.rb +11 -0
- metadata +46 -4
data/lib/consist/cli.rb
CHANGED
@@ -1,4 +1,17 @@
|
|
1
1
|
require "thor"
|
2
|
+
require "sshkit"
|
3
|
+
require "sshkit/dsl"
|
4
|
+
|
5
|
+
require "consist/recipe"
|
6
|
+
require "consist/recipes"
|
7
|
+
require "consist/step"
|
8
|
+
require "consist/consistfile"
|
9
|
+
require "consist/commands/includes/stream_logger"
|
10
|
+
require "consist/commands/includes/erbable"
|
11
|
+
require "consist/commands/exec"
|
12
|
+
require "consist/commands/upload"
|
13
|
+
require "consist/commands/mutate"
|
14
|
+
require "consist/commands/check"
|
2
15
|
|
3
16
|
module Consist
|
4
17
|
class CLI < Thor
|
@@ -6,9 +19,33 @@ module Consist
|
|
6
19
|
|
7
20
|
map %w[-v --version] => "version"
|
8
21
|
|
9
|
-
desc "version", "Display consist version"
|
22
|
+
desc "version", "Display consist version"
|
10
23
|
def version
|
11
24
|
say "consist/#{VERSION} #{RUBY_DESCRIPTION}"
|
12
25
|
end
|
26
|
+
|
27
|
+
desc "lightup", "Attempt to connect to a server and execute an idempotent statement."
|
28
|
+
def lightup(user, server)
|
29
|
+
puts "---> Attempting to connect to #{server} as #{user}"
|
30
|
+
on("#{user}@#{server}") do
|
31
|
+
as user do
|
32
|
+
execute "true"
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
desc "scaffold", "Apply a given recipe to (a) server(s)"
|
38
|
+
def scaffold(_recipe, server_ip)
|
39
|
+
Consist::Recipes.new(server_ip)
|
40
|
+
end
|
41
|
+
|
42
|
+
option :step, type: :string
|
43
|
+
option :consistfile, type: :string
|
44
|
+
desc "up", "Run a Consistfile against a server"
|
45
|
+
def up(server_ip)
|
46
|
+
specified_step = options[:step]
|
47
|
+
consistfile = options[:consistfile]
|
48
|
+
Consist::Consistfile.new(server_ip, consistfile:, specified_step:)
|
49
|
+
end
|
13
50
|
end
|
14
51
|
end
|
@@ -0,0 +1,36 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Consist
|
4
|
+
module Commands
|
5
|
+
class Check
|
6
|
+
def initialize(command)
|
7
|
+
@command = command
|
8
|
+
end
|
9
|
+
|
10
|
+
def perform!(executor)
|
11
|
+
status = @command[:status]
|
12
|
+
|
13
|
+
flag, val = if @command.has_key?(:path) && !@command[:path].nil?
|
14
|
+
["d", @command[:path]]
|
15
|
+
else
|
16
|
+
["f", @command[:file]]
|
17
|
+
end
|
18
|
+
|
19
|
+
exists = executor.test("[ -#{flag} #{val} ]")
|
20
|
+
|
21
|
+
if status == :exist && !exists
|
22
|
+
@command[:block].call
|
23
|
+
elsif status == :nonexistant && !exists
|
24
|
+
@command[:block].call
|
25
|
+
else
|
26
|
+
tense = if status == :exist
|
27
|
+
"should"
|
28
|
+
elsif status == :nonexistant
|
29
|
+
"shoudlnt"
|
30
|
+
end
|
31
|
+
puts "Checking path `#{status}` - `#{val}` - #{exists ? "exists" : "doesn't exist"} and #{tense} - skipping"
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Consist
|
4
|
+
module Commands
|
5
|
+
class Exec
|
6
|
+
include Erbable
|
7
|
+
|
8
|
+
def initialize(command)
|
9
|
+
@command = command
|
10
|
+
end
|
11
|
+
|
12
|
+
def perform!(executor)
|
13
|
+
@command[:commands].each do
|
14
|
+
executor.execute(erb_template(_1), interaction_handler: Consist::Commands::StreamLogger.new,
|
15
|
+
**@command[:params])
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "erb"
|
4
|
+
|
5
|
+
module Consist
|
6
|
+
module Commands
|
7
|
+
module Erbable
|
8
|
+
def self.included(klass)
|
9
|
+
klass.extend ClassMethods
|
10
|
+
end
|
11
|
+
|
12
|
+
def erb_template(contents)
|
13
|
+
b = binding
|
14
|
+
Consist.config.keys.each do |key|
|
15
|
+
b.local_variable_set(key, Consist.config[key])
|
16
|
+
end
|
17
|
+
ERB.new(contents).result(b)
|
18
|
+
end
|
19
|
+
|
20
|
+
module ClassMethods
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Consist
|
4
|
+
module Commands
|
5
|
+
class StreamLogger
|
6
|
+
def initialize(log_level = :info)
|
7
|
+
@log_level = log_level
|
8
|
+
end
|
9
|
+
|
10
|
+
def on_data(_command, _stream_name, data, _channel)
|
11
|
+
log(data)
|
12
|
+
end
|
13
|
+
|
14
|
+
private
|
15
|
+
|
16
|
+
def log(message)
|
17
|
+
SSHKit.config.output.send(@log_level, message) unless @log_level.nil?
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Consist
|
4
|
+
module Commands
|
5
|
+
class Mutate
|
6
|
+
include Erbable
|
7
|
+
|
8
|
+
def initialize(command)
|
9
|
+
@command = command
|
10
|
+
end
|
11
|
+
|
12
|
+
def perform!(executor)
|
13
|
+
delim = @command[:delim]
|
14
|
+
target_file = @command[:target_file]
|
15
|
+
target_string = erb_template(@command[:target_string])
|
16
|
+
match = erb_template(@command[:match])
|
17
|
+
|
18
|
+
case @command[:mode]
|
19
|
+
when :replace
|
20
|
+
cmd = "sed -i -E 's#{delim}#{match}#{delim}#{target_string}#{delim}' #{target_file} "
|
21
|
+
end
|
22
|
+
|
23
|
+
executor.execute(cmd, interaction_handler: Consist::Commands::StreamLogger.new)
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Consist
|
4
|
+
module Commands
|
5
|
+
class Upload
|
6
|
+
include Erbable
|
7
|
+
|
8
|
+
def initialize(command)
|
9
|
+
@command = command
|
10
|
+
end
|
11
|
+
|
12
|
+
def perform!(executor)
|
13
|
+
# rubocop:disable Style/ClassEqualityComparison
|
14
|
+
if @command[:local_file].class == Symbol
|
15
|
+
puts "---> Uploading defined file #{@command[:local_file]}"
|
16
|
+
target_file = Consist.files.detect { |f| f[:id] == @command[:local_file] }
|
17
|
+
raise "\n\nNo declared file of ID `#{@command[:local_file]}`" unless target_file
|
18
|
+
|
19
|
+
contents = StringIO.new(erb_template(target_file[:contents]))
|
20
|
+
upload_defined_file(executor, contents, @command[:remote_path])
|
21
|
+
else
|
22
|
+
local_path = File.expand_path("../steps/#{@id}/#{@command[:local_file]}", __dir__)
|
23
|
+
upload(executor, local_path, @command[:remote_path])
|
24
|
+
end
|
25
|
+
# rubocop:enable Style/ClassEqualityComparison
|
26
|
+
end
|
27
|
+
|
28
|
+
def upload(executor, local_path, remote_path)
|
29
|
+
executor.send(:upload!, local_path, remote_path, interaction_handler: Consist::Commands::StreamLogger.new)
|
30
|
+
end
|
31
|
+
|
32
|
+
def upload_defined_file(executor, contents, remote_path)
|
33
|
+
executor.upload! contents, remote_path, interaction_handler: Consist::Commands::StreamLogger.new
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
@@ -0,0 +1,71 @@
|
|
1
|
+
# frozen_string_literal: true37236
|
2
|
+
|
3
|
+
module Consist
|
4
|
+
class << self
|
5
|
+
attr_accessor :files, :config
|
6
|
+
end
|
7
|
+
|
8
|
+
@files = []
|
9
|
+
@config = {}
|
10
|
+
|
11
|
+
class Consistfile
|
12
|
+
include SSHKit::DSL
|
13
|
+
|
14
|
+
def initialize(server_ip, specified_step:, consistfile:)
|
15
|
+
@server_ip = server_ip
|
16
|
+
@specified_step = specified_step
|
17
|
+
consistfile_path = if consistfile
|
18
|
+
File.expand_path(consistfile, Dir.pwd)
|
19
|
+
else
|
20
|
+
File.expand_path("Consistfile", Dir.pwd)
|
21
|
+
end
|
22
|
+
|
23
|
+
consistfile_contents = File.read(consistfile_path)
|
24
|
+
instance_eval(consistfile_contents)
|
25
|
+
end
|
26
|
+
|
27
|
+
def consist(&definition)
|
28
|
+
instance_eval(&definition)
|
29
|
+
end
|
30
|
+
|
31
|
+
def recipe(id, &definition)
|
32
|
+
recipe = Consist::Recipe.new(id, &definition)
|
33
|
+
|
34
|
+
puts "Executing Recipe: #{recipe.name}"
|
35
|
+
|
36
|
+
if @specified_step.nil?
|
37
|
+
recipe.steps.each { exec_step(_1) }
|
38
|
+
else
|
39
|
+
puts "Specific step targeted: #{@specified_step.to_sym}"
|
40
|
+
specified_step, *_rest = recipe.steps.select { _1.id === @specified_step.to_sym }
|
41
|
+
raise "Step with id #{@specified_step.to_sym} not found." unless specified_step
|
42
|
+
|
43
|
+
exec_step(specified_step)
|
44
|
+
end
|
45
|
+
|
46
|
+
puts "Execution of #{recipe.name} has completed."
|
47
|
+
end
|
48
|
+
|
49
|
+
def file(id, &definition)
|
50
|
+
return unless definition
|
51
|
+
|
52
|
+
contents = yield
|
53
|
+
|
54
|
+
Consist.files << {id:, contents:}
|
55
|
+
end
|
56
|
+
|
57
|
+
def config(id, val)
|
58
|
+
Consist.config[id] = val
|
59
|
+
end
|
60
|
+
|
61
|
+
private
|
62
|
+
|
63
|
+
def exec_step(specified_step)
|
64
|
+
puts "Executing Step: #{specified_step.name}"
|
65
|
+
|
66
|
+
on("#{specified_step.required_user}@#{@server_ip}") do
|
67
|
+
specified_step.perform(self)
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Consist
|
4
|
+
class Recipe
|
5
|
+
def initialize(_id = nil, &definition)
|
6
|
+
@steps = []
|
7
|
+
instance_eval(&definition)
|
8
|
+
end
|
9
|
+
|
10
|
+
def name(name = nil)
|
11
|
+
@name = name if name
|
12
|
+
@name
|
13
|
+
end
|
14
|
+
|
15
|
+
def description(description = nil)
|
16
|
+
@description = description if description
|
17
|
+
@description
|
18
|
+
end
|
19
|
+
|
20
|
+
def user(user = nil)
|
21
|
+
@user = user if @user
|
22
|
+
@user
|
23
|
+
end
|
24
|
+
|
25
|
+
def steps(&block)
|
26
|
+
instance_eval(&block) if block
|
27
|
+
@steps
|
28
|
+
end
|
29
|
+
|
30
|
+
def step(step_name, &block)
|
31
|
+
if block
|
32
|
+
@steps << Step.new(id: step_name, &block)
|
33
|
+
return
|
34
|
+
end
|
35
|
+
|
36
|
+
target_path = File.join("../../", "steps", step_name.to_s, "step.rb")
|
37
|
+
step_path = File.expand_path(target_path, __FILE__)
|
38
|
+
step_content = File.read(step_path)
|
39
|
+
@steps << Step.new(id: step_name) { instance_eval(step_content) }
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Consist
|
4
|
+
class Recipes
|
5
|
+
include SSHKit::DSL
|
6
|
+
|
7
|
+
def initialize(server_ip)
|
8
|
+
recipe_directory = File.expand_path("../recipes", __dir__)
|
9
|
+
recipes = Dir[File.join(recipe_directory, "*.rb")]
|
10
|
+
|
11
|
+
recipes.each do |recipe_file|
|
12
|
+
recipe_content = File.read(recipe_file)
|
13
|
+
recipe = Recipe.new { instance_eval(recipe_content) }
|
14
|
+
|
15
|
+
puts "Executing Recipe: #{recipe.name}"
|
16
|
+
recipe.steps.each do |step|
|
17
|
+
puts "Executing Step: #{step.name}"
|
18
|
+
|
19
|
+
on("#{step.required_user}@#{server_ip}") do
|
20
|
+
step.perform(self)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
puts "Execution of #{recipe.name} has completed."
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
data/lib/consist/step.rb
ADDED
@@ -0,0 +1,74 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "erb"
|
4
|
+
|
5
|
+
module Consist
|
6
|
+
class Step
|
7
|
+
include SSHKit::DSL
|
8
|
+
|
9
|
+
def initialize(id:, &block)
|
10
|
+
@commands = []
|
11
|
+
@id = id
|
12
|
+
@required_user = :root
|
13
|
+
instance_eval(&block)
|
14
|
+
end
|
15
|
+
|
16
|
+
def id(id = nil)
|
17
|
+
@id = id if id
|
18
|
+
@id
|
19
|
+
end
|
20
|
+
|
21
|
+
def name(name = nil)
|
22
|
+
@name = name if name
|
23
|
+
@name
|
24
|
+
end
|
25
|
+
|
26
|
+
def required_user(user = nil)
|
27
|
+
@required_user = user if user
|
28
|
+
@required_user
|
29
|
+
end
|
30
|
+
|
31
|
+
def shell(message = "", params: {})
|
32
|
+
return unless block_given?
|
33
|
+
|
34
|
+
command = yield
|
35
|
+
commands = command.split(/(?<!\\)\n/).select { !_1.start_with?("#") }.compact
|
36
|
+
|
37
|
+
@commands << {message:, type: :exec, commands:, params:}
|
38
|
+
end
|
39
|
+
|
40
|
+
def mutate_file(mode:, target_file:, match:, target_string:, delim: "/", message: "")
|
41
|
+
@commands << {type: :mutate, mode:, message:, match:, target_file:, delim:, target_string:}
|
42
|
+
end
|
43
|
+
|
44
|
+
def upload_file(local_file:, remote_path:, message: "")
|
45
|
+
@commands << {message:, type: :upload, local_file:, remote_path:}
|
46
|
+
end
|
47
|
+
|
48
|
+
def check(status:, path: nil, file: nil, message: "", &block)
|
49
|
+
@commands << {type: :check, message:, status:, file:, path:, block: -> { instance_eval(&block) }}
|
50
|
+
end
|
51
|
+
|
52
|
+
def perform(executor)
|
53
|
+
@commands.each do |command|
|
54
|
+
banner(command[:message]) unless command[:message].empty?
|
55
|
+
|
56
|
+
execable = Object.const_get("Consist::Commands::#{command[:type].capitalize}").new(command)
|
57
|
+
executor.as @required_user do
|
58
|
+
execable.perform!(executor)
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
private
|
64
|
+
|
65
|
+
def banner(message)
|
66
|
+
return if message.empty?
|
67
|
+
|
68
|
+
msg = "********* #{message} ********"
|
69
|
+
puts "*" * msg.length
|
70
|
+
puts msg
|
71
|
+
puts "*" * msg.length
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
data/lib/consist/thor_ext.rb
CHANGED
@@ -28,12 +28,12 @@ module Consist
|
|
28
28
|
base.check_unknown_options!
|
29
29
|
end
|
30
30
|
|
31
|
-
def start(given_args=ARGV, config={})
|
31
|
+
def start(given_args = ARGV, config = {})
|
32
32
|
config[:shell] ||= Thor::Base.shell.new
|
33
33
|
handle_help_switches(given_args) do |args|
|
34
34
|
dispatch(nil, args, nil, config)
|
35
35
|
end
|
36
|
-
rescue
|
36
|
+
rescue => e
|
37
37
|
handle_exception_on_start(e, config)
|
38
38
|
end
|
39
39
|
|
data/lib/consist/version.rb
CHANGED
@@ -0,0 +1,19 @@
|
|
1
|
+
name "Install APT packages"
|
2
|
+
required_user :root
|
3
|
+
|
4
|
+
shell "Installing essential packages" do
|
5
|
+
<<~EOS
|
6
|
+
apt-get -y remove systemd-timesyncd
|
7
|
+
timedatectl set-ntp no
|
8
|
+
apt-get -y install build-essential curl fail2ban git ntp vim
|
9
|
+
apt-get autoremove
|
10
|
+
apt-get autoclean
|
11
|
+
EOS
|
12
|
+
end
|
13
|
+
|
14
|
+
shell "Start NTP and Fail2Ban" do
|
15
|
+
<<~EOS
|
16
|
+
service ntp restart
|
17
|
+
service fail2ban restart
|
18
|
+
EOS
|
19
|
+
end
|
@@ -0,0 +1,11 @@
|
|
1
|
+
name "Update the APT packages"
|
2
|
+
required_user :root
|
3
|
+
|
4
|
+
upload_file message: "Uploading APT config...", local_file: "apt_auto_upgrades",
|
5
|
+
remote_path: "/etc/apt/apt.conf.d/20auto-upgrades"
|
6
|
+
|
7
|
+
shell do
|
8
|
+
<<~EOS
|
9
|
+
apt-get update && apt-get upgrade -y
|
10
|
+
EOS
|
11
|
+
end
|
metadata
CHANGED
@@ -1,15 +1,43 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: consist
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.1.
|
4
|
+
version: 0.1.1
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- John McDowall
|
8
8
|
autorequire:
|
9
9
|
bindir: exe
|
10
10
|
cert_chain: []
|
11
|
-
date: 2023-11-
|
11
|
+
date: 2023-11-30 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: net-ssh
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '7.0'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '7.0'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: sshkit
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '1.21'
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '1.21'
|
13
41
|
- !ruby/object:Gem::Dependency
|
14
42
|
name: thor
|
15
43
|
requirement: !ruby/object:Gem::Requirement
|
@@ -26,7 +54,7 @@ dependencies:
|
|
26
54
|
version: '1.2'
|
27
55
|
description:
|
28
56
|
email:
|
29
|
-
-
|
57
|
+
- john@kantan.io
|
30
58
|
executables:
|
31
59
|
- consist
|
32
60
|
extensions: []
|
@@ -37,11 +65,25 @@ files:
|
|
37
65
|
- exe/consist
|
38
66
|
- lib/consist.rb
|
39
67
|
- lib/consist/cli.rb
|
68
|
+
- lib/consist/commands/check.rb
|
69
|
+
- lib/consist/commands/exec.rb
|
70
|
+
- lib/consist/commands/includes/erbable.rb
|
71
|
+
- lib/consist/commands/includes/stream_logger.rb
|
72
|
+
- lib/consist/commands/mutate.rb
|
73
|
+
- lib/consist/commands/upload.rb
|
74
|
+
- lib/consist/consistfile.rb
|
75
|
+
- lib/consist/recipe.rb
|
76
|
+
- lib/consist/recipes.rb
|
77
|
+
- lib/consist/step.rb
|
40
78
|
- lib/consist/thor_ext.rb
|
41
79
|
- lib/consist/version.rb
|
80
|
+
- lib/recipes/kamal_single_server.rb
|
81
|
+
- lib/steps/install_apt_packages/step.rb
|
82
|
+
- lib/steps/update_apt_packages/apt_auto_upgrades
|
83
|
+
- lib/steps/update_apt_packages/step.rb
|
42
84
|
homepage: https://github.com/johnmcdowall/consist
|
43
85
|
licenses:
|
44
|
-
-
|
86
|
+
- LGPL-3.0
|
45
87
|
metadata:
|
46
88
|
bug_tracker_uri: https://github.com/johnmcdowall/consist/issues
|
47
89
|
changelog_uri: https://github.com/johnmcdowall/consist/releases
|