bard 1.8.0 → 1.9.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 (62) hide show
  1. checksums.yaml +4 -4
  2. data/CLAUDE.md +76 -0
  3. data/PLUGINS.md +114 -0
  4. data/README.md +14 -6
  5. data/features/ci.feature +62 -0
  6. data/features/deploy_git_workflow.feature +88 -0
  7. data/features/step_definitions/bard_steps.rb +96 -0
  8. data/features/support/bard-coverage +16 -0
  9. data/features/support/env.rb +10 -1
  10. data/features/support/test_server.rb +2 -1
  11. data/lib/bard/ci/github_actions.rb +1 -2
  12. data/lib/bard/ci/jenkins.rb +82 -11
  13. data/lib/bard/ci/local.rb +6 -6
  14. data/lib/bard/ci/runner.rb +35 -1
  15. data/lib/bard/ci.rb +11 -23
  16. data/lib/bard/cli/ci.rb +45 -38
  17. data/lib/bard/cli/deploy.rb +40 -8
  18. data/lib/bard/cli/hurt.rb +10 -15
  19. data/lib/bard/cli/install.rb +7 -12
  20. data/lib/bard/cli/open.rb +12 -16
  21. data/lib/bard/cli/ping.rb +8 -14
  22. data/lib/bard/cli/run.rb +5 -3
  23. data/lib/bard/cli/stage.rb +10 -1
  24. data/lib/bard/cli/vim.rb +5 -10
  25. data/lib/bard/cli.rb +13 -11
  26. data/lib/bard/command.rb +3 -3
  27. data/lib/bard/config.rb +4 -14
  28. data/lib/bard/github.rb +2 -4
  29. data/lib/bard/plugin.rb +99 -0
  30. data/lib/bard/plugins/backup.rb +19 -0
  31. data/lib/bard/plugins/github_pages.rb +34 -0
  32. data/lib/bard/plugins/hurt.rb +5 -0
  33. data/lib/bard/plugins/install.rb +5 -0
  34. data/lib/bard/plugins/jenkins.rb +6 -0
  35. data/lib/bard/plugins/new.rb +5 -0
  36. data/lib/bard/plugins/ping.rb +6 -0
  37. data/lib/bard/plugins/provision.rb +5 -0
  38. data/lib/bard/plugins/vim.rb +5 -0
  39. data/lib/bard/provision/ssh.rb +9 -2
  40. data/lib/bard/secrets.rb +10 -0
  41. data/lib/bard/server.rb +3 -2
  42. data/lib/bard/target.rb +3 -2
  43. data/lib/bard/version.rb +1 -1
  44. data/spec/bard/ci/github_actions_spec.rb +116 -13
  45. data/spec/bard/ci/jenkins_spec.rb +139 -0
  46. data/spec/bard/ci/runner_spec.rb +61 -0
  47. data/spec/bard/cli/ci_spec.rb +34 -8
  48. data/spec/bard/cli/deploy_spec.rb +20 -8
  49. data/spec/bard/cli/hurt_spec.rb +2 -2
  50. data/spec/bard/cli/install_spec.rb +4 -4
  51. data/spec/bard/cli/open_spec.rb +10 -8
  52. data/spec/bard/cli/ping_spec.rb +5 -5
  53. data/spec/bard/cli/run_spec.rb +20 -1
  54. data/spec/bard/cli/stage_spec.rb +20 -0
  55. data/spec/bard/cli/vim_spec.rb +5 -5
  56. data/spec/bard/config_spec.rb +12 -0
  57. data/spec/bard/github_spec.rb +1 -1
  58. data/spec/bard/plugin_spec.rb +79 -0
  59. data/spec/bard/provision/ssh_spec.rb +17 -4
  60. data/spec/spec_helper.rb +6 -1
  61. metadata +27 -3
  62. data/README.rdoc +0 -15
data/lib/bard/cli.rb CHANGED
@@ -1,8 +1,10 @@
1
1
  # this file gets loaded in the CLI context, not the Rails boot context
2
2
 
3
3
  require "thor"
4
+ require "bard/version"
4
5
  require "bard/config"
5
6
  require "bard/command"
7
+ require "bard/plugin"
6
8
  require "term/ansicolor"
7
9
 
8
10
  module Bard
@@ -19,23 +21,23 @@ module Bard
19
21
  master_key: "MasterKey",
20
22
  setup: "Setup",
21
23
  run: "Run",
22
- open: "Open",
23
24
  ssh: "SSH",
24
- install: "Install",
25
- ping: "Ping",
26
- hurt: "Hurt",
27
- vim: "Vim",
28
25
  }.each do |command, klass|
29
26
  require "bard/cli/#{command}"
30
27
  include const_get(klass)
31
28
  end
32
29
 
33
- {
34
- provision: "Provision",
35
- new: "New",
36
- }.each do |command, klass|
37
- require "bard/cli/#{command}"
38
- const_get(klass).setup(self)
30
+ Plugin.load_all!
31
+ Plugin.all.each { |plugin| plugin.apply_to_cli(self) }
32
+
33
+ # Load core CI runners AFTER plugins so GithubActions is the default (last registered wins)
34
+ require "bard/ci/local"
35
+ require "bard/ci/github_actions"
36
+
37
+ map "--version" => :version
38
+ desc "version", "Display version"
39
+ def version
40
+ puts Bard::VERSION
39
41
  end
40
42
 
41
43
  def self.exit_on_failure? = true
data/lib/bard/command.rb CHANGED
@@ -17,9 +17,9 @@ module Bard
17
17
  end
18
18
 
19
19
  def run! verbose: false, quiet: false
20
- if !run(verbose:, quiet:)
21
- raise Error.new(full_command)
22
- end
20
+ result = run(verbose:, quiet:)
21
+ raise Error.new(full_command) unless result
22
+ result
23
23
  end
24
24
 
25
25
  def run verbose: false, quiet: false
data/lib/bard/config.rb CHANGED
@@ -52,7 +52,9 @@ module Bard
52
52
  # New v2.0 API - creates Target instances
53
53
  def target(key, &block)
54
54
  key = key.to_sym
55
- @servers[key] ||= Target.new(key, self)
55
+ unless @servers[key].is_a?(Target)
56
+ @servers[key] = Target.new(key, self)
57
+ end
56
58
  @servers[key].instance_eval(&block) if block
57
59
  @servers[key]
58
60
  end
@@ -131,19 +133,7 @@ module Bard
131
133
  return nil if @ci_system == false
132
134
 
133
135
  require "bard/ci"
134
-
135
- # Use the existing CI class which handles auto-detection
136
- case @ci_system
137
- when :local
138
- CI.new(project_name, branch, local: true)
139
- when :github_actions, :jenkins, nil
140
- # CI class auto-detects between github_actions and jenkins
141
- CI.new(project_name, branch)
142
- when false
143
- nil
144
- else
145
- CI.new(project_name, branch)
146
- end
136
+ CI.new(project_name, branch, runner_name: @ci_system)
147
137
  end
148
138
 
149
139
  private
data/lib/bard/github.rb CHANGED
@@ -3,6 +3,7 @@ require "json"
3
3
  require "base64"
4
4
  require "rbnacl"
5
5
  require "bard/ci/retryable"
6
+ require "bard/secrets"
6
7
 
7
8
  module Bard
8
9
  class Github < Struct.new(:project_name)
@@ -107,10 +108,7 @@ module Bard
107
108
  private
108
109
 
109
110
  def api_key
110
- @api_key ||= begin
111
- raw = `git ls-remote -t git@github.com:botandrosedesign/secrets`
112
- raw[/github-apikey\|(.+)$/, 1]
113
- end
111
+ @api_key ||= Bard::Secrets.fetch("github-apikey")
114
112
  end
115
113
 
116
114
  def request path, &block
@@ -0,0 +1,99 @@
1
+ module Bard
2
+ class Plugin
3
+ @registry = {}
4
+
5
+ class << self
6
+ attr_reader :registry
7
+
8
+ def register(name, &block)
9
+ plugin = new(name)
10
+ plugin.instance_eval(&block) if block
11
+ @registry[name.to_sym] = plugin
12
+ end
13
+
14
+ def [](name)
15
+ @registry[name.to_sym]
16
+ end
17
+
18
+ def all
19
+ @registry.values
20
+ end
21
+
22
+ def load_all!
23
+ Dir[File.join(__dir__, "plugins", "*.rb")].sort.each { |f| require f }
24
+ all.each(&:apply!)
25
+ end
26
+
27
+ def reset!
28
+ @registry = {}
29
+ end
30
+ end
31
+
32
+ attr_reader :name, :cli_modules
33
+
34
+ def initialize(name)
35
+ @name = name.to_sym
36
+ @cli_modules = []
37
+ @cli_requires = []
38
+ @target_methods = {}
39
+ @config_methods = {}
40
+ @requires = []
41
+ end
42
+
43
+ # DSL methods for defining plugins
44
+
45
+ def require_file(path)
46
+ @requires << path
47
+ end
48
+
49
+ def cli(mod, require: nil)
50
+ @cli_requires << require if require
51
+ @cli_modules << mod
52
+ end
53
+
54
+ def target_method(name, &block)
55
+ @target_methods[name] = block
56
+ end
57
+
58
+ def config_method(name, &block)
59
+ @config_methods[name] = block
60
+ end
61
+
62
+ # Apply plugin to the system (non-CLI parts)
63
+ def apply!
64
+ @requires.each { |path| require path }
65
+ apply_target_methods!
66
+ apply_config_methods!
67
+ end
68
+
69
+ def apply_to_cli(cli_class)
70
+ @cli_requires.each { |path| require path }
71
+ @cli_modules.each do |mod|
72
+ mod = resolve_constant(mod) if mod.is_a?(String)
73
+ mod.setup(cli_class)
74
+ end
75
+ end
76
+
77
+ private
78
+
79
+ def apply_target_methods!
80
+ return if @target_methods.empty?
81
+ require "bard/target"
82
+ @target_methods.each do |method_name, block|
83
+ Target.define_method(method_name, &block)
84
+ end
85
+ end
86
+
87
+ def apply_config_methods!
88
+ return if @config_methods.empty?
89
+ require "bard/config"
90
+ @config_methods.each do |method_name, block|
91
+ Config.define_method(method_name, &block)
92
+ end
93
+ end
94
+
95
+ def resolve_constant(name)
96
+ name.split("::").reduce(Object) { |mod, const| mod.const_get(const) }
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,19 @@
1
+ require "bard/plugin"
2
+
3
+ Bard::Plugin.register :backup do
4
+ config_method :backup do |value = nil, &block|
5
+ if block
6
+ @backup = Bard::BackupConfig.new(&block)
7
+ elsif value == false
8
+ @backup = Bard::BackupConfig.new { disabled }
9
+ elsif value.nil? # Getter
10
+ @backup ||= Bard::BackupConfig.new { bard }
11
+ else
12
+ raise ArgumentError, "backup accepts false or a block"
13
+ end
14
+ end
15
+
16
+ config_method :backup_enabled? do
17
+ backup == true
18
+ end
19
+ end
@@ -0,0 +1,34 @@
1
+ require "bard/plugin"
2
+
3
+ # Load the deploy strategy (auto-registers via inherited hook)
4
+ require "bard/deploy_strategy/github_pages"
5
+
6
+ Bard::Plugin.register :github_pages do
7
+ # Config DSL: github_pages "url" sets up a production target
8
+ config_method :github_pages do |url|
9
+ urls = []
10
+ uri = url.start_with?("http") ? URI.parse(url) : URI.parse("https://#{url}")
11
+ hostname = uri.hostname.sub(/^www\./, "")
12
+ urls = [hostname]
13
+ urls << "www.#{hostname}" if hostname.count(".") < 2
14
+
15
+ target :production do
16
+ github_pages url
17
+ ssh false
18
+ ping(*urls) if urls.any?
19
+ end
20
+
21
+ backup false
22
+ end
23
+
24
+ # Target DSL: github_pages sets deploy strategy
25
+ target_method :github_pages do |url = nil|
26
+ if url.nil?
27
+ @github_pages_url
28
+ else
29
+ @deploy_strategy = :github_pages
30
+ @github_pages_url = url
31
+ enable_capability(:github_pages)
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,5 @@
1
+ require "bard/plugin"
2
+
3
+ Bard::Plugin.register :hurt do
4
+ cli "Bard::CLI::Hurt", require: "bard/cli/hurt"
5
+ end
@@ -0,0 +1,5 @@
1
+ require "bard/plugin"
2
+
3
+ Bard::Plugin.register :install do
4
+ cli "Bard::CLI::Install", require: "bard/cli/install"
5
+ end
@@ -0,0 +1,6 @@
1
+ require "bard/plugin"
2
+
3
+ Bard::Plugin.register :jenkins do
4
+ # Jenkins CI runner - auto-registers via inherited hook when loaded
5
+ require_file "bard/ci/jenkins"
6
+ end
@@ -0,0 +1,5 @@
1
+ require "bard/plugin"
2
+
3
+ Bard::Plugin.register :new do
4
+ cli "Bard::CLI::New", require: "bard/cli/new"
5
+ end
@@ -0,0 +1,6 @@
1
+ require "bard/plugin"
2
+
3
+ Bard::Plugin.register :ping do
4
+ cli "Bard::CLI::Ping", require: "bard/cli/ping"
5
+ cli "Bard::CLI::Open", require: "bard/cli/open"
6
+ end
@@ -0,0 +1,5 @@
1
+ require "bard/plugin"
2
+
3
+ Bard::Plugin.register :provision do
4
+ cli "Bard::CLI::Provision", require: "bard/cli/provision"
5
+ end
@@ -0,0 +1,5 @@
1
+ require "bard/plugin"
2
+
3
+ Bard::Plugin.register :vim do
4
+ cli "Bard::CLI::Vim", require: "bard/cli/vim"
5
+ end
@@ -19,7 +19,14 @@ class Bard::Provision::SSH < Bard::Provision
19
19
  add_ssh_known_host!(provision_server.ssh_uri)
20
20
  end
21
21
  print " Reconfiguring port to #{target_port},"
22
- provision_server.run! %(echo "Port #{target_port}" | sudo tee /etc/ssh/sshd_config.d/port_#{target_port}.conf; sudo service ssh restart), home: true
22
+ provision_server.run! %(echo "Port #{target_port}" | sudo tee /etc/ssh/sshd_config.d/port_#{target_port}.conf && sudo service ssh restart), home: true
23
+ 5.times do
24
+ sleep 1
25
+ break if ssh_available?(provision_server.ssh_uri, port: target_port)
26
+ end
27
+ if !ssh_available?(provision_server.ssh_uri, port: target_port)
28
+ raise "reconfigured SSH to port #{target_port} but it's not responding — check firewall and sshd_config Include directive"
29
+ end
23
30
  end
24
31
 
25
32
  if !ssh_known_host?(provision_server.ssh_uri)
@@ -65,7 +72,7 @@ class Bard::Provision::SSH < Bard::Provision
65
72
 
66
73
  def disable_password_auth!
67
74
  provision_server.run!(
68
- %q{echo "PasswordAuthentication no" | sudo tee /etc/ssh/sshd_config.d/disable_password_auth.conf; sudo service ssh restart},
75
+ %q{echo "PasswordAuthentication no" | sudo tee /etc/ssh/sshd_config.d/disable_password_auth.conf && sudo service ssh restart},
69
76
  home: true
70
77
  )
71
78
  end
@@ -0,0 +1,10 @@
1
+ module Bard
2
+ module Secrets
3
+ REPO = "git@github.com:botandrosedesign/secrets"
4
+
5
+ def self.fetch(key)
6
+ raw = `git ls-remote -t #{REPO}`
7
+ raw[/#{Regexp.escape(key)}\|(.+)$/, 1]
8
+ end
9
+ end
10
+ end
data/lib/bard/server.rb CHANGED
@@ -135,8 +135,9 @@ module Bard
135
135
  key
136
136
  end
137
137
 
138
- def run! command, home: false, verbose: false, quiet: false
139
- Bard::Command.run! command, on: self, home:, verbose:, quiet:
138
+ def run! command, home: false, verbose: false, quiet: false, capture: false
139
+ result = Bard::Command.run!(command, on: self, home:, verbose:, quiet:)
140
+ result if capture
140
141
  end
141
142
 
142
143
  def run command, home: false, verbose: false, quiet: false
data/lib/bard/target.rb CHANGED
@@ -217,9 +217,10 @@ module Bard
217
217
  end
218
218
 
219
219
  # Remote command execution
220
- def run!(command, home: false, verbose: false, quiet: false)
220
+ def run!(command, home: false, verbose: false, quiet: false, capture: false)
221
221
  require_capability!(:ssh)
222
- Command.run!(command, on: self, home: home, verbose: verbose, quiet: quiet)
222
+ result = Command.run!(command, on: self, home: home, verbose: verbose, quiet: quiet)
223
+ result if capture
223
224
  end
224
225
 
225
226
  def run(command, home: false, verbose: false, quiet: false)
data/lib/bard/version.rb CHANGED
@@ -1,4 +1,4 @@
1
1
  module Bard
2
- VERSION = "1.8.0"
2
+ VERSION = "1.9.1"
3
3
  end
4
4
 
@@ -1,35 +1,138 @@
1
+ require "spec_helper"
1
2
  require "bard/ci/github_actions"
2
3
 
3
- describe Bard::CI::GithubActions do
4
- subject { described_class.new("metrc", "master", "0966308e204b256fdcc11457eb53306d84884c60") }
4
+ BASE_URL = "https://api.github.com/repos/botandrosedesign/metrc"
5
+
6
+ RSpec.shared_context "github actions stubs" do
7
+ let(:run_id) { 123 }
8
+ let(:job_id) { 456 }
9
+ let(:started_at) { "2024-01-15T10:00:00Z" }
10
+ let(:completed_at) { "2024-01-15T10:01:30Z" }
11
+ let(:sha) { "abc123" }
12
+
13
+ let(:run_json) do
14
+ {
15
+ "id" => run_id,
16
+ "status" => "completed",
17
+ "conclusion" => "success",
18
+ "head_branch" => "master",
19
+ "head_sha" => sha,
20
+ "run_started_at" => started_at,
21
+ "updated_at" => completed_at,
22
+ }
23
+ end
5
24
 
6
- xit "works" do
7
- subject.run
25
+ let(:job_json) do
26
+ {
27
+ "id" => job_id,
28
+ "started_at" => started_at,
29
+ "completed_at" => completed_at,
30
+ }
31
+ end
32
+
33
+ before do
34
+ allow(Bard::Secrets).to receive(:fetch).with("github-apikey").and_return("test-key")
8
35
  end
9
36
  end
10
37
 
11
38
  describe Bard::CI::GithubActions::API do
39
+ include_context "github actions stubs"
40
+
12
41
  subject { described_class.new("metrc") }
13
42
 
14
43
  describe "#last_successful_run" do
15
- xit "has #time_elapsed" do
44
+ before do
45
+ stub_request(:get, "#{BASE_URL}/actions/runs")
46
+ .with(query: hash_including("status" => "success"))
47
+ .to_return(
48
+ headers: { "Content-Type" => "application/json" },
49
+ body: JSON.dump("workflow_runs" => [run_json]),
50
+ )
51
+
52
+ stub_request(:get, "#{BASE_URL}/actions/runs/#{run_id}/jobs")
53
+ .with(query: hash_including("filter" => "latest"))
54
+ .to_return(
55
+ headers: { "Content-Type" => "application/json" },
56
+ body: JSON.dump("jobs" => [job_json]),
57
+ )
58
+ end
59
+
60
+ it "has #time_elapsed" do
16
61
  run = subject.last_successful_run
17
- run.time_elapsed
62
+ expect(run.time_elapsed).to eq 90
18
63
  end
19
64
 
20
- xit "has #console" do
21
- subject.last_successful_run.console
65
+ it "has #console" do
66
+ stub_request(:get, "#{BASE_URL}/actions/jobs/#{job_id}/logs")
67
+ .to_return(
68
+ headers: { "Content-Type" => "text/plain" },
69
+ body: "build log output here",
70
+ )
71
+
72
+ expect(subject.last_successful_run.console).to eq "build log output here"
22
73
  end
23
74
  end
24
75
 
25
76
  describe "#create_run!" do
26
- xit "returns a run" do
27
- subject.create_run! "master"
77
+ it "returns a run" do
78
+ stub_request(:post, "#{BASE_URL}/actions/workflows/ci.yml/dispatches")
79
+ .to_return(status: 204, body: "")
80
+
81
+ allow(subject).to receive(:`).with("git rev-parse master").and_return("#{sha}\n")
82
+
83
+ stub_request(:get, "#{BASE_URL}/actions/runs")
84
+ .with(query: hash_including("head_sha" => sha))
85
+ .to_return(
86
+ headers: { "Content-Type" => "application/json" },
87
+ body: JSON.dump("workflow_runs" => [run_json]),
88
+ )
89
+
90
+ run = subject.create_run!("master")
91
+ expect(run).to be_a Bard::CI::GithubActions::Run
92
+ expect(run.id).to eq run_id
28
93
  end
29
94
  end
30
95
  end
31
96
 
32
- describe Bard::Github do
33
- subject { described_class.new("metrc") }
34
- end
97
+ describe Bard::CI::GithubActions do
98
+ include_context "github actions stubs"
99
+
100
+ subject { described_class.new("metrc", "master", sha) }
101
+
102
+ it "returns true on successful run" do
103
+ stub_request(:post, "#{BASE_URL}/actions/workflows/ci.yml/dispatches")
104
+ .to_return(status: 204, body: "")
105
+
106
+ allow_any_instance_of(Bard::CI::GithubActions::API)
107
+ .to receive(:`).with("git rev-parse master").and_return("#{sha}\n")
35
108
 
109
+ stub_request(:get, "#{BASE_URL}/actions/runs")
110
+ .with(query: hash_including("head_sha" => sha))
111
+ .to_return(
112
+ headers: { "Content-Type" => "application/json" },
113
+ body: JSON.dump("workflow_runs" => [run_json]),
114
+ )
115
+
116
+ stub_request(:get, "#{BASE_URL}/actions/runs")
117
+ .with(query: hash_including("status" => "success"))
118
+ .to_return(
119
+ headers: { "Content-Type" => "application/json" },
120
+ body: JSON.dump("workflow_runs" => [run_json]),
121
+ )
122
+
123
+ stub_request(:get, "#{BASE_URL}/actions/runs/#{run_id}/jobs")
124
+ .with(query: hash_including("filter" => "latest"))
125
+ .to_return(
126
+ headers: { "Content-Type" => "application/json" },
127
+ body: JSON.dump("jobs" => [job_json]),
128
+ )
129
+
130
+ stub_request(:get, "#{BASE_URL}/actions/runs/#{run_id}")
131
+ .to_return(
132
+ headers: { "Content-Type" => "application/json" },
133
+ body: JSON.dump(run_json),
134
+ )
135
+
136
+ expect(subject.run { }).to eq true
137
+ end
138
+ end