bard 1.7.4 → 2.0.0.beta

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.
data/lib/bard/config.rb CHANGED
@@ -1,4 +1,5 @@
1
1
  require "bard/server"
2
+ require "bard/target"
2
3
 
3
4
  module Bard
4
5
  class Config
@@ -8,69 +9,75 @@ module Bard
8
9
  new(project_name, path: path)
9
10
  end
10
11
 
11
- def initialize project_name, path: nil, source: nil
12
+ attr_reader :project_name, :targets
13
+
14
+ def initialize(project_name = nil, path: nil, source: nil)
15
+ # Support both positional and keyword argument for project_name
12
16
  @project_name = project_name
13
- @servers = {
14
- local: Server.new(
15
- project_name,
16
- :local,
17
- false,
18
- "./",
19
- ["#{project_name}.local"],
20
- ),
21
- gubs: Server.new(
22
- project_name,
23
- :gubs,
24
- "botandrose@cloud.hackett.world:22022",
25
- "Sites/#{project_name}",
26
- false,
27
- ),
28
- ci: Server.new(
29
- project_name,
30
- :ci,
31
- "jenkins@staging.botandrose.com:22022",
32
- "jobs/#{project_name}/workspace",
33
- false,
34
- ),
35
- staging: Server.new(
36
- project_name,
37
- :staging,
38
- "www@staging.botandrose.com:22022",
39
- project_name,
40
- ["#{project_name}.botandrose.com"],
41
- ),
42
- }
17
+ @servers = {} # Unified hash for both Server and Target instances
18
+ @data_paths = []
19
+ @backup = nil
20
+ @ci_system = nil
21
+
22
+ # Load default configuration (creates Server instances for backward compat)
23
+ load_defaults if project_name
24
+
25
+ # Load user configuration
43
26
  if path && File.exist?(path)
44
27
  source = File.read(path)
45
28
  end
46
29
  if source
47
- instance_eval source
30
+ instance_eval(source)
48
31
  end
49
32
  end
50
33
 
51
- attr_reader :project_name, :servers
34
+ # Backward compatible accessor
35
+ def servers
36
+ @servers
37
+ end
38
+
39
+ # New v2.0 accessor (same as servers)
40
+ def targets
41
+ @servers
42
+ end
52
43
 
53
- def server key, &block
44
+ # Old v1.x API - creates Server instances
45
+ def server(key, &block)
54
46
  key = key.to_sym
55
47
  @servers[key] = Server.define(project_name, key, &block)
56
48
  end
57
49
 
58
- def [] key
50
+ # New v2.0 API - creates Target instances
51
+ def target(key, &block)
59
52
  key = key.to_sym
53
+ @servers[key] ||= Target.new(key, self)
54
+ @servers[key].instance_eval(&block) if block
55
+ @servers[key]
56
+ end
57
+
58
+ # Get a server/target by key
59
+ def [](key)
60
+ key = key.to_sym
61
+ # Fallback to staging if production not defined
60
62
  if @servers[key].nil? && key == :production
61
63
  key = :staging
62
64
  end
63
65
  @servers[key]
64
66
  end
65
67
 
66
- def data *paths
67
- if paths.length == 0
68
- Array(@data)
68
+ # Data paths configuration
69
+ def data(*paths)
70
+ if paths.empty?
71
+ @data_paths
69
72
  else
70
- @data = paths
73
+ @data_paths = paths
71
74
  end
72
75
  end
73
76
 
77
+ def data_paths
78
+ @data_paths
79
+ end
80
+
74
81
  def backup(value = nil, &block)
75
82
  if block_given?
76
83
  @backup = BackupConfig.new(&block)
@@ -83,7 +90,9 @@ module Bard
83
90
  end
84
91
  end
85
92
 
86
- # short-hand for michael
93
+ def backup_enabled?
94
+ backup == true
95
+ end
87
96
 
88
97
  def github_pages url
89
98
  urls = []
@@ -94,14 +103,80 @@ module Bard
94
103
  urls << "www.#{hostname}"
95
104
  end
96
105
 
97
- server :production do
98
- github_pages true
106
+ target :production do
107
+ github_pages url
99
108
  ssh false
100
- ping *urls
109
+ ping(*urls) if urls.any?
101
110
  end
102
111
 
103
112
  backup false
104
113
  end
114
+
115
+ # CI configuration
116
+ def ci(system = nil)
117
+ if system.nil?
118
+ @ci_system
119
+ else
120
+ @ci_system = system
121
+ end
122
+ end
123
+
124
+ def ci_system
125
+ @ci_system
126
+ end
127
+
128
+ def ci_instance(branch)
129
+ return nil if @ci_system == false
130
+
131
+ require "bard/ci"
132
+
133
+ # Use the existing CI class which handles auto-detection
134
+ case @ci_system
135
+ when :local
136
+ CI.new(project_name, branch, local: true)
137
+ when :github_actions, :jenkins, nil
138
+ # CI class auto-detects between github_actions and jenkins
139
+ CI.new(project_name, branch)
140
+ when false
141
+ nil
142
+ else
143
+ CI.new(project_name, branch)
144
+ end
145
+ end
146
+
147
+ private
148
+
149
+ # Load default server configurations (v1.x compatible)
150
+ def load_defaults
151
+ @servers[:local] = Server.new(
152
+ project_name,
153
+ :local,
154
+ false,
155
+ "./",
156
+ ["#{project_name}.local"],
157
+ )
158
+ @servers[:gubs] = Server.new(
159
+ project_name,
160
+ :gubs,
161
+ "botandrose@cloud.hackett.world:22022",
162
+ "Sites/#{project_name}",
163
+ false,
164
+ )
165
+ @servers[:ci] = Server.new(
166
+ project_name,
167
+ :ci,
168
+ "jenkins@staging.botandrose.com:22022",
169
+ "jobs/#{project_name}/workspace",
170
+ false,
171
+ )
172
+ @servers[:staging] = Server.new(
173
+ project_name,
174
+ :staging,
175
+ "www@staging.botandrose.com:22022",
176
+ project_name,
177
+ ["#{project_name}.botandrose.com"],
178
+ )
179
+ end
105
180
  end
106
181
 
107
182
  class BackupConfig
data/lib/bard/copy.rb CHANGED
@@ -21,12 +21,15 @@ module Bard
21
21
  end
22
22
  end
23
23
 
24
- def scp_using_local direction, server
25
- gateway = server.gateway ? "-oProxyCommand='ssh #{server.ssh_uri(:gateway)} -W %h:%p'" : ""
24
+ def scp_using_local direction, target_or_server
25
+ # Support both new Target (with server attribute) and old Server
26
+ ssh_server = target_or_server.respond_to?(:server) ? target_or_server.server : target_or_server
26
27
 
27
- ssh_key = server.ssh_key ? "-i #{server.ssh_key}" : ""
28
+ gateway = ssh_server.gateway ? "-oProxyCommand='ssh #{ssh_server.gateway} -W %h:%p'" : ""
28
29
 
29
- from_and_to = [path, server.scp_uri(path)]
30
+ ssh_key = ssh_server.ssh_key ? "-i #{ssh_server.ssh_key}" : ""
31
+
32
+ from_and_to = [path, target_or_server.scp_uri(path)]
30
33
  from_and_to.reverse! if direction == :from
31
34
 
32
35
  command = ["scp", gateway, ssh_key, *from_and_to].join(" ")
@@ -34,7 +37,10 @@ module Bard
34
37
  end
35
38
 
36
39
  def scp_as_mediator
37
- raise NotImplementedError if from.gateway || to.gateway || from.ssh_key || to.ssh_key
40
+ from_server = from.respond_to?(:server) ? from.server : from
41
+ to_server = to.respond_to?(:server) ? to.server : to
42
+
43
+ raise NotImplementedError if from_server.gateway || to_server.gateway || from_server.ssh_key || to_server.ssh_key
38
44
  command = "scp -o ForwardAgent=yes #{from.scp_uri(path)} #{to.scp_uri(path)}"
39
45
  Bard::Command.run! command, verbose: verbose
40
46
  end
@@ -49,13 +55,29 @@ module Bard
49
55
  end
50
56
  end
51
57
 
52
- def rsync_using_local direction, server
53
- gateway = server.gateway ? "-oProxyCommand=\"ssh #{server.ssh_uri(:gateway)} -W %h:%p\"" : ""
58
+ def rsync_using_local direction, target_or_server
59
+ # Support both new Target (with server attribute) and old Server
60
+ ssh_server = target_or_server.respond_to?(:server) ? target_or_server.server : target_or_server
61
+
62
+ # Get ssh_uri - it might be a URI object (old Server), string (new SSHServer), or mock
63
+ ssh_uri_value = ssh_server.respond_to?(:ssh_uri) ? ssh_server.ssh_uri : nil
64
+ if ssh_uri_value.respond_to?(:port)
65
+ # Already a URI-like object (old Server or mock)
66
+ ssh_uri = ssh_uri_value
67
+ elsif ssh_uri_value.is_a?(String)
68
+ # String from new SSHServer
69
+ ssh_uri = URI("ssh://#{ssh_uri_value}")
70
+ else
71
+ # Fallback
72
+ ssh_uri = ssh_uri_value
73
+ end
74
+
75
+ gateway = ssh_server.gateway ? "-oProxyCommand=\"ssh #{ssh_server.gateway} -W %h:%p\"" : ""
54
76
 
55
- ssh_key = server.ssh_key ? "-i #{server.ssh_key}" : ""
56
- ssh = "-e'ssh #{gateway} -p#{server.ssh_uri.port || 22}'"
77
+ ssh_key = ssh_server.ssh_key ? "-i #{ssh_server.ssh_key}" : ""
78
+ ssh = "-e'ssh #{gateway} -p#{ssh_uri.port || 22}'"
57
79
 
58
- from_and_to = ["./#{path}", server.rsync_uri(path)]
80
+ from_and_to = ["./#{path}", target_or_server.rsync_uri(path)]
59
81
  from_and_to.reverse! if direction == :from
60
82
  from_and_to[-1].sub! %r(/[^/]+$), '/'
61
83
 
@@ -64,12 +86,34 @@ module Bard
64
86
  end
65
87
 
66
88
  def rsync_as_mediator
67
- raise NotImplementedError if from.gateway || to.gateway || from.ssh_key || to.ssh_key
89
+ from_server = from.respond_to?(:server) ? from.server : from
90
+ to_server = to.respond_to?(:server) ? to.server : to
91
+
92
+ raise NotImplementedError if from_server.gateway || to_server.gateway || from_server.ssh_key || to_server.ssh_key
93
+
94
+ # Get ssh_uri - it might be a URI object (old Server), string (new SSHServer), or mock
95
+ from_uri_value = from_server.respond_to?(:ssh_uri) ? from_server.ssh_uri : nil
96
+ if from_uri_value.respond_to?(:port)
97
+ from_uri = from_uri_value
98
+ elsif from_uri_value.is_a?(String)
99
+ from_uri = URI("ssh://#{from_uri_value}")
100
+ else
101
+ from_uri = from_uri_value
102
+ end
103
+
104
+ to_uri_value = to_server.respond_to?(:ssh_uri) ? to_server.ssh_uri : nil
105
+ if to_uri_value.respond_to?(:port)
106
+ to_uri = to_uri_value
107
+ elsif to_uri_value.is_a?(String)
108
+ to_uri = URI("ssh://#{to_uri_value}")
109
+ else
110
+ to_uri = to_uri_value
111
+ end
68
112
 
69
- from_str = "-p#{from.ssh_uri.port || 22} #{from.ssh_uri.user}@#{from.ssh_uri.host}"
113
+ from_str = "-p#{from_uri.port || 22} #{from_uri.user}@#{from_uri.host}"
70
114
  to_str = to.rsync_uri(path).sub(%r(/[^/]+$), '/')
71
115
 
72
- command = %(ssh -A #{from_str} 'rsync -e \"ssh -A -p#{to.ssh_uri.port || 22} -o StrictHostKeyChecking=no\" --delete --info=progress2 -az #{from.path}/#{path} #{to_str}')
116
+ command = %(ssh -A #{from_str} 'rsync -e \"ssh -A -p#{to_uri.port || 22} -o StrictHostKeyChecking=no\" --delete --info=progress2 -az #{from.path}/#{path} #{to_str}')
73
117
  Bard::Command.run! command, verbose: verbose
74
118
  end
75
119
  end
@@ -0,0 +1,35 @@
1
+ module Bard
2
+ # Default configuration that is loaded before user's bard.rb
3
+ # Users can override any of these targets in their bard.rb
4
+ DEFAULT_CONFIG = lambda do |config, project_name|
5
+ # Local development target
6
+ config.instance_eval do
7
+ target :local do
8
+ ssh false
9
+ path "./"
10
+ ping "#{project_name}.local"
11
+ end
12
+
13
+ # Bot and Rose cloud server
14
+ target :gubs do
15
+ ssh "botandrose@cloud.hackett.world:22022",
16
+ path: "Sites/#{project_name}"
17
+ ping false
18
+ end
19
+
20
+ # CI target (Jenkins)
21
+ target :ci do
22
+ ssh "jenkins@staging.botandrose.com:22022",
23
+ path: "jobs/#{project_name}/workspace"
24
+ ping false
25
+ end
26
+
27
+ # Staging server
28
+ target :staging do
29
+ ssh "www@staging.botandrose.com:22022",
30
+ path: project_name
31
+ ping "#{project_name}.botandrose.com"
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,135 @@
1
+ require "bard/deploy_strategy"
2
+ require "bard/git"
3
+ require "fileutils"
4
+ require "uri"
5
+
6
+ module Bard
7
+ class DeployStrategy
8
+ class GithubPages < DeployStrategy
9
+ def initialize(target, url = nil, **options)
10
+ super(target)
11
+ @url = url
12
+ @options = options
13
+
14
+ # Auto-configure ping URL if provided
15
+ target.ping(url) if url
16
+ end
17
+
18
+ def deploy
19
+ @sha = Git.sha_of(Git.current_branch)
20
+ @build_dir = "tmp/github-build-#{@sha}"
21
+ @branch = "gh-pages"
22
+ @domain = extract_domain
23
+
24
+ puts "Starting deployment to GitHub Pages..."
25
+
26
+ build_site
27
+ tree_sha = create_tree_from_build
28
+ new_commit = create_commit(tree_sha)
29
+ commit_and_push(new_commit)
30
+ end
31
+
32
+ private
33
+
34
+ def extract_domain
35
+ return nil unless @url
36
+ domain = @url
37
+ domain = URI.parse(domain).hostname if domain =~ /^http/
38
+ domain
39
+ end
40
+
41
+ def build_site
42
+ system "rm -rf #{@build_dir.sub(@sha, "*")}"
43
+ run! <<~SH
44
+ set -e
45
+ RAILS_ENV=production bundle exec rails s -p 3000 -d --pid tmp/pids/server.pid
46
+ OUTPUT=$(bundle exec rake assets:clean assets:precompile 2>&1) || echo "$OUTPUT"
47
+
48
+ # Create the output directory and enter it
49
+ BUILD=#{@build_dir}
50
+ rm -rf $BUILD
51
+ mkdir -p $BUILD
52
+ cp -R public/assets $BUILD/
53
+ cd $BUILD
54
+
55
+ # wait until server responds
56
+ echo waiting...
57
+ curl -s --retry 5 --retry-delay 2 http://localhost:3000 >/dev/null 2>&1
58
+
59
+ echo copying...
60
+ # Mirror the site to the build folder, ignoring links with query params
61
+ wget -nv -r -l inf --no-remove-listing -FEnH --reject-regex "(\\.*)\\?(.*)" http://localhost:3000/ 2>&1
62
+
63
+ echo #{@domain} > CNAME
64
+ SH
65
+ ensure
66
+ # cleanup
67
+ run! <<~SH
68
+ cat tmp/pids/server.pid | xargs -I {} kill {}
69
+ rm -rf public/assets
70
+ SH
71
+ end
72
+
73
+ def create_tree_from_build
74
+ # Create temporary index
75
+ git_index_file = ".git/tmp-index"
76
+ git = "GIT_INDEX_FILE=#{git_index_file} git"
77
+ run! "#{git} read-tree --empty"
78
+
79
+ # Add build files to temporary index
80
+ Dir.chdir(@build_dir) do
81
+ Dir.glob('**/*', File::FNM_DOTMATCH).each do |file|
82
+ next if file == '.' || file == '..'
83
+ if File.file?(file)
84
+ run! "#{git} update-index --add --cacheinfo 100644 $(#{git} hash-object -w #{file}) #{file}"
85
+ end
86
+ end
87
+ end
88
+
89
+ # Create tree object from index
90
+ tree_sha = `#{git} write-tree`.chomp
91
+
92
+ # Clean up temporary index
93
+ FileUtils.rm_f(git_index_file)
94
+
95
+ tree_sha
96
+ end
97
+
98
+ def create_commit(tree_sha)
99
+ # Get parent commit if branch exists
100
+ parent = get_parent_commit
101
+
102
+ # Create commit object
103
+ message = "'Deploying to #{@branch} from @ #{@sha} 🚀'"
104
+ args = ['commit-tree', tree_sha]
105
+ args += ['-p', parent] if parent
106
+ args += ['-m', message]
107
+
108
+ commit_sha = `git #{args.join(' ')}`.chomp
109
+
110
+ commit_sha
111
+ end
112
+
113
+ def get_parent_commit
114
+ Git.sha_of("#{@branch}^{commit}")
115
+ end
116
+
117
+ def commit_and_push(commit_sha)
118
+ if branch_exists?
119
+ run! "git update-ref refs/heads/#{@branch} #{commit_sha}"
120
+ else
121
+ run! "git branch #{@branch} #{commit_sha}"
122
+ end
123
+ run! "git push -f origin #{@branch}:refs/heads/#{@branch}"
124
+ end
125
+
126
+ def branch_exists?
127
+ system("git show-ref --verify --quiet refs/heads/#{@branch}")
128
+ end
129
+
130
+ def cleanup
131
+ # Cleanup method for tests
132
+ end
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,19 @@
1
+ require "bard/deploy_strategy"
2
+
3
+ module Bard
4
+ class DeployStrategy
5
+ class SSH < DeployStrategy
6
+ def deploy
7
+ # Require SSH capability
8
+ target.require_capability!(:ssh)
9
+
10
+ # Determine branch
11
+ branch = target.instance_variable_get(:@branch) || "master"
12
+
13
+ # Run git pull and setup on remote server
14
+ target.run! "git pull origin #{branch}"
15
+ target.run! "bin/setup"
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,60 @@
1
+ require "bard/command"
2
+
3
+ module Bard
4
+ class DeployStrategy
5
+ @strategies = {}
6
+
7
+ class << self
8
+ attr_reader :strategies
9
+
10
+ def inherited(subclass)
11
+ super
12
+ # Extract strategy name from class name
13
+ # e.g., Bard::DeployStrategy::SSH -> :ssh
14
+ name = extract_strategy_name(subclass)
15
+ strategies[name] = subclass
16
+ end
17
+
18
+ def [](name)
19
+ strategies[name.to_sym]
20
+ end
21
+
22
+ private
23
+
24
+ def extract_strategy_name(klass)
25
+ # Get the class name without module prefix
26
+ class_name = klass.name.split('::').last
27
+ # Convert from CamelCase to snake_case
28
+ class_name
29
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
30
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
31
+ .downcase
32
+ .to_sym
33
+ end
34
+ end
35
+
36
+ attr_reader :target
37
+
38
+ def initialize(target)
39
+ @target = target
40
+ end
41
+
42
+ def deploy
43
+ raise NotImplementedError, "Subclasses must implement #deploy"
44
+ end
45
+
46
+ # Helper methods for strategies
47
+ def run!(command)
48
+ Command.run!(command)
49
+ end
50
+
51
+ def run(command)
52
+ Command.run(command)
53
+ end
54
+
55
+ def system!(command)
56
+ result = Kernel.system(command)
57
+ raise "Command failed: #{command}" unless result
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,100 @@
1
+ require "uri"
2
+ require "bard/command"
3
+
4
+ module Bard
5
+ class SSHServer
6
+ attr_reader :user, :host, :port, :path, :gateway, :ssh_key, :env
7
+
8
+ def initialize(uri_string, **options)
9
+ @uri_string = uri_string
10
+ @options = options
11
+
12
+ # Parse URI
13
+ uri = parse_uri(uri_string)
14
+ @user = uri.user || ENV['USER']
15
+ @host = uri.host
16
+ @port = uri.port ? uri.port.to_s : "22"
17
+
18
+ # Store options
19
+ @path = options[:path]
20
+ @gateway = options[:gateway]
21
+ @ssh_key = options[:ssh_key]
22
+ @env = options[:env]
23
+ end
24
+
25
+ def ssh_uri
26
+ "#{user}@#{host}:#{port}"
27
+ end
28
+
29
+ def hostname
30
+ host
31
+ end
32
+
33
+ def connection_string
34
+ "#{user}@#{host}"
35
+ end
36
+
37
+ def run(command)
38
+ full_command = build_command(command)
39
+ Open3.capture3(full_command)
40
+ end
41
+
42
+ def run!(command)
43
+ output, error, status = run(command)
44
+ if status.to_i.nonzero?
45
+ raise Command::Error, "Command failed: #{command}\n#{error}"
46
+ end
47
+ output
48
+ end
49
+
50
+ def exec!(command)
51
+ full_command = build_command(command)
52
+ exec(full_command)
53
+ end
54
+
55
+ private
56
+
57
+ def parse_uri(uri_string)
58
+ # Handle user@host:port format
59
+ if uri_string =~ /^([^@]+@)?([^:]+)(?::(\d+))?$/
60
+ user_part = $1&.chomp('@')
61
+ host_part = $2
62
+ port_part = $3
63
+
64
+ URI::Generic.build(
65
+ scheme: 'ssh',
66
+ userinfo: user_part,
67
+ host: host_part,
68
+ port: port_part&.to_i
69
+ )
70
+ else
71
+ URI.parse("ssh://#{uri_string}")
72
+ end
73
+ end
74
+
75
+ def build_command(command)
76
+ cmd = "ssh -tt"
77
+
78
+ # Add port
79
+ cmd += " -p #{port}" if port != "22"
80
+
81
+ # Add gateway
82
+ cmd += " -o ProxyJump=#{gateway}" if gateway
83
+
84
+ # Add SSH key
85
+ cmd += " -i #{ssh_key}" if ssh_key
86
+
87
+ # Add user@host
88
+ cmd += " #{user}@#{host}"
89
+
90
+ # Add command with path and env
91
+ remote_cmd = ""
92
+ remote_cmd += "#{env} " if env
93
+ remote_cmd += "cd #{path} && " if path
94
+ remote_cmd += command
95
+
96
+ cmd += " '#{remote_cmd}'"
97
+ cmd
98
+ end
99
+ end
100
+ end