okf 1.7.0 → 1.9.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 +368 -0
- data/README.md +78 -24
- data/lib/okf/bundle/folder.rb +25 -2
- data/lib/okf/bundle/graph.rb +5 -1
- data/lib/okf/bundle/linter.rb +6 -1
- data/lib/okf/bundle/reader.rb +21 -4
- data/lib/okf/bundle/search/index.rb +65 -0
- data/lib/okf/bundle/search/scan.rb +89 -0
- data/lib/okf/bundle/search.rb +262 -66
- data/lib/okf/bundle.rb +2 -2
- data/lib/okf/cli.rb +917 -131
- data/lib/okf/registry.rb +370 -0
- data/lib/okf/{server → render}/graph/template.html.erb +1081 -130
- data/lib/okf/{server → render}/graph.rb +67 -9
- data/lib/okf/server/app.rb +23 -45
- data/lib/okf/server/hub.rb +207 -0
- data/lib/okf/skill/SKILL.md +28 -17
- data/lib/okf/skill/playbooks/consume.md +5 -3
- data/lib/okf/skill/playbooks/maintain.md +1 -1
- data/lib/okf/skill/playbooks/search.md +50 -7
- data/lib/okf/skill/reference/authoring.md +3 -2
- data/lib/okf/skill/reference/cli.md +200 -23
- data/lib/okf/version.rb +1 -1
- data/lib/okf.rb +8 -0
- metadata +21 -3
data/lib/okf/registry.rb
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module OKF
|
|
6
|
+
# A persistent, ordered registry of bundle references — the kernel behind the
|
|
7
|
+
# multi-bundle server. It is a plain JSON file (no database) under $OKF_HOME
|
|
8
|
+
# (default ~/.okf), so `okf registry set`/`del` and a later bare `okf server`
|
|
9
|
+
# share one on-disk list. Part of the shell — it reads and writes a file.
|
|
10
|
+
#
|
|
11
|
+
# registry = OKF::Registry.load
|
|
12
|
+
# registry.add("docs") # persists, returns the Entry
|
|
13
|
+
# registry.default = "docs" # moves docs to the front
|
|
14
|
+
# registry.rename("docs", "handbook") # new slug, same path
|
|
15
|
+
# registry.default # => the first Entry
|
|
16
|
+
# registry.listing # => [{ slug:, title:, path:, default: }]
|
|
17
|
+
#
|
|
18
|
+
# **The first entry is the default** — the bundle a bare `okf server` opens at
|
|
19
|
+
# `/`. That is position, not a stored slug: a slug would be a foreign key into
|
|
20
|
+
# this same list, and every operation would owe it referential integrity —
|
|
21
|
+
# carry it through a rename, re-point it after an add --as, clear it on a
|
|
22
|
+
# remove, and survive it dangling. Order is state the registry already keeps,
|
|
23
|
+
# so `default=` just moves the entry to the front and there is nothing left to
|
|
24
|
+
# maintain or to dangle.
|
|
25
|
+
#
|
|
26
|
+
# On disk: { "bundles" => [ { "slug" => …, "path" => absolute dir,
|
|
27
|
+
# "title" => label } ] }, the first row being the default. A bare array (the
|
|
28
|
+
# original shape) still reads.
|
|
29
|
+
class Registry
|
|
30
|
+
# One registered bundle: a unique +slug+, the absolute +path+ on disk, and a
|
|
31
|
+
# human-readable +title+ ("parent/dir").
|
|
32
|
+
Entry = Struct.new(:slug, :path, :title)
|
|
33
|
+
|
|
34
|
+
HOME_ENV = "OKF_HOME"
|
|
35
|
+
DEFAULT_HOME = "~/.okf"
|
|
36
|
+
|
|
37
|
+
# Slugs the ref grammar has already spoken for. `@all` means every registered
|
|
38
|
+
# bundle, so a bundle slugged "all" could never be named — reserve it here,
|
|
39
|
+
# where both slug paths pass, rather than let one register and then be
|
|
40
|
+
# unreachable.
|
|
41
|
+
RESERVED_SLUGS = %w[all].freeze
|
|
42
|
+
|
|
43
|
+
class << self
|
|
44
|
+
# The registry file: $OKF_HOME/registry.json, $OKF_HOME defaulting to ~/.okf.
|
|
45
|
+
# The env var is the only lever the CLI offers; +home+ overrides it for an
|
|
46
|
+
# embedding app (and the tests), which should not have to mutate a
|
|
47
|
+
# process-global to say which registry it means. An empty +home+ or env var
|
|
48
|
+
# counts as unset — expand_path("") would silently plant the registry in
|
|
49
|
+
# the current directory.
|
|
50
|
+
def path(home: nil)
|
|
51
|
+
env = ENV.fetch(HOME_ENV, nil)
|
|
52
|
+
home = nil if home.nil? || home.to_s.empty?
|
|
53
|
+
base = home || (env.nil? || env.empty? ? DEFAULT_HOME : env)
|
|
54
|
+
File.join(expand(base), "registry.json")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# File.expand_path raises ArgumentError on a "~nosuchuser" (or bare "~"
|
|
58
|
+
# with no HOME) — a bad *argument*, which the CLI must report as a usage
|
|
59
|
+
# error rather than let escape as a backtrace and an exit code that means
|
|
60
|
+
# "failing bundle".
|
|
61
|
+
def expand(base)
|
|
62
|
+
File.expand_path(base)
|
|
63
|
+
rescue ArgumentError => e
|
|
64
|
+
raise OKF::Error, "cannot expand #{base}: #{e.message}"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def load(home: nil)
|
|
68
|
+
new(path(home: home))
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Normalize +base+ to a url-safe slug (lowercase, dashes) — "" when nothing
|
|
72
|
+
# survives. This is the form a *lookup* wants: "@***" normalizes to nothing
|
|
73
|
+
# and must stay nothing, so it fails as a bad ref instead of resolving to
|
|
74
|
+
# whatever #slugify's placeholder happens to name.
|
|
75
|
+
def normalize(base)
|
|
76
|
+
base.to_s.strip.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-+|-+\z/, "")
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# +base+ normalized, with a placeholder when nothing survives — for
|
|
80
|
+
# *minting* a slug from a directory basename, where some name must come
|
|
81
|
+
# out. Shared with the server's ephemeral (unregistered) bundles so both
|
|
82
|
+
# slug the same way.
|
|
83
|
+
def slugify(base)
|
|
84
|
+
slug = normalize(base)
|
|
85
|
+
slug.empty? ? "bundle" : slug
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Does this argument name a location rather than a slug? A separator settles
|
|
89
|
+
# it: #normalize maps one to a dash, so no slug can contain one. The reading
|
|
90
|
+
# matters because #remove takes either — and a path that matched no entry
|
|
91
|
+
# must not fall through to a *slug* lookup, where "./notes" strips to
|
|
92
|
+
# "notes" and deletes an entry pointing somewhere else entirely, reporting
|
|
93
|
+
# success. This is the line between the two readings.
|
|
94
|
+
def path_shaped?(arg)
|
|
95
|
+
arg.to_s.include?(File::SEPARATOR)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# +base+ slugified, then suffixed (-2, -3, …) until it avoids every slug in
|
|
99
|
+
# +taken+. Reserving is the caller's business, not this helper's: the
|
|
100
|
+
# ephemeral hub mints through here too, and it has no registry and no
|
|
101
|
+
# @refs, so a name reserved for the ref grammar would suffix it to /b/all-2/
|
|
102
|
+
# against a /b/all/ that does not exist. #unique_slug adds the reserved
|
|
103
|
+
# names because the registry is where they mean something.
|
|
104
|
+
def dedupe(base, taken)
|
|
105
|
+
slug = slugify(base)
|
|
106
|
+
return slug unless taken.include?(slug)
|
|
107
|
+
|
|
108
|
+
n = 2
|
|
109
|
+
n += 1 while taken.include?("#{slug}-#{n}")
|
|
110
|
+
"#{slug}-#{n}"
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
include Enumerable
|
|
115
|
+
|
|
116
|
+
attr_reader :path
|
|
117
|
+
|
|
118
|
+
def initialize(path)
|
|
119
|
+
@path = path
|
|
120
|
+
@entries = []
|
|
121
|
+
read
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def each(&block)
|
|
125
|
+
@entries.each(&block)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def size
|
|
129
|
+
@entries.size
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def empty?
|
|
133
|
+
@entries.empty?
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def slugs
|
|
137
|
+
@entries.map(&:slug)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def get(slug)
|
|
141
|
+
@entries.find { |entry| entry.slug == slug }
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# The default bundle a bare `okf server` selects: the first entry still on
|
|
145
|
+
# disk. Position decides it, but a position the hub cannot serve decides
|
|
146
|
+
# nothing — it drops a vanished directory rather than serving a hole, so the
|
|
147
|
+
# default has to skip the same ones or `registry list` would star a bundle
|
|
148
|
+
# `/` never opens. Falling back to the first entry when *every* one has
|
|
149
|
+
# vanished keeps a bare `@` failing with "points to <path>, which is not a
|
|
150
|
+
# directory" instead of the much worse "not a registered bundle". nil only
|
|
151
|
+
# when nothing is registered.
|
|
152
|
+
def default
|
|
153
|
+
@entries.find { |entry| File.directory?(entry.path) } || @entries.first
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Choose which bundle `/` opens, by moving that entry to the front. Persists;
|
|
157
|
+
# raises on an unknown slug. The ask is normalized the way registration
|
|
158
|
+
# normalized it, so the name the user typed at --as is the name that resolves
|
|
159
|
+
# here.
|
|
160
|
+
#
|
|
161
|
+
# A directory that is gone is refused, exactly as #add refuses to register
|
|
162
|
+
# one: both are explicit asks, and #default skips a vanished entry, so
|
|
163
|
+
# allowing the move would answer `default bundle → <some other slug>` to
|
|
164
|
+
# someone who named this one.
|
|
165
|
+
def default=(slug)
|
|
166
|
+
entry = get(self.class.normalize(slug))
|
|
167
|
+
raise OKF::Error, "no such bundle: #{slug}" unless entry
|
|
168
|
+
unless File.directory?(entry.path)
|
|
169
|
+
raise OKF::Error, "cannot default to #{entry.slug}: #{entry.path} is not a directory " \
|
|
170
|
+
"(okf registry del #{entry.slug}, or restore it)"
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
@entries.delete(entry)
|
|
174
|
+
@entries.unshift(entry)
|
|
175
|
+
write
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Give the bundle at +old_slug+ a new slug (its mount path and switcher name).
|
|
179
|
+
# The new name is slugified; a collision with another entry raises rather than
|
|
180
|
+
# silently suffixing — a rename is explicit. Position is untouched, so a
|
|
181
|
+
# renamed default stays the default with no bookkeeping.
|
|
182
|
+
def rename(old_slug, new_slug)
|
|
183
|
+
entry = get(self.class.normalize(old_slug))
|
|
184
|
+
raise OKF::Error, "no such bundle: #{old_slug}" unless entry
|
|
185
|
+
|
|
186
|
+
slug = explicit_slug(new_slug, entry)
|
|
187
|
+
entry.slug = slug
|
|
188
|
+
write
|
|
189
|
+
entry
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# One row per bundle for the CLI list: +dir+ is the on-disk directory, +mount+
|
|
193
|
+
# the server path, +default+ true for the first row, +missing+ true when the
|
|
194
|
+
# registered directory no longer exists on disk. +default+ stays in the row
|
|
195
|
+
# even though it is now derivable from position — a consumer reading the JSON
|
|
196
|
+
# should not have to know the rule to find the bundle `/` opens.
|
|
197
|
+
def listing
|
|
198
|
+
chosen = default
|
|
199
|
+
@entries.map do |entry|
|
|
200
|
+
{ slug: entry.slug, title: entry.title, dir: entry.path, mount: "/b/#{entry.slug}/",
|
|
201
|
+
default: entry.equal?(chosen), missing: !File.directory?(entry.path) }
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Register +dir+ (must be a readable bundle directory). Re-registering the same
|
|
206
|
+
# path refreshes its title in place (and its slug when +as+ is given). A
|
|
207
|
+
# basename-derived slug is deduped with a suffix; an explicit +as+ raises on
|
|
208
|
+
# collision instead — the same "explicit is explicit" rule as #rename.
|
|
209
|
+
# +default: true+ moves it to the front. Persists, then returns the entry.
|
|
210
|
+
def add(dir, as: nil, default: false)
|
|
211
|
+
root = self.class.expand(dir.to_s)
|
|
212
|
+
raise OKF::Error, "not a directory: #{dir}" unless File.directory?(root)
|
|
213
|
+
|
|
214
|
+
# The label is path arithmetic; Folder.load would parse every markdown
|
|
215
|
+
# file in the bundle to hand back its own basename.
|
|
216
|
+
title = Bundle::Folder.label(root)
|
|
217
|
+
entry = @entries.find { |candidate| candidate.path == root }
|
|
218
|
+
if entry
|
|
219
|
+
entry.title = title
|
|
220
|
+
entry.slug = explicit_slug(as, entry) if as
|
|
221
|
+
else
|
|
222
|
+
slug = as ? explicit_slug(as, nil) : unique_slug(File.basename(root), nil)
|
|
223
|
+
entry = Entry.new(slug, root, title)
|
|
224
|
+
@entries << entry
|
|
225
|
+
end
|
|
226
|
+
if default
|
|
227
|
+
@entries.delete(entry)
|
|
228
|
+
@entries.unshift(entry)
|
|
229
|
+
end
|
|
230
|
+
write
|
|
231
|
+
entry
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Remove the entry named by +slug+ (or whose path matches). Returns the removed
|
|
235
|
+
# entry, or nil when nothing matched. Removing the default needs no cleanup —
|
|
236
|
+
# the next entry is first, and so is the default. Persists on change.
|
|
237
|
+
def remove(slug)
|
|
238
|
+
# Slug-or-dir, so the normalized reading comes *last*: "./docs" must mean
|
|
239
|
+
# the directory while one is registered under that path, and only fall
|
|
240
|
+
# back to naming the "docs" slug when no path matches.
|
|
241
|
+
target = get(slug) ||
|
|
242
|
+
@entries.find { |entry| entry.path == self.class.expand(slug.to_s) } ||
|
|
243
|
+
(self.class.path_shaped?(slug) ? nil : get(self.class.normalize(slug)))
|
|
244
|
+
return nil unless target
|
|
245
|
+
|
|
246
|
+
@entries.delete(target)
|
|
247
|
+
write
|
|
248
|
+
target
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
private
|
|
252
|
+
|
|
253
|
+
# A basename-derived slug: silently deduped with a numeric suffix, around the
|
|
254
|
+
# reserved names as well as the taken ones — so a directory named all/
|
|
255
|
+
# registers as "all-2". This is the minting path, where the gem invents a name
|
|
256
|
+
# and a suffix is expected; #explicit_slug refuses instead, because there the
|
|
257
|
+
# name is the user's.
|
|
258
|
+
def unique_slug(base, skip)
|
|
259
|
+
taken = @entries.reject { |entry| entry.equal?(skip) }.map(&:slug)
|
|
260
|
+
self.class.dedupe(base, taken + RESERVED_SLUGS)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# An explicitly requested slug (--as, rename): normalized, and a collision
|
|
264
|
+
# with another entry is an error, never a silent suffix. Nothing surviving
|
|
265
|
+
# normalization is an error too — the same rule as a collision, since
|
|
266
|
+
# answering `--as "***"` with the placeholder slug would substitute a name
|
|
267
|
+
# the user did not choose.
|
|
268
|
+
def explicit_slug(base, skip)
|
|
269
|
+
slug = self.class.normalize(base)
|
|
270
|
+
raise OKF::Error, "not a usable slug: #{base} (letters and digits, please)" if slug.empty?
|
|
271
|
+
|
|
272
|
+
# Reserved names are refused, never suffixed: substituting "all-2" for the
|
|
273
|
+
# "all" they asked for is exactly the name-they-did-not-choose the rule
|
|
274
|
+
# below forbids.
|
|
275
|
+
if RESERVED_SLUGS.include?(slug)
|
|
276
|
+
raise OKF::Error, "not a usable slug: #{slug} is reserved (@#{slug} names every registered bundle)"
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
taken = @entries.reject { |entry| entry.equal?(skip) }.map(&:slug)
|
|
280
|
+
# Refusing is the "never substitute a name you chose" rule, but a refusal
|
|
281
|
+
# with no way forward is a dead end: the slug is spoken for by another
|
|
282
|
+
# entry, so say which move frees it.
|
|
283
|
+
raise OKF::Error, "slug already taken: #{slug} (rename or remove that entry first)" if taken.include?(slug)
|
|
284
|
+
|
|
285
|
+
slug
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def read
|
|
289
|
+
return unless File.exist?(@path)
|
|
290
|
+
|
|
291
|
+
data = JSON.parse(File.read(@path, encoding: "UTF-8"))
|
|
292
|
+
rows = data.is_a?(Hash) ? Array(data["bundles"]) : Array(data) # bare array: the original shape
|
|
293
|
+
@entries = rows.map { |row| entry_from(row) }
|
|
294
|
+
normalize_slugs
|
|
295
|
+
rescue JSON::ParserError => e
|
|
296
|
+
malformed("#{e.message} (fix or delete the file)")
|
|
297
|
+
rescue SystemCallError => e
|
|
298
|
+
malformed(e.message)
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# One row to an Entry, with the shape checked. Valid JSON is not a valid
|
|
302
|
+
# registry: the parse error above tells the user to fix the file by hand,
|
|
303
|
+
# which invites a row with no "path" — that must fail here as a usage error,
|
|
304
|
+
# not survive to crash a File.directory? call three frames away.
|
|
305
|
+
def entry_from(row)
|
|
306
|
+
unless row.is_a?(Hash) && row["slug"].is_a?(String) && row["path"].is_a?(String) && !row["path"].empty?
|
|
307
|
+
malformed('every entry needs a "slug" and a "path" (fix or delete the file)')
|
|
308
|
+
end
|
|
309
|
+
Entry.new(row["slug"], row["path"], row["title"] || File.basename(row["path"]))
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# Slugs enter this list three ways — minted from a basename, asked for with
|
|
313
|
+
# --as, and read from this file — and the first two normalize. The third did
|
|
314
|
+
# not, and that asymmetry is the whole bug: the file could hold a name the
|
|
315
|
+
# listing prints and nothing else can reach. `@my-docs` misses "My Docs", and
|
|
316
|
+
# so do #rename and #default=, which look it up through the very
|
|
317
|
+
# normalization the read skipped — so the two verbs that could fix the entry
|
|
318
|
+
# are the two that cannot see it, leaving hand-editing JSON as the only way
|
|
319
|
+
# out. Reserved names are the same story with a different cause, so they take
|
|
320
|
+
# the same cure rather than a second one.
|
|
321
|
+
#
|
|
322
|
+
# A slug registration would have produced unchanged is left exactly as it is —
|
|
323
|
+
# including one already carrying a suffix — so repairing a sick entry never
|
|
324
|
+
# renames a healthy one. Everything else is minted around the names the other
|
|
325
|
+
# entries hold, through the same call registration makes. The next write
|
|
326
|
+
# persists it.
|
|
327
|
+
def normalize_slugs
|
|
328
|
+
@entries.each do |entry|
|
|
329
|
+
next if usable_slug?(entry.slug)
|
|
330
|
+
|
|
331
|
+
entry.slug = unique_slug(entry.slug, entry)
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
# A stored slug that registration would have handed back untouched:
|
|
336
|
+
# normalized, non-empty, and not a name the ref grammar has taken.
|
|
337
|
+
def usable_slug?(slug)
|
|
338
|
+
!slug.empty? && slug == self.class.normalize(slug) && !RESERVED_SLUGS.include?(slug)
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def malformed(detail)
|
|
342
|
+
raise OKF::Error, "malformed registry at #{@path}: #{detail}"
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
# Write-to-temp then rename, so a concurrent reader (another verb, a booting
|
|
346
|
+
# server) never sees a torn file — the same promotion the bundle Writer uses.
|
|
347
|
+
# Two racing writers stay last-writer-wins; the registry is a per-user file.
|
|
348
|
+
def write
|
|
349
|
+
FileUtils.mkdir_p(File.dirname(@path))
|
|
350
|
+
rows = @entries.map { |entry| { "slug" => entry.slug, "path" => entry.path, "title" => entry.title } }
|
|
351
|
+
payload = { "bundles" => rows }
|
|
352
|
+
tmp = "#{@path}.tmp-#{Process.pid}"
|
|
353
|
+
begin
|
|
354
|
+
File.write(tmp, JSON.pretty_generate(payload) + "\n")
|
|
355
|
+
File.rename(tmp, @path)
|
|
356
|
+
rescue StandardError
|
|
357
|
+
# A failed write must not leave its scratch file behind: the registry
|
|
358
|
+
# lives in the user's $OKF_HOME, and litter there outlives the error.
|
|
359
|
+
FileUtils.rm_f(tmp)
|
|
360
|
+
raise
|
|
361
|
+
end
|
|
362
|
+
rescue SystemCallError => e
|
|
363
|
+
# #read already turns an errno into an OKF::Error, and every registry verb
|
|
364
|
+
# rescues exactly that — so letting one out of #write hands the user a
|
|
365
|
+
# backtrace under exit 1, a code the CLI spends on "non-conformant bundle".
|
|
366
|
+
# An unwritable $OKF_HOME is a usage error, and it says which file and why.
|
|
367
|
+
raise OKF::Error, "cannot write registry at #{@path}: #{e.message}"
|
|
368
|
+
end
|
|
369
|
+
end
|
|
370
|
+
end
|