@cocalc/project
Version:
CoCalc: project daemon
344 lines (313 loc) • 10.8 kB
text/typescript
/*
* This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.
* License: AGPLv3 s.t. "Commons Clause" – see LICENSE.md for details
*/
/*
* This derives the configuration and capabilities of the current project.
* It is used in the UI to only show/run those elements, which should work.
* The corresponding file in the webapp is @cocalc/frontend/project_configuration.ts
*/
import { APPS } from "@cocalc/frontend/frame-editors/x11-editor/apps";
import {
Capabilities,
Configuration,
ConfigurationAspect,
LIBRARY_INDEX_FILE,
MainCapabilities,
} from "@cocalc/frontend/project_configuration";
import { syntax2tool, Tool as FormatTool } from "@cocalc/util/code-formatter";
import { copy } from "@cocalc/util/misc";
import { exec as child_process_exec } from "child_process";
import { access as fs_access, constants as fs_constaints } from "fs";
import { realpath } from "fs/promises";
import { promisify } from "util";
import which from "which";
const exec = promisify(child_process_exec);
// we prefix the environment PATH by default bin paths pointing into it in order to pick up locally installed binaries.
// they can't be set as defaults for projects since this could break it from starting up
function construct_path(): string {
const env = process.env;
// we can safely assume that PATH is defined
const entries = env.PATH!.split(":");
const home = env.HOME ?? "/home/user";
entries.unshift(`${home}/.local/bin`);
entries.unshift(`${home}/bin`);
return entries.join(":");
}
const PATH = construct_path();
// test if the given utiltiy "name" exists (executable in the PATH)
async function have(name: string): Promise<boolean> {
return new Promise<boolean>((resolve) => {
which(name, { path: PATH }, function (error, path) {
resolve(error == null && path != null);
});
});
}
// we cache this as long as the project runs
const conf: { [key in ConfigurationAspect]?: Configuration } = {};
// check for all X11 apps.
// UI will only show buttons for existing executables.
async function x11_apps(): Promise<Capabilities> {
const status: Promise<boolean>[] = [];
const KEYS = Object.keys(APPS);
for (const key of KEYS) {
const app = APPS[key];
status.push(have(app.command != null ? app.command : key));
}
const results = await Promise.all(status);
const ret: { [key: string]: boolean } = {};
KEYS.map((name, idx) => (ret[name] = results[idx]));
return ret;
}
// determines if X11 support exists at all
async function get_x11(): Promise<boolean> {
return await have("xpra");
}
// Quarto document formatter (on top of pandoc)
async function get_quarto(): Promise<boolean> {
return await have("quarto");
}
// do we have "sage"? which version?
async function get_sage_info(): Promise<{
exists: boolean;
version: number[] | undefined;
}> {
// TODO probably also check if smc_sagews is working? or the sage server?
// without sage, sagews files are disabled
const exists = await have("sage");
let version: number[] | undefined = undefined;
if (exists) {
// We need the version of sage (--version runs quickly)
try {
const env = copy(process.env);
env.PATH = PATH;
const info = (await exec("sage --version", { env })).stdout.trim();
const m = info.match(/version ([\d+.]+[\d+])/);
if (m != null) {
const v = m[1];
if (v != null && v.length > 1) {
version = v.split(".").map((x) => parseInt(x));
// console.log(`Sage version info: ${info} -> ${version}`, env);
}
}
} catch (err) {
// TODO: do something better than silently ignoring errors. This console.log
// isn't going to be seen by the user.
console.log("Problem fetching sage version info -- ignoring", err);
}
}
return { exists, version };
}
// this checks the level of jupyter support. none (false), or classical, lab, ...
async function get_jupyter(): Promise<Capabilities | boolean> {
if (await have("jupyter")) {
return {
lab: await have("jupyter-lab"),
notebook: await have("jupyter-notebook"),
kernelspec: await have("jupyter-kernelspec"),
};
} else {
return false;
}
}
// to support latex, we need a couple of executables available
// TODO dumb down the UI to also work with less tools (e.g. without synctex)
async function get_latex(hashsums: Capabilities): Promise<boolean> {
const prereq: string[] = ["pdflatex", "latexmk", "synctex"];
const have_prereq = (await Promise.all(prereq.map(have))).every((p) => p);
// TODO webapp only uses sha1sum. use a fallback if not available.
return hashsums.sha1sum && have_prereq;
}
// plain text editors (md, tex, ...) use aspell → disable calling aspell if not available.
async function get_spellcheck(): Promise<boolean> {
return await have("aspell");
}
// without sshd we cannot copy to this project. that's vital for courses.
async function get_sshd(): Promise<boolean> {
return await have("/usr/sbin/sshd");
}
// we check if we can use headless chrome to do html to pdf conversion,
// which uses either google-chrome or chromium-browser. Note that there
// is no good headless pdf support using firefox.
// (TODO: I don't think this is used in our code in practice, and instead not
// having one of these at runtime would just result in a error message
// to the user mentioning it is missing.)
async function get_html2pdf(): Promise<boolean> {
return (await have("chromium-browser")) || (await have("google-chrome"));
}
// do we have pandoc, e.g. used for docx2md
async function get_pandoc(): Promise<boolean> {
return await have("pandoc");
}
// this is for rnw RMarkdown files.
// This just tests R, which provides knitr out of the box?
async function get_rmd(): Promise<boolean> {
return await have("R");
}
// jq is used to e.g. pre-process ipynb files
async function get_jq(): Promise<boolean> {
return await have("jq");
}
// code-server is VS Code's Sever version, which we use to provide a web-based editor.
async function get_vscode(): Promise<boolean> {
return await have("code-server");
}
// julia executable, for the programming language, and we also assume that "Pluto" package is installed
async function get_julia(): Promise<boolean> {
return await have("julia");
}
// check if we can read that json file.
// if it exists, show the corresponding button in "Files".
async function get_library(): Promise<boolean> {
return new Promise<boolean>((resolve) => {
fs_access(LIBRARY_INDEX_FILE, fs_constaints.R_OK, (err) => {
resolve(err ? false : true);
});
});
}
// formatting code, e.g. python, javascript, etc.
// we check this here, because the frontend should offer these choices if available.
// in some cases like python, there could be multiple ways (yapf, yapf3, black, autopep8, ...)
async function get_formatting(): Promise<Capabilities> {
const status: Promise<any>[] = [];
const tools = new Array(
...new Set(Object.keys(syntax2tool).map((k) => syntax2tool[k]))
);
tools.push("yapf3", "black", "autopep8");
const tidy = have("tidy");
const ret: Capabilities = {};
for (const tool of tools) {
if (tool === ("formatR" as FormatTool)) {
// TODO special case. must check for package "formatR" in "R" -- for now just test for R
status.push((async () => (ret[tool] = await have("R")))());
} else if (tool == ("bib-biber" as FormatTool)) {
// another special case
status.push((async () => (ret[tool] = await have("biber")))());
} else if (tool === ("xml-tidy" as FormatTool)) {
// tidy, already covered
} else {
status.push((async () => (ret[tool] = await have(tool)))());
}
}
// this populates all "await have" in ret[...]
await Promise.all(status);
ret["tidy"] = await tidy;
// just for testing
// ret['yapf'] = false;
// prettier always available, because it is a js library dependency
ret["prettier"] = true;
return ret;
}
// this could be used by the webapp to fall back to other hashsums
async function get_hashsums(): Promise<Capabilities> {
return {
sha1sum: await have("sha1sum"),
sha256sum: await have("sha256sum"),
md5sum: await have("md5sum"),
};
}
async function get_homeDirectory(): Promise<string | null> {
// realpath is necessary, because in some circumstances the home dir is a symlink
const home = process.env.HOME;
if (home == null) {
return null;
} else {
return await realpath(home);
}
}
// assemble capabilities object
async function capabilities(): Promise<MainCapabilities> {
const sage_info_future = get_sage_info();
const hashsums = await get_hashsums();
const [
formatting,
latex,
jupyter,
spellcheck,
html2pdf,
pandoc,
sshd,
library,
x11,
rmd,
qmd,
vscode,
julia,
homeDirectory,
] = await Promise.all([
get_formatting(),
get_latex(hashsums),
get_jupyter(),
get_spellcheck(),
get_html2pdf(),
get_pandoc(),
get_sshd(),
get_library(),
get_x11(),
get_rmd(),
get_quarto(),
get_vscode(),
get_julia(),
get_homeDirectory(),
]);
const caps: MainCapabilities = {
jupyter,
formatting,
hashsums,
latex,
sage: false,
sage_version: undefined,
x11,
rmd,
qmd,
jq: await get_jq(), // don't know why, but it doesn't compile when inside the Promise.all
spellcheck,
library,
sshd,
html2pdf,
pandoc,
vscode,
julia,
homeDirectory,
};
const sage = await sage_info_future;
caps.sage = sage.exists;
if (caps.sage) {
caps.sage_version = sage.version;
}
return caps;
}
// this is the entry point for the API call
// "main": everything that's needed throughout the project
// "x11": additional checks which are queried when an X11 editor opens up
// TODO similarly, query available "shells" to use for the corresponding code editor button
export async function get_configuration(
aspect: ConfigurationAspect,
no_cache = false
): Promise<Configuration> {
const cached = conf[aspect];
if (cached != null && !no_cache) return cached;
const t0 = new Date().getTime();
const new_conf: any = (async function () {
switch (aspect) {
case "main":
return {
timestamp: new Date(),
capabilities: await capabilities(),
};
case "x11":
return {
timestamp: new Date(),
capabilities: await x11_apps(),
};
}
})();
new_conf.timing_s = (new Date().getTime() - t0) / 1000;
conf[aspect] = await new_conf;
return new_conf;
}
// testing: uncomment, and run $ ts-node configuration.ts
// (async () => {
// console.log(await x11_apps());
// console.log(await capabilities());
// })();