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