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/tool.rb
ADDED
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Devex
|
|
4
|
+
# Represents a CLI tool/command with its metadata, flags, args, and subtools
|
|
5
|
+
class Tool
|
|
6
|
+
attr_accessor :builtin, :desc, :long_desc, :run_block, :source_code, :source_file, :source_proc
|
|
7
|
+
attr_reader :name, :parent, :subtools, :flags, :args, :mixins
|
|
8
|
+
|
|
9
|
+
# Global flag specs that tools cannot override
|
|
10
|
+
# These are handled by CLI before tool flag parsing
|
|
11
|
+
RESERVED_FLAG_SPECS = %w[
|
|
12
|
+
-f --format
|
|
13
|
+
-v --verbose --no-verbose
|
|
14
|
+
-q --quiet --no-quiet
|
|
15
|
+
--color --no-color
|
|
16
|
+
--dx-version --dx-from-dir
|
|
17
|
+
].freeze
|
|
18
|
+
|
|
19
|
+
def initialize(name, parent: nil)
|
|
20
|
+
@name = name
|
|
21
|
+
@parent = parent
|
|
22
|
+
@desc = nil
|
|
23
|
+
@long_desc = nil
|
|
24
|
+
@flags = []
|
|
25
|
+
@args = []
|
|
26
|
+
@subtools = {}
|
|
27
|
+
@run_block = nil
|
|
28
|
+
@mixins = []
|
|
29
|
+
@builtin = nil # reference to builtin tool if this is an override
|
|
30
|
+
@source_code = nil
|
|
31
|
+
@source_file = nil
|
|
32
|
+
@source_proc = nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Full command path (e.g., ["version", "bump"])
|
|
36
|
+
def full_path
|
|
37
|
+
if parent
|
|
38
|
+
parent.full_path + [name]
|
|
39
|
+
else
|
|
40
|
+
(name ? [name] : [])
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Full command string (e.g., "version bump")
|
|
45
|
+
def full_name = full_path.join(" ")
|
|
46
|
+
|
|
47
|
+
# Define a flag
|
|
48
|
+
# Examples:
|
|
49
|
+
# flag :verbose, "-v", "--verbose", desc: "Enable verbose output"
|
|
50
|
+
# flag :file, "-f FILE", "--file=FILE", desc: "Input file"
|
|
51
|
+
def flag(name, *specs, desc: nil, default: nil) = @flags << Flag.new(name, specs, desc: desc, default: default)
|
|
52
|
+
|
|
53
|
+
# Check that flag specs don't conflict with global flags (called at execute time)
|
|
54
|
+
def validate_flags!
|
|
55
|
+
@flags.each do |flag|
|
|
56
|
+
flag.specs.each do |spec|
|
|
57
|
+
# Extract the flag part (before space or =)
|
|
58
|
+
flag_part = spec.split(/[\s=]/).first
|
|
59
|
+
if RESERVED_FLAG_SPECS.include?(flag_part)
|
|
60
|
+
raise Error, <<~ERR.chomp
|
|
61
|
+
Tool '#{full_name}': flag '#{flag_part}' conflicts with global flag
|
|
62
|
+
|
|
63
|
+
Global flags like #{flag_part} are handled before tool execution.
|
|
64
|
+
Your tool can access the global value via:
|
|
65
|
+
verbose? # for -v/--verbose
|
|
66
|
+
global_options[:format] # for -f/--format
|
|
67
|
+
global_options[:quiet] # for -q/--quiet
|
|
68
|
+
|
|
69
|
+
To fix: remove this flag definition or use a different flag name.
|
|
70
|
+
ERR
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Define a required positional argument
|
|
77
|
+
def required_arg(name, desc: nil) = @args << Arg.new(name, required: true, desc: desc)
|
|
78
|
+
|
|
79
|
+
# Define an optional positional argument
|
|
80
|
+
def optional_arg(name, desc: nil, default: nil) = @args << Arg.new(name, required: false, desc: desc, default: default)
|
|
81
|
+
|
|
82
|
+
# Define remaining args (variadic)
|
|
83
|
+
def remaining_args(name, desc: nil) = @args << Arg.new(name, required: false, desc: desc, remaining: true)
|
|
84
|
+
|
|
85
|
+
# Add a subtool
|
|
86
|
+
def add_subtool(tool) = @subtools[tool.name] = tool
|
|
87
|
+
|
|
88
|
+
# Find a subtool by name
|
|
89
|
+
def subtool(name) = @subtools[name]
|
|
90
|
+
|
|
91
|
+
# Include a mixin by name
|
|
92
|
+
def include_mixin(name) = @mixins << name
|
|
93
|
+
|
|
94
|
+
# Set builtin reference for override support
|
|
95
|
+
|
|
96
|
+
# Parse arguments and execute the tool
|
|
97
|
+
def execute(argv, cli)
|
|
98
|
+
# Check for subcommand first
|
|
99
|
+
if argv.any? && (sub = subtool(argv.first))
|
|
100
|
+
return sub.execute(argv[1..], cli)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Validate flags don't conflict with global flags
|
|
104
|
+
validate_flags!
|
|
105
|
+
|
|
106
|
+
# Parse flags and args
|
|
107
|
+
context = ExecutionContext.new(self, cli)
|
|
108
|
+
context.parse(argv)
|
|
109
|
+
|
|
110
|
+
# Check for required args
|
|
111
|
+
@args.select(&:required).each do |arg|
|
|
112
|
+
raise Error, "Missing required argument: #{arg.name}" unless context.options.key?(arg.name)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Execute - re-evaluate source in context so `def run` has access to cli, options, etc.
|
|
116
|
+
if @source_code
|
|
117
|
+
# Re-evaluate the source code in the execution context
|
|
118
|
+
# This makes all def methods available with proper context
|
|
119
|
+
context.instance_eval(@source_code, @source_file || "(tool)")
|
|
120
|
+
if context.respond_to?(:run)
|
|
121
|
+
context.run
|
|
122
|
+
elsif @subtools.any?
|
|
123
|
+
cli.show_help(self)
|
|
124
|
+
else
|
|
125
|
+
raise Error, "Tool '#{full_name}' has no run method"
|
|
126
|
+
end
|
|
127
|
+
elsif @source_proc
|
|
128
|
+
# For nested tools defined with blocks.
|
|
129
|
+
# First eval the source file (if any) to define helper methods,
|
|
130
|
+
# then eval the block to define this subtool's run method.
|
|
131
|
+
if @source_file && File.exist?(@source_file)
|
|
132
|
+
context.instance_eval(File.read(@source_file), @source_file)
|
|
133
|
+
end
|
|
134
|
+
context.instance_eval(&@source_proc)
|
|
135
|
+
if context.respond_to?(:run)
|
|
136
|
+
context.run
|
|
137
|
+
elsif @subtools.any?
|
|
138
|
+
cli.show_help(self)
|
|
139
|
+
else
|
|
140
|
+
raise Error, "Tool '#{full_name}' has no run method"
|
|
141
|
+
end
|
|
142
|
+
elsif @run_block
|
|
143
|
+
context.instance_exec(&@run_block)
|
|
144
|
+
elsif @subtools.any?
|
|
145
|
+
cli.show_help(self)
|
|
146
|
+
else
|
|
147
|
+
raise Error, "Tool '#{full_name}' has no implementation"
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Generate help text
|
|
152
|
+
def help_text(executable_name = "dx")
|
|
153
|
+
lines = []
|
|
154
|
+
|
|
155
|
+
# Usage line
|
|
156
|
+
cmd = [executable_name, *full_path].join(" ")
|
|
157
|
+
usage_parts = [cmd]
|
|
158
|
+
usage_parts << "[OPTIONS]" if @flags.any?
|
|
159
|
+
@args.each do |arg|
|
|
160
|
+
usage_parts << if arg.remaining
|
|
161
|
+
"[#{arg.name.to_s.upcase}...]"
|
|
162
|
+
elsif arg.required
|
|
163
|
+
arg.name.to_s.upcase
|
|
164
|
+
else
|
|
165
|
+
"[#{arg.name.to_s.upcase}]"
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
usage_parts << "COMMAND" if @subtools.any?
|
|
169
|
+
|
|
170
|
+
lines << "Usage: #{usage_parts.join(' ')}"
|
|
171
|
+
lines << ""
|
|
172
|
+
|
|
173
|
+
# Description
|
|
174
|
+
if @desc
|
|
175
|
+
lines << @desc
|
|
176
|
+
lines << ""
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
if @long_desc
|
|
180
|
+
lines << @long_desc
|
|
181
|
+
lines << ""
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Subcommands
|
|
185
|
+
if @subtools.any?
|
|
186
|
+
lines << "Commands:"
|
|
187
|
+
max_name = @subtools.keys.map(&:length).max
|
|
188
|
+
@subtools.sort.each do |name, tool|
|
|
189
|
+
desc_text = tool.desc || ""
|
|
190
|
+
lines << " #{name.ljust(max_name)} #{desc_text}"
|
|
191
|
+
end
|
|
192
|
+
lines << ""
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Flags
|
|
196
|
+
if @flags.any?
|
|
197
|
+
lines << "Options:"
|
|
198
|
+
@flags.each do |flag|
|
|
199
|
+
flag_str = flag.specs.join(", ")
|
|
200
|
+
desc_text = flag.desc || ""
|
|
201
|
+
lines << " #{flag_str}"
|
|
202
|
+
lines << " #{desc_text}" if desc_text != ""
|
|
203
|
+
end
|
|
204
|
+
lines << ""
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Args
|
|
208
|
+
if @args.any?
|
|
209
|
+
lines << "Arguments:"
|
|
210
|
+
@args.each do |arg|
|
|
211
|
+
name_str = arg.name.to_s.upcase
|
|
212
|
+
name_str += "..." if arg.remaining
|
|
213
|
+
req_str = arg.required ? "(required)" : "(optional)"
|
|
214
|
+
desc_text = arg.desc ? " - #{arg.desc}" : ""
|
|
215
|
+
lines << " #{name_str} #{req_str}#{desc_text}"
|
|
216
|
+
end
|
|
217
|
+
lines << ""
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
lines.join("\n")
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Represents a flag definition
|
|
225
|
+
class Flag
|
|
226
|
+
attr_reader :name, :specs, :desc, :default
|
|
227
|
+
|
|
228
|
+
def initialize(name, specs, desc: nil, default: nil)
|
|
229
|
+
@name = name
|
|
230
|
+
@specs = specs
|
|
231
|
+
@desc = desc
|
|
232
|
+
@default = default
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# Does this flag take an argument?
|
|
236
|
+
def takes_argument? = @specs.any? { |s| s.include?(" ") || s.include?("=") }
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Represents a positional argument definition
|
|
240
|
+
class Arg
|
|
241
|
+
attr_reader :name, :required, :desc, :default, :remaining
|
|
242
|
+
|
|
243
|
+
def initialize(name, required:, desc: nil, default: nil, remaining: false)
|
|
244
|
+
@name = name
|
|
245
|
+
@required = required
|
|
246
|
+
@desc = desc
|
|
247
|
+
@default = default
|
|
248
|
+
@remaining = remaining
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# Execution context - the 'self' when a tool runs
|
|
253
|
+
class ExecutionContext
|
|
254
|
+
include Exec # All tools have access to cmd, capture, spawn, etc.
|
|
255
|
+
|
|
256
|
+
attr_reader :options, :cli
|
|
257
|
+
|
|
258
|
+
def initialize(tool, cli)
|
|
259
|
+
@tool = tool
|
|
260
|
+
@cli = cli
|
|
261
|
+
@options = {}
|
|
262
|
+
|
|
263
|
+
# Set defaults for flags
|
|
264
|
+
tool.flags.each do |flag|
|
|
265
|
+
@options[flag.name] = if !flag.default.nil?
|
|
266
|
+
flag.default
|
|
267
|
+
elsif !flag.takes_argument?
|
|
268
|
+
false # Boolean flags default to false
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
tool.args.each do |arg|
|
|
272
|
+
@options[arg.name] = arg.default unless arg.default.nil?
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# Parse argv into options, return unparsed remainder
|
|
277
|
+
def parse(argv)
|
|
278
|
+
require "optparse"
|
|
279
|
+
|
|
280
|
+
parser = OptionParser.new do |opts|
|
|
281
|
+
tool.flags.each do |flag|
|
|
282
|
+
if flag.takes_argument?
|
|
283
|
+
opts.on(*flag.specs) { |v| @options[flag.name] = v }
|
|
284
|
+
else
|
|
285
|
+
opts.on(*flag.specs) { @options[flag.name] = true }
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# Parse, collecting non-flag args
|
|
291
|
+
remaining = parser.parse(argv)
|
|
292
|
+
|
|
293
|
+
# Assign positional args
|
|
294
|
+
tool.args.each do |arg|
|
|
295
|
+
if arg.remaining
|
|
296
|
+
@options[arg.name] = remaining
|
|
297
|
+
remaining = []
|
|
298
|
+
elsif remaining.any?
|
|
299
|
+
@options[arg.name] = remaining.shift
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
remaining
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# DSL methods - no-ops during execution (already captured during load)
|
|
307
|
+
def desc(_text) = nil
|
|
308
|
+
def long_desc(_text) = nil
|
|
309
|
+
def flag(_name, *_specs, **_kwargs) = nil
|
|
310
|
+
def required_arg(_name, **_kwargs) = nil
|
|
311
|
+
def optional_arg(_name, **_kwargs) = nil
|
|
312
|
+
def remaining_args(_name, **_kwargs) = nil
|
|
313
|
+
def include(_name) = nil
|
|
314
|
+
def to_run(&) = nil
|
|
315
|
+
|
|
316
|
+
# We need special handling because `tool` is both an attr_reader
|
|
317
|
+
# and a DSL method. Override the reader to handle both cases.
|
|
318
|
+
def tool(name = nil, &block)
|
|
319
|
+
if name.nil? && block.nil?
|
|
320
|
+
# Called as attr_reader - return the Tool object
|
|
321
|
+
@tool
|
|
322
|
+
else
|
|
323
|
+
# Called as DSL method - no-op during execution (already captured)
|
|
324
|
+
nil
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
# Access options as methods
|
|
329
|
+
def method_missing(name, *args, &)
|
|
330
|
+
if @options.key?(name)
|
|
331
|
+
@options[name]
|
|
332
|
+
else
|
|
333
|
+
super
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def respond_to_missing?(name, include_private = false) = @options.key?(name) || super
|
|
338
|
+
|
|
339
|
+
# Access to builtin if this is an override
|
|
340
|
+
def builtin = tool.builtin ? ExecutionContext.new(tool.builtin, cli) : nil
|
|
341
|
+
|
|
342
|
+
# Run another tool by path
|
|
343
|
+
def run_tool(*path) = cli.run([*path.map(&:to_s)])
|
|
344
|
+
|
|
345
|
+
# Exit with code
|
|
346
|
+
def exit(code = 0) = Kernel.exit(code)
|
|
347
|
+
|
|
348
|
+
# --- Global options access ---
|
|
349
|
+
|
|
350
|
+
# Access CLI's global options
|
|
351
|
+
def global_options = cli.global_options
|
|
352
|
+
|
|
353
|
+
# Is verbose mode enabled? Returns verbosity level (0 = off, 1+ = on)
|
|
354
|
+
def verbose = global_options[:verbose]
|
|
355
|
+
|
|
356
|
+
def verbose? = verbose > 0
|
|
357
|
+
|
|
358
|
+
# Is quiet mode enabled?
|
|
359
|
+
def quiet? = global_options[:quiet]
|
|
360
|
+
|
|
361
|
+
# Get the effective output format
|
|
362
|
+
# Tool's --format flag takes precedence, then global --format, then context-based default
|
|
363
|
+
def output_format
|
|
364
|
+
# Tool-specific format (from options[:format]) takes precedence
|
|
365
|
+
return options[:format].to_sym if options[:format]
|
|
366
|
+
|
|
367
|
+
# Global format
|
|
368
|
+
return global_options[:format].to_sym if global_options[:format]
|
|
369
|
+
|
|
370
|
+
# Context-based default
|
|
371
|
+
Devex::Context.agent_mode? ? :json : :text
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "support/path"
|
|
4
|
+
require_relative "dirs"
|
|
5
|
+
|
|
6
|
+
module Devex
|
|
7
|
+
# Immutable working directory context for tool execution.
|
|
8
|
+
#
|
|
9
|
+
# The working directory is passed through the call tree but cannot be
|
|
10
|
+
# mutated. Child contexts see a new directory but parent is unaffected.
|
|
11
|
+
#
|
|
12
|
+
# Usage:
|
|
13
|
+
# working_dir # => current working directory (Path)
|
|
14
|
+
#
|
|
15
|
+
# within "packages/core" do
|
|
16
|
+
# working_dir # => /project/packages/core
|
|
17
|
+
# run "npm", "test" # Runs from packages/core
|
|
18
|
+
# end
|
|
19
|
+
#
|
|
20
|
+
# working_dir # => /project (unchanged)
|
|
21
|
+
#
|
|
22
|
+
# See ADR-003 for full specification.
|
|
23
|
+
#
|
|
24
|
+
module WorkingDir
|
|
25
|
+
Path = Support::Path
|
|
26
|
+
|
|
27
|
+
# Thread-local working directory stack
|
|
28
|
+
# Each entry is an immutable Path
|
|
29
|
+
@stack = []
|
|
30
|
+
@stack_mutex = Mutex.new
|
|
31
|
+
|
|
32
|
+
class << self
|
|
33
|
+
# Get current working directory
|
|
34
|
+
# Defaults to project_dir if no context has been pushed
|
|
35
|
+
def current = @stack_mutex.synchronize { @stack.last || Dirs.project_dir }
|
|
36
|
+
|
|
37
|
+
# Execute a block with a different working directory
|
|
38
|
+
# The working directory is restored after the block completes.
|
|
39
|
+
#
|
|
40
|
+
# @param subdir [String, Path] Directory to change to
|
|
41
|
+
# @yield Block to execute in the new context
|
|
42
|
+
# @return Result of the block
|
|
43
|
+
#
|
|
44
|
+
# @example Relative path
|
|
45
|
+
# within "packages/web" do
|
|
46
|
+
# run "npm", "test"
|
|
47
|
+
# end
|
|
48
|
+
#
|
|
49
|
+
# @example Absolute path
|
|
50
|
+
# within Path["/tmp/build"] do
|
|
51
|
+
# run "make"
|
|
52
|
+
# end
|
|
53
|
+
#
|
|
54
|
+
# @example Using project paths
|
|
55
|
+
# within prj.test do
|
|
56
|
+
# run "rake"
|
|
57
|
+
# end
|
|
58
|
+
#
|
|
59
|
+
def within(subdir)
|
|
60
|
+
new_wd = case subdir
|
|
61
|
+
when Path then subdir.absolute? ? subdir : current / subdir
|
|
62
|
+
when Pathname then subdir.absolute? ? Path.new(subdir) : current / subdir.to_s
|
|
63
|
+
when String then subdir.start_with?("/") ? Path[subdir] : current / subdir
|
|
64
|
+
else
|
|
65
|
+
raise ArgumentError, "Expected String or Path, got #{subdir.class}"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
push(new_wd)
|
|
69
|
+
yield
|
|
70
|
+
ensure
|
|
71
|
+
pop
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Reset the working directory stack (for testing)
|
|
75
|
+
def reset! = @stack_mutex.synchronize { @stack.clear }
|
|
76
|
+
|
|
77
|
+
# Get the full stack (for debugging)
|
|
78
|
+
def stack = @stack_mutex.synchronize { @stack.dup }
|
|
79
|
+
|
|
80
|
+
# Get the depth of the current context
|
|
81
|
+
def depth = @stack_mutex.synchronize { @stack.size }
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def push(path) = @stack_mutex.synchronize { @stack.push(path.freeze) }
|
|
86
|
+
|
|
87
|
+
def pop = @stack_mutex.synchronize { @stack.pop }
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Mixin module for tools that need working directory support
|
|
92
|
+
module WorkingDirMixin
|
|
93
|
+
# Get current working directory
|
|
94
|
+
def working_dir = WorkingDir.current
|
|
95
|
+
|
|
96
|
+
# Execute block in a different working directory
|
|
97
|
+
def within(subdir, &) = WorkingDir.within(subdir, &)
|
|
98
|
+
end
|
|
99
|
+
end
|
data/lib/devex.rb
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Devex - The dx CLI tool with all builtins and dx-specific configuration.
|
|
4
|
+
#
|
|
5
|
+
# This is the full dx application. For just the CLI framework (to build
|
|
6
|
+
# your own CLI tool), use: require "devex/core"
|
|
7
|
+
|
|
8
|
+
require_relative "devex/core"
|
|
9
|
+
|
|
10
|
+
module Devex
|
|
11
|
+
class Error < StandardError; end
|
|
12
|
+
|
|
13
|
+
# ─────────────────────────────────────────────────────────────
|
|
14
|
+
# dx-specific Configuration
|
|
15
|
+
# ─────────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
# dx-specific project root markers, checked in order
|
|
18
|
+
ROOT_MARKERS = %w[.dx.yml .dx .git tools].freeze
|
|
19
|
+
|
|
20
|
+
# Default tools directory name
|
|
21
|
+
DEFAULT_TOOLS_DIR = "tools"
|
|
22
|
+
|
|
23
|
+
# Templates directory name within the gem
|
|
24
|
+
TEMPLATES_DIR = "templates"
|
|
25
|
+
|
|
26
|
+
# dx-specific configuration for CLI
|
|
27
|
+
DX_CONFIG = Core::Configuration.new(
|
|
28
|
+
executable_name: "dx",
|
|
29
|
+
flag_prefix: "dx",
|
|
30
|
+
project_markers: %w[.dx.yml .dx .git tools Gemfile Rakefile .devex.yml],
|
|
31
|
+
config_file: ".dx.yml",
|
|
32
|
+
organized_dir: ".dx",
|
|
33
|
+
tools_dir: "tools",
|
|
34
|
+
env_prefix: "DX",
|
|
35
|
+
delegation_file: ".dx-use-local"
|
|
36
|
+
).freeze
|
|
37
|
+
|
|
38
|
+
class << self
|
|
39
|
+
# Get the dx configuration
|
|
40
|
+
# @return [Core::Configuration]
|
|
41
|
+
def config
|
|
42
|
+
DX_CONFIG
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Root directory of the devex gem itself
|
|
46
|
+
# Used for locating built-in templates, builtins, etc.
|
|
47
|
+
def gem_root
|
|
48
|
+
@gem_root ||= File.expand_path('..', __dir__)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Path to the templates directory within the gem
|
|
52
|
+
def templates_path
|
|
53
|
+
@templates_path ||= File.join(File.dirname(__FILE__), "devex", TEMPLATES_DIR)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Load and render a template from the gem's templates directory
|
|
57
|
+
# Returns the rendered string
|
|
58
|
+
#
|
|
59
|
+
# If locals hash is provided, creates a binding with TemplateHelpers
|
|
60
|
+
# and all locals available. If a binding is provided directly, uses that.
|
|
61
|
+
def render_template(name, locals_or_binding = nil)
|
|
62
|
+
path = template_path(name)
|
|
63
|
+
raise Error, "Template not found: #{name}" unless File.exist?(path)
|
|
64
|
+
|
|
65
|
+
bind = if locals_or_binding.is_a?(Hash)
|
|
66
|
+
TemplateHelpers.template_binding(locals_or_binding)
|
|
67
|
+
elsif locals_or_binding.is_a?(Binding)
|
|
68
|
+
locals_or_binding
|
|
69
|
+
else
|
|
70
|
+
TemplateHelpers.template_binding
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
Output.render_template_file(path, bind)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Get full path to a template file
|
|
77
|
+
# Adds .erb extension if not present
|
|
78
|
+
def template_path(name)
|
|
79
|
+
name = "#{name}.erb" unless name.end_with?(".erb")
|
|
80
|
+
File.join(templates_path, name)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Find the project root by walking up from the given directory
|
|
84
|
+
# looking for root markers (.dx.yml, .git, tools/)
|
|
85
|
+
#
|
|
86
|
+
# Returns [root_path, marker_found] or [nil, nil] if not found
|
|
87
|
+
def find_project_root(from = Dir.pwd)
|
|
88
|
+
dir = File.expand_path(from)
|
|
89
|
+
|
|
90
|
+
loop do
|
|
91
|
+
ROOT_MARKERS.each do |marker|
|
|
92
|
+
marker_path = File.join(dir, marker)
|
|
93
|
+
return [dir, marker] if File.exist?(marker_path)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
parent = File.dirname(dir)
|
|
97
|
+
break if parent == dir # reached filesystem root
|
|
98
|
+
|
|
99
|
+
dir = parent
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
[nil, nil]
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Get the tools directory for a project root
|
|
106
|
+
# Reads from .dx.yml or .dx/config.yml if present, otherwise uses default
|
|
107
|
+
def tools_dir(project_root)
|
|
108
|
+
return nil unless project_root
|
|
109
|
+
|
|
110
|
+
config = load_config(project_root)
|
|
111
|
+
tools_dir_name = config["tools_dir"] || DEFAULT_TOOLS_DIR
|
|
112
|
+
|
|
113
|
+
dir = File.join(project_root, tools_dir_name)
|
|
114
|
+
File.directory?(dir) ? dir : nil
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Load project configuration from .dx.yml or .dx/config.yml
|
|
118
|
+
# Returns empty hash if no config file exists
|
|
119
|
+
# Raises Error if both .dx.yml and .dx/ directory exist (conflict)
|
|
120
|
+
def load_config(project_root)
|
|
121
|
+
return {} unless project_root
|
|
122
|
+
|
|
123
|
+
dx_dir = File.join(project_root, ".dx")
|
|
124
|
+
dx_yml = File.join(project_root, ".dx.yml")
|
|
125
|
+
|
|
126
|
+
dx_dir_exists = File.directory?(dx_dir)
|
|
127
|
+
dx_yml_exists = File.exist?(dx_yml)
|
|
128
|
+
|
|
129
|
+
# Conflict check: both simple and organized mode markers present
|
|
130
|
+
if dx_dir_exists && dx_yml_exists
|
|
131
|
+
raise Error, <<~ERR.chomp
|
|
132
|
+
Conflicting dx configuration
|
|
133
|
+
|
|
134
|
+
Found both:
|
|
135
|
+
.dx.yml (simple mode config)
|
|
136
|
+
.dx/ (organized mode directory)
|
|
137
|
+
|
|
138
|
+
Choose one:
|
|
139
|
+
• Remove .dx.yml to use organized mode (.dx/config.yml)
|
|
140
|
+
• Remove .dx/ directory to use simple mode (.dx.yml)
|
|
141
|
+
ERR
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
config_file = if dx_dir_exists
|
|
145
|
+
File.join(dx_dir, "config.yml")
|
|
146
|
+
else
|
|
147
|
+
dx_yml
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
if File.exist?(config_file)
|
|
151
|
+
require "yaml"
|
|
152
|
+
YAML.safe_load_file(config_file) || {}
|
|
153
|
+
else
|
|
154
|
+
{}
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
data/sig/devex.rbs
ADDED