import { ensureDirSync, existsSync, safeRemoveSync } from "../deno_ral/fs.ts";
import { Confirm } from "cliffy/prompt/mod.ts";
import { Table } from "cliffy/table/mod.ts";
import { basename, dirname, join, relative } from "../deno_ral/path.ts";
import { projectContext } from "../project/project-context.ts";
import { TempContext } from "../core/temp-types.ts";
import { unzip } from "../core/zip.ts";
import { copyTo } from "../core/copy.ts";
import { Extension } from "./types.ts";
import { kExtensionDir } from "./constants.ts";
import { withSpinner } from "../core/console.ts";
import { downloadWithProgress } from "../core/download.ts";
import { createExtensionContext, readExtensions } from "./extension.ts";
import { info } from "../deno_ral/log.ts";
import { ExtensionSource, extensionSource } from "./extension-host.ts";
import { safeExistsSync } from "../core/path.ts";
import { InternalError } from "../core/lib/error.ts";
import { notebookContext } from "../render/notebook/notebook-context.ts";
import { openUrl } from "../core/shell.ts";
const kUnversionedFrom = " (?)";
const kUnversionedTo = "(?) ";
export async function installExtension(
target: string,
temp: TempContext,
allowPrompt: boolean,
embed?: string,
): Promise<boolean> {
const source = await extensionSource(target);
if (!source) {
info(
`Extension not found in local or remote sources`,
);
return false;
}
const trusted = await isTrusted(source, allowPrompt);
if (!trusted) {
cancelInstallation();
return false;
}
const currentDir = Deno.cwd();
const installDir = await determineInstallDir(
currentDir,
allowPrompt,
embed,
);
const extensionDir = await stageExtension(source, temp.createDir());
const stagedExtensions = await validateExtension(extensionDir);
const confirmed = await confirmInstallation(
stagedExtensions,
installDir,
{ allowPrompt },
);
if (!confirmed) {
cancelInstallation();
return false;
}
await completeInstallation(extensionDir, installDir);
await withSpinner(
{ message: "Extension installation complete" },
() => {
return Promise.resolve();
},
);
if (source.learnMoreUrl) {
info("");
if (allowPrompt) {
const open = await Confirm.prompt({
message: "View documentation using default browser?",
default: true,
});
if (open) {
await openUrl(source.learnMoreUrl);
}
} else {
info(
`\nLearn more about this extension at:\n${source.learnMoreUrl}\n`,
);
}
}
return true;
}
function cancelInstallation() {
info("Installation canceled\n");
}
async function isTrusted(
source: ExtensionSource,
allowPrompt: boolean,
): Promise<boolean> {
if (allowPrompt && source.type === "remote") {
const preamble =
`\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.`;
info(preamble);
const question = "Do you trust the authors of this extension";
const confirmed: boolean = await Confirm.prompt({
message: question,
default: true,
});
return confirmed;
} else {
return true;
}
}
async function determineInstallDir(
dir: string,
allowPrompt: boolean,
embed?: string,
) {
if (embed) {
const extensionName = embed;
const context = createExtensionContext();
const extension = await context.extension(extensionName, dir);
if (extension) {
if (Object.keys(extension?.contributes.formats || {}).length > 0) {
return extension?.path;
} else {
throw new Error(
`The extension ${embed} does not contribute a format.\nYou can only embed extensions within an extension which itself contributes a format.`,
);
}
} else {
throw new Error(
`Unable to locate the extension '${embed}' that you'd like to embed this within.`,
);
}
} else {
const nbContext = notebookContext();
const project = await projectContext(dir, nbContext);
if (project && project.dir !== dir) {
const question = "Install extension into project?";
if (allowPrompt) {
const useProject = await Confirm.prompt(question);
if (useProject) {
return project.dir;
} else {
return dir;
}
} else {
return dir;
}
} else {
return dir;
}
}
}
async function stageExtension(
source: ExtensionSource,
workingDir: string,
) {
if (source.type === "remote") {
const archiveDir = join(workingDir, "archive");
ensureDirSync(archiveDir);
const filename = (typeof (source.resolvedTarget) === "string"
? source.resolvedTarget
: source.resolvedFile) || "extension.zip";
const toFile = join(archiveDir, filename);
await downloadWithProgress(source.resolvedTarget, `Downloading`, toFile);
return unzipAndStage(toFile, source);
} else {
if (typeof source.resolvedTarget !== "string") {
throw new InternalError(
"local resolved extension should always have a string target.",
);
}
if (Deno.statSync(source.resolvedTarget).isDirectory) {
const srcDir = extensionDir(source.resolvedTarget);
if (srcDir) {
const destDir = join(workingDir, kExtensionDir);
await readAndCopyExtensions(srcDir, destDir);
}
return workingDir;
} else {
const filename = basename(source.resolvedTarget);
const toFile = join(workingDir, filename);
copyTo(source.resolvedTarget, toFile);
return unzipAndStage(toFile, source);
}
}
}
async function unzipAndStage(
zipFile: string,
source: ExtensionSource,
) {
await withSpinner(
{ message: "Unzipping" },
async () => {
const result = await unzip(zipFile);
if (!result.success) {
throw new Error("Failed to unzip extension.\n" + result.stderr);
}
await Deno.remove(zipFile);
return Promise.resolve();
},
);
const archiveDir = dirname(zipFile);
const findExtensionDir = () => {
if (source.targetSubdir) {
const subDirPath = join(archiveDir, source.targetSubdir);
if (existsSync(subDirPath)) {
return subDirPath;
}
}
if (safeExistsSync(join(archiveDir, kExtensionDir))) {
return archiveDir;
} else {
const dirEntries = Deno.readDirSync(archiveDir);
let count = 0;
let name;
for (const dirEntry of dirEntries) {
if (dirEntry.isDirectory) {
name = dirEntry.name;
count++;
}
}
if (count === 1 && name && name !== kExtensionDir) {
if (safeExistsSync(join(archiveDir, name, kExtensionDir))) {
return join(archiveDir, name);
} else {
return archiveDir;
}
} else {
return archiveDir;
}
}
};
const extensionsDir = join(findExtensionDir(), kExtensionDir);
const finalDir = join(archiveDir, "staged");
await copyExtensions(source, extensionsDir, finalDir);
return finalDir;
}
export async function copyExtensions(
source: ExtensionSource,
srcDir: string,
targetDir: string,
) {
const finalExtensionsDir = join(targetDir, kExtensionDir);
const finalExtensionTargetDir = source.owner
? join(finalExtensionsDir, source.owner)
: finalExtensionsDir;
ensureDirSync(finalExtensionTargetDir);
await readAndCopyExtensions(srcDir, finalExtensionTargetDir);
}
async function readAndCopyExtensions(
extensionsDir: string,
targetDir: string,
) {
const extensions = await readExtensions(extensionsDir);
info(
` Found ${extensions.length} ${
extensions.length === 1 ? "extension" : "extensions"
}.`,
);
for (const extension of extensions) {
copyTo(
extension.path,
join(targetDir, extension.id.name),
);
}
}
async function validateExtension(path: string) {
const extensionsFolder = extensionDir(path);
if (!extensionsFolder) {
throw new Error(
`Invalid extension\nThe extension staged at ${path} is missing an '_extensions' folder.`,
);
}
const extensions = await readExtensions(extensionsFolder);
if (extensions.length === 0) {
throw new Error(
`Invalid extension\nThe extension staged at ${path} does not provide any valid extensions.`,
);
}
return extensions;
}
export interface ConfirmationOptions {
allowPrompt: boolean;
throw?: boolean;
message?: string;
}
export async function confirmInstallation(
extensions: Extension[],
installDir: string,
options: ConfirmationOptions,
) {
const readExisting = async () => {
try {
const existingExtensions = await readExtensions(
join(installDir, kExtensionDir),
);
return existingExtensions;
} catch {
return [];
}
};
const name = (extension: Extension) => {
const idStr = extension.id.organization
? `${extension.id.organization}/${extension.id.name}`
: extension.id.name;
return extension.title || idStr;
};
const existingExtensions = await readExisting();
const existing = (extension: Extension) => {
return existingExtensions.find((existing) => {
return existing.id.name === extension.id.name &&
existing.id.organization === extension.id.organization;
});
};
if (existingExtensions.length > 0 && !options.allowPrompt && options.throw) {
throw new Error(
`There are extensions installed which would be overwritten. Aborting installation.\n${
existingExtensions.map((ext) => {
return ext.title;
}).join("\n - ")
}`,
);
}
const typeStr = (to: Extension) => {
const contributes = to.contributes;
const extTypes: string[] = [];
if (
contributes.formats &&
Object.keys(contributes.formats).length > 0
) {
Object.keys(contributes.formats).length === 1
? extTypes.push("format")
: extTypes.push("formats");
}
if (
contributes.shortcodes &&
contributes.shortcodes.length > 0
) {
contributes.shortcodes.length === 1
? extTypes.push("shortcode")
: extTypes.push("shortcodes");
}
if (contributes.filters && contributes.filters.length > 0) {
contributes.filters.length === 1
? extTypes.push("filter")
: extTypes.push("filters");
}
if (extTypes.length > 0) {
return `(${extTypes.join(",")})`;
} else {
return "";
}
};
const versionMessage = (to: Extension, from?: Extension) => {
if (to && !from) {
const versionStr = to.version?.format();
return {
action: "Install",
from: "",
to: versionStr,
};
} else {
if (to.version && from?.version) {
const comparison = to.version.compare(from.version);
if (comparison === 0) {
return {
action: "No Change",
from: "",
to: "",
};
} else if (comparison > 0) {
return {
action: "Update",
from: from.version.format(),
to: to.version.format(),
};
} else {
return {
action: "Revert",
from: from.version.format(),
to: to.version.format(),
};
}
} else if (to.version && !from?.version) {
return {
action: "Update",
from: kUnversionedFrom,
to: to.version.format(),
};
} else if (!to.version && from?.version) {
return {
action: "Update",
from: from.version.format(),
to: kUnversionedTo,
};
} else {
return {
action: "Update",
from: kUnversionedFrom,
to: kUnversionedTo,
};
}
}
};
const extensionRows: string[][] = [];
for (const stagedExtension of extensions) {
const installedExtension = existing(stagedExtension);
const message = versionMessage(
stagedExtension,
installedExtension,
);
const types = typeStr(stagedExtension);
if (message) {
extensionRows.push([
name(stagedExtension) + " ",
`[${message.action}]`,
message.from || "",
message.to && message.from ? "->" : "",
message.to || "",
types,
]);
}
}
if (extensionRows.length > 0) {
const table = new Table(...extensionRows);
info(
`\n${
options.message || "The following changes will be made:"
}\n${table.toString()}`,
);
const question = "Would you like to continue";
return !options.allowPrompt ||
await Confirm.prompt({
message: question,
default: true,
});
} else {
info(`\nNo changes required - extensions already installed.`);
return true;
}
}
export async function completeInstallation(
downloadDir: string,
installDir: string,
) {
info("");
await withSpinner({
message: `Copying`,
}, async () => {
const stagingDir = join(installDir, "._extensions.staging");
try {
const downloadedExtDir = join(downloadDir, kExtensionDir);
const stagingExtDir = join(stagingDir, kExtensionDir);
ensureDirSync(stagingExtDir);
const installExtDir = join(installDir, kExtensionDir);
ensureDirSync(installExtDir);
const extensions = await readExtensions(downloadedExtDir);
extensions.forEach((extension) => {
const extensionRelativeDir = relative(downloadedExtDir, extension.path);
const stagingPath = join(stagingExtDir, extensionRelativeDir);
copyTo(extension.path, stagingPath);
const installPath = join(installExtDir, extensionRelativeDir);
if (existsSync(installPath)) {
safeRemoveSync(installPath, { recursive: true });
}
ensureDirSync(dirname(installPath));
Deno.renameSync(stagingPath, installPath);
});
} finally {
safeRemoveSync(stagingDir, { recursive: true });
}
return Promise.resolve();
});
}
const extensionDir = (path: string) => {
if (basename(path) === kExtensionDir) {
return path;
} else {
const extDir = join(path, kExtensionDir);
if (existsSync(extDir) && Deno.statSync(extDir).isDirectory) {
return extDir;
} else {
return path;
}
}
};