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

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.



60
61
62
63
64
65
66
# File 'lib/everywhere/shellout.rb', line 60

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

.pty?Boolean

PTY is POSIX-only. Callers fall back to spawn + inherited fds.

Returns:

  • (Boolean)


149
150
151
152
153
154
# File 'lib/everywhere/shellout.rb', line 149

def pty?
  require "pty"
  true
rescue LoadError
  false
end

.run!(env, *cmd, chdir: Dir.pwd) ⇒ Object



41
42
43
44
# File 'lib/everywhere/shellout.rb', line 41

def run!(env, *cmd, chdir: Dir.pwd)
  success = system(child_env(env), *cmd, chdir: chdir)
  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.

Returns:

  • (Boolean)


50
51
52
53
54
# File 'lib/everywhere/shellout.rb', line 50

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.



79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
# File 'lib/everywhere/shellout.rb', line 79

def run_logged!(env, cmd, log:, filter: nil)
  require "open3"
  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.



123
124
125
# File 'lib/everywhere/shellout.rb', line 123

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.



138
139
140
141
142
143
144
145
146
# File 'lib/everywhere/shellout.rb', line 138

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

.unbundled_baseObject

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