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