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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +64 -1
- data/README.md +13 -11
- data/SPEC.md +13 -9
- data/docs/architecture.md +63 -28
- data/lib/textus/audit_log.rb +46 -11
- data/lib/textus/builder.rb +3 -3
- data/lib/textus/builtin_actions.rb +5 -5
- data/lib/textus/cli/accept.rb +13 -0
- data/lib/textus/cli/action.rb +51 -0
- data/lib/textus/cli/build.rb +11 -0
- data/lib/textus/cli/delete.rb +14 -0
- data/lib/textus/cli/deprecated_alias.rb +31 -0
- data/lib/textus/cli/deps.rb +10 -0
- data/lib/textus/cli/doctor.rb +13 -0
- data/lib/textus/cli/extension_group.rb +9 -0
- data/lib/textus/cli/extensions.rb +49 -0
- data/lib/textus/cli/get.rb +10 -0
- data/lib/textus/cli/group.rb +51 -0
- data/lib/textus/cli/init.rb +12 -0
- data/lib/textus/cli/intro.rb +9 -0
- data/lib/textus/cli/key_group.rb +10 -0
- data/lib/textus/cli/list.rb +12 -0
- data/lib/textus/cli/migrate.rb +41 -0
- data/lib/textus/cli/migrate_keys.rb +19 -0
- data/lib/textus/cli/mv.rb +20 -0
- data/lib/textus/cli/published.rb +9 -0
- data/lib/textus/cli/put.rb +48 -0
- data/lib/textus/cli/rdeps.rb +10 -0
- data/lib/textus/cli/refresh.rb +13 -0
- data/lib/textus/cli/schema.rb +10 -0
- data/lib/textus/cli/schema_diff.rb +15 -0
- data/lib/textus/cli/schema_group.rb +33 -0
- data/lib/textus/cli/schema_init.rb +19 -0
- data/lib/textus/cli/schema_migrate.rb +19 -0
- data/lib/textus/cli/stale.rb +12 -0
- data/lib/textus/cli/uid.rb +15 -0
- data/lib/textus/cli/verb.rb +62 -0
- data/lib/textus/cli/where.rb +10 -0
- data/lib/textus/cli.rb +65 -387
- data/lib/textus/doctor.rb +64 -33
- data/lib/textus/entry/json.rb +6 -4
- data/lib/textus/entry/markdown.rb +4 -4
- data/lib/textus/entry/text.rb +3 -3
- data/lib/textus/entry/yaml.rb +6 -4
- data/lib/textus/entry.rb +2 -2
- data/lib/textus/errors.rb +2 -2
- data/lib/textus/init.rb +1 -1
- data/lib/textus/intro.rb +2 -2
- data/lib/textus/manifest.rb +11 -221
- data/lib/textus/manifest_entry.rb +185 -0
- data/lib/textus/migrate_v2.rb +27 -0
- data/lib/textus/projection.rb +1 -1
- data/lib/textus/proposal.rb +3 -3
- data/lib/textus/refresh.rb +7 -7
- data/lib/textus/schema_tools.rb +8 -8
- data/lib/textus/store/events.rb +31 -0
- data/lib/textus/store/mover.rb +118 -0
- data/lib/textus/store/staleness.rb +142 -0
- data/lib/textus/store/validator.rb +53 -0
- data/lib/textus/store.rb +49 -354
- data/lib/textus/version.rb +2 -2
- data/lib/textus.rb +38 -0
- metadata +38 -1
data/lib/textus/cli.rb
CHANGED
|
@@ -1,12 +1,45 @@
|
|
|
1
1
|
require "json"
|
|
2
2
|
require "optparse"
|
|
3
|
-
require "time"
|
|
4
|
-
require "timeout"
|
|
5
|
-
require "yaml"
|
|
6
3
|
|
|
7
4
|
module Textus
|
|
8
|
-
# rubocop:disable Metrics/ClassLength
|
|
9
5
|
class CLI
|
|
6
|
+
# verb name → Verb subclass. Adding a new verb is a one-line entry here
|
|
7
|
+
# plus a new file under lib/textus/cli/.
|
|
8
|
+
VERBS = {
|
|
9
|
+
"accept" => Accept,
|
|
10
|
+
"action" => Action,
|
|
11
|
+
"build" => Build,
|
|
12
|
+
"delete" => Delete,
|
|
13
|
+
"deps" => Deps,
|
|
14
|
+
"doctor" => DoctorVerb,
|
|
15
|
+
"extension" => ExtensionGroup,
|
|
16
|
+
"extensions" => Extensions,
|
|
17
|
+
"get" => Get,
|
|
18
|
+
"init" => InitVerb,
|
|
19
|
+
"intro" => IntroVerb,
|
|
20
|
+
"key" => KeyGroup,
|
|
21
|
+
"list" => List,
|
|
22
|
+
"migrate" => Migrate,
|
|
23
|
+
"migrate-keys" => MigrateKeysVerb,
|
|
24
|
+
"mv" => Mv,
|
|
25
|
+
"published" => Published,
|
|
26
|
+
"put" => Put,
|
|
27
|
+
"rdeps" => Rdeps,
|
|
28
|
+
"refresh" => RefreshVerb,
|
|
29
|
+
"schema" => SchemaGroup,
|
|
30
|
+
"schema-diff" => SchemaDiff,
|
|
31
|
+
"schema-init" => SchemaInit,
|
|
32
|
+
"schema-migrate" => SchemaMigrate,
|
|
33
|
+
"stale" => Stale,
|
|
34
|
+
"uid" => Uid,
|
|
35
|
+
"where" => Where,
|
|
36
|
+
}.freeze
|
|
37
|
+
|
|
38
|
+
# Flat aliases kept for backward-compat through 0.5; emit deprecation warnings.
|
|
39
|
+
DEPRECATED_ALIASES = %w[
|
|
40
|
+
mv uid migrate-keys schema-init schema-diff schema-migrate extensions action
|
|
41
|
+
].freeze
|
|
42
|
+
|
|
10
43
|
def self.run(argv, stdin: $stdin, stdout: $stdout, stderr: $stderr, cwd: Dir.pwd)
|
|
11
44
|
new(stdin: stdin, stdout: stdout, stderr: stderr, cwd: cwd).run(argv)
|
|
12
45
|
end
|
|
@@ -19,44 +52,19 @@ module Textus
|
|
|
19
52
|
@root_arg = nil
|
|
20
53
|
end
|
|
21
54
|
|
|
22
|
-
def run(argv)
|
|
23
|
-
OptionParser.new
|
|
24
|
-
o.on("--root=PATH") { |v| @root_arg = v }
|
|
25
|
-
end.order!(argv)
|
|
55
|
+
def run(argv)
|
|
56
|
+
OptionParser.new { |o| o.on("--root=PATH") { |v| @root_arg = v } }.order!(argv)
|
|
26
57
|
verb = argv.shift
|
|
27
58
|
raise UsageError.new("missing verb") if verb.nil?
|
|
28
59
|
|
|
29
60
|
case verb
|
|
30
|
-
when "list" then verb_list(argv)
|
|
31
|
-
when "where" then verb_where(argv)
|
|
32
|
-
when "get" then verb_get(argv)
|
|
33
|
-
when "put" then verb_put(argv)
|
|
34
|
-
when "schema" then verb_schema(argv)
|
|
35
|
-
when "stale" then verb_stale(argv)
|
|
36
|
-
when "delete" then verb_delete(argv)
|
|
37
|
-
when "validate-all" then verb_validate_all(argv)
|
|
38
|
-
when "build" then verb_build(argv)
|
|
39
|
-
when "deps" then verb_deps(argv)
|
|
40
|
-
when "rdeps" then verb_rdeps(argv)
|
|
41
|
-
when "published" then verb_published(argv)
|
|
42
|
-
when "accept" then verb_accept(argv)
|
|
43
|
-
when "init" then verb_init(argv)
|
|
44
|
-
when "schema-init" then verb_schema_init(argv)
|
|
45
|
-
when "schema-diff" then verb_schema_diff(argv)
|
|
46
|
-
when "schema-migrate" then verb_schema_migrate(argv)
|
|
47
|
-
when "action" then verb_action(argv)
|
|
48
|
-
when "refresh" then verb_refresh(argv)
|
|
49
|
-
when "extensions" then verb_extensions(argv)
|
|
50
|
-
when "migrate-keys" then verb_migrate_keys(argv)
|
|
51
|
-
when "mv" then verb_mv(argv)
|
|
52
|
-
when "uid" then verb_uid(argv)
|
|
53
|
-
when "doctor" then verb_doctor(argv)
|
|
54
|
-
when "intro" then verb_intro(argv)
|
|
55
61
|
when "--version", "-v" then @stdout.puts(VERSION)
|
|
56
62
|
0
|
|
57
63
|
when "--help", "-h" then print_help
|
|
58
64
|
0
|
|
59
|
-
else
|
|
65
|
+
else
|
|
66
|
+
klass = VERBS[verb] or raise UsageError.new("unknown verb: #{verb}")
|
|
67
|
+
dispatch(klass, argv, deprecated_alias: DEPRECATED_ALIASES.include?(verb))
|
|
60
68
|
end
|
|
61
69
|
rescue Textus::Error => e
|
|
62
70
|
emit_error(e)
|
|
@@ -68,348 +76,11 @@ module Textus
|
|
|
68
76
|
@store ||= Store.discover(@cwd, root: @root_arg)
|
|
69
77
|
end
|
|
70
78
|
|
|
71
|
-
def
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
raise UsageError.new("only --format=json is supported in v1") unless fmt == "json"
|
|
77
|
-
|
|
78
|
-
fmt
|
|
79
|
-
end
|
|
80
|
-
|
|
81
|
-
def parse_prefix!(argv)
|
|
82
|
-
prefix = nil
|
|
83
|
-
OptionParser.new do |o|
|
|
84
|
-
o.on("--prefix=KEY") { |v| prefix = v }
|
|
85
|
-
o.on("--zone=Z") {}
|
|
86
|
-
o.on("--format=FMT") {}
|
|
87
|
-
end.permute!(argv)
|
|
88
|
-
prefix
|
|
89
|
-
end
|
|
90
|
-
|
|
91
|
-
def parse_prefix_and_zone!(argv)
|
|
92
|
-
prefix = nil
|
|
93
|
-
zone = nil
|
|
94
|
-
fmt = "text"
|
|
95
|
-
OptionParser.new do |o|
|
|
96
|
-
o.on("--prefix=KEY") { |v| prefix = v }
|
|
97
|
-
o.on("--zone=Z") { |v| zone = v }
|
|
98
|
-
o.on("--format=FMT") { |v| fmt = v }
|
|
99
|
-
end.permute!(argv)
|
|
100
|
-
raise UsageError.new("only --format=json is supported in v1") unless fmt == "json"
|
|
101
|
-
|
|
102
|
-
[prefix, zone]
|
|
103
|
-
end
|
|
104
|
-
|
|
105
|
-
def verb_list(argv)
|
|
106
|
-
prefix, zone = parse_prefix_and_zone!(argv)
|
|
107
|
-
emit({ "protocol" => PROTOCOL, "entries" => store.list(prefix: prefix, zone: zone) })
|
|
108
|
-
end
|
|
109
|
-
|
|
110
|
-
def verb_where(argv)
|
|
111
|
-
key = argv.shift or raise UsageError.new("where requires a key")
|
|
112
|
-
parse_format!(argv)
|
|
113
|
-
emit(store.where(key))
|
|
114
|
-
end
|
|
115
|
-
|
|
116
|
-
def verb_get(argv)
|
|
117
|
-
key = argv.shift or raise UsageError.new("get requires a key")
|
|
118
|
-
parse_format!(argv)
|
|
119
|
-
emit(store.get(key))
|
|
120
|
-
end
|
|
121
|
-
|
|
122
|
-
def verb_schema(argv)
|
|
123
|
-
key = argv.shift or raise UsageError.new("schema requires a key")
|
|
124
|
-
parse_format!(argv)
|
|
125
|
-
emit(store.schema_envelope(key))
|
|
126
|
-
end
|
|
127
|
-
|
|
128
|
-
def verb_stale(argv)
|
|
129
|
-
prefix, zone = parse_prefix_and_zone!(argv)
|
|
130
|
-
emit(store.stale(prefix: prefix, zone: zone))
|
|
131
|
-
end
|
|
132
|
-
|
|
133
|
-
def verb_put(argv) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
134
|
-
key = argv.shift or raise UsageError.new("put requires a key")
|
|
135
|
-
as_flag = nil
|
|
136
|
-
use_stdin = false
|
|
137
|
-
action_name = nil
|
|
138
|
-
OptionParser.new do |o|
|
|
139
|
-
o.on("--stdin") { use_stdin = true }
|
|
140
|
-
o.on("--as=ROLE") { |v| as_flag = v }
|
|
141
|
-
o.on("--action=NAME") { |v| action_name = v }
|
|
142
|
-
o.on("--format=FMT") {}
|
|
143
|
-
end.permute!(argv)
|
|
144
|
-
raise UsageError.new("put requires --stdin in v1") unless use_stdin
|
|
145
|
-
|
|
146
|
-
role = Role.resolve(flag: as_flag, env: ENV, root: store.root)
|
|
147
|
-
|
|
148
|
-
raw = @stdin.read
|
|
149
|
-
payload =
|
|
150
|
-
if action_name
|
|
151
|
-
callable = store.registry.action(action_name)
|
|
152
|
-
result =
|
|
153
|
-
begin
|
|
154
|
-
Timeout.timeout(Textus::Refresh::ACTION_TIMEOUT_SECONDS) do
|
|
155
|
-
callable.call(config: { "bytes" => raw }, store: Textus::StoreView.new(store), args: {})
|
|
156
|
-
end
|
|
157
|
-
rescue Timeout::Error
|
|
158
|
-
raise UsageError.new(
|
|
159
|
-
"action '#{action_name}' exceeded #{Textus::Refresh::ACTION_TIMEOUT_SECONDS}s timeout",
|
|
160
|
-
)
|
|
161
|
-
end
|
|
162
|
-
basename = key.split(".").last
|
|
163
|
-
{
|
|
164
|
-
"frontmatter" => {
|
|
165
|
-
"name" => basename,
|
|
166
|
-
"last_refreshed_at" => Time.now.utc.iso8601,
|
|
167
|
-
"actioned_with" => action_name,
|
|
168
|
-
}.merge(result[:frontmatter] || result["frontmatter"] || {}),
|
|
169
|
-
"body" => result[:body] || result["body"] || "",
|
|
170
|
-
}
|
|
171
|
-
else
|
|
172
|
-
JSON.parse(raw)
|
|
173
|
-
end
|
|
174
|
-
|
|
175
|
-
fm = payload["frontmatter"] || {}
|
|
176
|
-
body = payload["body"] || ""
|
|
177
|
-
if_etag = payload["if_etag"]
|
|
178
|
-
emit(store.put(key, frontmatter: fm, body: body, if_etag: if_etag, as: role))
|
|
179
|
-
end
|
|
180
|
-
|
|
181
|
-
def verb_delete(argv)
|
|
182
|
-
key = argv.shift or raise UsageError.new("delete requires a key")
|
|
183
|
-
as_flag = nil
|
|
184
|
-
if_etag = nil
|
|
185
|
-
OptionParser.new do |o|
|
|
186
|
-
o.on("--as=ROLE") { |v| as_flag = v }
|
|
187
|
-
o.on("--if-etag=E") { |v| if_etag = v }
|
|
188
|
-
o.on("--format=FMT") {}
|
|
189
|
-
end.permute!(argv)
|
|
190
|
-
role = Role.resolve(flag: as_flag, env: ENV, root: store.root)
|
|
191
|
-
emit(store.delete(key, if_etag: if_etag, as: role))
|
|
192
|
-
end
|
|
193
|
-
|
|
194
|
-
def verb_validate_all(argv)
|
|
195
|
-
parse_format!(argv)
|
|
196
|
-
res = store.validate_all
|
|
197
|
-
@stdout.puts(JSON.generate(res))
|
|
198
|
-
res["ok"] ? 0 : 1
|
|
199
|
-
end
|
|
200
|
-
|
|
201
|
-
def verb_build(argv)
|
|
202
|
-
prefix = nil
|
|
203
|
-
OptionParser.new do |o|
|
|
204
|
-
o.on("--prefix=K") { |v| prefix = v }
|
|
205
|
-
o.on("--format=FMT") {}
|
|
206
|
-
end.permute!(argv)
|
|
207
|
-
res = Textus::Builder.new(store).build(prefix: prefix)
|
|
208
|
-
@stdout.puts(JSON.generate(res))
|
|
209
|
-
0
|
|
210
|
-
end
|
|
211
|
-
|
|
212
|
-
def verb_deps(argv)
|
|
213
|
-
key = argv.shift or raise UsageError.new("deps requires a key")
|
|
214
|
-
parse_format!(argv)
|
|
215
|
-
emit({ "protocol" => Textus::PROTOCOL, "key" => key, "deps" => store.deps(key) })
|
|
216
|
-
end
|
|
217
|
-
|
|
218
|
-
def verb_rdeps(argv)
|
|
219
|
-
key = argv.shift or raise UsageError.new("rdeps requires a key")
|
|
220
|
-
parse_format!(argv)
|
|
221
|
-
emit({ "protocol" => Textus::PROTOCOL, "key" => key, "rdeps" => store.rdeps(key) })
|
|
222
|
-
end
|
|
223
|
-
|
|
224
|
-
def verb_schema_init(argv)
|
|
225
|
-
name = argv.shift or raise UsageError.new("schema-init NAME")
|
|
226
|
-
from_key = nil
|
|
227
|
-
OptionParser.new do |o|
|
|
228
|
-
o.on("--from=KEY") { |v| from_key = v }
|
|
229
|
-
o.on("--format=FMT") {}
|
|
230
|
-
end.permute!(argv)
|
|
231
|
-
raise UsageError.new("schema-init requires --from=KEY") unless from_key
|
|
232
|
-
|
|
233
|
-
emit(Textus::SchemaTools.init(store, name: name, from: from_key))
|
|
234
|
-
end
|
|
235
|
-
|
|
236
|
-
def verb_schema_diff(argv)
|
|
237
|
-
name = argv.shift or raise UsageError.new("schema-diff NAME")
|
|
238
|
-
parse_format!(argv)
|
|
239
|
-
emit(Textus::SchemaTools.diff(store, name: name))
|
|
240
|
-
end
|
|
241
|
-
|
|
242
|
-
def verb_schema_migrate(argv)
|
|
243
|
-
name = argv.shift or raise UsageError.new("schema-migrate NAME")
|
|
244
|
-
rename = nil
|
|
245
|
-
OptionParser.new do |o|
|
|
246
|
-
o.on("--rename=O:N") { |v| rename = v }
|
|
247
|
-
o.on("--format=FMT") {}
|
|
248
|
-
end.permute!(argv)
|
|
249
|
-
raise UsageError.new("schema-migrate requires --rename=OLD:NEW") unless rename
|
|
250
|
-
|
|
251
|
-
emit(Textus::SchemaTools.migrate(store, name: name, rename: rename))
|
|
252
|
-
end
|
|
253
|
-
|
|
254
|
-
def verb_init(argv)
|
|
255
|
-
OptionParser.new do |o|
|
|
256
|
-
o.on("--format=FMT") {}
|
|
257
|
-
end.permute!(argv)
|
|
258
|
-
target = File.join(@cwd, ".textus")
|
|
259
|
-
res = Textus::Init.run(target)
|
|
260
|
-
@stdout.puts(JSON.generate(res))
|
|
261
|
-
0
|
|
262
|
-
end
|
|
263
|
-
|
|
264
|
-
def verb_accept(argv)
|
|
265
|
-
key = argv.shift or raise UsageError.new("accept requires a key")
|
|
266
|
-
as_flag = nil
|
|
267
|
-
OptionParser.new do |o|
|
|
268
|
-
o.on("--as=ROLE") { |v| as_flag = v }
|
|
269
|
-
o.on("--format=FMT") {}
|
|
270
|
-
end.permute!(argv)
|
|
271
|
-
role = Role.resolve(flag: as_flag, env: ENV, root: store.root)
|
|
272
|
-
emit(store.accept(key, as: role))
|
|
273
|
-
end
|
|
274
|
-
|
|
275
|
-
def verb_action(argv)
|
|
276
|
-
name = argv.shift
|
|
277
|
-
raise UsageError.new("action requires a name") if name.nil?
|
|
278
|
-
|
|
279
|
-
as_flag = nil
|
|
280
|
-
args = {}
|
|
281
|
-
argv.each do |tok|
|
|
282
|
-
case tok
|
|
283
|
-
when /\A--as=(.+)\z/ then as_flag = ::Regexp.last_match(1)
|
|
284
|
-
when /\A--format=/ then next
|
|
285
|
-
when /\A--([\w-]+)=(.*)\z/ then args[::Regexp.last_match(1)] = ::Regexp.last_match(2)
|
|
286
|
-
else
|
|
287
|
-
raise UsageError.new("unknown arg to 'action #{name}': #{tok}")
|
|
288
|
-
end
|
|
289
|
-
end
|
|
290
|
-
|
|
291
|
-
role = Role.resolve(flag: as_flag, env: ENV, root: store.root)
|
|
292
|
-
callable = store.registry.action(name)
|
|
293
|
-
view = StoreView.new(store, writable: true, as: role)
|
|
294
|
-
|
|
295
|
-
begin
|
|
296
|
-
Timeout.timeout(Textus::Refresh::ACTION_TIMEOUT_SECONDS) do
|
|
297
|
-
callable.call(config: {}, store: view, args: args)
|
|
298
|
-
end
|
|
299
|
-
rescue Timeout::Error
|
|
300
|
-
raise UsageError.new(
|
|
301
|
-
"action '#{name}' exceeded #{Textus::Refresh::ACTION_TIMEOUT_SECONDS}s timeout",
|
|
302
|
-
)
|
|
303
|
-
rescue Textus::Error
|
|
304
|
-
raise
|
|
305
|
-
rescue StandardError => e
|
|
306
|
-
raise UsageError.new("action '#{name}' raised: #{e.class}: #{e.message}")
|
|
307
|
-
end
|
|
308
|
-
|
|
309
|
-
emit({ "protocol" => Textus::PROTOCOL, "action" => name, "ok" => true })
|
|
310
|
-
end
|
|
311
|
-
|
|
312
|
-
def verb_refresh(argv)
|
|
313
|
-
key = argv.shift or raise UsageError.new("refresh requires a key")
|
|
314
|
-
as_flag = nil
|
|
315
|
-
OptionParser.new do |o|
|
|
316
|
-
o.on("--as=ROLE") { |v| as_flag = v }
|
|
317
|
-
o.on("--format=FMT") {}
|
|
318
|
-
end.permute!(argv)
|
|
319
|
-
role = Role.resolve(flag: as_flag, env: ENV, root: store.root)
|
|
320
|
-
emit(Textus::Refresh.call(store, key, as: role))
|
|
321
|
-
end
|
|
322
|
-
|
|
323
|
-
def verb_extensions(argv) # rubocop:disable Metrics/AbcSize
|
|
324
|
-
subcommand = argv.shift
|
|
325
|
-
raise UsageError.new("extensions requires 'list'") unless subcommand == "list"
|
|
326
|
-
|
|
327
|
-
kind = nil
|
|
328
|
-
OptionParser.new do |o|
|
|
329
|
-
o.on("--kind=K") { |v| kind = v }
|
|
330
|
-
o.on("--format=FMT") {}
|
|
331
|
-
end.permute!(argv)
|
|
332
|
-
|
|
333
|
-
rows = []
|
|
334
|
-
rows += store.registry.action_names.map { |n| { "kind" => "action", "name" => n.to_s } }
|
|
335
|
-
rows += store.registry.doctor_check_names.map { |n| { "kind" => "doctor_check", "name" => n.to_s } }
|
|
336
|
-
rows += store.registry.reducer_names.map { |n| { "kind" => "reducer", "name" => n.to_s } }
|
|
337
|
-
store.registry.hook_events.each do |evt|
|
|
338
|
-
store.registry.hooks(evt).each do |h|
|
|
339
|
-
rows << { "kind" => "hook", "event" => evt.to_s, "name" => h[:name].to_s }
|
|
340
|
-
end
|
|
341
|
-
end
|
|
342
|
-
store.manifest.entries.each do |e|
|
|
343
|
-
e.events.each do |evt, defs|
|
|
344
|
-
Array(defs).each do |defn|
|
|
345
|
-
next unless defn["exec"]
|
|
346
|
-
|
|
347
|
-
rows << {
|
|
348
|
-
"kind" => "hook", "event" => evt.to_s, "exec" => defn["exec"],
|
|
349
|
-
"key" => e.key, "as" => defn["as"] || "script"
|
|
350
|
-
}
|
|
351
|
-
end
|
|
352
|
-
end
|
|
353
|
-
end
|
|
354
|
-
rows.select! { |r| r["kind"] == kind } if kind
|
|
355
|
-
|
|
356
|
-
emit({ "protocol" => Textus::PROTOCOL, "extensions" => rows })
|
|
357
|
-
end
|
|
358
|
-
|
|
359
|
-
def verb_migrate_keys(argv)
|
|
360
|
-
write = false
|
|
361
|
-
OptionParser.new do |o|
|
|
362
|
-
o.on("--dry-run") { write = false }
|
|
363
|
-
o.on("--write") { write = true }
|
|
364
|
-
o.on("--format=FMT") {}
|
|
365
|
-
end.permute!(argv)
|
|
366
|
-
res = Textus::MigrateKeys.run(store, write: write)
|
|
367
|
-
@stdout.puts(JSON.generate(res))
|
|
368
|
-
res["ok"] ? 0 : 1
|
|
369
|
-
end
|
|
370
|
-
|
|
371
|
-
def verb_mv(argv)
|
|
372
|
-
old_key = argv.shift or raise UsageError.new("mv requires <old-key> <new-key>")
|
|
373
|
-
new_key = argv.shift or raise UsageError.new("mv requires <old-key> <new-key>")
|
|
374
|
-
as_flag = nil
|
|
375
|
-
dry_run = false
|
|
376
|
-
OptionParser.new do |o|
|
|
377
|
-
o.on("--as=ROLE") { |v| as_flag = v }
|
|
378
|
-
o.on("--dry-run") { dry_run = true }
|
|
379
|
-
o.on("--format=FMT") {}
|
|
380
|
-
end.permute!(argv)
|
|
381
|
-
role = Role.resolve(flag: as_flag, env: ENV, root: store.root)
|
|
382
|
-
emit(store.mv(old_key, new_key, as: role, dry_run: dry_run))
|
|
383
|
-
end
|
|
384
|
-
|
|
385
|
-
def verb_uid(argv)
|
|
386
|
-
key = argv.shift or raise UsageError.new("uid requires a key")
|
|
387
|
-
parse_format!(argv)
|
|
388
|
-
emit({ "protocol" => PROTOCOL, "key" => key, "uid" => store.uid(key) })
|
|
389
|
-
end
|
|
390
|
-
|
|
391
|
-
def verb_intro(argv)
|
|
392
|
-
parse_format!(argv)
|
|
393
|
-
emit(Textus::Intro.run(store))
|
|
394
|
-
end
|
|
395
|
-
|
|
396
|
-
def verb_doctor(argv)
|
|
397
|
-
OptionParser.new do |o|
|
|
398
|
-
o.on("--format=FMT") {}
|
|
399
|
-
end.permute!(argv)
|
|
400
|
-
res = Textus::Doctor.run(store)
|
|
401
|
-
@stdout.puts(JSON.generate(res))
|
|
402
|
-
res["ok"] ? 0 : 1
|
|
403
|
-
end
|
|
404
|
-
|
|
405
|
-
def verb_published(argv)
|
|
406
|
-
parse_format!(argv)
|
|
407
|
-
emit({ "protocol" => Textus::PROTOCOL, "published" => store.published })
|
|
408
|
-
end
|
|
409
|
-
|
|
410
|
-
def emit(obj)
|
|
411
|
-
@stdout.puts(JSON.generate(obj))
|
|
412
|
-
0
|
|
79
|
+
def dispatch(klass, argv, deprecated_alias: false)
|
|
80
|
+
v = klass.new(stdin: @stdin, stdout: @stdout, stderr: @stderr, cwd: @cwd)
|
|
81
|
+
v.deprecated_alias = true if deprecated_alias && v.respond_to?(:deprecated_alias=)
|
|
82
|
+
v.parse(argv)
|
|
83
|
+
v.call(klass.needs_store? ? store : nil)
|
|
413
84
|
end
|
|
414
85
|
|
|
415
86
|
def emit_error(err)
|
|
@@ -421,18 +92,25 @@ module Textus
|
|
|
421
92
|
|
|
422
93
|
def print_help
|
|
423
94
|
@stdout.puts <<~HELP
|
|
424
|
-
textus #{VERSION} — reference implementation of #{PROTOCOL}
|
|
425
|
-
|
|
426
|
-
Usage:
|
|
427
|
-
textus list [--prefix=KEY] --
|
|
428
|
-
textus where KEY
|
|
429
|
-
textus get KEY
|
|
430
|
-
textus put KEY --stdin [--action=NAME] --
|
|
431
|
-
textus
|
|
432
|
-
textus
|
|
433
|
-
textus
|
|
95
|
+
textus #{VERSION} — reference implementation of #{PROTOCOL} (was textus/1)
|
|
96
|
+
|
|
97
|
+
Usage (json output is the default; --format=json accepted for back-compat):
|
|
98
|
+
textus list [--prefix=KEY] [--zone=Z]
|
|
99
|
+
textus where KEY
|
|
100
|
+
textus get KEY
|
|
101
|
+
textus put KEY --stdin [--action=NAME] --as=ROLE
|
|
102
|
+
textus stale [--prefix=KEY] [--zone=Z]
|
|
103
|
+
textus doctor
|
|
104
|
+
textus intro
|
|
105
|
+
textus migrate v2
|
|
106
|
+
|
|
107
|
+
textus key {mv,uid,migrate}
|
|
108
|
+
textus schema {show,init,diff,migrate}
|
|
109
|
+
textus extension {list,run}
|
|
110
|
+
|
|
111
|
+
Deprecated (removed in 0.6): mv, uid, migrate-keys, schema-init,
|
|
112
|
+
schema-diff, schema-migrate, extensions, action.
|
|
434
113
|
HELP
|
|
435
114
|
end
|
|
436
115
|
end
|
|
437
|
-
# rubocop:enable Metrics/ClassLength
|
|
438
116
|
end
|
data/lib/textus/doctor.rb
CHANGED
|
@@ -6,23 +6,27 @@ module Textus
|
|
|
6
6
|
# Health check for a Textus store. Returns a JSON-friendly Hash envelope
|
|
7
7
|
# with an `issues` array and a summary. Each issue is a Hash with
|
|
8
8
|
# `code`, `level`, `subject`, `message`, and optionally `fix`.
|
|
9
|
-
module Doctor # rubocop:disable Metrics/ModuleLength --
|
|
9
|
+
module Doctor # rubocop:disable Metrics/ModuleLength -- 9 built-in checks + extension dispatch
|
|
10
10
|
LEVELS = %w[error warning info].freeze
|
|
11
11
|
DOCTOR_CHECK_TIMEOUT_SECONDS = 2
|
|
12
|
+
ALL_CHECKS = %w[
|
|
13
|
+
manifest_files schemas templates extensions illegal_keys
|
|
14
|
+
sentinels audit_log unowned_schema_fields schema_violations
|
|
15
|
+
].freeze
|
|
12
16
|
|
|
13
17
|
module_function
|
|
14
18
|
|
|
15
|
-
def run(store)
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
issues
|
|
25
|
-
issues.concat(run_registered_checks(store))
|
|
19
|
+
def run(store, checks: nil)
|
|
20
|
+
selected = checks ? Array(checks).map(&:to_s) : ALL_CHECKS
|
|
21
|
+
unknown = selected - ALL_CHECKS
|
|
22
|
+
unless unknown.empty?
|
|
23
|
+
raise UsageError.new(
|
|
24
|
+
"unknown doctor check: #{unknown.first}. Valid checks: #{ALL_CHECKS.join(", ")}",
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
issues = run_builtin_checks(store, selected)
|
|
29
|
+
issues.concat(run_registered_checks(store)) # extensions always run
|
|
26
30
|
|
|
27
31
|
summary = LEVELS.to_h { |l| [l, issues.count { |i| i["level"] == l }] }
|
|
28
32
|
{
|
|
@@ -33,6 +37,20 @@ module Textus
|
|
|
33
37
|
}
|
|
34
38
|
end
|
|
35
39
|
|
|
40
|
+
def run_builtin_checks(store, selected)
|
|
41
|
+
issues = []
|
|
42
|
+
issues.concat(check_manifest_files(store)) if selected.include?("manifest_files")
|
|
43
|
+
issues.concat(check_schemas(store)) if selected.include?("schemas")
|
|
44
|
+
issues.concat(check_templates(store)) if selected.include?("templates")
|
|
45
|
+
issues.concat(check_extensions(store)) if selected.include?("extensions")
|
|
46
|
+
issues.concat(check_illegal_keys(store)) if selected.include?("illegal_keys")
|
|
47
|
+
issues.concat(check_sentinels(store)) if selected.include?("sentinels")
|
|
48
|
+
issues.concat(check_audit_log(store)) if selected.include?("audit_log")
|
|
49
|
+
issues.concat(check_unowned_schema_fields(store)) if selected.include?("unowned_schema_fields")
|
|
50
|
+
issues.concat(check_schema_violations(store)) if selected.include?("schema_violations")
|
|
51
|
+
issues
|
|
52
|
+
end
|
|
53
|
+
|
|
36
54
|
# --- Checks -----------------------------------------------------------
|
|
37
55
|
|
|
38
56
|
def check_manifest_files(store)
|
|
@@ -199,32 +217,30 @@ module Textus
|
|
|
199
217
|
stripped = line.chomp
|
|
200
218
|
next if stripped.empty?
|
|
201
219
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
220
|
+
if stripped.start_with?("{")
|
|
221
|
+
begin
|
|
222
|
+
JSON.parse(stripped)
|
|
223
|
+
rescue JSON::ParserError => e
|
|
224
|
+
out << {
|
|
225
|
+
"code" => "audit.parse_error",
|
|
226
|
+
"level" => "warning",
|
|
227
|
+
"subject" => "#{path}:#{lineno}",
|
|
228
|
+
"message" => "audit log line #{lineno} is invalid JSON: #{e.message}",
|
|
229
|
+
"fix" => "inspect #{path} at line #{lineno} and remove the corrupted row",
|
|
230
|
+
}
|
|
231
|
+
end
|
|
232
|
+
else
|
|
233
|
+
# Legacy TSV: minimum 6 fields. Removed in 0.6.
|
|
234
|
+
fields = stripped.split("\t")
|
|
235
|
+
next if fields.length >= 6
|
|
218
236
|
|
|
219
|
-
begin
|
|
220
|
-
JSON.parse(extras)
|
|
221
|
-
rescue JSON::ParserError => e
|
|
222
237
|
out << {
|
|
223
238
|
"code" => "audit.parse_error",
|
|
224
239
|
"level" => "warning",
|
|
225
240
|
"subject" => "#{path}:#{lineno}",
|
|
226
|
-
"message" => "audit log line #{lineno}
|
|
227
|
-
|
|
241
|
+
"message" => "audit log line #{lineno} has #{fields.length} fields " \
|
|
242
|
+
"(expected >=6 for legacy TSV; consider migrating to NDJSON)",
|
|
243
|
+
"fix" => "inspect #{path} at line #{lineno} and remove the corrupted row",
|
|
228
244
|
}
|
|
229
245
|
end
|
|
230
246
|
end
|
|
@@ -258,6 +274,21 @@ module Textus
|
|
|
258
274
|
out
|
|
259
275
|
end
|
|
260
276
|
|
|
277
|
+
def check_schema_violations(store)
|
|
278
|
+
res = store.validate_all
|
|
279
|
+
res["violations"].map do |v|
|
|
280
|
+
fix = v["expected"] &&
|
|
281
|
+
"field '#{v["field"]}' should be written by '#{v["expected"]}' (last writer: #{v["last_writer"]})"
|
|
282
|
+
{
|
|
283
|
+
"code" => v["code"],
|
|
284
|
+
"level" => "error",
|
|
285
|
+
"subject" => v["key"],
|
|
286
|
+
"message" => v["message"] || "#{v["code"]} on #{v["key"]}",
|
|
287
|
+
"fix" => fix,
|
|
288
|
+
}.compact
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
|
|
261
292
|
def run_registered_checks(store)
|
|
262
293
|
out = []
|
|
263
294
|
view = StoreView.new(store)
|
data/lib/textus/entry/json.rb
CHANGED
|
@@ -17,13 +17,15 @@ module Textus
|
|
|
17
17
|
|
|
18
18
|
meta = parsed["_meta"]
|
|
19
19
|
fm = meta.is_a?(Hash) ? meta : {}
|
|
20
|
-
|
|
20
|
+
content_without_meta = parsed.except("_meta")
|
|
21
|
+
{ "_meta" => fm, "body" => raw, "content" => content_without_meta }
|
|
21
22
|
end
|
|
22
23
|
|
|
23
|
-
def self.serialize(
|
|
24
|
-
_ = frontmatter
|
|
24
|
+
def self.serialize(meta:, body:, content: nil)
|
|
25
25
|
if content.is_a?(Hash)
|
|
26
|
-
|
|
26
|
+
# Re-inject _meta as the first key so on-disk shape is stable.
|
|
27
|
+
on_disk = meta && !meta.empty? ? { "_meta" => meta }.merge(content) : content
|
|
28
|
+
out = ::JSON.pretty_generate(on_disk)
|
|
27
29
|
out += "\n" unless out.end_with?("\n")
|
|
28
30
|
out
|
|
29
31
|
elsif body && !body.to_s.empty?
|
|
@@ -7,7 +7,7 @@ module Textus
|
|
|
7
7
|
def self.parse(raw, path: nil)
|
|
8
8
|
raw = raw.dup.force_encoding(Encoding::UTF_8)
|
|
9
9
|
raise BadFrontmatter.new(path, "entry is not valid UTF-8") unless raw.valid_encoding?
|
|
10
|
-
return { "
|
|
10
|
+
return { "_meta" => {}, "body" => raw, "content" => nil } unless raw.start_with?("---\n") || raw.start_with?("---\r\n")
|
|
11
11
|
|
|
12
12
|
lines = raw.split(/\r?\n/, -1)
|
|
13
13
|
close_idx = lines[1..].index("---")
|
|
@@ -22,12 +22,12 @@ module Textus
|
|
|
22
22
|
raise BadFrontmatter.new(path, "YAML parse failed: #{e.message}")
|
|
23
23
|
end
|
|
24
24
|
fm = {} unless fm.is_a?(Hash)
|
|
25
|
-
{ "
|
|
25
|
+
{ "_meta" => fm, "body" => body, "content" => nil }
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
-
def self.serialize(
|
|
28
|
+
def self.serialize(meta:, body:, content: nil)
|
|
29
29
|
_ = content # markdown ignores content
|
|
30
|
-
fm_yaml =
|
|
30
|
+
fm_yaml = meta.empty? ? "" : ::YAML.dump(meta).sub(/\A---\n/, "")
|
|
31
31
|
body = body.to_s
|
|
32
32
|
body += "\n" unless body.empty? || body.end_with?("\n")
|
|
33
33
|
"---\n#{fm_yaml}---\n#{body}"
|
data/lib/textus/entry/text.rb
CHANGED
|
@@ -6,11 +6,11 @@ module Textus
|
|
|
6
6
|
raw = raw.dup.force_encoding(Encoding::UTF_8)
|
|
7
7
|
raise BadFrontmatter.new(path, "entry is not valid UTF-8") unless raw.valid_encoding?
|
|
8
8
|
|
|
9
|
-
{ "
|
|
9
|
+
{ "_meta" => {}, "body" => raw, "content" => nil }
|
|
10
10
|
end
|
|
11
11
|
|
|
12
|
-
def self.serialize(
|
|
13
|
-
_ =
|
|
12
|
+
def self.serialize(meta:, body:, content: nil)
|
|
13
|
+
_ = meta
|
|
14
14
|
_ = content
|
|
15
15
|
b = body.to_s
|
|
16
16
|
b += "\n" unless b.empty? || b.end_with?("\n")
|