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.
Files changed (95) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +147 -2
  3. data/README.md +38 -28
  4. data/SPEC.md +84 -147
  5. data/docs/architecture.md +82 -28
  6. data/lib/textus/builder/pipeline.rb +56 -0
  7. data/lib/textus/builder/renderer/json.rb +42 -0
  8. data/lib/textus/builder/renderer/markdown.rb +22 -0
  9. data/lib/textus/builder/renderer/text.rb +14 -0
  10. data/lib/textus/builder/renderer/yaml.rb +42 -0
  11. data/lib/textus/builder/renderer.rb +17 -0
  12. data/lib/textus/builder.rb +9 -114
  13. data/lib/textus/cli/group/hook.rb +11 -0
  14. data/lib/textus/cli/group/key.rb +12 -0
  15. data/lib/textus/cli/group/schema.rb +13 -0
  16. data/lib/textus/cli/group.rb +51 -0
  17. data/lib/textus/cli/verb/accept.rb +15 -0
  18. data/lib/textus/cli/verb/build.rb +13 -0
  19. data/lib/textus/cli/verb/delete.rb +16 -0
  20. data/lib/textus/cli/verb/deps.rb +12 -0
  21. data/lib/textus/cli/verb/doctor.rb +15 -0
  22. data/lib/textus/cli/verb/get.rb +12 -0
  23. data/lib/textus/cli/verb/hook_run.rb +48 -0
  24. data/lib/textus/cli/verb/hooks.rb +50 -0
  25. data/lib/textus/cli/verb/init.rb +14 -0
  26. data/lib/textus/cli/verb/intro.rb +11 -0
  27. data/lib/textus/cli/verb/list.rb +14 -0
  28. data/lib/textus/cli/verb/migrate_keys.rb +16 -0
  29. data/lib/textus/cli/verb/mv.rb +17 -0
  30. data/lib/textus/cli/verb/published.rb +11 -0
  31. data/lib/textus/cli/verb/put.rb +50 -0
  32. data/lib/textus/cli/verb/rdeps.rb +12 -0
  33. data/lib/textus/cli/verb/refresh.rb +15 -0
  34. data/lib/textus/cli/verb/schema.rb +12 -0
  35. data/lib/textus/cli/verb/schema_diff.rb +12 -0
  36. data/lib/textus/cli/verb/schema_init.rb +16 -0
  37. data/lib/textus/cli/verb/schema_migrate.rb +16 -0
  38. data/lib/textus/cli/verb/stale.rb +14 -0
  39. data/lib/textus/cli/verb/uid.rb +12 -0
  40. data/lib/textus/cli/verb/where.rb +12 -0
  41. data/lib/textus/cli/verb.rb +62 -0
  42. data/lib/textus/cli.rb +44 -385
  43. data/lib/textus/doctor/check/audit_log.rb +50 -0
  44. data/lib/textus/doctor/check/hooks.rb +29 -0
  45. data/lib/textus/doctor/check/illegal_keys.rb +49 -0
  46. data/lib/textus/doctor/check/manifest_files.rb +38 -0
  47. data/lib/textus/doctor/check/schema_violations.rb +22 -0
  48. data/lib/textus/doctor/check/schemas.rb +26 -0
  49. data/lib/textus/doctor/check/sentinels.rb +57 -0
  50. data/lib/textus/doctor/check/templates.rb +26 -0
  51. data/lib/textus/doctor/check/unowned_schema_fields.rb +34 -0
  52. data/lib/textus/doctor/check.rb +30 -0
  53. data/lib/textus/doctor.rb +29 -264
  54. data/lib/textus/entry/base.rb +30 -0
  55. data/lib/textus/entry/json.rb +11 -5
  56. data/lib/textus/entry/markdown.rb +5 -5
  57. data/lib/textus/entry/text.rb +4 -4
  58. data/lib/textus/entry/yaml.rb +11 -5
  59. data/lib/textus/entry.rb +2 -7
  60. data/lib/textus/envelope.rb +30 -0
  61. data/lib/textus/errors.rb +2 -2
  62. data/lib/textus/hooks/builtin.rb +70 -0
  63. data/lib/textus/hooks/dispatcher.rb +49 -0
  64. data/lib/textus/hooks/loader.rb +26 -0
  65. data/lib/textus/hooks/registry.rb +73 -0
  66. data/lib/textus/init.rb +14 -11
  67. data/lib/textus/intro.rb +16 -18
  68. data/lib/textus/key/distance.rb +55 -0
  69. data/lib/textus/key/grammar.rb +33 -0
  70. data/lib/textus/key/path.rb +17 -0
  71. data/lib/textus/manifest/entry.rb +199 -0
  72. data/lib/textus/manifest.rb +20 -254
  73. data/lib/textus/migrate_keys.rb +1 -1
  74. data/lib/textus/projection.rb +6 -5
  75. data/lib/textus/proposal.rb +4 -4
  76. data/lib/textus/refresh.rb +17 -17
  77. data/lib/textus/schema/tools.rb +89 -0
  78. data/lib/textus/store/audit_log.rb +71 -0
  79. data/lib/textus/store/mover.rb +121 -0
  80. data/lib/textus/store/reader.rb +67 -0
  81. data/lib/textus/store/staleness.rb +133 -0
  82. data/lib/textus/store/validator.rb +56 -0
  83. data/lib/textus/store/view.rb +29 -0
  84. data/lib/textus/store/writer.rb +132 -0
  85. data/lib/textus/store.rb +26 -527
  86. data/lib/textus/version.rb +2 -2
  87. data/lib/textus.rb +14 -29
  88. metadata +78 -8
  89. data/lib/textus/audit_log.rb +0 -32
  90. data/lib/textus/builtin_actions.rb +0 -68
  91. data/lib/textus/extension_registry.rb +0 -61
  92. data/lib/textus/extensions.rb +0 -33
  93. data/lib/textus/key_distance.rb +0 -53
  94. data/lib/textus/schema_tools.rb +0 -87
  95. 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
@@ -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
- data =
63
- if mentry.projection
64
- Projection.new(@store, mentry.projection).run
65
- else
66
- { "entries" => [], "count" => 0, "generated_at" => Time.now.utc.iso8601 }
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
- # Markdown: projection -> template -> markdown.serialize(frontmatter, body).
87
- # Frontmatter carries the legacy `generated:` bookkeeping block. Per plan-1.2 §6,
88
- # `_meta` ordering applies to structured formats only; markdown keeps existing shape
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
- def render_template!(mentry, data)
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,11 @@
1
+ module Textus
2
+ class CLI
3
+ class Group
4
+ class Hook < Group
5
+ self.cli_name = "hook"
6
+ subcommands["list"] = Verb::Hooks
7
+ subcommands["run"] = Verb::HookRun
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,12 @@
1
+ module Textus
2
+ class CLI
3
+ class Group
4
+ class Key < Group
5
+ self.cli_name = "key"
6
+ subcommands["mv"] = Verb::Mv
7
+ subcommands["uid"] = Verb::Uid
8
+ subcommands["migrate"] = Verb::MigrateKeys
9
+ end
10
+ end
11
+ end
12
+ end
@@ -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,13 @@
1
+ module Textus
2
+ class CLI
3
+ class Verb
4
+ class Build < Verb
5
+ option :prefix, "--prefix=K"
6
+
7
+ def call(store)
8
+ emit(Textus::Builder.new(store).build(prefix: prefix))
9
+ end
10
+ end
11
+ end
12
+ end
13
+ 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,12 @@
1
+ module Textus
2
+ class CLI
3
+ class Verb
4
+ class Deps < Verb
5
+ def call(store)
6
+ key = positional.shift or raise UsageError.new("deps requires a key")
7
+ emit({ "key" => key, "deps" => store.deps(key) })
8
+ end
9
+ end
10
+ end
11
+ end
12
+ 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,12 @@
1
+ module Textus
2
+ class CLI
3
+ class Verb
4
+ class Get < Verb
5
+ def call(store)
6
+ key = positional.shift or raise UsageError.new("get requires a key")
7
+ emit(store.get(key))
8
+ end
9
+ end
10
+ end
11
+ end
12
+ 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
@@ -0,0 +1,14 @@
1
+ module Textus
2
+ class CLI
3
+ class Verb
4
+ class Init < Verb
5
+ def self.needs_store? = false
6
+
7
+ def call(_store)
8
+ target = File.join(@cwd, ".textus")
9
+ emit(Textus::Init.run(target))
10
+ end
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,11 @@
1
+ module Textus
2
+ class CLI
3
+ class Verb
4
+ class Intro < Verb
5
+ def call(store)
6
+ emit(Textus::Intro.run(store))
7
+ end
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,14 @@
1
+ module Textus
2
+ class CLI
3
+ class Verb
4
+ class List < Verb
5
+ option :prefix, "--prefix=KEY"
6
+ option :zone, "--zone=Z"
7
+
8
+ def call(store)
9
+ emit({ "entries" => store.list(prefix: prefix, zone: zone) })
10
+ end
11
+ end
12
+ end
13
+ end
14
+ end