ruby_everywhere 0.5.0 → 0.7.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/README.md +13 -1
- data/exe/every +12 -1
- data/exe/rbe +12 -1
- data/lib/everywhere/agents_guide.rb +59 -0
- data/lib/everywhere/blake2b.rb +17 -1
- data/lib/everywhere/boot.rb +21 -4
- data/lib/everywhere/builders/android.rb +144 -30
- data/lib/everywhere/builders/desktop.rb +25 -17
- data/lib/everywhere/builders/ios.rb +244 -9
- data/lib/everywhere/cli.rb +2 -0
- data/lib/everywhere/commands/build.rb +113 -58
- data/lib/everywhere/commands/clean.rb +8 -3
- data/lib/everywhere/commands/dev.rb +85 -11
- data/lib/everywhere/commands/doctor.rb +138 -21
- data/lib/everywhere/commands/install.rb +121 -9
- data/lib/everywhere/commands/platform/build.rb +136 -27
- data/lib/everywhere/commands/platform/login.rb +16 -2
- data/lib/everywhere/commands/platform/runner.rb +113 -23
- data/lib/everywhere/commands/preview.rb +359 -0
- data/lib/everywhere/commands/publish.rb +20 -2
- data/lib/everywhere/commands/release.rb +145 -28
- data/lib/everywhere/commands/shell_dir.rb +2 -2
- data/lib/everywhere/config.rb +88 -3
- data/lib/everywhere/console.rb +2 -1
- data/lib/everywhere/engine.rb +24 -0
- data/lib/everywhere/framework.rb +21 -4
- data/lib/everywhere/ignore.rb +3 -1
- data/lib/everywhere/jump.rb +121 -0
- data/lib/everywhere/mobile_config_endpoint.rb +47 -0
- data/lib/everywhere/mobile_configs_controller.rb +46 -0
- data/lib/everywhere/paths.rb +13 -6
- data/lib/everywhere/platform/client.rb +27 -6
- data/lib/everywhere/platform/credentials.rb +18 -8
- data/lib/everywhere/platform/snapshot.rb +10 -0
- data/lib/everywhere/receipt.rb +55 -10
- data/lib/everywhere/shellout.rb +19 -0
- data/lib/everywhere/simulator.rb +12 -1
- data/lib/everywhere/version.rb +1 -1
- data/support/agents/AGENTS.md +90 -0
- data/support/agents/frameworks/hanami-views.md +26 -0
- data/support/agents/frameworks/hanami.md +10 -0
- data/support/agents/frameworks/rails-views.md +98 -0
- data/support/agents/frameworks/rails.md +16 -0
- data/support/agents/frameworks/sinatra-views.md +24 -0
- data/support/agents/frameworks/sinatra.md +9 -0
- data/support/agents/modes/local.md +12 -0
- data/support/agents/modes/remote.md +14 -0
- data/support/mobile/android/app/build.gradle.kts +29 -1
- data/support/mobile/ios/App/EverywhereConfig.swift +17 -5
- data/support/mobile/ios/App/SceneDelegate.swift +75 -8
- data/support/mobile/ios/App.xcodeproj/project.pbxproj +2 -2
- data/support/mobile/ios/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +0 -1
- data/support/mobile/ios/README.md +13 -6
- metadata +41 -6
|
@@ -58,7 +58,7 @@ module Everywhere
|
|
|
58
58
|
def call(port: "3000", shell_dir: nil, target: nil, desktop: false, ios: false, android: false,
|
|
59
59
|
mobile: false, browser: false, dev_url: nil, **)
|
|
60
60
|
@shell_dir = shell_dir
|
|
61
|
-
@port = port
|
|
61
|
+
port = @port = validate_port!(port)
|
|
62
62
|
@dev_url_override = dev_url
|
|
63
63
|
# Children are spawned from pool threads as well as the main one.
|
|
64
64
|
@pids = {}
|
|
@@ -147,8 +147,8 @@ module Everywhere
|
|
|
147
147
|
else
|
|
148
148
|
UI.step("starting #{UI.bold(framework.name)} dev server #{UI.dim("(" + framework.dev_command + ")")}")
|
|
149
149
|
@dock.set(:web, :booting)
|
|
150
|
-
supervise(:web, { "PORT" => port.to_s }, framework.dev_command, chdir: framework.root)
|
|
151
|
-
wait_for_port(port)
|
|
150
|
+
pid = supervise(:web, { "PORT" => port.to_s }, framework.dev_command, chdir: framework.root)
|
|
151
|
+
wait_for_port(port, pid: pid)
|
|
152
152
|
@dock.set(:web, :running, detail: ":#{port}")
|
|
153
153
|
end
|
|
154
154
|
end
|
|
@@ -272,6 +272,12 @@ module Everywhere
|
|
|
272
272
|
end
|
|
273
273
|
|
|
274
274
|
def launch_desktop
|
|
275
|
+
# Checked before anything is staged: without cargo the build dies deep
|
|
276
|
+
# in a relayed `sh: cargo: command not found`, after a dock entry and a
|
|
277
|
+
# half-written dev bundle say a shell is on its way.
|
|
278
|
+
Shellout.ensure_tool!("cargo", "the desktop shell is built with Rust — install it from " \
|
|
279
|
+
"https://rustup.rs, then press d again")
|
|
280
|
+
|
|
275
281
|
# A bad `window:` or a broken assets/ tree marks the desktop red and
|
|
276
282
|
# leaves the dev server and the mobile shells alone — the same deal
|
|
277
283
|
# every other target gets when its build fails. Dying here would take
|
|
@@ -365,6 +371,15 @@ module Everywhere
|
|
|
365
371
|
# covers plain http for local networking. Pressing i again rebuilds and
|
|
366
372
|
# relaunches, so it doubles as a native-shell reload.
|
|
367
373
|
def launch_ios
|
|
374
|
+
# xcodebuild and simctl are macOS-only, so `i` elsewhere can only ever
|
|
375
|
+
# produce a long build that fails at the first tool. Android stays
|
|
376
|
+
# available everywhere — its toolchain genuinely is cross-platform.
|
|
377
|
+
unless RUBY_PLATFORM.include?("darwin")
|
|
378
|
+
UI.warn("iOS builds need macOS and Xcode — #{UI.dim("nothing to do on this platform")}")
|
|
379
|
+
@dock.set(:ios, :idle, detail: "needs macOS")
|
|
380
|
+
return
|
|
381
|
+
end
|
|
382
|
+
|
|
368
383
|
@dock.set(:ios, :building)
|
|
369
384
|
app = Builders::Ios.new(config: @config, root: @config.root, target: IOS_TARGET)
|
|
370
385
|
.build!(dist_dir: File.expand_path("dist", @config.root), dev: true)
|
|
@@ -406,7 +421,22 @@ module Everywhere
|
|
|
406
421
|
|
|
407
422
|
def launch_browser
|
|
408
423
|
UI.step("opening browser → #{UI.cyan(@dev_url)}")
|
|
424
|
+
# `start` is a cmd builtin, not an executable, so it can't be probed or
|
|
425
|
+
# spawned directly; the empty "" is the window title argument, without
|
|
426
|
+
# which cmd reads the URL as one.
|
|
427
|
+
if Gem.win_platform?
|
|
428
|
+
system("cmd", "/c", "start", "", @dev_url)
|
|
429
|
+
return
|
|
430
|
+
end
|
|
431
|
+
|
|
409
432
|
opener = RUBY_PLATFORM.include?("darwin") ? "open" : "xdg-open"
|
|
433
|
+
# A headless Linux box (or WSL) has no xdg-open, and run? would just
|
|
434
|
+
# return nil — the key would look broken. Print the URL instead.
|
|
435
|
+
unless Shellout.tool?(opener)
|
|
436
|
+
UI.warn("couldn't find #{opener} — open #{UI.cyan(@dev_url)} yourself")
|
|
437
|
+
return
|
|
438
|
+
end
|
|
439
|
+
|
|
410
440
|
Shellout.run?(opener, @dev_url, quiet: true)
|
|
411
441
|
end
|
|
412
442
|
|
|
@@ -506,8 +536,11 @@ module Everywhere
|
|
|
506
536
|
# while one is going — including `q`.
|
|
507
537
|
def interact
|
|
508
538
|
@dock.announce_keys
|
|
509
|
-
|
|
510
|
-
|
|
539
|
+
# stty is POSIX-only, and the non-pty spawn path otherwise works on
|
|
540
|
+
# Windows: without it the menu simply stays line-buffered (press d,
|
|
541
|
+
# then Enter) rather than the session dying on Errno::ENOENT.
|
|
542
|
+
saved_tty = saved_tty_state
|
|
543
|
+
system("stty", "-icanon", "-echo") if saved_tty
|
|
511
544
|
trap_exit_signals
|
|
512
545
|
loop do
|
|
513
546
|
case $stdin.getc
|
|
@@ -522,7 +555,19 @@ module Everywhere
|
|
|
522
555
|
end
|
|
523
556
|
end
|
|
524
557
|
ensure
|
|
525
|
-
system("stty", saved_tty)
|
|
558
|
+
system("stty", saved_tty) if saved_tty
|
|
559
|
+
end
|
|
560
|
+
|
|
561
|
+
# The tty settings to put back on the way out, or nil when there's no
|
|
562
|
+
# usable stty to read them with — the one signal both the raw-mode setup
|
|
563
|
+
# and its restore key off.
|
|
564
|
+
def saved_tty_state
|
|
565
|
+
return nil unless Shellout.tool?("stty")
|
|
566
|
+
|
|
567
|
+
state = `stty -g`.chomp
|
|
568
|
+
state.empty? ? nil : state
|
|
569
|
+
rescue StandardError
|
|
570
|
+
nil
|
|
526
571
|
end
|
|
527
572
|
|
|
528
573
|
# The dock hides the cursor and the key loop puts the tty in -icanon
|
|
@@ -613,17 +658,46 @@ module Everywhere
|
|
|
613
658
|
|
|
614
659
|
def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
615
660
|
|
|
661
|
+
# Checked once, up front: Integer() would otherwise raise from inside
|
|
662
|
+
# port_open? — an ArgumentError backtrace after the dock has already
|
|
663
|
+
# taken over the terminal.
|
|
664
|
+
def validate_port!(port)
|
|
665
|
+
number = begin
|
|
666
|
+
Integer(port.to_s, 10)
|
|
667
|
+
rescue ArgumentError, TypeError
|
|
668
|
+
UI.die!("--port must be a number (got #{port.inspect})")
|
|
669
|
+
end
|
|
670
|
+
UI.die!("--port must be between 1 and 65535 (got #{number})") unless (1..65_535).cover?(number)
|
|
671
|
+
number.to_s
|
|
672
|
+
end
|
|
673
|
+
|
|
674
|
+
# Any connect error means "nothing listening yet" for our purposes —
|
|
675
|
+
# ECONNREFUSED is the common one, but a firewall or an exhausted fd table
|
|
676
|
+
# answers differently and must not abort the session.
|
|
616
677
|
def port_open?(port)
|
|
617
678
|
TCPSocket.new("127.0.0.1", Integer(port)).close
|
|
618
679
|
true
|
|
619
|
-
rescue
|
|
680
|
+
rescue SystemCallError
|
|
620
681
|
false
|
|
621
682
|
end
|
|
622
683
|
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
684
|
+
# Watches the child as well as the port: a dev server that dies on
|
|
685
|
+
# startup (no `bundle install`, a bin/dev whose foreman isn't installed)
|
|
686
|
+
# never opens the port, and waiting out the full timeout for it buries
|
|
687
|
+
# the error it already printed under a minute of silence.
|
|
688
|
+
def wait_for_port(port, pid: nil, timeout: 60)
|
|
689
|
+
deadline = monotonic + timeout
|
|
690
|
+
until port_open?(port)
|
|
691
|
+
if pid && !ChildProcesses.alive?(pid)
|
|
692
|
+
UI.die!("the dev server exited before it finished booting — scroll up for its output " \
|
|
693
|
+
"(did `bundle install` run? does bin/dev's foreman exist?)")
|
|
694
|
+
end
|
|
695
|
+
if monotonic > deadline
|
|
696
|
+
UI.die!("dev server never came up on port #{port} after #{timeout}s — it may just be slow " \
|
|
697
|
+
"to boot; check its output above")
|
|
698
|
+
end
|
|
699
|
+
sleep 0.25
|
|
700
|
+
end
|
|
627
701
|
end
|
|
628
702
|
end
|
|
629
703
|
end
|
|
@@ -3,48 +3,166 @@
|
|
|
3
3
|
require "dry/cli"
|
|
4
4
|
require_relative "../shellout"
|
|
5
5
|
require_relative "../paths"
|
|
6
|
+
require_relative "../config"
|
|
6
7
|
require_relative "../android_sdk"
|
|
7
8
|
|
|
8
9
|
module Everywhere
|
|
9
10
|
module Commands
|
|
10
|
-
# Check that this machine can build native apps.
|
|
11
|
-
# run
|
|
12
|
-
#
|
|
11
|
+
# Check that this machine can build native apps. Every section always
|
|
12
|
+
# shows, so one run answers "what can this machine build?" — but only the
|
|
13
|
+
# sections the app's build.targets declare (or --target names) gate the
|
|
14
|
+
# exit status: failing doctor over a toolchain the app doesn't use would
|
|
15
|
+
# be a lie, the same reasoning as the cmdline-tools warning below.
|
|
13
16
|
class Doctor < Dry::CLI::Command
|
|
14
17
|
desc "Check the toolchain (rbe-tebako, rust, brew deps; Xcode for iOS, SDK/JDK for Android)"
|
|
15
18
|
|
|
16
|
-
option :root, default: ".", desc: "App root (its build.targets pick
|
|
17
|
-
option :target, default: nil, desc: "
|
|
19
|
+
option :root, default: ".", desc: "App root (its build.targets pick which sections are required)"
|
|
20
|
+
option :target, default: nil, desc: "Require a specific target's toolchain (e.g. ios-arm64)"
|
|
18
21
|
|
|
19
22
|
def call(root: ".", target: nil, **)
|
|
20
|
-
ok =
|
|
21
|
-
ok &= check("rbe-tebako installed") { Shellout.run?("rbe-tebako help > /dev/null 2>&1") }
|
|
22
|
-
ok &= check("cargo installed") { Shellout.run?("cargo --version > /dev/null 2>&1") }
|
|
23
|
-
ok &= check("homebrew bison") { !`brew --prefix bison 2>/dev/null`.strip.empty? }
|
|
23
|
+
ok = config_check(root)
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
|
|
25
|
+
@optional_gaps = []
|
|
26
|
+
sections = [
|
|
27
|
+
["Desktop", desktop_wanted?(root, target), method(:desktop_checks)],
|
|
28
|
+
["iOS", ios_wanted?(root, target), method(:ios_checks)],
|
|
29
|
+
["Android", android_wanted?(root, target), method(:android_checks)]
|
|
30
|
+
]
|
|
31
|
+
# The sections that gate the run are the ones the user came to fix, so
|
|
32
|
+
# they come first; the informational ones read as an appendix.
|
|
33
|
+
# partition, not sort_by — Ruby's sort isn't stable.
|
|
34
|
+
required_sections, info_sections = sections.partition { |_, required, _| required }
|
|
35
|
+
(required_sections + info_sections).each do |name, required, checks|
|
|
36
|
+
ok &= section(name, required: required) { |req| checks.call(req) }
|
|
37
|
+
end
|
|
27
38
|
|
|
28
39
|
puts
|
|
29
|
-
ok
|
|
40
|
+
if ok
|
|
41
|
+
note = @optional_gaps.any? ? " #{UI.dim("(#{@optional_gaps.join(" and ")} would need setup — see above)")}" : ""
|
|
42
|
+
UI.success("ready to build#{note}")
|
|
43
|
+
else
|
|
44
|
+
UI.bad("fix the items above (see https://rubyeverywhere.com/docs/diy/requirements)")
|
|
45
|
+
end
|
|
30
46
|
exit(1) unless ok
|
|
31
47
|
end
|
|
32
48
|
|
|
33
49
|
private
|
|
34
50
|
|
|
51
|
+
# A section always renders; only a required one can fail the run. An
|
|
52
|
+
# optional section's gaps are remembered for the summary line instead.
|
|
53
|
+
def section(name, required:)
|
|
54
|
+
passed = yield(required)
|
|
55
|
+
@optional_gaps << name if !passed && !required
|
|
56
|
+
required ? passed : true
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def section_label(name, required)
|
|
60
|
+
required ? name : "#{name} #{UI.dim("(not in build.targets — informational)")}"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def macos? = RUBY_PLATFORM.include?("darwin")
|
|
64
|
+
|
|
65
|
+
# Desktop is the implicit default: an app with no build.targets presses
|
|
66
|
+
# for the host. Any non-mobile target (or --target) counts as desktop,
|
|
67
|
+
# mirroring how build/dev route targets.
|
|
68
|
+
def desktop_wanted?(root, target)
|
|
69
|
+
return true if target && !%w[ios android].include?(Everywhere::Config.os_of(target))
|
|
70
|
+
|
|
71
|
+
targets = config(root).targets
|
|
72
|
+
targets.empty? || targets.any? { |t| !%w[ios android].include?(Everywhere::Config.os_of(t)) }
|
|
73
|
+
rescue StandardError
|
|
74
|
+
true
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def desktop_checks(required = true)
|
|
78
|
+
puts
|
|
79
|
+
UI.step(section_label("Desktop target", required))
|
|
80
|
+
# Mirrors the gate in `every build`: pressing is macOS-only today, so
|
|
81
|
+
# probing for its toolchain elsewhere would only mislead.
|
|
82
|
+
unless macos?
|
|
83
|
+
return fix_check("macOS", "desktop builds are macOS-only for now") { false } if required
|
|
84
|
+
|
|
85
|
+
UI.warn("desktop builds are macOS-only for now — skipped")
|
|
86
|
+
return false
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
ok = fix_check("rbe-tebako installed",
|
|
90
|
+
"it ships as a dependency of ruby_everywhere: `gem install rbe-tebako` " \
|
|
91
|
+
"(or reinstall the gem)") { Shellout.run?("rbe-tebako help > /dev/null 2>&1") }
|
|
92
|
+
ok &= fix_check("cargo installed", "install Rust: https://rustup.rs") do
|
|
93
|
+
Shellout.run?("cargo --version > /dev/null 2>&1")
|
|
94
|
+
end
|
|
95
|
+
ok &= fix_check(bison_label, bison_hint) { bison? }
|
|
96
|
+
ok
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# macOS ships a bison far too old for tebako's build, so there the brew
|
|
100
|
+
# one is the only one that counts — and asking brew is the only way to see
|
|
101
|
+
# it, since it stays off PATH (keg-only). Everywhere else the package
|
|
102
|
+
# manager installs a current bison normally.
|
|
103
|
+
def bison?
|
|
104
|
+
return Shellout.tool?("bison") unless macos?
|
|
105
|
+
|
|
106
|
+
out, status = Shellout.capture("brew", "--prefix", "bison")
|
|
107
|
+
!status.nil? && status.success? && !out.strip.empty?
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def bison_label = macos? ? "homebrew bison" : "bison"
|
|
111
|
+
def bison_hint = macos? ? "brew install bison" : "install bison with your package manager"
|
|
112
|
+
|
|
113
|
+
# Config.load raises on an unparseable everywhere.yml; doctor reports it
|
|
114
|
+
# as its first check and carries on with an empty config, so the machine
|
|
115
|
+
# that most needs the rest of the report still gets it. The load happens
|
|
116
|
+
# once, here, and every later reader gets what it produced.
|
|
117
|
+
def config_check(root)
|
|
118
|
+
return true unless File.exist?(File.join(File.expand_path(root), Everywhere::Config::FILE))
|
|
119
|
+
|
|
120
|
+
config(root)
|
|
121
|
+
if @config_error
|
|
122
|
+
UI.bad(@config_error)
|
|
123
|
+
return false
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
UI.ok(Everywhere::Config::FILE)
|
|
127
|
+
true
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def config(root)
|
|
131
|
+
return @config if defined?(@config)
|
|
132
|
+
|
|
133
|
+
@config_error = nil
|
|
134
|
+
@config = begin
|
|
135
|
+
Everywhere::Config.load(File.expand_path(root))
|
|
136
|
+
rescue StandardError, SystemExit => e
|
|
137
|
+
@config_error = e.message
|
|
138
|
+
Everywhere::Config.new({}, File.expand_path(root))
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
35
142
|
def ios_wanted?(root, target)
|
|
36
143
|
return true if target && Everywhere::Config.os_of(target) == "ios"
|
|
37
144
|
|
|
38
|
-
Everywhere::Config.
|
|
39
|
-
.any? { |t| Everywhere::Config.os_of(t) == "ios" }
|
|
145
|
+
config(root).targets.any? { |t| Everywhere::Config.os_of(t) == "ios" }
|
|
40
146
|
rescue StandardError
|
|
41
147
|
false
|
|
42
148
|
end
|
|
43
149
|
|
|
44
|
-
def ios_checks
|
|
150
|
+
def ios_checks(required = true)
|
|
45
151
|
puts
|
|
46
|
-
UI.step("iOS target")
|
|
47
|
-
|
|
152
|
+
UI.step(section_label("iOS target", required))
|
|
153
|
+
# Off macOS every tool below is guaranteed absent — one honest line
|
|
154
|
+
# beats four unfixable ✗s.
|
|
155
|
+
unless macos?
|
|
156
|
+
return fix_check("macOS with Xcode", "iOS apps can only be built on a Mac") { false } if required
|
|
157
|
+
|
|
158
|
+
UI.warn("needs macOS and Xcode — skipped")
|
|
159
|
+
return false
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
ok = fix_check("full Xcode selected",
|
|
163
|
+
"install Xcode and run `sudo xcode-select -s /Applications/Xcode.app`") do
|
|
164
|
+
Shellout.run?("xcodebuild -version > /dev/null 2>&1")
|
|
165
|
+
end
|
|
48
166
|
ok &= check("iOS Simulator SDK") do
|
|
49
167
|
Shellout.run?("xcrun", "--sdk", "iphonesimulator", "--show-sdk-path", quiet: true)
|
|
50
168
|
end
|
|
@@ -69,8 +187,7 @@ module Everywhere
|
|
|
69
187
|
def android_wanted?(root, target)
|
|
70
188
|
return true if target && Everywhere::Config.os_of(target) == "android"
|
|
71
189
|
|
|
72
|
-
Everywhere::Config.
|
|
73
|
-
.any? { |t| Everywhere::Config.os_of(t) == "android" }
|
|
190
|
+
config(root).targets.any? { |t| Everywhere::Config.os_of(t) == "android" }
|
|
74
191
|
rescue StandardError
|
|
75
192
|
false
|
|
76
193
|
end
|
|
@@ -80,9 +197,9 @@ module Everywhere
|
|
|
80
197
|
# without exporting any of them, so a `which adb` would report a broken
|
|
81
198
|
# machine that in fact builds fine. Nothing auto-installs — SDK components
|
|
82
199
|
# are gigabytes — so each failure carries the exact command to run.
|
|
83
|
-
def android_checks
|
|
200
|
+
def android_checks(required = true)
|
|
84
201
|
puts
|
|
85
|
-
UI.step("Android target")
|
|
202
|
+
UI.step(section_label("Android target", required))
|
|
86
203
|
sdk = Everywhere::AndroidSdk
|
|
87
204
|
root = sdk.sdk_root
|
|
88
205
|
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
require "dry/cli"
|
|
4
4
|
require "fileutils"
|
|
5
5
|
require_relative "../shellout"
|
|
6
|
+
require_relative "../config"
|
|
7
|
+
require_relative "../agents_guide"
|
|
6
8
|
|
|
7
9
|
module Everywhere
|
|
8
10
|
module Commands
|
|
@@ -13,8 +15,9 @@ module Everywhere
|
|
|
13
15
|
|
|
14
16
|
option :root, default: ".", desc: "App root directory"
|
|
15
17
|
option :local, type: :boolean, default: false, desc: "Configure local mode (compile & ship the app) instead of remote mode"
|
|
18
|
+
option :agents, type: :boolean, default: false, desc: "Write an AGENTS.md guide so AI coding agents understand this app"
|
|
16
19
|
|
|
17
|
-
def call(root: ".", local: false, **)
|
|
20
|
+
def call(root: ".", local: false, agents: false, **)
|
|
18
21
|
@mode = local ? "local" : "remote"
|
|
19
22
|
@framework = Framework.detect(root)
|
|
20
23
|
@root = @framework.root
|
|
@@ -29,6 +32,13 @@ module Everywhere
|
|
|
29
32
|
# Framework-specific config surgery.
|
|
30
33
|
send("install_#{@framework.name}")
|
|
31
34
|
|
|
35
|
+
if agents
|
|
36
|
+
write_agents_guide
|
|
37
|
+
write_claude_pointer
|
|
38
|
+
else
|
|
39
|
+
UI.step(UI.dim("tip: `every install --agents` writes an AGENTS.md guide for AI coding agents"))
|
|
40
|
+
end
|
|
41
|
+
|
|
32
42
|
UI.success("installed — run `every dev` to open the app, `every build` to package it")
|
|
33
43
|
end
|
|
34
44
|
|
|
@@ -46,6 +56,7 @@ module Everywhere
|
|
|
46
56
|
end
|
|
47
57
|
|
|
48
58
|
def install_sinatra
|
|
59
|
+
add_puma_gem
|
|
49
60
|
vendor_bridge_to_public
|
|
50
61
|
guide_rack_manual_steps(
|
|
51
62
|
db: "point your production SQLite path at ENV.fetch(\"NATIVE_STORAGE_DIR\", \"storage\")",
|
|
@@ -55,6 +66,7 @@ module Everywhere
|
|
|
55
66
|
end
|
|
56
67
|
|
|
57
68
|
def install_hanami
|
|
69
|
+
add_puma_gem
|
|
58
70
|
vendor_bridge_to_public
|
|
59
71
|
guide_rack_manual_steps(
|
|
60
72
|
db: "override DATABASE_URL from ENV[\"NATIVE_STORAGE_DIR\"] in config.ru when NATIVE_PACKAGED",
|
|
@@ -90,14 +102,35 @@ module Everywhere
|
|
|
90
102
|
made ? UI.ok(label) : UI.step("#{label} #{UI.dim("(already done, skipped)")}")
|
|
91
103
|
end
|
|
92
104
|
|
|
105
|
+
# A step whose file this app doesn't have. Every one of these is a shape a
|
|
106
|
+
# real app legitimately ships in (API-only, DATABASE_URL, no
|
|
107
|
+
# config/puma.rb), so say what was skipped — never raise on it.
|
|
108
|
+
def skip(label, reason)
|
|
109
|
+
UI.step("#{label} #{UI.dim("(skipped — #{reason})")}")
|
|
110
|
+
end
|
|
111
|
+
|
|
93
112
|
def add_gem
|
|
113
|
+
UI.die!("no Gemfile in #{@root} — run `every install` inside your app") unless File.exist?(app_file("Gemfile"))
|
|
114
|
+
|
|
115
|
+
add_gemfile_gem("ruby_everywhere",
|
|
116
|
+
comment: "# Desktop apps from this #{@framework.name.capitalize} app — https://rubyeverywhere.com")
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Packaged Rack apps serve through Puma at runtime (Framework#serve_rack
|
|
120
|
+
# requires it), so a missing gem surfaces as a LoadError on the end user's
|
|
121
|
+
# machine at first launch rather than here.
|
|
122
|
+
def add_puma_gem
|
|
123
|
+
add_gemfile_gem("puma", comment: "# Packaged RubyEverywhere apps serve through puma.")
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def add_gemfile_gem(name, comment:)
|
|
94
127
|
gemfile = app_file("Gemfile")
|
|
95
128
|
contents = File.read(gemfile)
|
|
96
|
-
return change("add
|
|
129
|
+
return change("add #{name} to Gemfile", false) if contents.match?(/gem ["']#{name}["']/)
|
|
97
130
|
|
|
98
|
-
File.write(gemfile, "#{contents.chomp}\n\n#
|
|
131
|
+
File.write(gemfile, "#{contents.chomp}\n\n#{comment}\ngem \"#{name}\"\n")
|
|
99
132
|
Shellout.run!({}, "bundle", "install", "--quiet", chdir: @root)
|
|
100
|
-
change("add
|
|
133
|
+
change("add #{name} to Gemfile", true)
|
|
101
134
|
end
|
|
102
135
|
|
|
103
136
|
def write_config
|
|
@@ -204,9 +237,10 @@ module Everywhere
|
|
|
204
237
|
# targets: # the platforms you ship — the CI matrix, expressed once
|
|
205
238
|
# - macos-arm64
|
|
206
239
|
# # - ios-arm64 # Hotwire Native shell; remote mode only. Local builds
|
|
207
|
-
# # # (`every build` / `every dev --ios`)
|
|
208
|
-
# # #
|
|
209
|
-
# # -
|
|
240
|
+
# # # (`every build` / `every dev --ios`) and hosted
|
|
241
|
+
# # # platform builds are both live.
|
|
242
|
+
# # - ios-arm64:testflight # optional per-target distribution channel
|
|
243
|
+
# # - android-arm64 # Hotwire Native shell; remote mode only, hosted builds live
|
|
210
244
|
# capabilities: [notifications, filesystem:read, clipboard] # desktop OS integration
|
|
211
245
|
|
|
212
246
|
# Per-platform overrides. Keys are platform names (macos / ios / android /
|
|
@@ -247,8 +281,72 @@ module Everywhere
|
|
|
247
281
|
change("create native_boot.rb", true)
|
|
248
282
|
end
|
|
249
283
|
|
|
284
|
+
# --- AGENTS.md (--agents) --------------------------------------------------
|
|
285
|
+
|
|
286
|
+
FRESH_HEADER = "# AGENTS.md\n\nGuidance for AI coding agents working in this repository.\n\n"
|
|
287
|
+
|
|
288
|
+
# AGENTS.md is the app's file, not ours — most apps that have one already
|
|
289
|
+
# have notes in it. So we own only what's between the markers, and a re-run
|
|
290
|
+
# refreshes that region in place.
|
|
291
|
+
def write_agents_guide
|
|
292
|
+
path = app_file("AGENTS.md")
|
|
293
|
+
block = AgentsGuide.block(framework: @framework.name, mode: guide_mode)
|
|
294
|
+
|
|
295
|
+
unless File.exist?(path)
|
|
296
|
+
File.write(path, FRESH_HEADER + block)
|
|
297
|
+
return change("create AGENTS.md", true)
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
existing = File.read(path)
|
|
301
|
+
updated = merge_agents_block(existing, block) or return # markers are broken; warned
|
|
302
|
+
File.write(path, updated) unless updated == existing
|
|
303
|
+
change("refresh the RubyEverywhere section of AGENTS.md", updated != existing)
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# Returns the merged file, or nil when the markers are malformed — a lone
|
|
307
|
+
# BEGIN would make the next run's region match swallow everything the app's
|
|
308
|
+
# authors wrote after it, so we refuse rather than guess at a repair.
|
|
309
|
+
def merge_agents_block(existing, block)
|
|
310
|
+
begins = existing.scan(AgentsGuide::BEGIN_MARKER).size
|
|
311
|
+
ends = existing.scan(AgentsGuide::END_MARKER).size
|
|
312
|
+
|
|
313
|
+
if begins.zero? && ends.zero?
|
|
314
|
+
return existing.strip.empty? ? block : "#{existing.chomp}\n\n#{block}"
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
if begins != 1 || ends != 1 ||
|
|
318
|
+
existing.index(AgentsGuide::BEGIN_MARKER) > existing.index(AgentsGuide::END_MARKER)
|
|
319
|
+
UI.warn("AGENTS.md has unbalanced #{AgentsGuide::BEGIN_MARKER} / #{AgentsGuide::END_MARKER} " \
|
|
320
|
+
"markers — leaving it alone; fix them by hand and re-run")
|
|
321
|
+
return nil
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
region = /^#{Regexp.escape(AgentsGuide::BEGIN_MARKER)}\n.*?^#{Regexp.escape(AgentsGuide::END_MARKER)}\n?/m
|
|
325
|
+
existing.sub(region) { block }
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
# Claude Code reads AGENTS.md on its own; the pointer is for the setups that
|
|
329
|
+
# only look at CLAUDE.md. An app that already has one has its own reasons for
|
|
330
|
+
# what's in it — leave it alone and let AGENTS.md be found the normal way.
|
|
331
|
+
def write_claude_pointer
|
|
332
|
+
claude = app_file("CLAUDE.md")
|
|
333
|
+
return change("create CLAUDE.md pointer", false) if File.exist?(claude)
|
|
334
|
+
|
|
335
|
+
File.write(claude, "See @AGENTS.md for how this app works and how RubyEverywhere packages it.\n")
|
|
336
|
+
change("create CLAUDE.md pointer", true)
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
# --local only describes what THIS run configured. Once config/everywhere.yml
|
|
340
|
+
# exists it is the truth, so a re-run without the flag can't mislabel a
|
|
341
|
+
# local-mode app as remote.
|
|
342
|
+
def guide_mode
|
|
343
|
+
File.exist?(app_file(Config::FILE)) ? Config.load(@root).mode : @mode
|
|
344
|
+
end
|
|
345
|
+
|
|
250
346
|
def guard_bootsnap
|
|
251
347
|
boot = app_file("config", "boot.rb")
|
|
348
|
+
return skip("guard bootsnap for packaged runs", "no config/boot.rb") unless File.exist?(boot)
|
|
349
|
+
|
|
252
350
|
contents = File.read(boot)
|
|
253
351
|
return change("guard bootsnap for packaged runs", false) if contents.include?("NATIVE_PACKAGED")
|
|
254
352
|
|
|
@@ -256,12 +354,16 @@ module Everywhere
|
|
|
256
354
|
"# Bootsnap writes its cache to tmp/, which is read-only inside the packaged filesystem.\n" \
|
|
257
355
|
"require \"bootsnap/setup\" unless ENV[\"NATIVE_PACKAGED\"]"
|
|
258
356
|
end
|
|
259
|
-
|
|
260
|
-
|
|
357
|
+
return skip("guard bootsnap for packaged runs", "this app doesn't load bootsnap") unless made
|
|
358
|
+
|
|
359
|
+
File.write(boot, contents)
|
|
360
|
+
change("guard bootsnap for packaged runs", true)
|
|
261
361
|
end
|
|
262
362
|
|
|
263
363
|
def disable_force_ssl
|
|
264
364
|
production = app_file("config", "environments", "production.rb")
|
|
365
|
+
return skip("disable force_ssl for localhost", "no config/environments/production.rb") unless File.exist?(production)
|
|
366
|
+
|
|
265
367
|
contents = File.read(production)
|
|
266
368
|
made = false
|
|
267
369
|
made |= !!contents.gsub!(/^(\s*config\.assume_ssl\s*=\s*)true/) { "#{Regexp.last_match(1)}false # desktop: webview talks plain HTTP to 127.0.0.1" }
|
|
@@ -344,6 +446,11 @@ module Everywhere
|
|
|
344
446
|
# connection was lost" (-1005). Single local user: keep-alives off.
|
|
345
447
|
def disable_puma_keep_alives
|
|
346
448
|
puma = app_file("config", "puma.rb")
|
|
449
|
+
unless File.exist?(puma)
|
|
450
|
+
return skip("disable puma keep-alives when packaged",
|
|
451
|
+
"no config/puma.rb — add `enable_keep_alives false if ENV[\"NATIVE_PACKAGED\"]` to yours if you add one")
|
|
452
|
+
end
|
|
453
|
+
|
|
347
454
|
contents = File.read(puma)
|
|
348
455
|
return change("disable puma keep-alives when packaged", false) if contents.include?("enable_keep_alives")
|
|
349
456
|
|
|
@@ -359,6 +466,11 @@ module Everywhere
|
|
|
359
466
|
|
|
360
467
|
def point_database_at_app_data
|
|
361
468
|
db = app_file("config", "database.yml")
|
|
469
|
+
unless File.exist?(db)
|
|
470
|
+
return skip("point production SQLite at app-data dir",
|
|
471
|
+
"no config/database.yml — point the packaged database at ENV[\"NATIVE_STORAGE_DIR\"] yourself")
|
|
472
|
+
end
|
|
473
|
+
|
|
362
474
|
contents = File.read(db)
|
|
363
475
|
return change("point production SQLite at app-data dir", false) if contents.include?("NATIVE_STORAGE_DIR")
|
|
364
476
|
|