Path: blob/main/src/publish/rsconnect/rsconnect.ts
6455 views
/*1* rsconnect.ts2*3* Copyright (C) 2020-2022 Posit Software, PBC4*/5import { info } from "../../deno_ral/log.ts";6import * as colors from "fmt/colors";78import { Input } from "cliffy/prompt/input.ts";9import { Secret } from "cliffy/prompt/secret.ts";1011import {12AccountToken,13AccountTokenType,14PublishFiles,15PublishProvider,16} from "../provider-types.ts";17import { ApiError, PublishOptions, PublishRecord } from "../types.ts";18import { RSConnectClient } from "./api/index.ts";19import { Content, Task } from "./api/types.ts";20import {21readAccessTokens,22writeAccessToken,23writeAccessTokens,24} from "../common/account.ts";25import { ensureProtocolAndTrailingSlash } from "../../core/url.ts";2627import { createTempContext } from "../../core/temp.ts";28import { completeMessage, withSpinner } from "../../core/console.ts";29import { randomHex } from "../../core/random.ts";30import { RenderFlags } from "../../command/render/types.ts";31import { createBundle } from "../common/bundle.ts";3233export const kRSConnect = "connect";34const kRSConnectDescription = "Posit Connect";3536export const kRSConnectServerVar = "CONNECT_SERVER";37export const kRSConnectAuthTokenVar = "CONNECT_API_KEY";3839export const rsconnectProvider: PublishProvider = {40name: kRSConnect,41description: kRSConnectDescription,42requiresServer: true,43listOriginOnly: true,44accountTokens,45authorizeToken,46removeToken,47resolveTarget,48publish,49isUnauthorized,50isNotFound,51};5253type Account = {54username: string;55server: string;56key: string;57};5859function accountTokens() {60const accounts: AccountToken[] = [];6162// check for environment variable63const server = Deno.env.get(kRSConnectServerVar);64const apiKey = Deno.env.get(kRSConnectAuthTokenVar);65if (server && apiKey) {66accounts.push({67type: AccountTokenType.Environment,68name: kRSConnectAuthTokenVar,69server,70token: apiKey,71});72}7374// check for recorded tokens75const tokens = readAccessTokens<Account>(kRSConnect);76if (tokens) {77accounts.push(...tokens.map((token) => ({78type: AccountTokenType.Authorized,79name: token.username,80server: token.server,81token: token.key,82})));83}8485return Promise.resolve(accounts);86}8788function removeToken(token: AccountToken) {89writeAccessTokens(90rsconnectProvider.name,91readAccessTokens<Account>(rsconnectProvider.name)?.filter(92(accessToken) => {93return accessToken.server !== token.server &&94accessToken.username !== token.name;95},96) || [],97);98}99100async function authorizeToken(101options: PublishOptions,102target?: PublishRecord,103): Promise<AccountToken | undefined> {104// ask for server (then validate that its actually a connect server105// by sending a request without an auth token)106let server = target?.url107? new URL(target.url).origin108: options.server || undefined;109if (server) {110server = ensureProtocolAndTrailingSlash(server);111}112while (server === undefined) {113// prompt for server114server = await Input.prompt({115message: "Server URL:",116hint: "e.g. https://connect.example.com/",117validate: (value) => {118// 'Enter' with no value ends publish119if (value.length === 0) {120throw new Error();121}122try {123const url = new URL(ensureProtocolAndTrailingSlash(value));124if (!["http:", "https:"].includes(url.protocol)) {125return `${value} is not an HTTP URL`;126} else {127return true;128}129} catch {130return `${value} is not a valid URL`;131}132},133transform: ensureProtocolAndTrailingSlash,134});135136// validate that its a connect server137const client = new RSConnectClient(server);138try {139await client.getUser();140} catch (err) {141if (!(err instanceof Error)) {142throw err;143}144// connect server will give 401 for unauthorized, break out145// of the loop in that case146if (isUnauthorized(err)) {147break;148} else {149info(150colors.red(151" Unable to connect to server (is this a valid Posit Connect Server?)",152),153);154server = undefined;155}156}157}158159// get apiKey and username160while (true) {161const apiKey = await Secret.prompt({162message: "API Key:",163hint: "Learn more at https://docs.rstudio.com/connect/user/api-keys/",164});165// 'Enter' with no value ends publish166if (apiKey.length === 0) {167throw new Error();168}169// get the user info170try {171const client = new RSConnectClient(server, apiKey);172const user = await client.getUser();173if (user.user_role !== "viewer") {174// record account175const account: Account = {176username: user.username,177server,178key: apiKey,179};180writeAccessToken(181kRSConnect,182account,183(a, b) => (a.server === b.server) && (a.username === b.username),184);185// return access token186return {187type: AccountTokenType.Authorized,188name: user.username,189server,190token: apiKey,191};192} else {193promptError(194"API key is for an Posit Connect viewer rather than a publisher.",195);196}197} catch (err) {198if (!(err instanceof Error)) {199throw err;200}201if (isUnauthorized(err)) {202promptError(203"API key is not authorized for this Posit Connect server.",204);205} else {206throw err;207}208}209}210}211212async function resolveTarget(213account: AccountToken,214target: PublishRecord,215): Promise<PublishRecord | undefined> {216const client = new RSConnectClient(account.server!, account.token);217const content = await client.getContent(target.id);218return contentAsTarget(content);219}220221async function publish(222account: AccountToken,223type: "document" | "site",224_input: string,225title: string,226slug: string,227render: (flags?: RenderFlags) => Promise<PublishFiles>,228_options: PublishOptions,229target?: PublishRecord,230): Promise<[PublishRecord, URL]> {231// create client232const client = new RSConnectClient(account.server!, account.token);233234let content: Content | undefined;235await withSpinner({236message: `Preparing to publish ${type}`,237}, async () => {238if (!target) {239content = await createContent(client, title, slug);240if (content) {241target = contentAsTarget(content);242} else {243throw new Error();244}245} else {246content = await client.getContent(target!.id);247}248});249info("");250251// render252const publishFiles = await render();253254// publish255const tempContext = createTempContext();256try {257// create and upload bundle258let task: Task | undefined;259await withSpinner({260message: () => `Uploading files`,261}, async () => {262const { bundlePath } = await createBundle(263type,264publishFiles,265tempContext,266);267const bundleBytes = Deno.readFileSync(bundlePath);268const bundleBlob = new Blob([bundleBytes.buffer]);269const bundle = await client.uploadBundle(target!.id, bundleBlob);270task = await client.deployBundle(bundle);271});272273await withSpinner({274message: `Publishing ${type}`,275}, async () => {276while (true) {277const status = await client.getTaskStatus(task!);278if (status.finished) {279if (status.code === 0) {280break;281} else {282throw new Error(283`Error attempting to publish content: ${status.code} - ${status.error}`,284);285}286}287}288});289completeMessage(`Published: ${content!.content_url}\n`);290return Promise.resolve([target!, new URL(content!.dashboard_url)]);291} finally {292tempContext.cleanup();293}294}295296function isUnauthorized(err: Error) {297return err instanceof ApiError && err.status === 401;298}299300function isConflict(err: Error) {301return err instanceof ApiError && err.status === 409;302}303304function isNotFound(err: Error) {305return err instanceof ApiError && err.status === 404;306}307308function contentAsTarget(content: Content): PublishRecord {309return { id: content.guid, url: content.content_url, code: false };310}311312async function createContent(313client: RSConnectClient,314title: string,315slug: string,316): Promise<Content | undefined> {317while (true) {318const name = slug + "-" + randomHex(4);319try {320return await client.createContent(name, title);321} catch (err) {322if (!(err instanceof Error)) {323throw err;324}325if (!isConflict(err)) {326throw err;327}328}329}330}331332function promptError(msg: string) {333info(colors.red(` ${msg}`));334}335336337