Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
quarto-dev
GitHub Repository: quarto-dev/quarto-cli
Path: blob/main/src/publish/posit-connect-cloud/posit-connect-cloud.ts
12925 views
1
/*
2
* posit-connect-cloud.ts
3
*
4
* Copyright (C) 2026 Posit Software, PBC
5
*
6
* Publish provider for Posit Connect Cloud (connect.posit.cloud).
7
* Supports static content publishing via bundle upload (Pattern B).
8
* Authentication uses OAuth 2.0 Device Code flow.
9
*/
10
11
import { info, warning } from "../../deno_ral/log.ts";
12
import * as colors from "fmt/colors";
13
14
import { Select } from "cliffy/prompt/select.ts";
15
16
import {
17
AccountToken,
18
AccountTokenType,
19
PublishFiles,
20
PublishProvider,
21
} from "../provider-types.ts";
22
import { ApiError, PublishOptions, PublishRecord } from "../types.ts";
23
import { authorizePrompt } from "../account.ts";
24
import { createBundle } from "../common/bundle.ts";
25
import { renderForPublish } from "../common/publish.ts";
26
import { createTempContext } from "../../core/temp.ts";
27
import { completeMessage, withSpinner } from "../../core/console.ts";
28
import { openUrl } from "../../core/shell.ts";
29
import { isServerSession } from "../../core/platform.ts";
30
import { sleep } from "../../core/wait.ts";
31
import { RenderFlags } from "../../command/render/types.ts";
32
33
import {
34
getEnvironment,
35
getEnvironmentConfig,
36
initiateDeviceAuth,
37
pollForToken,
38
PositConnectCloudClient,
39
positConnectCloudDebug as publishDebug,
40
readStoredTokens,
41
writeStoredToken,
42
writeStoredTokens,
43
} from "./api/index.ts";
44
import { Account, PositConnectCloudToken, Revision } from "./api/types.ts";
45
46
export const kPositConnectCloud = "posit-connect-cloud";
47
const kPositConnectCloudDescription = "Posit Connect Cloud";
48
const kPositConnectCloudAccessTokenVar = "POSIT_CONNECT_CLOUD_ACCESS_TOKEN";
49
const kPositConnectCloudRefreshTokenVar = "POSIT_CONNECT_CLOUD_REFRESH_TOKEN";
50
const kPositConnectCloudAccountIdVar = "POSIT_CONNECT_CLOUD_ACCOUNT_ID";
51
const kRevisionPollTimeoutMs = 30 * 60 * 1000; // 30 minutes
52
53
export const positConnectCloudProvider: PublishProvider = {
54
name: kPositConnectCloud,
55
description: kPositConnectCloudDescription,
56
requiresServer: false,
57
listOriginOnly: false,
58
accountTokens,
59
authorizeToken,
60
removeToken,
61
resolveTarget,
62
publish,
63
isUnauthorized,
64
isNotFound,
65
};
66
67
function accountTokens(): Promise<AccountToken[]> {
68
const accounts: AccountToken[] = [];
69
const env = getEnvironmentConfig();
70
const serverUrl = `https://${env.uiHost}`;
71
72
// Check for environment variable token (CI/CD)
73
// See also: POSIT_CONNECT_CLOUD_REFRESH_TOKEN, POSIT_CONNECT_CLOUD_ACCOUNT_ID
74
const envToken = Deno.env.get(kPositConnectCloudAccessTokenVar);
75
if (envToken) {
76
accounts.push({
77
type: AccountTokenType.Environment,
78
name: kPositConnectCloudAccessTokenVar,
79
server: serverUrl,
80
token: envToken,
81
});
82
}
83
84
// Check for stored tokens
85
const storedTokens = readStoredTokens();
86
const currentEnv = getEnvironment();
87
for (const stored of storedTokens) {
88
if (stored.environment === currentEnv) {
89
accounts.push({
90
type: AccountTokenType.Authorized,
91
name: stored.accountName,
92
server: serverUrl,
93
token: stored.accessToken,
94
});
95
}
96
}
97
98
return Promise.resolve(accounts);
99
}
100
101
async function authorizeToken(
102
_options: PublishOptions,
103
): Promise<AccountToken | undefined> {
104
if (!await authorizePrompt(positConnectCloudProvider)) {
105
return undefined;
106
}
107
108
const env = getEnvironmentConfig();
109
publishDebug(
110
`Starting authorization (env: ${getEnvironment()}, client_id: ${env.clientId})`,
111
);
112
113
// Step 1: Initiate device authorization
114
const deviceAuth = await initiateDeviceAuth(env);
115
116
// Step 2: Display user code and open browser
117
info("");
118
info(
119
` Your authorization code is: ${
120
colors.bold(colors.green(deviceAuth.user_code))
121
}`,
122
);
123
info("");
124
125
const authUrlObj = new URL(deviceAuth.verification_uri_complete);
126
authUrlObj.searchParams.set("utm_source", "quarto-cli");
127
const authUrl = authUrlObj.toString();
128
if (isServerSession()) {
129
info(
130
" Please open this URL to authorize: " +
131
colors.underline(authUrl),
132
);
133
} else {
134
info(" Opening browser to authorize...");
135
await openUrl(authUrl);
136
}
137
info("");
138
139
// Step 3: Poll for token
140
const tokenResponse = await pollForToken(
141
env,
142
deviceAuth.device_code,
143
deviceAuth.interval,
144
deviceAuth.expires_in,
145
);
146
147
// Step 4: Verify token and get user info (non-fatal if signup incomplete)
148
const client = new PositConnectCloudClient(tokenResponse.access_token);
149
try {
150
const user = await client.getUser();
151
publishDebug(
152
`Authenticated as: ${user.display_name} (${user.email})`,
153
);
154
} catch (err) {
155
// 401 means Connect Cloud signup incomplete — user authenticated with Posit
156
// but hasn't created a Connect Cloud account yet. The account creation flow
157
// in Step 6 below handles this. (rsconnect skips getUser entirely.)
158
if (!isUnauthorized(err as Error)) {
159
throw err;
160
}
161
publishDebug("getUser returned 401 — signup may be incomplete");
162
}
163
164
// Step 5: Get accounts with publishing permissions
165
let accounts: Account[];
166
try {
167
accounts = await client.listAccounts();
168
} catch (err) {
169
// 401 with "no_user_for_lucid_user" means signup incomplete
170
if (isUnauthorized(err as Error)) {
171
accounts = [];
172
} else {
173
throw err;
174
}
175
}
176
177
// Filter for accounts with content:create permission
178
const publishableAccounts = accounts.filter((a) =>
179
a.permissions?.includes("content:create")
180
);
181
182
// Step 6: Handle account selection
183
let publishable = publishableAccounts;
184
185
if (publishable.length === 0) {
186
// Open account creation page and poll
187
info(" No publishable accounts found. Opening account setup...");
188
const accountUrl = client.accountCreationUrl();
189
if (isServerSession()) {
190
info(
191
" Please open this URL to create an account: " +
192
colors.underline(accountUrl),
193
);
194
} else {
195
await openUrl(accountUrl);
196
}
197
198
// Poll for accounts for up to 10 minutes (300 iterations * 2s)
199
const kMaxAttempts = 300;
200
const kPollInterval = 2000;
201
let found: Account[] = [];
202
await withSpinner({
203
message: "Waiting for account setup",
204
}, async () => {
205
for (let i = 0; i < kMaxAttempts; i++) {
206
await sleep(kPollInterval);
207
try {
208
const refreshed = await client.listAccounts();
209
found = refreshed.filter((a) =>
210
a.permissions?.includes("content:create")
211
);
212
if (found.length > 0) break;
213
} catch (err) {
214
if (err instanceof ApiError || err instanceof TypeError) {
215
publishDebug(
216
`Account polling error (will retry): ${err}`,
217
);
218
} else {
219
throw err;
220
}
221
}
222
}
223
});
224
225
if (found.length === 0) {
226
throw new Error(
227
"Timed out waiting for account setup. Please complete account setup and try again.",
228
);
229
}
230
231
publishable = found;
232
}
233
234
// Unified account selection (matches rsconnect behavior)
235
let selectedAccount: Account;
236
if (publishable.length === 1) {
237
selectedAccount = publishable[0];
238
publishDebug(
239
`Auto-selected account: ${selectedAccount.name}`,
240
);
241
} else {
242
const selectedIdx = await Select.prompt({
243
indent: "",
244
message: "Select account:",
245
options: publishable.map((a, i) => ({
246
name: a.display_name ? `${a.display_name} (${a.name})` : a.name,
247
value: String(i),
248
})),
249
});
250
selectedAccount = publishable[Number(selectedIdx)];
251
}
252
253
info(
254
` Authorized to publish as ${
255
colors.bold(selectedAccount.display_name || selectedAccount.name)
256
}`,
257
);
258
259
// Step 7: Store token
260
const storedToken: PositConnectCloudToken = {
261
username: selectedAccount.display_name || selectedAccount.name,
262
accountId: selectedAccount.id,
263
accountName: selectedAccount.name,
264
accessToken: tokenResponse.access_token,
265
refreshToken: tokenResponse.refresh_token,
266
expiresAt: Date.now() + (tokenResponse.expires_in * 1000),
267
environment: getEnvironment(),
268
};
269
writeStoredToken(storedToken);
270
publishDebug("Token stored");
271
272
return {
273
type: AccountTokenType.Authorized,
274
name: selectedAccount.name,
275
server: `https://${env.uiHost}`,
276
token: tokenResponse.access_token,
277
};
278
}
279
280
function removeToken(token: AccountToken) {
281
const currentEnv = getEnvironment();
282
const tokens = readStoredTokens().filter(
283
(stored) =>
284
!(stored.accountName === token.name && stored.environment === currentEnv),
285
);
286
writeStoredTokens(tokens);
287
}
288
289
async function resolveTarget(
290
account: AccountToken,
291
target: PublishRecord,
292
): Promise<PublishRecord | undefined> {
293
const storedToken = findStoredToken(account);
294
const client = clientForAccount(account, storedToken);
295
try {
296
const content = await client.getContent(target.id);
297
if (content.state === "deleted") {
298
publishDebug(
299
`Content ${target.id} is deleted, treating as not found`,
300
);
301
return undefined;
302
}
303
return {
304
id: content.id,
305
url: target.url,
306
code: false,
307
};
308
} catch (err) {
309
if (isNotFound(err as Error)) {
310
publishDebug(
311
`Content ${target.id} not found (404), treating as not found`,
312
);
313
return undefined;
314
}
315
throw err;
316
}
317
}
318
319
async function publish(
320
account: AccountToken,
321
type: "document" | "site",
322
_input: string,
323
title: string,
324
_slug: string,
325
render: (flags?: RenderFlags) => Promise<PublishFiles>,
326
options: PublishOptions,
327
target?: PublishRecord,
328
): Promise<[PublishRecord, URL | undefined]> {
329
// --token is not supported: Connect Cloud uses short-lived OAuth tokens,
330
// not permanent API keys. CI/CD should use environment variables instead.
331
if (options.token) {
332
throw new Error(
333
"Posit Connect Cloud does not support --token. " +
334
"For CI/CD, use environment variables: " +
335
"POSIT_CONNECT_CLOUD_ACCESS_TOKEN, " +
336
"POSIT_CONNECT_CLOUD_REFRESH_TOKEN, and " +
337
"POSIT_CONNECT_CLOUD_ACCOUNT_ID.",
338
);
339
}
340
341
const storedToken = findStoredToken(account);
342
const client = clientForAccount(account, storedToken);
343
let accountName = storedToken?.accountName || account.name;
344
let accountId = storedToken?.accountId || "";
345
346
// For environment tokens (CI/CD), resolve account via API.
347
// Honors POSIT_CONNECT_CLOUD_ACCOUNT_ID env var to select a specific account;
348
// without it, auto-selects the first publishable account.
349
if (!storedToken && account.type === AccountTokenType.Environment) {
350
publishDebug(
351
"Resolving account for environment token",
352
);
353
const accounts = await client.listAccounts();
354
const publishable = accounts.filter((a) =>
355
a.permissions?.includes("content:create")
356
);
357
if (publishable.length === 0) {
358
throw new Error(
359
"No publishable accounts found for the provided access token.",
360
);
361
}
362
const envAccountId = Deno.env.get(kPositConnectCloudAccountIdVar)?.trim();
363
if (envAccountId) {
364
const match = publishable.find((a) => a.id === envAccountId);
365
if (!match) {
366
throw new Error(
367
`Account ${envAccountId} not found or lacks content:create permission.`,
368
);
369
}
370
accountName = match.name;
371
accountId = match.id;
372
} else {
373
if (publishable.length > 1 && !options.prompt) {
374
throw new Error(
375
"Multiple publishable accounts found. " +
376
`Set ${kPositConnectCloudAccountIdVar} to select a specific account.`,
377
);
378
}
379
accountName = publishable[0].name;
380
accountId = publishable[0].id;
381
if (publishable.length > 1) {
382
warning(
383
`Multiple publishable accounts found. Using "${accountName}". ` +
384
`Set ${kPositConnectCloudAccountIdVar} to select a specific account.`,
385
);
386
}
387
}
388
publishDebug(
389
`Resolved account: ${accountName} (${accountId})`,
390
);
391
}
392
393
// Step 1: Prepare - create or update content
394
const { contentId, revisionId, uploadUrl } = await withSpinner({
395
message: `Preparing to publish ${type}`,
396
}, async () => {
397
if (!target) {
398
// Guard: accountId is required for content creation
399
if (!accountId?.trim()) {
400
throw new Error(
401
"Account ID not found. Please re-authorize with " +
402
"'quarto publish posit-connect-cloud --authorize'.",
403
);
404
}
405
// New content
406
publishDebug("Creating new content");
407
const content = await client.createContent(
408
accountId,
409
title,
410
"index.html",
411
);
412
if (!content.next_revision?.source_bundle_upload_url) {
413
throw new Error(
414
"Content creation did not return an upload URL",
415
);
416
}
417
publishDebug(
418
`Content created: ${content.id}`,
419
);
420
return {
421
contentId: content.id,
422
revisionId: content.next_revision.id,
423
uploadUrl: content.next_revision.source_bundle_upload_url,
424
};
425
} else {
426
// Existing content - check if first publish or update
427
const content = await client.getContent(target.id);
428
429
if (content.state === "deleted") {
430
throw new Error(
431
"Content has been deleted on Connect Cloud. " +
432
"Remove the publish record from _publish.yml and try again.",
433
);
434
}
435
436
if (content.current_revision === null) {
437
// First publish after creation - use existing next_revision
438
publishDebug(
439
"First publish (no current_revision), using existing next_revision",
440
);
441
if (!content.next_revision?.source_bundle_upload_url) {
442
throw new Error(
443
"Content has no upload URL in next_revision",
444
);
445
}
446
return {
447
contentId: content.id,
448
revisionId: content.next_revision.id,
449
uploadUrl: content.next_revision.source_bundle_upload_url,
450
};
451
} else {
452
// Subsequent publish - PATCH to create new revision
453
publishDebug("Updating existing content");
454
const updated = await client.updateContent(
455
content.id,
456
"index.html",
457
);
458
if (!updated.next_revision?.source_bundle_upload_url) {
459
throw new Error(
460
"Content update did not return an upload URL",
461
);
462
}
463
return {
464
contentId: content.id,
465
revisionId: updated.next_revision.id,
466
uploadUrl: updated.next_revision.source_bundle_upload_url,
467
};
468
}
469
}
470
});
471
info("");
472
473
// Step 2: Render and stage
474
// Note: siteUrl is intentionally not passed here. Connect Cloud serves
475
// content at a different domain (*.share.connect.posit.cloud) than the
476
// dashboard URL stored in _publish.yml (connect.posit.cloud/account/content/id).
477
// Passing the dashboard URL would break linkExternalFilter by overriding
478
// the correct window.location.host fallback in the rendered HTML.
479
const publishFiles = await renderForPublish(
480
render,
481
kPositConnectCloud,
482
type,
483
title,
484
);
485
486
// Step 3: Bundle and upload
487
const tempContext = createTempContext();
488
try {
489
await withSpinner({
490
message: () => "Uploading files",
491
}, async () => {
492
const { bundlePath } = await createBundle(
493
type,
494
publishFiles,
495
tempContext,
496
);
497
const bundleBytes = await Deno.readFile(bundlePath);
498
publishDebug(
499
`Bundle: ${publishFiles.files.length} files, ${bundleBytes.length} bytes compressed`,
500
);
501
await client.uploadBundle(uploadUrl, bundleBytes);
502
});
503
504
// Step 4: Publish and poll
505
await withSpinner({
506
message: `Publishing ${type}`,
507
}, async () => {
508
await client.publishContent(contentId);
509
510
// Poll revision status
511
const pollStartTime = Date.now();
512
let lastStatus = "";
513
let consecutiveErrors = 0;
514
const kMaxConsecutiveErrors = 5;
515
while (true) {
516
if (Date.now() - pollStartTime > kRevisionPollTimeoutMs) {
517
throw new Error(
518
"Timed out waiting for publish to complete. " +
519
"Check the Connect Cloud dashboard for status.",
520
);
521
}
522
523
// Revision polling can run for up to 30 minutes (~1,800 calls),
524
// so transient HTTP 500s or network timeouts are likely. Tolerate
525
// up to kMaxConsecutiveErrors before aborting.
526
let revision: Revision;
527
try {
528
revision = await client.getRevision(revisionId);
529
consecutiveErrors = 0;
530
} catch (err) {
531
if (err instanceof ApiError || err instanceof TypeError) {
532
consecutiveErrors++;
533
publishDebug(
534
`Revision poll error (${consecutiveErrors}/${kMaxConsecutiveErrors}): ${err}`,
535
);
536
if (consecutiveErrors >= kMaxConsecutiveErrors) {
537
throw new Error(
538
"Lost connection to Connect Cloud while monitoring deployment. " +
539
"The deployment may still succeed — check the dashboard for status.",
540
);
541
}
542
await sleep(2000);
543
continue;
544
}
545
throw err;
546
}
547
548
// Log status changes
549
if (revision.status && revision.status !== lastStatus) {
550
publishDebug(
551
`Revision status: ${revision.status}`,
552
);
553
lastStatus = revision.status;
554
}
555
556
if (revision.publish_result === "success") {
557
break;
558
} else if (revision.publish_result === "failure") {
559
const errorMsg = revision.publish_error_details ||
560
revision.publish_error || "Unknown publish error";
561
throw new Error(`Publish failed: ${errorMsg}`);
562
} else if (revision.publish_result !== null) {
563
throw new Error(
564
`Unexpected publish result: ${revision.publish_result}`,
565
);
566
}
567
568
await sleep(1000);
569
}
570
});
571
572
// Build the content URL
573
const contentUrl = client.contentUrl(accountName, contentId);
574
completeMessage(`Published: ${contentUrl}\n`);
575
576
const publishRecord: PublishRecord = {
577
id: contentId,
578
url: contentUrl,
579
code: false,
580
};
581
return [publishRecord, new URL(contentUrl)];
582
} finally {
583
tempContext.cleanup();
584
}
585
}
586
587
// --- Helpers ---
588
589
function isUnauthorized(err: Error): boolean {
590
return err instanceof ApiError && err.status === 401;
591
}
592
593
function isNotFound(err: Error): boolean {
594
return err instanceof ApiError && err.status === 404;
595
}
596
function clientForAccount(
597
account: AccountToken,
598
storedToken: PositConnectCloudToken | undefined,
599
): PositConnectCloudClient {
600
// For environment tokens, check for optional refresh token
601
if (account.type === AccountTokenType.Environment) {
602
const refreshToken = Deno.env.get(kPositConnectCloudRefreshTokenVar);
603
if (refreshToken) {
604
const pseudoToken: PositConnectCloudToken = {
605
username: account.name,
606
accountId: "",
607
accountName: account.name,
608
accessToken: account.token,
609
refreshToken,
610
expiresAt: 0, // Unknown for env tokens; rely on reactive refresh
611
environment: getEnvironment(),
612
};
613
return new PositConnectCloudClient(account.token, pseudoToken);
614
}
615
return new PositConnectCloudClient(account.token);
616
}
617
618
return new PositConnectCloudClient(account.token, storedToken);
619
}
620
621
function findStoredToken(
622
account: AccountToken,
623
): PositConnectCloudToken | undefined {
624
const currentEnv = getEnvironment();
625
return readStoredTokens().find(
626
(t) => t.accountName === account.name && t.environment === currentEnv,
627
);
628
}
629
630