caml 0.1.0 → 1.0.0
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 +4 -4
- data/CHANGELOG.md +33 -0
- data/README.md +142 -37
- data/bin/caml +21 -55
- data/lib/caml/argument.rb +11 -0
- data/lib/caml/cli.rb +62 -0
- data/lib/caml/config.rb +98 -0
- data/lib/caml/init.rb +18 -0
- data/lib/caml/interpolation.rb +19 -0
- data/lib/caml/option.rb +14 -0
- data/lib/caml/plan.rb +39 -0
- data/lib/caml/runner.rb +37 -0
- data/lib/caml/shell.rb +7 -0
- data/lib/caml/task.rb +15 -0
- data/lib/caml/version.rb +3 -0
- data/lib/caml.rb +14 -0
- metadata +69 -34
- data/lib/caml/caml.rb +0 -107
- data/spec/caml/caml_spec.rb +0 -9
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b6c99302250394b3a09b78b2391b5ede1d3b6fa8304ab2d6dbf3733ad8f67726
|
|
4
|
+
data.tar.gz: 8c8c7532d97bfa544fa857238f94f3e6b104e71974714a367ddb3b418bcaefb2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 549fe339f84b6c62de4786fbbab80abdcc06145b8b8aadcfe6ff3e6cc43dba18136622f6af6abf76978ec187de7f7d88418d845e49c3c9820fb0b12ec3aa1b5c
|
|
7
|
+
data.tar.gz: c404fe5c3d691c00bf6ba641696df230adac242da11403986c0ae1d4a72a70e90e85a20fb49ee12e80d3a85019e5ac251beaaafdffe8761b005fc9af12225a1a
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes follow [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [Semantic Versioning](https://semver.org/).
|
|
4
|
+
|
|
5
|
+
## [1.0.0] — 2026-05-01
|
|
6
|
+
|
|
7
|
+
First stable release.
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- Positional arguments with `{{name}}` interpolation, shell-escaped via `Shellwords`
|
|
11
|
+
- Typed options (string / boolean / numeric) with aliases, defaults, and per-option `execute` overrides
|
|
12
|
+
- Task-level command aliases via Thor's `map`
|
|
13
|
+
- Multi-line `execute:` arrays joined with `&&` for fail-fast semantics
|
|
14
|
+
- Task dependencies via `needs:`, resolved with cycle detection and dedup
|
|
15
|
+
- Pure orchestrator tasks (only `needs:`, no `execute:`)
|
|
16
|
+
- `caml init` scaffolds a starter `caml.yaml`
|
|
17
|
+
- `caml --version` prints the installed version
|
|
18
|
+
- Walks up from the current directory to find `caml.yaml`
|
|
19
|
+
- Friendly errors for missing or malformed YAML
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
- Replaced the `class_eval`-of-source-strings command generator with a typed pipeline (Config → Task → Runner → Plan → Thor adapter)
|
|
23
|
+
- Single canonical "no such task" error: `Caml::Plan::UnknownTask`
|
|
24
|
+
- Minimum Ruby is now 3.0
|
|
25
|
+
- Runtime deps declared in the gemspec; dev deps in the Gemfile
|
|
26
|
+
|
|
27
|
+
### Security
|
|
28
|
+
- YAML loads via `safe_yaml` with `raise_on_unknown_tag: true`; `!ruby/object` tags are stripped, never instantiated
|
|
29
|
+
- Argument interpolation always shell-escapes substituted values; the only place caml shells out is `Caml::Shell#run`
|
|
30
|
+
|
|
31
|
+
## [0.1.1]
|
|
32
|
+
|
|
33
|
+
Initial scaffolding (pre-1.0; experimental).
|
data/README.md
CHANGED
|
@@ -1,70 +1,175 @@
|
|
|
1
|
-
#
|
|
1
|
+
# caml
|
|
2
2
|
|
|
3
|
-
> Build CLI apps
|
|
3
|
+
> Build CLI apps from a declarative `caml.yaml` file.
|
|
4
4
|
|
|
5
|
-
`caml`
|
|
6
|
-
`caml` aims to be like `make`, but by using descriptive, declarative yaml.
|
|
5
|
+
`caml` turns well-documented YAML tasks into a runnable CLI. Inspired by `make` and `just`, but YAML-driven.
|
|
7
6
|
|
|
8
|
-
##
|
|
9
|
-
|
|
10
|
-
Running the command without any arguments displays the commands defined in the `caml.yaml` file:
|
|
7
|
+
## Install
|
|
11
8
|
|
|
12
9
|
```sh
|
|
13
|
-
|
|
10
|
+
gem install caml
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Or in a `Gemfile`:
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
gem 'caml'
|
|
14
17
|
```
|
|
15
18
|
|
|
16
|
-
##
|
|
19
|
+
## Quickstart
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
caml init # scaffold a starter caml.yaml
|
|
23
|
+
caml # list tasks
|
|
24
|
+
caml hello # run the starter task
|
|
25
|
+
```
|
|
17
26
|
|
|
18
|
-
|
|
27
|
+
## Tasks
|
|
19
28
|
|
|
20
|
-
|
|
29
|
+
A task has a name, a `desc`, and an `execute` shell command:
|
|
21
30
|
|
|
22
31
|
```yaml
|
|
23
|
-
|
|
24
|
-
desc:
|
|
25
|
-
execute:
|
|
32
|
+
test:
|
|
33
|
+
desc: Run the test suite
|
|
34
|
+
execute: bundle exec rspec
|
|
26
35
|
```
|
|
27
36
|
|
|
28
|
-
|
|
37
|
+
`caml` and `caml help` list every task with its description.
|
|
38
|
+
|
|
39
|
+
## Multi-line execute
|
|
40
|
+
|
|
41
|
+
Pass a list to run multiple steps. Steps run with fail-fast semantics — the first non-zero exit aborts:
|
|
29
42
|
|
|
30
43
|
```yaml
|
|
31
|
-
|
|
44
|
+
setup:
|
|
45
|
+
desc: Install and migrate
|
|
46
|
+
execute:
|
|
47
|
+
- bundle install
|
|
48
|
+
- bin/rails db:migrate
|
|
32
49
|
```
|
|
33
50
|
|
|
34
|
-
|
|
51
|
+
## Arguments
|
|
35
52
|
|
|
36
|
-
|
|
53
|
+
Positional arguments substitute into the `execute` template via `{{name}}`. Values are shell-escaped:
|
|
37
54
|
|
|
38
55
|
```yaml
|
|
39
|
-
|
|
56
|
+
greet:
|
|
57
|
+
desc: Say hello to someone
|
|
40
58
|
args:
|
|
41
|
-
|
|
42
|
-
desc:
|
|
43
|
-
type: string
|
|
44
|
-
two:
|
|
45
|
-
desc: Second argument
|
|
59
|
+
name:
|
|
60
|
+
desc: Person to greet
|
|
46
61
|
type: string
|
|
62
|
+
execute: echo Hello, {{name}}!
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
```sh
|
|
66
|
+
caml greet world
|
|
67
|
+
# Hello, world!
|
|
47
68
|
```
|
|
48
69
|
|
|
49
|
-
##
|
|
70
|
+
## Options
|
|
71
|
+
|
|
72
|
+
Flags with type, optional aliases, default value, and an optional override `execute`:
|
|
50
73
|
|
|
51
74
|
```yaml
|
|
52
75
|
build:
|
|
53
|
-
desc: Build
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
76
|
+
desc: Build the project
|
|
77
|
+
opts:
|
|
78
|
+
target:
|
|
79
|
+
type: string
|
|
80
|
+
default: dist
|
|
81
|
+
aliases:
|
|
82
|
+
- t
|
|
83
|
+
desc: Output directory
|
|
84
|
+
verbose:
|
|
85
|
+
type: boolean
|
|
86
|
+
aliases:
|
|
87
|
+
- v
|
|
88
|
+
desc: Print verbose output
|
|
89
|
+
execute: make build TARGET={{target}}
|
|
58
90
|
```
|
|
59
91
|
|
|
92
|
+
```sh
|
|
93
|
+
caml build --target release
|
|
94
|
+
caml build -t release -v
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### Option-driven dispatch
|
|
98
|
+
|
|
99
|
+
When a boolean option has its own `execute`, that command runs instead of the task's default:
|
|
100
|
+
|
|
101
|
+
```yaml
|
|
102
|
+
start:
|
|
103
|
+
desc: Start the app
|
|
104
|
+
opts:
|
|
105
|
+
background:
|
|
106
|
+
type: boolean
|
|
107
|
+
aliases:
|
|
108
|
+
- b
|
|
109
|
+
desc: Run as a daemon
|
|
110
|
+
execute: app start --daemon
|
|
111
|
+
execute: app start
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
```sh
|
|
115
|
+
caml start # app start
|
|
116
|
+
caml start --background # app start --daemon
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Aliases
|
|
120
|
+
|
|
121
|
+
Add shortcut names for a task:
|
|
122
|
+
|
|
123
|
+
```yaml
|
|
124
|
+
test:
|
|
125
|
+
desc: Run the test suite
|
|
126
|
+
aliases:
|
|
127
|
+
- t
|
|
128
|
+
execute: bundle exec rspec
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
```sh
|
|
132
|
+
caml t # same as caml test
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## Dependencies
|
|
136
|
+
|
|
137
|
+
A task can declare prerequisites with `needs`. Deps run in declared order, each at most once per invocation; failures abort the run:
|
|
138
|
+
|
|
60
139
|
```yaml
|
|
61
|
-
build:
|
|
62
|
-
desc: Bundle
|
|
63
|
-
execute: bundle install
|
|
64
|
-
migrate:
|
|
65
|
-
desc: Migrate the test database
|
|
66
|
-
execute: rails db:migrate RAILS_ENV=test
|
|
67
140
|
test:
|
|
68
141
|
desc: Run tests
|
|
69
|
-
execute: rspec
|
|
142
|
+
execute: bundle exec rspec
|
|
143
|
+
|
|
144
|
+
lint:
|
|
145
|
+
desc: Check style
|
|
146
|
+
execute: bundle exec rubocop
|
|
147
|
+
|
|
148
|
+
ci:
|
|
149
|
+
desc: Lint and test
|
|
150
|
+
needs:
|
|
151
|
+
- lint
|
|
152
|
+
- test
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
```sh
|
|
156
|
+
caml ci # runs lint, then test
|
|
70
157
|
```
|
|
158
|
+
|
|
159
|
+
A task with only `needs:` and no `execute` is a pure orchestrator — useful for grouping.
|
|
160
|
+
|
|
161
|
+
## Discovery
|
|
162
|
+
|
|
163
|
+
`caml` walks up from the current directory to find a `caml.yaml`, just like `git`. You can run it from any subdirectory of your project.
|
|
164
|
+
|
|
165
|
+
## Built-in commands
|
|
166
|
+
|
|
167
|
+
| Command | Description |
|
|
168
|
+
|-------------------|----------------------------------------------------|
|
|
169
|
+
| `caml init` | Scaffold a starter `caml.yaml` |
|
|
170
|
+
| `caml --version` | Print the installed version |
|
|
171
|
+
| `caml help [cmd]` | Show help for a specific task (its args and opts) |
|
|
172
|
+
|
|
173
|
+
## License
|
|
174
|
+
|
|
175
|
+
MIT — see [LICENSE](LICENSE).
|
data/bin/caml
CHANGED
|
@@ -1,64 +1,30 @@
|
|
|
1
1
|
#!/usr/bin/env ruby
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
$LOAD_PATH.unshift File.expand_path('../lib', __dir__)
|
|
4
|
+
require 'caml'
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
# _ Add bash and ruby commands
|
|
11
|
-
# _ Turn into gem
|
|
12
|
-
# _ Distribute via CMM gem server
|
|
13
|
-
# _ Make some noise
|
|
14
|
-
# _ Make it better with Rust
|
|
15
|
-
# _ Distribute a release on GHE
|
|
16
|
-
# _ Make some more noise
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
class CamlCli < Thor
|
|
20
|
-
def self.load
|
|
21
|
-
Caml::Config.new.directives.each do |entry|
|
|
22
|
-
directive = build_directive(entry)
|
|
23
|
-
args = build_args(directive['args'])
|
|
24
|
-
opts = build_opts(directive['opts'])
|
|
25
|
-
command = build_command(directive.merge({'args' => args, 'opts' => opts}))
|
|
26
|
-
puts command
|
|
27
|
-
puts ''
|
|
28
|
-
class_eval command.to_s
|
|
29
|
-
end
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
private
|
|
33
|
-
|
|
34
|
-
def self.build_directive(entry)
|
|
35
|
-
entry.last.merge({'name' => entry.first, 'args' => entry.last['args'] || [], 'opts' => entry.last['opts'] || []})
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
def self.build_args(args)
|
|
39
|
-
args.reject { |arg| arg.empty? }
|
|
40
|
-
.map { |name, arg| arg.merge({'name' => name}) }
|
|
41
|
-
.map { |arg| Caml::Argument.new(arg) }
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
def self.build_opts(opts)
|
|
45
|
-
opts.reject { |opt| opt.empty? }
|
|
46
|
-
.map { |name, opt| opt.merge({'name' => name}) }
|
|
47
|
-
.map { |opt| Caml::Option.new(opt) }
|
|
48
|
-
end
|
|
6
|
+
if %w[--version -v].include?(ARGV.first)
|
|
7
|
+
puts Caml::VERSION
|
|
8
|
+
exit 0
|
|
9
|
+
end
|
|
49
10
|
|
|
50
|
-
|
|
51
|
-
Caml::Command.new(directive)
|
|
52
|
-
end
|
|
11
|
+
config_path = Caml::Config.discover(Dir.getwd)
|
|
53
12
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
13
|
+
if config_path
|
|
14
|
+
begin
|
|
15
|
+
config = Caml::Config.load(config_path)
|
|
16
|
+
rescue Caml::Config::Malformed => e
|
|
17
|
+
warn "caml: #{e.message}"
|
|
18
|
+
exit 1
|
|
59
19
|
end
|
|
60
20
|
|
|
21
|
+
runner = Caml::Runner.new(tasks: config.tasks, shell: Caml::Shell.new)
|
|
22
|
+
Caml::CLI.register(config: config, runner: runner)
|
|
23
|
+
elsif !ARGV.empty? && !%w[init help -h --help].include?(ARGV.first)
|
|
24
|
+
warn 'caml: no caml.yaml found in this directory or any parent.'
|
|
25
|
+
warn 'Run `caml init` to scaffold one.'
|
|
26
|
+
exit 1
|
|
61
27
|
end
|
|
62
28
|
|
|
63
|
-
|
|
64
|
-
|
|
29
|
+
result = Caml::CLI.start(ARGV)
|
|
30
|
+
exit(result == false ? 1 : 0)
|
data/lib/caml/cli.rb
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
require 'thor'
|
|
2
|
+
|
|
3
|
+
module Caml
|
|
4
|
+
class CLI < Thor
|
|
5
|
+
THOR_TYPES = {
|
|
6
|
+
'string' => :string,
|
|
7
|
+
'boolean' => :boolean,
|
|
8
|
+
'numeric' => :numeric
|
|
9
|
+
}.freeze
|
|
10
|
+
|
|
11
|
+
def self.exit_on_failure?
|
|
12
|
+
true
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
desc 'init', 'Scaffold a starter caml.yaml in the current directory'
|
|
16
|
+
def init
|
|
17
|
+
target = File.join(Dir.pwd, 'caml.yaml')
|
|
18
|
+
Init.scaffold(target)
|
|
19
|
+
say "Created #{target}", :green
|
|
20
|
+
rescue Init::Conflict => e
|
|
21
|
+
say "caml init: #{e.message}", :red
|
|
22
|
+
exit 1
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.register(config:, runner:)
|
|
26
|
+
config.tasks.each { |task| register_task(task, runner) }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.register_task(task, runner)
|
|
30
|
+
task.opts.each { |opt| register_option(opt) }
|
|
31
|
+
desc usage(task), task.desc.to_s
|
|
32
|
+
task.aliases.each { |alias_name| map alias_name => task.name.to_sym }
|
|
33
|
+
define_dispatcher(task, runner)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.define_dispatcher(task, runner)
|
|
37
|
+
define_method(task.name) do |*positional|
|
|
38
|
+
args_hash = task.args.each_with_index.to_h { |arg, i| [arg.name, positional[i]] }
|
|
39
|
+
runner.run(task.name, args: args_hash, opts: options.to_h)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def self.register_option(opt)
|
|
44
|
+
attrs = {
|
|
45
|
+
type: thor_type(opt.type),
|
|
46
|
+
desc: opt.desc.to_s,
|
|
47
|
+
aliases: opt.aliases.map { |a| "-#{a}" }
|
|
48
|
+
}
|
|
49
|
+
attrs[:default] = opt.default unless opt.default.nil?
|
|
50
|
+
method_option opt.name, **attrs
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.thor_type(yaml_type)
|
|
54
|
+
THOR_TYPES.fetch(yaml_type, :string)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def self.usage(task)
|
|
58
|
+
positional = task.args.map { |arg| arg.name.upcase }.join(' ')
|
|
59
|
+
positional.empty? ? task.name : "#{task.name} #{positional}"
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
data/lib/caml/config.rb
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
require 'safe_yaml/load'
|
|
2
|
+
|
|
3
|
+
module Caml
|
|
4
|
+
class Config
|
|
5
|
+
class NotFound < StandardError
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
class Malformed < StandardError
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
attr_reader :tasks
|
|
12
|
+
|
|
13
|
+
def self.load(path)
|
|
14
|
+
raise NotFound, "caml.yaml not found at #{path}" unless File.file?(path)
|
|
15
|
+
|
|
16
|
+
raw = parse(File.read(path), path)
|
|
17
|
+
from_raw(raw)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.discover(start_dir)
|
|
21
|
+
dir = File.expand_path(start_dir)
|
|
22
|
+
loop do
|
|
23
|
+
candidate = File.join(dir, 'caml.yaml')
|
|
24
|
+
return candidate if File.file?(candidate)
|
|
25
|
+
|
|
26
|
+
parent = File.dirname(dir)
|
|
27
|
+
return nil if parent == dir
|
|
28
|
+
|
|
29
|
+
dir = parent
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.from_tasks(tasks)
|
|
34
|
+
new(tasks)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def initialize(tasks)
|
|
38
|
+
@tasks = tasks
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.parse(content, path)
|
|
42
|
+
SafeYAML.load(content, nil, safe: true, raise_on_unknown_tag: true) || {}
|
|
43
|
+
rescue Psych::SyntaxError, SafeYAML::UnknownTagError => e
|
|
44
|
+
raise Malformed, "could not parse #{path}: #{e.message.sub(/\A\([^)]*\):\s*/, '')}"
|
|
45
|
+
end
|
|
46
|
+
private_class_method :parse
|
|
47
|
+
|
|
48
|
+
def self.from_raw(raw)
|
|
49
|
+
tasks = raw.map { |name, body| build_task(name, body || {}) }
|
|
50
|
+
new(tasks)
|
|
51
|
+
end
|
|
52
|
+
private_class_method :from_raw
|
|
53
|
+
|
|
54
|
+
def self.build_task(name, body)
|
|
55
|
+
Task.new(
|
|
56
|
+
name: name,
|
|
57
|
+
desc: body['desc'],
|
|
58
|
+
execute: body['execute'],
|
|
59
|
+
args: build_args(body['args']),
|
|
60
|
+
opts: build_opts(body['opts']),
|
|
61
|
+
aliases: body['aliases'] || [],
|
|
62
|
+
needs: body['needs'] || []
|
|
63
|
+
)
|
|
64
|
+
end
|
|
65
|
+
private_class_method :build_task
|
|
66
|
+
|
|
67
|
+
def self.build_args(raw)
|
|
68
|
+
return [] unless raw
|
|
69
|
+
|
|
70
|
+
raw.map { |name, fields| build_argument(name, fields || {}) }
|
|
71
|
+
end
|
|
72
|
+
private_class_method :build_args
|
|
73
|
+
|
|
74
|
+
def self.build_argument(name, fields)
|
|
75
|
+
attrs = { name: name, desc: fields['desc'] }
|
|
76
|
+
attrs[:type] = fields['type'] if fields['type']
|
|
77
|
+
Argument.new(**attrs)
|
|
78
|
+
end
|
|
79
|
+
private_class_method :build_argument
|
|
80
|
+
|
|
81
|
+
def self.build_opts(raw)
|
|
82
|
+
return [] unless raw
|
|
83
|
+
|
|
84
|
+
raw.map { |name, fields| build_option(name, fields || {}) }
|
|
85
|
+
end
|
|
86
|
+
private_class_method :build_opts
|
|
87
|
+
|
|
88
|
+
def self.build_option(name, fields)
|
|
89
|
+
attrs = { name: name, desc: fields['desc'] }
|
|
90
|
+
attrs[:type] = fields['type'] if fields['type']
|
|
91
|
+
attrs[:aliases] = fields['aliases'] if fields['aliases']
|
|
92
|
+
attrs[:default] = fields['default'] unless fields['default'].nil?
|
|
93
|
+
attrs[:execute] = fields['execute'] if fields['execute']
|
|
94
|
+
Option.new(**attrs)
|
|
95
|
+
end
|
|
96
|
+
private_class_method :build_option
|
|
97
|
+
end
|
|
98
|
+
end
|
data/lib/caml/init.rb
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
module Caml
|
|
2
|
+
module Init
|
|
3
|
+
class Conflict < StandardError
|
|
4
|
+
end
|
|
5
|
+
|
|
6
|
+
TEMPLATE = <<~YAML.freeze
|
|
7
|
+
hello:
|
|
8
|
+
desc: A starter task — replace me
|
|
9
|
+
execute: echo Hello, caml!
|
|
10
|
+
YAML
|
|
11
|
+
|
|
12
|
+
def self.scaffold(path)
|
|
13
|
+
raise Conflict, "#{path} already exists" if File.exist?(path)
|
|
14
|
+
|
|
15
|
+
File.write(path, TEMPLATE)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
require 'shellwords'
|
|
2
|
+
|
|
3
|
+
module Caml
|
|
4
|
+
module Interpolation
|
|
5
|
+
class Missing < StandardError
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
TOKEN = /\{\{(\w+)\}\}/
|
|
9
|
+
|
|
10
|
+
def self.apply(template, values)
|
|
11
|
+
template.gsub(TOKEN) do
|
|
12
|
+
name = Regexp.last_match(1)
|
|
13
|
+
raise Missing, "no value for {{#{name}}}" unless values.key?(name)
|
|
14
|
+
|
|
15
|
+
Shellwords.escape(values[name].to_s)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
data/lib/caml/option.rb
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
module Caml
|
|
2
|
+
class Option
|
|
3
|
+
attr_reader :name, :desc, :type, :aliases, :default, :execute
|
|
4
|
+
|
|
5
|
+
def initialize(name:, desc:, type: 'boolean', aliases: [], default: nil, execute: nil)
|
|
6
|
+
@name = name
|
|
7
|
+
@desc = desc
|
|
8
|
+
@type = type
|
|
9
|
+
@aliases = aliases
|
|
10
|
+
@default = default
|
|
11
|
+
@execute = execute
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
data/lib/caml/plan.rb
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
module Caml
|
|
2
|
+
module Plan
|
|
3
|
+
class Cycle < StandardError
|
|
4
|
+
end
|
|
5
|
+
|
|
6
|
+
class UnknownTask < StandardError
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def self.resolve(target_name, tasks)
|
|
10
|
+
index = tasks.to_h { |task| [task.name, task] }
|
|
11
|
+
state = { ordered: [], visiting: [], visited: {} }
|
|
12
|
+
visit(target_name, index, state)
|
|
13
|
+
state[:ordered]
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.visit(name, index, state)
|
|
17
|
+
return if state[:visited][name]
|
|
18
|
+
|
|
19
|
+
raise_if_cycle(state, name)
|
|
20
|
+
task = index[name] or raise UnknownTask, "no such task: #{name}"
|
|
21
|
+
|
|
22
|
+
state[:visiting] << name
|
|
23
|
+
task.needs.each { |dep| visit(dep, index, state) }
|
|
24
|
+
state[:visiting].pop
|
|
25
|
+
|
|
26
|
+
state[:visited][name] = true
|
|
27
|
+
state[:ordered] << task
|
|
28
|
+
end
|
|
29
|
+
private_class_method :visit
|
|
30
|
+
|
|
31
|
+
def self.raise_if_cycle(state, name)
|
|
32
|
+
return unless state[:visiting].include?(name)
|
|
33
|
+
|
|
34
|
+
path = state[:visiting].drop_while { |n| n != name } + [name]
|
|
35
|
+
raise Cycle, "cycle detected: #{path.join(' -> ')}"
|
|
36
|
+
end
|
|
37
|
+
private_class_method :raise_if_cycle
|
|
38
|
+
end
|
|
39
|
+
end
|
data/lib/caml/runner.rb
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
module Caml
|
|
2
|
+
class Runner
|
|
3
|
+
def initialize(tasks:, shell:)
|
|
4
|
+
@tasks = tasks
|
|
5
|
+
@shell = shell
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def run(name, args: {}, opts: {})
|
|
9
|
+
plan = Plan.resolve(name, @tasks)
|
|
10
|
+
plan.each do |task|
|
|
11
|
+
bindings = task.name == name ? { args: args, opts: opts } : { args: {}, opts: {} }
|
|
12
|
+
return false unless execute_task(task, **bindings)
|
|
13
|
+
end
|
|
14
|
+
true
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def execute_task(task, args:, opts:)
|
|
20
|
+
return true if task.execute.nil? || Array(task.execute).empty?
|
|
21
|
+
|
|
22
|
+
command = build_command(task, args, opts)
|
|
23
|
+
@shell.run(command)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def build_command(task, args, opts)
|
|
27
|
+
execute = effective_execute(task, opts)
|
|
28
|
+
bindings = args.merge(opts)
|
|
29
|
+
Array(execute).map { |step| Interpolation.apply(step, bindings) }.join(' && ')
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def effective_execute(task, opts)
|
|
33
|
+
override = task.opts.find { |opt| opt.execute && opts[opt.name] }
|
|
34
|
+
override ? override.execute : task.execute
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
data/lib/caml/shell.rb
ADDED
data/lib/caml/task.rb
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
module Caml
|
|
2
|
+
class Task
|
|
3
|
+
attr_reader :name, :desc, :execute, :args, :opts, :aliases, :needs
|
|
4
|
+
|
|
5
|
+
def initialize(name:, desc:, execute:, args: [], opts: [], aliases: [], needs: [])
|
|
6
|
+
@name = name
|
|
7
|
+
@desc = desc
|
|
8
|
+
@execute = execute
|
|
9
|
+
@args = args
|
|
10
|
+
@opts = opts
|
|
11
|
+
@aliases = aliases
|
|
12
|
+
@needs = needs
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
data/lib/caml/version.rb
ADDED
data/lib/caml.rb
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
require 'caml/version'
|
|
2
|
+
require 'caml/argument'
|
|
3
|
+
require 'caml/option'
|
|
4
|
+
require 'caml/task'
|
|
5
|
+
require 'caml/interpolation'
|
|
6
|
+
require 'caml/config'
|
|
7
|
+
require 'caml/plan'
|
|
8
|
+
require 'caml/shell'
|
|
9
|
+
require 'caml/runner'
|
|
10
|
+
require 'caml/init'
|
|
11
|
+
require 'caml/cli'
|
|
12
|
+
|
|
13
|
+
module Caml
|
|
14
|
+
end
|
metadata
CHANGED
|
@@ -1,62 +1,99 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: caml
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version:
|
|
4
|
+
version: 1.0.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ian Johnson
|
|
8
|
-
autorequire:
|
|
9
8
|
bindir: bin
|
|
10
9
|
cert_chain: []
|
|
11
|
-
date:
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
12
11
|
dependencies:
|
|
13
12
|
- !ruby/object:Gem::Dependency
|
|
14
|
-
name:
|
|
13
|
+
name: base64
|
|
15
14
|
requirement: !ruby/object:Gem::Requirement
|
|
16
15
|
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0.2'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '0.2'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: safe_yaml
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '1.0'
|
|
33
|
+
- - ">="
|
|
34
|
+
- !ruby/object:Gem::Version
|
|
35
|
+
version: 1.0.5
|
|
36
|
+
type: :runtime
|
|
37
|
+
prerelease: false
|
|
38
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
39
|
+
requirements:
|
|
40
|
+
- - "~>"
|
|
41
|
+
- !ruby/object:Gem::Version
|
|
42
|
+
version: '1.0'
|
|
17
43
|
- - ">="
|
|
18
44
|
- !ruby/object:Gem::Version
|
|
19
|
-
version:
|
|
20
|
-
|
|
45
|
+
version: 1.0.5
|
|
46
|
+
- !ruby/object:Gem::Dependency
|
|
47
|
+
name: thor
|
|
48
|
+
requirement: !ruby/object:Gem::Requirement
|
|
49
|
+
requirements:
|
|
50
|
+
- - "~>"
|
|
51
|
+
- !ruby/object:Gem::Version
|
|
52
|
+
version: '1.0'
|
|
53
|
+
- - ">="
|
|
54
|
+
- !ruby/object:Gem::Version
|
|
55
|
+
version: 1.0.1
|
|
56
|
+
type: :runtime
|
|
21
57
|
prerelease: false
|
|
22
58
|
version_requirements: !ruby/object:Gem::Requirement
|
|
23
59
|
requirements:
|
|
60
|
+
- - "~>"
|
|
61
|
+
- !ruby/object:Gem::Version
|
|
62
|
+
version: '1.0'
|
|
24
63
|
- - ">="
|
|
25
64
|
- !ruby/object:Gem::Version
|
|
26
|
-
version:
|
|
27
|
-
description:
|
|
28
|
-
|
|
29
|
-
`make`, but by using descriptive, declarative yaml.\n\n## Usage\n\nRunning the command
|
|
30
|
-
without any arguments displays the commands defined in the `caml.yaml` file:\n\n```sh\nbin/caml\n```\n\n##
|
|
31
|
-
Declaring commands\n\n`caml` reads a file called `caml.yaml` in the current directory
|
|
32
|
-
and converts those commands into a unified CLI command.\n\nThe basic structure is
|
|
33
|
-
to have a `command`, which has a `desc` and an `execute` for the bash command to
|
|
34
|
-
execute.\n\n```yaml\ncommand:\n desc: Command description\n execute: script.sh\n```\n\nThis
|
|
35
|
-
yaml will create the following command:\n\n```yaml\nbin/caml command # Command description\n```\n\nAnd
|
|
36
|
-
it will run any bash command defined.\n\nArguments may be added under `args` in
|
|
37
|
-
a nested fashion as displayed below.\n\n```yaml\ncommand:\n args:\n one:\n desc:
|
|
38
|
-
First argument\n type: string\n two:\n desc: Second argument\n type:
|
|
39
|
-
string\n```\n\n## Examples\n\n```yaml\nbuild:\n desc: Build our project\n execute:
|
|
40
|
-
make\nclean:\n desc: Clean our project\n execute: make clean\n```\n\n```yaml\nbuild:\n
|
|
41
|
-
\ desc: Bundle\n execute: bundle install\nmigrate:\n desc: Migrate the test database\n
|
|
42
|
-
\ execute: rails db:migrate RAILS_ENV=test\ntest:\n desc: Run tests\n execute:
|
|
43
|
-
rspec\n```\n"
|
|
65
|
+
version: 1.0.1
|
|
66
|
+
description: caml turns well-documented YAML tasks into a runnable CLI. Inspired by
|
|
67
|
+
make and just, but YAML-driven.
|
|
44
68
|
email: tacoda@hey.com
|
|
45
69
|
executables:
|
|
46
70
|
- caml
|
|
47
71
|
extensions: []
|
|
48
72
|
extra_rdoc_files: []
|
|
49
73
|
files:
|
|
74
|
+
- CHANGELOG.md
|
|
50
75
|
- LICENSE
|
|
51
76
|
- README.md
|
|
52
77
|
- bin/caml
|
|
53
|
-
- lib/caml
|
|
54
|
-
-
|
|
78
|
+
- lib/caml.rb
|
|
79
|
+
- lib/caml/argument.rb
|
|
80
|
+
- lib/caml/cli.rb
|
|
81
|
+
- lib/caml/config.rb
|
|
82
|
+
- lib/caml/init.rb
|
|
83
|
+
- lib/caml/interpolation.rb
|
|
84
|
+
- lib/caml/option.rb
|
|
85
|
+
- lib/caml/plan.rb
|
|
86
|
+
- lib/caml/runner.rb
|
|
87
|
+
- lib/caml/shell.rb
|
|
88
|
+
- lib/caml/task.rb
|
|
89
|
+
- lib/caml/version.rb
|
|
55
90
|
homepage: https://github.com/tacoda/caml
|
|
56
91
|
licenses:
|
|
57
92
|
- MIT
|
|
58
|
-
metadata:
|
|
59
|
-
|
|
93
|
+
metadata:
|
|
94
|
+
source_code_uri: https://github.com/tacoda/caml
|
|
95
|
+
changelog_uri: https://github.com/tacoda/caml/blob/main/CHANGELOG.md
|
|
96
|
+
rubygems_mfa_required: 'true'
|
|
60
97
|
rdoc_options: []
|
|
61
98
|
require_paths:
|
|
62
99
|
- lib
|
|
@@ -64,16 +101,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
64
101
|
requirements:
|
|
65
102
|
- - ">="
|
|
66
103
|
- !ruby/object:Gem::Version
|
|
67
|
-
version: '
|
|
104
|
+
version: '3.0'
|
|
68
105
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
69
106
|
requirements:
|
|
70
107
|
- - ">="
|
|
71
108
|
- !ruby/object:Gem::Version
|
|
72
109
|
version: '0'
|
|
73
110
|
requirements: []
|
|
74
|
-
rubygems_version:
|
|
75
|
-
signing_key:
|
|
111
|
+
rubygems_version: 4.0.3
|
|
76
112
|
specification_version: 4
|
|
77
|
-
summary: Build CLI apps
|
|
78
|
-
test_files:
|
|
79
|
-
- spec/caml/caml_spec.rb
|
|
113
|
+
summary: Build CLI apps from a declarative caml.yaml file
|
|
114
|
+
test_files: []
|
data/lib/caml/caml.rb
DELETED
|
@@ -1,107 +0,0 @@
|
|
|
1
|
-
require 'safe_yaml/load'
|
|
2
|
-
|
|
3
|
-
module Caml
|
|
4
|
-
# def self.wrap(object)
|
|
5
|
-
# if object.nil?
|
|
6
|
-
# []
|
|
7
|
-
# elsif object.respond_to?(:to_ary)
|
|
8
|
-
# object.to_ary || [object]
|
|
9
|
-
# else
|
|
10
|
-
# [object]
|
|
11
|
-
# end
|
|
12
|
-
# end
|
|
13
|
-
|
|
14
|
-
class Config
|
|
15
|
-
def initialize
|
|
16
|
-
# Recursively load (prompt to init if it doesn't exist)
|
|
17
|
-
SafeYAML::OPTIONS[:default_mode] = :safe
|
|
18
|
-
@config = YAML.load_file(File.join(Dir.getwd, 'caml.yaml'))
|
|
19
|
-
end
|
|
20
|
-
|
|
21
|
-
def directives
|
|
22
|
-
@config
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
def to_s
|
|
26
|
-
inspect
|
|
27
|
-
end
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
class Command
|
|
31
|
-
attr_reader :name, :desc, :aliases, :args, :opts, :execute
|
|
32
|
-
|
|
33
|
-
def initialize(options = {})
|
|
34
|
-
@name = options['name']
|
|
35
|
-
@desc = options['desc']
|
|
36
|
-
@aliases = options['aliases']
|
|
37
|
-
@args = options['args']
|
|
38
|
-
@opts = options['opts']
|
|
39
|
-
@execute = options['execute']
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
def dispatcher
|
|
43
|
-
if opts.empty?
|
|
44
|
-
executor
|
|
45
|
-
else
|
|
46
|
-
first_clause = "if options[:#{opts.first.name.to_sym}]\nexecute { \"#{opts.first.execute}\" }\n"
|
|
47
|
-
# else_clause = directive.execute.nil? ? "#\n" : "else\nexecute { \"#{wrap(directive.execute).join(';')}\" }\nend"
|
|
48
|
-
# clauses = directive.options.drop(1).map { |option| "elsif options[:#{option.name.to_sym}]\nexecute { \"#{wrap(option.execute).join(';')}\" }\n" }.join
|
|
49
|
-
# dispatcher = first_clause + clauses + else_clause
|
|
50
|
-
puts first_clause
|
|
51
|
-
executor
|
|
52
|
-
end
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
def executor
|
|
56
|
-
if execute.respond_to?(:join)
|
|
57
|
-
<<-EOS
|
|
58
|
-
execute do
|
|
59
|
-
<<-COMMANDS
|
|
60
|
-
#{execute.join("\n")}
|
|
61
|
-
COMMANDS
|
|
62
|
-
end
|
|
63
|
-
EOS
|
|
64
|
-
else
|
|
65
|
-
"execute { \"#{execute}\" }\n"
|
|
66
|
-
end
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
def to_s
|
|
70
|
-
descriptor = "desc '#{name}', '#{desc}'\n"
|
|
71
|
-
options = opts.empty? ? '' : opts.join("\n") + "\n"
|
|
72
|
-
signature = "def #{name}\n" # TODO: args
|
|
73
|
-
close_scope = "end\n"
|
|
74
|
-
descriptor + options + signature + executor + close_scope
|
|
75
|
-
end
|
|
76
|
-
end
|
|
77
|
-
|
|
78
|
-
class Option
|
|
79
|
-
attr_reader :name, :type, :desc, :aliases, :execute
|
|
80
|
-
|
|
81
|
-
def initialize(options = {})
|
|
82
|
-
@name = options['name']
|
|
83
|
-
@type = options['type']
|
|
84
|
-
@desc = options['desc']
|
|
85
|
-
@aliases = options['aliases']
|
|
86
|
-
@execute = options['execute']
|
|
87
|
-
end
|
|
88
|
-
|
|
89
|
-
def to_s
|
|
90
|
-
"option :#{name}, type: :#{type}, desc: '#{desc}'"
|
|
91
|
-
end
|
|
92
|
-
end
|
|
93
|
-
|
|
94
|
-
class Argument
|
|
95
|
-
attr_reader :name, :type, :desc, :value
|
|
96
|
-
|
|
97
|
-
def initialize(options ={})
|
|
98
|
-
@name = options['name']
|
|
99
|
-
@desc = options['desc']
|
|
100
|
-
@type = options['type']
|
|
101
|
-
end
|
|
102
|
-
|
|
103
|
-
def to_s
|
|
104
|
-
inspect
|
|
105
|
-
end
|
|
106
|
-
end
|
|
107
|
-
end
|