diff --git a/cmd/magebox/check.go b/cmd/magebox/check.go index 97226fc..0239456 100644 --- a/cmd/magebox/check.go +++ b/cmd/magebox/check.go @@ -185,10 +185,14 @@ func runCheck(cmd *cobra.Command, args []string) error { message: "Running", }) } else { + nginxStartHint := "magebox global start" + if runtime.GOOS == "darwin" { + nginxStartHint = "magebox global start (or: nginx)" + } results = append(results, checkResult{ name: "Nginx", status: "warning", - message: "Not running - run 'brew services start nginx'", + message: "Not running - run '" + nginxStartHint + "'", }) } printCheckResult(results[len(results)-1]) @@ -209,20 +213,40 @@ func runCheck(cmd *cobra.Command, args []string) error { } printCheckResult(results[len(results)-1]) - // Check vhost exists (check for upstream file which is unique per project) + // Check domain vhost exists (upstream alone is not enough for HTTPS / SNI) if cfg != nil { - upstreamPath := filepath.Join(p.MageBoxDir(), "nginx", "vhosts", cfg.Name+"-upstream.conf") - if _, err := os.Stat(upstreamPath); err == nil { + vhostsDir := filepath.Join(p.MageBoxDir(), "nginx", "vhosts") + hasDomainVhost := false + if entries, err := os.ReadDir(vhostsDir); err == nil { + prefix := cfg.Name + "-" + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := entry.Name() + if !strings.HasSuffix(name, ".conf") { + continue + } + if strings.HasSuffix(name, "-upstream.conf") || strings.HasPrefix(name, "000-magebox-default-ssl") { + continue + } + if strings.HasPrefix(name, prefix) { + hasDomainVhost = true + break + } + } + } + if hasDomainVhost { results = append(results, checkResult{ name: "Project Vhost", status: "ok", - message: filepath.Join(p.MageBoxDir(), "nginx", "vhosts", cfg.Name+"-*.conf"), + message: filepath.Join(vhostsDir, cfg.Name+"-.conf"), }) } else { results = append(results, checkResult{ name: "Project Vhost", status: "warning", - message: "Not found - run 'magebox start'", + message: "No HTTPS vhost — run 'magebox start' (wrong certificate in browser until then)", }) } printCheckResult(results[len(results)-1]) diff --git a/internal/nginx/templates/default-ssl.conf.tmpl b/internal/nginx/templates/default-ssl.conf.tmpl new file mode 100644 index 0000000..61eb91d --- /dev/null +++ b/internal/nginx/templates/default-ssl.conf.tmpl @@ -0,0 +1,10 @@ +# MageBox default SSL server — unknown hostnames must not receive another project's certificate. +# Regenerated by MageBox — do not edit manually. +server { + listen {{.HTTPSPort}} ssl default_server; +{{- if .EnableIPv6}} + listen [::]:{{.HTTPSPort}} ssl default_server; +{{- end}} + server_name _; + ssl_reject_handshake on; +} diff --git a/internal/nginx/vhost.go b/internal/nginx/vhost.go index 8fd0592..d641861 100644 --- a/internal/nginx/vhost.go +++ b/internal/nginx/vhost.go @@ -29,12 +29,18 @@ var proxyTemplateEmbed string //go:embed templates/upstream.conf.tmpl var upstreamTemplateEmbed string +//go:embed templates/default-ssl.conf.tmpl +var defaultSSLTemplateEmbed string + +const defaultSSLVhostFile = "000-magebox-default-ssl.conf" + func init() { // Register embedded templates as fallbacks lib.RegisterFallbackTemplate(lib.TemplateNginx, "vhost.conf.tmpl", vhostTemplateEmbed) lib.RegisterFallbackTemplate(lib.TemplateNginx, "vhost-laravel.conf.tmpl", vhostLaravelTemplateEmbed) lib.RegisterFallbackTemplate(lib.TemplateNginx, "proxy.conf.tmpl", proxyTemplateEmbed) lib.RegisterFallbackTemplate(lib.TemplateNginx, "upstream.conf.tmpl", upstreamTemplateEmbed) + lib.RegisterFallbackTemplate(lib.TemplateNginx, "default-ssl.conf.tmpl", defaultSSLTemplateEmbed) } // Template variables available in vhost.conf.tmpl: @@ -123,23 +129,27 @@ func (g *VhostGenerator) Generate(cfg *config.Config, projectPath string) error return fmt.Errorf("failed to create nginx logs directory: %w", err) } - // Generate upstream config (once per project, not per domain) - upstreamCfg := UpstreamConfig{ - ProjectName: cfg.Name, - PHPSocketPath: g.getPHPSocketPath(cfg.Name, cfg.PHP), - } - if err := g.generateUpstream(upstreamCfg); err != nil { - return fmt.Errorf("failed to generate upstream config: %w", err) - } - // Determine ports based on platform - // macOS uses port forwarding (80->8080, 443->8443), Linux uses standard ports httpPort := 80 httpsPort := 443 enableIPv6 := true if g.platform.Type == platform.Darwin { httpPort = 8080 httpsPort = 8443 + enableIPv6 = false + } + + if err := g.ensureDefaultSSLCatchAll(httpsPort, enableIPv6); err != nil { + return fmt.Errorf("default ssl vhost: %w", err) + } + + // Generate upstream config (once per project, not per domain) + upstreamCfg := UpstreamConfig{ + ProjectName: cfg.Name, + PHPSocketPath: g.getPHPSocketPath(cfg.Name, cfg.PHP), + } + if err := g.generateUpstream(upstreamCfg); err != nil { + return fmt.Errorf("failed to generate upstream config: %w", err) } for _, domain := range cfg.Domains { @@ -371,6 +381,38 @@ func (g *VhostGenerator) renderProxyVhost(cfg ProxyConfig) (string, error) { return buf.String(), nil } +// ensureDefaultSSLCatchAll installs a default_server block so HTTPS without a matching vhost +// does not present another project's certificate (common after Valet → MageBox migration). +func (g *VhostGenerator) ensureDefaultSSLCatchAll(httpsPort int, enableIPv6 bool) error { + tmplContent, err := lib.GetTemplate(lib.TemplateNginx, "default-ssl.conf.tmpl") + if err != nil { + tmplContent = defaultSSLTemplateEmbed + } + + tmpl, err := template.New("default-ssl").Parse(tmplContent) + if err != nil { + return fmt.Errorf("parse default ssl template: %w", err) + } + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, struct { + HTTPSPort int + EnableIPv6 bool + }{HTTPSPort: httpsPort, EnableIPv6: enableIPv6}); err != nil { + return fmt.Errorf("render default ssl template: %w", err) + } + + vhostFile := filepath.Join(g.vhostsDir, defaultSSLVhostFile) + if existing, err := os.ReadFile(vhostFile); err == nil && bytes.Equal(existing, buf.Bytes()) { + return nil + } + + if err := os.WriteFile(vhostFile, buf.Bytes(), 0644); err != nil { + return fmt.Errorf("write default ssl vhost: %w", err) + } + return nil +} + // sanitizeDomain converts a domain to a safe filename func sanitizeDomain(domain string) string { return domain // Domains are already safe for filenames @@ -429,8 +471,7 @@ func (c *Controller) Test() error { func (c *Controller) Start() error { switch c.platform.Type { case platform.Darwin: - cmd := exec.Command("brew", "services", "start", "nginx") - return cmd.Run() + return c.startDarwin() case platform.Linux: cmd := exec.Command("sudo", "systemctl", "start", "nginx") return cmd.Run() @@ -442,8 +483,7 @@ func (c *Controller) Start() error { func (c *Controller) Stop() error { switch c.platform.Type { case platform.Darwin: - cmd := exec.Command("brew", "services", "stop", "nginx") - return cmd.Run() + return c.stopDarwin() case platform.Linux: cmd := exec.Command("sudo", "systemctl", "stop", "nginx") return cmd.Run() @@ -455,8 +495,10 @@ func (c *Controller) Stop() error { func (c *Controller) Restart() error { switch c.platform.Type { case platform.Darwin: - cmd := exec.Command("brew", "services", "restart", "nginx") - return cmd.Run() + if err := c.stopDarwin(); err != nil { + return err + } + return c.startDarwin() case platform.Linux: cmd := exec.Command("sudo", "systemctl", "restart", "nginx") return cmd.Run() @@ -464,6 +506,28 @@ func (c *Controller) Restart() error { return fmt.Errorf("unsupported platform") } +// startDarwin starts Homebrew nginx using the MageBox config (bootstrap uses `nginx` directly). +func (c *Controller) startDarwin() error { + if c.IsRunning() { + return c.Reload() + } + cmd := exec.Command("nginx") + if err := cmd.Run(); err == nil { + return nil + } + return exec.Command("brew", "services", "start", "nginx").Run() +} + +// stopDarwin stops the MageBox nginx master (brew services alone does not stop a direct `nginx` process). +func (c *Controller) stopDarwin() error { + stopCmd := exec.Command("nginx", "-s", "stop") + if err := stopCmd.Run(); err == nil { + return nil + } + // Fallback for installs managed only via brew services. + return exec.Command("brew", "services", "stop", "nginx").Run() +} + // IsRunning checks if Nginx is running func (c *Controller) IsRunning() bool { cmd := exec.Command("pgrep", "nginx") @@ -490,6 +554,22 @@ func (c *Controller) SetupNginxConfig() error { fmt.Printf(" [warn] Could not set server_names_hash_bucket_size: %v\n", err) } + sslMgr := ssl.NewManager(c.platform) + vhostGen := NewVhostGenerator(c.platform, sslMgr) + if err := os.MkdirAll(vhostGen.vhostsDir, 0755); err != nil { + return fmt.Errorf("failed to create vhosts directory: %w", err) + } + + httpsPort := 443 + enableIPv6 := true + if c.platform.Type == platform.Darwin { + httpsPort = 8443 + enableIPv6 = false + } + if err := vhostGen.ensureDefaultSSLCatchAll(httpsPort, enableIPv6); err != nil { + return err + } + switch c.platform.Type { case platform.Darwin: // macOS: add explicit include to nginx.conf diff --git a/internal/nginx/vhost_test.go b/internal/nginx/vhost_test.go index 886c9f0..db9c230 100644 --- a/internal/nginx/vhost_test.go +++ b/internal/nginx/vhost_test.go @@ -45,6 +45,37 @@ func TestVhostGenerator_VhostsDir(t *testing.T) { } } +func TestEnsureDefaultSSLCatchAll(t *testing.T) { + g, _ := setupTestGenerator(t) + + if err := g.ensureDefaultSSLCatchAll(443, true); err != nil { + t.Fatalf("ensureDefaultSSLCatchAll failed: %v", err) + } + + vhostFile := filepath.Join(g.vhostsDir, defaultSSLVhostFile) + content, err := os.ReadFile(vhostFile) + if err != nil { + t.Fatalf("default ssl vhost not written: %v", err) + } + + contentStr := string(content) + for _, want := range []string{ + "listen 443 ssl default_server", + "listen [::]:443 ssl default_server", + "ssl_reject_handshake on", + "server_name _", + } { + if !strings.Contains(contentStr, want) { + t.Errorf("default ssl vhost should contain %q", want) + } + } + + // Idempotent: second call should not error + if err := g.ensureDefaultSSLCatchAll(443, true); err != nil { + t.Fatalf("second ensureDefaultSSLCatchAll failed: %v", err) + } +} + func TestVhostGenerator_Generate(t *testing.T) { g, tmpDir := setupTestGenerator(t) @@ -62,6 +93,11 @@ func TestVhostGenerator_Generate(t *testing.T) { t.Fatalf("Generate failed: %v", err) } + // Default SSL catch-all is created on every Generate + if _, err := os.Stat(filepath.Join(g.vhostsDir, defaultSSLVhostFile)); os.IsNotExist(err) { + t.Error("Default SSL vhost should have been created") + } + // Check that vhost file was created vhostFile := filepath.Join(g.vhostsDir, "mystore-mystore.test.conf") if _, err := os.Stat(vhostFile); os.IsNotExist(err) { diff --git a/scripts/valet/README.md b/scripts/valet/README.md new file mode 100644 index 0000000..8f31d54 --- /dev/null +++ b/scripts/valet/README.md @@ -0,0 +1,186 @@ +# Valet → MageBox migration scripts + +Helper scripts to move databases from **Homebrew MySQL** (Valet-era local MySQL) to **MageBox MySQL** (Docker), and optionally point parked Valet projects at MageBox. + +Brew MySQL runs on port **3307** by default so MageBox can keep using **3306** / **33080**. + +## Quick start + +```bash +bash ./scripts/valet/setup.sh +``` + +This will: + +1. Ask for **Homebrew MySQL** credentials (user/password, port 3307) +2. Ask for **MageBox MySQL** credentials (host/port/user/password, default port 33080) +3. Save them to `~/.config/magebox/valet-to-magebox.env` (mode `600`) +4. Install `valet-to-magebox` to `~/bin/` +5. Optionally patch all Magento / Laravel / WordPress projects in your Valet paths + +Uninstall after migration: + +```bash +bash ./scripts/valet/setup.sh --uninstall +``` + +Patch project configs later (uses saved credentials): + +```bash +valet-to-magebox --update-projects +valet-to-magebox --update-projects --dry-run +valet-to-magebox --start-projects +valet-to-magebox --update-projects --start-projects +``` + +## Files + +| File | Purpose | +|------|---------| +| `setup.sh` | Install to `~/bin`, save credentials, uninstall | +| `valet-to-magebox.sh` | Databases + `--update-projects` (source in repo) | +| `lib/common.sh` | Credentials and Valet path discovery | +| `lib/patch-projects.sh` | Update `env.php`, `.env`, `wp-config.php` | +| `lib/start-projects.sh` | Run `magebox start` for projects with `.magebox.yaml` | +## Requirements + +- Homebrew MySQL 8.0 (`/opt/homebrew/opt/mysql@8.0/bin`) +- MageBox MySQL running (default: `127.0.0.1:33080`, user `root`, password `magebox`) +- Bash +- `php` (to patch Magento `env.php` and WordPress `wp-config.php`) +- `python3` (recommended for Laravel `.env` updates) + +## Credentials + +Saved by `setup.sh` and loaded automatically by `valet-to-magebox`: + +| Variable | Default | Description | +|----------|---------|-------------| +| `BREW_MYSQL_PORT` | `3307` | Brew MySQL port | +| `BREW_MYSQL_USER` | `root` | Brew MySQL user | +| `BREW_MYSQL_PASSWORD` | *(empty)* | Brew MySQL password | +| `MAGEBOX_MYSQL_HOST` | `127.0.0.1` | MageBox MySQL host | +| `MAGEBOX_MYSQL_PORT` | `33080` | MageBox MySQL port | +| `MAGEBOX_MYSQL_USER` | `root` | MageBox MySQL user | +| `MAGEBOX_MYSQL_PASSWORD` | `magebox` | MageBox MySQL password | + +Override the file path with `VALET_TO_MAGEBOX_CONFIG`. + +## Database tool + +From anywhere (after install): + +```bash +valet-to-magebox --help +valet-to-magebox --list +valet-to-magebox --move='old_shop_*' +``` + +From the repo without installing: + +```bash +./scripts/valet/valet-to-magebox.sh --list +``` + +### List databases + +```bash +valet-to-magebox --list +valet-to-magebox --list='acme_shop_2025*' +``` + +### Delete databases + +```bash +valet-to-magebox --delete=acme_shop_20250320 +valet-to-magebox --delete='acme_shop_2025*' --force +``` + +### Move databases to MageBox + +```bash +valet-to-magebox --move=acme_shop_20250320 +valet-to-magebox --move='acme_shop_2025*' +valet-to-magebox --move=acme_shop_20250320 --keep-brew +``` + +## Project credential patching + +`valet-to-magebox --update-projects` scans: + +- `~/.config/valet/Sites` (linked sites) +- Paths from `~/.config/valet/config.json` (parked directories, one level deep) +- Output of `valet paths` when available + +| Stack | File updated | Backup (before first patch) | +|-------|----------------|-----------------------------| +| Magento 2 | `app/etc/env.php` | `app/etc/env.php.valet` | +| Laravel | `.env` | `.env.valet` | +| WordPress | `wp-config.php` | `wp-config.php.valet` | + +**Database:** host, user, password → MageBox (port 33080). Database names are unchanged. + +**`.magebox.yaml`** (Magento + Laravel only, skipped if already present): +- Generated from Valet site name, `composer.json` PHP version, and `~/.magebox/config.yaml` defaults +- Domain `https://.test`, `root: pub` (Magento) or `public` (Laravel), `ssl: true`, MySQL/Redis/Mailpit services +- Magento search: OpenSearch `2.19` with `memory: 2g` by default, or `elasticsearch: "8.11"` when `env.php` uses an Elasticsearch engine (or global defaults prefer Elasticsearch) + +**URLs (https):** +- Magento — `unsecure` → `http://…/`, `secure` → `https://…/`, enables `use_in_frontend` / `use_in_adminhtml` in `env.php` + database +- Laravel — `APP_URL` (from existing value or `https://.test`) +- WordPress — `WP_HOME` and `WP_SITEURL` + +Site hostname comes from `.magebox.yaml` (`domains[].host`), else the Valet Sites link name, else the project folder name + `.test`. + +The original file is copied to `*.valet` once; re-running `--update-projects` keeps that backup and only updates the live file. Restore with `cp app/etc/env.php.valet app/etc/env.php` (etc.). Review each project after patching. + +## Typical workflow + +1. `magebox global start` (MageBox MySQL on 33080) +2. `bash ./scripts/valet/setup.sh` +3. `valet-to-magebox --list` then `--move=…` for each database +4. `valet-to-magebox --update-projects` (or `--dry-run` first) +5. `valet-to-magebox --start-projects` (or `--update-projects --start-projects`) — registers nginx vhosts + SSL per site +6. `bash ./scripts/valet/setup.sh --uninstall` when Brew MySQL is no longer needed + +## Safety notes + +- System databases (`mysql`, `sys`, …) are never listed, deleted, or moved. +- Delete and move actions ask for confirmation unless `--force` is used. +- If Brew MySQL is not running, the tool can start it temporarily on port `3307`. + +## Troubleshooting + +**`ERR_CERT_COMMON_NAME_INVALID` / wrong certificate in Chrome** +This usually affects **all old Valet projects** after `--update-projects`, except sites you already ran `magebox start` on. + +`--update-projects` patches DB/URLs and can create `.magebox.yaml`, but **does not** register the site in MageBox nginx. Without a vhost under `~/.magebox/nginx/vhosts/`, HTTPS falls back to the **first** SSL `server` block nginx loaded (wrong hostname in the certificate). + +Fix for every migrated project: + +```bash +valet-to-magebox --start-projects +# or per project: +cd /path/to/project && magebox start +``` + +Then reload nginx if needed: + +```bash +magebox global stop && magebox global start +# or: nginx -s reload +``` + +Ensure mkcert trust is installed: `mkcert -install` + +**`~/bin` not in PATH** +Add `export PATH="${HOME}/bin:${PATH}"` to `~/.zshrc`. + +**MageBox connection failed** +Run `magebox global start`. + +**No projects found for `--update-projects`** +Ensure Valet paths exist or run `valet link` / `valet park` as before. + +**`command not found: =--delete=...`** +Use `--delete=name`, not `=--delete=name`. diff --git a/scripts/valet/dry-run-test.sh b/scripts/valet/dry-run-test.sh new file mode 100755 index 0000000..f3e102a --- /dev/null +++ b/scripts/valet/dry-run-test.sh @@ -0,0 +1,198 @@ +#!/usr/bin/env bash +# +# End-to-end dry-run test: fixture projects + optional real Valet discovery. +# Part of MageBox — https://github.com/qoliber/magebox +# +# Exit codes: +# 0 — all checks passed +# 1 — one or more assertions failed +# +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FIXTURE_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/valet-tm-dry-run.XXXXXX")" + +cleanup() { rm -rf "${FIXTURE_ROOT}"; } +trap cleanup EXIT + +fail=0 + +assert() { + local desc="$1" + shift + if "$@"; then + echo " PASS: ${desc}" + else + echo " FAIL: ${desc}" + fail=1 + fi +} + +# --- Fixture setup --- + +setup_fixtures() { + local magento="${FIXTURE_ROOT}/magento-shop" + local laravel="${FIXTURE_ROOT}/laravel-app" + local wordpress="${FIXTURE_ROOT}/wp-blog" + + mkdir -p "${magento}/app/etc" "${magento}/bin" + touch "${magento}/bin/magento" + cat >"${magento}/app/etc/env.php" <<'PHP' + [ + 'connection' => [ + 'default' => [ + 'host' => '127.0.0.1:3306', + 'dbname' => 'magento_shop', + 'username' => 'root', + 'password' => 'valet-secret', + ], + ], + ], + 'system' => [ + 'default' => [ + 'web' => [ + 'unsecure' => ['base_url' => 'http://magento-shop.test/'], + 'secure' => ['base_url' => 'http://magento-shop.test/'], + ], + ], + ], +]; +PHP + + mkdir -p "${laravel}" + touch "${laravel}/artisan" + cat >"${laravel}/.env" <<'ENV' +APP_NAME=Laravel +APP_URL=http://laravel-app.test +DB_CONNECTION=mysql +DB_HOST=127.0.0.1 +DB_PORT=3306 +DB_DATABASE=laravel_app +DB_USERNAME=root +DB_PASSWORD=valet-secret +ENV + + mkdir -p "${wordpress}" + cat >"${wordpress}/wp-config.php" <<'PHP' +"${fixture_dry_out}" 2>&1 +cat "${fixture_dry_out}" + +assert ".magebox.yaml dry-run line present" grep -q 'would create .magebox.yaml' "${fixture_dry_out}" +assert "Magento dry-run shows https URL" grep -q 'secure=https://magento-shop.test/' "${fixture_dry_out}" +assert "Laravel dry-run shows https URL" grep -q 'APP_URL=https://laravel-app.test' "${fixture_dry_out}" +assert "WordPress dry-run shows https URL" grep -q 'https://wp-blog.test' "${fixture_dry_out}" +rm -f "${fixture_dry_out}" + +# --- Test 2: Dry-run does not mutate fixtures --- + +echo "" +echo "=== 2. Verify fixtures unchanged ===" + +check_unchanged() { + local file="$1" + local backup="${file}.valet" + assert "no backup created for $(basename "${file}")" test ! -f "${backup}" + assert "$(basename "${file}") still has Valet-era password" grep -q 'valet-secret' "${file}" +} + +check_unchanged "${FIXTURE_ROOT}/magento-shop/app/etc/env.php" +check_unchanged "${FIXTURE_ROOT}/laravel-app/.env" +check_unchanged "${FIXTURE_ROOT}/wp-blog/wp-config.php" + +[[ "${fail}" -ne 0 ]] && { echo ""; echo "ABORT: dry-run mutated fixtures."; exit 1; } + +# --- Test 3: Live patch produces correct output --- + +echo "" +echo "=== 3. Live URL patch (fixture) ===" + +CFG="$(mktemp)" +cat >"${CFG}" <<'EOF' +MAGEBOX_MYSQL_HOST=127.0.0.1 +MAGEBOX_MYSQL_PORT=33080 +MAGEBOX_MYSQL_USER=root +MAGEBOX_MYSQL_PASSWORD=magebox +EOF +export VALET_TO_MAGEBOX_CONFIG="${CFG}" +export VALET_TM_PROJECT_ROOTS="${ROOTS}" +unset VALET_TM_DRY_RUN +"${SCRIPT_DIR}/valet-to-magebox.sh" --update-projects >/dev/null 2>&1 + +assert "Magento base_url is https" grep -q "https://magento-shop.test/" "${FIXTURE_ROOT}/magento-shop/app/etc/env.php" +assert "Magento DB host is MageBox" grep -q "127.0.0.1:33080" "${FIXTURE_ROOT}/magento-shop/app/etc/env.php" +assert "Laravel APP_URL is https" grep -q 'APP_URL=https://laravel-app.test' "${FIXTURE_ROOT}/laravel-app/.env" +assert "Laravel DB_PORT is 33080" grep -q 'DB_PORT=33080' "${FIXTURE_ROOT}/laravel-app/.env" +assert "WordPress WP_HOME is https" grep -q "define( 'WP_HOME', 'https://wp-blog.test' )" "${FIXTURE_ROOT}/wp-blog/wp-config.php" +assert "WordPress DB_HOST is MageBox" grep -q "define( 'DB_HOST', '127.0.0.1:33080' )" "${FIXTURE_ROOT}/wp-blog/wp-config.php" + +assert ".magebox.yaml created for Magento" test -f "${FIXTURE_ROOT}/magento-shop/.magebox.yaml" +assert "Magento yaml has correct host" grep -q 'host: magento-shop.test' "${FIXTURE_ROOT}/magento-shop/.magebox.yaml" +assert "Magento yaml has OpenSearch" grep -q 'opensearch:' "${FIXTURE_ROOT}/magento-shop/.magebox.yaml" +assert "Magento yaml has OpenSearch version" grep -q 'version: "2.19"' "${FIXTURE_ROOT}/magento-shop/.magebox.yaml" +assert "Magento yaml has OpenSearch memory" grep -q 'memory: "2g"' "${FIXTURE_ROOT}/magento-shop/.magebox.yaml" +assert ".magebox.yaml created for Laravel" test -f "${FIXTURE_ROOT}/laravel-app/.magebox.yaml" +assert "Laravel yaml has type: laravel" grep -q 'type: laravel' "${FIXTURE_ROOT}/laravel-app/.magebox.yaml" +assert "WordPress has no .magebox.yaml" test ! -f "${FIXTURE_ROOT}/wp-blog/.magebox.yaml" + +assert "Magento backup created" test -f "${FIXTURE_ROOT}/magento-shop/app/etc/env.php.valet" +assert "Laravel backup created" test -f "${FIXTURE_ROOT}/laravel-app/.env.valet" +assert "WordPress backup created" test -f "${FIXTURE_ROOT}/wp-blog/wp-config.php.valet" + +rm -f "${CFG}" +[[ "${fail}" -ne 0 ]] && { echo ""; echo "ABORT: live patch assertions failed."; exit 1; } + +echo "" +echo "=== 4. Real Valet paths dry-run (if any) ===" +echo "" + +unset VALET_TM_PROJECT_ROOTS +"${SCRIPT_DIR}/valet-to-magebox.sh" --update-projects --dry-run || true + +echo "" +echo "=== 5. --start-projects dry-run ===" +"${SCRIPT_DIR}/valet-to-magebox.sh" --start-projects --dry-run 2>&1 | head -15 || true + +echo "" +echo "=== 6. --list (Brew MySQL connectivity) ===" +echo "" + +if "${SCRIPT_DIR}/valet-to-magebox.sh" --list 2>&1; then + echo "OK: Brew MySQL list succeeded" +else + echo "Note: --list failed (Brew MySQL may be stopped — expected before setup.sh)" +fi + +echo "" +if [[ "${fail}" -ne 0 ]]; then + echo "RESULT: Some checks FAILED (see above)." + exit 1 +fi +echo "RESULT: All checks passed." diff --git a/scripts/valet/lib/common.sh b/scripts/valet/lib/common.sh new file mode 100755 index 0000000..7f91d65 --- /dev/null +++ b/scripts/valet/lib/common.sh @@ -0,0 +1,296 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2034 +# +# Shared helpers for Valet → MageBox migration scripts. +# Part of MageBox — https://github.com/qoliber/magebox +# +# This file is sourced by valet-to-magebox.sh and setup.sh. +# It must not be executed directly. + +set -euo pipefail + +readonly VALET_TO_MAGEBOX_CONFIG="${VALET_TO_MAGEBOX_CONFIG:-${HOME}/.config/magebox/valet-to-magebox.env}" +readonly VALET_TO_MAGEBOX_BIN_NAME="valet-to-magebox" +readonly VALET_TO_MAGEBOX_BIN="${HOME}/bin/${VALET_TO_MAGEBOX_BIN_NAME}" +export VALET_TM_DRY_RUN="${VALET_TM_DRY_RUN:-0}" + +# --- Utilities --- + +valet_tm_die() { + printf 'Error: %s\n' "$*" >&2 + exit 1 +} + +valet_tm_shell_quote() { + local value="$1" + printf "'%s'" "${value//\'/\'\\\'\'}" +} + +# --- Config file management --- + +valet_tm_load_config() { + if [[ -f "${VALET_TO_MAGEBOX_CONFIG}" ]]; then + # shellcheck disable=SC1090 + source "${VALET_TO_MAGEBOX_CONFIG}" + fi +} + +valet_tm_save_config() { + local brew_user="$1" + local brew_pass="$2" + local brew_port="${3:-3307}" + local mb_host="${4:-127.0.0.1}" + local mb_port="${5:-33080}" + local mb_user="${6:-root}" + local mb_pass="${7:-magebox}" + + mkdir -p "$(dirname "${VALET_TO_MAGEBOX_CONFIG}")" + cat >"${VALET_TO_MAGEBOX_CONFIG}" </dev/null && pwd -P)" || continue + add_root "${target}" + elif [[ -d "${link}" ]]; then + add_root "${link}" + fi + done + fi + + local config="${HOME}/.config/valet/config.json" + if [[ -f "${config}" ]]; then + local park_path + if command -v jq >/dev/null 2>&1; then + while IFS= read -r park_path; do + [[ -d "${park_path}" ]] || continue + for child in "${park_path}"/*; do + [[ -d "${child}" ]] || continue + add_root "${child}" + done + done < <(jq -r '.paths[]?' "${config}" 2>/dev/null) + elif command -v python3 >/dev/null 2>&1; then + while IFS= read -r park_path; do + [[ -d "${park_path}" ]] || continue + for child in "${park_path}"/*; do + [[ -d "${child}" ]] || continue + add_root "${child}" + done + done < <(python3 -c 'import json,sys;p=json.load(open(sys.argv[1]));print("\n".join(p.get("paths",[])))' "${config}" 2>/dev/null) + fi + fi + + if command -v valet >/dev/null 2>&1; then + while IFS= read -r path; do + [[ -d "${path}" ]] || continue + for child in "${path}"/*; do + [[ -d "${child}" ]] || continue + add_root "${child}" + done + done < <(valet paths 2>/dev/null || true) + fi +} + +# --- Project type detection --- + +valet_tm_detect_project_type() { + local root="$1" + if [[ -f "${root}/bin/magento" && -f "${root}/app/etc/env.php" ]]; then + echo "magento" + elif [[ -f "${root}/artisan" && -f "${root}/.env" ]]; then + echo "laravel" + elif [[ -f "${root}/wp-config.php" ]]; then + echo "wordpress" + fi +} + +# --- URL helpers --- + +# Valet site folder name (Sites symlink) or project directory basename. +valet_tm_valet_site_name() { + local root="$1" + local link target name + root="$(cd "${root}" && pwd -P)" + local sites_dir="${HOME}/.config/valet/Sites" + + if [[ -d "${sites_dir}" ]]; then + for link in "${sites_dir}"/*; do + [[ -L "${link}" ]] || continue + target="$(cd "$(dirname "${link}")" && cd "$(readlink "${link}")" 2>/dev/null && pwd -P)" || continue + if [[ "${target}" == "${root}" ]]; then + name="$(basename "${link}")" + printf '%s' "${name}" + return 0 + fi + done + fi + + basename "${root}" +} + +# https://host/ with trailing slash (Magento-style). +valet_tm_project_base_url() { + local root="$1" + local host="" magebox_yaml="${root}/.magebox.yaml" + + if [[ -f "${magebox_yaml}" ]]; then + host="$(grep -E '^[[:space:]]*-[[:space:]]*host:[[:space:]]*' "${magebox_yaml}" 2>/dev/null \ + | head -1 | sed -E 's/^[[:space:]]*-[[:space:]]*host:[[:space:]]*//' | tr -d "\"'")" + if [[ -z "${host}" ]]; then + host="$(grep -E '^[[:space:]]*host:[[:space:]]*' "${magebox_yaml}" 2>/dev/null \ + | head -1 | sed -E 's/^[[:space:]]*host:[[:space:]]*//' | tr -d "\"'")" + fi + fi + + if [[ -z "${host}" ]]; then + host="$(valet_tm_valet_site_name "${root}").test" + fi + + valet_tm_secure_url "https://${host}/" "trailing_slash" +} + +# https://host without trailing slash (Laravel / WordPress). +valet_tm_project_app_url() { + local url + url="$(valet_tm_project_base_url "$1")" + printf '%s' "${url%/}" +} + +# Upgrade http→https; optional trailing slash for Magento base URLs. +valet_tm_secure_url() { + local input="$1" + local mode="${2:-no_trailing_slash}" + + [[ -n "${input}" ]] || return 0 + + if command -v python3 >/dev/null 2>&1; then + python3 - "${input}" "${mode}" <<'PY' +import sys +from urllib.parse import urlparse, urlunparse + +raw = sys.argv[1].strip() +mode = sys.argv[2] +if not raw: + sys.exit(0) +if "://" not in raw: + raw = "https://" + raw.lstrip("/") +parsed = urlparse(raw) +host = parsed.netloc or parsed.path.split("/")[0] +path = parsed.path if parsed.netloc else "/" + "/".join(parsed.path.split("/")[1:]) +if mode == "trailing_slash": + if not path.endswith("/"): + path = path.rstrip("/") + "/" + if path == "": + path = "/" +else: + path = path.rstrip("/") or "" +out = urlunparse(("https", host, path or ("/" if mode == "trailing_slash" else ""), "", "", "")) +if mode != "trailing_slash" and out.endswith("/") and path == "": + out = out.rstrip("/") +print(out) +PY + return 0 + fi + + local url="${input}" + url="${url/http:\/\//https://}" + [[ "${url}" == https://* ]] || url="https://${url}" + if [[ "${mode}" == "trailing_slash" ]]; then + [[ "${url}" == */ ]] || url="${url}/" + else + url="${url%/}" + fi + printf '%s' "${url}" +} diff --git a/scripts/valet/lib/generate-magebox-yaml.sh b/scripts/valet/lib/generate-magebox-yaml.sh new file mode 100755 index 0000000..3c0dc40 --- /dev/null +++ b/scripts/valet/lib/generate-magebox-yaml.sh @@ -0,0 +1,281 @@ +#!/usr/bin/env bash +# +# Generate .magebox.yaml for Valet parked projects (when missing). +# Part of MageBox — https://github.com/qoliber/magebox +# +# This file is sourced by valet-to-magebox.sh. +# It must not be executed directly. + +# --- Global defaults --- + +valet_tm_read_magebox_global_defaults() { + VALET_TM_DEFAULT_PHP="${VALET_TM_DEFAULT_PHP:-8.3}" + VALET_TM_DEFAULT_MYSQL="${VALET_TM_DEFAULT_MYSQL:-8.0}" + VALET_TM_DEFAULT_TLD="${VALET_TM_DEFAULT_TLD:-test}" + VALET_TM_DEFAULT_REDIS="${VALET_TM_DEFAULT_REDIS:-1}" + VALET_TM_DEFAULT_VALKEY="${VALET_TM_DEFAULT_VALKEY:-0}" + VALET_TM_DEFAULT_OPENSEARCH="${VALET_TM_DEFAULT_OPENSEARCH:-}" + VALET_TM_DEFAULT_ELASTICSEARCH="${VALET_TM_DEFAULT_ELASTICSEARCH:-}" + VALET_TM_DEFAULT_MAILPIT="${VALET_TM_DEFAULT_MAILPIT:-1}" + VALET_TM_OPENSEARCH_VERSION="${VALET_TM_OPENSEARCH_VERSION:-2.19}" + VALET_TM_OPENSEARCH_MEMORY="${VALET_TM_OPENSEARCH_MEMORY:-2g}" + VALET_TM_ELASTICSEARCH_VERSION="${VALET_TM_ELASTICSEARCH_VERSION:-8.11}" + + local global_cfg="${HOME}/.magebox/config.yaml" + [[ -f "${global_cfg}" ]] || return 0 + command -v python3 >/dev/null 2>&1 || return 0 + + # shellcheck disable=SC2046 + eval "$(python3 - "${global_cfg}" <<'PY' +import sys +try: + import yaml +except ImportError: + yaml = None + +path = sys.argv[1] +data = {} +if yaml is not None: + with open(path, encoding="utf-8") as fh: + data = yaml.safe_load(fh) or {} +else: + import re + text = open(path, encoding="utf-8").read() + for key, var in ( + ("default_php", "VALET_TM_DEFAULT_PHP"), + ("tld", "VALET_TM_DEFAULT_TLD"), + ): + m = re.search(rf"^{key}:\s*[\"']?([^\"'\n]+)", text, re.M) + if m: + print(f'{var}="{m.group(1).strip()}"') + m = re.search(r"^\s*mysql:\s*[\"']?([^\"'\n]+)", text, re.M) + if m: + print(f'VALET_TM_DEFAULT_MYSQL="{m.group(1).strip()}"') + if re.search(r"^\s*redis:\s*true", text, re.M): + print('VALET_TM_DEFAULT_REDIS=1') + if re.search(r"^\s*valkey:\s*true", text, re.M): + print('VALET_TM_DEFAULT_VALKEY=1') + if re.search(r"^\s*mailpit:\s*true", text, re.M): + print('VALET_TM_DEFAULT_MAILPIT=1') + sys.exit(0) + +defaults = data.get("default_services") or {} +php = data.get("default_php") or "8.3" +tld = data.get("tld") or "test" +print(f'VALET_TM_DEFAULT_PHP="{php}"') +print(f'VALET_TM_DEFAULT_TLD="{tld}"') +if defaults.get("mysql"): + print(f'VALET_TM_DEFAULT_MYSQL="{defaults["mysql"]}"') +if defaults.get("redis"): + print("VALET_TM_DEFAULT_REDIS=1") +if defaults.get("valkey"): + print("VALET_TM_DEFAULT_VALKEY=1") +if defaults.get("opensearch"): + os_ver = defaults["opensearch"] + if isinstance(os_ver, dict): + if os_ver.get("version"): + print(f'VALET_TM_OPENSEARCH_VERSION="{os_ver["version"]}"') + if os_ver.get("memory"): + print(f'VALET_TM_OPENSEARCH_MEMORY="{os_ver["memory"]}"') + else: + print(f'VALET_TM_DEFAULT_OPENSEARCH="{os_ver}"') + print(f'VALET_TM_OPENSEARCH_VERSION="{os_ver}"') +if defaults.get("elasticsearch"): + es_ver = defaults["elasticsearch"] + if isinstance(es_ver, dict) and es_ver.get("version"): + print(f'VALET_TM_ELASTICSEARCH_VERSION="{es_ver["version"]}"') + else: + print(f'VALET_TM_DEFAULT_ELASTICSEARCH="{es_ver}"') + print(f'VALET_TM_ELASTICSEARCH_VERSION="{es_ver}"') +if defaults.get("mailpit"): + print("VALET_TM_DEFAULT_MAILPIT=1") +PY +)" +} + +# --- Search engine detection --- + +# Detect whether a Magento project uses OpenSearch or Elasticsearch. +# Prints "opensearch" or "elasticsearch" to stdout; empty if undetermined. +valet_tm_detect_magento_search_engine() { + local root="$1" + local env_file="${root}/app/etc/env.php" + + [[ -f "${env_file}" ]] || return 0 + command -v php >/dev/null 2>&1 || return 0 + + php -r ' +$config = @include $argv[1]; +if (!is_array($config)) { exit(0); } +$engine = $config["system"]["default"]["catalog"]["search"]["engine"] ?? ""; +$engine = is_string($engine) ? strtolower($engine) : ""; +if ($engine !== "" && str_contains($engine, "elastic")) { + echo "elasticsearch"; + exit(0); +} +echo "opensearch"; +' "${env_file}" 2>/dev/null || true +} + +# Write the search service YAML block to stdout. +valet_tm_write_search_service_yaml() { + local engine="${1:-opensearch}" + + case "${engine}" in + elasticsearch) + printf ' elasticsearch: "%s"\n' "${VALET_TM_ELASTICSEARCH_VERSION}" + ;; + *) + local version="${VALET_TM_OPENSEARCH_VERSION}" + if [[ -n "${VALET_TM_DEFAULT_OPENSEARCH}" ]]; then + version="${VALET_TM_DEFAULT_OPENSEARCH}" + fi + printf ' opensearch:\n' + printf ' version: "%s"\n' "${version}" + printf ' memory: "%s"\n' "${VALET_TM_OPENSEARCH_MEMORY}" + ;; + esac +} + +# --- PHP version detection --- + +valet_tm_magebox_project_name() { + local root="$1" + local name + name="$(valet_tm_valet_site_name "${root}")" + name="${name//\//.}" + printf '%s' "${name}" +} + +valet_tm_detect_php_version() { + local root="$1" + local composer="${root}/composer.json" + + [[ -f "${composer}" ]] || return 1 + command -v python3 >/dev/null 2>&1 || return 1 + + python3 - "${composer}" <<'PY' +import json, re, sys + +supported = {"8.1", "8.2", "8.3", "8.4"} +path = sys.argv[1] +with open(path, encoding="utf-8") as fh: + data = json.load(fh) + +def major_minor(value: str) -> str: + m = re.search(r"(\d+)\.(\d+)", value or "") + return f"{m.group(1)}.{m.group(2)}" if m else "" + +candidates = [] +platform = (data.get("config") or {}).get("platform") or {} +if platform.get("php"): + candidates.append(platform["php"]) +require = data.get("require") or {} +if require.get("php"): + candidates.append(require["php"]) + +for raw in candidates: + version = major_minor(raw) + if version in supported: + print(version) + sys.exit(0) +sys.exit(1) +PY +} + +# --- YAML generation --- + +valet_tm_build_magebox_yaml() { + local root="$1" + local ptype="$2" + local name host php doc_root + + valet_tm_read_magebox_global_defaults + + name="$(valet_tm_magebox_project_name "${root}")" + host="${name}.${VALET_TM_DEFAULT_TLD}" + + php="$(valet_tm_detect_php_version "${root}" 2>/dev/null || true)" + php="${php:-${VALET_TM_DEFAULT_PHP}}" + + case "${ptype}" in + magento) doc_root="pub" ;; + laravel) doc_root="public" ;; + *) doc_root="." ;; + esac + + { + printf 'name: %s\n' "${name}" + if [[ "${ptype}" == "laravel" ]]; then + printf 'type: laravel\n' + fi + printf 'domains:\n' + printf ' - host: %s\n' "${host}" + printf ' root: %s\n' "${doc_root}" + printf ' ssl: true\n' + printf 'php: "%s"\n' "${php}" + printf 'services:\n' + if [[ -n "${VALET_TM_DEFAULT_MYSQL}" ]]; then + printf ' mysql: "%s"\n' "${VALET_TM_DEFAULT_MYSQL}" + fi + if [[ "${VALET_TM_DEFAULT_VALKEY}" == "1" ]]; then + printf ' valkey: true\n' + elif [[ "${VALET_TM_DEFAULT_REDIS}" == "1" ]]; then + printf ' redis: true\n' + fi + if [[ "${ptype}" == "magento" ]]; then + local search_engine="opensearch" + if [[ -n "${VALET_TM_DEFAULT_ELASTICSEARCH}" && -z "${VALET_TM_DEFAULT_OPENSEARCH}" ]]; then + search_engine="elasticsearch" + else + local detected + detected="$(valet_tm_detect_magento_search_engine "${root}" 2>/dev/null || true)" + [[ -n "${detected}" ]] && search_engine="${detected}" + fi + valet_tm_write_search_service_yaml "${search_engine}" + elif [[ -n "${VALET_TM_DEFAULT_OPENSEARCH}" ]]; then + valet_tm_write_search_service_yaml "opensearch" + elif [[ -n "${VALET_TM_DEFAULT_ELASTICSEARCH}" ]]; then + valet_tm_write_search_service_yaml "elasticsearch" + fi + if [[ "${VALET_TM_DEFAULT_MAILPIT}" == "1" ]]; then + printf ' mailpit: true\n' + fi + } +} + +# --- Entrypoint --- + +valet_tm_ensure_magebox_yaml() { + local root="$1" + local ptype="$2" + local yaml="${root}/.magebox.yaml" + + case "${ptype}" in + magento|laravel) ;; + wordpress) + echo " skip .magebox.yaml: WordPress (MageBox has no WordPress type; patch DB/URLs only)" + return 0 + ;; + *) + return 0 + ;; + esac + + if [[ -f "${yaml}" ]]; then + echo " .magebox.yaml already exists" + return 0 + fi + + local preview host_line + preview="$(valet_tm_build_magebox_yaml "${root}" "${ptype}")" + host_line="$(printf '%s\n' "${preview}" | grep -E '^ - host:' | head -1 | sed 's/.*host:[[:space:]]*//')" + + if [[ "${VALET_TM_DRY_RUN:-0}" == "1" ]]; then + echo " [dry-run] would create .magebox.yaml → host=${host_line}" + return 0 + fi + + valet_tm_build_magebox_yaml "${root}" "${ptype}" >"${yaml}" + echo " created .magebox.yaml (host ${host_line})" +} diff --git a/scripts/valet/lib/patch-projects.sh b/scripts/valet/lib/patch-projects.sh new file mode 100755 index 0000000..853a177 --- /dev/null +++ b/scripts/valet/lib/patch-projects.sh @@ -0,0 +1,515 @@ +#!/usr/bin/env bash +# +# Update database credentials and URLs in Valet parked / linked projects. +# Part of MageBox — https://github.com/qoliber/magebox +# +# This file is sourced by valet-to-magebox.sh. +# It must not be executed directly. + +# --- Backup helper --- + +# Copy the original credentials file to .valet before the first patch. +valet_tm_backup_credentials_file() { + local file="$1" + local backup="${file}.valet" + + [[ -f "${file}" ]] || return 1 + + if [[ "${VALET_TM_DRY_RUN:-0}" == "1" ]]; then + if [[ -f "${backup}" ]]; then + echo " [dry-run] backup exists: ${backup}" + else + echo " [dry-run] would back up: ${file} → ${backup}" + fi + return 0 + fi + + if [[ -f "${backup}" ]]; then + echo " backup already exists: ${backup}" + return 0 + fi + + cp -p "${file}" "${backup}" + echo " backed up: ${backup}" +} + +# --- Magento patching --- + +valet_tm_patch_magento() { + local root="$1" + local env_file="${root}/app/etc/env.php" + local db_host="$2" + local db_user="$3" + local db_pass="$4" + local base_url + base_url="$(valet_tm_project_base_url "${root}")" + + if [[ ! -f "${env_file}" ]]; then + echo " skip: no app/etc/env.php" >&2 + return 1 + fi + + if ! command -v php >/dev/null 2>&1; then + echo " skip: php not found (needed to patch env.php)" >&2 + return 1 + fi + + valet_tm_backup_credentials_file "${env_file}" || return 1 + + if [[ "${VALET_TM_DRY_RUN:-0}" == "1" ]]; then + echo " [dry-run] would set app/etc/env.php → host=${db_host}, user=${db_user}" + echo " [dry-run] would set web URLs: unsecure=http, secure=${base_url}, use_in_frontend=1" + return 0 + fi + + VALET_TM_ENV_FILE="${env_file}" \ + VALET_TM_DB_HOST="${db_host}" \ + VALET_TM_DB_USER="${db_user}" \ + VALET_TM_DB_PASS="${db_pass}" \ + VALET_TM_BASE_URL="${base_url}" \ + php <<'PHP' + &$value) { + if ($key === 'base_url' && is_string($value)) { + $value = valet_secure_magento_url($value, $fallback); + $count++; + } elseif (is_array($value)) { + $count += valet_patch_magento_urls($value, $fallback); + } + } + unset($value); + return $count; +} + +function valet_apply_magento_ssl_settings(array &$config, string $secureBase): void +{ + $parsed = parse_url($secureBase); + $host = $parsed['host'] ?? ''; + if ($host === '') { + return; + } + $httpsBase = 'https://' . $host . '/'; + $httpBase = 'http://' . $host . '/'; + + if (!isset($config['system']) || !is_array($config['system'])) { + $config['system'] = []; + } + if (!isset($config['system']['default']) || !is_array($config['system']['default'])) { + $config['system']['default'] = []; + } + if (!isset($config['system']['default']['web']) || !is_array($config['system']['default']['web'])) { + $config['system']['default']['web'] = []; + } + + $web = &$config['system']['default']['web']; + if (!isset($web['unsecure']) || !is_array($web['unsecure'])) { + $web['unsecure'] = []; + } + if (!isset($web['secure']) || !is_array($web['secure'])) { + $web['secure'] = []; + } + + $web['unsecure']['base_url'] = $httpBase; + $web['secure']['base_url'] = $httpsBase; + $web['secure']['use_in_frontend'] = '1'; + $web['secure']['use_in_adminhtml'] = '1'; + $web['secure']['enable_upgrade_insecure'] = '1'; + unset($web); + + if (!isset($config['system']['stores']) || !is_array($config['system']['stores'])) { + return; + } + + foreach ($config['system']['stores'] as &$store) { + if (!is_array($store) || !isset($store['web']) || !is_array($store['web'])) { + continue; + } + $storeHost = $host; + if (isset($store['web']['secure']['base_url']) && is_string($store['web']['secure']['base_url'])) { + $storeParsed = parse_url(valet_secure_magento_url($store['web']['secure']['base_url'], $httpsBase)); + if (!empty($storeParsed['host'])) { + $storeHost = $storeParsed['host']; + } + } + $storeHttps = 'https://' . $storeHost . '/'; + $storeHttp = 'http://' . $storeHost . '/'; + if (!isset($store['web']['unsecure']) || !is_array($store['web']['unsecure'])) { + $store['web']['unsecure'] = []; + } + if (!isset($store['web']['secure']) || !is_array($store['web']['secure'])) { + $store['web']['secure'] = []; + } + $store['web']['unsecure']['base_url'] = $storeHttp; + $store['web']['secure']['base_url'] = $storeHttps; + $store['web']['secure']['use_in_frontend'] = '1'; + $store['web']['secure']['use_in_adminhtml'] = '1'; + } + unset($store); +} + +$urlCount = valet_patch_magento_urls($config, $fallbackBase); +valet_apply_magento_ssl_settings($config, $fallbackBase); + +$export = var_export($config, true); +file_put_contents($path, "/dev/null 2>&1; then + echo " note: mysql CLI not found — run bin/magento cache:flush after patch" + return 0 + fi + + local -a mysql_cmd=(mysql "-h${MAGEBOX_MYSQL_HOST:-127.0.0.1}" "-P${MAGEBOX_MYSQL_PORT:-33080}" "-u${MAGEBOX_MYSQL_USER:-root}") + if [[ -n "${MAGEBOX_MYSQL_PASSWORD:-}" ]]; then + mysql_cmd+=("-p${MAGEBOX_MYSQL_PASSWORD}") + fi + + if ! "${mysql_cmd[@]}" -N -e "USE \`${dbname//\`/\\\`}\`; SELECT 1" >/dev/null 2>&1; then + echo " note: database '${dbname}' not found — skipped DB URL sync" + return 0 + fi + + local sql + sql="$(cat </dev/null 2>&1; then + echo " synced SSL URL settings in database '${dbname}'" + else + echo " note: could not sync database (run bin/magento cache:flush)" + fi +} + +# --- Laravel patching --- + +valet_tm_upsert_dotenv_key() { + local file="$1" + local key="$2" + local value="$3" + + [[ -f "${file}" ]] || return 1 + + if command -v python3 >/dev/null 2>&1; then + python3 - "${file}" "${key}" "${value}" <<'PY' +import re, sys + +path, key, value = sys.argv[1], sys.argv[2], sys.argv[3] +with open(path, encoding="utf-8") as fh: + lines = fh.readlines() + +pattern = re.compile(rf"^{re.escape(key)}=") +out = [] +found = False +for line in lines: + if pattern.match(line): + out.append(f"{key}={value}\n") + found = True + else: + out.append(line) +if not found: + if out and not out[-1].endswith("\n"): + out[-1] += "\n" + out.append(f"{key}={value}\n") +with open(path, "w", encoding="utf-8") as fh: + fh.writelines(out) +PY + return 0 + fi + + if grep -q "^${key}=" "${file}" 2>/dev/null; then + local tmp + tmp="$(mktemp)" + while IFS= read -r line || [[ -n "${line}" ]]; do + if [[ "${line}" == "${key}="* ]]; then + printf '%s=%s\n' "${key}" "${value}" + else + printf '%s\n' "${line}" + fi + done <"${file}" >"${tmp}" + mv "${tmp}" "${file}" + else + printf '%s=%s\n' "${key}" "${value}" >>"${file}" + fi +} + +valet_tm_patch_laravel() { + local root="$1" + local env_file="${root}/.env" + local db_host="$2" + local db_port="$3" + local db_user="$4" + local db_pass="$5" + local app_url + app_url="$(valet_tm_project_app_url "${root}")" + + [[ -f "${env_file}" ]] || { + echo " skip: no .env" >&2 + return 1 + } + + valet_tm_backup_credentials_file "${env_file}" || return 1 + + if [[ "${VALET_TM_DRY_RUN:-0}" == "1" ]]; then + echo " [dry-run] would set .env → DB_HOST=${db_host}, DB_PORT=${db_port}, DB_USERNAME=${db_user}" + echo " [dry-run] would set APP_URL=${app_url} (https)" + return 0 + fi + + valet_tm_upsert_dotenv_key "${env_file}" "DB_HOST" "${db_host}" + valet_tm_upsert_dotenv_key "${env_file}" "DB_PORT" "${db_port}" + valet_tm_upsert_dotenv_key "${env_file}" "DB_USERNAME" "${db_user}" + valet_tm_upsert_dotenv_key "${env_file}" "DB_PASSWORD" "${db_pass}" + + if grep -q '^APP_URL=' "${env_file}" 2>/dev/null; then + local current + current="$(grep -E '^APP_URL=' "${env_file}" | head -1 | cut -d= -f2- | tr -d '"'"'")" + app_url="$(valet_tm_secure_url "${current}")" + fi + valet_tm_upsert_dotenv_key "${env_file}" "APP_URL" "${app_url}" + echo " updated .env (DB_HOST=${db_host}, APP_URL=${app_url})" +} + +# --- WordPress patching --- + +valet_tm_patch_wordpress() { + local root="$1" + local config_file="${root}/wp-config.php" + local db_host="$2" + local db_user="$3" + local db_pass="$4" + local site_url + site_url="$(valet_tm_project_app_url "${root}")" + + [[ -f "${config_file}" ]] || { + echo " skip: no wp-config.php" >&2 + return 1 + } + + if ! command -v php >/dev/null 2>&1; then + echo " skip: php not found (needed to patch wp-config.php)" >&2 + return 1 + fi + + valet_tm_backup_credentials_file "${config_file}" || return 1 + + if [[ "${VALET_TM_DRY_RUN:-0}" == "1" ]]; then + echo " [dry-run] would set wp-config.php → DB_HOST=${db_host}, DB_USER=${db_user}" + echo " [dry-run] would set WP_HOME/WP_SITEURL → ${site_url} (https)" + return 0 + fi + + VALET_TM_WP_CONFIG="${config_file}" \ + VALET_TM_WP_HOST="${db_host}" \ + VALET_TM_WP_USER="${db_user}" \ + VALET_TM_WP_PASS="${db_pass}" \ + VALET_TM_WP_SITE_URL="${site_url}" \ + php <<'PHP' + 0) { + $content = $new; + $replaced = true; + break; + } + } + if (!$replaced) { + $content = preg_replace( + '/(<\?php)/', + "$1\ndefine( '" . $name . "', '" . $escaped . "' );", + $content, + 1 + ); + } + return $content; +} + +$dbReplacements = [ + 'DB_HOST' => $host, + 'DB_USER' => $user, + 'DB_PASSWORD' => $pass, +]; +foreach ($dbReplacements as $name => $value) { + $content = valet_replace_define($content, $name, $value); +} + +foreach (['WP_HOME', 'WP_SITEURL'] as $name) { + if (preg_match("/define\\(\\s*['\"]" . preg_quote($name, '/') . "['\"]/", $content)) { + if (preg_match( + "/define\\(\\s*['\"]" . preg_quote($name, '/') . "['\"]\\s*,\\s*['\"]([^'\"]*)['\"]/", + $content, + $m + )) { + $content = valet_replace_define($content, $name, valet_secure_wp_url($m[1], $siteUrl)); + } + } else { + $content = valet_replace_define($content, $name, $siteUrl); + } +} + +file_put_contents($path, $content); +PHP + echo " updated wp-config.php (DB_HOST=${db_host}, WP_HOME=${site_url})" +} + +# --- Main orchestrator --- + +valet_tm_patch_all_projects() { + valet_tm_load_config + + local mb_host="${MAGEBOX_MYSQL_HOST:-127.0.0.1}" + local mb_port="${MAGEBOX_MYSQL_PORT:-33080}" + local mb_user="${MAGEBOX_MYSQL_USER:-root}" + local mb_pass="${MAGEBOX_MYSQL_PASSWORD:-magebox}" + local combined_host="${mb_host}:${mb_port}" + + local roots + roots="$(valet_tm_discover_project_roots)" + if [[ -z "${roots}" ]]; then + echo "No Valet project roots found (check ~/.config/valet/Sites and valet paths)." + return 0 + fi + + local root ptype patched=0 skipped=0 + if [[ "${VALET_TM_DRY_RUN:-0}" == "1" ]]; then + echo "[dry-run] Would patch DB + URLs (https) and add .magebox.yaml where missing → MageBox (${combined_host}, user ${mb_user})" + else + echo "Patching DB + URLs (https) and adding .magebox.yaml where missing → MageBox (${combined_host}, user ${mb_user})" + fi + echo "" + + while IFS= read -r root; do + [[ -n "${root}" ]] || continue + ptype="$(valet_tm_detect_project_type "${root}")" + [[ -n "${ptype}" ]] || continue + + echo "${root} (${ptype})" + valet_tm_ensure_magebox_yaml "${root}" "${ptype}" + + case "${ptype}" in + magento) + if valet_tm_patch_magento "${root}" "${combined_host}" "${mb_user}" "${mb_pass}"; then + patched=$((patched + 1)) + else + skipped=$((skipped + 1)) + fi + ;; + laravel) + if valet_tm_patch_laravel "${root}" "${mb_host}" "${mb_port}" "${mb_user}" "${mb_pass}"; then + patched=$((patched + 1)) + else + skipped=$((skipped + 1)) + fi + ;; + wordpress) + if valet_tm_patch_wordpress "${root}" "${combined_host}" "${mb_user}" "${mb_pass}"; then + patched=$((patched + 1)) + else + skipped=$((skipped + 1)) + fi + ;; + esac + echo "" + done <<< "${roots}" + + if [[ "${VALET_TM_DRY_RUN:-0}" == "1" ]]; then + echo "Done: ${patched} project(s) would be updated, ${skipped} skipped." + else + echo "Done: ${patched} project(s) updated, ${skipped} skipped." + echo "" + echo "Next: valet-to-magebox --start-projects" + echo " (registers each *.test site in nginx; fixes ERR_CERT_COMMON_NAME_INVALID for old Valet projects)" + fi +} diff --git a/scripts/valet/lib/start-projects.sh b/scripts/valet/lib/start-projects.sh new file mode 100755 index 0000000..2a6f489 --- /dev/null +++ b/scripts/valet/lib/start-projects.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +# +# Run magebox start for Valet projects that have .magebox.yaml (nginx vhost + SSL). +# Part of MageBox — https://github.com/qoliber/magebox +# +# This file is sourced by valet-to-magebox.sh. +# It must not be executed directly. + +# Reload nginx so newly-created vhosts take effect. +valet_tm_reload_nginx() { + if ! command -v nginx >/dev/null 2>&1; then + echo "Tip: run 'magebox global stop && magebox global start' to reload nginx." + return 0 + fi + + if ! nginx -t >/dev/null 2>&1; then + echo "Warning: nginx config test failed — skipping reload." >&2 + return 1 + fi + + if nginx -s reload 2>/dev/null; then + echo "Reloaded nginx." + return 0 + fi + + echo "Tip: run 'magebox global stop && magebox global start' if HTTPS still shows the wrong certificate." +} + +# Start all discovered projects that have a .magebox.yaml. +valet_tm_start_all_projects() { + if [[ "${VALET_TM_DRY_RUN:-0}" == "1" ]]; then + echo "[dry-run] Would run 'magebox start' in each project with .magebox.yaml" + else + echo "Running magebox start for each project with .magebox.yaml (nginx vhost + SSL cert)..." + fi + echo "" + + if [[ "${VALET_TM_DRY_RUN:-0}" != "1" ]] && ! command -v magebox >/dev/null 2>&1; then + echo "Error: magebox not found in PATH. Install MageBox and run magebox bootstrap." >&2 + return 1 + fi + + local roots root + roots="$(valet_tm_discover_project_roots)" + if [[ -z "${roots}" ]]; then + echo "No Valet project roots found." + return 0 + fi + + local started=0 skipped=0 failed=0 + while IFS= read -r root; do + [[ -n "${root}" ]] || continue + if [[ ! -f "${root}/.magebox.yaml" ]]; then + skipped=$((skipped + 1)) + continue + fi + + echo "${root}" + if [[ "${VALET_TM_DRY_RUN:-0}" == "1" ]]; then + echo " [dry-run] would run: magebox start" + started=$((started + 1)) + echo "" + continue + fi + + if (cd "${root}" && magebox start 2>&1); then + echo " started" + started=$((started + 1)) + else + echo " failed (see output above)" >&2 + failed=$((failed + 1)) + fi + echo "" + done <<< "${roots}" + + if [[ "${VALET_TM_DRY_RUN:-0}" == "1" ]]; then + echo "Done: ${started} project(s) would be started, ${skipped} skipped (no .magebox.yaml)." + return 0 + fi + + echo "Done: ${started} started, ${failed} failed, ${skipped} skipped (no .magebox.yaml)." + if [[ "${started}" -gt 0 ]]; then + echo "" + valet_tm_reload_nginx + fi + return 0 +} diff --git a/scripts/valet/setup.sh b/scripts/valet/setup.sh new file mode 100755 index 0000000..a54a6ca --- /dev/null +++ b/scripts/valet/setup.sh @@ -0,0 +1,155 @@ +#!/usr/bin/env bash +# +# Install valet-to-magebox (MySQL migration helper) and store credentials. +# +# Usage: +# ./scripts/valet/setup.sh +# ./scripts/valet/setup.sh --uninstall +# +# Project credential patching lives in valet-to-magebox: +# valet-to-magebox --update-projects +# valet-to-magebox --update-projects --dry-run +# valet-to-magebox --start-projects +# valet-to-magebox --update-projects --start-projects +# +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib/common.sh +source "${SCRIPT_DIR}/lib/common.sh" + +SOURCE="${SCRIPT_DIR}/valet-to-magebox.sh" +INSTALL_LIB="${HOME}/.config/magebox/valet-lib" + +setup_usage() { + cat < + valet-to-magebox --delete='acme_shop_2025*' + valet-to-magebox --move= + valet-to-magebox --move='acme_shop_2025*' + valet-to-magebox --move= --move-to= + valet-to-magebox --move= --keep-brew + valet-to-magebox --update-projects + valet-to-magebox --update-projects --dry-run + valet-to-magebox --start-projects + valet-to-magebox --update-projects --start-projects + +Options: + --list Show all user databases on Brew MySQL + --list= Show databases matching a glob pattern (* and ?) + --delete= Drop database(s) on Brew MySQL (supports * wildcard) + --move= Move database(s) to MageBox MySQL (supports * wildcard) + --move-to= Target database name on MageBox (default: same as --move) + --keep-brew Keep the Brew database after a successful move + --force Skip confirmations (also overwrites existing MageBox database) + --update-projects Add .magebox.yaml (if missing) and patch DB/URLs in Valet projects + --start-projects Run magebox start for each project with .magebox.yaml (nginx SSL vhost) + --dry-run, -n With --update-projects or --start-projects: show planned changes only + -h, --help Show this help + +Notes: + - Brew MySQL uses port 3307 by default so MageBox (3306/33080) can keep running. + - If Brew MySQL is not reachable, it is started temporarily (when BREW_MYSQL_AUTO_START=1). + - After a successful move, the Brew database is removed unless --keep-brew is set. + - System databases (mysql, sys, …) cannot be deleted or moved. + - Wildcards: use * and ? (e.g. anfors_2025* matches anfors_20250816, anfors_20250925, …). + - --move-to cannot be combined with a wildcard pattern (each database keeps its own name). + - --update-projects adds .magebox.yaml (if missing), backs up config to *.valet, patches DB + https URLs. + - --start-projects registers each site in nginx (fixes ERR_CERT_COMMON_NAME_INVALID for old Valet sites). + +Environment: + BREW_MYSQL_PORT, BREW_MYSQL_DATADIR, BREW_MYSQL_BIN, BREW_MYSQL_USER, + BREW_MYSQL_PASSWORD, BREW_MYSQL_AUTO_START, + MAGEBOX_MYSQL_HOST, MAGEBOX_MYSQL_PORT, MAGEBOX_MYSQL_USER, MAGEBOX_MYSQL_PASSWORD, + VALET_TO_MAGEBOX_CONFIG + +Credentials: + Run ./scripts/valet/setup.sh to save Brew and MageBox MySQL credentials to + ~/.config/magebox/valet-to-magebox.env (mode 600). +EOF +} + +is_system_database() { + case "$1" in + mysql|information_schema|performance_schema|sys) return 0 ;; + *) return 1 ;; + esac +} + +require_binaries() { + [[ -x "${BREW_MYSQL_BIN}/mysql" ]] || die "mysql not found at ${BREW_MYSQL_BIN}/mysql (set BREW_MYSQL_BIN)" + [[ -x "${BREW_MYSQL_BIN}/mysqldump" ]] || die "mysqldump not found at ${BREW_MYSQL_BIN}/mysqldump (set BREW_MYSQL_BIN)" + [[ -d "${BREW_MYSQL_DATADIR}" ]] || die "datadir not found: ${BREW_MYSQL_DATADIR}" +} + +escape_identifier() { + printf '%s' "${1//\`/\\\`}" +} + +magebox_exec() { + local cmd=("${BREW_MYSQL_BIN}/mysql" --protocol=TCP -h"${MAGEBOX_MYSQL_HOST}" "-P${MAGEBOX_MYSQL_PORT}" "-u${MAGEBOX_MYSQL_USER}" --batch --skip-column-names) + if [[ -n "${MAGEBOX_MYSQL_PASSWORD}" ]]; then + cmd+=("-p${MAGEBOX_MYSQL_PASSWORD}") + fi + "${cmd[@]}" "$@" +} + +magebox_can_connect() { + magebox_exec -e "SELECT 1" >/dev/null 2>&1 +} + +magebox_database_exists() { + local target="$1" + local escaped + escaped="$(escape_identifier "${target}")" + [[ "$(magebox_exec -N -e "SELECT COUNT(*) FROM information_schema.schemata WHERE schema_name = '${escaped//\'/\\\'}';")" == "1" ]] +} + +magebox_table_count() { + local target="$1" + local escaped + escaped="$(escape_identifier "${target}")" + magebox_exec -N -e " + SELECT COUNT(*) + FROM information_schema.tables + WHERE table_schema = '${escaped//\'/\\\'}'; + " +} + +brew_table_count() { + local target="$1" + local escaped + escaped="$(escape_identifier "${target}")" + mysql_exec -N -e " + SELECT COUNT(*) + FROM information_schema.tables + WHERE table_schema = '${escaped//\'/\\\'}'; + " +} + +port_is_listening() { + lsof -nP -iTCP:"${BREW_MYSQL_PORT}" -sTCP:LISTEN >/dev/null 2>&1 +} + +mysql_exec() { + local cmd=("${BREW_MYSQL_BIN}/mysql" --protocol=TCP -h127.0.0.1 "-P${BREW_MYSQL_PORT}" "-u${BREW_MYSQL_USER}" --batch --skip-column-names) + if [[ -n "${BREW_MYSQL_PASSWORD}" ]]; then + cmd+=("-p${BREW_MYSQL_PASSWORD}") + fi + "${cmd[@]}" "$@" +} + +mysql_can_connect() { + mysql_exec -e "SELECT 1" >/dev/null 2>&1 +} + +start_brew_mysqld() { + if [[ "${BREW_MYSQL_AUTO_START}" != "1" ]]; then + return 1 + fi + + if port_is_listening; then + local attempts=0 + while ! mysql_can_connect; do + attempts=$((attempts + 1)) + [[ "${attempts}" -lt 10 ]] || return 1 + sleep 1 + done + return 0 + fi + + echo "Starting Homebrew MySQL on port ${BREW_MYSQL_PORT}…" >&2 + "${BREW_MYSQL_BIN}/mysqld_safe" \ + --datadir="${BREW_MYSQL_DATADIR}" \ + --port="${BREW_MYSQL_PORT}" \ + --socket="${BREW_MYSQL_SOCKET}" \ + --bind-address=127.0.0.1 \ + >/dev/null 2>&1 & + + local attempts=0 + until mysql_can_connect; do + attempts=$((attempts + 1)) + if [[ "${attempts}" -ge 30 ]]; then + return 1 + fi + sleep 1 + done + + STARTED_MYSQLD=1 + echo "Brew MySQL is ready on port ${BREW_MYSQL_PORT}." >&2 + return 0 +} + +stop_brew_mysqld() { + if [[ "${STARTED_MYSQLD}" != "1" ]]; then + return 0 + fi + + echo "Stopping temporary Brew MySQL…" >&2 + mysql_exec -e "SHUTDOWN" >/dev/null 2>&1 || true + STARTED_MYSQLD=0 +} + +ensure_connection() { + if mysql_can_connect; then + return 0 + fi + + start_brew_mysqld || die "Cannot connect to Brew MySQL on port ${BREW_MYSQL_PORT}. Start it manually or set BREW_MYSQL_AUTO_START=1." +} + +fetch_user_databases() { + mysql_exec -N -e " + SELECT schema_name + FROM information_schema.schemata + WHERE schema_name NOT IN ('mysql','information_schema','performance_schema','sys','.') + ORDER BY schema_name; + " +} + +has_glob_pattern() { + [[ "$1" == *'*'* || "$1" == *'?'* ]] +} + +database_matches_pattern() { + local db="$1" + local pattern="$2" + [[ "${db}" == ${pattern} ]] +} + +# Prints matching database names (one per line). Sets RESOLVED_DB_COUNT. +resolve_database_pattern() { + local pattern="$1" + local db + RESOLVED_DATABASES=() + RESOLVED_DB_COUNT=0 + + [[ -n "${pattern}" ]] || die "Database pattern is empty." + + if is_system_database "${pattern}" && ! has_glob_pattern "${pattern}"; then + die "Refusing to match system database '${pattern}'." + fi + + while IFS= read -r db; do + [[ -n "${db}" ]] || continue + is_system_database "${db}" && continue + if database_matches_pattern "${db}" "${pattern}"; then + RESOLVED_DATABASES+=("${db}") + RESOLVED_DB_COUNT=$((RESOLVED_DB_COUNT + 1)) + fi + done <<< "$(fetch_user_databases)" + + if [[ "${RESOLVED_DB_COUNT}" -eq 0 ]]; then + if has_glob_pattern "${pattern}"; then + die "No Brew databases match pattern '${pattern}'." + fi + die "Database '${pattern}' not found on Brew MySQL." + fi +} + +print_resolved_databases() { + local label="$1" + local db + printf '%s\n' "${label}" + for db in "${RESOLVED_DATABASES[@]}"; do + printf ' %s\n' "${db}" + done +} + +confirm_pattern_action() { + local action="$1" + local pattern="$2" + + if [[ "${FORCE}" == "1" ]]; then + return 0 + fi + + print_resolved_databases "About to ${action} ${RESOLVED_DB_COUNT} Brew MySQL database(s):" + echo " (port ${BREW_MYSQL_PORT}, datadir ${BREW_MYSQL_DATADIR})" + if has_glob_pattern "${pattern}"; then + printf "Type the pattern to confirm: " + read -r confirmation + [[ "${confirmation}" == "${pattern}" ]] || die "Confirmation did not match. Aborted." + return 0 + fi + + printf "Type the database name to confirm: " + read -r confirmation + [[ "${confirmation}" == "${pattern}" ]] || die "Confirmation did not match. Aborted." +} + +cmd_list() { + local pattern="${1:-}" + + ensure_connection + + local output + if [[ -n "${pattern}" ]]; then + resolve_database_pattern "${pattern}" + output="$(printf '%s\n' "${RESOLVED_DATABASES[@]}")" + else + output="$(fetch_user_databases)" + fi + + if [[ -z "${output}" ]]; then + if [[ -n "${pattern}" ]]; then + die "No databases match pattern '${pattern}'." + fi + echo "No user databases found on Brew MySQL (port ${BREW_MYSQL_PORT})." + return 0 + fi + + local count + count="$(printf '%s\n' "${output}" | wc -l | tr -d ' ')" + + if [[ -n "${pattern}" ]]; then + printf '%s\n' "Brew MySQL databases matching '${pattern}' (port ${BREW_MYSQL_PORT}):" + else + printf '%s\n' "Brew MySQL databases (port ${BREW_MYSQL_PORT}, datadir ${BREW_MYSQL_DATADIR}):" + fi + printf '%s\n' "────────────────────────────────────────────────────────────" + while IFS= read -r db; do + [[ -n "${db}" ]] && printf ' %s\n' "${db}" + done <<< "${output}" + printf '\nTotal: %s database(s)\n' "${count}" +} + +database_exists() { + resolve_database_pattern "$1" + [[ "${RESOLVED_DB_COUNT}" -eq 1 && "${RESOLVED_DATABASES[0]}" == "$1" ]] +} + +delete_single() { + local target="$1" + mysql_exec -e "DROP DATABASE \`$(escape_identifier "${target}")\`;" + echo "Deleted database '${target}'." +} + +cmd_delete() { + local pattern="$1" + [[ -n "${pattern}" ]] || die "Missing database name. Use --delete=" + + ensure_connection + resolve_database_pattern "${pattern}" + confirm_pattern_action "permanently delete" "${pattern}" + + local db + for db in "${RESOLVED_DATABASES[@]}"; do + delete_single "${db}" + done + + if [[ "${RESOLVED_DB_COUNT}" -gt 1 ]]; then + echo "Deleted ${RESOLVED_DB_COUNT} database(s)." + fi +} + +move_single() { + local source="$1" + local dest="${2:-${source}}" + + [[ -n "${source}" ]] || die "Missing source database name." + [[ -n "${dest}" ]] || die "Target database name is empty." + + if is_system_database "${source}" || is_system_database "${dest}"; then + die "Refusing to move system database." + fi + + magebox_can_connect || die "Cannot connect to MageBox MySQL at ${MAGEBOX_MYSQL_HOST}:${MAGEBOX_MYSQL_PORT} (user ${MAGEBOX_MYSQL_USER})." + + if magebox_database_exists "${dest}"; then + if [[ "${FORCE}" != "1" ]]; then + die "Database '${dest}' already exists on MageBox. Use --force to overwrite." + fi + echo "Dropping existing MageBox database '${dest}'…" >&2 + magebox_exec -e "DROP DATABASE \`$(escape_identifier "${dest}")\`;" + fi + + local brew_tables + brew_tables="$(brew_table_count "${source}")" + + echo "Creating '${dest}' on MageBox…" >&2 + magebox_exec -e "CREATE DATABASE \`$(escape_identifier "${dest}")\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" + + echo "Dumping '${source}' from Brew MySQL…" >&2 + + local dump_cmd=("${BREW_MYSQL_BIN}/mysqldump" --protocol=TCP -h127.0.0.1 "-P${BREW_MYSQL_PORT}" "-u${BREW_MYSQL_USER}" --single-transaction --routines --triggers --set-gtid-purged=OFF "${source}") + local import_cmd=("${BREW_MYSQL_BIN}/mysql" --protocol=TCP -h"${MAGEBOX_MYSQL_HOST}" "-P${MAGEBOX_MYSQL_PORT}" "-u${MAGEBOX_MYSQL_USER}" "${dest}") + if [[ -n "${BREW_MYSQL_PASSWORD}" ]]; then + dump_cmd+=("-p${BREW_MYSQL_PASSWORD}") + fi + if [[ -n "${MAGEBOX_MYSQL_PASSWORD}" ]]; then + import_cmd+=("-p${MAGEBOX_MYSQL_PASSWORD}") + fi + + echo "Importing into MageBox as '${dest}'…" >&2 + if ! "${dump_cmd[@]}" | "${import_cmd[@]}"; then + die "Import failed. Brew database '${source}' was not removed." + fi + + local magebox_tables + magebox_tables="$(magebox_table_count "${dest}")" + + echo "Import complete: ${brew_tables} table(s) on Brew → ${magebox_tables} table(s) on MageBox." + + if [[ "${KEEP_BREW}" == "1" ]]; then + echo "Brew database '${source}' kept (--keep-brew)." + return 0 + fi + + if [[ "${FORCE}" != "1" ]]; then + printf "Remove '${source}' from Brew MySQL? [y/N]: " + read -r remove_confirm + [[ "${remove_confirm}" =~ ^[yY]$ ]] || { + echo "Brew database '${source}' kept." + return 0 + } + fi + + mysql_exec -e "DROP DATABASE \`$(escape_identifier "${source}")\`;" + echo "Removed '${source}' from Brew MySQL. Data is now on MageBox as '${dest}'." +} + +cmd_move() { + local pattern="$1" + local dest_override="${2:-}" + + [[ -n "${pattern}" ]] || die "Missing database name. Use --move=" + + if has_glob_pattern "${pattern}" && [[ -n "${dest_override}" && "${dest_override}" != "${pattern}" ]]; then + die "Cannot use --move-to with a wildcard pattern. Each database is moved under its own name." + fi + + ensure_connection + resolve_database_pattern "${pattern}" + + if [[ "${RESOLVED_DB_COUNT}" -gt 1 ]]; then + echo "Moving ${RESOLVED_DB_COUNT} database(s) to MageBox…" >&2 + confirm_pattern_action "move" "${pattern}" + local db + for db in "${RESOLVED_DATABASES[@]}"; do + echo "" >&2 + echo "── ${db} ──" >&2 + move_single "${db}" "${db}" + done + echo "" + echo "Moved ${RESOLVED_DB_COUNT} database(s) to MageBox." + return 0 + fi + + local source="${RESOLVED_DATABASES[0]}" + local dest="${dest_override:-${source}}" + + if [[ "${FORCE}" != "1" ]]; then + echo "Move database:" + echo " From: Brew MySQL ${source} (port ${BREW_MYSQL_PORT})" + echo " To: MageBox MySQL ${dest} (${MAGEBOX_MYSQL_HOST}:${MAGEBOX_MYSQL_PORT})" + if [[ "${KEEP_BREW}" == "1" ]]; then + echo " Brew copy will be kept after import (--keep-brew)." + else + echo " Brew copy will be deleted after a successful import." + fi + printf "Type the source database name to confirm: " + read -r confirmation + [[ "${confirmation}" == "${source}" ]] || die "Confirmation did not match. Aborted." + fi + + move_single "${source}" "${dest}" +} + +parse_args() { + if [[ $# -eq 0 ]]; then + usage + exit 1 + fi + + while [[ $# -gt 0 ]]; do + if [[ "$1" == =* ]]; then + die "Invalid argument '${1}'. Remove the leading '=' (use --delete=name, not =--delete=name)." + fi + + case "$1" in + -h|--help) + usage + exit 0 + ;; + --list) + ACTION="list" + shift + ;; + --list=*) + ACTION="list" + LIST_PATTERN="$(normalize_option_value "${1#--list=}")" + shift + ;; + --update-projects|--patch-projects) + [[ -z "${ACTION}" || "${ACTION}" == "update-projects" ]] || die "Use only one action per run." + ACTION="update-projects" + shift + ;; + --start-projects) + VALET_TM_START_PROJECTS=1 + if [[ -z "${ACTION}" ]]; then + ACTION="start-projects" + fi + shift + ;; + --dry-run|-n) + export VALET_TM_DRY_RUN=1 + shift + ;; + --delete=*) + [[ -z "${ACTION}" || "${ACTION}" == "delete" ]] || die "Use only one action per run." + ACTION="delete" + DELETE_TARGET="$(normalize_option_value "${1#--delete=}")" + shift + ;; + --delete) + [[ -z "${ACTION}" || "${ACTION}" == "delete" ]] || die "Use only one action per run." + ACTION="delete" + shift + [[ $# -gt 0 ]] || die "Missing value for --delete" + DELETE_TARGET="$(normalize_option_value "$1")" + shift + ;; + --move=*) + [[ -z "${ACTION}" || "${ACTION}" == "move" ]] || die "Use only one action per run." + ACTION="move" + MOVE_TARGET="$(normalize_option_value "${1#--move=}")" + shift + ;; + --move) + [[ -z "${ACTION}" || "${ACTION}" == "move" ]] || die "Use only one action per run." + ACTION="move" + shift + [[ $# -gt 0 ]] || die "Missing value for --move" + MOVE_TARGET="$(normalize_option_value "$1")" + shift + ;; + --move-to=*) + MOVE_TO="$(normalize_option_value "${1#--move-to=}")" + shift + ;; + --move-to) + shift + [[ $# -gt 0 ]] || die "Missing value for --move-to" + MOVE_TO="$(normalize_option_value "$1")" + shift + ;; + --keep-brew) + KEEP_BREW=1 + shift + ;; + --force) + FORCE=1 + shift + ;; + *) + die "Unknown argument: $1 (use --help)" + ;; + esac + done + + [[ -n "${ACTION}" ]] || die "Specify --list, --delete=, --move=, --update-projects, or --start-projects" +} + +main() { + parse_args "$@" + + case "${ACTION}" in + update-projects) + if [[ "${VALET_TM_DRY_RUN}" != "1" ]]; then + [[ -f "${VALET_TO_MAGEBOX_CONFIG}" ]] || die "No credentials file. Run ./scripts/valet/setup.sh first." + fi + valet_tm_patch_all_projects + if [[ "${VALET_TM_START_PROJECTS}" == "1" ]]; then + echo "" + valet_tm_start_all_projects + elif [[ "${VALET_TM_DRY_RUN}" != "1" ]]; then + echo "" + echo "SSL: run 'valet-to-magebox --start-projects' so each *.test site gets its own nginx vhost and certificate." + echo " Without magebox start, Chrome may show ERR_CERT_COMMON_NAME_INVALID (wrong default SSL server)." + fi + exit 0 + ;; + start-projects) + valet_tm_start_all_projects + exit 0 + ;; + esac + + require_binaries + trap stop_brew_mysqld EXIT + + case "${ACTION}" in + list) + cmd_list "${LIST_PATTERN}" + ;; + delete) + cmd_delete "${DELETE_TARGET}" + ;; + move) + cmd_move "${MOVE_TARGET}" "${MOVE_TO:-${MOVE_TARGET}}" + ;; + *) + die "Unknown action: ${ACTION}" + ;; + esac +} + +main "$@" +exit 0 diff --git a/vitepress/guide/migrating-from-valet.md b/vitepress/guide/migrating-from-valet.md index 1730d0a..07455dd 100644 --- a/vitepress/guide/migrating-from-valet.md +++ b/vitepress/guide/migrating-from-valet.md @@ -2,12 +2,39 @@ This guide helps you migrate from [Laravel Valet](https://laravel.com/docs/valet) or [Valet+](https://github.com/weprovide/valet-plus) to MageBox. -## Step 1: Export Your Database +## Step 1: Move databases from Brew MySQL (optional) + +If you still have databases on Homebrew MySQL from the Valet era, MageBox ships a migration helper: ```bash -# Using MySQL CLI -mysqldump -u root your_database > database-backup.sql +bash ./scripts/valet/setup.sh +``` + +This installs `valet-to-magebox` to `~/bin/`, saves Brew and MageBox MySQL credentials, and can update `app/etc/env.php`, Laravel `.env`, and WordPress `wp-config.php` for all projects in your Valet parked paths. + +To patch credentials, secure URLs, and add `.magebox.yaml` where missing: + +```bash +valet-to-magebox --update-projects --dry-run +valet-to-magebox --update-projects +valet-to-magebox --start-projects +``` + +`--start-projects` runs `magebox start` in each parked project that has `.magebox.yaml`. You need this for HTTPS: without it, nginx has no per-site SSL vhost and Chrome may show `ERR_CERT_COMMON_NAME_INVALID` for every old Valet site except those you already started manually. +List and move databases: + +```bash +valet-to-magebox --list +valet-to-magebox --move=your_database +``` + +See [scripts/valet/README.md](https://github.com/qoliber/magebox/blob/main/scripts/valet/README.md) for full usage. + +Alternatively, export manually: + +```bash +mysqldump -u root your_database > database-backup.sql # Or with Valet+ (if available) valet db export your_database ``` @@ -48,7 +75,9 @@ domains: services: mysql: "8.0" redis: true # or valkey: true - opensearch: "2.19" + opensearch: + version: "2.19" + memory: "2g" mailpit: true ``` diff --git a/vitepress/guide/troubleshooting.md b/vitepress/guide/troubleshooting.md index 82677a6..bb5c135 100644 --- a/vitepress/guide/troubleshooting.md +++ b/vitepress/guide/troubleshooting.md @@ -233,6 +233,27 @@ magebox ssl trust magebox ssl generate ``` +### Wrong certificate / `ERR_CERT_COMMON_NAME_INVALID` + +After migrating from Valet, Chrome may show a certificate for **another** `*.test` site (often the first project you ran `magebox start` on). `--update-projects` alone does not create nginx vhosts. + +**Solution:** + +```bash +# All parked projects with .magebox.yaml +valet-to-magebox --start-projects + +# Or per project +cd /path/to/project && magebox start + +# Reload nginx +magebox global stop && magebox global start +``` + +`magebox check` warns when only an upstream file exists (`project-upstream.conf`) but no `project-domain.test.conf`. + +MageBox also installs `000-magebox-default-ssl.conf` so unknown hostnames no longer receive another project's certificate (requires nginx 1.25+ with `ssl_reject_handshake`). + ### 502 Bad Gateway **Solution:**