plutonium 0.39.1 → 0.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium-connect-resource/SKILL.md +19 -1
  3. data/.claude/skills/plutonium-controller/SKILL.md +5 -9
  4. data/.claude/skills/plutonium-definition-query/SKILL.md +10 -2
  5. data/.claude/skills/plutonium-installation/SKILL.md +9 -7
  6. data/.claude/skills/plutonium-invites/SKILL.md +363 -0
  7. data/.claude/skills/plutonium-package/SKILL.md +2 -1
  8. data/.claude/skills/plutonium-portal/SKILL.md +30 -16
  9. data/.claude/skills/plutonium-rodauth/SKILL.md +111 -18
  10. data/CHANGELOG.md +48 -0
  11. data/app/assets/plutonium.css +1 -1
  12. data/config/initializers/sqlite_alias.rb +8 -8
  13. data/docs/.vitepress/config.ts +1 -0
  14. data/docs/getting-started/tutorial/07-author-portal.md +1 -0
  15. data/docs/getting-started/tutorial/08-customizing-ui.md +5 -2
  16. data/docs/guides/adding-resources.md +10 -0
  17. data/docs/guides/authentication.md +15 -8
  18. data/docs/guides/creating-packages.md +13 -8
  19. data/docs/guides/index.md +2 -0
  20. data/docs/guides/search-filtering.md +8 -3
  21. data/docs/guides/user-invites.md +497 -0
  22. data/docs/public/templates/base.rb +5 -1
  23. data/docs/public/templates/lite.rb +42 -0
  24. data/docs/public/templates/pluton8.rb +7 -2
  25. data/docs/reference/controller/index.md +12 -7
  26. data/docs/reference/definition/query.md +12 -3
  27. data/docs/reference/generators/index.md +70 -10
  28. data/docs/reference/portal/index.md +22 -11
  29. data/gemfiles/rails_7.gemfile.lock +1 -1
  30. data/gemfiles/rails_8.0.gemfile.lock +1 -1
  31. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  32. data/lib/generators/pu/gem/active_shrine/active_shrine_generator.rb +31 -0
  33. data/lib/generators/pu/gem/active_shrine/templates/config/initializers/shrine.rb.tt +58 -0
  34. data/lib/generators/pu/gem/annotated/templates/lib/tasks/auto_annotate_models.rake +6 -1
  35. data/lib/generators/pu/gem/dotenv/templates/config/initializers/001_ensure_required_env.rb +3 -0
  36. data/lib/generators/pu/invites/USAGE +27 -0
  37. data/lib/generators/pu/invites/install_generator.rb +364 -0
  38. data/lib/generators/pu/invites/invitable/USAGE +31 -0
  39. data/lib/generators/pu/invites/invitable_generator.rb +143 -0
  40. data/lib/generators/pu/invites/templates/INSTRUCTIONS +22 -0
  41. data/lib/generators/pu/invites/templates/app/interactions/invite_user_interaction.rb.tt +24 -0
  42. data/lib/generators/pu/invites/templates/app/interactions/user_invite_user_interaction.rb.tt +26 -0
  43. data/lib/generators/pu/invites/templates/db/migrate/create_user_invites.rb.tt +47 -0
  44. data/lib/generators/pu/invites/templates/invitable/invitation.html.erb.tt +45 -0
  45. data/lib/generators/pu/invites/templates/invitable/invitation.text.erb.tt +15 -0
  46. data/lib/generators/pu/invites/templates/invitable/invite_user_interaction.rb.tt +33 -0
  47. data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/user_invitations_controller.rb.tt +77 -0
  48. data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/welcome_controller.rb.tt +68 -0
  49. data/lib/generators/pu/invites/templates/packages/invites/app/definitions/invites/user_invite_definition.rb.tt +23 -0
  50. data/lib/generators/pu/invites/templates/packages/invites/app/interactions/invites/cancel_invite_interaction.rb.tt +7 -0
  51. data/lib/generators/pu/invites/templates/packages/invites/app/interactions/invites/resend_invite_interaction.rb.tt +7 -0
  52. data/lib/generators/pu/invites/templates/packages/invites/app/mailers/invites/user_invite_mailer.rb.tt +34 -0
  53. data/lib/generators/pu/invites/templates/packages/invites/app/models/invites/user_invite.rb.tt +41 -0
  54. data/lib/generators/pu/invites/templates/packages/invites/app/policies/invites/user_invite_policy.rb.tt +33 -0
  55. data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invitations/error.html.erb.tt +24 -0
  56. data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invitations/landing.html.erb.tt +40 -0
  57. data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invitations/show.html.erb.tt +39 -0
  58. data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invitations/signup.html.erb.tt +49 -0
  59. data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invite_mailer/invitation.html.erb.tt +45 -0
  60. data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invite_mailer/invitation.text.erb.tt +15 -0
  61. data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/welcome/pending_invitation.html.erb.tt +23 -0
  62. data/lib/generators/pu/invites/templates/packages/invites/app/views/layouts/invites/invitation.html.erb.tt +33 -0
  63. data/lib/generators/pu/lib/plutonium_generators/concerns/actions.rb +23 -2
  64. data/lib/generators/pu/lib/plutonium_generators/concerns/configures_sqlite.rb +130 -0
  65. data/lib/generators/pu/lib/plutonium_generators/concerns/mounts_engines.rb +72 -0
  66. data/lib/generators/pu/lib/plutonium_generators/concerns/package_selector.rb +4 -2
  67. data/lib/generators/pu/lib/plutonium_generators/model_generator_base.rb +7 -1
  68. data/lib/generators/pu/lite/litestream/litestream_generator.rb +105 -0
  69. data/lib/generators/pu/lite/rails_pulse/rails_pulse_generator.rb +88 -0
  70. data/lib/generators/pu/lite/rails_pulse/templates/config/initializers/rails_pulse.rb.tt +14 -0
  71. data/lib/generators/pu/lite/setup/setup_generator.rb +54 -0
  72. data/lib/generators/pu/lite/solid_cable/solid_cable_generator.rb +65 -0
  73. data/lib/generators/pu/lite/solid_cache/solid_cache_generator.rb +66 -0
  74. data/lib/generators/pu/lite/solid_errors/solid_errors_generator.rb +61 -0
  75. data/lib/generators/pu/lite/solid_queue/solid_queue_generator.rb +107 -0
  76. data/lib/generators/pu/pkg/portal/USAGE +8 -2
  77. data/lib/generators/pu/pkg/portal/portal_generator.rb +11 -1
  78. data/lib/generators/pu/pkg/portal/templates/app/controllers/concerns/controller.rb.tt +2 -0
  79. data/lib/generators/pu/pkg/portal/templates/app/controllers/plutonium_controller.rb.tt +1 -0
  80. data/lib/generators/pu/pkg/portal/templates/app/controllers/resource_controller.rb.tt +7 -0
  81. data/lib/generators/pu/pkg/portal/templates/lib/engine.rb.tt +3 -0
  82. data/lib/generators/pu/res/conn/USAGE +5 -0
  83. data/lib/generators/pu/res/conn/conn_generator.rb +30 -4
  84. data/lib/generators/pu/res/scaffold/scaffold_generator.rb +6 -3
  85. data/lib/generators/pu/res/scaffold/templates/policy.rb.tt +6 -6
  86. data/lib/generators/pu/rodauth/account_generator.rb +36 -11
  87. data/lib/generators/pu/rodauth/admin_generator.rb +55 -0
  88. data/lib/generators/pu/rodauth/install_generator.rb +1 -8
  89. data/lib/generators/pu/rodauth/templates/app/interactions/invite_admin_interaction.rb.tt +25 -0
  90. data/lib/generators/pu/rodauth/templates/app/models/account.rb.tt +6 -2
  91. data/lib/generators/pu/saas/USAGE +22 -0
  92. data/lib/generators/pu/saas/entity/USAGE +19 -0
  93. data/lib/generators/pu/saas/entity_generator.rb +55 -0
  94. data/lib/generators/pu/saas/membership/USAGE +25 -0
  95. data/lib/generators/pu/saas/membership_generator.rb +165 -0
  96. data/lib/generators/pu/saas/setup/USAGE +27 -0
  97. data/lib/generators/pu/saas/setup_generator.rb +98 -0
  98. data/lib/generators/pu/saas/user/USAGE +21 -0
  99. data/lib/generators/pu/saas/user_generator.rb +66 -0
  100. data/lib/plutonium/core/controller.rb +9 -5
  101. data/lib/plutonium/definition/base.rb +3 -1
  102. data/lib/plutonium/definition/scoping.rb +20 -0
  103. data/lib/plutonium/invites/concerns/cancel_invite.rb +44 -0
  104. data/lib/plutonium/invites/concerns/invitable.rb +98 -0
  105. data/lib/plutonium/invites/concerns/invite_token.rb +186 -0
  106. data/lib/plutonium/invites/concerns/invite_user.rb +147 -0
  107. data/lib/plutonium/invites/concerns/resend_invite.rb +66 -0
  108. data/lib/plutonium/invites/controller.rb +226 -0
  109. data/lib/plutonium/invites/pending_invite_check.rb +76 -0
  110. data/lib/plutonium/invites.rb +6 -0
  111. data/lib/plutonium/resource/controllers/queryable.rb +4 -0
  112. data/lib/plutonium/resource/query_object.rb +3 -5
  113. data/lib/plutonium/version.rb +1 -1
  114. data/package.json +1 -1
  115. metadata +64 -7
  116. data/lib/generators/pu/res/entity/entity_generator.rb +0 -158
  117. data/lib/generators/pu/rodauth/customer_generator.rb +0 -101
  118. data/public/plutonium-assets/plutonium-logo-original.png +0 -0
  119. data/public/plutonium-assets/plutonium-logo-white.png +0 -0
  120. data/public/plutonium-assets/plutonium-logo.png +0 -0
@@ -430,9 +430,14 @@ module PlutoniumGenerators
430
430
  end
431
431
 
432
432
  def bundle(*gems, **options)
433
- gems = Array(gems).join " "
433
+ gems = Array(gems).flatten
434
+ # Skip gems already in bundle
435
+ gems = gems.reject { |g| gem_in_bundle?(g) }
436
+ return if gems.empty?
437
+
438
+ gems_str = gems.join(" ")
434
439
  options = hash_to_cli_options options
435
- cmd_args = "add #{gems} #{options}"
440
+ cmd_args = "add #{gems_str} #{options}"
436
441
 
437
442
  log :bundle, cmd_args
438
443
  Bundler.with_unbundled_env do
@@ -440,6 +445,14 @@ module PlutoniumGenerators
440
445
  end
441
446
  end
442
447
 
448
+ def gem_in_bundle?(name)
449
+ in_root do
450
+ return true if File.exist?("Gemfile") && File.read("Gemfile").match?(/gem ['"]#{name}['"]/)
451
+ return true if File.exist?("Gemfile.lock") && File.read("Gemfile.lock").include?(" #{name} ")
452
+ end
453
+ false
454
+ end
455
+
443
456
  def unbundle(*gems)
444
457
  gems = Array(gems).join " "
445
458
  cmd_args = "remove #{gems}"
@@ -459,6 +472,14 @@ module PlutoniumGenerators
459
472
  end
460
473
  end
461
474
 
475
+ def file_includes?(path, check)
476
+ destination = File.expand_path(path, destination_root)
477
+ return false unless File.exist?(destination)
478
+
479
+ content = File.read(destination)
480
+ check.is_a?(Regexp) ? content.match?(check) : content.include?(check)
481
+ end
482
+
462
483
  private
463
484
 
464
485
  #
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "psych"
4
+
5
+ module PlutoniumGenerators
6
+ module Concerns
7
+ module ConfiguresSqlite
8
+ class DatabaseYAML
9
+ COMMENTED_PROD_DATABASE = "# database: path/to/persistent/storage/production.sqlite3"
10
+ UNCOMMENTED_PROD_DATABASE = "database: path/to/persistent/storage/production.sqlite3"
11
+
12
+ attr_reader :content
13
+
14
+ def initialize(path:)
15
+ @content = File.read(path)
16
+ # if the production environment has the default commented database value,
17
+ # uncomment it so that the value can be parsed
18
+ @content.gsub!(COMMENTED_PROD_DATABASE, UNCOMMENTED_PROD_DATABASE)
19
+ @stream = Psych.parse_stream(@content)
20
+ @emission_stream = Psych::Nodes::Stream.new
21
+ @emission_document = Psych::Nodes::Document.new
22
+ @emission_mapping = Psych::Nodes::Mapping.new
23
+ end
24
+
25
+ def add_database(name)
26
+ root = @stream.children.first.root
27
+ root.children.each_slice(2).map do |scalar, mapping|
28
+ next unless scalar.is_a?(Psych::Nodes::Scalar)
29
+ next unless mapping.is_a?(Psych::Nodes::Mapping)
30
+ next unless mapping.anchor.nil? || mapping.anchor.empty?
31
+ next if mapping.children.each_slice(2).any? do |key, value|
32
+ key.is_a?(Psych::Nodes::Scalar) && key.value == name && value.is_a?(Psych::Nodes::Alias) && value.anchor == name
33
+ end
34
+
35
+ new_mapping = Psych::Nodes::Mapping.new
36
+ if mapping.children.first.value == "<<" # 2-tiered environment
37
+ new_mapping.children.concat [
38
+ Psych::Nodes::Scalar.new("primary"),
39
+ mapping,
40
+ Psych::Nodes::Scalar.new(name),
41
+ Psych::Nodes::Alias.new(name)
42
+ ]
43
+ else # 3-tiered environment
44
+ new_mapping.children.concat mapping.children
45
+ new_mapping.children.concat [
46
+ Psych::Nodes::Scalar.new(name),
47
+ Psych::Nodes::Alias.new(name)
48
+ ]
49
+ end
50
+
51
+ old_environment_entry = emit_pair(scalar, mapping)
52
+ new_environment_entry = emit_pair(scalar, new_mapping)
53
+
54
+ [scalar.value, old_environment_entry, new_environment_entry]
55
+ end.compact!
56
+ end
57
+
58
+ def new_database(name, migrations_paths: nil)
59
+ migrations_paths ||= "db/#{name}_migrate"
60
+ db = Psych::Nodes::Mapping.new(name)
61
+ db.children.concat [
62
+ Psych::Nodes::Scalar.new("<<"),
63
+ Psych::Nodes::Alias.new("default"),
64
+ Psych::Nodes::Scalar.new("migrations_paths"),
65
+ Psych::Nodes::Scalar.new(migrations_paths),
66
+ Psych::Nodes::Scalar.new("database"),
67
+ Psych::Nodes::Scalar.new("storage/<%= Rails.env %>-#{name}.sqlite3")
68
+ ]
69
+ "\n" + emit_pair(Psych::Nodes::Scalar.new(name), db)
70
+ end
71
+
72
+ def database_def_regex(name)
73
+ /#{name}: &#{name}\n(?:[ \t]+.*\n)+/
74
+ end
75
+
76
+ private
77
+
78
+ def emit_pair(scalar, mapping)
79
+ @emission_mapping.children.clear.concat [scalar, mapping]
80
+ @emission_document.children.clear.concat [@emission_mapping]
81
+ @emission_stream.children.clear.concat [@emission_document]
82
+ output = @emission_stream.yaml.gsub!(/^---/, "").strip!
83
+ output.gsub!(UNCOMMENTED_PROD_DATABASE, COMMENTED_PROD_DATABASE)
84
+ output
85
+ end
86
+ end
87
+
88
+ private
89
+
90
+ def database_yaml
91
+ @database_yaml ||= DatabaseYAML.new(path: File.expand_path("config/database.yml", destination_root))
92
+ end
93
+
94
+ def add_sqlite_database(name, migrations_paths: nil)
95
+ # Define the new database configuration
96
+ insert_into_file "config/database.yml",
97
+ database_yaml.new_database(name, migrations_paths: migrations_paths) + "\n",
98
+ after: database_yaml.database_def_regex("default"),
99
+ verbose: false,
100
+ force: false
101
+ say_status :def_db, "#{name} (database.yml)"
102
+
103
+ # Add the new database to all environments
104
+ database_yaml.add_database(name)&.each do |environment, old_entry, new_entry|
105
+ gsub_file "config/database.yml", old_entry, new_entry, verbose: false
106
+ say_status :add_db, "#{name} -> #{environment} (database.yml)"
107
+ end
108
+ end
109
+
110
+ def prepare_database(name)
111
+ Bundler.with_unbundled_env do
112
+ run "bin/rails db:prepare", env: {"DATABASE" => name}
113
+ end
114
+ end
115
+
116
+ def add_application_config(config_line, after_pattern: nil)
117
+ return if file_includes?("config/application.rb", config_line)
118
+
119
+ pattern = after_pattern || /^([ \t]*).*?(?=\n\s*end\nend)$/
120
+ insert_into_file "config/application.rb", after: pattern do
121
+ if after_pattern
122
+ "\n\\1#{config_line}"
123
+ else
124
+ "\n\n\\1#{config_line}"
125
+ end
126
+ end
127
+ end
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PlutoniumGenerators
4
+ module Concerns
5
+ module MountsEngines
6
+ private
7
+
8
+ def mount_engine(engine_mount, route_file: "config/routes.rb", authenticated: false)
9
+ return if file_includes?(route_file, engine_mount)
10
+
11
+ if authenticated
12
+ mount_authenticated_engine(engine_mount, route_file)
13
+ else
14
+ insert_into_file route_file, before: /^end\s*\z/ do
15
+ " #{engine_mount}\n"
16
+ end
17
+ end
18
+ end
19
+
20
+ def mount_authenticated_engine(engine_mount, route_file)
21
+ ensure_management_constraint
22
+
23
+ content = File.read(File.expand_path(route_file, destination_root))
24
+ # Match constraint block opening - use [ \t]* instead of \s* to avoid matching newlines
25
+ constraint_match = content.match(/^(\s*)constraints ManagementConstraint do[ \t]*\n/)
26
+
27
+ if constraint_match
28
+ indent = constraint_match[1]
29
+ # Insert after the opening line (not including any subsequent blank lines)
30
+ insert_into_file route_file, after: /^#{Regexp.escape(indent)}constraints ManagementConstraint do[ \t]*\n/ do
31
+ "#{indent} #{engine_mount}\n"
32
+ end
33
+ else
34
+ # Create new constraint block before the final end
35
+ insert_into_file route_file, before: /^end\s*\z/ do
36
+ " constraints ManagementConstraint do\n #{engine_mount}\n end\n"
37
+ end
38
+ end
39
+ end
40
+
41
+ def ensure_management_constraint
42
+ constraint_file = "app/constraints/management_constraint.rb"
43
+ return if File.exist?(File.expand_path(constraint_file, destination_root))
44
+
45
+ create_file constraint_file, <<~RUBY
46
+ # frozen_string_literal: true
47
+
48
+ class ManagementConstraint
49
+ def self.matches?(request)
50
+ false # TODO: Implement authentication
51
+ # Examples:
52
+ # Rodauth: request.env["rodauth.admin"]&.logged_in?
53
+ # Devise: request.env["warden"].user(:admin).present?
54
+ # Custom: request.session[:admin_id].present?
55
+ # HTTP Basic: authenticate_with_http_basic(request)
56
+ end
57
+
58
+ # HTTP Basic Auth example:
59
+ # def self.authenticate_with_http_basic(request)
60
+ # auth = Rack::Auth::Basic::Request.new(request.env)
61
+ # return false unless auth.provided? && auth.basic?
62
+ #
63
+ # username, password = auth.credentials
64
+ # ActiveSupport::SecurityUtils.secure_compare(username, ENV["ADMIN_USERNAME"]) &&
65
+ # ActiveSupport::SecurityUtils.secure_compare(password, ENV["ADMIN_PASSWORD"])
66
+ # end
67
+ end
68
+ RUBY
69
+ end
70
+ end
71
+ end
72
+ end
@@ -37,8 +37,10 @@ module PlutoniumGenerators
37
37
 
38
38
  def select_package(selected_package = nil, msg: "Select package", pkgs: nil)
39
39
  pkgs ||= available_packages
40
- if pkgs.include?(selected_package)
41
- selected_package
40
+ # Normalize input to underscore format (e.g., "CustomerPortal" -> "customer_portal")
41
+ normalized = selected_package&.underscore
42
+ if pkgs.include?(normalized)
43
+ normalized
42
44
  else
43
45
  prompt.select(msg, pkgs)
44
46
  end
@@ -39,7 +39,13 @@ module PlutoniumGenerators
39
39
  def name
40
40
  @pu_name ||= begin
41
41
  @original_name = @name
42
- @name = [main_app? ? nil : selected_destination_feature.underscore, super.singularize.underscore].compact.join "/"
42
+ resource_name = super.singularize.underscore
43
+ dest_namespace = main_app? ? nil : selected_destination_feature.underscore
44
+ # Strip destination namespace from resource name if already present
45
+ if dest_namespace && resource_name.start_with?("#{dest_namespace}/")
46
+ resource_name = resource_name.sub("#{dest_namespace}/", "")
47
+ end
48
+ @name = [dest_namespace, resource_name].compact.join "/"
43
49
  set_destination_root!
44
50
  @name
45
51
  end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../lib/plutonium_generators"
4
+
5
+ module Pu
6
+ module Lite
7
+ class LitestreamGenerator < Rails::Generators::Base
8
+ include PlutoniumGenerators::Generator
9
+ include PlutoniumGenerators::Concerns::MountsEngines
10
+
11
+ desc "Set up Litestream for SQLite replication/backup"
12
+
13
+ class_option :route, type: :string, default: "/manage/litestream",
14
+ desc: "Route path for Litestream UI"
15
+ class_option :credentials, type: :boolean, default: true,
16
+ desc: "Configure Litestream to use Rails credentials"
17
+
18
+ def start
19
+ bundle "litestream"
20
+ run_litestream_install
21
+ create_litestream_script
22
+ configure_kamal
23
+ mount_litestream_engine
24
+ configure_litestream_initializer
25
+ show_instructions
26
+ rescue => e
27
+ exception "#{self.class} failed:", e
28
+ end
29
+
30
+ private
31
+
32
+ def run_litestream_install
33
+ Bundler.with_unbundled_env do
34
+ run "bin/rails generate litestream:install"
35
+ end
36
+ # Remove puma plugin added by litestream:install
37
+ run "git checkout -- config/puma.rb 2>/dev/null || true"
38
+ end
39
+
40
+ def create_litestream_script
41
+ litestream_script = "bin/litestream"
42
+ return if File.exist?(File.expand_path(litestream_script, destination_root))
43
+
44
+ create_file litestream_script, <<~BASH
45
+ #!/usr/bin/env bash
46
+ set -e
47
+ exec bundle exec litestream replicate -config config/litestream.yml
48
+ BASH
49
+ chmod litestream_script, 0o755
50
+ end
51
+
52
+ def configure_kamal
53
+ deploy_file = "config/deploy.yml"
54
+ return unless File.exist?(File.expand_path(deploy_file, destination_root))
55
+ return if file_includes?(deploy_file, "litestream:")
56
+
57
+ insert_into_file deploy_file, after: /^servers:.*\n/ do
58
+ <<~YAML
59
+ litestream:
60
+ hosts:
61
+ - <%= ENV['DEPLOY_HOST'] %>
62
+ cmd: bin/litestream
63
+ YAML
64
+ end
65
+ end
66
+
67
+ def mount_litestream_engine
68
+ mount_engine %(mount Litestream::Engine, at: "#{options[:route]}"), authenticated: true
69
+ end
70
+
71
+ def configure_litestream_initializer
72
+ return unless options[:credentials]
73
+
74
+ initializer_file = "config/initializers/litestream.rb"
75
+ uncomment_lines initializer_file, /litestream_credentials/
76
+ end
77
+
78
+ def show_instructions
79
+ say ""
80
+ say "Litestream Setup Instructions:", :green
81
+ say "=" * 50
82
+ say ""
83
+ say "Litestream requires an S3-compatible storage provider (AWS S3, DigitalOcean Spaces, etc.)"
84
+ say ""
85
+
86
+ if options[:credentials]
87
+ say "Edit your credentials to store bucket details:"
88
+ say " bin/rails credentials:edit"
89
+ say ""
90
+ say "Add the following:"
91
+ say " litestream:"
92
+ say " replica_bucket: <your-bucket-name>"
93
+ say " replica_key_id: <public-key>"
94
+ say " replica_access_key: <private-key>"
95
+ say ""
96
+ say "Verify configuration:"
97
+ say " bin/rails litestream:env"
98
+ else
99
+ say "Configure Litestream in: config/initializers/litestream.rb"
100
+ end
101
+ say ""
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../lib/plutonium_generators"
4
+
5
+ module Pu
6
+ module Lite
7
+ class RailsPulseGenerator < Rails::Generators::Base
8
+ include PlutoniumGenerators::Generator
9
+ include PlutoniumGenerators::Concerns::ConfiguresSqlite
10
+ include PlutoniumGenerators::Concerns::MountsEngines
11
+
12
+ source_root File.expand_path("templates", __dir__)
13
+
14
+ desc "Set up Rails Pulse for performance monitoring with SQLite"
15
+
16
+ class_option :database, type: :string, default: "rails_pulse",
17
+ desc: "Database name for Rails Pulse (default: rails_pulse)"
18
+ class_option :route, type: :string, default: "/manage/pulse",
19
+ desc: "Route path for Rails Pulse dashboard"
20
+
21
+ def start
22
+ bundle "rails_pulse"
23
+
24
+ if options[:database]
25
+ setup_separate_database
26
+ else
27
+ run_rails_pulse_install
28
+ end
29
+
30
+ template "config/initializers/rails_pulse.rb", force: true
31
+ mount_rails_pulse_engine
32
+ setup_recurring_tasks if solid_queue_installed?
33
+ rescue => e
34
+ exception "#{self.class} failed:", e
35
+ end
36
+
37
+ private
38
+
39
+ def run_rails_pulse_install
40
+ Bundler.with_unbundled_env do
41
+ run "bin/rails generate rails_pulse:install"
42
+ end
43
+ end
44
+
45
+ def setup_separate_database
46
+ @db_name = options[:database]
47
+
48
+ # Run install first - creates schema file and migration
49
+ Bundler.with_unbundled_env do
50
+ run "bin/rails generate rails_pulse:install --database=separate"
51
+ end
52
+
53
+ # Then add database config
54
+ add_sqlite_database(@db_name, migrations_paths: "db/rails_pulse_migrate")
55
+
56
+ # Finally prepare the database (runs migration that loads schema)
57
+ prepare_database(@db_name)
58
+ end
59
+
60
+ def mount_rails_pulse_engine
61
+ mount_engine %(mount RailsPulse::Engine, at: "#{options[:route]}"), authenticated: true
62
+ end
63
+
64
+ def solid_queue_installed?
65
+ gem_in_bundle?("solid_queue")
66
+ end
67
+
68
+ def setup_recurring_tasks
69
+ recurring_file = "config/recurring.yml"
70
+ return unless File.exist?(File.expand_path(recurring_file, destination_root))
71
+ return if file_includes?(recurring_file, "rails_pulse")
72
+
73
+ recurring_tasks = <<~YAML
74
+
75
+ rails_pulse_summary:
76
+ class: RailsPulse::SummaryJob
77
+ schedule: "5 * * * *" # 5 minutes past every hour
78
+
79
+ rails_pulse_cleanup:
80
+ class: RailsPulse::CleanupJob
81
+ schedule: "0 1 * * *" # Daily at 1am
82
+ YAML
83
+
84
+ append_to_file recurring_file, recurring_tasks
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ RailsPulse.configure do |config|
4
+ # Enable/disable Rails Pulse globally
5
+ config.enabled = Rails.env.production? || Rails.env.development?
6
+
7
+ # Asset tracking (disable to reduce noise)
8
+ config.track_assets = false
9
+ <%- if options[:database] -%>
10
+
11
+ # Use separate database for performance data
12
+ config.connects_to = {database: {writing: :<%= options[:database] %>}}
13
+ <%- end -%>
14
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../lib/plutonium_generators"
4
+
5
+ module Pu
6
+ module Lite
7
+ class SetupGenerator < Rails::Generators::Base
8
+ include PlutoniumGenerators::Generator
9
+
10
+ desc "Set up SQLite with proper configuration"
11
+
12
+ RAILS_8_VERSION = ::Gem::Version.new("8.0.0")
13
+
14
+ def start
15
+ ensure_sqlite3_version
16
+
17
+ # Add enhanced adapter for Rails 7
18
+ if rails_version < RAILS_8_VERSION
19
+ bundle "activerecord-enhancedsqlite3-adapter", version: "~> 0.8.0"
20
+ end
21
+ rescue => e
22
+ exception "#{self.class} failed:", e
23
+ end
24
+
25
+ private
26
+
27
+ SQLITE3_MIN_VERSION = ::Gem::Version.new("2.0.0")
28
+
29
+ def ensure_sqlite3_version
30
+ current = installed_gem_version("sqlite3")
31
+ if current.nil?
32
+ bundle "sqlite3", version: "~> 2.0"
33
+ elsif current < SQLITE3_MIN_VERSION
34
+ log :bundle, "updating sqlite3 from #{current} to ~> 2.0"
35
+ gsub_file "Gemfile", /^gem ["']sqlite3["'].*$/, 'gem "sqlite3", "~> 2.0"'
36
+ bundle!
37
+ end
38
+ end
39
+
40
+ def installed_gem_version(gem_name)
41
+ lockfile = File.join(destination_root, "Gemfile.lock")
42
+ return nil unless File.exist?(lockfile)
43
+
44
+ content = File.read(lockfile)
45
+ match = content.match(/#{gem_name} \((\d+\.\d+\.\d+)/)
46
+ ::Gem::Version.new(match[1]) if match
47
+ end
48
+
49
+ def rails_version
50
+ @rails_version ||= ::Gem::Version.new(Rails::VERSION::STRING).release
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../lib/plutonium_generators"
4
+
5
+ module Pu
6
+ module Lite
7
+ class SolidCableGenerator < Rails::Generators::Base
8
+ include PlutoniumGenerators::Generator
9
+ include PlutoniumGenerators::Concerns::ConfiguresSqlite
10
+
11
+ source_root File.expand_path("templates", __dir__)
12
+
13
+ desc "Set up Solid Cable for Action Cable with SQLite"
14
+
15
+ class_option :database, type: :string, default: "cable",
16
+ desc: "Database name for Solid Cable"
17
+
18
+ def start
19
+ @db_name = options[:database]
20
+
21
+ bundle "solid_cable"
22
+ add_sqlite_database(@db_name)
23
+ run_solid_cable_install
24
+ configure_cable_yml
25
+ prepare_database(@db_name)
26
+ rescue => e
27
+ exception "#{self.class} failed:", e
28
+ end
29
+
30
+ private
31
+
32
+ def run_solid_cable_install
33
+ Bundler.with_unbundled_env do
34
+ run "bin/rails generate solid_cable:install", env: {"DATABASE" => @db_name}
35
+ end
36
+ run "git checkout -- config/environments/production.rb 2>/dev/null || true"
37
+ end
38
+
39
+ def configure_cable_yml
40
+ cable_file = "config/cable.yml"
41
+ remove_file cable_file
42
+ create_file cable_file, <<~YAML
43
+ default: &default
44
+ adapter: solid_cable
45
+ polling_interval: 1.second
46
+ keep_messages_around_for: 1.day
47
+ connects_to:
48
+ database:
49
+ writing: #{@db_name}
50
+
51
+ development:
52
+ <<: *default
53
+ silence_polling: true
54
+
55
+ test:
56
+ <<: *default
57
+
58
+ production:
59
+ <<: *default
60
+ polling_interval: 0.1.seconds
61
+ YAML
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../lib/plutonium_generators"
4
+
5
+ module Pu
6
+ module Lite
7
+ class SolidCacheGenerator < Rails::Generators::Base
8
+ include PlutoniumGenerators::Generator
9
+ include PlutoniumGenerators::Concerns::ConfiguresSqlite
10
+
11
+ source_root File.expand_path("templates", __dir__)
12
+
13
+ desc "Set up Solid Cache with SQLite"
14
+
15
+ class_option :database, type: :string, default: "cache",
16
+ desc: "Database name for Solid Cache"
17
+ class_option :dev_cache, type: :boolean, default: true,
18
+ desc: "Enable caching in development"
19
+
20
+ def start
21
+ @db_name = options[:database]
22
+
23
+ bundle "solid_cache"
24
+ add_sqlite_database(@db_name)
25
+ run_solid_cache_install
26
+ configure_cache_yml
27
+ configure_application
28
+ prepare_database(@db_name)
29
+ enable_dev_cache if options[:dev_cache]
30
+ rescue => e
31
+ exception "#{self.class} failed:", e
32
+ end
33
+
34
+ private
35
+
36
+ def run_solid_cache_install
37
+ Bundler.with_unbundled_env do
38
+ run "bin/rails generate solid_cache:install", env: {"DATABASE" => @db_name}
39
+ end
40
+ run "git checkout -- config/environments/production.rb 2>/dev/null || true"
41
+ end
42
+
43
+ def configure_cache_yml
44
+ cache_file = "config/cache.yml"
45
+ gsub_file cache_file, "database: <%= Rails.env %>", "database: #{@db_name}"
46
+ gsub_file cache_file, "database: cache", "database: #{@db_name}"
47
+ end
48
+
49
+ def configure_application
50
+ create_file "config/initializers/solid_cache.rb", <<~RUBY
51
+ # frozen_string_literal: true
52
+
53
+ Rails.application.configure do
54
+ config.cache_store = :solid_cache_store
55
+ end
56
+ RUBY
57
+ end
58
+
59
+ def enable_dev_cache
60
+ Bundler.with_unbundled_env do
61
+ run "bin/rails dev:cache"
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end