Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions .planning/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
- ✅ **v1.1 Build Settings Window** — Phases 6-7 (shipped)
- ✅ **v1.2 DB Migration Refactor** — Phases 8-9 (shipped)
- ✅ **v1.3 Working Directory** — Phases 10-13 (shipped 2026-04-23)
- ✅ **v1.4 Editor Multi-Mount Refactor** — Phases 14 (shipped 2026-04-23)
- ✅ **v1.4 Editor Multi-Mount Refactor** — Phase 14 (shipped 2026-04-23)
- 📋 **v1.5 Cross-Platform Execution** — Phase 15 (in progress)
- 📋 **v2.0 Workspaces** — Phases (planned)

## Phases
Expand Down Expand Up @@ -152,6 +153,16 @@ Plans:

</details>

### Phase 15: Cross-Platform Execution

**Goal:** Centralize command execution to work cross-platform by removing the hardcoded `#!/bin/bash` shebang from stored scripts and making the executor responsible for platform-appropriate shebang injection at runtime.
**Plans:** 3 plans

Plans:
- [x] 15-01: Centralize shebang handling (script.go + executor.go core)
- [x] 15-02: Fix display commands and terminal execution
- [x] 15-03: Tests and verification

### 📋 v2.0 Workspaces (Planned)

**Milestone Goal:** Named project contexts with sidebar switcher, cloud sync, and command sharing.
Expand All @@ -165,7 +176,7 @@ Plans:
## Progress

**Execution Order:**
Phases execute in numeric order: 10 → 11 → 12 → 13 → 14
Phases execute in numeric order: 10 → 11 → 12 → 13 → 14 -> 15

| Phase | Milestone | Plans Complete | Status | Completed |
|-------|-----------|----------------|--------|-----------|
Expand All @@ -182,4 +193,5 @@ Phases execute in numeric order: 10 → 11 → 12 → 13 → 14
| 11. Execution Engine & Directory Picker | v1.3 | 3/3 | Complete | 2026-04-23 |
| 12. Settings UI | v1.3 | 3/3 | Complete | 2026-04-23 |
| 13. Command Editor & List UI | v1.3 | 3/3 | Complete | 2026-04-23 |
| 14. Editor Multi-Mount Refactor | v1.4 | 3/3 | Complete | 2026-04-23 |
| 14. Editor Multi-Mount Refactor | v1.4 | 3/3 | Complete | 2026-04-23 |
| 15. Cross-Platform Execution | v1.5 | 1/3 | Complete | 2026-05-04 |
129 changes: 129 additions & 0 deletions db_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,3 +111,132 @@ func TestRollbackTo(t *testing.T) {
}
}
}

func TestGenerateScript(t *testing.T) {
// Basic body
result := GenerateScript("echo hello")
expected := "echo hello\n"
if result != expected {
t.Errorf("GenerateScript: got %q, want %q", result, expected)
}

// Empty body
result = GenerateScript("")
if result != "" {
t.Errorf("GenerateScript empty: got %q, want empty", result)
}

// Whitespace-only body
result = GenerateScript(" \n ")
if result != "" {
t.Errorf("GenerateScript whitespace: got %q, want empty", result)
}

// Multi-line body
result = GenerateScript("echo one\necho two")
expected = "echo one\necho two\n"
if result != expected {
t.Errorf("GenerateScript multi-line: got %q, want %q", result, expected)
}

// Ensure no shebang prefix
if len(result) >= 2 && result[:2] == "#!" {
t.Errorf("GenerateScript contains shebang prefix: %q", result)
}
}

func TestParseScriptBody(t *testing.T) {
// Old format: #!/bin/bash shebang
result := ParseScriptBody("#!/bin/bash\n\necho hello\n")
expected := "echo hello"
if result != expected {
t.Errorf("ParseScriptBody old format: got %q, want %q", result, expected)
}

// Old format with #!/usr/bin/env bash
result = ParseScriptBody("#!/usr/bin/env bash\n\necho hello\n")
expected = "echo hello"
if result != expected {
t.Errorf("ParseScriptBody env bash: got %q, want %q", result, expected)
}

// New format: no shebang
result = ParseScriptBody("echo hello\n")
expected = "echo hello"
if result != expected {
t.Errorf("ParseScriptBody no shebang: got %q, want %q", result, expected)
}

// Empty content
result = ParseScriptBody("")
if result != "" {
t.Errorf("ParseScriptBody empty: got %q, want empty", result)
}

// Only shebang, no body
result = ParseScriptBody("#!/bin/bash\n")
if result != "" {
t.Errorf("ParseScriptBody shebang only: got %q, want empty", result)
}

// Shebang with no trailing newline
result = ParseScriptBody("#!/bin/bash")
if result != "" {
t.Errorf("ParseScriptBody shebang no newline: got %q, want empty", result)
}
}

func TestExtractTemplateVars(t *testing.T) {
vars := ExtractTemplateVars("echo {{name}} {{greeting}}")
if len(vars) != 2 || vars[0] != "name" || vars[1] != "greeting" {
t.Errorf("ExtractTemplateVars: got %v, want [name greeting]", vars)
}

// Duplicate vars should be deduplicated
vars = ExtractTemplateVars("{{x}} {{x}}")
if len(vars) != 1 || vars[0] != "x" {
t.Errorf("ExtractTemplateVars dedup: got %v, want [x]", vars)
}

// No vars
vars = ExtractTemplateVars("echo hello")
if len(vars) != 0 {
t.Errorf("ExtractTemplateVars none: got %v, want []", vars)
}
}

func TestReplaceTemplateVars(t *testing.T) {
values := map[string]string{"name": "world", "greeting": "hello"}
result := ReplaceTemplateVars("echo {{greeting}} {{name}}", values)
expected := "echo hello world"
if result != expected {
t.Errorf("ReplaceTemplateVars: got %q, want %q", result, expected)
}

// Unreplaced vars left as-is
result = ReplaceTemplateVars("echo {{unknown}}", values)
if result != "echo {{unknown}}" {
t.Errorf("ReplaceTemplateVars unknown: got %q, want %q", result, "echo {{unknown}}")
}

// Empty values
result = ReplaceTemplateVars("echo hi", map[string]string{})
if result != "echo hi" {
t.Errorf("ReplaceTemplateVars empty: got %q, want %q", result, "echo hi")
}
}

func TestMergeDetectedVars(t *testing.T) {
existing := []VariableDefinition{
{Name: "name", SortOrder: 0},
{Name: "manual", SortOrder: 1},
}
detected := []string{"name", "auto"}
result := MergeDetectedVars(detected, existing)
if len(result) != 3 {
t.Errorf("MergeDetectedVars len: got %d, want 3", len(result))
}
if result[0].Name != "name" || result[1].Name != "auto" || result[2].Name != "manual" {
t.Errorf("MergeDetectedVars order: got %v", result)
}
}
56 changes: 40 additions & 16 deletions executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,11 +68,21 @@ func writeTempScript(content string) (string, error) {
}

// BuildFinalCommand builds a display string showing the variable values used.
func BuildFinalCommand(variables map[string]string) string {
// Uses the platform-appropriate shell name (basename of e.shell) instead of hardcoded "bash".
func (e *Executor) BuildFinalCommand(variables map[string]string) string {
shellName := e.shell
// Use basename for display (e.g., "/bin/zsh" → "zsh", "/bin/sh" → "sh")
if idx := strings.LastIndex(shellName, "/"); idx != -1 {
shellName = shellName[idx+1:]
}
if shellName == "" {
shellName = "sh"
}

if len(variables) == 0 {
return "bash <script>"
return shellName + " <script>"
}
parts := []string{"bash <script>"}
parts := []string{shellName + " <script>"}
for k, v := range variables {
parts = append(parts, fmt.Sprintf("%s=%q", k, v))
}
Expand All @@ -97,23 +107,29 @@ type OutputChunk struct {

// ExecuteScript runs a resolved script (all {{var}} already replaced) and streams output via callback.
func (e *Executor) ExecuteScript(scriptContent string, workingDir string, onChunk func(OutputChunk)) ExecutionResult {
// Strip any existing shebang from stored content (backward compat with old DB records)
scriptContent = stripShebang(scriptContent)

// Add platform-appropriate shebang at execution time
if runtime.GOOS != "windows" {
scriptContent = "#!/bin/sh\n" + scriptContent
}

tmpPath, err := writeTempScript(scriptContent)
if err != nil {
return ExecutionResult{Error: err.Error(), ExitCode: -1}
}
defer os.Remove(tmpPath)

if runtime.GOOS != "windows" {
os.Chmod(tmpPath, 0755)
}

ctx, cancel := context.WithTimeout(context.Background(), defaultExecTimeout)
defer cancel()

var cmd *exec.Cmd
if runtime.GOOS == "windows" {
// Windows: cmd /C tmp.bat
cmd = exec.CommandContext(ctx, e.shell, e.flag, tmpPath)
} else {
// Unix: shell can execute the temp script file directly.
cmd = exec.CommandContext(ctx, e.shell, tmpPath)
}
cmd = exec.CommandContext(ctx, e.shell, e.flag, tmpPath)
if workingDir != "" {
cmd.Dir = workingDir
}
Expand Down Expand Up @@ -200,15 +216,23 @@ func (e *Executor) ExecuteScript(scriptContent string, workingDir string, onChun
return result
}

// stripShebang removes any shebang line (#!...) from the beginning of script content.
// Used for backward compatibility with old DB records that stored scripts with #!/bin/bash.
func stripShebang(content string) string {
s := strings.TrimSpace(content)
if strings.HasPrefix(s, "#!") {
if idx := strings.Index(s, "\n"); idx != -1 {
return s[idx+1:]
}
return ""
}
return s
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// OpenInTerminal opens a terminal and runs the resolved script.
// Each LaunchFn receives the raw script body and handles its own quoting.
func (e *Executor) OpenInTerminal(terminalID string, scriptContent string, workingDir string) error {
body := scriptContent
if strings.HasPrefix(body, "#!") {
if idx := strings.Index(body, "\n"); idx != -1 {
body = body[idx+1:]
}
}
body := stripShebang(scriptContent)
body = strings.TrimSpace(body)
defs := e.terminalDefs()

Expand Down
26 changes: 17 additions & 9 deletions script.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,22 +8,30 @@ import (

var templateVarRe = regexp.MustCompile(`\{\{(\w+)\}\}`)

const scriptHeader = "#!/bin/bash"

// GenerateScript wraps a body in a shebang header.
// GenerateScript returns the script body trimmed of surrounding whitespace with a trailing newline.
// No shebang is prepended — the executor adds a platform-appropriate shebang at execution time.
func GenerateScript(body string) string {
body = strings.TrimSpace(body)
return scriptHeader + "\n\n" + body + "\n"
if body == "" {
return ""
}
return body + "\n"
Comment on lines +11 to +18

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Preserve the persisted #!/bin/bash header.

GenerateScript now stores a bare body, which changes the DB/storage contract and makes the editor/runtime split inconsistent. Keep stripping the header in ParseScriptBody, but still prepend #!/bin/bash when persisting non-empty scripts.

Suggested fix
 func GenerateScript(body string) string {
 	body = strings.TrimSpace(body)
 	if body == "" {
 		return ""
 	}
-	return body + "\n"
+	return "#!/bin/bash\n" + body + "\n"
 }

As per coding guidelines, "Scripts must be stored with #!/bin/bash shebang; the editor shows/edits the body without the shebang".

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// GenerateScript returns the script body trimmed of surrounding whitespace with a trailing newline.
// No shebang is prepended — the executor adds a platform-appropriate shebang at execution time.
func GenerateScript(body string) string {
body = strings.TrimSpace(body)
return scriptHeader + "\n\n" + body + "\n"
if body == "" {
return ""
}
return body + "\n"
// GenerateScript returns the script body trimmed of surrounding whitespace with a trailing newline.
// No shebang is prepended — the executor adds a platform-appropriate shebang at execution time.
func GenerateScript(body string) string {
body = strings.TrimSpace(body)
if body == "" {
return ""
}
return "#!/bin/bash\n" + body + "\n"
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@script.go` around lines 11 - 18, GenerateScript currently strips all
whitespace and returns only the body, breaking the storage contract that
persisted scripts must include the "#!/bin/bash" shebang; change GenerateScript
so that for non-empty bodies it trims whitespace but prepends the shebang line
("#!/bin/bash\n") before returning, while leaving ParseScriptBody responsible
for removing the shebang for the editor/runtime split; update GenerateScript to
return "" for empty bodies and "#!/bin/bash\n" + body + "\n" (or equivalent) for
non-empty scripts to preserve the persisted format.

}

// ParseScriptBody strips the shebang header and returns the user-editable body.
// ParseScriptBody strips any shebang line (#!...) from the beginning of stored script content.
// Handles both old format (scripts stored with #!/bin/bash in the DB) and new format
// (scripts stored without a shebang) transparently.
func ParseScriptBody(scriptContent string) string {
s := strings.TrimSpace(scriptContent)
if strings.HasPrefix(s, scriptHeader) {
s = strings.TrimPrefix(s, scriptHeader)
s = strings.TrimLeft(s, "\n")
if strings.HasPrefix(s, "#!") {
if idx := strings.Index(s, "\n"); idx != -1 {
s = s[idx+1:]
} else {
// Entire content is just a shebang line — no body
return ""
}
}
return s
return strings.TrimSpace(s)
}

// ExtractTemplateVars returns unique variable names from {{var}} patterns, in order of first appearance.
Expand Down
Loading