planter-cli 0.0.3

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Main module
4
+ module Planter
5
+ def File.binary?(name)
6
+ IO.read(name) do |f|
7
+ f.each_byte { |x| x.nonzero? or return true }
8
+ end
9
+ false
10
+ end
11
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Planter
4
+ # A single file entry in a FileList
5
+ class FileEntry < Hash
6
+ # Operation to execute on the file
7
+ attr_accessor :operation
8
+
9
+ # File path and target path
10
+ attr_reader :file, :target
11
+
12
+ ##
13
+ ## Initialize a FileEntry object
14
+ ##
15
+ ## @param file [String] The source file path
16
+ ## @param target [String] The target path
17
+ ## @param operation [Symbol] The operation to perform
18
+ ##
19
+ ## @return [FileEntry] a Hash of parameters
20
+ ##
21
+ def initialize(file, target, operation)
22
+ @file = file
23
+ @target = target
24
+ @operation = operation
25
+
26
+ super()
27
+ end
28
+
29
+ ##
30
+ ## Test if file matches any pattern in config
31
+ ##
32
+ ## @return [Boolean] file matches pattern
33
+ ##
34
+ def matches_pattern?
35
+ Planter.patterns.filter { |pattern, _| @file =~ pattern }.count.positive?
36
+ end
37
+
38
+ ##
39
+ ## Determine operators based on configured filters,
40
+ ## asking for input if necessary
41
+ ##
42
+ ## @return [Symbol] Operator
43
+ ##
44
+ def test_operator
45
+ operator = Planter.overwrite ? :overwrite : :copy
46
+ Planter.patterns.each do |pattern, op|
47
+ next unless @file =~ pattern
48
+
49
+ operator = op == :ask && !Planter.overwrite ? ask_operation : op
50
+ break
51
+ end
52
+ operator
53
+ end
54
+
55
+ ##
56
+ ## Prompt for file handling. If File exists, offer a merge/overwrite/ignore,
57
+ ## otherwise simply ask whether or not to copy.
58
+ ##
59
+ def ask_operation
60
+ if File.exist?(@target)
61
+ Prompt.file_what?(self)
62
+ else
63
+ res = Prompt.yn("Copy #{File.basename(@file)} to #{File.basename(@target)}",
64
+ default_response: true)
65
+ res ? :copy : :ignore
66
+ end
67
+ end
68
+
69
+ ##
70
+ ## Returns a string representation of the object.
71
+ ##
72
+ ## @return [String] String representation of the object.
73
+ ##
74
+ def inspect
75
+ "<FileEntry: @file: #{@file}, @target: #{@target}, @operation: #{@operation}>"
76
+ end
77
+
78
+ ##
79
+ ## Returns a string representation of the object contents.
80
+ ##
81
+ ## @return [String] String representation of the object.
82
+ ##
83
+ def to_s
84
+ File.binary?(@file) ? 'Binary file' : IO.read(@file).to_s
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Planter
4
+ # File listing class
5
+ class FileList
6
+ attr_reader :files
7
+
8
+ ##
9
+ ## Initialize a new FileList object
10
+ ##
11
+ ## @param path [String] The base path for template
12
+ ##
13
+ def initialize(path)
14
+ @basedir = File.realdirpath(path)
15
+
16
+ search_path = File.join(@basedir, '**/*')
17
+ files = Dir.glob(search_path, File::FNM_DOTMATCH).reject do |file|
18
+ file =~ %r{/(_scripts|\.git|_config\.yml$|\.{1,2}$)}
19
+ end
20
+
21
+ files.sort_by!(&:length)
22
+
23
+ @files = files.map do |file|
24
+ new_file = "#{Planter.target}#{file.sub(/^#{@basedir}/, '').apply_variables.apply_regexes}"
25
+ operation = Planter.overwrite ? :overwrite : :copy
26
+ FileEntry.new(file, new_file, operation)
27
+ end
28
+
29
+ prepare_copy
30
+ end
31
+
32
+ ##
33
+ ## Public method for copying files based on their operator
34
+ ##
35
+ ## @return [Boolean] success
36
+ ##
37
+ def copy
38
+ @files.each do |file|
39
+ handle_operator(file)
40
+ end
41
+ rescue StandardError => e
42
+ Planter.notify("#{e}\n#{e.backtrace}", :debug)
43
+ Planter.notify('Error copying files/directories', :error, exit_code: 128)
44
+ end
45
+
46
+ private
47
+
48
+ ##
49
+ ## Perform operations
50
+ ##
51
+ ## @param entry [FileEntry] The file entry
52
+ ##
53
+ def handle_operator(entry)
54
+ case entry.operation
55
+ when :ignore
56
+ false
57
+ when :overwrite
58
+ copy_file(entry, overwrite: true)
59
+ when :merge
60
+ File.exist?(entry.target) ? merge(entry) : copy_file(entry)
61
+ else
62
+ copy_file(entry)
63
+ end
64
+ end
65
+
66
+ ##
67
+ ## Copy template files to new directory
68
+ ##
69
+ ## @return [Boolean] success
70
+ ##
71
+ def prepare_copy
72
+ @files.each do |entry|
73
+ if entry.matches_pattern?
74
+ entry.operation = entry.test_operator
75
+ propogate_operation(entry)
76
+ end
77
+ end
78
+ end
79
+
80
+ ##
81
+ ## Apply a parent operation to children
82
+ ##
83
+ ## @param entry [FileEntry] The file entry
84
+ ##
85
+ def propogate_operation(entry)
86
+ @files.each do |file|
87
+ file.operation = entry.operation if file.file =~ /^#{entry.file}/
88
+ end
89
+ end
90
+
91
+ ##
92
+ ## Copy tagged merge sections from source to target
93
+ ##
94
+ ## @param entry [FileEntry] The file entry
95
+ ##
96
+ def merge(entry)
97
+ return copy_file(entry) if File.directory?(entry.file)
98
+
99
+ type = `file #{entry.file}`
100
+ case type.sub(/^#{Regexp.escape(entry.file)}: /, '').split(/:/).first
101
+ when /Apple binary property list/
102
+ `plutil -convert xml1 #{entry.file}`
103
+ `plutil -convert xml1 #{entry.target}`
104
+ content = IO.read(entry.file)
105
+ when /data/
106
+ return copy_file(entry)
107
+ else
108
+ return copy_file(entry) if File.binary?(entry.file)
109
+
110
+ content = IO.read(entry.file)
111
+ end
112
+
113
+ merges = content.scan(%r{(?<=\A|\n).{,4}merge *\n(.*?)\n.{,4}/merge}m)
114
+ &.map { |m| m[0].strip.apply_variables.apply_regexes }
115
+ merges = [content] if !merges || merges.empty?
116
+ new_content = IO.read(entry.target)
117
+ merges.delete_if { |m| new_content =~ /#{Regexp.escape(m)}/ }
118
+ if merges.count.positive?
119
+ File.open(entry.target, 'w') { |f| f.puts "#{new_content.chomp}\n\n#{merges.join("\n\n")}" }
120
+ Planter.notify("Merged #{entry.file} => #{entry.target} (#{merges.count} merges)", :debug)
121
+ else
122
+ copy_file(entry)
123
+ end
124
+ end
125
+
126
+ ##
127
+ ## Perform file copy based on operator
128
+ ##
129
+ ## @param file [FileEntry] The file entry
130
+ ## @param overwrite [Boolean] Force overwrite
131
+ ##
132
+ def copy_file(file, overwrite: false)
133
+ if !File.exist?(file.target) || overwrite || Planter.overwrite
134
+ FileUtils.mkdir_p(File.dirname(file.target))
135
+ FileUtils.cp(file.file, file.target) unless File.directory?(file.file)
136
+ Planter.notify("Copied #{file.file} => #{file.target}", :debug)
137
+ true
138
+ else
139
+ Planter.notify("Skipped #{file.file} => #{file.target}", :debug)
140
+ false
141
+ end
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Hash helpers
4
+ class ::Hash
5
+ ## Turn all keys into string
6
+ ##
7
+ ## @return [Hash] copy of the hash where all its keys are strings
8
+ ##
9
+ def stringify_keys
10
+ each_with_object({}) do |(k, v), hsh|
11
+ hsh[k.to_s] = if v.is_a?(Hash)
12
+ v.stringify_keys
13
+ elsif v.is_a?(Array)
14
+ v.map(&:symbolize_keys)
15
+ else
16
+ v
17
+ end
18
+ end
19
+ end
20
+
21
+ ##
22
+ ## Turn all keys into symbols
23
+ ##
24
+ ## @return [Hash] hash with symbolized keys
25
+ ##
26
+ def symbolize_keys
27
+ each_with_object({}) do |(k, v), hsh|
28
+ hsh[k.to_sym] = if v.is_a?(Hash)
29
+ v.symbolize_keys
30
+ elsif v.is_a?(Array)
31
+ v.map(&:symbolize_keys)
32
+ else
33
+ v
34
+ end
35
+ end
36
+ end
37
+
38
+ ##
39
+ ## Deep merge a hash
40
+ ##
41
+ ## @param second [Hash] The hash to merge into self
42
+ ##
43
+ def deep_merge(second)
44
+ merger = proc do |_, v1, v2|
45
+ if v1.is_a?(Hash) && v2.is_a?(Hash)
46
+ v1.merge(v2, &merger)
47
+ elsif v1.is_a?(Array) && v2.is_a?(Array)
48
+ v1 | v2
49
+ elsif [:undefined, nil, :nil].include?(v2)
50
+ v1
51
+ else
52
+ v2
53
+ end
54
+ end
55
+ merge(second.to_h, &merger)
56
+ end
57
+
58
+ ##
59
+ ## Freeze all values in a hash
60
+ ##
61
+ ## @return [Hash] Hash with all values frozen
62
+ ##
63
+ def deep_freeze
64
+ chilled = {}
65
+ each do |k, v|
66
+ chilled[k] = v.is_a?(Hash) ? v.deep_freeze : v.freeze
67
+ end
68
+
69
+ chilled.freeze
70
+ end
71
+
72
+ ##
73
+ ## Destructive version of #deep_freeze
74
+ ##
75
+ ## @return [Hash] Hash with all values frozen
76
+ ##
77
+ def deep_freeze!
78
+ replace deep_thaw.deep_freeze
79
+ end
80
+
81
+ ##
82
+ ## Unfreeze a hash and all nested values
83
+ ##
84
+ ## @return [Hash] unfrozen hash
85
+ ##
86
+ def deep_thaw
87
+ chilled = {}
88
+ each do |k, v|
89
+ chilled[k] = v.is_a?(Hash) ? v.deep_thaw : v.dup
90
+ end
91
+
92
+ chilled.dup
93
+ end
94
+
95
+ ##
96
+ ## Destructive version of #deep_thaw
97
+ ##
98
+ ## @return [Hash] unfrozen hash
99
+ ##
100
+ def deep_thaw!
101
+ replace deep_thaw
102
+ end
103
+ end
@@ -0,0 +1,228 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Planter
4
+ # Primary class
5
+ class Plant
6
+ ##
7
+ ## Initialize a new Plant object
8
+ ##
9
+ ## @param template [String] the template name
10
+ ## @param variables [Hash] Pre-populated variables
11
+ ##
12
+ def initialize(template = nil, variables = nil)
13
+ Planter.variables = variables if variables.is_a?(Hash)
14
+ Planter.config = template if template
15
+
16
+ @basedir = File.join(Planter::BASE_DIR, 'templates', Planter.template)
17
+ @target = Planter.target || Dir.pwd
18
+
19
+ @git = Planter.config[:git_init] || false
20
+ @debug = Planter.debug
21
+ @repo = Planter.config[:repo] || false
22
+
23
+ # Coerce any existing variables (like from the command line) to the types
24
+ # defined in configuration
25
+ coerced = {}
26
+ Planter.variables.each do |k, v|
27
+ cfg_var = Planter.config[:variables].select { |var| k = var[:key] }
28
+ next unless cfg_var.count.positive?
29
+
30
+ var = cfg_var.first
31
+ type = var[:type].normalize_type
32
+ coerced[k] = v.coerce(type)
33
+ end
34
+ coerced.each { |k, v| Planter.variables[k] = v }
35
+
36
+ # Ask user for any variables not already defined
37
+ Planter.config[:variables].each do |var|
38
+ key = var[:key].to_var
39
+ next if Planter.variables.keys.include?(key)
40
+
41
+ q = Planter::Prompt::Question.new(
42
+ key: key,
43
+ prompt: var[:prompt] || var[:key],
44
+ type: var[:type].normalize_type || :string,
45
+ default: var[:default],
46
+ value: var[:value],
47
+ min: var[:min],
48
+ max: var[:max]
49
+ )
50
+ answer = q.ask
51
+ if answer.nil?
52
+ Planter.notify("Missing value #{key}", :error, exit_code: 15) unless var[:default]
53
+
54
+ answer = var[:default]
55
+ end
56
+
57
+ Planter.variables[key] = answer
58
+ end
59
+
60
+ git_pull if @repo
61
+
62
+ @files = FileList.new(@basedir)
63
+ end
64
+
65
+ ##
66
+ ## Expand GitHub name to full path
67
+ ##
68
+ ## @example Pass a GitHub-style repo path and get full url
69
+ ## expand_repo("ttscoff/planter-cli") #=> https://github.com/ttscoff/planter-cli.git
70
+ ##
71
+ ## @return { description_of_the_return_value }
72
+ ##
73
+ def expand_repo(repo)
74
+ repo =~ %r{(?!=http)\w+/\w+} ? "https://github.com/#{repo}.git" : repo
75
+ end
76
+
77
+ ##
78
+ ## Directory for repo, subdirectory of template
79
+ ##
80
+ ## @return [String] repo path
81
+ ##
82
+ def repo_dir
83
+ File.join(@basedir, File.basename(@repo).sub(/\.git$/, ''))
84
+ end
85
+
86
+ ##
87
+ ## Pull or clone a git repo
88
+ ##
89
+ ## @return [String] new base directory
90
+ ##
91
+ def git_pull
92
+ Planter.spinner.update(title: 'Pulling git repo')
93
+
94
+ raise Errors::GitError.new('`git` executable not found') unless TTY::Which.exist?('git')
95
+
96
+ pwd = Dir.pwd
97
+ @repo = expand_repo(@repo)
98
+
99
+ if File.exist?(repo_dir)
100
+ Dir.chdir(repo_dir)
101
+ raise Errors::GitError.new("Directory #{repo_dir} exists but is not git repo") unless File.exist?('.git')
102
+
103
+ res = `git pull`
104
+ raise Errors::GitError.new("Error pulling #{@repo}:\n#{res}") unless $?.success?
105
+ else
106
+ Dir.chdir(@basedir)
107
+ res = `git clone "#{@repo}" "#{repo_dir}"`
108
+ raise Errors::GitError.new("Error cloning #{@repo}:\n#{res}") unless $?.success?
109
+ end
110
+ Dir.chdir(pwd)
111
+ @basedir = repo_dir
112
+ rescue StandardError => e
113
+ raise Errors::GitError.new("Error pulling #{@repo}:\n#{e.message}")
114
+ end
115
+
116
+ ##
117
+ ## Plant the template to current directory
118
+ ##
119
+ def plant
120
+ Dir.chdir(@target)
121
+
122
+ Planter.spinner.auto_spin
123
+ Planter.spinner.update(title: 'Copying files')
124
+ res = copy_files
125
+ if res.is_a?(String)
126
+ Planter.spinner.error("(#{res})")
127
+ Process.exit 1
128
+ end
129
+
130
+ Planter.spinner.update(title: 'Applying variables')
131
+
132
+ res = update_files
133
+ if res.is_a?(String)
134
+ Planter.spinner.error('(Error)')
135
+ Planter.notify(res, :error, exit_code: 1)
136
+ end
137
+
138
+ if @git
139
+ raise Errors::GitError.new('`git` executable not found') unless TTY::Which.exist?('git')
140
+
141
+ Planter.spinner.update(title: 'Initializing git repo')
142
+ res = add_git
143
+ if res.is_a?(String)
144
+ Planter.spinner.error('(Error)')
145
+ Planter.notify(res, :error, exit_code: 1)
146
+ end
147
+ end
148
+
149
+ if Planter.config[:script]
150
+ Planter.spinner.update(title: 'Running script')
151
+
152
+ scripts = Planter.config[:script]
153
+ scripts = [scripts] if scripts.is_a?(String)
154
+ scripts.each do |script|
155
+ s = Planter::Script.new(@basedir, Dir.pwd, script)
156
+ s.run
157
+ end
158
+ end
159
+ Planter.spinner.update(title: '😄')
160
+ Planter.spinner.success(' Planting complete!')
161
+ end
162
+
163
+ ##
164
+ ## Copy files from template directory, renaming if %%template vars%% exist in title
165
+ ##
166
+ ## @return true if successful, otherwise error description
167
+ ##
168
+ def copy_files
169
+ @files.copy
170
+ true
171
+ end
172
+
173
+ ##
174
+ ## Update content of files in new directory using template variables
175
+ ##
176
+ def update_files
177
+ files = Dir.glob('**/*', File::FNM_DOTMATCH).reject { |f| File.directory?(f) || f =~ /^(\.git|config\.yml)/ }
178
+
179
+ files.each do |file|
180
+ type = `file #{file}`
181
+ case type.sub(/^#{Regexp.escape(file)}: /, '').split(/:/).first
182
+ when /Apple binary property list/
183
+ `plutil -convert xml1 #{file}`
184
+ when /data/
185
+ next
186
+ else
187
+ next if File.binary?(file)
188
+ end
189
+
190
+ content = IO.read(file)
191
+ new_content = content.apply_variables.apply_regexes
192
+
193
+ new_content.gsub!(%r{^.{.4}/?merge *.{,4}\n}, '') if new_content =~ /^.{.4}merge *\n/
194
+
195
+ unless content == new_content
196
+ Planter.notify("Applying variables to #{file}", :debug)
197
+ File.open(file, 'w') { |f| f.puts new_content }
198
+ end
199
+ end
200
+
201
+ true
202
+ rescue StandardError => e
203
+ Planter.notify("#{e}\n#{e.backtrace}", :debug)
204
+ 'Error updating files/directories'
205
+ end
206
+
207
+ ##
208
+ ## Initialize a git repo and create initial commit/tag
209
+ ##
210
+ ## @return true if successful, otherwise an error description
211
+ ##
212
+ def add_git
213
+ return if File.directory?('.git')
214
+
215
+ res = pass_fail('git init')
216
+ res = pass_fail('git add .') if res
217
+ res = pass_fail('git commit -a -m "initial commit"') if res
218
+ res = pass_fail('git tag -a 0.0.1 -m "v0.0.1"') if res
219
+
220
+ raise StandardError unless res
221
+
222
+ true
223
+ rescue StandardError => e
224
+ Planter.notify("#{e}\n#{e.backtrace}", :debug)
225
+ 'Error initializing git'
226
+ end
227
+ end
228
+ end