diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ErrorCodes.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ErrorCodes.cs index 55f3df5..d820c44 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ErrorCodes.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ErrorCodes.cs @@ -16,7 +16,10 @@ public static class ErrorCodes public const string DeploymentScopesFailed = "DEPLOYMENT_SCOPES_FAILED"; public const string DeploymentMcpFailed = "DEPLOYMENT_MCP_FAILED"; public const string HighPrivilegeScopeDetected = "HIGH_PRIVILEGE_SCOPE_DETECTED"; - public const string SetupValidationFailed = "SETUP_VALIDATION_FAILED"; + public const string NodeBuildFailed = "NODE_BUILD_FAILED"; + public const string NodeDependencyInstallFailed = "NODE_DEPENDENCY_INSTALL_FAILED"; + public const string NodeProjectNotFound = "NODE_PROJECT_NOT_FOUND"; public const string RetryExhausted = "RETRY_EXHAUSTED"; + public const string SetupValidationFailed = "SETUP_VALIDATION_FAILED"; } } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Exceptions/NodeBuildFailedException.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Exceptions/NodeBuildFailedException.cs new file mode 100644 index 0000000..fac3c97 --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Exceptions/NodeBuildFailedException.cs @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Agents.A365.DevTools.Cli.Constants; + +namespace Microsoft.Agents.A365.DevTools.Cli.Exceptions; + +/// +/// Thrown when the build process of a Node.js project fails. +/// +public sealed class NodeBuildFailedException : Agent365Exception +{ + public override int ExitCode => 1; + public override bool IsUserError => true; + + public NodeBuildFailedException(string projectDirectory, string? npmErrorOutput) + : base( + errorCode: ErrorCodes.NodeBuildFailed, + issueDescription: "Failed to build the Node.js project using 'npm run build'.", + errorDetails: BuildDetails(projectDirectory, npmErrorOutput), + mitigationSteps: new List + { + "Run 'npm run build' locally in the project directory and fix any TypeScript/webpack/build errors.", + "Verify that the 'build' script is defined correctly in package.json.", + "If the build depends on environment variables or private packages, ensure those are configured on the machine running 'a365 deploy'.", + "After resolving the build issues, rerun 'a365 deploy'." + }, + context: new Dictionary + { + ["ProjectDirectory"] = projectDirectory + }) + { + } + + private static List BuildDetails(string projectDirectory, string? npmErrorOutput) + { + var details = new List + { + $"Project directory: {projectDirectory}", + }; + + if (!string.IsNullOrWhiteSpace(npmErrorOutput)) + { + details.Add("npm build error output (truncated):"); + details.Add($" {TrimError(npmErrorOutput)}"); + } + + return details; + } + + private static string TrimError(string error) + { + const int maxLen = 400; + error = error.Trim(); + return error.Length <= maxLen ? error : error[..maxLen] + " ..."; + } +} \ No newline at end of file diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Exceptions/NodeDependencyInstallException.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Exceptions/NodeDependencyInstallException.cs new file mode 100644 index 0000000..a2ba9d5 --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Exceptions/NodeDependencyInstallException.cs @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Agents.A365.DevTools.Cli.Constants; + +namespace Microsoft.Agents.A365.DevTools.Cli.Exceptions; + +/// +/// Thrown when installation of Node.js dependencies fails. +/// +public class NodeDependencyInstallException : Agent365Exception +{ + public override int ExitCode => 1; + public override bool IsUserError => true; + + public NodeDependencyInstallException(string projectDirectory, string? npmErrorOutput) + : base( + errorCode: ErrorCodes.NodeDependencyInstallFailed, + issueDescription: "Failed to install Node.js dependencies for the project.", + errorDetails: BuildDetails(projectDirectory, npmErrorOutput), + mitigationSteps: new List + { + "Run 'npm install' (or 'npm ci') locally in the project directory and fix any errors.", + "Check that your internet connection and npm registry access are working.", + "If you use a private registry or npm auth, ensure those settings are configured on the machine running 'a365 deploy'.", + "After fixing the issue, rerun 'a365 deploy'." + }, + context: new Dictionary + { + ["ProjectDirectory"] = projectDirectory + }) + { + } + + private static List BuildDetails(string projectDirectory, string? npmErrorOutput) + { + var details = new List + { + $"Project directory: {projectDirectory}", + }; + + if (!string.IsNullOrWhiteSpace(npmErrorOutput)) + { + details.Add("npm error output (truncated):"); + details.Add($" {TrimError(npmErrorOutput)}"); + } + + return details; + } + + private static string TrimError(string error) + { + const int maxLen = 400; + error = error.Trim(); + return error.Length <= maxLen ? error : error[..maxLen] + " ..."; + } +} \ No newline at end of file diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Exceptions/NodeProjectNotFoundException.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Exceptions/NodeProjectNotFoundException.cs new file mode 100644 index 0000000..9ed5d7e --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Exceptions/NodeProjectNotFoundException.cs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Agents.A365.DevTools.Cli.Constants; + +namespace Microsoft.Agents.A365.DevTools.Cli.Exceptions; + +/// +/// Thrown when a Node.js deployment is requested from a directory that does not contain a package.json. +/// +public class NodeProjectNotFoundException : Agent365Exception +{ + public override int ExitCode => 1; + public override bool IsUserError => true; + + public NodeProjectNotFoundException(string projectDirectory) + : base( + errorCode: ErrorCodes.NodeProjectNotFound, + issueDescription: "No Node.js project was found in the specified directory.", + errorDetails: new List + { + "The deployment expects a package.json file to identify the Node.js project.", + $"Checked directory: {projectDirectory}" + }, + mitigationSteps: new List + { + "Run this command from the root of your Node.js project (where package.json is located), or", + "Update the --project-path in your a365 config to point to the folder containing package.json.", + "Verify that package.json is checked into source control and not ignored or deleted." + }, + context: new Dictionary + { + ["ProjectDirectory"] = projectDirectory + }) + { + } +} \ No newline at end of file diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/NodeBuilder.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/NodeBuilder.cs index 05b6c55..46e9d0e 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/NodeBuilder.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/NodeBuilder.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using System.Text.Json; +using Microsoft.Agents.A365.DevTools.Cli.Exceptions; using Microsoft.Agents.A365.DevTools.Cli.Models; using Microsoft.Agents.A365.DevTools.Cli.Services.Helpers; using Microsoft.Extensions.Logging; @@ -77,7 +78,7 @@ public async Task BuildAsync(string projectDir, string outputPath, bool var packageJsonPath = Path.Combine(projectDir, "package.json"); if (!File.Exists(packageJsonPath)) { - throw new FileNotFoundException("package.json not found in project directory"); + throw new NodeProjectNotFoundException(projectDir); } // Install dependencies @@ -89,7 +90,7 @@ public async Task BuildAsync(string projectDir, string outputPath, bool installResult = await _helper.ExecuteWithOutputAsync("npm", "install", projectDir, verbose); if (!installResult.Success) { - throw new Exception($"npm install failed: {installResult.StandardError}"); + throw new NodeDependencyInstallException(projectDir, installResult.StandardError); } } @@ -103,7 +104,7 @@ public async Task BuildAsync(string projectDir, string outputPath, bool var buildResult = await _helper.ExecuteWithOutputAsync("npm", "run build", projectDir, verbose); if (!buildResult.Success) { - throw new Exception($"npm run build failed: {buildResult.StandardError}"); + throw new NodeBuildFailedException(projectDir, buildResult.StandardError); } } else @@ -145,7 +146,7 @@ public async Task BuildAsync(string projectDir, string outputPath, bool CopyDirectory(srcDir, Path.Combine(publishPath, "src")); } - // Copy server files (.js files in root) + // Copy server/entry files (.js/.ts files in root) foreach (var jsFile in Directory.GetFiles(projectDir, "*.js")) { File.Copy(jsFile, Path.Combine(publishPath, Path.GetFileName(jsFile))); @@ -155,6 +156,17 @@ public async Task BuildAsync(string projectDir, string outputPath, bool File.Copy(tsFile, Path.Combine(publishPath, Path.GetFileName(tsFile))); } + var distDir = Path.Combine(projectDir, "dist"); + if (Directory.Exists(distDir)) + { + _logger.LogInformation("Found dist folder, copying to publish output..."); + CopyDirectory(distDir, Path.Combine(publishPath, "dist")); + } + else + { + _logger.LogInformation("No dist folder found in project; relying on Oryx build (if configured) to produce runtime output."); + } + // Create .deployment file to force Oryx build during Azure deployment await CreateDeploymentFile(publishPath); @@ -166,6 +178,11 @@ public async Task CreateManifestAsync(string projectDir, string pu _logger.LogInformation("Creating Oryx manifest for Node.js..."); var packageJsonPath = Path.Combine(projectDir, "package.json"); + if (!File.Exists(packageJsonPath)) + { + throw new NodeProjectNotFoundException(projectDir); + } + var packageJson = await File.ReadAllTextAsync(packageJsonPath); // Parse package.json to detect start command and version @@ -177,8 +194,7 @@ public async Task CreateManifestAsync(string projectDir, string pu if (root.TryGetProperty("engines", out var engines) && engines.TryGetProperty("node", out var nodeVersionProp)) { - var versionString = nodeVersionProp.GetString() ?? "18"; - // Extract major version (e.g., "18.x" -> "18") + var versionString = nodeVersionProp.GetString() ?? "20"; var match = System.Text.RegularExpressions.Regex.Match(versionString, @"(\d+)"); if (match.Success) { @@ -192,7 +208,13 @@ public async Task CreateManifestAsync(string projectDir, string pu if (root.TryGetProperty("scripts", out var scripts) && scripts.TryGetProperty("start", out var startScript)) { - startCommand = startScript.GetString() ?? startCommand; + var scriptValue = startScript.GetString(); + if (!string.IsNullOrWhiteSpace(scriptValue)) + { + // Use the script literally, same as package.json + startCommand = scriptValue; + } + _logger.LogInformation("Detected start command from package.json: {Command}", startCommand); } else if (root.TryGetProperty("main", out var mainProp)) @@ -203,25 +225,38 @@ public async Task CreateManifestAsync(string projectDir, string pu } else { - // Look for common entry point files + // Look for common entry point files under publish path var commonEntryPoints = new[] { "server.js", "app.js", "index.js", "main.js" }; foreach (var entryPoint in commonEntryPoints) { if (File.Exists(Path.Combine(publishPath, entryPoint))) { startCommand = $"node {entryPoint}"; - _logger.LogInformation("Detected entry point: {Command}", startCommand); + _logger.LogInformation("Detected entry point in publish folder: {Command}", startCommand); break; } } } - var buildCommand = "npm run build"; - var hasBuildScript = scripts.TryGetProperty("build", out var buildScript); - if (hasBuildScript) + // Detect build command – strictly Node + string buildCommand = ""; + bool buildRequired = false; + + if (root.TryGetProperty("scripts", out var scriptsForBuild) && + scriptsForBuild.TryGetProperty("build", out var buildScript)) + { + var buildValue = buildScript.GetString(); + if (!string.IsNullOrWhiteSpace(buildValue)) + { + // We always call through npm so it picks up the script from package.json + buildCommand = "npm run build"; + buildRequired = true; + _logger.LogInformation("Detected build script; using Oryx build command: {Command}", buildCommand); + } + } + else { - buildCommand = buildScript.GetString() ?? buildCommand; - _logger.LogInformation("Detected build command from package.json: {Command}", buildCommand); + _logger.LogInformation("No build script found; Oryx will only run npm install."); } return new OryxManifest @@ -229,8 +264,8 @@ public async Task CreateManifestAsync(string projectDir, string pu Platform = "nodejs", Version = nodeVersion, Command = startCommand, - BuildCommand = hasBuildScript ? buildCommand : "", - BuildRequired = hasBuildScript + BuildCommand = buildCommand, + BuildRequired = buildRequired }; }