Skip to content

Commit b66a35f

Browse files
committed
Phase 3 CLI implementation
1 parent 0ec5e2c commit b66a35f

16 files changed

Lines changed: 791 additions & 33 deletions

File tree

‎cmd/doctor.go‎

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"os/exec"
7+
"path/filepath"
8+
"strings"
9+
10+
"github.com/Vikrez22/phpvm/internal/paths"
11+
"github.com/Vikrez22/phpvm/internal/resolver"
12+
"github.com/spf13/cobra"
13+
)
14+
15+
var doctorCmd = &cobra.Command{
16+
Use: "doctor",
17+
Short: "Diagnose common phpvm issues",
18+
RunE: func(cmd *cobra.Command, args []string) error {
19+
warnings := 0
20+
errors := 0
21+
fmt.Println("phpvm doctor")
22+
fmt.Println()
23+
24+
if err := os.MkdirAll(paths.Root(), 0755); err != nil {
25+
fmt.Printf("x phpvm data directory writable: %v\n", err)
26+
errors++
27+
} else if err := writeProbe(paths.Root()); err != nil {
28+
fmt.Printf("x phpvm data directory writable: %v\n", err)
29+
errors++
30+
} else {
31+
fmt.Println("ok phpvm data directory exists and is writable")
32+
}
33+
34+
if phpPath, err := exec.LookPath("php"); err == nil && !strings.HasPrefix(filepath.ToSlash(phpPath), filepath.ToSlash(paths.RuntimesDir())) {
35+
fmt.Printf("warn system PHP appears before phpvm injection: %s\n", phpPath)
36+
warnings++
37+
} else {
38+
fmt.Println("ok no unmanaged PHP conflict detected")
39+
}
40+
41+
versions, err := resolver.InstalledVersions("php")
42+
if err != nil {
43+
fmt.Printf("x installed PHP versions readable: %v\n", err)
44+
errors++
45+
} else if len(versions) == 0 {
46+
fmt.Println("warn no PHP versions installed")
47+
fmt.Println("Fix: phpvm install php@8.3")
48+
warnings++
49+
} else {
50+
fmt.Printf("ok %d PHP version(s) installed\n", len(versions))
51+
}
52+
53+
if os.Getenv("APPDATA") == "" {
54+
fmt.Println("warn APPDATA is not set; phpvm will use ~/.phpvm")
55+
warnings++
56+
} else {
57+
fmt.Println("ok APPDATA is set")
58+
}
59+
60+
fmt.Println()
61+
if errors > 0 {
62+
return fmt.Errorf("doctor found %d error(s)", errors)
63+
}
64+
if warnings > 0 {
65+
fmt.Printf("doctor found %d warning(s)\n", warnings)
66+
return nil
67+
}
68+
fmt.Println("All checks passed. phpvm is healthy.")
69+
return nil
70+
},
71+
}
72+
73+
func writeProbe(dir string) error {
74+
path := filepath.Join(dir, ".write_test")
75+
if err := os.WriteFile(path, []byte("test"), 0644); err != nil {
76+
return err
77+
}
78+
return os.Remove(path)
79+
}
80+
81+
func init() {
82+
rootCmd.AddCommand(doctorCmd)
83+
}

‎cmd/helpers.go‎

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
package cmd
22

33
import (
4-
"crypto/md5"
5-
"fmt"
64
"os"
75
"path/filepath"
86

@@ -11,6 +9,7 @@ import (
119
"github.com/Vikrez22/phpvm/internal/manifest"
1210
"github.com/Vikrez22/phpvm/internal/paths"
1311
"github.com/Vikrez22/phpvm/internal/resolver"
12+
"github.com/Vikrez22/phpvm/internal/state"
1413
)
1514

1615
func prepareRuntime() (*manifest.Manifest, injector.Runtime, error) {
@@ -36,19 +35,10 @@ func prepareRuntime() (*manifest.Manifest, injector.Runtime, error) {
3635
PHPBinDir: rt.PHPBinDir,
3736
PHPIniPath: rt.PHPIniPath,
3837
PHPVersion: rt.PHPVersion,
39-
ProjectID: projectHash(m.ProjectRoot),
38+
ProjectID: state.HashProject(m.ProjectRoot),
4039
}, nil
4140
}
4241

43-
func projectHash(projectRoot string) string {
44-
abs, err := filepath.Abs(projectRoot)
45-
if err != nil {
46-
abs = projectRoot
47-
}
48-
h := md5.Sum([]byte(filepath.ToSlash(abs)))
49-
return fmt.Sprintf("%x", h[:6])
50-
}
51-
5242
func projectDisplayName(m *manifest.Manifest) string {
5343
if m.Project != nil && m.Project.Name != "" {
5444
return m.Project.Name
@@ -59,3 +49,12 @@ func projectDisplayName(m *manifest.Manifest) string {
5949
func ensureProjectStateDir(projectRoot string) error {
6050
return os.MkdirAll(paths.ProjectStateDir(projectRoot), 0755)
6151
}
52+
53+
func markActive(projectRoot, phpVersion string) error {
54+
ps, err := state.Load(projectRoot)
55+
if err != nil {
56+
return err
57+
}
58+
ps.MarkActivePHP(phpVersion)
59+
return ps.Save()
60+
}

‎cmd/init.go‎

Lines changed: 133 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,155 @@
11
package cmd
22

33
import (
4+
"bufio"
45
"fmt"
56
"os"
7+
"path/filepath"
8+
"strings"
69

710
"github.com/spf13/cobra"
811
)
912

1013
var initCmd = &cobra.Command{
1114
Use: "init",
1215
Short: "Create a runtime.yml in the current directory",
13-
RunE: func(cmd *cobra.Command, args []string) error {
14-
if _, err := os.Stat("runtime.yml"); err == nil {
15-
return fmt.Errorf("runtime.yml already exists")
16+
RunE: runInit,
17+
}
18+
19+
func init() {
20+
initCmd.Flags().String("php", "", "PHP version (default: 8.3)")
21+
initCmd.Flags().String("mysql", "", "MySQL version (optional)")
22+
initCmd.Flags().Bool("nginx", true, "Use Nginx as web server")
23+
initCmd.Flags().Bool("apache", false, "Use Apache as web server")
24+
initCmd.Flags().BoolP("yes", "y", false, "Skip interactive prompts")
25+
rootCmd.AddCommand(initCmd)
26+
}
27+
28+
func runInit(cmd *cobra.Command, args []string) error {
29+
cwd, err := os.Getwd()
30+
if err != nil {
31+
return err
32+
}
33+
manifestPath := filepath.Join(cwd, "runtime.yml")
34+
if _, err := os.Stat(manifestPath); err == nil {
35+
yes, _ := cmd.Flags().GetBool("yes")
36+
if !yes && !confirm("runtime.yml already exists. Overwrite? [y/N] ", false) {
37+
fmt.Println("Cancelled.")
38+
return nil
1639
}
40+
}
1741

18-
content := `phpvm: "1"
42+
if detected := detectProjectType(cwd); detected != "" {
43+
fmt.Printf("Detected: %s\n\n", detected)
44+
}
1945

20-
runtime:
21-
php: "8.3"
46+
phpVersion := promptOrFlag(cmd, "php", "PHP version", "8.3")
47+
mysqlVersion := promptOrFlag(cmd, "mysql", "MySQL version (leave blank to skip)", "")
48+
serverEngine := "nginx"
49+
if apache, _ := cmd.Flags().GetBool("apache"); apache {
50+
serverEngine = "apache"
51+
}
2252

23-
database:
24-
engine: none
53+
content := buildManifestContent(phpVersion, mysqlVersion, serverEngine, cwd)
54+
if err := os.WriteFile(manifestPath, []byte(content), 0644); err != nil {
55+
return fmt.Errorf("cannot write runtime.yml: %w", err)
56+
}
57+
58+
fmt.Println("Created runtime.yml")
59+
fmt.Println("Next: run 'phpvm install' then 'phpvm up'")
60+
return nil
61+
}
2562

26-
server:
27-
engine: nginx
28-
root: public
29-
`
30-
if err := os.WriteFile("runtime.yml", []byte(content), 0644); err != nil {
31-
return fmt.Errorf("cannot write runtime.yml: %w", err)
63+
func buildManifestContent(php, mysql, server, projectDir string) string {
64+
name := filepath.Base(projectDir)
65+
domain := strings.ToLower(strings.ReplaceAll(name, "_", "-")) + ".local"
66+
lines := []string{
67+
"# runtime.yml - managed by phpvm",
68+
`phpvm: "1"`,
69+
"",
70+
"runtime:",
71+
fmt.Sprintf(` php: "%s"`, php),
72+
}
73+
if mysql != "" {
74+
lines = append(lines,
75+
"",
76+
"database:",
77+
" engine: mysql",
78+
fmt.Sprintf(` version: "%s"`, mysql),
79+
)
80+
} else {
81+
lines = append(lines, "", "database:", " engine: none")
82+
}
83+
lines = append(lines,
84+
"",
85+
"server:",
86+
fmt.Sprintf(" engine: %s", server),
87+
" root: public",
88+
"",
89+
"project:",
90+
fmt.Sprintf(" name: %s", name),
91+
fmt.Sprintf(" domain: %s", domain),
92+
)
93+
return strings.Join(lines, "\n") + "\n"
94+
}
95+
96+
func detectProjectType(dir string) string {
97+
data, err := os.ReadFile(filepath.Join(dir, "composer.json"))
98+
if err != nil {
99+
if _, statErr := os.Stat(filepath.Join(dir, "index.php")); statErr == nil {
100+
return "PHP project"
101+
}
102+
return ""
103+
}
104+
content := string(data)
105+
switch {
106+
case strings.Contains(content, "laravel/framework"):
107+
if strings.Contains(content, `"^11`) {
108+
return "Laravel 11 project"
109+
}
110+
if strings.Contains(content, `"^10`) {
111+
return "Laravel 10 project"
32112
}
113+
return "Laravel project"
114+
case strings.Contains(content, "symfony/symfony"):
115+
return "Symfony project"
116+
case strings.Contains(strings.ToLower(content), "wordpress"):
117+
return "WordPress project"
118+
default:
119+
return "PHP project (composer.json found)"
120+
}
121+
}
33122

34-
fmt.Println("Created runtime.yml")
35-
fmt.Println("Next: run 'phpvm install' then 'phpvm up'")
36-
return nil
37-
},
123+
func promptOrFlag(cmd *cobra.Command, flag, label, defaultVal string) string {
124+
if cmd.Flags().Changed(flag) {
125+
val, _ := cmd.Flags().GetString(flag)
126+
return val
127+
}
128+
yes, _ := cmd.Flags().GetBool("yes")
129+
if yes {
130+
return defaultVal
131+
}
132+
if defaultVal == "" {
133+
fmt.Printf("%s: ", label)
134+
} else {
135+
fmt.Printf("%s [%s]: ", label, defaultVal)
136+
}
137+
reader := bufio.NewReader(os.Stdin)
138+
answer, _ := reader.ReadString('\n')
139+
answer = strings.TrimSpace(answer)
140+
if answer == "" {
141+
return defaultVal
142+
}
143+
return answer
38144
}
39145

40-
func init() {
41-
rootCmd.AddCommand(initCmd)
146+
func confirm(prompt string, defaultYes bool) bool {
147+
fmt.Print(prompt)
148+
reader := bufio.NewReader(os.Stdin)
149+
answer, _ := reader.ReadString('\n')
150+
answer = strings.ToLower(strings.TrimSpace(answer))
151+
if answer == "" {
152+
return defaultYes
153+
}
154+
return strings.HasPrefix(answer, "y")
42155
}

‎cmd/restore.go‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"path/filepath"
7+
8+
"github.com/Vikrez22/phpvm/internal/manifest"
9+
"github.com/Vikrez22/phpvm/internal/paths"
10+
"github.com/spf13/cobra"
11+
)
12+
13+
var restoreCmd = &cobra.Command{
14+
Use: "restore <name>",
15+
Short: "Restore environment from a snapshot",
16+
Args: cobra.ExactArgs(1),
17+
RunE: func(cmd *cobra.Command, args []string) error {
18+
cwd, err := os.Getwd()
19+
if err != nil {
20+
return err
21+
}
22+
m, err := manifest.Find(cwd)
23+
if err != nil {
24+
return err
25+
}
26+
name := args[0]
27+
snapshotPath := filepath.Join(paths.ProjectStateDir(m.ProjectRoot), "snapshots", name+".yml")
28+
if _, err := os.Stat(snapshotPath); err != nil {
29+
return fmt.Errorf("snapshot not found: %s", name)
30+
}
31+
snap, err := manifest.ParseFile(snapshotPath)
32+
if err != nil {
33+
return err
34+
}
35+
if err := updateManifestPHPVersion(m.FilePath, snap.Runtime.PHP); err != nil {
36+
return err
37+
}
38+
fmt.Printf("Restored snapshot %s: PHP %s\n", name, snap.Runtime.PHP)
39+
return nil
40+
},
41+
}
42+
43+
func init() {
44+
rootCmd.AddCommand(restoreCmd)
45+
}

‎cmd/run.go‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,13 @@ var runCmd = &cobra.Command{
1111
Args: cobra.MinimumNArgs(1),
1212
DisableFlagParsing: true,
1313
RunE: func(cmd *cobra.Command, args []string) error {
14-
_, rt, err := prepareRuntime()
14+
m, rt, err := prepareRuntime()
1515
if err != nil {
1616
return err
1717
}
18+
if err := markActive(m.ProjectRoot, rt.PHPVersion); err != nil {
19+
return err
20+
}
1821
return injector.RunCommand(rt, args)
1922
},
2023
}

‎cmd/shell.go‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ var shellCmd = &cobra.Command{
1717
if err != nil {
1818
return err
1919
}
20+
if err := markActive(m.ProjectRoot, rt.PHPVersion); err != nil {
21+
return err
22+
}
2023
fmt.Printf("Activating PHP %s for %s...\n", rt.PHPVersion, projectDisplayName(m))
2124
return injector.SpawnShell(rt, shellName)
2225
},

0 commit comments

Comments
 (0)