devex 0.3.5
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 +7 -0
- data/.obsidian/app.json +6 -0
- data/.obsidian/appearance.json +4 -0
- data/.obsidian/community-plugins.json +5 -0
- data/.obsidian/core-plugins.json +33 -0
- data/.obsidian/plugins/obsidian-minimal-settings/data.json +34 -0
- data/.obsidian/plugins/obsidian-minimal-settings/main.js +8 -0
- data/.obsidian/plugins/obsidian-minimal-settings/manifest.json +11 -0
- data/.obsidian/plugins/obsidian-style-settings/data.json +15 -0
- data/.obsidian/plugins/obsidian-style-settings/main.js +165 -0
- data/.obsidian/plugins/obsidian-style-settings/manifest.json +10 -0
- data/.obsidian/plugins/obsidian-style-settings/styles.css +243 -0
- data/.obsidian/plugins/table-editor-obsidian/data.json +6 -0
- data/.obsidian/plugins/table-editor-obsidian/main.js +236 -0
- data/.obsidian/plugins/table-editor-obsidian/manifest.json +17 -0
- data/.obsidian/plugins/table-editor-obsidian/styles.css +78 -0
- data/.obsidian/themes/AnuPpuccin/manifest.json +7 -0
- data/.obsidian/themes/AnuPpuccin/theme.css +9080 -0
- data/.obsidian/themes/Minimal/manifest.json +8 -0
- data/.obsidian/themes/Minimal/theme.css +2251 -0
- data/.rubocop.yml +231 -0
- data/CHANGELOG.md +97 -0
- data/LICENSE +21 -0
- data/README.md +314 -0
- data/Rakefile +13 -0
- data/devex-logo.jpg +0 -0
- data/docs/developing-tools.md +1000 -0
- data/docs/ref/agent-mode.md +46 -0
- data/docs/ref/cli-interface.md +60 -0
- data/docs/ref/configuration.md +46 -0
- data/docs/ref/design-philosophy.md +17 -0
- data/docs/ref/error-handling.md +38 -0
- data/docs/ref/io-handling.md +88 -0
- data/docs/ref/signals.md +141 -0
- data/docs/ref/temporal-software-theory.md +790 -0
- data/exe/dx +52 -0
- data/lib/devex/builtins/.index.rb +10 -0
- data/lib/devex/builtins/debug.rb +43 -0
- data/lib/devex/builtins/format.rb +44 -0
- data/lib/devex/builtins/gem.rb +77 -0
- data/lib/devex/builtins/lint.rb +61 -0
- data/lib/devex/builtins/test.rb +76 -0
- data/lib/devex/builtins/version.rb +156 -0
- data/lib/devex/cli.rb +340 -0
- data/lib/devex/context.rb +433 -0
- data/lib/devex/core/configuration.rb +136 -0
- data/lib/devex/core.rb +79 -0
- data/lib/devex/dirs.rb +210 -0
- data/lib/devex/dsl.rb +100 -0
- data/lib/devex/exec/controller.rb +245 -0
- data/lib/devex/exec/result.rb +229 -0
- data/lib/devex/exec.rb +662 -0
- data/lib/devex/loader.rb +136 -0
- data/lib/devex/output.rb +257 -0
- data/lib/devex/project_paths.rb +309 -0
- data/lib/devex/support/ansi.rb +437 -0
- data/lib/devex/support/core_ext.rb +560 -0
- data/lib/devex/support/global.rb +68 -0
- data/lib/devex/support/path.rb +357 -0
- data/lib/devex/support.rb +71 -0
- data/lib/devex/template_helpers.rb +136 -0
- data/lib/devex/templates/debug.erb +24 -0
- data/lib/devex/tool.rb +374 -0
- data/lib/devex/version.rb +5 -0
- data/lib/devex/working_dir.rb +99 -0
- data/lib/devex.rb +158 -0
- data/ruby-project-template/.gitignore +0 -0
- data/ruby-project-template/Gemfile +0 -0
- data/ruby-project-template/README.md +0 -0
- data/ruby-project-template/docs/README.md +0 -0
- data/sig/devex.rbs +4 -0
- metadata +122 -0
data/lib/devex/exec.rb
ADDED
|
@@ -0,0 +1,662 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "exec/result"
|
|
4
|
+
require_relative "exec/controller"
|
|
5
|
+
|
|
6
|
+
module Devex
|
|
7
|
+
# External command execution with automatic environment orchestration.
|
|
8
|
+
#
|
|
9
|
+
# This module provides the primary interface for running external commands
|
|
10
|
+
# from devex tools. All methods handle the environment stack automatically:
|
|
11
|
+
#
|
|
12
|
+
# [dotenv] [mise exec --] [bundle exec] command
|
|
13
|
+
#
|
|
14
|
+
# This means `run "rspec"` automatically:
|
|
15
|
+
# - Activates mise versions if .mise.toml or .tool-versions present
|
|
16
|
+
# - Runs through bundle exec if Gemfile present and command looks like a gem
|
|
17
|
+
# - Cleans RUBYOPT/BUNDLE_* from devex's own bundler context
|
|
18
|
+
#
|
|
19
|
+
# Dotenv requires explicit opt-in:
|
|
20
|
+
# run "rspec", dotenv: true
|
|
21
|
+
#
|
|
22
|
+
# Wrapper control:
|
|
23
|
+
# run "rspec" # auto-detect mise and bundle
|
|
24
|
+
# run "rspec", mise: false # skip mise wrapping
|
|
25
|
+
# run "rspec", bundle: false # skip bundle exec
|
|
26
|
+
# run "rspec", raw: true # skip all wrappers
|
|
27
|
+
# run "rspec", dotenv: true # explicitly enable dotenv
|
|
28
|
+
#
|
|
29
|
+
# See ADR-001-external-commands-v2.md for full specification.
|
|
30
|
+
#
|
|
31
|
+
# @example Basic usage
|
|
32
|
+
# include Devex::Exec
|
|
33
|
+
#
|
|
34
|
+
# run "bundle", "install"
|
|
35
|
+
# run("test").exit_on_failure!
|
|
36
|
+
#
|
|
37
|
+
# if run? "which", "rubocop"
|
|
38
|
+
# run "rubocop", "--autocorrect"
|
|
39
|
+
# end
|
|
40
|
+
#
|
|
41
|
+
# result = capture "git", "rev-parse", "HEAD"
|
|
42
|
+
# puts result.stdout.strip
|
|
43
|
+
#
|
|
44
|
+
module Exec
|
|
45
|
+
# ─────────────────────────────────────────────────────────────
|
|
46
|
+
# Core Commands
|
|
47
|
+
# ─────────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
# Run a command, streaming output, waiting for completion.
|
|
50
|
+
#
|
|
51
|
+
# This is the workhorse method. Output streams to terminal by default.
|
|
52
|
+
# Returns a Result object for inspection; never raises on non-zero exit.
|
|
53
|
+
#
|
|
54
|
+
# @param cmd [Array<String>] Command and arguments
|
|
55
|
+
# @param env [Hash] Additional environment variables
|
|
56
|
+
# @param chdir [String, Path] Working directory
|
|
57
|
+
# @param raw [Boolean] Skip environment stack entirely (no wrappers)
|
|
58
|
+
# @param bundle [Symbol, Boolean] :auto (default), true, or false
|
|
59
|
+
# @param mise [Symbol, Boolean] :auto (default), true, or false
|
|
60
|
+
# @param dotenv [Boolean] false (default), true to enable dotenv wrapper
|
|
61
|
+
# @param clean_env [Boolean] Clean devex's bundler pollution (default: true)
|
|
62
|
+
# @param timeout [Float, nil] Seconds before killing
|
|
63
|
+
# @param out [Symbol] :inherit (default), :capture, :null
|
|
64
|
+
# @param err [Symbol] :inherit (default), :capture, :null, [:child, :out]
|
|
65
|
+
# @return [Result] Execution result
|
|
66
|
+
#
|
|
67
|
+
# @example Simple
|
|
68
|
+
# run "bundle", "install"
|
|
69
|
+
#
|
|
70
|
+
# @example Check result
|
|
71
|
+
# result = run "make", "test"
|
|
72
|
+
# exit 1 if result.failed?
|
|
73
|
+
#
|
|
74
|
+
# @example Chain with early exit
|
|
75
|
+
# run("lint").then { run("test") }.exit_on_failure!
|
|
76
|
+
#
|
|
77
|
+
# @note Use `cmd` alias in tools to avoid conflict with `def run` entry point
|
|
78
|
+
#
|
|
79
|
+
def run(*cmd, **) = execute_command(cmd, **)
|
|
80
|
+
|
|
81
|
+
# Alias for `run` - use this in tools to avoid conflict with `def run`
|
|
82
|
+
alias cmd run
|
|
83
|
+
|
|
84
|
+
# Test if a command succeeds (exit code 0).
|
|
85
|
+
#
|
|
86
|
+
# Output is discarded. Returns boolean directly.
|
|
87
|
+
#
|
|
88
|
+
# @param cmd [Array<String>] Command and arguments
|
|
89
|
+
# @return [Boolean] true if exit code is 0
|
|
90
|
+
#
|
|
91
|
+
# @example
|
|
92
|
+
# if run? "which", "rubocop"
|
|
93
|
+
# run "rubocop"
|
|
94
|
+
# end
|
|
95
|
+
#
|
|
96
|
+
def run?(*cmd, **) = execute_command(cmd, **, out: :null, err: :null).success?
|
|
97
|
+
|
|
98
|
+
# Alias for `run?` - use this in tools to avoid conflict with `def run`
|
|
99
|
+
alias cmd? run?
|
|
100
|
+
|
|
101
|
+
# Run a command and capture its output.
|
|
102
|
+
#
|
|
103
|
+
# Output is collected into Result.stdout and Result.stderr
|
|
104
|
+
# instead of streaming to terminal.
|
|
105
|
+
#
|
|
106
|
+
# @param cmd [Array<String>] Command and arguments
|
|
107
|
+
# @return [Result] Result with .stdout and .stderr populated
|
|
108
|
+
#
|
|
109
|
+
# @example
|
|
110
|
+
# result = capture "git", "rev-parse", "HEAD"
|
|
111
|
+
# commit = result.stdout.strip
|
|
112
|
+
#
|
|
113
|
+
def capture(*cmd, **) = execute_command(cmd, **, out: :capture, err: :capture)
|
|
114
|
+
|
|
115
|
+
# Start a command in the background without waiting.
|
|
116
|
+
#
|
|
117
|
+
# Returns immediately with a Controller for managing the process.
|
|
118
|
+
# By default, stdout/stderr go to /dev/null (configurable).
|
|
119
|
+
#
|
|
120
|
+
# @param cmd [Array<String>] Command and arguments
|
|
121
|
+
# @param name [String, nil] Optional identifier
|
|
122
|
+
# @param stdin [Symbol] :null (default), :pipe, :inherit
|
|
123
|
+
# @param stdout [Symbol] :null (default), :pipe, :inherit
|
|
124
|
+
# @param stderr [Symbol] :null (default), :pipe, :inherit
|
|
125
|
+
# @return [Controller] Handle for the background process
|
|
126
|
+
#
|
|
127
|
+
# @example
|
|
128
|
+
# server = spawn "rails", "server"
|
|
129
|
+
# # ... do other work ...
|
|
130
|
+
# server.kill(:TERM)
|
|
131
|
+
# server.result
|
|
132
|
+
#
|
|
133
|
+
def spawn(*cmd, name: nil, stdin: :null, stdout: :null, stderr: :null, **)
|
|
134
|
+
spawn_command(cmd, name: name, stdin: stdin, stdout: stdout, stderr: stderr, **)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Replace the current process with a command.
|
|
138
|
+
#
|
|
139
|
+
# This never returns. The current Ruby process is replaced entirely.
|
|
140
|
+
# The bang (!) indicates this is irreversible.
|
|
141
|
+
#
|
|
142
|
+
# @param cmd [Array<String>] Command and arguments
|
|
143
|
+
# @return [void] Never returns
|
|
144
|
+
#
|
|
145
|
+
# @example
|
|
146
|
+
# exec! "vim", filename
|
|
147
|
+
# # This line never executes
|
|
148
|
+
#
|
|
149
|
+
def exec!(*cmd, **)
|
|
150
|
+
prepared = prepare_command(cmd, **)
|
|
151
|
+
Kernel.exec(prepared[:env], *prepared[:command], **prepared[:spawn_opts])
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# ─────────────────────────────────────────────────────────────
|
|
155
|
+
# Shell Commands
|
|
156
|
+
# ─────────────────────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
# Run a command through the shell.
|
|
159
|
+
#
|
|
160
|
+
# Use this when you need shell features: pipes, globs, variables.
|
|
161
|
+
# The string is passed to /bin/sh -c "...".
|
|
162
|
+
#
|
|
163
|
+
# @param command_string [String] Shell command
|
|
164
|
+
# @return [Result] Execution result
|
|
165
|
+
#
|
|
166
|
+
# @example
|
|
167
|
+
# shell "grep TODO **/*.rb | wc -l"
|
|
168
|
+
# shell "echo $HOME"
|
|
169
|
+
#
|
|
170
|
+
# @note Security: Never interpolate untrusted input
|
|
171
|
+
#
|
|
172
|
+
def shell(command_string, **) = execute_command(["/bin/sh", "-c", command_string], **, shell: true)
|
|
173
|
+
|
|
174
|
+
# Test if a shell command succeeds.
|
|
175
|
+
#
|
|
176
|
+
# @param command_string [String] Shell command
|
|
177
|
+
# @return [Boolean] true if exit code is 0
|
|
178
|
+
#
|
|
179
|
+
# @example
|
|
180
|
+
# if shell? "command -v docker"
|
|
181
|
+
# shell "docker compose up -d"
|
|
182
|
+
# end
|
|
183
|
+
#
|
|
184
|
+
def shell?(command_string, **) = shell(command_string, **, out: :null, err: :null).success?
|
|
185
|
+
|
|
186
|
+
# ─────────────────────────────────────────────────────────────
|
|
187
|
+
# Specialized Commands
|
|
188
|
+
# ─────────────────────────────────────────────────────────────
|
|
189
|
+
|
|
190
|
+
# Run Ruby with clean environment.
|
|
191
|
+
#
|
|
192
|
+
# Uses the project's Ruby version (via mise if configured).
|
|
193
|
+
# Cleans RUBYOPT and bundler pollution.
|
|
194
|
+
#
|
|
195
|
+
# @param args [Array<String>] Arguments to ruby
|
|
196
|
+
# @return [Result] Execution result
|
|
197
|
+
#
|
|
198
|
+
# @example
|
|
199
|
+
# ruby "-e", "puts RUBY_VERSION"
|
|
200
|
+
# ruby "script.rb", "--verbose"
|
|
201
|
+
#
|
|
202
|
+
def ruby(*args, **) = execute_command(["ruby", *args], **, clean_env: true)
|
|
203
|
+
|
|
204
|
+
# Run another tool programmatically.
|
|
205
|
+
#
|
|
206
|
+
# Propagates call tree so child tool knows it was invoked from parent.
|
|
207
|
+
# Inherits verbosity and format settings.
|
|
208
|
+
#
|
|
209
|
+
# @param tool_name [String] Name of the tool to run
|
|
210
|
+
# @param args [Array<String>] Arguments for the tool
|
|
211
|
+
# @param capture [Boolean] Capture output instead of streaming
|
|
212
|
+
# @return [Result] Execution result
|
|
213
|
+
#
|
|
214
|
+
# @example
|
|
215
|
+
# tool "lint", "--fix"
|
|
216
|
+
# tool "version", capture: true
|
|
217
|
+
#
|
|
218
|
+
def tool(tool_name, *args, capture: false, **opts)
|
|
219
|
+
# Get executable name and env prefix from CLI config if available
|
|
220
|
+
exe_name = respond_to?(:cli) && cli ? cli.executable_name : "dx"
|
|
221
|
+
env_prefix = respond_to?(:cli) && cli&.config ? cli.config.env_prefix : "DX"
|
|
222
|
+
|
|
223
|
+
# Propagate call tree
|
|
224
|
+
call_tree_var = "#{env_prefix}_CALL_TREE"
|
|
225
|
+
current_var = "#{env_prefix}_CURRENT_TOOL"
|
|
226
|
+
invoked_var = "#{env_prefix}_INVOKED_FROM_TOOL"
|
|
227
|
+
|
|
228
|
+
call_tree = ENV.fetch(call_tree_var, "")
|
|
229
|
+
current_tool = ENV.fetch(current_var, "")
|
|
230
|
+
new_tree = call_tree.empty? ? current_tool : "#{call_tree}:#{current_tool}"
|
|
231
|
+
|
|
232
|
+
env = opts[:env] || {}
|
|
233
|
+
env = env.merge(
|
|
234
|
+
call_tree_var => new_tree,
|
|
235
|
+
invoked_var => "1"
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
cmd = [exe_name, tool_name, *args]
|
|
239
|
+
if capture
|
|
240
|
+
execute_command(cmd, **opts, env: env, out: :capture, err: :capture)
|
|
241
|
+
else
|
|
242
|
+
execute_command(cmd, **opts, env: env)
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Test if a tool succeeds.
|
|
247
|
+
#
|
|
248
|
+
# @param tool_name [String] Name of the tool to run
|
|
249
|
+
# @param args [Array<String>] Arguments for the tool
|
|
250
|
+
# @return [Boolean] true if exit code is 0
|
|
251
|
+
#
|
|
252
|
+
def tool?(tool_name, *, **) = tool(tool_name, *, **, capture: true).success?
|
|
253
|
+
|
|
254
|
+
private
|
|
255
|
+
|
|
256
|
+
# ─────────────────────────────────────────────────────────────
|
|
257
|
+
# Command Execution Engine
|
|
258
|
+
# ─────────────────────────────────────────────────────────────
|
|
259
|
+
|
|
260
|
+
def execute_command(cmd, **opts)
|
|
261
|
+
prepared = prepare_command(cmd, **opts)
|
|
262
|
+
start_time = Time.now
|
|
263
|
+
|
|
264
|
+
begin
|
|
265
|
+
stdout_data, stderr_data, status = run_with_streams(prepared)
|
|
266
|
+
duration = Time.now - start_time
|
|
267
|
+
|
|
268
|
+
Result.from_status(
|
|
269
|
+
status,
|
|
270
|
+
command: prepared[:original_command],
|
|
271
|
+
duration: duration,
|
|
272
|
+
stdout: stdout_data,
|
|
273
|
+
stderr: stderr_data,
|
|
274
|
+
options: opts
|
|
275
|
+
)
|
|
276
|
+
rescue Errno::ENOENT, Errno::EACCES => e
|
|
277
|
+
Result.from_exception(
|
|
278
|
+
e,
|
|
279
|
+
command: prepared[:original_command],
|
|
280
|
+
duration: Time.now - start_time,
|
|
281
|
+
options: opts
|
|
282
|
+
)
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def spawn_command(cmd, name:, stdin:, stdout:, stderr:, **opts)
|
|
287
|
+
prepared = prepare_command(cmd, **opts)
|
|
288
|
+
|
|
289
|
+
spawn_opts = prepared[:spawn_opts].dup
|
|
290
|
+
stdin_pipe = nil
|
|
291
|
+
stdout_pipe = nil
|
|
292
|
+
stderr_pipe = nil
|
|
293
|
+
|
|
294
|
+
# Configure stdin
|
|
295
|
+
case stdin
|
|
296
|
+
when :null then spawn_opts[:in] = "/dev/null"
|
|
297
|
+
when :inherit then spawn_opts[:in] = $stdin
|
|
298
|
+
when :pipe
|
|
299
|
+
stdin_read, stdin_write = IO.pipe
|
|
300
|
+
spawn_opts[:in] = stdin_read
|
|
301
|
+
stdin_pipe = stdin_write
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# Configure stdout
|
|
305
|
+
case stdout
|
|
306
|
+
when :null then spawn_opts[:out] = "/dev/null"
|
|
307
|
+
when :inherit then spawn_opts[:out] = $stdout
|
|
308
|
+
when :pipe
|
|
309
|
+
stdout_read, stdout_write = IO.pipe
|
|
310
|
+
spawn_opts[:out] = stdout_write
|
|
311
|
+
stdout_pipe = stdout_read
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
# Configure stderr
|
|
315
|
+
case stderr
|
|
316
|
+
when :null then spawn_opts[:err] = "/dev/null"
|
|
317
|
+
when :inherit then spawn_opts[:err] = $stderr
|
|
318
|
+
when :pipe
|
|
319
|
+
stderr_read, stderr_write = IO.pipe
|
|
320
|
+
spawn_opts[:err] = stderr_write
|
|
321
|
+
stderr_pipe = stderr_read
|
|
322
|
+
when Array
|
|
323
|
+
# [:child, :out] merges stderr into stdout
|
|
324
|
+
spawn_opts[:err] = stderr if stderr[0] == :child
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
pid = Process.spawn(prepared[:env], *prepared[:command], **spawn_opts)
|
|
328
|
+
|
|
329
|
+
# Close parent's copy of write ends
|
|
330
|
+
stdin_read&.close
|
|
331
|
+
stdout_write&.close
|
|
332
|
+
stderr_write&.close
|
|
333
|
+
|
|
334
|
+
Controller.new(
|
|
335
|
+
pid: pid,
|
|
336
|
+
command: prepared[:original_command],
|
|
337
|
+
name: name,
|
|
338
|
+
stdin: stdin_pipe,
|
|
339
|
+
stdout: stdout_pipe,
|
|
340
|
+
stderr: stderr_pipe,
|
|
341
|
+
options: opts
|
|
342
|
+
)
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def run_with_streams(prepared)
|
|
346
|
+
opts = prepared[:spawn_opts]
|
|
347
|
+
out_mode = prepared[:out_mode]
|
|
348
|
+
err_mode = prepared[:err_mode]
|
|
349
|
+
timeout = prepared[:timeout]
|
|
350
|
+
|
|
351
|
+
stdout_data = nil
|
|
352
|
+
stderr_data = nil
|
|
353
|
+
|
|
354
|
+
case [out_mode, err_mode]
|
|
355
|
+
when [:inherit, :inherit]
|
|
356
|
+
# Simple case: just run the command
|
|
357
|
+
pid = Process.spawn(prepared[:env], *prepared[:command], **opts)
|
|
358
|
+
status = wait_with_timeout(pid, timeout, prepared[:original_command])
|
|
359
|
+
|
|
360
|
+
when [:capture, :capture]
|
|
361
|
+
# Capture both streams
|
|
362
|
+
stdout_data, stderr_data, status = capture_streams(prepared, timeout)
|
|
363
|
+
|
|
364
|
+
when [:null, :null]
|
|
365
|
+
# Discard both
|
|
366
|
+
opts = opts.merge(out: "/dev/null", err: "/dev/null")
|
|
367
|
+
pid = Process.spawn(prepared[:env], *prepared[:command], **opts)
|
|
368
|
+
status = wait_with_timeout(pid, timeout, prepared[:original_command])
|
|
369
|
+
|
|
370
|
+
else
|
|
371
|
+
# Mixed modes - use Open3
|
|
372
|
+
stdout_data, stderr_data, status = capture_streams(prepared, timeout)
|
|
373
|
+
stdout_data = nil if out_mode == :null
|
|
374
|
+
stderr_data = nil if err_mode == :null
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
[stdout_data, stderr_data, status]
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
def capture_streams(prepared, timeout)
|
|
381
|
+
require "open3"
|
|
382
|
+
|
|
383
|
+
stdout_data = +""
|
|
384
|
+
stderr_data = +""
|
|
385
|
+
|
|
386
|
+
Open3.popen3(prepared[:env], *prepared[:command], **prepared[:spawn_opts]) do |stdin, stdout, stderr, wait_thr|
|
|
387
|
+
stdin.close
|
|
388
|
+
|
|
389
|
+
# Read both streams (could be improved with select for large outputs)
|
|
390
|
+
threads = []
|
|
391
|
+
threads << Thread.new { stdout_data << stdout.read }
|
|
392
|
+
threads << Thread.new { stderr_data << stderr.read }
|
|
393
|
+
|
|
394
|
+
if timeout
|
|
395
|
+
deadline = Time.now + timeout
|
|
396
|
+
remaining = timeout
|
|
397
|
+
loop do
|
|
398
|
+
if threads.all?(&:stop?)
|
|
399
|
+
threads.each(&:join)
|
|
400
|
+
break
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
remaining = deadline - Time.now
|
|
404
|
+
if remaining <= 0
|
|
405
|
+
begin
|
|
406
|
+
Process.kill(:TERM, wait_thr.pid)
|
|
407
|
+
rescue StandardError
|
|
408
|
+
nil
|
|
409
|
+
end
|
|
410
|
+
sleep 0.1
|
|
411
|
+
begin
|
|
412
|
+
Process.kill(:KILL, wait_thr.pid)
|
|
413
|
+
rescue StandardError
|
|
414
|
+
nil
|
|
415
|
+
end
|
|
416
|
+
threads.each do |t|
|
|
417
|
+
t.kill
|
|
418
|
+
rescue StandardError
|
|
419
|
+
nil
|
|
420
|
+
end
|
|
421
|
+
return [stdout_data, stderr_data, build_timeout_status(wait_thr)]
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
sleep 0.05
|
|
425
|
+
end
|
|
426
|
+
else
|
|
427
|
+
threads.each(&:join)
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
[stdout_data, stderr_data, wait_thr.value]
|
|
431
|
+
end
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
def wait_with_timeout(pid, timeout, _command)
|
|
435
|
+
return Process.wait2(pid)[1] unless timeout
|
|
436
|
+
|
|
437
|
+
deadline = Time.now + timeout
|
|
438
|
+
loop do
|
|
439
|
+
result, status = Process.wait2(pid, Process::WNOHANG)
|
|
440
|
+
return status if result
|
|
441
|
+
|
|
442
|
+
remaining = deadline - Time.now
|
|
443
|
+
if remaining <= 0
|
|
444
|
+
begin
|
|
445
|
+
Process.kill(:TERM, pid)
|
|
446
|
+
rescue StandardError
|
|
447
|
+
nil
|
|
448
|
+
end
|
|
449
|
+
sleep 0.1
|
|
450
|
+
begin
|
|
451
|
+
Process.kill(:KILL, pid)
|
|
452
|
+
rescue StandardError
|
|
453
|
+
nil
|
|
454
|
+
end
|
|
455
|
+
_, status = Process.wait2(pid)
|
|
456
|
+
return build_timeout_status_from_status(status)
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
sleep([0.05, remaining].min)
|
|
460
|
+
end
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
def build_timeout_status(wait_thr)
|
|
464
|
+
wait_thr.value
|
|
465
|
+
rescue StandardError
|
|
466
|
+
# Fake a timed-out status
|
|
467
|
+
TimeoutStatus.new
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
def build_timeout_status_from_status(_status) = TimeoutStatus.new
|
|
471
|
+
|
|
472
|
+
# Minimal status object for timeout cases
|
|
473
|
+
class TimeoutStatus
|
|
474
|
+
def pid; 0; end
|
|
475
|
+
def exited?; false; end
|
|
476
|
+
def exitstatus; nil; end
|
|
477
|
+
def signaled?; true; end
|
|
478
|
+
def termsig; 9; end # SIGKILL
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
# ─────────────────────────────────────────────────────────────
|
|
482
|
+
# Command Preparation
|
|
483
|
+
# ─────────────────────────────────────────────────────────────
|
|
484
|
+
|
|
485
|
+
def prepare_command(cmd, **opts)
|
|
486
|
+
cmd = cmd.flatten.map(&:to_s)
|
|
487
|
+
original_command = cmd.dup
|
|
488
|
+
|
|
489
|
+
env = (opts[:env] || {}).transform_keys(&:to_s).transform_values(&:to_s)
|
|
490
|
+
spawn_opts = {}
|
|
491
|
+
|
|
492
|
+
# Working directory
|
|
493
|
+
if opts[:chdir]
|
|
494
|
+
chdir = opts[:chdir]
|
|
495
|
+
chdir = chdir.to_s if chdir.respond_to?(:to_s) && !chdir.is_a?(String)
|
|
496
|
+
spawn_opts[:chdir] = chdir
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
# Environment stack (unless raw mode)
|
|
500
|
+
env, cmd = apply_environment_stack(env, cmd, opts) unless opts[:raw]
|
|
501
|
+
|
|
502
|
+
{
|
|
503
|
+
env: env,
|
|
504
|
+
command: cmd,
|
|
505
|
+
original_command: original_command,
|
|
506
|
+
spawn_opts: spawn_opts,
|
|
507
|
+
out_mode: opts.fetch(:out, :inherit),
|
|
508
|
+
err_mode: opts.fetch(:err, :inherit),
|
|
509
|
+
timeout: opts[:timeout]
|
|
510
|
+
}
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
# Apply the environment wrapper chain in order:
|
|
514
|
+
# [dotenv] [mise exec --] [bundle exec] command
|
|
515
|
+
#
|
|
516
|
+
# Each wrapper is applied from inside-out, so we process:
|
|
517
|
+
# 1. bundle exec (innermost, around the command)
|
|
518
|
+
# 2. mise exec -- (wraps bundle exec)
|
|
519
|
+
# 3. dotenv (outermost wrapper)
|
|
520
|
+
#
|
|
521
|
+
def apply_environment_stack(env, cmd, opts)
|
|
522
|
+
# Clean devex's bundler pollution (default: true unless clean_env: false)
|
|
523
|
+
env = clean_bundler_env(env) if opts.fetch(:clean_env, true) && defined?(Bundler)
|
|
524
|
+
|
|
525
|
+
# Bundle exec wrapping (if appropriate)
|
|
526
|
+
cmd = maybe_bundle_exec(cmd, opts) unless opts[:shell] || opts[:bundle] == false
|
|
527
|
+
|
|
528
|
+
# Mise exec wrapping (if detected, unless disabled)
|
|
529
|
+
cmd = maybe_mise_exec(cmd, opts) unless opts[:shell]
|
|
530
|
+
|
|
531
|
+
# Dotenv wrapping (explicit opt-in only)
|
|
532
|
+
cmd = with_dotenv(cmd, opts) if opts[:dotenv] == true
|
|
533
|
+
|
|
534
|
+
[env, cmd]
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
def clean_bundler_env(env)
|
|
538
|
+
# Keys that bundler sets that we want to clear
|
|
539
|
+
bundler_keys = %w[
|
|
540
|
+
BUNDLE_GEMFILE
|
|
541
|
+
BUNDLE_BIN_PATH
|
|
542
|
+
BUNDLE_PATH
|
|
543
|
+
BUNDLER_VERSION
|
|
544
|
+
BUNDLER_SETUP
|
|
545
|
+
RUBYOPT
|
|
546
|
+
RUBYLIB
|
|
547
|
+
GEM_HOME
|
|
548
|
+
GEM_PATH
|
|
549
|
+
]
|
|
550
|
+
|
|
551
|
+
# Start with current env, remove bundler pollution
|
|
552
|
+
clean = ENV.to_h.dup
|
|
553
|
+
bundler_keys.each { |k| clean.delete(k) }
|
|
554
|
+
|
|
555
|
+
# Apply user's env additions
|
|
556
|
+
clean.merge(env)
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
def maybe_bundle_exec(cmd, opts)
|
|
560
|
+
return cmd if opts[:bundle] == false
|
|
561
|
+
return cmd if cmd.first == "bundle"
|
|
562
|
+
return cmd unless gemfile_present?
|
|
563
|
+
|
|
564
|
+
# Check if this looks like a gem command
|
|
565
|
+
if opts[:bundle] == true || looks_like_gem_command?(cmd.first)
|
|
566
|
+
["bundle", "exec", *cmd]
|
|
567
|
+
else
|
|
568
|
+
cmd
|
|
569
|
+
end
|
|
570
|
+
end
|
|
571
|
+
|
|
572
|
+
def gemfile_present?
|
|
573
|
+
# Cache this for the process lifetime
|
|
574
|
+
|
|
575
|
+
# Check in working directory or project root
|
|
576
|
+
@gemfile_present ||= File.exist?("Gemfile") ||
|
|
577
|
+
(defined?(Devex::Dirs) && Devex::Dirs.in_project? &&
|
|
578
|
+
File.exist?(File.join(Devex::Dirs.project_dir.to_s, "Gemfile")))
|
|
579
|
+
end
|
|
580
|
+
|
|
581
|
+
# Heuristic: is this likely a Ruby gem executable?
|
|
582
|
+
def looks_like_gem_command?(cmd)
|
|
583
|
+
# Common gem commands
|
|
584
|
+
gem_commands = %w[
|
|
585
|
+
rake rspec rubocop standardrb steep rbs
|
|
586
|
+
rails sidekiq puma unicorn thin
|
|
587
|
+
bundler bundle
|
|
588
|
+
erb rdoc ri
|
|
589
|
+
yard
|
|
590
|
+
]
|
|
591
|
+
|
|
592
|
+
return true if gem_commands.include?(cmd)
|
|
593
|
+
|
|
594
|
+
# Check if it's in bundle's bin stubs
|
|
595
|
+
# This is a simplification; could check actual Gemfile.lock
|
|
596
|
+
false
|
|
597
|
+
end
|
|
598
|
+
|
|
599
|
+
# ─────────────────────────────────────────────────────────────
|
|
600
|
+
# Mise Wrapper
|
|
601
|
+
# ─────────────────────────────────────────────────────────────
|
|
602
|
+
|
|
603
|
+
# Wrap command with `mise exec --` if mise is detected and enabled.
|
|
604
|
+
#
|
|
605
|
+
# @param cmd [Array<String>] Command to potentially wrap
|
|
606
|
+
# @param opts [Hash] Options (mise: :auto, true, or false)
|
|
607
|
+
# @return [Array<String>] Command, possibly wrapped
|
|
608
|
+
#
|
|
609
|
+
def maybe_mise_exec(cmd, opts)
|
|
610
|
+
return cmd if opts[:mise] == false
|
|
611
|
+
return cmd if cmd.first == "mise"
|
|
612
|
+
|
|
613
|
+
# :auto (default) - detect; true - always use mise
|
|
614
|
+
use_mise = case opts[:mise]
|
|
615
|
+
when true then true
|
|
616
|
+
when false then false
|
|
617
|
+
else mise_detected? # :auto or nil
|
|
618
|
+
end
|
|
619
|
+
|
|
620
|
+
return cmd unless use_mise
|
|
621
|
+
|
|
622
|
+
# Wrap with mise exec --
|
|
623
|
+
["mise", "exec", "--", *cmd]
|
|
624
|
+
end
|
|
625
|
+
|
|
626
|
+
# Check if mise is configured in the project.
|
|
627
|
+
# Caches the result for the process lifetime.
|
|
628
|
+
#
|
|
629
|
+
def mise_detected?
|
|
630
|
+
# Use :unset sentinel since nil is a valid cache value
|
|
631
|
+
@mise_detected = detect_mise_files if @mise_detected.nil?
|
|
632
|
+
@mise_detected
|
|
633
|
+
end
|
|
634
|
+
|
|
635
|
+
def detect_mise_files
|
|
636
|
+
# Check in current directory first
|
|
637
|
+
return true if File.exist?(".mise.toml") || File.exist?(".tool-versions")
|
|
638
|
+
|
|
639
|
+
# Check in project root if we're in a project
|
|
640
|
+
if defined?(Devex::Dirs) && Devex::Dirs.in_project?
|
|
641
|
+
project_dir = Devex::Dirs.project_dir.to_s
|
|
642
|
+
File.exist?(File.join(project_dir, ".mise.toml")) ||
|
|
643
|
+
File.exist?(File.join(project_dir, ".tool-versions"))
|
|
644
|
+
else
|
|
645
|
+
false
|
|
646
|
+
end
|
|
647
|
+
end
|
|
648
|
+
|
|
649
|
+
# ─────────────────────────────────────────────────────────────
|
|
650
|
+
# Dotenv Wrapper
|
|
651
|
+
# ─────────────────────────────────────────────────────────────
|
|
652
|
+
|
|
653
|
+
# Wrap command with `dotenv` to load .env files.
|
|
654
|
+
# Only used when explicitly requested (dotenv: true).
|
|
655
|
+
#
|
|
656
|
+
# @param cmd [Array<String>] Command to wrap
|
|
657
|
+
# @param opts [Hash] Options (unused currently, for future .env path override)
|
|
658
|
+
# @return [Array<String>] Command wrapped with dotenv
|
|
659
|
+
#
|
|
660
|
+
def with_dotenv(cmd, _opts) = ["dotenv", *cmd]
|
|
661
|
+
end
|
|
662
|
+
end
|