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