Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
quarto-dev
GitHub Repository: quarto-dev/quarto-cli
Path: blob/main/src/command/use/commands/template.ts
3587 views
1
/*
2
* template.ts
3
*
4
* Copyright (C) 2021-2022 Posit Software, PBC
5
*/
6
7
import {
8
ExtensionSource,
9
extensionSource,
10
} from "../../../extension/extension-host.ts";
11
import { info } from "../../../deno_ral/log.ts";
12
import { Confirm, Input } from "cliffy/prompt/mod.ts";
13
import { basename, dirname, join, relative } from "../../../deno_ral/path.ts";
14
import { ensureDir, ensureDirSync, existsSync } from "../../../deno_ral/fs.ts";
15
import { TempContext } from "../../../core/temp-types.ts";
16
import { downloadWithProgress } from "../../../core/download.ts";
17
import { withSpinner } from "../../../core/console.ts";
18
import { unzip } from "../../../core/zip.ts";
19
import { templateFiles } from "../../../extension/template.ts";
20
import { Command } from "cliffy/command/mod.ts";
21
import { initYamlIntelligenceResourcesFromFilesystem } from "../../../core/schema/utils.ts";
22
import { createTempContext } from "../../../core/temp.ts";
23
import {
24
completeInstallation,
25
confirmInstallation,
26
copyExtensions,
27
} from "../../../extension/install.ts";
28
import { kExtensionDir } from "../../../extension/constants.ts";
29
import { InternalError } from "../../../core/lib/error.ts";
30
import { readExtensions } from "../../../extension/extension.ts";
31
32
const kRootTemplateName = "template.qmd";
33
34
export const useTemplateCommand = new Command()
35
.name("template")
36
.arguments("<target:string>")
37
.description(
38
"Use a Quarto template for this directory or project.",
39
)
40
.option(
41
"--no-prompt",
42
"Do not prompt to confirm actions",
43
)
44
.example(
45
"Use a template from Github",
46
"quarto use template <gh-org>/<gh-repo>",
47
)
48
.action(async (options: { prompt?: boolean }, target: string) => {
49
await initYamlIntelligenceResourcesFromFilesystem();
50
const temp = createTempContext();
51
try {
52
await useTemplate(options, target, temp);
53
} finally {
54
temp.cleanup();
55
}
56
});
57
58
async function useTemplate(
59
options: { prompt?: boolean },
60
target: string,
61
tempContext: TempContext,
62
) {
63
// Resolve extension host and trust
64
const source = await extensionSource(target);
65
// Is this source valid?
66
if (!source) {
67
info(
68
`Extension not found in local or remote sources`,
69
);
70
return;
71
}
72
const trusted = await isTrusted(source, options.prompt !== false);
73
if (trusted) {
74
// Resolve target directory
75
const outputDirectory = await determineDirectory(options.prompt !== false);
76
77
// Extract and move the template into place
78
const stagedDir = await stageTemplate(source, tempContext);
79
80
// Filter the list to template files
81
const filesToCopy = templateFiles(stagedDir);
82
83
// Compute extensions that need to be installed (and confirm any)
84
// changes
85
const extDir = join(stagedDir, kExtensionDir);
86
87
// Determine whether we can update extensions
88
const templateExtensions = await readExtensions(extDir);
89
const installedExtensions = [];
90
let installExtensions = false;
91
if (templateExtensions.length > 0) {
92
installExtensions = await confirmInstallation(
93
templateExtensions,
94
outputDirectory,
95
{
96
allowPrompt: options.prompt !== false,
97
throw: true,
98
message: "The template requires the following changes to extensions:",
99
},
100
);
101
if (!installExtensions) {
102
return;
103
}
104
}
105
106
// Confirm any overwrites
107
info(
108
`\nPreparing template files...`,
109
);
110
111
const copyActions: Array<{ file: string; copy: () => Promise<void> }> = [];
112
for (const fileToCopy of filesToCopy) {
113
const isDir = Deno.statSync(fileToCopy).isDirectory;
114
const rel = relative(stagedDir, fileToCopy);
115
if (!isDir) {
116
// Compute the paths
117
let target = join(outputDirectory, rel);
118
let displayName = rel;
119
const targetDir = dirname(target);
120
if (rel === kRootTemplateName) {
121
displayName = `${basename(targetDir)}.qmd`;
122
target = join(targetDir, displayName);
123
}
124
const copyAction = {
125
file: displayName,
126
copy: async () => {
127
// Ensure the directory exists
128
await ensureDir(targetDir);
129
130
// Copy the file into place
131
await Deno.copyFile(fileToCopy, target);
132
},
133
};
134
135
if (existsSync(target)) {
136
if (options.prompt) {
137
const proceed = await Confirm.prompt({
138
message: `Overwrite file ${displayName}?`,
139
});
140
if (proceed) {
141
copyActions.push(copyAction);
142
}
143
} else {
144
throw new Error(
145
`The file ${displayName} already exists and would be overwritten by this action.`,
146
);
147
}
148
} else {
149
copyActions.push(copyAction);
150
}
151
}
152
}
153
154
if (installExtensions) {
155
installedExtensions.push(...templateExtensions);
156
await withSpinner({ message: "Installing extensions..." }, async () => {
157
// Copy the extensions into a substaging directory
158
// this will ensure that they are namespaced properly
159
const subStagedDir = tempContext.createDir();
160
await copyExtensions(source, stagedDir, subStagedDir);
161
162
// Now complete installation from this sub-staged directory
163
await completeInstallation(subStagedDir, outputDirectory);
164
});
165
}
166
167
// Copy the files
168
if (copyActions.length > 0) {
169
await withSpinner({ message: "Copying files..." }, async () => {
170
for (const copyAction of copyActions) {
171
await copyAction.copy();
172
}
173
});
174
}
175
176
if (installedExtensions.length > 0) {
177
info(
178
`\nExtensions installed:`,
179
);
180
for (const extension of installedExtensions) {
181
info(` - ${extension.title}`);
182
}
183
}
184
185
if (copyActions.length > 0) {
186
info(
187
`\nFiles created:`,
188
);
189
for (const copyAction of copyActions) {
190
info(` - ${copyAction.file}`);
191
}
192
}
193
} else {
194
return Promise.resolve();
195
}
196
}
197
198
async function stageTemplate(
199
source: ExtensionSource,
200
tempContext: TempContext,
201
) {
202
if (source.type === "remote") {
203
// A temporary working directory
204
const workingDir = tempContext.createDir();
205
206
// Stages a remote file by downloading and unzipping it
207
const archiveDir = join(workingDir, "archive");
208
ensureDirSync(archiveDir);
209
210
// The filename
211
const filename = (typeof (source.resolvedTarget) === "string"
212
? source.resolvedTarget
213
: source.resolvedFile) || "extension.zip";
214
215
// The tarball path
216
const toFile = join(archiveDir, filename);
217
218
// Download the file
219
await downloadWithProgress(source.resolvedTarget, `Downloading`, toFile);
220
221
// Unzip and remove zip
222
await unzipInPlace(toFile);
223
224
// Try to find the correct sub directory
225
if (source.targetSubdir) {
226
const sourceSubDir = join(archiveDir, source.targetSubdir);
227
if (existsSync(sourceSubDir)) {
228
return sourceSubDir;
229
}
230
}
231
232
// Couldn't find a source sub dir, see if there is only a single
233
// subfolder and if so use that
234
const dirEntries = Deno.readDirSync(archiveDir);
235
let count = 0;
236
let name;
237
let hasFiles = false;
238
for (const dirEntry of dirEntries) {
239
// ignore any files
240
if (dirEntry.isDirectory) {
241
name = dirEntry.name;
242
count++;
243
} else {
244
hasFiles = true;
245
}
246
}
247
// there is a lone subfolder - use that.
248
if (!hasFiles && count === 1 && name) {
249
return join(archiveDir, name);
250
}
251
252
return archiveDir;
253
} else {
254
if (typeof source.resolvedTarget !== "string") {
255
throw new InternalError(
256
"Local resolved extension should always have a string target.",
257
);
258
}
259
260
if (Deno.statSync(source.resolvedTarget).isDirectory) {
261
// copy the contents of the directory, filtered by quartoignore
262
return source.resolvedTarget;
263
} else {
264
// A temporary working directory
265
const workingDir = tempContext.createDir();
266
const targetFile = join(workingDir, basename(source.resolvedTarget));
267
268
// Copy the zip to the working dir
269
Deno.copyFileSync(
270
source.resolvedTarget,
271
targetFile,
272
);
273
274
await unzipInPlace(targetFile);
275
return workingDir;
276
}
277
}
278
}
279
280
// Determines whether the user trusts the template
281
async function isTrusted(
282
source: ExtensionSource,
283
allowPrompt: boolean,
284
): Promise<boolean> {
285
if (allowPrompt && source.type === "remote") {
286
// Write the preamble
287
const preamble =
288
`\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.`;
289
info(preamble);
290
291
// Ask for trust
292
const question = "Do you trust the authors of this template";
293
const confirmed: boolean = await Confirm.prompt({
294
message: question,
295
default: true,
296
});
297
return confirmed;
298
} else {
299
return true;
300
}
301
}
302
303
async function determineDirectory(allowPrompt: boolean) {
304
const currentDir = Deno.cwd();
305
if (!allowPrompt) {
306
// If we can't prompt, we'll use either the current directory (if empty), or throw
307
if (!directoryEmpty(currentDir)) {
308
throw new Error(
309
`Unable to install in ${currentDir} as the directory isn't empty.`,
310
);
311
} else {
312
return currentDir;
313
}
314
} else {
315
return promptForDirectory(currentDir, directoryEmpty(currentDir));
316
}
317
}
318
319
async function promptForDirectory(root: string, isEmpty: boolean) {
320
// Try to short directory creation
321
const useSubDir = await Confirm.prompt({
322
message: "Create a subdirectory for template?",
323
default: !isEmpty,
324
hint:
325
"Use a subdirectory for the template rather than the current directory.",
326
});
327
if (!useSubDir) {
328
return root;
329
}
330
331
const dirName = await Input.prompt({
332
message: "Directory name:",
333
validate: (input) => {
334
if (input.length === 0 || input === ".") {
335
return true;
336
}
337
338
const dir = join(root, input);
339
if (!existsSync(dir)) {
340
ensureDirSync(dir);
341
}
342
return true;
343
},
344
});
345
if (dirName.length === 0 || dirName === ".") {
346
return root;
347
} else {
348
return join(root, dirName);
349
}
350
}
351
352
// Unpack and stage a zipped file
353
async function unzipInPlace(zipFile: string) {
354
// Unzip the file
355
await withSpinner(
356
{ message: "Unzipping" },
357
async () => {
358
// Unzip the archive
359
const result = await unzip(zipFile);
360
if (!result.success) {
361
throw new Error("Failed to unzip template.\n" + result.stderr);
362
}
363
364
// Remove the tar ball itself
365
await Deno.remove(zipFile);
366
367
return Promise.resolve();
368
},
369
);
370
}
371
372
function directoryEmpty(path: string) {
373
const dirContents = Deno.readDirSync(path);
374
for (const _content of dirContents) {
375
return false;
376
}
377
return true;
378
}
379
380