Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Thrown when the build process of a Node.js project fails.
/// </summary>
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<string>
{
"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<string, string>
{
["ProjectDirectory"] = projectDirectory
})
{
}

private static List<string> BuildDetails(string projectDirectory, string? npmErrorOutput)
{
var details = new List<string>
{
$"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] + " ...";
}
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Thrown when installation of Node.js dependencies fails.
/// </summary>
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<string>
{
"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<string, string>
{
["ProjectDirectory"] = projectDirectory
})
{
}

private static List<string> BuildDetails(string projectDirectory, string? npmErrorOutput)
{
var details = new List<string>
{
$"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] + " ...";
}
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Thrown when a Node.js deployment is requested from a directory that does not contain a package.json.
/// </summary>
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<string>
{
"The deployment expects a package.json file to identify the Node.js project.",
$"Checked directory: {projectDirectory}"
},
mitigationSteps: new List<string>
{
"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<string, string>
{
["ProjectDirectory"] = projectDirectory
})
{
}
}
67 changes: 51 additions & 16 deletions src/Microsoft.Agents.A365.DevTools.Cli/Services/NodeBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -77,7 +78,7 @@ public async Task<string> 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
Expand All @@ -89,7 +90,7 @@ public async Task<string> 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);
}
}

Expand All @@ -103,7 +104,7 @@ public async Task<string> 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
Expand Down Expand Up @@ -145,7 +146,7 @@ public async Task<string> 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)));
Expand All @@ -155,6 +156,17 @@ public async Task<string> 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);

Expand All @@ -166,6 +178,11 @@ public async Task<OryxManifest> 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
Expand All @@ -177,8 +194,7 @@ public async Task<OryxManifest> 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)
{
Expand All @@ -192,7 +208,13 @@ public async Task<OryxManifest> 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))
Expand All @@ -203,34 +225,47 @@ public async Task<OryxManifest> 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
{
Platform = "nodejs",
Version = nodeVersion,
Command = startCommand,
BuildCommand = hasBuildScript ? buildCommand : "",
BuildRequired = hasBuildScript
BuildCommand = buildCommand,
BuildRequired = buildRequired
};
}

Expand Down