cpflow 4.1.1 → 5.0.0.rc.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.
Files changed (80) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/commands/update-changelog.md +367 -0
  3. data/.github/workflows/claude-code-review.yml +44 -0
  4. data/.github/workflows/claude.yml +55 -0
  5. data/.gitignore +2 -0
  6. data/.overcommit.yml +43 -3
  7. data/.rubocop.yml +3 -3
  8. data/CHANGELOG.md +39 -3
  9. data/CONTRIBUTING.md +6 -0
  10. data/Gemfile +8 -7
  11. data/Gemfile.lock +93 -73
  12. data/README.md +53 -22
  13. data/cpflow.gemspec +5 -5
  14. data/docs/ai-github-flow-prompt.md +61 -0
  15. data/docs/ci-automation.md +335 -0
  16. data/docs/commands.md +70 -5
  17. data/docs/releasing.md +153 -0
  18. data/lib/command/ai_github_flow_prompt.rb +47 -0
  19. data/lib/command/base.rb +14 -0
  20. data/lib/command/cleanup_images.rb +1 -1
  21. data/lib/command/cleanup_stale_apps.rb +1 -1
  22. data/lib/command/copy_image_from_upstream.rb +14 -3
  23. data/lib/command/exists.rb +13 -2
  24. data/lib/command/generate.rb +153 -4
  25. data/lib/command/generate_github_actions.rb +170 -0
  26. data/lib/command/generator_helpers.rb +31 -0
  27. data/lib/command/github_flow_readiness.rb +37 -0
  28. data/lib/command/ps_wait.rb +5 -1
  29. data/lib/command/run.rb +4 -21
  30. data/lib/command/terraform/generate.rb +1 -0
  31. data/lib/command/version.rb +1 -0
  32. data/lib/constants/exit_code.rb +1 -0
  33. data/lib/core/config.rb +1 -1
  34. data/lib/core/controlplane.rb +13 -10
  35. data/lib/core/controlplane_api_direct.rb +25 -3
  36. data/lib/core/github_flow_readiness/checks.rb +143 -0
  37. data/lib/core/github_flow_readiness_service.rb +453 -0
  38. data/lib/core/repo_introspection.rb +118 -0
  39. data/lib/core/terraform_config/dsl.rb +1 -1
  40. data/lib/core/terraform_config/local_variable.rb +1 -1
  41. data/lib/cpflow/version.rb +1 -1
  42. data/lib/cpflow.rb +66 -3
  43. data/lib/generator_templates/Dockerfile +59 -3
  44. data/lib/generator_templates/controlplane.yml +27 -39
  45. data/lib/generator_templates/entrypoint.sh +1 -1
  46. data/lib/generator_templates/release_script.sh +23 -0
  47. data/lib/generator_templates/templates/app.yml +5 -8
  48. data/lib/generator_templates/templates/rails.yml +2 -11
  49. data/lib/generator_templates_sqlite/controlplane.yml +46 -0
  50. data/lib/generator_templates_sqlite/release_script.sh +25 -0
  51. data/lib/generator_templates_sqlite/templates/app.yml +15 -0
  52. data/lib/generator_templates_sqlite/templates/db.yml +6 -0
  53. data/lib/generator_templates_sqlite/templates/rails.yml +32 -0
  54. data/lib/generator_templates_sqlite/templates/storage.yml +6 -0
  55. data/lib/github_flow_templates/.github/actions/cpflow-build-docker-image/action.yml +131 -0
  56. data/lib/github_flow_templates/.github/actions/cpflow-delete-control-plane-app/action.yml +24 -0
  57. data/lib/github_flow_templates/.github/actions/cpflow-delete-control-plane-app/delete-app.sh +50 -0
  58. data/lib/github_flow_templates/.github/actions/cpflow-detect-release-phase/action.yml +62 -0
  59. data/lib/github_flow_templates/.github/actions/cpflow-setup-environment/action.yml +98 -0
  60. data/lib/github_flow_templates/.github/actions/cpflow-validate-config/action.yml +85 -0
  61. data/lib/github_flow_templates/.github/actions/cpflow-wait-for-health/action.yml +92 -0
  62. data/lib/github_flow_templates/.github/cpflow-help.md +47 -0
  63. data/lib/github_flow_templates/.github/workflows/cpflow-cleanup-stale-review-apps.yml +56 -0
  64. data/lib/github_flow_templates/.github/workflows/cpflow-delete-review-app.yml +142 -0
  65. data/lib/github_flow_templates/.github/workflows/cpflow-deploy-review-app.yml +445 -0
  66. data/lib/github_flow_templates/.github/workflows/cpflow-deploy-staging.yml +140 -0
  67. data/lib/github_flow_templates/.github/workflows/cpflow-help-command.yml +53 -0
  68. data/lib/github_flow_templates/.github/workflows/cpflow-promote-staging-to-production.yml +490 -0
  69. data/lib/github_flow_templates/.github/workflows/cpflow-review-app-help.yml +46 -0
  70. data/rakelib/create_release.rake +662 -37
  71. data/script/check_command_docs +4 -2
  72. data/script/check_cpln_links +25 -11
  73. data/script/precommit/check_command_docs +22 -0
  74. data/script/precommit/check_cpln_links +21 -0
  75. data/script/precommit/check_trailing_newlines +68 -0
  76. data/script/precommit/get_changed_files +49 -0
  77. data/script/precommit/ruby_autofix +52 -0
  78. data/script/precommit/ruby_lint +33 -0
  79. metadata +56 -15
  80. /data/docs/{migrating.md → migrating-heroku-to-control-plane.md} +0 -0
@@ -0,0 +1,453 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler"
4
+ require "cgi"
5
+ require "net/http"
6
+ require "yaml"
7
+
8
+ require_relative "repo_introspection"
9
+ require_relative "github_flow_readiness/checks"
10
+
11
+ # Drives the readiness checks that gate `cpflow generate-github-actions`. The actual
12
+ # checks live in `GithubFlowReadiness::Checks`; this class is the host that owns the
13
+ # shared lockfile parser, package.json parser, HTTP version cache, and registry-check
14
+ # helpers used across multiple checks. Add a new check by creating a class with `call`
15
+ # under `GithubFlowReadiness::Checks` and registering it in `CHECKS`.
16
+ class GithubFlowReadinessService # rubocop:disable Metrics/ClassLength
17
+ Result = GithubFlowReadiness::Result
18
+ RegistryCheck = Struct.new(
19
+ :dependencies,
20
+ :empty_message,
21
+ :missing_prefix,
22
+ :unknown_prefix,
23
+ :success_noun,
24
+ :availability_proc,
25
+ :registry_name,
26
+ keyword_init: true
27
+ )
28
+
29
+ CHECKS = [
30
+ GithubFlowReadiness::Checks::RailsApp,
31
+ GithubFlowReadiness::Checks::RubyVersion,
32
+ GithubFlowReadiness::Checks::BundlerVersion,
33
+ GithubFlowReadiness::Checks::Dockerfile,
34
+ GithubFlowReadiness::Checks::SqliteProduction,
35
+ GithubFlowReadiness::Checks::GemSources,
36
+ GithubFlowReadiness::Checks::GemExactPins,
37
+ GithubFlowReadiness::Checks::NpmExactPins
38
+ ].freeze
39
+
40
+ PUBLIC_RUBYGEMS_REMOTE = "https://rubygems.org"
41
+ REGISTRY_FETCH_THREADS = 8
42
+ REGISTRY_FETCH_TIMEOUT_SECONDS = 60
43
+
44
+ attr_reader :root_path
45
+
46
+ def initialize(root_path: Dir.pwd)
47
+ @root_path = Pathname.new(root_path)
48
+ @package_json_parse_error = false
49
+ @rubygems_versions_cache = build_registry_cache
50
+ @npm_versions_cache = build_registry_cache
51
+ end
52
+
53
+ def results
54
+ @results ||= CHECKS.flat_map { |klass| wrap_check_result(klass.new(self).call) }
55
+ end
56
+
57
+ def blockers?
58
+ results.any? { |result| result.status == :fail }
59
+ end
60
+
61
+ def summary
62
+ if blockers?
63
+ "Blockers found. Fix them before generating the Control Plane GitHub flow."
64
+ else
65
+ "No blocking readiness issues detected. Validate the real production build path before merging."
66
+ end
67
+ end
68
+
69
+ # ------------------------------------------------------------------
70
+ # Helpers exposed to check classes (and stubbed by specs).
71
+ # ------------------------------------------------------------------
72
+
73
+ def gem_dependencies
74
+ @gem_dependencies ||= load_gem_dependencies
75
+ end
76
+
77
+ def public_rubygems_dependency?(dependency)
78
+ return false unless dependency[:source_type] == :rubygems
79
+
80
+ remotes = dependency[:source_remotes]
81
+ remotes.empty? || remotes.all? { |remote| remote == PUBLIC_RUBYGEMS_REMOTE }
82
+ end
83
+
84
+ def inferred_ruby_version
85
+ version_string = RepoIntrospection.inferred_ruby_version_string(root_path.to_s)
86
+ Gem::Version.new(version_string) if version_string
87
+ end
88
+
89
+ def lockfile_bundler_version
90
+ file_path = root_path.join("Gemfile.lock")
91
+ return unless file_path.file?
92
+
93
+ lines = file_path.readlines(chomp: true)
94
+ bundler_index = lines.index("BUNDLED WITH")
95
+ return unless bundler_index
96
+
97
+ version = lines[(bundler_index + 1)..]&.find { |line| !line.strip.empty? }&.strip
98
+ return unless version
99
+
100
+ Gem::Version.new(version)
101
+ end
102
+
103
+ def sqlite_database_in_production?
104
+ RepoIntrospection.sqlite_database_in_production?(root_path.to_s)
105
+ end
106
+
107
+ def parsed_package_json
108
+ return @parsed_package_json if instance_variable_defined?(:@parsed_package_json)
109
+
110
+ package_json_path = root_path.join("package.json")
111
+ @package_json_parse_error = false
112
+ return @parsed_package_json = nil unless package_json_path.file?
113
+
114
+ @parsed_package_json = JSON.parse(package_json_path.read)
115
+ rescue JSON::ParserError
116
+ @package_json_parse_error = true
117
+ @parsed_package_json = nil
118
+ end
119
+
120
+ def package_json_parse_error
121
+ # Calling `parsed_package_json` here is the explicit "make sure parsing has run"
122
+ # trigger, so a reader of `package_json_parse_error` does not have to know that the
123
+ # flag is populated lazily. Guarded so memoized state isn't re-fetched.
124
+ parsed_package_json unless instance_variable_defined?(:@parsed_package_json)
125
+ @package_json_parse_error
126
+ end
127
+
128
+ def package_json_parse_error_result
129
+ Result.new(
130
+ status: :warn,
131
+ message: "Could not parse `package.json`; exact-pinned direct npm package readiness could not be fully verified."
132
+ )
133
+ end
134
+
135
+ def rubygems_registry_check
136
+ RegistryCheck.new(
137
+ dependencies: exact_rubygems_dependencies,
138
+ empty_message: "No exact-pinned direct Ruby gems to verify.",
139
+ missing_prefix: "Direct Ruby gem versions not available on RubyGems",
140
+ unknown_prefix: "Could not verify some exact-pinned Ruby gems against RubyGems",
141
+ success_noun: "direct Ruby gem",
142
+ availability_proc: method(:rubygems_requirement_available?),
143
+ registry_name: "RubyGems"
144
+ )
145
+ end
146
+
147
+ def npm_registry_check
148
+ RegistryCheck.new(
149
+ dependencies: exact_npm_dependencies,
150
+ empty_message: "No exact-pinned direct npm packages to verify.",
151
+ missing_prefix: "Direct npm package versions not available on npm",
152
+ unknown_prefix: "Could not verify some exact-pinned npm packages against npm",
153
+ success_noun: "direct npm package",
154
+ availability_proc: method(:npm_dependency_available?),
155
+ registry_name: "npm"
156
+ )
157
+ end
158
+
159
+ def exact_pin_registry_result(check)
160
+ return Result.new(status: :info, message: check.empty_message) if check.dependencies.empty?
161
+
162
+ grouped = partition_dependencies(check.dependencies, check.availability_proc)
163
+ results = []
164
+ results << registry_unavailable_result(check, grouped[:unavailable]) if grouped[:unavailable].any?
165
+ results << registry_unknown_result(check, grouped[:unknown]) if grouped[:unknown].any?
166
+ return results if results.any?
167
+
168
+ Result.new(status: :pass, message: registry_success_message(check))
169
+ end
170
+
171
+ # Stubbed in specs; keep public.
172
+ def fetch_rubygems_versions(name)
173
+ fetch_with_cache(rubygems_versions_cache, name) { fetch_versions_from_rubygems(name) }
174
+ end
175
+
176
+ # Stubbed in specs; keep public.
177
+ def fetch_npm_versions(name)
178
+ fetch_with_cache(npm_versions_cache, name) { fetch_versions_from_npm(name) }
179
+ end
180
+
181
+ private
182
+
183
+ attr_reader :rubygems_versions_cache, :npm_versions_cache
184
+
185
+ # Wrap a check's return value into an array. Avoid Kernel#Array on a Result Struct,
186
+ # which would unpack it into [status, message] instead of wrapping it.
187
+ def wrap_check_result(value)
188
+ return [] if value.nil?
189
+ return value if value.is_a?(Array)
190
+
191
+ [value]
192
+ end
193
+
194
+ def build_registry_cache
195
+ { store: {}, mutex: Mutex.new }
196
+ end
197
+
198
+ def exact_rubygems_dependencies
199
+ gem_dependencies.select do |dependency|
200
+ public_rubygems_dependency?(dependency) && dependency[:exact_version]
201
+ end
202
+ end
203
+
204
+ def exact_npm_dependencies
205
+ package_json = parsed_package_json
206
+ return [] unless package_json
207
+
208
+ collect_exact_dependencies(
209
+ package_json.fetch("dependencies", {}),
210
+ package_json.fetch("devDependencies", {})
211
+ )
212
+ end
213
+
214
+ def collect_exact_dependencies(*dependency_sets)
215
+ dependency_sets.flat_map { |dependencies| exact_dependency_entries(dependencies) }
216
+ end
217
+
218
+ def exact_dependency_entries(dependencies)
219
+ dependencies.filter_map do |name, version|
220
+ { name: name, exact_version: version } if exact_version_string?(version)
221
+ end
222
+ end
223
+
224
+ def exact_version_string?(version)
225
+ version.is_a?(String) && version.match?(/\A\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?\z/)
226
+ end
227
+
228
+ def rubygems_requirement_available?(dependency)
229
+ versions = fetch_rubygems_versions(dependency[:name])
230
+ return nil unless versions
231
+
232
+ requirement = dependency[:requirement]
233
+ versions.any? { |version| requirement.satisfied_by?(Gem::Version.new(version)) }
234
+ end
235
+
236
+ def npm_dependency_available?(dependency)
237
+ versions = fetch_npm_versions(dependency[:name])
238
+ return nil unless versions
239
+
240
+ versions.include?(dependency[:exact_version])
241
+ end
242
+
243
+ # Fan out registry lookups across a small thread pool. Each HTTP call has a 5s timeout
244
+ # (see `http_get`), and the join deadline below bounds cases such as DNS resolution
245
+ # hangs that Net::HTTP does not cover. Results are memoized per dependency name;
246
+ # serially this scaled linearly with dependency count, which made readiness slow for
247
+ # repos with many exact pins.
248
+ def partition_dependencies(dependencies, availability_proc)
249
+ results = fetch_availability_in_parallel(dependencies, availability_proc)
250
+ results.each_with_object(unavailable: [], unknown: []) do |(dependency, status), grouped|
251
+ case status
252
+ when false
253
+ grouped[:unavailable] << dependency
254
+ when nil
255
+ grouped[:unknown] << dependency
256
+ end
257
+ end
258
+ end
259
+
260
+ def fetch_availability_in_parallel(dependencies, availability_proc)
261
+ queue = Queue.new
262
+ indexed = dependencies.each_with_index.to_a
263
+ indexed.each { |entry| queue << entry }
264
+ results = Array.new(dependencies.length)
265
+ result_state = { mutex: Mutex.new, timed_out: false }
266
+
267
+ workers = build_availability_workers(queue, availability_proc, results, dependencies.length, result_state)
268
+ wait_for_availability_workers(workers, result_state)
269
+ fill_missing_availability_results(indexed, results)
270
+ results
271
+ end
272
+
273
+ def build_availability_workers(queue, availability_proc, results, dependency_count, result_state)
274
+ Array.new([REGISTRY_FETCH_THREADS, dependency_count].min) do
275
+ Thread.new { drain_availability_queue(queue, availability_proc, results, result_state) }
276
+ end
277
+ end
278
+
279
+ def wait_for_availability_workers(workers, result_state)
280
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + REGISTRY_FETCH_TIMEOUT_SECONDS
281
+ workers.each do |worker|
282
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
283
+ next if worker.join(remaining.positive? ? remaining : 0)
284
+
285
+ # Timed-out workers are not killed; their in-flight HTTP calls have their own
286
+ # short timeouts and then exit before writing any more results.
287
+ mark_availability_timeout(result_state)
288
+ end
289
+ end
290
+
291
+ def fill_missing_availability_results(indexed, results)
292
+ indexed.each { |dependency, index| results[index] ||= [dependency, nil] }
293
+ end
294
+
295
+ def drain_availability_queue(queue, availability_proc, results, result_state)
296
+ loop do
297
+ break if availability_timed_out?(result_state)
298
+
299
+ dependency, index = queue.pop(true)
300
+ write_availability_result(results, index, [dependency, availability_proc.call(dependency)], result_state)
301
+ rescue ThreadError
302
+ break
303
+ end
304
+ end
305
+
306
+ def availability_timed_out?(result_state)
307
+ result_state[:mutex].synchronize { result_state[:timed_out] }
308
+ end
309
+
310
+ def mark_availability_timeout(result_state)
311
+ result_state[:mutex].synchronize { result_state[:timed_out] = true }
312
+ end
313
+
314
+ def write_availability_result(results, index, value, result_state)
315
+ result_state[:mutex].synchronize do
316
+ results[index] = value unless result_state[:timed_out]
317
+ end
318
+ end
319
+
320
+ # Worker threads in `fetch_availability_in_parallel` may share the cache, so guard
321
+ # both the duplicate-fetch check and the assignment with a mutex. The mutex is released
322
+ # before `yield` so a slow HTTP request does not block other workers from reading cached
323
+ # entries; the trade-off is that N threads racing on a cold cache for the same name can
324
+ # all fire HTTP requests in parallel. The duplicate-fetch window is bounded:
325
+ # the queue assigns each (dependency, index) entry to exactly one worker, so a name
326
+ # only races when two workers happen to look up the same `name` from different
327
+ # dependencies. At most REGISTRY_FETCH_THREADS duplicate requests can fire per cold
328
+ # name on the first parallel sweep — acceptable because the fetches are idempotent and
329
+ # the public registries (rubygems.org, registry.npmjs.org) are designed to absorb that.
330
+ # On the write side this means the second `cache[:store][name] = value` overwrites the
331
+ # first; that is safe (no `||=` guard needed) precisely because both racers fetched the
332
+ # same registry data and so write the same value.
333
+ def fetch_with_cache(cache, name)
334
+ cache[:mutex].synchronize do
335
+ return cache[:store][name] if cache[:store].key?(name)
336
+ end
337
+
338
+ value = yield
339
+ cache[:mutex].synchronize { cache[:store][name] = value }
340
+ end
341
+
342
+ def fetch_versions_from_rubygems(name)
343
+ uri = URI("https://rubygems.org/api/v1/versions/#{CGI.escape(name)}.json")
344
+ response = http_get(uri)
345
+ return nil unless response.is_a?(Net::HTTPSuccess)
346
+
347
+ JSON.parse(response.body).map { |entry| entry["number"] }
348
+ rescue JSON::ParserError
349
+ nil
350
+ end
351
+
352
+ def fetch_versions_from_npm(name)
353
+ uri = URI("https://registry.npmjs.org/#{npm_package_path_segment(name)}")
354
+ response = http_get(uri)
355
+ return nil unless response.is_a?(Net::HTTPSuccess)
356
+
357
+ JSON.parse(response.body).fetch("versions", {}).keys
358
+ rescue JSON::ParserError, URI::InvalidURIError
359
+ nil
360
+ end
361
+
362
+ # Encodes the `/` in scoped package names so the registry path is valid.
363
+ # Other characters that are rare-but-legal in npm names (e.g. `%`) are not
364
+ # encoded here; URI construction errors are rescued in `fetch_versions_from_npm`
365
+ # and treated as unknown availability.
366
+ def npm_package_path_segment(name)
367
+ name.gsub("/", "%2F")
368
+ end
369
+
370
+ def http_get(uri)
371
+ http = Net::HTTP.new(uri.host, uri.port)
372
+ http.use_ssl = true
373
+ http.open_timeout = 5
374
+ http.read_timeout = 5
375
+ http.get(uri.request_uri)
376
+ rescue StandardError => e
377
+ warn "github_flow_readiness: HTTP GET #{uri} failed: #{e.class}: #{e.message}" if ENV["CPFLOW_DEBUG"]
378
+ nil
379
+ end
380
+
381
+ def load_gem_dependencies
382
+ lockfile_path = root_path.join("Gemfile.lock")
383
+ return [] unless lockfile_path.file?
384
+
385
+ parse_gem_dependencies(lockfile_path)
386
+ rescue StandardError => e
387
+ warn "cpflow: failed to parse Gemfile.lock: #{e.class}: #{e.message}" if ENV["CPFLOW_DEBUG"]
388
+ []
389
+ end
390
+
391
+ # Parse Gemfile.lock via Bundler::LockfileParser rather than Bundler::Dsl#eval_gemfile.
392
+ # `eval_gemfile` instance_evals the user's Gemfile, which executes arbitrary Ruby. Readiness
393
+ # checks run against untrusted project trees, so we keep the trust boundary at "parse the
394
+ # lockfile only" — no Ruby from the user's repo is ever executed here.
395
+ def parse_gem_dependencies(lockfile_path)
396
+ parser = Bundler::LockfileParser.new(lockfile_path.read)
397
+ parser.dependencies.values.map do |dependency|
398
+ spec = parser.specs.find { |locked_spec| locked_spec.name == dependency.name }
399
+ build_gem_dependency(dependency, source: spec&.source)
400
+ end
401
+ end
402
+
403
+ def build_gem_dependency(dependency, source:)
404
+ {
405
+ name: dependency.name,
406
+ exact_version: exact_gem_version(dependency),
407
+ requirement: dependency.requirement,
408
+ source_type: gem_source_type(source),
409
+ source_remotes: gem_source_remotes(source)
410
+ }
411
+ end
412
+
413
+ def exact_gem_version(dependency)
414
+ dependency.requirement.requirements.first.last.to_s if dependency.requirement.exact?
415
+ end
416
+
417
+ def gem_source_type(source)
418
+ return :rubygems if source.nil? || source.is_a?(Bundler::Source::Rubygems)
419
+ return :path if source.is_a?(Bundler::Source::Path)
420
+ return :git if source.is_a?(Bundler::Source::Git)
421
+
422
+ :other
423
+ end
424
+
425
+ def gem_source_remotes(source)
426
+ return [] unless source.respond_to?(:remotes)
427
+
428
+ Array(source.remotes).map { |remote| normalize_remote(remote) }
429
+ end
430
+
431
+ def normalize_remote(remote)
432
+ remote.to_s.sub(%r{/+\z}, "")
433
+ end
434
+
435
+ def format_dependencies(dependencies)
436
+ dependencies.map { |dependency| "`#{dependency[:name]}@#{dependency[:exact_version]}`" }.join(", ")
437
+ end
438
+
439
+ def registry_success_message(check)
440
+ dependency_count = check.dependencies.length
441
+ noun = "#{check.success_noun}#{'s' if dependency_count != 1}"
442
+
443
+ "Checked #{dependency_count} exact-pinned #{noun}; all appear available on #{check.registry_name}."
444
+ end
445
+
446
+ def registry_unavailable_result(check, dependencies)
447
+ Result.new(status: :fail, message: "#{check.missing_prefix}: #{format_dependencies(dependencies)}.")
448
+ end
449
+
450
+ def registry_unknown_result(check, dependencies)
451
+ Result.new(status: :warn, message: "#{check.unknown_prefix}: #{format_dependencies(dependencies)}.")
452
+ end
453
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module RepoIntrospection
6
+ DEFAULT_APP_PREFIX = "my-app"
7
+ RUBY_VERSION_DIRECTIVE_PATTERN = /^\s*ruby\s+['"\d]/
8
+ RUBY_VERSION_DIRECTIVE_PREFIX = /^\s*ruby\s+/
9
+
10
+ # Pure string → version-string extractor. Strips a leading `ruby-` prefix and returns
11
+ # the first `MAJOR.MINOR[.PATCH]` found in the source, or nil.
12
+ def self.parse_ruby_version_string(source)
13
+ normalized = source.strip.sub(/\Aruby-/, "")
14
+ normalized[/\d+\.\d+(?:\.\d+)?/]
15
+ end
16
+
17
+ # Returns the first Ruby version string the repo declares, checked in the order Bundler
18
+ # itself uses: `.ruby-version`, then `.tool-versions`, then `Gemfile`. Returns nil when
19
+ # no source declares a version. Both `Command::Generator` and `GithubFlowReadinessService`
20
+ # call into this so a future format change (e.g. `.tool-versions`) only updates here.
21
+ def self.inferred_ruby_version_string(root)
22
+ ruby_version_from_ruby_version_file(root) ||
23
+ ruby_version_from_tool_versions(root) ||
24
+ ruby_version_from_gemfile(root)
25
+ end
26
+
27
+ def self.ruby_version_from_ruby_version_file(root)
28
+ path = File.join(root, ".ruby-version")
29
+ return unless File.file?(path)
30
+
31
+ parse_ruby_version_string(File.read(path))
32
+ end
33
+
34
+ def self.ruby_version_from_tool_versions(root)
35
+ path = File.join(root, ".tool-versions")
36
+ return unless File.file?(path)
37
+
38
+ ruby_line = File.readlines(path, chomp: true).find { |line| line.match?(RUBY_VERSION_DIRECTIVE_PREFIX) }
39
+ return unless ruby_line
40
+
41
+ parse_ruby_version_string(ruby_line.sub(RUBY_VERSION_DIRECTIVE_PREFIX, ""))
42
+ end
43
+
44
+ def self.ruby_version_from_gemfile(root)
45
+ path = File.join(root, "Gemfile")
46
+ return unless File.file?(path)
47
+
48
+ ruby_lines = File.readlines(path, chomp: true).select { |line| line.match?(RUBY_VERSION_DIRECTIVE_PREFIX) }
49
+ ruby_line = ruby_lines.find { |line| line.match?(RUBY_VERSION_DIRECTIVE_PATTERN) }
50
+ warn_dynamic_ruby_directive if ruby_lines.any? && ruby_line.nil?
51
+ return unless ruby_line
52
+
53
+ parse_ruby_version_string(ruby_line.sub(RUBY_VERSION_DIRECTIVE_PREFIX, ""))
54
+ end
55
+
56
+ def self.warn_dynamic_ruby_directive
57
+ return unless ENV["CPFLOW_DEBUG"]
58
+
59
+ warn "cpflow: Gemfile has a dynamic `ruby` directive; falling back to the default Ruby version"
60
+ end
61
+
62
+ # Returns a Control Plane-safe app prefix derived from the basename of `root`:
63
+ # lower-cased, with non-alphanumeric runs collapsed to dashes and stripped from
64
+ # the ends. Falls back to DEFAULT_APP_PREFIX when the result is empty.
65
+ def self.inferred_app_prefix(root)
66
+ sanitized = File.basename(root)
67
+ .downcase
68
+ .gsub(/[^a-z0-9]+/, "-")
69
+ .gsub(/\A-+|-+\z/, "")
70
+
71
+ sanitized.empty? ? DEFAULT_APP_PREFIX : sanitized
72
+ end
73
+
74
+ # Returns true if `config/database.yml` under `root` configures SQLite for production.
75
+ # YAML merge keys such as `<<: *default` are resolved by safe_load, so only the
76
+ # final production hash should be inspected.
77
+ def self.sqlite_database_in_production?(root)
78
+ path = File.join(root, "config/database.yml")
79
+ return false unless File.file?(path)
80
+
81
+ parsed = safe_load_database_yml(File.read(path))
82
+ return false unless parsed.is_a?(Hash)
83
+
84
+ production = parsed["production"]
85
+ return false unless production.is_a?(Hash)
86
+
87
+ url = production["url"]
88
+ return sqlite_database_url?(url) if url.is_a?(String) && !url.strip.empty?
89
+
90
+ sqlite_adapter_in_hash?(production)
91
+ end
92
+
93
+ def self.safe_load_database_yml(raw_contents)
94
+ # ERB conditionals can change YAML structure, so avoid guessing. Output-only
95
+ # ERB is stubbed as a scalar so common Rails defaults like `pool: <%= ... %>`
96
+ # still parse, but control-flow ERB returns unknown. `<%- ... %>` is a
97
+ # whitespace-trimming code tag, not an output tag, so treat it as unknown too.
98
+ # Callers treat unknown as non-SQLite and
99
+ # emit the default Postgres scaffold rather than guessing wrong.
100
+ return nil if raw_contents.match?(/<%(?![=#])/m)
101
+
102
+ stubbed = raw_contents.gsub(/<%=.*?%>/m, "__erb__").gsub(/<%#.*?%>/m, "")
103
+ YAML.safe_load(stubbed, aliases: true, permitted_classes: [Symbol])
104
+ rescue Psych::SyntaxError
105
+ nil
106
+ end
107
+
108
+ def self.sqlite_adapter_in_hash?(config)
109
+ return false unless config.is_a?(Hash)
110
+
111
+ adapter = config["adapter"]
112
+ adapter.is_a?(String) && adapter.strip.start_with?("sqlite3")
113
+ end
114
+
115
+ def self.sqlite_database_url?(url)
116
+ url.strip.downcase.start_with?("sqlite:", "sqlite3:")
117
+ end
118
+ end
@@ -4,7 +4,7 @@ module TerraformConfig
4
4
  module Dsl
5
5
  extend Forwardable
6
6
 
7
- EXPRESSION_PATTERN = /(var|local|cpln_\w+)\./.freeze
7
+ EXPRESSION_PATTERN = /(var|local|cpln_\w+)\./
8
8
 
9
9
  def_delegators :current_context, :put, :output
10
10
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  module TerraformConfig
4
4
  class LocalVariable < Base
5
- VARIABLE_NAME_REGEX = /\A[a-zA-Z][a-zA-Z0-9_]*\z/.freeze
5
+ VARIABLE_NAME_REGEX = /\A[a-zA-Z][a-zA-Z0-9_]*\z/
6
6
 
7
7
  attr_reader :variables
8
8
 
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Cpflow
4
- VERSION = "4.1.1"
4
+ VERSION = "5.0.0.rc.0"
5
5
  MIN_CPLN_VERSION = "3.1.0"
6
6
  end
data/lib/cpflow.rb CHANGED
@@ -51,10 +51,15 @@ module Cpflow
51
51
 
52
52
  def self.start(*args)
53
53
  ENV["CPLN_SKIP_UPDATE_CHECK"] = "true"
54
+ ENV["NODE_NO_WARNINGS"] = "1"
54
55
 
55
- check_cpln_version
56
- check_cpflow_version
57
56
  fix_help_option
57
+ # Thor's `start(args = ARGV.dup, ...)` accepts an explicit argv as the first
58
+ # positional argument. Use that when present so the startup-check decision matches
59
+ # the command Thor is about to dispatch (and so test invocations don't pick up
60
+ # rspec's ARGV by accident).
61
+ argv = args.first.is_a?(Array) ? args.first : ARGV
62
+ run_startup_checks if requires_startup_checks?(argv)
58
63
 
59
64
  super
60
65
  end
@@ -119,13 +124,71 @@ module Cpflow
119
124
  end
120
125
  private_class_method :subcommand?
121
126
 
127
+ def self.run_startup_checks
128
+ check_cpln_version
129
+ check_cpflow_version
130
+ end
131
+ private_class_method :run_startup_checks
132
+
133
+ def self.requires_startup_checks?(argv = ARGV)
134
+ return false if argv.empty?
135
+ return false if help_request?(argv)
136
+ return false if version_flag?(argv)
137
+
138
+ command_class = command_class_for_argv(argv)
139
+ # Default to true when the command name is unrecognized so a typo still gets the
140
+ # version check (Thor's "unknown command" error then surfaces). Pre-PR behavior
141
+ # was always-on; only known commands explicitly opt out.
142
+ command_class ? command_class::REQUIRES_STARTUP_CHECKS : true
143
+ end
144
+ private_class_method :requires_startup_checks?
145
+
146
+ def self.help_request?(argv)
147
+ help_mappings = Thor::HELP_MAPPINGS + ["help"]
148
+ help_mappings.include?(argv.first)
149
+ end
150
+ private_class_method :help_request?
151
+
152
+ def self.version_flag?(argv)
153
+ %w[--version -v].include?(argv.first)
154
+ end
155
+ private_class_method :version_flag?
156
+
157
+ def self.command_class_for_argv(argv)
158
+ first_arg = argv[0]
159
+ return if first_arg.nil?
160
+
161
+ return subcommand_class_for_argv(first_arg, argv[1]) if subcommand_names.include?(first_arg)
162
+
163
+ top_level_command_class_for(first_arg)
164
+ end
165
+ private_class_method :command_class_for_argv
166
+
167
+ def self.subcommand_class_for_argv(subcommand_name, command_name)
168
+ return if command_name.nil?
169
+
170
+ all_base_commands[:"#{subcommand_name}_#{command_name.tr('-', '_')}"] ||
171
+ all_base_commands.values.find do |command_class|
172
+ subcommand_name == command_class::SUBCOMMAND_NAME && command_name == command_class::NAME
173
+ end
174
+ end
175
+ private_class_method :subcommand_class_for_argv
176
+
177
+ def self.top_level_command_class_for(command_name)
178
+ all_base_commands[command_name.tr("-", "_").to_sym] ||
179
+ all_base_commands.values.find do |command_class|
180
+ command_class::SUBCOMMAND_NAME.nil? && command_name == command_class::NAME
181
+ end
182
+ end
183
+ private_class_method :top_level_command_class_for
184
+
122
185
  # Needed to silence deprecation warning
123
186
  def self.exit_on_failure?
124
187
  true
125
188
  end
126
189
 
127
190
  # Needed to be able to use "run" as a command
128
- def self.is_thor_reserved_word?(word, type) # rubocop:disable Naming/PredicateName
191
+ def self.is_thor_reserved_word?(word, type) # rubocop:disable Naming/PredicatePrefix
129
192
  return false if word == "run"
130
193
 
131
194
  super