Path: blob/main/src/command/use/commands/binder/binder.ts
3593 views
/*1* binder.ts2*3* Copyright (C) 2021-2022 Posit Software, PBC4*/56import { initYamlIntelligenceResourcesFromFilesystem } from "../../../../core/schema/utils.ts";7import { createTempContext } from "../../../../core/temp.ts";89import { rBinaryPath, resourcePath } from "../../../../core/resources.ts";1011import SemVer from "semver/mod.ts";12import { extname, join } from "../../../../deno_ral/path.ts";13import { info, warning } from "../../../../deno_ral/log.ts";14import { ensureDirSync, existsSync } from "../../../../deno_ral/fs.ts";15import {16EnvironmentConfiguration,17PythonConfiguration,18QuartoConfiguration,19RConfiguration,20VSCodeConfiguration,21} from "./binder-types.ts";22import { execProcess } from "../../../../core/process.ts";23import { safeFileWriter } from "./binder-utils.ts";24import { projectContext } from "../../../../project/project-context.ts";25import { ProjectEnvironment } from "../../../../project/project-environment-types.ts";26import { withSpinner } from "../../../../core/console.ts";27import { logProgress } from "../../../../core/log.ts";28import {29kProjectPostRender,30kProjectPreRender,31ProjectContext,32} from "../../../../project/types.ts";3334import { Command } from "cliffy/command/mod.ts";35import { Table } from "cliffy/table/mod.ts";36import { Confirm } from "cliffy/prompt/mod.ts";37import { notebookContext } from "../../../../render/notebook/notebook-context.ts";38import { asArray } from "../../../../core/array.ts";3940export const useBinderCommand = new Command()41.name("binder")42.description(43"Configure the current project with Binder support.",44)45.option(46"--no-prompt",47"Do not prompt to confirm actions",48)49.example(50"Configure project to use Binder",51"quarto use binder",52)53.action(async (options: { prompt?: boolean }) => {54await initYamlIntelligenceResourcesFromFilesystem();55const temp = createTempContext();56try {57// compute the project context58logProgress("Determining configuration");59const nbContext = notebookContext();60const context = await projectContext(Deno.cwd(), nbContext);61if (!context) {62throw new Error(63"You must be in a Quarto project in order to configure Binder support.",64);65}6667// Read the project environment68const projEnv = await withSpinner(69{70message: "Inspecting project configuration:",71doneMessage: "Detected Project configuration:\n",72},73() => {74return context.environment();75},76);7778const jupyterLab4 = jupyterLabVersion(context, projEnv);7980const rConfig: RConfiguration = {};81if (projectHasR(context, projEnv)) {82const result = await execProcess(83{84cmd: await rBinaryPath("R"),85args: [86"--version",87],88stdout: "piped",89stderr: "piped",90},91);92if (result.success) {93const output = result.stdout;94const verMatch = output?.match(95/R version (\d+\.\d+\.\d+) \((\d\d\d\d-\d\d-\d\d)\)/m,96);97if (verMatch) {98const version = verMatch[1];99rConfig.version = new SemVer(version);100rConfig.date = verMatch[2];101}102} else {103warning("Unable to detect R version, ommitting R configuration");104}105}106107const quartoVersion = typeof (projEnv.quarto) === "string"108? projEnv.quarto === "prerelease"109? "most recent prerelease"110: "most recent release"111: projEnv.quarto.toString();112113const table = new Table();114table.push(["Quarto", quartoVersion]);115table.push([116"JupyterLab",117jupyterLab4 ? "4.x" : "default",118]);119if (projEnv.engines.length > 0) {120table.push([121projEnv.engines.length === 1 ? "Engine" : "Engines",122projEnv.engines.join("\n"),123]);124}125if (rConfig.version || rConfig.date) {126const verStr = [];127if (rConfig.version) {128verStr.push(`${rConfig.version?.toString()}`);129}130if (rConfig.date) {131verStr.push(`(${rConfig.date})`);132}133134table.push([135"R",136verStr.join(" "),137]);138}139if (projEnv.tools.length > 0) {140table.push(["Tools", projEnv.tools.join("\n")]);141}142table.push(["Editor", projEnv.codeEnvironment]);143if (projEnv.environments.length > 0) {144table.push(["Environments", projEnv.environments.join("\n")]);145}146table.indent(4).minColWidth(12).render();147148// Note whether there are depedencies restored149const isMarkdownEngineOnly = (engines: string[]) => {150return engines.length === 1 && engines.includes("markdown");151};152if (153projEnv.environments.length === 0 &&154!isMarkdownEngineOnly(projEnv.engines)155) {156info(157"\nNo files which provide dependencies were discovered. If you continue, no dependencies will be restored when running this project with Binder.\n\nLearn more at:\nhttps://www.quarto.org/docs/prerelease/1.4/binder.html#dependencies\n",158);159const proceed = !options.prompt || await Confirm.prompt({160message: "Do you want to continue?",161default: true,162});163if (!proceed) {164return;165}166}167168// Get the list of operations that need to be performed169const fileOperations = await binderFileOperations(170projEnv,171jupyterLab4,172context,173options,174rConfig,175);176info(177"\nThe following files will be written:",178);179const changeTable = new Table();180fileOperations.forEach((op) => {181changeTable.push([op.file, op.desc]);182});183changeTable.border(true).render();184info("");185186const writeFiles = !options.prompt || await Confirm.prompt({187message: "Continue?",188default: true,189});190191if (writeFiles) {192logProgress("\nWriting configuration files");193for (const fileOperation of fileOperations) {194await fileOperation.performOp();195}196}197} finally {198temp.cleanup();199}200});201202const createPostBuild = (203quartoConfig: QuartoConfiguration,204vscodeConfig: VSCodeConfiguration,205pythonConfig: PythonConfiguration,206) => {207const postBuildScript: string[] = [];208postBuildScript.push("#!/usr/bin/env -S bash -v");209postBuildScript.push("");210postBuildScript.push(`# determine which version of Quarto to install`);211postBuildScript.push(`QUARTO_VERSION=${quartoConfig.version}`);212postBuildScript.push(kLookupQuartoVersion);213postBuildScript.push(msg("Installing Quarto $QUARTO_VERSION"));214postBuildScript.push(kInstallQuarto);215postBuildScript.push(msg("Installed Quarto"));216217// Maybe install TinyTeX218if (quartoConfig.tinytex) {219postBuildScript.push(msg("Installing TinyTex"));220postBuildScript.push("# install tinytex");221postBuildScript.push("quarto install tinytex --no-prompt");222postBuildScript.push(msg("Installed TinyTex"));223}224225// Maybe install Chromium226if (quartoConfig.chromium) {227postBuildScript.push(msg("Installing Chromium"));228postBuildScript.push("# install chromium");229postBuildScript.push("quarto install chromium --no-prompt");230postBuildScript.push(msg("Installed Chromium"));231}232233if (vscodeConfig.version) {234const version = typeof (vscodeConfig.version) === "boolean"235? new SemVer("4.16.1")236: vscodeConfig.version;237postBuildScript.push(msg("Configuring VSCode"));238postBuildScript.push("# download and install VS Code server");239postBuildScript.push(`CODE_VERSION=${version}`);240postBuildScript.push(kInstallVSCode);241242if (vscodeConfig.extensions) {243postBuildScript.push("# install vscode extensions");244for (const extension of vscodeConfig.extensions) {245postBuildScript.push(246`code-server --install-extension ${extension}`,247);248}249}250251postBuildScript.push(msg("Configured VSCode"));252}253254if (pythonConfig.pip) {255postBuildScript.push("# install required python packages");256for (const lib of pythonConfig.pip) {257postBuildScript.push(`python3 -m pip install ${lib}`);258}259}260261postBuildScript.push(msg("Completed"));262return postBuildScript.join("\n");263};264265const jupyterLabVersion = (266context: ProjectContext,267env: ProjectEnvironment,268) => {269const envs = env.environments;270271// Look in requirements, environment.yml, pipfile for hints272// that JL4 will be used (hacky to use regex but using for hint)273const envMatchers: Record<string, RegExp> = {};274envMatchers["requirements.txt"] = /jupyterlab>*=*4.*./g;275envMatchers["environment.yml"] = /jupyterlab *>*=*4.*./g;276envMatchers["pipfile"] = /jupyterlab = "*>*=*4.*."/g;277278const hasJL4 = envs.some((env) => {279const matcher = envMatchers[env];280if (!matcher) {281return false;282}283284const contents = Deno.readTextFileSync(join(context.dir, env));285return contents.match(matcher);286});287return hasJL4;288};289290const msg = (text: string): string => {291return `292echo293echo ${text}294echo`;295};296297const kInstallQuarto = `298# download and install the deb file299curl -LO https://github.com/quarto-dev/quarto-cli/releases/download/v$QUARTO_VERSION/quarto-$QUARTO_VERSION-linux-amd64.deb300dpkg -x quarto-$QUARTO_VERSION-linux-amd64.deb .quarto301rm -rf quarto-$QUARTO_VERSION-linux-amd64.deb302303# get quarto in the path304mkdir -p ~/.local/bin305ln -s ~/.quarto/opt/quarto/bin/quarto ~/.local/bin/quarto306307# create the proper pandoc symlink to enable visual editor in Quarto extension308ln -s ~/.quarto/opt/quarto/bin/tools/x86_64/pandoc ~/.quarto/opt/quarto/bin/tools/pandoc309`;310311const kInstallVSCode = `312# download and extract313wget -q -O code-server.tar.gz https://github.com/coder/code-server/releases/download/v$CODE_VERSION/code-server-$CODE_VERSION-linux-amd64.tar.gz314tar xzf code-server.tar.gz315rm -rf code-server.tar.gz316317# place in hidden folder318mv "code-server-$CODE_VERSION-linux-amd64" .code-server319320# get code-server in path321mkdir -p ./.local/bin322ln -s ~/.code-server/bin/code-server ~/.local/bin/code-server323`;324325const kLookupQuartoVersion = `326# See whether we need to lookup a Quarto version327if [ $QUARTO_VERSION = "prerelease" ]; then328QUARTO_JSON="_prerelease.json"329elif [ $QUARTO_VERSION = "release" ]; then330QUARTO_JSON="_download.json"331fi332333if [ $QUARTO_JSON != "" ]; then334335# create a python script and run it336PYTHON_SCRIPT=_quarto_version.py337if [ -e $PYTHON_SCRIPT ]; then338rm -rf $PYTHON_SCRIPT339fi340341cat > $PYTHON_SCRIPT <<EOF342import urllib, json343344import urllib.request, json345with urllib.request.urlopen("https://quarto.org/docs/download/\${QUARTO_JSON}") as url:346data = json.load(url)347print(data['version'])348349EOF350351QUARTO_VERSION=$(python $PYTHON_SCRIPT)352rm -rf $PYTHON_SCRIPT353354fi355`;356357async function binderFileOperations(358projEnv: ProjectEnvironment,359jupyterLab4: boolean,360context: ProjectContext,361options: { prompt?: boolean | undefined },362rConfig: RConfiguration,363) {364const operations: Array<365{ file: string; desc: string; performOp: () => Promise<void> }366> = [];367368// Write the post build to install Quarto369const quartoConfig: QuartoConfiguration = {370version: projEnv.quarto,371tinytex: projEnv.tools.includes("tinytex"),372chromium: projEnv.tools.includes("chromium"),373};374375const vsCodeConfig: VSCodeConfiguration = {376version: projEnv.codeEnvironment === "vscode"377? new SemVer("4.16.1")378: undefined,379extensions: [380"ms-python.python",381"sumneko.lua",382"quarto.quarto",383],384};385386// See if we should configure for JL3 or 4387const pythonConfig: PythonConfiguration = {388pip: [],389};390if (jupyterLab4) {391if (projEnv.codeEnvironment === "vscode") {392pythonConfig.pip?.push(393"git+https://github.com/trungleduc/jupyter-server-proxy@lab4",394);395}396pythonConfig.pip?.push("jupyterlab-quarto");397} else {398if (projEnv.codeEnvironment === "vscode") {399pythonConfig.pip?.push("jupyter-server-proxy");400}401402pythonConfig.pip?.push("jupyterlab-quarto==0.1.45");403}404405const environmentConfig: EnvironmentConfiguration = {406apt: ["zip"],407};408409// Get a file writer410const writeFile = safeFileWriter(context.dir, options.prompt);411412// Look for an renv.lock file413const renvPath = join(context.dir, "renv.lock");414if (existsSync(renvPath)) {415// Create an install.R file416const installRText = "install.packages('renv')\nrenv::restore()";417operations.push({418file: "install.R",419desc: "Activates the R environment described in renv.lock",420performOp: async () => {421await writeFile(422"install.R",423installRText,424);425},426});427}428429// Generate the postBuild text430const postBuildScriptText = createPostBuild(431quartoConfig,432vsCodeConfig,433pythonConfig,434);435436// Write the postBuild text437operations.push({438file: "postBuild",439desc: "Configures Quarto and supporting tools",440performOp: async () => {441await writeFile(442"postBuild",443postBuildScriptText,444);445},446});447448// Configure JupyterLab to support VSCode449if (vsCodeConfig.version) {450operations.push({451file: ".jupyter",452desc: "Configures JupyterLab with necessary extensions",453performOp: async () => {454const traitletsDir = ".jupyter";455ensureDirSync(join(context.dir, traitletsDir));456457// Move traitlets configuration into place458// Traitlets are used to configure the vscode tile in jupyterlab459// as well as to start the port proxying that permits vscode to work460const resDir = resourcePath("use/binder/");461for (const file of ["vscode.svg", "jupyter_notebook_config.py"]) {462const textContents = Deno.readTextFileSync(join(resDir, file));463await writeFile(join(traitletsDir, file), textContents);464}465},466});467}468469// Generate an apt.txt file470if (environmentConfig.apt && environmentConfig.apt.length) {471const aptText = environmentConfig.apt.join("\n");472operations.push({473file: "apt.txt",474desc: "Installs Quarto required packages",475performOp: async () => {476await writeFile(477"apt.txt",478aptText,479);480},481});482}483484// Generate a file to configure R485if (rConfig.version || rConfig.date) {486const runtime = ["r"];487if (rConfig.version) {488runtime.push(`-${rConfig.version}`);489}490491if (rConfig.date) {492runtime.push(`-${rConfig.date}`);493}494operations.push({495file: "runtime.txt",496desc: "Installs R and configures RStudio",497performOp: async () => {498await writeFile(499"runtime.txt",500runtime.join(""),501);502},503});504}505506return operations;507}508509const projectHasR = (context: ProjectContext, projEnv: ProjectEnvironment) => {510if (projEnv.engines.includes("knitr")) {511return true;512}513514if (existsSync(join(context.dir, "renv.lock"))) {515return true;516}517518if (existsSync(join(context.dir, "install.R"))) {519return true;520}521522if (context.config?.project?.[kProjectPreRender]) {523if (524asArray(context.config.project[kProjectPreRender]).some((file) => {525return extname(file).toLowerCase() === ".r";526})527) {528return true;529}530}531532if (context.config?.project?.[kProjectPostRender]) {533if (534asArray(context.config.project[kProjectPostRender]).some((file) => {535return extname(file).toLowerCase() === ".r";536})537) {538return true;539}540}541542return false;543};544545546