Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
quarto-dev
GitHub Repository: quarto-dev/quarto-cli
Path: blob/main/src/extension/extension-host.ts
6442 views
1
/*
2
* extension-host.ts
3
*
4
* Copyright (C) 2020-2022 Posit Software, PBC
5
*/
6
7
import { existsSync } from "../deno_ral/fs.ts";
8
import { isWindows } from "../deno_ral/platform.ts";
9
10
export interface ResolvedExtensionInfo {
11
// The url to the resolved extension
12
url: string;
13
14
// The file part of the url resolved
15
urlFile?: string;
16
17
// The Fetch Response from fetching that URL
18
response: Promise<Response>;
19
20
// The directory that should be used when attempting to extract
21
// the extension from the resolved archive
22
subdirectory?: string;
23
24
// The owner information for this extension
25
owner?: string;
26
27
// The learn more url for this extension
28
learnMoreUrl?: string;
29
}
30
31
export interface ExtensionSource {
32
type: "remote" | "local";
33
owner?: string;
34
resolvedTarget: Response | string;
35
resolvedFile?: string;
36
targetSubdir?: string;
37
learnMoreUrl?: string;
38
}
39
40
export async function extensionSource(
41
target: string,
42
): Promise<ExtensionSource | undefined> {
43
if (!target.match(/^https?:\/\/.*$/) && existsSync(target)) {
44
return { type: "local", resolvedTarget: target };
45
}
46
47
for (const resolver of extensionHostResolvers) {
48
const resolved = resolver(target);
49
if (!resolved) {
50
continue;
51
}
52
try {
53
const response = await resolved.response;
54
if (response.status === 200) {
55
return {
56
type: "remote",
57
resolvedTarget: response,
58
resolvedFile: resolved?.urlFile,
59
owner: resolved?.owner,
60
targetSubdir: resolved?.subdirectory,
61
learnMoreUrl: resolved?.learnMoreUrl,
62
};
63
}
64
} catch (err) {
65
if (!(err instanceof Error)) {
66
throw err;
67
}
68
err.message =
69
`A network error occurred when attempting to inspect the extension '${target}'. Please try again.\n\n` +
70
err.message;
71
throw err;
72
}
73
}
74
75
return undefined;
76
}
77
78
type ExtensionNameResolver = (
79
name: string,
80
) => ResolvedExtensionInfo | undefined;
81
82
// A single source for extensions (the name can be parsed)
83
// and turned into a url (for example, GitHub is a source)
84
interface ExtensionHostSource {
85
parse(name: string): ExtensionHost | undefined;
86
urlProviders: ExtensionUrlProvider[];
87
}
88
89
// The definition of an extension host which can be used to
90
// form a url
91
interface ExtensionHost {
92
name: string;
93
organization: string;
94
repo: string;
95
modifier?: string;
96
subdirectory?: string;
97
}
98
99
// Converts a parsed extension host into a url
100
interface ExtensionUrlProvider {
101
// The url from which to download the extension archive
102
extensionUrl: (host: ExtensionHost) => string | undefined;
103
// The subdirectory to use within the downloaded archive
104
archiveSubdir: (host: ExtensionHost) => string | undefined;
105
// The url the user may use to learn more
106
learnMoreUrl: (host: ExtensionHost) => string | undefined;
107
}
108
109
const archiveExt = isWindows ? ".zip" : ".tar.gz";
110
111
const githubLatestUrlProvider = {
112
extensionUrl: (host: ExtensionHost) => {
113
if (host.modifier === undefined || host.modifier === "latest") {
114
return `https://github.com/${host.organization}/${host.repo}/archive/refs/heads/main${archiveExt}`;
115
}
116
},
117
archiveSubdir: (host: ExtensionHost) => {
118
const baseDir = `${host.repo}-main`;
119
if (host.subdirectory) {
120
return baseDir + "/" + host.subdirectory;
121
} else {
122
return baseDir;
123
}
124
},
125
learnMoreUrl: (host: ExtensionHost) => {
126
return `https://www.github.com/${host.organization}/${host.repo}#readme`;
127
},
128
};
129
130
const githubTagUrlProvider = {
131
extensionUrl: (host: ExtensionHost) => {
132
if (host.modifier) {
133
return `https://github.com/${host.organization}/${host.repo}/archive/refs/tags/${host.modifier}${archiveExt}`;
134
}
135
},
136
archiveSubdir: (host: ExtensionHost) => {
137
const baseDir = `${host.repo}-${tagSubDirectory(host.modifier)}`;
138
if (host.subdirectory) {
139
return baseDir + "/" + host.subdirectory;
140
} else {
141
return baseDir;
142
}
143
},
144
learnMoreUrl: (host: ExtensionHost) => {
145
return `https://github.com/${host.organization}/${host.repo}/tree/${host.modifier}#readme`;
146
},
147
};
148
149
const githubBranchUrlProvider = {
150
extensionUrl: (host: ExtensionHost) => {
151
if (host.modifier) {
152
return `https://github.com/${host.organization}/${host.repo}/archive/refs/heads/${host.modifier}${archiveExt}`;
153
}
154
},
155
archiveSubdir: (host: ExtensionHost) => {
156
const baseDir = `${host.repo}-${host.modifier}`;
157
if (host.subdirectory) {
158
return baseDir + "/" + host.subdirectory;
159
} else {
160
return baseDir;
161
}
162
},
163
learnMoreUrl: (host: ExtensionHost) => {
164
return `https://github.com/${host.organization}/${host.repo}/tree/${host.modifier}#readme`;
165
},
166
};
167
168
// The github extension source
169
const kGithubExtensionSource: ExtensionHostSource = {
170
parse: (name: string) => {
171
const match = name.match(kGithubExtensionNameRegex);
172
if (match) {
173
return {
174
name,
175
organization: match[1],
176
repo: match[2],
177
subdirectory: match[3],
178
modifier: match[4],
179
};
180
} else {
181
return undefined;
182
}
183
},
184
urlProviders: [
185
githubLatestUrlProvider,
186
githubTagUrlProvider,
187
githubBranchUrlProvider,
188
],
189
};
190
const kGithubExtensionNameRegex =
191
/^([a-zA-Z0-9-_\.]*?)\/([a-zA-Z0-9-_\.]*?)(?:\/(.*?)\/?)?(?:@([a-zA-Z0-9-_\.\/]*))?$/;
192
193
const kGithubArchiveUrlSource: ExtensionHostSource = {
194
parse: (name: string) => {
195
const match = name.match(kGithubArchiveUrlRegex);
196
if (match) {
197
return {
198
name,
199
organization: match[1],
200
repo: match[2],
201
subdirectory: undefined, // Subdirectories aren't support for archive urls
202
modifier: match[3],
203
};
204
} else {
205
return undefined;
206
}
207
},
208
urlProviders: [
209
{
210
extensionUrl: (host: ExtensionHost) => host.name,
211
archiveSubdir: (host: ExtensionHost) => {
212
return `${host.repo}-${tagSubDirectory(host.modifier)}`;
213
},
214
learnMoreUrl: (host: ExtensionHost) =>
215
`https://www.github.com/${host.organization}/${host.repo}`,
216
},
217
],
218
};
219
220
const kGithubArchiveUrlRegex =
221
/https?\:\/\/github.com\/([a-zA-Z0-9-_\.]+?)\/([a-zA-Z0-9-_\.]+?)\/archive\/refs\/(?:tags|heads)\/(.+)(?:\.tar\.gz|\.zip)$/;
222
223
function tagSubDirectory(tag?: string) {
224
// Strip the leading 'v' from tag names
225
if (tag) {
226
return tag.startsWith("v") ? tag.slice(1) : tag;
227
} else {
228
return tag;
229
}
230
}
231
232
function makeResolvers(
233
source: ExtensionHostSource,
234
): ExtensionNameResolver[] {
235
return source.urlProviders.map((urlProvider) => {
236
return (name) => {
237
const host = source.parse(name);
238
if (!host) {
239
return undefined;
240
}
241
const url = urlProvider.extensionUrl(host);
242
if (url) {
243
return {
244
url,
245
urlFile: url.split("/").pop(),
246
response: fetch(url),
247
owner: host.organization,
248
subdirectory: urlProvider.archiveSubdir(host),
249
learnMoreUrl: urlProvider.learnMoreUrl(host),
250
};
251
} else {
252
return undefined;
253
}
254
};
255
});
256
}
257
258
const kGithubResolvers = [
259
...makeResolvers(kGithubExtensionSource),
260
...makeResolvers(kGithubArchiveUrlSource),
261
];
262
263
// This just resolves unknown URLs that are not resolved
264
// by any other resolver (allowing for installation of extensions
265
// from arbitrary urls)
266
const unknownUrlResolver = (
267
name: string,
268
): ResolvedExtensionInfo | undefined => {
269
try {
270
new URL(name);
271
} catch {
272
// That isn't a url, sadly
273
return undefined;
274
}
275
276
return {
277
url: name,
278
response: fetch(name),
279
};
280
};
281
282
const extensionHostResolvers: ExtensionNameResolver[] = [
283
...kGithubResolvers,
284
unknownUrlResolver,
285
];
286
287