diff --git a/pkg/appview/src/js/app.js b/pkg/appview/src/js/app.js index a97b61d..90ea529 100644 --- a/pkg/appview/src/js/app.js +++ b/pkg/appview/src/js/app.js @@ -601,7 +601,12 @@ document.addEventListener('DOMContentLoaded', () => { function updatePullCommand(client) { const prefix = client === 'none' ? '' : client + ' pull '; - const cmd = prefix + registryURL + '/' + ownerHandle + '/' + repoName + ':' + tag; + // crane requires a destination tarball: `crane pull `. + // Repo names can contain slashes, so only the last segment is used — + // otherwise the destination would point at a directory that may not exist. + const base = client === 'crane' ? repoName.split('/').pop() : ''; + const postfix = base ? ' ' + base + '.tar' : ''; + const cmd = prefix + registryURL + '/' + ownerHandle + '/' + repoName + ':' + tag + postfix; const display = document.getElementById('pull-cmd-display'); if (!display) return; const code = display.querySelector('code'); diff --git a/pkg/appview/templates/components/pull-command-switcher.html b/pkg/appview/templates/components/pull-command-switcher.html index 7d8569f..31177ff 100644 --- a/pkg/appview/templates/components/pull-command-switcher.html +++ b/pkg/appview/templates/components/pull-command-switcher.html @@ -49,9 +49,9 @@
{{ if .Tag }} - {{ template "docker-command" (print (pullPrefix .OciClient) .RegistryURL "/" .OwnerHandle "/" .RepoName ":" .Tag) }} + {{ template "docker-command" (print (pullPrefix .OciClient) .RegistryURL "/" .OwnerHandle "/" .RepoName ":" .Tag (pullPostfix .OciClient .RepoName)) }} {{ else }} - {{ template "docker-command" (print (pullPrefix .OciClient) .RegistryURL "/" .OwnerHandle "/" .RepoName ":latest") }} + {{ template "docker-command" (print (pullPrefix .OciClient) .RegistryURL "/" .OwnerHandle "/" .RepoName ":latest" (pullPostfix .OciClient .RepoName)) }} {{ end }}
diff --git a/pkg/appview/templates/components/repo-card.html b/pkg/appview/templates/components/repo-card.html index c8a1916..d563857 100644 --- a/pkg/appview/templates/components/repo-card.html +++ b/pkg/appview/templates/components/repo-card.html @@ -60,11 +60,11 @@ {{ if .Tag }} {{ template "image-ref" (dict "Display" (printf "%s/%s/%s:%s" .RegistryURL .OwnerHandle .Repository .Tag) - "Copy" (printf "%s%s/%s/%s:%s" (pullPrefix .OciClient) .RegistryURL .OwnerHandle .Repository .Tag)) }} + "Copy" (printf "%s%s/%s/%s:%s%s" (pullPrefix .OciClient) .RegistryURL .OwnerHandle .Repository .Tag (pullPostfix .OciClient .Repository))) }} {{ else }} {{ template "image-ref" (dict "Display" (printf "%s/%s/%s" .RegistryURL .OwnerHandle .Repository) - "Copy" (printf "%s%s/%s/%s" (pullPrefix .OciClient) .RegistryURL .OwnerHandle .Repository)) }} + "Copy" (printf "%s%s/%s/%s%s" (pullPrefix .OciClient) .RegistryURL .OwnerHandle .Repository (pullPostfix .OciClient .Repository))) }} {{ end }} {{ end }} diff --git a/pkg/appview/templates/partials/repo-tags.html b/pkg/appview/templates/partials/repo-tags.html index d25767d..0126988 100644 --- a/pkg/appview/templates/partials/repo-tags.html +++ b/pkg/appview/templates/partials/repo-tags.html @@ -56,9 +56,9 @@ {{ end }} {{ else }} {{ if .Entry.IsTagged }} - {{ template "docker-command" (print (pullPrefix .OciClient) .RegistryURL "/" .OwnerHandle "/" .RepoName ":" .Entry.Label) }} + {{ template "docker-command" (print (pullPrefix .OciClient) .RegistryURL "/" .OwnerHandle "/" .RepoName ":" .Entry.Label (pullPostfix .OciClient .RepoName)) }} {{ else }} - {{ template "docker-command" (print (pullPrefix .OciClient) .RegistryURL "/" .OwnerHandle "/" .RepoName "@" .Entry.Digest) }} + {{ template "docker-command" (print (pullPrefix .OciClient) .RegistryURL "/" .OwnerHandle "/" .RepoName "@" .Entry.Digest (pullPostfix .OciClient .RepoName)) }} {{ end }} {{ end }} diff --git a/pkg/appview/templates/partials/settings-panel-devices.html b/pkg/appview/templates/partials/settings-panel-devices.html index 0bb33fe..ca8c703 100644 --- a/pkg/appview/templates/partials/settings-panel-devices.html +++ b/pkg/appview/templates/partials/settings-panel-devices.html @@ -19,7 +19,7 @@ }
  • Run any Docker command: -
    {{ template "docker-command" (print (pullPrefix .OciClient) .RegistryURL "/" .Profile.Handle "/myimage") }}
    +
    {{ template "docker-command" (print (pullPrefix .OciClient) .RegistryURL "/" .Profile.Handle "/myimage" (pullPostfix .OciClient "myimage")) }}
  • Browser will open for authorization - click Approve
  • Done! Device is automatically authorized
  • diff --git a/pkg/appview/ui.go b/pkg/appview/ui.go index 4b00901..6b355a4 100644 --- a/pkg/appview/ui.go +++ b/pkg/appview/ui.go @@ -527,6 +527,31 @@ func Templates(overrides *BrandingOverrides) (*template.Template, error) { return client + " pull " }, + // pullPostfix returns the trailing argument a pull command needs after + // the image reference, including its leading space. Only crane needs + // one: `crane pull ` fails with "requires at least 2 arg(s)" + // because the destination tarball is mandatory, so we emit + // " .tar". Every other client, and "none" (image reference + // only), gets an empty string. + // + // Repository names may contain slashes; only the last path segment is + // used so the tarball lands in the working directory instead of a + // subdirectory that may not exist. + // Usage: {{ pullPostfix .OciClient .RepoName }} + "pullPostfix": func(client, image string) string { + if client != "crane" { + return "" + } + base := image + if i := strings.LastIndex(base, "/"); i >= 0 { + base = base[i+1:] + } + if base == "" { + return "" + } + return " " + base + ".tar" + }, + // toJSON marshals any value to a JSON string safe for use in HTML attributes. // json.Marshal escapes <, >, & and properly escapes " inside strings, // so the result can be used as template.HTML without further escaping. diff --git a/pkg/appview/ui_test.go b/pkg/appview/ui_test.go index f0adde4..bfa550f 100644 --- a/pkg/appview/ui_test.go +++ b/pkg/appview/ui_test.go @@ -934,3 +934,90 @@ func TestJSONLDScript(t *testing.T) { }) } } + +// TestPullCommandGrammar exercises pullPrefix and pullPostfix together, since +// the rendered command is the concatenation of both around the image +// reference. crane is the only client that requires a destination argument. +func TestPullCommandGrammar(t *testing.T) { + tmpl, err := Templates(nil) + if err != nil { + t.Fatalf("Templates(nil) error = %v", err) + } + + const src = `{{ define "pullcmd" }}{{ pullPrefix .Client }}reg.example/alice/{{ .Image }}:v1{{ pullPostfix .Client .Image }}{{ end }}` + + tests := []struct { + name string + client string + image string + want string + }{ + { + name: "crane gets a tarball destination", + client: "crane", + image: "bench8x1", + want: "crane pull reg.example/alice/bench8x1:v1 bench8x1.tar", + }, + { + name: "docker gets no destination", + client: "docker", + image: "bench8x1", + want: "docker pull reg.example/alice/bench8x1:v1", + }, + { + name: "empty client defaults to docker", + client: "", + image: "bench8x1", + want: "docker pull reg.example/alice/bench8x1:v1", + }, + { + name: "none emits neither prefix nor postfix", + client: "none", + image: "bench8x1", + want: "reg.example/alice/bench8x1:v1", + }, + { + name: "crane with a slashed image name uses the last segment", + client: "crane", + image: "team/sub/bench8x1", + want: "crane pull reg.example/alice/team/sub/bench8x1:v1 bench8x1.tar", + }, + { + name: "none with a slashed image name stays bare", + client: "none", + image: "team/sub/bench8x1", + want: "reg.example/alice/team/sub/bench8x1:v1", + }, + { + name: "crane with a trailing slash emits no destination", + client: "crane", + image: "team/", + want: "crane pull reg.example/alice/team/:v1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + temp, err := tmpl.Clone() + if err != nil { + t.Fatalf("Clone() error = %v", err) + } + if _, err := temp.Parse(src); err != nil { + t.Fatalf("Parse() error = %v", err) + } + + buf := new(bytes.Buffer) + data := struct { + Client string + Image string + }{Client: tt.client, Image: tt.image} + if err := temp.ExecuteTemplate(buf, "pullcmd", data); err != nil { + t.Fatalf("ExecuteTemplate() error = %v", err) + } + + if got := buf.String(); got != tt.want { + t.Errorf("pull command = %q, want %q", got, tt.want) + } + }) + } +}