planter-cli 0.0.3

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.
@@ -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