railstart 0.5.1 → 0.6.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: 502fb0b7fcebbc0983a28f858979bac1d540106adb74dd8de4324115cef97de7
4
- data.tar.gz: 18501279259afc8e1636ab8b5687c0c8d1fbc79f4b4c5159c9675c05b035c74c
3
+ metadata.gz: 43c88184e8a592416bb521ca50fcccecbe331caf46851218cd1edd31e6134827
4
+ data.tar.gz: 3f6084258ec5d559ef064c75ebb4937208a0e56373427a6983c883bbfae1ff74
5
5
  SHA512:
6
- metadata.gz: e0aff7a354d36a1b99881895bd1e37b429ba059f16d55a628b8c227c1d5c1437add24e6c08b778827cc3769399cbe174bc5fe1af3e0149a4895050f539b7d561
7
- data.tar.gz: 576ea5f508618c3b10a5dc09727e061c27f3669d50bbd12bf201d510f830d75de0182e1fdac7f5bb6ebc6b7765dd1a10bab21dc59fe5a331a2e10a8f2f7fdc68
6
+ metadata.gz: f4fa31d2a8ab8f838a8c269f0c952b997c857b296567c291c70b7647f92780ec1cae26ecbfd8a04121d882e565e479761a829b4ba6e6f076dafe3d4551e352ad
7
+ data.tar.gz: 2485f48632b2a43d415ef717d111ee87cc48da6e3b9a23c7b6c04ea9c9e5b08cc7bbdfd1bf3915b8427c56b239ddf3d9b4849cbc00efbe548d8564f491b0c888
data/CHANGELOG.md CHANGED
@@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.6.0] - 2026-10-03
11
+
12
+ ### Added
13
+ - `railstart new --yes` (`-y`) fully headless mode that implies defaults, never prompts, requires `APP_NAME`, and uses post-action `default` values for prompted actions.
14
+ - **File post-actions**: New `type: file` post-action that writes a file into the generated app from inline `content` or a `source` template (relative sources resolve against `config/templates/`), with `%{app_name}` interpolation, path traversal protection, and skip-on-existing `overwrite` semantics.
15
+ - **Shipped templates**: New `config/templates/AGENTS.md` (opinionated Rails 8 guide for coding agents, written by the enabled `write_agents_md` action) and `config/templates/bin/ci` (curated CI script behind the opt-in `setup_bin_ci` action).
16
+
10
17
  ## [0.5.1] - 2026-07-26
11
18
 
12
19
  ### Changed
data/README.md CHANGED
@@ -94,7 +94,8 @@ Presets are configuration overlays that let you define different defaults and ev
94
94
  **Modes:**
95
95
  - **Interactive** (default): prompts for each question from the config schema
96
96
  - **With --default**: skips questions, loads "default" preset, shows summary and confirms
97
- - **With --preset**: loads specified preset as config overlay (can be interactive or with --default)
97
+ - **With --yes / -y**: fully headless, implies `--default`, requires `APP_NAME`, skips all confirmations (post-action prompts follow each action's `default`)
98
+ - **With --preset**: loads specified preset as config overlay (can be interactive or with --default/--yes)
98
99
 
99
100
  ```bash
100
101
  # Interactive mode (builtin defaults)
@@ -104,11 +105,14 @@ railstart new my_app
104
105
  # Note: --default automatically loads the "default" preset (user or gem)
105
106
  railstart new my_app --default
106
107
 
108
+ # Fully headless mode (implies --default, no prompts at all)
109
+ railstart new my_app --yes
110
+
107
111
  # Interactive with custom preset
108
112
  railstart new my_app --preset api-only
109
113
 
110
- # Non-interactive with custom preset
111
- railstart new my_app --preset api-only --default
114
+ # Fully headless with custom preset
115
+ railstart new my_app --preset api-only --yes
112
116
  ```
113
117
 
114
118
  **Create custom presets** at `~/.config/railstart/presets/my-preset.yaml`:
@@ -348,6 +352,33 @@ Key differences from `command` actions:
348
352
  - `variables` is optional; when present, its keys become instance variables accessible from the template (e.g., `@app_label`). Railstart always exposes `@app_name` and `@answers` for convenience.
349
353
  - Template actions still honor `prompt`, `default`, and `if` just like command actions. Keep remote templates disabled by default unless you explicitly trust them.
350
354
 
355
+ #### File Post-Actions
356
+
357
+ For lightweight files (AGENTS.md, scripts, dotfiles) you can skip Rails templates entirely and write a file directly into the generated app. File actions never load Rails in-process.
358
+
359
+ ```yaml
360
+ post_actions:
361
+ - id: write_agents_md
362
+ name: "Create AGENTS.md for coding agents"
363
+ type: file
364
+ enabled: true
365
+ prompt: "Create AGENTS.md for coding agents?"
366
+ default: true
367
+ path: AGENTS.md # required, relative to the app root
368
+ source: AGENTS.md # template file (or use `content:` for an inline string)
369
+ overwrite: false # optional; skip instead of overwriting existing files
370
+ ```
371
+
372
+ Key differences from `template` actions:
373
+
374
+ - `path` is required and must stay inside the app (absolute, `~`-prefixed, and `..` paths are rejected).
375
+ - Provide exactly one of `content` (inline string) or `source` (file path). Relative `source` paths resolve against the gem's `config/templates/` directory; absolute or `~/` paths are used as-is.
376
+ - Content supports `%{app_name}` interpolation, like `%{value}` in `rails_flag`. Other `%` sequences pass through unchanged.
377
+ - `overwrite` defaults to `false`: existing files are skipped with a warning. Source-based writes keep the source file's executable bit.
378
+ - `prompt`, `default`, and `if` behave exactly like command actions.
379
+
380
+ The built-in config ships two file actions: `write_agents_md` (enabled by default, writes an opinionated AGENTS.md for coding agents) and `setup_bin_ci` (opt-in; Rails 8.1+ already generates `bin/ci`).
381
+
351
382
  ## Development
352
383
 
353
384
  ### Setup
@@ -126,6 +126,29 @@ questions:
126
126
  rails_flag: "--skip-test"
127
127
 
128
128
  post_actions:
129
+ - id: write_agents_md
130
+ name: "Create AGENTS.md for coding agents"
131
+ type: file
132
+ enabled: true
133
+ prompt: "Create AGENTS.md for coding agents?"
134
+ default: true
135
+ path: AGENTS.md
136
+ source: AGENTS.md
137
+ overwrite: false
138
+
139
+ # Opt-in: Rails 8.1+ already generates bin/ci, so this action stays
140
+ # disabled by default and never overwrites an existing script. Enable it
141
+ # to have Railstart write a curated CI script (keeps its executable bit).
142
+ - id: setup_bin_ci
143
+ name: "Create bin/ci script"
144
+ type: file
145
+ enabled: false
146
+ prompt: "Create bin/ci script?"
147
+ default: false
148
+ path: bin/ci
149
+ source: bin/ci
150
+ overwrite: false
151
+
129
152
  - id: init_git
130
153
  name: "Initialize git repository"
131
154
  enabled: true
@@ -0,0 +1,30 @@
1
+ # %{app_name}: Agent Guide
2
+
3
+ Guidance for AI coding agents working in this Rails 8 application.
4
+
5
+ ## Setup
6
+
7
+ ```bash
8
+ bin/setup
9
+ ```
10
+
11
+ ## Everyday Commands
12
+
13
+ - Run the test suite: `bin/rails test` (system tests: `bin/rails test:system`)
14
+ - Run a single test file: `bin/rails test test/models/user_test.rb`
15
+ - Lint: `bundle exec rubocop`
16
+ - Security scan: `bin/brakeman` (when the gem is installed)
17
+ - Console: `bin/rails console`
18
+
19
+ ## Conventions
20
+
21
+ - Follow standard Ruby and Rails style; keep the existing code style consistent.
22
+ - Use `bin/rails generate` for new models, controllers, and migrations instead of hand-writing boilerplate.
23
+ - Keep controllers thin; put business logic in models or plain service objects.
24
+ - Write tests for new behavior; prefer fixtures and plain assertions over heavy mocking.
25
+
26
+ ## Before Committing
27
+
28
+ 1. `bin/rails test` passes.
29
+ 2. `bundle exec rubocop` reports no offenses.
30
+ 3. New or changed behavior is covered by tests.
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env bash
2
+ # bin/ci — curated CI script written by Railstart.
3
+ # Rails 8.1+ generates its own bin/ci; enable the setup_bin_ci post-action
4
+ # only when the generated app does not already ship one.
5
+ set -euo pipefail
6
+
7
+ echo "== Running RuboCop =="
8
+ bundle exec rubocop
9
+
10
+ if bundle exec brakeman --version >/dev/null 2>&1; then
11
+ echo "== Running Brakeman =="
12
+ bundle exec brakeman --quiet --no-pager
13
+ fi
14
+
15
+ echo "== Running tests =="
16
+ bin/rails test
data/lib/railstart/cli.rb CHANGED
@@ -32,6 +32,7 @@ module Railstart
32
32
  puts " railstart init # Create config files (optional)"
33
33
  puts " railstart new my_app # Interactive mode"
34
34
  puts " railstart new my_app --default # Use defaults"
35
+ puts " railstart new my_app --yes # Fully headless"
35
36
  puts " railstart new my_app --preset api-only # Use preset"
36
37
  puts ""
37
38
  puts "Run 'railstart help init' or 'railstart help new' for details"
@@ -122,17 +123,20 @@ module Railstart
122
123
 
123
124
  Modes:
124
125
  - Interactive (default): prompts for each question
126
+ - With default mode (--default or --yes): uses config defaults
125
127
  - With preset: uses preset config (different questions/defaults), interactive or non-interactive
126
128
 
127
129
  Examples:
128
130
  railstart new my_app # Interactive mode
129
- railstart new my_app --default # Non-interactive with default preset (if exists)
131
+ railstart new my_app --default # Non-interactive with default preset (if exists), still confirms
132
+ railstart new my_app --yes # Fully headless (no prompts, requires APP_NAME)
130
133
  railstart new my_app --preset api-only # Interactive with api-only preset config
131
- railstart new my_app --preset api-only --default # Non-interactive with api-only preset
134
+ railstart new my_app --preset api-only --yes # Fully headless with api-only preset
132
135
 
133
136
  Presets are stored in: ~/.config/railstart/presets/*.yaml
134
137
  DESC
135
138
  option :default, type: :boolean, default: false, desc: "Use defaults non-interactively"
139
+ option :yes, type: :boolean, aliases: "-y", default: false, desc: "Headless mode: implies --default; no prompts"
136
140
  option :preset, type: :string, desc: "Preset name from ~/.config/railstart/presets/", banner: "NAME"
137
141
  #
138
142
  # @param app_name [String, nil] desired Rails app name, prompted if omitted
@@ -144,6 +148,8 @@ module Railstart
144
148
  # Railstart::CLI.start(%w[new my_app --preset api-only])
145
149
  # @example Use default preset non-interactively
146
150
  # Railstart::CLI.start(%w[new my_app --default])
151
+ # @example Run fully headless
152
+ # Railstart::CLI.start(%w[new my_app --yes])
147
153
  def new(app_name = nil)
148
154
  preset_name = determine_preset_name
149
155
  preset_path = preset_name ? preset_file_for(preset_name) : nil
@@ -153,7 +159,8 @@ module Railstart
153
159
  generator = Generator.new(
154
160
  app_name,
155
161
  config: config,
156
- use_defaults: options[:default]
162
+ use_defaults: options[:default] || options[:yes],
163
+ assume_yes: options[:yes]
157
164
  )
158
165
 
159
166
  generator.run
@@ -230,8 +237,8 @@ module Railstart
230
237
  # Explicit --preset flag takes priority
231
238
  return options[:preset] if options[:preset]
232
239
 
233
- # --default maps to "default" preset
234
- return "default" if options[:default]
240
+ # --default and --yes map to "default" preset
241
+ return "default" if options[:default] || options[:yes]
235
242
 
236
243
  nil
237
244
  end
@@ -157,7 +157,7 @@ module Railstart
157
157
  end
158
158
 
159
159
  def fetch_id(entry)
160
- return unless entry.respond_to?(:[])
160
+ return unless entry.is_a?(Hash)
161
161
 
162
162
  entry["id"] || entry[:id]
163
163
  end
@@ -406,11 +406,38 @@ module Railstart
406
406
  end
407
407
  end
408
408
  issues
409
+ when "file"
410
+ validate_file_action(entry, identifier, enabled)
409
411
  else
410
412
  ["Post-action #{identifier} has unsupported type '#{action_type}'"]
411
413
  end
412
414
  end
413
415
 
416
+ def validate_file_action(entry, identifier, enabled)
417
+ issues = []
418
+ path = value_for(entry, "path")
419
+ if enabled && (path.nil? || path.to_s.strip.empty?)
420
+ issues << "Post-action #{identifier} is a file but missing a path"
421
+ elsif path && !path.to_s.strip.empty? && unsafe_file_path?(path.to_s)
422
+ issues << "Post-action #{identifier} has unsafe file path #{path.inspect}"
423
+ end
424
+
425
+ if enabled
426
+ has_content = key_present?(entry, "content") && !value_for(entry, "content").nil?
427
+ has_source = !value_for(entry, "source").to_s.strip.empty?
428
+ if has_content && has_source
429
+ issues << "Post-action #{identifier} is a file but defines both content and source"
430
+ elsif !has_content && !has_source
431
+ issues << "Post-action #{identifier} is a file but missing content or source"
432
+ end
433
+ end
434
+ issues
435
+ end
436
+
437
+ def unsafe_file_path?(path)
438
+ path.start_with?("/", "~") || path.match?(%r{\A[A-Za-z]:[\\/]}) || path.split(%r{[/\\]+}).include?("..")
439
+ end
440
+
414
441
  def validate_post_action_condition(entry, identifier, question_ids)
415
442
  return [] unless key_present?(entry, "if")
416
443
 
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "pathname"
5
+ require_relative "errors"
6
+
7
+ module Railstart
8
+ # Writes lightweight files into a generated application directory.
9
+ #
10
+ # Backs `type: file` post-actions, providing a way to ship small files
11
+ # (AGENTS.md, scripts, dotfiles) without loading Rails in-process like
12
+ # template post-actions do.
13
+ #
14
+ # @example Write a file from inline content
15
+ # writer = Railstart::FileWriter.new(app_path: Dir.pwd, app_name: "blog")
16
+ # writer.write(path: "AGENTS.md", content: "# %{app_name}")
17
+ class FileWriter
18
+ # Directory inside the gem where relative `source` paths resolve.
19
+ TEMPLATES_DIR = File.expand_path("../../config/templates", __dir__)
20
+
21
+ #
22
+ # @param app_path [String] absolute path to the generated application
23
+ # @param app_name [String] application name used for `%{app_name}` interpolation
24
+ def initialize(app_path:, app_name:)
25
+ @app_path = app_path
26
+ @app_name = app_name
27
+ end
28
+
29
+ #
30
+ # Write a file into the application directory.
31
+ #
32
+ # @param path [String] target path relative to the application root
33
+ # @param content [String, nil] inline file content (mutually exclusive with `source`)
34
+ # @param source [String, nil] template file path; relative paths resolve
35
+ # against the gem's config/templates directory, absolute or ~/ paths are used as-is
36
+ # @param overwrite [Boolean] replace an existing file when true
37
+ # @return [Symbol] :written when the file was written, :skipped when it
38
+ # already existed and `overwrite` is false
39
+ # @raise [Railstart::Error] when the target path escapes the app, the
40
+ # source escapes config/templates, or the source file cannot be read
41
+ def write(path:, content: nil, source: nil, overwrite: false)
42
+ source = nil if source.to_s.strip.empty?
43
+ target = safe_target(path)
44
+ if File.symlink?(target)
45
+ raise Error, "Unsafe file post-action path #{path.inspect}: refusing to write through a symlink"
46
+ end
47
+ return :skipped if File.exist?(target) && !overwrite
48
+
49
+ source_path = resolve_source(source) if source
50
+ body = interpolate(source_path ? read_source(source_path) : content.to_s)
51
+ ensure_resolved_within_app!(target, path)
52
+ FileUtils.mkdir_p(File.dirname(target))
53
+ File.write(target, body)
54
+ FileUtils.chmod(File.stat(source_path).mode & 0o777, target) if source_path
55
+ :written
56
+ end
57
+
58
+ private
59
+
60
+ def safe_target(path)
61
+ root = File.expand_path(@app_path)
62
+ target = File.expand_path(path.to_s, root)
63
+ return target if target.start_with?("#{root}#{File::SEPARATOR}")
64
+
65
+ raise Error, "Unsafe file post-action path #{path.inspect}: must stay inside the app directory"
66
+ end
67
+
68
+ # Lexical checks cannot see directory symlinks inside the app. Resolve the
69
+ # nearest existing ancestor BEFORE mkdir_p so a symlinked directory can
70
+ # neither receive the write nor absorb the created directories outside
71
+ # the app.
72
+ def ensure_resolved_within_app!(target, path)
73
+ root = File.realpath(@app_path)
74
+ resolved = File.realpath(nearest_existing_ancestor(target))
75
+ return if resolved == root || resolved.start_with?("#{root}#{File::SEPARATOR}")
76
+
77
+ raise Error, "Unsafe file post-action path #{path.inspect}: resolves outside the app directory"
78
+ end
79
+
80
+ def nearest_existing_ancestor(target)
81
+ dir = File.dirname(target)
82
+ dir = File.dirname(dir) until File.exist?(dir) || File.dirname(dir) == dir
83
+ dir
84
+ end
85
+
86
+ def resolve_source(source)
87
+ raw = source.to_s
88
+ expanded = raw.start_with?("~") ? File.expand_path(raw) : raw
89
+ return expanded if Pathname.new(expanded).absolute?
90
+
91
+ resolved = File.expand_path(expanded, TEMPLATES_DIR)
92
+ return resolved if resolved.start_with?("#{TEMPLATES_DIR}#{File::SEPARATOR}")
93
+
94
+ raise Error, "Unsafe file post-action source #{source.inspect}: must stay inside #{TEMPLATES_DIR}"
95
+ end
96
+
97
+ def read_source(source_path)
98
+ File.read(source_path)
99
+ rescue SystemCallError => e
100
+ raise Error, "Cannot read file post-action source #{source_path}: #{e.message}"
101
+ end
102
+
103
+ # Replaces only the documented `%{app_name}` token so literal `%`
104
+ # characters in file content (printf, percentages) pass through unchanged.
105
+ def interpolate(body)
106
+ # rubocop:disable-next Style/FormatStringToken -- `%{token}` is the documented interpolation style
107
+ body.gsub("%{app_name}", @app_name.to_s)
108
+ end
109
+ end
110
+ end
@@ -2,13 +2,15 @@
2
2
 
3
3
  require "tty-prompt"
4
4
  require_relative "ui"
5
- require_relative "template_runner"
5
+ require_relative "question_asker"
6
+ require_relative "post_action_runner"
6
7
 
7
8
  module Railstart
8
- # Orchestrates the interactive Rails app generation flow.
9
+ # Orchestrates the Rails app generation flow: welcome screen, app name,
10
+ # questions, summary, `rails new`, and post-actions.
9
11
  #
10
- # Handles configuration loading, prompting, summary display, command execution,
11
- # and optional post-generation actions while remaining easy to test.
12
+ # Question answering lives in QuestionAsker, post-action execution in
13
+ # PostActionRunner; this class only sequences the steps.
12
14
  #
13
15
  # @example Run generator with provided config
14
16
  # config = Railstart::Config.load
@@ -23,11 +25,13 @@ module Railstart
23
25
  # @param app_name [String, nil] preset app name, prompted if nil
24
26
  # @param config [Hash, nil] injected config for testing, defaults to Config.load
25
27
  # @param use_defaults [Boolean] skip interactive questions, use config defaults
28
+ # @param assume_yes [Boolean] fully headless mode, implies defaults and bypasses confirmations
26
29
  # @param prompt [TTY::Prompt] injectable prompt for testing
27
- def initialize(app_name = nil, config: nil, use_defaults: false, prompt: nil)
30
+ def initialize(app_name = nil, config: nil, use_defaults: false, assume_yes: false, prompt: nil)
28
31
  @app_name = app_name
29
32
  @config = config || Config.load
30
- @use_defaults = use_defaults
33
+ @assume_yes = assume_yes
34
+ @use_defaults = use_defaults || @assume_yes
31
35
  @prompt = prompt || TTY::Prompt.new
32
36
  @answers = {}
33
37
  end
@@ -48,14 +52,8 @@ module Railstart
48
52
  def run
49
53
  show_welcome_screen unless @use_defaults
50
54
 
51
- ask_app_name unless @app_name
52
- validate_app_name!
53
-
54
- if @use_defaults
55
- collect_defaults
56
- else
57
- ask_interactive_questions
58
- end
55
+ resolve_app_name
56
+ @answers = collect_answers
59
57
 
60
58
  show_summary
61
59
  return unless confirm_proceed?
@@ -71,6 +69,15 @@ module Railstart
71
69
  UI.show_welcome
72
70
  end
73
71
 
72
+ def resolve_app_name
73
+ if @app_name.nil?
74
+ raise Error, "APP_NAME is required when running in --yes mode" if @assume_yes
75
+
76
+ ask_app_name
77
+ end
78
+ validate_app_name!
79
+ end
80
+
74
81
  def ask_app_name
75
82
  @app_name = @prompt.ask("App name?", default: "my_app") do |q|
76
83
  q.validate(APP_NAME_PATTERN, APP_NAME_ERROR)
@@ -83,91 +90,9 @@ module Railstart
83
90
  raise Error, "Invalid app name '#{@app_name}': #{APP_NAME_ERROR}"
84
91
  end
85
92
 
86
- def collect_defaults
87
- Array(@config["questions"]).each do |question|
88
- next if should_skip_question?(question)
89
-
90
- question_id = question["id"]
91
- default_value = find_default(question)
92
- @answers[question_id] = default_value unless default_value.nil?
93
- end
94
- end
95
-
96
- def ask_interactive_questions
97
- Array(@config["questions"]).each do |question|
98
- handle_question(question)
99
- end
100
- end
101
-
102
- def handle_question(question)
103
- return if should_skip_question?(question)
104
-
105
- @answers[question["id"]] = ask_question(question)
106
- end
107
-
108
- def should_skip_question?(question)
109
- depends = question["depends_on"]
110
- return false unless depends
111
-
112
- dep_question_id = depends["question"]
113
- dep_value = depends["value"]
114
-
115
- actual_value = @answers[dep_question_id]
116
- actual_value != dep_value
117
- end
118
-
119
- def ask_question(question)
120
- case question["type"]
121
- when "select"
122
- ask_select(question)
123
- when "multi_select"
124
- ask_multi_select(question)
125
- when "yes_no"
126
- ask_yes_no?(question)
127
- when "input"
128
- ask_input(question)
129
- end
130
- end
131
-
132
- def ask_select(question)
133
- # Convert to hash format: { 'Display Name' => 'value' }
134
- choices = question["choices"].to_h { |choice| [choice["name"], choice["value"]] }
135
- default_val = find_default(question)
136
-
137
- # TTY::Prompt expects 1-based index for default
138
- default_index = (question["choices"].index { |c| c["value"] == default_val }&.+(1) if default_val)
139
-
140
- @prompt.select(question["prompt"], choices, default: default_index)
141
- end
142
-
143
- def ask_multi_select(question)
144
- # Convert to hash format: { 'Display Name' => 'value' }
145
- choices = question["choices"].to_h { |choice| [choice["name"], choice["value"]] }
146
-
147
- # Transform value-based defaults to name-based defaults for TTY::Prompt
148
- # Config uses stable values (e.g., "action_mailer"), TTY::Prompt needs display names
149
- value_defaults = question["default"] || []
150
- name_defaults = value_defaults.map do |value|
151
- choice = question["choices"].find { |c| c["value"] == value }
152
- choice ? choice["name"] : nil
153
- end.compact
154
-
155
- @prompt.multi_select(question["prompt"], choices, default: name_defaults)
156
- end
157
-
158
- def ask_yes_no?(question)
159
- @prompt.yes?(question["prompt"], default: question.fetch("default", false))
160
- end
161
-
162
- def ask_input(question)
163
- @prompt.ask(question["prompt"], default: question["default"])
164
- end
165
-
166
- def find_default(question)
167
- # Support both default at question level and default: true on choice
168
- return question["default"] if question.key?("default")
169
-
170
- Array(question["choices"]).find { |c| c["default"] }&.[]("value")
93
+ def collect_answers
94
+ asker = QuestionAsker.new(questions: @config["questions"], prompt: @prompt)
95
+ @use_defaults ? asker.collect_defaults : asker.ask_all
171
96
  end
172
97
 
173
98
  def show_summary
@@ -212,9 +137,17 @@ module Railstart
212
137
  end
213
138
 
214
139
  def confirm_proceed?
140
+ return true if @assume_yes
141
+
215
142
  @prompt.yes?("Proceed with app generation?")
216
143
  end
217
144
 
145
+ def confirm_action?(action)
146
+ return action.fetch("default", true) if @assume_yes
147
+
148
+ @prompt.yes?(action["prompt"], default: action.fetch("default", true))
149
+ end
150
+
218
151
  def generate_app
219
152
  arguments = CommandBuilder.arguments(@app_name, @config, @answers)
220
153
  command = CommandBuilder.build(@app_name, @config, @answers)
@@ -237,12 +170,13 @@ module Railstart
237
170
 
238
171
  def run_post_actions
239
172
  Dir.chdir(@app_name) do
240
- template_runner = nil
241
-
242
- Array(@config["post_actions"]).each do |action|
243
- template_runner ||= TemplateRunner.new(app_path: Dir.pwd) if template_action?(action)
244
- process_post_action(action, template_runner)
245
- end
173
+ PostActionRunner.new(
174
+ actions: @config["post_actions"],
175
+ answers: @answers,
176
+ app_name: @app_name,
177
+ confirmer: ->(action) { confirm_action?(action) },
178
+ command_runner: ->(command) { system(command) }
179
+ ).run
246
180
 
247
181
  puts
248
182
  UI.success("Rails app created successfully at ./#{@app_name}")
@@ -250,69 +184,5 @@ module Railstart
250
184
  rescue Errno::ENOENT
251
185
  UI.warning("Could not change to app directory. Post-actions skipped.")
252
186
  end
253
-
254
- def process_post_action(action, template_runner)
255
- return unless should_run_action?(action)
256
- return unless confirm_action?(action)
257
-
258
- if template_action?(action)
259
- run_template_action(action, template_runner)
260
- else
261
- run_command_action(action)
262
- end
263
- end
264
-
265
- def confirm_action?(action)
266
- return true unless action["prompt"]
267
-
268
- @prompt.yes?(action["prompt"], default: action.fetch("default", true))
269
- end
270
-
271
- def run_command_action(action)
272
- UI.info(action["name"].to_s)
273
- success = system(action["command"])
274
- UI.warning("Post-action '#{action["name"]}' failed. Continuing anyway.") unless success
275
- end
276
-
277
- def run_template_action(action, template_runner)
278
- return unless template_runner
279
-
280
- UI.info(action["name"].to_s)
281
- source = action["source"]
282
- variables = template_variables(action)
283
- template_runner.apply(source, variables: variables)
284
- rescue TemplateError => e
285
- UI.warning("Post-action '#{action["name"]}' failed. #{e.message}")
286
- end
287
-
288
- def template_variables(action)
289
- base = { app_name: @app_name, answers: @answers }
290
- extras = action["variables"].is_a?(Hash) ? action["variables"].transform_keys(&:to_sym) : {}
291
- base.merge(extras)
292
- end
293
-
294
- def template_action?(action)
295
- action["type"].to_s == "template"
296
- end
297
-
298
- def should_run_action?(action)
299
- return false unless action.fetch("enabled", true)
300
-
301
- if_condition = action["if"]
302
- return true unless if_condition
303
-
304
- question_id = if_condition["question"]
305
- answer = @answers[question_id]
306
-
307
- if if_condition.key?("equals")
308
- answer == if_condition["equals"]
309
- elsif if_condition.key?("includes")
310
- expected = Array(if_condition["includes"])
311
- actual = Array(answer)
312
- expected.intersect?(actual)
313
- else
314
- true
315
- end
316
- end
317
187
  end
318
188
  end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "file_writer"
4
+ require_relative "template_runner"
5
+
6
+ module Railstart
7
+ # Executes post-generation actions (commands, Rails templates, file writes)
8
+ # inside a generated application directory.
9
+ #
10
+ # @example Run enabled actions for a generated app
11
+ # Railstart::PostActionRunner.new(
12
+ # actions: config["post_actions"], answers: answers,
13
+ # app_name: "blog", prompt: prompt, assume_yes: false
14
+ # ).run
15
+ class PostActionRunner
16
+ #
17
+ # @param actions [Array<Hash>] post_action entries from the merged config
18
+ # @param answers [Hash] question answers used by action conditions
19
+ # @param app_name [String] application name for file interpolation and template variables
20
+ # @param confirmer [#call] decides whether a prompted action runs, given the action
21
+ # @param command_runner [#call, nil] executes shell commands; injectable for testing
22
+ def initialize(actions:, answers:, app_name:, confirmer:, command_runner: nil)
23
+ @actions = Array(actions)
24
+ @answers = answers
25
+ @app_name = app_name
26
+ @confirmer = confirmer
27
+ @command_runner = command_runner || ->(command) { system(command) }
28
+ end
29
+
30
+ #
31
+ # Run every enabled action whose condition passes. The current working
32
+ # directory must be the generated app root.
33
+ #
34
+ # @return [void]
35
+ def run
36
+ template_runner = nil
37
+
38
+ @actions.each do |action|
39
+ template_runner ||= TemplateRunner.new(app_path: Dir.pwd) if template_action?(action)
40
+ process(action, template_runner)
41
+ end
42
+ end
43
+
44
+ private
45
+
46
+ def process(action, template_runner)
47
+ return unless should_run?(action)
48
+ return unless confirm?(action)
49
+
50
+ if template_action?(action)
51
+ run_template(action, template_runner)
52
+ elsif file_action?(action)
53
+ run_file(action)
54
+ else
55
+ run_command(action)
56
+ end
57
+ end
58
+
59
+ def confirm?(action)
60
+ return true unless action["prompt"]
61
+
62
+ @confirmer.call(action)
63
+ end
64
+
65
+ def run_command(action)
66
+ UI.info(action["name"].to_s)
67
+ success = @command_runner.call(action["command"])
68
+ UI.warning("Post-action '#{action["name"]}' failed. Continuing anyway.") unless success
69
+ end
70
+
71
+ def run_template(action, template_runner)
72
+ return unless template_runner
73
+
74
+ UI.info(action["name"].to_s)
75
+ template_runner.apply(action["source"], variables: template_variables(action))
76
+ rescue TemplateError => e
77
+ UI.warning("Post-action '#{action["name"]}' failed. #{e.message}")
78
+ end
79
+
80
+ def run_file(action)
81
+ UI.info(action["name"].to_s)
82
+ writer = FileWriter.new(app_path: Dir.pwd, app_name: @app_name)
83
+ result = writer.write(
84
+ path: action["path"],
85
+ content: action["content"],
86
+ source: action["source"],
87
+ overwrite: action.fetch("overwrite", false)
88
+ )
89
+ if result == :skipped
90
+ UI.warning("Skipped '#{action["path"]}' (already exists)")
91
+ else
92
+ UI.success("Created #{action["path"]}")
93
+ end
94
+ rescue Error, SystemCallError => e
95
+ UI.warning("Post-action '#{action["name"]}' failed. #{e.message}")
96
+ end
97
+
98
+ def template_variables(action)
99
+ base = { app_name: @app_name, answers: @answers }
100
+ extras = action["variables"].is_a?(Hash) ? action["variables"].transform_keys(&:to_sym) : {}
101
+ base.merge(extras)
102
+ end
103
+
104
+ def template_action?(action)
105
+ action["type"].to_s == "template"
106
+ end
107
+
108
+ def file_action?(action)
109
+ action["type"].to_s == "file"
110
+ end
111
+
112
+ def should_run?(action)
113
+ return false unless action.fetch("enabled", true)
114
+
115
+ if_condition = action["if"]
116
+ return true unless if_condition
117
+
118
+ answer = @answers[if_condition["question"]]
119
+
120
+ if if_condition.key?("equals")
121
+ answer == if_condition["equals"]
122
+ elsif if_condition.key?("includes")
123
+ expected = Array(if_condition["includes"])
124
+ expected.intersect?(Array(answer))
125
+ else
126
+ true
127
+ end
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railstart
4
+ # Answers configuration questions, either by collecting their defaults
5
+ # (non-interactive) or by prompting the user via TTY::Prompt.
6
+ #
7
+ # @example Collect defaults without prompting
8
+ # Railstart::QuestionAsker.new(questions: config["questions"], prompt: prompt).collect_defaults
9
+ # @example Ask every question interactively
10
+ # Railstart::QuestionAsker.new(questions: config["questions"], prompt: prompt).ask_all
11
+ class QuestionAsker
12
+ #
13
+ # @param questions [Array<Hash>] question entries from the merged config
14
+ # @param prompt [TTY::Prompt] prompt used for interactive questions
15
+ def initialize(questions:, prompt:)
16
+ @questions = Array(questions)
17
+ @prompt = prompt
18
+ end
19
+
20
+ #
21
+ # Build an answers hash from question defaults, skipping conditional
22
+ # questions whose dependency is not met.
23
+ #
24
+ # @return [Hash] question id => default value
25
+ def collect_defaults
26
+ answers = {}
27
+ @questions.each do |question|
28
+ next if skip_question?(question, answers)
29
+
30
+ default_value = find_default(question)
31
+ answers[question["id"]] = default_value unless default_value.nil?
32
+ end
33
+ answers
34
+ end
35
+
36
+ #
37
+ # Prompt for every question, respecting depends_on conditions.
38
+ #
39
+ # @return [Hash] question id => answer
40
+ def ask_all
41
+ answers = {}
42
+ @questions.each do |question|
43
+ next if skip_question?(question, answers)
44
+
45
+ answers[question["id"]] = ask_question(question)
46
+ end
47
+ answers
48
+ end
49
+
50
+ private
51
+
52
+ def skip_question?(question, answers)
53
+ depends = question["depends_on"]
54
+ return false unless depends
55
+
56
+ answers[depends["question"]] != depends["value"]
57
+ end
58
+
59
+ def ask_question(question)
60
+ case question["type"]
61
+ when "select"
62
+ ask_select(question)
63
+ when "multi_select"
64
+ ask_multi_select(question)
65
+ when "yes_no"
66
+ ask_yes_no?(question)
67
+ when "input"
68
+ ask_input(question)
69
+ end
70
+ end
71
+
72
+ def ask_select(question)
73
+ # Convert to hash format: { 'Display Name' => 'value' }
74
+ choices = question["choices"].to_h { |choice| [choice["name"], choice["value"]] }
75
+ default_val = find_default(question)
76
+
77
+ # TTY::Prompt expects 1-based index for default
78
+ default_index = (question["choices"].index { |c| c["value"] == default_val }&.+(1) if default_val)
79
+
80
+ @prompt.select(question["prompt"], choices, default: default_index)
81
+ end
82
+
83
+ def ask_multi_select(question)
84
+ # Convert to hash format: { 'Display Name' => 'value' }
85
+ choices = question["choices"].to_h { |choice| [choice["name"], choice["value"]] }
86
+
87
+ # Transform value-based defaults to name-based defaults for TTY::Prompt
88
+ # Config uses stable values (e.g., "action_mailer"), TTY::Prompt needs display names
89
+ value_defaults = question["default"] || []
90
+ name_defaults = value_defaults.map do |value|
91
+ choice = question["choices"].find { |c| c["value"] == value }
92
+ choice ? choice["name"] : nil
93
+ end.compact
94
+
95
+ @prompt.multi_select(question["prompt"], choices, default: name_defaults)
96
+ end
97
+
98
+ def ask_yes_no?(question)
99
+ @prompt.yes?(question["prompt"], default: question.fetch("default", false))
100
+ end
101
+
102
+ def ask_input(question)
103
+ @prompt.ask(question["prompt"], default: question["default"])
104
+ end
105
+
106
+ def find_default(question)
107
+ # Support both default at question level and default: true on choice
108
+ return question["default"] if question.key?("default")
109
+
110
+ Array(question["choices"]).find { |c| c["default"] }&.[]("value")
111
+ end
112
+ end
113
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Railstart
4
- VERSION = "0.5.1"
4
+ VERSION = "0.6.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: railstart
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.1
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - dpaluy
@@ -71,13 +71,18 @@ files:
71
71
  - config/presets/default.yaml
72
72
  - config/presets/vite-bun.yaml
73
73
  - config/rails8_defaults.yaml
74
+ - config/templates/AGENTS.md
75
+ - config/templates/bin/ci
74
76
  - exe/railstart
75
77
  - lib/railstart.rb
76
78
  - lib/railstart/cli.rb
77
79
  - lib/railstart/command_builder.rb
78
80
  - lib/railstart/config.rb
79
81
  - lib/railstart/errors.rb
82
+ - lib/railstart/file_writer.rb
80
83
  - lib/railstart/generator.rb
84
+ - lib/railstart/post_action_runner.rb
85
+ - lib/railstart/question_asker.rb
81
86
  - lib/railstart/template_runner.rb
82
87
  - lib/railstart/ui.rb
83
88
  - lib/railstart/version.rb