thecore_generators 3.8.0 → 3.13.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 43638f50febe1bf078edcf5e5490c33709065db0a522f2b14a659c9ae19954ab
4
- data.tar.gz: 368b0b26a79a4a5be2ccc394e0dd4d64061efb1c66aa099d124432a1455e4230
3
+ metadata.gz: 146693a69bac954bb16fae029f76d60a5f92d2702f90d1cfda0b19e8a4e1a796
4
+ data.tar.gz: 728d2a12604ebb33b6efa6c7e27e33f7b086e3f61f7042492e5db0d4f9772b6c
5
5
  SHA512:
6
- metadata.gz: a0a7cb2fc271fe3f495ad2a4234ba338fc9e9b50f8309e2aefc593777d85fc8fa5b5146698f7000009a2da0552e1a89cd296e680a0003c99f6bca09e1b962e7e
7
- data.tar.gz: 7495d392354b1f1834b8fba5117449f3b9383aa6df89f4bd637c9044bc7c3357f2136d316385b89057b2a851047887df7c49f94db5a7aa97641a91189ea05510
6
+ metadata.gz: 738f18d403082dedbbfc4ce40a3376bc03c5057c6789a63436114bf135e262e6127e201c44a65c7ce76a0423eaab0cc198705d47501057d855c96b634ba1ec33
7
+ data.tar.gz: 3559b1310d5d59046158ec81232bc8b86a527c05130c605b571589a65f11c23566b119029d9f36a35b985351b13d31306c66acfbb2f5ee281d6b259a95b2dcd7
data/README.md CHANGED
@@ -25,10 +25,22 @@ thecore:check_practices` (Scaffold Files + Models + Actions, `--fix` included) a
25
25
  **Phase 3's App application template** (porting `createApp.js`) is complete as of this release —
26
26
  both the core (`lib/templates/app_template.rb`: Rails app + Gemfile stack + vendor placeholders,
27
27
  thecore_generators#17) and devcontainer/CI/CLAUDE.md asset fetching from the thecore repo's own
28
- samples (thecore_generators#18) have shipped. The `thecore:atom` generator and a Collection
29
- Action generator remain deferred to a future session (ADR 0005). See
28
+ samples (thecore_generators#18) have shipped. See
30
29
  [ADR 0005](https://github.com/gabrieletassoni/thecore/blob/release/3/docs/adr/0005-app-template-scoped-to-rails-new-m-assets-sourced-from-thecore-samples.md)
31
- in the thecore repo for the full design.
30
+ in the thecore repo for the full design. **The Collection Action generator** (`rails generate
31
+ thecore:collection_action`, thecore_generators#21) is also complete as of this release — see
32
+ [ADR 0006](https://github.com/gabrieletassoni/thecore/blob/master/docs/adr/0006-atom-generator-dual-ci-manual-submodule-wiring-collection-action-reuses-existing-infra.md)
33
+ in the thecore repo (`master`, thecore's actual default branch — unlike this gem's own
34
+ `release/3`; the ADR 0001-0005 links above predate that distinction being double-checked; note
35
+ that as of this gem's 3.11.0 release the ADR 0006 commit exists only in a local `thecore`
36
+ checkout, not yet pushed to `origin/master` — same class of operational sequencing issue
37
+ `CLAUDE.md` documents for the App template's samples fetch). **`thecore:atom`**
38
+ (`rails generate thecore:atom NAME`, thecore_generators#20/#22) is feature-complete as of this
39
+ release, including its `CLAUDE.md` fetch from thecore's own `samples/ATOM_CLAUDE.md` — but that
40
+ fetch shares the exact same not-yet-pushed caveat as ADR 0006/the App template's own samples
41
+ fetch above: it 404s against the real default GitHub URL until `thecore`'s `master` actually
42
+ carries the commit that added `samples/ATOM_CLAUDE.md`. Phase 4's one remaining piece is
43
+ documenting both new generators in the thecore repo's `GUIDE.md`/`WALKTHROUGH.md` (thecore#19).
32
44
 
33
45
  ### What `rails generate model`/`rails generate migration` do now
34
46
 
@@ -207,6 +219,92 @@ templates exactly:
207
219
  XHR `GET` here vs. `fetch` there), and the view adds a `form_with(..., method: :patch)` for
208
220
  the PATCH half of the example.
209
221
 
222
+ ### `rails generate thecore:atom NAME`
223
+
224
+ A Ruby port of `thecore_code_extension`'s `createATOM.js` — produces a complete, working ATOM
225
+ (a Rails engine, the Thecore unit of modular functionality) from a terminal, no VS Code
226
+ required:
227
+
228
+ ```bash
229
+ rails generate thecore:atom tcp_debugger
230
+ ```
231
+
232
+ Unlike every other generator in this gem, `thecore:atom` takes **no `--atom=NAME` option** —
233
+ creating a *new* ATOM only ever makes sense from a host app's own root, so it doesn't include
234
+ `Thecore::Generators::AtomAware` at all. It fails fast with a clear error if `vendor/submodules/`
235
+ doesn't exist yet in the current app, if `NAME` isn't a valid gem name (lowercase letters,
236
+ digits, underscores, hyphens, starting with a letter), or if `vendor/submodules/<NAME>` already
237
+ exists — the last one matters because `rails plugin new`'s own `-f` (force) flag would otherwise
238
+ silently overwrite an existing ATOM with the same name.
239
+
240
+ It prompts (interactively, or via `--summary=`/`--description=`/`--author=`/`--email=`/`--url=`
241
+ with `--non-interactive`) for the same five fields `createATOM.js` always has, then:
242
+
243
+ - Shells to `rails plugin new <path> -fG --skip-gemfile-entry --skip-hotwire --full` (via
244
+ `bundle exec`, for robust gem resolution regardless of environment), producing the standard
245
+ Rails engine skeleton.
246
+ - Asks (`yes?`-style, default yes; `--skip-api-admin-deps` in non-interactive mode) whether to
247
+ add `model_driven_api`/`thecore_ui_rails_admin` as dependencies — at this gem's own ADR 0001
248
+ floor (`~> 3.9`/`~> 3.8`), not `createATOM.js`'s stale `~> 3.1`/`~> 3.2`. Declining adds
249
+ neither; the Scaffold Files/directories below are created either way.
250
+ - Creates the same Scaffold Files/directories `createATOM.js` always has: `db/migrate`, the
251
+ API/RailsAdmin concern directories, `config/initializers`, `config/locales`,
252
+ `lib/root_actions`/`member_actions`/`collection_actions`, JS/CSS asset directories, the
253
+ RailsAdmin main view directory, `.github/workflows` — plus `after_initialize.rb`/`assets.rb`/
254
+ `abilities.rb`, `db/seeds.rb`, and `en.yml`/`it.yml` locale files, all with the same skeleton
255
+ content `createATOM.js` already produces.
256
+ - Generates **both** `.github/workflows/gempush.yml` and `.gitlab-ci.yml` unconditionally — no
257
+ hosting-profile prompt (see ADR 0006 for why: which git host a developer pushes to is a
258
+ per-developer choice this ecosystem already treats as generic). `gempush.yml`'s two
259
+ long-standing bugs are fixed here (a broken `awk` pipeline computing the version string; a
260
+ `version_exists` check referenced in `if:` conditions but never actually set, so the
261
+ tag/publish steps have never run for any ATOM generated this way). The gemspec's
262
+ `allowed_push_host` stays hardcoded to `https://rubygems.org`.
263
+ - Rewrites the gemspec's authors/email/homepage/summary/description/metadata fields via targeted
264
+ in-memory substitutions (not `createATOM.js`'s own blind full-file line rewrite, which —
265
+ verified directly against a current `rails plugin new --full` gemspec — silently drops the
266
+ gem's own `rails` dependency when adding the two Thecore ones; this appends instead of
267
+ replacing).
268
+ - `git init`s the new ATOM directory with one initial commit, then **logs** (never runs) the
269
+ exact follow-up commands to create a remote and `git submodule add` it into the host app —
270
+ worded generically, no GitHub/GitLab assumption (ADR 0006: automating a remote-repo-creating,
271
+ credential-dependent step isn't something this generator does on your behalf).
272
+ - Adds `gem "<name>", path: "vendor/submodules/<name>"` to the host app's own `Gemfile` (via
273
+ Thor's own `gem` action) — skipped, with a warning, if the Gemfile already declares a gem with
274
+ that name (the same real collision the `vendor/submodules/<NAME>` guard above protects against,
275
+ one layer up: a duplicate entry for the same gem breaks the next `bundle install`). Does
276
+ **not** run `bundle install` — same as every other generator in this gem that touches a
277
+ Gemfile.
278
+ - Fetches a `CLAUDE.md` skeleton from `thecore`'s own `samples/ATOM_CLAUDE.md` and writes it as
279
+ the new ATOM's `CLAUDE.md`, via `Thecore::Generators::SampleFetcher` — a shared module using
280
+ the exact same `THECORE_SAMPLES_SOURCE` override mechanism the App template's own asset fetch
281
+ already established (`ENV["THECORE_SAMPLES_SOURCE"]`, defaulting to `thecore`'s `master`
282
+ branch; http(s) fetched over the network, anything else read as a local directory; fails fast
283
+ with a clear message naming the file/source/error on failure). Like the App template's own
284
+ `CLAUDE.md`/`.gitlab-ci.yml` fetches, this 404s against the real default URL until `thecore`'s
285
+ `master` actually carries the commit that added `samples/ATOM_CLAUDE.md` — see the caveat
286
+ above.
287
+
288
+ ### `rails generate thecore:collection_action NAME`
289
+
290
+ The third sibling to `thecore:root_action`/`thecore:member_action` — structurally identical
291
+ (same shared `AtomAware`/`CompanionFiles`/`ActionCompanion` modules, same file layout, same
292
+ `--atom=NAME`/idempotent-re-run behavior). Unlike Root/Member, there's no
293
+ `addCollectionAction.js` this ports — `checkPractices.js` audited `collection_actions` but
294
+ nothing ever generated them, so this is new, not a port:
295
+
296
+ ```bash
297
+ rails generate thecore:collection_action my_action
298
+ ```
299
+
300
+ Its own `templates/action.rb.tt` mirrors Root Action's simplicity — `add_action "my_action",
301
+ :base, :collection do ... end`, a minimal GET/JSON example with an `ActivityLogChannel`
302
+ broadcast — rather than the more complex, hand-written `save_filters.rb`/`load_filters.rb`
303
+ pattern already living in `thecore_ui_rails_admin`: a generator's starter template exists to be
304
+ customized from a simple base, not to demonstrate every RailsAdmin `:collection` feature. A
305
+ collection action operates against a model's whole index (all records), as distinct from a
306
+ member action (one record) or a root action (global, no model scope at all).
307
+
210
308
  ### `rails thecore:check_practices`
211
309
 
212
310
  A Ruby port of `thecore_code_extension`'s `checkPractices.js` — audits **Scaffold Files**
@@ -237,17 +335,15 @@ Flags combine freely, e.g. `rails thecore:check_practices -- --atom=my_atom --fi
237
335
  `cattr_accessor :json_attrs` for `Api::`, `rails_admin do` for `RailsAdmin::`). Not fixable —
238
336
  regenerating over an existing, hand-edited concern could clobber real customization.
239
337
  - **Actions** — scans `root_actions`, `member_actions`, and `collection_actions` (in `lib/` for
240
- an ATOM, `config/` for the host app), with the same rules for all three:
241
- `collection_actions` is scanned even though no generator creates files there yet, since a
242
- hand-written one could already exist and go unreported otherwise. Reports missing/broken
243
- action-file markers (`RailsAdmin::Config::Actions.add_action`, `http_methods`never
244
- fixable), a missing companion view/JS/SCSS or one present but missing its own marker (a
245
- *missing* companion is fixable for `root_action`/`member_action`, by delegating straight to
246
- that generator's own template rendering never for `collection_action`, which has no
247
- generator to delegate to; an *existing* companion missing a marker is never fixable, same
248
- reasoning as Models), a missing `after_initialize.rb` require line (fixable, all three
249
- kinds), and a missing locale entry checked against **every** `*.yml` already present in the
250
- locales directory, not just `en`/`it` (fixable, all three kinds).
338
+ an ATOM, `config/` for the host app), with the same rules for all three. Reports
339
+ missing/broken action-file markers (`RailsAdmin::Config::Actions.add_action`, `http_methods`
340
+ never fixable), a missing companion view/JS/SCSS or one present but missing its own marker
341
+ (a *missing* companion is fixable for all three kinds `root_action`/`member_action`/
342
+ `collection_action` by delegating straight to that kind's own generator's template
343
+ rendering; an *existing* companion missing a marker is never fixable, same reasoning as
344
+ Models), a missing `after_initialize.rb` require line (fixable, all three kinds), and a
345
+ missing locale entry checked against **every** `*.yml` already present in the locales
346
+ directory, not just `en`/`it` (fixable, all three kinds).
251
347
 
252
348
  Default output is human-readable text grouped by file; `--json` emits
253
349
  `{ "violations": [{ "file", "line", "message", "severity", "fixable", "code" }] }` — `code` is
@@ -255,9 +351,10 @@ a stable identifier (e.g. `missing_after_initialize`, `orphan_api_include`,
255
351
  `missing_companion_view`) a future consumer can filter on without depending on `message` text.
256
352
  `--fix` applies every fixable violation in one pass with no confirmation of its own — whoever
257
353
  passes it has already decided — then re-scans and reports/exits based on whatever violations
258
- remain (so a violation this run can't fix, e.g. a `collection_action` companion, still shows up
259
- after `--fix`). The task exits non-zero whenever any violation remains, zero otherwise, so it's
260
- usable as a CI gate either with or without `--fix`.
354
+ remain (so a violation this run can't fix, e.g. an action file's own broken
355
+ `RailsAdmin::Config::Actions.add_action` marker, still shows up after `--fix`). The task exits
356
+ non-zero whenever any violation remains, zero otherwise, so it's usable as a CI gate either
357
+ with or without `--fix`.
261
358
 
262
359
  ### Application Template (`rails new -m`) (thecore_generators#17/#18)
263
360
 
@@ -294,9 +391,12 @@ for the full design and why the two stay separate).
294
391
  - **Devcontainer/CI/CLAUDE.md**, fetched from the `thecore` repo's own `samples/` (single source
295
392
  of truth, not duplicated here) and written unconditionally, overwriting whatever the bootstrap
296
393
  "Setup Devcontainer" step created: `.devcontainer/*` (base image, plugin mounts, `gh`/`glab`
297
- CLI config mounts commented out by default), `.gitlab-ci.yml` (build/test/lint/deploy, no
298
- customer-specific paths), and `CLAUDE.md` (universal sections only, project-specific sections
299
- left as TODO placeholders). The fetch location is one overridable point,
394
+ CLI config mounts commented out by default), **both** `.gitlab-ci.yml` and
395
+ `.github/workflows/ci.yml` (build/test/lint/deploy, no customer-specific paths no hosting-
396
+ profile prompt, same git-hosting-agnostic reasoning ADR 0006 established for `thecore:atom`'s
397
+ own dual-CI generation, extended here by ADR 0007/thecore_generators#25), and `CLAUDE.md`
398
+ (universal sections only, project-specific sections left as TODO placeholders). The fetch
399
+ location is one overridable point,
300
400
  `ENV["THECORE_SAMPLES_SOURCE"]`, defaulting to the raw GitHub URL for `thecore`'s `samples/`
301
401
  on `master` (`thecore`'s actual default branch).
302
402
  - **The standard installer chain** (`devise:install`, `rails_admin:install`, `active_storage:
@@ -304,8 +404,16 @@ for the full design and why the two stay separate).
304
404
  each preceded by the necessary `bundle install`) is genuinely optional, gated behind an
305
405
  interactive prompt (`yes?`, wrapped in `after_bundle` so it only ever runs once the gems
306
406
  above are actually bundled) — a developer bootstrapping without network access can decline
307
- and run these by hand later. There is no non-interactive/unattended flag for this in the
308
- current version (tracked as a future improvement, not silently missing).
407
+ and run these by hand later.
408
+ - **Non-interactive/unattended mode**: set `THECORE_APP_TEMPLATE_NON_INTERACTIVE` (any non-blank
409
+ value) to bypass the installer-chain prompt above. When set, `THECORE_APP_TEMPLATE_RUN_INSTALLERS`
410
+ (`"true"` or `"false"`) is required — the template aborts with a clear message if it's missing
411
+ or unrecognized, rather than guessing. Example:
412
+ ```bash
413
+ THECORE_APP_TEMPLATE_NON_INTERACTIVE=1 THECORE_APP_TEMPLATE_RUN_INSTALLERS=true \
414
+ rails new myapp --database=postgresql --asset-pipeline=sprockets \
415
+ -m https://raw.githubusercontent.com/gabrieletassoni/thecore_generators/release/3/lib/templates/app_template.rb
416
+ ```
309
417
 
310
418
  ## Installation
311
419
 
@@ -1,4 +1,5 @@
1
1
  require "generators/thecore/workspace_context"
2
+ require "generators/thecore/tty_detection"
2
3
 
3
4
  module Thecore
4
5
  module Generators
@@ -114,7 +115,7 @@ module Thecore
114
115
  # includer was explicitly told not to prompt via --non-interactive) -
115
116
  # default straight to has_many, per ADR 0003.
116
117
  def interactive_association_prompt?
117
- !options[:non_interactive] && $stdin.tty? && $stdout.tty?
118
+ !options[:non_interactive] && Thecore::Generators::TtyDetection.real_tty?
118
119
  end
119
120
 
120
121
  def concern_path_for(target_class_name)
@@ -0,0 +1,481 @@
1
+ require "rails/generators/named_base"
2
+ require "generators/thecore/tty_detection"
3
+ require "generators/thecore/sample_fetcher"
4
+ require "shellwords"
5
+ require "yaml"
6
+
7
+ module Thecore
8
+ module Generators
9
+ # `rails generate thecore:atom NAME` (thecore_generators#20, per ADR 0006 in the
10
+ # thecore repo) — a Ruby port of thecore_code_extension's createATOM.js, producing
11
+ # a complete, working ATOM end-to-end from a terminal: no VS Code, no extension
12
+ # required.
13
+ #
14
+ # Unlike every other generator in this gem, AtomGenerator does NOT include
15
+ # Thecore::Generators::AtomAware and takes no --atom=NAME option — creating a
16
+ # *new* ATOM is only ever a host-app-root operation (it makes no sense to run
17
+ # "from inside" an ATOM that doesn't exist yet). destination_root therefore stays
18
+ # fixed at the host app root for the whole generator; every path this class writes
19
+ # is expressed relative to that root via #atom_root ("vendor/submodules/<name>"),
20
+ # not via AtomAware's destination_root-redirection trick.
21
+ #
22
+ # Two Gemfiles are in play here — the host app's own (destination_root/Gemfile)
23
+ # and the freshly-scaffolded ATOM's own (vendor/submodules/<name>/Gemfile) — which
24
+ # is why Rails::Generators::Actions#gem (used once, in #add_gem_to_host_gemfile)
25
+ # can't be reused for the ATOM's own Gemfile: #gem's own implementation always
26
+ # writes through `in_root { ... }`, and Thor::Actions#in_root is hardcoded to
27
+ # `@destination_stack.first` (the *original* destination_root), not whatever
28
+ # #inside might currently have pushed — so it can never be redirected to a nested
29
+ # path. The ATOM's own Gemfile is instead mutated via plain #append_to_file calls
30
+ # against an explicit relative path.
31
+ class AtomGenerator < Rails::Generators::NamedBase
32
+ class_option :non_interactive, type: :boolean, default: false,
33
+ desc: "Skip all interactive prompts; every required value must be supplied " \
34
+ "via --summary/--description/--author/--email/--url, and the " \
35
+ "API/Admin dependency choice defaults to included unless " \
36
+ "--skip-api-admin-deps is also passed"
37
+ class_option :summary, type: :string, default: nil,
38
+ desc: "The ATOM's one-line summary (required with --non-interactive)"
39
+ class_option :description, type: :string, default: nil,
40
+ desc: "The ATOM's longer description (required with --non-interactive)"
41
+ class_option :author, type: :string, default: nil,
42
+ desc: "The ATOM's author name (required with --non-interactive)"
43
+ class_option :email, type: :string, default: nil,
44
+ desc: "The ATOM author's email (required with --non-interactive)"
45
+ class_option :url, type: :string, default: nil,
46
+ desc: "The ATOM's homepage URL (required with --non-interactive)"
47
+ class_option :skip_api_admin_deps, type: :boolean, default: false,
48
+ desc: "Don't add model_driven_api/thecore_ui_rails_admin as dependencies " \
49
+ "(only consulted with --non-interactive; interactively this is its " \
50
+ "own yes/no prompt, default yes)"
51
+
52
+ source_root File.expand_path("templates", __dir__)
53
+
54
+ # Validated the same way as createATOM.js's own six prompts: every field must
55
+ # be present; email must contain "@"; url must start with "http". Order matches
56
+ # the original prompt sequence.
57
+ REQUIRED_STRING_FIELDS = {
58
+ "summary" => ->(v) { !v.to_s.strip.empty? },
59
+ "description" => ->(v) { !v.to_s.strip.empty? },
60
+ "author" => ->(v) { !v.to_s.strip.empty? },
61
+ "email" => ->(v) { v.to_s.include?("@") },
62
+ "url" => ->(v) { v.to_s.start_with?("http") },
63
+ }.freeze
64
+
65
+ # This gem's own ADR 0001 floor (matching the App template's already-corrected
66
+ # versions) — not createATOM.js's stale "~> 3.1"/"~> 3.2".
67
+ MODEL_DRIVEN_API_VERSION = "~> 3.9"
68
+ THECORE_UI_RAILS_ADMIN_VERSION = "~> 3.8"
69
+
70
+ # Faithful port of templates/createATOM/after_initialize.rb /
71
+ # templates/createATOM/assets.rb in thecore_code_extension — static content,
72
+ # no per-ATOM interpolation, so these are plain constants rather than .tt files.
73
+ AFTER_INITIALIZE_CONTENT = <<~RUBY
74
+ Rails.application.configure do
75
+ config.after_initialize do
76
+ # For example, it can be used to load a root action defined in lib, for example:
77
+ # require 'root_actions/tcp_debug'
78
+ end
79
+ end
80
+ RUBY
81
+
82
+ ASSETS_CONTENT = <<~RUBY
83
+ # PLEASE, uncomment if needed.
84
+ # For Example: in the case there's a root action called tcp_debug, add the following lines to include css and javascripts for auto loading:
85
+ # Rails.application.config.assets.precompile += %w(
86
+ # main_tcp_debug.js
87
+ # main_tcp_debug.css
88
+ # )
89
+ RUBY
90
+
91
+ # Faithful port of createATOM.js's addCICDFiles gempush.yml, with its two
92
+ # long-standing bugs fixed (thecore_generators#20 acceptance criteria): the awk
93
+ # pipeline computing the version string had a stray, unmatched `)` instead of a
94
+ # closing `}'`; and `version_exists` was referenced in two steps' `if:`
95
+ # conditions but never actually set anywhere (the "check" step only ever did
96
+ # `echo $?`), so those two steps have never run for any ATOM generated this way.
97
+ # Fixed here by actually writing `version_exists` to $GITHUB_ENV based on
98
+ # whether a git tag for the computed version already exists - kept as an
99
+ # `env.*`-style if: condition (not switched to `steps.*.outputs.*`) to stay as
100
+ # close to the original's structure as the fix allows.
101
+ GEMPUSH_YML_CONTENT = <<~YAML
102
+ name: Ruby Gem
103
+ on: push
104
+ jobs:
105
+ build:
106
+ name: Build + Publish
107
+ runs-on: ubuntu-latest
108
+ steps:
109
+ - uses: actions/checkout@v3
110
+ - name: Check if version already exists
111
+ run: |
112
+ version=$(grep -oP 'VERSION = "\\K[^"]+' lib/*/version.rb | awk -F'.' '{print $1"."$2"."$3}')
113
+ git fetch --unshallow --tags
114
+ if git rev-parse "$version" >/dev/null 2>&1; then
115
+ echo "version_exists=true" >> "$GITHUB_ENV"
116
+ else
117
+ echo "version_exists=false" >> "$GITHUB_ENV"
118
+ fi
119
+ - name: Set git tag
120
+ if: env.version_exists == 'false'
121
+ run: |
122
+ git config --local user.email "noreply@alchemic.it"
123
+ git config --local user.name "AlchemicIT"
124
+ version=$(grep -oP 'VERSION = "\\K[^"]+' lib/*/version.rb | awk -F'.' '{print $1"."$2"."$3}')
125
+ git tag -a $version -m "Version $version"
126
+ git push --tags
127
+ - name: Publish to RubyGems
128
+ if: env.version_exists == 'false'
129
+ env:
130
+ GEM_HOST_API_KEY: ${{secrets.RUBYGEMS_AUTH_TOKEN}}
131
+ run: |
132
+ mkdir -p $HOME/.gem
133
+ touch $HOME/.gem/credentials
134
+ chmod 0600 $HOME/.gem/credentials
135
+ printf -- "---\\n:rubygems_api_key: ${GEM_HOST_API_KEY}\\n" > $HOME/.gem/credentials
136
+ gem build *.gemspec
137
+ gem push *.gem
138
+ YAML
139
+
140
+ SCAFFOLD_DIRECTORIES = %w[
141
+ db/migrate
142
+ app/models/concerns/api
143
+ app/models/concerns/rails_admin
144
+ config/initializers
145
+ config/locales
146
+ lib/root_actions
147
+ lib/member_actions
148
+ lib/collection_actions
149
+ app/assets/javascripts
150
+ app/assets/stylesheets
151
+ app/views/rails_admin/main
152
+ .github/workflows
153
+ ].freeze
154
+
155
+ # Gem-name convention (matches real examples in this very ecosystem, including
156
+ # the hyphenated `thecore-spot-overrides`) - lowercase, starting with a letter,
157
+ # letters/digits/underscore/hyphen only. Deliberately stricter than NamedBase's
158
+ # own permissive `name` parsing: without this, a name containing a space or
159
+ # shell metacharacter would flow straight into the unescaped shell-out in
160
+ # #create_rails_engine (word-splitting or, worse, executing arbitrary shell),
161
+ # and a namespaced name (`acme/widget`) would desync #atom_root (which uses
162
+ # only #file_name, "widget") from #class_name (which uses the full namespaced
163
+ # "Acme::Widget"), breaking the abilities.rb template.
164
+ NAME_PATTERN = /\A[a-z][a-z0-9_-]*\z/
165
+
166
+ def validate_atom_name!
167
+ return if file_name.match?(NAME_PATTERN)
168
+
169
+ raise Thor::Error,
170
+ "'#{file_name}' is not a valid ATOM name - use lowercase letters, digits, " \
171
+ "underscores, or hyphens, starting with a letter (e.g. tcp_debugger)."
172
+ end
173
+
174
+ def ensure_submodules_dir_exists!
175
+ return if File.directory?(File.join(destination_root, "vendor", "submodules"))
176
+
177
+ raise Thor::Error,
178
+ "vendor/submodules does not exist under #{destination_root} - run `rails generate " \
179
+ "thecore:atom` from a Thecore host app root that already has it (see the App " \
180
+ "application template, thecore_generators#17/#18) before creating an ATOM."
181
+ end
182
+
183
+ # `rails plugin new`'s own `-f` (force) suppresses its normal file-collision
184
+ # prompt, so without this guard a name colliding with an existing ATOM (a real
185
+ # scenario in this very host app: `vendor/submodules/mytask` already exists)
186
+ # would silently overwrite that ATOM's working tree with freshly-generated
187
+ # plugin skeleton files - caught during review, reproduced directly against
188
+ # this app's own real `mytask` submodule.
189
+ def ensure_atom_does_not_already_exist!
190
+ return unless File.exist?(File.join(destination_root, atom_root))
191
+
192
+ raise Thor::Error,
193
+ "#{atom_root} already exists - choose a different name, or remove it first if you " \
194
+ "really mean to regenerate it."
195
+ end
196
+
197
+ def validate_non_interactive_options!
198
+ return unless effectively_non_interactive?
199
+
200
+ missing = REQUIRED_STRING_FIELDS.reject { |key, valid| valid.call(options[key]) }.keys
201
+ return if missing.empty?
202
+
203
+ raise Thor::Error,
204
+ "Missing required flags for --non-interactive: #{missing.map { |k| "--#{k}" }.join(", ")}"
205
+ end
206
+
207
+ def collect_metadata
208
+ @summary = required_field("summary", "the summary of the ATOM, i.e. TCP Debugger")
209
+ @description = required_field("description", "the description of the ATOM, i.e. TCP Debugger")
210
+ @author = required_field("author", "the author of the ATOM, i.e. Alchemic IT")
211
+ @email = required_field("email", "the email of the ATOM author")
212
+ @url = required_field("url", "the url of the ATOM")
213
+ end
214
+
215
+ def collect_api_admin_deps_choice
216
+ @include_api_admin_deps =
217
+ if effectively_non_interactive?
218
+ !options[:skip_api_admin_deps]
219
+ else
220
+ ask(
221
+ "Include model_driven_api/thecore_ui_rails_admin as dependencies?",
222
+ default: "yes", limited_to: %w[yes no]
223
+ ) == "yes"
224
+ end
225
+ end
226
+
227
+ # Faithful to createATOM.js's own `rails plugin new "<path>" -fG
228
+ # --skip-gemfile-entry --skip-hotwire --full` invocation, cwd'd to
229
+ # vendor/submodules (via #inside, which - unlike file-writing actions below -
230
+ # genuinely changes the OS process's cwd for #run's sake). `bundle exec` is a
231
+ # deliberate addition over the JS original (which shells a bare `rails`,
232
+ # relying entirely on whatever's globally on PATH): it makes gem resolution
233
+ # explicit rather than incidental, and costs nothing in the real host-app case,
234
+ # where cwd already sits under that app's own Gemfile either way. `file_name`
235
+ # is Shellwords-escaped even though #validate_atom_name! already restricts it
236
+ # to a shell-safe character set - defense in depth, matching the same care
237
+ # #git_init_and_commit already takes with the free-text @author/@email.
238
+ def create_rails_engine
239
+ inside("vendor/submodules") do
240
+ run("bundle exec rails plugin new #{Shellwords.escape(file_name)} " \
241
+ "-fG --skip-gemfile-entry --skip-hotwire --full",
242
+ abort_on_failure: true)
243
+ end
244
+ end
245
+
246
+ # #create_file already `mkdir_p`s its own parent directory, so a separate
247
+ # #empty_directory call per entry would just be redundant work (and, for the
248
+ # 3 of these 12 that #create_scaffold_files/#create_locale_files/
249
+ # #create_ci_files populate with a real file moments later, entirely so).
250
+ def create_scaffold_directories
251
+ SCAFFOLD_DIRECTORIES.each { |dir| create_file(File.join(atom_root, dir, ".keep")) }
252
+ end
253
+
254
+ def create_scaffold_files
255
+ create_file File.join(atom_root, "config/initializers/after_initialize.rb"), AFTER_INITIALIZE_CONTENT
256
+ create_file File.join(atom_root, "config/initializers/add_to_db_migration.rb"),
257
+ "Rails.application.config.paths['db/migrate'] << File.expand_path(\"../../db/migrate\", __dir__)\n"
258
+ create_file File.join(atom_root, "config/initializers/assets.rb"), ASSETS_CONTENT
259
+ template "abilities.rb.tt", File.join(atom_root, "config/initializers/abilities.rb")
260
+ template "seeds.rb.tt", File.join(atom_root, "db/seeds.rb")
261
+ end
262
+
263
+ # Bare "en:\n"/"it:\n", matching the exact convention
264
+ # Thecore::Generators::CompanionFiles#write_action_locale_entries! already uses
265
+ # for this identical "no locale file yet" bootstrap case elsewhere in this gem
266
+ # - not the YAML-document-with-null-value shape `{"en"=>nil}.to_yaml` produces,
267
+ # which is equivalent once parsed but an unnecessary second on-disk convention
268
+ # for the same thing.
269
+ def create_locale_files
270
+ create_file File.join(atom_root, "config/locales/en.yml"), "en:\n"
271
+ create_file File.join(atom_root, "config/locales/it.yml"), "it:\n"
272
+ end
273
+
274
+ def create_ci_files
275
+ create_file File.join(atom_root, ".github/workflows/gempush.yml"), GEMPUSH_YML_CONTENT
276
+
277
+ gitlab_ci = {
278
+ "image" => "gabrieletassoni/vscode-devcontainers-thecore:3",
279
+ "variables" => {
280
+ "GITLAB_EMAIL" => @email,
281
+ "GITLAB_USER_NAME" => @author,
282
+ "GITLAB_GEM_REPO_TARGET" => 'https://${GEM_HOST}/',
283
+ "GEM_HOST_API_KEY" => '${GEMS_REPO_CREDENTIALS}',
284
+ },
285
+ "stages" => %w[build release],
286
+ "build_gem" => {
287
+ "rules" => [{ "if" => "$CI_COMMIT_TAG", "when" => "never" }, { "when" => "always" }],
288
+ "stage" => "build",
289
+ "script" => ["/usr/bin/gem-compile.sh"],
290
+ },
291
+ }
292
+ create_file File.join(atom_root, ".gitlab-ci.yml"), gitlab_ci.to_yaml
293
+ end
294
+
295
+ def setup_gemfile
296
+ gemfile_addition = +"\ngem 'pg'\n"
297
+ gemfile_addition << "gem 'model_driven_api', '#{MODEL_DRIVEN_API_VERSION}'\n" \
298
+ "gem 'thecore_ui_rails_admin', '#{THECORE_UI_RAILS_ADMIN_VERSION}'\n" if @include_api_admin_deps
299
+ append_to_file File.join(atom_root, "Gemfile"), gemfile_addition
300
+
301
+ return unless @include_api_admin_deps
302
+
303
+ append_to_file File.join(atom_root, "lib", "#{file_name}.rb"),
304
+ "\nrequire 'model_driven_api'\nrequire 'thecore_ui_rails_admin'\n"
305
+ end
306
+
307
+ # One read, one write, one pass of in-memory substitutions - not createATOM.js's
308
+ # own blind "rewrite every line, branching on substring" approach (the current
309
+ # `rails plugin new --full` gemspec template, verified directly against a real
310
+ # generation rather than assumed from the JS original, has moved on
311
+ # significantly: new `homepage_uri`/`license` lines, different summary/
312
+ # description wording - a full-file line-by-line port would silently stop
313
+ # matching several of these fields), and not 8 separate #gsub_file calls
314
+ # either (each its own full read-modify-write cycle against the same small
315
+ # file - wasted I/O for no benefit). One deliberate correctness fix over the
316
+ # original along the way: the JS's `.add_dependency` branch *replaces* the
317
+ # whole line, which happens to be `spec.add_dependency "rails", ...` in
318
+ # current Rails - silently dropping the gem's own Rails dependency entirely.
319
+ # This appends the two Thecore dependencies right after that line instead of
320
+ # replacing it.
321
+ def setup_gemspec
322
+ gemspec_path = File.join(atom_root, "#{file_name}.gemspec")
323
+ content = File.read(File.join(destination_root, gemspec_path))
324
+
325
+ content = content.sub(/^(\s*spec\.add_dependency\s+["']rails["'].*)$/) do
326
+ next Regexp.last_match(1) unless @include_api_admin_deps
327
+
328
+ "#{Regexp.last_match(1)}\n spec.add_dependency \"model_driven_api\", \"#{MODEL_DRIVEN_API_VERSION}\"\n" \
329
+ " spec.add_dependency \"thecore_ui_rails_admin\", \"#{THECORE_UI_RAILS_ADMIN_VERSION}\""
330
+ end
331
+ content = content.sub(/^\s*spec\.authors\s*=.*$/, " spec.authors = [#{@author.to_s.inspect}]")
332
+ content = content.sub(/^\s*spec\.email\s*=.*$/, " spec.email = [#{@email.to_s.inspect}]")
333
+ content = content.sub(/^\s*spec\.homepage\s*=.*$/, " spec.homepage = #{@url.to_s.inspect}")
334
+ content = content.sub(/^\s*spec\.summary\s*=.*$/, " spec.summary = #{@summary.to_s.inspect}")
335
+ content = content.sub(/^\s*spec\.description\s*=.*$/, " spec.description = #{@description.to_s.inspect}")
336
+ content = content.sub(/^\s*spec\.metadata\["allowed_push_host"\]\s*=.*$/,
337
+ ' spec.metadata["allowed_push_host"] = "https://rubygems.org"')
338
+ content = content.sub(/^\s*spec\.metadata\["source_code_uri"\]\s*=.*$/,
339
+ ' spec.metadata["source_code_uri"] = spec.homepage')
340
+ content = content.sub(/^\s*spec\.metadata\["changelog_uri"\]\s*=.*$/,
341
+ ' spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/master/CHANGELOG.md"')
342
+
343
+ create_file gemspec_path, content, force: true
344
+ end
345
+
346
+ # Completes this generator's scope (thecore_generators#22, ADR 0006): fetches
347
+ # thecore's own samples/ATOM_CLAUDE.md and writes it as the new ATOM's
348
+ # CLAUDE.md, via the shared Thecore::Generators::SampleFetcher - the same
349
+ # THECORE_SAMPLES_SOURCE mechanism the App template's own (separate, still
350
+ # independent - see sample_fetcher.rb's own header for why) asset fetch
351
+ # already established.
352
+ #
353
+ # A fetch failure here raises SystemExit (via Kernel#abort), not Thor::Error
354
+ # like every guard method above - a deliberate inconsistency, not an
355
+ # oversight: it matches the App template's own established convention for
356
+ # this exact class of failure (an external, this-run-only fetch, as opposed
357
+ # to a validation the generator could have caught before doing any real
358
+ # work), and the ticket's own acceptance criteria asks for "fail-fast abort,"
359
+ # not a Thor::Error. A failure here does leave the partially-generated
360
+ # vendor/submodules/<name> directory behind (rails plugin new/the Gemfile/
361
+ # gemspec edits already succeeded) - #ensure_atom_does_not_already_exist!
362
+ # then refuses a same-named retry until it's removed by hand; the abort
363
+ # message says so.
364
+ #
365
+ # NOTE: like the App template's own fetch of thecore/samples/CLAUDE.md
366
+ # before it, this 404s against the real default GitHub URL until thecore's
367
+ # `master` actually carries the commit that added samples/ATOM_CLAUDE.md
368
+ # (thecore#18) - as of this gem's 3.11.0 release that commit exists only in
369
+ # a local `thecore` checkout, not yet pushed. Same operational sequencing
370
+ # note as the App template's own CLAUDE.md section: push `thecore` before
371
+ # relying on the default in production.
372
+ def fetch_claude_md
373
+ Thecore::Generators::SampleFetcher.fetch_thecore_sample(
374
+ self, "ATOM_CLAUDE.md", File.join(atom_root, "CLAUDE.md"), label: "the thecore:atom generator"
375
+ )
376
+ end
377
+
378
+ # `-fG` (`rails plugin new`'s own force+skip-git flags) means no git repo and no
379
+ # .gitignore exist yet at this point - a deliberate gap in createATOM.js this
380
+ # ticket narrows, not fully closes (ADR 0006): a local, safe `git init` +
381
+ # initial commit, but remote creation and `git submodule add` stay a logged,
382
+ # human-run follow-up rather than something this generator automates. No
383
+ # .gitignore is written (out of this ticket's scope - see ADR 0006/the ticket's
384
+ # own acceptance criteria, which doesn't list one): verified directly that a
385
+ # fresh `rails plugin new --full` output has no log/tmp/sqlite artifacts yet to
386
+ # need ignoring - nothing has been bundled or run against the dummy app at this
387
+ # point, so the initial commit is clean regardless.
388
+ def git_init_and_commit
389
+ atom_path = File.join(destination_root, atom_root)
390
+ committed = inside(atom_root) do
391
+ run("git init -q -b master", abort_on_failure: true)
392
+ run("git add -A", abort_on_failure: true)
393
+ # Not abort_on_failure: a freshly-generated tree always has something to
394
+ # commit in normal use, but `git commit` failing (e.g. "nothing to
395
+ # commit", however that state arose) shouldn't kill the whole process via
396
+ # a raw, unexplained Kernel#abort when every file this generator actually
397
+ # promises has already been written successfully by this point. The
398
+ # result is still checked below, so a real failure changes what gets
399
+ # logged rather than being silently treated as success.
400
+ run(
401
+ "git -c user.name=#{Shellwords.escape(@author)} -c user.email=#{Shellwords.escape(@email)} " \
402
+ 'commit -q -m "Initial commit"',
403
+ abort_on_failure: false
404
+ )
405
+ end
406
+
407
+ if committed
408
+ say_status :next_steps, <<~MSG.strip, :yellow
409
+ #{file_name} is git-initialized locally with one commit, but has no remote yet. To finish wiring it in:
410
+ 1. Create a repository for it on the git host of your choice (GitHub, GitLab, ...)
411
+ 2. cd #{atom_path} && git remote add origin <remote-url> && git push -u origin master
412
+ 3. From this app's root: git submodule add <remote-url> vendor/submodules/#{file_name}
413
+ MSG
414
+ else
415
+ say_status :warning, <<~MSG.strip, :red
416
+ #{file_name} was git-initialized, but `git commit` did not succeed - it has no commit
417
+ yet. Check the output above, commit by hand once resolved, then follow the usual
418
+ steps to create a remote and `git submodule add` it into this app.
419
+ MSG
420
+ end
421
+ end
422
+
423
+ # Guards against the same name-collision scenario #ensure_atom_does_not_already_exist!
424
+ # protects the ATOM directory itself from: a host Gemfile that already
425
+ # declares a same-named gem (real in this very host app - `mytask` is
426
+ # resolved from a gem server today) would otherwise get a second, conflicting
427
+ # `gem "mytask", path: ...` line appended, and the next `bundle install`
428
+ # fails outright ("You cannot specify the same gem twice"). In the normal
429
+ # case (no prior entry) this is unreachable in practice anyway, since
430
+ # #ensure_atom_does_not_already_exist! already refuses a name whose
431
+ # vendor/submodules/<name> directory exists - kept as its own explicit check
432
+ # since a Gemfile entry and a vendor/submodules directory are two independent
433
+ # pieces of state that could in principle drift apart.
434
+ def add_gem_to_host_gemfile
435
+ gemfile_path = File.join(destination_root, "Gemfile")
436
+ if File.exist?(gemfile_path) && File.read(gemfile_path).match?(/^\s*gem\s+["']#{Regexp.escape(file_name)}["']/)
437
+ say_status :skip, "Gemfile already declares '#{file_name}' - not adding a second entry", :yellow
438
+ return
439
+ end
440
+
441
+ gem file_name, path: "vendor/submodules/#{file_name}"
442
+ end
443
+
444
+ private
445
+
446
+ def atom_root
447
+ File.join("vendor", "submodules", file_name)
448
+ end
449
+
450
+ # True whenever prompting for input isn't viable: --non-interactive was
451
+ # passed explicitly, or (via the shared Thecore::Generators::TtyDetection,
452
+ # also used by AssociationWiring's own `interactive_association_prompt?`)
453
+ # there's no real TTY behind stdin/stdout at all - a CI runner or a
454
+ # shelled-out child process that simply forgot the flag. Without this,
455
+ # #required_field's own `ask`-in-a-loop would spin forever re-prompting a
456
+ # stream that can never supply input, since a closed/EOF stdin makes Thor's
457
+ # `ask` return nil immediately.
458
+ def effectively_non_interactive?
459
+ options[:non_interactive] || !Thecore::Generators::TtyDetection.real_tty?
460
+ end
461
+
462
+ # Interactive: loop with Thor's own `ask` until the validator passes, echoing
463
+ # the same "not valid, try again" wording createATOM.js's input boxes used.
464
+ # Non-interactive (explicit or TTY-detected): already validated present by
465
+ # #validate_non_interactive_options! (a task method that always runs first),
466
+ # so this simply reads the option.
467
+ def required_field(key, prompt_hint)
468
+ return options[key] if effectively_non_interactive?
469
+
470
+ validate = REQUIRED_STRING_FIELDS.fetch(key)
471
+ loop do
472
+ value = ask("Enter #{prompt_hint}:")
473
+ return value if validate.call(value)
474
+
475
+ say_status :error, "The #{key} is not valid. Please try again.", :red
476
+ end
477
+ end
478
+
479
+ end
480
+ end
481
+ end
@@ -0,0 +1,16 @@
1
+ module Abilities
2
+ class <%= class_name %>
3
+ include CanCan::Ability
4
+ def initialize user
5
+ if user.present?
6
+ # Users' abilities
7
+ # Example: can :read, ModelName
8
+ # Example: can [:read, :create], ModelName
9
+ if user.admin?
10
+ # Admins' abilities
11
+ # Example: can :manage, :all
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1 @@
1
+ puts "Seeding Data into DB from <%= file_name %>"
@@ -0,0 +1,64 @@
1
+ require "rails/generators/named_base"
2
+ require "generators/thecore/atom_aware"
3
+ require "generators/thecore/companion_files"
4
+ require "generators/thecore/action_companion"
5
+
6
+ module Thecore
7
+ module Generators
8
+ # `rails generate thecore:collection_action NAME` (thecore_generators#21,
9
+ # per ADR 0006 in the thecore repo) — the third sibling to
10
+ # RootActionGenerator/MemberActionGenerator. Unlike those two, there is
11
+ # no prior thecore_code_extension JS command this ports (no
12
+ # `addCollectionAction.js` ever existed — collection_actions were only
13
+ # ever audited by check_practices, never generated), so its own
14
+ # `templates/action.rb.tt` deliberately mirrors RootActionGenerator's
15
+ # simplicity (a minimal GET/JSON example with an ActivityLogChannel
16
+ # broadcast) rather than the real, more complex hand-written
17
+ # `save_filters.rb`/`load_filters.rb` pattern already living in
18
+ # thecore_ui_rails_admin — a generator's starter template exists to be
19
+ # customized from a simple base, not to demonstrate every RailsAdmin
20
+ # :collection feature.
21
+ #
22
+ # Structurally identical to RootActionGenerator/MemberActionGenerator:
23
+ # same three includes, same thin task-method sequence (see
24
+ # ActionCompanion's own comment for why those can't be shared further),
25
+ # only `action_kind` and this class's own templates differ. No changes
26
+ # needed anywhere in AtomAware/CompanionFiles/ActionCompanion —
27
+ # `action_kind "collection_action"` alone is enough for placement
28
+ # (lib/collection_actions or config/collection_actions),
29
+ # pluralization, and validation wording to fall out correctly.
30
+ class CollectionActionGenerator < Rails::Generators::NamedBase
31
+ include Thecore::Generators::AtomAware
32
+ include Thecore::Generators::CompanionFiles
33
+ include Thecore::Generators::ActionCompanion
34
+
35
+ action_kind "collection_action"
36
+
37
+ source_root File.expand_path("templates", __dir__)
38
+
39
+ def validate_action_name!
40
+ validate_action_name_for_kind!
41
+ end
42
+
43
+ def create_action_file
44
+ template "action.rb.tt", action_file_path
45
+ end
46
+
47
+ def create_view_js_scss_companions
48
+ render_view_js_scss_companions!
49
+ end
50
+
51
+ def add_after_initialize_require
52
+ ensure_after_initialize_require!(require_line)
53
+ end
54
+
55
+ def add_assets_precompile_line
56
+ ensure_assets_precompile_line!(assets_precompile_line)
57
+ end
58
+
59
+ def add_locale_entries
60
+ write_action_locale_entries!(file_name, title_case_name)
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,13 @@
1
+ <%%= stylesheet_link_tag 'rails_admin/actions/<%= file_name %>' %>
2
+ <div class="card mb-3">
3
+ <div class="card-body">
4
+ <div class="response <%= file_name %>-response" id="<%= file_name %>-response">
5
+ </div>
6
+ <div class="loader d-none" id="<%= file_name %>-loader">
7
+ <div class="double-bounce1"></div>
8
+ <div class="double-bounce2"></div>
9
+ </div>
10
+ </div>
11
+ </div>
12
+ <button class="btn btn-primary" id="<%= file_name %>-id" data-url="<%%= rails_admin.<%= file_name %>_path %>">Click me</button>
13
+ <%%= javascript_include_tag "rails_admin/actions/<%= file_name %>" %>
@@ -0,0 +1,42 @@
1
+ var <%= action_name_camel_case %>Cable = null;
2
+ // If the <%= action_name_camel_case %>Function is already defined, then don't redefine it and don't attach it to the eventListener
3
+ if (typeof <%= action_name_camel_case %>Function !== 'function') {
4
+ function <%= action_name_camel_case %>Function(event) {
5
+ console.log('Hello from <%= file_name %>', event);
6
+ // Action Cable WebSocket connection only if <%= action_name_camel_case %>Cable is not already defined and valid
7
+ if (typeof <%= action_name_camel_case %>Cable !== 'object' || <%= action_name_camel_case %>Cable === null) {
8
+ <%= action_name_camel_case %>Cable = App.cable.subscriptions.create("ActivityLogChannel", {
9
+ connected() {
10
+ console.log("Connected to the channel:", this);
11
+ this.send({ message: '<%= file_name %> Client is connected', topic: "<%= file_name %>", namespace: "subscriptions" });
12
+ },
13
+ disconnected() {
14
+ console.log("<%= file_name %> Client Disconnected");
15
+ },
16
+ received(data) {
17
+ if(data["topic"] == "<%= file_name %>") {
18
+ console.log("<%= file_name %>", data);
19
+ document.getElementById('<%= file_name %>-response').innerHTML = data["message"];
20
+ }
21
+ }
22
+ });
23
+ }
24
+ // Send a message to the server
25
+ <%= action_name_camel_case %>Cable.send({ message: '<%= file_name %> Client is sending a message', topic: "<%= file_name %>", namespace: "subscriptions" });
26
+ // Attach a click event listener to the button which sends a fetch GET request and shows the response.
27
+ // The URL is read from the data-url attribute to avoid ERB interpolation in plain .js files.
28
+ document.getElementById('<%= file_name %>-id').addEventListener('click', function() {
29
+ var url = this.dataset.url;
30
+ document.getElementById('<%= file_name %>-loader').classList.remove('d-none');
31
+ fetch(url, { headers: { 'Accept': 'application/json' } })
32
+ .then(response => response.json())
33
+ .then(data => {
34
+ console.log(data);
35
+ document.getElementById('<%= file_name %>-response').innerHTML = data.message;
36
+ document.getElementById('<%= file_name %>-loader').classList.add('d-none');
37
+ });
38
+ });
39
+ }
40
+ }
41
+ // Attach the function to the eventListener
42
+ document.addEventListener('turbo:load', <%= action_name_camel_case %>Function);
@@ -0,0 +1,33 @@
1
+ RailsAdmin::Config::Actions.add_action "<%= file_name %>", :base, :collection do
2
+ # show_in_sidebar/show_in_navigation/breadcrumb_parent are deliberately not
3
+ # overridden here (unlike a root action) - RailsAdmin's own :collection
4
+ # defaults already do the right thing: sidebar-visible, not top-nav, and
5
+ # breadcrumbing back to the model's index page.
6
+ # This ensures the action only shows up for authorized users
7
+ visible? authorized?
8
+ # Have a look at https://fontawesome.com/v5/search for available icons
9
+ link_icon 'fas fa-file'
10
+ # The controller which will be used to compute the action and the REST verbs it will respond to
11
+ http_methods [:get]
12
+ # Adding the controller which is needed to compute calls from the ui
13
+ # This is a collection action: it runs against the whole model index (all
14
+ # records), not a single record (member) or globally (root) - @abstract_model
15
+ # is available here to scope the collection this action operates on.
16
+ controller do
17
+ proc do # This is needed because we need that this code is re-evaluated each time is called
18
+ if request.format.json?
19
+ # This is the code that is executed when the action is called
20
+ # It is executed in the context of the controller
21
+ # So you can access all the controller methods
22
+ # and instance variables
23
+ status = 200
24
+ message = "Hello World!"
25
+ # Note: ActivityLogChannel is expected to re-broadcast messages from the "messages" channel
26
+ ActionCable.server.broadcast("messages", { topic: :<%= file_name %>, status: status, message: message})
27
+ render json: {message: message}.to_json, status: status
28
+ else
29
+ # Renders the action.html.erb view for browser requests (HTML format)
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,38 @@
1
+ // Spinner
2
+ .loader {
3
+ width: 40px;
4
+ height: 40px;
5
+ position: relative;
6
+ margin: 0 auto;
7
+ }
8
+ .double-bounce1,
9
+ .double-bounce2 {
10
+ width: 100%;
11
+ height: 100%;
12
+ border-radius: 50%;
13
+ background-color: #333;
14
+ opacity: 0.6;
15
+ position: absolute;
16
+ top: 0;
17
+ left: 0;
18
+ animation: sk-bounce 2.0s infinite ease-in-out;
19
+ }
20
+ .double-bounce2 {
21
+ animation-delay: -1.0s;
22
+ }
23
+ @keyframes sk-bounce {
24
+ 0%,
25
+ 100% {
26
+ transform: scale(0.0);
27
+ }
28
+ 50% {
29
+ transform: scale(1.0);
30
+ }
31
+ }
32
+ // End Spinner
33
+ #<%= file_name %>-response {
34
+ border-radius: 1em;
35
+ display: flex;
36
+ flex-direction: column;
37
+ justify-content: center;
38
+ }
@@ -0,0 +1,68 @@
1
+ module Thecore
2
+ module Generators
3
+ # Fetches one asset from thecore's own samples/ directory, via one overridable
4
+ # point: ENV["THECORE_SAMPLES_SOURCE"], resolved fresh on every call (never
5
+ # memoized), defaulting to the real raw GitHub URL for thecore's samples/ on
6
+ # `master` (thecore's own default branch). An http(s) value is fetched over the
7
+ # network via Thor's `get`; anything else is treated as a local directory and
8
+ # read directly with `File.read` (this gem's own tests point it at a fixture,
9
+ # so the suite runs offline and deterministically). Blank-string-safe
10
+ # (`nil? || empty?`, not `||` alone — `ENV["X"] || default` would treat
11
+ # THECORE_SAMPLES_SOURCE="" as a real override). Always writes `force: true` —
12
+ # a clean overwrite, no interactive Thor conflict prompt. Fails fast, naming
13
+ # the file/source/underlying error, rather than letting a raw
14
+ # OpenURI::HTTPError propagate and leave the caller half-scaffolded.
15
+ #
16
+ # A plain module function, not a mixin — `get`/`create_file` are public
17
+ # Thor::Actions instance methods (verified directly against Thor's own
18
+ # source), so they're callable on any `actor` passed in. `abort` (Kernel's) is
19
+ # private, so it's invoked via `actor.send(:abort, ...)` rather than a direct
20
+ # call — the one place this differs from calling it bare on `self`.
21
+ #
22
+ # A trailing slash on THECORE_SAMPLES_SOURCE is tolerated (`.chomp("/")`) before
23
+ # building the http(s) URL - a natural way to write/copy a base URL, and one
24
+ # `lib/templates/app_template.rb`'s own independent copy does not guard against
25
+ # (its own plain string interpolation would request a double-slashed path,
26
+ # 404ing against most static hosts/CDNs); caught here during review and not
27
+ # backported there, since that copy is intentionally left untouched (see below).
28
+ #
29
+ # AtomGenerator uses this module directly (`require`d normally, like any other
30
+ # file in this gem). The App application template's own `fetch_thecore_sample`
31
+ # (lib/templates/app_template.rb) is a *separate*, deliberately self-contained
32
+ # copy, NOT switched to call this module — that generator's initial version
33
+ # of this comment claimed the reason was instance_eval/mixin incompatibility,
34
+ # which review correctly identified as wrong (a module function needs no
35
+ # inheritance/mixin relationship to its caller; TtyDetection is proof this
36
+ # already works the same way from an instance_eval'd context). The real
37
+ # reason is deployment, not syntax: the App template's primary real-world
38
+ # invocation is `rails new myapp -m https://raw.githubusercontent.com/.../app_template.rb`
39
+ # — Thor's `apply`/`instance_eval` fetches and evaluates *that one URL's
40
+ # content only*, with no mechanism to also pull in a sibling file from this
41
+ # gem's own repo the way a normal `require` would. At the moment that command
42
+ # runs there is no app yet, so nothing has installed `thecore_generators` as a
43
+ # dependency either — a `require "generators/thecore/sample_fetcher"` inside
44
+ # the template would only work by accident (a global gem install happening to
45
+ # already be on the load path), not by design. So the App template keeps its
46
+ # own independent copy, and this module is not a hard requirement it could
47
+ # `require` — extracting it here still removes the duplication between this
48
+ # module and AtomGenerator, `thecore_generators`' own second, in-gem consumer.
49
+ module SampleFetcher
50
+ module_function
51
+
52
+ def fetch_thecore_sample(actor, relative_path, destination, label:)
53
+ source = ENV["THECORE_SAMPLES_SOURCE"]
54
+ source = "https://raw.githubusercontent.com/gabrieletassoni/thecore/master/samples" if source.nil? || source.empty?
55
+
56
+ if source.start_with?("http://", "https://")
57
+ actor.get("#{source.chomp("/")}/#{relative_path}", destination, force: true)
58
+ else
59
+ actor.create_file(destination, File.read(File.join(source, relative_path)), force: true)
60
+ end
61
+ rescue StandardError => e
62
+ actor.send(:abort, "Failed to fetch #{relative_path} from #{source} (#{e.class}: #{e.message}) " \
63
+ "-- aborting #{label}. Set THECORE_SAMPLES_SOURCE to override the source. If this left " \
64
+ "a partially-generated directory behind, remove it before retrying.")
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,19 @@
1
+ module Thecore
2
+ module Generators
3
+ # A caller with no real TTY behind stdin/stdout (CI, a shelled-out child
4
+ # process) can never answer an interactive prompt. Shared by
5
+ # AssociationWiring's own inverse-association cardinality prompt and
6
+ # AtomGenerator's metadata/dependency prompts, extracted after the two
7
+ # independently implemented the identical condition (caught in review,
8
+ # thecore_generators#20) - a single source of truth means a future
9
+ # refinement to this detection (e.g. an ENV["CI"] check) can't silently
10
+ # apply to one generator's prompts and not the other's.
11
+ module TtyDetection
12
+ module_function
13
+
14
+ def real_tty?
15
+ $stdin.tty? && $stdout.tty?
16
+ end
17
+ end
18
+ end
19
+ end
@@ -153,6 +153,10 @@ chmod ".devcontainer/link-host-home.sh", 0o755
153
153
  chmod ".devcontainer/check-plugins.sh", 0o755
154
154
 
155
155
  fetch_thecore_sample(".gitlab-ci.yml", ".gitlab-ci.yml")
156
+ # thecore_generators#25 (ADR 0007): fetched unconditionally, alongside .gitlab-ci.yml above --
157
+ # no hosting-profile prompt, same reasoning ADR 0006 already established for thecore:atom's own
158
+ # dual-CI generation (git hosting is a generic, per-developer choice, not something to ask about).
159
+ fetch_thecore_sample(".github/workflows/ci.yml", ".github/workflows/ci.yml")
156
160
  fetch_thecore_sample("CLAUDE.md", "CLAUDE.md")
157
161
 
158
162
  # --- Standard installer chain ------------------------------------------------
@@ -170,10 +174,44 @@ if options[:asset_pipeline].to_s != "sprockets"
170
174
  :yellow
171
175
  end
172
176
 
173
- run_setup_now = yes?(
174
- "Run `bundle install` and the standard installer generators (devise, rails_admin, " \
175
- "active_storage, action_text, action_mailbox, cancan, erd) now? (y/n)"
176
- )
177
+ # thecore_generators#23: non-interactive mode, triggered ONLY by the explicit env var
178
+ # below -- deliberately NOT auto-detected from tty absence the way `thecore:atom`'s
179
+ # `TtyDetection`-based `effectively_non_interactive?` is (ADR 0006). That precedent
180
+ # works because `AtomGenerator` runs in-process inside its own test, where stubbing
181
+ # `$stdin.tty?`/`$stdout.tty?` (see `association_wiring_test.rb`) is meaningful. This
182
+ # template instead always runs as a genuine separate `rails new` subprocess (its own
183
+ # test seam spawns it via Open3), whose stdin is a pipe -- never a real tty -- even
184
+ # when the test is legitimately simulating interactive use by feeding an answer via
185
+ # `stdin_data`. Auto-triggering on tty absence would misfire on every such simulated-
186
+ # interactive test run; the explicit, deliberate opt-in below avoids that with no loss
187
+ # of real-world safety (an unattended CI run with neither var set still fails loudly
188
+ # below, just one env var short of `thecore:atom`'s own auto-detected equivalent).
189
+ non_interactive = !ENV["THECORE_APP_TEMPLATE_NON_INTERACTIVE"].to_s.strip.empty?
190
+
191
+ run_setup_now =
192
+ if non_interactive
193
+ run_installers_raw = ENV["THECORE_APP_TEMPLATE_RUN_INSTALLERS"]
194
+ if run_installers_raw.nil? || run_installers_raw.strip.empty?
195
+ abort(
196
+ "THECORE_APP_TEMPLATE_NON_INTERACTIVE is set but THECORE_APP_TEMPLATE_RUN_INSTALLERS " \
197
+ "is not -- set it to \"true\" or \"false\" to say whether the installer chain " \
198
+ "(devise/rails_admin/active_storage/action_text/action_mailbox/cancan/erd) should run."
199
+ )
200
+ end
201
+ normalized = run_installers_raw.strip.downcase
202
+ unless %w[true false].include?(normalized)
203
+ abort(
204
+ "THECORE_APP_TEMPLATE_RUN_INSTALLERS must be \"true\" or \"false\", got " \
205
+ "#{run_installers_raw.inspect}."
206
+ )
207
+ end
208
+ normalized == "true"
209
+ else
210
+ yes?(
211
+ "Run `bundle install` and the standard installer generators (devise, rails_admin, " \
212
+ "active_storage, action_text, action_mailbox, cancan, erd) now? (y/n)"
213
+ )
214
+ end
177
215
 
178
216
  after_bundle do
179
217
  next unless run_setup_now
@@ -5,6 +5,7 @@ require "generators/thecore/workspace_context"
5
5
  require "generators/thecore/action_companion"
6
6
  require "generators/thecore/root_action/root_action_generator"
7
7
  require "generators/thecore/member_action/member_action_generator"
8
+ require "generators/thecore/collection_action/collection_action_generator"
8
9
 
9
10
  module Thecore
10
11
  # `rails thecore:check_practices` — a Ruby port of thecore_code_extension's
@@ -50,8 +51,9 @@ module Thecore
50
51
  # then re-scans and returns whatever violations remain, per ADR 0004:
51
52
  # "exits non-zero whenever violations remain unresolved after any --fix
52
53
  # pass" — this makes a fix that turns out incomplete (or a violation
53
- # this ticket doesn't know how to fix, e.g. a collection_action
54
- # companion) visible in the result rather than silently assumed fixed.
54
+ # this gem doesn't know how to fix at all, e.g. a broken action-file
55
+ # marker, never fixable for any kind) visible in the result rather than
56
+ # silently assumed fixed.
55
57
  def self.run(app_root:, atom_name: nil, fix: false)
56
58
  violations = Runner.new(app_root: app_root, atom_name: atom_name).run
57
59
  return violations unless fix
@@ -63,12 +65,15 @@ module Thecore
63
65
  # A Thor::Group instance whose only purpose is running
64
66
  # Thecore::Generators::CompanionFiles' generic (action-kind-agnostic)
65
67
  # after_initialize.rb/locale fixes against a specific destination_root
66
- # and action name — used by Runner's Actions check for all three kinds,
67
- # including collection_action, which has no generator of its own to
68
- # delegate companion-file (view/JS/SCSS) rendering to. Deliberately not
69
- # placed under lib/generators/ (and so never discovered as a
70
- # `rails generate` namespace) it is an internal implementation detail
71
- # of check_practices' --fix, not a public command.
68
+ # and action name — used by Runner's Actions check for all three kinds.
69
+ # Companion-file (view/JS/SCSS) fixes do NOT go through this class for
70
+ # any kind those delegate straight to the kind's own real generator
71
+ # (RootActionGenerator/MemberActionGenerator/CollectionActionGenerator,
72
+ # via ACTION_GENERATOR_CLASSES below) so the fix always uses that
73
+ # generator's own template. Deliberately not placed under
74
+ # lib/generators/ (and so never discovered as a `rails generate`
75
+ # namespace) — it is an internal implementation detail of
76
+ # check_practices' --fix, not a public command.
72
77
  class GenericFixTarget < Rails::Generators::NamedBase
73
78
  include Thecore::Generators::AtomAware
74
79
  include Thecore::Generators::CompanionFiles
@@ -85,14 +90,15 @@ module Thecore
85
90
  VIEW_MARKERS = ["stylesheet_link_tag", "javascript_include_tag"].freeze
86
91
  JS_MARKERS = ["document.addEventListener('turbo:load'"].freeze
87
92
  SCSS_MARKERS = ["@keyframes sk-bounce"].freeze
88
- # Only root_action/member_action have a generator whose own template
89
- # rendering a companion-file fix can delegate to; collection_action
90
- # has none (ADR 0004: "no generator to have gotten it right" - a
91
- # tracked, deliberate gap), so its missing-companion violations are
92
- # never fixable.
93
+ # All three kinds now have a generator whose own template rendering a
94
+ # companion-file fix can delegate to (thecore_generators#21, per ADR
95
+ # 0006 in the thecore repo) - collection_action's missing-companion
96
+ # violations were never fixable before this (ADR 0004 tracked it as a
97
+ # deliberate, temporary gap: "no generator to have gotten it right").
93
98
  ACTION_GENERATOR_CLASSES = {
94
99
  "root_action" => Thecore::Generators::RootActionGenerator,
95
100
  "member_action" => Thecore::Generators::MemberActionGenerator,
101
+ "collection_action" => Thecore::Generators::CollectionActionGenerator,
96
102
  }.freeze
97
103
 
98
104
  def initialize(app_root:, atom_name: nil)
@@ -235,7 +241,7 @@ module Thecore
235
241
  end
236
242
 
237
243
  # Renders only the one missing companion file, via the actual
238
- # Root/Member Action generator's own template - never the bundled
244
+ # Root/Member/Collection Action generator's own template - never the bundled
239
245
  # "render all three companions" method, which would risk a
240
246
  # non-interactive file-collision hang/prompt on a hand-customized
241
247
  # sibling file that already exists with different content. A file
@@ -1,3 +1,3 @@
1
1
  module ThecoreGenerators
2
- VERSION = "3.8.0"
2
+ VERSION = "3.13.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: thecore_generators
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.8.0
4
+ version: 3.13.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gabriele Tassoni
@@ -38,7 +38,15 @@ files:
38
38
  - Rakefile
39
39
  - lib/generators/thecore/action_companion.rb
40
40
  - lib/generators/thecore/association_wiring.rb
41
+ - lib/generators/thecore/atom/atom_generator.rb
42
+ - lib/generators/thecore/atom/templates/abilities.rb.tt
43
+ - lib/generators/thecore/atom/templates/seeds.rb.tt
41
44
  - lib/generators/thecore/atom_aware.rb
45
+ - lib/generators/thecore/collection_action/collection_action_generator.rb
46
+ - lib/generators/thecore/collection_action/templates/action.html.erb.tt
47
+ - lib/generators/thecore/collection_action/templates/action.js.tt
48
+ - lib/generators/thecore/collection_action/templates/action.rb.tt
49
+ - lib/generators/thecore/collection_action/templates/action.scss.tt
42
50
  - lib/generators/thecore/companion_files.rb
43
51
  - lib/generators/thecore/member_action/member_action_generator.rb
44
52
  - lib/generators/thecore/member_action/templates/action.html.erb.tt
@@ -54,6 +62,8 @@ files:
54
62
  - lib/generators/thecore/root_action/templates/action.js.tt
55
63
  - lib/generators/thecore/root_action/templates/action.rb.tt
56
64
  - lib/generators/thecore/root_action/templates/action.scss.tt
65
+ - lib/generators/thecore/sample_fetcher.rb
66
+ - lib/generators/thecore/tty_detection.rb
57
67
  - lib/generators/thecore/workspace_context.rb
58
68
  - lib/tasks/thecore_generators_tasks.rake
59
69
  - lib/templates/app_template.rb