gemstack 0.3.5 → 0.3.6

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 30c68d4db704acae059c66f9847b0158ad178ebd7da1368c37056e5b93232306
4
- data.tar.gz: 2b65b33d1e415354038e4f4bac03556196af8d95fc01f144c7a53e97d95527b1
3
+ metadata.gz: 3653be40ea53434b0176c572f0ecdba840c2af0b639a7d4ed64a9117037c72f4
4
+ data.tar.gz: 2a6fe58ab592b81ee4ecd2bbe794a9e6e94f1b693afa6963725f6ac1a0719f93
5
5
  SHA512:
6
- metadata.gz: 9939a8e3bd4fb59fc2794ae86b66fc05751f8aa6463a4cd637cc8cd575ba300c2d824c02a4a245cda0a3b666e0107ab383f7e5a16499dffafda152e433b02448
7
- data.tar.gz: 389c35595446d6c96ac811be7a978e019bb310e7cb03570b7e04033e1f2a3fda682cbecb1b1e513409933b18b5d6322375175ddc324ec23270a6a78308676af7
6
+ metadata.gz: f0059eae0a47937b5984e631adfd2430e0b261845f4d6165f3288d269b63cd3d9601b89b4b0efbde33933e678128350c4bdab767ed7cf8927d0e2272691beaee
7
+ data.tar.gz: 169b95e87f103a9d21a07e60b51da63da756381bdc541d9e57abf0224454e364d4d1d64c931398f3f702b545f4f5463be30eeefe7468ec899651ac1c63c04d8d
data/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.6
4
+
5
+ See the [GemStack changelog](https://github.com/gemstack-rb/gemstack/blob/main/CHANGELOG.md).
6
+
3
7
  ## 0.3.5
4
8
 
5
9
  See the [GemStack changelog](https://github.com/gemstack-rb/gemstack/blob/main/CHANGELOG.md).
@@ -28,16 +28,18 @@ module GemStack
28
28
  @module_name = Inflector.camelize(@name.tr("-", "_"))
29
29
  @gemstack_path = options[:gemstack_path] ? File.expand_path(options[:gemstack_path]) : detect_checkout
30
30
  @api_path = "/api"
31
+ self.templates = options[:templates] if options[:templates]
31
32
  end
32
33
 
33
- def version = GemStack::VERSION
34
+ # (gemstack update renders older releases' templates with their own values.)
35
+ def version = @options[:version] || GemStack::VERSION
34
36
 
35
37
  # Pins the app to the Ruby that generated it, in files every version
36
38
  # manager reads (.ruby-version: rbenv, rvm, chruby, asdf, mise;
37
39
  # .tool-versions: asdf, mise), and Node.js likewise (.node-version,
38
40
  # .nvmrc, .tool-versions).
39
- def ruby_version = RUBY_VERSION
40
- def node_version = @node_version ||= Dev::Toolchain.pinned_node_version
41
+ def ruby_version = @options[:ruby_version] || RUBY_VERSION
42
+ def node_version = @node_version ||= @options[:node_version] || Dev::Toolchain.pinned_node_version
41
43
  def frontend? = !@options[:skip_frontend]
42
44
  def database? = !@options[:skip_database]
43
45
 
@@ -77,10 +77,12 @@ module GemStack
77
77
  end
78
78
 
79
79
  def run
80
+ @manifest = GenerationManifest.new(@root)
80
81
  template_files("deploy", override_root: @root).sort.each do |rel, source|
81
82
  content = File.read(source)
82
83
  content = render(content, source) if rel.end_with?(".tt")
83
- write(File.join(@root, output_path(rel.delete_suffix(".tt"))), content, mode: File.stat(source).mode)
84
+ write_tracked(File.join(@root, output_path(rel.delete_suffix(".tt"))), content,
85
+ owner: "deploy:app", mode: File.stat(source).mode)
84
86
  end
85
87
  keep = File.join(@root, "vendor/.keep")
86
88
  write(keep, "") unless File.exist?(keep) # the Dockerfile copies vendor/ (e.g. vendor/cache)
@@ -88,6 +90,8 @@ module GemStack
88
90
  enable_standalone_frontend if frontend?
89
91
  warn_about_local_gems
90
92
  self
93
+ ensure
94
+ @manifest&.save
91
95
  end
92
96
 
93
97
  private
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GemStack
4
+ class CLI < Thor
5
+ class DestroyGenerator < Generator
6
+ # What each kind owns, and what `destroy job|policy|deploy` checks beyond
7
+ # files and routes.
8
+ module Kinds
9
+ private
10
+
11
+ def owners_for(name)
12
+ case @kind
13
+ when "controller" then return ["controller:#{ControllerGenerator.new(name, [], root: @root).file_name}"]
14
+ when "job"
15
+ @constant = JobGenerator.class_name_for(name)
16
+ return ["job:#{Inflector.underscore(@constant)}"]
17
+ when "policy"
18
+ policy = PolicyGenerator.new(name, root: @root)
19
+ @constant = policy.class_name
20
+ return ["policy:#{policy.file_name}"]
21
+ when "migration"
22
+ file_name = Inflector.underscore(name.to_s)
23
+ raise Thor::Error, "Invalid migration name #{name.inspect}" unless file_name.match?(/\A[a-z][a-z0-9_]*\z/)
24
+
25
+ return ["migration:#{file_name}"]
26
+ when "deploy" then return ["deploy:app"]
27
+ end
28
+
29
+ @spec = ResourceSpec.new(name)
30
+ owners = ["model:#{@spec.file_name}"]
31
+ owners += ["controller:#{@spec.plural}", "resource:#{@spec.file_name}"] if @kind == "resource"
32
+ owners
33
+ end
34
+
35
+ # `gemstack generate deploy` adds exactly DeployGenerator::KAMAL_GEM; that
36
+ # line is removed, an edited one is left for the user.
37
+ def plan_gemfile
38
+ path = @manifest.absolute("Gemfile")
39
+ @gemfile_content = File.file?(path) ? File.read(path, encoding: "UTF-8") : ""
40
+ lines = @gemfile_content.lines
41
+ return unless lines.include?(DeployGenerator::KAMAL_GEM)
42
+
43
+ @gemfile_after = "#{lines.reject { |line| line == DeployGenerator::KAMAL_GEM }.join.rstrip}\n"
44
+ end
45
+
46
+ # A job or policy that other code still names would break it at runtime.
47
+ def check_constant_references
48
+ Dir.glob(File.join(@root, "{app,config,lib}/**/*.rb")).each do |absolute|
49
+ relative = Pathname.new(absolute).relative_path_from(Pathname.new(@root)).to_s
50
+ next if @files.key?(relative)
51
+
52
+ @manifest.absolute(relative)
53
+ tokens = Ripper.lex(File.read(absolute, encoding: "UTF-8"))
54
+ next unless tokens.any? { |_, type, text, _| type == :on_const && text == @constant.split("::").last }
55
+
56
+ @conflicts << "Remaining Ruby reference to #{@constant}: #{relative}"
57
+ end
58
+ end
59
+
60
+ def verify_gemfile!
61
+ return if File.read(@manifest.absolute("Gemfile"), encoding: "UTF-8") == @gemfile_content
62
+
63
+ raise Thor::Error, "Gemfile changed during confirmation; run destroy again"
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -1,13 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "ripper"
4
+ require_relative "destroy_generator/kinds"
4
5
 
5
6
  module GemStack
6
7
  class CLI < Thor
7
8
  # Plans the complete operation before changing anything. Only migrations
8
9
  # confirmed pending in the selected environment are eligible for removal.
9
10
  class DestroyGenerator < Generator
10
- KINDS = %w[model controller resource].freeze
11
+ include Kinds
12
+
13
+ KINDS = %w[model controller resource job policy migration deploy].freeze
14
+ # Kinds that change the API, so the TypeScript contract is regenerated after them.
15
+ CONTRACT_KINDS = %w[model controller resource].freeze
16
+ # What `gemstack generate deploy` writes at the app's root.
17
+ DEPLOY_FILES = %w[Dockerfile .dockerignore config/deploy.yml .kamal/secrets bin/docker-entrypoint].freeze
11
18
  SHARED = (Generator::BASE_CLASSES.values + %w[
12
19
  app/controllers/application_controller.rb test/test_helper.rb
13
20
  frontend/lib/format.ts frontend/lib/gemstack/client.ts
@@ -36,7 +43,7 @@ module GemStack
36
43
  environment: options[:environment]
37
44
  )
38
45
  removal.run { options[:yes] || confirm?(cli) }
39
- yield root if removal.changed? && !options[:skip_contract]
46
+ yield root if removal.contract_affected? && !options[:skip_contract]
40
47
  end
41
48
 
42
49
  def self.confirm?(cli)
@@ -48,8 +55,9 @@ module GemStack
48
55
  def initialize(kind, name, root:, dry_run: false, force: false, remove_migrations: false,
49
56
  output: $stdout, environment: nil)
50
57
  super(output: output, force: force)
51
- unless KINDS.include?(kind) && name && !name.empty?
52
- raise Thor::Error, "Usage: gemstack destroy model|controller|resource NAME"
58
+ unless KINDS.include?(kind) && (kind == "deploy" || (name && !name.empty?))
59
+ raise Thor::Error, "Usage: gemstack destroy model|controller|resource|job|policy|migration NAME, " \
60
+ "or gemstack destroy deploy"
53
61
  end
54
62
 
55
63
  @root = File.expand_path(root)
@@ -65,12 +73,13 @@ module GemStack
65
73
  end
66
74
 
67
75
  def changed? = @changed
76
+ def contract_affected? = @changed && CONTRACT_KINDS.include?(@kind)
68
77
 
69
78
  def run
70
79
  plan!
71
80
  preview
72
81
  raise Thor::Error, "Nothing removed:\n #{@conflicts.join("\n ")}" unless @conflicts.empty?
73
- return self if @dry_run || (@files.empty? && @routes.empty?)
82
+ return self if @dry_run || (@files.empty? && @routes.empty? && !@gemfile_after)
74
83
 
75
84
  if block_given? && !yield
76
85
  @output.puts("Cancelled; no files changed.")
@@ -84,18 +93,6 @@ module GemStack
84
93
 
85
94
  private
86
95
 
87
- def owners_for(name)
88
- if @kind == "controller"
89
- controller = ControllerGenerator.new(name, [], root: @root)
90
- return ["controller:#{controller.file_name}"]
91
- end
92
-
93
- @spec = ResourceSpec.new(name)
94
- owners = ["model:#{@spec.file_name}"]
95
- owners += ["controller:#{@spec.plural}", "resource:#{@spec.file_name}"] if @kind == "resource"
96
- owners
97
- end
98
-
99
96
  def plan!
100
97
  @conflicts = []
101
98
  selected = @manifest.files.select { |_, entry| @owners.include?(entry["owner"]) }
@@ -105,13 +102,15 @@ module GemStack
105
102
  plan_migrations
106
103
  @files.each { |path, entry| check_file(path, entry) }
107
104
  plan_routes
105
+ plan_gemfile if @kind == "deploy"
108
106
  check_dependencies if @spec && @files.key?("app/models/#{@spec.file_name}.rb")
107
+ check_constant_references if @constant && @files.any?
109
108
  end
110
109
 
111
110
  def check_file(path, entry)
112
- if SHARED.include?(path) || !path.start_with?("app/", "test/", "frontend/", "db/migrations/")
113
- raise Thor::Error, "Refusing to remove shared or unsupported path: #{path}"
114
- end
111
+ allowed = path.start_with?("app/", "test/", "frontend/", "db/migrations/") ||
112
+ (@kind == "deploy" && DEPLOY_FILES.include?(path))
113
+ raise Thor::Error, "Refusing to remove shared or unsupported path: #{path}" if SHARED.include?(path) || !allowed
115
114
 
116
115
  absolute = @manifest.absolute(path)
117
116
  raise Thor::Error, "Expected a generated file: #{path}" if File.exist?(absolute) && !File.file?(absolute)
@@ -226,8 +225,11 @@ module GemStack
226
225
  status(@snapshots[path] ? "remove" : "missing", path)
227
226
  end
228
227
  @routes.each { |entry| status("unroute", "config/routes.rb", entry["line"].strip) }
229
- if @files.empty? && @routes.empty?
230
- @output.puts("No tracked files to remove. Resources generated before ownership tracking need manual removal.")
228
+ status("ungem", "Gemfile", "kamal") if @gemfile_after
229
+ if @files.empty? && @routes.empty? && !@gemfile_after
230
+ @output.puts("No tracked files to remove. Code generated before ownership tracking (resources, models " \
231
+ "and controllers before 0.3.4; jobs, policies, migrations and deploy files before 0.3.6) " \
232
+ "needs manual removal.")
231
233
  end
232
234
  @output.puts("Dry run; no files or API contracts changed.") if @dry_run
233
235
  end
@@ -235,15 +237,22 @@ module GemStack
235
237
  # Recheck after interactive confirmation, before the first mutation.
236
238
  def verify_snapshot!
237
239
  @manifest.verify!
240
+ verify_migrations!
241
+ verify_files!
242
+ verify_routes!
243
+ verify_gemfile! if @gemfile_after
244
+ end
238
245
 
246
+ def verify_migrations!
239
247
  if @pending && !@pending.empty? && @migration_status.applied != @applied
240
248
  raise Thor::Error, "Migration state changed or became unavailable; run destroy again"
241
249
  end
250
+ return unless @auto_removed_migrations&.any? { |path, _| @migration_git_status.committed(path) != false }
242
251
 
243
- if @auto_removed_migrations&.any? { |path, _| @migration_git_status.committed(path) != false }
244
- raise Thor::Error, "Migration Git state changed; run destroy again"
245
- end
252
+ raise Thor::Error, "Migration Git state changed; run destroy again"
253
+ end
246
254
 
255
+ def verify_files!
247
256
  @snapshots.each do |relative, original|
248
257
  path = @manifest.absolute(relative)
249
258
  current = File.file?(path) ? Digest::SHA256.file(path).hexdigest : nil
@@ -252,12 +261,12 @@ module GemStack
252
261
  "File changed during confirmation: #{relative}; run destroy again"
253
262
  end
254
263
  end
264
+ end
255
265
 
266
+ def verify_routes!
256
267
  path = @manifest.absolute("config/routes.rb")
257
268
  current = File.file?(path) ? File.read(path, encoding: "UTF-8") : ""
258
- return if current == @route_content
259
-
260
- raise Thor::Error, "Routes changed during confirmation; run destroy again"
269
+ raise Thor::Error, "Routes changed during confirmation; run destroy again" unless current == @route_content
261
270
  end
262
271
 
263
272
  def apply
@@ -266,10 +275,12 @@ module GemStack
266
275
  File.delete(path) if File.file?(path)
267
276
  end
268
277
  File.write(@manifest.absolute("config/routes.rb"), @route_after) unless @routes.empty?
278
+ File.write(@manifest.absolute("Gemfile"), @gemfile_after) if @gemfile_after
269
279
  @manifest.forget(@files.keys, @routes)
270
280
  @manifest.save
271
281
  @changed = true
272
282
  @output.puts("Removed tracked code. Database data was not changed.")
283
+ @output.puts("Run bundle install to update Gemfile.lock.") if @gemfile_after
273
284
  end
274
285
  end
275
286
  end
@@ -12,6 +12,8 @@ module GemStack
12
12
  class GenerationManifest
13
13
  PATH = ".gemstack/generators.json"
14
14
  VERSION = 1
15
+ # "<generator>:<name>", e.g. "model:post", "job:send_digest", "deploy:app".
16
+ OWNER = %r{\A(?:model|controller|resource|job|policy|migration|deploy):[a-z][a-z0-9_]*(?:/[a-z][a-z0-9_]*)*\z}
15
17
 
16
18
  attr_reader :files, :routes
17
19
 
@@ -115,7 +117,7 @@ module GemStack
115
117
  end
116
118
 
117
119
  def owner?(owner)
118
- owner.is_a?(String) && owner.match?(%r{\A(?:model|controller|resource):[a-z][a-z0-9_]*(?:/[a-z][a-z0-9_]*)*\z})
120
+ owner.is_a?(String) && owner.match?(OWNER)
119
121
  end
120
122
  end
121
123
  end
@@ -23,7 +23,10 @@ module GemStack
23
23
  @created = []
24
24
  end
25
25
 
26
- def template_root(name) = File.join(TEMPLATES, name)
26
+ # Another GemStack version's templates/ (gemstack update compares them).
27
+ attr_writer :templates
28
+
29
+ def template_root(name) = File.join(@templates || TEMPLATES, name)
27
30
 
28
31
  # Ruby core/stdlib names an app class must not reuse (a top-level class
29
32
  # named Digest or Set would collide with Ruby's own). Constants already
@@ -141,11 +144,11 @@ module GemStack
141
144
  true
142
145
  end
143
146
 
144
- def write_tracked(path, content, owner:)
147
+ def write_tracked(path, content, owner:, mode: nil)
145
148
  relative = Pathname.new(File.expand_path(path)).relative_path_from(Pathname.new(File.expand_path(@root))).to_s
146
149
  @manifest.absolute(relative)
147
150
  existed = File.exist?(path)
148
- return unless write(path, content)
151
+ return unless write(path, content, mode: mode)
149
152
 
150
153
  @manifest.record_file(path, owner: owner, existed: existed)
151
154
  end
@@ -22,26 +22,35 @@ module GemStack
22
22
  generator.write(File.join(root, "db/migrations/#{stamp}_create_gemstack_jobs.rb"), Jobs::Migration::SOURCE)
23
23
  end
24
24
 
25
- def initialize(name, root:, queue: nil, output: $stdout, force: false)
26
- super(output: output, force: force)
27
- @root = root
25
+ # The job class for NAME: "send_digest" → "SendDigest", "DigestJob" stays.
26
+ def self.class_name_for(name)
28
27
  base = Inflector.camelize(name.to_s).delete_suffix("Job")
29
28
  raise Thor::Error, "Invalid job name #{name.inspect}" unless base.match?(/\A[A-Z][A-Za-z0-9]*\z/)
30
29
 
31
- @class_name = name.to_s.end_with?("Job") ? Inflector.camelize(name.to_s) : base
30
+ name.to_s.end_with?("Job") ? Inflector.camelize(name.to_s) : base
31
+ end
32
+
33
+ def initialize(name, root:, queue: nil, output: $stdout, force: false)
34
+ super(output: output, force: force)
35
+ @root = root
36
+ @class_name = self.class.class_name_for(name)
32
37
  @file_name = Inflector.underscore(@class_name)
33
38
  @queue_name = queue
34
39
  Generator.check_constant!(@class_name, suggestion: "#{@class_name}Job") unless name == "Placeholder"
35
40
  end
36
41
 
37
42
  def run
43
+ @manifest = GenerationManifest.new(@root)
38
44
  ensure_base_classes(@root, :job)
39
45
  template_files("job", override_root: @root).each do |rel, source|
40
46
  target = File.join(@root, rel.delete_suffix(".tt").gsub("%file_name%", file_name))
41
- write(target, render(File.read(source), source))
47
+ write_tracked(target, render(File.read(source), source), owner: "job:#{file_name}")
42
48
  end
49
+ # The jobs table is shared by every job: not tracked, so destroy keeps it.
43
50
  self.class.install_migration(@root, output: @output) if File.directory?(File.join(@root, "db"))
44
51
  self
52
+ ensure
53
+ @manifest&.save
45
54
  end
46
55
  end
47
56
  end
@@ -26,11 +26,14 @@ module GemStack
26
26
  end
27
27
 
28
28
  def run
29
+ @manifest = GenerationManifest.new(@root)
29
30
  template_files("migration", override_root: @root).each do |rel, source|
30
31
  path = rel.delete_suffix(".tt").gsub("%timestamp%", timestamp).gsub("%file_name%", file_name)
31
- write(File.join(@root, path), render(File.read(source), source))
32
+ write_tracked(File.join(@root, path), render(File.read(source), source), owner: "migration:#{file_name}")
32
33
  end
33
34
  self
35
+ ensure
36
+ @manifest&.save
34
37
  end
35
38
 
36
39
  private
@@ -24,11 +24,14 @@ module GemStack
24
24
  end
25
25
 
26
26
  def run
27
+ @manifest = GenerationManifest.new(@root)
27
28
  template_files("policy", override_root: @root).each do |rel, source|
28
29
  target = File.join(@root, rel.delete_suffix(".tt").gsub("%file_name%", file_name))
29
- write(target, render(File.read(source), source))
30
+ write_tracked(target, render(File.read(source), source), owner: "policy:#{file_name}")
30
31
  end
31
32
  self
33
+ ensure
34
+ @manifest&.save
32
35
  end
33
36
  end
34
37
  end
@@ -0,0 +1,299 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "net/http"
6
+ require "open3"
7
+ require "rubygems/package"
8
+ require "stringio"
9
+ require "tempfile"
10
+ require "tmpdir"
11
+
12
+ module GemStack
13
+ class CLI < Thor
14
+ # Finds the templates/ directory of a GemStack version: this one, an
15
+ # installed gem, or the gem downloaded from rubygems.org.
16
+ module TemplateSource
17
+ module_function
18
+
19
+ # Releases since templates took their current layout (0.3.0).
20
+ FIRST = Gem::Version.new("0.3.0")
21
+
22
+ def locate(version, cache: Dir.mktmpdir("gemstack-templates"))
23
+ return Generator::TEMPLATES if version == GemStack::VERSION
24
+
25
+ installed(version) || download(version, cache)
26
+ end
27
+
28
+ # Every release before VERSION (rubygems.org's list, or the installed ones offline).
29
+ def releases_before(version)
30
+ limit = Gem::Version.new(version)
31
+ names = published_versions || installed_versions
32
+ names.map { |name| Gem::Version.new(name) }
33
+ .select { |v| !v.prerelease? && v >= FIRST && v < limit }.sort.map(&:to_s)
34
+ end
35
+
36
+ def published_versions
37
+ response = Net::HTTP.get_response(URI("https://rubygems.org/api/v1/versions/gemstack.json"))
38
+ JSON.parse(response.body).map { |release| release.fetch("number") } if response.is_a?(Net::HTTPSuccess)
39
+ rescue StandardError
40
+ nil
41
+ end
42
+
43
+ def installed_versions
44
+ gem_dirs.flat_map { |dir| Dir[File.join(dir, "gems", "gemstack-*", "templates")] }
45
+ .filter_map { |path| path[%r{gemstack-(\d+\.\d+\.\d+)/templates\z}, 1] }.uniq
46
+ end
47
+
48
+ def gem_dirs = (Gem.path + [(Bundler.bundle_path.to_s if defined?(Bundler))]).compact.uniq
49
+
50
+ def installed(version)
51
+ gem_dirs.map { |dir| File.join(dir, "gems", "gemstack-#{version}", "templates") }
52
+ .find { |path| File.directory?(path) }
53
+ end
54
+
55
+ def download(version, cache)
56
+ cache = File.join(cache, version)
57
+ FileUtils.mkdir_p(cache)
58
+ response = Net::HTTP.get_response(URI("https://rubygems.org/downloads/gemstack-#{version}.gem"))
59
+ return unless response.is_a?(Net::HTTPSuccess)
60
+
61
+ gem = File.join(cache, "gemstack-#{version}.gem")
62
+ File.binwrite(gem, response.body)
63
+ Gem::Package.new(gem).extract_files(cache, "templates/**/*")
64
+ templates = File.join(cache, "templates")
65
+ templates if File.directory?(templates)
66
+ rescue StandardError
67
+ nil
68
+ end
69
+ end
70
+
71
+ # The template half of `gemstack update`: brings the files `gemstack new`
72
+ # wrote into line with this GemStack version. Each file is compared three
73
+ # ways — the app's copy, the old version's template and this version's,
74
+ # both rendered for this app — so only files a release changed are touched:
75
+ #
76
+ # template unchanged skipped (your edits are yours)
77
+ # new in this version created
78
+ # you never edited the file updated
79
+ # you edited it you choose: overwrite, skip, diff, or save as FILE.new
80
+ #
81
+ # Apps record the templates they're on in .gemstack/version. Older apps don't,
82
+ # so each file is compared with every earlier release's template: matching
83
+ # one means you never edited it (it's just out of date). Without any old
84
+ # templates (offline), files that differ are offered, never updated.
85
+ class TemplateUpdate
86
+ VERSION_FILE = ".gemstack/version"
87
+ # Managed elsewhere: the Gemfile by `gemstack update`/Bundler.
88
+ MANAGED = %w[Gemfile .gemstack/version].freeze
89
+
90
+ # created/updated/saved (as FILE.new) are done; conflicts were left for you;
91
+ # removed are files you deleted, which stay deleted.
92
+ Result = Struct.new(:created, :updated, :saved, :conflicts, :removed, keyword_init: true)
93
+
94
+ def self.recorded_version(root)
95
+ path = File.join(root, VERSION_FILE)
96
+ File.read(path).strip if File.file?(path)
97
+ end
98
+
99
+ def initialize(root:, from: nil, output: $stdout, dry_run: false, interactive: $stdin.tty?, prompt: nil,
100
+ old_templates: nil)
101
+ @root = root
102
+ @from = from || self.class.recorded_version(root)
103
+ @output = output
104
+ @dry_run = dry_run
105
+ @interactive = interactive && !dry_run
106
+ @prompt = prompt || lambda { |question|
107
+ @output.print(" #{question} ")
108
+ $stdin.gets.to_s.strip.downcase
109
+ }
110
+ @old_templates = old_templates
111
+ @result = Result.new(created: [], updated: [], saved: [], conflicts: [], removed: [])
112
+ end
113
+
114
+ def run
115
+ new_tree = render(Generator::TEMPLATES, version: GemStack::VERSION)
116
+ old_trees = old_trees_for_comparison
117
+ new_tree.each { |path, file| apply(path, file, old_trees.map { |tree| tree[path]&.content }) }
118
+ report
119
+ record_version unless @dry_run || @result.conflicts.any?
120
+ @result
121
+ end
122
+
123
+ private
124
+
125
+ AppFile = Struct.new(:content, :mode)
126
+
127
+ # The renders to compare with: the recorded/given version's, or every
128
+ # earlier release's when the app doesn't say which it came from.
129
+ def old_trees_for_comparison
130
+ trees = if @old_templates
131
+ Array(@old_templates).map { |templates| render(templates, version: GemStack::VERSION) }
132
+ else
133
+ release_trees(@from ? [@from] : TemplateSource.releases_before(GemStack::VERSION))
134
+ end
135
+ describe(trees)
136
+ trees
137
+ end
138
+
139
+ def release_trees(versions)
140
+ @versions = versions
141
+ Dir.mktmpdir("gemstack-templates") do |cache|
142
+ versions.filter_map do |version|
143
+ source = TemplateSource.locate(version, cache: cache)
144
+ render(source, version: version) if source
145
+ rescue Thor::Error
146
+ nil # an old template this version can't render: compare without it
147
+ end
148
+ end
149
+ end
150
+
151
+ def describe(trees)
152
+ if trees.empty?
153
+ @output.puts("Templates: earlier GemStack templates aren't available, so files that differ are " \
154
+ "offered, never updated automatically")
155
+ elsif @from
156
+ @output.puts("Templates: comparing GemStack #{@from}'s with #{GemStack::VERSION}'s")
157
+ else
158
+ range = @versions&.any? ? " (#{@versions.first} to #{@versions.last})" : ""
159
+ @output.puts("Templates: the app doesn't record its templates version, so files are compared with " \
160
+ "every earlier release#{range}")
161
+ end
162
+ end
163
+
164
+ # Renders `gemstack new` for this app (its name, database, frontend, pinned
165
+ # Ruby and Node.js) from a release's templates, as that release wrote them.
166
+ def render(templates, version:)
167
+ Dir.mktmpdir("gemstack-render") do |dir|
168
+ name = File.basename(@root).downcase.gsub(/[^a-z0-9_-]/, "-").sub(/\A[^a-z]+/, "")
169
+ destination = File.join(dir, name.empty? ? "app" : name)
170
+ options = app_options.merge(templates: templates, version: version)
171
+ AppGenerator.new(destination, options, output: StringIO.new).run
172
+ Dir.glob("**/*", File::FNM_DOTMATCH, base: destination).sort.filter_map do |rel|
173
+ full = File.join(destination, rel)
174
+ next unless File.file?(full) && !MANAGED.include?(rel)
175
+
176
+ [rel, AppFile.new(File.binread(full), File.stat(full).mode)]
177
+ end.to_h
178
+ end
179
+ rescue StandardError => e
180
+ raise Thor::Error, "Couldn't render the app templates (#{e.class}: #{e.message.lines.first&.strip})"
181
+ end
182
+
183
+ def app_options
184
+ database_yml = File.join(@root, "config/database.yml")
185
+ adapter = File.file?(database_yml) && File.read(database_yml)[/^\s+adapter:\s*(\w+)/, 1]
186
+ {
187
+ skip_install: true, skip_git: true,
188
+ skip_frontend: !File.file?(File.join(@root, "frontend/package.json")),
189
+ skip_database: !adapter, database: adapter || nil,
190
+ ruby_version: pinned(".ruby-version")&.delete_prefix("ruby-"),
191
+ node_version: pinned(".nvmrc") || pinned(".node-version")
192
+ }.compact
193
+ end
194
+
195
+ def pinned(file)
196
+ path = File.join(@root, file)
197
+ File.read(path).strip.delete_prefix("v").then { |value| value unless value.empty? } if File.file?(path)
198
+ end
199
+
200
+ # olds: this file's content in each compared release's render (nil where absent).
201
+ def apply(path, file, olds)
202
+ mine_path = File.join(@root, path)
203
+ mine = File.binread(mine_path) if File.file?(mine_path)
204
+ return if mine == file.content
205
+ return if olds.any? && olds.all?(file.content) # no release changed it: your edits are yours
206
+ # Missing: new to your app if some compared release didn't have it either;
207
+ # deleted by you if every one did (then it stays deleted).
208
+ return create(path, file) if mine.nil? && (olds.empty? || olds.include?(nil))
209
+ return @result.removed << path if mine.nil?
210
+ return update(path, file, :updated) if olds.include?(mine) # an unedited, older copy
211
+
212
+ conflict(path, file, mine)
213
+ end
214
+
215
+ def create(path, file)
216
+ write(path, file)
217
+ @result.created << path
218
+ status("create", path)
219
+ end
220
+
221
+ def update(path, file, kind)
222
+ write(path, file)
223
+ @result.public_send(kind) << path
224
+ status("update", path)
225
+ end
226
+
227
+ def conflict(path, file, mine)
228
+ return record_conflict(path) unless @interactive
229
+
230
+ loop do
231
+ case @prompt.call("#{path} changed in this version and you edited it — [o]verwrite, [s]kip, " \
232
+ "[d]iff, save as [n]ew file?")
233
+ when "o" then return update(path, file, :updated)
234
+ when "n" then return save_new(path, file)
235
+ when "d" then @output.puts(diff(mine, file.content, path))
236
+ else return record_conflict(path)
237
+ end
238
+ end
239
+ end
240
+
241
+ def save_new(path, file)
242
+ write("#{path}.new", file)
243
+ @result.saved << path
244
+ status("create", "#{path}.new")
245
+ end
246
+
247
+ def record_conflict(path)
248
+ @result.conflicts << path
249
+ status("conflict", path)
250
+ end
251
+
252
+ def write(path, file)
253
+ return if @dry_run
254
+
255
+ target = File.join(@root, path)
256
+ FileUtils.mkdir_p(File.dirname(target))
257
+ File.binwrite(target, file.content)
258
+ File.chmod(file.mode & 0o777, target)
259
+ end
260
+
261
+ def diff(mine, theirs, path)
262
+ Tempfile.create("mine") do |a|
263
+ Tempfile.create("new") do |b|
264
+ a.write(mine)
265
+ b.write(theirs)
266
+ [a, b].each(&:flush)
267
+ labels = ["--label", "#{path} (yours)", "--label", "#{path} (new)"]
268
+ out, = Open3.capture2("diff", "-u", *labels, a.path, b.path)
269
+ out
270
+ end
271
+ end
272
+ rescue SystemCallError
273
+ "(diff isn't available on this system)"
274
+ end
275
+
276
+ def report
277
+ done = { "created" => @result.created, "updated" => @result.updated, "saved as .new" => @result.saved,
278
+ "left for you" => @result.conflicts }.reject { |_, paths| paths.empty? }
279
+ if done.empty?
280
+ @output.puts(" nothing to update")
281
+ else
282
+ @output.puts(" #{done.map { |label, paths| "#{paths.size} #{label}" }.join(", ")}" \
283
+ "#{" — dry run, nothing written" if @dry_run}")
284
+ end
285
+ return if @result.conflicts.empty?
286
+
287
+ @output.puts(" Review those in a terminal: gemstack update --templates#{" --from #{@from}" if @from}")
288
+ end
289
+
290
+ def record_version
291
+ target = File.join(@root, VERSION_FILE)
292
+ FileUtils.mkdir_p(File.dirname(target))
293
+ File.write(target, "#{GemStack::VERSION}\n")
294
+ end
295
+
296
+ def status(label, path) = @output.puts(" #{label.rjust(9)} #{path}")
297
+ end
298
+ end
299
+ end
@@ -17,12 +17,20 @@ module GemStack
17
17
  VERSION_FORMAT = /\A\d+\.\d+\.\d+\z/
18
18
  LATEST_URL = "https://rubygems.org/api/v1/versions/gemstack/latest.json"
19
19
 
20
- def initialize(root:, version: nil, output: $stdout, latest: nil, bundle: nil)
20
+ # The first version whose `gemstack update --templates` exists.
21
+ TEMPLATE_UPDATES_SINCE = Gem::Version.new("0.3.6")
22
+
23
+ def initialize(root:, version: nil, output: $stdout, latest: nil, bundle: nil, templates: nil)
21
24
  @root = root
22
25
  @version = version
23
26
  @output = output
24
27
  @latest = latest || -> { latest_release }
25
28
  @bundle = bundle || ->(gems) { Bundler.with_unbundled_env { system("bundle", "update", *gems, chdir: @root) } }
29
+ # Runs in the app's updated bundle, so the new version renders its own templates.
30
+ # (The lockfile's version can be newer than the app's templates, so it isn't passed on.)
31
+ @templates = templates || lambda {
32
+ Bundler.with_unbundled_env { system("bundle", "exec", "gemstack", "update", "--templates", chdir: @root) }
33
+ }
26
34
  end
27
35
 
28
36
  def run
@@ -71,9 +79,11 @@ module GemStack
71
79
  return failure("bundle update finished but Gemfile.lock has gemstack #{installed.inspect}, not #{target}")
72
80
  end
73
81
 
82
+ @output.puts("\nGemStack #{target} is installed.\n\n")
83
+ update_templates(target)
74
84
  @output.puts(<<~DONE)
75
85
 
76
- GemStack #{target} is installed. Next:
86
+ Next:
77
87
  - read what changed: https://github.com/gemstack-rb/gemstack/blob/main/CHANGELOG.md
78
88
  - gemstack doctor
79
89
  - gemstack test
@@ -89,6 +99,12 @@ module GemStack
89
99
  end
90
100
  end
91
101
 
102
+ def update_templates(target)
103
+ return if Gem::Version.new(target) < TEMPLATE_UPDATES_SINCE
104
+
105
+ @templates.call || @output.puts("Templates weren't updated; run: gemstack update --templates")
106
+ end
107
+
92
108
  def gem_line(name) = /^\s*gem\s+["']#{Regexp.escape(name)}["']/
93
109
 
94
110
  def checkout(gemfile)
data/lib/gemstack/cli.rb CHANGED
@@ -25,6 +25,7 @@ module GemStack
25
25
  require_relative "cli/deploy_generator"
26
26
  require_relative "cli/add_generator"
27
27
  require_relative "cli/update_generator"
28
+ require_relative "cli/template_update"
28
29
  require_relative "cli/commands/db"
29
30
  require_relative "cli/commands/jobs"
30
31
  require_relative "cli/console_methods"
@@ -188,7 +189,8 @@ module GemStack
188
189
  end
189
190
  end
190
191
 
191
- desc "destroy GENERATOR NAME", "Remove tracked generated code (alias: d): model, controller, resource"
192
+ desc "destroy GENERATOR [NAME]",
193
+ "Remove generated code (alias: d): model, controller, resource, job, policy, migration, deploy"
192
194
  DestroyGenerator.configure(self)
193
195
  def destroy(generator = nil, name = nil)
194
196
  DestroyGenerator.invoke(generator, name, cli: self) { |root| refresh_contract(root) }
@@ -230,19 +232,35 @@ module GemStack
230
232
  desc "version", "Print the GemStack version"
231
233
  def version = say("GemStack #{GemStack::VERSION}")
232
234
 
233
- desc "update [VERSION]", "Update this app to the latest GemStack release (or VERSION)"
235
+ desc "update [VERSION]", "Update this app to the latest GemStack release (or VERSION), templates included"
234
236
  long_desc <<~DESC
235
237
  Sets every GemStack gem in the Gemfile (gemstack, gemstack-auth, gemstack-realtime) to
236
238
  "~> VERSION" and runs bundle update for them. Without VERSION, uses the latest release on
237
- rubygems.org.
238
-
239
- gemstack update # the latest release
240
- gemstack update 0.3.5 # a specific version
239
+ rubygems.org. Then updates the files `gemstack new` wrote (config, bin/, frontend setup…):
240
+ files the release didn't change are left alone, new ones are created, ones you never edited
241
+ are updated, and for ones you edited you choose: overwrite, skip, see the diff, or FILE.new.
242
+
243
+ gemstack update # the latest release
244
+ gemstack update 0.3.6 # a specific version
245
+ gemstack update --templates --dry-run # only the template step, preview
246
+ gemstack update --templates --from 0.3.5
241
247
  DESC
248
+ method_option :templates, type: :boolean, default: false, desc: "Only update the app's templates"
249
+ method_option :from, type: :string, desc: "--templates: the GemStack version the app's files came from"
250
+ method_option :dry_run, type: :boolean, default: false, desc: "--templates: show what would change"
242
251
  def update(version = nil)
252
+ return update_templates if options[:templates]
253
+
243
254
  exit(1) unless UpdateGenerator.new(root: Project.root!, version: version).run
244
255
  end
245
256
 
257
+ no_commands do
258
+ def update_templates
259
+ root = Project.ensure_bundle!(self.class.argv) # the app's installed GemStack renders its templates
260
+ TemplateUpdate.new(root: root, from: options[:from], dry_run: options[:dry_run]).run
261
+ end
262
+ end
263
+
246
264
  include Helpers
247
265
  end
248
266
  end
@@ -2,5 +2,5 @@
2
2
 
3
3
  module GemStack
4
4
  # All GemStack gems are released together under a single version.
5
- VERSION = "0.3.5"
5
+ VERSION = "0.3.6"
6
6
  end
@@ -0,0 +1 @@
1
+ <%= version %>
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: gemstack
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.5
4
+ version: 0.3.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Adware Technologies
@@ -44,14 +44,14 @@ dependencies:
44
44
  requirements:
45
45
  - - '='
46
46
  - !ruby/object:Gem::Version
47
- version: 0.3.5
47
+ version: 0.3.6
48
48
  type: :runtime
49
49
  prerelease: false
50
50
  version_requirements: !ruby/object:Gem::Requirement
51
51
  requirements:
52
52
  - - '='
53
53
  - !ruby/object:Gem::Version
54
- version: 0.3.5
54
+ version: 0.3.6
55
55
  - !ruby/object:Gem::Dependency
56
56
  name: json
57
57
  requirement: !ruby/object:Gem::Requirement
@@ -180,6 +180,7 @@ files:
180
180
  - lib/gemstack/cli/controller_generator.rb
181
181
  - lib/gemstack/cli/deploy_generator.rb
182
182
  - lib/gemstack/cli/destroy_generator.rb
183
+ - lib/gemstack/cli/destroy_generator/kinds.rb
183
184
  - lib/gemstack/cli/doctor.rb
184
185
  - lib/gemstack/cli/doctor/secret_checks.rb
185
186
  - lib/gemstack/cli/doctor/upgrade_check.rb
@@ -195,6 +196,7 @@ files:
195
196
  - lib/gemstack/cli/project.rb
196
197
  - lib/gemstack/cli/resource_generator.rb
197
198
  - lib/gemstack/cli/resource_spec.rb
199
+ - lib/gemstack/cli/template_update.rb
198
200
  - lib/gemstack/cli/update_generator.rb
199
201
  - lib/gemstack/contract.rb
200
202
  - lib/gemstack/contract/builder.rb
@@ -295,6 +297,7 @@ files:
295
297
  - templates/app/db/migrations/dot_keep
296
298
  - templates/app/db/seeds.rb
297
299
  - templates/app/dot_env.example.tt
300
+ - templates/app/dot_gemstack/version.tt
298
301
  - templates/app/dot_gitignore
299
302
  - templates/app/dot_node-version.tt
300
303
  - templates/app/dot_nvmrc.tt