msnav 0.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 3ea0f1f4fee68dd182f4e86da45adb998043e96a62d79effa4a05094ab8fdbed
4
+ data.tar.gz: 75cd16332b3edc2b185adcbd155dcc4fc88e3904a5887065d3d910edaaccea64
5
+ SHA512:
6
+ metadata.gz: 87694f9c0587ea8bea74c54027101d26f90c7c42e06790e853388a8608562a1dfadee1736d15fb272aa0f8f86fead6e45afc6684825d47fca1912b3f5c255c9d
7
+ data.tar.gz: 9f1c22c8076ccf207e700b9378ebeb97ee2f4d6c1e6fccde3c6d2f37b23f670edf8aa3164a8c48d038a8f7f5c0629e7d37f0c5cea097956712416cbfb1ad13dd
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Yaroslav Zahoruiko
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,187 @@
1
+ # msnav — microservice navigation server (Ruby)
2
+
3
+ A pure-Ruby daemon, run the way ruby-lsp is run, that serves
4
+ [coderag](../graph-rag-v3)'s cross-service navigation API **inside a Ruby
5
+ devcontainer with no Python**. The host builds the index with coderag; the
6
+ container mounts the shared data dir and runs `msnav` — same endpoints, same
7
+ DB, same editor extension UX.
8
+
9
+ ```
10
+ HOST CONTAINER (ruby image, no Python)
11
+ ───────────────────────────── ──────────────────────────────────
12
+ coderag index <hub> ──DB──▶ msnav daemon ◀── editor extension
13
+ ~/.local/share/coderag/hubs/<hub>/ reads coderag.db through the
14
+ (coderag.db stays here) ~/.local/share/coderag bind mount
15
+ ```
16
+
17
+ msnav is a **navigation face over the shared index**: it never indexes. When
18
+ anything (a host `coderag index`, a coderag daemon elsewhere) bumps the DB's
19
+ `index_generation`, every msnav on the hub hot-reloads within a second.
20
+
21
+ ## What it serves
22
+
23
+ The exact endpoints the editor extension calls, wire-compatible with the
24
+ Python daemon (verified response-for-response against it — see Tests):
25
+
26
+ | Endpoint | Purpose |
27
+ |---|---|
28
+ | `GET /api/health` | identity probe: root, services, service_roots, pid |
29
+ | `GET /api/nav/definition` | cross-service go-to-definition (HTTP call → route, publish → consumer, route → handler) |
30
+ | `GET /api/nav/references` | cross-service callers of the endpoint under the cursor |
31
+ | `GET /api/nav/hover` | target endpoint's YARD doc for the call on this line |
32
+ | `GET /api/nav/file-targets` | every cross-service jump point in a file (CodeLens) |
33
+ | `POST /api/register-window` `GET /api/window-commands` `GET /api/windows` `POST /api/open` | bridge-mode window registry + open routing, shared with coderag daemons through the DB |
34
+ | `GET /api/services` `GET /api/routes` | diagnostics |
35
+
36
+ Not included (by design — they need the indexer/LLM stack): reindex, watch,
37
+ search, ask/chat, MCP, the composite LSP. Indexing freshness comes from the
38
+ host.
39
+
40
+ ## Quickstart
41
+
42
+ On the **host** (once per hub, with coderag installed):
43
+
44
+ ```sh
45
+ cd /path/to/workspace # the folder containing all services, with coderag.yml
46
+ coderag index . --no-embed
47
+ ```
48
+
49
+ In the **devcontainer** (Ruby image):
50
+
51
+ ```sh
52
+ gem install msnav # or from this repo: gem build msnav.gemspec && gem install msnav-*.gem
53
+ msnav up # idempotent; the editor extension runs this itself
54
+ msnav status
55
+ ```
56
+
57
+ The only container config needed is the data-dir mount — one line, identical
58
+ for every service of every hub:
59
+
60
+ ```json
61
+ "mounts": [
62
+ "source=${localEnv:HOME}/.local/share/coderag,target=/root/.local/share/coderag,type=bind"
63
+ ]
64
+ ```
65
+
66
+ `msnav up` finds the hub the same way `coderag up` does: nearest ancestor
67
+ `coderag.yml` → the conventional hub mount (`$CODERAG_HUB`, default
68
+ `/coderag-hub`) → the data dir, matching the folder against each hub's index
69
+ by directory name and then **by file contents** — so compose-style anonymous
70
+ mounts (`workspaceFolder: /app`) resolve without any settings. The resolved
71
+ service travels to the daemon, whose `/api/health` reports the `scope`
72
+ (container path ↔ canonical host root) that the extension uses as its exact
73
+ path mapping. Same exit codes too: 0 healthy, 1 failure, 2 no hub, 3 the
74
+ port serves a different hub. `$CODERAG_DATA_DIR` / `$CODERAG_CONFIG` are
75
+ honored. `msnav doctor` explains resolution from any folder.
76
+
77
+ Because service locations come straight from the shared DB, an in-container
78
+ msnav serves **host paths** for cross-service targets — jumps land in the
79
+ window that owns the service (routed via the DB-backed window registry, so
80
+ it works across per-container daemons, Ruby and Python alike) or open a new
81
+ host window.
82
+
83
+ ## Editor extension
84
+
85
+ [editor/msnav/](editor/msnav/) is the coderag extension adapted for msnav
86
+ (`msnav.*` settings, default daemon command `msnav up`, provider + bridge
87
+ modes). Package with `npx @vscode/vsce package --no-dependencies`. The
88
+ original coderag extension also works against msnav — the protocol is
89
+ identical — if you point its `coderag.daemonCommand` at `["msnav", "up"]`.
90
+
91
+ ## Devcontainer example
92
+
93
+ [examples/devcontainer/](examples/devcontainer/) has the complete per-service
94
+ pattern: Ruby-only Dockerfile (`libsqlite3-dev` for the sqlite3 gem, no
95
+ Python), the single data-dir mount, and a postCreate that installs msnav and
96
+ the extension.
97
+
98
+ ## CLI
99
+
100
+ ```
101
+ msnav daemon [--config PATH] [--host H] [--port P] # run in the foreground
102
+ msnav up [ROOT] [--port P] [--timeout S] # ensure one is running (idempotent)
103
+ msnav status [--port P] # health + registered windows
104
+ msnav down [--port P] # SIGTERM by health-reported pid
105
+ ```
106
+
107
+ `daemon`/`up` also accept `--service-root/--service/--no-embed/--no-watch`
108
+ for drop-in compatibility with `coderag up` command lines; they change
109
+ nothing (msnav never indexes).
110
+
111
+ ## Compatibility
112
+
113
+ - Reads coderag **schema v4** DBs (errors on older; warns-and-continues on
114
+ newer). Writes only the two v4 message-bus tables (`windows`,
115
+ `window_commands`) every daemon on the hub shares.
116
+ - Ruby ≥ 2.6; gems: `sqlite3`, `webrick`.
117
+ - The Docker-Desktop caveat from coderag applies here too: SQLite (WAL) over
118
+ bind mounts with several concurrent writers is the sensitive spot; msnav
119
+ keeps its writes tiny (window registry only).
120
+
121
+ ## Troubleshooting
122
+
123
+ **`msnav up` exits 2 / "no hub found" in a devcontainer** — hub resolution
124
+ failed. Run `msnav doctor` in the container terminal (from the workspace
125
+ folder): it walks the same ladder `up` uses and shows what each step found.
126
+ The folder is matched against each hub's index first by directory name, then
127
+ **by content** (which service's indexed file paths are actually on disk —
128
+ ≥3 files and ≥60% present), so anonymous mounts like `/app` resolve too.
129
+ Common causes when it still fails:
130
+
131
+ 1. *Hub not indexed / stale* — run `coderag index` on the HOST at the hub
132
+ (the folder containing all services); content matching compares against
133
+ that index.
134
+ 2. *Data dir not visible* — the mount targets `/root/...` but the container
135
+ runs as a non-root `remoteUser`, so msnav looks in `/home/<user>/...`.
136
+ Mount into the remote user's home, or mount anywhere and set
137
+ `$CODERAG_DATA_DIR` to the target.
138
+ 3. Escape hatches: `$CODERAG_HUB=<hub dir>` or
139
+ `--config <hub-dir>/coderag.yml` in `msnav.daemonCommand`.
140
+
141
+ **`can't find executable msnav for gem msnav. msnav is not currently
142
+ included in the bundle`** — msnav was invoked inside a Bundler context
143
+ (`bundle exec`, or an environment exporting `RUBYOPT=-rbundler/setup` /
144
+ `BUNDLE_GEMFILE`): rubygems binstubs then refuse any gem the active Gemfile
145
+ doesn't list. msnav never needs Bundler. Fixes, best first:
146
+
147
+ 1. Invoke it with the Bundler context stripped —
148
+ `env -u RUBYOPT -u RUBYLIB -u BUNDLE_GEMFILE -u BUNDLE_BIN_PATH msnav up`
149
+ (the editor extension and `examples/devcontainer/postCreate.sh` already
150
+ do this, and `msnav up` scrubs the same variables when spawning the
151
+ daemon).
152
+ 2. Or add `gem "msnav", group: :development` to the service's Gemfile so the
153
+ bundle knows it — the ruby-lsp way; only needed if you insist on running
154
+ it through Bundler.
155
+
156
+ **`can't find gem msnav (>= 0.a) with executable msnav
157
+ (Gem::GemNotFoundException)`** — msnav was installed as a **git gem** in a
158
+ Gemfile (`gem "msnav", github: …`). Git gems are visible only inside
159
+ `bundle exec` for that Gemfile; the binstub Bundler leaves on PATH cannot
160
+ activate them, so the extension's `msnav up` fails. msnav never loads your
161
+ app's code (unlike ruby-lsp), so don't put it in a Gemfile at all — install
162
+ it as a plain gem in the image (`gem install msnav`, or build+install from a
163
+ git checkout). Quick fix inside a running container:
164
+ `cd /usr/local/bundle/bundler/gems/msnav-*/ && gem build msnav.gemspec -o
165
+ /tmp/m.gem && gem install /tmp/m.gem`.
166
+
167
+ **`WARN: Unresolved or ambiguous specs during Gem::Specification.reset:
168
+ stringio (…)` during install** — benign RubyGems noise, not an msnav error.
169
+ It appears when the image carries two versions of a default gem (the one
170
+ bundled with its Ruby plus a newer one some earlier `gem install` pulled in);
171
+ any `gem` command then prints it. Install and runtime are unaffected —
172
+ verified with the exact duplicate pair on Ruby 3.1. Silence it by upgrading
173
+ RubyGems in the image (`gem update --system`) or just ignore it.
174
+
175
+ ## Tests
176
+
177
+ ```sh
178
+ rake test # or: ruby -Ilib -Itest test/test_*.rb
179
+ ```
180
+
181
+ Unit tests run against a generated schema-v4 fixture DB; the HTTP layer is
182
+ tested through a real WEBrick boot. Parity was verified against the Python
183
+ daemon on coderag's `examples/shop`: **776/776** identical responses across
184
+ every line of every file for definition / references / hover / file-targets
185
+ (+ services and routes), plus cross-daemon window routing (register with the
186
+ Python daemon, open via msnav, and vice versa) and generation hot-reload
187
+ after a Python-side reindex.
data/exe/msnav ADDED
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # run-from-checkout support: prefer the sibling lib when it's there (a gem
5
+ # install resolves msnav through RubyGems instead)
6
+ lib = File.expand_path("../lib", __dir__)
7
+ $LOAD_PATH.unshift(lib) if File.directory?(lib) && !$LOAD_PATH.include?(lib)
8
+
9
+ require "msnav"
10
+
11
+ exit Msnav::CLI.run(ARGV)
data/lib/msnav/cli.rb ADDED
@@ -0,0 +1,388 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require "pathname"
5
+
6
+ module Msnav
7
+ # `msnav` command line: daemon / up / status / down — the same lifecycle
8
+ # surface (flags, hub resolution, exit codes) as `coderag daemon/up/...`,
9
+ # so the editor extension's daemonCommand can point at either.
10
+ module CLI
11
+ USAGE = <<~TEXT
12
+ msnav — cross-service Ruby navigation server over a coderag index
13
+
14
+ Usage: msnav COMMAND [options]
15
+
16
+ Commands:
17
+ daemon Run the navigation daemon (HTTP API on --host/--port)
18
+ up Ensure a daemon for this hub is running (idempotent)
19
+ status Health and registered windows of the daemon
20
+ down Stop the daemon on --host/--port
21
+ doctor Explain hub resolution from the current directory
22
+ version Print the msnav version
23
+
24
+ The index is built on the host by coderag (`coderag index`); msnav
25
+ reads the shared coderag.db (typically via the devcontainer's
26
+ ~/.local/share/coderag bind mount) and serves the navigation API.
27
+ TEXT
28
+
29
+ class Exit < StandardError
30
+ attr_reader :code
31
+
32
+ def initialize(code, message = nil)
33
+ @code = code
34
+ super(message || "")
35
+ end
36
+ end
37
+
38
+ module_function
39
+
40
+ def run(argv)
41
+ command = argv.first
42
+ rest = argv[1..-1] || []
43
+ case command
44
+ when "daemon" then cmd_daemon(rest)
45
+ when "up" then cmd_up(rest)
46
+ when "status" then cmd_status(rest)
47
+ when "down" then cmd_down(rest)
48
+ when "doctor" then cmd_doctor(rest)
49
+ when "version", "--version", "-v"
50
+ puts "msnav #{VERSION}"
51
+ 0
52
+ when nil, "help", "--help", "-h"
53
+ puts USAGE
54
+ command.nil? ? 1 : 0
55
+ else
56
+ warn "unknown command: #{command}\n\n#{USAGE}"
57
+ 1
58
+ end
59
+ rescue Exit => e
60
+ warn e.message unless e.message.empty?
61
+ e.code
62
+ rescue Error => e
63
+ warn "msnav: #{e.message}"
64
+ 1
65
+ end
66
+
67
+ # -------------------------------------------------------------- commands
68
+
69
+ def cmd_daemon(argv)
70
+ opts = { host: "127.0.0.1", port: 8787 }
71
+ parser = OptionParser.new do |o|
72
+ o.banner = "Usage: msnav daemon [options]"
73
+ o.on("--config PATH", "coderag.yml to load") { |v| opts[:config] = v }
74
+ o.on("--host HOST") { |v| opts[:host] = v }
75
+ o.on("--port PORT", Integer) { |v| opts[:port] = v }
76
+ # service scope: names which indexed service this container's window
77
+ # holds (msnav never extracts — this feeds health's `scope`, the
78
+ # extension's exact container<->host path mapping)
79
+ o.on("--service-root DIR") { |v| opts[:service_root] = v }
80
+ o.on("--service NAME") { |v| opts[:service] = v }
81
+ # accepted for coderag CLI compatibility (msnav never indexes)
82
+ o.on("--no-embed") {}
83
+ o.on("--no-watch") {}
84
+ end
85
+ parser.parse!(argv)
86
+ cfg = Config.load(opts[:config])
87
+ puts "msnav #{VERSION} — index #{cfg.db_path}"
88
+ Server.new(cfg, host: opts[:host], port: opts[:port],
89
+ service_root: opts[:service_root],
90
+ service_name: opts[:service]).start
91
+ 0
92
+ end
93
+
94
+ def cmd_up(argv)
95
+ opts = { host: "127.0.0.1", port: 8787, timeout: 15.0 }
96
+ parser = OptionParser.new do |o|
97
+ o.banner = "Usage: msnav up [ROOT] [options]"
98
+ o.on("--config PATH") { |v| opts[:config] = v }
99
+ o.on("--host HOST") { |v| opts[:host] = v }
100
+ o.on("--port PORT", Integer) { |v| opts[:port] = v }
101
+ o.on("--timeout SECONDS", Float,
102
+ "Seconds to wait for the daemon to become healthy") { |v| opts[:timeout] = v }
103
+ # passthrough flags kept for coderag compatibility
104
+ o.on("--service-root DIR") { |v| opts[:service_root] = v }
105
+ o.on("--service NAME") { |v| opts[:service] = v }
106
+ o.on("--no-embed") {}
107
+ o.on("--no-watch") {}
108
+ end
109
+ parser.parse!(argv)
110
+ root = argv.shift
111
+
112
+ hub, config, implicit_root, implicit_name = resolve_hub(opts[:config], root)
113
+ service_root = opts[:service_root] || implicit_root
114
+ service_name = opts[:service] || implicit_name
115
+ expected = Ctl.expected_workspace_root(hub)
116
+ url = "http://#{opts[:host]}:#{opts[:port]}"
117
+
118
+ probe = Ctl.probe_health(url)
119
+ if probe.state == "foreign"
120
+ raise Exit.new(Ctl::EXIT_FAIL,
121
+ "#{url} is answering but is not a coderag daemon " \
122
+ "(#{probe.detail}) — is the port taken by something else?")
123
+ end
124
+ if probe.state == "healthy"
125
+ if Ctl.same_root(probe.root, expected)
126
+ puts "already running: #{url} root=#{probe.root} pid=#{probe.pid}"
127
+ return 0
128
+ end
129
+ raise Exit.new(Ctl::EXIT_WRONG_ROOT,
130
+ "a coderag daemon on #{url} serves a different hub:\n" \
131
+ " running: #{probe.root}\n requested: #{expected}\n" \
132
+ "leave it alone or use --port for a second daemon.")
133
+ end
134
+
135
+ log_path = Ctl.daemon_log_path(hub)
136
+ _pid, waiter = Ctl.spawn_daemon(hub, config, opts[:host], opts[:port],
137
+ service_root: service_root,
138
+ service_name: service_name,
139
+ log_path: log_path)
140
+ probe = Ctl.wait_healthy(url, waiter, opts[:timeout])
141
+ if probe.state == "healthy"
142
+ if Ctl.same_root(probe.root, expected)
143
+ puts "started: #{url} root=#{probe.root} pid=#{probe.pid}"
144
+ return 0
145
+ end
146
+ raise Exit.new(Ctl::EXIT_WRONG_ROOT,
147
+ "a daemon appeared on #{url} but for a different hub " \
148
+ "(#{probe.root})")
149
+ end
150
+ tail = Ctl.tail_log(log_path)
151
+ raise Exit.new(Ctl::EXIT_FAIL,
152
+ "daemon did not become healthy within " \
153
+ "#{opts[:timeout].round}s" +
154
+ (tail.empty? ? " and wrote nothing to daemon.log" :
155
+ " — last daemon.log lines:\n#{tail}"))
156
+ end
157
+
158
+ def cmd_status(argv)
159
+ opts = parse_host_port(argv, "status")
160
+ url = "http://#{opts[:host]}:#{opts[:port]}"
161
+ probe = Ctl.probe_health(url)
162
+ if probe.state == "down"
163
+ raise Exit.new(1, "no daemon at #{url} (#{probe.detail})")
164
+ end
165
+ if probe.state == "foreign"
166
+ raise Exit.new(1, "#{url} is answering but is not a coderag daemon " \
167
+ "(#{probe.detail})")
168
+ end
169
+ h = probe.health
170
+ puts "coderag-compatible daemon at #{url}"
171
+ puts " root: #{h['root']}"
172
+ puts " version: #{h['version']} pid: #{h['pid']} " \
173
+ "generation: #{h['generation']}"
174
+ puts " services: #{(h['services'] || []).join(', ')}"
175
+ windows = fetch_windows(url)
176
+ live = windows.count { |w| w["alive"] }
177
+ puts " windows: #{live} live / #{windows.length} registered"
178
+ windows.each do |w|
179
+ mark = w["alive"] ? "live" : "stale (#{w['last_poll_ago']}s)"
180
+ puts " [#{mark}] #{w['window_id']} roots=#{w['roots']}"
181
+ end
182
+ 0
183
+ end
184
+
185
+ def cmd_down(argv)
186
+ opts = parse_host_port(argv, "down")
187
+ url = "http://#{opts[:host]}:#{opts[:port]}"
188
+ probe = Ctl.probe_health(url)
189
+ if probe.state == "down"
190
+ puts "no daemon at #{url}"
191
+ return 0
192
+ end
193
+ if probe.state == "foreign"
194
+ raise Exit.new(1, "#{url} is not a coderag daemon (#{probe.detail}) " \
195
+ "— not touching it")
196
+ end
197
+ pid = probe.pid
198
+ raise Exit.new(1, "daemon health reports no pid — stop it manually") if pid.nil?
199
+ begin
200
+ Process.kill("TERM", pid)
201
+ rescue Errno::ESRCH
202
+ raise Exit.new(1, "daemon reports pid #{pid} but it is not visible " \
203
+ "here — stop it from its own environment " \
204
+ "(host vs container)")
205
+ rescue Errno::EPERM
206
+ raise Exit.new(1, "no permission to signal pid #{pid}")
207
+ end
208
+ 20.times do
209
+ if Ctl.probe_health(url).state == "down"
210
+ puts "stopped: pid #{pid}"
211
+ return 0
212
+ end
213
+ sleep 0.25
214
+ end
215
+ raise Exit.new(1, "sent SIGTERM to pid #{pid} but #{url} still answers")
216
+ end
217
+
218
+ # Walks the same resolution ladder `msnav up` uses and shows what each
219
+ # rung sees from THIS directory — run it where `up` failed with exit 2.
220
+ def cmd_doctor(argv)
221
+ opts = parse_host_port(argv, "doctor")
222
+ url = "http://#{opts[:host]}:#{opts[:port]}"
223
+ start = Pathname.new(Dir.pwd)
224
+ puts "msnav #{VERSION} — hub resolution from #{start}"
225
+
226
+ row = ->(k, v) { puts format(" %-17s %s", k, v) }
227
+ row.call("folder name:", "'#{start.basename}' (matched against service " \
228
+ "dir names at each hub, then by file contents)")
229
+ env_cfg = ENV["CODERAG_CONFIG"].to_s
230
+ row.call("CODERAG_CONFIG:", env_cfg.empty? ? "(unset)" : env_cfg)
231
+ walk = Ctl.find_hub_root(start)
232
+ row.call("coderag.yml:", walk ? "found at #{walk}" : "none from here up to /")
233
+ conv = Pathname.new(ENV["CODERAG_HUB"] || "/coderag-hub")
234
+ row.call("CODERAG_HUB:", "#{conv} " +
235
+ ((conv + "coderag.yml").file? ? "(usable)" : "(no coderag.yml there)"))
236
+ dd = Datadir.data_dir
237
+ src = ENV["CODERAG_DATA_DIR"].to_s.empty? ? "derived from home #{Dir.home}" : "$CODERAG_DATA_DIR"
238
+ row.call("data dir:", "#{dd} (#{src})")
239
+
240
+ hubs = dd + "hubs"
241
+ if hubs.directory?
242
+ entries = hubs.children.sort.select(&:directory?)
243
+ puts " no hubs inside — index on the HOST first (`coderag index` at the hub)" if entries.empty?
244
+ entries.each do |h|
245
+ db = h + "coderag.db"
246
+ next puts " #{h.basename}: (no coderag.db)" unless db.file?
247
+ names = begin
248
+ con = SQLite3::Database.new(db.to_s, readonly: true)
249
+ begin
250
+ con.execute("SELECT dirname FROM services").map { |r| r[0] }
251
+ ensure
252
+ con.close
253
+ end
254
+ rescue SQLite3::Exception => e
255
+ next puts " #{h.basename}: unreadable (#{e.message})"
256
+ end
257
+ identified = Datadir.identify_service(db, start)
258
+ mark = if identified.nil?
259
+ ""
260
+ elsif identified == start.basename.to_s
261
+ " <-- matches this folder by name"
262
+ else
263
+ " <-- matches this folder by content (as #{identified})"
264
+ end
265
+ puts " #{h.basename}: #{names.join(', ')}#{mark}"
266
+ end
267
+ else
268
+ puts " MISSING — the host's ~/.local/share/coderag is not visible here."
269
+ puts " Mount it into the REMOTE USER's home, or mount anywhere and set $CODERAG_DATA_DIR."
270
+ end
271
+
272
+ probe = Ctl.probe_health(url)
273
+ row.call("daemon #{url}:",
274
+ case probe.state
275
+ when "healthy" then "healthy (root #{probe.root}, pid #{probe.pid})"
276
+ when "foreign" then "something else answers (#{probe.detail})"
277
+ else "down"
278
+ end)
279
+
280
+ begin
281
+ hub, config, service_root, service_name = resolve_hub(nil, nil)
282
+ puts "\nresolution: OK — hub #{hub}, config #{config}" \
283
+ "#{service_root ? ", service root #{service_root}" : ''}" \
284
+ "#{service_name ? " (service #{service_name})" : ''}"
285
+ 0
286
+ rescue Exit
287
+ puts "\nresolution: FAILED — `msnav up` from here exits 2. Fixes, best first:"
288
+ puts " * index the hub on the HOST (`coderag index` in the folder containing"
289
+ puts " all services) so this service's files match an index by content"
290
+ puts " * run from the folder that actually holds the service's source"
291
+ puts " (content matching needs its .rb files on disk)"
292
+ puts " * or set $CODERAG_HUB to the hub dir shown above (it holds a coderag.yml),"
293
+ puts " or pass --config <hub-dir>/coderag.yml in msnav.daemonCommand"
294
+ puts " * non-root container? mount the data dir into the remote user's home," \
295
+ " or set $CODERAG_DATA_DIR to the mount target"
296
+ 1
297
+ end
298
+ end
299
+
300
+ # --------------------------------------------------------------- helpers
301
+
302
+ def parse_host_port(argv, name)
303
+ opts = { host: "127.0.0.1", port: 8787 }
304
+ OptionParser.new do |o|
305
+ o.banner = "Usage: msnav #{name} [options]"
306
+ o.on("--host HOST") { |v| opts[:host] = v }
307
+ o.on("--port PORT", Integer) { |v| opts[:port] = v }
308
+ end.parse!(argv)
309
+ opts
310
+ end
311
+
312
+ def fetch_windows(url)
313
+ require "net/http"
314
+ uri = URI.parse("#{url}/api/windows")
315
+ res = Net::HTTP.start(uri.host, uri.port,
316
+ open_timeout: 2, read_timeout: 2) do |http|
317
+ http.get(uri.request_uri)
318
+ end
319
+ res.is_a?(Net::HTTPSuccess) ? JSON.parse(res.body) : []
320
+ rescue StandardError
321
+ []
322
+ end
323
+
324
+ # Hub root + config for the lifecycle commands: explicit --config wins,
325
+ # then $CODERAG_CONFIG, then a coderag.yml walk-up from ROOT or the cwd,
326
+ # then the conventional container mount ($CODERAG_HUB, default
327
+ # /coderag-hub), then the package data dir (which hub's index matches the
328
+ # service this folder holds — by directory name or file contents, so
329
+ # `/app` style mounts work). In the last two cases the start directory is
330
+ # implicitly the service root (a service-only container). Returns
331
+ # [hub, config_path, implicit_service_root, implicit_service_name] or
332
+ # exits 2 — a daemon must never be rooted inside a single service
333
+ # directory.
334
+ def resolve_hub(config_path, root)
335
+ explicit = config_path
336
+ env = ENV["CODERAG_CONFIG"]
337
+ explicit ||= env if env && !env.empty?
338
+ if explicit
339
+ config = Pathname.new(File.expand_path(explicit))
340
+ unless config.file?
341
+ raise Exit.new(Ctl::EXIT_NO_HUB, "config not found: #{config}")
342
+ end
343
+ return [config.parent, config, nil, nil]
344
+ end
345
+
346
+ start = Pathname.new(File.expand_path(root || Dir.pwd))
347
+ start = start.realpath if start.exist?
348
+ hub = Ctl.find_hub_root(start)
349
+ return [hub, hub + "coderag.yml", nil, nil] unless hub.nil?
350
+
351
+ conv = Pathname.new(ENV["CODERAG_HUB"] || "/coderag-hub")
352
+ if (conv + "coderag.yml").file?
353
+ # service-only container: the hub arrives as a .coderag-only mount
354
+ # and this directory holds exactly one service's source
355
+ db = Ctl.resolved_storage(conv) + "coderag.db"
356
+ name = db.file? ? Datadir.identify_service(db, start) : nil
357
+ return [conv, conv + "coderag.yml", start.to_s, name]
358
+ end
359
+
360
+ # package data dir: a container with only the data-dir mount finds its
361
+ # hub by which index matches the service this folder holds
362
+ matches = Datadir.find_hubs_for_service_root(start)
363
+ if matches.length == 1
364
+ hub, name = matches[0]
365
+ config = hub + "coderag.yml"
366
+ # older index without a snapshot
367
+ config.write("storage_dir: .\n") unless config.file?
368
+ return [hub, config, start.to_s, name]
369
+ end
370
+ if matches.length > 1
371
+ raise Exit.new(Ctl::EXIT_NO_HUB,
372
+ "the service in #{start} is indexed in multiple hubs:\n " +
373
+ matches.map { |h, n| "#{h} (as #{n})" }.join("\n ") +
374
+ "\npass --config <hub-dir>/coderag.yml to pick one.")
375
+ end
376
+ raise Exit.new(Ctl::EXIT_NO_HUB,
377
+ "no coderag.yml found here, in any parent directory, or " \
378
+ "at the conventional hub mount (#{conv}); and no indexed " \
379
+ "hub in #{Datadir.data_dir} matches the service in " \
380
+ "#{start}.\nIndex the hub first — on the HOST, in the " \
381
+ "folder containing all services, run `coderag index` — " \
382
+ "and make sure the data dir (~/.local/share/coderag) is " \
383
+ "mounted into this container." \
384
+ "\nRun `msnav doctor` here to see what each resolution " \
385
+ "step found.")
386
+ end
387
+ end
388
+ end