gemstack-dev 0.2.4 → 0.3.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 +4 -4
- data/CHANGELOG.md +2 -22
- data/README.md +6 -16
- data/lib/gemstack-dev.rb +5 -0
- metadata +21 -17
- data/lib/gemstack/dev/file_watcher.rb +0 -58
- data/lib/gemstack/dev/gateway.rb +0 -220
- data/lib/gemstack/dev/managed_process.rb +0 -96
- data/lib/gemstack/dev/ports.rb +0 -26
- data/lib/gemstack/dev/supervisor.rb +0 -267
- data/lib/gemstack/dev/terminal.rb +0 -40
- data/lib/gemstack/dev/toolchain.rb +0 -86
- data/lib/gemstack/dev.rb +0 -43
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fb88e004507f1d705767b95ed2c9ebd5f3359d222671380c7cf9a06699ad1f20
|
|
4
|
+
data.tar.gz: a23d9b91475eec43af979ebc888bd0973d04799cd93b5883f1bc9295684387d5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a1b7a5c31396f6261abe9359dd98d72acddcd322307ee53094ef118daa9c56334e5b43c61b5bff142a8f5fc0b7c895dcf542571dc36a173be131e4cdd43ea1b9
|
|
7
|
+
data.tar.gz: ab411641ae5b0c8b8e511ecab3820b3697c4fa95388af55fb94b5009b1fa76017da63cd99a92c80b962caca294d511e99aba7cdbf6b72b2ca8f70c742316f07c
|
data/CHANGELOG.md
CHANGED
|
@@ -1,25 +1,5 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 0.
|
|
3
|
+
## 0.3.0
|
|
4
4
|
|
|
5
|
-
See the [GemStack changelog](https://github.com/gemstack-rb/gemstack/blob/main/CHANGELOG.md).
|
|
6
|
-
|
|
7
|
-
## 0.2.3
|
|
8
|
-
|
|
9
|
-
See the [GemStack changelog](https://github.com/gemstack-rb/gemstack/blob/main/CHANGELOG.md).
|
|
10
|
-
|
|
11
|
-
## 0.2.2
|
|
12
|
-
|
|
13
|
-
See the [GemStack changelog](https://github.com/gemstack-rb/gemstack/blob/main/CHANGELOG.md).
|
|
14
|
-
|
|
15
|
-
## 0.2.1
|
|
16
|
-
|
|
17
|
-
See the [GemStack changelog](https://github.com/gemstack-rb/gemstack/blob/main/CHANGELOG.md).
|
|
18
|
-
|
|
19
|
-
## 0.2.0
|
|
20
|
-
|
|
21
|
-
See the [GemStack changelog](https://github.com/gemstack-rb/gemstack/blob/main/CHANGELOG.md).
|
|
22
|
-
|
|
23
|
-
## 0.1.0
|
|
24
|
-
|
|
25
|
-
First release. See the [GemStack changelog](https://github.com/gemstack-rb/gemstack/blob/main/CHANGELOG.md).
|
|
5
|
+
Merged into the gemstack gem; this version is a transition shim. See the [GemStack changelog](https://github.com/gemstack-rb/gemstack/blob/main/CHANGELOG.md).
|
data/README.md
CHANGED
|
@@ -1,23 +1,13 @@
|
|
|
1
1
|
# gemstack-dev
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**Merged into [`gemstack`](https://rubygems.org/gems/gemstack) in GemStack 0.3.0.**
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
This version is a transition shim: it depends on `gemstack` and loads `gemstack/dev`, so
|
|
6
|
+
Gemfiles that still list `gemstack-dev` keep working. To finish upgrading, remove `gem "gemstack-dev"`
|
|
7
|
+
from your Gemfile (it's loaded by `require "gemstack"`).
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
Installed with the `gemstack` gem; you rarely need to add it yourself.
|
|
12
|
-
|
|
13
|
-
## Documentation
|
|
14
|
-
|
|
15
|
-
- [Guide](https://github.com/gemstack-rb/gemstack/blob/main/docs/development.md)
|
|
16
|
-
- [All guides](https://github.com/gemstack-rb/gemstack/tree/main/docs) ·
|
|
17
|
-
[Architecture](https://github.com/gemstack-rb/gemstack/blob/main/ARCHITECTURE.md)
|
|
18
|
-
|
|
19
|
-
Source, issues and pull requests: [gemstack-rb/gemstack](https://github.com/gemstack-rb/gemstack)
|
|
20
|
-
(this gem lives in `gems/gemstack-dev`).
|
|
9
|
+
GemStack is a modular Ruby API framework for Next.js applications by
|
|
10
|
+
[Adware Technologies](https://www.adwaretech.com) — [gemstack-rb/gemstack](https://github.com/gemstack-rb/gemstack).
|
|
21
11
|
|
|
22
12
|
## License
|
|
23
13
|
|
data/lib/gemstack-dev.rb
ADDED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: gemstack-dev
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Adware Technologies
|
|
@@ -11,19 +11,28 @@ cert_chain: []
|
|
|
11
11
|
date: 1980-01-02 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
|
-
name: gemstack
|
|
14
|
+
name: gemstack
|
|
15
15
|
requirement: !ruby/object:Gem::Requirement
|
|
16
16
|
requirements:
|
|
17
|
-
- -
|
|
17
|
+
- - ">="
|
|
18
18
|
- !ruby/object:Gem::Version
|
|
19
|
-
version: 0.
|
|
19
|
+
version: 0.3.0
|
|
20
|
+
- - "<"
|
|
21
|
+
- !ruby/object:Gem::Version
|
|
22
|
+
version: '1.0'
|
|
20
23
|
type: :runtime
|
|
21
24
|
prerelease: false
|
|
22
25
|
version_requirements: !ruby/object:Gem::Requirement
|
|
23
26
|
requirements:
|
|
24
|
-
- -
|
|
27
|
+
- - ">="
|
|
28
|
+
- !ruby/object:Gem::Version
|
|
29
|
+
version: 0.3.0
|
|
30
|
+
- - "<"
|
|
25
31
|
- !ruby/object:Gem::Version
|
|
26
|
-
version: 0
|
|
32
|
+
version: '1.0'
|
|
33
|
+
description: Since GemStack 0.3.0, gemstack-dev is part of the gemstack gem. This
|
|
34
|
+
version only depends on gemstack and loads gemstack/dev, so Gemfiles that still
|
|
35
|
+
list it keep working.
|
|
27
36
|
email:
|
|
28
37
|
- gemstack26@gmail.com
|
|
29
38
|
executables: []
|
|
@@ -33,23 +42,18 @@ files:
|
|
|
33
42
|
- CHANGELOG.md
|
|
34
43
|
- LICENSE.txt
|
|
35
44
|
- README.md
|
|
36
|
-
- lib/gemstack
|
|
37
|
-
- lib/gemstack/dev/file_watcher.rb
|
|
38
|
-
- lib/gemstack/dev/gateway.rb
|
|
39
|
-
- lib/gemstack/dev/managed_process.rb
|
|
40
|
-
- lib/gemstack/dev/ports.rb
|
|
41
|
-
- lib/gemstack/dev/supervisor.rb
|
|
42
|
-
- lib/gemstack/dev/terminal.rb
|
|
43
|
-
- lib/gemstack/dev/toolchain.rb
|
|
45
|
+
- lib/gemstack-dev.rb
|
|
44
46
|
homepage: https://github.com/gemstack-rb/gemstack
|
|
45
47
|
licenses:
|
|
46
48
|
- MIT
|
|
47
49
|
metadata:
|
|
48
50
|
rubygems_mfa_required: 'true'
|
|
49
|
-
source_code_uri: https://github.com/gemstack-rb/gemstack
|
|
50
|
-
changelog_uri: https://github.com/gemstack-rb/gemstack/blob/main/
|
|
51
|
+
source_code_uri: https://github.com/gemstack-rb/gemstack
|
|
52
|
+
changelog_uri: https://github.com/gemstack-rb/gemstack/blob/main/CHANGELOG.md
|
|
51
53
|
bug_tracker_uri: https://github.com/gemstack-rb/gemstack/issues
|
|
52
54
|
documentation_uri: https://github.com/gemstack-rb/gemstack/tree/main/docs
|
|
55
|
+
post_install_message: gemstack-dev is now part of the gemstack gem. Remove `gem "gemstack-dev"`
|
|
56
|
+
from your Gemfile (it's loaded by `require "gemstack"`).
|
|
53
57
|
rdoc_options: []
|
|
54
58
|
require_paths:
|
|
55
59
|
- lib
|
|
@@ -66,5 +70,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
66
70
|
requirements: []
|
|
67
71
|
rubygems_version: 4.0.20
|
|
68
72
|
specification_version: 4
|
|
69
|
-
summary:
|
|
73
|
+
summary: Merged into the gemstack gem — remove gemstack-dev from your Gemfile
|
|
70
74
|
test_files: []
|
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "digest"
|
|
4
|
-
|
|
5
|
-
module GemStack
|
|
6
|
-
module Dev
|
|
7
|
-
# Detects changes (edits, additions, deletions) in a set of glob patterns.
|
|
8
|
-
# Polling keeps it dependency-free and reliable across editors, containers
|
|
9
|
-
# and network filesystems; it is only used in development.
|
|
10
|
-
#
|
|
11
|
-
# Modification times are the cheap first check; a file whose mtime changed
|
|
12
|
-
# only counts as changed if its content digest changed too. Tools that
|
|
13
|
-
# rewrite files with identical content — Bundler 4 rewrites Gemfile.lock on
|
|
14
|
-
# every `bundle exec` — therefore don't trigger reloads or restarts.
|
|
15
|
-
class FileWatcher
|
|
16
|
-
Entry = Struct.new(:mtime, :bytes, :digest)
|
|
17
|
-
|
|
18
|
-
def initialize(patterns, root: Dir.pwd, exclude: [])
|
|
19
|
-
@patterns = Array(patterns)
|
|
20
|
-
@exclude = Array(exclude)
|
|
21
|
-
@root = root.to_s
|
|
22
|
-
@entries = scan({})
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
# True once per change: the new state becomes the baseline.
|
|
26
|
-
def changed?
|
|
27
|
-
current = scan(@entries)
|
|
28
|
-
changed = current.keys != @entries.keys ||
|
|
29
|
-
current.any? { |path, entry| entry.digest != @entries[path].digest }
|
|
30
|
-
@entries = current
|
|
31
|
-
changed
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
def files
|
|
35
|
-
found = Dir.glob(@patterns, base: @root)
|
|
36
|
-
found.reject! { |file| @exclude.any? { |pattern| File.fnmatch?(pattern, file, File::FNM_PATHNAME) } }
|
|
37
|
-
found.sort
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
private
|
|
41
|
-
|
|
42
|
-
# Reuses the previous digest when mtime and size are unchanged.
|
|
43
|
-
def scan(previous)
|
|
44
|
-
files.each_with_object({}) do |file, entries|
|
|
45
|
-
path = File.join(@root, file)
|
|
46
|
-
stat = File.stat(path)
|
|
47
|
-
next if stat.directory?
|
|
48
|
-
|
|
49
|
-
old = previous[file]
|
|
50
|
-
same = old && old.mtime == stat.mtime && old.bytes == stat.size
|
|
51
|
-
entries[file] = Entry.new(stat.mtime, stat.size, same ? old.digest : Digest::SHA256.file(path).hexdigest)
|
|
52
|
-
rescue Errno::ENOENT
|
|
53
|
-
next
|
|
54
|
-
end
|
|
55
|
-
end
|
|
56
|
-
end
|
|
57
|
-
end
|
|
58
|
-
end
|
data/lib/gemstack/dev/gateway.rb
DELETED
|
@@ -1,220 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "socket"
|
|
4
|
-
require "json"
|
|
5
|
-
|
|
6
|
-
module GemStack
|
|
7
|
-
module Dev
|
|
8
|
-
# The development gateway: one public origin in front of Next.js and the
|
|
9
|
-
# Ruby API (DECISIONS D-008).
|
|
10
|
-
#
|
|
11
|
-
# For each connection it reads only the request line and headers, picks
|
|
12
|
-
# an upstream by path (api_path → API, everything else → frontend), adds
|
|
13
|
-
# X-Forwarded-* headers and then copies raw bytes in both directions.
|
|
14
|
-
# Because it never interprets bodies, streaming responses, Server-Sent
|
|
15
|
-
# Events and WebSocket upgrades (Next.js HMR, realtime) just work.
|
|
16
|
-
#
|
|
17
|
-
# Each request gets its own upstream connection (`Connection: close`
|
|
18
|
-
# upstream), so routing stays correct per request without parsing
|
|
19
|
-
# response framing. That trade-off is fine on localhost and is the reason
|
|
20
|
-
# this gateway is for development only.
|
|
21
|
-
class Gateway
|
|
22
|
-
Upstream = Struct.new(:name, :host, :port, :label) do
|
|
23
|
-
def to_s = "#{host}:#{port}"
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
MAX_HEAD = 64 * 1024
|
|
27
|
-
CONNECT_TIMEOUT = 2
|
|
28
|
-
HOP_BY_HOP = %w[connection keep-alive proxy-connection].freeze
|
|
29
|
-
FORWARDED = %w[x-forwarded-for x-forwarded-proto x-forwarded-host x-forwarded-port].freeze
|
|
30
|
-
|
|
31
|
-
attr_reader :port
|
|
32
|
-
attr_accessor :api, :frontend
|
|
33
|
-
|
|
34
|
-
def initialize(port:, api_path:, api:, frontend: nil, bind: %w[127.0.0.1 ::1], on_error: nil)
|
|
35
|
-
@port = port
|
|
36
|
-
@api_path = api_path.to_s.chomp("/")
|
|
37
|
-
@api = api
|
|
38
|
-
@frontend = frontend
|
|
39
|
-
@bind = Array(bind)
|
|
40
|
-
@on_error = on_error
|
|
41
|
-
@servers = []
|
|
42
|
-
@threads = []
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
def start
|
|
46
|
-
@bind.each do |host|
|
|
47
|
-
server = listen(host)
|
|
48
|
-
next unless server
|
|
49
|
-
|
|
50
|
-
@port = server.addr[1] if @port.zero? # port 0: let the OS choose (tests)
|
|
51
|
-
@servers << server
|
|
52
|
-
end
|
|
53
|
-
raise Error, "could not listen on any of #{@bind.join(", ")}" if @servers.empty?
|
|
54
|
-
|
|
55
|
-
@threads = @servers.map { |server| Thread.new { accept_loop(server) } }
|
|
56
|
-
self
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
def stop
|
|
60
|
-
@servers.each { |server| server.close unless server.closed? }
|
|
61
|
-
@threads.each { |thread| thread.join(1) }
|
|
62
|
-
@servers.clear
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
def api_request?(path)
|
|
66
|
-
return true if @api_path.empty?
|
|
67
|
-
|
|
68
|
-
path == @api_path || path.start_with?("#{@api_path}/")
|
|
69
|
-
end
|
|
70
|
-
|
|
71
|
-
private
|
|
72
|
-
|
|
73
|
-
def listen(host)
|
|
74
|
-
TCPServer.new(host, @port)
|
|
75
|
-
rescue Errno::EADDRINUSE
|
|
76
|
-
stop
|
|
77
|
-
raise Error,
|
|
78
|
-
"port #{@port} is already in use on #{host}. Stop the other process or run PORT=#{@port + 1} gemstack dev"
|
|
79
|
-
rescue Errno::EADDRNOTAVAIL, Errno::EAFNOSUPPORT, SocketError
|
|
80
|
-
nil # e.g. no IPv6 loopback on this machine
|
|
81
|
-
end
|
|
82
|
-
|
|
83
|
-
def accept_loop(server)
|
|
84
|
-
loop do
|
|
85
|
-
client = server.accept
|
|
86
|
-
Thread.new(client) { |socket| handle(socket) }
|
|
87
|
-
end
|
|
88
|
-
rescue IOError, Errno::EBADF, Errno::EINVAL
|
|
89
|
-
nil # server closed
|
|
90
|
-
end
|
|
91
|
-
|
|
92
|
-
def handle(client)
|
|
93
|
-
head, rest = read_head(client)
|
|
94
|
-
return unless head
|
|
95
|
-
|
|
96
|
-
request_line, *header_lines = head.split("\r\n")
|
|
97
|
-
_method, target, = request_line.split(" ", 3)
|
|
98
|
-
return respond(client, 400, "text/plain", "Bad Request") unless target
|
|
99
|
-
|
|
100
|
-
path = target.sub(%r{\Ahttps?://[^/]+}i, "").split("?", 2).first
|
|
101
|
-
api = api_request?(path)
|
|
102
|
-
upstream = api ? @api : @frontend
|
|
103
|
-
return unavailable(client, api, header_lines, nil) unless upstream
|
|
104
|
-
|
|
105
|
-
server = connect(upstream)
|
|
106
|
-
return unavailable(client, api, header_lines, upstream) unless server
|
|
107
|
-
|
|
108
|
-
server.write("#{request_line}\r\n#{rewrite(header_lines, client).join("\r\n")}\r\n\r\n")
|
|
109
|
-
server.write(rest) unless rest.empty?
|
|
110
|
-
relay(client, server)
|
|
111
|
-
rescue IOError, SystemCallError
|
|
112
|
-
nil # client or upstream went away
|
|
113
|
-
rescue StandardError => e
|
|
114
|
-
@on_error&.call(e)
|
|
115
|
-
ensure
|
|
116
|
-
[client, server].each { |socket| socket&.close unless socket&.closed? }
|
|
117
|
-
end
|
|
118
|
-
|
|
119
|
-
def read_head(client)
|
|
120
|
-
buffer = String.new(encoding: Encoding::BINARY)
|
|
121
|
-
until (index = buffer.index("\r\n\r\n"))
|
|
122
|
-
return nil if buffer.bytesize > MAX_HEAD
|
|
123
|
-
|
|
124
|
-
buffer << client.readpartial(16 * 1024)
|
|
125
|
-
end
|
|
126
|
-
[buffer.byteslice(0, index), buffer.byteslice(index + 4, buffer.bytesize)]
|
|
127
|
-
rescue IOError, SystemCallError # EOFError is an IOError
|
|
128
|
-
nil
|
|
129
|
-
end
|
|
130
|
-
|
|
131
|
-
def rewrite(lines, client)
|
|
132
|
-
upgrade = upgrade?(lines)
|
|
133
|
-
host = nil
|
|
134
|
-
kept = lines.reject do |line|
|
|
135
|
-
name = line[/\A[^:]+/].to_s.strip.downcase
|
|
136
|
-
host = line.split(":", 2).last.strip if name == "host"
|
|
137
|
-
FORWARDED.include?(name) || (!upgrade && HOP_BY_HOP.include?(name))
|
|
138
|
-
end
|
|
139
|
-
kept << "Connection: close" unless upgrade
|
|
140
|
-
kept << "X-Forwarded-For: #{client_ip(client)}"
|
|
141
|
-
kept << "X-Forwarded-Proto: http"
|
|
142
|
-
kept << "X-Forwarded-Host: #{host}" if host
|
|
143
|
-
kept << "X-Forwarded-Port: #{@port}"
|
|
144
|
-
end
|
|
145
|
-
|
|
146
|
-
def upgrade?(lines)
|
|
147
|
-
connection = lines.find { |l| l.match?(/\Aconnection\s*:/i) }
|
|
148
|
-
connection&.match?(/upgrade/i) && lines.any? { |l| l.match?(/\Aupgrade\s*:/i) }
|
|
149
|
-
end
|
|
150
|
-
|
|
151
|
-
def client_ip(client)
|
|
152
|
-
client.remote_address.ip_address
|
|
153
|
-
rescue StandardError
|
|
154
|
-
"127.0.0.1"
|
|
155
|
-
end
|
|
156
|
-
|
|
157
|
-
def connect(upstream)
|
|
158
|
-
Socket.tcp(upstream.host, upstream.port, connect_timeout: CONNECT_TIMEOUT)
|
|
159
|
-
rescue SystemCallError, SocketError, IOError
|
|
160
|
-
nil
|
|
161
|
-
end
|
|
162
|
-
|
|
163
|
-
# Upstream → client on this thread; client → upstream on a helper
|
|
164
|
-
# thread. The exchange ends when the upstream closes (after its
|
|
165
|
-
# response, or when a WebSocket/stream ends).
|
|
166
|
-
def relay(client, server)
|
|
167
|
-
pump = Thread.new do
|
|
168
|
-
IO.copy_stream(client, server)
|
|
169
|
-
server.close_write
|
|
170
|
-
rescue IOError, SystemCallError
|
|
171
|
-
nil
|
|
172
|
-
end
|
|
173
|
-
IO.copy_stream(server, client)
|
|
174
|
-
rescue IOError, SystemCallError
|
|
175
|
-
nil
|
|
176
|
-
ensure
|
|
177
|
-
pump&.kill
|
|
178
|
-
end
|
|
179
|
-
|
|
180
|
-
def unavailable(client, api, header_lines, upstream)
|
|
181
|
-
name = api ? "Ruby API" : "Next.js"
|
|
182
|
-
state = upstream ? "is starting or not running" : "is not configured"
|
|
183
|
-
message = "#{name} #{state}. Check the terminal running `gemstack dev`."
|
|
184
|
-
if api
|
|
185
|
-
body = JSON.generate(error: { code: "upstream_unavailable", message: message })
|
|
186
|
-
respond(client, 503, "application/json", body)
|
|
187
|
-
elsif header_lines.any? { |l| l.match?(%r{\Aaccept\s*:.*text/html}i) }
|
|
188
|
-
respond(client, 503, "text/html; charset=utf-8", waiting_page(name, message, refresh: !upstream.nil?))
|
|
189
|
-
else
|
|
190
|
-
respond(client, 503, "text/plain", message)
|
|
191
|
-
end
|
|
192
|
-
end
|
|
193
|
-
|
|
194
|
-
def respond(client, status, type, body)
|
|
195
|
-
reason = { 400 => "Bad Request", 503 => "Service Unavailable" }.fetch(status, "Error")
|
|
196
|
-
client.write("HTTP/1.1 #{status} #{reason}\r\ncontent-type: #{type}\r\ncontent-length: #{body.bytesize}\r\n" \
|
|
197
|
-
"cache-control: no-store\r\nretry-after: 1\r\nconnection: close\r\n\r\n#{body}")
|
|
198
|
-
end
|
|
199
|
-
|
|
200
|
-
def waiting_page(name, message, refresh:)
|
|
201
|
-
<<~HTML
|
|
202
|
-
<!doctype html>
|
|
203
|
-
<html lang="en"><head><meta charset="utf-8"><title>GemStack · #{name}</title>
|
|
204
|
-
#{'<meta http-equiv="refresh" content="1">' if refresh}
|
|
205
|
-
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
206
|
-
<style>
|
|
207
|
-
body{font:16px/1.5 system-ui,sans-serif;display:grid;place-items:center;min-height:100vh;margin:0;
|
|
208
|
-
background:#0b0d12;color:#e6e8ee}
|
|
209
|
-
main{max-width:32rem;padding:2rem;text-align:center}
|
|
210
|
-
.dot{display:inline-block;width:.6rem;height:.6rem;border-radius:50%;background:#f5b041;
|
|
211
|
-
margin-right:.5rem;animation:p 1s infinite alternate}
|
|
212
|
-
@keyframes p{to{opacity:.3}} code{background:#1c2030;padding:.1rem .35rem;border-radius:4px}
|
|
213
|
-
</style></head>
|
|
214
|
-
<body><main><h1>GemStack</h1><p><span class="dot"></span>#{message.sub(/`([^`]+)`/, '<code>\1</code>')}</p>
|
|
215
|
-
<p style="color:#8a90a2">#{"This page refreshes automatically." if refresh}</p></main></body></html>
|
|
216
|
-
HTML
|
|
217
|
-
end
|
|
218
|
-
end
|
|
219
|
-
end
|
|
220
|
-
end
|
|
@@ -1,96 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module GemStack
|
|
4
|
-
module Dev
|
|
5
|
-
# A child process whose output is streamed, line by line, to a Terminal.
|
|
6
|
-
# Each child runs in its own process group so stopping it also stops
|
|
7
|
-
# anything it spawned (e.g. npm → node).
|
|
8
|
-
class ManagedProcess
|
|
9
|
-
attr_reader :name, :command, :pid, :status
|
|
10
|
-
|
|
11
|
-
def initialize(name, command, terminal:, env: {}, chdir: Dir.pwd)
|
|
12
|
-
@name = name
|
|
13
|
-
@command = Array(command)
|
|
14
|
-
@terminal = terminal
|
|
15
|
-
@env = env
|
|
16
|
-
@chdir = chdir.to_s
|
|
17
|
-
@pid = nil
|
|
18
|
-
@status = nil
|
|
19
|
-
end
|
|
20
|
-
|
|
21
|
-
def start
|
|
22
|
-
reader, writer = IO.pipe
|
|
23
|
-
@status = nil
|
|
24
|
-
@pid = Process.spawn(@env, *@command, chdir: @chdir, out: writer, err: writer, in: File::NULL,
|
|
25
|
-
pgroup: true)
|
|
26
|
-
writer.close
|
|
27
|
-
@output = Thread.new do
|
|
28
|
-
reader.each_line { |line| @terminal.line(@name, line) }
|
|
29
|
-
rescue IOError
|
|
30
|
-
nil
|
|
31
|
-
ensure
|
|
32
|
-
reader.close
|
|
33
|
-
end
|
|
34
|
-
self
|
|
35
|
-
rescue SystemCallError => e
|
|
36
|
-
writer&.close
|
|
37
|
-
reader&.close
|
|
38
|
-
@terminal.line(@name, "could not start `#{@command.join(" ")}`: #{e.message}")
|
|
39
|
-
@status = :failed_to_start
|
|
40
|
-
self
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
# Non-blocking. Records the exit status once the process has exited.
|
|
44
|
-
def running?
|
|
45
|
-
return false unless @pid
|
|
46
|
-
return false if @status
|
|
47
|
-
|
|
48
|
-
done = Process.waitpid2(@pid, Process::WNOHANG)
|
|
49
|
-
return true unless done
|
|
50
|
-
|
|
51
|
-
@status = done[1]
|
|
52
|
-
false
|
|
53
|
-
rescue Errno::ECHILD
|
|
54
|
-
@status ||= :unknown
|
|
55
|
-
false
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
def exited? = !@status.nil?
|
|
59
|
-
|
|
60
|
-
def stop(timeout: 5)
|
|
61
|
-
return unless running?
|
|
62
|
-
|
|
63
|
-
signal("TERM")
|
|
64
|
-
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
65
|
-
sleep 0.05 while running? && Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
|
|
66
|
-
signal("KILL") if running?
|
|
67
|
-
running?
|
|
68
|
-
@output&.join(1)
|
|
69
|
-
end
|
|
70
|
-
|
|
71
|
-
def restart
|
|
72
|
-
stop
|
|
73
|
-
start
|
|
74
|
-
end
|
|
75
|
-
|
|
76
|
-
def describe_status
|
|
77
|
-
case @status
|
|
78
|
-
when Process::Status then @status.signaled? ? "signal #{@status.termsig}" : "status #{@status.exitstatus}"
|
|
79
|
-
else @status.to_s
|
|
80
|
-
end
|
|
81
|
-
end
|
|
82
|
-
|
|
83
|
-
private
|
|
84
|
-
|
|
85
|
-
def signal(name)
|
|
86
|
-
Process.kill(name, -@pid)
|
|
87
|
-
rescue Errno::ESRCH, Errno::EPERM
|
|
88
|
-
begin
|
|
89
|
-
Process.kill(name, @pid)
|
|
90
|
-
rescue Errno::ESRCH, Errno::EPERM
|
|
91
|
-
nil
|
|
92
|
-
end
|
|
93
|
-
end
|
|
94
|
-
end
|
|
95
|
-
end
|
|
96
|
-
end
|
data/lib/gemstack/dev/ports.rb
DELETED
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "socket"
|
|
4
|
-
|
|
5
|
-
module GemStack
|
|
6
|
-
module Dev
|
|
7
|
-
module Ports
|
|
8
|
-
module_function
|
|
9
|
-
|
|
10
|
-
# An unused TCP port on host, chosen by the OS.
|
|
11
|
-
def free(host = "127.0.0.1")
|
|
12
|
-
server = TCPServer.new(host, 0)
|
|
13
|
-
server.addr[1]
|
|
14
|
-
ensure
|
|
15
|
-
server&.close
|
|
16
|
-
end
|
|
17
|
-
|
|
18
|
-
def open?(host, port, timeout: 0.5)
|
|
19
|
-
Socket.tcp(host, port, connect_timeout: timeout).close
|
|
20
|
-
true
|
|
21
|
-
rescue SystemCallError, SocketError, IOError
|
|
22
|
-
false
|
|
23
|
-
end
|
|
24
|
-
end
|
|
25
|
-
end
|
|
26
|
-
end
|
|
@@ -1,267 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module GemStack
|
|
4
|
-
module Dev
|
|
5
|
-
# Runs `gemstack dev`: the gateway on the public port, the Ruby API and
|
|
6
|
-
# Next.js on private ports chosen automatically, prefixed output, restarts
|
|
7
|
-
# of the API when configuration changes, and a clean shutdown on Ctrl-C.
|
|
8
|
-
class Supervisor
|
|
9
|
-
LOOPBACK = "127.0.0.1"
|
|
10
|
-
TICK = 0.25
|
|
11
|
-
|
|
12
|
-
attr_reader :gateway, :processes
|
|
13
|
-
|
|
14
|
-
def initialize(root:, config: GemStack.config, terminal: Terminal.new, env: ENV)
|
|
15
|
-
@root = Pathname.new(root)
|
|
16
|
-
@config = config
|
|
17
|
-
@dev = config.dev
|
|
18
|
-
@terminal = terminal
|
|
19
|
-
@env = env
|
|
20
|
-
@processes = {}
|
|
21
|
-
@stopping = false
|
|
22
|
-
end
|
|
23
|
-
|
|
24
|
-
def run
|
|
25
|
-
if frontend?
|
|
26
|
-
check_node!
|
|
27
|
-
prepare_frontend
|
|
28
|
-
end
|
|
29
|
-
start
|
|
30
|
-
install_signal_handlers
|
|
31
|
-
loop_until_stopped
|
|
32
|
-
ensure
|
|
33
|
-
shutdown
|
|
34
|
-
end
|
|
35
|
-
|
|
36
|
-
def start
|
|
37
|
-
@started_at = monotonic
|
|
38
|
-
api_port = Ports.free(LOOPBACK)
|
|
39
|
-
web_port = Ports.free(LOOPBACK) if frontend?
|
|
40
|
-
@gateway = Gateway.new(
|
|
41
|
-
port: @dev.port, bind: @dev.bind, api_path: @config.http.api_path,
|
|
42
|
-
api: Gateway::Upstream.new(:api, LOOPBACK, api_port, "Ruby API"),
|
|
43
|
-
frontend: web_port && Gateway::Upstream.new(:next, LOOPBACK, web_port, "Next.js"),
|
|
44
|
-
on_error: ->(e) { @terminal.line("gateway", "#{e.class}: #{e.message}") }
|
|
45
|
-
).start
|
|
46
|
-
|
|
47
|
-
start_processes(api_port, web_port)
|
|
48
|
-
@restart_watcher = FileWatcher.new(@dev.restart_on, root: @root, exclude: @dev.restart_exclude)
|
|
49
|
-
@recovery_watcher = FileWatcher.new(["app/**/*", "config/**/*", "Gemfile.lock"], root: @root)
|
|
50
|
-
@jobs_watcher = FileWatcher.new(["app/**/*.rb", "db/migrations/*.rb"], root: @root) if @dev.jobs_command
|
|
51
|
-
start_jobs if jobs_enabled?
|
|
52
|
-
if web_port && @dev.contract_command
|
|
53
|
-
@contract_watcher = FileWatcher.new(@dev.contract_watch, root: @root)
|
|
54
|
-
@contract_pending = true # generate once at startup
|
|
55
|
-
end
|
|
56
|
-
banner
|
|
57
|
-
watch_readiness
|
|
58
|
-
self
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
def start_processes(api_port, web_port)
|
|
62
|
-
@processes[:api] = spawn("api", @dev.api_command, @root, api_env(api_port))
|
|
63
|
-
@processes[:next] = spawn("next", frontend_command(web_port), frontend_dir, frontend_env(api_port)) if web_port
|
|
64
|
-
end
|
|
65
|
-
|
|
66
|
-
def spawn(name, command, dir, env)
|
|
67
|
-
ManagedProcess.new(name, command, terminal: @terminal, chdir: dir, env: env).start
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
def stop!
|
|
71
|
-
@stopping = true
|
|
72
|
-
end
|
|
73
|
-
|
|
74
|
-
def shutdown
|
|
75
|
-
return if @shut_down
|
|
76
|
-
|
|
77
|
-
@shut_down = true
|
|
78
|
-
@terminal.puts
|
|
79
|
-
@terminal.line("gemstack", "shutting down…")
|
|
80
|
-
@processes.values.map { |process| Thread.new { process.stop } }.each(&:join)
|
|
81
|
-
@gateway&.stop
|
|
82
|
-
end
|
|
83
|
-
|
|
84
|
-
private
|
|
85
|
-
|
|
86
|
-
def frontend? = frontend_dir.join("package.json").file?
|
|
87
|
-
def frontend_dir = @root.join(@dev.frontend_dir)
|
|
88
|
-
|
|
89
|
-
def api_env(port)
|
|
90
|
-
{
|
|
91
|
-
"GEMSTACK_ENV" => @env.fetch("GEMSTACK_ENV", "development"),
|
|
92
|
-
"GEMSTACK_API_HOST" => LOOPBACK,
|
|
93
|
-
"GEMSTACK_API_PORT" => port.to_s,
|
|
94
|
-
"GEMSTACK_LOG_COLOR" => @terminal.color ? "1" : "0",
|
|
95
|
-
"PORT" => nil # the public port belongs to the gateway
|
|
96
|
-
}
|
|
97
|
-
end
|
|
98
|
-
|
|
99
|
-
def frontend_env(api_port)
|
|
100
|
-
{
|
|
101
|
-
"GEMSTACK_API_URL" => "http://#{LOOPBACK}:#{api_port}",
|
|
102
|
-
"NEXT_PUBLIC_GEMSTACK_API_PATH" => @config.http.api_path,
|
|
103
|
-
"FORCE_COLOR" => @terminal.color ? "1" : nil,
|
|
104
|
-
"PORT" => nil
|
|
105
|
-
}
|
|
106
|
-
end
|
|
107
|
-
|
|
108
|
-
def frontend_command(port)
|
|
109
|
-
return Array(@dev.frontend_command) + ["--port", port.to_s] if @dev.frontend_command
|
|
110
|
-
|
|
111
|
-
[frontend_dir.join("node_modules/.bin/next").to_s, "dev", "--hostname", LOOPBACK, "--port", port.to_s]
|
|
112
|
-
end
|
|
113
|
-
|
|
114
|
-
# Next.js exits at once on an old Node.js with a line that's easy to miss
|
|
115
|
-
# among the other output; say it up front, with the command that fixes it.
|
|
116
|
-
def check_node!(node = Toolchain.node)
|
|
117
|
-
return if node && Toolchain.node_ok?(node[:version])
|
|
118
|
-
|
|
119
|
-
wanted = [@root.join(".node-version"), @root.join(".nvmrc")].find(&:file?)&.read&.strip
|
|
120
|
-
wanted = Toolchain::LTS_NODE if wanted.nil? || wanted.empty?
|
|
121
|
-
found = node ? "Node.js #{node[:version]} (#{node[:path]})" : "no `node` on the PATH"
|
|
122
|
-
raise Error, "Next.js needs Node.js #{Toolchain::MIN_NODE.join(".")} or newer; found #{found}.\n " \
|
|
123
|
-
"→ #{Toolchain.node_hint(wanted, Toolchain.node_manager(node&.fetch(:path)))}"
|
|
124
|
-
end
|
|
125
|
-
|
|
126
|
-
# Installs frontend dependencies on first run so `gemstack new && gemstack dev`
|
|
127
|
-
# works even with --skip-install.
|
|
128
|
-
def prepare_frontend
|
|
129
|
-
return if frontend_dir.join("node_modules/.bin/next").exist?
|
|
130
|
-
|
|
131
|
-
manager = package_manager
|
|
132
|
-
@terminal.line("gemstack", "installing frontend dependencies with #{manager} (first run)…")
|
|
133
|
-
installer = ManagedProcess.new(manager, [manager, "install"], terminal: @terminal, chdir: frontend_dir).start
|
|
134
|
-
sleep 0.1 while installer.running?
|
|
135
|
-
return if installer.status.is_a?(Process::Status) && installer.status.success?
|
|
136
|
-
|
|
137
|
-
raise Error, "#{manager} install failed (#{installer.describe_status}) in #{frontend_dir}"
|
|
138
|
-
end
|
|
139
|
-
|
|
140
|
-
def package_manager
|
|
141
|
-
{
|
|
142
|
-
"pnpm-lock.yaml" => "pnpm", "yarn.lock" => "yarn", "bun.lockb" => "bun", "bun.lock" => "bun"
|
|
143
|
-
}.each { |lock, manager| return manager if frontend_dir.join(lock).exist? }
|
|
144
|
-
"npm"
|
|
145
|
-
end
|
|
146
|
-
|
|
147
|
-
def banner
|
|
148
|
-
url = "http://localhost:#{@gateway.port}"
|
|
149
|
-
t = @terminal
|
|
150
|
-
t.puts
|
|
151
|
-
t.puts(" #{t.bold("GemStack")} #{t.dim("v#{GemStack::VERSION} · #{@env.fetch("GEMSTACK_ENV",
|
|
152
|
-
"development")}")}")
|
|
153
|
-
t.puts
|
|
154
|
-
split = t.dim("(#{@config.http.api_path}/* → Ruby, everything else → Next.js)")
|
|
155
|
-
t.puts(" #{t.green("✓")} Gateway #{url} #{split}")
|
|
156
|
-
t.puts(" #{t.yellow("…")} Ruby API #{t.dim("starting on #{@gateway.api} (internal)")}")
|
|
157
|
-
if @gateway.frontend
|
|
158
|
-
t.puts(" #{t.yellow("…")} Next.js #{t.dim("starting on #{@gateway.frontend} (internal)")}")
|
|
159
|
-
else
|
|
160
|
-
t.puts(" #{t.dim("-")} Next.js #{t.dim("no #{@dev.frontend_dir}/package.json — API only")}")
|
|
161
|
-
end
|
|
162
|
-
t.puts
|
|
163
|
-
t.puts(" Application: #{t.bold(url)}")
|
|
164
|
-
t.puts
|
|
165
|
-
end
|
|
166
|
-
|
|
167
|
-
def watch_readiness
|
|
168
|
-
[@gateway.api, @gateway.frontend].compact.each do |upstream|
|
|
169
|
-
Thread.new do
|
|
170
|
-
until @stopping
|
|
171
|
-
if Ports.open?(upstream.host, upstream.port)
|
|
172
|
-
seconds = (monotonic - @started_at).round(1)
|
|
173
|
-
@terminal.line("gemstack", "#{@terminal.green("✓")} #{upstream.label} ready (#{seconds}s)")
|
|
174
|
-
break
|
|
175
|
-
end
|
|
176
|
-
sleep TICK
|
|
177
|
-
end
|
|
178
|
-
end
|
|
179
|
-
end
|
|
180
|
-
end
|
|
181
|
-
|
|
182
|
-
def install_signal_handlers
|
|
183
|
-
%w[INT TERM].each { |signal| trap(signal) { @stopping = true } }
|
|
184
|
-
end
|
|
185
|
-
|
|
186
|
-
def loop_until_stopped
|
|
187
|
-
until @stopping
|
|
188
|
-
sleep TICK
|
|
189
|
-
supervise
|
|
190
|
-
end
|
|
191
|
-
end
|
|
192
|
-
|
|
193
|
-
def supervise
|
|
194
|
-
api = @processes[:api]
|
|
195
|
-
if @restart_watcher.changed?
|
|
196
|
-
restart_api("configuration changed")
|
|
197
|
-
elsif !api.running? && api.exited?
|
|
198
|
-
report_exit(api)
|
|
199
|
-
restart_api("files changed") if @recovery_watcher.changed?
|
|
200
|
-
end
|
|
201
|
-
web = @processes[:next]
|
|
202
|
-
report_exit(web) if web && !web.running? && web.exited?
|
|
203
|
-
supervise_contract
|
|
204
|
-
supervise_jobs
|
|
205
|
-
end
|
|
206
|
-
|
|
207
|
-
# A worker only makes sense with the database queue and its table.
|
|
208
|
-
def jobs_enabled?
|
|
209
|
-
return false unless @dev.jobs_command && defined?(GemStack::Jobs) && @config.respond_to?(:jobs)
|
|
210
|
-
return false unless %w[database postgres].include?(@config.jobs.adapter.to_s)
|
|
211
|
-
|
|
212
|
-
Dir.glob(@root.join("db/migrations/*_create_gemstack_jobs.rb").to_s).any?
|
|
213
|
-
end
|
|
214
|
-
|
|
215
|
-
def start_jobs
|
|
216
|
-
@processes[:jobs] = spawn("jobs", @dev.jobs_command, @root, { "GEMSTACK_ENV" => "development" })
|
|
217
|
-
end
|
|
218
|
-
|
|
219
|
-
# The worker doesn't reload code, so restart it when app/ changes
|
|
220
|
-
# (it finishes running jobs first), and start it once the jobs table exists.
|
|
221
|
-
def supervise_jobs
|
|
222
|
-
return unless @jobs_watcher&.changed?
|
|
223
|
-
|
|
224
|
-
jobs = @processes[:jobs]
|
|
225
|
-
if jobs&.running?
|
|
226
|
-
@terminal.line("gemstack", "app changed — restarting job worker")
|
|
227
|
-
jobs.restart
|
|
228
|
-
elsif jobs_enabled?
|
|
229
|
-
start_jobs
|
|
230
|
-
end
|
|
231
|
-
end
|
|
232
|
-
|
|
233
|
-
# One contract run at a time; changes during a run queue exactly one more.
|
|
234
|
-
def supervise_contract
|
|
235
|
-
return unless @contract_watcher
|
|
236
|
-
|
|
237
|
-
@contract_pending = true if @contract_watcher.changed?
|
|
238
|
-
running = @processes[:contract]&.running?
|
|
239
|
-
return if running || !@contract_pending
|
|
240
|
-
|
|
241
|
-
@contract_pending = false
|
|
242
|
-
@processes[:contract] = spawn("contract", @dev.contract_command, @root, { "GEMSTACK_ENV" => "development" })
|
|
243
|
-
end
|
|
244
|
-
|
|
245
|
-
# Both watchers overlap (config/), so re-baseline both after a restart
|
|
246
|
-
# to avoid restarting twice for a single change.
|
|
247
|
-
def restart_api(reason)
|
|
248
|
-
@terminal.line("gemstack", "#{reason} — restarting Ruby API")
|
|
249
|
-
@processes[:api].restart
|
|
250
|
-
@restart_watcher.changed?
|
|
251
|
-
@recovery_watcher.changed?
|
|
252
|
-
@reported_exit = nil
|
|
253
|
-
end
|
|
254
|
-
|
|
255
|
-
def report_exit(process)
|
|
256
|
-
key = [process.name, process.pid]
|
|
257
|
-
return if @reported_exit&.include?(key)
|
|
258
|
-
|
|
259
|
-
(@reported_exit ||= []) << key
|
|
260
|
-
hint = process.name == "api" ? " — fix the error; it restarts when you save a file" : ""
|
|
261
|
-
@terminal.line("gemstack", @terminal.red("#{process.name} exited (#{process.describe_status})#{hint}"))
|
|
262
|
-
end
|
|
263
|
-
|
|
264
|
-
def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
265
|
-
end
|
|
266
|
-
end
|
|
267
|
-
end
|
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module GemStack
|
|
4
|
-
module Dev
|
|
5
|
-
# Serialised, prefixed, coloured output for multiple processes:
|
|
6
|
-
#
|
|
7
|
-
# api │ 12:00:01.120 INFO GET /api/health status=200 ms=0.4
|
|
8
|
-
# next │ ✓ Compiled / in 812ms
|
|
9
|
-
class Terminal
|
|
10
|
-
COLORS = { "api" => 35, "next" => 36, "gateway" => 33, "gemstack" => 32, "jobs" => 34 }.freeze
|
|
11
|
-
WIDTH = 8
|
|
12
|
-
|
|
13
|
-
def initialize(io = $stdout, color: io.respond_to?(:tty?) && io.tty?)
|
|
14
|
-
@io = io
|
|
15
|
-
@io.sync = true if @io.respond_to?(:sync=) # output may be a pipe or file
|
|
16
|
-
@color = color
|
|
17
|
-
@mutex = Mutex.new
|
|
18
|
-
end
|
|
19
|
-
|
|
20
|
-
attr_reader :color
|
|
21
|
-
|
|
22
|
-
def line(name, text)
|
|
23
|
-
prefix = paint(name.ljust(WIDTH), COLORS.fetch(name, 37))
|
|
24
|
-
text = text.to_s.chomp
|
|
25
|
-
@mutex.synchronize { @io.puts("#{prefix}│ #{text}") }
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
def puts(text = "")
|
|
29
|
-
@mutex.synchronize { @io.puts(text) }
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
def paint(text, code) = @color ? "\e[#{code}m#{text}\e[0m" : text
|
|
33
|
-
def bold(text) = @color ? "\e[1m#{text}\e[0m" : text
|
|
34
|
-
def dim(text) = paint(text, 90)
|
|
35
|
-
def green(text) = paint(text, 32)
|
|
36
|
-
def red(text) = paint(text, 31)
|
|
37
|
-
def yellow(text) = paint(text, 33)
|
|
38
|
-
end
|
|
39
|
-
end
|
|
40
|
-
end
|
|
@@ -1,86 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "open3"
|
|
4
|
-
require "rbconfig"
|
|
5
|
-
|
|
6
|
-
module GemStack
|
|
7
|
-
module Dev
|
|
8
|
-
# The Ruby and Node.js a developer runs, and how to change them — for
|
|
9
|
-
# whichever version manager installed them (rbenv, rvm, asdf, mise,
|
|
10
|
-
# chruby, nvm, fnm, nodenv, Volta, Homebrew…). GemStack pins versions in
|
|
11
|
-
# files every manager reads (.ruby-version, .node-version, .nvmrc,
|
|
12
|
-
# .tool-versions) and never assumes one manager (DECISIONS D-066).
|
|
13
|
-
module Toolchain
|
|
14
|
-
MIN_RUBY = "3.3"
|
|
15
|
-
MIN_NODE = [20, 9].freeze # Next.js 16
|
|
16
|
-
LTS_NODE = "22"
|
|
17
|
-
|
|
18
|
-
# [pattern in the executable's path, manager name]; the first match wins.
|
|
19
|
-
RUBY_MANAGERS = [
|
|
20
|
-
[%r{/\.rbenv/}, :rbenv], [%r{/\.rvm/|/rvm/rubies/}, :rvm], [%r{/\.asdf/|/asdf/installs/}, :asdf],
|
|
21
|
-
[%r{/mise/installs/|/\.local/share/mise/}, :mise], [%r{/\.rubies/|/opt/rubies/}, :chruby],
|
|
22
|
-
[%r{/(?:opt/)?homebrew/|/usr/local/Cellar/}, :homebrew]
|
|
23
|
-
].freeze
|
|
24
|
-
NODE_MANAGERS = [
|
|
25
|
-
[%r{/\.nvm/}, :nvm], [%r{/fnm/|/\.fnm/|fnm_multishells}, :fnm], [%r{/\.nodenv/}, :nodenv],
|
|
26
|
-
[%r{/\.asdf/|/asdf/installs/}, :asdf], [%r{/mise/installs/|/\.local/share/mise/}, :mise],
|
|
27
|
-
[%r{/\.volta/}, :volta], [%r{/(?:opt/)?homebrew/|/usr/local/Cellar/}, :homebrew]
|
|
28
|
-
].freeze
|
|
29
|
-
|
|
30
|
-
module_function
|
|
31
|
-
|
|
32
|
-
def ruby_manager(path = RbConfig.ruby) = detect(RUBY_MANAGERS, path)
|
|
33
|
-
def node_manager(path) = path && detect(NODE_MANAGERS, path)
|
|
34
|
-
|
|
35
|
-
def detect(managers, path)
|
|
36
|
-
managers.find { |pattern, _| path.to_s.match?(pattern) }&.last
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
# How to install and select a Ruby, with the manager in use.
|
|
40
|
-
def ruby_hint(version, manager = ruby_manager)
|
|
41
|
-
{
|
|
42
|
-
rbenv: "rbenv install #{version} && rbenv local #{version}",
|
|
43
|
-
rvm: "rvm install #{version} && rvm use #{version}",
|
|
44
|
-
asdf: "asdf install ruby #{version} && asdf set ruby #{version}",
|
|
45
|
-
mise: "mise use ruby@#{version}",
|
|
46
|
-
chruby: "ruby-install ruby #{version}, then: chruby #{version}",
|
|
47
|
-
homebrew: "brew upgrade ruby (or install #{version} with rbenv, rvm, asdf or mise)"
|
|
48
|
-
}.fetch(manager, "install Ruby #{version} (e.g. with rbenv, rvm, asdf or mise) and make it the active Ruby")
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
# How to install and select a Node.js, with the manager in use.
|
|
52
|
-
def node_hint(version, manager)
|
|
53
|
-
{
|
|
54
|
-
nvm: "nvm install #{version} && nvm use #{version}",
|
|
55
|
-
fnm: "fnm install #{version} && fnm use #{version}",
|
|
56
|
-
nodenv: "nodenv install #{version} && nodenv local #{version}",
|
|
57
|
-
asdf: "asdf install nodejs #{version} && asdf set nodejs #{version}",
|
|
58
|
-
mise: "mise use node@#{version}",
|
|
59
|
-
volta: "volta install node@#{version}",
|
|
60
|
-
homebrew: "brew install node@#{version} (or use nvm, fnm, asdf or mise)"
|
|
61
|
-
}.fetch(manager, "install Node.js #{version} (e.g. with nvm, fnm, asdf or mise) and make it the active node")
|
|
62
|
-
end
|
|
63
|
-
|
|
64
|
-
# { version: "22.11.0", path: "/…/bin/node" } or nil when there's no node.
|
|
65
|
-
def node(run = ->(*cmd) { Open3.capture2e(*cmd) })
|
|
66
|
-
out, status = run.call("node", "-p", "process.version + ' ' + process.execPath")
|
|
67
|
-
return nil unless status.success?
|
|
68
|
-
|
|
69
|
-
version, path = out.strip.split(" ", 2)
|
|
70
|
-
{ version: version.delete_prefix("v"), path: path }
|
|
71
|
-
rescue SystemCallError
|
|
72
|
-
nil
|
|
73
|
-
end
|
|
74
|
-
|
|
75
|
-
def node_ok?(version) = (version.to_s.split(".").map(&:to_i) <=> MIN_NODE) >= 0
|
|
76
|
-
def ruby_ok?(version = RUBY_VERSION) = Gem::Version.new(version) >= Gem::Version.new(MIN_RUBY)
|
|
77
|
-
|
|
78
|
-
# The Node.js version new apps pin: the one running `gemstack new` when
|
|
79
|
-
# it's new enough, else the current LTS line.
|
|
80
|
-
def pinned_node_version(run = ->(*cmd) { Open3.capture2e(*cmd) })
|
|
81
|
-
found = node(run)
|
|
82
|
-
found && node_ok?(found[:version]) ? found[:version] : LTS_NODE
|
|
83
|
-
end
|
|
84
|
-
end
|
|
85
|
-
end
|
|
86
|
-
end
|
data/lib/gemstack/dev.rb
DELETED
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "gemstack/core"
|
|
4
|
-
|
|
5
|
-
module GemStack
|
|
6
|
-
# Development tooling: the single-origin gateway and the process
|
|
7
|
-
# supervisor behind `gemstack dev` (ARCHITECTURE §5). Nothing here is
|
|
8
|
-
# loaded in production unless explicitly required.
|
|
9
|
-
module Dev
|
|
10
|
-
autoload :FileWatcher, "gemstack/dev/file_watcher"
|
|
11
|
-
autoload :Gateway, "gemstack/dev/gateway"
|
|
12
|
-
autoload :ManagedProcess, "gemstack/dev/managed_process"
|
|
13
|
-
autoload :Ports, "gemstack/dev/ports"
|
|
14
|
-
autoload :Supervisor, "gemstack/dev/supervisor"
|
|
15
|
-
autoload :Terminal, "gemstack/dev/terminal"
|
|
16
|
-
autoload :Toolchain, "gemstack/dev/toolchain"
|
|
17
|
-
|
|
18
|
-
class Config < Settings
|
|
19
|
-
# The single public port.
|
|
20
|
-
setting :port, default: -> { Integer(ENV.fetch("PORT", 3000)) }
|
|
21
|
-
# Interfaces the gateway listens on. Both loopback families, because
|
|
22
|
-
# browsers may resolve "localhost" to either.
|
|
23
|
-
setting :bind, default: %w[127.0.0.1 ::1]
|
|
24
|
-
setting :frontend_dir, default: "frontend"
|
|
25
|
-
setting :api_command, default: %w[bundle exec puma -C config/puma.rb]
|
|
26
|
-
# nil = run the frontend's own `next` binary in dev mode.
|
|
27
|
-
setting :frontend_command, default: nil
|
|
28
|
-
# Changes here restart the Ruby process. app/ and config/routes.rb are
|
|
29
|
-
# reloaded in-process instead and don't need a restart.
|
|
30
|
-
setting :restart_on, default: ["config/**/*.rb", "Gemfile.lock", ".env*"]
|
|
31
|
-
setting :restart_exclude, default: ["config/routes.rb"]
|
|
32
|
-
# Regenerates TypeScript types/clients when backend code changes
|
|
33
|
-
# (only when the app has a frontend). nil disables.
|
|
34
|
-
setting :contract_command, default: %w[bundle exec gemstack contract --quiet]
|
|
35
|
-
setting :contract_watch, default: ["app/**/*.rb", "config/routes.rb"]
|
|
36
|
-
# Background job worker, run when the app uses the :postgres job
|
|
37
|
-
# adapter and has the gemstack_jobs migration. Restarted when app/ changes.
|
|
38
|
-
setting :jobs_command, default: %w[bundle exec gemstack jobs]
|
|
39
|
-
end
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
Config.namespace(:dev, Dev::Config)
|
|
43
|
-
end
|