CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutSign UpSign In
sagemathinc

Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place.

GitHub Repository: sagemathinc/cocalc
Path: blob/master/src/packages/project/configuration.ts
Views: 687
1
/*
2
* This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.
3
* License: MS-RSL – see LICENSE.md for details
4
*/
5
6
/*
7
* This derives the configuration and capabilities of the current project.
8
* It is used in the UI to only show/run those elements, which should work.
9
* The corresponding file in the webapp is @cocalc/frontend/project_configuration.ts
10
*/
11
12
import { APPS } from "@cocalc/comm/x11-apps";
13
import {
14
Capabilities,
15
Configuration,
16
ConfigurationAspect,
17
LIBRARY_INDEX_FILE,
18
MainCapabilities,
19
} from "@cocalc/comm/project-configuration";
20
import { syntax2tool, Tool as FormatTool } from "@cocalc/util/code-formatter";
21
import { copy } from "@cocalc/util/misc";
22
import { exec as child_process_exec } from "child_process";
23
import { access as fs_access, constants as fs_constaints } from "fs";
24
import { realpath } from "fs/promises";
25
import { promisify } from "util";
26
import which from "which";
27
const exec = promisify(child_process_exec);
28
import { reuseInFlight } from "@cocalc/util/reuse-in-flight";
29
import { getLogger } from "@cocalc/backend/logger";
30
const logger = getLogger("configuration");
31
32
// we prefix the environment PATH by default bin paths pointing into it in order to pick up locally installed binaries.
33
// they can't be set as defaults for projects since this could break it from starting up
34
function construct_path(): string {
35
const env = process.env;
36
// we can safely assume that PATH is defined
37
const entries = env.PATH!.split(":");
38
const home = env.HOME ?? "/home/user";
39
entries.unshift(`${home}/.local/bin`);
40
entries.unshift(`${home}/bin`);
41
return entries.join(":");
42
}
43
44
const PATH = construct_path();
45
46
// test if the given utility "name" exists (executable in the PATH)
47
async function have(name: string): Promise<boolean> {
48
return new Promise<boolean>((resolve) => {
49
which(name, { path: PATH }, function (error, path) {
50
resolve(error == null && path != null);
51
});
52
});
53
}
54
55
// we cache this as long as the project runs
56
const conf: { [key in ConfigurationAspect]?: Configuration } = {};
57
58
// check for all X11 apps.
59
// UI will only show buttons for existing executables.
60
async function x11_apps(): Promise<Capabilities> {
61
const status: Promise<boolean>[] = [];
62
const KEYS = Object.keys(APPS);
63
for (const key of KEYS) {
64
const app = APPS[key];
65
status.push(have(app.command != null ? app.command : key));
66
}
67
const results = await Promise.all(status);
68
const ret: { [key: string]: boolean } = {};
69
KEYS.map((name, idx) => (ret[name] = results[idx]));
70
return ret;
71
}
72
73
// determines if X11 support exists at all
74
async function get_x11(): Promise<boolean> {
75
return await have("xpra");
76
}
77
78
// Quarto document formatter (on top of pandoc)
79
async function get_quarto(): Promise<boolean> {
80
return await have("quarto");
81
}
82
83
// do we have "sage"? which version?
84
async function get_sage_info(): Promise<{
85
exists: boolean;
86
version: number[] | undefined;
87
}> {
88
// TODO probably also check if smc_sagews is working? or the sage server?
89
// without sage, sagews files are disabled
90
const exists = await have("sage");
91
let version: number[] | undefined = undefined;
92
if (exists) {
93
// We need the version of sage (--version runs quickly)
94
try {
95
const env = copy(process.env);
96
env.PATH = PATH;
97
const info = (await exec("sage --version", { env })).stdout.trim();
98
const m = info.match(/version ([\d+.]+[\d+])/);
99
if (m != null) {
100
const v = m[1];
101
if (v != null && v.length > 1) {
102
version = v.split(".").map((x) => parseInt(x));
103
// console.log(`Sage version info: ${info} -> ${version}`, env);
104
}
105
}
106
} catch (err) {
107
// TODO: do something better than silently ignoring errors. This console.log
108
// isn't going to be seen by the user.
109
console.log("Problem fetching sage version info -- ignoring", err);
110
}
111
}
112
return { exists, version };
113
}
114
115
// this checks the level of jupyter support. none (false), or classical, lab, ...
116
async function get_jupyter(): Promise<Capabilities | boolean> {
117
if (await have("jupyter")) {
118
return {
119
lab: await have("jupyter-lab"),
120
notebook: await have("jupyter-notebook"),
121
kernelspec: await have("jupyter-kernelspec"),
122
};
123
} else {
124
return false;
125
}
126
}
127
128
// to support latex, we need a couple of executables available
129
// TODO dumb down the UI to also work with less tools (e.g. without synctex)
130
async function get_latex(hashsums: Capabilities): Promise<boolean> {
131
const prereq: string[] = ["pdflatex", "latexmk", "synctex"];
132
const have_prereq = (await Promise.all(prereq.map(have))).every((p) => p);
133
// TODO webapp only uses sha1sum. use a fallback if not available.
134
return hashsums.sha1sum && have_prereq;
135
}
136
137
// plain text editors (md, tex, ...) use aspell → disable calling aspell if not available.
138
async function get_spellcheck(): Promise<boolean> {
139
return await have("aspell");
140
}
141
142
// without sshd we cannot copy to this project. that's vital for courses.
143
async function get_sshd(): Promise<boolean> {
144
return await have("/usr/sbin/sshd");
145
}
146
147
// we check if we can use headless chrome to do html to pdf conversion,
148
// which uses either google-chrome or chromium-browser. Note that there
149
// is no good headless pdf support using firefox.
150
// (TODO: I don't think this is used in our code in practice, and instead not
151
// having one of these at runtime would just result in a error message
152
// to the user mentioning it is missing.)
153
async function get_html2pdf(): Promise<boolean> {
154
return (await have("chromium-browser")) || (await have("google-chrome"));
155
}
156
157
// do we have pandoc, e.g. used for docx2md
158
async function get_pandoc(): Promise<boolean> {
159
return await have("pandoc");
160
}
161
162
// this is for rnw RMarkdown files.
163
// This just tests R, which provides knitr out of the box?
164
async function get_rmd(): Promise<boolean> {
165
return await have("R");
166
}
167
168
// jq is used to e.g. pre-process ipynb files
169
async function get_jq(): Promise<boolean> {
170
return await have("jq");
171
}
172
173
// code-server is VS Code's Sever version, which we use to provide a web-based editor.
174
async function get_vscode(): Promise<boolean> {
175
return await have("code-server");
176
}
177
178
// julia executable, for the programming language, and we also assume that "Pluto" package is installed
179
async function get_julia(): Promise<boolean> {
180
return await have("julia");
181
}
182
183
// rserver is the name of the executable to start the R IDE Server.
184
// In a default Linux installation, it is not in the PATH – therefore add a symlink pointing to it.
185
// At the time of writing this, it was here: /usr/lib/rstudio-server/bin/rserver
186
async function get_rserver(): Promise<boolean> {
187
return await have("rserver");
188
}
189
190
// check if we can read that json file.
191
// if it exists, show the corresponding button in "Files".
192
async function get_library(): Promise<boolean> {
193
return new Promise<boolean>((resolve) => {
194
fs_access(LIBRARY_INDEX_FILE, fs_constaints.R_OK, (err) => {
195
resolve(err ? false : true);
196
});
197
});
198
}
199
200
// formatting code, e.g. python, javascript, etc.
201
// we check this here, because the frontend should offer these choices if available.
202
// in some cases like python, there could be multiple ways (yapf, yapf3, black, autopep8, ...)
203
async function get_formatting(): Promise<Capabilities> {
204
const status: Promise<any>[] = [];
205
const tools = new Array(
206
...new Set(Object.keys(syntax2tool).map((k) => syntax2tool[k])),
207
);
208
tools.push("yapf3", "black", "autopep8");
209
const tidy = have("tidy");
210
211
const ret: Capabilities = {};
212
for (const tool of tools) {
213
if (tool === ("formatR" as FormatTool)) {
214
// TODO special case. must check for package "formatR" in "R" -- for now just test for R
215
status.push((async () => (ret[tool] = await have("R")))());
216
} else if (tool == ("bib-biber" as FormatTool)) {
217
// another special case
218
status.push((async () => (ret[tool] = await have("biber")))());
219
} else if (tool === ("xml-tidy" as FormatTool)) {
220
// tidy, already covered
221
} else {
222
status.push((async () => (ret[tool] = await have(tool)))());
223
}
224
}
225
226
// this populates all "await have" in ret[...]
227
await Promise.all(status);
228
229
ret["tidy"] = await tidy;
230
// just for testing
231
// ret['yapf'] = false;
232
// prettier always available, because it is a js library dependency
233
ret["prettier"] = true;
234
return ret;
235
}
236
237
// this could be used by the webapp to fall back to other hashsums
238
async function get_hashsums(): Promise<Capabilities> {
239
return {
240
sha1sum: await have("sha1sum"),
241
sha256sum: await have("sha256sum"),
242
md5sum: await have("md5sum"),
243
};
244
}
245
246
async function get_homeDirectory(): Promise<string | null> {
247
// realpath is necessary, because in some circumstances the home dir is a symlink
248
const home = process.env.HOME;
249
if (home == null) {
250
return null;
251
} else {
252
return await realpath(home);
253
}
254
}
255
256
// assemble capabilities object
257
// no matter what, never run this more than once very this many MS.
258
// I have at least one project in production that gets DOS'd due to
259
// calls to capabilities, even with the reuseInFlight stuff.
260
const SHORT_CAPABILITIES_CACHE_MS = 15000;
261
let shortCapabilitiesCache = {
262
time: 0,
263
caps: null as null | MainCapabilities,
264
error: null as any,
265
};
266
267
const capabilities = reuseInFlight(async (): Promise<MainCapabilities> => {
268
const time = Date.now();
269
if (time - shortCapabilitiesCache.time <= SHORT_CAPABILITIES_CACHE_MS) {
270
if (shortCapabilitiesCache.error != null) {
271
logger.debug("capabilities: using cache for error");
272
throw shortCapabilitiesCache.error;
273
}
274
if (shortCapabilitiesCache.caps != null) {
275
logger.debug("capabilities: using cache for caps");
276
return shortCapabilitiesCache.caps as MainCapabilities;
277
}
278
logger.debug("capabilities: BUG -- want to use cache but no data");
279
}
280
logger.debug("capabilities: running");
281
try {
282
const sage_info_future = get_sage_info();
283
const hashsums = await get_hashsums();
284
const [
285
formatting,
286
latex,
287
jupyter,
288
spellcheck,
289
html2pdf,
290
pandoc,
291
sshd,
292
library,
293
x11,
294
rmd,
295
qmd,
296
vscode,
297
julia,
298
homeDirectory,
299
rserver,
300
] = await Promise.all([
301
get_formatting(),
302
get_latex(hashsums),
303
get_jupyter(),
304
get_spellcheck(),
305
get_html2pdf(),
306
get_pandoc(),
307
get_sshd(),
308
get_library(),
309
get_x11(),
310
get_rmd(),
311
get_quarto(),
312
get_vscode(),
313
get_julia(),
314
get_homeDirectory(),
315
get_rserver(),
316
]);
317
const caps: MainCapabilities = {
318
jupyter,
319
rserver,
320
formatting,
321
hashsums,
322
latex,
323
sage: false,
324
sage_version: undefined,
325
x11,
326
rmd,
327
qmd,
328
jq: await get_jq(), // don't know why, but it doesn't compile when inside the Promise.all
329
spellcheck,
330
library,
331
sshd,
332
html2pdf,
333
pandoc,
334
vscode,
335
julia,
336
homeDirectory,
337
};
338
const sage = await sage_info_future;
339
caps.sage = sage.exists;
340
if (caps.sage) {
341
caps.sage_version = sage.version;
342
}
343
logger.debug("capabilities: saving caps");
344
shortCapabilitiesCache.time = time;
345
shortCapabilitiesCache.error = null;
346
shortCapabilitiesCache.caps = caps;
347
return caps as MainCapabilities;
348
} catch (err) {
349
logger.debug("capabilities: saving error", err);
350
shortCapabilitiesCache.time = time;
351
shortCapabilitiesCache.error = err;
352
shortCapabilitiesCache.caps = null;
353
throw err;
354
}
355
});
356
357
// this is the entry point for the API call
358
// "main": everything that's needed throughout the project
359
// "x11": additional checks which are queried when an X11 editor opens up
360
// TODO similarly, query available "shells" to use for the corresponding code editor button
361
362
// This is expensive, so put in a reuseInFlight to make it cheap in case a frontend
363
// annoyingly calls this dozens of times at once -- https://github.com/sagemathinc/cocalc/issues/7806
364
export const get_configuration = reuseInFlight(
365
async (
366
aspect: ConfigurationAspect,
367
no_cache = false,
368
): Promise<Configuration> => {
369
const cached = conf[aspect];
370
if (cached != null && !no_cache) return cached;
371
const t0 = new Date().getTime();
372
const new_conf: any = (async function () {
373
switch (aspect) {
374
case "main":
375
return {
376
timestamp: new Date(),
377
capabilities: await capabilities(),
378
};
379
case "x11":
380
return {
381
timestamp: new Date(),
382
capabilities: await x11_apps(),
383
};
384
}
385
})();
386
new_conf.timing_s = (new Date().getTime() - t0) / 1000;
387
conf[aspect] = await new_conf;
388
return new_conf;
389
},
390
);
391
392
// testing: uncomment, and run $ ts-node configuration.ts
393
// (async () => {
394
// console.log(await x11_apps());
395
// console.log(await capabilities());
396
// })();
397
398