From 8bc6d65e1eb99341138cb86cf22f6f7944ca2378 Mon Sep 17 00:00:00 2001 From: Evan Jarrett Date: Wed, 2 Sep 2026 21:03:01 -0500 Subject: [PATCH] appview: emit crane's mandatory destination in the pull command switcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The client switcher built every command as " pull ". That is valid for docker, podman, buildah and nerdctl, but crane requires a destination: $ crane pull seamark.cr/user/bench8x1:v2 Error: requires at least 2 arg(s), only received 1 So selecting crane handed the user a command that cannot run. pullPrefix is a prefix-only helper, which is precisely why it could not express this; add a matching pullPostfix that returns " .tar" for crane and "" for everything else, including "none" (image reference only), which must get neither prefix nor postfix. Both render paths change together, since fixing one leaves the bug visible in the other: the Go template helper paints first, and updatePullCommand in app.js re-renders when the dropdown changes. Repository names may contain slashes, so only the last path segment is used — otherwise the destination would name a subdirectory that does not exist. A name ending in "/" yields no destination at all rather than a bare ".tar", on the grounds that a visibly wrong-arity command beats silently writing a hidden file. That input is not reachable through the real repo-name path. The test asserts the whole command string rather than just the postfix, so it covers the prefix/postfix interaction and the "none" case where both vanish. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PDqoCE1j3njokkZ9b1C5n9 --- pkg/appview/src/js/app.js | 7 +- .../components/pull-command-switcher.html | 4 +- .../templates/components/repo-card.html | 4 +- pkg/appview/templates/partials/repo-tags.html | 4 +- .../partials/settings-panel-devices.html | 2 +- pkg/appview/ui.go | 25 ++++++ pkg/appview/ui_test.go | 87 +++++++++++++++++++ 7 files changed, 125 insertions(+), 8 deletions(-) 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) + } + }) + } +}