bard 1.7.3 → 1.8.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,6 @@
1
1
  require "bard/server"
2
+ require "bard/target"
3
+ require "bard/deprecation"
2
4
 
3
5
  module Bard
4
6
  class Config
@@ -8,69 +10,76 @@ module Bard
8
10
  new(project_name, path: path)
9
11
  end
10
12
 
11
- def initialize project_name, path: nil, source: nil
13
+ attr_reader :project_name, :targets
14
+
15
+ def initialize(project_name = nil, path: nil, source: nil)
16
+ # Support both positional and keyword argument for project_name
12
17
  @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
- }
18
+ @servers = {} # Unified hash for both Server and Target instances
19
+ @data_paths = []
20
+ @backup = nil
21
+ @ci_system = nil
22
+
23
+ # Load default configuration (creates Server instances for backward compat)
24
+ load_defaults if project_name
25
+
26
+ # Load user configuration
43
27
  if path && File.exist?(path)
44
28
  source = File.read(path)
45
29
  end
46
30
  if source
47
- instance_eval source
31
+ instance_eval(source)
48
32
  end
49
33
  end
50
34
 
51
- attr_reader :project_name, :servers
35
+ # Backward compatible accessor
36
+ def servers
37
+ @servers
38
+ end
39
+
40
+ # New v2.0 accessor (same as servers)
41
+ def targets
42
+ @servers
43
+ end
52
44
 
53
- def server key, &block
45
+ # Old v1.x API - creates Server instances
46
+ def server(key, &block)
47
+ Deprecation.warn "`server` is deprecated; use `target` instead (will be removed in v2.0)"
54
48
  key = key.to_sym
55
49
  @servers[key] = Server.define(project_name, key, &block)
56
50
  end
57
51
 
58
- def [] key
52
+ # New v2.0 API - creates Target instances
53
+ def target(key, &block)
59
54
  key = key.to_sym
55
+ @servers[key] ||= Target.new(key, self)
56
+ @servers[key].instance_eval(&block) if block
57
+ @servers[key]
58
+ end
59
+
60
+ # Get a server/target by key
61
+ def [](key)
62
+ key = key.to_sym
63
+ # Fallback to staging if production not defined
60
64
  if @servers[key].nil? && key == :production
61
65
  key = :staging
62
66
  end
63
67
  @servers[key]
64
68
  end
65
69
 
66
- def data *paths
67
- if paths.length == 0
68
- Array(@data)
70
+ # Data paths configuration
71
+ def data(*paths)
72
+ if paths.empty?
73
+ @data_paths
69
74
  else
70
- @data = paths
75
+ @data_paths = paths
71
76
  end
72
77
  end
73
78
 
79
+ def data_paths
80
+ @data_paths
81
+ end
82
+
74
83
  def backup(value = nil, &block)
75
84
  if block_given?
76
85
  @backup = BackupConfig.new(&block)
@@ -83,7 +92,9 @@ module Bard
83
92
  end
84
93
  end
85
94
 
86
- # short-hand for michael
95
+ def backup_enabled?
96
+ backup == true
97
+ end
87
98
 
88
99
  def github_pages url
89
100
  urls = []
@@ -94,14 +105,80 @@ module Bard
94
105
  urls << "www.#{hostname}"
95
106
  end
96
107
 
97
- server :production do
98
- github_pages true
108
+ target :production do
109
+ github_pages url
99
110
  ssh false
100
- ping *urls
111
+ ping(*urls) if urls.any?
101
112
  end
102
113
 
103
114
  backup false
104
115
  end
116
+
117
+ # CI configuration
118
+ def ci(system = nil)
119
+ if system.nil?
120
+ @ci_system
121
+ else
122
+ @ci_system = system
123
+ end
124
+ end
125
+
126
+ def ci_system
127
+ @ci_system
128
+ end
129
+
130
+ def ci_instance(branch)
131
+ return nil if @ci_system == false
132
+
133
+ 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
147
+ end
148
+
149
+ private
150
+
151
+ # Load default server configurations (v1.x compatible)
152
+ def load_defaults
153
+ @servers[:local] = Server.new(
154
+ project_name,
155
+ :local,
156
+ false,
157
+ "./",
158
+ ["#{project_name}.local"],
159
+ )
160
+ @servers[:gubs] = Server.new(
161
+ project_name,
162
+ :gubs,
163
+ "botandrose@cloud.hackett.world:22022",
164
+ "Sites/#{project_name}",
165
+ false,
166
+ )
167
+ @servers[:ci] = Server.new(
168
+ project_name,
169
+ :ci,
170
+ "jenkins@staging.botandrose.com:22022",
171
+ "jobs/#{project_name}/workspace",
172
+ false,
173
+ )
174
+ @servers[:staging] = Server.new(
175
+ project_name,
176
+ :staging,
177
+ "www@staging.botandrose.com:22022",
178
+ project_name,
179
+ ["#{project_name}.botandrose.com"],
180
+ )
181
+ end
105
182
  end
106
183
 
107
184
  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,19 @@
1
+ module Bard
2
+ module Deprecation
3
+ @warned = {}
4
+
5
+ def self.warn(message, callsite: nil)
6
+ callsite ||= caller_locations(2, 1).first
7
+ key = "#{callsite.path}:#{callsite.lineno}:#{message}"
8
+ return if @warned[key]
9
+
10
+ @warned[key] = true
11
+ location = "#{callsite.path}:#{callsite.lineno}"
12
+ Kernel.warn "[DEPRECATION] #{message} (called from #{location})"
13
+ end
14
+
15
+ def self.reset!
16
+ @warned = {}
17
+ end
18
+ end
19
+ end
@@ -22,34 +22,55 @@ module Bard
22
22
  private
23
23
 
24
24
  def build_site
25
- system "rm -rf #{@build_dir.sub(@sha, "*")}"
26
- run! <<~SH
27
- set -e
28
- RAILS_ENV=production bundle exec rails s -p 3000 -d --pid tmp/pids/server.pid
29
- OUTPUT=$(bundle exec rake assets:clean assets:precompile 2>&1) || echo "$OUTPUT"
30
-
31
- # Create the output directory and enter it
32
- BUILD=#{@build_dir}
33
- rm -rf $BUILD
34
- mkdir -p $BUILD
35
- cp -R public/assets $BUILD/
36
- cd $BUILD
37
-
38
- # wait until server responds
39
- echo waiting...
40
- curl -s --retry 5 --retry-delay 2 http://localhost:3000 >/dev/null 2>&1
41
-
42
- echo copying...
43
- # Mirror the site to the build folder, ignoring links with query params
44
- wget -nv -r -l inf --no-remove-listing -FEnH --reject-regex "(\\.*)\\?(.*)" http://localhost:3000/ 2>&1
45
-
46
- echo #{@domain} > CNAME
47
- SH
48
- ensure # cleanup
49
- run! <<~SH
50
- cat tmp/pids/server.pid | xargs -I {} kill {}
51
- rm -rf public/assets
52
- SH
25
+ with_locked_port do |port|
26
+ system "rm -rf #{@build_dir.sub(@sha, "*")}"
27
+ run! <<~SH
28
+ set -e
29
+ RAILS_ENV=production bundle exec rails s -p #{port} -d --pid tmp/pids/server.pid
30
+ OUTPUT=$(bundle exec rake assets:clean assets:precompile 2>&1) || echo "$OUTPUT"
31
+
32
+ # Create the output directory and enter it
33
+ BUILD=#{@build_dir}
34
+ rm -rf $BUILD
35
+ mkdir -p $BUILD
36
+ cp -R public/assets $BUILD/
37
+ cd $BUILD
38
+
39
+ # wait until server responds
40
+ echo waiting...
41
+ curl -s --retry 5 --retry-delay 2 http://localhost:#{port} >/dev/null 2>&1
42
+
43
+ echo copying...
44
+ # Mirror the site to the build folder, ignoring links with query params
45
+ wget -nv -r -l inf --no-remove-listing -FEnH --reject-regex "(\\.*)\\?(.*)" http://localhost:#{port}/ 2>&1
46
+
47
+ echo #{@domain} > CNAME
48
+ SH
49
+ ensure # cleanup
50
+ run! <<~SH
51
+ cat tmp/pids/server.pid | xargs -I {} kill {}
52
+ rm -rf public/assets
53
+ SH
54
+ end
55
+ end
56
+
57
+ def with_locked_port
58
+ (3000..3020).each do |port|
59
+ lock_file = "/tmp/bard_github_pages_#{port}.lock"
60
+ file = File.open(lock_file, File::RDWR | File::CREAT, 0644)
61
+ if file.flock(File::LOCK_EX | File::LOCK_NB)
62
+ begin
63
+ yield port
64
+ return
65
+ ensure
66
+ file.flock(File::LOCK_UN)
67
+ file.close
68
+ end
69
+ else
70
+ file.close
71
+ end
72
+ end
73
+ raise "Could not find an available port for GitHub Pages deployment (checked 3000-3020)."
53
74
  end
54
75
 
55
76
  def create_tree_from_build