geny 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,59 @@
1
+ require "tty-prompt"
2
+
3
+ module Geny
4
+ module Actions
5
+ # Utillities for printing to the console.
6
+ # @see https://rubydoc.info/github/piotrmurach/tty-prompt/TTY/Prompt TTY::Prompt
7
+ class UI < TTY::Prompt
8
+ attr_reader :color
9
+
10
+ # Create a new UI
11
+ # @param color [Pastel]
12
+ def initialize(color:, **opts)
13
+ super(opts)
14
+ @color = color
15
+ end
16
+
17
+ # Print a heading
18
+ # @param message [String]
19
+ #
20
+ # @example
21
+ # ui.heading "Files"
22
+ def heading(message)
23
+ say "#{@color.dim("==")} #{@color.bold(message)}"
24
+ end
25
+
26
+ # Print a status
27
+ # @param label [String]
28
+ # @param message [String]
29
+ # @param color [Symbol]
30
+ #
31
+ # @example
32
+ # ui.status "create", "hello.txt"
33
+ # ui.status "remove", "hello.txt", color: :red
34
+ def status(label, message, color: :green)
35
+ say "#{@color.send(color, label.rjust(12))} #{message}"
36
+ end
37
+
38
+ # Print an error
39
+ # @param message [String]
40
+ #
41
+ # @example
42
+ # ui.error "the world is ending"
43
+ def error(message)
44
+ stderr.puts "#{@color.red("ERROR:")} #{message}"
45
+ end
46
+
47
+ # Print and error and abort
48
+ # @param message [String]
49
+ # @raise [SystemExit]
50
+ #
51
+ # @example
52
+ # ui.abort! "command failed, exiting"
53
+ def abort!(message)
54
+ error(message)
55
+ exit 1
56
+ end
57
+ end
58
+ end
59
+ end
data/lib/geny/cli.rb ADDED
@@ -0,0 +1,99 @@
1
+ require "argy"
2
+ require "geny"
3
+ require "geny/registry"
4
+ require "geny/version"
5
+ require "geny/actions/ui"
6
+
7
+ module Geny
8
+ class CLI
9
+ # The registry used for locating commands
10
+ # @return [Registry]
11
+ attr_reader :registry
12
+
13
+ # The version of your program
14
+ # @return [String]
15
+ attr_reader :version
16
+
17
+ # The name of your program
18
+ # @return [String]
19
+ attr_reader :program_name
20
+
21
+ # A description for your program
22
+ # @return [String]
23
+ attr_reader :description
24
+
25
+ # The column width for help information
26
+ # @return [Integer]
27
+ attr_reader :column
28
+
29
+ # Create a new CLI
30
+ # @param registry [Registry]
31
+ # @param version [String]
32
+ # @param program_name [String]
33
+ # @param description [String]
34
+ # @param column [Integer]
35
+ def initialize(
36
+ registry: Registry.new,
37
+ version: VERSION,
38
+ program_name: "geny",
39
+ description: nil,
40
+ column: 20
41
+ )
42
+ @registry = registry
43
+ @version = version
44
+ @program_name = program_name
45
+ @description = description
46
+ @column = column
47
+ end
48
+
49
+ # Parse arguments and invoke a command
50
+ # @param argv [Array<String>]
51
+ def run(argv)
52
+ opts = parser.parse(argv, strategy: :order)
53
+ help! unless opts.command?
54
+
55
+ command = registry.find!(opts.command)
56
+ command.run(opts.unused_args)
57
+ end
58
+
59
+ # Print an error and abort the program.
60
+ # @param message [String] error message
61
+ # @raise [SystemExit]
62
+ def abort!(message)
63
+ color = Pastel.new(enabled: $stdout.tty?)
64
+ ui = Actions::UI.new(color: color)
65
+ ui.abort!(message)
66
+ end
67
+
68
+ private
69
+
70
+ def parser
71
+ @parser ||= Argy.new do |o|
72
+ o.usage "#{program_name} [COMMAND]"
73
+ o.description description
74
+ o.argument :command, desc: "generator to run"
75
+
76
+ o.on "-v", "--version", "print version and exit" do
77
+ puts version
78
+ exit
79
+ end
80
+
81
+ o.on "-h", "--help", "show this help and exit" do
82
+ help!
83
+ end
84
+ end
85
+ end
86
+
87
+ def help!
88
+ help = parser.help(column: column)
89
+ puts help
90
+ puts help.section("COMMANDS")
91
+
92
+ registry.scan.each do |cmd|
93
+ puts help.entry(cmd.name, desc: cmd.description)
94
+ end
95
+
96
+ exit
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,119 @@
1
+ require "geny/dsl"
2
+ require "geny/context/invoke"
3
+
4
+ module Geny
5
+ class Command
6
+ # The filename for a generator, relative to the root
7
+ FILENAME = "generator.rb"
8
+
9
+ # The directory where templates are stored, relative to the root
10
+ TEMPLATES = "templates"
11
+
12
+ # The name of the command
13
+ # @return [String]
14
+ attr_reader :name
15
+
16
+ # The root directory for the command
17
+ # @return [String]
18
+ attr_reader :root
19
+
20
+ # Create a new command
21
+ # @param name [String] name of the command
22
+ # @param root [String] name of the command
23
+ def initialize(name:, root:)
24
+ @name = name
25
+ @root = root
26
+ end
27
+
28
+ # The path where the command is located
29
+ # @return [String]
30
+ def file
31
+ File.join(root, FILENAME)
32
+ end
33
+
34
+ # The path where templates are located
35
+ # @return [String]
36
+ def templates_path
37
+ File.join(root, TEMPLATES)
38
+ end
39
+
40
+ # The command's option parser
41
+ # @return [Argy::Parser]
42
+ def parser
43
+ dsl.parser
44
+ end
45
+
46
+ # The command's helper modules
47
+ # @return [Array<Module>]
48
+ def helpers
49
+ dsl.helpers
50
+ end
51
+
52
+ # The description for a command
53
+ # @return [String]
54
+ def description
55
+ parser.description
56
+ end
57
+
58
+ # Parse command-line options
59
+ # @param argv [Array<String>]
60
+ # @return [Argy::Options]
61
+ def parse(argv)
62
+ parser.parse(argv)
63
+ end
64
+
65
+ # Parse command-line options and run the command
66
+ # @param argv [Array<String>]
67
+ def run(argv)
68
+ options = parse(argv)
69
+ invoke!(options.to_h)
70
+ end
71
+
72
+ # Invoke a command with options
73
+ # @param options [Hash{Symbol => Object}]
74
+ def invoke(**options)
75
+ options = parser.default_values.merge(options)
76
+ parser.validate!(options)
77
+ invoke!(options)
78
+ end
79
+
80
+ # Defines the behavior of a command. The block is evaluated
81
+ # within the context of a DSL.
82
+ #
83
+ # @example
84
+ # command = Commmand.new(name: "foo", root: "path/to/root")
85
+ # command.define do
86
+ # parse {}
87
+ # invoke {}
88
+ # end
89
+ def define(&block)
90
+ @dsl = DSL.new
91
+ @dsl.instance_eval(&block)
92
+ self
93
+ end
94
+
95
+ private
96
+
97
+ def invoke!(options)
98
+ context = Context::Invoke.new(
99
+ command: self,
100
+ locals: extend_with_queries(options)
101
+ )
102
+
103
+ context.instance_eval(&dsl.invoke)
104
+ end
105
+
106
+ def extend_with_queries(options)
107
+ queries = options.map { |k, v| [:"#{k}?", !!v] }
108
+ options.merge(queries.to_h)
109
+ end
110
+
111
+ def dsl
112
+ return @dsl if @dsl
113
+
114
+ @dsl = DSL.new
115
+ @dsl.instance_eval File.read(file)
116
+ @dsl
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,30 @@
1
+ module Geny
2
+ module Context
3
+ # @api private
4
+ class Base
5
+ attr_reader :command, :locals
6
+
7
+ def initialize(command:, locals: {})
8
+ @locals = locals
9
+ @command = command
10
+ @command.helpers.each { |h| extend h }
11
+ end
12
+
13
+ private
14
+
15
+ def respond_to_missing?(meth, *)
16
+ locals.key?(meth) || super
17
+ end
18
+
19
+ def method_missing(meth, *args)
20
+ return super unless locals.key?(meth)
21
+
22
+ unless args.empty?
23
+ raise ArgumentError, "wrong number of arguments (given #{args.length}, expected 0)"
24
+ end
25
+
26
+ locals[meth]
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,56 @@
1
+ require "pastel"
2
+ require "geny/context/base"
3
+ require "geny/actions/ui"
4
+ require "geny/actions/git"
5
+ require "geny/actions/shell"
6
+ require "geny/actions/files"
7
+ require "geny/actions/templates"
8
+
9
+ module Geny
10
+ module Context
11
+ # The `invoke` behavior for all commands is evaluated in
12
+ # the context of this class. All methods that are defined
13
+ # here are available inside `invoke`.
14
+ class Invoke < Base
15
+ # A utility for colored output
16
+ # @return [Pastel]
17
+ # @see https://github.com/piotrmurach/pastel
18
+ def color
19
+ Pastel.new(enabled: $stdout.tty?)
20
+ end
21
+
22
+ # A utility for printing messages to the console
23
+ # @return [Actions::UI]
24
+ def ui
25
+ Actions::UI.new(color: color)
26
+ end
27
+
28
+ # A utility for interacting with files
29
+ # @return [Actions::Files]
30
+ def files
31
+ Actions::Files.new
32
+ end
33
+
34
+ # A utility for running shell commands
35
+ # @return [Actions::Shell]
36
+ def shell
37
+ Actions::Shell.new(ui: ui)
38
+ end
39
+
40
+ # A utility for interacting with git repositories
41
+ # @return [Actions::Git]
42
+ def git
43
+ Actions::Git.new(shell: shell)
44
+ end
45
+
46
+ # A utility for rendering and copying templates
47
+ # @return [Actions::Templates]
48
+ def templates
49
+ Actions::Templates.new(
50
+ root: command.templates_path,
51
+ view: View.new(command: command, locals: locals)
52
+ )
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,15 @@
1
+ require "geny/context/base"
2
+
3
+ module Geny
4
+ module Context
5
+ # All templates are evaluated in the context of a View.
6
+ # All command-line options, locals, and helper methods
7
+ # wil be available in templates.
8
+ class View < Base
9
+ # @private
10
+ def merge(updates)
11
+ View.new(command: command, locals: locals.merge(updates))
12
+ end
13
+ end
14
+ end
15
+ end
data/lib/geny/dsl.rb ADDED
@@ -0,0 +1,44 @@
1
+ require "argy"
2
+ require "pathname"
3
+
4
+ module Geny
5
+ # The top-level command file is evaulated in the context
6
+ # of this class.
7
+ class DSL
8
+ # @private
9
+ def initialize
10
+ @helpers = []
11
+ @invoke = proc { warn "I don't know what to do!" }
12
+ end
13
+
14
+ # Define arguments and options that the command accepts.
15
+ # The block is evaluated in the context of an {https://rubydoc.info/github/rzane/argy/Argy/Parser Argy::Parser}.
16
+ def parse(&block)
17
+ parser.instance_eval(&block)
18
+ end
19
+
20
+ # Define the behavior for when the command runs. The block is
21
+ # evaluated in the context of a {Context::Invoke}.
22
+ def invoke(&block)
23
+ @invoke = block if block_given?
24
+ @invoke
25
+ end
26
+
27
+ # Define helper methods. These methods are available within
28
+ # the {#invoke} block and all templates.
29
+ def helpers(&block)
30
+ @helpers << Module.new(&block) if block_given?
31
+ @helpers
32
+ end
33
+
34
+ # @private
35
+ def parser
36
+ @parser ||= Argy.new do |o|
37
+ o.on "-h", "--help", "show this help and exit" do
38
+ puts o.help
39
+ exit
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
data/lib/geny/error.rb ADDED
@@ -0,0 +1,20 @@
1
+ module Geny
2
+ # A base class for all errors
3
+ class Error < StandardError
4
+ end
5
+
6
+ # Raised when a command cannot be found.
7
+ class NotFoundError < Error
8
+ end
9
+
10
+ # Raised when a shell command exits with a non-zero status.
11
+ class ExitError < Error
12
+ attr_reader :code, :command
13
+
14
+ def initialize(code:, command:)
15
+ @code = code
16
+ @command = command
17
+ super "Command `#{command}` failed (exit code: #{code})"
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,71 @@
1
+ require "pathname"
2
+ require "geny/error"
3
+ require "geny/command"
4
+
5
+ module Geny
6
+ class Registry
7
+ # The default load path. By default, Geny
8
+ # will search
9
+ LOAD_PATH = [
10
+ File.join(Dir.pwd, ".geny"),
11
+ *ENV.fetch("CODE_HEN_PATH", "").split(":"),
12
+ File.expand_path("../generators", __dir__)
13
+ ]
14
+
15
+ # The directories to search for commands in
16
+ # @return [Array<String>]
17
+ attr_reader :load_path
18
+
19
+ # Create a new registry
20
+ # @param load_path [Array<String>]
21
+ def initialize(load_path: LOAD_PATH)
22
+ @load_path = load_path
23
+ end
24
+
25
+ # Iterate over all load paths and find all commands
26
+ # @return [Array<Command>]
27
+ def scan
28
+ glob = File.join("**", Command::FILENAME)
29
+
30
+ commands = load_path.flat_map do |path|
31
+ path = Pathname.new(path)
32
+
33
+ path.glob(glob).map do |file|
34
+ root = file.dirname
35
+ name = root.relative_path_from(path)
36
+ name = name.to_s.tr(File::SEPARATOR, ":")
37
+ Command.new(name: name, root: root.to_s)
38
+ end
39
+ end
40
+
41
+ commands.sort_by(&:name)
42
+ end
43
+
44
+ # Find a command by name
45
+ # @param name [String] name of the command
46
+ # @return [Command,nil]
47
+ def find(name)
48
+ load_path.each do |path|
49
+ file = File.join(path, *name.split(":"), Command::FILENAME)
50
+ root = File.dirname(file)
51
+ return Command.new(name: name, root: root) if File.exist?(file)
52
+ end
53
+
54
+ nil
55
+ end
56
+
57
+ # Find a command by name or raise an error
58
+ # @param name [String] name of the command
59
+ # @raise [NotFoundError] when the command is not found
60
+ # @return [Command]
61
+ def find!(name)
62
+ find(name) || command_not_found!(name)
63
+ end
64
+
65
+ private
66
+
67
+ def command_not_found!(name)
68
+ raise NotFoundError, "There doesn't appear to be a generator named '#{name}'"
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,3 @@
1
+ module Geny
2
+ VERSION = "0.1.0"
3
+ end
data/lib/geny.rb ADDED
@@ -0,0 +1,4 @@
1
+ require "geny/version"
2
+
3
+ module Geny
4
+ end