@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,189 @@
1
+ // Package config stores local settings with atomic writes and a cross-process
2
+ // lock. Updates always reload the file so two Rian instances do not lose edits.
3
+ package config
4
+
5
+ import (
6
+ "encoding/json"
7
+ "errors"
8
+ "fmt"
9
+ "os"
10
+ "path/filepath"
11
+ "strings"
12
+ "sync"
13
+ "time"
14
+
15
+ "rian/internal/project"
16
+ )
17
+
18
+ const Version = 1
19
+
20
+ type Config struct {
21
+ Version int `json:"version"`
22
+ Revision uint64 `json:"revision,omitempty"`
23
+ Projects []project.Project `json:"projects"`
24
+ }
25
+
26
+ type Store struct {
27
+ Path string
28
+ mu sync.Mutex
29
+ }
30
+
31
+ func DefaultPath() (string, error) {
32
+ if dir := os.Getenv("RIAN_CONFIG_HOME"); dir != "" {
33
+ return filepath.Join(dir, "config.json"), nil
34
+ }
35
+ dir, err := os.UserConfigDir()
36
+ if err != nil {
37
+ return "", err
38
+ }
39
+ return filepath.Join(dir, "rian", "config.json"), nil
40
+ }
41
+
42
+ func New(path string) *Store { return &Store{Path: path} }
43
+
44
+ func (s *Store) Load() (Config, error) {
45
+ data, err := os.ReadFile(s.Path)
46
+ if errors.Is(err, os.ErrNotExist) {
47
+ return Config{Version: Version, Projects: []project.Project{}}, nil
48
+ }
49
+ if err != nil {
50
+ return Config{}, fmt.Errorf("read config: %w", err)
51
+ }
52
+ var cfg Config
53
+ if err := json.Unmarshal(data, &cfg); err != nil {
54
+ return Config{}, fmt.Errorf("invalid config %s (file preserved): %w", s.Path, err)
55
+ }
56
+ if cfg.Version != Version {
57
+ return Config{}, fmt.Errorf("unsupported config version %d (file preserved)", cfg.Version)
58
+ }
59
+ if err := validate(cfg); err != nil {
60
+ return Config{}, fmt.Errorf("invalid config (file preserved): %w", err)
61
+ }
62
+ project.Sort(cfg.Projects)
63
+ return cfg, nil
64
+ }
65
+
66
+ func validate(cfg Config) error {
67
+ ids := map[string]bool{}
68
+ for i, p := range cfg.Projects {
69
+ if strings.TrimSpace(p.ID) == "" || strings.TrimSpace(p.Name) == "" || !filepath.IsAbs(p.Path) {
70
+ return fmt.Errorf("project %d needs an ID, name, and absolute path", i+1)
71
+ }
72
+ if ids[p.ID] {
73
+ return fmt.Errorf("duplicate project ID %s", p.ID)
74
+ }
75
+ ids[p.ID] = true
76
+ for _, prior := range cfg.Projects[:i] {
77
+ if project.SamePath(p.Path, prior.Path) {
78
+ return fmt.Errorf("directory already belongs to %s", prior.Name)
79
+ }
80
+ }
81
+ }
82
+ return nil
83
+ }
84
+
85
+ func (s *Store) Update(change func(*Config) error) (Config, error) {
86
+ s.mu.Lock()
87
+ defer s.mu.Unlock()
88
+ dir := filepath.Dir(s.Path)
89
+ if err := os.MkdirAll(dir, 0700); err != nil {
90
+ return Config{}, err
91
+ }
92
+ lock := s.Path + ".lock"
93
+ deadline := time.Now().Add(3 * time.Second)
94
+ for {
95
+ err := os.Mkdir(lock, 0700)
96
+ if err == nil {
97
+ break
98
+ }
99
+ if !errors.Is(err, os.ErrExist) {
100
+ return Config{}, err
101
+ }
102
+ if time.Now().After(deadline) {
103
+ return Config{}, fmt.Errorf("config is locked by another Rian instance; if none is running, remove %s", lock)
104
+ }
105
+ time.Sleep(30 * time.Millisecond)
106
+ }
107
+ defer os.Remove(lock)
108
+ cfg, err := s.Load()
109
+ if err != nil {
110
+ return Config{}, err
111
+ }
112
+ if err := change(&cfg); err != nil {
113
+ return Config{}, err
114
+ }
115
+ cfg.Version = Version
116
+ cfg.Revision++
117
+ if err := validate(cfg); err != nil {
118
+ return Config{}, err
119
+ }
120
+ project.Sort(cfg.Projects)
121
+ data, err := json.MarshalIndent(cfg, "", " ")
122
+ if err != nil {
123
+ return Config{}, err
124
+ }
125
+ f, err := os.CreateTemp(dir, ".rian-*.tmp")
126
+ if err != nil {
127
+ return Config{}, err
128
+ }
129
+ defer os.Remove(f.Name())
130
+ if err := f.Chmod(0600); err != nil {
131
+ f.Close()
132
+ return Config{}, err
133
+ }
134
+ if _, err := f.Write(append(data, '\n')); err != nil {
135
+ f.Close()
136
+ return Config{}, err
137
+ }
138
+ if err := f.Sync(); err != nil {
139
+ f.Close()
140
+ return Config{}, err
141
+ }
142
+ if err := f.Close(); err != nil {
143
+ return Config{}, err
144
+ }
145
+ if err := os.Rename(f.Name(), s.Path); err != nil {
146
+ return Config{}, fmt.Errorf("save config: %w", err)
147
+ }
148
+ return cfg, nil
149
+ }
150
+
151
+ func Add(cfg *Config, p project.Project) error {
152
+ for _, existing := range cfg.Projects {
153
+ if project.SamePath(existing.Path, p.Path) {
154
+ return fmt.Errorf("this directory is already saved as %s", existing.Name)
155
+ }
156
+ }
157
+ cfg.Projects = append(cfg.Projects, p)
158
+ return nil
159
+ }
160
+
161
+ func Replace(cfg *Config, p project.Project) error {
162
+ for i := range cfg.Projects {
163
+ if cfg.Projects[i].ID == p.ID {
164
+ cfg.Projects[i] = p
165
+ return nil
166
+ }
167
+ }
168
+ return fmt.Errorf("project was removed by another Rian instance")
169
+ }
170
+
171
+ func Remove(cfg *Config, id string) error {
172
+ for i, p := range cfg.Projects {
173
+ if p.ID == id {
174
+ cfg.Projects = append(cfg.Projects[:i], cfg.Projects[i+1:]...)
175
+ return nil
176
+ }
177
+ }
178
+ return fmt.Errorf("project no longer exists")
179
+ }
180
+
181
+ func Touch(cfg *Config, id string) error {
182
+ for i := range cfg.Projects {
183
+ if cfg.Projects[i].ID == id {
184
+ cfg.Projects[i].LastUsed = time.Now()
185
+ return nil
186
+ }
187
+ }
188
+ return fmt.Errorf("project no longer exists")
189
+ }
@@ -0,0 +1,101 @@
1
+ package config
2
+
3
+ import (
4
+ "fmt"
5
+ "os"
6
+ "path/filepath"
7
+ "sync"
8
+ "testing"
9
+
10
+ "rian/internal/project"
11
+ )
12
+
13
+ func TestRoundTripAndRemoval(t *testing.T) {
14
+ dir := t.TempDir()
15
+ path := filepath.Join(dir, "settings", "config.json")
16
+ store := New(path)
17
+ cfg, err := store.Load()
18
+ if err != nil || len(cfg.Projects) != 0 {
19
+ t.Fatalf("empty load: %+v %v", cfg, err)
20
+ }
21
+ p := project.New("My project", dir)
22
+ p.Commands = map[string]string{"dev": "npm run dev"}
23
+ p.Tools = map[string]bool{"opencode": false}
24
+ p.URLs = map[string]string{"local": "http://localhost:3000"}
25
+ if _, err := store.Update(func(cfg *Config) error { return Add(cfg, p) }); err != nil {
26
+ t.Fatal(err)
27
+ }
28
+ cfg, err = New(path).Load()
29
+ if err != nil || len(cfg.Projects) != 1 || cfg.Projects[0].Commands["dev"] != "npm run dev" || cfg.Projects[0].ToolEnabled("opencode") {
30
+ t.Fatalf("round trip: %+v %v", cfg, err)
31
+ }
32
+ if _, err := store.Update(func(cfg *Config) error { return Add(cfg, p) }); err == nil {
33
+ t.Fatal("duplicate directory accepted")
34
+ }
35
+ if _, err := store.Update(func(cfg *Config) error { return Touch(cfg, p.ID) }); err != nil {
36
+ t.Fatal(err)
37
+ }
38
+ cfg, _ = store.Load()
39
+ if cfg.Projects[0].LastUsed.IsZero() {
40
+ t.Fatal("recent timestamp missing")
41
+ }
42
+ if _, err := store.Update(func(cfg *Config) error { return Remove(cfg, p.ID) }); err != nil {
43
+ t.Fatal(err)
44
+ }
45
+ if _, err := os.Stat(dir); err != nil {
46
+ t.Fatal("removal deleted the actual directory")
47
+ }
48
+ cfg, _ = store.Load()
49
+ if len(cfg.Projects) != 0 {
50
+ t.Fatal("entry not removed")
51
+ }
52
+ files, _ := filepath.Glob(filepath.Join(filepath.Dir(path), ".rian-*.tmp"))
53
+ if len(files) != 0 {
54
+ t.Fatalf("left temporary files: %v", files)
55
+ }
56
+ }
57
+
58
+ func TestInvalidConfigPreserved(t *testing.T) {
59
+ for _, data := range []string{"{broken", `{"version":99,"projects":[]}`, `{"version":1,"projects":[{"id":"x","name":"x","path":"relative"}]}`} {
60
+ t.Run(data, func(t *testing.T) {
61
+ path := filepath.Join(t.TempDir(), "config.json")
62
+ if err := os.WriteFile(path, []byte(data), 0600); err != nil {
63
+ t.Fatal(err)
64
+ }
65
+ if _, err := New(path).Update(func(*Config) error { return nil }); err == nil {
66
+ t.Fatal("invalid config accepted")
67
+ }
68
+ actual, _ := os.ReadFile(path)
69
+ if string(actual) != data {
70
+ t.Fatal("invalid configuration overwritten")
71
+ }
72
+ })
73
+ }
74
+ }
75
+
76
+ func TestConcurrentStoresDoNotLoseUpdates(t *testing.T) {
77
+ dir := t.TempDir()
78
+ path := filepath.Join(dir, "config.json")
79
+ var wg sync.WaitGroup
80
+ errors := make(chan error, 8)
81
+ for i := range 8 {
82
+ wg.Add(1)
83
+ go func(i int) {
84
+ defer wg.Done()
85
+ p := project.New(fmt.Sprintf("project-%d", i), filepath.Join(dir, fmt.Sprint(i)))
86
+ _, err := New(path).Update(func(cfg *Config) error { return Add(cfg, p) })
87
+ if err != nil {
88
+ errors <- err
89
+ }
90
+ }(i)
91
+ }
92
+ wg.Wait()
93
+ close(errors)
94
+ for err := range errors {
95
+ t.Error(err)
96
+ }
97
+ cfg, err := New(path).Load()
98
+ if err != nil || len(cfg.Projects) != 8 {
99
+ t.Fatalf("lost updates: %d %v", len(cfg.Projects), err)
100
+ }
101
+ }
@@ -0,0 +1,252 @@
1
+ // Package detect inspects a handful of files in the project root. It never
2
+ // installs packages, evaluates project files, or walks node_modules.
3
+ package detect
4
+
5
+ import (
6
+ "encoding/json"
7
+ "fmt"
8
+ "go/parser"
9
+ "go/token"
10
+ "io"
11
+ "os"
12
+ "path/filepath"
13
+ "runtime"
14
+ "sort"
15
+ "strings"
16
+
17
+ "rian/internal/git"
18
+ "rian/internal/project"
19
+ "rian/internal/tools"
20
+ )
21
+
22
+ type Result struct {
23
+ Project project.Project
24
+ Git git.Status
25
+ Available map[string]bool
26
+ Warnings []string
27
+ }
28
+
29
+ type PackageJSON struct {
30
+ Name string `json:"name"`
31
+ PackageManager string `json:"packageManager"`
32
+ Scripts map[string]string `json:"scripts"`
33
+ Dependencies map[string]json.RawMessage `json:"dependencies"`
34
+ DevDependencies map[string]json.RawMessage `json:"devDependencies"`
35
+ }
36
+
37
+ func AvailableTools() map[string]bool {
38
+ result := map[string]bool{}
39
+ for _, name := range []string{"code", "opencode", "kilo", "kilocode", "gh", "lazygit", "git", "npm", "pnpm", "yarn", "bun", "go", "cargo", "python", "python3", "flutter", "chatgpt"} {
40
+ _, err := tools.LookPath(name)
41
+ result[name] = err == nil
42
+ }
43
+ result["kilocode"] = result["kilo"] || result["kilocode"]
44
+ return result
45
+ }
46
+
47
+ func Inspect(path string) (Result, error) {
48
+ canonical, err := project.CanonicalPath(path)
49
+ if err != nil {
50
+ return Result{}, err
51
+ }
52
+ p := project.New(filepath.Base(canonical), canonical)
53
+ p.AutoCommands = map[string]string{}
54
+ result := Result{Project: p, Available: AvailableTools()}
55
+ exists := func(name string) bool {
56
+ info, err := os.Stat(filepath.Join(canonical, name))
57
+ return err == nil && !info.IsDir()
58
+ }
59
+ addStack := func(name string) { result.Project.Stack = append(result.Project.Stack, name) }
60
+ commands := result.Project.AutoCommands
61
+ if exists("package.json") {
62
+ var pkg PackageJSON
63
+ data, err := readSmall(filepath.Join(canonical, "package.json"))
64
+ if err == nil {
65
+ err = json.Unmarshal(data, &pkg)
66
+ }
67
+ if err != nil {
68
+ result.Warnings = append(result.Warnings, "Could not read package.json: "+err.Error())
69
+ } else {
70
+ addStack("Node.js")
71
+ manager := PackageManager(pkg.PackageManager, exists)
72
+ addStack(manager)
73
+ for name, command := range ScriptCommands(manager, pkg.Scripts) {
74
+ commands[name] = command
75
+ }
76
+ for _, framework := range []struct{ dep, name string }{{"next", "Next.js"}, {"vite", "Vite"}, {"expo", "Expo"}, {"react-native", "React Native"}, {"react", "React"}} {
77
+ _, prod := pkg.Dependencies[framework.dep]
78
+ _, dev := pkg.DevDependencies[framework.dep]
79
+ if prod || dev {
80
+ addStack(framework.name)
81
+ }
82
+ }
83
+ }
84
+ }
85
+ if exists("go.mod") {
86
+ addStack("Go")
87
+ defaults(commands, map[string]string{"build": "go build ./...", "test": "go test ./..."})
88
+ defaults(commands, GoRunCommands(canonical))
89
+ }
90
+ if exists("Cargo.toml") {
91
+ addStack("Rust")
92
+ defaults(commands, map[string]string{"run": "cargo run", "build": "cargo build", "test": "cargo test", "lint": "cargo clippy"})
93
+ }
94
+ if exists("pyproject.toml") || exists("requirements.txt") || exists("setup.py") || exists("Pipfile") {
95
+ addStack("Python")
96
+ interpreter := "python3"
97
+ if runtime.GOOS == "windows" {
98
+ interpreter = "python"
99
+ }
100
+ defaults(commands, map[string]string{"test": interpreter + " -m pytest"})
101
+ if exists("manage.py") {
102
+ defaults(commands, map[string]string{"dev": interpreter + " manage.py runserver"})
103
+ }
104
+ if exists("main.py") {
105
+ defaults(commands, map[string]string{"run": interpreter + " main.py"})
106
+ }
107
+ }
108
+ if exists("pubspec.yaml") {
109
+ data, _ := readSmall(filepath.Join(canonical, "pubspec.yaml"))
110
+ if strings.Contains(string(data), "sdk: flutter") {
111
+ addStack("Flutter")
112
+ defaults(commands, map[string]string{"run": "flutter run", "build": "flutter build bundle", "test": "flutter test", "lint": "flutter analyze"})
113
+ }
114
+ }
115
+ result.Git = git.Inspect(canonical)
116
+ return result, nil
117
+ }
118
+
119
+ func readSmall(path string) ([]byte, error) {
120
+ f, err := os.Open(path)
121
+ if err != nil {
122
+ return nil, err
123
+ }
124
+ defer f.Close()
125
+ data, err := io.ReadAll(io.LimitReader(f, 2*1024*1024+1))
126
+ if len(data) > 2*1024*1024 {
127
+ return nil, fmt.Errorf("file is larger than 2 MB")
128
+ }
129
+ return data, err
130
+ }
131
+
132
+ func defaults(commands, values map[string]string) {
133
+ for key, value := range values {
134
+ if _, exists := commands[key]; !exists {
135
+ commands[key] = value
136
+ }
137
+ }
138
+ }
139
+
140
+ func PackageManager(declared string, exists func(string) bool) string {
141
+ manager := strings.SplitN(declared, "@", 2)[0]
142
+ if manager == "npm" || manager == "pnpm" || manager == "yarn" || manager == "bun" {
143
+ return manager
144
+ }
145
+ for _, candidate := range []struct {
146
+ name string
147
+ files []string
148
+ }{{"bun", []string{"bun.lock", "bun.lockb"}}, {"pnpm", []string{"pnpm-lock.yaml"}}, {"yarn", []string{"yarn.lock"}}} {
149
+ for _, file := range candidate.files {
150
+ if exists(file) {
151
+ return candidate.name
152
+ }
153
+ }
154
+ }
155
+ return "npm"
156
+ }
157
+
158
+ func ScriptCommands(manager string, scripts map[string]string) map[string]string {
159
+ result := map[string]string{}
160
+ for name := range scripts {
161
+ // npm script names outside this portable set can be added as a custom
162
+ // command. Never interpolate shell metacharacters from a script name.
163
+ if name == "" {
164
+ continue
165
+ }
166
+ safe := true
167
+ for _, r := range name {
168
+ if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || strings.ContainsRune("_:-.", r)) {
169
+ safe = false
170
+ break
171
+ }
172
+ }
173
+ if safe {
174
+ result[name] = manager + " run " + name
175
+ }
176
+ }
177
+ return result
178
+ }
179
+
180
+ func CommandNames(commands map[string]string) []string {
181
+ var names []string
182
+ for name := range commands {
183
+ names = append(names, name)
184
+ }
185
+ order := map[string]int{"dev": 1, "run": 2, "start": 3, "build": 4, "test": 5, "lint": 6, "typecheck": 7}
186
+ sort.Slice(names, func(i, j int) bool {
187
+ a, b := order[names[i]], order[names[j]]
188
+ if a == 0 {
189
+ a = 100
190
+ }
191
+ if b == 0 {
192
+ b = 100
193
+ }
194
+ if a != b {
195
+ return a < b
196
+ }
197
+ return names[i] < names[j]
198
+ })
199
+ return names
200
+ }
201
+
202
+ // Go repositories often keep their executable under cmd/ and their root is a
203
+ // library. Inspect package clauses so the suggested run command is usable.
204
+ func GoRunCommands(root string) map[string]string {
205
+ result := map[string]string{}
206
+ if hasMainPackage(root) {
207
+ result["run"] = "go run ."
208
+ return result
209
+ }
210
+ entries, _ := os.ReadDir(filepath.Join(root, "cmd"))
211
+ var runnable []string
212
+ for i, entry := range entries {
213
+ if i >= 64 {
214
+ break
215
+ }
216
+ if entry.IsDir() && hasMainPackage(filepath.Join(root, "cmd", entry.Name())) {
217
+ // Only construct a shell command from portable directory names.
218
+ if _, safe := ScriptCommands("go", map[string]string{entry.Name(): ""})[entry.Name()]; safe {
219
+ runnable = append(runnable, entry.Name())
220
+ }
221
+ }
222
+ }
223
+ for _, name := range runnable {
224
+ result["run:"+name] = "go run ./cmd/" + name
225
+ }
226
+ if len(runnable) == 1 {
227
+ result["run"] = result["run:"+runnable[0]]
228
+ delete(result, "run:"+runnable[0])
229
+ }
230
+ return result
231
+ }
232
+
233
+ func hasMainPackage(dir string) bool {
234
+ entries, _ := os.ReadDir(dir)
235
+ for i, entry := range entries {
236
+ if i >= 256 {
237
+ break
238
+ }
239
+ if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".go") || strings.HasSuffix(entry.Name(), "_test.go") {
240
+ continue
241
+ }
242
+ data, err := readSmall(filepath.Join(dir, entry.Name()))
243
+ if err != nil {
244
+ continue
245
+ }
246
+ file, err := parser.ParseFile(token.NewFileSet(), entry.Name(), data, parser.PackageClauseOnly)
247
+ if err == nil && file.Name.Name == "main" {
248
+ return true
249
+ }
250
+ }
251
+ return false
252
+ }
@@ -0,0 +1,86 @@
1
+ package detect
2
+
3
+ import (
4
+ "os"
5
+ "path/filepath"
6
+ "slices"
7
+ "testing"
8
+ )
9
+
10
+ func TestInspectStacksAndCommands(t *testing.T) {
11
+ tests := []struct {
12
+ name string
13
+ files map[string]string
14
+ stack []string
15
+ command, value string
16
+ }{
17
+ {"next-pnpm", map[string]string{"package.json": `{"scripts":{"dev":"next dev","test":"vitest"},"dependencies":{"next":"15"}}`, "pnpm-lock.yaml": ""}, []string{"Node.js", "pnpm", "Next.js"}, "dev", "pnpm run dev"},
18
+ {"bun-expo", map[string]string{"package.json": `{"packageManager":"bun@1.2","scripts":{"start":"expo start"},"dependencies":{"expo":"1","react-native":"1"}}`}, []string{"Node.js", "bun", "Expo", "React Native"}, "start", "bun run start"},
19
+ {"go", map[string]string{"go.mod": "module example.test\n"}, []string{"Go"}, "test", "go test ./..."},
20
+ {"rust", map[string]string{"Cargo.toml": "[package]\n"}, []string{"Rust"}, "build", "cargo build"},
21
+ {"python", map[string]string{"requirements.txt": "pytest", "main.py": ""}, []string{"Python"}, "run", ""},
22
+ {"flutter", map[string]string{"pubspec.yaml": "dependencies:\n flutter:\n sdk: flutter\n"}, []string{"Flutter"}, "test", "flutter test"},
23
+ {"vite-yarn", map[string]string{"package.json": `{"scripts":{"build":"vite build"},"devDependencies":{"vite":"6"}}`, "yarn.lock": ""}, []string{"Node.js", "yarn", "Vite"}, "build", "yarn run build"},
24
+ }
25
+ for _, tt := range tests {
26
+ t.Run(tt.name, func(t *testing.T) {
27
+ dir := t.TempDir()
28
+ for name, data := range tt.files {
29
+ if err := os.WriteFile(filepath.Join(dir, name), []byte(data), 0600); err != nil {
30
+ t.Fatal(err)
31
+ }
32
+ }
33
+ result, err := Inspect(dir)
34
+ if err != nil {
35
+ t.Fatal(err)
36
+ }
37
+ for _, stack := range tt.stack {
38
+ if !slices.Contains(result.Project.Stack, stack) {
39
+ t.Errorf("missing %s in %v", stack, result.Project.Stack)
40
+ }
41
+ }
42
+ command := result.Project.AutoCommands[tt.command]
43
+ if command == "" || tt.value != "" && command != tt.value {
44
+ t.Fatalf("command: %q, want %q", command, tt.value)
45
+ }
46
+ })
47
+ }
48
+ }
49
+
50
+ func TestInvalidFilesAndScriptNames(t *testing.T) {
51
+ dir := t.TempDir()
52
+ os.WriteFile(filepath.Join(dir, "package.json"), []byte("{broken"), 0600)
53
+ result, err := Inspect(dir)
54
+ if err != nil || len(result.Warnings) == 0 {
55
+ t.Fatalf("malformed manifest must be nonfatal: %+v %v", result, err)
56
+ }
57
+ commands := ScriptCommands("npm", map[string]string{"test:unit": "vitest", "dev && bad": "ignored", "": "ignored", "lint": "eslint"})
58
+ if len(commands) != 2 || commands["test:unit"] != "npm run test:unit" {
59
+ t.Fatalf("unsafe or missing scripts: %v", commands)
60
+ }
61
+ if actual := PackageManager("yarn@4", func(string) bool { return true }); actual != "yarn" {
62
+ t.Fatalf("declared manager should win: %s", actual)
63
+ }
64
+ if actual := PackageManager("", func(s string) bool { return s == "bun.lockb" }); actual != "bun" {
65
+ t.Fatal(actual)
66
+ }
67
+ if _, err := Inspect(filepath.Join(dir, "missing")); err == nil {
68
+ t.Fatal("missing directory accepted")
69
+ }
70
+ }
71
+
72
+ func TestGoRunUsesExecutableDirectory(t *testing.T) {
73
+ dir := t.TempDir()
74
+ if err := os.MkdirAll(filepath.Join(dir, "cmd", "server"), 0700); err != nil {
75
+ t.Fatal(err)
76
+ }
77
+ os.WriteFile(filepath.Join(dir, "library.go"), []byte("package server\n"), 0600)
78
+ os.WriteFile(filepath.Join(dir, "cmd", "server", "main.go"), []byte("package main\nfunc main() {}\n"), 0600)
79
+ if commands := GoRunCommands(dir); commands["run"] != "go run ./cmd/server" || len(commands) != 1 {
80
+ t.Fatalf("bad cmd/ detection: %v", commands)
81
+ }
82
+ os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main\n"), 0600)
83
+ if commands := GoRunCommands(dir); commands["run"] != "go run ." {
84
+ t.Fatalf("bad root detection: %v", commands)
85
+ }
86
+ }