codebase-onboarder 0.1.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.
Files changed (216) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +275 -0
  3. package/bin/onboarder.js +14 -0
  4. package/bin/postinstall.js +6 -0
  5. package/cli/commands.js +436 -0
  6. package/cli/main.js +129 -0
  7. package/cli/prompt.js +101 -0
  8. package/cli/ui.js +55 -0
  9. package/cli/wizard.js +325 -0
  10. package/package.json +48 -0
  11. package/public/app.js +1339 -0
  12. package/public/index.html +455 -0
  13. package/public/js/about.js +284 -0
  14. package/public/js/aiDraft.js +163 -0
  15. package/public/js/analysisPanel.js +270 -0
  16. package/public/js/analysisReport.js +154 -0
  17. package/public/js/api.js +264 -0
  18. package/public/js/atlas.js +98 -0
  19. package/public/js/blameView.js +41 -0
  20. package/public/js/codeTab.js +256 -0
  21. package/public/js/codeViewer.js +184 -0
  22. package/public/js/components/FileChip.js +102 -0
  23. package/public/js/components/MetricSparkline.js +89 -0
  24. package/public/js/components/RiskBadge.js +105 -0
  25. package/public/js/deepAnalysisView.js +496 -0
  26. package/public/js/diagramPane.js +154 -0
  27. package/public/js/diffView.js +352 -0
  28. package/public/js/docsView.js +566 -0
  29. package/public/js/fileSourceBrowser.js +47 -0
  30. package/public/js/flameGraph.js +75 -0
  31. package/public/js/forceGraph.js +592 -0
  32. package/public/js/heatmap.js +185 -0
  33. package/public/js/highlight.js +122 -0
  34. package/public/js/html.js +50 -0
  35. package/public/js/insightsView.js +236 -0
  36. package/public/js/inspector.js +535 -0
  37. package/public/js/llm.js +345 -0
  38. package/public/js/mapView.js +159 -0
  39. package/public/js/markdown.js +119 -0
  40. package/public/js/mindmap.js +289 -0
  41. package/public/js/repoFiles.js +44 -0
  42. package/public/js/sbomView.js +142 -0
  43. package/public/js/scanCache.js +76 -0
  44. package/public/js/search.js +874 -0
  45. package/public/js/serverSettings.js +169 -0
  46. package/public/js/state.js +192 -0
  47. package/public/js/tour.js +17 -0
  48. package/public/js/transitions.js +15 -0
  49. package/public/js/tree.js +200 -0
  50. package/public/js/workflowsView.js +95 -0
  51. package/public/styles.css +2767 -0
  52. package/public/vendor/mermaid.min.js +3587 -0
  53. package/public/vendor/monaco/vs/base/browser/ui/codicons/codicon/codicon.ttf +0 -0
  54. package/public/vendor/monaco/vs/base/worker/workerMain.js +31 -0
  55. package/public/vendor/monaco/vs/basic-languages/abap/abap.js +10 -0
  56. package/public/vendor/monaco/vs/basic-languages/apex/apex.js +10 -0
  57. package/public/vendor/monaco/vs/basic-languages/azcli/azcli.js +10 -0
  58. package/public/vendor/monaco/vs/basic-languages/bat/bat.js +10 -0
  59. package/public/vendor/monaco/vs/basic-languages/bicep/bicep.js +11 -0
  60. package/public/vendor/monaco/vs/basic-languages/cameligo/cameligo.js +10 -0
  61. package/public/vendor/monaco/vs/basic-languages/clojure/clojure.js +10 -0
  62. package/public/vendor/monaco/vs/basic-languages/coffee/coffee.js +10 -0
  63. package/public/vendor/monaco/vs/basic-languages/cpp/cpp.js +10 -0
  64. package/public/vendor/monaco/vs/basic-languages/csharp/csharp.js +10 -0
  65. package/public/vendor/monaco/vs/basic-languages/csp/csp.js +10 -0
  66. package/public/vendor/monaco/vs/basic-languages/css/css.js +12 -0
  67. package/public/vendor/monaco/vs/basic-languages/cypher/cypher.js +10 -0
  68. package/public/vendor/monaco/vs/basic-languages/dart/dart.js +10 -0
  69. package/public/vendor/monaco/vs/basic-languages/dockerfile/dockerfile.js +10 -0
  70. package/public/vendor/monaco/vs/basic-languages/ecl/ecl.js +10 -0
  71. package/public/vendor/monaco/vs/basic-languages/elixir/elixir.js +10 -0
  72. package/public/vendor/monaco/vs/basic-languages/flow9/flow9.js +10 -0
  73. package/public/vendor/monaco/vs/basic-languages/freemarker2/freemarker2.js +12 -0
  74. package/public/vendor/monaco/vs/basic-languages/fsharp/fsharp.js +10 -0
  75. package/public/vendor/monaco/vs/basic-languages/go/go.js +10 -0
  76. package/public/vendor/monaco/vs/basic-languages/graphql/graphql.js +10 -0
  77. package/public/vendor/monaco/vs/basic-languages/handlebars/handlebars.js +10 -0
  78. package/public/vendor/monaco/vs/basic-languages/hcl/hcl.js +10 -0
  79. package/public/vendor/monaco/vs/basic-languages/html/html.js +10 -0
  80. package/public/vendor/monaco/vs/basic-languages/ini/ini.js +10 -0
  81. package/public/vendor/monaco/vs/basic-languages/java/java.js +10 -0
  82. package/public/vendor/monaco/vs/basic-languages/javascript/javascript.js +10 -0
  83. package/public/vendor/monaco/vs/basic-languages/julia/julia.js +10 -0
  84. package/public/vendor/monaco/vs/basic-languages/kotlin/kotlin.js +10 -0
  85. package/public/vendor/monaco/vs/basic-languages/less/less.js +11 -0
  86. package/public/vendor/monaco/vs/basic-languages/lexon/lexon.js +10 -0
  87. package/public/vendor/monaco/vs/basic-languages/liquid/liquid.js +10 -0
  88. package/public/vendor/monaco/vs/basic-languages/lua/lua.js +10 -0
  89. package/public/vendor/monaco/vs/basic-languages/m3/m3.js +10 -0
  90. package/public/vendor/monaco/vs/basic-languages/markdown/markdown.js +10 -0
  91. package/public/vendor/monaco/vs/basic-languages/mdx/mdx.js +10 -0
  92. package/public/vendor/monaco/vs/basic-languages/mips/mips.js +10 -0
  93. package/public/vendor/monaco/vs/basic-languages/msdax/msdax.js +10 -0
  94. package/public/vendor/monaco/vs/basic-languages/mysql/mysql.js +10 -0
  95. package/public/vendor/monaco/vs/basic-languages/objective-c/objective-c.js +10 -0
  96. package/public/vendor/monaco/vs/basic-languages/pascal/pascal.js +10 -0
  97. package/public/vendor/monaco/vs/basic-languages/pascaligo/pascaligo.js +10 -0
  98. package/public/vendor/monaco/vs/basic-languages/perl/perl.js +10 -0
  99. package/public/vendor/monaco/vs/basic-languages/pgsql/pgsql.js +10 -0
  100. package/public/vendor/monaco/vs/basic-languages/php/php.js +10 -0
  101. package/public/vendor/monaco/vs/basic-languages/pla/pla.js +10 -0
  102. package/public/vendor/monaco/vs/basic-languages/postiats/postiats.js +10 -0
  103. package/public/vendor/monaco/vs/basic-languages/powerquery/powerquery.js +10 -0
  104. package/public/vendor/monaco/vs/basic-languages/powershell/powershell.js +10 -0
  105. package/public/vendor/monaco/vs/basic-languages/protobuf/protobuf.js +11 -0
  106. package/public/vendor/monaco/vs/basic-languages/pug/pug.js +10 -0
  107. package/public/vendor/monaco/vs/basic-languages/python/python.js +10 -0
  108. package/public/vendor/monaco/vs/basic-languages/qsharp/qsharp.js +10 -0
  109. package/public/vendor/monaco/vs/basic-languages/r/r.js +10 -0
  110. package/public/vendor/monaco/vs/basic-languages/razor/razor.js +10 -0
  111. package/public/vendor/monaco/vs/basic-languages/redis/redis.js +10 -0
  112. package/public/vendor/monaco/vs/basic-languages/redshift/redshift.js +10 -0
  113. package/public/vendor/monaco/vs/basic-languages/restructuredtext/restructuredtext.js +10 -0
  114. package/public/vendor/monaco/vs/basic-languages/ruby/ruby.js +10 -0
  115. package/public/vendor/monaco/vs/basic-languages/rust/rust.js +10 -0
  116. package/public/vendor/monaco/vs/basic-languages/sb/sb.js +10 -0
  117. package/public/vendor/monaco/vs/basic-languages/scala/scala.js +10 -0
  118. package/public/vendor/monaco/vs/basic-languages/scheme/scheme.js +10 -0
  119. package/public/vendor/monaco/vs/basic-languages/scss/scss.js +12 -0
  120. package/public/vendor/monaco/vs/basic-languages/shell/shell.js +10 -0
  121. package/public/vendor/monaco/vs/basic-languages/solidity/solidity.js +10 -0
  122. package/public/vendor/monaco/vs/basic-languages/sophia/sophia.js +10 -0
  123. package/public/vendor/monaco/vs/basic-languages/sparql/sparql.js +10 -0
  124. package/public/vendor/monaco/vs/basic-languages/sql/sql.js +10 -0
  125. package/public/vendor/monaco/vs/basic-languages/st/st.js +10 -0
  126. package/public/vendor/monaco/vs/basic-languages/swift/swift.js +13 -0
  127. package/public/vendor/monaco/vs/basic-languages/systemverilog/systemverilog.js +10 -0
  128. package/public/vendor/monaco/vs/basic-languages/tcl/tcl.js +10 -0
  129. package/public/vendor/monaco/vs/basic-languages/twig/twig.js +10 -0
  130. package/public/vendor/monaco/vs/basic-languages/typescript/typescript.js +10 -0
  131. package/public/vendor/monaco/vs/basic-languages/typespec/typespec.js +10 -0
  132. package/public/vendor/monaco/vs/basic-languages/vb/vb.js +10 -0
  133. package/public/vendor/monaco/vs/basic-languages/wgsl/wgsl.js +307 -0
  134. package/public/vendor/monaco/vs/basic-languages/xml/xml.js +10 -0
  135. package/public/vendor/monaco/vs/basic-languages/yaml/yaml.js +10 -0
  136. package/public/vendor/monaco/vs/editor/editor.main.css +8 -0
  137. package/public/vendor/monaco/vs/editor/editor.main.js +798 -0
  138. package/public/vendor/monaco/vs/language/css/cssMode.js +13 -0
  139. package/public/vendor/monaco/vs/language/css/cssWorker.js +77 -0
  140. package/public/vendor/monaco/vs/language/html/htmlMode.js +13 -0
  141. package/public/vendor/monaco/vs/language/html/htmlWorker.js +454 -0
  142. package/public/vendor/monaco/vs/language/json/jsonMode.js +19 -0
  143. package/public/vendor/monaco/vs/language/json/jsonWorker.js +42 -0
  144. package/public/vendor/monaco/vs/language/typescript/tsMode.js +20 -0
  145. package/public/vendor/monaco/vs/language/typescript/tsWorker.js +51328 -0
  146. package/public/vendor/monaco/vs/loader.js +11 -0
  147. package/public/vendor/monaco/worker-boot.js +9 -0
  148. package/server/.fuse_hidden0000000800000001 +36 -0
  149. package/server/apiDiff.js +25 -0
  150. package/server/apiDocs.js +67 -0
  151. package/server/apiFile.js +36 -0
  152. package/server/apiGitBlame.js +63 -0
  153. package/server/apiMcp.js +110 -0
  154. package/server/apiScan.js +119 -0
  155. package/server/apiSearch.js +253 -0
  156. package/server/apiSettings.js +118 -0
  157. package/server/apiTools.js +90 -0
  158. package/server/config.js +227 -0
  159. package/server/fileSourceNode.js +44 -0
  160. package/server/gitClone.js +95 -0
  161. package/server/gitDiff.js +184 -0
  162. package/server/gitHistory.js +110 -0
  163. package/server/htmlText.js +44 -0
  164. package/server/http.js +55 -0
  165. package/server/httpGuards.js +87 -0
  166. package/server/index.js +156 -0
  167. package/server/llmProxy.js +162 -0
  168. package/server/logger.js +29 -0
  169. package/server/mcp/analysis.js +209 -0
  170. package/server/mcp/http.js +213 -0
  171. package/server/mcp/runner.js +278 -0
  172. package/server/mcp/server.js +241 -0
  173. package/server/mcp/standalone.js +42 -0
  174. package/server/mcp/tools.js +683 -0
  175. package/server/paths.js +39 -0
  176. package/server/router.js +218 -0
  177. package/server/searchIndex.js +118 -0
  178. package/server/sessions.js +163 -0
  179. package/server/static.js +59 -0
  180. package/server/tools/install.js +246 -0
  181. package/server/tools/parse.js +170 -0
  182. package/server/tools/platform.js +91 -0
  183. package/server/tools/registry.js +212 -0
  184. package/server/tools/scan.js +136 -0
  185. package/server/tools.js +212 -0
  186. package/server/tunnel.js +92 -0
  187. package/shared/analyzer/docs.js +135 -0
  188. package/shared/analyzer/explainLocal.js +163 -0
  189. package/shared/analyzer/graph.js +461 -0
  190. package/shared/analyzer/health.js +215 -0
  191. package/shared/analyzer/history.js +146 -0
  192. package/shared/analyzer/languages/csharp.js +39 -0
  193. package/shared/analyzer/languages/generic.js +131 -0
  194. package/shared/analyzer/languages/go.js +70 -0
  195. package/shared/analyzer/languages/index.js +43 -0
  196. package/shared/analyzer/languages/java.js +39 -0
  197. package/shared/analyzer/languages/javascript.js +240 -0
  198. package/shared/analyzer/languages/python.js +120 -0
  199. package/shared/analyzer/languages/rust.js +42 -0
  200. package/shared/analyzer/languages/typescript.js +138 -0
  201. package/shared/analyzer/licenses.js +151 -0
  202. package/shared/analyzer/metrics.js +120 -0
  203. package/shared/analyzer/pathUtil.js +69 -0
  204. package/shared/analyzer/patterns.js +272 -0
  205. package/shared/analyzer/scan.js +473 -0
  206. package/shared/analyzer/security.js +187 -0
  207. package/shared/analyzer/services.js +157 -0
  208. package/shared/analyzer/stack.js +173 -0
  209. package/shared/analyzer/tour.js +69 -0
  210. package/shared/analyzer/util.js +123 -0
  211. package/shared/analyzer/workflows.js +143 -0
  212. package/shared/diagram/aiFacts.js +145 -0
  213. package/shared/diagram/aiMermaid.js +59 -0
  214. package/shared/diagram/atlas.js +93 -0
  215. package/shared/diagram/mermaid.js +459 -0
  216. package/shared/search/query.js +518 -0
@@ -0,0 +1,156 @@
1
+ // Onboarder's server: where it lives on disk, and how it starts.
2
+ //
3
+ // The work is elsewhere. `router.js` holds the route table and the request
4
+ // gates; each endpoint has its own module beside it (`apiScan`, `apiFile`,
5
+ // `apiDocs`, `llmProxy`, `apiSettings`); `static.js` serves the page and the
6
+ // shared engine; `sessions.js` remembers which directory a scan came from.
7
+ // What is left here is the part that has to know about the filesystem it was
8
+ // installed into, and the difference between being imported and being run.
9
+ //
10
+ // Where it binds comes from the settings file (`server/config.js`): local mode
11
+ // is 127.0.0.1 and nothing else, self-hosted mode binds what it was given and
12
+ // turns on the access-key gate in the router. A bare `createServer()` — what
13
+ // the tests drive — never touches that file.
14
+
15
+ import http from 'node:http';
16
+ import path from 'node:path';
17
+ import { spawn } from 'node:child_process';
18
+ import { fileURLToPath } from 'node:url';
19
+
20
+ import { createRouter } from './router.js';
21
+ import { installExitCleanup } from './sessions.js';
22
+ import { createLogger } from './logger.js';
23
+ import { createMcpRunner } from './mcp/runner.js';
24
+ import { configPath, readSettings, serverUrls } from './config.js';
25
+ import { tunnelStatus } from './tunnel.js';
26
+
27
+ const logger = createLogger();
28
+
29
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
30
+ const PROJECT_ROOT = path.resolve(HERE, '..');
31
+
32
+ // The MCP runner is created here, not inside the router, and is put on the config
33
+ // object the router already passes to every handler. Two reasons: exactly one
34
+ // server can be supervised per process (a second button press must be a toggle,
35
+ // not a second child), and a test can supply its own runner in place of this one
36
+ // without the router knowing the difference.
37
+ const mcp = createMcpRunner({
38
+ onLog: (entry) => logger.info('mcp', entry.line),
39
+ });
40
+
41
+ export const CONFIG = {
42
+ projectRoot: PROJECT_ROOT,
43
+ publicDir: path.join(PROJECT_ROOT, 'public'),
44
+ // Served at `/shared/`, which is what lets the browser and the server import
45
+ // the same analyzer. See `static.js` for why that mapping constrains the code.
46
+ sharedDir: path.join(PROJECT_ROOT, 'shared'),
47
+ mcp,
48
+ };
49
+
50
+ // The tests want a server they can put on an ephemeral port; `npm start` wants
51
+ // one from the settings file. Same router either way.
52
+ export function createServer(config = CONFIG) {
53
+ const router = createRouter(config);
54
+ return http.createServer((req, res) => {
55
+ const start = Date.now();
56
+ res.on('finish', () => {
57
+ logger.http({ method: req.method, path: req.url, status: res.statusCode, ms: Date.now() - start });
58
+ });
59
+ router(req, res);
60
+ });
61
+ }
62
+
63
+ // What the terminal shows once the socket is listening. A pure string builder
64
+ // so the CLI prints exactly this too, and so a test can read it.
65
+ export function startupBanner(settings, { configFile } = {}) {
66
+ const urls = serverUrls(settings);
67
+ const lines = ['', ' Onboarder is up.'];
68
+ lines.push(settings.mode === 'self-hosted'
69
+ ? ' Mode self-hosted — the network can reach this; every API call needs the access key'
70
+ : ' Mode local — only this machine can reach it');
71
+ lines.push(' Local ' + urls.local);
72
+ if (urls.network) lines.push(' Network ' + urls.network);
73
+ if (urls.domain) lines.push(' Domain ' + urls.domain);
74
+ if (settings.mode === 'self-hosted' && !settings.accessKey) {
75
+ lines.push(' WARNING self-hosted with no access key — every API call is refused until one is set.');
76
+ lines.push(' Run `onboarder setup` or `onboarder config key rotate`.');
77
+ }
78
+ const tunnels = tunnelStatus(settings);
79
+ for (const name of ['cloudflare', 'tailscale']) {
80
+ const t = tunnels[name];
81
+ if (!t.enabled) continue;
82
+ lines.push(t.installed
83
+ ? ` Tunnel ${name}: ${t.command}`
84
+ : ` Tunnel ${name} is enabled but its CLI is not installed — ${t.install}`);
85
+ }
86
+ if (configFile) lines.push(' Config ' + configFile);
87
+ lines.push('');
88
+ return lines.join('\n');
89
+ }
90
+
91
+ // Ask the OS to open the app. Best-effort and detached: a missing opener on a
92
+ // headless box is not a reason the server failed to start.
93
+ export function openInBrowser(url) {
94
+ const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open';
95
+ const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
96
+ try {
97
+ const child = spawn(cmd, args, { detached: true, stdio: 'ignore' });
98
+ child.on('error', () => {});
99
+ child.unref();
100
+ } catch {
101
+ /* no opener — the URL is on the screen already */
102
+ }
103
+ }
104
+
105
+ // The real boot: read the settings, bind what they say, print the banner.
106
+ // Both `node server/index.js` and `onboarder start` land here.
107
+ export async function startServer({ configFile = configPath(), openBrowser, log = console.log } = {}) {
108
+ const settings = await readSettings(configFile);
109
+ // Environment wins over the file: PORT=8080 npm start has worked since
110
+ // before settings existed, and a flag you can see beats a file you cannot.
111
+ const host = process.env.HOST || settings.host;
112
+ const port = Number(process.env.PORT) || settings.port;
113
+ const live = { ...settings, host, port };
114
+
115
+ const server = createServer({
116
+ ...CONFIG,
117
+ configPath: configFile,
118
+ getSettings: () => readSettings(configFile),
119
+ boot: { host, port },
120
+ });
121
+
122
+ installExitCleanup();
123
+ // The MCP child has to go with the web server. `installExitCleanup` already
124
+ // handles cloned scan directories on SIGINT/SIGTERM; the child is registered
125
+ // on the same process events so a Ctrl-C in the terminal does not leave an
126
+ // orphan holding the port-less stdio pipe open.
127
+ for (const signal of ['SIGINT', 'SIGTERM']) {
128
+ process.on(signal, () => {
129
+ if (mcp.running) mcp.stop().catch(() => {});
130
+ });
131
+ }
132
+
133
+ await new Promise((resolve, reject) => {
134
+ server.once('error', reject);
135
+ server.listen(port, host, resolve);
136
+ });
137
+
138
+ log(startupBanner(live, { configFile }));
139
+
140
+ const shouldOpen = openBrowser ?? (live.autoOpen && process.stdout.isTTY && !process.env.NO_OPEN);
141
+ // Remote visitors still need the key; what opens locally is the loopback URL,
142
+ // which in local mode needs nothing and in self-hosted mode asks for the key.
143
+ if (shouldOpen) openInBrowser(serverUrls(live).local);
144
+ return { server, settings: live, host, port };
145
+ }
146
+
147
+ // `node server/index.js` listens. Importing this module — which the tests do, to
148
+ // drive the real router — does not, and does not install signal handlers either:
149
+ // a test runner should keep its own Ctrl-C.
150
+ const THIS_FILE = fileURLToPath(import.meta.url);
151
+ if (process.argv[1] && path.resolve(process.argv[1]) === THIS_FILE) {
152
+ startServer().catch((err) => {
153
+ console.error(' Onboarder did not start: ' + (err.message || err));
154
+ process.exitCode = 1;
155
+ });
156
+ }
@@ -0,0 +1,162 @@
1
+ // Forwards chat-completion requests to any OpenAI-compatible endpoint and
2
+ // streams the answer straight back. The API key passes through in memory
3
+ // only — it is never written anywhere, and never appears in logs.
4
+
5
+ import { sendError, sendJSON } from './http.js';
6
+
7
+ const UPSTREAM_TIMEOUT_MS = 60_000;
8
+ const MAX_MESSAGES_BYTES = 120_000;
9
+
10
+ const EXTRA_ALLOWED = new Set(['reasoning', 'temperature', 'top_p', 'presence_penalty', 'frequency_penalty']);
11
+
12
+ export function detectProvider(apiKey, baseUrl) {
13
+ if (apiKey?.startsWith('sk-ant-')) return 'anthropic';
14
+ if (apiKey?.startsWith('AIza')) return 'gemini';
15
+ return 'openai';
16
+ }
17
+
18
+ // Extracts the provider's request id from an upstream response. Each
19
+ // provider spells it differently; the order in this list is "what we have
20
+ // seen", not "what is correct" — a missing id is fine and collapses to
21
+ // null. The function is pure and exported for tests; the `headers` argument
22
+ // is anything with a `.get(name)` method, which both `Response.headers` and
23
+ // a hand-rolled plain object can satisfy in the test suite.
24
+ export function extractRequestId(headers) {
25
+ if (!headers || typeof headers.get !== 'function') return null;
26
+ return headers.get('x-request-id')
27
+ || headers.get('request-id')
28
+ || headers.get('x-amzn-requestid')
29
+ || headers.get('x-goog-request-id')
30
+ || null;
31
+ }
32
+
33
+ export async function proxyChat(res, body) {
34
+ const { baseUrl, apiKey, model, messages } = body || {};
35
+ const stream = body.stream !== false;
36
+ const maxTokens = Math.min(Number(body.max_tokens) || 700, 4096);
37
+
38
+ if (!model || typeof model !== 'string') {
39
+ return sendError(res, 400, 'A model name is needed.');
40
+ }
41
+ if (!Array.isArray(messages) || !messages.length) {
42
+ return sendError(res, 400, 'No messages to send.');
43
+ }
44
+ if (JSON.stringify(messages).length > MAX_MESSAGES_BYTES) {
45
+ return sendError(res, 413, 'That prompt is too large. Try a smaller file.');
46
+ }
47
+
48
+ const provider = detectProvider(apiKey, baseUrl);
49
+
50
+ let url;
51
+ let headers = { 'content-type': 'application/json' };
52
+ let payload = { stream };
53
+ const isAzure = /\.openai\.azure\.com|\.cognitiveservices\.azure\.com/i.test(baseUrl || '');
54
+
55
+ if (provider === 'anthropic') {
56
+ url = 'https://api.anthropic.com/v1/messages';
57
+ headers['x-api-key'] = apiKey;
58
+ headers['anthropic-version'] = '2023-06-01';
59
+
60
+ let system = '';
61
+ const anthropicMessages = [];
62
+ for (const m of messages) {
63
+ if (m.role === 'system') system += m.content + '\n';
64
+ else anthropicMessages.push({ role: m.role === 'user' ? 'user' : 'assistant', content: m.content });
65
+ }
66
+ payload = {
67
+ model,
68
+ max_tokens: maxTokens,
69
+ messages: anthropicMessages,
70
+ system: system.trim() || undefined,
71
+ stream
72
+ };
73
+ } else if (provider === 'gemini') {
74
+ const streamSuffix = stream ? 'streamGenerateContent?alt=sse' : 'generateContent';
75
+ url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:${streamSuffix}&key=${apiKey}`;
76
+
77
+ let system_instruction = null;
78
+ const contents = [];
79
+ for (const m of messages) {
80
+ if (m.role === 'system') {
81
+ system_instruction = { parts: [{ text: m.content }] };
82
+ } else {
83
+ contents.push({ role: m.role === 'assistant' ? 'model' : 'user', parts: [{ text: m.content }] });
84
+ }
85
+ }
86
+ payload = {
87
+ contents,
88
+ system_instruction,
89
+ generationConfig: { maxOutputTokens: maxTokens }
90
+ };
91
+ } else {
92
+ // OpenAI default
93
+ if (!baseUrl || !/^https?:\/\/\S+$/.test(baseUrl)) {
94
+ return sendError(res, 400, 'A valid base URL is needed — something like https://api.openai.com/v1.');
95
+ }
96
+ url = baseUrl.replace(/\/+$/, '') + '/chat/completions';
97
+ if (apiKey) {
98
+ if (isAzure) headers['api-key'] = apiKey;
99
+ else headers.authorization = 'Bearer ' + apiKey;
100
+ }
101
+ payload = { model, messages, stream };
102
+ if (isAzure) payload.max_completion_tokens = maxTokens;
103
+ else payload.max_tokens = maxTokens;
104
+ for (const key of Object.keys(body)) {
105
+ if (EXTRA_ALLOWED.has(key)) payload[key] = body[key];
106
+ }
107
+ }
108
+
109
+ let upstream;
110
+ try {
111
+ upstream = await fetch(url, {
112
+ method: 'POST',
113
+ headers,
114
+ body: JSON.stringify(payload),
115
+ signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),
116
+ });
117
+ } catch (err) {
118
+ const why = err.name === 'TimeoutError' ? 'The endpoint took too long to answer.' : 'Could not reach ' + url + '.';
119
+ return sendError(res, 502, why);
120
+ }
121
+
122
+ if (!upstream.ok) {
123
+ const text = await upstream.text().catch(() => '');
124
+ let detail = text.slice(0, 400);
125
+ try {
126
+ const parsed = JSON.parse(text);
127
+ detail = parsed.error?.message || detail;
128
+ } catch {}
129
+ // The provider's request id, when it sends one. Most providers do —
130
+ // OpenAI, Anthropic, Google all set one of these on every response —
131
+ // and "ask the user to paste this" is far more useful than "ask the
132
+ // user to describe what they did". `extractRequestId` is the one place
133
+ // that knows the spelling; the response body only carries the field
134
+ // when there is one to carry, so missing ids stay out of the payload.
135
+ const requestId = extractRequestId(upstream.headers);
136
+ const body = {
137
+ error: `The provider answered ${upstream.status}.`,
138
+ detail,
139
+ };
140
+ if (requestId) body.requestId = requestId;
141
+ return sendJSON(res, upstream.status, body);
142
+ }
143
+
144
+ if (!stream) {
145
+ const json = await upstream.json().catch(() => null);
146
+ return sendJSON(res, 200, json || {});
147
+ }
148
+
149
+ res.writeHead(200, {
150
+ 'content-type': 'text/event-stream',
151
+ 'cache-control': 'no-cache',
152
+ connection: 'keep-alive',
153
+ });
154
+ res.on('close', () => upstream.body?.cancel().catch(() => {}));
155
+
156
+ try {
157
+ for await (const chunk of upstream.body) {
158
+ if (!res.write(chunk)) await new Promise((r) => res.once('drain', r));
159
+ }
160
+ } catch {}
161
+ res.end();
162
+ }
@@ -0,0 +1,29 @@
1
+ const levels = { debug: 0, info: 1, warn: 2, error: 3 };
2
+
3
+ export function createLogger(level = process.env.LOG_LEVEL || 'info') {
4
+ const minLevel = levels[level] ?? levels.info;
5
+
6
+ function log(lvl, msg, extra = {}) {
7
+ if (levels[lvl] < minLevel) return;
8
+ const entry = {
9
+ ts: new Date().toISOString(),
10
+ level: lvl,
11
+ msg,
12
+ ...extra
13
+ };
14
+ const out = JSON.stringify(entry) + '\\n';
15
+ if (lvl === 'warn' || lvl === 'error') {
16
+ process.stderr.write(out);
17
+ } else {
18
+ process.stdout.write(out);
19
+ }
20
+ }
21
+
22
+ return {
23
+ debug: (msg, extra) => log('debug', msg, extra),
24
+ info: (msg, extra) => log('info', msg, extra),
25
+ warn: (msg, extra) => log('warn', msg, extra),
26
+ error: (msg, extra) => log('error', msg, extra),
27
+ http: (req) => log('info', 'HTTP Request', req)
28
+ };
29
+ }
@@ -0,0 +1,209 @@
1
+ // The analysis every MCP tool reads from, computed once per repository.
2
+ //
3
+ // The HTTP app already knows this shape: `apiScan.js` returns `scan`, `facts` and
4
+ // `manifest`, and each view derives the rest on demand. An MCP client has no
5
+ // session to hold a scan id in and no view to trigger those derivations, so this
6
+ // module does the whole derivation up front, once, and hands the result to every
7
+ // tool. The tools are then pure projections — a tool cannot disagree with another
8
+ // about what the repository looks like, because there is only one answer cached.
9
+ //
10
+ // It reuses the same `shared/` analyzers the browser runs, rather than a second
11
+ // implementation: a number in an MCP response is the same number the UI shows.
12
+
13
+ import { promises as fs } from 'node:fs';
14
+ import path from 'node:path';
15
+
16
+ import { scanRepo } from '../../shared/analyzer/scan.js';
17
+ import { computeFacts, scanIndex } from '../../shared/analyzer/graph.js';
18
+ import { detectManifest } from '../../shared/analyzer/services.js';
19
+ import { analyzeStack } from '../../shared/analyzer/stack.js';
20
+ import { computeLayers, detectPatterns, couplingMatrix } from '../../shared/analyzer/patterns.js';
21
+ import { analyzeHealth } from '../../shared/analyzer/health.js';
22
+ import { summarizeSecurity } from '../../shared/analyzer/security.js';
23
+ import { explainOverview, explainFile, explainFolder, scanCaveats } from '../../shared/analyzer/explainLocal.js';
24
+ import { docFileRow, fileFactsLine, fileStaticDoc, folderStaticDoc } from '../../shared/analyzer/docs.js';
25
+ import { baseName, dirOf } from '../../shared/analyzer/pathUtil.js';
26
+ import { buildSearchIndex } from '../searchIndex.js';
27
+ import { searchDocuments } from '../apiSearch.js';
28
+ import { nodeFileSource } from '../fileSourceNode.js';
29
+ import { gitLog, parseGitLog } from '../gitHistory.js';
30
+ import { analyzeHistory, unavailableHistory } from '../../shared/analyzer/history.js';
31
+ import { expandHome, resolveInside } from '../paths.js';
32
+
33
+ // How many repositories stay warm. One is the common case — a client points at a
34
+ // repo and works in it — but an agent comparing two projects should not pay for a
35
+ // rescan when it alternates between them. Each entry holds a parsed file list, so
36
+ // the cap is about memory, not count.
37
+ const MAX_CACHED = 4;
38
+
39
+ const cache = new Map();
40
+
41
+ // Root path -> the finished analysis. Resolving the path first means `/Users/x/~
42
+ // repo` and `/Users/x/repo` are one entry, not two that evict each other.
43
+ export function cachedAnalysis(root) {
44
+ return cache.get(path.resolve(root)) || null;
45
+ }
46
+
47
+ export function cachedRoots() {
48
+ return [...cache.keys()];
49
+ }
50
+
51
+ export function clearAnalysisCache() {
52
+ cache.clear();
53
+ }
54
+
55
+ // A tool error is a message the agent can act on, not a stack trace. The protocol
56
+ // layer turns these into `isError` results so the model sees the sentence and can
57
+ // correct itself, rather than the call failing opaquely.
58
+ export class ToolError extends Error {
59
+ constructor(message) {
60
+ super(message);
61
+ this.name = 'ToolError';
62
+ }
63
+ }
64
+
65
+ function buildPatterns(scan, facts, manifest) {
66
+ const layersInfo = computeLayers(scan, facts);
67
+ return {
68
+ layersInfo,
69
+ findings: detectPatterns(scan, facts, manifest, layersInfo),
70
+ coupling: couplingMatrix(scan, facts),
71
+ };
72
+ }
73
+
74
+ async function collectHistory(root, scan) {
75
+ try {
76
+ const log = await gitLog(root);
77
+ if (!log.ok) return unavailableHistory(log.reason);
78
+ return analyzeHistory(scan, parseGitLog(log.text), { totalCommits: log.totalCommits });
79
+ } catch {
80
+ return unavailableHistory('The history could not be read — the scan itself is unaffected.');
81
+ }
82
+ }
83
+
84
+ // The entry point. `force` is how `onboarder_rescan` differs from every other
85
+ // tool: the same call twice returns the same answer until it is asked not to.
86
+ export async function analyzeRepository(rootInput, { force = false } = {}) {
87
+ const trimmed = String(rootInput || '').trim();
88
+ if (!trimmed) throw new ToolError('A repository path is required.');
89
+
90
+ const root = expandHome(trimmed);
91
+ if (!force) {
92
+ const hit = cache.get(root);
93
+ if (hit) {
94
+ // Re-insert so the eviction below drops a genuinely cold repo rather than
95
+ // whichever one happened to be scanned first.
96
+ cache.delete(root);
97
+ cache.set(root, hit);
98
+ return hit;
99
+ }
100
+ }
101
+
102
+ const stat = await fs.stat(root).catch(() => null);
103
+ if (!stat?.isDirectory()) {
104
+ throw new ToolError(`No folder at ${root}. Check the path and try again.`);
105
+ }
106
+
107
+ const source = nodeFileSource(root);
108
+ const scan = await scanRepo(source);
109
+ const manifest = await detectManifest(source);
110
+ const facts = computeFacts(scan, manifest);
111
+
112
+ // Built here rather than on first search for the same reason the HTTP route
113
+ // builds it at scan time: a search that reads every file is a search whose cost
114
+ // depends on what was asked, and an agent asks repeatedly.
115
+ const searchIndexData = await buildSearchIndex(source, scan.files.map((f) => f.path));
116
+
117
+ const analysis = {
118
+ root,
119
+ scannedAt: Date.now(),
120
+ scan,
121
+ facts,
122
+ manifest,
123
+ stack: analyzeStack(manifest, scan.stats.languages),
124
+ patterns: buildPatterns(scan, facts, manifest),
125
+ health: analyzeHealth(scan, facts),
126
+ security: summarizeSecurity(scan),
127
+ history: await collectHistory(root, scan),
128
+ searchIndex: searchIndexData,
129
+ // The prose the overview panel shows, computed here so an agent gets the same
130
+ // onboarding summary a person reads rather than a pile of raw arrays.
131
+ overview: explainOverview(scan, facts, manifest),
132
+ caveats: scanCaveats(scan),
133
+ };
134
+
135
+ cache.set(root, analysis);
136
+ // Map preserves insertion order, so the first key is the least recently used.
137
+ while (cache.size > MAX_CACHED) cache.delete(cache.keys().next().value);
138
+ return analysis;
139
+ }
140
+
141
+
142
+ // Files anywhere under a folder, parsed or not. `scanIndex().filesIn` is an exact
143
+ // directory lookup over the *parsed* files, which is the right thing for a diagram
144
+ // of one directory and the wrong thing for "what is in this folder" — an agent
145
+ // asking about `public` wants the stylesheet and the icons too.
146
+ export function filesUnder(analysis, folder) {
147
+ const prefix = folder ? String(folder).replace(/\/+$/, '') + '/' : '';
148
+ return analysis.scan.allFiles.filter((p) => p.startsWith(prefix));
149
+ }
150
+
151
+ // The files under a folder that the parser understood, as file objects. The same
152
+ // containment rule, so the two cannot disagree about what a folder holds.
153
+ export function parsedFilesUnder(analysis, folder) {
154
+ const paths = new Set(filesUnder(analysis, folder));
155
+ return analysis.scan.files.filter((f) => paths.has(f.path));
156
+ }
157
+
158
+ // Resolves a repository-relative path to an absolute one, refusing anything that
159
+ // lands outside the root. This is the same check `/api/file` makes, and it is the
160
+ // only thing standing between an agent and the rest of the disk: an MCP client is
161
+ // another program on this machine, not a browser tab, so the cross-origin guards
162
+ // do not apply here. Containment is the boundary, not the scan.
163
+ export function resolveIn(analysis, relPath) {
164
+ if (typeof relPath !== 'string' || !relPath.trim()) {
165
+ throw new ToolError('A file path is required.');
166
+ }
167
+ const abs = resolveInside(analysis.root, relPath.trim());
168
+ if (!abs) throw new ToolError(`${relPath} is outside ${analysis.root}.`);
169
+ return { abs, rel: path.relative(analysis.root, abs).split(path.sep).join('/') };
170
+ }
171
+
172
+ // The same, but also insists the parser looked at the file. The analysis tools
173
+ // (`explain_file`, `explain_folder`) need a parsed file because every field they
174
+ // return came from parsing it; saying "package.json is not a file in the scanned
175
+ // repository" for a JSON manifest would be technically true and practically
176
+ // useless, so those tools check this and `read_file` does not.
177
+ export function fileIn(analysis, relPath) {
178
+ const { abs, rel } = resolveIn(analysis, relPath);
179
+ const file = scanIndex(analysis.scan).fileAt(rel);
180
+ if (!file) {
181
+ throw new ToolError(`${rel} is not a source file in the scan. Use onboarder_read_file to read it as text.`);
182
+ }
183
+ return { abs, rel, file };
184
+ }
185
+
186
+ export async function readFileText(analysis, relPath) {
187
+ const { abs, rel } = resolveIn(analysis, relPath);
188
+ const stat = await fs.stat(abs).catch(() => null);
189
+ if (!stat?.isFile()) throw new ToolError(`${rel} could not be read.`);
190
+ // Matches the HTTP route's ceiling. A tool that returns a 200 MB file has not
191
+ // answered the question, it has exhausted the context window.
192
+ if (stat.size > 200 * 1024) {
193
+ throw new ToolError(`${rel} is ${Math.round(stat.size / 1024)} KB — too large to return whole.`);
194
+ }
195
+ return { path: rel, text: await fs.readFile(abs, 'utf8') };
196
+ }
197
+
198
+ // Re-exported so tools build their payloads from the same helpers the views do,
199
+ // rather than re-deriving "which files are in this folder" a third time.
200
+ //
201
+ // `importsOf`/`importers` are the plain-object maps `computeFacts` returns, not
202
+ // methods on the scan index — the index only answers questions about files.
203
+ export {
204
+ scanIndex, dirOf, baseName,
205
+ explainFile, explainFolder,
206
+ docFileRow, fileFactsLine, fileStaticDoc, folderStaticDoc,
207
+ searchDocuments,
208
+ };
209
+