Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 11 additions & 20 deletions cli/azd/cmd/extension.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ import (
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"runtime"
"slices"
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand Down
28 changes: 27 additions & 1 deletion cli/azd/cmd/extension_bundle_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"}))
Expand Down Expand Up @@ -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()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading