okf 2.1.1 → 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.
@@ -10,7 +10,7 @@ module OKF
10
10
  class Registry < Command
11
11
  # The `registry` umbrella's subcommands — the dispatch, and the words a
12
12
  # flag-first invocation is checked against.
13
- SUBCOMMANDS = %w[init set del list default rename group ungroup].freeze
13
+ SUBCOMMANDS = %w[init set del list default rename group ungroup link unlink import].freeze
14
14
 
15
15
  def self.id
16
16
  :registry
@@ -20,17 +20,29 @@ module OKF
20
20
  :registry
21
21
  end
22
22
 
23
+ # One subcommand per row — the table `okf registry --help` prints, and the
24
+ # single place each subcommand's grammar is written down.
25
+ SUBCOMMAND_ROWS = [
26
+ [ "init", "create a project-local .okf.json (nearest one wins)" ],
27
+ [ "list [--json]", "list registered bundles (* marks the default)" ],
28
+ [ "set <dir|@slug> [--as SLUG] [--default]", "add or update a bundle (a bare `server` serves them)" ],
29
+ [ "del <dir|@slug>", "remove a bundle or group from the registry" ],
30
+ [ "default <@slug>", "move a bundle to the front (the default)" ],
31
+ [ "rename <@slug> <new>", "rename a bundle or group (<new> is a new name, not a ref)" ],
32
+ [ "group <slug> <@member…>", "create a group, or add members (search/server can target @slug)" ],
33
+ [ "ungroup <slug> <@member…>", "remove members from a group (emptying it deletes it)" ],
34
+ [ "link <name> <file>", "point the global registry at another one (its bundles resolve here)" ],
35
+ [ "unlink <name>", "drop a link and every bundle that arrived through it" ],
36
+ [ "import <@slug…> [--from FILE]", "copy bundles out of another registry (global by default) into this one" ]
37
+ ].freeze
38
+
39
+ # The umbrella is one row in `okf help`. Ten made a third of the map about
40
+ # registry management, which is not a third of what okf does — and the map's
41
+ # job is to name the verbs, not to be every verb's manual. The subcommands
42
+ # are one `--help` away, and the row says so rather than leaving a reader to
43
+ # guess that an umbrella has any.
23
44
  def self.help_rows
24
- [
25
- [ "registry init", "create a project-local .okf-registry.json (nearest one wins)" ],
26
- [ "registry list [--json]", "list registered bundles (* marks the default)" ],
27
- [ "registry set <dir|@slug> [--as SLUG] [--default]", "add or update a bundle (a bare `server` serves them)" ],
28
- [ "registry del <dir|@slug>", "remove a bundle or group from the registry" ],
29
- [ "registry default <@slug>", "move a bundle to the front (the default)" ],
30
- [ "registry rename <@slug> <new>", "rename a bundle or group (<new> is a new name, not a ref)" ],
31
- [ "registry group <slug> <@member…>", "create a group, or add members (search/server can target @slug)" ],
32
- [ "registry ungroup <slug> <@member…>", "remove members from a group (emptying it deletes it)" ]
33
- ]
45
+ [ [ "registry <command> [-g]", "name, group and link your bundles (okf registry --help)" ] ]
34
46
  end
35
47
 
36
48
  def call(argv)
@@ -38,6 +50,7 @@ module OKF
38
50
 
39
51
  sub = argv.first
40
52
  case sub
53
+ when "--help", "-h" then umbrella_help
41
54
  when "init" then registry_init(argv.drop(1))
42
55
  when "set" then registry_set(argv.drop(1))
43
56
  when "del" then registry_del(argv.drop(1))
@@ -46,6 +59,9 @@ module OKF
46
59
  when "rename" then registry_rename(argv.drop(1))
47
60
  when "group" then registry_group(argv.drop(1))
48
61
  when "ungroup" then registry_ungroup(argv.drop(1))
62
+ when "link" then registry_link(argv.drop(1))
63
+ when "unlink" then registry_unlink(argv.drop(1))
64
+ when "import" then registry_import(argv.drop(1))
49
65
  else
50
66
  # A bare word that isn't a known subcommand is a typo (`registry remove x`
51
67
  # must not silently render the list and read as success).
@@ -67,7 +83,32 @@ module OKF
67
83
 
68
84
  private
69
85
 
70
- # Create a project-local .okf-registry.json in the current directory. Once it
86
+ # The umbrella's own help: every subcommand, its grammar, and the one flag
87
+ # they share. A bare `okf registry` is still `registry list` — the shorthand
88
+ # is documented and predates this — so only an explicit --help lands here.
89
+ def umbrella_help
90
+ width = SUBCOMMAND_ROWS.map { |usage, _| usage.length }.max
91
+ @out.puts "Usage: okf registry <command> [options]"
92
+ @out.puts ""
93
+ SUBCOMMAND_ROWS.each { |usage, blurb| @out.puts " #{usage.ljust(width)} #{blurb}" }
94
+ @out.puts ""
95
+ @out.puts " -g, --global act on the global $OKF_HOME registry, ignoring a project-local one"
96
+ @out.puts " (every command but `init`; OKF_NO_DISCOVERY=1 does it for a whole shell)"
97
+ @out.puts ""
98
+ @out.puts "A bare `okf registry` lists them, the same as `okf registry list`."
99
+ @out.puts "`okf registry <command> --help` has that command's own flags."
100
+ 0
101
+ end
102
+
103
+ # `-g/--global` on every subcommand but `init`, whose whole job is to create
104
+ # a *local* file. One options hash per verb carries it to #open_registry.
105
+ def global_flag(parser, options)
106
+ parser.on("-g", "--global", "act on the global $OKF_HOME registry, ignoring a project-local one") do
107
+ options[:global] = true
108
+ end
109
+ end
110
+
111
+ # Create a project-local .okf.json in the current directory. Once it
71
112
  # exists, discovery finds it (walking up from cwd) and every registry op —
72
113
  # and every @ref — resolves through it instead of the global $OKF_HOME one.
73
114
  # init only writes the empty file; `registry set` fills it. Refuses to clobber
@@ -81,8 +122,11 @@ module OKF
81
122
  no_extras?(argv) or return 2
82
123
 
83
124
  target = File.join(Dir.pwd, OKF::Registry::LOCAL_FILE)
84
- display = "./#{OKF::Registry::LOCAL_FILE}"
85
- return usage_error("already initialized: #{display}") if File.exist?(target)
125
+ # Either name is "already initialized": the legacy one is still
126
+ # discovered, so writing a second registry beside it would shadow a file
127
+ # the user still thinks is in force — the trap, not the fix.
128
+ existing = OKF::Registry::LOCAL_FILES.find { |name| File.exist?(File.join(Dir.pwd, name)) }
129
+ return usage_error("already initialized: ./#{existing}") if existing
86
130
 
87
131
  # The parent it would shadow, if any — a courtesy, not a barrier: nested
88
132
  # registries resolve nearest-first, so creating one here is legitimate.
@@ -90,7 +134,7 @@ module OKF
90
134
  @err.puts "note: a parent registry at #{parent} — the nearest one wins" if parent
91
135
 
92
136
  OKF::Registry.new(target).save
93
- @out.puts "initialized #{display}"
137
+ @out.puts "initialized ./#{OKF::Registry::LOCAL_FILE}"
94
138
  0
95
139
  rescue OptionParser::ParseError => e
96
140
  @err.puts e.message
@@ -104,11 +148,12 @@ module OKF
104
148
  # path already registered refreshes its title in place, and --as renames it. A
105
149
  # new path is added, slugged by directory basename unless --as says otherwise.
106
150
  def registry_set(argv)
107
- options = { as: nil, default: false }
151
+ options = { as: nil, default: false, global: false }
108
152
  parser = OptionParser.new do |o|
109
- o.banner = "Usage: okf registry set <dir|@slug> [--as SLUG] [--default]"
153
+ o.banner = "Usage: okf registry set <dir|@slug> [--as SLUG] [--default] [-g]"
110
154
  o.on("--as SLUG", "slug to register under (default: directory basename)") { |v| options[:as] = v }
111
155
  o.on("--default", "put it first — the bundle a bare `okf server` opens") { options[:default] = true }
156
+ global_flag(o, options)
112
157
  help_flag(o)
113
158
  end
114
159
  # No no_extras? here: positional_dir has already refused a trailing
@@ -116,7 +161,7 @@ module OKF
116
161
  # positional through `positional`, which does not check.
117
162
  dir = positional_dir(parser, argv) or return 2
118
163
 
119
- reg = open_registry
164
+ reg = open_registry(global: options[:global])
120
165
  # Said before the upsert: after it, an update is indistinguishable from an
121
166
  # add, and "registered" for what was a rename reads as a duplicate entry.
122
167
  known = reg.listing.any? { |row| row[:dir] == File.expand_path(dir) }
@@ -135,14 +180,16 @@ module OKF
135
180
 
136
181
  # Remove a bundle from the persistent registry by slug or by its directory.
137
182
  def registry_del(argv)
183
+ options = { global: false }
138
184
  parser = OptionParser.new do |o|
139
- o.banner = "Usage: okf registry del <dir|@slug>"
185
+ o.banner = "Usage: okf registry del <dir|@slug> [-g]"
186
+ global_flag(o, options)
140
187
  help_flag(o)
141
188
  end
142
189
  slug = positional(parser, argv) or return 2
143
190
  no_extras?(argv) or return 2
144
191
 
145
- reg = open_registry
192
+ reg = open_registry(global: options[:global])
146
193
  slug = registry_slug(slug, reg) or return 2
147
194
  removed = reg.remove(slug)
148
195
  return usage_error("no such bundle: #{slug}") unless removed
@@ -154,11 +201,12 @@ module OKF
154
201
  end
155
202
 
156
203
  def registry_list(argv)
157
- options = { json: false }
204
+ options = { json: false, global: false }
158
205
  parser = OptionParser.new do |o|
159
- o.banner = "Usage: okf registry list [--json] [--pretty]\n " \
206
+ o.banner = "Usage: okf registry list [--json] [--pretty] [-g]\n " \
160
207
  "okf registry set <dir|@slug> | del <dir|@slug> | default <@slug> | rename <@slug> <new>"
161
208
  json_flags(o, options, "emit the registry as JSON")
209
+ global_flag(o, options)
162
210
  help_flag(o)
163
211
  end
164
212
  begin
@@ -169,13 +217,14 @@ module OKF
169
217
  end
170
218
  no_extras?(argv) or return 2
171
219
 
172
- reg = open_registry
220
+ reg = open_registry(global: options[:global])
173
221
  if options[:json]
174
- groups = { "groups" => reg.groups_listing.map { |row| stringify(row) } }
175
- return emit_list_json({ "registry" => reg.path }, "bundles", reg.listing.map { |row| stringify(row) }, options, groups)
222
+ extra = { "groups" => reg.groups_listing.map { |row| stringify(row) },
223
+ "links" => reg.links_listing.map { |row| stringify(row) } }
224
+ return emit_list_json({ "registry" => reg.path }, "bundles", reg.listing.map { |row| stringify(row) }, options, extra)
176
225
  end
177
226
 
178
- print_registry(reg)
227
+ print_registry(reg, global: options[:global])
179
228
  0
180
229
  rescue OKF::Error => e
181
230
  usage_error(e.message)
@@ -186,15 +235,17 @@ module OKF
186
235
  # meant to be hand-editable, so the move is stated rather than left to be
187
236
  # discovered from a reordered file.
188
237
  def registry_default(argv)
238
+ options = { global: false }
189
239
  parser = OptionParser.new do |o|
190
- o.banner = "Usage: okf registry default <@slug>\n " \
240
+ o.banner = "Usage: okf registry default <@slug> [-g]\n " \
191
241
  "moves it to the front — the first registered bundle is the default until you do"
242
+ global_flag(o, options)
192
243
  help_flag(o)
193
244
  end
194
245
  slug = positional(parser, argv) or return 2
195
246
  no_extras?(argv) or return 2
196
247
 
197
- reg = open_registry
248
+ reg = open_registry(global: options[:global])
198
249
  slug = registry_slug(slug, reg) or return 2
199
250
  reg.default = slug
200
251
  @out.puts "default bundle → #{reg.default.slug} (now first)"
@@ -225,8 +276,10 @@ module OKF
225
276
 
226
277
  # Rename a registered bundle's slug — its mount path and switcher name.
227
278
  def registry_rename(argv)
279
+ options = { global: false }
228
280
  parser = OptionParser.new do |o|
229
- o.banner = "Usage: okf registry rename <@slug> <new>"
281
+ o.banner = "Usage: okf registry rename <@slug> <new> [-g]"
282
+ global_flag(o, options)
230
283
  help_flag(o)
231
284
  end
232
285
  parser.parse!(argv)
@@ -237,7 +290,7 @@ module OKF
237
290
  end
238
291
  no_extras?(argv) or return 2
239
292
 
240
- reg = open_registry
293
+ reg = open_registry(global: options[:global])
241
294
  # The old name may be a ref; the new one is a name being minted, never one.
242
295
  old_slug = registry_slug(old_slug, reg) or return 2
243
296
  entry = reg.rename(old_slug, new_slug)
@@ -256,8 +309,10 @@ module OKF
256
309
  # bare or as @refs; the model normalizes, unions, checks each names something,
257
310
  # and refuses a cycle. Only `search`/`server` can then target @slug.
258
311
  def registry_group(argv)
312
+ options = { global: false }
259
313
  parser = OptionParser.new do |o|
260
- o.banner = "Usage: okf registry group <slug> <@member…>"
314
+ o.banner = "Usage: okf registry group <slug> <@member…> [-g]"
315
+ global_flag(o, options)
261
316
  help_flag(o)
262
317
  end
263
318
  parser.parse!(argv)
@@ -267,7 +322,7 @@ module OKF
267
322
  return 2
268
323
  end
269
324
 
270
- reg = open_registry
325
+ reg = open_registry(global: options[:global])
271
326
  group = reg.set_group(slug, argv)
272
327
  count = reg.expand(group.slug).size
273
328
  @out.puts "grouped #{group.slug} → #{group.members.map { |m| "@#{m}" }.join(", ")} " \
@@ -283,8 +338,10 @@ module OKF
283
338
  # Remove members from a group. Emptying it deletes the group — an empty group
284
339
  # resolves to nothing, so it is not worth keeping.
285
340
  def registry_ungroup(argv)
341
+ options = { global: false }
286
342
  parser = OptionParser.new do |o|
287
- o.banner = "Usage: okf registry ungroup <slug> <@member…>"
343
+ o.banner = "Usage: okf registry ungroup <slug> <@member…> [-g]"
344
+ global_flag(o, options)
288
345
  help_flag(o)
289
346
  end
290
347
  parser.parse!(argv)
@@ -294,7 +351,7 @@ module OKF
294
351
  return 2
295
352
  end
296
353
 
297
- reg = open_registry
354
+ reg = open_registry(global: options[:global])
298
355
  removed, emptied = reg.unset_group_members(slug, argv)
299
356
  name = OKF::Registry.normalize(slug)
300
357
  if emptied
@@ -312,39 +369,241 @@ module OKF
312
369
  usage_error(e.message)
313
370
  end
314
371
 
315
- def print_registry(reg)
372
+ # Point the global registry at another registry file. Refused from a
373
+ # project-local one rather than silently retargeting: `link` writes, and a
374
+ # write that lands in a file the user is not standing in is the one surprise
375
+ # worth an error. -g is the way to say it on purpose.
376
+ def registry_link(argv)
377
+ options = { global: false }
378
+ parser = OptionParser.new do |o|
379
+ o.banner = "Usage: okf registry link <name> <file> [-g]"
380
+ global_flag(o, options)
381
+ help_flag(o)
382
+ end
383
+ parser.parse!(argv)
384
+ name, file = argv.shift(2)
385
+ if name.nil? || file.nil?
386
+ @err.puts parser.banner
387
+ return 2
388
+ end
389
+ no_extras?(argv) or return 2
390
+
391
+ reg = open_registry(global: options[:global])
392
+ return links_are_global(reg) if local_registry?(reg)
393
+
394
+ link = reg.link(name, file)
395
+ count = reg.links_listing.find { |row| row[:slug] == link.slug }[:bundles]
396
+ @out.puts "linked #{link.slug} → #{link.registry} (#{count} #{pluralize(count, "bundle")})"
397
+ 0
398
+ rescue OptionParser::ParseError => e
399
+ @err.puts e.message
400
+ 2
401
+ rescue OKF::Error => e
402
+ usage_error(e.message)
403
+ end
404
+
405
+ # Drop a link and every bundle that arrived through it. The bundles
406
+ # themselves are untouched — a link never owned them.
407
+ def registry_unlink(argv)
408
+ options = { global: false }
409
+ parser = OptionParser.new do |o|
410
+ o.banner = "Usage: okf registry unlink <name> [-g]"
411
+ global_flag(o, options)
412
+ help_flag(o)
413
+ end
414
+ name = positional(parser, argv) or return 2
415
+ no_extras?(argv) or return 2
416
+
417
+ reg = open_registry(global: options[:global])
418
+ return links_are_global(reg) if local_registry?(reg)
419
+
420
+ removed = reg.unlink(name)
421
+ return usage_error("no such link: #{name}") unless removed
422
+
423
+ @out.puts "unlinked #{removed.slug}"
424
+ 0
425
+ rescue OKF::Error => e
426
+ usage_error(e.message)
427
+ end
428
+
429
+ # Copy chosen bundles — and the groups that hold them — out of another
430
+ # registry file into the one in force. The counterpart to `link`, not a
431
+ # variant of it: a link is live, whole-file and read-only, and an import
432
+ # copies the reference and hands over ownership.
433
+ #
434
+ # Two registries are named here, and only one flag names each. `-g` goes on
435
+ # meaning what it means on every sibling subcommand — the registry written
436
+ # *to* — and `--from` names the one read, defaulting to the global registry,
437
+ # which inside a repo is the only other one there is. Overloading `-g` into
438
+ # a source flag on this verb alone would make it mean "write here" nine
439
+ # times and "read there" once.
440
+ def registry_import(argv)
441
+ options = { from: nil, as: nil, global: false }
442
+ parser = OptionParser.new do |o|
443
+ o.banner = "Usage: okf registry import <@slug…> [--from FILE] [--as SLUG] [-g]"
444
+ o.on("--from FILE", "registry file to copy from (default: the global $OKF_HOME one)") do |v|
445
+ options[:from] = v
446
+ end
447
+ o.on("--as SLUG", "slug to import under, renaming one bundle or group") { |v| options[:as] = v }
448
+ global_flag(o, options)
449
+ help_flag(o)
450
+ end
451
+ parser.parse!(argv)
452
+ if argv.empty?
453
+ @err.puts parser.banner
454
+ return 2
455
+ end
456
+ # --as names *one* thing. Applying it to a list would either rename them
457
+ # all to the same slug (a collision the user did not ask for) or pick one
458
+ # silently, and there is no reading of "import a b --as c" worth guessing.
459
+ return usage_error("--as names one bundle or group, and #{argv.length} were asked for") if options[:as] && argv.length > 1
460
+
461
+ # Expanded for the report as `link` expands its target: a relative --from
462
+ # is the shorthand, and the file it actually read is the answer.
463
+ from = OKF::Registry.expand(options[:from] || OKF::Registry.path)
464
+ reg = open_registry(global: options[:global])
465
+ print_imported(reg.import(argv, from: from, as: options[:as]), from)
466
+ 0
467
+ rescue OptionParser::ParseError => e
468
+ @err.puts e.message
469
+ 2
470
+ rescue OKF::Error => e
471
+ usage_error(e.message)
472
+ end
473
+
474
+ # What landed: a count against the file it came from, then a row per bundle
475
+ # in `set`'s shape (with its concept count, the signal that says whether it
476
+ # was the right bundle), then a row per group. Groups print after the
477
+ # bundles they hold, which is the order they were planned in.
478
+ def print_imported(imported, from)
479
+ bundles = imported[:bundles]
480
+ @out.puts "imported #{bundles.length} #{pluralize(bundles.length, "bundle")} from #{from}"
481
+ bundles.each do |entry|
482
+ folder = OKF::Bundle::Folder.load(entry.path)
483
+ report_skipped(folder)
484
+ count = folder.graph(minimal: true).nodes.size
485
+ @out.puts " #{entry.slug} → #{entry.path} (#{count} #{pluralize(count, "concept")})"
486
+ end
487
+ imported[:groups].each do |group|
488
+ @out.puts " #{group.slug} → #{group.members.map { |member| "@#{member}" }.join(", ")} (group)"
489
+ end
490
+ end
491
+
492
+ # The one refusal both link verbs share: a project-local registry is in
493
+ # force, and links are the global one's alone — which is what keeps a linked
494
+ # file's own links unread, and so keeps depth at one with nothing to enforce.
495
+ def links_are_global(reg)
496
+ usage_error("links live in the global registry, and #{registry_display(reg)} is a project-local one " \
497
+ "(okf registry link … --global)")
498
+ end
499
+
500
+ def print_registry(reg, global: false)
316
501
  # A header only when a project-local registry is in play — the case where
317
502
  # "which registry am I looking at?" is a real question. The global $OKF_HOME
318
503
  # one is the default, so it stays headerless (and the JSON envelope names
319
504
  # the file for a script either way).
320
- @out.puts "registry: #{registry_display(reg)}" if local_registry?(reg)
505
+ # Named whenever the answer could have been the other file: a discovered
506
+ # local registry, or a -g that just overrode one. The bare global case
507
+ # stays headerless — nothing was chosen, so there is nothing to disclose.
508
+ @out.puts "registry: #{registry_display(reg)}" if local_registry?(reg) || global
509
+ # groups_listing carries the linked groups too; here they print under
510
+ # their link rather than among the ones `group`/`ungroup` can touch.
321
511
  groups = reg.groups_listing
322
- return @out.puts "no bundles registered okf registry set <dir>" if reg.empty? && groups.empty?
512
+ own, linked = groups.partition { |group| group[:link].nil? }
513
+ links = reg.links_listing
514
+ return @out.puts "no bundles registered — okf registry set <dir>" if reg.empty? && groups.empty? && links.empty?
323
515
 
324
516
  rows = reg.listing
325
- unless rows.empty?
326
- width = rows.map { |row| row[:slug].length }.max
327
- rows.each do |row|
328
- marker = row[:default] ? "*" : " "
329
- missing = row[:missing] ? " (missing)" : ""
330
- @out.puts "#{marker} #{row[:slug].ljust(width)} #{row[:title]} (#{row[:dir]})#{missing}"
517
+ width = width_of(rows)
518
+ rows.reject { |row| row[:link] }.each { |row| @out.puts bundle_row(row, width) }
519
+ print_groups(own, rows) unless own.empty?
520
+ print_links(links, linked, rows, width) unless links.empty?
521
+ end
522
+
523
+ # Why a link contributed nothing, when it did: a target that is gone, or one
524
+ # that is there and cannot be parsed. Both are reported, never raised.
525
+ def link_state(link)
526
+ return " (missing)" if link[:missing]
527
+ return " (unreadable)" if link[:unreadable]
528
+
529
+ ""
530
+ end
531
+
532
+ def width_of(rows)
533
+ rows.empty? ? 0 : rows.map { |row| row[:slug].length }.max
534
+ end
535
+
536
+ def bundle_row(row, width)
537
+ marker = row[:default] ? "*" : " "
538
+ missing = row[:missing] ? " (missing)" : ""
539
+ # The slug it *had* in the file it came from, shown only when this
540
+ # registry had to move it — the one place a ref that shifted is visible.
541
+ moved = row[:origin] && row[:origin] != row[:slug] ? " [#{row[:origin]}]" : ""
542
+ "#{marker} #{row[:slug].ljust(width)} #{row[:title]} (#{row[:dir]})#{missing}#{moved}"
543
+ end
544
+
545
+ # The links section: one heading per link naming the file it points at, then
546
+ # the bundles that arrived through it, then any groups that came with them. A
547
+ # target that is gone or unreadable says so instead of listing nothing.
548
+ def print_links(links, groups, rows, width)
549
+ @out.puts ""
550
+ @out.puts "links:"
551
+ links.each do |link|
552
+ state = link_state(link)
553
+ @out.puts " #{link[:slug]} → #{link[:registry]} " \
554
+ "(#{link[:bundles]} #{pluralize(link[:bundles], "bundle")})#{state}"
555
+ rows.select { |row| row[:link] == link[:slug] }.each { |row| @out.puts " #{bundle_row(row, width)}" }
556
+ groups.select { |group| group[:link] == link[:slug] && group[:slug] != link[:slug] }.each do |group|
557
+ @out.puts " #{group[:slug]} #{group[:members].map { |m| "@#{m}" }.join(", ")}"
331
558
  end
332
559
  end
333
- print_groups(groups, rows) unless groups.empty?
560
+ end
561
+
562
+ # Every registry read this verb makes, with the filename note attached. The
563
+ # note is the `registry` umbrella's alone: this is the one verb whose
564
+ # *subject* is a registry file, and the one nobody runs in a loop or pipes
565
+ # into something else. `lint` and `search` are both, and a note there is
566
+ # noise people learn to redirect away rather than act on.
567
+ #
568
+ # Once per invocation, not once per read — `registry set @slug` opens the
569
+ # registry twice (the ref, then the write), and saying it twice reads as
570
+ # two problems.
571
+ def open_registry(global: false)
572
+ reg = super
573
+ note_legacy_name(reg) unless @named_registry
574
+ @named_registry = true
575
+ reg
576
+ end
577
+
578
+ # Two things worth saying about a project-local registry's filename, and
579
+ # nothing at all about the global one (which is `registry.json` and always
580
+ # was). A legacy file that is *in force* gets the move that retires it. A
581
+ # legacy file sitting beside the `.okf.json` that beat it gets named too:
582
+ # reading one while the other lies there unread is a silent wrong answer
583
+ # unless somebody says so.
584
+ def note_legacy_name(reg)
585
+ return unless local_registry?(reg)
586
+
587
+ legacy = OKF::Registry::LEGACY_LOCAL_FILE
588
+ if File.basename(reg.path) == legacy
589
+ @err.puts "note: #{legacy} is the old name — git mv #{legacy} #{OKF::Registry::LOCAL_FILE}"
590
+ elsif File.file?(File.join(File.dirname(reg.path), legacy))
591
+ @err.puts "note: #{legacy} is ignored — #{OKF::Registry::LOCAL_FILE} beside it wins (delete the old one)"
592
+ end
334
593
  end
335
594
 
336
595
  # Whether this registry was discovered as a project-local file rather than
337
596
  # read from $OKF_HOME — the basename settles it (only a local one is named
338
- # .okf-registry.json).
597
+ # .okf.json, or the legacy .okf-registry.json).
339
598
  def local_registry?(reg)
340
- File.basename(reg.path) == OKF::Registry::LOCAL_FILE
599
+ OKF::Registry::LOCAL_FILES.include?(File.basename(reg.path))
341
600
  end
342
601
 
343
602
  # How to name the local registry in the header: `./` when it sits in cwd
344
603
  # (the common case, a bare `init` here), its absolute path when discovery
345
604
  # walked up to an ancestor.
346
605
  def registry_display(reg)
347
- File.dirname(reg.path) == Dir.pwd ? "./#{OKF::Registry::LOCAL_FILE}" : reg.path
606
+ File.dirname(reg.path) == Dir.pwd ? "./#{File.basename(reg.path)}" : reg.path
348
607
  end
349
608
 
350
609
  # The groups section under the bundle listing: one row per group, its members
data/lib/okf/cli.rb CHANGED
@@ -45,7 +45,7 @@ module OKF
45
45
  "references" => %w[path dir kind referenced_by],
46
46
  "directories" => %w[dir ancestor index_path present synthesized count types tags subdirs body listing],
47
47
  "dirs" => %w[dir ancestor count subtree subdirs],
48
- "bundles" => %w[slug title dir mount default missing]
48
+ "bundles" => %w[slug title dir mount default missing link origin]
49
49
  }.freeze
50
50
 
51
51
  # Runs a Rack app under WEBrick until interrupted. Injected into the CLI so