Module: Everywhere::Shellout
- Defined in:
- lib/everywhere/shellout.rb
Overview
All external commands (tebako, cargo, the app's own bin/dev) run outside this gem's bundle — they have their own gem environments.
Class Method Summary collapse
-
.capture(*cmd) ⇒ Object
Run a command and capture its combined stdout+stderr.
-
.child_env(overrides = {}) ⇒ Object
The environment a child should get: this gem's bundle stripped back out, plus the caller's overrides.
- .ensure_tool!(name, hint) ⇒ Object
-
.pty? ⇒ Boolean
PTY is POSIX-only.
- .run!(env, *cmd, chdir: Dir.pwd) ⇒ Object
-
.run?(*cmd, chdir: Dir.pwd, quiet: false) ⇒ Boolean
quiet also detaches stdin.
-
.run_logged!(env, cmd, log:, filter: nil) ⇒ Object
Run a command, capturing its full combined output to a log file while showing a (optionally filtered) view on the console.
-
.spawn(env, cmd, chdir:) ⇒ Object
pgroup: the child leads its own process group so teardown can TERM the whole tree (foreman + watchers) without signalling the CLI itself.
-
.spawn_pty(env, cmd, chdir:) ⇒ Object
Same, but on a pseudo-terminal, returning [master_io, pid].
-
.tool?(name) ⇒ Boolean
Whether
nameresolves as an executable on the PATH children get (the unbundled one — a Bundler binstub visible to the CLI doesn't count). -
.unbundled_base ⇒ Object
Bundler.unbundled_env derives from
original_env, a snapshot frozen at load time, so this is genuinely constant for the life of the process.
Class Method Details
.capture(*cmd) ⇒ Object
Run a command and capture its combined stdout+stderr. Returns [output_string, Process::Status]. For probing tool output (codesign, stapler, notarytool) rather than driving a build. A leading Hash is env overrides, matching Open3's own convention.
78 79 80 81 82 83 84 |
# File 'lib/everywhere/shellout.rb', line 78 def capture(*cmd) require "open3" overrides = cmd.first.is_a?(Hash) ? cmd.shift : {} Open3.capture2e(child_env(overrides), *cmd) rescue Errno::ENOENT ["", nil] end |
.child_env(overrides = {}) ⇒ Object
The environment a child should get: this gem's bundle stripped back out, plus the caller's overrides.
This is handed to spawn as its env argument rather than swapped into ENV,
which is what Bundler.with_unbundled_env does (ENV.replace, twice, around
the block). That is process-global mutable state, and every dev now runs
the iOS and Android builds concurrently: two overlapping swaps race so that
one child inherits the CLI's bundle after all — the exact thing unbundling
exists to prevent — and ENV is left wrong for the rest of the session.
A nil value tells spawn to REMOVE that key, which is most of what unbundling is. The resulting child environment is identical to what with_unbundled_env produced, since Bundler.unbundled_env is derived from the snapshot taken before Bundler was activated either way.
27 28 29 30 31 32 33 |
# File 'lib/everywhere/shellout.rb', line 27 def child_env(overrides = {}) base = unbundled_base delta = {} ENV.each_key { |key| delta[key] = nil unless base.key?(key) } base.each { |key, value| delta[key] = value unless ENV[key] == value } delta.merge(overrides.transform_keys(&:to_s)) end |
.ensure_tool!(name, hint) ⇒ Object
60 61 62 |
# File 'lib/everywhere/shellout.rb', line 60 def ensure_tool!(name, hint) tool?(name) or UI.die!("#{name} not found — #{hint}") end |
.pty? ⇒ Boolean
PTY is POSIX-only. Callers fall back to spawn + inherited fds.
168 169 170 171 172 173 |
# File 'lib/everywhere/shellout.rb', line 168 def pty? require "pty" true rescue LoadError false end |
.run!(env, *cmd, chdir: Dir.pwd) ⇒ Object
41 42 43 44 45 46 |
# File 'lib/everywhere/shellout.rb', line 41 def run!(env, *cmd, chdir: Dir.pwd) success = system(child_env(env), *cmd, chdir: chdir) # nil means the command never ran (missing binary), false that it failed. UI.die!("#{cmd.first} not found — is it installed and on your PATH?") if success.nil? UI.die!("command failed: #{cmd.join(" ")}") unless success end |
.run?(*cmd, chdir: Dir.pwd, quiet: false) ⇒ Boolean
quiet also detaches stdin. A quiet command is by definition one that has
nothing to say to the terminal, and every dev reads single keystrokes
from that same terminal — a child left holding stdin silently eats the
keypresses the menu is waiting on.
68 69 70 71 72 |
# File 'lib/everywhere/shellout.rb', line 68 def run?(*cmd, chdir: Dir.pwd, quiet: false) opts = { chdir: chdir } opts.update(in: File::NULL, out: File::NULL, err: File::NULL) if quiet system(child_env, *cmd, **opts) end |
.run_logged!(env, cmd, log:, filter: nil) ⇒ Object
Run a command, capturing its full combined output to a log file while showing a (optionally filtered) view on the console.
Pass filter: a LogFilter (or any #call(line) -> String | :drop | nil) to prettify what a human sees; the on-disk log always gets the raw stream, so nothing is lost. Unrecognized lines (filter returns nil) are shown dimmed and indented so they read as background detail under the current step.
The signature is deliberately unchanged: the builder tests stub this with fixed-signature lambdas, and cancellation is wired up out of band through ChildProcesses instead.
97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 |
# File 'lib/everywhere/shellout.rb', line 97 def run_logged!(env, cmd, log:, filter: nil) require "open3" ensure_tool!(cmd.first, "is it installed and on your PATH?") success = false File.open(log, "w") do |logf| # Inside an `every dev` worker the build leads its own process group, # for two reasons: a Ctrl-C at the terminal no longer reaches # xcodebuild/gradlew behind the CLI's back (the CLI decides when a build # dies), and teardown can TERM the whole compile tree with one signal. # Outside dev this stays off, so Ctrl-C during `every build` still stops # the compiler as it always has. opts = ChildProcesses.isolated? ? { pgroup: true } : {} Open3.popen2e(child_env(env), *cmd, **opts) do |stdin, out, wait| stdin.close ChildProcesses.track(wait.pid) begin out.each_line do |raw| logf.write(raw) emit(raw, filter) end success = wait.value.success? ensure ChildProcesses.untrack(wait.pid) end end end UI.die!("command failed: #{cmd.join(" ")} (full log: #{log})") unless success end |
.spawn(env, cmd, chdir:) ⇒ Object
pgroup: the child leads its own process group so teardown can TERM the whole tree (foreman + watchers) without signalling the CLI itself.
142 143 144 |
# File 'lib/everywhere/shellout.rb', line 142 def spawn(env, cmd, chdir:) Process.spawn(child_env(env), cmd, chdir: chdir, pgroup: true) end |
.spawn_pty(env, cmd, chdir:) ⇒ Object
Same, but on a pseudo-terminal, returning [master_io, pid].
every dev relays the dev server's and desktop shell's output itself so
it can tag each line with its source and keep the dock pinned at the
bottom. A plain pipe would work, but the children would see a non-TTY and
foreman, cargo and friends would drop their colors and progress output; on
a pty they behave exactly as they do when you run them by hand.
PTY.spawn calls setsid, so the child leads its own session: a terminal SIGINT can't reach it, and Process.kill("TERM", -pid) still takes down the whole tree.
157 158 159 160 161 162 163 164 165 |
# File 'lib/everywhere/shellout.rb', line 157 def spawn_pty(env, cmd, chdir:) require "pty" # PTY.spawn hands back the read and write ends of the pty MASTER. We never # forward stdin, so the write end is closed immediately — the child keeps # running because the read end still holds the master open. reader, writer, pid = PTY.spawn(child_env(env), cmd, chdir: chdir) writer.close [reader, pid] end |
.tool?(name) ⇒ Boolean
Whether name resolves as an executable on the PATH children get (the
unbundled one — a Bundler binstub visible to the CLI doesn't count).
50 51 52 53 54 55 56 57 58 |
# File 'lib/everywhere/shellout.rb', line 50 def tool?(name) return File.executable?(name) && !File.directory?(name) if name.include?("/") path = (unbundled_base["PATH"] || ENV["PATH"]).to_s exts = Gem.win_platform? ? [".exe", ".bat", ".cmd", ""] : [""] path.split(File::PATH_SEPARATOR).any? do |dir| exts.any? { |ext| File.executable?(File.join(dir, name + ext)) && !File.directory?(File.join(dir, name + ext)) } end end |
.unbundled_base ⇒ Object
Bundler.unbundled_env derives from original_env, a snapshot frozen at
load time, so this is genuinely constant for the life of the process.
37 38 39 |
# File 'lib/everywhere/shellout.rb', line 37 def unbundled_base @unbundled_base ||= defined?(Bundler) ? Bundler.unbundled_env : ENV.to_h end |