Module: Kettle::Jem::CLI

Defined in:
lib/kettle/jem/cli.rb

Constant Summary collapse

USAGE =
<<~USAGE
  Usage:
    kettle-jem [PROJECT_ROOT] [--accept-config] [--bootstrap-mode] [--changelog|--no-changelog] [--quiet|--verbose]
    kettle-jem setup [PROJECT_ROOT] [--accept-config] [--bootstrap-mode] [--changelog|--no-changelog] [--quiet|--verbose]
    kettle-jem prepare [PROJECT_ROOT] [--json|--events[=TYPE,...]] [--report PATH] [--accept|--force] [--changelog|--no-changelog] [--quiet|--verbose]
    kettle-jem plan [PROJECT_ROOT] [--json|--events[=TYPE,...]] [--report PATH] [--accept|--force|--interactive] [--failure-mode MODE] [--prompt-answer ID=ACTION]
    kettle-jem apply [PROJECT_ROOT] [--json|--events[=TYPE,...]] [--report PATH] [--accept|--force|--interactive] [--failure-mode MODE] [--prompt-answer ID=ACTION] [--changelog|--no-changelog]
    kettle-jem template [PROJECT_ROOT] [--json|--events[=TYPE,...]] [--report PATH] [--accept|--force|--interactive] [--failure-mode MODE] [--prompt-answer ID=ACTION] [--changelog|--no-changelog]
    kettle-jem install [PROJECT_ROOT] [--json|--events[=TYPE,...]] [--report PATH] [--accept|--force|--interactive] [--failure-mode MODE] [--prompt-answer ID=ACTION] [--changelog|--no-changelog]
    kettle-jem manifest [PROJECT_ROOT] [--json]
    kettle-jem selftest [PROJECT_ROOT] [--json] [--report PATH] [--destination PATH] [--template-root PATH] [--selftest-output PATH]
    kettle-jem version
USAGE

Class Method Summary collapse

Class Method Details

.canonical_path(path) ⇒ Object



263
264
265
266
267
# File 'lib/kettle/jem/cli.rb', line 263

def canonical_path(path)
  File.realpath(path)
rescue Errno::ENOENT
  File.expand_path(path)
end

.command_allowed?(command) ⇒ Boolean

Returns:

  • (Boolean)


66
67
68
# File 'lib/kettle/jem/cli.rb', line 66

def command_allowed?(command)
  %w[setup prepare plan apply template install manifest selftest help version].include?(command)
end

.debug_enabled?(env) ⇒ Boolean

Returns:

  • (Boolean)


320
321
322
# File 'lib/kettle/jem/cli.rb', line 320

def debug_enabled?(env)
  %w[DEBUG KETTLE_JEM_DEBUG KETTLE_DEV_DEBUG].any? { |key| env_true?(env_value(env, key)) }
end

.ensure_not_running_from_own_context!(command, project_root) ⇒ Object

Raises:

  • (ArgumentError)


250
251
252
253
254
255
256
257
258
259
260
261
# File 'lib/kettle/jem/cli.rb', line 250

def ensure_not_running_from_own_context!(command, project_root)
  return if %w[help version].include?(command)

  current_root = canonical_path(Dir.pwd)
  own_root = canonical_path(File.expand_path("../../..", __dir__))
  target_root = canonical_path(project_root)
  return unless current_root == own_root && target_root != own_root

  raise ArgumentError,
    "Refusing to run kettle-jem from its own project root against #{project_root}; " \
    "run from the destination repository so its environment is loaded, or target kettle-jem itself."
end

.env_true?(value) ⇒ Boolean

Returns:

  • (Boolean)


324
325
326
# File 'lib/kettle/jem/cli.rb', line 324

def env_true?(value)
  /\A(?:true|t|yes|y|on|1)\z/i.match?(value.to_s.strip)
end

.env_value(env, key) ⇒ Object



328
329
330
331
332
# File 'lib/kettle/jem/cli.rb', line 328

def env_value(env, key)
  env.fetch(key, nil)
rescue KeyError
  nil
end

.execute(command, project_root:, env:, options:) ⇒ Object



197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
# File 'lib/kettle/jem/cli.rb', line 197

def execute(command, project_root:, env:, options:)
  run_options = options.fetch(:run_options)
  if options[:events]
    run_options[:event_stream] = Kettle::Jem.event_stream(
      options.fetch(:event_io),
      types: options[:event_types]
    )
  end
  result = case command
  when "setup"
    Kettle::Jem.setup_project(project_root, env: env, run_options: run_options)
  when "prepare"
    Kettle::Jem::Tasks::PrepareTask.run(project_root: project_root, env: env, run_options: run_options)
  when "plan"
    Kettle::Jem.plan_project(project_root, env: env, run_options: run_options)
  when "apply"
    Kettle::Jem.apply_project(project_root, env: env, run_options: run_options)
  when "template"
    if scoped_template_run?(run_options)
      Kettle::Jem::Tasks::TemplateTask.run(project_root: project_root, env: env, run_options: run_options)
    else
      Kettle::Jem::Tasks::InstallTask.run(project_root: project_root, env: env, run_options: run_options)
    end
  when "install"
    Kettle::Jem::Tasks::InstallTask.run(project_root: project_root, env: env, run_options: run_options)
  when "manifest"
    Kettle::Jem.template_manifest(project_root: project_root)
  when "selftest"
    Kettle::Jem::Tasks::SelfTestTask.run(
      project_root: project_root,
      destination_root: options[:destination_root] || project_root,
      template_root: options[:template_root],
      output_root: options[:selftest_output_root],
      min_divergence_threshold: options[:min_divergence_threshold]
    )
  else
    raise ArgumentError, "Unsupported kettle-jem command #{command.inspect}"
  end
  return result unless %w[setup prepare apply template install].include?(command)

  Kettle::Jem::MaintenanceChangelog.record_template_run(
    project_root: project_root,
    report: result,
    run_options: run_options,
    label: (command == "prepare") ? "Prepare project for kettle-jem templates" : "Apply kettle-jem templates"
  )
end

.normalize_command(argv) ⇒ Object

Raises:

  • (ArgumentError)


50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
# File 'lib/kettle/jem/cli.rb', line 50

def normalize_command(argv)
  args = Array(argv).dup
  command = args.shift
  return ["help", []] if command == "help" || command == "--help" || command == "-h"
  return ["setup", []] if command.nil?

  unless command_allowed?(command)
    args.unshift(command)
    command = "setup"
  end
  command = "version" if command == "version" || command == "--version" || command == "-v"
  raise ArgumentError, "Unsupported kettle-jem command #{command.inspect}" unless command_allowed?(command)

  [command, args]
end

.parse_options(args) ⇒ Object

Raises:

  • (ArgumentError)


70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
# File 'lib/kettle/jem/cli.rb', line 70

def parse_options(args)
  options = {
    json: false,
    events: false,
    run_options: {}
  }
  parser = OptionParser.new do |opts|
    opts.banner = USAGE
    opts.on("--json", "Print the full machine-readable result as JSON.") { options[:json] = true }
    opts.on("--events[=TYPES]", "Print newline-delimited JSON progress events. Optional comma-separated TYPES filter.") do |value|
      options[:events] = true
      options[:event_types] = value if value
    end
    opts.on("--report PATH", "Write the full machine-readable result to PATH as JSON.") do |path|
      options[:report_path] = path
    end
    opts.on("--accept", "Use non-interactive default decisions.") { options[:run_options][:accept] = true }
    opts.on("--force", "Alias for --accept.") { options[:run_options][:force] = true }
    opts.on("--interactive", "Use interactive decision mode when supported.") do
      options[:run_options][:interactive] = true
    end
    opts.on("--quiet", "Suppress normal text output.") do
      options[:run_options][:quiet] = true
    end
    opts.on("--verbose", "Request verbose diagnostics where supported.") do
      options[:run_options][:verbose] = true
    end
    opts.on("--failure-mode MODE", "Set the template failure mode.") do |mode|
      options[:run_options][:failure_mode] = mode
    end
    opts.on("--prompt-answer ID=ACTION", "Answer an interactive decision prompt.") do |value|
      id, action = value.to_s.split("=", 2)
      raise OptionParser::InvalidArgument, "--prompt-answer must use ID=ACTION" if id.to_s.empty? || action.to_s.empty?

      (options[:run_options][:prompt_answers] ||= {})[id] = action
      options[:run_options][:interactive] = true
    end
    opts.on("--allowed VALUE", "Set the env-file change policy.") do |value|
      options[:run_options][:allowed] = value
    end
    opts.on("--hook-templates VALUE", "Set hook template handling.") do |value|
      options[:run_options][:hook_templates] = value
    end
    opts.on("--hook_templates VALUE", "Alias for --hook-templates.") do |value|
      options[:run_options][:hook_templates] = value
    end
    opts.on("--git-drivers VALUE", "Set Git diff/merge driver setup handling.") do |value|
      options[:run_options][:git_drivers] = value
    end
    opts.on("--git_drivers VALUE", "Alias for --git-drivers.") do |value|
      options[:run_options][:git_drivers] = value
    end
    opts.on("--dry-run", "Plan supported post-template actions without applying them.") do
      options[:run_options][:dry_run] = true
    end
    opts.on("--check", "Verify supported post-template setup without changing files.") do
      options[:run_options][:git_drivers] = "check"
    end
    opts.on("--undo", "Undo supported post-template setup.") do
      options[:run_options][:git_drivers] = "undo"
    end
    opts.on("--only PATHS", "Restrict templating to comma-separated paths or patterns.") do |value|
      (options[:run_options][:only] ||= []) << value
    end
    opts.on("--include PATHS", "Include comma-separated paths or patterns.") do |value|
      (options[:run_options][:include] ||= []) << value
    end
    opts.on("--skip-commit", "Skip bootstrap commit behavior.") do
      options[:run_options][:skip_commit] = true
    end
    opts.on("--skip-drift-check", "Skip post-apply duplicate drift checking.") do
      options[:run_options][:skip_drift_check] = true
    end
    opts.on("--skip-duplicate-drift", "Alias for --skip-drift-check.") do
      options[:run_options][:skip_drift_check] = true
    end
    opts.on("--skip-rubocop-gradual", "Skip post-template RuboCop Gradual autocorrect.") do
      options[:run_options][:skip_rubocop_gradual] = true
    end
    opts.on("--[no-]changelog", "Add a Changed entry summarizing actual template changes (default).") do |value|
      options[:run_options][:skip_changelog] = !value
    end
    opts.on("--skip-binstubs", "Skip post-template curated Bundler binstub generation.") do
      options[:run_options][:skip_binstubs] = true
    end
    opts.on("--checksums VALUE", "Set checksum skip mode: template, dest, ignore-template, ignore-dest, or off.") do |value|
      options[:run_options][:checksums] = value
    end
    opts.on("--ignore-checksums", "Alias for --checksums off.") do
      options[:run_options][:checksums] = "off"
    end
    opts.on("--accept-config", "Accept first-run template config bootstrap.") do
      options[:run_options][:accept_config] = true
    end
    opts.on("--bootstrap-mode", "Force first-run bootstrap mode.") do
      options[:run_options][:bootstrap_mode] = true
    end
    opts.on("--template-profile PROFILE", "Use a packaged template profile.") do |value|
      options[:run_options][:template_profile] = value
    end
    opts.on("--shimmed-gem GEM", "Runtime gem required by the shim profile.") do |value|
      options[:run_options][:shimmed_gem] = value
    end
    opts.on("--shimmed-require REQUIRE", "Require path loaded by the shim profile.") do |value|
      options[:run_options][:shimmed_require] = value
    end
    opts.on("--destination PATH", "Selftest destination root.") do |path|
      options[:destination_root] = path
    end
    opts.on("--template-root PATH", "Selftest template root.") do |path|
      options[:template_root] = path
    end
    opts.on("--selftest-output PATH", "Selftest output root.") do |path|
      options[:selftest_output_root] = path
    end
    opts.on("--min-divergence-threshold PERCENT", "Fail selftest when divergence exceeds PERCENT.") do |value|
      options[:min_divergence_threshold] = Float(value)
    end
  end
  remaining = parser.parse(args)
  raise ArgumentError, "Expected at most one PROJECT_ROOT" if remaining.length > 1
  raise OptionParser::InvalidOption, "--events cannot be combined with --json" if options[:events] && options[:json]

  options[:project_root] = remaining.first
  options
end


311
312
313
314
315
316
317
318
# File 'lib/kettle/jem/cli.rb', line 311

def print_debug_snapshot(command, project_root:, env:, err:)
  err.puts("[kettle-jem] DEBUG: early environment snapshot")
  err.puts("  command=#{command.inspect}")
  err.puts("  project_root=#{project_root.inspect}")
  %w[DEBUG KETTLE_JEM_DEBUG KETTLE_DEV_DEBUG KETTLE_DEV_DEV BUNDLE_GEMFILE BUNDLE_PATH GEM_HOME GEM_PATH RUBYOPT RUBYLIB PWD].each do |key|
    err.puts("  #{key}=#{env_value(env, key).inspect}")
  end
end


334
335
336
337
# File 'lib/kettle/jem/cli.rb', line 334

def print_help(out)
  out.puts(USAGE)
  0
end


269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
# File 'lib/kettle/jem/cli.rb', line 269

def print_result(command, result, options:, out:)
  return if options[:events]
  return if options.fetch(:run_options, {})[:quiet] && !options[:json]

  if options[:json]
    out.puts(JSON.pretty_generate(result))
    return
  end

  case command
  when "setup"
    out.puts("setup: #{result.fetch(:setup_status)}")
    result.fetch(:changed_files, []).each { |path| out.puts("  #{path}") }
    result.fetch(:diagnostics, []).each do |diagnostic|
      message = diagnostic.is_a?(Hash) ? diagnostic[:message] || diagnostic["message"] : diagnostic
      out.puts("  #{message}") unless message.to_s.empty?
    end
  when "manifest"
    entries = result.fetch(:entries, [])
    out.puts("template manifest: #{entries.length} entr#{(entries.length == 1) ? "y" : "ies"}")
  when "selftest"
    comparison = result.fetch(:comparison, {})
    divergent = comparison.fetch(:changed, []).size +
      comparison.fetch(:added, []).size +
      comparison.fetch(:removed, []).size
    out.puts("selftest: #{divergent} divergent file#{"s" unless divergent == 1}")
    out.puts("  report: #{result.fetch(:report_path)}") if result[:report_path]
  else
    changed_files = result.fetch(:changed_files, [])
    out.puts("#{result.fetch(:mode)}: #{changed_files.length} changed file#{"s" unless changed_files.length == 1}")
    changed_files.each { |path| out.puts("  #{path}") }
    Array(result[:warnings]).map(&:to_s).reject(&:empty?).uniq.each do |warning|
      out.puts("  warning: #{warning}")
    end
  end
end


339
340
341
342
# File 'lib/kettle/jem/cli.rb', line 339

def print_version(out)
  out.puts(Kettle::Jem::Version::VERSION)
  0
end

.run(argv = ARGV, env: ENV, out: $stdout, err: $stderr) ⇒ Object



26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
# File 'lib/kettle/jem/cli.rb', line 26

def run(argv = ARGV, env: ENV, out: $stdout, err: $stderr)
  command, args = normalize_command(argv)
  return print_help(out) if command == "help"
  return print_version(out) if command == "version"

  options = parse_options(args)
  project_root = File.expand_path(options.fetch(:project_root) || Dir.pwd)
  ensure_not_running_from_own_context!(command, project_root)
  print_debug_snapshot(command, project_root: project_root, env: env, err: err) if debug_enabled?(env)
  options[:event_io] = out
  result = execute(command, project_root: project_root, env: env, options: options)
  write_report(options[:report_path], result) if options[:report_path]
  print_result(command, result, options: options, out: out)
  0
rescue OptionParser::ParseError, ArgumentError => error
  err.puts(error.message)
  err.puts(USAGE)
  2
rescue => error
  err.puts("#{error.class}: #{error.message}")
  err.puts(error.backtrace.join("\n")) if debug_enabled?(env)
  1
end

.scoped_template_run?(run_options) ⇒ Boolean

Returns:

  • (Boolean)


245
246
247
248
# File 'lib/kettle/jem/cli.rb', line 245

def scoped_template_run?(run_options)
  run_options = run_options.to_h
  run_options.key?(:only) || run_options.key?(:include)
end

.write_report(path, result) ⇒ Object



306
307
308
309
# File 'lib/kettle/jem/cli.rb', line 306

def write_report(path, result)
  FileUtils.mkdir_p(File.dirname(File.expand_path(path)))
  File.write(path, "#{JSON.pretty_generate(result)}\n")
end