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,246 @@
1
+ // Installing an engine from the GUI.
2
+ //
3
+ // The engine's founding rule was "detect, never install" — the person
4
+ // self-hosting decides what goes on the machine. This module relaxes *who
5
+ // runs the command*, not *what runs*: the install plans below are fixed data,
6
+ // one per tool per way of installing, and the GUI's Install button is the
7
+ // person deciding. No plan takes user input; every step is an argument array
8
+ // spawned without a shell, exactly like the analyzers themselves.
9
+ //
10
+ // Plans are ordered by preference and each names the launcher it needs
11
+ // (`brew`, `pip`, `npm`, `winget`, `curl`, …). At install time the first plan
12
+ // whose launcher is found on PATH runs; the rest are skipped. That is what
13
+ // makes one codebase serve all three platforms — a Mac with Homebrew, a
14
+ // Windows box with winget, and a bare Linux server with nothing but curl all
15
+ // get a working path, and the log says plainly which one was taken.
16
+
17
+ import { spawn } from 'node:child_process';
18
+ import fs from 'node:fs';
19
+ import os from 'node:os';
20
+ import path from 'node:path';
21
+
22
+ import { findOnPath, onboarderBinDir, spawnArgv } from './platform.js';
23
+ import { clearDetectionCache } from '../tools.js';
24
+
25
+ // Gitleaks has no package-manager story on Linux, so the fallback is its
26
+ // official release asset, fetched over TLS from GitHub and unpacked into
27
+ // ~/.onboarder/bin — a directory `findOnPath` already checks. The version is
28
+ // pinned on purpose: a moving "latest" URL is a supply-chain surprise.
29
+ const GITLEAKS_VERSION = '8.24.3';
30
+
31
+ function gitleaksAsset(platform, arch) {
32
+ const osPart = { darwin: 'darwin', linux: 'linux', win32: 'windows' }[platform];
33
+ const archPart = arch === 'arm64' ? 'arm64' : 'x64';
34
+ const ext = platform === 'win32' ? 'zip' : 'tar.gz';
35
+ return {
36
+ url: `https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_${osPart}_${archPart}.${ext}`,
37
+ file: `gitleaks.${ext}`,
38
+ binary: platform === 'win32' ? 'gitleaks.exe' : 'gitleaks',
39
+ };
40
+ }
41
+
42
+ // A plan step is `{ argv }`, built from a context so paths stay absolute.
43
+ // `needs` is the launcher binary that must exist for the whole plan to run.
44
+ const INSTALL_PLANS = {
45
+ semgrep: [
46
+ { id: 'brew', label: 'Homebrew', needs: 'brew', platforms: ['darwin'],
47
+ steps: (bin) => [[bin, ['install', 'semgrep']]] },
48
+ { id: 'pipx', label: 'pipx', needs: 'pipx',
49
+ steps: (bin) => [[bin, ['install', 'semgrep']]] },
50
+ { id: 'uv', label: 'uv', needs: 'uv',
51
+ steps: (bin) => [[bin, ['tool', 'install', 'semgrep']]] },
52
+ { id: 'pip', label: 'pip (user site)', needs: 'python3', platforms: ['darwin', 'linux'],
53
+ steps: (bin) => [[bin, ['-m', 'pip', 'install', '--user', 'semgrep']]] },
54
+ { id: 'pip', label: 'pip (user site)', needs: 'python', platforms: ['win32'],
55
+ steps: (bin) => [[bin, ['-m', 'pip', 'install', '--user', 'semgrep']]] },
56
+ ],
57
+ vulture: [
58
+ { id: 'brew', label: 'Homebrew', needs: 'brew', platforms: ['darwin'],
59
+ steps: (bin) => [[bin, ['install', 'vulture']]] },
60
+ { id: 'pipx', label: 'pipx', needs: 'pipx',
61
+ steps: (bin) => [[bin, ['install', 'vulture']]] },
62
+ { id: 'uv', label: 'uv', needs: 'uv',
63
+ steps: (bin) => [[bin, ['tool', 'install', 'vulture']]] },
64
+ { id: 'pip', label: 'pip (user site)', needs: 'python3', platforms: ['darwin', 'linux'],
65
+ steps: (bin) => [[bin, ['-m', 'pip', 'install', '--user', 'vulture']]] },
66
+ { id: 'pip', label: 'pip (user site)', needs: 'python', platforms: ['win32'],
67
+ steps: (bin) => [[bin, ['-m', 'pip', 'install', '--user', 'vulture']]] },
68
+ ],
69
+ knip: [
70
+ { id: 'npm', label: 'npm (global)', needs: 'npm',
71
+ steps: (bin) => [[bin, ['install', '-g', 'knip']]] },
72
+ ],
73
+ depcheck: [
74
+ { id: 'npm', label: 'npm (global)', needs: 'npm',
75
+ steps: (bin) => [[bin, ['install', '-g', 'depcheck']]] },
76
+ ],
77
+ gitleaks: [
78
+ { id: 'brew', label: 'Homebrew', needs: 'brew', platforms: ['darwin'],
79
+ steps: (bin) => [[bin, ['install', 'gitleaks']]] },
80
+ { id: 'winget', label: 'winget', needs: 'winget', platforms: ['win32'],
81
+ steps: (bin) => [[bin, ['install', '--id', 'Gitleaks.Gitleaks', '-e',
82
+ '--accept-package-agreements', '--accept-source-agreements']]] },
83
+ { id: 'download', label: 'GitHub release download', needs: 'curl',
84
+ steps: (bin, ctx) => {
85
+ const asset = gitleaksAsset(ctx.platform, ctx.arch);
86
+ const archive = path.join(ctx.tmpDir, asset.file);
87
+ return [
88
+ [bin, ['-fSL', '-o', archive, asset.url]],
89
+ // bsdtar ships with Windows 10+ and reads zip; GNU tar reads tar.gz.
90
+ // One command shape serves both.
91
+ ['tar', ['-xf', archive, '-C', ctx.binDir]],
92
+ ];
93
+ },
94
+ after: (ctx) => {
95
+ const asset = gitleaksAsset(ctx.platform, ctx.arch);
96
+ const binPath = path.join(ctx.binDir, asset.binary);
97
+ if (ctx.platform !== 'win32') fs.chmodSync(binPath, 0o755);
98
+ return binPath;
99
+ },
100
+ },
101
+ ],
102
+ };
103
+ // The plans a tool can use on this platform, in preference order. Pure —
104
+ // exported for the status endpoint and for tests.
105
+ export function plansFor(toolId, platform = process.platform) {
106
+ return (INSTALL_PLANS[toolId] || [])
107
+ .filter((plan) => !plan.platforms || plan.platforms.includes(platform));
108
+ }
109
+
110
+ // What the GUI's install dialog shows before the button is pressed: for each
111
+ // plan, whether its launcher exists here. Injectable `find` keeps it testable.
112
+ export function planAvailability(toolId, options = {}) {
113
+ const platform = options.platform || process.platform;
114
+ const find = options.find || ((bin) => findOnPath(bin));
115
+ return plansFor(toolId, platform).map((plan) => ({
116
+ id: plan.id,
117
+ label: plan.label,
118
+ needs: plan.needs,
119
+ ready: !!find(plan.needs),
120
+ }));
121
+ }
122
+
123
+ // One install at a time per tool — two Install clicks must not run pip against
124
+ // itself. Held here rather than in the handler so the rule survives a second
125
+ // caller.
126
+ const installing = new Set();
127
+
128
+ export function isInstalling(toolId) {
129
+ return installing.has(toolId);
130
+ }
131
+
132
+ const STEP_TIMEOUT_MS = 10 * 60 * 1000; // pip on a slow link is still finite
133
+
134
+ // Spawn one step, forwarding output line by line. Resolves with the exit
135
+ // status; never rejects — a failed plan is data for the next plan, and the
136
+ // whole log goes to the GUI either way.
137
+ function runStep(command, args, onLog) {
138
+ const { command: cmd, args: argv } = spawnArgv([command, ...args]);
139
+ return new Promise((resolve) => {
140
+ let child;
141
+ try {
142
+ child = spawn(cmd, argv, { stdio: ['ignore', 'pipe', 'pipe'] });
143
+ } catch (err) {
144
+ onLog(`could not start ${cmd}: ${err.message}`);
145
+ resolve(-1);
146
+ return;
147
+ }
148
+ let pending = '';
149
+ const feed = (chunk) => {
150
+ pending += chunk.toString();
151
+ const lines = pending.split(/\r?\n/);
152
+ pending = lines.pop();
153
+ for (const line of lines) if (line.trim()) onLog(line);
154
+ };
155
+ child.stdout.on('data', feed);
156
+ child.stderr.on('data', feed);
157
+ const timer = setTimeout(() => {
158
+ child.kill('SIGKILL');
159
+ onLog(`${cmd} took too long and was stopped.`);
160
+ }, STEP_TIMEOUT_MS);
161
+ child.on('error', (err) => {
162
+ clearTimeout(timer);
163
+ onLog(`could not run ${cmd}: ${err.message}`);
164
+ resolve(-1);
165
+ });
166
+ child.on('close', (code) => {
167
+ clearTimeout(timer);
168
+ if (pending.trim()) onLog(pending);
169
+ resolve(code ?? -1);
170
+ });
171
+ });
172
+ }
173
+
174
+ // Install a tool, streaming progress through `onEvent({ type, ... })`:
175
+ // { type: 'log', line } — one line of output from the installer
176
+ // { type: 'plan', id, label } — which plan is being attempted
177
+ // { type: 'done', ok, error?, method? }
178
+ // Resolves with the same shape as the final `done` event.
179
+ export async function installTool(toolId, onEvent = () => {}) {
180
+ const log = (line) => onEvent({ type: 'log', line });
181
+ const finish = (result) => { onEvent({ type: 'done', ...result }); return result; };
182
+
183
+ const plans = plansFor(toolId);
184
+ if (!plans.length) {
185
+ return finish({ ok: false, error: `Onboarder does not know how to install "${toolId}".` });
186
+ }
187
+ if (installing.has(toolId)) {
188
+ return finish({ ok: false, error: `${toolId} is already being installed.` });
189
+ }
190
+ installing.add(toolId);
191
+
192
+ try {
193
+ const ctx = {
194
+ platform: process.platform,
195
+ arch: process.arch,
196
+ tmpDir: os.tmpdir(),
197
+ binDir: onboarderBinDir(),
198
+ };
199
+
200
+ let attempted = 0;
201
+ for (const plan of plans) {
202
+ const launcher = findOnPath(plan.needs);
203
+ if (!launcher) {
204
+ log(`— skipping ${plan.label}: no ${plan.needs} on this machine.`);
205
+ continue;
206
+ }
207
+ attempted += 1;
208
+ onEvent({ type: 'plan', id: plan.id, label: plan.label });
209
+ log(`Installing ${toolId} with ${plan.label}…`);
210
+
211
+ if (plan.id === 'download') fs.mkdirSync(ctx.binDir, { recursive: true });
212
+
213
+ let failed = false;
214
+ for (const [command, args] of plan.steps(launcher, ctx)) {
215
+ log(`$ ${command} ${args.join(' ')}`);
216
+ const status = await runStep(command, args, log);
217
+ if (status !== 0) {
218
+ log(`✗ that step exited ${status}.`);
219
+ failed = true;
220
+ break;
221
+ }
222
+ }
223
+ if (failed) continue;
224
+
225
+ try {
226
+ const binPath = plan.after ? plan.after(ctx) : null;
227
+ if (binPath) log(`Installed to ${binPath}`);
228
+ } catch (err) {
229
+ log(`✗ unpack failed: ${err.message}`);
230
+ continue;
231
+ }
232
+
233
+ clearDetectionCache();
234
+ log('✓ done. Re-checking the engine list…');
235
+ return finish({ ok: true, method: plan.label });
236
+ }
237
+
238
+ const why = attempted
239
+ ? 'Every available installer failed — the log above says where.'
240
+ : 'No installer this machine has can install it. Try the manual command in the engine list.';
241
+ return finish({ ok: false, error: why });
242
+ } finally {
243
+ installing.delete(toolId);
244
+ }
245
+ }
246
+
@@ -0,0 +1,170 @@
1
+ // Parsing the dialects. Every analyzer speaks its own JSON (or SARIF); the UI
2
+ // speaks one. This file is the translation layer, and it is pure — it takes a
3
+ // string a tool printed and returns findings, so it can be tested in Node with
4
+ // fixtures and needs no process, no filesystem, no PATH.
5
+ //
6
+ // The single target shape is the one the built-in scanner already emits
7
+ // (`shared/analyzer/security.js`), so the existing security view needs no new
8
+ // kind of row:
9
+ //
10
+ // { rule, severity, category, message, line, excerpt, source, tool }
11
+ //
12
+ // `severity` is one of the built-in five. `source` is 'builtin' or 'external'
13
+ // and `tool` names which engine answered, so the UI can say "found by Semgrep"
14
+ // rather than letting a finding appear from nowhere.
15
+
16
+ export const SEVERITY_ORDER = ['info', 'low', 'medium', 'high', 'critical'];
17
+
18
+ export function normalizeSeverity(raw) {
19
+ const s = String(raw || '').toLowerCase();
20
+ if (s === 'critical' || s === 'error') return s === 'error' ? 'high' : 'critical';
21
+ if (s === 'high') return 'high';
22
+ if (s === 'medium' || s === 'warning' || s === 'warn' || s === 'moderate') return 'medium';
23
+ if (s === 'low' || s === 'note') return 'low';
24
+ return 'info';
25
+ }
26
+
27
+ function clipExcerpt(text) {
28
+ return String(text ?? '').replace(/\s+/g, ' ').trim().slice(0, 90);
29
+ }
30
+
31
+ // ---- Semgrep / Opengrep ----------------------------------------------------
32
+ // `semgrep --json` → { results: [ { check_id, path, start:{line}, extra:
33
+ // { severity, message, lines, metadata:{category} } } ] }. Opengrep is the same
34
+ // JSON. `rel` strips the scan root so paths match the repo-relative form the
35
+ // rest of the app uses.
36
+ export function parseSemgrep(json, rel) {
37
+ const results = Array.isArray(json && json.results) ? json.results : [];
38
+ const findings = [];
39
+ for (const r of results) {
40
+ const extra = r.extra || {};
41
+ findings.push({
42
+ rule: String(r.check_id || 'semgrep-rule'),
43
+ severity: normalizeSeverity(extra.severity),
44
+ category: (extra.metadata && extra.metadata.category) || 'security',
45
+ message: clipExcerpt(extra.message || r.check_id),
46
+ path: rel(r.path),
47
+ line: r.start && r.start.line ? r.start.line : 1,
48
+ excerpt: clipExcerpt(extra.lines),
49
+ source: 'external',
50
+ tool: 'semgrep',
51
+ });
52
+ }
53
+ return findings;
54
+ }
55
+
56
+ // ---- Gitleaks ---------------------------------------------------------------
57
+ // `gitleaks --report-format json` → an array of secrets: [ { RuleID,
58
+ // Description, File, StartLine, Match, Secret } ]. The finding reports *that* a
59
+ // secret exists, never the secret — `Secret` is read for nothing.
60
+ export function parseGitleaks(json, rel) {
61
+ const list = Array.isArray(json) ? json : [];
62
+ const findings = [];
63
+ for (const r of list) {
64
+ findings.push({
65
+ rule: 'secret:' + (r.RuleID || 'unknown'),
66
+ severity: 'critical',
67
+ category: 'secret',
68
+ message: clipExcerpt(r.Description || 'A secret is committed to the source.'),
69
+ path: rel(r.File),
70
+ line: r.StartLine || 1,
71
+ excerpt: 'committed credential (value redacted)',
72
+ source: 'external',
73
+ tool: 'gitleaks',
74
+ });
75
+ }
76
+ return findings;
77
+ }
78
+
79
+ // ---- Knip -------------------------------------------------------------------
80
+ // `knip --reporter json` groups dead code by kind: { files:[], dependencies:[],
81
+ // unused:[{name,file,line}], ... }. Each becomes a finding so the dead-code
82
+ // pass can be read in one list; the kind is the rule.
83
+ export function parseKnip(json, rel) {
84
+ const findings = [];
85
+ const push = (rule, category, severity, path, line, message) => {
86
+ findings.push({ rule, category, severity, message, path: rel(path), line: line || 1, excerpt: '', source: 'external', tool: 'knip' });
87
+ };
88
+
89
+ for (const file of (json && json.files) || []) {
90
+ push('knip:unused-file', 'dead-file', 'medium', file, 1, 'This file is never imported anywhere.');
91
+ }
92
+ for (const dep of (json && json.dependencies) || []) {
93
+ push('knip:unused-dependency', 'dead-dependency', 'low', dep, 1, `Dependency "${dep}" is declared but never imported.`);
94
+ }
95
+ for (const u of (json && json.unused) || []) {
96
+ push('knip:unused-export', 'dead-export', 'low', u.file, u.line, `Export "${u.name}" is never used.`);
97
+ }
98
+ for (const u of (json && json.unresolved) || []) {
99
+ push('knip:unresolved', 'dead-import', 'medium', u.file, u.line, `Import "${u.name}" does not resolve to anything.`);
100
+ }
101
+ return findings;
102
+ }
103
+
104
+ // ---- Vulture ----------------------------------------------------------------
105
+ // `vulture` prints lines like `src/util.py:42: unused function 'helper' (60%
106
+ // confidence)`. There is no stable JSON, so we parse the text — which is why the
107
+ // parser is a pure function over a string, and why the line shape is pinned by
108
+ // a test rather than by reading the binary's source.
109
+ const VULTURE_RE = /^(.+?):(\d+):\s+(.+?)\s+\((\d+)% confidence\)/;
110
+
111
+ export function parseVulture(text, rel) {
112
+ const findings = [];
113
+ for (const line of String(text || '').split('\n')) {
114
+ const m = line.match(VULTURE_RE);
115
+ if (!m) continue;
116
+ const confidence = Number(m[4]);
117
+ if (confidence < 60) continue; // below vulture's own floor, it is guessing
118
+ findings.push({
119
+ rule: 'vulture:' + (m[3].split(' ')[0] || 'unused'),
120
+ severity: confidence >= 90 ? 'medium' : 'low',
121
+ category: 'dead-code',
122
+ message: clipExcerpt(m[3]) + ` (${confidence}% confident)`,
123
+ path: rel(m[1]),
124
+ line: Number(m[2]) || 1,
125
+ excerpt: '',
126
+ source: 'external',
127
+ tool: 'vulture',
128
+ });
129
+ }
130
+ return findings;
131
+ }
132
+
133
+ // ---- Depcheck ---------------------------------------------------------------
134
+ // `depcheck --json` → { dependencies:[], devDependencies:[], missing:{} }.
135
+ export function parseDepcheck(json) {
136
+ const findings = [];
137
+ const push = (name, dev) => findings.push({
138
+ rule: 'depcheck:unused',
139
+ severity: 'low',
140
+ category: 'dead-dependency',
141
+ message: `Package "${name}" is declared in package.json but never imported.`,
142
+ path: 'package.json',
143
+ line: 1,
144
+ excerpt: '',
145
+ source: 'external',
146
+ tool: 'depcheck',
147
+ dev,
148
+ });
149
+ for (const name of (json && json.dependencies) || []) push(name, false);
150
+ for (const name of (json && json.devDependencies) || []) push(name, true);
151
+ return findings;
152
+ }
153
+
154
+ // Merge the external passes into one list, keeping the built-in scan's findings
155
+ // untouched. Dedup on (path, line, rule): two engines that flag the same line
156
+ // for the same reason are one problem, not two.
157
+ export function mergeFindings(...lists) {
158
+ const seen = new Set();
159
+ const out = [];
160
+ for (const list of lists) {
161
+ for (const f of list || []) {
162
+ const key = `${f.path}:${f.line}:${f.rule}`;
163
+ if (seen.has(key)) continue;
164
+ seen.add(key);
165
+ out.push(f);
166
+ }
167
+ }
168
+ return out;
169
+ }
170
+
@@ -0,0 +1,91 @@
1
+ // Cross-platform binary lookup.
2
+ //
3
+ // The engine used to ask the Unix `which` command where a tool lives. That is
4
+ // the right answer on macOS and Linux and *no* answer on Windows, where the
5
+ // command is `where` and an npm-installed tool is a `npm.cmd` shim, not an
6
+ // ELF file. Doing the lookup by hand — split PATH, try the PATHEXT suffixes
7
+ // on Windows — keeps one code path for all three platforms, keeps it testable
8
+ // (`exists` is injectable), and removes a spawned process from detection.
9
+ //
10
+ // `findOnPath` also checks a couple of well-known directories that are on a
11
+ // user's interactive PATH but often not on the server's — Homebrew on Apple
12
+ // Silicon is the classic case — and the folder Onboarder's own installer
13
+ // drops downloaded binaries into, so a tool installed from the GUI is found
14
+ // on the next status check without a server restart.
15
+
16
+ import fs from 'node:fs';
17
+ import os from 'node:os';
18
+ import path from 'node:path';
19
+
20
+ // Where `install.js` puts binaries it downloads itself (gitleaks on Linux and
21
+ // Windows). One fixed place, under the user's home, never the scanned repo.
22
+ export function onboarderBinDir(home = os.homedir()) {
23
+ return path.join(home, '.onboarder', 'bin');
24
+ }
25
+
26
+ // The names a command can go by on disk. Windows resolves `npm` to the first
27
+ // of `npm.exe`, `npm.cmd`, … on PATHEXT; POSIX looks for the name as-is.
28
+ export function candidateNames(cmd, platform = process.platform, pathext = '') {
29
+ if (platform !== 'win32') return [cmd];
30
+ const exts = (pathext || process.env.PATHEXT || '.EXE;.CMD;.BAT;.COM')
31
+ .split(';').map((e) => e.trim()).filter(Boolean);
32
+ const lower = cmd.toLowerCase();
33
+ if (exts.some((e) => lower.endsWith(e.toLowerCase()))) return [cmd];
34
+ return [cmd, ...exts.map((e) => cmd + e.toLowerCase())];
35
+ }
36
+
37
+ // Directories worth checking beyond PATH itself. The Homebrew pair matters on
38
+ // a Mac launched from Spotlight rather than a shell; the Onboarder bin dir is
39
+ // where the GUI installer puts self-downloaded tools.
40
+ export function extraSearchDirs(platform = process.platform, home = os.homedir()) {
41
+ const dirs = [onboarderBinDir(home)];
42
+ if (platform === 'darwin') dirs.push('/opt/homebrew/bin', '/usr/local/bin');
43
+ if (platform === 'linux') dirs.push('/usr/local/bin', path.join(home, '.local', 'bin'));
44
+ if (platform === 'win32') {
45
+ const local = process.env.LOCALAPPDATA;
46
+ if (local) dirs.push(path.join(local, 'Programs', 'Gitleaks'));
47
+ }
48
+ return dirs;
49
+ }
50
+
51
+ // Split a PATH string the way the platform does. Exported for tests.
52
+ export function splitPath(pathValue, platform = process.platform) {
53
+ return String(pathValue || '')
54
+ .split(platform === 'win32' ? ';' : ':')
55
+ .map((d) => d.trim())
56
+ .filter(Boolean);
57
+ }
58
+
59
+ // Find `cmd` on PATH (plus the extra dirs). Returns the absolute path, or
60
+ // null. Pure apart from the `exists` probe, which defaults to the disk and is
61
+ // injectable so the tests never touch a real filesystem layout.
62
+ export function findOnPath(cmd, options = {}) {
63
+ const platform = options.platform || process.platform;
64
+ const envPath = options.envPath !== undefined ? options.envPath : process.env.PATH;
65
+ const pathext = options.pathext !== undefined ? options.pathext : process.env.PATHEXT;
66
+ const exists = options.exists || ((p) => fs.existsSync(p));
67
+ const home = options.home || os.homedir();
68
+
69
+ const dirs = [...splitPath(envPath, platform), ...extraSearchDirs(platform, home)];
70
+ for (const dir of dirs) {
71
+ for (const name of candidateNames(cmd, platform, pathext)) {
72
+ const full = path.join(dir, name);
73
+ try {
74
+ if (exists(full)) return full;
75
+ } catch {
76
+ /* an unreadable dir is just not where the tool lives */
77
+ }
78
+ }
79
+ }
80
+ return null;
81
+ }
82
+
83
+ // A `.cmd`/`.bat` shim cannot be spawned directly on Windows (Node refuses
84
+ // with EINVAL): it needs cmd.exe. `spawnArgv` rewrites the argv for that case
85
+ // and leaves everything else untouched, so callers keep passing plain arrays.
86
+ export function spawnArgv(argv, platform = process.platform) {
87
+ if (platform !== 'win32') return { command: argv[0], args: argv.slice(1) };
88
+ if (!/\.(cmd|bat)$/i.test(argv[0] || '')) return { command: argv[0], args: argv.slice(1) };
89
+ const quoted = argv.map((a) => `"${String(a).replace(/"/g, '\\"')}"`).join(' ');
90
+ return { command: 'cmd.exe', args: ['/d', '/s', '/c', quoted] };
91
+ }