foobar_templates 2.0.0
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.
- checksums.yaml +7 -0
- data/.beader/.gitignore +6 -0
- data/.beader/issues/1-support-flexible-template-topologies.yaml +21 -0
- data/.beader/issues/2-add-monorepo-support-for-template-discovery-and-generation.yaml +33 -0
- data/.beader/issues/3-implement-monorepo-aware-recursive-template-inventory.yaml +29 -0
- data/.beader/issues/4-add-leaf-template-selection-with-ambiguity-and-not-found-handling.yaml +30 -0
- data/.beader/issues/5-integrate-leaf-template-resolution-into-generation-flow.yaml +30 -0
- data/.beader/issues/6-add-monorepo-discovery-and-generation-test-coverage.yaml +31 -0
- data/.beader/issues/7-document-monorepo-template-configuration-and-selection.yaml +30 -0
- data/.beader/meta.yaml +1 -0
- data/.gitignore +16 -0
- data/.rspec +2 -0
- data/Gemfile +3 -0
- data/LICENSE.txt +22 -0
- data/README.md +154 -0
- data/Rakefile +55 -0
- data/bin/foobar_templates +72 -0
- data/changelog +107 -0
- data/config/config +8 -0
- data/foobar_templates.gemspec +33 -0
- data/lib/foobar_templates/cli/cheat_sheet.rb +12 -0
- data/lib/foobar_templates/cli/cli.rb +3 -0
- data/lib/foobar_templates/cli/dir_to_template.rb +120 -0
- data/lib/foobar_templates/cli/setup_personal_templates_repo.rb +135 -0
- data/lib/foobar_templates/cli/template_generator.rb +462 -0
- data/lib/foobar_templates/configurator.rb +99 -0
- data/lib/foobar_templates/core/core.rb +9 -0
- data/lib/foobar_templates/core/dir_to_template.rb +114 -0
- data/lib/foobar_templates/strings.rb +23 -0
- data/lib/foobar_templates/template_manager.rb +119 -0
- data/lib/foobar_templates/templates/template-test/.vscode/launch.json +0 -0
- data/lib/foobar_templates/templates/template-test/foo-bar/keep +0 -0
- data/lib/foobar_templates/templates/template-test/foo-bar.rb +16 -0
- data/lib/foobar_templates/templates/template-test/foo_bar/keep +0 -0
- data/lib/foobar_templates/templates/template-test/foobar.yml +2 -0
- data/lib/foobar_templates/templates/template-test/simple_dir/keep +0 -0
- data/lib/foobar_templates/templates/template-test/test_confirmed +0 -0
- data/lib/foobar_templates/templates/test_template/foo-bar/keep +0 -0
- data/lib/foobar_templates/templates/test_template/foo-bar.rb +21 -0
- data/lib/foobar_templates/templates/test_template/foo_bar/keep +0 -0
- data/lib/foobar_templates/templates/test_template/foobar.yml +2 -0
- data/lib/foobar_templates/templates/test_template/simple_dir/keep +0 -0
- data/lib/foobar_templates/version.rb +3 -0
- data/lib/foobar_templates.rb +151 -0
- data/spec/data/variable_manifest_test.rb +21 -0
- data/spec/foobar_templates/cli/dir_to_template_spec.rb +153 -0
- data/spec/foobar_templates/core/dir_to_template_spec.rb +104 -0
- data/spec/foobar_templates_spec.rb +573 -0
- data/spec/spec_helper.rb +106 -0
- data/spec/template_manager_spec.rb +68 -0
- metadata +157 -0
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
require 'pathname'
|
|
2
|
+
require 'yaml'
|
|
3
|
+
require 'open3'
|
|
4
|
+
require 'shellwords'
|
|
5
|
+
require 'set'
|
|
6
|
+
|
|
7
|
+
$TRACE = false
|
|
8
|
+
|
|
9
|
+
module FoobarTemplates::CLI
|
|
10
|
+
class TemplateGenerator
|
|
11
|
+
|
|
12
|
+
attr_reader :options, :gem_name, :name, :target
|
|
13
|
+
|
|
14
|
+
def initialize(options, gem_name)
|
|
15
|
+
@options = options
|
|
16
|
+
@gem_name = resolve_name(gem_name)
|
|
17
|
+
|
|
18
|
+
@name = @gem_name
|
|
19
|
+
@target = Pathname.pwd.join(gem_name)
|
|
20
|
+
@template_src = ::FoobarTemplates::TemplateManager.get_template_src(options)
|
|
21
|
+
@configurator = ::FoobarTemplates::Configurator.new
|
|
22
|
+
|
|
23
|
+
@tconf = load_template_configs
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def config
|
|
27
|
+
@config ||= build_interpolation_config
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def run
|
|
31
|
+
puts "Beginning run" if $TRACE
|
|
32
|
+
raise_project_with_that_name_already_exists! if File.exist?(target)
|
|
33
|
+
|
|
34
|
+
puts "ensure_safe_project_name" if $TRACE
|
|
35
|
+
ensure_safe_project_name(name, config[:constant_array])
|
|
36
|
+
|
|
37
|
+
puts "run_name_validation" if $TRACE
|
|
38
|
+
run_name_validation
|
|
39
|
+
|
|
40
|
+
template_src = match_template_src
|
|
41
|
+
|
|
42
|
+
puts "dynamically_generate_template_directories" if $TRACE
|
|
43
|
+
time_it("dynamically_generate_template_directories") do
|
|
44
|
+
@template_directories = dynamically_generate_template_directories
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
puts "dynamically_generate_templates_files" if $TRACE
|
|
48
|
+
templates = dynamically_generate_templates_files
|
|
49
|
+
|
|
50
|
+
puts "Creating new project folder '#{name}'\n\n"
|
|
51
|
+
create_template_directories(@template_directories, target)
|
|
52
|
+
|
|
53
|
+
templates.each do |src, dst|
|
|
54
|
+
template("#{template_src}/#{src}", target.join(dst), config)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
Dir.chdir(target) do
|
|
58
|
+
if @configurator.always_perform_git_init || !inside_git_work_tree?
|
|
59
|
+
`git init`
|
|
60
|
+
end
|
|
61
|
+
`git add .`
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
if @tconf[:bootstrap_command]
|
|
65
|
+
puts "Executing bootstrap_command"
|
|
66
|
+
cmd = safe_gsub_template_variables(@tconf[:bootstrap_command])
|
|
67
|
+
puts cmd
|
|
68
|
+
Dir.chdir(target) do
|
|
69
|
+
`#{cmd}`
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
puts "\nComplete."
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def build_interpolation_config
|
|
77
|
+
title = name.tr('-', '_').split('_').map(&:capitalize).join(" ")
|
|
78
|
+
pascal_name = name.tr('-', '_').split('_').map(&:capitalize).join
|
|
79
|
+
unprefixed_name = name.sub(/^#{@tconf[:prefix]}/, '')
|
|
80
|
+
underscored_name = name.tr('-', '_')
|
|
81
|
+
constant_name = name.split('_').map{|p| p[0..0].upcase + p[1..-1] unless p.empty?}.join
|
|
82
|
+
constant_name = constant_name.split('-').map{|q| q[0..0].upcase + q[1..-1] }.join('::') if constant_name =~ /-/
|
|
83
|
+
constant_array = constant_name.split('::')
|
|
84
|
+
git_user_name = `git config user.name`.chomp
|
|
85
|
+
git_user_email = `git config user.email`.chomp
|
|
86
|
+
|
|
87
|
+
# Resolve domain values from ~/.foobar/config, prompting if needed
|
|
88
|
+
required_domains = scan_template_for_required_domains
|
|
89
|
+
prompt_for_missing_domains(required_domains)
|
|
90
|
+
|
|
91
|
+
registry_domain = @configurator.domain('registry_domain')
|
|
92
|
+
k8s_domain = @configurator.domain('k8s_domain')
|
|
93
|
+
git_repo_domain = @configurator.domain('repo_domain') || 'github.com'
|
|
94
|
+
|
|
95
|
+
if git_user_name.empty?
|
|
96
|
+
raise FoobarTemplates::CLIError, [
|
|
97
|
+
"Error: git config user.name didn't return a value. You'll probably want to make sure that's configured with your github username:",
|
|
98
|
+
"",
|
|
99
|
+
"git config --global user.name YOUR_GH_NAME",
|
|
100
|
+
].join("\n")
|
|
101
|
+
else
|
|
102
|
+
# git_repo_path = provider.com/user/name
|
|
103
|
+
git_repo_path = "#{git_repo_domain}/#{git_user_name}/#{name}".downcase # downcasing for languages like go that are creative
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# git_repo_url = https://provider.com/user/name
|
|
107
|
+
git_repo_url = "https://#{git_repo_domain}/#{git_user_name}/#{name}"
|
|
108
|
+
|
|
109
|
+
image_path = "#{git_user_name}/#{name}".downcase
|
|
110
|
+
registry_repo_path = "#{registry_domain}/#{image_path}".downcase
|
|
111
|
+
|
|
112
|
+
config = {
|
|
113
|
+
:name => name,
|
|
114
|
+
:title => title,
|
|
115
|
+
:unprefixed_name => unprefixed_name,
|
|
116
|
+
unprefixed_pascal: unprefixed_name.tr('-', '_').split('_').map(&:capitalize).join,
|
|
117
|
+
underscored_name: underscored_name,
|
|
118
|
+
:pascal_name => pascal_name,
|
|
119
|
+
:camel_name => pascal_name.sub(/^./, &:downcase),
|
|
120
|
+
:screamcase_name => name.tr('-', '_').upcase,
|
|
121
|
+
:namespaced_path => name.tr('-', '/'),
|
|
122
|
+
:makefile_path => "#{underscored_name}/#{underscored_name}",
|
|
123
|
+
:constant_name => constant_name,
|
|
124
|
+
:constant_array => constant_array,
|
|
125
|
+
:author => git_user_name.empty? ? "TODO: Write your name" : git_user_name,
|
|
126
|
+
:email => git_user_email.empty? ? "TODO: Write your email address" : git_user_email,
|
|
127
|
+
:git_repo_domain => git_repo_domain,
|
|
128
|
+
:git_repo_url => git_repo_url,
|
|
129
|
+
:git_repo_path => git_repo_path,
|
|
130
|
+
:image_path => image_path,
|
|
131
|
+
:registry_domain => registry_domain,
|
|
132
|
+
:registry_repo_path => registry_repo_path,
|
|
133
|
+
:k8s_domain => k8s_domain,
|
|
134
|
+
:template => @options[:template],
|
|
135
|
+
:test => @options[:test],
|
|
136
|
+
}
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
private
|
|
141
|
+
|
|
142
|
+
def inside_git_work_tree?
|
|
143
|
+
system("git rev-parse --is-inside-work-tree", out: File::NULL, err: File::NULL)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def safe_gsub_template_variables(user_string)
|
|
147
|
+
build_content_replacement_pairs.inject(user_string) do |result, (find, replace)|
|
|
148
|
+
result.gsub(find, replace)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Runs declarative name validation rules from the template's foobar.yml:
|
|
153
|
+
#
|
|
154
|
+
# name_validation:
|
|
155
|
+
# reserved_names: [test, std, fmt] # exact-match denylist
|
|
156
|
+
# regex_validator: "^[a-z][a-z0-9-]*$" # name MUST match this pattern
|
|
157
|
+
#
|
|
158
|
+
# Both keys are optional. All checks run in pure Ruby — no shell, no
|
|
159
|
+
# cross-platform concerns.
|
|
160
|
+
def run_name_validation
|
|
161
|
+
rules = @tconf[:name_validation]
|
|
162
|
+
return if rules.nil? || rules.empty?
|
|
163
|
+
|
|
164
|
+
reserved = Array(rules[:reserved_names]).map(&:to_s)
|
|
165
|
+
if reserved.include?(name)
|
|
166
|
+
$stderr.puts "Invalid project name '#{name}': reserved by template '#{@options[:template]}'. Please choose another name."
|
|
167
|
+
raise
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
pattern = rules[:regex_validator]
|
|
171
|
+
if pattern && !pattern.to_s.empty?
|
|
172
|
+
begin
|
|
173
|
+
regex = Regexp.new(pattern.to_s)
|
|
174
|
+
rescue RegexpError => e
|
|
175
|
+
$stderr.puts "Template '#{@options[:template]}' has an invalid name_validation.regex_validator: #{e.message}"
|
|
176
|
+
raise "invalid name_validation.regex_validator"
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
unless regex.match?(name)
|
|
180
|
+
$stderr.puts "Invalid project name '#{name}': does not match #{regex.inspect} required by template '#{@options[:template]}'."
|
|
181
|
+
raise
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Domain placeholder → config key mapping
|
|
187
|
+
DOMAIN_PLACEHOLDERS = {
|
|
188
|
+
'registry_domain' => %w[FOO_REGISTRY_DOMAIN FOO_REGISTRY_REPO_PATH],
|
|
189
|
+
'k8s_domain' => %w[FOO_K8S_DOMAIN],
|
|
190
|
+
'repo_domain' => %w[FOO_GIT_REPO_DOMAIN FOO_GIT_REPO_PATH FOO_GIT_REPO_URL],
|
|
191
|
+
}.freeze
|
|
192
|
+
|
|
193
|
+
# Human-readable names for prompting
|
|
194
|
+
DOMAIN_DISPLAY_NAMES = {
|
|
195
|
+
'registry_domain' => 'registry-domain',
|
|
196
|
+
'k8s_domain' => 'k8s-domain',
|
|
197
|
+
'repo_domain' => 'repo-domain',
|
|
198
|
+
}.freeze
|
|
199
|
+
|
|
200
|
+
DOMAIN_DEFAULTS = {
|
|
201
|
+
'repo_domain' => 'github.com',
|
|
202
|
+
}.freeze
|
|
203
|
+
|
|
204
|
+
def scan_template_for_required_domains
|
|
205
|
+
all_placeholders = DOMAIN_PLACEHOLDERS.values.flatten
|
|
206
|
+
pattern = Regexp.union(all_placeholders)
|
|
207
|
+
found_placeholders = Set.new
|
|
208
|
+
|
|
209
|
+
Dir.glob("#{@template_src}/**/*", File::FNM_DOTMATCH).each do |f|
|
|
210
|
+
next unless File.file?(f)
|
|
211
|
+
base_path = f[@template_src.length+1..-1]
|
|
212
|
+
next if base_path.nil?
|
|
213
|
+
next if base_path.start_with?(".git" + File::SEPARATOR) || base_path == ".git"
|
|
214
|
+
next if binary_file?(f)
|
|
215
|
+
|
|
216
|
+
content = File.read(f)
|
|
217
|
+
all_placeholders.each do |ph|
|
|
218
|
+
found_placeholders << ph if content.include?(ph)
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Map found placeholders back to domain config keys
|
|
223
|
+
required = Set.new
|
|
224
|
+
DOMAIN_PLACEHOLDERS.each do |domain_key, placeholders|
|
|
225
|
+
required << domain_key if placeholders.any? { |ph| found_placeholders.include?(ph) }
|
|
226
|
+
end
|
|
227
|
+
required.to_a
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def prompt_for_missing_domains(required_domains)
|
|
231
|
+
required_domains.each do |domain_key|
|
|
232
|
+
next if @configurator.domain(domain_key) && !@configurator.domain(domain_key).empty?
|
|
233
|
+
|
|
234
|
+
display_name = DOMAIN_DISPLAY_NAMES[domain_key]
|
|
235
|
+
default = DOMAIN_DEFAULTS[domain_key]
|
|
236
|
+
default_hint = default ? " (default: #{default})" : ""
|
|
237
|
+
|
|
238
|
+
puts "This template requires '#{display_name}'. The value will be saved to ~/.foobar/config for future use."
|
|
239
|
+
print "Enter #{display_name}#{default_hint}: "
|
|
240
|
+
value = $stdin.gets&.chomp || ''
|
|
241
|
+
|
|
242
|
+
value = default if value.empty? && default
|
|
243
|
+
|
|
244
|
+
if value.empty?
|
|
245
|
+
puts "Warning: No value provided for '#{display_name}'. Template placeholders may not be fully resolved."
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
@configurator.set_domain(domain_key, value)
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def load_template_configs
|
|
253
|
+
template_config_path = File.join(@template_src, "foobar.yml")
|
|
254
|
+
|
|
255
|
+
if File.exist?(template_config_path)
|
|
256
|
+
t_config = YAML.load_file(template_config_path, symbolize_names: true)
|
|
257
|
+
else
|
|
258
|
+
t_config = {
|
|
259
|
+
purpose: "tool",
|
|
260
|
+
language: "go"
|
|
261
|
+
}
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
if t_config[:prefix].nil?
|
|
265
|
+
t_config[:prefix] = t_config[:purpose] ? "#{t_config[:purpose]}-" : ""
|
|
266
|
+
t_config[:prefix] += t_config[:language] ? "#{t_config[:language]}-" : ""
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
t_config
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# Returns a hash of source directory names and their destination mappings
|
|
273
|
+
def dynamically_generate_template_directories
|
|
274
|
+
template_dirs = Dir.glob("#{@template_src}/**/*", File::FNM_DOTMATCH).filter_map do |f|
|
|
275
|
+
base_path = f[@template_src.length+1..-1]
|
|
276
|
+
next if base_path.start_with?(".git" + File::SEPARATOR) || base_path == ".git"
|
|
277
|
+
next if f == "#{@template_src}/." || f == "#{@template_src}/.."
|
|
278
|
+
next unless File.directory?(f)
|
|
279
|
+
# next if ignored_by_git?(@template_src, base_path)
|
|
280
|
+
|
|
281
|
+
[base_path, substitute_template_values(base_path)]
|
|
282
|
+
end.to_h
|
|
283
|
+
filter_ignored_files!(@template_src, template_dirs)
|
|
284
|
+
|
|
285
|
+
template_dirs
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# Figures out the translation between all template files and their
|
|
289
|
+
# destination names
|
|
290
|
+
def dynamically_generate_templates_files
|
|
291
|
+
template_files = Dir.glob("#{@template_src}/**/*", File::FNM_DOTMATCH).filter_map do |f|
|
|
292
|
+
base_path = f[@template_src.length+1..-1]
|
|
293
|
+
next if base_path.nil?
|
|
294
|
+
next if base_path.start_with?(".git" + File::SEPARATOR) || base_path == ".git"
|
|
295
|
+
next if base_path == "foobar.yml"
|
|
296
|
+
next unless File.file?(f)
|
|
297
|
+
|
|
298
|
+
[base_path, substitute_template_values(base_path)]
|
|
299
|
+
end.to_h
|
|
300
|
+
|
|
301
|
+
raise_no_files_in_template_error! if template_files.empty?
|
|
302
|
+
filter_ignored_files!(@template_src, template_files)
|
|
303
|
+
|
|
304
|
+
return template_files
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
# Applies literal foo-bar variant substitutions to path strings
|
|
309
|
+
def substitute_template_values(path_str)
|
|
310
|
+
build_filename_replacement_pairs.inject(path_str) do |result, (find, replace)|
|
|
311
|
+
result.gsub(find, replace)
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def build_filename_replacement_pairs
|
|
316
|
+
[
|
|
317
|
+
['FOO_BAR', config[:screamcase_name]],
|
|
318
|
+
['FooBar', config[:pascal_name]],
|
|
319
|
+
['fooBar', config[:camel_name]],
|
|
320
|
+
['foo-bar', config[:name]],
|
|
321
|
+
['foo_bar', config[:underscored_name]],
|
|
322
|
+
]
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def build_content_replacement_pairs
|
|
326
|
+
[
|
|
327
|
+
# FOO_ prefixed non-name variables
|
|
328
|
+
['FOO_REGISTRY_REPO_PATH', config[:registry_repo_path] || ''],
|
|
329
|
+
['FOO_GIT_REPO_DOMAIN', config[:git_repo_domain]],
|
|
330
|
+
['FOO_GIT_REPO_PATH', config[:git_repo_path]],
|
|
331
|
+
['FOO_GIT_REPO_URL', config[:git_repo_url]],
|
|
332
|
+
['FOO_REGISTRY_DOMAIN', config[:registry_domain] || ''],
|
|
333
|
+
['FOO_IMAGE_PATH', config[:image_path]],
|
|
334
|
+
['FOO_K8S_DOMAIN', config[:k8s_domain] || ''],
|
|
335
|
+
['FOO_AUTHOR', config[:author]],
|
|
336
|
+
['FOO_EMAIL', config[:email]],
|
|
337
|
+
# Name-derived: compound/longer patterns first
|
|
338
|
+
['Foo::Bar', config[:constant_name]],
|
|
339
|
+
['FOO_BAR', config[:screamcase_name]],
|
|
340
|
+
['FooBar', config[:pascal_name]],
|
|
341
|
+
['fooBar', config[:camel_name]],
|
|
342
|
+
['Foo Bar', config[:title]],
|
|
343
|
+
['foo/bar', config[:namespaced_path]],
|
|
344
|
+
['foo-bar', config[:name]],
|
|
345
|
+
['foo_bar', config[:underscored_name]],
|
|
346
|
+
]
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def binary_file?(path)
|
|
350
|
+
chunk = File.binread(path, 8192)
|
|
351
|
+
chunk.nil? || chunk.include?("\x00")
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def filter_ignored_files!(repo_root, path_hash)
|
|
355
|
+
cmd = "git -C #{repo_root} check-ignore #{Shellwords.join(path_hash.keys)}"
|
|
356
|
+
stdout, _, status = Open3.capture3(cmd)
|
|
357
|
+
filter_these_paths = stdout.split
|
|
358
|
+
|
|
359
|
+
path_hash.delete_if { |key, _| filter_these_paths.include?(key) }
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def create_template_directories(template_directories, target)
|
|
363
|
+
template_directories.each do |k,v|
|
|
364
|
+
d = "#{target}/#{v}"
|
|
365
|
+
puts " mkdir #{d} ..."
|
|
366
|
+
FileUtils.mkdir_p(d)
|
|
367
|
+
end
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
# returns the full path of the template source
|
|
371
|
+
def match_template_src
|
|
372
|
+
template_src = ::FoobarTemplates::TemplateManager.get_template_src(@options)
|
|
373
|
+
|
|
374
|
+
if File.exist?(template_src)
|
|
375
|
+
return template_src # 'newgem' refers to the built in template that comes with the gem
|
|
376
|
+
else
|
|
377
|
+
raise_template_not_found! # else message the user that the template could not be found
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
def resolve_name(name)
|
|
382
|
+
Pathname.pwd.join(name).basename.to_s
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
# Reads a template source file, performs literal string replacements
|
|
388
|
+
# of foo-bar variants and FOO_ prefixed placeholders, and writes
|
|
389
|
+
# the result to the destination.
|
|
390
|
+
def template(source, destination, _config = {})
|
|
391
|
+
source = File.expand_path(source.to_s)
|
|
392
|
+
|
|
393
|
+
if binary_file?(source)
|
|
394
|
+
FileUtils.mkdir_p(File.dirname(destination))
|
|
395
|
+
FileUtils.cp(source, destination)
|
|
396
|
+
else
|
|
397
|
+
content = File.read(source)
|
|
398
|
+
content = content.gsub(/>>>\s+(\S+)/) { $1.chars.join("\x00") }
|
|
399
|
+
build_content_replacement_pairs.each do |find, replace|
|
|
400
|
+
content = content.gsub(find, replace)
|
|
401
|
+
end
|
|
402
|
+
content = content.gsub("\x00", '')
|
|
403
|
+
make_file(destination, {}) { content }
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
original_mode = File.stat(source).mode
|
|
407
|
+
File.chmod(original_mode, destination)
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
def make_file(destination, config, &block)
|
|
411
|
+
FileUtils.mkdir_p(File.dirname(destination))
|
|
412
|
+
puts " Writing #{destination} ..."
|
|
413
|
+
File.open(destination, "wb") { |f| f.write block.call }
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
def raise_no_files_in_template_error!
|
|
417
|
+
err_no_files_in_template = <<-HEREDOC
|
|
418
|
+
Ooops, the template was found for '#{@options[:template]}' in ~/.foobar/templates,
|
|
419
|
+
but no files were found within it.
|
|
420
|
+
|
|
421
|
+
Exiting...
|
|
422
|
+
HEREDOC
|
|
423
|
+
puts err_no_files_in_template
|
|
424
|
+
raise
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
def raise_project_with_that_name_already_exists!
|
|
428
|
+
err_project_with_that_name_exists = <<-HEREDOC
|
|
429
|
+
Ooops, a project with the name #{target} already exists.
|
|
430
|
+
Can't make project. Either delete that folder or choose a new project name
|
|
431
|
+
|
|
432
|
+
Exiting...
|
|
433
|
+
HEREDOC
|
|
434
|
+
puts err_project_with_that_name_exists
|
|
435
|
+
raise
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
def raise_template_not_found!
|
|
439
|
+
err_missing_template = "Could not find template folder '#{@options[:template]}' in `~/.foobar/templates/`. Please check to make sure your desired template exists."
|
|
440
|
+
$stderr.puts err_missing_template
|
|
441
|
+
raise
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
def time_it(label = nil)
|
|
445
|
+
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
446
|
+
yield
|
|
447
|
+
end_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
448
|
+
elapsed_ms = ((end_time - start_time) * 1000).round(2)
|
|
449
|
+
puts "#{label || 'Elapsed'}: #{elapsed_ms} ms"
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
# This checks to see that the gem_name is a valid ruby gem name and will 'work'
|
|
453
|
+
# and won't overlap with a foobar_templates constant apparently...
|
|
454
|
+
def ensure_safe_project_name(name, constant_array)
|
|
455
|
+
if name =~ /^\d/
|
|
456
|
+
$stderr.puts "Invalid gem name #{name} Please give a name which does not start with numbers."
|
|
457
|
+
raise
|
|
458
|
+
end
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
end
|
|
462
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
require 'fileutils'
|
|
2
|
+
require 'yaml'
|
|
3
|
+
|
|
4
|
+
module FoobarTemplates
|
|
5
|
+
|
|
6
|
+
class Configurator
|
|
7
|
+
attr_accessor :config_file_data
|
|
8
|
+
|
|
9
|
+
def initialize
|
|
10
|
+
@config_directory_root = "#{ENV['HOME']}/.foobar"
|
|
11
|
+
@config_file = "#{@config_directory_root}/config"
|
|
12
|
+
@user_defined_templates_path = "#{@config_directory_root}/templates"
|
|
13
|
+
|
|
14
|
+
create_config_file_if_needed!
|
|
15
|
+
|
|
16
|
+
# load configurations from config file
|
|
17
|
+
@config_file_data = YAML.load_file @config_file
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def default_template
|
|
21
|
+
@config_file_data["default_template"]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def default_template=(val)
|
|
25
|
+
@config_file_data["default_template"] = val
|
|
26
|
+
save_config!
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def always_perform_git_init
|
|
30
|
+
@config_file_data.fetch("always_perform_git_init", false)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def domain(key)
|
|
34
|
+
@config_file_data[key.to_s]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def set_domain(key, value)
|
|
38
|
+
@config_file_data[key.to_s] = value
|
|
39
|
+
save_config!
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def collect_user_defined_templates
|
|
43
|
+
immediate_subdirectories(@user_defined_templates_path).flat_map do |template_dir|
|
|
44
|
+
collect_templates_from_path(template_dir)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def create_config_file_if_needed!
|
|
49
|
+
FileUtils.mkdir_p @user_defined_templates_path
|
|
50
|
+
FileUtils.cp("#{SOURCE_ROOT}/config/config", @config_file) unless File.exist? @config_file
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def collect_templates_from_path(template_dir)
|
|
56
|
+
config = read_template_config(template_dir)
|
|
57
|
+
|
|
58
|
+
if monorepo_template?(config)
|
|
59
|
+
immediate_subdirectories(template_dir).flat_map do |child_dir|
|
|
60
|
+
collect_templates_from_path(child_dir)
|
|
61
|
+
end
|
|
62
|
+
else
|
|
63
|
+
category = config[:category] || "misc"
|
|
64
|
+
[{ category => File.basename(template_dir).sub(/^template-/, "") }]
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def monorepo_template?(config)
|
|
69
|
+
config[:monorepo] == true
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def read_template_config(template_dir)
|
|
73
|
+
template_config_path = File.join(template_dir, "foobar.yml")
|
|
74
|
+
return {} unless File.exist?(template_config_path)
|
|
75
|
+
|
|
76
|
+
raw_config = YAML.load_file(template_config_path, symbolize_names: true)
|
|
77
|
+
raw_config.is_a?(Hash) ? raw_config : {}
|
|
78
|
+
rescue StandardError
|
|
79
|
+
{}
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def immediate_subdirectories(dir)
|
|
83
|
+
Dir.children(dir).filter_map do |child_dir|
|
|
84
|
+
# Skip hidden files and dirs like .ds_store, etc.
|
|
85
|
+
if child_dir.start_with?(".")
|
|
86
|
+
nil
|
|
87
|
+
else
|
|
88
|
+
dir_path = File.join(dir, child_dir)
|
|
89
|
+
File.directory?(dir_path) ? dir_path : nil
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def save_config!
|
|
95
|
+
File.write(@config_file, "# Comments made to this file will not be preserved\n#{YAML.dump(@config_file_data)}")
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# This is the core logic for the app. CLI stuff should eventually get pulled
|
|
2
|
+
# out so the app is neat and the way I do CLI stuff lately.
|
|
3
|
+
module FoobarTemplates::Core
|
|
4
|
+
end
|
|
5
|
+
|
|
6
|
+
# I saw someone on the internet do requires this way so am giving it a spin :)
|
|
7
|
+
# It's a little odd, right? I'm not sure I like it yet but lets me go to
|
|
8
|
+
# town on namespaces and `class << self` definitions if I want...
|
|
9
|
+
require 'foobar_templates/core/dir_to_template'
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
require 'find'
|
|
2
|
+
require 'open3'
|
|
3
|
+
require 'shellwords'
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
module FoobarTemplates::Core::DirToTemplate
|
|
7
|
+
class << self
|
|
8
|
+
|
|
9
|
+
# Takes in a file_enumerator such as Find.find('.') and
|
|
10
|
+
# performs literal string replacements of project name variants
|
|
11
|
+
# with foo-bar template placeholders
|
|
12
|
+
def 🧙🪄! file_enumerator, template_name: "asdf-pkg", dry_run: false
|
|
13
|
+
# FOO_* composite values contain the project name (e.g. URLs, paths), so
|
|
14
|
+
# they must be substituted BEFORE the name-variant replacements rewrite
|
|
15
|
+
# `good-dog` → `foo-bar` inside them.
|
|
16
|
+
replacements = build_foo_var_replacement_pairs(template_name) + build_replacement_pairs(template_name)
|
|
17
|
+
files_changed = []
|
|
18
|
+
file_enumerator.each do |path|
|
|
19
|
+
next if should_skip?(path)
|
|
20
|
+
|
|
21
|
+
conduct_pkg_name_to_template_variable_replacements!(path, replacements) unless dry_run
|
|
22
|
+
|
|
23
|
+
files_changed << "Processed: #{path}"
|
|
24
|
+
end
|
|
25
|
+
files_changed
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def build_replacement_pairs(template_name)
|
|
31
|
+
underscored = template_name.tr('-', '_')
|
|
32
|
+
screamcase = underscored.upcase
|
|
33
|
+
pascal = underscored.split('_').map(&:capitalize).join
|
|
34
|
+
camel = pascal.sub(/^./, &:downcase)
|
|
35
|
+
|
|
36
|
+
# Order: longer/more-specific patterns first to avoid partial matches
|
|
37
|
+
[
|
|
38
|
+
[screamcase, 'FOO_BAR'],
|
|
39
|
+
[pascal, 'FooBar'],
|
|
40
|
+
[camel, 'fooBar'],
|
|
41
|
+
[template_name, 'foo-bar'],
|
|
42
|
+
[underscored, 'foo_bar'],
|
|
43
|
+
]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Reverse-substitute the user's real values for the FOO_* placeholders that
|
|
47
|
+
# `FoobarTemplates::CLI::TemplateGenerator#build_interpolation_config` would have
|
|
48
|
+
# interpolated. Keep this list in sync with that method.
|
|
49
|
+
#
|
|
50
|
+
# Ordered longest/most-specific first so composite values (URLs, paths)
|
|
51
|
+
# are consumed before their substrings (bare domain, author).
|
|
52
|
+
def build_foo_var_replacement_pairs(template_name)
|
|
53
|
+
author = `git config user.name`.chomp
|
|
54
|
+
email = `git config user.email`.chomp
|
|
55
|
+
|
|
56
|
+
configurator = FoobarTemplates::Configurator.new
|
|
57
|
+
repo_domain = configurator.domain('repo_domain')
|
|
58
|
+
registry_domain = configurator.domain('registry_domain')
|
|
59
|
+
k8s_domain = configurator.domain('k8s_domain')
|
|
60
|
+
|
|
61
|
+
pairs = []
|
|
62
|
+
|
|
63
|
+
if repo_domain && !repo_domain.empty? && author && !author.empty?
|
|
64
|
+
git_repo_url = "https://#{repo_domain}/#{author}/#{template_name}"
|
|
65
|
+
git_repo_path = "#{repo_domain}/#{author}/#{template_name}".downcase
|
|
66
|
+
pairs << [git_repo_url, 'FOO_GIT_REPO_URL']
|
|
67
|
+
pairs << [git_repo_path, 'FOO_GIT_REPO_PATH']
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
if author && !author.empty?
|
|
71
|
+
image_path = "#{author}/#{template_name}".downcase
|
|
72
|
+
if registry_domain && !registry_domain.empty?
|
|
73
|
+
registry_repo_path = "#{registry_domain}/#{image_path}".downcase
|
|
74
|
+
pairs << [registry_repo_path, 'FOO_REGISTRY_REPO_PATH']
|
|
75
|
+
end
|
|
76
|
+
pairs << [image_path, 'FOO_IMAGE_PATH']
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
pairs << [registry_domain, 'FOO_REGISTRY_DOMAIN'] if registry_domain && !registry_domain.empty?
|
|
80
|
+
pairs << [k8s_domain, 'FOO_K8S_DOMAIN'] if k8s_domain && !k8s_domain.empty?
|
|
81
|
+
pairs << [repo_domain, 'FOO_GIT_REPO_DOMAIN'] if repo_domain && !repo_domain.empty?
|
|
82
|
+
pairs << [email, 'FOO_EMAIL'] if email && !email.empty?
|
|
83
|
+
pairs << [author, 'FOO_AUTHOR'] if author && !author.empty?
|
|
84
|
+
|
|
85
|
+
pairs
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def conduct_pkg_name_to_template_variable_replacements!(path, replacements)
|
|
89
|
+
content = File.read(path)
|
|
90
|
+
original = content.dup
|
|
91
|
+
|
|
92
|
+
replacements.each do |find, replace|
|
|
93
|
+
content.gsub!(find, replace)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
File.write(path, content) if content != original
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def should_skip?(path)
|
|
100
|
+
!File.file?(path) || # skip directories
|
|
101
|
+
path.start_with?('./.git/') || # skip the .git directory
|
|
102
|
+
path == './.gitignore' || # skip .gitignore (must remain for git to work)
|
|
103
|
+
ignored_by_git?(path) || # skip things that are gitignored
|
|
104
|
+
path == "./foobar.yml" # skip the foobar.yml file
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def ignored_by_git?(path)
|
|
108
|
+
stdout, _, status = Open3.capture3("git check-ignore #{Shellwords.escape(path)}")
|
|
109
|
+
return false unless status.exitstatus == 0 || status.exitstatus == 1
|
|
110
|
+
status.success? && !stdout.strip.empty?
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
end
|
|
114
|
+
end
|