okf 2.1.0 → 2.2.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/.okf/capabilities/agent-skill.md +112 -0
- data/.okf/capabilities/bundles-manager.md +144 -0
- data/.okf/capabilities/graph-server.md +678 -0
- data/.okf/capabilities/index.md +26 -0
- data/.okf/capabilities/library-api.md +82 -0
- data/.okf/capabilities/linter.md +83 -0
- data/.okf/capabilities/read-views.md +228 -0
- data/.okf/capabilities/render.md +66 -0
- data/.okf/capabilities/search.md +297 -0
- data/.okf/capabilities/validator.md +60 -0
- data/.okf/cli.md +214 -0
- data/.okf/design/browser-tests.md +211 -0
- data/.okf/design/core-shell-split.md +73 -0
- data/.okf/design/index.md +17 -0
- data/.okf/design/integration-first.md +140 -0
- data/.okf/design/packaging.md +65 -0
- data/.okf/design/ruby-floor.md +53 -0
- data/.okf/design/runtime-dependencies.md +82 -0
- data/.okf/design/search-engines.md +154 -0
- data/.okf/design/server-trust-boundary.md +139 -0
- data/.okf/index.md +40 -0
- data/.okf/log.md +724 -0
- data/.okf/model/bundle.md +47 -0
- data/.okf/model/concept.md +75 -0
- data/.okf/model/graph.md +59 -0
- data/.okf/model/index.md +9 -0
- data/.okf/model/skeleton.md +76 -0
- data/.okf/overview.md +87 -0
- data/.okf/registry.md +432 -0
- data/.okf/structure/format-layer.md +59 -0
- data/.okf/structure/index.md +22 -0
- data/.okf/structure/search.md +53 -0
- data/.okf/structure/the-analysers.md +60 -0
- data/.okf/structure/the-cli.md +99 -0
- data/.okf/structure/the-disk-shell.md +76 -0
- data/.okf/structure/the-model.md +81 -0
- data/.okf/structure/the-server.md +74 -0
- data/.okf/structure/the-skill.md +52 -0
- data/.okf/testing/adding-a-verb.md +76 -0
- data/.okf/testing/index.md +12 -0
- data/.okf/testing/the-harness.md +45 -0
- data/CHANGELOG.md +161 -16
- data/README.md +226 -17
- data/lib/okf/cli/command.rb +8 -3
- data/lib/okf/cli/registry.rb +306 -47
- data/lib/okf/cli.rb +1 -1
- data/lib/okf/registry.rb +448 -22
- data/lib/okf/render/graph/template.html.erb +6 -2
- data/lib/okf/server/hub.rb +6 -2
- data/lib/okf/skill/reference/cli/registry.md +49 -6
- data/lib/okf/skill/reference/cli.md +1 -1
- data/lib/okf/version.rb +1 -1
- metadata +46 -5
data/lib/okf/registry.rb
CHANGED
|
@@ -6,7 +6,7 @@ require "pathname"
|
|
|
6
6
|
module OKF
|
|
7
7
|
# A persistent, ordered registry of bundle references — the kernel behind the
|
|
8
8
|
# multi-bundle server. It is a plain JSON file (no database): the global one
|
|
9
|
-
# under $OKF_HOME (default ~/.okf), or a project-local .okf
|
|
9
|
+
# under $OKF_HOME (default ~/.okf), or a project-local .okf.json
|
|
10
10
|
# discovered by walking up from cwd (see .load / .discover), which replaces the
|
|
11
11
|
# global one while you stand in its tree. Either way `okf registry set`/`del`
|
|
12
12
|
# and a later bare `okf server` share one on-disk list. Part of the shell — it
|
|
@@ -32,8 +32,11 @@ module OKF
|
|
|
32
32
|
# original shape) still reads.
|
|
33
33
|
class Registry
|
|
34
34
|
# One registered bundle: a unique +slug+, the absolute +path+ on disk, and a
|
|
35
|
-
# human-readable +title+ ("parent/dir").
|
|
36
|
-
|
|
35
|
+
# human-readable +title+ ("parent/dir"). +link+ names the Link an entry
|
|
36
|
+
# arrived through (nil for one this registry owns), and +origin+ the slug it
|
|
37
|
+
# carries in that linked file — the two differ only when the bare name was
|
|
38
|
+
# already taken here.
|
|
39
|
+
Entry = Struct.new(:slug, :path, :title, :link, :origin)
|
|
37
40
|
|
|
38
41
|
# A named set of bundle sources: a unique +slug+ and an ordered list of
|
|
39
42
|
# +members+ (bundle *or* group slugs), stored normalized. A group has no path
|
|
@@ -55,6 +58,14 @@ module OKF
|
|
|
55
58
|
end
|
|
56
59
|
end
|
|
57
60
|
|
|
61
|
+
# A pointer from the global registry to another registry file: a unique
|
|
62
|
+
# +slug+ and the absolute +registry+ path. Its bundles resolve through it at
|
|
63
|
+
# read time under their own slugs, so nothing is copied and nothing goes
|
|
64
|
+
# stale — the registry's "references, never content" rule one level up. Only
|
|
65
|
+
# the global registry follows links (see .load), which is why a linked file's
|
|
66
|
+
# own links are never read and there is no graph to cycle-guard.
|
|
67
|
+
Link = Struct.new(:slug, :registry)
|
|
68
|
+
|
|
58
69
|
HOME_ENV = "OKF_HOME"
|
|
59
70
|
DEFAULT_HOME = "~/.okf"
|
|
60
71
|
|
|
@@ -62,7 +73,21 @@ module OKF
|
|
|
62
73
|
# working directory rather than read from $OKF_HOME. Its presence is the whole
|
|
63
74
|
# state — no stored "local mode" flag — so a bare `okf server` inside a repo
|
|
64
75
|
# serves that repo's bundles with no global setup.
|
|
65
|
-
LOCAL_FILE = ".okf
|
|
76
|
+
LOCAL_FILE = ".okf.json"
|
|
77
|
+
|
|
78
|
+
# What that file used to be called. It is still discovered, because the file
|
|
79
|
+
# is *committed* — retiring the name outright would break every repository
|
|
80
|
+
# carrying one, to save eight characters. So the short name became canonical
|
|
81
|
+
# and this one keeps resolving; `okf registry` says so once, where a reader
|
|
82
|
+
# can act on it, and nothing else says anything.
|
|
83
|
+
LEGACY_LOCAL_FILE = ".okf-registry.json"
|
|
84
|
+
|
|
85
|
+
# Both names, in the order a single directory prefers them. Discovery reads
|
|
86
|
+
# this list *per directory* rather than sweeping the whole path for one name
|
|
87
|
+
# and then the other — otherwise a legacy file at the repo root would beat a
|
|
88
|
+
# `.okf.json` two levels down, and "the nearest one wins" would quietly mean
|
|
89
|
+
# something else.
|
|
90
|
+
LOCAL_FILES = [ LOCAL_FILE, LEGACY_LOCAL_FILE ].freeze
|
|
66
91
|
|
|
67
92
|
# The lever that forces the global registry even when a local one is on the
|
|
68
93
|
# path up from cwd. Set it (inline) and discovery is skipped — the escape hatch
|
|
@@ -100,8 +125,9 @@ module OKF
|
|
|
100
125
|
end
|
|
101
126
|
|
|
102
127
|
# The registry a run resolves to. Precedence, highest first: OKF_NO_DISCOVERY
|
|
103
|
-
# forces the global one; else a `.okf
|
|
104
|
-
# up from +cwd+ wins; else the
|
|
128
|
+
# forces the global one; else a `.okf.json` (or the legacy
|
|
129
|
+
# `.okf-registry.json`) discovered on the path up from +cwd+ wins; else the
|
|
130
|
+
# global $OKF_HOME registry, exactly as before.
|
|
105
131
|
# +cwd+ nil ⇒ no discovery, so an embedding app that calls `load` with no
|
|
106
132
|
# arguments keeps the global-only behavior — only the CLI opts in by passing
|
|
107
133
|
# `cwd: Dir.pwd`. $OKF_HOME names *where the global registry lives*; it does
|
|
@@ -112,7 +138,10 @@ module OKF
|
|
|
112
138
|
local = looking ? discover(cwd) : nil
|
|
113
139
|
# A local registry anchors its relative paths on its own directory; the
|
|
114
140
|
# global one has no common anchor, so it stays absolute (relative_base nil).
|
|
115
|
-
|
|
141
|
+
# Links are the *global* registry's alone: a discovered local one parses
|
|
142
|
+
# and preserves them but does not resolve them, so depth is one by
|
|
143
|
+
# construction rather than by a limit anyone has to enforce.
|
|
144
|
+
new(local || path(home: home), relative_base: local && File.dirname(local), follow_links: local.nil?)
|
|
116
145
|
end
|
|
117
146
|
|
|
118
147
|
# Walk up from +start+ looking for a local registry; return its absolute path
|
|
@@ -120,8 +149,8 @@ module OKF
|
|
|
120
149
|
def discover(start)
|
|
121
150
|
dir = expand(start.to_s)
|
|
122
151
|
loop do
|
|
123
|
-
|
|
124
|
-
return
|
|
152
|
+
found = LOCAL_FILES.map { |name| File.join(dir, name) }.find { |candidate| File.file?(candidate) }
|
|
153
|
+
return found if found
|
|
125
154
|
|
|
126
155
|
parent = File.dirname(dir)
|
|
127
156
|
break if parent == dir
|
|
@@ -182,11 +211,16 @@ module OKF
|
|
|
182
211
|
# (see .load). nil means an absolute-path registry — the global $OKF_HOME one,
|
|
183
212
|
# and every library caller — so its behavior is exactly what it was before
|
|
184
213
|
# relative storage existed.
|
|
185
|
-
def initialize(path, relative_base: nil)
|
|
214
|
+
def initialize(path, relative_base: nil, follow_links: true)
|
|
186
215
|
@path = path
|
|
187
216
|
@relative_base = relative_base
|
|
217
|
+
@follow_links = follow_links
|
|
188
218
|
@entries = []
|
|
189
219
|
@groups = []
|
|
220
|
+
@links = []
|
|
221
|
+
@link_groups = []
|
|
222
|
+
@link_state = {}
|
|
223
|
+
@link_of = {}
|
|
190
224
|
read
|
|
191
225
|
end
|
|
192
226
|
|
|
@@ -212,7 +246,7 @@ module OKF
|
|
|
212
246
|
|
|
213
247
|
# The group registered under +slug+ (already normalized, like #get), or nil.
|
|
214
248
|
def group?(slug)
|
|
215
|
-
@groups.find { |group| group.slug == slug }
|
|
249
|
+
@groups.find { |group| group.slug == slug } || @link_groups.find { |group| group.slug == slug }
|
|
216
250
|
end
|
|
217
251
|
|
|
218
252
|
# The default bundle a bare `okf server` selects: the first entry still on
|
|
@@ -244,6 +278,8 @@ module OKF
|
|
|
244
278
|
|
|
245
279
|
entry = get(normalized)
|
|
246
280
|
raise OKF::Error, "no such bundle: #{slug}" unless entry
|
|
281
|
+
|
|
282
|
+
refuse_linked(entry, "default to")
|
|
247
283
|
unless File.directory?(entry.path)
|
|
248
284
|
raise OKF::Error, "cannot default to #{entry.slug}: #{entry.path} is not a directory " \
|
|
249
285
|
"(okf registry del #{entry.slug}, or restore it)"
|
|
@@ -263,6 +299,7 @@ module OKF
|
|
|
263
299
|
entry = get(old) || group?(old)
|
|
264
300
|
raise OKF::Error, "no such bundle or group: #{old_slug}" unless entry
|
|
265
301
|
|
|
302
|
+
refuse_linked(entry, "rename")
|
|
266
303
|
slug = explicit_slug(new_slug, entry)
|
|
267
304
|
entry.slug = slug
|
|
268
305
|
# A member list stores slugs, so a rename that stopped at the entry would
|
|
@@ -281,7 +318,8 @@ module OKF
|
|
|
281
318
|
chosen = default
|
|
282
319
|
@entries.map do |entry|
|
|
283
320
|
{ slug: entry.slug, title: entry.title, dir: entry.path, mount: "/b/#{entry.slug}/",
|
|
284
|
-
default: entry.equal?(chosen), missing: !File.directory?(entry.path)
|
|
321
|
+
default: entry.equal?(chosen), missing: !File.directory?(entry.path),
|
|
322
|
+
link: entry.link, origin: entry.origin }
|
|
285
323
|
end
|
|
286
324
|
end
|
|
287
325
|
|
|
@@ -298,6 +336,7 @@ module OKF
|
|
|
298
336
|
# file in the bundle to hand back its own basename.
|
|
299
337
|
title = Bundle::Folder.label(root)
|
|
300
338
|
entry = @entries.find { |candidate| candidate.path == root }
|
|
339
|
+
refuse_linked(entry, "register") if entry
|
|
301
340
|
if entry
|
|
302
341
|
entry.title = title
|
|
303
342
|
entry.slug = explicit_slug(as, entry) if as
|
|
@@ -324,6 +363,7 @@ module OKF
|
|
|
324
363
|
target = get(slug) ||
|
|
325
364
|
@entries.find { |entry| entry.path == self.class.expand(slug.to_s) } ||
|
|
326
365
|
(self.class.path_shaped?(slug) ? nil : get(self.class.normalize(slug)))
|
|
366
|
+
refuse_linked(target, "remove") if target
|
|
327
367
|
if target
|
|
328
368
|
@entries.delete(target)
|
|
329
369
|
cascade_remove(target.slug)
|
|
@@ -337,6 +377,7 @@ module OKF
|
|
|
337
377
|
group = self.class.path_shaped?(slug) ? nil : (group?(slug) || group?(self.class.normalize(slug)))
|
|
338
378
|
return nil unless group
|
|
339
379
|
|
|
380
|
+
refuse_linked(group, "remove")
|
|
340
381
|
@groups.delete(group)
|
|
341
382
|
cascade_remove(group.slug)
|
|
342
383
|
write
|
|
@@ -354,9 +395,13 @@ module OKF
|
|
|
354
395
|
raise OKF::Error, "a group needs at least one member (okf registry group #{name} <@bundle…>)" if members.empty?
|
|
355
396
|
|
|
356
397
|
members.each do |member|
|
|
357
|
-
|
|
398
|
+
found = get(member) || group?(member)
|
|
399
|
+
raise OKF::Error, "no such bundle or group: @#{member} (okf registry list)" unless found
|
|
358
400
|
|
|
359
|
-
|
|
401
|
+
# A group stores slugs, and a linked slug lives only while its link
|
|
402
|
+
# resolves — holding one would dangle the group the moment the link goes,
|
|
403
|
+
# the same foreign key the default rule refused.
|
|
404
|
+
refuse_linked(found, "group")
|
|
360
405
|
end
|
|
361
406
|
|
|
362
407
|
group = group?(name)
|
|
@@ -383,6 +428,7 @@ module OKF
|
|
|
383
428
|
group = group?(name)
|
|
384
429
|
raise OKF::Error, "no such group: #{slug} (okf registry list)" unless group
|
|
385
430
|
|
|
431
|
+
refuse_linked(group, "ungroup")
|
|
386
432
|
asks = normalize_members(member_asks)
|
|
387
433
|
removed = group.members & asks
|
|
388
434
|
group.members -= asks
|
|
@@ -405,6 +451,93 @@ module OKF
|
|
|
405
451
|
entries
|
|
406
452
|
end
|
|
407
453
|
|
|
454
|
+
# Point this registry at another registry file under +slug+: its bundles
|
|
455
|
+
# resolve through the pointer from now on, under their own slugs unless one
|
|
456
|
+
# is already taken here. Re-linking a slug re-points it. Persists, returns
|
|
457
|
+
# the Link.
|
|
458
|
+
#
|
|
459
|
+
# The target must exist *now* — a link is an explicit ask, and one typed at a
|
|
460
|
+
# path that is not a registry file is a typo worth catching at the keyboard
|
|
461
|
+
# rather than a silent empty section later. (A target that vanishes
|
|
462
|
+
# afterwards is a different case, and is tolerated: see #links_listing.)
|
|
463
|
+
def link(slug, target)
|
|
464
|
+
name = explicit_link_slug(slug)
|
|
465
|
+
registry = self.class.expand(target.to_s)
|
|
466
|
+
raise OKF::Error, "not a registry file: #{target}" unless File.file?(registry)
|
|
467
|
+
raise OKF::Error, "a registry cannot link itself: #{registry}" if registry == self.class.expand(@path)
|
|
468
|
+
|
|
469
|
+
existing = @links.find { |candidate| candidate.slug == name }
|
|
470
|
+
existing ? existing.registry = registry : @links << Link.new(name, registry)
|
|
471
|
+
write
|
|
472
|
+
refresh_links
|
|
473
|
+
@links.find { |candidate| candidate.slug == name }
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
# Drop the link +slug+ and every bundle that arrived through it. Returns the
|
|
477
|
+
# removed Link, or nil when nothing matched.
|
|
478
|
+
def unlink(slug)
|
|
479
|
+
name = self.class.normalize(slug)
|
|
480
|
+
link = @links.find { |candidate| candidate.slug == name }
|
|
481
|
+
return nil unless link
|
|
482
|
+
|
|
483
|
+
@links.delete(link)
|
|
484
|
+
write
|
|
485
|
+
refresh_links
|
|
486
|
+
link
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
# Copy bundles and groups out of another registry file into this one, under
|
|
490
|
+
# the slugs they carry there. +asks+ names bundles or groups in the source
|
|
491
|
+
# (bare, or as `@ref`); a group brings everything it reaches and is recreated
|
|
492
|
+
# here. +as+ renames the single thing asked for. Persists once, and returns
|
|
493
|
+
# { bundles: [ Entry… ], groups: [ Group… ] }.
|
|
494
|
+
#
|
|
495
|
+
# Import is the opposite trade from #link, and the pair is the point: a link
|
|
496
|
+
# holds a live pointer, so the other file keeps owning what it lends and can
|
|
497
|
+
# take it back; an import copies the reference and owns it from then on — the
|
|
498
|
+
# source can be deleted, moved or rewritten and nothing here notices. That is
|
|
499
|
+
# why the two disagree on a collision. A linked name was never chosen here, so
|
|
500
|
+
# #link_slug invents around it; an imported name lands in *this* file under
|
|
501
|
+
# this registry's own rules, so a collision is refused exactly as #rename's
|
|
502
|
+
# is. The gem may invent a name; it may not substitute one you chose, and
|
|
503
|
+
# naming a slug on the command line is choosing it.
|
|
504
|
+
#
|
|
505
|
+
# Nothing is applied until everything has been checked. Half an import is a
|
|
506
|
+
# registry the user has to unpick by hand, reported as a success — so every
|
|
507
|
+
# ask is resolved and refused against the current state first, and one #write
|
|
508
|
+
# publishes the lot. Imported rows append, so the default stays where it was.
|
|
509
|
+
def import(asks, from:, as: nil)
|
|
510
|
+
source = open_source(from)
|
|
511
|
+
# #normalize_members drops what normalizes to nothing, which is right for a
|
|
512
|
+
# group's members and wrong here: dropping one ask and importing the rest is
|
|
513
|
+
# the silent partial all-or-nothing exists to rule out, and the report would
|
|
514
|
+
# count the ones that landed as a success.
|
|
515
|
+
unusable = asks.find { |ask| self.class.normalize(ask).empty? }
|
|
516
|
+
raise OKF::Error, "not a usable slug: #{unusable} (letters and digits, please)" if unusable
|
|
517
|
+
|
|
518
|
+
names = normalize_members(asks)
|
|
519
|
+
raise OKF::Error, "nothing to import (name a bundle or a group from #{source.path})" if names.empty?
|
|
520
|
+
|
|
521
|
+
plan = []
|
|
522
|
+
names.each { |name| plan_import(source, name, nil, plan, []) }
|
|
523
|
+
rename_import(plan, names.first, as) if as
|
|
524
|
+
plan.each { |step| refuse_import(step, source.path) }
|
|
525
|
+
apply_import(plan)
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
# One row per link for `registry list`: the file it points at, how many
|
|
529
|
+
# bundles it contributed, and why it contributed none when it did. A target
|
|
530
|
+
# that is gone or unreadable is *reported*, never raised — one bad pointer
|
|
531
|
+
# must not take down the registry that holds it, the same tolerance a
|
|
532
|
+
# vanished bundle directory gets.
|
|
533
|
+
def links_listing
|
|
534
|
+
@links.map do |link|
|
|
535
|
+
state = @link_state[link.slug] || { bundles: 0, missing: false, unreadable: false }
|
|
536
|
+
{ slug: link.slug, registry: link.registry, bundles: state[:bundles],
|
|
537
|
+
missing: state[:missing], unreadable: state[:unreadable] }
|
|
538
|
+
end
|
|
539
|
+
end
|
|
540
|
+
|
|
408
541
|
# Persist the current state to disk. The mutating verbs write as a side effect
|
|
409
542
|
# of the change; `save` is the public seam for the one caller that creates a
|
|
410
543
|
# registry with nothing to change yet — `okf registry init`, materializing an
|
|
@@ -422,19 +555,27 @@ module OKF
|
|
|
422
555
|
# write would flatten a newly-added in-tree bundle to an absolute path,
|
|
423
556
|
# silently undoing the portability the base exists for.
|
|
424
557
|
def reopen
|
|
425
|
-
self.class.new(@path, relative_base: @relative_base)
|
|
558
|
+
self.class.new(@path, relative_base: @relative_base, follow_links: @follow_links)
|
|
426
559
|
end
|
|
427
560
|
|
|
428
|
-
# One row per group
|
|
429
|
-
#
|
|
561
|
+
# One row per group — this registry's own first, then the ones that arrived
|
|
562
|
+
# through a link, each tagged with the +link+ it came from (nil for a group
|
|
563
|
+
# this registry owns). Members, and how many bundles it resolves to
|
|
564
|
+
# (+resolved+ is nil when a hand-edited cycle makes it unanswerable).
|
|
565
|
+
#
|
|
566
|
+
# One list, not two. #group? resolves a linked group, so the method that
|
|
567
|
+
# *enumerates* groups has to name it: a second listing for the linked half is
|
|
568
|
+
# how a caller comes to answer about a smaller set than the same object can
|
|
569
|
+
# resolve — the drift a sibling reading this method would inherit silently.
|
|
570
|
+
# A caller that wants only the editable ones filters on +link+.
|
|
430
571
|
def groups_listing
|
|
431
|
-
@groups.map do |group|
|
|
572
|
+
(@groups + @link_groups).map do |group|
|
|
432
573
|
resolved = begin
|
|
433
574
|
expand(group.slug).size
|
|
434
575
|
rescue OKF::Error
|
|
435
576
|
nil
|
|
436
577
|
end
|
|
437
|
-
{ slug: group.slug, members: group.members.dup, resolved: resolved }
|
|
578
|
+
{ slug: group.slug, members: group.members.dup, resolved: resolved, link: @link_of[group.slug] }
|
|
438
579
|
end
|
|
439
580
|
end
|
|
440
581
|
|
|
@@ -454,7 +595,9 @@ module OKF
|
|
|
454
595
|
# a collision check has to see both lists.
|
|
455
596
|
def taken_slugs(skip)
|
|
456
597
|
@entries.reject { |entry| entry.equal?(skip) }.map(&:slug) +
|
|
457
|
-
@groups.reject { |group| group.equal?(skip) }.map(&:slug)
|
|
598
|
+
@groups.reject { |group| group.equal?(skip) }.map(&:slug) +
|
|
599
|
+
@link_groups.reject { |group| group.equal?(skip) }.map(&:slug) +
|
|
600
|
+
@links.map(&:slug)
|
|
458
601
|
end
|
|
459
602
|
|
|
460
603
|
# An explicitly requested slug (--as, rename): normalized, and a collision
|
|
@@ -495,6 +638,13 @@ module OKF
|
|
|
495
638
|
raise OKF::Error, "slug already taken: #{slug} names a bundle (rename or remove that entry first)"
|
|
496
639
|
end
|
|
497
640
|
|
|
641
|
+
# A group a link brought in is the update path everywhere else — and here
|
|
642
|
+
# that is the trap: #write persists @groups alone, so mutating one reported
|
|
643
|
+
# success and dropped the change on the next read. A refusal, like every
|
|
644
|
+
# other write a link owns.
|
|
645
|
+
linked = @link_groups.find { |group| group.slug == slug }
|
|
646
|
+
refuse_linked(linked, "group") if linked
|
|
647
|
+
|
|
498
648
|
slug
|
|
499
649
|
end
|
|
500
650
|
|
|
@@ -577,7 +727,12 @@ module OKF
|
|
|
577
727
|
@entries = rows.map { |row| entry_from(row) }
|
|
578
728
|
group_rows = data.is_a?(Hash) ? Array(data["groups"]) : [] # a groups-less file has none
|
|
579
729
|
@groups = group_rows.map { |row| group_from(row) }.reject { |group| group.members.empty? }
|
|
730
|
+
link_rows = data.is_a?(Hash) ? Array(data["links"]) : []
|
|
731
|
+
@links = link_rows.map { |row| link_from(row) }
|
|
580
732
|
normalize_slugs
|
|
733
|
+
# After normalize_slugs, so a linked name is minted around the repaired
|
|
734
|
+
# local ones rather than around names nothing could reach.
|
|
735
|
+
resolve_links if @follow_links
|
|
581
736
|
rescue JSON::ParserError => e
|
|
582
737
|
malformed("#{e.message} (fix or delete the file)")
|
|
583
738
|
rescue SystemCallError => e
|
|
@@ -634,6 +789,17 @@ module OKF
|
|
|
634
789
|
Group.new(row["slug"], members)
|
|
635
790
|
end
|
|
636
791
|
|
|
792
|
+
# One row to a Link, shape-checked like #entry_from and #group_from — the file
|
|
793
|
+
# is hand-editable, so a row missing its target must fail here rather than
|
|
794
|
+
# three frames away in a File.file? on nil.
|
|
795
|
+
def link_from(row)
|
|
796
|
+
unless row.is_a?(Hash) && row["slug"].is_a?(String) &&
|
|
797
|
+
row["registry"].is_a?(String) && !row["registry"].empty?
|
|
798
|
+
malformed('every link needs a "slug" and a "registry" path (fix or delete the file)')
|
|
799
|
+
end
|
|
800
|
+
Link.new(self.class.normalize(row["slug"]), resolve_stored(row["registry"]))
|
|
801
|
+
end
|
|
802
|
+
|
|
637
803
|
# Slugs enter this list three ways — minted from a basename, asked for with
|
|
638
804
|
# --as, and read from this file — and the first two normalize. The third did
|
|
639
805
|
# not, and that asymmetry is the whole bug: the file could hold a name the
|
|
@@ -668,6 +834,261 @@ module OKF
|
|
|
668
834
|
!slug.empty? && slug == self.class.normalize(slug) && !RESERVED_SLUGS.include?(slug)
|
|
669
835
|
end
|
|
670
836
|
|
|
837
|
+
# Refuse a write aimed at something a link owns, naming the file that does own
|
|
838
|
+
# it. The refusal lives here rather than in the CLI because the graph server's
|
|
839
|
+
# bundles panel posts straight into these same methods — a guard one layer up
|
|
840
|
+
# would leave the browser doing what the terminal refuses.
|
|
841
|
+
def refuse_linked(target, action)
|
|
842
|
+
link = target.respond_to?(:link) ? target.link : @link_of[target.slug]
|
|
843
|
+
return unless link
|
|
844
|
+
|
|
845
|
+
owner = @links.find { |candidate| candidate.slug == link }
|
|
846
|
+
raise OKF::Error, "cannot #{action} @#{target.slug}: it comes from the linked registry at " \
|
|
847
|
+
"#{owner&.registry} (edit that file, or okf registry unlink #{link})"
|
|
848
|
+
end
|
|
849
|
+
|
|
850
|
+
# A link name: usable, not reserved, and not already a bundle's or a group's.
|
|
851
|
+
# Re-pointing an existing link keeps its name, so that is the one collision
|
|
852
|
+
# that is the update path rather than an error.
|
|
853
|
+
def explicit_link_slug(base)
|
|
854
|
+
slug = self.class.normalize(base)
|
|
855
|
+
raise OKF::Error, "not a usable slug: #{base} (letters and digits, please)" if slug.empty?
|
|
856
|
+
|
|
857
|
+
if RESERVED_SLUGS.include?(slug)
|
|
858
|
+
raise OKF::Error, "not a usable slug: #{slug} is reserved (@#{slug} names every registered bundle)"
|
|
859
|
+
end
|
|
860
|
+
return slug if @links.any? { |link| link.slug == slug }
|
|
861
|
+
|
|
862
|
+
if get(slug) || group?(slug)
|
|
863
|
+
raise OKF::Error, "slug already taken: #{slug} (rename or remove that entry first)"
|
|
864
|
+
end
|
|
865
|
+
|
|
866
|
+
slug
|
|
867
|
+
end
|
|
868
|
+
|
|
869
|
+
# Drop everything the links contributed and resolve them again — what a
|
|
870
|
+
# mutating link verb owes the in-memory instance, so the object it returns
|
|
871
|
+
# from agrees with the file it just wrote.
|
|
872
|
+
def refresh_links
|
|
873
|
+
@entries.reject!(&:link)
|
|
874
|
+
@link_groups = []
|
|
875
|
+
@link_state = {}
|
|
876
|
+
@link_of = {}
|
|
877
|
+
resolve_links
|
|
878
|
+
end
|
|
879
|
+
|
|
880
|
+
# Read each linked registry and fold its bundles (then its groups) into this
|
|
881
|
+
# one. Nothing is copied to disk: the entries carry their link, and #write
|
|
882
|
+
# leaves them out.
|
|
883
|
+
#
|
|
884
|
+
# The target is opened with `follow_links: false`, which is the whole of the
|
|
885
|
+
# depth rule — a linked file's own links are never read, so no chain forms and
|
|
886
|
+
# no cycle is possible. It is also anchored on its own directory, so a repo's
|
|
887
|
+
# committed `.okf.json` resolves its relative paths exactly as it
|
|
888
|
+
# would from inside that repo.
|
|
889
|
+
def resolve_links
|
|
890
|
+
@links.each do |link|
|
|
891
|
+
state = { bundles: 0, missing: false, unreadable: false }
|
|
892
|
+
@link_state[link.slug] = state
|
|
893
|
+
source = open_linked(link, state) or next
|
|
894
|
+
|
|
895
|
+
map = fold_linked_bundles(source, link)
|
|
896
|
+
state[:bundles] = map.size
|
|
897
|
+
@link_groups << Group.new(link.slug, map.values) unless map.empty?
|
|
898
|
+
@link_of[link.slug] = link.slug
|
|
899
|
+
fold_linked_groups(source, link, map)
|
|
900
|
+
end
|
|
901
|
+
end
|
|
902
|
+
|
|
903
|
+
# The linked registry, or nil after recording why there is none. A target that
|
|
904
|
+
# is gone or malformed is a reported state, not an exception: the registry that
|
|
905
|
+
# holds the link is still perfectly usable without it.
|
|
906
|
+
def open_linked(link, state)
|
|
907
|
+
unless File.file?(link.registry)
|
|
908
|
+
state[:missing] = true
|
|
909
|
+
return nil
|
|
910
|
+
end
|
|
911
|
+
|
|
912
|
+
self.class.new(link.registry, relative_base: File.dirname(link.registry), follow_links: false)
|
|
913
|
+
rescue OKF::Error
|
|
914
|
+
state[:unreadable] = true
|
|
915
|
+
nil
|
|
916
|
+
end
|
|
917
|
+
|
|
918
|
+
# The linked file's bundles, as entries of this registry. Returns the map from
|
|
919
|
+
# each bundle's slug *there* to the slug it answers to *here* — what the groups
|
|
920
|
+
# below are remapped through.
|
|
921
|
+
def fold_linked_bundles(source, link)
|
|
922
|
+
source.each_with_object({}) do |entry, map|
|
|
923
|
+
slug = link_slug(entry.slug, link.slug)
|
|
924
|
+
map[entry.slug] = slug
|
|
925
|
+
@entries << Entry.new(slug, entry.path, entry.title, link.slug, entry.slug)
|
|
926
|
+
end
|
|
927
|
+
end
|
|
928
|
+
|
|
929
|
+
# The linked file's own groups, so a link carries a file's curation rather than
|
|
930
|
+
# just its rows. Names are minted like the bundles above; members are remapped
|
|
931
|
+
# through +map+ (and through the group names, so a nested group survives), and
|
|
932
|
+
# a group left with nothing that resolved here is dropped.
|
|
933
|
+
def fold_linked_groups(source, link, map)
|
|
934
|
+
rows = source.groups_listing
|
|
935
|
+
names = rows.each_with_object({}) { |row, acc| acc[row[:slug]] = link_slug(row[:slug], link.slug) }
|
|
936
|
+
resolvable = map.merge(names)
|
|
937
|
+
rows.each do |row|
|
|
938
|
+
members = row[:members].map { |member| resolvable[member] }.compact
|
|
939
|
+
next if members.empty?
|
|
940
|
+
|
|
941
|
+
@link_groups << Group.new(names[row[:slug]], members)
|
|
942
|
+
@link_of[names[row[:slug]]] = link.slug
|
|
943
|
+
end
|
|
944
|
+
end
|
|
945
|
+
|
|
946
|
+
# The slug a linked name answers to here: its own when free, otherwise
|
|
947
|
+
# prefixed with the link it came from (and suffixed beyond that, through the
|
|
948
|
+
# same #dedupe a basename goes through). A name arriving from a file this
|
|
949
|
+
# registry does not own was never chosen here, so inventing around a collision
|
|
950
|
+
# is the forgiving half of the rule #explicit_slug keeps the strict half of —
|
|
951
|
+
# and refusing instead would let one foreign row take down the whole link.
|
|
952
|
+
def link_slug(slug, link_name)
|
|
953
|
+
base = self.class.slugify(slug)
|
|
954
|
+
taken = taken_slugs(nil) + RESERVED_SLUGS
|
|
955
|
+
return base unless taken.include?(base)
|
|
956
|
+
|
|
957
|
+
self.class.dedupe("#{link_name}-#{base}", taken)
|
|
958
|
+
end
|
|
959
|
+
|
|
960
|
+
# The registry an import reads from, opened once. It must exist *now* — an
|
|
961
|
+
# import is an explicit ask, and a path that is not a registry file is a typo
|
|
962
|
+
# worth catching at the keyboard.
|
|
963
|
+
#
|
|
964
|
+
# +relative_base+ is the source's own directory, unconditionally, and that is
|
|
965
|
+
# deliberately not .load's rule. The base does two jobs, and .load's nil for
|
|
966
|
+
# the global registry is the *write*-side one: #store_form must leave those
|
|
967
|
+
# paths absolute. A reader that never writes to the source has no stake in
|
|
968
|
+
# that half. On the read side the base answers only "what does a relative row
|
|
969
|
+
# in this file mean?", and for a file being read from another directory the
|
|
970
|
+
# one available answer is "beside that file". For a global-shaped source it is
|
|
971
|
+
# inert — every row #store_form wrote there is absolute and #resolve_stored
|
|
972
|
+
# short-circuits — so it bites only a hand-typed relative row, where anchoring
|
|
973
|
+
# beats resolving against whatever cwd happens to be.
|
|
974
|
+
#
|
|
975
|
+
# +follow_links+ is true, and has to be: a bundle that reached the source
|
|
976
|
+
# through a link lives in its @entries only after #resolve_links. It is also
|
|
977
|
+
# right. An import copies the path and holds nothing live, so the depth rule
|
|
978
|
+
# that makes #open_linked pass false — which bounds *live* resolution — has
|
|
979
|
+
# nothing to bound here, and depth is two at most, so a source that links back
|
|
980
|
+
# here still terminates.
|
|
981
|
+
def open_source(from)
|
|
982
|
+
registry = self.class.expand(from.to_s)
|
|
983
|
+
raise OKF::Error, "not a registry file: #{from}" unless File.file?(registry)
|
|
984
|
+
|
|
985
|
+
if registry == self.class.expand(@path)
|
|
986
|
+
raise OKF::Error, "the source and the target are the same registry: #{registry} (--from names another file)"
|
|
987
|
+
end
|
|
988
|
+
|
|
989
|
+
self.class.new(registry, relative_base: File.dirname(registry), follow_links: true)
|
|
990
|
+
end
|
|
991
|
+
|
|
992
|
+
# Walk one ask into +plan+: the bundle it names, or the group and everything
|
|
993
|
+
# that group reaches. A member that is itself a group comes too, asked for or
|
|
994
|
+
# not — recreating a group here without the group inside it would leave a name
|
|
995
|
+
# resolving to a smaller set than the same name resolves to there, which is
|
|
996
|
+
# the quiet substitution the slug rule exists to stop, and nothing on screen
|
|
997
|
+
# would say so. Members are planned before the set that holds them, so a
|
|
998
|
+
# refusal names the leaf that caused it rather than the group it hid in.
|
|
999
|
+
def plan_import(source, name, via, plan, chain)
|
|
1000
|
+
if chain.include?(name)
|
|
1001
|
+
raise OKF::Error, "group cycle in #{source.path}: " \
|
|
1002
|
+
"#{(chain + [ name ]).map { |slug| "@#{slug}" }.join(" → ")} (fix that file first)"
|
|
1003
|
+
end
|
|
1004
|
+
# After the cycle guard, never before: memoizing a second visit would
|
|
1005
|
+
# swallow the cycle it is there to catch.
|
|
1006
|
+
return if plan.any? { |step| step[:slug] == name }
|
|
1007
|
+
|
|
1008
|
+
entry = source.get(name)
|
|
1009
|
+
# The title comes across as it stands rather than re-derived from the path
|
|
1010
|
+
# the way #add derives it: it is what that file says this bundle is called.
|
|
1011
|
+
return plan << { slug: name, path: entry.path, title: entry.title, members: nil, via: via } if entry
|
|
1012
|
+
|
|
1013
|
+
group = source.group?(name)
|
|
1014
|
+
raise OKF::Error, "no such bundle or group in #{source.path}: @#{name}" unless group
|
|
1015
|
+
|
|
1016
|
+
# A member that dangles in the source names nothing there and would name
|
|
1017
|
+
# nothing here; importing it would only move the damage into a second file.
|
|
1018
|
+
members = group.members.select { |member| source.get(member) || source.group?(member) }
|
|
1019
|
+
raise OKF::Error, "@#{name} names nothing in #{source.path}: its members are gone" if members.empty?
|
|
1020
|
+
|
|
1021
|
+
members.each { |member| plan_import(source, member, name, plan, chain + [ name ]) }
|
|
1022
|
+
# Members are stored verbatim, and that is the payoff of preserving slugs: a
|
|
1023
|
+
# name means the same thing on both sides, so there is nothing to remap —
|
|
1024
|
+
# the work #fold_linked_groups must do precisely because a link mints names.
|
|
1025
|
+
plan << { slug: name, path: nil, title: nil, members: members, via: via }
|
|
1026
|
+
end
|
|
1027
|
+
|
|
1028
|
+
# +as+ renames the thing that was asked for, not the members it dragged in:
|
|
1029
|
+
# you named one bundle or one group, so one name changes. Applied before the
|
|
1030
|
+
# refusals, so the collision that matters is the one the new name would cause.
|
|
1031
|
+
def rename_import(plan, asked, as)
|
|
1032
|
+
root = plan.find { |step| step[:slug] == asked && step[:via].nil? }
|
|
1033
|
+
root[:slug] = explicit_slug(as, nil)
|
|
1034
|
+
end
|
|
1035
|
+
|
|
1036
|
+
# Every reason a step could be refused, checked while nothing has been
|
|
1037
|
+
# applied. What the row *is* comes before what it wants to be called: when
|
|
1038
|
+
# both collide, "that bundle is already here" is the answer and "the name is
|
|
1039
|
+
# taken" is only the symptom.
|
|
1040
|
+
def refuse_import(step, registry)
|
|
1041
|
+
# Which ask dragged this name in. Without it a group import refuses on a
|
|
1042
|
+
# slug the user never typed and nothing says where it came from.
|
|
1043
|
+
via = step[:via] ? ", a member of @#{step[:via]}" : ""
|
|
1044
|
+
unless step[:members]
|
|
1045
|
+
unless File.directory?(step[:path])
|
|
1046
|
+
raise OKF::Error, "cannot import @#{step[:slug]}#{via}: #{step[:path]} is not a directory " \
|
|
1047
|
+
"(restore it, or drop that entry from #{registry})"
|
|
1048
|
+
end
|
|
1049
|
+
|
|
1050
|
+
# #add would key on the path and rename that entry in place. Import must
|
|
1051
|
+
# not: the bundle is already yours under a name you chose, so taking the
|
|
1052
|
+
# source's name for it is the substitution the rule forbids — and skipping
|
|
1053
|
+
# it silently would report an import that did not happen.
|
|
1054
|
+
existing = @entries.find { |candidate| candidate.path == step[:path] }
|
|
1055
|
+
if existing
|
|
1056
|
+
raise OKF::Error, "cannot import @#{step[:slug]}#{via}: #{step[:path]} is already registered here " \
|
|
1057
|
+
"as #{existing.slug} (remove that entry first, or leave this one out)"
|
|
1058
|
+
end
|
|
1059
|
+
end
|
|
1060
|
+
|
|
1061
|
+
return unless taken_slugs(nil).include?(step[:slug])
|
|
1062
|
+
|
|
1063
|
+
raise OKF::Error, "slug already taken: #{step[:slug]}#{via} " \
|
|
1064
|
+
"(rename or remove that entry first, or import it under another name with --as)"
|
|
1065
|
+
end
|
|
1066
|
+
|
|
1067
|
+
# The plan, applied and persisted in one write. #add and #set_group are out of
|
|
1068
|
+
# reach for the one reason they share: both write, so N asks would be N files
|
|
1069
|
+
# on disk and a refusal in the middle would leave the earlier half applied.
|
|
1070
|
+
# What is left of either once the write is taken out is a constructor and a
|
|
1071
|
+
# push, which is what this is — the rest of #add answers questions import has
|
|
1072
|
+
# already answered, and differently.
|
|
1073
|
+
def apply_import(plan)
|
|
1074
|
+
imported = { bundles: [], groups: [] }
|
|
1075
|
+
plan.each do |step|
|
|
1076
|
+
if step[:members]
|
|
1077
|
+
group = Group.new(step[:slug], step[:members].dup)
|
|
1078
|
+
@groups << group
|
|
1079
|
+
imported[:groups] << group
|
|
1080
|
+
else
|
|
1081
|
+
# No link and no origin: an imported row is this registry's own from
|
|
1082
|
+
# here on, which is the whole difference between importing and linking.
|
|
1083
|
+
entry = Entry.new(step[:slug], step[:path], step[:title])
|
|
1084
|
+
@entries << entry
|
|
1085
|
+
imported[:bundles] << entry
|
|
1086
|
+
end
|
|
1087
|
+
end
|
|
1088
|
+
write
|
|
1089
|
+
imported
|
|
1090
|
+
end
|
|
1091
|
+
|
|
671
1092
|
def malformed(detail)
|
|
672
1093
|
raise OKF::Error, "malformed registry at #{@path}: #{detail}"
|
|
673
1094
|
end
|
|
@@ -677,9 +1098,14 @@ module OKF
|
|
|
677
1098
|
# Two racing writers stay last-writer-wins; the registry is a per-user file.
|
|
678
1099
|
def write
|
|
679
1100
|
FileUtils.mkdir_p(File.dirname(@path))
|
|
680
|
-
|
|
1101
|
+
# Only what this registry owns: a linked entry is a view onto another file,
|
|
1102
|
+
# and writing it here would be the copy the whole feature exists to avoid.
|
|
1103
|
+
rows = @entries.reject(&:link).map do |entry|
|
|
1104
|
+
{ "slug" => entry.slug, "path" => store_form(entry.path), "title" => entry.title }
|
|
1105
|
+
end
|
|
681
1106
|
groups = @groups.map { |group| { "slug" => group.slug, "members" => group.members } }
|
|
682
|
-
|
|
1107
|
+
links = @links.map { |link| { "slug" => link.slug, "registry" => store_form(link.registry) } }
|
|
1108
|
+
payload = { "bundles" => rows, "groups" => groups, "links" => links }
|
|
683
1109
|
tmp = "#{@path}.tmp-#{Process.pid}"
|
|
684
1110
|
begin
|
|
685
1111
|
File.write(tmp, JSON.pretty_generate(payload) + "\n")
|
|
@@ -3148,7 +3148,8 @@ function bridgeReport(v,shown,total){if(bridge)bridge.report(v,shown,total);}
|
|
|
3148
3148
|
|
|
3149
3149
|
function row(b){
|
|
3150
3150
|
const cur=b.mount!==null&&b.mount===SELF_SLUG;
|
|
3151
|
-
const pills=(b['default']?'<span class="ws-pill def">default</span>':'')+(cur?'<span class="ws-pill cur">current</span>':'')
|
|
3151
|
+
const pills=(b['default']?'<span class="ws-pill def">default</span>':'')+(cur?'<span class="ws-pill cur">current</span>':'')+
|
|
3152
|
+
(b.link?'<span class="ws-pill">via @'+esc(b.link)+'</span>':'');
|
|
3152
3153
|
/* A row the hub could not load links nowhere, because there is nowhere to
|
|
3153
3154
|
link — but it is still a row: "where did my bundle go?" is the question it
|
|
3154
3155
|
exists to answer. */
|
|
@@ -3165,7 +3166,10 @@ function bridgeReport(v,shown,total){if(bridge)bridge.report(v,shown,total);}
|
|
|
3165
3166
|
'<div class="ws-meta">'+(where?'<span class="ws-where">'+esc(where)+'</span>':'')+count+
|
|
3166
3167
|
'<span class="ws-health '+esc(b.health)+'">'+esc(b.word)+'</span></div>'+
|
|
3167
3168
|
(editing===b.slug?edit(b):'')+(confirming===b.slug?confirm(b):'')+'</div>';
|
|
3168
|
-
|
|
3169
|
+
/* No menu on a linked row: the registry that owns it is another file, so
|
|
3170
|
+
rename/remove/default are refused there and offering them here would be a
|
|
3171
|
+
menu of three errors. It is listed, opened and searched like any other. */
|
|
3172
|
+
return '<div class="ws-row" data-slug="'+esc(b.slug)+'">'+body+(manageable()&&!b.link?menuBtn(b):'')+'</div>';}
|
|
3169
3173
|
|
|
3170
3174
|
function menuBtn(b){
|
|
3171
3175
|
return '<button class="ws-menu-btn" type="button" aria-haspopup="true" aria-expanded="false" '+
|