Path: blob/main/src/command/use/commands/template.ts
3587 views
/*1* template.ts2*3* Copyright (C) 2021-2022 Posit Software, PBC4*/56import {7ExtensionSource,8extensionSource,9} from "../../../extension/extension-host.ts";10import { info } from "../../../deno_ral/log.ts";11import { Confirm, Input } from "cliffy/prompt/mod.ts";12import { basename, dirname, join, relative } from "../../../deno_ral/path.ts";13import { ensureDir, ensureDirSync, existsSync } from "../../../deno_ral/fs.ts";14import { TempContext } from "../../../core/temp-types.ts";15import { downloadWithProgress } from "../../../core/download.ts";16import { withSpinner } from "../../../core/console.ts";17import { unzip } from "../../../core/zip.ts";18import { templateFiles } from "../../../extension/template.ts";19import { Command } from "cliffy/command/mod.ts";20import { initYamlIntelligenceResourcesFromFilesystem } from "../../../core/schema/utils.ts";21import { createTempContext } from "../../../core/temp.ts";22import {23completeInstallation,24confirmInstallation,25copyExtensions,26} from "../../../extension/install.ts";27import { kExtensionDir } from "../../../extension/constants.ts";28import { InternalError } from "../../../core/lib/error.ts";29import { readExtensions } from "../../../extension/extension.ts";3031const kRootTemplateName = "template.qmd";3233export const useTemplateCommand = new Command()34.name("template")35.arguments("<target:string>")36.description(37"Use a Quarto template for this directory or project.",38)39.option(40"--no-prompt",41"Do not prompt to confirm actions",42)43.example(44"Use a template from Github",45"quarto use template <gh-org>/<gh-repo>",46)47.action(async (options: { prompt?: boolean }, target: string) => {48await initYamlIntelligenceResourcesFromFilesystem();49const temp = createTempContext();50try {51await useTemplate(options, target, temp);52} finally {53temp.cleanup();54}55});5657async function useTemplate(58options: { prompt?: boolean },59target: string,60tempContext: TempContext,61) {62// Resolve extension host and trust63const source = await extensionSource(target);64// Is this source valid?65if (!source) {66info(67`Extension not found in local or remote sources`,68);69return;70}71const trusted = await isTrusted(source, options.prompt !== false);72if (trusted) {73// Resolve target directory74const outputDirectory = await determineDirectory(options.prompt !== false);7576// Extract and move the template into place77const stagedDir = await stageTemplate(source, tempContext);7879// Filter the list to template files80const filesToCopy = templateFiles(stagedDir);8182// Compute extensions that need to be installed (and confirm any)83// changes84const extDir = join(stagedDir, kExtensionDir);8586// Determine whether we can update extensions87const templateExtensions = await readExtensions(extDir);88const installedExtensions = [];89let installExtensions = false;90if (templateExtensions.length > 0) {91installExtensions = await confirmInstallation(92templateExtensions,93outputDirectory,94{95allowPrompt: options.prompt !== false,96throw: true,97message: "The template requires the following changes to extensions:",98},99);100if (!installExtensions) {101return;102}103}104105// Confirm any overwrites106info(107`\nPreparing template files...`,108);109110const copyActions: Array<{ file: string; copy: () => Promise<void> }> = [];111for (const fileToCopy of filesToCopy) {112const isDir = Deno.statSync(fileToCopy).isDirectory;113const rel = relative(stagedDir, fileToCopy);114if (!isDir) {115// Compute the paths116let target = join(outputDirectory, rel);117let displayName = rel;118const targetDir = dirname(target);119if (rel === kRootTemplateName) {120displayName = `${basename(targetDir)}.qmd`;121target = join(targetDir, displayName);122}123const copyAction = {124file: displayName,125copy: async () => {126// Ensure the directory exists127await ensureDir(targetDir);128129// Copy the file into place130await Deno.copyFile(fileToCopy, target);131},132};133134if (existsSync(target)) {135if (options.prompt) {136const proceed = await Confirm.prompt({137message: `Overwrite file ${displayName}?`,138});139if (proceed) {140copyActions.push(copyAction);141}142} else {143throw new Error(144`The file ${displayName} already exists and would be overwritten by this action.`,145);146}147} else {148copyActions.push(copyAction);149}150}151}152153if (installExtensions) {154installedExtensions.push(...templateExtensions);155await withSpinner({ message: "Installing extensions..." }, async () => {156// Copy the extensions into a substaging directory157// this will ensure that they are namespaced properly158const subStagedDir = tempContext.createDir();159await copyExtensions(source, stagedDir, subStagedDir);160161// Now complete installation from this sub-staged directory162await completeInstallation(subStagedDir, outputDirectory);163});164}165166// Copy the files167if (copyActions.length > 0) {168await withSpinner({ message: "Copying files..." }, async () => {169for (const copyAction of copyActions) {170await copyAction.copy();171}172});173}174175if (installedExtensions.length > 0) {176info(177`\nExtensions installed:`,178);179for (const extension of installedExtensions) {180info(` - ${extension.title}`);181}182}183184if (copyActions.length > 0) {185info(186`\nFiles created:`,187);188for (const copyAction of copyActions) {189info(` - ${copyAction.file}`);190}191}192} else {193return Promise.resolve();194}195}196197async function stageTemplate(198source: ExtensionSource,199tempContext: TempContext,200) {201if (source.type === "remote") {202// A temporary working directory203const workingDir = tempContext.createDir();204205// Stages a remote file by downloading and unzipping it206const archiveDir = join(workingDir, "archive");207ensureDirSync(archiveDir);208209// The filename210const filename = (typeof (source.resolvedTarget) === "string"211? source.resolvedTarget212: source.resolvedFile) || "extension.zip";213214// The tarball path215const toFile = join(archiveDir, filename);216217// Download the file218await downloadWithProgress(source.resolvedTarget, `Downloading`, toFile);219220// Unzip and remove zip221await unzipInPlace(toFile);222223// Try to find the correct sub directory224if (source.targetSubdir) {225const sourceSubDir = join(archiveDir, source.targetSubdir);226if (existsSync(sourceSubDir)) {227return sourceSubDir;228}229}230231// Couldn't find a source sub dir, see if there is only a single232// subfolder and if so use that233const dirEntries = Deno.readDirSync(archiveDir);234let count = 0;235let name;236let hasFiles = false;237for (const dirEntry of dirEntries) {238// ignore any files239if (dirEntry.isDirectory) {240name = dirEntry.name;241count++;242} else {243hasFiles = true;244}245}246// there is a lone subfolder - use that.247if (!hasFiles && count === 1 && name) {248return join(archiveDir, name);249}250251return archiveDir;252} else {253if (typeof source.resolvedTarget !== "string") {254throw new InternalError(255"Local resolved extension should always have a string target.",256);257}258259if (Deno.statSync(source.resolvedTarget).isDirectory) {260// copy the contents of the directory, filtered by quartoignore261return source.resolvedTarget;262} else {263// A temporary working directory264const workingDir = tempContext.createDir();265const targetFile = join(workingDir, basename(source.resolvedTarget));266267// Copy the zip to the working dir268Deno.copyFileSync(269source.resolvedTarget,270targetFile,271);272273await unzipInPlace(targetFile);274return workingDir;275}276}277}278279// Determines whether the user trusts the template280async function isTrusted(281source: ExtensionSource,282allowPrompt: boolean,283): Promise<boolean> {284if (allowPrompt && source.type === "remote") {285// Write the preamble286const preamble =287`\nQuarto templates may execute code when documents are rendered. If you do not \ntrust the authors of the template, we recommend that you do not install or \nuse the template.`;288info(preamble);289290// Ask for trust291const question = "Do you trust the authors of this template";292const confirmed: boolean = await Confirm.prompt({293message: question,294default: true,295});296return confirmed;297} else {298return true;299}300}301302async function determineDirectory(allowPrompt: boolean) {303const currentDir = Deno.cwd();304if (!allowPrompt) {305// If we can't prompt, we'll use either the current directory (if empty), or throw306if (!directoryEmpty(currentDir)) {307throw new Error(308`Unable to install in ${currentDir} as the directory isn't empty.`,309);310} else {311return currentDir;312}313} else {314return promptForDirectory(currentDir, directoryEmpty(currentDir));315}316}317318async function promptForDirectory(root: string, isEmpty: boolean) {319// Try to short directory creation320const useSubDir = await Confirm.prompt({321message: "Create a subdirectory for template?",322default: !isEmpty,323hint:324"Use a subdirectory for the template rather than the current directory.",325});326if (!useSubDir) {327return root;328}329330const dirName = await Input.prompt({331message: "Directory name:",332validate: (input) => {333if (input.length === 0 || input === ".") {334return true;335}336337const dir = join(root, input);338if (!existsSync(dir)) {339ensureDirSync(dir);340}341return true;342},343});344if (dirName.length === 0 || dirName === ".") {345return root;346} else {347return join(root, dirName);348}349}350351// Unpack and stage a zipped file352async function unzipInPlace(zipFile: string) {353// Unzip the file354await withSpinner(355{ message: "Unzipping" },356async () => {357// Unzip the archive358const result = await unzip(zipFile);359if (!result.success) {360throw new Error("Failed to unzip template.\n" + result.stderr);361}362363// Remove the tar ball itself364await Deno.remove(zipFile);365366return Promise.resolve();367},368);369}370371function directoryEmpty(path: string) {372const dirContents = Deno.readDirSync(path);373for (const _content of dirContents) {374return false;375}376return true;377}378379380