colloq 0.2.0__py3-none-any.whl

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 (224) hide show
  1. colloq/__init__.py +12 -0
  2. colloq/__main__.py +315 -0
  3. colloq/_app/.colloq-dist.json +66 -0
  4. colloq/_app/LICENSE +21 -0
  5. colloq/_app/THIRD_PARTY_NOTICES.md +106 -0
  6. colloq/_app/cli/colloq.mjs +2517 -0
  7. colloq/_app/cli/launch.mjs +1572 -0
  8. colloq/_app/cli/package.json +5 -0
  9. colloq/_app/cli/public-url-lease.mjs +198 -0
  10. colloq/_app/kernel/Dockerfile +93 -0
  11. colloq/_app/kernel/environments/base-gpu.txt +21 -0
  12. colloq/_app/kernel/environments/base.txt +12 -0
  13. colloq/_app/kernel/environments/cv.txt +15 -0
  14. colloq/_app/kernel/environments/gpu.txt +17 -0
  15. colloq/_app/kernel/environments/kaggle-base.txt +20 -0
  16. colloq/_app/kernel/environments/pvz-geo.txt +6 -0
  17. colloq/_app/kernel/requirements.txt +18 -0
  18. colloq/_app/package.json +20 -0
  19. colloq/_app/scripts/backup-local.sh +165 -0
  20. colloq/_app/scripts/backup.sh +34 -0
  21. colloq/_app/scripts/cluster.sh +492 -0
  22. colloq/_app/scripts/dns.sh +251 -0
  23. colloq/_app/scripts/host.sh +1514 -0
  24. colloq/_app/scripts/lib.sh +62 -0
  25. colloq/_app/scripts/restore.sh +338 -0
  26. colloq/_app/scripts/runtime-backup.py +373 -0
  27. colloq/_app/scripts/state-lock.py +85 -0
  28. colloq/_app/server/assets/fonts/HSESans-Black.otf +0 -0
  29. colloq/_app/server/assets/fonts/HSESans-Bold.otf +0 -0
  30. colloq/_app/server/assets/fonts/HSESans-Regular.otf +0 -0
  31. colloq/_app/server/assets/fonts/JetBrainsMono-Medium.ttf +0 -0
  32. colloq/_app/server/assets/fonts/JetBrainsMono-OFL.txt +93 -0
  33. colloq/_app/server/dist/local-cleanup.js +15987 -0
  34. colloq/_app/server/dist/runtime-smoke.js +17026 -0
  35. colloq/_app/server/dist/server.js +54333 -0
  36. colloq/_app/server/package.json +6 -0
  37. colloq/_app/web/dist/assets/AdminScreen-C-cg0GlJ.css +1 -0
  38. colloq/_app/web/dist/assets/AdminScreen-C-cg0GlJ.css.br +0 -0
  39. colloq/_app/web/dist/assets/AdminScreen-C-cg0GlJ.css.gz +0 -0
  40. colloq/_app/web/dist/assets/AdminScreen-fu4cB6Ra.js +105 -0
  41. colloq/_app/web/dist/assets/AdminScreen-fu4cB6Ra.js.br +0 -0
  42. colloq/_app/web/dist/assets/AdminScreen-fu4cB6Ra.js.gz +0 -0
  43. colloq/_app/web/dist/assets/App-CMXNqOjp.css +1 -0
  44. colloq/_app/web/dist/assets/App-CMXNqOjp.css.br +0 -0
  45. colloq/_app/web/dist/assets/App-CMXNqOjp.css.gz +0 -0
  46. colloq/_app/web/dist/assets/App-DvCPzIRO.js +26 -0
  47. colloq/_app/web/dist/assets/App-DvCPzIRO.js.br +0 -0
  48. colloq/_app/web/dist/assets/App-DvCPzIRO.js.gz +0 -0
  49. colloq/_app/web/dist/assets/CellOutputs-Dl-AZL45.js +42 -0
  50. colloq/_app/web/dist/assets/CellOutputs-Dl-AZL45.js.br +0 -0
  51. colloq/_app/web/dist/assets/CellOutputs-Dl-AZL45.js.gz +0 -0
  52. colloq/_app/web/dist/assets/CommandPalette-DhBGho7_.js +3 -0
  53. colloq/_app/web/dist/assets/CommandPalette-DhBGho7_.js.br +0 -0
  54. colloq/_app/web/dist/assets/CommandPalette-DhBGho7_.js.gz +0 -0
  55. colloq/_app/web/dist/assets/CompetitionsScreen-Vu3RpHmf.js +3 -0
  56. colloq/_app/web/dist/assets/CompetitionsScreen-Vu3RpHmf.js.br +0 -0
  57. colloq/_app/web/dist/assets/CompetitionsScreen-Vu3RpHmf.js.gz +0 -0
  58. colloq/_app/web/dist/assets/ConsoleView-B2uyYvFh.js +1 -0
  59. colloq/_app/web/dist/assets/ConsoleView-B2uyYvFh.js.br +0 -0
  60. colloq/_app/web/dist/assets/ConsoleView-B2uyYvFh.js.gz +0 -0
  61. colloq/_app/web/dist/assets/ConsoleView-DLbp0Jul.css +1 -0
  62. colloq/_app/web/dist/assets/DependencyBundleCard-B392XMVZ.js +4 -0
  63. colloq/_app/web/dist/assets/DependencyBundleCard-B392XMVZ.js.br +0 -0
  64. colloq/_app/web/dist/assets/DependencyBundleCard-B392XMVZ.js.gz +0 -0
  65. colloq/_app/web/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  66. colloq/_app/web/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  67. colloq/_app/web/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  68. colloq/_app/web/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  69. colloq/_app/web/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  70. colloq/_app/web/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  71. colloq/_app/web/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  72. colloq/_app/web/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  73. colloq/_app/web/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  74. colloq/_app/web/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  75. colloq/_app/web/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  76. colloq/_app/web/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  77. colloq/_app/web/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  78. colloq/_app/web/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  79. colloq/_app/web/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  80. colloq/_app/web/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  81. colloq/_app/web/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  82. colloq/_app/web/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  83. colloq/_app/web/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  84. colloq/_app/web/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  85. colloq/_app/web/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  86. colloq/_app/web/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  87. colloq/_app/web/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  88. colloq/_app/web/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  89. colloq/_app/web/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  90. colloq/_app/web/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  91. colloq/_app/web/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  92. colloq/_app/web/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  93. colloq/_app/web/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  94. colloq/_app/web/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  95. colloq/_app/web/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  96. colloq/_app/web/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  97. colloq/_app/web/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  98. colloq/_app/web/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  99. colloq/_app/web/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  100. colloq/_app/web/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  101. colloq/_app/web/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  102. colloq/_app/web/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  103. colloq/_app/web/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  104. colloq/_app/web/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  105. colloq/_app/web/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  106. colloq/_app/web/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  107. colloq/_app/web/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  108. colloq/_app/web/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  109. colloq/_app/web/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  110. colloq/_app/web/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  111. colloq/_app/web/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  112. colloq/_app/web/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  113. colloq/_app/web/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  114. colloq/_app/web/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  115. colloq/_app/web/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  116. colloq/_app/web/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  117. colloq/_app/web/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  118. colloq/_app/web/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  119. colloq/_app/web/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  120. colloq/_app/web/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  121. colloq/_app/web/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  122. colloq/_app/web/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  123. colloq/_app/web/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  124. colloq/_app/web/dist/assets/PultWindow-B3cc8537.css +1 -0
  125. colloq/_app/web/dist/assets/PultWindow-B3cc8537.css.br +0 -0
  126. colloq/_app/web/dist/assets/PultWindow-B3cc8537.css.gz +0 -0
  127. colloq/_app/web/dist/assets/PultWindow-DPOUhSqt.js +8 -0
  128. colloq/_app/web/dist/assets/PultWindow-DPOUhSqt.js.br +0 -0
  129. colloq/_app/web/dist/assets/PultWindow-DPOUhSqt.js.gz +0 -0
  130. colloq/_app/web/dist/assets/ReaderScreen-CV_0VAvW.js +9 -0
  131. colloq/_app/web/dist/assets/ReaderScreen-CV_0VAvW.js.br +0 -0
  132. colloq/_app/web/dist/assets/ReaderScreen-CV_0VAvW.js.gz +0 -0
  133. colloq/_app/web/dist/assets/SessionScreen-BrcB6CLH.js +132 -0
  134. colloq/_app/web/dist/assets/SessionScreen-BrcB6CLH.js.br +0 -0
  135. colloq/_app/web/dist/assets/SessionScreen-BrcB6CLH.js.gz +0 -0
  136. colloq/_app/web/dist/assets/SessionScreen-Dek9dK3C.css +1 -0
  137. colloq/_app/web/dist/assets/SessionScreen-Dek9dK3C.css.br +0 -0
  138. colloq/_app/web/dist/assets/SessionScreen-Dek9dK3C.css.gz +0 -0
  139. colloq/_app/web/dist/assets/actions-DLPyolgo.js +1 -0
  140. colloq/_app/web/dist/assets/admin-en-BHmR7Co0.js +9 -0
  141. colloq/_app/web/dist/assets/admin-en-BHmR7Co0.js.br +0 -0
  142. colloq/_app/web/dist/assets/admin-en-BHmR7Co0.js.gz +0 -0
  143. colloq/_app/web/dist/assets/admin-ru-Dr6Z36Zi.js +11 -0
  144. colloq/_app/web/dist/assets/admin-ru-Dr6Z36Zi.js.br +0 -0
  145. colloq/_app/web/dist/assets/admin-ru-Dr6Z36Zi.js.gz +0 -0
  146. colloq/_app/web/dist/assets/codemirror-6LUh0q1l.js +32 -0
  147. colloq/_app/web/dist/assets/codemirror-6LUh0q1l.js.br +0 -0
  148. colloq/_app/web/dist/assets/codemirror-6LUh0q1l.js.gz +0 -0
  149. colloq/_app/web/dist/assets/competitions-en-zw57uAGL.js +5 -0
  150. colloq/_app/web/dist/assets/competitions-en-zw57uAGL.js.br +0 -0
  151. colloq/_app/web/dist/assets/competitions-en-zw57uAGL.js.gz +0 -0
  152. colloq/_app/web/dist/assets/competitions-ru-BBQUjAx0.js +5 -0
  153. colloq/_app/web/dist/assets/competitions-ru-BBQUjAx0.js.br +0 -0
  154. colloq/_app/web/dist/assets/competitions-ru-BBQUjAx0.js.gz +0 -0
  155. colloq/_app/web/dist/assets/editor-theme-CVYtpZDy.js +1 -0
  156. colloq/_app/web/dist/assets/editor-theme-CVYtpZDy.js.br +0 -0
  157. colloq/_app/web/dist/assets/editor-theme-CVYtpZDy.js.gz +0 -0
  158. colloq/_app/web/dist/assets/index-CwbYK31L.css +1 -0
  159. colloq/_app/web/dist/assets/index-CwbYK31L.css.br +0 -0
  160. colloq/_app/web/dist/assets/index-CwbYK31L.css.gz +0 -0
  161. colloq/_app/web/dist/assets/index-Di16xdmz.js +2 -0
  162. colloq/_app/web/dist/assets/index-Di16xdmz.js.br +0 -0
  163. colloq/_app/web/dist/assets/index-Di16xdmz.js.gz +0 -0
  164. colloq/_app/web/dist/assets/katex-CS_ezkZp.css +1 -0
  165. colloq/_app/web/dist/assets/katex-CS_ezkZp.css.br +0 -0
  166. colloq/_app/web/dist/assets/katex-CS_ezkZp.css.gz +0 -0
  167. colloq/_app/web/dist/assets/katex-DLorvT8M.js +257 -0
  168. colloq/_app/web/dist/assets/katex-DLorvT8M.js.br +0 -0
  169. colloq/_app/web/dist/assets/katex-DLorvT8M.js.gz +0 -0
  170. colloq/_app/web/dist/assets/pdf-DEp6sk-T.js +55 -0
  171. colloq/_app/web/dist/assets/pdf-DEp6sk-T.js.br +0 -0
  172. colloq/_app/web/dist/assets/pdf-DEp6sk-T.js.gz +0 -0
  173. colloq/_app/web/dist/assets/persistence.svelte-vcuCBcT5.js +1 -0
  174. colloq/_app/web/dist/assets/persistence.svelte-vcuCBcT5.js.br +0 -0
  175. colloq/_app/web/dist/assets/persistence.svelte-vcuCBcT5.js.gz +0 -0
  176. colloq/_app/web/dist/assets/reader-en-UCGE3U6V.js +3 -0
  177. colloq/_app/web/dist/assets/reader-en-UCGE3U6V.js.br +0 -0
  178. colloq/_app/web/dist/assets/reader-en-UCGE3U6V.js.gz +0 -0
  179. colloq/_app/web/dist/assets/reader-ru-CaiNj0K8.js +3 -0
  180. colloq/_app/web/dist/assets/reader-ru-CaiNj0K8.js.br +0 -0
  181. colloq/_app/web/dist/assets/reader-ru-CaiNj0K8.js.gz +0 -0
  182. colloq/_app/web/dist/assets/render-CjP3RuRT.js +155 -0
  183. colloq/_app/web/dist/assets/render-CjP3RuRT.js.br +0 -0
  184. colloq/_app/web/dist/assets/render-CjP3RuRT.js.gz +0 -0
  185. colloq/_app/web/dist/assets/render.svelte-B4_hAJmX.js +0 -0
  186. colloq/_app/web/dist/assets/render.svelte-B4_hAJmX.js.br +0 -0
  187. colloq/_app/web/dist/assets/render.svelte-B4_hAJmX.js.gz +0 -0
  188. colloq/_app/web/dist/assets/room-en-Bh8L6NcO.js +3 -0
  189. colloq/_app/web/dist/assets/room-en-Bh8L6NcO.js.br +0 -0
  190. colloq/_app/web/dist/assets/room-en-Bh8L6NcO.js.gz +0 -0
  191. colloq/_app/web/dist/assets/room-ru-Bar2GgS0.js +3 -0
  192. colloq/_app/web/dist/assets/room-ru-Bar2GgS0.js.br +0 -0
  193. colloq/_app/web/dist/assets/room-ru-Bar2GgS0.js.gz +0 -0
  194. colloq/_app/web/dist/assets/seminar-link-CRaZl9Ku.js +1 -0
  195. colloq/_app/web/dist/assets/seminar-link-CRaZl9Ku.js.br +0 -0
  196. colloq/_app/web/dist/assets/seminar-link-CRaZl9Ku.js.gz +0 -0
  197. colloq/_app/web/dist/assets/seminar-link-DoTugxxA.css +1 -0
  198. colloq/_app/web/dist/assets/seminar-link-DoTugxxA.css.br +0 -0
  199. colloq/_app/web/dist/assets/seminar-link-DoTugxxA.css.gz +0 -0
  200. colloq/_app/web/dist/assets/yjs-rjB3hdnL.js +6 -0
  201. colloq/_app/web/dist/assets/yjs-rjB3hdnL.js.br +0 -0
  202. colloq/_app/web/dist/assets/yjs-rjB3hdnL.js.gz +0 -0
  203. colloq/_app/web/dist/fonts/JetBrainsMono-OFL.txt +93 -0
  204. colloq/_app/web/dist/fonts/hse-sans-400.woff2 +0 -0
  205. colloq/_app/web/dist/fonts/hse-sans-600.woff2 +0 -0
  206. colloq/_app/web/dist/fonts/hse-sans-700.woff2 +0 -0
  207. colloq/_app/web/dist/fonts/hse-sans-900.woff2 +0 -0
  208. colloq/_app/web/dist/fonts/jetbrains-mono-cyrillic-ext.woff2 +0 -0
  209. colloq/_app/web/dist/fonts/jetbrains-mono-cyrillic.woff2 +0 -0
  210. colloq/_app/web/dist/fonts/jetbrains-mono-latin-ext.woff2 +0 -0
  211. colloq/_app/web/dist/fonts/jetbrains-mono-latin.woff2 +0 -0
  212. colloq/_app/web/dist/index.html +268 -0
  213. colloq/_app/web/dist/og/colloq.png +0 -0
  214. colloq/_app/web/dist/pdf/pdf.worker.min.mjs +29 -0
  215. colloq/_app/web/dist/pdf/pdf.worker.min.mjs.br +0 -0
  216. colloq/_app/web/dist/pdf/pdf.worker.min.mjs.gz +0 -0
  217. colloq/_app/web/dist/plotly/plotly.min.js +3620 -0
  218. colloq/_app/web/dist/plotly/plotly.min.js.br +0 -0
  219. colloq/_app/web/dist/plotly/plotly.min.js.gz +0 -0
  220. colloq/_version.py +5 -0
  221. colloq-0.2.0.dist-info/METADATA +124 -0
  222. colloq-0.2.0.dist-info/RECORD +224 -0
  223. colloq-0.2.0.dist-info/WHEEL +4 -0
  224. colloq-0.2.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,2517 @@
1
+ #!/usr/bin/env node
2
+
3
+ // cli/src/main.ts
4
+ import { parseArgs } from "node:util";
5
+
6
+ // cli/src/ui.ts
7
+ var SYMBOL = { on: "\u25CF", off: "\u25CB", ok: "\u2713", bad: "\u2717", next: "\u2192" };
8
+ var BOLD = "\x1B[1m";
9
+ var DIM = "\x1B[2m";
10
+ var CYAN = "\x1B[36m";
11
+ var RED = "\x1B[31m";
12
+ var OFF = "\x1B[0m";
13
+ var PreconditionError = class extends Error {
14
+ code = 3;
15
+ fix;
16
+ why;
17
+ constructor(message, fix = "", why = "") {
18
+ super(message);
19
+ this.name = "PreconditionError";
20
+ this.fix = fix;
21
+ this.why = why;
22
+ }
23
+ };
24
+ var UsageError = class extends Error {
25
+ code = 2;
26
+ fix;
27
+ why;
28
+ constructor(message, fix = "", why = "") {
29
+ super(message);
30
+ this.name = "UsageError";
31
+ this.fix = fix;
32
+ this.why = why;
33
+ }
34
+ };
35
+ function visibleLength(text) {
36
+ return stripAnsi(text).length;
37
+ }
38
+ function stripAnsi(text) {
39
+ return text.replace(/\u001b\[[0-9;]*m/g, "");
40
+ }
41
+ function colorAllowed(env, isTty) {
42
+ if ("NO_COLOR" in env) return false;
43
+ return isTty;
44
+ }
45
+ function writer(stream) {
46
+ let broken = false;
47
+ stream.on("error", () => void (broken = true));
48
+ return (text) => {
49
+ if (broken) return;
50
+ try {
51
+ stream.write(text + "\n");
52
+ } catch {
53
+ broken = true;
54
+ }
55
+ };
56
+ }
57
+ function createUi(opts = {}) {
58
+ const color = opts.color ?? false;
59
+ const out = opts.out ?? writer(process.stdout);
60
+ const err = opts.err ?? writer(process.stderr);
61
+ const tty = opts.tty ?? false;
62
+ let jsonMode = opts.json ?? false;
63
+ let jsonDone = false;
64
+ const paint = (code, text) => color ? code + text + OFF : text;
65
+ const ui = {
66
+ get color() {
67
+ return color;
68
+ },
69
+ get jsonMode() {
70
+ return jsonMode;
71
+ },
72
+ bold: (text) => paint(BOLD, text),
73
+ dim: (text) => paint(DIM, text),
74
+ cyan: (text) => paint(CYAN, text),
75
+ red: (text) => paint(RED, text),
76
+ line(text = "") {
77
+ if (jsonMode) return;
78
+ out(text);
79
+ },
80
+ header(text) {
81
+ if (jsonMode) return;
82
+ out(ui.bold(text));
83
+ },
84
+ kv(key, value, hint) {
85
+ if (jsonMode) return;
86
+ const tail = hint ? " \xB7 " + ui.dim(hint) : "";
87
+ out(" " + key.padEnd(12) + value + tail);
88
+ },
89
+ hint(text) {
90
+ if (jsonMode) return;
91
+ out(" " + ui.dim(SYMBOL.next + " " + text));
92
+ },
93
+ table(rows) {
94
+ if (jsonMode) return;
95
+ if (rows.length === 0) return;
96
+ const indent = " ";
97
+ const gap = " ";
98
+ const columns = Math.max(...rows.map((row) => row.length));
99
+ const widths = [];
100
+ for (let i = 0; i < columns; i++) {
101
+ widths[i] = Math.max(...rows.map((row) => visibleLength(row[i] ?? "")));
102
+ }
103
+ for (const row of rows) {
104
+ const cells = [];
105
+ for (let i = 0; i < columns; i++) {
106
+ const cell = row[i] ?? "";
107
+ const pad = " ".repeat(Math.max(0, (widths[i] ?? 0) - visibleLength(cell)));
108
+ cells.push(cell + pad);
109
+ }
110
+ out((indent + cells.join(gap)).replace(/[ ]+$/, ""));
111
+ }
112
+ },
113
+ refuse(what, why, fix) {
114
+ if (jsonMode) {
115
+ ui.json({ ok: false, code: 3, error: what + (why ? ". " + why : ""), hint: fix });
116
+ return;
117
+ }
118
+ err(ui.red(SYMBOL.bad + " " + what));
119
+ if (why) err(why);
120
+ if (fix) err(ui.dim(SYMBOL.next + " " + fix));
121
+ },
122
+ async confirm(question) {
123
+ const ask2 = opts.ask;
124
+ if (!ask2 && !tty) {
125
+ throw new PreconditionError(
126
+ "no terminal to ask in",
127
+ "repeat with --yes if you agree in advance"
128
+ );
129
+ }
130
+ const line = SYMBOL.next + " " + question + " [y/N] ";
131
+ const answer = ask2 ? await ask2(line) : await readLine(line);
132
+ const value = answer.trim().toLowerCase();
133
+ return value === "y" || value === "yes";
134
+ },
135
+ json(value) {
136
+ jsonMode = true;
137
+ if (jsonDone) return;
138
+ jsonDone = true;
139
+ out(JSON.stringify(value));
140
+ }
141
+ };
142
+ return ui;
143
+ }
144
+ function countWord(count, one, many = one + "s") {
145
+ return count + " " + (count === 1 ? one : many);
146
+ }
147
+ function roomsWord(count) {
148
+ return countWord(count, "room");
149
+ }
150
+ function heading(ctx, text) {
151
+ if (!ctx.dryRun) ctx.ui.header(text);
152
+ }
153
+ function cancelled(ui) {
154
+ ui.line(ui.dim("cancelled"));
155
+ return 4;
156
+ }
157
+ async function readLine(question) {
158
+ const readline = await import("node:readline/promises");
159
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
160
+ try {
161
+ return await rl.question(question);
162
+ } finally {
163
+ rl.close();
164
+ }
165
+ }
166
+
167
+ // cli/src/env.ts
168
+ import * as fs2 from "node:fs";
169
+
170
+ // cli/src/launch-state.ts
171
+ import fs from "node:fs";
172
+ import os from "node:os";
173
+ import path from "node:path";
174
+ import { fileURLToPath } from "node:url";
175
+ var selfDir = path.dirname(fileURLToPath(import.meta.url));
176
+ function expandHome(value) {
177
+ return value === "~" || value.startsWith("~/") ? path.join(os.homedir(), value.slice(1)) : value;
178
+ }
179
+ function isRepository(dir) {
180
+ try {
181
+ if (!fs.existsSync(path.join(dir, "Makefile"))) return false;
182
+ return /"name"\s*:\s*"colloq"/.test(fs.readFileSync(path.join(dir, "package.json"), "utf8"));
183
+ } catch {
184
+ return false;
185
+ }
186
+ }
187
+ function isWorkingTree(dir) {
188
+ return fs.existsSync(path.join(dir, "cli/src/launch.ts"));
189
+ }
190
+ function isDistribution(dir = appDir()) {
191
+ return fs.existsSync(path.join(dir, ".colloq-dist.json"));
192
+ }
193
+ function looksLikeApp(dir) {
194
+ if (isRepository(dir) || fs.existsSync(path.join(dir, ".colloq-dist.json"))) return true;
195
+ return fs.existsSync(path.join(dir, "server/dist/server.js")) && fs.existsSync(path.join(dir, "web/dist"));
196
+ }
197
+ var appCache;
198
+ function appDir() {
199
+ if (appCache) return appCache;
200
+ const explicit = process.env.COLLOQ_APP_DIR;
201
+ if (explicit) return appCache = path.resolve(expandHome(explicit));
202
+ for (let dir = selfDir, step = 0; step < 12; step++) {
203
+ if (looksLikeApp(dir)) return appCache = dir;
204
+ const up = path.dirname(dir);
205
+ if (up === dir) break;
206
+ dir = up;
207
+ }
208
+ return appCache = path.resolve(selfDir, "../..");
209
+ }
210
+ function userStateDir() {
211
+ const xdg = process.env.XDG_DATA_HOME;
212
+ return xdg && path.isAbsolute(xdg) ? path.join(xdg, "colloq") : path.join(os.homedir(), ".colloq");
213
+ }
214
+ function resolveHome() {
215
+ const explicit = process.env.COLLOQ_HOME;
216
+ if (explicit) return path.resolve(expandHome(explicit));
217
+ const app = appDir();
218
+ if (isRepository(app)) return app;
219
+ if (isDistribution(app)) return userStateDir();
220
+ if (isWorkingTree(app)) return app;
221
+ return userStateDir();
222
+ }
223
+ var homeCache;
224
+ function homeDir() {
225
+ if (homeCache) return homeCache;
226
+ const home = resolveHome();
227
+ fs.mkdirSync(home, { recursive: true, mode: 448 });
228
+ return homeCache = home;
229
+ }
230
+ var SESSION_FILE = ".colloq/local-session.json";
231
+
232
+ // cli/src/env.ts
233
+ function envNameOk(name) {
234
+ return /^[A-Za-z0-9-]+$/.test(name) && !name.startsWith("-") && !name.endsWith("-");
235
+ }
236
+ var SECRET_KEYS = [
237
+ "JUPYTER_TOKEN",
238
+ "SESSION_SECRET",
239
+ "RELAY_TOKEN",
240
+ "CF_TOKEN",
241
+ "VAST_TOKEN",
242
+ "OPENAI_API_KEY",
243
+ "OPENROUTER_API_KEY"
244
+ ];
245
+ var SEP = "/";
246
+ function joinPath(...parts) {
247
+ const segments = [];
248
+ let absolute = false;
249
+ for (const part of parts) {
250
+ if (part === "") continue;
251
+ if (part.startsWith(SEP)) {
252
+ segments.length = 0;
253
+ absolute = true;
254
+ }
255
+ for (const piece of part.split(SEP)) {
256
+ if (piece === "" || piece === ".") continue;
257
+ if (piece === "..") {
258
+ segments.pop();
259
+ continue;
260
+ }
261
+ segments.push(piece);
262
+ }
263
+ }
264
+ return (absolute ? SEP : "") + segments.join(SEP);
265
+ }
266
+ function parseEnvValue(text, key) {
267
+ let found = "";
268
+ for (const raw of text.split("\n")) {
269
+ if (!raw.startsWith(key + "=")) continue;
270
+ found = raw.slice(key.length + 1);
271
+ }
272
+ let value = found;
273
+ if (value.endsWith("\r")) value = value.slice(0, -1);
274
+ value = value.replace(/^[\s]+/, "").replace(/[\s]+$/, "");
275
+ if (value.length >= 2) {
276
+ const first = value[0];
277
+ const last = value[value.length - 1];
278
+ if (first === '"' && last === '"' || first === "'" && last === "'") {
279
+ value = value.slice(1, -1);
280
+ }
281
+ }
282
+ return value;
283
+ }
284
+ function createIo() {
285
+ return {
286
+ exists: (path2) => fs2.existsSync(path2),
287
+ readText: (path2) => {
288
+ try {
289
+ return fs2.readFileSync(path2, "utf8");
290
+ } catch {
291
+ return null;
292
+ }
293
+ },
294
+ mtime: (path2) => {
295
+ try {
296
+ return fs2.statSync(path2).mtimeMs;
297
+ } catch {
298
+ return null;
299
+ }
300
+ },
301
+ list: (path2) => {
302
+ try {
303
+ return fs2.readdirSync(path2).slice().sort();
304
+ } catch {
305
+ return [];
306
+ }
307
+ },
308
+ now: () => Date.now(),
309
+ writeText: (path2, text, mode) => {
310
+ const dir = path2.slice(0, path2.lastIndexOf(SEP));
311
+ if (dir) fs2.mkdirSync(dir, { recursive: true });
312
+ fs2.writeFileSync(path2, text, mode === void 0 ? void 0 : { mode });
313
+ }
314
+ };
315
+ }
316
+ function createEnv(opts = {}) {
317
+ const processEnv = opts.processEnv ?? process.env;
318
+ const io = opts.io ?? createIo();
319
+ const root = opts.root ?? appDir();
320
+ const home = opts.home ?? opts.root ?? homeDir();
321
+ const cwd = opts.cwd ?? processEnv.COLLOQ_CWD ?? root;
322
+ const path2 = (...parts) => joinPath(root, ...parts);
323
+ const statePath = (...parts) => joinPath(home, ...parts);
324
+ const paths = {
325
+ root,
326
+ home,
327
+ envFile: statePath(".env"),
328
+ pidFile: statePath(".colloq.pid"),
329
+ logFile: statePath(".colloq.log"),
330
+ envDir: path2("kernel/environments"),
331
+ /*
332
+ * Второй каталог окружений — и он заводится ровно там, где первый писать
333
+ * нельзя.
334
+ *
335
+ * Окружения, приехавшие с продуктом (base, base-gpu, cv, gpu), лежат в
336
+ * <app>/kernel/environments. У установленного colloq это site-packages:
337
+ * каталог целиком перезаписывается следующим `pip install -U`, а на многих
338
+ * машинах не пишется вовсе. Завести там своё окружение значит либо
339
+ * получить отказ прав, либо потерять файл на первом же обновлении.
340
+ *
341
+ * Поэтому своё живёт в каталоге состояния, рядом с .env и data/, — там,
342
+ * где его никто не перезапишет: <home>/environments.
343
+ *
344
+ * В репозитории (и в любом рабочем дереве, где home и есть корень
345
+ * приложения) второй каталог — это ПЕРВЫЙ, тот же самый путь. Так и
346
+ * задумано: там kernel/environments и пишется, и читается, и попадает в
347
+ * контекст `docker build`, и её же видят Makefile, launch-config.ts ·
348
+ * kernelInputs и панель преподавателя. Новый каталог рядом увёл бы файл
349
+ * из-под всех троих, и `colloq env new` в клоне заводил бы окружение,
350
+ * которого не видит сборка.
351
+ */
352
+ ownEnvDir: home === root ? path2("kernel/environments") : joinPath(home, "environments"),
353
+ sessionFile: statePath(SESSION_FILE),
354
+ backupsDir: statePath("backups"),
355
+ dist: path2("web/dist")
356
+ };
357
+ const readRaw = (key) => {
358
+ const text = io.readText(paths.envFile);
359
+ if (text === null) return "";
360
+ return parseEnvValue(text, key);
361
+ };
362
+ const env = {
363
+ root: () => root,
364
+ path: path2,
365
+ userPath: (value) => value.startsWith(SEP) ? value : joinPath(cwd, value),
366
+ read(key) {
367
+ if (SECRET_KEYS.includes(key)) {
368
+ throw new Error(
369
+ "the value of " + key + " is not readable: secrets live in .env, the CLI only has has()"
370
+ );
371
+ }
372
+ return readRaw(key);
373
+ },
374
+ has: (key) => readRaw(key) !== "",
375
+ port() {
376
+ const value = Number.parseInt(readRaw("PORT"), 10);
377
+ return Number.isFinite(value) && value > 0 ? value : 3e3;
378
+ },
379
+ kernelEnv: () => readRaw("KERNEL_ENV") || "base",
380
+ home: () => processEnv.HOME ?? processEnv.USERPROFILE ?? "",
381
+ publicUrl: () => readRaw("PUBLIC_URL"),
382
+ relay() {
383
+ const port = Number.parseInt(readRaw("RELAY_PORT"), 10);
384
+ return {
385
+ domain: readRaw("RELAY_DOMAIN"),
386
+ addr: readRaw("RELAY_ADDR"),
387
+ port: Number.isFinite(port) && port > 0 ? port : 7e3
388
+ };
389
+ },
390
+ paths,
391
+ io
392
+ };
393
+ return env;
394
+ }
395
+
396
+ // cli/src/session.ts
397
+ function whole(value) {
398
+ return typeof value === "number" && Number.isSafeInteger(value) && value > 1 ? value : null;
399
+ }
400
+ function loopback(value) {
401
+ if (typeof value !== "string") return null;
402
+ try {
403
+ const url = new URL(value);
404
+ if (url.protocol !== "http:") return null;
405
+ if (!["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)) return null;
406
+ if (url.username || url.password || url.search || url.hash) return null;
407
+ if (url.pathname !== "/") return null;
408
+ return value;
409
+ } catch {
410
+ return null;
411
+ }
412
+ }
413
+ function readSession(io, file) {
414
+ let data;
415
+ try {
416
+ const parsed = JSON.parse(io.readText(file) ?? "null");
417
+ if (!parsed || typeof parsed !== "object") return null;
418
+ data = parsed;
419
+ } catch {
420
+ return null;
421
+ }
422
+ const pid = whole(data.pid);
423
+ const url = loopback(data.url);
424
+ const port = whole(data.port);
425
+ const mode = data.mode === "dev" ? "dev" : data.mode === "run" ? "run" : null;
426
+ if (pid === null || url === null || port === null || port > 65535 || mode === null) return null;
427
+ if (typeof data.runId !== "string" || !data.runId) return null;
428
+ const phase = ["preparing", "starting", "ready", "stopping"].includes(String(data.phase)) ? data.phase : "ready";
429
+ return {
430
+ pid,
431
+ serverPid: whole(data.serverPid),
432
+ port,
433
+ url,
434
+ runId: data.runId,
435
+ mode,
436
+ phase,
437
+ startedAt: whole(data.startedAt) ?? 0,
438
+ leaseFile: typeof data.leaseFile === "string" ? data.leaseFile : ""
439
+ };
440
+ }
441
+
442
+ // cli/src/commands/host.ts
443
+ var PERIMETER = {
444
+ what: "student code runs in a hardened container per room, cut off from your home network",
445
+ fix: "the link is a door: anyone who has it runs code in a sandbox on this computer \u2014 keep it for your class; Ctrl+C closes it"
446
+ };
447
+ var NAME = /^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/;
448
+ function hostNameOk(host) {
449
+ return NAME.test(host);
450
+ }
451
+ function nameOf(ctx) {
452
+ return (ctx.positionals[0] ?? "").trim();
453
+ }
454
+ function directOf(ctx) {
455
+ return ctx.values.direct === true;
456
+ }
457
+ function checkName(ctx) {
458
+ const host = nameOf(ctx);
459
+ if (directOf(ctx) && !host) {
460
+ throw new UsageError(
461
+ "direct mode needs a name",
462
+ "colloq host class.example.org --direct",
463
+ "the certificate is issued for that name, and it cannot be made up for you"
464
+ );
465
+ }
466
+ if (host && !hostNameOk(host)) {
467
+ throw new UsageError(
468
+ "name " + host + " will not do",
469
+ "colloq host class.example.org",
470
+ "a name holds only latin letters, digits, dots and hyphens"
471
+ );
472
+ }
473
+ }
474
+ function needEnv(ctx) {
475
+ if (ctx.io.exists(ctx.env.paths.envFile)) return;
476
+ throw new PreconditionError(
477
+ "no " + ctx.env.paths.envFile + " \u2014 PORT, RELAY_* and CF_TOKEN are read from it",
478
+ "colloq start creates it on the first run"
479
+ );
480
+ }
481
+ function stateEnv(ctx) {
482
+ return { COLLOQ_HOME: ctx.env.paths.home };
483
+ }
484
+ function publishCall(ctx, host, direct) {
485
+ const env = { ...stateEnv(ctx) };
486
+ if (direct) env.COLLOQ_DIRECT = "1";
487
+ if (host) env.COLLOQ_HOSTNAME = host;
488
+ return ctx.sh.script("./scripts/host.sh", [], { env });
489
+ }
490
+ async function publishWhen(ctx) {
491
+ return directOf(ctx) || await ctx.rooms() > 0;
492
+ }
493
+ function publishQuestion(ctx) {
494
+ const host = nameOf(ctx);
495
+ return directOf(ctx) ? "install caddy on 80 and 443 and rewrite the A record for " + host + "?" : "publish " + (host || "the class") + "?";
496
+ }
497
+ function headline(ctx, host, direct) {
498
+ if (direct) return "installing caddy on this machine: it holds 80 and 443 for " + host;
499
+ const domain = ctx.env.relay().domain;
500
+ if (host && domain && (host === domain || host.endsWith("." + domain))) {
501
+ return "publishing " + host + " through the relay: keep this window open";
502
+ }
503
+ if (host) return "publishing " + host + " through Cloudflare, which does not open from Russia";
504
+ return "publishing through a quick Cloudflare tunnel: the name is random, and it does not open from Russia";
505
+ }
506
+ var commands = [
507
+ {
508
+ name: "host",
509
+ aliases: ["public"],
510
+ group: "host",
511
+ summary: "Publish the running class and get a link",
512
+ usage: "colloq host [name] [--direct]",
513
+ args: [
514
+ {
515
+ name: "name",
516
+ summary: "Class address, e.g. class.example.org. Without a name: a quick tunnel"
517
+ }
518
+ ],
519
+ flags: [{ name: "direct", summary: "caddy on this machine itself, without the relay" }],
520
+ destructive: true,
521
+ confirm: "cli",
522
+ check: checkName,
523
+ confirmWhen: publishWhen,
524
+ confirmQuestion: publishQuestion,
525
+ delegates: "COLLOQ_HOSTNAME=<name> ./scripts/host.sh (with --direct: COLLOQ_DIRECT=1 as well)",
526
+ examples: ["colloq host class.example.org", "colloq host class.example.org --direct"],
527
+ notes: "The class must already be running: host.sh publishes, it does not bring anything up \u2014 if /api/health is silent, it dies with an instruction. Keep this window open: the tunnel is this very process, and Ctrl+C closes only the tunnel. In a local session the temporary address is taken down without restarting the server and without changing .env. A name under RELAY_DOMAIN goes to the relay; any other name goes silently to Cloudflare, which does not open from Russia. Only the relay completes a short name to RELAY_DOMAIN, and host.sh decides that. The price of --direct: caddy takes 80 and 443, /etc/caddy/Caddyfile and its unit are rewritten, and an A record points the name at this machine; after Ctrl+C the name stays live. It needs Linux, systemd, root and a public address: on macOS the script refuses by itself. host.sh publishes nothing unless the server confirms in /api/health that every room runs in a container of its own (Docker or the runtime broker). Without cloudflared on PATH, the Cloudflare transports get a pinned release downloaded once into <state>/bin and checked by sha256; COLLOQ_CLOUDFLARED=/path names your own file. For a class started here, colloq start --share does all of this in one step and prints the link for students. Two lines about the perimeter are printed under the header: every room is a hardened container cut off from your home network, and the link is still a door to code on this computer. colloq doctor says the same.",
528
+ async run(ctx) {
529
+ const host = nameOf(ctx);
530
+ const direct = directOf(ctx);
531
+ if (ctx.dryRun) return await publishCall(ctx, host, direct);
532
+ needEnv(ctx);
533
+ ctx.ui.header(headline(ctx, host, direct));
534
+ ctx.ui.hint(PERIMETER.what);
535
+ ctx.ui.hint(PERIMETER.fix);
536
+ return await publishCall(ctx, host, direct);
537
+ }
538
+ }
539
+ ];
540
+
541
+ // shared/local-public-url-lease.ts
542
+ function publicLeaseAddress(value) {
543
+ if (typeof value !== "string") return null;
544
+ try {
545
+ const url = new URL(value);
546
+ if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.search || url.hash) return null;
547
+ return url.href.replace(/\/+$/, "");
548
+ } catch {
549
+ return null;
550
+ }
551
+ }
552
+ function parsePublicUrlLease(raw) {
553
+ try {
554
+ const lease = JSON.parse(raw ?? "");
555
+ if (!lease || typeof lease.runId !== "string" || !lease.runId || typeof lease.owner !== "string" || !lease.owner || !Number.isFinite(lease.expiresAt)) return null;
556
+ const url = publicLeaseAddress(lease.url);
557
+ return url ? { runId: lease.runId, owner: lease.owner, expiresAt: lease.expiresAt, url } : null;
558
+ } catch {
559
+ return null;
560
+ }
561
+ }
562
+ function leaseUrl(raw, runId, now) {
563
+ const lease = parsePublicUrlLease(raw);
564
+ return lease && lease.runId === runId && lease.expiresAt > now ? lease.url : void 0;
565
+ }
566
+
567
+ // cli/src/commands/local.ts
568
+ async function ask(ctx, question) {
569
+ if (await ctx.confirm(question)) return true;
570
+ cancelled(ctx.ui);
571
+ return false;
572
+ }
573
+ function flag(ctx, name) {
574
+ const raw = ctx.values[name];
575
+ return typeof raw === "string" && raw !== "" ? raw : void 0;
576
+ }
577
+ function stateEnv2(ctx) {
578
+ return { COLLOQ_HOME: ctx.env.paths.home };
579
+ }
580
+ function copyCall(ctx, script, vars = {}, args = []) {
581
+ const env = stateEnv2(ctx);
582
+ for (const [key, item] of Object.entries(vars)) {
583
+ if (item !== void 0 && item !== "") env[key] = item;
584
+ }
585
+ return ctx.sh.script(script, args, { env });
586
+ }
587
+ function checkName2(name) {
588
+ if (name === void 0) return;
589
+ if (!envNameOk(name)) {
590
+ throw new UsageError(
591
+ 'environment name "' + name + '" is not valid',
592
+ "letters, digits and a hyphen in the middle: --name lab"
593
+ );
594
+ }
595
+ }
596
+ function checkLines(lines) {
597
+ if (lines === void 0) return;
598
+ if (!/^[0-9]+$/.test(lines) || lines === "0") {
599
+ throw new UsageError(
600
+ '"' + lines + '" is not a number of lines',
601
+ "a whole number: colloq logs -n 200"
602
+ );
603
+ }
604
+ }
605
+ function localSession(ctx) {
606
+ return readSession(ctx.io, ctx.env.paths.sessionFile);
607
+ }
608
+ async function sessionAddress(ctx) {
609
+ const session = localSession(ctx);
610
+ if (session === null) return void 0;
611
+ const alive = await ctx.sh.capture("kill", ["-0", String(session.pid)], { timeoutMs: 4e3 });
612
+ const publicUrl = alive.code === 0 && session.leaseFile !== "" ? leaseUrl(ctx.io.readText(session.leaseFile), session.runId, ctx.io.now()) : void 0;
613
+ return { local: session.url, public: publicUrl };
614
+ }
615
+ function checkLaunch(ctx) {
616
+ const port = ctx.values.port;
617
+ if (typeof port === "string" && (!/^[0-9]+$/.test(port) || Number(port) < 1 || Number(port) > 65535)) {
618
+ throw new UsageError(
619
+ "the port must be a whole number from 1 to 65535",
620
+ "colloq run --port 3000"
621
+ );
622
+ }
623
+ const host = ctx.values.host;
624
+ if (typeof host === "string" && !hostNameOk(host)) {
625
+ throw new UsageError("the tunnel name is not valid", "colloq run --host class.example.org");
626
+ }
627
+ if (ctx.values.share === true && host !== void 0) {
628
+ throw new UsageError(
629
+ "--share and --host do not work together",
630
+ "colloq start --share for a quick link, or colloq start --host class.example.org for your own name"
631
+ );
632
+ }
633
+ }
634
+ function launcher(ctx) {
635
+ return ctx.dist ? [ctx.env.path("cli/launch.mjs")] : ["--import", "tsx", ctx.env.path("cli/src/launch.ts")];
636
+ }
637
+ async function launch(ctx, command) {
638
+ const args = [...launcher(ctx), command];
639
+ if (command === "run") {
640
+ const host = flag(ctx, "host");
641
+ if (host) args.push("--host", host);
642
+ if (ctx.values.share === true) args.push("--share");
643
+ if (ctx.values.detach === true) args.push("--detach");
644
+ const port = flag(ctx, "port");
645
+ if (port) args.push("--port", port);
646
+ if (ctx.values["no-open"] === true) args.push("--no-open");
647
+ }
648
+ if (command === "restart" && ctx.values.build === true) args.push("--build");
649
+ if (command !== "stop" && ctx.values.fast === true) args.push("--fast");
650
+ return await ctx.sh.run("node", args);
651
+ }
652
+ var commands2 = [
653
+ {
654
+ name: "run",
655
+ aliases: ["start"],
656
+ group: "local",
657
+ summary: "Start a local session in this terminal and open the browser",
658
+ usage: "colloq run [--share | --host <name>] [--detach] [--port <port>] [--no-open] [--fast]",
659
+ flags: [
660
+ {
661
+ name: "share",
662
+ summary: "Put the class online through a quick Cloudflare tunnel and print one link"
663
+ },
664
+ { name: "host", arg: "name", summary: "Publish the session through the tunnel it owns" },
665
+ { name: "detach", summary: "Leave the session in the background" },
666
+ { name: "port", arg: "port", summary: "Port of the local server" },
667
+ { name: "no-open", summary: "Do not open the browser" },
668
+ { name: "fast", summary: "Build without pre-compressing the frontend" }
669
+ ],
670
+ destructive: false,
671
+ check: checkLaunch,
672
+ delegates: "native: the class supervisor \u2014 cli/launch.mjs in the distribution, cli/src/launch.ts through tsx from the sources",
673
+ examples: ["colloq start", "colloq start --share", "colloq run --host class.example.org"],
674
+ notes: "colloq start is the same command under its other name, and it is the one a class is started with most of the time. Logs stay in the terminal and are appended to .colloq.log in the state directory as well; Ctrl+C ends the session, its tunnel and the kernels of this database, keeping files and data. A busy server is not restarted. --detach leaves the session in the background; colloq logs follows it, colloq stop ends it. --fast matters only when colloq runs from its sources: an installed colloq arrives built. --share opens a quick Cloudflare tunnel and prints one block: the link for students (or, with no class yet, where to create one), a warning never to share /admin/ links, and how long the link lives. The address is new on every start, and Cloudflare does not open from Russia \u2014 there, use --host with your own relay. Without cloudflared on PATH, colloq downloads a pinned release from GitHub into <state>/bin once and checks its sha256 before running it; COLLOQ_CLOUDFLARED=/path names your own file, COLLOQ_CLOUDFLARED_DOWNLOAD=0 forbids the download. --share and --host publish a class only when every room runs in a Docker container of its own; otherwise they refuse and the class stays local. They do not work together.",
675
+ async run(ctx) {
676
+ heading(ctx, "starting the local session");
677
+ return await launch(ctx, "run");
678
+ }
679
+ },
680
+ {
681
+ name: "stop",
682
+ group: "local",
683
+ summary: "End the local session, its tunnel and the kernels of this database",
684
+ usage: "colloq stop",
685
+ flags: [],
686
+ destructive: true,
687
+ confirm: "cli",
688
+ // Спрашивать «остановить сервер?», чтобы следом сказать «останавливать
689
+ // нечего», — значит спросить зря: вопрос есть только у идущего занятия.
690
+ confirmWhen: async (ctx) => localSession(ctx) !== null,
691
+ confirmQuestion: "stop the server?",
692
+ delegates: "native: the same supervisor with the stop command",
693
+ examples: ["colloq stop", "colloq stop --yes"],
694
+ notes: "From the session receipt (.colloq/local-session.json in the state directory) the supervisor ends the processes it owns and the kernels of its own database. Files and the database are kept. Without a receipt no class is running and there is nothing to stop: every colloq start leaves one.",
695
+ async run(ctx) {
696
+ if (localSession(ctx) !== null) {
697
+ heading(ctx, "stopping the server");
698
+ return await launch(ctx, "stop");
699
+ }
700
+ ctx.ui.line("no class is running \u2014 nothing to stop");
701
+ ctx.ui.hint("start one: colloq start");
702
+ return 0;
703
+ }
704
+ },
705
+ {
706
+ name: "restart",
707
+ group: "local",
708
+ summary: "Restart the local session with the same port, data and tunnel",
709
+ usage: "colloq restart [--build] [--fast]",
710
+ flags: [
711
+ { name: "build", summary: "Rebuild the frontend before restarting" },
712
+ { name: "fast", summary: "With --build, build without compression" }
713
+ ],
714
+ destructive: true,
715
+ confirm: "self",
716
+ delegates: "native: the same supervisor with the restart command",
717
+ examples: ["colloq restart", "colloq restart --build"],
718
+ notes: "The supervisor reads the session receipt, ends the session and starts it again with the same port, data and flags. Without a receipt no class is running, and the command says so in words. --build and --fast matter only when colloq runs from its sources: an installed colloq arrives built and has nothing to rebuild.",
719
+ async run(ctx) {
720
+ if (localSession(ctx) === null) {
721
+ ctx.ui.refuse(
722
+ "no class is running \u2014 nothing to restart",
723
+ "there is no session receipt at " + ctx.env.paths.sessionFile,
724
+ "start one: colloq start"
725
+ );
726
+ return 3;
727
+ }
728
+ if (!ctx.dryRun && !await ask(ctx, "restart the local session?")) return 4;
729
+ heading(ctx, "restarting the local session");
730
+ return await launch(ctx, "restart");
731
+ }
732
+ },
733
+ {
734
+ name: "logs",
735
+ aliases: ["log"],
736
+ group: "local",
737
+ summary: "Watch the log of the class on this machine",
738
+ usage: "colloq logs [-f|--no-follow] [-n <lines>]",
739
+ flags: [
740
+ { name: "follow", short: "f", summary: "Follow (on by default)" },
741
+ { name: "no-follow", summary: "Show the tail and exit" },
742
+ { name: "lines", short: "n", arg: "lines", summary: "How many lines of the tail" }
743
+ ],
744
+ destructive: false,
745
+ delegates: "tail .colloq.log in the state directory",
746
+ examples: ["colloq logs", "colloq logs -n 200 --no-follow"],
747
+ notes: ".colloq.log is written by every colloq start, in the terminal and with --detach alike. It is appended to and never truncated \u2014 the history of past weeks is evidence, and tail only reads. The log holds /admin/t/<token> links: the CLI cuts nothing out of the stream and retells nothing, and it never prints the token itself either.",
748
+ async run(ctx) {
749
+ if (ctx.values.follow === true && ctx.values["no-follow"] === true) {
750
+ throw new UsageError(
751
+ "-f and --no-follow do not work together",
752
+ "follow with -f, show the tail and exit with --no-follow"
753
+ );
754
+ }
755
+ const follow = ctx.values["no-follow"] !== true;
756
+ const lines = flag(ctx, "lines");
757
+ checkLines(lines);
758
+ const log = ctx.env.paths.logFile;
759
+ if (!ctx.dryRun && !ctx.io.exists(log)) {
760
+ ctx.ui.refuse(
761
+ "no log at " + log,
762
+ "no class has been started on this machine yet \u2014 every colloq start writes its log there",
763
+ "colloq start --detach \u2014 the class goes to the background, and colloq logs follows it"
764
+ );
765
+ return 3;
766
+ }
767
+ heading(ctx, "the log of the class");
768
+ const args = [];
769
+ if (follow) args.push("-f");
770
+ if (lines !== void 0) args.push("-n", lines);
771
+ args.push(log);
772
+ return await ctx.sh.run("tail", args);
773
+ }
774
+ },
775
+ {
776
+ name: "link",
777
+ group: "local",
778
+ summary: "Show the class address and where to find the sign-in to the panel",
779
+ usage: "colloq link",
780
+ flags: [],
781
+ destructive: false,
782
+ delegates: "native: reads the session receipt, the temporary address and .env",
783
+ examples: ["colloq link", "colloq link --dry-run"],
784
+ notes: "The setup token is never printed \u2014 not from data/setup-token, not from the log: the /admin/t/<token> link is the key to the whole instance.",
785
+ async run(ctx) {
786
+ if (ctx.dryRun) {
787
+ return ctx.sh.dry(
788
+ "native: link (reads the session receipt, the temporary address and .env)"
789
+ );
790
+ }
791
+ const session = await sessionAddress(ctx);
792
+ if (!session && !ctx.io.exists(ctx.env.paths.envFile)) {
793
+ ctx.ui.refuse(
794
+ "no .env",
795
+ "the class address comes from there",
796
+ "the first run creates it: colloq run"
797
+ );
798
+ return 3;
799
+ }
800
+ const url = session ? session.public ?? session.local : ctx.env.publicUrl();
801
+ const inside = session !== void 0 && session.public === void 0 || url === "" || /^https?:\/\/(127\.0\.0\.1|localhost|0\.0\.0\.0)([:/]|$)/.test(url);
802
+ ctx.ui.header("Class");
803
+ if (session) ctx.ui.kv("local", session.local);
804
+ if (inside) {
805
+ ctx.ui.kv("address", "not published", url === "" ? void 0 : url);
806
+ ctx.ui.hint("colloq host <name> \u2014 publish it and get a link for the room");
807
+ } else {
808
+ ctx.ui.kv("address", ctx.ui.cyan(url));
809
+ }
810
+ const domain = ctx.env.relay().domain;
811
+ if (domain !== "") ctx.ui.kv("domain", domain);
812
+ ctx.ui.kv("sign-in", "printed once at startup");
813
+ ctx.ui.hint(
814
+ "it is in " + ctx.env.paths.logFile + "; on the machine \u2014 in " + joinPath(ctx.env.paths.home, "data/setup-token") + ". The CLI never prints the token itself"
815
+ );
816
+ return 0;
817
+ }
818
+ },
819
+ {
820
+ name: "backup",
821
+ group: "local",
822
+ summary: "Take a backup: a local one of this machine or a portable k3s one",
823
+ usage: "colloq backup [--legacy] [--mode <live|consistent>] [--resume] [--name <environment>] [--out <path>] [--release <path>]",
824
+ flags: [
825
+ { name: "legacy", summary: "A local pair of .db and -files.tar.gz (the default)" },
826
+ { name: "mode", arg: "mode", summary: "Portable: live (the default) or consistent" },
827
+ { name: "resume", summary: "Portable: start the writers again after consistent" },
828
+ {
829
+ name: "name",
830
+ arg: "environment",
831
+ summary: "Portable backup of an environment: into backups/<environment>/"
832
+ },
833
+ { name: "out", arg: "path", summary: "Portable: where to put the archive" },
834
+ { name: "release", arg: "path", summary: "Portable: which release counts as installed" }
835
+ ],
836
+ destructive: true,
837
+ confirm: "self",
838
+ delegates: "./scripts/backup-local.sh \xB7 with a portable flag \u2014 MODE=\u2026 ./scripts/backup.sh; COLLOQ_HOME names the state directory",
839
+ examples: ["colloq backup", "colloq backup --mode consistent --resume"],
840
+ notes: 'Without flags it is a local backup: the database and the class files of this machine. The portable one is for a machine with k3s on it, and --mode, --resume, --out, --release or --name lead into it. The question is asked only with --mode consistent: it stops app, the broker and every room and deliberately leaves them down \u2014 only --resume brings them back, and a backup that fails leaves the writers down as well. backup.sh restarts itself under scripts/state-lock.py, refuses while .restore-in-progress is there and refuses without an installed release. The local backup (scripts/backup-local.sh) does a VACUUM INTO (sqlite3 required) and an archive with workspace, data/session-secret, data/setup-token and its own environment lists, both files 0600; exit code 1 from tar means "the file changed while it was read", that is a class in progress, and it is allowed. Everything counts from the state directory: backups of this machine live in its backups/, backups of environments in backups/<environment>/.',
841
+ async run(ctx) {
842
+ const given = flag(ctx, "mode");
843
+ const mode = given ?? "live";
844
+ const resume = ctx.values.resume === true;
845
+ const name = flag(ctx, "name");
846
+ const out = flag(ctx, "out");
847
+ const release = flag(ctx, "release");
848
+ const portable = given !== void 0 || resume || name !== void 0 || out !== void 0 || release !== void 0;
849
+ const legacy = ctx.values.legacy === true || !portable;
850
+ if (legacy) {
851
+ const extra = [
852
+ ["--mode", given !== void 0],
853
+ ["--resume", resume],
854
+ ["--out", out !== void 0],
855
+ ["--release", release !== void 0],
856
+ ["--name", name !== void 0]
857
+ ];
858
+ const used = extra.filter(([, on2]) => on2).map(([option]) => option);
859
+ if (used.length > 0) {
860
+ throw new UsageError(
861
+ used.join(" and ") + " does not work with --legacy",
862
+ "a local backup is always the same pair in backups/: colloq backup --legacy"
863
+ );
864
+ }
865
+ heading(ctx, "taking a local backup: the database and the files");
866
+ return await copyCall(ctx, "./scripts/backup-local.sh");
867
+ }
868
+ if (mode !== "live" && mode !== "consistent") {
869
+ throw new UsageError(
870
+ 'there is no "' + mode + '" mode',
871
+ "there is --mode live and there is --mode consistent"
872
+ );
873
+ }
874
+ if (resume && mode !== "consistent") {
875
+ throw new UsageError(
876
+ "--resume only makes sense with --mode consistent",
877
+ "live stops nobody, there is nothing to bring back"
878
+ );
879
+ }
880
+ checkName2(name);
881
+ if (mode === "consistent") {
882
+ const tail = resume ? "" : " without --resume they stay down";
883
+ if (!await ask(ctx, "stop every writer?" + tail)) return 4;
884
+ }
885
+ heading(ctx, mode === "consistent" ? "taking a consistent backup" : "taking a backup on the fly");
886
+ return await copyCall(ctx, "./scripts/backup.sh", {
887
+ MODE: mode,
888
+ RESUME: resume ? "1" : void 0,
889
+ NAME: name,
890
+ OUT: out === void 0 ? void 0 : ctx.env.userPath(out),
891
+ RELEASE: release === void 0 ? void 0 : ctx.env.userPath(release)
892
+ });
893
+ }
894
+ },
895
+ {
896
+ name: "restore",
897
+ group: "local",
898
+ summary: "Restore a backup: a local one of this machine or a portable k3s one",
899
+ usage: "colloq restore --archive <file> --release <file> [--replace] [--recover] [--name <environment>] | colloq restore --legacy --db <file> [--files <archive>] [--name <environment>] [--replace]",
900
+ flags: [
901
+ { name: "archive", arg: "file", summary: "The portable backup, a .tar.gz" },
902
+ { name: "release", arg: "file", summary: "release.json of the same generation" },
903
+ { name: "legacy", summary: "A local pair of .db and -files.tar.gz" },
904
+ { name: "db", arg: "file", summary: "The database from a local backup" },
905
+ { name: "files", arg: "archive", summary: "Class files from a local backup" },
906
+ {
907
+ name: "name",
908
+ arg: "environment",
909
+ summary: "Backup of an environment: from backups/<environment>/"
910
+ },
911
+ {
912
+ name: "replace",
913
+ summary: "Restore over the current database (--yes does not grant this)"
914
+ },
915
+ { name: "recover", summary: "Finish an interrupted restore" }
916
+ ],
917
+ destructive: true,
918
+ confirm: "self",
919
+ delegates: "./scripts/restore.sh <db> [<files>] \xB7 ./scripts/restore.sh --archive \u2026 --release \u2026; COLLOQ_HOME names the state directory",
920
+ examples: [
921
+ "colloq restore --legacy --db backups/colloq-20260914.db --files backups/colloq-20260914-files.tar.gz",
922
+ "colloq restore --archive backups/{env}/colloq-20260914.tar.gz --release release.json"
923
+ ],
924
+ notes: "The portable path asks nothing on its own \u2014 we ask; the local one asks for itself (scripts/restore.sh), and we do not ask a second time. The two worlds must not be mixed: restore.sh rejects an old pair laid on top as soon as a k3s release is installed. The local path refuses if the colloq service is active, the compose app is running, .colloq.pid is alive, or somebody answers on localhost:<PORT>. WAL and SHM travel with their own database, the old database is set aside as data/colloq.db.replaced-<stamp>, and the files from the archive go over workspace/ without a backup of their own. Relative paths count from the directory the command was called from.",
925
+ async run(ctx) {
926
+ const legacy = ctx.values.legacy === true;
927
+ const archive = flag(ctx, "archive");
928
+ const release = flag(ctx, "release");
929
+ const db = flag(ctx, "db");
930
+ const files = flag(ctx, "files");
931
+ const name = flag(ctx, "name");
932
+ const recover = ctx.values.recover === true;
933
+ const replace = ctx.values.replace === true;
934
+ checkName2(name);
935
+ const missing2 = (path2, what) => {
936
+ ctx.ui.refuse(
937
+ "no file " + path2,
938
+ what,
939
+ "see what has been taken: ls backups" + (name === void 0 ? "" : "/" + name)
940
+ );
941
+ return 3;
942
+ };
943
+ if (legacy || db !== void 0 || files !== void 0) {
944
+ const wrong = [
945
+ ["--archive", archive !== void 0],
946
+ ["--release", release !== void 0],
947
+ ["--recover", recover]
948
+ ];
949
+ const used = wrong.filter(([, given]) => given).map(([option]) => option);
950
+ if (used.length > 0) {
951
+ throw new UsageError(
952
+ "an old-format backup and a portable one are different worlds: drop " + used.join(" and "),
953
+ "k3s: colloq restore --archive \u2026 --release \u2026"
954
+ );
955
+ }
956
+ if (db === void 0) {
957
+ throw new UsageError(
958
+ "no --db <file>",
959
+ "Usage: colloq restore --legacy --db backups/colloq-\u2026.db [--files backups/colloq-\u2026-files.tar.gz]"
960
+ );
961
+ }
962
+ const dbPath = ctx.env.userPath(db);
963
+ const filesPath = files === void 0 ? void 0 : ctx.env.userPath(files);
964
+ if (!ctx.dryRun) {
965
+ if (!ctx.io.exists(dbPath)) return missing2(dbPath, "there is nothing to restore");
966
+ if (filesPath !== void 0 && !ctx.io.exists(filesPath)) {
967
+ return missing2(filesPath, "the archive holds the class files and the instance keys");
968
+ }
969
+ }
970
+ heading(ctx, "restoring an old-format backup");
971
+ return await copyCall(
972
+ ctx,
973
+ "./scripts/restore.sh",
974
+ { NAME: name, REPLACE: replace ? "1" : void 0 },
975
+ filesPath === void 0 ? [dbPath] : [dbPath, filesPath]
976
+ );
977
+ }
978
+ if (archive === void 0 || release === void 0) {
979
+ const what = archive === void 0 ? "--archive <file>" : "--release <file>";
980
+ throw new UsageError(
981
+ "no " + what,
982
+ "a portable backup needs both: colloq restore --archive backup.tar.gz --release release.json"
983
+ );
984
+ }
985
+ const archivePath = ctx.env.userPath(archive);
986
+ const releasePath = ctx.env.userPath(release);
987
+ if (!ctx.dryRun) {
988
+ if (!ctx.io.exists(archivePath)) return missing2(archivePath, "there is nothing to restore");
989
+ if (!ctx.io.exists(releasePath)) {
990
+ return missing2(
991
+ releasePath,
992
+ "without release.json the generation of the backup cannot be checked"
993
+ );
994
+ }
995
+ if (!await ask(
996
+ ctx,
997
+ "restore over this machine? the cluster will be stopped and the writers will stay down"
998
+ )) {
999
+ return 4;
1000
+ }
1001
+ }
1002
+ heading(ctx, "restoring a portable backup");
1003
+ return await copyCall(ctx, "./scripts/restore.sh", { NAME: name }, [
1004
+ "--archive",
1005
+ archivePath,
1006
+ "--release",
1007
+ releasePath,
1008
+ ...replace ? ["--replace"] : [],
1009
+ ...recover ? ["--recover"] : []
1010
+ ]);
1011
+ }
1012
+ }
1013
+ ];
1014
+
1015
+ // cli/src/launch-config.ts
1016
+ import { createHash, randomBytes } from "node:crypto";
1017
+ function localClassEnv(dist) {
1018
+ return `# Settings for this machine. colloq wrote this file on its first start:
1019
+ # edit it and restart with colloq restart. The full list of everything that
1020
+ # can be configured is in .env.example next to the application.
1021
+
1022
+ # The address of the class on this machine. From outside it is visible
1023
+ # only through colloq host.
1024
+ PORT=3000
1025
+ BIND_ADDR=127.0.0.1
1026
+ UI_LANGUAGE=${dist ? "en" : "ru"}
1027
+
1028
+ # The kernels of a class are Docker containers on this computer, one per room.
1029
+ # Each room container is hardened: no privileges, a process limit, the internet
1030
+ # yes, your local network and this computer no. COLLOQ_ROOM_NETWORK=open lifts
1031
+ # the network block for a machine you fully trust.
1032
+ KERNEL_BACKEND=docker
1033
+ KERNEL_ENV=base
1034
+ # Memory and processors of one room. 4g is enough for an ordinary class; for a
1035
+ # class that trains networks set 8g-16g, if the machine has that much.
1036
+ KERNEL_MEM=4g
1037
+ KERNEL_CPUS=2
1038
+ KERNEL_SHM=1g
1039
+
1040
+ # Signs the sign-in links of teachers and students. Left empty, the key is
1041
+ # created in data/ on its own and survives a restart; a value written here
1042
+ # wins over it.
1043
+ SESSION_SECRET=
1044
+ # Not a shared Jupyter: every room has a token of its own. This line is here
1045
+ # so the log does not keep the token from .env.example, which everyone knows.
1046
+ JUPYTER_TOKEN=${randomBytes(24).toString("hex")}
1047
+
1048
+ # File uploads and the disk space one class may take.
1049
+ MAX_UPLOAD_MB=50
1050
+ MAX_SESSION_MB=1024
1051
+
1052
+ # The Oracle. Without a key the class still runs, but the model cannot be asked.
1053
+ AI_PROVIDER=openai
1054
+ OPENAI_API_KEY=
1055
+ OPENAI_BASE_URL=https://api.openai.com/v1
1056
+ OPENAI_MODEL=gpt-4o-mini
1057
+ `;
1058
+ }
1059
+
1060
+ // shared/admin.ts
1061
+ var PYTHON_VERSIONS = ["3.10", "3.11", "3.12", "3.13"];
1062
+
1063
+ // cli/src/commands/env.ts
1064
+ var CHAIN_LIMIT = 8;
1065
+ var FROM = /^[ \t]*#[ \t]*colloq:[ \t]*from[ \t]+([^\s]+)[ \t]*$/m;
1066
+ var PYTHON = /^[ \t]*#[ \t]*colloq:[ \t]*python[ \t]+(3\.[0-9]+)[ \t]*$/m;
1067
+ var NAME2 = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
1068
+ var NOT_A_PACKAGE = /^[ \t]*(#|-|$)/;
1069
+ var NOT_A_LINE = /^[ \t]*(#|$)/;
1070
+ function envDirs(ctx) {
1071
+ return { app: ctx.env.paths.envDir, own: ctx.env.paths.ownEnvDir };
1072
+ }
1073
+ function envFileIn(io, dirs, name) {
1074
+ const own = joinPath(dirs.own, name + ".txt");
1075
+ return io.exists(own) ? own : joinPath(dirs.app, name + ".txt");
1076
+ }
1077
+ function listEnvNames(io, dirs) {
1078
+ const names = /* @__PURE__ */ new Set();
1079
+ for (const dir of [dirs.app, dirs.own]) {
1080
+ for (const file of io.list(dir)) {
1081
+ if (file.endsWith(".txt")) names.add(file.slice(0, -".txt".length));
1082
+ }
1083
+ }
1084
+ return [...names].sort();
1085
+ }
1086
+ function envFile(ctx, name) {
1087
+ return envFileIn(ctx.io, envDirs(ctx), name);
1088
+ }
1089
+ function ownFile(ctx, name) {
1090
+ return joinPath(ctx.env.paths.ownEnvDir, name + ".txt");
1091
+ }
1092
+ function isOwn(ctx, name) {
1093
+ const dirs = envDirs(ctx);
1094
+ if (dirs.own === dirs.app) return false;
1095
+ return ctx.io.exists(ownFile(ctx, name));
1096
+ }
1097
+ function short(ctx, path2) {
1098
+ const root = ctx.env.root() + "/";
1099
+ if (path2.startsWith(root)) return path2.slice(root.length);
1100
+ const home = ctx.env.home();
1101
+ if (home && path2.startsWith(home + "/")) return "~" + path2.slice(home.length);
1102
+ return path2;
1103
+ }
1104
+ function places(ctx) {
1105
+ const dirs = envDirs(ctx);
1106
+ const app = short(ctx, dirs.app) + "/";
1107
+ return dirs.own === dirs.app ? app : app + " and " + short(ctx, dirs.own) + "/";
1108
+ }
1109
+ function textOf(ctx, path2) {
1110
+ const text = ctx.io.readText(path2);
1111
+ return text === null ? null : text.replace(/\r/g, "");
1112
+ }
1113
+ function wantName(ctx, usage) {
1114
+ const name = (ctx.positionals[0] ?? "").trim();
1115
+ if (!name) throw new UsageError("no environment given", "Usage: " + usage);
1116
+ checkName3(name);
1117
+ return name;
1118
+ }
1119
+ function checkName3(name) {
1120
+ if (NAME2.test(name)) return;
1121
+ throw new UsageError(
1122
+ '"' + name + '" cannot be an environment name: lowercase latin letters, digits and a hyphen',
1123
+ "colloq env list: which ones already exist"
1124
+ );
1125
+ }
1126
+ function needFile(ctx, name, fix) {
1127
+ if (ctx.io.exists(envFile(ctx, name))) return;
1128
+ throw new PreconditionError(
1129
+ "no " + short(ctx, envFile(ctx, name)) + ': environment "' + name + '" has not been created',
1130
+ fix
1131
+ );
1132
+ }
1133
+ var PYTHONS = PYTHON_VERSIONS;
1134
+ function wantPython(ctx) {
1135
+ const value = (ctx.values.python ?? "").trim();
1136
+ if (!value) return void 0;
1137
+ if (!PYTHONS.includes(value)) {
1138
+ throw new UsageError(
1139
+ "Python " + value + " is not one of those the kernel builds on",
1140
+ "available: " + PYTHONS.join(" ")
1141
+ );
1142
+ }
1143
+ return value;
1144
+ }
1145
+ function defaultPython(ctx) {
1146
+ const text = textOf(ctx, ctx.env.path("kernel/Dockerfile")) ?? "";
1147
+ return /^ARG PARENT=python:([0-9]+\.[0-9]+)-/m.exec(text)?.[1] ?? "3.11";
1148
+ }
1149
+ function chainOf(io, dirs, name) {
1150
+ const names = [];
1151
+ let current = name;
1152
+ while (current) {
1153
+ const file = envFileIn(io, dirs, current);
1154
+ if (names.includes(current)) {
1155
+ return { names, trouble: "environments reference each other in a circle: " + current };
1156
+ }
1157
+ if (!NAME2.test(current)) {
1158
+ return {
1159
+ names,
1160
+ trouble: '"' + current + '" cannot be an environment name: neither a file nor an image tag'
1161
+ };
1162
+ }
1163
+ const text = io.readText(file);
1164
+ if (text === null) {
1165
+ return {
1166
+ names,
1167
+ trouble: names.length === 0 ? 'environment "' + current + '" has not been created' : 'environment "' + current + '" is named as a parent, and it does not exist'
1168
+ };
1169
+ }
1170
+ names.unshift(current);
1171
+ if (names.length > CHAIN_LIMIT) {
1172
+ return { names, trouble: "the environment chain is longer than eight links" };
1173
+ }
1174
+ current = FROM.exec(text.replace(/\r/g, ""))?.[1];
1175
+ }
1176
+ return { names };
1177
+ }
1178
+ function pythonOf(ctx, name, fallback) {
1179
+ const root = chainOf(ctx.io, envDirs(ctx), name).names[0];
1180
+ if (root === void 0) return fallback;
1181
+ const text = textOf(ctx, envFile(ctx, root));
1182
+ return (text === null ? void 0 : PYTHON.exec(text)?.[1]) ?? fallback;
1183
+ }
1184
+ function needChain(ctx, name, fix) {
1185
+ const chain = chainOf(ctx.io, envDirs(ctx), name);
1186
+ if (chain.trouble) throw new PreconditionError(chain.trouble, fix);
1187
+ return chain;
1188
+ }
1189
+ function packagesOf(text) {
1190
+ return text.split("\n").filter((line) => !NOT_A_PACKAGE.test(line)).length;
1191
+ }
1192
+ function meaningful(text) {
1193
+ return text.split("\n").filter((line) => !NOT_A_LINE.test(line));
1194
+ }
1195
+ function starter(name, python, fallback) {
1196
+ const lines = [];
1197
+ if (python && python !== fallback) lines.push("# colloq: python " + python, "#");
1198
+ lines.push(
1199
+ '# Environment "' + name + '". It is installed on top of the base from',
1200
+ "# kernel/requirements.txt, so numpy/pandas/matplotlib/scikit-learn need no line here.",
1201
+ "#",
1202
+ "# One package per line, as in an ordinary requirements.txt:",
1203
+ "#",
1204
+ "# transformers>=4.44",
1205
+ "# datasets>=2.20",
1206
+ ""
1207
+ );
1208
+ return lines.join("\n");
1209
+ }
1210
+ function writeKernelEnv(ctx, name) {
1211
+ const existing = ctx.io.readText(ctx.env.paths.envFile);
1212
+ const text = existing ?? localClassEnv(ctx.dist);
1213
+ const kept = text.split("\n").filter((line) => !/^KERNEL_ENV=/.test(line));
1214
+ while (kept.length > 0 && (kept[kept.length - 1] ?? "").trim() === "") kept.pop();
1215
+ kept.push("KERNEL_ENV=" + name, "");
1216
+ ctx.io.writeText(ctx.env.paths.envFile, kept.join("\n"), existing === null ? 384 : void 0);
1217
+ }
1218
+ function renderList(ctx) {
1219
+ const current = ctx.env.kernelEnv();
1220
+ const fallback = defaultPython(ctx);
1221
+ const names = listEnvNames(ctx.io, envDirs(ctx));
1222
+ ctx.ui.line(ctx.ui.bold("Environments") + " " + ctx.ui.dim("(" + places(ctx) + ")"));
1223
+ ctx.ui.line();
1224
+ if (names.length === 0) {
1225
+ ctx.ui.line(" " + ctx.ui.dim("no environment files: create one with colloq env new <name>"));
1226
+ return;
1227
+ }
1228
+ const width = Math.max(14, ...names.map((name) => name.length));
1229
+ for (const name of names) {
1230
+ const text = textOf(ctx, envFile(ctx, name)) ?? "";
1231
+ const tail = "Python " + pythonOf(ctx, name, fallback) + " \xB7 " + countWord(packagesOf(text), "package") + " on top of the base" + (isOwn(ctx, name) ? " \xB7 your own" : "");
1232
+ ctx.ui.line(
1233
+ " " + (name === current ? "*" : " ") + " " + name.padEnd(width) + " " + ctx.ui.dim(tail)
1234
+ );
1235
+ }
1236
+ ctx.ui.line();
1237
+ ctx.ui.line(ctx.ui.dim("* \u2014 the default for new classes. Change it: colloq env use <name>"));
1238
+ }
1239
+ function renderShow(ctx, name) {
1240
+ const file = envFile(ctx, name);
1241
+ const python = pythonOf(ctx, name, defaultPython(ctx));
1242
+ ctx.ui.line(ctx.ui.bold(name) + " " + ctx.ui.dim("\u2014 " + short(ctx, file) + " \xB7 Python " + python));
1243
+ ctx.ui.line();
1244
+ const own = meaningful(textOf(ctx, file) ?? "");
1245
+ if (own.length > 0) ctx.ui.table(own.map((line) => [line]));
1246
+ else ctx.ui.line(" " + ctx.ui.dim("nothing on top of the base"));
1247
+ ctx.ui.line();
1248
+ ctx.ui.line(ctx.ui.dim("Base (always there):"));
1249
+ ctx.ui.table(
1250
+ meaningful(textOf(ctx, ctx.env.path("kernel/requirements.txt")) ?? "").map((line) => [line])
1251
+ );
1252
+ }
1253
+ var commands3 = [
1254
+ {
1255
+ name: "env list",
1256
+ aliases: ["env", "env ls"],
1257
+ group: "env",
1258
+ summary: "Which environments exist",
1259
+ usage: "colloq env list [--json]",
1260
+ flags: [
1261
+ { name: "json", summary: "Machine-readable: name, Python, packages on top of the base" }
1262
+ ],
1263
+ destructive: false,
1264
+ delegates: "native: reads both environment directories and KERNEL_ENV from .env",
1265
+ examples: ["colloq env list", "colloq env list --json"],
1266
+ notes: 'The star marks the default for new classes: KERNEL_ENV from .env, otherwise base. The Python version comes from the root of the "# colloq: from" chain, which is at most eight links long. "your own" is an environment you created yourself: it lives next to .env and survives an update of the package.',
1267
+ async run(ctx) {
1268
+ const intent = "native: env list (reads " + places(ctx) + " and KERNEL_ENV from .env)";
1269
+ if (ctx.dryRun) {
1270
+ if (!ctx.json) return ctx.sh.dry(intent);
1271
+ ctx.ui.json({ ok: true, dryRun: true, command: intent });
1272
+ return 0;
1273
+ }
1274
+ if (!ctx.json) {
1275
+ renderList(ctx);
1276
+ return 0;
1277
+ }
1278
+ const current = ctx.env.kernelEnv();
1279
+ const fallback = defaultPython(ctx);
1280
+ const dirs = envDirs(ctx);
1281
+ ctx.ui.json({
1282
+ ok: true,
1283
+ current,
1284
+ dir: short(ctx, dirs.app),
1285
+ ownDir: short(ctx, dirs.own),
1286
+ environments: listEnvNames(ctx.io, dirs).map((name) => ({
1287
+ name,
1288
+ python: pythonOf(ctx, name, fallback),
1289
+ packages: packagesOf(textOf(ctx, envFile(ctx, name)) ?? ""),
1290
+ current: name === current,
1291
+ own: isOwn(ctx, name),
1292
+ file: short(ctx, envFile(ctx, name))
1293
+ }))
1294
+ });
1295
+ return 0;
1296
+ }
1297
+ },
1298
+ {
1299
+ name: "env show",
1300
+ group: "env",
1301
+ summary: "What is in an environment and which Python it runs on",
1302
+ usage: "colloq env show [name]",
1303
+ args: [{ name: "name", summary: "Environment; without a name, the current one" }],
1304
+ flags: [],
1305
+ destructive: false,
1306
+ delegates: "native: reads <environment>.txt and kernel/requirements.txt",
1307
+ examples: ["colloq env show", "colloq env show {env}"],
1308
+ notes: "Without a name it shows the current one: what stands in KERNEL_ENV. When there is no such file it refuses. The base packages are listed separately: they are in every environment, and there is no need to repeat them in your own list.",
1309
+ async run(ctx) {
1310
+ const asked = (ctx.positionals[0] ?? "").trim() || ctx.env.kernelEnv();
1311
+ checkName3(asked);
1312
+ needFile(ctx, asked, "colloq env list");
1313
+ needChain(ctx, asked, "colloq env show " + asked + ': fix the "# colloq: from" line');
1314
+ if (ctx.dryRun) {
1315
+ return ctx.sh.dry(
1316
+ "native: env show " + asked + " (reads " + short(ctx, envFile(ctx, asked)) + " and kernel/requirements.txt)"
1317
+ );
1318
+ }
1319
+ renderShow(ctx, asked);
1320
+ return 0;
1321
+ }
1322
+ },
1323
+ {
1324
+ name: "env new",
1325
+ group: "env",
1326
+ summary: "Create an environment",
1327
+ usage: "colloq env new <name> [--python <version>]",
1328
+ args: [
1329
+ {
1330
+ name: "name",
1331
+ summary: "What to call it: lowercase latin letters, digits, hyphen",
1332
+ required: true
1333
+ }
1334
+ ],
1335
+ flags: [
1336
+ {
1337
+ name: "python",
1338
+ arg: "version",
1339
+ summary: "Which Python to build on; otherwise the default"
1340
+ }
1341
+ ],
1342
+ destructive: false,
1343
+ delegates: "native: creates <name>.txt with a header in your own environment directory",
1344
+ examples: ["colloq env new cv", "colloq env new cv --python 3.12"],
1345
+ notes: 'Creates an empty file with a header and does not overwrite an existing one. It is always written into your own directory, next to .env, and not where the package is installed: otherwise the environment would be gone on the first update. A name already taken by a bundled environment is allowed: your file overrides the bundled one. The version comes from PYTHON_VERSIONS in shared/admin.ts, and the "# colloq: python X.Y" line is written only when the version differs from ARG PARENT in kernel/Dockerfile.',
1346
+ async run(ctx) {
1347
+ const name = wantName(ctx, "colloq env new <name>");
1348
+ const python = wantPython(ctx);
1349
+ const file = ownFile(ctx, name);
1350
+ if (ctx.io.exists(file)) {
1351
+ throw new PreconditionError(
1352
+ short(ctx, file) + " already exists",
1353
+ "colloq env show " + name + ": what is in it now"
1354
+ );
1355
+ }
1356
+ const shadows = ctx.io.exists(joinPath(ctx.env.paths.envDir, name + ".txt"));
1357
+ if (ctx.dryRun) {
1358
+ return ctx.sh.dry(
1359
+ "native: env new " + name + " (creates " + short(ctx, file) + ", Python " + (python ?? defaultPython(ctx)) + ")"
1360
+ );
1361
+ }
1362
+ ctx.io.writeText(file, starter(name, python, defaultPython(ctx)));
1363
+ ctx.ui.line(ctx.ui.bold("created") + " " + short(ctx, file));
1364
+ if (shadows) {
1365
+ ctx.ui.line(ctx.ui.dim("overrides the bundled environment with the same name"));
1366
+ }
1367
+ ctx.ui.line(
1368
+ ctx.ui.dim(
1369
+ "Python " + pythonOf(ctx, name, defaultPython(ctx)) + " \xB7 write the packages in, then: colloq env use " + name
1370
+ )
1371
+ );
1372
+ return 0;
1373
+ }
1374
+ },
1375
+ {
1376
+ name: "env use",
1377
+ aliases: ["env switch"],
1378
+ group: "env",
1379
+ summary: "Make an environment the default for new classes",
1380
+ usage: "colloq env use <name>",
1381
+ args: [{ name: "name", summary: "An environment from colloq env list", required: true }],
1382
+ flags: [],
1383
+ destructive: true,
1384
+ confirm: "cli",
1385
+ // Проверка до вопроса: окружения нет или цепочка негодная — отказ, а не
1386
+ // вопрос перед отказом. Негодную цепочку не соберёт и запуск, и узнать об
1387
+ // этом сейчас дешевле, чем на colloq start перед парой.
1388
+ check(ctx) {
1389
+ const name = wantName(ctx, "colloq env use <name>");
1390
+ needFile(ctx, name, "colloq env new " + name);
1391
+ needChain(ctx, name, 'fix the "# colloq: from" line in the chain');
1392
+ },
1393
+ // Вопрос обещает ровно то, что команда сделает, — одну строку в .env.
1394
+ // Сборки здесь нет, значит нет и обещания долгого ожидания: о минутах
1395
+ // сборки на colloq start команда говорит уже после записи.
1396
+ confirmQuestion: (ctx) => "make environment " + wantName(ctx, "colloq env use <name>") + " the default for new classes?",
1397
+ delegates: "native: KERNEL_ENV in .env",
1398
+ examples: ["colloq env use cv", "colloq env use {env} --yes"],
1399
+ notes: "The KERNEL_ENV= line is written into the existing .env instead of a fresh file put on top of it: under sudo the file would stay root:0600, and the next start would bring an instance up with the defaults. Classes that are already open stay on their own environment. The image is not built here: colloq start builds it before the class, together with the rest of the preparation, and the first time that can take minutes.",
1400
+ async run(ctx) {
1401
+ const name = wantName(ctx, "colloq env use <name>");
1402
+ if (ctx.dryRun) {
1403
+ return ctx.sh.dry(
1404
+ "native: env use " + name + " (KERNEL_ENV=" + name + " in " + short(ctx, ctx.env.paths.envFile) + ")"
1405
+ );
1406
+ }
1407
+ writeKernelEnv(ctx, name);
1408
+ ctx.ui.line(ctx.ui.bold("Default environment for new classes: " + name));
1409
+ ctx.ui.line(
1410
+ ctx.ui.dim("the image is built on your next colloq start: this can take minutes")
1411
+ );
1412
+ return 0;
1413
+ }
1414
+ }
1415
+ ];
1416
+
1417
+ // cli/src/commands/tools.ts
1418
+ function on(ctx, name) {
1419
+ return ctx.values[name] === true;
1420
+ }
1421
+ function missing() {
1422
+ return Promise.resolve({ code: 127, stdout: "", stderr: "" });
1423
+ }
1424
+ var REINSTALL = "reinstall colloq: pip install --force-reinstall colloq";
1425
+ var KERNEL_IMAGE_FIX = "colloq start \u2014 the image is built before the class";
1426
+ function parseEtime(value) {
1427
+ const raw = value.trim();
1428
+ if (!/^(\d+-)?(\d+:)?\d+:\d+$/.test(raw)) return null;
1429
+ let days = 0;
1430
+ let rest = raw;
1431
+ const dash = raw.indexOf("-");
1432
+ if (dash >= 0) {
1433
+ days = Number(raw.slice(0, dash));
1434
+ rest = raw.slice(dash + 1);
1435
+ }
1436
+ const parts = rest.split(":").map((piece) => Number(piece));
1437
+ const hours = parts.length === 3 ? parts[0] ?? 0 : 0;
1438
+ const minutes = parts.length === 3 ? parts[1] ?? 0 : parts[0] ?? 0;
1439
+ const seconds = parts.length === 3 ? parts[2] ?? 0 : parts[1] ?? 0;
1440
+ return days * 86400 + hours * 3600 + minutes * 60 + seconds;
1441
+ }
1442
+ function humanDuration(seconds) {
1443
+ const total = Math.max(0, Math.round(seconds));
1444
+ if (total < 60) return total + "s";
1445
+ const minutes = Math.floor(total / 60);
1446
+ if (minutes < 60) return minutes + "m";
1447
+ const hours = Math.floor(minutes / 60);
1448
+ const restMinutes = minutes % 60;
1449
+ if (hours < 24) return restMinutes ? hours + "h " + restMinutes + "m" : hours + "h";
1450
+ const days = Math.floor(hours / 24);
1451
+ const restHours = hours % 24;
1452
+ return restHours ? days + "d " + restHours + "h" : days + "d";
1453
+ }
1454
+ function humanAge(milliseconds) {
1455
+ return humanDuration(milliseconds / 1e3) + " ago";
1456
+ }
1457
+ function two(value) {
1458
+ return String(value).padStart(2, "0");
1459
+ }
1460
+ function clock(milliseconds) {
1461
+ const date = new Date(milliseconds);
1462
+ return two(date.getHours()) + ":" + two(date.getMinutes());
1463
+ }
1464
+ function day(milliseconds) {
1465
+ const date = new Date(milliseconds);
1466
+ return two(date.getDate()) + "." + two(date.getMonth() + 1);
1467
+ }
1468
+ function hostOf(url) {
1469
+ const value = url.trim();
1470
+ if (value === "") return "";
1471
+ const match = /^[a-z][a-z0-9+.-]*:\/\/([^/?#]+)/i.exec(value);
1472
+ const authority = match ? match[1] ?? "" : value;
1473
+ const host = authority.split("@").pop() ?? "";
1474
+ return host.replace(/:\d+$/, "").toLowerCase();
1475
+ }
1476
+ function outsideOf(url) {
1477
+ const host = hostOf(url);
1478
+ return host !== "" && !["127.0.0.1", "localhost", "[::1]"].includes(host);
1479
+ }
1480
+ function sessionAddress2(ctx, session) {
1481
+ if (session.leaseFile === "") return session.url;
1482
+ return leaseUrl(ctx.io.readText(session.leaseFile), session.runId, ctx.io.now()) ?? session.url;
1483
+ }
1484
+ function newestFile(ctx, dir, keep) {
1485
+ let best = null;
1486
+ for (const name of ctx.io.list(dir)) {
1487
+ if (!keep(name)) continue;
1488
+ const at = ctx.io.mtime(joinPath(dir, name));
1489
+ if (at === null) continue;
1490
+ if (!best || at > best.at) best = { name, at };
1491
+ }
1492
+ return best;
1493
+ }
1494
+ function rowPrinter(ctx, labels) {
1495
+ const width = Math.max(12, ...labels.map((label) => label.length + 4));
1496
+ return (label, value) => ctx.ui.line(" " + label.padEnd(width) + value);
1497
+ }
1498
+ var PS_FORMAT = '{{.Label "colloq.kind"}}|{{.Label "com.docker.compose.service"}}|{{.State}}|{{.Label "colloq.session"}}';
1499
+ async function statusFacts(ctx) {
1500
+ const { env, io, sh } = ctx;
1501
+ const kernelEnv = env.kernelEnv();
1502
+ const relayDomain = env.relay().domain;
1503
+ const session = readSession(io, env.paths.sessionFile);
1504
+ const sessionUrl = session === null ? "" : sessionAddress2(ctx, session);
1505
+ const envUrl = env.publicUrl();
1506
+ const pidText = (io.readText(env.paths.pidFile) ?? "").trim();
1507
+ const filePid = /^\d+$/.test(pidText) ? Number(pidText) : null;
1508
+ const pid = session === null ? filePid : session.serverPid ?? session.pid;
1509
+ const outside = outsideOf(sessionUrl) || outsideOf(envUrl);
1510
+ const [docker, image, etime, frpc, cloudflared] = await Promise.all([
1511
+ sh.capture("docker", ["ps", "--format", PS_FORMAT], { timeoutMs: 700 }),
1512
+ sh.capture(
1513
+ "docker",
1514
+ ["image", "inspect", "colloq-kernel:" + kernelEnv, "--format", "{{.Created}}"],
1515
+ { timeoutMs: 700 }
1516
+ ),
1517
+ // Живость pid и время работы — одной строкой ps, она есть и на macOS, и на Linux.
1518
+ pid === null ? missing() : sh.capture("ps", ["-p", String(pid), "-o", "etime="], {
1519
+ timeoutMs: 300
1520
+ }),
1521
+ outside ? sh.capture("pgrep", ["-x", "frpc"], { timeoutMs: 300 }) : missing(),
1522
+ outside ? sh.capture("pgrep", ["-x", "cloudflared"], { timeoutMs: 300 }) : missing()
1523
+ ]);
1524
+ const seen = /* @__PURE__ */ new Set();
1525
+ let appRunning = false;
1526
+ for (const line of docker.stdout.split("\n")) {
1527
+ const [kind, compose, state, session2] = line.split("|");
1528
+ if ((kind ?? "") === "room-kernel") seen.add((session2 ?? "").trim() || line);
1529
+ if ((compose ?? "") === "app" && (state ?? "").toLowerCase().includes("running")) {
1530
+ appRunning = true;
1531
+ }
1532
+ }
1533
+ const rooms = seen.size;
1534
+ const dockerNote = docker.code === 0 ? "" : docker.code === 124 ? "docker did not answer in 0.7s" : docker.code === 127 ? "docker not found" : "docker is not answering";
1535
+ const uptimeSec = etime.code === 0 ? parseEtime(etime.stdout) : null;
1536
+ const alive = etime.code === 0 && uptimeSec !== null;
1537
+ const live = alive ? session : null;
1538
+ const port = live ? live.port : env.port();
1539
+ const publicUrl = live ? sessionUrl : envUrl;
1540
+ const host = hostOf(publicUrl);
1541
+ const published = outsideOf(publicUrl);
1542
+ const form = live ? live.mode === "dev" ? "npm run dev" : "colloq run" : appRunning ? "container" : alive ? "make run" : rooms > 0 ? "another form" : "";
1543
+ const startedAt = uptimeSec === null ? null : ctx.io.now() - uptimeSec * 1e3;
1544
+ const distAt = newestDist(ctx);
1545
+ const stale = distAt !== null && startedAt !== null && distAt > startedAt;
1546
+ const transport = !published ? "" : relayDomain !== "" && (host === relayDomain || host.endsWith("." + relayDomain)) ? "relay" : "Cloudflare";
1547
+ const frpcPid = firstPid(frpc);
1548
+ const cloudflaredPid = firstPid(cloudflared);
1549
+ const tunnelPid = transport === "relay" ? frpcPid : cloudflaredPid;
1550
+ const tunnel = transport === "relay" ? "frpc" : "cloudflared";
1551
+ const imageAt = image.code === 0 ? dateValue(image.stdout) : null;
1552
+ const envName = host === "" || !published ? "" : host.split(".")[0] ?? "";
1553
+ const backupDir = envName !== "" && io.exists(joinPath(env.paths.backupsDir, envName)) ? joinPath(env.paths.backupsDir, envName) : env.paths.backupsDir;
1554
+ const backup = newestFile(ctx, backupDir, (name) => /\.(db|tar\.gz|tgz|zip)$/.test(name));
1555
+ return {
1556
+ envName,
1557
+ port,
1558
+ kernelEnv,
1559
+ publicUrl,
1560
+ relayDomain,
1561
+ form,
1562
+ /*
1563
+ * Под подписью «сервер» стоит номер СЕРВЕРА, и только он: тем же номером
1564
+ * называет слушателя порта doctor, а до этой правки два экрана звали один
1565
+ * и тот же сервер по-разному — status номером супервизора, doctor номером
1566
+ * его ребёнка. Пока сервер занятия не поднялся, номера нет вовсе, и строка
1567
+ * обойдётся портом и временем: выдать вместо него супервизора значило бы
1568
+ * вернуть ту же путаницу. Номер из .colloq.pid печатается, только если ps
1569
+ * подтвердил, что он жив: рядом с «container» мёртвый номер от прошлого
1570
+ * `make run` выдавал бы себя за сервер контейнера.
1571
+ */
1572
+ pid: live ? live.serverPid : alive ? pid : null,
1573
+ uptimeSec,
1574
+ startedAt,
1575
+ distAt,
1576
+ stale,
1577
+ transport,
1578
+ tunnel,
1579
+ tunnelPid,
1580
+ rooms,
1581
+ image: "colloq-kernel:" + kernelEnv,
1582
+ imageAt,
1583
+ dockerNote,
1584
+ backupPath: backup ? joinPath(backupDir, backup.name) : "",
1585
+ backupAt: backup ? backup.at : null
1586
+ };
1587
+ }
1588
+ function newestDist(ctx) {
1589
+ const dist = ctx.env.paths.dist;
1590
+ if (!ctx.io.exists(dist)) return null;
1591
+ let best = null;
1592
+ for (const dir of [dist, joinPath(dist, "assets")]) {
1593
+ for (const name of ctx.io.list(dir)) {
1594
+ const at = ctx.io.mtime(joinPath(dir, name));
1595
+ if (at === null) continue;
1596
+ if (best === null || at > best) best = at;
1597
+ }
1598
+ }
1599
+ return best;
1600
+ }
1601
+ function firstPid(result) {
1602
+ if (result.code !== 0) return null;
1603
+ const line = result.stdout.split("\n").find((item) => /^\d+$/.test(item.trim()));
1604
+ return line ? Number(line.trim()) : null;
1605
+ }
1606
+ function dateValue(value) {
1607
+ const at = Date.parse(value.trim());
1608
+ return Number.isFinite(at) ? at : null;
1609
+ }
1610
+ function renderStatus(ctx, facts) {
1611
+ const { ui } = ctx;
1612
+ const empty = facts.form === "" && facts.rooms === 0 && facts.publicUrl === "" && facts.pid === null;
1613
+ if (empty) {
1614
+ ui.line("nothing is running \xB7 " + ui.cyan("colloq run"));
1615
+ return;
1616
+ }
1617
+ ui.header("Colloq \xB7 " + (facts.envName || "local"));
1618
+ serverLine(ctx, facts);
1619
+ if (facts.distAt === null) {
1620
+ ui.kv(SYMBOL.off + " client", "the frontend is not built");
1621
+ ui.hint(REINSTALL);
1622
+ } else if (facts.stale) {
1623
+ ui.kv(
1624
+ SYMBOL.on + " client",
1625
+ "web/dist built " + clock(facts.distAt) + " \u2014 newer than the server (started " + clock(facts.startedAt ?? 0) + ")"
1626
+ );
1627
+ ui.hint("colloq restart");
1628
+ } else {
1629
+ ui.kv(SYMBOL.on + " client", "web/dist built " + clock(facts.distAt));
1630
+ }
1631
+ publicLine(ctx, facts);
1632
+ if (facts.transport !== "" && facts.publicUrl !== "") ui.kv(" link", ui.cyan(facts.publicUrl));
1633
+ if (facts.dockerNote !== "") ui.kv(SYMBOL.off + " kernels", facts.dockerNote);
1634
+ else if (facts.imageAt === null) {
1635
+ ui.kv(
1636
+ SYMBOL.off + " kernels",
1637
+ "no " + facts.image + " image \xB7 rooms with a kernel: " + facts.rooms
1638
+ );
1639
+ ui.hint(KERNEL_IMAGE_FIX);
1640
+ } else {
1641
+ ui.kv(
1642
+ SYMBOL.on + " kernels",
1643
+ facts.image + ", built " + day(facts.imageAt) + " \xB7 rooms with a kernel: " + facts.rooms
1644
+ );
1645
+ }
1646
+ ui.kv(" env", facts.kernelEnv, "change: colloq env use <name>");
1647
+ backupLine(ctx, facts);
1648
+ }
1649
+ function serverLine(ctx, facts, hint = true) {
1650
+ const { ui } = ctx;
1651
+ if (facts.form === "") {
1652
+ ui.kv(SYMBOL.off + " server", "not running \xB7 port " + facts.port);
1653
+ if (hint) ui.hint("colloq run");
1654
+ return;
1655
+ }
1656
+ const parts = [];
1657
+ if (facts.pid !== null) parts.push("pid " + facts.pid);
1658
+ parts.push("port " + facts.port);
1659
+ if (facts.uptimeSec !== null) parts.push(humanDuration(facts.uptimeSec));
1660
+ parts.push(facts.form);
1661
+ ui.kv(SYMBOL.on + " server", parts.join(" \xB7 "));
1662
+ }
1663
+ function publicLine(ctx, facts) {
1664
+ const { ui } = ctx;
1665
+ const host = hostOf(facts.publicUrl);
1666
+ if (facts.transport === "") {
1667
+ ui.kv(
1668
+ SYMBOL.off + " outside",
1669
+ "not published \xB7 " + (facts.publicUrl || "this machine") + " only"
1670
+ );
1671
+ ui.hint("colloq host <name>");
1672
+ return;
1673
+ }
1674
+ const tail = facts.tunnelPid === null ? " \u2014 no tunnel process" : " (" + facts.tunnel + " pid " + facts.tunnelPid + ")";
1675
+ ui.kv(SYMBOL.on + " outside", host + " \xB7 " + facts.transport + tail);
1676
+ }
1677
+ function publicLineShort(ctx, facts) {
1678
+ const host = hostOf(facts.publicUrl);
1679
+ if (facts.transport === "") {
1680
+ ctx.ui.kv(SYMBOL.off + " outside", "not published \xB7 colloq host <name>");
1681
+ return;
1682
+ }
1683
+ const tail = facts.tunnelPid === null ? " \u2014 no tunnel process" : " (" + facts.tunnel + " pid " + facts.tunnelPid + ")";
1684
+ ctx.ui.kv(SYMBOL.on + " outside", host + " \xB7 " + facts.transport + tail);
1685
+ }
1686
+ function backupLine(ctx, facts) {
1687
+ const { ui } = ctx;
1688
+ if (facts.backupAt === null) {
1689
+ ui.kv(SYMBOL.off + " backup", "no backups");
1690
+ ui.hint("colloq backup");
1691
+ return;
1692
+ }
1693
+ const name = facts.backupPath.split("/").pop() ?? facts.backupPath;
1694
+ ui.kv(SYMBOL.on + " backup", name + " \xB7 " + humanAge(ctx.io.now() - facts.backupAt));
1695
+ }
1696
+ var PROGRAMS = ["frpc", "cloudflared"];
1697
+ async function doctorChecks(ctx) {
1698
+ const { env, io, sh } = ctx;
1699
+ const kernelEnv = env.kernelEnv();
1700
+ const relay = env.relay();
1701
+ const pidText = (io.readText(env.paths.pidFile) ?? "").trim();
1702
+ const session = readSession(io, env.paths.sessionFile);
1703
+ const port = session?.port ?? env.port();
1704
+ const [programs, info, image, listen, disk] = await Promise.all([
1705
+ // Одна оболочка на обе программы: отдельные вызовы стоили бы вдвое дольше.
1706
+ sh.capture(
1707
+ "sh",
1708
+ [
1709
+ "-c",
1710
+ PROGRAMS.map((name) => "command -v " + name + " >/dev/null 2>&1 && echo " + name).join(
1711
+ "; "
1712
+ )
1713
+ ],
1714
+ {
1715
+ timeoutMs: 3e3
1716
+ }
1717
+ ),
1718
+ sh.capture("docker", ["info", "--format", "{{.ServerVersion}} {{.Name}} {{.MemTotal}}"], {
1719
+ timeoutMs: 3e3
1720
+ }),
1721
+ sh.capture(
1722
+ "docker",
1723
+ ["image", "inspect", "colloq-kernel:" + kernelEnv, "--format", "{{.Created}}"],
1724
+ { timeoutMs: 3e3 }
1725
+ ),
1726
+ // Только LISTEN: чужой CLOSE_WAIT однажды уже дал ложное «порт занят».
1727
+ sh.capture("lsof", ["-nP", "-iTCP:" + port, "-sTCP:LISTEN"], { timeoutMs: 2e3 }),
1728
+ /*
1729
+ * Место меряем на томе каталога СОСТОЯНИЯ, а не приложения. Растёт всё
1730
+ * там: <home>/data, <home>/workspace, <home>/backups, <home>/.colloq.log —
1731
+ * и подсказка ниже сама зовёт чистить backups/. У поставленного через pip
1732
+ * colloq каталоги разные, и легко на разных томах: осмотр показывал
1733
+ * свободное место под site-packages, а кончалось оно у данных.
1734
+ */
1735
+ sh.capture("df", ["-k", env.paths.home], { timeoutMs: 2e3 })
1736
+ ]);
1737
+ const found = new Set(programs.stdout.split("\n").map((line) => line.trim()));
1738
+ const checks = [];
1739
+ const add = (check) => void checks.push(check);
1740
+ const major = Number.parseInt((process.version ?? "v0").slice(1), 10);
1741
+ add({
1742
+ id: "node",
1743
+ label: "node",
1744
+ ok: Number.isFinite(major) && major >= 22,
1745
+ optional: false,
1746
+ value: process.version ?? "unknown",
1747
+ hint: "Node \u2265 22 is required: brew install node"
1748
+ });
1749
+ const daemon = info.code === 0 ? info.stdout.trim().split(/\s+/) : [];
1750
+ const memTotal = daemon.length ? Number(daemon[daemon.length - 1]) : 0;
1751
+ const daemonName = daemon.length >= 2 ? daemon.slice(1, -1).join(" ") : "";
1752
+ const enoughMemory = memTotal >= 4 * 1024 ** 3;
1753
+ add({
1754
+ id: "docker",
1755
+ label: "docker",
1756
+ ok: info.code === 0 && enoughMemory,
1757
+ optional: false,
1758
+ value: info.code !== 0 ? "the daemon is not answering" : daemonName + ", " + (memTotal / 1024 ** 3).toFixed(0) + " GB" + (enoughMemory ? "" : " \u2014 not enough for a room kernel"),
1759
+ hint: info.code !== 0 ? "start Docker Desktop or colima start" : "raise docker memory: under 4 GB the room kernel is killed by OOM"
1760
+ });
1761
+ const listener = listen.code === 0 ? listenerOf(listen.stdout) : null;
1762
+ const ourPids = /* @__PURE__ */ new Set();
1763
+ if (session !== null) {
1764
+ ourPids.add(session.pid);
1765
+ if (session.serverPid !== null) ourPids.add(session.serverPid);
1766
+ }
1767
+ if (/^\d+$/.test(pidText)) ourPids.add(Number(pidText));
1768
+ const ours = listener !== null && ourPids.has(listener.pid);
1769
+ add({
1770
+ id: "port",
1771
+ label: "port " + port,
1772
+ ok: listen.code === 127 ? false : listener === null || ours,
1773
+ optional: listen.code === 127,
1774
+ value: listen.code === 127 ? "no lsof \u2014 the port cannot be checked" : listener === null ? "free" : ours ? "our server is listening, pid " + listener.pid : "taken: " + listener.name + " pid " + listener.pid + " \u2014 a second Colloq",
1775
+ hint: "colloq stop \xB7 colloq status (never pkill by name)"
1776
+ });
1777
+ add({
1778
+ id: "kernel-image",
1779
+ label: "kernel image",
1780
+ ok: image.code === 0,
1781
+ optional: false,
1782
+ value: image.code === 0 ? "colloq-kernel:" + kernelEnv : "no colloq-kernel:" + kernelEnv + " image",
1783
+ hint: KERNEL_IMAGE_FIX
1784
+ });
1785
+ const ownEnvFile = joinPath(env.paths.ownEnvDir, kernelEnv + ".txt");
1786
+ const appEnvFile = joinPath(env.paths.envDir, kernelEnv + ".txt");
1787
+ const envFileAt = io.exists(ownEnvFile) ? ownEnvFile : io.exists(appEnvFile) ? appEnvFile : "";
1788
+ add({
1789
+ id: "kernel-env-file",
1790
+ label: "environment",
1791
+ ok: envFileAt !== "",
1792
+ optional: false,
1793
+ value: envFileAt === "" ? "no " + kernelEnv + ".txt list in either environment directory" : kernelEnv + ".txt in place" + (envFileAt === ownEnvFile ? " \xB7 your own" : ""),
1794
+ hint: "colloq env list \xB7 colloq env new <name>"
1795
+ });
1796
+ const distNames = io.list(env.paths.dist);
1797
+ const assets = io.list(joinPath(env.paths.dist, "assets"));
1798
+ const plain = assets.filter((name) => /\.(js|css|html|json|svg)$/.test(name));
1799
+ const squeezed = plain.filter((name) => assets.includes(name + ".br"));
1800
+ const hasDist = distNames.length > 0;
1801
+ const share = plain.length === 0 ? 1 : squeezed.length / plain.length;
1802
+ add({
1803
+ id: "web-dist",
1804
+ label: "web/dist",
1805
+ ok: hasDist && share >= 0.8,
1806
+ optional: hasDist,
1807
+ value: !hasDist ? "the frontend is not built" : share >= 0.8 ? "built, pre-compressed: " + squeezed.length + " of " + plain.length : "the frontend is not pre-compressed \u2014 11.6ms and 25 KB per student per file",
1808
+ hint: REINSTALL
1809
+ });
1810
+ const wantRelay = relay.domain !== "";
1811
+ add({
1812
+ id: "frpc",
1813
+ label: "frpc",
1814
+ ok: found.has("frpc"),
1815
+ optional: !wantRelay,
1816
+ value: found.has("frpc") ? "present" : wantRelay ? "missing \u2014 no way out under *." + relay.domain : "the relay is not set up",
1817
+ hint: "brew install frpc"
1818
+ });
1819
+ add({
1820
+ id: "cloudflared",
1821
+ label: "cloudflared",
1822
+ ok: found.has("cloudflared"),
1823
+ optional: true,
1824
+ value: found.has("cloudflared") ? "present" : "missing \u2014 the quick tunnel will not start (not needed for *.colloq.ru)",
1825
+ hint: "brew install cloudflared"
1826
+ });
1827
+ const oracle = env.has("OPENAI_API_KEY") || env.has("OPENROUTER_API_KEY");
1828
+ add({
1829
+ id: "oracle",
1830
+ label: "Oracle",
1831
+ ok: oracle,
1832
+ optional: true,
1833
+ value: oracle ? "key present" : "no key \u2014 suggestions are off",
1834
+ hint: "OPENAI_API_KEY or OPENROUTER_API_KEY in .env"
1835
+ });
1836
+ const freeGb = freeSpaceGb(disk);
1837
+ add({
1838
+ id: "disk",
1839
+ label: "disk space",
1840
+ ok: freeGb !== null && freeGb >= 15,
1841
+ optional: freeGb === null || freeGb >= 5,
1842
+ value: freeGb === null ? "df did not answer" : freeGb < 5 ? freeGb.toFixed(1) + " GB \u2014 an image build and a backup will not fit" : freeGb < 15 ? freeGb.toFixed(1) + " GB \u2014 barely enough for an image and a backup" : freeGb.toFixed(0) + " GB",
1843
+ hint: "clear old backups out of " + env.paths.backupsDir
1844
+ });
1845
+ const roomNetworkOpen = env.read("COLLOQ_ROOM_NETWORK").trim().toLowerCase() === "open";
1846
+ add({
1847
+ id: "room-network",
1848
+ label: "room network",
1849
+ ok: !roomNetworkOpen,
1850
+ optional: true,
1851
+ value: roomNetworkOpen ? "open (COLLOQ_ROOM_NETWORK=open): rooms reach your LAN, router and this computer" : "local addresses blocked: LAN, router, this computer, cloud metadata",
1852
+ hint: "remove COLLOQ_ROOM_NETWORK=open from .env, then colloq restart"
1853
+ });
1854
+ add({
1855
+ id: "perimeter",
1856
+ label: "perimeter",
1857
+ ok: false,
1858
+ optional: true,
1859
+ value: PERIMETER.what,
1860
+ hint: PERIMETER.fix
1861
+ });
1862
+ return checks;
1863
+ }
1864
+ function listenerOf(stdout) {
1865
+ for (const line of stdout.split("\n")) {
1866
+ const parts = line.trim().split(/\s+/);
1867
+ if (parts.length < 2) continue;
1868
+ if (parts[0] === "COMMAND") continue;
1869
+ if (!/^\d+$/.test(parts[1] ?? "")) continue;
1870
+ return { name: parts[0] ?? "", pid: Number(parts[1]) };
1871
+ }
1872
+ return null;
1873
+ }
1874
+ function freeSpaceGb(result) {
1875
+ if (result.code !== 0) return null;
1876
+ const lines = result.stdout.split("\n").filter((line) => line.trim() !== "");
1877
+ const last = lines[lines.length - 1] ?? "";
1878
+ const parts = last.trim().split(/\s+/);
1879
+ const kb = Number(parts[3]);
1880
+ return Number.isFinite(kb) ? kb / 1024 / 1024 : null;
1881
+ }
1882
+ var commands4 = [
1883
+ {
1884
+ name: "status",
1885
+ aliases: ["st"],
1886
+ group: "tools",
1887
+ summary: "Show what is on this machine right now: one screen, no network",
1888
+ usage: "colloq status [--short] [--json]",
1889
+ flags: [
1890
+ { name: "short", summary: "Two lines: the server and the outside" },
1891
+ { name: "json", summary: "Machine-readable" }
1892
+ ],
1893
+ destructive: false,
1894
+ delegates: "native: .colloq/local-session.json, .env, .colloq.pid, ps, docker ps, web/dist, backups/",
1895
+ examples: ["colloq status", "colloq status --short"],
1896
+ notes: 'Reading only, and no network at all. The port, the address and the server pid come from the receipt of the class that is running, and only when there is no receipt \u2014 from .env: `colloq run --port 4100` writes nothing into .env. Under the "server" label stands the pid of the server, not of the supervisor: the same one doctor names as the listener on the port. Docker is asked once, with a ceiling of 0.7s \u2014 no answer means one line about docker, and the screen prints on.',
1897
+ async run(ctx) {
1898
+ if (ctx.dryRun) {
1899
+ return ctx.sh.dry("native: status (reads the class receipt, .env, .colloq.pid, docker ps)");
1900
+ }
1901
+ const facts = await statusFacts(ctx);
1902
+ if (ctx.json) {
1903
+ ctx.ui.json({
1904
+ ok: true,
1905
+ server: {
1906
+ form: facts.form,
1907
+ pid: facts.pid,
1908
+ port: facts.port,
1909
+ uptimeSec: facts.uptimeSec
1910
+ },
1911
+ client: {
1912
+ builtAt: facts.distAt === null ? null : new Date(facts.distAt).toISOString(),
1913
+ stale: facts.stale
1914
+ },
1915
+ public: {
1916
+ url: facts.publicUrl,
1917
+ transport: facts.transport,
1918
+ tunnelPid: facts.tunnelPid
1919
+ },
1920
+ kernel: {
1921
+ image: facts.image,
1922
+ builtAt: facts.imageAt === null ? null : new Date(facts.imageAt).toISOString(),
1923
+ env: facts.kernelEnv,
1924
+ rooms: facts.rooms
1925
+ },
1926
+ backup: {
1927
+ path: facts.backupPath,
1928
+ ageSec: facts.backupAt === null ? null : Math.round((ctx.io.now() - facts.backupAt) / 1e3)
1929
+ }
1930
+ });
1931
+ return 0;
1932
+ }
1933
+ if (on(ctx, "short")) {
1934
+ serverLine(ctx, facts, false);
1935
+ publicLineShort(ctx, facts);
1936
+ return 0;
1937
+ }
1938
+ renderStatus(ctx, facts);
1939
+ return 0;
1940
+ }
1941
+ },
1942
+ {
1943
+ name: "doctor",
1944
+ group: "tools",
1945
+ summary: "Check that everything is in place before a class",
1946
+ usage: "colloq doctor [--json]",
1947
+ flags: [{ name: "json", summary: "Machine-readable" }],
1948
+ destructive: false,
1949
+ delegates: "native: programs, docker, the port, the kernel image, files, disk space",
1950
+ examples: ["colloq doctor", "colloq doctor --json"],
1951
+ notes: '\u2717 \u2014 broken, fix it; \u25CB \u2014 a capability is switched off, and that is not an error. Out of .env only the port and the kernel environment are printed; keys are only "present / missing". The port that gets checked is the one the class runs on (from the receipt), and its listener counts as ours by the pids of the server and of the supervisor. No network at all. Code 3 if there is at least one \u2717. The last line is about the perimeter: it checks nothing and does not change the code, it says how a local class differs from a server installation.',
1952
+ async run(ctx) {
1953
+ if (ctx.dryRun) {
1954
+ return ctx.sh.dry(
1955
+ "native: doctor (programs, docker, the port, the kernel image, files, disk space)"
1956
+ );
1957
+ }
1958
+ const checks = await doctorChecks(ctx);
1959
+ const broken = checks.filter((check) => !check.ok && !check.optional);
1960
+ if (ctx.json) {
1961
+ ctx.ui.json(
1962
+ checks.map((check) => ({
1963
+ id: check.id,
1964
+ ok: check.ok,
1965
+ optional: check.optional,
1966
+ value: check.value,
1967
+ hint: check.ok ? "" : check.hint
1968
+ }))
1969
+ );
1970
+ return broken.length ? 3 : 0;
1971
+ }
1972
+ const row = rowPrinter(
1973
+ ctx,
1974
+ checks.map((check) => check.label)
1975
+ );
1976
+ for (const check of checks) {
1977
+ const symbol = check.ok ? SYMBOL.ok : check.optional ? SYMBOL.off : SYMBOL.bad;
1978
+ row(symbol + " " + check.label, check.value);
1979
+ if (!check.ok) ctx.ui.hint(check.hint);
1980
+ }
1981
+ return broken.length ? 3 : 0;
1982
+ }
1983
+ }
1984
+ ];
1985
+
1986
+ // cli/src/registry.ts
1987
+ var GROUPS = [
1988
+ { name: "local", title: "Locally" },
1989
+ { name: "host", title: "Class online" },
1990
+ { name: "env", title: "Kernel environments" },
1991
+ { name: "tools", title: "Tools" }
1992
+ ];
1993
+ var registry = [...commands2, ...commands, ...commands3, ...commands4];
1994
+
1995
+ // cli/src/sh.ts
1996
+ import { constants } from "node:os";
1997
+ var TCP_PROBE = [
1998
+ "const net = require('net')",
1999
+ "const [addr, port] = process.argv.slice(1)",
2000
+ "const socket = net.connect({ host: addr, port: Number(port) })",
2001
+ "const done = (code) => { socket.destroy(); process.exit(code) }",
2002
+ "socket.setTimeout(2000)",
2003
+ "socket.on('connect', () => done(0))",
2004
+ "socket.on('timeout', () => done(1))",
2005
+ "socket.on('error', () => done(1))"
2006
+ ].join("; ");
2007
+ function exitFromSignal(signal) {
2008
+ const number = constants.signals[signal];
2009
+ return 128 + (typeof number === "number" ? number : 2);
2010
+ }
2011
+ function quote(value) {
2012
+ if (value === "") return "''";
2013
+ if (/^[A-Za-z0-9_@%+=:,./-]+$/.test(value)) return value;
2014
+ return "'" + value.replace(/'/g, "'\\''") + "'";
2015
+ }
2016
+ function commandLine(cmd, args, env) {
2017
+ const head = env ? Object.entries(env).map(([key, value]) => key + "=" + quote(value)).join(" ") : "";
2018
+ const body = [cmd, ...args].map(quote).join(" ");
2019
+ return head ? head + " " + body : body;
2020
+ }
2021
+ function createSh(opts) {
2022
+ const { root, ui } = opts;
2023
+ const dryRun = opts.dryRun ?? false;
2024
+ const runner = opts.runner ?? null;
2025
+ const real = {
2026
+ async run(cmd, args, runOpts) {
2027
+ const { spawn } = await import("node:child_process");
2028
+ return await new Promise((resolve) => {
2029
+ const child = spawn(cmd, args, {
2030
+ cwd: runOpts.cwd ?? root,
2031
+ env: { ...process.env, ...runOpts.env ?? {} },
2032
+ stdio: "inherit"
2033
+ });
2034
+ const forward = (signal) => () => {
2035
+ try {
2036
+ child.kill(signal);
2037
+ } catch {
2038
+ }
2039
+ };
2040
+ const onInt = forward("SIGINT");
2041
+ const onTerm = forward("SIGTERM");
2042
+ const onHangup = forward("SIGHUP");
2043
+ process.on("SIGINT", onInt);
2044
+ process.on("SIGTERM", onTerm);
2045
+ process.on("SIGHUP", onHangup);
2046
+ const done = (code) => {
2047
+ process.off("SIGINT", onInt);
2048
+ process.off("SIGTERM", onTerm);
2049
+ process.off("SIGHUP", onHangup);
2050
+ resolve(code);
2051
+ };
2052
+ child.on("error", () => done(127));
2053
+ child.on("close", (code, signal) => done(signal ? exitFromSignal(signal) : code ?? 0));
2054
+ });
2055
+ },
2056
+ async capture(cmd, args, captureOpts) {
2057
+ const { spawn } = await import("node:child_process");
2058
+ return await new Promise((resolve) => {
2059
+ const child = spawn(cmd, args, {
2060
+ cwd: captureOpts.cwd ?? root,
2061
+ env: { ...process.env, ...captureOpts.env ?? {} },
2062
+ stdio: ["pipe", "pipe", "pipe"]
2063
+ });
2064
+ let stdout = "";
2065
+ let stderr = "";
2066
+ let timer = null;
2067
+ let expired = false;
2068
+ child.stdout?.on("data", (chunk) => stdout += chunk.toString());
2069
+ child.stderr?.on("data", (chunk) => stderr += chunk.toString());
2070
+ child.stdin?.end();
2071
+ if (captureOpts.timeoutMs) {
2072
+ timer = setTimeout(() => {
2073
+ expired = true;
2074
+ try {
2075
+ child.kill("SIGKILL");
2076
+ } catch {
2077
+ }
2078
+ }, captureOpts.timeoutMs);
2079
+ }
2080
+ const done = (code) => {
2081
+ if (timer) clearTimeout(timer);
2082
+ resolve({ code, stdout, stderr });
2083
+ };
2084
+ child.on("error", () => done(127));
2085
+ child.on("close", (code, signal) => {
2086
+ if (expired) return done(124);
2087
+ done(signal ? exitFromSignal(signal) : code ?? 0);
2088
+ });
2089
+ });
2090
+ }
2091
+ };
2092
+ const sh = {
2093
+ get dryRun() {
2094
+ return dryRun;
2095
+ },
2096
+ dry(line) {
2097
+ ui.line(line);
2098
+ return 0;
2099
+ },
2100
+ async run(cmd, args, runOpts = {}) {
2101
+ if (dryRun) {
2102
+ ui.line(commandLine(cmd, args, runOpts.env));
2103
+ return 0;
2104
+ }
2105
+ return await (runner ?? real).run(cmd, args, runOpts);
2106
+ },
2107
+ async capture(cmd, args, captureOpts = {}) {
2108
+ return await (runner ?? real).capture(cmd, args, captureOpts);
2109
+ },
2110
+ async script(path2, args = [], runOpts = {}) {
2111
+ return await sh.run(path2, args, runOpts);
2112
+ }
2113
+ };
2114
+ return sh;
2115
+ }
2116
+
2117
+ // cli/src/main.ts
2118
+ var GLOBAL_FLAGS = /* @__PURE__ */ new Set([
2119
+ "--help",
2120
+ "-h",
2121
+ "--dry-run",
2122
+ "--yes",
2123
+ "-y",
2124
+ "--json",
2125
+ "--no-color",
2126
+ "--version",
2127
+ "-V"
2128
+ ]);
2129
+ async function cli(argv, deps = {}) {
2130
+ const processEnv = deps.processEnv ?? process.env;
2131
+ const commands5 = deps.commands ?? registry;
2132
+ const io = deps.io ?? createIo();
2133
+ const tty = deps.tty ?? Boolean(process.stdout.isTTY && process.stdin.isTTY);
2134
+ const globals = {
2135
+ help: false,
2136
+ dryRun: false,
2137
+ yes: false,
2138
+ json: false,
2139
+ noColor: false,
2140
+ version: false
2141
+ };
2142
+ const tokens = [];
2143
+ let raw = false;
2144
+ for (const token of argv) {
2145
+ if (raw) {
2146
+ tokens.push(token);
2147
+ continue;
2148
+ }
2149
+ if (token === "--") {
2150
+ raw = true;
2151
+ tokens.push(token);
2152
+ continue;
2153
+ }
2154
+ if (!GLOBAL_FLAGS.has(token)) {
2155
+ tokens.push(token);
2156
+ continue;
2157
+ }
2158
+ if (token === "--help" || token === "-h") globals.help = true;
2159
+ else if (token === "--dry-run") globals.dryRun = true;
2160
+ else if (token === "--yes" || token === "-y") globals.yes = true;
2161
+ else if (token === "--json") globals.json = true;
2162
+ else if (token === "--no-color") globals.noColor = true;
2163
+ else globals.version = true;
2164
+ }
2165
+ const color = globals.noColor ? false : colorAllowed(processEnv, Boolean(process.stdout.isTTY));
2166
+ const ui = createUi({
2167
+ color,
2168
+ json: globals.json,
2169
+ out: deps.out,
2170
+ err: deps.err,
2171
+ tty,
2172
+ ask: deps.ask
2173
+ });
2174
+ const env = createEnv({ io, root: deps.root, home: deps.home, cwd: deps.cwd, processEnv });
2175
+ const sh = createSh({
2176
+ root: env.root(),
2177
+ ui,
2178
+ dryRun: globals.dryRun,
2179
+ runner: deps.runner ?? null
2180
+ });
2181
+ let roomsCache = null;
2182
+ const ctx = {
2183
+ positionals: [],
2184
+ values: {},
2185
+ dryRun: globals.dryRun,
2186
+ yes: globals.yes,
2187
+ json: globals.json,
2188
+ sh,
2189
+ ui,
2190
+ env,
2191
+ io,
2192
+ dist: deps.dist ?? isDistribution(),
2193
+ rooms() {
2194
+ roomsCache ??= countRooms();
2195
+ return roomsCache;
2196
+ },
2197
+ async confirm(question) {
2198
+ if (globals.yes || globals.dryRun) return true;
2199
+ return await ui.confirm(await withRooms(question));
2200
+ }
2201
+ };
2202
+ async function countRooms() {
2203
+ const result = await sh.capture(
2204
+ "docker",
2205
+ ["ps", "--filter", "label=colloq.kind=room-kernel", "--format", '{{.Label "colloq.session"}}'],
2206
+ { timeoutMs: 4e3 }
2207
+ );
2208
+ if (result.code !== 0) return 0;
2209
+ const rooms = new Set(result.stdout.split("\n").map((line) => line.trim()).filter(Boolean));
2210
+ return rooms.size;
2211
+ }
2212
+ async function withRooms(question) {
2213
+ const count = await ctx.rooms();
2214
+ return count > 0 ? question + " " + roomsPhrase(count) : question;
2215
+ }
2216
+ try {
2217
+ if (globals.version) {
2218
+ ui.line(version(io, env.path("cli/package.json")));
2219
+ return 0;
2220
+ }
2221
+ if (tokens.length === 0) {
2222
+ if (globals.help) renderHelp(ui, commands5, env.kernelEnv());
2223
+ else renderIntro(ui);
2224
+ return 0;
2225
+ }
2226
+ return await dispatch(tokens);
2227
+ } catch (error) {
2228
+ return fail(error);
2229
+ }
2230
+ function fail(error) {
2231
+ if (error instanceof UsageError) return report(2, error.message, error.fix, error.why);
2232
+ if (error instanceof PreconditionError) return report(3, error.message, error.fix, error.why);
2233
+ const message = error instanceof Error ? error.message : String(error);
2234
+ return report(1, message, "colloq help");
2235
+ }
2236
+ function report(code, what, fix, why = "") {
2237
+ if (globals.json) {
2238
+ ui.json({ ok: false, code, error: what + (why ? ". " + why : ""), hint: fix });
2239
+ return code;
2240
+ }
2241
+ ui.refuse(what, why, fix);
2242
+ return code;
2243
+ }
2244
+ async function dispatch(input) {
2245
+ const head = input.filter((token) => !token.startsWith("-"));
2246
+ const first = head[0] ?? "";
2247
+ if (first === "help") {
2248
+ const wanted = head[1];
2249
+ if (wanted) {
2250
+ const target = find(commands5, wanted) ?? find(commands5, head.slice(1, 3).join(" "));
2251
+ if (!target) return unknown(wanted);
2252
+ renderCommandHelp(ui, target, env);
2253
+ return 0;
2254
+ }
2255
+ renderHelp(ui, commands5, env.kernelEnv());
2256
+ return 0;
2257
+ }
2258
+ const two2 = head.length > 1 ? first + " " + head[1] : "";
2259
+ const found = (two2 ? find(commands5, two2) : void 0) ?? find(commands5, first);
2260
+ if (!found) {
2261
+ const twin = first.includes("-") ? find(commands5, first.replaceAll("-", " ")) : void 0;
2262
+ if (twin) return await dispatch(respell(input, first, twin.name));
2263
+ if (globals.help) {
2264
+ renderHelp(ui, commands5, env.kernelEnv());
2265
+ return 2;
2266
+ }
2267
+ return unknown(first);
2268
+ }
2269
+ const matchedTwo = Boolean(two2) && (found.name === two2 || (found.aliases ?? []).includes(two2));
2270
+ const used = matchedTwo ? 2 : 1;
2271
+ const tail = dropCommandTokens(input, used);
2272
+ if (globals.help) {
2273
+ renderCommandHelp(ui, found, env);
2274
+ return 0;
2275
+ }
2276
+ if (globals.json && !found.flags.some((flag2) => flag2.name === "json")) {
2277
+ const where = commands5.filter((command) => command.flags.some((flag2) => flag2.name === "json")).map((command) => command.name);
2278
+ throw new UsageError(
2279
+ "command " + found.name + " has no --json",
2280
+ where.length ? "--json works with " + where.join(", ") : "no command has a machine-readable view"
2281
+ );
2282
+ }
2283
+ const options = {};
2284
+ for (const flag2 of found.flags) {
2285
+ options[flag2.name] = {
2286
+ type: flag2.arg ? "string" : "boolean",
2287
+ ...flag2.short ? { short: flag2.short } : {},
2288
+ ...flag2.multiple ? { multiple: true } : {}
2289
+ };
2290
+ }
2291
+ let parsed;
2292
+ try {
2293
+ parsed = parseArgs({ args: tail, options, strict: true, allowPositionals: true });
2294
+ } catch (error) {
2295
+ throw parseTrouble(error, found);
2296
+ }
2297
+ ctx.positionals = parsed.positionals;
2298
+ ctx.values = parsed.values;
2299
+ const declared = found.args ?? [];
2300
+ for (let i = 0; i < declared.length; i++) {
2301
+ const arg = declared[i];
2302
+ if (!arg?.required) continue;
2303
+ if ((parsed.positionals[i] ?? "").trim() !== "") continue;
2304
+ throw new UsageError("missing required argument <" + arg.name + ">", "Usage: " + found.usage);
2305
+ }
2306
+ if (parsed.positionals.length > declared.length) {
2307
+ throw new UsageError(
2308
+ "extra argument: " + (parsed.positionals[declared.length] ?? ""),
2309
+ "Usage: " + found.usage
2310
+ );
2311
+ }
2312
+ if (found.check) await found.check(ctx);
2313
+ if (found.destructive && found.confirm === "cli" && !globals.yes && !globals.dryRun) {
2314
+ const needed = found.confirmWhen ? await found.confirmWhen(ctx) : true;
2315
+ if (needed) {
2316
+ const asked = typeof found.confirmQuestion === "function" ? await found.confirmQuestion(ctx) : found.confirmQuestion ?? "continue?";
2317
+ const question = found.rooms === false ? asked : await withRooms(asked);
2318
+ if (!await ui.confirm(question)) return cancelled(ui);
2319
+ }
2320
+ }
2321
+ return await found.run(ctx);
2322
+ }
2323
+ function unknown(name) {
2324
+ const inGroup = commands5.filter((command) => command.name.startsWith(name + " ")).map((command) => command.name);
2325
+ if (inGroup.length > 0) {
2326
+ return report(
2327
+ 2,
2328
+ name + " is a group of commands, not a command",
2329
+ "colloq " + inGroup.join(" \xB7 colloq "),
2330
+ "the group needs a second word"
2331
+ );
2332
+ }
2333
+ const near = nearest(commands5, name);
2334
+ return report(
2335
+ 2,
2336
+ "no command " + name,
2337
+ near ? "did you mean colloq " + near + "?" : "colloq help \u2014 the list of commands"
2338
+ );
2339
+ }
2340
+ }
2341
+ function dropCommandTokens(input, count) {
2342
+ const out = [];
2343
+ let dropped = 0;
2344
+ for (const token of input) {
2345
+ if (dropped < count && !token.startsWith("-")) {
2346
+ dropped++;
2347
+ continue;
2348
+ }
2349
+ out.push(token);
2350
+ }
2351
+ return out;
2352
+ }
2353
+ function respell(input, from, to) {
2354
+ const out = [];
2355
+ let done = false;
2356
+ for (const token of input) {
2357
+ if (!done && token === from) {
2358
+ out.push(...to.split(" "));
2359
+ done = true;
2360
+ continue;
2361
+ }
2362
+ out.push(token);
2363
+ }
2364
+ return out;
2365
+ }
2366
+ function parseTrouble(error, command) {
2367
+ const message = error instanceof Error ? error.message : String(error);
2368
+ const fix = "colloq " + command.name + " --help";
2369
+ if (error.code === "ERR_PARSE_ARGS_UNKNOWN_OPTION") {
2370
+ const flag2 = /'(-[^']+)'/.exec(message)?.[1] ?? "";
2371
+ const near = nearestWord(
2372
+ command.flags.map((item) => "--" + item.name),
2373
+ flag2
2374
+ );
2375
+ return new UsageError(
2376
+ "command " + command.name + " has no flag " + flag2,
2377
+ near ? "did you mean " + near + "? \xB7 " + fix : fix
2378
+ );
2379
+ }
2380
+ return new UsageError(message, fix);
2381
+ }
2382
+ function find(commands5, name) {
2383
+ if (!name) return void 0;
2384
+ return commands5.find((command) => command.name === name || (command.aliases ?? []).includes(name));
2385
+ }
2386
+ function nearest(commands5, name) {
2387
+ let best;
2388
+ let bestDistance = 4;
2389
+ for (const command of commands5) {
2390
+ for (const candidate of [command.name, ...command.aliases ?? []]) {
2391
+ const distance = levenshtein(name, candidate);
2392
+ if (distance < bestDistance) {
2393
+ bestDistance = distance;
2394
+ best = command.name;
2395
+ }
2396
+ }
2397
+ }
2398
+ return best;
2399
+ }
2400
+ function nearestWord(words, name) {
2401
+ let best;
2402
+ let bestDistance = 4;
2403
+ for (const word of words) {
2404
+ const distance = levenshtein(name, word);
2405
+ if (distance < bestDistance) {
2406
+ bestDistance = distance;
2407
+ best = word;
2408
+ }
2409
+ }
2410
+ return best;
2411
+ }
2412
+ function levenshtein(a, b) {
2413
+ const rows = a.length + 1;
2414
+ const cols = b.length + 1;
2415
+ let previous = Array.from({ length: cols }, (_, i) => i);
2416
+ for (let i = 1; i < rows; i++) {
2417
+ const current = [i];
2418
+ for (let j = 1; j < cols; j++) {
2419
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
2420
+ current[j] = Math.min(
2421
+ (current[j - 1] ?? 0) + 1,
2422
+ (previous[j] ?? 0) + 1,
2423
+ (previous[j - 1] ?? 0) + cost
2424
+ );
2425
+ }
2426
+ previous = current;
2427
+ }
2428
+ return previous[cols - 1] ?? Math.max(a.length, b.length);
2429
+ }
2430
+ function roomsPhrase(count) {
2431
+ return roomsWord(count) + (count === 1 ? " is running" : " are running");
2432
+ }
2433
+ function version(io, packagePath) {
2434
+ const text = io.readText(packagePath);
2435
+ const match = text ? /"version"\s*:\s*"([^"]+)"/.exec(text) : null;
2436
+ return match?.[1] ?? "0.0.0";
2437
+ }
2438
+ function renderIntro(ui) {
2439
+ ui.line(ui.bold("colloq") + " \u2014 a class on this computer: notebooks, a Python kernel and a board");
2440
+ ui.line();
2441
+ ui.table([
2442
+ [ui.cyan("colloq start"), "start the class and open the browser"],
2443
+ [ui.cyan("colloq host <name>"), "publish it and give the room a link"],
2444
+ [ui.cyan("colloq stop"), "end the class"]
2445
+ ]);
2446
+ ui.line();
2447
+ ui.line(ui.dim("More: colloq help \xB7 one command: colloq <command> --help"));
2448
+ }
2449
+ function renderHelp(ui, commands5, kernelEnv) {
2450
+ for (const group of GROUPS) {
2451
+ const inGroup = commands5.filter((command) => command.group === group.name);
2452
+ if (inGroup.length === 0) continue;
2453
+ ui.header(group.title);
2454
+ for (const command of inGroup) {
2455
+ ui.line(" " + ui.cyan(command.name.padEnd(18)) + command.summary);
2456
+ }
2457
+ ui.line();
2458
+ }
2459
+ ui.line(ui.dim("Most used: colloq start \xB7 colloq host <name> \xB7 colloq status \xB7 colloq stop"));
2460
+ ui.line(ui.dim("Everywhere: --dry-run \u2014 show what would run and do nothing \xB7 --yes \u2014 do not ask"));
2461
+ ui.line(ui.dim("Kernel environment now: " + kernelEnv));
2462
+ ui.line(ui.dim("In detail: colloq <command> --help"));
2463
+ }
2464
+ function renderCommandHelp(ui, command, env) {
2465
+ const domain = env.relay().domain || "class.example.org";
2466
+ const kernel = env.kernelEnv() || "base";
2467
+ const fill = (text) => text.replaceAll("{domain}", domain).replaceAll("{env}", kernel);
2468
+ ui.line(command.summary);
2469
+ ui.line("Usage: " + ui.cyan(command.usage));
2470
+ if (command.args?.length) {
2471
+ ui.line();
2472
+ ui.header("Arguments");
2473
+ ui.table(
2474
+ command.args.map((arg) => [
2475
+ arg.required ? "<" + arg.name + ">" : "[" + arg.name + "]",
2476
+ arg.summary
2477
+ ])
2478
+ );
2479
+ }
2480
+ ui.line();
2481
+ ui.header("Flags");
2482
+ ui.table([
2483
+ ...command.flags.map((flag2) => [
2484
+ (flag2.short ? "-" + flag2.short + ", " : "") + "--" + flag2.name + (flag2.arg ? " <" + flag2.arg + ">" : ""),
2485
+ flag2.summary
2486
+ ]),
2487
+ // Общие флаги дописываются здесь, а не в usage каждой команды: иначе одни
2488
+ // группы их перечисляют, другие нет, и help читается как пять разных.
2489
+ ...globalFlagRows(command)
2490
+ ]);
2491
+ const examples = (command.examples?.length ? command.examples : [command.usage]).map(fill);
2492
+ const shown = examples.length >= 2 ? examples.slice(0, 2) : [examples[0] ?? "", fill(command.usage) + " --dry-run"];
2493
+ ui.line();
2494
+ ui.header("Examples");
2495
+ ui.table(shown.map((example) => [ui.cyan(example)]));
2496
+ if (command.notes) {
2497
+ ui.line();
2498
+ ui.line(ui.dim(command.notes));
2499
+ }
2500
+ ui.line();
2501
+ ui.line(ui.dim("delegates: " + command.delegates));
2502
+ }
2503
+ function globalFlagRows(command) {
2504
+ const rows = [["--dry-run", "Show what would run and do nothing"]];
2505
+ if (command.destructive) rows.push(["-y, --yes", "Agree in advance, no question will be asked"]);
2506
+ rows.push(["--no-color", "No colour: the same output without a single escape"]);
2507
+ rows.push(["-h, --help", "This page"]);
2508
+ return rows;
2509
+ }
2510
+ var entry = process.argv[1] ?? "";
2511
+ if (entry.endsWith("/cli/src/main.ts") || entry.endsWith("/cli/src/main.js")) {
2512
+ const code = await cli(process.argv.slice(2));
2513
+ process.exitCode = code;
2514
+ }
2515
+
2516
+ // cli/src/bin.ts
2517
+ process.exitCode = await cli(process.argv.slice(2));