Skip to content

Commit cb8dcf8

Browse files
cdervclaude
andauthored
Add quarto publish posit-connect-cloud provider (#14041)
* Add `quarto publish posit-connect-cloud` provider Implements a new publish provider for Posit Connect Cloud (connect.posit.cloud) supporting static content publishing. Authentication uses OAuth 2.0 Device Code flow (RFC 8628) with dual-strategy token refresh (proactive before expiry + reactive on 401). Bundle upload follows the rsconnect pattern: create tar.gz with manifest.json, upload to presigned URL, then trigger revision-based deployment with polling. Key implementation details: - Environment-aware: production, staging, development configs selectable via CONNECT_CLOUD_ENVIRONMENT env var - CI/CD support via CONNECT_CLOUD_ACCESS_TOKEN env var - Multi-account selection when user belongs to multiple orgs - Account creation polling for users without publishable accounts - Content state tracking: handles deleted content gracefully Staging-first development: defaults to staging environment while production client_id (quarto-cli) registration is pending. The staging client_id (quarto-cli-staging) is available now. Closes #14027 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: add publishing architecture overview for LLM context * Move OAuth params to POST body, consume publish response, add account polling spinner Move OAuth parameters from URL query strings to POST request body in initiateDeviceAuth, pollForToken, and refreshAccessToken per RFC 6749 §2.3.1 best practice — keeps refresh_token out of server/proxy access logs. Add response body consumption in publishContent to prevent resource leak. Wrap account creation polling in withSpinner for progress indication. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Rename to use full name posit-connect-cloud * Address design review: eliminate non-null assertions, use async file read Restructure publish() to return values from withSpinner callback via destructuring, removing all non-null assertions. Switch bundle read from Deno.readFileSync to async Deno.readFile. Update architecture doc to reflect posit-connect-cloud provider addition. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Update copyright year * Avoid duplicate disk read for stored token in publish flow clientForAccount already called findStoredToken internally, but publish() also called it separately. Pass the token through instead. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add manual test fixtures for posit-connect-cloud publishing Minimal single document and website project for manual testing against the staging environment. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix publish bugs: document staging, server URL display, getUser 401 Use renderForPublish() instead of render() directly so single documents are staged correctly (document.html copied to index.html). Without this, the Connect Cloud API rejects the bundle with "Unable to locate index.html". Set server URL in AccountToken objects so account listings show which environment is being used (e.g. "cderv (https://staging.connect.posit.cloud)"). Wrap getUser() in try/catch to handle 401 for users who authenticated with Posit but haven't completed Connect Cloud signup. The account creation polling in Step 6 handles this case. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add gitignore for test fixtures, document renderForPublish in llm-docs Add .gitignore to manual test fixture directories to exclude rendered output, _publish.yml, and _site/ from version control. Document renderForPublish() in publishing-architecture.md — providers publishing documents must use this instead of render() directly to ensure proper staging (HTML→index.html, PDF→pdf.js wrapper). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add manual testing protocol for posit-connect-cloud publishing Covers 12 tests: authorization, token persistence, website publishing, content updates, account management, CI/CD mode, provider selection, deprecation warnings, error cases, and token refresh. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Address review findings: add debug log for 401, fix test protocol docs Add debug trace when getUser returns 401 during authorization so the event is visible in verbose output. Fix manual testing protocol: remove unreferenced REFRESH_TOKEN from cleanup, add refreshToken pre-condition to Test 10. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Consume response body before 401 retry to prevent connection leak In fetchWithRetry_, the original 401 response body was not consumed before making the retry request after token refresh. Unconsumed response bodies in Deno's fetch can leak TCP connections. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Guard against empty accountId before content creation Validates accountId is non-empty before calling createContent(), preventing a confusing API error if stored token data is corrupted. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Trim accountId in guard to also reject whitespace-only values Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add publishDebug wrapper, help example, and --token guard - Centralize the [publish][posit-connect-cloud] debug prefix into a publishDebug() helper (exported as positConnectCloudDebug) to avoid repeating the prefix across 30 debug calls in two files. - Add quarto publish posit-connect-cloud example to help text. - Reject --token early with a clear error directing users to the environment variable approach for CI/CD (Connect Cloud uses short-lived OAuth tokens, not permanent API keys). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Switch default environment from staging to production The production client_id (quarto-cli) is now registered. Staging/development environments remain available via POSIT_CONNECT_CLOUD_ENVIRONMENT for testing. * Remove outdated _README.md for manual test fixtures README.md already covers the full testing protocol with correct production-default instructions. * Address review findings for posit-connect-cloud provider Fix two bugs: guard writeAccessToken on non-empty accountId to prevent ghost tokens from env pseudo-tokens (.1), and skip proactive refresh when expiresAt is 0 to avoid redundant refreshes for env tokens (.2). Unify account selection after polling to match rsconnect behavior — after account creation polling succeeds, fall through to the same selection logic used for pre-existing accounts (.6). Additional improvements: add listAccounts pagination comment (.4), extract buildUrl_ helper (.5), pass stored token explicitly in resolveTarget (.7), move timeout check before sleep in pollForToken (.8), extract kOAuthScope constant (.9). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Address review findings batch 2 for posit-connect-cloud provider - Pass siteUrl to renderForPublish for site re-publishes so canonical URLs and external link filtering work correctly (.11) - Remove dead findStoredToken fallback in clientForAccount, make storedToken parameter explicit (.12) - Narrow catch in account polling loop to ApiError|TypeError, rethrow programming bugs instead of silently swallowing them (.14) - Add comment explaining response body drain in publishContent (.15) - Add external link to simple-website fixture for siteUrl testability - Add siteUrl verification items to Test 4 in manual testing README Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Revert siteUrl pass-through for Connect Cloud rendering (.11) During production testing, we discovered that Connect Cloud serves content at a different domain than the dashboard URL stored in _publish.yml: - Dashboard URL: connect.posit.cloud/account/content/<id> - Serving URL: <id>.share.connect.posit.cloud/ Passing the dashboard URL as siteUrl would override the correct window.location.host fallback in linkExternalFilter, causing all links to be misclassified as external. The same mismatch would affect canonical URLs and Open Graph metadata. This differs from gh-pages where the published URL and serving URL are the same domain, making siteUrl pass-through correct there. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Address review findings batch 3 for posit-connect-cloud provider Three hardening fixes: - Trim POSIT_CONNECT_CLOUD_ACCOUNT_ID env var to prevent CI/CD copy-paste whitespace from causing silent match failure (.17) - Enforce minimum 5-second device code poll interval per RFC 8628 §3.5 to prevent tight loop if server returns interval=0 (.18) - Catch transient errors (HTTP 500, network timeouts) during 30-minute revision polling with consecutive error threshold before aborting, since ~1,800 poll calls makes transient failures likely (.19) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add debug log for 404 in resolveTarget, clarify Test 9b expectations During production testing, deleted content was silently handled by resolveTarget returning undefined (correct behavior). Added debug logging for the 404 case to match the existing deleted-state logging. Updated README to document the actual flow: silent detection, no prompt for stale target, falls through to new publish. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fail on ambiguous account selection in non-interactive mode Other publish providers fail when --no-prompt is set and multiple accounts exist, via the shared resolveAccount logic. Connect Cloud bypasses that shared path for environment token account resolution, so it needs its own guard: throw an error when multiple publishable accounts exist without POSIT_CONNECT_CLOUD_ACCOUNT_ID in non-interactive mode, instead of silently picking the first. Also add Test 6b to the manual testing protocol covering this case. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Update publishing architecture doc for posit-connect-cloud provider Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * reduce copyright years on new files * Extract buildHeaders helper to deduplicate auth headers in fetchWithRetry * Add a note on where the handled error code comes from * Use a `postFormUrlEncoded` to make clear this is the same call * Use error helpers everywhere they should be used * Add a note about revision logic --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent aaaba63 commit cb8dcf8

14 files changed

Lines changed: 1922 additions & 3 deletions

File tree

Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
---
2+
main_commit: 76b946025
3+
analyzed_date: 2026-02-16
4+
key_files:
5+
- src/publish/provider-types.ts
6+
- src/publish/provider.ts
7+
- src/publish/publish.ts
8+
- src/publish/config.ts
9+
- src/publish/common/publish.ts
10+
- src/publish/common/bundle.ts
11+
- src/publish/common/account.ts
12+
- src/publish/types.ts
13+
- src/command/publish/cmd.ts
14+
- src/publish/posit-connect-cloud/posit-connect-cloud.ts
15+
- src/publish/posit-connect-cloud/api/index.ts
16+
- src/publish/posit-connect-cloud/api/types.ts
17+
---
18+
19+
# Quarto Publishing Architecture
20+
21+
How `quarto publish` works internally. Covers the provider interface, publish patterns, account management, and the end-to-end publish flow.
22+
23+
## Provider Interface
24+
25+
File: `src/publish/provider-types.ts`
26+
27+
Every publish target implements `PublishProvider`:
28+
29+
```typescript
30+
export interface PublishProvider {
31+
name: string; // e.g. "netlify", "rsconnect", "quarto-pub"
32+
description: string; // Human-readable name
33+
requiresServer: boolean; // true if user provides server URL (e.g. rsconnect)
34+
hidden?: boolean; // Hide from provider selection
35+
listOriginOnly?: boolean; // Only list in origin project
36+
37+
accountTokens(): Promise<AccountToken[]>;
38+
removeToken(token: AccountToken): void;
39+
authorizeToken(options: PublishOptions, target?: PublishRecord): Promise<AccountToken | undefined>;
40+
resolveTarget(account: AccountToken, target: PublishRecord): Promise<PublishRecord | undefined>;
41+
publish(
42+
account: AccountToken, type: "document" | "site", input: string,
43+
title: string, slug: string,
44+
render: (flags?: RenderFlags) => Promise<PublishFiles>,
45+
options: PublishOptions, target?: PublishRecord
46+
): Promise<[PublishRecord | undefined, URL | undefined]>;
47+
isUnauthorized(error: Error): boolean;
48+
isNotFound(error: Error): boolean;
49+
}
50+
```
51+
52+
Key types:
53+
54+
- `AccountToken` — stored credential with `name`, `server?`, `token` (generic string or structured object)
55+
- `AccountTokenType``"authorized"` (user-stored) or `"environment"` (env var)
56+
- `PublishRecord` — entry in `_publish.yml` with `id`, `url`, `code?`
57+
- `PublishFiles``{ baseDir: string, rootFile: string, files: string[], metafiles?: string[] }`
58+
59+
## Provider Registration
60+
61+
File: `src/publish/provider.ts`
62+
63+
Providers are imported and added to `kPublishProviders`:
64+
65+
```typescript
66+
const kPublishProviders = [
67+
quartoPubProvider,
68+
ghpagesProvider,
69+
rsconnectProvider,
70+
netlifyProvider,
71+
positConnectCloudProvider,
72+
confluenceProvider,
73+
huggingfaceProvider,
74+
];
75+
```
76+
77+
Discovery functions: `publishProviders()`, `findProvider(name)`.
78+
79+
There is also a deprecation warning for the old `posit-cloud` provider (removed in `142a8791f`) that now suggests using `posit-connect-cloud` as an alternative.
80+
81+
## Two Publish Patterns
82+
83+
### Pattern A: File-by-file upload
84+
85+
Used by: `quarto-pub`, `netlify`
86+
87+
File: `src/publish/common/publish.ts`
88+
89+
Uses `handlePublish()` which:
90+
1. SHA-1 hashes each file in the rendered output
91+
2. Creates a deploy with a file manifest (listing all files + checksums)
92+
3. Server responds with which files it needs (doesn't already have)
93+
4. Uploads only changed files individually
94+
5. Activates the deploy
95+
96+
Providers using this pattern implement `PublishHandler`:
97+
98+
```typescript
99+
export interface PublishHandler {
100+
name: string;
101+
createSite(type, title, slug, account): Promise<[string, string]>;
102+
createDeploy(siteId, account, files): Promise<PublishDeploy>;
103+
getDeploy(deployId, account): Promise<PublishDeploy>;
104+
uploadDeployFile(deployId, path, fileBody, account): Promise<void>;
105+
}
106+
```
107+
108+
### Pattern B: Bundle upload
109+
110+
Used by: `rsconnect` (Posit Connect), `posit-connect-cloud` (Posit Connect Cloud)
111+
112+
File: `src/publish/common/bundle.ts`
113+
114+
Uses `createBundle()` which:
115+
1. Creates a `manifest.json` with file checksums, `appmode`, `content_category`, etc.
116+
2. Packages all files + manifest into a `.tar.gz` archive
117+
3. Returns `{ bundlePath: string, manifest: object }`
118+
119+
The provider then uploads the entire bundle as a single blob and triggers deployment. Each provider using this pattern has its own publish logic (not via `handlePublish()`).
120+
121+
`createBundle()` signature:
122+
```typescript
123+
function createBundle(
124+
type: "document" | "site",
125+
files: PublishFiles,
126+
tempContext: TempContext
127+
): Promise<{ bundlePath: string; manifest: Record<string, unknown> }>
128+
```
129+
130+
## End-to-End Publish Flow
131+
132+
### 1. CLI Entry
133+
134+
File: `src/command/publish/cmd.ts`
135+
136+
`quarto publish [provider] [path]` invokes `publishAction()`.
137+
138+
### 2. Resolve Deployment Target
139+
140+
File: `src/publish/config.ts`
141+
142+
Reads `_publish.yml` to find existing publish targets. Format:
143+
144+
```yaml
145+
- source: document.qmd
146+
provider-name:
147+
- id: site-or-content-id
148+
url: https://published-url.example.com
149+
```
150+
151+
### 3. Select Provider
152+
153+
If not specified on CLI, user is prompted to choose from available providers.
154+
155+
### 4. Resolve Account
156+
157+
File: `src/publish/common/account.ts`
158+
159+
Account resolution order:
160+
1. Environment variable tokens (via provider's `accountTokens()`)
161+
2. Stored tokens in `~/.quarto/publish/accounts/{provider}/accounts.json`
162+
3. Interactive authorization (via provider's `authorizeToken()`)
163+
164+
Token storage functions:
165+
- `readAccessTokens<T>(provider)`reads stored tokens
166+
- `writeAccessToken<T>(provider, token, compareFn)`writes/updates token
167+
- `readAccessTokenFile(provider)`raw file path
168+
169+
### 5. Publish
170+
171+
File: `src/publish/publish.ts`
172+
173+
`publishSite()` or `publishDocument()` coordinates:
174+
1. Calls provider's `publish()`, passing a `render` callback
175+
2. Provider calls `renderForPublish()` (or `render()` directly for sites)
176+
3. Provider uploads and deploys
177+
4. Returns `[PublishRecord, URL]`
178+
179+
**Important:** Providers publishing documents should call `renderForPublish()` instead of `render()` directly. `renderForPublish()` wraps `render()` and stages the output: for HTML documents it copies `document.html``index.html`, for PDFs it creates a pdf.js viewer wrapper as `index.html`. Without this staging, the primary file name won't match `index.html` and bundle-based providers will fail.
180+
181+
### 6. Update `_publish.yml`
182+
183+
File: `src/publish/config.ts`
184+
185+
The returned `PublishRecord` is written back to `_publish.yml` for future republishing.
186+
187+
## Account / Token Management
188+
189+
File: `src/publish/common/account.ts`
190+
191+
### Storage
192+
193+
Tokens stored at: `~/.quarto/publish/accounts/{provider}/accounts.json`
194+
195+
Format: JSON array of `AccountToken` objects. The `token` field is provider-specific (can be a string or structured object).
196+
197+
### Authorization Patterns
198+
199+
**Ticket-based auth** (quarto-pub): `authorizeAccessToken()` in `src/publish/common/account.ts`
200+
- Opens browser to auth URL
201+
- Polls a ticket endpoint until user completes auth
202+
- Exchanges ticket for access token
203+
204+
**API key auth** (rsconnect): User provides API key directly or via env var.
205+
206+
**OAuth Device Code** (posit-connect-cloud): Uses RFC 8628 Device Code flow. See `src/publish/posit-connect-cloud/api/index.ts` for implementation.
207+
208+
### Environment Variables
209+
210+
Each provider can check for env vars in `accountTokens()`. Convention:
211+
- `QUARTO_PUB_AUTH_TOKEN`
212+
- `CONNECT_SERVER` + `CONNECT_API_KEY` (rsconnect)
213+
- `NETLIFY_AUTH_TOKEN`
214+
- `POSIT_CONNECT_CLOUD_ACCESS_TOKEN` + `POSIT_CONNECT_CLOUD_REFRESH_TOKEN` + `POSIT_CONNECT_CLOUD_ACCOUNT_ID` (posit-connect-cloud)
215+
216+
## Existing Providers Summary
217+
218+
| Provider | Pattern | Auth | `requiresServer` |
219+
|----------|---------|------|-------------------|
220+
| `quarto-pub` | A (file-by-file) | Ticket-based OAuth | false |
221+
| `netlify` | A (file-by-file) | API key / env var | false |
222+
| `rsconnect` | B (bundle) | API key (`Key <key>`) | true |
223+
| `ghpages` | Custom (git push) | Git credentials | false |
224+
| `confluence` | Custom | API token | true |
225+
| `posit-connect-cloud` | B (bundle) | OAuth Device Code (RFC 8628) | false |
226+
| `huggingface` | Custom | HF token | false |
227+
228+
## Reusable Utilities
229+
230+
| Utility | File | Purpose |
231+
|---------|------|---------|
232+
| `createBundle()` | `src/publish/common/bundle.ts` | tar.gz bundle with manifest.json |
233+
| `readAccessTokens<T>()` | `src/publish/common/account.ts` | Read stored tokens |
234+
| `writeAccessToken<T>()` | `src/publish/common/account.ts` | Write/update token |
235+
| `authorizeAccessToken()` | `src/publish/common/account.ts` | Ticket-based auth flow |
236+
| `renderForPublish()` | `src/publish/common/publish.ts` | Render + stage documents (HTMLindex.html, PDFpdf.js wrapper) |
237+
| `handlePublish()` | `src/publish/common/publish.ts` | File-by-file upload orchestration |
238+
| `withSpinner()` | `src/core/console.ts` | Progress spinner display |
239+
| `completeMessage()` | `src/core/console.ts` | Success/failure messages |
240+
| `createTempContext()` | `src/core/temp.ts` | Temp file management |
241+
| `openUrl()` | `src/core/shell.ts` | Open URL in browser |
242+
| `isServerSession()` | `src/core/platform.ts` | Detect headless/CI environment |
243+
| `ApiError` | `src/publish/types.ts` | HTTP error type with status code |

news/changelog-1.9.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,10 @@ All changes included in 1.9:
119119

120120
## Publishing
121121

122+
### Posit Connect Cloud
123+
124+
- ([#14027](https://github.com/quarto-dev/quarto-cli/issues/14027)): Add `quarto publish posit-connect-cloud` for publishing static content to Posit Connect Cloud.
125+
122126
### Confluence
123127

124128
- ([#13414](https://github.com/quarto-dev/quarto-cli/issues/13414)): Be more forgiving when Confluence server returns malformed JSON response. (author: @m1no)

src/command/publish/cmd.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/*
22
* cmd.ts
33
*
4-
* Copyright (C) 2020-2022 Posit Software, PBC
4+
* Copyright (C) 2020-2026 Posit Software, PBC
55
*/
66

77
import { existsSync } from "../../deno_ral/fs.ts";
@@ -50,6 +50,7 @@ export const publishCommand =
5050
" - Quarto Pub (quarto-pub)\n" +
5151
" - GitHub Pages (gh-pages)\n" +
5252
" - Posit Connect (connect)\n" +
53+
" - Posit Connect Cloud (posit-connect-cloud)\n" +
5354
" - Netlify (netlify)\n" +
5455
" - Confluence (confluence)\n" +
5556
" - Hugging Face Spaces (huggingface)\n\n" +
@@ -113,6 +114,10 @@ export const publishCommand =
113114
"Publish with explicit credentials",
114115
"quarto publish connect --server example.com --token 01A24233E294",
115116
)
117+
.example(
118+
"Publish project to Posit Connect Cloud",
119+
"quarto publish posit-connect-cloud",
120+
)
116121
.example(
117122
"Publish without confirmation prompt",
118123
"quarto publish --no-prompt",

0 commit comments

Comments
 (0)