gemstack 0.3.3 → 0.3.4

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: 594ad3149a7e3fec2988cc9e4aeb71c5be12d4ac5085d59f106fc08daf996f31
4
- data.tar.gz: cb928670277794edc81566287b5bab7943d74fe5277e3e7151ea20877f290ecd
3
+ metadata.gz: 3aad267b7791a6ed292930136454e90238f3ccacb61ac93fcf9e9cbd4d13a1e2
4
+ data.tar.gz: d2a196ec3a4cf3fc652a7f8eba101ed02b48ddba87d53ed079835680ba65f890
5
5
  SHA512:
6
- metadata.gz: 4e7b4bd185326403d60d0f4cf28bb449b60c75e322a110c942ce973e18438b487ff6992f471a6306a748bee879eefec67e6b4e71c753e0285363cfa2d9ec7491
7
- data.tar.gz: 7378352448b0ab14ce174b67470f351f53ee87b1f649d2918155519b10f4b729513cdbbaf8d4ddce497ac72ef8fa8cf86607ec9e29f245fc78313ee143b56f94
6
+ metadata.gz: cab7c1fa05b4d071574044a98df461ccfdcb3050df12927ebf780c5b45a8b3eaa649b1fbb089e217986efc86b5aa35131dcfea4bbf5ca4491f6c17ec65f991bd
7
+ data.tar.gz: 4dd5583fd43c3b2f750748e8d754c313d10089eeab9ab0ad0245999a4e7790a9db58eb151065414401505ba243ea060074712d17ac36a146cd4976089fffd393
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GemStack
4
+ module AddNextSteps
5
+ STEPS = {
6
+ "realtime" => "Next: declare channels in config/channels.rb, then GemStack.broadcast(...) — see docs/realtime.md",
7
+ "auth" => "Next: gemstack db:migrate · open http://localhost:3000/signup · " \
8
+ "`before :require_login` in controllers — see docs/authentication.md",
9
+ "storage" => "Next: uploadFile(file) from frontend/lib/upload.ts · production: STORAGE_SERVICE=s3, S3_BUCKET " \
10
+ "— see docs/storage.md"
11
+ }.freeze
12
+ end
13
+ end
@@ -27,12 +27,15 @@ module GemStack
27
27
  end
28
28
 
29
29
  def run
30
+ @manifest = GenerationManifest.new(@root)
30
31
  template_files("controller", override_root: @root).each do |rel, source|
31
32
  target = File.join(@root, rel.delete_suffix(".tt").gsub("%file_name%", file_name))
32
- write(target, render(File.read(source), source))
33
+ write_tracked(target, render(File.read(source, encoding: "UTF-8"), source), owner: "controller:#{file_name}")
33
34
  end
34
35
  add_routes
35
36
  self
37
+ ensure
38
+ @manifest&.save
36
39
  end
37
40
 
38
41
  def route_for(action)
@@ -60,12 +63,14 @@ module GemStack
60
63
  path = File.join(@root, "config/routes.rb")
61
64
  return status("skip", path, "not found — add routes manually") unless File.file?(path)
62
65
 
63
- content = File.read(path)
66
+ @manifest.absolute("config/routes.rb")
67
+ content = File.read(path, encoding: "UTF-8")
64
68
  lines = actions.map { |action| route_line(action) }.reject { |line| content.include?(line) }
65
69
  return status("identical", path) if lines.empty?
66
70
  return status("skip", path, "no `GemStack.routes do` block found") unless content.match?(ROUTES_BLOCK)
67
71
 
68
72
  File.write(path, content.sub(ROUTES_BLOCK) { |open| open + lines.map { |l| " #{l}\n" }.join })
73
+ lines.each { |line| @manifest.record_route(" #{line}\n", owner: "controller:#{file_name}") }
69
74
  status("route", path, lines.join("; "))
70
75
  end
71
76
  end
@@ -0,0 +1,276 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ripper"
4
+
5
+ module GemStack
6
+ class CLI < Thor
7
+ # Plans the complete operation before changing anything. Only migrations
8
+ # confirmed pending in the selected environment are eligible for removal.
9
+ class DestroyGenerator < Generator
10
+ KINDS = %w[model controller resource].freeze
11
+ SHARED = (Generator::BASE_CLASSES.values + %w[
12
+ app/controllers/application_controller.rb test/test_helper.rb
13
+ frontend/lib/format.ts frontend/lib/gemstack/client.ts
14
+ frontend/app/layout.tsx frontend/app/providers.tsx frontend/app/globals.css
15
+ ]).freeze
16
+
17
+ def self.configure(cli)
18
+ cli.method_option :dry_run, type: :boolean, default: false, desc: "Preview without changing files or contracts"
19
+ cli.method_option :yes, type: :boolean, default: false, desc: "Confirm removal without an interactive prompt"
20
+ cli.method_option :force, type: :boolean, default: false, desc: "Allow removing modified, tracked files"
21
+ cli.method_option :remove_migrations, type: :boolean, default: false,
22
+ desc: "Remove pending migrations even if committed to Git"
23
+ cli.method_option :skip_contract, type: :boolean, default: false, desc: "Don't regenerate the API contract"
24
+ end
25
+
26
+ def self.invoke(kind, name, cli:)
27
+ options = cli.options
28
+ root = Project.root!
29
+ removal = new(
30
+ kind,
31
+ name,
32
+ root: root,
33
+ dry_run: options[:dry_run],
34
+ force: options[:force],
35
+ remove_migrations: options[:remove_migrations],
36
+ environment: options[:environment]
37
+ )
38
+ removal.run { options[:yes] || confirm?(cli) }
39
+ yield root if removal.changed? && !options[:skip_contract]
40
+ end
41
+
42
+ def self.confirm?(cli)
43
+ raise Thor::Error, "Non-interactive removal requires --yes. Preview with --dry-run first." unless $stdin.tty?
44
+
45
+ %w[y yes].include?(cli.ask("Remove the listed generated files and routes? [y/N]").strip.downcase)
46
+ end
47
+
48
+ def initialize(kind, name, root:, dry_run: false, force: false, remove_migrations: false,
49
+ output: $stdout, environment: nil)
50
+ super(output: output, force: force)
51
+ unless KINDS.include?(kind) && name && !name.empty?
52
+ raise Thor::Error, "Usage: gemstack destroy model|controller|resource NAME"
53
+ end
54
+
55
+ @root = File.expand_path(root)
56
+ @kind = kind
57
+ @dry_run = dry_run
58
+ @remove_migrations = remove_migrations
59
+ @owners = owners_for(name)
60
+ @manifest = GenerationManifest.new(@root)
61
+ @environment = environment || ENV.fetch("GEMSTACK_ENV", "development")
62
+ @migration_status = MigrationStatus.new(@root, environment: @environment)
63
+ @migration_git_status = MigrationGitStatus.new(@root)
64
+ @changed = false
65
+ end
66
+
67
+ def changed? = @changed
68
+
69
+ def run
70
+ plan!
71
+ preview
72
+ raise Thor::Error, "Nothing removed:\n #{@conflicts.join("\n ")}" unless @conflicts.empty?
73
+ return self if @dry_run || (@files.empty? && @routes.empty?)
74
+
75
+ if block_given? && !yield
76
+ @output.puts("Cancelled; no files changed.")
77
+ return self
78
+ end
79
+
80
+ verify_snapshot!
81
+ apply
82
+ self
83
+ end
84
+
85
+ private
86
+
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
+ def plan!
100
+ @conflicts = []
101
+ selected = @manifest.files.select { |_, entry| @owners.include?(entry["owner"]) }
102
+ @migrations, @files = selected.partition { |path, _| path.start_with?("db/migrations/") }.map(&:to_h)
103
+ @routes = @manifest.routes.select { |entry| @owners.include?(entry["owner"]) }
104
+ @snapshots = {}
105
+ plan_migrations
106
+ @files.each { |path, entry| check_file(path, entry) }
107
+ plan_routes
108
+ check_dependencies if @spec && @files.key?("app/models/#{@spec.file_name}.rb")
109
+ end
110
+
111
+ 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
115
+
116
+ absolute = @manifest.absolute(path)
117
+ raise Thor::Error, "Expected a generated file: #{path}" if File.exist?(absolute) && !File.file?(absolute)
118
+
119
+ digest = File.file?(absolute) ? Digest::SHA256.file(absolute).hexdigest : nil
120
+ @snapshots[path] = digest
121
+ return unless digest && digest != entry["sha256"] && !@force
122
+
123
+ @conflicts << "Modified file: #{path} (review it, then use --force to remove it explicitly)"
124
+ end
125
+
126
+ def plan_migrations
127
+ return if @migrations.empty?
128
+
129
+ @applied = @migration_status.applied
130
+ @pending = @migrations.reject { |path, _| @applied.nil? || @applied.include?(File.basename(path)) }
131
+ @migration_reasons = {}
132
+ @auto_removed_migrations = {}
133
+
134
+ @migrations.each do |path, entry|
135
+ reason = migration_retention_reason(path)
136
+ if reason
137
+ @migration_reasons[path] = reason
138
+ else
139
+ @files[path] = entry
140
+ @auto_removed_migrations[path] = entry unless @remove_migrations
141
+ end
142
+ end
143
+
144
+ @migrations = @migrations.select { |path, _| @migration_reasons.key?(path) }
145
+ end
146
+
147
+ def migration_retention_reason(path)
148
+ return "database state unknown" if @applied.nil?
149
+ return "already applied" if @applied.include?(File.basename(path))
150
+ return if @remove_migrations
151
+
152
+ case @migration_git_status.committed(path)
153
+ when true then "pending but committed to Git; use --remove-migrations to remove"
154
+ when nil then "pending but Git state is unknown; use --remove-migrations to remove"
155
+ end
156
+ end
157
+
158
+ def plan_routes
159
+ path = @manifest.absolute("config/routes.rb")
160
+ @route_content = File.file?(path) ? File.read(path, encoding: "UTF-8") : ""
161
+ @route_after = @route_content.dup
162
+ @routes.each do |entry|
163
+ line = entry.fetch("line")
164
+ unless @route_content.lines.count(line) == 1
165
+ @conflicts << "Generated route was edited, removed or duplicated: #{line.strip.inspect}; restore it first"
166
+ next
167
+ end
168
+
169
+ @route_after = @route_after.lines.reject { |candidate| candidate == line }.join
170
+ end
171
+ check_remaining_routes
172
+ end
173
+
174
+ def check_remaining_routes
175
+ controllers = @files.keys.filter_map do |path|
176
+ path.delete_prefix("app/controllers/").delete_suffix("_controller.rb") if
177
+ path.start_with?("app/controllers/") && path.end_with?("_controller.rb")
178
+ end
179
+ tokens = Ripper.lex(@route_after).reject do |_, type, _, _|
180
+ %i[on_sp on_ignored_nl on_nl on_comment].include?(type)
181
+ end
182
+ controllers.each do |controller|
183
+ target = tokens.any? { |_, type, text, _| type == :on_tstring_content && text.start_with?("#{controller}#") }
184
+ resource = resource_route?(tokens, controller)
185
+ @conflicts << "Remaining route to #{controller}: config/routes.rb (remove it manually)" if target || resource
186
+ end
187
+ end
188
+
189
+ def resource_route?(tokens, controller)
190
+ tokens.each_cons(3).any? do |first, second, third|
191
+ first[1] == :on_ident && %w[resources resource].include?(first[2]) && second[1] == :on_symbeg &&
192
+ third[2] == controller.split("/").last
193
+ end
194
+ end
195
+
196
+ # Conservative checks for direct Ruby constants and association symbols.
197
+ # Dynamic references and application-specific frontend imports still need
198
+ # review; destroy never rewrites application business logic.
199
+ def check_dependencies
200
+ Dir.glob(File.join(@root, "app/**/*.rb")).each do |absolute|
201
+ relative = Pathname.new(absolute).relative_path_from(Pathname.new(@root)).to_s
202
+ next if @files.key?(relative)
203
+
204
+ @manifest.absolute(relative)
205
+ tokens = Ripper.lex(File.read(absolute, encoding: "UTF-8"))
206
+ constant = tokens.any? { |_, type, text, _| type == :on_const && text == @spec.class_name }
207
+ association = tokens.each_cons(2).any? do |left, right|
208
+ left[1] == :on_symbeg && %i[on_ident on_const].include?(right[1]) &&
209
+ [@spec.file_name, @spec.plural].include?(right[2])
210
+ end
211
+ @conflicts << "Remaining Ruby reference to #{@spec.class_name}: #{relative}" if constant || association
212
+ end
213
+ end
214
+
215
+ def preview
216
+ @migrations.each_key do |path|
217
+ status("keep", path, @migration_reasons.fetch(path))
218
+ end
219
+ unless @migrations.empty?
220
+ @output.puts("To remove the table, review `gemstack db:status -e #{@environment}` and roll back with " \
221
+ "`gemstack db:rollback -e #{@environment}` " \
222
+ "if it is the latest migration, or add a migration that drops the table. " \
223
+ "These database operations can delete data; destroy never runs them.")
224
+ end
225
+ @files.each_key do |path|
226
+ status(@snapshots[path] ? "remove" : "missing", path)
227
+ end
228
+ @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.")
231
+ end
232
+ @output.puts("Dry run; no files or API contracts changed.") if @dry_run
233
+ end
234
+
235
+ # Recheck after interactive confirmation, before the first mutation.
236
+ def verify_snapshot!
237
+ @manifest.verify!
238
+
239
+ if @pending && !@pending.empty? && @migration_status.applied != @applied
240
+ raise Thor::Error, "Migration state changed or became unavailable; run destroy again"
241
+ end
242
+
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
246
+
247
+ @snapshots.each do |relative, original|
248
+ path = @manifest.absolute(relative)
249
+ current = File.file?(path) ? Digest::SHA256.file(path).hexdigest : nil
250
+ unless current == original
251
+ raise Thor::Error,
252
+ "File changed during confirmation: #{relative}; run destroy again"
253
+ end
254
+ end
255
+
256
+ path = @manifest.absolute("config/routes.rb")
257
+ 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"
261
+ end
262
+
263
+ def apply
264
+ @files.each_key do |relative|
265
+ path = @manifest.absolute(relative)
266
+ File.delete(path) if File.file?(path)
267
+ end
268
+ File.write(@manifest.absolute("config/routes.rb"), @route_after) unless @routes.empty?
269
+ @manifest.forget(@files.keys, @routes)
270
+ @manifest.save
271
+ @changed = true
272
+ @output.puts("Removed tracked code. Database data was not changed.")
273
+ end
274
+ end
275
+ end
276
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+ require "pathname"
6
+ require "tempfile"
7
+
8
+ module GemStack
9
+ class CLI < Thor
10
+ # Ownership, not filename guesses, determines what destroy may remove.
11
+ # Commit this file with the generated code so ownership survives a checkout.
12
+ class GenerationManifest
13
+ PATH = ".gemstack/generators.json"
14
+ VERSION = 1
15
+
16
+ attr_reader :files, :routes
17
+
18
+ def initialize(root)
19
+ @root = File.expand_path(root)
20
+ @path = absolute(PATH)
21
+ @fingerprint = fingerprint
22
+ data = File.file?(@path) ? JSON.parse(File.read(@path, encoding: "UTF-8")) : empty
23
+ validate!(data)
24
+ @files = data.fetch("files")
25
+ @routes = data.fetch("routes")
26
+ @dirty = false
27
+ rescue JSON::ParserError => e
28
+ raise Thor::Error, "Invalid #{PATH}: #{e.message}"
29
+ end
30
+
31
+ # Reject traversal and symlinks, including symlinked parent directories.
32
+ # Never follow a manifest entry outside this application, even with --force.
33
+ def absolute(relative)
34
+ parts = relative.to_s.split("/", -1)
35
+ if parts.empty? || parts.intersect?(["", ".", ".."]) || relative.include?("\0")
36
+ raise Thor::Error, "Unsafe generated path: #{relative.inspect}"
37
+ end
38
+
39
+ parts.reduce(@root) do |parent, part|
40
+ path = File.join(parent, part)
41
+ raise Thor::Error, "Refusing symlink: #{relative}" if File.symlink?(path)
42
+
43
+ path
44
+ end
45
+ end
46
+
47
+ def record_file(path, owner:, existed:)
48
+ relative = Pathname.new(File.expand_path(path)).relative_path_from(Pathname.new(@root)).to_s
49
+ absolute(relative)
50
+ previous = files[relative]
51
+ return if existed && (!previous || previous["owner"] != owner)
52
+
53
+ files[relative] = { "owner" => owner, "sha256" => Digest::SHA256.file(path).hexdigest }
54
+ @dirty = true
55
+ end
56
+
57
+ def record_route(line, owner:)
58
+ entry = { "owner" => owner, "line" => line }
59
+ return if routes.include?(entry)
60
+
61
+ routes << entry
62
+ @dirty = true
63
+ end
64
+
65
+ def forget(paths, removed_routes)
66
+ paths.each { |path| files.delete(path) }
67
+ @routes -= removed_routes
68
+ @dirty = true
69
+ end
70
+
71
+ def save
72
+ return unless @dirty
73
+
74
+ verify!
75
+ FileUtils.mkdir_p(File.dirname(@path))
76
+ data = { "version" => VERSION, "files" => files.sort.to_h, "routes" => routes }
77
+ Tempfile.create(["generators", ".json"], File.dirname(@path)) do |file|
78
+ file.write("#{JSON.pretty_generate(data)}\n")
79
+ file.close
80
+ File.rename(file.path, @path)
81
+ end
82
+ @dirty = false
83
+ @fingerprint = fingerprint
84
+ end
85
+
86
+ def verify!
87
+ absolute(PATH)
88
+ return if fingerprint == @fingerprint
89
+
90
+ raise Thor::Error, "#{PATH} changed while the command was running; retry the command"
91
+ end
92
+
93
+ private
94
+
95
+ def empty = { "version" => VERSION, "files" => {}, "routes" => [] }
96
+
97
+ def fingerprint = File.file?(@path) ? Digest::SHA256.file(@path).hexdigest : nil
98
+
99
+ def validate!(data)
100
+ valid = data.is_a?(Hash) && data["version"] == VERSION && data["files"].is_a?(Hash) &&
101
+ data["routes"].is_a?(Array)
102
+ valid &&= data["files"].all? { |path, entry| file_entry?(path, entry) }
103
+ valid &&= data["routes"].all? { |entry| route_entry?(entry) }
104
+ raise Thor::Error, "Invalid or unsupported #{PATH}; no files changed" unless valid
105
+ end
106
+
107
+ def file_entry?(path, entry)
108
+ path.is_a?(String) && entry.is_a?(Hash) && owner?(entry["owner"]) &&
109
+ entry["sha256"].is_a?(String) && entry["sha256"].match?(/\A[0-9a-f]{64}\z/)
110
+ end
111
+
112
+ def route_entry?(entry)
113
+ entry.is_a?(Hash) && owner?(entry["owner"]) && entry["line"].is_a?(String) &&
114
+ entry["line"].lines.size == 1 && entry["line"].end_with?("\n")
115
+ end
116
+
117
+ 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})
119
+ end
120
+ end
121
+ end
122
+ end
@@ -141,6 +141,15 @@ module GemStack
141
141
  true
142
142
  end
143
143
 
144
+ def write_tracked(path, content, owner:)
145
+ relative = Pathname.new(File.expand_path(path)).relative_path_from(Pathname.new(File.expand_path(@root))).to_s
146
+ @manifest.absolute(relative)
147
+ existed = File.exist?(path)
148
+ return unless write(path, content)
149
+
150
+ @manifest.record_file(path, owner: owner, existed: existed)
151
+ end
152
+
144
153
  def status(label, path, note = nil)
145
154
  shown = path.delete_prefix("#{Dir.pwd}/")
146
155
  @output.puts(" #{label.rjust(9)} #{shown}#{" (#{note})" if note}")
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ripper"
4
+
5
+ module GemStack
6
+ class CLI < Thor
7
+ # Read the generated schema as syntax, never by executing a migration.
8
+ # Customized or subsequently altered schemas require manual comparison.
9
+ class MigrationChanges
10
+ COLUMN_METHODS = %w[primary_key String Integer Bignum Float BigDecimal Date column foreign_key TrueClass].freeze
11
+
12
+ def initialize(generator, root:, output:)
13
+ @generator = generator
14
+ @spec = generator.spec
15
+ @root = root
16
+ @output = output
17
+ end
18
+
19
+ def report(paths)
20
+ schema = schema_from(paths)
21
+ return manual_review unless schema
22
+
23
+ columns, indexes = schema
24
+ @spec.fields.each do |field|
25
+ if !columns.key?(field.column)
26
+ @output.puts("New column #{field.column}: gemstack g migration " \
27
+ "Add#{Inflector.camelize(field.name)}To#{@spec.plural_class} #{field_argument(field)}")
28
+ elsif columns[field.column] != declaration(@generator.migration_column(field)) ||
29
+ indexes.include?(field.column) != (field.index && !field.unique)
30
+ @output.puts("Changed column #{field.column} (#{field_argument(field)}): gemstack g migration " \
31
+ "Change#{Inflector.camelize(field.name)}On#{@spec.plural_class}")
32
+ @output.puts(" Edit its empty change block to alter the existing column, constraints or indexes " \
33
+ "on :#{@spec.table}; do not add the column again.")
34
+ end
35
+ end
36
+ @output.puts("Review schema changes and backfill required columns before gemstack db:migrate.")
37
+ end
38
+
39
+ private
40
+
41
+ def schema_from(paths)
42
+ return unless paths.one?
43
+ return if subsequent_changes?(paths.first)
44
+
45
+ create_table_body(File.read(paths.first, encoding: "UTF-8"))
46
+ end
47
+
48
+ def create_table_body(source)
49
+ statements = Ripper.sexp(source)&.fetch(1)
50
+ return unless statements&.one?
51
+
52
+ migration = body(statements.first)
53
+ return unless canonical(statements.first[1]) == canonical(Ripper.sexp("Sequel.migration")[1][0])
54
+ return unless migration&.one? && call(migration.first[1]) == ["change", []]
55
+
56
+ changes = body(migration.first)
57
+ return unless changes&.one? && call(changes.first[1]) == ["create_table", [symbol(@spec.table)]]
58
+
59
+ parse_columns(body(changes.first))
60
+ end
61
+
62
+ def subsequent_changes?(path)
63
+ Dir.glob(File.join(@root, "db/migrations/*.rb")).any? do |other|
64
+ other > path && File.read(other, encoding: "UTF-8").match?(/\b#{Regexp.escape(@spec.table)}\b/)
65
+ end
66
+ end
67
+
68
+ def body(node)
69
+ return unless node&.first == :method_add_block
70
+
71
+ block = node[2]
72
+ return unless block[0] == :do_block && block[1].nil?
73
+
74
+ statements = block[2]
75
+ statements[1] if statements[0] == :bodystmt && statements.drop(2).all?(&:nil?)
76
+ end
77
+
78
+ def parse_columns(statements)
79
+ return unless statements
80
+
81
+ columns = {}
82
+ indexes = []
83
+ statements.each do |node|
84
+ next if node == [:void_stmt]
85
+
86
+ name, args = call(node)
87
+ column = args&.first
88
+ return nil unless column&.dig(0) == :symbol_literal
89
+
90
+ column_name = column.dig(1, 1, 1)
91
+ if name == "index" && args.one?
92
+ indexes << column_name
93
+ elsif COLUMN_METHODS.include?(name) && !columns.key?(column_name)
94
+ columns[column_name] = [name, args]
95
+ else
96
+ return nil
97
+ end
98
+ end
99
+ [columns, indexes]
100
+ end
101
+
102
+ def declaration(source) = call(Ripper.sexp(source)[1][0])
103
+ def symbol(name) = canonical(Ripper.sexp(":#{name}")[1][0])
104
+
105
+ def call(node)
106
+ return unless node.is_a?(Array)
107
+
108
+ case node[0]
109
+ when :command
110
+ name = node[1][1]
111
+ arguments = node[2]
112
+ when :method_add_arg
113
+ return unless node[1][0] == :fcall
114
+
115
+ name = node[1][1][1]
116
+ arguments = node[2]
117
+ arguments = arguments[1] if arguments[0] == :arg_paren
118
+ else return
119
+ end
120
+ return [name, []] if arguments.nil? || arguments.empty?
121
+ return unless arguments[0] == :args_add_block && arguments[2] == false
122
+
123
+ [name, canonical(arguments[1])]
124
+ end
125
+
126
+ def canonical(node)
127
+ return node unless node.is_a?(Array)
128
+ return node.first(2) if node[0].is_a?(Symbol) && node[0].to_s.start_with?("@")
129
+
130
+ normalized = node.map { |part| canonical(part) }
131
+ normalized[1] = normalized[1].sort_by(&:inspect) if normalized[0] == :bare_assoc_hash
132
+ normalized
133
+ end
134
+
135
+ def field_argument(field)
136
+ [field.name, field.type, ("optional" if field.optional), ("unique" if field.unique),
137
+ ("index" if field.index && !field.reference?)].compact.join(":")
138
+ end
139
+
140
+ def manual_review
141
+ @output.puts("Cannot safely compare the retained migration with the requested fields " \
142
+ "(custom schema or later migrations). Review the current :#{@spec.table} schema.")
143
+ @output.puts("Create a migration with gemstack g migration Update#{@spec.plural_class}Schema " \
144
+ "and edit its empty change block before gemstack db:migrate.")
145
+ end
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module GemStack
6
+ class CLI < Thor
7
+ # Unknown or incomplete history must never authorize automatic deletion.
8
+ class MigrationGitStatus
9
+ def initialize(root)
10
+ @root = root
11
+ end
12
+
13
+ def committed(path)
14
+ return unless git("rev-parse", "--is-inside-work-tree") == "true"
15
+
16
+ history = git("log", "--all", "-1", "--format=%H", "--", path)
17
+ return if history.nil?
18
+ return true unless history.empty?
19
+ return unless git("rev-parse", "--is-shallow-repository") == "false"
20
+
21
+ false
22
+ end
23
+
24
+ private
25
+
26
+ def git(*)
27
+ output, _, status = Open3.capture3("git", "--literal-pathspecs", "-C", @root, *)
28
+ output.strip if status.success?
29
+ rescue SystemCallError
30
+ nil
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module GemStack
6
+ class CLI < Thor
7
+ # Read migration history in the app's bundle without booting its models.
8
+ # Unknown state is never treated as permission to delete a migration.
9
+ class MigrationStatus
10
+ PROBE = <<~RUBY
11
+ require "json"
12
+ require File.expand_path("config/app.rb")
13
+ abort "Database module unavailable" unless defined?(GemStack::DB)
14
+ settings = GemStack::DB.settings.except(:source).merge(GemStack::DB.config.options)
15
+ if settings[:adapter] == "sqlite"
16
+ abort "In-memory database has no persistent history" if settings[:database] == ":memory:"
17
+ unless File.exist?(settings[:database])
18
+ puts JSON.generate([])
19
+ exit
20
+ end
21
+ settings[:readonly] = true
22
+ end
23
+ Sequel.connect(**settings) do |db|
24
+ puts JSON.generate(GemStack::DB::Migrator.new(db).applied)
25
+ end
26
+ RUBY
27
+
28
+ def initialize(root, environment: nil)
29
+ @root = root
30
+ @environment = environment || ENV.fetch("GEMSTACK_ENV", "development")
31
+ end
32
+
33
+ def applied
34
+ return unless File.file?(File.join(@root, "config/app.rb"))
35
+
36
+ bundle_path = ENV["BUNDLE_PATH"] || Bundler.settings[:path]&.to_s if defined?(Bundler)
37
+
38
+ run = lambda do
39
+ Open3.capture3(
40
+ {
41
+ "BUNDLE_GEMFILE" => File.join(@root, "Gemfile"),
42
+ "BUNDLE_PATH" => bundle_path,
43
+ "BUNDLE_FROZEN" => "true",
44
+ "GEMSTACK_ENV" => @environment
45
+ }.compact,
46
+ "bundle",
47
+ "exec",
48
+ "ruby",
49
+ "-e",
50
+ PROBE,
51
+ chdir: @root
52
+ )
53
+ end
54
+
55
+ output, _, status = defined?(Bundler) ? Bundler.with_unbundled_env(&run) : run.call
56
+ return unless status.success?
57
+
58
+ files = JSON.parse(output.lines.last.to_s)
59
+ files if files.is_a?(Array) && files.all?(String)
60
+ rescue JSON::ParserError, SystemCallError
61
+ nil
62
+ end
63
+ end
64
+ end
65
+ end
@@ -34,10 +34,13 @@ module GemStack
34
34
  end
35
35
 
36
36
  def run
37
+ @manifest = GenerationManifest.new(@root)
37
38
  ensure_base_classes(@root, *(%i[model serializer] & @parts))
38
39
  @parts.each { |part| render_part(part) }
39
40
  add_routes if @parts.include?(:controller)
40
41
  self
42
+ ensure
43
+ @manifest&.save
41
44
  end
42
45
 
43
46
  # --- template helpers -------------------------------------------------
@@ -127,14 +130,34 @@ module GemStack
127
130
  private
128
131
 
129
132
  def render_part(part)
133
+ return if part == :migration && existing_migration?
134
+
130
135
  template_files("resource/#{part}", override_root: @root).sort.each do |rel, source|
131
136
  next if skip?(part, rel)
132
137
 
133
138
  target = File.join(@root, substitute(rel.delete_suffix(".tt")))
134
- write(target, render(File.read(source), source))
139
+ content = render(File.read(source, encoding: "UTF-8"), source)
140
+ if rel == "frontend/lib/format.ts"
141
+ write(target, content) # shared by every resource
142
+ else
143
+ write_tracked(target, content, owner: owner_for(part))
144
+ end
135
145
  end
136
146
  end
137
147
 
148
+ def existing_migration?
149
+ paths = Dir.glob(File.join(@root, "db/migrations/*_create_#{spec.table}.rb"))
150
+ paths.each { |path| status("keep", path, "existing create migration; use a new migration for schema changes") }
151
+ MigrationChanges.new(self, root: @root, output: @output).report(paths) unless paths.empty?
152
+ !paths.empty?
153
+ end
154
+
155
+ def owner_for(part)
156
+ return "controller:#{spec.plural}" if part == :controller
157
+
158
+ "#{part == :frontend ? "resource" : "model"}:#{spec.file_name}"
159
+ end
160
+
138
161
  def skip?(part, rel)
139
162
  return true if !tests && rel.start_with?("test/")
140
163
  return false unless part == :frontend
@@ -156,13 +179,15 @@ module GemStack
156
179
 
157
180
  only = spec.crud? ? "" : ", only: %i[#{spec.actions.join(" ")}]"
158
181
  line = "resources :#{spec.plural}#{only}"
159
- content = File.read(path)
182
+ @manifest.absolute("config/routes.rb")
183
+ content = File.read(path, encoding: "UTF-8")
160
184
  return status("identical", path) if content.match?(/^\s*resources :#{spec.plural}\b/)
161
185
  unless content.match?(ControllerGenerator::ROUTES_BLOCK)
162
186
  return status("skip", path, "no `GemStack.routes do` block — add `#{line}`")
163
187
  end
164
188
 
165
189
  File.write(path, content.sub(ControllerGenerator::ROUTES_BLOCK) { |open| "#{open} #{line}\n" })
190
+ @manifest.record_route(" #{line}\n", owner: "controller:#{spec.plural}")
166
191
  status("route", path, line)
167
192
  end
168
193
  end
data/lib/gemstack/cli.rb CHANGED
@@ -6,11 +6,17 @@ require "gemstack/core"
6
6
  module GemStack
7
7
  # The `gemstack` command.
8
8
  class CLI < Thor
9
+ require_relative "cli/add_next_steps"
9
10
  require_relative "cli/project"
10
11
  require_relative "cli/generator"
12
+ require_relative "cli/generation_manifest"
13
+ require_relative "cli/migration_status"
14
+ require_relative "cli/migration_git_status"
15
+ require_relative "cli/destroy_generator"
11
16
  require_relative "cli/app_generator"
12
17
  require_relative "cli/controller_generator"
13
18
  require_relative "cli/resource_spec"
19
+ require_relative "cli/migration_changes"
14
20
  require_relative "cli/resource_generator"
15
21
  require_relative "cli/migration_generator"
16
22
  require_relative "cli/job_generator"
@@ -40,10 +46,7 @@ module GemStack
40
46
  GENERATORS = "resource, model, migration, controller, job, policy, deploy"
41
47
 
42
48
  map %w[-v --version] => :version
43
- map "s" => :server
44
- map "c" => :console
45
- map "t" => :test
46
- map "g" => :generate
49
+ map "s" => :server, "c" => :console, "t" => :test, "g" => :generate, "d" => :destroy
47
50
 
48
51
  desc "new NAME", "Create a new GemStack application (Ruby API + Next.js frontend)"
49
52
  long_desc <<~DESC
@@ -183,6 +186,12 @@ module GemStack
183
186
  end
184
187
  end
185
188
 
189
+ desc "destroy GENERATOR NAME", "Remove tracked generated code (alias: d): model, controller, resource"
190
+ DestroyGenerator.configure(self)
191
+ def destroy(generator = nil, name = nil)
192
+ DestroyGenerator.invoke(generator, name, cli: self) { |root| refresh_contract(root) }
193
+ end
194
+
186
195
  desc "add FEATURE", "Add an optional module to this app: realtime, auth, storage"
187
196
  long_desc <<~DESC
188
197
  gemstack add realtime
@@ -204,17 +213,9 @@ module GemStack
204
213
  AddGenerator.new(feature, root: root, install: !options[:skip_install]).run
205
214
  refresh_contract(root) if %w[auth
206
215
  storage].include?(feature) && !options[:skip_install] && !options[:skip_contract]
207
- say("\n#{ADD_NEXT_STEPS.fetch(feature)}")
216
+ say("\n#{AddNextSteps::STEPS.fetch(feature)}")
208
217
  end
209
218
 
210
- ADD_NEXT_STEPS = {
211
- "realtime" => "Next: declare channels in config/channels.rb, then GemStack.broadcast(...) — see docs/realtime.md",
212
- "auth" => "Next: gemstack db:migrate · open http://localhost:3000/signup · " \
213
- "`before :require_login` in controllers — see docs/authentication.md",
214
- "storage" => "Next: uploadFile(file) from frontend/lib/upload.ts · production: STORAGE_SERVICE=s3, S3_BUCKET " \
215
- "— see docs/storage.md"
216
- }.freeze
217
-
218
219
  desc "doctor", "Check that this app can run (Ruby, Node, database, migrations, contract…) and how to fix it"
219
220
  long_desc <<~DESC
220
221
  gemstack doctor checks for development
@@ -228,9 +229,7 @@ module GemStack
228
229
  end
229
230
 
230
231
  desc "version", "Print the GemStack version"
231
- def version
232
- say("GemStack #{GemStack::VERSION}")
233
- end
232
+ def version = say("GemStack #{GemStack::VERSION}")
234
233
 
235
234
  no_commands do
236
235
  # -e ENV, else GEMSTACK_ENV, else development.
@@ -36,6 +36,10 @@ module GemStack
36
36
  # clearly with "relation does not exist".
37
37
  self.require_valid_table = false
38
38
 
39
+ dataset_module do
40
+ def delete_all = delete
41
+ end
42
+
39
43
  class << self
40
44
  def gemstack_fields
41
45
  @gemstack_fields ||= superclass.respond_to?(:gemstack_fields) ? superclass.gemstack_fields.dup : {}
@@ -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.3"
5
+ VERSION = "0.3.4"
6
6
  end
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.3
4
+ version: 0.3.4
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.3
47
+ version: 0.3.4
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.3
54
+ version: 0.3.4
55
55
  - !ruby/object:Gem::Dependency
56
56
  name: json
57
57
  requirement: !ruby/object:Gem::Requirement
@@ -172,17 +172,23 @@ files:
172
172
  - lib/gemstack/cache/store.rb
173
173
  - lib/gemstack/cli.rb
174
174
  - lib/gemstack/cli/add_generator.rb
175
+ - lib/gemstack/cli/add_next_steps.rb
175
176
  - lib/gemstack/cli/app_generator.rb
176
177
  - lib/gemstack/cli/commands/db.rb
177
178
  - lib/gemstack/cli/commands/jobs.rb
178
179
  - lib/gemstack/cli/console_methods.rb
179
180
  - lib/gemstack/cli/controller_generator.rb
180
181
  - lib/gemstack/cli/deploy_generator.rb
182
+ - lib/gemstack/cli/destroy_generator.rb
181
183
  - lib/gemstack/cli/doctor.rb
182
184
  - lib/gemstack/cli/doctor/upgrade_check.rb
185
+ - lib/gemstack/cli/generation_manifest.rb
183
186
  - lib/gemstack/cli/generator.rb
184
187
  - lib/gemstack/cli/job_generator.rb
188
+ - lib/gemstack/cli/migration_changes.rb
185
189
  - lib/gemstack/cli/migration_generator.rb
190
+ - lib/gemstack/cli/migration_git_status.rb
191
+ - lib/gemstack/cli/migration_status.rb
186
192
  - lib/gemstack/cli/policy_generator.rb
187
193
  - lib/gemstack/cli/project.rb
188
194
  - lib/gemstack/cli/resource_generator.rb