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,253 @@
1
+ // POST /api/search — runs a TF-IDF query against the index built at scan
2
+ // time. The work that used to live here (re-listing the tree, re-reading
3
+ // every file, parsing its tokens) now lives in `searchIndex.js`, which
4
+ // `apiScan.js` invokes once when the scan finishes. What is left in this
5
+ // module is the request shape: validate, look up the session, score, send.
6
+ //
7
+ // The pure scoring functions (`buildTfIdfIndex` for one-off in-memory
8
+ // indexes used by tests, and `searchIndex` for the score-and-snippet pass)
9
+ // are still exported so the front end and tests can construct indexes
10
+ // directly when they need to.
11
+ //
12
+ // There are two ways through, and which one a query takes is the point:
13
+ // `searchIndex` answers a query of bare words the forgiving way it always
14
+ // did — partial matches, ranked by TF-IDF — while `searchDocuments` parses
15
+ // the shared query language (`shared/search/query.js`, the same module the
16
+ // palette uses) and enforces anything the person actually decided: filters,
17
+ // phrases, regexes, the case toggle. A half-remembered name should still find
18
+ // the file; `ext:js -vendor` should not be negotiated with.
19
+
20
+ import { getSession } from './sessions.js';
21
+ import { sendError, sendJSON } from './http.js';
22
+ import {
23
+ isAdvanced, lineOfIndex, matchesPathFilters, parseQuery, queryIsEmpty,
24
+ scoreContent, scorePath, snippetAt,
25
+ } from '../shared/search/query.js';
26
+
27
+ export function buildTfIdfIndex(documents) {
28
+ // documents: [{ path, content }]
29
+ const index = new Map();
30
+ const docCounts = new Map();
31
+ let totalDocs = 0;
32
+
33
+ for (const doc of documents) {
34
+ if (!doc.content || typeof doc.content !== 'string') continue;
35
+ const tokens = doc.content.toLowerCase().split(/\W+/).filter((t) => t.length > 1);
36
+ if (!tokens.length) continue;
37
+ totalDocs++;
38
+
39
+ const tf = new Map();
40
+ for (const t of tokens) {
41
+ tf.set(t, (tf.get(t) || 0) + 1);
42
+ }
43
+ for (const t of tf.keys()) {
44
+ docCounts.set(t, (docCounts.get(t) || 0) + 1);
45
+ }
46
+ index.set(doc.path, { tf, content: doc.content, tokenCount: tokens.length });
47
+ }
48
+
49
+ return { index, docCounts, totalDocs };
50
+ }
51
+
52
+ export function searchIndex(indexData, queryString, limit = 10) {
53
+ if (!indexData || !queryString) return [];
54
+ const terms = queryString.toLowerCase().split(/\s+/).filter((t) => t.length > 0);
55
+ if (!terms.length) return [];
56
+
57
+ const { index, docCounts, totalDocs } = indexData;
58
+ if (!totalDocs) return [];
59
+
60
+ const idf = new Map();
61
+ for (const t of terms) {
62
+ const df = docCounts.get(t) || 0;
63
+ idf.set(t, df === 0 ? 0 : Math.log(1 + totalDocs / df));
64
+ }
65
+
66
+ const results = [];
67
+ for (const [docPath, data] of index.entries()) {
68
+ let score = 0;
69
+ let matchCount = 0;
70
+ for (const t of terms) {
71
+ const tf = data.tf.get(t) || 0;
72
+ if (tf > 0) {
73
+ matchCount++;
74
+ // Normalized TF * IDF
75
+ score += (tf / Math.sqrt(data.tokenCount)) * (idf.get(t) || 1);
76
+ }
77
+ }
78
+
79
+ if (score > 0) {
80
+ // Find best snippet
81
+ const lowerContent = data.content.toLowerCase();
82
+ let bestIdx = -1;
83
+ for (const t of terms) {
84
+ const idx = lowerContent.indexOf(t);
85
+ if (idx !== -1 && (bestIdx === -1 || idx < bestIdx)) {
86
+ bestIdx = idx;
87
+ }
88
+ }
89
+
90
+ let snippet = '';
91
+ let line = 1;
92
+ if (bestIdx !== -1) {
93
+ const linesBefore = data.content.slice(0, bestIdx).split('\n');
94
+ line = linesBefore.length;
95
+ const allLines = data.content.split('\n');
96
+ const targetLine = (allLines[line - 1] || '').trim();
97
+ snippet = targetLine.length > 120 ? targetLine.slice(0, 117) + '…' : targetLine;
98
+ }
99
+
100
+ results.push({
101
+ path: docPath,
102
+ score: score * (matchCount / terms.length), // Boost docs containing all terms
103
+ snippet,
104
+ line,
105
+ });
106
+ }
107
+ }
108
+
109
+ results.sort((a, b) => b.score - a.score);
110
+ return results.slice(0, limit);
111
+ }
112
+
113
+ // The strict pass: filters are gates, not hints, and every term has to land.
114
+ // Ranking is positional — `scorePath` and `scoreContent` reward the file whose
115
+ // name matches and the file where the word appears early — because a query with
116
+ // a regex or a filter in it is a person who knows what they want, and does not
117
+ // need the corpus statistics of `searchIndex` second-guessing them.
118
+ function strictSearch(indexData, parsed) {
119
+ // `kind:` is a statement about symbols, and content cannot answer it. A query
120
+ // that is *only* a kind filter is answered by the palette's symbol pass and
121
+ // by nothing here, which is the honest result rather than "every file".
122
+ const kindOnly = parsed.kinds.length > 0
123
+ && !parsed.terms.length && !parsed.phrases.length && !parsed.regex;
124
+
125
+ const results = [];
126
+ for (const [docPath, doc] of indexData.index.entries()) {
127
+ if (kindOnly) break;
128
+ if (!matchesPathFilters(parsed, docPath)) continue;
129
+
130
+ const pathScore = scorePath(parsed, docPath);
131
+ const content = scoreContent(parsed, doc.content);
132
+ if (!pathScore && !content.score) continue;
133
+
134
+ const at = content.index;
135
+ results.push({
136
+ path: docPath,
137
+ score: pathScore + content.score,
138
+ line: at >= 0 ? lineOfIndex(doc.content, at) : 1,
139
+ snippet: snippetAt(doc.content, at >= 0 ? at : 0),
140
+ });
141
+ }
142
+
143
+ results.sort((a, b) => b.score - a.score || a.path.localeCompare(b.path));
144
+ return results;
145
+ }
146
+
147
+ // What the index could not read, in the shape the palette can say out loud —
148
+ // "searched 127 files, 3 skipped" beats a mystery file that never turns up.
149
+ function indexStats(indexData) {
150
+ const skipped = indexData && indexData.skipped;
151
+ if (!skipped) return null;
152
+ return { bytes: indexData.totalBytes ?? 0, skipped: { ...skipped }, cap: indexData.cap ?? null };
153
+ }
154
+
155
+ // The serializable half of a parsed query. `parsed.regex` is a RegExp and would
156
+ // go over the wire as `{}`, so the pattern travels as the text it was typed as.
157
+ function querySummary(parsed) {
158
+ return {
159
+ terms: parsed.terms,
160
+ phrases: parsed.phrases,
161
+ negatives: parsed.negatives,
162
+ exts: parsed.exts,
163
+ paths: parsed.paths,
164
+ kinds: parsed.kinds,
165
+ isTest: parsed.isTest,
166
+ isSource: parsed.isSource,
167
+ regex: parsed.regexSource ? `/${parsed.regexSource}/${parsed.regexFlags}` : null,
168
+ caseSensitive: parsed.caseSensitive,
169
+ advanced: isAdvanced(parsed),
170
+ };
171
+ }
172
+
173
+ // The one entry point the route uses, kept separate from `searchIndex` so the
174
+ // forgiving ranking that shipped first survives intact: a query of bare words
175
+ // is still answered by that function, partial matches and all. Only a query the
176
+ // person made decisions in — a filter, a phrase, a regex, the case toggle —
177
+ // goes down the strict path, where those decisions are enforced.
178
+ export function searchDocuments(indexData, rawQuery, options = {}) {
179
+ const limit = Number.isFinite(options.limit) ? Math.max(1, Math.floor(options.limit)) : 15;
180
+ const parsed = parseQuery(rawQuery, { caseSensitive: !!options.caseSensitive });
181
+ const advanced = isAdvanced(parsed);
182
+ const base = {
183
+ results: [],
184
+ total: 0,
185
+ indexed: indexData?.totalDocs ?? 0,
186
+ capped: false,
187
+ advanced,
188
+ query: querySummary(parsed),
189
+ error: parsed.error,
190
+ stats: indexStats(indexData),
191
+ };
192
+
193
+ if (!indexData || !indexData.totalDocs) return base;
194
+ if (queryIsEmpty(parsed)) return base;
195
+ // A pattern that would not compile is the whole answer. Reporting "no
196
+ // results" instead would send someone hunting for a file sitting right there.
197
+ if (parsed.error) return base;
198
+
199
+ if (!advanced) {
200
+ const ranked = searchIndex(indexData, parsed.terms.join(' '), limit);
201
+ return { ...base, results: ranked, total: ranked.length, capped: ranked.length >= limit };
202
+ }
203
+
204
+ const all = strictSearch(indexData, parsed);
205
+ return {
206
+ ...base,
207
+ results: all.slice(0, limit),
208
+ total: all.length,
209
+ capped: all.length > limit,
210
+ };
211
+ }
212
+
213
+ export async function handleSearch(res, body) {
214
+ const { scanId, query, limit = 15, caseSensitive = false } = body || {};
215
+ if (!scanId) return sendError(res, 400, 'Missing scanId.');
216
+ const session = getSession(scanId);
217
+ if (!session) return sendError(res, 404, 'Scan not found.');
218
+
219
+ const q = (query || '').trim();
220
+ if (!q) {
221
+ return sendJSON(res, 200, { results: [], total: 0, indexed: session.searchIndex?.totalDocs ?? 0 });
222
+ }
223
+
224
+ // No index means the scan was opened before this change shipped, or the
225
+ // caller is using a session that was never given a scan-time index. The
226
+ // honest answer is "no results" rather than rebuilding the whole thing on
227
+ // the request thread.
228
+ if (!session.searchIndex || session.searchIndex.totalDocs === 0) {
229
+ return sendJSON(res, 200, {
230
+ results: [],
231
+ total: 0,
232
+ indexed: 0,
233
+ note: 'Nothing was indexed for this scan.',
234
+ });
235
+ }
236
+
237
+ try {
238
+ const out = searchDocuments(session.searchIndex, q, { limit, caseSensitive });
239
+ return sendJSON(res, 200, {
240
+ results: out.results,
241
+ total: out.total,
242
+ indexed: out.indexed,
243
+ capped: out.capped,
244
+ advanced: out.advanced,
245
+ query: out.query,
246
+ error: out.error || null,
247
+ stats: out.stats || null,
248
+ });
249
+ } catch (err) {
250
+ return sendError(res, 500, 'Search failed: ' + err.message);
251
+ }
252
+ }
253
+
@@ -0,0 +1,118 @@
1
+ // The settings endpoints: read the public shape, apply a patch, rotate the key.
2
+ //
3
+ // All three go through `server/config.js` for validation and the atomic 0600
4
+ // write, so the CLI wizard, `onboarder config set`, and this API can never save
5
+ // three dialects of the same file. What lives here is only what HTTP adds:
6
+ // status codes, the secret-handling rules (never read the key back out, show a
7
+ // new one exactly once), and the restart hint — the running server bound its
8
+ // socket at boot, so a host/port change is real but not yet in effect.
9
+
10
+ import {
11
+ DEFAULT_SETTINGS, generateAccessKey, normalizeSettings, publicSettings,
12
+ readSettings, updateSettings, writeSettings, configPath,
13
+ } from './config.js';
14
+ import { sendError, sendJSON } from './http.js';
15
+ import { tunnelStatus } from './tunnel.js';
16
+
17
+ // Where this request's settings live. The router's config object carries the
18
+ // path when the server was booted with one; tests and a bare `createServer()`
19
+ // get the defaults-in-memory view instead of somebody's home directory.
20
+ function settingsFile(config) {
21
+ return config?.configPath || null;
22
+ }
23
+
24
+ async function currentSettings(config) {
25
+ if (typeof config?.getSettings === 'function') return config.getSettings();
26
+ const file = settingsFile(config);
27
+ if (file) return readSettings(file);
28
+ return { ...DEFAULT_SETTINGS, account: { ...DEFAULT_SETTINGS.account }, tunnel: { ...DEFAULT_SETTINGS.tunnel } };
29
+ }
30
+
31
+ // The public body every read returns: settings with the secret masked, plus
32
+ // the connection picture (URLs, tunnel status) the drawer and CLI both render.
33
+ async function publicBody(config) {
34
+ const settings = await currentSettings(config);
35
+ return {
36
+ settings: publicSettings(settings),
37
+ configFile: settingsFile(config) || configPath(),
38
+ node: process.version,
39
+ tunnels: tunnelStatus(settings),
40
+ };
41
+ }
42
+
43
+ export async function handleGetSettings(req, res, config) {
44
+ try {
45
+ sendJSON(res, 200, await publicBody(config));
46
+ } catch (err) {
47
+ sendError(res, 500, err.message || 'The settings could not be read.');
48
+ }
49
+ }
50
+
51
+ // Which changes the running server cannot absorb. Auth, domain, and tunnel
52
+ // flags are read live from disk on each request; the bind is a socket that
53
+ // already exists.
54
+ function restartRequired(config, next) {
55
+ const boot = config?.boot;
56
+ if (!boot) return [];
57
+ const changed = [];
58
+ if (next.host !== boot.host) changed.push('host');
59
+ if (next.port !== boot.port) changed.push('port');
60
+ return changed;
61
+ }
62
+
63
+ export async function handleUpdateSettings(res, body, config) {
64
+ const file = settingsFile(config);
65
+ if (!file) {
66
+ return sendError(res, 400, 'This server was started without a config file, so there is nothing to save to.');
67
+ }
68
+ if (!body || typeof body !== 'object' || Array.isArray(body)) {
69
+ return sendError(res, 400, 'Send a JSON object of settings to change.');
70
+ }
71
+ // Only known keys may be patched — a typo like "ports" must be a loud 400,
72
+ // not a silently ignored write that the UI then claims it saved.
73
+ const allowed = new Set(['mode', 'host', 'port', 'domain', 'autoOpen', 'account', 'tunnel']);
74
+ const unknown = Object.keys(body).filter((k) => k !== 'accessKey' && !allowed.has(k));
75
+ if (unknown.length) {
76
+ return sendError(res, 400, `Unknown setting${unknown.length === 1 ? '' : 's'}: ${unknown.join(', ')}.`);
77
+ }
78
+ if ('accessKey' in body) {
79
+ return sendError(res, 400, 'The access key is rotated, not patched — POST /api/settings/access-key.');
80
+ }
81
+ try {
82
+ const next = await updateSettings(body, file);
83
+ sendJSON(res, 200, {
84
+ settings: publicSettings(next),
85
+ restartRequired: restartRequired(config, next),
86
+ });
87
+ } catch (err) {
88
+ sendError(res, 400, err.message || 'Those settings did not validate.');
89
+ }
90
+ }
91
+
92
+ // Rotation returns the new key in the clear, once. It is never in a GET after
93
+ // that — `hasAccessKey`/`accessKeyMasked` are all the readback there is. The
94
+ // person copies it from this response into the devices that need it.
95
+ export async function handleRotateAccessKey(res, config) {
96
+ const file = settingsFile(config);
97
+ if (!file) {
98
+ return sendError(res, 400, 'This server was started without a config file, so there is nothing to save to.');
99
+ }
100
+ try {
101
+ const accessKey = generateAccessKey();
102
+ const current = await currentSettings(config);
103
+ const next = await writeSettings({ ...current, accessKey }, file);
104
+ sendJSON(res, 200, {
105
+ accessKey,
106
+ settings: publicSettings(next),
107
+ note: 'Shown once. Update every device that connects remotely; the old key is dead.',
108
+ });
109
+ } catch (err) {
110
+ sendError(res, 400, err.message || 'The key could not be rotated.');
111
+ }
112
+ }
113
+
114
+ // Kept exported for the tests: a patch that survives normalizeSettings is the
115
+ // same patch the wizard and the API would both accept.
116
+ export function validatePatch(patch) {
117
+ return normalizeSettings({ ...DEFAULT_SETTINGS, ...patch });
118
+ }
@@ -0,0 +1,90 @@
1
+ // GET /api/tools — which analyzers this machine can run, for the engines panel.
2
+ // POST /api/tools/run — run the available analyzers against a live scan and
3
+ // return the normalized findings.
4
+ //
5
+ // The scan id is a capability the same way it is for `/api/file`: it names a
6
+ // root the person chose, and the analyzers are run inside that root and nowhere
7
+ // else. `tools.js` spawns with argument arrays and hard timeouts, so a crafted
8
+ // path becomes an argument and a hung tool becomes a failed pass, not a stuck
9
+ // server.
10
+
11
+ import { sendError, sendJSON } from './http.js';
12
+ import { getSession } from './sessions.js';
13
+ import { runExternalAnalysis, toolsStatus } from './tools/scan.js';
14
+ import { installTool, isInstalling, plansFor } from './tools/install.js';
15
+ import { TOOL_DEFS } from './tools/registry.js';
16
+
17
+ const KNOWN_TOOLS = new Set(TOOL_DEFS.map((d) => d.id));
18
+
19
+ export function handleToolsStatus(res) {
20
+ sendJSON(res, 200, { tools: toolsStatus() });
21
+ }
22
+
23
+ export async function handleToolsRun(res, body) {
24
+ const { scanId, kinds, tools, options } = body || {};
25
+ if (!scanId) return sendError(res, 400, 'Missing scanId.');
26
+ const session = getSession(scanId);
27
+ if (!session) return sendError(res, 404, 'That scan is gone. Rescan the repo.');
28
+
29
+ // `kinds` narrows by purpose, `tools` to named engines. An unknown value is a
30
+ // typo the caller would rather hear about than have silently ignored.
31
+ let kindFilter = null;
32
+ if (Array.isArray(kinds) && kinds.length) {
33
+ const ok = kinds.filter((k) => k === 'security' || k === 'dead-code');
34
+ if (!ok.length) return sendError(res, 400, 'kinds must be "security" and/or "dead-code".');
35
+ kindFilter = ok;
36
+ }
37
+ let toolFilter = null;
38
+ if (Array.isArray(tools) && tools.length) {
39
+ toolFilter = tools.map((t) => String(t));
40
+ }
41
+
42
+ // `options` is the per-engine GUI form. Validation happens against the
43
+ // registry schema inside the run — the handler just passes it along.
44
+ const report = await runExternalAnalysis(session.root, {
45
+ kinds: kindFilter, tools: toolFilter, options,
46
+ });
47
+ return sendJSON(res, 200, report);
48
+ }
49
+
50
+ // POST /api/tools/install — install a missing engine, streaming progress.
51
+ //
52
+ // This is the one endpoint that changes the machine, so it is worth spelling
53
+ // out why that is safe enough for a local, self-hosted app:
54
+ //
55
+ // * The route is a POST, which the router's same-origin gate already
56
+ // refuses for any page that is not Onboarder's own — a foreign tab cannot
57
+ // drive it.
58
+ // * `tool` must name a registry entry; the install plans are fixed data in
59
+ // `tools/install.js`, spawned as argument arrays. Nothing from the
60
+ // request body ever becomes part of a command line.
61
+ // * The stream is SSE in the same `data:`-framed shape the explain proxy
62
+ // uses, so the front end reads install progress exactly like AI output.
63
+ export async function handleToolsInstall(res, body) {
64
+ const tool = String(body?.tool || '');
65
+ if (!KNOWN_TOOLS.has(tool)) {
66
+ return sendError(res, 400, `Unknown tool "${tool}". Known: ${[...KNOWN_TOOLS].join(', ')}.`);
67
+ }
68
+ if (!plansFor(tool).length) {
69
+ return sendError(res, 400, `Onboarder has no install plan for ${tool} on ${process.platform}.`);
70
+ }
71
+ if (isInstalling(tool)) {
72
+ return sendError(res, 409, `${tool} is already being installed.`);
73
+ }
74
+
75
+ res.writeHead(200, {
76
+ 'content-type': 'text/event-stream; charset=utf-8',
77
+ 'cache-control': 'no-cache',
78
+ connection: 'keep-alive',
79
+ });
80
+ const send = (event) => {
81
+ try { res.write('data: ' + JSON.stringify(event) + '\n\n'); } catch { /* client went away */ }
82
+ };
83
+
84
+ try {
85
+ await installTool(tool, send);
86
+ } catch (err) {
87
+ send({ type: 'done', ok: false, error: err.message || 'The install failed.' });
88
+ }
89
+ res.end();
90
+ }
@@ -0,0 +1,227 @@
1
+ // Durable, user-scoped settings for the CLI/server. This file deliberately does
2
+ // not know about HTTP: the CLI and the settings API use the same validation and
3
+ // write path so a browser can change a setting without inventing a second schema.
4
+
5
+ import { promises as fs } from 'node:fs';
6
+ import { randomBytes, timingSafeEqual } from 'node:crypto';
7
+ import os from 'node:os';
8
+ import path from 'node:path';
9
+
10
+ export const CONFIG_VERSION = 1;
11
+ export const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '::1', '[::1]']);
12
+ export const DEFAULT_SETTINGS = Object.freeze({
13
+ version: CONFIG_VERSION,
14
+ mode: 'local',
15
+ host: '127.0.0.1',
16
+ port: 4310,
17
+ domain: '',
18
+ accessKey: '',
19
+ // Whether `onboarder start` opens the app in a browser once it is listening.
20
+ // Off in the schema so `npm start` from a checkout stays quiet; the setup
21
+ // wizard offers to turn it on, which is where the people who want it are.
22
+ autoOpen: false,
23
+ account: Object.freeze({ name: '', email: '', provider: 'openai-compatible', baseUrl: '', model: '' }),
24
+ tunnel: Object.freeze({ cloudflare: false, tailscale: false }),
25
+ });
26
+
27
+ export function configHome(env = process.env) {
28
+ if (env.XDG_CONFIG_HOME) return path.resolve(env.XDG_CONFIG_HOME, 'onboarder');
29
+ return path.join(os.homedir(), '.config', 'onboarder');
30
+ }
31
+
32
+ export function configPath(env = process.env) {
33
+ // An explicit override first: the CLI's --config flag and the test suite both
34
+ // need a config file that is not the one in the person's home directory.
35
+ if (env.ONBOARDER_CONFIG) return path.resolve(env.ONBOARDER_CONFIG);
36
+ return path.join(configHome(env), 'config.json');
37
+ }
38
+
39
+ export function isLoopbackHost(host) {
40
+ return LOOPBACK_HOSTS.has(String(host || '').trim().toLowerCase());
41
+ }
42
+
43
+ function cleanString(value, max = 240) {
44
+ return String(value ?? '').trim().slice(0, max);
45
+ }
46
+
47
+ function normalizePort(value) {
48
+ const port = Number(value);
49
+ if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('Port must be a whole number from 1 to 65535.');
50
+ return port;
51
+ }
52
+
53
+ function normalizeDomain(value) {
54
+ const domain = cleanString(value).toLowerCase();
55
+ if (!domain) return '';
56
+ if (domain.length > 253 || domain.includes('/') || domain.includes('\\') || domain.includes(' ') || domain.includes('@') || domain.startsWith('.')) {
57
+ throw new Error('Domain must be a hostname such as map.example.com.');
58
+ }
59
+ return domain;
60
+ }
61
+
62
+ function normalizeAccount(value = {}) {
63
+ return {
64
+ name: cleanString(value.name, 80),
65
+ email: cleanString(value.email, 160),
66
+ provider: cleanString(value.provider || 'openai-compatible', 80),
67
+ baseUrl: cleanString(value.baseUrl, 500),
68
+ model: cleanString(value.model, 160),
69
+ };
70
+ }
71
+
72
+ function normalizeTunnel(value = {}) {
73
+ return { cloudflare: Boolean(value.cloudflare), tailscale: Boolean(value.tailscale) };
74
+ }
75
+
76
+ export function normalizeSettings(value = {}) {
77
+ const mode = value.mode === 'self-hosted' ? 'self-hosted' : 'local';
78
+ const host = cleanString(value.host || DEFAULT_SETTINGS.host, 120);
79
+ const settings = {
80
+ version: CONFIG_VERSION,
81
+ mode,
82
+ host,
83
+ port: normalizePort(value.port ?? DEFAULT_SETTINGS.port),
84
+ domain: normalizeDomain(value.domain),
85
+ accessKey: cleanString(value.accessKey, 256),
86
+ autoOpen: value.autoOpen === undefined ? DEFAULT_SETTINGS.autoOpen : Boolean(value.autoOpen),
87
+ account: normalizeAccount(value.account),
88
+ tunnel: normalizeTunnel(value.tunnel),
89
+ };
90
+ if (!host) throw new Error('Host cannot be empty.');
91
+ if (settings.mode === 'local' && !isLoopbackHost(settings.host)) {
92
+ throw new Error('Local mode only binds to 127.0.0.1, localhost, or ::1. Choose self-hosted mode for a network bind.');
93
+ }
94
+ if (settings.mode === 'self-hosted' && !isLoopbackHost(settings.host) && !settings.domain) {
95
+ throw new Error('A self-hosted network bind needs a domain (or keep the server on loopback behind a tunnel).');
96
+ }
97
+ return settings;
98
+ }
99
+
100
+ export function generateAccessKey() {
101
+ return `ob_${randomBytes(32).toString('base64url')}`;
102
+ }
103
+
104
+ // Every URL a person might reach this server through, derived from the settings
105
+ // so the CLI banner, the settings drawer, and `onboarder config` never disagree
106
+ // about what to print.
107
+ export function serverUrls(value = DEFAULT_SETTINGS) {
108
+ const settings = normalizeSettings(value);
109
+ const urls = { local: `http://localhost:${settings.port}` };
110
+ if (!isLoopbackHost(settings.host)) {
111
+ urls.network = settings.host === '0.0.0.0' || settings.host === '::'
112
+ ? `http://<this-machine>:${settings.port} (every interface)`
113
+ : `http://${settings.host}:${settings.port}`;
114
+ }
115
+ if (settings.domain) urls.domain = `https://${settings.domain}`;
116
+ return urls;
117
+ }
118
+
119
+ // The key, safe to print in a list: enough to recognize it, not enough to use it.
120
+ export function maskAccessKey(key) {
121
+ const value = String(key || '');
122
+ if (value.length <= 8) return value ? '…' : '';
123
+ return `${value.slice(0, 6)}…${value.slice(-4)}`;
124
+ }
125
+
126
+ export function publicSettings(value = DEFAULT_SETTINGS) {
127
+ const settings = normalizeSettings(value);
128
+ return {
129
+ version: settings.version,
130
+ mode: settings.mode,
131
+ host: settings.host,
132
+ port: settings.port,
133
+ domain: settings.domain,
134
+ autoOpen: settings.autoOpen,
135
+ hasAccessKey: Boolean(settings.accessKey),
136
+ accessKeyMasked: maskAccessKey(settings.accessKey),
137
+ account: settings.account,
138
+ tunnel: settings.tunnel,
139
+ urls: serverUrls(settings),
140
+ security: {
141
+ remoteAuth: settings.mode === 'self-hosted',
142
+ loopback: isLoopbackHost(settings.host),
143
+ },
144
+ };
145
+ }
146
+
147
+ // Hostnames the rebinding guard should accept beyond loopback: the configured
148
+ // domain, and a non-loopback bind address itself. A loopback-only local server
149
+ // gets an empty list, which is exactly the old behavior.
150
+ //
151
+ // Enabled tunnels add wildcard suffixes: a Cloudflare quick tunnel's name is
152
+ // random (https://<something>.trycloudflare.com) and a Tailscale machine name
153
+ // belongs to the tailnet, so neither can be configured ahead of time. This is
154
+ // safe because it can only widen Host acceptance in self-hosted mode, where
155
+ // every API call already needs the access key — the guard's job there is
156
+ // keeping drive-by traffic off the static files, not authentication.
157
+ export function allowedHosts(value = DEFAULT_SETTINGS) {
158
+ const settings = normalizeSettings(value);
159
+ const hosts = new Set();
160
+ if (settings.domain) hosts.add(settings.domain);
161
+ if (!isLoopbackHost(settings.host)) hosts.add(settings.host.toLowerCase());
162
+ if (settings.tunnel.cloudflare) hosts.add('*.trycloudflare.com');
163
+ if (settings.tunnel.tailscale) hosts.add('*.ts.net');
164
+ return [...hosts];
165
+ }
166
+
167
+ export function accessKeysMatch(expected, actual) {
168
+ const a = Buffer.from(String(expected || ''));
169
+ const b = Buffer.from(String(actual || ''));
170
+ return a.length > 0 && a.length === b.length && timingSafeEqual(a, b);
171
+ }
172
+
173
+ export function bearerToken(req) {
174
+ const value = String(req.headers?.authorization || '');
175
+ const match = value.match(/^Bearer\s+(.+)$/i);
176
+ return match ? match[1].trim() : '';
177
+ }
178
+
179
+ export function authReason(req, settings = DEFAULT_SETTINGS) {
180
+ const normalized = normalizeSettings(settings);
181
+ if (normalized.mode !== 'self-hosted') return null;
182
+ if (!normalized.accessKey) return 'Self-hosted mode has no access key configured.';
183
+ const token = bearerToken(req);
184
+ if (!accessKeysMatch(normalized.accessKey, token)) return 'A valid access key is required.';
185
+ return null;
186
+ }
187
+
188
+ // Has a config ever been written? The wizard asks this to decide between
189
+ // "start from the defaults" and "start from what you already have".
190
+ export async function configExists(file = configPath()) {
191
+ try {
192
+ await fs.access(file);
193
+ return true;
194
+ } catch {
195
+ return false;
196
+ }
197
+ }
198
+
199
+
200
+ export async function readSettings(file = configPath()) {
201
+ try {
202
+ const raw = await fs.readFile(file, 'utf8');
203
+ return normalizeSettings(JSON.parse(raw));
204
+ } catch (err) {
205
+ if (err.code === 'ENOENT') return { ...DEFAULT_SETTINGS, account: { ...DEFAULT_SETTINGS.account }, tunnel: { ...DEFAULT_SETTINGS.tunnel } };
206
+ if (err instanceof SyntaxError) throw new Error(`Config is not valid JSON: ${file}`);
207
+ throw err;
208
+ }
209
+ }
210
+
211
+ export async function writeSettings(value, file = configPath()) {
212
+ const settings = normalizeSettings(value);
213
+ const dir = path.dirname(file);
214
+ await fs.mkdir(dir, { recursive: true, mode: 0o700 });
215
+ const temp = `${file}.${process.pid}.${Date.now()}.tmp`;
216
+ const body = JSON.stringify(settings, null, 2) + '\n';
217
+ await fs.writeFile(temp, body, { encoding: 'utf8', mode: 0o600 });
218
+ await fs.rename(temp, file);
219
+ await fs.chmod(file, 0o600);
220
+ return settings;
221
+ }
222
+
223
+ export async function updateSettings(patch = {}, file = configPath()) {
224
+ const current = await readSettings(file);
225
+ const next = normalizeSettings({ ...current, ...patch, account: { ...current.account, ...(patch.account || {}) }, tunnel: { ...current.tunnel, ...(patch.tunnel || {}) } });
226
+ return writeSettings(next, file);
227
+ }