codebase-onboarder 0.6.0 → 0.7.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 (448) hide show
  1. package/README.md +174 -46
  2. package/bin/build-if-source.js +23 -0
  3. package/cli/commands.d.ts +26 -0
  4. package/cli/commands.js +836 -811
  5. package/cli/commands.js.map +1 -0
  6. package/cli/explorer/advanced.d.ts +14 -0
  7. package/cli/explorer/advanced.js +321 -329
  8. package/cli/explorer/advanced.js.map +1 -0
  9. package/cli/explorer/app.d.ts +1 -0
  10. package/cli/explorer/app.js +384 -313
  11. package/cli/explorer/app.js.map +1 -0
  12. package/cli/explorer/assistant.d.ts +9 -0
  13. package/cli/explorer/assistant.js +58 -0
  14. package/cli/explorer/assistant.js.map +1 -0
  15. package/cli/explorer/commands.d.ts +17 -0
  16. package/cli/explorer/commands.js +368 -302
  17. package/cli/explorer/commands.js.map +1 -0
  18. package/cli/explorer/featureViews.d.ts +124 -0
  19. package/cli/explorer/featureViews.js +179 -0
  20. package/cli/explorer/featureViews.js.map +1 -0
  21. package/cli/explorer/github.d.ts +21 -0
  22. package/cli/explorer/github.js +42 -44
  23. package/cli/explorer/github.js.map +1 -0
  24. package/cli/explorer/graphs.d.ts +7 -0
  25. package/cli/explorer/graphs.js +90 -95
  26. package/cli/explorer/graphs.js.map +1 -0
  27. package/cli/explorer/picker.d.ts +22 -0
  28. package/cli/explorer/picker.js +150 -0
  29. package/cli/explorer/picker.js.map +1 -0
  30. package/cli/explorer/report.d.ts +85 -0
  31. package/cli/explorer/report.js +60 -0
  32. package/cli/explorer/report.js.map +1 -0
  33. package/cli/explorer/session.d.ts +449 -0
  34. package/cli/explorer/session.js +143 -144
  35. package/cli/explorer/session.js.map +1 -0
  36. package/cli/explorer/views.d.ts +19 -0
  37. package/cli/explorer/views.js +465 -482
  38. package/cli/explorer/views.js.map +1 -0
  39. package/cli/explorer/wrap.d.ts +1 -0
  40. package/cli/explorer/wrap.js +28 -44
  41. package/cli/explorer/wrap.js.map +1 -0
  42. package/cli/main.d.ts +1 -0
  43. package/cli/main.js +214 -161
  44. package/cli/main.js.map +1 -0
  45. package/cli/prompt.d.ts +5 -0
  46. package/cli/prompt.js +84 -80
  47. package/cli/prompt.js.map +1 -0
  48. package/cli/scanOptions.d.ts +2 -0
  49. package/cli/scanOptions.js +11 -0
  50. package/cli/scanOptions.js.map +1 -0
  51. package/cli/ui.d.ts +33 -0
  52. package/cli/ui.js +35 -44
  53. package/cli/ui.js.map +1 -0
  54. package/cli/wizard.d.ts +187 -0
  55. package/cli/wizard.js +304 -284
  56. package/cli/wizard.js.map +1 -0
  57. package/package.json +15 -4
  58. package/public/app.d.ts +1 -0
  59. package/public/app.js +1057 -1009
  60. package/public/app.js.map +1 -0
  61. package/public/browser.d.ts +68 -0
  62. package/public/browser.js +2 -0
  63. package/public/browser.js.map +1 -0
  64. package/public/index.html +30 -13
  65. package/public/js/about.d.ts +6 -0
  66. package/public/js/about.js +152 -172
  67. package/public/js/about.js.map +1 -0
  68. package/public/js/aiDraft.d.ts +3 -0
  69. package/public/js/aiDraft.js +116 -124
  70. package/public/js/aiDraft.js.map +1 -0
  71. package/public/js/analysisPanel.d.ts +7 -0
  72. package/public/js/analysisPanel.js +166 -166
  73. package/public/js/analysisPanel.js.map +1 -0
  74. package/public/js/analysisReport.d.ts +38 -0
  75. package/public/js/analysisReport.js +105 -110
  76. package/public/js/analysisReport.js.map +1 -0
  77. package/public/js/api.d.ts +26 -0
  78. package/public/js/api.js +176 -167
  79. package/public/js/api.js.map +1 -0
  80. package/public/js/atlas.d.ts +6 -0
  81. package/public/js/atlas.js +50 -58
  82. package/public/js/atlas.js.map +1 -0
  83. package/public/js/blameView.d.ts +2 -0
  84. package/public/js/blameView.js +36 -37
  85. package/public/js/blameView.js.map +1 -0
  86. package/public/js/codeTab.d.ts +3 -0
  87. package/public/js/codeTab.js +175 -180
  88. package/public/js/codeTab.js.map +1 -0
  89. package/public/js/codeViewer.d.ts +5 -0
  90. package/public/js/codeViewer.js +162 -150
  91. package/public/js/codeViewer.js.map +1 -0
  92. package/public/js/components/FileChip.d.ts +9 -0
  93. package/public/js/components/FileChip.js +45 -49
  94. package/public/js/components/FileChip.js.map +1 -0
  95. package/public/js/components/MetricSparkline.d.ts +7 -0
  96. package/public/js/components/MetricSparkline.js +35 -44
  97. package/public/js/components/MetricSparkline.js.map +1 -0
  98. package/public/js/components/RiskBadge.d.ts +9 -0
  99. package/public/js/components/RiskBadge.js +55 -63
  100. package/public/js/components/RiskBadge.js.map +1 -0
  101. package/public/js/deepAnalysisView.d.ts +99 -0
  102. package/public/js/deepAnalysisView.js +282 -302
  103. package/public/js/deepAnalysisView.js.map +1 -0
  104. package/public/js/diagramPane.d.ts +8 -0
  105. package/public/js/diagramPane.js +133 -135
  106. package/public/js/diagramPane.js.map +1 -0
  107. package/public/js/diffView.d.ts +6 -0
  108. package/public/js/diffView.js +222 -209
  109. package/public/js/diffView.js.map +1 -0
  110. package/public/js/docsView.d.ts +2 -0
  111. package/public/js/docsView.js +441 -442
  112. package/public/js/docsView.js.map +1 -0
  113. package/public/js/dom.d.ts +189 -0
  114. package/public/js/dom.js +5 -0
  115. package/public/js/dom.js.map +1 -0
  116. package/public/js/fileSourceBrowser.d.ts +5 -0
  117. package/public/js/fileSourceBrowser.js +33 -41
  118. package/public/js/fileSourceBrowser.js.map +1 -0
  119. package/public/js/flameGraph.d.ts +5 -0
  120. package/public/js/flameGraph.js +62 -72
  121. package/public/js/flameGraph.js.map +1 -0
  122. package/public/js/forceGraph.d.ts +10 -0
  123. package/public/js/forceGraph.js +581 -556
  124. package/public/js/forceGraph.js.map +1 -0
  125. package/public/js/heatmap.d.ts +4 -0
  126. package/public/js/heatmap.js +142 -164
  127. package/public/js/heatmap.js.map +1 -0
  128. package/public/js/highlight.d.ts +2 -0
  129. package/public/js/highlight.js +116 -107
  130. package/public/js/highlight.js.map +1 -0
  131. package/public/js/html.d.ts +2 -0
  132. package/public/js/html.js +14 -44
  133. package/public/js/html.js.map +1 -0
  134. package/public/js/insightsView.d.ts +5 -0
  135. package/public/js/insightsView.js +93 -111
  136. package/public/js/insightsView.js.map +1 -0
  137. package/public/js/inspector.d.ts +37 -0
  138. package/public/js/inspector.js +416 -445
  139. package/public/js/inspector.js.map +1 -0
  140. package/public/js/llm.d.ts +113 -0
  141. package/public/js/llm.js +233 -272
  142. package/public/js/llm.js.map +1 -0
  143. package/public/js/mapView.d.ts +3 -0
  144. package/public/js/mapView.js +121 -120
  145. package/public/js/mapView.js.map +1 -0
  146. package/public/js/markdown.d.ts +3 -0
  147. package/public/js/markdown.js +84 -91
  148. package/public/js/markdown.js.map +1 -0
  149. package/public/js/mindmap.d.ts +32 -0
  150. package/public/js/mindmap.js +244 -259
  151. package/public/js/mindmap.js.map +1 -0
  152. package/public/js/mobilePanels.d.ts +5 -0
  153. package/public/js/mobilePanels.js +50 -0
  154. package/public/js/mobilePanels.js.map +1 -0
  155. package/public/js/repoFiles.d.ts +12 -0
  156. package/public/js/repoFiles.js +28 -27
  157. package/public/js/repoFiles.js.map +1 -0
  158. package/public/js/reviewView.d.ts +28 -0
  159. package/public/js/reviewView.js +256 -0
  160. package/public/js/reviewView.js.map +1 -0
  161. package/public/js/sbomView.d.ts +3 -0
  162. package/public/js/sbomView.js +59 -67
  163. package/public/js/sbomView.js.map +1 -0
  164. package/public/js/scanCache.d.ts +16 -0
  165. package/public/js/scanCache.js +67 -65
  166. package/public/js/scanCache.js.map +1 -0
  167. package/public/js/search.d.ts +7 -0
  168. package/public/js/search.js +868 -800
  169. package/public/js/search.js.map +1 -0
  170. package/public/js/searchLocal.d.ts +42 -0
  171. package/public/js/searchLocal.js +87 -0
  172. package/public/js/searchLocal.js.map +1 -0
  173. package/public/js/serverSettings.d.ts +5 -0
  174. package/public/js/serverSettings.js +153 -164
  175. package/public/js/serverSettings.js.map +1 -0
  176. package/public/js/state.d.ts +798 -0
  177. package/public/js/state.js +114 -122
  178. package/public/js/state.js.map +1 -0
  179. package/public/js/tour.d.ts +6 -0
  180. package/public/js/tour.js +5 -7
  181. package/public/js/tour.js.map +1 -0
  182. package/public/js/transitions.d.ts +2 -0
  183. package/public/js/transitions.js +9 -10
  184. package/public/js/transitions.js.map +1 -0
  185. package/public/js/tree.d.ts +12 -0
  186. package/public/js/tree.js +157 -154
  187. package/public/js/tree.js.map +1 -0
  188. package/public/js/workflowsView.d.ts +3 -0
  189. package/public/js/workflowsView.js +15 -22
  190. package/public/js/workflowsView.js.map +1 -0
  191. package/public/search.css +495 -0
  192. package/public/styles.css +163 -346
  193. package/public/workbench.css +166 -0
  194. package/server/apiAuth.d.ts +3 -0
  195. package/server/apiAuth.js +18 -20
  196. package/server/apiAuth.js.map +1 -0
  197. package/server/apiDiff.d.ts +2 -0
  198. package/server/apiDiff.js +20 -18
  199. package/server/apiDiff.js.map +1 -0
  200. package/server/apiDocs.d.ts +2 -0
  201. package/server/apiDocs.js +38 -40
  202. package/server/apiDocs.js.map +1 -0
  203. package/server/apiFile.d.ts +1 -0
  204. package/server/apiFile.js +14 -17
  205. package/server/apiFile.js.map +1 -0
  206. package/server/apiGitBlame.d.ts +2 -0
  207. package/server/apiGitBlame.js +50 -51
  208. package/server/apiGitBlame.js.map +1 -0
  209. package/server/apiMcp.d.ts +4 -0
  210. package/server/apiMcp.js +64 -66
  211. package/server/apiMcp.js.map +1 -0
  212. package/server/apiReview.d.ts +2 -0
  213. package/server/apiReview.js +45 -0
  214. package/server/apiReview.js.map +1 -0
  215. package/server/apiScan.d.ts +5 -0
  216. package/server/apiScan.js +90 -71
  217. package/server/apiScan.js.map +1 -0
  218. package/server/apiSearch.d.ts +33 -0
  219. package/server/apiSearch.js +184 -200
  220. package/server/apiSearch.js.map +1 -0
  221. package/server/apiSettings.d.ts +4 -0
  222. package/server/apiSettings.js +84 -85
  223. package/server/apiSettings.js.map +1 -0
  224. package/server/apiTools.d.ts +3 -0
  225. package/server/apiTools.js +55 -56
  226. package/server/apiTools.js.map +1 -0
  227. package/server/auth.d.ts +5 -0
  228. package/server/auth.js +54 -51
  229. package/server/auth.js.map +1 -0
  230. package/server/config.d.ts +42 -0
  231. package/server/config.js +189 -194
  232. package/server/config.js.map +1 -0
  233. package/server/daemon.d.ts +12 -0
  234. package/server/daemon.js +124 -114
  235. package/server/daemon.js.map +1 -0
  236. package/server/fileSourceNode.d.ts +2 -0
  237. package/server/fileSourceNode.js +34 -36
  238. package/server/fileSourceNode.js.map +1 -0
  239. package/server/gitClone.d.ts +9 -0
  240. package/server/gitClone.js +59 -61
  241. package/server/gitClone.js.map +1 -0
  242. package/server/gitDiff.d.ts +36 -0
  243. package/server/gitDiff.js +271 -169
  244. package/server/gitDiff.js.map +1 -0
  245. package/server/gitHistory.d.ts +12 -0
  246. package/server/gitHistory.js +80 -80
  247. package/server/gitHistory.js.map +1 -0
  248. package/server/gitRemote.d.ts +12 -0
  249. package/server/gitRemote.js +37 -38
  250. package/server/gitRemote.js.map +1 -0
  251. package/server/htmlText.d.ts +4 -0
  252. package/server/htmlText.js +16 -23
  253. package/server/htmlText.js.map +1 -0
  254. package/server/http.d.ts +5 -0
  255. package/server/http.js +27 -30
  256. package/server/http.js.map +1 -0
  257. package/server/httpGuards.d.ts +2 -0
  258. package/server/httpGuards.js +47 -43
  259. package/server/httpGuards.js.map +1 -0
  260. package/server/https.d.ts +34 -0
  261. package/server/https.js +121 -111
  262. package/server/https.js.map +1 -0
  263. package/server/index.d.ts +40 -0
  264. package/server/index.js +139 -147
  265. package/server/index.js.map +1 -0
  266. package/server/layout.d.ts +26 -0
  267. package/server/layout.js +59 -64
  268. package/server/layout.js.map +1 -0
  269. package/server/llmProxy.d.ts +3 -0
  270. package/server/llmProxy.js +137 -135
  271. package/server/llmProxy.js.map +1 -0
  272. package/server/logger.d.ts +38 -0
  273. package/server/logger.js +198 -211
  274. package/server/logger.js.map +1 -0
  275. package/server/mcp/analysis.d.ts +29 -0
  276. package/server/mcp/analysis.js +99 -120
  277. package/server/mcp/analysis.js.map +1 -0
  278. package/server/mcp/http.d.ts +9 -0
  279. package/server/mcp/http.js +128 -137
  280. package/server/mcp/http.js.map +1 -0
  281. package/server/mcp/runner.d.ts +68 -0
  282. package/server/mcp/runner.js +208 -218
  283. package/server/mcp/runner.js.map +1 -0
  284. package/server/mcp/server.d.ts +29 -0
  285. package/server/mcp/server.js +166 -179
  286. package/server/mcp/server.js.map +1 -0
  287. package/server/mcp/standalone.d.ts +1 -0
  288. package/server/mcp/standalone.js +8 -10
  289. package/server/mcp/standalone.js.map +1 -0
  290. package/server/mcp/tools.d.ts +2020 -0
  291. package/server/mcp/tools.js +523 -574
  292. package/server/mcp/tools.js.map +1 -0
  293. package/server/paths.d.ts +3 -0
  294. package/server/paths.js +17 -15
  295. package/server/paths.js.map +1 -0
  296. package/server/pidfile.d.ts +9 -0
  297. package/server/pidfile.js +43 -45
  298. package/server/pidfile.js.map +1 -0
  299. package/server/review.d.ts +5 -0
  300. package/server/review.js +35 -0
  301. package/server/review.js.map +1 -0
  302. package/server/router.d.ts +33 -0
  303. package/server/router.js +191 -197
  304. package/server/router.js.map +1 -0
  305. package/server/scanRequest.d.ts +3 -0
  306. package/server/scanRequest.js +36 -0
  307. package/server/scanRequest.js.map +1 -0
  308. package/server/searchIndex.d.ts +17 -0
  309. package/server/searchIndex.js +83 -80
  310. package/server/searchIndex.js.map +1 -0
  311. package/server/sessions.d.ts +14 -0
  312. package/server/sessions.js +62 -70
  313. package/server/sessions.js.map +1 -0
  314. package/server/startup.d.ts +118 -0
  315. package/server/startup.js +143 -148
  316. package/server/startup.js.map +1 -0
  317. package/server/static.d.ts +6 -0
  318. package/server/static.js +28 -31
  319. package/server/static.js.map +1 -0
  320. package/server/tools/install.d.ts +4 -0
  321. package/server/tools/install.js +176 -185
  322. package/server/tools/install.js.map +1 -0
  323. package/server/tools/parse.d.ts +8 -0
  324. package/server/tools/parse.js +112 -114
  325. package/server/tools/parse.js.map +1 -0
  326. package/server/tools/platform.d.ts +9 -0
  327. package/server/tools/platform.js +49 -47
  328. package/server/tools/platform.js.map +1 -0
  329. package/server/tools/registry.d.ts +137 -0
  330. package/server/tools/registry.js +173 -172
  331. package/server/tools/registry.js.map +1 -0
  332. package/server/tools/scan.d.ts +34 -0
  333. package/server/tools/scan.js +89 -98
  334. package/server/tools/scan.js.map +1 -0
  335. package/server/tools.d.ts +34 -0
  336. package/server/tools.js +94 -107
  337. package/server/tools.js.map +1 -0
  338. package/server/tunnel.d.ts +29 -0
  339. package/server/tunnel.js +61 -66
  340. package/server/tunnel.js.map +1 -0
  341. package/shared/analyzer/docs.d.ts +16 -0
  342. package/shared/analyzer/docs.js +99 -93
  343. package/shared/analyzer/docs.js.map +1 -0
  344. package/shared/analyzer/explainLocal.d.ts +4 -0
  345. package/shared/analyzer/explainLocal.js +122 -142
  346. package/shared/analyzer/explainLocal.js.map +1 -0
  347. package/shared/analyzer/github.d.ts +37 -0
  348. package/shared/analyzer/github.js +41 -43
  349. package/shared/analyzer/github.js.map +1 -0
  350. package/shared/analyzer/graph.d.ts +41 -0
  351. package/shared/analyzer/graph.js +393 -400
  352. package/shared/analyzer/graph.js.map +1 -0
  353. package/shared/analyzer/health.d.ts +33 -0
  354. package/shared/analyzer/health.js +197 -193
  355. package/shared/analyzer/health.js.map +1 -0
  356. package/shared/analyzer/history.d.ts +29 -0
  357. package/shared/analyzer/history.js +110 -111
  358. package/shared/analyzer/history.js.map +1 -0
  359. package/shared/analyzer/jsonConfig.d.ts +2 -0
  360. package/shared/analyzer/jsonConfig.js +61 -0
  361. package/shared/analyzer/jsonConfig.js.map +1 -0
  362. package/shared/analyzer/languages/csharp.d.ts +26 -0
  363. package/shared/analyzer/languages/csharp.js +83 -88
  364. package/shared/analyzer/languages/csharp.js.map +1 -0
  365. package/shared/analyzer/languages/generic.d.ts +23 -0
  366. package/shared/analyzer/languages/generic.js +94 -105
  367. package/shared/analyzer/languages/generic.js.map +1 -0
  368. package/shared/analyzer/languages/go.d.ts +24 -0
  369. package/shared/analyzer/languages/go.js +45 -54
  370. package/shared/analyzer/languages/go.js.map +1 -0
  371. package/shared/analyzer/languages/index.d.ts +6 -0
  372. package/shared/analyzer/languages/index.js +19 -20
  373. package/shared/analyzer/languages/index.js.map +1 -0
  374. package/shared/analyzer/languages/java.d.ts +20 -0
  375. package/shared/analyzer/languages/java.js +73 -78
  376. package/shared/analyzer/languages/java.js.map +1 -0
  377. package/shared/analyzer/languages/javascript.d.ts +12 -0
  378. package/shared/analyzer/languages/javascript.js +258 -209
  379. package/shared/analyzer/languages/javascript.js.map +1 -0
  380. package/shared/analyzer/languages/python.d.ts +23 -0
  381. package/shared/analyzer/languages/python.js +92 -100
  382. package/shared/analyzer/languages/python.js.map +1 -0
  383. package/shared/analyzer/languages/rust.d.ts +17 -0
  384. package/shared/analyzer/languages/rust.js +96 -98
  385. package/shared/analyzer/languages/rust.js.map +1 -0
  386. package/shared/analyzer/languages/typescript.d.ts +11 -0
  387. package/shared/analyzer/languages/typescript.js +117 -116
  388. package/shared/analyzer/languages/typescript.js.map +1 -0
  389. package/shared/analyzer/licenses.d.ts +18 -0
  390. package/shared/analyzer/licenses.js +130 -133
  391. package/shared/analyzer/licenses.js.map +1 -0
  392. package/shared/analyzer/metrics.d.ts +20 -0
  393. package/shared/analyzer/metrics.js +112 -104
  394. package/shared/analyzer/metrics.js.map +1 -0
  395. package/shared/analyzer/pathUtil.d.ts +10 -0
  396. package/shared/analyzer/pathUtil.js +29 -35
  397. package/shared/analyzer/pathUtil.js.map +1 -0
  398. package/shared/analyzer/patterns.d.ts +11 -0
  399. package/shared/analyzer/patterns.js +231 -253
  400. package/shared/analyzer/patterns.js.map +1 -0
  401. package/shared/analyzer/scan.d.ts +62 -0
  402. package/shared/analyzer/scan.js +451 -442
  403. package/shared/analyzer/scan.js.map +1 -0
  404. package/shared/analyzer/scanControl.d.ts +11 -0
  405. package/shared/analyzer/scanControl.js +62 -0
  406. package/shared/analyzer/scanControl.js.map +1 -0
  407. package/shared/analyzer/security.d.ts +18 -0
  408. package/shared/analyzer/security.js +159 -159
  409. package/shared/analyzer/security.js.map +1 -0
  410. package/shared/analyzer/services.d.ts +5 -0
  411. package/shared/analyzer/services.js +141 -138
  412. package/shared/analyzer/services.js.map +1 -0
  413. package/shared/analyzer/stack.d.ts +6 -0
  414. package/shared/analyzer/stack.js +134 -141
  415. package/shared/analyzer/stack.js.map +1 -0
  416. package/shared/analyzer/tour.d.ts +2 -0
  417. package/shared/analyzer/tour.js +45 -53
  418. package/shared/analyzer/tour.js.map +1 -0
  419. package/shared/analyzer/util.d.ts +8 -0
  420. package/shared/analyzer/util.js +130 -76
  421. package/shared/analyzer/util.js.map +1 -0
  422. package/shared/analyzer/workflows.d.ts +8 -0
  423. package/shared/analyzer/workflows.js +139 -134
  424. package/shared/analyzer/workflows.js.map +1 -0
  425. package/shared/contracts.d.ts +250 -0
  426. package/shared/contracts.js +2 -0
  427. package/shared/contracts.js.map +1 -0
  428. package/shared/diagram/aiFacts.d.ts +35 -0
  429. package/shared/diagram/aiFacts.js +102 -105
  430. package/shared/diagram/aiFacts.js.map +1 -0
  431. package/shared/diagram/aiMermaid.d.ts +5 -0
  432. package/shared/diagram/aiMermaid.js +46 -45
  433. package/shared/diagram/aiMermaid.js.map +1 -0
  434. package/shared/diagram/atlas.d.ts +16 -0
  435. package/shared/diagram/atlas.js +51 -60
  436. package/shared/diagram/atlas.js.map +1 -0
  437. package/shared/diagram/mermaid.d.ts +43 -0
  438. package/shared/diagram/mermaid.js +348 -370
  439. package/shared/diagram/mermaid.js.map +1 -0
  440. package/shared/review/contracts.d.ts +95 -0
  441. package/shared/review/contracts.js +2 -0
  442. package/shared/review/contracts.js.map +1 -0
  443. package/shared/review/review.d.ts +10 -0
  444. package/shared/review/review.js +184 -0
  445. package/shared/review/review.js.map +1 -0
  446. package/shared/search/query.d.ts +32 -0
  447. package/shared/search/query.js +395 -388
  448. package/shared/search/query.js.map +1 -0
package/cli/commands.js CHANGED
@@ -4,16 +4,11 @@
4
4
  //
5
5
  // Every command returns an exit code instead of calling process.exit — the
6
6
  // bin wrapper is the only place that touches the process.
7
-
8
7
  import fs from 'node:fs';
9
8
  import fsp from 'node:fs/promises';
10
9
  import net from 'node:net';
11
10
  import { spawn } from 'node:child_process';
12
-
13
- import {
14
- DEFAULT_SETTINGS, configPath, configExists, readSettings, writeSettings,
15
- publicSettings, serverUrls, maskAccessKey, isLoopbackHost,
16
- } from '../server/config.js';
11
+ import { DEFAULT_SETTINGS, configPath, configExists, readSettings, writeSettings, publicSettings, serverUrls, maskAccessKey, isLoopbackHost, } from '../server/config.js';
17
12
  import { startServer } from '../server/index.js';
18
13
  import { pidIsAlive, readPidFile, removePidFile, readRunInfo, runInfoPath } from '../server/pidfile.js';
19
14
  import { logPath, rotateLogIfNeeded, tailLog, followLog, logExists, spawnDetached, waitForPidFile, waitForReady } from '../server/daemon.js';
@@ -23,165 +18,164 @@ import { caddyRun, caddyValidate, httpsReadiness, httpsStatus, writeCaddyfile }
23
18
  import { bold, cyan, dim, ok, warn, bad, paint, kv, tick, cross, dash, welcomeBanner, panel, row, hint } from './ui.js';
24
19
  import { buildSteps, unansweredSteps, defaultOf, applyFlags, answersToSettings, summaryLines } from './wizard.js';
25
20
  import { runSteps, WizardCancelled } from './prompt.js';
26
-
27
21
  // ---------------------------------------------------------------- setup ---
28
-
29
22
  export async function runSetup({ flags = {}, out = console.log, err = console.error, version = '' } = {}) {
30
- const file = flags.config || configPath();
31
- const existed = await configExists(file);
32
- const current = existed ? await readSettings(file) : { ...DEFAULT_SETTINGS };
33
-
34
- // Non-interactive (or a piped stdout): flags are the answers, defaults fill
35
- // the rest, and the summary is printed instead of asked about.
36
- if (flags.nonInteractive || !process.stdout.isTTY) {
37
- const { settings } = applyFlags(current, flags);
23
+ const file = flags.config || configPath();
24
+ const existed = await configExists(file);
25
+ const current = existed ? await readSettings(file) : { ...DEFAULT_SETTINGS };
26
+ // Non-interactive (or a piped stdout): flags are the answers, defaults fill
27
+ // the rest, and the summary is printed instead of asked about.
28
+ if (flags.nonInteractive || !process.stdout.isTTY) {
29
+ const { settings } = applyFlags(current, flags);
30
+ await writeSettings(settings, file);
31
+ if (flags.json) {
32
+ out(JSON.stringify({ configFile: file, created: !existed, settings: publicSettings(settings) }, null, 2));
33
+ }
34
+ else {
35
+ out(tick + `Wrote ${file}`);
36
+ printSummary(out, settings, { revealKey: Boolean(patchHasNewKey(flags)) });
37
+ }
38
+ await printTunnelFollowup(out, settings);
39
+ // `start` may be the flag `--start` (true) or the mode a subcommand asked
40
+ // for ('background') — both mean "boot it now", differently.
41
+ if (flags.start === 'background')
42
+ return runStartBackground({ flags, out, err });
43
+ if (flags.start)
44
+ return runStart({ flags, out, err });
45
+ return 0;
46
+ }
47
+ out(welcomeBanner(version, file));
48
+ if (existed)
49
+ out(dim(' A config already exists — answers start from what it says.\n'));
50
+ // Flag answers go in first; the wizard only asks what is left.
51
+ const seed = {};
52
+ for (const id of ['name', 'email', 'mode', 'host', 'port', 'domain', 'https', 'autoOpen']) {
53
+ if (flags[id] !== undefined)
54
+ seed[id] = flags[id];
55
+ }
56
+ if (flags.provider !== undefined)
57
+ seed.provider = flags.provider;
58
+ if (flags.baseUrl !== undefined)
59
+ seed.baseUrl = flags.baseUrl;
60
+ if (flags.model !== undefined)
61
+ seed.model = flags.model;
62
+ let answers;
63
+ try {
64
+ answers = await runSteps(unansweredSteps(buildSteps(current), seed), seed);
65
+ }
66
+ catch (e) {
67
+ if (e instanceof WizardCancelled) {
68
+ err('\n Setup cancelled — nothing was written.');
69
+ return 130;
70
+ }
71
+ throw e;
72
+ }
73
+ let settings;
74
+ try {
75
+ settings = answersToSettings(current, answers);
76
+ }
77
+ catch (e) {
78
+ err(bad(' Those answers do not make a valid config: ' + e.message));
79
+ return 1;
80
+ }
81
+ out('');
82
+ printSummary(out, settings, { revealKey: false });
83
+ out('');
84
+ const confirmed = await confirm(out, 'Write this config?');
85
+ if (!confirmed) {
86
+ err(' Not written.');
87
+ return 1;
88
+ }
38
89
  await writeSettings(settings, file);
39
- if (flags.json) {
40
- out(JSON.stringify({ configFile: file, created: !existed, settings: publicSettings(settings) }, null, 2));
41
- } else {
42
- out(tick + `Wrote ${file}`);
43
- printSummary(out, settings, { revealKey: Boolean(patchHasNewKey(flags)) });
90
+ out(tick + 'Wrote ' + file);
91
+ // The generated key exists exactly once in a readable form: right now.
92
+ if (answers.keyChoice === 'generate' && settings.accessKey) {
93
+ out('');
94
+ out(warn(' Your access key — shown this once, store it somewhere safe:'));
95
+ out(' ' + bold(settings.accessKey));
44
96
  }
45
97
  await printTunnelFollowup(out, settings);
46
- // `start` may be the flag `--start` (true) or the mode a subcommand asked
47
- // for ('background') — both mean "boot it now", differently.
48
- if (flags.start === 'background') return runStartBackground({ flags, out, err });
49
- if (flags.start) return runStart({ flags, out, err });
98
+ if (flags.start === 'background' || (!flags.start && await confirm(out, 'Start Onboarder now?', true))) {
99
+ out('');
100
+ return flags.start === 'background' ? runStartBackground({ flags, out, err }) : runStart({ flags, out, err });
101
+ }
102
+ out(dim(settings.https
103
+ ? ' Later: `onboarder start` (starts Caddy automatically), or `onboarder https status`'
104
+ : ' Later: `onboarder start`'));
50
105
  return 0;
51
- }
52
-
53
- out(welcomeBanner(version, file));
54
- if (existed) out(dim(' A config already exists — answers start from what it says.\n'));
55
-
56
- // Flag answers go in first; the wizard only asks what is left.
57
- const seed = {};
58
- for (const id of ['name', 'email', 'mode', 'host', 'port', 'domain', 'https', 'autoOpen']) {
59
- if (flags[id] !== undefined) seed[id] = flags[id];
60
- }
61
- if (flags.provider !== undefined) seed.provider = flags.provider;
62
- if (flags.baseUrl !== undefined) seed.baseUrl = flags.baseUrl;
63
- if (flags.model !== undefined) seed.model = flags.model;
64
-
65
- let answers;
66
- try {
67
- answers = await runSteps(unansweredSteps(buildSteps(current), seed), seed);
68
- } catch (e) {
69
- if (e instanceof WizardCancelled) {
70
- err('\n Setup cancelled — nothing was written.');
71
- return 130;
72
- }
73
- throw e;
74
- }
75
-
76
- let settings;
77
- try {
78
- settings = answersToSettings(current, answers);
79
- } catch (e) {
80
- err(bad(' Those answers do not make a valid config: ' + e.message));
81
- return 1;
82
- }
83
-
84
- out('');
85
- printSummary(out, settings, { revealKey: false });
86
- out('');
87
- const confirmed = await confirm(out, 'Write this config?');
88
- if (!confirmed) {
89
- err(' Not written.');
90
- return 1;
91
- }
92
- await writeSettings(settings, file);
93
- out(tick + 'Wrote ' + file);
94
-
95
- // The generated key exists exactly once in a readable form: right now.
96
- if (answers.keyChoice === 'generate' && settings.accessKey) {
97
- out('');
98
- out(warn(' Your access key — shown this once, store it somewhere safe:'));
99
- out(' ' + bold(settings.accessKey));
100
- }
101
- await printTunnelFollowup(out, settings);
102
-
103
- if (flags.start === 'background' || (!flags.start && await confirm(out, 'Start Onboarder now?', true))) {
104
- out('');
105
- return flags.start === 'background' ? runStartBackground({ flags, out, err }) : runStart({ flags, out, err });
106
- }
107
- out(dim(settings.https
108
- ? ' Later: `onboarder start` (starts Caddy automatically), or `onboarder https status`'
109
- : ' Later: `onboarder start`'));
110
- return 0;
111
106
  }
112
-
113
107
  function patchHasNewKey(flags) {
114
- return flags.accessKey === 'generate' || (flags.accessKey === undefined && flags.mode === 'self-hosted');
108
+ return flags.accessKey === 'generate' || (flags.accessKey === undefined && flags.mode === 'self-hosted');
115
109
  }
116
-
117
110
  function printSummary(out, settings, { revealKey = false } = {}) {
118
- out(bold(' About to write:'));
119
- for (const [label, value] of summaryLines(settings, { revealKey })) out(kv(label, String(value)));
111
+ out(bold(' About to write:'));
112
+ for (const [label, value] of summaryLines(settings, { revealKey }))
113
+ out(kv(label, String(value)));
120
114
  }
121
-
122
115
  async function confirm(out, question, fallback = false) {
123
- const { default: readline } = await import('node:readline/promises');
124
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
125
- try {
126
- const typed = await rl.question(` ${bold(question)} ${dim(fallback ? '(Y/n)' : '(y/N)')} `);
127
- const v = typed.trim().toLowerCase();
128
- return v ? v === 'y' || v === 'yes' : fallback;
129
- } finally {
130
- rl.close();
131
- }
116
+ const { default: readline } = await import('node:readline/promises');
117
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
118
+ try {
119
+ const typed = await rl.question(` ${bold(question)} ${dim(fallback ? '(Y/n)' : '(y/N)')} `);
120
+ const v = typed.trim().toLowerCase();
121
+ return v ? v === 'y' || v === 'yes' : fallback;
122
+ }
123
+ finally {
124
+ rl.close();
125
+ }
132
126
  }
133
-
134
127
  async function printTunnelFollowup(out, settings) {
135
- const status = tunnelStatus(settings);
136
- for (const name of ['cloudflare', 'tailscale']) {
137
- const t = status[name];
138
- if (!t.enabled) continue;
139
- out('');
140
- if (t.installed) {
141
- out(` ${name} — start the tunnel with:`);
142
- out(' ' + cyan(t.command));
143
- out(dim(` or just: onboarder tunnel ${name}`));
144
- } else {
145
- out(warn(` ${name} is enabled but its CLI is not installed.`));
146
- out(dim(' Install: ' + t.install));
147
- }
148
- }
128
+ const status = tunnelStatus(settings);
129
+ for (const name of ['cloudflare', 'tailscale']) {
130
+ const t = status[name];
131
+ if (!t.enabled)
132
+ continue;
133
+ out('');
134
+ if (t.installed) {
135
+ out(` ${name} — start the tunnel with:`);
136
+ out(' ' + cyan(t.command));
137
+ out(dim(` or just: onboarder tunnel ${name}`));
138
+ }
139
+ else {
140
+ out(warn(` ${name} is enabled but its CLI is not installed.`));
141
+ out(dim(' Install: ' + t.install));
142
+ }
143
+ }
149
144
  }
150
-
151
145
  // ---------------------------------------------------------------- start ---
152
-
153
146
  // What the person is about to be looking at, as a titled panel instead of a
154
147
  // loose run of lines. One function serves the foreground banner and the
155
148
  // background confirmation, so "what does `onboarder start` tell me" has exactly
156
149
  // one answer no matter which mode you used.
157
150
  export function serverDetails(started, { configFile, pid = null, mode = 'foreground', logFile = '' } = {}) {
158
- const { settings, host, port } = started;
159
- const urls = serverUrls(settings);
160
- const rows = [
161
- row('URL', bold(cyan(urls.local))),
162
- row('Bind', `${host}:${port}`),
163
- row('Mode', settings.mode === 'self-hosted'
164
- ? (isLoopbackHost(host) ? 'self-hosted (loopback — use a tunnel for remote access)' : 'self-hosted (network reachable, access key required)')
165
- : 'local (this machine only)'),
166
- ];
167
- if (urls.network) rows.push(row('Network', urls.network));
168
- if (urls.domain) rows.push(row('Public', urls.domain));
169
- if (settings.mode === 'self-hosted') {
170
- rows.push(row('Access key', settings.accessKey ? maskAccessKey(settings.accessKey) : bad('NOT SET — every API call is refused')));
171
- }
172
- rows.push(row('PID', String(pid ?? process.pid)));
173
- rows.push(row('Config', dim(configFile || '')));
174
- if (logFile) rows.push(row('Logs', dim(logFile)));
175
- return { rows, urls, mode };
151
+ const { settings, host, port } = started;
152
+ const urls = serverUrls(settings);
153
+ const rows = [
154
+ row('URL', bold(cyan(urls.local))),
155
+ row('Bind', `${host}:${port}`),
156
+ row('Mode', settings.mode === 'self-hosted'
157
+ ? (isLoopbackHost(host) ? 'self-hosted (loopback — use a tunnel for remote access)' : 'self-hosted (network reachable, access key required)')
158
+ : 'local (this machine only)'),
159
+ ];
160
+ if (urls.network)
161
+ rows.push(row('Network', urls.network));
162
+ if (urls.domain)
163
+ rows.push(row('Public', urls.domain));
164
+ if (settings.mode === 'self-hosted') {
165
+ rows.push(row('Access key', settings.accessKey ? maskAccessKey(settings.accessKey) : bad('NOT SET — every API call is refused')));
166
+ }
167
+ rows.push(row('PID', String(pid ?? process.pid)));
168
+ rows.push(row('Config', dim(configFile || '')));
169
+ if (logFile)
170
+ rows.push(row('Logs', dim(logFile)));
171
+ return { rows, urls, mode };
176
172
  }
177
-
178
173
  function printServerDetails(out, started, options) {
179
- const { rows } = serverDetails(started, options);
180
- out('');
181
- out(panel('Onboarder is running', rows));
182
- out('');
174
+ const { rows } = serverDetails(started, options);
175
+ out('');
176
+ out(panel('Onboarder is running', rows));
177
+ out('');
183
178
  }
184
-
185
179
  // Keep a foreground panel at the terminal's current width.
186
180
  //
187
181
  // Resizing a window after the server started used to leave a panel whose right
@@ -194,765 +188,796 @@ function printServerDetails(out, started, options) {
194
188
  // A non-TTY (a pipe, a log file) never redraws: there is no cursor to move and
195
189
  // re-printing would just duplicate the block.
196
190
  export function watchResize(render, stream = process.stdout) {
197
- if (!stream.isTTY || typeof process.stdout.on !== 'function') return () => {};
198
- let previous = '';
199
- const onResize = () => {
200
- const next = render();
201
- if (next === previous) return;
202
- const lines = previous ? previous.split('\n').length : 0;
203
- // Up over the old block, clear it, print the new one. `\x1b[J` clears from
204
- // the cursor to the end of the screen, which is exactly the old block.
205
- stream.write(lines ? `\x1b[${lines}A\x1b[J` : '');
206
- stream.write(next);
207
- previous = next;
208
- };
209
- process.stdout.on('SIGWINCH', onResize);
210
- return () => process.stdout.removeListener('SIGWINCH', onResize);
191
+ if (!stream.isTTY || typeof process.stdout.on !== 'function')
192
+ return (..._args) => { };
193
+ let previous = '';
194
+ const onResize = () => {
195
+ const next = render();
196
+ if (next === previous)
197
+ return;
198
+ const lines = previous ? previous.split('\n').length : 0;
199
+ // Up over the old block, clear it, print the new one. `\x1b[J` clears from
200
+ // the cursor to the end of the screen, which is exactly the old block.
201
+ stream.write(lines ? `\x1b[${lines}A\x1b[J` : '');
202
+ stream.write(next);
203
+ previous = next;
204
+ };
205
+ process.stdout.on('SIGWINCH', onResize);
206
+ return () => process.stdout.removeListener('SIGWINCH', onResize);
211
207
  }
212
-
213
208
  export async function runStart({ flags = {}, out = console.log, err = console.error } = {}) {
214
- const file = flags.config || configPath();
215
- if (!await configExists(file)) {
216
- // No config yet: on a terminal the kind thing is to run setup first; in a
217
- // script the predictable thing is to boot the defaults (which are the
218
- // pre-settings behavior) and say so.
219
- if (process.stdout.isTTY && !flags.nonInteractive) {
220
- out(dim(' No settings yet — running setup first.'));
221
- return runSetup({ flags: { ...flags, start: true }, out, err });
222
- }
223
- out(dim(` No config at ${file} — starting with defaults (local mode).`));
224
- }
225
- const recorded = readPidFile(file);
226
- if (recorded && pidIsAlive(recorded)) {
227
- err(` Onboarder is already running (PID ${recorded}).`);
228
- err(dim(' Use `onboarder status`, `onboarder stop`, or `onboarder restart`.'));
229
- return 1;
230
- }
231
- if (recorded) removePidFile(file, recorded);
232
-
233
- // `quiet`: the CLI prints its own details panel below, so the loose banner
234
- // would be the same facts twice. `node server/index.js` still gets the banner.
235
- const started = await startServer({ configFile: file, log: () => {}, openBrowser: false });
236
- if (started.settings.https) {
209
+ const file = flags.config || configPath();
210
+ if (!await configExists(file)) {
211
+ // No config yet: on a terminal the kind thing is to run setup first; in a
212
+ // script the predictable thing is to boot the defaults (which are the
213
+ // pre-settings behavior) and say so.
214
+ if (process.stdout.isTTY && !flags.nonInteractive) {
215
+ out(dim(' No settings yet — running setup first.'));
216
+ return runSetup({ flags: { ...flags, start: true }, out, err });
217
+ }
218
+ out(dim(` No config at ${file} — starting with defaults (local mode).`));
219
+ }
220
+ const recorded = readPidFile(file);
221
+ if (recorded && pidIsAlive(recorded)) {
222
+ err(` Onboarder is already running (PID ${recorded}).`);
223
+ err(dim(' Use `onboarder status`, `onboarder stop`, or `onboarder restart`.'));
224
+ return 1;
225
+ }
226
+ if (recorded)
227
+ removePidFile(file, recorded);
228
+ // `quiet`: the CLI prints its own details panel below, so the loose banner
229
+ // would be the same facts twice. `node server/index.js` still gets the banner.
230
+ const started = await startServer({ configFile: file, log: (..._args) => { }, openBrowser: false });
231
+ if (started.settings.https) {
232
+ out('');
233
+ const httpsCode = await runHttps('setup', { flags, out: flags.json ? (..._args) => { } : out, err });
234
+ if (httpsCode !== 0) {
235
+ await new Promise((resolve) => started.server.close(resolve));
236
+ return httpsCode;
237
+ }
238
+ }
239
+ if (flags.json)
240
+ out(JSON.stringify({ host: started.host, port: started.port, url: serverUrls(started.settings).local }));
241
+ // `start` foreground prints the details itself rather than letting
242
+ // `startServer` print the old loose banner — the panel replaces it. The
243
+ // background child lands here too (that is the point of reusing this path),
244
+ // and ONBOARDER_BACKGROUND is what tells the two apart: one is attached to a
245
+ // terminal you are about to close, the other is already detached from it.
246
+ printServerDetails(out, started, { configFile: file, mode: 'foreground' });
247
+ // Redraw the panel when the terminal is resized, so the border stays on screen
248
+ // and long paths re-elide to the new width instead of hanging off the edge.
249
+ watchResize(() => '\n' + panel('Onboarder is running', serverDetails(started, { configFile: file }).rows) + '\n');
250
+ if (process.env.ONBOARDER_LAUNCH) {
251
+ // Started by launchd/systemd/the Startup folder: these lines are going into
252
+ // a log file nobody is watching, so they say what the supervisor is doing
253
+ // rather than telling a person to press Ctrl-C.
254
+ out(dim(` Launched at login by ${process.env.ONBOARDER_LAUNCH}. This process is supervised — stop it with \`onboarder stop\`.`));
255
+ }
256
+ else if (process.env.ONBOARDER_BACKGROUND) {
257
+ out(dim(' Started in the background — this process is now independent of any terminal.'));
258
+ }
259
+ else {
260
+ out(dim(' Running in the foreground. Ctrl-C stops it; closing this terminal stops it too.'));
261
+ out(dim(' To keep it alive after you close the terminal: ') + cyan('onboarder start background'));
262
+ }
237
263
  out('');
238
- const httpsCode = await runHttps('setup', { flags, out: flags.json ? () => {} : out, err });
239
- if (httpsCode !== 0) {
240
- await new Promise((resolve) => started.server.close(resolve));
241
- return httpsCode;
242
- }
243
- }
244
- if (flags.json) out(JSON.stringify({ host: started.host, port: started.port, url: serverUrls(started.settings).local }));
245
- // `start` foreground prints the details itself rather than letting
246
- // `startServer` print the old loose banner — the panel replaces it. The
247
- // background child lands here too (that is the point of reusing this path),
248
- // and ONBOARDER_BACKGROUND is what tells the two apart: one is attached to a
249
- // terminal you are about to close, the other is already detached from it.
250
- printServerDetails(out, started, { configFile: file, mode: 'foreground' });
251
- // Redraw the panel when the terminal is resized, so the border stays on screen
252
- // and long paths re-elide to the new width instead of hanging off the edge.
253
- watchResize(() => '\n' + panel('Onboarder is running', serverDetails(started, { configFile: file }).rows) + '\n');
254
- if (process.env.ONBOARDER_LAUNCH) {
255
- // Started by launchd/systemd/the Startup folder: these lines are going into
256
- // a log file nobody is watching, so they say what the supervisor is doing
257
- // rather than telling a person to press Ctrl-C.
258
- out(dim(` Launched at login by ${process.env.ONBOARDER_LAUNCH}. This process is supervised — stop it with \`onboarder stop\`.`));
259
- } else if (process.env.ONBOARDER_BACKGROUND) {
260
- out(dim(' Started in the background — this process is now independent of any terminal.'));
261
- } else {
262
- out(dim(' Running in the foreground. Ctrl-C stops it; closing this terminal stops it too.'));
263
- out(dim(' To keep it alive after you close the terminal: ') + cyan('onboarder start background'));
264
- }
265
- out('');
266
- // The listening server holds the event loop; resolve so callers/tests know
267
- // we are up, but leave the process running.
268
- return { ...started, code: 0 };
264
+ // The listening server holds the event loop; resolve so callers/tests know
265
+ // we are up, but leave the process running.
266
+ return { ...started, code: 0 };
269
267
  }
270
-
271
268
  // `onboarder start background` — same server, no terminal attached. The child is
272
269
  // an ordinary foreground `start`; all this does is detach it and then *wait for
273
270
  // it to answer* before reporting success, so a bind failure surfaces here as a
274
271
  // failure with the log tail attached rather than a cheerful lie.
275
272
  export async function runStartBackground({ flags = {}, out = console.log, err = console.error } = {}) {
276
- const file = flags.config || configPath();
277
- if (!await configExists(file) && process.stdout.isTTY && !flags.nonInteractive) {
278
- out(dim(' No settings yet — running setup first.'));
279
- return runSetup({ flags: { ...flags, start: 'background' }, out, err });
280
- }
281
- const recorded = readPidFile(file);
282
- if (recorded && pidIsAlive(recorded)) {
283
- err(` Onboarder is already running (PID ${recorded}).`);
284
- err(dim(' Use `onboarder status`, `onboarder stop`, or `onboarder restart`.'));
285
- return 1;
286
- }
287
- if (recorded) removePidFile(file, recorded);
288
-
289
- const settings = await readSettings(file);
290
- const log = logPath(file);
291
- await rotateLogIfNeeded(log);
292
-
293
- spawnDetached({ configFile: file, log });
294
- const pid = await waitForPidFile(file, { timeoutMs: flags.timeout ? Number(flags.timeout) * 1000 : 20000 });
295
- const urls = serverUrls(settings);
296
- const ready = pid ? await waitForReady(new URL('/api/health', urls.local).toString(), { timeoutMs: 8000 }) : false;
297
-
298
- if (!ready) {
299
- // Two different failures with two different fixes, so they are reported
300
- // differently. No pid record means the child never finished binding — the
301
- // log holds the reason (a busy port, an invalid config). A live pid that will
302
- // not answer means it bound and then something in front of it is in the way
303
- // (a proxy, a firewall), and no amount of log-reading will show that.
304
- if (!pid) {
305
- err(bad(' Onboarder did not start in the background.'));
306
- const tail = await tailLog(log, 15);
307
- if (tail.length) {
308
- err(dim(` Last lines of ${log}:`));
309
- for (const line of tail) err(' ' + line);
310
- }
311
- err(dim(' Fix the cause above, then run `onboarder start background` again.'));
312
- } else {
313
- err(bad(` Onboarder is running (PID ${pid}) but ${urls.local} is not answering.`));
314
- err(dim(` Its log is ${log}. If a proxy or firewall fronts this port, check that first.`));
315
- err(dim(' Otherwise: `onboarder stop`, then `onboarder start background` again.'));
316
- }
317
- return 1;
318
- }
319
-
320
- if (flags.json) {
321
- out(JSON.stringify({ background: true, pid, url: urls.local, log, configFile: file }, null, 2));
273
+ const file = flags.config || configPath();
274
+ if (!await configExists(file) && process.stdout.isTTY && !flags.nonInteractive) {
275
+ out(dim(' No settings yet — running setup first.'));
276
+ return runSetup({ flags: { ...flags, start: 'background' }, out, err });
277
+ }
278
+ const recorded = readPidFile(file);
279
+ if (recorded && pidIsAlive(recorded)) {
280
+ err(` Onboarder is already running (PID ${recorded}).`);
281
+ err(dim(' Use `onboarder status`, `onboarder stop`, or `onboarder restart`.'));
282
+ return 1;
283
+ }
284
+ if (recorded)
285
+ removePidFile(file, recorded);
286
+ const settings = await readSettings(file);
287
+ const log = logPath(file);
288
+ await rotateLogIfNeeded(log);
289
+ spawnDetached({ configFile: file, log });
290
+ const pid = await waitForPidFile(file, { timeoutMs: flags.timeout ? Number(flags.timeout) * 1000 : 20000 });
291
+ const urls = serverUrls(settings);
292
+ const ready = pid ? await waitForReady(new URL('/api/health', urls.local).toString(), { timeoutMs: 8000 }) : false;
293
+ if (!ready) {
294
+ // Two different failures with two different fixes, so they are reported
295
+ // differently. No pid record means the child never finished binding — the
296
+ // log holds the reason (a busy port, an invalid config). A live pid that will
297
+ // not answer means it bound and then something in front of it is in the way
298
+ // (a proxy, a firewall), and no amount of log-reading will show that.
299
+ if (!pid) {
300
+ err(bad(' Onboarder did not start in the background.'));
301
+ const tail = await tailLog(log, 15);
302
+ if (tail.length) {
303
+ err(dim(` Last lines of ${log}:`));
304
+ for (const line of tail)
305
+ err(' ' + line);
306
+ }
307
+ err(dim(' Fix the cause above, then run `onboarder start background` again.'));
308
+ }
309
+ else {
310
+ err(bad(` Onboarder is running (PID ${pid}) but ${urls.local} is not answering.`));
311
+ err(dim(` Its log is ${log}. If a proxy or firewall fronts this port, check that first.`));
312
+ err(dim(' Otherwise: `onboarder stop`, then `onboarder start background` again.'));
313
+ }
314
+ return 1;
315
+ }
316
+ if (flags.json) {
317
+ out(JSON.stringify({ background: true, pid, url: urls.local, log, configFile: file }, null, 2));
318
+ return 0;
319
+ }
320
+ out('');
321
+ out(panel('Onboarder is running in the background', [
322
+ row('URL', bold(cyan(urls.local))),
323
+ row('PID', String(pid)),
324
+ row('Mode', 'background — survives closing this terminal'),
325
+ row('Logs', dim(log)),
326
+ row('Config', dim(file)),
327
+ ]));
328
+ out('');
329
+ out(dim(' Follow the log: ') + cyan('onboarder logs -f'));
330
+ out(dim(' Stop it: ') + cyan('onboarder stop'));
331
+ out(dim(' Check on it: ') + cyan('onboarder status'));
332
+ out('');
322
333
  return 0;
323
- }
324
-
325
- out('');
326
- out(panel('Onboarder is running in the background', [
327
- row('URL', bold(cyan(urls.local))),
328
- row('PID', String(pid)),
329
- row('Mode', 'background — survives closing this terminal'),
330
- row('Logs', dim(log)),
331
- row('Config', dim(file)),
332
- ]));
333
- out('');
334
- out(dim(' Follow the log: ') + cyan('onboarder logs -f'));
335
- out(dim(' Stop it: ') + cyan('onboarder stop'));
336
- out(dim(' Check on it: ') + cyan('onboarder status'));
337
- out('');
338
- return 0;
339
334
  }
340
-
341
-
342
335
  // ------------------------------------------------------------- lifecycle ---
343
-
344
336
  // Panel rows carry their own inline marker rather than the `tick`/`cross`/`dash`
345
337
  // constants: those bake in a two-column indent for standalone lines, which would
346
338
  // push the first row out of the panel's label column.
347
339
  const MARK = { yes: (s) => paint('✓ ', 'green') + s, no: (s) => paint('– ', 'gray') + s, warn: (s) => paint('! ', 'yellow') + s };
348
-
349
340
  // How a live instance presents itself, in one sentence per mode. The point of
350
341
  // the sentence is the thing someone actually needs to know: can I close my
351
342
  // terminal, or will that kill it?
352
343
  const MODE_NOTES = {
353
- background: 'background — survives closing the terminal',
354
- startup: 'started at login — the OS restarts it if it stops',
355
- foreground: 'foreground — stops when you press Ctrl-C or close the terminal',
344
+ background: 'background — survives closing the terminal',
345
+ startup: 'started at login — the OS restarts it if it stops',
346
+ foreground: 'foreground — stops when you press Ctrl-C or close the terminal',
356
347
  };
357
-
358
348
  export async function runStatus({ flags = {}, out = console.log } = {}) {
359
- const file = flags.config || configPath();
360
- const pid = readPidFile(file);
361
- let running = pid && pidIsAlive(pid);
362
- let settings = null;
363
- try { settings = await readSettings(file); } catch { /* defaults are still meaningful */ }
364
- const portBusy = settings ? !(await portIsFree(settings.host, settings.port)) : false;
365
- if (pid && !running) removePidFile(file, pid);
366
- // The pid says whether it is up; the run record says *how* it came up, which
367
- // is what tells someone whether closing their terminal will kill it.
368
- const info = running ? readRunInfo(file) : null;
369
- const result = {
370
- running: Boolean(running || portBusy),
371
- pid: running ? pid : null,
372
- port: settings ? `${settings.host}:${settings.port}` : null,
373
- portBusy,
374
- managed: Boolean(running),
375
- configFile: file,
376
- mode: running ? (info?.mode || 'foreground') : null,
377
- url: running ? (info?.url || (settings ? serverUrls(settings).local : null)) : null,
378
- uptimeMs: running && info?.startedAt ? Date.now() - Date.parse(info.startedAt) : null,
379
- log: running && info?.log ? info.log : null,
380
- startup: (await startupStatus()).installed,
381
- };
382
- if (flags.json) {
383
- out(JSON.stringify(result, null, 2));
384
- } else {
385
- out('');
386
- out(panel('Onboarder status', [
387
- row('State', running
388
- ? MARK.yes(`Running (PID ${pid})`)
389
- : portBusy
390
- ? MARK.warn(`Port busy — ${result.port}, but no Onboarder process record`)
391
- : MARK.no('Stopped')),
392
- ...(running ? [
393
- row('URL', cyan(result.url || '')),
394
- row('Mode', MODE_NOTES[result.mode] || MODE_NOTES.foreground),
395
- ...(result.uptimeMs ? [row('Uptime', humanDuration(result.uptimeMs))] : []),
396
- ...(result.log ? [row('Logs', dim(result.log))] : []),
397
- ] : []),
398
- row('Config', dim(file)),
399
- row('At login', result.startup ? MARK.yes('Enabled') : MARK.no('Not enabled — `onboarder start startup install`')),
400
- ...(result.portBusy && !running
401
- ? [hint('Inspect the listener with `ss -ltnp` or `lsof -i :' + settings.port + '` before stopping another process.')]
402
- : []),
403
- ]));
404
- out('');
405
- }
406
- return 0;
349
+ const file = flags.config || configPath();
350
+ const pid = readPidFile(file);
351
+ let running = pid && pidIsAlive(pid);
352
+ let settings = null;
353
+ try {
354
+ settings = await readSettings(file);
355
+ }
356
+ catch { /* defaults are still meaningful */ }
357
+ const portBusy = settings ? !(await portIsFree(settings.host, settings.port)) : false;
358
+ if (pid && !running)
359
+ removePidFile(file, pid);
360
+ // The pid says whether it is up; the run record says *how* it came up, which
361
+ // is what tells someone whether closing their terminal will kill it.
362
+ const info = running ? readRunInfo(file) : null;
363
+ const result = {
364
+ running: Boolean(running || portBusy),
365
+ pid: running ? pid : null,
366
+ port: settings ? `${settings.host}:${settings.port}` : null,
367
+ portBusy,
368
+ managed: Boolean(running),
369
+ configFile: file,
370
+ mode: running ? (info?.mode || 'foreground') : null,
371
+ url: running ? (info?.url || (settings ? serverUrls(settings).local : null)) : null,
372
+ uptimeMs: running && info?.startedAt ? Date.now() - Date.parse(info.startedAt) : null,
373
+ log: running && info?.log ? info.log : null,
374
+ startup: (await startupStatus()).installed,
375
+ };
376
+ if (flags.json) {
377
+ out(JSON.stringify(result, null, 2));
378
+ }
379
+ else {
380
+ out('');
381
+ out(panel('Onboarder status', [
382
+ row('State', running
383
+ ? MARK.yes(`Running (PID ${pid})`)
384
+ : portBusy
385
+ ? MARK.warn(`Port busy — ${result.port}, but no Onboarder process record`)
386
+ : MARK.no('Stopped')),
387
+ ...(running ? [
388
+ row('URL', cyan(result.url || '')),
389
+ row('Mode', MODE_NOTES[result.mode] || MODE_NOTES.foreground),
390
+ ...(result.uptimeMs ? [row('Uptime', humanDuration(result.uptimeMs))] : []),
391
+ ...(result.log ? [row('Logs', dim(result.log))] : []),
392
+ ] : []),
393
+ row('Config', dim(file)),
394
+ row('At login', result.startup ? MARK.yes('Enabled') : MARK.no('Not enabled — `onboarder start startup install`')),
395
+ ...(result.portBusy && !running
396
+ ? [hint('Inspect the listener with `ss -ltnp` or `lsof -i :' + settings.port + '` before stopping another process.')]
397
+ : []),
398
+ ]));
399
+ out('');
400
+ }
401
+ return 0;
407
402
  }
408
-
409
403
  // "3d 4h", "12m 5s" — coarse on purpose. Precision past the second is noise on
410
404
  // something a person glances at.
411
405
  export function humanDuration(ms) {
412
- const total = Math.max(0, Math.floor(ms / 1000));
413
- const days = Math.floor(total / 86400);
414
- const hours = Math.floor((total % 86400) / 3600);
415
- const minutes = Math.floor((total % 3600) / 60);
416
- const seconds = total % 60;
417
- if (days) return `${days}d ${hours}h`;
418
- if (hours) return `${hours}h ${minutes}m`;
419
- if (minutes) return `${minutes}m ${seconds}s`;
420
- return `${seconds}s`;
406
+ const total = Math.max(0, Math.floor(ms / 1000));
407
+ const days = Math.floor(total / 86400);
408
+ const hours = Math.floor((total % 86400) / 3600);
409
+ const minutes = Math.floor((total % 3600) / 60);
410
+ const seconds = total % 60;
411
+ if (days)
412
+ return `${days}d ${hours}h`;
413
+ if (hours)
414
+ return `${hours}h ${minutes}m`;
415
+ if (minutes)
416
+ return `${minutes}m ${seconds}s`;
417
+ return `${seconds}s`;
421
418
  }
422
-
423
419
  function waitForExit(pid, timeoutMs = 5000) {
424
- return new Promise((resolve) => {
425
- const started = Date.now();
426
- const poll = () => {
427
- if (!pidIsAlive(pid)) return resolve(true);
428
- if (Date.now() - started >= timeoutMs) return resolve(false);
429
- setTimeout(poll, 100);
430
- };
431
- poll();
432
- });
420
+ return new Promise((resolve) => {
421
+ const started = Date.now();
422
+ const poll = () => {
423
+ if (!pidIsAlive(pid))
424
+ return resolve(true);
425
+ if (Date.now() - started >= timeoutMs)
426
+ return resolve(false);
427
+ setTimeout(poll, 100);
428
+ };
429
+ poll();
430
+ });
433
431
  }
434
-
435
432
  export async function runStop({ flags = {}, out = console.log, err = console.error } = {}) {
436
- const file = flags.config || configPath();
437
- const pid = readPidFile(file);
438
- if (!pid) {
439
- const settings = await readSettings(file);
440
- const portBusy = !(await portIsFree(settings.host, settings.port));
441
- if (flags.json) out(JSON.stringify({ stopped: false, reason: portBusy ? 'unmanaged busy port' : 'not running', port: `${settings.host}:${settings.port}` }, null, 2));
442
- else if (portBusy) {
443
- out(`${warn('!')}Port busy ${settings.host}:${settings.port} — no Onboarder process record.`);
444
- out(dim(' Inspect it with `ss -ltnp` or `lsof -i :' + settings.port + '`; `onboarder stop` will not kill an unmanaged process.'));
445
- } else {
446
- out(`${dash}Not running.`);
447
- }
448
- return portBusy ? 1 : 0;
449
- }
450
- if (!pidIsAlive(pid)) {
433
+ const file = flags.config || configPath();
434
+ const pid = readPidFile(file);
435
+ if (!pid) {
436
+ const settings = await readSettings(file);
437
+ const portBusy = !(await portIsFree(settings.host, settings.port));
438
+ if (flags.json)
439
+ out(JSON.stringify({ stopped: false, reason: portBusy ? 'unmanaged busy port' : 'not running', port: `${settings.host}:${settings.port}` }, null, 2));
440
+ else if (portBusy) {
441
+ out(`${warn('!')}Port busy ${settings.host}:${settings.port} — no Onboarder process record.`);
442
+ out(dim(' Inspect it with `ss -ltnp` or `lsof -i :' + settings.port + '`; `onboarder stop` will not kill an unmanaged process.'));
443
+ }
444
+ else {
445
+ out(`${dash}Not running.`);
446
+ }
447
+ return portBusy ? 1 : 0;
448
+ }
449
+ if (!pidIsAlive(pid)) {
450
+ removePidFile(file, pid);
451
+ if (flags.json)
452
+ out(JSON.stringify({ stopped: true, pid, stale: true }, null, 2));
453
+ else
454
+ out(tick + `Removed stale process record for PID ${pid}.`);
455
+ return 0;
456
+ }
457
+ try {
458
+ process.kill(pid, 'SIGTERM');
459
+ }
460
+ catch (error) {
461
+ if (error?.code !== 'ESRCH')
462
+ throw error;
463
+ }
464
+ const stopped = await waitForExit(pid);
465
+ if (!stopped && pidIsAlive(pid)) {
466
+ err(` PID ${pid} did not stop after SIGTERM.`);
467
+ err(dim(' Check it with `ps -p ' + pid + ' -f`, then stop it only if it really is Onboarder.'));
468
+ return 1;
469
+ }
451
470
  removePidFile(file, pid);
452
- if (flags.json) out(JSON.stringify({ stopped: true, pid, stale: true }, null, 2));
453
- else out(tick + `Removed stale process record for PID ${pid}.`);
471
+ if (flags.json)
472
+ out(JSON.stringify({ stopped: true, pid }, null, 2));
473
+ else
474
+ out(tick + `Stopped Onboarder (PID ${pid}).`);
454
475
  return 0;
455
- }
456
- try {
457
- process.kill(pid, 'SIGTERM');
458
- } catch (error) {
459
- if (error?.code !== 'ESRCH') throw error;
460
- }
461
- const stopped = await waitForExit(pid);
462
- if (!stopped && pidIsAlive(pid)) {
463
- err(` PID ${pid} did not stop after SIGTERM.`);
464
- err(dim(' Check it with `ps -p ' + pid + ' -f`, then stop it only if it really is Onboarder.'));
465
- return 1;
466
- }
467
- removePidFile(file, pid);
468
- if (flags.json) out(JSON.stringify({ stopped: true, pid }, null, 2));
469
- else out(tick + `Stopped Onboarder (PID ${pid}).`);
470
- return 0;
471
476
  }
472
-
473
477
  export async function runRestart(options = {}) {
474
- const file = options.flags?.config || configPath();
475
- const pid = readPidFile(file);
476
- if (pid && pidIsAlive(pid)) {
477
- const stopped = await runStop(options);
478
- if (stopped !== 0) return stopped;
479
- } else if (pid) {
480
- removePidFile(file, pid);
481
- }
482
- return runStart(options);
478
+ const file = options.flags?.config || configPath();
479
+ const pid = readPidFile(file);
480
+ if (pid && pidIsAlive(pid)) {
481
+ const stopped = await runStop(options);
482
+ if (stopped !== 0)
483
+ return stopped;
484
+ }
485
+ else if (pid) {
486
+ removePidFile(file, pid);
487
+ }
488
+ return runStart(options);
483
489
  }
484
-
485
490
  // ----------------------------------------------------------------- logs ---
486
-
487
491
  // `onboarder logs` — the answer to "it is running in the background, what is it
488
492
  // doing?". Prints the tail of the same file the background child writes, and
489
493
  // `-f` follows it. Line-oriented and level-colored, so a wall of request logs
490
494
  // stays scannable instead of being one undifferentiated block.
491
495
  export async function runLogs({ flags = {}, out = console.log, err = console.error } = {}) {
492
- const file = flags.config || configPath();
493
- const log = logPath(file);
494
- const lines = flags.lines === undefined ? 40 : Math.max(1, Number(flags.lines) || 40);
495
- if (!await logExists(log)) {
496
- if (flags.json) out(JSON.stringify({ log, exists: false, lines: [] }, null, 2));
497
- else {
498
- err(` No log file yet at ${log}.`);
499
- err(dim(' A foreground `onboarder start` prints to the terminal; only `onboarder start background` writes this file.'));
500
- }
501
- return flags.json ? 0 : 1;
502
- }
503
- if (!flags.follow) {
504
- const tail = await tailLog(log, lines);
505
- if (flags.json) { out(JSON.stringify({ log, exists: true, lines: tail }, null, 2)); return 0; }
506
- out('');
507
- out(bold(` ${log}`) + dim(` (last ${tail.length} of ${lines})`));
496
+ const file = flags.config || configPath();
497
+ const log = logPath(file);
498
+ const lines = flags.lines === undefined ? 40 : Math.max(1, Number(flags.lines) || 40);
499
+ if (!await logExists(log)) {
500
+ if (flags.json)
501
+ out(JSON.stringify({ log, exists: false, lines: [] }, null, 2));
502
+ else {
503
+ err(` No log file yet at ${log}.`);
504
+ err(dim(' A foreground `onboarder start` prints to the terminal; only `onboarder start background` writes this file.'));
505
+ }
506
+ return flags.json ? 0 : 1;
507
+ }
508
+ if (!flags.follow) {
509
+ const tail = await tailLog(log, lines);
510
+ if (flags.json) {
511
+ out(JSON.stringify({ log, exists: true, lines: tail }, null, 2));
512
+ return 0;
513
+ }
514
+ out('');
515
+ out(bold(` ${log}`) + dim(` (last ${tail.length} of ${lines})`));
516
+ out('');
517
+ for (const line of tail)
518
+ out(' ' + line);
519
+ out('');
520
+ return 0;
521
+ }
522
+ if (flags.json) {
523
+ err(' --follow and --json do not combine; pick one.');
524
+ return 2;
525
+ }
508
526
  out('');
509
- for (const line of tail) out(' ' + line);
527
+ out(bold(` Following ${log}`) + dim(' (Ctrl-C to stop)'));
510
528
  out('');
529
+ const stop = followLog(log, (line) => out(' ' + line), { from: 'start' });
530
+ const finish = () => { stop(); process.exit(0); };
531
+ process.once('SIGINT', finish);
532
+ await new Promise((resolve) => process.once('exit', resolve));
511
533
  return 0;
512
- }
513
- if (flags.json) { err(' --follow and --json do not combine; pick one.'); return 2; }
514
- out('');
515
- out(bold(` Following ${log}`) + dim(' (Ctrl-C to stop)'));
516
- out('');
517
- const stop = followLog(log, (line) => out(' ' + line), { from: 'start' });
518
- const finish = () => { stop(); process.exit(0); };
519
- process.once('SIGINT', finish);
520
- await new Promise((resolve) => process.once('exit', resolve));
521
- return 0;
522
534
  }
523
-
524
535
  // -------------------------------------------------------------- startup ---
525
-
526
536
  // `onboarder start startup` — run at login, not just right now. Three verbs
527
537
  // because the three actions are genuinely different: `install` writes the
528
538
  // artifact and hands it to the OS, `remove` takes it back out, `status` only
529
539
  // looks. `install` also starts it once so the person is not left waiting for the
530
540
  // next reboot to find out whether it worked.
531
541
  export async function runStartup(action = 'status', { flags = {}, out = console.log, err = console.error } = {}) {
532
- const file = flags.config || configPath();
533
- const target = startupTarget();
534
- const log = logPath(file);
535
-
536
- if (action === 'install' || action === 'enable' || action === 'on') {
537
- const result = await installStartup({ configFile: file, log, target });
538
- if (!result.ok) {
539
- err(bad(' Could not install the startup entry.'));
540
- err(' ' + (result.reason || result.output || result.error || 'unknown error'));
541
- return 1;
542
- }
543
- out(tick + `Onboarder will start at login (${target.hint}).`);
544
- out(kv('Startup file', result.path));
545
- if (result.output) out(dim(' ' + result.output.split('\n').slice(-2).join('\n ')));
546
- out('');
547
- out(dim(' Start it right now too: ') + cyan('onboarder start background'));
548
- out(dim(' Turn it off again: ') + cyan('onboarder start startup remove'));
549
- out('');
550
- return 0;
551
- }
552
-
553
- if (action === 'remove' || action === 'disable' || action === 'off' || action === 'uninstall') {
554
- const status = await startupStatus(target);
555
- if (!status.installed) {
556
- out(dash + 'No startup entry is installed — nothing to remove.');
557
- return 0;
558
- }
559
- const result = await removeStartup({ target });
560
- if (!result.ok) { err(bad(' Could not remove the startup entry: ' + (result.reason || 'unknown error'))); return 1; }
561
- out(tick + 'Startup entry removed. A running server is untouched — use `onboarder stop` for that.');
562
- out(kv('Removed', result.path));
563
- out('');
564
- return 0;
565
- }
566
-
567
- if (action === 'status' || action === undefined) {
568
- const status = await startupStatus(target);
569
- if (flags.json) { out(JSON.stringify(status, null, 2)); return status.installed ? 0 : 1; }
570
- out('');
571
- out(panel('Onboarder at login', [
572
- row('State', status.installed
573
- ? (status.active ? MARK.yes('Enabled and running') : MARK.warn('Enabled, not running'))
574
- : MARK.no('Not enabled')),
575
- row('How', status.detail),
576
- ...(status.path ? [row('File', dim(status.path))] : []),
577
- ...(status.supported ? [hint(status.installed
578
- ? 'Remove it with `onboarder start startup remove`.'
579
- : 'Enable it with `onboarder start startup install`.')] : []),
580
- ]));
581
- out('');
582
- return status.installed ? 0 : 1;
583
- }
584
-
585
- throw new Error('Usage: onboarder start startup [install|remove|status]');
542
+ const file = flags.config || configPath();
543
+ const target = startupTarget();
544
+ const log = logPath(file);
545
+ if (action === 'install' || action === 'enable' || action === 'on') {
546
+ const result = await installStartup({ configFile: file, log, target });
547
+ if (!result.ok) {
548
+ err(bad(' Could not install the startup entry.'));
549
+ err(' ' + (result.reason || result.output || result.error || 'unknown error'));
550
+ return 1;
551
+ }
552
+ out(tick + `Onboarder will start at login (${target.hint}).`);
553
+ out(kv('Startup file', result.path));
554
+ if (result.output)
555
+ out(dim(' ' + result.output.split('\n').slice(-2).join('\n ')));
556
+ out('');
557
+ out(dim(' Start it right now too: ') + cyan('onboarder start background'));
558
+ out(dim(' Turn it off again: ') + cyan('onboarder start startup remove'));
559
+ out('');
560
+ return 0;
561
+ }
562
+ if (action === 'remove' || action === 'disable' || action === 'off' || action === 'uninstall') {
563
+ const status = await startupStatus(target);
564
+ if (!status.installed) {
565
+ out(dash + 'No startup entry is installed — nothing to remove.');
566
+ return 0;
567
+ }
568
+ const result = await removeStartup({ target });
569
+ if (!result.ok) {
570
+ err(bad(' Could not remove the startup entry: ' + (result.reason || 'unknown error')));
571
+ return 1;
572
+ }
573
+ out(tick + 'Startup entry removed. A running server is untouched — use `onboarder stop` for that.');
574
+ out(kv('Removed', result.path));
575
+ out('');
576
+ return 0;
577
+ }
578
+ if (action === 'status' || action === undefined) {
579
+ const status = await startupStatus(target);
580
+ if (flags.json) {
581
+ out(JSON.stringify(status, null, 2));
582
+ return status.installed ? 0 : 1;
583
+ }
584
+ out('');
585
+ out(panel('Onboarder at login', [
586
+ row('State', status.installed
587
+ ? (status.active ? MARK.yes('Enabled and running') : MARK.warn('Enabled, not running'))
588
+ : MARK.no('Not enabled')),
589
+ row('How', status.detail),
590
+ ...(status.path ? [row('File', dim(status.path))] : []),
591
+ ...(status.supported ? [hint(status.installed
592
+ ? 'Remove it with `onboarder start startup remove`.'
593
+ : 'Enable it with `onboarder start startup install`.')] : []),
594
+ ]));
595
+ out('');
596
+ return status.installed ? 0 : 1;
597
+ }
598
+ throw new Error('Usage: onboarder start startup [install|remove|status]');
586
599
  }
587
-
588
600
  // --------------------------------------------------------------- config ---
589
-
590
601
  // Keys `config set` may touch — the same allow-list the HTTP API enforces, so
591
602
  // neither surface can smuggle in a key the schema does not know.
592
603
  const SETTABLE = {
593
- mode: (v) => { if (!['local', 'self-hosted'].includes(v)) throw new Error('mode must be "local" or "self-hosted".'); return v; },
594
- host: (v) => v,
595
- port: (v) => { const n = Number(v); if (!Number.isInteger(n) || n < 1 || n > 65535) throw new Error('port must be 1-65535.'); return n; },
596
- domain: (v) => v,
597
- https: (v) => ['true', 'yes', '1', 'on'].includes(String(v).toLowerCase()),
598
- autoOpen: (v) => ['true', 'yes', '1', 'on'].includes(String(v).toLowerCase()),
599
- 'account.name': (v) => v,
600
- 'account.email': (v) => v,
601
- 'account.provider': (v) => v,
602
- 'account.baseUrl': (v) => v,
603
- 'account.model': (v) => v,
604
- 'tunnel.cloudflare': (v) => ['true', 'yes', '1', 'on'].includes(String(v).toLowerCase()),
605
- 'tunnel.tailscale': (v) => ['true', 'yes', '1', 'on'].includes(String(v).toLowerCase()),
604
+ mode: (v) => { if (!['local', 'self-hosted'].includes(v))
605
+ throw new Error('mode must be "local" or "self-hosted".'); return v; },
606
+ host: (v) => v,
607
+ port: (v) => { const n = Number(v); if (!Number.isInteger(n) || n < 1 || n > 65535)
608
+ throw new Error('port must be 1-65535.'); return n; },
609
+ domain: (v) => v,
610
+ https: (v) => ['true', 'yes', '1', 'on'].includes(String(v).toLowerCase()),
611
+ autoOpen: (v) => ['true', 'yes', '1', 'on'].includes(String(v).toLowerCase()),
612
+ 'account.name': (v) => v,
613
+ 'account.email': (v) => v,
614
+ 'account.provider': (v) => v,
615
+ 'account.baseUrl': (v) => v,
616
+ 'account.model': (v) => v,
617
+ 'tunnel.cloudflare': (v) => ['true', 'yes', '1', 'on'].includes(String(v).toLowerCase()),
618
+ 'tunnel.tailscale': (v) => ['true', 'yes', '1', 'on'].includes(String(v).toLowerCase()),
606
619
  };
607
-
608
620
  function dig(obj, dotted) {
609
- return dotted.split('.').reduce((o, k) => (o == null ? undefined : o[k]), obj);
621
+ return dotted.split('.').reduce((o, k) => (o == null ? undefined : o[k]), obj);
610
622
  }
611
-
612
623
  function bury(obj, dotted, value) {
613
- const keys = dotted.split('.');
614
- let o = obj;
615
- for (const k of keys.slice(0, -1)) o = o[k] = o[k] && typeof o[k] === 'object' ? o[k] : {};
616
- o[keys.at(-1)] = value;
624
+ const keys = dotted.split('.');
625
+ let o = obj;
626
+ for (const k of keys.slice(0, -1))
627
+ o = o[k] = o[k] && typeof o[k] === 'object' ? o[k] : {};
628
+ o[keys.at(-1)] = value;
617
629
  }
618
-
619
-
620
630
  export async function runConfig(sub, args, { flags = {}, out = console.log } = {}) {
621
- const file = flags.config || configPath();
622
- switch (sub) {
623
- case 'path':
624
- out(file);
625
- return 0;
626
- case 'show': {
627
- const settings = await readSettings(file);
628
- if (flags.json) {
629
- const pub = publicSettings(settings);
630
- if (flags.reveal) pub.accessKey = settings.accessKey || null;
631
- out(JSON.stringify({ configFile: file, ...pub }, null, 2));
632
- return 0;
633
- }
634
- const urls = serverUrls(settings);
635
- out(bold(' ' + file));
636
- out(kv('Mode', settings.mode));
637
- out(kv('Bind', `${settings.host}:${settings.port}`));
638
- if (settings.domain) out(kv('Domain', settings.domain));
639
- out(kv('HTTPS', settings.https ? `enabled — ${urls.domain}` : settings.domain ? 'disabled (plain HTTP)' : 'no domain configured'));
640
- out(kv('Local', urls.local));
641
- if (urls.network) out(kv('Network', urls.network));
642
- if (urls.domain) out(kv('Public', urls.domain));
643
- if (settings.mode === 'self-hosted') {
644
- out(kv('Access key', flags.reveal ? (settings.accessKey || '(none)') : maskAccessKey(settings.accessKey)));
645
- if (!flags.reveal) out(dim(' `onboarder config show --reveal` prints it — you are on this machine, after all.'));
646
- }
647
- const who = [settings.account.name, settings.account.email].filter(Boolean).join(' · ');
648
- if (who) out(kv('Profile', who));
649
- if (settings.account.baseUrl) {
650
- out(kv('AI', `${settings.account.provider} — ${settings.account.baseUrl}${settings.account.model ? ' — ' + settings.account.model : ''}`));
651
- }
652
- const status = tunnelStatus(settings);
653
- for (const name of ['cloudflare', 'tailscale']) {
654
- if (status[name].enabled) {
655
- out(kv(name, status[name].installed ? status[name].command : 'enabled, CLI missing — ' + status[name].install));
631
+ const file = flags.config || configPath();
632
+ switch (sub) {
633
+ case 'path':
634
+ out(file);
635
+ return 0;
636
+ case 'show': {
637
+ const settings = await readSettings(file);
638
+ if (flags.json) {
639
+ const pub = publicSettings(settings);
640
+ if (flags.reveal)
641
+ pub.accessKey = settings.accessKey || null;
642
+ out(JSON.stringify({ configFile: file, ...pub }, null, 2));
643
+ return 0;
644
+ }
645
+ const urls = serverUrls(settings);
646
+ out(bold(' ' + file));
647
+ out(kv('Mode', settings.mode));
648
+ out(kv('Bind', `${settings.host}:${settings.port}`));
649
+ if (settings.domain)
650
+ out(kv('Domain', settings.domain));
651
+ out(kv('HTTPS', settings.https ? `enabled — ${urls.domain}` : settings.domain ? 'disabled (plain HTTP)' : 'no domain configured'));
652
+ out(kv('Local', urls.local));
653
+ if (urls.network)
654
+ out(kv('Network', urls.network));
655
+ if (urls.domain)
656
+ out(kv('Public', urls.domain));
657
+ if (settings.mode === 'self-hosted') {
658
+ out(kv('Access key', flags.reveal ? (settings.accessKey || '(none)') : maskAccessKey(settings.accessKey)));
659
+ if (!flags.reveal)
660
+ out(dim(' `onboarder config show --reveal` prints it — you are on this machine, after all.'));
661
+ }
662
+ const who = [settings.account.name, settings.account.email].filter(Boolean).join(' · ');
663
+ if (who)
664
+ out(kv('Profile', who));
665
+ if (settings.account.baseUrl) {
666
+ out(kv('AI', `${settings.account.provider} — ${settings.account.baseUrl}${settings.account.model ? ' — ' + settings.account.model : ''}`));
667
+ }
668
+ const status = tunnelStatus(settings);
669
+ for (const name of ['cloudflare', 'tailscale']) {
670
+ if (status[name].enabled) {
671
+ out(kv(name, status[name].installed ? status[name].command : 'enabled, CLI missing — ' + status[name].install));
672
+ }
673
+ }
674
+ out(kv('Auto-open', settings.autoOpen ? 'yes' : 'no'));
675
+ return 0;
676
+ }
677
+ case 'get': {
678
+ const [key] = args;
679
+ if (!key || (!(key in SETTABLE) && key !== 'accessKey'))
680
+ throw new Error('Unknown key. Known: ' + [...Object.keys(SETTABLE), 'accessKey'].join(', '));
681
+ const settings = await readSettings(file);
682
+ const value = key === 'accessKey' ? maskAccessKey(settings.accessKey) : dig(settings, key);
683
+ out(String(value ?? ''));
684
+ return 0;
685
+ }
686
+ case 'set': {
687
+ const [key, ...rest] = args;
688
+ const raw = rest.join(' ');
689
+ if (!key || !(key in SETTABLE))
690
+ throw new Error('Unknown or unsettable key "' + key + '". Known: ' + Object.keys(SETTABLE).join(', ') + '\nThe access key is managed by `onboarder config key …`.');
691
+ if (!raw)
692
+ throw new Error('Missing value: onboarder config set ' + key + ' <value>');
693
+ const value = SETTABLE[key](raw);
694
+ const current = await readSettings(file);
695
+ const next = { ...current, account: { ...current.account }, tunnel: { ...current.tunnel } };
696
+ bury(next, key, value);
697
+ await writeSettings(next, file); // normalizeSettings validates the result
698
+ out(tick + key + ' = ' + (typeof value === 'boolean' ? String(value) : value));
699
+ if (['host', 'port'].includes(key))
700
+ out(dim(' Restart the server for this to take effect.'));
701
+ return 0;
656
702
  }
657
- }
658
- out(kv('Auto-open', settings.autoOpen ? 'yes' : 'no'));
659
- return 0;
660
- }
661
- case 'get': {
662
- const [key] = args;
663
- if (!key || (!(key in SETTABLE) && key !== 'accessKey')) throw new Error('Unknown key. Known: ' + [...Object.keys(SETTABLE), 'accessKey'].join(', '));
664
- const settings = await readSettings(file);
665
- const value = key === 'accessKey' ? maskAccessKey(settings.accessKey) : dig(settings, key);
666
- out(String(value ?? ''));
667
- return 0;
668
- }
669
- case 'set': {
670
- const [key, ...rest] = args;
671
- const raw = rest.join(' ');
672
- if (!key || !(key in SETTABLE)) throw new Error('Unknown or unsettable key "' + key + '". Known: ' + Object.keys(SETTABLE).join(', ') + '\nThe access key is managed by `onboarder config key …`.');
673
- if (!raw) throw new Error('Missing value: onboarder config set ' + key + ' <value>');
674
- const value = SETTABLE[key](raw);
675
- const current = await readSettings(file);
676
- const next = { ...current, account: { ...current.account }, tunnel: { ...current.tunnel } };
677
- bury(next, key, value);
678
- await writeSettings(next, file); // normalizeSettings validates the result
679
- out(tick + key + ' = ' + (typeof value === 'boolean' ? String(value) : value));
680
- if (['host', 'port'].includes(key)) out(dim(' Restart the server for this to take effect.'));
681
- return 0;
682
- }
683
- default:
684
- throw new Error('Usage: onboarder config <show|get|set|path|reset|key>');
685
- }
703
+ default:
704
+ throw new Error('Usage: onboarder config <show|get|set|path|reset|key>');
705
+ }
686
706
  }
687
-
688
-
689
707
  // Config surgery that did not fit in `set`: full reset, and the access key,
690
708
  // which gets its own verb because it is the one value that is shown once and
691
709
  // never round-trips through an API.
692
710
  export async function runConfigKey(action, args, { flags = {}, out = console.log, err = console.error } = {}) {
693
- const file = flags.config || configPath();
694
- const current = await readSettings(file);
695
- if (action === 'rotate') {
696
- const { generateAccessKey } = await import('../server/config.js');
697
- const key = generateAccessKey();
698
- await writeSettings({ ...current, accessKey: key }, file);
699
- out(tick + 'Rotated. The old key is dead this second — settings are re-read per request.');
700
- out(' ' + bold(key));
701
- out(dim(' Shown once. Distribute it to your devices.'));
702
- return 0;
703
- }
704
- if (action === 'show') {
705
- out(current.accessKey || '(no key set)');
706
- return 0;
707
- }
708
- if (action === 'set') {
709
- const value = args.join(' ').trim();
710
- if (value.length < 16) throw new Error('Access keys are at least 16 characters.');
711
- await writeSettings({ ...current, accessKey: value }, file);
712
- out(tick + 'Access key updated.');
713
- return 0;
714
- }
715
- throw new Error('Usage: onboarder config key <rotate|show|set <value>>');
711
+ const file = flags.config || configPath();
712
+ const current = await readSettings(file);
713
+ if (action === 'rotate') {
714
+ const { generateAccessKey } = await import('../server/config.js');
715
+ const key = generateAccessKey();
716
+ await writeSettings({ ...current, accessKey: key }, file);
717
+ out(tick + 'Rotated. The old key is dead this second — settings are re-read per request.');
718
+ out(' ' + bold(key));
719
+ out(dim(' Shown once. Distribute it to your devices.'));
720
+ return 0;
721
+ }
722
+ if (action === 'show') {
723
+ out(current.accessKey || '(no key set)');
724
+ return 0;
725
+ }
726
+ if (action === 'set') {
727
+ const value = args.join(' ').trim();
728
+ if (value.length < 16)
729
+ throw new Error('Access keys are at least 16 characters.');
730
+ await writeSettings({ ...current, accessKey: value }, file);
731
+ out(tick + 'Access key updated.');
732
+ return 0;
733
+ }
734
+ throw new Error('Usage: onboarder config key <rotate|show|set <value>>');
716
735
  }
717
-
718
736
  export async function runConfigReset({ flags = {}, out = console.log, err = console.error } = {}) {
719
- const file = flags.config || configPath();
720
- if (!flags.yes) {
721
- err(' This rewrites ' + file + ' with factory defaults (local mode, port 4310, no key).');
722
- err(' Re-run with --yes to confirm.');
723
- return 1;
724
- }
725
- await writeSettings({ ...DEFAULT_SETTINGS }, file);
726
- out(tick + 'Reset to defaults: ' + file);
727
- return 0;
737
+ const file = flags.config || configPath();
738
+ if (!flags.yes) {
739
+ err(' This rewrites ' + file + ' with factory defaults (local mode, port 4310, no key).');
740
+ err(' Re-run with --yes to confirm.');
741
+ return 1;
742
+ }
743
+ await writeSettings({ ...DEFAULT_SETTINGS }, file);
744
+ out(tick + 'Reset to defaults: ' + file);
745
+ return 0;
728
746
  }
729
-
730
-
731
747
  // --------------------------------------------------------------- doctor ---
732
-
733
748
  // Diagnose without mutating. Every check reports { id, ok, required, detail }
734
749
  // and the command exits non-zero if any *required* check failed — the same
735
750
  // shape `doctor --json` hands to scripts.
736
751
  export async function runDoctor({ flags = {}, out = console.log } = {}) {
737
- const file = flags.config || configPath();
738
- const checks = [];
739
-
740
- checks.push({
741
- id: 'node', ok: Number(process.versions.node.split('.')[0]) >= 20, required: true,
742
- detail: `v${process.versions.node} (needs ≥ 20)`,
743
- });
744
-
745
- let settings = null;
746
- try {
747
- settings = await readSettings(file);
748
- checks.push({ id: 'config', ok: true, required: true, detail: file });
749
- } catch (e) {
750
- checks.push({ id: 'config', ok: false, required: true, detail: `${file} — ${e.message}` });
751
- }
752
-
753
- const dir = file.slice(0, file.lastIndexOf('/'));
754
- let dirOk = true;
755
- try { await fsp.access(dir, fs.constants.W_OK); } catch { dirOk = false; }
756
- checks.push({ id: 'config-dir', ok: dirOk, required: true, detail: dirOk ? dir + ' is writable' : dir + ' is NOT writable' });
757
-
758
- if (settings) {
759
- const free = await portIsFree(settings.host, settings.port);
752
+ const file = flags.config || configPath();
753
+ const checks = [];
760
754
  checks.push({
761
- id: 'port', ok: free, required: false,
762
- detail: free ? `${settings.host}:${settings.port} is free` : `${settings.host}:${settings.port} is already in use (running already?)`,
755
+ id: 'node', ok: Number(process.versions.node.split('.')[0]) >= 20, required: true,
756
+ detail: `v${process.versions.node} (needs ≥ 20)`,
763
757
  });
764
- if (settings.mode === 'self-hosted') {
765
- checks.push({
766
- id: 'access-key', ok: Boolean(settings.accessKey), required: true,
767
- detail: settings.accessKey ? 'set — ' + maskAccessKey(settings.accessKey) : 'MISSING — the API refuses every call until one is set',
768
- });
769
- const loopback = ['127.0.0.1', 'localhost', '::1', '[::1]'].includes(settings.host);
770
- checks.push({
771
- id: 'domain', ok: true, required: false,
772
- detail: settings.domain || (loopback ? '(none — a tunnel or local reverse proxy provides the name)' : '(none — visitors can connect by server IP)'),
773
- });
774
- if (settings.https) {
775
- const readiness = await httpsReadiness(settings, { configFile: file });
776
- for (const check of readiness.checks.slice(1).filter((item) => item.id !== 'caddy')) {
777
- checks.push({ ...check, required: check.id !== 'dns-target' });
758
+ let settings = null;
759
+ try {
760
+ settings = await readSettings(file);
761
+ checks.push({ id: 'config', ok: true, required: true, detail: file });
762
+ }
763
+ catch (e) {
764
+ checks.push({ id: 'config', ok: false, required: true, detail: `${file} — ${e.message}` });
765
+ }
766
+ const dir = file.slice(0, file.lastIndexOf('/'));
767
+ let dirOk = true;
768
+ try {
769
+ await fsp.access(dir, fs.constants.W_OK);
770
+ }
771
+ catch {
772
+ dirOk = false;
773
+ }
774
+ checks.push({ id: 'config-dir', ok: dirOk, required: true, detail: dirOk ? dir + ' is writable' : dir + ' is NOT writable' });
775
+ if (settings) {
776
+ const free = await portIsFree(settings.host, settings.port);
777
+ checks.push({
778
+ id: 'port', ok: free, required: false,
779
+ detail: free ? `${settings.host}:${settings.port} is free` : `${settings.host}:${settings.port} is already in use (running already?)`,
780
+ });
781
+ if (settings.mode === 'self-hosted') {
782
+ checks.push({
783
+ id: 'access-key', ok: Boolean(settings.accessKey), required: true,
784
+ detail: settings.accessKey ? 'set — ' + maskAccessKey(settings.accessKey) : 'MISSING — the API refuses every call until one is set',
785
+ });
786
+ const loopback = ['127.0.0.1', 'localhost', '::1', '[::1]'].includes(settings.host);
787
+ checks.push({
788
+ id: 'domain', ok: true, required: false,
789
+ detail: settings.domain || (loopback ? '(none — a tunnel or local reverse proxy provides the name)' : '(none — visitors can connect by server IP)'),
790
+ });
791
+ if (settings.https) {
792
+ const readiness = await httpsReadiness(settings, { configFile: file });
793
+ for (const check of readiness.checks.slice(1).filter((item) => item.id !== 'caddy')) {
794
+ checks.push({ ...check, required: check.id !== 'dns-target' });
795
+ }
796
+ }
778
797
  }
779
- }
780
- }
781
- }
782
-
783
- for (const name of ['git', 'cloudflared', 'tailscale', 'caddy']) {
784
- const wanted = name === 'git'
785
- || (name === 'caddy' && settings?.https)
786
- || (settings && settings.tunnel?.[name === 'cloudflared' ? 'cloudflare' : 'tailscale']);
787
- const found = Boolean(findOnPath(name));
788
- checks.push({
789
- id: name,
790
- ok: found || !wanted,
791
- required: name === 'git' || (name === 'caddy' && settings?.https),
792
- detail: found ? 'installed' : wanted ? 'not installed — ' + installHint(name) : 'not installed (not needed for your settings)',
793
- });
794
- }
795
-
796
- if (flags.json) {
797
- out(JSON.stringify({ ok: checks.every((c) => c.ok || !c.required), checks }, null, 2));
798
- } else {
799
- out('');
800
- out(bold(' Onboarder doctor'));
801
- for (const c of checks) {
802
- const mark = c.ok ? tick : c.required ? cross : dash;
803
- out(`${mark}${c.id} — ${c.detail}`);
804
798
  }
805
- out('');
806
- }
807
- return checks.every((c) => c.ok || !c.required) ? 0 : 1;
799
+ for (const name of ['git', 'cloudflared', 'tailscale', 'caddy']) {
800
+ const wanted = name === 'git'
801
+ || (name === 'caddy' && settings?.https)
802
+ || (settings && settings.tunnel?.[name === 'cloudflared' ? 'cloudflare' : 'tailscale']);
803
+ const found = Boolean(findOnPath(name));
804
+ checks.push({
805
+ id: name,
806
+ ok: found || !wanted,
807
+ required: name === 'git' || (name === 'caddy' && settings?.https),
808
+ detail: found ? 'installed' : wanted ? 'not installed — ' + installHint(name) : 'not installed (not needed for your settings)',
809
+ });
810
+ }
811
+ if (flags.json) {
812
+ out(JSON.stringify({ ok: checks.every((c) => c.ok || !c.required), checks }, null, 2));
813
+ }
814
+ else {
815
+ out('');
816
+ out(bold(' Onboarder doctor'));
817
+ for (const c of checks) {
818
+ const mark = c.ok ? tick : c.required ? cross : dash;
819
+ out(`${mark}${c.id} — ${c.detail}`);
820
+ }
821
+ out('');
822
+ }
823
+ return checks.every((c) => c.ok || !c.required) ? 0 : 1;
808
824
  }
809
-
810
825
  function portIsFree(host, port) {
811
- return new Promise((resolve) => {
812
- const probe = net.createServer();
813
- probe.once('error', () => resolve(false));
814
- probe.listen(port, host, () => probe.close(() => resolve(true)));
815
- });
826
+ return new Promise((resolve) => {
827
+ const probe = net.createServer();
828
+ probe.once('error', () => resolve(false));
829
+ probe.listen(port, host, () => probe.close(() => resolve(true)));
830
+ });
816
831
  }
817
-
818
832
  // ------------------------------------------------------------------ https ---
819
-
820
833
  export async function runHttps(action = 'status', { flags = {}, out = console.log, err = console.error } = {}) {
821
- const file = flags.config || configPath();
822
- const settings = await readSettings(file);
823
- const status = httpsStatus(settings, file);
824
- if (action === 'status') {
825
- if (flags.json) out(JSON.stringify(status, null, 2));
826
- else {
827
- out('');
828
- out(bold(' Onboarder HTTPS'));
829
- out(kv('Enabled', status.enabled ? 'yes' : 'no'));
830
- out(kv('Domain', status.domain || '(not configured)'));
831
- out(kv('URL', status.url || '(none)'));
832
- out(kv('Caddy', status.caddyInstalled ? status.caddyVersion : 'not installed'));
833
- out(kv('Caddyfile', status.caddyfile));
834
- if (status.enabled) out(dim(' `onboarder https check` verifies DNS and ports before issuance.'));
835
- out('');
836
- }
837
- return status.enabled ? 0 : 1;
838
- }
839
- if (action === 'check') {
840
- const readiness = await httpsReadiness(settings, { configFile: file });
841
- if (flags.json) out(JSON.stringify(readiness, null, 2));
842
- else {
843
- out('');
844
- out(bold(' HTTPS readiness'));
845
- for (const check of readiness.checks) out(`${check.ok ? tick : check.required ? cross : dash}${check.id} — ${check.detail}`);
846
- if (!readiness.ok) {
847
- out('');
848
- out(warn(' Fix the required items, then run `onboarder https setup` again.'));
849
- out(dim(' DNS: point an A/AAAA record to this VPS. Firewall: allow inbound 80 and 443.'));
850
- }
851
- out('');
852
- }
853
- return readiness.ok ? 0 : 1;
854
- }
855
- if (action === 'setup' || action === 'start') {
856
- const readiness = await httpsReadiness(settings, { configFile: file });
857
- if (!readiness.ok) {
858
- err(bad(' HTTPS setup is not ready:'));
859
- for (const check of readiness.checks.filter((item) => item.required && !item.ok)) err(cross + check.id + ' — ' + check.detail);
860
- err(' Point DNS at this VPS, allow inbound TCP 80/443, install Caddy, then retry.');
861
- return 1;
862
- }
863
- const caddyfile = await writeCaddyfile(settings, file);
864
- out(dim(' Caddyfile: ' + caddyfile));
865
- await caddyValidate(settings, file);
866
-
867
- // Ubuntu's package usually leaves Caddy running as a service. Reload first
868
- // so repeat setup is idempotent and does not mistake Caddy for a foreign
869
- // listener. Reload talks only to Caddy's local admin endpoint and never
870
- // stops an unknown process.
871
- let result;
872
- let reloaded = false;
873
- try {
874
- result = caddyRun(settings, file, 'reload');
875
- reloaded = true;
876
- } catch {
877
- try {
878
- result = caddyRun(settings, file, 'start');
879
- } catch (error) {
880
- err(bad(' Caddy could not start: ' + (error.message || error)));
881
- if (process.platform !== 'win32') {
882
- err(' On Ubuntu, start the packaged service once:');
883
- err(' sudo systemctl enable --now caddy');
884
- err(' Then run `onboarder https setup` again.');
834
+ const file = flags.config || configPath();
835
+ const settings = await readSettings(file);
836
+ const status = httpsStatus(settings, file);
837
+ if (action === 'status') {
838
+ if (flags.json)
839
+ out(JSON.stringify(status, null, 2));
840
+ else {
841
+ out('');
842
+ out(bold(' Onboarder HTTPS'));
843
+ out(kv('Enabled', status.enabled ? 'yes' : 'no'));
844
+ out(kv('Domain', status.domain || '(not configured)'));
845
+ out(kv('URL', status.url || '(none)'));
846
+ out(kv('Caddy', status.caddyInstalled ? status.caddyVersion : 'not installed'));
847
+ out(kv('Caddyfile', status.caddyfile));
848
+ if (status.enabled)
849
+ out(dim(' `onboarder https check` verifies DNS and ports before issuance.'));
850
+ out('');
885
851
  }
886
- return 1;
887
- }
852
+ return status.enabled ? 0 : 1;
888
853
  }
889
- out(tick + (reloaded ? 'Caddy reloaded. It is using' : 'Caddy started. It is using') + ' https://' + settings.domain + '.');
890
- out(' Public URL https://' + settings.domain);
891
- out(dim(' Upstream http://127.0.0.1:' + settings.port));
892
- if (result.output) out(dim(' ' + result.output.split('\n').slice(-3).join('\n ')));
893
- return 0;
894
- }
895
- if (action === 'stop') {
896
- if (!status.caddyInstalled) { err(' Caddy is not installed.'); return 1; }
897
- caddyRun(settings, file, 'stop');
898
- out(tick + 'Caddy stopped. The Onboarder HTTP server is unchanged.');
899
- return 0;
900
- }
901
- throw new Error('Usage: onboarder https <check|setup|start|stop|status>');
854
+ if (action === 'check') {
855
+ const readiness = await httpsReadiness(settings, { configFile: file });
856
+ if (flags.json)
857
+ out(JSON.stringify(readiness, null, 2));
858
+ else {
859
+ out('');
860
+ out(bold(' HTTPS readiness'));
861
+ for (const check of readiness.checks)
862
+ out(`${check.ok ? tick : check.required ? cross : dash}${check.id} — ${check.detail}`);
863
+ if (!readiness.ok) {
864
+ out('');
865
+ out(warn(' Fix the required items, then run `onboarder https setup` again.'));
866
+ out(dim(' DNS: point an A/AAAA record to this VPS. Firewall: allow inbound 80 and 443.'));
867
+ }
868
+ out('');
869
+ }
870
+ return readiness.ok ? 0 : 1;
871
+ }
872
+ if (action === 'setup' || action === 'start') {
873
+ const readiness = await httpsReadiness(settings, { configFile: file });
874
+ if (!readiness.ok) {
875
+ err(bad(' HTTPS setup is not ready:'));
876
+ for (const check of readiness.checks.filter((item) => item.required && !item.ok))
877
+ err(cross + check.id + ' — ' + check.detail);
878
+ err(' Point DNS at this VPS, allow inbound TCP 80/443, install Caddy, then retry.');
879
+ return 1;
880
+ }
881
+ const caddyfile = await writeCaddyfile(settings, file);
882
+ out(dim(' Caddyfile: ' + caddyfile));
883
+ await caddyValidate(settings, file);
884
+ // Ubuntu's package usually leaves Caddy running as a service. Reload first
885
+ // so repeat setup is idempotent and does not mistake Caddy for a foreign
886
+ // listener. Reload talks only to Caddy's local admin endpoint and never
887
+ // stops an unknown process.
888
+ let result;
889
+ let reloaded = false;
890
+ try {
891
+ result = caddyRun(settings, file, 'reload');
892
+ reloaded = true;
893
+ }
894
+ catch {
895
+ try {
896
+ result = caddyRun(settings, file, 'start');
897
+ }
898
+ catch (error) {
899
+ err(bad(' Caddy could not start: ' + (error.message || error)));
900
+ if (process.platform !== 'win32') {
901
+ err(' On Ubuntu, start the packaged service once:');
902
+ err(' sudo systemctl enable --now caddy');
903
+ err(' Then run `onboarder https setup` again.');
904
+ }
905
+ return 1;
906
+ }
907
+ }
908
+ out(tick + (reloaded ? 'Caddy reloaded. It is using' : 'Caddy started. It is using') + ' https://' + settings.domain + '.');
909
+ out(' Public URL https://' + settings.domain);
910
+ out(dim(' Upstream http://127.0.0.1:' + settings.port));
911
+ if (result.output)
912
+ out(dim(' ' + result.output.split('\n').slice(-3).join('\n ')));
913
+ return 0;
914
+ }
915
+ if (action === 'stop') {
916
+ if (!status.caddyInstalled) {
917
+ err(' Caddy is not installed.');
918
+ return 1;
919
+ }
920
+ caddyRun(settings, file, 'stop');
921
+ out(tick + 'Caddy stopped. The Onboarder HTTP server is unchanged.');
922
+ return 0;
923
+ }
924
+ throw new Error('Usage: onboarder https <check|setup|start|stop|status>');
902
925
  }
903
-
904
926
  // --------------------------------------------------------------- tunnel ---
905
-
906
927
  export async function runTunnel(kind, { flags = {}, out = console.log, err = console.error } = {}) {
907
- const settings = await readSettings(flags.config || configPath());
908
- const target = `http://127.0.0.1:${settings.port}`;
909
- if (kind === 'tailscale') {
910
- if (!findOnPath('tailscale')) {
911
- err(' tailscale is not installed — ' + installHint('tailscale'));
912
- return 1;
913
- }
914
- const cmd = tailscaleCommand(settings);
915
- out(' ' + cmd);
916
- const { spawnSync } = await import('node:child_process');
917
- const r = spawnSync(cmd, { shell: true, stdio: 'inherit' });
918
- if (r.status === 0) {
919
- out(tick + 'Tailscale is serving ' + target + ' over your tailnet (https://<machine>.<tailnet>.ts.net).');
920
- out(dim(' `tailscale serve status` to verify, `onboarder doctor` to re-check.'));
921
- }
922
- return r.status ?? 1;
923
- }
924
- if (kind === 'cloudflare') {
925
- if (!findOnPath('cloudflared')) {
926
- err(' cloudflared is not installed — ' + installHint('cloudflared'));
927
- return 1;
928
- }
929
- const cmd = cloudflareCommand(settings);
930
- out(dim(' $ ' + cmd));
931
- out(dim(' Ctrl-C stops the tunnel; the server keeps running.\n'));
932
- // Quick tunnels print their random name on stderr; surface just that line
933
- // plus everything when --verbose, so the default output is one useful URL.
934
- const child = spawn('cloudflared', ['tunnel', '--url', target], { stdio: ['ignore', 'pipe', 'pipe'] });
935
- let announced = false;
936
- child.stderr.on('data', (chunk) => {
937
- const text = String(chunk);
938
- const m = text.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/);
939
- if (m && !announced) {
940
- announced = true;
941
- out(tick + 'Public URL: ' + bold(m[0]));
942
- out(dim(' Anyone with the URL still needs the access key: ' + m[0]));
943
- }
944
- if (flags.verbose) process.stderr.write(text);
945
- });
946
- child.stdout.on('data', (d) => { if (flags.verbose) process.stdout.write(d); });
947
- const stop = () => child.kill('SIGINT');
948
- process.once('SIGINT', stop);
949
- return new Promise((resolve) => {
950
- child.on('close', (code) => {
951
- process.removeListener('SIGINT', stop);
952
- if (!announced && code !== 0) err(' cloudflared exited without printing a URL — re-run with --verbose to see why.');
953
- resolve(code ?? 0);
954
- });
955
- });
956
- }
957
- throw new Error('Usage: onboarder tunnel <cloudflare|tailscale>');
928
+ const settings = await readSettings(flags.config || configPath());
929
+ const target = `http://127.0.0.1:${settings.port}`;
930
+ if (kind === 'tailscale') {
931
+ if (!findOnPath('tailscale')) {
932
+ err(' tailscale is not installed — ' + installHint('tailscale'));
933
+ return 1;
934
+ }
935
+ const cmd = tailscaleCommand(settings);
936
+ out(' ' + cmd);
937
+ const { spawnSync } = await import('node:child_process');
938
+ const r = spawnSync(cmd, { shell: true, stdio: 'inherit' });
939
+ if (r.status === 0) {
940
+ out(tick + 'Tailscale is serving ' + target + ' over your tailnet (https://<machine>.<tailnet>.ts.net).');
941
+ out(dim(' `tailscale serve status` to verify, `onboarder doctor` to re-check.'));
942
+ }
943
+ return r.status ?? 1;
944
+ }
945
+ if (kind === 'cloudflare') {
946
+ if (!findOnPath('cloudflared')) {
947
+ err(' cloudflared is not installed — ' + installHint('cloudflared'));
948
+ return 1;
949
+ }
950
+ const cmd = cloudflareCommand(settings);
951
+ out(dim(' $ ' + cmd));
952
+ out(dim(' Ctrl-C stops the tunnel; the server keeps running.\n'));
953
+ // Quick tunnels print their random name on stderr; surface just that line
954
+ // plus everything when --verbose, so the default output is one useful URL.
955
+ const child = spawn('cloudflared', ['tunnel', '--url', target], { stdio: ['ignore', 'pipe', 'pipe'] });
956
+ let announced = false;
957
+ child.stderr.on('data', (chunk) => {
958
+ const text = String(chunk);
959
+ const m = text.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/);
960
+ if (m && !announced) {
961
+ announced = true;
962
+ out(tick + 'Public URL: ' + bold(m[0]));
963
+ out(dim(' Anyone with the URL still needs the access key: ' + m[0]));
964
+ }
965
+ if (flags.verbose)
966
+ process.stderr.write(text);
967
+ });
968
+ child.stdout.on('data', (d) => { if (flags.verbose)
969
+ process.stdout.write(d); });
970
+ const stop = () => child.kill('SIGINT');
971
+ process.once('SIGINT', stop);
972
+ return new Promise((resolve) => {
973
+ child.on('close', (code) => {
974
+ process.removeListener('SIGINT', stop);
975
+ if (!announced && code !== 0)
976
+ err(' cloudflared exited without printing a URL — re-run with --verbose to see why.');
977
+ resolve(code ?? 0);
978
+ });
979
+ });
980
+ }
981
+ throw new Error('Usage: onboarder tunnel <cloudflare|tailscale>');
958
982
  }
983
+ //# sourceMappingURL=commands.js.map