1 /*
2 * SessionRSConnect.cpp
3 *
4 * Copyright (C) 2021 by RStudio, PBC
5 *
6 * Unless you have received this program directly from RStudio pursuant
7 * to the terms of a commercial license agreement with RStudio, then
8 * this program is licensed to you under the terms of version 3 of the
9 * GNU Affero General Public License. This program is distributed WITHOUT
10 * ANY EXPRESS OR IMPLIED WARRANTY, INCLUDING THOSE OF NON-INFRINGEMENT,
11 * MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. Please refer to the
12 * AGPL (http://www.gnu.org/licenses/agpl-3.0.txt) for more details.
13 *
14 */
15
16 #include "SessionRSConnect.hpp"
17
18 #include <boost/algorithm/string.hpp>
19
20 #include <shared_core/Error.hpp>
21 #include <core/Exec.hpp>
22 #include <core/FileSerializer.hpp>
23 #include <core/r_util/RProjectFile.hpp>
24
25 #include <r/RSexp.hpp>
26 #include <r/RExec.hpp>
27 #include <r/RJson.hpp>
28 #include <r/ROptions.hpp>
29
30 #include <session/SessionModuleContext.hpp>
31 #include <session/SessionAsyncRProcess.hpp>
32 #include <session/SessionSourceDatabase.hpp>
33 #include <session/SessionQuarto.hpp>
34 #include <session/projects/SessionProjects.hpp>
35 #include <session/prefs/UserPrefs.hpp>
36
37 #define kFinishedMarker "Deployment completed: "
38 #define kRSConnectFolder "rsconnect/"
39 #define kPackratFolder "packrat/"
40
41 #define kMaxDeploymentSize 104857600
42
43 using namespace rstudio::core;
44
45 namespace rstudio {
46 namespace session {
47 namespace modules {
48 namespace rsconnect {
49
50 namespace {
51
52 // transforms a JSON array of file names into a single string. If 'quoted',
53 // then the input strings are quoted and comma-delimited; otherwise, file names
54 // are pipe-delimited.
quotedFilesFromArray(json::Array array,bool quoted)55 std::string quotedFilesFromArray(json::Array array, bool quoted)
56 {
57 std::string joined;
58 for (size_t i = 0; i < array.getSize(); i++)
59 {
60 // convert filenames to system encoding and escape quotes if quoted
61 std::string filename =
62 string_utils::singleQuotedStrEscape(string_utils::utf8ToSystem(
63 array[i].getString()));
64
65 // join into a single string
66 joined += (quoted ? "'" : "") +
67 filename +
68 (quoted ? "'" : "");
69 if (i < array.getSize() - 1)
70 joined += (quoted ? ", " : "|");
71 }
72 return joined;
73 }
74
75 // transforms a FilePath into an aliased json string
toJsonString(const core::FilePath & filePath)76 json::Value toJsonString(const core::FilePath& filePath)
77 {
78 return json::Value(module_context::createAliasedPath(filePath));
79 }
80
quartoMetadata(const std::string & dir,const std::string & file,const std::string & contentCategory)81 std::string quartoMetadata(const std::string& dir,
82 const std::string& file,
83 const std::string& contentCategory)
84 {
85 std::string quartoMetadata;
86
87 FilePath dirPath = module_context::resolveAliasedPath(dir);
88 FilePath filePath = dirPath.completeChildPath(file);
89 FilePath inspectTarget = contentCategory == "site" ? dirPath : filePath;
90
91 json::Object jsonInspect;
92 Error error = session::quarto::quartoInspect(
93 string_utils::utf8ToSystem(inspectTarget.getAbsolutePath()), &jsonInspect
94 );
95 if (!error)
96 {
97 json::Object quartoJson;
98 json::Array enginesJson;
99 error = json::readObject(jsonInspect, "quarto", quartoJson, "engines", enginesJson);
100 if (!error)
101 {
102 std::string version;
103 error = json::readObject(quartoJson, "version", version);
104 if (!error)
105 {
106 std::vector<std::string> engines;
107 std::transform(enginesJson.begin(), enginesJson.end(), std::back_inserter(engines), [](const json::Value& engine) {
108 return "'" + string_utils::singleQuotedStrEscape(engine.getString()) + "'";
109 });
110 quartoMetadata += "quarto_version = '" + string_utils::singleQuotedStrEscape(version) + "', " +
111 "quarto_engines = I(c(" + boost::algorithm::join(engines, ", ") + ")), ";
112 }
113 }
114 }
115
116 if (error)
117 {
118 LOG_ERROR(error);
119 }
120
121
122 return quartoMetadata;
123 }
124
125 class RSConnectPublish : public async_r::AsyncRProcess
126 {
127 public:
create(const std::string & dir,const json::Array & fileList,const std::string & file,const std::string & sourceDoc,const std::string & account,const std::string & server,const std::string & appName,const std::string & appTitle,const std::string & appId,const std::string & contentCategory,const std::string & websiteDir,const json::Array & additionalFilesList,const json::Array & ignoredFilesList,bool asMultiple,bool asStatic,bool isQuarto,boost::shared_ptr<RSConnectPublish> * pDeployOut)128 static Error create(
129 const std::string& dir,
130 const json::Array& fileList,
131 const std::string& file,
132 const std::string& sourceDoc,
133 const std::string& account,
134 const std::string& server,
135 const std::string& appName,
136 const std::string& appTitle,
137 const std::string& appId,
138 const std::string& contentCategory,
139 const std::string& websiteDir,
140 const json::Array& additionalFilesList,
141 const json::Array& ignoredFilesList,
142 bool asMultiple,
143 bool asStatic,
144 bool isQuarto,
145 boost::shared_ptr<RSConnectPublish>* pDeployOut)
146 {
147 boost::shared_ptr<RSConnectPublish> pDeploy(new RSConnectPublish(file));
148
149 // environment variables to be passed to the publish process
150 core::system::Options env;
151
152 // lead command with download options and certificate check state
153 std::string cmd("{ " + module_context::CRANDownloadOptions() + "; "
154 "options(rsconnect.check.certificate = " +
155 (prefs::userPrefs().publishCheckCertificates() ? "TRUE" : "FALSE") + "); ");
156
157 if (prefs::userPrefs().usePublishCaBundle() &&
158 !prefs::userPrefs().publishCaBundle().empty())
159 {
160 FilePath caBundleFile = module_context::resolveAliasedPath(
161 prefs::userPrefs().publishCaBundle());
162 if (caBundleFile.exists())
163 {
164 // if a valid bundle path was specified, use it
165 cmd += "options(rsconnect.ca.bundle = '" +
166 string_utils::utf8ToSystem(string_utils::singleQuotedStrEscape(
167 caBundleFile.getAbsolutePath())) +
168 "'); ";
169 }
170 }
171
172 // when not deploying static content, figure out what version of Python
173 // the content needs
174 if (!asStatic)
175 {
176 std::string reticulatePython;
177 Error error = r::exec::RFunction(".rs.inferReticulatePython").call(&reticulatePython);
178 if (error) {
179 LOG_ERROR(error);
180 }
181
182 if (!reticulatePython.empty())
183 {
184 // we found a Python version; forward it
185 core::system::setenv(&env, "RETICULATE_PYTHON", reticulatePython);
186
187 // if showing publishing diagnostics, emit the version of Python
188 // we're forwarding (for troubleshooting purposes)
189 if (prefs::userPrefs().showPublishDiagnostics())
190 {
191 cmd += "cat('Set RETICULATE_PYTHON = \"" +
192 string_utils::singleQuotedStrEscape(reticulatePython) + "\"\n'); ";
193 }
194 }
195 else if (prefs::userPrefs().showPublishDiagnostics())
196 {
197 // if we didn't find Python, note as such
198 cmd += "cat('RETICULATE_PYTHON unset (no Python installation found)\n'); ";
199 }
200 }
201
202 // create temporary file to host file manifest
203 if (!fileList.isEmpty())
204 {
205 Error error = FilePath::tempFilePath(pDeploy->manifestPath_);
206 if (error)
207 return error;
208
209 // write manifest to temporary file
210 std::vector<std::string> deployFileList;
211 fileList.toVectorString(deployFileList);
212 error = core::writeStringVectorToFile(pDeploy->manifestPath_,
213 deployFileList);
214 if (error)
215 return error;
216 }
217
218 // join and quote incoming filenames to deploy
219 std::string additionalFiles = quotedFilesFromArray(additionalFilesList,
220 false);
221 std::string ignoredFiles = quotedFilesFromArray(ignoredFilesList,
222 false);
223
224 // if an R Markdown document or HTML document is being deployed, mark it
225 // as the primary file, unless deploying a website
226 std::string primaryDoc;
227 if (!file.empty() && contentCategory != "site")
228 {
229 FilePath docFile = module_context::resolveAliasedPath(file);
230 std::string extension = docFile.getExtensionLowerCase();
231 if (extension == ".rmd" || extension == ".html" || extension == ".r" ||
232 extension == ".pdf" || extension == ".docx" || extension == ".rtf" ||
233 extension == ".odt" || extension == ".pptx" || extension == ".qmd" ||
234 extension == ".md")
235 {
236 primaryDoc = string_utils::utf8ToSystem(file);
237 }
238 }
239
240 // for static website deployments, store the publish record in the
241 // website root instead of the appDir; this prevents record from being blown
242 // away when the static content is cleaned and regenerated, thus permitting
243 // iterative republishing of static content
244 std::string recordDir;
245 if (asStatic && contentCategory == "site" && !websiteDir.empty())
246 {
247 recordDir = string_utils::utf8ToSystem(websiteDir);
248 }
249
250 std::string appDir = string_utils::utf8ToSystem(dir);
251 if (appDir == "~")
252 appDir = "~/";
253
254
255 // determine quarto version and engines
256 std::string quarto = (isQuarto && !asStatic) ? quartoMetadata(dir, file, contentCategory) : "";
257
258 // form the deploy command to hand off to the async deploy process
259 cmd += "rsconnect::deployApp("
260 "appDir = '" + string_utils::singleQuotedStrEscape(appDir) + "'," +
261 (recordDir.empty() ? "" : "recordDir = '" +
262 string_utils::singleQuotedStrEscape(recordDir) + "',") +
263 (pDeploy->manifestPath_.isEmpty() ? "" : "appFileManifest = '" +
264 string_utils::singleQuotedStrEscape(
265 pDeploy->manifestPath_.getAbsolutePath()) + "', ") +
266 (primaryDoc.empty() ? "" : "appPrimaryDoc = '" +
267 string_utils::singleQuotedStrEscape(primaryDoc) + "', ") +
268 (sourceDoc.empty() ? "" : "appSourceDoc = '" +
269 string_utils::singleQuotedStrEscape(sourceDoc) + "', ") +
270 "account = '" + string_utils::singleQuotedStrEscape(account) + "',"
271 "server = '" + string_utils::singleQuotedStrEscape(server) + "', "
272 "appName = '" + string_utils::singleQuotedStrEscape(appName) + "', " +
273 (appTitle.empty() ? "" : "appTitle = '" +
274 string_utils::singleQuotedStrEscape(appTitle) + "', ") +
275 (appId.empty() ? "" : "appId = " + appId + ", ") +
276 (contentCategory.empty() ? "" : "contentCategory = '" +
277 contentCategory + "', ") +
278 "launch.browser = function (url) { "
279 " message('" kFinishedMarker "', url) "
280 "}, "
281 "lint = FALSE,"
282 "metadata = list(" +
283 quarto +
284 " asMultiple = " + (asMultiple ? "TRUE" : "FALSE") + ", "
285 " asStatic = " + (asStatic ? "TRUE" : "FALSE") +
286 (additionalFiles.empty() ? "" : ", additionalFiles = '" +
287 additionalFiles + "'") +
288 (ignoredFiles.empty() ? "" : ", ignoredFiles = '" +
289 ignoredFiles + "'") +
290 ")" +
291 (prefs::userPrefs().showPublishDiagnostics() ? ", logLevel = 'verbose'" : "") +
292 ")}";
293
294 pDeploy->start(cmd.c_str(), env, FilePath(), async_r::R_PROCESS_VANILLA);
295 *pDeployOut = pDeploy;
296 return Success();
297 }
298
299 private:
RSConnectPublish(const std::string & file)300 RSConnectPublish(const std::string& file)
301 {
302 sourceFile_ = file;
303 }
304
onStderr(const std::string & output)305 void onStderr(const std::string& output)
306 {
307 onOutput(module_context::kCompileOutputNormal, output);
308 }
309
onStdout(const std::string & output)310 void onStdout(const std::string& output)
311 {
312 onOutput(module_context::kCompileOutputError, output);
313 }
314
onOutput(int type,const std::string & output)315 void onOutput(int type, const std::string& output)
316 {
317 r::sexp::Protect protect;
318 Error error;
319
320 // check for HTTP errors
321 boost::regex re("Error: HTTP (\\d{3})\\s+\\w+\\s+(\\S+)");
322 boost::smatch match;
323 if (regex_utils::search(output, match, re))
324 {
325 json::Object failure;
326 failure["http_status"] = (int)safe_convert::stringTo(match[1], 0);
327 failure["path"] = match[2].str();
328 ClientEvent event(client_events::kRmdRSConnectDeploymentFailed,
329 failure);
330 module_context::enqueClientEvent(event);
331 }
332
333 // look on each line of emitted output to see whether it contains the
334 // finished marker
335 std::vector<std::string> lines;
336 boost::algorithm::split(lines, output,
337 boost::algorithm::is_any_of("\n\r"));
338 int ncharMarker = sizeof(kFinishedMarker) - 1;
339 for (std::string& line : lines)
340 {
341 if (line.substr(0, ncharMarker) == kFinishedMarker)
342 deployedUrl_ = line.substr(ncharMarker, line.size() - ncharMarker);
343 }
344
345 // emit the output to the client for display
346 module_context::CompileOutput deployOutput(type, output);
347 ClientEvent event(client_events::kRmdRSConnectDeploymentOutput,
348 module_context::compileOutputAsJson(deployOutput));
349 module_context::enqueClientEvent(event);
350 }
351
onCompleted(int exitStatus)352 void onCompleted(int exitStatus)
353 {
354 // when the process completes, emit the discovered URL, if any
355 ClientEvent event(client_events::kRmdRSConnectDeploymentCompleted,
356 deployedUrl_);
357 module_context::enqueClientEvent(event);
358
359 // clean up the manifest if we created it
360 Error error = manifestPath_.removeIfExists();
361 if (error)
362 LOG_ERROR(error);
363 }
364
365 std::string deployedUrl_;
366 std::string sourceFile_;
367 FilePath manifestPath_;
368 };
369
370 boost::shared_ptr<RSConnectPublish> s_pRSConnectPublish_;
371
cancelPublish(const json::JsonRpcRequest & request,json::JsonRpcResponse * pResponse)372 Error cancelPublish(const json::JsonRpcRequest& request,
373 json::JsonRpcResponse* pResponse)
374 {
375 if (s_pRSConnectPublish_ &&
376 s_pRSConnectPublish_->isRunning())
377 {
378 // There is a running publish operation; end it.
379 s_pRSConnectPublish_->terminate();
380 pResponse->setResult(true);
381 }
382 else
383 {
384 // No running publish operation.
385 pResponse->setResult(false);
386 }
387
388 return Success();
389 }
390
rsconnectPublish(const json::JsonRpcRequest & request,json::JsonRpcResponse * pResponse)391 Error rsconnectPublish(const json::JsonRpcRequest& request,
392 json::JsonRpcResponse* pResponse)
393 {
394 json::Object source, settings;
395 std::string account, server, appName, appTitle, appId;
396 Error error = json::readParams(request.params, &source, &settings,
397 &account, &server,
398 &appName, &appTitle, &appId);
399 if (error)
400 return error;
401
402 // read publish source information
403 std::string sourceDir, sourceDoc, sourceFile, contentCategory, websiteDir;
404 bool isQuarto;
405 error = json::readObject(source, "deploy_dir", sourceDir,
406 "deploy_file", sourceFile,
407 "source_file", sourceDoc,
408 "content_category", contentCategory,
409 "website_dir", websiteDir,
410 "is_quarto", isQuarto);
411 if (error)
412 return error;
413
414 // read publish settings
415 bool asMultiple = false, asStatic = false;
416 json::Array deployFiles, additionalFiles, ignoredFiles;
417 error = json::readObject(settings, "deploy_files", deployFiles,
418 "additional_files", additionalFiles,
419 "ignored_files", ignoredFiles,
420 "as_multiple", asMultiple,
421 "as_static", asStatic);
422 if (error)
423 return error;
424
425 if (s_pRSConnectPublish_ &&
426 s_pRSConnectPublish_->isRunning())
427 {
428 pResponse->setResult(false);
429 }
430 else
431 {
432 error = RSConnectPublish::create(sourceDir, deployFiles,
433 sourceFile, sourceDoc,
434 account, server, appName, appTitle, appId,
435 contentCategory,
436 websiteDir,
437 additionalFiles,
438 ignoredFiles, asMultiple, asStatic, isQuarto,
439 &s_pRSConnectPublish_);
440 if (error)
441 return error;
442
443 pResponse->setResult(true);
444 }
445
446 return Success();
447 }
448
449
rsconnectDeployments(const json::JsonRpcRequest & request,json::JsonRpcResponse * pResponse)450 Error rsconnectDeployments(const json::JsonRpcRequest& request,
451 json::JsonRpcResponse* pResponse)
452 {
453
454 std::string sourcePath, outputPath;
455 Error error = json::readParams(request.params, &sourcePath, &outputPath);
456 if (error)
457 return error;
458
459 // get prior RPubs upload IDs, if any are known
460 std::string rpubsUploadId;
461 if (!outputPath.empty())
462 {
463 rpubsUploadId = module_context::previousRpubsUploadId(
464 module_context::resolveAliasedPath(outputPath));
465 }
466
467 // blend with known deployments from the rsconnect package
468 r::sexp::Protect protect;
469 SEXP sexpDeployments;
470 error = r::exec::RFunction(".rs.getRSConnectDeployments", sourcePath,
471 rpubsUploadId).call(&sexpDeployments, &protect);
472 if (error)
473 return error;
474
475 // convert result to JSON and return
476 json::Value result;
477 error = r::json::jsonValueFromObject(sexpDeployments, &result);
478 if (error)
479 return error;
480
481 // we want to always return an array, even if it's just one element long, so
482 // wrap the result in an array if it isn't one already
483 if (result.getType() != json::Type::ARRAY)
484 {
485 json::Array singleEle;
486 singleEle.push_back(result);
487 result = singleEle;
488 }
489
490 pResponse->setResult(result);
491
492 return Success();
493 }
494
getEditPublishedDocs(const json::JsonRpcRequest & request,json::JsonRpcResponse * pResponse)495 Error getEditPublishedDocs(const json::JsonRpcRequest& request,
496 json::JsonRpcResponse* pResponse)
497 {
498 std::string appPathParam;
499 Error error = json::readParams(request.params, &appPathParam);
500 if (error)
501 return error;
502
503 FilePath appPath = module_context::resolveAliasedPath(appPathParam);
504 if (!appPath.exists())
505 return pathNotFoundError(ERROR_LOCATION);
506
507 // doc paths to return
508 std::vector<FilePath> docPaths;
509
510 // if it's a file then just return the file
511 if (!appPath.isDirectory())
512 {
513 docPaths.push_back(appPath);
514 }
515 // otherwise look for shiny files
516 else
517 {
518 std::vector<FilePath> shinyPaths;
519 shinyPaths.push_back(appPath.completeChildPath("app.R"));
520 shinyPaths.push_back(appPath.completeChildPath("ui.R"));
521 shinyPaths.push_back(appPath.completeChildPath("server.R"));
522 shinyPaths.push_back(appPath.completeChildPath("www/index.html"));
523 for (const FilePath& filePath : shinyPaths)
524 {
525 if (filePath.exists())
526 docPaths.push_back(filePath);
527 }
528 }
529
530 // return as json
531 json::Array resultJson;
532 std::transform(docPaths.begin(),
533 docPaths.end(),
534 std::back_inserter(resultJson),
535 toJsonString);
536 pResponse->setResult(resultJson);
537 return Success();
538 }
539
getRmdPublishDetails(const json::JsonRpcRequest & request,json::JsonRpcResponse * pResponse)540 Error getRmdPublishDetails(const json::JsonRpcRequest& request,
541 json::JsonRpcResponse* pResponse)
542 {
543 std::string target;
544 Error error = json::readParams(request.params, &target);
545
546 // look up the source document in the database to see if we know its
547 // encoding
548 std::string encoding("unknown");
549 std::string id;
550 source_database::getId(target, &id);
551 if (!id.empty())
552 {
553 boost::shared_ptr<source_database::SourceDocument> pDoc(
554 new source_database::SourceDocument());
555 error = source_database::get(id, pDoc);
556 if (error)
557 LOG_ERROR(error);
558 else
559 encoding = pDoc->encoding();
560 }
561
562 // extract publish details we can discover with R
563 r::sexp::Protect protect;
564 SEXP sexpDetails;
565 error = r::exec::RFunction(".rs.getRmdPublishDetails")
566 .addUtf8Param(target)
567 .addParam(encoding)
568 .call(&sexpDetails, &protect);
569
570 if (error)
571 return error;
572
573 // extract JSON object from result
574 json::Value resultVal;
575 error = r::json::jsonValueFromList(sexpDetails, &resultVal);
576 if (resultVal.getType() != json::Type::OBJECT)
577 return Error(json::errc::ParseError, ERROR_LOCATION);
578 json::Object result = resultVal.getValue<json::Object>();
579
580 // augment with website project information
581 FilePath path = module_context::resolveAliasedPath(target);
582 std::string websiteDir;
583 std::string websiteOutputDir;
584 if (path.exists() && (path.hasExtensionLowerCase(".rmd") ||
585 path.hasExtensionLowerCase(".md")))
586 {
587 FilePath webPath = session::projects::projectContext().fileUnderWebsitePath(path);
588 if (!webPath.isEmpty())
589 {
590 websiteDir = webPath.getAbsolutePath();
591
592 // also get build output dir
593 if (!module_context::websiteOutputDir().empty())
594 {
595 FilePath websiteOutputPath =
596 module_context::resolveAliasedPath(module_context::websiteOutputDir());
597 websiteOutputDir = websiteOutputPath.getAbsolutePath();
598 }
599 }
600 }
601 result["website_dir"] = websiteDir;
602 result["website_output_dir"] = websiteOutputDir;
603
604 pResponse->setResult(result);
605
606 return Success();
607 }
608
applyPreferences()609 void applyPreferences()
610 {
611 // push preference changes into rsconnect package options immediately, so that it's possible to
612 // use them without restarting R
613 r::options::setOption("rsconnect.check.certificate", prefs::userPrefs().publishCheckCertificates());
614 if (prefs::userPrefs().usePublishCaBundle())
615 r::options::setOption("rsconnect.ca.bundle", prefs::userPrefs().publishCaBundle());
616 else
617 r::options::setOption("rsconnect.ca.bundle", R_NilValue);
618 }
619
initializeOptions()620 Error initializeOptions()
621 {
622 SEXP checkSEXP = r::options::getOption("rsconnect.check.certificate");
623 if (checkSEXP == R_NilValue)
624 {
625 // no user defined setting for certificate checks; disable if requested for the session
626 if (!prefs::userPrefs().publishCheckCertificates())
627 r::options::setOption("rsconnect.check.certificate", false);
628 }
629 else
630 {
631 // the user has a setting defined; mirror it if it differs. this allows us to reflect the
632 // the current value of the option in our preferences, but also means that if you've set the
633 // option in e.g. .Rprofile then it wins over RStudio's setting in the end.
634 bool check = r::sexp::asLogical(checkSEXP);
635 if (prefs::userPrefs().publishCheckCertificates() != check)
636 {
637 prefs::userPrefs().setPublishCheckCertificates(check);
638 }
639 }
640
641 std::string caBundle = r::sexp::safeAsString(r::options::getOption("rsconnect.ca.bundle"));
642 if (caBundle.empty() && prefs::userPrefs().usePublishCaBundle())
643 {
644 // no user defined setting for CA bundle; inject a bundle if the user asked for one
645 r::options::setOption("rsconnect.ca.bundle", prefs::userPrefs().publishCaBundle());
646 }
647 else if (!caBundle.empty())
648 {
649 // promote user setting as above
650 prefs::userPrefs().setUsePublishCaBundle(true);
651 prefs::userPrefs().setPublishCaBundle(caBundle);
652 }
653
654 return Success();
655 }
656
657 } // anonymous namespace
658
initialize()659 Error initialize()
660 {
661 using boost::bind;
662 using namespace module_context;
663
664 module_context::events().onPreferencesSaved.connect(applyPreferences);
665
666 ExecBlock initBlock;
667 initBlock.addFunctions()
668 (bind(registerRpcMethod, "get_rsconnect_deployments", rsconnectDeployments))
669 (bind(registerRpcMethod, "rsconnect_publish", rsconnectPublish))
670 (bind(registerRpcMethod, "cancel_publish", cancelPublish))
671 (bind(registerRpcMethod, "get_edit_published_docs", getEditPublishedDocs))
672 (bind(registerRpcMethod, "get_rmd_publish_details", getRmdPublishDetails))
673 (bind(sourceModuleRFile, "SessionRSConnect.R"))
674 (bind(initializeOptions));
675
676 return initBlock.execute();
677 }
678
679 } // namespace rsconnect
680 } // namespace modules
681 } // namespace session
682 } // namespace rstudio
683
684