Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ All notable changes to this project are documented here. The format follows [Kee
- Every warning and error links to its section of the [code reference](./docs/reference/codes.md) (`HelpLink`, shown by the terminal logger and IDEs). `MSKit_CodesHelpBaseUrl` points the links at another copy of the page.
- `MSKit_DefaultPackageIconUrl`: when the kit packs its own icon, this URL is also written as `PackageIconUrl`, so clients that predate embedded icons show it; nuget.org keeps showing the embedded one. Empty by default; an owner sets it in its owner layer. `MSKITPKG008` now reports only a `PackageIconUrl` the project sets itself. See [docs/packaging.md](./docs/packaging.md#package-metadata).
- `manager/`: the first build of `mskit-manager`, the `DragoAnt.MSBuildKit.Manager` .NET tool (`net8.0`, `net10.0`, `RollForward=Major`) that will install, update and migrate the kit. This build has one command, `status [--json]`, which prints the tool version; the logo goes to stderr, only on a terminal and never with `--no-logo`, so `--json` output always parses. Each run writes a log under `<system temp>/mskit-manager/logs/`, named after the command, newest 20 kept. Not published yet.
- `manager/`: the package model behind the coming `install` — readers for `mskit.package.json`, the trunk's `catalog.json` and a company or team `filter.json`; the part selection (core defaults, then the company filter, the team filter and the repository's `kit.json`, a later layer winning per part, with required and company-locked parts refused and `requires` closed); `kit.json` schema 2, which also reads the schema 1 that `update.sh` writes and keeps a `parts` array spread over several lines; and the install manifest. No command uses them yet.

### Changed

Expand Down
1 change: 1 addition & 0 deletions manager/Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
<ItemGroup>
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.12" />
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.12" />
<PackageVersion Include="NuGet.Versioning" Version="7.9.0" />
<PackageVersion Include="Serilog.Extensions.Logging" Version="10.0.0" />
<PackageVersion Include="Serilog.Formatting.Compact" Version="3.0.0" />
<PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Microsoft.Extensions.Logging" />
<PackageReference Include="NuGet.Versioning" />
<PackageReference Include="Serilog.Extensions.Logging" />
<PackageReference Include="Serilog.Formatting.Compact" />
<PackageReference Include="Serilog.Sinks.File" />
Expand Down
111 changes: 111 additions & 0 deletions manager/src/DragoAnt.MSBuildKit.Manager/Install/KitJson.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
using DragoAnt.MSBuildKit.Manager.Packages;

namespace DragoAnt.MSBuildKit.Manager.Install;

/// <summary>
/// <c>&lt;kit&gt;/kit.json</c>: the repository layer of the selection. Reads schema 2 and the schema 1 that <c>update.sh</c>
/// writes (its <c>parts</c> become enabled parts); always writes schema 2.
/// </summary>
internal sealed record KitJson(IReadOnlyList<KitJsonFilter> Filters, PartToggles Parts, bool Prerelease, KitSourceKind Source)
{
public const int CurrentSchema = 2;
private const string Document = KitLayout.KitJsonFileName;
private static readonly string[] Schema1Fields = ["repository", "version", "sha256", "parts"];

public static KitJson Empty { get; } = new([], PartToggles.Empty, false, KitSourceKind.Feed);

/// <summary>The schema the document was read from; 2 for a new one.</summary>
public int ReadSchema { get; init; } = CurrentSchema;

/// <summary>The schema-1 fields, when the document was schema 1.</summary>
public KitJsonLegacy? Legacy { get; init; }

public static KitJson Parse(string json)
{
var root = KitDocumentFormat.ReadRoot(Document, json, lenient: true);
var schema = KitDocumentFormat.ReadSchema(Document, root);
if (schema is null && Schema1Fields.Any(f => root.TryGetProperty(f, out _)))
return ParseSchema1(root);

KitDocumentFormat.RequireSchema(Document, root, CurrentSchema);
var dto = KitDocumentFormat.Deserialize<Dto>(Document, root);

var filters = (dto.Filters ?? []).Select(f => new KitJsonFilter(
KitDocumentFormat.Required(Document, f.Package, "a filter's package"),
string.IsNullOrWhiteSpace(f.Source) ? null : f.Source)).ToList();
var parts = new PartToggles(dto.Parts?.Enable ?? [], dto.Parts?.Disable ?? []);
KitDocumentFormat.RequireDisjoint(Document, parts.Enable, parts.Disable);
var source = dto.Source switch
{
null or "feed" => KitSourceKind.Feed,
"local" => KitSourceKind.Local,
_ => throw new KitDocumentException(Document, $"unknown source '{dto.Source}' (expected feed or local)"),
};

return new KitJson(filters, parts, dto.Prerelease ?? false, source);
}

public string ToJson() => KitDocumentFormat.Write(writer =>
{
writer.WriteStartObject();
writer.WriteNumber("schema", CurrentSchema);
writer.WriteStartArray("filters");
foreach (var filter in Filters)
{
writer.WriteStartObject();
writer.WriteString("package", filter.Package);
if (filter.Source is not null)
writer.WriteString("source", filter.Source);
writer.WriteEndObject();
}

writer.WriteEndArray();
writer.WriteStartObject("parts");
KitDocumentFormat.WriteStringArray(writer, "enable", Parts.Enable);
KitDocumentFormat.WriteStringArray(writer, "disable", Parts.Disable);
writer.WriteEndObject();
writer.WriteBoolean("prerelease", Prerelease);
writer.WriteString("source", Source == KitSourceKind.Local ? "local" : "feed");
writer.WriteEndObject();
});

private static KitJson ParseSchema1(System.Text.Json.JsonElement root)
{
var dto = KitDocumentFormat.Deserialize<Schema1Dto>(Document, root);
return Empty with
{
Parts = new PartToggles(dto.Parts ?? [], []),
ReadSchema = 1,
Legacy = new KitJsonLegacy(dto.Repository, dto.Version, dto.Sha256),
};
}

private sealed class Dto
{
public int? Schema { get; init; }
public FilterDto[]? Filters { get; init; }
public PartsDto? Parts { get; init; }
public bool? Prerelease { get; init; }
public string? Source { get; init; }
}

private sealed class FilterDto
{
public string? Package { get; init; }
public string? Source { get; init; }
}

private sealed class PartsDto
{
public string[]? Enable { get; init; }
public string[]? Disable { get; init; }
}

private sealed class Schema1Dto
{
public string? Repository { get; init; }
public string? Version { get; init; }
public string? Sha256 { get; init; }
public string[]? Parts { get; init; }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
namespace DragoAnt.MSBuildKit.Manager.Install;

/// <summary>A company or team package the repository attached, and the NuGet source it comes from.</summary>
internal sealed record KitJsonFilter(string Package, string? Source);
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
namespace DragoAnt.MSBuildKit.Manager.Install;

/// <summary>The fields of a schema-1 <c>kit.json</c> (written by <c>update.sh</c>) that schema 2 no longer has.</summary>
internal sealed record KitJsonLegacy(string? Repository, string? Version, string? Sha256);
57 changes: 57 additions & 0 deletions manager/src/DragoAnt.MSBuildKit.Manager/Install/KitLayout.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
namespace DragoAnt.MSBuildKit.Manager.Install;

/// <summary>Every path of an installed kit. The only place that knows the folder and file names.</summary>
internal sealed class KitLayout
{
public const string DefaultKitDirName = ".mskit";
public const string MsbuildDirName = "msbuild";
public const string LocalDirName = ".local";
public const string ManagerDirName = ".manager";
public const string KitJsonFileName = "kit.json";
public const string InitPropsFileName = "init.props";
public const string InitTargetsFileName = "init.targets";
public const string PackagesProjectFileName = "packages.csproj";
public const string LockFileName = "packages.lock.json";
public const string ManifestFileName = "manifest.json";

/// <param name="root">The solution root, the folder holding <c>Directory.Build.props</c>.</param>
/// <param name="kitDirName">The kit folder relative to <paramref name="root"/>; no <c>..</c> segment.</param>
public KitLayout(string root, string kitDirName = DefaultKitDirName)
{
RequireRelativeKitDir(kitDirName);
Root = root;
KitDirName = kitDirName;
KitPath = Path.Combine(root, kitDirName);
}

public string Root { get; }
public string KitDirName { get; }
public string KitPath { get; }
public string KitJsonPath => Path.Combine(KitPath, KitJsonFileName);
public string MsbuildPath => Path.Combine(KitPath, MsbuildDirName);
public string InitPropsPath => Path.Combine(MsbuildPath, InitPropsFileName);
public string InitTargetsPath => Path.Combine(MsbuildPath, InitTargetsFileName);
public string LocalPath => Path.Combine(KitPath, LocalDirName);
public string ManagerPath => Path.Combine(KitPath, ManagerDirName);
public string PackagesProjectPath => Path.Combine(ManagerPath, PackagesProjectFileName);
public string LockFilePath => Path.Combine(ManagerPath, LockFileName);
public string ManifestPath => Path.Combine(ManagerPath, ManifestFileName);

/// <summary>The folder a package's <c>msbuild/**</c> content is deployed to: <c>msbuild/&lt;package id&gt;</c>.</summary>
public string PackageContentPath(string packageId)
{
if (string.IsNullOrWhiteSpace(packageId) || packageId is "." or ".." || packageId.IndexOfAny(['/', '\\']) >= 0)
throw new ArgumentException($"'{packageId}' is not a package id", nameof(packageId));
return Path.Combine(MsbuildPath, packageId);
}

private static void RequireRelativeKitDir(string kitDirName)
{
var segments = kitDirName.Split('/', '\\');
var rooted = Path.IsPathRooted(kitDirName)
|| kitDirName.StartsWith('/') || kitDirName.StartsWith('\\')
|| (kitDirName.Length >= 2 && char.IsAsciiLetter(kitDirName[0]) && kitDirName[1] == ':');
if (string.IsNullOrWhiteSpace(kitDirName) || rooted || segments.Any(s => s is ".." or ""))
throw new ArgumentException($"the kit folder '{kitDirName}' must be a relative path without '..'", nameof(kitDirName));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace DragoAnt.MSBuildKit.Manager.Install;

/// <summary>Where the kit's packages come from: the configured feeds, or a recorded local folder or checkout.</summary>
internal enum KitSourceKind
{
Feed,
Local,
}
95 changes: 95 additions & 0 deletions manager/src/DragoAnt.MSBuildKit.Manager/Install/Manifest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
using DragoAnt.MSBuildKit.Manager.Packages;

namespace DragoAnt.MSBuildKit.Manager.Install;

/// <summary>
/// <c>&lt;kit&gt;/.manager/manifest.json</c>: every file the last install deployed and its SHA-256 — the source of
/// orphan removal and <c>verify</c>. Written sorted, indented, LF-only, so it round-trips byte for byte.
/// </summary>
internal sealed record Manifest(IReadOnlyList<ManifestPackage> Packages, IReadOnlyList<ManifestFile> Files)
{
public const int Schema = 1;
private const string Document = KitLayout.ManifestFileName;

public static Manifest Parse(string json)
{
var root = KitDocumentFormat.ReadRoot(Document, json);
KitDocumentFormat.RequireSchema(Document, root, Schema);
var dto = KitDocumentFormat.Deserialize<Dto>(Document, root);

var packageIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var packages = (dto.Packages ?? []).Select(p =>
{
var id = KitDocumentFormat.Required(Document, p.Id, "a package's id");
if (!packageIds.Add(id))
throw new KitDocumentException(Document, $"package '{id}' is listed twice");
return new ManifestPackage(id, KitDocumentFormat.Required(Document, p.Version, $"package '{id}' version"));
}).ToList();

var paths = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var files = (dto.Files ?? []).Select(f =>
{
var path = KitDocumentFormat.Required(Document, f.Path, "a file's path");
if (path.Contains('\\') || path.StartsWith('/') || path.Contains(':') || path.Split('/').Any(s => s is "" or "." or ".."))
throw new KitDocumentException(Document, $"file path '{path}' is not a relative forward-slash path inside the kit folder");
if (!paths.Add(path))
throw new KitDocumentException(Document, $"file '{path}' is listed twice");
var sha256 = f.Sha256 ?? string.Empty;
if (sha256.Length != 64 || !sha256.All(char.IsAsciiHexDigitLower))
throw new KitDocumentException(Document, $"file '{path}' has sha256 '{sha256}', expected 64 lowercase hex digits");
if (f.Package is not null && !packageIds.Contains(f.Package))
throw new KitDocumentException(Document, $"file '{path}' names package '{f.Package}', which is not listed");
return new ManifestFile(path, f.Package, sha256);
}).ToList();

return new Manifest(packages, files);
}

public string ToJson() => KitDocumentFormat.Write(writer =>
{
writer.WriteStartObject();
writer.WriteNumber("schema", Schema);
writer.WriteStartArray("packages");
foreach (var package in Packages.OrderBy(p => p.Id, StringComparer.Ordinal))
{
writer.WriteStartObject();
writer.WriteString("id", package.Id);
writer.WriteString("version", package.Version);
writer.WriteEndObject();
}

writer.WriteEndArray();
writer.WriteStartArray("files");
foreach (var file in Files.OrderBy(f => f.Path, StringComparer.Ordinal))
{
writer.WriteStartObject();
writer.WriteString("path", file.Path);
writer.WriteString("package", file.Package);
writer.WriteString("sha256", file.Sha256);
writer.WriteEndObject();
}

writer.WriteEndArray();
writer.WriteEndObject();
});

private sealed class Dto
{
public int? Schema { get; init; }
public PackageDto[]? Packages { get; init; }
public FileDto[]? Files { get; init; }
}

private sealed class PackageDto
{
public string? Id { get; init; }
public string? Version { get; init; }
}

private sealed class FileDto
{
public string? Path { get; init; }
public string? Package { get; init; }
public string? Sha256 { get; init; }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace DragoAnt.MSBuildKit.Manager.Install;

/// <summary>
/// A deployed file, relative to the kit folder with forward slashes. <see cref="Package"/> is <see langword="null"/>
/// for a file the tool generates (the aggregator).
/// </summary>
internal sealed record ManifestFile(string Path, string? Package, string Sha256);
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
namespace DragoAnt.MSBuildKit.Manager.Install;

/// <summary>A package the last install deployed, at its exact version.</summary>
internal sealed record ManifestPackage(string Id, string Version);
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
namespace DragoAnt.MSBuildKit.Manager.Packages;

/// <summary>A company or team package attached to the repository, with the filter it ships.</summary>
internal sealed record AttachedFilter(string PackageId, KitFilter Filter);
68 changes: 68 additions & 0 deletions manager/src/DragoAnt.MSBuildKit.Manager/Packages/KitCatalog.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
namespace DragoAnt.MSBuildKit.Manager.Packages;

/// <summary><c>mskit/catalog.json</c> in the trunk package: every core part, ordered by <see cref="KitCatalogPart.Order"/>.</summary>
internal sealed class KitCatalog
{
public const string PackagePath = "mskit/catalog.json";
public const int Schema = 1;

private readonly Dictionary<string, KitCatalogPart> _byId = new(StringComparer.Ordinal);

public KitCatalog(IEnumerable<KitCatalogPart> parts)
{
Parts = [.. parts.OrderBy(p => p.Order).ThenBy(p => p.Id, StringComparer.Ordinal)];
foreach (var part in Parts)
{
if (!_byId.TryAdd(part.Id, part))
throw new KitDocumentException(PackagePath, $"part '{part.Id}' is listed twice");
}

foreach (var part in Parts)
foreach (var required in part.Requires)
{
if (!_byId.ContainsKey(required))
throw new KitDocumentException(PackagePath, $"part '{part.Id}' requires unknown part '{required}'");
}
}

public IReadOnlyList<KitCatalogPart> Parts { get; }

public KitCatalogPart? Find(string id) => _byId.GetValueOrDefault(id);

public static KitCatalog Parse(string json)
{
var root = KitDocumentFormat.ReadRoot(PackagePath, json);
KitDocumentFormat.RequireSchema(PackagePath, root, Schema);
var dto = KitDocumentFormat.Deserialize<Dto>(PackagePath, root);
if (dto.Parts is null)
throw new KitDocumentException(PackagePath, "parts is missing");

return new KitCatalog(dto.Parts.Select(p => new KitCatalogPart(
KitDocumentFormat.Required(PackagePath, p.Id, "a part's id"),
ParseKind(p.Kind, p.Id!),
p.Order ?? throw new KitDocumentException(PackagePath, $"part '{p.Id}' has no order"),
p.Requires ?? [])));
}

private static KitPartKind ParseKind(string? kind, string id) => kind switch
{
"required" => KitPartKind.Required,
"default" => KitPartKind.Default,
"optional" => KitPartKind.Optional,
_ => throw new KitDocumentException(PackagePath, $"part '{id}' has unknown kind '{kind}' (expected required, default or optional)"),
};

private sealed class Dto
{
public int? Schema { get; init; }
public PartDto[]? Parts { get; init; }
}

private sealed class PartDto
{
public string? Id { get; init; }
public string? Kind { get; init; }
public int? Order { get; init; }
public string[]? Requires { get; init; }
}
}
Loading
Loading