From 2f5d79f601efff8f81021ba853d946562773bb6b Mon Sep 17 00:00:00 2001 From: Jeffrey Chen Date: Mon, 10 Aug 2026 18:18:00 +0000 Subject: [PATCH] fix(extensions): support bundle short links Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d743ee8d-6f2c-4244-9ca9-b64fb20643d1 --- cli/azd/cmd/extension.go | 31 +++++++------------ cli/azd/cmd/extension_bundle_test.go | 28 ++++++++++++++++- .../extension-resolution-and-versioning.md | 8 +++++ 3 files changed, 46 insertions(+), 21 deletions(-) diff --git a/cli/azd/cmd/extension.go b/cli/azd/cmd/extension.go index 7d00b672f28..b170553b81f 100644 --- a/cli/azd/cmd/extension.go +++ b/cli/azd/cmd/extension.go @@ -16,7 +16,6 @@ import ( "net/http" "net/url" "os" - "path" "path/filepath" "runtime" "slices" @@ -101,9 +100,10 @@ confirming first for a URL) and then installs from it. If the location is alread registered, azd reuses that source. You can also pass a self-contained extension bundle (.zip), either as a local -path or an https URL: azd downloads (when remote) and extracts it, then installs -the bundled extension. Bundled extensions aren't tracked for updates; reinstall -from a newer bundle to update.`, +path or an https URL (including a short link without a .zip suffix): azd +downloads (when remote) and extracts it, then installs the bundled extension. +Bundled extensions aren't tracked for updates; reinstall from a newer bundle to +update.`, }, ActionResolver: newExtensionInstallAction, FlagsResolver: newExtensionInstallFlags, @@ -1259,7 +1259,7 @@ func wrapDependencyError(err error) error { } // isBundleArg reports whether the provided arguments represent a single -// self-contained extension bundle (.zip), either as a local path that exists on +// self-contained extension bundle, either as a local .zip path that exists on // disk or as an HTTP or HTTPS URL. func isBundleArg(args []string) bool { if len(args) != 1 { @@ -1278,23 +1278,14 @@ func isBundleArg(args []string) bool { return err == nil && !info.IsDir() } -// isRemoteBundleArg reports whether the value is an HTTP or HTTPS URL pointing at a -// self-contained extension bundle (.zip). Detection is intentionally lexical so -// malformed URLs still reach generic URL validation instead of being displayed as -// extension IDs. +// isRemoteBundleArg reports whether the value is an HTTP or HTTPS URL that should +// be treated as a remote bundle candidate. The URL does not need a .zip suffix +// because short links commonly omit it. Download and extraction validate the +// actual content. func isRemoteBundleArg(value string) bool { lowerValue := strings.ToLower(value) - if !strings.HasPrefix(lowerValue, "http://") && - !strings.HasPrefix(lowerValue, "https://") { - return false - } - - pathEnd := len(value) - if index := strings.IndexAny(value, "?#"); index >= 0 { - pathEnd = index - } - - return strings.EqualFold(path.Ext(value[:pathEnd]), ".zip") + return strings.HasPrefix(lowerValue, "http://") || + strings.HasPrefix(lowerValue, "https://") } // prepareBundleInstall resolves the bundle (downloading it first when the diff --git a/cli/azd/cmd/extension_bundle_test.go b/cli/azd/cmd/extension_bundle_test.go index 4afbd13095a..c44c4ffcbd4 100644 --- a/cli/azd/cmd/extension_bundle_test.go +++ b/cli/azd/cmd/extension_bundle_test.go @@ -50,7 +50,8 @@ func TestIsBundleArg(t *testing.T) { require.True(t, isBundleArg([]string{"http://example.com/path/my-ext.ZIP"})) require.True(t, isBundleArg([]string{"https://example.com/my-ext.zip?token=abc"})) require.True(t, isBundleArg([]string{"https://example.com/%ZZ/my-ext.zip?token=abc"})) - require.False(t, isBundleArg([]string{"https://example.com/registry.json"})) + require.True(t, isBundleArg([]string{"https://aka.ms/azd-extension-bundle-example"})) + require.True(t, isBundleArg([]string{"https://example.com/registry.json"})) require.True(t, isBundleArg([]string{"https:///my-ext.zip"})) require.False(t, isBundleArg([]string{"ftp://example.com/my-ext.zip"})) require.False(t, isBundleArg([]string{"https://example.com/my-ext_1.0.0.zip", "other"})) @@ -563,6 +564,31 @@ func TestPrepareBundleInstall_RemoteURL(t *testing.T) { require.Empty(t, action.bundleTempDir) } +func TestPrepareBundleInstall_RemoteShortLink(t *testing.T) { + t.Parallel() + + const bundleURL = "https://aka.ms/azd-extension-bundle-example" + + action, _, mockContext := newBundleInstallTestActionWithMocks(t) + zipPath := makeBundleZip(t, []*extensions.ExtensionMetadata{ + { + Id: "test.ext", + DisplayName: "Test Extension", + Versions: []extensions.ExtensionVersion{ + {Version: "1.0.0", Artifacts: map[string]extensions.ExtensionArtifact{ + "linux/amd64": {URL: "artifacts/ext.tar.gz"}, + }}, + }, + }, + }) + respondWithBundleZip(t, mockContext, bundleURL, zipPath) + + require.NoError(t, action.prepareBundleInstall(t.Context(), bundleURL)) + require.Equal(t, []string{"test.ext"}, action.args) + require.Regexp(t, `^bundle-[0-9a-f]{8}$`, action.bundleSourceName) + action.cleanupBundleInstall(t.Context()) +} + func TestPrepareBundleInstall_RejectsHTTPURL(t *testing.T) { t.Parallel() diff --git a/cli/azd/docs/extensions/extension-resolution-and-versioning.md b/cli/azd/docs/extensions/extension-resolution-and-versioning.md index bffd93fe33d..f720fdf735e 100644 --- a/cli/azd/docs/extensions/extension-resolution-and-versioning.md +++ b/cli/azd/docs/extensions/extension-resolution-and-versioning.md @@ -205,6 +205,14 @@ A bundle can also be installed directly from an `https` URL, so a preview or int azd extension install https://example.com/builds/my-ext_1.0.0.zip ``` +Short links and other bundle URLs without a `.zip` suffix are also supported: + +```bash +azd extension install https://aka.ms/azd-extension-bundle-example +``` + +`azd` downloads the URL and validates the content by extracting it as a bundle, so the URL path itself does not need to identify a ZIP file. + The install flow treats the bundle as an **installer, not a registry** — nothing about the bundle persists as a configured source once installation finishes: 1. **Download** (URLs only) the bundle to a temporary file. Download failures — an unreachable host or a non-`200` response — are reported as such, separately from a `.zip` that turns out not to be a valid bundle. From here on, remote and local bundles follow the exact same path.