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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ Planned version: **1.5.4**.
suggests ordinary domain users with delegation (#705).
- The step details of a past run label the workflow's current configuration as such and show the
target machine the run actually used (#603).
- A `forEach` parallelism of 0 is saved as 0 (parallel up to the engine limit) instead of 1 (#602).
- After a reconnect or a reload, the designer shows a paused debug step with its variables again
and catches up on steps that changed during the disconnect (#606).
- Variable edits at a breakpoint belong to that one pause and are dropped when another step
Expand All @@ -73,6 +74,9 @@ Planned version: **1.5.4**.
- Input values that look like `{{…}}` stay plain text and no longer fail the step as unresolved
references (#686).
- Editing a text file keeps its permissions (#683).
- `wmiQuery` method calls pass their arguments in the types the CIM method declares, so a UInt32
above 2147483647 such as `hDefKey` 2147483649 (HKCU) works. A value outside the declared range
fails the step with a message that names the argument (#616).
- **Notifications**
- A run that finishes while the database is briefly unavailable still triggers its notification
rules once the commit arrives, even when other runs finished later (#678).
Expand All @@ -84,6 +88,13 @@ Planned version: **1.5.4**.
- The MCP server rejects broken nodes and edges before it merges a workflow change (#726).
- All MCP requests, including dependency queries, wait until the server connection is ready
(#727).
- `np auth login --password-stdin` sends the password as read, including leading and trailing
spaces, so the CLI and the Engine Switcher sign in with such passwords again (#617).
- `np backup manifest`, `preview` and `restore` honour `-o json` and `-o yaml` (#618).
- `np machine create --ssl` without `--port` and the MCP tool `create_machine` with `useSsl` and no
port use the HTTPS port 5986 (#619).
- `-o yaml` keeps multiline strings, empty lists and objects, and strings such as `null`, `yes`
or a date as they are when the output is parsed again (#621).
- **Setup and uninstall**
- A server uninstall whose cleanup fails stops before the "Apps & features" entry is removed,
keeps the installation marker and the deploy scripts and exits with a non-zero code, so the
Expand Down
3 changes: 2 additions & 1 deletion src/NodePilot.Cli/Commands/Auth/AuthCommands.cs
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,8 @@ protected override async Task<int> ExecuteAsync(CommandContext context, LoginSet
var username = settings.Username ?? await AnsiConsole.AskAsync<string>("Username:");
string password;
if (settings.PasswordStdin)
password = ((await Console.In.ReadLineAsync()) ?? "").Trim();
// ReadLine already drops the line ending; spaces belong to the password.
password = (await Console.In.ReadLineAsync()) ?? "";
else if (!string.IsNullOrEmpty(settings.Password))
password = settings.Password;
else
Expand Down
33 changes: 21 additions & 12 deletions src/NodePilot.Cli/Commands/Backup/BackupCommands.cs
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,13 @@ protected override async Task<int> RunAsync(CommandContext _, GlobalSettings set
var api = ClientFactory.Create(session);
var manifest = await api.GetBackupManifestAsync(ct);

var table = new Table().AddColumn("Section").AddColumn(new TableColumn("Count").RightAligned());
foreach (var s in manifest.Sections)
table.AddRow(Markup.Escape(s.Section), s.Count.ToString());
AnsiConsole.Write(table);
writer.WriteData(manifest, (console, value) =>
{
var table = new Table().AddColumn("Section").AddColumn(new TableColumn("Count").RightAligned());
foreach (var s in value.Sections)
table.AddRow(Markup.Escape(s.Section), s.Count.ToString());
console.Write(table);
});
return ExitCodes.Success;
}
}
Expand Down Expand Up @@ -153,10 +156,13 @@ protected override async Task<int> RunAsync(CommandContext _, BackupPreviewSetti
var result = await api.PreviewBackupAsync(content, passphrase, ct);

writer.Info($"Backup app version: {result.AppVersion ?? "?"} — integrity [green]verified[/]");
var table = new Table().AddColumn("Section").AddColumn("In backup").AddColumn("New").AddColumn("Conflicts");
foreach (var s in result.Sections)
table.AddRow(Markup.Escape(s.Section), s.InBackup.ToString(), s.New.ToString(), s.Conflicts.ToString());
AnsiConsole.Write(table);
writer.WriteData(result, (console, value) =>
{
var table = new Table().AddColumn("Section").AddColumn("In backup").AddColumn("New").AddColumn("Conflicts");
foreach (var s in value.Sections)
table.AddRow(Markup.Escape(s.Section), s.InBackup.ToString(), s.New.ToString(), s.Conflicts.ToString());
console.Write(table);
});
foreach (var w in result.Warnings) writer.Warning($" ! {w}");
return ExitCodes.Success;
}
Expand Down Expand Up @@ -212,10 +218,13 @@ protected override async Task<int> RunAsync(CommandContext _, BackupRestoreSetti
var content = await File.ReadAllBytesAsync(settings.File, ct);
var result = await api.RestoreBackupAsync(content, passphrase, settings.Policy, ct);

var table = new Table().AddColumn("Section").AddColumn("Created").AddColumn("Overwritten").AddColumn("Skipped").AddColumn("Renamed");
foreach (var s in result.Sections)
table.AddRow(Markup.Escape(s.Section), s.Created.ToString(), s.Overwritten.ToString(), s.Skipped.ToString(), s.Renamed.ToString());
AnsiConsole.Write(table);
writer.WriteData(result, (console, value) =>
{
var table = new Table().AddColumn("Section").AddColumn("Created").AddColumn("Overwritten").AddColumn("Skipped").AddColumn("Renamed");
foreach (var s in value.Sections)
table.AddRow(Markup.Escape(s.Section), s.Created.ToString(), s.Overwritten.ToString(), s.Skipped.ToString(), s.Renamed.ToString());
console.Write(table);
});
if (result.Settings is not null)
writer.Info($"Settings: {(result.Settings.Applied ? "[green]applied[/]" : "[yellow]not applied[/]")} — {Markup.Escape(result.Settings.Message ?? "")}");
foreach (var w in result.Warnings) writer.Warning($" ! {w}");
Expand Down
5 changes: 3 additions & 2 deletions src/NodePilot.Cli/Commands/Machines/MachineCommands.cs
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ public class MachineWriteSettings : GlobalSettings
public string? Hostname { get; set; }

[CommandOption("--port <PORT>")]
[Description("WinRM port (default 5985 on create).")]
[Description("WinRM port (default on create: 5985, or 5986 with --ssl).")]
public int? Port { get; set; }

[CommandOption("--ssl")]
Expand Down Expand Up @@ -83,9 +83,10 @@ protected override async Task<int> RunAsync(CommandContext _, MachineWriteSettin
}

var api = ClientFactory.Create(session);
var useSsl = settings.UseSsl ?? false;
var req = new CreateMachineRequest(
settings.Name, settings.Hostname,
settings.Port ?? 5985, settings.UseSsl ?? false,
settings.Port ?? (useSsl ? 5986 : 5985), useSsl,
settings.CredentialId, settings.Tags);
var created = await api.CreateMachineAsync(req, ct);
writer.Success($"Machine created: [bold]{Markup.Escape(created.Name)}[/] ({created.Id}).");
Expand Down
87 changes: 66 additions & 21 deletions src/NodePilot.Cli/Output/YamlEmitter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ namespace NodePilot.Cli.Output;

/// <summary>
/// Minimal YAML emitter for CLI output: a human-readable, line-oriented rendering of API
/// responses only, with no round-trip parsing. Kept in-house to avoid pulling in YamlDotNet
/// just for `--output yaml`.
/// responses whose values read back unchanged with a YAML parser. Kept in-house to avoid
/// pulling in YamlDotNet just for `--output yaml`.
/// </summary>
public static class YamlEmitter
{
Expand All @@ -21,54 +21,71 @@ public static string Emit<T>(T value)
return sb.ToString();
}

private static readonly char[] IndicatorChars =
[':', '#', '"', '\'', '{', '}', '[', ']', ',', '&', '*', '!', '|', '>', '%', '@', '`'];

// Plain words that YAML 1.1 or 1.2 resolves to null or a boolean instead of a string.
private static readonly HashSet<string> NonStringWords = new(StringComparer.OrdinalIgnoreCase)
{ "null", "true", "false", "yes", "no", "on", "off", "y", "n" };

private static void Write(JsonElement el, StringBuilder sb, int indent, bool isListItem)
{
var pad = new string(' ', indent);
switch (el.ValueKind)
{
case JsonValueKind.Object:
case JsonValueKind.Object when el.EnumerateObject().Any():
bool first = true;
foreach (var p in el.EnumerateObject())
{
var prefix = first && isListItem ? "" : pad;
if (IsScalar(p.Value))
if (IsInline(p.Value))
{
sb.Append(prefix).Append(p.Name).Append(": ").AppendLine(Scalar(p.Value));
sb.Append(prefix).Append(Quote(p.Name)).Append(": ").AppendLine(Inline(p.Value));
}
else
{
sb.Append(prefix).Append(p.Name).AppendLine(":");
sb.Append(prefix).Append(Quote(p.Name)).AppendLine(":");
Write(p.Value, sb, indent + 2, isListItem: false);
}
first = false;
}
break;
case JsonValueKind.Array:
case JsonValueKind.Array when el.GetArrayLength() > 0:
bool firstItem = true;
foreach (var item in el.EnumerateArray())
{
if (IsScalar(item))
var prefix = firstItem && isListItem ? "" : pad;
if (IsInline(item))
{
sb.Append(pad).Append("- ").AppendLine(Scalar(item));
sb.Append(prefix).Append("- ").AppendLine(Inline(item));
}
else
{
sb.Append(pad).Append("- ");
sb.Append(prefix).Append("- ");
Write(item, sb, indent + 2, isListItem: true);
}
firstItem = false;
}
break;
default:
sb.Append(pad).AppendLine(Scalar(el));
// Only the root gets here: nested scalars and empty collections are written inline.
sb.AppendLine(Inline(el));
break;
}
}

private static bool IsScalar(JsonElement el) => el.ValueKind
is JsonValueKind.String or JsonValueKind.Number or JsonValueKind.True
or JsonValueKind.False or JsonValueKind.Null;
/// <summary>Scalars and empty collections fit on the line of their key or list dash.</summary>
private static bool IsInline(JsonElement el) => el.ValueKind switch
{
JsonValueKind.Object => !el.EnumerateObject().Any(),
JsonValueKind.Array => el.GetArrayLength() == 0,
_ => true,
};

private static string Scalar(JsonElement el) => el.ValueKind switch
private static string Inline(JsonElement el) => el.ValueKind switch
{
JsonValueKind.Object => "{}",
JsonValueKind.Array => "[]",
JsonValueKind.String => Quote(el.GetString() ?? ""),
JsonValueKind.Number => el.GetRawText(),
JsonValueKind.True => "true",
Expand All @@ -79,11 +96,39 @@ is JsonValueKind.String or JsonValueKind.Number or JsonValueKind.True

private static string Quote(string s)
{
if (s.Length == 0) return "''";
bool needsQuote = s.IndexOfAny(new[] { ':', '#', '\n', '\r', '"', '\'', '{', '}', '[', ']', ',', '&', '*', '!', '|', '>', '%', '@', '`' }) >= 0
|| char.IsWhiteSpace(s[0]) || char.IsWhiteSpace(s[^1])
|| bool.TryParse(s, out _) || double.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out _);
if (!needsQuote) return s;
return "\"" + s.Replace("\\", "\\\\").Replace("\"", "\\\"") + "\"";
if (CanStayPlain(s)) return s;
// Double-quoted with escapes: a physical line break inside quotes would fold into a space.
var sb = new StringBuilder(s.Length + 2).Append('"');
foreach (var c in s)
{
switch (c)
{
case '\\': sb.Append("\\\\"); break;
case '"': sb.Append("\\\""); break;
case '\n': sb.Append("\\n"); break;
case '\r': sb.Append("\\r"); break;
case '\t': sb.Append("\\t"); break;
default:
if (NeedsEscape(c))
sb.Append("\\u").Append(((int)c).ToString("X4", CultureInfo.InvariantCulture));
else
sb.Append(c);
break;
}
}
return sb.Append('"').ToString();
}

/// <summary>
/// A string stays unquoted only if YAML cannot read it as anything else: it starts with a
/// letter (so no number, date, <c>~</c>, <c>-</c> or indicator), has no indicator, control
/// character or trailing space, and is not a null or boolean word.
/// </summary>
private static bool CanStayPlain(string s) =>
s.Length > 0 && char.IsLetter(s[0]) && !char.IsWhiteSpace(s[^1])
&& s.IndexOfAny(IndicatorChars) < 0 && !s.Any(NeedsEscape)
&& !NonStringWords.Contains(s);

// Control characters, the Unicode line separators YAML treats as breaks, and a BOM.
private static bool NeedsEscape(char c) => char.IsControl(c) || c is (char)0x2028 or (char)0x2029 or (char)0xFEFF;
}
45 changes: 40 additions & 5 deletions src/NodePilot.Engine/Activities/WmiQueryActivity.cs
Original file line number Diff line number Diff line change
Expand Up @@ -274,10 +274,23 @@ private static string BuildInvokeMethodScript(JsonElement config, string qNs)
var qClass = PowerShellQuoter.Literal(className);
var qMethod = PowerShellQuoter.Literal(methodName);
var filter = config.GetStringOrNull("filter");
var hasFilter = !string.IsNullOrWhiteSpace(filter);
var argsLiteral = BuildArgumentsHashtable(config);

var sb = new StringBuilder();
if (!string.IsNullOrWhiteSpace(filter))
// Invoke-CimMethod converts arguments to the declared CIM types only when it has the class
// declaration. With -ClassName it sends the PowerShell types, and a UInt32 parameter
// rejects the Int64 that PowerShell uses for values above Int32.MaxValue.
if (!hasFilter || argsLiteral is not null)
sb.Append("$__npClass = Get-CimClass -ClassName ").Append(qClass)
.Append(" -Namespace ").Append(qNs).Append('\n');
if (argsLiteral is not null)
{
sb.Append("$__npArgs = ").Append(argsLiteral).Append('\n');
AppendIntegerArgumentRangeCheck(sb, qMethod);
}

if (hasFilter)
{
// Instance method: locate target instances first, then pipe into Invoke-CimMethod.
// Without -Filter the cmdlet would target the class itself (static-method semantics),
Expand All @@ -291,17 +304,39 @@ private static string BuildInvokeMethodScript(JsonElement config, string qNs)
else
{
// Static method on the class.
sb.Append("Invoke-CimMethod -ClassName ").Append(qClass)
.Append(" -Namespace ").Append(qNs)
.Append(" -MethodName ").Append(qMethod);
sb.Append("Invoke-CimMethod -CimClass $__npClass -MethodName ").Append(qMethod);
}

if (argsLiteral is not null)
sb.Append(" -Arguments ").Append(argsLiteral);
sb.Append(" -Arguments $__npArgs");

return sb.ToString();
}

/// <summary>
/// Rejects an argument whose value does not fit the integer CIM type its parameter declares,
/// naming the argument and the allowed range. The CIM conversion itself only reports an
/// overflow without saying which argument caused it.
/// </summary>
private static void AppendIntegerArgumentRangeCheck(StringBuilder sb, string qMethod)
{
sb.Append("$__npMethod = $__npClass.CimClassMethods[").Append(qMethod).Append("]\n");
sb.Append("if ($null -ne $__npMethod) {\n");
sb.Append(" $__npIntTypes = @{ UInt8 = [byte]; SInt8 = [sbyte]; UInt16 = [uint16]; SInt16 = [int16]; UInt32 = [uint32]; SInt32 = [int32]; UInt64 = [uint64]; SInt64 = [int64] }\n");
sb.Append(" foreach ($__npName in @($__npArgs.Keys)) {\n");
sb.Append(" $__npParam = $__npMethod.Parameters[$__npName]\n");
sb.Append(" $__npValue = $__npArgs[$__npName]\n");
sb.Append(" if ($null -eq $__npParam -or $null -eq $__npValue) { continue }\n");
sb.Append(" $__npType = $__npIntTypes[[string]$__npParam.CimType]\n");
sb.Append(" if ($null -eq $__npType) { continue }\n");
sb.Append(" $__npNumber = [decimal]0\n");
sb.Append(" if (-not [decimal]::TryParse([string]$__npValue, [Globalization.NumberStyles]::Float, [Globalization.CultureInfo]::InvariantCulture, [ref]$__npNumber) -or $__npNumber -ne [decimal]::Truncate($__npNumber) -or $__npNumber -lt $__npType::MinValue -or $__npNumber -gt $__npType::MaxValue) {\n");
sb.Append(" throw \"WMI Query: argument '$__npName' value '$__npValue' does not fit the declared CIM type $($__npParam.CimType) (a whole number from $($__npType::MinValue) to $($__npType::MaxValue)).\"\n");
sb.Append(" }\n");
sb.Append(" }\n");
sb.Append("}\n");
}

/// <summary>
/// Renders <c>config.arguments</c> as a PowerShell hashtable literal (e.g.
/// <c>@{ Name = 'svc'; Count = 5; Force = $true }</c>) or returns <c>null</c> when there
Expand Down
5 changes: 3 additions & 2 deletions src/NodePilot.Mcp/Tools/SupportingDataTools.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,13 +45,14 @@ public async Task<object> GetMachine(
public async Task<object> CreateMachine(
[Description("Display name.")] string name,
[Description("Hostname or IP for WinRM.")] string hostname,
[Description("WinRM port (default 5985).")] int winRmPort = 5985,
[Description("WinRM port (default 5986 with SSL, otherwise 5985).")] int? winRmPort = null,
[Description("Use HTTPS/SSL WinRM (default false).")] bool useSsl = false,
[Description("Optional default credential GUID.")] string? defaultCredentialId = null,
[Description("Optional comma-separated tags.")] string? tags = null,
CancellationToken cancellationToken = default)
{
var req = new CreateMachineRequest(name, hostname, winRmPort, useSsl, OptGuid(defaultCredentialId, "defaultCredentialId"), tags);
var port = winRmPort ?? (useSsl ? 5986 : 5985);
var req = new CreateMachineRequest(name, hostname, port, useSsl, OptGuid(defaultCredentialId, "defaultCredentialId"), tags);
var m = await ApiErrorMapper.Guard(() => _api.CreateMachineAsync(req, cancellationToken));
return new { created = true, machineId = m.Id, name = m.Name };
}
Expand Down
2 changes: 1 addition & 1 deletion src/nodepilot-docs-ui/content/de/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,7 @@ np exec resume 7e3f... --step runHealth --mode stop

## machine

Maschinen-Body via Flags (nicht `--file`). `create`/`update`-Flags: `--name`, `--hostname`, `--port` (Default 5985), `--ssl`, `--credential <GUID>`, `--tags <CSV>`. `update` ist Client-seitig Patch (fetch + merge, Server will Full-Body).
Maschinen-Body via Flags (nicht `--file`). `create`/`update`-Flags: `--name`, `--hostname`, `--port` (Default bei `create`: 5985, mit `--ssl` 5986), `--ssl`, `--credential <GUID>`, `--tags <CSV>`. `update` ist Client-seitig Patch (fetch + merge, Server will Full-Body).

```bash
np machine create --name srv01 --hostname srv01.internal --credential 4c2a... --tags prod,web
Expand Down
Loading
Loading