textus 0.4.0 → 0.8.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 +147 -2
- data/README.md +38 -28
- data/SPEC.md +84 -147
- data/docs/architecture.md +82 -28
- data/lib/textus/builder/pipeline.rb +56 -0
- data/lib/textus/builder/renderer/json.rb +42 -0
- data/lib/textus/builder/renderer/markdown.rb +22 -0
- data/lib/textus/builder/renderer/text.rb +14 -0
- data/lib/textus/builder/renderer/yaml.rb +42 -0
- data/lib/textus/builder/renderer.rb +17 -0
- data/lib/textus/builder.rb +9 -114
- data/lib/textus/cli/group/hook.rb +11 -0
- data/lib/textus/cli/group/key.rb +12 -0
- data/lib/textus/cli/group/schema.rb +13 -0
- data/lib/textus/cli/group.rb +51 -0
- data/lib/textus/cli/verb/accept.rb +15 -0
- data/lib/textus/cli/verb/build.rb +13 -0
- data/lib/textus/cli/verb/delete.rb +16 -0
- data/lib/textus/cli/verb/deps.rb +12 -0
- data/lib/textus/cli/verb/doctor.rb +15 -0
- data/lib/textus/cli/verb/get.rb +12 -0
- data/lib/textus/cli/verb/hook_run.rb +48 -0
- data/lib/textus/cli/verb/hooks.rb +50 -0
- data/lib/textus/cli/verb/init.rb +14 -0
- data/lib/textus/cli/verb/intro.rb +11 -0
- data/lib/textus/cli/verb/list.rb +14 -0
- data/lib/textus/cli/verb/migrate_keys.rb +16 -0
- data/lib/textus/cli/verb/mv.rb +17 -0
- data/lib/textus/cli/verb/published.rb +11 -0
- data/lib/textus/cli/verb/put.rb +50 -0
- data/lib/textus/cli/verb/rdeps.rb +12 -0
- data/lib/textus/cli/verb/refresh.rb +15 -0
- data/lib/textus/cli/verb/schema.rb +12 -0
- data/lib/textus/cli/verb/schema_diff.rb +12 -0
- data/lib/textus/cli/verb/schema_init.rb +16 -0
- data/lib/textus/cli/verb/schema_migrate.rb +16 -0
- data/lib/textus/cli/verb/stale.rb +14 -0
- data/lib/textus/cli/verb/uid.rb +12 -0
- data/lib/textus/cli/verb/where.rb +12 -0
- data/lib/textus/cli/verb.rb +62 -0
- data/lib/textus/cli.rb +44 -385
- data/lib/textus/doctor/check/audit_log.rb +50 -0
- data/lib/textus/doctor/check/hooks.rb +29 -0
- data/lib/textus/doctor/check/illegal_keys.rb +49 -0
- data/lib/textus/doctor/check/manifest_files.rb +38 -0
- data/lib/textus/doctor/check/schema_violations.rb +22 -0
- data/lib/textus/doctor/check/schemas.rb +26 -0
- data/lib/textus/doctor/check/sentinels.rb +57 -0
- data/lib/textus/doctor/check/templates.rb +26 -0
- data/lib/textus/doctor/check/unowned_schema_fields.rb +34 -0
- data/lib/textus/doctor/check.rb +30 -0
- data/lib/textus/doctor.rb +29 -264
- data/lib/textus/entry/base.rb +30 -0
- data/lib/textus/entry/json.rb +11 -5
- data/lib/textus/entry/markdown.rb +5 -5
- data/lib/textus/entry/text.rb +4 -4
- data/lib/textus/entry/yaml.rb +11 -5
- data/lib/textus/entry.rb +2 -7
- data/lib/textus/envelope.rb +30 -0
- data/lib/textus/errors.rb +2 -2
- data/lib/textus/hooks/builtin.rb +70 -0
- data/lib/textus/hooks/dispatcher.rb +49 -0
- data/lib/textus/hooks/loader.rb +26 -0
- data/lib/textus/hooks/registry.rb +73 -0
- data/lib/textus/init.rb +14 -11
- data/lib/textus/intro.rb +16 -18
- data/lib/textus/key/distance.rb +55 -0
- data/lib/textus/key/grammar.rb +33 -0
- data/lib/textus/key/path.rb +17 -0
- data/lib/textus/manifest/entry.rb +199 -0
- data/lib/textus/manifest.rb +20 -254
- data/lib/textus/migrate_keys.rb +1 -1
- data/lib/textus/projection.rb +6 -5
- data/lib/textus/proposal.rb +4 -4
- data/lib/textus/refresh.rb +17 -17
- data/lib/textus/schema/tools.rb +89 -0
- data/lib/textus/store/audit_log.rb +71 -0
- data/lib/textus/store/mover.rb +121 -0
- data/lib/textus/store/reader.rb +67 -0
- data/lib/textus/store/staleness.rb +133 -0
- data/lib/textus/store/validator.rb +56 -0
- data/lib/textus/store/view.rb +29 -0
- data/lib/textus/store/writer.rb +132 -0
- data/lib/textus/store.rb +26 -527
- data/lib/textus/version.rb +2 -2
- data/lib/textus.rb +14 -29
- metadata +78 -8
- data/lib/textus/audit_log.rb +0 -32
- data/lib/textus/builtin_actions.rb +0 -68
- data/lib/textus/extension_registry.rb +0 -61
- data/lib/textus/extensions.rb +0 -33
- data/lib/textus/key_distance.rb +0 -53
- data/lib/textus/schema_tools.rb +0 -87
- data/lib/textus/store_view.rb +0 -27
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
require "time"
|
|
3
|
+
|
|
4
|
+
module Textus
|
|
5
|
+
class Builder
|
|
6
|
+
module InjectMeta
|
|
7
|
+
# Returns a new hash with _meta as the first key, per SPEC §6 ordering.
|
|
8
|
+
def self.call(content_hash, mentry)
|
|
9
|
+
meta = { "generated_at" => Time.now.utc.iso8601 }
|
|
10
|
+
from = Array(mentry.projection&.fetch("select", nil)).compact
|
|
11
|
+
meta["from"] = from unless from.empty?
|
|
12
|
+
meta["template"] = mentry.template if mentry.template
|
|
13
|
+
reduce = mentry.projection&.dig("reduce")
|
|
14
|
+
meta["reduce"] = reduce if reduce
|
|
15
|
+
|
|
16
|
+
out = { "_meta" => meta }
|
|
17
|
+
content_hash.each { |k, v| out[k] = v unless k == "_meta" }
|
|
18
|
+
out
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
module Pipeline
|
|
23
|
+
def self.renderers
|
|
24
|
+
@renderers ||= {
|
|
25
|
+
"markdown" => Renderer::Markdown,
|
|
26
|
+
"text" => Renderer::Text,
|
|
27
|
+
"json" => Renderer::Json,
|
|
28
|
+
"yaml" => Renderer::Yaml,
|
|
29
|
+
}
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.run(store:, mentry:, template_loader:)
|
|
33
|
+
# 1. Load sources + project + reduce
|
|
34
|
+
data =
|
|
35
|
+
if mentry.projection
|
|
36
|
+
Projection.new(store, mentry.projection).run
|
|
37
|
+
else
|
|
38
|
+
{ "entries" => [], "count" => 0, "generated_at" => Time.now.utc.iso8601 }
|
|
39
|
+
end
|
|
40
|
+
data = data.merge("intro" => Intro.run(store)) if mentry.inject_intro
|
|
41
|
+
|
|
42
|
+
# 2. Render
|
|
43
|
+
klass = renderers[mentry.format] or
|
|
44
|
+
raise UsageError.new("builder: unsupported format #{mentry.format.inspect} for '#{mentry.key}'")
|
|
45
|
+
bytes = klass.new(template_loader: template_loader).call(mentry: mentry, data: data)
|
|
46
|
+
|
|
47
|
+
# 3. Write
|
|
48
|
+
target_path = Key::Path.resolve(store.manifest, mentry)
|
|
49
|
+
FileUtils.mkdir_p(File.dirname(target_path))
|
|
50
|
+
File.binwrite(target_path, bytes)
|
|
51
|
+
|
|
52
|
+
target_path
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
class Builder
|
|
5
|
+
class Renderer
|
|
6
|
+
class Json < Renderer
|
|
7
|
+
def call(mentry:, data:)
|
|
8
|
+
content = mentry.template ? parse_rendered_template!(mentry, data) : default_shape(mentry, data)
|
|
9
|
+
final = InjectMeta.call(content, mentry)
|
|
10
|
+
Entry.for_format("json").serialize(meta: {}, body: "", content: final)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
def parse_rendered_template!(mentry, data)
|
|
16
|
+
rendered = Mustache.render(@template_loader.call(mentry.template), data)
|
|
17
|
+
begin
|
|
18
|
+
parsed = ::JSON.parse(rendered)
|
|
19
|
+
rescue ::JSON::ParserError => e
|
|
20
|
+
raise BadRender.new("entry '#{mentry.key}': template did not render valid json: #{e.message}", format: "json")
|
|
21
|
+
end
|
|
22
|
+
unless parsed.is_a?(Hash)
|
|
23
|
+
raise BadRender.new("entry '#{mentry.key}': template must render a top-level object/mapping",
|
|
24
|
+
format: "json")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
parsed
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def default_shape(mentry, data)
|
|
31
|
+
if mentry.projection && mentry.projection["reduce"] && data.is_a?(Hash) && !data.key?("entries")
|
|
32
|
+
data
|
|
33
|
+
elsif data.is_a?(Hash) && data["entries"].is_a?(Array)
|
|
34
|
+
{ "entries" => data["entries"] }
|
|
35
|
+
else
|
|
36
|
+
data.is_a?(Hash) ? data : { "entries" => Array(data) }
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
require "time"
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
class Builder
|
|
5
|
+
class Renderer
|
|
6
|
+
class Markdown < Renderer
|
|
7
|
+
def call(mentry:, data:)
|
|
8
|
+
raise TemplateError.new("entry '#{mentry.key}': markdown build requires a template") unless mentry.template
|
|
9
|
+
|
|
10
|
+
body = Mustache.render(@template_loader.call(mentry.template), data)
|
|
11
|
+
frontmatter = {
|
|
12
|
+
"generated" => {
|
|
13
|
+
"at" => Time.now.utc.iso8601,
|
|
14
|
+
"from" => Array(mentry.projection&.fetch("select", nil)).compact,
|
|
15
|
+
},
|
|
16
|
+
}
|
|
17
|
+
Entry.for_format("markdown").serialize(meta: frontmatter, body: body)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class Builder
|
|
3
|
+
class Renderer
|
|
4
|
+
class Text < Renderer
|
|
5
|
+
def call(mentry:, data:)
|
|
6
|
+
raise TemplateError.new("entry '#{mentry.key}': text build requires a template") unless mentry.template
|
|
7
|
+
|
|
8
|
+
body = Mustache.render(@template_loader.call(mentry.template), data)
|
|
9
|
+
Entry.for_format("text").serialize(meta: {}, body: body)
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
require "yaml"
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
class Builder
|
|
5
|
+
class Renderer
|
|
6
|
+
class Yaml < Renderer
|
|
7
|
+
def call(mentry:, data:)
|
|
8
|
+
content = mentry.template ? parse_rendered_template!(mentry, data) : default_shape(mentry, data)
|
|
9
|
+
final = InjectMeta.call(content, mentry)
|
|
10
|
+
Entry.for_format("yaml").serialize(meta: {}, body: "", content: final)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
def parse_rendered_template!(mentry, data)
|
|
16
|
+
rendered = Mustache.render(@template_loader.call(mentry.template), data)
|
|
17
|
+
begin
|
|
18
|
+
parsed = ::YAML.safe_load(rendered, permitted_classes: [Date, Time], aliases: false)
|
|
19
|
+
rescue Psych::SyntaxError, Psych::DisallowedClass, Psych::AliasesNotEnabled => e
|
|
20
|
+
raise BadRender.new("entry '#{mentry.key}': template did not render valid yaml: #{e.message}", format: "yaml")
|
|
21
|
+
end
|
|
22
|
+
unless parsed.is_a?(Hash)
|
|
23
|
+
raise BadRender.new("entry '#{mentry.key}': template must render a top-level object/mapping",
|
|
24
|
+
format: "yaml")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
parsed
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def default_shape(mentry, data)
|
|
31
|
+
if mentry.projection && mentry.projection["reduce"] && data.is_a?(Hash) && !data.key?("entries")
|
|
32
|
+
data
|
|
33
|
+
elsif data.is_a?(Hash) && data["entries"].is_a?(Array)
|
|
34
|
+
{ "entries" => data["entries"] }
|
|
35
|
+
else
|
|
36
|
+
data.is_a?(Hash) ? data : { "entries" => Array(data) }
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class Builder
|
|
3
|
+
# Abstract base for output renderers. Each concrete renderer owns
|
|
4
|
+
# producing the bytes for one manifest format (markdown/json/yaml/text).
|
|
5
|
+
class Renderer
|
|
6
|
+
def initialize(template_loader:)
|
|
7
|
+
@template_loader = template_loader
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def call(mentry:, data:)
|
|
11
|
+
_ = mentry
|
|
12
|
+
_ = data
|
|
13
|
+
raise NotImplementedError.new("#{self.class.name}#call not implemented")
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
data/lib/textus/builder.rb
CHANGED
|
@@ -1,7 +1,4 @@
|
|
|
1
1
|
require "fileutils"
|
|
2
|
-
require "json"
|
|
3
|
-
require "time"
|
|
4
|
-
require "yaml"
|
|
5
2
|
|
|
6
3
|
module Textus
|
|
7
4
|
class Builder
|
|
@@ -59,122 +56,20 @@ module Textus
|
|
|
59
56
|
end
|
|
60
57
|
|
|
61
58
|
def materialize(mentry)
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
bytes =
|
|
70
|
-
case mentry.format
|
|
71
|
-
when "markdown" then build_markdown(mentry, data)
|
|
72
|
-
when "text" then build_text(mentry, data)
|
|
73
|
-
when "json" then build_structured(mentry, data, "json")
|
|
74
|
-
when "yaml" then build_structured(mentry, data, "yaml")
|
|
75
|
-
else raise UsageError.new("builder: unsupported format #{mentry.format.inspect} for '#{mentry.key}'")
|
|
76
|
-
end
|
|
77
|
-
|
|
78
|
-
target_path = File.join(@root, "zones", mentry.path)
|
|
79
|
-
FileUtils.mkdir_p(File.dirname(target_path))
|
|
80
|
-
File.binwrite(target_path, bytes)
|
|
81
|
-
|
|
59
|
+
target_path = Pipeline.run(
|
|
60
|
+
store: @store,
|
|
61
|
+
mentry: mentry,
|
|
62
|
+
template_loader: ->(name) { read_template(name) },
|
|
63
|
+
)
|
|
82
64
|
publish_and_fire(mentry, target_path)
|
|
83
65
|
{ "key" => mentry.key, "path" => target_path, "published_to" => mentry.publish_to }
|
|
84
66
|
end
|
|
85
67
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
# for backward compat with consumers reading frontmatter["generated"]["at"].
|
|
90
|
-
def build_markdown(mentry, data)
|
|
91
|
-
data = data.merge("intro" => Intro.run(@store)) if mentry.inject_intro
|
|
92
|
-
body = render_template!(mentry, data)
|
|
93
|
-
frontmatter = {
|
|
94
|
-
"generated" => {
|
|
95
|
-
"at" => Time.now.utc.iso8601,
|
|
96
|
-
"from" => Array(mentry.projection&.fetch("select", nil)).compact,
|
|
97
|
-
},
|
|
98
|
-
}
|
|
99
|
-
Entry.for_format("markdown").serialize(frontmatter: frontmatter, body: body)
|
|
100
|
-
end
|
|
101
|
-
|
|
102
|
-
# Text: projection -> template -> text.serialize(body). No frontmatter, no _meta.
|
|
103
|
-
def build_text(mentry, data)
|
|
104
|
-
data = data.merge("intro" => Intro.run(@store)) if mentry.inject_intro
|
|
105
|
-
body = render_template!(mentry, data)
|
|
106
|
-
Entry.for_format("text").serialize(frontmatter: {}, body: body)
|
|
107
|
-
end
|
|
108
|
-
|
|
109
|
-
# JSON / YAML pipeline. Templateless = default; template = escape hatch.
|
|
110
|
-
def build_structured(mentry, data, format)
|
|
111
|
-
strategy = Entry.for_format(format)
|
|
112
|
-
|
|
113
|
-
content =
|
|
114
|
-
if mentry.template
|
|
115
|
-
parse_rendered_template!(mentry, data, format)
|
|
116
|
-
else
|
|
117
|
-
# Default rule: if the reducer returned a Hash (it replaced `rows`), use it as-is.
|
|
118
|
-
# Otherwise wrap the entries list as { "entries" => [...] } so the top level is a Hash
|
|
119
|
-
# (required to carry _meta).
|
|
120
|
-
if mentry.projection && mentry.projection["reducer"] && data.is_a?(Hash) && !data.key?("entries")
|
|
121
|
-
data
|
|
122
|
-
elsif data.is_a?(Hash) && data["entries"].is_a?(Array)
|
|
123
|
-
{ "entries" => data["entries"] }
|
|
124
|
-
else
|
|
125
|
-
data.is_a?(Hash) ? data : { "entries" => Array(data) }
|
|
126
|
-
end
|
|
127
|
-
end
|
|
128
|
-
|
|
129
|
-
final = inject_meta(content, mentry)
|
|
130
|
-
strategy.serialize(frontmatter: {}, body: "", content: final)
|
|
131
|
-
end
|
|
68
|
+
def read_template(name)
|
|
69
|
+
tpl_path = File.join(@root, "templates", name)
|
|
70
|
+
raise TemplateError.new("template not found: #{tpl_path}", template_name: name) unless File.exist?(tpl_path)
|
|
132
71
|
|
|
133
|
-
|
|
134
|
-
raise TemplateError.new("entry '#{mentry.key}': #{mentry.format} build requires a template") unless mentry.template
|
|
135
|
-
|
|
136
|
-
tpl_path = File.join(@root, "templates", mentry.template)
|
|
137
|
-
raise TemplateError.new("template not found: #{tpl_path}", template_name: mentry.template) unless File.exist?(tpl_path)
|
|
138
|
-
|
|
139
|
-
Mustache.render(File.read(tpl_path), data)
|
|
140
|
-
end
|
|
141
|
-
|
|
142
|
-
def parse_rendered_template!(mentry, data, format)
|
|
143
|
-
tpl_path = File.join(@root, "templates", mentry.template)
|
|
144
|
-
raise TemplateError.new("template not found: #{tpl_path}", template_name: mentry.template) unless File.exist?(tpl_path)
|
|
145
|
-
|
|
146
|
-
rendered = Mustache.render(File.read(tpl_path), data)
|
|
147
|
-
begin
|
|
148
|
-
parsed =
|
|
149
|
-
case format
|
|
150
|
-
when "json" then ::JSON.parse(rendered)
|
|
151
|
-
when "yaml" then ::YAML.safe_load(rendered, permitted_classes: [Date, Time], aliases: false)
|
|
152
|
-
end
|
|
153
|
-
rescue ::JSON::ParserError, Psych::SyntaxError, Psych::DisallowedClass, Psych::AliasesNotEnabled => e
|
|
154
|
-
raise BadRender.new("entry '#{mentry.key}': template did not render valid #{format}: #{e.message}", format: format)
|
|
155
|
-
end
|
|
156
|
-
unless parsed.is_a?(Hash)
|
|
157
|
-
raise BadRender.new("entry '#{mentry.key}': template must render a top-level object/mapping",
|
|
158
|
-
format: format)
|
|
159
|
-
end
|
|
160
|
-
|
|
161
|
-
parsed
|
|
162
|
-
end
|
|
163
|
-
|
|
164
|
-
# Builds the _meta block per §6 ordering and inserts it as the first top-level key.
|
|
165
|
-
def inject_meta(content_hash, mentry)
|
|
166
|
-
meta = {}
|
|
167
|
-
meta["generated_at"] = Time.now.utc.iso8601
|
|
168
|
-
from = Array(mentry.projection&.fetch("select", nil)).compact
|
|
169
|
-
meta["from"] = from unless from.empty?
|
|
170
|
-
meta["template"] = mentry.template if mentry.template
|
|
171
|
-
reducer = mentry.projection&.dig("reducer")
|
|
172
|
-
meta["reducer"] = reducer if reducer
|
|
173
|
-
|
|
174
|
-
# Rebuild so _meta appears first; user content follows.
|
|
175
|
-
out = { "_meta" => meta }
|
|
176
|
-
content_hash.each { |k, v| out[k] = v unless k == "_meta" }
|
|
177
|
-
out
|
|
72
|
+
File.read(tpl_path)
|
|
178
73
|
end
|
|
179
74
|
|
|
180
75
|
def publish_and_fire(mentry, target_path)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class CLI
|
|
3
|
+
class Group
|
|
4
|
+
class Schema < Group
|
|
5
|
+
self.cli_name = "schema"
|
|
6
|
+
subcommands["show"] = Verb::Schema
|
|
7
|
+
subcommands["init"] = Verb::SchemaInit
|
|
8
|
+
subcommands["diff"] = Verb::SchemaDiff
|
|
9
|
+
subcommands["migrate"] = Verb::SchemaMigrate
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class CLI
|
|
3
|
+
class Group < Verb
|
|
4
|
+
class << self
|
|
5
|
+
def subcommands
|
|
6
|
+
@subcommands ||= {}
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def cli_name
|
|
10
|
+
@cli_name || raise("subclass must define cli_name")
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
attr_writer :cli_name
|
|
14
|
+
|
|
15
|
+
def inherited(subclass)
|
|
16
|
+
super
|
|
17
|
+
subclass.instance_variable_set(:@subcommands, {})
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def needs_store?
|
|
21
|
+
# Delegate to the matched subcommand at parse time; default true.
|
|
22
|
+
true
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def parse(argv)
|
|
27
|
+
subname = argv.shift
|
|
28
|
+
if subname.nil?
|
|
29
|
+
raise UsageError.new(
|
|
30
|
+
"#{self.class.cli_name} requires a subcommand: #{self.class.subcommands.keys.join(", ")}",
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
@sub_klass = self.class.subcommands[subname]
|
|
35
|
+
unless @sub_klass
|
|
36
|
+
raise UsageError.new(
|
|
37
|
+
"unknown #{self.class.cli_name} subcommand '#{subname}'. " \
|
|
38
|
+
"Valid: #{self.class.subcommands.keys.join(", ")}",
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
@sub = @sub_klass.new(stdin: @stdin, stdout: @stdout, stderr: @stderr, cwd: @cwd)
|
|
43
|
+
@sub.parse(argv)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def call(store)
|
|
47
|
+
@sub.call(@sub_klass.needs_store? ? store : nil)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class CLI
|
|
3
|
+
class Verb
|
|
4
|
+
class Accept < Verb
|
|
5
|
+
option :as_flag, "--as=ROLE"
|
|
6
|
+
|
|
7
|
+
def call(store)
|
|
8
|
+
key = positional.shift or raise UsageError.new("accept requires a key")
|
|
9
|
+
role = Role.resolve(flag: as_flag, env: ENV, root: store.root)
|
|
10
|
+
emit(store.accept(key, as: role))
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class CLI
|
|
3
|
+
class Verb
|
|
4
|
+
class Delete < Verb
|
|
5
|
+
option :as_flag, "--as=ROLE"
|
|
6
|
+
option :if_etag, "--if-etag=E"
|
|
7
|
+
|
|
8
|
+
def call(store)
|
|
9
|
+
key = positional.shift or raise UsageError.new("delete requires a key")
|
|
10
|
+
role = Role.resolve(flag: as_flag, env: ENV, root: store.root)
|
|
11
|
+
emit(store.delete(key, if_etag: if_etag, as: role))
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class CLI
|
|
3
|
+
class Verb
|
|
4
|
+
class Doctor < Verb
|
|
5
|
+
option :checks, "--check=NAME"
|
|
6
|
+
|
|
7
|
+
def call(store)
|
|
8
|
+
check_list = checks&.split(",")&.map(&:strip)
|
|
9
|
+
res = Textus::Doctor.run(store, checks: check_list)
|
|
10
|
+
emit(res, exit_code: res["ok"] ? 0 : 1)
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class CLI
|
|
3
|
+
class Verb
|
|
4
|
+
class HookRun < Verb
|
|
5
|
+
def parse(argv)
|
|
6
|
+
@raw_argv = argv
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def call(store)
|
|
10
|
+
name = @raw_argv.shift
|
|
11
|
+
raise UsageError.new("hook run requires a name") if name.nil?
|
|
12
|
+
|
|
13
|
+
as_flag = nil
|
|
14
|
+
args = {}
|
|
15
|
+
@raw_argv.each do |tok|
|
|
16
|
+
case tok
|
|
17
|
+
when /\A--as=(.+)\z/ then as_flag = ::Regexp.last_match(1)
|
|
18
|
+
when /\A--format=/ then next
|
|
19
|
+
when /\A--([\w-]+)=(.*)\z/ then args[::Regexp.last_match(1)] = ::Regexp.last_match(2)
|
|
20
|
+
else
|
|
21
|
+
raise UsageError.new("unknown arg to 'hook run #{name}': #{tok}")
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
role = Role.resolve(flag: as_flag, env: ENV, root: store.root)
|
|
26
|
+
callable = store.registry.rpc_callable(:fetch, name)
|
|
27
|
+
view = Store::View.new(store, writable: true, as: role)
|
|
28
|
+
|
|
29
|
+
begin
|
|
30
|
+
Timeout.timeout(Textus::Refresh::FETCH_TIMEOUT_SECONDS) do
|
|
31
|
+
callable.call(config: {}, store: view, args: args)
|
|
32
|
+
end
|
|
33
|
+
rescue Timeout::Error
|
|
34
|
+
raise UsageError.new(
|
|
35
|
+
"hook run '#{name}' exceeded #{Textus::Refresh::FETCH_TIMEOUT_SECONDS}s timeout",
|
|
36
|
+
)
|
|
37
|
+
rescue Textus::Error
|
|
38
|
+
raise
|
|
39
|
+
rescue StandardError => e
|
|
40
|
+
raise UsageError.new("hook run '#{name}' raised: #{e.class}: #{e.message}")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
emit({ "action" => name, "ok" => true })
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class CLI
|
|
3
|
+
class Verb
|
|
4
|
+
class Hooks < Verb
|
|
5
|
+
option :event_filter, "--event=E"
|
|
6
|
+
|
|
7
|
+
def call(store) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity
|
|
8
|
+
subcommand = positional.first
|
|
9
|
+
if subcommand
|
|
10
|
+
raise UsageError.new("hook requires 'list'") unless subcommand == "list"
|
|
11
|
+
|
|
12
|
+
positional.shift
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
rows = []
|
|
16
|
+
Textus::Hooks::Registry::EVENTS.each do |event, spec|
|
|
17
|
+
mode = spec[:mode].to_s
|
|
18
|
+
case spec[:mode]
|
|
19
|
+
when :rpc
|
|
20
|
+
store.registry.rpc_names(event).each do |name|
|
|
21
|
+
rows << { "event" => event.to_s, "mode" => mode, "name" => name.to_s }
|
|
22
|
+
end
|
|
23
|
+
when :pubsub
|
|
24
|
+
store.registry.pubsub_handlers(event).each do |h|
|
|
25
|
+
row = { "event" => event.to_s, "mode" => mode, "name" => h[:name].to_s }
|
|
26
|
+
row["keys"] = Array(h[:keys]) if h[:keys]
|
|
27
|
+
rows << row
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
store.manifest.entries.each do |e|
|
|
32
|
+
e.events.each do |evt, defs|
|
|
33
|
+
Array(defs).each do |defn|
|
|
34
|
+
next unless defn["exec"]
|
|
35
|
+
|
|
36
|
+
rows << {
|
|
37
|
+
"event" => evt.to_s, "mode" => "manifest", "exec" => defn["exec"],
|
|
38
|
+
"key" => e.key, "as" => defn["as"] || "script"
|
|
39
|
+
}
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
rows.select! { |r| r["event"] == event_filter } if event_filter
|
|
44
|
+
|
|
45
|
+
emit({ "hooks" => rows })
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|