Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
quarto-dev
GitHub Repository: quarto-dev/quarto-cli
Path: blob/main/src/command/use/commands/binder/binder.ts
3593 views
1
/*
2
* binder.ts
3
*
4
* Copyright (C) 2021-2022 Posit Software, PBC
5
*/
6
7
import { initYamlIntelligenceResourcesFromFilesystem } from "../../../../core/schema/utils.ts";
8
import { createTempContext } from "../../../../core/temp.ts";
9
10
import { rBinaryPath, resourcePath } from "../../../../core/resources.ts";
11
12
import SemVer from "semver/mod.ts";
13
import { extname, join } from "../../../../deno_ral/path.ts";
14
import { info, warning } from "../../../../deno_ral/log.ts";
15
import { ensureDirSync, existsSync } from "../../../../deno_ral/fs.ts";
16
import {
17
EnvironmentConfiguration,
18
PythonConfiguration,
19
QuartoConfiguration,
20
RConfiguration,
21
VSCodeConfiguration,
22
} from "./binder-types.ts";
23
import { execProcess } from "../../../../core/process.ts";
24
import { safeFileWriter } from "./binder-utils.ts";
25
import { projectContext } from "../../../../project/project-context.ts";
26
import { ProjectEnvironment } from "../../../../project/project-environment-types.ts";
27
import { withSpinner } from "../../../../core/console.ts";
28
import { logProgress } from "../../../../core/log.ts";
29
import {
30
kProjectPostRender,
31
kProjectPreRender,
32
ProjectContext,
33
} from "../../../../project/types.ts";
34
35
import { Command } from "cliffy/command/mod.ts";
36
import { Table } from "cliffy/table/mod.ts";
37
import { Confirm } from "cliffy/prompt/mod.ts";
38
import { notebookContext } from "../../../../render/notebook/notebook-context.ts";
39
import { asArray } from "../../../../core/array.ts";
40
41
export const useBinderCommand = new Command()
42
.name("binder")
43
.description(
44
"Configure the current project with Binder support.",
45
)
46
.option(
47
"--no-prompt",
48
"Do not prompt to confirm actions",
49
)
50
.example(
51
"Configure project to use Binder",
52
"quarto use binder",
53
)
54
.action(async (options: { prompt?: boolean }) => {
55
await initYamlIntelligenceResourcesFromFilesystem();
56
const temp = createTempContext();
57
try {
58
// compute the project context
59
logProgress("Determining configuration");
60
const nbContext = notebookContext();
61
const context = await projectContext(Deno.cwd(), nbContext);
62
if (!context) {
63
throw new Error(
64
"You must be in a Quarto project in order to configure Binder support.",
65
);
66
}
67
68
// Read the project environment
69
const projEnv = await withSpinner(
70
{
71
message: "Inspecting project configuration:",
72
doneMessage: "Detected Project configuration:\n",
73
},
74
() => {
75
return context.environment();
76
},
77
);
78
79
const jupyterLab4 = jupyterLabVersion(context, projEnv);
80
81
const rConfig: RConfiguration = {};
82
if (projectHasR(context, projEnv)) {
83
const result = await execProcess(
84
{
85
cmd: await rBinaryPath("R"),
86
args: [
87
"--version",
88
],
89
stdout: "piped",
90
stderr: "piped",
91
},
92
);
93
if (result.success) {
94
const output = result.stdout;
95
const verMatch = output?.match(
96
/R version (\d+\.\d+\.\d+) \((\d\d\d\d-\d\d-\d\d)\)/m,
97
);
98
if (verMatch) {
99
const version = verMatch[1];
100
rConfig.version = new SemVer(version);
101
rConfig.date = verMatch[2];
102
}
103
} else {
104
warning("Unable to detect R version, ommitting R configuration");
105
}
106
}
107
108
const quartoVersion = typeof (projEnv.quarto) === "string"
109
? projEnv.quarto === "prerelease"
110
? "most recent prerelease"
111
: "most recent release"
112
: projEnv.quarto.toString();
113
114
const table = new Table();
115
table.push(["Quarto", quartoVersion]);
116
table.push([
117
"JupyterLab",
118
jupyterLab4 ? "4.x" : "default",
119
]);
120
if (projEnv.engines.length > 0) {
121
table.push([
122
projEnv.engines.length === 1 ? "Engine" : "Engines",
123
projEnv.engines.join("\n"),
124
]);
125
}
126
if (rConfig.version || rConfig.date) {
127
const verStr = [];
128
if (rConfig.version) {
129
verStr.push(`${rConfig.version?.toString()}`);
130
}
131
if (rConfig.date) {
132
verStr.push(`(${rConfig.date})`);
133
}
134
135
table.push([
136
"R",
137
verStr.join(" "),
138
]);
139
}
140
if (projEnv.tools.length > 0) {
141
table.push(["Tools", projEnv.tools.join("\n")]);
142
}
143
table.push(["Editor", projEnv.codeEnvironment]);
144
if (projEnv.environments.length > 0) {
145
table.push(["Environments", projEnv.environments.join("\n")]);
146
}
147
table.indent(4).minColWidth(12).render();
148
149
// Note whether there are depedencies restored
150
const isMarkdownEngineOnly = (engines: string[]) => {
151
return engines.length === 1 && engines.includes("markdown");
152
};
153
if (
154
projEnv.environments.length === 0 &&
155
!isMarkdownEngineOnly(projEnv.engines)
156
) {
157
info(
158
"\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",
159
);
160
const proceed = !options.prompt || await Confirm.prompt({
161
message: "Do you want to continue?",
162
default: true,
163
});
164
if (!proceed) {
165
return;
166
}
167
}
168
169
// Get the list of operations that need to be performed
170
const fileOperations = await binderFileOperations(
171
projEnv,
172
jupyterLab4,
173
context,
174
options,
175
rConfig,
176
);
177
info(
178
"\nThe following files will be written:",
179
);
180
const changeTable = new Table();
181
fileOperations.forEach((op) => {
182
changeTable.push([op.file, op.desc]);
183
});
184
changeTable.border(true).render();
185
info("");
186
187
const writeFiles = !options.prompt || await Confirm.prompt({
188
message: "Continue?",
189
default: true,
190
});
191
192
if (writeFiles) {
193
logProgress("\nWriting configuration files");
194
for (const fileOperation of fileOperations) {
195
await fileOperation.performOp();
196
}
197
}
198
} finally {
199
temp.cleanup();
200
}
201
});
202
203
const createPostBuild = (
204
quartoConfig: QuartoConfiguration,
205
vscodeConfig: VSCodeConfiguration,
206
pythonConfig: PythonConfiguration,
207
) => {
208
const postBuildScript: string[] = [];
209
postBuildScript.push("#!/usr/bin/env -S bash -v");
210
postBuildScript.push("");
211
postBuildScript.push(`# determine which version of Quarto to install`);
212
postBuildScript.push(`QUARTO_VERSION=${quartoConfig.version}`);
213
postBuildScript.push(kLookupQuartoVersion);
214
postBuildScript.push(msg("Installing Quarto $QUARTO_VERSION"));
215
postBuildScript.push(kInstallQuarto);
216
postBuildScript.push(msg("Installed Quarto"));
217
218
// Maybe install TinyTeX
219
if (quartoConfig.tinytex) {
220
postBuildScript.push(msg("Installing TinyTex"));
221
postBuildScript.push("# install tinytex");
222
postBuildScript.push("quarto install tinytex --no-prompt");
223
postBuildScript.push(msg("Installed TinyTex"));
224
}
225
226
// Maybe install Chromium
227
if (quartoConfig.chromium) {
228
postBuildScript.push(msg("Installing Chromium"));
229
postBuildScript.push("# install chromium");
230
postBuildScript.push("quarto install chromium --no-prompt");
231
postBuildScript.push(msg("Installed Chromium"));
232
}
233
234
if (vscodeConfig.version) {
235
const version = typeof (vscodeConfig.version) === "boolean"
236
? new SemVer("4.16.1")
237
: vscodeConfig.version;
238
postBuildScript.push(msg("Configuring VSCode"));
239
postBuildScript.push("# download and install VS Code server");
240
postBuildScript.push(`CODE_VERSION=${version}`);
241
postBuildScript.push(kInstallVSCode);
242
243
if (vscodeConfig.extensions) {
244
postBuildScript.push("# install vscode extensions");
245
for (const extension of vscodeConfig.extensions) {
246
postBuildScript.push(
247
`code-server --install-extension ${extension}`,
248
);
249
}
250
}
251
252
postBuildScript.push(msg("Configured VSCode"));
253
}
254
255
if (pythonConfig.pip) {
256
postBuildScript.push("# install required python packages");
257
for (const lib of pythonConfig.pip) {
258
postBuildScript.push(`python3 -m pip install ${lib}`);
259
}
260
}
261
262
postBuildScript.push(msg("Completed"));
263
return postBuildScript.join("\n");
264
};
265
266
const jupyterLabVersion = (
267
context: ProjectContext,
268
env: ProjectEnvironment,
269
) => {
270
const envs = env.environments;
271
272
// Look in requirements, environment.yml, pipfile for hints
273
// that JL4 will be used (hacky to use regex but using for hint)
274
const envMatchers: Record<string, RegExp> = {};
275
envMatchers["requirements.txt"] = /jupyterlab>*=*4.*./g;
276
envMatchers["environment.yml"] = /jupyterlab *>*=*4.*./g;
277
envMatchers["pipfile"] = /jupyterlab = "*>*=*4.*."/g;
278
279
const hasJL4 = envs.some((env) => {
280
const matcher = envMatchers[env];
281
if (!matcher) {
282
return false;
283
}
284
285
const contents = Deno.readTextFileSync(join(context.dir, env));
286
return contents.match(matcher);
287
});
288
return hasJL4;
289
};
290
291
const msg = (text: string): string => {
292
return `
293
echo
294
echo ${text}
295
echo`;
296
};
297
298
const kInstallQuarto = `
299
# download and install the deb file
300
curl -LO https://github.com/quarto-dev/quarto-cli/releases/download/v$QUARTO_VERSION/quarto-$QUARTO_VERSION-linux-amd64.deb
301
dpkg -x quarto-$QUARTO_VERSION-linux-amd64.deb .quarto
302
rm -rf quarto-$QUARTO_VERSION-linux-amd64.deb
303
304
# get quarto in the path
305
mkdir -p ~/.local/bin
306
ln -s ~/.quarto/opt/quarto/bin/quarto ~/.local/bin/quarto
307
308
# create the proper pandoc symlink to enable visual editor in Quarto extension
309
ln -s ~/.quarto/opt/quarto/bin/tools/x86_64/pandoc ~/.quarto/opt/quarto/bin/tools/pandoc
310
`;
311
312
const kInstallVSCode = `
313
# download and extract
314
wget -q -O code-server.tar.gz https://github.com/coder/code-server/releases/download/v$CODE_VERSION/code-server-$CODE_VERSION-linux-amd64.tar.gz
315
tar xzf code-server.tar.gz
316
rm -rf code-server.tar.gz
317
318
# place in hidden folder
319
mv "code-server-$CODE_VERSION-linux-amd64" .code-server
320
321
# get code-server in path
322
mkdir -p ./.local/bin
323
ln -s ~/.code-server/bin/code-server ~/.local/bin/code-server
324
`;
325
326
const kLookupQuartoVersion = `
327
# See whether we need to lookup a Quarto version
328
if [ $QUARTO_VERSION = "prerelease" ]; then
329
QUARTO_JSON="_prerelease.json"
330
elif [ $QUARTO_VERSION = "release" ]; then
331
QUARTO_JSON="_download.json"
332
fi
333
334
if [ $QUARTO_JSON != "" ]; then
335
336
# create a python script and run it
337
PYTHON_SCRIPT=_quarto_version.py
338
if [ -e $PYTHON_SCRIPT ]; then
339
rm -rf $PYTHON_SCRIPT
340
fi
341
342
cat > $PYTHON_SCRIPT <<EOF
343
import urllib, json
344
345
import urllib.request, json
346
with urllib.request.urlopen("https://quarto.org/docs/download/\${QUARTO_JSON}") as url:
347
data = json.load(url)
348
print(data['version'])
349
350
EOF
351
352
QUARTO_VERSION=$(python $PYTHON_SCRIPT)
353
rm -rf $PYTHON_SCRIPT
354
355
fi
356
`;
357
358
async function binderFileOperations(
359
projEnv: ProjectEnvironment,
360
jupyterLab4: boolean,
361
context: ProjectContext,
362
options: { prompt?: boolean | undefined },
363
rConfig: RConfiguration,
364
) {
365
const operations: Array<
366
{ file: string; desc: string; performOp: () => Promise<void> }
367
> = [];
368
369
// Write the post build to install Quarto
370
const quartoConfig: QuartoConfiguration = {
371
version: projEnv.quarto,
372
tinytex: projEnv.tools.includes("tinytex"),
373
chromium: projEnv.tools.includes("chromium"),
374
};
375
376
const vsCodeConfig: VSCodeConfiguration = {
377
version: projEnv.codeEnvironment === "vscode"
378
? new SemVer("4.16.1")
379
: undefined,
380
extensions: [
381
"ms-python.python",
382
"sumneko.lua",
383
"quarto.quarto",
384
],
385
};
386
387
// See if we should configure for JL3 or 4
388
const pythonConfig: PythonConfiguration = {
389
pip: [],
390
};
391
if (jupyterLab4) {
392
if (projEnv.codeEnvironment === "vscode") {
393
pythonConfig.pip?.push(
394
"git+https://github.com/trungleduc/jupyter-server-proxy@lab4",
395
);
396
}
397
pythonConfig.pip?.push("jupyterlab-quarto");
398
} else {
399
if (projEnv.codeEnvironment === "vscode") {
400
pythonConfig.pip?.push("jupyter-server-proxy");
401
}
402
403
pythonConfig.pip?.push("jupyterlab-quarto==0.1.45");
404
}
405
406
const environmentConfig: EnvironmentConfiguration = {
407
apt: ["zip"],
408
};
409
410
// Get a file writer
411
const writeFile = safeFileWriter(context.dir, options.prompt);
412
413
// Look for an renv.lock file
414
const renvPath = join(context.dir, "renv.lock");
415
if (existsSync(renvPath)) {
416
// Create an install.R file
417
const installRText = "install.packages('renv')\nrenv::restore()";
418
operations.push({
419
file: "install.R",
420
desc: "Activates the R environment described in renv.lock",
421
performOp: async () => {
422
await writeFile(
423
"install.R",
424
installRText,
425
);
426
},
427
});
428
}
429
430
// Generate the postBuild text
431
const postBuildScriptText = createPostBuild(
432
quartoConfig,
433
vsCodeConfig,
434
pythonConfig,
435
);
436
437
// Write the postBuild text
438
operations.push({
439
file: "postBuild",
440
desc: "Configures Quarto and supporting tools",
441
performOp: async () => {
442
await writeFile(
443
"postBuild",
444
postBuildScriptText,
445
);
446
},
447
});
448
449
// Configure JupyterLab to support VSCode
450
if (vsCodeConfig.version) {
451
operations.push({
452
file: ".jupyter",
453
desc: "Configures JupyterLab with necessary extensions",
454
performOp: async () => {
455
const traitletsDir = ".jupyter";
456
ensureDirSync(join(context.dir, traitletsDir));
457
458
// Move traitlets configuration into place
459
// Traitlets are used to configure the vscode tile in jupyterlab
460
// as well as to start the port proxying that permits vscode to work
461
const resDir = resourcePath("use/binder/");
462
for (const file of ["vscode.svg", "jupyter_notebook_config.py"]) {
463
const textContents = Deno.readTextFileSync(join(resDir, file));
464
await writeFile(join(traitletsDir, file), textContents);
465
}
466
},
467
});
468
}
469
470
// Generate an apt.txt file
471
if (environmentConfig.apt && environmentConfig.apt.length) {
472
const aptText = environmentConfig.apt.join("\n");
473
operations.push({
474
file: "apt.txt",
475
desc: "Installs Quarto required packages",
476
performOp: async () => {
477
await writeFile(
478
"apt.txt",
479
aptText,
480
);
481
},
482
});
483
}
484
485
// Generate a file to configure R
486
if (rConfig.version || rConfig.date) {
487
const runtime = ["r"];
488
if (rConfig.version) {
489
runtime.push(`-${rConfig.version}`);
490
}
491
492
if (rConfig.date) {
493
runtime.push(`-${rConfig.date}`);
494
}
495
operations.push({
496
file: "runtime.txt",
497
desc: "Installs R and configures RStudio",
498
performOp: async () => {
499
await writeFile(
500
"runtime.txt",
501
runtime.join(""),
502
);
503
},
504
});
505
}
506
507
return operations;
508
}
509
510
const projectHasR = (context: ProjectContext, projEnv: ProjectEnvironment) => {
511
if (projEnv.engines.includes("knitr")) {
512
return true;
513
}
514
515
if (existsSync(join(context.dir, "renv.lock"))) {
516
return true;
517
}
518
519
if (existsSync(join(context.dir, "install.R"))) {
520
return true;
521
}
522
523
if (context.config?.project?.[kProjectPreRender]) {
524
if (
525
asArray(context.config.project[kProjectPreRender]).some((file) => {
526
return extname(file).toLowerCase() === ".r";
527
})
528
) {
529
return true;
530
}
531
}
532
533
if (context.config?.project?.[kProjectPostRender]) {
534
if (
535
asArray(context.config.project[kProjectPostRender]).some((file) => {
536
return extname(file).toLowerCase() === ".r";
537
})
538
) {
539
return true;
540
}
541
}
542
543
return false;
544
};
545
546