chore(unity): 固定Unity CLI Loop本地包并移除UnitySkills依赖
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
namespace io.github.hatayama.uLoopMCP
|
||||
{
|
||||
public static class CliConstants
|
||||
{
|
||||
public const string EXECUTABLE_NAME = "uloop";
|
||||
public const string NPX_EXECUTABLE_NAME = "npx";
|
||||
public const string NPX_YES_FLAG = "--yes";
|
||||
public const string NPM_PACKAGE_NAME = "uloop-cli";
|
||||
public const string VERSION_FLAG = "--version";
|
||||
public const int NPM_INSTALL_TIMEOUT_MS = 30000;
|
||||
public const string SKILL_DIR_PREFIX = "uloop-";
|
||||
public const string SKILL_DIR_GLOB = "uloop-*";
|
||||
public const string SKILL_CLI_INVOCATION_GLOBAL = "global";
|
||||
public const string SKILL_CLI_INVOCATION_NPX = "npx";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d4dee3377edc04b59beef35d2efe1aa8
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace io.github.hatayama.uLoopMCP
|
||||
{
|
||||
public readonly struct CliInstallResult
|
||||
{
|
||||
public readonly bool Success;
|
||||
public readonly string ErrorOutput;
|
||||
|
||||
public CliInstallResult(bool success, string errorOutput)
|
||||
{
|
||||
Success = success;
|
||||
ErrorOutput = errorOutput;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 69b23e7edc3c54fa497a13aa732314d7
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,196 @@
|
||||
using System.Diagnostics;
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using UnityEngine;
|
||||
|
||||
namespace io.github.hatayama.uLoopMCP
|
||||
{
|
||||
public static class CliInstallationDetector
|
||||
{
|
||||
private const int PROCESS_TIMEOUT_MS = 5000;
|
||||
|
||||
private static string _cachedCliVersion;
|
||||
private static bool _cacheInitialized;
|
||||
private static bool _isRefreshing;
|
||||
|
||||
public static bool IsCliInstalled()
|
||||
{
|
||||
return GetCachedCliVersion() != null;
|
||||
}
|
||||
|
||||
public static string GetCachedCliVersion()
|
||||
{
|
||||
return _cacheInitialized ? _cachedCliVersion : null;
|
||||
}
|
||||
|
||||
public static bool IsCheckCompleted()
|
||||
{
|
||||
return _cacheInitialized;
|
||||
}
|
||||
|
||||
public static async Task RefreshCliVersionAsync(CancellationToken ct)
|
||||
{
|
||||
if (_cacheInitialized || _isRefreshing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isRefreshing = true;
|
||||
try
|
||||
{
|
||||
string version = await DetectCliVersionAsync(ct);
|
||||
_cachedCliVersion = version;
|
||||
_cacheInitialized = true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isRefreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
public static bool AreSkillsInstalled(string targetDir)
|
||||
{
|
||||
UnityEngine.Debug.Assert(!string.IsNullOrEmpty(targetDir), "targetDir must not be null or empty");
|
||||
|
||||
string projectRoot = UnityMcpPathResolver.GetProjectRoot();
|
||||
return AreSkillsInstalled(projectRoot, targetDir);
|
||||
}
|
||||
|
||||
public static bool AreSkillsInstalled(string targetDir, bool groupSkillsUnderUnityCliLoop)
|
||||
{
|
||||
UnityEngine.Debug.Assert(!string.IsNullOrEmpty(targetDir), "targetDir must not be null or empty");
|
||||
|
||||
string projectRoot = UnityMcpPathResolver.GetProjectRoot();
|
||||
return AreSkillsInstalled(projectRoot, targetDir, groupSkillsUnderUnityCliLoop);
|
||||
}
|
||||
|
||||
internal static bool AreSkillsInstalled(string projectRoot, string targetDir)
|
||||
{
|
||||
UnityEngine.Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty");
|
||||
UnityEngine.Debug.Assert(!string.IsNullOrEmpty(targetDir), "targetDir must not be null or empty");
|
||||
|
||||
string targetRoot = Path.Combine(projectRoot, targetDir);
|
||||
return SkillInstallLayout.HasInstalledSkills(projectRoot, targetRoot);
|
||||
}
|
||||
|
||||
internal static bool AreSkillsInstalled(
|
||||
string projectRoot,
|
||||
string targetDir,
|
||||
bool groupSkillsUnderUnityCliLoop)
|
||||
{
|
||||
UnityEngine.Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty");
|
||||
UnityEngine.Debug.Assert(!string.IsNullOrEmpty(targetDir), "targetDir must not be null or empty");
|
||||
|
||||
string targetRoot = Path.Combine(projectRoot, targetDir);
|
||||
return SkillInstallLayout.HasInstalledSkills(projectRoot, targetRoot, groupSkillsUnderUnityCliLoop);
|
||||
}
|
||||
|
||||
public static async Task ForceRefreshCliVersionAsync(CancellationToken ct)
|
||||
{
|
||||
string version = await DetectCliVersionAsync(ct);
|
||||
_cachedCliVersion = version;
|
||||
_cacheInitialized = true;
|
||||
}
|
||||
|
||||
public static void InvalidateCache()
|
||||
{
|
||||
_cachedCliVersion = null;
|
||||
_cacheInitialized = false;
|
||||
_isRefreshing = false;
|
||||
}
|
||||
|
||||
private static Task<string> DetectCliVersionAsync(CancellationToken ct)
|
||||
{
|
||||
RuntimePlatform platform = Application.platform;
|
||||
return Task.Run(() => DetectCliVersionBlocking(platform, ct), ct);
|
||||
}
|
||||
|
||||
internal static string DetectCliVersionBlocking(RuntimePlatform platform, CancellationToken ct)
|
||||
{
|
||||
string executablePath = NodeEnvironmentResolver.FindExecutablePathAtPlatform(
|
||||
CliConstants.EXECUTABLE_NAME,
|
||||
platform);
|
||||
// FindExecutablePath resolves .cmd shims on Windows via 'where' command
|
||||
string fileName = executablePath ?? CliConstants.EXECUTABLE_NAME;
|
||||
|
||||
ProcessStartInfo startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = fileName,
|
||||
Arguments = CliConstants.VERSION_FLAG,
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
|
||||
string nodePath = NodeEnvironmentResolver.FindNodePathAtPlatform(platform);
|
||||
NodeEnvironmentResolver.SetupEnvironmentPathAtPlatform(startInfo, nodePath, platform);
|
||||
|
||||
Process process = ProcessStartHelper.TryStart(startInfo);
|
||||
if (process == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
StringBuilder outputBuilder = new StringBuilder();
|
||||
|
||||
process.OutputDataReceived += (sender, e) =>
|
||||
{
|
||||
if (e.Data != null)
|
||||
{
|
||||
outputBuilder.Append(e.Data);
|
||||
}
|
||||
};
|
||||
process.ErrorDataReceived += (sender, e) => { };
|
||||
|
||||
process.BeginOutputReadLine();
|
||||
process.BeginErrorReadLine();
|
||||
|
||||
using CancellationTokenRegistration registration = ct.Register(() =>
|
||||
{
|
||||
try { process.Kill(); } catch (System.InvalidOperationException) { }
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
bool exited = process.WaitForExit(PROCESS_TIMEOUT_MS);
|
||||
|
||||
if (!exited)
|
||||
{
|
||||
try { process.Kill(); } catch (System.InvalidOperationException) { }
|
||||
process.Dispose();
|
||||
return null;
|
||||
}
|
||||
|
||||
// Parameterless WaitForExit flushes async output buffers
|
||||
process.WaitForExit();
|
||||
|
||||
string output = outputBuilder.ToString().Trim();
|
||||
bool failed = process.ExitCode != 0 || string.IsNullOrEmpty(output);
|
||||
process.Dispose();
|
||||
|
||||
return failed ? null : output;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
process.Dispose();
|
||||
if (!ct.IsCancellationRequested)
|
||||
{
|
||||
UnityEngine.Debug.LogWarning($"[uLoopMCP] Failed to detect CLI version: {ex.Message}");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal static class EditorTimestamp
|
||||
{
|
||||
public static double Now()
|
||||
{
|
||||
return UnityEditor.EditorApplication.timeSinceStartup;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f2ca35faabc904c53abc3aa4f6703500
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,99 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using UnityEngine;
|
||||
|
||||
namespace io.github.hatayama.uLoopMCP
|
||||
{
|
||||
public static class CliInstaller
|
||||
{
|
||||
/// <summary>
|
||||
/// Windows: npm global prefix often points to admin-only directories (e.g. C:\Program Files\nodejs).
|
||||
/// Non-Windows: always returns true.
|
||||
/// </summary>
|
||||
public static bool CheckWindowsPermissions(
|
||||
string npmPath,
|
||||
string installTarget,
|
||||
out string globalPrefix,
|
||||
out string manualCommand)
|
||||
{
|
||||
UnityEngine.Debug.Assert(!string.IsNullOrEmpty(npmPath), "npmPath must not be null or empty");
|
||||
UnityEngine.Debug.Assert(!string.IsNullOrEmpty(installTarget), "installTarget must not be null or empty");
|
||||
|
||||
globalPrefix = null;
|
||||
manualCommand = null;
|
||||
|
||||
if (Application.platform != RuntimePlatform.WindowsEditor)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
globalPrefix = NpmInstallDiagnostics.GetGlobalPrefix(npmPath);
|
||||
if (string.IsNullOrEmpty(globalPrefix) || NpmInstallDiagnostics.IsGlobalPrefixWritable(globalPrefix))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
manualCommand = $"npm install -g {installTarget}";
|
||||
return false;
|
||||
}
|
||||
|
||||
public static async Task<CliInstallResult> InstallAsync(string npmPath, string installTarget, string nodePath)
|
||||
{
|
||||
UnityEngine.Debug.Assert(!string.IsNullOrEmpty(npmPath), "npmPath must not be null or empty");
|
||||
UnityEngine.Debug.Assert(!string.IsNullOrEmpty(installTarget), "installTarget must not be null or empty");
|
||||
ProcessStartInfo startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = npmPath,
|
||||
Arguments = $"install -g {installTarget}",
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(nodePath))
|
||||
{
|
||||
NodeEnvironmentResolver.SetupEnvironmentPath(startInfo, nodePath);
|
||||
}
|
||||
|
||||
bool success = false;
|
||||
string errorOutput = "";
|
||||
|
||||
await Task.Run(() =>
|
||||
{
|
||||
Process process = ProcessStartHelper.TryStart(startInfo);
|
||||
if (process == null)
|
||||
{
|
||||
errorOutput = "Failed to start npm process";
|
||||
return;
|
||||
}
|
||||
|
||||
StringBuilder errorBuilder = new StringBuilder();
|
||||
process.OutputDataReceived += (s, e) => { };
|
||||
process.ErrorDataReceived += (s, e) => { if (e.Data != null) errorBuilder.AppendLine(e.Data); };
|
||||
process.BeginOutputReadLine();
|
||||
process.BeginErrorReadLine();
|
||||
|
||||
if (!process.WaitForExit(CliConstants.NPM_INSTALL_TIMEOUT_MS))
|
||||
{
|
||||
if (!process.HasExited) process.Kill();
|
||||
process.Dispose();
|
||||
errorOutput = $"Installation timed out after {CliConstants.NPM_INSTALL_TIMEOUT_MS / 1000} seconds";
|
||||
return;
|
||||
}
|
||||
|
||||
// Parameterless WaitForExit flushes async output buffers
|
||||
process.WaitForExit();
|
||||
errorOutput = errorBuilder.ToString();
|
||||
success = process.ExitCode == 0;
|
||||
process.Dispose();
|
||||
});
|
||||
|
||||
CliInstallationDetector.InvalidateCache();
|
||||
|
||||
return new CliInstallResult(success, errorOutput);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 547608caf378b47ff95df317059ab01d
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,198 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using UnityEngine;
|
||||
|
||||
namespace io.github.hatayama.uLoopMCP
|
||||
{
|
||||
/// <summary>
|
||||
/// Pre-flight diagnostics and error classification for npm global install.
|
||||
/// Windows npm installs often fail silently due to directory permissions
|
||||
/// (e.g. C:\Program Files\nodejs requires admin elevation).
|
||||
/// </summary>
|
||||
public static class NpmInstallDiagnostics
|
||||
{
|
||||
private const int PREFIX_CHECK_TIMEOUT_MS = 5000;
|
||||
|
||||
private const string ERROR_PATTERN_EPERM = "EPERM";
|
||||
private const string ERROR_PATTERN_EACCES = "EACCES";
|
||||
private const string ERROR_PATTERN_OPERATION_NOT_PERMITTED = "operation not permitted";
|
||||
|
||||
/// <summary>
|
||||
/// Returns the npm global prefix path by running `npm prefix -g`, or null if resolution fails.
|
||||
/// </summary>
|
||||
public static string GetGlobalPrefix(string npmPath)
|
||||
{
|
||||
UnityEngine.Debug.Assert(!string.IsNullOrEmpty(npmPath), "npmPath must not be null or empty");
|
||||
|
||||
ProcessStartInfo startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = npmPath,
|
||||
Arguments = "prefix -g",
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
|
||||
NodeEnvironmentResolver.SetupEnvironmentPath(startInfo, NodeEnvironmentResolver.FindNodePath());
|
||||
|
||||
using (Process process = Process.Start(startInfo))
|
||||
{
|
||||
if (process == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
StringBuilder stdoutBuilder = new StringBuilder();
|
||||
|
||||
process.OutputDataReceived += (sender, e) =>
|
||||
{
|
||||
if (e.Data != null)
|
||||
{
|
||||
stdoutBuilder.AppendLine(e.Data);
|
||||
}
|
||||
};
|
||||
process.ErrorDataReceived += (sender, e) => { };
|
||||
|
||||
process.BeginOutputReadLine();
|
||||
process.BeginErrorReadLine();
|
||||
|
||||
if (!process.WaitForExit(PREFIX_CHECK_TIMEOUT_MS))
|
||||
{
|
||||
// Process may exit between WaitForExit(timeout) and Kill() (TOCTOU race)
|
||||
try
|
||||
{
|
||||
process.Kill();
|
||||
process.WaitForExit(1000);
|
||||
}
|
||||
catch (System.InvalidOperationException)
|
||||
{
|
||||
UnityEngine.Debug.Log("Process already exited before Kill() was called");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
process.WaitForExit();
|
||||
|
||||
string output = stdoutBuilder.ToString().Trim();
|
||||
if (process.ExitCode == 0 && !string.IsNullOrEmpty(output))
|
||||
{
|
||||
return output;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether the npm global prefix directory is writable by creating and deleting a temp file.
|
||||
/// Returns true if writable, false otherwise.
|
||||
/// </summary>
|
||||
public static bool IsGlobalPrefixWritable(string globalPrefixPath)
|
||||
{
|
||||
UnityEngine.Debug.Assert(!string.IsNullOrEmpty(globalPrefixPath), "globalPrefixPath must not be null or empty");
|
||||
|
||||
try
|
||||
{
|
||||
// npm creates the prefix directory on first install, so a non-existent directory
|
||||
// does not mean non-writable — test by creating it (no-op if it already exists)
|
||||
if (!Directory.Exists(globalPrefixPath))
|
||||
{
|
||||
Directory.CreateDirectory(globalPrefixPath);
|
||||
}
|
||||
|
||||
string testFilePath = Path.Combine(
|
||||
globalPrefixPath,
|
||||
".uloop_write_test_" + System.Guid.NewGuid().ToString("N").Substring(0, 8));
|
||||
File.WriteAllText(testFilePath, "");
|
||||
File.Delete(testFilePath);
|
||||
return true;
|
||||
}
|
||||
// OS permission denial cannot be pre-checked with assertions; must attempt the operation
|
||||
catch (System.UnauthorizedAccessException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// npm prefix -g output is external input — malformed paths throw these exceptions
|
||||
// Return true (indeterminate) to skip pre-flight and let npm attempt the actual install
|
||||
catch (System.ArgumentException e)
|
||||
{
|
||||
UnityEngine.Debug.LogWarning("[uLoopMCP] Cannot validate npm global prefix path: " + e.Message);
|
||||
return true;
|
||||
}
|
||||
catch (System.NotSupportedException e)
|
||||
{
|
||||
UnityEngine.Debug.LogWarning("[uLoopMCP] Cannot validate npm global prefix path: " + e.Message);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Analyzes npm stderr output and returns a user-friendly guidance message.
|
||||
/// Returns null if no specific pattern is matched (caller falls back to raw error).
|
||||
/// </summary>
|
||||
public static string ClassifyInstallError(string stderrOutput, string manualCommand)
|
||||
{
|
||||
UnityEngine.Debug.Assert(!string.IsNullOrEmpty(manualCommand), "manualCommand must not be null or empty");
|
||||
|
||||
if (string.IsNullOrEmpty(stderrOutput))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!IsPermissionError(stderrOutput))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return "npm does not have permission to write to the global directory.\n\n"
|
||||
+ BuildPermissionSolutions(manualCommand);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Combines classified guidance with the raw npm stderr so users see both
|
||||
/// the actionable suggestion and the original error details.
|
||||
/// </summary>
|
||||
public static string BuildInstallErrorMessage(string guidance, string rawStderr)
|
||||
{
|
||||
UnityEngine.Debug.Assert(!string.IsNullOrEmpty(guidance), "guidance must not be null or empty");
|
||||
UnityEngine.Debug.Assert(!string.IsNullOrEmpty(rawStderr), "rawStderr must not be null or empty");
|
||||
|
||||
return guidance + "\n\n--- npm output ---\n" + rawStderr;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the common "Solutions" block shared by the pre-flight dialog and
|
||||
/// the post-install error classifier.
|
||||
/// </summary>
|
||||
public static string BuildPermissionSolutions(string manualCommand)
|
||||
{
|
||||
UnityEngine.Debug.Assert(!string.IsNullOrEmpty(manualCommand), "manualCommand must not be null or empty");
|
||||
|
||||
return "Solutions:\n"
|
||||
+ "1. Open a terminal as Administrator and run:\n"
|
||||
+ " " + manualCommand + "\n\n"
|
||||
+ "2. Or change npm's global prefix to a user-writable directory:\n"
|
||||
+ " Step 1: Open Command Prompt (cmd.exe) and run:\n"
|
||||
+ " npm config set prefix \"%USERPROFILE%\\.npm-global\"\n"
|
||||
+ " (PowerShell: npm config set prefix \"$env:USERPROFILE\\.npm-global\")\n"
|
||||
+ " Step 2: Add %USERPROFILE%\\.npm-global to your PATH\n"
|
||||
+ " (System Settings > Environment Variables > Path)";
|
||||
}
|
||||
|
||||
internal static bool IsPermissionError(string stderrOutput)
|
||||
{
|
||||
UnityEngine.Debug.Assert(stderrOutput != null, "stderrOutput must not be null");
|
||||
|
||||
return stderrOutput.Contains(ERROR_PATTERN_EPERM)
|
||||
|| stderrOutput.Contains(ERROR_PATTERN_EACCES)
|
||||
|| stderrOutput.IndexOf(ERROR_PATTERN_OPERATION_NOT_PERMITTED, System.StringComparison.OrdinalIgnoreCase) >= 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: faa6f3b29439b4cb484ad289fb21247e
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Reference in New Issue
Block a user