Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
quarto-dev
GitHub Repository: quarto-dev/quarto-cli
Path: blob/main/src/extension/install.ts
6458 views
1
/*
2
* install.ts
3
*
4
* Copyright (C) 2020-2022 Posit Software, PBC
5
*/
6
7
import { ensureDirSync, existsSync, safeRemoveSync } from "../deno_ral/fs.ts";
8
import { Confirm } from "cliffy/prompt/mod.ts";
9
import { Table } from "cliffy/table/mod.ts";
10
import { basename, dirname, join, relative } from "../deno_ral/path.ts";
11
12
import { projectContext } from "../project/project-context.ts";
13
import { TempContext } from "../core/temp-types.ts";
14
import { unzip } from "../core/zip.ts";
15
import { copyTo } from "../core/copy.ts";
16
import { Extension } from "./types.ts";
17
import { kExtensionDir } from "./constants.ts";
18
import { withSpinner } from "../core/console.ts";
19
import { downloadWithProgress } from "../core/download.ts";
20
import { createExtensionContext, readExtensions } from "./extension.ts";
21
import { info } from "../deno_ral/log.ts";
22
import { ExtensionSource, extensionSource } from "./extension-host.ts";
23
import { safeExistsSync } from "../core/path.ts";
24
import { InternalError } from "../core/lib/error.ts";
25
import { notebookContext } from "../render/notebook/notebook-context.ts";
26
import { openUrl } from "../core/shell.ts";
27
28
const kUnversionedFrom = " (?)";
29
const kUnversionedTo = "(?) ";
30
31
// Core Installation
32
export async function installExtension(
33
target: string,
34
temp: TempContext,
35
allowPrompt: boolean,
36
embed?: string,
37
): Promise<boolean> {
38
// Is this local or remote?
39
const source = await extensionSource(target);
40
41
// Is this source valid?
42
if (!source) {
43
info(
44
`Extension not found in local or remote sources`,
45
);
46
return false;
47
}
48
49
// Does the user trust the extension?
50
const trusted = await isTrusted(source, allowPrompt);
51
if (!trusted) {
52
// Not trusted, cancel
53
cancelInstallation();
54
return false;
55
}
56
57
// Compute the installation directory
58
const currentDir = Deno.cwd();
59
const installDir = await determineInstallDir(
60
currentDir,
61
allowPrompt,
62
embed,
63
);
64
65
// Stage the extension locally
66
const extensionDir = await stageExtension(source, temp.createDir());
67
68
// Validate the extension in in the staging dir
69
const stagedExtensions = await validateExtension(extensionDir);
70
71
// Confirm that the user would like to take this action
72
const confirmed = await confirmInstallation(
73
stagedExtensions,
74
installDir,
75
{ allowPrompt },
76
);
77
78
if (!confirmed) {
79
// Not confirmed, cancel the installation
80
cancelInstallation();
81
return false;
82
}
83
84
// Complete the installation
85
await completeInstallation(extensionDir, installDir);
86
87
await withSpinner(
88
{ message: "Extension installation complete" },
89
() => {
90
return Promise.resolve();
91
},
92
);
93
94
if (source.learnMoreUrl) {
95
info("");
96
if (allowPrompt) {
97
const open = await Confirm.prompt({
98
message: "View documentation using default browser?",
99
default: true,
100
});
101
if (open) {
102
await openUrl(source.learnMoreUrl);
103
}
104
} else {
105
info(
106
`\nLearn more about this extension at:\n${source.learnMoreUrl}\n`,
107
);
108
}
109
}
110
return true;
111
}
112
113
// Cancels the installation, providing user feedback that the installation is canceled
114
function cancelInstallation() {
115
info("Installation canceled\n");
116
}
117
118
// Determines whether the user trusts the extension
119
async function isTrusted(
120
source: ExtensionSource,
121
allowPrompt: boolean,
122
): Promise<boolean> {
123
if (allowPrompt && source.type === "remote") {
124
// Write the preamble
125
const preamble =
126
`\nQuarto extensions may execute code when documents are rendered. If you do not \ntrust the authors of the extension, we recommend that you do not install or \nuse the extension.`;
127
info(preamble);
128
129
// Ask for trust
130
const question = "Do you trust the authors of this extension";
131
const confirmed: boolean = await Confirm.prompt({
132
message: question,
133
default: true,
134
});
135
return confirmed;
136
} else {
137
return true;
138
}
139
}
140
141
// If the installation is happening in a project
142
// we should offer to install the extension into the project
143
async function determineInstallDir(
144
dir: string,
145
allowPrompt: boolean,
146
embed?: string,
147
) {
148
if (embed) {
149
// We're embeddeding this within an extension
150
const extensionName = embed;
151
const context = createExtensionContext();
152
153
// Load the extension to be sure it exists and then
154
// use its path as the target for installation
155
const extension = await context.extension(extensionName, dir);
156
if (extension) {
157
if (Object.keys(extension?.contributes.formats || {}).length > 0) {
158
return extension?.path;
159
} else {
160
throw new Error(
161
`The extension ${embed} does not contribute a format.\nYou can only embed extensions within an extension which itself contributes a format.`,
162
);
163
}
164
} else {
165
throw new Error(
166
`Unable to locate the extension '${embed}' that you'd like to embed this within.`,
167
);
168
}
169
} else {
170
// We're not embeddeding, check if we're in a project
171
// and offer to use that directory if we are
172
const nbContext = notebookContext();
173
const project = await projectContext(dir, nbContext);
174
if (project && project.dir !== dir) {
175
const question = "Install extension into project?";
176
if (allowPrompt) {
177
const useProject = await Confirm.prompt(question);
178
if (useProject) {
179
return project.dir;
180
} else {
181
return dir;
182
}
183
} else {
184
return dir;
185
}
186
} else {
187
return dir;
188
}
189
}
190
}
191
192
// This downloads or copies the extension files into a temporary working
193
// directory that we can use to enumerate, compare, etc... when deciding
194
// whether to proceed with installation
195
//
196
// Currently supports
197
// - Remote Paths
198
// - Local files (tarballs / zips)
199
// - Local folders (either the path to the _extensions directory or its parent)
200
async function stageExtension(
201
source: ExtensionSource,
202
workingDir: string,
203
) {
204
if (source.type === "remote") {
205
// Stages a remote file by downloading and unzipping it
206
const archiveDir = join(workingDir, "archive");
207
ensureDirSync(archiveDir);
208
209
// The filename
210
const filename = (typeof (source.resolvedTarget) === "string"
211
? source.resolvedTarget
212
: source.resolvedFile) || "extension.zip";
213
214
// The tarball path
215
const toFile = join(archiveDir, filename);
216
217
// Download the file
218
await downloadWithProgress(source.resolvedTarget, `Downloading`, toFile);
219
220
return unzipAndStage(toFile, source);
221
} else {
222
if (typeof source.resolvedTarget !== "string") {
223
throw new InternalError(
224
"local resolved extension should always have a string target.",
225
);
226
}
227
if (Deno.statSync(source.resolvedTarget).isDirectory) {
228
// Copy the extension dir only
229
const srcDir = extensionDir(source.resolvedTarget);
230
if (srcDir) {
231
const destDir = join(workingDir, kExtensionDir);
232
// If there is something to stage, go for it, otherwise
233
// just leave the directory empty
234
await readAndCopyExtensions(srcDir, destDir);
235
}
236
return workingDir;
237
} else {
238
const filename = basename(source.resolvedTarget);
239
240
// A local copy of a zip file
241
const toFile = join(workingDir, filename);
242
copyTo(source.resolvedTarget, toFile);
243
return unzipAndStage(toFile, source);
244
}
245
}
246
}
247
248
// Unpack and stage a zipped file
249
async function unzipAndStage(
250
zipFile: string,
251
source: ExtensionSource,
252
) {
253
// Unzip the file
254
await withSpinner(
255
{ message: "Unzipping" },
256
async () => {
257
// Unzip the archive
258
const result = await unzip(zipFile);
259
if (!result.success) {
260
throw new Error("Failed to unzip extension.\n" + result.stderr);
261
}
262
263
// Remove the tar ball itself
264
await Deno.remove(zipFile);
265
266
return Promise.resolve();
267
},
268
);
269
270
// Use any subdirectory inside, if appropriate
271
const archiveDir = dirname(zipFile);
272
273
const findExtensionDir = () => {
274
if (source.targetSubdir) {
275
// If the source provides a subdirectory, just use that
276
const subDirPath = join(archiveDir, source.targetSubdir);
277
if (existsSync(subDirPath)) {
278
return subDirPath;
279
}
280
}
281
282
// Otherwise, we should inspect the directory either:
283
// - use the directory itself it has an _extensions dir
284
// - use a subdirectory if there is a single subdirectory and it has an
285
// _extensions dir
286
if (safeExistsSync(join(archiveDir, kExtensionDir))) {
287
return archiveDir;
288
} else {
289
const dirEntries = Deno.readDirSync(archiveDir);
290
let count = 0;
291
let name;
292
for (const dirEntry of dirEntries) {
293
// ignore any files
294
if (dirEntry.isDirectory) {
295
name = dirEntry.name;
296
count++;
297
}
298
}
299
300
if (count === 1 && name && name !== kExtensionDir) {
301
if (safeExistsSync(join(archiveDir, name, kExtensionDir))) {
302
return join(archiveDir, name);
303
} else {
304
return archiveDir;
305
}
306
} else {
307
return archiveDir;
308
}
309
}
310
};
311
// Use a subdirectory if the source provides one
312
const extensionsDir = join(findExtensionDir(), kExtensionDir);
313
314
// Make the final directory we're staging into
315
const finalDir = join(archiveDir, "staged");
316
await copyExtensions(source, extensionsDir, finalDir);
317
318
return finalDir;
319
}
320
321
export async function copyExtensions(
322
source: ExtensionSource,
323
srcDir: string,
324
targetDir: string,
325
) {
326
const finalExtensionsDir = join(targetDir, kExtensionDir);
327
const finalExtensionTargetDir = source.owner
328
? join(finalExtensionsDir, source.owner)
329
: finalExtensionsDir;
330
ensureDirSync(finalExtensionTargetDir);
331
332
// Move extensions into the target directory (root or owner)
333
await readAndCopyExtensions(srcDir, finalExtensionTargetDir);
334
}
335
336
// Reads the extensions from an extensions directory and copies
337
// them to a destination directory
338
async function readAndCopyExtensions(
339
extensionsDir: string,
340
targetDir: string,
341
) {
342
const extensions = await readExtensions(extensionsDir);
343
info(
344
` Found ${extensions.length} ${
345
extensions.length === 1 ? "extension" : "extensions"
346
}.`,
347
);
348
349
for (const extension of extensions) {
350
copyTo(
351
extension.path,
352
join(targetDir, extension.id.name),
353
);
354
}
355
}
356
357
// Validates that a path on disk is a valid path to extensions
358
// Currently just ensures there is an _extensions directory
359
// and that the directory contains readable extensions
360
async function validateExtension(path: string) {
361
const extensionsFolder = extensionDir(path);
362
if (!extensionsFolder) {
363
throw new Error(
364
`Invalid extension\nThe extension staged at ${path} is missing an '_extensions' folder.`,
365
);
366
}
367
368
const extensions = await readExtensions(extensionsFolder);
369
if (extensions.length === 0) {
370
throw new Error(
371
`Invalid extension\nThe extension staged at ${path} does not provide any valid extensions.`,
372
);
373
}
374
return extensions;
375
}
376
377
export interface ConfirmationOptions {
378
allowPrompt: boolean;
379
throw?: boolean;
380
message?: string;
381
}
382
383
// Confirm that the user would like to proceed with the installation
384
export async function confirmInstallation(
385
extensions: Extension[],
386
installDir: string,
387
options: ConfirmationOptions,
388
) {
389
const readExisting = async () => {
390
try {
391
const existingExtensions = await readExtensions(
392
join(installDir, kExtensionDir),
393
);
394
return existingExtensions;
395
} catch {
396
return [];
397
}
398
};
399
400
const name = (extension: Extension) => {
401
const idStr = extension.id.organization
402
? `${extension.id.organization}/${extension.id.name}`
403
: extension.id.name;
404
return extension.title || idStr;
405
};
406
407
const existingExtensions = await readExisting();
408
const existing = (extension: Extension) => {
409
return existingExtensions.find((existing) => {
410
return existing.id.name === extension.id.name &&
411
existing.id.organization === extension.id.organization;
412
});
413
};
414
if (existingExtensions.length > 0 && !options.allowPrompt && options.throw) {
415
throw new Error(
416
`There are extensions installed which would be overwritten. Aborting installation.\n${
417
existingExtensions.map((ext) => {
418
return ext.title;
419
}).join("\n - ")
420
}`,
421
);
422
}
423
424
const typeStr = (to: Extension) => {
425
const contributes = to.contributes;
426
const extTypes: string[] = [];
427
if (
428
contributes.formats &&
429
Object.keys(contributes.formats).length > 0
430
) {
431
Object.keys(contributes.formats).length === 1
432
? extTypes.push("format")
433
: extTypes.push("formats");
434
}
435
436
if (
437
contributes.shortcodes &&
438
contributes.shortcodes.length > 0
439
) {
440
contributes.shortcodes.length === 1
441
? extTypes.push("shortcode")
442
: extTypes.push("shortcodes");
443
}
444
445
if (contributes.filters && contributes.filters.length > 0) {
446
contributes.filters.length === 1
447
? extTypes.push("filter")
448
: extTypes.push("filters");
449
}
450
451
if (extTypes.length > 0) {
452
return `(${extTypes.join(",")})`;
453
} else {
454
return "";
455
}
456
};
457
458
const versionMessage = (to: Extension, from?: Extension) => {
459
if (to && !from) {
460
const versionStr = to.version?.format();
461
// New Install
462
return {
463
action: "Install",
464
from: "",
465
to: versionStr,
466
};
467
} else {
468
if (to.version && from?.version) {
469
// From version to version
470
const comparison = to.version.compare(from.version);
471
if (comparison === 0) {
472
return {
473
action: "No Change",
474
from: "",
475
to: "",
476
};
477
} else if (comparison > 0) {
478
return {
479
action: "Update",
480
from: from.version.format(),
481
to: to.version.format(),
482
};
483
} else {
484
return {
485
action: "Revert",
486
from: from.version.format(),
487
to: to.version.format(),
488
};
489
}
490
} else if (to.version && !from?.version) {
491
// From unversioned to versioned
492
return {
493
action: "Update",
494
from: kUnversionedFrom,
495
to: to.version.format(),
496
};
497
} else if (!to.version && from?.version) {
498
// From versioned to unversioned
499
return {
500
action: "Update",
501
from: from.version.format(),
502
to: kUnversionedTo,
503
};
504
} else {
505
// Both unversioned
506
return {
507
action: "Update",
508
from: kUnversionedFrom,
509
to: kUnversionedTo,
510
};
511
}
512
}
513
};
514
515
const extensionRows: string[][] = [];
516
for (const stagedExtension of extensions) {
517
const installedExtension = existing(stagedExtension);
518
const message = versionMessage(
519
stagedExtension,
520
installedExtension,
521
);
522
523
const types = typeStr(stagedExtension);
524
if (message) {
525
extensionRows.push([
526
name(stagedExtension) + " ",
527
`[${message.action}]`,
528
message.from || "",
529
message.to && message.from ? "->" : "",
530
message.to || "",
531
types,
532
]);
533
}
534
}
535
536
if (extensionRows.length > 0) {
537
const table = new Table(...extensionRows);
538
info(
539
`\n${
540
options.message || "The following changes will be made:"
541
}\n${table.toString()}`,
542
);
543
const question = "Would you like to continue";
544
return !options.allowPrompt ||
545
await Confirm.prompt({
546
message: question,
547
default: true,
548
});
549
} else {
550
info(`\nNo changes required - extensions already installed.`);
551
return true;
552
}
553
}
554
555
// Copy the extension files into place
556
export async function completeInstallation(
557
downloadDir: string,
558
installDir: string,
559
) {
560
info("");
561
562
await withSpinner({
563
message: `Copying`,
564
}, async () => {
565
// Determine a staging location in the installDir
566
// (to ensure we can use move without fear of spanning volumes)
567
const stagingDir = join(installDir, "._extensions.staging");
568
try {
569
// For each 'extension' in the install dir, perform a move
570
const downloadedExtDir = join(downloadDir, kExtensionDir);
571
572
// We'll stage the extension in a directory within the install dir
573
// then move it to the install dir when ready
574
const stagingExtDir = join(stagingDir, kExtensionDir);
575
ensureDirSync(stagingExtDir);
576
577
// The final installation target
578
const installExtDir = join(installDir, kExtensionDir);
579
ensureDirSync(installExtDir);
580
581
// Read the extensions that have been downloaded and install them
582
// one by bone
583
const extensions = await readExtensions(downloadedExtDir);
584
extensions.forEach((extension) => {
585
const extensionRelativeDir = relative(downloadedExtDir, extension.path);
586
// Copy to the staging path
587
const stagingPath = join(stagingExtDir, extensionRelativeDir);
588
copyTo(extension.path, stagingPath);
589
590
// Move from the staging path to the install dir
591
const installPath = join(installExtDir, extensionRelativeDir);
592
if (existsSync(installPath)) {
593
safeRemoveSync(installPath, { recursive: true });
594
}
595
596
// Ensure the parent directory exists
597
ensureDirSync(dirname(installPath));
598
Deno.renameSync(stagingPath, installPath);
599
});
600
} finally {
601
// Clean up the staging directory
602
safeRemoveSync(stagingDir, { recursive: true });
603
}
604
return Promise.resolve();
605
});
606
}
607
608
// Is this _extensions or does this contain _extensions?
609
const extensionDir = (path: string) => {
610
if (basename(path) === kExtensionDir) {
611
// If this is pointing to an _extensions dir, use that
612
return path;
613
} else {
614
// Otherwise, add _extensions to this and use that
615
const extDir = join(path, kExtensionDir);
616
if (existsSync(extDir) && Deno.statSync(extDir).isDirectory) {
617
return extDir;
618
} else {
619
return path;
620
}
621
}
622
};
623
624