Module: Msnav::CLI

Defined in:
lib/msnav/cli.rb

Overview

msnav command line: daemon / up / status / down — the same lifecycle surface (flags, hub resolution, exit codes) as coderag daemon/up/..., so the editor extension's daemonCommand can point at either.

Defined Under Namespace

Classes: Exit

Constant Summary collapse

USAGE =
<<~TEXT
  msnav — cross-service Ruby navigation server over a coderag index

  Usage: msnav COMMAND [options]

  Commands:
    daemon    Run the navigation daemon (HTTP API on --host/--port)
    up        Ensure a daemon for this hub is running (idempotent)
    status    Health and registered windows of the daemon
    down      Stop the daemon on --host/--port
    doctor    Explain hub resolution from the current directory
    version   Print the msnav version

  The index is built on the host by coderag (`coderag index`); msnav
  reads the shared coderag.db (typically via the devcontainer's
  ~/.local/share/coderag bind mount) and serves the navigation API.
TEXT

Class Method Summary collapse

Class Method Details

.cmd_daemon(argv) ⇒ Object

-------------------------------------------------------------- commands



69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
# File 'lib/msnav/cli.rb', line 69

def cmd_daemon(argv)
  opts = { host: "127.0.0.1", port: 8787 }
  parser = OptionParser.new do |o|
    o.banner = "Usage: msnav daemon [options]"
    o.on("--config PATH", "coderag.yml to load") { |v| opts[:config] = v }
    o.on("--host HOST") { |v| opts[:host] = v }
    o.on("--port PORT", Integer) { |v| opts[:port] = v }
    # service scope: names which indexed service this container's window
    # holds (msnav never extracts — this feeds health's `scope`, the
    # extension's exact container<->host path mapping)
    o.on("--service-root DIR") { |v| opts[:service_root] = v }
    o.on("--service NAME") { |v| opts[:service] = v }
    # accepted for coderag CLI compatibility (msnav never indexes)
    o.on("--no-embed") {}
    o.on("--no-watch") {}
  end
  parser.parse!(argv)
  cfg = Config.load(opts[:config])
  puts "msnav #{VERSION} — index #{cfg.db_path}"
  Server.new(cfg, host: opts[:host], port: opts[:port],
                  service_root: opts[:service_root],
                  service_name: opts[:service]).start
  0
end

.cmd_doctor(argv) ⇒ Object

Walks the same resolution ladder msnav up uses and shows what each rung sees from THIS directory — run it where up failed with exit 2.



220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
# File 'lib/msnav/cli.rb', line 220

def cmd_doctor(argv)
  opts = parse_host_port(argv, "doctor")
  url = "http://#{opts[:host]}:#{opts[:port]}"
  start = Pathname.new(Dir.pwd)
  puts "msnav #{VERSION} — hub resolution from #{start}"

  row = ->(k, v) { puts format("  %-17s %s", k, v) }
  row.call("folder name:", "'#{start.basename}' (matched against service " \
                           "dir names at each hub, then by file contents)")
  env_cfg = ENV["CODERAG_CONFIG"].to_s
  row.call("CODERAG_CONFIG:", env_cfg.empty? ? "(unset)" : env_cfg)
  walk = Ctl.find_hub_root(start)
  row.call("coderag.yml:", walk ? "found at #{walk}" : "none from here up to /")
  conv = Pathname.new(ENV["CODERAG_HUB"] || "/coderag-hub")
  row.call("CODERAG_HUB:", "#{conv} " +
           ((conv + "coderag.yml").file? ? "(usable)" : "(no coderag.yml there)"))
  dd = Datadir.data_dir
  src = ENV["CODERAG_DATA_DIR"].to_s.empty? ? "derived from home #{Dir.home}" : "$CODERAG_DATA_DIR"
  row.call("data dir:", "#{dd} (#{src})")

  hubs = dd + "hubs"
  if hubs.directory?
    entries = hubs.children.sort.select(&:directory?)
    puts "    no hubs inside — index on the HOST first (`coderag index` at the hub)" if entries.empty?
    entries.each do |h|
      db = h + "coderag.db"
      next puts "    #{h.basename}: (no coderag.db)" unless db.file?
      names = begin
        con = SQLite3::Database.new(db.to_s, readonly: true)
        begin
          con.execute("SELECT dirname FROM services").map { |r| r[0] }
        ensure
          con.close
        end
      rescue SQLite3::Exception => e
        next puts "    #{h.basename}: unreadable (#{e.message})"
      end
      identified = Datadir.identify_service(db, start)
      mark = if identified.nil?
               ""
             elsif identified == start.basename.to_s
               "   <-- matches this folder by name"
             else
               "   <-- matches this folder by content (as #{identified})"
             end
      puts "    #{h.basename}: #{names.join(', ')}#{mark}"
    end
  else
    puts "    MISSING — the host's ~/.local/share/coderag is not visible here."
    puts "    Mount it into the REMOTE USER's home, or mount anywhere and set $CODERAG_DATA_DIR."
  end

  probe = Ctl.probe_health(url)
  row.call("daemon #{url}:",
           case probe.state
           when "healthy" then "healthy (root #{probe.root}, pid #{probe.pid})"
           when "foreign" then "something else answers (#{probe.detail})"
           else "down"
           end)

  begin
    hub, config, service_root, service_name = resolve_hub(nil, nil)
    puts "\nresolution: OK — hub #{hub}, config #{config}" \
         "#{service_root ? ", service root #{service_root}" : ''}" \
         "#{service_name ? " (service #{service_name})" : ''}"
    0
  rescue Exit
    puts "\nresolution: FAILED — `msnav up` from here exits 2. Fixes, best first:"
    puts "  * index the hub on the HOST (`coderag index` in the folder containing"
    puts "    all services) so this service's files match an index by content"
    puts "  * run from the folder that actually holds the service's source"
    puts "    (content matching needs its .rb files on disk)"
    puts "  * or set $CODERAG_HUB to the hub dir shown above (it holds a coderag.yml),"
    puts "    or pass --config <hub-dir>/coderag.yml in msnav.daemonCommand"
    puts "  * non-root container? mount the data dir into the remote user's home," \
         " or set $CODERAG_DATA_DIR to the mount target"
    1
  end
end

.cmd_down(argv) ⇒ Object

Raises:



185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
# File 'lib/msnav/cli.rb', line 185

def cmd_down(argv)
  opts = parse_host_port(argv, "down")
  url = "http://#{opts[:host]}:#{opts[:port]}"
  probe = Ctl.probe_health(url)
  if probe.state == "down"
    puts "no daemon at #{url}"
    return 0
  end
  if probe.state == "foreign"
    raise Exit.new(1, "#{url} is not a coderag daemon (#{probe.detail}) " \
                      "— not touching it")
  end
  pid = probe.pid
  raise Exit.new(1, "daemon health reports no pid — stop it manually") if pid.nil?
  begin
    Process.kill("TERM", pid)
  rescue Errno::ESRCH
    raise Exit.new(1, "daemon reports pid #{pid} but it is not visible " \
                      "here — stop it from its own environment " \
                      "(host vs container)")
  rescue Errno::EPERM
    raise Exit.new(1, "no permission to signal pid #{pid}")
  end
  20.times do
    if Ctl.probe_health(url).state == "down"
      puts "stopped: pid #{pid}"
      return 0
    end
    sleep 0.25
  end
  raise Exit.new(1, "sent SIGTERM to pid #{pid} but #{url} still answers")
end

.cmd_status(argv) ⇒ Object



158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
# File 'lib/msnav/cli.rb', line 158

def cmd_status(argv)
  opts = parse_host_port(argv, "status")
  url = "http://#{opts[:host]}:#{opts[:port]}"
  probe = Ctl.probe_health(url)
  if probe.state == "down"
    raise Exit.new(1, "no daemon at #{url} (#{probe.detail})")
  end
  if probe.state == "foreign"
    raise Exit.new(1, "#{url} is answering but is not a coderag daemon " \
                      "(#{probe.detail})")
  end
  h = probe.health
  puts "coderag-compatible daemon at #{url}"
  puts "  root:       #{h['root']}"
  puts "  version:    #{h['version']}   pid: #{h['pid']}   " \
       "generation: #{h['generation']}"
  puts "  services:   #{(h['services'] || []).join(', ')}"
  windows = fetch_windows(url)
  live = windows.count { |w| w["alive"] }
  puts "  windows:    #{live} live / #{windows.length} registered"
  windows.each do |w|
    mark = w["alive"] ? "live" : "stale (#{w['last_poll_ago']}s)"
    puts "    [#{mark}] #{w['window_id']} roots=#{w['roots']}"
  end
  0
end

.cmd_up(argv) ⇒ Object

Raises:



94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
# File 'lib/msnav/cli.rb', line 94

def cmd_up(argv)
  opts = { host: "127.0.0.1", port: 8787, timeout: 15.0 }
  parser = OptionParser.new do |o|
    o.banner = "Usage: msnav up [ROOT] [options]"
    o.on("--config PATH") { |v| opts[:config] = v }
    o.on("--host HOST") { |v| opts[:host] = v }
    o.on("--port PORT", Integer) { |v| opts[:port] = v }
    o.on("--timeout SECONDS", Float,
         "Seconds to wait for the daemon to become healthy") { |v| opts[:timeout] = v }
    # passthrough flags kept for coderag compatibility
    o.on("--service-root DIR") { |v| opts[:service_root] = v }
    o.on("--service NAME") { |v| opts[:service] = v }
    o.on("--no-embed") {}
    o.on("--no-watch") {}
  end
  parser.parse!(argv)
  root = argv.shift

  hub, config, implicit_root, implicit_name = resolve_hub(opts[:config], root)
  service_root = opts[:service_root] || implicit_root
  service_name = opts[:service] || implicit_name
  expected = Ctl.expected_workspace_root(hub)
  url = "http://#{opts[:host]}:#{opts[:port]}"

  probe = Ctl.probe_health(url)
  if probe.state == "foreign"
    raise Exit.new(Ctl::EXIT_FAIL,
                   "#{url} is answering but is not a coderag daemon " \
                   "(#{probe.detail}) — is the port taken by something else?")
  end
  if probe.state == "healthy"
    if Ctl.same_root(probe.root, expected)
      puts "already running: #{url} root=#{probe.root} pid=#{probe.pid}"
      return 0
    end
    raise Exit.new(Ctl::EXIT_WRONG_ROOT,
                   "a coderag daemon on #{url} serves a different hub:\n" \
                   "  running: #{probe.root}\n  requested: #{expected}\n" \
                   "leave it alone or use --port for a second daemon.")
  end

  log_path = Ctl.daemon_log_path(hub)
  _pid, waiter = Ctl.spawn_daemon(hub, config, opts[:host], opts[:port],
                                  service_root: service_root,
                                  service_name: service_name,
                                  log_path: log_path)
  probe = Ctl.wait_healthy(url, waiter, opts[:timeout])
  if probe.state == "healthy"
    if Ctl.same_root(probe.root, expected)
      puts "started: #{url} root=#{probe.root} pid=#{probe.pid}"
      return 0
    end
    raise Exit.new(Ctl::EXIT_WRONG_ROOT,
                   "a daemon appeared on #{url} but for a different hub " \
                   "(#{probe.root})")
  end
  tail = Ctl.tail_log(log_path)
  raise Exit.new(Ctl::EXIT_FAIL,
                 "daemon did not become healthy within " \
                 "#{opts[:timeout].round}s" +
                 (tail.empty? ? " and wrote nothing to daemon.log" :
                    " — last daemon.log lines:\n#{tail}"))
end

.fetch_windows(url) ⇒ Object



312
313
314
315
316
317
318
319
320
321
322
# File 'lib/msnav/cli.rb', line 312

def fetch_windows(url)
  require "net/http"
  uri = URI.parse("#{url}/api/windows")
  res = Net::HTTP.start(uri.host, uri.port,
                        open_timeout: 2, read_timeout: 2) do |http|
    http.get(uri.request_uri)
  end
  res.is_a?(Net::HTTPSuccess) ? JSON.parse(res.body) : []
rescue StandardError
  []
end

.parse_host_port(argv, name) ⇒ Object

--------------------------------------------------------------- helpers



302
303
304
305
306
307
308
309
310
# File 'lib/msnav/cli.rb', line 302

def parse_host_port(argv, name)
  opts = { host: "127.0.0.1", port: 8787 }
  OptionParser.new do |o|
    o.banner = "Usage: msnav #{name} [options]"
    o.on("--host HOST") { |v| opts[:host] = v }
    o.on("--port PORT", Integer) { |v| opts[:port] = v }
  end.parse!(argv)
  opts
end

.resolve_hub(config_path, root) ⇒ Object

Hub root + config for the lifecycle commands: explicit --config wins, then $CODERAG_CONFIG, then a coderag.yml walk-up from ROOT or the cwd, then the conventional container mount ($CODERAG_HUB, default /coderag-hub), then the package data dir (which hub's index matches the service this folder holds — by directory name or file contents, so /app style mounts work). In the last two cases the start directory is implicitly the service root (a service-only container). Returns [hub, config_path, implicit_service_root, implicit_service_name] or exits 2 — a daemon must never be rooted inside a single service directory.

Raises:



334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
# File 'lib/msnav/cli.rb', line 334

def resolve_hub(config_path, root)
  explicit = config_path
  env = ENV["CODERAG_CONFIG"]
  explicit ||= env if env && !env.empty?
  if explicit
    config = Pathname.new(File.expand_path(explicit))
    unless config.file?
      raise Exit.new(Ctl::EXIT_NO_HUB, "config not found: #{config}")
    end
    return [config.parent, config, nil, nil]
  end

  start = Pathname.new(File.expand_path(root || Dir.pwd))
  start = start.realpath if start.exist?
  hub = Ctl.find_hub_root(start)
  return [hub, hub + "coderag.yml", nil, nil] unless hub.nil?

  conv = Pathname.new(ENV["CODERAG_HUB"] || "/coderag-hub")
  if (conv + "coderag.yml").file?
    # service-only container: the hub arrives as a .coderag-only mount
    # and this directory holds exactly one service's source
    db = Ctl.resolved_storage(conv) + "coderag.db"
    name = db.file? ? Datadir.identify_service(db, start) : nil
    return [conv, conv + "coderag.yml", start.to_s, name]
  end

  # package data dir: a container with only the data-dir mount finds its
  # hub by which index matches the service this folder holds
  matches = Datadir.find_hubs_for_service_root(start)
  if matches.length == 1
    hub, name = matches[0]
    config = hub + "coderag.yml"
    # older index without a snapshot
    config.write("storage_dir: .\n") unless config.file?
    return [hub, config, start.to_s, name]
  end
  if matches.length > 1
    raise Exit.new(Ctl::EXIT_NO_HUB,
                   "the service in #{start} is indexed in multiple hubs:\n  " +
                   matches.map { |h, n| "#{h} (as #{n})" }.join("\n  ") +
                   "\npass --config <hub-dir>/coderag.yml to pick one.")
  end
  raise Exit.new(Ctl::EXIT_NO_HUB,
                 "no coderag.yml found here, in any parent directory, or " \
                 "at the conventional hub mount (#{conv}); and no indexed " \
                 "hub in #{Datadir.data_dir} matches the service in " \
                 "#{start}.\nIndex the hub first — on the HOST, in the " \
                 "folder containing all services, run `coderag index` — " \
                 "and make sure the data dir (~/.local/share/coderag) is " \
                 "mounted into this container." \
                 "\nRun `msnav doctor` here to see what each resolution " \
                 "step found.")
end

.run(argv) ⇒ Object



40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
# File 'lib/msnav/cli.rb', line 40

def run(argv)
  command = argv.first
  rest = argv[1..-1] || []
  case command
  when "daemon" then cmd_daemon(rest)
  when "up" then cmd_up(rest)
  when "status" then cmd_status(rest)
  when "down" then cmd_down(rest)
  when "doctor" then cmd_doctor(rest)
  when "version", "--version", "-v"
    puts "msnav #{VERSION}"
    0
  when nil, "help", "--help", "-h"
    puts USAGE
    command.nil? ? 1 : 0
  else
    warn "unknown command: #{command}\n\n#{USAGE}"
    1
  end
rescue Exit => e
  warn e.message unless e.message.empty?
  e.code
rescue Error => e
  warn "msnav: #{e.message}"
  1
end