Add Unity Pipeline package support

This commit is contained in:
ud18010
2026-07-31 17:34:04 +08:00
parent d1a526094e
commit 786b7ffff9
590 changed files with 51995 additions and 2 deletions
@@ -0,0 +1,161 @@
using System;
namespace Unity.Pipeline.Editor.Commands.PackageManager
{
/// <summary>Where an added package comes from, derived from its identifier string (CLI-203).</summary>
public enum PackageSourceKind
{
/// <summary>Could not be classified (e.g. empty input).</summary>
Unknown,
/// <summary>A registry package name, optionally pinned: <c>com.unity.foo</c> or <c>com.unity.foo@1.2.3</c>.</summary>
Registry,
/// <summary>A git URL: <c>https://…​.git</c>, <c>git+…</c>, <c>ssh://…</c>, <c>git@host:…</c>, optionally <c>#revision</c>.</summary>
Git,
/// <summary>A local package reference: <c>file:../RelativePath</c> or <c>file:/abs/path</c>.</summary>
Local
}
/// <summary>A parsed UPM <c>package_add</c> identifier — the normalized string handed to
/// <c>Client.Add</c> plus the pieces extracted for plan/audit text.</summary>
public sealed class ParsedPackageId
{
/// <summary>How the package will be resolved by UPM.</summary>
public PackageSourceKind Kind { get; set; }
/// <summary>The identifier passed verbatim to <c>UnityEditor.PackageManager.Client.Add</c>.</summary>
public string Identifier { get; set; }
/// <summary>Registry package name (Registry only); null otherwise.</summary>
public string Name { get; set; }
/// <summary>Registry version or git revision when given; null otherwise.</summary>
public string Version { get; set; }
/// <summary>Human-readable summary for plan/audit text.</summary>
public string Description { get; set; }
}
/// <summary>
/// Pure, editor-independent classification of a <c>package_add</c> identifier into a registry name,
/// git URL, or local path (CLI-203). Kept free of any Unity API so the add-path's input handling is
/// unit-testable without a live editor; the command layer feeds the result to
/// <c>UnityEditor.PackageManager.Client.Add</c>.
/// </summary>
public static class PackageIdentifier
{
/// <summary>Classify an identifier without parsing out its parts.</summary>
public static PackageSourceKind Classify(string identifier)
{
if (string.IsNullOrWhiteSpace(identifier))
return PackageSourceKind.Unknown;
var id = identifier.Trim();
if (StartsWith(id, "file:"))
return PackageSourceKind.Local;
// Explicit git forms and any URL scheme (UPM treats http(s) identifiers as git URLs).
if (StartsWith(id, "git+") || StartsWith(id, "ssh://") || id.StartsWith("git@", StringComparison.Ordinal))
return PackageSourceKind.Git;
if (StartsWith(id, "http://") || StartsWith(id, "https://") || id.Contains("://"))
return PackageSourceKind.Git;
// A bare "owner/repo.git" (no scheme) is still a git reference. Strip an optional
// "#revision" before testing the suffix.
var core = id;
var hash = core.IndexOf('#');
if (hash >= 0)
core = core.Substring(0, hash);
if (core.EndsWith(".git", StringComparison.OrdinalIgnoreCase))
return PackageSourceKind.Git;
return PackageSourceKind.Registry;
}
/// <summary>
/// Classify and split <paramref name="identifier"/>. Returns false with <paramref name="error"/>
/// set for empty/unclassifiable input.
/// </summary>
public static bool TryParse(string identifier, out ParsedPackageId parsed, out string error)
{
parsed = null;
error = null;
if (string.IsNullOrWhiteSpace(identifier))
{
error = "Package identifier is empty. Provide a name (com.unity.foo[@version]), a git URL, or a file: path.";
return false;
}
var id = identifier.Trim();
switch (Classify(id))
{
case PackageSourceKind.Local:
parsed = new ParsedPackageId
{
Kind = PackageSourceKind.Local,
Identifier = id,
Description = $"local package '{id}'"
};
return true;
case PackageSourceKind.Git:
{
string revision = null;
var hash = id.IndexOf('#');
if (hash >= 0 && hash < id.Length - 1)
revision = id.Substring(hash + 1);
parsed = new ParsedPackageId
{
Kind = PackageSourceKind.Git,
Identifier = id,
Version = revision,
Description = revision != null ? $"git package '{id}' @ {revision}" : $"git package '{id}'"
};
return true;
}
case PackageSourceKind.Registry:
{
var name = id;
string version = null;
// Split on the version separator '@'. Use the last '@' so npm-scoped names
// ("@scope/pkg@1.0.0") still split on the version, not the leading scope marker.
var at = id.LastIndexOf('@');
if (at > 0)
{
name = id.Substring(0, at);
version = id.Substring(at + 1);
}
if (string.IsNullOrWhiteSpace(name))
{
error = $"Package name is empty in '{identifier}'.";
return false;
}
parsed = new ParsedPackageId
{
Kind = PackageSourceKind.Registry,
Identifier = id,
Name = name,
Version = version,
Description = version != null ? $"'{name}' @ {version}" : $"'{name}' (latest compatible)"
};
return true;
}
default:
error = $"Could not classify package identifier '{identifier}'.";
return false;
}
}
private static bool StartsWith(string value, string prefix) =>
value.StartsWith(prefix, StringComparison.OrdinalIgnoreCase);
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: b95405e8a4f04c93acbdd564a74f8cc2
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,644 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using Newtonsoft.Json;
using Unity.Pipeline.Commands;
using UnityEditor;
using UnityEditor.PackageManager;
using UnityEditor.PackageManager.Requests;
using UnityEngine;
using PackageInfo = UnityEditor.PackageManager.PackageInfo;
namespace Unity.Pipeline.Editor.Commands.PackageManager
{
/// <summary>
/// UPM package management over the Client API (CLI-203): list / search / add / remove / resolve.
///
/// Threading: UPM <see cref="Client"/> operations are asynchronous (the <see cref="Request"/> only
/// progresses while the editor ticks) and their members are main-thread only. Commands that wait on a
/// request therefore run off the main thread (<c>MainThreadRequired = false</c>) and marshal every
/// UPM touch back onto the main thread via the server dispatcher (<see cref="RunOnMain"/>), so they
/// can block without freezing the editor.
///
/// Command surface:
/// - <c>package_list</c> (installed scope) reads the resolved set inline; <c>available</c>/<c>all</c>
/// and <c>package_search</c> query the registry and wait for it — all return <b>synchronously</b>.
/// - <c>package_add</c>/<c>package_remove</c> mutate the manifest and trigger a <b>domain reload</b>
/// that tears down the in-flight request and the HTTP connection. They are <b>dual-mode</b>:
/// <list type="bullet">
/// <item><description><b>async (default)</b> — kick the op off, return <c>in_progress</c> immediately, and let
/// an <see cref="EditorApplication.update"/> poller finalize a Temp status file. Poll
/// <c>package_status</c> until <c>completed</c>/<c>failed</c>, then <c>recompile_status</c>. This
/// keeps the (single-request) server responsive and survives the reload.</description></item>
/// <item><description><b>synchronous</b> (<c>wait=true</c>) — block until the request completes and return the
/// full result. The result is captured in one main-thread hop the moment the request completes
/// (before the compile-triggered reload). Reliable for add; for remove the reload can fire fast
/// enough to drop the reply — the status file still settles, so <c>package_status</c> confirms it.</description></item>
/// </list>
/// Both write the status file, so a lost reply (or a reload mid-wait) is recoverable via
/// <c>package_status</c>; <see cref="RecoverInterruptedOperation"/> settles an <c>in_progress</c>
/// file on the next load.
/// - <c>package_resolve</c> records its status too, so <c>package_status</c> validates it.
///
/// Mutating commands (add / remove) follow the shared <c>confirm</c>/<c>dry_run</c> convention.
/// UPM operations are not part of Unity's Undo.
/// </summary>
[InitializeOnLoad]
public static class PackageManagerCommand
{
const string StatusFile = "Temp/pipeline_package_status.json";
// Bound on a synchronous wait, kept under the CLI's default request timeout so a stuck operation
// surfaces as a clean error rather than a dropped connection.
const int WaitTimeoutSeconds = 25;
const int PollIntervalMs = 50;
static readonly object s_Lock = new object();
// The in-flight mutating request (add/remove) and how to project its result. s_InProgress is a
// plain flag (read off-thread by IsBusy) so the busy check never touches main-thread-only Request
// members. Both are reset by a domain reload; RecoverInterruptedOperation settles the status file.
static volatile bool s_InProgress;
static Request s_Request;
static string s_Operation;
static string s_Argument;
static Func<Request, PackageSummary> s_ReadPackage;
static PackageManagerCommand()
{
RecoverInterruptedOperation();
}
// ---- List / search (synchronous) -------------------------------------------------------
[CliCommand("package_list",
"List packages by scope: installed (default) | available (registry) | all (both). Returns the full " +
"result synchronously — available/all block until the registry query completes.",
MainThreadRequired = false)]
public static object PackageList(
[CliArg("scope", "Which packages to list: installed (default) | available | all.")] string scope = "installed",
[CliArg("include_indirect", "Include indirect (transitive) installed dependencies (applies to scope=installed/all).")]
bool includeIndirect = true,
[CliArg("offline", "For available/all: query the local cache instead of the registry.")] bool offline = false)
{
switch ((scope ?? "installed").Trim().ToLowerInvariant())
{
case "installed":
return RunOnMain(() => ListInstalled(includeIndirect));
case "available":
return ListFromRegistry("available", includeIndirect, offline);
case "all":
return ListFromRegistry("all", includeIndirect, offline);
default:
return new PackageListResponse
{
Success = false,
Scope = scope,
Message = $"Unknown scope '{scope}'. Use installed | available | all."
};
}
}
/// <summary>
/// Installed scope: <see cref="PackageInfo.GetAllRegisteredPackages"/> reflects the resolved
/// set without a registry round-trip. Runs on the main thread (via <see cref="RunOnMain"/>).
/// </summary>
static PackageListResponse ListInstalled(bool includeIndirect)
{
var summaries = new List<PackageSummary>();
foreach (var p in PackageInfo.GetAllRegisteredPackages())
{
if (includeIndirect || p.isDirectDependency)
summaries.Add(Map(p, isInstalled: true));
}
return new PackageListResponse
{
Success = true,
Scope = "installed",
Count = summaries.Count,
Packages = summaries,
Manifest = SafeReadManifest(),
Message = $"{summaries.Count} installed package(s)."
};
}
/// <summary>
/// available / all scope: these need the registry, so a <c>Client.SearchAll</c> request is
/// started on the main thread and awaited here (on the command's background thread). For
/// <c>all</c>, the installed set is merged in (and marked) so callers see one unified list.
/// </summary>
static object ListFromRegistry(string scope, bool includeIndirect, bool offline)
{
var request = RunOnMain(() => Client.SearchAll(offline));
if (!WaitForCompletion(request, out var waitError))
return new PackageListResponse { Success = false, Scope = scope, Message = waitError };
return RunOnMain<object>(() =>
{
if (request.Status != StatusCode.Success)
return new PackageListResponse
{
Success = false,
Scope = scope,
Message = $"Registry query failed: {request.Error?.message ?? "unknown error"}"
};
var packages = BuildScopedList(scope, request.Result, includeIndirect);
return new PackageListResponse
{
Success = true,
Scope = scope,
Count = packages.Count,
Packages = packages,
Manifest = SafeReadManifest(),
Message = $"{packages.Count} package(s)."
};
});
}
/// <summary>
/// Combine the registry search result with the installed set per <paramref name="scope"/>:
/// <c>available</c> returns every registry package (flagged whether it is installed);
/// <c>all</c> returns installed packages (resolved info) plus the registry packages that are
/// not installed.
/// </summary>
static List<PackageSummary> BuildScopedList(string scope, PackageInfo[] available, bool includeIndirect)
{
var installed = new Dictionary<string, PackageInfo>();
foreach (var p in PackageInfo.GetAllRegisteredPackages())
installed[p.name] = p;
var result = new List<PackageSummary>();
if (scope == "available")
{
foreach (var p in available ?? Array.Empty<PackageInfo>())
result.Add(Map(p, isInstalled: installed.ContainsKey(p.name)));
return result;
}
// scope == "all": installed entries first (with their resolved info), then registry-only ones.
var seen = new HashSet<string>();
foreach (var entry in installed.Values)
{
if (!includeIndirect && !entry.isDirectDependency)
continue;
result.Add(Map(entry, isInstalled: true));
seen.Add(entry.name);
}
foreach (var p in available ?? Array.Empty<PackageInfo>())
{
if (!seen.Contains(p.name))
result.Add(Map(p, isInstalled: false));
}
return result;
}
[CliCommand("package_search",
"Search packages available in the registry. Provide a name (e.g. com.unity.foo) or omit to list all. " +
"Returns the full result synchronously (blocks until the registry query completes).",
MainThreadRequired = false)]
public static object PackageSearch(
[CliArg("query", "Package name to search for. Omit/empty to list all available packages.")] string query = "",
[CliArg("offline", "Search the local cache only.")] bool offline = false)
{
var trimmed = (query ?? string.Empty).Trim();
var request = RunOnMain(() => string.IsNullOrEmpty(trimmed)
? Client.SearchAll(offline)
: Client.Search(trimmed, offline));
if (!WaitForCompletion(request, out var waitError))
return new PackageSearchResponse { Success = false, Query = trimmed, Message = waitError };
return RunOnMain<object>(() =>
{
if (request.Status != StatusCode.Success)
return new PackageSearchResponse
{
Success = false,
Query = trimmed,
Message = $"Search failed: {request.Error?.message ?? "unknown error"}"
};
var packages = MapMarkingInstalled(request.Result);
return new PackageSearchResponse
{
Success = true,
Query = trimmed,
Count = packages.Count,
Packages = packages,
Message = $"{packages.Count} package(s)."
};
});
}
// ---- Mutating operations (dual-mode, CAT-2509 gated) -----------------------------------
[CliCommand("package_add",
"Add a UPM package by name@version, git URL, or 'file:' local path. Async by default (returns " +
"in_progress; poll package_status); pass wait=true to block until added. A recompile/domain reload " +
"follows — poll recompile_status. Requires confirm=true; use dry_run to preview.",
MainThreadRequired = false)]
public static object PackageAdd(
[CliArg("identifier", "Package to add: 'com.unity.foo@1.2.3', a git URL, or 'file:../Path'.", Required = true)] string identifier = "",
[CliArg("confirm", "Apply the change. Without it the call is refused.")] bool confirm = false,
[CliArg("dry_run", "Preview the change without applying it.")] bool dryRun = false,
[CliArg("wait", "Block until the operation completes and return the result (synchronous). Default: return immediately and poll package_status.")] bool wait = false)
{
if (!PackageIdentifier.TryParse(identifier, out var parsed, out var parseError))
return PackageMutationResponse.Failed("add", identifier, parseError);
if (IsBusy())
return PackageMutationResponse.Busy("add");
return ExecuteMutation(
operation: "add",
commandName: "package_add",
argument: parsed.Identifier,
planText: $"Add package {parsed.Description} [{parsed.Kind}]",
confirm: confirm,
dryRun: dryRun,
wait: wait,
start: () => Client.Add(parsed.Identifier),
readPackage: req =>
{
var added = (req as AddRequest)?.Result;
return added != null ? Map(added, isInstalled: true) : null;
});
}
[CliCommand("package_remove",
"Remove a UPM package by name. Async by default (returns in_progress; poll package_status); pass " +
"wait=true to block until removed. A recompile/domain reload follows — poll recompile_status. " +
"Requires confirm=true; use dry_run to preview.",
MainThreadRequired = false)]
public static object PackageRemove(
[CliArg("name", "Package name to remove (e.g. com.unity.foo).", Required = true)] string name = "",
[CliArg("confirm", "Apply the change. Without it the call is refused.")] bool confirm = false,
[CliArg("dry_run", "Preview the change without applying it.")] bool dryRun = false,
[CliArg("wait", "Block until the operation completes and return the result (synchronous). Default: return immediately and poll package_status.")] bool wait = false)
{
if (string.IsNullOrWhiteSpace(name))
return PackageMutationResponse.Failed("remove", name, "A package name is required.");
var packageName = name.Trim();
if (IsBusy())
return PackageMutationResponse.Busy("remove");
return ExecuteMutation(
operation: "remove",
commandName: "package_remove",
argument: packageName,
planText: $"Remove package '{packageName}'",
confirm: confirm,
dryRun: dryRun,
wait: wait,
start: () => Client.Remove(packageName),
readPackage: null);
}
[CliCommand("package_resolve",
"Resolve/refresh packages from the manifest (re-fetch and re-link). May trigger a recompile/domain " +
"reload — poll recompile_status. Its outcome is recorded for package_status.",
MainThreadRequired = true)]
public static object PackageResolve()
{
WriteStatus(new PackageStatus
{
Status = "in_progress",
Operation = "resolve",
StartedAt = NowIso(),
Message = "Resolving packages..."
});
try
{
// Client.Resolve() is fire-and-forget (returns void) and schedules a resolve on a later
// tick, so the response flushes before any reload — no request to wait on.
Client.Resolve();
}
catch (Exception ex)
{
var failed = new PackageStatus
{
Status = "failed",
Operation = "resolve",
Success = false,
Error = ex.Message,
Manifest = SafeReadManifest(),
CompletedAt = NowIso(),
Message = $"Resolve failed: {ex.Message}"
};
WriteStatus(failed);
return PackageMutationResponse.Failed("resolve", null, failed.Error);
}
var done = new PackageStatus
{
Status = "completed",
Operation = "resolve",
Success = true,
RequiresRecompile = true,
Manifest = SafeReadManifest(),
CompletedAt = NowIso(),
Message = "Resolve requested. If assemblies changed, a domain reload follows — poll recompile_status."
};
WriteStatus(done);
return ToMutationResponse(done, plan: null);
}
// ---- Status ----------------------------------------------------------------------------
[CliCommand("package_status",
"Status of the last async package operation (add/remove/resolve): idle | in_progress | completed | " +
"failed, with the added package, manifest, and any error.",
MainThreadRequired = false)]
public static string GetPackageStatus()
{
if (File.Exists(StatusFile))
return File.ReadAllText(StatusFile);
return "{\"status\":\"idle\"}";
}
// ---- Mutation execution ----------------------------------------------------------------
/// <summary>
/// Gate (confirm / dry-run) and kick off the mutating op, then
/// either return <c>in_progress</c> (async, default) or block until completion (<paramref name="wait"/>).
/// Either way the operation is tracked and a status file is written, so the result is recoverable
/// via <c>package_status</c> even if the domain reload severs a synchronous reply.
/// </summary>
static object ExecuteMutation(
string operation, string commandName, string argument, string planText,
bool confirm, bool dryRun, bool wait,
Func<Request> start, Func<Request, PackageSummary> readPackage)
{
if (dryRun)
return PackageMutationResponse.DryRunPreview(operation, argument, planText, $"Dry run — {planText}");
if (!confirm)
return PackageMutationResponse.Rejected(operation, argument,
"Refused: this changes project packages. Re-run with confirm=true to apply, or dry_run=true to preview.");
// UPM operations are not part of Unity's Undo system, so there is no undo scope here.
var request = RunOnMain(() =>
{
var r = start();
// async mode finalizes via the update-loop poller; sync mode finalizes itself.
BeginTracking(operation, argument, r, readPackage, subscribePoll: !wait);
return r;
});
if (request == null)
return PackageMutationResponse.Failed(operation, argument, "Operation was confirmed but failed to start.");
if (!wait)
return PackageMutationResponse.InProgress(operation, argument, planText);
// Synchronous: poll until complete, capturing status + result atomically on the main thread
// (a reload can only fire between ticks, so the snapshot can't be interleaved).
var deadline = DateTime.UtcNow.AddSeconds(WaitTimeoutSeconds);
while (true)
{
var status = RunOnMain(TryFinalize);
if (status != null)
return ToMutationResponse(status, planText);
if (DateTime.UtcNow > deadline)
{
// Hand off to the update-loop poller so package_status still settles after we bail.
RunOnMain<object>(() => { SubscribePoll(); return null; });
return PackageMutationResponse.Failed(operation, argument,
$"Timed out after {WaitTimeoutSeconds}s; the operation may still be in progress — poll package_status.");
}
Thread.Sleep(PollIntervalMs);
}
}
/// <summary>Record the in-flight op, write the in_progress status, and (async only) start polling.</summary>
static void BeginTracking(string operation, string argument, Request request,
Func<Request, PackageSummary> readPackage, bool subscribePoll)
{
lock (s_Lock)
{
s_Request = request;
s_Operation = operation;
s_Argument = argument;
s_ReadPackage = readPackage;
s_InProgress = true;
}
WriteStatus(new PackageStatus
{
Status = "in_progress",
Operation = operation,
Argument = argument,
StartedAt = NowIso(),
Message = $"{operation} in progress. Poll package_status."
});
if (subscribePoll)
SubscribePoll();
}
static void SubscribePoll()
{
EditorApplication.update -= Poll;
EditorApplication.update += Poll;
}
/// <summary>Update-loop poller for async mode (and a timed-out sync wait): finalize when done.</summary>
static void Poll() => TryFinalize();
/// <summary>
/// If the tracked request has completed, write its final status, clear tracking, and return the
/// status; otherwise return null. Main-thread only (reads Request members); guarded so the sync
/// waiter and the update-loop poller can't double-finalize.
/// </summary>
static PackageStatus TryFinalize()
{
lock (s_Lock)
{
var request = s_Request;
if (request == null)
{
EditorApplication.update -= Poll;
return null;
}
if (!request.IsCompleted)
return null;
var status = BuildStatus(s_Operation, s_Argument, request, s_ReadPackage);
WriteStatus(status);
s_Request = null;
s_Operation = null;
s_Argument = null;
s_ReadPackage = null;
s_InProgress = false;
EditorApplication.update -= Poll;
return status;
}
}
static PackageStatus BuildStatus(string operation, string argument, Request request,
Func<Request, PackageSummary> readPackage)
{
var status = new PackageStatus
{
Operation = operation,
Argument = argument,
CompletedAt = NowIso(),
Manifest = SafeReadManifest()
};
if (request.Status == StatusCode.Success)
{
status.Status = "completed";
status.Success = true;
status.RequiresRecompile = true;
try { status.Package = readPackage?.Invoke(request); }
catch (Exception ex) { Debug.LogWarning($"[pipeline] package result projection failed: {ex.Message}"); }
status.Message = $"{operation} completed.";
}
else
{
status.Status = "failed";
status.Success = false;
status.Error = request.Error?.message ?? "Unknown package manager error.";
status.Message = $"{operation} failed: {status.Error}";
}
return status;
}
static PackageMutationResponse ToMutationResponse(PackageStatus s, string plan) => new PackageMutationResponse
{
Success = s.Success,
Operation = s.Operation,
Argument = s.Argument,
Status = s.Status,
Applied = s.Success,
Plan = plan,
Package = s.Package,
Manifest = s.Manifest,
RequiresRecompile = s.RequiresRecompile,
Message = s.Message
};
/// <summary>
/// On load, settle a status file left at "in_progress" by a domain reload (the expected outcome of
/// a successful add/remove). The manifest change that triggered the reload has taken effect, so
/// the op is recorded completed; the follow-on recompile is observed via recompile_status.
/// </summary>
static void RecoverInterruptedOperation()
{
try
{
if (!File.Exists(StatusFile))
return;
var status = JsonConvert.DeserializeObject<PackageStatus>(File.ReadAllText(StatusFile));
if (status == null || status.Status != "in_progress")
return;
status.Status = "completed";
status.Success = true;
status.RequiresRecompile = true;
status.CompletedAt = NowIso();
status.Manifest = SafeReadManifest();
status.Message = $"{status.Operation} completed (domain reload). Manifest updated; poll recompile_status for the recompile.";
WriteStatus(status);
}
catch (Exception ex)
{
Debug.LogWarning($"[pipeline] package status recovery failed: {ex.Message}");
}
}
// ---- Helpers ---------------------------------------------------------------------------
static bool IsBusy() => s_InProgress;
/// <summary>
/// Block until <paramref name="request"/> completes, polling its (main-thread-only) state via
/// <see cref="RunOnMain"/> while this thread sleeps between checks. Returns false with
/// <paramref name="error"/> set on timeout. Used by the read-only registry queries.
/// </summary>
static bool WaitForCompletion(Request request, out string error)
{
error = null;
var deadline = DateTime.UtcNow.AddSeconds(WaitTimeoutSeconds);
while (!RunOnMain(() => request.IsCompleted))
{
if (DateTime.UtcNow > deadline)
{
error = $"Timed out after {WaitTimeoutSeconds}s waiting for the package manager.";
return false;
}
Thread.Sleep(PollIntervalMs);
}
return true;
}
/// <summary>
/// Run <paramref name="fn"/> on the Unity main thread. When invoked off-thread (the normal case
/// for the waiting commands), it marshals through the live server's dispatcher; when already on
/// the main thread (e.g. a direct in-process/test call, or no server running) it runs inline.
/// </summary>
static T RunOnMain<T>(Func<T> fn)
{
var dispatcher = PipelineServerStartup.Server?.Dispatcher;
if (dispatcher != null && dispatcher.IsInitialized && !dispatcher.IsMainThread())
return dispatcher.Invoke(fn);
return fn();
}
static Dictionary<string, string> SafeReadManifest() =>
PackageManifest.TryRead(out var deps, out _) ? deps : null;
static PackageSummary Map(PackageInfo p, bool isInstalled = false) => new PackageSummary
{
Name = p.name,
Version = p.version,
DisplayName = p.displayName,
Source = p.source.ToString(),
ResolvedPath = p.resolvedPath,
IsDirectDependency = p.isDirectDependency,
IsInstalled = isInstalled
};
/// <summary>Map registry results, flagging which are currently installed.</summary>
static List<PackageSummary> MapMarkingInstalled(IEnumerable<PackageInfo> packages)
{
var installed = new HashSet<string>();
foreach (var p in PackageInfo.GetAllRegisteredPackages())
installed.Add(p.name);
var list = new List<PackageSummary>();
if (packages != null)
foreach (var p in packages)
list.Add(Map(p, installed.Contains(p.name)));
return list;
}
static string NowIso() => DateTime.UtcNow.ToString("o");
static void WriteStatus(PackageStatus status)
{
try
{
File.WriteAllText(StatusFile, JsonConvert.SerializeObject(status));
}
catch (Exception ex)
{
Debug.LogError($"[Package] Failed to write status file: {ex.Message}");
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 248529bee7274235a46b64bf3f03da11
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,78 @@
using System;
using System.Collections.Generic;
using System.IO;
using Newtonsoft.Json.Linq;
using UnityEngine;
namespace Unity.Pipeline.Editor.Commands.PackageManager
{
/// <summary>
/// Reads the project's UPM manifest (<c>Packages/manifest.json</c>) <c>dependencies</c> as a flat
/// name → version map, satisfying CLI-203's "return manifest state". The JSON parsing is split into
/// a pure <see cref="ReadDependencies(string)"/> seam so it is unit-testable without a live project;
/// <see cref="TryRead"/> resolves the project path and is main-thread only (it reads
/// <see cref="Application.dataPath"/>).
/// </summary>
public static class PackageManifest
{
/// <summary>Project-relative path to the UPM manifest.</summary>
public const string RelativePath = "Packages/manifest.json";
/// <summary>Absolute path to the manifest for the current project. Main thread only.</summary>
public static string ManifestPath
{
get
{
var projectRoot = Path.GetDirectoryName(Application.dataPath);
return Path.Combine(projectRoot, "Packages", "manifest.json");
}
}
/// <summary>
/// Parse the <c>dependencies</c> object of a manifest.json document into a name → version map.
/// Returns an empty map for null/empty input or a manifest without dependencies.
/// </summary>
public static Dictionary<string, string> ReadDependencies(string manifestJson)
{
var result = new Dictionary<string, string>();
if (string.IsNullOrWhiteSpace(manifestJson))
return result;
var root = JObject.Parse(manifestJson);
if (root["dependencies"] is JObject deps)
{
foreach (var kv in deps)
result[kv.Key] = kv.Value?.ToString();
}
return result;
}
/// <summary>
/// Read the current project's manifest dependencies. Returns false with
/// <paramref name="error"/> set when the manifest is missing or unreadable.
/// </summary>
public static bool TryRead(out Dictionary<string, string> dependencies, out string error)
{
dependencies = null;
error = null;
try
{
var path = ManifestPath;
if (!File.Exists(path))
{
error = $"manifest not found at {path}";
return false;
}
dependencies = ReadDependencies(File.ReadAllText(path));
return true;
}
catch (Exception ex)
{
error = ex.Message;
return false;
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 0401cb232d5143bbb185ac248086a09f
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,209 @@
using System;
using System.Collections.Generic;
using Newtonsoft.Json;
namespace Unity.Pipeline.Editor.Commands.PackageManager
{
/// <summary>
/// One package in a list/search/add result — a JSON-friendly projection of
/// <c>UnityEditor.PackageManager.PackageInfo</c> (CLI-203).
/// </summary>
[Serializable]
public class PackageSummary
{
[JsonProperty("name")] public string Name { get; set; }
[JsonProperty("version")] public string Version { get; set; }
[JsonProperty("displayName")] public string DisplayName { get; set; }
/// <summary>Resolved source: Registry, Embedded, Local, Git, BuiltIn, LocalTarball, Unknown.</summary>
[JsonProperty("source")] public string Source { get; set; }
[JsonProperty("resolvedPath")] public string ResolvedPath { get; set; }
/// <summary>True when the package is a direct manifest dependency (vs. a transitive one).</summary>
[JsonProperty("isDirectDependency")] public bool IsDirectDependency { get; set; }
/// <summary>True when the package is currently installed/resolved in the project (vs. only
/// available in the registry). Always true for an installed-scope listing.</summary>
[JsonProperty("isInstalled")] public bool IsInstalled { get; set; }
}
/// <summary>
/// Synchronous result of <c>package_list</c> (CLI-203). The command returns the full result in one
/// call for every scope — <c>installed</c> reads the resolved set inline; <c>available</c>/<c>all</c>
/// run a registry query and block until it completes — so listing never uses the trigger-then-poll
/// path that the mutating commands do.
/// </summary>
[Serializable]
public class PackageListResponse
{
[JsonProperty("success")] public bool Success { get; set; }
/// <summary>The scope that was listed: installed | available | all.</summary>
[JsonProperty("scope")] public string Scope { get; set; }
[JsonProperty("count")] public int Count { get; set; }
/// <summary>The packages in the requested scope.</summary>
[JsonProperty("packages")] public List<PackageSummary> Packages { get; set; }
/// <summary>Manifest dependencies (name → version).</summary>
[JsonProperty("manifest")] public Dictionary<string, string> Manifest { get; set; }
[JsonProperty("message")] public string Message { get; set; }
}
/// <summary>
/// Synchronous result of <c>package_search</c> (CLI-203). Like <c>package_list</c>, the registry
/// query is awaited inside the command, so the matches are returned in one call. Each entry carries
/// <see cref="PackageSummary.IsInstalled"/> so callers can tell which results are already installed.
/// </summary>
[Serializable]
public class PackageSearchResponse
{
[JsonProperty("success")] public bool Success { get; set; }
/// <summary>The search query (empty when listing all available packages).</summary>
[JsonProperty("query")] public string Query { get; set; }
[JsonProperty("count")] public int Count { get; set; }
/// <summary>The matching packages available in the registry.</summary>
[JsonProperty("packages")] public List<PackageSummary> Packages { get; set; }
[JsonProperty("message")] public string Message { get; set; }
}
/// <summary>
/// Persisted status of the last async mutating operation (add / remove / resolve), written to a Temp
/// status file that survives the domain reload an add/remove/resolve triggers (CLI-203). Read back
/// verbatim by <c>package_status</c> so a caller can poll an async op — or validate one whose
/// synchronous reply was lost to the reload.
/// </summary>
[Serializable]
public class PackageStatus
{
/// <summary>idle | in_progress | completed | failed.</summary>
[JsonProperty("status")] public string Status { get; set; }
/// <summary>add | remove | resolve.</summary>
[JsonProperty("operation")] public string Operation { get; set; }
/// <summary>The operation's subject (identifier/name), when applicable.</summary>
[JsonProperty("argument")] public string Argument { get; set; }
[JsonProperty("success")] public bool Success { get; set; }
[JsonProperty("error")] public string Error { get; set; }
[JsonProperty("message")] public string Message { get; set; }
/// <summary>The operation triggers a recompile/domain reload; poll <c>recompile_status</c>.</summary>
[JsonProperty("requiresRecompile")] public bool RequiresRecompile { get; set; }
/// <summary>The added package (add only); null for remove/resolve.</summary>
[JsonProperty("package")] public PackageSummary Package { get; set; }
/// <summary>Manifest dependencies (name → version) after the operation.</summary>
[JsonProperty("manifest")] public Dictionary<string, string> Manifest { get; set; }
[JsonProperty("startedAt")] public string StartedAt { get; set; }
[JsonProperty("completedAt")] public string CompletedAt { get; set; }
}
/// <summary>
/// Result of a mutating package command — <c>package_add</c> / <c>package_remove</c> /
/// <c>package_resolve</c> (CLI-203). These are dual-mode: by default the call returns
/// <c>in_progress</c> immediately (poll <c>package_status</c>); with <c>wait=true</c> it blocks and
/// this carries the final <c>completed</c>/<c>failed</c> outcome. It reports the <c>confirm</c>/<c>dry_run</c>
/// gate via <see cref="DryRun"/> / <see cref="Applied"/>. A successful add/remove
/// leaves a recompile/domain reload in flight (<see cref="RequiresRecompile"/>) — poll
/// <c>recompile_status</c> to wait for the editor to be ready.
/// </summary>
[Serializable]
public class PackageMutationResponse
{
[JsonProperty("success")] public bool Success { get; set; }
/// <summary>add | remove | resolve.</summary>
[JsonProperty("operation")] public string Operation { get; set; }
/// <summary>The operation's subject (identifier/name), when applicable.</summary>
[JsonProperty("argument")] public string Argument { get; set; }
/// <summary>in_progress | completed | dry_run | rejected | failed | busy.</summary>
[JsonProperty("status")] public string Status { get; set; }
/// <summary>True only when the operation actually mutated the project.</summary>
[JsonProperty("applied")] public bool Applied { get; set; }
[JsonProperty("dryRun")] public bool DryRun { get; set; }
/// <summary>Human-readable description of the intended/performed change.</summary>
[JsonProperty("plan")] public string Plan { get; set; }
/// <summary>The operation leaves a recompile/domain reload in flight; poll <c>recompile_status</c>.</summary>
[JsonProperty("requiresRecompile")] public bool RequiresRecompile { get; set; }
/// <summary>The added package (add only); null for remove/resolve.</summary>
[JsonProperty("package")] public PackageSummary Package { get; set; }
/// <summary>Manifest dependencies (name → version) after the operation.</summary>
[JsonProperty("manifest")] public Dictionary<string, string> Manifest { get; set; }
[JsonProperty("message")] public string Message { get; set; }
public static PackageMutationResponse InProgress(string operation, string argument, string plan) =>
new PackageMutationResponse
{
Success = true,
Operation = operation,
Argument = argument,
Status = "in_progress",
Applied = true,
Plan = plan,
Message = $"{operation} started. Poll package_status until status is 'completed' or 'failed'."
};
public static PackageMutationResponse Busy(string operation) =>
new PackageMutationResponse
{
Success = false,
Operation = operation,
Status = "busy",
Message = "Another package operation is in progress. Poll package_status, then retry."
};
public static PackageMutationResponse DryRunPreview(string operation, string argument, string plan, string message) =>
new PackageMutationResponse
{
Success = true,
Operation = operation,
Argument = argument,
Status = "dry_run",
DryRun = true,
Plan = plan,
Message = message
};
public static PackageMutationResponse Rejected(string operation, string argument, string message) =>
new PackageMutationResponse
{
Success = false,
Operation = operation,
Argument = argument,
Status = "rejected",
Message = message
};
public static PackageMutationResponse Failed(string operation, string argument, string message) =>
new PackageMutationResponse
{
Success = false,
Operation = operation,
Argument = argument,
Status = "failed",
Message = message
};
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 8fa665b51bbb4f2d8543fc02de12c377
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: