@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.
Files changed (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +155 -2
  3. package/THIRD_PARTY_NOTICES.txt +673 -0
  4. package/bin/rian.cjs +12 -0
  5. package/cmd/rian/main.go +17 -0
  6. package/dist/binaries/darwin-arm64/rian +0 -0
  7. package/dist/binaries/darwin-x64/rian +0 -0
  8. package/dist/binaries/linux-arm64/rian +0 -0
  9. package/dist/binaries/linux-x64/rian +0 -0
  10. package/dist/binaries/manifest.json +29 -0
  11. package/dist/binaries/win32-arm64/rian.exe +0 -0
  12. package/dist/binaries/win32-x64/rian.exe +0 -0
  13. package/go.mod +28 -0
  14. package/go.sum +44 -0
  15. package/internal/app/app.go +298 -0
  16. package/internal/app/app_test.go +85 -0
  17. package/internal/config/config.go +189 -0
  18. package/internal/config/config_test.go +101 -0
  19. package/internal/detect/detect.go +252 -0
  20. package/internal/detect/detect_test.go +86 -0
  21. package/internal/git/git.go +166 -0
  22. package/internal/git/git_test.go +73 -0
  23. package/internal/git/window_unix.go +7 -0
  24. package/internal/git/window_windows.go +8 -0
  25. package/internal/process/platform_unix.go +19 -0
  26. package/internal/process/platform_windows.go +25 -0
  27. package/internal/process/process.go +172 -0
  28. package/internal/process/process_test.go +137 -0
  29. package/internal/project/project.go +180 -0
  30. package/internal/project/project_test.go +71 -0
  31. package/internal/tools/lookup.go +41 -0
  32. package/internal/tools/platform_unix.go +34 -0
  33. package/internal/tools/platform_windows.go +52 -0
  34. package/internal/tools/platform_windows_test.go +29 -0
  35. package/internal/tools/tools.go +152 -0
  36. package/internal/tools/tools_test.go +56 -0
  37. package/internal/ui/actions.go +259 -0
  38. package/internal/ui/forms.go +156 -0
  39. package/internal/ui/model.go +220 -0
  40. package/internal/ui/styles.go +85 -0
  41. package/internal/ui/ui_test.go +170 -0
  42. package/internal/ui/update.go +470 -0
  43. package/internal/ui/view.go +459 -0
  44. package/lib/launcher.cjs +46 -0
  45. package/lib/platforms.cjs +20 -0
  46. package/package.json +39 -4
  47. package/scripts/build-binaries.cjs +60 -0
  48. package/scripts/check-package.cjs +49 -0
  49. package/scripts/ensure-binary.cjs +11 -0
  50. package/test/npm/launcher.test.cjs +45 -0
  51. 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
+ }