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 +4 -4
- data/lib/gemstack/cli/add_next_steps.rb +13 -0
- data/lib/gemstack/cli/controller_generator.rb +7 -2
- data/lib/gemstack/cli/destroy_generator.rb +276 -0
- data/lib/gemstack/cli/generation_manifest.rb +122 -0
- data/lib/gemstack/cli/generator.rb +9 -0
- data/lib/gemstack/cli/migration_changes.rb +148 -0
- data/lib/gemstack/cli/migration_git_status.rb +34 -0
- data/lib/gemstack/cli/migration_status.rb +65 -0
- data/lib/gemstack/cli/resource_generator.rb +27 -2
- data/lib/gemstack/cli.rb +15 -16
- data/lib/gemstack/db/model.rb +4 -0
- data/lib/gemstack/version.rb +1 -1
- metadata +9 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3aad267b7791a6ed292930136454e90238f3ccacb61ac93fcf9e9cbd4d13a1e2
|
|
4
|
+
data.tar.gz: d2a196ec3a4cf3fc652a7f8eba101ed02b48ddba87d53ed079835680ba65f890
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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#{
|
|
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.
|
data/lib/gemstack/db/model.rb
CHANGED
|
@@ -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 : {}
|
data/lib/gemstack/version.rb
CHANGED
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.
|
|
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.
|
|
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.
|
|
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
|