rails-newというCLIツールを触ってみた

Githubを眺めてたらrails organizationにrails-newというプロジェクトができていたので触ってみました。

https://github.com/rails/rails-new

## 想定読者

- Railsアプリ開発の経験がある人

## はじめにまとめ

- `rails-new`はシングルバイナリのCLIツール
- 事前に`ruby`や`rails`のセットアップなしにRailsアプリを作成できる
- まだプレリリースなので注意

## 環境など

- macOS Sequoia
- Apple Silicon M1
- rails-new 0.4.1
- Docker Desktop 4.36.0
- Ruby 3.3.6

`rails-new`はDockerを利用するので事前にインストールする必要があります。

## インストール

READMEに従ってダウンロードして好きなところに展開します。
今回は適当な作業ディレクトリに置きました。

```
$ wget https://github.com/rails/rails-new/releases/download/v0.4.1/rails-new-aarch64-apple-darwin.tar.gz
$ tar xvfz rails-new-aarch64-apple-darwin.tar.gz
```

## ヘルプを見てみる

とりあえずヘルプを見てみます。

```
$ ./rails-new --help
A CLI tool to generate a new Rails project

Usage: rails-new [OPTIONS] <ARGS>...
       rails-new [OPTIONS] [ARGS]... <COMMAND>

Commands:
  rails-help  Prints `rails new --help`
  help        Print this message or the help of the given subcommand(s)

Arguments:
  <ARGS>...  arguments passed to `rails new`

Options:
  -u, --ruby-version <RUBY_VERSION>    [default: 3.3.4]
  -r, --rails-version <RAILS_VERSION>  [default: 7.2.0]
  -h, --help                           Print help
  -V, --version                        Print version
```

`--ruby-version`や`--rails-version`でRubyとRailsのバージョンを指定できるみたいです。

その他コマンドライン引数は`rails new`と同じものが指定できそうです。

## 実行してみる

ひとまずシンプルに`rails-new`を実行してみます。

```
./rails-new rails-app
```

<details><summary>実行ログ</summary>

```
$ ./rails-new rails-app
[+] Building 62.5s (6/6) FINISHED                                                                                                                                                       docker:desktop-linux
 => [internal] load build definition from Dockerfile                                                                                                                                                    0.0s
 => => transferring dockerfile: 344B                                                                                                                                                                    0.0s
 => [internal] load metadata for docker.io/library/ruby:3.3.4                                                                                                                                           2.6s
 => [internal] load .dockerignore                                                                                                                                                                       0.0s
 => => transferring context: 2B                                                                                                                                                                         0.0s
 => [1/2] FROM docker.io/library/ruby:3.3.4@sha256:d4233f4242ea25346f157709bb8417c615e7478468e2699c8e86a4e1f0156de8                                                                                    48.0s
 => => resolve docker.io/library/ruby:3.3.4@sha256:d4233f4242ea25346f157709bb8417c615e7478468e2699c8e86a4e1f0156de8                                                                                     0.0s
 => => sha256:faa8a0eb6d78b389c99ff48e6ecd13a435be5c7a2df30f220f79fb3ea23fe3ca 143B / 143B                                                                                                              0.2s
 => => sha256:61f10e05a7d7a4b5b48011aa8493e5e68e8240719192e66a9e0bdb17dd4f6b30 38.05MB / 38.05MB                                                                                                       15.0s
 => => sha256:cf06774bbc5695ecd62a3b3c88ac91b5da1014564df7a11c9eaf9e4e37e25154 197B / 197B                                                                                                              0.9s
 => => sha256:b46e144614e1ae9b82b5d89d16a31a506542733eabceebfac041e0192dfafcf4 202.62MB / 202.62MB                                                                                                     45.0s
 => => sha256:275677961327bd0cf394699228e29d7caf27f171c627899a20ebc9eeb550e209 63.99MB / 63.99MB                                                                                                       29.4s
 => => sha256:1593650c75729f64218ae272e8ffff9da7bbba9599bd1815877da99a2651fd9b 23.59MB / 23.59MB                                                                                                       10.8s
 => => sha256:7b24851aa36de07cd94173b8e2052846573dacc3b241620d713254e647352394 49.59MB / 49.59MB                                                                                                       18.0s
 => => extracting sha256:7b24851aa36de07cd94173b8e2052846573dacc3b241620d713254e647352394                                                                                                               0.8s
 => => extracting sha256:1593650c75729f64218ae272e8ffff9da7bbba9599bd1815877da99a2651fd9b                                                                                                               0.3s
 => => extracting sha256:275677961327bd0cf394699228e29d7caf27f171c627899a20ebc9eeb550e209                                                                                                               0.9s
 => => extracting sha256:b46e144614e1ae9b82b5d89d16a31a506542733eabceebfac041e0192dfafcf4                                                                                                               2.5s
 => => extracting sha256:cf06774bbc5695ecd62a3b3c88ac91b5da1014564df7a11c9eaf9e4e37e25154                                                                                                               0.0s
 => => extracting sha256:61f10e05a7d7a4b5b48011aa8493e5e68e8240719192e66a9e0bdb17dd4f6b30                                                                                                               0.3s
 => => extracting sha256:faa8a0eb6d78b389c99ff48e6ecd13a435be5c7a2df30f220f79fb3ea23fe3ca                                                                                                               0.0s
 => [2/2] RUN if [ -z "7.2.0" ] ; then gem install rails ; else gem install rails -v 7.2.0 ; fi                                                                                                        10.7s
 => exporting to image                                                                                                                                                                                  1.2s
 => => exporting layers                                                                                                                                                                                 0.9s
 => => exporting manifest sha256:939eb034dc2bc93bd12eafdc8d4356af3b3c8299c9170d3fefad04ba54cc89cf                                                                                                       0.0s
 => => exporting config sha256:7df179bfb130ad543331f1f8575c54ba56807a62257ba090b24c86638c64be33                                                                                                         0.0s
 => => exporting attestation manifest sha256:f7942a5d73f58031f5aadaa7fb933f2a7fcb7c179a440669f36c2c7484f100a0                                                                                           0.0s
 => => exporting manifest list sha256:d79d8d2851a1df0fb6149b07135e341e43905ca257db62cc0dc82ecdf6b3d4ad                                                                                                  0.0s
 => => naming to docker.io/library/rails-new-3.3.4-7.2.0:latest                                                                                                                                         0.0s
 => => unpacking to docker.io/library/rails-new-3.3.4-7.2.0:latest                                                                                                                                      0.2s

View build details: docker-desktop://dashboard/build/desktop-linux/desktop-linux/pu8pj522n2f80lc9k12vg6p92

What's next:
    View a summary of image vulnerabilities and recommendations → docker scout quickview
      create
      create  README.md
      create  Rakefile
      create  .ruby-version
      create  config.ru
      create  .gitignore
      create  .gitattributes
      create  Gemfile
         run  git init -b main from "."
Initialized empty Git repository in /private/tmp/rails-new-tap/rails-app/.git/
      create  app
      create  app/assets/config/manifest.js
      create  app/assets/stylesheets/application.css
      create  app/channels/application_cable/channel.rb
      create  app/channels/application_cable/connection.rb
      create  app/controllers/application_controller.rb
      create  app/helpers/application_helper.rb
      create  app/jobs/application_job.rb
      create  app/mailers/application_mailer.rb
      create  app/models/application_record.rb
      create  app/views/layouts/application.html.erb
      create  app/views/layouts/mailer.html.erb
      create  app/views/layouts/mailer.text.erb
      create  app/views/pwa/manifest.json.erb
      create  app/views/pwa/service-worker.js
      create  app/assets/images
      create  app/assets/images/.keep
      create  app/controllers/concerns/.keep
      create  app/models/concerns/.keep
      create  bin
      create  bin/brakeman
      create  bin/rails
      create  bin/rake
      create  bin/rubocop
      create  bin/setup
      create  Dockerfile
      create  .dockerignore
      create  bin/docker-entrypoint
      create  .rubocop.yml
      create  .github/workflows
      create  .github/workflows/ci.yml
      create  .github/dependabot.yml
      create  config
      create  config/routes.rb
      create  config/application.rb
      create  config/environment.rb
      create  config/cable.yml
      create  config/puma.rb
      create  config/storage.yml
      create  config/environments
      create  config/environments/development.rb
      create  config/environments/production.rb
      create  config/environments/test.rb
      create  config/initializers
      create  config/initializers/assets.rb
      create  config/initializers/content_security_policy.rb
      create  config/initializers/cors.rb
      create  config/initializers/filter_parameter_logging.rb
      create  config/initializers/inflections.rb
      create  config/initializers/new_framework_defaults_7_2.rb
      create  config/initializers/permissions_policy.rb
      create  config/locales
      create  config/locales/en.yml
      create  config/master.key
      append  .gitignore
      create  config/boot.rb
      create  config/database.yml
      create  db
      create  db/seeds.rb
      create  lib
      create  lib/tasks
      create  lib/tasks/.keep
      create  lib/assets
      create  lib/assets/.keep
      create  log
      create  log/.keep
      create  public
      create  public/404.html
      create  public/406-unsupported-browser.html
      create  public/422.html
      create  public/500.html
      create  public/icon.png
      create  public/icon.svg
      create  public/robots.txt
      create  tmp
      create  tmp/.keep
      create  tmp/pids
      create  tmp/pids/.keep
      create  vendor
      create  vendor/.keep
      create  test/fixtures/files
      create  test/fixtures/files/.keep
      create  test/controllers
      create  test/controllers/.keep
      create  test/mailers
      create  test/mailers/.keep
      create  test/models
      create  test/models/.keep
      create  test/helpers
      create  test/helpers/.keep
      create  test/integration
      create  test/integration/.keep
      create  test/channels/application_cable/connection_test.rb
      create  test/test_helper.rb
      create  test/system
      create  test/system/.keep
      create  test/application_system_test_case.rb
      create  storage
      create  storage/.keep
      create  tmp/storage
      create  tmp/storage/.keep
      remove  config/initializers/cors.rb
      remove  config/initializers/new_framework_defaults_7_2.rb
         run  bundle install --quiet
         run  bundle lock --add-platform=x86_64-linux
Writing lockfile to /private/tmp/rails-new-tap/rails-app/Gemfile.lock
         run  bundle binstubs bundler
       rails  importmap:install
       apply  /usr/local/bundle/gems/importmap-rails-2.0.3/lib/install/install.rb
  Add Importmap include tags in application layout
      insert    app/views/layouts/application.html.erb
  Create application.js module as entrypoint
      create    app/javascript/application.js
  Use vendor/javascript for downloaded pins
      create    vendor/javascript
      create    vendor/javascript/.keep
  Ensure JavaScript files are in the Sprocket manifest
      append    app/assets/config/manifest.js
  Configure importmap paths in config/importmap.rb
      create    config/importmap.rb
  Copying binstub
      create    bin/importmap
         run  bundle install --quiet
       rails  turbo:install stimulus:install
       apply  /usr/local/bundle/gems/turbo-rails-2.0.11/lib/install/turbo_with_importmap.rb
  Import Turbo
      append    app/javascript/application.js
  Pin Turbo
      append    config/importmap.rb
         run  bundle install --quiet
       apply  /usr/local/bundle/gems/stimulus-rails-1.3.4/lib/install/stimulus_with_importmap.rb
  Create controllers directory
      create    app/javascript/controllers
      create    app/javascript/controllers/index.js
      create    app/javascript/controllers/application.js
      create    app/javascript/controllers/hello_controller.js
  Import Stimulus controllers
      append    app/javascript/application.js
  Pin Stimulus
  Appending: pin "@hotwired/stimulus", to: "stimulus.min.js"
      append    config/importmap.rb
  Appending: pin "@hotwired/stimulus-loading", to: "stimulus-loading.js"
      append    config/importmap.rb
  Pin all controllers
  Appending: pin_all_from "app/javascript/controllers", under: "controllers"
      append    config/importmap.rb
         run  bundle install --quiet
```

</details>


何やらコンテナイメージがビルドされたあと、見慣れた`rails new`のログが流れて完了します。

完了後、Railsアプリのディレクトリが作られているので見てみます。

```
$ ls -al rails-app
total 96
drwxr-xr-x  26 okonomi  wheel   832 Nov  4 16:56 ./
drwxr-xr-x@ 12 okonomi  wheel   384 Nov  4 16:55 ../
-rw-r--r--   1 okonomi  wheel   854 Nov  4 16:55 .dockerignore
drwxr-xr-x  10 okonomi  wheel   320 Nov  4 16:55 .git/
-rw-r--r--   1 okonomi  wheel   348 Nov  4 16:55 .gitattributes
drwxr-xr-x   4 okonomi  wheel   128 Nov  4 16:55 .github/
-rw-r--r--   1 okonomi  wheel   767 Nov  4 16:55 .gitignore
-rw-r--r--   1 okonomi  wheel   249 Nov  4 16:55 .rubocop.yml
-rw-r--r--   1 okonomi  wheel    11 Nov  4 16:55 .ruby-version
-rw-r--r--   1 okonomi  wheel  2196 Nov  4 16:55 Dockerfile
-rw-r--r--   1 okonomi  wheel  2154 Nov  4 16:55 Gemfile
-rw-r--r--   1 okonomi  wheel  7829 Nov  4 16:56 Gemfile.lock
-rw-r--r--   1 okonomi  wheel   374 Nov  4 16:55 README.md
-rw-r--r--   1 okonomi  wheel   227 Nov  4 16:55 Rakefile
drwxr-xr-x  11 okonomi  wheel   352 Nov  4 16:56 app/
drwxr-xr-x@ 10 okonomi  wheel   320 Nov  4 16:56 bin/
drwxr-xr-x  16 okonomi  wheel   512 Nov  4 16:56 config/
-rw-r--r--   1 okonomi  wheel   160 Nov  4 16:55 config.ru
drwxr-xr-x   3 okonomi  wheel    96 Nov  4 16:55 db/
drwxr-xr-x   4 okonomi  wheel   128 Nov  4 16:55 lib/
drwxr-xr-x   4 okonomi  wheel   128 Nov  4 16:56 log/
drwxr-xr-x   9 okonomi  wheel   288 Nov  4 16:55 public/
drwxr-xr-x   3 okonomi  wheel    96 Nov  4 16:55 storage/
drwxr-xr-x  12 okonomi  wheel   384 Nov  4 16:55 test/
drwxr-xr-x   7 okonomi  wheel   224 Nov  4 16:56 tmp/
drwxr-xr-x   4 okonomi  wheel   128 Nov  4 16:56 vendor/
```

見慣れたファイルが並んでいて、 Railsアプリが作成されているのがわかります。

依存gemがインストールされてないので、インストールしてから、

```
bundle config set path 'vendor/bundle' && bundle install
```

Railsアプリを起動します。

```
$ bin/rails s
=> Booting Puma
=> Rails 7.2.2 application starting in development
=> Run `bin/rails server --help` for more startup options
Puma starting in single mode...
* Puma version: 6.4.3 (ruby 3.3.4-p94) ("The Eagle of Durango")
*  Min threads: 3
*  Max threads: 3
*  Environment: development
*          PID: 54632
* Listening on http://127.0.0.1:3000
* Listening on http://[::1]:3000
Use Ctrl-C to stop
```

`http://127.0.0.1:3000/` をブラウザで開くと、いつものRailsの画面が表示されます。

`rails-new`でRailsアプリを作成できることが確認できました。

RubyやRailsのバージョンを確認しておきます。

```
$ ruby -v
ruby 3.3.4 (2024-07-09 revision be1089c8ec) [arm64-darwin24]
$ bin/rails version
Rails 7.2.2
```

どちらも`rails-new`がデフォルトで設定するバージョンになっています。
先ほどヘルプで見た`--ruby-version`や`--rails-version`で指定してみると、

```
./rails-new --ruby-version 3.3.6 --rails-version 8.0.0 rails-app-80
```

無事バージョンが指定のものに変わりました。

```
$ ruby
ruby 3.3.6 (2024-11-05 revision 75015d4c1f) [arm64-darwin24]
$ bin/rails version
Rails 8.0.0
```

ひとつ注意点で、`rails-new`のオプションはアプリ名の前に書きます。アプリ名以降は`rails new`のオプションとして認識されます。
無効なオプションは無視されてそのまま`rails new`が進んでしまうので気付きづらい…。

```
./rails-new rails-app-mistake --ruby-version 3.3.6 --rails-version 8.0.0
```

```
$ ruby -v
ruby 3.3.4 (2024-07-09 revision be1089c8ec) [arm64-darwin24]
$ bin/rails version
Rails 7.2.2
```

## 仕組み

`rails-new`の使い方が確認できたところで、次はコードを読みながら仕組みを見てみます。

`rails-new`は次の流れで処理を行います。

1. オフィシャルRubyイメージをベースイメージに、`gem install rails`したコンテナイメージをビルド
2. ビルドしたコンテナイメージを使って`rails new`を実行

いずれも`rails-new`から`docker`コマンドを実行する形になっています。

コンテナイメージのビルドはこの箇所:

https://github.com/rails/rails-new/blob/v0.4.1/src/main.rs#L23-L30

```rs
    // Run docker build --build-arg RUBY_VERSION=$RUBY_VERSION --build-arg RAILS_VERSION=$RAILS_VERSION -t rails-new-$RUBY_VERSION-$RAILS_VERSION
    // passing the content of DOCKERFILE to the command stdin
    let mut child = DockerClient::build_image(
        &ruby_version,
        &rails_version,
        os_specific::get_user_id(),
        os_specific::get_group_id(),
    )
    .spawn()
    .expect("Failed to execute process");
```

`Dockerfile`は標準入力から渡される。

https://github.com/rails/rails-new/blob/v0.4.1/src/main.rs#L34-L37

```rs
    let mut stdin = child.stdin.take().expect("Failed to open stdin");
    std::thread::spawn(move || {
        stdin.write_all(os_specific::dockerfile_content()).unwrap();
    });
```

Unix系とそれ以外で`Dockerfile`が分けられていて、Unix系はこれ:

https://github.com/rails/rails-new/blob/v0.4.1/Dockerfile.unix

macOSとWindowsはこちら:

https://github.com/rails/rails-new/blob/v0.4.1/Dockerfile

ふたつの`Dockerfile`の違いはユーザーが作成されるかどうかで、Rootlessモードが考慮されてるのかなと思います。

https://docs.docker.com/engine/security/rootless/

この切り替えはRustの条件付きコンパイルで行われるようです。

https://github.com/rails/rails-new/blob/v0.4.1/src/main.rs#L13-L14

```rs
#[cfg_attr(all(unix, not(target_os = "macos")), path = "unix.rs")]
#[cfg_attr(any(windows, target_os = "macos"), path = "windows.rs")]
```

コンテナビルドの次、`rails new`を実行するのはこの箇所:

https://github.com/rails/rails-new/blob/v0.4.1/src/main.rs#L51-L52

```rs
            // Run the image with docker run -v $(pwd):/$(pwd) -w $(pwd) rails-new-$RUBY_VERSION-$RAILS_VERSION rails new $@
            command = DockerClient::run_image(&ruby_version, &rails_version, cli.args)
```

`-v $(pwd):/$(pwd)`によってホスト側のカレントディレクトリがマウントされ、そこに`rails new`の生成ファイルが書き込まれるので、結果として`rails-new`を実行したディレクトリに`rails new`したRailsアプリが出来上がります。

以上で、コンテナイメージをビルドして、そのコンテナイメージでコンテナを起動して`rails new`を実行する、という流れが確認できました。
`Dockerfile`もほとんど`gem install rails`するだけのシンプルなものでした。

## `rails new`に使う`rails`コマンドをどうやってインストールするか問題

個人的に、Railsアプリを開発するとき`rails`コマンドをどこにインストールするかという問題があると思っています。

Railsアプリがすでに作成されていれば`bin/rails`を使えば問題ないです。
問題は`rails new`で、このときの`rails`コマンドをどうするか。

`gem install rails`するのがまず考えられますが、Railsの依存でいろんなgemがグローバル環境に入るのであんまりやりたくありません。好みの問題かもですが…。

なので、まずプロジェクトローカルに`rails`をインストールしてから`bundle exec rails new . -f`する、みたいなことをやってました。

https://oknm.jp/posts/76a6c160baf571683cc48cdaa45705ff27fdfa96

`rails-new`を使えばグローバル環境を汚染することなく`rails new`を実行できます。
Railsのバージョン指定もオプションできて手軽です。

## 現時点の動作上の問題

`rails new`の`--css`や`--js`オプションを指定するとエラーが発生するようです。

- [Rails new --css boostrap failed · Issue #31 · rails/rails-new](https://github.com/rails/rails-new/issues/31)
- [npx missing when using `--js esbuild` · Issue #22 · rails/rails-new](https://github.com/rails/rails-new/issues/22)

`Dockerfile`にNodeの実行環境のセットアップがないので、それはたしかにそうなるな、という内容です。

また、Windows環境でも動作に問題があるようです。

- [Error on Windows · Issue #28 · rails/rails-new](https://github.com/rails/rails-new/issues/28)

パスの取り扱いに問題があるらしいです。

いずれの問題もそのうち修正されそうなものではあります。

## まとめ

`rails-new`を試してみました。
プレリリースなためか、まだ一部動作に問題があるものの、それを除けば手軽にRailsアプリを作れる便利なツールでした。

このプロジェクト自体今後どう発展するのかわかりませんが（Railsの標準セットアップ手順になるのか、とか）、引き続き見守っていきたいと思います。