1package cli
2
3import (
4 "bufio"
5 "context"
6 "errors"
7 "fmt"
8 "net/http"
9 "os"
10 "os/exec"
11 "path/filepath"
12 "regexp"
13 "strings"
14 "time"
15
16 tea "github.com/charmbracelet/bubbletea"
17 "github.com/spf13/cobra"
18
19 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
20 "github.com/bnema/gordon/internal/adapters/in/cli/ui/components"
21 "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"
22 "github.com/bnema/gordon/internal/domain"
23 "github.com/bnema/gordon/pkg/validation"
24)
25
26var newImageOpsFn = newImageOpsFromFlags
27
28// buildConfig holds image build settings for push commands.
29type buildConfig struct {
30 Enabled bool
31 Platform string
32 Dockerfile string
33 BuildArgs []string
34}
35
36// imagePush holds resolved image identity for push operations.
37type imagePush struct {
38 Registry string
39 ImageName string
40 Version string
41 VersionRef string
42 LatestRef string
43}
44
45// pushRequest holds all inputs for the push command.
46type pushRequest struct {
47 ImageArg string
48 Domain string
49 Tag string
50 Build buildConfig
51 NoDeploy bool
52 NoConfirm bool
53}
54
55func newPushCmd() *cobra.Command {
56 var (
57 noDeploy bool
58 noConfirm bool
59 build bool
60 platform string
61 tag string
62 dockerfile string
63 buildArgs []string
64 domainFlag string
65 )
66
67 cmd := &cobra.Command{
68 Use: "push [image]",
69 Short: "Tag, push, and optionally deploy an image",
70 Long: `Tags a local image for the Gordon registry and pushes it.
71Uses git tags for versioning. Optionally triggers deployment after push.
72
73The image argument is optional. Resolution order:
74 1. If --domain is provided, uses the legacy domain-based lookup
75 2. If an image name is provided as argument, resolves domain(s) from the backend
76 3. If no argument, auto-detects from Dockerfile labels or current directory name
77
78With --build, builds the image first using docker buildx.
79
80Examples:
81 gordon push --build --remote ...
82 gordon push myapp --build --remote ...
83 gordon push --domain myapp.example.com --build --remote ...
84 gordon push myapp --tag v1.2.0 --no-deploy --remote ...
85 gordon push --build --build-arg CGO_ENABLED=0 --remote ...`,
86 Args: cobra.MaximumNArgs(1),
87 RunE: func(cmd *cobra.Command, args []string) error {
88 var imageArg string
89 if len(args) > 0 {
90 imageArg = args[0]
91 }
92 return runPush(cmd.Context(), pushRequest{
93 ImageArg: imageArg,
94 Domain: domainFlag,
95 Tag: tag,
96 Build: buildConfig{Enabled: build, Platform: platform, Dockerfile: dockerfile, BuildArgs: buildArgs},
97 NoDeploy: noDeploy,
98 NoConfirm: noConfirm,
99 })
100 },
101 }
102
103 cmd.Flags().BoolVar(&noDeploy, "no-deploy", false, "Push only, don't trigger deploy")
104 cmd.Flags().BoolVar(&noConfirm, "no-confirm", false, "Skip deploy confirmation prompt")
105 cmd.Flags().BoolVar(&build, "build", false, "Build the image first using docker buildx")
106 cmd.Flags().StringVar(&platform, "platform", "linux/amd64", "Target platform (used with --build)")
107 cmd.Flags().StringVarP(&dockerfile, "file", "f", "", "Path to Dockerfile (default: ./Dockerfile, used with --build)")
108 cmd.Flags().StringVar(&tag, "tag", "", "Override version tag (default: CI tag ref or git describe)")
109 cmd.Flags().StringArrayVar(&buildArgs, "build-arg", nil, "Additional build args (used with --build)")
110 cmd.Flags().StringVar(&domainFlag, "domain", "", "Explicit domain override (legacy mode)")
111
112 return cmd
113}
114
115func resolveDockerfile(dockerfile string, build bool) (string, error) {
116 if dockerfile != "" && !build {
117 return "", fmt.Errorf("--file can only be used with --build")
118 }
119 if dockerfile == "" {
120 return "Dockerfile", nil
121 }
122 return dockerfile, nil
123}
124
125func resolveImageRefs(registry, imageName, version string) (versionRef, latestRef string) {
126 versionRef = fmt.Sprintf("%s/%s:%s", registry, imageName, version)
127 latestRef = fmt.Sprintf("%s/%s:latest", registry, imageName)
128 return versionRef, latestRef
129}
130
131// resolveRoute determines the registry, image name, and domain from the input mode.
132func resolveRoute(ctx context.Context, cp ControlPlane, imageArg, domainFlag, dockerfile string) (registry, imageName, pushDomain string, err error) {
133 if domainFlag != "" {
134 return resolveFromDomain(ctx, cp, domainFlag)
135 }
136
137 if imageArg == "" {
138 imageArg, err = detectImageName(dockerfile)
139 if err != nil {
140 return "", "", "", err
141 }
142 fmt.Printf("Detected image: %s\n", styles.Theme.Bold.Render(imageArg))
143 }
144
145 return resolveFromImage(ctx, cp, imageArg, dockerfile)
146}
147
148// resolveVersion determines and validates the version tag.
149func resolveVersion(ctx context.Context, tag string) (string, error) {
150 version := determineVersion(ctx, tag)
151 if version != "latest" {
152 if err := validation.ValidateReference(version); err != nil {
153 return "", fmt.Errorf("invalid version tag %q: %w", version, err)
154 }
155 }
156 return version, nil
157}
158
159func runPush(ctx context.Context, req pushRequest) error {
160 handle, err := resolveControlPlane(configPath)
161 if err != nil {
162 return err
163 }
164 defer handle.close()
165
166 dockerfile, err := resolveDockerfile(req.Build.Dockerfile, req.Build.Enabled)
167 if err != nil {
168 return err
169 }
170
171 registry, imageName, pushDomain, err := resolveRoute(ctx, handle.plane, req.ImageArg, req.Domain, dockerfile)
172 if err != nil {
173 return err
174 }
175
176 version, err := resolveVersion(ctx, req.Tag)
177 if err != nil {
178 return err
179 }
180
181 for _, ba := range req.Build.BuildArgs {
182 if err := validateBuildArg(ba); err != nil {
183 return err
184 }
185 }
186
187 img := imagePush{
188 Registry: registry,
189 ImageName: imageName,
190 Version: version,
191 }
192 img.VersionRef, img.LatestRef = resolveImageRefs(registry, imageName, version)
193
194 imageOps, err := newImageOpsFn()
195 if err != nil {
196 return err
197 }
198
199 fmt.Printf("Image: %s\n", styles.Theme.Bold.Render(img.VersionRef))
200 if version != "latest" {
201 fmt.Printf("Also: %s\n", styles.Theme.Bold.Render(img.LatestRef))
202 }
203 fmt.Printf("Domain: %s\n", styles.Theme.Bold.Render(pushDomain))
204
205 skipExplicitDeploy := shouldSkipDeploy(ctx, handle.plane, imageName, req.NoDeploy)
206
207 build := buildConfig{
208 Enabled: req.Build.Enabled,
209 Platform: req.Build.Platform,
210 Dockerfile: dockerfile,
211 BuildArgs: req.Build.BuildArgs,
212 }
213
214 if build.Enabled {
215 if err := buildAndPush(ctx, imageOps, build, img); err != nil {
216 return err
217 }
218 } else {
219 if err := tagAndPush(ctx, imageOps, img); err != nil {
220 return err
221 }
222 }
223
224 fmt.Println(styles.RenderSuccess("Push complete"))
225
226 if !skipExplicitDeploy {
227 return deployAfterPush(ctx, handle.plane, pushDomain, req.NoConfirm)
228 }
229
230 return nil
231}
232
233// shouldSkipDeploy signals deploy intent and returns whether the explicit deploy
234// should be skipped (either because --no-deploy was set, or the token lacks scope).
235func shouldSkipDeploy(ctx context.Context, plane ControlPlane, imageName string, noDeploy bool) bool {
236 // Always call DeployIntent to suppress server-side auto-deploy, even with --no-deploy.
237 // Without this, the server's event listener would still auto-deploy the pushed image.
238 if err := plane.DeployIntent(ctx, imageName); err != nil {
239 if isInsufficientScope(err) {
240 fmt.Fprintln(os.Stderr, "info: deploy intent skipped (insufficient scope), server will auto-deploy on image receive")
241 return true
242 }
243 // Non-fatal: worst case we get a redundant deploy via event
244 fmt.Fprintf(os.Stderr, "warning: failed to register deploy intent: %v\n", err)
245 }
246 return noDeploy
247}
248
249// isInsufficientScope returns true if the error is an HTTP 403 Forbidden response,
250// meaning the token lacks the required admin scope.
251func isInsufficientScope(err error) bool {
252 var httpErr *remote.HTTPError
253 return errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusForbidden
254}
255
256// resolveFromDomain resolves image info from a domain name (legacy mode).
257func resolveFromDomain(ctx context.Context, cp ControlPlane, pushDomain string) (registry, imageName, resolvedDomain string, err error) {
258 route, err := cp.GetRoute(ctx, pushDomain)
259 if err != nil {
260 return "", "", "", fmt.Errorf("failed to get route for domain %q: %w", pushDomain, err)
261 }
262
263 registry, imageName, _ = parseImageRef(route.Image)
264 if registry == "" || imageName == "" {
265 return "", "", "", fmt.Errorf("cannot parse registry/image from route image: %s", route.Image)
266 }
267
268 return registry, imageName, pushDomain, nil
269}
270
271// resolveFromImage resolves domain(s) from an image name using the backend.
272func resolveFromImage(ctx context.Context, cp ControlPlane, imageArg, dockerfile string) (registry, imageName, resolvedDomain string, err error) {
273 // First, query the backend to find routes for this image
274 routes, err := cp.FindRoutesByImage(ctx, imageArg)
275 if err != nil {
276 return "", "", "", fmt.Errorf("failed to find routes for image %q: %w", imageArg, err)
277 }
278
279 // Filter out preview routes (domains containing "--") so they don't
280 // pollute the selection when pushing the base image.
281 filtered := routes[:0]
282 for _, r := range routes {
283 if !strings.Contains(r.Domain, domain.DefaultPreviewSeparator) {
284 filtered = append(filtered, r)
285 }
286 }
287 routes = filtered
288
289 if len(routes) == 0 {
290 // bootstrap command is registered in bootstrap.go (see issue #98)
291 return "", "", "", fmt.Errorf(
292 "no route configured for image %q\n\nFor a first deploy, use 'gordon bootstrap <domain> %s'\nOr configure the route directly with 'gordon routes add <domain> %s'\nIf this is an attachment image, use 'gordon attachments push %s'",
293 imageArg,
294 imageArg,
295 imageArg,
296 imageArg,
297 )
298 }
299
300 // Pick the target domain
301 var targetRoute domain.Route
302 if len(routes) == 1 {
303 targetRoute = routes[0]
304 } else {
305 // Multiple domains: check Dockerfile labels first, then prompt
306 targetRoute, err = selectDomain(routes, dockerfile)
307 if err != nil {
308 return "", "", "", err
309 }
310 }
311
312 registry, imageName, _ = parseImageRef(targetRoute.Image)
313 if registry == "" || imageName == "" {
314 return "", "", "", fmt.Errorf("cannot parse registry/image from route image: %s", targetRoute.Image)
315 }
316
317 return registry, imageName, targetRoute.Domain, nil
318}
319
320// selectDomain picks the target domain from multiple routes.
321// Checks Dockerfile labels first, then falls back to interactive selection.
322func selectDomain(routes []domain.Route, dockerfile string) (domain.Route, error) {
323 // Try to resolve from Dockerfile labels
324 labels := parseDockerfileLabels(dockerfile)
325 labelDomain := labels[domain.LabelDomain]
326 labelDomains := labels[domain.LabelDomains]
327
328 // Collect domains from labels
329 var labelDomainList []string
330 if labelDomain != "" {
331 labelDomainList = append(labelDomainList, labelDomain)
332 }
333 if labelDomains != "" {
334 for _, d := range strings.Split(labelDomains, ",") {
335 d = strings.TrimSpace(d)
336 if d != "" {
337 labelDomainList = append(labelDomainList, d)
338 }
339 }
340 }
341
342 // Try to find a matching route from labels
343 if len(labelDomainList) > 0 {
344 routeMap := make(map[string]domain.Route, len(routes))
345 for _, r := range routes {
346 routeMap[r.Domain] = r
347 }
348 for _, ld := range labelDomainList {
349 if r, ok := routeMap[ld]; ok {
350 return r, nil
351 }
352 }
353 }
354
355 // Fall back to interactive selection
356 items := make([]string, 0, len(routes))
357 for _, r := range routes {
358 items = append(items, fmt.Sprintf("%s %s", r.Domain, styles.Theme.Muted.Render(r.Image)))
359 }
360
361 selected, err := components.RunSelector(
362 "Multiple domains found for this image. Select target:",
363 items,
364 "",
365 )
366 if err != nil {
367 return domain.Route{}, fmt.Errorf("selection error: %w", err)
368 }
369 if selected == "" {
370 return domain.Route{}, fmt.Errorf("no domain selected")
371 }
372
373 // Extract domain from the selected display string
374 for i, item := range items {
375 if item == selected {
376 return routes[i], nil
377 }
378 }
379
380 return domain.Route{}, fmt.Errorf("selected domain not found")
381}
382
383// detectImageName auto-detects the image name from context.
384// Resolution order:
385// 1. Dockerfile label gordon.domain (if Dockerfile exists)
386// 2. Current directory name
387func detectImageName(dockerfile string) (string, error) {
388 // Try Dockerfile labels
389 labels := parseDockerfileLabels(dockerfile)
390 if d := labels[domain.LabelDomain]; d != "" {
391 // Strip the registry prefix if it looks like a full image ref
392 _, name, _ := parseImageRef(d)
393 if name != "" {
394 return name, nil
395 }
396 return d, nil
397 }
398
399 // Fall back to current directory name
400 cwd, err := os.Getwd()
401 if err != nil {
402 return "", fmt.Errorf("cannot detect image name: %w", err)
403 }
404
405 dirName := filepath.Base(cwd)
406 if dirName == "." || dirName == "/" {
407 return "", fmt.Errorf("cannot detect image name from current directory; provide an image name or use --domain")
408 }
409
410 return dirName, nil
411}
412
413// parseDockerfileLabels extracts LABEL instructions from a Dockerfile.
414// Returns a map of label key -> value. Only parses gordon.* labels.
415func parseDockerfileLabels(dockerfile string) map[string]string {
416 labels := make(map[string]string)
417
418 f, err := os.Open(dockerfile)
419 if err != nil {
420 return labels
421 }
422 defer f.Close()
423
424 scanner := bufio.NewScanner(f)
425 for scanner.Scan() {
426 line := strings.TrimSpace(scanner.Text())
427
428 // Skip comments and empty lines
429 if line == "" || strings.HasPrefix(line, "#") {
430 continue
431 }
432
433 // Match LABEL instructions
434 if !strings.HasPrefix(strings.ToUpper(line), "LABEL ") {
435 continue
436 }
437
438 // Parse "LABEL key=value" or "LABEL key=\"value\""
439 labelContent := strings.TrimSpace(line[6:])
440 for _, pair := range splitLabelPairs(labelContent) {
441 key, value, ok := parseLabelPair(pair)
442 if ok && strings.HasPrefix(key, "gordon.") {
443 labels[key] = value
444 }
445 }
446 }
447
448 return labels
449}
450
451// splitLabelPairs splits LABEL content into key=value pairs,
452// handling quoted values that may contain spaces.
453func splitLabelPairs(content string) []string {
454 var pairs []string
455 var current strings.Builder
456 inQuote := false
457 quoteChar := byte(0)
458
459 for i := 0; i < len(content); i++ {
460 ch := content[i]
461 if inQuote {
462 current.WriteByte(ch)
463 if ch == quoteChar {
464 inQuote = false
465 }
466 } else if ch == '"' || ch == '\'' {
467 inQuote = true
468 quoteChar = ch
469 current.WriteByte(ch)
470 } else if ch == ' ' || ch == '\t' {
471 if current.Len() > 0 {
472 pairs = append(pairs, current.String())
473 current.Reset()
474 }
475 } else {
476 current.WriteByte(ch)
477 }
478 }
479 if current.Len() > 0 {
480 pairs = append(pairs, current.String())
481 }
482
483 return pairs
484}
485
486// parseLabelPair parses a single "key=value" or "key=\"value\"" pair.
487func parseLabelPair(pair string) (key, value string, ok bool) {
488 idx := strings.Index(pair, "=")
489 if idx == -1 {
490 return "", "", false
491 }
492 key = pair[:idx]
493 value = pair[idx+1:]
494 // Strip surrounding quotes
495 value = strings.Trim(value, "\"'")
496 return key, value, true
497}
498
499func determineVersion(ctx context.Context, tag string) string {
500 version := tag
501 if version == "" {
502 version = versionFromTagRefs(os.Getenv)
503 }
504 if version == "" {
505 version = getGitVersion(ctx)
506 }
507 if version == "" {
508 version = "latest"
509 }
510 return version
511}
512
513func versionFromTagRefs(getenv func(string) string) string {
514 if ref := strings.TrimSpace(getenv("GITHUB_REF")); ref != "" {
515 if tag := parseTagRef(ref); tag != "" {
516 return tag
517 }
518 }
519
520 if strings.TrimSpace(getenv("GITHUB_REF_TYPE")) == "tag" {
521 if tag := strings.TrimSpace(getenv("GITHUB_REF_NAME")); tag != "" {
522 return tag
523 }
524 }
525
526 if tag := strings.TrimSpace(getenv("CI_COMMIT_TAG")); tag != "" {
527 return tag
528 }
529
530 if ref := strings.TrimSpace(getenv("BUILD_SOURCEBRANCH")); ref != "" {
531 if tag := parseTagRef(ref); tag != "" {
532 return tag
533 }
534 }
535
536 return ""
537}
538
539func parseTagRef(ref string) string {
540 ref = strings.TrimSpace(strings.TrimSuffix(ref, "^{}"))
541 if !strings.HasPrefix(ref, "refs/tags/") {
542 return ""
543 }
544 tag := strings.TrimPrefix(ref, "refs/tags/")
545 if tag == "" {
546 return ""
547 }
548 return tag
549}
550
551func buildAndPush(ctx context.Context, ops pushImageOps, build buildConfig, img imagePush) error {
552 if _, err := os.Stat(build.Dockerfile); os.IsNotExist(err) {
553 return fmt.Errorf("dockerfile not found: %s", build.Dockerfile)
554 }
555
556 // Build and load into local daemon (NOT --push).
557 // The native registry push client handles chunked uploads to stay
558 // within Cloudflare's 100MB per-request limit.
559 fmt.Println("\nBuilding image...")
560 if err := ops.Build(ctx, buildImageArgs(ctx, img.Version, build.Platform, build.Dockerfile, build.BuildArgs, img.VersionRef, img.LatestRef)); err != nil {
561 return err
562 }
563
564 fmt.Println("Pushing...")
565 if err := ops.Push(ctx, img.LatestRef); err != nil {
566 return fmt.Errorf("failed to push %s: %w", img.LatestRef, err)
567 }
568 if img.Version != "latest" {
569 if err := ops.Push(ctx, img.VersionRef); err != nil {
570 return fmt.Errorf("failed to push %s: %w", img.VersionRef, err)
571 }
572 }
573
574 return nil
575}
576
577// standardBuildArgs returns the standard set of git-related build args as
578// explicit KEY=VALUE pairs. User-supplied args are appended after and take
579// precedence (Docker uses the last occurrence of a duplicate key).
580func standardBuildArgs(ctx context.Context, version string) []string {
581 gitSHA := resolveGitSHA(ctx)
582 buildTime := time.Now().UTC().Format(time.RFC3339)
583 return []string{
584 "VERSION=" + version,
585 "GIT_TAG=" + version,
586 "GIT_SHA=" + gitSHA,
587 "BUILD_TIME=" + buildTime,
588 }
589}
590
591// resolveGitSHA returns the short git SHA of HEAD, or "unknown" if unavailable.
592func resolveGitSHA(ctx context.Context) string {
593 out, err := exec.CommandContext(ctx, "git", "rev-parse", "--short", "HEAD").Output() // #nosec G204
594 if err != nil {
595 return "unknown"
596 }
597 return strings.TrimSpace(string(out))
598}
599
600// buildImageArgs constructs the docker buildx build arguments.
601// Uses --load instead of --push so the image is loaded into the local
602// daemon, allowing docker push to handle the upload with chunked requests.
603func buildImageArgs(ctx context.Context, version, platform, dockerfile string, buildArgs []string, versionRef, latestRef string) []string {
604 args := []string{
605 "buildx", "build",
606 "--platform", platform,
607 "-f", dockerfile,
608 "-t", latestRef,
609 }
610 if version != "latest" {
611 args = append(args, "-t", versionRef)
612 }
613
614 // Inject standard git build args as explicit KEY=VALUE pairs.
615 // User-supplied --build-arg flags are appended AFTER so they override defaults.
616 for _, ba := range standardBuildArgs(ctx, version) {
617 args = append(args, "--build-arg", ba)
618 }
619 for _, ba := range buildArgs {
620 args = append(args, "--build-arg", ba)
621 }
622
623 args = append(args, "--load", ".")
624 return args
625}
626
627func tagAndPush(ctx context.Context, ops pushImageOps, img imagePush) error {
628 localImage := fmt.Sprintf("%s/%s", img.Registry, img.ImageName)
629
630 fmt.Println("\nChecking local image...")
631 exists, err := ops.Exists(ctx, localImage)
632 if err != nil {
633 return fmt.Errorf("failed to inspect local image %s: %w", localImage, err)
634 }
635 if !exists {
636 return fmt.Errorf("local image %s not found; build and tag it before pushing", localImage)
637 }
638
639 fmt.Println("Tagging...")
640 if err := ops.Tag(ctx, localImage, img.VersionRef); err != nil {
641 return fmt.Errorf("failed to tag %s: %w", img.VersionRef, err)
642 }
643 if img.Version != "latest" {
644 if err := ops.Tag(ctx, localImage, img.LatestRef); err != nil {
645 return fmt.Errorf("failed to tag %s: %w", img.LatestRef, err)
646 }
647 }
648
649 fmt.Println("Pushing...")
650 if err := ops.Push(ctx, img.VersionRef); err != nil {
651 return fmt.Errorf("failed to push %s: %w", img.VersionRef, err)
652 }
653 if img.Version != "latest" {
654 if err := ops.Push(ctx, img.LatestRef); err != nil {
655 return fmt.Errorf("failed to push %s: %w", img.LatestRef, err)
656 }
657 }
658 return nil
659}
660
661func deployAfterPush(ctx context.Context, cp ControlPlane, pushDomain string, noConfirm bool) error {
662 if !noConfirm {
663 confirmed, err := components.RunConfirm("Deploy now?", components.WithDefaultYes())
664 if err != nil {
665 return err
666 }
667 if !confirmed {
668 return nil
669 }
670 }
671
672 var (
673 result *remote.DeployResult
674 err error
675 )
676 if remoteCP, ok := cp.(*remoteControlPlane); ok {
677 result, err = deployWithSpinner(ctx, remoteCP.client, pushDomain)
678 } else {
679 result, err = cp.Deploy(ctx, pushDomain)
680 }
681 if err != nil {
682 return formatDeployFailure(err)
683 }
684 containerID := shortContainerID(result.ContainerID)
685 fmt.Println(styles.RenderSuccess(fmt.Sprintf("Deployed %s (container: %s)", pushDomain, containerID)))
686 return nil
687}
688
689func deployWithSpinner(ctx context.Context, client *remote.Client, pushDomain string) (*remote.DeployResult, error) {
690 if !isInteractiveTerminal() {
691 fmt.Printf("Deploying %s...\n", pushDomain)
692 return client.Deploy(ctx, pushDomain)
693 }
694
695 done := make(chan deployOutcome, 1)
696 go func() {
697 result, err := client.Deploy(ctx, pushDomain)
698 done <- deployOutcome{result: result, err: err}
699 }()
700
701 model := newDeploySpinnerModel(pushDomain, done)
702 final, err := tea.NewProgram(model, tea.WithContext(ctx)).Run()
703 fmt.Print("\r\033[K")
704 if err != nil {
705 return nil, err
706 }
707
708 deployModel, ok := final.(deploySpinnerModel)
709 if !ok {
710 return nil, fmt.Errorf("spinner exited with unexpected model type %T", final)
711 }
712 if !deployModel.finished {
713 return nil, fmt.Errorf("deploy spinner exited before deploy result was received")
714 }
715
716 return deployModel.outcome.result, deployModel.outcome.err
717}
718
719type deployOutcome struct {
720 result *remote.DeployResult
721 err error
722}
723
724type deployDoneMsg deployOutcome
725
726type deploySpinnerModel struct {
727 spinner components.SpinnerModel
728 done <-chan deployOutcome
729 outcome deployOutcome
730 finished bool
731}
732
733func newDeploySpinnerModel(pushDomain string, done <-chan deployOutcome) deploySpinnerModel {
734 return deploySpinnerModel{
735 spinner: components.NewSpinner(
736 components.WithMessage(fmt.Sprintf("Deploying %s...", pushDomain)),
737 components.WithSpinnerType(components.SpinnerMiniDot),
738 ),
739 done: done,
740 }
741}
742
743func (m deploySpinnerModel) Init() tea.Cmd {
744 return tea.Batch(m.spinner.Init(), waitForDeployDone(m.done))
745}
746
747func (m deploySpinnerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
748 switch msg := msg.(type) {
749 case deployDoneMsg:
750 m.outcome = deployOutcome(msg)
751 m.finished = true
752 return m, tea.Quit
753 default:
754 updated, cmd := m.spinner.Update(msg)
755 spinnerModel, ok := updated.(components.SpinnerModel)
756 if ok {
757 m.spinner = spinnerModel
758 }
759 return m, cmd
760 }
761}
762
763func (m deploySpinnerModel) View() string {
764 return m.spinner.View()
765}
766
767func waitForDeployDone(done <-chan deployOutcome) tea.Cmd {
768 return func() tea.Msg {
769 return deployDoneMsg(<-done)
770 }
771}
772
773func isInteractiveTerminal() bool {
774 term := os.Getenv("TERM")
775 if term == "" || term == "dumb" {
776 return false
777 }
778 info, err := os.Stdout.Stat()
779 if err != nil {
780 return false
781 }
782 return (info.Mode() & os.ModeCharDevice) != 0
783}
784
785// parseImageRef splits "registry/name:tag" into components.
786func parseImageRef(image string) (registry, name, tag string) {
787 // Handle "reg.bnema.dev/jp-bot:latest" or "reg.bnema.dev/jp-bot"
788 parts := strings.SplitN(image, "/", 2)
789 if len(parts) != 2 {
790 return "", "", ""
791 }
792 registry = parts[0]
793 nameTag := parts[1]
794 if idx := strings.LastIndex(nameTag, ":"); idx != -1 {
795 name = nameTag[:idx]
796 tag = nameTag[idx+1:]
797 } else {
798 name = nameTag
799 tag = "latest"
800 }
801 return
802}
803
804// getGitVersion returns git describe output, or empty string if unavailable.
805// When it falls back it prints a warning to stderr so the user knows the
806// image will be tagged "latest" rather than a real version.
807func getGitVersion(ctx context.Context) string {
808 out, err := exec.CommandContext(ctx, "git", "describe", "--tags", "--dirty").Output() // #nosec G204
809 if err != nil {
810 fmt.Fprintf(os.Stderr, "Warning: unable to determine git tag (%v) — image version will be 'latest'. Tag your repo to get versioned images.\n", err)
811 return ""
812 }
813 return strings.TrimSpace(string(out))
814}
815
816// buildArgPattern matches KEY=VALUE where KEY starts with a letter or underscore.
817var buildArgPattern = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*=.*$`)
818
819func validateBuildArg(arg string) error {
820 if !buildArgPattern.MatchString(arg) {
821 return fmt.Errorf("invalid build arg %q: must match KEY=VALUE where KEY is [a-zA-Z_][a-zA-Z0-9_]*", arg)
822 }
823 return nil
824}