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