Repository: posit-dev/publisher
Pull request #2637: Quote paths to support windows
Author: login: jonkeane
Created: 2025-05-07T16:25:47Z | Merged: 2025-05-27T14:27:18Z

## Intent

Added functional tests for calling into R from Windows (and particularly when R is installed by rig). Those confirmed the failure. Due to the [way quotes work in windows](https://learn.microsoft.com/en-gb/archive/blogs/twistylittlepassagesallalike/everyone-quotes-command-line-arguments-the-wrong-way), when the R executable was a `.bat`, we needed to do more + different quoting than we already were for the scripts we shelled out to. This PR changes those to write the script portions out to files and then use `R... -f ...` to run them. This is more robust since we don't have to worry about quoting within the contents of the script.

Resolves #2598

## Type of Change

- - [x] Bug Fix <!-- A change which fixes an existing issue -->
- - [ ] New Feature <!-- A change which adds additional functionality -->
- - [ ] Breaking Change <!-- A breaking change which causes existing functionality to change -->
- - [ ] Documentation <!-- User or developer documentation -->
- - [ ] Refactor <!-- Code restructuring -->
- - [ ] Tooling <!-- Build, CI, or release scripts and configuration -->

## Approach

_last sentence of intent_

## User Impact

Windows users who have installed rig are able to use the publisher.

## Automated Tests

Added functional tests that confirm the bug and assert that it is resolved.

## Directions for Reviewers

The first commit shows the test failures, and the second shows the code the resolves the issue. If you want to review the tests separately from the fix, that is the way.

## Checklist

Not yet, but will:
- [ ] I have updated [CHANGELOG.md](../CHANGELOG.md) to cover notable changes.


Linked issue #2598: R command problem in positron when publishing from Windows
I tried the publisher in Positron (2025-03.0+116) and it fails at renv scanning 
````
Could not scan R packages from lockfile: renv.lock, exit status 1. See [Troubleshooting docs](https://github.com/posit-dev/publisher/blob/main/docs/troubleshooting.md) for help.
````

Looking at the log I see this 
```
time=2025-03-11T18:10:44.855+01:00 level=DEBUG msg="Collecting manifest R packages" local_id=2wZVlqVUa0bAxIjh event_op=publish/getRPackageDescriptions lockfile=c:\Users\chris\Documents\DEV_OTHER\00-TESTS\test-quarto\renv.lock
time=2025-03-11T18:10:44.856+01:00 level=DEBUG msg="Running command" local_id=2wZVlqVUa0bAxIjh event_op=publish/getRPackageDescriptions cmd="C:\\Program Files\\R\\bin\\R.bat" args="-s -e cat(.libPaths(), sep=\"\\n\")"
time=2025-03-11T18:10:44.900+01:00 level=ERROR msg="Error running command" local_id=2wZVlqVUa0bAxIjh event_op=publish/getRPackageDescriptions command="C:\\Program Files\\R\\bin\\R.bat" error="exit status 1"
'C:\Program' n’est pas reconnu en tant que commande interne
ou externe, un programme exécutable ou un fichier de commandes.
````

The interesting part is 
```
command="C:\\Program Files\\R\\bin\\R.bat" error="exit status 1"
'C:\Program' n’est pas reconnu en tant que commande interne
ou externe, un programme exécutable ou un fichier de commandes.
```

which is French because I work on French Windows. 

It means 
````
command="C:\\Program Files\\R\\bin\\R.bat" error="exit status 1"
'C:\Program' is not recognized as an internal
or external command, an executable program or a batch file.
````

and this is usually a quoting problem when running the command on Windows. 

I figured I should report here. If you prefer direct slack message in our Posit slack, tell me the channel to use. Thanks ! 

Linked issue comments: user: login: jonkeane
created_at: 2025-05-09T20:33:39Z
body: I've been trying to replicate this failure in CI (so that I could then fix it), but haven't yet been able to trip it. 

For example, in a test run (where I have enabled more verbose logging than usual) I can seemingly get through the code path you were hitting without a problem:

```
=== RUN   TestRPackageDescFunctionalSuite/TestGetRPackagesFunctional
...
2025/05/09 20:21:10 INFO Collecting manifest R packages event_op=publish/getRPackageDescriptions lockfile="C:\\Users\\RUNNER~1\\AppData\\Local\\Temp\\r-packages-test-3190255402\\With Spaces\\renv.lock"
2025/05/09 20:21:10 INFO Running command event_op=publish/getRPackageDescriptions cmd="C:\\Program Files\\R\\R-4.4.2\\bin\\x64\\R.exe" args="-s -e cat(.libPaths(), sep=\"\\n\")"
2025/05/09 20:21:10 INFO Running command event_op=publish/getRPackageDescriptions cmd="C:\\Program Files\\R\\R-4.4.2\\bin\\x64\\R.exe" args="-s -e (function() { pkgs <- available.packages( repos = setNames(c(\"https://cloud.r-project.org\"), c(\"CRAN\")), type = \"source\", filters = c(getOption(\"rsconnect.available_packages_filters\", default = c()), \"duplicates\"));info <- pkgs[,c(\"Package\", \"Version\", \"Repository\")];apply(info, 1, function(x) { cat(x, sep=\" \", collapse=\"\\n\") } );invisible()})()"
2025/05/09 20:21:12 INFO Running command event_op=publish/getRPackageDescriptions cmd="C:\\Program Files\\R\\R-4.4.2\\bin\\x64\\R.exe" args="-s -e (function() { if (requireNamespace(\"BiocManager\", quietly = TRUE) || requireNamespace(\"BiocInstaller\", quietly = TRUE)) {repos <- getFromNamespace(\"renv_bioconductor_repos\", \"renv\")(\"C:\\\\Users\\\\RUNNER~1\\\\AppData\\\\Local\\\\Temp\\\\r-packages-test-3190255402\\\\With Spaces\"); repos <- repos[setdiff(names(repos), \"CRAN\")]; cat(repos, labels=names(repos), fill=1); invisible()}})()"
2025/05/09 20:21:12 INFO Done collecting R package descriptions event_op=publish/getRPackageDescriptions
--- PASS: TestRPackageDescFunctionalSuite (1.[80](https://github.com/posit-dev/publisher/actions/runs/14937023950/job/41966758212?pr=2637#step:8:81)s)
```

Besides the `INFO`/`DEBUG` level (which I changed to get this to show in CI, but isn't impacting the actual flow), I can get to an R executable even with a space in the path. There is a difference here though, where I'm finding `R.exe` on the path, but on your system it's finding / using `R.bat`. Do you know where that `R.bat` is coming from? Possibly related: how did you install R on the system?
author_association: COLLABORATOR
user: login: cderv
created_at: 2025-05-12T08:02:44Z
body: > There is a difference here though, where I'm finding R.exe on the path, but on your system it's finding / using R.bat. Do you know where that R.bat is coming from? Possibly related: how did you install R on the system?

I am using **rig** : https://github.com/r-lib/rig

And I think `rig default` is creating this `R.bat` file. 
https://github.com/r-lib/rig/blob/8c105b59c7bbdd6b93bf952bb6f8a481289c05f0/src/windows.rs#L680-L683

I'll try again to deploy with a more recent Positron version. (Maybe not daily because it has some issues with PATH right now)
author_association: NONE
user: login: cderv
created_at: 2025-05-12T09:11:34Z
body: I still get the same error with this positron version
````
Positron Version: 2025.05.0 (user setup) build 142
Code - OSS Version: 1.99.0
Commit: 17df8dd67df471d39688fc6cd02e3f23543b4d72
Date: 2025-05-05T15:59:59.712Z
Electron: 34.3.2
Chromium: 132.0.6834.210
Node.js: 20.18.3
V8: 13.2.152.41-electron.0
OS: Windows_NT x64 10.0.26100
````

If I set `R.exe` in PATH first to try as your setup, I get a different error that I don't understand yet ("cannot re-install packages installed from source; all packages must be installed from a reproducible location such as a repository. Package MASS, Version 7.3-65", but MASS is a base package, I did not specifically install) - so maybe there is something I am not doing correctly in the deploy workflow. 

Anyhow, not the same error anyway. 


After this "success", I tried back with the `R.bat` in path from **rig** install, and it is not working with same error. 

So there is something about this .bat file. On Windows, .bat files are scripts to run with `CMD`, which is a bit different than .exe binary. So maybe calling the BAT script is indeed the problem.  
Also, quotation of command line is not straightforward on Windows. 

I don't know how Go will run a bat script, but it may be because of that. 



author_association: NONE
user: login: jonkeane
created_at: 2025-05-12T12:59:49Z
body: > I am using rig ...

Ah! This is super helpful, let me try to replicate with that. We should play friendly with rig here.

> I get a different error that I don't understand yet ("cannot re-install packages installed from source; all packages must be installed from a reproducible location such as a repository. Package MASS, Version 7.3-65", but MASS is a base package, I did not specifically install) 

Ah, that sounds like an issue where the version of R you used to create your renv.lock is different from the one that you're on currently (or have active). Would you mind looking at those versions and seeing if there's a mismatch?
author_association: COLLABORATOR
user: login: cderv
created_at: 2025-05-13T09:22:38Z
body: > Ah, that sounds like an issue where the version of R you used to create your renv.lock is different from the one that you're on currently (or have active). Would you mind looking at those versions and seeing if there's a mismatch?

I think it was that. I can confirm that using the `R.exe` works, and `R.bat` doesn't. 

The error about MASS version and source install problem was not really helpful to understand I needed to udpate my renv version. 😅  thanks ! 
author_association: NONE
user: login: dotNomad
created_at: 2025-05-28T19:36:44Z
body: Howdy @cderv we closed this up and shipped version `1.16.1` of the Posit Publisher extension. Could you check to see if that resolved your issue?
author_association: COLLABORATOR
user: login: cderv
created_at: 2025-06-02T10:54:06Z
body: All good! This is working fine now. Thank you
author_association: NONE

Diff:
diff --git a/.github/workflows/agent.yaml b/.github/workflows/agent.yaml
index 6e91c0bc83..96e10c6cfe 100644
--- a/.github/workflows/agent.yaml
+++ b/.github/workflows/agent.yaml
@@ -13,7 +13,33 @@ jobs:
         with:
           fetch-depth: 0
       - uses: extractions/setup-just@v2
+
+      # Install R with rig for our funcitonal tests to ensure we are compatible with it.
+      # We aren't used r-lib/actions here becuase rig, especially on windows, installs in
+      # slightly different ways (e.g. using a batch file on windows)
+      - name: Install rig with apt
+        if: ${{ runner.os == 'Linux' }}
+        run: |
+          sudo curl -L https://rig.r-pkg.org/deb/rig.gpg -o /etc/apt/trusted.gpg.d/rig.gpg
+          sudo sh -c 'echo "deb http://rig.r-pkg.org/deb rig main" > /etc/apt/sources.list.d/rig.list'
+          sudo apt update
+          sudo apt install -y r-rig
+      - name: Install rig with brew
+        if: ${{ runner.os == 'macOS' }}
+        run: |
+          brew tap r-lib/rig
+          brew install --cask rig
+      - name: Install rig with Choco
+        if: ${{ runner.os == 'Windows' }}
+        run: choco install rig
+
+      - name: Install R with rig
+        run: rig add release
+      - name: Install renv package
+        run: Rscript -e "install.packages('renv')"
       - uses: actions/setup-go@v5
         with:
           go-version-file: go.mod
-      - run: just test
+      # Run tests without `-short` to run functional tests
+      # TODO: https://github.com/posit-dev/publisher/issues/2649
+      - run: just test ./...
diff --git a/internal/executor/executor.go b/internal/executor/executor.go
index e10ca54c8d..050e39322b 100644
--- a/internal/executor/executor.go
+++ b/internal/executor/executor.go
@@ -4,6 +4,7 @@ package executor
 
 import (
 	"bytes"
+	"fmt"
 	"os"
 	"os/exec"
 	"strings"
@@ -14,6 +15,7 @@ import (
 
 type Executor interface {
 	RunCommand(executable string, args []string, cwd util.AbsolutePath, log logging.Logger) ([]byte, []byte, error)
+	RunScript(executable string, args []string, script string, cwd util.AbsolutePath, log logging.Logger) ([]byte, []byte, error)
 }
 
 type defaultExecutor struct{}
@@ -40,3 +42,28 @@ func (e *defaultExecutor) RunCommand(executable string, args []string, cwd util.
 	}
 	return stdout.Bytes(), stderrBuf.Bytes(), err
 }
+
+// RunScript is helpful when running R scripts, they avoid needing to ensure that the contents of the script
+// is fully escaped and quoted.
+func (e *defaultExecutor) RunScript(executable string, args []string, script string, cwd util.AbsolutePath, log logging.Logger) ([]byte, []byte, error) {
+	log.Debug("Running script", "cmd", executable, "args", strings.Join(args, " "), "script", script)
+	// Write script contents to a file, clean it up, and append to args
+	tempFile, err := os.CreateTemp("", "*")
+
+	// defer cleaning up
+	cleanup := func() {
+		tempFile.Close()
+		os.Remove(tempFile.Name())
+	}
+	defer cleanup()
+
+	if err != nil {
+		return nil, nil, fmt.Errorf("failed to create temporary R script: %w", err)
+	}
+
+	tempFile.WriteString(script)
+	tempFile.Sync()
+
+	args = append(args, "-f", tempFile.Name())
+	return e.RunCommand(executable, args, cwd, log)
+}
diff --git a/internal/executor/executortest/executortest.go b/internal/executor/executortest/executortest.go
index e06f7a5c23..c60fb5d0dd 100644
--- a/internal/executor/executortest/executortest.go
+++ b/internal/executor/executortest/executortest.go
@@ -31,3 +31,19 @@ func (m *MockExecutor) RunCommand(executable string, argv []string, cwd util.Abs
 	}
 	return outSlice, errSlice, args.Error(2)
 }
+
+func (m *MockExecutor) RunScript(executable string, args []string, script string, cwd util.AbsolutePath, log logging.Logger) ([]byte, []byte, error) {
+	mockArgs := m.Called(executable, args, script, cwd, log)
+
+	var outSlice []byte
+	out := mockArgs.Get(0)
+	if out != nil {
+		outSlice = out.([]byte)
+	}
+	var errSlice []byte
+	stderr := mockArgs.Get(1)
+	if stderr != nil {
+		errSlice = stderr.([]byte)
+	}
+	return outSlice, errSlice, mockArgs.Error(2)
+}
diff --git a/internal/inspect/dependencies/renv/available_packages.go b/internal/inspect/dependencies/renv/available_packages.go
index 79df4779af..2f91f3e34b 100644
--- a/internal/inspect/dependencies/renv/available_packages.go
+++ b/internal/inspect/dependencies/renv/available_packages.go
@@ -73,6 +73,7 @@ func repoNamesAsStrings(repos []Repository) string {
 }
 
 func (l *defaultAvailablePackagesLister) ListAvailablePackages(repos []Repository, log logging.Logger) ([]AvailablePackage, error) {
+	// List package repos by writing a temp script, calling it, then cleaning it up
 	const packageListCodeTemplate = `(function() { pkgs <- available.packages( repos = setNames(c(%s), c(%s)), type = "source", filters = c(getOption("rsconnect.available_packages_filters", default = c()), "duplicates"));info <- pkgs[,c("Package", "Version", "Repository")];apply(info, 1, function(x) { cat(x, sep=" ", collapse="\n") } );invisible()})()`
 	repoUrls := repoUrlsAsStrings(repos)
 	repoNames := repoNamesAsStrings(repos)
@@ -83,13 +84,12 @@ func (l *defaultAvailablePackagesLister) ListAvailablePackages(repos []Repositor
 		return nil, err
 	}
 
-	out, _, err := l.rExecutor.RunCommand(
+	out, _, err := l.rExecutor.RunScript(
 		rExecutable.String(),
 		[]string{
 			"-s",
-			"-e",
-			packageListCode,
 		},
+		packageListCode,
 		l.base,
 		log)
 
@@ -118,6 +118,7 @@ func (l *defaultAvailablePackagesLister) ListAvailablePackages(repos []Repositor
 }
 
 func (l *defaultAvailablePackagesLister) GetBioconductorRepos(base util.AbsolutePath, log logging.Logger) ([]Repository, error) {
+	// List bioc repos by writing a temp script, calling it, then cleaning it up
 	const bioconductorReposCodeTemplate = `(function() { if (requireNamespace("BiocManager", quietly = TRUE) || requireNamespace("BiocInstaller", quietly = TRUE)) {repos <- getFromNamespace("renv_bioconductor_repos", "renv")("%s"); repos <- repos[setdiff(names(repos), "CRAN")]; cat(repos, labels=names(repos), fill=1); invisible()}})()`
 	escapedBase := strings.ReplaceAll(l.base.String(), `\`, `\\`)
 	biocRepoListCode := fmt.Sprintf(bioconductorReposCodeTemplate, escapedBase)
@@ -127,13 +128,12 @@ func (l *defaultAvailablePackagesLister) GetBioconductorRepos(base util.Absolute
 		return nil, err
 	}
 
-	out, _, err := l.rExecutor.RunCommand(
+	out, _, err := l.rExecutor.RunScript(
 		rExecutable.String(),
 		[]string{
 			"-s",
-			"-e",
-			biocRepoListCode,
 		},
+		biocRepoListCode,
 		l.base,
 		log)
 
@@ -168,14 +168,14 @@ func (l *defaultAvailablePackagesLister) GetLibPaths(log logging.Logger) ([]util
 		return nil, err
 	}
 
+	// Gather lib paths, by writing a temp script, calling it, then cleaning it up
 	const getLibPathsCode = `cat(.libPaths(), sep="\n")`
-	out, _, err := l.rExecutor.RunCommand(
+	out, _, err := l.rExecutor.RunScript(
 		rExecutable.String(),
 		[]string{
 			"-s",
-			"-e",
-			getLibPathsCode,
 		},
+		getLibPathsCode,
 		l.base,
 		log)
 
@@ -187,7 +187,8 @@ func (l *defaultAvailablePackagesLister) GetLibPaths(log logging.Logger) ([]util
 	lines := strings.Split(string(out), "\n")
 	for _, line := range lines {
 		line = strings.TrimSpace(line)
-		if line == "" {
+		if line == "" || line[0] == '-' {
+			// e.g. "- The project is out-of-sync -- use `renv::status()` for details."
 			continue
 		}
 		paths = append(paths, util.NewAbsolutePath(line, nil))
diff --git a/internal/inspect/dependencies/renv/available_packages_test.go b/internal/inspect/dependencies/renv/available_packages_test.go
index 16bf99776d..2d01942829 100644
--- a/internal/inspect/dependencies/renv/available_packages_test.go
+++ b/internal/inspect/dependencies/renv/available_packages_test.go
@@ -57,7 +57,7 @@ func (s *AvailablePackagesSuite) TestListAvailablePackages() {
 	rExecutablePath := util.NewAbsolutePath("R", nil)
 
 	executor := executortest.NewMockExecutor()
-	executor.On("RunCommand", rExecutablePath.String(), mock.Anything, s.base, mock.Anything).Return([]byte(
+	executor.On("RunScript", rExecutablePath.String(), mock.Anything, mock.Anything, s.base, mock.Anything).Return([]byte(
 		"pkg1 1.0 https://cran.rstudio.com/src/contrib \npkg2 2.0 https://cran.rstudio.com/src/contrib \n"), []byte{}, nil)
 	lister.rExecutor = executor
 
@@ -97,7 +97,7 @@ func (s *AvailablePackagesSuite) TestGetBioconductorRepos() {
 	rExecutablePath := util.NewAbsolutePath("R", nil)
 
 	executor := executortest.NewMockExecutor()
-	executor.On("RunCommand", rExecutablePath.String(), mock.Anything, s.base, mock.Anything).Return([]byte(biocReposOutput), []byte{}, nil)
+	executor.On("RunScript", rExecutablePath.String(), mock.Anything, mock.Anything, s.base, mock.Anything).Return([]byte(biocReposOutput), []byte{}, nil)
 	lister.rExecutor = executor
 
 	repos, err := lister.GetBioconductorRepos(s.base, logging.New())
@@ -139,7 +139,7 @@ func (s *AvailablePackagesSuite) TestGetLibPaths() {
 	s.NoError(err)
 
 	executor := executortest.NewMockExecutor()
-	executor.On("RunCommand", rExecutablePath.String(), mock.Anything, s.base, mock.Anything).Return([]byte(libPathsOutput), []byte{}, nil)
+	executor.On("RunScript", rExecutablePath.String(), mock.Anything, mock.Anything, s.base, mock.Anything).Return([]byte(libPathsOutput), []byte{}, nil)
 	lister.rExecutor = executor
 
 	repos, err := lister.GetLibPaths(logging.New())
@@ -177,7 +177,7 @@ func (s *AvailablePackagesSuite) TestGetLibPathsWindows() {
 	s.NoError(err)
 
 	executor := executortest.NewMockExecutor()
-	executor.On("RunCommand", rExecutablePath.String(), []string{"-s", "-e", "cat(.libPaths(), sep=\"\\n\")"}, s.base, mock.Anything).Return([]byte(windowsLibPathsOutput), []byte{}, nil)
+	executor.On("RunScript", rExecutablePath.String(), []string{"-s"}, "cat(.libPaths(), sep=\"\\n\")", s.base, mock.Anything).Return([]byte(windowsLibPathsOutput), []byte{}, nil)
 	lister.rExecutor = executor
 
 	repos, err := lister.GetLibPaths(logging.New())
diff --git a/internal/interpreters/r.go b/internal/interpreters/r.go
index 076bd20d29..72de85bf66 100644
--- a/internal/interpreters/r.go
+++ b/internal/interpreters/r.go
@@ -212,8 +212,7 @@ func (i *defaultRInterpreter) RenvEnvironmentErrorCheck() *types.AgentError {
 }
 
 func (i *defaultRInterpreter) isRenvInstalled(rexecPath string) *types.AgentError {
-	args := []string{"-s", "-e", "cat(system.file(package = \"renv\"))"}
-	output, _, err := i.cmdExecutor.RunCommand(rexecPath, args, i.base, i.log)
+	output, _, err := i.cmdExecutor.RunScript(rexecPath, []string{"-s"}, "cat(system.file(package = \"renv\"))", i.base, i.log)
 	if err != nil {
 		i.log.Error("Unable to determine if renv is installed", "error", err.Error())
 		return types.NewAgentError(
@@ -242,8 +241,7 @@ func (i *defaultRInterpreter) isRenvInstalled(rexecPath string) *types.AgentErro
 }
 
 func (i *defaultRInterpreter) renvStatus(rexecPath string) (string, *types.AgentError) {
-	args := []string{"-s", "-e", "renv::status()"}
-	output, _, err := i.cmdExecutor.RunCommand(rexecPath, args, i.base, i.log)
+	output, _, err := i.cmdExecutor.RunScript(rexecPath, []string{"-s"}, "renv::status()", i.base, i.log)
 	if err != nil {
 		i.log.Error("Error attempting to run renv::status()", "error", err.Error())
 		return "", types.NewAgentError(
@@ -468,13 +466,13 @@ func (i *defaultRInterpreter) getRenvLockfilePathFromRExecutable(rExecutable str
 	var renvLockRE = regexp.MustCompile(`^\[1\] "(.*)"`)
 
 	i.log.Info("Getting renv lockfile path from R executable", "r", rExecutable)
-	args := []string{"-s", "-e", "renv::paths$lockfile()"}
-	output, stderr, err := i.cmdExecutor.RunCommand(rExecutable, args, i.base, i.log)
+	cmd := "renv::paths$lockfile()"
+	output, stderr, err := i.cmdExecutor.RunScript(rExecutable, []string{"-s"}, cmd, i.base, i.log)
 	if err != nil {
 		if _, ok := err.(*exec.ExitError); ok {
 			i.log.Warn("Couldn't detect lockfile path from R executable (renv::paths$lockfile()); is renv installed?")
 		} else {
-			i.log.Warn("Error running R executable", "args", args)
+			i.log.Warn("Error running R executable", "script", "renv::paths$lockfile()")
 		}
 		return util.AbsolutePath{}, err
 	}
@@ -514,8 +512,7 @@ func (i *defaultRInterpreter) CreateLockfile(lockfilePath util.AbsolutePath) err
 		escaped := strings.ReplaceAll(lockfilePath.String(), `\`, `\\`)
 		cmd = fmt.Sprintf(`renv::snapshot(lockfile="%s")`, escaped)
 	}
-	args := []string{"-s", "-e", cmd}
-	stdout, stderr, err := i.cmdExecutor.RunCommand(rExecutable.String(), args, i.base, i.log)
+	stdout, stderr, err := i.cmdExecutor.RunScript(rExecutable.String(), []string{"-s"}, cmd, i.base, i.log)
 	i.log.Debug("renv::snapshot()", "out", string(stdout), "err", string(stderr))
 	return err
 }
diff --git a/internal/interpreters/r_interpreter_functional_test.go b/internal/interpreters/r_interpreter_functional_test.go
new file mode 100644
index 0000000000..62df7a4d14
--- /dev/null
+++ b/internal/interpreters/r_interpreter_functional_test.go
@@ -0,0 +1,179 @@
+package interpreters
+
+// Copyright (C) 2025 by Posit Software, PBC.
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/posit-dev/publisher/internal/logging"
+	"github.com/posit-dev/publisher/internal/types"
+	"github.com/posit-dev/publisher/internal/util"
+	"github.com/spf13/afero"
+	"github.com/stretchr/testify/suite"
+)
+
+// RInterpreterFunctionalSuite contains functional tests that actually
+// interact with real R installations
+type RInterpreterFunctionalSuite struct {
+	suite.Suite
+	testProjectDir     util.AbsolutePath
+	log                logging.Logger
+	fs                 afero.Fs
+	interpreter        RInterpreter
+	rExecutable        util.AbsolutePath
+	defaultInterpreter *defaultRInterpreter
+	renvInstalled      bool
+}
+
+func TestRInterpreterFunctionalSuite(t *testing.T) {
+	if testing.Short() {
+		t.Skip()
+	}
+
+	suite.Run(t, new(RInterpreterFunctionalSuite))
+}
+
+func (s *RInterpreterFunctionalSuite) SetupTest() {
+	// Set up the test directory
+	parentDir, err := os.MkdirTemp("", "r-interpreter-test-*")
+	s.Require().NoError(err)
+
+	s.fs = afero.NewOsFs()
+	s.testProjectDir = util.NewAbsolutePath(parentDir, s.fs)
+	s.log = logging.New()
+
+	// Initialize R interpreter once for all tests
+	s.interpreter, err = NewRInterpreter(s.testProjectDir, util.Path{}, s.log, nil, nil, nil)
+	s.Require().NoError(err)
+
+	// Try to get R executable
+	s.rExecutable, err = s.interpreter.GetRExecutable()
+	if err != nil {
+		s.T().Logf("R executable not found: %v", err)
+		// Not failing here, individual tests will skip if needed
+	} else {
+		s.T().Logf("Using R executable: %s", s.rExecutable)
+		s.defaultInterpreter = s.interpreter.(*defaultRInterpreter)
+
+		// Check if renv is installed
+		aerr := s.defaultInterpreter.isRenvInstalled(s.rExecutable.String())
+		s.renvInstalled = (aerr == nil)
+		if !s.renvInstalled {
+			s.T().Logf("renv is not installed: %s", aerr.Error())
+		} else {
+			s.T().Logf("renv is installed and available")
+		}
+	}
+}
+
+func (s *RInterpreterFunctionalSuite) TearDownTest() {
+	os.RemoveAll(s.testProjectDir.String())
+}
+
+func (s *RInterpreterFunctionalSuite) createTestRenvLock() {
+	// Create a simple but valid renv.lock file
+	// this file does not need to be valid, but must exist
+	lockContent := `{
+      "R": {
+        "Version": "4.2.3",
+        "Repositories": [
+          {
+            "Name": "CRAN",
+            "URL": "https://cloud.r-project.org"
+          }
+        ]
+      },
+      "Packages": {
+        "renv": {
+          "Package": "renv",
+          "Version": "1.1.4",
+          "Source": "Repository",
+          "Repository": "CRAN",
+          "Hash": "fa15e"
+        }
+      }
+    }`
+
+	lockPath := s.testProjectDir.Join("renv.lock")
+	err := lockPath.WriteFile([]byte(lockContent), 0644)
+	s.Require().NoError(err)
+}
+
+// TestIsRenvInstalled tests the isRenvInstalled function with a real R executable
+func (s *RInterpreterFunctionalSuite) TestIsRenvInstalled() {
+	// Attempt to detect if renv is installed
+	aerr := s.defaultInterpreter.isRenvInstalled(s.rExecutable.String())
+
+	// If renv is not installed, we expect an error with ErrorRenvPackageNotInstalled code
+	if aerr != nil {
+		// Check we got the expected error
+		s.Equal(types.ErrorRenvPackageNotInstalled, aerr.GetCode())
+		s.T().Logf("renv is not installed: %s", aerr.Error())
+		s.Contains(aerr.Data["Command"], "install.packages(\"renv\")")
+	} else {
+		s.T().Logf("renv is already installed on the system")
+	}
+}
+
+// TestRenvStatus tests the renvStatus function with a real R executable
+func (s *RInterpreterFunctionalSuite) TestRenvStatus() {
+	// Create test project with renv.lock
+	s.createTestRenvLock()
+
+	// Check renv status
+	statusOutput, aerr := s.defaultInterpreter.renvStatus(s.rExecutable.String())
+	s.Require().Nil(aerr)
+	s.T().Logf("renv status output: %s", statusOutput)
+	s.NotEmpty(statusOutput)
+}
+
+// TestGetRenvLockfilePathFromRExecutable tests the getRenvLockfilePathFromRExecutable function
+func (s *RInterpreterFunctionalSuite) TestGetRenvLockfilePathFromRExecutable() {
+	// Create test project with renv.lock
+	s.createTestRenvLock()
+
+	// Get lockfile path
+	lockfilePath, err := s.defaultInterpreter.getRenvLockfilePathFromRExecutable(s.rExecutable.String())
+	if err != nil {
+		s.T().Logf("Error getting lockfile path: %v", err)
+		// This is not necessarily a failure - renv might not be in a project context
+		return
+	}
+
+	s.T().Logf("Detected lockfile path: %s", lockfilePath)
+	s.NotEmpty(lockfilePath.String())
+
+	// If we got a path, verify it's a valid path that could exist
+	_, err = os.Stat(filepath.Dir(lockfilePath.String()))
+	s.NoError(err, "The directory containing the lockfile should exist")
+}
+
+// TestCreateLockfile tests the CreateLockfile function
+func (s *RInterpreterFunctionalSuite) TestCreateLockfile() {
+	// Create a temporary directory for the lockfile
+	lockfileDir := s.testProjectDir.Join("lockfile_test")
+	err := lockfileDir.MkdirAll(0755)
+	s.Require().NoError(err)
+
+	lockfilePath := lockfileDir.Join("custom_renv.lock")
+
+	// Create the lockfile - this might fail in non-project contexts,
+	// but we should still attempt it for real-world testing
+	err = s.interpreter.CreateLockfile(lockfilePath)
+	if err != nil {
+		s.T().Logf("Error creating lockfile (may be expected in non-project context): %v", err)
+		return
+	}
+
+	// If lockfile was created, verify it exists and has content
+	exists, err := lockfilePath.Exists()
+	s.NoError(err)
+	s.True(exists, "Lockfile should have been created")
+
+	content, err := lockfilePath.ReadFile()
+	s.NoError(err)
+	s.NotEmpty(content, "Lockfile should not be empty")
+	s.T().Logf("Successfully created lockfile at %s", lockfilePath)
+}
diff --git a/internal/interpreters/r_test.go b/internal/interpreters/r_test.go
index 802461f44c..7527cba888 100644
--- a/internal/interpreters/r_test.go
+++ b/internal/interpreters/r_test.go
@@ -68,7 +68,7 @@ func (s *RSuite) TestInit() {
 
 	executor := executortest.NewMockExecutor()
 	executor.On("RunCommand", mock.Anything, []string{"--version"}, mock.Anything, mock.Anything).Return([]byte("R version 4.3.0 (2023-04-21)"), nil, nil)
-	executor.On("RunCommand", mock.Anything, []string{"-s", "-e", "renv::paths$lockfile()"}, mock.Anything, mock.Anything).Return([]byte(`[1] "/test/sample-content/shinyapp/renv.lock"`), nil, nil).Once()
+	executor.On("RunScript", mock.Anything, []string{"-s"}, "renv::paths$lockfile()", mock.Anything, mock.Anything).Return([]byte(`[1] "/test/sample-content/shinyapp/renv.lock"`), nil, nil).Once()
 
 	i, err := NewRInterpreter(s.cwd, rPath.Path, log, executor, nil, nil)
 	s.NoError(err)
@@ -212,7 +212,7 @@ func (s *RSuite) TestGetRVersionFromExecutable() {
 
 		executor := executortest.NewMockExecutor()
 		executor.On("RunCommand", mock.Anything, []string{"--version"}, mock.Anything, mock.Anything).Return([]byte(tc.versionOutput), nil, nil)
-		executor.On("RunCommand", mock.Anything, []string{"-s", "-e", "renv::paths$lockfile()"}, mock.Anything, mock.Anything).Return([]byte(tc.pathsLockfileOutput), nil, nil)
+		executor.On("RunScript", mock.Anything, []string{"-s"}, "renv::paths$lockfile()", mock.Anything, mock.Anything).Return([]byte(tc.pathsLockfileOutput), nil, nil)
 
 		rInterpreter, err := NewRInterpreter(s.cwd, rPath.Path, log, executor, nil, nil)
 		s.NoError(err)
@@ -253,7 +253,7 @@ func (s *RSuite) TestGetRVersionFromExecutableWindows() {
 
 		executor := executortest.NewMockExecutor()
 		executor.On("RunCommand", mock.Anything, []string{"--version"}, mock.Anything, mock.Anything).Return(nil, []byte(tc.versionOutput), nil)
-		executor.On("RunCommand", mock.Anything, []string{"-s", "-e", "renv::paths$lockfile()"}, mock.Anything, mock.Anything).Return(nil, []byte(tc.pathsLockfileOutput), nil)
+		executor.On("RunScript", mock.Anything, []string{"-s"}, "renv::paths$lockfile()", mock.Anything, mock.Anything).Return(nil, []byte(tc.pathsLockfileOutput), nil)
 
 		rInterpreter, err := NewRInterpreter(s.cwd, rPath.Path, log, executor, nil, nil)
 		s.NoError(err)
@@ -486,7 +486,7 @@ func (s *RSuite) TestResolveRenvLockFileWithRSpecifyingDefaultNameAndExists() {
 
 	executor := executortest.NewMockExecutor()
 	outputLine := fmt.Sprintf(`[1] "%s"`, rPath.String())
-	executor.On("RunCommand", mock.Anything, []string{"-s", "-e", "renv::paths$lockfile()"}, mock.Anything, mock.Anything).Return([]byte(outputLine), nil, nil)
+	executor.On("RunScript", mock.Anything, []string{"-s"}, "renv::paths$lockfile()", mock.Anything, mock.Anything).Return([]byte(outputLine), nil, nil)
 	executor.On("RunCommand", mock.Anything, []string{"--version"}, mock.Anything, mock.Anything).Return([]byte(""), nil, errors.New("problem"))
 
 	i, _ := NewRInterpreter(s.cwd, util.Path{}, log, executor, nil, nil)
@@ -509,7 +509,7 @@ func (s *RSuite) TestResolveRenvLockFileWithRSpecifyingDefaultNameAndDoesNotExis
 	}
 
 	executor := executortest.NewMockExecutor()
-	executor.On("RunCommand", mock.Anything, []string{"-s", "-e", "renv::paths$lockfile()"}, mock.Anything, mock.Anything).Return([]byte(`[1] "internal/interpreters/renv.lock"`), nil, nil)
+	executor.On("RunScript", mock.Anything, []string{"-s"}, "renv::paths$lockfile()", mock.Anything, mock.Anything).Return([]byte(`[1] "internal/interpreters/renv.lock"`), nil, nil)
 	executor.On("RunCommand", mock.Anything, []string{"--version"}, mock.Anything, mock.Anything).Return([]byte(""), nil, errors.New("problem"))
 
 	i, _ := NewRInterpreter(s.cwd, util.Path{}, log, executor, nil, nil)
@@ -536,7 +536,7 @@ func (s *RSuite) TestResolveRenvLockFileWithRSpecialNameAndExists() {
 
 	executor := executortest.NewMockExecutor()
 	outputLine := fmt.Sprintf(`[1] "%s"`, rPath.String())
-	executor.On("RunCommand", mock.Anything, []string{"-s", "-e", "renv::paths$lockfile()"}, mock.Anything, mock.Anything).Return([]byte(outputLine), nil, nil)
+	executor.On("RunScript", mock.Anything, []string{"-s"}, "renv::paths$lockfile()", mock.Anything, mock.Anything).Return([]byte(outputLine), nil, nil)
 	executor.On("RunCommand", mock.Anything, []string{"--version"}, mock.Anything, mock.Anything).Return([]byte(""), nil, errors.New("problem"))
 
 	i, _ := NewRInterpreter(s.cwd, util.Path{}, log, executor, nil, nil)
@@ -569,7 +569,7 @@ func (s *RSuite) TestCreateLockfileWithNonEmptyPath() {
 	log := logging.New()
 
 	executor := executortest.NewMockExecutor()
-	executor.On("RunCommand", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return([]byte("success"), nil, nil)
+	executor.On("RunScript", mock.Anything, []string{"-s"}, mock.Anything, mock.Anything, mock.Anything).Return([]byte("success"), nil, nil)
 	executor.On("RunCommand", mock.Anything, []string{"--version"}, mock.Anything, mock.Anything).Return([]byte(""), nil, errors.New("problem"))
 
 	i, _ := NewRInterpreter(s.cwd, util.Path{}, log, executor, nil, nil)
@@ -586,7 +586,7 @@ func (s *RSuite) TestCreateLockfileWithEmptyPath() {
 	log := logging.New()
 
 	executor := executortest.NewMockExecutor()
-	executor.On("RunCommand", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return([]byte("success"), nil, nil)
+	executor.On("RunScript", mock.Anything, []string{"-s"}, "renv::snapshot()", mock.Anything, mock.Anything).Return([]byte("success"), nil, nil)
 	executor.On("RunCommand", mock.Anything, []string{"--version"}, mock.Anything, mock.Anything).Return([]byte(""), nil, errors.New("problem"))
 
 	i, _ := NewRInterpreter(s.cwd, util.Path{}, log, executor, nil, nil)
@@ -604,7 +604,7 @@ func (s *RSuite) TestRenvEnvironmentErrorCheck_renvNotInstalled() {
 
 	executor := executortest.NewMockExecutor()
 	executor.On("RunCommand", mock.Anything, []string{"--version"}, mock.Anything, mock.Anything).Return([]byte("R version 4.3.0 (2023-04-21)"), nil, nil)
-	executor.On("RunCommand", mock.Anything, []string{"-s", "-e", "cat(system.file(package = \"renv\"))"}, mock.Anything, mock.Anything).Return([]byte(""), nil, nil)
+	executor.On("RunScript", mock.Anything, []string{"-s"}, "cat(system.file(package = \"renv\"))", mock.Anything, mock.Anything).Return([]byte(""), nil, nil)
 
 	i, _ := NewRInterpreter(s.cwd, util.Path{}, log, executor, nil, nil)
 	interpreter := i.(*defaultRInterpreter)
@@ -628,7 +628,7 @@ func (s *RSuite) TestRenvEnvironmentErrorCheck_renvInstallCheckErr() {
 	renvCmdErr := errors.New("renv command errrz")
 	executor := executortest.NewMockExecutor()
 	executor.On("RunCommand", mock.Anything, []string{"--version"}, mock.Anything, mock.Anything).Return([]byte("R version 4.3.0 (2023-04-21)"), nil, nil)
-	executor.On("RunCommand", mock.Anything, []string{"-s", "-e", "cat(system.file(package = \"renv\"))"}, mock.Anything, mock.Anything).Return([]byte(""), nil, renvCmdErr)
+	executor.On("RunScript", mock.Anything, []string{"-s"}, "cat(system.file(package = \"renv\"))", mock.Anything, mock.Anything).Return([]byte(""), nil, renvCmdErr)
 
 	i, _ := NewRInterpreter(s.cwd, util.Path{}, log, executor, nil, nil)
 	interpreter := i.(*defaultRInterpreter)
@@ -649,8 +649,8 @@ func (s *RSuite) TestRenvEnvironmentErrorCheck_renvRequiresInit() {
 	renvStatusOutput := []byte("Use `renv::init()` to initialize the project.")
 	executor := executortest.NewMockExecutor()
 	executor.On("RunCommand", mock.Anything, []string{"--version"}, mock.Anything, mock.Anything).Return([]byte("R version 4.3.0 (2023-04-21)"), nil, nil)
-	executor.On("RunCommand", mock.Anything, []string{"-s", "-e", "cat(system.file(package = \"renv\"))"}, mock.Anything, mock.Anything).Return([]byte("/usr/dir/lib/R/x86_64/4.4/library/renv"), nil, nil)
-	executor.On("RunCommand", mock.Anything, []string{"-s", "-e", "renv::status()"}, mock.Anything, mock.Anything).Return(renvStatusOutput, nil, nil)
+	executor.On("RunScript", mock.Anything, []string{"-s"}, "cat(system.file(package = \"renv\"))", mock.Anything, mock.Anything).Return([]byte("/usr/dir/lib/R/x86_64/4.4/library/renv"), nil, nil)
+	executor.On("RunScript", mock.Anything, []string{"-s"}, "renv::status()", mock.Anything, mock.Anything).Return(renvStatusOutput, nil, nil)
 
 	i, _ := NewRInterpreter(s.cwd, util.Path{}, log, executor, nil, nil)
 	interpreter := i.(*defaultRInterpreter)
@@ -673,8 +673,8 @@ func (s *RSuite) TestRenvEnvironmentErrorCheck_lockfileMissing() {
 	renvStatusOutput := []byte("Use `renv::snapshot()` to create a lockfile.")
 	executor := executortest.NewMockExecutor()
 	executor.On("RunCommand", mock.Anything, []string{"--version"}, mock.Anything, mock.Anything).Return([]byte("R version 4.3.0 (2023-04-21)"), nil, nil)
-	executor.On("RunCommand", mock.Anything, []string{"-s", "-e", "cat(system.file(package = \"renv\"))"}, mock.Anything, mock.Anything).Return([]byte("/usr/dir/lib/R/x86_64/4.4/library/renv"), nil, nil)
-	executor.On("RunCommand", mock.Anything, []string{"-s", "-e", "renv::status()"}, mock.Anything, mock.Anything).Return(renvStatusOutput, nil, nil)
+	executor.On("RunScript", mock.Anything, []string{"-s"}, "cat(system.file(package = \"renv\"))", mock.Anything, mock.Anything).Return([]byte("/usr/dir/lib/R/x86_64/4.4/library/renv"), nil, nil)
+	executor.On("RunScript", mock.Anything, []string{"-s"}, "renv::status()", mock.Anything, mock.Anything).Return(renvStatusOutput, nil, nil)
 
 	i, _ := NewRInterpreter(s.cwd, util.Path{}, log, executor, nil, nil)
 	interpreter := i.(*defaultRInterpreter)
@@ -697,8 +697,8 @@ func (s *RSuite) TestRenvEnvironmentErrorCheck_unknownRenvStatus() {
 	renvStatusOutput := []byte("- The project is out-of-sync -- use `renv::status()` for details.")
 	executor := executortest.NewMockExecutor()
 	executor.On("RunCommand", mock.Anything, []string{"--version"}, mock.Anything, mock.Anything).Return([]byte("R version 4.3.0 (2023-04-21)"), nil, nil)
-	executor.On("RunCommand", mock.Anything, []string{"-s", "-e", "cat(system.file(package = \"renv\"))"}, mock.Anything, mock.Anything).Return([]byte("/usr/dir/lib/R/x86_64/4.4/library/renv"), nil, nil)
-	executor.On("RunCommand", mock.Anything, []string{"-s", "-e", "renv::status()"}, mock.Anything, mock.Anything).Return(renvStatusOutput, nil, nil)
+	executor.On("RunScript", mock.Anything, []string{"-s"}, "cat(system.file(package = \"renv\"))", mock.Anything, mock.Anything).Return([]byte("/usr/dir/lib/R/x86_64/4.4/library/renv"), nil, nil)
+	executor.On("RunScript", mock.Anything, []string{"-s"}, "renv::status()", mock.Anything, mock.Anything).Return(renvStatusOutput, nil, nil)
 
 	i, _ := NewRInterpreter(s.cwd, util.Path{}, log, executor, nil, nil)
 	interpreter := i.(*defaultRInterpreter)
diff --git a/internal/publish/r_functional_test.go b/internal/publish/r_functional_test.go
new file mode 100644
index 0000000000..4271a2207f
--- /dev/null
+++ b/internal/publish/r_functional_test.go
@@ -0,0 +1,219 @@
+package publish
+
+// Copyright (C) 2025 by Posit Software, PBC.
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/posit-dev/publisher/internal/accounts"
+	"github.com/posit-dev/publisher/internal/clients/connect"
+	"github.com/posit-dev/publisher/internal/config"
+	"github.com/posit-dev/publisher/internal/deployment"
+	"github.com/posit-dev/publisher/internal/events"
+	"github.com/posit-dev/publisher/internal/executor"
+	"github.com/posit-dev/publisher/internal/inspect/dependencies/renv"
+	"github.com/posit-dev/publisher/internal/interpreters"
+	"github.com/posit-dev/publisher/internal/logging"
+	"github.com/posit-dev/publisher/internal/schema"
+	"github.com/posit-dev/publisher/internal/state"
+	"github.com/posit-dev/publisher/internal/types"
+	"github.com/posit-dev/publisher/internal/util"
+	"github.com/spf13/afero"
+	"github.com/stretchr/testify/mock"
+	"github.com/stretchr/testify/suite"
+)
+
+type RPublishFunctionalSuite struct {
+	suite.Suite
+	testProjectDir util.AbsolutePath
+	log            logging.Logger
+	stateStore     *state.State
+	emitter        events.Emitter
+}
+
+func TestRPublishFunctionalSuite(t *testing.T) {
+	if testing.Short() {
+		t.Skip()
+	}
+	suite.Run(t, new(RPublishFunctionalSuite))
+}
+
+func (s *RPublishFunctionalSuite) SetupTest() {
+	// Set up the test directory
+	parentDir, err := os.MkdirTemp("", "r-packages-test-*")
+	s.Require().NoError(err)
+
+	// Create a subdirectory with spaces in the name
+	dirWithSpaces := filepath.Join(parentDir, "WithSpaces")
+	err = os.MkdirAll(dirWithSpaces, 0755)
+	s.Require().NoError(err)
+
+	s.testProjectDir = util.NewAbsolutePath(dirWithSpaces, afero.NewOsFs())
+
+	// Set up our test state
+	s.stateStore = state.Empty()
+	s.stateStore.Dir = s.testProjectDir
+	s.emitter = events.NewCapturingEmitter()
+	s.log = logging.New()
+
+	// Create minimal renv.lock file in the temp directory
+	s.createTestRenvLock()
+}
+
+func (s *RPublishFunctionalSuite) TearDownTest() {
+	parentDir := filepath.Dir(s.testProjectDir.String())
+	os.RemoveAll(parentDir)
+}
+
+func (s *RPublishFunctionalSuite) createTestRenvLock() {
+	// Initialize R interpreter and check for R executable and renv availability
+	rInterpreter, err := interpreters.NewRInterpreter(s.testProjectDir, util.Path{}, s.log, nil, nil, nil)
+	s.Require().NoError(err)
+
+	// use R to create a legit renv.lock file
+	rExecutable, err := rInterpreter.GetRExecutable()
+	s.Require().NoError(err)
+
+	exec := executor.NewExecutor()
+	_, _, err = exec.RunScript(rExecutable.String(), []string{"-s"}, "renv::init()", s.testProjectDir, s.log)
+	s.Require().NoError(err)
+}
+
+func (s *RPublishFunctionalSuite) TestGetRPackagesFunctional() {
+	// Configure the state
+	s.stateStore.Config = &config.Config{
+		R: &config.R{
+			PackageFile: "renv.lock",
+		},
+	}
+
+	rInterpreter, err := interpreters.NewRInterpreter(s.testProjectDir, util.Path{}, s.log, nil, nil, nil)
+	s.Require().NoError(err)
+
+	rExecutable, err := rInterpreter.GetRExecutable()
+	s.T().Logf("R executable path: %s", rExecutable)
+	// Don't require this to succeed as it might not be available in all test environments
+	if err != nil {
+		s.T().Logf("Could not get R executable: %v", err)
+	}
+
+	mapper, err := renv.NewPackageMapper(s.testProjectDir, util.Path{}, s.log)
+	s.Require().NoError(err)
+
+	publisher := &defaultPublisher{
+		State:          s.stateStore,
+		log:            s.log,
+		emitter:        s.emitter,
+		rPackageMapper: mapper,
+	}
+
+	// Actually call getRPackages
+	packageMap, err := publisher.getRPackages()
+
+	// we should have a valid package map which includes renv
+	s.Require().Nil(err)
+	s.Require().Contains(packageMap, "renv")
+}
+
+func (s *RPublishFunctionalSuite) TestPublishWithClientFunctional() {
+	// Set up a mock client
+	client := connect.NewMockClient()
+
+	// Set up test account
+	account := &accounts.Account{
+		ServerType: accounts.ServerTypeConnect,
+		Name:       "test-account",
+		URL:        "https://connect.example.com",
+	}
+	client.On("TestAuthentication", mock.Anything).Return(&connect.User{
+		Username: "test-user",
+		Email:    "test@example.com",
+	}, nil)
+
+	// Mock responses
+	contentID := types.ContentID("test-content-id")
+	client.On("CreateDeployment", mock.Anything, mock.Anything).Return(contentID, nil)
+	client.On("CheckCapabilities", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil)
+	client.On("UpdateDeployment", contentID, mock.Anything, mock.Anything).Return(nil)
+	client.On("SetEnvVars", contentID, mock.Anything, mock.Anything).Return(nil)
+	bundleID := types.BundleID("test-bundle-id")
+	client.On("UploadBundle", contentID, mock.Anything, mock.Anything).Return(bundleID, nil)
+	taskID := types.TaskID("test-task-id")
+	client.On("DeployBundle", contentID, bundleID, mock.Anything).Return(taskID, nil)
+	client.On("WaitForTask", taskID, mock.Anything, mock.Anything).Return(nil)
+	client.On("ValidateDeployment", contentID, mock.Anything).Return(nil)
+
+	// Config for R Shiny app (instead of Python Dash)
+	cfg := config.New()
+	cfg.Schema = schema.ConfigSchemaURL
+	cfg.Type = config.ContentTypeRShiny
+	cfg.Entrypoint = "app.R"
+	cfg.Title = "Test R Application"
+	cfg.Environment = map[string]string{"TEST_VAR": "test-value"}
+	cfg.Validate = true
+
+	cfg.R = &config.R{
+		PackageManager: "renv",
+		PackageFile:    "renv.lock",
+	}
+
+	cfg.Files = []string{
+		"app.R",
+		"renv.lock",
+	}
+
+	stateStore := &state.State{
+		Dir:        s.testProjectDir,
+		Account:    account,
+		Config:     cfg,
+		ConfigName: "test-config",
+		SaveName:   "test-deployment",
+		TargetName: "test-deployment",
+	}
+
+	rPackageMapper, err := renv.NewPackageMapper(s.testProjectDir, util.Path{}, s.log)
+	s.Require().NoError(err)
+
+	publisher := &defaultPublisher{
+		State:          stateStore,
+		log:            s.log,
+		emitter:        events.NewCapturingEmitter(),
+		rPackageMapper: rPackageMapper,
+	}
+
+	// Test files to be deployed
+	appRPath := s.testProjectDir.Join("app.R")
+	err = appRPath.WriteFile([]byte(`
+library(shiny)
+
+ui <- fluidPage(
+  titlePanel("Hello Shiny!")
+)
+
+server <- function(input, output) {
+}
+
+shinyApp(ui = ui, server = server)
+`), 0644)
+	s.Require().NoError(err)
+
+	// The actual test call
+	err = publisher.publishWithClient(account, client)
+	s.Require().NoError(err)
+
+	// Verify the mock calls were made as expected
+	client.AssertExpectations(s.T())
+
+	// Verify deployment record was created with correct information
+	recordPath := deployment.GetDeploymentPath(stateStore.Dir, stateStore.TargetName)
+	record, err := deployment.FromFile(recordPath)
+	s.Require().NoError(err)
+
+	s.Equal(contentID, record.ID)
+	s.Equal(bundleID, record.BundleID)
+	s.Equal("https://connect.example.com/connect/#/apps/test-content-id", record.DashboardURL)
+	s.Equal("https://connect.example.com/content/test-content-id/", record.DirectURL)
+	s.NotEmpty(record.DeployedAt)
+}
diff --git a/justfile b/justfile
index 3be097b196..031f5918ff 100644
--- a/justfile
+++ b/justfile
@@ -233,8 +233,9 @@ run *args:
     pathname=`just executable-path`
     ${pathname} {{ args }}
 
-# Execute unit tests.
-test *args=("./..."):
+# Execute unit tests. -short disables our functional tests
+# To run all of the tests, use `just test ./...`
+test *args=("-short ./..."):
     #!/usr/bin/env bash
     set -eou pipefail
     {{ _with_debug }}


Reviews:
user: login: jonkeane
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-19T12:34:09Z
user: login: jonkeane
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-19T12:35:47Z
user: login: jonkeane
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-19T12:38:03Z
user: login: dotNomad
body: Few questions and an error when running `just test`. I'll continue the review based on the new info
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-19T17:53:55Z
user: login: jonkeane
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-19T18:15:13Z
user: login: jonkeane
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-19T18:19:21Z
user: login: jonkeane
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-19T18:24:13Z
user: login: dotNomad
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-19T18:24:55Z
user: login: jonkeane
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-19T18:27:19Z
user: login: tdstein
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-19T18:35:07Z
user: login: tdstein
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-19T18:37:51Z
user: login: tdstein
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-19T18:39:12Z
user: login: tdstein
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-19T18:43:52Z
user: login: tdstein
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-19T18:47:03Z
user: login: jonkeane
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-19T19:12:06Z
user: login: jonkeane
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-19T19:15:33Z
user: login: tdstein
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-19T22:13:02Z
user: login: tdstein
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-20T13:47:58Z
user: login: jonkeane
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-23T13:52:27Z
user: login: jonkeane
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-23T23:05:53Z
user: login: dotNomad
body: I was able to run `just test` with no problems, and this is look very clean. Marking as approved, but I'd wait a moment for @tdstein to take a look for the Go specific review like answer the question around `defer` being in the right spot.
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-05-23T23:19:59Z
user: login: tdstein
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-27T14:03:13Z
user: login: tdstein
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-27T14:05:30Z
user: login: tdstein
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-27T14:13:12Z
user: login: jonkeane
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-27T14:17:06Z
user: login: tdstein
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-27T14:18:38Z
user: login: jonkeane
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-27T14:18:50Z
user: login: tdstein
body: Overall, this looks good enough to release as a patch. Any of the lingering things could be addressed as follow up.
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-05-27T14:20:48Z
user: login: tdstein
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-27T14:31:08Z

Review comments:
diff_hunk: @@ -13,6 +13,28 @@ jobs:
         with:
           fetch-depth: 0
       - uses: extractions/setup-just@v2
+
+      # Install R with rig to ensure we are compatible
+      - name: Install rig with apt
+        if: ${{ runner.os == 'Linux' }}
+        run: |
+          sudo curl -L https://rig.r-pkg.org/deb/rig.gpg -o /etc/apt/trusted.gpg.d/rig.gpg
+          sudo sh -c 'echo "deb http://rig.r-pkg.org/deb rig main" > /etc/apt/sources.list.d/rig.list'
+          sudo apt update
+          sudo apt install -y r-rig
+      - name: Install rig with brew
+        if: ${{ runner.os == 'macOS' }}
+        run: |
+          brew tap r-lib/rig
+          brew install --cask rig
+      - name: Install rig with Choco
+        if: ${{ runner.os == 'Windows' }}
+        run: choco install rig
+
+      - name: Install R with rig
+        run: rig add release
+      - name: Install renv package
+        run: Rscript -e "install.packages('renv')"
path: .github/workflows/agent.yaml
user: login: jonkeane
body: We could consider running these functional style tests in a separate action, though IMHO that's a refinement for later.
created_at: 2025-05-19T12:34:08Z
author_association: COLLABORATOR
diff_hunk: @@ -40,3 +42,57 @@ func (e *defaultExecutor) RunCommand(executable string, args []string, cwd util.
 	}
 	return stdout.Bytes(), stderrBuf.Bytes(), err
 }
+
+// createTempRScript creates a temporary file with R code and returns the file path.
+// The caller is responsible for removing the temporary file.
+func createTempRScript(content string) (string, func(), error) {
+	tempFile, err := os.CreateTemp("", "*")
+	if err != nil {
+		return "", nil, fmt.Errorf("failed to create temporary R script: %w", err)
+	}
+
+	// Write the R code to the temporary file
+	if _, err := tempFile.WriteString(content); err != nil {
+		tempFile.Close()
+		os.Remove(tempFile.Name())
+		return "", nil, fmt.Errorf("failed to write to temporary R script: %w", err)
+	}
+	if err := tempFile.Sync(); err != nil {
+		tempFile.Close()
+		os.Remove(tempFile.Name())
+		return "", nil, fmt.Errorf("failed to flush temporary R script to disk: %w", err)
+	}
+
+	// Return the file path and a cleanup function
+	cleanup := func() {
+		tempFile.Close()
+		os.Remove(tempFile.Name())
+	}
+	return tempFile.Name(), cleanup, nil
+}
path: internal/executor/executor.go
user: login: jonkeane
body: I was a little surprised I couldn't find a function that did this in the standard library / the filesystem packages we have. But maybe I just missed it. I'm happy to 🚮 this code if there's a better + more standard thing we can just use instead of not-invented-hereing this.
created_at: 2025-05-19T12:35:47Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,213 @@
+package interpreters
path: internal/interpreters/r_interpreter_functional_test.go
user: login: jonkeane
body: Here and in the `r_functional_test.go` — I'm not sure if this is the right pattern for doing functional-style tests like this (e.g. comingled with the go unit tests). But I do think it is good to have them to catch circumstances like this one where our assumptions about how our calls get made hit operating system quirks. 
created_at: 2025-05-19T12:38:03Z
author_association: COLLABORATOR
diff_hunk: @@ -13,6 +13,28 @@ jobs:
         with:
           fetch-depth: 0
       - uses: extractions/setup-just@v2
+
+      # Install R with rig to ensure we are compatible
+      - name: Install rig with apt
+        if: ${{ runner.os == 'Linux' }}
+        run: |
+          sudo curl -L https://rig.r-pkg.org/deb/rig.gpg -o /etc/apt/trusted.gpg.d/rig.gpg
+          sudo sh -c 'echo "deb http://rig.r-pkg.org/deb rig main" > /etc/apt/sources.list.d/rig.list'
+          sudo apt update
+          sudo apt install -y r-rig
+      - name: Install rig with brew
+        if: ${{ runner.os == 'macOS' }}
+        run: |
+          brew tap r-lib/rig
+          brew install --cask rig
+      - name: Install rig with Choco
+        if: ${{ runner.os == 'Windows' }}
+        run: choco install rig
+
+      - name: Install R with rig
+        run: rig add release
+      - name: Install renv package
+        run: Rscript -e "install.packages('renv')"
path: .github/workflows/agent.yaml
user: login: dotNomad
body: Sounds good, would you mind creating a follow-up issue for that when we merge this?

I found it a bit confusing these were part of the unit tests at first blush, but understand it a bit more after digging in. The separation would make it a bit easier to understand right from the start.
created_at: 2025-05-19T17:44:34Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,213 @@
+package interpreters
+
+// Copyright (C) 2025 by Posit Software, PBC.
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/posit-dev/publisher/internal/logging"
+	"github.com/posit-dev/publisher/internal/types"
+	"github.com/posit-dev/publisher/internal/util"
+	"github.com/spf13/afero"
+	"github.com/stretchr/testify/suite"
+)
+
+// RInterpreterFunctionalSuite contains functional tests that actually
+// interact with real R installations
+type RInterpreterFunctionalSuite struct {
+	suite.Suite
+	testProjectDir     util.AbsolutePath
+	log                logging.Logger
+	fs                 afero.Fs
+	interpreter        RInterpreter
+	rExecutable        util.AbsolutePath
+	defaultInterpreter *defaultRInterpreter
+	renvInstalled      bool
+}
+
+func TestRInterpreterFunctionalSuite(t *testing.T) {
+	suite.Run(t, new(RInterpreterFunctionalSuite))
+}
+
+func (s *RInterpreterFunctionalSuite) SetupTest() {
+	// Set up the test directory
+	parentDir, err := os.MkdirTemp("", "r-interpreter-test-*")
+	s.Require().NoError(err)
+
+	s.fs = afero.NewOsFs()
+	s.testProjectDir = util.NewAbsolutePath(parentDir, s.fs)
+	s.log = logging.New()
+
+	// Initialize R interpreter once for all tests
+	s.interpreter, err = NewRInterpreter(s.testProjectDir, util.Path{}, s.log, nil, nil, nil)
+	s.Require().NoError(err)
+
+	// Try to get R executable
+	s.rExecutable, err = s.interpreter.GetRExecutable()
+	if err != nil {
+		s.T().Logf("R executable not found: %v", err)
+		// Not failing here, individual tests will skip if needed
+	} else {
+		s.T().Logf("Using R executable: %s", s.rExecutable)
+		s.defaultInterpreter = s.interpreter.(*defaultRInterpreter)
+
+		// Check if renv is installed
+		aerr := s.defaultInterpreter.isRenvInstalled(s.rExecutable.String())
+		s.renvInstalled = (aerr == nil)
+		if !s.renvInstalled {
+			s.T().Logf("renv is not installed: %s", aerr.Error())
+		} else {
+			s.T().Logf("renv is installed and available")
+		}
+	}
+}
+
+// skipIfNoR skips the current test if R executable is not available
+// unless we're running in CI where we should enforce that R tests run
+func (s *RInterpreterFunctionalSuite) skipIfNoR() bool {
+	if os.Getenv("GITHUB_ACTIONS") != "true" && s.rExecutable.String() == "" {
+		s.T().Skip("Skipping test: R executable not found")
+		return true
+	}
+	return false
+}
+
+// skipIfNoRenv skips the current test if either R executable is not available or renv is not installed
+// unless we're running in CI where we should enforce that R tests run
+func (s *RInterpreterFunctionalSuite) skipIfNoRenv() bool {
+	if s.skipIfNoR() {
+		return true
+	}
+	if os.Getenv("GITHUB_ACTIONS") != "true" && !s.renvInstalled {
+		s.T().Skip("Skipping test because renv is not installed")
+		return true
+	}
+	return false
+}
+
+func (s *RInterpreterFunctionalSuite) TearDownTest() {
+	os.RemoveAll(s.testProjectDir.String())
+}
+
+func (s *RInterpreterFunctionalSuite) createTestRenvLock() {
+	// Create a simple but valid renv.lock file
+	lockContent := `{
+      "R": {
+        "Version": "4.2.3",
+        "Repositories": [
+          {
+            "Name": "CRAN",
+            "URL": "https://cloud.r-project.org"
+          }
+        ]
+      },
+      "Packages": {
+        "renv": {
+          "Package": "renv",
+          "Version": "1.1.4",
+          "Source": "Repository",
+          "Repository": "CRAN",
+          "Hash": "fa15e"
+        }
+      }
+    }`
+
+	lockPath := s.testProjectDir.Join("renv.lock")
+	err := lockPath.WriteFile([]byte(lockContent), 0644)
+	s.Require().NoError(err)
+}
+
+// TestIsRenvInstalled tests the isRenvInstalled function with a real R executable
+func (s *RInterpreterFunctionalSuite) TestIsRenvInstalled() {
+	if s.skipIfNoR() {
+		return
+	}
+
+	// Attempt to detect if renv is installed
+	aerr := s.defaultInterpreter.isRenvInstalled(s.rExecutable.String())
+
+	// If renv is not installed, we expect an error with ErrorRenvPackageNotInstalled code
+	if aerr != nil {
+		// Check we got the expected error
+		s.Equal(types.ErrorRenvPackageNotInstalled, aerr.GetCode())
+		s.T().Logf("renv is not installed: %s", aerr.Error())
+		s.Contains(aerr.Data["Command"], "install.packages(\"renv\")")
+	} else {
+		s.T().Logf("renv is already installed on the system")
+	}
+}
+
+// TestRenvStatus tests the renvStatus function with a real R executable
+func (s *RInterpreterFunctionalSuite) TestRenvStatus() {
+	if s.skipIfNoRenv() {
+		return
+	}
+
+	// Create test project with renv.lock
+	s.createTestRenvLock()
+
+	// Check renv status
+	statusOutput, aerr := s.defaultInterpreter.renvStatus(s.rExecutable.String())
+	s.Require().Nil(aerr)
+	s.T().Logf("renv status output: %s", statusOutput)
+	s.NotEmpty(statusOutput)
+}
+
+// TestGetRenvLockfilePathFromRExecutable tests the getRenvLockfilePathFromRExecutable function
+func (s *RInterpreterFunctionalSuite) TestGetRenvLockfilePathFromRExecutable() {
+	if s.skipIfNoRenv() {
+		return
+	}
+
+	// Create test project with renv.lock
+	s.createTestRenvLock()
+
+	// Get lockfile path
+	lockfilePath, err := s.defaultInterpreter.getRenvLockfilePathFromRExecutable(s.rExecutable.String())
+	if err != nil {
+		s.T().Logf("Error getting lockfile path: %v", err)
path: internal/interpreters/r_interpreter_functional_test.go
user: login: dotNomad
body: I ran `just test` locally and got a failure:

```
        r_functional_test.go:170: 
                Error Trace:    /Users/jordan/development/publishing-client/internal/publish/r_functional_test.go:170
                Error:          Expected nil, but got: &types.AgentError{Code:"renvPackageVersionMismatch", Err:(*errors.errorString)(0x1400003a2a0), Message:"Package renv: versions in lockfile '1.1.4' and library '1.0.7' are out of sync. Use renv::restore() or renv::snapshot() to synchronize.", Op:"", Data:types.ErrorData{"LibraryVersion":"1.0.7", "Lockfile":"/var/folders/wn/83k9zr2s791brfmfk90c_6vh0000gp/T/r-packages-test-2533733140/With Spaces/renv.lock", "LockfileVersion":"1.1.4", "Package":"renv"}}
                Test:           TestRPublishFunctionalSuite/TestGetRPackagesFunctional
    --- FAIL: TestRPublishFunctionalSuite/TestPublishWithClientFunctional (1.55s)
        r_functional_test.go:82: Using R executable: /usr/local/bin/R
        r_functional_test.go:96: renv is installed but has issues: the renv environment for this project is not in a healthy state. Run renv::status() for more details
        r_functional_test.go:304: 
                Error Trace:    /Users/jordan/development/publishing-client/internal/publish/r_functional_test.go:304
                Error:          Received unexpected error:
                                package renv: versions in lockfile '1.1.4' and library '1.0.7' are out of sync. Use renv::restore() or renv::snapshot() to synchronize
```

I have `R 4.4.2` installed locally with `rig`. Perhaps there is some additional setup I need to get the tests working as expected?


created_at: 2025-05-19T17:48:21Z
author_association: COLLABORATOR
diff_hunk: @@ -68,7 +68,7 @@ func (s *RSuite) TestInit() {
 
 	executor := executortest.NewMockExecutor()
 	executor.On("RunCommand", mock.Anything, []string{"--version"}, mock.Anything, mock.Anything).Return([]byte("R version 4.3.0 (2023-04-21)"), nil, nil)
-	executor.On("RunCommand", mock.Anything, []string{"-s", "-e", "renv::paths$lockfile()"}, mock.Anything, mock.Anything).Return([]byte(`[1] "/test/sample-content/shinyapp/renv.lock"`), nil, nil).Once()
+	executor.On("RunScript", mock.Anything, []string{"-s"}, "renv::paths$lockfile()", mock.Anything, mock.Anything).Return([]byte(`[1] "/test/sample-content/shinyapp/renv.lock"`), nil, nil).Once()
path: internal/interpreters/r_test.go
user: login: dotNomad
body: If I'm understanding correctly we are changing these from `RunCommand` to `RunScript` because `R... -f` can be used. For my own edification what is the distinction for when to run using `RunScript` and `RunCommand`?
created_at: 2025-05-19T17:52:56Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,213 @@
+package interpreters
+
+// Copyright (C) 2025 by Posit Software, PBC.
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/posit-dev/publisher/internal/logging"
+	"github.com/posit-dev/publisher/internal/types"
+	"github.com/posit-dev/publisher/internal/util"
+	"github.com/spf13/afero"
+	"github.com/stretchr/testify/suite"
+)
+
+// RInterpreterFunctionalSuite contains functional tests that actually
+// interact with real R installations
+type RInterpreterFunctionalSuite struct {
+	suite.Suite
+	testProjectDir     util.AbsolutePath
+	log                logging.Logger
+	fs                 afero.Fs
+	interpreter        RInterpreter
+	rExecutable        util.AbsolutePath
+	defaultInterpreter *defaultRInterpreter
+	renvInstalled      bool
+}
+
+func TestRInterpreterFunctionalSuite(t *testing.T) {
+	suite.Run(t, new(RInterpreterFunctionalSuite))
+}
+
+func (s *RInterpreterFunctionalSuite) SetupTest() {
+	// Set up the test directory
+	parentDir, err := os.MkdirTemp("", "r-interpreter-test-*")
+	s.Require().NoError(err)
+
+	s.fs = afero.NewOsFs()
+	s.testProjectDir = util.NewAbsolutePath(parentDir, s.fs)
+	s.log = logging.New()
+
+	// Initialize R interpreter once for all tests
+	s.interpreter, err = NewRInterpreter(s.testProjectDir, util.Path{}, s.log, nil, nil, nil)
+	s.Require().NoError(err)
+
+	// Try to get R executable
+	s.rExecutable, err = s.interpreter.GetRExecutable()
+	if err != nil {
+		s.T().Logf("R executable not found: %v", err)
+		// Not failing here, individual tests will skip if needed
+	} else {
+		s.T().Logf("Using R executable: %s", s.rExecutable)
+		s.defaultInterpreter = s.interpreter.(*defaultRInterpreter)
+
+		// Check if renv is installed
+		aerr := s.defaultInterpreter.isRenvInstalled(s.rExecutable.String())
+		s.renvInstalled = (aerr == nil)
+		if !s.renvInstalled {
+			s.T().Logf("renv is not installed: %s", aerr.Error())
+		} else {
+			s.T().Logf("renv is installed and available")
+		}
+	}
+}
+
+// skipIfNoR skips the current test if R executable is not available
+// unless we're running in CI where we should enforce that R tests run
+func (s *RInterpreterFunctionalSuite) skipIfNoR() bool {
+	if os.Getenv("GITHUB_ACTIONS") != "true" && s.rExecutable.String() == "" {
+		s.T().Skip("Skipping test: R executable not found")
+		return true
+	}
+	return false
+}
+
+// skipIfNoRenv skips the current test if either R executable is not available or renv is not installed
+// unless we're running in CI where we should enforce that R tests run
+func (s *RInterpreterFunctionalSuite) skipIfNoRenv() bool {
+	if s.skipIfNoR() {
+		return true
+	}
+	if os.Getenv("GITHUB_ACTIONS") != "true" && !s.renvInstalled {
+		s.T().Skip("Skipping test because renv is not installed")
+		return true
+	}
+	return false
+}
+
+func (s *RInterpreterFunctionalSuite) TearDownTest() {
+	os.RemoveAll(s.testProjectDir.String())
+}
+
+func (s *RInterpreterFunctionalSuite) createTestRenvLock() {
+	// Create a simple but valid renv.lock file
+	lockContent := `{
+      "R": {
+        "Version": "4.2.3",
+        "Repositories": [
+          {
+            "Name": "CRAN",
+            "URL": "https://cloud.r-project.org"
+          }
+        ]
+      },
+      "Packages": {
+        "renv": {
+          "Package": "renv",
+          "Version": "1.1.4",
+          "Source": "Repository",
+          "Repository": "CRAN",
+          "Hash": "fa15e"
+        }
+      }
+    }`
+
+	lockPath := s.testProjectDir.Join("renv.lock")
+	err := lockPath.WriteFile([]byte(lockContent), 0644)
+	s.Require().NoError(err)
+}
+
+// TestIsRenvInstalled tests the isRenvInstalled function with a real R executable
+func (s *RInterpreterFunctionalSuite) TestIsRenvInstalled() {
+	if s.skipIfNoR() {
+		return
+	}
+
+	// Attempt to detect if renv is installed
+	aerr := s.defaultInterpreter.isRenvInstalled(s.rExecutable.String())
+
+	// If renv is not installed, we expect an error with ErrorRenvPackageNotInstalled code
+	if aerr != nil {
+		// Check we got the expected error
+		s.Equal(types.ErrorRenvPackageNotInstalled, aerr.GetCode())
+		s.T().Logf("renv is not installed: %s", aerr.Error())
+		s.Contains(aerr.Data["Command"], "install.packages(\"renv\")")
+	} else {
+		s.T().Logf("renv is already installed on the system")
+	}
+}
+
+// TestRenvStatus tests the renvStatus function with a real R executable
+func (s *RInterpreterFunctionalSuite) TestRenvStatus() {
+	if s.skipIfNoRenv() {
+		return
+	}
+
+	// Create test project with renv.lock
+	s.createTestRenvLock()
+
+	// Check renv status
+	statusOutput, aerr := s.defaultInterpreter.renvStatus(s.rExecutable.String())
+	s.Require().Nil(aerr)
+	s.T().Logf("renv status output: %s", statusOutput)
+	s.NotEmpty(statusOutput)
+}
+
+// TestGetRenvLockfilePathFromRExecutable tests the getRenvLockfilePathFromRExecutable function
+func (s *RInterpreterFunctionalSuite) TestGetRenvLockfilePathFromRExecutable() {
+	if s.skipIfNoRenv() {
+		return
+	}
+
+	// Create test project with renv.lock
+	s.createTestRenvLock()
+
+	// Get lockfile path
+	lockfilePath, err := s.defaultInterpreter.getRenvLockfilePathFromRExecutable(s.rExecutable.String())
+	if err != nil {
+		s.T().Logf("Error getting lockfile path: %v", err)
path: internal/interpreters/r_interpreter_functional_test.go
user: login: jonkeane
body: Ah, no I should catch _this_ condition and also probably skip too. The approach I took is that locally, if you don't have R or renv these pass, but are forced in CI. I'll update this test to skip (locally) if there's a mismatch.
created_at: 2025-05-19T18:15:13Z
author_association: COLLABORATOR
diff_hunk: @@ -68,7 +68,7 @@ func (s *RSuite) TestInit() {
 
 	executor := executortest.NewMockExecutor()
 	executor.On("RunCommand", mock.Anything, []string{"--version"}, mock.Anything, mock.Anything).Return([]byte("R version 4.3.0 (2023-04-21)"), nil, nil)
-	executor.On("RunCommand", mock.Anything, []string{"-s", "-e", "renv::paths$lockfile()"}, mock.Anything, mock.Anything).Return([]byte(`[1] "/test/sample-content/shinyapp/renv.lock"`), nil, nil).Once()
+	executor.On("RunScript", mock.Anything, []string{"-s"}, "renv::paths$lockfile()", mock.Anything, mock.Anything).Return([]byte(`[1] "/test/sample-content/shinyapp/renv.lock"`), nil, nil).Once()
path: internal/interpreters/r_test.go
user: login: jonkeane
body: Correct, exactly. 

That's (basically) the only difference between them. There are a few calls we make that call things like `R --version` where no R code is executed. In those cases we have less exposure to quoting issues on windows (though not 0, these are also now tested in our functional tests).

I should probably add a comment here that explains to us `RunScript` when one has code to execute, but if you're just calling with flags like `--version` `RunCommand` is the way to go. 

Alternatively, we could collapse these functions together (e.g. if one provides a script argument, write things out, add "-f" to args, and run that way, if one doesn't then don't do that) — though IMO it's a bit confusing to have rather different experiences based on arguments being present or not.
created_at: 2025-05-19T18:19:21Z
author_association: COLLABORATOR
diff_hunk: @@ -13,6 +13,28 @@ jobs:
         with:
           fetch-depth: 0
       - uses: extractions/setup-just@v2
+
+      # Install R with rig to ensure we are compatible
+      - name: Install rig with apt
+        if: ${{ runner.os == 'Linux' }}
+        run: |
+          sudo curl -L https://rig.r-pkg.org/deb/rig.gpg -o /etc/apt/trusted.gpg.d/rig.gpg
+          sudo sh -c 'echo "deb http://rig.r-pkg.org/deb rig main" > /etc/apt/sources.list.d/rig.list'
+          sudo apt update
+          sudo apt install -y r-rig
+      - name: Install rig with brew
+        if: ${{ runner.os == 'macOS' }}
+        run: |
+          brew tap r-lib/rig
+          brew install --cask rig
+      - name: Install rig with Choco
+        if: ${{ runner.os == 'Windows' }}
+        run: choco install rig
+
+      - name: Install R with rig
+        run: rig add release
+      - name: Install renv package
+        run: Rscript -e "install.packages('renv')"
path: .github/workflows/agent.yaml
user: login: jonkeane
body: Of course: https://github.com/posit-dev/publisher/issues/2649
created_at: 2025-05-19T18:24:13Z
author_association: COLLABORATOR
diff_hunk: @@ -68,7 +68,7 @@ func (s *RSuite) TestInit() {
 
 	executor := executortest.NewMockExecutor()
 	executor.On("RunCommand", mock.Anything, []string{"--version"}, mock.Anything, mock.Anything).Return([]byte("R version 4.3.0 (2023-04-21)"), nil, nil)
-	executor.On("RunCommand", mock.Anything, []string{"-s", "-e", "renv::paths$lockfile()"}, mock.Anything, mock.Anything).Return([]byte(`[1] "/test/sample-content/shinyapp/renv.lock"`), nil, nil).Once()
+	executor.On("RunScript", mock.Anything, []string{"-s"}, "renv::paths$lockfile()", mock.Anything, mock.Anything).Return([]byte(`[1] "/test/sample-content/shinyapp/renv.lock"`), nil, nil).Once()
path: internal/interpreters/r_test.go
user: login: dotNomad
body: A comment explaining the difference would be fantastic 👍 
created_at: 2025-05-19T18:24:54Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,213 @@
+package interpreters
+
+// Copyright (C) 2025 by Posit Software, PBC.
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/posit-dev/publisher/internal/logging"
+	"github.com/posit-dev/publisher/internal/types"
+	"github.com/posit-dev/publisher/internal/util"
+	"github.com/spf13/afero"
+	"github.com/stretchr/testify/suite"
+)
+
+// RInterpreterFunctionalSuite contains functional tests that actually
+// interact with real R installations
+type RInterpreterFunctionalSuite struct {
+	suite.Suite
+	testProjectDir     util.AbsolutePath
+	log                logging.Logger
+	fs                 afero.Fs
+	interpreter        RInterpreter
+	rExecutable        util.AbsolutePath
+	defaultInterpreter *defaultRInterpreter
+	renvInstalled      bool
+}
+
+func TestRInterpreterFunctionalSuite(t *testing.T) {
+	suite.Run(t, new(RInterpreterFunctionalSuite))
+}
+
+func (s *RInterpreterFunctionalSuite) SetupTest() {
+	// Set up the test directory
+	parentDir, err := os.MkdirTemp("", "r-interpreter-test-*")
+	s.Require().NoError(err)
+
+	s.fs = afero.NewOsFs()
+	s.testProjectDir = util.NewAbsolutePath(parentDir, s.fs)
+	s.log = logging.New()
+
+	// Initialize R interpreter once for all tests
+	s.interpreter, err = NewRInterpreter(s.testProjectDir, util.Path{}, s.log, nil, nil, nil)
+	s.Require().NoError(err)
+
+	// Try to get R executable
+	s.rExecutable, err = s.interpreter.GetRExecutable()
+	if err != nil {
+		s.T().Logf("R executable not found: %v", err)
+		// Not failing here, individual tests will skip if needed
+	} else {
+		s.T().Logf("Using R executable: %s", s.rExecutable)
+		s.defaultInterpreter = s.interpreter.(*defaultRInterpreter)
+
+		// Check if renv is installed
+		aerr := s.defaultInterpreter.isRenvInstalled(s.rExecutable.String())
+		s.renvInstalled = (aerr == nil)
+		if !s.renvInstalled {
+			s.T().Logf("renv is not installed: %s", aerr.Error())
+		} else {
+			s.T().Logf("renv is installed and available")
+		}
+	}
+}
+
+// skipIfNoR skips the current test if R executable is not available
+// unless we're running in CI where we should enforce that R tests run
+func (s *RInterpreterFunctionalSuite) skipIfNoR() bool {
+	if os.Getenv("GITHUB_ACTIONS") != "true" && s.rExecutable.String() == "" {
+		s.T().Skip("Skipping test: R executable not found")
+		return true
+	}
+	return false
+}
+
+// skipIfNoRenv skips the current test if either R executable is not available or renv is not installed
+// unless we're running in CI where we should enforce that R tests run
+func (s *RInterpreterFunctionalSuite) skipIfNoRenv() bool {
+	if s.skipIfNoR() {
+		return true
+	}
+	if os.Getenv("GITHUB_ACTIONS") != "true" && !s.renvInstalled {
+		s.T().Skip("Skipping test because renv is not installed")
+		return true
+	}
+	return false
+}
+
+func (s *RInterpreterFunctionalSuite) TearDownTest() {
+	os.RemoveAll(s.testProjectDir.String())
+}
+
+func (s *RInterpreterFunctionalSuite) createTestRenvLock() {
+	// Create a simple but valid renv.lock file
+	lockContent := `{
+      "R": {
+        "Version": "4.2.3",
+        "Repositories": [
+          {
+            "Name": "CRAN",
+            "URL": "https://cloud.r-project.org"
+          }
+        ]
+      },
+      "Packages": {
+        "renv": {
+          "Package": "renv",
+          "Version": "1.1.4",
+          "Source": "Repository",
+          "Repository": "CRAN",
+          "Hash": "fa15e"
+        }
+      }
+    }`
+
+	lockPath := s.testProjectDir.Join("renv.lock")
+	err := lockPath.WriteFile([]byte(lockContent), 0644)
+	s.Require().NoError(err)
+}
+
+// TestIsRenvInstalled tests the isRenvInstalled function with a real R executable
+func (s *RInterpreterFunctionalSuite) TestIsRenvInstalled() {
+	if s.skipIfNoR() {
+		return
+	}
+
+	// Attempt to detect if renv is installed
+	aerr := s.defaultInterpreter.isRenvInstalled(s.rExecutable.String())
+
+	// If renv is not installed, we expect an error with ErrorRenvPackageNotInstalled code
+	if aerr != nil {
+		// Check we got the expected error
+		s.Equal(types.ErrorRenvPackageNotInstalled, aerr.GetCode())
+		s.T().Logf("renv is not installed: %s", aerr.Error())
+		s.Contains(aerr.Data["Command"], "install.packages(\"renv\")")
+	} else {
+		s.T().Logf("renv is already installed on the system")
+	}
+}
+
+// TestRenvStatus tests the renvStatus function with a real R executable
+func (s *RInterpreterFunctionalSuite) TestRenvStatus() {
+	if s.skipIfNoRenv() {
+		return
+	}
+
+	// Create test project with renv.lock
+	s.createTestRenvLock()
+
+	// Check renv status
+	statusOutput, aerr := s.defaultInterpreter.renvStatus(s.rExecutable.String())
+	s.Require().Nil(aerr)
+	s.T().Logf("renv status output: %s", statusOutput)
+	s.NotEmpty(statusOutput)
+}
+
+// TestGetRenvLockfilePathFromRExecutable tests the getRenvLockfilePathFromRExecutable function
+func (s *RInterpreterFunctionalSuite) TestGetRenvLockfilePathFromRExecutable() {
+	if s.skipIfNoRenv() {
+		return
+	}
+
+	// Create test project with renv.lock
+	s.createTestRenvLock()
+
+	// Get lockfile path
+	lockfilePath, err := s.defaultInterpreter.getRenvLockfilePathFromRExecutable(s.rExecutable.String())
+	if err != nil {
+		s.T().Logf("Error getting lockfile path: %v", err)
path: internal/interpreters/r_interpreter_functional_test.go
user: login: jonkeane
body: Oh, actually, another possibility is to to update the `createTestRenvLock()` to actually use renv to create that (so that it matches). Also this is a flavor of #2646 it's annoying for us, for testing, but it also is annoying to our users!
created_at: 2025-05-19T18:27:19Z
author_association: COLLABORATOR
diff_hunk: @@ -13,6 +13,28 @@ jobs:
         with:
           fetch-depth: 0
       - uses: extractions/setup-just@v2
+
+      # Install R with rig to ensure we are compatible
+      - name: Install rig with apt
+        if: ${{ runner.os == 'Linux' }}
+        run: |
+          sudo curl -L https://rig.r-pkg.org/deb/rig.gpg -o /etc/apt/trusted.gpg.d/rig.gpg
+          sudo sh -c 'echo "deb http://rig.r-pkg.org/deb rig main" > /etc/apt/sources.list.d/rig.list'
+          sudo apt update
+          sudo apt install -y r-rig
+      - name: Install rig with brew
+        if: ${{ runner.os == 'macOS' }}
+        run: |
+          brew tap r-lib/rig
+          brew install --cask rig
+      - name: Install rig with Choco
+        if: ${{ runner.os == 'Windows' }}
+        run: choco install rig
+
+      - name: Install R with rig
+        run: rig add release
+      - name: Install renv package
+        run: Rscript -e "install.packages('renv')"
path: .github/workflows/agent.yaml
user: login: tdstein
body: This should be its own GitHub Action on the marketplace :)
created_at: 2025-05-19T18:35:07Z
author_association: COLLABORATOR
diff_hunk: @@ -40,3 +42,57 @@ func (e *defaultExecutor) RunCommand(executable string, args []string, cwd util.
 	}
 	return stdout.Bytes(), stderrBuf.Bytes(), err
 }
+
+// createTempRScript creates a temporary file with R code and returns the file path.
+// The caller is responsible for removing the temporary file.
+func createTempRScript(content string) (string, func(), error) {
+	tempFile, err := os.CreateTemp("", "*")
+	if err != nil {
+		return "", nil, fmt.Errorf("failed to create temporary R script: %w", err)
+	}
path: internal/executor/executor.go
user: login: tdstein
body: I would use defer here to ensure that the file is always deleted instead of handling it within the error blocks. https://go.dev/blog/defer-panic-and-recover
created_at: 2025-05-19T18:37:51Z
author_association: COLLABORATOR
diff_hunk: @@ -40,3 +42,57 @@ func (e *defaultExecutor) RunCommand(executable string, args []string, cwd util.
 	}
 	return stdout.Bytes(), stderrBuf.Bytes(), err
 }
+
+// createTempRScript creates a temporary file with R code and returns the file path.
+// The caller is responsible for removing the temporary file.
+func createTempRScript(content string) (string, func(), error) {
+	tempFile, err := os.CreateTemp("", "*")
+	if err != nil {
+		return "", nil, fmt.Errorf("failed to create temporary R script: %w", err)
+	}
+
+	// Write the R code to the temporary file
+	if _, err := tempFile.WriteString(content); err != nil {
+		tempFile.Close()
+		os.Remove(tempFile.Name())
+		return "", nil, fmt.Errorf("failed to write to temporary R script: %w", err)
+	}
+	if err := tempFile.Sync(); err != nil {
+		tempFile.Close()
+		os.Remove(tempFile.Name())
+		return "", nil, fmt.Errorf("failed to flush temporary R script to disk: %w", err)
+	}
+
+	// Return the file path and a cleanup function
+	cleanup := func() {
+		tempFile.Close()
+		os.Remove(tempFile.Name())
+	}
path: internal/executor/executor.go
user: login: tdstein
body: Yeah, reuse this above using defer.
created_at: 2025-05-19T18:39:12Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,213 @@
+package interpreters
+
+// Copyright (C) 2025 by Posit Software, PBC.
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/posit-dev/publisher/internal/logging"
+	"github.com/posit-dev/publisher/internal/types"
+	"github.com/posit-dev/publisher/internal/util"
+	"github.com/spf13/afero"
+	"github.com/stretchr/testify/suite"
+)
+
+// RInterpreterFunctionalSuite contains functional tests that actually
+// interact with real R installations
+type RInterpreterFunctionalSuite struct {
+	suite.Suite
+	testProjectDir     util.AbsolutePath
+	log                logging.Logger
+	fs                 afero.Fs
+	interpreter        RInterpreter
+	rExecutable        util.AbsolutePath
+	defaultInterpreter *defaultRInterpreter
+	renvInstalled      bool
+}
+
+func TestRInterpreterFunctionalSuite(t *testing.T) {
+	suite.Run(t, new(RInterpreterFunctionalSuite))
+}
+
+func (s *RInterpreterFunctionalSuite) SetupTest() {
+	// Set up the test directory
+	parentDir, err := os.MkdirTemp("", "r-interpreter-test-*")
+	s.Require().NoError(err)
+
+	s.fs = afero.NewOsFs()
+	s.testProjectDir = util.NewAbsolutePath(parentDir, s.fs)
+	s.log = logging.New()
+
+	// Initialize R interpreter once for all tests
+	s.interpreter, err = NewRInterpreter(s.testProjectDir, util.Path{}, s.log, nil, nil, nil)
+	s.Require().NoError(err)
+
+	// Try to get R executable
+	s.rExecutable, err = s.interpreter.GetRExecutable()
+	if err != nil {
+		s.T().Logf("R executable not found: %v", err)
+		// Not failing here, individual tests will skip if needed
+	} else {
+		s.T().Logf("Using R executable: %s", s.rExecutable)
+		s.defaultInterpreter = s.interpreter.(*defaultRInterpreter)
+
+		// Check if renv is installed
+		aerr := s.defaultInterpreter.isRenvInstalled(s.rExecutable.String())
+		s.renvInstalled = (aerr == nil)
+		if !s.renvInstalled {
+			s.T().Logf("renv is not installed: %s", aerr.Error())
+		} else {
+			s.T().Logf("renv is installed and available")
+		}
+	}
+}
+
+// skipIfNoR skips the current test if R executable is not available
+// unless we're running in CI where we should enforce that R tests run
+func (s *RInterpreterFunctionalSuite) skipIfNoR() bool {
+	if os.Getenv("GITHUB_ACTIONS") != "true" && s.rExecutable.String() == "" {
+		s.T().Skip("Skipping test: R executable not found")
+		return true
+	}
+	return false
+}
path: internal/interpreters/r_interpreter_functional_test.go
user: login: tdstein
body: I would approach these as integration tests and run them outside of the unit tests. That way you don't have to conditionally check if R exists on the system.
created_at: 2025-05-19T18:43:52Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,213 @@
+package interpreters
+
+// Copyright (C) 2025 by Posit Software, PBC.
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/posit-dev/publisher/internal/logging"
+	"github.com/posit-dev/publisher/internal/types"
+	"github.com/posit-dev/publisher/internal/util"
+	"github.com/spf13/afero"
+	"github.com/stretchr/testify/suite"
+)
+
+// RInterpreterFunctionalSuite contains functional tests that actually
+// interact with real R installations
+type RInterpreterFunctionalSuite struct {
+	suite.Suite
+	testProjectDir     util.AbsolutePath
+	log                logging.Logger
+	fs                 afero.Fs
+	interpreter        RInterpreter
+	rExecutable        util.AbsolutePath
+	defaultInterpreter *defaultRInterpreter
+	renvInstalled      bool
+}
+
+func TestRInterpreterFunctionalSuite(t *testing.T) {
+	suite.Run(t, new(RInterpreterFunctionalSuite))
+}
+
+func (s *RInterpreterFunctionalSuite) SetupTest() {
+	// Set up the test directory
+	parentDir, err := os.MkdirTemp("", "r-interpreter-test-*")
+	s.Require().NoError(err)
+
+	s.fs = afero.NewOsFs()
+	s.testProjectDir = util.NewAbsolutePath(parentDir, s.fs)
+	s.log = logging.New()
+
+	// Initialize R interpreter once for all tests
+	s.interpreter, err = NewRInterpreter(s.testProjectDir, util.Path{}, s.log, nil, nil, nil)
+	s.Require().NoError(err)
+
+	// Try to get R executable
+	s.rExecutable, err = s.interpreter.GetRExecutable()
+	if err != nil {
+		s.T().Logf("R executable not found: %v", err)
+		// Not failing here, individual tests will skip if needed
+	} else {
+		s.T().Logf("Using R executable: %s", s.rExecutable)
+		s.defaultInterpreter = s.interpreter.(*defaultRInterpreter)
+
+		// Check if renv is installed
+		aerr := s.defaultInterpreter.isRenvInstalled(s.rExecutable.String())
+		s.renvInstalled = (aerr == nil)
+		if !s.renvInstalled {
+			s.T().Logf("renv is not installed: %s", aerr.Error())
+		} else {
+			s.T().Logf("renv is installed and available")
+		}
+	}
+}
+
+// skipIfNoR skips the current test if R executable is not available
+// unless we're running in CI where we should enforce that R tests run
+func (s *RInterpreterFunctionalSuite) skipIfNoR() bool {
+	if os.Getenv("GITHUB_ACTIONS") != "true" && s.rExecutable.String() == "" {
+		s.T().Skip("Skipping test: R executable not found")
+		return true
+	}
+	return false
+}
path: internal/interpreters/r_interpreter_functional_test.go
user: login: tdstein
body: You could do this fairly quickly be leveraging t.Short() skips: https://pkg.go.dev/testing#hdr-Skipping
created_at: 2025-05-19T18:47:03Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,213 @@
+package interpreters
+
+// Copyright (C) 2025 by Posit Software, PBC.
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/posit-dev/publisher/internal/logging"
+	"github.com/posit-dev/publisher/internal/types"
+	"github.com/posit-dev/publisher/internal/util"
+	"github.com/spf13/afero"
+	"github.com/stretchr/testify/suite"
+)
+
+// RInterpreterFunctionalSuite contains functional tests that actually
+// interact with real R installations
+type RInterpreterFunctionalSuite struct {
+	suite.Suite
+	testProjectDir     util.AbsolutePath
+	log                logging.Logger
+	fs                 afero.Fs
+	interpreter        RInterpreter
+	rExecutable        util.AbsolutePath
+	defaultInterpreter *defaultRInterpreter
+	renvInstalled      bool
+}
+
+func TestRInterpreterFunctionalSuite(t *testing.T) {
+	suite.Run(t, new(RInterpreterFunctionalSuite))
+}
+
+func (s *RInterpreterFunctionalSuite) SetupTest() {
+	// Set up the test directory
+	parentDir, err := os.MkdirTemp("", "r-interpreter-test-*")
+	s.Require().NoError(err)
+
+	s.fs = afero.NewOsFs()
+	s.testProjectDir = util.NewAbsolutePath(parentDir, s.fs)
+	s.log = logging.New()
+
+	// Initialize R interpreter once for all tests
+	s.interpreter, err = NewRInterpreter(s.testProjectDir, util.Path{}, s.log, nil, nil, nil)
+	s.Require().NoError(err)
+
+	// Try to get R executable
+	s.rExecutable, err = s.interpreter.GetRExecutable()
+	if err != nil {
+		s.T().Logf("R executable not found: %v", err)
+		// Not failing here, individual tests will skip if needed
+	} else {
+		s.T().Logf("Using R executable: %s", s.rExecutable)
+		s.defaultInterpreter = s.interpreter.(*defaultRInterpreter)
+
+		// Check if renv is installed
+		aerr := s.defaultInterpreter.isRenvInstalled(s.rExecutable.String())
+		s.renvInstalled = (aerr == nil)
+		if !s.renvInstalled {
+			s.T().Logf("renv is not installed: %s", aerr.Error())
+		} else {
+			s.T().Logf("renv is installed and available")
+		}
+	}
+}
+
+// skipIfNoR skips the current test if R executable is not available
+// unless we're running in CI where we should enforce that R tests run
+func (s *RInterpreterFunctionalSuite) skipIfNoR() bool {
+	if os.Getenv("GITHUB_ACTIONS") != "true" && s.rExecutable.String() == "" {
+		s.T().Skip("Skipping test: R executable not found")
+		return true
+	}
+	return false
+}
path: internal/interpreters/r_interpreter_functional_test.go
user: login: jonkeane
body: _nods_ let me see if I grok this before I implement it: if we took the `t.Short()` approach it would be adding something like 

```
   if testing.Short() {
        t.Skip("skipping functional tests in short mode.")
    }
```

And then in the Justfile add in `--short` by default (but in CI, when we want the functional tests, we don't use that). yeah?
created_at: 2025-05-19T19:12:06Z
author_association: COLLABORATOR
diff_hunk: @@ -40,3 +42,57 @@ func (e *defaultExecutor) RunCommand(executable string, args []string, cwd util.
 	}
 	return stdout.Bytes(), stderrBuf.Bytes(), err
 }
+
+// createTempRScript creates a temporary file with R code and returns the file path.
+// The caller is responsible for removing the temporary file.
+func createTempRScript(content string) (string, func(), error) {
+	tempFile, err := os.CreateTemp("", "*")
+	if err != nil {
+		return "", nil, fmt.Errorf("failed to create temporary R script: %w", err)
+	}
+
+	// Write the R code to the temporary file
+	if _, err := tempFile.WriteString(content); err != nil {
+		tempFile.Close()
+		os.Remove(tempFile.Name())
+		return "", nil, fmt.Errorf("failed to write to temporary R script: %w", err)
+	}
+	if err := tempFile.Sync(); err != nil {
+		tempFile.Close()
+		os.Remove(tempFile.Name())
+		return "", nil, fmt.Errorf("failed to flush temporary R script to disk: %w", err)
+	}
+
+	// Return the file path and a cleanup function
+	cleanup := func() {
+		tempFile.Close()
+		os.Remove(tempFile.Name())
+	}
path: internal/executor/executor.go
user: login: jonkeane
body: Ah, yeah that makes sense. And just to be clear (because I questioned this while I was writing this): I _can't_ just `defer` from inside of `createTempRScript()` because that would cleanup the file when the `createTempRScript()` finishes (and critically before our command can call it) yeah?

Maybe this is actually a sign that this should be all within the `RunScript()` method at that level and `defer` can do it all 🤔 
created_at: 2025-05-19T19:15:32Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,213 @@
+package interpreters
+
+// Copyright (C) 2025 by Posit Software, PBC.
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/posit-dev/publisher/internal/logging"
+	"github.com/posit-dev/publisher/internal/types"
+	"github.com/posit-dev/publisher/internal/util"
+	"github.com/spf13/afero"
+	"github.com/stretchr/testify/suite"
+)
+
+// RInterpreterFunctionalSuite contains functional tests that actually
+// interact with real R installations
+type RInterpreterFunctionalSuite struct {
+	suite.Suite
+	testProjectDir     util.AbsolutePath
+	log                logging.Logger
+	fs                 afero.Fs
+	interpreter        RInterpreter
+	rExecutable        util.AbsolutePath
+	defaultInterpreter *defaultRInterpreter
+	renvInstalled      bool
+}
+
+func TestRInterpreterFunctionalSuite(t *testing.T) {
+	suite.Run(t, new(RInterpreterFunctionalSuite))
+}
+
+func (s *RInterpreterFunctionalSuite) SetupTest() {
+	// Set up the test directory
+	parentDir, err := os.MkdirTemp("", "r-interpreter-test-*")
+	s.Require().NoError(err)
+
+	s.fs = afero.NewOsFs()
+	s.testProjectDir = util.NewAbsolutePath(parentDir, s.fs)
+	s.log = logging.New()
+
+	// Initialize R interpreter once for all tests
+	s.interpreter, err = NewRInterpreter(s.testProjectDir, util.Path{}, s.log, nil, nil, nil)
+	s.Require().NoError(err)
+
+	// Try to get R executable
+	s.rExecutable, err = s.interpreter.GetRExecutable()
+	if err != nil {
+		s.T().Logf("R executable not found: %v", err)
+		// Not failing here, individual tests will skip if needed
+	} else {
+		s.T().Logf("Using R executable: %s", s.rExecutable)
+		s.defaultInterpreter = s.interpreter.(*defaultRInterpreter)
+
+		// Check if renv is installed
+		aerr := s.defaultInterpreter.isRenvInstalled(s.rExecutable.String())
+		s.renvInstalled = (aerr == nil)
+		if !s.renvInstalled {
+			s.T().Logf("renv is not installed: %s", aerr.Error())
+		} else {
+			s.T().Logf("renv is installed and available")
+		}
+	}
+}
+
+// skipIfNoR skips the current test if R executable is not available
+// unless we're running in CI where we should enforce that R tests run
+func (s *RInterpreterFunctionalSuite) skipIfNoR() bool {
+	if os.Getenv("GITHUB_ACTIONS") != "true" && s.rExecutable.String() == "" {
+		s.T().Skip("Skipping test: R executable not found")
+		return true
+	}
+	return false
+}
path: internal/interpreters/r_interpreter_functional_test.go
user: login: tdstein
body: Yep, that's exactly how I was thinking about it.
created_at: 2025-05-19T22:13:02Z
author_association: COLLABORATOR
diff_hunk: @@ -40,3 +42,57 @@ func (e *defaultExecutor) RunCommand(executable string, args []string, cwd util.
 	}
 	return stdout.Bytes(), stderrBuf.Bytes(), err
 }
+
+// createTempRScript creates a temporary file with R code and returns the file path.
+// The caller is responsible for removing the temporary file.
+func createTempRScript(content string) (string, func(), error) {
+	tempFile, err := os.CreateTemp("", "*")
+	if err != nil {
+		return "", nil, fmt.Errorf("failed to create temporary R script: %w", err)
+	}
+
+	// Write the R code to the temporary file
+	if _, err := tempFile.WriteString(content); err != nil {
+		tempFile.Close()
+		os.Remove(tempFile.Name())
+		return "", nil, fmt.Errorf("failed to write to temporary R script: %w", err)
+	}
+	if err := tempFile.Sync(); err != nil {
+		tempFile.Close()
+		os.Remove(tempFile.Name())
+		return "", nil, fmt.Errorf("failed to flush temporary R script to disk: %w", err)
+	}
+
+	// Return the file path and a cleanup function
+	cleanup := func() {
+		tempFile.Close()
+		os.Remove(tempFile.Name())
+	}
path: internal/executor/executor.go
user: login: tdstein
body: Right. Moving to `RunScript` makes the most sense. An alternative approach could be to make createTempScript a closure, but that seems like overkill for this situation.
created_at: 2025-05-20T13:47:57Z
author_association: COLLABORATOR
diff_hunk: @@ -13,6 +13,28 @@ jobs:
         with:
           fetch-depth: 0
       - uses: extractions/setup-just@v2
+
+      # Install R with rig to ensure we are compatible
+      - name: Install rig with apt
+        if: ${{ runner.os == 'Linux' }}
+        run: |
+          sudo curl -L https://rig.r-pkg.org/deb/rig.gpg -o /etc/apt/trusted.gpg.d/rig.gpg
+          sudo sh -c 'echo "deb http://rig.r-pkg.org/deb rig main" > /etc/apt/sources.list.d/rig.list'
+          sudo apt update
+          sudo apt install -y r-rig
+      - name: Install rig with brew
+        if: ${{ runner.os == 'macOS' }}
+        run: |
+          brew tap r-lib/rig
+          brew install --cask rig
+      - name: Install rig with Choco
+        if: ${{ runner.os == 'Windows' }}
+        run: choco install rig
+
+      - name: Install R with rig
+        run: rig add release
+      - name: Install renv package
+        run: Rscript -e "install.packages('renv')"
path: .github/workflows/agent.yaml
user: login: jonkeane
body: > This should be its own GitHub Action on the marketplace :)

There actually is [a set of actions provided by r-lib](https://github.com/r-lib/actions) which is great and does lots of good things *but* it doesn't use rig (and thus doesn't have hit the `R.bat` / `cmd.exe` quoting issues). 🫠 I'll add a comment about this just so it's clear _why_ we are doing this kind of install.
created_at: 2025-05-23T13:52:27Z
author_association: COLLABORATOR
diff_hunk: @@ -40,3 +42,57 @@ func (e *defaultExecutor) RunCommand(executable string, args []string, cwd util.
 	}
 	return stdout.Bytes(), stderrBuf.Bytes(), err
 }
+
+// createTempRScript creates a temporary file with R code and returns the file path.
+// The caller is responsible for removing the temporary file.
+func createTempRScript(content string) (string, func(), error) {
+	tempFile, err := os.CreateTemp("", "*")
+	if err != nil {
+		return "", nil, fmt.Errorf("failed to create temporary R script: %w", err)
+	}
+
+	// Write the R code to the temporary file
+	if _, err := tempFile.WriteString(content); err != nil {
+		tempFile.Close()
+		os.Remove(tempFile.Name())
+		return "", nil, fmt.Errorf("failed to write to temporary R script: %w", err)
+	}
+	if err := tempFile.Sync(); err != nil {
+		tempFile.Close()
+		os.Remove(tempFile.Name())
+		return "", nil, fmt.Errorf("failed to flush temporary R script to disk: %w", err)
+	}
+
+	// Return the file path and a cleanup function
+	cleanup := func() {
+		tempFile.Close()
+		os.Remove(tempFile.Name())
+	}
path: internal/executor/executor.go
user: login: jonkeane
body: Ok, I've done this, would you mind confirming that I have the defer in the right place?
created_at: 2025-05-23T23:05:53Z
author_association: COLLABORATOR
diff_hunk: @@ -40,3 +42,57 @@ func (e *defaultExecutor) RunCommand(executable string, args []string, cwd util.
 	}
 	return stdout.Bytes(), stderrBuf.Bytes(), err
 }
+
+// createTempRScript creates a temporary file with R code and returns the file path.
+// The caller is responsible for removing the temporary file.
+func createTempRScript(content string) (string, func(), error) {
+	tempFile, err := os.CreateTemp("", "*")
+	if err != nil {
+		return "", nil, fmt.Errorf("failed to create temporary R script: %w", err)
+	}
+
+	// Write the R code to the temporary file
+	if _, err := tempFile.WriteString(content); err != nil {
+		tempFile.Close()
+		os.Remove(tempFile.Name())
+		return "", nil, fmt.Errorf("failed to write to temporary R script: %w", err)
+	}
+	if err := tempFile.Sync(); err != nil {
+		tempFile.Close()
+		os.Remove(tempFile.Name())
+		return "", nil, fmt.Errorf("failed to flush temporary R script to disk: %w", err)
+	}
+
+	// Return the file path and a cleanup function
+	cleanup := func() {
+		tempFile.Close()
+		os.Remove(tempFile.Name())
+	}
path: internal/executor/executor.go
user: login: tdstein
body: I think you want to do the error check on line 60 before the cleanup function is defined. I'm unsure what the behavior will be if you invoke the cleanup function when the `tempFile` doesn't exist.
created_at: 2025-05-27T14:03:13Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,179 @@
+package interpreters
+
+// Copyright (C) 2025 by Posit Software, PBC.
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/posit-dev/publisher/internal/logging"
+	"github.com/posit-dev/publisher/internal/types"
+	"github.com/posit-dev/publisher/internal/util"
+	"github.com/spf13/afero"
+	"github.com/stretchr/testify/suite"
+)
+
+// RInterpreterFunctionalSuite contains functional tests that actually
+// interact with real R installations
+type RInterpreterFunctionalSuite struct {
+	suite.Suite
+	testProjectDir     util.AbsolutePath
+	log                logging.Logger
+	fs                 afero.Fs
+	interpreter        RInterpreter
+	rExecutable        util.AbsolutePath
+	defaultInterpreter *defaultRInterpreter
+	renvInstalled      bool
+}
+
+func TestRInterpreterFunctionalSuite(t *testing.T) {
+	if testing.Short() {
+		t.Skip()
+	}
+
+	suite.Run(t, new(RInterpreterFunctionalSuite))
+}
+
+func (s *RInterpreterFunctionalSuite) SetupTest() {
+	// Set up the test directory
+	parentDir, err := os.MkdirTemp("", "r-interpreter-test-*")
path: internal/interpreters/r_interpreter_functional_test.go
user: login: tdstein
body: I think you can use `afero` to create this temporary directory. I haven't used `afero` in a while, so I could be mistaken. Ideally, you can use the `afero.NewMemMapFs()` for the file system.
created_at: 2025-05-27T14:05:30Z
author_association: COLLABORATOR
diff_hunk: @@ -233,8 +233,9 @@ run *args:
     pathname=`just executable-path`
     ${pathname} {{ args }}
 
-# Execute unit tests.
-test *args=("./..."):
+# Execute unit tests. -short disables our functional tests
+# To run all of the tests, use `just test ./...`
+test *args=("-short ./..."):
path: justfile
user: login: tdstein
body: Maybe for a different pull request, but some tooling to call the functional tests directly could be nice.
created_at: 2025-05-27T14:13:12Z
author_association: COLLABORATOR
diff_hunk: @@ -233,8 +233,9 @@ run *args:
     pathname=`just executable-path`
     ${pathname} {{ args }}
 
-# Execute unit tests.
-test *args=("./..."):
+# Execute unit tests. -short disables our functional tests
+# To run all of the tests, use `just test ./...`
+test *args=("-short ./..."):
path: justfile
user: login: jonkeane
body: Agreed, I'll add this to #2649 too.
created_at: 2025-05-27T14:17:05Z
author_association: COLLABORATOR
diff_hunk: @@ -13,7 +13,33 @@ jobs:
         with:
           fetch-depth: 0
       - uses: extractions/setup-just@v2
+
+      # Install R with rig for our funcitonal tests to ensure we are compatible with it.
+      # We aren't used r-lib/actions here becuase rig, especially on windows, installs in
+      # slightly different ways (e.g. using a batch file on windows)
+      - name: Install rig with apt
+        if: ${{ runner.os == 'Linux' }}
+        run: |
+          sudo curl -L https://rig.r-pkg.org/deb/rig.gpg -o /etc/apt/trusted.gpg.d/rig.gpg
+          sudo sh -c 'echo "deb http://rig.r-pkg.org/deb rig main" > /etc/apt/sources.list.d/rig.list'
+          sudo apt update
+          sudo apt install -y r-rig
+      - name: Install rig with brew
+        if: ${{ runner.os == 'macOS' }}
+        run: |
+          brew tap r-lib/rig
+          brew install --cask rig
+      - name: Install rig with Choco
+        if: ${{ runner.os == 'Windows' }}
+        run: choco install rig
+
+      - name: Install R with rig
+        run: rig add release
path: .github/workflows/agent.yaml
user: login: tdstein
body: This would be a nice GitHub Action for the Marketplace. 😁 

```yaml
- uses: posit-dev/setup-r
  with:
    r-version: release
```
created_at: 2025-05-27T14:18:37Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,179 @@
+package interpreters
+
+// Copyright (C) 2025 by Posit Software, PBC.
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/posit-dev/publisher/internal/logging"
+	"github.com/posit-dev/publisher/internal/types"
+	"github.com/posit-dev/publisher/internal/util"
+	"github.com/spf13/afero"
+	"github.com/stretchr/testify/suite"
+)
+
+// RInterpreterFunctionalSuite contains functional tests that actually
+// interact with real R installations
+type RInterpreterFunctionalSuite struct {
+	suite.Suite
+	testProjectDir     util.AbsolutePath
+	log                logging.Logger
+	fs                 afero.Fs
+	interpreter        RInterpreter
+	rExecutable        util.AbsolutePath
+	defaultInterpreter *defaultRInterpreter
+	renvInstalled      bool
+}
+
+func TestRInterpreterFunctionalSuite(t *testing.T) {
+	if testing.Short() {
+		t.Skip()
+	}
+
+	suite.Run(t, new(RInterpreterFunctionalSuite))
+}
+
+func (s *RInterpreterFunctionalSuite) SetupTest() {
+	// Set up the test directory
+	parentDir, err := os.MkdirTemp("", "r-interpreter-test-*")
path: internal/interpreters/r_interpreter_functional_test.go
user: login: jonkeane
body: _nods_ I looked into `afero` when I started and didn't find a way to do this easily (or as easily as ^^^). Mind if we punt on this refinement? I can add a TODO, but I think it's better to get this fix out than it is to be perfect here. Thoughts?
created_at: 2025-05-27T14:18:50Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,179 @@
+package interpreters
+
+// Copyright (C) 2025 by Posit Software, PBC.
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/posit-dev/publisher/internal/logging"
+	"github.com/posit-dev/publisher/internal/types"
+	"github.com/posit-dev/publisher/internal/util"
+	"github.com/spf13/afero"
+	"github.com/stretchr/testify/suite"
+)
+
+// RInterpreterFunctionalSuite contains functional tests that actually
+// interact with real R installations
+type RInterpreterFunctionalSuite struct {
+	suite.Suite
+	testProjectDir     util.AbsolutePath
+	log                logging.Logger
+	fs                 afero.Fs
+	interpreter        RInterpreter
+	rExecutable        util.AbsolutePath
+	defaultInterpreter *defaultRInterpreter
+	renvInstalled      bool
+}
+
+func TestRInterpreterFunctionalSuite(t *testing.T) {
+	if testing.Short() {
+		t.Skip()
+	}
+
+	suite.Run(t, new(RInterpreterFunctionalSuite))
+}
+
+func (s *RInterpreterFunctionalSuite) SetupTest() {
+	// Set up the test directory
+	parentDir, err := os.MkdirTemp("", "r-interpreter-test-*")
path: internal/interpreters/r_interpreter_functional_test.go
user: login: tdstein
body: Yep, I believe it's this:

```
fs := afero.NewMemMapFs()
dir, err := afero.TempDir(fs, p.path, prefix)
```

The `afero` API is a little confusing because some things are done via the `fs`, others require function calls on `afero`.
created_at: 2025-05-27T14:31:08Z
author_association: COLLABORATOR

PR comments:
user: login: dotNomad
created_at: 2025-05-12T18:52:25Z
body: Removing myself as a reviewer and unsubscribing until this is ready. Feel free to re-request a review when you'd like me to take a look.
author_association: COLLABORATOR
user: login: jonkeane
created_at: 2025-05-12T19:44:34Z
body: I also removed marcos and Bill, sorry fro the spam I'll re-add when I'm not fighting with windows CI 🫠 
author_association: COLLABORATOR
user: login: jonkeane
created_at: 2025-05-23T23:06:50Z
body: Ok, I've followed up on all of the comments. Would y'all mind giving this a look again?
author_association: COLLABORATOR
user: login: jonkeane
created_at: 2025-05-23T23:28:29Z
body: > I was able to run `just test` with no problems

Would you also mind trying `just test ./...` which will (try to) run the functional tests too? I'm curious if you get errors — it's not the end of the world if you do, since now they don't run by default — but it would be good to know if you do!
author_association: COLLABORATOR
user: login: dotNomad
created_at: 2025-05-23T23:31:30Z
body: > Would you also mind trying `just test ./...` which will (try to) run the functional tests too? I'm curious if you get errors — it's not the end of the world if you do, since now they don't run by default — but it would be good to know if you do!

Absolutely. I just ran `just test ./...` at the root with no errors, ran great.

I did notice that when I tried to do the "Run Extension" local development in VS Code from the `/extensions/vscode` folder that I was getting unknown API errors on this branch. I suspect that is because this diverged from `main` a bit and there are schema updates, etc. It may be worth updating off `main` and I can re-test that to make sure there isn't something odd going on with this branch.
author_association: COLLABORATOR