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,212 @@
1
+ // The registry: what each engine is for, where to find it, and how to run it.
2
+ //
3
+ // This is data, deliberately — the engine in `tools.js` walks it and asserts
4
+ // every entry is well-formed, and the UI renders it as the "available tools"
5
+ // list. A new analyzer is a new entry here and a new parser in `parse.js`;
6
+ // nothing else should have to know about it.
7
+
8
+ import os from 'node:os';
9
+ import path from 'node:path';
10
+
11
+ import {
12
+ parseDepcheck, parseGitleaks, parseKnip, parseSemgrep, parseVulture,
13
+ } from './parse.js';
14
+
15
+ // `rel` turns a tool's absolute path into the repo-relative path the whole app
16
+ // uses. Tools report paths rooted wherever they ran from.
17
+ const makeRel = (root) => (p) => {
18
+ const s = String(p || '');
19
+ const win = s.startsWith(root + '\\') ? s.slice(root.length + 1) : null;
20
+ if (win) return win.replace(/\\/g, '/');
21
+ return s.startsWith(root + '/') ? s.slice(root.length + 1) : s.replace(/^[/\\]+/, '');
22
+ };
23
+
24
+ // Gitleaks writes its JSON to a *file* (`--report-path`); the old code pointed
25
+ // that at `/dev/stdout`, a path Windows does not have. Every run gets a real
26
+ // temp file instead, which `scan.js` reads and removes afterwards.
27
+ function defaultReportPath() {
28
+ return path.join(os.tmpdir(), `onboarder-gitleaks-${process.pid}.json`);
29
+ }
30
+
31
+ // The options schema is data for two consumers. The GUI renders it as a small
32
+ // per-engine form (select, checkbox, number) so a person can use what each
33
+ // tool actually offers without a terminal; the server's `sanitizeOptions`
34
+ // walks the same list and drops anything else, so an option the registry does
35
+ // not declare can never reach a command line.
36
+
37
+ export const TOOL_DEFS = [
38
+ {
39
+ id: 'semgrep',
40
+ label: 'Semgrep',
41
+ kind: 'security',
42
+ purpose: 'Deep static analysis — injection, crypto, auth — across 30+ languages.',
43
+ commands: [
44
+ { kind: 'direct', bin: 'semgrep' },
45
+ { kind: 'direct', bin: 'opengrep' }, // the OSS fork, drop-in compatible
46
+ { kind: 'uvx', pkg: 'semgrep', bin: 'semgrep' },
47
+ ],
48
+ install: ['Install with: pip install semgrep (or: brew install semgrep, or uv tool install semgrep).'],
49
+ options: [
50
+ {
51
+ key: 'config', label: 'Rule set', type: 'enum',
52
+ values: ['p/default', 'p/security-audit', 'p/secrets', 'p/ci', 'auto'],
53
+ default: 'p/default',
54
+ hint: '“auto” downloads the recommended set — it needs network access.',
55
+ },
56
+ {
57
+ key: 'severity', label: 'Minimum severity', type: 'enum',
58
+ values: ['all', 'WARNING', 'ERROR'],
59
+ default: 'all',
60
+ hint: 'ERROR only keeps the findings Semgrep itself calls errors.',
61
+ },
62
+ ],
63
+ // `--config auto` needs network; the offline path is `--config p/default`.
64
+ // We run offline by default: self-host means the box may not be online.
65
+ argv: (root, options = {}) => {
66
+ const args = ['scan', '--json', '--quiet', '--no-git-ignore', '--timeout', '60',
67
+ '--config', options.config || 'p/default'];
68
+ if (options.severity === 'WARNING') args.push('--severity', 'WARNING', '--severity', 'ERROR');
69
+ if (options.severity === 'ERROR') args.push('--severity', 'ERROR');
70
+ args.push(root);
71
+ return args;
72
+ },
73
+ parse: (text, root) => parseSemgrep(JSON.parse(text), makeRel(root)),
74
+ },
75
+ {
76
+ id: 'gitleaks',
77
+ label: 'Gitleaks',
78
+ kind: 'security',
79
+ purpose: 'Secrets — committed keys, tokens, private keys.',
80
+ commands: [{ kind: 'direct', bin: 'gitleaks' }],
81
+ install: ['Install with: brew install gitleaks (or download the binary from its GitHub releases).'],
82
+ options: [
83
+ {
84
+ key: 'history', label: 'Scan git history too', type: 'boolean',
85
+ default: false,
86
+ hint: 'Slower, but finds secrets that were committed and later deleted.',
87
+ },
88
+ {
89
+ key: 'redact', label: 'Redact secrets in output', type: 'boolean',
90
+ default: true,
91
+ hint: 'The parser never relays a secret either way; this hides it in the raw report too.',
92
+ },
93
+ ],
94
+ usesReportFile: true,
95
+ argv: (root, options = {}, ctx = {}) => {
96
+ const args = ['detect', '--source', root,
97
+ '--report-format', 'json', '--report-path', ctx.reportPath || defaultReportPath(),
98
+ '--exit-code', '0'];
99
+ if (!options.history) args.push('--no-git');
100
+ if (options.redact !== false) args.push('--redact');
101
+ return args;
102
+ },
103
+ parse: (text, root) => parseGitleaks(JSON.parse(text), makeRel(root)),
104
+ },
105
+ {
106
+ id: 'knip',
107
+ label: 'Knip',
108
+ kind: 'dead-code',
109
+ purpose: 'Dead code for JS/TS — unused files, exports and dependencies.',
110
+ commands: [
111
+ { kind: 'direct', bin: 'knip' },
112
+ { kind: 'npx', pkg: 'knip' },
113
+ ],
114
+ install: ['Install with: npm install -g knip (or run it through npx, which this will do for you).'],
115
+ options: [
116
+ {
117
+ key: 'include', label: 'Issue types', type: 'multi',
118
+ values: ['files', 'dependencies', 'exports', 'types', 'duplicates'],
119
+ default: [],
120
+ hint: 'Nothing checked means Knip reports every kind it knows.',
121
+ },
122
+ ],
123
+ argv: (root, options = {}) => {
124
+ const args = ['--reporter', 'json', '--directory', root, '--no-progress'];
125
+ const include = Array.isArray(options.include) ? options.include.filter(Boolean) : [];
126
+ if (include.length) args.push('--include', include.join(','));
127
+ return args;
128
+ },
129
+ parse: (text, root) => parseKnip(JSON.parse(text), makeRel(root)),
130
+ },
131
+ {
132
+ id: 'vulture',
133
+ label: 'Vulture',
134
+ kind: 'dead-code',
135
+ purpose: 'Dead code for Python — unused functions, classes, variables.',
136
+ commands: [
137
+ { kind: 'direct', bin: 'vulture' },
138
+ { kind: 'uvx', pkg: 'vulture', bin: 'vulture' },
139
+ ],
140
+ install: ['Install with: pip install vulture (or run it through uvx, which this will do for you).'],
141
+ options: [
142
+ {
143
+ key: 'minConfidence', label: 'Minimum confidence (%)', type: 'number',
144
+ min: 1, max: 100, default: 60,
145
+ hint: 'Lower finds more, and guesses more. 60 is Vulture’s own default.',
146
+ },
147
+ ],
148
+ argv: (root, options = {}) => {
149
+ const n = Number(options.minConfidence);
150
+ const confidence = Number.isFinite(n) ? Math.min(100, Math.max(1, Math.round(n))) : 60;
151
+ return [root, '--min-confidence', String(confidence)];
152
+ },
153
+ parse: (text, root) => parseVulture(text, makeRel(root)),
154
+ textOutput: true,
155
+ },
156
+ {
157
+ id: 'depcheck',
158
+ label: 'Depcheck',
159
+ kind: 'dead-code',
160
+ purpose: 'Unused npm dependencies (a narrower, faster slice of Knip).',
161
+ commands: [
162
+ { kind: 'direct', bin: 'depcheck' },
163
+ { kind: 'npx', pkg: 'depcheck' },
164
+ ],
165
+ install: ['Install with: npm install -g depcheck (or run it through npx).'],
166
+ options: [
167
+ {
168
+ key: 'skipMissing', label: 'Skip “missing from package.json” reports', type: 'boolean',
169
+ default: false,
170
+ hint: 'Useful when imports are resolved by a bundler depcheck cannot see.',
171
+ },
172
+ ],
173
+ argv: (root, options = {}) => {
174
+ const args = ['--json'];
175
+ if (options.skipMissing) args.push('--skip-missing=true');
176
+ return args;
177
+ },
178
+ cwd: true, // depcheck reads the package.json in its working directory
179
+ parse: (text) => parseDepcheck(JSON.parse(text)),
180
+ },
181
+ ];
182
+
183
+ // Turn a raw `options` object from a request body into one the registry
184
+ // trusts: only declared keys, values coerced to the declared type, enum and
185
+ // multi values restricted to the declared lists. Anything else falls back to
186
+ // the default — the GUI sends well-formed values, and anything else is noise.
187
+ export function sanitizeOptions(def, raw) {
188
+ const clean = {};
189
+ const input = raw && typeof raw === 'object' ? raw : {};
190
+ for (const opt of def.options || []) {
191
+ const value = input[opt.key];
192
+ if (opt.type === 'boolean') {
193
+ clean[opt.key] = value === undefined ? opt.default : Boolean(value);
194
+ } else if (opt.type === 'enum') {
195
+ clean[opt.key] = opt.values.includes(value) ? value : opt.default;
196
+ } else if (opt.type === 'multi') {
197
+ clean[opt.key] = Array.isArray(value) ? value.filter((v) => opt.values.includes(v)) : [...opt.default];
198
+ } else if (opt.type === 'number') {
199
+ const n = Number(value);
200
+ clean[opt.key] = Number.isFinite(n)
201
+ ? Math.min(opt.max, Math.max(opt.min, Math.round(n)))
202
+ : opt.default;
203
+ }
204
+ }
205
+ return clean;
206
+ }
207
+
208
+ // The schema with every default filled in — what the GUI renders before the
209
+ // person touches anything.
210
+ export function defaultOptions(def) {
211
+ return sanitizeOptions(def, {});
212
+ }
@@ -0,0 +1,136 @@
1
+ // The orchestrator: detect what is on the machine, run each available analyzer
2
+ // against the scanned root, and hand back one normalized report.
3
+ //
4
+ // Two promises drive the shape. First, detection is *honest*: the report always
5
+ // lists every tool and whether it ran, so "no security issues" and "no security
6
+ // tool installed" are never the same sentence. Second, failure is *local*: a
7
+ // tool that hangs, crashes or prints garbage fails its own pass and nothing
8
+ // else — the scan the person asked for already succeeded, and this is a bonus
9
+ // layer over it, exactly the way `collectHistory` treats git.
10
+
11
+ import fs from 'node:fs';
12
+ import os from 'node:os';
13
+ import path from 'node:path';
14
+
15
+ import { emptyPass, runTool, toolArgv, TOOL_TIMEOUT_MS } from '../tools.js';
16
+ import { defaultOptions, sanitizeOptions, TOOL_DEFS } from './registry.js';
17
+ import { mergeFindings } from './parse.js';
18
+
19
+ // What is installed, for the UI's "engines" panel. Detection is cached by
20
+ // `which`, so asking is cheap. The payload also carries each tool's option
21
+ // schema and defaults, so the GUI can render a form for what the engine
22
+ // actually offers without hard-coding a flag anywhere in the front end.
23
+ export function toolsStatus() {
24
+ const out = {};
25
+ for (const def of TOOL_DEFS) {
26
+ const resolved = toolArgv(def, '.');
27
+ out[def.id] = {
28
+ id: def.id,
29
+ label: def.label,
30
+ kind: def.kind,
31
+ purpose: def.purpose,
32
+ available: !!resolved,
33
+ how: resolved ? resolved.how : null,
34
+ command: resolved ? resolved.display : null,
35
+ reason: resolved ? null : unavailableReason(def),
36
+ options: def.options || [],
37
+ defaults: defaultOptions(def),
38
+ installable: true,
39
+ platform: process.platform,
40
+ };
41
+ }
42
+ return out;
43
+ }
44
+
45
+ function unavailableReason(def) {
46
+ return (def.install && def.install.length) ? def.install.join(' ') : 'Not found on PATH.';
47
+ }
48
+
49
+ async function runOneTool(def, root, toolOptions = {}) {
50
+ // A tool that reports through a file (gitleaks) gets a per-run temp path —
51
+ // never /dev/stdout, which does not exist on Windows, and never a fixed
52
+ // name, so two concurrent runs cannot read each other's report.
53
+ const ctx = {};
54
+ if (def.usesReportFile) {
55
+ ctx.reportPath = path.join(os.tmpdir(), `onboarder-${def.id}-${Date.now()}-${Math.floor(Math.random() * 1e6)}.json`);
56
+ }
57
+
58
+ const resolved = toolArgv(def, root, toolOptions, ctx);
59
+ if (!resolved) {
60
+ return emptyPass(def.id, def.label, unavailableReason(def));
61
+ }
62
+
63
+ const started = Date.now();
64
+ const result = await runTool(resolved.argv, {
65
+ cwd: def.cwd ? root : undefined,
66
+ timeout: TOOL_TIMEOUT_MS,
67
+ });
68
+
69
+ // Read and remove the report file whatever happened above.
70
+ let reportText = null;
71
+ if (ctx.reportPath) {
72
+ try {
73
+ reportText = fs.readFileSync(ctx.reportPath, 'utf8');
74
+ } catch {
75
+ reportText = null; // a crashed tool may never have written it
76
+ }
77
+ try { fs.unlinkSync(ctx.reportPath); } catch { /* already gone */ }
78
+ }
79
+
80
+ if (result.timedOut) return { ...emptyPass(def.id, def.label, result.error), available: true };
81
+ if (!result.ok) {
82
+ return { ...emptyPass(def.id, def.label, result.error || 'The tool could not be run.'), available: true };
83
+ }
84
+
85
+ const stdout = (def.usesReportFile ? (reportText || '') : (result.stdout || '')).trim();
86
+ if (!stdout) {
87
+ // A clean pass: the tool ran and found nothing. That is a real answer, and
88
+ // worth saying — "Gitleaks: no secrets" is the outcome a self-hosting user
89
+ // runs the tool to hear.
90
+ return passResult(def, resolved, [], started);
91
+ }
92
+
93
+ try {
94
+ const findings = def.parse(stdout, root);
95
+ return passResult(def, resolved, findings, started);
96
+ } catch (err) {
97
+ return { ...emptyPass(def.id, def.label, 'Its output could not be read: ' + err.message), available: true };
98
+ }
99
+ }
100
+
101
+ function passResult(def, resolved, findings, started) {
102
+ return {
103
+ id: def.id, label: def.label, kind: def.kind, ok: true, available: true,
104
+ findings, source: 'external', tool: def.id, how: resolved.how, ms: Date.now() - started,
105
+ };
106
+ }
107
+
108
+ // Tools run concurrently — a scan is I/O-bound and the analyzers are
109
+ // independent — but a repo with zero analyzers still gets a well-formed report,
110
+ // so the frontend renders the built-in floor and the "what to install" list.
111
+ // `kinds` narrows by purpose, `tools` narrows to named engines; both exist so
112
+ // the UI can offer "run just the secrets scan" as readily as "run everything".
113
+ // `options` is per-engine GUI settings, sanitized against the registry schema
114
+ // before any of it touches a command line.
115
+ export async function runExternalAnalysis(root, options = {}) {
116
+ const kinds = options.kinds ? new Set(options.kinds) : null;
117
+ const tools = options.tools ? new Set(options.tools) : null;
118
+ const rawOptions = options.options && typeof options.options === 'object' ? options.options : {};
119
+ const defs = TOOL_DEFS.filter((d) => (!kinds || kinds.has(d.kind)) && (!tools || tools.has(d.id)));
120
+
121
+ const started = Date.now();
122
+ const passes = await Promise.all(defs.map((def) => runOneTool(def, root, sanitizeOptions(def, rawOptions[def.id]))));
123
+
124
+ const ran = passes.filter((p) => p.ok);
125
+ const findings = mergeFindings(...passes.map((p) => p.findings));
126
+
127
+ return {
128
+ ms: Date.now() - started,
129
+ passes,
130
+ findings,
131
+ ranCount: ran.length,
132
+ totalTools: defs.length,
133
+ unavailable: passes.filter((p) => !p.available).map((p) => ({ id: p.id, label: p.label, reason: p.reason })),
134
+ source: ran.length ? 'external' : 'none',
135
+ };
136
+ }
@@ -0,0 +1,212 @@
1
+ // The deep-analysis engine: Onboarder becomes the frontend, and the sharpest
2
+ // open-source analyzers become its backend.
3
+ //
4
+ // The idea, stated once: this tool's built-in scanner (`shared/analyzer/`) is
5
+ // deliberately a *local, zero-dependency* pass — regex rules, import graphs,
6
+ // metrics. It is honest about what it is. But the best answers to "is this
7
+ // safe?" and "is this dead?" come from real engines: **Semgrep** and
8
+ // **Gitleaks** for security, **Knip** / **Vulture** for dead code. Those are
9
+ // command-line programs, which is exactly the right seam for a self-hosted,
10
+ // zero-npm-dep project: we never import them, we *run* them, read their JSON
11
+ // or SARIF, and normalize everything into one finding shape the UI already
12
+ // knows how to draw.
13
+ //
14
+ // The rules that keep this self-host-first and safe:
15
+ //
16
+ // * **Optional, always.** No tool is required. Every analyzer the engine
17
+ // detects is a bonus; every one it cannot find is a graceful absence, and
18
+ // the built-in scanner is the floor that never goes away. The scan payload
19
+ // reports `source: 'builtin'` or `'external'` per pass so the UI can say
20
+ // which engine answered, instead of pretending.
21
+ //
22
+ // * **Detect, never install.** We look for tools on PATH (plus a few named
23
+ // fallbacks — `uvx`, `npx`, `opengrep` — the ways these tools are actually
24
+ // distributed) and report what is there. We do not download or `pip install`
25
+ // anything behind a scan; the person self-hosting this decides what to put
26
+ // on the machine.
27
+ //
28
+ // * **Run, never eval.** Every tool is spawned with an argument array — never
29
+ // a shell — the same discipline `gitClone.js` uses, so a crafted path or a
30
+ // malicious filename becomes an argument, not a command. No tool output is
31
+ // ever executed; it is parsed as data.
32
+ //
33
+ // * **Bounded.** A tool gets a hard timeout and a capped buffer. A hung or
34
+ // pathological analyzer fails that pass and nothing else; it cannot hold the
35
+ // server open.
36
+ //
37
+ // Each tool is a descriptor (`TOOL_DEFS`): how to find it, how to invoke it,
38
+ // and how to turn its output into findings. `scan.js` (this folder) parses;
39
+ // this file owns process lifecycle. `shared/` stays isomorphic — none of this
40
+ // lives there, because none of it can run in a browser tab.
41
+
42
+ import { spawn } from 'node:child_process';
43
+ import { findOnPath, spawnArgv } from './tools/platform.js';
44
+
45
+ // ---- the registry ----------------------------------------------------------
46
+ //
47
+ // What the engine can drive. `id` is the key the rest of the app talks about;
48
+ // `commands` is the preference order for locating the binary. `invocation` and
49
+ // `parse` are filled in by the per-tool modules so this file stays about
50
+ // lifecycle, not dialects. The whole list is data, which is what lets a test
51
+ // walk it and assert every tool is well-formed.
52
+
53
+ export const TOOL_IDS = ['semgrep', 'gitleaks', 'knip', 'vulture', 'depcheck'];
54
+
55
+ // Where a binary can come from. `direct` is a tool on PATH. `uvx` and `npx`
56
+ // run a tool without a pre-installed binary, which is how a self-hosting user
57
+ // who has neither semgrep nor a Python env still gets a run — the fallback is
58
+ // declared, not hidden in a shell string. Detection goes through `findOnPath`
59
+ // (PATH split + PATHEXT on Windows + the Onboarder bin dir), not the Unix
60
+ // `which`, so the same code answers on macOS, Linux and Windows.
61
+ const WHICH_CACHE = new Map();
62
+
63
+ function which(cmd) {
64
+ if (WHICH_CACHE.has(cmd)) return WHICH_CACHE.get(cmd);
65
+ const found = findOnPath(cmd);
66
+ WHICH_CACHE.set(cmd, found);
67
+ return found;
68
+ }
69
+
70
+ // An install from the GUI changes what is on disk; the cache must forget, or
71
+ // the engine would keep reporting "not installed" until a server restart.
72
+ export function clearDetectionCache() {
73
+ WHICH_CACHE.clear();
74
+ }
75
+
76
+ // Does the runtime have a way to run a tool that is not installed? uvx ships
77
+ // with `uv`, npx ships with npm; both can fetch-and-run. Detected once.
78
+ export function detectRunners() {
79
+ return {
80
+ uvx: which('uvx'),
81
+ npx: which('npx'),
82
+ };
83
+ }
84
+
85
+ // What is actually on this machine, per tool. The UI reads this to grey out
86
+ // what is unavailable and to say *why* — "install semgrep" beats silence.
87
+ export function detectTools(defs) {
88
+ const runners = detectRunners();
89
+ const out = {};
90
+ for (const def of defs) {
91
+ const resolution = resolveTool(def, runners);
92
+ out[def.id] = {
93
+ id: def.id,
94
+ label: def.label,
95
+ purpose: def.purpose,
96
+ available: !!resolution,
97
+ how: resolution ? resolution.how : null,
98
+ command: resolution ? resolution.display : null,
99
+ reason: resolution ? null : unavailableReason(def, runners),
100
+ };
101
+ }
102
+ return out;
103
+ }
104
+
105
+ function unavailableReason(def, runners) {
106
+ const parts = def.install ? def.install : [];
107
+ if (!runners.uvx && !runners.npx) {
108
+ return parts.length
109
+ ? `Not found. ${parts.join(' ')}`
110
+ : 'Not found on PATH, and no uvx/npx to run it with.';
111
+ }
112
+ return parts.length ? `Not found. ${parts.join(' ')}` : 'Not found.';
113
+ }
114
+
115
+ // Prefer a real binary on PATH; fall back to a runner that can fetch it. The
116
+ // returned `{ prefix }` is prepended to the tool's own arguments — a plain
117
+ // array, never a string, so nothing reaches a shell.
118
+ function resolveTool(def, runners) {
119
+ for (const cmd of def.commands) {
120
+ if (cmd.kind === 'direct') {
121
+ const found = which(cmd.bin);
122
+ // The absolute path, not the bare name: on Windows the suffix is how
123
+ // `spawnArgv` knows a shim needs cmd.exe, and `npm.cmd` on PATH is how
124
+ // knip and depcheck usually arrive there.
125
+ if (found) return { how: 'path', display: cmd.bin, prefix: [found] };
126
+ } else if (cmd.kind === 'uvx' && runners.uvx) {
127
+ return { how: 'uvx', display: `uvx ${cmd.pkg}`, prefix: [runners.uvx, '--from', cmd.pkg, cmd.bin] };
128
+ } else if (cmd.kind === 'npx' && runners.npx) {
129
+ return { how: 'npx', display: `npx ${cmd.pkg}`, prefix: [runners.npx, '--yes', cmd.pkg] };
130
+ }
131
+ }
132
+ return null;
133
+ }
134
+
135
+ // The full command line for a tool against a root, or null if it cannot be run.
136
+ // Detection and spawning both go through this, so "available" can never mean a
137
+ // command we then fail to build. `toolOptions` are the GUI-tunable settings the
138
+ // registry declares; `ctx` carries run-time necessities like a report path for
139
+ // tools that cannot write JSON to stdout on every platform (gitleaks).
140
+ export function toolArgv(def, root, toolOptions = {}, ctx = {}) {
141
+ const resolution = resolveTool(def, detectRunners());
142
+ if (!resolution) return null;
143
+ return {
144
+ argv: [...resolution.prefix, ...def.argv(root, toolOptions, ctx)],
145
+ how: resolution.how,
146
+ display: resolution.display,
147
+ };
148
+ }
149
+
150
+ // ---- running ---------------------------------------------------------------
151
+
152
+ export const TOOL_TIMEOUT_MS = 120_000;
153
+ const MAX_OUTPUT_BYTES = 16 * 1024 * 1024;
154
+
155
+ // Spawn a tool with an argument array and collect stdout. Deliberately mirrors
156
+ // `gitClone.run`: no shell, a timeout, a capped buffer, and a settled promise
157
+ // that resolves with whatever was captured — a non-zero exit from an analyzer
158
+ // is information ("knip found dead code exits 1"), not an exception.
159
+ // `spawnArgv` handles the one platform wrinkle: a `.cmd` shim (npm-installed
160
+ // CLIs on Windows) may only run through cmd.exe, with the arguments quoted
161
+ // into a single command string by the helper — never user-authored text.
162
+ export function runTool(argv, options = {}) {
163
+ const cwd = options.cwd || process.cwd();
164
+ const timeout = options.timeout || TOOL_TIMEOUT_MS;
165
+ const { command, args } = spawnArgv(argv);
166
+
167
+ return new Promise((resolve) => {
168
+ let child;
169
+ try {
170
+ child = spawn(command, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'], env: options.env });
171
+ } catch (err) {
172
+ resolve({ ok: false, status: -1, stdout: '', stderr: String(err && err.message || err), timedOut: false });
173
+ return;
174
+ }
175
+
176
+ let stdout = '';
177
+ let stderr = '';
178
+ let settled = false;
179
+ const finish = (extra) => {
180
+ if (settled) return;
181
+ settled = true;
182
+ clearTimeout(timer);
183
+ resolve({ status: child.exitCode, stdout, stderr, ...extra });
184
+ };
185
+
186
+ const cap = (chunk, into) => {
187
+ const next = into + chunk;
188
+ return next.length > MAX_OUTPUT_BYTES ? next.slice(0, MAX_OUTPUT_BYTES) : next;
189
+ };
190
+ child.stdout.on('data', (c) => { stdout = cap(c.toString(), stdout); });
191
+ child.stderr.on('data', (c) => { stderr = cap(c.toString(), stderr).slice(-4000); });
192
+
193
+ const timer = setTimeout(() => {
194
+ child.kill('SIGKILL');
195
+ finish({ ok: false, timedOut: true, error: `${argv[0]} took too long and was stopped.` });
196
+ }, timeout);
197
+
198
+ child.on('error', (err) => {
199
+ finish({ ok: false, timedOut: false, error: 'Could not run ' + argv[0] + ': ' + err.message });
200
+ });
201
+ child.on('close', () => finish({ ok: true, timedOut: false }));
202
+ });
203
+ }
204
+
205
+ // The contract every analyzer hands back: findings normalized to the shape the
206
+ // built-in scanner already uses (`rule`, `severity`, `category`, `message`,
207
+ // `line`, `excerpt`), plus a `source` so the UI can attribute them. A tool that
208
+ // produced nothing parseable is an `ok:false` pass with a reason, never a crash.
209
+ export function emptyPass(id, label, reason) {
210
+ return { id, label, ok: false, available: false, findings: [], reason, source: 'external', tool: id };
211
+ }
212
+
@@ -0,0 +1,92 @@
1
+ // The two tunnels this server knows how to sit behind, and nothing else.
2
+ //
3
+ // A tunnel here is not a feature of the server — it is another program the
4
+ // person runs that forwards a public name to our port. What this module owns
5
+ // is everything we can honestly say about that arrangement: whether the tool
6
+ // is installed, the exact command that would expose the current settings, and
7
+ // the install hint for when it isn't. The CLI (`onboarder tunnel`) is what
8
+ // actually spawns anything; the HTTP layer only ever reads this status.
9
+
10
+ import { spawnSync } from 'node:child_process';
11
+
12
+ import { isLoopbackHost, normalizeSettings } from './config.js';
13
+
14
+ // `spawnSync` on a missing binary resolves with an ENOENT error rather than
15
+ // throwing, which is exactly the signal we need. `--version` is a flag every
16
+ // CLI here answers; anything that doesn't is treated as absent.
17
+ export function findOnPath(name) {
18
+ try {
19
+ const res = spawnSync(name, ['--version'], { encoding: 'utf8', timeout: 5000 });
20
+ if (res.error) return { installed: false };
21
+ const first = String(res.stdout || res.stderr || '').trim().split('\n')[0] || '';
22
+ return { installed: true, version: first.slice(0, 120) };
23
+ } catch {
24
+ return { installed: false };
25
+ }
26
+ }
27
+
28
+ // The one address a tunnel should forward to. Tunnels always dial loopback —
29
+ // that is what makes "loopback behind a tunnel" a safe self-hosted layout, so
30
+ // even a 0.0.0.0 bind is proxied from 127.0.0.1.
31
+ export function tunnelTarget(settings) {
32
+ const s = normalizeSettings(settings);
33
+ return `http://127.0.0.1:${s.port}`;
34
+ }
35
+
36
+ export function cloudflareCommand(settings) {
37
+ // A "quick tunnel": no account, no DNS edit, a random trycloudflare.com name
38
+ // that lives as long as the process. The named-tunnel route (a fixed domain
39
+ // on the person's own Cloudflare account) is a `cloudflared tunnel` setup of
40
+ // its own; the wizard says so rather than pretending one command covers both.
41
+ return `cloudflared tunnel --url ${tunnelTarget(settings)}`;
42
+ }
43
+
44
+ export function tailscaleCommand(settings) {
45
+ const s = normalizeSettings(settings);
46
+ // serve puts the machine's tailnet name (https://host.tailnet.ts.net) in
47
+ // front of the port, visible only to the tailnet. funnel would go further
48
+ // and publish it to the internet — mentioned, never run for you.
49
+ return `tailscale serve --bg --https=443 ${tunnelTarget(s)}`;
50
+ }
51
+
52
+ export function installHint(name) {
53
+ if (name === 'cloudflared') {
54
+ return process.platform === 'darwin'
55
+ ? 'brew install cloudflared (or see https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/)'
56
+ : 'See https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/';
57
+ }
58
+ return process.platform === 'darwin'
59
+ ? 'brew install --cask tailscale (or see https://tailscale.com/download)'
60
+ : 'See https://tailscale.com/download';
61
+ }
62
+
63
+ // The settings drawer's tunnel section and `onboarder doctor` both render this:
64
+ // what is enabled, what is installed, and what to run. Never spawns anything.
65
+ export function tunnelStatus(settings) {
66
+ const s = normalizeSettings(settings);
67
+ const cf = findOnPath('cloudflared');
68
+ const ts = findOnPath('tailscale');
69
+ return {
70
+ target: tunnelTarget(s),
71
+ cloudflare: {
72
+ enabled: s.tunnel.cloudflare,
73
+ installed: cf.installed,
74
+ version: cf.version || '',
75
+ command: cloudflareCommand(s),
76
+ install: cf.installed ? '' : installHint('cloudflared'),
77
+ },
78
+ tailscale: {
79
+ enabled: s.tunnel.tailscale,
80
+ installed: ts.installed,
81
+ version: ts.version || '',
82
+ command: tailscaleCommand(s),
83
+ install: ts.installed ? '' : installHint('tailscale'),
84
+ },
85
+ // A tunnel answering a bind the server refused is the classic misread of
86
+ // "self-hosted": the tunnel terminates TLS and dials loopback, so the bind
87
+ // stays 127.0.0.1. Worth one explicit line everywhere the status is shown.
88
+ note: isLoopbackHost(s.host)
89
+ ? 'Loopback bind — a tunnel is the right way to reach this server remotely.'
90
+ : 'Network bind — a tunnel is optional; the access key is not.',
91
+ };
92
+ }