Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
quarto-dev
GitHub Repository: quarto-dev/quarto-cli
Path: blob/main/src/publish/rsconnect/rsconnect.ts
6455 views
1
/*
2
* rsconnect.ts
3
*
4
* Copyright (C) 2020-2022 Posit Software, PBC
5
*/
6
import { info } from "../../deno_ral/log.ts";
7
import * as colors from "fmt/colors";
8
9
import { Input } from "cliffy/prompt/input.ts";
10
import { Secret } from "cliffy/prompt/secret.ts";
11
12
import {
13
AccountToken,
14
AccountTokenType,
15
PublishFiles,
16
PublishProvider,
17
} from "../provider-types.ts";
18
import { ApiError, PublishOptions, PublishRecord } from "../types.ts";
19
import { RSConnectClient } from "./api/index.ts";
20
import { Content, Task } from "./api/types.ts";
21
import {
22
readAccessTokens,
23
writeAccessToken,
24
writeAccessTokens,
25
} from "../common/account.ts";
26
import { ensureProtocolAndTrailingSlash } from "../../core/url.ts";
27
28
import { createTempContext } from "../../core/temp.ts";
29
import { completeMessage, withSpinner } from "../../core/console.ts";
30
import { randomHex } from "../../core/random.ts";
31
import { RenderFlags } from "../../command/render/types.ts";
32
import { createBundle } from "../common/bundle.ts";
33
34
export const kRSConnect = "connect";
35
const kRSConnectDescription = "Posit Connect";
36
37
export const kRSConnectServerVar = "CONNECT_SERVER";
38
export const kRSConnectAuthTokenVar = "CONNECT_API_KEY";
39
40
export const rsconnectProvider: PublishProvider = {
41
name: kRSConnect,
42
description: kRSConnectDescription,
43
requiresServer: true,
44
listOriginOnly: true,
45
accountTokens,
46
authorizeToken,
47
removeToken,
48
resolveTarget,
49
publish,
50
isUnauthorized,
51
isNotFound,
52
};
53
54
type Account = {
55
username: string;
56
server: string;
57
key: string;
58
};
59
60
function accountTokens() {
61
const accounts: AccountToken[] = [];
62
63
// check for environment variable
64
const server = Deno.env.get(kRSConnectServerVar);
65
const apiKey = Deno.env.get(kRSConnectAuthTokenVar);
66
if (server && apiKey) {
67
accounts.push({
68
type: AccountTokenType.Environment,
69
name: kRSConnectAuthTokenVar,
70
server,
71
token: apiKey,
72
});
73
}
74
75
// check for recorded tokens
76
const tokens = readAccessTokens<Account>(kRSConnect);
77
if (tokens) {
78
accounts.push(...tokens.map((token) => ({
79
type: AccountTokenType.Authorized,
80
name: token.username,
81
server: token.server,
82
token: token.key,
83
})));
84
}
85
86
return Promise.resolve(accounts);
87
}
88
89
function removeToken(token: AccountToken) {
90
writeAccessTokens(
91
rsconnectProvider.name,
92
readAccessTokens<Account>(rsconnectProvider.name)?.filter(
93
(accessToken) => {
94
return accessToken.server !== token.server &&
95
accessToken.username !== token.name;
96
},
97
) || [],
98
);
99
}
100
101
async function authorizeToken(
102
options: PublishOptions,
103
target?: PublishRecord,
104
): Promise<AccountToken | undefined> {
105
// ask for server (then validate that its actually a connect server
106
// by sending a request without an auth token)
107
let server = target?.url
108
? new URL(target.url).origin
109
: options.server || undefined;
110
if (server) {
111
server = ensureProtocolAndTrailingSlash(server);
112
}
113
while (server === undefined) {
114
// prompt for server
115
server = await Input.prompt({
116
message: "Server URL:",
117
hint: "e.g. https://connect.example.com/",
118
validate: (value) => {
119
// 'Enter' with no value ends publish
120
if (value.length === 0) {
121
throw new Error();
122
}
123
try {
124
const url = new URL(ensureProtocolAndTrailingSlash(value));
125
if (!["http:", "https:"].includes(url.protocol)) {
126
return `${value} is not an HTTP URL`;
127
} else {
128
return true;
129
}
130
} catch {
131
return `${value} is not a valid URL`;
132
}
133
},
134
transform: ensureProtocolAndTrailingSlash,
135
});
136
137
// validate that its a connect server
138
const client = new RSConnectClient(server);
139
try {
140
await client.getUser();
141
} catch (err) {
142
if (!(err instanceof Error)) {
143
throw err;
144
}
145
// connect server will give 401 for unauthorized, break out
146
// of the loop in that case
147
if (isUnauthorized(err)) {
148
break;
149
} else {
150
info(
151
colors.red(
152
" Unable to connect to server (is this a valid Posit Connect Server?)",
153
),
154
);
155
server = undefined;
156
}
157
}
158
}
159
160
// get apiKey and username
161
while (true) {
162
const apiKey = await Secret.prompt({
163
message: "API Key:",
164
hint: "Learn more at https://docs.rstudio.com/connect/user/api-keys/",
165
});
166
// 'Enter' with no value ends publish
167
if (apiKey.length === 0) {
168
throw new Error();
169
}
170
// get the user info
171
try {
172
const client = new RSConnectClient(server, apiKey);
173
const user = await client.getUser();
174
if (user.user_role !== "viewer") {
175
// record account
176
const account: Account = {
177
username: user.username,
178
server,
179
key: apiKey,
180
};
181
writeAccessToken(
182
kRSConnect,
183
account,
184
(a, b) => (a.server === b.server) && (a.username === b.username),
185
);
186
// return access token
187
return {
188
type: AccountTokenType.Authorized,
189
name: user.username,
190
server,
191
token: apiKey,
192
};
193
} else {
194
promptError(
195
"API key is for an Posit Connect viewer rather than a publisher.",
196
);
197
}
198
} catch (err) {
199
if (!(err instanceof Error)) {
200
throw err;
201
}
202
if (isUnauthorized(err)) {
203
promptError(
204
"API key is not authorized for this Posit Connect server.",
205
);
206
} else {
207
throw err;
208
}
209
}
210
}
211
}
212
213
async function resolveTarget(
214
account: AccountToken,
215
target: PublishRecord,
216
): Promise<PublishRecord | undefined> {
217
const client = new RSConnectClient(account.server!, account.token);
218
const content = await client.getContent(target.id);
219
return contentAsTarget(content);
220
}
221
222
async function publish(
223
account: AccountToken,
224
type: "document" | "site",
225
_input: string,
226
title: string,
227
slug: string,
228
render: (flags?: RenderFlags) => Promise<PublishFiles>,
229
_options: PublishOptions,
230
target?: PublishRecord,
231
): Promise<[PublishRecord, URL]> {
232
// create client
233
const client = new RSConnectClient(account.server!, account.token);
234
235
let content: Content | undefined;
236
await withSpinner({
237
message: `Preparing to publish ${type}`,
238
}, async () => {
239
if (!target) {
240
content = await createContent(client, title, slug);
241
if (content) {
242
target = contentAsTarget(content);
243
} else {
244
throw new Error();
245
}
246
} else {
247
content = await client.getContent(target!.id);
248
}
249
});
250
info("");
251
252
// render
253
const publishFiles = await render();
254
255
// publish
256
const tempContext = createTempContext();
257
try {
258
// create and upload bundle
259
let task: Task | undefined;
260
await withSpinner({
261
message: () => `Uploading files`,
262
}, async () => {
263
const { bundlePath } = await createBundle(
264
type,
265
publishFiles,
266
tempContext,
267
);
268
const bundleBytes = Deno.readFileSync(bundlePath);
269
const bundleBlob = new Blob([bundleBytes.buffer]);
270
const bundle = await client.uploadBundle(target!.id, bundleBlob);
271
task = await client.deployBundle(bundle);
272
});
273
274
await withSpinner({
275
message: `Publishing ${type}`,
276
}, async () => {
277
while (true) {
278
const status = await client.getTaskStatus(task!);
279
if (status.finished) {
280
if (status.code === 0) {
281
break;
282
} else {
283
throw new Error(
284
`Error attempting to publish content: ${status.code} - ${status.error}`,
285
);
286
}
287
}
288
}
289
});
290
completeMessage(`Published: ${content!.content_url}\n`);
291
return Promise.resolve([target!, new URL(content!.dashboard_url)]);
292
} finally {
293
tempContext.cleanup();
294
}
295
}
296
297
function isUnauthorized(err: Error) {
298
return err instanceof ApiError && err.status === 401;
299
}
300
301
function isConflict(err: Error) {
302
return err instanceof ApiError && err.status === 409;
303
}
304
305
function isNotFound(err: Error) {
306
return err instanceof ApiError && err.status === 404;
307
}
308
309
function contentAsTarget(content: Content): PublishRecord {
310
return { id: content.guid, url: content.content_url, code: false };
311
}
312
313
async function createContent(
314
client: RSConnectClient,
315
title: string,
316
slug: string,
317
): Promise<Content | undefined> {
318
while (true) {
319
const name = slug + "-" + randomHex(4);
320
try {
321
return await client.createContent(name, title);
322
} catch (err) {
323
if (!(err instanceof Error)) {
324
throw err;
325
}
326
if (!isConflict(err)) {
327
throw err;
328
}
329
}
330
}
331
}
332
333
function promptError(msg: string) {
334
info(colors.red(` ${msg}`));
335
}
336
337