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/dirs.rb
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "support/path"
|
|
4
|
+
|
|
5
|
+
module Devex
|
|
6
|
+
# Directory context for a CLI application.
|
|
7
|
+
#
|
|
8
|
+
# Holds the directory state for a CLI invocation:
|
|
9
|
+
# - invoked_dir: Where user actually ran the command
|
|
10
|
+
# - dest_dir: Effective start directory (invoked_dir or overridden)
|
|
11
|
+
# - project_dir: Discovered project root
|
|
12
|
+
# - src_dir: Framework gem installation directory
|
|
13
|
+
#
|
|
14
|
+
# Instance-based for Core usage, with module methods for backward compatibility.
|
|
15
|
+
#
|
|
16
|
+
# @example Core usage (recommended)
|
|
17
|
+
# config = Devex::Core::Configuration.new(project_markers: %w[.mycli.yml .git])
|
|
18
|
+
# dirs = Devex::Dirs.new(config: config)
|
|
19
|
+
# dirs.project_dir # => discovered project root
|
|
20
|
+
#
|
|
21
|
+
# @example Backward-compatible module usage
|
|
22
|
+
# Devex::Dirs.project_dir # uses default dx configuration
|
|
23
|
+
#
|
|
24
|
+
class Dirs
|
|
25
|
+
Path = Support::Path
|
|
26
|
+
|
|
27
|
+
# Default project markers
|
|
28
|
+
# Includes dx-specific markers since this is the devex gem.
|
|
29
|
+
# Core users can override via Configuration.project_markers.
|
|
30
|
+
DEFAULT_PROJECT_MARKERS = %w[.dx.yml .dx .git Gemfile Rakefile .devex.yml].freeze
|
|
31
|
+
|
|
32
|
+
# @param config [Core::Configuration, nil] configuration (uses defaults if nil)
|
|
33
|
+
# @param invoked_from [String] directory where command was invoked (default: pwd)
|
|
34
|
+
# @param dest_dir [String, nil] override for dest_dir (e.g., from --flag-from-dir)
|
|
35
|
+
# @param src_dir [String, nil] framework source directory (defaults to devex gem)
|
|
36
|
+
def initialize(config: nil, invoked_from: Dir.pwd, dest_dir: nil, src_dir: nil)
|
|
37
|
+
@config = config
|
|
38
|
+
@invoked_dir = Path[invoked_from].exp
|
|
39
|
+
@dest_dir = dest_dir ? Path[dest_dir].exp : @invoked_dir
|
|
40
|
+
@src_dir = src_dir ? Path[src_dir] : Path[File.expand_path("../..", __dir__)]
|
|
41
|
+
@project_dir = nil # lazy
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Where the user actually ran the command (captured at startup)
|
|
45
|
+
# @return [Path]
|
|
46
|
+
attr_reader :invoked_dir
|
|
47
|
+
|
|
48
|
+
# Effective start directory (invoked_dir unless overridden)
|
|
49
|
+
# @return [Path]
|
|
50
|
+
attr_reader :dest_dir
|
|
51
|
+
|
|
52
|
+
# Framework source directory (for templates, builtins, etc.)
|
|
53
|
+
# @return [Path]
|
|
54
|
+
attr_reader :src_dir
|
|
55
|
+
|
|
56
|
+
# Project markers to search for
|
|
57
|
+
# @return [Array<String>]
|
|
58
|
+
def project_markers
|
|
59
|
+
@config&.project_markers || DEFAULT_PROJECT_MARKERS
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Discovered project root directory
|
|
63
|
+
# Searched upward from dest_dir using project_markers
|
|
64
|
+
# @param raise_on_missing [Boolean] raise error if not found (default: true)
|
|
65
|
+
# @return [Path, nil]
|
|
66
|
+
def project_dir(raise_on_missing: true)
|
|
67
|
+
@project_dir ||= discover_project_root(raise_on_missing: raise_on_missing)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Check if we're inside a project (without raising)
|
|
71
|
+
# @return [Boolean]
|
|
72
|
+
def in_project?
|
|
73
|
+
!!@project_dir || !!discover_project_root(raise_on_missing: false)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Reset cached state (for testing)
|
|
77
|
+
def reset!
|
|
78
|
+
@project_dir = nil
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def discover_project_root(raise_on_missing: true)
|
|
84
|
+
current = @dest_dir
|
|
85
|
+
|
|
86
|
+
loop do
|
|
87
|
+
project_markers.each do |marker|
|
|
88
|
+
marker_path = current / marker
|
|
89
|
+
return current if marker_path.exist?
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
parent = current.parent
|
|
93
|
+
break if parent.to_s == current.to_s # Reached filesystem root
|
|
94
|
+
|
|
95
|
+
current = parent
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
return nil unless raise_on_missing
|
|
99
|
+
|
|
100
|
+
fail_no_project!
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def fail_no_project!
|
|
104
|
+
exe_name = @config&.executable_name || "cli"
|
|
105
|
+
from_flag = @config ? @config.flag("from_dir") : "--from-dir"
|
|
106
|
+
|
|
107
|
+
message = <<~ERR
|
|
108
|
+
ERROR: Not inside a project
|
|
109
|
+
|
|
110
|
+
Searched from: #{@dest_dir}
|
|
111
|
+
Looked for: #{project_markers.join(', ')}
|
|
112
|
+
|
|
113
|
+
To create a new project:
|
|
114
|
+
#{exe_name} init
|
|
115
|
+
|
|
116
|
+
To operate on a different directory:
|
|
117
|
+
#{exe_name} #{from_flag}=/path/to/project command
|
|
118
|
+
|
|
119
|
+
Exit code: 78 (EX_CONFIG)
|
|
120
|
+
ERR
|
|
121
|
+
|
|
122
|
+
raise message
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# ─────────────────────────────────────────────────────────────
|
|
126
|
+
# Module-Level API (Backward Compatibility)
|
|
127
|
+
# ─────────────────────────────────────────────────────────────
|
|
128
|
+
#
|
|
129
|
+
# These class methods provide backward compatibility with the
|
|
130
|
+
# original module-based API. They delegate to a thread-local
|
|
131
|
+
# default instance.
|
|
132
|
+
#
|
|
133
|
+
class << self
|
|
134
|
+
# Get or create the default Dirs instance for this thread
|
|
135
|
+
# @return [Dirs]
|
|
136
|
+
def default
|
|
137
|
+
Thread.current[:devex_dirs] ||= new_default
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Set the default Dirs instance (used by devex.rb to configure for dx)
|
|
141
|
+
# @param dirs [Dirs]
|
|
142
|
+
def default=(dirs)
|
|
143
|
+
Thread.current[:devex_dirs] = dirs
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Reset the default instance (for testing)
|
|
147
|
+
def reset!
|
|
148
|
+
Thread.current[:devex_dirs] = nil
|
|
149
|
+
# Also clear any instance state if there was one
|
|
150
|
+
Thread.current[:devex_dirs_dest_override] = nil
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Backward-compatible: set dest_dir before project discovery
|
|
154
|
+
# Must be called early, before project_dir is accessed
|
|
155
|
+
def dest_dir=(path)
|
|
156
|
+
if Thread.current[:devex_dirs]&.instance_variable_get(:@project_dir)
|
|
157
|
+
raise "Cannot change dest_dir after project_dir is computed"
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
Thread.current[:devex_dirs_dest_override] = path
|
|
161
|
+
# Clear default so it gets recreated with new dest_dir
|
|
162
|
+
Thread.current[:devex_dirs] = nil
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Delegate instance methods to default
|
|
166
|
+
def invoked_dir = default.invoked_dir
|
|
167
|
+
def dest_dir = default.dest_dir
|
|
168
|
+
def project_dir = default.project_dir
|
|
169
|
+
def src_dir = default.src_dir
|
|
170
|
+
def dx_src_dir = default.src_dir # Backward-compatible alias
|
|
171
|
+
def in_project? = default.in_project?
|
|
172
|
+
|
|
173
|
+
private
|
|
174
|
+
|
|
175
|
+
def new_default
|
|
176
|
+
dest_override = Thread.current[:devex_dirs_dest_override]
|
|
177
|
+
new(dest_dir: dest_override)
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# ─────────────────────────────────────────────────────────────
|
|
182
|
+
# Delegation Support
|
|
183
|
+
# ─────────────────────────────────────────────────────────────
|
|
184
|
+
|
|
185
|
+
# Check if we should delegate to a bundled/local version of the CLI
|
|
186
|
+
# Call early in startup, before any real work.
|
|
187
|
+
#
|
|
188
|
+
# @param config [Core::Configuration] configuration with delegation settings
|
|
189
|
+
# @param argv [Array<String>] command line arguments to pass through
|
|
190
|
+
# @return [void] (exits process if delegating)
|
|
191
|
+
def self.maybe_delegate_to_local!(config: nil, argv: ARGV)
|
|
192
|
+
delegation_file = config&.delegation_file
|
|
193
|
+
return unless delegation_file
|
|
194
|
+
|
|
195
|
+
delegation_env = config.env_var(:delegated)
|
|
196
|
+
return if ENV[delegation_env]
|
|
197
|
+
|
|
198
|
+
dirs = config ? new(config: config) : default
|
|
199
|
+
return unless dirs.in_project?
|
|
200
|
+
|
|
201
|
+
use_local = dirs.project_dir / delegation_file
|
|
202
|
+
return unless use_local.exist?
|
|
203
|
+
|
|
204
|
+
# Delegate: set flag, change to project dir, exec bundled version
|
|
205
|
+
ENV[delegation_env] = "1"
|
|
206
|
+
Dir.chdir(dirs.project_dir.to_s)
|
|
207
|
+
exec "bundle", "exec", config.executable_name, *argv
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
data/lib/devex/dsl.rb
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Devex
|
|
4
|
+
# DSL context for defining tools in task files
|
|
5
|
+
class DSL
|
|
6
|
+
attr_reader :tool
|
|
7
|
+
|
|
8
|
+
def initialize(tool) = @tool = tool
|
|
9
|
+
|
|
10
|
+
def desc(text) = @tool.desc = text
|
|
11
|
+
|
|
12
|
+
def long_desc(text) = @tool.long_desc = text
|
|
13
|
+
|
|
14
|
+
def flag(name, *specs, desc: nil, default: nil) = @tool.flag(name, *specs, desc: desc, default: default)
|
|
15
|
+
|
|
16
|
+
def required_arg(name, desc: nil) = @tool.required_arg(name, desc: desc)
|
|
17
|
+
|
|
18
|
+
def optional_arg(name, desc: nil, default: nil) = @tool.optional_arg(name, desc: desc, default: default)
|
|
19
|
+
|
|
20
|
+
def remaining_args(name, desc: nil) = @tool.remaining_args(name, desc: desc)
|
|
21
|
+
|
|
22
|
+
def include(name) = @tool.include_mixin(name)
|
|
23
|
+
|
|
24
|
+
def tool(name, &block)
|
|
25
|
+
subtool = Tool.new(name, parent: @tool)
|
|
26
|
+
if block
|
|
27
|
+
# Capture the block source location for later re-evaluation
|
|
28
|
+
subtool.source_file = block.source_location[0]
|
|
29
|
+
subtool.source_proc = block
|
|
30
|
+
end
|
|
31
|
+
@tool.add_subtool(subtool)
|
|
32
|
+
subtool
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def to_run(&block) = @tool.run_block = block
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Evaluates task files and captures tool definitions
|
|
39
|
+
module TaskFileDSL
|
|
40
|
+
def self.evaluate(tool, code, filename)
|
|
41
|
+
# Store the source for later execution
|
|
42
|
+
tool.source_code = code
|
|
43
|
+
tool.source_file = filename
|
|
44
|
+
|
|
45
|
+
# Parse the DSL parts (desc, flags, etc.) but don't execute run yet
|
|
46
|
+
context = DSLContext.new(tool)
|
|
47
|
+
context.instance_eval(code, filename)
|
|
48
|
+
|
|
49
|
+
tool
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Context for parsing DSL declarations (not execution)
|
|
54
|
+
class DSLContext
|
|
55
|
+
def initialize(tool) = @tool = tool
|
|
56
|
+
|
|
57
|
+
def desc(text) = @tool.desc = text
|
|
58
|
+
|
|
59
|
+
def long_desc(text) = @tool.long_desc = text
|
|
60
|
+
|
|
61
|
+
def flag(name, *specs, desc: nil, default: nil) = @tool.flag(name, *specs, desc: desc, default: default)
|
|
62
|
+
|
|
63
|
+
def required_arg(name, desc: nil) = @tool.required_arg(name, desc: desc)
|
|
64
|
+
|
|
65
|
+
def optional_arg(name, desc: nil, default: nil) = @tool.optional_arg(name, desc: desc, default: default)
|
|
66
|
+
|
|
67
|
+
def remaining_args(name, desc: nil) = @tool.remaining_args(name, desc: desc)
|
|
68
|
+
|
|
69
|
+
def include(name) = @tool.include_mixin(name)
|
|
70
|
+
|
|
71
|
+
def tool(name, &block)
|
|
72
|
+
subtool = Tool.new(name, parent: @tool)
|
|
73
|
+
if block
|
|
74
|
+
# For nested tools, we need to capture the block's source
|
|
75
|
+
# Since we can't easily get block source, we'll use a different strategy:
|
|
76
|
+
# Evaluate the block in a new DSLContext to get the DSL parts,
|
|
77
|
+
# and mark that it has a run method defined
|
|
78
|
+
nested_context = DSLContext.new(subtool)
|
|
79
|
+
nested_context.instance_eval(&block)
|
|
80
|
+
# Store source_file so the whole file is eval'd at runtime,
|
|
81
|
+
# making helper methods from the parent file available
|
|
82
|
+
subtool.source_file = block.source_location[0]
|
|
83
|
+
subtool.source_proc = block
|
|
84
|
+
end
|
|
85
|
+
@tool.add_subtool(subtool)
|
|
86
|
+
subtool
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def to_run(&block) = @tool.run_block = block
|
|
90
|
+
|
|
91
|
+
# Capture def statements - they become the tool's methods
|
|
92
|
+
# We use method_missing to collect method names, but can't capture the bodies
|
|
93
|
+
# Instead, we mark that the tool has a run method and will re-eval at runtime
|
|
94
|
+
def method_missing(name, *args, &)
|
|
95
|
+
# Silently ignore - methods will be available at runtime via re-eval
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def respond_to_missing?(_name, _include_private = false) = true
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "result"
|
|
4
|
+
|
|
5
|
+
module Devex
|
|
6
|
+
module Exec
|
|
7
|
+
# Controller for managing background (spawned) processes.
|
|
8
|
+
#
|
|
9
|
+
# Returned by `spawn` to provide control over the child process.
|
|
10
|
+
# Use this to monitor, signal, or wait for completion.
|
|
11
|
+
#
|
|
12
|
+
# @example Basic usage
|
|
13
|
+
# ctrl = spawn "rails", "server"
|
|
14
|
+
# sleep 5
|
|
15
|
+
# ctrl.kill(:TERM)
|
|
16
|
+
# result = ctrl.result
|
|
17
|
+
#
|
|
18
|
+
# @example With IO access
|
|
19
|
+
# ctrl = spawn "cat", stdin: :pipe, stdout: :pipe
|
|
20
|
+
# ctrl.stdin.puts "hello"
|
|
21
|
+
# ctrl.stdin.close
|
|
22
|
+
# output = ctrl.stdout.read
|
|
23
|
+
# ctrl.result
|
|
24
|
+
#
|
|
25
|
+
class Controller
|
|
26
|
+
# @return [Integer] Process ID
|
|
27
|
+
attr_reader :pid
|
|
28
|
+
|
|
29
|
+
# @return [String, nil] Optional name/identifier
|
|
30
|
+
attr_reader :name
|
|
31
|
+
|
|
32
|
+
# @return [Array<String>] The command being executed
|
|
33
|
+
attr_reader :command
|
|
34
|
+
|
|
35
|
+
# @return [Time] When the process was started
|
|
36
|
+
attr_reader :started_at
|
|
37
|
+
|
|
38
|
+
# @return [IO, nil] Stdin pipe (if configured)
|
|
39
|
+
attr_reader :stdin
|
|
40
|
+
|
|
41
|
+
# @return [IO, nil] Stdout pipe (if configured)
|
|
42
|
+
attr_reader :stdout
|
|
43
|
+
|
|
44
|
+
# @return [IO, nil] Stderr pipe (if configured)
|
|
45
|
+
attr_reader :stderr
|
|
46
|
+
|
|
47
|
+
# @return [Hash] Options passed when spawning
|
|
48
|
+
attr_reader :options
|
|
49
|
+
|
|
50
|
+
def initialize(
|
|
51
|
+
pid:,
|
|
52
|
+
command:,
|
|
53
|
+
name: nil,
|
|
54
|
+
stdin: nil,
|
|
55
|
+
stdout: nil,
|
|
56
|
+
stderr: nil,
|
|
57
|
+
options: {}
|
|
58
|
+
)
|
|
59
|
+
@pid = pid
|
|
60
|
+
@command = Array(command)
|
|
61
|
+
@name = name
|
|
62
|
+
@stdin = stdin
|
|
63
|
+
@stdout = stdout
|
|
64
|
+
@stderr = stderr
|
|
65
|
+
@options = options
|
|
66
|
+
@started_at = Time.now
|
|
67
|
+
@result = nil
|
|
68
|
+
@mutex = Mutex.new
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# ─────────────────────────────────────────────────────────────
|
|
72
|
+
# Status
|
|
73
|
+
# ─────────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
# @return [Boolean] true if process is still running
|
|
76
|
+
def executing?
|
|
77
|
+
return false if @result
|
|
78
|
+
|
|
79
|
+
# Non-blocking check
|
|
80
|
+
pid_result, _status = Process.wait2(pid, Process::WNOHANG)
|
|
81
|
+
pid_result.nil?
|
|
82
|
+
rescue Errno::ECHILD
|
|
83
|
+
false
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
alias running? executing?
|
|
87
|
+
|
|
88
|
+
# @return [Boolean] true if process has finished
|
|
89
|
+
def finished? = !executing?
|
|
90
|
+
|
|
91
|
+
# @return [Float] Seconds since process started
|
|
92
|
+
def elapsed = Time.now - @started_at
|
|
93
|
+
|
|
94
|
+
# ─────────────────────────────────────────────────────────────
|
|
95
|
+
# Signals
|
|
96
|
+
# ─────────────────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
# Send a signal to the process.
|
|
99
|
+
#
|
|
100
|
+
# @param signal [Symbol, String, Integer] Signal to send
|
|
101
|
+
# @return [Boolean] true if signal was sent successfully
|
|
102
|
+
#
|
|
103
|
+
# @example
|
|
104
|
+
# ctrl.kill(:TERM)
|
|
105
|
+
# ctrl.kill(:INT)
|
|
106
|
+
# ctrl.kill(:KILL)
|
|
107
|
+
# ctrl.kill(9)
|
|
108
|
+
# ctrl.kill("SIGTERM")
|
|
109
|
+
#
|
|
110
|
+
def kill(signal = :TERM)
|
|
111
|
+
Process.kill(signal, pid)
|
|
112
|
+
true
|
|
113
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
114
|
+
# Process already gone or we don't have permission
|
|
115
|
+
false
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
alias signal kill
|
|
119
|
+
|
|
120
|
+
# Send SIGTERM and wait for graceful shutdown.
|
|
121
|
+
#
|
|
122
|
+
# @param timeout [Float] Seconds to wait before SIGKILL
|
|
123
|
+
# @return [Result] Final result
|
|
124
|
+
def terminate(timeout: 5)
|
|
125
|
+
kill(:TERM)
|
|
126
|
+
result(timeout: timeout)
|
|
127
|
+
rescue Timeout::Error
|
|
128
|
+
kill(:KILL)
|
|
129
|
+
result(timeout: 1)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# ─────────────────────────────────────────────────────────────
|
|
133
|
+
# Wait for Completion
|
|
134
|
+
# ─────────────────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
# Wait for process to complete and return Result.
|
|
137
|
+
#
|
|
138
|
+
# @param timeout [Float, nil] Maximum seconds to wait (nil = forever)
|
|
139
|
+
# @return [Result] Final result with exit status
|
|
140
|
+
# @raise [Timeout::Error] if timeout exceeded
|
|
141
|
+
#
|
|
142
|
+
# @example
|
|
143
|
+
# result = ctrl.result
|
|
144
|
+
# result = ctrl.result(timeout: 30)
|
|
145
|
+
#
|
|
146
|
+
def result(timeout: nil)
|
|
147
|
+
@mutex.synchronize do
|
|
148
|
+
return @result if @result
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
status = if timeout
|
|
152
|
+
wait_with_timeout(timeout)
|
|
153
|
+
else
|
|
154
|
+
Process.wait2(pid)[1]
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
duration = Time.now - @started_at
|
|
158
|
+
close_pipes
|
|
159
|
+
|
|
160
|
+
@mutex.synchronize do
|
|
161
|
+
@result = Result.from_status(
|
|
162
|
+
status,
|
|
163
|
+
command: @command,
|
|
164
|
+
duration: duration,
|
|
165
|
+
options: @options
|
|
166
|
+
)
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
alias wait result
|
|
171
|
+
|
|
172
|
+
# ─────────────────────────────────────────────────────────────
|
|
173
|
+
# IO Helpers
|
|
174
|
+
# ─────────────────────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
# Write to stdin and optionally close.
|
|
177
|
+
#
|
|
178
|
+
# @param data [String] Data to write
|
|
179
|
+
# @param close_after [Boolean] Close stdin after writing
|
|
180
|
+
# @return [Integer] Bytes written
|
|
181
|
+
def write(data, close_after: false)
|
|
182
|
+
raise "No stdin pipe available" unless @stdin
|
|
183
|
+
|
|
184
|
+
bytes = @stdin.write(data)
|
|
185
|
+
@stdin.close if close_after
|
|
186
|
+
bytes
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Read all available stdout.
|
|
190
|
+
#
|
|
191
|
+
# @return [String, nil] Stdout content or nil if no pipe
|
|
192
|
+
def read_stdout = @stdout&.read
|
|
193
|
+
|
|
194
|
+
# Read all available stderr.
|
|
195
|
+
#
|
|
196
|
+
# @return [String, nil] Stderr content or nil if no pipe
|
|
197
|
+
def read_stderr = @stderr&.read
|
|
198
|
+
|
|
199
|
+
# ─────────────────────────────────────────────────────────────
|
|
200
|
+
# Inspection
|
|
201
|
+
# ─────────────────────────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
def to_s
|
|
204
|
+
status = if @result
|
|
205
|
+
@result.success? ? "exited" : "failed"
|
|
206
|
+
else
|
|
207
|
+
"running"
|
|
208
|
+
end
|
|
209
|
+
"#<Controller #{command.first} pid=#{pid} #{status}>"
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def inspect
|
|
213
|
+
parts = ["#<Controller"]
|
|
214
|
+
parts << "name=#{name.inspect}" if name
|
|
215
|
+
parts << "command=#{command.inspect}"
|
|
216
|
+
parts << "pid=#{pid}"
|
|
217
|
+
parts << "elapsed=#{'%.2f' % elapsed}s"
|
|
218
|
+
parts << "status=#{@result ? 'finished' : 'running'}"
|
|
219
|
+
parts << ">"
|
|
220
|
+
parts.join(" ")
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
private
|
|
224
|
+
|
|
225
|
+
def wait_with_timeout(timeout)
|
|
226
|
+
deadline = Time.now + timeout
|
|
227
|
+
loop do
|
|
228
|
+
pid_result, status = Process.wait2(pid, Process::WNOHANG)
|
|
229
|
+
return status if pid_result
|
|
230
|
+
|
|
231
|
+
remaining = deadline - Time.now
|
|
232
|
+
raise Timeout::Error, "Process #{pid} did not exit within #{timeout}s" if remaining <= 0
|
|
233
|
+
|
|
234
|
+
sleep([0.1, remaining].min)
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def close_pipes
|
|
239
|
+
@stdin&.close unless @stdin&.closed?
|
|
240
|
+
@stdout&.close unless @stdout&.closed?
|
|
241
|
+
@stderr&.close unless @stderr&.closed?
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
end
|