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.
- colloq/__init__.py +12 -0
- colloq/__main__.py +315 -0
- colloq/_app/.colloq-dist.json +66 -0
- colloq/_app/LICENSE +21 -0
- colloq/_app/THIRD_PARTY_NOTICES.md +106 -0
- colloq/_app/cli/colloq.mjs +2517 -0
- colloq/_app/cli/launch.mjs +1572 -0
- colloq/_app/cli/package.json +5 -0
- colloq/_app/cli/public-url-lease.mjs +198 -0
- colloq/_app/kernel/Dockerfile +93 -0
- colloq/_app/kernel/environments/base-gpu.txt +21 -0
- colloq/_app/kernel/environments/base.txt +12 -0
- colloq/_app/kernel/environments/cv.txt +15 -0
- colloq/_app/kernel/environments/gpu.txt +17 -0
- colloq/_app/kernel/environments/kaggle-base.txt +20 -0
- colloq/_app/kernel/environments/pvz-geo.txt +6 -0
- colloq/_app/kernel/requirements.txt +18 -0
- colloq/_app/package.json +20 -0
- colloq/_app/scripts/backup-local.sh +165 -0
- colloq/_app/scripts/backup.sh +34 -0
- colloq/_app/scripts/cluster.sh +492 -0
- colloq/_app/scripts/dns.sh +251 -0
- colloq/_app/scripts/host.sh +1514 -0
- colloq/_app/scripts/lib.sh +62 -0
- colloq/_app/scripts/restore.sh +338 -0
- colloq/_app/scripts/runtime-backup.py +373 -0
- colloq/_app/scripts/state-lock.py +85 -0
- colloq/_app/server/assets/fonts/HSESans-Black.otf +0 -0
- colloq/_app/server/assets/fonts/HSESans-Bold.otf +0 -0
- colloq/_app/server/assets/fonts/HSESans-Regular.otf +0 -0
- colloq/_app/server/assets/fonts/JetBrainsMono-Medium.ttf +0 -0
- colloq/_app/server/assets/fonts/JetBrainsMono-OFL.txt +93 -0
- colloq/_app/server/dist/local-cleanup.js +15987 -0
- colloq/_app/server/dist/runtime-smoke.js +17026 -0
- colloq/_app/server/dist/server.js +54333 -0
- colloq/_app/server/package.json +6 -0
- colloq/_app/web/dist/assets/AdminScreen-C-cg0GlJ.css +1 -0
- colloq/_app/web/dist/assets/AdminScreen-C-cg0GlJ.css.br +0 -0
- colloq/_app/web/dist/assets/AdminScreen-C-cg0GlJ.css.gz +0 -0
- colloq/_app/web/dist/assets/AdminScreen-fu4cB6Ra.js +105 -0
- colloq/_app/web/dist/assets/AdminScreen-fu4cB6Ra.js.br +0 -0
- colloq/_app/web/dist/assets/AdminScreen-fu4cB6Ra.js.gz +0 -0
- colloq/_app/web/dist/assets/App-CMXNqOjp.css +1 -0
- colloq/_app/web/dist/assets/App-CMXNqOjp.css.br +0 -0
- colloq/_app/web/dist/assets/App-CMXNqOjp.css.gz +0 -0
- colloq/_app/web/dist/assets/App-DvCPzIRO.js +26 -0
- colloq/_app/web/dist/assets/App-DvCPzIRO.js.br +0 -0
- colloq/_app/web/dist/assets/App-DvCPzIRO.js.gz +0 -0
- colloq/_app/web/dist/assets/CellOutputs-Dl-AZL45.js +42 -0
- colloq/_app/web/dist/assets/CellOutputs-Dl-AZL45.js.br +0 -0
- colloq/_app/web/dist/assets/CellOutputs-Dl-AZL45.js.gz +0 -0
- colloq/_app/web/dist/assets/CommandPalette-DhBGho7_.js +3 -0
- colloq/_app/web/dist/assets/CommandPalette-DhBGho7_.js.br +0 -0
- colloq/_app/web/dist/assets/CommandPalette-DhBGho7_.js.gz +0 -0
- colloq/_app/web/dist/assets/CompetitionsScreen-Vu3RpHmf.js +3 -0
- colloq/_app/web/dist/assets/CompetitionsScreen-Vu3RpHmf.js.br +0 -0
- colloq/_app/web/dist/assets/CompetitionsScreen-Vu3RpHmf.js.gz +0 -0
- colloq/_app/web/dist/assets/ConsoleView-B2uyYvFh.js +1 -0
- colloq/_app/web/dist/assets/ConsoleView-B2uyYvFh.js.br +0 -0
- colloq/_app/web/dist/assets/ConsoleView-B2uyYvFh.js.gz +0 -0
- colloq/_app/web/dist/assets/ConsoleView-DLbp0Jul.css +1 -0
- colloq/_app/web/dist/assets/DependencyBundleCard-B392XMVZ.js +4 -0
- colloq/_app/web/dist/assets/DependencyBundleCard-B392XMVZ.js.br +0 -0
- colloq/_app/web/dist/assets/DependencyBundleCard-B392XMVZ.js.gz +0 -0
- colloq/_app/web/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- colloq/_app/web/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- colloq/_app/web/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- colloq/_app/web/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- colloq/_app/web/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- colloq/_app/web/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- colloq/_app/web/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- colloq/_app/web/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- colloq/_app/web/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- colloq/_app/web/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- colloq/_app/web/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- colloq/_app/web/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- colloq/_app/web/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- colloq/_app/web/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- colloq/_app/web/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- colloq/_app/web/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- colloq/_app/web/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- colloq/_app/web/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- colloq/_app/web/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- colloq/_app/web/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- colloq/_app/web/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- colloq/_app/web/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- colloq/_app/web/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- colloq/_app/web/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- colloq/_app/web/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- colloq/_app/web/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- colloq/_app/web/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- colloq/_app/web/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- colloq/_app/web/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- colloq/_app/web/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- colloq/_app/web/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- colloq/_app/web/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- colloq/_app/web/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- colloq/_app/web/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- colloq/_app/web/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- colloq/_app/web/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- colloq/_app/web/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- colloq/_app/web/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- colloq/_app/web/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- colloq/_app/web/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- colloq/_app/web/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- colloq/_app/web/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- colloq/_app/web/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- colloq/_app/web/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- colloq/_app/web/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- colloq/_app/web/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- colloq/_app/web/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- colloq/_app/web/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- colloq/_app/web/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- colloq/_app/web/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- colloq/_app/web/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- colloq/_app/web/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- colloq/_app/web/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- colloq/_app/web/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- colloq/_app/web/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- colloq/_app/web/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- colloq/_app/web/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- colloq/_app/web/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- colloq/_app/web/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- colloq/_app/web/dist/assets/PultWindow-B3cc8537.css +1 -0
- colloq/_app/web/dist/assets/PultWindow-B3cc8537.css.br +0 -0
- colloq/_app/web/dist/assets/PultWindow-B3cc8537.css.gz +0 -0
- colloq/_app/web/dist/assets/PultWindow-DPOUhSqt.js +8 -0
- colloq/_app/web/dist/assets/PultWindow-DPOUhSqt.js.br +0 -0
- colloq/_app/web/dist/assets/PultWindow-DPOUhSqt.js.gz +0 -0
- colloq/_app/web/dist/assets/ReaderScreen-CV_0VAvW.js +9 -0
- colloq/_app/web/dist/assets/ReaderScreen-CV_0VAvW.js.br +0 -0
- colloq/_app/web/dist/assets/ReaderScreen-CV_0VAvW.js.gz +0 -0
- colloq/_app/web/dist/assets/SessionScreen-BrcB6CLH.js +132 -0
- colloq/_app/web/dist/assets/SessionScreen-BrcB6CLH.js.br +0 -0
- colloq/_app/web/dist/assets/SessionScreen-BrcB6CLH.js.gz +0 -0
- colloq/_app/web/dist/assets/SessionScreen-Dek9dK3C.css +1 -0
- colloq/_app/web/dist/assets/SessionScreen-Dek9dK3C.css.br +0 -0
- colloq/_app/web/dist/assets/SessionScreen-Dek9dK3C.css.gz +0 -0
- colloq/_app/web/dist/assets/actions-DLPyolgo.js +1 -0
- colloq/_app/web/dist/assets/admin-en-BHmR7Co0.js +9 -0
- colloq/_app/web/dist/assets/admin-en-BHmR7Co0.js.br +0 -0
- colloq/_app/web/dist/assets/admin-en-BHmR7Co0.js.gz +0 -0
- colloq/_app/web/dist/assets/admin-ru-Dr6Z36Zi.js +11 -0
- colloq/_app/web/dist/assets/admin-ru-Dr6Z36Zi.js.br +0 -0
- colloq/_app/web/dist/assets/admin-ru-Dr6Z36Zi.js.gz +0 -0
- colloq/_app/web/dist/assets/codemirror-6LUh0q1l.js +32 -0
- colloq/_app/web/dist/assets/codemirror-6LUh0q1l.js.br +0 -0
- colloq/_app/web/dist/assets/codemirror-6LUh0q1l.js.gz +0 -0
- colloq/_app/web/dist/assets/competitions-en-zw57uAGL.js +5 -0
- colloq/_app/web/dist/assets/competitions-en-zw57uAGL.js.br +0 -0
- colloq/_app/web/dist/assets/competitions-en-zw57uAGL.js.gz +0 -0
- colloq/_app/web/dist/assets/competitions-ru-BBQUjAx0.js +5 -0
- colloq/_app/web/dist/assets/competitions-ru-BBQUjAx0.js.br +0 -0
- colloq/_app/web/dist/assets/competitions-ru-BBQUjAx0.js.gz +0 -0
- colloq/_app/web/dist/assets/editor-theme-CVYtpZDy.js +1 -0
- colloq/_app/web/dist/assets/editor-theme-CVYtpZDy.js.br +0 -0
- colloq/_app/web/dist/assets/editor-theme-CVYtpZDy.js.gz +0 -0
- colloq/_app/web/dist/assets/index-CwbYK31L.css +1 -0
- colloq/_app/web/dist/assets/index-CwbYK31L.css.br +0 -0
- colloq/_app/web/dist/assets/index-CwbYK31L.css.gz +0 -0
- colloq/_app/web/dist/assets/index-Di16xdmz.js +2 -0
- colloq/_app/web/dist/assets/index-Di16xdmz.js.br +0 -0
- colloq/_app/web/dist/assets/index-Di16xdmz.js.gz +0 -0
- colloq/_app/web/dist/assets/katex-CS_ezkZp.css +1 -0
- colloq/_app/web/dist/assets/katex-CS_ezkZp.css.br +0 -0
- colloq/_app/web/dist/assets/katex-CS_ezkZp.css.gz +0 -0
- colloq/_app/web/dist/assets/katex-DLorvT8M.js +257 -0
- colloq/_app/web/dist/assets/katex-DLorvT8M.js.br +0 -0
- colloq/_app/web/dist/assets/katex-DLorvT8M.js.gz +0 -0
- colloq/_app/web/dist/assets/pdf-DEp6sk-T.js +55 -0
- colloq/_app/web/dist/assets/pdf-DEp6sk-T.js.br +0 -0
- colloq/_app/web/dist/assets/pdf-DEp6sk-T.js.gz +0 -0
- colloq/_app/web/dist/assets/persistence.svelte-vcuCBcT5.js +1 -0
- colloq/_app/web/dist/assets/persistence.svelte-vcuCBcT5.js.br +0 -0
- colloq/_app/web/dist/assets/persistence.svelte-vcuCBcT5.js.gz +0 -0
- colloq/_app/web/dist/assets/reader-en-UCGE3U6V.js +3 -0
- colloq/_app/web/dist/assets/reader-en-UCGE3U6V.js.br +0 -0
- colloq/_app/web/dist/assets/reader-en-UCGE3U6V.js.gz +0 -0
- colloq/_app/web/dist/assets/reader-ru-CaiNj0K8.js +3 -0
- colloq/_app/web/dist/assets/reader-ru-CaiNj0K8.js.br +0 -0
- colloq/_app/web/dist/assets/reader-ru-CaiNj0K8.js.gz +0 -0
- colloq/_app/web/dist/assets/render-CjP3RuRT.js +155 -0
- colloq/_app/web/dist/assets/render-CjP3RuRT.js.br +0 -0
- colloq/_app/web/dist/assets/render-CjP3RuRT.js.gz +0 -0
- colloq/_app/web/dist/assets/render.svelte-B4_hAJmX.js +0 -0
- colloq/_app/web/dist/assets/render.svelte-B4_hAJmX.js.br +0 -0
- colloq/_app/web/dist/assets/render.svelte-B4_hAJmX.js.gz +0 -0
- colloq/_app/web/dist/assets/room-en-Bh8L6NcO.js +3 -0
- colloq/_app/web/dist/assets/room-en-Bh8L6NcO.js.br +0 -0
- colloq/_app/web/dist/assets/room-en-Bh8L6NcO.js.gz +0 -0
- colloq/_app/web/dist/assets/room-ru-Bar2GgS0.js +3 -0
- colloq/_app/web/dist/assets/room-ru-Bar2GgS0.js.br +0 -0
- colloq/_app/web/dist/assets/room-ru-Bar2GgS0.js.gz +0 -0
- colloq/_app/web/dist/assets/seminar-link-CRaZl9Ku.js +1 -0
- colloq/_app/web/dist/assets/seminar-link-CRaZl9Ku.js.br +0 -0
- colloq/_app/web/dist/assets/seminar-link-CRaZl9Ku.js.gz +0 -0
- colloq/_app/web/dist/assets/seminar-link-DoTugxxA.css +1 -0
- colloq/_app/web/dist/assets/seminar-link-DoTugxxA.css.br +0 -0
- colloq/_app/web/dist/assets/seminar-link-DoTugxxA.css.gz +0 -0
- colloq/_app/web/dist/assets/yjs-rjB3hdnL.js +6 -0
- colloq/_app/web/dist/assets/yjs-rjB3hdnL.js.br +0 -0
- colloq/_app/web/dist/assets/yjs-rjB3hdnL.js.gz +0 -0
- colloq/_app/web/dist/fonts/JetBrainsMono-OFL.txt +93 -0
- colloq/_app/web/dist/fonts/hse-sans-400.woff2 +0 -0
- colloq/_app/web/dist/fonts/hse-sans-600.woff2 +0 -0
- colloq/_app/web/dist/fonts/hse-sans-700.woff2 +0 -0
- colloq/_app/web/dist/fonts/hse-sans-900.woff2 +0 -0
- colloq/_app/web/dist/fonts/jetbrains-mono-cyrillic-ext.woff2 +0 -0
- colloq/_app/web/dist/fonts/jetbrains-mono-cyrillic.woff2 +0 -0
- colloq/_app/web/dist/fonts/jetbrains-mono-latin-ext.woff2 +0 -0
- colloq/_app/web/dist/fonts/jetbrains-mono-latin.woff2 +0 -0
- colloq/_app/web/dist/index.html +268 -0
- colloq/_app/web/dist/og/colloq.png +0 -0
- colloq/_app/web/dist/pdf/pdf.worker.min.mjs +29 -0
- colloq/_app/web/dist/pdf/pdf.worker.min.mjs.br +0 -0
- colloq/_app/web/dist/pdf/pdf.worker.min.mjs.gz +0 -0
- colloq/_app/web/dist/plotly/plotly.min.js +3620 -0
- colloq/_app/web/dist/plotly/plotly.min.js.br +0 -0
- colloq/_app/web/dist/plotly/plotly.min.js.gz +0 -0
- colloq/_version.py +5 -0
- colloq-0.2.0.dist-info/METADATA +124 -0
- colloq-0.2.0.dist-info/RECORD +224 -0
- colloq-0.2.0.dist-info/WHEEL +4 -0
- colloq-0.2.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,1572 @@
|
|
|
1
|
+
// cli/src/launch.ts
|
|
2
|
+
import fs7 from "node:fs";
|
|
3
|
+
import path6 from "node:path";
|
|
4
|
+
import net from "node:net";
|
|
5
|
+
import { spawn as spawn2 } from "node:child_process";
|
|
6
|
+
import { randomUUID as randomUUID3 } from "node:crypto";
|
|
7
|
+
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
8
|
+
import { parse as parseEnv } from "dotenv";
|
|
9
|
+
|
|
10
|
+
// cli/src/launch-config.ts
|
|
11
|
+
import fs2 from "node:fs";
|
|
12
|
+
import path2 from "node:path";
|
|
13
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
14
|
+
|
|
15
|
+
// cli/src/launch-state.ts
|
|
16
|
+
import fs from "node:fs";
|
|
17
|
+
import os from "node:os";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import { randomUUID } from "node:crypto";
|
|
20
|
+
import { fileURLToPath } from "node:url";
|
|
21
|
+
var selfDir = path.dirname(fileURLToPath(import.meta.url));
|
|
22
|
+
function expandHome(value) {
|
|
23
|
+
return value === "~" || value.startsWith("~/") ? path.join(os.homedir(), value.slice(1)) : value;
|
|
24
|
+
}
|
|
25
|
+
function isRepository(dir) {
|
|
26
|
+
try {
|
|
27
|
+
if (!fs.existsSync(path.join(dir, "Makefile"))) return false;
|
|
28
|
+
return /"name"\s*:\s*"colloq"/.test(fs.readFileSync(path.join(dir, "package.json"), "utf8"));
|
|
29
|
+
} catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function isWorkingTree(dir) {
|
|
34
|
+
return fs.existsSync(path.join(dir, "cli/src/launch.ts"));
|
|
35
|
+
}
|
|
36
|
+
function isDistribution(dir = appDir()) {
|
|
37
|
+
return fs.existsSync(path.join(dir, ".colloq-dist.json"));
|
|
38
|
+
}
|
|
39
|
+
function looksLikeApp(dir) {
|
|
40
|
+
if (isRepository(dir) || fs.existsSync(path.join(dir, ".colloq-dist.json"))) return true;
|
|
41
|
+
return fs.existsSync(path.join(dir, "server/dist/server.js")) && fs.existsSync(path.join(dir, "web/dist"));
|
|
42
|
+
}
|
|
43
|
+
var appCache;
|
|
44
|
+
function appDir() {
|
|
45
|
+
if (appCache) return appCache;
|
|
46
|
+
const explicit = process.env.COLLOQ_APP_DIR;
|
|
47
|
+
if (explicit) return appCache = path.resolve(expandHome(explicit));
|
|
48
|
+
for (let dir = selfDir, step = 0; step < 12; step++) {
|
|
49
|
+
if (looksLikeApp(dir)) return appCache = dir;
|
|
50
|
+
const up = path.dirname(dir);
|
|
51
|
+
if (up === dir) break;
|
|
52
|
+
dir = up;
|
|
53
|
+
}
|
|
54
|
+
return appCache = path.resolve(selfDir, "../..");
|
|
55
|
+
}
|
|
56
|
+
function userStateDir() {
|
|
57
|
+
const xdg = process.env.XDG_DATA_HOME;
|
|
58
|
+
return xdg && path.isAbsolute(xdg) ? path.join(xdg, "colloq") : path.join(os.homedir(), ".colloq");
|
|
59
|
+
}
|
|
60
|
+
function resolveHome() {
|
|
61
|
+
const explicit = process.env.COLLOQ_HOME;
|
|
62
|
+
if (explicit) return path.resolve(expandHome(explicit));
|
|
63
|
+
const app = appDir();
|
|
64
|
+
if (isRepository(app)) return app;
|
|
65
|
+
if (isDistribution(app)) return userStateDir();
|
|
66
|
+
if (isWorkingTree(app)) return app;
|
|
67
|
+
return userStateDir();
|
|
68
|
+
}
|
|
69
|
+
var homeCache;
|
|
70
|
+
function homeDir() {
|
|
71
|
+
if (homeCache) return homeCache;
|
|
72
|
+
const home2 = resolveHome();
|
|
73
|
+
fs.mkdirSync(home2, { recursive: true, mode: 448 });
|
|
74
|
+
return homeCache = home2;
|
|
75
|
+
}
|
|
76
|
+
var SESSION_FILE = ".colloq/local-session.json";
|
|
77
|
+
function readJson(file) {
|
|
78
|
+
try {
|
|
79
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
80
|
+
} catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
function writeJson(file, value) {
|
|
85
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 448 });
|
|
86
|
+
const temporary = `${file}.${process.pid}.${randomUUID()}`;
|
|
87
|
+
fs.writeFileSync(temporary, JSON.stringify(value) + "\n", { mode: 384 });
|
|
88
|
+
try {
|
|
89
|
+
fs.renameSync(temporary, file);
|
|
90
|
+
} finally {
|
|
91
|
+
try {
|
|
92
|
+
fs.unlinkSync(temporary);
|
|
93
|
+
} catch {
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
function alive(pid) {
|
|
98
|
+
if (!Number.isSafeInteger(pid) || pid <= 1) return false;
|
|
99
|
+
try {
|
|
100
|
+
process.kill(pid, 0);
|
|
101
|
+
return true;
|
|
102
|
+
} catch (error) {
|
|
103
|
+
return error.code === "EPERM";
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
function readReceipt(file) {
|
|
107
|
+
const value = readJson(file);
|
|
108
|
+
if (!value || !Number.isSafeInteger(value.pid) || value.pid <= 1 || typeof value.runId !== "string" || !value.runId || !["run", "dev"].includes(value.mode) || !Number.isInteger(value.port) || value.port < 1 || value.port > 65535 || !["root", "url", "dataDir", "workspaceDir", "leaseFile"].every(
|
|
109
|
+
(key) => typeof value[key] === "string"
|
|
110
|
+
) || !value.options || typeof value.options !== "object")
|
|
111
|
+
return null;
|
|
112
|
+
return value;
|
|
113
|
+
}
|
|
114
|
+
function acquireLock(file, pid, runId, isAlive = alive) {
|
|
115
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 448 });
|
|
116
|
+
const create = () => {
|
|
117
|
+
const fd = fs.openSync(file, "wx", 384);
|
|
118
|
+
try {
|
|
119
|
+
fs.writeFileSync(fd, JSON.stringify({ pid, runId }) + "\n");
|
|
120
|
+
} finally {
|
|
121
|
+
fs.closeSync(fd);
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
try {
|
|
125
|
+
create();
|
|
126
|
+
} catch (error) {
|
|
127
|
+
if (error.code !== "EEXIST") throw error;
|
|
128
|
+
const guard = `${file}.reclaim`;
|
|
129
|
+
try {
|
|
130
|
+
fs.mkdirSync(guard, { mode: 448 });
|
|
131
|
+
} catch {
|
|
132
|
+
throw new Error("Another run is already checking the local session. Repeat the command.");
|
|
133
|
+
}
|
|
134
|
+
try {
|
|
135
|
+
const prior = readJson(file);
|
|
136
|
+
if (!prior || isAlive(prior.pid))
|
|
137
|
+
throw new Error(
|
|
138
|
+
"A local session is already starting or running. Use colloq status / colloq stop."
|
|
139
|
+
);
|
|
140
|
+
fs.unlinkSync(file);
|
|
141
|
+
create();
|
|
142
|
+
} finally {
|
|
143
|
+
fs.rmdirSync(guard);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return () => {
|
|
147
|
+
if (readJson(file)?.runId === runId) fs.unlinkSync(file);
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// cli/src/launch-config.ts
|
|
152
|
+
function parseLaunchArgs(args) {
|
|
153
|
+
const options = {
|
|
154
|
+
action: "run",
|
|
155
|
+
detach: false,
|
|
156
|
+
open: true,
|
|
157
|
+
fast: false,
|
|
158
|
+
build: false
|
|
159
|
+
};
|
|
160
|
+
let at = 0;
|
|
161
|
+
if (["run", "dev", "stop", "restart", "cloudflared"].includes(args[0]))
|
|
162
|
+
options.action = args[at++];
|
|
163
|
+
for (; at < args.length; at++) {
|
|
164
|
+
const flag = args[at];
|
|
165
|
+
if (flag === "--detach") options.detach = true;
|
|
166
|
+
else if (flag === "--no-open") options.open = false;
|
|
167
|
+
else if (flag === "--fast") options.fast = true;
|
|
168
|
+
else if (flag === "--build") options.build = true;
|
|
169
|
+
else if (flag === "--child") options.child = true;
|
|
170
|
+
else if (flag === "--share") options.share = true;
|
|
171
|
+
else if (flag === "--port") {
|
|
172
|
+
const value = args[++at];
|
|
173
|
+
if (!value || !/^\d+$/.test(value) || Number(value) < 1 || Number(value) > 65535)
|
|
174
|
+
throw new Error("The port must be a whole number from 1 to 65535.");
|
|
175
|
+
options.port = Number(value);
|
|
176
|
+
} else if (flag === "--host") {
|
|
177
|
+
const host = args[++at];
|
|
178
|
+
if (!host || !/^[a-zA-Z0-9](?:[a-zA-Z0-9.-]*[a-zA-Z0-9])?$/.test(host))
|
|
179
|
+
throw new Error("Give a name after --host, for example class.example.ru.");
|
|
180
|
+
options.host = host;
|
|
181
|
+
} else throw new Error(`Unknown launch argument: ${flag}`);
|
|
182
|
+
}
|
|
183
|
+
if (options.action === "dev" && options.detach)
|
|
184
|
+
throw new Error("dev runs in the terminal; use run --detach for the background.");
|
|
185
|
+
if (options.share && options.host)
|
|
186
|
+
throw new Error(
|
|
187
|
+
"--share and --host do not work together: --share is a quick Cloudflare link, --host is your own name."
|
|
188
|
+
);
|
|
189
|
+
return options;
|
|
190
|
+
}
|
|
191
|
+
function launchConfig(root2, options, source, home2 = root2) {
|
|
192
|
+
const dev = options.action === "dev";
|
|
193
|
+
const configuredPort = Number(source.PORT || 3e3);
|
|
194
|
+
const port = dev ? configuredPort : options.port ?? configuredPort;
|
|
195
|
+
const uiPort = dev ? options.port ?? 5173 : port;
|
|
196
|
+
for (const value of [port, uiPort])
|
|
197
|
+
if (!Number.isInteger(value) || value < 1 || value > 65535)
|
|
198
|
+
throw new Error("Invalid PORT in the launch settings.");
|
|
199
|
+
if (dev && port === uiPort)
|
|
200
|
+
throw new Error("The dev frontend port must differ from the server PORT in .env.");
|
|
201
|
+
const dataDir = path2.resolve(home2, source.DATA_DIR || "data");
|
|
202
|
+
const workspaceDir = path2.resolve(home2, source.WORKSPACE_DIR || "workspace");
|
|
203
|
+
const leaseFile = path2.join(home2, ".colloq/public-url.json");
|
|
204
|
+
const url = `http://localhost:${uiPort}`;
|
|
205
|
+
const kernelEnv = source.KERNEL_ENV || "base";
|
|
206
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(kernelEnv))
|
|
207
|
+
throw new Error("Invalid environment name in KERNEL_ENV.");
|
|
208
|
+
if (source.COLLOQ_CLUSTER === "1")
|
|
209
|
+
throw new Error(
|
|
210
|
+
"This is a runtime broker installation: use the service or cluster commands for it. run is meant for local Docker."
|
|
211
|
+
);
|
|
212
|
+
if (source.KERNEL_BACKEND === "broker")
|
|
213
|
+
throw new Error(
|
|
214
|
+
`KERNEL_BACKEND=broker is the production setting from .env.example, and a local class runs its kernels in Docker. Set KERNEL_BACKEND=docker in ${path2.join(home2, ".env")} (or move that file aside: colloq then writes one for this machine). A real runtime broker installation is run with the service or cluster commands.`
|
|
215
|
+
);
|
|
216
|
+
const env = Object.fromEntries(
|
|
217
|
+
Object.entries(source).filter((pair) => typeof pair[1] === "string")
|
|
218
|
+
);
|
|
219
|
+
Object.assign(env, {
|
|
220
|
+
NODE_ENV: "development",
|
|
221
|
+
KERNEL_BACKEND: "docker",
|
|
222
|
+
PORT: String(port),
|
|
223
|
+
BIND_ADDR: "127.0.0.1",
|
|
224
|
+
DATA_DIR: dataDir,
|
|
225
|
+
WORKSPACE_DIR: workspaceDir,
|
|
226
|
+
WORKSPACE_HOST_DIR: "",
|
|
227
|
+
COLLOQ_LOCAL_SESSION: "1",
|
|
228
|
+
COLLOQ_LOCAL_URL: url,
|
|
229
|
+
COLLOQ_PUBLIC_URL_LEASE_FILE: leaseFile,
|
|
230
|
+
COLLOQ_STOP_KERNELS_ON_EXIT: dev ? "0" : "1",
|
|
231
|
+
// Каталог состояния — серверу его надо СКАЗАТЬ. Сам он считает только
|
|
232
|
+
// корень приложения (шагами вверх до kernel/environments), и потому окно
|
|
233
|
+
// «Окружения» не видело ни одного окружения, заведённого `colloq env new`,
|
|
234
|
+
// а заведённое из панели клало в site-packages. По этой строке панель
|
|
235
|
+
// находит свои окружения и .env; без неё каталог у неё один, как раньше.
|
|
236
|
+
COLLOQ_HOME: home2,
|
|
237
|
+
// Собранный интерфейс — часть приложения, поэтому root, а не home.
|
|
238
|
+
STATIC_DIR: path2.join(root2, "web/dist"),
|
|
239
|
+
VITE_API_TARGET: `http://127.0.0.1:${port}`
|
|
240
|
+
});
|
|
241
|
+
return {
|
|
242
|
+
root: root2,
|
|
243
|
+
home: home2,
|
|
244
|
+
dist: isDistribution(root2),
|
|
245
|
+
port,
|
|
246
|
+
uiPort,
|
|
247
|
+
url,
|
|
248
|
+
dataDir,
|
|
249
|
+
workspaceDir,
|
|
250
|
+
leaseFile,
|
|
251
|
+
env,
|
|
252
|
+
kernelEnv
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
function localClassEnv(dist2) {
|
|
256
|
+
return `# Settings for this machine. colloq wrote this file on its first start:
|
|
257
|
+
# edit it and restart with colloq restart. The full list of everything that
|
|
258
|
+
# can be configured is in .env.example next to the application.
|
|
259
|
+
|
|
260
|
+
# The address of the class on this machine. From outside it is visible
|
|
261
|
+
# only through colloq host.
|
|
262
|
+
PORT=3000
|
|
263
|
+
BIND_ADDR=127.0.0.1
|
|
264
|
+
UI_LANGUAGE=${dist2 ? "en" : "ru"}
|
|
265
|
+
|
|
266
|
+
# The kernels of a class are Docker containers on this computer, one per room.
|
|
267
|
+
# Each room container is hardened: no privileges, a process limit, the internet
|
|
268
|
+
# yes, your local network and this computer no. COLLOQ_ROOM_NETWORK=open lifts
|
|
269
|
+
# the network block for a machine you fully trust.
|
|
270
|
+
KERNEL_BACKEND=docker
|
|
271
|
+
KERNEL_ENV=base
|
|
272
|
+
# Memory and processors of one room. 4g is enough for an ordinary class; for a
|
|
273
|
+
# class that trains networks set 8g-16g, if the machine has that much.
|
|
274
|
+
KERNEL_MEM=4g
|
|
275
|
+
KERNEL_CPUS=2
|
|
276
|
+
KERNEL_SHM=1g
|
|
277
|
+
|
|
278
|
+
# Signs the sign-in links of teachers and students. Left empty, the key is
|
|
279
|
+
# created in data/ on its own and survives a restart; a value written here
|
|
280
|
+
# wins over it.
|
|
281
|
+
SESSION_SECRET=
|
|
282
|
+
# Not a shared Jupyter: every room has a token of its own. This line is here
|
|
283
|
+
# so the log does not keep the token from .env.example, which everyone knows.
|
|
284
|
+
JUPYTER_TOKEN=${randomBytes(24).toString("hex")}
|
|
285
|
+
|
|
286
|
+
# File uploads and the disk space one class may take.
|
|
287
|
+
MAX_UPLOAD_MB=50
|
|
288
|
+
MAX_SESSION_MB=1024
|
|
289
|
+
|
|
290
|
+
# The Oracle. Without a key the class still runs, but the model cannot be asked.
|
|
291
|
+
AI_PROVIDER=openai
|
|
292
|
+
OPENAI_API_KEY=
|
|
293
|
+
OPENAI_BASE_URL=https://api.openai.com/v1
|
|
294
|
+
OPENAI_MODEL=gpt-4o-mini
|
|
295
|
+
`;
|
|
296
|
+
}
|
|
297
|
+
function filesBelow(root2, relative) {
|
|
298
|
+
const full = path2.join(root2, relative);
|
|
299
|
+
if (!fs2.existsSync(full)) return [];
|
|
300
|
+
const stat = fs2.lstatSync(full);
|
|
301
|
+
if (stat.isSymbolicLink()) return [];
|
|
302
|
+
if (!stat.isDirectory()) return [relative];
|
|
303
|
+
return fs2.readdirSync(full).sort().flatMap((name) => {
|
|
304
|
+
if (["node_modules", "dist", ".git", ".vite"].includes(name) || name.endsWith(".built") || // Копии чужих бандлов, которые раскладывает сам шаг сборки (`npm run
|
|
305
|
+
// assets` в web/package.json): воркер pdf.js и plotly.js. Они не
|
|
306
|
+
// исходники — их содержимое уже посчитано через package-lock.json, а
|
|
307
|
+
// считать их отдельно значило бы пересобирать фронтенд всякий раз,
|
|
308
|
+
// когда копия появилась или пропала.
|
|
309
|
+
["web/public/pdf", "web/public/plotly"].includes(path2.join(relative, name)))
|
|
310
|
+
return [];
|
|
311
|
+
return filesBelow(root2, path2.join(relative, name));
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
function fingerprint(root2, inputs = [
|
|
315
|
+
"web",
|
|
316
|
+
"server",
|
|
317
|
+
"runtime",
|
|
318
|
+
"shared",
|
|
319
|
+
"package.json",
|
|
320
|
+
"package-lock.json",
|
|
321
|
+
"tsconfig.json"
|
|
322
|
+
]) {
|
|
323
|
+
const hash = createHash("sha256");
|
|
324
|
+
for (const file of inputs.flatMap((input) => filesBelow(root2, input)).sort()) {
|
|
325
|
+
hash.update(file).update("\0").update(fs2.readFileSync(path2.join(root2, file))).update("\0");
|
|
326
|
+
}
|
|
327
|
+
return hash.digest("hex");
|
|
328
|
+
}
|
|
329
|
+
function buildIsCurrent(root2, stamp, fast) {
|
|
330
|
+
return Boolean(
|
|
331
|
+
stamp && stamp.fingerprint === fingerprint(root2) && (fast || !stamp.fast) && fs2.existsSync(path2.join(root2, "web/dist/index.html")) && fs2.existsSync(path2.join(root2, "server/dist/server.js"))
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
function kernelInputs(root2, name) {
|
|
335
|
+
const files = ["kernel/Dockerfile", "kernel/requirements.txt"];
|
|
336
|
+
const seen = /* @__PURE__ */ new Set();
|
|
337
|
+
let current = name;
|
|
338
|
+
while (current) {
|
|
339
|
+
if (seen.has(current) || seen.size >= 8)
|
|
340
|
+
throw new Error("A cycle or too long a chain of Python environments.");
|
|
341
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(current))
|
|
342
|
+
throw new Error("Invalid parent environment name.");
|
|
343
|
+
seen.add(current);
|
|
344
|
+
const file = `kernel/environments/${current}.txt`;
|
|
345
|
+
if (!fs2.existsSync(path2.join(root2, file))) throw new Error(`No environment ${current}: ${file}`);
|
|
346
|
+
files.push(file);
|
|
347
|
+
current = fs2.readFileSync(path2.join(root2, file), "utf8").match(/^\s*#\s*colloq:\s*from\s+(\S+)/m)?.[1];
|
|
348
|
+
}
|
|
349
|
+
return files;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// cli/src/launch-process.ts
|
|
353
|
+
import { spawn } from "node:child_process";
|
|
354
|
+
import fs3 from "node:fs";
|
|
355
|
+
import { StringDecoder } from "node:string_decoder";
|
|
356
|
+
var delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
357
|
+
var Processes = class {
|
|
358
|
+
constructor(root2, env, log) {
|
|
359
|
+
this.root = root2;
|
|
360
|
+
this.env = env;
|
|
361
|
+
this.log = log;
|
|
362
|
+
}
|
|
363
|
+
children = /* @__PURE__ */ new Set();
|
|
364
|
+
/**
|
|
365
|
+
* onLine — слушатель строк stdout ребёнка: вернул true — строка его, и на
|
|
366
|
+
* экран она не идёт (в журнал идёт всё как есть). Так супервизор слышит
|
|
367
|
+
* строку-метку host.sh под --share, не показывая её человеку. Без
|
|
368
|
+
* слушателя поток идёт кусками, как шёл: построчная буферизация нужна
|
|
369
|
+
* только тому, кто просил.
|
|
370
|
+
*/
|
|
371
|
+
start(name, command, args, quiet = false, extraEnv = {}, cwd = this.root, onLine) {
|
|
372
|
+
const child = spawn(command, args, {
|
|
373
|
+
cwd,
|
|
374
|
+
env: { ...this.env, ...extraEnv },
|
|
375
|
+
detached: process.platform !== "win32",
|
|
376
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
377
|
+
});
|
|
378
|
+
const item = { child, name, settled: false, done: Promise.resolve(0) };
|
|
379
|
+
for (const [stream, target] of [
|
|
380
|
+
[child.stdout, process.stdout],
|
|
381
|
+
[child.stderr, process.stderr]
|
|
382
|
+
]) {
|
|
383
|
+
const lines = stream === child.stdout && onLine ? { rest: "", decoder: new StringDecoder("utf8") } : null;
|
|
384
|
+
stream?.on("data", (chunk) => {
|
|
385
|
+
if (this.log !== void 0) fs3.writeSync(this.log, chunk);
|
|
386
|
+
if (!lines) {
|
|
387
|
+
if (!quiet) target.write(chunk);
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
const text = lines.rest + lines.decoder.write(chunk);
|
|
391
|
+
const parts = text.split("\n");
|
|
392
|
+
lines.rest = parts.pop() ?? "";
|
|
393
|
+
for (const line of parts) if (!onLine(line) && !quiet) target.write(line + "\n");
|
|
394
|
+
});
|
|
395
|
+
if (lines)
|
|
396
|
+
stream?.on("end", () => {
|
|
397
|
+
lines.rest += lines.decoder.end();
|
|
398
|
+
if (lines.rest && !onLine(lines.rest) && !quiet) target.write(lines.rest);
|
|
399
|
+
lines.rest = "";
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
item.done = new Promise((resolve) => {
|
|
403
|
+
let finished = false;
|
|
404
|
+
const done = (code) => {
|
|
405
|
+
if (finished) return;
|
|
406
|
+
finished = true;
|
|
407
|
+
item.settled = true;
|
|
408
|
+
this.children.delete(item);
|
|
409
|
+
resolve(code);
|
|
410
|
+
};
|
|
411
|
+
child.on("error", (error) => {
|
|
412
|
+
console.error(`${name}: ${error.message}`);
|
|
413
|
+
done(127);
|
|
414
|
+
});
|
|
415
|
+
child.on(
|
|
416
|
+
"close",
|
|
417
|
+
(code, signal) => done(code ?? (signal === "SIGINT" ? 130 : signal ? 143 : 0))
|
|
418
|
+
);
|
|
419
|
+
});
|
|
420
|
+
this.children.add(item);
|
|
421
|
+
return item;
|
|
422
|
+
}
|
|
423
|
+
async run(name, command, args) {
|
|
424
|
+
const code = await this.start(name, command, args).done;
|
|
425
|
+
if (code !== 0) throw new Error(`${name}: the process exited with code ${code}.`);
|
|
426
|
+
}
|
|
427
|
+
signal(item, signal) {
|
|
428
|
+
if (item.settled || !item.child.pid) return;
|
|
429
|
+
try {
|
|
430
|
+
if (process.platform === "win32") item.child.kill(signal);
|
|
431
|
+
else process.kill(-item.child.pid, signal);
|
|
432
|
+
} catch {
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
async stop(item, grace = 8e4) {
|
|
436
|
+
if (item.settled) return;
|
|
437
|
+
this.signal(item, "SIGTERM");
|
|
438
|
+
let timer;
|
|
439
|
+
await Promise.race([
|
|
440
|
+
item.done,
|
|
441
|
+
new Promise((resolve) => {
|
|
442
|
+
timer = setTimeout(resolve, grace);
|
|
443
|
+
})
|
|
444
|
+
]);
|
|
445
|
+
if (timer) clearTimeout(timer);
|
|
446
|
+
if (!item.settled) {
|
|
447
|
+
this.signal(item, "SIGKILL");
|
|
448
|
+
await item.done;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
async stopAll(grace = 8e4) {
|
|
452
|
+
await Promise.all([...this.children].map((item) => this.stop(item, grace)));
|
|
453
|
+
}
|
|
454
|
+
};
|
|
455
|
+
async function capture(root2, env, command, args, timeout = 5e3) {
|
|
456
|
+
return await new Promise((resolve) => {
|
|
457
|
+
const child = spawn(command, args, {
|
|
458
|
+
cwd: root2,
|
|
459
|
+
env,
|
|
460
|
+
detached: process.platform !== "win32",
|
|
461
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
462
|
+
});
|
|
463
|
+
let text = "";
|
|
464
|
+
child.stdout.on("data", (chunk) => {
|
|
465
|
+
text += chunk.toString();
|
|
466
|
+
});
|
|
467
|
+
child.stderr.resume();
|
|
468
|
+
const timer = setTimeout(() => {
|
|
469
|
+
try {
|
|
470
|
+
if (process.platform === "win32") child.kill("SIGKILL");
|
|
471
|
+
else if (child.pid) process.kill(-child.pid, "SIGKILL");
|
|
472
|
+
} catch {
|
|
473
|
+
}
|
|
474
|
+
}, timeout);
|
|
475
|
+
const finish = (code) => {
|
|
476
|
+
clearTimeout(timer);
|
|
477
|
+
resolve({ code, text });
|
|
478
|
+
};
|
|
479
|
+
child.on("error", () => finish(127));
|
|
480
|
+
child.on("close", (code) => finish(code ?? 1));
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// cli/src/launch-prepare.ts
|
|
485
|
+
import fs4 from "node:fs";
|
|
486
|
+
import path3 from "node:path";
|
|
487
|
+
async function buildKernelImage(processes, buildRoot, viaMake, name, parentImage) {
|
|
488
|
+
if (viaMake) {
|
|
489
|
+
const target = ["--no-print-directory", "env-build", `NAME=${name}`];
|
|
490
|
+
await processes.run("Python build", "make", target);
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
const kernel = path3.join(buildRoot, "kernel");
|
|
494
|
+
const args = [
|
|
495
|
+
"build",
|
|
496
|
+
"-f",
|
|
497
|
+
path3.join(kernel, "Dockerfile"),
|
|
498
|
+
"--build-arg",
|
|
499
|
+
`KERNEL_ENV=${name}`,
|
|
500
|
+
...parentImage ? ["--build-arg", `PARENT=${parentImage}`] : [],
|
|
501
|
+
"-t",
|
|
502
|
+
`colloq-kernel:${name}`,
|
|
503
|
+
kernel
|
|
504
|
+
];
|
|
505
|
+
const build = processes.start("Python build", "docker", args, false, {
|
|
506
|
+
DOCKER_BUILDKIT: "1",
|
|
507
|
+
BUILDKIT_PROGRESS: "plain"
|
|
508
|
+
});
|
|
509
|
+
const code = await build.done;
|
|
510
|
+
if (code !== 0) throw new Error(`Python build: the process exited with code ${code}.`);
|
|
511
|
+
}
|
|
512
|
+
function kernelRoot(root2, home2) {
|
|
513
|
+
const own = path3.join(home2, "environments");
|
|
514
|
+
if (home2 === root2 || !fs4.existsSync(own)) return root2;
|
|
515
|
+
const staged = path3.join(home2, ".colloq/kernel-build");
|
|
516
|
+
const to = path3.join(staged, "kernel");
|
|
517
|
+
fs4.rmSync(staged, { recursive: true, force: true });
|
|
518
|
+
const keep = { recursive: true, preserveTimestamps: true };
|
|
519
|
+
fs4.cpSync(path3.join(root2, "kernel"), to, keep);
|
|
520
|
+
const into = path3.join(to, "environments");
|
|
521
|
+
fs4.mkdirSync(into, { recursive: true });
|
|
522
|
+
for (const file of fs4.readdirSync(own))
|
|
523
|
+
if (file.endsWith(".txt")) fs4.cpSync(path3.join(own, file), path3.join(into, file), keep);
|
|
524
|
+
return staged;
|
|
525
|
+
}
|
|
526
|
+
async function prepare(config, options, processes, stopped) {
|
|
527
|
+
const { root: root2, home: home2, env, kernelEnv, dist: dist2 } = config;
|
|
528
|
+
const kernelFrom = kernelRoot(root2, home2);
|
|
529
|
+
const viaMake = !dist2 && kernelFrom === root2;
|
|
530
|
+
if ((await capture(root2, env, "docker", ["info", "--format", "{{.ServerVersion}}"])).code !== 0)
|
|
531
|
+
throw new Error("Docker is not responding. Start Docker and run colloq run again.");
|
|
532
|
+
if (stopped()) throw new Error("Launch cancelled.");
|
|
533
|
+
const inputs = kernelInputs(kernelFrom, kernelEnv);
|
|
534
|
+
const sourceHash = fingerprint(kernelFrom, inputs);
|
|
535
|
+
const chain = inputs.slice(2).reverse().map((file2) => {
|
|
536
|
+
const name = path3.basename(file2, ".txt");
|
|
537
|
+
const sources = kernelInputs(kernelFrom, name);
|
|
538
|
+
return { name, sources, fingerprint: fingerprint(kernelFrom, sources) };
|
|
539
|
+
});
|
|
540
|
+
const stamps = [];
|
|
541
|
+
const inspect = async (name) => {
|
|
542
|
+
const result = await capture(root2, env, "docker", ["image", "inspect", `colloq-kernel:${name}`]);
|
|
543
|
+
if (stopped()) throw new Error("Launch cancelled.");
|
|
544
|
+
if (result.code !== 0) return void 0;
|
|
545
|
+
try {
|
|
546
|
+
const image = JSON.parse(result.text)[0];
|
|
547
|
+
return typeof image?.Id === "string" && image.Id ? image : void 0;
|
|
548
|
+
} catch {
|
|
549
|
+
return void 0;
|
|
550
|
+
}
|
|
551
|
+
};
|
|
552
|
+
let parentId = "", parentImage = "", parentCreated = 0, ancestorRebuilt = false;
|
|
553
|
+
for (const item of chain) {
|
|
554
|
+
let image = await inspect(item.name);
|
|
555
|
+
const marker = path3.join(home2, `.colloq/kernel-${item.name}.json`);
|
|
556
|
+
const stamp = readJson(marker);
|
|
557
|
+
const freshImage = image && Date.parse(image.Created) >= parentCreated && item.sources.every(
|
|
558
|
+
(file2) => fs4.statSync(path3.join(kernelFrom, file2)).mtimeMs <= Date.parse(image.Created)
|
|
559
|
+
);
|
|
560
|
+
const current = stamp ? stamp.fingerprint === item.fingerprint && stamp.id === image?.Id && (stamp.parentId ?? "") === parentId : freshImage;
|
|
561
|
+
if (!image || ancestorRebuilt || !current) {
|
|
562
|
+
console.log(`Preparing the Python environment: ${item.name}`);
|
|
563
|
+
await buildKernelImage(processes, kernelFrom, viaMake, item.name, parentImage);
|
|
564
|
+
image = await inspect(item.name);
|
|
565
|
+
if (!image) throw new Error("The build finished, but the kernel image is not there.");
|
|
566
|
+
ancestorRebuilt = true;
|
|
567
|
+
}
|
|
568
|
+
if (fingerprint(kernelFrom, inputs) !== sourceHash)
|
|
569
|
+
throw new Error("The sources changed during the build. Start again, or use make dev.");
|
|
570
|
+
stamps.push({ file: marker, value: { fingerprint: item.fingerprint, id: image.Id, parentId } });
|
|
571
|
+
parentId = image.Id;
|
|
572
|
+
parentImage = `colloq-kernel:${item.name}`;
|
|
573
|
+
parentCreated = Date.parse(image.Created);
|
|
574
|
+
}
|
|
575
|
+
for (const stamp of stamps) writeJson(stamp.file, stamp.value);
|
|
576
|
+
if (stopped()) throw new Error("Launch cancelled.");
|
|
577
|
+
if (options.action === "dev") return;
|
|
578
|
+
if (dist2) {
|
|
579
|
+
for (const required of ["web/dist/index.html", "server/dist/server.js"])
|
|
580
|
+
if (!fs4.existsSync(path3.join(root2, required)))
|
|
581
|
+
throw new Error(
|
|
582
|
+
`The installation is incomplete: no ${required} in ${root2}. Reinstall colloq (pip install --force-reinstall colloq).`
|
|
583
|
+
);
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
const file = path3.join(root2, ".colloq/build.json");
|
|
587
|
+
if (options.build || !buildIsCurrent(root2, readJson(file), options.fast)) {
|
|
588
|
+
const before = fingerprint(root2);
|
|
589
|
+
console.log("Building the application\u2026");
|
|
590
|
+
await processes.run("Application build", "npm", [
|
|
591
|
+
"run",
|
|
592
|
+
options.fast ? "build" : "build:optimized"
|
|
593
|
+
]);
|
|
594
|
+
if (!fs4.existsSync(path3.join(root2, "web/dist/index.html")) || !fs4.existsSync(path3.join(root2, "server/dist/server.js")))
|
|
595
|
+
throw new Error("The build produced no frontend and no server. Check the output above.");
|
|
596
|
+
if (fingerprint(root2) !== before)
|
|
597
|
+
throw new Error("The sources changed during the build. Start again, or use make dev.");
|
|
598
|
+
writeJson(file, { fingerprint: before, fast: options.fast });
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// cli/src/launch-readiness.ts
|
|
603
|
+
async function devFrontendReady(url) {
|
|
604
|
+
for (const resource of ["/", "/src/index.css"]) {
|
|
605
|
+
let response;
|
|
606
|
+
try {
|
|
607
|
+
response = await fetch(new URL(resource, url), { signal: AbortSignal.timeout(5e3) });
|
|
608
|
+
await response.arrayBuffer();
|
|
609
|
+
} catch {
|
|
610
|
+
return false;
|
|
611
|
+
}
|
|
612
|
+
if (response.status >= 500)
|
|
613
|
+
throw new Error(`Vite could not compile ${resource}. Check the frontend error above.`);
|
|
614
|
+
if (!response.ok) return false;
|
|
615
|
+
}
|
|
616
|
+
return true;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// cli/src/launch-cloudflared.ts
|
|
620
|
+
import fs5 from "node:fs";
|
|
621
|
+
import path4 from "node:path";
|
|
622
|
+
import { createHash as createHash2, randomUUID as randomUUID2 } from "node:crypto";
|
|
623
|
+
import { gunzipSync } from "node:zlib";
|
|
624
|
+
|
|
625
|
+
// cli/src/launch-cloudflared-pin.ts
|
|
626
|
+
var CLOUDFLARED_VERSION = "2026.9.1";
|
|
627
|
+
var CLOUDFLARED_ASSETS = [
|
|
628
|
+
{
|
|
629
|
+
platform: "darwin",
|
|
630
|
+
arch: "arm64",
|
|
631
|
+
name: "cloudflared-darwin-arm64.tgz",
|
|
632
|
+
archive: "tgz",
|
|
633
|
+
size: 19217478,
|
|
634
|
+
sha256: "c27ab8fd0aa489449e3d201eb02f957ef460a13b613662928b1b23394bf1bcfe",
|
|
635
|
+
binarySha256: "9a0b19f67dc7a3011bc6b972c7ce06a5fcea8784ac6bd599ffa382ea4aeb5a6e"
|
|
636
|
+
},
|
|
637
|
+
{
|
|
638
|
+
platform: "darwin",
|
|
639
|
+
arch: "x64",
|
|
640
|
+
name: "cloudflared-darwin-amd64.tgz",
|
|
641
|
+
archive: "tgz",
|
|
642
|
+
size: 21118723,
|
|
643
|
+
sha256: "ff0d3b51d5ff70eceef89d6b32145fee985018a2174596a5dbe405e2766e2ac4",
|
|
644
|
+
binarySha256: "1ea07ae775b03236bd6be18ca1848d6bdc4af2f4f3bce398823b5a36e5761b75"
|
|
645
|
+
},
|
|
646
|
+
{
|
|
647
|
+
platform: "linux",
|
|
648
|
+
arch: "x64",
|
|
649
|
+
name: "cloudflared-linux-amd64",
|
|
650
|
+
archive: "binary",
|
|
651
|
+
size: 39838488,
|
|
652
|
+
sha256: "03f1f25d1cc93b9ad6c60569d44060bc4f17ed97075760ed8cfca4b12dcd68cc",
|
|
653
|
+
binarySha256: "03f1f25d1cc93b9ad6c60569d44060bc4f17ed97075760ed8cfca4b12dcd68cc"
|
|
654
|
+
},
|
|
655
|
+
{
|
|
656
|
+
platform: "linux",
|
|
657
|
+
arch: "arm64",
|
|
658
|
+
name: "cloudflared-linux-arm64",
|
|
659
|
+
archive: "binary",
|
|
660
|
+
size: 37466252,
|
|
661
|
+
sha256: "3d97437c71848bd8df68041e12436b484a661d95073ea1937f01a845ce88faa3",
|
|
662
|
+
binarySha256: "3d97437c71848bd8df68041e12436b484a661d95073ea1937f01a845ce88faa3"
|
|
663
|
+
}
|
|
664
|
+
];
|
|
665
|
+
|
|
666
|
+
// cli/src/launch-cloudflared.ts
|
|
667
|
+
var CLOUDFLARED_RELEASES = "https://github.com/cloudflare/cloudflared/releases";
|
|
668
|
+
var INSTALL_HINT = "Install cloudflared yourself (brew install cloudflared, or a package from " + CLOUDFLARED_RELEASES + ") or name the file: COLLOQ_CLOUDFLARED=/path/to/cloudflared.";
|
|
669
|
+
var SYSTEMS = { darwin: "macOS", linux: "Linux" };
|
|
670
|
+
function cloudflaredUrl(asset, version = CLOUDFLARED_VERSION) {
|
|
671
|
+
return `${CLOUDFLARED_RELEASES}/download/${version}/${asset.name}`;
|
|
672
|
+
}
|
|
673
|
+
function pickCloudflaredAsset(platform, arch, assets = CLOUDFLARED_ASSETS) {
|
|
674
|
+
if (platform === "win32")
|
|
675
|
+
throw new Error(
|
|
676
|
+
"Sharing a class from Windows is not supported: colloq runs on macOS and Linux. Run it inside WSL 2 (Ubuntu on Windows), where it works as on Linux."
|
|
677
|
+
);
|
|
678
|
+
const asset = assets.find((item) => item.platform === platform && item.arch === arch);
|
|
679
|
+
if (!asset)
|
|
680
|
+
throw new Error(
|
|
681
|
+
`colloq has no pinned cloudflared build for ${SYSTEMS[platform] ?? platform} ${arch}. ` + INSTALL_HINT
|
|
682
|
+
);
|
|
683
|
+
return asset;
|
|
684
|
+
}
|
|
685
|
+
function sha256(data) {
|
|
686
|
+
return createHash2("sha256").update(data).digest("hex");
|
|
687
|
+
}
|
|
688
|
+
function verifySha256(data, expected, what) {
|
|
689
|
+
const actual = sha256(data);
|
|
690
|
+
if (actual !== expected)
|
|
691
|
+
throw new Error(
|
|
692
|
+
`${what}: the sha256 does not match the pinned one (expected ${expected}, got ${actual}). Nothing was saved or run.`
|
|
693
|
+
);
|
|
694
|
+
}
|
|
695
|
+
function untarFile(tar, wanted) {
|
|
696
|
+
const data = Buffer.from(tar.buffer, tar.byteOffset, tar.byteLength);
|
|
697
|
+
let at = 0;
|
|
698
|
+
let nextName = null;
|
|
699
|
+
while (at + 512 <= data.length) {
|
|
700
|
+
const header = data.subarray(at, at + 512);
|
|
701
|
+
if (header.every((byte) => byte === 0)) break;
|
|
702
|
+
const field = (start, length) => {
|
|
703
|
+
const raw = header.subarray(start, start + length);
|
|
704
|
+
const end = raw.indexOf(0);
|
|
705
|
+
return raw.subarray(0, end === -1 ? length : end).toString("utf8");
|
|
706
|
+
};
|
|
707
|
+
const size = parseInt(field(124, 12).trim() || "0", 8);
|
|
708
|
+
if (!Number.isSafeInteger(size) || size < 0) throw new Error("The archive is damaged.");
|
|
709
|
+
const body = at + 512;
|
|
710
|
+
if (body + size > data.length) throw new Error("The archive is cut short.");
|
|
711
|
+
const type = header[156];
|
|
712
|
+
const content = data.subarray(body, body + size);
|
|
713
|
+
const prefix = field(345, 155);
|
|
714
|
+
const name = nextName ?? (prefix ? `${prefix}/${field(0, 100)}` : field(0, 100));
|
|
715
|
+
nextName = null;
|
|
716
|
+
if (type === 76) nextName = content.toString("utf8").replace(/\0+$/, "");
|
|
717
|
+
else if (type === 120)
|
|
718
|
+
nextName = /(?:^|\n)\d+ path=([^\n]*)\n/.exec(content.toString("utf8"))?.[1] ?? null;
|
|
719
|
+
else if ((type === 48 || type === 0) && name.replace(/^\.\//, "") === wanted)
|
|
720
|
+
return Buffer.from(content);
|
|
721
|
+
at = body + Math.ceil(size / 512) * 512;
|
|
722
|
+
}
|
|
723
|
+
throw new Error(`There is no ${wanted} inside the archive.`);
|
|
724
|
+
}
|
|
725
|
+
function cloudflaredBinary(asset, downloaded) {
|
|
726
|
+
verifySha256(downloaded, asset.sha256, asset.name);
|
|
727
|
+
const binary = asset.archive === "tgz" ? untarFile(gunzipSync(downloaded), "cloudflared") : Buffer.from(downloaded);
|
|
728
|
+
verifySha256(binary, asset.binarySha256, `cloudflared from ${asset.name}`);
|
|
729
|
+
return binary;
|
|
730
|
+
}
|
|
731
|
+
function resolveCloudflared(lookup) {
|
|
732
|
+
const named = (lookup.env.COLLOQ_CLOUDFLARED ?? "").trim();
|
|
733
|
+
if (named) {
|
|
734
|
+
if (!path4.isAbsolute(named))
|
|
735
|
+
return {
|
|
736
|
+
kind: "refuse",
|
|
737
|
+
message: `COLLOQ_CLOUDFLARED must be an absolute path to the file, not "${named}".`
|
|
738
|
+
};
|
|
739
|
+
if (!lookup.executable(named))
|
|
740
|
+
return {
|
|
741
|
+
kind: "refuse",
|
|
742
|
+
message: `COLLOQ_CLOUDFLARED names ${named}, and that is not an executable file.`
|
|
743
|
+
};
|
|
744
|
+
return { kind: "use", path: named, source: "override" };
|
|
745
|
+
}
|
|
746
|
+
const ours = path4.join(lookup.binDir, "cloudflared");
|
|
747
|
+
for (const dir of (lookup.env.PATH ?? "").split(path4.delimiter)) {
|
|
748
|
+
if (!dir || !path4.isAbsolute(dir) || path4.resolve(dir) === path4.resolve(lookup.binDir)) continue;
|
|
749
|
+
const candidate = path4.join(dir, "cloudflared");
|
|
750
|
+
if (lookup.executable(candidate)) return { kind: "use", path: candidate, source: "path" };
|
|
751
|
+
}
|
|
752
|
+
let asset;
|
|
753
|
+
try {
|
|
754
|
+
asset = pickCloudflaredAsset(lookup.platform, lookup.arch, lookup.assets);
|
|
755
|
+
} catch (error) {
|
|
756
|
+
return { kind: "refuse", message: error.message };
|
|
757
|
+
}
|
|
758
|
+
const present = lookup.executable(ours);
|
|
759
|
+
if (present && lookup.hashOf(ours) === asset.binarySha256)
|
|
760
|
+
return { kind: "use", path: ours, source: "colloq" };
|
|
761
|
+
if (lookup.env.COLLOQ_CLOUDFLARED_DOWNLOAD?.trim() === "0")
|
|
762
|
+
return {
|
|
763
|
+
kind: "refuse",
|
|
764
|
+
message: (present ? `${ours} is not the pinned cloudflared ${CLOUDFLARED_VERSION}` : "cloudflared is not installed") + ", and downloading is switched off (COLLOQ_CLOUDFLARED_DOWNLOAD=0). " + INSTALL_HINT
|
|
765
|
+
};
|
|
766
|
+
return { kind: "download", asset, stale: present };
|
|
767
|
+
}
|
|
768
|
+
async function download(url, asset, fetchImpl, say, cancel) {
|
|
769
|
+
const controller = new AbortController();
|
|
770
|
+
const abort = () => controller.abort();
|
|
771
|
+
cancel?.addEventListener("abort", abort, { once: true });
|
|
772
|
+
let quiet;
|
|
773
|
+
const idle = () => {
|
|
774
|
+
if (quiet) clearTimeout(quiet);
|
|
775
|
+
quiet = setTimeout(abort, 6e4);
|
|
776
|
+
};
|
|
777
|
+
try {
|
|
778
|
+
idle();
|
|
779
|
+
const response = await fetchImpl(url, { redirect: "follow", signal: controller.signal });
|
|
780
|
+
if (!response.ok || !response.body)
|
|
781
|
+
throw new Error(`GitHub answered ${response.status} for ${url}.`);
|
|
782
|
+
const chunks = [];
|
|
783
|
+
let received = 0;
|
|
784
|
+
let told = 0;
|
|
785
|
+
const reader = response.body.getReader();
|
|
786
|
+
for (; ; ) {
|
|
787
|
+
const { done, value } = await reader.read();
|
|
788
|
+
if (done) break;
|
|
789
|
+
idle();
|
|
790
|
+
received += value.byteLength;
|
|
791
|
+
if (received > asset.size)
|
|
792
|
+
throw new Error(`${asset.name} is larger than the pinned ${asset.size} bytes.`);
|
|
793
|
+
chunks.push(Buffer.from(value));
|
|
794
|
+
const quarter = Math.floor(received / asset.size * 4);
|
|
795
|
+
if (quarter > told && quarter < 4) {
|
|
796
|
+
told = quarter;
|
|
797
|
+
say(` \u2026 ${quarter * 25}%`);
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
return Buffer.concat(chunks, received);
|
|
801
|
+
} catch (error) {
|
|
802
|
+
if (cancel?.aborted) throw new Error("The download was cancelled.");
|
|
803
|
+
if (controller.signal.aborted) throw new Error("The download stalled: no data for a minute.");
|
|
804
|
+
const cause = error.cause;
|
|
805
|
+
const reason = cause instanceof Error ? cause.message : error.message;
|
|
806
|
+
throw new Error(`Could not download ${asset.name}: ${reason}`);
|
|
807
|
+
} finally {
|
|
808
|
+
if (quiet) clearTimeout(quiet);
|
|
809
|
+
cancel?.removeEventListener("abort", abort);
|
|
810
|
+
controller.abort();
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
function install(binDir, binary, expected) {
|
|
814
|
+
fs5.mkdirSync(binDir, { recursive: true, mode: 448 });
|
|
815
|
+
const file = path4.join(binDir, "cloudflared");
|
|
816
|
+
const temporary = path4.join(binDir, `.cloudflared-${process.pid}-${randomUUID2()}`);
|
|
817
|
+
try {
|
|
818
|
+
fs5.writeFileSync(temporary, binary, { mode: 384, flag: "wx" });
|
|
819
|
+
verifySha256(fs5.readFileSync(temporary), expected, temporary);
|
|
820
|
+
fs5.chmodSync(temporary, 493);
|
|
821
|
+
fs5.renameSync(temporary, file);
|
|
822
|
+
} finally {
|
|
823
|
+
try {
|
|
824
|
+
fs5.unlinkSync(temporary);
|
|
825
|
+
} catch {
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
return file;
|
|
829
|
+
}
|
|
830
|
+
function executable(file) {
|
|
831
|
+
try {
|
|
832
|
+
if (!fs5.statSync(file).isFile()) return false;
|
|
833
|
+
fs5.accessSync(file, fs5.constants.X_OK);
|
|
834
|
+
return true;
|
|
835
|
+
} catch {
|
|
836
|
+
return false;
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
function hashOf(file) {
|
|
840
|
+
try {
|
|
841
|
+
return sha256(fs5.readFileSync(file));
|
|
842
|
+
} catch {
|
|
843
|
+
return null;
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
async function ensureCloudflared(options) {
|
|
847
|
+
const platform = options.platform ?? process.platform;
|
|
848
|
+
const arch = options.arch ?? process.arch;
|
|
849
|
+
const binDir = path4.join(options.home, "bin");
|
|
850
|
+
const decision = resolveCloudflared({
|
|
851
|
+
env: options.env,
|
|
852
|
+
platform,
|
|
853
|
+
arch,
|
|
854
|
+
binDir,
|
|
855
|
+
executable,
|
|
856
|
+
hashOf,
|
|
857
|
+
assets: options.assets
|
|
858
|
+
});
|
|
859
|
+
if (decision.kind === "use") return decision.path;
|
|
860
|
+
if (decision.kind === "refuse") throw new Error(decision.message);
|
|
861
|
+
const { asset } = decision;
|
|
862
|
+
const url = cloudflaredUrl(asset);
|
|
863
|
+
options.say(
|
|
864
|
+
decision.stale ? `The cloudflared in ${binDir} is not the pinned ${CLOUDFLARED_VERSION} build: fetching it again.` : "cloudflared is not installed: colloq fetches it once, for this and every later link."
|
|
865
|
+
);
|
|
866
|
+
options.say(
|
|
867
|
+
` cloudflared ${CLOUDFLARED_VERSION} for ${SYSTEMS[asset.platform]} ${asset.arch}, ${(asset.size / 1e6).toFixed(0)} MB, from ${url}`
|
|
868
|
+
);
|
|
869
|
+
const downloaded = await download(
|
|
870
|
+
url,
|
|
871
|
+
asset,
|
|
872
|
+
options.fetch ?? fetch,
|
|
873
|
+
options.say,
|
|
874
|
+
options.signal
|
|
875
|
+
);
|
|
876
|
+
const binary = cloudflaredBinary(asset, downloaded);
|
|
877
|
+
const file = install(binDir, binary, asset.binarySha256);
|
|
878
|
+
options.say(` sha256 matches the pinned one; saved as ${file}`);
|
|
879
|
+
return file;
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
// cli/src/launch-share.ts
|
|
883
|
+
import fs6 from "node:fs";
|
|
884
|
+
import path5 from "node:path";
|
|
885
|
+
function publishRefusal(check) {
|
|
886
|
+
const backend = (check.env.KERNEL_BACKEND ?? "").trim() || "docker";
|
|
887
|
+
if ((check.env.KERNEL_ISOLATION ?? "").trim().toLowerCase() === "off")
|
|
888
|
+
return "KERNEL_ISOLATION=off is set, so the rooms would not be kept apart";
|
|
889
|
+
if (backend !== "docker" && backend !== "broker")
|
|
890
|
+
return `KERNEL_BACKEND=${backend} does not give every room a container of its own`;
|
|
891
|
+
if (backend === "docker" && check.dockerReachable === false)
|
|
892
|
+
return "Docker is not responding, and without it nothing keeps the rooms apart";
|
|
893
|
+
if (check.health !== void 0) {
|
|
894
|
+
const said = check.health?.isolation;
|
|
895
|
+
if (said !== backend)
|
|
896
|
+
return typeof said === "string" ? `the server reports room isolation "${said}", not ${backend}` : "the server does not confirm that every room runs in a container of its own";
|
|
897
|
+
}
|
|
898
|
+
return null;
|
|
899
|
+
}
|
|
900
|
+
function refusalText(reason, local) {
|
|
901
|
+
return [
|
|
902
|
+
`Not published: ${reason}.`,
|
|
903
|
+
"A public link lets anyone who has it run code on this computer, so colloq",
|
|
904
|
+
"publishes a class only when every room runs in a Docker container of its own.",
|
|
905
|
+
"Check Docker and KERNEL_BACKEND / KERNEL_ISOLATION in .env, then try again.",
|
|
906
|
+
...local ? [`The class keeps running locally: ${local}`] : []
|
|
907
|
+
].join("\n");
|
|
908
|
+
}
|
|
909
|
+
function parseShareMarker(line) {
|
|
910
|
+
const match = /^@colloq-share (ok|unverified) (https:\/\/[A-Za-z0-9.-]+)\/?\s*$/.exec(line);
|
|
911
|
+
return match ? { url: match[2], verified: match[1] === "ok" } : null;
|
|
912
|
+
}
|
|
913
|
+
function printable(name) {
|
|
914
|
+
const clean = name.replace(/[\u0000-\u001f\u007f-\u009f]+/g, " ").trim();
|
|
915
|
+
return clean.length > 48 ? clean.slice(0, 47) + "\u2026" : clean;
|
|
916
|
+
}
|
|
917
|
+
function renderShareBlock(block) {
|
|
918
|
+
const out = ["", ` \u250C Colloq is online at ${block.url}`, " \u2502"];
|
|
919
|
+
const link = (item) => ` \u2502 ${block.url}/s/${encodeURIComponent(item.id)} ${printable(item.name)}`;
|
|
920
|
+
if (block.classes.length === 1 && block.total === 1) {
|
|
921
|
+
out.push(" \u2502 Give your students this link:", link(block.classes[0]));
|
|
922
|
+
} else if (block.classes.length > 0) {
|
|
923
|
+
out.push(" \u2502 Give your students the link of the class you teach (newest first):");
|
|
924
|
+
for (const item of block.classes) out.push(link(item));
|
|
925
|
+
const more = block.total - block.classes.length;
|
|
926
|
+
if (more > 0) out.push(` \u2502 \u2026 and ${more} more: the panel lists every class with its link`);
|
|
927
|
+
} else {
|
|
928
|
+
out.push(
|
|
929
|
+
" \u2502 There is no class yet. Create one in the panel and copy its link from",
|
|
930
|
+
" \u2502 the list \u2014 the list already gives links on this public address:",
|
|
931
|
+
` \u2502 ${block.url}/admin`,
|
|
932
|
+
` \u2502 (on this computer the panel is also at ${block.local}/admin)`
|
|
933
|
+
);
|
|
934
|
+
}
|
|
935
|
+
out.push(
|
|
936
|
+
" \u2502",
|
|
937
|
+
" \u2502 Never share a link with /admin/ in it (/admin/t/\u2026, /admin/k/\u2026): it is a",
|
|
938
|
+
" \u2502 key to the panel. Students only ever get /s/\u2026 links."
|
|
939
|
+
);
|
|
940
|
+
if (block.verified === false)
|
|
941
|
+
out.push(
|
|
942
|
+
" \u2502",
|
|
943
|
+
" \u2502 The check from this computer did not get through \u2014 usually the DNS of",
|
|
944
|
+
" \u2502 this network, not the class. Open the link on a phone on mobile data."
|
|
945
|
+
);
|
|
946
|
+
out.push(
|
|
947
|
+
" \u2502",
|
|
948
|
+
block.detached ? " \u2502 The link lives until colloq stop, which closes it with the class." : " \u2502 The link lives while this terminal is open: Ctrl+C closes it and the class.",
|
|
949
|
+
" \u2502 A quick tunnel gets a new address on every start: send the new link each time.",
|
|
950
|
+
" \u2502 Cloudflare addresses do not open from Russia. For students there, use a relay:",
|
|
951
|
+
block.relayDomain ? ` \u2502 colloq start --host <name>.${block.relayDomain}` : " \u2502 colloq start --host <name> with RELAY_* in .env (see the docs: networking)",
|
|
952
|
+
" \u2514",
|
|
953
|
+
""
|
|
954
|
+
);
|
|
955
|
+
return out;
|
|
956
|
+
}
|
|
957
|
+
async function readClasses(dataDir, limit = 3) {
|
|
958
|
+
const file = path5.join(dataDir, "colloq.db");
|
|
959
|
+
if (!fs6.existsSync(file)) return { classes: [], total: 0 };
|
|
960
|
+
try {
|
|
961
|
+
const { default: Database } = await import("better-sqlite3");
|
|
962
|
+
const db = new Database(file, { readonly: true, fileMustExist: true });
|
|
963
|
+
try {
|
|
964
|
+
const rows = db.prepare(
|
|
965
|
+
"SELECT id, name FROM sessions WHERE archived_at IS NULL ORDER BY created_at DESC LIMIT ?"
|
|
966
|
+
).all(limit);
|
|
967
|
+
const { total } = db.prepare("SELECT COUNT(*) AS total FROM sessions WHERE archived_at IS NULL").get();
|
|
968
|
+
return {
|
|
969
|
+
classes: rows.filter(
|
|
970
|
+
(row) => typeof row.id === "string" && /^[A-Za-z0-9_-]+$/.test(row.id)
|
|
971
|
+
),
|
|
972
|
+
total
|
|
973
|
+
};
|
|
974
|
+
} finally {
|
|
975
|
+
db.close();
|
|
976
|
+
}
|
|
977
|
+
} catch {
|
|
978
|
+
return { classes: [], total: 0 };
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
// shared/local-public-url-lease.ts
|
|
983
|
+
function publicLeaseAddress(value) {
|
|
984
|
+
if (typeof value !== "string") return null;
|
|
985
|
+
try {
|
|
986
|
+
const url = new URL(value);
|
|
987
|
+
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.search || url.hash) return null;
|
|
988
|
+
return url.href.replace(/\/+$/, "");
|
|
989
|
+
} catch {
|
|
990
|
+
return null;
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
function parsePublicUrlLease(raw) {
|
|
994
|
+
try {
|
|
995
|
+
const lease = JSON.parse(raw ?? "");
|
|
996
|
+
if (!lease || typeof lease.runId !== "string" || !lease.runId || typeof lease.owner !== "string" || !lease.owner || !Number.isFinite(lease.expiresAt)) return null;
|
|
997
|
+
const url = publicLeaseAddress(lease.url);
|
|
998
|
+
return url ? { runId: lease.runId, owner: lease.owner, expiresAt: lease.expiresAt, url } : null;
|
|
999
|
+
} catch {
|
|
1000
|
+
return null;
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
function leaseUrl(raw, runId, now) {
|
|
1004
|
+
const lease = parsePublicUrlLease(raw);
|
|
1005
|
+
return lease && lease.runId === runId && lease.expiresAt > now ? lease.url : void 0;
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
// cli/src/launch.ts
|
|
1009
|
+
import { createRequire } from "node:module";
|
|
1010
|
+
function layOutWebAssets(root2) {
|
|
1011
|
+
const from = createRequire(path6.join(root2, "web/package.json"));
|
|
1012
|
+
const copies = [
|
|
1013
|
+
["pdfjs-dist/build/pdf.worker.min.mjs", "web/public/pdf/pdf.worker.min.mjs"],
|
|
1014
|
+
["plotly.js-strict-dist-min/plotly-strict.min.js", "web/public/plotly/plotly.min.js"]
|
|
1015
|
+
];
|
|
1016
|
+
for (const [module, target] of copies) {
|
|
1017
|
+
try {
|
|
1018
|
+
const source = from.resolve(module);
|
|
1019
|
+
const to = path6.join(root2, target);
|
|
1020
|
+
if (fs7.existsSync(to) && fs7.statSync(to).size === fs7.statSync(source).size) continue;
|
|
1021
|
+
fs7.mkdirSync(path6.dirname(to), { recursive: true });
|
|
1022
|
+
fs7.copyFileSync(source, to);
|
|
1023
|
+
} catch (error) {
|
|
1024
|
+
console.warn(`[colloq] ${target}: ${error instanceof Error ? error.message : String(error)} \u2014 npm ci?`);
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
var root = appDir();
|
|
1029
|
+
var home = homeDir();
|
|
1030
|
+
var dist = isDistribution(root);
|
|
1031
|
+
var receiptFile = path6.join(home, SESSION_FILE);
|
|
1032
|
+
var resultFile = path6.join(home, ".colloq/local-session-result.json");
|
|
1033
|
+
var pidFile = path6.join(home, ".colloq.pid");
|
|
1034
|
+
var logFile = path6.join(home, ".colloq.log");
|
|
1035
|
+
var entry = fileURLToPath2(import.meta.url);
|
|
1036
|
+
var GRACE = 8e4;
|
|
1037
|
+
async function health(port) {
|
|
1038
|
+
try {
|
|
1039
|
+
const response = await fetch(`http://127.0.0.1:${port}/api/health`, {
|
|
1040
|
+
signal: AbortSignal.timeout(1500)
|
|
1041
|
+
});
|
|
1042
|
+
if (!response.ok) return null;
|
|
1043
|
+
return await response.json();
|
|
1044
|
+
} catch {
|
|
1045
|
+
return null;
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
async function portOccupied(port) {
|
|
1049
|
+
return await new Promise((resolve) => {
|
|
1050
|
+
const socket = net.connect(port, "127.0.0.1");
|
|
1051
|
+
const done = (busy) => {
|
|
1052
|
+
socket.destroy();
|
|
1053
|
+
resolve(busy);
|
|
1054
|
+
};
|
|
1055
|
+
socket.once("connect", () => done(true));
|
|
1056
|
+
socket.once("error", () => done(false));
|
|
1057
|
+
socket.setTimeout(1e3, () => done(true));
|
|
1058
|
+
});
|
|
1059
|
+
}
|
|
1060
|
+
async function supervisorOwned(receipt) {
|
|
1061
|
+
if (receipt.root !== home || !alive(receipt.pid)) return false;
|
|
1062
|
+
const command = await capture(root, process.env, "ps", [
|
|
1063
|
+
"-ww",
|
|
1064
|
+
"-p",
|
|
1065
|
+
String(receipt.pid),
|
|
1066
|
+
"-o",
|
|
1067
|
+
"command="
|
|
1068
|
+
]);
|
|
1069
|
+
return command.code === 0 && command.text.trim() === `clq:${receipt.runId.slice(0, 24)}`;
|
|
1070
|
+
}
|
|
1071
|
+
function openBrowser(url) {
|
|
1072
|
+
if (!process.stdout.isTTY) return;
|
|
1073
|
+
const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
|
|
1074
|
+
const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
1075
|
+
const child = spawn2(command, args, { stdio: "ignore", detached: process.platform !== "win32" });
|
|
1076
|
+
child.on("error", () => console.log(`Open in your browser: ${url}`));
|
|
1077
|
+
child.unref();
|
|
1078
|
+
}
|
|
1079
|
+
function banner(receipt) {
|
|
1080
|
+
console.log(
|
|
1081
|
+
`
|
|
1082
|
+
Colloq is running
|
|
1083
|
+
|
|
1084
|
+
Local: ${receipt.url}
|
|
1085
|
+
Panel: ${receipt.url}/admin
|
|
1086
|
+
Data: ${receipt.dataDir}
|
|
1087
|
+
Files: ${receipt.workspaceDir}
|
|
1088
|
+
|
|
1089
|
+
Ctrl+C \u2014 save and stop the server and the local kernels
|
|
1090
|
+
`
|
|
1091
|
+
);
|
|
1092
|
+
}
|
|
1093
|
+
async function announceShare(url, receipt, relayDomain, verified, detached2) {
|
|
1094
|
+
const { classes, total } = await readClasses(receipt.dataDir);
|
|
1095
|
+
console.log(
|
|
1096
|
+
renderShareBlock({
|
|
1097
|
+
url,
|
|
1098
|
+
local: receipt.url,
|
|
1099
|
+
classes,
|
|
1100
|
+
total,
|
|
1101
|
+
verified,
|
|
1102
|
+
detached: detached2,
|
|
1103
|
+
relayDomain
|
|
1104
|
+
}).join("\n")
|
|
1105
|
+
);
|
|
1106
|
+
}
|
|
1107
|
+
async function shareCloudflared(env, signal) {
|
|
1108
|
+
try {
|
|
1109
|
+
return await ensureCloudflared({ home, env, say: (line) => console.log(line), signal });
|
|
1110
|
+
} catch (error) {
|
|
1111
|
+
throw new Error(
|
|
1112
|
+
`No cloudflared for --share: ${error.message}
|
|
1113
|
+
Start without --share (colloq start) and publish later with colloq host.`
|
|
1114
|
+
);
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
function effectiveEnv() {
|
|
1118
|
+
const envFile = path6.join(home, ".env");
|
|
1119
|
+
return {
|
|
1120
|
+
...fs7.existsSync(envFile) ? parseEnv(fs7.readFileSync(envFile)) : {},
|
|
1121
|
+
...process.env
|
|
1122
|
+
};
|
|
1123
|
+
}
|
|
1124
|
+
async function stopSession() {
|
|
1125
|
+
const receipt = readReceipt(receiptFile);
|
|
1126
|
+
if (!receipt) {
|
|
1127
|
+
console.log("No local session found.");
|
|
1128
|
+
return null;
|
|
1129
|
+
}
|
|
1130
|
+
if (!alive(receipt.pid)) {
|
|
1131
|
+
console.log("The local session has already ended.");
|
|
1132
|
+
return receipt;
|
|
1133
|
+
}
|
|
1134
|
+
if (!await supervisorOwned(receipt))
|
|
1135
|
+
throw new Error(
|
|
1136
|
+
"The PID in the session receipt does not belong to this run. Nothing was stopped."
|
|
1137
|
+
);
|
|
1138
|
+
console.log("Saving and stopping the local session\u2026");
|
|
1139
|
+
process.kill(receipt.pid, "SIGTERM");
|
|
1140
|
+
const deadline = Date.now() + GRACE * 2 + 1e4;
|
|
1141
|
+
while (Date.now() < deadline) {
|
|
1142
|
+
if (readReceipt(receiptFile)?.runId !== receipt.runId && !alive(receipt.pid)) {
|
|
1143
|
+
const result = readJson(resultFile);
|
|
1144
|
+
if (result?.runId === receipt.runId && result.error)
|
|
1145
|
+
throw new Error(result.error + " Check colloq logs.");
|
|
1146
|
+
return receipt;
|
|
1147
|
+
}
|
|
1148
|
+
await delay(100);
|
|
1149
|
+
}
|
|
1150
|
+
throw new Error("The stop did not finish. Check colloq logs; nothing was killed by force.");
|
|
1151
|
+
}
|
|
1152
|
+
function kernelCleanupArgs() {
|
|
1153
|
+
const bundle = path6.join(root, "server/dist/local-cleanup.js");
|
|
1154
|
+
const source = path6.join(root, "server/src/ops/local-cleanup.ts");
|
|
1155
|
+
for (const candidate of dist ? [bundle, source] : [source, bundle]) {
|
|
1156
|
+
if (!fs7.existsSync(candidate)) continue;
|
|
1157
|
+
return candidate.endsWith(".ts") ? ["--import", "tsx", candidate] : [candidate];
|
|
1158
|
+
}
|
|
1159
|
+
return null;
|
|
1160
|
+
}
|
|
1161
|
+
function childArgs(options) {
|
|
1162
|
+
const args = [options.action, "--child"];
|
|
1163
|
+
if (options.detach) args.push("--detach");
|
|
1164
|
+
if (!options.open) args.push("--no-open");
|
|
1165
|
+
if (options.fast) args.push("--fast");
|
|
1166
|
+
if (options.build) args.push("--build");
|
|
1167
|
+
if (options.host) args.push("--host", options.host);
|
|
1168
|
+
if (options.share) args.push("--share");
|
|
1169
|
+
if (options.port) args.push("--port", String(options.port));
|
|
1170
|
+
return args;
|
|
1171
|
+
}
|
|
1172
|
+
async function detached(options) {
|
|
1173
|
+
const existing = readReceipt(receiptFile);
|
|
1174
|
+
if (existing && await supervisorOwned(existing)) {
|
|
1175
|
+
console.log(`Colloq is already running: ${existing.url}`);
|
|
1176
|
+
return 0;
|
|
1177
|
+
}
|
|
1178
|
+
if (options.share || options.host) {
|
|
1179
|
+
const env = effectiveEnv();
|
|
1180
|
+
const refusal = publishRefusal({ env });
|
|
1181
|
+
if (refusal) throw new Error(refusalText(refusal));
|
|
1182
|
+
if (options.share) await shareCloudflared(env);
|
|
1183
|
+
}
|
|
1184
|
+
const runId = randomUUID3();
|
|
1185
|
+
const log = fs7.openSync(logFile, "a", 384);
|
|
1186
|
+
fs7.chmodSync(logFile, 384);
|
|
1187
|
+
const loader = entry.endsWith(".ts") ? ["--import", "tsx"] : [];
|
|
1188
|
+
const child = spawn2(process.execPath, [...loader, entry, ...childArgs(options)], {
|
|
1189
|
+
cwd: root,
|
|
1190
|
+
env: { ...process.env, COLLOQ_LAUNCH_ID: runId, COLLOQ_APP_DIR: root, COLLOQ_HOME: home },
|
|
1191
|
+
detached: true,
|
|
1192
|
+
stdio: ["ignore", log, log]
|
|
1193
|
+
});
|
|
1194
|
+
fs7.closeSync(log);
|
|
1195
|
+
let ended = false, failure = "", cancelled = 0;
|
|
1196
|
+
child.once("error", (error) => {
|
|
1197
|
+
failure = error.message;
|
|
1198
|
+
ended = true;
|
|
1199
|
+
});
|
|
1200
|
+
child.once("exit", () => {
|
|
1201
|
+
ended = true;
|
|
1202
|
+
});
|
|
1203
|
+
const stop = (code) => {
|
|
1204
|
+
cancelled = code;
|
|
1205
|
+
try {
|
|
1206
|
+
child.kill("SIGTERM");
|
|
1207
|
+
} catch {
|
|
1208
|
+
}
|
|
1209
|
+
};
|
|
1210
|
+
const onInt = () => stop(130), onTerm = () => stop(143);
|
|
1211
|
+
process.once("SIGINT", onInt);
|
|
1212
|
+
process.once("SIGTERM", onTerm);
|
|
1213
|
+
try {
|
|
1214
|
+
console.log(`Preparing the background start. Log: ${logFile}`);
|
|
1215
|
+
while (!ended) {
|
|
1216
|
+
const receipt = readReceipt(receiptFile);
|
|
1217
|
+
let publicUrl;
|
|
1218
|
+
if (receipt?.runId === runId) {
|
|
1219
|
+
try {
|
|
1220
|
+
publicUrl = leaseUrl(fs7.readFileSync(receipt.leaseFile, "utf8"), runId, Date.now());
|
|
1221
|
+
} catch {
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
const publishing = Boolean(options.host || options.share);
|
|
1225
|
+
if (!cancelled && receipt?.runId === runId && receipt.phase === "ready" && (!publishing || publicUrl || receipt.hosting === "failed")) {
|
|
1226
|
+
console.log(
|
|
1227
|
+
`Colloq is running in the background: ${receipt.url}
|
|
1228
|
+
Logs: colloq logs
|
|
1229
|
+
Stop: colloq stop`
|
|
1230
|
+
);
|
|
1231
|
+
if (publicUrl && options.share)
|
|
1232
|
+
await announceShare(
|
|
1233
|
+
publicUrl,
|
|
1234
|
+
receipt,
|
|
1235
|
+
effectiveEnv().RELAY_DOMAIN?.trim() ?? "",
|
|
1236
|
+
null,
|
|
1237
|
+
true
|
|
1238
|
+
);
|
|
1239
|
+
else if (publicUrl) console.log(`Public address: ${publicUrl}`);
|
|
1240
|
+
else if (publishing)
|
|
1241
|
+
console.log(`The tunnel did not come up; local work continues. Details: ${logFile}`);
|
|
1242
|
+
if (options.open) openBrowser(`${receipt.url}/admin`);
|
|
1243
|
+
child.unref();
|
|
1244
|
+
return 0;
|
|
1245
|
+
}
|
|
1246
|
+
await delay(150);
|
|
1247
|
+
}
|
|
1248
|
+
if (cancelled) return cancelled;
|
|
1249
|
+
throw new Error(failure || `The background start failed. Log: ${logFile}`);
|
|
1250
|
+
} finally {
|
|
1251
|
+
process.off("SIGINT", onInt);
|
|
1252
|
+
process.off("SIGTERM", onTerm);
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
async function runSession(options) {
|
|
1256
|
+
const prior = readReceipt(receiptFile);
|
|
1257
|
+
if (prior && await supervisorOwned(prior)) {
|
|
1258
|
+
console.log(
|
|
1259
|
+
`Colloq is already ${prior.phase === "ready" ? "running" : "starting"}: ${prior.url}`
|
|
1260
|
+
);
|
|
1261
|
+
if (options.host) console.log(`For public access: colloq host ${options.host}`);
|
|
1262
|
+
if (options.share) console.log("For a public link: colloq host");
|
|
1263
|
+
if (options.open && prior.phase === "ready") openBrowser(`${prior.url}/admin`);
|
|
1264
|
+
return 0;
|
|
1265
|
+
}
|
|
1266
|
+
if (options.detach && !options.child) return await detached(options);
|
|
1267
|
+
const runId = options.child ? process.env.COLLOQ_LAUNCH_ID || randomUUID3() : randomUUID3();
|
|
1268
|
+
process.title = `clq:${runId.slice(0, 24)}`;
|
|
1269
|
+
const releases = [];
|
|
1270
|
+
let processes, receipt, log;
|
|
1271
|
+
let stopping = false, exitCode = 0, started = false;
|
|
1272
|
+
let stopPromise;
|
|
1273
|
+
let cleanupFailure = null;
|
|
1274
|
+
let tunnel;
|
|
1275
|
+
const cancel = new AbortController();
|
|
1276
|
+
const stop = (code) => {
|
|
1277
|
+
if (stopping) return;
|
|
1278
|
+
stopping = true;
|
|
1279
|
+
exitCode = code;
|
|
1280
|
+
cancel.abort();
|
|
1281
|
+
console.log("\nStopping the local session\u2026");
|
|
1282
|
+
stopPromise = (async () => {
|
|
1283
|
+
if (tunnel) await processes?.stop(tunnel, 5e3);
|
|
1284
|
+
await processes?.stopAll(GRACE);
|
|
1285
|
+
})();
|
|
1286
|
+
};
|
|
1287
|
+
const onInt = () => stop(130), onTerm = () => stop(143), onHangup = () => stop(129);
|
|
1288
|
+
const onOutputError = () => stop(129);
|
|
1289
|
+
process.on("SIGINT", onInt);
|
|
1290
|
+
process.on("SIGTERM", onTerm);
|
|
1291
|
+
process.on("SIGHUP", onHangup);
|
|
1292
|
+
process.stdout.on("error", onOutputError);
|
|
1293
|
+
process.stderr.on("error", onOutputError);
|
|
1294
|
+
try {
|
|
1295
|
+
releases.push(acquireLock(path6.join(home, ".colloq/local-session.lock"), process.pid, runId));
|
|
1296
|
+
const legacy = Number(fs7.existsSync(pidFile) ? fs7.readFileSync(pidFile, "utf8").trim() : 0);
|
|
1297
|
+
if (legacy && alive(legacy))
|
|
1298
|
+
throw new Error(
|
|
1299
|
+
"A server from .colloq.pid is already running. Use it, or stop it explicitly with colloq stop."
|
|
1300
|
+
);
|
|
1301
|
+
const envFile = path6.join(home, ".env");
|
|
1302
|
+
if (!fs7.existsSync(envFile)) {
|
|
1303
|
+
fs7.writeFileSync(envFile, localClassEnv(dist), { flag: "wx", mode: 384 });
|
|
1304
|
+
console.log(`Created ${envFile}: the settings for this machine.`);
|
|
1305
|
+
}
|
|
1306
|
+
const config = launchConfig(
|
|
1307
|
+
root,
|
|
1308
|
+
options,
|
|
1309
|
+
{
|
|
1310
|
+
...parseEnv(fs7.readFileSync(envFile)),
|
|
1311
|
+
...process.env
|
|
1312
|
+
},
|
|
1313
|
+
home
|
|
1314
|
+
);
|
|
1315
|
+
config.env.COLLOQ_LOCAL_RUN_ID = runId;
|
|
1316
|
+
if (await portOccupied(config.port) || config.uiPort !== config.port && await portOccupied(config.uiPort))
|
|
1317
|
+
throw new Error(
|
|
1318
|
+
`Port ${config.port} or ${config.uiPort} is already taken. The running process was left alone.`
|
|
1319
|
+
);
|
|
1320
|
+
const publishing = Boolean(options.host || options.share);
|
|
1321
|
+
const early = publishing ? publishRefusal({ env: config.env }) : null;
|
|
1322
|
+
if (early) throw new Error(refusalText(early));
|
|
1323
|
+
let cloudflared;
|
|
1324
|
+
if (options.share) {
|
|
1325
|
+
try {
|
|
1326
|
+
cloudflared = await shareCloudflared(config.env, cancel.signal);
|
|
1327
|
+
} catch (error) {
|
|
1328
|
+
if (stopping) return exitCode;
|
|
1329
|
+
throw error;
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
if (stopping) return exitCode;
|
|
1333
|
+
releases.push(acquireLock(path6.join(config.dataDir, ".local-session.lock"), process.pid, runId));
|
|
1334
|
+
fs7.mkdirSync(config.workspaceDir, { recursive: true });
|
|
1335
|
+
if (!options.child) {
|
|
1336
|
+
log = fs7.openSync(logFile, "a", 384);
|
|
1337
|
+
fs7.chmodSync(logFile, 384);
|
|
1338
|
+
}
|
|
1339
|
+
processes = new Processes(root, config.env, log);
|
|
1340
|
+
receipt = {
|
|
1341
|
+
pid: process.pid,
|
|
1342
|
+
runId,
|
|
1343
|
+
// Каталог состояния: он и опознаёт сессию (см. supervisorOwned).
|
|
1344
|
+
root: home,
|
|
1345
|
+
port: config.port,
|
|
1346
|
+
url: config.url,
|
|
1347
|
+
dataDir: config.dataDir,
|
|
1348
|
+
workspaceDir: config.workspaceDir,
|
|
1349
|
+
leaseFile: config.leaseFile,
|
|
1350
|
+
mode: options.action === "dev" ? "dev" : "run",
|
|
1351
|
+
startedAt: Date.now(),
|
|
1352
|
+
phase: "preparing",
|
|
1353
|
+
options
|
|
1354
|
+
};
|
|
1355
|
+
writeJson(receiptFile, receipt);
|
|
1356
|
+
fs7.writeFileSync(pidFile, String(process.pid) + "\n", { mode: 384 });
|
|
1357
|
+
if (stopping) return exitCode;
|
|
1358
|
+
await prepare(config, options, processes, () => stopping);
|
|
1359
|
+
if (stopping) return exitCode;
|
|
1360
|
+
receipt.phase = "starting";
|
|
1361
|
+
writeJson(receiptFile, receipt);
|
|
1362
|
+
const dev = options.action === "dev";
|
|
1363
|
+
if (dev) layOutWebAssets(root);
|
|
1364
|
+
const server = dev ? processes.start("Dev server", process.execPath, [
|
|
1365
|
+
path6.join(root, "node_modules/tsx/dist/cli.mjs"),
|
|
1366
|
+
"watch",
|
|
1367
|
+
"--clear-screen=false",
|
|
1368
|
+
"server/src/index.ts"
|
|
1369
|
+
]) : processes.start("Server", process.execPath, ["server/dist/server.js"]);
|
|
1370
|
+
started = true;
|
|
1371
|
+
receipt.serverPid = server.child.pid;
|
|
1372
|
+
writeJson(receiptFile, receipt);
|
|
1373
|
+
let frontend;
|
|
1374
|
+
if (dev) {
|
|
1375
|
+
frontend = processes.start(
|
|
1376
|
+
"Dev frontend",
|
|
1377
|
+
process.execPath,
|
|
1378
|
+
[
|
|
1379
|
+
path6.join(root, "node_modules/vite/bin/vite.js"),
|
|
1380
|
+
path6.join(root, "web"),
|
|
1381
|
+
"--host",
|
|
1382
|
+
"127.0.0.1",
|
|
1383
|
+
"--port",
|
|
1384
|
+
String(config.uiPort),
|
|
1385
|
+
"--strictPort",
|
|
1386
|
+
"--clearScreen=false"
|
|
1387
|
+
],
|
|
1388
|
+
false,
|
|
1389
|
+
{},
|
|
1390
|
+
path6.join(root, "web")
|
|
1391
|
+
);
|
|
1392
|
+
}
|
|
1393
|
+
const deadline = Date.now() + 9e4;
|
|
1394
|
+
while (!stopping) {
|
|
1395
|
+
if (server.settled || frontend?.settled)
|
|
1396
|
+
throw new Error(
|
|
1397
|
+
"The server or the frontend exited before it was ready. Check the log above."
|
|
1398
|
+
);
|
|
1399
|
+
const status = await health(config.port);
|
|
1400
|
+
const uiReady = !dev || await devFrontendReady(config.url);
|
|
1401
|
+
if (status?.localRunId === runId && uiReady) break;
|
|
1402
|
+
if (Date.now() > deadline)
|
|
1403
|
+
throw new Error(
|
|
1404
|
+
"The server did not become ready in 90 seconds. Check Docker and the log above."
|
|
1405
|
+
);
|
|
1406
|
+
await delay(150);
|
|
1407
|
+
}
|
|
1408
|
+
if (stopping) return exitCode;
|
|
1409
|
+
receipt.phase = "ready";
|
|
1410
|
+
if (publishing) receipt.hosting = "starting";
|
|
1411
|
+
writeJson(receiptFile, receipt);
|
|
1412
|
+
banner(receipt);
|
|
1413
|
+
if (options.open) openBrowser(`${config.url}/admin`);
|
|
1414
|
+
if (publishing) {
|
|
1415
|
+
const script = path6.join(root, "scripts/host.sh");
|
|
1416
|
+
const docker = fs7.existsSync(script) ? await capture(root, config.env, "docker", ["info", "--format", "{{.ServerVersion}}"]) : null;
|
|
1417
|
+
const refusal = docker ? publishRefusal({
|
|
1418
|
+
env: config.env,
|
|
1419
|
+
dockerReachable: docker.code === 0,
|
|
1420
|
+
health: await health(config.port)
|
|
1421
|
+
}) : null;
|
|
1422
|
+
if (!fs7.existsSync(script)) {
|
|
1423
|
+
receipt.hosting = "failed";
|
|
1424
|
+
writeJson(receiptFile, receipt);
|
|
1425
|
+
console.error(
|
|
1426
|
+
`
|
|
1427
|
+
Not published: there is no ${script} next to the application.
|
|
1428
|
+
The class runs locally: ${config.url}
|
|
1429
|
+
The package looks built without scripts/: update colloq (pip install -U colloq).`
|
|
1430
|
+
);
|
|
1431
|
+
} else if (refusal) {
|
|
1432
|
+
receipt.hosting = "failed";
|
|
1433
|
+
writeJson(receiptFile, receipt);
|
|
1434
|
+
console.error("\n" + refusalText(refusal, config.url));
|
|
1435
|
+
} else {
|
|
1436
|
+
const relayDomain = config.env.RELAY_DOMAIN?.trim() ?? "";
|
|
1437
|
+
tunnel = processes.start(
|
|
1438
|
+
"Public access",
|
|
1439
|
+
"bash",
|
|
1440
|
+
[script],
|
|
1441
|
+
false,
|
|
1442
|
+
{
|
|
1443
|
+
COLLOQ_HOME: home,
|
|
1444
|
+
...options.share ? {
|
|
1445
|
+
COLLOQ_HOSTNAME: "",
|
|
1446
|
+
COLLOQ_DIRECT: "",
|
|
1447
|
+
COLLOQ_SHARE: "1",
|
|
1448
|
+
COLLOQ_CLOUDFLARED: cloudflared ?? ""
|
|
1449
|
+
} : { COLLOQ_HOSTNAME: options.host }
|
|
1450
|
+
},
|
|
1451
|
+
root,
|
|
1452
|
+
options.share ? (line) => {
|
|
1453
|
+
const shared = parseShareMarker(line);
|
|
1454
|
+
if (!shared || !receipt) return false;
|
|
1455
|
+
void announceShare(shared.url, receipt, relayDomain, shared.verified, false);
|
|
1456
|
+
return true;
|
|
1457
|
+
} : void 0
|
|
1458
|
+
);
|
|
1459
|
+
void tunnel.done.then((code) => {
|
|
1460
|
+
if (!stopping && receipt) {
|
|
1461
|
+
receipt.hosting = "failed";
|
|
1462
|
+
writeJson(receiptFile, receipt);
|
|
1463
|
+
console.log(
|
|
1464
|
+
`
|
|
1465
|
+
Public access ended (code ${code}). Local work continues: ${config.url}
|
|
1466
|
+
` + (options.share ? "Try again: colloq host (a new quick tunnel \u2014 and a new address to send)." : `Try again: colloq host ${options.host}`)
|
|
1467
|
+
);
|
|
1468
|
+
}
|
|
1469
|
+
});
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
const ended = await Promise.race([server.done, ...frontend ? [frontend.done] : []]);
|
|
1473
|
+
if (!stopping) {
|
|
1474
|
+
exitCode = ended || 1;
|
|
1475
|
+
console.error("The local server or frontend exited. Stopping the remaining processes.");
|
|
1476
|
+
}
|
|
1477
|
+
return exitCode;
|
|
1478
|
+
} catch (error) {
|
|
1479
|
+
if (!stopping) console.error(error instanceof Error ? error.message : String(error));
|
|
1480
|
+
return stopping ? exitCode : 1;
|
|
1481
|
+
} finally {
|
|
1482
|
+
stopping = true;
|
|
1483
|
+
if (receipt && readReceipt(receiptFile)?.runId === runId) {
|
|
1484
|
+
receipt.phase = "stopping";
|
|
1485
|
+
writeJson(receiptFile, receipt);
|
|
1486
|
+
}
|
|
1487
|
+
await stopPromise;
|
|
1488
|
+
if (tunnel) await processes?.stop(tunnel, 5e3);
|
|
1489
|
+
await processes?.stopAll(GRACE);
|
|
1490
|
+
const cleanupArgs = started && processes ? kernelCleanupArgs() : null;
|
|
1491
|
+
if (started && processes && !cleanupArgs)
|
|
1492
|
+
console.error(
|
|
1493
|
+
"Nothing here can stop the kernels: neither server/dist/local-cleanup.js nor the source. Containers left behind are visible with: docker ps --filter name=colloq-room-"
|
|
1494
|
+
);
|
|
1495
|
+
if (processes && cleanupArgs) {
|
|
1496
|
+
const cleanup = processes.start("Stopping kernels", process.execPath, cleanupArgs);
|
|
1497
|
+
let timer;
|
|
1498
|
+
const cleanupCode = await Promise.race([
|
|
1499
|
+
cleanup.done,
|
|
1500
|
+
new Promise((resolve) => {
|
|
1501
|
+
timer = setTimeout(() => resolve(void 0), GRACE);
|
|
1502
|
+
})
|
|
1503
|
+
]);
|
|
1504
|
+
if (cleanupCode !== 0)
|
|
1505
|
+
cleanupFailure = cleanupCode === void 0 ? "Stopping the kernels timed out." : `Stopping the kernels exited with code ${cleanupCode}.`;
|
|
1506
|
+
if (timer) clearTimeout(timer);
|
|
1507
|
+
if (!cleanup.settled) {
|
|
1508
|
+
processes.signal(cleanup, "SIGKILL");
|
|
1509
|
+
await cleanup.done;
|
|
1510
|
+
}
|
|
1511
|
+
}
|
|
1512
|
+
if (receipt && readJson(receipt.leaseFile)?.runId === runId) {
|
|
1513
|
+
try {
|
|
1514
|
+
fs7.unlinkSync(receipt.leaseFile);
|
|
1515
|
+
} catch {
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
if (receipt) writeJson(resultFile, { runId, error: cleanupFailure });
|
|
1519
|
+
if (readReceipt(receiptFile)?.runId === runId) fs7.unlinkSync(receiptFile);
|
|
1520
|
+
try {
|
|
1521
|
+
if (fs7.readFileSync(pidFile, "utf8").trim() === String(process.pid)) fs7.unlinkSync(pidFile);
|
|
1522
|
+
} catch {
|
|
1523
|
+
}
|
|
1524
|
+
for (const release of releases.reverse()) release();
|
|
1525
|
+
if (log !== void 0) fs7.closeSync(log);
|
|
1526
|
+
process.off("SIGINT", onInt);
|
|
1527
|
+
process.off("SIGTERM", onTerm);
|
|
1528
|
+
process.off("SIGHUP", onHangup);
|
|
1529
|
+
process.stdout.off("error", onOutputError);
|
|
1530
|
+
process.stderr.off("error", onOutputError);
|
|
1531
|
+
if (cleanupFailure) throw new Error(cleanupFailure + " Check colloq logs; the data is saved.");
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
async function main() {
|
|
1535
|
+
const options = parseLaunchArgs(process.argv.slice(2));
|
|
1536
|
+
if (options.action === "cloudflared") {
|
|
1537
|
+
const file = await ensureCloudflared({
|
|
1538
|
+
home,
|
|
1539
|
+
env: effectiveEnv(),
|
|
1540
|
+
say: (line) => console.error(line)
|
|
1541
|
+
});
|
|
1542
|
+
process.stdout.write(file + "\n");
|
|
1543
|
+
return 0;
|
|
1544
|
+
}
|
|
1545
|
+
if (options.action === "stop") {
|
|
1546
|
+
await stopSession();
|
|
1547
|
+
return 0;
|
|
1548
|
+
}
|
|
1549
|
+
if (options.action === "restart") {
|
|
1550
|
+
const prior = await stopSession();
|
|
1551
|
+
if (!prior) return 1;
|
|
1552
|
+
process.env.DATA_DIR = prior.dataDir;
|
|
1553
|
+
process.env.WORKSPACE_DIR = prior.workspaceDir;
|
|
1554
|
+
process.env.PORT = String(prior.port);
|
|
1555
|
+
const port = prior.mode === "dev" ? Number(new URL(prior.url).port || 80) : prior.port;
|
|
1556
|
+
return await runSession({
|
|
1557
|
+
...prior.options,
|
|
1558
|
+
action: prior.mode,
|
|
1559
|
+
port,
|
|
1560
|
+
child: false,
|
|
1561
|
+
build: options.build,
|
|
1562
|
+
fast: options.fast || prior.options.fast
|
|
1563
|
+
});
|
|
1564
|
+
}
|
|
1565
|
+
return await runSession(options);
|
|
1566
|
+
}
|
|
1567
|
+
main().then((code) => {
|
|
1568
|
+
process.exitCode = code;
|
|
1569
|
+
}).catch((error) => {
|
|
1570
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
1571
|
+
process.exitCode = 1;
|
|
1572
|
+
});
|