From 3c9873e3edd486a066a4dcbf236b9103420fd0cf Mon Sep 17 00:00:00 2001 From: Michael Jolley Date: Wed, 22 Jul 2026 14:50:51 -0500 Subject: [PATCH] Move JS manifest capabilities removal and publisher/author fallback to phase 2 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 762976e0-2453-40ca-8034-978a04a19d1e --- .../Models/JSCmdPalSection.cs | 6 -- .../Models/JSExtensionManifest.cs | 64 +++++++++++++-- .../Models/JSPackageJson.cs | 10 +++ .../JSExtensionManifestTests.cs | 80 +++++++++++++++++-- 4 files changed, 142 insertions(+), 18 deletions(-) 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));