@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,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
|
+
}
|