Skip to content

Commit f40a859

Browse files
committed
feat: implement remote shell bootstrapping for zsh and bash in execution flow
1 parent 7ad4f5a commit f40a859

8 files changed

Lines changed: 181 additions & 4 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,8 @@ echo "version = $VERSION"
115115

116116
Using `@SHELL` for remote servers with non-bash default shells:
117117

118+
Shellflow starts remote shells in login mode. For remote `zsh` and `bash` blocks, Shellflow also bootstraps `~/.zshrc` or `~/.bashrc` quietly before running your commands so tools initialized there, such as `mise`, remain available in non-interactive automation even if the rc file exits non-zero.
119+
118120
```bash
119121
#!/bin/bash
120122

features/execution_contract.feature

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,4 +26,18 @@ Feature: Agent-facing execution contract
2626
Then the parse failure should exit with code 2
2727
And the missing SSH host failure should exit with code 3
2828
And the block execution failure should exit with code 1
29-
And the timeout failure should exit with code 4
29+
And the timeout failure should exit with code 4
30+
31+
Scenario: Remote zsh payload bootstraps zshrc before user commands
32+
Given host "testhost" is configured in SSH config
33+
And a script file with a remote "zsh" block
34+
When I inspect the generated remote script payload
35+
Then the output should contain "test -f ~/.zshrc && { source ~/.zshrc >/dev/null 2>&1 || true; }"
36+
And the output should contain "bootstrap-check"
37+
38+
Scenario: Remote bash payload bootstraps bashrc before user commands
39+
Given host "testhost" is configured in SSH config
40+
And a script file with a remote "bash" block
41+
When I inspect the generated remote script payload
42+
Then the output should contain "test -f ~/.bashrc && { set +e; . ~/.bashrc >/dev/null 2>&1; set -e; }"
43+
And the output should contain "bootstrap-check"

features/steps/shellflow_steps.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -721,6 +721,18 @@ def step_given_script_file_with_secret_like_export(context: Context) -> None:
721721
)
722722

723723

724+
@given('a script file with a remote "{shell_name}" block')
725+
def step_given_script_file_with_remote_shell_block(context: Context, shell_name: str) -> None:
726+
given_script_file_with_content(
727+
context,
728+
f"""
729+
# @REMOTE testhost
730+
# @SHELL {shell_name}
731+
echo "bootstrap-check"
732+
""",
733+
)
734+
735+
724736
@given("a script with content:")
725737
def step_given_script_with_content(context: Context) -> None:
726738
given_script_with_content(context, context.text)
@@ -736,6 +748,33 @@ def step_when_run_the_script(context: Context) -> None:
736748
when_run_the_script(context)
737749

738750

751+
@when("I inspect the generated remote script payload")
752+
def step_when_inspect_generated_remote_script_payload(context: Context) -> None:
753+
script_content = getattr(context, "script_content", None)
754+
if not script_content:
755+
raise ValueError("No script content set. Did you call the Given step first?")
756+
757+
blocks = parse_script(script_content)
758+
if len(blocks) != 1 or not blocks[0].is_remote:
759+
raise AssertionError(f"Expected exactly one remote block, got: {blocks}")
760+
761+
block = blocks[0]
762+
ssh_config = _read_ssh_config_for_context(context, block.host or "")
763+
if ssh_config is None:
764+
raise AssertionError(f"Remote host not configured for test: {block.host}")
765+
766+
mock_result = mock.Mock(returncode=0, stdout="", stderr="")
767+
with mock.patch("shellflow.subprocess.run", return_value=mock_result) as mock_run:
768+
result = execute_remote(block, ExecutionContext(), ssh_config)
769+
770+
if not result.success:
771+
raise AssertionError(f"Expected execute_remote to succeed, got: {result}")
772+
773+
context.stdout = mock_run.call_args.kwargs["input"]
774+
context.stderr = ""
775+
context.exit_code = 0
776+
777+
739778
@when("I run the script with JSON output enabled")
740779
def step_when_run_the_script_with_json_output(context: Context) -> None:
741780
when_run_the_script_with_cli_args(context, "--json")

playbooks/arch_up.sh

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
#!/bin/bash
2+
set -euo pipefail
3+
4+
# @REMOTE sui
5+
# @SHELL zsh
6+
mise install go@latest

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "shellflow"
3-
version = "0.2.1"
3+
version = "0.2.2"
44
description = "A minimal shell script orchestrator with SSH support"
55
readme = "README.md"
66
license = "Apache-2.0"

src/shellflow.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -619,15 +619,30 @@ def _build_executable_script(
619619
context: ExecutionContext,
620620
*,
621621
include_context_exports: bool,
622+
shell: str | None = None,
622623
) -> str:
623624
"""Build a shell script payload for local or remote execution."""
624625
script_lines = ["set -e"]
625626
if include_context_exports:
626627
script_lines.extend(_build_context_exports(context))
628+
script_lines.extend(_build_shell_bootstrap(shell))
627629
script_lines.extend(commands)
628630
return "\n".join(script_lines)
629631

630632

633+
def _build_shell_bootstrap(shell: str | None) -> list[str]:
634+
"""Build shell-specific bootstrap lines needed for non-interactive automation."""
635+
if not shell:
636+
return []
637+
638+
shell_name = Path(shell).name
639+
if shell_name == "zsh":
640+
return ["test -f ~/.zshrc && { source ~/.zshrc >/dev/null 2>&1 || true; }"]
641+
if shell_name == "bash":
642+
return ["test -f ~/.bashrc && { set +e; . ~/.bashrc >/dev/null 2>&1; set -e; }"]
643+
return []
644+
645+
631646
def _build_context_exports(context: ExecutionContext) -> list[str]:
632647
"""Build export statements for explicit shellflow context values only."""
633648
exports = [f"export SHELLFLOW_LAST_OUTPUT={_quote_shell_value(context.last_output)}"]
@@ -897,11 +912,12 @@ def execute_remote(
897912
ssh_args.extend(["-F", str(ssh_config_path)])
898913

899914
shell = block.shell or "bash"
900-
ssh_args.extend(["-o", "BatchMode=yes", host, shell, "-se"])
915+
ssh_args.extend(["-o", "BatchMode=yes", host, shell, "-l", "-s", "-e"])
901916
remote_script = _build_executable_script(
902917
block.commands,
903918
context,
904919
include_context_exports=True,
920+
shell=shell,
905921
)
906922
run_kwargs: dict[str, Any] = {
907923
"input": remote_script,

tests/test_shellflow.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
RunResult,
2828
ShellflowError,
2929
SSHConfig,
30+
_build_executable_script,
3031
_clean_commands,
3132
_is_valid_env_name,
3233
create_parser,
@@ -688,6 +689,43 @@ def test_ssh_config_used_in_command(
688689
assert "-i" in call_args
689690
assert "/path/to/key" in call_args
690691

692+
def test_custom_remote_shell_runs_as_login_shell(
693+
self,
694+
execution_context: ExecutionContext,
695+
) -> None:
696+
"""Test custom remote shells run in login mode so host PATH initialization is loaded."""
697+
block = Block(target="REMOTE:myhost", commands=["mise --version"], shell="zsh") # noqa: S604
698+
ssh_config = SSHConfig(host="myhost")
699+
700+
mock_result = mock.Mock(returncode=0, stdout="2026.1.0\n", stderr="")
701+
702+
with mock.patch("shellflow.subprocess.run", return_value=mock_result) as mock_run:
703+
result = execute_remote(block, execution_context, ssh_config)
704+
705+
assert result.success is True
706+
call_args = mock_run.call_args[0][0]
707+
assert call_args[-4:] == ["zsh", "-l", "-s", "-e"]
708+
709+
def test_remote_zsh_bootstraps_zshrc_before_commands(
710+
self,
711+
execution_context: ExecutionContext,
712+
) -> None:
713+
"""Test remote zsh execution sources ~/.zshrc so non-login PATH customizations are available."""
714+
block = Block(target="REMOTE:myhost", commands=["mise --version"], shell="zsh") # noqa: S604
715+
ssh_config = SSHConfig(host="myhost")
716+
717+
mock_result = mock.Mock(returncode=0, stdout="2026.1.0\n", stderr="")
718+
719+
with mock.patch("shellflow.subprocess.run", return_value=mock_result) as mock_run:
720+
result = execute_remote(block, execution_context, ssh_config)
721+
722+
assert result.success is True
723+
sent_script = mock_run.call_args.kwargs["input"]
724+
assert "test -f ~/.zshrc && { source ~/.zshrc >/dev/null 2>&1 || true; }" in sent_script
725+
assert sent_script.index(
726+
"test -f ~/.zshrc && { source ~/.zshrc >/dev/null 2>&1 || true; }"
727+
) < sent_script.index("mise --version")
728+
691729
def test_remote_execution_only_exports_explicit_context(
692730
self,
693731
execution_context: ExecutionContext,
@@ -792,6 +830,68 @@ def test_no_ssh_config_uses_manual_lookup(
792830
assert "2222" in call_args
793831

794832

833+
class TestShellBootstrapIntegration:
834+
"""Integration-style tests for non-interactive shell bootstrap behavior."""
835+
836+
def test_zsh_bootstrap_ignores_nonzero_zshrc_and_keeps_path_customizations(self, tmp_path: Path) -> None:
837+
"""Test guarded zshrc bootstrap still exposes commands after a non-zero rc return."""
838+
bin_dir = tmp_path / "bin"
839+
bin_dir.mkdir()
840+
fake_mise = bin_dir / "mise"
841+
fake_mise.write_text("#!/bin/sh\necho fake-mise\n")
842+
fake_mise.chmod(0o755)
843+
844+
(tmp_path / ".zshrc").write_text(f'export PATH="{bin_dir}:$PATH"\nfalse\n')
845+
846+
script = _build_executable_script( # noqa: S604
847+
["command -v mise"],
848+
ExecutionContext(),
849+
include_context_exports=False,
850+
shell="zsh",
851+
)
852+
853+
result = subprocess.run(
854+
["/bin/zsh", "-l", "-s", "-e"],
855+
input=script,
856+
capture_output=True,
857+
text=True,
858+
env={"HOME": str(tmp_path), "PATH": "/usr/bin:/bin:/usr/sbin:/sbin"},
859+
check=False,
860+
)
861+
862+
assert result.returncode == 0
863+
assert str(fake_mise) in result.stdout
864+
865+
def test_bash_bootstrap_ignores_nonzero_bashrc_and_keeps_path_customizations(self, tmp_path: Path) -> None:
866+
"""Test guarded bashrc bootstrap still exposes commands after a non-zero rc return."""
867+
bin_dir = tmp_path / "bin"
868+
bin_dir.mkdir()
869+
fake_tool = bin_dir / "tool-from-bashrc"
870+
fake_tool.write_text("#!/bin/sh\necho fake-bash-tool\n")
871+
fake_tool.chmod(0o755)
872+
873+
(tmp_path / ".bashrc").write_text(f'export PATH="{bin_dir}:$PATH"\nfalse\n')
874+
875+
script = _build_executable_script( # noqa: S604
876+
["command -v tool-from-bashrc"],
877+
ExecutionContext(),
878+
include_context_exports=False,
879+
shell="bash",
880+
)
881+
882+
result = subprocess.run(
883+
["/bin/bash", "-l", "-s", "-e"],
884+
input=script,
885+
capture_output=True,
886+
text=True,
887+
env={"HOME": str(tmp_path), "PATH": "/usr/bin:/bin:/usr/sbin:/sbin"},
888+
check=False,
889+
)
890+
891+
assert result.returncode == 0
892+
assert str(fake_tool) in result.stdout
893+
894+
795895
# =============================================================================
796896
# parse_script Advanced Tests
797897
# =============================================================================

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)