rubino-agent 0.4.0 → 0.5.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 (192) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +11 -2
  3. data/AGENTS.md +1 -1
  4. data/CHANGELOG.md +137 -1
  5. data/CONTRIBUTING.md +10 -1
  6. data/README.md +14 -5
  7. data/Rakefile +31 -0
  8. data/docs/agents.md +42 -23
  9. data/docs/architecture.md +2 -2
  10. data/docs/commands.md +28 -1
  11. data/docs/configuration.md +20 -23
  12. data/docs/getting-started.md +5 -3
  13. data/docs/security.md +16 -5
  14. data/docs/troubleshooting.md +1 -1
  15. data/exe/rubino +16 -2
  16. data/install.sh +715 -54
  17. data/lib/rubino/active_agent.rb +73 -0
  18. data/lib/rubino/agent/action_claim_guard.rb +881 -0
  19. data/lib/rubino/agent/agent_registry.rb +5 -2
  20. data/lib/rubino/agent/definition.rb +1 -9
  21. data/lib/rubino/agent/fallback_chain.rb +0 -6
  22. data/lib/rubino/agent/iteration_budget.rb +109 -3
  23. data/lib/rubino/agent/loop.rb +476 -20
  24. data/lib/rubino/agent/model_call_runner.rb +81 -3
  25. data/lib/rubino/agent/prompts/build.txt +22 -5
  26. data/lib/rubino/agent/response_validator.rb +8 -0
  27. data/lib/rubino/agent/runner.rb +133 -8
  28. data/lib/rubino/agent/tool_executor.rb +166 -14
  29. data/lib/rubino/agent/truncation_continuation.rb +4 -1
  30. data/lib/rubino/api/server.rb +19 -0
  31. data/lib/rubino/boot/config_guard.rb +71 -0
  32. data/lib/rubino/cli/chat/completion_builder.rb +42 -6
  33. data/lib/rubino/cli/chat/idle_card_host.rb +7 -1
  34. data/lib/rubino/cli/chat/session_resolver.rb +87 -21
  35. data/lib/rubino/cli/chat_command.rb +1189 -50
  36. data/lib/rubino/cli/commands.rb +281 -1
  37. data/lib/rubino/cli/config_command.rb +68 -8
  38. data/lib/rubino/cli/doctor_command.rb +204 -12
  39. data/lib/rubino/cli/jobs_command.rb +12 -0
  40. data/lib/rubino/cli/memory_command.rb +53 -20
  41. data/lib/rubino/cli/onboarding_wizard.rb +79 -6
  42. data/lib/rubino/cli/session_command.rb +172 -18
  43. data/lib/rubino/cli/setup_command.rb +131 -8
  44. data/lib/rubino/cli/skills_command.rb +67 -20
  45. data/lib/rubino/cli/trust_gate.rb +16 -7
  46. data/lib/rubino/commands/built_ins.rb +2 -0
  47. data/lib/rubino/commands/command.rb +12 -2
  48. data/lib/rubino/commands/executor.rb +149 -12
  49. data/lib/rubino/commands/handlers/agent_switch.rb +100 -0
  50. data/lib/rubino/commands/handlers/agents.rb +133 -38
  51. data/lib/rubino/commands/handlers/config.rb +4 -1
  52. data/lib/rubino/commands/handlers/help.rb +113 -14
  53. data/lib/rubino/commands/handlers/memory.rb +15 -5
  54. data/lib/rubino/commands/handlers/sessions.rb +26 -3
  55. data/lib/rubino/commands/handlers/status.rb +9 -4
  56. data/lib/rubino/commands/loader.rb +12 -0
  57. data/lib/rubino/config/configuration.rb +86 -24
  58. data/lib/rubino/config/defaults.rb +140 -33
  59. data/lib/rubino/config/loader.rb +62 -12
  60. data/lib/rubino/config/validator.rb +341 -0
  61. data/lib/rubino/config/writer.rb +123 -31
  62. data/lib/rubino/context/compressor.rb +184 -22
  63. data/lib/rubino/context/message_boundary.rb +27 -1
  64. data/lib/rubino/context/project_languages.rb +90 -0
  65. data/lib/rubino/context/prompt_assembler.rb +104 -21
  66. data/lib/rubino/context/summary_builder.rb +45 -4
  67. data/lib/rubino/context/token_budget.rb +36 -11
  68. data/lib/rubino/context/token_estimate.rb +45 -0
  69. data/lib/rubino/context/tool_result_pruner.rb +81 -0
  70. data/lib/rubino/database/connection.rb +154 -3
  71. data/lib/rubino/database/migrations/001_create_initial_schema.rb +314 -40
  72. data/lib/rubino/database/migrator.rb +98 -5
  73. data/lib/rubino/documents/cap_exceeded.rb +13 -0
  74. data/lib/rubino/documents/converters/csv.rb +4 -3
  75. data/lib/rubino/documents/converters/docx.rb +29 -5
  76. data/lib/rubino/documents/converters/html.rb +5 -1
  77. data/lib/rubino/documents/converters/json.rb +2 -1
  78. data/lib/rubino/documents/converters/pdf.rb +11 -2
  79. data/lib/rubino/documents/converters/plain.rb +2 -1
  80. data/lib/rubino/documents/converters/pptx.rb +11 -2
  81. data/lib/rubino/documents/converters/xlsx.rb +35 -4
  82. data/lib/rubino/documents/converters/xml.rb +2 -1
  83. data/lib/rubino/documents/limits.rb +210 -0
  84. data/lib/rubino/documents.rb +10 -3
  85. data/lib/rubino/errors.rb +36 -5
  86. data/lib/rubino/interaction/cancel_token.rb +19 -3
  87. data/lib/rubino/interaction/events.rb +13 -0
  88. data/lib/rubino/interaction/lifecycle.rb +99 -13
  89. data/lib/rubino/interaction/polishing.rb +176 -0
  90. data/lib/rubino/jobs/cron_job_repository.rb +5 -8
  91. data/lib/rubino/jobs/handlers/cleanup_sessions_job.rb +11 -0
  92. data/lib/rubino/jobs/handlers/distill_skill_job.rb +65 -9
  93. data/lib/rubino/jobs/queue.rb +63 -8
  94. data/lib/rubino/jobs/runner.rb +24 -6
  95. data/lib/rubino/jobs/worker.rb +0 -4
  96. data/lib/rubino/llm/adapter_response.rb +47 -4
  97. data/lib/rubino/llm/credential_check.rb +15 -16
  98. data/lib/rubino/llm/error_classifier.rb +89 -1
  99. data/lib/rubino/llm/inline_think_filter.rb +69 -12
  100. data/lib/rubino/llm/request.rb +30 -3
  101. data/lib/rubino/llm/ruby_llm_adapter.rb +394 -46
  102. data/lib/rubino/llm/tool_bridge.rb +113 -9
  103. data/lib/rubino/mcp/manager.rb +18 -1
  104. data/lib/rubino/mcp/mcp_tool_wrapper.rb +14 -3
  105. data/lib/rubino/memory/aux_retry.rb +107 -0
  106. data/lib/rubino/memory/backends/sqlite.rb +73 -44
  107. data/lib/rubino/memory/backends.rb +23 -7
  108. data/lib/rubino/memory/salience_gate.rb +103 -0
  109. data/lib/rubino/memory/sqlite_extraction.rb +70 -0
  110. data/lib/rubino/memory/sqlite_extraction_prompt.rb +11 -0
  111. data/lib/rubino/memory/store.rb +33 -5
  112. data/lib/rubino/memory/threat_scanner.rb +52 -0
  113. data/lib/rubino/output/cost.rb +52 -0
  114. data/lib/rubino/output/headless_block_latch.rb +53 -0
  115. data/lib/rubino/output/result_serializer.rb +222 -0
  116. data/lib/rubino/output/turn_recorder.rb +77 -0
  117. data/lib/rubino/security/approval_policy.rb +227 -32
  118. data/lib/rubino/security/command_allowlist.rb +79 -4
  119. data/lib/rubino/security/doom_loop_detector.rb +21 -2
  120. data/lib/rubino/security/hardline_guard.rb +189 -16
  121. data/lib/rubino/security/pattern_matcher.rb +28 -5
  122. data/lib/rubino/security/prefix_deriver.rb +25 -6
  123. data/lib/rubino/security/readonly_commands.rb +145 -5
  124. data/lib/rubino/security/secret_path.rb +134 -0
  125. data/lib/rubino/security/url_safety.rb +255 -0
  126. data/lib/rubino/session/repository.rb +212 -11
  127. data/lib/rubino/session/store.rb +139 -14
  128. data/lib/rubino/skills/installer.rb +116 -32
  129. data/lib/rubino/skills/prompt_index.rb +2 -2
  130. data/lib/rubino/skills/registry.rb +42 -1
  131. data/lib/rubino/skills/skill.rb +63 -2
  132. data/lib/rubino/skills/skill_tool.rb +16 -5
  133. data/lib/rubino/tools/background_tasks.rb +122 -9
  134. data/lib/rubino/tools/base.rb +204 -3
  135. data/lib/rubino/tools/edit_tool.rb +73 -18
  136. data/lib/rubino/tools/glob_tool.rb +48 -9
  137. data/lib/rubino/tools/grep_tool.rb +103 -9
  138. data/lib/rubino/tools/multi_edit_tool.rb +64 -9
  139. data/lib/rubino/tools/patch_tool.rb +5 -0
  140. data/lib/rubino/tools/read_attachment_tool.rb +3 -1
  141. data/lib/rubino/tools/read_tool.rb +33 -15
  142. data/lib/rubino/tools/read_tracker.rb +153 -35
  143. data/lib/rubino/tools/registry.rb +113 -12
  144. data/lib/rubino/tools/result.rb +9 -1
  145. data/lib/rubino/tools/ruby_tool.rb +0 -0
  146. data/lib/rubino/tools/shell_registry.rb +70 -0
  147. data/lib/rubino/tools/shell_tool.rb +40 -1
  148. data/lib/rubino/tools/summarize_file_tool.rb +6 -0
  149. data/lib/rubino/tools/task_stop_tool.rb +10 -16
  150. data/lib/rubino/tools/task_tool.rb +36 -8
  151. data/lib/rubino/tools/vision_tool.rb +5 -0
  152. data/lib/rubino/tools/webfetch_tool.rb +39 -7
  153. data/lib/rubino/tools/websearch_tool.rb +92 -30
  154. data/lib/rubino/tools/write_tool.rb +23 -4
  155. data/lib/rubino/ui/api.rb +10 -1
  156. data/lib/rubino/ui/base.rb +11 -0
  157. data/lib/rubino/ui/bottom_composer.rb +382 -74
  158. data/lib/rubino/ui/cli.rb +515 -83
  159. data/lib/rubino/ui/completion_menu.rb +11 -7
  160. data/lib/rubino/ui/headless_trace.rb +63 -0
  161. data/lib/rubino/ui/live_region.rb +70 -7
  162. data/lib/rubino/ui/markdown_renderer.rb +142 -7
  163. data/lib/rubino/ui/notifier.rb +0 -2
  164. data/lib/rubino/ui/null.rb +52 -5
  165. data/lib/rubino/ui/paste_store.rb +16 -2
  166. data/lib/rubino/ui/queued_indicators.rb +6 -1
  167. data/lib/rubino/ui/status_bar.rb +61 -7
  168. data/lib/rubino/ui/streaming_markdown.rb +59 -6
  169. data/lib/rubino/ui/subagent_view.rb +15 -1
  170. data/lib/rubino/ui/tool_label.rb +52 -0
  171. data/lib/rubino/update_check.rb +39 -4
  172. data/lib/rubino/util/atomic_file.rb +117 -0
  173. data/lib/rubino/util/ignore_rules.rb +120 -0
  174. data/lib/rubino/util/output.rb +229 -12
  175. data/lib/rubino/util/secrets_mask.rb +70 -7
  176. data/lib/rubino/util/spill_store.rb +153 -0
  177. data/lib/rubino/version.rb +1 -1
  178. data/lib/rubino/workspace.rb +9 -1
  179. data/lib/rubino.rb +191 -7
  180. data/rubino-agent.gemspec +1 -0
  181. data/skills/ruby-expert/SKILL.md +1 -0
  182. metadata +41 -12
  183. data/lib/rubino/agent/router.rb +0 -65
  184. data/lib/rubino/database/migrations/002_create_runs.rb +0 -45
  185. data/lib/rubino/database/migrations/003_create_skill_states.rb +0 -15
  186. data/lib/rubino/database/migrations/004_create_cron_jobs.rb +0 -36
  187. data/lib/rubino/database/migrations/005_create_oauth_connections.rb +0 -27
  188. data/lib/rubino/database/migrations/006_create_webhook_deliveries.rb +0 -34
  189. data/lib/rubino/database/migrations/007_create_messages_fts.rb +0 -59
  190. data/lib/rubino/database/migrations/008_create_memory_facts.rb +0 -75
  191. data/lib/rubino/database/migrations/009_create_memory_graph.rb +0 -55
  192. data/lib/rubino/database/migrations/010_add_owner_pid_to_sessions.rb +0 -20
data/install.sh CHANGED
@@ -4,17 +4,37 @@
4
4
  #
5
5
  # curl -fsSL https://raw.githubusercontent.com/Jhonnyr97/rubino-agent/main/install.sh | bash
6
6
  #
7
- # What it does (all in user space, no sudo):
8
- # 1. Provisions a Ruby toolchain:
9
- # - Linux: via `rv` (https://github.com/spinel-coop/rv), a fast Ruby
10
- # version manager that fetches a precompiled Ruby (no build step).
11
- # - macOS: if Homebrew is present you're asked whether to use Homebrew
12
- # (`brew install ruby`) or rv; if Homebrew is absent it uses rv directly.
13
- # 2. Installs the `rubino-agent` gem under that Ruby. If a published gem with
14
- # the CLI isn't available yet, it falls back to building from this repo.
15
- # 3. Prints the exact PATH line for the `rubino` executable.
7
+ # What it does (all in user space, no sudo): provisions a Ruby toolchain and
8
+ # installs the `rubino-agent` gem under it. There are THREE install methods:
16
9
  #
17
- # Non-interactive override: set RUBINO_INSTALL_METHOD=brew|rv to skip the prompt.
10
+ # - Homebrew (macOS): `brew install ruby`, then `gem install rubino-agent`.
11
+ # - rv: fetches a precompiled Ruby via rv
12
+ # (https://github.com/spinel-coop/rv), then installs the gem.
13
+ # - mise: uses mise (https://mise.jdx.dev) and its `gem:` backend,
14
+ # which provisions a Ruby (`ruby@3.3`, precompiled) and
15
+ # registers `rubino` as a mise tool. mise additionally
16
+ # supports a global (user-wide) or local (per-project) scope.
17
+ #
18
+ # Method selection:
19
+ # - macOS (interactive): you're asked to pick 1) Homebrew 2) rv 3) mise.
20
+ # Default is Homebrew when present, else rv.
21
+ # - Linux (interactive): you're asked to pick 1) rv 2) mise (Homebrew offered
22
+ # only if `brew` is on PATH). Default is rv.
23
+ # - Non-interactive override: RUBINO_INSTALL_METHOD=brew|rv|mise.
24
+ #
25
+ # For the mise method only, choose the scope:
26
+ # RUBINO_INSTALL_SCOPE=global # default; user-wide (~/.config/mise/config.toml)
27
+ # RUBINO_INSTALL_SCOPE=local # this project/directory only (./mise.toml)
28
+ # (Or answer the follow-up prompt.)
29
+ #
30
+ # Other overrides:
31
+ # RUBINO_RUBY_VERSION=3.3.3 # Ruby pinned for the rv method.
32
+ #
33
+ # If a published gem with the CLI isn't available yet (brew/rv methods), the
34
+ # script falls back to building from this repo.
35
+ #
36
+ # Prerequisites: a C toolchain (cc/clang + make) is required because the gem
37
+ # builds native extensions. On Debian/Ubuntu: `apt-get install build-essential`.
18
38
  #
19
39
  # Security note: you are piping a script from the internet into a shell.
20
40
  # Review it first: curl -fsSL <url> -o install.sh && less install.sh && bash install.sh
@@ -39,9 +59,17 @@ RUBY_VERSION="${RUBINO_RUBY_VERSION:-3.3.3}"
39
59
  GEM_NAME="rubino-agent"
40
60
  BIN_NAME="rubino"
41
61
 
42
- # Optional: brew | rv. When unset on macOS with Homebrew present, we prompt.
62
+ # mise tool spec for the gem backend, and the Ruby to provision when mise has none.
63
+ GEM_TOOL="gem:${GEM_NAME}"
64
+ RUBY_TOOL="ruby@3.3"
65
+
66
+ # Optional: brew | rv | mise. When unset on macOS (or Linux with brew present),
67
+ # we prompt. Linux without brew prompts between rv and mise.
43
68
  INSTALL_METHOD="${RUBINO_INSTALL_METHOD:-}"
44
69
 
70
+ # Optional (mise method only): global | local. When unset and interactive, we prompt.
71
+ INSTALL_SCOPE="${RUBINO_INSTALL_SCOPE:-}"
72
+
45
73
  # --- output helpers ---------------------------------------------------------
46
74
 
47
75
  if [ -t 1 ]; then
@@ -56,6 +84,201 @@ ok() { printf '%s==>%s %s\n' "$GREEN" "$RESET" "$*"; }
56
84
  warn() { printf '%s==>%s %s\n' "$YELLOW" "$RESET" "$*" >&2; }
57
85
  die() { printf '%serror:%s %s\n' "$RED" "$RESET" "$*" >&2; exit 1; }
58
86
 
87
+ # --- shell-rc activation (persist PATH / mise activation) -------------------
88
+
89
+ # Marker comment we drop next to the line(s) we append, so re-runs are
90
+ # idempotent and users can find/remove what we added.
91
+ RC_MARKER="# added by rubino installer (https://github.com/${REPO_OWNER}/${REPO_NAME})"
92
+
93
+ # Opt out of any shell-rc modification with RUBINO_NO_MODIFY_RC=1.
94
+ RUBINO_NO_MODIFY_RC="${RUBINO_NO_MODIFY_RC:-0}"
95
+
96
+ # Set by persist_to_rc() to the rc file(s) it touched. Init for `set -u` safety.
97
+ PERSISTED_RC=""
98
+
99
+ # The rc file the user is most likely to open/read (shown in hints). Honor
100
+ # $SHELL; default to bash so a `curl | bash` run (where $SHELL may be empty
101
+ # under -u) still persists.
102
+ detect_shell_rc() {
103
+ local shell_name
104
+ shell_name="$(basename "${SHELL:-bash}")"
105
+ case "$shell_name" in
106
+ zsh) printf '%s\n' "${ZDOTDIR:-$HOME}/.zshrc" ;;
107
+ bash) printf '%s\n' "$HOME/.bashrc" ;;
108
+ fish) printf '%s\n' "${__fish_config_dir:-$HOME/.config/fish}/config.fish" ;;
109
+ *) printf '%s\n' "$HOME/.profile" ;;
110
+ esac
111
+ }
112
+
113
+ # The set of startup files to persist into. We cover BOTH the interactive rc
114
+ # (sourced when you open a terminal) AND the login-shell profile (sourced by
115
+ # `bash -l` / SSH logins) — on some distros the interactive rc early-returns for
116
+ # non-interactive shells, so the profile is what makes a login shell pick it up.
117
+ rc_targets() {
118
+ local shell_name
119
+ shell_name="$(basename "${SHELL:-bash}")"
120
+ case "$shell_name" in
121
+ zsh)
122
+ printf '%s\n' "${ZDOTDIR:-$HOME}/.zshrc"
123
+ printf '%s\n' "${ZDOTDIR:-$HOME}/.zprofile"
124
+ ;;
125
+ bash)
126
+ printf '%s\n' "$HOME/.bashrc"
127
+ # bash login shells read the first of .bash_profile/.bash_login/.profile;
128
+ # prefer an existing one, else .profile (Debian/Ubuntu default sources .bashrc).
129
+ if [ -e "$HOME/.bash_profile" ]; then printf '%s\n' "$HOME/.bash_profile"
130
+ elif [ -e "$HOME/.bash_login" ]; then printf '%s\n' "$HOME/.bash_login"
131
+ else printf '%s\n' "$HOME/.profile"
132
+ fi
133
+ ;;
134
+ fish)
135
+ # fish does NOT read ~/.profile (it isn't POSIX); its config lives in
136
+ # config.fish, sourced for every fish session (login + interactive).
137
+ printf '%s\n' "${__fish_config_dir:-$HOME/.config/fish}/config.fish"
138
+ ;;
139
+ *)
140
+ printf '%s\n' "$HOME/.profile"
141
+ ;;
142
+ esac
143
+ }
144
+
145
+ # The shell-correct line that prepends $1 (a directory) to PATH, persisted into
146
+ # an rc file. POSIX shells (bash/zsh/sh) get `export PATH="DIR:$PATH"`; fish does
147
+ # NOT understand that syntax (no `$PATH` colon list, no `export`) — it needs
148
+ # `fish_add_path DIR`. Writing the POSIX form into config.fish would be ignored
149
+ # (or error), leaving fish users with a broken PATH while we report success
150
+ # (INST-R3-1). Args: $1 = bindir.
151
+ path_persist_line() {
152
+ local dir="$1" shell_name
153
+ shell_name="$(basename "${SHELL:-bash}")"
154
+ case "$shell_name" in
155
+ fish) printf 'fish_add_path %s\n' "$dir" ;;
156
+ *) printf 'export PATH="%s:$PATH"\n' "$dir" ;;
157
+ esac
158
+ }
159
+
160
+ # Acquire an exclusive per-rc lock, run a command, release. The lock makes the
161
+ # check-then-append in _append_line_to_rc atomic: without it two concurrent
162
+ # installs both pass the `grep -qF` (the line is in neither yet) and both append,
163
+ # producing DUPLICATE activation blocks (TOCTOU).
164
+ #
165
+ # We use `mkdir` as the mutex primitive, not `flock`: mkdir is atomic on every
166
+ # POSIX filesystem and present on macOS/busybox alike (flock ships with
167
+ # util-linux and is absent on stock macOS). Spin with a short sleep until the
168
+ # lock dir is ours, with a stale-lock timeout so a crashed installer can't wedge
169
+ # the next one forever. Falls back to running unlocked only if even mkdir is
170
+ # somehow unavailable. Args: $1 = lock dir, $2... = command to run while held.
171
+ with_rc_lock() {
172
+ local lockdir="$1"; shift
173
+ local waited=0
174
+ # Try for up to ~5s (50 * 0.1s), then assume the holder died and proceed.
175
+ while ! mkdir "$lockdir" 2>/dev/null; do
176
+ if [ "$waited" -ge 50 ]; then
177
+ rm -rf "$lockdir" 2>/dev/null || true
178
+ mkdir "$lockdir" 2>/dev/null || break
179
+ break
180
+ fi
181
+ sleep 0.1
182
+ waited=$((waited + 1))
183
+ done
184
+ # Ensure we drop the lock even if the command fails.
185
+ "$@"
186
+ local rc=$?
187
+ rmdir "$lockdir" 2>/dev/null || true
188
+ return "$rc"
189
+ }
190
+
191
+ # Append a single line, once, to one rc file. Idempotent via RC_MARKER + a grep
192
+ # for the exact line. MUST run under with_rc_lock so the grep-then-append can't
193
+ # race a concurrent install. Echoes "touched" if the line is present afterward.
194
+ _append_line_to_rc() {
195
+ local line="$1" rc="$2"
196
+ # Create the file if missing (login shells will source it). Ensure the parent
197
+ # dir exists first — fish's config.fish lives under ~/.config/fish, which may
198
+ # not exist yet on a fresh box (a bare `: >"$rc"` would then fail silently).
199
+ [ -e "$rc" ] || mkdir -p "$(dirname "$rc")" 2>/dev/null || true
200
+ [ -e "$rc" ] || : >"$rc" 2>/dev/null || return 0
201
+ if grep -qF "$line" "$rc" 2>/dev/null; then
202
+ printf 'touched'
203
+ return 0
204
+ fi
205
+ if {
206
+ printf '\n%s\n' "$RC_MARKER"
207
+ printf '%s\n' "$line"
208
+ } >>"$rc" 2>/dev/null; then
209
+ printf 'touched'
210
+ fi
211
+ }
212
+
213
+ # Append a single line, once, to each startup file from rc_targets(). Guarded by
214
+ # RC_MARKER + a grep for the exact line so re-runs don't duplicate, and by an
215
+ # exclusive per-rc lock so CONCURRENT installs don't duplicate either (TOCTOU).
216
+ # Sets PERSISTED_RC to the space-separated files it touched. Returns 0 if the
217
+ # line is present in at least one target afterward.
218
+ persist_to_rc() {
219
+ local line="$1" rc any=1 touched="" res
220
+ [ "$RUBINO_NO_MODIFY_RC" = "1" ] && return 1
221
+ while IFS= read -r rc; do
222
+ [ -n "$rc" ] || continue
223
+ # The subshell scopes the "touched" capture; the lock serializes the
224
+ # check-then-append against any other installer touching this same rc.
225
+ res="$(with_rc_lock "${rc}.rubino.lock.d" _append_line_to_rc "$line" "$rc")"
226
+ if [ "$res" = "touched" ]; then
227
+ touched="${touched:+$touched }$rc"; any=0
228
+ fi
229
+ done <<EOF
230
+ $(rc_targets)
231
+ EOF
232
+ PERSISTED_RC="$touched"
233
+ return "$any"
234
+ }
235
+
236
+ # Post-install gate: confirm `$BIN_NAME` is reachable from a FRESH interactive/
237
+ # login shell (not just this process). If it isn't, fail loudly with the exact
238
+ # line to paste — never print a success banner over a broken install.
239
+ # Args: $1 = the activation/PATH line we tried to persist (for the error hint).
240
+ verify_fresh_shell() {
241
+ local fix_line="$1" shell_name
242
+ shell_name="$(basename "${SHELL:-bash}")"
243
+
244
+ # Probe a fresh login+interactive shell — interactive so the rc body (which on
245
+ # some distros is guarded by a non-interactive early-return) actually runs,
246
+ # login so profile files are sourced too. This mirrors opening a new terminal.
247
+ local found=1
248
+ case "$shell_name" in
249
+ zsh) zsh -i -c "command -v ${BIN_NAME} >/dev/null 2>&1" >/dev/null 2>&1 || found=0 ;;
250
+ # fish: probe fish itself (a fresh login+interactive session sources
251
+ # config.fish), NOT bash -lic — bash would find a POSIX export the user's
252
+ # fish never reads, reporting a false success over a broken fish (INST-R3-1).
253
+ # `type -q` is fish's `command -v`. If fish isn't installed to probe with,
254
+ # fall through to a best-effort PATH check rather than claim success.
255
+ fish)
256
+ if command -v fish >/dev/null 2>&1; then
257
+ fish -l -i -c "type -q ${BIN_NAME}" >/dev/null 2>&1 || found=0
258
+ else
259
+ command -v "${BIN_NAME}" >/dev/null 2>&1 || found=0
260
+ fi
261
+ ;;
262
+ *) bash -lic "command -v ${BIN_NAME} >/dev/null 2>&1" >/dev/null 2>&1 || found=0 ;;
263
+ esac
264
+
265
+ if [ "$found" -eq 1 ]; then
266
+ ok "Verified: a fresh login shell finds '${BIN_NAME}'."
267
+ return 0
268
+ fi
269
+
270
+ # Broken: tell the user exactly what to do, then exit non-zero.
271
+ printf '\n'
272
+ warn "${BIN_NAME} was installed but a fresh login shell can't find it yet."
273
+ if [ "$RUBINO_NO_MODIFY_RC" = "1" ]; then
274
+ warn "RUBINO_NO_MODIFY_RC=1 is set, so no shell rc was modified."
275
+ fi
276
+ printf '%sAdd this line to your shell profile%s (%s), then open a new shell:\n' \
277
+ "$BOLD" "$RESET" "$(detect_shell_rc)" >&2
278
+ printf '\n %s\n\n' "$fix_line" >&2
279
+ die "post-install check failed: '${BIN_NAME}' not on PATH in a fresh shell (see the line above)."
280
+ }
281
+
59
282
  # --- preflight: OS / arch ---------------------------------------------------
60
283
 
61
284
  OS="$(uname -s)"
@@ -77,57 +300,233 @@ need() { command -v "$1" >/dev/null 2>&1 || die "required command not found: $1
77
300
  need curl
78
301
  need uname
79
302
 
80
- # --- choose install method (macOS may use Homebrew or rv) -------------------
303
+ # --- preflight: build prerequisites (#242) ----------------------------------
304
+ #
305
+ # The gem builds native extensions (e.g. nio4r), so a C toolchain is always
306
+ # needed; the rv/mise methods additionally fetch + unpack precompiled tarballs
307
+ # (xz) and may clone the repo (git). Rather than let `gem install` blow up deep
308
+ # in a native build, check the prerequisites the CHOSEN method needs up front:
309
+ # install them automatically when we're privileged (root + a known pkg manager),
310
+ # otherwise fail with a single actionable command the user can copy-paste.
311
+
312
+ # True when we can install OS packages non-interactively (root, or sudo present).
313
+ can_install_pkgs() {
314
+ [ "$(id -u 2>/dev/null || echo 1000)" = "0" ] || command -v sudo >/dev/null 2>&1
315
+ }
81
316
 
82
- # Decide how we get Ruby. Linux always uses rv. macOS: honor an explicit
83
- # RUBINO_INSTALL_METHOD; else if Homebrew is present, ask (when a terminal is
84
- # available); else fall back to rv. The prompt reads from /dev/tty so it works
85
- # even under `curl ... | bash`, where stdin is the script itself.
86
- choose_method() {
87
- if [ "$PLATFORM" = "linux" ]; then
88
- printf 'rv\n'; return 0
317
+ # Run a privileged command (direct as root, else via sudo).
318
+ as_root() {
319
+ if [ "$(id -u 2>/dev/null || echo 1000)" = "0" ]; then "$@"; else sudo "$@"; fi
320
+ }
321
+
322
+ # Detect a C compiler (any of cc/gcc/clang) — toolchains name it differently.
323
+ have_cc() { for c in cc gcc clang; do command -v "$c" >/dev/null 2>&1 && return 0; done; return 1; }
324
+
325
+ # Map an abstract prerequisite to the package providing it, install via the
326
+ # host's package manager. Returns non-zero if we couldn't install it.
327
+ install_pkg_for() {
328
+ local want="$1" # one of: toolchain xz git curl
329
+ if command -v apt-get >/dev/null 2>&1; then
330
+ local pkg
331
+ case "$want" in
332
+ toolchain) pkg="build-essential" ;;
333
+ xz) pkg="xz-utils" ;;
334
+ git) pkg="git" ;;
335
+ curl) pkg="curl" ;;
336
+ esac
337
+ as_root env DEBIAN_FRONTEND=noninteractive apt-get update -qq >/dev/null 2>&1 || true
338
+ as_root env DEBIAN_FRONTEND=noninteractive apt-get install -y -qq "$pkg" >/dev/null 2>&1
339
+ elif command -v dnf >/dev/null 2>&1; then
340
+ case "$want" in
341
+ toolchain) as_root dnf install -y -q gcc make >/dev/null 2>&1 ;;
342
+ xz) as_root dnf install -y -q xz >/dev/null 2>&1 ;;
343
+ git) as_root dnf install -y -q git >/dev/null 2>&1 ;;
344
+ curl) as_root dnf install -y -q curl >/dev/null 2>&1 ;;
345
+ esac
346
+ elif command -v apk >/dev/null 2>&1; then
347
+ case "$want" in
348
+ toolchain) as_root apk add --no-cache build-base >/dev/null 2>&1 ;;
349
+ xz) as_root apk add --no-cache xz >/dev/null 2>&1 ;;
350
+ git) as_root apk add --no-cache git >/dev/null 2>&1 ;;
351
+ curl) as_root apk add --no-cache curl >/dev/null 2>&1 ;;
352
+ esac
353
+ else
354
+ return 1
89
355
  fi
356
+ }
357
+
358
+ # The copy-paste install command we suggest when we can't install ourselves.
359
+ pkg_hint() {
360
+ case "$PLATFORM" in
361
+ macos) printf 'xcode-select --install # C toolchain (Homebrew also provides git/curl)' ;;
362
+ linux)
363
+ if command -v apt-get >/dev/null 2>&1; then printf 'sudo apt-get install -y build-essential xz-utils git curl'
364
+ elif command -v dnf >/dev/null 2>&1; then printf 'sudo dnf install -y gcc make xz git curl'
365
+ elif command -v apk >/dev/null 2>&1; then printf 'sudo apk add build-base xz git curl'
366
+ else printf 'install a C toolchain (gcc/clang + make), xz, git and curl with your package manager'
367
+ fi
368
+ ;;
369
+ esac
370
+ }
371
+
372
+ # Check (and, when privileged, install) the prerequisites the chosen method
373
+ # needs. Args: $1 = method (brew|rv|mise). Fails loudly if a hard requirement is
374
+ # missing and we can't provide it.
375
+ preflight_prereqs() {
376
+ local method="$1"
377
+ # Every method ends up building native extensions → needs a C toolchain.
378
+ # rv/mise fetch and unpack xz tarballs → need xz. The git fallback needs git.
379
+ # (curl is already required at the top of the script.)
380
+ local needs="toolchain"
381
+ case "$method" in
382
+ rv|mise) needs="toolchain xz git" ;;
383
+ brew) needs="toolchain git" ;;
384
+ esac
385
+
386
+ local want missing="" installed=""
387
+ for want in $needs; do
388
+ local present=0
389
+ case "$want" in
390
+ toolchain) have_cc && command -v make >/dev/null 2>&1 && present=1 ;;
391
+ *) command -v "$want" >/dev/null 2>&1 && present=1 ;;
392
+ esac
393
+ [ "$present" -eq 1 ] && continue
394
+
395
+ # macOS: don't try to install the toolchain ourselves (xcode-select is
396
+ # interactive); just record it as missing for the actionable error.
397
+ if [ "$PLATFORM" = "macos" ] || ! can_install_pkgs; then
398
+ missing="${missing:+$missing }$want"
399
+ continue
400
+ fi
401
+
402
+ info "Missing build prerequisite '${want}'; installing it..."
403
+ if install_pkg_for "$want"; then
404
+ # Re-check so we don't claim success on a no-op package manager.
405
+ local ok_now=0
406
+ case "$want" in
407
+ toolchain) have_cc && command -v make >/dev/null 2>&1 && ok_now=1 ;;
408
+ *) command -v "$want" >/dev/null 2>&1 && ok_now=1 ;;
409
+ esac
410
+ if [ "$ok_now" -eq 1 ]; then installed="${installed:+$installed }$want"
411
+ else missing="${missing:+$missing }$want"; fi
412
+ else
413
+ missing="${missing:+$missing }$want"
414
+ fi
415
+ done
416
+
417
+ [ -n "$installed" ] && ok "Installed build prerequisites: ${installed}."
418
+
419
+ if [ -n "$missing" ]; then
420
+ warn "Missing build prerequisite(s): ${missing}."
421
+ warn "rubino's gem builds native extensions and the ${method} method needs these to proceed."
422
+ printf '%sInstall them, then re-run this installer:%s\n' "$BOLD" "$RESET" >&2
423
+ printf '\n %s\n\n' "$(pkg_hint)" >&2
424
+ die "missing build prerequisites: ${missing} (see the command above)."
425
+ fi
426
+ }
427
+
428
+ # --- choose install method (brew | rv | mise) -------------------------------
429
+
430
+ # Honor an explicit RUBINO_INSTALL_METHOD. Otherwise prompt on /dev/tty (so it
431
+ # works under `curl ... | bash`, where stdin is the script itself). The offered
432
+ # options differ by platform: macOS leads with Homebrew (when present); Linux
433
+ # offers rv + mise (and Homebrew only if `brew` happens to be on PATH).
434
+ #
435
+ # A /dev/tty node can exist (e.g. in a container) yet not be openable when
436
+ # there's no controlling terminal. Probe an actual open before prompting so we
437
+ # silently fall back to the default instead of erroring on the redirect.
438
+ tty_usable() { { : >/dev/tty; } 2>/dev/null && { : </dev/tty; } 2>/dev/null; }
90
439
 
440
+ choose_method() {
91
441
  case "$INSTALL_METHOD" in
92
442
  brew) printf 'brew\n'; return 0 ;;
93
443
  rv) printf 'rv\n'; return 0 ;;
444
+ mise) printf 'mise\n'; return 0 ;;
94
445
  "") ;;
95
- *) die "RUBINO_INSTALL_METHOD must be 'brew' or 'rv' (got '${INSTALL_METHOD}')." ;;
446
+ *) die "RUBINO_INSTALL_METHOD must be 'brew', 'rv', or 'mise' (got '${INSTALL_METHOD}')." ;;
96
447
  esac
97
448
 
98
- if ! command -v brew >/dev/null 2>&1; then
99
- # No Homebrew rv directly, as requested.
100
- printf 'rv\n'; return 0
449
+ local have_brew=0
450
+ command -v brew >/dev/null 2>&1 && have_brew=1
451
+
452
+ # Defaults preserve prior behavior: macOS → brew if present else rv; Linux → rv.
453
+ local default_method
454
+ if [ "$PLATFORM" = "macos" ] && [ "$have_brew" -eq 1 ]; then
455
+ default_method="brew"
456
+ else
457
+ default_method="rv"
101
458
  fi
102
459
 
103
- # Homebrew present. Ask, if we have a terminal to ask on.
104
- if [ -r /dev/tty ] && [ -w /dev/tty ]; then
460
+ if ! tty_usable; then
461
+ # Non-interactive, no override platform default.
462
+ if [ "$default_method" = "brew" ]; then
463
+ warn "Homebrew detected but no interactive terminal; defaulting to Homebrew. Set RUBINO_INSTALL_METHOD=rv or =mise to choose another method."
464
+ fi
465
+ printf '%s\n' "$default_method"; return 0
466
+ fi
467
+
468
+ # Interactive: build a numbered menu. macOS leads with Homebrew (when present).
469
+ local ans=""
470
+ if [ "$PLATFORM" = "macos" ] && [ "$have_brew" -eq 1 ]; then
105
471
  {
106
- printf '\n%sHomebrew detected.%s How should Ruby be installed?\n' "$BOLD" "$RESET"
107
- printf ' %s1)%s Homebrew %s(brew install ruby)%s\n' "$BOLD" "$RESET" "$DIM" "$RESET"
108
- printf ' %s2)%s rv %s(fast, self-contained, no Homebrew)%s\n' "$BOLD" "$RESET" "$DIM" "$RESET"
109
- printf 'Choose %s[1/2]%s (default 1): ' "$BOLD" "$RESET"
472
+ printf '\n%sHow should rubino be installed?%s\n' "$BOLD" "$RESET"
473
+ printf ' %s1)%s Homebrew %s(brew install ruby)%s\n' "$BOLD" "$RESET" "$DIM" "$RESET"
474
+ printf ' %s2)%s rv %s(fast, self-contained, no Homebrew)%s\n' "$BOLD" "$RESET" "$DIM" "$RESET"
475
+ printf ' %s3)%s mise %s(polyglot tool manager, global or local scope)%s\n' "$BOLD" "$RESET" "$DIM" "$RESET"
476
+ printf 'Choose %s[1/2/3]%s (default 1, Homebrew): ' "$BOLD" "$RESET"
110
477
  } >/dev/tty
111
- local ans=""
112
478
  read -r ans </dev/tty || ans=""
113
479
  case "$ans" in
114
- 2|rv|RV) printf 'rv\n' ;;
115
- ""|1|brew) printf 'brew\n' ;;
116
- *) printf 'brew\n' ;;
480
+ 2|rv|RV) printf 'rv\n' ;;
481
+ 3|mise|MISE) printf 'mise\n' ;;
482
+ ""|1|brew|BREW) printf 'brew\n' ;;
483
+ *) printf 'brew\n' ;;
117
484
  esac
118
485
  return 0
119
486
  fi
120
487
 
121
- # Homebrew present but no terminal to prompt on → default to Homebrew
122
- # (the native macOS expectation). Override with RUBINO_INSTALL_METHOD=rv.
123
- warn "Homebrew detected but no interactive terminal; defaulting to Homebrew. Set RUBINO_INSTALL_METHOD=rv to use rv instead."
124
- printf 'brew\n'
488
+ if [ "$have_brew" -eq 1 ]; then
489
+ # Linux with brew present: offer all three, default rv.
490
+ {
491
+ printf '\n%sHow should rubino be installed?%s\n' "$BOLD" "$RESET"
492
+ printf ' %s1)%s rv %s(fast, self-contained; recommended)%s\n' "$BOLD" "$RESET" "$DIM" "$RESET"
493
+ printf ' %s2)%s mise %s(polyglot tool manager, global or local scope)%s\n' "$BOLD" "$RESET" "$DIM" "$RESET"
494
+ printf ' %s3)%s Homebrew %s(brew install ruby)%s\n' "$BOLD" "$RESET" "$DIM" "$RESET"
495
+ printf 'Choose %s[1/2/3]%s (default 1, rv): ' "$BOLD" "$RESET"
496
+ } >/dev/tty
497
+ read -r ans </dev/tty || ans=""
498
+ case "$ans" in
499
+ 2|mise|MISE) printf 'mise\n' ;;
500
+ 3|brew|BREW) printf 'brew\n' ;;
501
+ ""|1|rv|RV) printf 'rv\n' ;;
502
+ *) printf 'rv\n' ;;
503
+ esac
504
+ return 0
505
+ fi
506
+
507
+ # Linux without brew: rv vs mise, default rv.
508
+ {
509
+ printf '\n%sHow should rubino be installed?%s\n' "$BOLD" "$RESET"
510
+ printf ' %s1)%s rv %s(fast, self-contained; recommended)%s\n' "$BOLD" "$RESET" "$DIM" "$RESET"
511
+ printf ' %s2)%s mise %s(polyglot tool manager, global or local scope)%s\n' "$BOLD" "$RESET" "$DIM" "$RESET"
512
+ printf 'Choose %s[1/2]%s (default 1, rv): ' "$BOLD" "$RESET"
513
+ } >/dev/tty
514
+ read -r ans </dev/tty || ans=""
515
+ case "$ans" in
516
+ 2|mise|MISE) printf 'mise\n' ;;
517
+ ""|1|rv|RV) printf 'rv\n' ;;
518
+ *) printf 'rv\n' ;;
519
+ esac
125
520
  }
126
521
 
127
522
  METHOD="$(choose_method)"
128
523
 
524
+ # Now that we know the method, check (and auto-install when privileged) the
525
+ # build prerequisites it needs, with a clear actionable error otherwise (#242).
526
+ preflight_prereqs "$METHOD"
527
+
129
528
  # `rubyx <cmd...>` runs a command (gem/bundle/rake/ruby) under the Ruby we set
130
- # up, regardless of method. Each setup_* defines it plus RUBY_LABEL.
529
+ # up, for the brew/rv methods. Each setup_* defines it plus RUBY_LABEL.
131
530
  rubyx() { die "internal: ruby toolchain not initialized"; }
132
531
 
133
532
  # --- ruby toolchain: rv -----------------------------------------------------
@@ -156,10 +555,30 @@ setup_ruby_rv() {
156
555
  export PATH="$(dirname "$rv_bin"):${PATH}"
157
556
 
158
557
  info "Installing Ruby ${RUBY_VERSION} via rv (precompiled, no build step)..."
159
- "$rv_bin" ruby install "${RUBY_VERSION}" # idempotent
558
+ # On systems whose glibc rv considers "too old" (e.g. Debian 12 / glibc 2.36)
559
+ # rv installs a musl-static build and then provisions a musl Ruby that this
560
+ # glibc system can't execute. `rv ruby install` may even print "Installed",
561
+ # but `rv ruby find` then reports NoMatchingRuby — a silent, broken install
562
+ # (#241). We don't `die` here: rv on such a system simply can't provide a
563
+ # working Ruby, but the mise path (precompiled, glibc-correct) can. So we
564
+ # steer the user over to mise instead of leaving them with a broken rubino.
565
+ #
566
+ # Detection is post-hoc on `rv ruby find`: it's the exact failure the user
567
+ # hits, regardless of the underlying cause, and it doesn't regress the working
568
+ # ubuntu/rv path (where `find` succeeds and we proceed as before).
569
+ if ! "$rv_bin" ruby install "${RUBY_VERSION}" >/dev/null 2>&1; then
570
+ warn "rv could not install Ruby ${RUBY_VERSION} on this system."
571
+ fallback_to_mise_from_rv
572
+ return 0 # not reached: fallback_to_mise_from_rv exits the script.
573
+ fi
160
574
  local ruby_bin
161
- ruby_bin="$("$rv_bin" ruby find "${RUBY_VERSION}")"
162
- [ -x "$ruby_bin" ] || die "rv reported Ruby ${RUBY_VERSION} installed but its ruby binary wasn't found."
575
+ if ! ruby_bin="$("$rv_bin" ruby find "${RUBY_VERSION}" 2>/dev/null)" || [ -z "$ruby_bin" ] || [ ! -x "$ruby_bin" ]; then
576
+ warn "rv installed Ruby ${RUBY_VERSION} but can't locate a usable binary for it"
577
+ warn "(common on Debian 12 / older glibc, where rv falls back to a musl build"
578
+ warn "that this system can't execute)."
579
+ fallback_to_mise_from_rv
580
+ return 0 # not reached.
581
+ fi
163
582
  RUBY_BIN_DIR="$(dirname "$ruby_bin")"
164
583
  RUBY_LABEL="Ruby ${RUBY_VERSION} (rv)"
165
584
 
@@ -167,6 +586,18 @@ setup_ruby_rv() {
167
586
  ok "${RUBY_LABEL} ready: ${RUBY_BIN_DIR}"
168
587
  }
169
588
 
589
+ # Hand off from a broken rv install to the mise method, which provisions a
590
+ # precompiled glibc-correct Ruby that works where rv's musl build doesn't (#241).
591
+ # setup_mise() runs the full install and exits, so this never returns.
592
+ fallback_to_mise_from_rv() {
593
+ warn "Falling back to the mise install method (works on this system)."
594
+ METHOD="mise"
595
+ # mise needs the same build prerequisites; re-run preflight for its method now
596
+ # that we've switched (the earlier preflight ran for 'rv').
597
+ preflight_prereqs "mise"
598
+ setup_mise
599
+ }
600
+
170
601
  # --- ruby toolchain: Homebrew ----------------------------------------------
171
602
 
172
603
  setup_ruby_brew() {
@@ -190,11 +621,184 @@ setup_ruby_brew() {
190
621
  ok "${RUBY_LABEL} ready: ${RUBY_BIN_DIR}"
191
622
  }
192
623
 
624
+ # --- ruby toolchain + gem install: mise -------------------------------------
625
+
626
+ # mise is special: it both provisions Ruby and installs the gem (via its `gem:`
627
+ # backend), and supports global/local scope. So setup_mise() runs the full
628
+ # install and then exits the script, rather than returning into the shared
629
+ # gem-install path used by brew/rv.
630
+ setup_mise() {
631
+ # mise's installer drops the binary in ~/.local/bin/mise (or $MISE_INSTALL_PATH).
632
+ # We pass through without forcing shell-rc edits and locate the binary ourselves.
633
+ locate_mise() {
634
+ if command -v mise >/dev/null 2>&1; then command -v mise; return 0; fi
635
+ for cand in "${MISE_INSTALL_PATH:-}" "$HOME/.local/bin/mise"; do
636
+ [ -n "$cand" ] && [ -x "$cand" ] && { printf '%s\n' "$cand"; return 0; }
637
+ done
638
+ return 1
639
+ }
640
+
641
+ local mise_bin
642
+ if mise_bin="$(locate_mise)"; then
643
+ ok "mise already installed: ${mise_bin}"
644
+ else
645
+ info "Installing mise (polyglot tool/version manager)..."
646
+ curl -fsSL https://mise.run | sh
647
+ mise_bin="$(locate_mise)" || die "mise install completed but the mise binary wasn't found at ~/.local/bin/mise or \$MISE_INSTALL_PATH."
648
+ ok "Installed mise: ${mise_bin}"
649
+ fi
650
+
651
+ # Use the located binary for everything so we don't depend on the user's shell
652
+ # being activated. (`mise` may print activation hints to stderr; that's fine.)
653
+ mise() { "$mise_bin" "$@"; }
654
+
655
+ # Put the mise bindir on PATH for the rest of this process. The gem: backend
656
+ # installs a RubyGems plugin whose post-install hook shells out to a bare `mise`
657
+ # (mise reshim); on a freshly bootstrapped machine that binary isn't on PATH yet,
658
+ # so the gem install would error with "No such file or directory - mise". Adding
659
+ # its dir here makes the hook resolve. (No persistent shell-rc edit.)
660
+ case ":${PATH}:" in
661
+ *":$(dirname "$mise_bin"):"*) ;;
662
+ *) PATH="$(dirname "$mise_bin"):${PATH}"; export PATH ;;
663
+ esac
664
+
665
+ # The gem backend is experimental; persist the setting.
666
+ info "Enabling mise experimental features (required for the gem: backend)..."
667
+ mise settings experimental=true || die "failed to persist 'mise settings experimental=true'."
668
+
669
+ # The gem backend needs a Ruby to install under and to build native exts with.
670
+ # If mise can't resolve a ruby, provision a precompiled one globally.
671
+ if mise which ruby >/dev/null 2>&1; then
672
+ ok "mise already manages a Ruby: $(mise which ruby 2>/dev/null || echo '?')"
673
+ else
674
+ info "No mise-managed Ruby found; installing ${RUBY_TOOL} (precompiled)..."
675
+ mise use -g "${RUBY_TOOL}" || die "mise use -g ${RUBY_TOOL} failed."
676
+ ok "Ruby ready via mise (${RUBY_TOOL})."
677
+ fi
678
+
679
+ # Choose scope (global default, or local/project).
680
+ local scope=""
681
+ case "$INSTALL_SCOPE" in
682
+ global|local) scope="$INSTALL_SCOPE" ;;
683
+ "") ;;
684
+ *) die "RUBINO_INSTALL_SCOPE must be 'global' or 'local' (got '${INSTALL_SCOPE}')." ;;
685
+ esac
686
+
687
+ if [ -z "$scope" ]; then
688
+ if tty_usable; then
689
+ {
690
+ printf '\n%sInstall rubino globally or for this project?%s\n' "$BOLD" "$RESET"
691
+ printf ' %sglobal%s %s(user-wide → ~/.config/mise/config.toml)%s\n' "$BOLD" "$RESET" "$DIM" "$RESET"
692
+ printf ' %slocal%s %s(this directory only → ./mise.toml)%s\n' "$BOLD" "$RESET" "$DIM" "$RESET"
693
+ printf 'Choose %s[global/local]%s (default global): ' "$BOLD" "$RESET"
694
+ } >/dev/tty
695
+ local ans=""
696
+ read -r ans </dev/tty || ans=""
697
+ case "$ans" in
698
+ local|l|2) scope="local" ;;
699
+ ""|global|g|1) scope="global" ;;
700
+ *) scope="global" ;;
701
+ esac
702
+ else
703
+ scope="global"
704
+ fi
705
+ fi
706
+
707
+ info "Detected ${OS} ${ARCH}. Installing rubino via mise (${scope} scope)."
708
+
709
+ # Resolve the latest PUBLISHED gem version and pin it explicitly. By default
710
+ # mise applies `minimum_release_age`, which hides a freshly published release
711
+ # (you'd see "... hidden by minimum_release_age" and get a stale version). We
712
+ # both pin the exact version AND disable the release-age gate for this install
713
+ # so the just-published gem is taken. (#258)
714
+ local gem_tool="${GEM_TOOL}" want_ver
715
+ want_ver="$(curl -fsSL "https://rubygems.org/api/v1/gems/${GEM_NAME}.json" 2>/dev/null \
716
+ | grep -oE '"version":"[0-9]+\.[0-9]+\.[0-9]+[^"]*"' | head -n1 \
717
+ | grep -oE '[0-9]+\.[0-9]+\.[0-9]+[^"]*')"
718
+ if [ -n "$want_ver" ]; then
719
+ gem_tool="${GEM_TOOL}@${want_ver}"
720
+ info "Latest published ${GEM_NAME} is ${want_ver}; pinning ${gem_tool}."
721
+ else
722
+ warn "Could not resolve the latest ${GEM_NAME} version from RubyGems; letting mise pick."
723
+ fi
724
+ # MISE_MINIMUM_RELEASE_AGE=0 ensures a just-published version isn't filtered.
725
+ export MISE_MINIMUM_RELEASE_AGE=0
726
+
727
+ case "$scope" in
728
+ global)
729
+ info "Installing ${gem_tool} globally (mise use -g)..."
730
+ mise use -g "${gem_tool}" || die "mise use -g ${gem_tool} failed (native build needs a C toolchain — see prerequisites above)."
731
+ ;;
732
+ local)
733
+ info "Installing ${gem_tool} for this directory (mise use)..."
734
+ mise use "${gem_tool}" || die "mise use ${gem_tool} failed (native build needs a C toolchain — see prerequisites above)."
735
+ ;;
736
+ esac
737
+
738
+ # Verify.
739
+ local rubino_path ver
740
+ rubino_path="$(mise which "${BIN_NAME}" 2>/dev/null || true)"
741
+ [ -n "$rubino_path" ] || die "mise installed ${GEM_TOOL} but 'mise which ${BIN_NAME}' resolved nothing."
742
+
743
+ ver="$(mise exec -- "${BIN_NAME}" --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -n1 || true)"
744
+ [ -n "$ver" ] || die "installed ${GEM_TOOL} but '${BIN_NAME} --version' did not report a version."
745
+
746
+ printf '\n'
747
+ ok "rubino v${ver} installed via mise (${scope} scope)."
748
+ ok "executable: ${rubino_path}"
749
+ printf '\n'
750
+
751
+ # Activation: rubino resolves through mise's shims/activation. If mise isn't
752
+ # activated in the user's shell, `rubino` won't be found in a plain shell even
753
+ # though it's installed. Persist the activation line to the user's rc so a
754
+ # fresh login shell works — printing a hint alone left fresh shells broken (#257).
755
+ local shell_name act_sh act_line
756
+ shell_name="$(basename "${SHELL:-bash}")"
757
+ case "$shell_name" in
758
+ zsh) act_sh="zsh" ;;
759
+ bash) act_sh="bash" ;;
760
+ *) act_sh="$shell_name" ;;
761
+ esac
762
+ # The activation snippet differs by shell: POSIX shells eval the command
763
+ # substitution; fish pipes it to `source` (fish has no `eval "$(...)"`). Using
764
+ # the POSIX form in config.fish would error and leave fish broken (INST-R3-1).
765
+ if [ "$shell_name" = "fish" ]; then
766
+ act_line="$mise_bin activate fish | source"
767
+ else
768
+ act_line="eval \"\$($mise_bin activate ${act_sh:-bash})\""
769
+ fi
770
+
771
+ if command -v "${BIN_NAME}" >/dev/null 2>&1; then
772
+ ok "${BIN_NAME} is already on your PATH (mise is activated)."
773
+ elif persist_to_rc "$act_line"; then
774
+ ok "Added mise activation to ${PERSISTED_RC} (open a new shell to pick it up)."
775
+ printf 'Until then you can run it with:\n'
776
+ printf '\n %smise exec -- %s%s\n\n' "$DIM" "${BIN_NAME}" "$RESET"
777
+ else
778
+ # Opt-out or couldn't write: keep the manual hint.
779
+ printf '%sActivate mise in your shell%s so %s%s%s is on your PATH. Add to %s:\n' \
780
+ "$BOLD" "$RESET" "$BOLD" "${BIN_NAME}" "$RESET" "$(detect_shell_rc)"
781
+ printf '\n %s%s%s\n\n' "$DIM" "$act_line" "$RESET"
782
+ printf 'Then open a new shell. Until then you can run it with:\n'
783
+ printf '\n %smise exec -- %s%s\n\n' "$DIM" "${BIN_NAME}" "$RESET"
784
+ fi
785
+
786
+ # Post-install gate: fail loudly if a fresh shell still can't find rubino.
787
+ verify_fresh_shell "$act_line"
788
+
789
+ printf '%sNext step:%s\n\n' "$BOLD" "$RESET"
790
+ printf ' %s%s setup%s %s# guided first-run: pick a provider, paste a key%s\n\n' "$GREEN" "${BIN_NAME}" "$RESET" "$DIM" "$RESET"
791
+ printf 'Run: %s%s setup%s\n' "$BOLD" "${BIN_NAME}" "$RESET"
792
+
793
+ exit 0
794
+ }
795
+
193
796
  info "Detected ${OS} ${ARCH}. Installing rubino via ${METHOD}."
194
797
 
195
798
  case "$METHOD" in
196
799
  rv) setup_ruby_rv ;;
197
800
  brew) setup_ruby_brew ;;
801
+ mise) setup_mise ;; # runs the full mise install and exits.
198
802
  *) die "internal: unknown method '${METHOD}'." ;;
199
803
  esac
200
804
 
@@ -204,21 +808,43 @@ esac
204
808
  GEM_BIN_DIR="$(rubyx ruby -e 'print Gem.bindir' 2>/dev/null)" || GEM_BIN_DIR=""
205
809
  [ -n "${GEM_BIN_DIR:-}" ] || GEM_BIN_DIR="$RUBY_BIN_DIR"
206
810
 
207
- # --- install the rubino gem -------------------------------------------------
811
+ # --- install the rubino gem (brew / rv methods) -----------------------------
208
812
 
209
813
  gem_bin_present() { [ -x "${GEM_BIN_DIR}/${BIN_NAME}" ]; }
210
814
 
815
+ # Set by install_published() when the gem install ran but failed for a reason
816
+ # OTHER than "not published / no CLI" — i.e. a real error (network, native build,
817
+ # permissions) we must surface instead of the misleading git-fallback message.
818
+ GEM_INSTALL_LOG=""
819
+
211
820
  install_published() {
212
821
  info "Trying published gem: gem install ${GEM_NAME}..."
213
- if rubyx gem install "${GEM_NAME}" >/dev/null 2>&1; then
822
+ # Capture output instead of discarding it: on a genuine failure we want to show
823
+ # the real cause, not hide it behind ">/dev/null" and a misleading message (#242).
824
+ local out rc
825
+ out="$(rubyx gem install "${GEM_NAME}" 2>&1)"; rc=$?
826
+ if [ "$rc" -eq 0 ]; then
214
827
  if gem_bin_present; then
215
828
  ok "Installed ${GEM_NAME} from RubyGems."
216
829
  return 0
217
830
  fi
831
+ # Installed cleanly but ships no CLI → legitimately fall through to the git
832
+ # build (this is the real "not the CLI gem yet" case).
218
833
  warn "A '${GEM_NAME}' gem was installed but it doesn't provide the '${BIN_NAME}' CLI; building from source instead."
219
834
  rubyx gem uninstall "${GEM_NAME}" -aIx >/dev/null 2>&1 || true
835
+ return 1
220
836
  fi
221
- return 1
837
+
838
+ # gem install actually errored. If RubyGems says the gem can't be found, the
839
+ # CLI simply isn't published yet → the git build is the right fallback (quietly).
840
+ if printf '%s' "$out" | grep -qiE "could not find a valid gem|Unable to download|find .*${GEM_NAME}.* in any"; then
841
+ return 1
842
+ fi
843
+
844
+ # Any other failure (native build, network, permissions): stash it so the
845
+ # caller surfaces the real error rather than "isn't on RubyGems yet".
846
+ GEM_INSTALL_LOG="$out"
847
+ return 2
222
848
  }
223
849
 
224
850
  install_from_git() {
@@ -227,19 +853,30 @@ install_from_git() {
227
853
  local work
228
854
  work="$(mktemp -d)"
229
855
  trap 'rm -rf "$work"' RETURN
230
- git clone --depth 1 "$REPO_URL" "$work/${REPO_NAME}" >/dev/null 2>&1 \
231
- || die "git clone of ${REPO_URL} failed."
856
+ # Run a build step, capturing output; on failure surface the real error (#242)
857
+ # instead of a bare "X failed" with the cause swallowed by >/dev/null.
858
+ run_step() {
859
+ local label="$1"; shift
860
+ local out rc
861
+ out="$("$@" 2>&1)"; rc=$?
862
+ if [ "$rc" -ne 0 ]; then
863
+ warn "${label} failed. The actual error was:"
864
+ printf '%s\n' "$out" >&2
865
+ die "${label} failed (real error shown above)."
866
+ fi
867
+ }
868
+ run_step "git clone of ${REPO_URL}" git clone --depth 1 "$REPO_URL" "$work/${REPO_NAME}"
232
869
  (
233
870
  cd "$work/${REPO_NAME}"
234
871
  info "Resolving dependencies (bundle install)..."
235
- rubyx bundle install >/dev/null 2>&1 || die "bundle install failed."
872
+ run_step "bundle install" rubyx bundle install
236
873
  info "Building the gem (rake build)..."
237
- rubyx rake build >/dev/null 2>&1 || die "rake build failed."
874
+ run_step "rake build" rubyx rake build
238
875
  local pkg
239
876
  pkg="$(ls -1 pkg/${GEM_NAME}-*.gem 2>/dev/null | head -n1)"
240
877
  [ -n "$pkg" ] || die "rake build produced no gem in pkg/."
241
878
  info "Installing ${pkg}..."
242
- rubyx gem install "$pkg" >/dev/null 2>&1 || die "gem install of the built package failed."
879
+ run_step "gem install of the built package" rubyx gem install "$pkg"
243
880
  )
244
881
  gem_bin_present || die "built and installed ${GEM_NAME} but the '${BIN_NAME}' executable is missing."
245
882
  ok "Installed ${GEM_NAME} from source."
@@ -248,8 +885,20 @@ install_from_git() {
248
885
  if gem_bin_present; then
249
886
  CURRENT_VER="$("${GEM_BIN_DIR}/${BIN_NAME}" version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -n1 || true)"
250
887
  ok "${BIN_NAME} ${CURRENT_VER:+v$CURRENT_VER }is already installed (re-run safe)."
251
- elif ! install_published; then
252
- install_from_git
888
+ else
889
+ # `|| gem_rc=$?` keeps the non-zero returns (1=not-published, 2=real-error)
890
+ # from tripping `set -e`; default 0 on success.
891
+ gem_rc=0
892
+ install_published || gem_rc=$?
893
+ case "$gem_rc" in
894
+ 0) : ;; # installed the published gem
895
+ 1) install_from_git ;; # not published / no CLI yet → build from source
896
+ *) # real error: surface the actual gem output (#242)
897
+ warn "gem install ${GEM_NAME} failed. The actual error was:"
898
+ printf '%s\n' "${GEM_INSTALL_LOG}" >&2
899
+ die "gem install ${GEM_NAME} failed (real error shown above)."
900
+ ;;
901
+ esac
253
902
  fi
254
903
 
255
904
  # --- PATH guidance + success ------------------------------------------------
@@ -264,12 +913,24 @@ else
264
913
  PATH_OK=0
265
914
  fi
266
915
 
916
+ # Shell-correct PATH-persist line (fish needs `fish_add_path`, not POSIX export).
917
+ PATH_LINE="$(path_persist_line "${GEM_BIN_DIR}")"
918
+
267
919
  if [ "$PATH_OK" -ne 1 ]; then
268
- printf '%sAdd this line to your shell profile%s (~/.bashrc, ~/.zshrc, ~/.profile):\n' "$BOLD" "$RESET"
269
- printf '\n %sexport PATH="%s:$PATH"%s\n\n' "$DIM" "${GEM_BIN_DIR}" "$RESET"
270
- printf 'Then open a new shell (or run the export above) so %s%s%s is on your PATH.\n\n' "$BOLD" "${BIN_NAME}" "$RESET"
920
+ # Persist the PATH line to the user's rc so a fresh login shell finds rubino —
921
+ # printing the hint alone left fresh shells broken (#257).
922
+ if persist_to_rc "$PATH_LINE"; then
923
+ ok "Added ${BIN_NAME} to your PATH in ${PERSISTED_RC} (open a new shell to pick it up)."
924
+ else
925
+ printf '%sAdd this line to your shell profile%s (%s):\n' "$BOLD" "$RESET" "$(detect_shell_rc)"
926
+ printf '\n %s%s%s\n\n' "$DIM" "$PATH_LINE" "$RESET"
927
+ printf 'Then open a new shell (or run the export above) so %s%s%s is on your PATH.\n\n' "$BOLD" "${BIN_NAME}" "$RESET"
928
+ fi
271
929
  fi
272
930
 
931
+ # Post-install gate: confirm a fresh login shell finds rubino, or fail loudly.
932
+ verify_fresh_shell "$PATH_LINE"
933
+
273
934
  printf '%sNext step:%s\n\n' "$BOLD" "$RESET"
274
935
  printf ' %s%s setup%s %s# guided first-run: pick a provider, paste a key%s\n\n' "$GREEN" "${BIN_NAME}" "$RESET" "$DIM" "$RESET"
275
936