textus 0.4.0 → 0.5.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 (64) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +64 -1
  3. data/README.md +13 -11
  4. data/SPEC.md +13 -9
  5. data/docs/architecture.md +63 -28
  6. data/lib/textus/audit_log.rb +46 -11
  7. data/lib/textus/builder.rb +3 -3
  8. data/lib/textus/builtin_actions.rb +5 -5
  9. data/lib/textus/cli/accept.rb +13 -0
  10. data/lib/textus/cli/action.rb +51 -0
  11. data/lib/textus/cli/build.rb +11 -0
  12. data/lib/textus/cli/delete.rb +14 -0
  13. data/lib/textus/cli/deprecated_alias.rb +31 -0
  14. data/lib/textus/cli/deps.rb +10 -0
  15. data/lib/textus/cli/doctor.rb +13 -0
  16. data/lib/textus/cli/extension_group.rb +9 -0
  17. data/lib/textus/cli/extensions.rb +49 -0
  18. data/lib/textus/cli/get.rb +10 -0
  19. data/lib/textus/cli/group.rb +51 -0
  20. data/lib/textus/cli/init.rb +12 -0
  21. data/lib/textus/cli/intro.rb +9 -0
  22. data/lib/textus/cli/key_group.rb +10 -0
  23. data/lib/textus/cli/list.rb +12 -0
  24. data/lib/textus/cli/migrate.rb +41 -0
  25. data/lib/textus/cli/migrate_keys.rb +19 -0
  26. data/lib/textus/cli/mv.rb +20 -0
  27. data/lib/textus/cli/published.rb +9 -0
  28. data/lib/textus/cli/put.rb +48 -0
  29. data/lib/textus/cli/rdeps.rb +10 -0
  30. data/lib/textus/cli/refresh.rb +13 -0
  31. data/lib/textus/cli/schema.rb +10 -0
  32. data/lib/textus/cli/schema_diff.rb +15 -0
  33. data/lib/textus/cli/schema_group.rb +33 -0
  34. data/lib/textus/cli/schema_init.rb +19 -0
  35. data/lib/textus/cli/schema_migrate.rb +19 -0
  36. data/lib/textus/cli/stale.rb +12 -0
  37. data/lib/textus/cli/uid.rb +15 -0
  38. data/lib/textus/cli/verb.rb +62 -0
  39. data/lib/textus/cli/where.rb +10 -0
  40. data/lib/textus/cli.rb +65 -387
  41. data/lib/textus/doctor.rb +64 -33
  42. data/lib/textus/entry/json.rb +6 -4
  43. data/lib/textus/entry/markdown.rb +4 -4
  44. data/lib/textus/entry/text.rb +3 -3
  45. data/lib/textus/entry/yaml.rb +6 -4
  46. data/lib/textus/entry.rb +2 -2
  47. data/lib/textus/errors.rb +2 -2
  48. data/lib/textus/init.rb +1 -1
  49. data/lib/textus/intro.rb +2 -2
  50. data/lib/textus/manifest.rb +11 -221
  51. data/lib/textus/manifest_entry.rb +185 -0
  52. data/lib/textus/migrate_v2.rb +27 -0
  53. data/lib/textus/projection.rb +1 -1
  54. data/lib/textus/proposal.rb +3 -3
  55. data/lib/textus/refresh.rb +7 -7
  56. data/lib/textus/schema_tools.rb +8 -8
  57. data/lib/textus/store/events.rb +31 -0
  58. data/lib/textus/store/mover.rb +118 -0
  59. data/lib/textus/store/staleness.rb +142 -0
  60. data/lib/textus/store/validator.rb +53 -0
  61. data/lib/textus/store.rb +49 -354
  62. data/lib/textus/version.rb +2 -2
  63. data/lib/textus.rb +38 -0
  64. metadata +38 -1
@@ -0,0 +1,13 @@
1
+ module Textus
2
+ class CLI
3
+ class DoctorVerb < Verb
4
+ option :checks, "--check=NAME"
5
+
6
+ def call(store)
7
+ check_list = checks&.split(",")&.map(&:strip)
8
+ res = Textus::Doctor.run(store, checks: check_list)
9
+ emit(res, exit_code: res["ok"] ? 0 : 1)
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,9 @@
1
+ module Textus
2
+ class CLI
3
+ class ExtensionGroup < Group
4
+ self.cli_name = "extension"
5
+ subcommands["list"] = Extensions
6
+ subcommands["run"] = Action
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,49 @@
1
+ module Textus
2
+ class CLI
3
+ class Extensions < Verb
4
+ prepend DeprecatedAliasMixin
5
+
6
+ def self.deprecated_name = "extensions"
7
+ def self.replacement_path = "extension list"
8
+
9
+ option :kind, "--kind=K"
10
+
11
+ def call(store) # rubocop:disable Metrics/AbcSize
12
+ # When invoked as the flat alias `textus extensions list`, the positional "list"
13
+ # is still present. When invoked via `textus extension list` (group), it has been
14
+ # consumed by the group dispatcher — both are valid.
15
+ subcommand = positional.first
16
+ if subcommand
17
+ raise UsageError.new("extensions requires 'list'") unless subcommand == "list"
18
+
19
+ positional.shift
20
+ end
21
+
22
+ rows = []
23
+ rows += store.registry.action_names.map { |n| { "kind" => "action", "name" => n.to_s } }
24
+ rows += store.registry.doctor_check_names.map { |n| { "kind" => "doctor_check", "name" => n.to_s } }
25
+ rows += store.registry.reducer_names.map { |n| { "kind" => "reducer", "name" => n.to_s } }
26
+ store.registry.hook_events.each do |evt|
27
+ store.registry.hooks(evt).each do |h|
28
+ rows << { "kind" => "hook", "event" => evt.to_s, "name" => h[:name].to_s }
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
+ "kind" => "hook", "event" => evt.to_s, "exec" => defn["exec"],
38
+ "key" => e.key, "as" => defn["as"] || "script"
39
+ }
40
+ end
41
+ end
42
+ end
43
+ rows.select! { |r| r["kind"] == kind } if kind
44
+
45
+ emit({ "extensions" => rows })
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,10 @@
1
+ module Textus
2
+ class CLI
3
+ class Get < Verb
4
+ def call(store)
5
+ key = positional.shift or raise UsageError.new("get requires a key")
6
+ emit(store.get(key))
7
+ end
8
+ end
9
+ end
10
+ 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,12 @@
1
+ module Textus
2
+ class CLI
3
+ class InitVerb < Verb
4
+ def self.needs_store? = false
5
+
6
+ def call(_store)
7
+ target = File.join(@cwd, ".textus")
8
+ emit(Textus::Init.run(target))
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,9 @@
1
+ module Textus
2
+ class CLI
3
+ class IntroVerb < Verb
4
+ def call(store)
5
+ emit(Textus::Intro.run(store))
6
+ end
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,10 @@
1
+ module Textus
2
+ class CLI
3
+ class KeyGroup < Group
4
+ self.cli_name = "key"
5
+ subcommands["mv"] = Mv
6
+ subcommands["uid"] = Uid
7
+ subcommands["migrate"] = MigrateKeysVerb
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,12 @@
1
+ module Textus
2
+ class CLI
3
+ class List < Verb
4
+ option :prefix, "--prefix=KEY"
5
+ option :zone, "--zone=Z"
6
+
7
+ def call(store)
8
+ emit({ "entries" => store.list(prefix: prefix, zone: zone) })
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,41 @@
1
+ module Textus
2
+ class CLI
3
+ class Migrate < Verb
4
+ # Does not need a store — the target manifest may still be textus/1.
5
+ def self.needs_store? = false
6
+
7
+ def call(_store)
8
+ target = positional.shift or raise UsageError.new("migrate requires a target (e.g. 'v2')")
9
+ raise UsageError.new("unknown migration target: #{target.inspect}; supported: v2") unless target == "v2"
10
+
11
+ # Locate the .textus directory the same way Store.discover does.
12
+ root = find_textus_root
13
+ emit(Textus::MigrateV2.run(root))
14
+ end
15
+
16
+ private
17
+
18
+ def find_textus_root
19
+ explicit = ENV.fetch("TEXTUS_ROOT", nil)
20
+ if explicit
21
+ abs = File.expand_path(explicit)
22
+ return abs if File.directory?(abs)
23
+
24
+ raise IoError.new("no textus store at #{abs}")
25
+ end
26
+
27
+ dir = File.expand_path(@cwd)
28
+ loop do
29
+ candidate = File.join(dir, ".textus")
30
+ return candidate if File.directory?(candidate) && File.exist?(File.join(candidate, "manifest.yaml"))
31
+
32
+ parent = File.dirname(dir)
33
+ break if parent == dir
34
+
35
+ dir = parent
36
+ end
37
+ raise IoError.new("no .textus directory found from #{@cwd}")
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,19 @@
1
+ module Textus
2
+ class CLI
3
+ class MigrateKeysVerb < Verb
4
+ prepend DeprecatedAliasMixin
5
+
6
+ def self.deprecated_name = "migrate-keys"
7
+ def self.replacement_path = "key migrate"
8
+
9
+ option :write, "--write"
10
+ option :dry_run, "--dry-run"
11
+
12
+ def call(store)
13
+ effective_write = write && !dry_run
14
+ res = Textus::MigrateKeys.run(store, write: effective_write || false)
15
+ emit(res, exit_code: res["ok"] ? 0 : 1)
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,20 @@
1
+ module Textus
2
+ class CLI
3
+ class Mv < Verb
4
+ prepend DeprecatedAliasMixin
5
+
6
+ def self.deprecated_name = "mv"
7
+ def self.replacement_path = "key mv"
8
+
9
+ option :as_flag, "--as=ROLE"
10
+ option :dry_run, "--dry-run"
11
+
12
+ def call(store)
13
+ old_key = positional.shift or raise UsageError.new("mv requires <old-key> <new-key>")
14
+ new_key = positional.shift or raise UsageError.new("mv requires <old-key> <new-key>")
15
+ role = Role.resolve(flag: as_flag, env: ENV, root: store.root)
16
+ emit(store.mv(old_key, new_key, as: role, dry_run: dry_run || false))
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,9 @@
1
+ module Textus
2
+ class CLI
3
+ class Published < Verb
4
+ def call(store)
5
+ emit({ "published" => store.published })
6
+ end
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,48 @@
1
+ module Textus
2
+ class CLI
3
+ class Put < Verb
4
+ option :as_flag, "--as=ROLE"
5
+ option :use_stdin, "--stdin"
6
+ option :action_name, "--action=NAME"
7
+
8
+ def call(store) # rubocop:disable Metrics/AbcSize
9
+ key = positional.shift or raise UsageError.new("put requires a key")
10
+ raise UsageError.new("put requires --stdin in v1") unless use_stdin
11
+
12
+ role = Role.resolve(flag: as_flag, env: ENV, root: store.root)
13
+
14
+ raw = @stdin.read
15
+ payload =
16
+ if action_name
17
+ callable = store.registry.action(action_name)
18
+ result =
19
+ begin
20
+ Timeout.timeout(Textus::Refresh::ACTION_TIMEOUT_SECONDS) do
21
+ callable.call(config: { "bytes" => raw }, store: Textus::StoreView.new(store), args: {})
22
+ end
23
+ rescue Timeout::Error
24
+ raise UsageError.new(
25
+ "action '#{action_name}' exceeded #{Textus::Refresh::ACTION_TIMEOUT_SECONDS}s timeout",
26
+ )
27
+ end
28
+ basename = key.split(".").last
29
+ {
30
+ "_meta" => {
31
+ "name" => basename,
32
+ "last_refreshed_at" => Time.now.utc.iso8601,
33
+ "actioned_with" => action_name,
34
+ }.merge(result[:_meta] || result["_meta"] || result[:frontmatter] || result["frontmatter"] || {}),
35
+ "body" => result[:body] || result["body"] || "",
36
+ }
37
+ else
38
+ JSON.parse(raw)
39
+ end
40
+
41
+ meta = payload["_meta"] || payload["frontmatter"] || {}
42
+ body = payload["body"] || ""
43
+ if_etag = payload["if_etag"]
44
+ emit(store.put(key, meta: meta, body: body, if_etag: if_etag, as: role))
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,10 @@
1
+ module Textus
2
+ class CLI
3
+ class Rdeps < Verb
4
+ def call(store)
5
+ key = positional.shift or raise UsageError.new("rdeps requires a key")
6
+ emit({ "key" => key, "rdeps" => store.rdeps(key) })
7
+ end
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,13 @@
1
+ module Textus
2
+ class CLI
3
+ class RefreshVerb < Verb
4
+ option :as_flag, "--as=ROLE"
5
+
6
+ def call(store)
7
+ key = positional.shift or raise UsageError.new("refresh requires a key")
8
+ role = Role.resolve(flag: as_flag, env: ENV, root: store.root)
9
+ emit(Textus::Refresh.call(store, key, as: role))
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,10 @@
1
+ module Textus
2
+ class CLI
3
+ class SchemaVerb < Verb
4
+ def call(store)
5
+ key = positional.shift or raise UsageError.new("schema requires a key")
6
+ emit(store.schema_envelope(key))
7
+ end
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,15 @@
1
+ module Textus
2
+ class CLI
3
+ class SchemaDiff < Verb
4
+ prepend DeprecatedAliasMixin
5
+
6
+ def self.deprecated_name = "schema-diff"
7
+ def self.replacement_path = "schema diff"
8
+
9
+ def call(store)
10
+ name = positional.shift or raise UsageError.new("schema-diff NAME")
11
+ emit(Textus::SchemaTools.diff(store, name: name))
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,33 @@
1
+ module Textus
2
+ class CLI
3
+ class SchemaGroup < Group
4
+ self.cli_name = "schema"
5
+ subcommands["show"] = SchemaVerb
6
+ subcommands["init"] = SchemaInit
7
+ subcommands["diff"] = SchemaDiff
8
+ subcommands["migrate"] = SchemaMigrate
9
+
10
+ # Back-compat: `textus schema KEY` (dotted key, no subcommand word).
11
+ # If the first positional looks like a dotted key, treat it as `schema show KEY`.
12
+ def parse(argv)
13
+ first = argv.first
14
+ if first && dotted_key?(first)
15
+ @stderr.puts(
16
+ "textus: 'schema KEY' is deprecated; use 'textus schema show KEY' instead. Removed in 0.6.",
17
+ )
18
+ argv.unshift("show")
19
+ end
20
+ super
21
+ end
22
+
23
+ private
24
+
25
+ def dotted_key?(token)
26
+ return false if token.start_with?("-")
27
+ return false unless token.include?(".")
28
+
29
+ token.split(".").all? { |seg| seg.match?(Manifest::KEY_SEGMENT) }
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,19 @@
1
+ module Textus
2
+ class CLI
3
+ class SchemaInit < Verb
4
+ prepend DeprecatedAliasMixin
5
+
6
+ def self.deprecated_name = "schema-init"
7
+ def self.replacement_path = "schema init"
8
+
9
+ option :from_key, "--from=KEY"
10
+
11
+ def call(store)
12
+ name = positional.shift or raise UsageError.new("schema-init NAME")
13
+ raise UsageError.new("schema-init requires --from=KEY") unless from_key
14
+
15
+ emit(Textus::SchemaTools.init(store, name: name, from: from_key))
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,19 @@
1
+ module Textus
2
+ class CLI
3
+ class SchemaMigrate < Verb
4
+ prepend DeprecatedAliasMixin
5
+
6
+ def self.deprecated_name = "schema-migrate"
7
+ def self.replacement_path = "schema migrate"
8
+
9
+ option :rename, "--rename=O:N"
10
+
11
+ def call(store)
12
+ name = positional.shift or raise UsageError.new("schema-migrate NAME")
13
+ raise UsageError.new("schema-migrate requires --rename=OLD:NEW") unless rename
14
+
15
+ emit(Textus::SchemaTools.migrate(store, name: name, rename: rename))
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,12 @@
1
+ module Textus
2
+ class CLI
3
+ class Stale < Verb
4
+ option :prefix, "--prefix=KEY"
5
+ option :zone, "--zone=Z"
6
+
7
+ def call(store)
8
+ emit(store.stale(prefix: prefix, zone: zone))
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,15 @@
1
+ module Textus
2
+ class CLI
3
+ class Uid < Verb
4
+ prepend DeprecatedAliasMixin
5
+
6
+ def self.deprecated_name = "uid"
7
+ def self.replacement_path = "key uid"
8
+
9
+ def call(store)
10
+ key = positional.shift or raise UsageError.new("uid requires a key")
11
+ emit({ "key" => key, "uid" => store.uid(key) })
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,62 @@
1
+ require "json"
2
+ require "optparse"
3
+
4
+ module Textus
5
+ class CLI
6
+ # Subclasses must implement #call(store) and return an integer exit code.
7
+ # Use #emit(obj) for normal JSON output (returns 0).
8
+ # Subclasses that don't need a Textus store (e.g. Init) override
9
+ # `.needs_store?` to return false; dispatch will pass nil instead.
10
+ class Verb
11
+ class << self
12
+ def option(name, optspec)
13
+ options << [name, optspec]
14
+ attr_accessor(name)
15
+ end
16
+
17
+ def options
18
+ @options ||= []
19
+ end
20
+
21
+ def needs_store?
22
+ true
23
+ end
24
+
25
+ def inherited(subclass)
26
+ super
27
+ subclass.instance_variable_set(:@options, [])
28
+ end
29
+ end
30
+
31
+ def initialize(stdin:, stdout:, stderr:, cwd: nil)
32
+ @stdin = stdin
33
+ @stdout = stdout
34
+ @stderr = stderr
35
+ @cwd = cwd
36
+ end
37
+
38
+ def parse(argv)
39
+ fmt = "json"
40
+ OptionParser.new do |o|
41
+ self.class.options.each do |name, optspec|
42
+ o.on(optspec) { |v| public_send(:"#{name}=", v) }
43
+ end
44
+ o.on("--format=FMT") { |v| fmt = v }
45
+ end.permute!(argv)
46
+ raise UsageError.new("only --format=json is supported in v1") unless fmt == "json"
47
+
48
+ @positional = argv.dup
49
+ end
50
+
51
+ attr_reader :positional
52
+
53
+ # Hashes get "protocol" => PROTOCOL prepended unless they already
54
+ # carry one (Store envelopes do). Caller's value wins on collision.
55
+ def emit(obj, exit_code: 0)
56
+ payload = obj.is_a?(Hash) ? { "protocol" => PROTOCOL }.merge(obj) : obj
57
+ @stdout.puts(JSON.generate(payload))
58
+ exit_code
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,10 @@
1
+ module Textus
2
+ class CLI
3
+ class Where < Verb
4
+ def call(store)
5
+ key = positional.shift or raise UsageError.new("where requires a key")
6
+ emit(store.where(key))
7
+ end
8
+ end
9
+ end
10
+ end