rails-dev 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: d79ed132a36f779c8025cd306d4e75f80360de8cfb65409295b8da11bc2badcd
4
+ data.tar.gz: a9db8f42cb70536b5c49adfc58d65bc0736a9483e23473abc6e571f8e98812a0
5
+ SHA512:
6
+ metadata.gz: 323b6bae902098dc3536f66eb643d7b7fadcaeca488a26862a65ad6f3bd4ca469b45e199f137c3025cfb975081a182011b678fd0b720a2610f98dfe482083484
7
+ data.tar.gz: 92deea7d90f997e1d7b6af91d2614c68a133a760fdd49da0f058a65cf100be20c1959eff44cb230e166d61df031dbd4e8918c4e3cc67520edbb06c389aee45cc
data/CHANGELOG.md ADDED
@@ -0,0 +1,11 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 - 2026-09-24
4
+
5
+ - Run app Procfiles through Foreman on available ports, including multiple instances from the same checkout.
6
+ - Provide local HTTPS URLs and optional Tailscale sharing.
7
+ - Start and reuse Docker Compose services on available loopback ports with explicit connection mappings.
8
+ - Support global and app configuration, lifecycle hooks, optional processes, and configurable readiness checks.
9
+ - Configure Rails hostnames, URLs, cookies, and PID files, with an optional helper for Rails Vite apps.
10
+ - List instances across projects and recover interrupted runs while preserving shared services.
11
+ - Support Ruby 3.4 and newer on macOS and Linux, with ERB or Vite apps.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Ali Hamdi Ali Fadel
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,134 @@
1
+ # rails-dev
2
+
3
+ Run Rails on an available port, get a local HTTPS URL, and share it through Tailscale when needed. Start several instances from the same checkout; each gets its own URL.
4
+
5
+ Works on macOS and Linux with ERB or Vite apps. Foreman runs your Procfile. Your app chooses its tools and preparation commands; rails-dev handles ports, HTTPS, and optional Docker Compose services.
6
+
7
+ ## Install
8
+
9
+ | Requirement | When you need it |
10
+ | --- | --- |
11
+ | Ruby 3.4+ | Always; Foreman is included as a dependency |
12
+ | Node.js 24+ and npm | Local HTTPS |
13
+ | Docker Compose 2.24.4+ | Managed Docker services |
14
+ | Connected Tailscale client | Sharing over your tailnet |
15
+
16
+ Install using the same Ruby as your app:
17
+
18
+ ```sh
19
+ gem install rails-dev
20
+ ```
21
+
22
+ For a bundled installation, use `bundle add` below and prefix rails-dev commands with `bundle exec`.
23
+
24
+ ## Set up your app
25
+
26
+ Use your app's usual shell environment and install its dependencies first. Mise is optional.
27
+
28
+ **1. Add the Rails helper to your app:**
29
+
30
+ ```sh
31
+ bundle add rails-dev --group development,test --require rails_dev/railtie
32
+ ```
33
+
34
+ The helper configures hostnames, route and mailer URLs, and cookies for each instance. Apps using `rails_vite` also need the [Vite setup](docs/reference.md#vite).
35
+
36
+ **2. Put your server command in `Procfile.dev`:**
37
+
38
+ ```procfile
39
+ web: bin/rails server -b 127.0.0.1
40
+ ```
41
+
42
+ Keep your existing workers and asset watchers there too. Let commands use the supplied ports; fixed ports in commands or `.env` can override them.
43
+
44
+ Configure [Docker services](#add-docker-services) or [preparation hooks](docs/reference.md#hooks) before starting if your app needs them.
45
+
46
+ **3. Set up local HTTPS once per machine, then start from your app directory:**
47
+
48
+ ```sh
49
+ rails-dev setup
50
+ rails-dev start
51
+ ```
52
+
53
+ `setup` installs the Portless proxy and certificate trust. `start` picks a free port starting at 3000 and prints a URL such as `https://my-app-3001.localhost`.
54
+
55
+ Startup waits for `GET /up` to return HTTP 200. [Change the readiness check](docs/reference.md#readiness) if your app uses another health endpoint.
56
+
57
+ Run `start` in another terminal for a second instance. **Ctrl+C stops that instance**; shared Docker services stay running.
58
+
59
+ ## Everyday commands
60
+
61
+ | Task | Command |
62
+ | --- | --- |
63
+ | Start an instance | `rails-dev start` |
64
+ | List your instances across projects | `rails-dev list` |
65
+ | List one checkout's instances | `rails-dev list --root .` |
66
+ | Stop an instance using its listed ID | `rails-dev stop INSTANCE` |
67
+ | Check local HTTPS | `rails-dev doctor` |
68
+ | See command options | `rails-dev --help` |
69
+
70
+ If a terminal or launcher exits unexpectedly, use `list` and `stop` to recover the instance. [Status and recovery details](docs/reference.md#instances-and-recovery).
71
+
72
+ ## Add Docker services
73
+
74
+ Create `config/rails_dev.rb` in your app. For PostgreSQL:
75
+
76
+ ```ruby
77
+ RailsDev.configure do |config|
78
+ config.compose_file = "dev-docker-compose.yml"
79
+ config.connect "postgres", port: 5432,
80
+ env: { "PGHOST" => "%{host}", "PGPORT" => "%{port}" }
81
+
82
+ config.before_start { |run| run.run("bin/rails", "db:prepare") }
83
+ end
84
+ ```
85
+
86
+ Use your Compose filename and service name. Rails-dev starts enabled services and publishes their declared ports on available loopback ports. It supplies the connection variables you map; your app provides database names and credentials.
87
+
88
+ If your app uses `DATABASE_URL`, map it instead of leaving a fixed URL that overrides `PGHOST` and `PGPORT`. See [Compose configuration](docs/reference.md#docker-compose) for Redis, ClickHouse, profiles, and exclusions.
89
+
90
+ ```sh
91
+ rails-dev services exec -- bin/rails console
92
+ RAILS_ENV=test rails-dev services exec -- bin/rails test
93
+ ```
94
+
95
+ These commands start or reuse services and pass their connections to the host command. Rails does not need to be running.
96
+
97
+ ## Share through Tailscale
98
+
99
+ Connect Tailscale and enable HTTPS in your tailnet's DNS settings, then run:
100
+
101
+ ```sh
102
+ rails-dev start --tailscale
103
+ ```
104
+
105
+ Open the printed URL from another device allowed to reach your machine on the tailnet. Each instance uses its own HTTPS port. [Tailscale permissions and setup](docs/reference.md#tailscale).
106
+
107
+ ## Customize
108
+
109
+ App settings live in `config/rails_dev.rb` and override global defaults. Keep personal overrides in `config/rails_dev.local.rb` and add that file to your app's ignore list.
110
+
111
+ | What you want to change | Guide |
112
+ | --- | --- |
113
+ | App name, ports, Procfile, or defaults | [Configuration](docs/reference.md#configuration) |
114
+ | Environment and process commands | [Processes and environment](docs/reference.md#processes-and-environment) |
115
+ | More HTTP servers | [Additional HTTP servers](docs/reference.md#additional-http-servers) |
116
+ | Preparation and lifecycle hooks | [Hooks](docs/reference.md#hooks) |
117
+ | Optional processes, such as Stripe | [Optional processes](docs/reference.md#optional-processes) |
118
+ | Vite URLs and hot reload | [Vite](docs/reference.md#vite) |
119
+ | Independent restarts and shared assets | [Multiple runs](docs/reference.md#multiple-runs-from-one-checkout) |
120
+ | Plain HTTP or a different HTTPS proxy port | [Local HTTPS](docs/reference.md#local-https) |
121
+
122
+ OAuth relay, public webhook relay, and direct LAN HTTPS are deferred.
123
+
124
+ ## Development
125
+
126
+ ```sh
127
+ mise ci
128
+ mise run test:docker
129
+ mise run test:https
130
+ ```
131
+
132
+ Without Mise, use `bundle exec rake test rubocop build`, `bundle exec rake test_docker`, and `bundle exec rake test_https`.
133
+
134
+ Tests use Minitest. CI covers Ruby 3.4 and 4.0 on Linux and macOS.
data/docs/reference.md ADDED
@@ -0,0 +1,220 @@
1
+ # Configuration reference
2
+
3
+ See the [README](../README.md) for installation and everyday commands. Ruby examples below belong in `config/rails_dev.rb` unless another file is named.
4
+
5
+ ## Configuration
6
+
7
+ Settings load in order:
8
+
9
+ 1. Gem defaults.
10
+ 2. `$XDG_CONFIG_HOME/rails-dev/config.rb`, defaulting to `~/.config/rails-dev/config.rb`.
11
+ 3. Your app's `config/rails_dev.rb`.
12
+ 4. Your app's optional `config/rails_dev.local.rb`.
13
+
14
+ Later assignments replace earlier values. Arrays and hooks can be extended or cleared. Repeated `http` declarations merge options, including readiness settings.
15
+
16
+ | Setting | Default |
17
+ | --- | --- |
18
+ | `name` | Derived from the checkout directory name |
19
+ | `procfile` | `"Procfile.dev"` |
20
+ | `web` | `"web"`, the primary HTTP process |
21
+ | `https` | `true` |
22
+ | `startup_timeout` | 90 seconds |
23
+ | `shutdown_timeout` | 5 seconds |
24
+ | `compose_file` | Unset; Docker management is off |
25
+
26
+ Use `--root PATH` to select the working directory, even when the Procfile lives in a subdirectory.
27
+
28
+ ## Processes and environment
29
+
30
+ Foreman runs your original Procfile and handles sibling shutdown when a process exits. Its normal `.env` behavior applies. Values loaded by Foreman are unavailable to earlier preparation hooks; the app must supply those separately.
31
+
32
+ | Variable | Meaning |
33
+ | --- | --- |
34
+ | `PORT` | Selected port in the primary web process; other processes follow Foreman's usual numbering |
35
+ | `RAILS_PORT` | Primary web port, available to every process |
36
+ | `RAILS_DEV_URL` | Primary public URL |
37
+ | `RAILS_DEV_RUNTIME` | This instance's runtime directory |
38
+
39
+ Procfile commands have no terminal input. Use `services exec` for interactive commands. Rails-dev inherits your shell environment and removes the launcher's Bundler settings from child processes.
40
+
41
+ ## Docker Compose
42
+
43
+ Extend the [PostgreSQL example](../README.md#add-docker-services) with the connections and service selections your app needs:
44
+
45
+ ```ruby
46
+ RailsDev.configure do |config|
47
+ config.connect "redis", port: 6379,
48
+ env: { "REDIS_URL" => "redis://%{host}:%{port}/0" }
49
+ config.connect "clickhouse", port: 8123,
50
+ env: { "CLICKHOUSE_URL" => "http://%{host}:%{port}" }
51
+ config.profiles = ["debug"]
52
+ config.exclude_services = ["mail"]
53
+ config.publish_ports["internal"] = [6379]
54
+ end
55
+ ```
56
+
57
+ Templates accept `%{host}` and `%{port}`. Credentials and database names remain app settings. An inherited `DATABASE_URL` with a PGPORT-only mapping is rejected; map the URL or remove it from the launch environment.
58
+
59
+ - All Compose-enabled services start, respecting dependencies and profiles. Any service type can use explicit connection mappings.
60
+ - Declared publications and requested ports receive available loopback ports. Existing containers are reused.
61
+ - Healthchecks determine readiness where present; otherwise running status is enough.
62
+ - Required dependencies cannot be excluded. Host networking, scaled services, and conflicting or non-loopback publications are unsupported.
63
+
64
+ `rails-dev services start` starts shared services. `rails-dev services stop` stops them for **every instance**, keeping containers and volumes. Both require Compose configuration.
65
+
66
+ `services exec` preserves arguments, terminal input, and exit status. It skips web hooks and preserves `RAILS_ENV`. Without Compose configured, it uses the existing environment.
67
+
68
+ ## Additional HTTP servers
69
+
70
+ Match the endpoint name to its Procfile process and declare the port variable that command consumes:
71
+
72
+ ```ruby
73
+ RailsDev.configure do |config|
74
+ config.http :js, port: 5173, env: "VITE_PORT",
75
+ url_env: "RAILS_DEV_VITE_URL", readiness: "/@vite/client"
76
+ end
77
+ ```
78
+
79
+ Add to `Procfile.dev`:
80
+
81
+ ```procfile
82
+ js: pnpm exec vite --host 127.0.0.1 --port ${VITE_PORT:-5173} --strictPort
83
+ ```
84
+
85
+ Here, `js` is the process name. Vite's `--strictPort` makes a taken port fail; rails-dev retries confirmed bind conflicts without repeating preparation hooks.
86
+
87
+ Each endpoint gets a URL such as `https://my-app-3001-js.localhost`. `url_env` defaults to `RAILS_DEV_<PROCESS>_URL`, using uppercase and replacing hyphens with underscores. Processes that do not serve HTTP need no endpoint declaration.
88
+
89
+ ## Hooks
90
+
91
+ ```ruby
92
+ RailsDev.configure do |config|
93
+ config.around_run do |run, continue|
94
+ continue.call
95
+ ensure
96
+ run.say("Run finished")
97
+ end
98
+ end
99
+ ```
100
+
101
+ | Hook | When it runs |
102
+ | --- | --- |
103
+ | `before_start` | After services and connection mapping, before Foreman |
104
+ | `after_ready` | After enabled readiness checks pass |
105
+ | `around_run` | Around preparation, startup, and the app run; call its continuation once |
106
+ | `after_stop` | After processes stop and around hooks unwind, including on errors and interrupts |
107
+
108
+ Hooks run once per invocation. Before/ready hooks run in registration order; stop hooks run in reverse order and all are attempted. Cleanup errors do not replace an earlier failure. Clear inherited hooks with `config.before_start.clear`.
109
+
110
+ The context exposes `root`, `runtime`, `env`, `url(process = primary_web)`, `error`, and `enabled?(name)`. URLs become available after networking is allocated.
111
+
112
+ `run(*argv)` streams output; `capture(*argv)` returns stdout. Both use argument arrays without a shell and run in the app directory with `run.env`. Use `say(message)` for output and `check_for_interrupt` during long custom work.
113
+
114
+ ## Optional processes
115
+
116
+ Declare a Procfile process with `config.optional` and select it through `--with`. For Stripe, install its CLI and run `stripe login` first:
117
+
118
+ ```ruby
119
+ RailsDev.configure do |config|
120
+ config.optional :stripe
121
+ config.before_start do |run|
122
+ next unless run.enabled?(:stripe)
123
+
124
+ run.env["STRIPE_SIGNING_SECRET"] = run.capture("stripe", "listen", "--print-secret").strip
125
+ end
126
+ end
127
+ ```
128
+
129
+ Add to `Procfile.dev`, using your app's webhook path:
130
+
131
+ ```procfile
132
+ stripe: stripe listen --skip-update --forward-to http://127.0.0.1:${RAILS_PORT}/pay/webhooks/stripe
133
+ ```
134
+
135
+ Start with `rails-dev start --with stripe`. Foreman starts selected processes together, so webhooks can arrive before Rails is ready.
136
+
137
+ - `config.optional :listener, with: :payments` lets a selection enable a differently named process. Several processes can share one selection.
138
+ - For optional hook work alone, add a name to `config.integrations` and check `run.enabled?`. Unknown selections fail before startup.
139
+
140
+ ## Readiness
141
+
142
+ The primary endpoint defaults to `GET /up`, expecting HTTP 200; other endpoints need an explicit check. Requests use loopback HTTP with the public hostname in the Host header.
143
+
144
+ ```ruby
145
+ RailsDev.configure do |config|
146
+ config.readiness = { request_timeout: 1, interval: 0.1 }
147
+ config.http :web, readiness: { path: "/health", success: 200, timeout: 30 }
148
+ end
149
+ ```
150
+
151
+ | Option | Meaning |
152
+ | --- | --- |
153
+ | `path`, `method`, `headers`, `body` | Request settings; method defaults to `GET` |
154
+ | `success` | Status code, array of codes, or callable receiving the response; defaults to `200` |
155
+ | `timeout` | Endpoint timeout, also bounded by `startup_timeout` |
156
+ | `request_timeout` | Timeout for each connection, read, or write; defaults to 1 second |
157
+ | `interval` | Polling interval; defaults to 0.1 seconds |
158
+
159
+ `config.readiness` supplies shared defaults; endpoint settings override them. Use `readiness: false` to disable a check or `readiness: ->(endpoint) { ... }` for a custom check. The endpoint exposes `name`, `port`, and `url`.
160
+
161
+ Custom checks must return promptly and manage their own timeouts. Checks gate `after_ready` and the Ready message, not process launch order or ongoing health. A timeout stops owned processes and removes their routes.
162
+
163
+ ## Vite
164
+
165
+ For `rails_vite` and `rails-vite-plugin`, load both helpers in your Gemfile's development/test group:
166
+
167
+ ```ruby
168
+ gem "rails-dev", require: ["rails_dev/railtie", "rails_dev/rails_vite"]
169
+ ```
170
+
171
+ Declare the [HTTP endpoint](#additional-http-servers) with `url_env: "RAILS_DEV_VITE_URL"`. Add the helper to your existing Vite configuration, keeping your other plugins. Its returned options can be customized:
172
+
173
+ ```js
174
+ export default defineConfig(async ({ command }) => {
175
+ const runtime = command === 'serve' ? process.env.RAILS_DEV_RUNTIME : undefined
176
+ const development = runtime ? (await import(`${runtime}/vite.mjs`)).default() : undefined
177
+
178
+ return {
179
+ plugins: [development?.plugin, rails(development?.rails)],
180
+ }
181
+ })
182
+ ```
183
+
184
+ ## Multiple runs from one checkout
185
+
186
+ Ports, URLs, Rails PID files, cookies, Vite metadata, and Vite caches are separate per run. Compiled JavaScript and CSS remain shared.
187
+
188
+ For independent restarts, make Puma's shared restart watcher conditional in `config/puma.rb`:
189
+
190
+ ```ruby
191
+ plugin :tmp_restart unless ENV["RAILS_DEV_RUNTIME"]
192
+ ```
193
+
194
+ ## Local HTTPS
195
+
196
+ Use `rails-dev setup --port 8443` for another proxy port, or `config.https = false` for loopback HTTP. `.localhost` URLs work only on the developer's own machine.
197
+
198
+ ## Tailscale
199
+
200
+ Follow the [Tailscale setup](../README.md#share-through-tailscale). On Linux, an administrator may need to grant operator access:
201
+
202
+ ```sh
203
+ sudo tailscale set --operator="$USER"
204
+ ```
205
+
206
+ Tailscale mode needs no local Portless setup. Each endpoint gets an HTTPS port on the machine's tailnet hostname. Existing Serve routes and tailnet permissions are preserved; Funnel is not enabled.
207
+
208
+ ## Instances and recovery
209
+
210
+ | Status | Meaning |
211
+ | --- | --- |
212
+ | `starting` | Preparation or startup checks are running |
213
+ | `ready` | Startup and ready hooks completed |
214
+ | `orphaned` | The launcher exited, but verified processes remain |
215
+ | `stale` | No processes remain in the recorded groups |
216
+ | `interrupted` | Process ownership cannot be verified |
217
+
218
+ Status and saved URLs are not ongoing health checks. Use `rails-dev stop INSTANCE` for recovery; cleanup is not automatic after a forced launcher exit.
219
+
220
+ Unverified ownership keeps the record for inspection. Detached daemons and unrecorded processes cannot be recovered this way. Shared Docker services and the HTTPS proxy stay running.
data/exe/rails-dev ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../lib/rails_dev"
5
+ $stdout.sync = true
6
+ exit RailsDev::CLI.run(ARGV)
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsDev
4
+ class Child
5
+ attr_reader :pid, :name, :stdout, :stderr, :status
6
+
7
+ def initialize(name, argv, env:, root:, output: nil, ready_message: nil, keep_stdin_open: false)
8
+ @name = name
9
+ @output = output
10
+ @stdout = +""
11
+ @stderr = +""
12
+ @readers = {}
13
+ @ready_message = ready_message
14
+ @ready = ready_message.nil?
15
+ spawn(argv, env, root, keep_stdin_open)
16
+ end
17
+
18
+ def poll
19
+ @readers.each { |reader, buffer| drain(reader, buffer) unless reader.closed? }
20
+ @status ||= Process.waitpid2(pid, Process::WNOHANG)&.last
21
+ @status
22
+ end
23
+
24
+ def running?
25
+ poll.nil?
26
+ end
27
+
28
+ def ready?
29
+ @ready
30
+ end
31
+
32
+ def failure_message
33
+ detail = stderr.strip.empty? ? stdout.strip : stderr.strip
34
+ ["#{name} failed (#{exit_reason}).", detail].reject(&:empty?).join("\n")
35
+ end
36
+
37
+ def exit_reason
38
+ return "exit status #{status.exitstatus}" unless status.signaled?
39
+
40
+ "signal #{Signal.signame(status.termsig) || status.termsig}"
41
+ end
42
+
43
+ def startup_conflict?
44
+ output.match?(/EADDRINUSE|Address already in use|Port \d+ is already in use/i) ||
45
+ (name.start_with?("tailscale-") &&
46
+ output.match?(/listener already exists for port|Another client is changing the serve config/))
47
+ end
48
+
49
+ def reap
50
+ @status ||= Process.waitpid2(pid).last
51
+ poll
52
+ end
53
+
54
+ def group_alive?
55
+ !!signal(0)
56
+ end
57
+
58
+ def signal(value)
59
+ Process.kill(value, -pid)
60
+ rescue Errno::ESRCH
61
+ nil
62
+ rescue Errno::EPERM
63
+ raise unless ProcessTable.new.members([pid]).empty?
64
+ end
65
+
66
+ def output
67
+ "#{stdout}\n#{stderr}"
68
+ end
69
+
70
+ def close
71
+ @readers.each_key { |reader| reader.close unless reader.closed? }
72
+ @input&.close
73
+ end
74
+
75
+ private
76
+
77
+ def spawn(argv, env, root, keep_stdin_open)
78
+ in_read, @input = IO.pipe if keep_stdin_open
79
+ out_read, out_write = IO.pipe
80
+ err_read, err_write = IO.pipe
81
+ @readers = { out_read => @stdout, err_read => @stderr }
82
+ command = [argv.first, argv.first]
83
+ @pid = Process.spawn(env, command, *argv.drop(1), chdir: root, unsetenv_others: true, pgroup: true,
84
+ in: in_read || File::NULL, out: out_write, err: err_write)
85
+ rescue Errno::ENOENT
86
+ close
87
+ raise Error, "Command not found: #{argv.first}. Make it available on PATH before starting rails-dev."
88
+ ensure
89
+ in_read&.close
90
+ out_write&.close
91
+ err_write&.close
92
+ end
93
+
94
+ def drain(reader, buffer)
95
+ loop do
96
+ chunk = reader.read_nonblock(8192, exception: false)
97
+ break if chunk == :wait_readable
98
+ return reader.close if chunk.nil?
99
+
100
+ record_output(buffer, chunk)
101
+ end
102
+ end
103
+
104
+ def record_output(buffer, chunk)
105
+ buffer << chunk
106
+ @ready ||= buffer.include?(@ready_message) if @ready_message
107
+ @output&.write("[#{name}] #{chunk}")
108
+ buffer.replace(buffer.byteslice(-32_768, 32_768)) if @output && buffer.bytesize > 32_768
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsDev
4
+ module CLI
5
+ class Base < Thor
6
+ def self.exit_on_failure?
7
+ true
8
+ end
9
+
10
+ remove_command :tree
11
+ check_unknown_options!
12
+
13
+ class_option :root, type: :string, banner: "PATH",
14
+ desc: "App directory (default: current directory; list/stop: all projects)"
15
+ class_option :help, type: :boolean, aliases: "-h", desc: "Show help"
16
+ class_option :version, type: :boolean, aliases: "-v", desc: "Print version"
17
+
18
+ no_commands do
19
+ def invoke_command(command, *arguments)
20
+ if options[:version]
21
+ say VERSION
22
+ elsif options[:help]
23
+ help(command.name == "help" ? nil : command.name)
24
+ else
25
+ super
26
+ end
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ def root
33
+ @root ||= File.realpath(options[:root] || Dir.pwd)
34
+ end
35
+
36
+ def configuration
37
+ Configuration.load(root)
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsDev
4
+ module CLI
5
+ class Main < Base
6
+ desc "setup", "Install the pinned Portless version, trust its CA, and start the shared HTTPS proxy"
7
+ option :port, type: :numeric, default: 443, desc: "HTTPS proxy port"
8
+ def setup
9
+ Portless.new(Commands.new(root, output: shell.output)).setup(port: options[:port])
10
+ end
11
+
12
+ desc "doctor", "Check the shared proxy, certificates, and hostname resolution"
13
+ def doctor
14
+ Portless.new(Commands.new(root, output: shell.output)).doctor
15
+ end
16
+
17
+ desc "start", "Run the app's Procfile with available ports and HTTPS"
18
+ option :with, type: :array, banner: "NAME [NAME...]", desc: "Enable optional integrations declared by the app"
19
+ option :tailscale, type: :boolean, default: false, desc: "Share this run over HTTPS on your tailnet"
20
+ def start
21
+ config = configuration
22
+ config.enable_integrations(options[:with])
23
+ Session.new(root, config, tailscale: options[:tailscale], output: shell.output).start
24
+ end
25
+
26
+ desc "list", "List your active and interrupted instances across projects"
27
+ def list
28
+ selected_root = root if options[:root]
29
+ runs = State.new.runs
30
+ runs.select! { |run| run.fetch("root") == selected_root } if selected_root
31
+ return say("No instances.") if runs.empty?
32
+
33
+ rows = runs.flat_map { |run| instance_rows(run) }
34
+ print_table([%w[INSTANCE APP PID STATUS CHECKOUT ENDPOINT URL], *rows], truncate: false)
35
+ end
36
+
37
+ desc "stop INSTANCE", "Stop one instance and recover its interrupted processes"
38
+ def stop(instance)
39
+ State.new.stop(instance, root: options[:root] ? root : nil)
40
+ say "Stopped #{instance}."
41
+ end
42
+
43
+ desc "services COMMAND", "Manage Compose services and run host commands"
44
+ subcommand "services", Services
45
+ stop_on_unknown_option! :services
46
+
47
+ no_commands do
48
+ remove_method :services
49
+
50
+ def services(*arguments)
51
+ raise Thor::InvocationError, "Specify a command. See rails-dev services --help." if arguments.empty?
52
+
53
+ Services.start(arguments, debug: true, shell: shell, class_options: options)
54
+ end
55
+ end
56
+
57
+ private
58
+
59
+ def instance_rows(run)
60
+ urls = run.fetch("urls")
61
+ urls = { "" => "" } if urls.empty?
62
+ urls.each_with_index.map do |(name, url), index|
63
+ fields = index.zero? ? run.values_at("id", "name", "pid", "status", "root") : Array.new(5, "")
64
+ fields + [name, url]
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsDev
4
+ module CLI
5
+ class Services < Base
6
+ desc "start", "Start or reuse Compose services"
7
+ def start
8
+ run("start")
9
+ end
10
+
11
+ desc "stop", "Stop Compose services, keeping containers and volumes"
12
+ def stop
13
+ run("stop")
14
+ end
15
+
16
+ desc "exec -- COMMAND [ARGS]", "Run a host command with service connection settings"
17
+ def exec(command, *arguments)
18
+ run("exec", [command, *arguments])
19
+ end
20
+
21
+ private
22
+
23
+ def run(action, arguments = [])
24
+ RailsDev::Services.new(root, configuration, output: shell.output).run(action, arguments)
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsDev
4
+ module CLI
5
+ class Shell < Thor::Shell::Basic
6
+ attr_reader :output
7
+
8
+ def initialize(output, error)
9
+ super()
10
+ @output = output
11
+ @error = error
12
+ end
13
+
14
+ private
15
+
16
+ def stdout
17
+ @output
18
+ end
19
+
20
+ def stderr
21
+ @error
22
+ end
23
+ end
24
+ end
25
+ end