Skip to content

Commit 03d0318

Browse files
fullstackjamclaude
andcommitted
fix(installer): execute post-install script as single process
Previously each line of the post-install script ran as a separate /bin/zsh -c process, breaking multi-line constructs (if/then/fi, for/do/done, variable sharing, heredocs). Now all commands are joined and executed as a single script. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 09c061c commit 03d0318

2 files changed

Lines changed: 36 additions & 11 deletions

File tree

internal/installer/installer.go

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -722,20 +722,18 @@ func stepPostInstall(cfg *config.Config) error {
722722
}
723723

724724
var errs []error
725-
for _, command := range commands {
726-
if cfg.DryRun {
725+
if cfg.DryRun {
726+
for _, command := range commands {
727727
fmt.Printf("[DRY-RUN] Would run: %s\n", command)
728-
continue
729728
}
730-
731-
cmd := exec.Command("/bin/zsh", "-c", command)
729+
} else {
730+
script := strings.Join(commands, "\n")
731+
cmd := exec.Command("/bin/zsh", "-c", script)
732732
cmd.Stdout = os.Stdout
733733
cmd.Stderr = os.Stderr
734734
cmd.Dir = home
735-
736735
if err := cmd.Run(); err != nil {
737-
ui.Error(fmt.Sprintf("Command failed: %s", command))
738-
errs = append(errs, fmt.Errorf("post-install %q: %w", command, err))
736+
errs = append(errs, fmt.Errorf("post-install script: %w", err))
739737
}
740738
}
741739

internal/installer/installer_test.go

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -607,7 +607,7 @@ func TestStepPostInstall_CommandFailureReturnsSoftError(t *testing.T) {
607607

608608
err := stepPostInstall(cfg)
609609
assert.Error(t, err)
610-
assert.Contains(t, err.Error(), "post-install")
610+
assert.Contains(t, err.Error(), "post-install script")
611611
}
612612

613613
func TestStepPostInstall_ContinuesAfterCommandFailure(t *testing.T) {
@@ -618,17 +618,44 @@ func TestStepPostInstall_ContinuesAfterCommandFailure(t *testing.T) {
618618
cfg := &config.Config{
619619
Silent: true,
620620
RemoteConfig: &config.RemoteConfig{
621-
PostInstall: []string{"exit 1", "touch " + markerFile},
621+
// Use "false" (a command that fails with exit 1) instead of "exit 1"
622+
// because exit terminates the entire script, while false just sets $?.
623+
PostInstall: []string{"false", "touch " + markerFile},
622624
},
623625
}
624626

627+
// With single-script execution, zsh runs all lines without set -e,
628+
// so the second command runs and the script exits 0 (touch succeeds).
625629
err := stepPostInstall(cfg)
626-
assert.Error(t, err)
630+
assert.NoError(t, err)
627631

628632
_, statErr := os.Stat(markerFile)
629633
assert.NoError(t, statErr, "second command should still run after first fails")
630634
}
631635

636+
func TestStepPostInstall_SharedContext(t *testing.T) {
637+
tmpDir := t.TempDir()
638+
t.Setenv("HOME", tmpDir)
639+
640+
markerFile := tmpDir + "/shared-context"
641+
cfg := &config.Config{
642+
Silent: true,
643+
RemoteConfig: &config.RemoteConfig{
644+
PostInstall: []string{
645+
"MY_VAR=hello",
646+
"echo $MY_VAR > " + markerFile,
647+
},
648+
},
649+
}
650+
651+
err := stepPostInstall(cfg)
652+
assert.NoError(t, err)
653+
654+
content, readErr := os.ReadFile(markerFile)
655+
assert.NoError(t, readErr)
656+
assert.Equal(t, "hello\n", string(content), "variable set on one line should be available on the next")
657+
}
658+
632659
func TestRunCustomInstall_WithPostInstallScript(t *testing.T) {
633660
tmpDir := t.TempDir()
634661
t.Setenv("HOME", tmpDir)

0 commit comments

Comments
 (0)