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.
Files changed (72) hide show
  1. checksums.yaml +7 -0
  2. data/.obsidian/app.json +6 -0
  3. data/.obsidian/appearance.json +4 -0
  4. data/.obsidian/community-plugins.json +5 -0
  5. data/.obsidian/core-plugins.json +33 -0
  6. data/.obsidian/plugins/obsidian-minimal-settings/data.json +34 -0
  7. data/.obsidian/plugins/obsidian-minimal-settings/main.js +8 -0
  8. data/.obsidian/plugins/obsidian-minimal-settings/manifest.json +11 -0
  9. data/.obsidian/plugins/obsidian-style-settings/data.json +15 -0
  10. data/.obsidian/plugins/obsidian-style-settings/main.js +165 -0
  11. data/.obsidian/plugins/obsidian-style-settings/manifest.json +10 -0
  12. data/.obsidian/plugins/obsidian-style-settings/styles.css +243 -0
  13. data/.obsidian/plugins/table-editor-obsidian/data.json +6 -0
  14. data/.obsidian/plugins/table-editor-obsidian/main.js +236 -0
  15. data/.obsidian/plugins/table-editor-obsidian/manifest.json +17 -0
  16. data/.obsidian/plugins/table-editor-obsidian/styles.css +78 -0
  17. data/.obsidian/themes/AnuPpuccin/manifest.json +7 -0
  18. data/.obsidian/themes/AnuPpuccin/theme.css +9080 -0
  19. data/.obsidian/themes/Minimal/manifest.json +8 -0
  20. data/.obsidian/themes/Minimal/theme.css +2251 -0
  21. data/.rubocop.yml +231 -0
  22. data/CHANGELOG.md +97 -0
  23. data/LICENSE +21 -0
  24. data/README.md +314 -0
  25. data/Rakefile +13 -0
  26. data/devex-logo.jpg +0 -0
  27. data/docs/developing-tools.md +1000 -0
  28. data/docs/ref/agent-mode.md +46 -0
  29. data/docs/ref/cli-interface.md +60 -0
  30. data/docs/ref/configuration.md +46 -0
  31. data/docs/ref/design-philosophy.md +17 -0
  32. data/docs/ref/error-handling.md +38 -0
  33. data/docs/ref/io-handling.md +88 -0
  34. data/docs/ref/signals.md +141 -0
  35. data/docs/ref/temporal-software-theory.md +790 -0
  36. data/exe/dx +52 -0
  37. data/lib/devex/builtins/.index.rb +10 -0
  38. data/lib/devex/builtins/debug.rb +43 -0
  39. data/lib/devex/builtins/format.rb +44 -0
  40. data/lib/devex/builtins/gem.rb +77 -0
  41. data/lib/devex/builtins/lint.rb +61 -0
  42. data/lib/devex/builtins/test.rb +76 -0
  43. data/lib/devex/builtins/version.rb +156 -0
  44. data/lib/devex/cli.rb +340 -0
  45. data/lib/devex/context.rb +433 -0
  46. data/lib/devex/core/configuration.rb +136 -0
  47. data/lib/devex/core.rb +79 -0
  48. data/lib/devex/dirs.rb +210 -0
  49. data/lib/devex/dsl.rb +100 -0
  50. data/lib/devex/exec/controller.rb +245 -0
  51. data/lib/devex/exec/result.rb +229 -0
  52. data/lib/devex/exec.rb +662 -0
  53. data/lib/devex/loader.rb +136 -0
  54. data/lib/devex/output.rb +257 -0
  55. data/lib/devex/project_paths.rb +309 -0
  56. data/lib/devex/support/ansi.rb +437 -0
  57. data/lib/devex/support/core_ext.rb +560 -0
  58. data/lib/devex/support/global.rb +68 -0
  59. data/lib/devex/support/path.rb +357 -0
  60. data/lib/devex/support.rb +71 -0
  61. data/lib/devex/template_helpers.rb +136 -0
  62. data/lib/devex/templates/debug.erb +24 -0
  63. data/lib/devex/tool.rb +374 -0
  64. data/lib/devex/version.rb +5 -0
  65. data/lib/devex/working_dir.rb +99 -0
  66. data/lib/devex.rb +158 -0
  67. data/ruby-project-template/.gitignore +0 -0
  68. data/ruby-project-template/Gemfile +0 -0
  69. data/ruby-project-template/README.md +0 -0
  70. data/ruby-project-template/docs/README.md +0 -0
  71. data/sig/devex.rbs +4 -0
  72. 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