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
This commit is contained in:
Michael Jolley
2026-07-22 14:50:51 -05:00
parent 1129694634
commit 3c9873e3ed
4 changed files with 142 additions and 18 deletions

View File

@@ -47,10 +47,4 @@ public sealed record JSCmdPalSection
/// </summary>
[JsonPropertyName("debugPort")]
public int? DebugPort { get; init; }
/// <summary>
/// Gets the capabilities declared by the extension (for example, "commands").
/// </summary>
[JsonPropertyName("capabilities")]
public string[]? Capabilities { get; init; }
}

View File

@@ -69,11 +69,6 @@ public sealed record JSExtensionManifest
/// </summary>
public JSExtensionEngines? Engines { get; init; }
/// <summary>
/// Gets the capabilities declared by the extension (cmdpal.capabilities).
/// </summary>
public string[]? Capabilities { get; init; }
/// <summary>
/// Gets the effective display name, falling back to <see cref="Name"/> when no display name is set.
/// </summary>
@@ -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);
}
/// <summary>
/// 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.
/// </summary>
private static string? ResolvePublisher(JSPackageJson package)
{
if (!string.IsNullOrWhiteSpace(package.CmdPal?.Publisher))
{
return package.CmdPal.Publisher;
}
return ExtractAuthorName(package.Author);
}
/// <summary>
/// Extracts the author name from the npm "author" field. The field is either a string
/// such as "Jane Doe &lt;jane@example.com&gt; (https://example.com)" or an object with a
/// "name" property. For the string form, the substring before the first '&lt;' or '('
/// delimiter is taken. Returns null when no usable name can be determined.
/// </summary>
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;

View File

@@ -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; }
/// <summary>
/// Gets the raw npm "author" field. It can be either a string
/// (for example, "Jane Doe &lt;jane@example.com&gt; (https://example.com)")
/// or an object with "name", "email", and "url" properties. Only the name is
/// used, and only when cmdpal.publisher is absent.
/// </summary>
[JsonPropertyName("author")]
public JsonElement? Author { get; init; }
/// <summary>
/// Gets the relative path to the entry point JavaScript file.
/// </summary>

View File

@@ -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 <jane@example.com> (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));