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.
Files changed (55) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +13 -1
  3. data/exe/every +12 -1
  4. data/exe/rbe +12 -1
  5. data/lib/everywhere/agents_guide.rb +59 -0
  6. data/lib/everywhere/blake2b.rb +17 -1
  7. data/lib/everywhere/boot.rb +21 -4
  8. data/lib/everywhere/builders/android.rb +144 -30
  9. data/lib/everywhere/builders/desktop.rb +25 -17
  10. data/lib/everywhere/builders/ios.rb +244 -9
  11. data/lib/everywhere/cli.rb +2 -0
  12. data/lib/everywhere/commands/build.rb +113 -58
  13. data/lib/everywhere/commands/clean.rb +8 -3
  14. data/lib/everywhere/commands/dev.rb +85 -11
  15. data/lib/everywhere/commands/doctor.rb +138 -21
  16. data/lib/everywhere/commands/install.rb +121 -9
  17. data/lib/everywhere/commands/platform/build.rb +136 -27
  18. data/lib/everywhere/commands/platform/login.rb +16 -2
  19. data/lib/everywhere/commands/platform/runner.rb +113 -23
  20. data/lib/everywhere/commands/preview.rb +359 -0
  21. data/lib/everywhere/commands/publish.rb +20 -2
  22. data/lib/everywhere/commands/release.rb +145 -28
  23. data/lib/everywhere/commands/shell_dir.rb +2 -2
  24. data/lib/everywhere/config.rb +88 -3
  25. data/lib/everywhere/console.rb +2 -1
  26. data/lib/everywhere/engine.rb +24 -0
  27. data/lib/everywhere/framework.rb +21 -4
  28. data/lib/everywhere/ignore.rb +3 -1
  29. data/lib/everywhere/jump.rb +121 -0
  30. data/lib/everywhere/mobile_config_endpoint.rb +47 -0
  31. data/lib/everywhere/mobile_configs_controller.rb +46 -0
  32. data/lib/everywhere/paths.rb +13 -6
  33. data/lib/everywhere/platform/client.rb +27 -6
  34. data/lib/everywhere/platform/credentials.rb +18 -8
  35. data/lib/everywhere/platform/snapshot.rb +10 -0
  36. data/lib/everywhere/receipt.rb +55 -10
  37. data/lib/everywhere/shellout.rb +19 -0
  38. data/lib/everywhere/simulator.rb +12 -1
  39. data/lib/everywhere/version.rb +1 -1
  40. data/support/agents/AGENTS.md +90 -0
  41. data/support/agents/frameworks/hanami-views.md +26 -0
  42. data/support/agents/frameworks/hanami.md +10 -0
  43. data/support/agents/frameworks/rails-views.md +98 -0
  44. data/support/agents/frameworks/rails.md +16 -0
  45. data/support/agents/frameworks/sinatra-views.md +24 -0
  46. data/support/agents/frameworks/sinatra.md +9 -0
  47. data/support/agents/modes/local.md +12 -0
  48. data/support/agents/modes/remote.md +14 -0
  49. data/support/mobile/android/app/build.gradle.kts +29 -1
  50. data/support/mobile/ios/App/EverywhereConfig.swift +17 -5
  51. data/support/mobile/ios/App/SceneDelegate.swift +75 -8
  52. data/support/mobile/ios/App.xcodeproj/project.pbxproj +2 -2
  53. data/support/mobile/ios/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +0 -1
  54. data/support/mobile/ios/README.md +13 -6
  55. 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
- saved_tty = `stty -g`.chomp
510
- system("stty", "-icanon", "-echo")
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) unless saved_tty.to_s.empty?
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 Errno::ECONNREFUSED, Errno::ETIMEDOUT
680
+ rescue SystemCallError
620
681
  false
621
682
  end
622
683
 
623
- def wait_for_port(port, timeout: 60)
624
- deadline = Time.now + timeout
625
- sleep 0.5 until port_open?(port) || Time.now > deadline
626
- UI.die!("dev server never came up on port #{port}") unless port_open?(port)
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. Desktop checks always
11
- # run; the iOS and Android sections join in when the app's build.targets
12
- # include such a target, or explicitly via --target ios-* / android-*.
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 the sections)"
17
- option :target, default: nil, desc: "Also check a specific target's toolchain (e.g. ios-arm64)"
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 = true
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
- ok &= ios_checks if ios_wanted?(root, target)
26
- ok &= android_checks if android_wanted?(root, target)
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 ? UI.success("ready to build") : UI.bad("fix the items above (see https://rubyeverywhere.com/docs/diy/requirements)")
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.load(File.expand_path(root)).targets
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
- ok = check("full Xcode selected") { Shellout.run?("xcodebuild -version > /dev/null 2>&1") }
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.load(File.expand_path(root)).targets
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 ruby_everywhere to Gemfile", false) if contents.match?(/gem ["']ruby_everywhere["']/)
129
+ return change("add #{name} to Gemfile", false) if contents.match?(/gem ["']#{name}["']/)
97
130
 
98
- File.write(gemfile, "#{contents.chomp}\n\n# Desktop apps from this #{@framework.name.capitalize} app — https://rubyeverywhere.com\ngem \"ruby_everywhere\"\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 ruby_everywhere to Gemfile", true)
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`) work today;
208
- # # # hosted platform builds for iOS aren't live yet.
209
- # # - android-arm64 # future mobile is remote mode only
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
- File.write(boot, contents) if made
260
- change("guard bootsnap for packaged runs", !!made)
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