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