From ec0d55ad464b2ae734e943bad32d14a6103b7bf0 Mon Sep 17 00:00:00 2001 From: Mike Sawka Date: Thu, 3 Sep 2026 10:40:21 -0700 Subject: [PATCH 1/6] checkpoint on vibeapps api --- cmd/harness/main-harness.go | 2 + modules/vibeapps/deployment_log.go | 181 ++++++++++ modules/vibeapps/pull_vibeapp.go | 103 ++++++ modules/vibeapps/push_source.go | 252 ++++++++++++++ modules/vibeapps/util.go | 29 ++ modules/vibeapps/vibeapps.go | 13 + pkg/spec/vibeapps.spec.yaml | 533 +++++++++++++++++++++++++++++ 7 files changed, 1113 insertions(+) create mode 100644 modules/vibeapps/deployment_log.go create mode 100644 modules/vibeapps/pull_vibeapp.go create mode 100644 modules/vibeapps/push_source.go create mode 100644 modules/vibeapps/util.go create mode 100644 modules/vibeapps/vibeapps.go create mode 100644 pkg/spec/vibeapps.spec.yaml diff --git a/cmd/harness/main-harness.go b/cmd/harness/main-harness.go index 55b44ff..76972a8 100644 --- a/cmd/harness/main-harness.go +++ b/cmd/harness/main-harness.go @@ -20,6 +20,7 @@ import ( "github.com/harness/cli/modules/pipeline" "github.com/harness/cli/modules/platform" "github.com/harness/cli/modules/rt" + "github.com/harness/cli/modules/vibeapps" "github.com/harness/cli/pkg/console" "github.com/harness/cli/pkg/hbase" "github.com/harness/cli/pkg/registry" @@ -55,6 +56,7 @@ func main() { // har is an external module (external_binary: harness-har) — ModuleInit is not loaded here. iacm.ModuleInit(reg.Module("iacm")) rt.ModuleInit(reg.Module("rt")) + vibeapps.ModuleInit(reg.Module("vibeapps")) rootcmd.MaybeCheckSpecs(reg) root := &cobra.Command{ diff --git a/modules/vibeapps/deployment_log.go b/modules/vibeapps/deployment_log.go new file mode 100644 index 0000000..761401d --- /dev/null +++ b/modules/vibeapps/deployment_log.go @@ -0,0 +1,181 @@ +// Copyright © 2026 Harness Inc. +// SPDX-License-Identifier: Apache-2.0 + +package vibeapps + +import ( + "fmt" + "os" + "strings" + "time" + + "github.com/harness/cli/pkg/client" + "github.com/harness/cli/pkg/cmdctx" +) + +const getVibeappDeploymentLogWorkflowID = "get_vibeapp_deployment_log" +const vibeappDeployFollowFnID = "vibeapp_deploy_follow" + +const deploymentLogPollInterval = 3 * time.Second + +type deploymentEvent struct { + StageKey string `json:"stageKey"` + Level string `json:"level"` + Message string `json:"message"` + CreatedAt string `json:"createdAt"` +} + +type deploymentError struct { + StageKey string `json:"stageKey"` + WorkloadName string `json:"workloadName"` + Message string `json:"message"` + Remediation string `json:"remediation"` +} + +type deploymentSecurityFinding struct { + Title string `json:"title"` + Severity string `json:"severity"` + Remediation string `json:"remediation"` + Blocking bool `json:"blocking"` +} + +type deploymentLogView struct { + ID string `json:"id"` + Status string `json:"status"` + Events []deploymentEvent `json:"events"` + Errors []deploymentError `json:"errors"` + Security struct { + Status string `json:"status"` + BlockingCount int `json:"blockingCount"` + Findings []deploymentSecurityFinding `json:"findings"` + } `json:"security"` + ErrorMessage string `json:"errorMessage"` +} + +func lastPart(s string) string { + if i := strings.LastIndex(s, "/"); i >= 0 { + return s[i+1:] + } + return s +} + +func isTerminalDeploymentStatus(status string) bool { + switch status { + case "completed", "failed", "canceled": + return true + } + return false +} + +func fetchDeploymentLogView(ctx *cmdctx.Ctx, deploymentID string) (*deploymentLogView, error) { + raw, _, err := client.New(ctx).Get(apiPrefix+"/api/v1/deployments/"+deploymentID, nil) + if err != nil { + return nil, fmt.Errorf("fetching deployment %s: %w", deploymentID, err) + } + var view deploymentLogView + if err := decodeInto(raw, &view); err != nil { + return nil, fmt.Errorf("parsing deployment response: %w", err) + } + return &view, nil +} + +func printNewEvents(events []deploymentEvent, from int) int { + for _, e := range events[from:] { + fmt.Printf("[%s] %-8s %s\n", e.StageKey, strings.ToUpper(e.Level), e.Message) + } + return len(events) +} + +func printDeploymentSummary(view *deploymentLogView) { + if len(view.Errors) > 0 { + fmt.Println("\nErrors:") + for _, e := range view.Errors { + fmt.Printf(" [%s/%s] %s\n", e.StageKey, e.WorkloadName, e.Message) + if e.Remediation != "" { + fmt.Printf(" remediation: %s\n", e.Remediation) + } + } + } + if len(view.Security.Findings) > 0 { + fmt.Printf("\nSecurity (%s, %d blocking):\n", view.Security.Status, view.Security.BlockingCount) + for _, f := range view.Security.Findings { + marker := "" + if f.Blocking { + marker = " [BLOCKING]" + } + fmt.Printf(" [%s]%s %s\n", f.Severity, marker, f.Title) + if f.Remediation != "" { + fmt.Printf(" remediation: %s\n", f.Remediation) + } + } + } + if view.ErrorMessage != "" { + fmt.Printf("\nerror: %s\n", view.ErrorMessage) + } + fmt.Printf("\nstatus: %s\n", view.Status) +} + +// getVibeappDeploymentLogWorkflow implements "get vibeapp_deployment:log / +// [--follow]": fetches (or, with --follow, polls and streams) the deployment's events step +// log, errors, and security findings — the single payload with everything needed to debug a +// failed or running deployment. +func getVibeappDeploymentLogWorkflow(ctx *cmdctx.Ctx) error { + deploymentID := lastPart(ctx.Id) + if deploymentID == "" { + return fmt.Errorf("expected / or ") + } + + follow := cmdctx.GetBool(ctx.FlagValues, "follow") + + view, err := fetchDeploymentLogView(ctx, deploymentID) + if err != nil { + return err + } + printed := printNewEvents(view.Events, 0) + + if !follow || isTerminalDeploymentStatus(view.Status) { + printDeploymentSummary(view) + return nil + } + + ticker := time.NewTicker(deploymentLogPollInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Context.Done(): + return fmt.Errorf("canceled while following deployment %s (last status: %s)", deploymentID, view.Status) + case <-ticker.C: + } + + view, err = fetchDeploymentLogView(ctx, deploymentID) + if err != nil { + return err + } + printed = printNewEvents(view.Events, printed) + if isTerminalDeploymentStatus(view.Status) { + printDeploymentSummary(view) + return nil + } + } +} + +// vibeappDeployFollowFn is the follow_fn for "execute vibeapp:deploy --follow": it extracts +// the newly-triggered deployment's id from the response and streams its log to completion. +func vibeappDeployFollowFn(ctx *cmdctx.Ctx, result any) error { + m := asMap(result) + deploymentID, _ := m["id"].(string) + if deploymentID == "" { + return fmt.Errorf("--follow: could not extract deployment ID from response") + } + fmt.Fprintln(os.Stderr, "\nFollowing deployment log ...") + + followCtx := *ctx + fv := make(map[string]any, len(ctx.FlagValues)+1) + for k, v := range ctx.FlagValues { + fv[k] = v + } + fv["follow"] = true + followCtx.FlagValues = fv + followCtx.Id = deploymentID + return getVibeappDeploymentLogWorkflow(&followCtx) +} diff --git a/modules/vibeapps/pull_vibeapp.go b/modules/vibeapps/pull_vibeapp.go new file mode 100644 index 0000000..33d828f --- /dev/null +++ b/modules/vibeapps/pull_vibeapp.go @@ -0,0 +1,103 @@ +// Copyright © 2026 Harness Inc. +// SPDX-License-Identifier: Apache-2.0 + +package vibeapps + +import ( + "encoding/base64" + "fmt" + "net/url" + "os" + "os/exec" + "strings" + + "github.com/harness/cli/pkg/client" + "github.com/harness/cli/pkg/cmdctx" + "github.com/harness/cli/pkg/hlog" +) + +const pullVibeappWorkflowID = "pull_vibeapp" + +// requireGit checks that the git binary is installed and on the PATH. +func requireGit() error { + if _, err := exec.LookPath("git"); err != nil { + return fmt.Errorf("git is required for this command but was not found on your PATH; install git and try again") + } + return nil +} + +// gitCredentialEnv builds the env vars that inject HTTPS basic auth (email:pat) for +// host into a git subprocess, without ever touching argv, ~/.gitconfig, or the URL. +func gitCredentialEnv(host, email, pat string) []string { + basic := base64.StdEncoding.EncodeToString([]byte(email + ":" + pat)) + return []string{ + "GIT_CONFIG_COUNT=1", + "GIT_CONFIG_KEY_0=http.https://" + host + "/.extraheader", + "GIT_CONFIG_VALUE_0=Authorization: Basic " + basic, + } +} + +func gitHost(rawURL string) (string, error) { + u, err := url.Parse(rawURL) + if err != nil || u.Host == "" { + return "", fmt.Errorf("could not parse host from git URL %q", rawURL) + } + return u.Host, nil +} + +// runGitCommand execs git with args, streaming stdio through, injecting PAT +// credentials scoped to credURL's host. +func runGitCommand(cc *cmdctx.Ctx, credURL string, args ...string) error { + env := append(os.Environ(), "GIT_TERMINAL_PROMPT=0") + if cc.Auth.PATToken == "" { + return fmt.Errorf("this operation requires PAT-based auth (email + PAT); the current auth session has no PAT") + } + host, err := gitHost(credURL) + if err != nil { + return err + } + env = append(env, gitCredentialEnv(host, cc.Auth.Email, cc.Auth.PATToken)...) + + hlog.Debug("git " + strings.Join(args, " ")) + + cmd := exec.CommandContext(cc.Context, "git", args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Env = env + if err := cmd.Run(); err != nil { + return fmt.Errorf("git %s: %w", strings.Join(args, " "), err) + } + return nil +} + +// appRepoURL fetches the backing repo's clone URL for a Vibe App. +func appRepoURL(ctx *cmdctx.Ctx, appID string) (string, error) { + raw, _, err := client.New(ctx).Get(apiPrefix+"/api/v1/apps/"+appID, nil) + if err != nil { + return "", fmt.Errorf("fetching app %q: %w", appID, err) + } + m := asMap(raw) + repoURL, _ := m["repoUrl"].(string) + if repoURL == "" { + return "", fmt.Errorf("app %q has no repoUrl in response", appID) + } + return repoURL, nil +} + +// pullVibeappWorkflow implements "pull vibeapp []" (git clone). +func pullVibeappWorkflow(ctx *cmdctx.Ctx) error { + if err := requireGit(); err != nil { + return err + } + + repoURL, err := appRepoURL(ctx, ctx.Id) + if err != nil { + return err + } + + args := []string{"clone", repoURL} + if len(ctx.Args) > 0 { + args = append(args, ctx.Args[0]) + } + return runGitCommand(ctx, repoURL, args...) +} diff --git a/modules/vibeapps/push_source.go b/modules/vibeapps/push_source.go new file mode 100644 index 0000000..46312a3 --- /dev/null +++ b/modules/vibeapps/push_source.go @@ -0,0 +1,252 @@ +// Copyright © 2026 Harness Inc. +// SPDX-License-Identifier: Apache-2.0 + +package vibeapps + +import ( + "crypto/md5" + "encoding/hex" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "time" + + "github.com/harness/cli/pkg/client" + "github.com/harness/cli/pkg/cmdctx" +) + +const pushVibeappSourceWorkflowID = "push_vibeapp_source" + +const sourcePollInterval = 2 * time.Second + +type createSourceResponse struct { + Source struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + } `json:"source"` + Upload struct { + UploadID string `json:"uploadId"` + Files []uploadFileTarget `json:"files"` + } `json:"upload"` +} + +type uploadFileTarget struct { + Path string `json:"path"` + ObjectPath string `json:"objectPath"` + UploadURL string `json:"uploadUrl"` + Method string `json:"method"` + Headers map[string]string `json:"headers"` +} + +type sourceStatusResponse struct { + ID string `json:"id"` + Status string `json:"status"` + StatusDetail string `json:"statusDetail"` +} + +// pushVibeappSourceWorkflow implements "push vibeapp_source ": creates a +// source (type upload), PUTs the zip's bytes to the returned upload target(s), then +// polls until the source status is "ready". +func pushVibeappSourceWorkflow(ctx *cmdctx.Ctx) error { + if len(ctx.Args) == 0 { + return fmt.Errorf("push vibeapp_source requires a local zip path") + } + localFile := ctx.Args[0] + + f, err := os.Open(localFile) + if err != nil { + return fmt.Errorf("opening %q: %w", localFile, err) + } + defer f.Close() + + fi, err := f.Stat() + if err != nil { + return fmt.Errorf("stat %q: %w", localFile, err) + } + + sum := md5.New() + if _, err := io.Copy(sum, f); err != nil { + return fmt.Errorf("reading %q: %w", localFile, err) + } + md5Hex := hex.EncodeToString(sum.Sum(nil)) + + name := cmdctx.GetString(ctx.FlagValues, "name") + if name == "" { + base := filepath.Base(localFile) + name = strings.TrimSuffix(base, filepath.Ext(base)) + } + appID := cmdctx.GetString(ctx.FlagValues, "app") + description := cmdctx.GetString(ctx.FlagValues, "description") + + body := map[string]any{ + "name": name, + "source": map[string]any{ + "type": "upload", + "files": []map[string]any{ + { + "path": filepath.Base(localFile), + "sizeBytes": fi.Size(), + "contentType": "application/zip", + "md5": md5Hex, + }, + }, + }, + } + if description != "" { + body["description"] = description + } + + path := fmt.Sprintf(apiPrefix+"/api/v1/spaces/%s/sources", sentinelSpaceID) + if appID != "" { + path = fmt.Sprintf(apiPrefix+"/api/v1/apps/%s/sources", appID) + } + + fmt.Fprintf(os.Stderr, "Creating source %q (%s) ...\n", name, formatBytes(fi.Size())) + raw, _, err := client.New(ctx).Post(path, nil, body) + if err != nil { + return fmt.Errorf("creating source: %w", err) + } + var created createSourceResponse + if err := decodeInto(raw, &created); err != nil { + return fmt.Errorf("parsing create-source response: %w", err) + } + if created.Source.ID == "" { + return fmt.Errorf("create-source response had no source id") + } + + for _, target := range created.Upload.Files { + fmt.Fprintf(os.Stderr, "Uploading %s ...\n", target.Path) + if err := putUploadFile(ctx, target, localFile); err != nil { + return fmt.Errorf("uploading %s: %w", target.Path, err) + } + } + + fmt.Fprintln(os.Stderr, "Waiting for source to become ready ...") + status, err := pollSourceReady(ctx, created.Source.ID) + if err != nil { + return err + } + + fmt.Printf("\nSource ready: %s (%s)\n", status.ID, status.Status) + if appID != "" { + fmt.Printf("Intaken as a new version on app %s.\n", appID) + } else { + fmt.Printf("\nTo create an app from it:\nharness create vibeapp %s --source-id %s\n", name, status.ID) + } + return nil +} + +func putUploadFile(ctx *cmdctx.Ctx, target uploadFileTarget, localFile string) error { + f, err := os.Open(localFile) + if err != nil { + return fmt.Errorf("opening %q: %w", localFile, err) + } + defer f.Close() + fi, err := f.Stat() + if err != nil { + return fmt.Errorf("stat %q: %w", localFile, err) + } + + method := target.Method + if method == "" { + method = http.MethodPut + } + req, err := http.NewRequestWithContext(ctx.Context, method, target.UploadURL, f) + if err != nil { + return fmt.Errorf("building upload request: %w", err) + } + req.ContentLength = fi.Size() + for k, v := range target.Headers { + req.Header.Set(k, v) + } + if req.Header.Get("Content-Type") == "" { + req.Header.Set("Content-Type", "application/zip") + } + // The upload target's own headers (or a GCS pre-signed query string) already carry + // whatever auth that URL needs; only add ours when the URL points back at our own API + // host, since GCS would reject an unsigned extra header on a signed URL. + if sameHost(target.UploadURL, ctx.Auth.APIUrl) { + ctx.Auth.SetAuthHeader(req) + } + + httpClient := &http.Client{Timeout: 10 * time.Minute} + resp, err := httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + respBody, _ := io.ReadAll(resp.Body) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(respBody))) + } + return nil +} + +func sameHost(rawURL, apiURL string) bool { + u, err := url.Parse(rawURL) + if err != nil { + return false + } + a, err := url.Parse(apiURL) + if err != nil { + return false + } + return u.Host == a.Host +} + +// pollSourceReady polls GET /api/v1/sources/{id} until the source reaches a terminal +// status ("ready" or "failed"), or the command context is canceled. +func pollSourceReady(ctx *cmdctx.Ctx, sourceID string) (*sourceStatusResponse, error) { + ticker := time.NewTicker(sourcePollInterval) + defer ticker.Stop() + + var previous string + for { + raw, _, err := client.New(ctx).Get(apiPrefix+"/api/v1/sources/"+sourceID, nil) + if err != nil { + return nil, fmt.Errorf("polling source %s: %w", sourceID, err) + } + var status sourceStatusResponse + if err := decodeInto(raw, &status); err != nil { + return nil, fmt.Errorf("parsing source status: %w", err) + } + if status.Status != previous { + fmt.Fprintf(os.Stderr, " status: %s\n", status.Status) + previous = status.Status + } + switch status.Status { + case "ready": + return &status, nil + case "failed": + detail := status.StatusDetail + if detail == "" { + detail = "source intake failed" + } + return nil, fmt.Errorf("source %s failed: %s", sourceID, detail) + } + + select { + case <-ctx.Context.Done(): + return nil, fmt.Errorf("canceled while waiting for source %s to become ready (last status: %s)", sourceID, status.Status) + case <-ticker.C: + } + } +} + +func formatBytes(n int64) string { + const unit = 1024 + if n < unit { + return fmt.Sprintf("%d B", n) + } + div, exp := int64(unit), 0 + for m := n / unit; m >= unit; m /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp]) +} diff --git a/modules/vibeapps/util.go b/modules/vibeapps/util.go new file mode 100644 index 0000000..aa4c7bd --- /dev/null +++ b/modules/vibeapps/util.go @@ -0,0 +1,29 @@ +// Copyright © 2026 Harness Inc. +// SPDX-License-Identifier: Apache-2.0 + +package vibeapps + +import "encoding/json" + +// sentinelSpaceID is the single hardcoded default space UUID the vibe-orchestrator +// API uses server-side today (no multi-space concept exists yet). +const sentinelSpaceID = "00000000-0000-0000-0000-000000000001" + +// apiPrefix is the vibe-orchestrator service's mount point ahead of its own +// /api/v1 routes, matching the paths used in pkg/spec/vibeapps.spec.yaml. +const apiPrefix = "/vibe-orchestrator" + +// decodeInto re-marshals a generically-decoded API response (map[string]any, as +// returned by pkg/client) into a concrete struct, so callers get typed field access. +func decodeInto(raw any, out any) error { + b, err := json.Marshal(raw) + if err != nil { + return err + } + return json.Unmarshal(b, out) +} + +func asMap(v any) map[string]any { + m, _ := v.(map[string]any) + return m +} diff --git a/modules/vibeapps/vibeapps.go b/modules/vibeapps/vibeapps.go new file mode 100644 index 0000000..f448920 --- /dev/null +++ b/modules/vibeapps/vibeapps.go @@ -0,0 +1,13 @@ +// Copyright © 2026 Harness Inc. +// SPDX-License-Identifier: Apache-2.0 + +package vibeapps + +import "github.com/harness/cli/pkg/registry" + +func ModuleInit(reg registry.ModuleRegistrar) { + reg.RegisterWorkflow(pushVibeappSourceWorkflowID, pushVibeappSourceWorkflow) + reg.RegisterWorkflow(pullVibeappWorkflowID, pullVibeappWorkflow) + reg.RegisterWorkflow(getVibeappDeploymentLogWorkflowID, getVibeappDeploymentLogWorkflow) + reg.RegisterFollowFn(vibeappDeployFollowFnID, vibeappDeployFollowFn) +} diff --git a/pkg/spec/vibeapps.spec.yaml b/pkg/spec/vibeapps.spec.yaml new file mode 100644 index 0000000..e7fa733 --- /dev/null +++ b/pkg/spec/vibeapps.spec.yaml @@ -0,0 +1,533 @@ +spec_version: 1 +module_type: builtin +module_desc: Harness Vibe Apps — AI-generated micro-apps built and deployed via the Launchpad +harness_internal: true +help_text: | + ## Vibe Apps (vibeapps) + + Vibe Apps are AI-generated micro-apps built and deployed through the Harness + Launchpad. Each app is backed by a managed Git repo and gets a preview URL; + approved apps can also be published to a production URL. + + ### Nouns + + {{nouns}} + +nouns: + - noun: vibeapp + short_desc: A Harness Vibe App built and deployed via the Launchpad. + noun_aliases: [vibeapps] + fields: + - id: id + label: ID + expr: it.id + - id: name + expr: it.name + mutable_path: name + - id: slug + expr: it.slug + - id: description + expr: it.description ?? "" + mutable_path: description + - id: status + expr: it.status + - id: owner + expr: it.owner ?? "" + mutable_path: owner + - id: team + expr: it.team ?? "" + mutable_path: team + - id: access_level + label: Access + expr: it.accessLevel ?? "" + mutable_path: accessLevel + - id: latest_deployment_status + label: Latest Deployment + expr: it.latestDeploymentStatus ?? "" + - id: latest_deployment_id + label: Latest Deployment ID + expr: it.latestDeploymentId ?? "" + - id: approval_status + label: Approval + expr: it.latestDeployment.approvalStatus ?? "" + - id: approval_gate + label: Approval Gate + expr: it.latestDeployment.approvalGate ?? "" + - id: blocking_findings + label: Blocking Findings + expr: string(it.latestDeployment.blockingFindings ?? 0) + align: right + - id: live_url + label: Live URL + expr: it.liveUrl ?? "" + - id: preview_url + label: Preview URL + expr: it.latestDeployment.previewUrl ?? "" + - id: production_url + label: Production URL + expr: it.latestDeployment.productionUrl ?? "" + - id: repo_url + label: Repo URL + expr: it.repoUrl ?? "" + - id: preferred_url + label: Preferred URL + expr: it.preferredUrl ?? "" + mutable_path: preferredUrl + - id: policy_profile_id + label: Policy Profile + expr: it.policyProfileId ?? "" + mutable_path: policyProfileId + - id: error_message + label: Error + expr: it.latestDeployment.errorMessage ?? "" + - id: created + expr: it.createdAt + - id: updated + expr: it.updatedAt + + - noun: vibeapp_deployment + short_desc: A single triggered run ("deployment") of a Vibe App against a pinned source commit. + noun_aliases: [vibeapp_deployments, vibedeploy] + fields: + - id: id + label: ID + expr: it.id + - id: app_id + label: App ID + expr: it.appId ?? "" + - id: status + expr: it.status + - id: source_version + label: Source Version + expr: it.sourceVersion ?? "" + - id: workflow_name + label: Workflow + expr: it.workflowName ?? "" + - id: preview_url + label: Preview URL + expr: it.links.previewUrl ?? "" + - id: production_url + label: Production URL + expr: it.links.productionUrl ?? "" + - id: approval_status + label: Approval + expr: it.approval.status ?? "" + - id: approval_gate + label: Approval Gate + expr: it.approval.gate ?? "" + - id: security_status + label: Security + expr: it.security.status ?? "" + - id: blocking_findings + label: Blocking Findings + expr: string(it.security.blockingCount ?? 0) + align: right + - id: error_message + label: Error + expr: it.errorMessage ?? "" + - id: created + expr: it.createdAt + - id: updated + expr: it.updatedAt + + - noun: vibeapp_approval + short_desc: A human gate decision (production publish gate) tied to a Vibe App deployment. + noun_aliases: [vibeapp_approvals] + fields: + - id: id + label: ID + expr: it.id + - id: app_id + label: App ID + expr: it.appId ?? "" + - id: app_name + label: App + expr: it.appName ?? "" + - id: deployment_id + label: Deployment ID + expr: it.deploymentId ?? "" + - id: gate + expr: it.gate ?? "" + - id: status + expr: it.status + - id: note + expr: it.note ?? "" + - id: decided_by + label: Decided By + expr: it.decidedBy ?? "" + - id: preview_url + label: Preview URL + expr: it.previewUrl ?? "" + - id: production_url + label: Production URL + expr: it.productionUrl ?? "" + - id: created + expr: it.createdAt + - id: decided + expr: it.decidedAt ?? "" + +commands: + # ── vibeapp ─────────────────────────────────────────────────────────────────── + + - command: list vibeapp + verb: list + noun: vibeapp + short: List Vibe Apps + handler_type: endpoint + endpoint: + # space_id is a single hardcoded default UUID server-side today (no multi-space + # concept exists yet) — not exposed as a flag, per the vibe-orchestrator API doc. + path: /vibe-orchestrator/api/v1/spaces/00000000-0000-0000-0000-000000000001/apps + items_expr: it.apps + get_id_expr: it.id + completion: + id_expr: it.id + name_expr: it.name + paging: + paging_strategy: flat_list + columns: + [ + name, + status, + latest_deployment_status, + approval_status, + preview_url, + live_url, + created, + ] + + - command: get vibeapp + verb: get + noun: vibeapp + short: Get a Vibe App by ID + handler_type: endpoint + endpoint: + path: /vibe-orchestrator/api/v1/apps/{{ctx.id}} + item_expr: it + yaml_pick_expr: it + + - command: create vibeapp + verb: create + noun: vibeapp + requires_id: true + short: "Create a Vibe App from a ready source: harness create vibeapp --source-id " + long: | + Creates a Vibe App bound to an already-`ready` source (see 'harness push vibeapp_source' + to upload a zip and obtain a source ID first). + + harness create vibeapp my-app --source-id + handler_type: endpoint + flags: + - name: source-id + description: ID of the ready source to build the app from (see 'harness push vibeapp_source') + required: true + flags_builtin: + set: true + endpoint: + method: POST + # space_id is a single hardcoded default UUID server-side today; see 'list vibeapp'. + path: /vibe-orchestrator/api/v1/spaces/00000000-0000-0000-0000-000000000001/apps + create_strategy: set-fields + create_body_init: + name: ctx.id + sourceId: flags["source-id"] + create_body_wrap: "" + item_expr: it + text_header: "\nCreated Vibe App {{it.name}} ({{it.id}})\n" + text_footer: "\nTo deploy: harness execute vibeapp:deploy {{it.id}}\n" + + - command: update vibeapp + verb: update + noun: vibeapp + short: "Update a Vibe App: harness update vibeapp --set description=... owner=..." + handler_type: endpoint + flags_builtin: + set: true + del: true + endpoint: + method: PATCH + path: /vibe-orchestrator/api/v1/apps/{{ctx.id}} + get_path: /vibe-orchestrator/api/v1/apps/{{ctx.id}} + update_strategy: get-then-patch + update_body_pick: it + update_body_wrap: "" + item_expr: it + text_header: "\nUpdated Vibe App {{ctx.id}}\n" + + - command: delete vibeapp + verb: delete + noun: vibeapp + confirm_mode: prompt + short: "Delete a Vibe App (cascades approvals, events, deployments, workloads, sources)" + handler_type: endpoint + endpoint: + method: DELETE + path: /vibe-orchestrator/api/v1/apps/{{ctx.id}} + text_header: "\nDeleted Vibe App {{ctx.id}}\n" + + - command: execute vibeapp:archive + verb: execute + noun: vibeapp + noun_variant: archive + short: Soft-archive a Vibe App + handler_type: endpoint + endpoint: + method: POST + path: /vibe-orchestrator/api/v1/apps/{{ctx.id}}/archive + item_expr: it + text_header: "\nArchived Vibe App {{ctx.id}}\n" + + - command: execute vibeapp:restore + verb: execute + noun: vibeapp + noun_variant: restore + short: Un-archive a Vibe App + handler_type: endpoint + endpoint: + method: POST + path: /vibe-orchestrator/api/v1/apps/{{ctx.id}}/restore + item_expr: it + text_header: "\nRestored Vibe App {{ctx.id}}\n" + + - command: execute vibeapp:deploy + verb: execute + noun: vibeapp + noun_variant: deploy + fields_noun: vibeapp_deployment + short: "Trigger a deployment for a Vibe App: harness execute vibeapp:deploy [--source-version ] [--workflow ]" + long: | + Triggers a deployment (run/preview) for a Vibe App from its latest ready source + commit, or a pinned commit via --source-version. Requires a token (PAT/SAT/SSO) — + local dev with no credentials falls back to the server's own configured token, but + real CLI calls must be authenticated. + handler_type: endpoint + follow_fn: vibeapp_deploy_follow + flags: + - name: source-version + description: "Pin the deployment to a specific source commit (default: latest ready commit)" + - name: workflow + description: 'Workflow name to run (default: "platform-default")' + - name: follow + is_bool: true + description: Stream deployment events live after triggering; exits when the deployment reaches a terminal state + endpoint: + method: POST + path: /vibe-orchestrator/api/v1/apps/{{ctx.id}}/deployments + body_params: + sourceVersion: flags["source-version"] + workflowName: flags.workflow + item_expr: it + text_header: "\nDeployment triggered for Vibe App {{ctx.id}}\n" + text_footer: | + + To follow: + harness get vibeapp_deployment {{ctx.id}}/{{it.id}} + harness get vibeapp_deployment:log {{ctx.id}}/{{it.id}} --follow + + # ── vibeapp_deployment ────────────────────────────────────────────────────────── + + - command: list vibeapp_deployment + verb: list + noun: vibeapp_deployment + short: "List deployments for a Vibe App: harness list vibeapp_deployment " + requires_parentid: true + parentid_label: "" + completion_noun: vibeapp + handler_type: endpoint + endpoint: + path: /vibe-orchestrator/api/v1/apps/{{ctx.parentId}}/deployments + items_expr: it.deployments + get_id_expr: ctx.parentId + "/" + it.id + completion: + id_expr: it.id + name_expr: it.status + " " + (it.sourceVersion ?? "") + paging: + paging_strategy: flat_list + columns: + [ + id, + status, + source_version, + approval_status, + preview_url, + production_url, + created, + ] + + - command: get vibeapp_deployment + verb: get + noun: vibeapp_deployment + short: "Get a Vibe App deployment (full stage breakdown, events, security, approval): harness get vibeapp_deployment /" + id_allow_slash: true + id_label: "/" + completion_seq: + - completion_noun: vibeapp + - completion_noun: vibeapp_deployment + keep_order: true + handler_type: endpoint + endpoint: + path: /vibe-orchestrator/api/v1/deployments/{{lastPart(ctx.id)}} + item_expr: it + yaml_pick_expr: it + + - command: execute vibeapp_deployment:cancel + verb: execute + noun: vibeapp_deployment + noun_variant: cancel + short: "Cancel an in-flight Vibe App deployment: harness execute vibeapp_deployment:cancel /" + id_allow_slash: true + id_label: "/" + completion_seq: + - completion_noun: vibeapp + - completion_noun: vibeapp_deployment + keep_order: true + handler_type: endpoint + endpoint: + method: POST + path: /vibe-orchestrator/api/v1/deployments/{{lastPart(ctx.id)}}/cancel + item_expr: it + text_header: "\nCanceled deployment {{lastPart(ctx.id)}}\n" + + - command: execute vibeapp_deployment:redeploy + verb: execute + noun: vibeapp_deployment + noun_variant: redeploy + short: "Re-run a historical deployment's pinned source version as a new deployment (rollback): harness execute vibeapp_deployment:redeploy /" + id_allow_slash: true + id_label: "/" + completion_seq: + - completion_noun: vibeapp + - completion_noun: vibeapp_deployment + keep_order: true + handler_type: endpoint + endpoint: + method: POST + path: /vibe-orchestrator/api/v1/deployments/{{lastPart(ctx.id)}}/redeploy + item_expr: it + text_header: "\nRedeployed from {{lastPart(ctx.id)}}\n" + + - command: execute vibeapp_deployment:decision + verb: execute + noun: vibeapp_deployment + noun_variant: decision + short: "Approve, reject, or request changes on a pending publish gate: harness execute vibeapp_deployment:decision / --decision approved" + id_allow_slash: true + id_label: "/" + completion_seq: + - completion_noun: vibeapp + - completion_noun: vibeapp_deployment + keep_order: true + handler_type: endpoint + flags: + - name: decision + description: "One of: approved, rejected, changes_requested" + required: true + completion_values: [approved, rejected, changes_requested] + - name: note + description: Optional note explaining the decision + - name: decided-by + description: Optional name/identifier of the decider + endpoint: + method: POST + path: /vibe-orchestrator/api/v1/deployments/{{lastPart(ctx.id)}}/decision + body_params: + decision: flags.decision + note: flags.note + decidedBy: flags["decided-by"] + item_expr: it + text_header: "\nRecorded decision {{flags.decision}} for deployment {{lastPart(ctx.id)}}\n" + + - command: get vibeapp_deployment:log + verb: get + noun: vibeapp_deployment + noun_variant: log + short: "Tail the step log (events) of a Vibe App deployment for debugging: harness get vibeapp_deployment:log / [--follow]" + long: | + Fetches (or, with --follow, polls and streams) the `events` step log, `errors[]` + remediation hints, and security findings for a deployment — the single payload + with everything needed to diagnose a failed or running run. + id_allow_slash: true + id_label: "/" + completion_seq: + - completion_noun: vibeapp + - completion_noun: vibeapp_deployment + keep_order: true + handler_type: workflow + workflow_id: get_vibeapp_deployment_log + flags: + - name: follow + is_bool: true + description: Poll and stream new events until the deployment reaches a terminal state + + # ── vibeapp_approval ──────────────────────────────────────────────────────────── + + - command: list vibeapp_approval + verb: list + noun: vibeapp_approval + short: List every gate decision across the space, newest first + handler_type: endpoint + endpoint: + # space_id is a single hardcoded default UUID server-side today; see 'list vibeapp'. + path: /vibe-orchestrator/api/v1/spaces/00000000-0000-0000-0000-000000000001/approvals + items_expr: it.approvals + get_id_expr: it.id + paging: + paging_strategy: flat_list + columns: + [ + app_name, + gate, + status, + decided_by, + preview_url, + production_url, + created, + ] + + # ── vibeapp_source ────────────────────────────────────────────────────────────── + + - command: push vibeapp_source + verb: push + noun: vibeapp_source + no_id: true + short: "Upload a local zip as a new Vibe App source: harness push vibeapp_source [--name ] [--app ]" + long: | + Uploads a local zip file as a new source lineage: creates the source, PUTs the + zip's bytes to the returned upload target(s), then polls until the source status + is "ready". Prints the resulting source ID for use with 'harness create vibeapp + --source-id ' (new app) or pass --app to intake it as a new version on + an existing app instead. + has_args: true + args_label: "" + handler_type: workflow + workflow_id: push_vibeapp_source + flags: + - name: name + description: "Source name (default: the zip's base filename without extension)" + - name: app + description: Intake as a new source version for this existing app ID, instead of creating a standalone source + completion_noun: vibeapp + - name: description + description: Optional description for the source + + - command: get vibeapp_source + verb: get + noun: vibeapp_source + short: "Poll a source's intake/publish status: harness get vibeapp_source " + handler_type: endpoint + endpoint: + path: /vibe-orchestrator/api/v1/sources/{{ctx.id}} + item_expr: it + yaml_pick_expr: it + + # ── vibeapp checkout ──────────────────────────────────────────────────────────── + + - command: pull vibeapp + verb: pull + noun: vibeapp + short: "Clone a Vibe App's backing repo: harness pull vibeapp []" + handler_type: workflow + workflow_id: pull_vibeapp + has_args: true + args_label: "[]" + completion_noun: vibeapp From 236d1da40c4e48dcc959b9b0a5fe09da96ad25d5 Mon Sep 17 00:00:00 2001 From: Mike Sawka Date: Thu, 3 Sep 2026 13:52:47 -0700 Subject: [PATCH 2/6] add ids --- pkg/spec/vibeapps.spec.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pkg/spec/vibeapps.spec.yaml b/pkg/spec/vibeapps.spec.yaml index e7fa733..a894fbb 100644 --- a/pkg/spec/vibeapps.spec.yaml +++ b/pkg/spec/vibeapps.spec.yaml @@ -187,6 +187,7 @@ commands: paging_strategy: flat_list columns: [ + id, name, status, latest_deployment_status, @@ -475,6 +476,7 @@ commands: paging_strategy: flat_list columns: [ + id, app_name, gate, status, From 30f204d434305c0ab40312e617eb140ee29999c8 Mon Sep 17 00:00:00 2001 From: Mike Sawka Date: Thu, 3 Sep 2026 16:29:17 -0700 Subject: [PATCH 3/6] Add execute vibeapp:deploy (zip-and-push-from-cwd), rename old :deploy to :run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit execute vibeapp:deploy zips the current git working tree (respecting .gitignore, with a hard .git/.harness/node_modules exclusion), pushes it as a new source, creates or updates the linked Vibe App via a .harness/vibeapp.yaml link file, and streams the run's log — a Netlify/Vercel-style "deploy my project dir" command. The old granular trigger command is renamed to execute vibeapp:run to free up the :deploy name. pull vibeapp now writes the link file on clone so a subsequent deploy updates the same app instead of creating a duplicate. Co-Authored-By: Claude Sonnet 5 AI-Session-Id: 3a3f7693-39fd-4bca-9a2b-fd725bfd4cb5 AI-Tool: claude-code AI-Model: unknown --- modules/vibeapps/deploy.go | 389 +++++++++++++++++++++++++++++ modules/vibeapps/deployment_log.go | 25 +- modules/vibeapps/pull_vibeapp.go | 23 +- modules/vibeapps/push_source.go | 63 +++-- modules/vibeapps/vibeapps.go | 3 +- pkg/spec/vibeapps.spec.yaml | 63 ++++- 6 files changed, 522 insertions(+), 44 deletions(-) create mode 100644 modules/vibeapps/deploy.go diff --git a/modules/vibeapps/deploy.go b/modules/vibeapps/deploy.go new file mode 100644 index 0000000..5699bdb --- /dev/null +++ b/modules/vibeapps/deploy.go @@ -0,0 +1,389 @@ +// Copyright © 2026 Harness Inc. +// SPDX-License-Identifier: Apache-2.0 + +package vibeapps + +import ( + "archive/zip" + "bytes" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + + "go.yaml.in/yaml/v3" + + "github.com/harness/cli/pkg/client" + "github.com/harness/cli/pkg/cmdctx" + "github.com/harness/cli/pkg/console" +) + +const vibeappDeployWorkflowID = "vibeapp_deploy" + +const vibeappLinkFile = ".harness/vibeapp.yaml" + +// vibeappLink mirrors the .harness/vibeapp.yaml link file written by +// "execute vibeapp:deploy" and "pull vibeapp" to remember which Vibe App a +// local directory is linked to. +type vibeappLink struct { + AppID string `yaml:"app_id"` +} + +// loadVibeappLink reads root's link file. A nil, nil return means no link file exists. +func loadVibeappLink(root string) (*vibeappLink, error) { + data, err := os.ReadFile(filepath.Join(root, vibeappLinkFile)) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("reading %s: %w", vibeappLinkFile, err) + } + var link vibeappLink + if err := yaml.Unmarshal(data, &link); err != nil { + return nil, fmt.Errorf("parsing %s: %w", vibeappLinkFile, err) + } + return &link, nil +} + +// writeVibeappLink writes root's link file and best-effort ensures .harness/ is +// gitignored. Gitignore bookkeeping failures are only warned about, not fatal — +// the link file itself is what matters for future deploys. +func writeVibeappLink(root, appID string) error { + dir := filepath.Join(root, ".harness") + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("creating .harness directory: %w", err) + } + data, err := yaml.Marshal(&vibeappLink{AppID: appID}) + if err != nil { + return err + } + if err := os.WriteFile(filepath.Join(dir, "vibeapp.yaml"), data, 0o644); err != nil { + return fmt.Errorf("writing %s: %w", vibeappLinkFile, err) + } + if err := ensureHarnessGitignored(root); err != nil { + fmt.Fprintf(os.Stderr, "warning: %v\n", err) + } + return nil +} + +// ensureHarnessGitignored appends ".harness/" to root's .gitignore (creating it if +// missing) unless an existing rule already covers it, per `git check-ignore`. +func ensureHarnessGitignored(root string) error { + if gitCheckIgnore(root, ".harness") { + return nil + } + gitignorePath := filepath.Join(root, ".gitignore") + existing, err := os.ReadFile(gitignorePath) + if err != nil && !os.IsNotExist(err) { + return fmt.Errorf("reading .gitignore: %w", err) + } + content := string(existing) + if content != "" && !strings.HasSuffix(content, "\n") { + content += "\n" + } + content += ".harness/\n" + if err := os.WriteFile(gitignorePath, []byte(content), 0o644); err != nil { + return fmt.Errorf("writing .gitignore: %w", err) + } + return nil +} + +func gitCheckIgnore(root, target string) bool { + cmd := exec.Command("git", "-C", root, "check-ignore", "-q", target) + return cmd.Run() == nil +} + +// gitProjectRoot resolves the git working tree root for the current directory, so +// "execute vibeapp:deploy" packages the whole project even when run from a subdirectory. +func gitProjectRoot() (string, error) { + out, err := exec.Command("git", "rev-parse", "--show-toplevel").Output() + if err != nil { + return "", fmt.Errorf("not inside a git working tree (required for 'execute vibeapp:deploy'): %w", err) + } + return strings.TrimSpace(string(out)), nil +} + +// alwaysExcludedPaths are dropped from the deploy zip regardless of .gitignore +// contents (or absence): .git and .harness are CLI/VCS bookkeeping, not app source, +// and node_modules is a hard blacklist against oversized/misconfigured-gitignore zips. +var alwaysExcludedPaths = []string{".git/", ".harness/", "node_modules/"} + +func isAlwaysExcluded(relPath string) bool { + slashed := filepath.ToSlash(relPath) + for _, prefix := range alwaysExcludedPaths { + dir := strings.TrimSuffix(prefix, "/") + if slashed == dir || strings.HasPrefix(slashed, prefix) { + return true + } + } + return false +} + +// gitDeployFiles lists the files "execute vibeapp:deploy" should zip: tracked plus +// untracked-but-not-ignored files (so .gitignore, including nested/global excludes, +// is honored with no reimplementation of ignore logic), minus alwaysExcludedPaths. +func gitDeployFiles(root string) ([]string, error) { + out, err := exec.Command("git", "-C", root, "ls-files", "-c", "-o", "--exclude-standard").Output() + if err != nil { + return nil, fmt.Errorf("git ls-files: %w", err) + } + var files []string + for _, line := range strings.Split(string(out), "\n") { + line = strings.TrimSpace(line) + if line == "" || isAlwaysExcluded(line) { + continue + } + files = append(files, line) + } + return files, nil +} + +// zipProjectDir zips root's deployable files (per gitDeployFiles) in memory. +func zipProjectDir(root string) ([]byte, error) { + files, err := gitDeployFiles(root) + if err != nil { + return nil, err + } + if len(files) == 0 { + return nil, fmt.Errorf("no files to deploy: 'git ls-files' returned nothing under %s (everything gitignored?)", root) + } + + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + for _, rel := range files { + if err := addFileToZip(zw, root, rel); err != nil { + return nil, err + } + } + if err := zw.Close(); err != nil { + return nil, fmt.Errorf("finalizing zip: %w", err) + } + return buf.Bytes(), nil +} + +func addFileToZip(zw *zip.Writer, root, rel string) error { + fullPath := filepath.Join(root, rel) + fi, err := os.Lstat(fullPath) + if err != nil { + return fmt.Errorf("stat %q: %w", rel, err) + } + if !fi.Mode().IsRegular() { + return nil + } + w, err := zw.Create(filepath.ToSlash(rel)) + if err != nil { + return fmt.Errorf("creating zip entry %q: %w", rel, err) + } + f, err := os.Open(fullPath) + if err != nil { + return fmt.Errorf("opening %q: %w", rel, err) + } + defer f.Close() + if _, err := io.Copy(w, f); err != nil { + return fmt.Errorf("writing zip entry %q: %w", rel, err) + } + return nil +} + +type vibeappSummary struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` +} + +// getVibeappByID fetches an app by id. A nil, nil return means the app doesn't exist. +func getVibeappByID(ctx *cmdctx.Ctx, id string) (*vibeappSummary, error) { + raw, _, err := client.New(ctx).Get(apiPrefix+"/api/v1/apps/"+id, nil) + if err != nil { + if strings.Contains(err.Error(), "404") { + return nil, nil + } + return nil, fmt.Errorf("fetching app %s: %w", id, err) + } + var app vibeappSummary + if err := decodeInto(raw, &app); err != nil { + return nil, fmt.Errorf("parsing app response: %w", err) + } + return &app, nil +} + +func createVibeapp(ctx *cmdctx.Ctx, name, sourceID string) (*vibeappSummary, error) { + path := fmt.Sprintf(apiPrefix+"/api/v1/spaces/%s/apps", sentinelSpaceID) + raw, _, err := client.New(ctx).Post(path, nil, map[string]any{ + "name": name, + "sourceId": sourceID, + }) + if err != nil { + return nil, fmt.Errorf("creating app: %w", err) + } + var app vibeappSummary + if err := decodeInto(raw, &app); err != nil { + return nil, fmt.Errorf("parsing create-app response: %w", err) + } + if app.ID == "" { + return nil, fmt.Errorf("create-app response had no app id") + } + return &app, nil +} + +func triggerVibeappDeployment(ctx *cmdctx.Ctx, appID, workflowName string) (string, error) { + body := map[string]any{} + if workflowName != "" { + body["workflowName"] = workflowName + } + raw, _, err := client.New(ctx).Post(apiPrefix+"/api/v1/apps/"+appID+"/deployments", nil, body) + if err != nil { + return "", fmt.Errorf("triggering deployment: %w", err) + } + deploymentID, _ := asMap(raw)["id"].(string) + if deploymentID == "" { + return "", fmt.Errorf("trigger-deployment response had no deployment id") + } + return deploymentID, nil +} + +// resolveDeployTargetApp implements the id-vs-link-file overwrite-semantics table for +// "execute vibeapp:deploy". Returns the app id to push a new source version to, or "" +// if a brand-new app should be created (no id given and no live linked app found). +// For the "adopt an explicitly-passed existing id" case, it also writes the link file +// on confirmation — same as the create-new-app path, linking happens as soon as the +// app's identity for this directory is settled, independent of what happens next. +func resolveDeployTargetApp(ctx *cmdctx.Ctx, root, explicitID string, link *vibeappLink, force bool) (string, error) { + switch { + case explicitID != "" && link == nil: + app, err := getVibeappByID(ctx, explicitID) + if err != nil { + return "", err + } + if app == nil { + return "", fmt.Errorf("Vibe App %s not found", explicitID) + } + if !force { + question := fmt.Sprintf("Vibe App %s (%s) already exists — deploying will push a new version and trigger a run. Continue?", app.ID, app.Name) + if !console.PromptYesNo(question) { + return "", fmt.Errorf("canceled") + } + } + if err := writeVibeappLink(root, app.ID); err != nil { + return "", err + } + return app.ID, nil + + case explicitID != "" && link != nil: + if explicitID != link.AppID { + return "", fmt.Errorf("this directory is linked to Vibe App %s (%s), which differs from the id passed (%s); remove %s if you meant to switch apps", link.AppID, vibeappLinkFile, explicitID, vibeappLinkFile) + } + return explicitID, nil + + case explicitID == "" && link != nil: + app, err := getVibeappByID(ctx, link.AppID) + if err != nil { + return "", err + } + if app == nil { + // The linked app is gone server-side, but the link file is still the + // strongest signal this directory owns that app slot: create a new app + // rather than erroring, and the caller overwrites the link with its id. + return "", nil + } + return app.ID, nil + + default: // explicitID == "" && link == nil + return "", nil + } +} + +// vibeappDeployWorkflow implements "execute vibeapp:deploy [] [--force] [--no-follow] +// [--workflow ]": zips the current git working tree, pushes it as a new source, +// creates or updates the linked Vibe App, and triggers a run. +func vibeappDeployWorkflow(ctx *cmdctx.Ctx) error { + if err := requireGit(); err != nil { + return err + } + root, err := gitProjectRoot() + if err != nil { + return err + } + + force := cmdctx.GetBool(ctx.FlagValues, "force") + noFollow := cmdctx.GetBool(ctx.FlagValues, "no-follow") + workflowName := cmdctx.GetString(ctx.FlagValues, "workflow") + + link, err := loadVibeappLink(root) + if err != nil { + return err + } + + appID, err := resolveDeployTargetApp(ctx, root, ctx.Id, link, force) + if err != nil { + return err + } + + zipData, err := zipProjectDir(root) + if err != nil { + return err + } + tmpPath, err := writeTempZip(zipData) + if err != nil { + return err + } + defer os.Remove(tmpPath) + + name := filepath.Base(root) + + if appID == "" { + fmt.Fprintln(os.Stderr, "No linked app found — creating a new Vibe App...") + status, err := createAndUploadSource(ctx, tmpPath, name, "", "") + if err != nil { + return err + } + app, err := createVibeapp(ctx, name, status.ID) + if err != nil { + return err + } + appID = app.ID + fmt.Fprintf(os.Stderr, "Created Vibe App %s (%s)\n", app.Name, app.ID) + if err := writeVibeappLink(root, appID); err != nil { + return err + } + } else { + if _, err := createAndUploadSource(ctx, tmpPath, name, appID, ""); err != nil { + return err + } + } + + deploymentID, err := triggerVibeappDeployment(ctx, appID, workflowName) + if err != nil { + return err + } + fmt.Printf("\nDeployment triggered for Vibe App %s (%s)\n", appID, deploymentID) + + if noFollow { + fmt.Printf("\nTo follow:\nharness get vibeapp_deployment %s/%s\nharness get vibeapp_deployment:log %s/%s --follow\n", appID, deploymentID, appID, deploymentID) + return nil + } + + fmt.Fprintln(os.Stderr, "\nFollowing deployment log ...") + view, err := streamDeploymentLog(ctx, deploymentID, true) + if err != nil { + return err + } + if view.Status == "failed" { + return fmt.Errorf("deployment %s failed", deploymentID) + } + return nil +} + +func writeTempZip(zipData []byte) (string, error) { + tmp, err := os.CreateTemp("", "vibeapp-deploy-*.zip") + if err != nil { + return "", fmt.Errorf("creating temp zip: %w", err) + } + defer tmp.Close() + if _, err := tmp.Write(zipData); err != nil { + return "", fmt.Errorf("writing temp zip: %w", err) + } + return tmp.Name(), nil +} diff --git a/modules/vibeapps/deployment_log.go b/modules/vibeapps/deployment_log.go index 761401d..b100838 100644 --- a/modules/vibeapps/deployment_log.go +++ b/modules/vibeapps/deployment_log.go @@ -14,7 +14,7 @@ import ( ) const getVibeappDeploymentLogWorkflowID = "get_vibeapp_deployment_log" -const vibeappDeployFollowFnID = "vibeapp_deploy_follow" +const vibeappRunFollowFnID = "vibeapp_run_follow" const deploymentLogPollInterval = 3 * time.Second @@ -126,16 +126,25 @@ func getVibeappDeploymentLogWorkflow(ctx *cmdctx.Ctx) error { } follow := cmdctx.GetBool(ctx.FlagValues, "follow") + _, err := streamDeploymentLog(ctx, deploymentID, follow) + return err +} +// streamDeploymentLog prints a deployment's log once, or (with follow) polls and streams +// new events until the deployment reaches a terminal state, then prints the summary and +// returns the final view. Callers decide what a terminal "failed" status means for their +// own exit behavior; this never returns a non-nil error for a failed deployment itself, +// only for transport/cancellation failures. +func streamDeploymentLog(ctx *cmdctx.Ctx, deploymentID string, follow bool) (*deploymentLogView, error) { view, err := fetchDeploymentLogView(ctx, deploymentID) if err != nil { - return err + return nil, err } printed := printNewEvents(view.Events, 0) if !follow || isTerminalDeploymentStatus(view.Status) { printDeploymentSummary(view) - return nil + return view, nil } ticker := time.NewTicker(deploymentLogPollInterval) @@ -143,25 +152,25 @@ func getVibeappDeploymentLogWorkflow(ctx *cmdctx.Ctx) error { for { select { case <-ctx.Context.Done(): - return fmt.Errorf("canceled while following deployment %s (last status: %s)", deploymentID, view.Status) + return nil, fmt.Errorf("canceled while following deployment %s (last status: %s)", deploymentID, view.Status) case <-ticker.C: } view, err = fetchDeploymentLogView(ctx, deploymentID) if err != nil { - return err + return nil, err } printed = printNewEvents(view.Events, printed) if isTerminalDeploymentStatus(view.Status) { printDeploymentSummary(view) - return nil + return view, nil } } } -// vibeappDeployFollowFn is the follow_fn for "execute vibeapp:deploy --follow": it extracts +// vibeappRunFollowFn is the follow_fn for "execute vibeapp:run --follow": it extracts // the newly-triggered deployment's id from the response and streams its log to completion. -func vibeappDeployFollowFn(ctx *cmdctx.Ctx, result any) error { +func vibeappRunFollowFn(ctx *cmdctx.Ctx, result any) error { m := asMap(result) deploymentID, _ := m["id"].(string) if deploymentID == "" { diff --git a/modules/vibeapps/pull_vibeapp.go b/modules/vibeapps/pull_vibeapp.go index 33d828f..480ed8e 100644 --- a/modules/vibeapps/pull_vibeapp.go +++ b/modules/vibeapps/pull_vibeapp.go @@ -9,6 +9,7 @@ import ( "net/url" "os" "os/exec" + "path" "strings" "github.com/harness/cli/pkg/client" @@ -84,7 +85,11 @@ func appRepoURL(ctx *cmdctx.Ctx, appID string) (string, error) { return repoURL, nil } -// pullVibeappWorkflow implements "pull vibeapp []" (git clone). +// pullVibeappWorkflow implements "pull vibeapp []" (git clone). A +// manually cloned-then-edited checkout is unambiguously that app's directory, so it +// writes the .harness/vibeapp.yaml link file on success — same as "execute +// vibeapp:deploy" does on first deploy — so a later deploy from here updates this app +// instead of creating a duplicate. func pullVibeappWorkflow(ctx *cmdctx.Ctx) error { if err := requireGit(); err != nil { return err @@ -95,9 +100,21 @@ func pullVibeappWorkflow(ctx *cmdctx.Ctx) error { return err } + destDir := repoDirFromURL(repoURL) args := []string{"clone", repoURL} if len(ctx.Args) > 0 { - args = append(args, ctx.Args[0]) + destDir = ctx.Args[0] + args = append(args, destDir) } - return runGitCommand(ctx, repoURL, args...) + if err := runGitCommand(ctx, repoURL, args...); err != nil { + return err + } + return writeVibeappLink(destDir, ctx.Id) +} + +// repoDirFromURL mirrors git clone's own default destination-directory rule: +// the URL's last path segment with a trailing ".git" stripped. +func repoDirFromURL(repoURL string) string { + base := path.Base(repoURL) + return strings.TrimSuffix(base, ".git") } diff --git a/modules/vibeapps/push_source.go b/modules/vibeapps/push_source.go index 46312a3..ca0b5ff 100644 --- a/modules/vibeapps/push_source.go +++ b/modules/vibeapps/push_source.go @@ -58,31 +58,51 @@ func pushVibeappSourceWorkflow(ctx *cmdctx.Ctx) error { } localFile := ctx.Args[0] + name := cmdctx.GetString(ctx.FlagValues, "name") + if name == "" { + base := filepath.Base(localFile) + name = strings.TrimSuffix(base, filepath.Ext(base)) + } + appID := cmdctx.GetString(ctx.FlagValues, "app") + description := cmdctx.GetString(ctx.FlagValues, "description") + + status, err := createAndUploadSource(ctx, localFile, name, appID, description) + if err != nil { + return err + } + + fmt.Printf("\nSource ready: %s (%s)\n", status.ID, status.Status) + if appID != "" { + fmt.Printf("Intaken as a new version on app %s.\n", appID) + } else { + fmt.Printf("\nTo create an app from it:\nharness create vibeapp %s --source-id %s\n", name, status.ID) + } + return nil +} + +// createAndUploadSource creates a source from localFile's bytes — bound directly to +// appID's app if appID is non-empty, or space-scoped and unbound otherwise — uploads +// the file to the returned target(s), and polls until it becomes ready. Shared by +// "push vibeapp_source" and "execute vibeapp:deploy" (which zips the cwd into a temp +// file and passes it here rather than duplicating the create/upload/poll sequence). +func createAndUploadSource(ctx *cmdctx.Ctx, localFile, name, appID, description string) (*sourceStatusResponse, error) { f, err := os.Open(localFile) if err != nil { - return fmt.Errorf("opening %q: %w", localFile, err) + return nil, fmt.Errorf("opening %q: %w", localFile, err) } defer f.Close() fi, err := f.Stat() if err != nil { - return fmt.Errorf("stat %q: %w", localFile, err) + return nil, fmt.Errorf("stat %q: %w", localFile, err) } sum := md5.New() if _, err := io.Copy(sum, f); err != nil { - return fmt.Errorf("reading %q: %w", localFile, err) + return nil, fmt.Errorf("reading %q: %w", localFile, err) } md5Hex := hex.EncodeToString(sum.Sum(nil)) - name := cmdctx.GetString(ctx.FlagValues, "name") - if name == "" { - base := filepath.Base(localFile) - name = strings.TrimSuffix(base, filepath.Ext(base)) - } - appID := cmdctx.GetString(ctx.FlagValues, "app") - description := cmdctx.GetString(ctx.FlagValues, "description") - body := map[string]any{ "name": name, "source": map[string]any{ @@ -109,36 +129,25 @@ func pushVibeappSourceWorkflow(ctx *cmdctx.Ctx) error { fmt.Fprintf(os.Stderr, "Creating source %q (%s) ...\n", name, formatBytes(fi.Size())) raw, _, err := client.New(ctx).Post(path, nil, body) if err != nil { - return fmt.Errorf("creating source: %w", err) + return nil, fmt.Errorf("creating source: %w", err) } var created createSourceResponse if err := decodeInto(raw, &created); err != nil { - return fmt.Errorf("parsing create-source response: %w", err) + return nil, fmt.Errorf("parsing create-source response: %w", err) } if created.Source.ID == "" { - return fmt.Errorf("create-source response had no source id") + return nil, fmt.Errorf("create-source response had no source id") } for _, target := range created.Upload.Files { fmt.Fprintf(os.Stderr, "Uploading %s ...\n", target.Path) if err := putUploadFile(ctx, target, localFile); err != nil { - return fmt.Errorf("uploading %s: %w", target.Path, err) + return nil, fmt.Errorf("uploading %s: %w", target.Path, err) } } fmt.Fprintln(os.Stderr, "Waiting for source to become ready ...") - status, err := pollSourceReady(ctx, created.Source.ID) - if err != nil { - return err - } - - fmt.Printf("\nSource ready: %s (%s)\n", status.ID, status.Status) - if appID != "" { - fmt.Printf("Intaken as a new version on app %s.\n", appID) - } else { - fmt.Printf("\nTo create an app from it:\nharness create vibeapp %s --source-id %s\n", name, status.ID) - } - return nil + return pollSourceReady(ctx, created.Source.ID) } func putUploadFile(ctx *cmdctx.Ctx, target uploadFileTarget, localFile string) error { diff --git a/modules/vibeapps/vibeapps.go b/modules/vibeapps/vibeapps.go index f448920..9efe7d6 100644 --- a/modules/vibeapps/vibeapps.go +++ b/modules/vibeapps/vibeapps.go @@ -9,5 +9,6 @@ func ModuleInit(reg registry.ModuleRegistrar) { reg.RegisterWorkflow(pushVibeappSourceWorkflowID, pushVibeappSourceWorkflow) reg.RegisterWorkflow(pullVibeappWorkflowID, pullVibeappWorkflow) reg.RegisterWorkflow(getVibeappDeploymentLogWorkflowID, getVibeappDeploymentLogWorkflow) - reg.RegisterFollowFn(vibeappDeployFollowFnID, vibeappDeployFollowFn) + reg.RegisterWorkflow(vibeappDeployWorkflowID, vibeappDeployWorkflow) + reg.RegisterFollowFn(vibeappRunFollowFnID, vibeappRunFollowFn) } diff --git a/pkg/spec/vibeapps.spec.yaml b/pkg/spec/vibeapps.spec.yaml index a894fbb..b996a01 100644 --- a/pkg/spec/vibeapps.spec.yaml +++ b/pkg/spec/vibeapps.spec.yaml @@ -9,6 +9,28 @@ help_text: | Launchpad. Each app is backed by a managed Git repo and gets a preview URL; approved apps can also be published to a production URL. + ### Deploying + + Two commands trigger a deployment, for two different audiences: + + harness execute vibeapp:deploy [] [--force] [--no-follow] [--workflow ] + harness execute vibeapp:run [--source-version ] [--workflow ] + + `deploy` is the one to reach for from a project directory: it zips the + current git working tree (respecting `.gitignore`), pushes it as a new + source, creates the app on first run or updates the one this directory is + already linked to, and streams the run's log to completion. It writes a + `.harness/vibeapp.yaml` link file (`app_id: `) on the first successful + create/adopt so later `deploy` runs from the same directory know which app + to update instead of creating a duplicate — remove that file to unlink a + directory. `run` is the lower-level trigger for an app that already has a + ready source: it starts a deployment from the app's current (or a pinned + `--source-version`) commit without touching any local files or git state, + which is what CI/scripts/agents that already have an app id typically want. + + See `harness execute vibeapp:deploy --help` / `harness execute vibeapp:run + --help` for the full flag reference. + ### Nouns {{nouns}} @@ -235,7 +257,7 @@ commands: create_body_wrap: "" item_expr: it text_header: "\nCreated Vibe App {{it.name}} ({{it.id}})\n" - text_footer: "\nTo deploy: harness execute vibeapp:deploy {{it.id}}\n" + text_footer: "\nTo deploy: harness execute vibeapp:run {{it.id}}\n" - command: update vibeapp verb: update @@ -290,19 +312,22 @@ commands: item_expr: it text_header: "\nRestored Vibe App {{ctx.id}}\n" - - command: execute vibeapp:deploy + - command: execute vibeapp:run verb: execute noun: vibeapp - noun_variant: deploy + noun_variant: run fields_noun: vibeapp_deployment - short: "Trigger a deployment for a Vibe App: harness execute vibeapp:deploy [--source-version ] [--workflow ]" + short: "Trigger a deployment for a Vibe App: harness execute vibeapp:run [--source-version ] [--workflow ]" long: | Triggers a deployment (run/preview) for a Vibe App from its latest ready source commit, or a pinned commit via --source-version. Requires a token (PAT/SAT/SSO) — local dev with no credentials falls back to the server's own configured token, but real CLI calls must be authenticated. + + For a Netlify/Vercel-style "zip my project dir and deploy it" flow, see + 'harness execute vibeapp:deploy' instead. handler_type: endpoint - follow_fn: vibeapp_deploy_follow + follow_fn: vibeapp_run_follow flags: - name: source-version description: "Pin the deployment to a specific source commit (default: latest ready commit)" @@ -325,6 +350,34 @@ commands: harness get vibeapp_deployment {{ctx.id}}/{{it.id}} harness get vibeapp_deployment:log {{ctx.id}}/{{it.id}} --follow + - command: execute vibeapp:deploy + verb: execute + noun: vibeapp + noun_variant: deploy + no_id: true + allows_id: true + short: "Zip the current project dir, push it as a new source, and run it: harness execute vibeapp:deploy [] [--force] [--no-follow] [--workflow ]" + long: | + Netlify/Vercel-style deploy: zips the current git working tree (via 'git ls-files', + honoring .gitignore), pushes it as a new source, creates or updates the linked Vibe + App, and triggers a run — streaming its log by default. + + Must be run inside a git repository. On first deploy from a directory, writes + .harness/vibeapp.yaml linking it to the app for subsequent deploys. See + 'harness execute vibeapp:run' for triggering a run against an existing source + commit without zipping/pushing anything new. + handler_type: workflow + workflow_id: vibeapp_deploy + flags: + - name: force + is_bool: true + description: Skip the confirmation prompt when adopting an explicitly-passed existing app id + - name: no-follow + is_bool: true + description: Trigger the run and exit immediately instead of streaming its log + - name: workflow + description: 'Workflow name to run (default: "platform-default")' + # ── vibeapp_deployment ────────────────────────────────────────────────────────── - command: list vibeapp_deployment From ebb46b82555ef43ccf9d8556b907ac5364d404b5 Mon Sep 17 00:00:00 2001 From: Mike Sawka Date: Thu, 3 Sep 2026 17:12:19 -0700 Subject: [PATCH 4/6] Add --check mode to execute vibeapp:deploy Lets a deploy be previewed (project root, file count/size, create-vs-update plan) without any mutating API calls, backed by the same resolveDeployPlan used by a real deploy so the preview can't drift. Also always prints the resolved root and file stats, and warns (non-fatal) if the project has no .gitignore. Co-Authored-By: Claude Sonnet 5 --- modules/vibeapps/deploy.go | 147 ++++++++++++++++++++++++++++-------- pkg/spec/vibeapps.spec.yaml | 34 ++++++--- 2 files changed, 136 insertions(+), 45 deletions(-) diff --git a/modules/vibeapps/deploy.go b/modules/vibeapps/deploy.go index 5699bdb..d76319c 100644 --- a/modules/vibeapps/deploy.go +++ b/modules/vibeapps/deploy.go @@ -140,8 +140,18 @@ func gitDeployFiles(root string) ([]string, error) { return files, nil } -// zipProjectDir zips root's deployable files (per gitDeployFiles) in memory. -func zipProjectDir(root string) ([]byte, error) { +// deployFileStats is the local, read-only "what would get zipped" summary printed +// before every deploy (including --check) so a human/agent can sanity-check the file +// count and size before anything is uploaded. +type deployFileStats struct { + Files []string + TotalBytes int64 +} + +// computeDeployFileStats runs gitDeployFiles and stats each entry (rather than +// actually zipping) to report count/size cheaply and identically for --check and a +// real deploy. +func computeDeployFileStats(root string) (*deployFileStats, error) { files, err := gitDeployFiles(root) if err != nil { return nil, err @@ -149,7 +159,22 @@ func zipProjectDir(root string) ([]byte, error) { if len(files) == 0 { return nil, fmt.Errorf("no files to deploy: 'git ls-files' returned nothing under %s (everything gitignored?)", root) } + var total int64 + for _, rel := range files { + fi, err := os.Lstat(filepath.Join(root, rel)) + if err != nil { + return nil, fmt.Errorf("stat %q: %w", rel, err) + } + if fi.Mode().IsRegular() { + total += fi.Size() + } + } + return &deployFileStats{Files: files, TotalBytes: total}, nil +} +// zipProjectDirFiles zips root's already-resolved deployable files (per +// computeDeployFileStats/gitDeployFiles) in memory. +func zipProjectDirFiles(root string, files []string) ([]byte, error) { var buf bytes.Buffer zw := zip.NewWriter(&buf) for _, rel := range files { @@ -244,60 +269,87 @@ func triggerVibeappDeployment(ctx *cmdctx.Ctx, appID, workflowName string) (stri return deploymentID, nil } -// resolveDeployTargetApp implements the id-vs-link-file overwrite-semantics table for -// "execute vibeapp:deploy". Returns the app id to push a new source version to, or "" -// if a brand-new app should be created (no id given and no live linked app found). -// For the "adopt an explicitly-passed existing id" case, it also writes the link file -// on confirmation — same as the create-new-app path, linking happens as soon as the -// app's identity for this directory is settled, independent of what happens next. -func resolveDeployTargetApp(ctx *cmdctx.Ctx, root, explicitID string, link *vibeappLink, force bool) (string, error) { +// deployPlanKind is the action "execute vibeapp:deploy" will take against the +// vibe-orchestrator, per the id-vs-link-file overwrite-semantics table. +type deployPlanKind string + +const ( + deployPlanCreate deployPlanKind = "create" // no id, no (live) link: create a brand-new app + deployPlanRecreateLink deployPlanKind = "recreate-link" // no id, link present but app is gone: create a new app, replacing the stale link + deployPlanReuse deployPlanKind = "reuse" // linked app (matches explicit id if one was given): push a new version to it, no prompt + deployPlanAdopt deployPlanKind = "adopt" // explicit id, no link file, app exists: prompt (unless --force), then link it +) + +// deployPlan is the fully-resolved, side-effect-free description of what a deploy +// will do to the app side of things — computed identically for a real deploy and for +// --check, so --check can't drift from what actually happens. +type deployPlan struct { + Kind deployPlanKind + AppID string // set for Reuse/Adopt; empty for Create/RecreateLink + AppName string // best-effort, from the GET; empty if not fetched (Create) or unknown +} + +// resolveDeployPlan implements the id-vs-link-file overwrite-semantics table for +// "execute vibeapp:deploy" as a pure read (only GETs, no writes/prompts), so it can +// back both --check and the real deploy path. It errors exactly when a real deploy +// would hard-error: an explicitly-passed id that doesn't exist, or one that conflicts +// with an existing link file. +func resolveDeployPlan(ctx *cmdctx.Ctx, explicitID string, link *vibeappLink) (*deployPlan, error) { switch { case explicitID != "" && link == nil: app, err := getVibeappByID(ctx, explicitID) if err != nil { - return "", err + return nil, err } if app == nil { - return "", fmt.Errorf("Vibe App %s not found", explicitID) - } - if !force { - question := fmt.Sprintf("Vibe App %s (%s) already exists — deploying will push a new version and trigger a run. Continue?", app.ID, app.Name) - if !console.PromptYesNo(question) { - return "", fmt.Errorf("canceled") - } - } - if err := writeVibeappLink(root, app.ID); err != nil { - return "", err + return nil, fmt.Errorf("Vibe App %s not found", explicitID) } - return app.ID, nil + return &deployPlan{Kind: deployPlanAdopt, AppID: app.ID, AppName: app.Name}, nil case explicitID != "" && link != nil: if explicitID != link.AppID { - return "", fmt.Errorf("this directory is linked to Vibe App %s (%s), which differs from the id passed (%s); remove %s if you meant to switch apps", link.AppID, vibeappLinkFile, explicitID, vibeappLinkFile) + return nil, fmt.Errorf("this directory is linked to Vibe App %s (%s), which differs from the id passed (%s); remove %s if you meant to switch apps", link.AppID, vibeappLinkFile, explicitID, vibeappLinkFile) } - return explicitID, nil + return &deployPlan{Kind: deployPlanReuse, AppID: explicitID}, nil case explicitID == "" && link != nil: app, err := getVibeappByID(ctx, link.AppID) if err != nil { - return "", err + return nil, err } if app == nil { // The linked app is gone server-side, but the link file is still the // strongest signal this directory owns that app slot: create a new app // rather than erroring, and the caller overwrites the link with its id. - return "", nil + return &deployPlan{Kind: deployPlanRecreateLink}, nil } - return app.ID, nil + return &deployPlan{Kind: deployPlanReuse, AppID: app.ID, AppName: app.Name}, nil default: // explicitID == "" && link == nil - return "", nil + return &deployPlan{Kind: deployPlanCreate}, nil + } +} + +// describeDeployPlan renders plan as the human-readable line printed for both --check +// and a real deploy, before anything happens. +func describeDeployPlan(plan *deployPlan, link *vibeappLink) string { + switch plan.Kind { + case deployPlanCreate: + return "Plan: no linked app found — will create a new Vibe App" + case deployPlanRecreateLink: + return fmt.Sprintf("Plan: %s points at Vibe App %s, which no longer exists — will create a new Vibe App and replace the link", vibeappLinkFile, link.AppID) + case deployPlanReuse: + return fmt.Sprintf("Plan: will push a new source version to linked Vibe App %s (%s) and trigger a run", plan.AppID, plan.AppName) + case deployPlanAdopt: + return fmt.Sprintf("Plan: Vibe App %s (%s) already exists — will prompt to adopt it, link this directory to it, push a new source version, and trigger a run", plan.AppID, plan.AppName) + default: + return "Plan: unknown" } } // vibeappDeployWorkflow implements "execute vibeapp:deploy [] [--force] [--no-follow] -// [--workflow ]": zips the current git working tree, pushes it as a new source, -// creates or updates the linked Vibe App, and triggers a run. +// [--check] [--workflow ]": zips the current git working tree, pushes it as a new +// source, creates or updates the linked Vibe App, and triggers a run. func vibeappDeployWorkflow(ctx *cmdctx.Ctx) error { if err := requireGit(); err != nil { return err @@ -306,7 +358,12 @@ func vibeappDeployWorkflow(ctx *cmdctx.Ctx) error { if err != nil { return err } + fmt.Fprintf(os.Stderr, "Project root: %s\n", root) + if _, err := os.Stat(filepath.Join(root, ".gitignore")); os.IsNotExist(err) { + fmt.Fprintf(os.Stderr, "warning: no .gitignore found at %s — everything under the project root not already tracked by git will be zipped and uploaded\n", root) + } + check := cmdctx.GetBool(ctx.FlagValues, "check") force := cmdctx.GetBool(ctx.FlagValues, "force") noFollow := cmdctx.GetBool(ctx.FlagValues, "no-follow") workflowName := cmdctx.GetString(ctx.FlagValues, "workflow") @@ -316,12 +373,30 @@ func vibeappDeployWorkflow(ctx *cmdctx.Ctx) error { return err } - appID, err := resolveDeployTargetApp(ctx, root, ctx.Id, link, force) + stats, err := computeDeployFileStats(root) if err != nil { return err } + fmt.Fprintf(os.Stderr, "Files to deploy: %d (%s)\n", len(stats.Files), formatBytes(stats.TotalBytes)) - zipData, err := zipProjectDir(root) + plan, err := resolveDeployPlan(ctx, ctx.Id, link) + if err != nil { + return err + } + fmt.Fprintln(os.Stderr, describeDeployPlan(plan, link)) + + if check { + return nil + } + + if plan.Kind == deployPlanAdopt && !force { + question := fmt.Sprintf("Vibe App %s (%s) already exists — deploying will push a new version and trigger a run. Continue?", plan.AppID, plan.AppName) + if !console.PromptYesNo(question) { + return fmt.Errorf("canceled") + } + } + + zipData, err := zipProjectDirFiles(root, stats.Files) if err != nil { return err } @@ -332,9 +407,10 @@ func vibeappDeployWorkflow(ctx *cmdctx.Ctx) error { defer os.Remove(tmpPath) name := filepath.Base(root) + appID := plan.AppID - if appID == "" { - fmt.Fprintln(os.Stderr, "No linked app found — creating a new Vibe App...") + if plan.Kind == deployPlanCreate || plan.Kind == deployPlanRecreateLink { + fmt.Fprintln(os.Stderr, "Creating a new Vibe App...") status, err := createAndUploadSource(ctx, tmpPath, name, "", "") if err != nil { return err @@ -349,6 +425,11 @@ func vibeappDeployWorkflow(ctx *cmdctx.Ctx) error { return err } } else { + if plan.Kind == deployPlanAdopt { + if err := writeVibeappLink(root, appID); err != nil { + return err + } + } if _, err := createAndUploadSource(ctx, tmpPath, name, appID, ""); err != nil { return err } diff --git a/pkg/spec/vibeapps.spec.yaml b/pkg/spec/vibeapps.spec.yaml index b996a01..acb20aa 100644 --- a/pkg/spec/vibeapps.spec.yaml +++ b/pkg/spec/vibeapps.spec.yaml @@ -13,20 +13,21 @@ help_text: | Two commands trigger a deployment, for two different audiences: - harness execute vibeapp:deploy [] [--force] [--no-follow] [--workflow ] + harness execute vibeapp:deploy [] [--check] [--force] [--no-follow] [--workflow ] harness execute vibeapp:run [--source-version ] [--workflow ] `deploy` is the one to reach for from a project directory: it zips the - current git working tree (respecting `.gitignore`), pushes it as a new - source, creates the app on first run or updates the one this directory is - already linked to, and streams the run's log to completion. It writes a - `.harness/vibeapp.yaml` link file (`app_id: `) on the first successful - create/adopt so later `deploy` runs from the same directory know which app - to update instead of creating a duplicate — remove that file to unlink a - directory. `run` is the lower-level trigger for an app that already has a - ready source: it starts a deployment from the app's current (or a pinned - `--source-version`) commit without touching any local files or git state, - which is what CI/scripts/agents that already have an app id typically want. + current git working tree, pushes it as a new source, creates the app on + first run or updates the one this directory is already linked to (tracked + via a `.harness/vibeapp.yaml` link file — remove it to unlink), and + streams the run's log to completion. Use `--check` to preview what it + would do without deploying anything. `run` is the lower-level trigger for + an app that already has a ready source: it starts a deployment from the + app's current (or a pinned `--source-version`) commit without touching any + local files or git state, which is what CI/scripts/agents that already + have an app id typically want. Make sure the project has an appropriate + `.gitignore` before running `deploy` so build output, dependencies, and + secrets don't get swept into the uploaded source. See `harness execute vibeapp:deploy --help` / `harness execute vibeapp:run --help` for the full flag reference. @@ -356,7 +357,7 @@ commands: noun_variant: deploy no_id: true allows_id: true - short: "Zip the current project dir, push it as a new source, and run it: harness execute vibeapp:deploy [] [--force] [--no-follow] [--workflow ]" + short: "Zip the current project dir, push it as a new source, and run it: harness execute vibeapp:deploy [] [--check] [--force] [--no-follow] [--workflow ]" long: | Netlify/Vercel-style deploy: zips the current git working tree (via 'git ls-files', honoring .gitignore), pushes it as a new source, creates or updates the linked Vibe @@ -366,9 +367,18 @@ commands: .harness/vibeapp.yaml linking it to the app for subsequent deploys. See 'harness execute vibeapp:run' for triggering a run against an existing source commit without zipping/pushing anything new. + + Always prints the resolved git project root and the file count/size that would be + zipped, before doing anything else. --check stops right after printing those plus + what it would do (create a new app, push a new version to the linked/adopted app, + etc.) and any error that would occur — without zipping, uploading, creating, + pushing a source, or triggering a run. handler_type: workflow workflow_id: vibeapp_deploy flags: + - name: check + is_bool: true + description: Resolve and print what this deploy would do (project root, file count/size, create-vs-update plan) without making any API calls that mutate state - name: force is_bool: true description: Skip the confirmation prompt when adopting an explicitly-passed existing app id From 3f50dbaa79c14532b0afaaa3876e4dcb0d0f357d Mon Sep 17 00:00:00 2001 From: Mike Sawka Date: Thu, 3 Sep 2026 17:47:39 -0700 Subject: [PATCH 5/6] Fix source-upload MD5 encoding and improve vibeapp:deploy observability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The md5 field sent to the vibe-orchestrator was hex-encoded but passed through verbatim into a GCS-signed Content-MD5 header, which requires base64 — uploads were failing with InvalidDigest. Also adds debug logging for the upload target/response, source status polling, and link-file writes, plus prints the source id, resolved app id, and a Launchpad UI link during a deploy, to make live deploys easier to follow/debug. Co-Authored-By: Claude Sonnet 5 AI-Session-Id: 3a3f7693-39fd-4bca-9a2b-fd725bfd4cb5 AI-Tool: claude-code AI-Model: unknown --- modules/vibeapps/deploy.go | 7 +++++++ modules/vibeapps/push_source.go | 12 ++++++++---- modules/vibeapps/util.go | 22 +++++++++++++++++++++- 3 files changed, 36 insertions(+), 5 deletions(-) diff --git a/modules/vibeapps/deploy.go b/modules/vibeapps/deploy.go index d76319c..db76c96 100644 --- a/modules/vibeapps/deploy.go +++ b/modules/vibeapps/deploy.go @@ -18,6 +18,7 @@ import ( "github.com/harness/cli/pkg/client" "github.com/harness/cli/pkg/cmdctx" "github.com/harness/cli/pkg/console" + "github.com/harness/cli/pkg/hlog" ) const vibeappDeployWorkflowID = "vibeapp_deploy" @@ -62,6 +63,7 @@ func writeVibeappLink(root, appID string) error { if err := os.WriteFile(filepath.Join(dir, "vibeapp.yaml"), data, 0o644); err != nil { return fmt.Errorf("writing %s: %w", vibeappLinkFile, err) } + hlog.Debug("wrote vibeapp link file", "path", filepath.Join(root, vibeappLinkFile), "app_id", appID) if err := ensureHarnessGitignored(root); err != nil { fmt.Fprintf(os.Stderr, "warning: %v\n", err) } @@ -430,11 +432,16 @@ func vibeappDeployWorkflow(ctx *cmdctx.Ctx) error { return err } } + fmt.Fprintf(os.Stderr, "Deploying to Vibe App %s\n", appID) if _, err := createAndUploadSource(ctx, tmpPath, name, appID, ""); err != nil { return err } } + if uiURL := vibeappUIURL(ctx, appID); uiURL != "" { + fmt.Fprintf(os.Stderr, "View in Launchpad: %s\n", uiURL) + } + deploymentID, err := triggerVibeappDeployment(ctx, appID, workflowName) if err != nil { return err diff --git a/modules/vibeapps/push_source.go b/modules/vibeapps/push_source.go index ca0b5ff..4082d1e 100644 --- a/modules/vibeapps/push_source.go +++ b/modules/vibeapps/push_source.go @@ -5,7 +5,7 @@ package vibeapps import ( "crypto/md5" - "encoding/hex" + "encoding/base64" "fmt" "io" "net/http" @@ -17,6 +17,7 @@ import ( "github.com/harness/cli/pkg/client" "github.com/harness/cli/pkg/cmdctx" + "github.com/harness/cli/pkg/hlog" ) const pushVibeappSourceWorkflowID = "push_vibeapp_source" @@ -101,7 +102,7 @@ func createAndUploadSource(ctx *cmdctx.Ctx, localFile, name, appID, description if _, err := io.Copy(sum, f); err != nil { return nil, fmt.Errorf("reading %q: %w", localFile, err) } - md5Hex := hex.EncodeToString(sum.Sum(nil)) + md5Base64 := base64.StdEncoding.EncodeToString(sum.Sum(nil)) body := map[string]any{ "name": name, @@ -112,7 +113,7 @@ func createAndUploadSource(ctx *cmdctx.Ctx, localFile, name, appID, description "path": filepath.Base(localFile), "sizeBytes": fi.Size(), "contentType": "application/zip", - "md5": md5Hex, + "md5": md5Base64, }, }, }, @@ -146,7 +147,7 @@ func createAndUploadSource(ctx *cmdctx.Ctx, localFile, name, appID, description } } - fmt.Fprintln(os.Stderr, "Waiting for source to become ready ...") + fmt.Fprintf(os.Stderr, "Waiting for source %s to become ready ...\n", created.Source.ID) return pollSourceReady(ctx, created.Source.ID) } @@ -165,6 +166,7 @@ func putUploadFile(ctx *cmdctx.Ctx, target uploadFileTarget, localFile string) e if method == "" { method = http.MethodPut } + hlog.Debug("upload target", "method", method, "url", target.UploadURL, "headers", target.Headers) req, err := http.NewRequestWithContext(ctx.Context, method, target.UploadURL, f) if err != nil { return fmt.Errorf("building upload request: %w", err) @@ -190,6 +192,7 @@ func putUploadFile(ctx *cmdctx.Ctx, target uploadFileTarget, localFile string) e } defer resp.Body.Close() respBody, _ := io.ReadAll(resp.Body) + hlog.Debug("upload target response", "status", resp.StatusCode) if resp.StatusCode < 200 || resp.StatusCode >= 300 { return fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(respBody))) } @@ -224,6 +227,7 @@ func pollSourceReady(ctx *cmdctx.Ctx, sourceID string) (*sourceStatusResponse, e if err := decodeInto(raw, &status); err != nil { return nil, fmt.Errorf("parsing source status: %w", err) } + hlog.Debug("source status", "id", sourceID, "status", status.Status, "detail", status.StatusDetail) if status.Status != previous { fmt.Fprintf(os.Stderr, " status: %s\n", status.Status) previous = status.Status diff --git a/modules/vibeapps/util.go b/modules/vibeapps/util.go index aa4c7bd..fddfb1d 100644 --- a/modules/vibeapps/util.go +++ b/modules/vibeapps/util.go @@ -3,7 +3,12 @@ package vibeapps -import "encoding/json" +import ( + "encoding/json" + "strings" + + "github.com/harness/cli/pkg/cmdctx" +) // sentinelSpaceID is the single hardcoded default space UUID the vibe-orchestrator // API uses server-side today (no multi-space concept exists yet). @@ -27,3 +32,18 @@ func asMap(v any) map[string]any { m, _ := v.(map[string]any) return m } + +// vibeappUIURL builds the Launchpad UI link for a Vibe App, e.g. +// "https:///ng/account//all/vibe-mode/rvc/apps/". Not +// org/project-scoped. Empty if ctx.Auth has no UI URL (e.g. PAT auth without an +// SSO-derived subdomain). +func vibeappUIURL(ctx *cmdctx.Ctx, appID string) string { + base := ctx.Auth.UIUrl + if base == "" { + base = ctx.Auth.APIUrl + } + if base == "" || ctx.Auth.AccountID == "" { + return "" + } + return strings.TrimSuffix(base, "/") + "/ng/account/" + ctx.Auth.AccountID + "/all/vibe-mode/rvc/apps/" + appID +} From 34399c767f80b5f401fd084fd7e801b35f7bbc42 Mon Sep 17 00:00:00 2001 From: Mike Sawka Date: Thu, 3 Sep 2026 18:52:03 -0700 Subject: [PATCH 6/6] Fix staticcheck ST1005 lint error in vibeapp:deploy Co-Authored-By: Claude Sonnet 5 --- modules/vibeapps/deploy.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/vibeapps/deploy.go b/modules/vibeapps/deploy.go index db76c96..3f7ffb9 100644 --- a/modules/vibeapps/deploy.go +++ b/modules/vibeapps/deploy.go @@ -304,7 +304,7 @@ func resolveDeployPlan(ctx *cmdctx.Ctx, explicitID string, link *vibeappLink) (* return nil, err } if app == nil { - return nil, fmt.Errorf("Vibe App %s not found", explicitID) + return nil, fmt.Errorf("vibe app %s not found", explicitID) } return &deployPlan{Kind: deployPlanAdopt, AppID: app.ID, AppName: app.Name}, nil