Unify typed agent transport and shared state

This commit is contained in:
Meta Vens 2026-07-15 20:23:41 +01:00
commit c4376ee129
23 changed files with 1434 additions and 538 deletions

View file

@ -73,7 +73,7 @@ $action = New-ScheduledTaskAction -Execute "wscript.exe" -Argument "//B //nologo
$trigger = New-ScheduledTaskTrigger -Daily -At $runAt
$settings = New-ScheduledTaskSettingsSet -StartWhenAvailable -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -ExecutionTimeLimit (New-TimeSpan -Hours 6)
$principal = New-ScheduledTaskPrincipal -UserId "$env:USERDOMAIN\$env:USERNAME" -LogonType Interactive -RunLevel Limited
$description = "Runs VoidBot offsite backup sync to the Qwen box. Interactive logon required so the SSH key material is available."
$description = "Runs VoidBot offsite backup sync to the configured SSH target. Interactive logon required so the SSH key material is available."
Register-ScheduledTask -TaskName $taskNameValue -Action $action -Trigger $trigger -Settings $settings -Principal $principal -Description $description -Force | Out-Null

View file

@ -21,7 +21,11 @@ const IDUNN_HEALTH_RUDP_CONNECTION_ID = 0x1d0d0001;
async function main() {
const options = parseArgs(process.argv.slice(2));
const endpoint = parseEndpoint(options.endpoint || process.env.VOIDBOT_IDUNN_RUDP_HEALTH || "127.0.0.1:17870");
const endpointValue = options.endpoint || process.env.VOIDBOT_IDUNN_RUDP_HEALTH;
if (!endpointValue || !String(endpointValue).trim()) {
throw new Error("VoidBot Idunn health publication requires --endpoint or VOIDBOT_IDUNN_RUDP_HEALTH; no localhost default is assumed.");
}
const endpoint = parseEndpoint(endpointValue);
const daemonId = options.daemon || process.env.VOIDBOT_IDUNN_DAEMON || "voidbot";
const healthContract = options.contract || process.env.VOIDBOT_IDUNN_HEALTH_CONTRACT || "voidbot.cultnet-rudp-stack-health";
const state = options.state || "healthy";

View file

@ -15,6 +15,17 @@ $lockPath = Join-Path $statusDir "gamecult-orchestrator.lock"
$agentSwarmPausePath = Join-Path $repoRoot "state\agent-swarm-paused.json"
$hiddenLauncher = Join-Path $PSScriptRoot "run-hidden-powershell.vbs"
$bifrostRoot = "E:\Projects\Bifrost"
$bootstrapLogPath = Join-Path $logDir "bootstrap.log"
function Write-BootstrapLog {
param([string] $Message)
try {
New-Item -ItemType Directory -Force -Path $logDir | Out-Null
$line = "[{0}] pid={1} cwd={2} {3}" -f ([DateTime]::UtcNow.ToString("o")), $PID, (Get-Location).Path, $Message
Add-Content -LiteralPath $bootstrapLogPath -Encoding UTF8 -Value $line
} catch {
}
}
function Read-DotEnv {
param([Parameter(Mandatory = $true)][string] $Path)
@ -114,10 +125,15 @@ function Publish-IdunnRudpHealth {
return
}
$endpoint = if ($env:VOIDBOT_IDUNN_RUDP_HEALTH) { $env:VOIDBOT_IDUNN_RUDP_HEALTH } else { "127.0.0.1:17870" }
$endpoint = $env:VOIDBOT_IDUNN_RUDP_HEALTH
if ([string]::IsNullOrWhiteSpace($endpoint)) {
Write-BootstrapLog "skipping-idunn-health VOIDBOT_IDUNN_RUDP_HEALTH is not configured"
return
}
$state = if ($Summary.ok) { "healthy" } else { "failed" }
$detail = "VoidBot orchestrator pulse ran $($Summary.ranCount) organ(s) in $($Summary.durationSeconds)s; ok=$($Summary.ok)"
try {
Write-BootstrapLog "publishing-idunn-health state=$state ran=$($Summary.ranCount) duration=$($Summary.durationSeconds)"
$nodeExe = (Get-Command node.exe -ErrorAction Stop).Source
$output = & $nodeExe $publisher `
--endpoint $endpoint `
@ -129,9 +145,106 @@ function Publish-IdunnRudpHealth {
if ($LASTEXITCODE -ne 0) {
throw "publisher exited with code $LASTEXITCODE`: $($output -join [Environment]::NewLine)"
}
Write-BootstrapLog "published-idunn-health"
} catch {
$message = "[$([DateTime]::UtcNow.ToString("o"))] Idunn RUDP health publish failed: $($_.Exception.Message)"
Add-Content -LiteralPath (Join-Path $logDir "idunn-rudp-health.log") -Encoding UTF8 -Value $message
Write-BootstrapLog "publish-idunn-health-failed $($_.Exception.Message)"
}
}
function Get-ConfigString {
param(
[hashtable] $Config,
[string] $Name,
[string] $Default
)
$raw = [Environment]::GetEnvironmentVariable($Name)
if ([string]::IsNullOrWhiteSpace($raw) -and $Config.ContainsKey($Name)) {
$raw = $Config[$Name]
}
if ([string]::IsNullOrWhiteSpace($raw)) {
return $Default
}
return $raw.Trim()
}
function Find-VoidBotSwarmCultMeshProcess {
param([string] $ScriptPath)
$escaped = $ScriptPath.Replace("\", "\\")
return @(Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | Where-Object {
$_.Name -eq "node.exe" -and
$_.CommandLine -and
($_.CommandLine -like "*serve-voidbot-swarm-cultmesh.cjs*" -or $_.CommandLine -like "*$escaped*")
})
}
function Ensure-VoidBotSwarmCultMeshServer {
param(
[hashtable] $Config,
[string] $NodePath
)
$scriptPath = Join-Path $PSScriptRoot "serve-voidbot-swarm-cultmesh.cjs"
if (-not (Test-Path -LiteralPath $scriptPath)) {
throw "VoidBot swarm CultMesh server script is missing at $scriptPath."
}
$storePath = Get-ConfigString -Config $Config -Name "VOIDBOT_SWARM_CULTMESH_STORE" -Default (Join-Path $statusDir "cultmesh\voidbot-swarm-state.cc")
$storePath = [System.IO.Path]::GetFullPath($storePath)
if (-not (Test-Path -LiteralPath $storePath)) {
throw "VoidBot swarm CultMesh store is missing at $storePath."
}
$bind = Get-ConfigString -Config $Config -Name "VOIDBOT_SWARM_CULTMESH_BIND" -Default "127.0.0.1:17873"
$odinCultMeshUri = Get-ConfigString -Config $Config -Name "VOIDBOT_ODIN_CULTMESH_URI" -Default (
Get-ConfigString -Config $Config -Name "ODIN_CULTMESH_URI" -Default "cultmesh://odin/rendezvous/provider-catalog"
)
$odinRudpEndpoint = Get-ConfigString -Config $Config -Name "VOIDBOT_ODIN_RUDP" -Default (
Get-ConfigString -Config $Config -Name "CULTMESH_URI_ODIN_RUDP" -Default "rudp://127.0.0.1:17871"
)
$existing = @(Find-VoidBotSwarmCultMeshProcess -ScriptPath $scriptPath)
if ($existing.Count -gt 0) {
return [pscustomobject]@{
status = "already_running"
pid = [int]$existing[0].ProcessId
bind = $bind
storePath = $storePath
odinCultMeshUri = $odinCultMeshUri
odinRudpEndpoint = $odinRudpEndpoint
}
}
$stdoutPath = Join-Path $logDir "voidbot-swarm-cultmesh.stdout.log"
$stderrPath = Join-Path $logDir "voidbot-swarm-cultmesh.stderr.log"
New-Item -ItemType Directory -Force -Path $logDir | Out-Null
$arguments = @(
$scriptPath,
"--store", $storePath,
"--bind", $bind,
"--odin-cultmesh-uri", $odinCultMeshUri,
"--odin-rudp-endpoint", $odinRudpEndpoint
)
$process = Start-Process -FilePath $NodePath -ArgumentList $arguments -WorkingDirectory $repoRoot -RedirectStandardOutput $stdoutPath -RedirectStandardError $stderrPath -WindowStyle Hidden -PassThru
Start-Sleep -Seconds 2
$process.Refresh()
if ($process.HasExited) {
$stdoutTail = if (Test-Path -LiteralPath $stdoutPath) { (Get-Content -LiteralPath $stdoutPath -Tail 40) -join "`n" } else { "" }
$stderrTail = if (Test-Path -LiteralPath $stderrPath) { (Get-Content -LiteralPath $stderrPath -Tail 40) -join "`n" } else { "" }
throw "VoidBot swarm CultMesh server exited early.`nSTDOUT:`n$stdoutTail`nSTDERR:`n$stderrTail"
}
return [pscustomobject]@{
status = "started"
pid = $process.Id
bind = $bind
storePath = $storePath
odinCultMeshUri = $odinCultMeshUri
odinRudpEndpoint = $odinRudpEndpoint
stdoutPath = $stdoutPath
stderrPath = $stderrPath
}
}
@ -184,6 +297,7 @@ function Invoke-Organ {
New-Item -ItemType Directory -Force -Path $logDir | Out-Null
$startedAt = [DateTime]::UtcNow
Write-BootstrapLog "invoke-organ start id=$($Organ.Id) executable=$($Organ.Executable)"
$exitCode = 0
$timedOut = $false
try {
@ -200,6 +314,8 @@ function Invoke-Organ {
$process = [System.Diagnostics.Process]::new()
$process.StartInfo = $startInfo
[void]$process.Start()
$stdoutTask = $process.StandardOutput.ReadToEndAsync()
$stderrTask = $process.StandardError.ReadToEndAsync()
$timeoutSeconds = [Math]::Max(60, $Organ.TimeoutMinutes * 60)
if (-not $process.WaitForExit($timeoutSeconds * 1000)) {
@ -207,8 +323,8 @@ function Invoke-Organ {
cmd /c "taskkill /PID $($process.Id) /T /F" | Out-Null
$process.WaitForExit()
}
$stdout = $process.StandardOutput.ReadToEnd()
$stderr = $process.StandardError.ReadToEnd()
$stdout = $stdoutTask.GetAwaiter().GetResult()
$stderr = $stderrTask.GetAwaiter().GetResult()
[System.IO.File]::WriteAllText($stdoutPath, $stdout, [System.Text.UTF8Encoding]::new($false))
[System.IO.File]::WriteAllText($stderrPath, $stderr, [System.Text.UTF8Encoding]::new($false))
$exitCode = if ($timedOut) { 124 } elseif ($null -eq $process.ExitCode) { 0 } else { $process.ExitCode }
@ -242,6 +358,7 @@ function Invoke-Organ {
$stderr.Trim()
) -join [Environment]::NewLine
[System.IO.File]::WriteAllText($logPath, $combined.Trim(), [System.Text.UTF8Encoding]::new($false))
Write-BootstrapLog "invoke-organ finish id=$($Organ.Id) status=$(if ($timedOut) { 'timed_out' } elseif ($exitCode -eq 0) { 'ok' } else { 'failed' }) exit=$exitCode"
return [pscustomobject]@{
id = $Organ.Id
@ -291,6 +408,31 @@ function Test-OrchestratorLock {
return $true
}
function Acquire-OrchestratorLock {
$directory = Split-Path -Parent $lockPath
New-Item -ItemType Directory -Force -Path $directory | Out-Null
for ($attempt = 0; $attempt -lt 2; $attempt++) {
try {
$stream = [System.IO.File]::Open($lockPath, [System.IO.FileMode]::CreateNew, [System.IO.FileAccess]::ReadWrite, [System.IO.FileShare]::Read)
$payload = @{
pid = $PID
startedAt = ([DateTime]::UtcNow).ToString("o")
} | ConvertTo-Json -Depth 4
$bytes = [System.Text.UTF8Encoding]::new($false).GetBytes($payload)
$stream.Write($bytes, 0, $bytes.Length)
$stream.Flush()
return $stream
} catch [System.IO.IOException] {
if (Test-OrchestratorLock) {
return $null
}
}
}
return $null
}
function Test-AgentSwarmPaused {
if (-not (Test-Path -LiteralPath $agentSwarmPausePath)) {
return $false
@ -310,12 +452,15 @@ function Test-AgentSwarmPaused {
}
New-Item -ItemType Directory -Force -Path $statusDir, $logDir | Out-Null
if (Test-OrchestratorLock) {
Write-BootstrapLog "script-start only=$($Only -join ',') force=$Force"
$lockHandle = Acquire-OrchestratorLock
if ($null -eq $lockHandle) {
Write-BootstrapLog "lock-already-active"
exit 0
}
$startedAt = [DateTime]::UtcNow
Write-JsonFile -Path $lockPath -Data @{ pid = $PID; startedAt = $startedAt.ToString("o") }
Write-BootstrapLog "lock-created path=$lockPath"
try {
$config = Read-DotEnv -Path (Join-Path $repoRoot ".env")
@ -449,8 +594,9 @@ try {
}
$result = Invoke-Organ -Organ $organ -Now $now
$serverState = $null
$runs += $result
Set-OrganState -State $state -Id $organ.Id -Value ([pscustomobject]@{
$organState = [pscustomobject]@{
label = $organ.Label
intervalMinutes = $organ.IntervalMinutes
lastStartedAt = $result.startedAt
@ -458,7 +604,11 @@ try {
lastExitCode = $result.exitCode
lastStatus = $result.status
lastLogPath = $result.logPath
})
}
if ($null -ne $serverState) {
$organState | Add-Member -NotePropertyName cultMeshServer -NotePropertyValue $serverState -Force
}
Set-OrganState -State $state -Id $organ.Id -Value $organState
Write-JsonFile -Path $statePath -Data $state
}
@ -475,11 +625,18 @@ try {
}
Write-JsonFile -Path (Join-Path $statusDir "gamecult-orchestrator-last-run.json") -Data $summary
Write-JsonFile -Path $statePath -Data $state
Write-BootstrapLog "summary-written ok=$($summary.ok) ran=$($summary.ranCount)"
Publish-IdunnRudpHealth -Summary $summary
if (-not $summary.ok) {
Write-BootstrapLog "script-exit-failed"
exit 1
}
Write-BootstrapLog "script-exit-ok"
} finally {
if ($null -ne $lockHandle) {
$lockHandle.Dispose()
}
Remove-Item -LiteralPath $lockPath -Force -ErrorAction SilentlyContinue
Write-BootstrapLog "lock-removed"
}

View file

@ -0,0 +1,75 @@
#!/usr/bin/env node
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { existsSync } from "node:fs";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
const workspaceRoot = resolve(process.cwd());
const serverPath = resolve(workspaceRoot, "apps", "worker", "dist", "mcp-server.js");
const storageRoot = await mkdtemp(join(tmpdir(), "voidbot-shared-documents-mcp-"));
if (!existsSync(serverPath)) {
throw new Error(`MCP server entrypoint is missing at ${serverPath}. Run the build first.`);
}
const expectedTools = ["read_shared_document", "create_shared_document", "update_shared_document"];
const transport = new StdioClientTransport({
command: process.execPath,
args: [serverPath],
cwd: workspaceRoot,
env: {
...process.env,
STORAGE_ROOT: storageRoot,
REPO_DISCORD_IDENTITIES_PATH: resolve(workspaceRoot, ".voidbot", "private", "repo-discord-identities.json"),
VOIDBOT_REPO_FACE_IDENTITY: "nibu",
VOIDBOT_MCP_TOOL_ALLOWLIST: expectedTools.join(","),
},
stderr: "pipe",
});
const client = new Client({ name: "voidbot-shared-documents-smoke", version: "0.0.0" });
try {
await client.connect(transport);
const tools = await client.listTools();
const names = tools.tools.map((tool) => tool.name).sort();
if (JSON.stringify(names) !== JSON.stringify([...expectedTools].sort())) {
throw new Error(`Unexpected shared-document tool surface: ${names.join(", ")}`);
}
const created = await client.callTool({
name: "create_shared_document",
arguments: { documentId: "shared-scratch", title: "Shared scratch", body: "First mark" },
});
assertOk(created, "create_shared_document");
if (created.structuredContent?.document?.createdBy !== "nibu") {
throw new Error("MCP server did not stamp the active Face identity on create.");
}
const read = await client.callTool({ name: "read_shared_document", arguments: { documentId: "shared-scratch" } });
assertOk(read, "read_shared_document");
if (read.structuredContent?.document?.version !== 1) {
throw new Error("MCP read did not return the created revision.");
}
const updated = await client.callTool({
name: "update_shared_document",
arguments: { documentId: "shared-scratch", body: "Second mark", expectedVersion: 1 },
});
assertOk(updated, "update_shared_document");
if (updated.structuredContent?.document?.version !== 2) {
throw new Error("MCP update did not advance the revision.");
}
process.stdout.write(`${JSON.stringify({ ok: true, tools: names, document: updated.structuredContent?.document })}\n`);
} finally {
await client.close();
await rm(storageRoot, { recursive: true, force: true });
}
function assertOk(result, tool) {
if (result.isError) {
const text = (result.content ?? []).map((entry) => entry.type === "text" ? entry.text : "").join("\n");
throw new Error(`${tool} failed: ${text}`);
}
}

View file

@ -0,0 +1,66 @@
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
createRepoFaceSharedDocument,
listRepoFaceSharedDocuments,
updateRepoFaceSharedDocument,
} from "@voidbot/core";
async function main(): Promise<void> {
const directory = await mkdtemp(join(tmpdir(), "voidbot-shared-documents-"));
const path = join(directory, "shared.cc");
try {
const created = await createRepoFaceSharedDocument({
canonicalPath: path,
documentId: "coordination-note",
title: "Coordination note",
body: "Initial state",
actorId: "face-a",
now: "2026-07-12T00:00:00.000Z",
});
if (created.version !== 1 || created.createdBy !== "face-a") {
throw new Error("Create lost initial revision provenance.");
}
const updated = await updateRepoFaceSharedDocument({
canonicalPath: path,
documentId: "coordination-note",
body: "Updated state",
expectedVersion: 1,
actorId: "face-b",
now: "2026-07-12T00:05:00.000Z",
});
if (updated.version !== 2 || updated.updatedBy !== "face-b") {
throw new Error("Update lost revision provenance.");
}
let rejected = false;
try {
await updateRepoFaceSharedDocument({
canonicalPath: path,
documentId: "coordination-note",
body: "Stale overwrite",
expectedVersion: 1,
actorId: "face-c",
});
} catch {
rejected = true;
}
if (!rejected) {
throw new Error("A stale update overwrote shared state.");
}
const documents = await listRepoFaceSharedDocuments(path);
if (documents.length !== 1 || documents[0]?.body !== "Updated state") {
throw new Error("Canonical shared state was not preserved.");
}
console.log(JSON.stringify({ ok: true, document: documents[0] }));
} finally {
await rm(directory, { recursive: true, force: true });
}
}
void main();

View file

@ -253,49 +253,64 @@ function Ensure-DockerOnPath {
} -FailureMessage "Docker is installed but the daemon is not ready." -Attempts 45 -SleepSeconds 2
}
function Ensure-BifrostLocalStack {
function Ensure-BifrostCultMeshCommandStore {
param(
[Parameter(Mandatory = $true)]
[string] $BifrostRoot,
[Parameter(Mandatory = $true)]
[string] $HealthUrl
[string] $StorePath,
[Parameter(Mandatory = $true)]
[string] $CommandUri
)
if (Test-HttpEndpoint -Url $HealthUrl) {
return @{
root = $BifrostRoot
healthUrl = $HealthUrl
startedByScript = $false
healthy = $true
}
if ($CommandUri -notmatch '^cultmesh://[^/]+/commands/discord-post(?:$|[/?#])') {
throw "BIFROST_CULTMESH_COMMAND_URI must be a CultMesh Discord command URI, got '$CommandUri'."
}
if (-not (Test-Path $BifrostRoot)) {
throw "Bifrost root does not exist at $BifrostRoot."
}
$composeFile = Join-Path $BifrostRoot "compose.local.yaml"
if (-not (Test-Path $composeFile)) {
throw "Bifrost local compose file does not exist at $composeFile."
$processor = Join-Path $BifrostRoot "tools\cultmesh-bridge-commands.mjs"
if (-not (Test-Path $processor)) {
throw "Bifrost CultMesh command processor does not exist at $processor."
}
Ensure-DockerOnPath
& docker compose -f $composeFile --project-directory $BifrostRoot up -d
if ($LASTEXITCODE -ne 0) {
throw "Docker Compose failed to start Bifrost."
$storeParent = Split-Path -Parent $StorePath
if (-not [string]::IsNullOrWhiteSpace($storeParent)) {
New-Item -ItemType Directory -Force -Path $storeParent | Out-Null
}
Wait-Until -Condition { Test-HttpEndpoint -Url $HealthUrl } -FailureMessage "Bifrost did not become reachable at $HealthUrl in time." -Attempts 45 -SleepSeconds 2
$exportedByScript = $false
if (-not (Test-Path $StorePath)) {
$exporter = Join-Path $BifrostRoot "tools\provider-advertisement.mjs"
if (-not (Test-Path $exporter)) {
throw "Bifrost provider advertisement exporter does not exist at $exporter."
}
$nodeCommand = Get-Command node.exe -ErrorAction Stop
& $nodeCommand.Source $exporter export --out $StorePath
if ($LASTEXITCODE -ne 0) {
throw "Bifrost provider CultMesh store export failed."
}
$exportedByScript = $true
}
$storeItem = Get-Item -LiteralPath $StorePath -ErrorAction Stop
if ($storeItem.Length -le 0) {
throw "Bifrost provider CultMesh store is empty at $StorePath."
}
return @{
root = $BifrostRoot
composeFile = $composeFile
healthUrl = $HealthUrl
startedByScript = $true
commandUri = $CommandUri
storePath = $StorePath
processor = $processor
exportedByScript = $exportedByScript
healthy = $true
healthAuthority = "cultmesh-command-store"
}
}
@ -561,7 +576,8 @@ $localLlmBaseUrl = if ($config.ContainsKey("LOCAL_LLM_OLLAMA_BASE_URL")) { $conf
$localLlmModel = if ($config.ContainsKey("LOCAL_LLM_OLLAMA_MODEL")) { $config["LOCAL_LLM_OLLAMA_MODEL"] } else { "qwen3.5:9b" }
$localLlmSocialReadModel = if ($config.ContainsKey("LOCAL_LLM_SOCIAL_READ_OLLAMA_MODEL") -and -not [string]::IsNullOrWhiteSpace($config["LOCAL_LLM_SOCIAL_READ_OLLAMA_MODEL"])) { $config["LOCAL_LLM_SOCIAL_READ_OLLAMA_MODEL"] } else { $localLlmModel }
$bifrostRoot = if ($config.ContainsKey("BIFROST_ROOT")) { [System.IO.Path]::GetFullPath($config["BIFROST_ROOT"]) } else { "E:\Projects\Bifrost" }
$bifrostHealthUrl = if ($config.ContainsKey("BIFROST_HEALTH_URL")) { $config["BIFROST_HEALTH_URL"] } else { "http://127.0.0.1:5080/healthz" }
$bifrostCultMeshCommandUri = if ($config.ContainsKey("BIFROST_CULTMESH_COMMAND_URI")) { $config["BIFROST_CULTMESH_COMMAND_URI"] } else { "cultmesh://asgard.starfire.bifrost/commands/discord-post" }
$bifrostCultMeshStorePath = if ($config.ContainsKey("BIFROST_CULTMESH_STORE_PATH")) { [System.IO.Path]::GetFullPath($config["BIFROST_CULTMESH_STORE_PATH"]) } else { "E:\Projects\Bifrost\.bifrost\provider-store.cc" }
if ($stateStorageBackend -eq "postgres") {
$status.stage = "postgres"
@ -637,7 +653,7 @@ if ($localLlmEnabled) {
$status.stage = "bifrost"
Write-StatusFile -Path $statusPath -Status $status
$status.bifrost = Ensure-BifrostLocalStack -BifrostRoot $bifrostRoot -HealthUrl $bifrostHealthUrl
$status.bifrost = Ensure-BifrostCultMeshCommandStore -BifrostRoot $bifrostRoot -StorePath $bifrostCultMeshStorePath -CommandUri $bifrostCultMeshCommandUri
Write-StatusFile -Path $statusPath -Status $status
$npmCommand = Get-Command npm.cmd -ErrorAction Stop