diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Models/JSCmdPalSection.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Models/JSCmdPalSection.cs
index f68fc5e10c..f4767560e5 100644
--- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Models/JSCmdPalSection.cs
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Models/JSCmdPalSection.cs
@@ -47,10 +47,4 @@ public sealed record JSCmdPalSection
///
[JsonPropertyName("debugPort")]
public int? DebugPort { get; init; }
-
- ///
- /// Gets the capabilities declared by the extension (for example, "commands").
- ///
- [JsonPropertyName("capabilities")]
- public string[]? Capabilities { get; init; }
}
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Models/JSExtensionManifest.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Models/JSExtensionManifest.cs
index d8874c2338..997b728264 100644
--- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Models/JSExtensionManifest.cs
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Models/JSExtensionManifest.cs
@@ -69,11 +69,6 @@ public sealed record JSExtensionManifest
///
public JSExtensionEngines? Engines { get; init; }
- ///
- /// Gets the capabilities declared by the extension (cmdpal.capabilities).
- ///
- public string[]? Capabilities { get; init; }
-
///
/// Gets the effective display name, falling back to when no display name is set.
///
@@ -178,18 +173,73 @@ public sealed record JSExtensionManifest
Version = package.Version,
Description = package.Description,
Icon = package.CmdPal.Icon,
- Publisher = package.CmdPal.Publisher,
+ Publisher = ResolvePublisher(package),
Main = entryPoint,
EntryPointPath = resolvedEntryPoint,
Debug = package.CmdPal.Debug,
DebugPort = package.CmdPal.DebugPort,
Engines = package.Engines,
- Capabilities = package.CmdPal.Capabilities,
};
return JSExtensionManifestParseResult.Success(manifest);
}
+ ///
+ /// Resolves the publisher name. The explicit cmdpal.publisher value wins. When it is
+ /// absent or whitespace, the name portion of the top-level npm "author" field is used.
+ /// Returns null when neither source provides a name.
+ ///
+ private static string? ResolvePublisher(JSPackageJson package)
+ {
+ if (!string.IsNullOrWhiteSpace(package.CmdPal?.Publisher))
+ {
+ return package.CmdPal.Publisher;
+ }
+
+ return ExtractAuthorName(package.Author);
+ }
+
+ ///
+ /// Extracts the author name from the npm "author" field. The field is either a string
+ /// such as "Jane Doe <jane@example.com> (https://example.com)" or an object with a
+ /// "name" property. For the string form, the substring before the first '<' or '('
+ /// delimiter is taken. Returns null when no usable name can be determined.
+ ///
+ private static string? ExtractAuthorName(JsonElement? author)
+ {
+ if (author is not { } authorElement)
+ {
+ return null;
+ }
+
+ switch (authorElement.ValueKind)
+ {
+ case JsonValueKind.String:
+ var raw = authorElement.GetString();
+ if (string.IsNullOrWhiteSpace(raw))
+ {
+ return null;
+ }
+
+ var end = raw.AsSpan().IndexOfAny('<', '(');
+ var name = (end >= 0 ? raw[..end] : raw).Trim();
+ return string.IsNullOrEmpty(name) ? null : name;
+
+ case JsonValueKind.Object:
+ if (authorElement.TryGetProperty("name", out var nameElement) &&
+ nameElement.ValueKind == JsonValueKind.String)
+ {
+ var objectName = nameElement.GetString();
+ return string.IsNullOrWhiteSpace(objectName) ? null : objectName.Trim();
+ }
+
+ return null;
+
+ default:
+ return null;
+ }
+ }
+
private static string? ResolveEntryPoint(string extensionDirectory, string entryPoint, out string? error)
{
error = null;
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Models/JSPackageJson.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Models/JSPackageJson.cs
index f546c25c0e..9b4ed9f694 100644
--- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Models/JSPackageJson.cs
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Models/JSPackageJson.cs
@@ -2,6 +2,7 @@
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
+using System.Text.Json;
using System.Text.Json.Serialization;
namespace Microsoft.CmdPal.UI.ViewModels.Models;
@@ -30,6 +31,15 @@ public sealed record JSPackageJson
[JsonPropertyName("description")]
public string? Description { get; init; }
+ ///
+ /// Gets the raw npm "author" field. It can be either a string
+ /// (for example, "Jane Doe <jane@example.com> (https://example.com)")
+ /// or an object with "name", "email", and "url" properties. Only the name is
+ /// used, and only when cmdpal.publisher is absent.
+ ///
+ [JsonPropertyName("author")]
+ public JsonElement? Author { get; init; }
+
///
/// Gets the relative path to the entry point JavaScript file.
///
diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.ViewModels.UnitTests/JSExtensionManifestTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.ViewModels.UnitTests/JSExtensionManifestTests.cs
index dd91f2751f..eb485681e6 100644
--- a/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.ViewModels.UnitTests/JSExtensionManifestTests.cs
+++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.ViewModels.UnitTests/JSExtensionManifestTests.cs
@@ -12,8 +12,6 @@ namespace Microsoft.CmdPal.UI.ViewModels.UnitTests;
[TestClass]
public class JSExtensionManifestTests
{
- private static readonly string[] ExpectedCapabilities = ["commands"];
-
private string _testDirectory = null!;
[TestInitialize]
@@ -47,7 +45,6 @@ public class JSExtensionManifestTests
"displayName": "Sample Extension",
"icon": "icon.png",
"publisher": "sample-publisher",
- "capabilities": ["commands"],
"debug": true,
"debugPort": 9230
}
@@ -65,8 +62,6 @@ public class JSExtensionManifestTests
Assert.AreEqual("Sample Extension", manifest.DisplayName);
Assert.AreEqual("icon.png", manifest.Icon);
Assert.AreEqual("sample-publisher", manifest.Publisher);
- Assert.IsNotNull(manifest.Capabilities);
- CollectionAssert.AreEqual(ExpectedCapabilities, manifest.Capabilities);
Assert.IsTrue(manifest.Debug);
Assert.AreEqual(9230, manifest.DebugPort);
Assert.AreEqual(">=18", manifest.Engines!.Node);
@@ -317,6 +312,81 @@ public class JSExtensionManifestTests
}
}
+ [TestMethod]
+ public void TryParse_PublisherFromAuthorString_WhenNoCmdPalPublisher()
+ {
+ CreateEntryPoint("index.js");
+ const string Json = """
+ {
+ "name": "author-string",
+ "main": "index.js",
+ "author": "Jane Doe (https://example.com)",
+ "cmdpal": {}
+ }
+ """;
+
+ var result = JSExtensionManifest.TryParse(Json, _testDirectory);
+
+ Assert.IsTrue(result.IsValid, result.FailureReason);
+ Assert.AreEqual("Jane Doe", result.Manifest!.Publisher);
+ }
+
+ [TestMethod]
+ public void TryParse_PublisherFromAuthorObject_WhenNoCmdPalPublisher()
+ {
+ CreateEntryPoint("index.js");
+ const string Json = """
+ {
+ "name": "author-object",
+ "main": "index.js",
+ "author": { "name": "Acme Corp", "email": "dev@acme.example", "url": "https://acme.example" },
+ "cmdpal": {}
+ }
+ """;
+
+ var result = JSExtensionManifest.TryParse(Json, _testDirectory);
+
+ Assert.IsTrue(result.IsValid, result.FailureReason);
+ Assert.AreEqual("Acme Corp", result.Manifest!.Publisher);
+ }
+
+ [TestMethod]
+ public void TryParse_CmdPalPublisher_TakesPrecedenceOverAuthor()
+ {
+ CreateEntryPoint("index.js");
+ const string Json = """
+ {
+ "name": "precedence",
+ "main": "index.js",
+ "author": "Author Name",
+ "cmdpal": { "publisher": "cmdpal-publisher" }
+ }
+ """;
+
+ var result = JSExtensionManifest.TryParse(Json, _testDirectory);
+
+ Assert.IsTrue(result.IsValid, result.FailureReason);
+ Assert.AreEqual("cmdpal-publisher", result.Manifest!.Publisher);
+ }
+
+ [TestMethod]
+ public void TryParse_NoPublisherAndNoAuthor_LeavesPublisherNull()
+ {
+ CreateEntryPoint("index.js");
+ const string Json = """
+ {
+ "name": "no-publisher",
+ "main": "index.js",
+ "cmdpal": {}
+ }
+ """;
+
+ var result = JSExtensionManifest.TryParse(Json, _testDirectory);
+
+ Assert.IsTrue(result.IsValid, result.FailureReason);
+ Assert.IsNull(result.Manifest!.Publisher);
+ }
+
private void CreateEntryPoint(string relativePath)
{
var fullPath = Path.Combine(_testDirectory, relativePath.Replace('/', Path.DirectorySeparatorChar));