textus 0.3.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 (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +79 -1
  3. data/README.md +22 -18
  4. data/SPEC.md +49 -35
  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_fetchers.rb → builtin_actions.rb} +16 -11
  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 -347
  41. data/lib/textus/doctor.rb +103 -32
  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/extension_registry.rb +22 -9
  49. data/lib/textus/extensions.rb +6 -2
  50. data/lib/textus/init.rb +6 -5
  51. data/lib/textus/intro.rb +11 -9
  52. data/lib/textus/manifest.rb +11 -215
  53. data/lib/textus/manifest_entry.rb +185 -0
  54. data/lib/textus/migrate_v2.rb +27 -0
  55. data/lib/textus/projection.rb +1 -1
  56. data/lib/textus/proposal.rb +3 -3
  57. data/lib/textus/refresh.rb +21 -20
  58. data/lib/textus/schema_tools.rb +8 -8
  59. data/lib/textus/store/events.rb +31 -0
  60. data/lib/textus/store/mover.rb +118 -0
  61. data/lib/textus/store/staleness.rb +142 -0
  62. data/lib/textus/store/validator.rb +53 -0
  63. data/lib/textus/store.rb +50 -355
  64. data/lib/textus/store_view.rb +11 -2
  65. data/lib/textus/version.rb +2 -2
  66. data/lib/textus.rb +39 -1
  67. metadata +39 -2
@@ -96,14 +96,14 @@ module Textus
96
96
  "from" => Array(mentry.projection&.fetch("select", nil)).compact,
97
97
  },
98
98
  }
99
- Entry.for_format("markdown").serialize(frontmatter: frontmatter, body: body)
99
+ Entry.for_format("markdown").serialize(meta: frontmatter, body: body)
100
100
  end
101
101
 
102
102
  # Text: projection -> template -> text.serialize(body). No frontmatter, no _meta.
103
103
  def build_text(mentry, data)
104
104
  data = data.merge("intro" => Intro.run(@store)) if mentry.inject_intro
105
105
  body = render_template!(mentry, data)
106
- Entry.for_format("text").serialize(frontmatter: {}, body: body)
106
+ Entry.for_format("text").serialize(meta: {}, body: body)
107
107
  end
108
108
 
109
109
  # JSON / YAML pipeline. Templateless = default; template = escape hatch.
@@ -127,7 +127,7 @@ module Textus
127
127
  end
128
128
 
129
129
  final = inject_meta(content, mentry)
130
- strategy.serialize(frontmatter: {}, body: "", content: final)
130
+ strategy.serialize(meta: {}, body: "", content: final)
131
131
  end
132
132
 
133
133
  def render_template!(mentry, data)
@@ -4,31 +4,35 @@ require "yaml"
4
4
  require "rexml/document"
5
5
 
6
6
  module Textus
7
- module BuiltinFetchers
7
+ module BuiltinActions
8
8
  # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
9
9
  def self.register_all
10
- Textus.fetcher(:json) do |config:, store:|
10
+ Textus.action(:json) do |config:, store:, args:|
11
11
  _ = store
12
+ _ = args
12
13
  data = JSON.parse(config["bytes"].to_s)
13
- { frontmatter: {}, body: YAML.dump(data) }
14
+ { _meta: {}, body: YAML.dump(data) }
14
15
  end
15
16
 
16
- Textus.fetcher(:csv) do |config:, store:|
17
+ Textus.action(:csv) do |config:, store:, args:|
17
18
  _ = store
19
+ _ = args
18
20
  rows = CSV.parse(config["bytes"].to_s, headers: true).map(&:to_h)
19
- { frontmatter: {}, body: YAML.dump(rows) }
21
+ { _meta: {}, body: YAML.dump(rows) }
20
22
  end
21
23
 
22
- Textus.fetcher(:"markdown-links") do |config:, store:|
24
+ Textus.action(:"markdown-links") do |config:, store:, args:|
23
25
  _ = store
26
+ _ = args
24
27
  links = config["bytes"].to_s.scan(%r{\[([^\]]+)\]\((https?://[^)\s]+)\)}).map do |text, href|
25
28
  { "text" => text, "href" => href }
26
29
  end
27
- { frontmatter: {}, body: YAML.dump(links) }
30
+ { _meta: {}, body: YAML.dump(links) }
28
31
  end
29
32
 
30
- Textus.fetcher(:"ical-events") do |config:, store:|
33
+ Textus.action(:"ical-events") do |config:, store:, args:|
31
34
  _ = store
35
+ _ = args
32
36
  events = []
33
37
  current = nil
34
38
  config["bytes"].to_s.each_line do |line|
@@ -42,11 +46,12 @@ module Textus
42
46
  current[Regexp.last_match(1).downcase] = Regexp.last_match(2) if current
43
47
  end
44
48
  end
45
- { frontmatter: {}, body: YAML.dump(events) }
49
+ { _meta: {}, body: YAML.dump(events) }
46
50
  end
47
51
 
48
- Textus.fetcher(:rss) do |config:, store:|
52
+ Textus.action(:rss) do |config:, store:, args:|
49
53
  _ = store
54
+ _ = args
50
55
  doc = REXML::Document.new(config["bytes"].to_s)
51
56
  items = doc.elements.to_a("//item").map do |item|
52
57
  {
@@ -55,7 +60,7 @@ module Textus
55
60
  "pubDate" => item.elements["pubDate"]&.text,
56
61
  }
57
62
  end
58
- { frontmatter: {}, body: YAML.dump(items) }
63
+ { _meta: {}, body: YAML.dump(items) }
59
64
  end
60
65
  end
61
66
  # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
@@ -0,0 +1,13 @@
1
+ module Textus
2
+ class CLI
3
+ class Accept < Verb
4
+ option :as_flag, "--as=ROLE"
5
+
6
+ def call(store)
7
+ key = positional.shift or raise UsageError.new("accept requires a key")
8
+ role = Role.resolve(flag: as_flag, env: ENV, root: store.root)
9
+ emit(store.accept(key, as: role))
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,51 @@
1
+ module Textus
2
+ class CLI
3
+ class Action < Verb
4
+ prepend DeprecatedAliasMixin
5
+
6
+ def self.deprecated_name = "action"
7
+ def self.replacement_path = "extension run"
8
+
9
+ def parse(argv)
10
+ @raw_argv = argv
11
+ end
12
+
13
+ def call(store)
14
+ name = @raw_argv.shift
15
+ raise UsageError.new("action requires a name") if name.nil?
16
+
17
+ as_flag = nil
18
+ args = {}
19
+ @raw_argv.each do |tok|
20
+ case tok
21
+ when /\A--as=(.+)\z/ then as_flag = ::Regexp.last_match(1)
22
+ when /\A--format=/ then next
23
+ when /\A--([\w-]+)=(.*)\z/ then args[::Regexp.last_match(1)] = ::Regexp.last_match(2)
24
+ else
25
+ raise UsageError.new("unknown arg to 'action #{name}': #{tok}")
26
+ end
27
+ end
28
+
29
+ role = Role.resolve(flag: as_flag, env: ENV, root: store.root)
30
+ callable = store.registry.action(name)
31
+ view = StoreView.new(store, writable: true, as: role)
32
+
33
+ begin
34
+ Timeout.timeout(Textus::Refresh::ACTION_TIMEOUT_SECONDS) do
35
+ callable.call(config: {}, store: view, args: args)
36
+ end
37
+ rescue Timeout::Error
38
+ raise UsageError.new(
39
+ "action '#{name}' exceeded #{Textus::Refresh::ACTION_TIMEOUT_SECONDS}s timeout",
40
+ )
41
+ rescue Textus::Error
42
+ raise
43
+ rescue StandardError => e
44
+ raise UsageError.new("action '#{name}' raised: #{e.class}: #{e.message}")
45
+ end
46
+
47
+ emit({ "action" => name, "ok" => true })
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,11 @@
1
+ module Textus
2
+ class CLI
3
+ class Build < Verb
4
+ option :prefix, "--prefix=K"
5
+
6
+ def call(store)
7
+ emit(Textus::Builder.new(store).build(prefix: prefix))
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,14 @@
1
+ module Textus
2
+ class CLI
3
+ class Delete < Verb
4
+ option :as_flag, "--as=ROLE"
5
+ option :if_etag, "--if-etag=E"
6
+
7
+ def call(store)
8
+ key = positional.shift or raise UsageError.new("delete requires a key")
9
+ role = Role.resolve(flag: as_flag, env: ENV, root: store.root)
10
+ emit(store.delete(key, if_etag: if_etag, as: role))
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,31 @@
1
+ module Textus
2
+ class CLI
3
+ module DeprecatedAliasMixin
4
+ def self.prepended(base)
5
+ base.extend(ClassMethods)
6
+ end
7
+
8
+ module ClassMethods
9
+ def deprecated_name
10
+ raise NotImplementedError.new("#{self}.deprecated_name must be defined")
11
+ end
12
+
13
+ def replacement_path
14
+ raise NotImplementedError.new("#{self}.replacement_path must be defined")
15
+ end
16
+ end
17
+
18
+ attr_writer :deprecated_alias
19
+
20
+ def call(store)
21
+ if @deprecated_alias
22
+ @stderr.puts(
23
+ "textus: '#{self.class.deprecated_name}' is deprecated; " \
24
+ "use 'textus #{self.class.replacement_path}' instead. Removed in 0.6.",
25
+ )
26
+ end
27
+ super
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,10 @@
1
+ module Textus
2
+ class CLI
3
+ class Deps < Verb
4
+ def call(store)
5
+ key = positional.shift or raise UsageError.new("deps requires a key")
6
+ emit({ "key" => key, "deps" => store.deps(key) })
7
+ end
8
+ end
9
+ end
10
+ end
@@ -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