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 { /// /// UPM package management over the Client API (CLI-203): list / search / add / remove / resolve. /// /// Threading: UPM operations are asynchronous (the 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 (MainThreadRequired = false) and marshal every /// UPM touch back onto the main thread via the server dispatcher (), so they /// can block without freezing the editor. /// /// Command surface: /// - package_list (installed scope) reads the resolved set inline; available/all /// and package_search query the registry and wait for it — all return synchronously. /// - package_add/package_remove mutate the manifest and trigger a domain reload /// that tears down the in-flight request and the HTTP connection. They are dual-mode: /// /// async (default) — kick the op off, return in_progress immediately, and let /// an poller finalize a Temp status file. Poll /// package_status until completed/failed, then recompile_status. This /// keeps the (single-request) server responsive and survives the reload. /// synchronous (wait=true) — 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 package_status confirms it. /// /// Both write the status file, so a lost reply (or a reload mid-wait) is recoverable via /// package_status; settles an in_progress /// file on the next load. /// - package_resolve records its status too, so package_status validates it. /// /// Mutating commands (add / remove) follow the shared confirm/dry_run convention. /// UPM operations are not part of Unity's Undo. /// [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 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." }; } } /// /// Installed scope: reflects the resolved /// set without a registry round-trip. Runs on the main thread (via ). /// static PackageListResponse ListInstalled(bool includeIndirect) { var summaries = new List(); 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)." }; } /// /// available / all scope: these need the registry, so a Client.SearchAll request is /// started on the main thread and awaited here (on the command's background thread). For /// all, the installed set is merged in (and marked) so callers see one unified list. /// 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(() => { 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)." }; }); } /// /// Combine the registry search result with the installed set per : /// available returns every registry package (flagged whether it is installed); /// all returns installed packages (resolved info) plus the registry packages that are /// not installed. /// static List BuildScopedList(string scope, PackageInfo[] available, bool includeIndirect) { var installed = new Dictionary(); foreach (var p in PackageInfo.GetAllRegisteredPackages()) installed[p.name] = p; var result = new List(); if (scope == "available") { foreach (var p in available ?? Array.Empty()) 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(); 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()) { 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(() => { 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 ---------------------------------------------------------------- /// /// Gate (confirm / dry-run) and kick off the mutating op, then /// either return in_progress (async, default) or block until completion (). /// Either way the operation is tracked and a status file is written, so the result is recoverable /// via package_status even if the domain reload severs a synchronous reply. /// static object ExecuteMutation( string operation, string commandName, string argument, string planText, bool confirm, bool dryRun, bool wait, Func start, Func 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(() => { 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); } } /// Record the in-flight op, write the in_progress status, and (async only) start polling. static void BeginTracking(string operation, string argument, Request request, Func 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; } /// Update-loop poller for async mode (and a timed-out sync wait): finalize when done. static void Poll() => TryFinalize(); /// /// 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. /// 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 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 }; /// /// 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. /// static void RecoverInterruptedOperation() { try { if (!File.Exists(StatusFile)) return; var status = JsonConvert.DeserializeObject(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; /// /// Block until completes, polling its (main-thread-only) state via /// while this thread sleeps between checks. Returns false with /// set on timeout. Used by the read-only registry queries. /// 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; } /// /// Run 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. /// static T RunOnMain(Func fn) { var dispatcher = PipelineServerStartup.Server?.Dispatcher; if (dispatcher != null && dispatcher.IsInitialized && !dispatcher.IsMainThread()) return dispatcher.Invoke(fn); return fn(); } static Dictionary 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 }; /// Map registry results, flagging which are currently installed. static List MapMarkingInstalled(IEnumerable packages) { var installed = new HashSet(); foreach (var p in PackageInfo.GetAllRegisteredPackages()) installed.Add(p.name); var list = new List(); 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}"); } } } }