plum-cms 0.2.1 → 0.2.3

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 (54) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +249 -0
  3. data/README.md +68 -0
  4. data/app/assets/builds/tailwind.css +1 -1
  5. data/app/controllers/plum/cp/entries_controller.rb +88 -5
  6. data/app/controllers/plum/cp/static_cache_controller.rb +12 -0
  7. data/app/controllers/plum/form_submissions_controller.rb +13 -0
  8. data/app/controllers/plum/pages_controller.rb +8 -0
  9. data/app/controllers/plum/theme_assets_controller.rb +2 -0
  10. data/app/javascript/controllers/plum/write_controller.js +174 -0
  11. data/app/models/plum/asset.rb +1 -0
  12. data/app/models/plum/content_type.rb +1 -0
  13. data/app/models/plum/entry.rb +57 -0
  14. data/app/models/plum/entry_term.rb +1 -0
  15. data/app/models/plum/form_definition.rb +1 -0
  16. data/app/models/plum/global.rb +1 -0
  17. data/app/models/plum/nav_item.rb +1 -0
  18. data/app/models/plum/nav_menu.rb +1 -0
  19. data/app/models/plum/site.rb +2 -0
  20. data/app/models/plum/site_setting.rb +1 -0
  21. data/app/models/plum/static_cache_invalidation.rb +30 -0
  22. data/app/models/plum/taxonomy.rb +1 -0
  23. data/app/models/plum/term.rb +1 -0
  24. data/app/services/plum/config_sync.rb +252 -0
  25. data/app/services/plum/draft_diff.rb +177 -0
  26. data/app/services/plum/form_renderer.rb +12 -1
  27. data/app/services/plum/liquid_context.rb +0 -2
  28. data/app/views/layouts/plum/cp.html.erb +1 -1
  29. data/app/views/layouts/plum/session.html.erb +1 -1
  30. data/app/views/layouts/plum/write.html.erb +140 -0
  31. data/app/views/plum/cp/dashboard/show.html.erb +10 -3
  32. data/app/views/plum/cp/entries/_form.html.erb +2 -2
  33. data/app/views/plum/cp/entries/diff.html.erb +40 -0
  34. data/app/views/plum/cp/entries/edit.html.erb +24 -0
  35. data/app/views/plum/cp/entries/index.html.erb +3 -0
  36. data/app/views/plum/cp/entries/write.html.erb +60 -0
  37. data/config/plum_routes.rb +5 -0
  38. data/db/engine_migrate/20260811090000_add_draft_data_to_plum_entries.rb +5 -0
  39. data/docs/config-as-code.md +103 -0
  40. data/docs/plum-cli.md +422 -0
  41. data/docs/static-caching.md +163 -0
  42. data/docs/zero-to-agency.md +172 -0
  43. data/lib/generators/plum/install/install_generator.rb +13 -1
  44. data/lib/generators/plum/install/templates/plum_initializer.rb +8 -0
  45. data/lib/plum/configuration.rb +12 -1
  46. data/lib/plum/engine.rb +18 -4
  47. data/lib/plum/static_cache/middleware.rb +61 -0
  48. data/lib/plum/static_cache.rb +103 -0
  49. data/lib/plum/version.rb +1 -1
  50. data/lib/plum.rb +7 -0
  51. data/lib/tasks/plum_config.rake +48 -0
  52. data/lib/tasks/plum_portability.rake +54 -29
  53. data/lib/tasks/plum_styles.rake +14 -9
  54. metadata +30 -5
@@ -0,0 +1,172 @@
1
+ # Zero to Agency
2
+
3
+ How to go from an empty directory to hosting client sites — with one
4
+ cheap VPS, the `plum` CLI, and nothing else. No GitHub required, no
5
+ container registry, no CI pipeline, no platform-as-a-service bill. Your
6
+ laptop builds, your server runs, SSH connects them.
7
+
8
+ The stack under the hood: Rails + Plum in a Docker container, managed by
9
+ [Once](https://once.com) (37signals' single-container deploy tool) behind
10
+ its bundled TLS-terminating proxy, on any Linux box. The examples use
11
+ DigitalOcean; any VPS provider works the same way.
12
+
13
+ ## What you need
14
+
15
+ - A [DigitalOcean](https://digitalocean.com) account (or any VPS provider)
16
+ - Docker on your laptop (Docker Desktop or Docker Engine) — `plum deploy`
17
+ builds images locally
18
+ - The `plum` CLI
19
+ - Ruby and Rails — and if you don't have them, `plum new` offers to
20
+ install both for you (via [mise](https://mise.jdx.dev))
21
+
22
+ ## 1. Create the server
23
+
24
+ In DigitalOcean: **Create → Droplet**. Ubuntu LTS, the $6/month size is
25
+ enough to start (pick $12 for headroom). Add your SSH key if you have one
26
+ — if you don't, that's fine: `plum connect` will generate one and offer
27
+ to copy it over with the root password DigitalOcean emails you. Note the
28
+ droplet's IP.
29
+
30
+ That's the last time you touch a cloud dashboard.
31
+
32
+ ## 2. Create the site
33
+
34
+ ```bash
35
+ plum new client-site
36
+ cd client-site
37
+ bin/rails server
38
+ ```
39
+
40
+ You have a running Plum site at `http://localhost:3000` — the control
41
+ panel is at `/cp`. Build the client's theme in `app/themes/`, model
42
+ their content types in the CP (or as YAML in `plum/` — see
43
+ `docs/config-as-code.md`), and load in their content.
44
+
45
+ ## 3. Connect the server
46
+
47
+ ```bash
48
+ plum connect 203.0.113.5 # your droplet's IP
49
+ ```
50
+
51
+ The wizard handles the entire first-contact ritual: SSH keys, passwordless
52
+ login, detecting that this is a Once-shaped app, and — on a fresh droplet —
53
+ **installing Docker Engine and Once for you** (each with your
54
+ confirmation). When it asks for the app hostname, give it the client's
55
+ real domain (`clientname.com`): that's how the server routes requests and
56
+ provisions TLS.
57
+
58
+ ## 4. Point DNS
59
+
60
+ At the client's DNS: an **A record** for their domain (and `www`) to the
61
+ droplet's IP. **This must happen before the first deploy** — Once
62
+ verifies `https://<domain>` end-to-end as the final step of a first
63
+ deploy and rolls back if the domain doesn't reach the server yet
64
+ (`plum deploy` checks this up front and warns you). Proxied DNS
65
+ (Cloudflare in Full strict mode) is fine.
66
+
67
+ Building ahead of a domain cutover? That's what previews are for:
68
+
69
+ ```bash
70
+ plum deploy --preview
71
+ ```
72
+
73
+ This ships the site as a *separate* Once app under a preview hostname —
74
+ by default `<app>.<server-ip>.sslip.io`, magic wildcard DNS that reaches
75
+ your server with zero configuration, so you have a shareable client
76
+ preview URL before any real DNS exists. For branded previews, point one
77
+ wildcard record (`*.preview.your-agency.com`) at the droplet and set
78
+ `preview_host: client.preview.your-agency.com` on the remote in
79
+ plum.yml.
80
+
81
+ Iterate on the preview as long as you like — build the content, tune the
82
+ theme, let the client click around. Then, when they flip DNS to the
83
+ droplet:
84
+
85
+ ```bash
86
+ plum promote
87
+ ```
88
+
89
+ The preview app *becomes* the site: same container, same database, every
90
+ word of content you built during the preview — it simply starts answering
91
+ to the real domain, with TLS provisioned on the spot. No rebuild, no
92
+ content migration, no separate go-live deploy. (If the site should live
93
+ on its own droplet instead, `plum connect` it there and `plum deploy` —
94
+ site archives carry the content across.)
95
+
96
+ ## 5. Deploy
97
+
98
+ ```bash
99
+ plum deploy
100
+ ```
101
+
102
+ Watch it go: the image builds on your laptop, streams to the server over
103
+ SSH (`docker save | docker load` — this is why no registry account
104
+ exists in this guide), Once starts it behind the proxy, and the CLI waits
105
+ for the container to report healthy. `https://clientname.com` is live.
106
+
107
+ Deploys are explicit — the site changes when you run `plum deploy`,
108
+ never by surprise. Ship again any time with the same command.
109
+
110
+ ## 6. The agency rhythm
111
+
112
+ Day-to-day, the CLI is your relationship with every client site:
113
+
114
+ ```bash
115
+ plum pull # production content → your laptop, any DB engine
116
+ plum push # content-model changes (plum/ YAML) → production
117
+ plum check # did anyone drift the model? (CI-friendly)
118
+ plum logs --follow # heroku-style tail, prettified
119
+ plum backup # timestamped archive on the server
120
+ plum run -- TASK # any rake task, any remote
121
+ ```
122
+
123
+ Content pulls down, structure pushes up. Clients edit in `/cp` on
124
+ production; you develop locally against a full copy of their real
125
+ content. Turn on **static caching** (`docs/static-caching.md`) and the
126
+ site serves rendered pages from disk — SQLite happily runs a
127
+ production site on the smallest droplet, because cached reads never
128
+ touch the database at all.
129
+
130
+ ## 7. The second client
131
+
132
+ This is where it becomes an agency:
133
+
134
+ ```bash
135
+ plum new second-client
136
+ cd second-client
137
+ plum connect # the host prompt offers your existing server
138
+ plum deploy
139
+ ```
140
+
141
+ Once runs each site in its own container on the same box, routing by
142
+ hostname, each with its own TLS certificate. A $6–12 droplet comfortably
143
+ hosts several small client sites; each new client is marginal-cost-zero
144
+ until the box runs hot. Manage the fleet from anywhere:
145
+
146
+ ```bash
147
+ plum projects list # every site you run
148
+ plum use second-client # switch the active project
149
+ plum pull --project client-site # or target one explicitly
150
+ ```
151
+
152
+ ## 8. When you outgrow one box
153
+
154
+ - **More sites than one droplet likes**: create a second droplet,
155
+ `plum connect` the new sites there. The registry doesn't care how many
156
+ servers are behind it.
157
+ - **A client needs Postgres** (heavy dynamic features): switch that app's
158
+ database and redeploy — `plum pull` still works across engines.
159
+ - **Backups off the box**: `plum backup` writes archives on the server;
160
+ Once's auto-backup handles scheduling. Pull copies down with `plum pull`
161
+ or scp them to storage you control.
162
+
163
+ ## Costs, honestly
164
+
165
+ | Thing | Cost |
166
+ | --- | --- |
167
+ | Droplet (several small sites) | $6–12/mo |
168
+ | Domains | the client's problem |
169
+ | Registry, CI, PaaS, per-seat CMS pricing | $0 — none exist in this setup |
170
+
171
+ The whole model: you charge for design and content strategy; the
172
+ infrastructure is a rounding error.
@@ -30,7 +30,19 @@ module Plum
30
30
  def mount_engine
31
31
  return if options[:skip_route]
32
32
 
33
- route %(mount Plum::Engine, at: "#{options[:mount_path]}")
33
+ # Mounted at "/", Plum serves every path the app doesn't claim — so
34
+ # it must come AFTER the app's own routes (notably `get "up"`, the
35
+ # health check load balancers and deploy tooling probe). Rails'
36
+ # `route` helper prepends, which would shadow them; append at the
37
+ # bottom of the block instead.
38
+ inject_into_file "config/routes.rb", before: /^end\s*\z/ do
39
+ <<~RUBY.indent(2)
40
+
41
+ # Plum last: mounted at #{options[:mount_path].inspect} it serves every path
42
+ # the app doesn't claim, so app routes (like /up) must come first.
43
+ mount Plum::Engine, at: #{options[:mount_path].inspect}
44
+ RUBY
45
+ end
34
46
  end
35
47
 
36
48
  def print_next_steps
@@ -12,6 +12,14 @@ Plum.configure do |config|
12
12
  Plum::Engine.root.join("app/themes")
13
13
  ]
14
14
 
15
+ # Static page caching serves rendered pages from disk instead of hitting
16
+ # the database/Liquid on every request — a big win on a single server, but
17
+ # only correct on a single server. See docs/static-caching.md before
18
+ # enabling this on a multi-node/PaaS deployment (Heroku, Render, etc. with
19
+ # 2+ dynos or instances).
20
+ #
21
+ # config.static_cache_enabled = true
22
+
15
23
  # White-label the control panel:
16
24
  #
17
25
  # config.cp_name = "My Brand"
@@ -6,7 +6,8 @@ module Plum
6
6
  :mailer_sender, :cp_name, :cp_subtitle, :cp_logo_path,
7
7
  :cp_accent_color, :cp_sidebar_bg, :cp_sidebar_header_bg, :cp_sidebar_text, :cp_sidebar_muted,
8
8
  :cp_back_url, :cp_back_label,
9
- :powered_by_name, :powered_by_url
9
+ :powered_by_name, :powered_by_url,
10
+ :static_cache_enabled, :static_cache_path, :config_path
10
11
  attr_writer :theme_paths
11
12
  attr_reader :content_sources
12
13
 
@@ -25,6 +26,16 @@ module Plum
25
26
  @cp_back_label = "← Back"
26
27
  @powered_by_name = "Plum"
27
28
  @powered_by_url = "https://plumcms.com"
29
+ # Explicit opt-in — see docs/static-caching.md before enabling. Off by
30
+ # default because it's only correct on a single-server deployment;
31
+ # silently corrupts pages (stale content served forever from whichever
32
+ # node didn't get the invalidation) on multi-node platforms like
33
+ # Heroku/Render with 2+ dynos.
34
+ @static_cache_enabled = false
35
+ @static_cache_path = nil
36
+ # Directory for config-as-code YAML (content types, fieldsets).
37
+ # nil = default to Rails.root/plum when the tasks are invoked.
38
+ @config_path = nil
28
39
  @content_sources = ContentSourceRegistry.new
29
40
  @current_site_resolver = ->(_controller) { Plum::Site.first_or_create_standalone! }
30
41
  @current_user_resolver = lambda { |controller|
data/lib/plum/engine.rb CHANGED
@@ -1,4 +1,6 @@
1
1
  require_relative "configuration"
2
+ require_relative "static_cache"
3
+ require_relative "static_cache/middleware"
2
4
 
3
5
  module Plum
4
6
  class Engine < ::Rails::Engine
@@ -6,6 +8,11 @@ module Plum
6
8
  config.paths["config/routes.rb"] = "config/plum_routes.rb"
7
9
  config.paths["db/migrate"] = [ "db/engine_migrate" ]
8
10
 
11
+ initializer "plum.static_cache" do |app|
12
+ # Always installed; it no-ops per request unless StaticCache.enabled?
13
+ app.middleware.use Plum::StaticCache::Middleware
14
+ end
15
+
9
16
  initializer "plum.assets" do |app|
10
17
  app.config.assets.paths << root.join("app/assets/javascripts")
11
18
  # Importmap exposes these files as `plum/*`, so Propshaft must resolve
@@ -23,10 +30,17 @@ module Plum
23
30
  end
24
31
  end
25
32
 
26
- initializer "plum.importmap", before: "importmap" do |app|
27
- if app.config.respond_to?(:importmap)
28
- app.config.importmap.paths << root.join("config/plum_importmap.rb")
29
- end
33
+ # Build a Plum-scoped importmap instead of merging Plum's pins into the
34
+ # host app's global importmap. Plum's layouts render this map (host JS stack
35
+ # + Plum's additions), while the host app's own pages keep a clean importmap
36
+ # free of Plum's controllers/lexxy/activestorage pins. Runs after the host's
37
+ # importmap is configured so we can copy its paths.
38
+ initializer "plum.importmap", after: "importmap" do |app|
39
+ next unless defined?(Importmap::Map) && app.config.respond_to?(:importmap)
40
+
41
+ Plum.importmap = Importmap::Map.new
42
+ Array(app.config.importmap.paths).each { |path| Plum.importmap.draw(path) }
43
+ Plum.importmap.draw(root.join("config/plum_importmap.rb"))
30
44
  end
31
45
  end
32
46
  end
@@ -0,0 +1,61 @@
1
+ require_relative "../static_cache"
2
+
3
+ module Plum
4
+ module StaticCache
5
+ # Serves cached pages before the request reaches Rails and captures
6
+ # renders on the way out. Controllers opt responses in by setting the
7
+ # X-Plum-Static-Cache header to "store" (see PagesController); everything
8
+ # else passes through untouched.
9
+ class Middleware
10
+ MARKER = StaticCache::MARKER_HEADER.downcase
11
+
12
+ def initialize(app)
13
+ @app = app
14
+ end
15
+
16
+ def call(env)
17
+ if StaticCache.enabled? && cacheable_request?(env) && (file = StaticCache.read(env["HTTP_HOST"], env["PATH_INFO"]))
18
+ return serve(file, env)
19
+ end
20
+
21
+ status, headers, body = @app.call(env)
22
+ maybe_store(env, status, headers, body)
23
+ end
24
+
25
+ private
26
+
27
+ def cacheable_request?(env)
28
+ return false unless env["REQUEST_METHOD"] == "GET" || env["REQUEST_METHOD"] == "HEAD"
29
+
30
+ env["QUERY_STRING"].to_s.empty?
31
+ end
32
+
33
+ def serve(file, env)
34
+ body = env["REQUEST_METHOD"] == "HEAD" ? [] : [ file.binread ]
35
+ headers = {
36
+ "content-type" => StaticCache.content_type_for(file),
37
+ "content-length" => file.size.to_s,
38
+ MARKER => "hit"
39
+ }
40
+ [ 200, headers, body ]
41
+ end
42
+
43
+ def maybe_store(env, status, headers, body)
44
+ # The marker is internal — strip it even when the response isn't stored.
45
+ marker = headers.delete(MARKER) || headers.delete(StaticCache::MARKER_HEADER)
46
+ return [ status, headers, body ] unless marker == "store" && StaticCache.enabled? && cacheable_request?(env)
47
+ return [ status, headers, body ] unless status == 200 && env["REQUEST_METHOD"] == "GET"
48
+ return [ status, headers, body ] if headers["set-cookie"].present? || headers["Set-Cookie"].present?
49
+
50
+ chunks = []
51
+ body.each { |chunk| chunks << chunk.to_s }
52
+ body.close if body.respond_to?(:close)
53
+ full_body = chunks.join
54
+
55
+ StaticCache.store(env["HTTP_HOST"], env["PATH_INFO"], full_body)
56
+ headers[MARKER] = "miss"
57
+ [ status, headers, [ full_body ] ]
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,103 @@
1
+ require "rack/mime"
2
+
3
+ module Plum
4
+ # Full-page static cache. Rendered public pages are written to disk keyed by
5
+ # host + path; subsequent requests are served from the file without touching
6
+ # the database or Liquid. A web server can serve the same files directly
7
+ # (nginx try_files) for the "full measure" where Rails never sees the hit.
8
+ #
9
+ # Cache entries are invalidated by deletion (see StaticCacheInvalidation) —
10
+ # the next request re-renders and re-stores the page. Over-flushing is cheap,
11
+ # so anything ambiguous flushes the whole site.
12
+ module StaticCache
13
+ MARKER_HEADER = "X-Plum-Static-Cache".freeze
14
+
15
+ class << self
16
+ # Explicit opt-in only — see docs/static-caching.md. There is
17
+ # deliberately no "auto-on in production" mode: this cache is only
18
+ # correct on a single-server deployment, and defaulting it on would
19
+ # silently corrupt pages the moment an app scales to 2+ nodes.
20
+ def enabled?
21
+ !!Plum.configuration.static_cache_enabled
22
+ end
23
+
24
+ def cache_root
25
+ Pathname(Plum.configuration.static_cache_path || Rails.root.join("storage", "plum_static_cache"))
26
+ end
27
+
28
+ # Pages are stored as {host}/{path}/index.html so a web server can map
29
+ # URLs to files directly. Paths with a file extension (theme assets) are
30
+ # stored verbatim.
31
+ def file_path(host, path)
32
+ key = normalized_path(path)
33
+ return nil unless key
34
+
35
+ base = cache_root.join(sanitized_host(host))
36
+ full = File.extname(key).present? ? base.join(key) : base.join(key, "index.html")
37
+ full = Pathname(File.expand_path(full))
38
+ return nil unless full.to_s.start_with?(cache_root.to_s + File::SEPARATOR)
39
+
40
+ full
41
+ end
42
+
43
+ def read(host, path)
44
+ file = file_path(host, path)
45
+ file if file&.file?
46
+ end
47
+
48
+ def store(host, path, body)
49
+ file = file_path(host, path)
50
+ return unless file
51
+
52
+ file.dirname.mkpath
53
+ tmp = file.sub_ext(".tmp-#{Process.pid}-#{Thread.current.object_id}")
54
+ tmp.binwrite(body)
55
+ File.rename(tmp, file)
56
+ file
57
+ rescue SystemCallError => e
58
+ Rails.logger.error("[Plum] static cache write failed for #{path}: #{e.message}")
59
+ nil
60
+ end
61
+
62
+ def flush_site!(site)
63
+ return flush_all! if site.nil? || site.domain.blank?
64
+
65
+ [ site.domain, "www.#{site.domain}" ].each do |host|
66
+ dir = cache_root.join(sanitized_host(host))
67
+ FileUtils.rm_rf(dir) if dir.to_s.start_with?(cache_root.to_s) && dir.exist?
68
+ end
69
+ end
70
+
71
+ def flush_all!
72
+ return unless cache_root.exist?
73
+
74
+ cache_root.children.each { |child| FileUtils.rm_rf(child) }
75
+ end
76
+
77
+ def content_type_for(file)
78
+ Rack::Mime.mime_type(File.extname(file.to_s), "text/html")
79
+ end
80
+
81
+ private
82
+
83
+ def sanitized_host(host)
84
+ cleaned = host.to_s.downcase.gsub(/[^a-z0-9.\-]/, "")
85
+ cleaned.presence || "_default"
86
+ end
87
+
88
+ # Rejects anything that could escape the cache directory and normalizes
89
+ # "/" and "/about/" style paths to a shared key.
90
+ def normalized_path(path)
91
+ decoded = begin
92
+ URI.decode_www_form_component(path.to_s)
93
+ rescue ArgumentError
94
+ return nil
95
+ end
96
+ return nil if decoded.include?("..") || decoded.include?("\0")
97
+
98
+ trimmed = decoded.delete_prefix("/").chomp("/")
99
+ trimmed.presence || "_root"
100
+ end
101
+ end
102
+ end
103
+ end
data/lib/plum/version.rb CHANGED
@@ -1,6 +1,6 @@
1
1
  module Plum
2
2
  module Version
3
- STRING = "0.2.1"
3
+ STRING = "0.2.3"
4
4
  end
5
5
 
6
6
  VERSION = Version::STRING
data/lib/plum.rb CHANGED
@@ -3,6 +3,13 @@ require_relative "plum/content_source"
3
3
  require_relative "plum/configuration"
4
4
 
5
5
  module Plum
6
+ class << self
7
+ # A Plum-scoped importmap (host pins + Plum's own pins), used only by Plum's
8
+ # layouts. Keeps Plum's controllers/lexxy/activestorage out of the host
9
+ # app's global importmap so a mounted Plum can't pollute the host's JS.
10
+ attr_accessor :importmap
11
+ end
12
+
6
13
  def self.table_name_prefix
7
14
  "plum_"
8
15
  end
@@ -0,0 +1,48 @@
1
+ # This repo is both the engine and an app, so Rails loads lib/tasks twice
2
+ # (engine railtie + application). Rake appends actions on re-definition, which
3
+ # would run every task body twice — guard against the second load.
4
+ unless Rake::Task.task_defined?("plum:config:export")
5
+ namespace :plum do
6
+ namespace :config do
7
+ def plum_config_site
8
+ site = ENV["SITE_ID"].present? ? Plum::Site.find(ENV["SITE_ID"]) : Plum::Site.first
9
+ abort "No Plum site exists" unless site
10
+ site
11
+ end
12
+
13
+ def plum_config_dir
14
+ ENV["DIR"].presence || Plum.configuration.config_path || Rails.root.join("plum")
15
+ end
16
+
17
+ desc "Write the content model (content types, fieldsets) to YAML files (SITE_ID optional; DIR defaults to plum/)"
18
+ task export: :environment do
19
+ files = Plum::ConfigSync.export(site: plum_config_site, dir: plum_config_dir)
20
+ puts "Exported #{files.length} config files to #{plum_config_dir}"
21
+ end
22
+
23
+ desc "Apply YAML config files to the database (SITE_ID optional; PRUNE=1 deletes types absent from files; FORCE=1 allows deleting types with entries)"
24
+ task sync: :environment do
25
+ result = Plum::ConfigSync.apply(
26
+ site: plum_config_site,
27
+ dir: plum_config_dir,
28
+ prune: ENV["PRUNE"].present?,
29
+ force: ENV["FORCE"].present?
30
+ )
31
+ puts "Config sync: #{result.summary}"
32
+ rescue Plum::ConfigSync::UnsafePruneError => e
33
+ abort e.message
34
+ end
35
+
36
+ desc "Fail if the database content model has drifted from the YAML files (for CI)"
37
+ task check: :environment do
38
+ drift = Plum::ConfigSync.check(site: plum_config_site, dir: plum_config_dir)
39
+ if drift.any?
40
+ drift.each { |line| puts "DRIFT #{line}" }
41
+ abort "Content model has drifted from config files (#{drift.length} differences)"
42
+ else
43
+ puts "Content model matches config files"
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -1,37 +1,62 @@
1
- namespace :plum do
2
- namespace :site do
3
- desc "Export a complete Plum site archive (SITE_ID or first site; ARCHIVE required)"
4
- task export: :environment do
5
- path = ENV.fetch("ARCHIVE") { abort "ARCHIVE is required" }
6
- site = ENV["SITE_ID"].present? ? Plum::Site.find(ENV["SITE_ID"]) : Plum::Site.first
7
- abort "No Plum site exists" unless site
8
-
9
- archive = Plum::SiteArchive.dump(site: site, path: path)
10
- puts "Exported #{site.name} to #{archive}"
11
- end
1
+ # This repo is both the engine and an app, so Rails loads lib/tasks twice
2
+ # (engine railtie + application). Rake appends actions on re-definition, which
3
+ # would run every task body twice guard against the second load.
4
+ unless Rake::Task.task_defined?("plum:site:export")
5
+ namespace :plum do
6
+ namespace :site do
7
+ desc "Export a complete Plum site archive (SITE_ID or first site; ARCHIVE required)"
8
+ task export: :environment do
9
+ path = ENV.fetch("ARCHIVE") { abort "ARCHIVE is required" }
10
+ site = ENV["SITE_ID"].present? ? Plum::Site.find(ENV["SITE_ID"]) : Plum::Site.first
11
+ abort "No Plum site exists" unless site
12
+
13
+ archive = Plum::SiteArchive.dump(site: site, path: path)
14
+ puts "Exported #{site.name} to #{archive}"
15
+ end
12
16
 
13
- desc "Import a Plum site archive as a new site (ARCHIVE required; NAME and DOMAIN optional)"
14
- task import: :environment do
15
- path = ENV.fetch("ARCHIVE") { abort "ARCHIVE is required" }
16
- site = Plum::SiteArchive.load(path: path, name: ENV["NAME"], domain: ENV["DOMAIN"])
17
- puts "Imported #{site.name} as site #{site.id}"
17
+ desc "Import a Plum site archive as a new site (ARCHIVE required; NAME and DOMAIN optional)"
18
+ task import: :environment do
19
+ path = ENV.fetch("ARCHIVE") { abort "ARCHIVE is required" }
20
+ site = Plum::SiteArchive.load(path: path, name: ENV["NAME"], domain: ENV["DOMAIN"])
21
+ puts "Imported #{site.name} as site #{site.id}"
22
+ end
18
23
  end
19
- end
20
24
 
21
- namespace :backup do
22
- desc "Create a timestamped Plum site backup (SITE_ID optional; DIRECTORY defaults to backups/plum)"
23
- task create: :environment do
24
- site = ENV["SITE_ID"].present? ? Plum::Site.find(ENV["SITE_ID"]) : Plum::Site.first
25
- abort "No Plum site exists" unless site
26
- directory = Rails.root.join(ENV.fetch("DIRECTORY", "backups/plum"))
27
- filename = "plum-site-#{site.id}-#{Time.current.utc.strftime('%Y%m%d%H%M%S')}.plum.zip"
28
- archive = Plum::SiteArchive.dump(site: site, path: directory.join(filename))
29
- puts "Backed up #{site.name} to #{archive}"
25
+ namespace :site do
26
+ desc "Replace the local site with an archive — the 'pull' refresh (ARCHIVE required; SITE_ID optional; FORCE=1 required in production)"
27
+ task replace: :environment do
28
+ path = ENV.fetch("ARCHIVE") { abort "ARCHIVE is required" }
29
+ if Rails.env.production? && ENV["FORCE"].blank?
30
+ abort "Refusing to replace a site in production (set FORCE=1 to override)"
31
+ end
32
+
33
+ old_site = ENV["SITE_ID"].present? ? Plum::Site.find(ENV["SITE_ID"]) : Plum::Site.first
34
+ old_name = old_site&.name
35
+
36
+ site = ActiveRecord::Base.transaction do
37
+ old_site&.destroy!
38
+ Plum::SiteArchive.load(path: path, name: ENV["NAME"], domain: ENV["DOMAIN"])
39
+ end
40
+
41
+ puts old_name ? "Replaced #{old_name} with #{site.name} (site #{site.id})" : "Imported #{site.name} as site #{site.id}"
42
+ end
30
43
  end
31
44
 
32
- desc "Restore a Plum backup as a new site (ARCHIVE required; NAME and DOMAIN optional)"
33
- task restore: :environment do
34
- Rake::Task["plum:site:import"].invoke
45
+ namespace :backup do
46
+ desc "Create a timestamped Plum site backup (SITE_ID optional; DIRECTORY defaults to backups/plum)"
47
+ task create: :environment do
48
+ site = ENV["SITE_ID"].present? ? Plum::Site.find(ENV["SITE_ID"]) : Plum::Site.first
49
+ abort "No Plum site exists" unless site
50
+ directory = Rails.root.join(ENV.fetch("DIRECTORY", "backups/plum"))
51
+ filename = "plum-site-#{site.id}-#{Time.current.utc.strftime('%Y%m%d%H%M%S')}.plum.zip"
52
+ archive = Plum::SiteArchive.dump(site: site, path: directory.join(filename))
53
+ puts "Backed up #{site.name} to #{archive}"
54
+ end
55
+
56
+ desc "Restore a Plum backup as a new site (ARCHIVE required; NAME and DOMAIN optional)"
57
+ task restore: :environment do
58
+ Rake::Task["plum:site:import"].invoke
59
+ end
35
60
  end
36
61
  end
37
62
  end
@@ -1,12 +1,17 @@
1
- require "fileutils"
1
+ # This repo is both the engine and an app, so Rails loads lib/tasks twice
2
+ # (engine railtie + application). Rake appends actions on re-definition, which
3
+ # would run every task body twice — guard against the second load.
4
+ unless Rake::Task.task_defined?("plum:build_styles")
5
+ require "fileutils"
2
6
 
3
- namespace :plum do
4
- desc "Build Tailwind and copy the result into Plum's packaged stylesheet"
5
- task build_styles: :environment do
6
- system(Rails.root.join("bin/rails").to_s, "tailwindcss:build", exception: true)
7
- source = Rails.root.join("app/assets/builds/tailwind.css")
8
- destination = Rails.root.join("app/assets/stylesheets/plum/control_panel.css")
9
- FileUtils.cp(source, destination)
10
- puts "Packaged #{destination.relative_path_from(Rails.root)}"
7
+ namespace :plum do
8
+ desc "Build Tailwind and copy the result into Plum's packaged stylesheet"
9
+ task build_styles: :environment do
10
+ system(Rails.root.join("bin/rails").to_s, "tailwindcss:build", exception: true)
11
+ source = Rails.root.join("app/assets/builds/tailwind.css")
12
+ destination = Rails.root.join("app/assets/stylesheets/plum/control_panel.css")
13
+ FileUtils.cp(source, destination)
14
+ puts "Packaged #{destination.relative_path_from(Rails.root)}"
15
+ end
11
16
  end
12
17
  end