From 142a8791f60c4e21362356093086f747a5e3d143 Mon Sep 17 00:00:00 2001 From: Carlos Scheidegger Date: Wed, 3 Sep 2025 12:25:22 -0500 Subject: [PATCH 1/3] remove posit-cloud from quarto publish sources (#13014) --- src/command/publish/cmd.ts | 5 - src/publish/posit-cloud/api/index.ts | 281 ---------------------- src/publish/posit-cloud/api/types.ts | 46 ---- src/publish/posit-cloud/posit-cloud.ts | 319 ------------------------- src/publish/provider.ts | 8 +- 5 files changed, 6 insertions(+), 653 deletions(-) delete mode 100644 src/publish/posit-cloud/api/index.ts delete mode 100644 src/publish/posit-cloud/api/types.ts delete mode 100644 src/publish/posit-cloud/posit-cloud.ts diff --git a/src/command/publish/cmd.ts b/src/command/publish/cmd.ts index a4317e300c8..49002ff7041 100644 --- a/src/command/publish/cmd.ts +++ b/src/command/publish/cmd.ts @@ -50,7 +50,6 @@ export const publishCommand = " - Quarto Pub (quarto-pub)\n" + " - GitHub Pages (gh-pages)\n" + " - Posit Connect (connect)\n" + - " - Posit Cloud (posit-cloud)\n" + " - Netlify (netlify)\n" + " - Confluence (confluence)\n" + " - Hugging Face Spaces (huggingface)\n\n" + @@ -114,10 +113,6 @@ export const publishCommand = "Publish with explicit credentials", "quarto publish connect --server example.com --token 01A24233E294", ) - .example( - "Publish project to Posit Cloud", - "quarto publish posit-cloud", - ) .example( "Publish without confirmation prompt", "quarto publish --no-prompt", diff --git a/src/publish/posit-cloud/api/index.ts b/src/publish/posit-cloud/api/index.ts deleted file mode 100644 index 425ae14984c..00000000000 --- a/src/publish/posit-cloud/api/index.ts +++ /dev/null @@ -1,281 +0,0 @@ -/* - * index.ts - * - * Copyright (C) 2020-2023 Posit Software, PBC - */ - -import { ApiError } from "../../types.ts"; -import { - Application, - Bundle, - Content, - ErrorBody, - OutputRevision, - Task, - User, -} from "./types.ts"; - -import { md5HashAsync } from "../../../core/hash.ts"; -import { quartoConfig } from "../../../core/quarto.ts"; - -import { crypto } from "crypto/crypto"; -import { - decodeBase64 as base64Decode, - encodeBase64 as base64Encode, -} from "encoding/base64"; - -interface FetchOpts { - body?: string; - queryParams?: Record; -} - -export class PositCloudClient { - private key: CryptoKey | undefined; - - public constructor( - private readonly server: string, - private readonly token: string, - private readonly token_secret: string, - ) { - this.server = server; - this.token = token; - this.token_secret = token_secret; - } - - public getUser(): Promise { - return this.get("users/me"); - } - - public getApplication(id: string): Promise { - return this.get(`applications/${id}`); - } - - public createOutput( - name: string, - spaceId: number | null, - projectId: number | null, - contentCategory: string | null, - ): Promise { - return this.post( - "outputs", - JSON.stringify({ - name: name, - application_type: "static", - space: spaceId, - project: projectId, - content_category: contentCategory, - }), - ); - } - - public setBundleReady(bundleId: number) { - return this.post( - `bundles/${bundleId}/status`, - JSON.stringify({ status: "ready" }), - ); - } - public createBundle( - applicationId: number, - contentLength: number, - checksum: string, - ): Promise { - return this.post( - `bundles`, - JSON.stringify({ - "application": applicationId, - "content_type": "application/x-tar", - "content_length": contentLength, - "checksum": checksum, - }), - ); - } - - public getTask(id: number) { - return this.get(`tasks/${id}`, { legacy: "false" }); - } - - public createRevision(outputId: number, contentCategory: string | null) { - return this.post( - `outputs/${outputId}/revisions`, - JSON.stringify({ - content_category: contentCategory, - }), - ); - } - - public getContent(id: number | string): Promise { - return this.get(`content/${id}`); - } - - public deployApplication( - applicationId: number, - bundleId: number, - ): Promise { - return this.post( - `applications/${applicationId}/deploy`, - JSON.stringify({ "bundle": bundleId, rebuild: false }), - ); - } - - private get = ( - path: string, - queryParams?: Record, - ): Promise => this.fetch("GET", path, { queryParams }); - private post = (path: string, body?: string): Promise => - this.fetch("POST", path, { body }); - - private fetch = async ( - method: string, - path: string, - opts: FetchOpts, - ): Promise => { - const fullPath = `/v1/${path}`; - - const pathAndQuery = opts.queryParams - ? `${fullPath}?${opts.queryParams}` - : fullPath; - - const url = `${this.server}${pathAndQuery}`; - const authHeaders = await this.authorizationHeaders( - method, - fullPath, - opts.body, - ); - const contentTypeHeader: HeadersInit = opts.body - ? { "Content-Type": "application/json" } - : {}; - - const headers = { - Accept: "application/json", - "User-Agent": `quarto-cli/${quartoConfig.version()}`, - ...authHeaders, - ...contentTypeHeader, - }; - - const requestInit: RequestInit = { - method, - headers, - body: opts.body, - redirect: "manual", - }; - const request = new Request(url, requestInit); - - return await this.handleResponse( - await fetch(request), - ); - }; - - private handleResponse = async ( - response: Response, - ): Promise => { - if (response.status >= 200 && response.status < 400) { - if ( - response.headers.get("Content-Type")?.startsWith("application/json") - ) { - return await response.json() as unknown as T; - } else { - return await response.text() as unknown as T; - } - } else if (response.status >= 400) { - const json = await response.json() as unknown as ErrorBody; - let errorDescription = undefined; - if (json.error) { - errorDescription = json.error; - if (json.error_type) { - errorDescription = `${errorDescription}, code=${json.error_type}`; - } - } - throw new ApiError( - response.status, - response.statusText, - errorDescription, - ); - } else { - throw new Error(`${response.status} - ${response.statusText}`); - } - }; - - private authorizationHeaders = async ( - method: string, - path: string, - body?: string, - ): Promise => { - const date = new Date().toUTCString(); - const checksum = await md5HashAsync(body || ""); - - const canonicalRequest = [ - method, - path, - date, - checksum, - ].join("\n"); - - const signature = await this.getSignature(canonicalRequest); - - return { - "X-Auth-Token": this.token, - "X-Auth-Signature": `${signature}; version=1`, - "Date": date, - "X-Content-Checksum": checksum, - }; - }; - - private async getSignature(data: string): Promise { - if (!this.key) { - const decodedTokenSecret = base64Decode(this.token_secret); - this.key = await crypto.subtle.importKey( - "raw", - decodedTokenSecret, - { name: "HMAC", hash: "SHA-256" }, - false, - ["sign"], - ); - } - - const canonicalRequestBytes = new TextEncoder().encode(data); - const signatureArrayBuffer = await crypto.subtle.sign( - "HMAC", - this.key, - canonicalRequestBytes, - ); - - const signatureBytes = Array.from(new Uint8Array(signatureArrayBuffer)); - const signatureHex = signatureBytes - .map((b) => b.toString(16).padStart(2, "0")) - .join(""); - return base64Encode(signatureHex); - } -} - -export class UploadClient { - public constructor( - private readonly url: string, - ) { - this.url = url; - } - - upload = async ( - fileBody: Blob, - bundleSize: number, - presignedChecksum: string, - ) => { - const response = await fetch(this.url, { - method: "PUT", - headers: { - Accept: "application/json", - "content-type": "application/x-tar", - "content-length": bundleSize.toString(), - "content-md5": presignedChecksum, - }, - body: fileBody, - }); - - if (!response.ok) { - if (response.status !== 200) { - throw new ApiError(response.status, response.statusText); - } else { - throw new Error(`${response.status} - ${response.statusText}`); - } - } - }; -} diff --git a/src/publish/posit-cloud/api/types.ts b/src/publish/posit-cloud/api/types.ts deleted file mode 100644 index 59cdf58f31a..00000000000 --- a/src/publish/posit-cloud/api/types.ts +++ /dev/null @@ -1,46 +0,0 @@ -/* - * types.ts - * - * Copyright (C) 2020-2023 Posit Software, PBC - */ - -export type User = { - id: number; - email: string; -}; - -export type Content = { - id: number; - url: string; - space_id: number; - source_id: number; -}; - -export type OutputRevision = { - id: number; - application_id: number; -}; - -export type Application = { - id: number; - content_id: number; -}; - -export type Bundle = { - id: number; - presigned_url: string; - presigned_checksum: string; -}; - -export type Task = { - task_id: number; - finished: boolean; - description: string; - state: string; - error?: string; -}; - -export type ErrorBody = { - error?: string; - error_type?: string; -}; diff --git a/src/publish/posit-cloud/posit-cloud.ts b/src/publish/posit-cloud/posit-cloud.ts deleted file mode 100644 index af74f372594..00000000000 --- a/src/publish/posit-cloud/posit-cloud.ts +++ /dev/null @@ -1,319 +0,0 @@ -/* - * posit-cloud.ts - * - * Copyright (C) 2020-2023 Posit Software, PBC - */ -import { info } from "../../deno_ral/log.ts"; -import * as colors from "fmt/colors"; - -import { Input } from "cliffy/prompt/input.ts"; -import { Secret } from "cliffy/prompt/secret.ts"; - -import { - AccountToken, - AccountTokenType, - PublishFiles, - PublishProvider, -} from "../provider-types.ts"; -import { ApiError, PublishOptions, PublishRecord } from "../types.ts"; -import { PositCloudClient, UploadClient } from "./api/index.ts"; -import { Content } from "./api/types.ts"; -import { - readAccessTokens, - writeAccessToken, - writeAccessTokens, -} from "../common/account.ts"; - -import { createTempContext } from "../../core/temp.ts"; -import { completeMessage, withSpinner } from "../../core/console.ts"; -import { RenderFlags } from "../../command/render/types.ts"; -import { createBundle } from "../common/bundle.ts"; -import { md5HashBytes } from "../../core/hash.ts"; - -export const kPositCloud = "posit-cloud"; -const kPositCloudDescription = "Posit Cloud"; - -export const kPositCloudServerVar = "POSIT_CLOUD_SERVER"; -export const kPositCloudTokenVar = "POSIT_CLOUD_TOKEN"; -export const kPositCloudAuthTokenSecretVar = "POSIT_CLOUD_SECRET"; - -export const positCloudProvider: PublishProvider = { - name: kPositCloud, - description: kPositCloudDescription, - requiresServer: true, - listOriginOnly: true, - hidden: true, - accountDescriptor: "credential", - accountTokens, - authorizeToken, - removeToken, - resolveTarget, - publish, - isUnauthorized, - isNotFound, -}; - -type Account = { - username: string; - token: string; - tokenSecret: string; -}; - -function getServer(): string { - return Deno.env.get(kPositCloudServerVar) || "https://api.posit.cloud"; -} - -function createClientFromAccountToken( - accountToken: AccountToken, -): PositCloudClient { - const [token, tokenSecret] = accountToken.token.split("|"); - return new PositCloudClient(getServer(), token, tokenSecret); -} - -function combineTokenAndSecret(token: string, tokenSecret: string): string { - return `${token}|${tokenSecret}`; -} - -function accountTokens() { - const accounts: AccountToken[] = []; - - // check for environment variable - const server = Deno.env.get(kPositCloudServerVar); - const token = Deno.env.get(kPositCloudTokenVar); - const tokenSecret = Deno.env.get(kPositCloudAuthTokenSecretVar); - if (server && token && tokenSecret) { - accounts.push({ - type: AccountTokenType.Environment, - name: kPositCloudTokenVar, - server, - token: combineTokenAndSecret(token, tokenSecret), - }); - } - - // check for recorded tokens - const tokens = readAccessTokens(kPositCloud); - if (tokens) { - accounts.push(...tokens.map((token) => ({ - type: AccountTokenType.Authorized, - name: token.username, - server: null, - token: combineTokenAndSecret(token.token, token.tokenSecret), - }))); - } - - return Promise.resolve(accounts); -} - -function removeToken(token: AccountToken) { - writeAccessTokens( - positCloudProvider.name, - readAccessTokens(positCloudProvider.name)?.filter( - (accessToken) => { - return accessToken.username !== token.name; - }, - ) || [], - ); -} - -async function authorizeToken( - _options: PublishOptions, - _target?: PublishRecord, -): Promise { - // get credentials - while (true) { - const token = await Input.prompt({ - message: "Token:", - }); - if (token.length === 0) { - throw new Error(); - } - - const tokenSecret = await Secret.prompt({ - message: "Token secret:", - }); - if (tokenSecret.length === 0) { - throw new Error(); - } - - const tokenAndSecret = combineTokenAndSecret(token, tokenSecret); - - try { - const client = new PositCloudClient( - getServer(), - token, - tokenSecret, - ); - - const user = await client.getUser(); - - // record account - const account = { - username: user.email, - token, - tokenSecret, - }; - writeAccessToken( - kPositCloud, - account, - (a, b) => a.username === b.username, - ); - // return access token - return { - type: AccountTokenType.Authorized, - name: user.email, - server: null, - token: tokenAndSecret, - }; - } catch (err) { - if (!(err instanceof Error)) { - throw err; - } - if (isUnauthorized(err)) { - promptError( - "Credential is unauthorized.", - ); - } else { - throw err; - } - } - } -} - -async function resolveTarget( - account: AccountToken, - target: PublishRecord, -): Promise { - const client = createClientFromAccountToken(account); - const content = await client.getContent(target.id); - return contentAsTarget(content); -} - -async function publish( - account: AccountToken, - type: "document" | "site", - _input: string, - title: string, - _slug: string, - render: (flags?: RenderFlags) => Promise, - _options: PublishOptions, - target?: PublishRecord, -): Promise<[PublishRecord, URL]> { - // create client - const client = createClientFromAccountToken(account); - - // render - const publishFiles = await render(); - const tempContext = createTempContext(); - const { bundlePath, manifest } = await createBundle( - type, - publishFiles, - tempContext, - ); - const { content_category } = manifest.metadata; - - const { content, applicationId } = await withSpinner({ - message: `Preparing to publish ${type}`, - }, async () => { - if (target) { - const content = await client.getContent(target.id); - const revision = await client.createRevision( - content.id, - content_category, - ); - return { content, applicationId: revision.application_id }; - } else { - const content = await createContent( - client, - title, - content_category, - ); - target = { id: content.id.toString(), url: content.url, code: false }; - return { content, applicationId: content.source_id }; - } - }); - info(""); - - // publish - try { - // create and upload bundle - const bundle = await withSpinner({ - message: () => `Uploading files`, - }, async () => { - const bundleBytes = Deno.readFileSync(bundlePath); - const bundleSize = bundleBytes.length; - const bundleHash = await md5HashBytes(bundleBytes); - - const bundle = await client.createBundle( - applicationId, - bundleSize, - bundleHash, - ); - - const bundleBlob = new Blob([bundleBytes.buffer]); - const uploadClient = new UploadClient(bundle.presigned_url); - await uploadClient.upload( - bundleBlob, - bundleSize, - bundle.presigned_checksum, - ); - return bundle; - }); - - await withSpinner({ - message: `Publishing ${type}`, - }, async () => { - await client.setBundleReady(bundle.id); - const initialTask = await client.deployApplication( - applicationId, - bundle.id, - ); - - while (true) { - await new Promise((resolve) => setTimeout(resolve, 1000)); - const task = await client.getTask(initialTask.task_id); - if (task.finished) { - break; - } - } - }); - completeMessage(`Published: ${content!.url}\n`); - return [target!, new URL(content!.url)]; - } finally { - tempContext.cleanup(); - } -} - -function isUnauthorized(err: Error) { - return err instanceof ApiError && err.status === 401; -} - -function isNotFound(err: Error) { - return err instanceof ApiError && err.status === 404; -} - -function contentAsTarget(content: Content): PublishRecord { - return { id: content.id.toString(), url: content.url, code: false }; -} - -async function createContent( - client: PositCloudClient, - title: string, - contentCategory: string | null, -): Promise { - let spaceId = null; - let projectId = null; - const projectApplicationId = Deno.env.get("LUCID_APPLICATION_ID"); - if (projectApplicationId) { - const projectApplication = await client.getApplication( - projectApplicationId, - ); - const project = await client.getContent(projectApplication.content_id); - projectId = project.id; - spaceId = project.space_id; - } - return await client.createOutput(title, spaceId, projectId, contentCategory); -} - -function promptError(msg: string) { - info(colors.red(` ${msg}`)); -} diff --git a/src/publish/provider.ts b/src/publish/provider.ts index afb067b2f5b..02a51ffd8a7 100644 --- a/src/publish/provider.ts +++ b/src/publish/provider.ts @@ -8,10 +8,10 @@ import { netlifyProvider } from "./netlify/netlify.ts"; import { ghpagesProvider } from "./gh-pages/gh-pages.ts"; import { quartoPubProvider } from "./quarto-pub/quarto-pub.ts"; import { rsconnectProvider } from "./rsconnect/rsconnect.ts"; -import { positCloudProvider } from "./posit-cloud/posit-cloud.ts"; import { confluenceProvider } from "./confluence/confluence.ts"; import { huggingfaceProvider } from "./huggingface/huggingface.ts"; import { AccountToken } from "./provider-types.ts"; +import { warning } from "../deno_ral/log.ts"; export function accountTokenText(token: AccountToken) { return token.name + (token.server ? ` (${token.server})` : ""); @@ -21,7 +21,6 @@ const kPublishProviders = [ quartoPubProvider, ghpagesProvider, rsconnectProvider, - positCloudProvider, netlifyProvider, confluenceProvider, huggingfaceProvider, @@ -32,5 +31,10 @@ export function publishProviders() { } export function findProvider(name?: string) { + if (name === "posit-cloud") { + warning( + `The Posit Cloud publishing destination is no longer supported.`, + ); + } return kPublishProviders.find((provider) => provider.name === name); } From b57395735cadb45aa5caaa69c1a83c0b44aab229 Mon Sep 17 00:00:00 2001 From: Carlos Scheidegger Date: Wed, 3 Sep 2025 12:27:51 -0500 Subject: [PATCH 2/3] add link to posit-cloud sunsetting notice --- news/changelog-1.8.md | 4 ++++ src/publish/provider.ts | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/news/changelog-1.8.md b/news/changelog-1.8.md index 07565bcd62e..356a7963d32 100644 --- a/news/changelog-1.8.md +++ b/news/changelog-1.8.md @@ -8,6 +8,10 @@ All changes included in 1.8: - ([#12780](https://github.com/quarto-dev/quarto-cli/issues/12780)): `keep-ipynb: true` now works again correctly and intermediate `.quarto_ipynb` is not removed. - ([#13051](https://github.com/quarto-dev/quarto-cli/issues/13051)): Fixed support for captioned Markdown table inside Div syntax for crossref. This is special handling, but this could be output by function like `knitr::kable()` with old option support. +## Backwards-compatibility breaking changes + +- ([#13014](https://github.com/quarto-dev/quarto-cli/issues/13014)): Remove `posit-cloud` as `quarto publish` destination. See for details. + ## Dependencies - Update `bootstrap-icons` to version v1.13.1 from v1.11.1. diff --git a/src/publish/provider.ts b/src/publish/provider.ts index 02a51ffd8a7..40e9a546709 100644 --- a/src/publish/provider.ts +++ b/src/publish/provider.ts @@ -33,7 +33,7 @@ export function publishProviders() { export function findProvider(name?: string) { if (name === "posit-cloud") { warning( - `The Posit Cloud publishing destination is no longer supported.`, + `The Posit Cloud publishing destination is no longer supported. See https://docs.posit.co/cloud/whats_new/#october-2024 for details.`, ); } return kPublishProviders.find((provider) => provider.name === name); From dbb6f0ac818ea675efc271b4b8a49281ab42df70 Mon Sep 17 00:00:00 2001 From: Carlos Scheidegger Date: Wed, 3 Sep 2025 12:28:40 -0500 Subject: [PATCH 3/3] fix typo on issue number --- news/changelog-1.8.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/news/changelog-1.8.md b/news/changelog-1.8.md index 356a7963d32..5de0719dc2f 100644 --- a/news/changelog-1.8.md +++ b/news/changelog-1.8.md @@ -10,7 +10,7 @@ All changes included in 1.8: ## Backwards-compatibility breaking changes -- ([#13014](https://github.com/quarto-dev/quarto-cli/issues/13014)): Remove `posit-cloud` as `quarto publish` destination. See for details. +- ([#13104](https://github.com/quarto-dev/quarto-cli/issues/13104)): Remove `posit-cloud` as `quarto publish` destination. See for details. ## Dependencies