textus 0.5.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 (124) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +83 -1
  3. data/README.md +29 -21
  4. data/SPEC.md +75 -142
  5. data/docs/architecture.md +42 -23
  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/verb/accept.rb +15 -0
  17. data/lib/textus/cli/verb/build.rb +13 -0
  18. data/lib/textus/cli/verb/delete.rb +16 -0
  19. data/lib/textus/cli/verb/deps.rb +12 -0
  20. data/lib/textus/cli/verb/doctor.rb +15 -0
  21. data/lib/textus/cli/verb/get.rb +12 -0
  22. data/lib/textus/cli/verb/hook_run.rb +48 -0
  23. data/lib/textus/cli/verb/hooks.rb +50 -0
  24. data/lib/textus/cli/verb/init.rb +14 -0
  25. data/lib/textus/cli/verb/intro.rb +11 -0
  26. data/lib/textus/cli/verb/list.rb +14 -0
  27. data/lib/textus/cli/verb/migrate_keys.rb +16 -0
  28. data/lib/textus/cli/verb/mv.rb +17 -0
  29. data/lib/textus/cli/verb/published.rb +11 -0
  30. data/lib/textus/cli/verb/put.rb +50 -0
  31. data/lib/textus/cli/verb/rdeps.rb +12 -0
  32. data/lib/textus/cli/verb/refresh.rb +15 -0
  33. data/lib/textus/cli/verb/schema.rb +12 -0
  34. data/lib/textus/cli/verb/schema_diff.rb +12 -0
  35. data/lib/textus/cli/verb/schema_init.rb +16 -0
  36. data/lib/textus/cli/verb/schema_migrate.rb +16 -0
  37. data/lib/textus/cli/verb/stale.rb +14 -0
  38. data/lib/textus/cli/verb/uid.rb +12 -0
  39. data/lib/textus/cli/verb/where.rb +12 -0
  40. data/lib/textus/cli.rb +23 -42
  41. data/lib/textus/doctor/check/audit_log.rb +50 -0
  42. data/lib/textus/doctor/check/hooks.rb +29 -0
  43. data/lib/textus/doctor/check/illegal_keys.rb +49 -0
  44. data/lib/textus/doctor/check/manifest_files.rb +38 -0
  45. data/lib/textus/doctor/check/schema_violations.rb +22 -0
  46. data/lib/textus/doctor/check/schemas.rb +26 -0
  47. data/lib/textus/doctor/check/sentinels.rb +57 -0
  48. data/lib/textus/doctor/check/templates.rb +26 -0
  49. data/lib/textus/doctor/check/unowned_schema_fields.rb +34 -0
  50. data/lib/textus/doctor/check.rb +30 -0
  51. data/lib/textus/doctor.rb +22 -288
  52. data/lib/textus/entry/base.rb +30 -0
  53. data/lib/textus/entry/json.rb +5 -1
  54. data/lib/textus/entry/markdown.rb +1 -1
  55. data/lib/textus/entry/text.rb +1 -1
  56. data/lib/textus/entry/yaml.rb +5 -1
  57. data/lib/textus/entry.rb +0 -5
  58. data/lib/textus/envelope.rb +30 -0
  59. data/lib/textus/hooks/builtin.rb +70 -0
  60. data/lib/textus/hooks/dispatcher.rb +49 -0
  61. data/lib/textus/hooks/loader.rb +26 -0
  62. data/lib/textus/hooks/registry.rb +73 -0
  63. data/lib/textus/init.rb +13 -10
  64. data/lib/textus/intro.rb +14 -16
  65. data/lib/textus/key/distance.rb +55 -0
  66. data/lib/textus/key/grammar.rb +33 -0
  67. data/lib/textus/key/path.rb +17 -0
  68. data/lib/textus/manifest/entry.rb +199 -0
  69. data/lib/textus/manifest.rb +10 -34
  70. data/lib/textus/migrate_keys.rb +1 -1
  71. data/lib/textus/projection.rb +5 -4
  72. data/lib/textus/proposal.rb +1 -1
  73. data/lib/textus/refresh.rb +11 -11
  74. data/lib/textus/schema/tools.rb +89 -0
  75. data/lib/textus/store/audit_log.rb +71 -0
  76. data/lib/textus/store/mover.rb +19 -16
  77. data/lib/textus/store/reader.rb +67 -0
  78. data/lib/textus/store/staleness.rb +10 -19
  79. data/lib/textus/store/validator.rb +11 -8
  80. data/lib/textus/store/view.rb +29 -0
  81. data/lib/textus/store/writer.rb +132 -0
  82. data/lib/textus/store.rb +25 -221
  83. data/lib/textus/version.rb +1 -1
  84. data/lib/textus.rb +14 -67
  85. metadata +73 -40
  86. data/lib/textus/audit_log.rb +0 -67
  87. data/lib/textus/builtin_actions.rb +0 -68
  88. data/lib/textus/cli/accept.rb +0 -13
  89. data/lib/textus/cli/action.rb +0 -51
  90. data/lib/textus/cli/build.rb +0 -11
  91. data/lib/textus/cli/delete.rb +0 -14
  92. data/lib/textus/cli/deprecated_alias.rb +0 -31
  93. data/lib/textus/cli/deps.rb +0 -10
  94. data/lib/textus/cli/doctor.rb +0 -13
  95. data/lib/textus/cli/extension_group.rb +0 -9
  96. data/lib/textus/cli/extensions.rb +0 -49
  97. data/lib/textus/cli/get.rb +0 -10
  98. data/lib/textus/cli/init.rb +0 -12
  99. data/lib/textus/cli/intro.rb +0 -9
  100. data/lib/textus/cli/key_group.rb +0 -10
  101. data/lib/textus/cli/list.rb +0 -12
  102. data/lib/textus/cli/migrate.rb +0 -41
  103. data/lib/textus/cli/migrate_keys.rb +0 -19
  104. data/lib/textus/cli/mv.rb +0 -20
  105. data/lib/textus/cli/published.rb +0 -9
  106. data/lib/textus/cli/put.rb +0 -48
  107. data/lib/textus/cli/rdeps.rb +0 -10
  108. data/lib/textus/cli/refresh.rb +0 -13
  109. data/lib/textus/cli/schema.rb +0 -10
  110. data/lib/textus/cli/schema_diff.rb +0 -15
  111. data/lib/textus/cli/schema_group.rb +0 -33
  112. data/lib/textus/cli/schema_init.rb +0 -19
  113. data/lib/textus/cli/schema_migrate.rb +0 -19
  114. data/lib/textus/cli/stale.rb +0 -12
  115. data/lib/textus/cli/uid.rb +0 -15
  116. data/lib/textus/cli/where.rb +0 -10
  117. data/lib/textus/extension_registry.rb +0 -61
  118. data/lib/textus/extensions.rb +0 -33
  119. data/lib/textus/key_distance.rb +0 -53
  120. data/lib/textus/manifest_entry.rb +0 -185
  121. data/lib/textus/migrate_v2.rb +0 -27
  122. data/lib/textus/schema_tools.rb +0 -87
  123. data/lib/textus/store/events.rb +0 -31
  124. data/lib/textus/store_view.rb +0 -27
@@ -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(meta: 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(meta: {}, 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(meta: {}, 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,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
@@ -0,0 +1,16 @@
1
+ module Textus
2
+ class CLI
3
+ class Verb
4
+ class MigrateKeys < Verb
5
+ option :write, "--write"
6
+ option :dry_run, "--dry-run"
7
+
8
+ def call(store)
9
+ effective_write = write && !dry_run
10
+ res = Textus::MigrateKeys.run(store, write: effective_write || false)
11
+ emit(res, exit_code: res["ok"] ? 0 : 1)
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,17 @@
1
+ module Textus
2
+ class CLI
3
+ class Verb
4
+ class Mv < Verb
5
+ option :as_flag, "--as=ROLE"
6
+ option :dry_run, "--dry-run"
7
+
8
+ def call(store)
9
+ old_key = positional.shift or raise UsageError.new("mv requires <old-key> <new-key>")
10
+ new_key = positional.shift or raise UsageError.new("mv requires <old-key> <new-key>")
11
+ role = Role.resolve(flag: as_flag, env: ENV, root: store.root)
12
+ emit(store.mv(old_key, new_key, as: role, dry_run: dry_run || false))
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,11 @@
1
+ module Textus
2
+ class CLI
3
+ class Verb
4
+ class Published < Verb
5
+ def call(store)
6
+ emit({ "published" => store.published })
7
+ end
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,50 @@
1
+ module Textus
2
+ class CLI
3
+ class Verb
4
+ class Put < Verb
5
+ option :as_flag, "--as=ROLE"
6
+ option :use_stdin, "--stdin"
7
+ option :fetch_name, "--fetch=NAME"
8
+
9
+ def call(store) # rubocop:disable Metrics/AbcSize
10
+ key = positional.shift or raise UsageError.new("put requires a key")
11
+ raise UsageError.new("put requires --stdin in v1") unless use_stdin
12
+
13
+ role = Role.resolve(flag: as_flag, env: ENV, root: store.root)
14
+
15
+ raw = @stdin.read
16
+ payload =
17
+ if fetch_name
18
+ callable = store.registry.rpc_callable(:fetch, fetch_name)
19
+ result =
20
+ begin
21
+ Timeout.timeout(Textus::Refresh::FETCH_TIMEOUT_SECONDS) do
22
+ callable.call(config: { "bytes" => raw }, store: Textus::Store::View.new(store), args: {})
23
+ end
24
+ rescue Timeout::Error
25
+ raise UsageError.new(
26
+ "fetch '#{fetch_name}' exceeded #{Textus::Refresh::FETCH_TIMEOUT_SECONDS}s timeout",
27
+ )
28
+ end
29
+ basename = key.split(".").last
30
+ {
31
+ "_meta" => {
32
+ "name" => basename,
33
+ "last_refreshed_at" => Time.now.utc.iso8601,
34
+ "fetched_with" => fetch_name,
35
+ }.merge(result[:_meta] || result["_meta"] || result[:frontmatter] || result["frontmatter"] || {}),
36
+ "body" => result[:body] || result["body"] || "",
37
+ }
38
+ else
39
+ JSON.parse(raw)
40
+ end
41
+
42
+ meta = payload["_meta"] || payload["frontmatter"] || {}
43
+ body = payload["body"] || ""
44
+ if_etag = payload["if_etag"]
45
+ emit(store.put(key, meta: meta, body: body, if_etag: if_etag, as: role))
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,12 @@
1
+ module Textus
2
+ class CLI
3
+ class Verb
4
+ class Rdeps < Verb
5
+ def call(store)
6
+ key = positional.shift or raise UsageError.new("rdeps requires a key")
7
+ emit({ "key" => key, "rdeps" => store.rdeps(key) })
8
+ end
9
+ end
10
+ end
11
+ end
12
+ end