@fatalmistake02/rian 0.0.0-stage → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +155 -2
- package/THIRD_PARTY_NOTICES.txt +673 -0
- package/bin/rian.cjs +12 -0
- package/cmd/rian/main.go +17 -0
- package/dist/binaries/darwin-arm64/rian +0 -0
- package/dist/binaries/darwin-x64/rian +0 -0
- package/dist/binaries/linux-arm64/rian +0 -0
- package/dist/binaries/linux-x64/rian +0 -0
- package/dist/binaries/manifest.json +29 -0
- package/dist/binaries/win32-arm64/rian.exe +0 -0
- package/dist/binaries/win32-x64/rian.exe +0 -0
- package/go.mod +28 -0
- package/go.sum +44 -0
- package/internal/app/app.go +298 -0
- package/internal/app/app_test.go +85 -0
- package/internal/config/config.go +189 -0
- package/internal/config/config_test.go +101 -0
- package/internal/detect/detect.go +252 -0
- package/internal/detect/detect_test.go +86 -0
- package/internal/git/git.go +166 -0
- package/internal/git/git_test.go +73 -0
- package/internal/git/window_unix.go +7 -0
- package/internal/git/window_windows.go +8 -0
- package/internal/process/platform_unix.go +19 -0
- package/internal/process/platform_windows.go +25 -0
- package/internal/process/process.go +172 -0
- package/internal/process/process_test.go +137 -0
- package/internal/project/project.go +180 -0
- package/internal/project/project_test.go +71 -0
- package/internal/tools/lookup.go +41 -0
- package/internal/tools/platform_unix.go +34 -0
- package/internal/tools/platform_windows.go +52 -0
- package/internal/tools/platform_windows_test.go +29 -0
- package/internal/tools/tools.go +152 -0
- package/internal/tools/tools_test.go +56 -0
- package/internal/ui/actions.go +259 -0
- package/internal/ui/forms.go +156 -0
- package/internal/ui/model.go +220 -0
- package/internal/ui/styles.go +85 -0
- package/internal/ui/ui_test.go +170 -0
- package/internal/ui/update.go +470 -0
- package/internal/ui/view.go +459 -0
- package/lib/launcher.cjs +46 -0
- package/lib/platforms.cjs +20 -0
- package/package.json +39 -4
- package/scripts/build-binaries.cjs +60 -0
- package/scripts/check-package.cjs +49 -0
- package/scripts/ensure-binary.cjs +11 -0
- package/test/npm/launcher.test.cjs +45 -0
- package/test/npm/package.test.cjs +26 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// Package project contains the persistent project model and lookup rules.
|
|
2
|
+
package project
|
|
3
|
+
|
|
4
|
+
import (
|
|
5
|
+
"crypto/rand"
|
|
6
|
+
"encoding/hex"
|
|
7
|
+
"fmt"
|
|
8
|
+
"os"
|
|
9
|
+
"path/filepath"
|
|
10
|
+
"runtime"
|
|
11
|
+
"sort"
|
|
12
|
+
"strings"
|
|
13
|
+
"time"
|
|
14
|
+
"unicode"
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
type Project struct {
|
|
18
|
+
ID string `json:"id"`
|
|
19
|
+
Name string `json:"name"`
|
|
20
|
+
Path string `json:"path"`
|
|
21
|
+
Favorite bool `json:"favorite,omitempty"`
|
|
22
|
+
GitHub string `json:"github,omitempty"`
|
|
23
|
+
Tools map[string]bool `json:"tools,omitempty"`
|
|
24
|
+
Commands map[string]string `json:"commands,omitempty"`
|
|
25
|
+
AutoCommands map[string]string `json:"auto_commands,omitempty"`
|
|
26
|
+
Stack []string `json:"stack,omitempty"`
|
|
27
|
+
URLs map[string]string `json:"urls,omitempty"`
|
|
28
|
+
AddedAt time.Time `json:"added_at"`
|
|
29
|
+
LastUsed time.Time `json:"last_used,omitempty"`
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
func New(name, path string) Project {
|
|
33
|
+
b := make([]byte, 8)
|
|
34
|
+
if _, err := rand.Read(b); err != nil {
|
|
35
|
+
// A timestamp remains sufficient for a local entry if the OS RNG fails.
|
|
36
|
+
return Project{ID: fmt.Sprintf("%x", time.Now().UnixNano()), Name: name, Path: path, AddedAt: time.Now()}
|
|
37
|
+
}
|
|
38
|
+
return Project{ID: hex.EncodeToString(b), Name: name, Path: path, AddedAt: time.Now()}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
func (p Project) ToolEnabled(name string) bool {
|
|
42
|
+
enabled, set := p.Tools[name]
|
|
43
|
+
return !set || enabled
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
func (p Project) AllCommands() map[string]string {
|
|
47
|
+
result := make(map[string]string, len(p.AutoCommands)+len(p.Commands))
|
|
48
|
+
for name, command := range p.AutoCommands {
|
|
49
|
+
result[name] = command
|
|
50
|
+
}
|
|
51
|
+
for name, command := range p.Commands {
|
|
52
|
+
result[name] = command
|
|
53
|
+
}
|
|
54
|
+
return result
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
func CanonicalPath(path string) (string, error) {
|
|
58
|
+
path = strings.TrimSpace(path)
|
|
59
|
+
if path == "~" || strings.HasPrefix(path, "~/") || strings.HasPrefix(path, "~\\") {
|
|
60
|
+
home, err := os.UserHomeDir()
|
|
61
|
+
if err != nil {
|
|
62
|
+
return "", err
|
|
63
|
+
}
|
|
64
|
+
path = filepath.Join(home, strings.TrimLeft(strings.TrimPrefix(path, "~"), "/\\"))
|
|
65
|
+
}
|
|
66
|
+
if path == "" {
|
|
67
|
+
return "", fmt.Errorf("enter a project directory")
|
|
68
|
+
}
|
|
69
|
+
abs, err := filepath.Abs(path)
|
|
70
|
+
if err != nil {
|
|
71
|
+
return "", err
|
|
72
|
+
}
|
|
73
|
+
info, err := os.Stat(abs)
|
|
74
|
+
if err != nil {
|
|
75
|
+
return "", fmt.Errorf("directory is unavailable: %w", err)
|
|
76
|
+
}
|
|
77
|
+
if !info.IsDir() {
|
|
78
|
+
return "", fmt.Errorf("%s is not a directory", abs)
|
|
79
|
+
}
|
|
80
|
+
resolved, err := filepath.EvalSymlinks(abs)
|
|
81
|
+
if err != nil {
|
|
82
|
+
return "", err
|
|
83
|
+
}
|
|
84
|
+
return filepath.Clean(resolved), nil
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
func SamePath(a, b string) bool {
|
|
88
|
+
a, b = filepath.Clean(a), filepath.Clean(b)
|
|
89
|
+
if runtime.GOOS == "windows" {
|
|
90
|
+
return strings.EqualFold(a, b)
|
|
91
|
+
}
|
|
92
|
+
return a == b
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
func Sort(projects []Project) {
|
|
96
|
+
sort.SliceStable(projects, func(i, j int) bool {
|
|
97
|
+
a, b := projects[i], projects[j]
|
|
98
|
+
if a.Favorite != b.Favorite {
|
|
99
|
+
return a.Favorite
|
|
100
|
+
}
|
|
101
|
+
if !a.LastUsed.Equal(b.LastUsed) {
|
|
102
|
+
return a.LastUsed.After(b.LastUsed)
|
|
103
|
+
}
|
|
104
|
+
return strings.ToLower(a.Name) < strings.ToLower(b.Name)
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Score performs Unicode-aware subsequence matching. Consecutive letters and
|
|
109
|
+
// word boundaries rank ahead of scattered matches. A negative score is no match.
|
|
110
|
+
func Score(query, value string) int {
|
|
111
|
+
q := []rune(strings.ToLower(strings.TrimSpace(query)))
|
|
112
|
+
v := []rune(strings.ToLower(value))
|
|
113
|
+
if len(q) == 0 {
|
|
114
|
+
return 0
|
|
115
|
+
}
|
|
116
|
+
qi, score, previous := 0, 0, -2
|
|
117
|
+
for i, r := range v {
|
|
118
|
+
if r != q[qi] {
|
|
119
|
+
continue
|
|
120
|
+
}
|
|
121
|
+
score += 10
|
|
122
|
+
if i == previous+1 {
|
|
123
|
+
score += 12
|
|
124
|
+
}
|
|
125
|
+
if i == 0 || !unicode.IsLetter(v[i-1]) && !unicode.IsDigit(v[i-1]) {
|
|
126
|
+
score += 8
|
|
127
|
+
}
|
|
128
|
+
score -= i - previous - 1
|
|
129
|
+
previous = i
|
|
130
|
+
qi++
|
|
131
|
+
if qi == len(q) {
|
|
132
|
+
return max(0, score-(len(v)-len(q))/3)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return -1
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
func Filter(projects []Project, query string) []Project {
|
|
139
|
+
result := make([]Project, 0, len(projects))
|
|
140
|
+
pathQuery := strings.ToLower(strings.TrimSpace(query))
|
|
141
|
+
for _, p := range projects {
|
|
142
|
+
if Score(query, p.Name) >= 0 || strings.Contains(strings.ToLower(p.Path), pathQuery) {
|
|
143
|
+
result = append(result, p)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if strings.TrimSpace(query) != "" {
|
|
147
|
+
sort.SliceStable(result, func(i, j int) bool { return Score(query, result[i].Name) > Score(query, result[j].Name) })
|
|
148
|
+
}
|
|
149
|
+
return result
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
func Lookup(projects []Project, query string) (Project, error) {
|
|
153
|
+
if strings.TrimSpace(query) == "" {
|
|
154
|
+
return Project{}, fmt.Errorf("enter a project name or ID")
|
|
155
|
+
}
|
|
156
|
+
var exact []Project
|
|
157
|
+
for _, p := range projects {
|
|
158
|
+
if p.ID == query {
|
|
159
|
+
return p, nil
|
|
160
|
+
}
|
|
161
|
+
if strings.EqualFold(p.Name, query) {
|
|
162
|
+
exact = append(exact, p)
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
matches := exact
|
|
166
|
+
if len(matches) == 0 {
|
|
167
|
+
matches = Filter(projects, query)
|
|
168
|
+
}
|
|
169
|
+
if len(matches) == 1 {
|
|
170
|
+
return matches[0], nil
|
|
171
|
+
}
|
|
172
|
+
if len(matches) == 0 {
|
|
173
|
+
return Project{}, fmt.Errorf("no project matches %q", query)
|
|
174
|
+
}
|
|
175
|
+
var names []string
|
|
176
|
+
for _, p := range matches {
|
|
177
|
+
names = append(names, fmt.Sprintf(" %s [%s] %s", p.Name, p.ID, p.Path))
|
|
178
|
+
}
|
|
179
|
+
return Project{}, fmt.Errorf("%q is ambiguous; use a project ID:\n%s", query, strings.Join(names, "\n"))
|
|
180
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
package project
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"path/filepath"
|
|
5
|
+
"strings"
|
|
6
|
+
"testing"
|
|
7
|
+
"time"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
func TestFuzzyLookup(t *testing.T) {
|
|
11
|
+
projects := []Project{{ID: "one", Name: "Cardly", Path: "/dev/cardly"}, {ID: "two", Name: "Card Scanner Test", Path: "/dev/scanner"}, {ID: "three", Name: "Drift", Path: "/dev/drift"}}
|
|
12
|
+
if matches := Filter(projects, "car"); len(matches) != 2 || matches[0].Name != "Cardly" {
|
|
13
|
+
t.Fatalf("unexpected matches: %+v", matches)
|
|
14
|
+
}
|
|
15
|
+
for _, query := range []string{"Cardly", "CARDLY", "one", "cdly"} {
|
|
16
|
+
p, err := Lookup(projects, query)
|
|
17
|
+
if err != nil || p.ID != "one" {
|
|
18
|
+
t.Fatalf("%q: %+v %v", query, p, err)
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
if _, err := Lookup(projects, "car"); err == nil || !strings.Contains(err.Error(), "ambiguous") || !strings.Contains(err.Error(), "two") {
|
|
22
|
+
t.Fatalf("expected ambiguity with IDs, got %v", err)
|
|
23
|
+
}
|
|
24
|
+
if _, err := Lookup(projects, "xyz"); err == nil {
|
|
25
|
+
t.Fatal("expected no match")
|
|
26
|
+
}
|
|
27
|
+
if _, err := Lookup(projects[:1], ""); err == nil {
|
|
28
|
+
t.Fatal("empty query must not select a project")
|
|
29
|
+
}
|
|
30
|
+
if Score("ré", "Résumé") < 0 || Score("ab", "a"+strings.Repeat("x", 500)+"b") < 0 {
|
|
31
|
+
t.Fatal("subsequence matches must not be lost")
|
|
32
|
+
}
|
|
33
|
+
if Score("drf", "Drift") < 0 || Score("frd", "Drift") >= 0 {
|
|
34
|
+
t.Fatal("fuzzy letters must stay in order")
|
|
35
|
+
}
|
|
36
|
+
projects[1].Name = "Cardly"
|
|
37
|
+
if _, err := Lookup(projects, "Cardly"); err == nil {
|
|
38
|
+
t.Fatal("duplicate display names require ID")
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
func TestOrderingAndCommands(t *testing.T) {
|
|
43
|
+
now := time.Now()
|
|
44
|
+
projects := []Project{{ID: "recent", Name: "Zed", LastUsed: now}, {ID: "fav", Name: "Alpha", Favorite: true}, {ID: "old", Name: "Beta"}}
|
|
45
|
+
Sort(projects)
|
|
46
|
+
if projects[0].ID != "fav" || projects[1].ID != "recent" {
|
|
47
|
+
t.Fatalf("wrong order: %+v", projects)
|
|
48
|
+
}
|
|
49
|
+
p := Project{AutoCommands: map[string]string{"dev": "npm run dev", "test": "npm test"}, Commands: map[string]string{"dev": "pnpm dev"}, Tools: map[string]bool{"opencode": false}}
|
|
50
|
+
if p.AllCommands()["dev"] != "pnpm dev" || p.AllCommands()["test"] != "npm test" {
|
|
51
|
+
t.Fatal("custom commands should override detection")
|
|
52
|
+
}
|
|
53
|
+
if p.ToolEnabled("opencode") || !p.ToolEnabled("vscode") {
|
|
54
|
+
t.Fatal("unexpected tool settings")
|
|
55
|
+
}
|
|
56
|
+
p.AllCommands()["test"] = "changed"
|
|
57
|
+
if p.AutoCommands["test"] != "npm test" {
|
|
58
|
+
t.Fatal("merged command map must not alias the project")
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
func TestCanonicalPath(t *testing.T) {
|
|
63
|
+
dir := t.TempDir()
|
|
64
|
+
path, err := CanonicalPath(filepath.Join(dir, "."))
|
|
65
|
+
if err != nil || !SamePath(path, dir) {
|
|
66
|
+
t.Fatalf("%q %v", path, err)
|
|
67
|
+
}
|
|
68
|
+
if _, err := CanonicalPath(filepath.Join(dir, "missing")); err == nil {
|
|
69
|
+
t.Fatal("missing directory accepted")
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
package tools
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"os"
|
|
5
|
+
"os/exec"
|
|
6
|
+
"path/filepath"
|
|
7
|
+
"runtime"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
// LookPath also finds VS Code's bundled macOS launcher. Installing its shell
|
|
11
|
+
// command manually is optional when the app is in an Applications directory.
|
|
12
|
+
func LookPath(name string) (string, error) {
|
|
13
|
+
path, err := exec.LookPath(name)
|
|
14
|
+
if err == nil {
|
|
15
|
+
return path, nil
|
|
16
|
+
}
|
|
17
|
+
if runtime.GOOS == "darwin" && name == "code" {
|
|
18
|
+
if app := applicationPath("Visual Studio Code", applicationDirectories()); app != "" {
|
|
19
|
+
return exec.LookPath(filepath.Join(app, "Contents", "Resources", "app", "bin", "code"))
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return "", err
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
func applicationDirectories() []string {
|
|
26
|
+
dirs := []string{"/Applications"}
|
|
27
|
+
if home, err := os.UserHomeDir(); err == nil {
|
|
28
|
+
dirs = append(dirs, filepath.Join(home, "Applications"))
|
|
29
|
+
}
|
|
30
|
+
return dirs
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
func applicationPath(name string, dirs []string) string {
|
|
34
|
+
for _, dir := range dirs {
|
|
35
|
+
path := filepath.Join(dir, name+".app")
|
|
36
|
+
if info, err := os.Stat(path); err == nil && info.IsDir() {
|
|
37
|
+
return path
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return ""
|
|
41
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
//go:build !windows
|
|
2
|
+
|
|
3
|
+
package tools
|
|
4
|
+
|
|
5
|
+
import (
|
|
6
|
+
"os"
|
|
7
|
+
"os/exec"
|
|
8
|
+
"syscall"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
func executable(name string, args ...string) (*exec.Cmd, error) {
|
|
12
|
+
path, err := LookPath(name)
|
|
13
|
+
if err != nil {
|
|
14
|
+
return nil, err
|
|
15
|
+
}
|
|
16
|
+
return exec.Command(path, args...), nil
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
func Shell(path, command string) *exec.Cmd {
|
|
20
|
+
shell := os.Getenv("SHELL")
|
|
21
|
+
if shell == "" {
|
|
22
|
+
shell = "/bin/sh"
|
|
23
|
+
}
|
|
24
|
+
var cmd *exec.Cmd
|
|
25
|
+
if command == "" {
|
|
26
|
+
cmd = exec.Command(shell)
|
|
27
|
+
} else {
|
|
28
|
+
cmd = exec.Command(shell, "-c", command)
|
|
29
|
+
}
|
|
30
|
+
cmd.Dir = path
|
|
31
|
+
return cmd
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
func background(cmd *exec.Cmd) { cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} }
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
package tools
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"fmt"
|
|
5
|
+
"os/exec"
|
|
6
|
+
"strings"
|
|
7
|
+
"syscall"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
func executable(name string, args ...string) (*exec.Cmd, error) {
|
|
11
|
+
path, err := exec.LookPath(name)
|
|
12
|
+
if err != nil {
|
|
13
|
+
return nil, err
|
|
14
|
+
}
|
|
15
|
+
if strings.HasSuffix(strings.ToLower(path), ".cmd") || strings.HasSuffix(strings.ToLower(path), ".bat") {
|
|
16
|
+
// CmdLine avoids Go's CRT argument escaping, which cmd.exe does not use.
|
|
17
|
+
// Reject expansion characters in generated arguments to keep paths literal.
|
|
18
|
+
all := append([]string{path}, args...)
|
|
19
|
+
for i, arg := range all {
|
|
20
|
+
if strings.ContainsAny(arg, "\"%\r\n") {
|
|
21
|
+
return nil, fmt.Errorf("batch launcher cannot safely pass this path: %s", arg)
|
|
22
|
+
}
|
|
23
|
+
all[i] = "\"" + arg + "\""
|
|
24
|
+
}
|
|
25
|
+
cmd := exec.Command("cmd.exe")
|
|
26
|
+
cmd.SysProcAttr = &syscall.SysProcAttr{CmdLine: "cmd.exe /d /v:off /s /c \"" + strings.Join(all, " ") + "\""}
|
|
27
|
+
return cmd, nil
|
|
28
|
+
}
|
|
29
|
+
return exec.Command(path, args...), nil
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
func Shell(path, command string) *exec.Cmd {
|
|
33
|
+
var cmd *exec.Cmd
|
|
34
|
+
if command != "" {
|
|
35
|
+
cmd = exec.Command("cmd.exe")
|
|
36
|
+
cmd.SysProcAttr = &syscall.SysProcAttr{CmdLine: "cmd.exe /d /s /c \"" + command + "\""}
|
|
37
|
+
} else if pwsh, err := exec.LookPath("pwsh"); err == nil {
|
|
38
|
+
cmd = exec.Command(pwsh, "-NoLogo")
|
|
39
|
+
} else {
|
|
40
|
+
cmd = exec.Command("powershell.exe", "-NoLogo")
|
|
41
|
+
}
|
|
42
|
+
cmd.Dir = path
|
|
43
|
+
return cmd
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
func background(cmd *exec.Cmd) {
|
|
47
|
+
if cmd.SysProcAttr == nil {
|
|
48
|
+
cmd.SysProcAttr = &syscall.SysProcAttr{}
|
|
49
|
+
}
|
|
50
|
+
cmd.SysProcAttr.HideWindow = true
|
|
51
|
+
cmd.SysProcAttr.CreationFlags |= 0x00000200 // CREATE_NEW_PROCESS_GROUP
|
|
52
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
package tools
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"os"
|
|
5
|
+
"path/filepath"
|
|
6
|
+
"strings"
|
|
7
|
+
"testing"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
func TestBatchLauncherPassesDirectoryLiterally(t *testing.T) {
|
|
11
|
+
dir := t.TempDir()
|
|
12
|
+
launcher := filepath.Join(dir, "rian-test-launcher.cmd")
|
|
13
|
+
if err := os.WriteFile(launcher, []byte("@echo off\r\necho \"%~1\"\r\n"), 0600); err != nil {
|
|
14
|
+
t.Fatal(err)
|
|
15
|
+
}
|
|
16
|
+
argument := filepath.Join(dir, "A & B!")
|
|
17
|
+
cmd, err := executable(launcher, argument)
|
|
18
|
+
if err != nil {
|
|
19
|
+
t.Fatal(err)
|
|
20
|
+
}
|
|
21
|
+
background(cmd)
|
|
22
|
+
output, err := cmd.CombinedOutput()
|
|
23
|
+
if err != nil || !strings.Contains(string(output), argument) {
|
|
24
|
+
t.Fatalf("batch argument changed: %q %v", output, err)
|
|
25
|
+
}
|
|
26
|
+
if _, err := executable(launcher, filepath.Join(dir, "%PATH%")); err == nil {
|
|
27
|
+
t.Fatal("cmd expansion characters must be rejected")
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// Package tools isolates launchers from the interface. Interactive tools return
|
|
2
|
+
// an exec.Cmd that Bubble Tea can run while suspending its terminal renderer.
|
|
3
|
+
package tools
|
|
4
|
+
|
|
5
|
+
import (
|
|
6
|
+
"errors"
|
|
7
|
+
"fmt"
|
|
8
|
+
"net/url"
|
|
9
|
+
"os"
|
|
10
|
+
"os/exec"
|
|
11
|
+
"runtime"
|
|
12
|
+
"strings"
|
|
13
|
+
|
|
14
|
+
"rian/internal/git"
|
|
15
|
+
"rian/internal/project"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
type Launcher interface {
|
|
19
|
+
OpenURL(string) error
|
|
20
|
+
OpenEditor(project.Project) error
|
|
21
|
+
Interactive(string, project.Project) (*exec.Cmd, error)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type System struct{}
|
|
25
|
+
|
|
26
|
+
func checkDirectory(p project.Project) error {
|
|
27
|
+
info, err := os.Stat(p.Path)
|
|
28
|
+
if err != nil || !info.IsDir() {
|
|
29
|
+
return fmt.Errorf("this directory is no longer available: %s", p.Path)
|
|
30
|
+
}
|
|
31
|
+
return nil
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
func (System) OpenEditor(p project.Project) error {
|
|
35
|
+
if err := checkDirectory(p); err != nil {
|
|
36
|
+
return err
|
|
37
|
+
}
|
|
38
|
+
cmd, err := executable("code", p.Path)
|
|
39
|
+
if err != nil {
|
|
40
|
+
if errors.Is(err, exec.ErrNotFound) {
|
|
41
|
+
return fmt.Errorf("VS Code was not found in PATH; install its 'code' launcher")
|
|
42
|
+
}
|
|
43
|
+
return err
|
|
44
|
+
}
|
|
45
|
+
cmd.Dir = p.Path
|
|
46
|
+
return detach(cmd)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
func (System) Interactive(name string, p project.Project) (*exec.Cmd, error) {
|
|
50
|
+
if err := checkDirectory(p); err != nil {
|
|
51
|
+
return nil, err
|
|
52
|
+
}
|
|
53
|
+
if name == "terminal" {
|
|
54
|
+
return Shell(p.Path, ""), nil
|
|
55
|
+
}
|
|
56
|
+
if name == "kilocode" {
|
|
57
|
+
if _, err := exec.LookPath("kilo"); err == nil {
|
|
58
|
+
name = "kilo"
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
cmd, err := executable(name)
|
|
62
|
+
if err != nil {
|
|
63
|
+
return nil, fmt.Errorf("%s was not found in PATH", name)
|
|
64
|
+
}
|
|
65
|
+
cmd.Dir = p.Path
|
|
66
|
+
return cmd, nil
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
func (s System) OpenChatGPT() error {
|
|
70
|
+
if _, err := exec.LookPath("chatgpt"); err == nil {
|
|
71
|
+
cmd, err := executable("chatgpt")
|
|
72
|
+
if err == nil {
|
|
73
|
+
return detach(cmd)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if runtime.GOOS == "darwin" {
|
|
77
|
+
if app := applicationPath("ChatGPT", applicationDirectories()); app != "" {
|
|
78
|
+
return detach(exec.Command("/usr/bin/open", "-a", app))
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return s.OpenURL("https://chatgpt.com")
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
func GitHubURL(p project.Project, status git.Status) (string, error) {
|
|
85
|
+
repo := p.GitHub
|
|
86
|
+
if repo == "" {
|
|
87
|
+
repo = status.GitHub
|
|
88
|
+
}
|
|
89
|
+
if repo == "" {
|
|
90
|
+
return "", fmt.Errorf("this project has no GitHub remote; add an override in Edit Project")
|
|
91
|
+
}
|
|
92
|
+
repo, err := git.NormalizeRepo(repo)
|
|
93
|
+
if err != nil {
|
|
94
|
+
return "", err
|
|
95
|
+
}
|
|
96
|
+
return "https://github.com/" + repo, nil
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
func ValidateURL(value string) error {
|
|
100
|
+
if value == "" {
|
|
101
|
+
return nil
|
|
102
|
+
}
|
|
103
|
+
u, err := url.Parse(value)
|
|
104
|
+
if err != nil || u.Hostname() == "" || u.Scheme != "https" && u.Scheme != "http" {
|
|
105
|
+
return fmt.Errorf("use a complete http:// or https:// URL")
|
|
106
|
+
}
|
|
107
|
+
return nil
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
func (System) OpenURL(value string) error {
|
|
111
|
+
if err := ValidateURL(value); err != nil {
|
|
112
|
+
return err
|
|
113
|
+
}
|
|
114
|
+
var cmd *exec.Cmd
|
|
115
|
+
switch runtime.GOOS {
|
|
116
|
+
case "windows":
|
|
117
|
+
cmd = exec.Command("rundll32.exe", "url.dll,FileProtocolHandler", value)
|
|
118
|
+
case "darwin":
|
|
119
|
+
cmd = exec.Command("open", value)
|
|
120
|
+
default:
|
|
121
|
+
if _, err := exec.LookPath("xdg-open"); err == nil {
|
|
122
|
+
cmd = exec.Command("xdg-open", value)
|
|
123
|
+
} else {
|
|
124
|
+
cmd = exec.Command("gio", "open", value)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return detach(cmd)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
func detach(cmd *exec.Cmd) error {
|
|
131
|
+
background(cmd)
|
|
132
|
+
if err := cmd.Start(); err != nil {
|
|
133
|
+
return err
|
|
134
|
+
}
|
|
135
|
+
// Reap the child without tying a graphical application's lifetime to Rian.
|
|
136
|
+
go func() { _ = cmd.Wait() }()
|
|
137
|
+
return nil
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
func GitCommand(p project.Project, args ...string) (*exec.Cmd, error) {
|
|
141
|
+
if err := checkDirectory(p); err != nil {
|
|
142
|
+
return nil, err
|
|
143
|
+
}
|
|
144
|
+
cmd, err := executable("git", args...)
|
|
145
|
+
if err != nil {
|
|
146
|
+
return nil, fmt.Errorf("Git was not found in PATH")
|
|
147
|
+
}
|
|
148
|
+
cmd.Dir = p.Path
|
|
149
|
+
return cmd, nil
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
func CommandPreview(command string) string { return strings.TrimSpace(command) }
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
package tools
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"os"
|
|
5
|
+
"path/filepath"
|
|
6
|
+
"strings"
|
|
7
|
+
"testing"
|
|
8
|
+
|
|
9
|
+
"rian/internal/git"
|
|
10
|
+
"rian/internal/project"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
func TestURLsAndRepositoryOverride(t *testing.T) {
|
|
14
|
+
for _, value := range []string{"https://chatgpt.com", "http://localhost:3000", "https://example.com/a?x=1", ""} {
|
|
15
|
+
if err := ValidateURL(value); err != nil {
|
|
16
|
+
t.Errorf("%q: %v", value, err)
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
for _, value := range []string{"javascript:alert(1)", "file:///tmp/a", "https://", "example.com"} {
|
|
20
|
+
if err := ValidateURL(value); err == nil {
|
|
21
|
+
t.Errorf("accepted %q", value)
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
value, err := GitHubURL(project.Project{GitHub: "override/repo"}, git.Status{GitHub: "detected/repo"})
|
|
25
|
+
if err != nil || value != "https://github.com/override/repo" {
|
|
26
|
+
t.Fatalf("%s %v", value, err)
|
|
27
|
+
}
|
|
28
|
+
if _, err := GitHubURL(project.Project{}, git.Status{}); err == nil {
|
|
29
|
+
t.Fatal("missing remote accepted")
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
func TestMacApplicationsInSystemAndUserDirectories(t *testing.T) {
|
|
34
|
+
root := t.TempDir()
|
|
35
|
+
system, user := filepath.Join(root, "Applications"), filepath.Join(root, "User Applications")
|
|
36
|
+
app := filepath.Join(user, "Visual Studio Code.app")
|
|
37
|
+
if err := os.MkdirAll(app, 0700); err != nil {
|
|
38
|
+
t.Fatal(err)
|
|
39
|
+
}
|
|
40
|
+
if path := applicationPath("Visual Studio Code", []string{system, user}); path != app {
|
|
41
|
+
t.Fatalf("application path: %q", path)
|
|
42
|
+
}
|
|
43
|
+
if path := applicationPath("Missing", []string{system, user}); path != "" {
|
|
44
|
+
t.Fatalf("missing app accepted: %q", path)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
func TestShellRunsInSelectedDirectory(t *testing.T) {
|
|
49
|
+
dir := t.TempDir()
|
|
50
|
+
cmd := Shell(dir, "echo rian-command-ok")
|
|
51
|
+
background(cmd)
|
|
52
|
+
output, err := cmd.CombinedOutput()
|
|
53
|
+
if err != nil || !strings.Contains(string(output), "rian-command-ok") || cmd.Dir != dir {
|
|
54
|
+
t.Fatalf("%s %v", output, err)
|
|
55
|
+
}
|
|
56
|
+
}
|