Editor/HexagonPublishTools.cs

Editor MCP toolset for Hexagon package publishing. It lists publishable projects and automates publishing a new package revision by compiling (optional), querying which files are missing, uploading files via Project.Package.UploadFile, and calling ProjectPublisher.Publish to create the revision.

NetworkingFile Access
🌐 https://sbox.game/
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Sandbox;   // Package.UploadFile is an extension method declared in this namespace.

namespace Editor.Mcp;

/// <summary>
/// Publishing automation for the Hexagon packages, exposed over the editor's MCP server so a release
/// can be driven without clicking through the publish wizard.
/// <para>
/// s&amp;box has no command-line publish path — <c>sbox-dev.exe</c> only understands
/// <c>-generatesolution</c>, <c>-project</c> and <c>-test</c> — and the shipped <c>package</c> toolset
/// is read-only. Publishing goes through <see cref="ProjectPublisher"/>, which uses the editor's
/// authenticated session. So this removes the manual wizard, not the editor: the editor must be running
/// and signed in, and this cannot run headless in CI.
/// </para>
/// <para>
/// Tools are discovered from every loaded editor assembly (<c>ToolRegistry</c> enumerates
/// <c>EditorTypeLibrary.GetMethodsWithAttribute&lt;McpToolAttribute&gt;()</c>), which is why an addon
/// can register its own toolset at all.
/// </para>
/// </summary>
[McpToolset( "hexagon_release", "Hexagon release automation - inspect and publish package revisions to sbox.game" )]
public static class HexagonPublishTools
{
	/// <summary>
	/// Lists the projects currently open in the editor that can be published, with the ident
	/// <c>publish_package</c> expects. Use this first rather than guessing an ident.
	/// </summary>
	[McpTool.ReadOnly( "list_publishable_projects" )]
	public static ProjectSummary[] ListPublishableProjects()
	{
		return EditorUtility.Projects.GetAll()
			.Where( project => project?.Config is not null )
			.Select( project => new ProjectSummary
			{
				Ident = project.Config.FullIdent,
				SourcePublish = project.IsSourcePublish(),
				StandaloneOnly = project.Config.IsStandaloneOnly,
				ConfigPath = project.ConfigFilePath
			} )
			.OrderBy( summary => summary.Ident, StringComparer.Ordinal )
			.ToArray();
	}

	/// <summary>
	/// Publishes a new revision of a package to sbox.game. This is an outward, public action: it makes
	/// the current working tree the code that servers and clients will download. It is deliberately not
	/// marked read-only so the client can prompt.
	/// </summary>
	/// <param name="ident">Package ident, e.g. 'kbj.hexagon'. Get it from list_publishable_projects.</param>
	/// <param name="changeTitle">Revision title, shown in the package's change history.</param>
	/// <param name="changeDetail">Optional longer description of what changed.</param>
	/// <param name="compileFirst">Compile the project before publishing, as the wizard does, so compiler diagnostics appear in the editor before anything is uploaded.</param>
	[McpTool( "publish_package" )]
	public static async Task<PublishOutcome> PublishPackage(
		string ident,
		string changeTitle,
		string? changeDetail = null,
		bool compileFirst = true )
	{
		if ( string.IsNullOrWhiteSpace( ident ) )
			throw new ArgumentException( "An package ident is required, e.g. 'kbj.hexagon'.", nameof( ident ) );
		if ( string.IsNullOrWhiteSpace( changeTitle ) )
			throw new ArgumentException( "A change title is required so the revision is identifiable.", nameof( changeTitle ) );

		var project = EditorUtility.Projects.GetAll()
			.FirstOrDefault( candidate => candidate?.Config is not null
				&& string.Equals( candidate.Config.FullIdent, ident, StringComparison.OrdinalIgnoreCase ) );

		if ( project is null )
		{
			var known = string.Join( ", ", ListPublishableProjects().Select( summary => summary.Ident ) );
			throw new Exception( $"No open project has ident '{ident}'. Open projects: {known}" );
		}

		if ( project.Config.IsStandaloneOnly )
			throw new Exception( $"'{ident}' is standalone-only and cannot be published as a package." );

		// Compile for its diagnostics, as the wizard does. The result is deliberately NOT inspected for
		// success: CompilerOutput ships as a binary reference, so any "did it fail" predicate here would
		// be guessed rather than verified, and a detector nobody can check is worse than none. The build
		// gates that run before a publish (dotnet build of the Code csproj, and verify.ps1) are what
		// establish the tree compiles.
		if ( compileFirst )
		{
			var compileLog = new System.Text.StringBuilder();
			await EditorUtility.Projects.Compile( project, line => compileLog.AppendLine( line ) );
		}

		var publisher = await ProjectPublisher.FromProject( project )
			?? throw new Exception( $"Could not build a publisher for '{ident}'." );

		// PrePublish only ASKS the backend which files it already has; it marks those Skip=true and
		// uploads nothing. The bytes go up in a separate pass, which the wizard does in its own
		// FinishAsync rather than inside ProjectPublisher — so driving the publisher directly means
		// replicating it. Omitting this posts a manifest referencing files the backend never received,
		// which is a silent no-op rather than an error.
		await publisher.PrePublish();

		var totalFiles = publisher.TotalFileCount;
		var uploadBytes = publisher.MissingFileSize;

		// Local function so the project's type never has to be named - it is not in scope here by name.
		async Task UploadOne( ProjectPublisher.ProjectFile file )
		{
			if ( file.Contents is not null )
			{
				if ( await project.Package.UploadFile( file.Contents, file.Name, _ => { } ) )
					file.Skip = true;
			}
			else if ( file.AbsolutePath is not null )
			{
				if ( await project.Package.UploadFile( file.AbsolutePath, file.Name, _ => { } ) )
					file.Skip = true;
			}
		}

		var pending = publisher.Files.Where( file => !file.Skip && file.Size > 0 ).ToArray();
		var uploadedFiles = 0;
		var running = new List<Task>();

		foreach ( var file in pending )
		{
			running.Add( UploadOne( file ) );

			// Match the wizard's concurrency rather than opening 238 sockets at once.
			while ( running.Count > 8 )
			{
				await Task.WhenAny( running );
				running.RemoveAll( task => task.IsCompleted );
			}
		}
		await Task.WhenAll( running );

		uploadedFiles = pending.Count( file => file.Skip );

		// The wizard's own success condition, and one that is genuinely checkable: every file must have
		// been accepted. Refuse to post a manifest that references bytes the backend does not hold.
		var failed = publisher.Files.Where( file => !file.Skip && file.Size > 0 ).ToArray();
		if ( failed.Length > 0 )
		{
			var names = string.Join( ", ", failed.Take( 5 ).Select( file => file.Name ) );
			throw new Exception(
				$"{failed.Length} of {pending.Length} file(s) failed to upload for '{ident}', so no revision " +
				$"was published. First failures: {names}" );
		}

		publisher.SetChangeDetails( changeTitle, changeDetail ?? string.Empty );
		await publisher.Publish();

		return new PublishOutcome
		{
			Ident = publisher.TargetPackageIdent,
			ChangeTitle = changeTitle,
			SourcePublish = project.IsSourcePublish(),
			TotalFiles = totalFiles,
			UploadedFiles = uploadedFiles,
			UploadedBytes = uploadBytes,
			Url = $"https://sbox.game/{publisher.TargetPackageIdent.Replace( '.', '/' )}"
		};
	}

	/// <summary>A project open in the editor that could be published.</summary>
	public class ProjectSummary
	{
		/// <summary>The ident publish_package takes, e.g. 'kbj.hexagon'.</summary>
		public string Ident { get; set; } = string.Empty;

		/// <summary>True for a library, which publishes source; false for a game, which publishes compiled assets.</summary>
		public bool SourcePublish { get; set; }

		public bool StandaloneOnly { get; set; }

		public string ConfigPath { get; set; } = string.Empty;
	}

	/// <summary>What a publish actually did.</summary>
	public class PublishOutcome
	{
		public string Ident { get; set; } = string.Empty;
		public string ChangeTitle { get; set; } = string.Empty;
		public bool SourcePublish { get; set; }

		/// <summary>Files in the manifest for this revision.</summary>
		public int TotalFiles { get; set; }

		/// <summary>Files the backend did not already have, so the ones this revision actually uploaded.</summary>
		public int UploadedFiles { get; set; }

		public long UploadedBytes { get; set; }

		public string Url { get; set; } = string.Empty;
	}
}