From 73bbdb68e16d00090629e2ac917d026bb238ca3a Mon Sep 17 00:00:00 2001 From: Joe Rucci Date: Wed, 24 Jun 2026 11:37:17 -0400 Subject: [PATCH] Add Laravel deployment provider commands --- README.md | 61 +++- internal/app/agent.go | 4 +- internal/app/deploy.go | 19 +- internal/app/deploy_laravel_cloud.go | 245 +++++++++++++ internal/app/deploy_laravel_forge.go | 329 ++++++++++++++++++ internal/app/deploy_laravel_providers_test.go | 322 +++++++++++++++++ ...eploy_vapor.go => deploy_laravel_vapor.go} | 14 +- ...r_test.go => deploy_laravel_vapor_test.go} | 8 +- internal/app/pull.go | 50 +-- 9 files changed, 998 insertions(+), 54 deletions(-) create mode 100644 internal/app/deploy_laravel_cloud.go create mode 100644 internal/app/deploy_laravel_forge.go create mode 100644 internal/app/deploy_laravel_providers_test.go rename internal/app/{deploy_vapor.go => deploy_laravel_vapor.go} (95%) rename internal/app/{deploy_vapor_test.go => deploy_laravel_vapor_test.go} (91%) diff --git a/README.md b/README.md index 855b0e55..9b7b15c2 100644 --- a/README.md +++ b/README.md @@ -112,19 +112,57 @@ and signed `.ghostable/` records. GitHub Actions workflow references under `.env`. It replaces `.env` by default so stale deploy values do not survive between runs. Use `--merge` when an existing file should be preserved. +Provider targets use `ghostable deploy [environment] [options]`: + +```sh +ghostable deploy laravel-forge production +ghostable deploy laravel-vapor production +ghostable deploy laravel-cloud production +``` + Laravel Vapor deploys can split regular environment variables from values that should be stored through Vapor Secrets: ```sh ghostable var vapor-secret --env production --key APP_KEY --enabled -ghostable deploy vapor production --dry-run -ghostable deploy vapor production +ghostable deploy laravel-vapor production --dry-run +ghostable deploy laravel-vapor production +``` + +`ghostable deploy laravel-vapor` requires the `vapor` CLI on `PATH` unless +`--dry-run` is used. Regular variables are merged into Vapor's +`.env.` file and pushed with `vapor env:push`; variables marked as +Vapor Secrets are synced with Vapor Secrets instead. + +Laravel Cloud deploys sync Ghostable values to Laravel Cloud environment +variables using the Laravel Cloud CLI: + +```sh +ghostable deploy laravel-cloud production --dry-run +ghostable deploy laravel-cloud production --cloud-env production +``` + +`ghostable deploy laravel-cloud` requires the `cloud` CLI on `PATH` unless +`--dry-run` is used. It calls `cloud environment:variables ` with +`--action=set` for each selected key, so existing Cloud variables with matching +keys are updated and missing keys are added. The Cloud environment defaults to +the Ghostable environment name; use `--cloud-env` when Laravel Cloud uses a +different environment ID or name. + +Laravel Forge deploys sync Ghostable values to a Forge site's environment file +using the Laravel Forge CLI: + +```sh +ghostable deploy laravel-forge production --dry-run --forge-site example.com +ghostable deploy laravel-forge production --forge-site example.com ``` -`ghostable deploy vapor` requires the `vapor` CLI on `PATH` unless `--dry-run` -is used. Regular variables are merged into Vapor's `.env.` file and -pushed with `vapor env:push`; variables marked as Vapor Secrets are synced with -Vapor Secrets instead. +`ghostable deploy laravel-forge` requires the `forge` CLI on `PATH` unless +`--dry-run` is used. It pulls the remote site environment file with +`forge env:pull`, merges selected Ghostable values into a temporary file, then +pushes the result with `forge env:push`. Existing Forge variables with matching +keys are updated and missing keys are added. Pass `--forge-site` with the Forge +site name that should receive the variables. Deploy systems can use a scoped automation credential instead of a local device identity: @@ -149,18 +187,13 @@ but before Laravel commands that read `.env`: ```sh npm ci export GHOSTABLE_CI_TOKEN="$(cat "$HOME/.ghostable-ci-token")" -npx --no-install ghostable deploy production +ghostable deploy laravel-forge production --forge-site example.com npm run build $FORGE_PHP artisan migrate --force ``` -For Laravel Cloud, do not rely on `ghostable deploy` inside Cloud deploy -commands to persist a generated `.env` file. Laravel Cloud deploy commands run -on Cloud infrastructure just before a deployment goes live, but filesystem -changes made there are not persisted to the application. Until Ghostable has a -native Laravel Cloud environment sync command, use Ghostable as the source of -truth locally and copy/sync the values into Laravel Cloud's environment variable -settings. +For Laravel Cloud, run `ghostable deploy laravel-cloud` before starting a Cloud +deployment when changed variables should be available to the next deploy. ## Secret Scanning diff --git a/internal/app/agent.go b/internal/app/agent.go index 2bb73e68..ff7b8245 100644 --- a/internal/app/agent.go +++ b/internal/app/agent.go @@ -84,7 +84,9 @@ func (r *Runner) runAgentCapabilities(args []string) error { "validate --file --json", "review --base --env --json", "deploy --dry-run --json", - "deploy vapor --dry-run --json", + "deploy laravel-forge --forge-site --dry-run --json", + "deploy laravel-cloud --dry-run --json", + "deploy laravel-vapor --dry-run --json", "var pull --env --key --json", "var history --env --key --json", "scan --json", diff --git a/internal/app/deploy.go b/internal/app/deploy.go index c8f689b7..37cea33e 100644 --- a/internal/app/deploy.go +++ b/internal/app/deploy.go @@ -14,12 +14,12 @@ func (r *Runner) runDeploy(args []string) error { } if len(args) > 0 && !strings.HasPrefix(args[0], "-") { switch args[0] { - case "vapor": + case "laravel-vapor": return r.runDeployVapor(args[1:]) - case "forge": - return fmt.Errorf("`ghostable deploy forge` is not implemented in the Go client yet") - case "cloud", "laravel-cloud": - return fmt.Errorf("`ghostable deploy cloud` is not implemented in the Go client yet") + case "laravel-forge": + return r.runDeployForge(args[1:]) + case "laravel-cloud": + return r.runDeployCloud(args[1:]) } } return r.runDeployWrite(args) @@ -60,9 +60,14 @@ func (r *Runner) runDeployWrite(args []string) error { } func (r *Runner) printDeployHelp() { - fmt.Fprintln(r.out, "Usage: ghostable deploy [environment] [options]") + fmt.Fprintln(r.out, "Usage: ghostable deploy [target] [environment] [options]") fmt.Fprintln(r.out) - fmt.Fprintln(r.out, "Decrypt an environment into a local .env file for deploy scripts.") + fmt.Fprintln(r.out, "Decrypt an environment into a local .env file or sync it to a supported deployment provider.") + fmt.Fprintln(r.out) + fmt.Fprintln(r.out, warn("Targets:")) + fmt.Fprintln(r.out, " laravel-forge Sync Laravel Forge site environment variables") + fmt.Fprintln(r.out, " laravel-vapor Sync Laravel Vapor environment variables and Vapor Secrets") + fmt.Fprintln(r.out, " laravel-cloud Sync Laravel Cloud environment variables") fmt.Fprintln(r.out) fmt.Fprintln(r.out, warn("Options:")) fmt.Fprintln(r.out, " --env Environment name") diff --git a/internal/app/deploy_laravel_cloud.go b/internal/app/deploy_laravel_cloud.go new file mode 100644 index 00000000..3b6fd40e --- /dev/null +++ b/internal/app/deploy_laravel_cloud.go @@ -0,0 +1,245 @@ +package app + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/ghostable-dev/beta/internal/cli" + "github.com/ghostable-dev/beta/internal/store" +) + +const cloudCommandTimeout = 2 * time.Minute + +type cloudDeployPlan struct { + Target string `json:"target"` + Provider string `json:"provider"` + Environment string `json:"environment"` + CloudEnvironment string `json:"cloudEnvironment"` + DryRun bool `json:"dryRun"` + Synced bool `json:"synced"` + Variables []string `json:"variables"` + Device string `json:"device"` + Source string `json:"source,omitempty"` + values map[string]string `json:"-"` +} + +func (r *Runner) runDeployCloud(args []string) error { + if len(args) > 0 && isHelpArg(args[0]) { + r.printDeployCloudHelp() + return nil + } + + fs := newFlagSet("deploy laravel-cloud", r.errOut) + env := fs.String("env", "", "Ghostable environment name") + cloudEnv := fs.String("cloud-env", "", "Laravel Cloud environment ID or name") + var only cli.Strings + fs.Var(&only, "only", "Only include these keys") + dryRun := fs.Bool("dry-run", false, "Show what would sync without invoking Laravel Cloud") + jsonOut := fs.Bool("json", false, "Print Laravel Cloud deploy result as JSON") + positionals, err := cli.Parse(fs, args, cli.BoolFlags("dry-run", "json")) + if err != nil { + return err + } + if len(positionals) > 1 { + return fmt.Errorf("usage: ghostable deploy laravel-cloud [environment] [options]") + } + if *env == "" && len(positionals) == 1 { + *env = positionals[0] + } + + repo, err := r.openRepo() + if err != nil { + return err + } + selected, err := r.selectEnvironment(repo, *env) + if err != nil { + return err + } + + cloudEnvironment := strings.TrimSpace(*cloudEnv) + if cloudEnvironment == "" { + cloudEnvironment = selected + } + if cloudEnvironment == "" { + return fmt.Errorf("Laravel Cloud environment is required") + } + + plan, err := buildCloudDeployPlan(repo, selected, cloudEnvironment, only) + if err != nil { + return err + } + plan.DryRun = *dryRun + + if *dryRun { + if *jsonOut { + return printJSON(r.out, plan) + } + r.printCloudDeployPlan(plan) + return nil + } + + cloudPath, err := resolveCloudBinary(repo.Root) + if err != nil { + return err + } + if err := syncCloudDeployPlan(plan, cloudPath); err != nil { + return err + } + plan.Synced = true + + if *jsonOut { + return printJSON(r.out, plan) + } + r.printCloudDeploySuccess(plan) + return nil +} + +func (r *Runner) printDeployCloudHelp() { + fmt.Fprintln(r.out, "Usage: ghostable deploy laravel-cloud [environment] [options]") + fmt.Fprintln(r.out) + fmt.Fprintln(r.out, "Sync decrypted values to Laravel Cloud environment variables using the Laravel Cloud CLI.") + fmt.Fprintln(r.out) + fmt.Fprintln(r.out, warn("Options:")) + fmt.Fprintln(r.out, " --env Ghostable environment name") + fmt.Fprintln(r.out, " --cloud-env Laravel Cloud environment ID or name (defaults to the Ghostable environment)") + fmt.Fprintln(r.out, " --only Only include these keys; may be repeated or comma-separated") + fmt.Fprintln(r.out, " --dry-run Show what would sync without invoking Laravel Cloud") + fmt.Fprintln(r.out, " --json Print Laravel Cloud deploy result as JSON") +} + +func buildCloudDeployPlan(repo store.Repository, env string, cloudEnv string, only []string) (cloudDeployPlan, error) { + variables, err := repo.ReadVariables(env) + if err != nil { + return cloudDeployPlan{}, err + } + + plan := cloudDeployPlan{ + Target: "laravel-cloud", + Provider: "Laravel Cloud", + Environment: env, + CloudEnvironment: cloudEnv, + Device: deployIdentityDisplay(repo), + Source: strings.TrimSpace(deployIdentitySource(repo)), + values: map[string]string{}, + } + + onlyKeys := deployOnlyKeySet(only) + for _, key := range vaporVariableKeys(variables) { + if len(onlyKeys) > 0 && !onlyKeys[key] { + continue + } + variable := variables[key] + plan.values[key] = variable.Value + plan.Variables = append(plan.Variables, key) + } + + return plan, nil +} + +func deployOnlyKeySet(keys []string) map[string]bool { + result := map[string]bool{} + for _, key := range keys { + key = strings.TrimSpace(key) + if key != "" { + result[key] = true + } + } + return result +} + +func syncCloudDeployPlan(plan cloudDeployPlan, cloudPath string) error { + for _, key := range plan.Variables { + if err := syncCloudEnvironmentVariable(cloudPath, plan.CloudEnvironment, key, plan.values[key]); err != nil { + return err + } + } + return nil +} + +func syncCloudEnvironmentVariable(cloudPath string, cloudEnv string, key string, value string) error { + if strings.ContainsRune(value, 0) { + return fmt.Errorf("sync Laravel Cloud variable %s: value contains a NUL byte", key) + } + + ctx, cancel := context.WithTimeout(context.Background(), cloudCommandTimeout) + defer cancel() + + cmd := exec.CommandContext( + ctx, + cloudPath, + "environment:variables", + cloudEnv, + "--json", + "--no-interaction", + "--action=set", + "--key="+key, + "--value="+value, + ) + output, err := cmd.CombinedOutput() + if ctx.Err() == context.DeadlineExceeded { + return fmt.Errorf("sync Laravel Cloud variable %s: Cloud CLI timed out", key) + } + if err != nil { + detail := sanitizeCloudCommandOutput(string(output), value) + if detail == "" { + detail = err.Error() + } + return fmt.Errorf("sync Laravel Cloud variable %s: %s", key, detail) + } + return nil +} + +func sanitizeCloudCommandOutput(output string, sensitiveValue string) string { + detail := strings.TrimSpace(output) + if detail == "" || sensitiveValue == "" { + return detail + } + return strings.ReplaceAll(detail, sensitiveValue, "[redacted]") +} + +func resolveCloudBinary(projectRoot string) (string, error) { + path, err := exec.LookPath("cloud") + if err != nil { + return "", fmt.Errorf("Laravel Cloud CLI not found on PATH; install it with `composer global require laravel/cloud-cli` before running `ghostable deploy laravel-cloud`") + } + absolutePath, err := filepath.Abs(path) + if err != nil { + return "", err + } + if binaryInsideProject(projectRoot, absolutePath) { + return "", fmt.Errorf("refusing to run Laravel Cloud CLI from project path %s; put a trusted Cloud executable earlier on PATH outside this repository", absolutePath) + } + info, err := os.Stat(absolutePath) + if err != nil { + return "", err + } + if info.IsDir() { + return "", fmt.Errorf("Laravel Cloud CLI path %s is a directory", absolutePath) + } + return absolutePath, nil +} + +func (r *Runner) printCloudDeployPlan(plan cloudDeployPlan) { + fmt.Fprintln(r.out, success("👻 Ghostable Laravel Cloud deploy plan.")) + printCloudDeployDetails(r, plan) +} + +func (r *Runner) printCloudDeploySuccess(plan cloudDeployPlan) { + fmt.Fprintln(r.out, success("👻 Ghostable Laravel Cloud deploy successful.")) + printCloudDeployDetails(r, plan) +} + +func printCloudDeployDetails(r *Runner, plan cloudDeployPlan) { + printDeployDetail(r.out, "Environment", plan.Environment) + printDeployDetail(r.out, "Cloud environment", plan.CloudEnvironment) + printDeployDetail(r.out, "Variables", deployVariableCount(len(plan.Variables))) + printDeployDetail(r.out, "Device", plan.Device) + if plan.Source != "" { + printDeployDetail(r.out, "Source", plan.Source) + } +} diff --git a/internal/app/deploy_laravel_forge.go b/internal/app/deploy_laravel_forge.go new file mode 100644 index 00000000..2bae022a --- /dev/null +++ b/internal/app/deploy_laravel_forge.go @@ -0,0 +1,329 @@ +package app + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/ghostable-dev/beta/internal/cli" + "github.com/ghostable-dev/beta/internal/dotenv" + "github.com/ghostable-dev/beta/internal/store" +) + +const forgeCommandTimeout = 2 * time.Minute + +type forgeDeployPlan struct { + Target string `json:"target"` + Provider string `json:"provider"` + Environment string `json:"environment"` + ForgeSite string `json:"forgeSite"` + DryRun bool `json:"dryRun"` + Synced bool `json:"synced"` + Variables []string `json:"variables"` + Device string `json:"device"` + Source string `json:"source,omitempty"` + values map[string]string `json:"-"` +} + +func (r *Runner) runDeployForge(args []string) error { + if len(args) > 0 && isHelpArg(args[0]) { + r.printDeployForgeHelp() + return nil + } + + fs := newFlagSet("deploy laravel-forge", r.errOut) + env := fs.String("env", "", "Ghostable environment name") + forgeSite := fs.String("forge-site", "", "Laravel Forge site name") + var only cli.Strings + fs.Var(&only, "only", "Only include these keys") + dryRun := fs.Bool("dry-run", false, "Show what would sync without invoking Laravel Forge") + jsonOut := fs.Bool("json", false, "Print Laravel Forge deploy result as JSON") + positionals, err := cli.Parse(fs, args, cli.BoolFlags("dry-run", "json")) + if err != nil { + return err + } + if len(positionals) > 1 { + return fmt.Errorf("usage: ghostable deploy laravel-forge [environment] [options]") + } + if *env == "" && len(positionals) == 1 { + *env = positionals[0] + } + + repo, err := r.openRepo() + if err != nil { + return err + } + selected, err := r.selectEnvironment(repo, *env) + if err != nil { + return err + } + + site := strings.TrimSpace(*forgeSite) + if site == "" { + return fmt.Errorf("Laravel Forge site is required; pass --forge-site ") + } + + plan, err := buildForgeDeployPlan(repo, selected, site, only) + if err != nil { + return err + } + plan.DryRun = *dryRun + + if *dryRun { + if *jsonOut { + return printJSON(r.out, plan) + } + r.printForgeDeployPlan(plan) + return nil + } + + forgePath, err := resolveForgeBinary(repo.Root) + if err != nil { + return err + } + if err := syncForgeDeployPlan(plan, forgePath); err != nil { + return err + } + plan.Synced = true + + if *jsonOut { + return printJSON(r.out, plan) + } + r.printForgeDeploySuccess(plan) + return nil +} + +func (r *Runner) printDeployForgeHelp() { + fmt.Fprintln(r.out, "Usage: ghostable deploy laravel-forge [environment] [options]") + fmt.Fprintln(r.out) + fmt.Fprintln(r.out, "Sync decrypted values to a Laravel Forge site environment file using the Laravel Forge CLI.") + fmt.Fprintln(r.out) + fmt.Fprintln(r.out, warn("Options:")) + fmt.Fprintln(r.out, " --env Ghostable environment name") + fmt.Fprintln(r.out, " --forge-site Laravel Forge site name") + fmt.Fprintln(r.out, " --only Only include these keys; may be repeated or comma-separated") + fmt.Fprintln(r.out, " --dry-run Show what would sync without invoking Laravel Forge") + fmt.Fprintln(r.out, " --json Print Laravel Forge deploy result as JSON") +} + +func buildForgeDeployPlan(repo store.Repository, env string, forgeSite string, only []string) (forgeDeployPlan, error) { + variables, err := repo.ReadVariables(env) + if err != nil { + return forgeDeployPlan{}, err + } + + plan := forgeDeployPlan{ + Target: "laravel-forge", + Provider: "Laravel Forge", + Environment: env, + ForgeSite: forgeSite, + Device: deployIdentityDisplay(repo), + Source: strings.TrimSpace(deployIdentitySource(repo)), + values: map[string]string{}, + } + + onlyKeys := deployOnlyKeySet(only) + for _, key := range vaporVariableKeys(variables) { + if len(onlyKeys) > 0 && !onlyKeys[key] { + continue + } + variable := variables[key] + plan.values[key] = variable.Value + plan.Variables = append(plan.Variables, key) + } + + return plan, nil +} + +func syncForgeDeployPlan(plan forgeDeployPlan, forgePath string) error { + envPath, err := createTemporaryForgeEnvironmentFile(plan.ForgeSite) + if err != nil { + return err + } + defer removeForgeEnvironmentFile(envPath) + + if err := runForgeCommand(plan, forgePath, "pull Forge environment", "env:pull", plan.ForgeSite, envPath); err != nil { + return err + } + if err := ensureForgeEnvironmentFileIsRegular(envPath); err != nil { + return err + } + + existing := "" + if content, err := os.ReadFile(envPath); err == nil { + existing = string(content) + } else if !os.IsNotExist(err) { + return err + } + + next, err := dotenv.Merge(existing, plan.values, plan.Variables, false) + if err != nil { + return err + } + if err := writeForgeEnvironmentFile(envPath, []byte(next)); err != nil { + return err + } + + if err := runForgeCommand(plan, forgePath, "push Forge environment", "env:push", plan.ForgeSite, envPath); err != nil { + return err + } + return removeForgeEnvironmentFile(envPath) +} + +func createTemporaryForgeEnvironmentFile(forgeSite string) (string, error) { + temp, err := os.CreateTemp("", "ghostable-forge-"+safeTempFileSegment(forgeSite)+"-*.env") + if err != nil { + return "", err + } + path := temp.Name() + if err := temp.Close(); err != nil { + _ = os.Remove(path) + return "", err + } + return path, nil +} + +func safeTempFileSegment(value string) string { + var builder strings.Builder + for _, r := range value { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_', r == '.': + builder.WriteRune(r) + default: + builder.WriteByte('-') + } + } + result := strings.Trim(builder.String(), "-.") + if result == "" { + return "site" + } + return result +} + +func ensureForgeEnvironmentFileIsRegular(path string) error { + info, err := os.Lstat(path) + if err == nil { + if info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("refusing to use symlinked Forge environment file %s", path) + } + if info.IsDir() { + return fmt.Errorf("Forge environment file %s is a directory", path) + } + return nil + } + if os.IsNotExist(err) { + return nil + } + return err +} + +func writeForgeEnvironmentFile(path string, content []byte) error { + if err := ensureForgeEnvironmentFileIsRegular(path); err != nil { + return err + } + temp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*") + if err != nil { + return err + } + tempPath := temp.Name() + defer os.Remove(tempPath) + if _, err := temp.Write(content); err != nil { + _ = temp.Close() + return err + } + if err := temp.Close(); err != nil { + return err + } + if err := os.Chmod(tempPath, 0o600); err != nil { + return err + } + return os.Rename(tempPath, path) +} + +func removeForgeEnvironmentFile(path string) error { + if err := ensureForgeEnvironmentFileIsRegular(path); err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return err + } + return nil +} + +func resolveForgeBinary(projectRoot string) (string, error) { + path, err := exec.LookPath("forge") + if err != nil { + return "", fmt.Errorf("Laravel Forge CLI not found on PATH; install it with `composer global require laravel/forge-cli` before running `ghostable deploy laravel-forge`") + } + absolutePath, err := filepath.Abs(path) + if err != nil { + return "", err + } + if binaryInsideProject(projectRoot, absolutePath) { + return "", fmt.Errorf("refusing to run Laravel Forge CLI from project path %s; put a trusted Forge executable earlier on PATH outside this repository", absolutePath) + } + info, err := os.Stat(absolutePath) + if err != nil { + return "", err + } + if info.IsDir() { + return "", fmt.Errorf("Laravel Forge CLI path %s is a directory", absolutePath) + } + return absolutePath, nil +} + +func runForgeCommand(plan forgeDeployPlan, forgePath string, action string, args ...string) error { + ctx, cancel := context.WithTimeout(context.Background(), forgeCommandTimeout) + defer cancel() + + cmd := exec.CommandContext(ctx, forgePath, args...) + output, err := cmd.CombinedOutput() + if ctx.Err() == context.DeadlineExceeded { + return fmt.Errorf("%s: Forge CLI timed out", action) + } + if err != nil { + detail := sanitizeForgeCommandOutput(string(output), plan.values) + if detail == "" { + detail = err.Error() + } + return fmt.Errorf("%s: %s", action, detail) + } + return nil +} + +func sanitizeForgeCommandOutput(output string, values map[string]string) string { + detail := strings.TrimSpace(output) + for _, value := range values { + if value != "" { + detail = strings.ReplaceAll(detail, value, "[redacted]") + } + } + return detail +} + +func (r *Runner) printForgeDeployPlan(plan forgeDeployPlan) { + fmt.Fprintln(r.out, success("👻 Ghostable Laravel Forge deploy plan.")) + printForgeDeployDetails(r, plan) +} + +func (r *Runner) printForgeDeploySuccess(plan forgeDeployPlan) { + fmt.Fprintln(r.out, success("👻 Ghostable Laravel Forge deploy successful.")) + printForgeDeployDetails(r, plan) +} + +func printForgeDeployDetails(r *Runner, plan forgeDeployPlan) { + printDeployDetail(r.out, "Environment", plan.Environment) + printDeployDetail(r.out, "Forge site", plan.ForgeSite) + printDeployDetail(r.out, "Variables", deployVariableCount(len(plan.Variables))) + printDeployDetail(r.out, "Device", plan.Device) + if plan.Source != "" { + printDeployDetail(r.out, "Source", plan.Source) + } +} diff --git a/internal/app/deploy_laravel_providers_test.go b/internal/app/deploy_laravel_providers_test.go new file mode 100644 index 00000000..3da0b09b --- /dev/null +++ b/internal/app/deploy_laravel_providers_test.go @@ -0,0 +1,322 @@ +package app + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/ghostable-dev/beta/internal/store" +) + +func TestRunDeployCloudInvokesLaravelCloudCLI(t *testing.T) { + root := setupDeployCommandTest(t) + repo, err := store.Open(root) + if err != nil { + t.Fatal(err) + } + if err := repo.SetVariable("production", "APP_NAME", "Ghostable", "test"); err != nil { + t.Fatal(err) + } + if err := repo.SetVariable("production", "API_KEY", "secret", "test"); err != nil { + t.Fatal(err) + } + + binDir := t.TempDir() + logPath := filepath.Join(t.TempDir(), "cloud.log") + cloudPath := filepath.Join(binDir, "cloud") + script := "#!/bin/sh\n" + + "echo \"$@\" >> \"$CLOUD_LOG\"\n" + + "exit 0\n" + if err := os.WriteFile(cloudPath, []byte(script), 0o700); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("CLOUD_LOG", logPath) + + var output bytes.Buffer + runner := NewRunner([]string{"ghostable", "deploy", "laravel-cloud", "production", "--cloud-env", "cloud-prod", "--only", "APP_NAME"}, strings.NewReader(""), &output, &output) + if err := runner.Run(); err != nil { + t.Fatal(err) + } + + logContent, err := os.ReadFile(logPath) + if err != nil { + t.Fatal(err) + } + logText := string(logContent) + for _, expected := range []string{ + "environment:variables cloud-prod --json --no-interaction --action=set --key=APP_NAME --value=Ghostable", + } { + if !strings.Contains(logText, expected) { + t.Fatalf("expected Laravel Cloud CLI log to contain %q, got:\n%s", expected, logText) + } + } + if strings.Contains(logText, "API_KEY") { + t.Fatalf("did not expect --only filtered key to be synced, got:\n%s", logText) + } + if _, err := os.Stat(filepath.Join(root, ".env")); !os.IsNotExist(err) { + t.Fatalf("Laravel Cloud deploy should not write .env, stat err: %v", err) + } + if !strings.Contains(output.String(), "👻 Ghostable Laravel Cloud deploy successful.") { + t.Fatalf("expected Cloud deploy success output, got:\n%s", output.String()) + } +} + +func TestRunDeployLaravelCloudUsesCloudTarget(t *testing.T) { + root := setupDeployCommandTest(t) + repo, err := store.Open(root) + if err != nil { + t.Fatal(err) + } + if err := repo.SetVariable("production", "APP_NAME", "Ghostable", "test"); err != nil { + t.Fatal(err) + } + + var output bytes.Buffer + runner := NewRunner([]string{"ghostable", "deploy", "laravel-cloud", "production", "--dry-run", "--json"}, strings.NewReader(""), &output, &output) + if err := runner.Run(); err != nil { + t.Fatal(err) + } + + text := output.String() + if !strings.Contains(text, `"target": "laravel-cloud"`) || !strings.Contains(text, `"provider": "Laravel Cloud"`) { + t.Fatalf("expected laravel-cloud route to use Cloud target JSON, got:\n%s", text) + } + if _, err := os.Stat(filepath.Join(root, ".env")); !os.IsNotExist(err) { + t.Fatalf("dry-run should not write .env, stat err: %v", err) + } +} + +func TestRunDeployCloudRedactsValueFromCLIError(t *testing.T) { + root := setupDeployCommandTest(t) + repo, err := store.Open(root) + if err != nil { + t.Fatal(err) + } + if err := repo.SetVariable("production", "APP_NAME", "Ghostable", "test"); err != nil { + t.Fatal(err) + } + + binDir := t.TempDir() + cloudPath := filepath.Join(binDir, "cloud") + script := "#!/bin/sh\n" + + "echo \"failed with $@\" >&2\n" + + "exit 1\n" + if err := os.WriteFile(cloudPath, []byte(script), 0o700); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + var output bytes.Buffer + runner := NewRunner([]string{"ghostable", "deploy", "laravel-cloud", "production"}, strings.NewReader(""), &output, &output) + err = runner.Run() + if err == nil { + t.Fatal("expected Laravel Cloud CLI failure") + } + if strings.Contains(err.Error(), "Ghostable") { + t.Fatalf("expected CLI error to redact variable value, got %v", err) + } + if !strings.Contains(err.Error(), "[redacted]") { + t.Fatalf("expected CLI error to include redaction marker, got %v", err) + } +} + +func TestRunDeployCloudRejectsProjectLocalCloudCLI(t *testing.T) { + root := setupDeployCommandTest(t) + repo, err := store.Open(root) + if err != nil { + t.Fatal(err) + } + if err := repo.SetVariable("production", "APP_NAME", "Ghostable", "test"); err != nil { + t.Fatal(err) + } + + binDir := filepath.Join(root, "bin") + if err := os.MkdirAll(binDir, 0o755); err != nil { + t.Fatal(err) + } + cloudPath := filepath.Join(binDir, "cloud") + if err := os.WriteFile(cloudPath, []byte("#!/bin/sh\nexit 0\n"), 0o700); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + var output bytes.Buffer + runner := NewRunner([]string{"ghostable", "deploy", "laravel-cloud", "production"}, strings.NewReader(""), &output, &output) + if err := runner.Run(); err == nil || !strings.Contains(err.Error(), "refusing to run Laravel Cloud CLI from project path") { + t.Fatalf("expected project-local Laravel Cloud CLI to be rejected, got %v", err) + } +} + +func TestRunDeployLaravelVaporUsesVaporTarget(t *testing.T) { + root := setupDeployCommandTest(t) + repo, err := store.Open(root) + if err != nil { + t.Fatal(err) + } + if err := repo.SetVariable("production", "APP_NAME", "Ghostable", "test"); err != nil { + t.Fatal(err) + } + + var output bytes.Buffer + runner := NewRunner([]string{"ghostable", "deploy", "laravel-vapor", "production", "--dry-run"}, strings.NewReader(""), &output, &output) + if err := runner.Run(); err != nil { + t.Fatal(err) + } + + if !strings.Contains(output.String(), "👻 Ghostable Vapor deploy plan.") { + t.Fatalf("expected laravel-vapor route to use Vapor deploy path, got:\n%s", output.String()) + } +} + +func TestRunDeployShortProviderTargetsAreNotAccepted(t *testing.T) { + setupDeployCommandTest(t) + + for _, target := range []string{"forge", "cloud", "vapor"} { + t.Run(target, func(t *testing.T) { + var output bytes.Buffer + runner := NewRunner([]string{"ghostable", "deploy", target, "production", "--dry-run"}, strings.NewReader(""), &output, &output) + + err := runner.Run() + if err == nil || !strings.Contains(err.Error(), "usage: ghostable deploy [environment] [options]") { + t.Fatalf("expected short provider target %q to be rejected by deploy usage, got %v", target, err) + } + }) + } +} + +func TestRunDeployForgeInvokesLaravelForgeCLI(t *testing.T) { + root := setupDeployCommandTest(t) + repo, err := store.Open(root) + if err != nil { + t.Fatal(err) + } + if err := repo.SetVariable("production", "APP_NAME", "Ghostable", "test"); err != nil { + t.Fatal(err) + } + if err := repo.SetVariable("production", "API_KEY", "secret", "test"); err != nil { + t.Fatal(err) + } + + binDir := t.TempDir() + logPath := filepath.Join(t.TempDir(), "forge.log") + forgePath := filepath.Join(binDir, "forge") + script := "#!/bin/sh\n" + + "echo \"$@\" >> \"$FORGE_LOG\"\n" + + "if [ \"$1\" = \"env:pull\" ]; then printf 'EXISTING=1\\n' > \"$3\"; exit 0; fi\n" + + "if [ \"$1\" = \"env:push\" ]; then cat \"$3\" >> \"$FORGE_LOG\"; exit 0; fi\n" + + "exit 0\n" + if err := os.WriteFile(forgePath, []byte(script), 0o700); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("FORGE_LOG", logPath) + + var output bytes.Buffer + runner := NewRunner([]string{"ghostable", "deploy", "laravel-forge", "production", "--forge-site", "example.com", "--only", "APP_NAME"}, strings.NewReader(""), &output, &output) + if err := runner.Run(); err != nil { + t.Fatal(err) + } + + logContent, err := os.ReadFile(logPath) + if err != nil { + t.Fatal(err) + } + logText := string(logContent) + for _, expected := range []string{ + "env:pull example.com ", + "env:push example.com ", + "EXISTING=1", + "APP_NAME=Ghostable", + } { + if !strings.Contains(logText, expected) { + t.Fatalf("expected Laravel Forge CLI log to contain %q, got:\n%s", expected, logText) + } + } + if strings.Contains(logText, "API_KEY") { + t.Fatalf("did not expect --only filtered key to be synced, got:\n%s", logText) + } + if _, err := os.Stat(filepath.Join(root, ".env")); !os.IsNotExist(err) { + t.Fatalf("Laravel Forge deploy should not write .env, stat err: %v", err) + } + + text := output.String() + if !strings.Contains(text, "👻 Ghostable Laravel Forge deploy successful.") || + !strings.Contains(text, warn("Forge site:")+" example.com") { + t.Fatalf("expected Forge deploy output to include provider success and site details, got:\n%s", text) + } +} + +func TestRunDeployForgeRejectsProjectLocalForgeCLI(t *testing.T) { + root := setupDeployCommandTest(t) + repo, err := store.Open(root) + if err != nil { + t.Fatal(err) + } + if err := repo.SetVariable("production", "APP_NAME", "Ghostable", "test"); err != nil { + t.Fatal(err) + } + + binDir := filepath.Join(root, "bin") + if err := os.MkdirAll(binDir, 0o755); err != nil { + t.Fatal(err) + } + forgePath := filepath.Join(binDir, "forge") + if err := os.WriteFile(forgePath, []byte("#!/bin/sh\nexit 0\n"), 0o700); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + var output bytes.Buffer + runner := NewRunner([]string{"ghostable", "deploy", "laravel-forge", "production", "--forge-site", "example.com"}, strings.NewReader(""), &output, &output) + if err := runner.Run(); err == nil || !strings.Contains(err.Error(), "refusing to run Laravel Forge CLI from project path") { + t.Fatalf("expected project-local Laravel Forge CLI to be rejected, got %v", err) + } +} + +func TestRunDeployForgeRequiresSite(t *testing.T) { + setupDeployCommandTest(t) + + var output bytes.Buffer + runner := NewRunner([]string{"ghostable", "deploy", "laravel-forge", "production", "--dry-run"}, strings.NewReader(""), &output, &output) + err := runner.Run() + if err == nil || !strings.Contains(err.Error(), "Laravel Forge site is required") { + t.Fatalf("expected Forge deploy to require --forge-site, got %v", err) + } +} + +func TestRunDeployForgeRedactsValuesFromCLIError(t *testing.T) { + root := setupDeployCommandTest(t) + repo, err := store.Open(root) + if err != nil { + t.Fatal(err) + } + if err := repo.SetVariable("production", "APP_NAME", "Ghostable", "test"); err != nil { + t.Fatal(err) + } + + binDir := t.TempDir() + forgePath := filepath.Join(binDir, "forge") + script := "#!/bin/sh\n" + + "echo \"failed with Ghostable\" >&2\n" + + "exit 1\n" + if err := os.WriteFile(forgePath, []byte(script), 0o700); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + var output bytes.Buffer + runner := NewRunner([]string{"ghostable", "deploy", "laravel-forge", "production", "--forge-site", "example.com"}, strings.NewReader(""), &output, &output) + err = runner.Run() + if err == nil { + t.Fatal("expected Laravel Forge CLI failure") + } + if strings.Contains(err.Error(), "Ghostable") { + t.Fatalf("expected CLI error to redact variable value, got %v", err) + } + if !strings.Contains(err.Error(), "[redacted]") { + t.Fatalf("expected CLI error to include redaction marker, got %v", err) + } +} diff --git a/internal/app/deploy_vapor.go b/internal/app/deploy_laravel_vapor.go similarity index 95% rename from internal/app/deploy_vapor.go rename to internal/app/deploy_laravel_vapor.go index b33ffeb6..af55ddd9 100644 --- a/internal/app/deploy_vapor.go +++ b/internal/app/deploy_laravel_vapor.go @@ -40,7 +40,7 @@ func (r *Runner) runDeployVapor(args []string) error { return nil } - fs := newFlagSet("deploy vapor", r.errOut) + fs := newFlagSet("deploy laravel-vapor", r.errOut) env := fs.String("env", "", "Ghostable environment name") vaporEnv := fs.String("vapor-env", "", "Laravel Vapor environment name") dryRun := fs.Bool("dry-run", false, "Show what would sync without invoking Vapor") @@ -50,7 +50,7 @@ func (r *Runner) runDeployVapor(args []string) error { return err } if len(positionals) > 1 { - return fmt.Errorf("usage: ghostable deploy vapor [environment] [options]") + return fmt.Errorf("usage: ghostable deploy laravel-vapor [environment] [options]") } if *env == "" && len(positionals) == 1 { *env = positionals[0] @@ -103,7 +103,7 @@ func (r *Runner) runDeployVapor(args []string) error { } func (r *Runner) printDeployVaporHelp() { - fmt.Fprintln(r.out, "Usage: ghostable deploy vapor [environment] [options]") + fmt.Fprintln(r.out, "Usage: ghostable deploy laravel-vapor [environment] [options]") fmt.Fprintln(r.out) fmt.Fprintln(r.out, "Sync decrypted values to Laravel Vapor using Vapor environment variables and Vapor Secrets.") fmt.Fprintln(r.out) @@ -298,13 +298,13 @@ func writeVaporSecretTempFile(value string) (string, error) { func resolveVaporBinary(projectRoot string) (string, error) { path, err := exec.LookPath("vapor") if err != nil { - return "", fmt.Errorf("Vapor CLI not found on PATH; install the Laravel Vapor CLI before running `ghostable deploy vapor`") + return "", fmt.Errorf("Vapor CLI not found on PATH; install the Laravel Vapor CLI before running `ghostable deploy laravel-vapor`") } absolutePath, err := filepath.Abs(path) if err != nil { return "", err } - if vaporBinaryInsideProject(projectRoot, absolutePath) { + if binaryInsideProject(projectRoot, absolutePath) { return "", fmt.Errorf("refusing to run Vapor CLI from project path %s; put a trusted Vapor executable earlier on PATH outside this repository", absolutePath) } info, err := os.Stat(absolutePath) @@ -317,12 +317,12 @@ func resolveVaporBinary(projectRoot string) (string, error) { return absolutePath, nil } -func vaporBinaryInsideProject(projectRoot string, vaporPath string) bool { +func binaryInsideProject(projectRoot string, binaryPath string) bool { absoluteRoot, err := filepath.Abs(projectRoot) if err != nil { return false } - absolutePath, err := filepath.Abs(vaporPath) + absolutePath, err := filepath.Abs(binaryPath) if err != nil { return false } diff --git a/internal/app/deploy_vapor_test.go b/internal/app/deploy_laravel_vapor_test.go similarity index 91% rename from internal/app/deploy_vapor_test.go rename to internal/app/deploy_laravel_vapor_test.go index f0bee445..3e55dfdf 100644 --- a/internal/app/deploy_vapor_test.go +++ b/internal/app/deploy_laravel_vapor_test.go @@ -27,7 +27,7 @@ func TestRunDeployVaporDryRunUsesVaporSecretMetadata(t *testing.T) { } var output bytes.Buffer - runner := NewRunner([]string{"ghostable", "deploy", "vapor", "production", "--dry-run"}, strings.NewReader(""), &output, &output) + runner := NewRunner([]string{"ghostable", "deploy", "laravel-vapor", "production", "--dry-run"}, strings.NewReader(""), &output, &output) if err := runner.Run(); err != nil { t.Fatal(err) } @@ -79,7 +79,7 @@ func TestRunDeployVaporInvokesVaporCLI(t *testing.T) { t.Setenv("VAPOR_LOG", logPath) var output bytes.Buffer - runner := NewRunner([]string{"ghostable", "deploy", "vapor", "production", "--vapor-env", "staging"}, strings.NewReader(""), &output, &output) + runner := NewRunner([]string{"ghostable", "deploy", "laravel-vapor", "production", "--vapor-env", "staging"}, strings.NewReader(""), &output, &output) if err := runner.Run(); err != nil { t.Fatal(err) } @@ -144,7 +144,7 @@ func TestRunDeployVaporRejectsProjectLocalVaporCLI(t *testing.T) { t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) var output bytes.Buffer - runner := NewRunner([]string{"ghostable", "deploy", "vapor", "production"}, strings.NewReader(""), &output, &output) + runner := NewRunner([]string{"ghostable", "deploy", "laravel-vapor", "production"}, strings.NewReader(""), &output, &output) if err := runner.Run(); err == nil || !strings.Contains(err.Error(), "refusing to run Vapor CLI from project path") { t.Fatalf("expected project-local Vapor CLI to be rejected, got %v", err) } @@ -181,7 +181,7 @@ func TestRunDeployVaporDoesNotUseRepoLocalEnvironmentFile(t *testing.T) { } var output bytes.Buffer - runner := NewRunner([]string{"ghostable", "deploy", "vapor", "production", "--vapor-env", "staging"}, strings.NewReader(""), &output, &output) + runner := NewRunner([]string{"ghostable", "deploy", "laravel-vapor", "production", "--vapor-env", "staging"}, strings.NewReader(""), &output, &output) if err := runner.Run(); err != nil { t.Fatalf("expected Vapor deploy to avoid repo-local symlinked environment file, got %v", err) } diff --git a/internal/app/pull.go b/internal/app/pull.go index c0c90985..11395a0e 100644 --- a/internal/app/pull.go +++ b/internal/app/pull.go @@ -23,27 +23,7 @@ type environmentPullRequest struct { } func (r *Runner) pullEnvironmentFile(request environmentPullRequest) error { - repo, err := r.openRepo() - if err != nil { - return err - } - selected, err := r.selectEnvironment(repo, request.Environment) - if err != nil { - return err - } - if request.File == "" { - request.File = envFileDefault(selected) - } - result, rendered, err := repo.Pull(selected, store.PullOptions{ - File: request.File, - Only: request.Only, - DryRun: request.DryRun, - Replace: request.Replace, - Backup: request.Backup, - Force: request.Force, - ShowValue: request.ShowValues, - SkipEvent: request.SkipEvent, - }) + repo, result, rendered, err := r.writeEnvironmentFile(request) if err != nil { return err } @@ -69,6 +49,34 @@ func (r *Runner) pullEnvironmentFile(request environmentPullRequest) error { return nil } +func (r *Runner) writeEnvironmentFile(request environmentPullRequest) (store.Repository, store.PullResult, string, error) { + repo, err := r.openRepo() + if err != nil { + return store.Repository{}, store.PullResult{}, "", err + } + selected, err := r.selectEnvironment(repo, request.Environment) + if err != nil { + return store.Repository{}, store.PullResult{}, "", err + } + if request.File == "" { + request.File = envFileDefault(selected) + } + result, rendered, err := repo.Pull(selected, store.PullOptions{ + File: request.File, + Only: request.Only, + DryRun: request.DryRun, + Replace: request.Replace, + Backup: request.Backup, + Force: request.Force, + ShowValue: request.ShowValues, + SkipEvent: request.SkipEvent, + }) + if err != nil { + return store.Repository{}, store.PullResult{}, "", err + } + return repo, result, rendered, nil +} + func (r *Runner) printDeploySuccess(repo store.Repository, result store.PullResult) { fmt.Fprintln(r.out, success("👻 Ghostable deploy successful.")) printDeployDetail(r.out, "Environment", result.Environment)